SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%

feat(excel): comparables grid computes adjustments by formula from subject characteristics and unit rates; fix bulk e-mail parsing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent f261ace

5 changed files +301 −152

modified backend/app/api/v1/professor.py +2 −2
@@ -180,7 +180,7 @@ class CreateStudentsReq(BaseModel):
180 180 @router.post("/students", status_code=201)
181 181 async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_professor),
182 182 settings: Settings = Depends(get_settings)) -> dict:
183 − raw = req.emails if isinstance(req.emails, list) else re.split(r"[\s,;]+", req.emails)
183 + raw = req.emails if isinstance(req.emails, list) else re.split(r"[\n;,]+", req.emails)
184 184 names: dict[str, str] = {}
185 185 emails: list[str] = []
186 186 for item in raw:
@@ -192,7 +192,7 @@ async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_
192 192 names[m.group(2).strip().lower()] = m.group(1).strip().strip('"')
193 193 emails.append(m.group(2))
194 194 else:
195 − emails.append(item)
195 + emails.extend(t for t in re.split(r"\s+", item) if t)
196 196 if req.role not in {"student", "professor"}:
197 197 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")
198 198 return await users.create_users(emails[:500], settings, req.role, names)
modified backend/app/tools/excel_templates/comparables_ajustes.py +254 −148
@@ -1,164 +1,270 @@
1 −"""Template: adjusted comparables grid (sales comparison) — flexible columns.
2 −
3 −Accepted `params`:
4 − sujet: str
5 − ajustements: [str] # adjustment names (optional; inferred from comparables)
6 − comparables: [ { adresse|nom, prix, date|date_vente, temps|ajustement_temps_pct,
7 − ajustements: {name: montant $ | "+3 %"}, # or adjustment keys at top level
8 − caracteristiques: {name: texte}, # e.g. garage: "Oui"
9 − ... } ]
10 −Text values in adjustment columns ("Oui", "meilleur") are moved to the characteristics table.
1 +"""Template: adjusted comparables grid (sales comparison) with live formulas.
2 +
3 +Two ways to feed it (both tolerant to strings like "425 000 $", "+3 %", "Oui", "bon") :
4 +
5 +A) Characteristics + unit rates (preferred — adjustments are computed by Excel formulas):
6 + sujet: {adresse, superficie: 1200, terrain: 6000, garage: "Oui", etat: "bon", ...}
7 + taux: {superficie: 120, terrain: 8, garage: 15000, etat: 10000} # $ per unit / per step
8 + echelles: {etat: ["mauvais", "moyen", "bon", "très bon"]} # ordinal scales (optional)
9 + taux_temps_mensuel: 0.005 # market trend (optional)
10 + comparables: [{adresse, prix, date, mois: 3, superficie: 1150, terrain: 5500, garage: "Oui",
11 + etat: "bon"}, ...]
12 + → ajustement = (sujet − comparable) × taux, in the workbook, cell by cell.
13 +
14 +B) Direct dollar adjustments (no `taux`): comparables: [{adresse, prix, temps: "+2 %",
15 + ajustements: {Superficie: -5000, Garage: -12000}, caracteristiques: {Garage: "Oui"}}]
11 16 """
12 17
13 18 from __future__ import annotations
14 19
20 +import unicodedata
15 21 from typing import Any
16 22
17 −from openpyxl.utils import get_column_letter
18 −
19 −from app.tools.coerce import cell, num, pct
20 −
21 −META_KEYS = {"adresse", "nom", "comparable", "prix", "prix_vente", "date", "date_vente",
22 − "temps", "ajustement_temps_pct", "date_pct", "ajust_temps", "ajustements",
23 − "caracteristiques", "notes", "note", "source"}
24 −
25 −DEFAULT_COMPS = [
26 − {"adresse": "Comparable 1", "prix": 415000, "temps": "2 %",
27 − "ajustements": {"Superficie": -5000, "Garage": -12000},
28 − "caracteristiques": {"Garage": "Oui", "État": "Bon"}},
29 − {"adresse": "Comparable 2", "prix": 439000, "temps": "1 %",
30 − "ajustements": {"Superficie": 8000, "Terrain": -6000, "État": -10000},
31 − "caracteristiques": {"Garage": "Non", "État": "Très bon"}},
32 − {"adresse": "Comparable 3", "prix": 402000, "temps": "3 %",
33 − "ajustements": {"Terrain": 4000, "État": 15000},
34 − "caracteristiques": {"Garage": "Non", "État": "Moyen"}},
35 −]
36 −
37 −
38 −def _normalise(comps: list[Any]) -> tuple[list[dict[str, Any]], list[str], list[str]]:
39 − out: list[dict[str, Any]] = []
40 − adj_names: list[str] = []
41 − char_names: list[str] = []
42 −
43 − def add(names: list[str], k: str) -> None:
44 − if k not in names:
45 − names.append(k)
46 −
47 − for i, raw in enumerate(comps):
48 − c = dict(raw) if isinstance(raw, dict) else {"adresse": str(raw)}
49 − item: dict[str, Any] = {
50 − "nom": str(c.get("adresse") or c.get("nom") or c.get("comparable") or f"Comparable {i + 1}"),
51 − "prix": num(c.get("prix", c.get("prix_vente")), 0.0) or 0.0,
52 − "date": str(c.get("date") or c.get("date_vente") or ""),
53 − "temps": pct(c.get("temps", c.get("ajustement_temps_pct", c.get("date_pct",
54 − c.get("ajust_temps")))), 0.0) or 0.0,
55 − "adj": {}, "chars": {}, "note": str(c.get("notes") or c.get("note") or ""),
56 − }
57 − candidates: dict[str, Any] = {}
58 − if isinstance(c.get("ajustements"), dict):
59 − candidates.update(c["ajustements"])
23 +from openpyxl.utils import get_column_letter as L
24 +
25 +from app.tools.coerce import boolish, cell, num, pct
26 +
27 +META = {"adresse", "nom", "comparable", "prix", "prix_vente", "date", "date_vente", "temps",
28 + "ajustement_temps_pct", "date_pct", "ajust_temps", "mois", "ajustements",
29 + "caracteristiques", "notes", "note", "source", "filename"}
30 +QUALITY_WORDS = ["très mauvais", "mauvais", "passable", "moyen", "bon", "très bon", "excellent", "neuf"]
31 +
32 +DEFAULT = {
33 + "sujet": {"adresse": "Sujet — bungalow fictif, Gatineau", "superficie": 1200, "terrain": 6000,
34 + "garage": "Oui", "etat": "bon"},
35 + "taux": {"superficie": 120, "terrain": 8, "garage": 15000, "etat": 10000},
36 + "taux_temps_mensuel": 0.005,
37 + "comparables": [
38 + {"adresse": "45 rue Laurier", "prix": 425000, "date": "2026-03", "mois": 6, "superficie": 1150,
39 + "terrain": 5500, "garage": "Oui", "etat": "bon"},
40 + {"adresse": "12 rue Front", "prix": 398000, "date": "2026-01", "mois": 8, "superficie": 1250,
41 + "terrain": 7000, "garage": "Non", "etat": "moyen"},
42 + {"adresse": "88 boul. Saint-Joseph", "prix": 449000, "date": "2026-04", "mois": 5,
43 + "superficie": 1300, "terrain": 6200, "garage": "Oui", "etat": "très bon"},
44 + ],
45 +}
46 +
47 +
48 +def _norm(s: str) -> str:
49 + s = unicodedata.normalize("NFD", str(s)).encode("ascii", "ignore").decode().lower().strip()
50 + return s.replace(" ", "_").replace("-", "_")
51 +
52 +
53 +def _label(key: str) -> str:
54 + return key.replace("_", " ").strip().capitalize()
55 +
56 +
57 +def _rank(value: Any, scale: list[str] | None) -> float | None:
58 + """Ordinal text → rank (0-based). Booleans → 1/0. Numbers pass through."""
59 + n = num(value, None)
60 + if n is not None and not isinstance(value, bool):
61 + return n
62 + b = boolish(value)
63 + if b is not None and not (isinstance(value, str) and scale and _norm(value) in [_norm(x) for x in scale]):
64 + return 1.0 if b else 0.0
65 + if isinstance(value, str):
66 + words = [_norm(x) for x in (scale or QUALITY_WORDS)]
67 + v = _norm(value)
68 + if v in words:
69 + return float(words.index(v))
70 + for i, w in enumerate(words): # "bon état" → "bon"
71 + if w and w in v:
72 + return float(i)
73 + return None
74 +
75 +
76 +def _time_pct(c: dict[str, Any]) -> float | None:
77 + for k in ("temps", "ajustement_temps_pct", "date_pct", "ajust_temps"):
78 + if k in c:
79 + return pct(c[k], None)
80 + return None
81 +
82 +
83 +def build(p: dict[str, Any]) -> dict[str, Any]:
84 + if not p.get("comparables"):
85 + p = {**DEFAULT, **{k: v for k, v in p.items() if k != "comparables"}} if p.get("taux") is None else {**DEFAULT, **p}
86 + comps_raw = [c if isinstance(c, dict) else {"adresse": str(c)} for c in (p.get("comparables") or [])]
87 + taux_raw = {_norm(k): v for k, v in (p.get("taux") or p.get("unit_rates") or p.get("taux_unitaires") or {}).items()}
88 + scales = {_norm(k): list(v) for k, v in (p.get("echelles") or p.get("scales") or {}).items() if isinstance(v, list)}
89 + sujet_raw = p.get("sujet") or {}
90 + sujet: dict[str, Any] = {_norm(k): v for k, v in sujet_raw.items()} if isinstance(sujet_raw, dict) else {}
91 + sujet_label = (sujet_raw.get("adresse") or sujet_raw.get("nom") if isinstance(sujet_raw, dict)
92 + else str(sujet_raw or "Sujet — immeuble fictif, Gatineau")) or "Sujet"
93 + monthly = pct(p.get("taux_temps_mensuel", p.get("taux_temps_pct_mensuel")), None)
94 + mode_rates = bool(taux_raw)
95 +
96 + # ---- characteristics (mode A) or adjustments (mode B)
97 + keys: list[str] = []
98 + for c in comps_raw:
60 99 for k, v in c.items():
61 − if k not in META_KEYS and not isinstance(v, (dict, list)):
62 − candidates[k] = v
63 − for k, v in candidates.items():
64 − name = str(k).replace("_", " ").strip().capitalize()
65 − n = num(v, None)
66 − if n is None or (isinstance(v, str) and v.strip().lower() in {"oui", "non"}):
67 − if v not in (None, ""):
68 − item["chars"][name] = str(v)
69 − add(char_names, name)
100 + nk = _norm(k)
101 + if nk in META or isinstance(v, (dict, list)):
70 102 continue
71 − if isinstance(v, str) and v.strip().endswith("%"):
72 − n = n * item["prix"] # percent of price → dollars
73 − item["adj"][name] = n
74 − add(adj_names, name)
75 − if isinstance(c.get("caracteristiques"), dict):
76 − for k, v in c["caracteristiques"].items():
77 − name = str(k).replace("_", " ").strip().capitalize()
78 − item["chars"][name] = str(v)
79 − add(char_names, name)
80 − out.append(item)
81 − return out, adj_names, char_names
103 + if nk not in keys:
104 + keys.append(nk)
105 + for k in (c.get("ajustements") or {}) if isinstance(c.get("ajustements"), dict) else {}:
106 + if _norm(k) not in keys:
107 + keys.append(_norm(k))
108 + for k in taux_raw:
109 + if k not in keys and k in sujet:
110 + keys.append(k)
111 + if mode_rates:
112 + keys = [k for k in keys if k in taux_raw] # only characteristics with a rate are adjusted
113 + text_keys: list[str] = [] # qualitative columns kept as text (mode B or no rank)
82 114
115 + n = len(comps_raw)
116 + sheet: dict[str, Any] = {"name": "Comparables", "title": f"Grille de comparables ajustés — {sujet_label}",
117 + "inputs": [], "tables": [], "charts": [], "notes": []}
118 + row = 4
119 + inputs_map: dict[str, str] = {} # key → cell of subject value / rate
120 +
121 + if mode_rates:
122 + sheet["inputs_title"] = "Hypothèses (cellules bleues modifiables)"
123 + # subject values in column B, rates in column D (labels in A and C)
124 + r = row
125 + for k in keys:
126 + sv = _rank(sujet.get(k), scales.get(k))
127 + if sv is None:
128 + sv = 0.0
129 + sheet["inputs"].append({"cell": f"B{r}", "label": f"Sujet — {_label(k)}"
130 + + (f" ({'/'.join(scales[k])})" if k in scales else ""),
131 + "value": sv, "format": "number"})
132 + sheet["inputs"].append({"cell": f"D{r}", "label": f"Taux — {_label(k)} ($/unité ou $/cran)",
133 + "value": num(taux_raw.get(k), 0.0) or 0.0, "format": "currency"})
134 + inputs_map[k] = f"$B${r}"
135 + inputs_map[k + "__taux"] = f"$D${r}"
136 + r += 1
137 + if monthly is not None:
138 + sheet["inputs"].append({"cell": f"D{r}", "label": "Tendance du marché (%/mois)", "value": monthly,
139 + "format": "percent"})
140 + inputs_map["__monthly"] = f"$D${r}"
141 + r += 1
142 + row = r + 2
143 +
144 + # ---- main grid
145 + header_row = row
146 + first = header_row + 1
147 + last = first + n - 1
148 + columns: list[dict[str, Any]] = [{"header": "Comparable", "type": "text"}, {"header": "Prix de vente ($)", "type": "currency"},
149 + {"header": "Date de vente", "type": "text"}]
150 + col = 4 # D
151 + if mode_rates and monthly is not None:
152 + columns += [{"header": "Mois écoulés", "type": "number"}, {"header": "Ajust. temps (%)", "type": "percent"}]
153 + c_mois, c_temps = col, col + 1
154 + col += 2
155 + else:
156 + columns += [{"header": "Ajust. temps (%)", "type": "percent"}]
157 + c_mois, c_temps = None, col
158 + col += 1
159 + columns.append({"header": "Prix ajusté temps ($)", "type": "currency"})
160 + c_pat = col
161 + col += 1
162 + c_char: dict[str, int] = {}
163 + c_adj: dict[str, int] = {}
164 + if mode_rates:
165 + for k in keys:
166 + columns.append({"header": f"{_label(k)} (comp.)", "type": "number"})
167 + c_char[k] = col
168 + col += 1
169 + for k in keys:
170 + columns.append({"header": f"Ajust. {_label(k)} ($)", "type": "currency"})
171 + c_adj[k] = col
172 + col += 1
173 + else:
174 + for k in keys:
175 + columns.append({"header": f"{_label(k)} ($)", "type": "currency"})
176 + c_adj[k] = col
177 + col += 1
178 + c_total, c_final, c_gross, c_gross_pct = col, col + 1, col + 2, col + 3
179 + columns += [{"header": "Total ajustements ($)", "type": "currency"}, {"header": "Prix ajusté ($)", "type": "currency"},
180 + {"header": "Ajust. bruts ($)", "type": "currency"}, {"header": "Ajust. bruts (%)", "type": "percent"}]
83 181
84 −def build(p: dict[str, Any]) -> dict[str, Any]:
85 − comps_raw = p.get("comparables") or DEFAULT_COMPS
86 − comps, adj_names, char_names = _normalise(list(comps_raw))
87 − for extra in p.get("ajustements") or []:
88 − name = str(extra).strip().capitalize()
89 − if name and name not in adj_names:
90 − adj_names.append(name)
91 − sujet = str(p.get("sujet", "Sujet — immeuble fictif, Gatineau"))
92 − n = len(comps)
93 − first_row = 5
94 − last = first_row + n - 1
95 − # columns: A nom, B prix, C date, D temps %, E prix ajusté temps, F.. adjustments, then totals
96 − adj_start = 6 # F
97 − adj_end = adj_start + len(adj_names) - 1 if adj_names else adj_start - 1
98 − col_total = adj_end + 1
99 − col_final = adj_end + 2
100 − col_gross = adj_end + 3
101 − col_gross_pct = adj_end + 4
102 − L = get_column_letter
103 182 rows: list[list[Any]] = []
104 − for i, c in enumerate(comps):
105 − r = first_row + i
106 − row: list[Any] = [c["nom"], c["prix"], c["date"] or "—", c["temps"], f"=B{r}*(1+D{r})"]
107 − for name in adj_names:
108 − row.append(c["adj"].get(name, 0.0))
109 − adj_range = f"{L(adj_start)}{r}:{L(adj_end)}{r}" if adj_names else None
110 − row.append(f"=SUM({adj_range})" if adj_range else 0)
111 − row.append(f"=E{r}+{L(col_total)}{r}")
112 − row.append(f"=SUMPRODUCT(ABS({adj_range}))" if adj_range else 0)
113 − row.append(f"=IF(E{r}=0,0,{L(col_gross)}{r}/E{r})")
114 − rows.append(row)
115 − columns = ([{"header": "Comparable", "type": "text"}, {"header": "Prix de vente ($)", "type": "currency"},
116 − {"header": "Date de vente", "type": "text"}, {"header": "Ajust. temps (%)", "type": "percent"},
117 − {"header": "Prix ajusté temps ($)", "type": "currency"}]
118 − + [{"header": f"{a} ($)", "type": "currency"} for a in adj_names]
119 − + [{"header": "Total ajustements ($)", "type": "currency"},
120 − {"header": "Prix ajusté ($)", "type": "currency"},
121 − {"header": "Ajust. bruts ($)", "type": "currency"},
122 − {"header": "Ajust. bruts (%)", "type": "percent"}])
123 − grid = {"anchor": "A4", "columns": columns, "rows": rows}
124 − F = L(col_final)
125 − G = L(col_gross_pct)
126 − stats_anchor = last + 3
127 − stats = {
128 − "anchor": f"A{stats_anchor}",
183 + chars_text: list[list[Any]] = []
184 + for i, c in enumerate(comps_raw):
185 + cn = {_norm(k): v for k, v in c.items()}
186 + r = first + i
187 + name = str(cn.get("adresse") or cn.get("nom") or cn.get("comparable") or f"Comparable {i + 1}")
188 + line: list[Any] = [name, num(cn.get("prix", cn.get("prix_vente")), 0.0) or 0.0,
189 + str(cn.get("date") or cn.get("date_vente") or "—")]
190 + tp = _time_pct(cn)
191 + if c_mois is not None:
192 + line.append(num(cn.get("mois"), 0.0) or 0.0)
193 + line.append(f"={L(c_mois)}{r}*{inputs_map['__monthly']}" if tp is None else tp)
194 + else:
195 + line.append(tp if tp is not None else 0.0)
196 + line.append(f"=B{r}*(1+{L(c_temps)}{r})")
197 + adj_vals = {_norm(k): v for k, v in (cn.get("ajustements") or {}).items()} if isinstance(cn.get("ajustements"), dict) else {}
198 + text_row: list[Any] = [name]
199 + if mode_rates:
200 + for k in keys:
201 + rank = _rank(cn.get(k), scales.get(k))
202 + line.append(rank if rank is not None else 0.0)
203 + for k in keys:
204 + line.append(f"=({inputs_map[k]}-{L(c_char[k])}{r})*{inputs_map[k + '__taux']}")
205 + for k, v in cn.items():
206 + if k not in META and k not in keys and not isinstance(v, (dict, list)):
207 + if k not in text_keys:
208 + text_keys.append(k)
209 + else:
210 + for k in keys:
211 + v = adj_vals.get(k, cn.get(k))
212 + nv = num(v, None)
213 + if nv is None:
214 + line.append(0.0)
215 + if v not in (None, ""):
216 + if k not in text_keys:
217 + text_keys.append(k)
218 + elif isinstance(v, str) and v.strip().endswith("%"):
219 + line.append(f"={L(c_pat)}{r}*{nv}")
220 + else:
221 + line.append(nv)
222 + chars_text.append(text_row)
223 + adj_range = f"{L(min(c_adj.values()))}{r}:{L(max(c_adj.values()))}{r}" if c_adj else None
224 + line.append(f"=SUM({adj_range})" if adj_range else 0)
225 + line.append(f"={L(c_pat)}{r}+{L(c_total)}{r}")
226 + line.append(f"=SUMPRODUCT(ABS({adj_range}))" if adj_range else 0)
227 + line.append(f"=IF({L(c_pat)}{r}=0,0,{L(c_gross)}{r}/{L(c_pat)}{r})")
228 + rows.append(line)
229 + sheet["tables"].append({"anchor": f"A{header_row}", "columns": columns, "rows": rows})
230 +
231 + # ---- statistics
232 + F, G = L(c_final), L(c_gross_pct)
233 + s0 = last + 3
234 + sheet["tables"].append({
235 + "anchor": f"A{s0}",
129 236 "columns": [{"header": "Statistique", "type": "text"}, {"header": "Valeur", "type": "currency"}],
130 237 "rows": [
131 − ["Minimum des prix ajustés", f"=MIN({F}{first_row}:{F}{last})"],
132 − ["Maximum des prix ajustés", f"=MAX({F}{first_row}:{F}{last})"],
133 − ["Moyenne simple", f"=AVERAGE({F}{first_row}:{F}{last})"],
134 − ["Médiane", f"=MEDIAN({F}{first_row}:{F}{last})"],
135 − ["Comparable le moins ajusté (rang)", f"=MATCH(MIN({G}{first_row}:{G}{last}),{G}{first_row}:{G}{last},0)"],
136 − ["Prix ajusté du comparable le moins ajusté", f"=INDEX({F}{first_row}:{F}{last},B{stats_anchor + 5})"],
238 + ["Minimum des prix ajustés", f"=MIN({F}{first}:{F}{last})"],
239 + ["Maximum des prix ajustés", f"=MAX({F}{first}:{F}{last})"],
240 + ["Moyenne simple (indicatif)", f"=AVERAGE({F}{first}:{F}{last})"],
241 + ["Médiane", f"=MEDIAN({F}{first}:{F}{last})"],
242 + ["Comparable le moins ajusté (rang)", f"=MATCH(MIN({G}{first}:{G}{last}),{G}{first}:{G}{last},0)"],
243 + ["Prix ajusté du comparable le moins ajusté", f"=INDEX({F}{first}:{F}{last},B{s0 + 5})"],
244 + ["Pondération réconciliée (poids inverses des ajust. bruts)",
245 + f"=SUMPRODUCT({F}{first}:{F}{last},1/(1+{G}{first}:{G}{last}))/SUMPRODUCT(1/(1+{G}{first}:{G}{last}))"],
137 246 ],
138 247 "row_formats": {4: "integer"},
139 − }
140 − tables = [grid, stats]
141 − if char_names:
142 − crow = stats_anchor + 9
143 − tables.append({
144 − "anchor": f"A{crow}",
145 − "columns": [{"header": "Comparable", "type": "text"}] + [{"header": c, "type": "text"} for c in char_names],
146 − "rows": [[c["nom"]] + [cell(c["chars"].get(name, "—")) for name in char_names] for c in comps],
248 + })
249 + next_row = s0 + 10
250 +
251 + # ---- qualitative characteristics kept as text
252 + if text_keys:
253 + sheet["tables"].append({
254 + "anchor": f"A{next_row}",
255 + "columns": [{"header": "Comparable", "type": "text"}] + [{"header": _label(k), "type": "text"} for k in text_keys],
256 + "rows": [[str(c.get("adresse") or c.get("nom") or f"Comparable {i + 1}")]
257 + + [cell({_norm(k2): v for k2, v in c.items()}.get(k, "—")) for k in text_keys]
258 + for i, c in enumerate(comps_raw)],
147 259 })
148 − return {
149 − "filename": p.get("filename", "comparables_ajustes.xlsx"),
150 − "style": "uqo",
151 − "objective": f"Grille de comparables ajustés — {sujet}",
152 − "sheets": [{
153 − "name": "Comparables",
154 − "title": f"Grille de comparables ajustés — {sujet}",
155 − "inputs": [],
156 − "tables": tables,
157 − "notes": [
158 − "On ajuste le comparable vers le sujet : le comparable est meilleur → ajustement négatif.",
159 − "Ordre : conditions de vente, financement, marché (temps), puis caractéristiques physiques.",
160 − "La réconciliation pondère les indications (poids plus fort au comparable le moins ajusté) ; ce n'est pas une moyenne.",
161 − ] + ([f"Caractéristiques qualitatives ({', '.join(char_names)}) présentées dans le tableau du bas ; "
162 − "leur traduction en $ est une hypothèse à justifier."] if char_names else []),
163 − }],
164 − }
260 + if mode_rates and scales:
261 + sheet["notes"].append("Échelles ordinales : " + " ; ".join(f"{_label(k)} = {' < '.join(v)} (rang 0, 1, 2…)" for k, v in scales.items()))
262 + sheet["notes"] += [
263 + "Ajustement = (caractéristique du sujet − celle du comparable) × taux : le comparable est meilleur → ajustement négatif."
264 + if mode_rates else "On ajuste le comparable vers le sujet : le comparable est meilleur → ajustement négatif.",
265 + "Ordre des ajustements : conditions de vente, financement, marché (temps), puis caractéristiques physiques.",
266 + "La réconciliation pondère les indications (plus de poids au comparable le moins ajusté) ; la moyenne simple n'est qu'indicative.",
267 + "Repères du cours : ajustements bruts ≤ 25 % et nets ≤ 15 % pour un comparable fiable.",
268 + ]
269 + return {"filename": p.get("filename", "comparables_ajustes.xlsx"), "style": "uqo",
270 + "objective": f"Grille de comparables ajustés — {sujet_label}", "sheets": [sheet]}
modified backend/app/tools/schemas/create_excel.json +1 −1
@@ -1,6 +1,6 @@
1 1 {
2 2 "name": "create_excel",
3 − "description": "Crée un classeur Excel (.xlsx) professionnel aux couleurs UQO avec formules vivantes, cellules d'hypothèses en bleu, feuille Lisez-moi, graphiques. Deux modes : (1) `template` + `params` pour un gabarit prêt : methode_du_cout (superficie_m2, cout_unitaire_m2, couts_indirects_pct, profit_pct, age_effectif, duree_vie_economique, valeur_terrain, ameliorations_site, depreciation_fonctionnelle, depreciation_economique, adresse), comparables_ajustes (sujet, comparables:[{adresse,prix,date_pct,superficie,terrain,garage,etat}]), age_vie (cout_neuf, age_effectif, duree_vie_economique), tableau_amortissement (capital, taux_annuel, amortissement_ans, versements_par_an), six_fonctions (taux, periodes_max), sensibilite (superficie_m2, cout_unitaire_m2, duree_vie_economique, valeur_terrain, ages, variations) ; (2) `spec` libre : {filename, objective, sheets:[{name, title, inputs:[{cell:'B4', label, value, format:'currency|percent|number|integer|area|factor', name}], tables:[{anchor:'A8', columns:[{header,type}], rows:[[...]], totals:{label, formula}}], charts:[{type:'bar|line|pie', title, categories_range, values_range, anchor}], notes:[...]}]}. Les valeurs commençant par '=' sont des formules Excel (références relatives aux cellules réelles : la 1re ligne de données d'un tableau ancré en A8 est la ligne 9).",
3 + "description": "Crée un classeur Excel (.xlsx) professionnel aux couleurs UQO : formules vivantes, cellules d'hypothèses en bleu, feuille Lisez-moi, graphiques. Tolérant : accepte '425 000 $', '+3 %', 'Oui'/'Non', 'bon'. Deux modes.\n(1) `template` + `params` :\n- comparables_ajustes : {sujet:{adresse, superficie, terrain, garage:'Oui', etat:'bon', …}, taux:{superficie:120, terrain:8, garage:15000, etat:10000} ($/unité ou $/cran), echelles:{etat:['moyen','bon','très bon']}, taux_temps_mensuel:0.005, comparables:[{adresse, prix, date, mois, superficie, terrain, garage, etat, …}]} → les ajustements sont CALCULÉS par formules (sujet − comparable) × taux. Sans `taux`, donne directement des ajustements en $ : comparables:[{adresse, prix, temps:'+2 %', ajustements:{Superficie:-5000}, caracteristiques:{Garage:'Oui'}}].\n- methode_du_cout : {adresse, superficie_m2, cout_unitaire_m2, couts_indirects_pct, profit_pct, age_effectif, duree_vie_economique, valeur_terrain, ameliorations_site, depreciation_fonctionnelle, depreciation_economique}.\n- age_vie : {cout_neuf, age_effectif, duree_vie_economique}.\n- tableau_amortissement : {capital, taux_annuel, amortissement_ans, versements_par_an}.\n- six_fonctions : {taux, periodes_max}.\n- sensibilite : {superficie_m2, cout_unitaire_m2, duree_vie_economique, valeur_terrain, ages:[…], variations:[…]}.\n(2) `spec` libre : {filename, objective, sheets:[{name, title, inputs:[{cell:'B4', label, value, format:'currency|percent|number|integer|area|text', name}], tables:[{anchor:'A8', columns:[{header,type}] ou ['Col1','Col2'], rows:[[…]] ou [{col:val}], totals:{label, formula}}], charts:[{type:'bar|line|pie', title, categories_range, values_range, anchor}], notes:[…]}]}. Les valeurs commençant par '=' sont des formules (références réelles : la 1re ligne de données d'un tableau ancré en A8 est la ligne 9). Reste compact (≤ 60 lignes par appel).",
4 4 "parameters": {
5 5 "type": "object",
6 6 "properties": {
modified backend/tests/tools/test_coerce_and_spec.py +1 −1
@@ -43,7 +43,7 @@ def test_comparables_with_text_values_and_dynamic_columns() -> None:
43 43 assert ws["B5"].value == 415000 and ws["D5"].value == 0.02
44 44 # -3 % of price → dollars
45 45 piscine_col = headers.index("Piscine ($)") + 1
46 − assert abs(ws.cell(row=5, column=piscine_col).value + 0.03 * 415000) < 1e-6
46 + assert ws.cell(row=5, column=piscine_col).value == "=E5*-0.03" # live formula: −3 % of time-adjusted price
47 47 # "Oui" landed in the characteristics table, not in a numeric column
48 48 texts = [c.value for row in ws.iter_rows() for c in row if isinstance(c.value, str)]
49 49 assert "Oui" in texts and "Garage" in texts
added backend/tests/tools/test_comparables_rates.py +43 −0
@@ -0,0 +1,43 @@
1 +import io
2 +
3 +from openpyxl import load_workbook
4 +
5 +from app.tools.create_excel import build_workbook
6 +from app.tools.excel_templates import TEMPLATES
7 +
8 +
9 +def _ws(params): # noqa: ANN001, ANN202
10 + data, _ = build_workbook(TEMPLATES["comparables_ajustes"](params))
11 + return load_workbook(io.BytesIO(data))["Comparables"]
12 +
13 +
14 +def test_rates_mode_builds_live_formulas() -> None:
15 + ws = _ws({
16 + "sujet": {"adresse": "Bungalow Hull", "superficie": "1 200 pi²", "terrain": 6000, "garage": "Oui", "etat": "bon"},
17 + "taux": {"superficie": "120 $", "terrain": 8, "garage": "15 000 $", "etat": 10000},
18 + "taux_temps_mensuel": "0,5 %",
19 + "comparables": [
20 + {"adresse": "45 rue Laurier", "prix": "425 000 $", "date": "mars 2026", "mois": 6, "superficie": 1150,
21 + "terrain": 5500, "garage": "Oui", "etat": "bon"},
22 + {"adresse": "12 rue Front", "prix": 398000, "mois": 8, "superficie": 1250, "terrain": 7000,
23 + "garage": "Non", "etat": "moyen"},
24 + ],
25 + })
26 + values = {c.coordinate: c.value for row in ws.iter_rows() for c in row if c.value is not None}
27 + # subject inputs and rates present as blue inputs
28 + assert values["B4"] == 1200 and values["D4"] == 120
29 + assert any(v == "Oui".lower() or v == 1.0 for k, v in values.items() if k.startswith("B")) # garage → 1
30 + # adjustment formulas reference subject/rate cells
31 + formulas = [v for v in values.values() if isinstance(v, str) and v.startswith("=(")]
32 + assert formulas and all("$B$" in f and "$D$" in f for f in formulas)
33 + # time adjustment uses months × monthly rate
34 + assert any(isinstance(v, str) and v.startswith("=D") and "*$D$" in v for v in values.values())
35 + assert any(h == "Ajust. Garage ($)" for h in values.values())
36 + assert any(h == "Médiane" for h in values.values())
37 +
38 +
39 +def test_dollar_mode_still_works_with_text() -> None:
40 + ws = _ws({"comparables": [{"adresse": "A", "prix": 415000, "temps": "+2 %", "garage": "Oui",
41 + "ajustements": {"Superficie": -5000}}]})
42 + values = [c.value for row in ws.iter_rows() for c in row if c.value is not None]
43 + assert "Oui" in values and -5000 in values and "Garage" in values
44