| 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]} |