SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

Méthode du coût + propriétés à vendre (portage uqo-eval → Vrai-Prix)

Deux nouveaux univers, portés du fork UQO Éval (commits 8fa85f6, 726b80b,
1779481, 08b3a13) et rebrandés Vrai-Prix :

• /cout — méthode du coût « ultra avancée » : base data/cost.db (184 articles,
  99 assemblages, 16 localisations ; 4 967 observations, 2 895 grilles APCHQ
  2023→2026, 1 084 points StatCan 18-10-0289), moteur déterministe
  (RCN → dépréciation âge-vie / composantes → terrain → indication, fourchette
  P10-P90, confiance A-D décomposée), atelier propriété / construction,
  catalogue d'assemblages, console admin (jeton COST_ADMIN_TOKEN), connecteurs
  Firecrawl (APCHQ, CCQ, StatCan, Canac, BMR, Patrick Morin, imports
  Altus/RSMeans) + planificateur `npm run cost:sync`, PDF « Rapport — Méthode
  du coût » (mot-symbole Vrai-Prix, palette rouge/bordeaux), 9 outils Ka
  (cout_*), lien « Voir la méthode du coût » sur chaque rapport de valeur.

• /a-vendre — toutes les propriétés réellement à vendre au Québec (copie
  Immo-Ka data/immoka.db, ~71 600 annonces vivantes) : recherche FTS +
  filtres, fiche complète (galerie, prix, caractéristiques, historique de prix,
  mesure Vrai-Prix vs prix demandé, duel des méthodes, portrait au rôle,
  voisines), atelier « mon évaluation » (bassin de ventes réelles + annonces,
  comparables, grille d'ajustements en $, réconciliation, confrontation ;
  localStorage), analyse IA du bâtiment (Sonnet 5, photos + description →
  composantes → moteur déterministe du coût, overrides, JSON technique,
  similaires techniques). Table vp_eval (ex-uqo_eval) produite par
  scripts/eval-immoka.ts (shards) + build-marche-stats.mjs.

• /stats/marche + teaser /stats — la mesure à l'épreuve du marché, lue à chaud
  depuis data/marche-stats.json (src/lib/marche-stats.ts) avec repli sur
  l'instantané versionné src/data/marche-stats.json.

• scripts/immoka-sync.sh — rafraîchissement nocturne : .backup Immo-Ka sur
  M4M64b (SSH LAN, clé gardien), rsync delta, ré-évaluation en shards,
  vp_eval + agrégats, index/FTS, bascule atomique, pm2 restart.

• Intégration : nav header/tabbar/footer/sitemap, accueil (2 cartes),
  méthodologie (sections 08 Méthode du coût et 09 Marché), confidentialité
  (analyse IA à la demande, photos publiques, rétention 30 j), prompt système
  de Ka. Rebranding complet : aucune mention UQO / IMM1003 / IMM1033 /
  « laboratoire pédagogique » ; « mode étudiant » → « mode exercice ».

• Dépôt : .gitignore `data/` → `/data/` (src/data/*.json désormais versionné :
  stats.json, stats-v2.json, marche-stats.json) + `tmp/` ; devDep tsx ;
  scripts cost:sync / immoka:sync ; docs/cost-*.md portés.

Tests : 69 vitest (moteur, connecteurs hors ligne, IA hors ligne), tsc propre,
next build OK.

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

139 changed files +17,656 −13

modified .gitignore +6 −2
@@ -4,8 +4,12 @@ node_modules/
4 4 *.tsbuildinfo
5 5 next-env.d.ts
6 6
7 −# données — jamais versionnées (base SQLite 1,5 Go)
8 −data/
7 +# données — jamais versionnées (bases SQLite : vraiprix.db 2,1 Go, immoka.db 2 Go, cost.db)
8 +# ⚠️ ancré à la racine : src/data/*.json (stats, marche-stats) DOIT être versionné
9 +/data/
10 +
11 +# caches temporaires (images d'annonces téléchargées pour l'analyse IA, shards d'évaluation)
12 +tmp/
9 13
10 14 # divers
11 15 .DS_Store
added docs/cost-connectors.md +135 −0
@@ -0,0 +1,135 @@
1 +# Méthode du coût — connecteurs, sources et planificateur
2 +
3 +> Porté d'UQO Éval vers Vrai-Prix le 2026-09-08 — processus PM2 `vrai-prix-cost-sync` sur M3U96a.
4 +
5 +> Documentation du chantier « données réelles » de l'onglet Coût (2026-09-06).
6 +> Code : `src/lib/cost/connectors/`, script `scripts/cost-sync.ts`, API admin `src/app/api/admin/cost/`.
7 +
8 +## Principe
9 +
10 +```
11 +Sources externes → discover → fetch (Firecrawl / API / PDF, hash) → extract → normalize → validate → cost.db
12 +```
13 +
14 +- Aucun connecteur ne tourne dans le chemin utilisateur : l'interface lit `data/cost.db` ; les
15 + synchronisations se font par `npm run cost:sync` (PM2 cron sur le nœud) ou l'API admin.
16 +- Toute observation brute est **historisée** (`cost_raw_observations`, statut `accepted` /
17 + `rejected` / `unchanged`, `content_hash`, `parser_version`). Rien n'est effacé ; un document
18 + inchangé (même hash) n'est pas retraité.
19 +- Les rejets gardent leur raison (`reject_reason`) : prix ≤ 0, devise ≠ CAD, unité inconnue,
20 + emballage mal interprété (prix par unité canonique hors bornes), hors bornes métier (×0,25…×4
21 + du prix de référence), aberration statistique (MAD / ratio à la médiane, `pricing.detectOutliers`).
22 +- L'IA (Claude Haiku 4.5) ne fixe **jamais** un prix. Elle sert à (1) choisir le bon produit
23 + parmi des résultats de recherche (`matching.ts`, sortie JSON stricte, mapping en attente si
24 + confiance < 0,8) et (2) transcrire les grilles APCHQ dont la mise en page PDF est décalée,
25 + chaque ligne étant vérifiée contre l'extraction déterministe du même document
26 + (`apchq-ai.ts` : couple (taux, total) présent dans le regex **ou** somme des colonnes = total).
27 + Consommation enregistrée dans `ai_usage` (`GET /api/admin/cost/usage`).
28 +
29 +## Sources
30 +
31 +| Clé | Source | Licence (`cost_sources.license_status`) | Fréquence | Ce qui est ingéré |
32 +|---|---|---|---|---|
33 +| `apchq` | APCHQ — grilles « Coût horaire de la main-d'œuvre » (PDF publics, media.apchq.com) | `public_open` (documents publics ; valeurs + URL, pas de copie du document) | 7 j + hash | `labour_rates` : taux horaire, vacances (13 %), avantages sociaux, cotisations employeur (AE, RQAP, RRQ, FSS, CNESST), autres prélèvements fixes (taxe assurance, équipement, CCQ, AECQ, fonds), **total employeur**, par métier × classification (compagnon, apprenti 1-5) × secteur (résidentiel léger, résidentiel lourd, IC), historique depuis 2023 (`effective_from` / `effective_to`). Spécialités dérivées : poseur de systèmes intérieurs ← charpentier-menuisier, poseur de bardeaux ← couvreur. Exclus : temps demi/double, R-2, chantiers isolés / Baie-James, exemples de paie. |
34 +| `ccq` | CCQ — Salaire et taux | `public_open` | 7 j + hash | Détection de changement seulement. Site en incident de sécurité (fermé jusqu'au 2026-09-08) : le run se termine en `unavailable`, aucune valeur n'est écrite. Quand des tableaux « métier / taux $ » apparaîtront, `hasRateTable` passe à vrai et les observations brutes sont conservées (statut `new`) — la normalisation exige une confirmation de la structure (secteur, cotisations) avant écriture dans `labour_rates`. |
35 +| `statcan` | Statistique Canada — tableau 18-10-0289-01 (indices des prix de la construction de bâtiments), API WDS | `public_open` (licence ouverte) | 30 j | `construction_cost_indices` : 38 séries × 40 trimestres — Québec (province), RMR de Québec, Montréal, Ottawa–Gatineau × bâtiments résidentiels / appartements / maison individuelle / maison en rangée (agrégat) + 11 divisions pour Montréal et Québec résidentiel. Codes `statcan:18100289:<geo>:<type>:<division>` ; `statcan:18100289:10:1:1` = série d'actualisation par défaut, `…:10:1:8` = proxy « bois, plastiques et composites ». Variations trimestrielle et annuelle calculées. |
36 +| `canac` | Canac | `public_open` (robots.txt permissif, fiches publiques) | 1 j | `cost_item_prices` (observed) via fiches produit ; prix dans `product:price:amount`, unité « / Chaque ». |
37 +| `bmr` | BMR | `public_open` (URL SEO `/fr/….html` ; `/catalog/product/view` interdit par robots.txt et non utilisé) | 1 j | idem (`product:price:amount`). |
38 +| `patrickmorin` | Patrick Morin | `public_open` | 1 j | idem. |
39 +| `rona` | RONA | `public_restricted` — **inactif** (Cloudflare, recherche interdite par robots.txt ; fiches permises). Activer via `UPDATE cost_sources SET is_active=1 WHERE key='rona'` si les fiches répondent de façon stable. | 1 j | adaptateur prêt (`ronaAdapter`). |
40 +| `homedepot` | Home Depot Canada | `public_restricted` — **inactif** (prix dépendants du magasin sélectionné, défaut hors Québec). | 1 j | adaptateur prêt. |
41 +| `altus` | Altus Group — Canadian Cost Guide | `public_restricted` : PDF gratuit derrière un formulaire → **import manuel** après téléchargement légal. Aucun scraping. | annuel | `cost_benchmarks` ($/pi² low/high par type et marché). |
42 +| `rsmeans` | RSMeans / Gordian | `licensed` : import CSV autorisé seulement, jamais redistribué ; inactif. | manuel | `cost_item_prices` (price_kind `reference`, source rsmeans). |
43 +
44 +Les prix de référence internes (`cost_items.reference_price`) restent la dernière solution de
45 +repli du catalogue et sont toujours étiquetés « hypothèse » par le moteur.
46 +
47 +## Détaillants : de l'article à l'observation
48 +
49 +1. Chaque article du catalogue possède un `retail_query` (ex. « panneau de gypse 1/2 po 4 x 8 »),
50 + un contenu d'emballage par défaut (`retail_pack_qty`, ex. 32 pi²) et son unité canonique.
51 +2. **Découverte** : s'il existe déjà une URL approuvée (`cost_item_sources`), elle est réutilisée ;
52 + sinon recherche Firecrawl `« <domaine> <retail_query> »` (6 résultats), filtrage des URL de fiche,
53 + score déterministe (`matching.scoreCandidate` : toutes les dimensions de la requête doivent
54 + apparaître, puis recouvrement des mots). Score ≥ 0,85 avec marge → approuvé ; 0,35–0,85 →
55 + arbitrage IA (choix d'un candidat ou aucun) ; < 0,35 → aucun. Résultat dans `product_mappings`
56 + (`auto_approved` / `pending` / `rejected`) ; `pending` à valider dans l'admin.
57 +3. **Fiche** : scrape Firecrawl (markdown + métadonnées), hash incluant le prix affiché.
58 +4. **Extraction** : prix affiché, prix régulier (« Prix régulier », prix barré `~~`) et promotion
59 + (« Spécial », « Économisez »…) détectés uniquement dans la zone du produit principal (avant
60 + « Utile avec cet achat » / produits associés). Le prix **régulier** est préféré ; une promo
61 + sans prix régulier est marquée `is_regular_price = 0` et n'entre dans le prix canonique que
62 + s'il n'existe rien d'autre (`pricing.canonicalPrice`).
63 +5. **Normalisation** : contenu d'emballage lu sur la fiche (« couvre 39,8 pi² », « 100 pi »,
64 + « 50 lb », « pqt/100 ») sinon défaut de l'article ; conversion `units.convertPrice`
65 + (source_unit, canonical_unit, conversion_factor conservés).
66 +6. **Validation** puis écriture : une observation par jour et par (article, source, URL).
67 +
68 +## Commandes
69 +
70 +```bash
71 +npm run cost:sync # connecteurs dus (fréquence nominale)
72 +npx tsx scripts/cost-sync.ts apchq statcan # connecteurs choisis
73 +npx tsx scripts/cost-sync.ts canac --force -v # ignore hash/fréquence, journal détaillé
74 +npx tsx scripts/cost-sync.ts --items LUM-2X6-8,GYP-1/2-4X8 bmr
75 +npx tsx scripts/cost-sync.ts --max 20 patrickmorin
76 +```
77 +
78 +Variables (`.env.local`, serveur seulement) : `FIRECRAWL_API_KEY`, `ANTHROPIC_API_KEY` (matching,
79 +lecture APCHQ), `COST_ADMIN_TOKEN`, optionnel `AI_DAILY_BUDGET_USD` / `AI_MONTHLY_BUDGET_USD`
80 +(alertes de l'endpoint usage), `COST_DB` (chemin de la base, défaut `data/cost.db`).
81 +
82 +PM2 (nœud) : processus `vrai-prix-cost-sync` avec `cron_restart` quotidien
83 +(`/opt/homebrew/bin/npx tsx scripts/cost-sync.ts`, `autorestart: false`).
84 +
85 +## API admin (jeton `COST_ADMIN_TOKEN`, `Authorization: Bearer …` ou `?token=`)
86 +
87 +| Route | Rôle |
88 +|---|---|
89 +| `GET /api/admin/cost/connectors` | état par source (licence, actif, dernière synchro, dernière erreur, volumes, mappings en attente, dû ?), 40 derniers runs, indices, métriques (taux de succès 30 j, observations par source, âge du dernier prix, taux d'échec de mapping, taux d'aberrations, usage IA), runs en cours |
90 +| `POST /api/admin/cost/sync/<connecteur>[?force=1&items=A,B]` | lance un run en arrière-plan (202 + `runId`, 409 si déjà en cours) |
91 +| `GET /api/admin/cost/anomalies` | observations rejetées (aberrations…), mappings `pending`, sources sans synchro depuis 14 j, sauts de prix > 30 % entre deux observations, articles sans observation récente |
92 +| `GET /api/admin/cost/mappings[?status=pending]` · `POST` `{source,itemCode,url,title?,packQty?,packUnit?}` | liste / création-approbation manuelle |
93 +| `POST /api/admin/cost/mappings/<id>/approve` · `/reject` | validation humaine (met à jour `cost_item_sources`) |
94 +| `POST /api/admin/cost/benchmarks/import` | `{kind:"benchmarks"\|"rsmeans", data: CSV texte ou tableau JSON}` (ou corps `text/csv` + `?kind=`) |
95 +| `GET /api/admin/cost/usage` | `ai_usage` jour / mois / par usage, budgets et alertes |
96 +
97 +### Format d'import Altus (benchmarks)
98 +
99 +```csv
100 +source,building_type,market,unit,low,high,year,notes
101 +Altus Group — Canadian Cost Guide 2026,single_family,Montréal,$/pi2,255,395,2026,Single family residential (custom)
102 +Altus Group — Canadian Cost Guide 2026,townhouse,Montréal,$/pi2,190,300,2026,
103 +```
104 +
105 +`building_type ∈ {single_family, townhouse, plex, condo}` ; `market` = Montréal | Québec |
106 +Ottawa-Gatineau (le moteur se rabat sur Montréal). Les valeurs doivent être recopiées du guide
107 +téléchargé légalement ; elles restent à usage informatif (avis de l'éditeur).
108 +
109 +### Format d'import RSMeans (prix licenciés)
110 +
111 +```csv
112 +item_code,price,unit,pack_qty,date,notes
113 +LUM-2X6-8,6.12,chaque,,2026-06-01,RSMeans 2026 Q2 — Montréal CCI
114 +```
115 +
116 +## Tests
117 +
118 +`npx vitest run src/lib/cost/connectors` — fixtures dans `__fixtures__/` (grilles APCHQ 2025 et
119 +2026, page APCHQ avec ses liens, grille IC 2026, fiches Canac / BMR / Patrick Morin, réponses WDS
120 +synthétiques). Couvre : dates/secteurs, découverte et exclusions, rangées fusionnées/décalées,
121 +réconciliation IA, StatCan (yoy/qoq), CCQ indisponible, prix/promo/emballage, page sans prix,
122 +403 / contenu vide, unité inconnue, aberrations, hash inchangé, imports.
123 +
124 +## Dépannage
125 +
126 +- **`FIRECRAWL_API_KEY manquante`** : le script lit `.env.local` du répertoire courant.
127 +- **Run `unavailable` pour CCQ** : normal tant que l'incident dure ; rien n'est écrit.
128 +- **Mapping `pending`** : `GET /api/admin/cost/anomalies` → `POST …/mappings/<id>/approve`.
129 + Un article rejeté récemment n'est pas recherché de nouveau avant 7 jours (sauf `--force`).
130 +- **Prix aberrant légitime** (nouveau niveau de marché) : il est rejeté tant que les autres
131 + sources ne suivent pas ; après une semaine d'observations cohérentes il sera accepté.
132 +- **Lecture IA APCHQ « insuffisante »** : le parseur déterministe est conservé ; vérifier
133 + `connector_runs.log_json`.
134 +- **RONA / Home Depot** : inactifs par conception (voir tableau) ; activer dans `cost_sources`
135 + seulement après vérification manuelle de quelques fiches.
added docs/cost-method-implementation-plan.md +141 −0
@@ -0,0 +1,141 @@
1 +# Vrai-Prix — Onglet « Coût » : plan technique d'implantation
2 +
3 +> Porté d'UQO Éval vers Vrai-Prix le 2026-09-08 (même socle Next 16 ; nœud M3U96a, `~/apps/vrai-prix`). Les chemins et noms de processus ci-dessous sont ceux de Vrai-Prix.
4 +
5 +> Rédigé le 2026-09-06 après audit complet du dépôt. Ce document décrit ce qui
6 +> existe, ce qui est ajouté, les sources de données réellement accessibles et
7 +> l'ordre de réalisation. Il est la référence de la fonctionnalité « Méthode du
8 +> coût » et de l'analyse multimodale des annonces « À vendre ».
9 +
10 +## 1. Audit du dépôt (état au 2026-09-06)
11 +
12 +| Aspect | Constat |
13 +|---|---|
14 +| Framework | Next.js 16.3 (App Router, React 19, React Compiler lint), TypeScript strict, Tailwind v4 |
15 +| Backend | Route handlers Node (`src/app/api/*`), aucune couche serveur séparée |
16 +| Bases | SQLite via `better-sqlite3` : `data/vraiprix.db` (3,75 M unités MAMH + 745 k ventes, `src/lib/db.ts`), `data/immoka.db` (70 k annonces à vendre + table `vp_eval`, `src/lib/immoka.ts`) |
17 +| UI | Design « terminal éditorial » Vrai-Prix : tokens `--accent` (rouge signal), classes `.kicker .klabel .btn .vp-card .src-table .kv-cell`, `useLang()` FR/EN (`LangContext`), sections numérotées `01 —` |
18 +| Navigation | `HeaderNav.tsx` (desktop + menu mobile CSS), `MobileTabBar.tsx` (KaTabbar 6 onglets), `FooterNav.tsx`, `sitemap.ts` |
19 +| Traduction | Dictionnaire `src/lib/i18n.ts` + libellés inline `fr ? … : …` dans les composants |
20 +| PDF | `pdfkit` (`src/lib/report*.ts`), polices dans `assets/fonts`, logo Vrai-Prix, format Lettre |
21 +| Propriété | `getUnit(id)` → `UnitRow` (aire_etages_m2, nb_etages, année, valeur_terrain, valeur_batiment, valeur_role, lat/lng, municipalite, genre_construction, lien_physique, cubf) ; `estimateByUnitId` → hédonique + comparables |
22 +| Annonces | `getListing(uid)` → photos (`images` JSON), description, `details` JSON, caractéristiques, `eval.unit_id` (jumelage MAMH), `vp_eval` (est, model_est, comps_est, cost_est calibré, role_est) |
23 +| Ka | `src/lib/ka/tools.ts` (`KA_TOOLS` + `runKaTool`), boucle agentique SSE dans `src/app/api/ka/route.ts` (Haiku 4.5) |
24 +| Anthropic | `@anthropic-ai/sdk` déjà installé, `ANTHROPIC_API_KEY` dans `.env.local` (laptop et nœud) |
25 +| Tests | vitest (`src/**/*.test.ts`), moteur d'estimation testé |
26 +| Déploiement | Source laptop `~/Desktop/vrai-prix` → rsync staging M1M32 → `mld deploy vrai-prix --node M2U64` (hook post_sync `npm run build`), PM2 `vrai-prix` :8150, ngrok www.vrai-prix.app |
27 +
28 +Aucune table de coûts, aucun connecteur web, aucun vector store n'existait.
29 +
30 +## 2. Sources externes — faisabilité vérifiée (tests du 2026-09-06)
31 +
32 +| Source | Accès testé | Statut licence retenu | Rôle |
33 +|---|---|---|---|
34 +| **APCHQ** — grilles « Coût horaire de la main-d'œuvre » (PDF publics, media.apchq.com, secteur résidentiel léger/lourd, 2023 → 26 avril 2026) | Firecrawl parse le PDF : taux horaire, vacances 13 %, avantages sociaux, AE, RQAP, FSS, CCQ, CNESST, **total employeur** par métier | `public_open` (documents publics de l'association, métadonnées + valeurs numériques, URL conservée) | **Source principale main-d'œuvre** (coût employeur complet, historisé) |
35 +| **CCQ** — salaires conventionnés | Site en incident de sécurité (services fermés jusqu'au 2026-09-08) ; page « Salaire et taux » accessible mais l'outil de taux est JS/indisponible | `public_open` | Connecteur implanté (détection de changement par hash), synchro reprendra à la remise en service ; APCHQ reflète déjà les conventions CCQ |
36 +| **Statistique Canada** — 18-10-0289-01 Indices des prix de la construction de bâtiments (Montréal, Québec RMR ; résidentiel, maison individuelle, rangée, appartements ; 23 divisions), trimestriel, dernier point 2026-T2 | API WDS ouverte (`getDataFromCubePidCoordAndLatestNPeriods`) | `public_open` (licence ouverte StatCan) | Indexation temporelle, tableau de bord « Coût aujourd'hui », validation macro |
37 +| **Canac** (canac.ca) | Firecrawl : métadonnée `product:price:amount` + unité (« / Chaque ») | `public_open` (prix affichés publiquement, robots.txt permissif) | Prix matériaux |
38 +| **BMR** (bmr.ca) | Firecrawl : `product:price:amount` (attention aux variantes « à partir de ») | `public_open` (URL SEO permises) | Prix matériaux |
39 +| **Patrick Morin** | Firecrawl : `product:price:amount` | `public_open` | Prix matériaux |
40 +| **RONA** | Cloudflare ; URL FR devinée → 404, l'URL EN de la recherche existe | `public_restricted` (à valider par connecteur, faible priorité) | Prix matériaux (si stable) |
41 +| **Home Depot Canada** | Page servie, mais magasin par défaut hors Québec (Niagara Falls) → prix non localisés | `public_restricted` | Désactivé par défaut |
42 +| **Altus Group** — Canadian Cost Guide 2026 | PDF derrière formulaire (courriel requis) | `public_restricted` → import manuel après téléchargement légal | Benchmark $/pi² (import CSV admin) |
43 +| **RSMeans / Gordian** | Licence payante | `licensed` / `manual_import` | Modèle prévu, import CSV/XLSX autorisé seulement |
44 +| **Vrai-Prix (interne)** — coût calibré du duel des méthodes (`marche-stats.json`, `vp_eval.cost_est`) | Local | interne | Benchmark de cohérence (« Trois lectures de la valeur ») |
45 +
46 +Règle : aucun prix n'est inventé. Les quantités d'assemblage, heures de
47 +productivité et pourcentages par défaut sont des **hypothèses documentées**
48 +(`source_type = assumption`, badge « hypothèse ») modifiables par l'utilisateur ;
49 +les prix de référence internes pour les articles sans observation détaillant
50 +(béton livré, fermes, fenêtres…) sont étiquetés `internal_reference` et abaissent
51 +la couverture/confiance. L'interface affiche « Donnée non disponible » plutôt
52 +qu'un nombre fictif.
53 +
54 +## 3. Architecture ajoutée
55 +
56 +```
57 +src/lib/cost/
58 + types.ts types partagés (CostInput, CostEstimate, lignes, provenance)
59 + units.ts conversion d'unités (source_unit → canonical_unit, facteur conservé)
60 + taxonomy.ts divisions MasterFormat, catégories UX, qualités, conditions, matériaux
61 + db.ts data/cost.db (schéma §4, migrations versionnées, seeds idempotents)
62 + seed/ sources, localisations, articles (~130), assemblages (~55), règles
63 + pricing.ts prix canonique par article (médiane robuste multi-source, outliers, indexation)
64 + catalog.ts accès DB : articles + prix courants, assemblages, taux, facteurs, indices, benchmarks
65 + geometry.ts moteur de quantités (empreinte, périmètre, murs, toit, fondations…)
66 + engine.ts calcul PUR et déterministe : assemblages → direct → indirect → RCN → dépréciation → valeur
67 + estimate.ts service : entrée (propriété MAMH / construction / annonce) → estimation sauvegardée
68 + connectors/ firecrawl.ts (client), apchq, ccq, statcan, canac, bmr, patrickmorin, rona, homedepot, altus, rsmeans, matching
69 + ai/ analyse multimodale des annonces (schéma JSON, prompt, images, vecteur, mapping)
70 +src/app/cout/ landing + atelier (/cout), /cout/assemblage/[code], /cout/admin
71 +src/app/api/cost/* API publique §6
72 +src/app/api/admin/cost/* synchro, connecteurs, anomalies, mappings (jeton COST_ADMIN_TOKEN)
73 +src/app/a-vendre/[uid]/analyse-cout analyse IA d'une annonce
74 +src/app/api/listings/[uid]/* analyse IA, estimation, overrides, similaires
75 +scripts/cost-sync.ts planificateur (PM2 cron sur le nœud) : connecteurs → observations → prix canoniques
76 +prompts/property-cost-analysis-v1.md
77 +```
78 +
79 +Firecrawl n'est jamais appelé dans le chemin utilisateur : l'UI lit `cost.db`.
80 +
81 +## 4. Schéma `data/cost.db`
82 +
83 +Tables (toutes avec `created_at`) : `cost_sources` (license_status), `cost_raw_observations`
84 +(historisées, `content_hash`, `parser_version`, `status`), `cost_items`,
85 +`cost_item_sources` (URL produit par détaillant, quantité par emballage),
86 +`cost_item_prices` (série temporelle canonique : matériau/main-d'œuvre/équipement,
87 +low/median/high, confiance, `price_kind` observed|derived|indexed|reference),
88 +`labour_rates` (métier, secteur, base, vacances, avantages, cotisations, total employeur,
89 +effective_from/to, source), `cost_locations` (facteurs matériau/main-d'œuvre/équipement),
90 +`construction_cost_indices`, `cost_benchmarks`, `cost_assemblies`, `cost_assembly_components`,
91 +`cost_estimates` (+ `assumptions_json`, versions méthode/base/assemblages, `cost_snapshot_date`),
92 +`cost_estimate_lines` (`calculation_json`), `cost_estimate_snapshots`,
93 +`component_condition_rules`, `connector_runs` (observabilité), `product_mappings`
94 +(matching IA à valider), `listing_ai_analyses`, `listing_ai_overrides`, `listing_images`,
95 +`property_embeddings`, `analysis_conflicts`, `ai_usage`, `cost_meta`.
96 +
97 +## 5. Moteur (déterministe)
98 +
99 +```
100 +matériau_i = Q × qté_i × (1 + pertes_i) × prix_i × FacteurMatériau
101 +main-d'œuvre = Q × heures_i × taux_métier_i × FacteurMainDœuvre
102 +équipement = Q × équip_i × taux_équip × FacteurÉquipement
103 +Direct = Σ (par assemblage, regroupé en catégories UX)
104 +Indirects = Direct × Σ pct (plans, permis, assurances, gestion, financement…)
105 +Frais gén. = (Direct + Indirects) × pct ; Profit = (… + FG) × pct ; Contingence = (Direct + Indirects) × pct
106 +RCN = Direct + Indirects + FG + Profit + Contingence
107 +D_phys = âge-vie (âge effectif / vie économique) OU par composante (vie propre + condition → âge effectif)
108 +V_coût = V_terrain + RCN − (D_phys + D_fonct + D_ext)
109 +```
110 +Fourchette : σ par ligne (dispersion observée ou ±8 % référence / ±12 % hypothèse,
111 +heures ±15 %) combinés en racine des carrés + composante systématique (facteur
112 +régional, modèle d'assemblage 5 %) → P10/P90. Confiance 0-100 (fraîcheur 20,
113 +couverture 20, localisation 15, main-d'œuvre 15, benchmarks 10, bâtiment 20) → A-D.
114 +
115 +Invariants testés : `RCN ≥ Direct`, `valeur dépréciée ≥ 0`, `V = terrain + dépréciée`,
116 +même entrée + même instantané de prix = même résultat.
117 +
118 +## 6. API
119 +
120 +Publique : `GET /api/cost/overview`, `/items`, `/items/[code]`, `/assemblies`,
121 +`/assemblies/[code]`, `/labour`, `/materials/history`, `/locations`, `/benchmarks`,
122 +`/sources`, `/property?id=` (préremplissage MAMH), `POST /api/cost/estimate`,
123 +`GET /api/cost/estimate/[id]`, `GET /api/cost/report?estimate=` (PDF).
124 +Admin (jeton) : `GET /api/admin/cost/connectors`, `POST /sync/[connector]`,
125 +`GET /anomalies`, `GET|POST /mappings`, `POST /benchmarks/import`.
126 +Annonces : `POST|GET /api/listings/[uid]/ai-cost-analysis`, `/reanalyze`,
127 +`PATCH /override`, `POST|GET /cost-estimate`, `GET /technical-similar`, `GET /ai-analysis-json`.
128 +
129 +## 7. Ordre de réalisation
130 +
131 +1. Audit (fait) · 2. Schéma + seeds · 3. Moteur pur + tests · 4. ≥ 40 assemblages ·
132 +5. Main-d'œuvre APCHQ (+ CCQ stub) · 6. StatCan · 7. Détaillants Firecrawl ·
133 +8. Benchmarks · 9. UI `/cout` · 10. Intégration propriété · 11. Dépréciation ·
134 +12. Sources/audit · 13. PDF · 14. Ka · 15. Admin · 16. Tests, mobile, FR/EN ·
135 +17. Analyse multimodale des annonces (Sonnet 5) · 18. Déploiement M2U64 via mld
136 +(cron PM2 `vrai-prix-cost-sync`, `FIRECRAWL_API_KEY` côté serveur).
137 +
138 +## 8. Hors périmètre du MVP (phase 2/3)
139 +
140 +Vision à partir de plans, embeddings d'images, 500+ articles, localisation par
141 +municipalité fine, import RSMeans réel, entraînement de modèles.
modified package-lock.json +1 −0
@@ -3250,6 +3250,7 @@
3250 3250 "version": "13.0.3",
3251 3251 "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
3252 3252 "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
3253 + "hasInstallScript": true,
3253 3254 "license": "MIT",
3254 3255 "dependencies": {
3255 3256 "node-addon-api": "^8.0.0"
modified package.json +4 −1
@@ -7,7 +7,9 @@
7 7 "build": "next build",
8 8 "start": "next start",
9 9 "lint": "eslint",
10 − "test": "vitest run"
10 + "test": "vitest run",
11 + "cost:sync": "tsx scripts/cost-sync.ts",
12 + "immoka:sync": "zsh scripts/immoka-sync.sh"
11 13 },
12 14 "dependencies": {
13 15 "@anthropic-ai/sdk": "^0.116.0",
@@ -30,6 +32,7 @@
30 32 "eslint-config-next": "16.3.0",
31 33 "playwright": "^1.62.1",
32 34 "tailwindcss": "^4",
35 + "tsx": "^4.23.12",
33 36 "typescript": "^5",
34 37 "vitest": "^4.1.10"
35 38 }
added prompts/property-cost-analysis-v1.md +23 −0
@@ -0,0 +1,23 @@
1 +# Property cost analysis — system prompt v1 (Vrai-Prix)
2 +
3 +You are a building component extraction engine for a transparent real-estate valuation platform (Vrai-Prix, Québec). You receive a real for-sale listing: photos (each identified `photo_NN` in the manifest), the broker description, structured listing fields and, when available, authoritative assessment-roll data (MAMH). You describe the PHYSICAL BUILDING so that a deterministic cost engine can apply current market costs component by component.
4 +
5 +## Absolute rules
6 +
7 +1. Use ONLY evidence contained in: listing metadata, listing description, the supplied images, authoritative property data (MAMH). Never invent a feature, a dimension or a characteristic that is not supported by evidence.
8 +2. If uncertain: return `null`, give a low `confidence`, list the `evidence` you did see. Never present a supposition as a certainty.
9 +3. Every important inference carries `confidence` (0.0–1.0), a `source` (`listing` = structured field, `MAMH` = assessment roll, `photo` = seen in an image, `derived` = simple arithmetic from given facts, `ai_estimated` = your visual estimate, `assumed` = typical assumption you were forced to make, `unknown`) and `evidence` referencing `photo_NN`, `listing`, `MAMH` or `description`.
10 +4. Do NOT estimate monetary costs, prices or values of any kind. Your role is to describe the building and estimate technical quantities only.
11 +5. Return ONLY the structured tool call matching the supplied schema. No prose outside the tool input.
12 +6. Source hierarchy: authoritative property data (MAMH) > structured listing fields > listing description > visual observation > derived geometry > typical assumption. When a stronger source contradicts what you see, keep the stronger source's value and report the disagreement in `uncertainties` (e.g. "listing says metal roof; photos show asphalt shingles").
13 +7. Quantities you must NOT compute (the code derives them from area, storeys and footprint): wall areas, roof area, foundation, slabs, drywall, flooring, partitions. Provide them only when directly observable (e.g. a floor plan) and mark the source. Quantities you SHOULD estimate from the photos when visible: window count, exterior doors, kitchen cabinet linear feet, countertop and backsplash square feet, bathroom tile square feet, deck/patio/driveway square feet, fence length, garage area, finished-basement share, interior door count.
14 +8. Distinguish AGE, CONDITION and QUALITY. An old kitchen can be high quality; a recent kitchen can be economy. Report renovations you can see (component, likelihood, estimated age range in years) — as estimates only.
15 +9. Normalized vocabularies: materials (quartz, granite, laminate, butcher_block, solid_surface, marble, hardwood, engineered_wood, vinyl_plank, laminate_floor, ceramic_tile, porcelain_tile, carpet, concrete_floor, brick, stone, vinyl_siding, fiber_cement, wood_siding, stucco, aluminum_siding, steel_siding, asphalt_shingle, metal_roof, elastomeric_membrane, epdm, cedar_shake, pvc_window, hybrid_window, aluminum_window, wood_window, poured_concrete, concrete_block, stone_foundation, melamine, thermofoil, painted_mdf, wood_veneer, solid_wood); quality levels (economy, standard, superior, prestige, custom_luxury); conditions (poor, below_average, average, good, very_good, excellent, renovated, new).
16 +10. Siding shares must sum to 100 when any siding is visible; use the façade AND side/rear views to weight (brick often covers the front only).
17 +11. Effective age: propose `chronological_age`, `effective_age` and `economic_life` with a factual `reasoning_summary` (short observed facts such as "kitchen renovated", "windows appear newer", "roof appears mid-life"). No internal deliberation, only observable facts.
18 +12. Assemblies: suggest assembly codes ONLY from the catalogue list supplied after these rules; give a quantity only when you can support it, otherwise `null` (the code derives it). Use weighted hybrids when a façade mixes materials (e.g. brick 60 % / vinyl 40 %).
19 +13. Privacy: ignore people, faces, licence plates, documents, screens and personal belongings; report only whether such elements were visible (`privacy` flags). Analyse only the building and its site.
20 +14. Hidden elements (insulation, underground plumbing, framing): do not claim observation. Use the year of construction and typical Québec practice, mark the source `assumed`, keep confidence low.
21 +15. Photos may be duplicated, cropped or show staging furniture: never count furniture as built-ins; never infer room count from staged photos alone when the listing gives it.
22 +
23 +Be exhaustive within the schema, factual, and calibrated: a `confidence` of 0.9 means you would be surprised to be wrong.
added scripts/build-marche-stats.mjs +246 −0
@@ -0,0 +1,246 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * scripts/build-marche-stats.mjs — fusion des shards d'évaluation Immo-Ka
4 + * (tmp/eval-shards/*.jsonl, produits par scripts/eval-immoka.ts) :
5 + * 1. écrit la table `vp_eval` dans data/immoka.db (le « merge » annonce ↔
6 + * estimation Vrai-Prix, requêtable en SQL) ;
7 + * 2. calcule src/data/marche-stats.json — performance de la mesure face aux
8 + * prix demandés du marché actif, servi par /stats/marche.
9 + * Données 100 % réelles — aucune valeur inventée. À relancer quand la copie
10 + * d'immoka.db ou le moteur change.
11 + */
12 +import Database from "better-sqlite3";
13 +import { readdirSync, readFileSync, writeFileSync } from "fs";
14 +import path from "path";
15 +
16 +const IMMOKA_DB = process.env.IMMOKA_DB ?? path.join(process.cwd(), "data", "immoka.db");
17 +const SHARD_DIR = path.join(process.cwd(), "tmp", "eval-shards");
18 +
19 +/* ---------------- 1. fusion des shards → table vp_eval ---------------- */
20 +const files = readdirSync(SHARD_DIR).filter((f) => f.endsWith(".jsonl"));
21 +if (!files.length) throw new Error(`aucun shard dans ${SHARD_DIR}`);
22 +const rows = files.flatMap((f) =>
23 + readFileSync(path.join(SHARD_DIR, f), "utf8")
24 + .split("\n")
25 + .filter(Boolean)
26 + .map((l) => JSON.parse(l))
27 +);
28 +console.log(`${rows.length} évaluations lues (${files.length} shards)`);
29 +
30 +const db = new Database(IMMOKA_DB);
31 +db.pragma("journal_mode = WAL");
32 +db.exec(`
33 + DROP TABLE IF EXISTS vp_eval;
34 + CREATE TABLE vp_eval (
35 + uid TEXT PRIMARY KEY, -- listings.uid (annonce Immo-Ka)
36 + unit_id TEXT, -- units.id_provinc (rôle MAMH)
37 + match_m REAL, -- distance du jumelage spatial lat/lng (m)
38 + price REAL, -- prix demandé au moment de la copie
39 + est REAL, low REAL, high REAL, -- estimation Vrai-Prix + P10-P90
40 + confidence_pct REAL, confidence TEXT,
41 + model_est REAL, comps_est REAL, model_weight REAL, n_comps INTEGER,
42 + cost_est REAL, -- méthode du coût calibrée (terrain + bâtiment déprécié)
43 + role_est REAL, -- méthode du rôle indexé (ratios de vente IAAO)
44 + ens_est REAL, -- ensemble : médiane (hybride, coût, rôle indexé)
45 + valeur_role REAL, type_prop TEXT, municipalite TEXT,
46 + ratio REAL, -- est / prix demandé
47 + diff REAL, -- prix demandé − est ($)
48 + evaluated_at TEXT
49 + );
50 +`);
51 +const ins = db.prepare(`
52 + INSERT OR REPLACE INTO vp_eval VALUES
53 + (@uid,@unit_id,@match_m,@price,@est,@low,@high,@cpct,@clevel,@model,@comps,@mw,@ncomps,
54 + @cost,@ridx,@ens,@role,@type,@muni,@ratio,@diff,@at)`);
55 +const at = new Date().toISOString().slice(0, 10);
56 +db.transaction(() => {
57 + for (const r of rows)
58 + ins.run({
59 + ...r,
60 + ratio: r.price > 0 && r.est > 0 ? r.est / r.price : null,
61 + diff: r.est > 0 ? r.price - r.est : null,
62 + at,
63 + });
64 +})();
65 +console.log("table vp_eval écrite dans", IMMOKA_DB);
66 +
67 +/* ---------------- 2. agrégats → src/data/marche-stats.json ---------------- */
68 +const median = (xs) => {
69 + if (!xs.length) return null;
70 + const s = [...xs].sort((a, b) => a - b);
71 + const m = Math.floor(s.length / 2);
72 + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
73 +};
74 +const r4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000);
75 +
76 +// univers : annonces résidentielles plausibles (≥ 50 000 $, même seuil que les
77 +// ventes retenues par le moteur) avec estimation positive
78 +const val = db
79 + .prepare(
80 + `SELECT e.uid, e.price, e.est, e.low, e.high, e.confidence AS lvl, e.n_comps,
81 + e.match_m, e.model_est, e.comps_est, e.cost_est, e.role_est, e.ens_est,
82 + e.valeur_role, e.type_prop, l.region, l.city,
83 + l.property_type, l.bedrooms
84 + FROM vp_eval e JOIN listings l ON l.uid = e.uid
85 + WHERE e.price >= 50000 AND e.est > 0`
86 + )
87 + .all();
88 +
89 +const totalListings = db
90 + .prepare(
91 + `SELECT COUNT(*) AS n FROM listings
92 + WHERE status='a-vendre' AND published=1 AND active=1 AND dup_hidden=0 AND price>0`
93 + )
94 + .get().n;
95 +const geolocated = db
96 + .prepare(
97 + `SELECT COUNT(*) AS n FROM listings
98 + WHERE status='a-vendre' AND published=1 AND active=1 AND dup_hidden=0 AND price>0
99 + AND lat IS NOT NULL AND lng IS NOT NULL`
100 + )
101 + .get().n;
102 +
103 +const stats = (subset) => {
104 + const ratios = subset.map((r) => r.est / r.price);
105 + const apes = subset.map((r) => Math.abs(r.est - r.price) / r.price);
106 + const diffs = subset.map((r) => r.price - r.est).sort((a, b) => a - b);
107 + const q = (p) => (diffs.length ? diffs[Math.min(diffs.length - 1, Math.floor(p * diffs.length))] : null);
108 + const inRange = subset.filter((r) => r.price >= r.low && r.price <= r.high).length;
109 + return {
110 + n: subset.length,
111 + medianRatio: r4(median(ratios)),
112 + mdape: r4(median(apes)),
113 + within10: r4(apes.filter((a) => a <= 0.1).length / (apes.length || 1)),
114 + within20: r4(apes.filter((a) => a <= 0.2).length / (apes.length || 1)),
115 + askAbove: r4(subset.filter((r) => r.price > r.est).length / (subset.length || 1)),
116 + inP10P90: r4(inRange / (subset.length || 1)),
117 + medianAsk: median(subset.map((r) => r.price)),
118 + medianEst: median(subset.map((r) => r.est)),
119 + // écart prix demandé − estimation : la « prime d'affichage » mesurée
120 + medianDiff: median(diffs) == null ? null : Math.round(median(diffs)),
121 + medianDiffPct: r4(median(subset.map((r) => (r.price - r.est) / r.est))),
122 + p25Diff: q(0.25) == null ? null : Math.round(q(0.25)),
123 + p75Diff: q(0.75) == null ? null : Math.round(q(0.75)),
124 + };
125 +};
126 +
127 +// clés géographiques : les sources d'Immo-Ka mélangent accents et graphies
128 +// (« Montreal » / « Montréal ») et laissent parfois la région vide → on groupe
129 +// sur une clé pliée (minuscules sans accents) et on affiche la graphie la plus
130 +// fréquente ; les vides sont exclus.
131 +const fold = (s) =>
132 + s
133 + .normalize("NFD")
134 + .replace(/[\u0300-\u036f]/g, "")
135 + .toLowerCase()
136 + .trim();
137 +const groupBy = (key, { normalize = false } = {}) => {
138 + const m = new Map();
139 + for (const r of val) {
140 + const raw = (r[key] ?? "").toString().trim();
141 + if (normalize && !raw) continue;
142 + const k = normalize ? fold(raw) : raw || "?";
143 + if (!m.has(k)) m.set(k, { rows: [], labels: new Map() });
144 + const g = m.get(k);
145 + g.rows.push(r);
146 + g.labels.set(raw, (g.labels.get(raw) ?? 0) + 1);
147 + }
148 + return [...m.values()]
149 + .filter((g) => g.rows.length >= 30)
150 + .sort((a, b) => b.rows.length - a.rows.length)
151 + .map((g) => ({
152 + key: [...g.labels.entries()].sort((a, b) => b[1] - a[1])[0][0],
153 + ...stats(g.rows),
154 + }));
155 +};
156 +
157 +// histogramme du ratio estimation / prix demandé (pas de 5 %, queues groupées)
158 +const hist = [];
159 +for (let lo = 0.5; lo < 1.5 - 1e-9; lo += 0.05) hist.push({ lo: r4(lo), hi: r4(lo + 0.05), n: 0 });
160 +let below = 0,
161 + above = 0;
162 +for (const r of val) {
163 + const q = r.est / r.price;
164 + if (q < 0.5) below++;
165 + else if (q >= 1.5) above++;
166 + else hist[Math.min(hist.length - 1, Math.floor((q - 0.5) / 0.05))].n++;
167 +}
168 +
169 +// tranches de prix demandé
170 +const brackets = [
171 + [0, 300000, "<300k"],
172 + [300000, 500000, "300-500k"],
173 + [500000, 750000, "500-750k"],
174 + [750000, 1000000, "750k-1M"],
175 + [1000000, Infinity, ">1M"],
176 +].map(([lo, hi, label]) => ({
177 + key: label,
178 + ...stats(val.filter((r) => r.price >= lo && r.price < hi)),
179 +}));
180 +
181 +// nuage prix demandé × estimation (échantillon stable pour le graphique)
182 +const step = Math.max(1, Math.floor(val.length / 1500));
183 +const scatter = val
184 + .filter((_, i) => i % step === 0)
185 + .map((r) => ({ p: Math.round(r.price), e: Math.round(r.est), l: r.lvl }));
186 +
187 +// duel des méthodes — la mesure principale reste l'hybride 65/35 ; les autres
188 +// (hédonique pur, comparables purs, coût calibré, rôle indexé, ensemble) sont
189 +// évaluées sur le même univers, chacune là où elle est disponible
190 +const methodStats = (field) => {
191 + const sub = val.filter((r) => r[field] != null && r[field] > 0);
192 + const ratios = sub.map((r) => r[field] / r.price);
193 + const apes = sub.map((r) => Math.abs(r[field] - r.price) / r.price);
194 + return {
195 + n: sub.length,
196 + coverage: r4(sub.length / (val.length || 1)),
197 + medianRatio: r4(median(ratios)),
198 + mdape: r4(median(apes)),
199 + within10: r4(apes.filter((a) => a <= 0.1).length / (apes.length || 1)),
200 + within20: r4(apes.filter((a) => a <= 0.2).length / (apes.length || 1)),
201 + medianDiff: sub.length ? Math.round(median(sub.map((r) => r.price - r[field]))) : null,
202 + };
203 +};
204 +const methods = [
205 + { key: "hybride", field: "est", main: true },
206 + { key: "hedonique", field: "model_est", main: false },
207 + { key: "comparables", field: "comps_est", main: false },
208 + { key: "cout", field: "cost_est", main: false },
209 + { key: "role_indexe", field: "role_est", main: false },
210 + { key: "ensemble", field: "ens_est", main: false },
211 +].map((m) => ({ key: m.key, main: m.main, ...methodStats(m.field) }));
212 +
213 +const out = {
214 + generated: new Date().toISOString(),
215 + source: {
216 + app: "Immo-Ka",
217 + copiedAt: at,
218 + totalListings,
219 + geolocated,
220 + evaluated: rows.length,
221 + valid: val.length,
222 + matchMedianM: r4(median(val.map((r) => r.match_m).filter((v) => v != null))),
223 + },
224 + global: stats(val),
225 + methods,
226 + byConfidence: ["A", "B", "C", "D"].map((lvl) => ({
227 + key: lvl,
228 + ...stats(val.filter((r) => r.lvl === lvl)),
229 + })),
230 + // type côté annonce (Maison/Condo/…) : plus parlant que le type du rôle —
231 + // beaucoup de condos sont classés « unifamilial » dans le rôle MAMH.
232 + byType: groupBy("property_type", { normalize: true }).slice(0, 10),
233 + byRegion: groupBy("region", { normalize: true })
234 + .filter((g) => !/^\d+$/.test(g.key)) // certaines sources donnent le code de région, pas le nom
235 + .slice(0, 18),
236 + byCity: groupBy("city", { normalize: true }).slice(0, 15),
237 + byBracket: brackets,
238 + histogram: { below, above, bins: hist },
239 + scatter,
240 +};
241 +// Destination : src/data/marche-stats.json (instantané versionné, secours du site)
242 +// ou MARCHE_STATS_OUT (ex. data/marche-stats.json, lu à chaud par src/lib/marche-stats.ts
243 +// lors du rafraîchissement nocturne — évite tout rebuild et toute dérive git).
244 +const dest = process.env.MARCHE_STATS_OUT ?? path.join(process.cwd(), "src", "data", "marche-stats.json");
245 +writeFileSync(dest, JSON.stringify(out));
246 +console.log(`écrit ${dest} — global :`, out.global);
added scripts/cost-sync.ts +61 −0
@@ -0,0 +1,61 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Planificateur de la base de coûts (méthode du coût) :
4 + * npx tsx scripts/cost-sync.ts # tous les connecteurs dus (fréquence nominale)
5 + * npx tsx scripts/cost-sync.ts apchq statcan # connecteurs choisis (même si non dus)
6 + * npx tsx scripts/cost-sync.ts canac --force # ignore le cache de hash / la fréquence
7 + * npx tsx scripts/cost-sync.ts --items LUM-2X6-8,GYP-1/2-4X8 canac
8 + * npx tsx scripts/cost-sync.ts --max 20 bmr
9 + * Fréquences nominales : détaillants 1 j · APCHQ 7 j (+ hash) · CCQ 7 j · StatCan 30 j.
10 + * Charge .env.local sans dépendance. Conçu pour PM2 (cron_restart) sur le nœud.
11 + */
12 +import fs from "fs";
13 +import path from "path";
14 +
15 +function loadEnv(): void {
16 + for (const f of [".env.local", ".env"]) {
17 + const p = path.join(process.cwd(), f);
18 + if (!fs.existsSync(p)) continue;
19 + for (const line of fs.readFileSync(p, "utf8").split("\n")) {
20 + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
21 + if (!m || line.trim().startsWith("#")) continue;
22 + if (process.env[m[1]] == null) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
23 + }
24 + }
25 +}
26 +loadEnv();
27 +
28 +async function main(): Promise<void> {
29 + const { RUNNABLE_KEYS, isRunnable } = await import("../src/lib/cost/connectors/registry");
30 + const { isDue, runConnector } = await import("../src/lib/cost/connectors/run");
31 + const { getCostDb } = await import("../src/lib/cost/db");
32 +
33 + const args = process.argv.slice(2);
34 + const force = args.includes("--force");
35 + const itemsArg = args.find((a) => a.startsWith("--items"));
36 + const items = itemsArg ? (itemsArg.includes("=") ? itemsArg.split("=")[1] : args[args.indexOf(itemsArg) + 1]).split(",").map((s) => s.trim()).filter(Boolean) : undefined;
37 + const maxArg = args.find((a) => a.startsWith("--max"));
38 + const maxPages = maxArg ? Number(maxArg.includes("=") ? maxArg.split("=")[1] : args[args.indexOf(maxArg) + 1]) : undefined;
39 + const verbose = args.includes("--verbose") || args.includes("-v");
40 + const requested = args.filter((a) => !a.startsWith("-") && isRunnable(a) && a !== (items ? items.join(",") : "") && a !== String(maxPages));
41 + const d = getCostDb();
42 + const keys = requested.length ? requested : RUNNABLE_KEYS.filter((k) => force || isDue(k, d));
43 + if (!keys.length) { console.log("[cost-sync] rien à faire (aucun connecteur dû)."); return; }
44 + console.log(`[cost-sync] ${new Date().toISOString()} — connecteurs : ${keys.join(", ")}${force ? " (--force)" : ""}${items ? ` — articles : ${items.join(",")}` : ""}`);
45 + let failed = 0;
46 + for (const key of keys) {
47 + if (!isRunnable(key)) continue;
48 + const t0 = Date.now();
49 + const stats = await runConnector(key, {
50 + force, onlyItems: items, maxPages, db: d,
51 + log: (e) => { if (verbose || e.status === "error" || e.status === "unavailable") console.log(` [${key}] ${e.status.padEnd(11)} ${e.url ?? ""} ${e.message ?? ""}`.trimEnd()); },
52 + });
53 + if (stats.status === "error") failed++;
54 + console.log(`[cost-sync] ${key.padEnd(13)} ${stats.status.padEnd(11)} pages ${stats.pages} · obs ${stats.observations} · acceptées ${stats.accepted} · rejetées ${stats.rejected} · inchangées ${stats.unchanged} · erreurs ${stats.errors} · ${Math.round((Date.now() - t0) / 1000)} s${stats.message ? ` — ${stats.message}` : ""}`);
55 + }
56 + const s = d.prepare("SELECT (SELECT COUNT(*) FROM cost_item_prices WHERE price_kind IN ('observed','official')) prices, (SELECT COUNT(DISTINCT cost_item_id) FROM cost_item_prices WHERE observation_date >= date('now','-120 days')) items, (SELECT COUNT(*) FROM labour_rates) labour, (SELECT COUNT(*) FROM construction_cost_indices) idx").get() as { prices: number; items: number; labour: number; idx: number };
57 + console.log(`[cost-sync] base : ${s.prices} observations de prix · ${s.items} articles avec prix récent · ${s.labour} grilles de main-d'œuvre · ${s.idx} points d'indice`);
58 + if (failed) process.exitCode = 1;
59 +}
60 +
61 +main().catch((e) => { console.error("[cost-sync] échec :", e); process.exit(1); });
added scripts/eval-immoka.ts +342 −0
@@ -0,0 +1,342 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * scripts/eval-immoka.ts — évalue en lot les annonces actives d'Immo-Ka avec
4 + * le moteur Vrai-Prix (même chemin que estimateByUnitId, sans le contexte
5 + * marché, inutile pour les agrégats).
6 + *
7 + * Jumelage 100 % spatial : chaque annonce (lat/lng géocodés par Immo-Ka) est
8 + * appariée à l'unité du rôle MAMH la plus proche (bbox sur idx_units_geo,
9 + * haversine ≤ 75 m puis repli ≤ 250 m), départagée par compatibilité de type,
10 + * superficie habitable et année de construction — indispensable pour les
11 + * condos/plex où plusieurs unités partagent les mêmes coordonnées.
12 + *
13 + * Sharding par processus (14 cœurs M4M36) :
14 + * SHARDS=6 SHARD=0..5 npx tsx scripts/eval-immoka.ts
15 + * Chaque shard écrit tmp/eval-shards/shard-N.jsonl ; la fusion + les agrégats
16 + * sont faits par scripts/build-marche-stats.mjs.
17 + *
18 + * Env : IMMOKA_DB (défaut data/immoka.db), VRAIPRIX_DB (défaut data/vraiprix.db),
19 + * LIMIT (test), SHARD/SHARDS.
20 + */
21 +import Database from "better-sqlite3";
22 +import fs from "fs";
23 +import path from "path";
24 +import { getCandidates, getDb, getMarketIndex, type TxRow, type UnitRow } from "../src/lib/db";
25 +import { estimate, haversineM, type CompInput, type Subject } from "../src/lib/engine";
26 +import { indexType } from "../src/lib/estimator";
27 +
28 +const SHARD = Number(process.env.SHARD ?? 0);
29 +const SHARDS = Number(process.env.SHARDS ?? 1);
30 +const LIMIT = Number(process.env.LIMIT ?? 0);
31 +const IMMOKA_DB = process.env.IMMOKA_DB ?? path.join(process.cwd(), "data", "immoka.db");
32 +const OUT_DIR = path.join(process.cwd(), "tmp", "eval-shards");
33 +
34 +// --- répliques des helpers privés d'estimator.ts (même logique exactement) ---
35 +function toComps(rows: TxRow[]): CompInput[] {
36 + return rows.map((r) => ({
37 + id: r.id,
38 + date: r.date,
39 + amount: r.amount,
40 + lat: r.lat,
41 + lng: r.lng,
42 + propertyType: r.property_type,
43 + yearBuilt: r.year_built,
44 + floorArea: r.floor_area,
45 + street: r.street,
46 + city: r.city,
47 + }));
48 +}
49 +function since(months: number): string {
50 + const d = new Date();
51 + d.setMonth(d.getMonth() - months);
52 + return d.toISOString().slice(0, 10);
53 +}
54 +function candidatesAround(lat: number, lng: number): TxRow[] {
55 + for (const halfDeg of [0.02, 0.1]) {
56 + const rows = getCandidates(lat, lng, halfDeg, since(30), 600);
57 + if (rows.length >= 30) return rows;
58 + }
59 + return getCandidates(lat, lng, 0.25, since(36), 600);
60 +}
61 +const nowISO = new Date().toISOString().slice(0, 10);
62 +
63 +// L'indice de marché ne dépend que du type (3 valeurs) — cache évident en lot.
64 +const idxCache = new Map<string, { month: string; idx: number }[]>();
65 +function idxFor(typeProp: string) {
66 + const k = indexType(typeProp);
67 + let v = idxCache.get(k);
68 + if (!v) {
69 + v = getMarketIndex(k);
70 + idxCache.set(k, v);
71 + }
72 + return v;
73 +}
74 +
75 +/* ------------------------- jumelage spatial lat/lng ------------------------- */
76 +// types d'unités plausibles selon le type d'annonce Immo-Ka (clé pliée)
77 +function typeBucket(pt: string | null): Set<string> | null {
78 + const t = (pt ?? "")
79 + .normalize("NFD")
80 + .replace(/[\u0300-\u036f]/g, "")
81 + .toLowerCase();
82 + if (!t) return null; // inconnu → aucune contrainte
83 + if (t.includes("condo")) return new Set(["condo_ou_multi", "unifamilial", "plex"]);
84 + if (/(du|tri|multi|quadru)plex/.test(t) || t.includes("plex")) return new Set(["plex", "condo_ou_multi"]);
85 + if (t.includes("terrain") || t.includes("lot")) return new Set(["terrain"]);
86 + if (t.includes("commerc") || t.includes("industri") || t.includes("bureau")) return new Set(["autre"]);
87 + if (t.includes("chalet")) return new Set(["chalet", "unifamilial"]);
88 + if (t.includes("ferme")) return new Set(["unifamilial", "autre", "terrain"]);
89 + if (t.includes("mobile")) return new Set(["maison_mobile", "unifamilial"]);
90 + // maison, jumelé, détachée, maison de ville, propriétés résidentielles…
91 + return new Set(["unifamilial", "chalet", "maison_mobile"]);
92 +}
93 +
94 +const unitsInBox = getDb().prepare(
95 + `SELECT * FROM units
96 + WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`
97 +);
98 +
99 +interface ListingRow {
100 + uid: string;
101 + price: number;
102 + lat: number;
103 + lng: number;
104 + property_type: string | null;
105 + area_sqft: number | null;
106 + year_built: number | null;
107 +}
108 +
109 +/** Unité la plus plausible pour l'annonce : distance + type + superficie + année. */
110 +function matchUnit(l: ListingRow): { u: UnitRow; dist: number } | null {
111 + const bucket = typeBucket(l.property_type);
112 + const areaM2 = l.area_sqft && l.area_sqft > 100 ? l.area_sqft * 0.092903 : null;
113 + for (const [halfDeg, capM] of [
114 + [0.001, 75],
115 + [0.003, 250],
116 + ] as const) {
117 + const cands = unitsInBox.all(
118 + l.lat - halfDeg,
119 + l.lat + halfDeg,
120 + l.lng - halfDeg,
121 + l.lng + halfDeg
122 + ) as UnitRow[];
123 + let best: { u: UnitRow; dist: number; score: number } | null = null;
124 + for (const u of cands) {
125 + if (u.lat == null || u.lng == null) continue;
126 + const dist = haversineM(l.lat, l.lng, u.lat, u.lng);
127 + if (dist > capM) continue;
128 + let score = dist;
129 + if (bucket && !bucket.has(u.type_prop)) score += 120;
130 + if (areaM2 && u.aire_etages_m2 && u.aire_etages_m2 > 10)
131 + score += Math.min(1.5, Math.abs(areaM2 - u.aire_etages_m2) / u.aire_etages_m2) * 150;
132 + if (l.year_built && u.annee_construction && u.annee_construction > 1500)
133 + score += Math.min(50, Math.abs(l.year_built - u.annee_construction)) * 1.5;
134 + if (!best || score < best.score) best = { u, dist, score };
135 + }
136 + if (best) return { u: best.u, dist: best.dist };
137 + }
138 + return null;
139 +}
140 +
141 +/* --------------- méthodes additionnelles : coût & rôle indexé --------------- */
142 +/**
143 + * Méthode du coût, calibrée sur le marché par la technique du
144 + * résiduel : terrain au marché = valeur_terrain(rôle) × ratio de vente médian
145 + * du type ; bâtiment = aire d'étages × coût unitaire net médian $/m², lu dans
146 + * les ventes des 18 derniers mois (résiduel = prix ajusté au marché − terrain)
147 + * par type × tranche d'âge — la dépréciation est donc observée, pas imposée.
148 + * Méthode du rôle indexé (étude de ratios IAAO) : valeur = valeur_role ×
149 + * ratio médian vente/rôle de la municipalité (repli : province), par type.
150 + */
151 +const AGE_BANDS = [5, 15, 30, 50, 75, Infinity];
152 +const ageBand = (age: number | null): string => {
153 + if (age == null || age < 0) return "?";
154 + return String(AGE_BANDS.find((b) => age <= b));
155 +};
156 +
157 +interface CalibTx {
158 + amount: number;
159 + date: string;
160 + type_prop: string;
161 + municipalite: string | null;
162 + valeur_terrain: number | null;
163 + aire_etages_m2: number | null;
164 + annee_construction: number | null;
165 + valeur_role: number | null;
166 +}
167 +const nowYear = new Date().getFullYear();
168 +const idxByMonth = (bucket: string) => {
169 + const m = new Map<string, number>();
170 + for (const p of getMarketIndex(bucket)) m.set(p.month, p.idx);
171 + return m;
172 +};
173 +const calib = (() => {
174 + const txs = getDb()
175 + .prepare(
176 + `SELECT t.amount, t.date, u.type_prop, u.municipalite, u.valeur_terrain,
177 + u.aire_etages_m2, u.annee_construction, u.valeur_role
178 + FROM transactions t JOIN units u ON u.id_provinc = t.id_provinc
179 + WHERE t.date >= ? AND t.amount BETWEEN 50000 AND 5000000`
180 + )
181 + .all(since(18)) as CalibTx[];
182 + const idx: Record<string, Map<string, number>> = {
183 + unifamilial: idxByMonth("unifamilial"),
184 + plex: idxByMonth("plex"),
185 + condo: idxByMonth("condo"),
186 + };
187 + const adj = (t: CalibTx) => {
188 + const i = idx[indexType(t.type_prop)]?.get(t.date.slice(0, 7));
189 + return i && i > 0.3 ? t.amount / i : t.amount;
190 + };
191 + const med = (xs: number[]) => {
192 + const s = [...xs].sort((x, y) => x - y);
193 + const m = Math.floor(s.length / 2);
194 + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
195 + };
196 + // --- rôle indexé : ratio médian vente/rôle par municipalité×type (n≥12)
197 + const groups = new Map<string, number[]>();
198 + for (const t of txs) {
199 + if (!t.valeur_role || t.valeur_role < 10000) continue;
200 + const r = adj(t) / t.valeur_role;
201 + if (r < 0.3 || r > 4) continue;
202 + const bucket = indexType(t.type_prop);
203 + for (const k of [`${t.municipalite ?? "?"}|${bucket}`, `*|${bucket}`]) {
204 + if (!groups.has(k)) groups.set(k, []);
205 + groups.get(k)!.push(r);
206 + }
207 + }
208 + const roleRatio = new Map<string, number>();
209 + for (const [k, rs] of groups) if (rs.length >= 12) roleRatio.set(k, med(rs));
210 +
211 + // --- coût : terrain × r* + résiduel médian $/m² par type × tranche d'âge
212 + const landFactor = new Map<string, number>();
213 + for (const bucket of ["unifamilial", "plex", "condo"]) {
214 + const r = roleRatio.get(`*|${bucket}`);
215 + if (r) landFactor.set(bucket, r);
216 + }
217 + const resid = new Map<string, number[]>();
218 + for (const t of txs) {
219 + if (!t.valeur_terrain || t.valeur_terrain <= 1000) continue;
220 + if (!t.aire_etages_m2 || t.aire_etages_m2 <= 20) continue;
221 + const bucket = indexType(t.type_prop);
222 + const lf = landFactor.get(bucket);
223 + if (!lf) continue;
224 + const rpm2 = (adj(t) - lf * t.valeur_terrain) / t.aire_etages_m2;
225 + if (rpm2 < -1000 || rpm2 > 10000) continue;
226 + const band = ageBand(t.annee_construction && t.annee_construction > 1500 ? nowYear - t.annee_construction : null);
227 + for (const k of [`${bucket}|${band}`, `${bucket}|*`]) {
228 + if (!resid.has(k)) resid.set(k, []);
229 + resid.get(k)!.push(rpm2);
230 + }
231 + }
232 + const costUnit = new Map<string, number>();
233 + for (const [k, rs] of resid) if (rs.length >= 150) costUnit.set(k, med(rs));
234 + console.log(
235 + `[shard ${SHARD}] calibration : ${txs.length} ventes 18 mois · coût ${[...costUnit.entries()]
236 + .filter(([k]) => k.endsWith("|*"))
237 + .map(([k, v]) => `${k.split("|")[0]} ${v.toFixed(0)}$/m²`)
238 + .join(" · ")} (${costUnit.size} cellules) · ${roleRatio.size} ratios rôle`
239 + );
240 + return { costUnit, landFactor, roleRatio };
241 +})();
242 +
243 +function costEstimate(u: UnitRow): number | null {
244 + const bucket = indexType(u.type_prop);
245 + const lf = calib.landFactor.get(bucket);
246 + if (!lf || !u.valeur_terrain || u.valeur_terrain <= 1000) return null;
247 + if (!u.aire_etages_m2 || u.aire_etages_m2 <= 20) return null;
248 + const age = u.annee_construction && u.annee_construction > 1500 ? nowYear - u.annee_construction : null;
249 + const cpm2 = calib.costUnit.get(`${bucket}|${ageBand(age)}`) ?? calib.costUnit.get(`${bucket}|*`);
250 + if (cpm2 == null) return null;
251 + const v = lf * u.valeur_terrain + cpm2 * u.aire_etages_m2;
252 + return v > 0 ? Math.round(v / 100) * 100 : null;
253 +}
254 +function roleEstimate(u: UnitRow): number | null {
255 + if (!u.valeur_role || u.valeur_role < 10000) return null;
256 + const bucket = indexType(u.type_prop);
257 + const r = calib.roleRatio.get(`${u.municipalite ?? "?"}|${bucket}`) ?? calib.roleRatio.get(`*|${bucket}`);
258 + return r ? Math.round((u.valeur_role * r) / 100) * 100 : null;
259 +}
260 +const medianOf = (xs: number[]) => {
261 + const s = [...xs].sort((a, b) => a - b);
262 + const m = Math.floor(s.length / 2);
263 + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
264 +};
265 +
266 +/* ---------------------------------- lot ---------------------------------- */
267 +const immo = new Database(IMMOKA_DB, { readonly: true, fileMustExist: true });
268 +const rows = immo
269 + .prepare(
270 + `SELECT uid, price, lat, lng, property_type, area_sqft, year_built FROM listings
271 + WHERE status='a-vendre' AND published=1 AND active=1 AND dup_hidden=0
272 + AND price > 0 AND lat IS NOT NULL AND lng IS NOT NULL
273 + AND (rowid % ?) = ?
274 + ${LIMIT ? "LIMIT " + LIMIT : ""}`
275 + )
276 + .all(SHARDS, SHARD) as ListingRow[];
277 +
278 +fs.mkdirSync(OUT_DIR, { recursive: true });
279 +const outPath = path.join(OUT_DIR, `shard-${SHARD}.jsonl`);
280 +const out = fs.createWriteStream(outPath);
281 +
282 +let done = 0;
283 +let noUnit = 0;
284 +const t0 = Date.now();
285 +for (const r of rows) {
286 + const m = matchUnit(r);
287 + if (!m) {
288 + noUnit++;
289 + continue;
290 + }
291 + const u = m.u;
292 + const subject: Subject = {
293 + lat: u.lat,
294 + lng: u.lng,
295 + typeProp: u.type_prop,
296 + floorArea: u.aire_etages_m2,
297 + yearBuilt: u.annee_construction,
298 + landArea: u.superficie_terrain_m2,
299 + modelEstimate: u.est_2026,
300 + modelP10: u.p10,
301 + modelP90: u.p90,
302 + };
303 + const res = estimate(subject, toComps(candidatesAround(u.lat, u.lng)), idxFor(u.type_prop), nowISO);
304 + // méthodes complémentaires (la mesure principale reste l'hybride 65/35)
305 + const cost = costEstimate(u);
306 + const ridx = roleEstimate(u);
307 + const parts = [res.estimate, cost, ridx].filter((v): v is number => v != null && v > 0);
308 + const ens = parts.length ? Math.round(medianOf(parts)) : null;
309 + out.write(
310 + JSON.stringify({
311 + uid: r.uid,
312 + unit_id: u.id_provinc,
313 + match_m: Math.round(m.dist * 10) / 10,
314 + price: r.price,
315 + est: res.estimate,
316 + low: res.low,
317 + high: res.high,
318 + cpct: res.confidencePct,
319 + clevel: res.confidenceLevel,
320 + model: res.modelEstimate,
321 + comps: res.compsEstimate,
322 + mw: res.modelWeight,
323 + ncomps: res.nCompsUsed,
324 + cost,
325 + ridx,
326 + ens,
327 + role: u.valeur_role,
328 + type: u.type_prop,
329 + muni: u.municipalite,
330 + }) + "\n"
331 + );
332 + done++;
333 + if (done % 2000 === 0) {
334 + const rate = done / ((Date.now() - t0) / 1000);
335 + console.log(`[shard ${SHARD}] ${done}/${rows.length} (${rate.toFixed(0)}/s)`);
336 + }
337 +}
338 +out.end();
339 +console.log(
340 + `[shard ${SHARD}] terminé : ${done} évaluées, ${noUnit} sans unité à ≤250 m, ` +
341 + `${((Date.now() - t0) / 1000).toFixed(0)} s → ${outPath}`
342 +);
added scripts/immoka-sync.sh +98 −0
@@ -0,0 +1,98 @@
1 +#!/bin/zsh -l
2 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +# scripts/immoka-sync.sh — rafraîchissement nocturne des propriétés à vendre.
4 +#
5 +# 1. instantané cohérent de la base Immo-Ka (sqlite3 .backup) sur le nœud
6 +# hôte d'Immo-Ka (M4M64b, LAN — SSH inter-nœuds via Tailscale bloqué) ;
7 +# 2. rapatriement rsync (delta contre la copie précédente) → data/immoka.db.new ;
8 +# 3. ré-évaluation de toutes les annonces vivantes par le moteur Vrai-Prix
9 +# (scripts/run-eval-shards.sh, N shards parallèles) → table vp_eval ;
10 +# 4. agrégats marché → data/marche-stats.json (lu à chaud par /stats et
11 +# /stats/marche via src/lib/marche-stats.ts — pas de rebuild) ;
12 +# 5. index + FTS5 pré-construits (src/lib/immoka.ts) ;
13 +# 6. bascule atomique data/immoka.db.new → data/immoka.db, puis pm2 restart
14 +# (le handle better-sqlite3 du serveur est un singleton : sans restart il
15 +# continuerait de lire l'ancien inode).
16 +#
17 +# Usage (racine du dépôt, sur le nœud) :
18 +# scripts/immoka-sync.sh # complet
19 +# SKIP_FETCH=1 scripts/immoka-sync.sh # réutilise data/immoka.db.new déjà téléchargé
20 +# SHARDS=24 scripts/immoka-sync.sh # nombre de processus d'évaluation (défaut : cœurs − 4)
21 +# NO_RESTART=1 … # ne redémarre pas PM2 (tests)
22 +#
23 +# Variables : IMMOKA_HOST (défaut MacStudio-de-simon-pierre-4.local = M4M64b),
24 +# IMMOKA_REMOTE_DB (défaut ~/apps/immo-ka/data/immoka.db),
25 +# IMMOKA_SSH_KEY (défaut ~/.ssh/ka_guardian_ed25519, autorisée sur M4M64b),
26 +# PM2_APP (défaut vrai-prix).
27 +set -u
28 +set -o pipefail
29 +cd "$(dirname "$0")/.."
30 +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
31 +
32 +IMMOKA_HOST=${IMMOKA_HOST:-MacStudio-de-simon-pierre-4.local}
33 +IMMOKA_REMOTE_DB=${IMMOKA_REMOTE_DB:-/Users/simon-pierreboucher/apps/immo-ka/data/immoka.db}
34 +IMMOKA_SSH_KEY=${IMMOKA_SSH_KEY:-$HOME/.ssh/ka_guardian_ed25519}
35 +PM2_APP=${PM2_APP:-vrai-prix}
36 +NCPU=$(sysctl -n hw.ncpu 2>/dev/null || echo 8)
37 +SHARDS=${SHARDS:-$(( NCPU > 8 ? NCPU - 4 : 4 ))}
38 +LIVE=data/immoka.db
39 +NEW=data/immoka.db.new
40 +SNAP=/tmp/immoka-vp-snap.db
41 +LOG=tmp/immoka-sync.log
42 +mkdir -p data tmp
43 +T0=$(date +%s)
44 +log() { local m="[$(date '+%Y-%m-%d %H:%M:%S')] $*"; echo "$m"; echo "$m" >> "$LOG"; }
45 +die() { log "ÉCHEC : $*"; exit 1; }
46 +
47 +SSH_OPTS=(-i "$IMMOKA_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=15
48 + -o StrictHostKeyChecking=accept-new -o ControlMaster=no -o ControlPath=none)
49 +REMOTE="simon-pierreboucher@$IMMOKA_HOST"
50 +
51 +log "=== immoka-sync — hôte $IMMOKA_HOST · shards $SHARDS ==="
52 +
53 +if [[ "${SKIP_FETCH:-0}" != "1" ]]; then
54 + # 1. instantané cohérent côté Immo-Ka (la base est chaude : WAL en écriture continue)
55 + log "1/6 instantané distant (.backup)…"
56 + ssh "${SSH_OPTS[@]}" "$REMOTE" "rm -f $SNAP && /usr/bin/sqlite3 '$IMMOKA_REMOTE_DB' '.backup $SNAP' && ls -l $SNAP" >> "$LOG" 2>&1 \
57 + || die "instantané distant impossible (SSH LAN vers $IMMOKA_HOST ?)"
58 +
59 + # 2. rapatriement (delta contre la copie précédente si elle existe)
60 + log "2/6 rsync → $NEW…"
61 + [[ -f "$LIVE" && ! -f "$NEW" ]] && cp "$LIVE" "$NEW"
62 + rsync -a --inplace --no-whole-file -e "ssh ${SSH_OPTS[*]}" "$REMOTE:$SNAP" "$NEW" >> "$LOG" 2>&1 \
63 + || die "rsync de l'instantané"
64 + ssh "${SSH_OPTS[@]}" "$REMOTE" "rm -f $SNAP" >> "$LOG" 2>&1
65 + rm -f "$NEW-wal" "$NEW-shm"
66 +fi
67 +[[ -f "$NEW" ]] || die "$NEW absent"
68 +
69 +N_LIVE=$(/usr/bin/sqlite3 "$NEW" "select count(*) from listings where status='a-vendre' and published=1 and active=1 and dup_hidden=0 and price>0" 2>/dev/null || echo 0)
70 +log " annonces vivantes dans l'instantané : $N_LIVE"
71 +(( N_LIVE > 10000 )) || die "instantané suspect ($N_LIVE annonces vivantes) — bascule annulée"
72 +
73 +# 3. ré-évaluation par le moteur (shards parallèles) → tmp/eval-shards/*.jsonl
74 +log "3/6 évaluation de $N_LIVE annonces ($SHARDS shards)…"
75 +IMMOKA_DB="$PWD/$NEW" SHARDS=$SHARDS scripts/run-eval-shards.sh >> "$LOG" 2>&1 || die "run-eval-shards"
76 +N_EVAL=$(cat tmp/eval-shards/*.jsonl 2>/dev/null | wc -l | tr -d ' ')
77 +log " $N_EVAL évaluations produites"
78 +(( N_EVAL > 10000 )) || die "trop peu d'évaluations ($N_EVAL) — bascule annulée"
79 +
80 +# 4. table vp_eval + agrégats marché (fichier lu à chaud par le site)
81 +log "4/6 table vp_eval + data/marche-stats.json…"
82 +IMMOKA_DB="$PWD/$NEW" MARCHE_STATS_OUT="$PWD/data/marche-stats.json.new" node scripts/build-marche-stats.mjs >> "$LOG" 2>&1 || die "build-marche-stats"
83 +
84 +# 5. index + FTS5 (sinon la première requête du site les construirait)
85 +log "5/6 index + FTS5…"
86 +IMMOKA_DB="$PWD/$NEW" npx tsx -e "import('./src/lib/immoka.ts').then((m) => { m.getImmoDb(); console.log('index ok'); })" >> "$LOG" 2>&1 || die "index/FTS"
87 +
88 +# 6. bascule atomique + restart
89 +log "6/6 bascule → $LIVE + restart $PM2_APP…"
90 +mv -f data/marche-stats.json.new data/marche-stats.json
91 +rm -f "$LIVE-wal" "$LIVE-shm"
92 +mv -f "$NEW" "$LIVE"
93 +rm -f "$NEW-wal" "$NEW-shm"
94 +if [[ "${NO_RESTART:-0}" != "1" ]] && command -v pm2 >/dev/null 2>&1; then
95 + pm2 restart "$PM2_APP" --update-env >> "$LOG" 2>&1 || log "avertissement : pm2 restart $PM2_APP a échoué"
96 +fi
97 +rm -f tmp/eval-shards/*.jsonl
98 +log "=== terminé en $(( $(date +%s) - T0 )) s — $N_LIVE annonces, $N_EVAL évaluées ==="
added scripts/run-eval-shards.sh +17 −0
@@ -0,0 +1,17 @@
1 +#!/bin/zsh -l
2 +# scripts/run-eval-shards.sh — évalue en lot les annonces Immo-Ka (data/immoka.db)
3 +# avec le moteur Vrai-Prix, en N processus parallèles (défaut : 6 shards).
4 +# Portable : s'exécute depuis la racine du dépôt (laptop ou nœud).
5 +# SHARDS=8 scripts/run-eval-shards.sh
6 +# Ensuite : node scripts/build-marche-stats.mjs (table vp_eval + marche-stats.json).
7 +set -u
8 +cd "$(dirname "$0")/.."
9 +N=${SHARDS:-6}
10 +mkdir -p tmp/eval-shards
11 +rm -f tmp/eval-shards/*.jsonl tmp/eval-shards/log-*.txt
12 +for ((i = 0; i < N; i++)); do
13 + SHARDS=$N SHARD=$i npx tsx scripts/eval-immoka.ts > tmp/eval-shards/log-$i.txt 2>&1 &
14 +done
15 +wait
16 +tail -n1 tmp/eval-shards/log-*.txt
17 +wc -l tmp/eval-shards/*.jsonl
modified src/app/HomeClient.tsx +32 −0
@@ -212,6 +212,38 @@ export default function HomeClient({
212 212 </div>
213 213 </section>
214 214
215 + {/* ================= ATELIERS — à vendre + méthode du coût ================= */}
216 + <section className="mt-8 grid gap-4 lg:grid-cols-2">
217 + <Link href="/a-vendre" className="vp-card vp-card-hover flex flex-col border-l-4 border-l-[var(--accent)] p-5">
218 + <span className="kicker">{fr ? "Nouveau — maisons à vendre" : "New — homes for sale"}</span>
219 + <h2 className="vp-display mt-2 text-[20px] font-bold uppercase tracking-[-0.02em]">
220 + {fr ? "Évaluer une propriété réellement à vendre" : "Value a property actually for sale"}
221 + </h2>
222 + <p className="mt-2 flex-1 text-[13.5px] leading-relaxed text-ink-2">
223 + {fr
224 + ? "Plus de 70 000 propriétés à vendre au Québec (base Immo-Ka), chacune déjà mesurée par le moteur Vrai-Prix. Ouvrez une fiche, comparez la mesure au prix demandé, puis choisissez vos propres comparables et ajustez-les en dollars."
225 + : "Over 70,000 properties for sale in Québec (Immo-Ka database), each already measured by the Vrai-Prix engine. Open a listing, compare the measure with the asking price, then pick your own comparables and adjust them in dollars."}
226 + </p>
227 + <span className="vp-mono mt-3 text-[11px] font-bold uppercase tracking-[0.08em] text-accent">
228 + {fr ? "Voir les propriétés à vendre" : "See properties for sale"} →
229 + </span>
230 + </Link>
231 + <Link href="/cout" className="vp-card vp-card-hover flex flex-col border-l-4 border-l-[var(--accent-bright)] p-5">
232 + <span className="kicker">{fr ? "Nouveau — méthode du coût" : "New — cost approach"}</span>
233 + <h2 className="vp-display mt-2 text-[20px] font-bold uppercase tracking-[-0.02em]">
234 + {fr ? "Combien coûterait ce bâtiment à reconstruire ?" : "What would this building cost to rebuild?"}
235 + </h2>
236 + <p className="mt-2 flex-1 text-[13.5px] leading-relaxed text-ink-2">
237 + {fr
238 + ? "Coût de remplacement à neuf décomposé par assemblages — matériaux sourcés (Canac, BMR, Patrick Morin), heures de métier aux taux APCHQ/CCQ, indices StatCan, facteurs régionaux —, puis dépréciation et valeur du terrain. Chaque hypothèse et chaque source sont visibles."
239 + : "Replacement cost new broken down by assemblies — sourced materials (Canac, BMR, Patrick Morin), trade hours at APCHQ/CCQ rates, StatCan indexes, regional factors —, then depreciation and land value. Every assumption and every source is visible."}
240 + </p>
241 + <span className="vp-mono mt-3 text-[11px] font-bold uppercase tracking-[0.08em] text-accent">
242 + {fr ? "Explorer les coûts" : "Explore costs"} →
243 + </span>
244 + </Link>
245 + </section>
246 +
215 247 {/* ================= MARCHÉ — data visualization éditoriale ================= */}
216 248 <section className="mt-12">
217 249 <SectionHead
added src/app/a-vendre/AVendreClient.tsx +302 −0
@@ -0,0 +1,302 @@
1 +// Vrai-Prix — onglet « À vendre » : recherche, filtres et grille d'annonces.
2 +"use client";
3 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4 +import Link from "next/link";
5 +import { usePathname, useRouter, useSearchParams } from "next/navigation";
6 +import { useLang } from "@/components/LangContext";
7 +import ListingCardView, { groupLabel } from "@/components/avendre/ListingCardView";
8 +import { money, num, pct } from "@/components/avendre/fmt";
9 +import type { AVendreOverview, SearchResult, SortKey, TypeGroupKey } from "@/lib/immoka";
10 +
11 +const SORT_LABELS: Record<SortKey, { fr: string; en: string }> = {
12 + recent: { fr: "Plus récentes", en: "Newest" },
13 + price_asc: { fr: "Prix croissant", en: "Price ↑" },
14 + price_desc: { fr: "Prix décroissant", en: "Price ↓" },
15 + deal: { fr: "Estimation ≫ prix (aubaines ?)", en: "Estimate ≫ price (bargains?)" },
16 + premium: { fr: "Prix ≫ estimation (primes ?)", en: "Price ≫ estimate (premiums?)" },
17 + est_desc: { fr: "Estimation décroissante", en: "Estimate ↓" },
18 +};
19 +
20 +interface Filters {
21 + q: string;
22 + muni: string;
23 + group: TypeGroupKey | "";
24 + min: string;
25 + max: string;
26 + beds: string;
27 + eval: boolean;
28 + sort: SortKey;
29 + page: number;
30 +}
31 +
32 +function readFilters(sp: URLSearchParams): Filters {
33 + return {
34 + q: sp.get("q") ?? "",
35 + muni: sp.get("muni") ?? "",
36 + group: (sp.get("group") as TypeGroupKey | null) ?? "",
37 + min: sp.get("min") ?? "",
38 + max: sp.get("max") ?? "",
39 + beds: sp.get("beds") ?? "",
40 + eval: sp.get("eval") === "1",
41 + sort: (sp.get("sort") as SortKey | null) ?? "recent",
42 + page: Math.max(1, Number(sp.get("page") ?? 1) || 1),
43 + };
44 +}
45 +
46 +function toQuery(f: Filters): string {
47 + const sp = new URLSearchParams();
48 + if (f.q) sp.set("q", f.q);
49 + if (f.muni) sp.set("muni", f.muni);
50 + if (f.group) sp.set("group", f.group);
51 + if (f.min) sp.set("min", f.min);
52 + if (f.max) sp.set("max", f.max);
53 + if (f.beds) sp.set("beds", f.beds);
54 + if (f.eval) sp.set("eval", "1");
55 + if (f.sort !== "recent") sp.set("sort", f.sort);
56 + if (f.page > 1) sp.set("page", String(f.page));
57 + return sp.toString();
58 +}
59 +
60 +function Kpi({ label, value, hint }: { label: string; value: string; hint?: string }) {
61 + return (
62 + <div className="border-t border-[var(--line)] py-3">
63 + <p className="klabel">{label}</p>
64 + <p className="vp-display mt-1 text-[24px] font-bold leading-none tracking-[-0.02em]">{value}</p>
65 + {hint && <p className="mt-1 text-[11.5px] leading-snug text-ink-3">{hint}</p>}
66 + </div>
67 + );
68 +}
69 +
70 +export default function AVendreClient({ overview }: { overview: AVendreOverview }) {
71 + const { lang } = useLang();
72 + const fr = lang === "fr";
73 + const router = useRouter();
74 + const pathname = usePathname();
75 + const sp = useSearchParams();
76 + const [f, setF] = useState<Filters>(() => readFilters(new URLSearchParams(sp.toString())));
77 + // résultat + requête qui l'a produit : « en chargement » = la requête courante n'est pas encore servie
78 + const [res, setRes] = useState<(SearchResult & { qs: string }) | null>(null);
79 + const loading = res == null || res.qs !== toQuery(f);
80 + const [qDraft, setQDraft] = useState(f.q);
81 + const abortRef = useRef<AbortController | null>(null);
82 +
83 + // URL ↔ état (partageable, retour arrière naturel)
84 + const apply = useCallback(
85 + (patch: Partial<Filters>, resetPage = true) => {
86 + setF((prev) => {
87 + const next = { ...prev, ...patch, page: resetPage ? 1 : (patch.page ?? prev.page) };
88 + const qs = toQuery(next);
89 + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
90 + return next;
91 + });
92 + },
93 + [router, pathname]
94 + );
95 +
96 + // recherche texte : débounce 350 ms
97 + useEffect(() => {
98 + const id = setTimeout(() => {
99 + if (qDraft !== f.q) apply({ q: qDraft });
100 + }, 350);
101 + return () => clearTimeout(id);
102 + }, [qDraft, f.q, apply]);
103 +
104 + useEffect(() => {
105 + abortRef.current?.abort();
106 + const ctl = new AbortController();
107 + abortRef.current = ctl;
108 + const qs = toQuery(f);
109 + fetch(`/api/avendre${qs ? `?${qs}` : ""}`, { signal: ctl.signal })
110 + .then((r) => r.json())
111 + .then((d: SearchResult) => {
112 + if (!ctl.signal.aborted) setRes({ ...d, qs });
113 + })
114 + .catch(() => {});
115 + return () => ctl.abort();
116 + }, [f]);
117 +
118 + const pages = res ? Math.max(1, Math.ceil(res.total / res.per)) : 1;
119 + const groups = useMemo(() => res?.facets.groups ?? overview.groups, [res, overview]);
120 + const munis = res?.facets.munis ?? overview.topMunis;
121 +
122 + return (
123 + <div className="space-y-10 py-8">
124 + {/* ================= masthead ================= */}
125 + <section className="rise">
126 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">
127 + <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
128 + <Link href="/" className="text-ink-2 hover:text-accent-deep">
129 + Vrai&nbsp;Prix
130 + </Link>
131 + <span aria-hidden="true">/</span>
132 + <span>{fr ? "À vendre" : "For sale"}</span>
133 + </nav>
134 + <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">
135 + {fr ? "Source : Immo-Ka · copie du" : "Source: Immo-Ka · snapshot of"} {overview.copiedAt ?? "—"}
136 + </span>
137 + </div>
138 + <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.6fr_1fr]">
139 + <div>
140 + <span className="kicker">{fr ? "Atelier d'évaluation" : "Valuation workshop"}</span>
141 + <h1 className="vp-display mt-2 text-[clamp(28px,4.5vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">
142 + {fr ? "Évaluer une propriété à vendre" : "Value a property for sale"}
143 + </h1>
144 + <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2">
145 + {fr
146 + ? "Toutes les propriétés réellement à vendre au Québec, déjà mesurées par le moteur Vrai-Prix. Choisissez-en une, sélectionnez vos propres comparables, ajustez-les en dollars, réconciliez votre valeur — puis confrontez-la à la mesure Vrai-Prix et au prix demandé."
147 + : "Every property actually for sale in Québec, already measured by the Vrai-Prix engine. Pick one, select your own comparables, adjust them in dollars, reconcile your value — then confront it with the Vrai-Prix measure and the asking price."}
148 + </p>
149 + </div>
150 + <aside className="grid grid-cols-2 gap-x-6 lg:grid-cols-1 lg:border-l lg:border-[var(--line)] lg:pl-10">
151 + <Kpi label={fr ? "Propriétés à vendre" : "Properties for sale"} value={num(overview.total, lang)} />
152 + <Kpi
153 + label={fr ? "Déjà évaluées par le moteur" : "Already valued by the engine"}
154 + value={num(overview.evaluated, lang)}
155 + hint={fr ? "jumelage spatial au rôle d'évaluation" : "spatially matched to the assessment roll"}
156 + />
157 + <Kpi label={fr ? "Prix demandé médian" : "Median asking price"} value={money(overview.medianPrice, lang)} />
158 + <Kpi
159 + label={fr ? "Ratio médian estimation / prix" : "Median estimate / price ratio"}
160 + value={overview.medianRatio != null ? overview.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 3 }) : "—"}
161 + hint={fr ? "< 1 : le marché demande plus que la mesure" : "< 1: sellers ask more than the measure"}
162 + />
163 + </aside>
164 + </div>
165 + </section>
166 +
167 + {/* ================= filtres ================= */}
168 + <section className="space-y-4">
169 + <div className="grid gap-4 md:grid-cols-[2fr_1fr_1fr]">
170 + <label className="block">
171 + <span className="klabel">{fr ? "Adresse, ville, secteur, n° MLS" : "Address, city, sector, MLS #"}</span>
172 + <input
173 + value={qDraft}
174 + onChange={(e) => setQDraft(e.target.value)}
175 + placeholder={fr ? "ex. Gatineau, rue Principale, Sherbrooke…" : "e.g. Gatineau, Main street, Sherbrooke…"}
176 + className="vp-input mt-1"
177 + type="search"
178 + />
179 + </label>
180 + <label className="block">
181 + <span className="klabel">{fr ? "Municipalité" : "Municipality"}</span>
182 + <select value={f.muni} onChange={(e) => apply({ muni: e.target.value })} className="vp-input mt-1 cursor-pointer">
183 + <option value="">{fr ? "Toutes" : "All"}</option>
184 + {f.muni && !munis.some((m) => m.name === f.muni) && <option value={f.muni}>{f.muni}</option>}
185 + {munis.map((m) => (
186 + <option key={m.name} value={m.name}>
187 + {m.name} ({num(m.n, lang)})
188 + </option>
189 + ))}
190 + </select>
191 + </label>
192 + <label className="block">
193 + <span className="klabel">{fr ? "Tri" : "Sort"}</span>
194 + <select value={f.sort} onChange={(e) => apply({ sort: e.target.value as SortKey })} className="vp-input mt-1 cursor-pointer">
195 + {(Object.keys(SORT_LABELS) as SortKey[]).map((k) => (
196 + <option key={k} value={k}>
197 + {fr ? SORT_LABELS[k].fr : SORT_LABELS[k].en}
198 + </option>
199 + ))}
200 + </select>
201 + </label>
202 + </div>
203 +
204 + {/* groupes de types */}
205 + <div className="flex flex-wrap gap-x-4 gap-y-1 border-b border-[var(--line)]">
206 + <button type="button" className="vp-tab" aria-pressed={f.group === ""} onClick={() => apply({ group: "" })}>
207 + {fr ? "Tous" : "All"}
208 + </button>
209 + {groups.map((g) => (
210 + <button key={g.key} type="button" className="vp-tab" aria-pressed={f.group === g.key} onClick={() => apply({ group: g.key })}>
211 + {groupLabel(g.key, fr)} <span className="text-ink-3">{num(g.n, lang)}</span>
212 + </button>
213 + ))}
214 + </div>
215 +
216 + <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-4">
217 + <label className="block">
218 + <span className="klabel">{fr ? "Prix min ($)" : "Min price ($)"}</span>
219 + <input value={f.min} onChange={(e) => apply({ min: e.target.value.replace(/\D/g, "") })} inputMode="numeric" className="vp-input mt-1" placeholder="0" />
220 + </label>
221 + <label className="block">
222 + <span className="klabel">{fr ? "Prix max ($)" : "Max price ($)"}</span>
223 + <input value={f.max} onChange={(e) => apply({ max: e.target.value.replace(/\D/g, "") })} inputMode="numeric" className="vp-input mt-1" placeholder="∞" />
224 + </label>
225 + <label className="block">
226 + <span className="klabel">{fr ? "Chambres (min)" : "Bedrooms (min)"}</span>
227 + <select value={f.beds} onChange={(e) => apply({ beds: e.target.value })} className="vp-input mt-1 cursor-pointer">
228 + <option value="">—</option>
229 + {[1, 2, 3, 4, 5].map((n) => (
230 + <option key={n} value={n}>
231 + {n}+
232 + </option>
233 + ))}
234 + </select>
235 + </label>
236 + <label className="flex items-end gap-2 pb-2 text-[13px]">
237 + <input type="checkbox" checked={f.eval} onChange={(e) => apply({ eval: e.target.checked })} className="h-4 w-4 accent-[var(--accent)]" />
238 + {fr ? "Seulement les annonces déjà évaluées" : "Only listings already valued"}
239 + </label>
240 + </div>
241 + </section>
242 +
243 + {/* ================= résultats ================= */}
244 + <section>
245 + <div className="flex flex-wrap items-baseline justify-between gap-2 border-b-2 border-ink pb-2">
246 + <p className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">
247 + {res ? (
248 + <>
249 + <b className="text-ink">{num(res.total, lang)}</b> {fr ? "propriétés" : "properties"}
250 + {res.summary.medianPrice != null && (
251 + <>
252 + {" · "}
253 + {fr ? "prix médian" : "median price"} <b className="text-ink">{money(res.summary.medianPrice, lang)}</b>
254 + </>
255 + )}
256 + {res.summary.medianRatio != null && (
257 + <>
258 + {" · "}
259 + {fr ? "estimation vs prix (médiane)" : "estimate vs price (median)"}{" "}
260 + <b className="text-ink">{pct((res.summary.medianRatio - 1) * 100, lang, 1)}</b>
261 + </>
262 + )}
263 + </>
264 + ) : (
265 + fr ? "Chargement…" : "Loading…"
266 + )}
267 + </p>
268 + <p className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
269 + {fr ? "page" : "page"} {f.page} / {pages}
270 + </p>
271 + </div>
272 +
273 + <div className={`mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 ${loading ? "opacity-60" : ""}`}>
274 + {res?.items.map((c) => <ListingCardView key={c.uid} c={c} fr={fr} />)}
275 + </div>
276 + {res && res.items.length === 0 && (
277 + <p className="py-16 text-center text-ink-3">{fr ? "Aucune propriété ne correspond à ces filtres." : "No property matches these filters."}</p>
278 + )}
279 +
280 + {pages > 1 && (
281 + <div className="mt-8 flex items-center justify-center gap-3">
282 + <button type="button" className="btn btn-ghost" disabled={f.page <= 1} onClick={() => apply({ page: f.page - 1 }, false)}>
283 + ← {fr ? "Précédent" : "Previous"}
284 + </button>
285 + <span className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">
286 + {f.page} / {pages}
287 + </span>
288 + <button type="button" className="btn btn-ghost" disabled={f.page >= pages} onClick={() => apply({ page: f.page + 1 }, false)}>
289 + {fr ? "Suivant" : "Next"} →
290 + </button>
291 + </div>
292 + )}
293 + </section>
294 +
295 + <p className="vp-mono border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">
296 + {fr
297 + ? "Annonces : copie de la base Immo-Ka (groupe Ka), agrégées de sources publiques ; photos et descriptions appartiennent aux courtiers et vendeurs. Estimations Vrai-Prix à titre indicatif — pas une évaluation professionnelle (OEAQ)."
298 + : "Listings: copy of the Immo-Ka database (Ka group), aggregated from public sources; photos and descriptions belong to brokers and sellers. Vrai-Prix estimates are educational — not a professional appraisal (OEAQ)."}
299 + </p>
300 + </div>
301 + );
302 +}
added src/app/a-vendre/[uid]/analyse-cout/page.tsx +38 −0
@@ -0,0 +1,38 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Analyse IA du bâtiment d'une annonce : photos → profil technique (Claude Sonnet 5)
3 +// → assemblages → coûts actuels → méthode du coût. Déclenchée sur demande seulement.
4 +import type { Metadata } from "next";
5 +import { notFound } from "next/navigation";
6 +import { getListing } from "@/lib/immoka";
7 +import { getLatest } from "@/lib/cost/ai/service";
8 +import { moneyFr } from "@/lib/seo";
9 +import AiCostAnalysis, { type AiHead } from "@/components/avendre/cost/AiCostAnalysis";
10 +
11 +export const dynamic = "force-dynamic";
12 +
13 +function head(uid: string): AiHead | null {
14 + const det = getListing(uid);
15 + if (!det) return null;
16 + const l = det.listing;
17 + return {
18 + uid: l.uid, address: l.address, city: l.city, propertyType: l.property_type, price: l.price, images: det.images, source: l.source, yearBuilt: l.year_built, areaSqft: l.area_sqft,
19 + unitId: det.eval?.unit_id ?? null, evalEst: det.eval?.est ?? null, modelEst: det.eval?.model_est ?? null, compsEst: det.eval?.comps_est ?? null, roleValue: det.eval?.valeur_role ?? null,
20 + maxImages: Number(process.env.PROPERTY_ANALYSIS_MAX_IMAGES ?? 40), model: process.env.PROPERTY_ANALYSIS_MODEL ?? "claude-sonnet-5",
21 + };
22 +}
23 +
24 +export async function generateMetadata({ params }: { params: Promise<{ uid: string }> }): Promise<Metadata> {
25 + const { uid } = await params;
26 + const h = head(decodeURIComponent(uid));
27 + if (!h) return { title: "Propriété introuvable" };
28 + const where = [h.address, h.city].filter(Boolean).join(", ");
29 + return { title: `Analyse IA du bâtiment — ${where} (${moneyFr(h.price)})`, description: "Des photos à la méthode du coût : l'IA transforme une annonce réelle en profil technique de bâtiment ; les coûts actuels du marché sont ensuite appliqués composante par composante.", robots: { index: false, follow: false } };
30 +}
31 +
32 +export default async function Page({ params }: { params: Promise<{ uid: string }> }) {
33 + const { uid } = await params;
34 + const h = head(decodeURIComponent(uid));
35 + if (!h) notFound();
36 + const initial = getLatest(h.uid);
37 + return <AiCostAnalysis head={h} initial={initial} />;
38 +}
added src/app/a-vendre/[uid]/evaluer/page.tsx +68 −0
@@ -0,0 +1,68 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Atelier « mon évaluation » : l'utilisateur choisit ses comparables, les ajuste,
3 +// réconcilie sa valeur et la compare à la mesure Vrai-Prix et au prix demandé.
4 +import type { Metadata } from "next";
5 +import { notFound } from "next/navigation";
6 +import { getListing } from "@/lib/immoka";
7 +import EvalWorkbench, { type WorkbenchHead } from "@/components/avendre/EvalWorkbench";
8 +import { moneyFr } from "@/lib/seo";
9 +
10 +export const dynamic = "force-dynamic";
11 +
12 +function head(uid: string): WorkbenchHead | null {
13 + const det = getListing(uid);
14 + if (!det) return null;
15 + const l = det.listing;
16 + const e = det.eval;
17 + return {
18 + uid: l.uid,
19 + address: l.address,
20 + city: l.city,
21 + propertyType: l.property_type,
22 + group: det.group,
23 + price: l.price,
24 + image: det.images[0] ?? null,
25 + bedrooms: l.bedrooms,
26 + bathrooms: l.bathrooms,
27 + areaSqft: l.area_sqft,
28 + lotSqft: l.lot_sqft,
29 + yearBuilt: l.year_built,
30 + geolocated: l.lat != null && l.lng != null,
31 + eval: e
32 + ? {
33 + est: e.est,
34 + low: e.low,
35 + high: e.high,
36 + confidence: e.confidence,
37 + modelEst: e.model_est,
38 + compsEst: e.comps_est,
39 + costEst: e.cost_est,
40 + roleEst: e.role_est,
41 + ensEst: e.ens_est,
42 + valeurRole: e.valeur_role,
43 + nComps: e.n_comps,
44 + unitId: e.unit_id,
45 + evaluatedAt: e.evaluated_at,
46 + }
47 + : null,
48 + };
49 +}
50 +
51 +export async function generateMetadata({ params }: { params: Promise<{ uid: string }> }): Promise<Metadata> {
52 + const { uid } = await params;
53 + const h = head(decodeURIComponent(uid));
54 + if (!h) return { title: "Propriété introuvable" };
55 + const where = [h.address, h.city].filter(Boolean).join(", ");
56 + return {
57 + title: `Mon évaluation — ${where} (${moneyFr(h.price)})`,
58 + description: "Atelier : choisir ses comparables, les ajuster en dollars, réconcilier une valeur et la comparer à la mesure Vrai-Prix.",
59 + robots: { index: false, follow: false },
60 + };
61 +}
62 +
63 +export default async function EvaluerPage({ params }: { params: Promise<{ uid: string }> }) {
64 + const { uid } = await params;
65 + const h = head(decodeURIComponent(uid));
66 + if (!h) notFound();
67 + return <EvalWorkbench head={h} />;
68 +}
added src/app/a-vendre/[uid]/page.tsx +117 −0
@@ -0,0 +1,117 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Fiche complète d'une propriété à vendre (copie Immo-Ka) + mesure Vrai-Prix.
3 +import type { Metadata } from "next";
4 +import { cache } from "react";
5 +import { notFound } from "next/navigation";
6 +import { getUnit } from "@/lib/db";
7 +import { getListing, listingsNear } from "@/lib/immoka";
8 +import ListingView, { type ListingPageData } from "@/components/avendre/ListingView";
9 +import { moneyFr } from "@/lib/seo";
10 +import { summaryForListing } from "@/lib/cost/ai/service";
11 +
12 +export const dynamic = "force-dynamic";
13 +
14 +const load = cache((uid: string): ListingPageData | null => {
15 + const det = getListing(uid);
16 + if (!det) return null;
17 + const l = det.listing;
18 + const u = det.eval?.unit_id ? getUnit(det.eval.unit_id) : undefined;
19 + const nearby =
20 + l.lat != null && l.lng != null
21 + ? listingsNear(l.lat, l.lng, 3, { group: det.group, excludeUid: uid, limit: 6 })
22 + : [];
23 + return {
24 + listing: {
25 + uid: l.uid,
26 + source: l.source,
27 + url: l.url,
28 + title: l.title,
29 + address: l.address,
30 + sector: l.sector,
31 + city: l.city,
32 + region: l.region,
33 + propertyType: l.property_type,
34 + price: l.price,
35 + bedrooms: l.bedrooms,
36 + bathrooms: l.bathrooms,
37 + powderRooms: l.powder_rooms,
38 + areaSqft: l.area_sqft,
39 + lotSqft: l.lot_sqft,
40 + yearBuilt: l.year_built,
41 + mls: l.mls,
42 + brokerName: l.broker_name,
43 + agency: l.agency,
44 + description: l.description,
45 + lat: l.lat,
46 + lng: l.lng,
47 + firstSeen: l.first_seen,
48 + lastSeen: l.last_seen,
49 + },
50 + group: det.group,
51 + images: det.images,
52 + features: det.features,
53 + details: det.details,
54 + priceLog: det.priceLog,
55 + eval: det.eval,
56 + unit: u
57 + ? {
58 + id: u.id_provinc,
59 + adresse: u.adresse,
60 + apt: u.apt,
61 + municipalite: u.municipalite,
62 + typeProp: u.type_prop,
63 + anneeConstruction: u.annee_construction,
64 + aireEtagesM2: u.aire_etages_m2,
65 + superficieTerrainM2: u.superficie_terrain_m2,
66 + nbEtages: u.nb_etages,
67 + nbLogements: u.nb_logements,
68 + genreConstruction: u.genre_construction,
69 + lienPhysique: u.lien_physique,
70 + valeurTerrain: u.valeur_terrain,
71 + valeurBatiment: u.valeur_batiment,
72 + valeurRole: u.valeur_role,
73 + est2026: u.est_2026,
74 + p10: u.p10,
75 + p90: u.p90,
76 + history: ([2021, 2022, 2023, 2024, 2025, 2026] as const).map((y) => ({
77 + year: y,
78 + value: u[`est_${y}`] as number | null,
79 + })),
80 + }
81 + : null,
82 + nearby,
83 + aiSummary: safeSummary(uid),
84 + };
85 +});
86 +
87 +/** Résumé de l'analyse IA du bâtiment (chantier Coût) — jamais bloquant pour la fiche. */
88 +function safeSummary(uid: string) {
89 + try { return summaryForListing(uid); } catch { return null; }
90 +}
91 +
92 +export async function generateMetadata({ params }: { params: Promise<{ uid: string }> }): Promise<Metadata> {
93 + const { uid } = await params;
94 + const d = load(decodeURIComponent(uid));
95 + if (!d) return { title: "Propriété introuvable" };
96 + const l = d.listing;
97 + const where = [l.address, l.city].filter(Boolean).join(", ") || l.title || "Propriété à vendre";
98 + const est = d.eval?.est ? ` · estimation Vrai-Prix ${moneyFr(d.eval.est)}` : "";
99 + const title = `${where} — ${moneyFr(l.price)}${est}`;
100 + const description = `${l.propertyType ?? "Propriété"} à vendre à ${l.city ?? "—"} : prix demandé ${moneyFr(
101 + l.price
102 + )}${d.eval?.est ? `, estimation Vrai-Prix ${moneyFr(d.eval.est)} (${moneyFr(d.eval.low)} – ${moneyFr(d.eval.high)})` : ""}. Choisissez vos comparables et comparez votre évaluation à la mesure Vrai-Prix.`;
103 + return {
104 + title,
105 + description,
106 + alternates: { canonical: `/a-vendre/${encodeURIComponent(l.uid)}` },
107 + openGraph: { title, description, type: "website", images: d.images[0] ? [{ url: d.images[0] }] : undefined },
108 + robots: { index: false, follow: true }, // annonces éphémères, contenu tiers
109 + };
110 +}
111 +
112 +export default async function ListingPage({ params }: { params: Promise<{ uid: string }> }) {
113 + const { uid } = await params;
114 + const data = load(decodeURIComponent(uid));
115 + if (!data) notFound();
116 + return <ListingView data={data} />;
117 +}
added src/app/a-vendre/page.tsx +30 −0
@@ -0,0 +1,30 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * /a-vendre — évaluer une propriété réellement à vendre au Québec.
4 + * Toutes les annonces actives de la base Immo-Ka (copie locale), chacune déjà
5 + * mesurée par le moteur Vrai-Prix (table vp_eval) ; l'utilisateur choisit une
6 + * propriété, bâtit sa propre évaluation par comparables, puis la confronte à
7 + * la mesure Vrai-Prix et au prix demandé.
8 + */
9 +import type { Metadata } from "next";
10 +import { Suspense } from "react";
11 +import { avendreOverview } from "@/lib/immoka";
12 +import AVendreClient from "./AVendreClient";
13 +
14 +export const dynamic = "force-dynamic";
15 +
16 +export const metadata: Metadata = {
17 + title: "Évaluer une propriété à vendre — toutes les annonces du Québec",
18 + description:
19 + "Plus de 66 000 propriétés réellement à vendre au Québec (base Immo-Ka), chacune déjà estimée par le moteur Vrai-Prix. Choisissez une propriété, sélectionnez vos comparables, ajustez-les et comparez votre évaluation à la mesure Vrai-Prix et au prix demandé.",
20 + alternates: { canonical: "/a-vendre" },
21 +};
22 +
23 +export default function AVendrePage() {
24 + const overview = avendreOverview();
25 + return (
26 + <Suspense fallback={<div className="py-16 text-center text-ink-3">…</div>}>
27 + <AVendreClient overview={overview} />
28 + </Suspense>
29 + );
30 +}
added src/app/api/admin/cost/anomalies/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +// Vrai-Prix — admin méthode du coût : prix aberrants, mappings à valider, sources sans observation récente, sauts de prix.
2 +import { NextResponse } from "next/server";
3 +import { adminAuth, anomalies } from "@/lib/cost/connectors/admin";
4 +
5 +export const runtime = "nodejs";
6 +
7 +export function GET(req: Request) {
8 + const denied = adminAuth(req);
9 + if (denied) return denied;
10 + try {
11 + return NextResponse.json(anomalies());
12 + } catch (e) {
13 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
14 + }
15 +}
added src/app/api/admin/cost/benchmarks/import/route.ts +27 −0
@@ -0,0 +1,27 @@
1 +// Vrai-Prix — admin méthode du coût : import manuel de benchmarks (Altus) ou de prix licenciés (RSMeans).
2 +// Corps : { kind: "benchmarks" | "rsmeans", data: <CSV texte | tableau JSON> }
3 +import { NextRequest, NextResponse } from "next/server";
4 +import { adminAuth } from "@/lib/cost/connectors/admin";
5 +import { importBenchmarks, importRsmeans } from "@/lib/cost/connectors/imports";
6 +import { invalidateCache } from "@/lib/cost/cache";
7 +import { metaSet } from "@/lib/cost/db";
8 +
9 +export const runtime = "nodejs";
10 +
11 +export async function POST(req: NextRequest) {
12 + const denied = adminAuth(req);
13 + if (denied) return denied;
14 + let b: { kind?: string; data?: unknown };
15 + const ct = req.headers.get("content-type") ?? "";
16 + try {
17 + if (ct.includes("text/csv") || ct.includes("text/plain")) b = { kind: req.nextUrl.searchParams.get("kind") ?? "benchmarks", data: await req.text() };
18 + else b = await req.json();
19 + } catch { return NextResponse.json({ error: "corps invalide (JSON ou CSV)" }, { status: 400 }); }
20 + try {
21 + const r = b.kind === "rsmeans" ? importRsmeans(b.data) : importBenchmarks(b.data);
22 + if (r.imported) { metaSet("last_ingest", new Date().toISOString()); invalidateCache(); }
23 + return NextResponse.json({ kind: b.kind ?? "benchmarks", ...r });
24 + } catch (e) {
25 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
26 + }
27 +}
added src/app/api/admin/cost/connectors/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +// Vrai-Prix — admin méthode du coût : état des connecteurs, runs, indices, métriques (jeton requis).
2 +import { NextResponse } from "next/server";
3 +import { adminAuth, connectorsStatus } from "@/lib/cost/connectors/admin";
4 +
5 +export const runtime = "nodejs";
6 +
7 +export function GET(req: Request) {
8 + const denied = adminAuth(req);
9 + if (denied) return denied;
10 + try {
11 + return NextResponse.json(connectorsStatus());
12 + } catch (e) {
13 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
14 + }
15 +}
added src/app/api/admin/cost/mappings/[id]/[decision]/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +// Vrai-Prix — admin méthode du coût : approuver / rejeter une correspondance proposée.
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { adminAuth } from "@/lib/cost/connectors/admin";
4 +import { reviewMapping } from "@/lib/cost/connectors/matching";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string; decision: string }> }) {
9 + const denied = adminAuth(req);
10 + if (denied) return denied;
11 + const { id, decision } = await params;
12 + if (decision !== "approve" && decision !== "reject") return NextResponse.json({ error: "décision : approve | reject" }, { status: 400 });
13 + const ok = reviewMapping(Number(id), decision === "approve" ? "approved" : "rejected");
14 + return ok ? NextResponse.json({ ok: true, id: Number(id), status: decision === "approve" ? "approved" : "rejected" }) : NextResponse.json({ error: "mapping introuvable" }, { status: 404 });
15 +}
added src/app/api/admin/cost/mappings/route.ts +35 −0
@@ -0,0 +1,35 @@
1 +// Vrai-Prix — admin méthode du coût : correspondances produit ↔ article (liste, création manuelle).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { adminAuth } from "@/lib/cost/connectors/admin";
4 +import { getCostDb, sourceIdByKey } from "@/lib/cost/db";
5 +import { reviewMapping } from "@/lib/cost/connectors/matching";
6 +import { isRunnable } from "@/lib/cost/connectors/registry";
7 +
8 +export const runtime = "nodejs";
9 +
10 +export function GET(req: NextRequest) {
11 + const denied = adminAuth(req);
12 + if (denied) return denied;
13 + const status = req.nextUrl.searchParams.get("status");
14 + const rows = getCostDb().prepare(`SELECT m.id, s.key source, m.product_url url, m.product_title title, m.proposed_item_code itemCode, m.pack_qty packQty, m.pack_unit packUnit, m.method, m.confidence, m.rationale, m.status, m.reviewed_at reviewedAt, m.created_at createdAt
15 + FROM product_mappings m JOIN cost_sources s ON s.id=m.source_id ${status ? "WHERE m.status=?" : ""} ORDER BY m.created_at DESC LIMIT 500`).all(...(status ? [status] : []));
16 + return NextResponse.json({ mappings: rows });
17 +}
18 +
19 +/** Création/approbation manuelle : { source, itemCode, url, title?, packQty?, packUnit? } */
20 +export async function POST(req: NextRequest) {
21 + const denied = adminAuth(req);
22 + if (denied) return denied;
23 + let b: { source?: string; itemCode?: string; url?: string; title?: string; packQty?: number; packUnit?: string };
24 + try { b = await req.json(); } catch { return NextResponse.json({ error: "JSON invalide" }, { status: 400 }); }
25 + if (!b.source || !isRunnable(b.source) || !b.itemCode || !b.url) return NextResponse.json({ error: "source, itemCode et url requis" }, { status: 400 });
26 + const d = getCostDb();
27 + const item = d.prepare("SELECT id FROM cost_items WHERE canonical_code=?").get(b.itemCode) as { id: number } | undefined;
28 + if (!item) return NextResponse.json({ error: "article inconnu" }, { status: 404 });
29 + const sid = sourceIdByKey(b.source, d);
30 + d.prepare(`INSERT INTO product_mappings(source_id,product_url,product_title,proposed_item_code,pack_qty,pack_unit,method,confidence,rationale,status) VALUES(?,?,?,?,?,?,'manual',1,'saisie administrateur','pending')
31 + ON CONFLICT(source_id,product_url) DO UPDATE SET proposed_item_code=excluded.proposed_item_code, pack_qty=excluded.pack_qty, pack_unit=excluded.pack_unit, method='manual', confidence=1`).run(sid, b.url, b.title ?? null, b.itemCode, b.packQty ?? null, b.packUnit ?? null);
32 + const id = (d.prepare("SELECT id FROM product_mappings WHERE source_id=? AND product_url=?").get(sid, b.url) as { id: number }).id;
33 + reviewMapping(id, "approved", d);
34 + return NextResponse.json({ ok: true, id });
35 +}
added src/app/api/admin/cost/sync/[connector]/route.ts +19 −0
@@ -0,0 +1,19 @@
1 +// Vrai-Prix — admin méthode du coût : déclenche une synchronisation en arrière-plan (jeton requis).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { adminAuth, launchConnector } from "@/lib/cost/connectors/admin";
4 +import { isRunnable, IMPORT_ONLY } from "@/lib/cost/connectors/registry";
5 +
6 +export const runtime = "nodejs";
7 +export const maxDuration = 25;
8 +
9 +export async function POST(req: NextRequest, { params }: { params: Promise<{ connector: string }> }) {
10 + const denied = adminAuth(req);
11 + if (denied) return denied;
12 + const { connector } = await params;
13 + if (IMPORT_ONLY.includes(connector as never)) return NextResponse.json({ error: `${connector} : import manuel seulement (POST /api/admin/cost/benchmarks/import)` }, { status: 400 });
14 + if (!isRunnable(connector)) return NextResponse.json({ error: "connecteur inconnu" }, { status: 404 });
15 + const force = req.nextUrl.searchParams.get("force") === "1";
16 + const items = req.nextUrl.searchParams.get("items")?.split(",").map((s) => s.trim()).filter(Boolean);
17 + const r = launchConnector(connector, force, items);
18 + return NextResponse.json({ connector, ...r }, { status: r.started ? 202 : 409 });
19 +}
added src/app/api/admin/cost/usage/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +// Vrai-Prix — admin méthode du coût : consommation IA (ai_usage) et budgets jour / mois.
2 +import { NextResponse } from "next/server";
3 +import { adminAuth } from "@/lib/cost/connectors/admin";
4 +import { getCostDb } from "@/lib/cost/db";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export function GET(req: Request) {
9 + const denied = adminAuth(req);
10 + if (denied) return denied;
11 + const d = getCostDb();
12 + const day = d.prepare("SELECT COUNT(*) n, COALESCE(SUM(estimated_cost_usd),0) usd, COALESCE(SUM(input_tokens),0) input, COALESCE(SUM(output_tokens),0) output FROM ai_usage WHERE created_at >= date('now')").get();
13 + const month = d.prepare("SELECT COUNT(*) n, COALESCE(SUM(estimated_cost_usd),0) usd, COALESCE(SUM(input_tokens),0) input, COALESCE(SUM(output_tokens),0) output FROM ai_usage WHERE created_at >= date('now','start of month')").get();
14 + const byPurpose = d.prepare("SELECT purpose, model, COUNT(*) n, ROUND(COALESCE(SUM(estimated_cost_usd),0),4) usd, ROUND(AVG(latency_ms)) latencyMs, ROUND(AVG(estimated_cost_usd),5) avgUsd FROM ai_usage GROUP BY purpose, model ORDER BY usd DESC").all();
15 + const recent = d.prepare("SELECT id, analysis_id analysisId, purpose, model, input_images inputImages, input_tokens inputTokens, output_tokens outputTokens, estimated_cost_usd usd, latency_ms latencyMs, created_at createdAt FROM ai_usage ORDER BY created_at DESC LIMIT 50").all();
16 + const budgets = { dailyUsd: Number(process.env.AI_DAILY_BUDGET_USD ?? 5), monthlyUsd: Number(process.env.AI_MONTHLY_BUDGET_USD ?? 60) };
17 + const dayUsd = (day as { usd: number }).usd;
18 + const monthUsd = (month as { usd: number }).usd;
19 + return NextResponse.json({ day, month, byPurpose, recent, budgets, alerts: [dayUsd > budgets.dailyUsd ? "budget quotidien dépassé" : null, monthUsd > budgets.monthlyUsd ? "budget mensuel dépassé" : null].filter(Boolean) });
20 +}
added src/app/api/avendre/comps/route.ts +28 −0
@@ -0,0 +1,28 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Bassin de comparables pour l'atelier « mon évaluation » d'une annonce.
3 +import { NextRequest, NextResponse } from "next/server";
4 +import { buildComps } from "@/lib/comps";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +export function GET(req: NextRequest) {
9 + const q = req.nextUrl.searchParams;
10 + const uid = q.get("uid");
11 + if (!uid) return NextResponse.json({ error: "uid requis" }, { status: 400 });
12 + const num = (k: string) => {
13 + const v = Number(q.get(k));
14 + return Number.isFinite(v) && v > 0 ? v : undefined;
15 + };
16 + try {
17 + const res = buildComps(uid, {
18 + months: num("months"),
19 + radiusKm: num("radius"),
20 + includeListings: q.get("listings") !== "0",
21 + limit: num("limit"),
22 + });
23 + if (!res) return NextResponse.json({ error: "annonce introuvable ou non géolocalisée" }, { status: 404 });
24 + return NextResponse.json(res);
25 + } catch (e) {
26 + return NextResponse.json({ error: String(e) }, { status: 500 });
27 + }
28 +}
added src/app/api/avendre/route.ts +38 −0
@@ -0,0 +1,38 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Recherche dans les propriétés à vendre (copie Immo-Ka + estimations vp_eval).
3 +import { NextRequest, NextResponse } from "next/server";
4 +import { searchListings, type SortKey, type TypeGroupKey } from "@/lib/immoka";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +const SORTS: SortKey[] = ["recent", "price_asc", "price_desc", "deal", "premium", "est_desc"];
9 +const GROUPS: TypeGroupKey[] = ["maison", "condo", "plex", "terrain", "chalet", "fermette", "mobile", "commercial", "autre"];
10 +
11 +export function GET(req: NextRequest) {
12 + const q = req.nextUrl.searchParams;
13 + const num = (k: string) => {
14 + const v = Number(q.get(k));
15 + return Number.isFinite(v) && v > 0 ? v : undefined;
16 + };
17 + const sort = q.get("sort") as SortKey | null;
18 + const group = q.get("group") as TypeGroupKey | null;
19 + try {
20 + const res = searchListings({
21 + q: q.get("q") ?? undefined,
22 + muni: q.get("muni") ?? undefined,
23 + group: group && GROUPS.includes(group) ? group : "",
24 + min: num("min"),
25 + max: num("max"),
26 + beds: num("beds"),
27 + evalOnly: q.get("eval") === "1",
28 + sort: sort && SORTS.includes(sort) ? sort : "recent",
29 + page: num("page") ?? 1,
30 + per: num("per") ?? 24,
31 + });
32 + return NextResponse.json(res, {
33 + headers: { "Cache-Control": "public, max-age=120, stale-while-revalidate=600" },
34 + });
35 + } catch (e) {
36 + return NextResponse.json({ error: String(e) }, { status: 500 });
37 + }
38 +}
added src/app/api/cost/assemblies/[code]/route.ts +40 −0
@@ -0,0 +1,40 @@
1 +// Vrai-Prix — méthode du coût : fiche d'un assemblage (composition, prix, main-d'œuvre, sources, variations régionales, historique).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { buildContext, loadLocations } from "@/lib/cost/catalog";
4 +import { assemblyUnitCost } from "@/lib/cost/engine";
5 +import { cached } from "@/lib/cost/cache";
6 +
7 +export const runtime = "nodejs";
8 +
9 +export async function GET(req: NextRequest, { params }: { params: Promise<{ code: string }> }) {
10 + const { code: raw } = await params;
11 + const code = decodeURIComponent(raw);
12 + const loc = req.nextUrl.searchParams.get("location") ?? "QC-MTL";
13 + try {
14 + const data = cached(`assembly:${code}:${loc}`, 10 * 60_000, () => {
15 + const ctx = buildContext({ locationCode: loc });
16 + const a = ctx.assemblies.get(code);
17 + if (!a) return null;
18 + const unit = assemblyUnitCost(a, ctx);
19 + // variations régionales (facteurs appliqués au coût direct par unité)
20 + const regional = loadLocations().map((l) => ({ code: l.code, nameFr: l.nameFr, nameEn: l.nameEn, direct: Math.round(100 * (unit.material * l.materialFactor + unit.labour * l.labourFactor + unit.equipment * l.equipmentFactor)) / 100, materialFactor: l.materialFactor, labourFactor: l.labourFactor, equipmentFactor: l.equipmentFactor }));
21 + // historique : coût unitaire recalculé aux 8 derniers trimestres avec les prix de l'époque
22 + const history: { date: string; direct: number; material: number; labour: number; equipment: number; kind: string }[] = [];
23 + const now = new Date(ctx.asOf);
24 + for (let k = 7; k >= 0; k--) {
25 + const d = new Date(now);
26 + d.setMonth(d.getMonth() - 3 * k);
27 + const asOf = d.toISOString().slice(0, 10);
28 + const c2 = buildContext({ asOf, locationCode: loc });
29 + const u2 = assemblyUnitCost(a, c2);
30 + const kinds = new Set(u2.components.map((c) => c.provenance.kind));
31 + history.push({ date: asOf, direct: u2.direct, material: u2.material, labour: u2.labour, equipment: u2.equipment, kind: kinds.has("observed") || kinds.has("official") ? "observed" : kinds.has("indexed") ? "indexed" : "reference" });
32 + }
33 + return { asOf: ctx.asOf, location: ctx.location, assembly: a, unit, regional, history };
34 + });
35 + if (!data) return NextResponse.json({ error: "assemblage introuvable" }, { status: 404 });
36 + return NextResponse.json(data);
37 + } catch (e) {
38 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
39 + }
40 +}
added src/app/api/cost/assemblies/route.ts +36 −0
@@ -0,0 +1,36 @@
1 +// Vrai-Prix — méthode du coût : catalogue des assemblages avec coût unitaire décomposé (localisation au choix).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { buildContext } from "@/lib/cost/catalog";
4 +import { assemblyUnitCost } from "@/lib/cost/engine";
5 +import { cached } from "@/lib/cost/cache";
6 +
7 +export const runtime = "nodejs";
8 +
9 +export function GET(req: NextRequest) {
10 + const loc = req.nextUrl.searchParams.get("location") ?? "QC-MTL";
11 + const q = (req.nextUrl.searchParams.get("q") ?? "").trim().toLowerCase();
12 + const cat = req.nextUrl.searchParams.get("category");
13 + try {
14 + const data = cached(`assemblies:${loc}`, 10 * 60_000, () => {
15 + const ctx = buildContext({ locationCode: loc });
16 + const f = ctx.location;
17 + return {
18 + asOf: ctx.asOf, location: f,
19 + assemblies: [...ctx.assemblies.values()].map((a) => {
20 + const u = assemblyUnitCost(a, ctx);
21 + return { code: a.code, nameFr: a.nameFr, nameEn: a.nameEn, descriptionFr: a.descriptionFr, descriptionEn: a.descriptionEn, unit: a.unit, category: a.category, masterformat: a.masterformat, quality: a.quality, economicLife: a.economicLife, conditionGroup: a.conditionGroup,
22 + material: u.material, labour: u.labour, equipment: u.equipment, direct: u.direct,
23 + adjusted: Math.round(100 * (u.material * f.materialFactor + u.labour * f.labourFactor + u.equipment * f.equipmentFactor)) / 100,
24 + sigmaPct: Math.round(u.sigmaPct * 1000) / 1000, observedShare: Math.round(u.observedShare * 100) / 100, freshestObservation: u.freshestObservation, components: a.components.length };
25 + }),
26 + };
27 + });
28 + const fold = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
29 + let list = data.assemblies;
30 + if (q) list = list.filter((a) => fold(a.nameFr).includes(fold(q)) || fold(a.nameEn).includes(fold(q)) || a.code.toLowerCase().includes(q));
31 + if (cat) list = list.filter((a) => a.category === cat);
32 + return NextResponse.json({ asOf: data.asOf, location: data.location, count: list.length, assemblies: list });
33 + } catch (e) {
34 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
35 + }
36 +}
added src/app/api/cost/benchmarks/route.ts +13 −0
@@ -0,0 +1,13 @@
1 +// Vrai-Prix — méthode du coût : benchmarks externes ($/pi² par type et marché).
2 +import { NextResponse } from "next/server";
3 +import { loadBenchmarks } from "@/lib/cost/catalog";
4 +
5 +export const runtime = "nodejs";
6 +
7 +export function GET() {
8 + try {
9 + return NextResponse.json({ benchmarks: loadBenchmarks() });
10 + } catch (e) {
11 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
12 + }
13 +}
added src/app/api/cost/estimate/[id]/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +// Vrai-Prix — méthode du coût : relecture d'une estimation sauvegardée (reproductible).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { getEstimate } from "@/lib/cost/estimate";
4 +
5 +export const runtime = "nodejs";
6 +
7 +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
8 + const { id } = await params;
9 + if (!/^[0-9a-f-]{36}$/.test(id)) return NextResponse.json({ error: "id invalide" }, { status: 400 });
10 + try {
11 + const est = getEstimate(id);
12 + if (!est) return NextResponse.json({ error: "estimation introuvable" }, { status: 404 });
13 + return NextResponse.json(est, { headers: { "Cache-Control": "private, max-age=3600" } });
14 + } catch (e) {
15 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
16 + }
17 +}
added src/app/api/cost/estimate/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +// Vrai-Prix — méthode du coût : calcul d'une estimation (déterministe, sauvegardée avec son instantané de prix).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { runEstimate, sanitizeInput } from "@/lib/cost/estimate";
4 +import { clientKey, rateLimit } from "@/lib/cost/cache";
5 +
6 +export const runtime = "nodejs";
7 +export const maxDuration = 30;
8 +
9 +export async function POST(req: NextRequest) {
10 + if (!rateLimit(`estimate:${clientKey(req)}`, 60, 60_000)) return NextResponse.json({ error: "Trop de requêtes — réessayez dans une minute." }, { status: 429 });
11 + let body: unknown;
12 + try { body = await req.json(); } catch { return NextResponse.json({ error: "JSON invalide" }, { status: 400 }); }
13 + try {
14 + const input = sanitizeInput((body as { input?: unknown })?.input ?? body);
15 + const save = (body as { save?: boolean })?.save !== false;
16 + const est = runEstimate(input, { save, sessionId: req.headers.get("x-session-id") });
17 + return NextResponse.json(est);
18 + } catch (e) {
19 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
20 + }
21 +}
added src/app/api/cost/items/[code]/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +// Vrai-Prix — méthode du coût : fiche d'un article (prix canonique, historique, provenance, assemblages qui l'utilisent).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { buildContext, getItem, loadAssemblies, priceHistory } from "@/lib/cost/catalog";
4 +import { cached } from "@/lib/cost/cache";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export async function GET(req: NextRequest, { params }: { params: Promise<{ code: string }> }) {
9 + const { code } = await params;
10 + const loc = req.nextUrl.searchParams.get("location") ?? "QC-MTL";
11 + try {
12 + const item = getItem(decodeURIComponent(code));
13 + if (!item) return NextResponse.json({ error: "article introuvable" }, { status: 404 });
14 + const ctx = cached(`ctx:${loc}`, 10 * 60_000, () => buildContext({ locationCode: loc }));
15 + const price = ctx.prices.get(item.code) ?? null;
16 + const history = priceHistory(item.code);
17 + const usedBy = [...loadAssemblies().values()].filter((a) => a.components.some((c) => c.itemCode === item.code)).map((a) => ({ code: a.code, nameFr: a.nameFr, nameEn: a.nameEn, unit: a.unit, quantity: a.components.find((c) => c.itemCode === item.code)!.quantity }));
18 + return NextResponse.json({ asOf: ctx.asOf, item, price, history, usedBy });
19 + } catch (e) {
20 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
21 + }
22 +}
added src/app/api/cost/items/route.ts +25 −0
@@ -0,0 +1,25 @@
1 +// Vrai-Prix — méthode du coût : recherche d'articles (coûts unitaires) avec prix canonique courant.
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { buildContext, loadItems, searchItems } from "@/lib/cost/catalog";
4 +import { cached } from "@/lib/cost/cache";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export function GET(req: NextRequest) {
9 + const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
10 + const loc = req.nextUrl.searchParams.get("location") ?? "QC-MTL";
11 + const cat = req.nextUrl.searchParams.get("category");
12 + try {
13 + const ctx = cached(`ctx:${loc}`, 10 * 60_000, () => buildContext({ locationCode: loc }));
14 + let items = q.length >= 2 ? searchItems(q, 40) : [...loadItems().values()];
15 + if (cat) items = items.filter((i) => i.category === cat);
16 + const out = items.slice(0, 200).map((i) => {
17 + const p = ctx.prices.get(i.code) ?? null;
18 + return { code: i.code, nameFr: i.nameFr, nameEn: i.nameEn, unit: i.unit, category: i.category, masterformat: i.masterformat, division: i.division, trade: i.trade,
19 + price: p ? Math.round(p.price * 100) / 100 : null, low: p ? Math.round(p.low * 100) / 100 : null, high: p ? Math.round(p.high * 100) / 100 : null, kind: p?.kind ?? null, sourceCount: p?.sourceCount ?? 0, observedAt: p?.observedAt ?? null, confidence: p?.confidence ?? null, note: p?.note ?? null, sources: p?.sources ?? [] };
20 + });
21 + return NextResponse.json({ asOf: ctx.asOf, location: ctx.location.code, count: out.length, items: out });
22 + } catch (e) {
23 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
24 + }
25 +}
added src/app/api/cost/labour/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +// Vrai-Prix — méthode du coût : grilles de main-d'œuvre (coût employeur complet par métier, historique).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { allLabourRates, labourHistory, today } from "@/lib/cost/catalog";
4 +import { TRADES } from "@/lib/cost/taxonomy";
5 +import { cached } from "@/lib/cost/cache";
6 +
7 +export const runtime = "nodejs";
8 +
9 +export function GET(req: NextRequest) {
10 + const trade = req.nextUrl.searchParams.get("trade");
11 + try {
12 + if (trade) return NextResponse.json({ trade, history: labourHistory(trade) });
13 + const data = cached("labour", 10 * 60_000, () => ({ asOf: today(), trades: TRADES.map((t) => ({ code: t.code, fr: t.fr, en: t.en })), rates: allLabourRates(today()) }));
14 + return NextResponse.json(data);
15 + } catch (e) {
16 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
17 + }
18 +}
added src/app/api/cost/locations/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +// Vrai-Prix — méthode du coût : facteurs de localisation (matériaux / main-d'œuvre / équipement).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { loadLocations, resolveLocation } from "@/lib/cost/catalog";
4 +
5 +export const runtime = "nodejs";
6 +
7 +export function GET(req: NextRequest) {
8 + const muni = req.nextUrl.searchParams.get("municipality");
9 + const lat = req.nextUrl.searchParams.get("lat");
10 + const lng = req.nextUrl.searchParams.get("lng");
11 + try {
12 + if (muni || (lat && lng)) {
13 + const r = resolveLocation({ municipality: muni, lat: lat ? Number(lat) : null, lng: lng ? Number(lng) : null });
14 + return NextResponse.json({ resolved: r.location, method: r.method });
15 + }
16 + return NextResponse.json({ locations: loadLocations() });
17 + } catch (e) {
18 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
19 + }
20 +}
added src/app/api/cost/materials/history/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +// Vrai-Prix — méthode du coût : historique des prix d'un matériau (toutes observations, aberrations marquées).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { getItem, priceHistory } from "@/lib/cost/catalog";
4 +
5 +export const runtime = "nodejs";
6 +
7 +export function GET(req: NextRequest) {
8 + const code = req.nextUrl.searchParams.get("code");
9 + if (!code) return NextResponse.json({ error: "code requis" }, { status: 400 });
10 + try {
11 + const item = getItem(code);
12 + if (!item) return NextResponse.json({ error: "article introuvable" }, { status: 404 });
13 + return NextResponse.json({ item: { code: item.code, nameFr: item.nameFr, nameEn: item.nameEn, unit: item.unit }, history: priceHistory(code) });
14 + } catch (e) {
15 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
16 + }
17 +}
added src/app/api/cost/overview/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +// Vrai-Prix — méthode du coût : tableau de bord « Coût aujourd'hui » (base locale, jamais de crawl).
2 +import { NextResponse } from "next/server";
3 +import { buildOverview } from "@/lib/cost/catalog";
4 +import { cached } from "@/lib/cost/cache";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export function GET() {
9 + try {
10 + const data = cached("overview", 10 * 60_000, () => buildOverview());
11 + return NextResponse.json(data, { headers: { "Cache-Control": "public, max-age=300, stale-while-revalidate=600" } });
12 + } catch (e) {
13 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
14 + }
15 +}
added src/app/api/cost/property/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +// Vrai-Prix — méthode du coût : préremplissage d'une propriété du rôle (MAMH) + autres lectures de la valeur.
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { otherReadingsForProperty, prefillFromProperty, recentEstimatesForProperty } from "@/lib/cost/estimate";
4 +import { resolveLocation } from "@/lib/cost/catalog";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export function GET(req: NextRequest) {
9 + const raw = req.nextUrl.searchParams.get("id");
10 + if (!raw) return NextResponse.json({ error: "id requis" }, { status: 400 });
11 + const id = raw.replace(/\D/g, "") || raw;
12 + try {
13 + const p = prefillFromProperty(id);
14 + if (!p) return NextResponse.json({ error: "propriété introuvable" }, { status: 404 });
15 + const loc = resolveLocation({ municipality: p.input.municipality, lat: p.input.lat, lng: p.input.lng });
16 + const readings = req.nextUrl.searchParams.get("readings") === "0" ? null : otherReadingsForProperty(id);
17 + return NextResponse.json({ ...p, location: loc.location, locationMethod: loc.method, otherReadings: readings, recent: recentEstimatesForProperty(id) });
18 + } catch (e) {
19 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
20 + }
21 +}
added src/app/api/cost/report/route.ts +56 −0
@@ -0,0 +1,56 @@
1 +// Vrai-Prix — méthode du coût : rapport PDF « Rapport — Méthode du coût » .
2 +// GET ?estimate=<uuid> → estimation sauvegardée ; POST {input} → calcul sans sauvegarde.
3 +import { NextRequest, NextResponse } from "next/server";
4 +import { getEstimate, runEstimate, sanitizeInput } from "@/lib/cost/estimate";
5 +import { buildCostReport } from "@/lib/report-cost";
6 +import type { CostEstimate } from "@/lib/cost/types";
7 +
8 +export const runtime = "nodejs";
9 +export const maxDuration = 60;
10 +
11 +function slugOf(est: CostEstimate): string {
12 + const base = est.input.address ?? est.input.municipality ?? `${est.input.building.type}-${Math.round(est.input.building.grossFloorAreaSqft)}pi2`;
13 + return (
14 + base
15 + .toLowerCase()
16 + .normalize("NFD")
17 + .replace(/[̀-ͯ]/g, "")
18 + .replace(/[^a-z0-9]+/g, "-")
19 + .replace(/^-+|-+$/g, "") || "batiment"
20 + );
21 +}
22 +
23 +async function respond(est: CostEstimate): Promise<Response> {
24 + const pdf = await buildCostReport(est);
25 + return new NextResponse(new Uint8Array(pdf), {
26 + headers: {
27 + "Content-Type": "application/pdf",
28 + "Content-Disposition": `attachment; filename="vrai-prix-cout-${slugOf(est)}.pdf"`,
29 + "Cache-Control": "private, max-age=300",
30 + },
31 + });
32 +}
33 +
34 +export async function GET(req: NextRequest) {
35 + const id = req.nextUrl.searchParams.get("estimate") ?? "";
36 + if (!/^[0-9a-f-]{36}$/i.test(id)) return NextResponse.json({ error: "paramètre estimate (uuid) requis" }, { status: 400 });
37 + try {
38 + const est = getEstimate(id);
39 + if (!est) return NextResponse.json({ error: "estimation introuvable" }, { status: 404 });
40 + return await respond(est);
41 + } catch (e) {
42 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
43 + }
44 +}
45 +
46 +export async function POST(req: NextRequest) {
47 + let body: unknown;
48 + try { body = await req.json(); } catch { return NextResponse.json({ error: "JSON invalide" }, { status: 400 }); }
49 + try {
50 + const input = sanitizeInput((body as { input?: unknown })?.input ?? body);
51 + const est = runEstimate(input, { save: false });
52 + return await respond(est);
53 + } catch (e) {
54 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
55 + }
56 +}
added src/app/api/cost/sources/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +// Vrai-Prix — méthode du coût : registre des sources (licence, dernière synchro, volume).
2 +import { NextResponse } from "next/server";
3 +import { getCostDb } from "@/lib/cost/db";
4 +
5 +export const runtime = "nodejs";
6 +
7 +export function GET() {
8 + try {
9 + const d = getCostDb();
10 + const rows = d.prepare(`SELECT s.key, s.name, s.source_type type, s.base_url url, s.jurisdiction, s.license_status license, s.license_notes notes, s.terms_url, s.is_active active, s.priority, s.quality, s.refresh_frequency frequency, s.last_successful_sync lastSync, s.last_error lastError,
11 + (SELECT COUNT(*) FROM cost_item_prices p WHERE p.source_id=s.id) prices,
12 + (SELECT COUNT(*) FROM labour_rates l WHERE l.source_id=s.id) labour,
13 + (SELECT COUNT(*) FROM cost_raw_observations o WHERE o.source_id=s.id) raw
14 + FROM cost_sources s ORDER BY s.priority, s.name`).all();
15 + const runs = d.prepare("SELECT connector, status, started_at, finished_at, observations, accepted, rejected, unchanged, errors FROM connector_runs ORDER BY started_at DESC LIMIT 30").all();
16 + const indices = d.prepare("SELECT index_code code, geography, building_type, division, MAX(period) last, COUNT(*) n FROM construction_cost_indices GROUP BY index_code").all();
17 + return NextResponse.json({ sources: rows, runs, indices });
18 + } catch (e) {
19 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
20 + }
21 +}
modified src/app/api/ka/route.ts +11 −0
@@ -42,6 +42,17 @@ const SYSTEM = `Tu es Ka, l'agent d'évaluation immobilière de Vrai-Prix (www.v
42 42 - Les DEUX seuls formats d'URL de rapport qui existent : …/api/report?id=ID (standard) et …/api/report/pro?id=ID (professionnel). Il n'existe AUCUN paramètre « format », aucun chemin /rapports/. N'invente JAMAIS une URL : copie-la telle quelle depuis une réf ou un outil.
43 43 - Pas de réf ni de liens d'outil sous la main ? Tu DOIS appeler chercher puis liens_rapports AVANT de donner un lien — jamais de lien de mémoire.
44 44
45 +# Méthode du coût — onglet « Coût » (www.vrai-prix.com/cout)
46 +- Quand on demande « combien coûterait cette maison à reconstruire ? », « quel est le coût de remplacement à neuf ? », « la valeur par la méthode du coût ? » : chercher_propriete → cout_estimer_propriete (id). Pour un bâtiment sans adresse (« construire une maison de 2 000 pi² à Sherbrooke ») : cout_estimer_construction.
47 +- Présente TOUJOURS la chaîne complète et la formule : V_coût = V_terrain + RCN − Dépréciation ; RCN = coûts directs (matériaux + main-d'œuvre + équipement, par catégorie) + indirects + frais généraux + profit + contingence ; puis dépréciation (physique, fonctionnelle, externe), terrain (source : rôle ≠ valeur marchande), indication par le coût, fourchette P10-P90 et confiance A-D décomposée.
48 +- Distingue explicitement quatre couches : OBSERVÉ (rôle MAMH, caractéristiques saisies), CALCULÉ (quantités par formule, totaux), PRIX SOURCÉ (● observé chez un détaillant, ● officiel APCHQ/CCQ, ○ indexé StatCan) et HYPOTHÈSE (◇ prix de référence interne, pourcentages par défaut, caractéristiques « supposées », taux de repli). Les prix de référence internes NE SONT PAS des observations : dis-le quand la part de matériaux observés est faible ou la confiance C/D.
49 +- « Pourquoi la toiture coûte-t-elle X ? » → cout_detail_assemblage avec le code d'assemblage du résultat (ex. ROOF-ASPHALT-ARCH) : descends jusqu'aux articles, quantités, prix unitaires, sources, dates, heures et taux employeur. « Explique la dépréciation » → cout_expliquer_depreciation (estimation_id). Coût de reproduction (à l'identique) vs coût de remplacement (utilité équivalente, normes actuelles) : Vrai-Prix calcule le REMPLACEMENT.
50 +- Main-d'œuvre : cout_taux_main_doeuvre — toujours parler de coût horaire EMPLOYEUR complet (salaire CCQ + congés 13 % + avantages + cotisations), jamais de salaire nu. Matériaux : cout_chercher_articles / cout_historique_materiau. Régions : cout_comparer_localisations. Provenance : cout_sources.
51 +- N'invente JAMAIS un coût, une quantité ou un pourcentage : tout vient des outils. Termine en offrant la page /cout?estimate=ID (pour modifier les hypothèses) et le PDF /api/cost/report?estimate=ID (liens fournis par l'outil, à copier tels quels). Rappelle : estimation indicative — ni soumission d'entrepreneur ni évaluation certifiée. La méthode du coût est une TROISIÈME lecture à comparer à l'hédonique et aux comparables, jamais à moyenner naïvement.
52 +
53 +# Propriétés à vendre (www.vrai-prix.com/a-vendre)
54 +- Vrai-Prix mesure aussi plus de 70 000 propriétés réellement à vendre au Québec (base Immo-Ka) : quand on te parle d'une annonce ou d'un prix demandé, invite à ouvrir /a-vendre (recherche par adresse ou municipalité) — chaque fiche confronte la mesure Vrai-Prix au prix demandé et propose un atelier de comparables et une analyse IA du bâtiment par la méthode du coût. Rappelle que prix demandé ≠ valeur marchande.
55 +
45 56 # Limites (à respecter strictement)
46 57 - Estimations statistiques à titre indicatif : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un courtier.
47 58 - Ne réponds qu'aux sujets liés à l'immobilier québécois et à Vrai-Prix. Pour tout autre sujet, ramène poliment la conversation.
added src/app/api/listings/[uid]/ai-analysis-json/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +// Vrai-Prix — téléchargement du JSON technique complet d'une analyse (profil, métadonnées, géométrie dérivée, images utilisées).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { getById, getLatest } from "@/lib/cost/ai/service";
4 +
5 +export const runtime = "nodejs";
6 +
7 +export async function GET(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) {
8 + const { uid } = await params;
9 + const id = req.nextUrl.searchParams.get("id");
10 + const v = id ? getById(id) : getLatest(decodeURIComponent(uid));
11 + if (!v || v.listingUid !== decodeURIComponent(uid) || !v.analysis) return NextResponse.json({ error: "aucune analyse complète" }, { status: 404 });
12 + const body = JSON.stringify({ ...v.analysis, merged_facts: v.merged, mapping: v.mapping, conflicts: v.conflicts, overrides: v.overrides, cost_snapshot_date: v.costSnapshotDate, estimate_id: v.estimate?.id ?? null, combined_confidence: v.combined }, null, 2);
13 + const slug = decodeURIComponent(uid).replace(/[^a-zA-Z0-9]+/g, "-");
14 + return new NextResponse(body, { headers: { "Content-Type": "application/json; charset=utf-8", "Content-Disposition": `attachment; filename="vrai-prix-analyse-ia-${slug}-v${v.version}.json"`, "Cache-Control": "no-store" } });
15 +}
added src/app/api/listings/[uid]/ai-cost-analysis/override/route.ts +24 −0
@@ -0,0 +1,24 @@
1 +// Vrai-Prix — analyse multimodale : correction manuelle d'une inférence (origine conservée), puis recalcul déterministe du coût.
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { AnalysisError, override } from "@/lib/cost/ai/service";
4 +import { clientKey, rateLimit } from "@/lib/cost/cache";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) {
9 + const { uid } = await params;
10 + if (!rateLimit(`ai-override:${clientKey(req)}`, 60, 60_000)) return NextResponse.json({ error: "Trop de requêtes." }, { status: 429 });
11 + let body: { analysisId?: string; field_path?: string; fieldPath?: string; new_value?: unknown; value?: unknown };
12 + try { body = await req.json(); } catch { return NextResponse.json({ error: "JSON invalide" }, { status: 400 }); }
13 + const fieldPath = body.field_path ?? body.fieldPath;
14 + const value = body.new_value ?? body.value;
15 + if (!body.analysisId || !fieldPath || value === undefined) return NextResponse.json({ error: "analysisId, field_path et new_value requis" }, { status: 400 });
16 + try {
17 + const v = override(body.analysisId, String(fieldPath).slice(0, 80), value, req.headers.get("x-session-id"));
18 + if (v.listingUid !== decodeURIComponent(uid)) return NextResponse.json({ error: "analyse d'une autre annonce" }, { status: 400 });
19 + return NextResponse.json(v, { headers: { "Cache-Control": "no-store" } });
20 + } catch (e) {
21 + if (e instanceof AnalysisError) return NextResponse.json({ error: e.message, code: e.code }, { status: e.status });
22 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
23 + }
24 +}
added src/app/api/listings/[uid]/ai-cost-analysis/reanalyze/route.ts +19 −0
@@ -0,0 +1,19 @@
1 +// Vrai-Prix — analyse multimodale : forcer une nouvelle version (nouvelles photos, correction, nouveau prompt/modèle).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { AnalysisError, startAnalysis } from "@/lib/cost/ai/service";
4 +import { clientKey, rateLimit } from "@/lib/cost/cache";
5 +
6 +export const runtime = "nodejs";
7 +export const maxDuration = 60;
8 +
9 +export async function POST(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) {
10 + const { uid } = await params;
11 + if (!rateLimit(`ai:${clientKey(req)}`, 3, 10 * 60_000)) return NextResponse.json({ error: "Trop d'analyses lancées — réessayez dans quelques minutes.", code: "rate_limited" }, { status: 429 });
12 + try {
13 + const r = await startAnalysis(decodeURIComponent(uid), { force: true });
14 + return NextResponse.json(r, { status: 202 });
15 + } catch (e) {
16 + if (e instanceof AnalysisError) return NextResponse.json({ error: e.message, code: e.code }, { status: e.status });
17 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
18 + }
19 +}
added src/app/api/listings/[uid]/ai-cost-analysis/route.ts +38 −0
@@ -0,0 +1,38 @@
1 +// Vrai-Prix — analyse multimodale d'une annonce : démarrage (POST, sur demande seulement) et suivi/lecture (GET).
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { AnalysisError, getById, getLatest, startAnalysis } from "@/lib/cost/ai/service";
4 +import { clientKey, rateLimit } from "@/lib/cost/cache";
5 +
6 +export const runtime = "nodejs";
7 +export const maxDuration = 60;
8 +
9 +export async function POST(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) {
10 + const { uid } = await params;
11 + if (!rateLimit(`ai:${clientKey(req)}`, 3, 10 * 60_000)) return NextResponse.json({ error: "Trop d'analyses lancées — réessayez dans quelques minutes.", code: "rate_limited" }, { status: 429 });
12 + try {
13 + const r = await startAnalysis(decodeURIComponent(uid), { force: false });
14 + return NextResponse.json(r, { status: r.reused ? 200 : 202 });
15 + } catch (e) {
16 + return errorResponse(e);
17 + }
18 +}
19 +
20 +export async function GET(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) {
21 + const { uid } = await params;
22 + const id = req.nextUrl.searchParams.get("id");
23 + try {
24 + const v = id ? getById(id) : getLatest(decodeURIComponent(uid));
25 + if (!v) return NextResponse.json({ status: "none" }, { headers: { "Cache-Control": "no-store" } });
26 + if (v.listingUid !== decodeURIComponent(uid)) return NextResponse.json({ error: "analyse d'une autre annonce" }, { status: 400 });
27 + // en cours : réponse légère pour le polling
28 + if (v.status !== "completed" && v.status !== "failed") return NextResponse.json({ id: v.id, status: v.status, stage: v.stage, version: v.version, imageCount: v.imageCount, createdAt: v.createdAt }, { headers: { "Cache-Control": "no-store" } });
29 + return NextResponse.json(v, { headers: { "Cache-Control": "no-store" } });
30 + } catch (e) {
31 + return errorResponse(e);
32 + }
33 +}
34 +
35 +function errorResponse(e: unknown) {
36 + if (e instanceof AnalysisError) return NextResponse.json({ error: e.message, code: e.code }, { status: e.status });
37 + return NextResponse.json({ error: (e as Error).message ?? "erreur", code: "error" }, { status: 500 });
38 +}
added src/app/api/listings/[uid]/cost-estimate/route.ts +31 −0
@@ -0,0 +1,31 @@
1 +// Vrai-Prix — coût par la méthode du coût d'une annonce analysée : lecture (GET) et recalcul aux coûts d'aujourd'hui (POST) — sans rappeler le modèle.
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { AnalysisError, getById, getLatest, recalc } from "@/lib/cost/ai/service";
4 +import { clientKey, rateLimit } from "@/lib/cost/cache";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export async function GET(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) {
9 + const { uid } = await params;
10 + const id = req.nextUrl.searchParams.get("id");
11 + const v = id ? getById(id) : getLatest(decodeURIComponent(uid));
12 + if (!v || v.listingUid !== decodeURIComponent(uid)) return NextResponse.json({ error: "aucune analyse" }, { status: 404 });
13 + if (!v.estimate) return NextResponse.json({ error: "aucune estimation (analyse en cours ou échouée)", status: v.status }, { status: 409 });
14 + return NextResponse.json({ analysisId: v.id, costSnapshotDate: v.costSnapshotDate, combined: v.combined, estimate: v.estimate }, { headers: { "Cache-Control": "no-store" } });
15 +}
16 +
17 +export async function POST(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) {
18 + const { uid } = await params;
19 + if (!rateLimit(`ai-recalc:${clientKey(req)}`, 20, 60_000)) return NextResponse.json({ error: "Trop de requêtes." }, { status: 429 });
20 + let body: { analysisId?: string } = {};
21 + try { body = await req.json(); } catch { /* corps vide permis */ }
22 + try {
23 + const cur = body.analysisId ? getById(body.analysisId) : getLatest(decodeURIComponent(uid));
24 + if (!cur || cur.listingUid !== decodeURIComponent(uid)) return NextResponse.json({ error: "aucune analyse" }, { status: 404 });
25 + const v = recalc(cur.id);
26 + return NextResponse.json(v, { headers: { "Cache-Control": "no-store" } });
27 + } catch (e) {
28 + if (e instanceof AnalysisError) return NextResponse.json({ error: e.message, code: e.code }, { status: e.status });
29 + return NextResponse.json({ error: (e as Error).message }, { status: 500 });
30 + }
31 +}
added src/app/api/listings/[uid]/technical-similar/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +// Vrai-Prix — propriétés de construction similaire (vecteur technique, cosinus) — comparables TECHNIQUES, pas de marché.
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { getById, getLatest, technicalSimilar } from "@/lib/cost/ai/service";
4 +import { getListing } from "@/lib/immoka";
5 +
6 +export const runtime = "nodejs";
7 +
8 +export async function GET(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) {
9 + const { uid } = await params;
10 + const id = req.nextUrl.searchParams.get("id");
11 + const v = id ? getById(id) : getLatest(decodeURIComponent(uid));
12 + if (!v || v.listingUid !== decodeURIComponent(uid)) return NextResponse.json({ similar: [] });
13 + const sims = technicalSimilar(v.id, Number(req.nextUrl.searchParams.get("limit") ?? 6)).map((s) => {
14 + const d = getListing(s.listingUid);
15 + return { ...s, address: d?.listing.address ?? null, city: d?.listing.city ?? null, price: d?.listing.price ?? null, image: d?.images[0] ?? null, areaSqft: d?.listing.area_sqft ?? null, yearBuilt: d?.listing.year_built ?? null };
16 + });
17 + return NextResponse.json({ analysisId: v.id, similar: sims }, { headers: { "Cache-Control": "no-store" } });
18 +}
modified src/app/confidentialite/ConfidentialiteClient.tsx +2 −0
@@ -22,6 +22,7 @@ const FR = [
22 22 title: "Finalités et conservation",
23 23 paras: [
24 24 "Le courriel fourni sert exclusivement à vous transmettre le rapport demandé et, si vous y consentez explicitement, des suivis liés à Vrai-Prix. Il est conservé au maximum 24 mois après la dernière interaction, puis supprimé. Les journaux techniques sont conservés au plus 90 jours. Les données sont hébergées sur une infrastructure privée située au Canada (Québec).",
25 + "Propriétés à vendre et analyse du bâtiment par IA : la section « À vendre » reproduit des annonces publiques (base Immo-Ka du Groupe Ka) dont les photos, textes et prix appartiennent aux vendeurs et courtiers. L'analyse IA du bâtiment n'est jamais lancée automatiquement : elle s'exécute uniquement à votre demande, envoie les photos publiques de l'annonce et sa description à un modèle de langage (Anthropic, États-Unis) pour en décrire les composantes visibles, et ne transmet aucun renseignement sur vous. Les copies d'images téléchargées pour l'analyse sont supprimées après 30 jours ; seuls le résultat structuré et l'estimation de coût sont conservés. Vos choix dans les ateliers (comparables, ajustements, mode exercice) restent dans le stockage local de votre navigateur et ne sont pas transmis à nos serveurs.",
25 26 ],
26 27 },
27 28 {
@@ -71,6 +72,7 @@ const EN = [
71 72 title: "Purposes and retention",
72 73 paras: [
73 74 "The email you provide is used exclusively to send you the requested report and, with your explicit consent, Vrai-Prix follow-ups. It is kept at most 24 months after the last interaction, then deleted. Technical logs are kept at most 90 days. Data is hosted on private infrastructure located in Canada (Québec).",
75 + "Properties for sale and AI building analysis: the “For sale” section reproduces public listings (Immo-Ka database, Groupe Ka) whose photos, texts and prices belong to sellers and brokers. The AI building analysis is never launched automatically: it runs only at your request, sends the listing's public photos and description to a language model (Anthropic, United States) to describe the visible components, and transmits no information about you. Image copies downloaded for the analysis are deleted after 30 days; only the structured result and the cost estimate are kept. Your choices in the workshops (comparables, adjustments, exercise mode) stay in your browser's local storage and are not sent to our servers.",
74 76 ],
75 77 },
76 78 {
added src/app/cout/admin/page.tsx +9 −0
@@ -0,0 +1,9 @@
1 +// Vrai-Prix — méthode du coût : console d'administration (jeton).
2 +import type { Metadata } from "next";
3 +import AdminConsole from "@/components/cout/AdminConsole";
4 +
5 +export const metadata: Metadata = { title: "Administration — base de coûts", robots: { index: false, follow: false } };
6 +
7 +export default function CoutAdminPage() {
8 + return <AdminConsole />;
9 +}
added src/app/cout/assemblage/[code]/page.tsx +45 −0
@@ -0,0 +1,45 @@
1 +// Vrai-Prix — méthode du coût : fiche d'un assemblage.
2 +import type { Metadata } from "next";
3 +import { notFound } from "next/navigation";
4 +import AssemblyDetail, { type AssemblyDetailData } from "@/components/cout/AssemblyDetail";
5 +import { buildContext, loadLocations } from "@/lib/cost/catalog";
6 +import { assemblyUnitCost } from "@/lib/cost/engine";
7 +import { cached } from "@/lib/cost/cache";
8 +
9 +export const dynamic = "force-dynamic";
10 +
11 +function load(code: string): AssemblyDetailData | null {
12 + return cached(`assembly-page:${code}`, 10 * 60_000, () => {
13 + const ctx = buildContext({ locationCode: "QC-MTL" });
14 + const a = ctx.assemblies.get(code);
15 + if (!a) return null;
16 + const unit = assemblyUnitCost(a, ctx);
17 + const regional = loadLocations().map((l) => ({ code: l.code, nameFr: l.nameFr, nameEn: l.nameEn, direct: Math.round(100 * (unit.material * l.materialFactor + unit.labour * l.labourFactor + unit.equipment * l.equipmentFactor)) / 100, materialFactor: l.materialFactor, labourFactor: l.labourFactor, equipmentFactor: l.equipmentFactor }));
18 + const history: AssemblyDetailData["history"] = [];
19 + const now = new Date(ctx.asOf);
20 + for (let k = 7; k >= 0; k--) {
21 + const d = new Date(now); d.setMonth(d.getMonth() - 3 * k);
22 + const asOf = d.toISOString().slice(0, 10);
23 + const c2 = k === 0 ? ctx : buildContext({ asOf, locationCode: "QC-MTL" });
24 + const u2 = assemblyUnitCost(a, c2);
25 + const kinds = new Set(u2.components.filter((c) => c.itemCode).map((c) => c.provenance.kind));
26 + history.push({ date: asOf, direct: u2.direct, material: u2.material, labour: u2.labour, equipment: u2.equipment, kind: kinds.has("observed") || kinds.has("official") ? "observed" : kinds.has("indexed") ? "indexed" : "reference" });
27 + }
28 + const neighbours = [...ctx.assemblies.values()].filter((x) => x.category === a.category && x.code !== a.code).slice(0, 8).map((x) => { const u = assemblyUnitCost(x, ctx); return { code: x.code, nameFr: x.nameFr, nameEn: x.nameEn, unit: x.unit, direct: u.direct }; });
29 + return { asOf: ctx.asOf, location: ctx.location, assembly: a, unit, regional, history, neighbours };
30 + });
31 +}
32 +
33 +export async function generateMetadata({ params }: { params: Promise<{ code: string }> }): Promise<Metadata> {
34 + const { code } = await params;
35 + const d = load(decodeURIComponent(code));
36 + if (!d) return { title: "Assemblage introuvable" };
37 + return { title: `${d.assembly.nameFr} — ${d.unit.direct.toLocaleString("fr-CA", { maximumFractionDigits: 2 })} $ / ${d.assembly.unit} · assemblage`, description: d.assembly.descriptionFr, alternates: { canonical: `/cout/assemblage/${encodeURIComponent(d.assembly.code)}` } };
38 +}
39 +
40 +export default async function AssemblyPage({ params }: { params: Promise<{ code: string }> }) {
41 + const { code } = await params;
42 + const d = load(decodeURIComponent(code));
43 + if (!d) notFound();
44 + return <AssemblyDetail data={d} />;
45 +}
added src/app/cout/assemblage/page.tsx +34 −0
@@ -0,0 +1,34 @@
1 +// Vrai-Prix — méthode du coût : catalogue des assemblages (+ fiche d'un article via ?item=CODE).
2 +import type { Metadata } from "next";
3 +import AssemblyCatalog, { type CatalogRow, type ItemCard } from "@/components/cout/AssemblyCatalog";
4 +import { buildContext, getItem, priceHistory } from "@/lib/cost/catalog";
5 +import { assemblyUnitCost } from "@/lib/cost/engine";
6 +import { cached } from "@/lib/cost/cache";
7 +
8 +export const dynamic = "force-dynamic";
9 +
10 +export const metadata: Metadata = {
11 + title: "Catalogue des assemblages de construction — méthode du coût",
12 + description: "Plus de 70 assemblages résidentiels (fondations, structure, enveloppe, toiture, ouvertures, finitions, cuisine, salle de bain, mécanique, électricité, extérieur) décomposés en matériaux, heures de main-d'œuvre APCHQ/CCQ et équipement, avec provenance de chaque prix.",
13 + alternates: { canonical: "/cout/assemblage" },
14 +};
15 +
16 +export default async function AssemblyCatalogPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
17 + const sp = await searchParams;
18 + const itemCode = typeof sp.item === "string" ? sp.item.slice(0, 40) : null;
19 + let rows: CatalogRow[] = [];
20 + let asOf = "";
21 + let item: ItemCard | null = null;
22 + try {
23 + const data = cached("catalog:QC-MTL", 10 * 60_000, () => {
24 + const ctx = buildContext({ locationCode: "QC-MTL" });
25 + return { asOf: ctx.asOf, rows: [...ctx.assemblies.values()].map((a) => { const u = assemblyUnitCost(a, ctx); return { code: a.code, nameFr: a.nameFr, nameEn: a.nameEn, unit: a.unit, category: a.category, quality: a.quality, economicLife: a.economicLife, material: u.material, labour: u.labour, equipment: u.equipment, direct: u.direct, observedShare: u.observedShare, freshestObservation: u.freshestObservation, components: a.components.length } satisfies CatalogRow; }), ctx };
26 + });
27 + rows = data.rows; asOf = data.asOf;
28 + if (itemCode) {
29 + const it = getItem(itemCode);
30 + if (it) item = { item: it, price: data.ctx.prices.get(it.code) ?? null, history: priceHistory(it.code).map((h) => ({ date: h.date, price: h.price, source: h.source, url: h.url, regular: h.regular, kind: h.kind, outlier: h.outlier })), usedBy: [...data.ctx.assemblies.values()].filter((a) => a.components.some((c) => c.itemCode === it.code)).map((a) => ({ code: a.code, nameFr: a.nameFr, nameEn: a.nameEn, unit: a.unit, quantity: a.components.find((c) => c.itemCode === it.code)!.quantity })) };
31 + }
32 + } catch { /* base indisponible */ }
33 + return <AssemblyCatalog rows={rows} asOf={asOf} item={item} />;
34 +}
added src/app/cout/page.tsx +37 −0
@@ -0,0 +1,37 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * /cout — Méthode du coût : landing « Coût aujourd'hui » ou atelier
4 + * (?property=<id_provinc> | ?mode=construction | ?estimate=<uuid>).
5 + * Les données de la landing sont lues côté serveur dans la base de coûts locale
6 + * (jamais de crawl dans le chemin utilisateur).
7 + */
8 +import type { Metadata } from "next";
9 +import Landing from "@/components/cout/Landing";
10 +import Workbench from "@/components/cout/Workbench";
11 +import { buildOverview, loadLocations } from "@/lib/cost/catalog";
12 +import { cached } from "@/lib/cost/cache";
13 +import type { CostOverview } from "@/lib/cost/types";
14 +
15 +export const dynamic = "force-dynamic";
16 +
17 +export const metadata: Metadata = {
18 + title: "Méthode du coût — combien coûterait ce bâtiment à reconstruire aujourd'hui ?",
19 + description:
20 + "Méthode du coût : coût de remplacement à neuf décomposé par assemblages (matériaux, main-d'œuvre APCHQ/CCQ, équipement), facteurs régionaux, frais indirects, dépréciation physique, fonctionnelle et externe, valeur du terrain, fourchette et indice de confiance. Chaque hypothèse et chaque source sont visibles.",
21 + alternates: { canonical: "/cout" },
22 +};
23 +
24 +export default async function CoutPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
25 + const sp = await searchParams;
26 + const property = typeof sp.property === "string" ? sp.property.replace(/\D/g, "") || null : null;
27 + const estimate = typeof sp.estimate === "string" && /^[0-9a-f-]{36}$/.test(sp.estimate) ? sp.estimate : null;
28 + const construction = sp.mode === "construction";
29 + if (property || estimate || construction) {
30 + let locations: CostOverview["locations"] = [];
31 + try { locations = loadLocations(); } catch { /* base indisponible */ }
32 + return <Workbench key={`${property ?? ""}|${estimate ?? ""}|${construction}`} propertyId={property} estimateId={estimate} locations={locations} />;
33 + }
34 + let overview: CostOverview | null = null;
35 + try { overview = cached("overview", 10 * 60_000, () => buildOverview()); } catch { overview = null; }
36 + return <Landing overview={overview} />;
37 +}
modified src/app/methodologie/MethodologieClient.tsx +80 −0
@@ -93,6 +93,46 @@ const FR: Section[] = [
93 93 "L'honnêteté méthodologique exige de nommer ce que le système ne voit pas. Les rénovations intérieures, garages, piscines et l'état d'entretien n'apparaissent pas dans les données ouvertes du rôle. Dans les marchés ruraux minces, la dispersion dépasse les cibles IAAO urbaines — l'indice de confiance le reflète. Les unités d'une même tour à condos partagent un point géographique : sans numéro d'appartement, la recherche peut retourner une unité voisine. Les valeurs au rôle utilisées comme variables sont établies 18 à 24 mois avant leur entrée en vigueur. Enfin, une estimation statistique, aussi rigoureuse soit-elle, ne remplace pas l'évaluation d'un évaluateur agréé (OEAQ) ni l'analyse d'un courtier pour une mise en vente.",
94 94 ],
95 95 },
96 + {
97 + num: "08",
98 + title: "Méthode du coût — onglet Coût",
99 + paras: [
100 + "L'onglet Coût applique la troisième approche de l'évaluation : V = valeur du terrain + coût de remplacement à neuf (RCN) − dépréciation. Il s'agit d'un coût de REMPLACEMENT (composantes modernes équivalentes : mur 2 × 6 R24, comble R50, poutrelles ajourées), pas d'un coût de reproduction à l'identique. La formule n'est jamais masquée et chaque nombre est retraçable : source → observation → conversion d'unité → prix canonique → formule → résultat.",
101 + "Sources. Main-d'œuvre : grilles publiques « Coût horaire de la main-d'œuvre » de l'APCHQ (fondées sur les conventions collectives CCQ, secteur résidentiel léger, compagnon) — coût EMPLOYEUR complet (taux + vacances 13 % + avantages sociaux + AE, RQAP, FSS, CCQ, CNESST…), jamais le seul salaire ; le connecteur CCQ tourne en détection de changement. Indices : Statistique Canada 18-10-0289-01 (indices des prix de la construction de bâtiments, Montréal et Québec, résidentiel et divisions), trimestriel, pour actualiser les observations anciennes (Cost_t1 = Cost_t0 × Index_t1 / Index_t0). Matériaux : observations de prix publiques chez Canac, BMR et Patrick Morin (URL et date conservées, prix régulier préféré au prix promotionnel, médiane robuste multi-source, aberrations marquées mais jamais effacées). Articles sans observation détaillant (béton livré, fermes, fenêtres sur mesure, armoires…) : prix de référence interne étiqueté « hypothèse », qui abaisse la couverture observée et la confiance. Benchmarks : Altus Group (Canadian Cost Guide) par import manuel après téléchargement légal ; RSMeans par import licencié seulement. L'interface ne déclenche jamais de collecte : la synchronisation des sources tourne la nuit, hors du chemin de l'utilisateur.",
102 + "Assemblages et quantités. Un assemblage (≈ 100 au catalogue : fondations, structure, enveloppe, toiture, ouvertures, finitions, cuisine, salle de bain, plomberie, CVCA, électricité, extérieur) réunit des articles (quantité par unité × (1 + pertes) × prix), des heures de métier (× coût employeur) et de l'équipement. Le moteur de géométrie dérive les quantités à partir de la description du bâtiment avec des hypothèses de prise de quantités affichées dans chaque formule : rapport de forme 1,5, hauteur d'étage 9 pi, montants à 16 po c/c, 1 fenêtre de 10 pi² par 90 pi² de plancher (≈ 11 % vitré), 0,09 pi lin de cloison par pi², toit = empreinte × facteur de pente × 1,10 de débords, excavation = (empreinte + 3 pi × périmètre) × profondeur. Chaque quantité peut être remplacée (source « saisi ») et chaque assemblage exclu.",
103 + "Coûts directs, indirects, frais généraux, profit, contingence. Direct = Σ(matériaux × facteur matériaux + main-d'œuvre × facteur main-d'œuvre + équipement × facteur équipement). Indirects = direct × Σ pourcentages (plans 3 %, permis 0,8 %, inspection 0,5 %, assurances 1,5 %, mobilisation 1,5 %, gestion 3 %, financement 2 %, administration 1 % — hypothèses documentées, modifiables). Frais généraux = (direct + indirects) × 7 % ; profit = (direct + indirects + frais généraux) × 8 % ; contingence = (direct + indirects) × 3 % — trois notions distinctes, jamais fusionnées. RCN = direct + indirects + frais généraux + profit + contingence.",
104 + "Localisation. Les conventions collectives de l'industrie (Loi R-20) fixent des taux uniformes au Québec : facteur main-d'œuvre 1,00 partout sauf régions éloignées (Abitibi, Côte-Nord, Gaspésie, Nord-du-Québec — primes et hébergement). Les facteurs matériaux et équipement (1,00 à Montréal, jusqu'à 1,20 au Nord-du-Québec) traduisent le transport et la disponibilité : hypothèses documentées, résolues par municipalité, puis par centre le plus proche, puis « rural ».",
105 + "Dépréciation. Physique : méthode âge-vie (âge effectif ÷ vie économique, plafonnée à 90 %) ou, en mode avancé, composante par composante — chaque groupe (structure 75 ans, toiture 22, enveloppe 40, ouvertures 30, intérieur 25, cuisine 20, salles de bain 22, mécanique 22, électricité 35, site 25) porte sa vie et sa condition ; les règles condition → âge effectif (neuf 0 %, excellent 10 %, rénové 12 %, très bon 20 %, bon 35 %, moyen 55 %, sous la moyenne 70 %, mauvais 85 % de la vie) sont des hypothèses stockées en base et modifiables. Fonctionnelle : déficiences saisies, curables (coût de correction) ou incurables (perte de valeur). Externe : montant saisi, 0 $ par défaut — jamais calculée sans preuve.",
106 + "Terrain. En mode propriété, la valeur du terrain au rôle 2026 (MAMH) est préremplie et identifiée comme telle ; vous pouvez la conserver, la remplacer (comparables de terrains, technique résiduelle) ou la retirer. La valeur au rôle n'est pas la valeur marchande du terrain : elle est fixée 18 à 24 mois avant l'entrée en vigueur du rôle.",
107 + "Fourchette et confiance. Chaque ligne porte un écart-type (dispersion des prix observés ou ±8 % observé / ±15 % référence / ±20 % hypothèse, heures ±15 %, incertitude de quantité selon sa source) ; les lignes se combinent en racine des carrés, plus une composante systématique (localisation 2-4 %, modèle d'assemblages 5 %) ; P10/P90 = RCN ± 1,28 σ. L'indice de confiance (0-100, A ≥ 80, B ≥ 65, C ≥ 50, D) additionne fraîcheur des observations (20), couverture observée des matériaux (20), localisation (15), main-d'œuvre officielle (15), benchmarks (10) et complétude des caractéristiques du bâtiment (20). Un benchmark hors plage est signalé, jamais utilisé pour recaler le moteur.",
108 + "Annonces à vendre. Pour une propriété réellement à vendre (onglet À vendre), l'analyse IA du bâtiment lit les photos et le texte de l'annonce pour en déduire les composantes visibles (structure, revêtements, toiture, fenêtres, finitions, état) ; chaque inférence porte sa confiance et sa provenance, peut être corrigée à la main, puis est chiffrée par le même moteur déterministe. Le modèle ne produit jamais de montant : il décrit, le moteur chiffre.",
109 + "Reproductibilité et limites. Chaque estimation enregistre sa version de méthode, de base de coûts et d'assemblages, la date des prix et un instantané des prix et taux utilisés : elle se reconstruit à l'identique. Limites : hypothèses de quantités génériques (pas de plans), prix de référence pour une partie des articles, dépréciation dépendante de la condition saisie, condos limités à l'unité (parties communes exclues). Les trois lectures de la valeur (hédonique, comparables, coût) sont comparées, jamais moyennées.",
110 + ],
111 + kv: [
112 + ["Formule", "V = terrain + RCN − (D_phys + D_fonct + D_ext)"],
113 + ["Main-d'œuvre", "APCHQ (coût employeur complet) · CCQ"],
114 + ["Indices", "StatCan 18-10-0289-01 (trimestriel)"],
115 + ["Matériaux", "Canac · BMR · Patrick Morin (+ références étiquetées)"],
116 + ["Assemblages", "≈ 100, ≈ 550 composants, ≈ 180 articles"],
117 + ["Confiance", "20 + 20 + 15 + 15 + 10 + 20 = 100"],
118 + ],
119 + },
120 + {
121 + num: "09",
122 + title: "La mesure à l'épreuve du marché — propriétés à vendre",
123 + paras: [
124 + "L'onglet À vendre confronte le moteur à la réalité du marché : plus de 70 000 propriétés réellement en vente au Québec (base Immo-Ka, rafraîchie chaque nuit) sont jumelées spatialement à leur unité du rôle d'évaluation (bbox lat/lng, départage par type, superficie et année ; distance médiane ≈ 14 m), puis évaluées par le moteur exactement comme une fiche ordinaire. La page /stats/marche publie le résultat : ratio médian estimation / prix demandé, erreur absolue médiane (MdAPE), calibration de l'indice de confiance A-D, performance par type, région et tranche de prix.",
125 + "Le duel des méthodes. Chaque annonce est aussi évaluée par les approches classiques — méthode du coût calibrée (terrain au marché + coût unitaire net du bâtiment, calibrés sur les ventes des 18 derniers mois grâce à la composition de la valeur au rôle), rôle indexé (IAAO) et hédonique — et par un ensemble médian. L'hybride 65/35 reste la mesure principale ; l'écart entre les lectures est une information, pas un défaut.",
126 + "L'atelier d'évaluation. Sur chaque fiche, vous pouvez refaire l'évaluation vous-même : bassin de ventes réelles (et d'annonces actives) autour du sujet, choix des comparables, grille d'ajustements en dollars pré-remplie par le moteur (marché, superficie, âge) et complétée à volonté, pondération, réconciliation (médiane, moyenne, pondérées), puis confrontation avec la mesure Vrai-Prix, le prix demandé et le rôle. Vos choix restent sur votre appareil.",
127 + "Ce que l'écart mesure vraiment. Le prix demandé n'est pas la valeur marchande : l'écart entre la mesure et le prix affiché additionne l'erreur du modèle ET la stratégie de mise en marché du vendeur. Seule la vente conclue tranche — c'est pourquoi le corpus d'apprentissage reste les 745 000 transactions réelles, et non les annonces.",
128 + ],
129 + kv: [
130 + ["Annonces mesurées", "≈ 66 000 sur ≈ 71 000 actives"],
131 + ["Jumelage", "spatial ≤ 75 m (repli 250 m), distance médiane ≈ 14 m"],
132 + ["Mesure principale", "hybride 65 % modèle · 35 % comparables"],
133 + ["Rafraîchissement", "quotidien (snapshot Immo-Ka + ré-évaluation)"],
134 + ],
135 + },
96 136 ];
97 137
98 138 const EN: Section[] = [
@@ -176,6 +216,46 @@ const EN: Section[] = [
176 216 "Methodological honesty requires naming what the system cannot see. Interior renovations, garages, pools and upkeep do not appear in open roll data. In thin rural markets, dispersion exceeds urban IAAO targets — the confidence index reflects it. Units in the same condo tower share one geographic point: without an apartment number, search may return a neighbouring unit. Assessed values used as features are set 18-24 months before taking effect. Finally, a statistical estimate, however rigorous, does not replace a chartered appraiser's evaluation (OEAQ) or a broker's analysis for a listing.",
177 217 ],
178 218 },
219 + {
220 + num: "08",
221 + title: "Cost approach — Cost tab",
222 + paras: [
223 + "The Cost tab applies the third appraisal approach: V = land value + replacement cost new (RCN) − depreciation. It is a REPLACEMENT cost (modern equivalent components: 2 × 6 R24 wall, R50 attic, I-joists), not an identical reproduction cost. The formula is never hidden and every number is traceable: source → observation → unit conversion → canonical price → formula → result.",
224 + "Sources. Labour: APCHQ public 'hourly labour cost' grids (based on CCQ collective agreements, light residential sector, journeyperson) — full EMPLOYER cost (wage + 13 % vacation + benefits + EI, QPIP, HSF, CCQ, CNESST…), never the bare wage; the CCQ connector runs in change-detection mode. Indexes: Statistics Canada 18-10-0289-01 (building construction price indexes, Montréal and Québec, residential and divisions), quarterly, to update older observations (Cost_t1 = Cost_t0 × Index_t1 / Index_t0). Materials: public price observations at Canac, BMR and Patrick Morin (URL and date kept, regular price preferred over sale price, robust multi-source median, outliers flagged but never deleted). Items without a retail observation (delivered concrete, trusses, custom windows, cabinets…): internal reference price labelled 'assumption', which lowers observed coverage and confidence. Benchmarks: Altus Group (Canadian Cost Guide) via manual import after legal download; RSMeans via licensed import only. The interface never triggers a crawl: source synchronisation runs overnight, outside the user path.",
225 + "Assemblies and quantities. An assembly (≈ 100 in the catalogue: foundations, structure, envelope, roofing, openings, finishes, kitchen, bathroom, plumbing, HVAC, electrical, exterior) combines items (quantity per unit × (1 + waste) × price), trade hours (× employer cost) and equipment. The geometry engine derives quantities from the building description with take-off assumptions shown in every formula: aspect ratio 1.5, 9 ft storey height, studs 16 in o.c., one 10 sq ft window per 90 sq ft of floor (≈ 11 % glazed), 0.09 lin ft of partition per sq ft, roof = footprint × slope factor × 1.10 overhangs, excavation = (footprint + 3 ft × perimeter) × depth. Any quantity can be replaced (source 'entered') and any assembly excluded.",
226 + "Direct, indirect, overhead, profit, contingency. Direct = Σ(materials × material factor + labour × labour factor + equipment × equipment factor). Indirects = direct × Σ percentages (plans 3 %, permits 0.8 %, inspection 0.5 %, insurance 1.5 %, mobilization 1.5 %, site management 3 %, financing 2 %, administration 1 % — documented, editable assumptions). Overhead = (direct + indirects) × 7 %; profit = (direct + indirects + overhead) × 8 %; contingency = (direct + indirects) × 3 % — three distinct notions, never merged. RCN = direct + indirects + overhead + profit + contingency.",
227 + "Location. Industry collective agreements (Act R-20) set uniform wages across Québec: labour factor 1.00 everywhere except remote regions (Abitibi, North Shore, Gaspésie, Northern Québec — premiums and lodging). Material and equipment factors (1.00 in Montréal, up to 1.20 in Northern Québec) reflect transport and availability: documented assumptions, resolved by municipality, then nearest centre, then 'rural'.",
228 + "Depreciation. Physical: age-life method (effective age ÷ economic life, capped at 90 %) or, in advanced mode, component by component — each group (structure 75 yrs, roof 22, envelope 40, openings 30, interior 25, kitchen 20, bathrooms 22, mechanical 22, electrical 35, site 25) carries its own life and condition; condition → effective-age rules (new 0 %, excellent 10 %, renovated 12 %, very good 20 %, good 35 %, average 55 %, below average 70 %, poor 85 % of life) are assumptions stored in the database and editable. Functional: entered deficiencies, curable (cost to cure) or incurable (value loss). External: entered amount, 0 $ by default — never computed without evidence.",
229 + "Land. In property mode, the 2026 roll land value (MAMH) is prefilled and identified as such; you may keep, replace (land comparables, residual technique) or remove it. The roll value is not the land's market value: it is set 18 to 24 months before the roll takes effect.",
230 + "Range and confidence. Every line carries a standard deviation (observed price dispersion or ±8 % observed / ±15 % reference / ±20 % assumption, hours ±15 %, quantity uncertainty by source); lines combine in root-sum-square, plus a systematic component (location 2-4 %, assembly model 5 %); P10/P90 = RCN ± 1.28 σ. The confidence index (0-100, A ≥ 80, B ≥ 65, C ≥ 50, D) adds observation freshness (20), observed material coverage (20), location (15), official labour (15), benchmarks (10) and building-attribute completeness (20). An out-of-range benchmark is flagged, never used to recalibrate the engine.",
231 + "Listings for sale. For a property actually for sale (For-sale tab), the AI building analysis reads the photos and the listing text to infer the visible components (structure, claddings, roofing, windows, finishes, condition); every inference carries its confidence and provenance, can be corrected by hand, then is costed by the same deterministic engine. The model never produces an amount: it describes, the engine prices.",
232 + "Reproducibility and limits. Every estimate records its method, cost-database and assembly versions, the price date and a snapshot of the prices and rates used: it can be rebuilt identically. Limits: generic quantity assumptions (no plans), reference prices for part of the items, depreciation dependent on entered condition, condos limited to the unit (common areas excluded). The three readings of value (hedonic, comparables, cost) are compared, never averaged.",
233 + ],
234 + kv: [
235 + ["Formula", "V = land + RCN − (D_phys + D_func + D_ext)"],
236 + ["Labour", "APCHQ (full employer cost) · CCQ"],
237 + ["Indexes", "StatCan 18-10-0289-01 (quarterly)"],
238 + ["Materials", "Canac · BMR · Patrick Morin (+ labelled references)"],
239 + ["Assemblies", "≈ 100, ≈ 550 components, ≈ 180 items"],
240 + ["Confidence", "20 + 20 + 15 + 15 + 10 + 20 = 100"],
241 + ],
242 + },
243 + {
244 + num: "09",
245 + title: "The measure put to the market test — properties for sale",
246 + paras: [
247 + "The For-sale tab confronts the engine with market reality: over 70,000 properties actually for sale in Québec (Immo-Ka database, refreshed nightly) are spatially matched to their assessment-roll unit (lat/lng bbox, tie-broken by type, floor area and year; median distance ≈ 14 m), then valued by the engine exactly like an ordinary sheet. The /stats/marche page publishes the outcome: median estimate / asking-price ratio, median absolute error (MdAPE), A-D confidence calibration, performance by type, region and price bracket.",
248 + "The method duel. Each listing is also appraised with the classic approaches — calibrated cost approach (market land + net unit building cost, calibrated on the last 18 months of sales using each building's value composition in the roll), indexed roll (IAAO) and hedonic — and by a median ensemble. The 65/35 hybrid remains the main measure; the gap between readings is information, not a defect.",
249 + "The valuation workshop. On every sheet you can redo the valuation yourself: a pool of real sales (and active listings) around the subject, your choice of comparables, a dollar adjustment grid prefilled by the engine (market, area, age) and extended at will, weights, reconciliation (median, mean, weighted), then a confrontation with the Vrai-Prix measure, the asking price and the roll. Your choices stay on your device.",
250 + "What the gap really measures. The asking price is not market value: the gap between the measure and the displayed price adds the model's error AND the seller's marketing strategy. Only the closed sale settles it — which is why the training corpus remains the 745,000 real transactions, not the listings.",
251 + ],
252 + kv: [
253 + ["Listings measured", "≈ 66,000 of ≈ 71,000 active"],
254 + ["Matching", "spatial ≤ 75 m (fallback 250 m), median distance ≈ 14 m"],
255 + ["Main measure", "hybrid 65 % model · 35 % comparables"],
256 + ["Refresh", "daily (Immo-Ka snapshot + re-valuation)"],
257 + ],
258 + },
179 259 ];
180 260
181 261 export default function MethodologieClient() {
modified src/app/sitemap.ts +4 −0
@@ -14,8 +14,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
14 14 const statics: MetadataRoute.Sitemap = [
15 15 { url: `${SITE_URL}/`, lastModified: LASTMOD, changeFrequency: "weekly", priority: 1 },
16 16 { url: `${SITE_URL}/municipalites`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.9 },
17 + { url: `${SITE_URL}/a-vendre`, lastModified: LASTMOD, changeFrequency: "daily", priority: 0.9 },
18 + { url: `${SITE_URL}/cout`, lastModified: LASTMOD, changeFrequency: "daily", priority: 0.9 },
19 + { url: `${SITE_URL}/cout/assemblage`, lastModified: LASTMOD, changeFrequency: "weekly", priority: 0.7 },
17 20 { url: `${SITE_URL}/carte`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.8 },
18 21 { url: `${SITE_URL}/stats`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.8 },
22 + { url: `${SITE_URL}/stats/marche`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.7 },
19 23 { url: `${SITE_URL}/ka`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.7 },
20 24 { url: `${SITE_URL}/parc`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.6 },
21 25 { url: `${SITE_URL}/methodologie`, lastModified: LASTMOD, changeFrequency: "monthly", priority: 0.7 },
added src/app/stats/marche/page.tsx +29 −0
@@ -0,0 +1,29 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /stats/marche — la mesure Vrai-Prix à l'épreuve du marché : toutes les annonces
3 +// réellement à vendre (base Immo-Ka, rafraîchie chaque nuit) ré-évaluées par le
4 +// moteur, ratio estimation / prix demandé, MdAPE, calibration A-D, duel des méthodes.
5 +import MarcheView from "@/components/MarcheView";
6 +import { loadMarcheStats } from "@/lib/marche-stats";
7 +
8 +import type { Metadata } from "next";
9 +
10 +export const dynamic = "force-dynamic";
11 +
12 +export function generateMetadata(): Metadata {
13 + const s = loadMarcheStats();
14 + const n = new Intl.NumberFormat("fr-CA").format(Math.round(s.source.valid / 1000) * 1000);
15 + return {
16 + title: "La mesure à l'épreuve du marché — estimation vs prix demandés",
17 + description: `Le moteur Vrai-Prix confronté à ~${n} annonces réellement à vendre au Québec (base Immo-Ka) : ratio médian estimation/prix demandé, MdAPE, calibration de l'indice de confiance A-D, performance par type, région et tranche de prix, duel des méthodes (hédonique, comparables, coût, rôle).`,
18 + alternates: { canonical: "/stats/marche" },
19 + };
20 +}
21 +
22 +export default function MarchePage() {
23 + const stats = loadMarcheStats();
24 + return (
25 + <div className="pb-6">
26 + <MarcheView s={stats} />
27 + </div>
28 + );
29 +}
modified src/app/stats/page.tsx +9 −0
@@ -1,7 +1,9 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 import StatsView, { type ProvStats } from "@/components/StatsView";
3 3 import StatsDashboard, { type DashboardPayload } from "@/components/StatsDashboard";
4 +import MarcheTeaser from "@/components/MarcheTeaser";
4 5 import { getDashboard } from "@/lib/stats-dashboard";
6 +import { loadMarcheStats } from "@/lib/marche-stats";
5 7 import stats from "@/data/stats.json";
6 8
7 9 import type { Metadata } from "next";
@@ -15,9 +17,16 @@ export const metadata: Metadata = {
15 17
16 18 export default function StatsPage() {
17 19 const dash = getDashboard() as unknown as DashboardPayload;
20 + // « la mesure à l'épreuve du marché » — fichier nocturne (data/) ou instantané versionné
21 + const marche = loadMarcheStats();
18 22 return (
19 23 <div className="pb-6">
20 24 <StatsView s={stats as ProvStats} />
25 + <MarcheTeaser
26 + n={marche.source.valid}
27 + mdape={marche.global.mdape ?? 0}
28 + ratio={marche.global.medianRatio ?? 0}
29 + />
21 30 <StatsDashboard initial={dash} />
22 31 </div>
23 32 );
modified src/components/FooterNav.tsx +6 −0
@@ -12,6 +12,12 @@ export default function FooterNav() {
12 12 "vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2 border-b border-transparent hover:text-green-deep hover:border-green-deep transition-colors";
13 13 return (
14 14 <nav className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-2">
15 + <Link href="/a-vendre" className={cls}>
16 + {fr ? "À vendre" : "For sale"}
17 + </Link>
18 + <Link href="/cout" className={cls}>
19 + {fr ? "Méthode du coût" : "Cost approach"}
20 + </Link>
15 21 <Link href="/ka" className={cls}>
16 22 {fr ? "Ka · agent IA" : "Ka · AI agent"}
17 23 </Link>
modified src/components/HeaderNav.tsx +2 −0
@@ -29,6 +29,8 @@ export default function HeaderNav() {
29 29
30 30 const items = [
31 31 { href: "/", label: fr ? "Estimer" : "Estimate" },
32 + { href: "/a-vendre", label: fr ? "À vendre" : "For sale" },
33 + { href: "/cout", label: fr ? "Coût" : "Cost" },
32 34 { href: "/carte", label: fr ? "Carte" : "Map" },
33 35 { href: "/ka", label: fr ? "Ka · agent IA" : "Ka · AI agent" },
34 36 { href: "/parc", label: fr ? "Parc immobilier" : "Portfolio" },
added src/components/MarcheTeaser.tsx +37 −0
@@ -0,0 +1,37 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/** Bandeau /stats → /stats/marche : la validation externe de la mesure. */
3 +"use client";
4 +import Link from "next/link";
5 +import { useLang } from "./LangContext";
6 +
7 +export default function MarcheTeaser({
8 + n,
9 + mdape,
10 + ratio,
11 +}: {
12 + n: number;
13 + mdape: number;
14 + ratio: number;
15 +}) {
16 + const { lang } = useLang();
17 + const fr = lang === "fr";
18 + const loc = fr ? "fr-CA" : "en-CA";
19 + return (
20 + <section className="mx-auto mt-12 max-w-5xl px-4">
21 + <Link
22 + href="/stats/marche"
23 + className="vp-card vp-card-hover block border-l-4 border-l-[var(--accent)] p-5"
24 + >
25 + <span className="kicker">{fr ? "Nouveau — validation externe" : "New — external validation"}</span>
26 + <h2 className="vp-display mt-2 text-[20px] font-bold uppercase tracking-[-0.02em]">
27 + {fr ? "La mesure à l'épreuve du marché" : "The measure vs. the market"}
28 + </h2>
29 + <p className="mt-2 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">
30 + {fr
31 + ? `Le moteur confronté à ${n.toLocaleString(loc)} annonces réellement à vendre (base Immo-Ka) : ratio médian estimation / prix demandé ${ratio.toLocaleString(loc, { minimumFractionDigits: 3 })}, MdAPE ${(mdape * 100).toLocaleString(loc, { maximumFractionDigits: 1 })} %, et la calibration A-D vérifiée sur le terrain →`
32 + : `The engine tested on ${n.toLocaleString(loc)} properties actually for sale (Immo-Ka data): median estimate/asking ratio ${ratio.toLocaleString(loc, { minimumFractionDigits: 3 })}, ${(mdape * 100).toLocaleString(loc, { maximumFractionDigits: 1 })}% MdAPE, and the A-D calibration verified in the field →`}
33 + </p>
34 + </Link>
35 + </section>
36 + );
37 +}
added src/components/MarcheView.tsx +475 −0
@@ -0,0 +1,475 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * /stats/marche — la mesure à l'épreuve du marché actif.
4 + * Compare l'estimation Vrai-Prix au prix demandé de ~49 000 annonces
5 + * réelles (copie de la base Immo-Ka, merge table vp_eval), et montre —
6 + * démarche — ce que l'écart mesure vraiment : l'erreur du
7 + * modèle PLUS la stratégie d'affichage du vendeur.
8 + */
9 +"use client";
10 +import { locNum } from "./MetricViz";
11 +import { useLang } from "./LangContext";
12 +
13 +/* ---------------------------------- types --------------------------------- */
14 +interface Slice {
15 + key: string;
16 + n: number;
17 + medianRatio: number | null;
18 + mdape: number | null;
19 + within10: number | null;
20 + within20: number | null;
21 + askAbove: number | null;
22 + inP10P90: number | null;
23 + medianAsk: number | null;
24 + medianEst: number | null;
25 + medianDiff: number | null;
26 + medianDiffPct: number | null;
27 + p25Diff: number | null;
28 + p75Diff: number | null;
29 +}
30 +interface MethodRow {
31 + key: string;
32 + main: boolean;
33 + n: number;
34 + coverage: number | null;
35 + medianRatio: number | null;
36 + mdape: number | null;
37 + within10: number | null;
38 + within20: number | null;
39 + medianDiff: number | null;
40 +}
41 +export interface MarcheStats {
42 + generated: string;
43 + source: {
44 + app: string;
45 + copiedAt: string;
46 + totalListings: number;
47 + geolocated: number;
48 + evaluated: number;
49 + valid: number;
50 + matchMedianM: number | null;
51 + };
52 + global: Omit<Slice, "key">;
53 + methods: MethodRow[];
54 + byConfidence: Slice[];
55 + byType: Slice[];
56 + byRegion: Slice[];
57 + byCity: Slice[];
58 + byBracket: Slice[];
59 + histogram: { below: number; above: number; bins: { lo: number; hi: number; n: number }[] };
60 + scatter: { p: number; e: number; l: string }[];
61 +}
62 +
63 +/* -------------------------------- helpers -------------------------------- */
64 +const money = (v: number | null, lang: string) =>
65 + v == null
66 + ? "—"
67 + : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {
68 + style: "currency",
69 + currency: "CAD",
70 + maximumFractionDigits: 0,
71 + }).format(v);
72 +const pct = (v: number | null, lang: string, frac = 1) =>
73 + v == null ? "—" : `${(v * 100).toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: frac })} %`;
74 +
75 +function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
76 + return (
77 + <div className="mb-4 mt-12">
78 + <span className="kicker">{kicker}</span>
79 + <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{title}</h2>
80 + </div>
81 + );
82 +}
83 +
84 +function Kpi({ label, value, hint }: { label: string; value: string; hint?: string }) {
85 + return (
86 + <div className="vp-card p-4">
87 + <div className="text-[11px] uppercase tracking-[0.08em] text-ink-3">{label}</div>
88 + <div className="vp-display mt-1 text-[26px] font-bold leading-none">{value}</div>
89 + {hint ? <div className="mt-2 text-[12px] leading-snug text-ink-2">{hint}</div> : null}
90 + </div>
91 + );
92 +}
93 +
94 +/* ------------------------- histogramme du ratio ------------------------- */
95 +function RatioHistogram({ s, fr }: { s: MarcheStats; fr: boolean }) {
96 + const bins = [
97 + { lo: 0, hi: 0.5, n: s.histogram.below },
98 + ...s.histogram.bins,
99 + { lo: 1.5, hi: Infinity, n: s.histogram.above },
100 + ];
101 + const max = Math.max(...bins.map((b) => b.n));
102 + const W = 720;
103 + const H = 210;
104 + const pad = { l: 8, r: 8, t: 14, b: 26 };
105 + const bw = (W - pad.l - pad.r) / bins.length;
106 + // repère x du ratio 1,0 : frontière entre les bacs 0,95-1,00 et 1,00-1,05
107 + const oneIdx = 1 + s.histogram.bins.findIndex((b) => Math.abs(b.lo - 1) < 1e-9);
108 + const oneX = pad.l + oneIdx * bw;
109 + return (
110 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img"
111 + aria-label={fr ? "Distribution du ratio estimation / prix demandé" : "Distribution of estimate / asking price ratio"}>
112 + {bins.map((b, i) => {
113 + const h = max ? ((H - pad.t - pad.b) * b.n) / max : 0;
114 + const near1 = b.lo >= 0.9 - 1e-9 && b.hi <= 1.1 + 1e-9;
115 + return (
116 + <rect key={i} x={pad.l + i * bw + 1} y={H - pad.b - h} width={bw - 2} height={h}
117 + fill={near1 ? "var(--accent)" : "var(--ink-3)"} opacity={near1 ? 1 : 0.55} />
118 + );
119 + })}
120 + <line x1={oneX} y1={pad.t - 6} x2={oneX} y2={H - pad.b} stroke="var(--ink)" strokeDasharray="3 3" />
121 + <text x={oneX + 5} y={pad.t + 4} fontSize="11" fill="var(--ink)" fontFamily="var(--font-mono)">
122 + 1,0 — {fr ? "estimation = prix demandé" : "estimate = asking price"}
123 + </text>
124 + {[0.5, 0.75, 1.0, 1.25, 1.5].map((v) => {
125 + const idx = 1 + (v - 0.5) / 0.05; // frontières des bacs intérieurs
126 + return (
127 + <text key={v} x={pad.l + idx * bw} y={H - 8} fontSize="10" textAnchor="middle" fill="var(--ink-3)"
128 + fontFamily="var(--font-mono)">
129 + {v.toLocaleString(fr ? "fr-CA" : "en-CA")}
130 + </text>
131 + );
132 + })}
133 + </svg>
134 + );
135 +}
136 +
137 +/* --------------------- nuage prix demandé × estimation -------------------- */
138 +function AskScatter({ s, fr }: { s: MarcheStats; fr: boolean }) {
139 + const CAP = 1_500_000;
140 + const pts = s.scatter.filter((d) => d.p <= CAP && d.e <= CAP);
141 + const W = 360;
142 + const H = 360;
143 + const pad = 34;
144 + const sc = (v: number) => pad + ((W - 2 * pad) * v) / CAP;
145 + return (
146 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full max-w-[420px]" role="img"
147 + aria-label={fr ? "Nuage prix demandé vs estimation" : "Asking price vs estimate scatter"}>
148 + {[500000, 1000000, 1500000].map((v) => (
149 + <g key={v}>
150 + <line x1={sc(v)} y1={H - pad} x2={sc(v)} y2={pad} stroke="var(--line-soft)" />
151 + <line x1={pad} y1={H - sc(v)} x2={W - pad} y2={H - sc(v)} stroke="var(--line-soft)" />
152 + <text x={sc(v)} y={H - pad + 14} fontSize="9" textAnchor="middle" fill="var(--ink-3)" fontFamily="var(--font-mono)">
153 + {v / 1000000 >= 1 ? `${v / 1000000}M` : `${v / 1000}k`}
154 + </text>
155 + <text x={pad - 6} y={H - sc(v) + 3} fontSize="9" textAnchor="end" fill="var(--ink-3)" fontFamily="var(--font-mono)">
156 + {v / 1000000 >= 1 ? `${v / 1000000}M` : `${v / 1000}k`}
157 + </text>
158 + </g>
159 + ))}
160 + <line x1={sc(0)} y1={H - sc(0)} x2={sc(CAP)} y2={H - sc(CAP)} stroke="var(--ink)" strokeDasharray="4 3" />
161 + {pts.map((d, i) => (
162 + <circle key={i} cx={sc(d.p)} cy={H - sc(d.e)} r={1.6}
163 + fill={d.l === "A" ? "var(--accent)" : d.l === "B" ? "var(--accent-bright)" : "var(--ink-3)"} opacity={0.5} />
164 + ))}
165 + <text x={W / 2} y={H - 4} fontSize="10" textAnchor="middle" fill="var(--ink-2)">
166 + {fr ? "Prix demandé" : "Asking price"}
167 + </text>
168 + <text x={10} y={H / 2} fontSize="10" textAnchor="middle" fill="var(--ink-2)" transform={`rotate(-90 10 ${H / 2})`}>
169 + {fr ? "Estimation Vrai-Prix" : "Vrai-Prix estimate"}
170 + </text>
171 + </svg>
172 + );
173 +}
174 +
175 +/* ------------------------------ tableau générique ------------------------------ */
176 +function SliceTable({ rows, label, fr, keyHeader }: { rows: Slice[]; label?: string; fr: boolean; keyHeader: string }) {
177 + return (
178 + <div className="vp-card overflow-x-auto p-0">
179 + {label ? <div className="border-b border-[var(--line)] px-4 py-2 text-[12px] font-semibold uppercase tracking-[0.06em]">{label}</div> : null}
180 + <table className="w-full text-[13px]">
181 + <thead>
182 + <tr className="border-b border-[var(--line)] text-left text-[11px] uppercase tracking-[0.06em] text-ink-3">
183 + <th className="px-4 py-2">{keyHeader}</th>
184 + <th className="px-2 py-2 text-right">n</th>
185 + <th className="px-2 py-2 text-right">{fr ? "Ratio méd." : "Med. ratio"}</th>
186 + <th className="px-2 py-2 text-right">MdAPE</th>
187 + <th className="px-2 py-2 text-right">±20 %</th>
188 + <th className="px-2 py-2 text-right">{fr ? "Écart méd." : "Med. gap"}</th>
189 + <th className="px-4 py-2 text-right">{fr ? "Prix méd." : "Med. ask"}</th>
190 + </tr>
191 + </thead>
192 + <tbody>
193 + {rows.map((r) => (
194 + <tr key={r.key} className="border-b border-[var(--line-soft)] last:border-0">
195 + <td className="px-4 py-1.5 font-medium">{r.key}</td>
196 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">{locNum(r.n, fr ? "fr" : "en", { maxFrac: 0 })}</td>
197 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">
198 + {r.medianRatio == null ? "—" : r.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { minimumFractionDigits: 3, maximumFractionDigits: 3 })}
199 + </td>
200 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(r.mdape, fr ? "fr" : "en")}</td>
201 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(r.within20, fr ? "fr" : "en", 0)}</td>
202 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">{money(r.medianDiff, fr ? "fr" : "en")}</td>
203 + <td className="px-4 py-1.5 text-right font-mono text-[12px]">{money(r.medianAsk, fr ? "fr" : "en")}</td>
204 + </tr>
205 + ))}
206 + </tbody>
207 + </table>
208 + </div>
209 + );
210 +}
211 +
212 +/* ---------------------------------- vue ---------------------------------- */
213 +export default function MarcheView({ s }: { s: MarcheStats }) {
214 + const { lang } = useLang();
215 + const fr = lang === "fr";
216 + const g = s.global;
217 + const copied = new Date(s.source.copiedAt + "T12:00:00").toLocaleDateString(fr ? "fr-CA" : "en-CA", {
218 + year: "numeric", month: "long", day: "numeric",
219 + });
220 +
221 + return (
222 + <div className="mx-auto max-w-5xl px-4 pt-10 sm:pt-14">
223 + {/* ---- manchette ---- */}
224 + <section>
225 + <span className="kicker">{fr ? "Validation externe" : "External validation"}</span>
226 + <h1 className="vp-display mt-3 text-[clamp(26px,4.6vw,46px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">
227 + {fr ? "La mesure à l'épreuve du marché" : "The measure vs. the market"}
228 + </h1>
229 + <p className="mt-4 max-w-3xl text-[15px] leading-relaxed text-ink-2">
230 + {fr ? (
231 + <>
232 + Nous avons évalué avec le moteur Vrai-Prix{" "}
233 + <b>{locNum(s.source.evaluated, "fr", { maxFrac: 0 })} annonces réellement à vendre</b> au Québec
234 + (base de l&apos;agrégateur Immo-Ka, copiée le {copied}) et comparé chaque estimation au{" "}
235 + <b>prix demandé</b> par le vendeur. C&apos;est un test grandeur nature — et une leçon d&apos;évaluation :
236 + l&apos;écart observé mélange l&apos;erreur du modèle <i>et</i> la stratégie d&apos;affichage.
237 + </>
238 + ) : (
239 + <>
240 + We ran the Vrai-Prix engine on{" "}
241 + <b>{locNum(s.source.evaluated, "en", { maxFrac: 0 })} properties actually for sale</b> in Québec
242 + (Immo-Ka aggregator database, copied on {copied}) and compared each estimate to the seller&apos;s{" "}
243 + <b>asking price</b>. A full-scale test — and an appraisal lesson: the observed gap mixes model
244 + error <i>and</i> pricing strategy.
245 + </>
246 + )}
247 + </p>
248 + <div className="mt-8 grid grid-cols-1 gap-3 sm:grid-cols-3">
249 + <Kpi
250 + label={fr ? "Annonces évaluées" : "Listings appraised"}
251 + value={locNum(s.source.valid, fr ? "fr" : "en", { maxFrac: 0 })}
252 + hint={fr ? `sur ${locNum(s.source.totalListings, "fr", { maxFrac: 0 })} annonces actives publiées` : `of ${locNum(s.source.totalListings, "en", { maxFrac: 0 })} active published listings`}
253 + />
254 + <Kpi
255 + label={fr ? "Ratio médian estimation / prix demandé" : "Median estimate / asking ratio"}
256 + value={g.medianRatio == null ? "—" : g.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { minimumFractionDigits: 3 })}
257 + hint={
258 + fr
259 + ? `les vendeurs affichent en médiane ${pct(g.medianDiffPct, "fr")} au-dessus de notre estimation`
260 + : `sellers list a median ${pct(g.medianDiffPct, "en")} above our estimate`
261 + }
262 + />
263 + <Kpi
264 + label={fr ? "Prix demandés dans P10-P90" : "Asking prices within P10-P90"}
265 + value={pct(g.inP10P90, fr ? "fr" : "en")}
266 + hint={fr ? "part des prix demandés couverts par notre intervalle de confiance" : "share of asking prices covered by our confidence interval"}
267 + />
268 + </div>
269 + </section>
270 +
271 + {/* ---- avertissement ---- */}
272 + <div className="vp-card mt-8 border-l-4 border-l-[var(--accent)] p-4 text-[13.5px] leading-relaxed text-ink-2">
273 + {fr ? (
274 + <>
275 + <b className="text-ink">Prix demandé ≠ valeur marchande.</b> Le moteur est calibré sur des{" "}
276 + <b>transactions réelles</b> (MdAPE 11,0 % contre les prix de vente). Face aux prix <i>demandés</i>, l&apos;écart
277 + médian monte à {pct(g.mdape, "fr")} : la différence contient la marge de négociation, la surenchère
278 + d&apos;affichage et les propriétés atypiques — pas seulement l&apos;erreur du modèle.{" "}
279 + {pct(g.askAbove, "fr", 0)} des annonces sont affichées <b>au-dessus</b> de notre estimation, un biais
280 + attendu et documenté en évaluation.
281 + </>
282 + ) : (
283 + <>
284 + <b className="text-ink">Asking price ≠ market value.</b> The engine is calibrated on{" "}
285 + <b>real transactions</b> (11.0% MdAPE against sale prices). Against <i>asking</i> prices the median gap
286 + rises to {pct(g.mdape, "en")}: the difference contains negotiation margin, listing premium and atypical
287 + properties — not just model error. {pct(g.askAbove, "en", 0)} of listings are priced <b>above</b> our
288 + estimate, an expected and well-documented bias.
289 + </>
290 + )}
291 + </div>
292 +
293 + {/* ---- indicateurs ---- */}
294 + <SectionTitle kicker={fr ? "Indicateurs" : "Indicators"} title={fr ? "Performance globale" : "Overall performance"} />
295 + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
296 + <Kpi label={fr ? "MdAPE vs prix demandé" : "MdAPE vs asking"} value={pct(g.mdape, fr ? "fr" : "en")} />
297 + <Kpi label={fr ? "Écart ≤ 10 %" : "Within 10%"} value={pct(g.within10, fr ? "fr" : "en")} />
298 + <Kpi label={fr ? "Écart ≤ 20 %" : "Within 20%"} value={pct(g.within20, fr ? "fr" : "en")} />
299 + <Kpi label={fr ? "Affichées au-dessus de l'estimation" : "Listed above estimate"} value={pct(g.askAbove, fr ? "fr" : "en")} />
300 + <Kpi label={fr ? "Prix demandé médian" : "Median asking price"} value={money(g.medianAsk, fr ? "fr" : "en")} />
301 + <Kpi label={fr ? "Estimation médiane" : "Median estimate"} value={money(g.medianEst, fr ? "fr" : "en")} />
302 + </div>
303 +
304 + {/* ---- l'écart en dollars ---- */}
305 + <SectionTitle kicker={fr ? "L'écart" : "The gap"} title={fr ? "Prix demandé − valeur estimée" : "Asking price − estimated value"} />
306 + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
307 + <Kpi
308 + label={fr ? "Écart médian" : "Median gap"}
309 + value={money(g.medianDiff, fr ? "fr" : "en")}
310 + hint={fr ? "en médiane, le vendeur demande ce montant de plus que notre estimation" : "median amount sellers ask above our estimate"}
311 + />
312 + <Kpi
313 + label={fr ? "Écart médian (%)" : "Median gap (%)"}
314 + value={pct(g.medianDiffPct, fr ? "fr" : "en")}
315 + hint={fr ? "(prix demandé − estimation) / estimation" : "(asking − estimate) / estimate"}
316 + />
317 + <Kpi
318 + label={fr ? "Écart interquartile" : "Interquartile gap"}
319 + value={`${money(g.p25Diff, fr ? "fr" : "en")} … ${money(g.p75Diff, fr ? "fr" : "en")}`}
320 + hint={fr ? "la moitié des annonces se situe dans cette fourchette d'écart (P25-P75)" : "half of listings fall in this gap range (P25-P75)"}
321 + />
322 + </div>
323 +
324 + {/* ---- distribution ---- */}
325 + <SectionTitle kicker={fr ? "Distribution" : "Distribution"} title={fr ? "Ratio estimation / prix demandé" : "Estimate / asking price ratio"} />
326 + <div className="vp-card p-4">
327 + <RatioHistogram s={s} fr={fr} />
328 + <p className="mt-2 text-[12.5px] text-ink-2">
329 + {fr
330 + ? "En bleu : annonces où l'estimation est à ±10 % du prix demandé. La masse à gauche de 1,0 = vendeurs qui demandent plus que notre valeur estimée."
331 + : "Blue: listings where the estimate is within ±10% of asking. Mass left of 1.0 = sellers asking more than our estimated value."}
332 + </p>
333 + </div>
334 +
335 + {/* ---- nuage + confiance ---- */}
336 + <div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
337 + <div className="vp-card p-4">
338 + <div className="mb-2 text-[12px] font-semibold uppercase tracking-[0.06em]">
339 + {fr ? "Prix demandé × estimation (échantillon)" : "Asking × estimate (sample)"}
340 + </div>
341 + <AskScatter s={s} fr={fr} />
342 + <p className="mt-1 text-[12px] text-ink-2">
343 + {fr
344 + ? "Diagonale = accord parfait. Bleu foncé : confiance A · turquoise : B · gris : C-D."
345 + : "Diagonal = perfect agreement. Dark blue: confidence A · turquoise: B · grey: C-D."}
346 + </p>
347 + </div>
348 + <div>
349 + <SliceTable
350 + rows={s.byConfidence.filter((r) => r.n > 0)}
351 + fr={fr}
352 + keyHeader={fr ? "Indice de confiance" : "Confidence index"}
353 + label={fr ? "L'indice de confiance tient sa promesse" : "The confidence index keeps its promise"}
354 + />
355 + <p className="mt-2 px-1 text-[12.5px] leading-relaxed text-ink-2">
356 + {fr
357 + ? "Point clé : l'erreur croît exactement comme l'indice l'annonce — de "
358 + : "Key point: error grows exactly as the index predicts — from "}
359 + {pct(s.byConfidence[0]?.mdape ?? null, fr ? "fr" : "en")} (A){fr ? " à " : " to "}
360 + {pct(s.byConfidence[2]?.mdape ?? null, fr ? "fr" : "en")} (C).{" "}
361 + {fr
362 + ? "En confiance A, le ratio médian est de 1,000 : aucune sur- ni sous-évaluation systématique."
363 + : "At confidence A the median ratio is 1.000: no systematic over- or under-valuation."}
364 + </p>
365 + </div>
366 + </div>
367 +
368 + {/* ---- duel des méthodes ---- */}
369 + <SectionTitle kicker={fr ? "Trois méthodes" : "Three approaches"} title={fr ? "Le duel des méthodes" : "The method duel"} />
370 + <p className="mb-4 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">
371 + {fr
372 + ? "Chaque annonce est aussi évaluée par les méthodes classiques de l'évaluation : la méthode du coût (terrain au marché + coût unitaire net du bâtiment, calibrés sur les ventes des 18 derniers mois grâce à la composition de chaque immeuble au rôle — aire d'étages, terrain, année, valeurs terrain/bâtiment) et le rôle indexé (étude de ratios de vente, IAAO). La mesure officielle Vrai-Prix reste l'hybride 65/35 — les autres servent de contre-expertise."
373 + : "Each listing is also appraised with the classic appraisal methods: the cost approach (market land + net unit building cost, calibrated on the last 18 months of sales using each building's composition in the roll — floor area, lot, year, land/building values) and the indexed roll (sales-ratio study, IAAO). The official Vrai-Prix measure remains the 65/35 hybrid — the others serve as cross-checks."}
374 + </p>
375 + <div className="vp-card overflow-x-auto p-0">
376 + <table className="w-full text-[13px]">
377 + <thead>
378 + <tr className="border-b border-[var(--line)] text-left text-[11px] uppercase tracking-[0.06em] text-ink-3">
379 + <th className="px-4 py-2">{fr ? "Méthode" : "Method"}</th>
380 + <th className="px-2 py-2 text-right">n</th>
381 + <th className="px-2 py-2 text-right">{fr ? "Ratio méd." : "Med. ratio"}</th>
382 + <th className="px-2 py-2 text-right">MdAPE</th>
383 + <th className="px-2 py-2 text-right">±10 %</th>
384 + <th className="px-2 py-2 text-right">±20 %</th>
385 + <th className="px-4 py-2 text-right">{fr ? "Écart méd." : "Med. gap"}</th>
386 + </tr>
387 + </thead>
388 + <tbody>
389 + {s.methods.map((m) => {
390 + const labels: Record<string, [string, string]> = {
391 + hybride: ["Hybride 65/35 — la mesure Vrai-Prix", "65/35 hybrid — the Vrai-Prix measure"],
392 + hedonique: ["Modèle hédonique seul (LightGBM)", "Hedonic model alone (LightGBM)"],
393 + comparables: ["Comparables ajustés seuls", "Adjusted comparables alone"],
394 + cout: ["Méthode du coût (calibrée marché)", "Cost approach (market-calibrated)"],
395 + role_indexe: ["Rôle indexé (ratios de vente IAAO)", "Indexed roll (IAAO sales ratios)"],
396 + ensemble: ["Ensemble — médiane hybride·coût·rôle", "Ensemble — median of hybrid·cost·roll"],
397 + };
398 + const lbl = labels[m.key]?.[fr ? 0 : 1] ?? m.key;
399 + return (
400 + <tr key={m.key}
401 + className={`border-b border-[var(--line-soft)] last:border-0 ${m.main ? "bg-[var(--accent-soft)] font-semibold" : ""}`}>
402 + <td className="px-4 py-1.5">{m.main ? "★ " : ""}{lbl}</td>
403 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">{locNum(m.n, fr ? "fr" : "en", { maxFrac: 0 })}</td>
404 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">
405 + {m.medianRatio == null ? "—" : m.medianRatio.toLocaleString(fr ? "fr-CA" : "en-CA", { minimumFractionDigits: 3, maximumFractionDigits: 3 })}
406 + </td>
407 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(m.mdape, fr ? "fr" : "en")}</td>
408 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(m.within10, fr ? "fr" : "en", 0)}</td>
409 + <td className="px-2 py-1.5 text-right font-mono text-[12px]">{pct(m.within20, fr ? "fr" : "en", 0)}</td>
410 + <td className="px-4 py-1.5 text-right font-mono text-[12px]">{money(m.medianDiff, fr ? "fr" : "en")}</td>
411 + </tr>
412 + );
413 + })}
414 + </tbody>
415 + </table>
416 + </div>
417 + <p className="mt-2 max-w-3xl text-[12.5px] leading-relaxed text-ink-2">
418 + {fr
419 + ? "Leçon du duel : aucune méthode ne domine partout — le coût peine sur les condos (quote-part de terrain au rôle) et les propriétés atypiques, les comparables seuls dérapent en marché mince, et c'est la combinaison qui stabilise la mesure."
420 + : "Duel takeaway: no method dominates everywhere — cost struggles with condos (land share in the roll) and atypical properties, comparables alone drift in thin markets, and the combination is what stabilizes the measure."}
421 + </p>
422 +
423 + {/* ---- segments ---- */}
424 + <SectionTitle kicker={fr ? "Segments" : "Segments"} title={fr ? "Où la mesure excelle — et où elle peine" : "Where the measure excels — and struggles"} />
425 + <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
426 + <SliceTable rows={s.byType} fr={fr} keyHeader={fr ? "Type d'annonce" : "Listing type"} label={fr ? "Par type de propriété" : "By property type"} />
427 + <SliceTable rows={s.byBracket} fr={fr} keyHeader={fr ? "Tranche de prix" : "Price bracket"} label={fr ? "Par tranche de prix demandé" : "By asking price bracket"} />
428 + <SliceTable rows={s.byRegion} fr={fr} keyHeader={fr ? "Région" : "Region"} label={fr ? "Par région" : "By region"} />
429 + <SliceTable rows={s.byCity} fr={fr} keyHeader={fr ? "Ville" : "City"} label={fr ? "Principales villes" : "Top cities"} />
430 + </div>
431 +
432 + {/* ---- méthode ---- */}
433 + <SectionTitle kicker={fr ? "Méthode" : "Method"} title={fr ? "D'où viennent ces chiffres" : "Where these numbers come from"} />
434 + <div className="vp-card p-4 text-[13.5px] leading-relaxed text-ink-2">
435 + {fr ? (
436 + <>
437 + Copie intégrale de la base d&apos;annonces <b>Immo-Ka</b> ({copied}) :{" "}
438 + {locNum(s.source.totalListings, "fr", { maxFrac: 0 })} annonces actives publiées, dont{" "}
439 + {locNum(s.source.geolocated, "fr", { maxFrac: 0 })} géolocalisées. Chaque annonce est{" "}
440 + <b>jumelée par coordonnées GPS</b> à l&apos;unité du rôle d&apos;évaluation MAMH la plus proche
441 + (rayon 75 m puis 250 m, départage par type, superficie habitable et année — distance médiane
442 + de jumelage : {locNum(s.source.matchMedianM, "fr", { maxFrac: 1, unit: "m" })}) ;{" "}
443 + {locNum(s.source.evaluated, "fr", { maxFrac: 0 })} ont ainsi été évaluées, et l&apos;analyse retient
444 + les {locNum(s.source.valid, "fr", { maxFrac: 0 })} à prix demandé ≥ 50 000 $ — le même seuil que les
445 + ventes retenues par le moteur. Le merge complet annonce ↔ estimations (hybride, coût, rôle indexé)
446 + est conservé dans la table <code className="font-mono text-[12px]">vp_eval</code> et chaque annonce
447 + passe par <i>exactement le même calcul</i> que le formulaire public : modèle hédonique + comparables
448 + ajustés, pondération 65 / 35. Ratio médian et MdAPE sont des médianes — robustes aux annonces
449 + extrêmes.
450 + </>
451 + ) : (
452 + <>
453 + Full copy of the <b>Immo-Ka</b> listings database ({copied}):{" "}
454 + {locNum(s.source.totalListings, "en", { maxFrac: 0 })} active published listings,{" "}
455 + {locNum(s.source.geolocated, "en", { maxFrac: 0 })} geolocated. Each listing is{" "}
456 + <b>matched by GPS coordinates</b> to the nearest MAMH assessment-roll unit (75 m then 250 m radius,
457 + tie-broken by type, living area and year — median match distance:{" "}
458 + {locNum(s.source.matchMedianM, "en", { maxFrac: 1, unit: "m" })});{" "}
459 + {locNum(s.source.evaluated, "en", { maxFrac: 0 })} were appraised, and the analysis keeps the{" "}
460 + {locNum(s.source.valid, "en", { maxFrac: 0 })} listings asking ≥ $50,000 — the same floor as the
461 + sales the engine uses. The full listing ↔ estimates merge (hybrid, cost, indexed roll) lives in the{" "}
462 + <code className="font-mono text-[12px]">vp_eval</code> table, and every listing goes through{" "}
463 + <i>exactly the same computation</i> as the public form: hedonic model + adjusted comparables, 65/35
464 + weighting. Median ratio and MdAPE are medians — robust to extreme listings.
465 + </>
466 + )}
467 + </div>
468 + <p className="mb-4 mt-6 text-[11.5px] text-ink-3">
469 + {fr
470 + ? `Généré le ${new Date(s.generated).toLocaleDateString("fr-CA")} · données Immo-Ka + rôles MAMH · à titre indicatif.`
471 + : `Generated ${new Date(s.generated).toLocaleDateString("en-CA")} · Immo-Ka data + MAMH rolls · for teaching purposes.`}
472 + </p>
473 + </div>
474 + );
475 +}
modified src/components/MobileTabBar.tsx +14 −7
@@ -1,9 +1,11 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 /**
3 3 * Barre de navigation mobile commune Groupe KA (ka-ui/nav — KA Tabbar v1) :
4 − * Estimer · Marché · Ventes · Méthode, accent rouge signature, bilingue.
4 + * Estimer · À vendre · Marché · Coût · Méthode, accent rouge signature, bilingue.
5 5 * Estimer mène immédiatement à la recherche d'adresse (focus du champ héros) ;
6 − * Ventes ancre le flux « dernières ventes réelles » de l'accueil.
6 + * À vendre ouvre les propriétés réellement en vente (atelier d'évaluation) ;
7 + * Coût ouvre l'atelier de la méthode du coût. Le flux « dernières ventes »
8 + * reste accessible depuis l'accueil (#ventes).
7 9 */
8 10 "use client";
9 11 import { useEffect, useState } from "react";
@@ -49,6 +51,12 @@ export default function MobileTabBar() {
49 51 },
50 52 active: (path === "/" && !ventes) || path.startsWith("/estimation"),
51 53 },
54 + {
55 + label: fr ? "À vendre" : "For sale",
56 + icon: "tag",
57 + to: "/a-vendre",
58 + active: path.startsWith("/a-vendre"),
59 + },
52 60 {
53 61 label: fr ? "Marché" : "Market",
54 62 icon: "trendup",
@@ -56,11 +64,10 @@ export default function MobileTabBar() {
56 64 active: path.startsWith("/stats") || path.startsWith("/municipalite"),
57 65 },
58 66 {
59 − label: fr ? "Ventes" : "Sales",
60 − icon: "receipt",
61 − to: "/#ventes",
62 − onClick: () => setHash("#ventes"),
63 − active: ventes,
67 + label: fr ? "Coût" : "Cost",
68 + icon: "home",
69 + to: "/cout",
70 + active: path.startsWith("/cout"),
64 71 },
65 72 {
66 73 label: fr ? "Méthode" : "Method",
modified src/components/ResultView.tsx +3 −0
@@ -285,6 +285,9 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
285 285 <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />
286 286 <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />
287 287 </div>
288 + <Link href={`/cout?property=${encodeURIComponent(u.id)}`} className="vp-mono mt-4 inline-block border-b border-accent pb-0.5 text-[11px] font-bold uppercase tracking-[0.08em] text-accent hover:text-accent-deep">
289 + {fr ? "Voir la méthode du coût" : "See the cost approach"} →
290 + </Link>
288 291 </div>
289 292 </div>
290 293 {/* registre brut complet */}
added src/components/avendre/EvalWorkbench.tsx +972 −0
@@ -0,0 +1,972 @@
1 +// Vrai-Prix — atelier « mon évaluation » d'une propriété à vendre.
2 +/**
3 + * Quatre temps, comme un rapport d'évaluation par comparaison directe :
4 + * 01 bassin — ventes réelles (et annonces actives) autour du sujet, l'utilisateur
5 + * coche celles qu'il juge comparables (les 12 du moteur sont signalées) ;
6 + * 02 grille — ajustements en dollars (marché, superficie, âge, + libres),
7 + * pré-remplis avec les formules du moteur mais entièrement modifiables ;
8 + * 03 réconciliation — médiane, moyenne, moyennes pondérées ; valeur conclue ;
9 + * 04 comparaison — face à la mesure Vrai-Prix déjà calculée (hybride, hédonique,
10 + * comparables, coût, rôle indexé, ensemble), au prix demandé et au rôle.
11 + * Tout est conservé sur l'appareil (localStorage) — aucun envoi au serveur.
12 + */
13 +"use client";
14 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15 +import Link from "next/link";
16 +import { useLang } from "@/components/LangContext";
17 +import RadarMap, { type RadarComp } from "@/components/RadarMap";
18 +import { TYPE_MATCH } from "@/lib/engine";
19 +import type { CompCandidate, CompsPayload } from "@/lib/comps";
20 +import type { TypeGroupKey } from "@/lib/immoka";
21 +import { groupLabel } from "./ListingCardView";
22 +import { dateFr, mean, median, money, num, pct, round100, signedMoney, sqftToM2, weightedMean, weightedMedian } from "./fmt";
23 +
24 +/* ---------------------------------- types --------------------------------- */
25 +export interface WorkbenchHead {
26 + uid: string;
27 + address: string | null;
28 + city: string | null;
29 + propertyType: string | null;
30 + group: TypeGroupKey;
31 + price: number;
32 + image: string | null;
33 + bedrooms: number | null;
34 + bathrooms: number | null;
35 + areaSqft: number | null;
36 + lotSqft: number | null;
37 + yearBuilt: number | null;
38 + geolocated: boolean;
39 + eval: {
40 + est: number | null;
41 + low: number | null;
42 + high: number | null;
43 + confidence: string | null;
44 + modelEst: number | null;
45 + compsEst: number | null;
46 + costEst: number | null;
47 + roleEst: number | null;
48 + ensEst: number | null;
49 + valeurRole: number | null;
50 + nComps: number | null;
51 + unitId: string | null;
52 + evaluatedAt: string | null;
53 + } | null;
54 +}
55 +
56 +interface Extra {
57 + id: string;
58 + label: string;
59 + amount: number;
60 +}
61 +interface CompState {
62 + adjTime: number;
63 + adjArea: number;
64 + adjAge: number;
65 + extras: Extra[];
66 + weight: number; // 1..5
67 + rev?: number; // incrémenté à chaque « ↺ moteur » → remonte les champs (key)
68 +}
69 +type Method = "median" | "mean" | "wmean" | "wmedian";
70 +interface Params {
71 + months: number;
72 + radiusKm: number;
73 + includeListings: boolean;
74 + sameType: boolean;
75 +}
76 +interface Saved {
77 + v: 1;
78 + params: Params;
79 + selected: string[];
80 + states: Record<string, CompState>;
81 + snap: Record<string, CompCandidate>;
82 + method: Method;
83 + rangePct: number;
84 + finalValue: number | null;
85 +}
86 +
87 +const DEFAULT_PARAMS: Params = { months: 24, radiusKm: 3, includeListings: true, sameType: true };
88 +const key = (uid: string) => `vrai-prix-avendre:${uid}`;
89 +
90 +const adjustedOf = (c: CompCandidate, s: CompState) =>
91 + c.amount + s.adjTime + s.adjArea + s.adjAge + s.extras.reduce((a, e) => a + (Number.isFinite(e.amount) ? e.amount : 0), 0);
92 +const grossAdjPct = (c: CompCandidate, s: CompState) =>
93 + ((Math.abs(s.adjTime) + Math.abs(s.adjArea) + Math.abs(s.adjAge) + s.extras.reduce((a, e) => a + Math.abs(e.amount || 0), 0)) / c.amount) * 100;
94 +
95 +const fmtDist = (m: number) => (m < 1000 ? `${m} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`);
96 +
97 +/* ------------------------------- sous-blocs -------------------------------- */
98 +function Sect({ num: n, kicker, title, right }: { num: string; kicker: string; title: string; right?: React.ReactNode }) {
99 + return (
100 + <header className="sec">
101 + <div className="flex flex-wrap items-baseline justify-between gap-2">
102 + <span className="sec-num">
103 + {n} — {kicker}
104 + </span>
105 + {right}
106 + </div>
107 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{title}</h2>
108 + </header>
109 + );
110 +}
111 +
112 +/** Champ monétaire : texte libre pendant la saisie ; remonté par `key` quand le parent réinitialise. */
113 +function MoneyInput({ value, onChange, hint, label }: { value: number; onChange: (v: number) => void; hint?: string; label: string }) {
114 + const [txt, setTxt] = useState(() => String(Math.round(value)));
115 + return (
116 + <label className="block">
117 + <span className="klabel">{label}</span>
118 + <input
119 + value={txt}
120 + inputMode="numeric"
121 + onChange={(e) => {
122 + const t = e.target.value.replace(/[^\d-]/g, "");
123 + setTxt(t);
124 + const n = Number(t);
125 + if (Number.isFinite(n) && t !== "" && t !== "-") onChange(n);
126 + if (t === "") onChange(0);
127 + }}
128 + className="vp-input vp-mono mt-0.5 text-[13px]"
129 + />
130 + {hint && <span className="vp-mono mt-0.5 block text-[9.5px] uppercase tracking-[0.05em] text-ink-3">{hint}</span>}
131 + </label>
132 + );
133 +}
134 +
135 +/** Graphique à points : chaque repère sur sa ligne, échelle commune, ma valeur en trait vertical. */
136 +function DotPlot({
137 + rows,
138 + me,
139 + lang,
140 +}: {
141 + rows: { key: string; label: string; value: number | null; band?: [number, number] | null; tone: "me" | "main" | "method" | "ask" | "role" }[];
142 + me: number | null;
143 + lang: string;
144 +}) {
145 + const vals = rows.flatMap((r) => [r.value, r.band?.[0], r.band?.[1]]).filter((v): v is number => v != null && Number.isFinite(v));
146 + if (!vals.length) return null;
147 + const lo = Math.min(...vals) * 0.96;
148 + const hi = Math.max(...vals) * 1.04;
149 + const W = 760;
150 + const L = 250;
151 + const R = 24;
152 + const RH = 30;
153 + const H = rows.length * RH + 28;
154 + const x = (v: number) => L + ((v - lo) / (hi - lo)) * (W - L - R);
155 + const ticks = 4;
156 + return (
157 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label="Comparaison des valeurs">
158 + {Array.from({ length: ticks + 1 }, (_, i) => lo + ((hi - lo) * i) / ticks).map((v, i) => (
159 + <g key={i}>
160 + <line x1={x(v)} y1={4} x2={x(v)} y2={H - 22} stroke="var(--line)" strokeWidth="1" />
161 + <text x={x(v)} y={H - 8} textAnchor="middle" fontSize="9.5" fontFamily="var(--font-jetbrains), monospace" fill="var(--ink-3)">
162 + {new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { notation: "compact", maximumFractionDigits: 0 }).format(v)} $
163 + </text>
164 + </g>
165 + ))}
166 + {me != null && Number.isFinite(me) && <line x1={x(me)} y1={2} x2={x(me)} y2={H - 22} stroke="var(--accent)" strokeWidth="1.5" strokeDasharray="4 4" />}
167 + {rows.map((r, i) => {
168 + const y = i * RH + RH / 2 + 4;
169 + const color = r.tone === "me" ? "var(--accent)" : r.tone === "ask" ? "var(--danger)" : r.tone === "role" ? "var(--ink-3)" : "var(--ink)";
170 + return (
171 + <g key={r.key}>
172 + <text x={0} y={y + 4} fontSize={r.tone === "me" || r.tone === "main" ? 11.5 : 10.5} fontWeight={r.tone === "me" || r.tone === "main" ? 700 : 500} fontFamily="var(--font-space-grotesk), sans-serif" fill={color}>
173 + {r.label.length > 40 ? r.label.slice(0, 39) + "…" : r.label}
174 + </text>
175 + <line x1={L} y1={y} x2={W - R} y2={y} stroke="var(--line-soft)" strokeWidth="1" />
176 + {r.band && r.band[1] > r.band[0] && <rect x={x(r.band[0])} y={y - 6} width={Math.max(2, x(r.band[1]) - x(r.band[0]))} height={12} fill={color} opacity={0.14} />}
177 + {r.value != null && Number.isFinite(r.value) && (
178 + <>
179 + {r.tone === "me" ? (
180 + <rect x={x(r.value) - 7} y={y - 7} width={14} height={14} transform={`rotate(45 ${x(r.value)} ${y})`} fill={color} stroke="var(--paper)" strokeWidth="1.5" />
181 + ) : (
182 + <circle cx={x(r.value)} cy={y} r={r.tone === "main" ? 6.5 : 5} fill={r.tone === "ask" ? "var(--paper)" : color} stroke={color} strokeWidth={2} />
183 + )}
184 + <text x={x(r.value) + (x(r.value) > W - 120 ? -12 : 12)} y={y + 4} textAnchor={x(r.value) > W - 120 ? "end" : "start"} fontSize="10.5" fontWeight="700" fontFamily="var(--font-jetbrains), monospace" fill={color}>
185 + {money(r.value, lang)}
186 + </text>
187 + </>
188 + )}
189 + </g>
190 + );
191 + })}
192 + </svg>
193 + );
194 +}
195 +
196 +/* --------------------------------- atelier --------------------------------- */
197 +export default function EvalWorkbench({ head }: { head: WorkbenchHead }) {
198 + const { lang } = useLang();
199 + const fr = lang === "fr";
200 + const [params, setParams] = useState<Params>(DEFAULT_PARAMS);
201 + const [data, setData] = useState<CompsPayload | null>(null);
202 + // la réponse (ou l'erreur) porte la clé de la requête qui l'a produite :
203 + // « en chargement » = la requête courante n'est pas encore servie
204 + const reqKey = `${head.uid}|${params.months}|${params.radiusKm}|${params.includeListings ? 1 : 0}`;
205 + const [dataKey, setDataKey] = useState<string | null>(null);
206 + const [errState, setErrState] = useState<{ key: string; msg: string } | null>(null);
207 + const error = errState?.key === reqKey ? errState.msg : null;
208 + const [selected, setSelected] = useState<string[]>([]);
209 + const [states, setStates] = useState<Record<string, CompState>>({});
210 + const [snap, setSnap] = useState<Record<string, CompCandidate>>({});
211 + const [method, setMethod] = useState<Method>("wmedian");
212 + const [rangePct, setRangePct] = useState(8);
213 + const [finalValue, setFinalValue] = useState<number | null>(null);
214 + const [finalTxt, setFinalTxt] = useState("");
215 + const [hover, setHover] = useState<string | null>(null);
216 + const [sortBy, setSortBy] = useState<"weight" | "distance" | "date" | "price">("weight");
217 + const [showAll, setShowAll] = useState(false);
218 + const [ready, setReady] = useState(false);
219 + const abortRef = useRef<AbortController | null>(null);
220 +
221 + /* --- restauration locale --- */
222 + useEffect(() => {
223 + const id = setTimeout(() => {
224 + try {
225 + const raw = window.localStorage.getItem(key(head.uid));
226 + if (raw) {
227 + const s = JSON.parse(raw) as Saved;
228 + if (s.v === 1) {
229 + setParams({ ...DEFAULT_PARAMS, ...s.params });
230 + setSelected(s.selected ?? []);
231 + setStates(s.states ?? {});
232 + setSnap(s.snap ?? {});
233 + setMethod(s.method ?? "wmedian");
234 + setRangePct(s.rangePct ?? 8);
235 + setFinalValue(s.finalValue ?? null);
236 + if (s.finalValue != null) setFinalTxt(String(Math.round(s.finalValue)));
237 + }
238 + }
239 + } catch {}
240 + setReady(true);
241 + }, 0);
242 + return () => clearTimeout(id);
243 + }, [head.uid]);
244 +
245 + /* --- sauvegarde locale (débouncée) --- */
246 + useEffect(() => {
247 + if (!ready) return;
248 + const id = setTimeout(() => {
249 + try {
250 + const s: Saved = { v: 1, params, selected, states, snap, method, rangePct, finalValue };
251 + window.localStorage.setItem(key(head.uid), JSON.stringify(s));
252 + } catch {}
253 + }, 300);
254 + return () => clearTimeout(id);
255 + }, [ready, params, selected, states, snap, method, rangePct, finalValue, head.uid]);
256 +
257 + /* --- bassin de comparables --- */
258 + useEffect(() => {
259 + if (!ready || !head.geolocated) return;
260 + abortRef.current?.abort();
261 + const ctl = new AbortController();
262 + abortRef.current = ctl;
263 + const key = `${head.uid}|${params.months}|${params.radiusKm}|${params.includeListings ? 1 : 0}`;
264 + const qs = new URLSearchParams({
265 + uid: head.uid,
266 + months: String(params.months),
267 + radius: String(params.radiusKm),
268 + listings: params.includeListings ? "1" : "0",
269 + limit: "80",
270 + });
271 + fetch(`/api/avendre/comps?${qs}`, { signal: ctl.signal })
272 + .then(async (r) => {
273 + if (!r.ok) throw new Error((await r.json()).error ?? r.statusText);
274 + return r.json() as Promise<CompsPayload>;
275 + })
276 + .then((d) => {
277 + if (ctl.signal.aborted) return;
278 + setData(d);
279 + setDataKey(key);
280 + })
281 + .catch((e) => {
282 + if (ctl.signal.aborted) return;
283 + setErrState({ key, msg: String(e.message ?? e) });
284 + });
285 + return () => ctl.abort();
286 + }, [ready, head.uid, head.geolocated, params.months, params.radiusKm, params.includeListings]);
287 +
288 + const loading = head.geolocated && ready && dataKey !== reqKey && error == null;
289 + const subject = data?.subject ?? null;
290 + const subjArea = subject?.floorArea ?? sqftToM2(head.areaSqft);
291 +
292 + const pool = useMemo(() => {
293 + if (!data) return [] as CompCandidate[];
294 + let list = data.comps;
295 + if (params.sameType && subject) {
296 + const types = TYPE_MATCH[subject.typeProp] ?? TYPE_MATCH.autre;
297 + list = list.filter((c) => c.kind === "listing" || (c.propertyType != null && types.includes(c.propertyType)));
298 + }
299 + const sorted = [...list];
300 + sorted.sort((a, b) => {
301 + if (sortBy === "distance") return a.distanceM - b.distanceM;
302 + if (sortBy === "date") return b.date.localeCompare(a.date);
303 + if (sortBy === "price") return a.amount - b.amount;
304 + return Number(b.engineUsed) - Number(a.engineUsed) || b.weight - a.weight;
305 + });
306 + return sorted;
307 + }, [data, params.sameType, sortBy, subject]);
308 +
309 + const visible = showAll ? pool : pool.slice(0, 30);
310 + const maxW = useMemo(() => Math.max(1e-6, ...pool.map((c) => c.weight)), [pool]);
311 +
312 + const compOf = useCallback((id: string): CompCandidate | undefined => data?.comps.find((c) => c.id === id) ?? snap[id], [data, snap]);
313 +
314 + const toggle = (c: CompCandidate) => {
315 + setSelected((prev) => (prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id]));
316 + setSnap((prev) => (prev[c.id] ? prev : { ...prev, [c.id]: c }));
317 + setStates((prev) =>
318 + prev[c.id] ? prev : { ...prev, [c.id]: { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], weight: c.engineUsed ? 4 : 3 } }
319 + );
320 + };
321 + const takeEngine = () => {
322 + if (!data) return;
323 + const eng = data.comps.filter((c) => c.engineUsed);
324 + setSelected(eng.map((c) => c.id));
325 + setSnap((prev) => ({ ...prev, ...Object.fromEntries(eng.map((c) => [c.id, c])) }));
326 + setStates((prev) => ({
327 + ...prev,
328 + ...Object.fromEntries(eng.map((c) => [c.id, prev[c.id] ?? { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], weight: 4 }])),
329 + }));
330 + };
331 + const clearAll = () => setSelected([]);
332 + const resetAll = () => {
333 + setSelected([]);
334 + setStates({});
335 + setSnap({});
336 + setMethod("wmedian");
337 + setRangePct(8);
338 + setFinalValue(null);
339 + setFinalTxt("");
340 + setParams(DEFAULT_PARAMS);
341 + try {
342 + window.localStorage.removeItem(key(head.uid));
343 + } catch {}
344 + };
345 + const patchState = (id: string, patch: Partial<CompState>) => setStates((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
346 +
347 + /* --- réconciliation --- */
348 + const chosen = selected.map((id) => ({ c: compOf(id), s: states[id] })).filter((x): x is { c: CompCandidate; s: CompState } => !!x.c && !!x.s);
349 + const adjusted = chosen.map(({ c, s }) => adjustedOf(c, s));
350 + const weights = chosen.map(({ s }) => s.weight);
351 + const indications: Record<Method, number | null> = {
352 + median: median(adjusted),
353 + mean: mean(adjusted),
354 + wmean: weightedMean(adjusted, weights),
355 + wmedian: weightedMedian(adjusted, weights),
356 + };
357 + const indication = indications[method];
358 + const myValue = finalValue ?? (indication != null ? round100(indication) : null);
359 + const dispersion = (() => {
360 + if (adjusted.length < 2 || indication == null || indication <= 0) return null;
361 + const dev = adjusted.map((v) => Math.abs(v - indication) / indication).sort((a, b) => a - b);
362 + return dev[Math.floor(dev.length / 2)] * 100;
363 + })();
364 + const avgGross = chosen.length ? chosen.reduce((a, { c, s }) => a + grossAdjPct(c, s), 0) / chosen.length : null;
365 + const engineOverlap = chosen.filter(({ c }) => c.engineUsed).length;
366 +
367 + /* --- repères de comparaison --- */
368 + const ev = head.eval;
369 + const vpMain = ev?.est ?? data?.engine.estimate ?? null;
370 + const vpLow = ev?.low ?? data?.engine.low ?? null;
371 + const vpHigh = ev?.high ?? data?.engine.high ?? null;
372 + const rows = [
373 + { key: "me", label: fr ? "Ma valeur conclue" : "My concluded value", value: myValue, band: myValue != null ? ([myValue * (1 - rangePct / 100), myValue * (1 + rangePct / 100)] as [number, number]) : null, tone: "me" as const },
374 + { key: "vp", label: fr ? "Vrai-Prix — hybride (mesure principale)" : "Vrai-Prix — hybrid (main measure)", value: vpMain, band: vpLow != null && vpHigh != null ? ([vpLow, vpHigh] as [number, number]) : null, tone: "main" as const },
375 + { key: "model", label: fr ? "Modèle hédonique" : "Hedonic model", value: ev?.modelEst ?? data?.engine.modelEstimate ?? null, tone: "method" as const },
376 + { key: "comps", label: fr ? "Comparables du moteur" : "Engine comparables", value: ev?.compsEst ?? data?.engine.compsEstimate ?? null, tone: "method" as const },
377 + { key: "cost", label: fr ? "Méthode du coût" : "Cost approach", value: ev?.costEst ?? null, tone: "method" as const },
378 + { key: "role_idx", label: fr ? "Rôle indexé (IAAO)" : "Indexed roll (IAAO)", value: ev?.roleEst ?? null, tone: "method" as const },
379 + { key: "ens", label: fr ? "Ensemble des méthodes" : "Ensemble of methods", value: ev?.ensEst ?? null, tone: "method" as const },
380 + { key: "ask", label: fr ? "Prix demandé" : "Asking price", value: head.price, tone: "ask" as const },
381 + { key: "role", label: fr ? "Valeur au rôle" : "Assessed value", value: ev?.valeurRole ?? null, tone: "role" as const },
382 + ].filter((r) => r.value != null || r.key === "me");
383 +
384 + const diffPct = (v: number | null) => (v != null && myValue != null && v > 0 ? ((myValue - v) / v) * 100 : null);
385 + const inBand = myValue != null && vpLow != null && vpHigh != null ? myValue >= vpLow && myValue <= vpHigh : null;
386 +
387 + const radarComps: RadarComp[] = visible.map((c) => ({
388 + id: c.id,
389 + lat: c.lat,
390 + lng: c.lng,
391 + label: `${c.street ?? "—"}${c.city ? `, ${c.city}` : ""}${c.kind === "listing" ? (fr ? " (en vente)" : " (for sale)") : ""}`,
392 + price: money(c.amount, lang),
393 + adjusted: money(states[c.id] ? adjustedOf(c, states[c.id]) : c.adjustedPrice, lang),
394 + date: dateFr(c.date, lang),
395 + distanceM: c.distanceM,
396 + weight: selected.includes(c.id) ? 1 : (c.weight / maxW) * 0.5,
397 + }));
398 +
399 + const step = (n: number, done: boolean, label: string) => (
400 + <span className={`vp-mono flex items-center gap-1.5 text-[10px] uppercase tracking-[0.08em] ${done ? "text-ink" : "text-ink-3"}`}>
401 + <span className={`inline-flex h-5 w-5 items-center justify-center border text-[10px] font-bold ${done ? "border-ink bg-ink text-paper" : "border-[var(--line-strong)]"}`}>{n}</span>
402 + {label}
403 + </span>
404 + );
405 +
406 + return (
407 + <div className="space-y-12 py-8">
408 + {/* ================= masthead ================= */}
409 + <section className="rise">
410 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">
411 + <nav className="vp-mono flex min-w-0 flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
412 + <Link href="/" className="text-ink-2 hover:text-accent-deep">
413 + Vrai&nbsp;Prix
414 + </Link>
415 + <span aria-hidden="true">/</span>
416 + <Link href="/a-vendre" className="text-ink-2 hover:text-accent-deep">
417 + {fr ? "À vendre" : "For sale"}
418 + </Link>
419 + <span aria-hidden="true">/</span>
420 + <Link href={`/a-vendre/${encodeURIComponent(head.uid)}`} className="truncate text-ink-2 hover:text-accent-deep">
421 + {fr ? "Fiche" : "Listing"}
422 + </Link>
423 + <span aria-hidden="true">/</span>
424 + <span>{fr ? "Mon évaluation" : "My valuation"}</span>
425 + </nav>
426 + <div className="flex flex-wrap gap-4 print:hidden">
427 + {step(1, selected.length > 0, fr ? `Comparables (${selected.length})` : `Comparables (${selected.length})`)}
428 + {step(2, selected.length > 0, fr ? "Ajustements" : "Adjustments")}
429 + {step(3, myValue != null, fr ? "Réconciliation" : "Reconciliation")}
430 + {step(4, myValue != null, fr ? "Comparaison" : "Comparison")}
431 + </div>
432 + </div>
433 + <div className="mt-6 grid gap-x-10 gap-y-6 lg:grid-cols-[1fr_260px]">
434 + <div className="min-w-0">
435 + <span className="kicker">{fr ? "Atelier — mode exercice" : "Workshop — exercise mode"}</span>
436 + <h1 className="vp-display mt-2 text-[clamp(22px,3.2vw,34px)] font-bold uppercase leading-tight tracking-[-0.025em]">
437 + {fr ? "Mon évaluation" : "My valuation"} <span className="text-ink-3">·</span> {head.address || head.propertyType || "—"}
438 + {head.city ? <span className="text-ink-3"> · {head.city}</span> : null}
439 + </h1>
440 + <p className="vp-mono mt-3 text-[11px] uppercase tracking-[0.06em] text-ink-2">
441 + {[
442 + groupLabel(head.group, fr),
443 + `${fr ? "prix demandé" : "asking"} ${money(head.price, lang)}`,
444 + subjArea ? `${num(subjArea, lang)} m²` : null,
445 + (subject?.yearBuilt ?? head.yearBuilt) ? `${fr ? "constr." : "built"} ${subject?.yearBuilt ?? head.yearBuilt}` : null,
446 + subject?.landArea ? `${fr ? "terrain" : "lot"} ${num(subject.landArea, lang)} m²` : null,
447 + head.bedrooms != null ? `${head.bedrooms} ${fr ? "ch." : "bd"}` : null,
448 + ]
449 + .filter(Boolean)
450 + .join(" · ")}
451 + </p>
452 + {subject && (
453 + <p className="mt-2 text-[12.5px] text-ink-3">
454 + {subject.fromUnit
455 + ? fr
456 + ? `Caractéristiques du sujet lues au rôle d'évaluation (unité ${subject.unitId}) : type ${subject.typeProp}, aire d'étages, terrain, année.`
457 + : `Subject characteristics read from the assessment roll (unit ${subject.unitId}): type ${subject.typeProp}, floor area, lot, year.`
458 + : fr
459 + ? "Aucune unité du rôle jumelée : les caractéristiques du sujet viennent de l'annonce (superficie et année déclarées par le vendeur)."
460 + : "No roll unit matched: subject characteristics come from the listing (area and year declared by the seller)."}
461 + </p>
462 + )}
463 + <div className="mt-5 flex flex-wrap gap-2.5 print:hidden">
464 + <Link href={`/a-vendre/${encodeURIComponent(head.uid)}`} className="btn btn-ghost">
465 + ← {fr ? "Fiche de la propriété" : "Property listing"}
466 + </Link>
467 + <button type="button" className="btn btn-ghost" onClick={() => window.print()}>
468 + {fr ? "Imprimer / PDF" : "Print / PDF"}
469 + </button>
470 + <button type="button" className="btn btn-ghost" onClick={resetAll}>
471 + ↺ {fr ? "Réinitialiser l'atelier" : "Reset workshop"}
472 + </button>
473 + </div>
474 + </div>
475 + {head.image && (
476 + // eslint-disable-next-line @next/next/no-img-element
477 + <img src={head.image} alt="" referrerPolicy="no-referrer" className="aspect-[4/3] w-full border border-[var(--line)] object-cover" />
478 + )}
479 + </div>
480 + </section>
481 +
482 + {!head.geolocated && (
483 + <p className="border-l-2 border-[var(--danger)] pl-4 text-[14px] text-ink-2">
484 + {fr ? "Cette annonce n'est pas géolocalisée : impossible de chercher des comparables autour d'elle." : "This listing is not geolocated: comparables cannot be searched around it."}
485 + </p>
486 + )}
487 +
488 + {/* ================= 01 — bassin ================= */}
489 + <section>
490 + <Sect
491 + num="01"
492 + kicker={fr ? "Sélection" : "Selection"}
493 + title={fr ? "Choisir mes comparables" : "Choose my comparables"}
494 + right={
495 + <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">
496 + {data ? `${pool.length} ${fr ? "candidats" : "candidates"} · ${data.comps.filter((c) => c.engineUsed).length} ${fr ? "retenus par le moteur" : "kept by the engine"}` : ""}
497 + </span>
498 + }
499 + />
500 + <p className="mt-3 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">
501 + {fr
502 + ? "Ventes réelles publiées autour du sujet (et, si vous le souhaitez, annonces actives voisines — des prix demandés, pas des ventes). Cochez les propriétés que VOUS jugez comparables : même marché, même type, superficie et âge voisins, vente récente. Le moteur signale ses 12 choix, mais c'est votre jugement qui compte ici."
503 + : "Published real sales around the subject (and, optionally, nearby active listings — asking prices, not sales). Tick the properties YOU judge comparable: same market, same type, similar size and age, recent sale. The engine flags its 12 picks, but your judgment is what matters here."}
504 + </p>
505 +
506 + <div className="mt-5 grid gap-4 print:hidden sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_1fr_auto]">
507 + <label className="block">
508 + <span className="klabel">{fr ? "Rayon de recherche" : "Search radius"}</span>
509 + <select value={params.radiusKm} onChange={(e) => setParams({ ...params, radiusKm: Number(e.target.value) })} className="vp-input mt-1 cursor-pointer">
510 + {[1, 2, 3, 5, 10, 20].map((k) => (
511 + <option key={k} value={k}>
512 + {k} km
513 + </option>
514 + ))}
515 + </select>
516 + </label>
517 + <label className="block">
518 + <span className="klabel">{fr ? "Ventes depuis" : "Sales since"}</span>
519 + <select value={params.months} onChange={(e) => setParams({ ...params, months: Number(e.target.value) })} className="vp-input mt-1 cursor-pointer">
520 + {[12, 24, 36, 60].map((m) => (
521 + <option key={m} value={m}>
522 + {m} {fr ? "mois" : "months"}
523 + </option>
524 + ))}
525 + </select>
526 + </label>
527 + <label className="block">
528 + <span className="klabel">{fr ? "Trier par" : "Sort by"}</span>
529 + <select value={sortBy} onChange={(e) => setSortBy(e.target.value as typeof sortBy)} className="vp-input mt-1 cursor-pointer">
530 + <option value="weight">{fr ? "Pertinence (poids moteur)" : "Relevance (engine weight)"}</option>
531 + <option value="distance">{fr ? "Distance" : "Distance"}</option>
532 + <option value="date">{fr ? "Date (récentes d'abord)" : "Date (newest first)"}</option>
533 + <option value="price">{fr ? "Prix" : "Price"}</option>
534 + </select>
535 + </label>
536 + <div className="flex flex-col justify-end gap-1.5 text-[13px]">
537 + <label className="flex items-center gap-2">
538 + <input type="checkbox" checked={params.sameType} onChange={(e) => setParams({ ...params, sameType: e.target.checked })} className="h-4 w-4 accent-[var(--accent)]" />
539 + {fr ? "Même famille de type" : "Same type family"}
540 + </label>
541 + <label className="flex items-center gap-2">
542 + <input type="checkbox" checked={params.includeListings} onChange={(e) => setParams({ ...params, includeListings: e.target.checked })} className="h-4 w-4 accent-[var(--accent)]" />
543 + {fr ? "Inclure les annonces actives" : "Include active listings"}
544 + </label>
545 + </div>
546 + <div className="flex flex-col justify-end gap-2">
547 + <button type="button" className="btn btn-primary whitespace-nowrap" onClick={takeEngine} disabled={!data}>
548 + {fr ? "Reprendre les 12 du moteur" : "Take the engine's 12"}
549 + </button>
550 + <button type="button" className="btn btn-ghost whitespace-nowrap" onClick={clearAll} disabled={!selected.length}>
551 + {fr ? "Tout retirer" : "Clear all"}
552 + </button>
553 + </div>
554 + </div>
555 +
556 + {error && <p className="mt-4 text-[13px] text-[var(--danger)]">{error}</p>}
557 +
558 + <div className="mt-6 grid gap-8 lg:grid-cols-[minmax(0,420px)_1fr]">
559 + {subject && (
560 + <div className="border border-[var(--line)]">
561 + <RadarMap subject={{ lat: subject.lat, lng: subject.lng, label: head.address ?? head.uid }} comps={radarComps} highlight={hover} onHover={setHover} />
562 + <p className="vp-mono border-t border-[var(--line)] px-3 py-2 text-[9.5px] uppercase tracking-[0.06em] text-ink-3">
563 + {fr ? "Gros cercles = comparables cochés. Survolez une ligne du tableau pour la repérer." : "Large circles = ticked comparables. Hover a table row to locate it."}
564 + </p>
565 + </div>
566 + )}
567 + <div className={`src-wrap ${loading ? "opacity-60" : ""}`}>
568 + <table className="src-table">
569 + <thead>
570 + <tr>
571 + <th></th>
572 + <th>#</th>
573 + <th>{fr ? "Propriété" : "Property"}</th>
574 + <th>{fr ? "Date" : "Date"}</th>
575 + <th className="text-right">{fr ? "Prix" : "Price"}</th>
576 + <th className="text-right">{fr ? "Dist." : "Dist."}</th>
577 + <th className="text-right">m²</th>
578 + <th className="text-right">{fr ? "Année" : "Year"}</th>
579 + <th>{fr ? "Type" : "Type"}</th>
580 + </tr>
581 + </thead>
582 + <tbody>
583 + {visible.map((c, i) => {
584 + const on = selected.includes(c.id);
585 + return (
586 + <tr
587 + key={c.id}
588 + onMouseEnter={() => setHover(c.id)}
589 + onMouseLeave={() => setHover(null)}
590 + onClick={() => toggle(c)}
591 + className={`cursor-pointer ${on ? "bg-[var(--accent-soft)]" : ""} ${hover === c.id ? "outline outline-1 outline-[var(--accent)]" : ""}`}
592 + >
593 + <td>
594 + <input type="checkbox" checked={on} onChange={() => toggle(c)} onClick={(e) => e.stopPropagation()} className="h-4 w-4 accent-[var(--accent)]" aria-label={fr ? "Retenir ce comparable" : "Keep this comparable"} />
595 + </td>
596 + <td className="vp-mono text-[11px] text-ink-3">{i + 1}</td>
597 + <td>
598 + <p className="font-semibold leading-snug">{c.street ?? "—"}</p>
599 + <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">
600 + {c.city ?? ""}
601 + {c.engineUsed ? <span className="ml-2 border border-ink px-1 text-ink">{fr ? "moteur" : "engine"}</span> : null}
602 + {c.kind === "listing" ? <span className="ml-2 border border-[var(--danger)] px-1 text-[var(--danger)]">{fr ? "en vente" : "for sale"}</span> : null}
603 + </p>
604 + </td>
605 + <td className="vp-mono whitespace-nowrap text-[12px]">{c.kind === "listing" ? (fr ? "actuel" : "current") : dateFr(c.date, lang)}</td>
606 + <td className="vp-mono whitespace-nowrap text-right font-bold">{money(c.amount, lang)}</td>
607 + <td className="vp-mono whitespace-nowrap text-right text-[12px]">{fmtDist(c.distanceM)}</td>
608 + <td className="vp-mono text-right text-[12px]">{c.floorArea ? num(c.floorArea, lang) : "—"}</td>
609 + <td className="vp-mono text-right text-[12px]">{c.yearBuilt ?? "—"}</td>
610 + <td className="text-[12px] text-ink-2">{c.propertyType ?? "—"}</td>
611 + </tr>
612 + );
613 + })}
614 + {!loading && data && visible.length === 0 && (
615 + <tr>
616 + <td colSpan={9} className="text-center text-ink-3">
617 + {fr ? "Aucun candidat avec ces critères — élargissez le rayon ou la fenêtre." : "No candidate with these criteria — widen the radius or window."}
618 + </td>
619 + </tr>
620 + )}
621 + </tbody>
622 + </table>
623 + {pool.length > visible.length && (
624 + <button type="button" className="btn btn-ghost mt-3 w-full print:hidden" onClick={() => setShowAll(true)}>
625 + {fr ? `Voir les ${pool.length - visible.length} autres candidats` : `Show the ${pool.length - visible.length} other candidates`}
626 + </button>
627 + )}
628 + </div>
629 + </div>
630 + </section>
631 +
632 + {/* ================= 02 — ajustements ================= */}
633 + <section>
634 + <Sect num="02" kicker={fr ? "Grille" : "Grid"} title={fr ? "Ajuster chaque comparable au sujet" : "Adjust each comparable to the subject"} />
635 + <p className="mt-3 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">
636 + {fr
637 + ? "On ajuste le comparable vers le sujet : si le comparable est plus petit, on ajoute ; s'il est meilleur, on retire. Les trois ajustements du moteur sont pré-remplis (marché = indice mensuel du type ; superficie = 50 % du $/m² marginal, borné à ±25 % ; âge = 0,5 %/an, borné à ±10 %). Modifiez-les, ajoutez les vôtres (garage, terrain, état, vue…) et pondérez de 1 à 5."
638 + : "Comparables are adjusted toward the subject: smaller comparable → add; better comparable → subtract. The engine's three adjustments are prefilled (market = monthly type index; area = 50% of marginal $/m², capped ±25%; age = 0.5%/yr, capped ±10%). Edit them, add your own (garage, lot, condition, view…) and weight 1 to 5."}
639 + </p>
640 + {chosen.length === 0 ? (
641 + <p className="mt-6 border border-dashed border-[var(--line-strong)] p-6 text-center text-[13.5px] text-ink-3">
642 + {fr ? "Cochez au moins un comparable ci-dessus pour ouvrir la grille." : "Tick at least one comparable above to open the grid."}
643 + </p>
644 + ) : (
645 + <div className="mt-6 space-y-5">
646 + {chosen.map(({ c, s }, i) => {
647 + const adj = adjustedOf(c, s);
648 + const gross = grossAdjPct(c, s);
649 + return (
650 + <article key={c.id} className="border border-[var(--line)] bg-surface p-4 sm:p-5">
651 + <header className="flex flex-wrap items-baseline justify-between gap-2 border-b border-[var(--line)] pb-3">
652 + <div className="min-w-0">
653 + <p className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">
654 + <span className="vp-mono mr-2 text-[11px] text-accent">C{i + 1}</span>
655 + {c.street ?? "—"}
656 + {c.city ? <span className="text-ink-3"> · {c.city}</span> : null}
657 + </p>
658 + <p className="vp-mono mt-0.5 text-[10px] uppercase tracking-[0.05em] text-ink-3">
659 + {c.kind === "listing" ? (fr ? "annonce active — prix demandé" : "active listing — asking price") : `${fr ? "vendu le" : "sold"} ${dateFr(c.date, lang)}`} · {fmtDist(c.distanceM)}
660 + {c.floorArea ? ` · ${num(c.floorArea, lang)} m²` : ""}
661 + {c.yearBuilt ? ` · ${c.yearBuilt}` : ""}
662 + {c.propertyType ? ` · ${c.propertyType}` : ""}
663 + {c.engineUsed ? ` · ${fr ? "retenu par le moteur" : "kept by the engine"}` : ""}
664 + {c.idProvinc ? (
665 + <>
666 + {" · "}
667 + <Link href={`/estimation/${encodeURIComponent(c.idProvinc)}`} className="text-accent underline-offset-2 hover:underline">
668 + {fr ? "fiche" : "record"}
669 + </Link>
670 + </>
671 + ) : null}
672 + {c.uid ? (
673 + <>
674 + {" · "}
675 + <Link href={`/a-vendre/${encodeURIComponent(c.uid)}`} className="text-accent underline-offset-2 hover:underline">
676 + {fr ? "annonce" : "listing"}
677 + </Link>
678 + </>
679 + ) : null}
680 + </p>
681 + </div>
682 + <div className="flex items-center gap-2 print:hidden">
683 + <button type="button" className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-2 hover:text-accent-deep" onClick={() => patchState(c.id, { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], rev: (s.rev ?? 0) + 1 })}>
684 + ↺ {fr ? "moteur" : "engine"}
685 + </button>
686 + <button type="button" className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-[var(--danger)]" onClick={() => setSelected((p) => p.filter((x) => x !== c.id))}>
687 + ✕ {fr ? "retirer" : "remove"}
688 + </button>
689 + </div>
690 + </header>
691 + <div className="mt-4 grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_1fr_1.2fr]">
692 + <div>
693 + <p className="klabel">{c.kind === "listing" ? (fr ? "Prix demandé" : "Asking price") : fr ? "Prix de vente" : "Sale price"}</p>
694 + <p className="vp-display mt-1 text-[20px] font-bold tracking-[-0.02em]">{money(c.amount, lang)}</p>
695 + {c.valeurRole ? <p className="vp-mono text-[9.5px] uppercase tracking-[0.05em] text-ink-3">{fr ? "rôle" : "roll"} {money(c.valeurRole, lang)}</p> : null}
696 + </div>
697 + <MoneyInput
698 + key={`t${s.rev ?? 0}`}
699 + label={fr ? "Ajust. marché ($)" : "Market adj. ($)"}
700 + value={s.adjTime}
701 + onChange={(v) => patchState(c.id, { adjTime: v })}
702 + hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjTime, lang)} · ${c.kind === "listing" ? (fr ? "actuel" : "current") : `${num(c.monthsAgo, lang)} ${fr ? "mois" : "mo"}`}`}
703 + />
704 + <MoneyInput
705 + key={`a${s.rev ?? 0}`}
706 + label={fr ? "Ajust. superficie ($)" : "Area adj. ($)"}
707 + value={s.adjArea}
708 + onChange={(v) => patchState(c.id, { adjArea: v })}
709 + hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjArea, lang)}${subjArea && c.floorArea ? ` · Δ ${subjArea - c.floorArea >= 0 ? "+" : "−"}${num(Math.abs(subjArea - c.floorArea), lang)} m²` : ""}`}
710 + />
711 + <MoneyInput
712 + key={`g${s.rev ?? 0}`}
713 + label={fr ? "Ajust. âge ($)" : "Age adj. ($)"}
714 + value={s.adjAge}
715 + onChange={(v) => patchState(c.id, { adjAge: v })}
716 + hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjAge, lang)}${(subject?.yearBuilt ?? head.yearBuilt) && c.yearBuilt ? ` · Δ ${(subject?.yearBuilt ?? head.yearBuilt)! - c.yearBuilt} ${fr ? "ans" : "yrs"}` : ""}`}
717 + />
718 + <div className="border-l-2 border-accent pl-4">
719 + <p className="klabel">{fr ? "Prix ajusté" : "Adjusted price"}</p>
720 + <p className="vp-display mt-1 text-[24px] font-bold tracking-[-0.02em]">{money(adj, lang)}</p>
721 + <p className={`vp-mono text-[9.5px] uppercase tracking-[0.05em] ${gross > 25 ? "text-[var(--danger)]" : "text-ink-3"}`}>
722 + {fr ? "ajust. brut" : "gross adj."} {pct(gross, lang, 1, false)}
723 + {gross > 25 ? (fr ? " — comparable faible" : " — weak comparable") : ""}
724 + </p>
725 + </div>
726 + </div>
727 + {/* ajustements libres */}
728 + <div className="mt-4 grid gap-x-6 gap-y-3 sm:grid-cols-[1fr_auto]">
729 + <div className="space-y-2">
730 + {s.extras.map((x) => (
731 + <div key={x.id} className="grid grid-cols-[1fr_140px_auto] items-end gap-3">
732 + <label className="block">
733 + <span className="klabel">{fr ? "Ajustement libre" : "Custom adjustment"}</span>
734 + <input value={x.label} onChange={(e) => patchState(c.id, { extras: s.extras.map((y) => (y.id === x.id ? { ...y, label: e.target.value } : y)) })} placeholder={fr ? "ex. garage, piscine, état, vue…" : "e.g. garage, pool, condition, view…"} className="vp-input mt-0.5 text-[13px]" />
735 + </label>
736 + <MoneyInput label="$" value={x.amount} onChange={(v) => patchState(c.id, { extras: s.extras.map((y) => (y.id === x.id ? { ...y, amount: v } : y)) })} />
737 + <button type="button" className="vp-mono pb-2 text-[11px] text-[var(--danger)] print:hidden" onClick={() => patchState(c.id, { extras: s.extras.filter((y) => y.id !== x.id) })} aria-label={fr ? "Supprimer" : "Delete"}>
738 + ✕
739 + </button>
740 + </div>
741 + ))}
742 + <button type="button" className="vp-mono text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent print:hidden" onClick={() => patchState(c.id, { extras: [...s.extras, { id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, label: "", amount: 0 }] })}>
743 + + {fr ? "ajouter un ajustement" : "add an adjustment"}
744 + </button>
745 + </div>
746 + <div>
747 + <p className="klabel">{fr ? "Poids (1-5)" : "Weight (1-5)"}</p>
748 + <div className="mt-1 flex gap-1">
749 + {[1, 2, 3, 4, 5].map((w) => (
750 + <button key={w} type="button" onClick={() => patchState(c.id, { weight: w })} className={`vp-mono h-8 w-8 border text-[12px] font-bold ${s.weight === w ? "border-ink bg-ink text-paper" : "border-[var(--line-strong)] text-ink-2"}`} aria-pressed={s.weight === w}>
751 + {w}
752 + </button>
753 + ))}
754 + </div>
755 + </div>
756 + </div>
757 + </article>
758 + );
759 + })}
760 + <p className="vp-mono overflow-x-auto whitespace-nowrap border-l-2 border-accent bg-surface px-3 py-2 text-[11.5px] text-ink">
761 + {fr ? "prix ajusté = prix + ajust. marché + ajust. superficie + ajust. âge + Σ ajustements libres" : "adjusted price = price + market adj. + area adj. + age adj. + Σ custom adjustments"}
762 + </p>
763 + </div>
764 + )}
765 + </section>
766 +
767 + {/* ================= 03 — réconciliation ================= */}
768 + <section>
769 + <Sect num="03" kicker={fr ? "Conclusion" : "Conclusion"} title={fr ? "Réconcilier ma valeur" : "Reconcile my value"} />
770 + {chosen.length === 0 ? (
771 + <p className="mt-6 text-[13.5px] text-ink-3">{fr ? "La réconciliation s'ouvre dès qu'un comparable est retenu." : "Reconciliation opens as soon as one comparable is kept."}</p>
772 + ) : (
773 + <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.3fr_1fr]">
774 + <div>
775 + <p className="klabel">{fr ? "Indications de valeur selon la règle de réconciliation" : "Value indications by reconciliation rule"}</p>
776 + <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">
777 + {(
778 + [
779 + ["median", fr ? "Médiane" : "Median"],
780 + ["mean", fr ? "Moyenne" : "Mean"],
781 + ["wmean", fr ? "Moyenne pondérée" : "Weighted mean"],
782 + ["wmedian", fr ? "Médiane pondérée" : "Weighted median"],
783 + ] as [Method, string][]
784 + ).map(([k, label]) => (
785 + <button key={k} type="button" onClick={() => setMethod(k)} aria-pressed={method === k} className={`border p-3 text-left ${method === k ? "border-ink bg-ink text-paper" : "border-[var(--line)] bg-surface hover:border-[var(--line-strong)]"}`}>
786 + <p className={`vp-mono text-[9.5px] uppercase tracking-[0.08em] ${method === k ? "text-paper/70" : "text-ink-3"}`}>{label}</p>
787 + <p className="vp-display mt-1 text-[17px] font-bold tracking-[-0.02em]">{indications[k] != null ? money(round100(indications[k]!), lang) : "—"}</p>
788 + </button>
789 + ))}
790 + </div>
791 + <div className="mt-6 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
792 + <div className="kv-cell">
793 + <p className="k">{fr ? "Comparables" : "Comparables"}</p>
794 + <p className="v">
795 + {chosen.length} <span className="text-ink-3">({engineOverlap} {fr ? "du moteur" : "engine"})</span>
796 + </p>
797 + </div>
798 + <div className="kv-cell">
799 + <p className="k">{fr ? "Étendue ajustée" : "Adjusted range"}</p>
800 + <p className="v text-[12.5px]">
801 + {money(Math.min(...adjusted), lang)} – {money(Math.max(...adjusted), lang)}
802 + </p>
803 + </div>
804 + <div className="kv-cell">
805 + <p className="k">{fr ? "Dispersion médiane" : "Median dispersion"}</p>
806 + <p className="v">{dispersion != null ? `±${pct(dispersion, lang, 1, false)}` : "—"}</p>
807 + </div>
808 + <div className="kv-cell">
809 + <p className="k">{fr ? "Ajust. brut moyen" : "Avg gross adj."}</p>
810 + <p className={`v ${avgGross != null && avgGross > 25 ? "text-[var(--danger)]" : ""}`}>{avgGross != null ? pct(avgGross, lang, 1, false) : "—"}</p>
811 + </div>
812 + </div>
813 + <p className="mt-4 max-w-2xl text-[13px] leading-relaxed text-ink-2">
814 + {fr
815 + ? "La médiane résiste aux valeurs extrêmes ; la moyenne pondérée laisse parler votre confiance dans chaque comparable. Le rapport final ne retient qu'UNE valeur conclue, arrondie, avec une fourchette qui reflète la dispersion observée."
816 + : "The median resists outliers; the weighted mean lets your confidence in each comparable speak. The final report keeps ONE concluded value, rounded, with a range reflecting the observed dispersion."}
817 + </p>
818 + </div>
819 + <div className="lg:border-l lg:border-[var(--line)] lg:pl-10">
820 + <label className="block">
821 + <span className="klabel">{fr ? "Ma valeur conclue ($)" : "My concluded value ($)"}</span>
822 + <input
823 + value={finalTxt !== "" ? finalTxt : myValue != null ? String(Math.round(myValue)) : ""}
824 + inputMode="numeric"
825 + onChange={(e) => {
826 + const t = e.target.value.replace(/\D/g, "");
827 + setFinalTxt(t);
828 + setFinalValue(t ? Number(t) : null);
829 + }}
830 + className="vp-input vp-display mt-1 text-[32px] font-bold tracking-[-0.03em]"
831 + />
832 + </label>
833 + <button
834 + type="button"
835 + className="vp-mono mt-2 text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent print:hidden"
836 + onClick={() => {
837 + setFinalValue(null);
838 + setFinalTxt("");
839 + }}
840 + >
841 + ↺ {fr ? "reprendre l'indication" : "take the indication"} ({indication != null ? money(round100(indication), lang) : "—"})
842 + </button>
843 + <label className="mt-6 block">
844 + <span className="klabel">
845 + {fr ? "Fourchette" : "Range"} ±{rangePct} %
846 + </span>
847 + <input type="range" min={1} max={30} value={rangePct} onChange={(e) => setRangePct(Number(e.target.value))} className="mt-2 w-full accent-[var(--accent)]" />
848 + </label>
849 + {myValue != null && (
850 + <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.05em] text-ink-2">
851 + {money(round100(myValue * (1 - rangePct / 100)), lang)} – {money(round100(myValue * (1 + rangePct / 100)), lang)}
852 + {subjArea ? ` · ${num(Math.round(myValue / subjArea), lang)} $/m²` : ""}
853 + </p>
854 + )}
855 + {dispersion != null && (
856 + <button type="button" className="vp-mono mt-2 text-[10px] uppercase tracking-[0.08em] text-ink-3 print:hidden" onClick={() => setRangePct(Math.max(1, Math.min(30, Math.round(dispersion))))}>
857 + {fr ? "→ aligner sur la dispersion" : "→ align on dispersion"} (±{Math.round(dispersion)} %)
858 + </button>
859 + )}
860 + </div>
861 + </div>
862 + )}
863 + </section>
864 +
865 + {/* ================= 04 — comparaison ================= */}
866 + <section>
867 + <Sect num="04" kicker={fr ? "Confrontation" : "Confrontation"} title={fr ? "Ma valeur face à la mesure Vrai-Prix" : "My value vs. the Vrai-Prix measure"} />
868 + {myValue == null ? (
869 + <p className="mt-6 text-[13.5px] text-ink-3">{fr ? "Concluez une valeur pour la comparer." : "Conclude a value to compare it."}</p>
870 + ) : (
871 + <div className="mt-6 space-y-8">
872 + <div className="border border-[var(--line)] bg-surface p-3 sm:p-5">
873 + <DotPlot rows={rows} me={myValue} lang={lang} />
874 + <p className="vp-mono mt-1 text-[9.5px] uppercase tracking-[0.06em] text-ink-3">
875 + {fr ? "Bandes : ma fourchette (bleu) et l'intervalle P10-P90 de Vrai-Prix (encre). Trait pointillé : ma valeur." : "Bands: my range (blue) and the Vrai-Prix P10-P90 interval (ink). Dashed line: my value."}
876 + </p>
877 + </div>
878 + <div className="grid gap-x-12 gap-y-8 lg:grid-cols-[1.2fr_1fr]">
879 + <div className="src-wrap">
880 + <table className="src-table">
881 + <thead>
882 + <tr>
883 + <th>{fr ? "Repère" : "Benchmark"}</th>
884 + <th className="text-right">{fr ? "Valeur" : "Value"}</th>
885 + <th className="text-right">{fr ? "Ma valeur − repère" : "My value − benchmark"}</th>
886 + <th className="text-right">%</th>
887 + </tr>
888 + </thead>
889 + <tbody>
890 + {rows
891 + .filter((r) => r.key !== "me")
892 + .map((r) => {
893 + const d = r.value != null ? myValue - r.value : null;
894 + const p = diffPct(r.value);
895 + return (
896 + <tr key={r.key} className={r.tone === "main" ? "font-bold" : ""}>
897 + <td>{r.label}</td>
898 + <td className="vp-mono text-right">{money(r.value, lang)}</td>
899 + <td className={`vp-mono text-right ${d != null && d < 0 ? "text-[var(--danger)]" : ""}`}>{d != null ? signedMoney(d, lang) : "—"}</td>
900 + <td className={`vp-mono text-right ${p != null && p < 0 ? "text-[var(--danger)]" : ""}`}>{pct(p, lang, 1)}</td>
901 + </tr>
902 + );
903 + })}
904 + </tbody>
905 + </table>
906 + </div>
907 + <div className="space-y-4 text-[14px] leading-relaxed text-ink-2 lg:border-l lg:border-[var(--line)] lg:pl-10">
908 + <p className="klabel">{fr ? "Lecture" : "Reading"}</p>
909 + {vpMain != null && (
910 + <p>
911 + {fr ? (
912 + <>
913 + Votre valeur conclue (<b className="text-ink">{money(myValue, lang)}</b>) est{" "}
914 + <b className="text-ink">{pct(diffPct(vpMain), lang, 1)}</b> par rapport à la mesure principale Vrai-Prix ({money(vpMain, lang)}
915 + {ev?.confidence ? `, confiance ${ev.confidence}` : ""}).{" "}
916 + {inBand === true ? "Elle se situe DANS l'intervalle P10-P90 de Vrai-Prix : les deux lectures sont compatibles." : inBand === false ? "Elle se situe HORS de l'intervalle P10-P90 de Vrai-Prix : l'écart mérite d'être expliqué (comparables différents, ajustements, superficie retenue)." : ""}
917 + </>
918 + ) : (
919 + <>
920 + Your concluded value (<b className="text-ink">{money(myValue, lang)}</b>) is <b className="text-ink">{pct(diffPct(vpMain), lang, 1)}</b> relative to the main Vrai-Prix measure ({money(vpMain, lang)}
921 + {ev?.confidence ? `, confidence ${ev.confidence}` : ""}).{" "}
922 + {inBand === true ? "It lies WITHIN the Vrai-Prix P10-P90 interval: both readings are compatible." : inBand === false ? "It lies OUTSIDE the Vrai-Prix P10-P90 interval: the gap deserves an explanation (different comparables, adjustments, retained area)." : ""}
923 + </>
924 + )}
925 + </p>
926 + )}
927 + <p>
928 + {fr ? (
929 + <>
930 + Face au prix demandé ({money(head.price, lang)}), votre évaluation suggère un affichage{" "}
931 + <b className="text-ink">{myValue < head.price ? `au-dessus de la valeur de ${pct(((head.price - myValue) / myValue) * 100, lang, 1, false)}` : `en dessous de la valeur de ${pct(((myValue - head.price) / myValue) * 100, lang, 1, false)}`}</b>. Rappel : un prix demandé n&apos;est pas une valeur — le marché tranche à la vente.
932 + </>
933 + ) : (
934 + <>
935 + Against the asking price ({money(head.price, lang)}), your valuation suggests a listing{" "}
936 + <b className="text-ink">{myValue < head.price ? `priced ${pct(((head.price - myValue) / myValue) * 100, lang, 1, false)} above value` : `priced ${pct(((myValue - head.price) / myValue) * 100, lang, 1, false)} below value`}</b>. Reminder: an asking price is not a value — the market decides at the sale.
937 + </>
938 + )}
939 + </p>
940 + <p>
941 + {fr
942 + ? `${engineOverlap} de vos ${chosen.length} comparables font partie des ${ev?.nComps ?? data?.engine.nComps ?? 12} retenus par le moteur${chosen.some(({ c }) => c.kind === "listing") ? " ; vous avez aussi retenu des annonces actives (prix demandés, non des ventes — à pondérer avec prudence)" : ""}.`
943 + : `${engineOverlap} of your ${chosen.length} comparables are among the ${ev?.nComps ?? data?.engine.nComps ?? 12} kept by the engine${chosen.some(({ c }) => c.kind === "listing") ? "; you also kept active listings (asking prices, not sales — weight with caution)" : ""}.`}
944 + </p>
945 + {ev?.evaluatedAt && (
946 + <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">
947 + {fr ? "Mesure Vrai-Prix calculée le" : "Vrai-Prix measure computed on"} {ev.evaluatedAt}
948 + {ev.unitId ? ` · ${fr ? "unité" : "unit"} ${ev.unitId}` : ""}
949 + </p>
950 + )}
951 + {!ev && (
952 + <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">
953 + {fr ? "Aucune mesure stockée pour cette annonce : repère Vrai-Prix calculé à la volée (comparables du moteur seulement)." : "No stored measure for this listing: Vrai-Prix benchmark computed on the fly (engine comparables only)."}
954 + </p>
955 + )}
956 + </div>
957 + </div>
958 + </div>
959 + )}
960 + </section>
961 +
962 + <p className="vp-mono border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">
963 + {fr
964 + ? "Atelier d'évaluation — vos choix restent sur cet appareil. Ni la mesure Vrai-Prix ni votre évaluation ne constituent une évaluation professionnelle certifiée (OEAQ)."
965 + : "Valuation workshop — your choices stay on this device. Neither the Vrai-Prix measure nor your valuation is a certified professional appraisal (OEAQ)."}
966 + </p>
967 + </div>
968 + );
969 +}
970 +
971 +// utilitaires exposés pour les tests
972 +export { adjustedOf, grossAdjPct };
added src/components/avendre/ListingCardView.tsx +108 −0
@@ -0,0 +1,108 @@
1 +// Vrai-Prix — carte d'annonce (liste « À vendre », voisines, comparables en vente).
2 +"use client";
3 +import Link from "next/link";
4 +import type { ListingCard } from "@/lib/immoka";
5 +import { TYPE_GROUPS } from "@/lib/listing-types";
6 +import { money, num, pct, sqftToM2 } from "./fmt";
7 +
8 +export function groupLabel(key: string, fr: boolean): string {
9 + const g = TYPE_GROUPS.find((x) => x.key === key);
10 + return g ? (fr ? g.fr : g.en) : key;
11 +}
12 +
13 +/** Pastille écart estimation vs prix demandé (encre = au-dessus, rouge = en-dessous). */
14 +export function RatioPill({ ratio, fr, size = "sm" }: { ratio: number | null; fr: boolean; size?: "sm" | "md" }) {
15 + const cls = size === "md" ? "px-2.5 py-1 text-[11px]" : "px-1.5 py-0.5 text-[9.5px]";
16 + if (ratio == null)
17 + return (
18 + <span className={`vp-mono inline-block border border-[var(--line)] uppercase tracking-[0.06em] text-ink-3 ${cls}`}>
19 + {fr ? "non évaluée" : "not evaluated"}
20 + </span>
21 + );
22 + const d = (ratio - 1) * 100;
23 + const tone =
24 + Math.abs(d) < 5
25 + ? "border-[var(--line-strong)] text-ink"
26 + : d > 0
27 + ? "border-ink bg-ink text-paper"
28 + : "border-[var(--danger)] text-[var(--danger)]";
29 + return (
30 + <span className={`vp-mono inline-block border font-bold uppercase tracking-[0.06em] ${tone} ${cls}`} title={fr ? "Estimation Vrai-Prix vs prix demandé" : "Vrai-Prix estimate vs asking price"}>
31 + {fr ? "éval." : "est."} {pct(d, fr ? "fr" : "en")}
32 + </span>
33 + );
34 +}
35 +
36 +/** Badge « Coût IA » — affiché seulement quand une analyse IA complète existe pour l'annonce (jamais avant). */
37 +export function AiCostBadge({ rcn, fr }: { rcn: number | null; fr: boolean }) {
38 + return (
39 + <span className="vp-mono inline-block border border-[var(--accent)] px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-[0.06em] text-[var(--accent-deep)]" title={fr ? "Analyse IA du bâtiment disponible" : "AI building analysis available"}>
40 + {rcn ? `RCN ${fr ? "estimé" : "est."} ${Math.round(rcn / 1000)} k$` : fr ? "Coût IA disponible" : "AI cost available"}
41 + </span>
42 + );
43 +}
44 +
45 +export default function ListingCardView({ c, fr, compact = false, ai = null }: { c: ListingCard; fr: boolean; compact?: boolean; ai?: { rcn: number | null } | null }) {
46 + const lang = fr ? "fr" : "en";
47 + const area = sqftToM2(c.areaSqft);
48 + const facts = [
49 + c.bedrooms != null ? `${c.bedrooms} ${fr ? "ch." : "bd"}` : null,
50 + c.bathrooms != null ? `${c.bathrooms} ${fr ? "sdb" : "ba"}` : null,
51 + area ? `${num(area, lang)} m²` : null,
52 + c.yearBuilt ? `${c.yearBuilt}` : null,
53 + ].filter((x): x is string => x != null);
54 + return (
55 + <Link
56 + href={`/a-vendre/${encodeURIComponent(c.uid)}`}
57 + className="group flex flex-col border border-[var(--line)] bg-surface transition-colors hover:border-[var(--line-strong)]"
58 + >
59 + <div className={`relative w-full overflow-hidden bg-[var(--surface-2)] ${compact ? "aspect-[4/3]" : "aspect-[3/2]"}`}>
60 + {c.image ? (
61 + // eslint-disable-next-line @next/next/no-img-element
62 + <img
63 + src={c.image}
64 + alt=""
65 + loading="lazy"
66 + referrerPolicy="no-referrer"
67 + className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
68 + />
69 + ) : (
70 + <div className="vp-mono flex h-full w-full items-center justify-center text-[10px] uppercase tracking-[0.1em] text-ink-3">
71 + {fr ? "sans photo" : "no photo"}
72 + </div>
73 + )}
74 + <span className="vp-mono absolute left-2 top-2 bg-paper/95 px-1.5 py-0.5 text-[9.5px] uppercase tracking-[0.08em] text-ink-2">
75 + {groupLabel(c.group, fr)}
76 + </span>
77 + {c.nImages > 1 && (
78 + <span className="vp-mono absolute bottom-2 right-2 bg-ink/80 px-1.5 py-0.5 text-[9.5px] text-paper">
79 + {c.nImages} 📷
80 + </span>
81 + )}
82 + </div>
83 + <div className="flex flex-1 flex-col p-3">
84 + <div className="flex items-baseline justify-between gap-2">
85 + <p className="vp-display text-[19px] font-bold leading-none tracking-[-0.02em]">{money(c.price, lang)}</p>
86 + <span className="flex flex-wrap items-center justify-end gap-1">
87 + {ai ? <AiCostBadge rcn={ai.rcn} fr={fr} /> : null}
88 + <RatioPill ratio={c.ratio} fr={fr} />
89 + </span>
90 + </div>
91 + <p className="mt-2 truncate text-[13.5px] font-semibold leading-snug">{c.address || c.propertyType || "—"}</p>
92 + <p className="truncate text-[12px] text-ink-2">
93 + {[c.sector, c.municipalite ?? c.city].filter(Boolean).join(" · ")}
94 + </p>
95 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">
96 + {facts.length ? facts.join(" · ") : c.propertyType ?? ""}
97 + {c.distanceM != null ? ` · ${c.distanceM < 1000 ? `${c.distanceM} m` : `${(c.distanceM / 1000).toFixed(1)} km`}` : ""}
98 + </p>
99 + {c.est != null && (
100 + <p className="vp-mono mt-auto pt-2 text-[10px] uppercase tracking-[0.05em] text-ink-2">
101 + {fr ? "Vrai-Prix" : "Vrai-Prix"} <b className="text-ink">{money(c.est, lang)}</b>
102 + {c.confidence ? ` · ${fr ? "conf." : "conf."} ${c.confidence}` : ""}
103 + </p>
104 + )}
105 + </div>
106 + </Link>
107 + );
108 +}
added src/components/avendre/ListingView.tsx +598 −0
@@ -0,0 +1,598 @@
1 +// Vrai-Prix — fiche d'une propriété à vendre : toute l'information de l'annonce,
2 +// la mesure Vrai-Prix déjà calculée, le portrait du rôle et l'accès à l'atelier.
3 +// Ordre du DOM = ordre visuel, identique mobile et desktop (standard Groupe Ka) :
4 +// galerie → prix/adresse/caractéristiques → description → inclusions → détails
5 +// → analyses (Vrai-Prix, historique de prix) → registre → voisines.
6 +"use client";
7 +import { useEffect, useState } from "react";
8 +import Link from "next/link";
9 +import { useLang } from "@/components/LangContext";
10 +import { RangeBar } from "@/components/MetricViz";
11 +import ListingCardView, { groupLabel, RatioPill } from "./ListingCardView";
12 +import { dateFr, epochDate, m2, money, num, pct, sqftToM2 } from "./fmt";
13 +import type { EvalRow, ListingCard, PricePoint, TypeGroupKey } from "@/lib/immoka";
14 +
15 +export interface ListingPageData {
16 + listing: {
17 + uid: string;
18 + source: string;
19 + url: string | null;
20 + title: string | null;
21 + address: string | null;
22 + sector: string | null;
23 + city: string | null;
24 + region: string | null;
25 + propertyType: string | null;
26 + price: number;
27 + bedrooms: number | null;
28 + bathrooms: number | null;
29 + powderRooms: number | null;
30 + areaSqft: number | null;
31 + lotSqft: number | null;
32 + yearBuilt: number | null;
33 + mls: string | null;
34 + brokerName: string | null;
35 + agency: string | null;
36 + description: string | null;
37 + lat: number | null;
38 + lng: number | null;
39 + firstSeen: number | null;
40 + lastSeen: number | null;
41 + };
42 + group: TypeGroupKey;
43 + images: string[];
44 + features: string[];
45 + details: Record<string, unknown>;
46 + priceLog: PricePoint[];
47 + eval: EvalRow | null;
48 + unit: {
49 + id: string;
50 + adresse: string | null;
51 + apt: string | null;
52 + municipalite: string | null;
53 + typeProp: string;
54 + anneeConstruction: number | null;
55 + aireEtagesM2: number | null;
56 + superficieTerrainM2: number | null;
57 + nbEtages: number | null;
58 + nbLogements: number | null;
59 + genreConstruction: string | null;
60 + lienPhysique: string | null;
61 + valeurTerrain: number | null;
62 + valeurBatiment: number | null;
63 + valeurRole: number | null;
64 + est2026: number | null;
65 + p10: number | null;
66 + p90: number | null;
67 + history: { year: number; value: number | null }[];
68 + } | null;
69 + nearby: ListingCard[];
70 + /** analyse IA du bâtiment déjà complétée (chantier Coût) — null si aucune */
71 + aiSummary?: { analysisId: string; version: number; confidence: number | null; rcn: number | null; costValue: number | null; structure: string | null; foundation: string | null; roof: string | null; quality: string | null; condition: string | null; completedAt: string | null } | null;
72 +}
73 +
74 +const SOURCE_LABELS: Record<string, string> = {
75 + remax_quebec: "RE/MAX Québec",
76 + duproprio: "DuProprio",
77 + kijiji: "Kijiji",
78 + via_capitale: "Via Capitale",
79 + sutton: "Sutton",
80 + lespac: "LesPAC",
81 + fb_marketplace: "Facebook Marketplace",
82 + proprio_direct: "Proprio Direct",
83 + royal_lepage: "Royal LePage",
84 + engel_volkers: "Engel & Völkers",
85 + sothebys_quebec: "Sotheby's",
86 + pmml: "PMML",
87 +};
88 +const sourceLabel = (s: string) => SOURCE_LABELS[s] ?? s.replace(/_/g, " ").replace(/\bag\b/g, "").trim();
89 +
90 +const HIDDEN_DETAIL_KEYS = new Set(["img_audited", "transaction", "pieces", "remarques_proprio", "prix_pi2", "prix_m2"]);
91 +
92 +function Sect({ num: n, kicker, title }: { num: string; kicker: string; title: string }) {
93 + return (
94 + <header className="sec">
95 + <span className="sec-num">
96 + {n} — {kicker}
97 + </span>
98 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{title}</h2>
99 + </header>
100 + );
101 +}
102 +
103 +function Chip({ k, v }: { k: string; v: string | number | null | undefined }) {
104 + return (
105 + <div className="kv-cell">
106 + <p className="k">{k}</p>
107 + <p className="v break-words">{v == null || v === "" ? "—" : v}</p>
108 + </div>
109 + );
110 +}
111 +
112 +/* --------------------------------- galerie --------------------------------- */
113 +function Gallery({ images, alt }: { images: string[]; alt: string }) {
114 + const [i, setI] = useState(0);
115 + const [open, setOpen] = useState(false);
116 + useEffect(() => {
117 + if (!open) return;
118 + const onKey = (e: KeyboardEvent) => {
119 + if (e.key === "Escape") setOpen(false);
120 + if (e.key === "ArrowRight") setI((x) => (x + 1) % images.length);
121 + if (e.key === "ArrowLeft") setI((x) => (x - 1 + images.length) % images.length);
122 + };
123 + window.addEventListener("keydown", onKey);
124 + document.body.style.overflow = "hidden";
125 + return () => {
126 + window.removeEventListener("keydown", onKey);
127 + document.body.style.overflow = "";
128 + };
129 + }, [open, images.length]);
130 +
131 + if (!images.length)
132 + return (
133 + <div className="vp-mono flex aspect-[16/7] w-full items-center justify-center border border-[var(--line)] bg-surface-2 text-[11px] uppercase tracking-[0.1em] text-ink-3">
134 + Aucune photo
135 + </div>
136 + );
137 + const cur = images[Math.min(i, images.length - 1)];
138 + return (
139 + <div>
140 + <div className="grid gap-2 md:grid-cols-[3fr_1fr]">
141 + <button type="button" onClick={() => setOpen(true)} className="relative block aspect-[16/10] w-full overflow-hidden bg-surface-2 md:aspect-auto md:h-[440px]">
142 + {/* eslint-disable-next-line @next/next/no-img-element */}
143 + <img src={cur} alt={alt} referrerPolicy="no-referrer" className="h-full w-full object-cover" />
144 + <span className="vp-mono absolute bottom-2 right-2 bg-ink/80 px-2 py-1 text-[10px] text-paper">
145 + {i + 1} / {images.length}
146 + </span>
147 + </button>
148 + <div className="flex gap-2 overflow-x-auto md:h-[440px] md:flex-col md:overflow-y-auto">
149 + {images.slice(0, 40).map((src, k) => (
150 + <button
151 + key={src + k}
152 + type="button"
153 + onClick={() => setI(k)}
154 + className={`relative aspect-[4/3] w-24 shrink-0 overflow-hidden border-2 md:w-full ${k === i ? "border-accent" : "border-transparent"}`}
155 + >
156 + {/* eslint-disable-next-line @next/next/no-img-element */}
157 + <img src={src} alt="" loading="lazy" referrerPolicy="no-referrer" className="h-full w-full object-cover" />
158 + </button>
159 + ))}
160 + </div>
161 + </div>
162 + {open && (
163 + <div className="vp-dark fixed inset-0 z-[var(--z-modal,900)] flex flex-col" role="dialog" aria-modal="true">
164 + <div className="flex h-12 shrink-0 items-center justify-between px-4">
165 + <span className="vp-mono text-[11px] uppercase tracking-[0.1em]">
166 + {i + 1} / {images.length}
167 + </span>
168 + <button type="button" onClick={() => setOpen(false)} className="vp-mono h-10 w-10 text-[16px]" aria-label="Fermer">
169 + ✕
170 + </button>
171 + </div>
172 + <div className="relative flex flex-1 items-center justify-center overflow-hidden px-2 pb-4">
173 + {/* eslint-disable-next-line @next/next/no-img-element */}
174 + <img src={cur} alt={alt} referrerPolicy="no-referrer" className="max-h-full max-w-full object-contain" />
175 + <button type="button" onClick={() => setI((x) => (x - 1 + images.length) % images.length)} className="vp-mono absolute left-2 top-1/2 h-12 w-12 -translate-y-1/2 bg-ink/60 text-[20px]" aria-label="Précédente">
176 + ‹
177 + </button>
178 + <button type="button" onClick={() => setI((x) => (x + 1) % images.length)} className="vp-mono absolute right-2 top-1/2 h-12 w-12 -translate-y-1/2 bg-ink/60 text-[20px]" aria-label="Suivante">
179 + ›
180 + </button>
181 + </div>
182 + </div>
183 + )}
184 + </div>
185 + );
186 +}
187 +
188 +/* ---------------------------- historique de prix ---------------------------- */
189 +function PriceHistory({ log, current, lastSeen, fr, lang }: { log: PricePoint[]; current: number; lastSeen: number | null; fr: boolean; lang: string }) {
190 + const pts = [...log];
191 + if (!pts.length || pts[pts.length - 1].price !== current) pts.push({ ts: lastSeen ?? pts[pts.length - 1]?.ts ?? 0, price: current });
192 + // déduplique les prix consécutifs identiques
193 + const series = pts.filter((p, i) => i === 0 || p.price !== pts[i - 1].price);
194 + if (series.length < 2)
195 + return <p className="text-[13px] text-ink-3">{fr ? "Aucun changement de prix observé depuis la première capture." : "No price change observed since first capture."}</p>;
196 + const first = series[0].price;
197 + return (
198 + <ol className="space-y-1.5">
199 + {series.map((p, i) => {
200 + const prev = i > 0 ? series[i - 1].price : null;
201 + const d = prev ? ((p.price - prev) / prev) * 100 : null;
202 + return (
203 + <li key={p.ts} className="flex flex-wrap items-baseline gap-x-4 border-b border-[var(--line-soft)] py-1.5 text-[13.5px]">
204 + <span className="vp-mono w-28 text-[11px] uppercase tracking-[0.05em] text-ink-3">{epochDate(p.ts, lang)}</span>
205 + <span className="vp-display font-bold">{money(p.price, lang)}</span>
206 + {d != null && <span className={`vp-mono text-[11px] ${d < 0 ? "text-[var(--danger)]" : "text-ink-2"}`}>{pct(d, lang, 1)}</span>}
207 + </li>
208 + );
209 + })}
210 + <li className="vp-mono pt-1 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
211 + {fr ? "Cumul depuis la mise en marché" : "Since listing"} : {pct(((current - first) / first) * 100, lang, 1)}
212 + </li>
213 + </ol>
214 + );
215 +}
216 +
217 +/* ------------------------------- composant -------------------------------- */
218 +export default function ListingView({ data }: { data: ListingPageData }) {
219 + const { lang } = useLang();
220 + const fr = lang === "fr";
221 + const l = data.listing;
222 + const e = data.eval;
223 + const u = data.unit;
224 + const area = sqftToM2(l.areaSqft);
225 + const lot = sqftToM2(l.lotSqft);
226 + const ppm2 = area ? Math.round(l.price / area) : null;
227 + const where = [l.sector, l.city].filter(Boolean).join(" · ");
228 + const alt = [l.address, l.city].filter(Boolean).join(", ");
229 +
230 + const detailEntries = Object.entries(data.details).filter(
231 + ([k, v]) => !HIDDEN_DETAIL_KEYS.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim() !== ""
232 + ) as [string, string | number][];
233 + const pieces = Array.isArray(data.details.pieces) ? (data.details.pieces as Record<string, string>[]) : [];
234 + const remarques = typeof data.details.remarques_proprio === "string" ? (data.details.remarques_proprio as string) : null;
235 +
236 + const methods: { key: string; fr: string; en: string; v: number | null; main?: boolean }[] = e
237 + ? [
238 + { key: "est", fr: "Vrai-Prix — hybride 65/35 (mesure principale)", en: "Vrai-Prix — 65/35 hybrid (main measure)", v: e.est, main: true },
239 + { key: "model", fr: "Modèle hédonique (LightGBM)", en: "Hedonic model (LightGBM)", v: e.model_est },
240 + { key: "comps", fr: "Comparables ajustés (moteur)", en: "Adjusted comparables (engine)", v: e.comps_est },
241 + { key: "cost", fr: "Méthode du coût calibrée", en: "Calibrated cost approach", v: e.cost_est },
242 + { key: "role", fr: "Rôle indexé (ratios IAAO)", en: "Indexed assessment (IAAO ratios)", v: e.role_est },
243 + { key: "ens", fr: "Ensemble (médiane des méthodes)", en: "Ensemble (median of methods)", v: e.ens_est },
244 + ]
245 + : [];
246 +
247 + return (
248 + <div className="space-y-12 py-8">
249 + {/* ================= fil d'Ariane ================= */}
250 + <nav className="vp-mono flex flex-wrap items-center gap-2 border-b-2 border-ink pb-2.5 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
251 + <Link href="/" className="text-ink-2 hover:text-accent-deep">
252 + Vrai&nbsp;Prix
253 + </Link>
254 + <span aria-hidden="true">/</span>
255 + <Link href="/a-vendre" className="text-ink-2 hover:text-accent-deep">
256 + {fr ? "À vendre" : "For sale"}
257 + </Link>
258 + <span aria-hidden="true">/</span>
259 + <span className="truncate">{l.uid}</span>
260 + </nav>
261 +
262 + {/* ================= 1. galerie ================= */}
263 + <section className="rise">
264 + <Gallery images={data.images} alt={alt} />
265 + </section>
266 +
267 + {/* ================= 2. prix + adresse + caractéristiques ================= */}
268 + <section className="grid gap-x-12 gap-y-8 lg:grid-cols-[1.5fr_1fr]">
269 + <div className="min-w-0">
270 + <p className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
271 + {groupLabel(data.group, fr)}
272 + {l.propertyType && l.propertyType !== groupLabel(data.group, fr) ? ` · ${l.propertyType}` : ""}
273 + {" · "}
274 + {sourceLabel(l.source)}
275 + {l.mls ? ` · MLS ${l.mls}` : ""}
276 + </p>
277 + <h1 className="vp-display mt-2 text-[clamp(20px,2.8vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">
278 + {l.address || l.title || (fr ? "Propriété à vendre" : "Property for sale")}
279 + {where ? <span className="text-ink-3"> · {where}</span> : null}
280 + </h1>
281 + <p className="klabel mt-5">{fr ? "Prix demandé" : "Asking price"}</p>
282 + <p className="vp-display mt-1 text-[clamp(44px,8vw,84px)] font-bold leading-none tracking-[-0.045em]">{money(l.price, lang)}</p>
283 + <div className="mt-3 flex flex-wrap items-center gap-3">
284 + <RatioPill ratio={e?.ratio ?? null} fr={fr} size="md" />
285 + {e?.est != null && (
286 + <span className="vp-mono text-[11px] uppercase tracking-[0.06em] text-ink-2">
287 + {fr ? "Mesure Vrai-Prix" : "Vrai-Prix measure"} <b className="text-ink">{money(e.est, lang)}</b>
288 + {e.confidence ? ` · ${fr ? "confiance" : "confidence"} ${e.confidence}` : ""}
289 + </span>
290 + )}
291 + </div>
292 + <p className="vp-mono mt-4 text-[11px] uppercase tracking-[0.06em] text-ink-2">
293 + {[
294 + l.bedrooms != null ? `${l.bedrooms} ${fr ? "chambres" : "bedrooms"}` : null,
295 + l.bathrooms != null ? `${l.bathrooms} ${fr ? "salles de bain" : "bathrooms"}` : null,
296 + l.powderRooms ? `${l.powderRooms} ${fr ? "salle d'eau" : "powder room"}` : null,
297 + area ? `${num(area, lang)} m² (${num(l.areaSqft, lang)} pi²)` : null,
298 + ppm2 ? `${num(ppm2, lang)} $/m²` : null,
299 + lot ? `${fr ? "terrain" : "lot"} ${num(lot, lang)} m²` : null,
300 + l.yearBuilt ? `${fr ? "constr." : "built"} ${l.yearBuilt}` : null,
301 + ]
302 + .filter((x): x is string => x != null)
303 + .join(" · ")}
304 + </p>
305 + <div className="mt-7 flex flex-wrap gap-2.5">
306 + <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/evaluer`} className="btn btn-accent">
307 + {fr ? "Faire mon évaluation" : "Build my valuation"} →
308 + </Link>
309 + {u && (
310 + <Link href={`/estimation/${encodeURIComponent(u.id)}`} className="btn btn-ghost">
311 + {fr ? "Rapport de valeur Vrai-Prix" : "Vrai-Prix value report"}
312 + </Link>
313 + )}
314 + <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/analyse-cout`} className="btn btn-ghost" title={fr ? "Des photos à la méthode du coût — Claude Sonnet 5" : "From photos to the cost approach — Claude Sonnet 5"}>
315 + {data.aiSummary ? (fr ? "Coût IA disponible" : "AI cost available") : fr ? "Analyser le coût avec l'IA" : "Analyse the cost with AI"} →
316 + </Link>
317 + {u && (
318 + <Link href={`/cout?property=${encodeURIComponent(u.id)}`} className="btn btn-ghost">
319 + {fr ? "Voir la méthode du coût" : "See the cost approach"} →
320 + </Link>
321 + )}
322 + {l.url && (
323 + <a href={l.url} target="_blank" rel="noopener noreferrer nofollow" className="btn btn-ghost">
324 + {fr ? "Annonce originale" : "Original listing"} ↗
325 + </a>
326 + )}
327 + </div>
328 + </div>
329 + <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">
330 + <dl className="grid grid-cols-2 gap-x-6 lg:grid-cols-1">
331 + {e?.est != null && (
332 + <div className="border-t border-[var(--line)] py-3">
333 + <dt className="klabel">{fr ? "Estimation Vrai-Prix" : "Vrai-Prix estimate"}</dt>
334 + <dd className="vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em]">{money(e.est, lang)}</dd>
335 + <dd className="vp-mono text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
336 + {money(e.low, lang)} – {money(e.high, lang)}
337 + </dd>
338 + </div>
339 + )}
340 + {e?.diff != null && (
341 + <div className="border-t border-[var(--line)] py-3">
342 + <dt className="klabel">{fr ? "Prix demandé − estimation" : "Asking − estimate"}</dt>
343 + <dd className={`vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] ${e.diff > 0 ? "text-ink" : "text-[var(--danger)]"}`}>
344 + {e.diff >= 0 ? "+" : "−"}
345 + {money(Math.abs(e.diff), lang)}
346 + </dd>
347 + </div>
348 + )}
349 + {(u?.valeurRole ?? e?.valeur_role) != null && (
350 + <div className="border-t border-[var(--line)] py-3">
351 + <dt className="klabel">{fr ? "Évaluation municipale (rôle)" : "Municipal assessment (roll)"}</dt>
352 + <dd className="vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] text-ink-2">{money(u?.valeurRole ?? e?.valeur_role, lang)}</dd>
353 + {(u?.valeurRole ?? e?.valeur_role)! > 0 && (
354 + <dd className="vp-mono text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
355 + {fr ? "prix / rôle" : "price / roll"} {(l.price / (u?.valeurRole ?? e?.valeur_role)!).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })}
356 + </dd>
357 + )}
358 + </div>
359 + )}
360 + <div className="border-t border-[var(--line)] py-3">
361 + <dt className="klabel">{fr ? "En marché depuis" : "On market since"}</dt>
362 + <dd className="vp-display mt-0.5 text-[16px] font-bold tracking-[-0.02em]">{epochDate(l.firstSeen, lang)}</dd>
363 + <dd className="vp-mono text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
364 + {fr ? "vu le" : "seen"} {epochDate(l.lastSeen, lang)}
365 + </dd>
366 + </div>
367 + {(l.brokerName || l.agency) && (
368 + <div className="border-t border-[var(--line)] py-3">
369 + <dt className="klabel">{fr ? "Courtier / vendeur" : "Broker / seller"}</dt>
370 + <dd className="mt-0.5 text-[13.5px] font-semibold">{l.brokerName || "—"}</dd>
371 + {l.agency && l.agency !== l.brokerName && <dd className="text-[12px] text-ink-2">{l.agency}</dd>}
372 + </div>
373 + )}
374 + </dl>
375 + </aside>
376 + </section>
377 +
378 + {/* ================= 3. description ================= */}
379 + {(l.description || remarques) && (
380 + <section>
381 + <Sect num="01" kicker={fr ? "Annonce" : "Listing"} title={fr ? "Description" : "Description"} />
382 + <div className="mt-5 max-w-3xl whitespace-pre-line text-[14.5px] leading-relaxed text-ink-2">{l.description || remarques}</div>
383 + {l.description && remarques && remarques !== l.description && (
384 + <div className="mt-4 max-w-3xl whitespace-pre-line border-l-2 border-accent pl-4 text-[13.5px] leading-relaxed text-ink-2">{remarques}</div>
385 + )}
386 + </section>
387 + )}
388 +
389 + {/* ================= 4. inclusions / caractéristiques ================= */}
390 + {data.features.length > 0 && (
391 + <section>
392 + <Sect num="02" kicker={fr ? "Annonce" : "Listing"} title={fr ? "Caractéristiques et inclusions" : "Features and inclusions"} />
393 + <ul className="mt-5 grid gap-x-8 gap-y-1.5 text-[13.5px] sm:grid-cols-2 lg:grid-cols-3">
394 + {data.features.map((f, i) => (
395 + <li key={i} className="flex gap-2 border-b border-[var(--line-soft)] py-1.5">
396 + <span aria-hidden="true" className="mt-[7px] inline-block h-[5px] w-[5px] shrink-0 bg-accent" />
397 + <span>{f}</span>
398 + </li>
399 + ))}
400 + </ul>
401 + </section>
402 + )}
403 +
404 + {/* ================= 5. détails structurés ================= */}
405 + {(detailEntries.length > 0 || pieces.length > 0) && (
406 + <section>
407 + <Sect num="03" kicker={fr ? "Annonce" : "Listing"} title={fr ? "Détails pratiques" : "Practical details"} />
408 + {detailEntries.length > 0 && (
409 + <div className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3 lg:grid-cols-4">
410 + {detailEntries.map(([k, v]) => (
411 + <Chip key={k} k={k.replace(/_/g, " ")} v={v} />
412 + ))}
413 + </div>
414 + )}
415 + {pieces.length > 0 && (
416 + <div className="src-wrap mt-6">
417 + <table className="src-table">
418 + <thead>
419 + <tr>
420 + <th>{fr ? "Pièce" : "Room"}</th>
421 + <th>{fr ? "Niveau" : "Level"}</th>
422 + <th>{fr ? "Dimensions" : "Dimensions"}</th>
423 + <th>{fr ? "Revêtement" : "Flooring"}</th>
424 + </tr>
425 + </thead>
426 + <tbody>
427 + {pieces.map((p, i) => (
428 + <tr key={i}>
429 + <td className="font-semibold">{p.nom ?? p.name ?? "—"}</td>
430 + <td>{p.niveau ?? p.level ?? "—"}</td>
431 + <td className="vp-mono text-[12px]">{p.dimensions ?? "—"}</td>
432 + <td>{p.revetement ?? p.flooring ?? "—"}</td>
433 + </tr>
434 + ))}
435 + </tbody>
436 + </table>
437 + </div>
438 + )}
439 + </section>
440 + )}
441 +
442 + {/* ================= 6. analyses : mesure Vrai-Prix ================= */}
443 + <section>
444 + <Sect num="04" kicker={fr ? "Analyse" : "Analysis"} title={fr ? "La mesure Vrai-Prix face au prix demandé" : "The Vrai-Prix measure vs. the asking price"} />
445 + {e?.est ? (
446 + <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.4fr_1fr]">
447 + <div>
448 + <p className="klabel mb-1">{fr ? "Intervalle de confiance (P10-P90) et prix demandé" : "Confidence interval (P10-P90) and asking price"}</p>
449 + <RangeBar low={e.low ?? e.est} estimate={e.est} high={e.high ?? e.est} role={l.price} lang={lang} />
450 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">
451 + {fr ? "Le repère « rôle » de la barre marque ici le prix demandé." : "The “roll” marker on the bar shows the asking price here."}
452 + </p>
453 + <div className="src-wrap mt-6">
454 + <table className="src-table">
455 + <thead>
456 + <tr>
457 + <th>{fr ? "Méthode" : "Method"}</th>
458 + <th className="text-right">{fr ? "Valeur" : "Value"}</th>
459 + <th className="text-right">{fr ? "vs prix demandé" : "vs asking"}</th>
460 + </tr>
461 + </thead>
462 + <tbody>
463 + {methods.map((m) => (
464 + <tr key={m.key} className={m.main ? "font-bold" : ""}>
465 + <td>{fr ? m.fr : m.en}</td>
466 + <td className="vp-mono text-right">{money(m.v, lang)}</td>
467 + <td className={`vp-mono text-right ${m.v != null && m.v < l.price ? "text-[var(--danger)]" : ""}`}>
468 + {m.v != null ? pct((m.v / l.price - 1) * 100, lang, 1) : "—"}
469 + </td>
470 + </tr>
471 + ))}
472 + <tr className="bg-surface-2">
473 + <td>{fr ? "Prix demandé" : "Asking price"}</td>
474 + <td className="vp-mono text-right">{money(l.price, lang)}</td>
475 + <td className="vp-mono text-right">—</td>
476 + </tr>
477 + </tbody>
478 + </table>
479 + </div>
480 + <p className="mt-4 max-w-3xl text-[13px] leading-relaxed text-ink-2">
481 + {fr
482 + ? "Rappel : l'écart entre la mesure et le prix demandé additionne l'erreur du modèle ET la stratégie d'affichage du vendeur. Le prix demandé n'est pas la valeur marchande ; seule la vente la révèle."
483 + : "Reminder: the gap between the measure and the asking price adds up the model's error AND the seller's pricing strategy. The asking price is not market value; only the sale reveals it."}
484 + </p>
485 + </div>
486 + <div className="lg:border-l lg:border-[var(--line)] lg:pl-10">
487 + <p className="klabel">{fr ? "Historique du prix demandé" : "Asking price history"}</p>
488 + <div className="mt-3">
489 + <PriceHistory log={data.priceLog} current={l.price} lastSeen={l.lastSeen} fr={fr} lang={lang} />
490 + </div>
491 + <p className="klabel mt-8">{fr ? "Jumelage au rôle" : "Match to the roll"}</p>
492 + <p className="mt-1 text-[13px] text-ink-2">
493 + {u
494 + ? fr
495 + ? `Unité ${u.id} (${u.adresse ?? "—"}) jumelée à ${e.match_m != null ? `${num(e.match_m, lang, 1)} m` : "—"} de l'annonce · ${e.n_comps ?? 0} comparables du moteur · évaluée le ${e.evaluated_at ?? "—"}.`
496 + : `Unit ${u.id} (${u.adresse ?? "—"}) matched ${e.match_m != null ? `${num(e.match_m, lang, 1)} m` : "—"} from the listing · ${e.n_comps ?? 0} engine comparables · valued on ${e.evaluated_at ?? "—"}.`
497 + : fr
498 + ? "Aucune unité du rôle à moins de 250 m."
499 + : "No assessment unit within 250 m."}
500 + </p>
501 + <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/evaluer`} className="btn btn-primary mt-6">
502 + {fr ? "Choisir mes comparables et comparer" : "Pick my comparables and compare"} →
503 + </Link>
504 + </div>
505 + </div>
506 + ) : (
507 + <div className="mt-6 max-w-3xl text-[14px] leading-relaxed text-ink-2">
508 + <p>
509 + {fr
510 + ? "Cette annonce n'a pas de mesure Vrai-Prix : aucune unité du rôle d'évaluation n'a pu être jumelée à moins de 250 m de ses coordonnées (ou l'annonce n'est pas géolocalisée)."
511 + : "This listing has no Vrai-Prix measure: no assessment unit could be matched within 250 m of its coordinates (or the listing is not geolocated)."}
512 + </p>
513 + {l.lat != null && (
514 + <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/evaluer`} className="btn btn-primary mt-5">
515 + {fr ? "Faire quand même mon évaluation par comparables" : "Build my valuation from comparables anyway"} →
516 + </Link>
517 + )}
518 + </div>
519 + )}
520 + </section>
521 +
522 + {/* ================= 6b. analyse technique IA (si déjà calculée) ================= */}
523 + {data.aiSummary && (
524 + <section>
525 + <Sect num="04b" kicker={fr ? "Analyse IA" : "AI analysis"} title={fr ? "Analyse technique IA du bâtiment" : "AI technical analysis of the building"} />
526 + <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">
527 + Claude Sonnet 5 · v{data.aiSummary.version}{data.aiSummary.confidence != null ? ` · ${fr ? "confiance" : "confidence"} ${Math.round(data.aiSummary.confidence)} %` : ""}{data.aiSummary.completedAt ? ` · ${dateFr(data.aiSummary.completedAt, lang)}` : ""}
528 + </p>
529 + <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3 lg:grid-cols-6">
530 + <Chip k={fr ? "Structure" : "Structure"} v={data.aiSummary.structure?.replace(/_/g, " ")} />
531 + <Chip k={fr ? "Fondation" : "Foundation"} v={data.aiSummary.foundation?.replace(/_/g, " ")} />
532 + <Chip k={fr ? "Toiture" : "Roof"} v={data.aiSummary.roof?.replace(/_/g, " ")} />
533 + <Chip k={fr ? "Qualité" : "Quality"} v={data.aiSummary.quality} />
534 + <Chip k={fr ? "Condition" : "Condition"} v={data.aiSummary.condition?.replace(/_/g, " ")} />
535 + <Chip k={fr ? "RCN estimé" : "Estimated RCN"} v={money(data.aiSummary.rcn, lang)} />
536 + </div>
537 + <p className="mt-3 text-[13px] text-ink-2">
538 + {fr ? "Inféré par IA à partir des photos et de l'annonce, puis chiffré par le moteur déterministe de la méthode du coût — estimation indicative." : "AI-inferred from the photos and listing, then costed by the deterministic cost-approach engine — indicative estimate."}
539 + {data.aiSummary.costValue != null ? ` ${fr ? "Indication par le coût" : "Cost approach indication"} : ${money(data.aiSummary.costValue, lang)}.` : ""}
540 + </p>
541 + <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/analyse-cout`} className="vp-mono mt-3 inline-block text-[11px] font-bold uppercase tracking-[0.08em] text-accent">
542 + {fr ? "Voir le profil complet" : "See the full profile"} →
543 + </Link>
544 + </section>
545 + )}
546 +
547 + {/* ================= 7. registre (unité jumelée) ================= */}
548 + {u && (
549 + <section>
550 + <Sect num="05" kicker={fr ? "Registre officiel" : "Official record"} title={fr ? "Portrait au rôle d'évaluation" : "Assessment roll portrait"} />
551 + <div className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-4">
552 + <Chip k={fr ? "Adresse au rôle" : "Address on roll"} v={`${u.adresse ?? "—"}${u.apt ? ` app. ${u.apt}` : ""}`} />
553 + <Chip k={fr ? "Municipalité" : "Municipality"} v={u.municipalite} />
554 + <Chip k={fr ? "Type (rôle)" : "Type (roll)"} v={u.typeProp} />
555 + <Chip k={fr ? "Année de construction" : "Year built"} v={u.anneeConstruction} />
556 + <Chip k={fr ? "Aire d'étages" : "Floor area"} v={m2(u.aireEtagesM2, lang)} />
557 + <Chip k={fr ? "Terrain" : "Lot"} v={m2(u.superficieTerrainM2, lang)} />
558 + <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${u.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} />
559 + <Chip k={fr ? "Genre / lien" : "Style / link"} v={[u.genreConstruction, u.lienPhysique].filter(Boolean).join(" · ")} />
560 + <Chip k={fr ? "Valeur terrain (rôle)" : "Land value (roll)"} v={money(u.valeurTerrain, lang)} />
561 + <Chip k={fr ? "Valeur bâtiment (rôle)" : "Building value (roll)"} v={money(u.valeurBatiment, lang)} />
562 + <Chip k={fr ? "Valeur au rôle" : "Assessed value"} v={money(u.valeurRole, lang)} />
563 + <Chip k={fr ? "Modèle hédonique 2026" : "Hedonic model 2026"} v={money(u.est2026, lang)} />
564 + </div>
565 + {area && u.aireEtagesM2 && Math.abs(area - u.aireEtagesM2) / u.aireEtagesM2 > 0.25 && (
566 + <p className="mt-4 max-w-3xl border-l-2 border-[var(--amber,#c98a1b)] pl-4 text-[13px] leading-relaxed text-ink-2">
567 + {fr
568 + ? `Attention : la superficie annoncée (${num(area, lang)} m²) s'écarte de plus de 25 % de l'aire d'étages au rôle (${num(u.aireEtagesM2, lang)} m²). Le rôle mesure l'aire brute des étages hors sous-sol ; l'annonce peut inclure le sous-sol aménagé ou le terrain — ou le jumelage peut être imparfait (condos empilés).`
569 + : `Note: the advertised area (${num(area, lang)} m²) differs by more than 25% from the roll's floor area (${num(u.aireEtagesM2, lang)} m²). The roll measures gross floor area excluding basement; the listing may include a finished basement or the lot — or the match may be imperfect (stacked condos).`}
570 + </p>
571 + )}
572 + <Link href={`/estimation/${encodeURIComponent(u.id)}`} className="vp-mono mt-4 inline-block text-[11px] font-bold uppercase tracking-[0.08em] text-accent">
573 + {fr ? "Voir le rapport de valeur complet de cette unité" : "See this unit's full value report"} →
574 + </Link>
575 + </section>
576 + )}
577 +
578 + {/* ================= 8. voisines à vendre ================= */}
579 + {data.nearby.length > 0 && (
580 + <section>
581 + <Sect num={u ? "06" : "05"} kicker={fr ? "Marché actif" : "Active market"} title={fr ? "À vendre à proximité (même catégorie)" : "For sale nearby (same category)"} />
582 + <div className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
583 + {data.nearby.map((c) => (
584 + <ListingCardView key={c.uid} c={c} fr={fr} compact />
585 + ))}
586 + </div>
587 + </section>
588 + )}
589 +
590 + <p className="vp-mono border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">
591 + {fr
592 + ? `Annonce ${l.uid} — copie Immo-Ka, capturée le ${epochDate(l.lastSeen, lang)}. Photos, textes et prix appartiennent au vendeur ou au courtier ; ils sont reproduits à titre informatif. Estimations Vrai-Prix : statistiques, non certifiées (OEAQ).`
593 + : `Listing ${l.uid} — Immo-Ka copy, captured ${epochDate(l.lastSeen, lang)}. Photos, texts and prices belong to the seller or broker; reproduced for information purposes. Vrai-Prix estimates: statistical, not certified (OEAQ).`}
594 + {e?.evaluated_at ? ` · ${fr ? "évaluée le" : "valued on"} ${dateFr(e.evaluated_at, lang)}` : ""}
595 + </p>
596 + </div>
597 + );
598 +}
added src/components/avendre/cost/AiCostAnalysis.tsx +549 −0
@@ -0,0 +1,549 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Page « Analyse IA du bâtiment » d'une annonce (§138-142, 156-157, 174-176, 206-208).
4 + * Avant : rien n'est analysé tant que l'utilisateur ne le demande pas.
5 + * Pendant : étapes du pipeline (polling léger).
6 + * Après : 01 photos · 02 profil · 03 construction · 04 quantités · 05 assemblages
7 + * · 06 coût · 07 dépréciation · 08 terrain · 09 résultat · 10 sources · 11 JSON.
8 + * Chaque valeur porte sa couche (OBSERVÉ / INFÉRÉ PAR IA / CALCULÉ / PRIX SOURCÉ)
9 + * et peut être corrigée ; le coût est recalculé sans rappeler le modèle.
10 + * DOM = ordre visuel, une colonne sur mobile.
11 + */
12 +"use client";
13 +import { useCallback, useEffect, useMemo, useState } from "react";
14 +import Link from "next/link";
15 +import { useLang } from "@/components/LangContext";
16 +import type { AnalysisView } from "@/lib/cost/ai/service";
17 +import type { Fact } from "@/lib/cost/ai/merge";
18 +import { CONDITIONS, QUALITIES, categoryLabel } from "@/lib/cost/taxonomy";
19 +import { money, num, pct } from "../fmt";
20 +import { ConfBar, CostTable, JsonViewer, LayerBadge, Sect, Stat, layerOf } from "./ui";
21 +
22 +export interface AiHead {
23 + uid: string; address: string | null; city: string | null; propertyType: string | null; price: number; images: string[]; source: string; yearBuilt: number | null; areaSqft: number | null;
24 + unitId: string | null; evalEst: number | null; modelEst: number | null; compsEst: number | null; roleValue: number | null; maxImages: number; model: string;
25 +}
26 +
27 +type Status = "none" | "queued" | "fetching_images" | "analyzing" | "validating" | "embedding" | "mapping_assemblies" | "pricing" | "completed" | "failed";
28 +interface Poll { id: string; status: Status; stage: string | null; version: number; error?: string | null }
29 +
30 +const STEPS: { key: Status[]; fr: string; en: string }[] = [
31 + { key: ["queued", "fetching_images"], fr: "Analyse des photos", en: "Analysing photos" },
32 + { key: ["analyzing"], fr: "Extraction des caractéristiques", en: "Extracting features" },
33 + { key: ["validating", "embedding"], fr: "Estimation des composantes", en: "Estimating components" },
34 + { key: ["mapping_assemblies"], fr: "Construction du profil technique", en: "Building the technical profile" },
35 + { key: ["pricing"], fr: "Association aux coûts actuels", en: "Applying current costs" },
36 +];
37 +
38 +const EX_KEY = (uid: string) => `vrai-prix-ai-exercice:${uid}`;
39 +
40 +export default function AiCostAnalysis({ head, initial }: { head: AiHead; initial: AnalysisView | null }) {
41 + const { lang } = useLang();
42 + const fr = lang === "fr";
43 + const [view, setView] = useState<AnalysisView | null>(initial);
44 + const [poll, setPoll] = useState<Poll | null>(initial && initial.status !== "completed" && initial.status !== "failed" ? { id: initial.id, status: initial.status, stage: initial.stage, version: initial.version } : null);
45 + const [busy, setBusy] = useState(false);
46 + const [err, setErr] = useState<string | null>(null);
47 + const [exerciseMode, setExerciseMode] = useState(false);
48 + const [revealed, setRevealed] = useState(true);
49 + const base = `/api/listings/${encodeURIComponent(head.uid)}`;
50 +
51 + // polling tant qu'une analyse est en cours
52 + useEffect(() => {
53 + if (!poll) return;
54 + const t = setInterval(async () => {
55 + try {
56 + const r = await fetch(`${base}/ai-cost-analysis?id=${poll.id}`, { cache: "no-store" });
57 + const d = (await r.json()) as Poll & Partial<AnalysisView>;
58 + if (d.status === "completed" || d.status === "failed") { setPoll(null); setView(d as AnalysisView); if (d.status === "failed") setErr(d.error ?? "échec"); }
59 + else setPoll({ id: d.id, status: d.status, stage: d.stage, version: d.version });
60 + } catch { /* réessai au prochain tick */ }
61 + }, 2500);
62 + return () => clearInterval(t);
63 + }, [poll, base]);
64 +
65 + const start = useCallback(async (force: boolean) => {
66 + setBusy(true); setErr(null);
67 + try {
68 + const r = await fetch(`${base}/ai-cost-analysis${force ? "/reanalyze" : ""}`, { method: "POST" });
69 + const d = await r.json();
70 + if (!r.ok) throw new Error(d.error ?? r.statusText);
71 + if (d.status === "completed" && d.reused) { const v = await (await fetch(`${base}/ai-cost-analysis?id=${d.analysisId}`, { cache: "no-store" })).json(); setView(v as AnalysisView); }
72 + else setPoll({ id: d.analysisId, status: d.status, stage: d.status, version: d.version });
73 + } catch (e) { setErr((e as Error).message); } finally { setBusy(false); }
74 + }, [base]);
75 +
76 + const recalc = useCallback(async () => {
77 + if (!view) return;
78 + setBusy(true); setErr(null);
79 + try {
80 + const r = await fetch(`${base}/cost-estimate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ analysisId: view.id }) });
81 + const d = await r.json(); if (!r.ok) throw new Error(d.error ?? r.statusText); setView(d as AnalysisView);
82 + } catch (e) { setErr((e as Error).message); } finally { setBusy(false); }
83 + }, [base, view]);
84 +
85 + const override = useCallback(async (fieldPath: string, value: unknown) => {
86 + if (!view) return;
87 + setBusy(true); setErr(null);
88 + try {
89 + const r = await fetch(`${base}/ai-cost-analysis/override`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ analysisId: view.id, field_path: fieldPath, new_value: value }) });
90 + const d = await r.json(); if (!r.ok) throw new Error(d.error ?? r.statusText); setView(d as AnalysisView);
91 + } catch (e) { setErr((e as Error).message); } finally { setBusy(false); }
92 + }, [base, view]);
93 +
94 + const completed = view?.status === "completed" && view.analysis && view.merged && view.estimate ? view : null;
95 + const where = [head.address, head.city].filter(Boolean).join(", ");
96 +
97 + return (
98 + <div className="space-y-12 py-8">
99 + <nav className="vp-mono flex flex-wrap items-center gap-2 border-b-2 border-ink pb-2.5 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
100 + <Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai&nbsp;Prix</Link><span aria-hidden="true">/</span>
101 + <Link href="/a-vendre" className="text-ink-2 hover:text-accent-deep">{fr ? "À vendre" : "For sale"}</Link><span aria-hidden="true">/</span>
102 + <Link href={`/a-vendre/${encodeURIComponent(head.uid)}`} className="truncate text-ink-2 hover:text-accent-deep">{where || head.uid}</Link><span aria-hidden="true">/</span>
103 + <span>{fr ? "Analyse IA du bâtiment" : "AI building analysis"}</span>
104 + </nav>
105 +
106 + {/* ---------------- en-tête ---------------- */}
107 + <section className="grid gap-x-12 gap-y-6 lg:grid-cols-[1.5fr_1fr]">
108 + <div>
109 + <span className="kicker">{fr ? "Des photos à la méthode du coût" : "From photos to the cost approach"}</span>
110 + <h1 className="vp-display mt-3 text-[clamp(26px,4vw,44px)] font-bold uppercase leading-[1.0] tracking-[-0.03em]">{fr ? "Analyse IA du bâtiment" : "AI building analysis"}</h1>
111 + <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2">
112 + {fr ? "L'IA transforme cette annonce réelle en profil technique de bâtiment (structure, enveloppe, finitions, mécanique, condition). Les coûts actuels du marché sont ensuite appliqués composante par composante par le moteur déterministe de la méthode du coût — l'IA ne fixe jamais un prix." : "AI turns this real listing into a technical building profile (structure, envelope, finishes, mechanical, condition). Current market costs are then applied component by component by the deterministic cost engine — the AI never sets a price."}
113 + </p>
114 + <p className="vp-mono mt-3 text-[11px] uppercase tracking-[0.06em] text-ink-2">{head.propertyType ?? "—"} · {money(head.price, lang)}{head.yearBuilt ? ` · ${fr ? "constr." : "built"} ${head.yearBuilt}` : ""}{head.areaSqft ? ` · ${num(head.areaSqft, lang)} pi²` : ""} · {head.images.length} {fr ? "photos" : "photos"}</p>
115 + </div>
116 + <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">
117 + <p className="klabel">{fr ? "Analyse du bâtiment" : "Building analysis"}</p>
118 + <p className="mt-2 text-[13px] leading-relaxed text-ink-2">{fr ? "Les inférences visuelles sont des estimations. Les coûts présentés ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ). Photos et textes appartiennent au vendeur ou au courtier ; ils ne sont utilisés que pour cette analyse." : "Visual inferences are estimates. Costs are neither a contractor quote nor a certified professional appraisal (OEAQ). Photos and texts belong to the seller or broker and are used only for this analysis."}</p>
119 + <label className="mt-4 flex items-center gap-2 text-[13px]"><input type="checkbox" checked={exerciseMode} onChange={(e) => { setExerciseMode(e.target.checked); setRevealed(!e.target.checked); }} /> ✎ {fr ? "Mode exercice : analysez vous-même avant de révéler l'IA" : "Exercise mode: analyse it yourself before revealing the AI"}</label>
120 + </aside>
121 + </section>
122 +
123 + {exerciseMode && <ExerciseBlock uid={head.uid} fr={fr} view={completed} revealed={revealed} onReveal={() => setRevealed(true)} />}
124 +
125 + {/* ---------------- état : avant / pendant / échec ---------------- */}
126 + {!completed && (
127 + <section className="border border-[var(--line)] bg-surface p-6">
128 + {poll ? (
129 + <div>
130 + <p className="klabel">{fr ? "Analyse en cours" : "Analysis in progress"} · v{poll.version}</p>
131 + <ol className="mt-4 space-y-2">
132 + {STEPS.map((s, i) => {
133 + const idx = STEPS.findIndex((x) => x.key.includes(poll.status));
134 + const state = i < idx ? "done" : i === idx ? "active" : "todo";
135 + return (
136 + <li key={s.fr} className={`flex items-center gap-3 text-[14px] ${state === "todo" ? "text-ink-3" : "text-ink"}`}>
137 + <span className={`inline-block h-[10px] w-[10px] ${state === "done" ? "bg-ink" : state === "active" ? "animate-pulse bg-[var(--accent)]" : "border border-[var(--line-strong)]"}`} />
138 + {fr ? s.fr : s.en}{state === "active" ? "…" : ""}
139 + </li>
140 + );
141 + })}
142 + </ol>
143 + <p className="vp-mono mt-4 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Vous pouvez quitter la page : l'analyse continue côté serveur." : "You can leave the page: the analysis continues server-side."}</p>
144 + </div>
145 + ) : (
146 + <div className="grid gap-6 lg:grid-cols-[1fr_auto] lg:items-center">
147 + <div>
148 + <p className="klabel">{view?.status === "failed" ? (fr ? "Dernière analyse échouée" : "Last analysis failed") : (fr ? "Analyse multimodale non effectuée" : "Multimodal analysis not performed")}</p>
149 + {view?.status === "failed" && <p className="mt-2 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-ink-2">{view.error}</p>}
150 + <p className="mt-2 text-[14px] text-ink-2">{fr ? `Cette analyse utilisera jusqu'à ${Math.min(head.images.length, head.maxImages)} photos (${head.images.length} disponibles) ainsi que la description, les caractéristiques structurées${head.unitId ? " et les données du rôle d'évaluation (MAMH)" : ""}.` : `This analysis will use up to ${Math.min(head.images.length, head.maxImages)} photos (${head.images.length} available), the description, the structured fields${head.unitId ? " and the assessment-roll data (MAMH)" : ""}.`}</p>
151 + {!head.unitId && <p className="vp-mono mt-2 text-[10.5px] uppercase tracking-[0.05em] text-[#8a5a12]">{fr ? "Annonce non jumelée au rôle : la valeur du terrain devra être saisie." : "Listing not matched to the roll: land value must be entered."}</p>}
152 + </div>
153 + <button type="button" disabled={busy || !head.images.length} onClick={() => start(view?.status === "failed")} className="btn btn-accent">{busy ? "…" : fr ? `Analyser avec ${head.model === "claude-sonnet-5" ? "Claude Sonnet 5" : head.model}` : `Analyse with ${head.model === "claude-sonnet-5" ? "Claude Sonnet 5" : head.model}`} →</button>
154 + </div>
155 + )}
156 + {err && <p className="mt-4 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-[var(--danger)]">{err}</p>}
157 + </section>
158 + )}
159 +
160 + {completed && revealed && <Results v={completed} head={head} fr={fr} busy={busy} err={err} onRecalc={recalc} onReanalyze={() => start(true)} onOverride={override} />}
161 + {completed && !revealed && <p className="vp-mono text-[11px] uppercase tracking-[0.06em] text-ink-3">{fr ? "Résultats de l'IA masqués — complétez l'exercice puis révélez." : "AI results hidden — complete the exercise, then reveal."}</p>}
162 + </div>
163 + );
164 +}
165 +
166 +/* ================================================================ résultats */
167 +
168 +function Results({ v, head, fr, busy, err, onRecalc, onReanalyze, onOverride }: { v: AnalysisView; head: AiHead; fr: boolean; busy: boolean; err: string | null; onRecalc: () => void; onReanalyze: () => void; onOverride: (path: string, value: unknown) => void }) {
169 + const lang = fr ? "fr" : "en";
170 + const a = v.analysis!; const m = v.merged!; const e = v.estimate!;
171 + const conf = v.combined?.overall ?? Math.round((a.confidence.overall ?? 0) * 100);
172 + const [similar, setSimilar] = useState<{ listingUid: string; similarity: number; address: string | null; city: string | null; price: number | null; image: string | null }[] | null>(null);
173 + useEffect(() => {
174 + let alive = true;
175 + fetch(`/api/listings/${encodeURIComponent(head.uid)}/technical-similar?id=${v.id}`).then((r) => r.json()).then((d) => { if (alive) setSimilar(d.similar ?? []); }).catch(() => { if (alive) setSimilar([]); });
176 + return () => { alive = false; };
177 + }, [head.uid, v.id]);
178 + const photosFor = (ev: string[]) => a.images.filter((i) => ev.includes(i.id));
179 + const implicitBuilding = head.price - e.landValue;
180 + const gap = e.depreciation.depreciatedImprovementValue > 0 ? ((implicitBuilding - e.depreciation.depreciatedImprovementValue) / e.depreciation.depreciatedImprovementValue) * 100 : null;
181 +
182 + return (
183 + <>
184 + {/* résumé */}
185 + <section className="border-2 border-ink bg-surface p-6">
186 + <div className="flex flex-wrap items-baseline justify-between gap-4">
187 + <div>
188 + <p className="klabel">{fr ? "Profil technique généré" : "Technical profile generated"} · v{v.version} · {a.metadata.model} · {a.metadata.image_count} {fr ? "photos" : "photos"}</p>
189 + <p className="vp-display mt-1 text-[clamp(22px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">{fr ? "Confiance globale" : "Overall confidence"} : {conf} %</p>
190 + </div>
191 + <div className="flex flex-wrap gap-2">
192 + <button type="button" className="btn btn-ghost" disabled={busy} onClick={onRecalc}>{fr ? "Recalculer avec les coûts d'aujourd'hui" : "Recalculate with today's costs"}</button>
193 + <button type="button" className="btn btn-ghost" disabled={busy} onClick={onReanalyze}>{fr ? "Réanalyser le bâtiment" : "Re-analyse the building"}</button>
194 + <a className="btn btn-ghost" href={`/api/listings/${encodeURIComponent(head.uid)}/ai-analysis-json?id=${v.id}`}>{fr ? "Télécharger le JSON" : "Download JSON"}</a>
195 + {head.unitId && <Link className="btn btn-primary" href={`/cout?property=${encodeURIComponent(head.unitId)}`}>{fr ? "Voir la méthode du coût" : "Open the cost approach"} →</Link>}
196 + </div>
197 + </div>
198 + {v.combined && (
199 + <div className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3 lg:grid-cols-6">
200 + <Stat k={fr ? "Analyse bâtiment" : "Building analysis"} v={`${v.combined.building}`} /><Stat k={fr ? "Quantités" : "Quantities"} v={`${v.combined.quantities}`} /><Stat k={fr ? "Mapping assemblages" : "Assembly mapping"} v={`${v.combined.mapping}`} />
201 + <Stat k={fr ? "Données de coût" : "Cost data"} v={`${v.combined.costData}`} /><Stat k={fr ? "Localisation" : "Location"} v={`${v.combined.location}`} /><Stat k={fr ? "Globale" : "Overall"} v={`${v.combined.overall} / 100`} big />
202 + </div>
203 + )}
204 + <p className="vp-mono mt-4 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? `Prix du ${v.costSnapshotDate} · RCN ${money(e.replacementCostNew, lang)} · indication par le coût ${money(e.costApproachValue, lang)}` : `Prices as of ${v.costSnapshotDate} · RCN ${money(e.replacementCostNew, lang)} · cost approach ${money(e.costApproachValue, lang)}`}</p>
205 + {err && <p className="mt-3 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-[var(--danger)]">{err}</p>}
206 + {v.conflicts.length > 0 && (
207 + <div className="mt-4 border-l-2 border-[var(--amber)] pl-4">
208 + <p className="klabel">{fr ? "Conflits détectés entre données d'annonce et analyse visuelle" : "Conflicts between listing data and visual analysis"}</p>
209 + <ul className="mt-1 space-y-0.5 text-[13px] text-ink-2">{v.conflicts.map((c) => <li key={c.id}><b>{c.field}</b> : {c.sourceA} = {c.valueA} · {c.sourceB} = {c.valueB} <span className="vp-mono text-[10px] uppercase text-ink-3">({c.severity})</span> — {fr ? "la source la plus forte a été conservée, sans écrasement" : "stronger source kept, nothing overwritten"}</li>)}</ul>
210 + </div>
211 + )}
212 + {a.privacy.people_visible || a.privacy.documents_visible || a.privacy.licence_plates_visible ? <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Éléments personnels visibles sur certaines photos : ignorés par l'analyse (bâtiment seulement)." : "Personal elements visible on some photos: ignored by the analysis (building only)."}</p> : null}
213 + </section>
214 +
215 + {/* 01 photos */}
216 + <section>
217 + <Sect num="01" kicker={fr ? "Preuves" : "Evidence"} title={fr ? "Photos analysées" : "Photos analysed"} />
218 + <p className="mt-3 text-[13px] text-ink-2">{fr ? `${a.images.length} photos retenues après dédoublonnage (identifiants photo_NN cités dans chaque inférence).` : `${a.images.length} photos kept after de-duplication (photo_NN ids cited in each inference).`}</p>
219 + <div className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-5 lg:grid-cols-8">
220 + {a.images.map((im) => (
221 + <figure key={im.id} className="relative aspect-[4/3] overflow-hidden border border-[var(--line)] bg-surface-2">
222 + {/* eslint-disable-next-line @next/next/no-img-element */}
223 + <img src={im.source_url} alt={im.id} loading="lazy" className="h-full w-full object-cover" />
224 + <figcaption className="vp-mono absolute bottom-0 left-0 bg-ink px-1 text-[9px] text-paper">{im.id} · {im.room_hint}</figcaption>
225 + </figure>
226 + ))}
227 + </div>
228 + </section>
229 +
230 + {/* 02 profil technique */}
231 + <section>
232 + <Sect num="02" kicker={fr ? "Bâtiment" : "Building"} title={fr ? "Profil technique" : "Technical profile"} />
233 + <p className="mt-3 max-w-3xl text-[13px] text-ink-2">{fr ? "Chaque valeur indique sa couche d'origine et sa confiance. « Modifier » remplace l'inférence (l'originale est conservée pour audit) et recalcule le coût sans rappeler le modèle." : "Each value shows its source layer and confidence. “Edit” replaces the inference (the original is kept for audit) and recalculates the cost without calling the model again."}</p>
234 + <div className="mt-5 grid gap-x-8 gap-y-2 md:grid-cols-2">
235 + <FactRow fr={fr} label={fr ? "Type de bâtiment" : "Building type"} f={m.buildingType} path="buildingType" options={["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile"]} photos={photosFor} onOverride={onOverride} />
236 + <FactRow fr={fr} label={fr ? "Année de construction" : "Year built"} f={m.yearBuilt} path="yearBuilt" numeric photos={photosFor} onOverride={onOverride} />
237 + <FactRow fr={fr} label={fr ? "Étages" : "Storeys"} f={m.stories} path="stories" numeric photos={photosFor} onOverride={onOverride} />
238 + <FactRow fr={fr} label={fr ? "Aire d'étages (pi², hors sous-sol)" : "Gross floor area (sq ft, excl. basement)"} f={m.grossFloorAreaSqft} path="grossFloorAreaSqft" numeric photos={photosFor} onOverride={onOverride} />
239 + <FactRow fr={fr} label={fr ? "Qualité générale" : "Overall quality"} f={m.quality} path="quality" options={QUALITIES.map((q) => q.key)} photos={photosFor} onOverride={onOverride} />
240 + <FactRow fr={fr} label={fr ? "Sous-sol" : "Basement"} f={m.basement} path="basement" options={["none", "crawl", "unfinished", "partial", "finished", "walkout"]} photos={photosFor} onOverride={onOverride} />
241 + <FactRow fr={fr} label={fr ? "Sous-sol fini (part)" : "Finished basement (share)"} f={m.basementFinishedPct} path="basementFinishedPct" numeric fmt={(x) => pct(Number(x) * 100, lang, 0, false)} photos={photosFor} onOverride={onOverride} />
242 + <FactRow fr={fr} label={fr ? "Garage" : "Garage"} f={m.garage} path="garage.type" options={["none", "attached", "detached", "integrated", "carport"]} fmt={(g) => { const x = g as { type: string; spaces: number }; return x.type === "none" ? (fr ? "aucun" : "none") : `${x.type} · ${x.spaces} ${fr ? "place(s)" : "space(s)"}`; }} photos={photosFor} onOverride={onOverride} />
243 + <FactRow fr={fr} label={fr ? "Cuisines × qualité" : "Kitchens × quality"} f={m.kitchenQuality} path="kitchenQuality" options={QUALITIES.map((q) => q.key)} fmt={(x) => `${m.kitchens.value} × ${x}`} photos={photosFor} onOverride={onOverride} />
244 + <FactRow fr={fr} label={fr ? "Salles de bain" : "Bathrooms"} f={m.bathrooms} path="bathrooms" numeric photos={photosFor} onOverride={onOverride} />
245 + <FactRow fr={fr} label={fr ? "Salles d'eau" : "Powder rooms"} f={m.powderRooms} path="powderRooms" numeric photos={photosFor} onOverride={onOverride} />
246 + <FactRow fr={fr} label={fr ? "Qualité des salles de bain" : "Bathroom quality"} f={m.bathroomQuality} path="bathroomQuality" options={QUALITIES.map((q) => q.key)} photos={photosFor} onOverride={onOverride} />
247 + <FactRow fr={fr} label={fr ? "Chauffage" : "Heating"} f={m.heating} path="heating" options={["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "furnace_oil", "hydronic", "geothermal", "wood"]} photos={photosFor} onOverride={onOverride} />
248 + <FactRow fr={fr} label={fr ? "Climatisation · échangeur d'air" : "A/C · air exchanger"} f={m.hasAirConditioning} path="hasAirConditioning" options={["true", "false"]} fmt={(x) => `${x ? (fr ? "clim. oui" : "A/C yes") : (fr ? "clim. non" : "A/C no")} · ${m.hasAirExchanger.value ? "VRC" : fr ? "sans VRC" : "no HRV"}`} photos={photosFor} onOverride={(p, val) => onOverride(p, val === "true")} />
249 + <FactRow fr={fr} label={fr ? "Planchers" : "Flooring"} f={m.flooring} fmt={(x) => Object.entries(x as Record<string, number>).map(([k, s]) => `${k} ${Math.round(s * 100)} %`).join(", ") || "—"} photos={photosFor} onOverride={onOverride} />
250 + <FactRow fr={fr} label={fr ? "Terrasse (pi²)" : "Deck (sq ft)"} f={m.deckSqft} path="deckSqft" numeric photos={photosFor} onOverride={onOverride} />
251 + <FactRow fr={fr} label={fr ? "Entrée" : "Driveway"} f={m.driveway} path="driveway" options={["asphalt", "pavers", "gravel", "concrete", "none"]} fmt={(x) => `${x} · ${num(m.drivewaySqft.value, lang)} pi²`} photos={photosFor} onOverride={onOverride} />
252 + <FactRow fr={fr} label={fr ? "Piscine" : "Pool"} f={m.pool} path="pool" options={["none", "above_ground", "inground"]} photos={photosFor} onOverride={onOverride} />
253 + </div>
254 + {a.renovations.length > 0 && (
255 + <div className="mt-5">
256 + <p className="klabel">{fr ? "Rénovations probables (estimation IA)" : "Likely renovations (AI estimate)"}</p>
257 + <ul className="mt-1 grid gap-1 text-[13px] text-ink-2 sm:grid-cols-2">{a.renovations.map((r, i) => <li key={i}>{r.component} — {fr ? "probabilité" : "likelihood"} {Math.round(r.renovation_likelihood * 100)} %{r.estimated_renovation_age_range.length === 2 ? ` · ${r.estimated_renovation_age_range[0]}–${r.estimated_renovation_age_range[1]} ${fr ? "ans" : "yrs"}` : ""} <span className="vp-mono text-[10px] text-ink-3">{r.evidence.join(", ")}</span></li>)}</ul>
258 + </div>
259 + )}
260 + </section>
261 +
262 + {/* 03 construction */}
263 + <section>
264 + <Sect num="03" kicker={fr ? "Composantes" : "Components"} title={fr ? "Construction et enveloppe" : "Construction and envelope"} />
265 + <div className="mt-5 grid gap-x-8 gap-y-2 md:grid-cols-2">
266 + <FactRow fr={fr} label={fr ? "Structure" : "Structure"} f={m.structure} path="structure" options={["wood_frame", "steel", "concrete", "log", "masonry"]} photos={photosFor} onOverride={onOverride} />
267 + <FactRow fr={fr} label={fr ? "Fondation" : "Foundation"} f={m.foundation} path="foundation" options={["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone"]} photos={photosFor} onOverride={onOverride} />
268 + <FactRow fr={fr} label={fr ? "Revêtement extérieur" : "Exterior cladding"} f={m.siding} fmt={(x) => Object.entries(x as Record<string, number>).sort((p, q) => q[1] - p[1]).map(([k, s]) => `${Math.round(s * 100)} % ${k.replace(/_/g, " ")}`).join(" + ")} photos={photosFor} onOverride={onOverride} />
269 + <FactRow fr={fr} label={fr ? "Toiture" : "Roof covering"} f={m.roof} path="roof" options={["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile"]} photos={photosFor} onOverride={onOverride} />
270 + <FactRow fr={fr} label={fr ? "Géométrie du toit · pente" : "Roof geometry · pitch"} f={m.roofGeometry} path="roofGeometry" options={["gable", "hip", "flat", "mansard", "complex"]} fmt={(x) => `${x} · ${m.roofPitch.value}/12`} photos={photosFor} onOverride={onOverride} />
271 + <FactRow fr={fr} label={fr ? "Fenêtres" : "Windows"} f={m.windows} path="windows" options={["pvc", "hybrid", "aluminum", "wood"]} fmt={(x) => `${x}${m.windowCount.value ? ` · ${m.windowCount.value} ${fr ? "fenêtres (IA)" : "windows (AI)"}` : ""}`} photos={photosFor} onOverride={onOverride} />
272 + </div>
273 + <div className="mt-6 grid gap-x-8 gap-y-2 md:grid-cols-3">
274 + {(["heat_source", "heat_distribution", "heat_pump", "air_conditioning", "air_exchanger", "water_heater"] as const).map((k) => <MechRow key={k} fr={fr} label={k.replace(/_/g, " ")} m={a.mechanical[k]} />)}
275 + {(["panel_type", "service_type", "ev_charger"] as const).map((k) => <MechRow key={k} fr={fr} label={k.replace(/_/g, " ")} m={a.electrical[k]} />)}
276 + {(["water_supply", "waste_system"] as const).map((k) => <MechRow key={k} fr={fr} label={k.replace(/_/g, " ")} m={a.plumbing[k]} />)}
277 + </div>
278 + {a.uncertainties.length > 0 && <div className="mt-5 border-l-2 border-[var(--line-strong)] pl-4"><p className="klabel">{fr ? "Incertitudes signalées par le modèle" : "Uncertainties reported by the model"}</p><ul className="mt-1 list-disc pl-4 text-[13px] text-ink-2">{a.uncertainties.map((u, i) => <li key={i}>{u}</li>)}</ul></div>}
279 + </section>
280 +
281 + {/* 04 quantités */}
282 + <section>
283 + <Sect num="04" kicker={fr ? "Prise de quantités" : "Quantity take-off"} title={fr ? "Quantités" : "Quantities"} />
284 + <p className="mt-3 max-w-3xl text-[13px] text-ink-2">{fr ? "Quantité estimée à partir des données et images disponibles. Les surfaces (murs, toit, fondations, gypse, planchers) sont CALCULÉES par le moteur de géométrie à partir de l'aire d'étages et du nombre d'étages ; l'IA ne fournit que ce que le code ne peut pas dériver." : "Quantities estimated from available data and images. Areas (walls, roof, foundations, drywall, floors) are COMPUTED by the geometry engine from floor area and storeys; the AI only provides what code cannot derive."}</p>
285 + <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-5">
286 + <Stat k={fr ? "Empreinte" : "Footprint"} v={`${num(a.derived_geometry.footprint_sqft, lang)} pi²`} sub={fr ? "calculé" : "computed"} />
287 + <Stat k={fr ? "Périmètre exposé" : "Exposed perimeter"} v={`${num(a.derived_geometry.exposed_perimeter_ft, lang)} pi`} sub={fr ? "calculé" : "computed"} />
288 + <Stat k={fr ? "Murs extérieurs nets" : "Net exterior walls"} v={`${num(a.derived_geometry.net_wall_sqft, lang)} pi²`} sub={fr ? "calculé" : "computed"} />
289 + <Stat k={fr ? "Toit" : "Roof"} v={`${num(a.derived_geometry.roof_sqft, lang)} pi²`} sub={fr ? "calculé" : "computed"} />
290 + <Stat k={fr ? "Fenêtres" : "Windows"} v={`${a.derived_geometry.window_count}`} sub={m.windowCount.value ? (fr ? "IA" : "AI") : fr ? "calculé" : "computed"} />
291 + </div>
292 + <div className="src-wrap mt-5">
293 + <table className="src-table">
294 + <thead><tr><th>{fr ? "Quantité IA" : "AI quantity"}</th><th className="text-right">{fr ? "Valeur" : "Value"}</th><th>{fr ? "Confiance" : "Confidence"}</th><th>{fr ? "Méthode / preuves" : "Method / evidence"}</th><th>{fr ? "Utilisée ?" : "Used?"}</th></tr></thead>
295 + <tbody>
296 + {Object.entries(a.estimated_quantities).map(([k, qv]) => (
297 + <tr key={k}><td>{k.replace(/_/g, " ")}</td><td className="vp-mono text-right">{qv.value == null ? "—" : `${num(qv.value, lang, 1)} ${qv.unit}`}</td><td><ConfBar value={qv.confidence} fr={fr} /></td><td className="text-[12px] text-ink-2">{qv.method ?? "—"} <span className="vp-mono text-[10px] text-ink-3">{qv.evidence.join(", ")}</span></td><td className="vp-mono text-[10px] uppercase">{qv.value != null && qv.confidence >= 0.6 ? (fr ? "oui (≥ 0,6)" : "yes (≥ 0.6)") : fr ? "non → moteur" : "no → engine"}</td></tr>
298 + ))}
299 + </tbody>
300 + </table>
301 + </div>
302 + </section>
303 +
304 + {/* 05 assemblages */}
305 + <section>
306 + <Sect num="05" kicker={fr ? "Mapping" : "Mapping"} title={fr ? "Assemblages retenus" : "Assemblies used"} />
307 + <p className="mt-3 max-w-3xl text-[13px] text-ink-2">{fr ? `${e.lines.length} assemblages du catalogue, quantifiés par le moteur (ou par l'IA quand la confiance ≥ 0,6). Les façades mixtes sont pondérées (ex. 60 % brique / 40 % vinyle).` : `${e.lines.length} catalogue assemblies, quantified by the engine (or by the AI when confidence ≥ 0.6). Mixed façades are weighted (e.g. 60 % brick / 40 % vinyl).`}</p>
308 + <div className="src-wrap mt-4">
309 + <table className="src-table">
310 + <thead><tr><th>{fr ? "Assemblage" : "Assembly"}</th><th className="text-right">{fr ? "Quantité" : "Quantity"}</th><th>{fr ? "Origine" : "Source"}</th><th className="text-right">{fr ? "Coût unitaire" : "Unit cost"}</th><th className="text-right">{fr ? "Coût" : "Cost"}</th><th>{fr ? "Formule" : "Formula"}</th></tr></thead>
311 + <tbody>
312 + {e.lines.map((l) => (
313 + <tr key={l.assemblyCode}><td><Link href={`/cout/assemblage/${encodeURIComponent(l.assemblyCode)}`} className="hover:text-accent-deep">{fr ? l.nameFr : l.nameEn}</Link><span className="vp-mono ml-2 text-[10px] text-ink-3">{categoryLabel(l.category, fr)}</span></td><td className="vp-mono text-right">{num(l.quantity, lang, 1)} {l.unit}</td><td><LayerBadge layer={layerOf(l.quantitySource)} fr={fr} /></td><td className="vp-mono text-right">{num(l.unitCost, lang, 2)} $</td><td className="vp-mono text-right">{money(l.adjusted, lang)}</td><td className="text-[11.5px] text-ink-2">{l.quantityFormula}</td></tr>
314 + ))}
315 + </tbody>
316 + </table>
317 + </div>
318 + </section>
319 +
320 + {/* 06 coût */}
321 + <section>
322 + <Sect num="06" kicker={fr ? "Coûts actuels" : "Current costs"} title={fr ? "Coût de remplacement à neuf" : "Replacement cost new"} />
323 + <div className="mt-5"><CostTable e={e} fr={fr} /></div>
324 + </section>
325 +
326 + {/* 07 dépréciation */}
327 + <section>
328 + <Sect num="07" kicker={fr ? "Dépréciation" : "Depreciation"} title={fr ? "Condition observée → âge effectif → dépréciation" : "Observed condition → effective age → depreciation"} />
329 + <p className="mt-3 max-w-3xl text-[13px] text-ink-2">{fr ? "L'IA propose une condition par composante (INFÉRÉ PAR IA) ; les règles métier (table component_condition_rules) la transforment en âge effectif = ratio × vie économique ; le moteur calcule les dollars (CALCULÉ). L'IA ne calcule jamais la dépréciation en dollars." : "The AI proposes a condition per component (AI-INFERRED); business rules (component_condition_rules) turn it into an effective age = ratio × economic life; the engine computes the dollars (COMPUTED). The AI never computes depreciation dollars."}</p>
330 + <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-4">
331 + <Stat k={fr ? "Âge chronologique" : "Chronological age"} v={e.depreciation.chronologicalAge != null ? `${e.depreciation.chronologicalAge} ${fr ? "ans" : "yrs"}` : "—"} />
332 + <Stat k={fr ? "Âge effectif (suggestion IA)" : "Effective age (AI suggestion)"} v={a.estimated_effective_age.effective_age != null ? `${a.estimated_effective_age.effective_age} ${fr ? "ans" : "yrs"}` : "—"} sub={`${fr ? "confiance" : "confidence"} ${Math.round(a.estimated_effective_age.confidence * 100)} %`} />
333 + <Stat k={fr ? "Vie économique" : "Economic life"} v={`${e.depreciation.economicLife} ${fr ? "ans" : "yrs"}`} />
334 + <Stat k={fr ? "Détérioration physique" : "Physical deterioration"} v={pct(e.depreciation.physicalPct, lang, 1, false)} sub={money(e.depreciation.physical, lang)} />
335 + </div>
336 + {a.estimated_effective_age.reasoning_summary.length > 0 && <ul className="mt-3 flex flex-wrap gap-2">{a.estimated_effective_age.reasoning_summary.map((r, i) => <li key={i} className="vp-mono border border-[var(--line)] px-2 py-0.5 text-[10.5px] text-ink-2">{r}</li>)}</ul>}
337 + <div className="src-wrap mt-5">
338 + <table className="src-table">
339 + <thead><tr><th>{fr ? "Composante" : "Component"}</th><th className="text-right">RCN</th><th>{fr ? "Condition (IA)" : "Condition (AI)"}</th><th className="text-right">{fr ? "Vie" : "Life"}</th><th className="text-right">{fr ? "Âge eff." : "Eff. age"}</th><th className="text-right">%</th><th className="text-right">{fr ? "Dépréciation" : "Depreciation"}</th></tr></thead>
340 + <tbody>
341 + {e.depreciation.components.map((c) => (
342 + <tr key={c.conditionGroup}>
343 + <td>{fr ? c.labelFr : c.labelEn}</td><td className="vp-mono text-right">{money(c.rcn, lang)}</td>
344 + <td>
345 + <select className="vp-input !py-0.5 text-[12px]" value={c.condition ?? ""} onChange={(ev) => onOverride(`conditions.${c.conditionGroup}`, ev.target.value)} disabled={busy}>
346 + <option value="">{fr ? "— (âge chronologique)" : "— (chronological age)"}</option>
347 + {CONDITIONS.map((o) => <option key={o.key} value={o.key}>{fr ? o.fr : o.en}</option>)}
348 + </select>
349 + </td>
350 + <td className="vp-mono text-right">{c.economicLife}</td><td className="vp-mono text-right">{num(c.effectiveAge, lang, 1)}</td><td className="vp-mono text-right">{num(c.depreciationPct, lang, 1)} %</td><td className="vp-mono text-right text-[var(--danger)]">−{money(c.depreciation, lang)}</td>
351 + </tr>
352 + ))}
353 + </tbody>
354 + </table>
355 + </div>
356 + </section>
357 +
358 + {/* 08 terrain */}
359 + <section>
360 + <Sect num="08" kicker={fr ? "Terrain" : "Land"} title={fr ? "Valeur du terrain utilisée" : "Land value used"} />
361 + <div className="mt-4 grid gap-x-8 gap-y-3 md:grid-cols-[auto_1fr]">
362 + <p className="vp-display text-[36px] font-bold tracking-[-0.03em]">{e.landValue ? money(e.landValue, lang) : fr ? "Donnée non disponible" : "Not available"}</p>
363 + <div className="text-[13.5px] text-ink-2">
364 + <p><b>{fr ? "Source" : "Source"}</b> : {e.input.land.source === "role" ? (fr ? "Rôle d'évaluation foncière 2026 (MAMH), via le jumelage de l'annonce à son unité d'évaluation." : "2026 assessment roll (MAMH), via the listing's match to its assessment unit.") : fr ? "Annonce non jumelée au rôle — saisir la valeur du terrain dans l'atelier Coût." : "Listing not matched to the roll — enter the land value in the Cost workbench."} <LayerBadge layer={e.input.land.source === "role" ? "observed" : "assumption"} fr={fr} /></p>
365 + <p className="mt-2 border-l-2 border-[var(--amber)] pl-3">{fr ? "⚠ La valeur du terrain au rôle n'est pas nécessairement la valeur marchande actuelle du terrain." : "⚠ The land value on the roll is not necessarily the land's current market value."}</p>
366 + </div>
367 + </div>
368 + </section>
369 +
370 + {/* 09 résultat */}
371 + <section>
372 + <Sect num="09" kicker={fr ? "Résultat" : "Result"} title={fr ? "Indication par la méthode du coût et croisements" : "Cost approach indication and cross-checks"} />
373 + <div className="mt-5 grid gap-x-12 gap-y-8 lg:grid-cols-[1fr_1fr]">
374 + <div className="src-wrap">
375 + <table className="src-table">
376 + <tbody>
377 + <tr><td>{fr ? "Prix demandé" : "Asking price"}</td><td className="vp-mono text-right">{money(head.price, lang)}</td></tr>
378 + <tr><td>{fr ? "Terrain" : "Land"}</td><td className="vp-mono text-right">{money(e.landValue, lang)}</td></tr>
379 + <tr><td>{fr ? "Valeur implicite du bâtiment (prix − terrain)" : "Implicit building value (price − land)"}</td><td className="vp-mono text-right">{money(implicitBuilding, lang)}</td></tr>
380 + <tr><td>RCN</td><td className="vp-mono text-right">{money(e.replacementCostNew, lang)}</td></tr>
381 + <tr><td>{fr ? "Valeur bâtiment dépréciée" : "Depreciated building value"}</td><td className="vp-mono text-right">{money(e.depreciation.depreciatedImprovementValue, lang)}</td></tr>
382 + <tr className="font-bold border-t-2 border-ink"><td>{fr ? "Indication par le coût" : "Cost approach indication"}</td><td className="vp-mono text-right">{money(e.costApproachValue, lang)}</td></tr>
383 + <tr><td>{fr ? "Écart relatif (bâtiment implicite vs déprécié)" : "Relative gap (implicit vs depreciated building)"}</td><td className="vp-mono text-right">{gap == null ? "—" : pct(gap, lang, 1)}</td></tr>
384 + </tbody>
385 + </table>
386 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Aucune conclusion automatique de sur- ou sous-évaluation : la comparaison des approches est l'objet de l'analyse." : "No automatic over/under-valuation conclusion: comparing approaches is the teaching goal."}</p>
387 + </div>
388 + <ReadingsChart fr={fr} rows={[
389 + { label: fr ? "Hédonique" : "Hedonic", v: e.otherReadings?.hedonic ?? head.modelEst },
390 + { label: fr ? "Comparables" : "Comparables", v: e.otherReadings?.comparables ?? head.compsEst },
391 + { label: fr ? "Hybride Vrai-Prix" : "Vrai-Prix hybrid", v: e.otherReadings?.hybrid ?? head.evalEst },
392 + { label: fr ? "Méthode du coût" : "Cost approach", v: e.costApproachValue },
393 + { label: fr ? "Prix demandé" : "Asking price", v: head.price },
394 + { label: fr ? "Rôle 2026" : "2026 roll", v: e.otherReadings?.rollValue ?? head.roleValue },
395 + ]} />
396 + </div>
397 + {similar && similar.length > 0 && (
398 + <div className="mt-8">
399 + <p className="klabel">{fr ? "Propriétés de construction similaire (vecteur technique — comparables TECHNIQUES, pas de marché)" : "Technically similar properties (technical vector — TECHNICAL comparables, not market)"}</p>
400 + <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
401 + {similar.map((s) => (
402 + <Link key={s.listingUid} href={`/a-vendre/${encodeURIComponent(s.listingUid)}/analyse-cout`} className="flex gap-3 border border-[var(--line)] bg-surface p-2 hover:border-[var(--line-strong)]">
403 + {/* eslint-disable-next-line @next/next/no-img-element */}
404 + {s.image ? <img src={s.image} alt="" className="h-16 w-20 object-cover" /> : <div className="h-16 w-20 bg-surface-2" />}
405 + <div className="min-w-0 text-[12.5px]"><p className="truncate font-bold">{s.address ?? s.listingUid}</p><p className="text-ink-2">{s.city} · {s.price ? money(s.price, lang) : "—"}</p><p className="vp-mono text-[10px] text-ink-3">{fr ? "similarité" : "similarity"} {Math.round(s.similarity * 100)} %</p></div>
406 + </Link>
407 + ))}
408 + </div>
409 + </div>
410 + )}
411 + {similar && similar.length === 0 && <p className="vp-mono mt-6 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Aucune autre annonce analysée de type et de taille comparables pour l'instant." : "No other analysed listing of comparable type and size yet."}</p>}
412 + </section>
413 +
414 + {/* 10 sources */}
415 + <section>
416 + <Sect num="10" kicker={fr ? "Provenance" : "Provenance"} title={fr ? "Sources" : "Sources"} />
417 + <dl className="mt-4 grid gap-x-8 gap-y-2 text-[13.5px] sm:grid-cols-2">
418 + {[
419 + [fr ? "Photos" : "Photos", fr ? `annonce immobilière (${a.metadata.listing_source}), ${a.metadata.image_count} photos analysées` : `real-estate listing (${a.metadata.listing_source}), ${a.metadata.image_count} photos analysed`],
420 + [fr ? "Caractéristiques" : "Characteristics", fr ? `annonce${head.unitId ? " + rôle d'évaluation MAMH" : ""}` : `listing${head.unitId ? " + MAMH assessment roll" : ""}`],
421 + [fr ? "Analyse du bâtiment" : "Building analysis", `${a.metadata.model} · prompt ${a.metadata.prompt_version} · ${fr ? "schéma" : "schema"} ${a.metadata.schema_version}`],
422 + [fr ? "Main-d'œuvre" : "Labour", "APCHQ (coût horaire employeur, secteur résidentiel léger) · CCQ (conventions)"],
423 + [fr ? "Matériaux" : "Materials", "Canac · BMR · Patrick Morin (prix affichés) · prix de référence internes étiquetés « hypothèse »"],
424 + [fr ? "Indices" : "Indices", "Statistique Canada 18-10-0289 (indices des prix de la construction de bâtiments)"],
425 + [fr ? "Benchmarks" : "Benchmarks", e.benchmarks.length ? e.benchmarks.map((b) => `${b.source} ${b.year} : ${b.low}–${b.high} ${b.unit} (${b.status})`).join(" · ") : fr ? "aucun benchmark externe disponible pour ce type/marché" : "no external benchmark available for this type/market"],
426 + [fr ? "Géométrie et quantités" : "Geometry and quantities", fr ? "moteur Vrai-Prix (formules affichées en 05)" : "Vrai-Prix engine (formulas shown in 05)"],
427 + [fr ? "Instantané des prix" : "Price snapshot", `${v.costSnapshotDate} · ${fr ? "base" : "database"} ${e.costDatabaseVersion}`],
428 + ].map(([k, val]) => <div key={k} className="border-t border-[var(--line)] pt-1.5"><dt className="vp-mono text-[9.5px] uppercase tracking-[0.1em] text-ink-3">{k}</dt><dd className="text-ink-2">{val}</dd></div>)}
429 + </dl>
430 + {v.usage && typeof v.usage.inputTokens === "number" && <p className="vp-mono mt-4 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Usage modèle" : "Model usage"} : {num(v.usage.inputTokens as number, lang)} {fr ? "jetons entrée" : "input tokens"} · {num(v.usage.outputTokens as number, lang)} {fr ? "sortie" : "output"} · {num((v.usage.latencyMs as number) / 1000, lang, 0)} s</p>}
431 + </section>
432 +
433 + {/* 11 JSON */}
434 + <section>
435 + <Sect num="11" kicker={fr ? "Données" : "Data"} title={fr ? "JSON technique" : "Technical JSON"} />
436 + <p className="mt-3 text-[13px] text-ink-2">{fr ? "Le profil complet (sections metadata, listing, property, geometry, construction, exterior, interior, kitchens, bathrooms, mechanical, electrical, plumbing, basement, garage, exterior_improvements, quality, condition, estimated_effective_age, estimated_quantities, assemblies, uncertainties, evidence, confidence) et le JSON compact du moteur." : "The full profile (metadata, listing, property, geometry, construction, exterior, interior, kitchens, bathrooms, mechanical, electrical, plumbing, basement, garage, exterior_improvements, quality, condition, estimated_effective_age, estimated_quantities, assemblies, uncertainties, evidence, confidence) and the engine's compact JSON."}</p>
437 + <div className="mt-4 flex flex-wrap gap-3"><JsonViewer value={{ ...a, compact_for_cost_engine: v.compact }} fr={fr} /><a className="btn btn-ghost" href={`/api/listings/${encodeURIComponent(head.uid)}/ai-analysis-json?id=${v.id}`}>{fr ? "Télécharger JSON" : "Download JSON"}</a></div>
438 + {v.versions.length > 1 && <p className="vp-mono mt-4 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Versions" : "Versions"} : {v.versions.map((x) => `v${x.version} ${x.status}`).join(" · ")}</p>}
439 + </section>
440 + </>
441 + );
442 +}
443 +
444 +/* ================================================================ lignes de faits */
445 +
446 +function FactRow<T>({ fr, label, f, path, options, numeric, fmt, photos, onOverride }: { fr: boolean; label: string; f: Fact<T>; path?: string; options?: string[]; numeric?: boolean; fmt?: (v: T) => string; photos: (ev: string[]) => { id: string; source_url: string }[]; onOverride: (p: string, v: unknown) => void }) {
447 + const [edit, setEdit] = useState(false);
448 + const [showPhotos, setShowPhotos] = useState(false);
449 + const [val, setVal] = useState<string>(f.value == null ? "" : typeof f.value === "object" ? "" : String(f.value));
450 + const shown = f.value == null ? "—" : fmt ? fmt(f.value) : typeof f.value === "object" ? JSON.stringify(f.value) : String(f.value).replace(/_/g, " ");
451 + const evPhotos = photos(f.evidence);
452 + return (
453 + <div className="border-t border-[var(--line)] py-2">
454 + <div className="flex flex-wrap items-center justify-between gap-2">
455 + <p className="vp-mono text-[9.5px] uppercase tracking-[0.1em] text-ink-3">{label}</p>
456 + <div className="flex items-center gap-2"><LayerBadge layer={layerOf(f.source)} fr={fr} /><ConfBar value={f.confidence} fr={fr} /></div>
457 + </div>
458 + <div className="mt-1 flex flex-wrap items-center gap-3">
459 + <p className="vp-display text-[15px] font-bold">{shown}</p>
460 + {evPhotos.length > 0 && <button type="button" className="vp-mono text-[10px] uppercase tracking-[0.06em] text-accent" onClick={() => setShowPhotos((s) => !s)}>{fr ? `Voir les ${evPhotos.length} photos utilisées` : `See the ${evPhotos.length} photos used`}</button>}
461 + {path && (options || numeric) && <button type="button" className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-2 underline" onClick={() => setEdit((s) => !s)}>{fr ? "Modifier" : "Edit"}</button>}
462 + {f.alternatives.length > 0 && <span className="vp-mono text-[10px] text-ink-3" title={f.alternatives.map((alt) => `${alt.source}: ${JSON.stringify(alt.value)}`).join("\n")}>{fr ? `${f.alternatives.length} autre(s) source(s)` : `${f.alternatives.length} other source(s)`}</span>}
463 + </div>
464 + {edit && path && (
465 + <form className="mt-2 flex flex-wrap items-center gap-2" onSubmit={(ev) => { ev.preventDefault(); onOverride(path, numeric ? Number(val) : val); setEdit(false); }}>
466 + {options ? <select className="vp-input !py-1 text-[13px]" value={val} onChange={(ev) => setVal(ev.target.value)}><option value="">—</option>{options.map((o) => <option key={o} value={o}>{o.replace(/_/g, " ")}</option>)}</select> : <input className="vp-input !py-1 w-32 text-[13px]" type="number" step="any" value={val} onChange={(ev) => setVal(ev.target.value)} />}
467 + <button type="submit" className="btn btn-primary !py-1 text-[12px]" disabled={val === ""}>{fr ? "Appliquer et recalculer" : "Apply and recalculate"}</button>
468 + </form>
469 + )}
470 + {showPhotos && <div className="mt-2 flex flex-wrap gap-2">{evPhotos.map((p) => (
471 + // eslint-disable-next-line @next/next/no-img-element
472 + <img key={p.id} src={p.source_url} alt={p.id} className="h-24 w-32 object-cover border border-[var(--line)]" />
473 + ))}</div>}
474 + </div>
475 + );
476 +}
477 +
478 +function MechRow({ fr, label, m }: { fr: boolean; label: string; m: { value: string | null; status: string; confidence: number; evidence: string[] } }) {
479 + const layer = m.status === "observed" || m.status === "inferred" ? "ai_inferred" : m.status === "listing" ? "observed" : "assumption";
480 + return (
481 + <div className="border-t border-[var(--line)] py-2">
482 + <p className="vp-mono text-[9.5px] uppercase tracking-[0.1em] text-ink-3">{label}</p>
483 + <div className="mt-1 flex flex-wrap items-center gap-2"><p className="vp-display text-[14px] font-bold">{m.value ? m.value.replace(/_/g, " ") : fr ? "inconnu" : "unknown"}</p><LayerBadge layer={layer} fr={fr} /><ConfBar value={m.confidence} fr={fr} /></div>
484 + </div>
485 + );
486 +}
487 +
488 +function ReadingsChart({ fr, rows }: { fr: boolean; rows: { label: string; v: number | null }[] }) {
489 + const lang = fr ? "fr" : "en";
490 + const vals = rows.map((r) => r.v).filter((x): x is number => x != null && x > 0);
491 + const max = Math.max(...vals, 1);
492 + return (
493 + <div>
494 + <p className="klabel">{fr ? "Lectures de la valeur — hédonique, comparables, coût, prix demandé" : "Value readings — hedonic, comparables, cost, asking price"}</p>
495 + <div className="mt-3 space-y-2">
496 + {rows.map((r) => (
497 + <div key={r.label} className="grid grid-cols-[130px_1fr_110px] items-center gap-2 text-[12.5px]">
498 + <span className="truncate text-ink-2">{r.label}</span>
499 + <span className="h-[14px] bg-[var(--line-soft)]"><span className={`block h-full ${r.label.includes("coût") || r.label.includes("Cost") ? "bg-[var(--accent)]" : "bg-ink"}`} style={{ width: `${r.v ? (100 * r.v) / max : 0}%` }} /></span>
500 + <span className="vp-mono text-right">{r.v ? money(r.v, lang) : "—"}</span>
501 + </div>
502 + ))}
503 + </div>
504 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Pas de moyenne automatique des approches." : "No automatic averaging of approaches."}</p>
505 + </div>
506 + );
507 +}
508 +
509 +/* ================================================================ exercice */
510 +
511 +interface Exercise { foundation: string; siding: string; roof: string; quality: string; roofCondition: string; kitchenCondition: string; windowCount: string }
512 +const EMPTY: Exercise = { foundation: "", siding: "", roof: "", quality: "", roofCondition: "", kitchenCondition: "", windowCount: "" };
513 +
514 +function ExerciseBlock({ uid, fr, view, revealed, onReveal }: { uid: string; fr: boolean; view: AnalysisView | null; revealed: boolean; onReveal: () => void }) {
515 + const [ex, setEx] = useState<Exercise>(EMPTY);
516 + const [ready, setReady] = useState(false);
517 + useEffect(() => { const id = setTimeout(() => { try { const raw = window.localStorage.getItem(EX_KEY(uid)); if (raw) setEx({ ...EMPTY, ...(JSON.parse(raw) as Exercise) }); } catch {} setReady(true); }, 0); return () => clearTimeout(id); }, [uid]);
518 + useEffect(() => { if (ready) try { window.localStorage.setItem(EX_KEY(uid), JSON.stringify(ex)); } catch {} }, [ex, ready, uid]);
519 + const set = (k: keyof Exercise) => (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => setEx((s) => ({ ...s, [k]: e.target.value }));
520 + const m = view?.merged ?? null;
521 + const dominant = m ? Object.entries(m.siding.value).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))[0]?.[0] ?? "" : "";
522 + const rows = useMemo(() => m ? [
523 + [fr ? "Fondation" : "Foundation", ex.foundation, m.foundation.value], [fr ? "Revêtement dominant" : "Dominant cladding", ex.siding, dominant], [fr ? "Toiture" : "Roof", ex.roof, m.roof.value], [fr ? "Qualité" : "Quality", ex.quality, m.quality.value],
524 + [fr ? "Condition toiture" : "Roof condition", ex.roofCondition, m.conditions.roof ?? "—"], [fr ? "Condition cuisine" : "Kitchen condition", ex.kitchenCondition, m.conditions.kitchen ?? "—"], [fr ? "Fenêtres" : "Windows", ex.windowCount, String(m.windowCount.value ?? (view?.analysis?.derived_geometry.window_count ?? "—"))],
525 + ] as [string, string, string][] : [], [m, ex, fr, dominant, view]);
526 + return (
527 + <section className="border border-[var(--accent)] bg-surface p-6">
528 + <p className="klabel">✎ {fr ? "Exercice — votre lecture du bâtiment" : "Exercise — your reading of the building"}</p>
529 + <p className="mt-2 text-[13px] text-ink-2">{fr ? "Observez les photos de l'annonce et remplissez la grille avant de révéler l'analyse IA ; vos réponses restent sur cet appareil." : "Look at the listing photos and fill the grid before revealing the AI analysis; your answers stay on this device."}</p>
530 + <div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
531 + <label className="block"><span className="klabel">{fr ? "Fondation" : "Foundation"}</span><select className="vp-input mt-1" value={ex.foundation} onChange={set("foundation")}><option value="">—</option>{["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone"].map((o) => <option key={o}>{o}</option>)}</select></label>
532 + <label className="block"><span className="klabel">{fr ? "Revêtement dominant" : "Dominant cladding"}</span><select className="vp-input mt-1" value={ex.siding} onChange={set("siding")}><option value="">—</option>{["vinyl", "brick", "fiber_cement", "wood", "stone", "stucco", "aluminum", "steel"].map((o) => <option key={o}>{o}</option>)}</select></label>
533 + <label className="block"><span className="klabel">{fr ? "Toiture" : "Roof"}</span><select className="vp-input mt-1" value={ex.roof} onChange={set("roof")}><option value="">—</option>{["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile"].map((o) => <option key={o}>{o}</option>)}</select></label>
534 + <label className="block"><span className="klabel">{fr ? "Qualité" : "Quality"}</span><select className="vp-input mt-1" value={ex.quality} onChange={set("quality")}><option value="">—</option>{QUALITIES.map((o) => <option key={o.key} value={o.key}>{fr ? o.fr : o.en}</option>)}</select></label>
535 + <label className="block"><span className="klabel">{fr ? "Condition toiture" : "Roof condition"}</span><select className="vp-input mt-1" value={ex.roofCondition} onChange={set("roofCondition")}><option value="">—</option>{CONDITIONS.map((o) => <option key={o.key} value={o.key}>{fr ? o.fr : o.en}</option>)}</select></label>
536 + <label className="block"><span className="klabel">{fr ? "Condition cuisine" : "Kitchen condition"}</span><select className="vp-input mt-1" value={ex.kitchenCondition} onChange={set("kitchenCondition")}><option value="">—</option>{CONDITIONS.map((o) => <option key={o.key} value={o.key}>{fr ? o.fr : o.en}</option>)}</select></label>
537 + <label className="block"><span className="klabel">{fr ? "Nombre de fenêtres" : "Window count"}</span><input className="vp-input mt-1" type="number" value={ex.windowCount} onChange={set("windowCount")} /></label>
538 + </div>
539 + <div className="mt-4 flex flex-wrap gap-2">
540 + {!revealed && <button type="button" className="btn btn-accent" onClick={onReveal} disabled={!view}>{fr ? "Comparer avec l'analyse IA" : "Compare with the AI analysis"} →</button>}
541 + {!view && <span className="vp-mono self-center text-[10px] uppercase text-ink-3">{fr ? "Lancez d'abord l'analyse IA (ci-dessous)." : "Launch the AI analysis first (below)."}</span>}
542 + </div>
543 + {revealed && rows.length > 0 && (
544 + <div className="src-wrap mt-4"><table className="src-table"><thead><tr><th>{fr ? "Élément" : "Item"}</th><th>{fr ? "Votre réponse" : "Your answer"}</th><th>{fr ? "Analyse IA" : "AI analysis"}</th><th>{fr ? "Accord" : "Match"}</th></tr></thead>
545 + <tbody>{rows.map(([k, mine, ai]) => <tr key={k}><td>{k}</td><td className="vp-mono">{mine || "—"}</td><td className="vp-mono">{String(ai).replace(/_/g, " ")}</td><td>{mine ? (mine === ai ? "✓" : "✗") : "—"}</td></tr>)}</tbody></table></div>
546 + )}
547 + </section>
548 + );
549 +}
added src/components/avendre/cost/ui.tsx +107 −0
@@ -0,0 +1,107 @@
1 +// Vrai-Prix — petits composants partagés de l'analyse IA du bâtiment (badges de couche, sections, tableau RCN, viewer JSON).
2 +"use client";
3 +import { useState } from "react";
4 +import type { Layer } from "@/lib/cost/types";
5 +import type { CostEstimate } from "@/lib/cost/types";
6 +import { money, num, pct } from "../fmt";
7 +
8 +export function Sect({ num: n, kicker, title, id }: { num: string; kicker: string; title: string; id?: string }) {
9 + return (
10 + <header className="sec" id={id}>
11 + <span className="sec-num">{n} — {kicker}</span>
12 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{title}</h2>
13 + </header>
14 + );
15 +}
16 +
17 +/** §208 — les quatre couches, toujours nommées : OBSERVÉ · INFÉRÉ PAR IA · CALCULÉ · PRIX SOURCÉ (+ hypothèse). */
18 +export function LayerBadge({ layer, fr }: { layer: Layer; fr: boolean }) {
19 + const map: Record<Layer, { fr: string; en: string; cls: string }> = {
20 + observed: { fr: "Observé", en: "Observed", cls: "border-ink bg-ink text-paper" },
21 + ai_inferred: { fr: "Inféré par IA", en: "AI-inferred", cls: "border-[var(--accent)] text-[var(--accent-deep)]" },
22 + computed: { fr: "Calculé", en: "Computed", cls: "border-[var(--line-strong)] text-ink" },
23 + sourced_price: { fr: "Prix sourcé", en: "Sourced price", cls: "border-[var(--green)] text-[var(--green-deep)]" },
24 + assumption: { fr: "Hypothèse", en: "Assumption", cls: "border-[var(--amber)] bg-[var(--amber-soft)] text-[#8a5a12]" },
25 + };
26 + const m = map[layer];
27 + return <span className={`vp-mono inline-block border px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.08em] ${m.cls}`}>{fr ? m.fr : m.en}</span>;
28 +}
29 +
30 +export function layerOf(source: string | null | undefined): Layer {
31 + switch (source) {
32 + case "MAMH": case "listing": case "cadastral": case "user": return "observed";
33 + case "AI": case "photo": case "ai_estimated": return "ai_inferred";
34 + case "derived": return "computed";
35 + case "assumed": case "unknown": return "assumption";
36 + default: return "computed";
37 + }
38 +}
39 +
40 +export function ConfBar({ value, fr }: { value: number; fr: boolean }) {
41 + const p = Math.round(value <= 1 ? value * 100 : value);
42 + const tone = p >= 80 ? "bg-ink" : p >= 60 ? "bg-[var(--accent)]" : p >= 40 ? "bg-[var(--amber)]" : "bg-[var(--danger)]";
43 + return (
44 + <span className="inline-flex items-center gap-1.5" title={fr ? `Confiance ${p} %` : `Confidence ${p}%`}>
45 + <span className="inline-block h-[5px] w-10 bg-[var(--line)]"><span className={`block h-full ${tone}`} style={{ width: `${p}%` }} /></span>
46 + <span className="vp-mono text-[10px] text-ink-3">{p} %</span>
47 + </span>
48 + );
49 +}
50 +
51 +export function Stat({ k, v, sub, big }: { k: string; v: string; sub?: string; big?: boolean }) {
52 + return (
53 + <div className="kv-cell">
54 + <p className="k">{k}</p>
55 + <p className={`v ${big ? "text-[22px]" : ""}`}>{v}</p>
56 + {sub ? <p className="vp-mono mt-0.5 text-[10px] uppercase tracking-[0.05em] text-ink-3">{sub}</p> : null}
57 + </div>
58 + );
59 +}
60 +
61 +/** Tableau RCN par catégorie → indirects → RCN → dépréciation → terrain → indication (§206). */
62 +export function CostTable({ e, fr }: { e: CostEstimate; fr: boolean }) {
63 + const lang = fr ? "fr" : "en";
64 + const row = (label: string, v: number | null, opts: { bold?: boolean; neg?: boolean; sep?: boolean; layer?: Layer } = {}) => (
65 + <tr key={label} className={`${opts.bold ? "font-bold" : ""} ${opts.sep ? "border-t-2 border-ink" : ""}`}>
66 + <td>{label}{opts.layer ? <span className="ml-2"><LayerBadge layer={opts.layer} fr={fr} /></span> : null}</td>
67 + <td className={`vp-mono text-right ${opts.neg ? "text-[var(--danger)]" : ""}`}>{v == null ? "—" : `${opts.neg && v > 0 ? "−" : ""}${money(Math.abs(v), lang)}`}</td>
68 + </tr>
69 + );
70 + return (
71 + <div className="src-wrap">
72 + <table className="src-table">
73 + <thead><tr><th>{fr ? "Coût de remplacement à neuf" : "Replacement cost new"}</th><th className="text-right">$</th></tr></thead>
74 + <tbody>
75 + {e.categories.map((c) => row(fr ? c.labelFr : c.labelEn, c.adjusted, { layer: "computed" }))}
76 + {row(fr ? "Coûts directs" : "Direct costs", e.directCost, { bold: true, sep: true })}
77 + {row(fr ? "Coûts indirects" : "Indirect costs", e.indirectCost, { layer: "assumption" })}
78 + {row(fr ? `Frais généraux (${num(e.input.params.overheadPct, lang, 1)} %)` : `Overhead (${num(e.input.params.overheadPct, lang, 1)}%)`, e.contractorOverhead, { layer: "assumption" })}
79 + {row(fr ? `Profit (${num(e.input.params.profitPct, lang, 1)} %)` : `Profit (${num(e.input.params.profitPct, lang, 1)}%)`, e.contractorProfit, { layer: "assumption" })}
80 + {row(fr ? `Contingence (${num(e.input.params.contingencyPct, lang, 1)} %)` : `Contingency (${num(e.input.params.contingencyPct, lang, 1)}%)`, e.contingency, { layer: "assumption" })}
81 + {row(fr ? "COÛT DE REMPLACEMENT À NEUF (RCN)" : "REPLACEMENT COST NEW (RCN)", e.replacementCostNew, { bold: true, sep: true })}
82 + {row(fr ? "Détérioration physique" : "Physical deterioration", e.depreciation.physical, { neg: true, layer: "computed" })}
83 + {row(fr ? "Désuétude fonctionnelle" : "Functional obsolescence", e.depreciation.functional, { neg: true })}
84 + {row(fr ? "Désuétude externe" : "External obsolescence", e.depreciation.external, { neg: true })}
85 + {row(fr ? "Valeur dépréciée du bâtiment" : "Depreciated building value", e.depreciation.depreciatedImprovementValue, { bold: true, sep: true })}
86 + {row(fr ? "Valeur du terrain" : "Land value", e.landValue, { layer: e.input.land.source === "role" ? "observed" : e.input.land.source === "none" ? "assumption" : "observed" })}
87 + {row(fr ? "INDICATION PAR LE COÛT" : "COST APPROACH INDICATION", e.costApproachValue, { bold: true, sep: true })}
88 + </tbody>
89 + </table>
90 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">
91 + {fr ? `Fourchette RCN : ${money(e.range.p10, lang)} – ${money(e.range.p90, lang)} (P10-P90) · ${num(e.perSqft, lang, 0)} $/pi² · prix du ${e.priceDate}` : `RCN range: ${money(e.range.p10, lang)} – ${money(e.range.p90, lang)} (P10-P90) · $${num(e.perSqft, lang, 0)}/sq ft · prices as of ${e.priceDate}`}
92 + {e.coverage.pricingCoverage < 1 ? ` · ${fr ? "couverture des coûts" : "cost coverage"} ${pct(e.coverage.pricingCoverage * 100, lang, 0, false)}` : ""}
93 + </p>
94 + </div>
95 + );
96 +}
97 +
98 +export function JsonViewer({ value, fr }: { value: unknown; fr: boolean }) {
99 + const [open, setOpen] = useState(false);
100 + const txt = JSON.stringify(value, null, 2);
101 + return (
102 + <div>
103 + <button type="button" className="btn btn-ghost" onClick={() => setOpen((o) => !o)}>{open ? (fr ? "Masquer le JSON" : "Hide JSON") : (fr ? "Voir les données techniques (JSON)" : "View technical data (JSON)")}</button>
104 + {open && <pre className="vp-mono mt-3 max-h-[520px] overflow-auto border border-[var(--line)] bg-surface-2 p-3 text-[10.5px] leading-snug">{txt}</pre>}
105 + </div>
106 + );
107 +}
added src/components/avendre/fmt.ts +79 −0
@@ -0,0 +1,79 @@
1 +// Vrai-Prix — aides de format pour l'onglet « À vendre » (client + serveur).
2 +export const SQFT_M2 = 0.092903;
3 +
4 +export const money = (v: number | null | undefined, lang = "fr") =>
5 + v == null || !Number.isFinite(v)
6 + ? "—"
7 + : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {
8 + style: "currency",
9 + currency: "CAD",
10 + maximumFractionDigits: 0,
11 + }).format(v);
12 +
13 +export const signedMoney = (v: number, lang = "fr") =>
14 + `${v >= 0 ? "+" : "−"}${money(Math.abs(Math.round(v)), lang)}`;
15 +
16 +export const num = (v: number | null | undefined, lang = "fr", maxFrac = 0) =>
17 + v == null || !Number.isFinite(v)
18 + ? "—"
19 + : v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: maxFrac });
20 +
21 +export const pct = (v: number | null | undefined, lang = "fr", frac = 0, signed = true) => {
22 + if (v == null || !Number.isFinite(v)) return "—";
23 + const s = Math.abs(v).toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: frac });
24 + return `${signed ? (v >= 0 ? "+" : "−") : ""}${s} %`;
25 +};
26 +
27 +export const dateFr = (iso: string | null | undefined, lang = "fr") => {
28 + if (!iso) return "—";
29 + const d = new Date(iso.length <= 10 ? `${iso}T12:00:00` : iso);
30 + if (Number.isNaN(d.getTime())) return iso;
31 + return d.toLocaleDateString(lang === "fr" ? "fr-CA" : "en-CA", { year: "numeric", month: "short", day: "numeric" });
32 +};
33 +
34 +export const epochDate = (ts: number | null | undefined, lang = "fr") =>
35 + ts ? dateFr(new Date(ts * 1000).toISOString(), lang) : "—";
36 +
37 +/** pi² → m² arrondi (superficie habitable plausible seulement). */
38 +export const sqftToM2 = (sqft: number | null | undefined): number | null =>
39 + sqft && sqft > 50 ? Math.round(sqft * SQFT_M2) : null;
40 +
41 +export const m2 = (v: number | null | undefined, lang = "fr") => (v == null ? "—" : `${num(v, lang)} m²`);
42 +
43 +/** Ratio estimation / prix demandé → libellé de lecture. */
44 +export function ratioLabel(ratio: number | null | undefined, fr: boolean): { text: string; tone: "up" | "down" | "flat" | "none" } {
45 + if (ratio == null) return { text: fr ? "Non évaluée" : "Not evaluated", tone: "none" };
46 + const d = (ratio - 1) * 100;
47 + if (Math.abs(d) < 5) return { text: fr ? "Prix aligné sur l'estimation" : "Price in line with estimate", tone: "flat" };
48 + if (d > 0) return { text: fr ? `Estimation ${pct(d, fr ? "fr" : "en")} vs prix` : `Estimate ${pct(d, "en")} vs price`, tone: "up" };
49 + return { text: fr ? `Estimation ${pct(d, "fr")} vs prix` : `Estimate ${pct(d, "en")} vs price`, tone: "down" };
50 +}
51 +
52 +export const median = (xs: number[]): number | null => {
53 + if (!xs.length) return null;
54 + const s = [...xs].sort((a, b) => a - b);
55 + const m = Math.floor(s.length / 2);
56 + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
57 +};
58 +
59 +export const mean = (xs: number[]): number | null => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
60 +
61 +export function weightedMean(values: number[], weights: number[]): number | null {
62 + const tw = weights.reduce((a, b) => a + b, 0);
63 + if (!tw) return null;
64 + return values.reduce((s, v, i) => s + v * weights[i], 0) / tw;
65 +}
66 +
67 +export function weightedMedian(values: number[], weights: number[]): number | null {
68 + const order = values.map((v, i) => ({ v, w: weights[i] })).sort((a, b) => a.v - b.v);
69 + const total = order.reduce((s, o) => s + o.w, 0);
70 + if (total <= 0) return null;
71 + let acc = 0;
72 + for (const o of order) {
73 + acc += o.w;
74 + if (acc >= total / 2) return o.v;
75 + }
76 + return order[order.length - 1]?.v ?? null;
77 +}
78 +
79 +export const round100 = (v: number) => Math.round(v / 100) * 100;
added src/components/cout/AdminConsole.tsx +134 −0
@@ -0,0 +1,134 @@
1 +// Vrai-Prix — méthode du coût : console d'administration (/cout/admin) — jeton côté client envoyé en
2 +// Authorization: Bearer aux routes /api/admin/cost/*. Connecteurs, anomalies, mappings IA, benchmarks, usage IA.
3 +"use client";
4 +import { useEffect, useState } from "react";
5 +import Link from "next/link";
6 +import { useLang } from "@/components/LangContext";
7 +import { fmtDate } from "./Provenance";
8 +
9 +type J = Record<string, unknown>;
10 +const KEY = "vrai-prix-cout-admin-token";
11 +
12 +export default function AdminConsole() {
13 + const { lang } = useLang();
14 + const fr = lang === "fr";
15 + const [token, setToken] = useState("");
16 + const [ready, setReady] = useState(false);
17 + const [tab, setTab] = useState<"connectors" | "anomalies" | "mappings" | "benchmarks" | "usage">("connectors");
18 + const [data, setData] = useState<J | null>(null);
19 + const [err, setErr] = useState<string | null>(null);
20 + const [busy, setBusy] = useState<string | null>(null);
21 + const [csv, setCsv] = useState("");
22 + const [log, setLog] = useState<string[]>([]);
23 +
24 + useEffect(() => { const id = setTimeout(() => { try { setToken(window.sessionStorage.getItem(KEY) ?? ""); } catch { /* ignore */ } setReady(true); }, 0); return () => clearTimeout(id); }, []);
25 +
26 + const call = async (path: string, init?: RequestInit): Promise<J> => {
27 + const r = await fetch(`/api/admin/cost/${path}`, { ...init, headers: { ...(init?.headers ?? {}), Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
28 + const d = (await r.json().catch(() => ({}))) as J;
29 + if (!r.ok) throw new Error(String(d.error ?? `${r.status} ${r.statusText}`));
30 + return d;
31 + };
32 + const load = async (t = tab) => {
33 + if (!token) return;
34 + setErr(null);
35 + try { setData(await call(t === "benchmarks" ? "benchmarks/import" : t)); try { window.sessionStorage.setItem(KEY, token); } catch { /* ignore */ } }
36 + catch (e) { setErr(e instanceof Error ? e.message : String(e)); setData(null); }
37 + };
38 + const act = async (label: string, path: string, body?: unknown) => {
39 + setBusy(label);
40 + try { const d = await call(path, { method: "POST", body: body ? JSON.stringify(body) : undefined }); setLog((l) => [`${new Date().toLocaleTimeString()} ${label} → ${JSON.stringify(d).slice(0, 300)}`, ...l].slice(0, 30)); await load(); }
41 + catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
42 + finally { setBusy(null); }
43 + };
44 + const rows = (k: string): J[] => (Array.isArray(data?.[k]) ? (data![k] as J[]) : []);
45 + const s = (v: unknown) => (v == null ? "—" : String(v));
46 +
47 + return (
48 + <div className="space-y-8 py-8">
49 + <section className="rise">
50 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">
51 + <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3"><Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai&nbsp;Prix</Link><span aria-hidden="true">/</span><Link href="/cout" className="text-ink-2 hover:text-accent-deep">{fr ? "Coût" : "Cost"}</Link><span aria-hidden="true">/</span><span>Admin</span></nav>
52 + <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{fr ? "accès protégé — jeton serveur" : "protected — server token"}</span>
53 + </div>
54 + <span className="kicker mt-6 inline-flex">{fr ? "Administration" : "Administration"}</span>
55 + <h1 className="vp-display mt-2 text-[clamp(24px,3.6vw,38px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{fr ? "Base de coûts — connecteurs, anomalies, mappings" : "Cost database — connectors, anomalies, mappings"}</h1>
56 + <div className="mt-5 flex flex-wrap items-end gap-3">
57 + <label className="block flex-1 min-w-[240px] max-w-md"><span className="klabel">{fr ? "Jeton d'administration (COST_ADMIN_TOKEN)" : "Admin token (COST_ADMIN_TOKEN)"}</span><input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="vp-input vp-mono text-[14px]" autoComplete="off" /></label>
58 + <button type="button" className="btn btn-primary" onClick={() => load()} disabled={!ready || !token}>{fr ? "Connecter" : "Connect"}</button>
59 + </div>
60 + {err && <p className="mt-3 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-[var(--danger)]">{err}</p>}
61 + </section>
62 +
63 + {data && (
64 + <>
65 + <div className="flex flex-wrap gap-1 border-b border-[var(--line)]">
66 + {(["connectors", "anomalies", "mappings", "benchmarks", "usage"] as const).map((t) => (<button key={t} type="button" aria-pressed={tab === t} onClick={() => { setTab(t); void load(t); }} className="vp-tab">{{ connectors: fr ? "Connecteurs" : "Connectors", anomalies: fr ? "Anomalies" : "Anomalies", mappings: fr ? "Mappings IA" : "AI mappings", benchmarks: fr ? "Benchmarks" : "Benchmarks", usage: fr ? "Usage IA" : "AI usage" }[t]}</button>))}
67 + </div>
68 +
69 + {tab === "connectors" && (
70 + <section className="space-y-6">
71 + <div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Source" : "Source"}</th><th>{fr ? "État" : "Status"}</th><th>{fr ? "Licence" : "Licence"}</th><th>{fr ? "Dernier crawl" : "Last crawl"}</th><th className="text-right">Obs.</th><th className="text-right">{fr ? "Erreurs" : "Errors"}</th><th /></tr></thead><tbody>
72 + {rows("connectors").map((c, i) => (
73 + <tr key={i}><td className="font-semibold">{s(c.name)}<br /><span className="vp-mono text-[10px] text-ink-3">{s(c.key)} · {s(c.frequency)}</span></td>
74 + <td><span className={`pill ${c.status === "healthy" ? "pill-conf-A" : c.status === "warning" ? "pill-conf-C" : c.status === "error" ? "pill-conf-D" : "pill-conf-B"}`}>{s(c.status)}</span></td>
75 + <td className="vp-mono text-[11px]">{s(c.license)}{c.active === 0 || c.active === false ? ` · ${fr ? "inactif" : "inactive"}` : ""}</td>
76 + <td className="vp-mono text-[11px]">{c.lastSync ? fmtDate(String(c.lastSync), fr) : "—"}{c.lastError ? <><br /><span className="text-[var(--danger)]">{String(c.lastError).slice(0, 80)}</span></> : null}</td>
77 + <td className="vp-mono text-right">{s(c.observations)}</td>
78 + <td className="vp-mono text-right">{s(c.errors ?? 0)}</td>
79 + <td><button type="button" disabled={busy != null} onClick={() => act(`sync ${s(c.key)}`, `sync/${encodeURIComponent(s(c.key))}`)} className="btn btn-ghost !min-h-0 !px-2.5 !py-1 text-[11px]">{busy === `sync ${s(c.key)}` ? "…" : (fr ? "Synchroniser" : "Sync")}</button></td></tr>
80 + ))}
81 + </tbody></table></div>
82 + {rows("runs").length > 0 && <div><p className="klabel mb-2">{fr ? "Dernières exécutions" : "Recent runs"}</p><div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Connecteur" : "Connector"}</th><th>{fr ? "Début" : "Start"}</th><th>{fr ? "État" : "Status"}</th><th className="text-right">Pages</th><th className="text-right">Obs.</th><th className="text-right">{fr ? "Acceptées" : "Accepted"}</th><th className="text-right">{fr ? "Rejetées" : "Rejected"}</th><th className="text-right">{fr ? "Inchangées" : "Unchanged"}</th><th className="text-right">{fr ? "Erreurs" : "Errors"}</th></tr></thead><tbody>
83 + {rows("runs").map((r, i) => (<tr key={i}><td className="font-semibold">{s(r.connector)}</td><td className="vp-mono text-[11px]">{s(r.started_at ?? r.startedAt)}</td><td className="vp-mono text-[11px]">{s(r.status)}</td><td className="vp-mono text-right">{s(r.pages)}</td><td className="vp-mono text-right">{s(r.observations)}</td><td className="vp-mono text-right">{s(r.accepted)}</td><td className="vp-mono text-right">{s(r.rejected)}</td><td className="vp-mono text-right">{s(r.unchanged)}</td><td className="vp-mono text-right">{s(r.errors)}</td></tr>))}
84 + </tbody></table></div></div>}
85 + </section>
86 + )}
87 +
88 + {tab === "anomalies" && (
89 + <section><div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Article" : "Item"}</th><th>{fr ? "Source" : "Source"}</th><th className="text-right">{fr ? "Prix" : "Price"}</th><th>{fr ? "Date" : "Date"}</th><th>{fr ? "Motif" : "Reason"}</th><th>URL</th></tr></thead><tbody>
90 + {rows("anomalies").map((a, i) => (<tr key={i}><td className="font-semibold">{s(a.item ?? a.itemCode ?? a.canonical_code)}</td><td>{s(a.source)}</td><td className="vp-mono text-right">{s(a.price ?? a.total_cost)}</td><td className="vp-mono text-[11px]">{s(a.date ?? a.observation_date)}</td><td className="text-[12px]">{s(a.reason ?? a.outlier_reason ?? a.reject_reason)}</td><td className="text-[11px]">{a.url ? <a href={String(a.url)} target="_blank" rel="noopener noreferrer nofollow" className="text-accent">↗</a> : "—"}</td></tr>))}
91 + {!rows("anomalies").length && <tr><td colSpan={6} className="text-ink-2">{fr ? "Aucune anomalie." : "No anomaly."}</td></tr>}
92 + </tbody></table></div></section>
93 + )}
94 +
95 + {tab === "mappings" && (
96 + <section><div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Produit" : "Product"}</th><th>{fr ? "Article proposé" : "Proposed item"}</th><th>{fr ? "Méthode" : "Method"}</th><th className="text-right">{fr ? "Confiance" : "Confidence"}</th><th>{fr ? "État" : "Status"}</th><th /></tr></thead><tbody>
97 + {rows("mappings").map((m, i) => (<tr key={i}><td><a href={String(m.product_url ?? m.url ?? "#")} target="_blank" rel="noopener noreferrer nofollow" className="font-semibold hover:text-accent-deep">{s(m.product_title ?? m.title)} ↗</a><br /><span className="vp-mono text-[10px] text-ink-3">{s(m.source)} · {fr ? "emballage" : "pack"} {s(m.pack_qty ?? m.packQty)} {s(m.pack_unit ?? m.packUnit)}</span></td><td className="vp-mono text-[12px]">{s(m.proposed_item_code ?? m.itemCode)}</td><td className="text-[12px]">{s(m.method)}<br /><span className="text-[10.5px] text-ink-3">{String(m.rationale ?? "").slice(0, 120)}</span></td><td className="vp-mono text-right">{Math.round(Number(m.confidence ?? 0) * (Number(m.confidence) <= 1 ? 100 : 1))} %</td><td className="vp-mono text-[11px]">{s(m.status)}</td>
98 + <td className="whitespace-nowrap"><button type="button" disabled={busy != null} onClick={() => act(`approve ${s(m.id)}`, `mappings/${s(m.id)}/approve`)} className="btn btn-primary !min-h-0 !px-2.5 !py-1 text-[11px]">{fr ? "Approuver" : "Approve"}</button> <button type="button" disabled={busy != null} onClick={() => act(`reject ${s(m.id)}`, `mappings/${s(m.id)}/reject`)} className="btn btn-ghost !min-h-0 !px-2.5 !py-1 text-[11px]">{fr ? "Rejeter" : "Reject"}</button></td></tr>))}
99 + {!rows("mappings").length && <tr><td colSpan={6} className="text-ink-2">{fr ? "Aucun mapping en attente." : "No pending mapping."}</td></tr>}
100 + </tbody></table></div></section>
101 + )}
102 +
103 + {tab === "benchmarks" && (
104 + <section className="grid gap-x-12 gap-y-6 lg:grid-cols-2">
105 + <div>
106 + <p className="klabel">{fr ? "Import CSV (téléchargement légal requis — ex. Altus Canadian Cost Guide)" : "CSV import (legal download required — e.g. Altus Canadian Cost Guide)"}</p>
107 + <p className="mt-1 text-[12.5px] text-ink-2">source,building_type,market,unit,low,high,year,notes</p>
108 + <textarea value={csv} onChange={(e) => setCsv(e.target.value)} rows={8} className="vp-input vp-mono mt-2 w-full text-[12px]" placeholder={"Altus Group — Canadian Cost Guide,single_family,Montréal,$/pi2,260,395,2026,Custom-built"} />
109 + <button type="button" disabled={busy != null || !csv.trim()} onClick={() => act("import benchmarks", "benchmarks/import", { csv })} className="btn btn-primary mt-2">{fr ? "Importer" : "Import"}</button>
110 + </div>
111 + <div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Source" : "Source"}</th><th>{fr ? "Type" : "Type"}</th><th>{fr ? "Marché" : "Market"}</th><th className="text-right">{fr ? "Plage" : "Range"}</th><th>{fr ? "Année" : "Year"}</th></tr></thead><tbody>
112 + {rows("benchmarks").map((b, i) => (<tr key={i}><td className="font-semibold">{s(b.source)}</td><td>{s(b.building_type ?? b.buildingType)}</td><td>{s(b.market)}</td><td className="vp-mono text-right">{s(b.low)}–{s(b.high)} {s(b.unit)}</td><td className="vp-mono">{s(b.year)}</td></tr>))}
113 + {!rows("benchmarks").length && <tr><td colSpan={5} className="text-ink-2">{fr ? "Aucun benchmark importé." : "No benchmark imported."}</td></tr>}
114 + </tbody></table></div>
115 + </section>
116 + )}
117 +
118 + {tab === "usage" && (
119 + <section>
120 + <div className="grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-4">
121 + {[["today", fr ? "Coût IA aujourd'hui" : "AI cost today"], ["month", fr ? "Coût IA ce mois" : "AI cost this month"], ["average", fr ? "Coût moyen / analyse" : "Average cost / analysis"], ["analyses", fr ? "Analyses" : "Analyses"]].map(([k, l]) => (<div key={k} className="kv-cell"><p className="k">{l}</p><p className="v">{data[k] != null ? (typeof data[k] === "number" && k !== "analyses" ? `${Number(data[k]).toFixed(2)} $ US` : s(data[k])) : "—"}</p></div>))}
122 + </div>
123 + <div className="src-wrap mt-4"><table className="src-table"><thead><tr><th>{fr ? "Date" : "Date"}</th><th>{fr ? "Usage" : "Purpose"}</th><th>{fr ? "Modèle" : "Model"}</th><th className="text-right">Images</th><th className="text-right">In</th><th className="text-right">Out</th><th className="text-right">$ US</th><th className="text-right">ms</th></tr></thead><tbody>
124 + {rows("usage").map((u, i) => (<tr key={i}><td className="vp-mono text-[11px]">{s(u.created_at ?? u.createdAt)}</td><td>{s(u.purpose)}</td><td className="vp-mono text-[11px]">{s(u.model)}</td><td className="vp-mono text-right">{s(u.input_images)}</td><td className="vp-mono text-right">{s(u.input_tokens)}</td><td className="vp-mono text-right">{s(u.output_tokens)}</td><td className="vp-mono text-right">{u.estimated_cost_usd != null ? Number(u.estimated_cost_usd).toFixed(4) : "—"}</td><td className="vp-mono text-right">{s(u.latency_ms)}</td></tr>))}
125 + </tbody></table></div>
126 + </section>
127 + )}
128 +
129 + {log.length > 0 && <pre className="vp-mono max-h-[200px] overflow-auto border border-[var(--line)] bg-surface p-3 text-[10.5px] leading-snug">{log.join("\n")}</pre>}
130 + </>
131 + )}
132 + </div>
133 + );
134 +}
added src/components/cout/AssemblyCatalog.tsx +102 −0
@@ -0,0 +1,102 @@
1 +// Vrai-Prix — méthode du coût : catalogue des assemblages (/cout/assemblage) avec filtre par catégorie / recherche
2 +// et, si ?item=, la fiche d'un article (prix canonique, historique, assemblages qui l'utilisent).
3 +"use client";
4 +import { useState } from "react";
5 +import Link from "next/link";
6 +import { useLang } from "@/components/LangContext";
7 +import type { CostItem, ItemPrice, UxCategory } from "@/lib/cost/types";
8 +import { UX_CATEGORIES, categoryLabel } from "@/lib/cost/taxonomy";
9 +import { UNIT_LABELS } from "@/lib/cost/units";
10 +import { num } from "@/components/avendre/fmt";
11 +import { KindIcon, KindLegend, fmtDate } from "./Provenance";
12 +import { PriceSourceCard } from "./SourceDrawer";
13 +import { Sparkline } from "./Charts";
14 +
15 +export interface CatalogRow { code: string; nameFr: string; nameEn: string; unit: string; category: UxCategory; quality: string | null; economicLife: number | null; material: number; labour: number; equipment: number; direct: number; observedShare: number; freshestObservation: string | null; components: number }
16 +export interface ItemCard { item: CostItem; price: ItemPrice | null; history: { date: string; price: number; source: string; url: string | null; regular: boolean; kind: string; outlier: boolean }[]; usedBy: { code: string; nameFr: string; nameEn: string; unit: string; quantity: number }[] }
17 +
18 +export default function AssemblyCatalog({ rows, asOf, item }: { rows: CatalogRow[]; asOf: string; item: ItemCard | null }) {
19 + const { lang } = useLang();
20 + const fr = lang === "fr";
21 + const [q, setQ] = useState("");
22 + const [cat, setCat] = useState<UxCategory | "">("");
23 + const unitL = (x: string) => UNIT_LABELS[x as keyof typeof UNIT_LABELS]?.[fr ? "fr" : "en"] ?? x;
24 + const fold = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
25 + const list = rows.filter((r) => (!cat || r.category === cat) && (!q || fold(fr ? r.nameFr : r.nameEn).includes(fold(q)) || r.code.toLowerCase().includes(q.toLowerCase())));
26 + const cats = UX_CATEGORIES.filter((c) => rows.some((r) => r.category === c.key));
27 + const hist = item ? (() => { const byDate = new Map<string, number[]>(); for (const h of item.history) if (!h.outlier && h.kind !== "reference") byDate.set(h.date, [...(byDate.get(h.date) ?? []), h.price]); return [...byDate.entries()].map(([date, ps]) => ({ date, price: ps.sort((a, b) => a - b)[Math.floor(ps.length / 2)] })).sort((a, b) => a.date.localeCompare(b.date)); })() : [];
28 + return (
29 + <div className="space-y-12 py-8">
30 + <section className="rise">
31 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">
32 + <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
33 + <Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai&nbsp;Prix</Link><span aria-hidden="true">/</span>
34 + <Link href="/cout" className="text-ink-2 hover:text-accent-deep">{fr ? "Coût" : "Cost"}</Link><span aria-hidden="true">/</span>
35 + <span>{fr ? "Assemblages" : "Assemblies"}</span>
36 + </nav>
37 + <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{rows.length} {fr ? "assemblages · prix au" : "assemblies · prices as of"} {asOf} · Montréal</span>
38 + </div>
39 + <span className="kicker mt-6 inline-flex">{fr ? "Catalogue" : "Catalogue"}</span>
40 + <h1 className="vp-display mt-2 text-[clamp(26px,4vw,42px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{fr ? "Les assemblages de construction" : "Construction assemblies"}</h1>
41 + <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-ink-2">{fr ? "Chaque assemblage décompose une composante réelle (mur, toiture, fenêtre, cuisine…) en articles chiffrés, heures de métier et équipement. Le coût direct par unité est recalculé à chaque synchronisation des sources." : "Each assembly breaks a real component (wall, roof, window, kitchen…) down into priced items, trade hours and equipment. The direct cost per unit is recomputed at every source synchronisation."}</p>
42 + </section>
43 +
44 + {item && (
45 + <section className="grid gap-x-12 gap-y-6 border border-[var(--line-strong)] bg-surface p-5 lg:grid-cols-[1.2fr_1fr]">
46 + <div>
47 + <p className="kicker">{fr ? "Article" : "Item"} · {item.item.code}</p>
48 + <h2 className="vp-display mt-2 text-[22px] font-bold leading-tight">{fr ? item.item.nameFr : item.item.nameEn}</h2>
49 + <p className="vp-mono mt-1 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">MasterFormat {item.item.masterformat} · {item.item.division} · {categoryLabel(item.item.category, fr)} · {fr ? "pertes par défaut" : "default waste"} {Math.round(item.item.defaultWastePct * 100)} %</p>
50 + <div className="mt-4"><PriceSourceCard fr={fr} itemName={fr ? item.item.nameFr : item.item.nameEn} unit={item.item.unit} price={item.price} /></div>
51 + </div>
52 + <div>
53 + <p className="klabel">{fr ? "Historique (médiane par date, aberrations exclues)" : "History (median per date, outliers excluded)"}</p>
54 + <div className="mt-2 max-w-[360px]"><Sparkline points={hist} lang={lang} /></div>
55 + {item.history.length > 0 && (
56 + <div className="src-wrap mt-3 max-h-[240px] overflow-y-auto"><table className="src-table"><thead><tr><th>{fr ? "Date" : "Date"}</th><th>{fr ? "Source" : "Source"}</th><th className="text-right">{fr ? "Prix" : "Price"}</th><th /></tr></thead><tbody>
57 + {[...item.history].reverse().slice(0, 40).map((h, i) => (<tr key={i} className={h.outlier ? "opacity-50" : ""}><td className="vp-mono text-[12px]">{h.date}</td><td className="text-[12.5px]">{h.url ? <a href={h.url} target="_blank" rel="noopener noreferrer nofollow" className="hover:text-accent-deep">{h.source} ↗</a> : h.source}</td><td className="vp-mono text-right text-[12px]">{num(h.price, lang, 2)} $</td><td className="vp-mono text-[9.5px] uppercase text-ink-3">{h.outlier ? (fr ? "aberrant" : "outlier") : !h.regular ? (fr ? "promo" : "sale") : ""}</td></tr>))}
58 + </tbody></table></div>
59 + )}
60 + <p className="klabel mt-4">{fr ? "Utilisé par" : "Used by"}</p>
61 + <ul className="mt-1 flex flex-wrap gap-2">
62 + {item.usedBy.map((u) => (<li key={u.code}><Link href={`/cout/assemblage/${encodeURIComponent(u.code)}`} className="vp-mono inline-block border border-[var(--line)] px-2 py-1 text-[10.5px] uppercase tracking-[0.05em] hover:border-ink">{fr ? u.nameFr : u.nameEn} · {num(u.quantity, lang, 3)} / {fr ? "unité" : "unit"}</Link></li>))}
63 + {!item.usedBy.length && <li className="text-[12.5px] text-ink-3">—</li>}
64 + </ul>
65 + </div>
66 + </section>
67 + )}
68 +
69 + <section>
70 + <div className="flex flex-wrap items-end gap-4">
71 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder={fr ? "Rechercher un assemblage…" : "Search an assembly…"} className="vp-input max-w-sm text-base" aria-label={fr ? "Rechercher" : "Search"} />
72 + <div className="flex flex-wrap gap-1">
73 + <button type="button" aria-pressed={cat === ""} onClick={() => setCat("")} className="vp-tab">{fr ? "Toutes" : "All"}</button>
74 + {cats.map((c) => (<button key={c.key} type="button" aria-pressed={cat === c.key} onClick={() => setCat(c.key)} className="vp-tab">{fr ? c.fr : c.en}</button>))}
75 + </div>
76 + </div>
77 + <div className="mt-3"><KindLegend fr={fr} /></div>
78 + <div className="src-wrap mt-4">
79 + <table className="src-table">
80 + <thead><tr><th>{fr ? "Assemblage" : "Assembly"}</th><th>{fr ? "Catégorie" : "Category"}</th><th className="text-right">{fr ? "Matériaux" : "Materials"}</th><th className="text-right">{fr ? "Main-d'œuvre" : "Labour"}</th><th className="text-right">{fr ? "Équip." : "Equip."}</th><th className="text-right">{fr ? "Direct / unité" : "Direct / unit"}</th><th className="text-right">{fr ? "Observé" : "Observed"}</th><th>{fr ? "Vie" : "Life"}</th><th>{fr ? "Mise à jour" : "Updated"}</th></tr></thead>
81 + <tbody>
82 + {list.map((r) => (
83 + <tr key={r.code}>
84 + <td><Link href={`/cout/assemblage/${encodeURIComponent(r.code)}`} className="font-semibold hover:text-accent-deep">{fr ? r.nameFr : r.nameEn}</Link><br /><span className="vp-mono text-[10px] uppercase text-ink-3">{r.code} · {r.components} {fr ? "composants" : "components"}</span></td>
85 + <td className="text-[12.5px]">{categoryLabel(r.category, fr)}</td>
86 + <td className="vp-mono text-right text-[12px]">{num(r.material, lang, 2)}</td>
87 + <td className="vp-mono text-right text-[12px]">{num(r.labour, lang, 2)}</td>
88 + <td className="vp-mono text-right text-[12px]">{num(r.equipment, lang, 2)}</td>
89 + <td className="vp-mono text-right font-bold">{num(r.direct, lang, 2)} $ <span className="text-[10px] font-normal text-ink-3">/ {unitL(r.unit)}</span></td>
90 + <td className="vp-mono text-right text-[12px]"><KindIcon kind={r.observedShare >= 0.5 ? "observed" : r.observedShare > 0 ? "derived" : "reference"} fr={fr} /> {Math.round(r.observedShare * 100)} %</td>
91 + <td className="vp-mono text-[12px]">{r.economicLife ?? "—"}</td>
92 + <td className="vp-mono text-[11px]">{r.freshestObservation ? fmtDate(r.freshestObservation, fr) : (fr ? "référence" : "reference")}</td>
93 + </tr>
94 + ))}
95 + {!list.length && <tr><td colSpan={9} className="text-ink-2">{fr ? "Aucun assemblage." : "No assembly."}</td></tr>}
96 + </tbody>
97 + </table>
98 + </div>
99 + </section>
100 + </div>
101 + );
102 +}
added src/components/cout/AssemblyDetail.tsx +160 −0
@@ -0,0 +1,160 @@
1 +// Vrai-Prix — méthode du coût : fiche d'un assemblage (/cout/assemblage/[code]) — description, schéma, composition,
2 +// prix + provenance, main-d'œuvre, sources, historique 8 trimestres, variations régionales, voisins.
3 +"use client";
4 +import { useState } from "react";
5 +import Link from "next/link";
6 +import { useLang } from "@/components/LangContext";
7 +import type { Assembly, AssemblyUnitCost, CostLocation } from "@/lib/cost/types";
8 +import { categoryLabel, MASTERFORMAT, QUALITIES, tradeLabel } from "@/lib/cost/taxonomy";
9 +import { UNIT_LABELS } from "@/lib/cost/units";
10 +import { num } from "@/components/avendre/fmt";
11 +import { AssemblySketch, StackBar, UnitCostHistory } from "./Charts";
12 +import { fmtDate, KindIcon, KindLegend, kindLabel, LayerBadge } from "./Provenance";
13 +import { Drawer, PriceSourceCard } from "./SourceDrawer";
14 +
15 +export interface AssemblyDetailData {
16 + asOf: string;
17 + location: CostLocation;
18 + assembly: Assembly;
19 + unit: AssemblyUnitCost;
20 + regional: { code: string; nameFr: string; nameEn: string; direct: number; materialFactor: number; labourFactor: number; equipmentFactor: number }[];
21 + history: { date: string; direct: number; material: number; labour: number; equipment: number; kind: string }[];
22 + neighbours: { code: string; nameFr: string; nameEn: string; unit: string; direct: number }[];
23 +}
24 +
25 +export default function AssemblyDetail({ data }: { data: AssemblyDetailData }) {
26 + const { lang } = useLang();
27 + const fr = lang === "fr";
28 + const a = data.assembly;
29 + const u = data.unit;
30 + const unitL = (x: string) => UNIT_LABELS[x as keyof typeof UNIT_LABELS]?.[fr ? "fr" : "en"] ?? x;
31 + const [src, setSrc] = useState<AssemblyUnitCost["components"][number] | null>(null);
32 + const maxRegional = Math.max(...data.regional.map((r) => r.direct), 0.01);
33 + const observedShare = Math.round(u.observedShare * 100);
34 + return (
35 + <div className="space-y-12 py-8">
36 + <section className="rise">
37 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">
38 + <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
39 + <Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai&nbsp;Prix</Link><span aria-hidden="true">/</span>
40 + <Link href="/cout" className="text-ink-2 hover:text-accent-deep">{fr ? "Coût" : "Cost"}</Link><span aria-hidden="true">/</span>
41 + <Link href="/cout/assemblage" className="text-ink-2 hover:text-accent-deep">{fr ? "Assemblages" : "Assemblies"}</Link><span aria-hidden="true">/</span>
42 + <span>{a.code}</span>
43 + </nav>
44 + <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{fr ? "prix au" : "prices as of"} {data.asOf} · {fr ? data.location.nameFr : data.location.nameEn} · v{a.version}</span>
45 + </div>
46 + <div className="mt-6 grid gap-x-12 gap-y-6 lg:grid-cols-[1.5fr_1fr]">
47 + <div>
48 + <span className="kicker">{categoryLabel(a.category, fr)} · MasterFormat {a.masterformat} — {MASTERFORMAT[a.masterformat]?.[fr ? "fr" : "en"] ?? ""}</span>
49 + <h1 className="vp-display mt-2 text-[clamp(24px,3.8vw,40px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{fr ? a.nameFr : a.nameEn}</h1>
50 + <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-ink-2">{fr ? a.descriptionFr : a.descriptionEn}</p>
51 + <div className="mt-4 flex flex-wrap gap-x-6 gap-y-2">
52 + <div className="kv-cell"><p className="k">{fr ? "Unité" : "Unit"}</p><p className="v">{unitL(a.unit)}</p></div>
53 + <div className="kv-cell"><p className="k">{fr ? "Qualité" : "Quality"}</p><p className="v">{a.quality ? (QUALITIES.find((q) => q.key === a.quality)?.[fr ? "fr" : "en"] ?? a.quality) : (fr ? "toutes" : "all")}</p></div>
54 + <div className="kv-cell"><p className="k">{fr ? "Vie économique" : "Economic life"}</p><p className="v">{a.economicLife ?? "—"} {fr ? "ans" : "yrs"}</p></div>
55 + <div className="kv-cell"><p className="k">{fr ? "Groupe de condition" : "Condition group"}</p><p className="v">{a.conditionGroup ?? "—"}</p></div>
56 + <div className="kv-cell"><p className="k">{fr ? "Mise à jour des prix" : "Price update"}</p><p className="v">{u.freshestObservation ? fmtDate(u.freshestObservation, fr) : (fr ? "référence seulement" : "reference only")}</p></div>
57 + </div>
58 + </div>
59 + <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">
60 + <p className="klabel">{fr ? "Coût direct par unité (Montréal, avant facteur régional)" : "Direct cost per unit (Montréal, before regional factor)"}</p>
61 + <p className="vp-display mt-1 text-[clamp(30px,4vw,44px)] font-bold tracking-[-0.03em]">{num(u.direct, lang, 2)} $ <span className="vp-mono text-[11px] font-medium text-ink-3">/ {unitL(a.unit)}</span></p>
62 + <div className="mt-3"><StackBar lang={lang} parts={[{ label: fr ? "Matériaux" : "Materials", value: u.material }, { label: fr ? "Main-d'œuvre" : "Labour", value: u.labour }, { label: fr ? "Équipement" : "Equipment", value: u.equipment }]} /></div>
63 + <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">{fr ? "part observée des matériaux" : "observed share of materials"} : <b className="text-ink">{observedShare} %</b> · σ ≈ {Math.round(u.sigmaPct * 100)} %</p>
64 + <div className="mt-4"><AssemblySketch category={a.category} /></div>
65 + <p className="vp-mono mt-1 text-[9.5px] uppercase tracking-[0.05em] text-ink-3">{fr ? "schéma de principe (coupe générique)" : "schematic (generic section)"}</p>
66 + </aside>
67 + </div>
68 + </section>
69 +
70 + <section>
71 + <div className="sec"><span className="sec-num">01</span> <span className="kicker">{fr ? "Composition" : "Composition"}</span>
72 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{fr ? "Composants, quantités, prix et main-d'œuvre" : "Components, quantities, prices and labour"}</h2>
73 + <p className="mt-2 max-w-3xl text-[14px] text-ink-2">{fr ? "Par unité d'assemblage : matériau = quantité × (1 + pertes) × prix canonique ; main-d'œuvre = heures × coût employeur du métier ; équipement = forfait par unité. Cliquez une ligne pour la fiche source." : "Per assembly unit: material = quantity × (1 + waste) × canonical price; labour = hours × trade employer cost; equipment = lump per unit. Click a row for the source card."}</p>
74 + </div>
75 + <div className="src-wrap mt-5">
76 + <table className="src-table">
77 + <thead><tr><th>{fr ? "Composant" : "Component"}</th><th className="text-right">{fr ? "Qté / unité" : "Qty / unit"}</th><th className="text-right">{fr ? "Pertes" : "Waste"}</th><th className="text-right">{fr ? "Prix unitaire" : "Unit price"}</th><th>{fr ? "Provenance" : "Provenance"}</th><th className="text-right">{fr ? "Matériau" : "Material"}</th><th className="text-right">{fr ? "Heures" : "Hours"}</th><th className="text-right">{fr ? "Taux" : "Rate"}</th><th className="text-right">MO</th><th className="text-right">É</th></tr></thead>
78 + <tbody>
79 + {u.components.map((c, i) => (
80 + <tr key={i} className="cursor-pointer" onClick={() => setSrc(c)}>
81 + <td><span className="font-semibold">{fr ? c.nameFr : c.nameEn}</span>{c.itemCode && <><br /><span className="vp-mono text-[10px] text-ink-3">{c.itemCode}</span></>}{c.trade && c.labourHours > 0 && <><br /><span className="vp-mono text-[10px] text-ink-3">{tradeLabel(c.trade, fr)}</span></>}</td>
82 + <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${num(c.quantity, lang, 4)} ${unitL(c.unit)}` : "—"}</td>
83 + <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${Math.round(c.wasteFactor * 100)} %` : "—"}</td>
84 + <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${num(c.unitPrice, lang, 2)} $` : "—"}</td>
85 + <td className="text-[12px]">{c.itemCode ? <span className="inline-flex items-center gap-1.5"><KindIcon kind={c.provenance.kind} fr={fr} source={c.provenance.source} observedAt={c.provenance.observedAt} note={c.provenance.note} /> {kindLabel(c.provenance.kind, fr)}{c.provenance.observedAt ? ` · ${c.provenance.observedAt}` : ""}</span> : <LayerBadge layer="assumption" fr={fr} />}</td>
86 + <td className="vp-mono text-right text-[12px]">{c.materialCost ? `${num(c.materialCost, lang, 2)} $` : "—"}</td>
87 + <td className="vp-mono text-right text-[12px]">{c.labourHours ? num(c.labourHours, lang, 3) : "—"}</td>
88 + <td className="vp-mono text-right text-[12px]">{c.hourlyRate != null ? <span className="inline-flex items-center gap-1">{num(c.hourlyRate, lang, 2)} $/h <KindIcon kind={c.rateProvenance?.kind ?? "assumption"} fr={fr} source={c.rateProvenance?.source} observedAt={c.rateProvenance?.effectiveDate} note={c.rateProvenance?.note} /></span> : "—"}</td>
89 + <td className="vp-mono text-right text-[12px]">{c.labourCost ? `${num(c.labourCost, lang, 2)} $` : "—"}</td>
90 + <td className="vp-mono text-right text-[12px]">{c.equipmentCost ? `${num(c.equipmentCost, lang, 2)} $` : "—"}</td>
91 + </tr>
92 + ))}
93 + <tr className="bg-surface-2 font-bold"><td>{fr ? "Total par unité" : "Total per unit"}</td><td colSpan={4} /><td className="vp-mono text-right">{num(u.material, lang, 2)} $</td><td colSpan={2} className="vp-mono text-right">{num(u.components.reduce((s, c) => s + c.labourHours, 0), lang, 3)} h</td><td className="vp-mono text-right">{num(u.labour, lang, 2)} $</td><td className="vp-mono text-right">{num(u.equipment, lang, 2)} $</td></tr>
94 + </tbody>
95 + </table>
96 + </div>
97 + <div className="mt-3"><KindLegend fr={fr} /></div>
98 + </section>
99 +
100 + <section className="grid gap-x-12 gap-y-10 lg:grid-cols-2">
101 + <div>
102 + <div className="sec"><span className="sec-num">02</span> <span className="kicker">{fr ? "Historique" : "History"}</span>
103 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{fr ? "Coût unitaire — 8 trimestres" : "Unit cost — 8 quarters"}</h2>
104 + <p className="mt-2 text-[13.5px] text-ink-2">{fr ? "Recalculé avec les prix disponibles à chaque date (observé ●, indexé ○ ou référence ◇). Une base de référence stable donne une ligne plate : ce n'est pas une observation." : "Recomputed with the prices available at each date (observed ●, indexed ○ or reference ◇). A stable reference base gives a flat line: it is not an observation."}</p>
105 + </div>
106 + <div className="mt-4"><UnitCostHistory rows={data.history} lang={lang} unit={unitL(a.unit)} /></div>
107 + </div>
108 + <div>
109 + <div className="sec"><span className="sec-num">03</span> <span className="kicker">{fr ? "Régions" : "Regions"}</span>
110 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{fr ? "Variations régionales" : "Regional variations"}</h2>
111 + <p className="mt-2 text-[13.5px] text-ink-2">{fr ? "Direct × facteurs matériaux / main-d'œuvre / équipement de chaque région (conventions CCQ uniformes → main-d'œuvre 1,00 hors régions éloignées)." : "Direct × material / labour / equipment factors of each region (uniform CCQ agreements → labour 1.00 outside remote regions)."}</p>
112 + </div>
113 + <div className="mt-4 space-y-1.5">
114 + {data.regional.map((r) => (
115 + <div key={r.code} className="grid grid-cols-[150px_1fr_90px] items-center gap-2 text-[12px]">
116 + <span className="truncate" title={fr ? r.nameFr : r.nameEn}>{fr ? r.nameFr : r.nameEn}</span>
117 + <span className="h-[8px] bg-[var(--line)]"><span className={`block h-full ${r.code === data.location.code ? "bg-accent" : "bg-ink"}`} style={{ width: `${(r.direct / maxRegional) * 100}%` }} /></span>
118 + <span className="vp-mono text-right">{num(r.direct, lang, 2)} $</span>
119 + </div>
120 + ))}
121 + </div>
122 + </div>
123 + </section>
124 +
125 + <section>
126 + <div className="sec"><span className="sec-num">04</span> <span className="kicker">{fr ? "Voisins" : "Neighbours"}</span>
127 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{fr ? "Assemblages de la même catégorie" : "Assemblies of the same category"}</h2>
128 + </div>
129 + <div className="mt-5 grid gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">
130 + {data.neighbours.map((n) => (
131 + <Link key={n.code} href={`/cout/assemblage/${encodeURIComponent(n.code)}`} className="group border-t border-[var(--line)] pt-3">
132 + <p className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{n.code}</p>
133 + <p className="text-[13.5px] font-semibold leading-snug group-hover:text-accent-deep">{fr ? n.nameFr : n.nameEn}</p>
134 + <p className="vp-mono mt-1 text-[12px]">{num(n.direct, lang, 2)} $ / {unitL(n.unit)}</p>
135 + </Link>
136 + ))}
137 + </div>
138 + <Link href="/cout/assemblage" className="vp-mono mt-5 inline-block text-[11px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Tout le catalogue" : "Full catalogue"} →</Link>
139 + </section>
140 +
141 + <Drawer open={!!src} onClose={() => setSrc(null)} title={fr ? "Source" : "Source"} fr={fr}>
142 + {src && (
143 + <div className="space-y-6">
144 + {src.itemCode ? <PriceSourceCard fr={fr} itemName={fr ? src.nameFr : src.nameEn} unit={src.unit} price={src.provenance} locationFactor={data.location.materialFactor} locationName={fr ? data.location.nameFr : data.location.nameEn} /> : null}
145 + {src.rateProvenance && (
146 + <div>
147 + <p className="klabel">{fr ? "Taux de main-d'œuvre" : "Labour rate"}</p>
148 + <p className="vp-display mt-1 text-[16px] font-bold">{tradeLabel(src.trade, fr)} — {num(src.hourlyRate ?? 0, lang, 2)} $/h</p>
149 + <p className="mt-1 inline-flex items-center gap-2 text-[12.5px] text-ink-2"><KindIcon kind={src.rateProvenance.kind} fr={fr} /> {kindLabel(src.rateProvenance.kind, fr)} · {src.rateProvenance.source}{src.rateProvenance.effectiveDate ? ` · ${src.rateProvenance.effectiveDate}` : ""}</p>
150 + {src.rateProvenance.note && <p className="mt-1 text-[12.5px] text-ink-2">{src.rateProvenance.note}</p>}
151 + {src.rateProvenance.sourceUrl && <a href={src.rateProvenance.sourceUrl} target="_blank" rel="noopener noreferrer nofollow" className="vp-mono mt-1 inline-block text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Document source" : "Source document"} ↗</a>}
152 + </div>
153 + )}
154 + {!src.itemCode && !src.rateProvenance && <p className="text-[13px] text-ink-2">{fr ? "Équipement : forfait par unité d'assemblage (hypothèse documentée)." : "Equipment: lump per assembly unit (documented assumption)."}{src.provenance.note ? ` ${src.provenance.note}` : ""}</p>}
155 + </div>
156 + )}
157 + </Drawer>
158 + </div>
159 + );
160 +}
added src/components/cout/BuildingForm.tsx +144 −0
@@ -0,0 +1,144 @@
1 +// Vrai-Prix — méthode du coût : 01 Propriété (données du rôle + provenance) et 02 Caractéristiques (BuildingSpec complet).
2 +"use client";
3 +import Link from "next/link";
4 +import type { AttributeSource, BuildingSpec, CostInput, CostLocation, Quality } from "@/lib/cost/types";
5 +import { BUILDING_TYPES, QUALITIES } from "@/lib/cost/taxonomy";
6 +import { money, num } from "@/components/avendre/fmt";
7 +import { AttrBadge } from "./Provenance";
8 +import { Block, Field, MixField, NumField, SelectField, Toggle } from "./fields";
9 +
10 +export interface PrefillMeta {
11 + unit: { id: string; adresse: string | null; municipalite: string | null; typeProp: string; genre: string | null; lien: string | null; cubf: number | null; cubfLibelle: string | null; nbLogements: number | null; aireM2: number | null; terrainM2: number | null } | null;
12 + missing: string[];
13 + location: CostLocation | null;
14 + locationMethod: string | null;
15 +}
16 +
17 +const Q = (fr: boolean) => QUALITIES.map((q) => ({ key: q.key, label: fr ? q.fr : q.en }));
18 +
19 +export function PropertyPanel({ input, meta, fr, locations, patch, setSrc }: { input: CostInput; meta: PrefillMeta | null; fr: boolean; locations: CostLocation[]; patch: (p: Partial<CostInput>) => void; setSrc: (k: string, s: AttributeSource) => void }) {
20 + const lang = fr ? "fr" : "en";
21 + const u = meta?.unit;
22 + const src = input.attributeSources;
23 + const isProp = input.mode === "property";
24 + return (
25 + <Block num="01" title={fr ? "Propriété" : "Property"} id="s01" aside={<span className={`vp-mono border px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] ${isProp ? "border-ink bg-ink text-paper" : "border-accent text-accent"}`}>{isProp ? (fr ? "Mode propriété" : "Property mode") : (fr ? "Mode construction" : "Construction mode")}</span>}>
26 + {isProp && u ? (
27 + <>
28 + <div>
29 + <p className="vp-display text-[19px] font-bold leading-tight">{input.address ?? u.adresse ?? "—"}</p>
30 + <p className="text-[13.5px] text-ink-2">{u.municipalite ?? "—"}{input.lat != null && input.lng != null ? ` · ${input.lat.toFixed(5)}, ${input.lng.toFixed(5)}` : ""}</p>
31 + <div className="mt-2 flex flex-wrap gap-2.5">
32 + <Link href={`/estimation/${encodeURIComponent(u.id)}`} className="vp-mono text-[11px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Rapport de valeur Vrai-Prix" : "Vrai-Prix value report"} →</Link>
33 + <Link href="/cout" className="vp-mono text-[11px] font-bold uppercase tracking-[0.08em] text-ink-2">{fr ? "Changer d'adresse" : "Change address"}</Link>
34 + </div>
35 + </div>
36 + <div className="grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3">
37 + {([
38 + [fr ? "Genre de propriété" : "Property kind", u.typeProp, "MAMH"],
39 + [fr ? "Genre de construction" : "Construction kind", u.genre || "—", "MAMH"],
40 + [fr ? "Lien physique" : "Physical link", u.lien || "—", "MAMH"],
41 + ["CUBF", u.cubf != null ? `${u.cubf} — ${u.cubfLibelle ?? ""}` : "—", "MAMH"],
42 + [fr ? "Année de construction" : "Year built", input.building.yearBuilt ?? "—", src.yearBuilt ?? "assumed"],
43 + [fr ? "Aire d'étages (rôle)" : "Floor area (roll)", u.aireM2 ? `${num(u.aireM2, lang)} m² · ${num(input.building.grossFloorAreaSqft, lang)} pi²` : "—", src.grossFloorAreaSqft ?? "assumed"],
44 + [fr ? "Étages" : "Storeys", input.building.stories, src.stories ?? "assumed"],
45 + [fr ? "Logements" : "Dwellings", u.nbLogements ?? 1, src.units ?? "assumed"],
46 + [fr ? "Superficie du terrain" : "Lot area", u.terrainM2 ? `${num(u.terrainM2, lang)} m²` : "—", "MAMH"],
47 + [fr ? "Valeur du terrain au rôle" : "Land value (roll)", money(input.roll?.landValue, lang), "MAMH"],
48 + [fr ? "Valeur du bâtiment au rôle" : "Building value (roll)", money(input.roll?.buildingValue, lang), "MAMH"],
49 + [fr ? "Valeur totale au rôle 2026" : "Total roll value 2026", money(input.roll?.totalValue, lang), "MAMH"],
50 + ] as [string, string | number, AttributeSource][]).map(([k, v, s]) => (
51 + <div key={k} className="kv-cell">
52 + <p className="k flex items-center gap-1.5">{k} <AttrBadge source={s} fr={fr} /></p>
53 + <p className="v">{v}</p>
54 + </div>
55 + ))}
56 + </div>
57 + {meta?.missing?.length ? (
58 + <p className="border-l-2 border-[var(--amber)] pl-3 text-[12.5px] text-ink-2">
59 + {fr ? `${meta.missing.length} caractéristiques ne sont pas au rôle (sous-sol, garage, revêtements, toiture, chauffage, salles de bain, qualité…) : elles sont supposées par défaut — complétez-les à la section 02 pour améliorer l'indice de confiance.` : `${meta.missing.length} attributes are not on the roll (basement, garage, cladding, roof, heating, bathrooms, quality…): they are default assumptions — complete them in section 02 to improve the confidence index.`}
60 + </p>
61 + ) : null}
62 + </>
63 + ) : (
64 + <div className="grid gap-4 sm:grid-cols-2">
65 + <Field label={fr ? "Municipalité (facteur régional)" : "Municipality (regional factor)"}>
66 + <input value={input.municipality ?? ""} onChange={(e) => { patch({ municipality: e.target.value || null }); setSrc("municipality", "user"); }} placeholder="Gatineau, Québec, Montréal…" className="vp-input text-[14px]" />
67 + </Field>
68 + <Field label={fr ? "Ou région imposée" : "Or forced region"}>
69 + <select value={input.locationCode ?? ""} onChange={(e) => patch({ locationCode: e.target.value || null })} className="vp-input text-[14px]">
70 + <option value="">{fr ? "Automatique (municipalité → centre → rural)" : "Automatic (municipality → centre → rural)"}</option>
71 + {locations.map((l) => (<option key={l.code} value={l.code}>{fr ? l.nameFr : l.nameEn} (× {num(l.overallFactor, lang, 3)})</option>))}
72 + </select>
73 + </Field>
74 + <p className="text-[12.5px] text-ink-2 sm:col-span-2">{fr ? "Bâtiment hypothétique : aucune donnée du rôle. Décrivez-le à la section 02 et saisissez une valeur de terrain à la section 07." : "Hypothetical building: no roll data. Describe it in section 02 and enter a land value in section 07."}</p>
75 + </div>
76 + )}
77 + {meta?.location && (
78 + <p className="vp-mono text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
79 + {fr ? "Localisation" : "Location"} : <b className="text-ink">{fr ? meta.location.nameFr : meta.location.nameEn}</b> · M × {num(meta.location.materialFactor, lang, 2)} · MO × {num(meta.location.labourFactor, lang, 2)} · É × {num(meta.location.equipmentFactor, lang, 2)} · {meta.locationMethod}
80 + </p>
81 + )}
82 + </Block>
83 + );
84 +}
85 +
86 +export function BuildingFormPanel({ b, src, fr, patchB, setSrc }: { b: BuildingSpec; src: Partial<Record<string, AttributeSource>>; fr: boolean; patchB: (p: Partial<BuildingSpec>) => void; setSrc: (k: string, s: AttributeSource) => void }) {
87 + const set = <K extends keyof BuildingSpec>(k: K, v: BuildingSpec[K]) => { patchB({ [k]: v } as Partial<BuildingSpec>); setSrc(String(k), "user"); };
88 + const qOpts = Q(fr);
89 + return (
90 + <Block num="02" title={fr ? "Caractéristiques du bâtiment" : "Building characteristics"} id="s02" aside={<span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{fr ? "chaque champ porte son origine" : "each field carries its origin"}</span>}>
91 + <div className="grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
92 + <Field label={fr ? "Type de bâtiment" : "Building type"} source={src.type} fr={fr}><SelectField value={b.type} onChange={(v) => set("type", v)} options={BUILDING_TYPES.map((t) => ({ key: t.key, label: fr ? t.fr : t.en }))} /></Field>
93 + <Field label={fr ? "Qualité générale" : "Overall quality"} source={src.quality} fr={fr}><SelectField value={b.quality} onChange={(v) => { set("quality", v); }} options={qOpts} /></Field>
94 + <Field label={fr ? "Logements" : "Dwellings"} source={src.units} fr={fr}><NumField value={b.units} min={1} max={12} onChange={(v) => set("units", v ?? 1)} /></Field>
95 + <Field label={fr ? "Aire d'étages brute (hors sous-sol)" : "Gross floor area (excl. basement)"} source={src.grossFloorAreaSqft} fr={fr}><NumField value={b.grossFloorAreaSqft} min={200} max={30000} step={10} unit="pi²" onChange={(v) => set("grossFloorAreaSqft", v ?? 1800)} /></Field>
96 + <Field label={fr ? "Empreinte au sol (optionnel)" : "Footprint (optional)"} source={src.footprintSqft ?? "derived"} fr={fr} hint={fr ? "Vide = aire ÷ étages" : "Empty = area ÷ storeys"}><NumField value={b.footprintSqft} min={100} max={20000} step={10} unit="pi²" placeholder={`${Math.round(b.grossFloorAreaSqft / Math.max(1, b.stories))}`} onChange={(v) => set("footprintSqft", v)} /></Field>
97 + <Field label={fr ? "Étages" : "Storeys"} source={src.stories} fr={fr}><NumField value={b.stories} min={1} max={6} onChange={(v) => set("stories", v ?? 1)} /></Field>
98 + <Field label={fr ? "Année de construction" : "Year built"} source={src.yearBuilt} fr={fr}><NumField value={b.yearBuilt} min={1700} max={2026} onChange={(v) => set("yearBuilt", v)} /></Field>
99 + <Field label={fr ? "Sous-sol" : "Basement"} source={src.basement} fr={fr}><SelectField value={b.basement} onChange={(v) => set("basement", v)} options={[["none", "Aucun (dalle sur sol)", "None (slab on grade)"], ["crawl", "Vide sanitaire", "Crawl space"], ["unfinished", "Non fini", "Unfinished"], ["partial", "Partiellement fini", "Partially finished"], ["finished", "Fini", "Finished"], ["walkout", "Fini avec sortie extérieure", "Finished walkout"]].map(([k, f, e]) => ({ key: k as BuildingSpec["basement"], label: fr ? f : e }))} /></Field>
100 + <Field label={fr ? "Sous-sol fini (%)" : "Basement finished (%)"} source={src.basement} fr={fr}><NumField value={Math.round(b.basementFinishedPct * 100)} min={0} max={100} step={5} unit="%" onChange={(v) => set("basementFinishedPct", Math.min(1, Math.max(0, (v ?? 0) / 100)))} /></Field>
101 + <Field label={fr ? "Garage" : "Garage"} source={src.garage} fr={fr}><SelectField value={b.garage.type} onChange={(v) => set("garage", { ...b.garage, type: v, spaces: v === "none" ? 0 : Math.max(1, b.garage.spaces) })} options={[["none", "Aucun", "None"], ["attached", "Attenant", "Attached"], ["detached", "Détaché", "Detached"], ["integrated", "Intégré", "Integrated"], ["carport", "Abri d'auto", "Carport"]].map(([k, f, e]) => ({ key: k as BuildingSpec["garage"]["type"], label: fr ? f : e }))} /></Field>
102 + <Field label={fr ? "Places de garage" : "Garage spaces"} source={src.garage} fr={fr}><NumField value={b.garage.spaces} min={0} max={4} disabled={b.garage.type === "none"} onChange={(v) => set("garage", { ...b.garage, spaces: v ?? 0 })} /></Field>
103 + <Field label={fr ? "Aire du garage (optionnel)" : "Garage area (optional)"} source={src.garage} fr={fr}><NumField value={b.garage.areaSqft} min={100} max={2000} step={10} unit="pi²" disabled={b.garage.type === "none"} onChange={(v) => set("garage", { ...b.garage, areaSqft: v })} /></Field>
104 + <Field label={fr ? "Structure" : "Structure"} source={src.structure} fr={fr}><SelectField value={b.structure} onChange={(v) => set("structure", v)} options={[["wood_frame", "Ossature de bois", "Wood frame"], ["steel", "Acier", "Steel"], ["concrete", "Béton", "Concrete"], ["log", "Bois rond", "Log"], ["masonry", "Maçonnerie porteuse", "Load-bearing masonry"]].map(([k, f, e]) => ({ key: k as BuildingSpec["structure"], label: fr ? f : e }))} /></Field>
105 + <Field label={fr ? "Fondation" : "Foundation"} source={src.foundation} fr={fr}><SelectField value={b.foundation} onChange={(v) => set("foundation", v)} options={[["poured_concrete", "Béton coulé", "Poured concrete"], ["concrete_block", "Blocs de béton", "Concrete block"], ["slab_on_grade", "Dalle sur sol", "Slab on grade"], ["piers", "Pieux / pilotis", "Piers"], ["stone", "Pierre", "Stone"]].map(([k, f, e]) => ({ key: k as BuildingSpec["foundation"], label: fr ? f : e }))} /></Field>
106 + <Field label={fr ? "Toiture" : "Roof covering"} source={src.roof} fr={fr}><SelectField value={b.roof} onChange={(v) => set("roof", v)} options={[["asphalt_shingle", "Bardeau d'asphalte", "Asphalt shingle"], ["metal", "Tôle d'acier", "Steel"], ["membrane", "Membrane (toit plat)", "Membrane (flat roof)"], ["cedar", "Bardeau de cèdre", "Cedar shake"], ["slate_tile", "Ardoise / tuile", "Slate / tile"]].map(([k, f, e]) => ({ key: k as BuildingSpec["roof"], label: fr ? f : e }))} /></Field>
107 + <Field label={fr ? "Géométrie du toit" : "Roof geometry"} source={src.roof} fr={fr}><SelectField value={b.roofGeometry} onChange={(v) => set("roofGeometry", v)} options={[["gable", "À deux versants", "Gable"], ["hip", "À quatre versants", "Hip"], ["flat", "Plat", "Flat"], ["mansard", "Mansardé", "Mansard"], ["complex", "Complexe", "Complex"]].map(([k, f, e]) => ({ key: k as BuildingSpec["roofGeometry"], label: fr ? f : e }))} /></Field>
108 + <Field label={fr ? "Pente (x / 12)" : "Pitch (x / 12)"} source={src.roof} fr={fr}><NumField value={b.roofPitch} min={0} max={18} disabled={b.roofGeometry === "flat"} onChange={(v) => set("roofPitch", v ?? 6)} /></Field>
109 + <Field label={fr ? "Type de fenêtres" : "Window type"} source={src.windows} fr={fr}><SelectField value={b.windows} onChange={(v) => set("windows", v)} options={[["pvc", "PVC", "PVC"], ["hybrid", "Hybride PVC/aluminium", "Hybrid PVC/aluminum"], ["aluminum", "Aluminium", "Aluminum"], ["wood", "Bois", "Wood"]].map(([k, f, e]) => ({ key: k as BuildingSpec["windows"], label: fr ? f : e }))} /></Field>
110 + <Field label={fr ? "Nombre de fenêtres (optionnel)" : "Window count (optional)"} source={src.windowCount ?? "derived"} fr={fr} hint={fr ? "Vide = aire × 0,011" : "Empty = area × 0.011"}><NumField value={b.windowCount} min={0} max={200} placeholder={`${Math.max(4, Math.round(b.grossFloorAreaSqft * 0.011))}`} onChange={(v) => set("windowCount", v)} /></Field>
111 + <Field label={fr ? "Chauffage" : "Heating"} source={src.heating} fr={fr}><SelectField value={b.heating} onChange={(v) => set("heating", v)} options={[["electric_baseboard", "Plinthes électriques", "Electric baseboards"], ["heat_pump", "Thermopompe centrale", "Central heat pump"], ["furnace_electric", "Fournaise électrique", "Electric furnace"], ["furnace_gas", "Fournaise au gaz", "Gas furnace"], ["furnace_oil", "Fournaise au mazout", "Oil furnace"], ["hydronic", "Eau chaude (hydronique)", "Hydronic"], ["geothermal", "Géothermie", "Geothermal"], ["wood", "Bois", "Wood"]].map(([k, f, e]) => ({ key: k as BuildingSpec["heating"], label: fr ? f : e }))} /></Field>
112 + <div className="flex flex-col gap-2 pt-5">
113 + <Toggle checked={b.hasAirConditioning} onChange={(v) => set("hasAirConditioning", v)} label={fr ? "Climatisation" : "Air conditioning"} />
114 + <Toggle checked={b.hasAirExchanger} onChange={(v) => set("hasAirExchanger", v)} label={fr ? "Échangeur d'air (VRC)" : "Air exchanger (HRV)"} />
115 + </div>
116 + <Field label={fr ? "Cuisines" : "Kitchens"} source={src.kitchens ?? src.kitchenQuality} fr={fr}><NumField value={b.kitchens} min={0} max={12} onChange={(v) => set("kitchens", v ?? 1)} /></Field>
117 + <Field label={fr ? "Qualité de la cuisine" : "Kitchen quality"} source={src.kitchenQuality} fr={fr}><SelectField value={b.kitchenQuality} onChange={(v) => set("kitchenQuality", v as Quality)} options={qOpts} /></Field>
118 + <Field label={fr ? "Salles de bain" : "Bathrooms"} source={src.bathrooms} fr={fr}><NumField value={b.bathrooms} min={0} max={12} onChange={(v) => set("bathrooms", v ?? 0)} /></Field>
119 + <Field label={fr ? "Salles d'eau" : "Powder rooms"} source={src.powderRooms ?? src.bathrooms} fr={fr}><NumField value={b.powderRooms} min={0} max={6} onChange={(v) => set("powderRooms", v ?? 0)} /></Field>
120 + <Field label={fr ? "Qualité des salles de bain" : "Bathroom quality"} source={src.bathroomQuality ?? src.bathrooms} fr={fr}><SelectField value={b.bathroomQuality} onChange={(v) => set("bathroomQuality", v as Quality)} options={qOpts} /></Field>
121 + <Field label={fr ? "Chambres (info)" : "Bedrooms (info)"} source={src.bedrooms} fr={fr}><NumField value={b.bedrooms} min={0} max={20} onChange={(v) => set("bedrooms", v)} /></Field>
122 + </div>
123 + <div className="grid gap-x-10 gap-y-4 lg:grid-cols-2">
124 + <Field label={fr ? "Revêtements extérieurs (parts)" : "Exterior cladding (shares)"} source={src.siding} fr={fr}>
125 + <MixField fr={fr} value={b.siding} onChange={(v) => set("siding", v)} options={[{ key: "vinyl", fr: "Vinyle", en: "Vinyl" }, { key: "brick", fr: "Brique", en: "Brick" }, { key: "fiber_cement", fr: "Fibrociment", en: "Fiber cement" }, { key: "wood", fr: "Bois", en: "Wood" }, { key: "stone", fr: "Pierre", en: "Stone" }, { key: "stucco", fr: "Stuc / acrylique", en: "Stucco" }, { key: "aluminum", fr: "Aluminium", en: "Aluminum" }, { key: "steel", fr: "Acier", en: "Steel" }] as { key: keyof BuildingSpec["siding"]; fr: string; en: string }[]} />
126 + </Field>
127 + <Field label={fr ? "Planchers (parts) — vide = défaut selon la qualité" : "Flooring (shares) — empty = quality default"} source={src.flooring ?? "assumed"} fr={fr}>
128 + <MixField fr={fr} value={b.flooring} onChange={(v) => set("flooring", v)} options={[{ key: "hardwood", fr: "Bois franc", en: "Hardwood" }, { key: "engineered", fr: "Ingénierie", en: "Engineered" }, { key: "vinyl_plank", fr: "Vinyle SPC", en: "Vinyl plank" }, { key: "laminate", fr: "Stratifié", en: "Laminate" }, { key: "ceramic", fr: "Céramique", en: "Ceramic" }, { key: "carpet", fr: "Tapis", en: "Carpet" }] as { key: keyof BuildingSpec["flooring"]; fr: string; en: string }[]} />
129 + </Field>
130 + </div>
131 + <div className="grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">
132 + <Field label={fr ? "Terrasse" : "Deck"} source={src.deckSqft ?? "assumed"} fr={fr}><NumField value={b.deckSqft} min={0} max={3000} step={10} unit="pi²" onChange={(v) => set("deckSqft", v ?? 0)} /></Field>
133 + <Field label={fr ? "Entrée" : "Driveway"} source={src.driveway ?? "assumed"} fr={fr}><SelectField value={b.driveway} onChange={(v) => set("driveway", v)} options={[["none", "Aucune", "None"], ["asphalt", "Asphalte", "Asphalt"], ["pavers", "Pavés", "Pavers"], ["gravel", "Gravier", "Gravel"], ["concrete", "Béton", "Concrete"]].map(([k, f, e]) => ({ key: k as BuildingSpec["driveway"], label: fr ? f : e }))} /></Field>
134 + <Field label={fr ? "Aire d'entrée" : "Driveway area"} source={src.drivewaySqft ?? "assumed"} fr={fr}><NumField value={b.drivewaySqft} min={0} max={10000} step={10} unit="pi²" disabled={b.driveway === "none"} onChange={(v) => set("drivewaySqft", v ?? 0)} /></Field>
135 + <Field label={fr ? "Clôture" : "Fence"} source={src.fenceLinFt ?? "assumed"} fr={fr}><NumField value={b.fenceLinFt} min={0} max={2000} step={5} unit="pi lin." onChange={(v) => set("fenceLinFt", v ?? 0)} /></Field>
136 + <Field label={fr ? "Aménagement paysager (gazon)" : "Landscaping (sod)"} source={src.landscapingSqft ?? "assumed"} fr={fr}><NumField value={b.landscapingSqft} min={0} max={40000} step={50} unit="pi²" onChange={(v) => set("landscapingSqft", v ?? 0)} /></Field>
137 + <Field label={fr ? "Piscine" : "Pool"} source={src.pool ?? "assumed"} fr={fr}><SelectField value={b.pool} onChange={(v) => set("pool", v)} options={[["none", "Aucune", "None"], ["above_ground", "Hors terre", "Above ground"], ["inground", "Creusée", "Inground"]].map(([k, f, e]) => ({ key: k as BuildingSpec["pool"], label: fr ? f : e }))} /></Field>
138 + </div>
139 + <p className="vp-mono text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">
140 + {fr ? "Coût de REMPLACEMENT : quelle que soit l'année, le bâtiment est chiffré avec les composantes modernes équivalentes (2 × 6 R24, R50 au comble, poutrelles ajourées)." : "REPLACEMENT cost: whatever the year, the building is priced with modern equivalent components (2 × 6 R24, R50 attic, I-joists)."}
141 + </p>
142 + </Block>
143 + );
144 +}
added src/components/cout/Charts.tsx +262 −0
@@ -0,0 +1,262 @@
1 +// Vrai-Prix — méthode du coût : graphiques SVG éditoriaux (aucune dépendance).
2 +"use client";
3 +
4 +const MONO = "var(--font-jetbrains), monospace";
5 +const SANS = "var(--font-space-grotesk), sans-serif";
6 +const SERIES = ["var(--ink)", "var(--accent)", "#ea6047", "var(--ink-3)", "var(--amber)", "var(--accent-bright)"];
7 +
8 +const loc = (lang: string) => (lang === "fr" ? "fr-CA" : "en-CA");
9 +export const fmtMoneyK = (v: number, lang: string) => new Intl.NumberFormat(loc(lang), { style: "currency", currency: "CAD", notation: "compact", maximumFractionDigits: 0 }).format(v);
10 +export const fmtMoney = (v: number, lang: string) => new Intl.NumberFormat(loc(lang), { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);
11 +
12 +function niceTicks(lo: number, hi: number, n = 5): number[] {
13 + if (!(hi > lo)) return [lo];
14 + const span = hi - lo;
15 + const mag = Math.pow(10, Math.floor(Math.log10(span / n)));
16 + const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => span / s <= n + 1) ?? mag;
17 + const out: number[] = [];
18 + for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(Math.round(v * 1e6) / 1e6);
19 + return out;
20 +}
21 +
22 +/** Lignes temporelles (indices) : plusieurs séries, légende, dernier point étiqueté. */
23 +export function IndexLines({ series, lang, from = "2021-01-01" }: { series: { label: string; points: { period: string; value: number }[] }[]; lang: string; from?: string }) {
24 + const S = series.map((s) => ({ ...s, points: s.points.filter((p) => p.period >= from) })).filter((s) => s.points.length >= 2);
25 + if (!S.length) return null;
26 + const W = 720, H = 260, pad = { l: 44, r: 90, t: 14, b: 28 };
27 + const all = S.flatMap((s) => s.points);
28 + const dates = all.map((p) => new Date(p.period).getTime());
29 + const x0 = Math.min(...dates), x1 = Math.max(...dates);
30 + const vals = all.map((p) => p.value);
31 + const ylo = Math.floor(Math.min(...vals) / 5) * 5, yhi = Math.ceil(Math.max(...vals) / 5) * 5;
32 + const X = (t: number) => pad.l + ((t - x0) / (x1 - x0 || 1)) * (W - pad.l - pad.r);
33 + const Y = (v: number) => H - pad.b - ((v - ylo) / (yhi - ylo || 1)) * (H - pad.t - pad.b);
34 + const years: number[] = [];
35 + for (let y = new Date(x0).getFullYear(); y <= new Date(x1).getFullYear(); y++) years.push(y);
36 + return (
37 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={lang === "fr" ? "Indices des coûts de construction" : "Construction cost indices"}>
38 + {niceTicks(ylo, yhi, 5).map((v) => (
39 + <g key={v}><line x1={pad.l} x2={W - pad.r} y1={Y(v)} y2={Y(v)} stroke="var(--line)" /><text x={pad.l - 6} y={Y(v) + 3} textAnchor="end" fontSize="9" fontFamily={MONO} fill="var(--ink-3)">{v}</text></g>
40 + ))}
41 + {years.map((y) => { const t = new Date(`${y}-01-01`).getTime(); if (t < x0 || t > x1) return null; return (<g key={y}><line x1={X(t)} x2={X(t)} y1={pad.t} y2={H - pad.b} stroke="var(--line-soft)" /><text x={X(t)} y={H - pad.b + 14} textAnchor="middle" fontSize="9" fontFamily={MONO} fill="var(--ink-3)">{y}</text></g>); })}
42 + {S.map((s, i) => {
43 + const d = s.points.map((p, k) => `${k ? "L" : "M"}${X(new Date(p.period).getTime()).toFixed(1)},${Y(p.value).toFixed(1)}`).join(" ");
44 + const last = s.points[s.points.length - 1];
45 + return (
46 + <g key={s.label}>
47 + <path d={d} fill="none" stroke={SERIES[i % SERIES.length]} strokeWidth={i === 0 ? 2.2 : 1.6} />
48 + <circle cx={X(new Date(last.period).getTime())} cy={Y(last.value)} r={3} fill={SERIES[i % SERIES.length]} />
49 + <text x={X(new Date(last.period).getTime()) + 6} y={Y(last.value) + 3 + (i % 2 ? 9 : -4)} fontSize="9" fontFamily={MONO} fill={SERIES[i % SERIES.length]}>{last.value.toFixed(1)} · {s.label.length > 14 ? s.label.slice(0, 13) + "…" : s.label}</text>
50 + </g>
51 + );
52 + })}
53 + </svg>
54 + );
55 +}
56 +
57 +/** Mini-courbe d'historique de prix. */
58 +export function Sparkline({ points, lang, unit = "$" }: { points: { date: string; price: number }[]; lang: string; unit?: string }) {
59 + if (points.length < 2) return <p className="vp-mono text-[10px] uppercase text-ink-3">{lang === "fr" ? "Historique insuffisant" : "Not enough history"}</p>;
60 + const W = 220, H = 48, p = 4;
61 + const vs = points.map((x) => x.price);
62 + const lo = Math.min(...vs), hi = Math.max(...vs);
63 + const X = (i: number) => p + (i / (points.length - 1)) * (W - 2 * p);
64 + const Y = (v: number) => H - p - ((v - lo) / (hi - lo || 1)) * (H - 2 * p);
65 + const d = points.map((x, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(x.price).toFixed(1)}`).join(" ");
66 + const last = points[points.length - 1];
67 + return (
68 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={`${points.length} points`}>
69 + <path d={d} fill="none" stroke="var(--ink)" strokeWidth="1.6" />
70 + <circle cx={X(points.length - 1)} cy={Y(last.price)} r={2.6} fill="var(--accent)" />
71 + <text x={X(points.length - 1) - 4} y={Y(last.price) - 6} textAnchor="end" fontSize="9" fontFamily={MONO} fill="var(--ink-2)">{last.price.toLocaleString(loc(lang), { maximumFractionDigits: 2 })} {unit}</text>
72 + </svg>
73 + );
74 +}
75 +
76 +/** Barres horizontales (RCN par catégorie, dépréciation…). */
77 +export function HBarsMoney({ rows, lang, total, highlight }: { rows: { label: string; value: number; sub?: string }[]; lang: string; total?: number; highlight?: string }) {
78 + if (!rows.length) return null;
79 + const W = 720, RH = 24, pad = { l: 190, r: 120, t: 6, b: 6 };
80 + const H = rows.length * RH + pad.t + pad.b;
81 + const max = Math.max(...rows.map((r) => Math.abs(r.value)), 1);
82 + const X = (v: number) => pad.l + (Math.abs(v) / max) * (W - pad.l - pad.r);
83 + return (
84 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img">
85 + {rows.map((r, i) => {
86 + const y = pad.t + i * RH + RH / 2;
87 + const neg = r.value < 0;
88 + const color = r.label === highlight ? "var(--accent)" : neg ? "var(--danger)" : "var(--ink)";
89 + return (
90 + <g key={r.label + i}>
91 + <text x={pad.l - 8} y={y + 3.5} textAnchor="end" fontSize="10.5" fontFamily={SANS} fill="var(--ink)">{r.label.length > 30 ? r.label.slice(0, 29) + "…" : r.label}</text>
92 + <rect x={pad.l} y={y - 7} width={Math.max(1, X(r.value) - pad.l)} height={14} fill={color} opacity={0.85} />
93 + <text x={X(r.value) + 6} y={y + 3.5} fontSize="9.5" fontFamily={MONO} fill={color}>{neg ? "−" : ""}{fmtMoney(Math.abs(r.value), lang)}{total ? ` · ${((Math.abs(r.value) / total) * 100).toFixed(1)} %` : ""}{r.sub ? ` · ${r.sub}` : ""}</text>
94 + </g>
95 + );
96 + })}
97 + </svg>
98 + );
99 +}
100 +
101 +/** Barre empilée à 100 % (matériaux · main-d'œuvre · équipement). */
102 +export function StackBar({ parts, lang }: { parts: { label: string; value: number }[]; lang: string }) {
103 + const total = parts.reduce((s, p) => s + Math.max(0, p.value), 0);
104 + if (!total) return null;
105 + return (
106 + <div>
107 + <div className="flex h-5 w-full overflow-hidden border border-[var(--line-strong)]">
108 + {parts.map((p, i) => (
109 + <div key={p.label} style={{ width: `${(Math.max(0, p.value) / total) * 100}%`, background: SERIES[i % SERIES.length] }} title={`${p.label} : ${fmtMoney(p.value, lang)}`} />
110 + ))}
111 + </div>
112 + <div className="vp-mono mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">
113 + {parts.map((p, i) => (
114 + <span key={p.label} className="inline-flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5" style={{ background: SERIES[i % SERIES.length] }} />{p.label} <b className="text-ink">{fmtMoney(p.value, lang)}</b> ({((p.value / total) * 100).toFixed(0)} %)</span>
115 + ))}
116 + </div>
117 + </div>
118 + );
119 +}
120 +
121 +/** Distribution Low / Central / High (courbe en cloche schématique + repères). */
122 +export function RangeDist({ low, central, high, lang, marker, markerLabel }: { low: number; central: number; high: number; lang: string; marker?: number | null; markerLabel?: string }) {
123 + if (!(high > low)) return null;
124 + const W = 520, H = 130, pad = { l: 16, r: 16, t: 14, b: 30 };
125 + const sigma = (high - low) / (2 * 1.2816);
126 + const lo = Math.min(low - 1.5 * sigma, marker ?? Infinity), hi = Math.max(high + 1.5 * sigma, marker ?? -Infinity);
127 + const X = (v: number) => pad.l + ((v - lo) / (hi - lo)) * (W - pad.l - pad.r);
128 + const pts: string[] = [];
129 + for (let i = 0; i <= 80; i++) {
130 + const v = lo + (i / 80) * (hi - lo);
131 + const y = Math.exp(-0.5 * ((v - central) / sigma) ** 2);
132 + pts.push(`${i ? "L" : "M"}${X(v).toFixed(1)},${(H - pad.b - y * (H - pad.t - pad.b)).toFixed(1)}`);
133 + }
134 + const area = `${pts.join(" ")} L${X(hi).toFixed(1)},${H - pad.b} L${X(lo).toFixed(1)},${H - pad.b} Z`;
135 + return (
136 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label="P10 – central – P90">
137 + <path d={area} fill="var(--accent-soft)" />
138 + <path d={pts.join(" ")} fill="none" stroke="var(--ink)" strokeWidth="1.6" />
139 + <rect x={X(low)} y={H - pad.b - 4} width={X(high) - X(low)} height={4} fill="var(--accent)" />
140 + {[[low, "P10"], [central, lang === "fr" ? "Central" : "Central"], [high, "P90"]].map(([v, l]) => (
141 + <g key={String(l)}><line x1={X(Number(v))} x2={X(Number(v))} y1={pad.t} y2={H - pad.b} stroke="var(--ink)" strokeDasharray="3 3" /><text x={X(Number(v))} y={H - 6} textAnchor="middle" fontSize="9" fontFamily={MONO} fill="var(--ink-2)">{l} {fmtMoneyK(Number(v), lang)}</text></g>
142 + ))}
143 + {marker != null && <g><line x1={X(marker)} x2={X(marker)} y1={pad.t} y2={H - pad.b} stroke="#ea6047" strokeWidth="2" /><text x={X(marker)} y={pad.t - 3} textAnchor="middle" fontSize="9" fontFamily={MONO} fill="#ea6047">{markerLabel ?? ""} {fmtMoneyK(marker, lang)}</text></g>}
144 + </svg>
145 + );
146 +}
147 +
148 +/** Comparaison de lectures (barres verticales) : hédonique, comparables, coût, rôle… */
149 +export function CompareBars({ rows, lang }: { rows: { label: string; value: number | null; accent?: boolean }[]; lang: string }) {
150 + const valid = rows.filter((r) => r.value != null && r.value > 0);
151 + if (!valid.length) return null;
152 + const W = 560, H = 210, pad = { l: 12, r: 12, t: 26, b: 40 };
153 + const max = Math.max(...valid.map((r) => r.value!));
154 + const bw = (W - pad.l - pad.r) / rows.length;
155 + return (
156 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img">
157 + {rows.map((r, i) => {
158 + const x = pad.l + i * bw + bw * 0.15;
159 + const h = r.value ? ((r.value / max) * (H - pad.t - pad.b)) : 0;
160 + return (
161 + <g key={r.label}>
162 + <rect x={x} y={H - pad.b - h} width={bw * 0.7} height={Math.max(h, r.value ? 1 : 0)} fill={r.accent ? "var(--accent)" : "var(--ink)"} opacity={r.value ? 0.9 : 0} />
163 + {!r.value && <text x={x + bw * 0.35} y={H - pad.b - 8} textAnchor="middle" fontSize="9" fontFamily={MONO} fill="var(--ink-3)">{lang === "fr" ? "n. d." : "n/a"}</text>}
164 + {r.value != null && r.value > 0 && <text x={x + bw * 0.35} y={H - pad.b - h - 6} textAnchor="middle" fontSize="10" fontFamily={MONO} fill={r.accent ? "var(--accent)" : "var(--ink)"} fontWeight={700}>{fmtMoneyK(r.value, lang)}</text>}
165 + <text x={x + bw * 0.35} y={H - pad.b + 14} textAnchor="middle" fontSize="9.5" fontFamily={SANS} fill="var(--ink-2)">{r.label.length > 18 ? r.label.slice(0, 17) + "…" : r.label}</text>
166 + </g>
167 + );
168 + })}
169 + <line x1={pad.l} x2={W - pad.r} y1={H - pad.b} y2={H - pad.b} stroke="var(--ink)" />
170 + </svg>
171 + );
172 +}
173 +
174 +/** Cascade RCN → dépréciation → valeur dépréciée → terrain → indication. */
175 +export function Waterfall({ steps, lang }: { steps: { label: string; value: number; kind: "total" | "minus" | "plus" }[]; lang: string }) {
176 + if (!steps.length) return null;
177 + const W = 640, H = 220, pad = { l: 12, r: 12, t: 24, b: 44 };
178 + const bars = steps.reduce<{ acc: number; rows: (typeof steps[number] & { y0: number; y1: number })[] }>((st, s) => {
179 + if (s.kind === "total") return { acc: s.value, rows: [...st.rows, { ...s, y0: 0, y1: s.value }] };
180 + const next = st.acc + (s.kind === "minus" ? -s.value : s.value);
181 + return { acc: next, rows: [...st.rows, { ...s, y0: Math.min(st.acc, next), y1: Math.max(st.acc, next) }] };
182 + }, { acc: 0, rows: [] }).rows;
183 + const max = Math.max(...bars.map((b) => b.y1), 1);
184 + const bw = (W - pad.l - pad.r) / steps.length;
185 + const Y = (v: number) => H - pad.b - (v / max) * (H - pad.t - pad.b);
186 + return (
187 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img">
188 + {bars.map((b, i) => {
189 + const x = pad.l + i * bw + bw * 0.12;
190 + const color = b.kind === "minus" ? "var(--danger)" : b.kind === "plus" ? "var(--accent)" : "var(--ink)";
191 + return (
192 + <g key={b.label + i}>
193 + <rect x={x} y={Y(b.y1)} width={bw * 0.76} height={Math.max(1, Y(b.y0) - Y(b.y1))} fill={color} opacity={0.88} />
194 + <text x={x + bw * 0.38} y={Y(b.y1) - 5} textAnchor="middle" fontSize="9.5" fontFamily={MONO} fill={color} fontWeight={700}>{b.kind === "minus" ? "−" : ""}{fmtMoneyK(b.value, lang)}</text>
195 + <text x={x + bw * 0.38} y={H - pad.b + 14} textAnchor="middle" fontSize="9" fontFamily={SANS} fill="var(--ink-2)">{b.label.length > 20 ? b.label.slice(0, 19) + "…" : b.label}</text>
196 + </g>
197 + );
198 + })}
199 + <line x1={pad.l} x2={W - pad.r} y1={H - pad.b} y2={H - pad.b} stroke="var(--ink)" />
200 + </svg>
201 + );
202 +}
203 +
204 +/** Historique du coût unitaire d'un assemblage (8 trimestres) : M / MO / É empilés. */
205 +export function UnitCostHistory({ rows, lang, unit }: { rows: { date: string; material: number; labour: number; equipment: number; direct: number; kind: string }[]; lang: string; unit: string }) {
206 + if (!rows.length) return null;
207 + const W = 640, H = 200, pad = { l: 46, r: 12, t: 14, b: 34 };
208 + const max = Math.max(...rows.map((r) => r.direct), 0.01);
209 + const bw = (W - pad.l - pad.r) / rows.length;
210 + const Y = (v: number) => H - pad.b - (v / max) * (H - pad.t - pad.b);
211 + return (
212 + <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img">
213 + {niceTicks(0, max, 4).map((v) => (<g key={v}><line x1={pad.l} x2={W - pad.r} y1={Y(v)} y2={Y(v)} stroke="var(--line)" /><text x={pad.l - 5} y={Y(v) + 3} textAnchor="end" fontSize="9" fontFamily={MONO} fill="var(--ink-3)">{v.toLocaleString(loc(lang), { maximumFractionDigits: 2 })}</text></g>))}
214 + {rows.map((r, i) => {
215 + const x = pad.l + i * bw + bw * 0.2;
216 + const w = bw * 0.6;
217 + return (
218 + <g key={r.date}>
219 + <rect x={x} y={Y(r.material)} width={w} height={Y(0) - Y(r.material)} fill={SERIES[0]} />
220 + <rect x={x} y={Y(r.material + r.labour)} width={w} height={Y(0) - Y(r.labour)} fill={SERIES[1]} />
221 + <rect x={x} y={Y(r.direct)} width={w} height={Y(0) - Y(r.equipment)} fill={SERIES[2]} />
222 + <text x={x + w / 2} y={Y(r.direct) - 4} textAnchor="middle" fontSize="9" fontFamily={MONO} fill={r.kind === "observed" ? "var(--ink)" : "var(--amber)"}>{r.direct.toLocaleString(loc(lang), { maximumFractionDigits: 2 })}{r.kind === "observed" ? " ●" : r.kind === "indexed" ? " ○" : " ◇"}</text>
223 + <text x={x + w / 2} y={H - pad.b + 14} textAnchor="middle" fontSize="8.5" fontFamily={MONO} fill="var(--ink-3)">{r.date.slice(0, 7)}</text>
224 + </g>
225 + );
226 + })}
227 + <text x={W - pad.r} y={H - 4} textAnchor="end" fontSize="8.5" fontFamily={MONO} fill="var(--ink-3)">$ / {unit} · ● observé ○ indexé ◇ référence</text>
228 + </svg>
229 + );
230 +}
231 +
232 +/** Coupe schématique d'un assemblage par catégorie (SVG générique). */
233 +export function AssemblySketch({ category }: { category: string }) {
234 + const W = 260, H = 150;
235 + const base = <rect x="0" y="0" width={W} height={H} fill="var(--surface-2)" />;
236 + const stroke = "var(--ink)";
237 + const layers = (n: number) => Array.from({ length: n }, (_, i) => <rect key={i} x={30 + i * 22} y="20" width="14" height="110" fill={i % 2 ? "var(--accent-soft)" : "var(--paper)"} stroke={stroke} strokeWidth="1.2" />);
238 + const studs = <g>{[40, 80, 120, 160, 200].map((x) => <rect key={x} x={x} y="24" width="8" height="102" fill="var(--paper)" stroke={stroke} strokeWidth="1.2" />)}<rect x="30" y="14" width="200" height="10" fill="var(--paper)" stroke={stroke} strokeWidth="1.2" /><rect x="30" y="126" width="200" height="10" fill="var(--paper)" stroke={stroke} strokeWidth="1.2" /></g>;
239 + const roof = <g><polygon points="30,120 130,30 230,120" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><polyline points="30,120 130,30 230,120" fill="none" stroke="var(--accent)" strokeWidth="5" /></g>;
240 + const found = <g><rect x="60" y="20" width="140" height="80" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><rect x="40" y="100" width="180" height="24" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1.4" /><line x1="20" y1="60" x2="240" y2="60" stroke="var(--ink-3)" strokeDasharray="4 3" /></g>;
241 + const slab = <g><rect x="30" y="80" width="200" height="22" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><rect x="30" y="102" width="200" height="14" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1" /><rect x="30" y="116" width="200" height="14" fill="var(--surface)" stroke={stroke} strokeWidth="1" /></g>;
242 + const opening = <g><rect x="80" y="30" width="100" height="90" fill="var(--paper)" stroke={stroke} strokeWidth="1.6" /><rect x="90" y="40" width="80" height="70" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1" /><line x1="130" y1="40" x2="130" y2="110" stroke={stroke} /></g>;
243 + const floor = <g>{[50, 90, 130, 170, 210].map((x) => <rect key={x} x={x - 6} y="60" width="12" height="60" fill="var(--paper)" stroke={stroke} strokeWidth="1.2" />)}<rect x="30" y="48" width="200" height="12" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1.2" /></g>;
244 + const grid = <g>{[40, 80, 120, 160, 200].map((x) => <line key={x} x1={x} y1="30" x2={x} y2="120" stroke="var(--ink-3)" />)}{[30, 60, 90, 120].map((y) => <line key={y} x1="40" y1={y} x2="200" y2={y} stroke="var(--ink-3)" />)}<rect x="40" y="30" width="160" height="90" fill="none" stroke={stroke} strokeWidth="1.4" /></g>;
245 + const box = <g><rect x="40" y="40" width="180" height="80" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><rect x="40" y="40" width="180" height="16" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1" />{[70, 100, 130, 160, 190].map((x) => <line key={x} x1={x} y1="56" x2={x} y2="120" stroke={stroke} />)}</g>;
246 + const mech = <g><rect x="60" y="30" width="60" height="90" fill="var(--paper)" stroke={stroke} strokeWidth="1.4" /><rect x="130" y="60" width="90" height="16" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1.2" /><rect x="130" y="90" width="90" height="16" fill="var(--accent-soft)" stroke={stroke} strokeWidth="1.2" /></g>;
247 + const pick = () => {
248 + switch (category) {
249 + case "structure": return studs;
250 + case "roofing": return roof;
251 + case "foundation": case "excavation": return found;
252 + case "site": case "exterior": case "garage": case "basement": return slab;
253 + case "openings": return opening;
254 + case "insulation": case "envelope": case "finishes_ext": return layers(9);
255 + case "interior": return floor;
256 + case "kitchen": case "bathroom": return box;
257 + case "plumbing": case "hvac": case "electrical": return mech;
258 + default: return grid;
259 + }
260 + };
261 + return <svg viewBox={`0 0 ${W} ${H}`} className="w-full max-w-[320px]" role="img" aria-hidden="true">{base}{pick()}</svg>;
262 +}
added src/components/cout/CostPanels.tsx +236 −0
@@ -0,0 +1,236 @@
1 +// Vrai-Prix — méthode du coût : 03 Coût à neuf (tableau par catégorie, indirects, FG/profit/contingence, RCN, fourchette),
2 +// 04 Assemblages (lignes + surcharges + détail du calcul), 05 Main-d'œuvre.
3 +"use client";
4 +import { useState } from "react";
5 +import Link from "next/link";
6 +import type { CostEstimate, CostInput, CostParams, EstimateLine, IndirectParams } from "@/lib/cost/types";
7 +import { INDIRECT_LABELS, categoryLabel, tradeLabel } from "@/lib/cost/taxonomy";
8 +import { UNIT_LABELS } from "@/lib/cost/units";
9 +import { money, num, pct } from "@/components/avendre/fmt";
10 +import { AttrBadge, KindIcon, kindLabel, LayerBadge } from "./Provenance";
11 +import { Drawer, PriceSourceCard } from "./SourceDrawer";
12 +import { Block, PctField } from "./fields";
13 +import { RangeDist, StackBar } from "./Charts";
14 +
15 +const unitL = (u: string, fr: boolean) => UNIT_LABELS[u as keyof typeof UNIT_LABELS]?.[fr ? "fr" : "en"] ?? u;
16 +
17 +export function CostTablePanel({ est, params, fr, patchP, hide }: { est: CostEstimate | null; params: CostParams; fr: boolean; patchP: (p: Partial<CostParams>) => void; hide: boolean }) {
18 + const lang = fr ? "fr" : "en";
19 + const f = (v: number | null | undefined) => (hide ? "•••" : money(v, lang));
20 + const base = est?.directCost ?? 0;
21 + const indirectBase = base + (est?.indirectCost ?? 0);
22 + return (
23 + <Block num="03" title={fr ? "Coût de remplacement à neuf" : "Replacement cost new"} id="s03" aside={est && <span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{fr ? "prix au" : "prices as of"} {est.priceDate} · {fr ? est.location.nameFr : est.location.nameEn}</span>}>
24 + {!est ? <p className="text-[13.5px] text-ink-2">{fr ? "Calcul en cours…" : "Computing…"}</p> : (
25 + <>
26 + <div className="src-wrap">
27 + <table className="src-table">
28 + <thead><tr><th>{fr ? "Catégorie" : "Category"}</th><th className="text-right">{fr ? "Matériaux" : "Materials"}</th><th className="text-right">{fr ? "Main-d'œuvre" : "Labour"}</th><th className="text-right">{fr ? "Équipement" : "Equipment"}</th><th className="text-right">{fr ? "Direct" : "Direct"}</th><th className="text-right">{fr ? "Ajust. localisation" : "Location adj."}</th><th className="text-right">{fr ? "Ajusté" : "Adjusted"}</th><th className="text-right">%</th></tr></thead>
29 + <tbody>
30 + {est.categories.map((c) => (
31 + <tr key={c.category}>
32 + <td className="font-semibold">{fr ? c.labelFr : c.labelEn}</td>
33 + <td className="vp-mono text-right">{f(c.material)}</td>
34 + <td className="vp-mono text-right">{f(c.labour)}</td>
35 + <td className="vp-mono text-right">{f(c.equipment)}</td>
36 + <td className="vp-mono text-right">{f(c.direct)}</td>
37 + <td className="vp-mono text-right text-ink-3">{hide ? "•••" : `${c.adjusted - c.direct >= 0 ? "+" : "−"}${money(Math.abs(c.adjusted - c.direct), lang)}`}</td>
38 + <td className="vp-mono text-right font-bold">{f(c.adjusted)}</td>
39 + <td className="vp-mono text-right text-ink-3">{num(c.sharePct, lang, 1)}</td>
40 + </tr>
41 + ))}
42 + <tr className="bg-surface-2 font-bold">
43 + <td>{fr ? "Coûts directs" : "Direct costs"}</td>
44 + <td className="vp-mono text-right">{f(est.directMaterial)}</td>
45 + <td className="vp-mono text-right">{f(est.directLabour)}</td>
46 + <td className="vp-mono text-right">{f(est.directEquipment)}</td>
47 + <td className="vp-mono text-right">{f(est.categories.reduce((s, c) => s + c.direct, 0))}</td>
48 + <td className="vp-mono text-right text-ink-3">{hide ? "•••" : `${est.directCost - est.categories.reduce((s, c) => s + c.direct, 0) >= 0 ? "+" : "−"}${money(Math.abs(est.directCost - est.categories.reduce((s, c) => s + c.direct, 0)), lang)}`}</td>
49 + <td className="vp-mono text-right">{f(est.directCost)}</td>
50 + <td className="vp-mono text-right">100</td>
51 + </tr>
52 + </tbody>
53 + </table>
54 + </div>
55 + {!hide && <StackBar lang={lang} parts={[{ label: fr ? "Matériaux" : "Materials", value: est.directMaterial }, { label: fr ? "Main-d'œuvre" : "Labour", value: est.directLabour }, { label: fr ? "Équipement" : "Equipment", value: est.directEquipment }]} />}
56 + <div className="grid gap-x-12 gap-y-6 lg:grid-cols-2">
57 + <div>
58 + <p className="klabel mb-1">{fr ? "Coûts indirects — % du coût direct (hypothèses modifiables)" : "Indirect costs — % of direct cost (editable assumptions)"}</p>
59 + {(Object.keys(INDIRECT_LABELS) as (keyof IndirectParams)[]).map((k) => (
60 + <PctField key={k} fr={fr} lang={lang} label={fr ? INDIRECT_LABELS[k].fr : INDIRECT_LABELS[k].en} note={INDIRECT_LABELS[k].noteFr} value={params.indirect[k]} amount={hide ? undefined : (base * params.indirect[k]) / 100} onChange={(v) => patchP({ indirect: { ...params.indirect, [k]: v } })} />
61 + ))}
62 + <div className="grid grid-cols-[1fr_84px_110px] gap-3 py-2 text-[13px] font-bold"><span>{fr ? "Total indirects" : "Total indirects"}</span><span className="vp-mono text-right">{num(Object.values(params.indirect).reduce((s, v) => s + v, 0), lang, 1)} %</span><span className="vp-mono text-right">{f(est.indirectCost)}</span></div>
63 + </div>
64 + <div>
65 + <p className="klabel mb-1">{fr ? "Entrepreneur — frais généraux et profit séparés" : "Contractor — overhead and profit kept separate"}</p>
66 + <PctField fr={fr} lang={lang} label={fr ? "Frais généraux entrepreneur" : "Contractor overhead"} note={fr ? "Bureau, supervision, assurances d'entreprise, véhicules — appliqués sur (direct + indirects). Hypothèse 7 %." : "Office, supervision, business insurance, vehicles — applied to (direct + indirects). Assumption 7 %."} value={params.overheadPct} amount={hide ? undefined : (indirectBase * params.overheadPct) / 100} onChange={(v) => patchP({ overheadPct: v })} />
67 + <PctField fr={fr} lang={lang} label={fr ? "Profit entrepreneur" : "Contractor profit"} note={fr ? "Marge bénéficiaire appliquée sur (direct + indirects + frais généraux). Hypothèse 8 %." : "Profit margin applied to (direct + indirects + overhead). Assumption 8 %."} value={params.profitPct} amount={hide ? undefined : est.contractorProfit} onChange={(v) => patchP({ profitPct: v })} />
68 + <PctField fr={fr} lang={lang} label={fr ? "Contingence" : "Contingency"} note={fr ? "Réserve pour imprévus du projet (sol, conditions cachées), sur (direct + indirects). Ce n'est NI le profit (rémunération du risque de l'entrepreneur) NI un frais indirect (coût réel prévisible)." : "Reserve for project unknowns (soil, hidden conditions), on (direct + indirects). It is NEITHER profit (remuneration of contractor risk) NOR an indirect cost (real foreseeable cost)."} value={params.contingencyPct} amount={hide ? undefined : est.contingency} onChange={(v) => patchP({ contingencyPct: v })} />
69 + <div className="mt-4 border-t-2 border-ink pt-3">
70 + <p className="klabel">{fr ? "Coût de remplacement à neuf (RCN)" : "Replacement cost new (RCN)"}</p>
71 + <p className="vp-display mt-1 text-[clamp(28px,4vw,40px)] font-bold tracking-[-0.03em]">{f(est.replacementCostNew)}</p>
72 + <p className="vp-mono text-[11px] uppercase tracking-[0.05em] text-ink-2">{hide ? "•••" : `${num(est.perSqft, lang, 0)} $/pi² · ${num(est.perM2, lang, 0)} $/m²`} · {fr ? "fourchette" : "range"} {hide ? "•••" : `${money(est.range.p10, lang)} – ${money(est.range.p90, lang)}`}</p>
73 + <p className="vp-mono mt-1 text-[10px] uppercase tracking-[0.05em] text-ink-3">RCN = {fr ? "Directs + Indirects + Frais généraux + Profit + Contingence" : "Direct + Indirect + Overhead + Profit + Contingency"}</p>
74 + </div>
75 + </div>
76 + </div>
77 + {!hide && <RangeDist low={est.range.p10} central={est.replacementCostNew} high={est.range.p90} lang={lang} marker={est.otherReadings?.rollBuilding ?? null} markerLabel={fr ? "Bâtiment au rôle" : "Roll building"} />}
78 + </>
79 + )}
80 + </Block>
81 + );
82 +}
83 +
84 +/* ------------------------------------------------------------ 04 assemblages */
85 +
86 +export function AssemblyLinesPanel({ est, input, fr, setQty, toggleExclude, hide }: { est: CostEstimate | null; input: CostInput; fr: boolean; setQty: (code: string, q: number | null) => void; toggleExclude: (code: string) => void; hide: boolean }) {
87 + const lang = fr ? "fr" : "en";
88 + const [open, setOpen] = useState<EstimateLine | null>(null);
89 + const [src, setSrc] = useState<{ comp: EstimateLine["unitDetail"]["components"][number]; line: EstimateLine } | null>(null);
90 + const f = (v: number) => (hide ? "•••" : money(v, lang));
91 + const excluded = input.excludedAssemblies;
92 + return (
93 + <Block num="04" title={fr ? "Assemblages" : "Assemblies"} id="s04" aside={est && <span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{est.lines.length} {fr ? "lignes · cliquez pour le détail du calcul" : "lines · click for calculation detail"}</span>}>
94 + {!est ? null : (
95 + <div className="src-wrap">
96 + <table className="src-table">
97 + <thead><tr><th>{fr ? "Assemblage" : "Assembly"}</th><th className="text-right">{fr ? "Quantité" : "Quantity"}</th><th>{fr ? "Formule" : "Formula"}</th><th className="text-right">{fr ? "Coût unit." : "Unit cost"}</th><th className="text-right">M</th><th className="text-right">MO</th><th className="text-right">É</th><th className="text-right">{fr ? "Ajusté" : "Adjusted"}</th><th /></tr></thead>
98 + <tbody>
99 + {est.lines.map((l) => (
100 + <tr key={l.assemblyCode} className={excluded.includes(l.assemblyCode) ? "opacity-50" : ""}>
101 + <td>
102 + <button type="button" onClick={() => setOpen(l)} className="text-left font-semibold hover:text-accent-deep">{fr ? l.nameFr : l.nameEn}</button>
103 + <br /><span className="vp-mono text-[10px] uppercase text-ink-3">{l.assemblyCode} · {categoryLabel(l.category, fr)} · {fr ? "part observée" : "observed share"} {Math.round(l.observedShare * 100)} %</span>
104 + </td>
105 + <td className="vp-mono text-right whitespace-nowrap">
106 + <span className="inline-flex items-baseline gap-1">
107 + <input type="number" inputMode="decimal" min={0} value={input.quantityOverrides[l.assemblyCode] ?? l.quantity} onChange={(e) => setQty(l.assemblyCode, e.target.value === "" ? null : Number(e.target.value))} className="vp-input vp-mono !min-h-0 w-[88px] !py-0.5 text-right text-[12.5px]" aria-label={fr ? "Quantité" : "Quantity"} />
108 + <span className="text-[10px] text-ink-3">{unitL(l.unit, fr)}</span>
109 + </span>
110 + <div className="mt-0.5 flex justify-end gap-1"><AttrBadge source={l.quantitySource} fr={fr} />{input.quantityOverrides[l.assemblyCode] != null && <button type="button" onClick={() => setQty(l.assemblyCode, null)} className="vp-mono text-[9px] uppercase text-accent">{fr ? "réinit." : "reset"}</button>}</div>
111 + </td>
112 + <td className="max-w-[260px] text-[11.5px] leading-snug text-ink-2">{l.quantityFormula}</td>
113 + <td className="vp-mono text-right">{hide ? "•••" : `${num(l.unitCost, lang, 2)} $`}<br /><span className="text-[10px] text-ink-3">/ {unitL(l.unit, fr)}</span></td>
114 + <td className="vp-mono text-right text-[12px]">{f(l.material)}</td>
115 + <td className="vp-mono text-right text-[12px]">{f(l.labour)}</td>
116 + <td className="vp-mono text-right text-[12px]">{f(l.equipment)}</td>
117 + <td className="vp-mono text-right font-bold">{f(l.adjusted)}</td>
118 + <td><button type="button" onClick={() => toggleExclude(l.assemblyCode)} className="vp-mono text-[9.5px] uppercase text-ink-3 hover:text-[var(--danger)]">{excluded.includes(l.assemblyCode) ? (fr ? "inclure" : "include") : (fr ? "exclure" : "exclude")}</button></td>
119 + </tr>
120 + ))}
121 + {excluded.filter((c) => !est.lines.some((l) => l.assemblyCode === c)).map((c) => (
122 + <tr key={c} className="opacity-60"><td colSpan={8} className="vp-mono text-[11px] uppercase text-ink-3">{c} — {fr ? "exclu" : "excluded"}</td><td><button type="button" onClick={() => toggleExclude(c)} className="vp-mono text-[9.5px] uppercase text-accent">{fr ? "inclure" : "include"}</button></td></tr>
123 + ))}
124 + </tbody>
125 + </table>
126 + </div>
127 + )}
128 + <Drawer open={!!open} onClose={() => setOpen(null)} title={fr ? "Détails du calcul" : "Calculation details"} fr={fr}>
129 + {open && (
130 + <div className="space-y-4">
131 + <div>
132 + <p className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{open.assemblyCode}</p>
133 + <p className="vp-display text-[18px] font-bold leading-tight">{fr ? open.nameFr : open.nameEn}</p>
134 + <Link href={`/cout/assemblage/${encodeURIComponent(open.assemblyCode)}`} className="vp-mono mt-1 inline-block text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Fiche complète de l'assemblage" : "Full assembly card"} →</Link>
135 + </div>
136 + <div className="grid grid-cols-2 gap-x-6 gap-y-2">
137 + <div className="kv-cell"><p className="k">{fr ? "Quantité" : "Quantity"}</p><p className="v vp-mono text-[13px] font-medium">{num(open.quantity, lang, 2)} {unitL(open.unit, fr)} <AttrBadge source={open.quantitySource} fr={fr} /></p></div>
138 + <div className="kv-cell"><p className="k">{fr ? "Formule de quantité" : "Quantity formula"}</p><p className="v text-[12.5px] font-normal">{open.quantityFormula}</p></div>
139 + <div className="kv-cell"><p className="k">{fr ? "Coût unitaire direct" : "Unit direct cost"}</p><p className="v vp-mono text-[13px] font-medium">{num(open.unitCost, lang, 2)} $ / {unitL(open.unit, fr)}</p></div>
140 + <div className="kv-cell"><p className="k">{fr ? "Ligne (avant / après localisation)" : "Line (before / after location)"}</p><p className="v vp-mono text-[13px] font-medium">{money(open.direct, lang)} → {money(open.adjusted, lang)}</p></div>
141 + <div className="kv-cell"><p className="k">{fr ? "Écart-type de la ligne" : "Line standard deviation"}</p><p className="v vp-mono text-[13px] font-medium">± {money(open.sigma, lang)}</p></div>
142 + <div className="kv-cell"><p className="k">{fr ? "Vie économique · groupe" : "Economic life · group"}</p><p className="v vp-mono text-[13px] font-medium">{open.economicLife ?? "—"} {fr ? "ans" : "yrs"} · {open.conditionGroup ?? "—"}</p></div>
143 + </div>
144 + <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "matériau = qté × (1 + pertes) × prix · main-d'œuvre = heures × taux employeur · par unité d'assemblage" : "material = qty × (1 + waste) × price · labour = hours × employer rate · per assembly unit"}</p>
145 + <div className="src-wrap">
146 + <table className="src-table">
147 + <thead><tr><th>{fr ? "Composant" : "Component"}</th><th className="text-right">{fr ? "Qté / unité" : "Qty / unit"}</th><th className="text-right">{fr ? "Pertes" : "Waste"}</th><th className="text-right">{fr ? "Prix unit." : "Unit price"}</th><th>{fr ? "Prov." : "Prov."}</th><th className="text-right">{fr ? "Matériau" : "Material"}</th><th className="text-right">{fr ? "Heures" : "Hours"}</th><th className="text-right">{fr ? "Taux" : "Rate"}</th><th className="text-right">MO</th><th className="text-right">É</th></tr></thead>
148 + <tbody>
149 + {open.unitDetail.components.map((c, i) => (
150 + <tr key={i} className="cursor-pointer" onClick={() => setSrc({ comp: c, line: open })}>
151 + <td><span className="font-semibold">{fr ? c.nameFr : c.nameEn}</span>{c.itemCode && <><br /><span className="vp-mono text-[10px] text-ink-3">{c.itemCode}</span></>}{c.trade && c.labourHours > 0 && <><br /><span className="vp-mono text-[10px] text-ink-3">{tradeLabel(c.trade, fr)}</span></>}</td>
152 + <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${num(c.quantity, lang, 4)} ${unitL(c.unit, fr)}` : "—"}</td>
153 + <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${Math.round(c.wasteFactor * 100)} %` : "—"}</td>
154 + <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${num(c.unitPrice, lang, 2)} $` : "—"}</td>
155 + <td>{c.itemCode ? <KindIcon kind={c.provenance.kind} fr={fr} source={c.provenance.source} observedAt={c.provenance.observedAt} note={c.provenance.note} /> : <LayerBadge layer="assumption" fr={fr} />}</td>
156 + <td className="vp-mono text-right text-[12px]">{c.materialCost ? `${num(c.materialCost, lang, 2)} $` : "—"}</td>
157 + <td className="vp-mono text-right text-[12px]">{c.labourHours ? num(c.labourHours, lang, 3) : "—"}</td>
158 + <td className="vp-mono text-right text-[12px]">{c.hourlyRate != null ? <span className="inline-flex items-center gap-1">{num(c.hourlyRate, lang, 2)} $/h <KindIcon kind={c.rateProvenance?.kind ?? "assumption"} fr={fr} source={c.rateProvenance?.source} observedAt={c.rateProvenance?.effectiveDate} note={c.rateProvenance?.note} /></span> : "—"}</td>
159 + <td className="vp-mono text-right text-[12px]">{c.labourCost ? `${num(c.labourCost, lang, 2)} $` : "—"}</td>
160 + <td className="vp-mono text-right text-[12px]">{c.equipmentCost ? `${num(c.equipmentCost, lang, 2)} $` : "—"}</td>
161 + </tr>
162 + ))}
163 + <tr className="bg-surface-2 font-bold"><td>{fr ? "Par unité d'assemblage" : "Per assembly unit"}</td><td colSpan={4} /><td className="vp-mono text-right">{num(open.unitDetail.material, lang, 2)} $</td><td colSpan={2} /><td className="vp-mono text-right">{num(open.unitDetail.labour, lang, 2)} $</td><td className="vp-mono text-right">{num(open.unitDetail.equipment, lang, 2)} $</td></tr>
164 + </tbody>
165 + </table>
166 + </div>
167 + <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Cliquez un composant pour sa fiche source." : "Click a component for its source card."}</p>
168 + </div>
169 + )}
170 + </Drawer>
171 + <Drawer open={!!src} onClose={() => setSrc(null)} title={fr ? "Source" : "Source"} fr={fr}>
172 + {src && (
173 + <div className="space-y-6">
174 + {src.comp.itemCode ? <PriceSourceCard fr={fr} itemName={fr ? src.comp.nameFr : src.comp.nameEn} unit={src.comp.unit} price={src.comp.provenance} locationFactor={est?.location.materialFactor} locationName={est ? (fr ? est.location.nameFr : est.location.nameEn) : undefined} /> : null}
175 + {src.comp.rateProvenance && (
176 + <div>
177 + <p className="klabel">{fr ? "Taux de main-d'œuvre" : "Labour rate"}</p>
178 + <p className="vp-display mt-1 text-[16px] font-bold">{tradeLabel(src.comp.trade, fr)} — {num(src.comp.hourlyRate ?? 0, lang, 2)} $/h</p>
179 + <p className="mt-1 inline-flex items-center gap-2 text-[12.5px] text-ink-2"><KindIcon kind={src.comp.rateProvenance.kind} fr={fr} /> {kindLabel(src.comp.rateProvenance.kind, fr)} · {src.comp.rateProvenance.source}{src.comp.rateProvenance.effectiveDate ? ` · ${fr ? "en vigueur le" : "effective"} ${src.comp.rateProvenance.effectiveDate}` : ""}</p>
180 + {src.comp.rateProvenance.note && <p className="mt-1 text-[12.5px] text-ink-2">{src.comp.rateProvenance.note}</p>}
181 + {src.comp.rateProvenance.sourceUrl && <a href={src.comp.rateProvenance.sourceUrl} target="_blank" rel="noopener noreferrer nofollow" className="vp-mono mt-1 inline-block text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Document source" : "Source document"} ↗</a>}
182 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Coût employeur complet : taux horaire + vacances (13 %) + avantages sociaux + cotisations (AE, RQAP, FSS, CCQ, CNESST…) — jamais le seul salaire." : "Full employer cost: hourly wage + vacation (13 %) + benefits + contributions (EI, QPIP, HSF, CCQ, CNESST…) — never the bare wage."}</p>
183 + </div>
184 + )}
185 + </div>
186 + )}
187 + </Drawer>
188 + </Block>
189 + );
190 +}
191 +
192 +/* ------------------------------------------------------------ 05 main-d'œuvre */
193 +
194 +export function LabourPanel({ est, fr, hide }: { est: CostEstimate | null; fr: boolean; hide: boolean }) {
195 + const lang = fr ? "fr" : "en";
196 + if (!est) return null;
197 + const byTrade = new Map<string, { hours: number; cost: number; rate: number | null; official: boolean; source: string | null; date: string | null }>();
198 + for (const l of est.lines) for (const c of l.unitDetail.components) {
199 + if (!(c.labourHours > 0)) continue;
200 + const k = c.trade ?? "?";
201 + const cur = byTrade.get(k) ?? { hours: 0, cost: 0, rate: c.hourlyRate, official: c.rateProvenance?.kind === "official", source: c.rateProvenance?.source ?? null, date: c.rateProvenance?.effectiveDate ?? null };
202 + cur.hours += c.labourHours * l.quantity;
203 + cur.cost += c.labourCost * l.quantity * est.location.labourFactor;
204 + byTrade.set(k, cur);
205 + }
206 + const rows = [...byTrade.entries()].sort((a, b) => b[1].cost - a[1].cost);
207 + const totalH = rows.reduce((s, [, r]) => s + r.hours, 0);
208 + const officialShare = est.directLabour > 0 ? rows.filter(([, r]) => r.official).reduce((s, [, r]) => s + r.cost, 0) / est.directLabour : 0;
209 + return (
210 + <Block num="05" title={fr ? "Main-d'œuvre" : "Labour"} id="s05" aside={<span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{fr ? "part des taux officiels" : "official-rate share"} : {Math.round(officialShare * 100)} %</span>}>
211 + <div className="grid grid-cols-3 gap-x-6">
212 + <div className="kv-cell"><p className="k">{fr ? "Heures totales" : "Total hours"}</p><p className="v">{hide ? "•••" : num(totalH, lang)} h</p></div>
213 + <div className="kv-cell"><p className="k">{fr ? "Coût de main-d'œuvre (localisé)" : "Labour cost (localised)"}</p><p className="v">{hide ? "•••" : money(est.directLabour, lang)}</p></div>
214 + <div className="kv-cell"><p className="k">{fr ? "Facteur régional MO" : "Regional labour factor"}</p><p className="v">× {num(est.location.labourFactor, lang, 2)}</p></div>
215 + </div>
216 + <div className="src-wrap">
217 + <table className="src-table">
218 + <thead><tr><th>{fr ? "Métier" : "Trade"}</th><th className="text-right">{fr ? "Heures" : "Hours"}</th><th className="text-right">{fr ? "Taux employeur" : "Employer rate"}</th><th>{fr ? "Source · date" : "Source · date"}</th><th className="text-right">{fr ? "Coût" : "Cost"}</th><th className="text-right">%</th></tr></thead>
219 + <tbody>
220 + {rows.map(([t, r]) => (
221 + <tr key={t}>
222 + <td className="font-semibold">{tradeLabel(t, fr)}</td>
223 + <td className="vp-mono text-right">{hide ? "•••" : num(r.hours, lang)}</td>
224 + <td className="vp-mono text-right">{r.rate != null ? `${num(r.rate, lang, 2)} $/h` : "—"} <KindIcon kind={r.official ? "official" : "assumption"} fr={fr} /></td>
225 + <td className="text-[12px] text-ink-2">{r.official ? `${r.source}${r.date ? ` · ${r.date}` : ""}` : (fr ? "Taux de repli (hypothèse) — aucune grille" : "Fallback rate (assumption) — no grid")}</td>
226 + <td className="vp-mono text-right font-bold">{hide ? "•••" : money(r.cost, lang)}</td>
227 + <td className="vp-mono text-right text-ink-3">{est.directLabour ? pct((r.cost / est.directLabour) * 100, lang, 1, false) : "—"}</td>
228 + </tr>
229 + ))}
230 + </tbody>
231 + </table>
232 + </div>
233 + <p className="vp-mono text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">{fr ? "Heures = Σ (heures par unité d'assemblage × quantité). Taux = coût employeur complet des grilles APCHQ (conventions CCQ, secteur résidentiel léger, compagnon). Les heures de productivité sont des hypothèses Vrai-Prix." : "Hours = Σ (hours per assembly unit × quantity). Rate = full employer cost from APCHQ grids (CCQ agreements, light residential sector, journeyperson). Productivity hours are Vrai-Prix assumptions."}</p>
234 + </Block>
235 + );
236 +}
added src/components/cout/Landing.tsx +287 −0
@@ -0,0 +1,287 @@
1 +// Vrai-Prix — méthode du coût : page d'atterrissage /cout (héro, KPI « Coût aujourd'hui », indices,
2 +// explorateur de coûts unitaires, observatoire des matériaux, assemblages populaires, régions, méthode, sources, limites).
3 +"use client";
4 +import { useEffect, useRef, useState } from "react";
5 +import Link from "next/link";
6 +import { useRouter } from "next/navigation";
7 +import { useLang } from "@/components/LangContext";
8 +import SearchBox from "@/components/SearchBox";
9 +import type { CostOverview, PriceKind } from "@/lib/cost/types";
10 +import { UNIT_LABELS } from "@/lib/cost/units";
11 +import { categoryLabel } from "@/lib/cost/taxonomy";
12 +import { money, num, pct } from "@/components/avendre/fmt";
13 +import { IndexLines, Sparkline } from "./Charts";
14 +import { fmtDate, KindIcon, KindLegend } from "./Provenance";
15 +import { Drawer, PriceSourceCard } from "./SourceDrawer";
16 +
17 +interface ItemRow { code: string; nameFr: string; nameEn: string; unit: string; category: string; price: number | null; low: number | null; high: number | null; kind: PriceKind | null; sourceCount: number; observedAt: string | null; confidence: number | null; note: string | null; sources: { source: string; price: number; url: string | null; observedAt: string; sourceUnit: string; conversionFactor: number; regular: boolean }[] }
18 +
19 +export function Sect({ num, kicker, title, lede, id }: { num: string; kicker: string; title: string; lede?: string; id?: string }) {
20 + return (
21 + <div className="sec" id={id}>
22 + <div className="flex items-baseline gap-3">
23 + <span className="sec-num">{num}</span>
24 + <span className="kicker">{kicker}</span>
25 + </div>
26 + <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{title}</h2>
27 + {lede && <p className="mt-2 max-w-3xl text-[14px] leading-relaxed text-ink-2">{lede}</p>}
28 + </div>
29 + );
30 +}
31 +
32 +function Kpi({ k, v, sub, na }: { k: string; v: string | null; sub?: string | null; na: string }) {
33 + return (
34 + <div className="border-t border-[var(--line)] pt-3">
35 + <p className="klabel">{k}</p>
36 + <p className={`vp-display mt-1 text-[clamp(20px,2.6vw,28px)] font-bold tracking-[-0.02em] ${v ? "" : "text-ink-3"}`}>{v ?? na}</p>
37 + {sub && <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">{sub}</p>}
38 + </div>
39 + );
40 +}
41 +
42 +export default function Landing({ overview }: { overview: CostOverview | null }) {
43 + const { lang } = useLang();
44 + const fr = lang === "fr";
45 + const router = useRouter();
46 + const na = fr ? "Donnée non disponible" : "Data not available";
47 + const k = overview?.kpis;
48 + const [q, setQ] = useState("");
49 + const [items, setItems] = useState<ItemRow[] | null>(null);
50 + const [searching, setSearching] = useState<string | null>(null);
51 + const [drawer, setDrawer] = useState<ItemRow | null>(null);
52 + const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
53 +
54 + useEffect(() => {
55 + if (timer.current) clearTimeout(timer.current);
56 + const query = q.trim();
57 + if (query.length < 2) return;
58 + timer.current = setTimeout(async () => {
59 + try {
60 + const r = await fetch(`/api/cost/items?q=${encodeURIComponent(query)}`);
61 + const d = (await r.json()) as { items: ItemRow[] };
62 + setItems(d.items ?? []);
63 + setSearching(query);
64 + } catch { setItems([]); setSearching(query); }
65 + }, 250);
66 + return () => { if (timer.current) clearTimeout(timer.current); };
67 + }, [q]);
68 + const loading = q.trim().length >= 2 && searching !== q.trim();
69 +
70 + const indexSeries = (overview?.indices ?? [])
71 + .filter((s) => ["statcan:18100289:10:1:1", "statcan:18100289:9:1:1", "statcan:18100289:10:5:1", "statcan:18100289:10:1:8", "statcan:18100289:10:1:19"].includes(s.code))
72 + .map((s) => ({ label: `${s.geography.split(",")[0]} — ${s.division === "aggregate" || s.division.startsWith("Agr") ? s.buildingType : s.division}`.slice(0, 40), points: s.points.map((p) => ({ period: p.period, value: p.value })) }));
73 + const unitL = (u: string) => UNIT_LABELS[u as keyof typeof UNIT_LABELS]?.[fr ? "fr" : "en"] ?? u;
74 +
75 + return (
76 + <div className="space-y-14 py-8">
77 + {/* ================= héro ================= */}
78 + <section className="rise">
79 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">
80 + <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
81 + <Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai&nbsp;Prix</Link>
82 + <span aria-hidden="true">/</span>
83 + <span>{fr ? "Coût" : "Cost"}</span>
84 + </nav>
85 + <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-accent">{fr ? "Troisième lecture de la valeur" : "Third reading of value"}</span>
86 + </div>
87 + <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.5fr_1fr]">
88 + <div>
89 + <span className="kicker">{fr ? "Méthode du coût" : "Cost approach"}</span>
90 + <h1 className="vp-display mt-2 text-[clamp(30px,5vw,54px)] font-bold uppercase leading-[0.98] tracking-[-0.03em]">
91 + {fr ? <>Combien coûterait ce bâtiment<br /><span className="hl">à reconstruire aujourd&apos;hui ?</span></> : <>What would this building cost<br /><span className="hl">to rebuild today?</span></>}
92 + </h1>
93 + <p className="mt-4 max-w-2xl text-[15.5px] leading-relaxed text-ink-2">
94 + {fr ? "Décomposez le coût de remplacement à neuf, composante par composante, puis mesurez la dépréciation et ajoutez la valeur du terrain. Chaque hypothèse et chaque source sont visibles." : "Break the replacement cost new down component by component, then measure depreciation and add the land value. Every assumption and every source is visible."}
95 + </p>
96 + <div className="mt-6 max-w-xl">
97 + <p className="klabel mb-1">{fr ? "Mode propriété — une adresse du rôle d'évaluation" : "Property mode — an address from the assessment roll"}</p>
98 + <SearchBox onPick={(s) => router.push(`/cout?property=${encodeURIComponent(s.id)}`)} placeholder={fr ? "Entrez une adresse (ex. 35 rue Servantes, Gatineau)" : "Enter an address (e.g. 35 rue Servantes, Gatineau)"} />
99 + <div className="mt-3 flex flex-wrap gap-2.5">
100 + <Link href="/cout?mode=construction" className="btn btn-ghost">{fr ? "Mode construction (sans adresse)" : "Construction mode (no address)"}</Link>
101 + <Link href="/a-vendre" className="btn btn-ghost">{fr ? "Depuis une annonce à vendre" : "From a listing for sale"} →</Link>
102 + </div>
103 + </div>
104 + <p className="vp-mono mt-6 max-w-2xl border-l-2 border-accent pl-4 text-[11px] uppercase leading-relaxed tracking-[0.05em] text-ink-2">
105 + {fr ? "Estimation indicative — les coûts présentés sont des estimations statistiques et ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ)." : "Indicative estimate — the costs shown are statistical estimates and constitute neither a contractor's bid nor a certified professional appraisal (OEAQ)."}
106 + </p>
107 + </div>
108 + <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">
109 + <p className="kicker">{fr ? "Des photos à la méthode du coût." : "From photos to the cost approach."}</p>
110 + <p className="mt-3 text-[14px] leading-relaxed text-ink-2">
111 + {fr ? "L'IA transforme une annonce réelle en profil technique de bâtiment. Les coûts actuels du marché sont ensuite appliqués composante par composante — chaque inférence est étiquetée, chaque prix est sourcé, chaque calcul est déterministe." : "AI turns a real listing into a technical building profile. Current market costs are then applied component by component — every inference is labelled, every price is sourced, every computation is deterministic."}
112 + </p>
113 + <Link href="/a-vendre" className="vp-mono mt-3 inline-block text-[11px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Choisir une annonce" : "Pick a listing"} →</Link>
114 + <div className="mt-6 border-t border-[var(--line)] pt-4">
115 + <p className="klabel">{fr ? "La formule, jamais masquée" : "The formula, never hidden"}</p>
116 + <pre className="vp-mono mt-2 whitespace-pre-wrap text-[12px] leading-relaxed text-ink">{`V_coût = V_terrain + RCN − D
117 +D = D_physique + D_fonctionnelle + D_externe
118 +RCN = Directs + Indirects + Frais généraux + Profit + Contingence`}</pre>
119 + </div>
120 + </aside>
121 + </div>
122 + </section>
123 +
124 + {/* ================= KPI ================= */}
125 + <section>
126 + <Sect num="01" kicker={fr ? "Coût aujourd'hui" : "Cost today"} title={fr ? "Indice des coûts de construction — Québec, aujourd'hui" : "Construction cost index — Québec, today"} lede={fr ? "Données synchronisées en arrière-plan (Statistique Canada, APCHQ, détaillants publics). L'interface ne déclenche jamais de collecte : elle lit la base locale." : "Data synchronised in the background (Statistics Canada, APCHQ, public retailers). The interface never triggers a crawl: it reads the local database."} />
127 + <div className="mt-6 grid grid-cols-2 gap-x-8 gap-y-6 md:grid-cols-3 lg:grid-cols-5">
128 + <Kpi k={fr ? "Coût résidentiel (indice)" : "Residential cost (index)"} v={k?.residentialIndex ? num(k.residentialIndex.value, lang, 1) : null} sub={k?.residentialIndex ? `${k.residentialIndex.pctYoy != null ? `${pct(k.residentialIndex.pctYoy, lang, 1)} / 12 ${fr ? "mois" : "months"} · ` : ""}${k.residentialIndex.geography.split(",")[0]} · ${k.residentialIndex.period.slice(0, 7)}` : (fr ? "StatCan 18-10-0289" : "StatCan 18-10-0289")} na={na} />
129 + <Kpi k={fr ? "Matériaux (proxy bois)" : "Materials (wood proxy)"} v={k?.materialsProxy ? num(k.materialsProxy.value, lang, 1) : null} sub={k?.materialsProxy ? `${k.materialsProxy.pctYoy != null ? `${pct(k.materialsProxy.pctYoy, lang, 1)} / 12 ${fr ? "mois" : "months"} · ` : ""}${k.materialsProxy.label}` : null} na={na} />
130 + <Kpi k={fr ? "Main-d'œuvre (charpentier)" : "Labour (carpenter)"} v={k?.labour ? `${num(k.labour.totalEmployerCost, lang, 2)} $/h` : null} sub={k?.labour ? `${k.labour.pctYoy != null ? `${pct(k.labour.pctYoy, lang, 1)} / 12 ${fr ? "mois" : "months"} · ` : ""}${fr ? "coût employeur" : "employer cost"} · ${k.labour.source}` : null} na={na} />
131 + <Kpi k={fr ? "Observations de prix" : "Price observations"} v={k ? num(k.observations, lang) : null} sub={k ? `${k.itemsObserved} / ${k.itemsTotal} ${fr ? "articles observés (120 j)" : "items observed (120 d)"}` : null} na={na} />
132 + <Kpi k={fr ? "Dernière mise à jour" : "Last update"} v={k?.lastSync ? fmtDate(k.lastSync, fr) : null} sub={fr ? "synchronisation des sources" : "source synchronisation"} na={na} />
133 + </div>
134 + <div className="mt-8">
135 + <p className="klabel mb-2">{fr ? "Indices des prix de la construction de bâtiments — 2021 → présent (StatCan 18-10-0289-01, base 2017 = 100)" : "Building construction price indexes — 2021 → present (StatCan 18-10-0289-01, 2017 = 100)"}</p>
136 + {indexSeries.length ? <IndexLines series={indexSeries} lang={lang} /> : <p className="vp-mono border border-dashed border-[var(--line-strong)] p-6 text-center text-[11px] uppercase tracking-[0.06em] text-ink-3">{na}</p>}
137 + </div>
138 + </section>
139 +
140 + {/* ================= explorateur ================= */}
141 + <section>
142 + <Sect num="02" kicker={fr ? "Explorer" : "Explore"} title={fr ? "Explorer les coûts unitaires" : "Explore unit costs"} lede={fr ? "Chaque article a un prix canonique (médiane robuste multi-source), une provenance et une date. Cliquez une ligne pour ouvrir la fiche source." : "Every item has a canonical price (robust multi-source median), a provenance and a date. Click a row to open the source card."} />
143 + <div className="mt-5 max-w-xl">
144 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder={fr ? "Rechercher : gypse, 2 x 6, bardeau, R24, fenêtre…" : "Search: drywall, 2 x 6, shingle, R24, window…"} className="vp-input text-base" aria-label={fr ? "Rechercher un coût unitaire" : "Search a unit cost"} />
145 + </div>
146 + <div className="mt-3"><KindLegend fr={fr} /></div>
147 + {q.trim().length >= 2 && (
148 + <div className="src-wrap mt-4">
149 + <table className="src-table">
150 + <thead><tr><th>{fr ? "Article" : "Item"}</th><th className="text-right">{fr ? "Prix" : "Price"}</th><th>{fr ? "Unité" : "Unit"}</th><th>{fr ? "Prov." : "Prov."}</th><th className="text-right">{fr ? "Sources" : "Sources"}</th><th>{fr ? "Mise à jour" : "Updated"}</th></tr></thead>
151 + <tbody>
152 + {loading && <tr><td colSpan={6} className="vp-mono text-[11px] uppercase text-ink-3">{fr ? "Recherche…" : "Searching…"}</td></tr>}
153 + {!loading && items?.length === 0 && <tr><td colSpan={6} className="text-ink-2">{fr ? "Aucun article." : "No item."}</td></tr>}
154 + {!loading && items?.map((it) => (
155 + <tr key={it.code} className="cursor-pointer" onClick={() => setDrawer(it)}>
156 + <td><span className="font-semibold">{fr ? it.nameFr : it.nameEn}</span><br /><span className="vp-mono text-[10px] uppercase text-ink-3">{it.code} · {categoryLabel(it.category as never, fr)}</span></td>
157 + <td className={`vp-mono text-right ${it.price == null ? "text-ink-3" : ""}`}>{it.price != null ? `${num(it.price, lang, 2)} $` : na}</td>
158 + <td className="vp-mono text-[12px]">/ {unitL(it.unit)}</td>
159 + <td><KindIcon kind={it.kind} fr={fr} source={it.sources[0]?.source} observedAt={it.observedAt} note={it.note} /></td>
160 + <td className="vp-mono text-right">{it.sourceCount}</td>
161 + <td className="vp-mono text-[12px]">{it.observedAt ?? "—"}</td>
162 + </tr>
163 + ))}
164 + </tbody>
165 + </table>
166 + </div>
167 + )}
168 + <Drawer open={!!drawer} onClose={() => setDrawer(null)} title={fr ? "Source du prix" : "Price source"} fr={fr}>
169 + {drawer && (
170 + <PriceSourceCard fr={fr} itemName={fr ? drawer.nameFr : drawer.nameEn} unit={drawer.unit} lastSync={k?.lastSync ?? null}
171 + price={drawer.price != null ? { itemCode: drawer.code, unit: drawer.unit as never, price: drawer.price, low: drawer.low ?? drawer.price, high: drawer.high ?? drawer.price, kind: drawer.kind ?? "reference", sourceCount: drawer.sourceCount, observedAt: drawer.observedAt, sources: drawer.sources, confidence: drawer.confidence ?? 0, indexed: null, note: drawer.note } : null} />
172 + )}
173 + {drawer && <Link href={`/cout/assemblage?item=${encodeURIComponent(drawer.code)}`} className="vp-mono mt-4 inline-block text-[11px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Voir les assemblages qui l'utilisent" : "See assemblies using it"} →</Link>}
174 + </Drawer>
175 + </section>
176 +
177 + {/* ================= observatoire matériaux ================= */}
178 + <section>
179 + <Sect num="03" kicker={fr ? "Observatoire" : "Observatory"} title={fr ? "Prix des matériaux" : "Material prices"} lede={fr ? "Séries historiques des matériaux courants (médiane par date des observations détaillants). Un article sans observation affiche sa référence interne, étiquetée hypothèse." : "Historical series of common materials (median per date of retail observations). An item without observation shows its internal reference, labelled as an assumption."} />
180 + <div className="mt-6 grid gap-x-8 gap-y-6 sm:grid-cols-2 lg:grid-cols-4">
181 + {(overview?.materials ?? []).map((m) => (
182 + <Link key={m.itemCode} href={`/cout/assemblage?item=${encodeURIComponent(m.itemCode)}`} className="group border-t border-[var(--line)] pt-3">
183 + <p className="text-[13px] font-semibold leading-snug group-hover:text-accent-deep">{fr ? m.nameFr : m.nameEn}</p>
184 + <p className="vp-display mt-1 flex items-baseline gap-2 text-[22px] font-bold tracking-[-0.02em]">
185 + {m.price != null ? `${num(m.price, lang, 2)} $` : <span className="text-[14px] text-ink-3">{na}</span>}
186 + <span className="vp-mono text-[10px] font-medium text-ink-3">/ {unitL(m.unit)}</span>
187 + <KindIcon kind={m.kind} fr={fr} observedAt={m.observedAt} />
188 + </p>
189 + <div className="mt-2"><Sparkline points={m.history} lang={lang} /></div>
190 + <p className="vp-mono mt-1 text-[10px] uppercase tracking-[0.05em] text-ink-3">{m.sourceCount} {fr ? "source(s)" : "source(s)"} · {m.observedAt ?? (fr ? "référence" : "reference")}</p>
191 + </Link>
192 + ))}
193 + {!overview?.materials?.length && <p className="text-ink-2">{na}</p>}
194 + </div>
195 + </section>
196 +
197 + {/* ================= assemblages populaires ================= */}
198 + <section>
199 + <Sect num="04" kicker={fr ? "Assemblages" : "Assemblies"} title={fr ? "Assemblages populaires" : "Popular assemblies"} lede={fr ? "Un assemblage est une composante réelle de construction : matériaux (avec pertes) + heures de main-d'œuvre (taux APCHQ/CCQ) + équipement. Coût direct à Montréal, avant facteur régional." : "An assembly is a real construction component: materials (with waste) + labour hours (APCHQ/CCQ rates) + equipment. Direct cost in Montréal, before regional factor."} />
200 + <div className="mt-6 grid gap-x-8 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
201 + {(overview?.popularAssemblies ?? []).map((a) => (
202 + <Link key={a.code} href={`/cout/assemblage/${encodeURIComponent(a.code)}`} className="vp-card vp-card-hover flex flex-col p-4">
203 + <p className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{a.code}</p>
204 + <p className="vp-display mt-1 text-[15px] font-bold leading-tight">{fr ? a.nameFr : a.nameEn}</p>
205 + <p className="vp-display mt-3 text-[24px] font-bold tracking-[-0.02em]">{num(a.direct, lang, 2)} $ <span className="vp-mono text-[10px] font-medium text-ink-3">/ {unitL(a.unit)}</span></p>
206 + <div className="vp-mono mt-2 grid grid-cols-3 gap-2 text-[10px] uppercase tracking-[0.04em] text-ink-2">
207 + <span>{fr ? "Matériaux" : "Materials"}<br /><b className="text-ink">{num(a.material, lang, 2)}</b></span>
208 + <span>{fr ? "Main-d'œuvre" : "Labour"}<br /><b className="text-ink">{num(a.labour, lang, 2)}</b></span>
209 + <span>{fr ? "Équip." : "Equip."}<br /><b className="text-ink">{num(a.equipment, lang, 2)}</b></span>
210 + </div>
211 + <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Part observée" : "Observed share"} : {Math.round(a.observedShare * 100)} %</p>
212 + </Link>
213 + ))}
214 + </div>
215 + <Link href="/cout/assemblage" className="vp-mono mt-5 inline-block text-[11px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Tout le catalogue d'assemblages" : "Full assembly catalogue"} →</Link>
216 + </section>
217 +
218 + {/* ================= régions ================= */}
219 + <section>
220 + <Sect num="05" kicker={fr ? "Localisation" : "Location"} title={fr ? "Coûts par région" : "Costs by region"} lede={fr ? "Les conventions collectives de la construction (Loi R-20, CCQ) fixent des taux uniformes pour tout le Québec : le facteur main-d'œuvre vaut 1,00 sauf régions éloignées. Les facteurs matériaux et équipement traduisent le transport — hypothèses Vrai-Prix, modifiables." : "Construction collective agreements (Act R-20, CCQ) set uniform wages across Québec: the labour factor is 1.00 except remote regions. Material and equipment factors reflect transport — Vrai-Prix assumptions, editable."} />
221 + <div className="src-wrap mt-5">
222 + <table className="src-table">
223 + <thead><tr><th>{fr ? "Région" : "Region"}</th><th className="text-right">{fr ? "Matériaux" : "Materials"}</th><th className="text-right">{fr ? "Main-d'œuvre" : "Labour"}</th><th className="text-right">{fr ? "Équipement" : "Equipment"}</th><th className="text-right">{fr ? "Global" : "Overall"}</th><th>{fr ? "Confiance" : "Confidence"}</th></tr></thead>
224 + <tbody>
225 + {(overview?.locations ?? []).map((l) => (
226 + <tr key={l.code}>
227 + <td><span className="font-semibold">{fr ? l.nameFr : l.nameEn}</span> <span className="vp-mono text-[10px] text-ink-3">{l.code}</span></td>
228 + <td className="vp-mono text-right">× {num(l.materialFactor, lang, 2)}</td>
229 + <td className="vp-mono text-right">× {num(l.labourFactor, lang, 2)}</td>
230 + <td className="vp-mono text-right">× {num(l.equipmentFactor, lang, 2)}</td>
231 + <td className="vp-mono text-right font-bold">× {num(l.overallFactor, lang, 3)}</td>
232 + <td className="vp-mono text-[11px]">{l.confidence} / 100</td>
233 + </tr>
234 + ))}
235 + </tbody>
236 + </table>
237 + </div>
238 + </section>
239 +
240 + {/* ================= méthode en 4 étapes ================= */}
241 + <section>
242 + <Sect num="06" kicker={fr ? "La méthode" : "The method"} title={fr ? "La méthode du coût en 4 étapes" : "The cost approach in 4 steps"} />
243 + <ol className="mt-6 grid gap-x-8 gap-y-6 sm:grid-cols-2 lg:grid-cols-4">
244 + {(fr
245 + ? [["Décrire le bâtiment", "Type, aire, étages, sous-sol, garage, revêtements, toiture, fenêtres, mécanique, cuisine, salles de bain — chaque caractéristique porte son origine (rôle, saisie, IA, hypothèse)."], ["Quantifier et chiffrer à neuf", "Le moteur de géométrie dérive les quantités d'assemblages (formules visibles) ; chaque assemblage = matériaux + heures × taux + équipement, localisés, puis indirects, frais généraux, profit, contingence → RCN."], ["Déprécier", "Physique (âge-vie ou composante par composante selon la condition), fonctionnelle (curable / incurable), externe. Rien n'est calculé sans preuve : l'externe vaut 0 par défaut."], ["Ajouter le terrain, comparer", "Valeur du terrain au rôle (ou saisie) + bâtiment déprécié = indication par le coût. Confrontée aux lectures hédonique et comparables, aux benchmarks, jamais moyennée."]]
246 + : [["Describe the building", "Type, area, storeys, basement, garage, cladding, roof, windows, mechanical, kitchen, bathrooms — each attribute carries its origin (roll, entered, AI, assumption)."], ["Quantify and price new", "The geometry engine derives assembly quantities (visible formulas); each assembly = materials + hours × rate + equipment, localised, then indirects, overhead, profit, contingency → RCN."], ["Depreciate", "Physical (age-life or component by component by condition), functional (curable / incurable), external. Nothing is computed without evidence: external defaults to 0."], ["Add land, compare", "Land value from the roll (or entered) + depreciated building = cost indication. Confronted with the hedonic and comparables readings and benchmarks, never averaged."]]
247 + ).map(([t, b], i) => (
248 + <li key={t} className="border-t border-[var(--line)] pt-3">
249 + <span className="vp-mono text-[11px] font-bold text-accent">0{i + 1}</span>
250 + <p className="vp-display mt-1 text-[15px] font-bold uppercase tracking-[-0.01em]">{t}</p>
251 + <p className="mt-1.5 text-[13px] leading-snug text-ink-2">{b}</p>
252 + </li>
253 + ))}
254 + </ol>
255 + </section>
256 +
257 + {/* ================= sources + limites ================= */}
258 + <section className="grid gap-x-12 gap-y-10 lg:grid-cols-2">
259 + <div>
260 + <Sect num="07" kicker={fr ? "Sources" : "Sources"} title={fr ? "D'où viennent les nombres" : "Where the numbers come from"} />
261 + <ul className="mt-5 space-y-2.5">
262 + {(overview?.sources ?? []).filter((s) => s.active || s.observations > 0).map((s) => (
263 + <li key={s.name} className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-[var(--line)] pb-2 text-[13px]">
264 + <span className="font-semibold">{s.url ? <a href={s.url} target="_blank" rel="noopener noreferrer nofollow" className="hover:text-accent-deep">{s.name} ↗</a> : s.name}</span>
265 + <span className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{s.type} · {s.license.replace("_", " ")} · {s.observations} obs. · {s.lastSync ? fmtDate(s.lastSync, fr) : (fr ? "jamais synchronisé" : "never synced")}</span>
266 + </li>
267 + ))}
268 + </ul>
269 + <p className="vp-mono mt-4 text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">
270 + {fr ? "CCQ · APCHQ · Statistique Canada · Détaillants publics (Canac, BMR, Patrick Morin) · Altus Group (import licencié) · Sources autorisées. Une source « unknown » n'est jamais redistribuée avant validation ; Firecrawl n'est qu'un outil d'extraction et ne confère aucun droit de réutilisation." : "CCQ · APCHQ · Statistics Canada · Public retailers (Canac, BMR, Patrick Morin) · Altus Group (licensed import) · Authorised sources. An 'unknown' source is never redistributed before validation; Firecrawl is only an extraction tool and grants no reuse right."}
271 + </p>
272 + </div>
273 + <div>
274 + <Sect num="08" kicker={fr ? "Limites" : "Limits"} title={fr ? "Ce que la méthode ne dit pas" : "What the method does not say"} />
275 + <ul className="mt-5 space-y-2 text-[13.5px] leading-relaxed text-ink-2">
276 + {(fr
277 + ? ["Coût de remplacement (composantes modernes équivalentes), pas de reproduction à l'identique : un bâtiment ancien est chiffré avec un 2 × 6 R24, pas avec ses matériaux d'origine.", "Les quantités d'assemblages découlent d'hypothèses de prise de quantités (rapport de forme 1,5, hauteur d'étage 9 pi, 16 po c/c) : vous pouvez les remplacer une à une.", "Les prix de référence internes (béton livré, fermes, fenêtres sur mesure…) sont des hypothèses documentées ; ils abaissent la couverture observée et l'indice de confiance.", "La valeur du terrain au rôle n'est pas la valeur marchande du terrain : elle date de 18 à 24 mois avant l'entrée en vigueur du rôle.", "La dépréciation par composante dépend de la condition saisie ou observée ; sans information, l'âge chronologique plafonné est utilisé.", "Aucune moyenne des trois approches : la comparaison est l'objet de l'analyse, pas un chiffre unique."]
278 + : ["Replacement cost (modern equivalent components), not identical reproduction: an old building is priced with a 2 × 6 R24 wall, not with its original materials.", "Assembly quantities follow take-off assumptions (aspect ratio 1.5, 9 ft storey height, 16 in o.c.): you can replace them one by one.", "Internal reference prices (delivered concrete, trusses, custom windows…) are documented assumptions; they lower observed coverage and the confidence index.", "The land value on the roll is not the land's market value: it dates from 18 to 24 months before the roll took effect.", "Component depreciation depends on entered or observed condition; without information, capped chronological age is used.", "No averaging of the three approaches: the comparison is the object of the analysis, not a single number."]
279 + ).map((t) => (<li key={t} className="border-l-2 border-[var(--line-strong)] pl-3">{t}</li>))}
280 + </ul>
281 + </div>
282 + </section>
283 + </div>
284 + );
285 +}
286 +
287 +export { money };
added src/components/cout/Provenance.tsx +97 −0
@@ -0,0 +1,97 @@
1 +// Vrai-Prix — méthode du coût : marqueurs de provenance (● observé ◐ calculé ○ indexé ◇ référence/hypothèse · AI),
2 +// badges de couche (OBSERVÉ · INFÉRÉ PAR IA · CALCULÉ · PRIX SOURCÉ · HYPOTHÈSE) et origine des caractéristiques.
3 +"use client";
4 +import type { AttributeSource, Layer, PriceKind } from "@/lib/cost/types";
5 +
6 +export function kindGlyph(kind: PriceKind | null | undefined): string {
7 + switch (kind) {
8 + case "observed": case "official": return "●";
9 + case "derived": return "◐";
10 + case "indexed": return "○";
11 + case "reference": case "assumption": return "◇";
12 + default: return "·";
13 + }
14 +}
15 +
16 +export function kindLabel(kind: PriceKind | null | undefined, fr: boolean): string {
17 + switch (kind) {
18 + case "observed": return fr ? "Observé directement" : "Directly observed";
19 + case "official": return fr ? "Publication officielle" : "Official publication";
20 + case "derived": return fr ? "Calculé depuis composants" : "Computed from components";
21 + case "indexed": return fr ? "Indexé" : "Indexed";
22 + case "reference": return fr ? "Référence interne (hypothèse)" : "Internal reference (assumption)";
23 + case "assumption": return fr ? "Hypothèse" : "Assumption";
24 + default: return fr ? "Donnée non disponible" : "Data not available";
25 + }
26 +}
27 +
28 +export function kindTooltip(p: { kind: PriceKind | null | undefined; source?: string | null; observedAt?: string | null; note?: string | null }, fr: boolean): string {
29 + const d = p.observedAt ? fmtDate(p.observedAt, fr) : null;
30 + switch (p.kind) {
31 + case "observed": return fr ? `Observé directement chez ${p.source ?? "un fournisseur"}${d ? ` le ${d}` : ""}.` : `Directly observed at ${p.source ?? "a supplier"}${d ? ` on ${d}` : ""}.`;
32 + case "official": return fr ? `Taux publié par ${p.source ?? "une source officielle"}${d ? ` (en vigueur le ${d})` : ""}.` : `Rate published by ${p.source ?? "an official source"}${d ? ` (effective ${d})` : ""}.`;
33 + case "indexed": return p.note ?? (fr ? "Observation ancienne actualisée avec un indice Statistique Canada." : "Older observation updated with a Statistics Canada index.");
34 + case "derived": return fr ? "Calculé à partir de composants eux-mêmes sourcés." : "Computed from components that are themselves sourced.";
35 + case "reference": return fr ? "Prix de référence interne — hypothèse documentée, non observé." : "Internal reference price — documented assumption, not observed.";
36 + case "assumption": return p.note ?? (fr ? "Hypothèse documentée (productivité, pourcentage)." : "Documented assumption (productivity, percentage).");
37 + default: return fr ? "Donnée non disponible." : "Data not available.";
38 + }
39 +}
40 +
41 +export function fmtDate(iso: string | null | undefined, fr: boolean): string {
42 + if (!iso) return "—";
43 + const d = new Date(iso.length <= 10 ? `${iso}T12:00:00` : iso);
44 + if (Number.isNaN(d.getTime())) return iso;
45 + return d.toLocaleDateString(fr ? "fr-CA" : "en-CA", { year: "numeric", month: "long", day: "numeric" });
46 +}
47 +
48 +/** Icône de provenance avec infobulle native (title). */
49 +export function KindIcon({ kind, fr, source, observedAt, note, className = "" }: { kind: PriceKind | null | undefined; fr: boolean; source?: string | null; observedAt?: string | null; note?: string | null; className?: string }) {
50 + const tone = kind === "observed" || kind === "official" ? "text-ink" : kind === "indexed" || kind === "derived" ? "text-accent" : "text-[var(--amber)]";
51 + return (
52 + <span className={`vp-mono inline-block text-[12px] leading-none ${tone} ${className}`} title={kindTooltip({ kind, source, observedAt, note }, fr)} aria-label={kindLabel(kind, fr)}>
53 + {kindGlyph(kind)}
54 + </span>
55 + );
56 +}
57 +
58 +/** Légende des icônes de provenance. */
59 +export function KindLegend({ fr }: { fr: boolean }) {
60 + const rows: [PriceKind, string][] = [["observed", fr ? "Observé directement" : "Observed directly"], ["derived", fr ? "Calculé depuis composants" : "Computed from components"], ["indexed", fr ? "Indexé (StatCan)" : "Indexed (StatCan)"], ["reference", fr ? "Référence interne · hypothèse" : "Internal reference · assumption"]];
61 + return (
62 + <div className="vp-mono flex flex-wrap gap-x-4 gap-y-1 text-[10px] uppercase tracking-[0.06em] text-ink-3">
63 + {rows.map(([k, l]) => (
64 + <span key={k} className="inline-flex items-center gap-1.5"><KindIcon kind={k} fr={fr} />{l}</span>
65 + ))}
66 + <span className="inline-flex items-center gap-1.5"><span className="vp-mono text-[9px] font-bold text-accent">AI</span>{fr ? "Classifié / inféré par IA" : "AI-classified / inferred"}</span>
67 + </div>
68 + );
69 +}
70 +
71 +/** Badge de couche (§208) : OBSERVÉ · INFÉRÉ PAR IA · CALCULÉ · PRIX SOURCÉ · HYPOTHÈSE. */
72 +export function LayerBadge({ layer, fr }: { layer: Layer; fr: boolean }) {
73 + const map: Record<Layer, { fr: string; en: string; cls: string }> = {
74 + observed: { fr: "Observé", en: "Observed", cls: "border-ink bg-ink text-paper" },
75 + ai_inferred: { fr: "Inféré par IA", en: "AI-inferred", cls: "border-accent text-accent" },
76 + computed: { fr: "Calculé", en: "Computed", cls: "border-[var(--line-strong)] text-ink" },
77 + sourced_price: { fr: "Prix sourcé", en: "Sourced price", cls: "border-[var(--line-strong)] text-ink-2" },
78 + assumption: { fr: "Hypothèse", en: "Assumption", cls: "border-[var(--amber)] text-[#8a5a12] bg-[var(--amber-soft)]" },
79 + };
80 + const m = map[layer];
81 + return <span className={`vp-mono inline-block border px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.08em] ${m.cls}`}>{fr ? m.fr : m.en}</span>;
82 +}
83 +
84 +/** Origine d'une caractéristique du bâtiment : MAMH / saisi / IA / dérivé / supposé / annonce. */
85 +export function AttrBadge({ source, fr }: { source: AttributeSource | null | undefined; fr: boolean }) {
86 + const map: Record<string, { fr: string; en: string; cls: string }> = {
87 + MAMH: { fr: "MAMH", en: "MAMH", cls: "border-ink bg-ink text-paper" },
88 + cadastral: { fr: "Cadastre", en: "Cadastral", cls: "border-ink bg-ink text-paper" },
89 + listing: { fr: "Annonce", en: "Listing", cls: "border-[var(--line-strong)] text-ink" },
90 + user: { fr: "Saisi", en: "Entered", cls: "border-[var(--line-strong)] text-ink" },
91 + AI: { fr: "IA", en: "AI", cls: "border-accent text-accent" },
92 + derived: { fr: "Dérivé", en: "Derived", cls: "border-[var(--line)] text-ink-2" },
93 + assumed: { fr: "Supposé", en: "Assumed", cls: "border-[var(--amber)] text-[#8a5a12] bg-[var(--amber-soft)]" },
94 + };
95 + const m = map[source ?? "assumed"] ?? map.assumed;
96 + return <span className={`vp-mono inline-block border px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.08em] ${m.cls}`} title={fr ? "Origine de la caractéristique" : "Attribute origin"}>{fr ? m.fr : m.en}</span>;
97 +}
added src/components/cout/ResultPanels.tsx +283 −0
@@ -0,0 +1,283 @@
1 +// Vrai-Prix — méthode du coût : 06 Dépréciation, 07 Terrain, 08 Résultat (tableau, fourchette, confiance, benchmarks,
2 +// trois lectures, graphiques, exports, mode exercice), 09 Sources et transparence.
3 +"use client";
4 +import { useState } from "react";
5 +import type { Condition, CostEstimate, CostInput, DepreciationInput, LandInput, PriceKind } from "@/lib/cost/types";
6 +import { CONDITIONS, CONDITION_GROUPS, categoryLabel } from "@/lib/cost/taxonomy";
7 +import { money, num, pct } from "@/components/avendre/fmt";
8 +import { ConfidenceDial } from "@/components/MetricViz";
9 +import { Block, Field, NumField, SelectField, Toggle } from "./fields";
10 +import { CompareBars, HBarsMoney, RangeDist, StackBar, Waterfall } from "./Charts";
11 +import { fmtDate, KindIcon, kindLabel, KindLegend, LayerBadge } from "./Provenance";
12 +
13 +/* ------------------------------------------------------------ 06 dépréciation */
14 +
15 +export function DepreciationPanel({ est, dep, fr, patchD, hide }: { est: CostEstimate | null; dep: DepreciationInput; fr: boolean; patchD: (p: Partial<DepreciationInput>) => void; hide: boolean }) {
16 + const lang = fr ? "fr" : "en";
17 + const d = est?.depreciation;
18 + const f = (v: number | null | undefined) => (hide ? "•••" : money(v, lang));
19 + const condOpts = [{ key: "", label: fr ? "— non renseignée (âge chronologique)" : "— not entered (chronological age)" }, ...CONDITIONS.map((c) => ({ key: c.key, label: `${fr ? c.fr : c.en} (âge eff. ${Math.round(c.effectiveAgeRatio * 100)} % de la vie)` }))];
20 + return (
21 + <Block num="06" title={fr ? "Dépréciation" : "Depreciation"} id="s06">
22 + <div className="grid grid-cols-2 gap-2 sm:max-w-md">
23 + {(["age_life", "components"] as const).map((m) => (
24 + <button key={m} type="button" aria-pressed={dep.method === m} onClick={() => patchD({ method: m })} className={`border p-3 text-left ${dep.method === m ? "border-ink bg-ink text-paper" : "border-[var(--line)] bg-surface"}`}>
25 + <p className="vp-display text-[13px] font-bold uppercase">{m === "age_life" ? (fr ? "Âge-vie (simple)" : "Age-life (simple)") : (fr ? "Par composante (avancée)" : "By component (advanced)")}</p>
26 + <p className={`mt-0.5 text-[11px] ${dep.method === m ? "text-paper/70" : "text-ink-3"}`}>{m === "age_life" ? (fr ? "% = âge effectif ÷ vie économique" : "% = effective age ÷ economic life") : (fr ? "vie et condition propres à chaque groupe" : "own life and condition per group")}</p>
27 + </button>
28 + ))}
29 + </div>
30 + <div className="grid gap-x-6 gap-y-4 sm:grid-cols-4">
31 + <div className="kv-cell"><p className="k">{fr ? "Âge chronologique" : "Chronological age"}</p><p className="v">{d?.chronologicalAge != null ? `${d.chronologicalAge} ${fr ? "ans" : "yrs"}` : "—"}</p></div>
32 + <Field label={fr ? "Âge effectif (années)" : "Effective age (years)"} hint={fr ? "Vide = âge chronologique. Une suggestion IA serait affichée comme telle." : "Empty = chronological age."} source={dep.effectiveAge != null ? "user" : "derived"} fr={fr}><NumField value={dep.effectiveAge} min={0} max={200} placeholder={d?.chronologicalAge != null ? String(d.chronologicalAge) : ""} onChange={(v) => patchD({ effectiveAge: v, effectiveAgeSource: v == null ? "derived" : "user" })} /></Field>
33 + <Field label={fr ? "Vie économique (années)" : "Economic life (years)"} source="assumed" fr={fr}><NumField value={dep.economicLife} min={10} max={150} onChange={(v) => patchD({ economicLife: v ?? 60 })} /></Field>
34 + <div className="kv-cell"><p className="k">{fr ? "Détérioration physique" : "Physical deterioration"}</p><p className="v">{d ? `${num(d.physicalPct, lang, 1)} % · ${f(d.physical)}` : "—"}</p></div>
35 + </div>
36 + {dep.method === "components" && d && (
37 + <div className="src-wrap">
38 + <table className="src-table">
39 + <thead><tr><th>{fr ? "Groupe" : "Group"}</th><th className="text-right">RCN</th><th className="text-right">{fr ? "Vie" : "Life"}</th><th>{fr ? "Condition" : "Condition"}</th><th className="text-right">{fr ? "Âge eff." : "Eff. age"}</th><th className="text-right">%</th><th className="text-right">{fr ? "Dépréciation" : "Depreciation"}</th><th className="text-right">{fr ? "Restant" : "Remaining"}</th></tr></thead>
40 + <tbody>
41 + {d.components.map((c) => (
42 + <tr key={c.conditionGroup}>
43 + <td className="font-semibold">{fr ? c.labelFr : c.labelEn}<br /><span className="text-[10.5px] font-normal text-ink-3">{c.rule}</span></td>
44 + <td className="vp-mono text-right">{f(c.rcn)}</td>
45 + <td className="vp-mono text-right">{c.economicLife}</td>
46 + <td><select value={dep.componentConditions[c.conditionGroup] ?? ""} onChange={(e) => patchD({ componentConditions: { ...dep.componentConditions, [c.conditionGroup]: (e.target.value || undefined) as Condition | undefined } })} className="vp-input !min-h-0 !py-1 text-[12px]">{condOpts.map((o) => (<option key={o.key} value={o.key}>{o.label}</option>))}</select></td>
47 + <td className="vp-mono text-right">{num(c.effectiveAge, lang, 1)}</td>
48 + <td className="vp-mono text-right">{num(c.depreciationPct, lang, 1)}</td>
49 + <td className="vp-mono text-right text-[var(--danger)]">−{f(c.depreciation)}</td>
50 + <td className="vp-mono text-right font-bold">{f(c.remaining)}</td>
51 + </tr>
52 + ))}
53 + </tbody>
54 + </table>
55 + </div>
56 + )}
57 + {dep.method === "components" && (
58 + <p className="vp-mono text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">{fr ? "Règles condition → âge effectif (table component_condition_rules, hypothèses documentées) : " : "Condition → effective-age rules (component_condition_rules table, documented assumptions): "}{CONDITIONS.map((c) => `${fr ? c.fr : c.en} ${Math.round(c.effectiveAgeRatio * 100)} %`).join(" · ")}. {fr ? "Groupes : " : "Groups: "}{CONDITION_GROUPS.map((g) => `${fr ? g.fr : g.en} ${g.economicLife} ans`).join(" · ")}.</p>
59 + )}
60 + <div className="grid gap-x-12 gap-y-6 lg:grid-cols-2">
61 + <div>
62 + <div className="flex items-baseline justify-between"><p className="klabel">{fr ? "Désuétude fonctionnelle" : "Functional obsolescence"}</p><button type="button" className="vp-mono text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent" onClick={() => patchD({ functional: [...dep.functional, { id: `${Date.now()}`, type: "", curable: true, costToCure: 0, valueLoss: 0, notes: "" }] })}>+ {fr ? "Ajouter une déficience" : "Add a deficiency"}</button></div>
63 + {dep.functional.length === 0 && <p className="mt-1 text-[12.5px] text-ink-3">{fr ? "Aucune déficience saisie. Exemples : nombre insuffisant de salles de bain, configuration dépassée, absence de garage, hauteur sous plafond, cuisine inadéquate, suramélioration, mécanique inadéquate." : "No deficiency entered. Examples: too few bathrooms, outdated layout, no garage, ceiling height, inadequate kitchen, over-improvement, inadequate mechanical."}</p>}
64 + <div className="mt-2 space-y-3">
65 + {dep.functional.map((fo, i) => {
66 + const upd = (p: Partial<typeof fo>) => patchD({ functional: dep.functional.map((x, j) => (j === i ? { ...x, ...p } : x)) });
67 + return (
68 + <div key={fo.id} className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-[var(--line)] pt-2 sm:grid-cols-[1.4fr_1fr_1fr_1fr_auto]">
69 + <input value={fo.type} onChange={(e) => upd({ type: e.target.value })} placeholder={fr ? "Type (ex. 1 seule salle de bain)" : "Type (e.g. single bathroom)"} className="vp-input !min-h-0 !py-1 text-[13px] sm:col-span-1" />
70 + <select value={fo.curable ? "c" : "i"} onChange={(e) => upd({ curable: e.target.value === "c" })} className="vp-input !min-h-0 !py-1 text-[13px]"><option value="c">{fr ? "Curable" : "Curable"}</option><option value="i">{fr ? "Incurable" : "Incurable"}</option></select>
71 + <NumField value={fo.curable ? fo.costToCure : fo.valueLoss} min={0} step={500} unit="$" onChange={(v) => upd(fo.curable ? { costToCure: v ?? 0 } : { valueLoss: v ?? 0 })} />
72 + <input value={fo.notes} onChange={(e) => upd({ notes: e.target.value })} placeholder={fr ? "Notes" : "Notes"} className="vp-input !min-h-0 !py-1 text-[13px]" />
73 + <button type="button" onClick={() => patchD({ functional: dep.functional.filter((_, j) => j !== i) })} className="vp-mono text-[10px] uppercase text-ink-3 hover:text-[var(--danger)]">{fr ? "retirer" : "remove"}</button>
74 + </div>
75 + );
76 + })}
77 + </div>
78 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Curable = coût de correction · Incurable = perte de valeur" : "Curable = cost to cure · Incurable = value loss"}{d ? ` · ${fr ? "total" : "total"} ${f(d.functional)}` : ""}</p>
79 + </div>
80 + <div>
81 + <p className="klabel">{fr ? "Désuétude externe" : "External obsolescence"}</p>
82 + <div className="mt-1 grid grid-cols-[140px_1fr] gap-3">
83 + <NumField value={dep.externalValueLoss} min={0} step={1000} unit="$" onChange={(v) => patchD({ externalValueLoss: v ?? 0 })} />
84 + <input value={dep.externalNote} onChange={(e) => patchD({ externalNote: e.target.value })} placeholder={fr ? "Motif (nuisance, proximité industrielle, marché faible…)" : "Reason (nuisance, industrial proximity, weak market…)"} className="vp-input text-[13px]" />
85 + </div>
86 + <p className="vp-mono mt-2 text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">{fr ? "Jamais calculée automatiquement sans preuve : 0 $ par défaut." : "Never computed automatically without evidence: 0 $ by default."}</p>
87 + {d && !hide && (
88 + <div className="mt-4">
89 + <HBarsMoney lang={lang} rows={[{ label: fr ? "Détérioration physique" : "Physical deterioration", value: -d.physical }, { label: fr ? "Désuétude fonctionnelle" : "Functional obsolescence", value: -d.functional }, { label: fr ? "Désuétude externe" : "External obsolescence", value: -d.external }, { label: fr ? "Valeur dépréciée du bâtiment" : "Depreciated building value", value: d.depreciatedImprovementValue }]} />
90 + </div>
91 + )}
92 + </div>
93 + </div>
94 + </Block>
95 + );
96 +}
97 +
98 +/* ------------------------------------------------------------------ 07 terrain */
99 +
100 +export function LandPanel({ input, fr, patchL }: { input: CostInput; fr: boolean; patchL: (p: Partial<LandInput>) => void }) {
101 + const lang = fr ? "fr" : "en";
102 + const l = input.land;
103 + const roll = input.roll?.landValue ?? null;
104 + return (
105 + <Block num="07" title={fr ? "Valeur du terrain" : "Land value"} id="s07">
106 + <div className="grid gap-x-10 gap-y-5 lg:grid-cols-[1fr_1.2fr]">
107 + <div>
108 + <p className="klabel">{fr ? "Valeur du terrain utilisée" : "Land value used"}</p>
109 + <p className={`vp-display mt-1 text-[clamp(26px,3.5vw,36px)] font-bold tracking-[-0.03em] ${l.value == null ? "text-ink-3" : ""}`}>{l.value != null ? money(l.value, lang) : (fr ? "Donnée non disponible" : "Data not available")}</p>
110 + <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.05em] text-ink-2">{fr ? "Source" : "Source"} : {l.source === "role" ? (fr ? `Rôle d'évaluation foncière ${l.rollYear ?? 2026} (MAMH)` : `Assessment roll ${l.rollYear ?? 2026} (MAMH)`) : l.source === "user" ? (fr ? "Saisie de l'utilisateur" : "User entry") : l.source === "market" ? (fr ? "Analyse de marché (saisie)" : "Market analysis (entered)") : l.source === "residual" ? (fr ? "Technique résiduelle (saisie)" : "Residual technique (entered)") : (fr ? "aucune" : "none")}</p>
111 + <p className="mt-3 border-l-2 border-[var(--amber)] pl-3 text-[13px] leading-relaxed text-ink-2">⚠ {fr ? "La valeur du terrain au rôle n'est pas nécessairement la valeur marchande actuelle du terrain : elle est fixée 18 à 24 mois avant l'entrée en vigueur du rôle et vise l'équité fiscale." : "The land value on the roll is not necessarily the land's current market value: it is set 18 to 24 months before the roll takes effect and aims at fiscal equity."}</p>
112 + </div>
113 + <div className="grid gap-4 sm:grid-cols-2">
114 + <Field label={fr ? "Méthode" : "Method"}>
115 + <SelectField value={l.source} onChange={(v) => patchL({ source: v, value: v === "role" ? roll : l.value, method: v === "role" ? (fr ? "Valeur du terrain au rôle 2026 (MAMH)" : "Land value on the 2026 roll (MAMH)") : l.method })} options={[{ key: "role" as const, label: fr ? "Conserver la valeur au rôle" : "Keep the roll value" }, { key: "user" as const, label: fr ? "Remplacer par une valeur saisie" : "Replace with an entered value" }, { key: "market" as const, label: fr ? "Comparables de terrains (saisie)" : "Land comparables (entered)" }, { key: "residual" as const, label: fr ? "Technique résiduelle (saisie)" : "Residual technique (entered)" }, { key: "none" as const, label: fr ? "Aucune (0 $)" : "None (0 $)" }].filter((o) => roll != null || o.key !== "role")} />
116 + </Field>
117 + <Field label={fr ? "Valeur ($)" : "Value ($)"}>
118 + <NumField value={l.value} min={0} step={1000} unit="$" disabled={l.source === "role" || l.source === "none"} onChange={(v) => patchL({ value: v })} />
119 + </Field>
120 + <Field label={fr ? "Justification / méthode" : "Rationale / method"}>
121 + <input value={l.method} onChange={(e) => patchL({ method: e.target.value })} className="vp-input text-[13px]" placeholder={fr ? "ex. 3 ventes de terrains vacants ajustées…" : "e.g. 3 adjusted vacant-land sales…"} disabled={l.source === "role"} />
122 + </Field>
123 + {roll != null && <div className="kv-cell"><p className="k">{fr ? "Rappel — terrain au rôle" : "Reminder — roll land value"}</p><p className="v">{money(roll, lang)}</p></div>}
124 + </div>
125 + </div>
126 + </Block>
127 + );
128 +}
129 +
130 +/* ---------------------------------------------------------------- 08 résultat */
131 +
132 +export function ResultPanel({ est, fr, hide, exercise, onSave, saving, savedId, onRefreshPrices, structureAnswer }: { est: CostEstimate | null; fr: boolean; hide: boolean; exercise: boolean; onSave: () => void; saving: boolean; savedId: string | null; onRefreshPrices: () => void; structureAnswer: React.ReactNode }) {
133 + const lang = fr ? "fr" : "en";
134 + const [showJson, setShowJson] = useState(false);
135 + if (!est) return <Block num="08" title={fr ? "Résultat" : "Result"} id="s08"><p className="text-ink-2">{fr ? "Calcul en cours…" : "Computing…"}</p></Block>;
136 + const f = (v: number | null | undefined) => (hide ? "•••" : money(v, lang));
137 + const d = est.depreciation;
138 + const c = est.confidence;
139 + const o = est.otherReadings;
140 + const rows: [string, string, boolean?][] = [
141 + [fr ? "Coûts directs" : "Direct costs", f(est.directCost)],
142 + [fr ? "Coûts indirects" : "Indirect costs", f(est.indirectCost)],
143 + [fr ? "Frais généraux entrepreneur" : "Contractor overhead", f(est.contractorOverhead)],
144 + [fr ? "Profit entrepreneur" : "Contractor profit", f(est.contractorProfit)],
145 + [fr ? "Contingence" : "Contingency", f(est.contingency)],
146 + [fr ? "COÛT DE REMPLACEMENT À NEUF" : "REPLACEMENT COST NEW", f(est.replacementCostNew), true],
147 + [fr ? "Détérioration physique" : "Physical deterioration", hide ? "•••" : `−${money(d.physical, lang)}`],
148 + [fr ? "Désuétude fonctionnelle" : "Functional obsolescence", hide ? "•••" : `−${money(d.functional, lang)}`],
149 + [fr ? "Désuétude externe" : "External obsolescence", hide ? "•••" : `−${money(d.external, lang)}`],
150 + [fr ? "Valeur dépréciée du bâtiment" : "Depreciated building value", f(d.depreciatedImprovementValue), true],
151 + [fr ? "Valeur du terrain" : "Land value", f(est.landValue)],
152 + [fr ? "INDICATION PAR LE COÛT" : "COST INDICATION", f(est.costApproachValue), true],
153 + ];
154 + const conf = [[fr ? "Fraîcheur" : "Freshness", c.freshness, 20], [fr ? "Couverture" : "Coverage", c.coverage, 20], [fr ? "Localisation" : "Location", c.location, 15], [fr ? "Main-d'œuvre" : "Labour", c.labour, 15], [fr ? "Benchmarks" : "Benchmarks", c.benchmarks, 10], [fr ? "Bâtiment" : "Building", c.building, 20]] as [string, number, number][];
155 + return (
156 + <Block num="08" title={fr ? "Résultat" : "Result"} id="s08" aside={<span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{fr ? "méthode" : "method"} v{est.methodVersion} · {fr ? "base" : "db"} {est.costDatabaseVersion.split("|")[0]} · {est.priceDate}</span>}>
157 + {exercise && structureAnswer}
158 + <div className="grid gap-x-12 gap-y-8 lg:grid-cols-[1.1fr_1fr]">
159 + <div>
160 + <div className="src-wrap">
161 + <table className="src-table">
162 + <tbody>
163 + {rows.map(([k, v, b], i) => (
164 + <tr key={i} className={b ? "bg-surface-2 font-bold" : ""}><td className={b ? "vp-display uppercase tracking-[-0.01em]" : ""}>{k}</td><td className="vp-mono text-right">{v}</td></tr>
165 + ))}
166 + </tbody>
167 + </table>
168 + </div>
169 + <div className="mt-5 border-t-2 border-ink pt-3">
170 + <p className="klabel">{fr ? "Indication de valeur par la méthode du coût" : "Value indication by the cost approach"}</p>
171 + <p className="vp-display mt-1 text-[clamp(32px,5vw,52px)] font-bold tracking-[-0.035em]">{f(est.costApproachValue)}</p>
172 + <p className="vp-mono text-[11px] uppercase tracking-[0.05em] text-ink-2">RCN {hide ? "•••" : `${money(est.range.p10, lang)} – ${money(est.range.p90, lang)}`} (P10 – P90) · {hide ? "•••" : `${num(est.perSqft, lang, 0)} $/pi²`}</p>
173 + {est.warnings.filter((w) => w !== "land_missing" && !w.startsWith("localisation")).length > 0 && <p className="mt-2 text-[12px] text-ink-2">{est.warnings.filter((w) => w !== "land_missing" && !w.startsWith("localisation")).map((w) => ({ condo_common_areas: fr ? "Condo : quote-part des parties communes non incluse." : "Condo: share of common areas not included.", geothermal_as_heatpump: fr ? "Géothermie chiffrée comme thermopompe centrale." : "Geothermal priced as central heat pump.", hydronic_as_furnace: fr ? "Hydronique chiffré comme fournaise électrique + conduits." : "Hydronic priced as electric furnace + ducts.", wood_as_baseboard: fr ? "Chauffage au bois : plinthes d'appoint chiffrées." : "Wood heating: backup baseboards priced." } as Record<string, string>)[w] ?? w).join(" ")}</p>}
174 + {est.warnings.includes("land_missing") && <p className="mt-2 border-l-2 border-[var(--amber)] pl-3 text-[12.5px] text-ink-2">{fr ? "Aucune valeur de terrain : l'indication ne comprend que le bâtiment déprécié (section 07)." : "No land value: the indication only includes the depreciated building (section 07)."}</p>}
175 + </div>
176 + {!hide && <div className="mt-5"><Waterfall lang={lang} steps={[{ label: "RCN", value: est.replacementCostNew, kind: "total" }, { label: fr ? "Dépréciation" : "Depreciation", value: d.total, kind: "minus" }, { label: fr ? "Terrain" : "Land", value: est.landValue, kind: "plus" }, { label: fr ? "Indication" : "Indication", value: est.costApproachValue, kind: "total" }]} /></div>}
177 + <div className="mt-5 flex flex-wrap gap-2.5">
178 + <button type="button" onClick={onSave} disabled={saving} className="btn btn-primary">{saving ? "…" : savedId ? (fr ? "Enregistré ✓ — ré-enregistrer" : "Saved ✓ — save again") : (fr ? "Enregistrer et partager" : "Save and share")}</button>
179 + {savedId && <a href={`/api/cost/report?estimate=${encodeURIComponent(savedId)}`} className="btn btn-accent" target="_blank" rel="noopener">{fr ? "Télécharger le rapport PDF" : "Download PDF report"}</a>}
180 + {!savedId && <button type="button" onClick={onSave} disabled={saving} className="btn btn-ghost" title={fr ? "Enregistre d'abord l'estimation" : "Save the estimate first"}>{fr ? "Rapport PDF (enregistrer d'abord)" : "PDF report (save first)"}</button>}
181 + <button type="button" onClick={() => setShowJson((v) => !v)} className="btn btn-ghost">{showJson ? (fr ? "Masquer le JSON" : "Hide JSON") : (fr ? "Voir le JSON" : "View JSON")}</button>
182 + <button type="button" onClick={onRefreshPrices} className="btn btn-ghost">{fr ? "Recalculer avec les coûts d'aujourd'hui" : "Recompute with today's costs"}</button>
183 + </div>
184 + {savedId && <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Lien partageable" : "Shareable link"} : /cout?estimate={savedId}</p>}
185 + {showJson && <pre className="vp-mono mt-3 max-h-[420px] overflow-auto border border-[var(--line)] bg-surface p-3 text-[10.5px] leading-snug">{JSON.stringify({ id: est.id, priceDate: est.priceDate, methodVersion: est.methodVersion, costDatabaseVersion: est.costDatabaseVersion, assemblyVersion: est.assemblyVersion, location: est.location, directCost: est.directCost, indirectCost: est.indirectCost, contractorOverhead: est.contractorOverhead, contractorProfit: est.contractorProfit, contingency: est.contingency, replacementCostNew: est.replacementCostNew, range: est.range, depreciation: est.depreciation, landValue: est.landValue, costApproachValue: est.costApproachValue, confidence: est.confidence, coverage: est.coverage, benchmarks: est.benchmarks, categories: est.categories, quantities: est.quantities, input: est.input }, null, 2)}</pre>}
186 + </div>
187 + <div className="space-y-7">
188 + <div>
189 + <p className="klabel mb-2">{fr ? "Indice de confiance" : "Confidence index"}</p>
190 + <div className="flex flex-wrap items-start gap-6">
191 + <ConfidenceDial pct={c.total} level={c.letter} label={fr ? "Confiance" : "Confidence"} />
192 + <div className="flex-1 min-w-[220px]">
193 + {conf.map(([k, v, max]) => (
194 + <div key={k} className="grid grid-cols-[110px_1fr_48px] items-center gap-2 py-1 text-[12px]">
195 + <span>{k}</span>
196 + <span className="h-[6px] bg-[var(--line)]"><span className="block h-full bg-ink" style={{ width: `${(v / max) * 100}%` }} /></span>
197 + <span className="vp-mono text-right">{v}/{max}</span>
198 + </div>
199 + ))}
200 + <p className="vp-mono mt-1 text-[11px] font-bold">{c.total} / 100 · <span className={`pill pill-conf-${c.letter}`}>{c.letter}</span></p>
201 + </div>
202 + </div>
203 + {(fr ? c.notesFr : c.notesEn).length > 0 && <ul className="mt-2 space-y-1 text-[12px] text-ink-2">{(fr ? c.notesFr : c.notesEn).map((n) => (<li key={n} className="border-l-2 border-[var(--line-strong)] pl-2">{n}</li>))}</ul>}
204 + <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "couverture observée des matériaux" : "observed material coverage"} {Math.round(est.coverage.materialObservedShare * 100)} % · {est.coverage.assembliesPriced}/{est.coverage.assembliesTotal} {fr ? "assemblages chiffrés" : "assemblies priced"}</p>
205 + </div>
206 + <div>
207 + <p className="klabel mb-2">{fr ? "Validation externe" : "External validation"}</p>
208 + {est.benchmarks.length === 0 ? <p className="text-[13px] text-ink-2">{fr ? "Aucun benchmark externe disponible pour ce type et ce marché (Altus Group : import licencié requis). Le moteur n'est jamais recalé sur un benchmark." : "No external benchmark available for this type and market (Altus Group: licensed import required). The engine is never forced onto a benchmark."}</p> : (
209 + <div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Source" : "Source"}</th><th className="text-right">{fr ? "Benchmark" : "Benchmark"}</th><th className="text-right">Vrai-Prix</th><th>{fr ? "État" : "Status"}</th></tr></thead><tbody>
210 + {est.benchmarks.map((b, i) => (
211 + <tr key={i}><td>{b.source} {b.year}<br /><span className="text-[10.5px] text-ink-3">{b.market} · {b.notes ?? ""}</span></td><td className="vp-mono text-right">{num(b.low, lang)}–{num(b.high, lang)} {b.unit}</td><td className="vp-mono text-right">{hide ? "•••" : `${num(b.estimatePerUnit, lang)} $/pi²`}</td><td className={b.status === "within" ? "text-ink" : "font-bold text-[var(--danger)]"}>{b.status === "within" ? (fr ? "Dans la plage" : "Within range") : `⚠ ${fr ? "Écart significatif" : "Significant deviation"} ${b.deviationPct != null ? pct(b.deviationPct, lang, 0) : ""}`}</td></tr>
212 + ))}
213 + </tbody></table></div>
214 + )}
215 + </div>
216 + <div>
217 + <p className="klabel mb-2">{fr ? "Trois lectures de la valeur — jamais moyennées" : "Three readings of value — never averaged"}</p>
218 + {o ? (
219 + <>
220 + {!hide && <CompareBars lang={lang} rows={[{ label: fr ? "Modèle hédonique" : "Hedonic model", value: o.hedonic }, { label: fr ? "Comparables" : "Comparables", value: o.comparables }, { label: fr ? "Méthode du coût" : "Cost approach", value: est.costApproachValue, accent: true }, { label: fr ? "Rôle 2026" : "Roll 2026", value: o.rollValue }, ...(o.askingPrice ? [{ label: fr ? "Prix demandé" : "Asking price", value: o.askingPrice }] : [])]} />}
221 + <div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">
222 + <div className="kv-cell"><p className="k">{fr ? "Hédonique" : "Hedonic"}</p><p className="v">{money(o.hedonic, lang)}</p></div>
223 + <div className="kv-cell"><p className="k">{fr ? "Comparables" : "Comparables"}</p><p className="v">{money(o.comparables, lang)}</p></div>
224 + <div className="kv-cell"><p className="k">{fr ? "Coût" : "Cost"}</p><p className="v text-accent">{f(est.costApproachValue)}</p></div>
225 + <div className="kv-cell"><p className="k">{fr ? "Mesure Vrai-Prix (hybride)" : "Vrai-Prix measure (hybrid)"}</p><p className="v">{money(o.hybrid, lang)}</p></div>
226 + </div>
227 + {o.rollBuilding != null && !hide && <div className="mt-3"><p className="klabel mb-1">{fr ? "RCN vs valeur du bâtiment au rôle" : "RCN vs building value on the roll"}</p><HBarsMoney lang={lang} rows={[{ label: fr ? "RCN (à neuf)" : "RCN (new)", value: est.replacementCostNew }, { label: fr ? "Bâtiment déprécié (coût)" : "Depreciated building (cost)", value: d.depreciatedImprovementValue }, { label: fr ? "Bâtiment au rôle 2026" : "Building on roll 2026", value: o.rollBuilding }]} /></div>}
228 + <p className="mt-2 text-[12px] leading-relaxed text-ink-2">{fr ? "L'objectif est de comparer les approches : un écart entre le coût et le marché s'explique (dépréciation sous- ou surestimée, terrain, marché tendu), il ne se moyenne pas." : "The teaching goal is to compare approaches: a gap between cost and market is explained (under- or over-estimated depreciation, land, tight market), not averaged."}</p>
229 + </>
230 + ) : <p className="text-[13px] text-ink-2">{fr ? "Mode construction : aucune lecture hédonique ou par comparables (bâtiment hypothétique)." : "Construction mode: no hedonic or comparables reading (hypothetical building)."}</p>}
231 + </div>
232 + {!hide && (
233 + <div className="space-y-5">
234 + <div><p className="klabel mb-1">{fr ? "RCN par catégorie" : "RCN by category"}</p><HBarsMoney lang={lang} total={est.directCost} rows={est.categories.map((k) => ({ label: fr ? k.labelFr : k.labelEn, value: k.adjusted }))} /></div>
235 + <div><p className="klabel mb-1">{fr ? "Matériaux · main-d'œuvre · équipement" : "Materials · labour · equipment"}</p><StackBar lang={lang} parts={[{ label: fr ? "Matériaux" : "Materials", value: est.directMaterial }, { label: fr ? "Main-d'œuvre" : "Labour", value: est.directLabour }, { label: fr ? "Équipement" : "Equipment", value: est.directEquipment }]} /></div>
236 + <div><p className="klabel mb-1">{fr ? "Distribution du RCN (P10 · central · P90)" : "RCN distribution (P10 · central · P90)"}</p><RangeDist low={est.range.p10} central={est.replacementCostNew} high={est.range.p90} lang={lang} /></div>
237 + </div>
238 + )}
239 + </div>
240 + </div>
241 + </Block>
242 + );
243 +}
244 +
245 +/* ------------------------------------------------------------------ 09 sources */
246 +
247 +export function SourcesPanel({ est, fr }: { est: CostEstimate | null; fr: boolean }) {
248 + if (!est) return null;
249 + const agg = new Map<string, { kind: PriceKind; n: number; latest: string | null; url: string | null }>();
250 + for (const l of est.lines) for (const c of l.unitDetail.components) {
251 + for (const p of [c.provenance, c.rateProvenance].filter((x): x is NonNullable<typeof x> => !!x)) {
252 + if (p.kind === "assumption" && !c.itemCode && !c.labourHours) continue;
253 + const k = `${p.source}|${p.kind}`;
254 + const cur = agg.get(k) ?? { kind: p.kind, n: 0, latest: null, url: p.sourceUrl ?? null };
255 + cur.n++;
256 + const dte = p.observedAt ?? p.effectiveDate ?? null;
257 + if (dte && (!cur.latest || dte > cur.latest)) cur.latest = dte;
258 + agg.set(k, cur);
259 + }
260 + }
261 + const rows = [...agg.entries()].map(([k, v]) => ({ source: k.split("|")[0], ...v })).sort((a, b) => b.n - a.n);
262 + return (
263 + <Block num="09" title={fr ? "Sources et transparence" : "Sources and transparency"} id="s09">
264 + <div className="flex flex-wrap gap-2">{(["observed", "ai_inferred", "computed", "sourced_price", "assumption"] as const).map((l) => <LayerBadge key={l} layer={l} fr={fr} />)}</div>
265 + <p className="text-[13px] leading-relaxed text-ink-2">{fr ? "Quatre couches, toujours distinguées : OBSERVÉ (rôle, annonce, fournisseur), INFÉRÉ PAR IA (profil technique d'une annonce), CALCULÉ (géométrie, quantités, formules du moteur), PRIX SOURCÉ (observation détaillant, grille APCHQ/CCQ, indice StatCan). Les hypothèses (productivités, pourcentages, prix de référence) sont étiquetées comme telles." : "Four layers, always distinguished: OBSERVED (roll, listing, supplier), AI-INFERRED (technical profile of a listing), COMPUTED (geometry, quantities, engine formulas), SOURCED PRICE (retail observation, APCHQ/CCQ grid, StatCan index). Assumptions (productivities, percentages, reference prices) are labelled as such."}</p>
266 + <div className="src-wrap">
267 + <table className="src-table">
268 + <thead><tr><th>{fr ? "Source" : "Source"}</th><th>{fr ? "Nature" : "Nature"}</th><th className="text-right">{fr ? "Composants" : "Components"}</th><th>{fr ? "Date la plus récente" : "Most recent date"}</th></tr></thead>
269 + <tbody>
270 + {rows.map((r) => (
271 + <tr key={r.source + r.kind}><td className="font-semibold">{r.url ? <a href={r.url} target="_blank" rel="noopener noreferrer nofollow" className="hover:text-accent-deep">{r.source} ↗</a> : r.source}</td><td className="inline-flex items-center gap-1.5 text-[12.5px]"><KindIcon kind={r.kind} fr={fr} /> {kindLabel(r.kind, fr)}</td><td className="vp-mono text-right">{r.n}</td><td className="vp-mono text-[12px]">{r.latest ? fmtDate(r.latest, fr) : "—"}</td></tr>
272 + ))}
273 + </tbody>
274 + </table>
275 + </div>
276 + <KindLegend fr={fr} />
277 + <p className="vp-mono text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">{fr ? `Localisation : ${est.location.nameFr} (M × ${est.location.materialFactor}, MO × ${est.location.labourFactor}, É × ${est.location.equipmentFactor}) — ${est.location.sourceMethod}. Version de méthode ${est.methodVersion}, base ${est.costDatabaseVersion}, assemblages v${est.assemblyVersion}, prix au ${est.priceDate} : l'estimation est reproductible à l'identique.` : `Location: ${est.location.nameEn} (M × ${est.location.materialFactor}, L × ${est.location.labourFactor}, E × ${est.location.equipmentFactor}) — ${est.location.sourceMethod}. Method ${est.methodVersion}, database ${est.costDatabaseVersion}, assemblies v${est.assemblyVersion}, prices as of ${est.priceDate}: the estimate is exactly reproducible.`}</p>
278 + <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Catégories chiffrées : " : "Categories priced: "}{est.categories.map((c) => categoryLabel(c.category, fr)).join(" · ")}</p>
279 + </Block>
280 + );
281 +}
282 +
283 +export { Toggle };
added src/components/cout/SourceDrawer.tsx +106 −0
@@ -0,0 +1,106 @@
1 +// Vrai-Prix — méthode du coût : tiroir latéral générique + fiche « Source » d'un prix
2 +// (source, date, URL, valeur observée, unité d'origine, conversion, facteur régional, méthode, synchro, confiance).
3 +"use client";
4 +import { useEffect } from "react";
5 +import type { ItemPrice, Provenance } from "@/lib/cost/types";
6 +import { UNIT_LABELS } from "@/lib/cost/units";
7 +import { fmtDate, KindIcon, kindLabel } from "./Provenance";
8 +import { money, num } from "@/components/avendre/fmt";
9 +
10 +export function Drawer({ open, onClose, title, children, fr }: { open: boolean; onClose: () => void; title: string; children: React.ReactNode; fr: boolean }) {
11 + useEffect(() => {
12 + if (!open) return;
13 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
14 + document.addEventListener("keydown", onKey);
15 + const prev = document.body.style.overflow;
16 + document.body.style.overflow = "hidden";
17 + return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
18 + }, [open, onClose]);
19 + if (!open) return null;
20 + return (
21 + <div className="fixed inset-0 z-[var(--z-modal,900)] flex justify-end" role="dialog" aria-modal="true" aria-label={title}>
22 + <button type="button" aria-label={fr ? "Fermer" : "Close"} onClick={onClose} className="absolute inset-0 bg-[rgba(22,24,27,0.45)]" />
23 + <aside className="relative flex h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-[var(--line-strong)] bg-paper shadow-xl">
24 + <div className="sticky top-0 flex items-center justify-between border-b-2 border-ink bg-paper px-5 py-3">
25 + <p className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">{title}</p>
26 + <button type="button" onClick={onClose} className="vp-mono flex h-10 w-10 items-center justify-center text-[15px]" aria-label={fr ? "Fermer" : "Close"}>✕</button>
27 + </div>
28 + <div className="px-5 pb-[max(24px,env(safe-area-inset-bottom))] pt-4">{children}</div>
29 + </aside>
30 + </div>
31 + );
32 +}
33 +
34 +export function KV({ k, v, mono = false }: { k: string; v: React.ReactNode; mono?: boolean }) {
35 + return (
36 + <div className="kv-cell">
37 + <p className="k">{k}</p>
38 + <p className={`v ${mono ? "vp-mono text-[13px] font-medium" : ""}`}>{v}</p>
39 + </div>
40 + );
41 +}
42 +
43 +/** Contenu d'un tiroir source pour un prix canonique d'article. */
44 +export function PriceSourceCard({ price, fr, itemName, unit, locationFactor, locationName, lastSync }: { price: ItemPrice | Provenance | null; fr: boolean; itemName: string; unit?: string; locationFactor?: number; locationName?: string; lastSync?: string | null }) {
45 + const lang = fr ? "fr" : "en";
46 + if (!price) return <p className="text-[13.5px] text-ink-2">{fr ? "Donnée non disponible." : "Data not available."}</p>;
47 + const isItem = "itemCode" in price;
48 + const kind = price.kind;
49 + const unitLabel = unit ? (UNIT_LABELS[unit as keyof typeof UNIT_LABELS]?.[lang] ?? unit) : "";
50 + return (
51 + <div className="space-y-4">
52 + <div>
53 + <p className="klabel">{fr ? "Article" : "Item"}</p>
54 + <p className="vp-display mt-1 text-[17px] font-bold">{itemName}</p>
55 + <p className="mt-1 inline-flex items-center gap-2 text-[12.5px] text-ink-2"><KindIcon kind={kind} fr={fr} source={isItem ? price.sources[0]?.source : price.source} observedAt={price.observedAt} note={price.note} /> {kindLabel(kind, fr)}</p>
56 + </div>
57 + <div className="grid grid-cols-2 gap-x-6 gap-y-2">
58 + {isItem ? (
59 + <>
60 + <KV k={fr ? "Prix canonique" : "Canonical price"} v={`${num(price.price, lang, 2)} $ / ${unitLabel}`} mono />
61 + <KV k={fr ? "Fourchette (min–max sources)" : "Range (min–max sources)"} v={`${num(price.low, lang, 2)} – ${num(price.high, lang, 2)} $`} mono />
62 + <KV k={fr ? "Nombre de sources" : "Source count"} v={price.sourceCount} mono />
63 + <KV k={fr ? "Date d'observation" : "Observation date"} v={fmtDate(price.observedAt, fr)} />
64 + <KV k={fr ? "Confiance" : "Confidence"} v={`${price.confidence} / 100`} mono />
65 + <KV k={fr ? "Méthode" : "Method"} v={fr ? (price.sourceCount >= 2 ? "Médiane robuste multi-source (prix régulier préféré, aberrations écartées)" : price.kind === "indexed" ? "Actualisation par indice StatCan" : price.kind === "reference" ? "Prix de référence interne (hypothèse)" : "Observation unique") : price.sourceCount >= 2 ? "Robust multi-source median (regular price preferred, outliers flagged)" : price.kind === "indexed" ? "StatCan index update" : price.kind === "reference" ? "Internal reference price (assumption)" : "Single observation"} />
66 + </>
67 + ) : (
68 + <>
69 + <KV k={fr ? "Source" : "Source"} v={price.source} />
70 + <KV k={fr ? "Date" : "Date"} v={fmtDate(price.observedAt ?? price.effectiveDate, fr)} />
71 + <KV k={fr ? "Confiance" : "Confidence"} v={`${price.confidence} / 100`} mono />
72 + </>
73 + )}
74 + {locationFactor != null && <KV k={fr ? "Facteur régional (matériaux)" : "Regional factor (materials)"} v={`× ${num(locationFactor, lang, 3)}${locationName ? ` — ${locationName}` : ""}`} mono />}
75 + {lastSync !== undefined && <KV k={fr ? "Dernière synchronisation" : "Last synchronisation"} v={lastSync ? fmtDate(lastSync, fr) : fr ? "Donnée non disponible" : "Data not available"} />}
76 + </div>
77 + {isItem && price.indexed && (
78 + <p className="border-l-2 border-accent pl-3 text-[12.5px] text-ink-2">
79 + {fr ? `Prix du ${fmtDate(price.indexed.fromDate, fr)} actualisé au ${fmtDate(price.indexed.toDate, fr)} avec l'indice ${price.indexed.index} (× ${num(price.indexed.factor, lang, 3)}).` : `Price of ${fmtDate(price.indexed.fromDate, fr)} updated to ${fmtDate(price.indexed.toDate, fr)} with the ${price.indexed.index} index (× ${num(price.indexed.factor, lang, 3)}).`}
80 + </p>
81 + )}
82 + {price.note && <p className="border-l-2 border-[var(--amber)] pl-3 text-[12.5px] text-ink-2">{price.note}</p>}
83 + {isItem && price.sources.length > 0 && (
84 + <div className="src-wrap">
85 + <table className="src-table">
86 + <thead><tr><th>{fr ? "Source" : "Source"}</th><th className="text-right">{fr ? "Valeur observée" : "Observed value"}</th><th>{fr ? "Unité d'origine" : "Original unit"}</th><th>{fr ? "Conversion" : "Conversion"}</th><th>{fr ? "Date" : "Date"}</th></tr></thead>
87 + <tbody>
88 + {price.sources.map((s, i) => (
89 + <tr key={i}>
90 + <td>{s.url ? <a href={s.url} target="_blank" rel="noopener noreferrer nofollow" className="border-b border-transparent text-accent hover:border-accent">{s.source} ↗</a> : s.source}{!s.regular && <span className="vp-mono ml-1 text-[9px] uppercase text-[var(--amber)]">{fr ? "promo" : "sale"}</span>}</td>
91 + <td className="vp-mono text-right">{s.conversionFactor !== 1 ? `${num(s.price / s.conversionFactor, lang, 2)} $` : `${num(s.price, lang, 2)} $`}</td>
92 + <td className="vp-mono text-[12px]">{s.sourceUnit}</td>
93 + <td className="vp-mono text-[12px]">{s.conversionFactor === 1 ? "—" : `× ${s.conversionFactor < 0.01 ? s.conversionFactor.toExponential(2) : num(s.conversionFactor, lang, 4)} → ${num(s.price, lang, 2)} $/${unitLabel}`}</td>
94 + <td className="vp-mono text-[12px]">{s.observedAt}</td>
95 + </tr>
96 + ))}
97 + </tbody>
98 + </table>
99 + </div>
100 + )}
101 + <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Chaque nombre affiché est retraçable : source → observation → conversion → prix canonique → formule → résultat." : "Every displayed number is traceable: source → observation → conversion → canonical price → formula → result."}</p>
102 + </div>
103 + );
104 +}
105 +
106 +export { money };
added src/components/cout/Workbench.tsx +205 −0
@@ -0,0 +1,205 @@
1 +// Vrai-Prix — méthode du coût : l'ATELIER (/cout?property=… | ?mode=construction | ?estimate=…).
2 +// Deux modes, neuf sections, recalcul déterministe à chaque changement (debounce), état local persistant,
3 +// mode exercice (masquer / estimer soi-même / révéler). Aucun calcul métier ici : tout passe par POST /api/cost/estimate.
4 +"use client";
5 +import { useEffect, useRef, useState } from "react";
6 +import Link from "next/link";
7 +import { useLang } from "@/components/LangContext";
8 +import type { AttributeSource, BuildingSpec, CostEstimate, CostInput, CostLocation, CostParams, DepreciationInput, LandInput } from "@/lib/cost/types";
9 +import { defaultInput } from "@/lib/cost/estimate-defaults";
10 +import { money, num } from "@/components/avendre/fmt";
11 +import { PropertyPanel, BuildingFormPanel, type PrefillMeta } from "./BuildingForm";
12 +import { AssemblyLinesPanel, CostTablePanel, LabourPanel } from "./CostPanels";
13 +import { DepreciationPanel, LandPanel, ResultPanel, SourcesPanel } from "./ResultPanels";
14 +import { Toggle } from "./fields";
15 +
16 +interface PrefillResponse extends PrefillMeta { input: CostInput; otherReadings: CostEstimate["otherReadings"]; recent: { id: string; createdAt: string; rcn: number; value: number; letter: string }[] }
17 +
18 +const SECTIONS = ["01", "02", "03", "04", "05", "06", "07", "08", "09"];
19 +const NAMES_FR = ["Propriété", "Caractéristiques", "Coût à neuf", "Assemblages", "Main-d'œuvre", "Dépréciation", "Terrain", "Résultat", "Sources"];
20 +const NAMES_EN = ["Property", "Characteristics", "Cost new", "Assemblies", "Labour", "Depreciation", "Land", "Result", "Sources"];
21 +
22 +export default function Workbench({ propertyId, estimateId, locations }: { propertyId: string | null; estimateId: string | null; locations: CostLocation[] }) {
23 + const { lang } = useLang();
24 + const fr = lang === "fr";
25 + const storageKey = `vrai-prix-cout:${propertyId ?? "construction"}`;
26 + const [input, setInput] = useState<CostInput | null>(null);
27 + const [meta, setMeta] = useState<PrefillMeta | null>(null);
28 + const [est, setEst] = useState<CostEstimate | null>(null);
29 + const [computedKey, setComputedKey] = useState<string>("");
30 + const [error, setError] = useState<string | null>(null);
31 + const [saving, setSaving] = useState(false);
32 + const [savedId, setSavedId] = useState<string | null>(estimateId);
33 + const [exercise, setExercise] = useState(false);
34 + const [hideResult, setHideResult] = useState(false);
35 + const [hideCosts, setHideCosts] = useState(false);
36 + const [answer, setAnswer] = useState("");
37 + const [revealed, setRevealed] = useState(false);
38 + const [restored, setRestored] = useState(false);
39 + const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
40 +
41 + /* ---------------------------------------------------- chargement initial */
42 + useEffect(() => {
43 + let cancelled = false;
44 + (async () => {
45 + try {
46 + if (estimateId) {
47 + const r = await fetch(`/api/cost/estimate/${encodeURIComponent(estimateId)}`);
48 + if (!r.ok) throw new Error((await r.json()).error ?? r.statusText);
49 + const e = (await r.json()) as CostEstimate;
50 + if (cancelled) return;
51 + setEst(e); setInput(e.input); setComputedKey(JSON.stringify(e.input));
52 + setMeta({ unit: null, missing: [], location: e.location, locationMethod: null });
53 + if (e.input.propertyId) {
54 + const p = await fetch(`/api/cost/property?id=${encodeURIComponent(e.input.propertyId)}&readings=0`);
55 + if (p.ok && !cancelled) { const d = (await p.json()) as PrefillResponse; setMeta({ unit: d.unit, missing: d.missing, location: d.location, locationMethod: d.locationMethod }); }
56 + }
57 + return;
58 + }
59 + let saved: CostInput | null = null;
60 + try { const raw = window.localStorage.getItem(storageKey); if (raw) saved = JSON.parse(raw) as CostInput; } catch { /* ignore */ }
61 + if (propertyId) {
62 + const r = await fetch(`/api/cost/property?id=${encodeURIComponent(propertyId)}`);
63 + if (!r.ok) throw new Error((await r.json()).error ?? r.statusText);
64 + const d = (await r.json()) as PrefillResponse;
65 + if (cancelled) return;
66 + setMeta({ unit: d.unit, missing: d.missing, location: d.location, locationMethod: d.locationMethod });
67 + const use = saved && saved.propertyId === propertyId && saved.roll?.totalValue === d.input.roll?.totalValue ? { ...saved, roll: d.input.roll, lat: d.input.lat, lng: d.input.lng } : d.input;
68 + setInput(use); setRestored(!!saved && use === saved);
69 + } else {
70 + const base = saved && saved.mode === "construction" ? saved : defaultInput();
71 + if (cancelled) return;
72 + setInput(base); setRestored(!!saved && saved.mode === "construction");
73 + setMeta({ unit: null, missing: [], location: null, locationMethod: null });
74 + }
75 + } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }
76 + })();
77 + return () => { cancelled = true; };
78 + }, [propertyId, estimateId, storageKey]);
79 +
80 + /* ---------------------------------------------------- recalcul (debounce) */
81 + const key = input ? JSON.stringify(input) : "";
82 + useEffect(() => {
83 + if (!input || key === computedKey) return;
84 + if (timer.current) clearTimeout(timer.current);
85 + timer.current = setTimeout(async () => {
86 + try {
87 + const r = await fetch("/api/cost/estimate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input, save: false }) });
88 + const d = await r.json();
89 + if (!r.ok) throw new Error(d.error ?? r.statusText);
90 + setEst(d as CostEstimate); setComputedKey(key); setError(null);
91 + try { window.localStorage.setItem(storageKey, key); } catch { /* quota */ }
92 + } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
93 + }, 400);
94 + return () => { if (timer.current) clearTimeout(timer.current); };
95 + }, [key, computedKey, input, storageKey]);
96 + const pending = !!input && key !== computedKey;
97 +
98 + /* ----------------------------------------------------------- mutateurs */
99 + const patch = (p: Partial<CostInput>) => setInput((i) => (i ? { ...i, ...p } : i));
100 + const patchB = (p: Partial<BuildingSpec>) => setInput((i) => (i ? { ...i, building: { ...i.building, ...p } } : i));
101 + const patchP = (p: Partial<CostParams>) => setInput((i) => (i ? { ...i, params: { ...i.params, ...p } } : i));
102 + const patchD = (p: Partial<DepreciationInput>) => setInput((i) => (i ? { ...i, depreciation: { ...i.depreciation, ...p } } : i));
103 + const patchL = (p: Partial<LandInput>) => setInput((i) => (i ? { ...i, land: { ...i.land, ...p } } : i));
104 + const setSrc = (k: string, s: AttributeSource) => setInput((i) => (i ? { ...i, attributeSources: { ...i.attributeSources, [k]: s } } : i));
105 + const setQty = (code: string, q: number | null) => setInput((i) => { if (!i) return i; const o = { ...i.quantityOverrides }; if (q == null) delete o[code]; else o[code] = q; return { ...i, quantityOverrides: o }; });
106 + const toggleExclude = (code: string) => setInput((i) => (i ? { ...i, excludedAssemblies: i.excludedAssemblies.includes(code) ? i.excludedAssemblies.filter((c) => c !== code) : [...i.excludedAssemblies, code] } : i));
107 +
108 + const save = async () => {
109 + if (!input) return;
110 + setSaving(true);
111 + try {
112 + const r = await fetch("/api/cost/estimate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input, save: true }) });
113 + const d = await r.json();
114 + if (!r.ok) throw new Error(d.error ?? r.statusText);
115 + setEst(d as CostEstimate); setComputedKey(key); setSavedId((d as CostEstimate).id);
116 + const url = new URL(window.location.href); url.searchParams.set("estimate", (d as CostEstimate).id); window.history.replaceState(null, "", url.toString());
117 + } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); }
118 + };
119 + const refreshPrices = () => { patch({ priceDate: null }); setComputedKey(""); };
120 + const reset = () => { try { window.localStorage.removeItem(storageKey); } catch { /* ignore */ } window.location.href = propertyId ? `/cout?property=${encodeURIComponent(propertyId)}` : "/cout?mode=construction"; };
121 +
122 + if (error && !input) return <div className="py-16"><p className="vp-display text-[22px] font-bold">{fr ? "Impossible de charger l'atelier" : "Cannot load the workshop"}</p><p className="mt-2 text-ink-2">{error}</p><Link href="/cout" className="btn btn-ghost mt-4">← {fr ? "Retour" : "Back"}</Link></div>;
123 + if (!input) return <div className="py-16 vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-3">{fr ? "Chargement de la propriété…" : "Loading property…"}</div>;
124 +
125 + const hide = exercise && hideResult;
126 + const hideC = exercise && hideCosts;
127 + const structure = est?.categories.find((c) => c.category === "structure");
128 + const structureAnswer = (
129 + <div className="border border-[var(--line-strong)] bg-surface p-4">
130 + <p className="kicker">{fr ? "Exercice" : "Exercise"}</p>
131 + <p className="vp-display mt-1 text-[16px] font-bold">{fr ? "Calculez le coût direct de la structure." : "Compute the direct cost of the structure."}</p>
132 + <p className="mt-1 text-[12.5px] text-ink-2">{fr ? "À partir des lignes de la catégorie Structure (section 04) : Σ quantité × coût unitaire, puis × facteur régional. Saisissez votre réponse, puis comparez." : "From the Structure lines (section 04): Σ quantity × unit cost, then × regional factor. Enter your answer, then compare."}</p>
133 + <div className="mt-2 flex flex-wrap items-center gap-3">
134 + <input value={answer} onChange={(e) => setAnswer(e.target.value)} inputMode="numeric" placeholder="$" className="vp-input vp-mono max-w-[200px] text-[14px]" />
135 + <button type="button" className="btn btn-primary !min-h-0 !py-2" onClick={() => setRevealed(true)}>{fr ? "Voir la correction" : "See the correction"}</button>
136 + {revealed && <button type="button" className="btn btn-ghost !min-h-0 !py-2" onClick={() => { setRevealed(false); setAnswer(""); }}>{fr ? "Recommencer" : "Try again"}</button>}
137 + </div>
138 + {revealed && structure && est && (
139 + <div className="mt-3">
140 + <p className="vp-mono text-[12px]">{fr ? "Réponse du moteur" : "Engine answer"} : <b>{money(structure.adjusted, lang)}</b>{answer && Number(answer.replace(/[^\d.]/g, "")) > 0 ? ` · ${fr ? "votre réponse" : "your answer"} ${money(Number(answer.replace(/[^\d.]/g, "")), lang)} (${fr ? "écart" : "gap"} ${num(((Number(answer.replace(/[^\d.]/g, "")) / structure.adjusted) - 1) * 100, lang, 1)} %)` : ""}</p>
141 + <div className="src-wrap mt-2"><table className="src-table"><thead><tr><th>{fr ? "Assemblage" : "Assembly"}</th><th className="text-right">{fr ? "Quantité" : "Quantity"}</th><th className="text-right">×</th><th className="text-right">{fr ? "Coût unitaire" : "Unit cost"}</th><th className="text-right">=</th><th className="text-right">{fr ? "Direct" : "Direct"}</th></tr></thead><tbody>
142 + {est.lines.filter((l) => l.category === "structure").map((l) => (<tr key={l.assemblyCode}><td>{fr ? l.nameFr : l.nameEn}</td><td className="vp-mono text-right">{num(l.quantity, lang, 1)} {l.unit}</td><td className="text-right">×</td><td className="vp-mono text-right">{num(l.unitCost, lang, 2)} $</td><td className="text-right">=</td><td className="vp-mono text-right">{money(l.direct, lang)}</td></tr>))}
143 + <tr className="bg-surface-2 font-bold"><td colSpan={5}>{fr ? "Direct × facteur régional" : "Direct × regional factor"} (M {est.location.materialFactor} · MO {est.location.labourFactor} · É {est.location.equipmentFactor})</td><td className="vp-mono text-right">{money(structure.adjusted, lang)}</td></tr>
144 + </tbody></table></div>
145 + </div>
146 + )}
147 + </div>
148 + );
149 +
150 + return (
151 + <div className="py-8">
152 + {/* masthead */}
153 + <section className="rise">
154 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">
155 + <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
156 + <Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai&nbsp;Prix</Link><span aria-hidden="true">/</span>
157 + <Link href="/cout" className="text-ink-2 hover:text-accent-deep">{fr ? "Coût" : "Cost"}</Link><span aria-hidden="true">/</span>
158 + <span>{input.mode === "property" ? (fr ? "Atelier — propriété" : "Workshop — property") : (fr ? "Atelier — construction" : "Workshop — construction")}</span>
159 + </nav>
160 + <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{pending ? (fr ? "recalcul…" : "recomputing…") : est ? `${fr ? "calculé en" : "computed"} ${est.priceDate}` : ""}</span>
161 + </div>
162 + <div className="mt-5 grid gap-x-10 gap-y-4 lg:grid-cols-[1.5fr_1fr]">
163 + <div>
164 + <span className="kicker">{fr ? "Méthode du coût — atelier" : "Cost approach — workshop"}</span>
165 + <h1 className="vp-display mt-2 text-[clamp(24px,3.6vw,38px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{input.address ?? (fr ? "Bâtiment hypothétique" : "Hypothetical building")}</h1>
166 + <p className="mt-1 text-[14px] text-ink-2">{input.municipality ?? (fr ? "Sans adresse — décrivez le bâtiment" : "No address — describe the building")}{est ? ` · ${fr ? est.location.nameFr : est.location.nameEn}` : ""}</p>
167 + </div>
168 + <aside className="flex flex-col gap-2 lg:items-end">
169 + <div className="flex flex-wrap gap-2">
170 + <button type="button" aria-pressed={exercise} onClick={() => setExercise((v) => !v)} className={`btn !min-h-0 !px-3 !py-1.5 text-[12px] ${exercise ? "btn-primary" : "btn-ghost"}`}>✎ {fr ? "Mode exercice" : "Exercise mode"}</button>
171 + <button type="button" onClick={reset} className="btn btn-ghost !min-h-0 !px-3 !py-1.5 text-[12px]">{fr ? "Réinitialiser" : "Reset"}</button>
172 + </div>
173 + {exercise && (
174 + <div className="flex flex-wrap gap-x-4 gap-y-1">
175 + <Toggle checked={hideResult} onChange={setHideResult} label={fr ? "Masquer le résultat final" : "Hide final result"} />
176 + <Toggle checked={hideCosts} onChange={setHideCosts} label={fr ? "Masquer les coûts des lignes" : "Hide line costs"} />
177 + </div>
178 + )}
179 + {restored && <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "état restauré depuis cet appareil" : "state restored from this device"}</p>}
180 + </aside>
181 + </div>
182 + {/* sommaire des sections */}
183 + <ol className="vp-mono mt-5 flex flex-wrap gap-x-4 gap-y-1.5 border-y border-[var(--line)] py-2.5 text-[10px] uppercase tracking-[0.08em]">
184 + {SECTIONS.map((s, i) => (<li key={s}><a href={`#s${s}`} className="text-ink-2 hover:text-accent"><b className="text-accent">{s}</b> {fr ? NAMES_FR[i] : NAMES_EN[i]}</a></li>))}
185 + </ol>
186 + {error && <p className="mt-3 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-[var(--danger)]">{error}</p>}
187 + </section>
188 +
189 + <div className="mt-8 space-y-12">
190 + <PropertyPanel input={input} meta={meta} fr={fr} locations={locations} patch={patch} setSrc={setSrc} />
191 + <BuildingFormPanel b={input.building} src={input.attributeSources} fr={fr} patchB={patchB} setSrc={setSrc} />
192 + <CostTablePanel est={est} params={input.params} fr={fr} patchP={patchP} hide={hide || hideC} />
193 + <AssemblyLinesPanel est={est} input={input} fr={fr} setQty={setQty} toggleExclude={toggleExclude} hide={hideC} />
194 + <LabourPanel est={est} fr={fr} hide={hideC} />
195 + <DepreciationPanel est={est} dep={input.depreciation} fr={fr} patchD={patchD} hide={hide} />
196 + <LandPanel input={input} fr={fr} patchL={patchL} />
197 + <ResultPanel est={est} fr={fr} hide={hide} exercise={exercise} onSave={save} saving={saving} savedId={savedId} onRefreshPrices={refreshPrices} structureAnswer={structureAnswer} />
198 + <SourcesPanel est={est} fr={fr} />
199 + </div>
200 + <p className="vp-mono mt-10 border-l-2 border-accent pl-4 text-[11px] uppercase leading-relaxed tracking-[0.05em] text-ink-2">
201 + {fr ? "Estimation indicative — les coûts présentés sont des estimations statistiques et ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ)." : "Building analysis — the costs shown are estimates and constitute neither a contractor's bid nor a certified professional appraisal (OEAQ)."}
202 + </p>
203 + </div>
204 + );
205 +}
added src/components/cout/fields.tsx +87 −0
@@ -0,0 +1,87 @@
1 +// Vrai-Prix — méthode du coût : primitives de formulaire de l'atelier (champs éditoriaux, parts en %, blocs).
2 +"use client";
3 +import type { AttributeSource } from "@/lib/cost/types";
4 +import { AttrBadge } from "./Provenance";
5 +
6 +export function Block({ num, title, children, id, aside }: { num: string; title: string; children: React.ReactNode; id?: string; aside?: React.ReactNode }) {
7 + return (
8 + <section id={id} className="sec scroll-mt-24">
9 + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
10 + <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.12em] text-accent">{num} — {title}</p>
11 + {aside}
12 + </div>
13 + <div className="mt-4 space-y-4">{children}</div>
14 + </section>
15 + );
16 +}
17 +
18 +export function Field({ label, children, hint, source, fr }: { label: string; children: React.ReactNode; hint?: string; source?: AttributeSource | null; fr?: boolean }) {
19 + return (
20 + <label className="block" title={hint}>
21 + <span className="flex items-center gap-2"><span className="klabel">{label}</span>{source && fr != null && <AttrBadge source={source} fr={fr} />}</span>
22 + <div className="mt-1">{children}</div>
23 + </label>
24 + );
25 +}
26 +
27 +export function NumField({ value, onChange, min, max, step, unit, placeholder, disabled }: { value: number | null | undefined; onChange: (v: number | null) => void; min?: number; max?: number; step?: number; unit?: string; placeholder?: string; disabled?: boolean }) {
28 + return (
29 + <div className="flex items-baseline gap-2">
30 + <input type="number" inputMode="decimal" value={value ?? ""} min={min} max={max} step={step ?? 1} placeholder={placeholder} disabled={disabled}
31 + onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} className="vp-input vp-mono text-[14px]" />
32 + {unit && <span className="vp-mono shrink-0 text-[10.5px] uppercase text-ink-3">{unit}</span>}
33 + </div>
34 + );
35 +}
36 +
37 +export function SelectField<T extends string>({ value, onChange, options }: { value: T; onChange: (v: T) => void; options: { key: T; label: string }[] }) {
38 + return (
39 + <select value={value} onChange={(e) => onChange(e.target.value as T)} className="vp-input text-[14px]">
40 + {options.map((o) => (<option key={o.key} value={o.key}>{o.label}</option>))}
41 + </select>
42 + );
43 +}
44 +
45 +export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) {
46 + return (
47 + <label className="flex items-center gap-2 text-[13.5px]">
48 + <input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="accent-[var(--accent)]" />
49 + {label}
50 + </label>
51 + );
52 +}
53 +
54 +/** Parts en % d'un mélange (revêtements, planchers) — la somme est normalisée par le moteur. */
55 +export function MixField<K extends string>({ value, onChange, options, fr }: { value: Partial<Record<K, number>>; onChange: (v: Partial<Record<K, number>>) => void; options: { key: K; fr: string; en: string }[]; fr: boolean }) {
56 + const total = Object.values(value as Record<string, number>).reduce((s, v) => s + (v || 0), 0);
57 + return (
58 + <div>
59 + <div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
60 + {options.map((o) => (
61 + <label key={o.key} className="flex items-center justify-between gap-2 text-[12.5px]">
62 + <span>{fr ? o.fr : o.en}</span>
63 + <span className="flex items-baseline gap-1">
64 + <input type="number" inputMode="numeric" min={0} max={100} value={value[o.key] != null ? Math.round((value[o.key] as number) * 100) : ""} placeholder="0"
65 + onChange={(e) => { const n = { ...value }; const v = Number(e.target.value); if (!e.target.value || v <= 0) delete n[o.key]; else n[o.key] = v / 100; onChange(n); }}
66 + className="vp-input vp-mono !min-h-0 w-[56px] !py-1 text-right text-[13px]" />
67 + <span className="vp-mono text-[10px] text-ink-3">%</span>
68 + </span>
69 + </label>
70 + ))}
71 + </div>
72 + <p className={`vp-mono mt-1 text-[10px] uppercase tracking-[0.05em] ${Math.abs(total - 1) > 0.02 && total > 0 ? "text-[var(--amber)]" : "text-ink-3"}`}>
73 + {fr ? "Total" : "Total"} {Math.round(total * 100)} % {total > 0 && Math.abs(total - 1) > 0.02 ? (fr ? "— normalisé à 100 % par le moteur" : "— normalised to 100 % by the engine") : ""}
74 + </p>
75 + </div>
76 + );
77 +}
78 +
79 +export function PctField({ value, onChange, label, note, amount, fr, lang }: { value: number; onChange: (v: number) => void; label: string; note?: string; amount?: number; fr: boolean; lang: string }) {
80 + return (
81 + <div className="grid grid-cols-[1fr_84px_110px] items-center gap-3 border-b border-[var(--line)] py-1.5 text-[13px]" title={note}>
82 + <span className="flex items-center gap-1.5">{label}{note && <span className="vp-mono cursor-help text-[10px] text-ink-3" aria-label={note}>ⓘ</span>}</span>
83 + <span className="flex items-baseline gap-1"><input type="number" inputMode="decimal" step={0.1} min={0} max={40} value={value} onChange={(e) => onChange(Number(e.target.value) || 0)} className="vp-input vp-mono !min-h-0 !py-1 text-right text-[13px]" /><span className="vp-mono text-[10px] text-ink-3">%</span></span>
84 + <span className="vp-mono text-right text-[12.5px]">{amount != null ? new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(amount) : (fr ? "—" : "—")}</span>
85 + </div>
86 + );
87 +}
added src/data/marche-stats.json +1 −0
@@ -0,0 +1 @@
1 +{"generated":"2026-09-06T04:18:54.297Z","source":{"app":"Immo-Ka","copiedAt":"2026-09-06","totalListings":70512,"geolocated":66787,"evaluated":66070,"valid":64722,"matchMedianM":14.3},"global":{"n":64722,"medianRatio":0.9694,"mdape":0.2186,"within10":0.2675,"within20":0.4703,"askAbove":0.544,"inP10P90":0.6085,"medianAsk":500000,"medianEst":489200,"medianDiff":15000,"medianDiffPct":0.0316,"p25Diff":-79600,"p75Diff":138900},"methods":[{"key":"hybride","main":true,"n":64722,"coverage":1,"medianRatio":0.9694,"mdape":0.2186,"within10":0.2675,"within20":0.4703,"medianDiff":15000},{"key":"hedonique","main":false,"n":64722,"coverage":1,"medianRatio":0.9413,"mdape":0.1908,"within10":0.3142,"within20":0.5147,"medianDiff":28300},{"key":"comparables","main":false,"n":60392,"coverage":0.9331,"medianRatio":0.9981,"mdape":0.3042,"within10":0.2052,"within20":0.3673,"medianDiff":900},{"key":"cout","main":false,"n":48759,"coverage":0.7534,"medianRatio":0.9506,"mdape":0.2231,"within10":0.2434,"within20":0.4575,"medianDiff":25100},{"key":"role_indexe","main":false,"n":61869,"coverage":0.9559,"medianRatio":0.949,"mdape":0.1852,"within10":0.3138,"within20":0.5237,"medianDiff":23100},{"key":"ensemble","main":false,"n":64722,"coverage":1,"medianRatio":0.9644,"mdape":0.1918,"within10":0.3013,"within20":0.5132,"medianDiff":17300}],"byConfidence":[{"key":"A","n":17590,"medianRatio":1.0087,"mdape":0.1212,"within10":0.4327,"within20":0.6912,"askAbove":0.4769,"inP10P90":0.6392,"medianAsk":539650,"medianEst":545650,"medianDiff":-4800,"medianDiffPct":-0.0086,"p25Diff":-68500,"p75Diff":61500},{"key":"B","n":19534,"medianRatio":0.9506,"mdape":0.1851,"within10":0.2888,"within20":0.5327,"askAbove":0.5792,"inP10P90":0.609,"medianAsk":549900,"medianEst":527900,"medianDiff":25800,"medianDiffPct":0.0519,"p25Diff":-68000,"p75Diff":136000},{"key":"C","n":11974,"medianRatio":0.8923,"mdape":0.2776,"within10":0.1967,"within20":0.3772,"askAbove":0.6152,"inP10P90":0.596,"medianAsk":521000,"medianEst":468600,"medianDiff":49400,"medianDiffPct":0.1207,"p25Diff":-68000,"p75Diff":228400},{"key":"D","n":15624,"medianRatio":0.9609,"mdape":0.5155,"within10":0.109,"within20":0.2147,"askAbove":0.5208,"inP10P90":0.5828,"medianAsk":360000,"medianEst":332700,"medianDiff":11100,"medianDiffPct":0.0407,"p25Diff":-137100,"p75Diff":235400}],"byType":[{"key":"Maison","n":34333,"medianRatio":0.914,"mdape":0.1626,"within10":0.3386,"within20":0.5783,"askAbove":0.6729,"inP10P90":0.6588,"medianAsk":569000,"medianEst":503200,"medianDiff":45600,"medianDiffPct":0.0941,"p25Diff":-22300,"p75Diff":152800},{"key":"Condo","n":11735,"medianRatio":1.1768,"mdape":0.2179,"within10":0.2478,"within20":0.4637,"askAbove":0.2275,"inP10P90":0.6275,"medianAsk":424000,"medianEst":502000,"medianDiff":-68900,"medianDiffPct":-0.1502,"p25Diff":-143400,"p75Diff":-7300},{"key":"Terrain","n":8848,"medianRatio":1.64,"mdape":0.7796,"within10":0.0766,"within20":0.1498,"askAbove":0.2864,"inP10P90":0.4604,"medianAsk":179000,"medianEst":324900,"medianDiff":-100350,"medianDiffPct":-0.3903,"p25Diff":-223800,"p75Diff":25900},{"key":"Commercial","n":2425,"medianRatio":0.6388,"mdape":0.4928,"within10":0.1039,"within20":0.2049,"askAbove":0.7534,"inP10P90":0.3814,"medianAsk":849900,"medianEst":551500,"medianDiff":271600,"medianDiffPct":0.5654,"p25Diff":3500,"p75Diff":959400},{"key":"Duplex","n":1947,"medianRatio":0.9247,"mdape":0.1687,"within10":0.3123,"within20":0.5603,"askAbove":0.6333,"inP10P90":0.7745,"medianAsk":599900,"medianEst":518100,"medianDiff":41500,"medianDiffPct":0.0815,"p25Diff":-39200,"p75Diff":140600},{"key":"Triplex","n":1241,"medianRatio":0.8782,"mdape":0.1958,"within10":0.2627,"within20":0.5052,"askAbove":0.697,"inP10P90":0.6575,"medianAsk":875000,"medianEst":732700,"medianDiff":95400,"medianDiffPct":0.1387,"p25Diff":-30400,"p75Diff":253900},{"key":"Multiplex","n":1054,"medianRatio":0.7447,"mdape":0.306,"within10":0.166,"within20":0.3501,"askAbove":0.7998,"inP10P90":0.518,"medianAsk":1150000,"medianEst":761250,"medianDiff":255700,"medianDiffPct":0.3429,"p25Diff":43200,"p75Diff":691100},{"key":"Propriétés résidentielles","n":969,"medianRatio":0.8356,"mdape":0.3479,"within10":0.1692,"within20":0.3302,"askAbove":0.6852,"inP10P90":0.4438,"medianAsk":1169000,"medianEst":888600,"medianDiff":158100,"medianDiffPct":0.1968,"p25Diff":-82800,"p75Diff":595200},{"key":"Fermette/Agricole","n":628,"medianRatio":0.4853,"mdape":0.5208,"within10":0.0605,"within20":0.1481,"askAbove":0.9268,"inP10P90":0.2675,"medianAsk":899000,"medianEst":441900,"medianDiff":445400,"medianDiffPct":1.0607,"p25Diff":183800,"p75Diff":985500},{"key":"Jumelé","n":417,"medianRatio":0.9771,"mdape":0.0807,"within10":0.5659,"within20":0.7794,"askAbove":0.5827,"inP10P90":0.7338,"medianAsk":489900,"medianEst":478800,"medianDiff":10900,"medianDiffPct":0.0234,"p25Diff":-26100,"p75Diff":50100}],"byRegion":[{"key":"Laurentides","n":4407,"medianRatio":0.9499,"mdape":0.265,"within10":0.2333,"within20":0.4089,"askAbove":0.5661,"inP10P90":0.5773,"medianAsk":525000,"medianEst":475400,"medianDiff":28000,"medianDiffPct":0.0527,"p25Diff":-89400,"p75Diff":171600},{"key":"Montreal","n":4391,"medianRatio":1.0653,"mdape":0.1811,"within10":0.3054,"within20":0.5363,"askAbove":0.3894,"inP10P90":0.673,"medianAsk":599000,"medianEst":686700,"medianDiff":-35800,"medianDiffPct":-0.0613,"p25Diff":-139801,"p75Diff":75600},{"key":"Outaouais","n":2460,"medianRatio":0.9803,"mdape":0.188,"within10":0.3301,"within20":0.524,"askAbove":0.5366,"inP10P90":0.6398,"medianAsk":469400,"medianEst":439000,"medianDiff":8700,"medianDiffPct":0.0201,"p25Diff":-53500,"p75Diff":102300},{"key":"Lanaudiere","n":2335,"medianRatio":0.9347,"mdape":0.1918,"within10":0.3024,"within20":0.5066,"askAbove":0.6154,"inP10P90":0.6385,"medianAsk":549000,"medianEst":482500,"medianDiff":38900,"medianDiffPct":0.0698,"p25Diff":-52600,"p75Diff":147200},{"key":"Monteregie","n":2292,"medianRatio":0.9338,"mdape":0.1642,"within10":0.3438,"within20":0.5698,"askAbove":0.6313,"inP10P90":0.6527,"medianAsk":599000,"medianEst":539950,"medianDiff":42300,"medianDiffPct":0.0709,"p25Diff":-39300,"p75Diff":161800},{"key":"Estrie","n":2146,"medianRatio":0.9043,"mdape":0.2396,"within10":0.2265,"within20":0.4338,"askAbove":0.6319,"inP10P90":0.6011,"medianAsk":506750,"medianEst":448500,"medianDiff":44350,"medianDiffPct":0.1059,"p25Diff":-54500,"p75Diff":191899},{"key":"Laval Rive Nord","n":1384,"medianRatio":0.9827,"mdape":0.127,"within10":0.4104,"within20":0.6712,"askAbove":0.539,"inP10P90":0.6929,"medianAsk":617450,"medianEst":587100,"medianDiff":9400,"medianDiffPct":0.0176,"p25Diff":-61100,"p75Diff":100000},{"key":"Mauricie","n":1289,"medianRatio":0.9386,"mdape":0.3013,"within10":0.2009,"within20":0.3856,"askAbove":0.5749,"inP10P90":0.5733,"medianAsk":364900,"medianEst":324000,"medianDiff":22800,"medianDiffPct":0.0654,"p25Diff":-72000,"p75Diff":130900},{"key":"Chaudiere Appalaches","n":1285,"medianRatio":0.8881,"mdape":0.2141,"within10":0.2568,"within20":0.4739,"askAbove":0.6669,"inP10P90":0.5984,"medianAsk":374900,"medianEst":314200,"medianDiff":37930,"medianDiffPct":0.126,"p25Diff":-18300,"p75Diff":154000},{"key":"Saguenay Lac Saint Jean","n":960,"medianRatio":0.9108,"mdape":0.2084,"within10":0.2542,"within20":0.4781,"askAbove":0.6292,"inP10P90":0.6073,"medianAsk":366750,"medianEst":333750,"medianDiff":32500,"medianDiffPct":0.098,"p25Diff":-35000,"p75Diff":121600},{"key":"Montérégie Rive Sud Montréal","n":947,"medianRatio":0.9667,"mdape":0.1733,"within10":0.2925,"within20":0.5628,"askAbove":0.5459,"inP10P90":0.6146,"medianAsk":619000,"medianEst":579600,"medianDiff":19600,"medianDiffPct":0.0344,"p25Diff":-83300,"p75Diff":124200},{"key":"Region De Quebec","n":935,"medianRatio":0.9807,"mdape":0.1672,"within10":0.3102,"within20":0.5829,"askAbove":0.5422,"inP10P90":0.6941,"medianAsk":469000,"medianEst":463100,"medianDiff":10900,"medianDiffPct":0.0197,"p25Diff":-54800,"p75Diff":118900},{"key":"Centre Du Quebec","n":840,"medianRatio":0.8774,"mdape":0.1669,"within10":0.3226,"within20":0.5607,"askAbove":0.744,"inP10P90":0.6536,"medianAsk":449900,"medianEst":382700,"medianDiff":55350,"medianDiffPct":0.1398,"p25Diff":-800,"p75Diff":137700},{"key":"Rive Sud De Montreal","n":839,"medianRatio":0.9571,"mdape":0.1113,"within10":0.4589,"within20":0.7223,"askAbove":0.6281,"inP10P90":0.7223,"medianAsk":699000,"medianEst":652200,"medianDiff":31700,"medianDiffPct":0.0448,"p25Diff":-33600,"p75Diff":127400},{"key":"Bas Saint Laurent","n":622,"medianRatio":0.8695,"mdape":0.257,"within10":0.2219,"within20":0.3939,"askAbove":0.6559,"inP10P90":0.5788,"medianAsk":333600,"medianEst":275400,"medianDiff":38200,"medianDiffPct":0.15,"p25Diff":-20800,"p75Diff":147400},{"key":"Québec Rive Nord","n":528,"medianRatio":0.9872,"mdape":0.1928,"within10":0.2936,"within20":0.5208,"askAbove":0.5133,"inP10P90":0.6117,"medianAsk":525000,"medianEst":485150,"medianDiff":6250,"medianDiffPct":0.013,"p25Diff":-71700,"p75Diff":127200},{"key":"Québec","n":491,"medianRatio":0.9704,"mdape":0.1659,"within10":0.3585,"within20":0.5499,"askAbove":0.5418,"inP10P90":0.6517,"medianAsk":529000,"medianEst":527800,"medianDiff":13300,"medianDiffPct":0.0305,"p25Diff":-62300,"p75Diff":116800},{"key":"Gaspesie Iles De La Madeleine","n":367,"medianRatio":0.838,"mdape":0.3057,"within10":0.1635,"within20":0.3597,"askAbove":0.6866,"inP10P90":0.5477,"medianAsk":265000,"medianEst":226700,"medianDiff":43300,"medianDiffPct":0.1933,"p25Diff":-23300,"p75Diff":137400}],"byCity":[{"key":"Montréal","n":5617,"medianRatio":1.0703,"mdape":0.2033,"within10":0.2873,"within20":0.4939,"askAbove":0.3924,"inP10P90":0.6571,"medianAsk":649000,"medianEst":740300,"medianDiff":-38900,"medianDiffPct":-0.0657,"p25Diff":-166700,"p75Diff":87300},{"key":"Québec","n":1379,"medianRatio":1.0243,"mdape":0.1895,"within10":0.2973,"within20":0.5257,"askAbove":0.4663,"inP10P90":0.6519,"medianAsk":459900,"medianEst":486100,"medianDiff":-12300,"medianDiffPct":-0.0238,"p25Diff":-78400,"p75Diff":101100},{"key":"Laval","n":1231,"medianRatio":0.9753,"mdape":0.1478,"within10":0.3582,"within20":0.6231,"askAbove":0.5516,"inP10P90":0.632,"medianAsk":639900,"medianEst":589200,"medianDiff":16800,"medianDiffPct":0.0253,"p25Diff":-56700,"p75Diff":154400},{"key":"Sherbrooke","n":1036,"medianRatio":0.9387,"mdape":0.1933,"within10":0.2857,"within20":0.5222,"askAbove":0.5985,"inP10P90":0.6718,"medianAsk":495000,"medianEst":446050,"medianDiff":27350,"medianDiffPct":0.0654,"p25Diff":-53800,"p75Diff":137300},{"key":"Laval / North Shore","n":928,"medianRatio":0.9799,"mdape":0.1484,"within10":0.3858,"within20":0.5862,"askAbove":0.5431,"inP10P90":0.6659,"medianAsk":549000,"medianEst":535650,"medianDiff":12000,"medianDiffPct":0.0205,"p25Diff":-73100,"p75Diff":87900},{"key":"Gatineau","n":831,"medianRatio":1.0369,"mdape":0.1274,"within10":0.4116,"within20":0.6438,"askAbove":0.4116,"inP10P90":0.6943,"medianAsk":464999,"medianEst":476500,"medianDiff":-16500,"medianDiffPct":-0.0356,"p25Diff":-66500,"p75Diff":47000},{"key":"City of Montréal","n":769,"medianRatio":1.1214,"mdape":0.2418,"within10":0.2549,"within20":0.446,"askAbove":0.3459,"inP10P90":0.5956,"medianAsk":549999,"medianEst":683100,"medianDiff":-70100,"medianDiffPct":-0.1082,"p25Diff":-238000,"p75Diff":49800},{"key":"Lévis","n":703,"medianRatio":0.9492,"mdape":0.1846,"within10":0.3243,"within20":0.5235,"askAbove":0.5974,"inP10P90":0.6387,"medianAsk":450000,"medianEst":425600,"medianDiff":20500,"medianDiffPct":0.0535,"p25Diff":-43165,"p75Diff":139500},{"key":"Laurentides","n":692,"medianRatio":0.9455,"mdape":0.3059,"within10":0.2009,"within20":0.3772,"askAbove":0.5636,"inP10P90":0.5405,"medianAsk":563150,"medianEst":488050,"medianDiff":34400,"medianDiffPct":0.0577,"p25Diff":-115700,"p75Diff":209500},{"key":"Longueuil","n":677,"medianRatio":1.0453,"mdape":0.1822,"within10":0.3087,"within20":0.5347,"askAbove":0.4313,"inP10P90":0.6366,"medianAsk":559000,"medianEst":569900,"medianDiff":-24500,"medianDiffPct":-0.0433,"p25Diff":-99586,"p75Diff":74000},{"key":"Trois-Rivières","n":647,"medianRatio":0.9375,"mdape":0.16,"within10":0.3338,"within20":0.5997,"askAbove":0.6182,"inP10P90":0.6955,"medianAsk":450000,"medianEst":411000,"medianDiff":28700,"medianDiffPct":0.0667,"p25Diff":-30612,"p75Diff":118000},{"key":"Saguenay","n":633,"medianRatio":0.9418,"mdape":0.1854,"within10":0.3096,"within20":0.5403,"askAbove":0.6003,"inP10P90":0.6288,"medianAsk":392639,"medianEst":375100,"medianDiff":23700,"medianDiffPct":0.0618,"p25Diff":-41400,"p75Diff":102600},{"key":"Longueuil / South Shore","n":596,"medianRatio":1.0062,"mdape":0.1535,"within10":0.3641,"within20":0.5822,"askAbove":0.4883,"inP10P90":0.6594,"medianAsk":596450,"medianEst":579350,"medianDiff":-3650,"medianDiffPct":-0.0061,"p25Diff":-91300,"p75Diff":83700},{"key":"Granby","n":582,"medianRatio":0.9571,"mdape":0.1685,"within10":0.3333,"within20":0.579,"askAbove":0.5808,"inP10P90":0.6581,"medianAsk":499900,"medianEst":483400,"medianDiff":23000,"medianDiffPct":0.0448,"p25Diff":-53300,"p75Diff":109000},{"key":"Québec City","n":572,"medianRatio":1.0217,"mdape":0.1713,"within10":0.3252,"within20":0.5752,"askAbove":0.4615,"inP10P90":0.6783,"medianAsk":443950,"medianEst":450100,"medianDiff":-9100,"medianDiffPct":-0.0212,"p25Diff":-74800,"p75Diff":70100}],"byBracket":[{"key":"<300k","n":13394,"medianRatio":1.4573,"mdape":0.4867,"within10":0.1327,"within20":0.2571,"askAbove":0.2031,"inP10P90":0.5557,"medianAsk":199000,"medianEst":290000,"medianDiff":-87250,"medianDiffPct":-0.3138,"p25Diff":-206500,"p75Diff":-13400},{"key":"300-500k","n":18909,"medianRatio":1.0438,"mdape":0.1667,"within10":0.33,"within20":0.571,"askAbove":0.4188,"inP10P90":0.717,"medianAsk":399900,"medianEst":423900,"medianDiff":-18200,"medianDiffPct":-0.0419,"p25Diff":-86700,"p75Diff":44900},{"key":"500-750k","n":15546,"medianRatio":0.9242,"mdape":0.1444,"within10":0.3754,"within20":0.6158,"askAbove":0.6717,"inP10P90":0.6907,"medianAsk":609000,"medianEst":562100,"medianDiff":46600,"medianDiffPct":0.082,"p25Diff":-25500,"p75Diff":133200},{"key":"750k-1M","n":7864,"medianRatio":0.8486,"mdape":0.1944,"within10":0.2778,"within20":0.5092,"askAbove":0.7758,"inP10P90":0.5543,"medianAsk":850000,"medianEst":722150,"medianDiff":129600,"medianDiffPct":0.1783,"p25Diff":16000,"p75Diff":292500},{"key":">1M","n":9009,"medianRatio":0.6409,"mdape":0.3797,"within10":0.1411,"within20":0.2906,"askAbove":0.8908,"inP10P90":0.3647,"medianAsk":1495000,"medianEst":957600,"medianDiff":556100,"medianDiffPct":0.5604,"p25Diff":189500,"p75Diff":1099300}],"histogram":{"below":5867,"above":9525,"bins":[{"lo":0.5,"hi":0.55,"n":1205},{"lo":0.55,"hi":0.6,"n":1410},{"lo":0.6,"hi":0.65,"n":1693},{"lo":0.65,"hi":0.7,"n":1937},{"lo":0.7,"hi":0.75,"n":2546},{"lo":0.75,"hi":0.8,"n":2951},{"lo":0.8,"hi":0.85,"n":3726},{"lo":0.85,"hi":0.9,"n":4344},{"lo":0.9,"hi":0.95,"n":4821},{"lo":0.95,"hi":1,"n":4708},{"lo":1,"hi":1.05,"n":4331},{"lo":1.05,"hi":1.1,"n":3439},{"lo":1.1,"hi":1.15,"n":2763},{"lo":1.15,"hi":1.2,"n":2304},{"lo":1.2,"hi":1.25,"n":1982},{"lo":1.25,"hi":1.3,"n":1539},{"lo":1.3,"hi":1.35,"n":1270},{"lo":1.35,"hi":1.4,"n":994},{"lo":1.4,"hi":1.45,"n":815},{"lo":1.45,"hi":1.5,"n":552}]},"scatter":[{"p":2200000,"e":289400,"l":"D"},{"p":324900,"e":423000,"l":"A"},{"p":699900,"e":409300,"l":"B"},{"p":149900,"e":254600,"l":"D"},{"p":489000,"e":430500,"l":"C"},{"p":929000,"e":517300,"l":"B"},{"p":1889000,"e":831800,"l":"C"},{"p":219000,"e":272200,"l":"C"},{"p":500000,"e":495600,"l":"C"},{"p":168000,"e":134500,"l":"C"},{"p":349000,"e":293300,"l":"C"},{"p":439900,"e":371700,"l":"B"},{"p":165000,"e":189900,"l":"D"},{"p":84900,"e":178100,"l":"D"},{"p":284900,"e":322100,"l":"A"},{"p":669000,"e":277000,"l":"D"},{"p":200000,"e":125500,"l":"C"},{"p":245000,"e":205900,"l":"D"},{"p":449000,"e":349200,"l":"B"},{"p":329000,"e":380300,"l":"B"},{"p":699500,"e":222700,"l":"B"},{"p":384500,"e":556900,"l":"B"},{"p":99500,"e":268200,"l":"D"},{"p":750000,"e":523100,"l":"C"},{"p":259000,"e":174500,"l":"D"},{"p":249000,"e":410100,"l":"B"},{"p":255000,"e":236400,"l":"C"},{"p":149000,"e":273400,"l":"D"},{"p":199000,"e":222300,"l":"D"},{"p":469000,"e":422800,"l":"B"},{"p":69999,"e":354500,"l":"D"},{"p":165000,"e":291000,"l":"D"},{"p":399000,"e":359800,"l":"B"},{"p":129000,"e":191800,"l":"D"},{"p":549000,"e":324800,"l":"D"},{"p":399000,"e":394500,"l":"B"},{"p":319000,"e":662800,"l":"C"},{"p":55000,"e":336300,"l":"B"},{"p":165000,"e":609800,"l":"D"},{"p":1195000,"e":1097200,"l":"C"},{"p":969000,"e":1154900,"l":"B"},{"p":200000,"e":340400,"l":"A"},{"p":355000,"e":324200,"l":"A"},{"p":799000,"e":250200,"l":"D"},{"p":598000,"e":605300,"l":"C"},{"p":175000,"e":232300,"l":"D"},{"p":689000,"e":594500,"l":"B"},{"p":699000,"e":950400,"l":"D"},{"p":350000,"e":147600,"l":"D"},{"p":469000,"e":389300,"l":"B"},{"p":329900,"e":141100,"l":"D"},{"p":328000,"e":300400,"l":"A"},{"p":324900,"e":215800,"l":"C"},{"p":1495000,"e":951500,"l":"C"},{"p":649000,"e":533500,"l":"B"},{"p":54000,"e":370100,"l":"A"},{"p":98000,"e":682200,"l":"B"},{"p":314900,"e":292400,"l":"B"},{"p":399900,"e":477100,"l":"B"},{"p":424900,"e":348600,"l":"A"},{"p":1549000,"e":1087300,"l":"B"},{"p":1375000,"e":1215300,"l":"B"},{"p":860000,"e":802000,"l":"A"},{"p":198400,"e":421800,"l":"C"},{"p":315000,"e":283100,"l":"B"},{"p":107500,"e":143000,"l":"C"},{"p":369900,"e":382100,"l":"B"},{"p":649000,"e":598400,"l":"A"},{"p":259900,"e":485000,"l":"C"},{"p":87000,"e":438100,"l":"C"},{"p":263000,"e":241700,"l":"B"},{"p":214500,"e":311100,"l":"D"},{"p":369000,"e":300200,"l":"A"},{"p":55000,"e":222400,"l":"D"},{"p":1050000,"e":946400,"l":"B"},{"p":629800,"e":548200,"l":"A"},{"p":635000,"e":650200,"l":"A"},{"p":928000,"e":783900,"l":"B"},{"p":619000,"e":547700,"l":"A"},{"p":69000,"e":228200,"l":"D"},{"p":970000,"e":990100,"l":"B"},{"p":1899990,"e":1045300,"l":"C"},{"p":295000,"e":417600,"l":"B"},{"p":210000,"e":486800,"l":"B"},{"p":58000,"e":167700,"l":"D"},{"p":505000,"e":505800,"l":"D"},{"p":359900,"e":431700,"l":"A"},{"p":699000,"e":686800,"l":"A"},{"p":948000,"e":685100,"l":"B"},{"p":499900,"e":636700,"l":"B"},{"p":650000,"e":886800,"l":"B"},{"p":598000,"e":995800,"l":"C"},{"p":1250000,"e":2234300,"l":"D"},{"p":3450000,"e":859100,"l":"B"},{"p":849900,"e":1107000,"l":"C"},{"p":1899000,"e":1753600,"l":"D"},{"p":395000,"e":424400,"l":"C"},{"p":549000,"e":775400,"l":"A"},{"p":1825000,"e":1091100,"l":"D"},{"p":629000,"e":706000,"l":"B"},{"p":575000,"e":910900,"l":"C"},{"p":3850000,"e":376800,"l":"D"},{"p":949000,"e":1031900,"l":"B"},{"p":2003868,"e":1366300,"l":"C"},{"p":1998000,"e":1097700,"l":"C"},{"p":449000,"e":451900,"l":"C"},{"p":1795000,"e":1284300,"l":"C"},{"p":549000,"e":924600,"l":"B"},{"p":1349000,"e":1269600,"l":"A"},{"p":350000,"e":391400,"l":"D"},{"p":249000,"e":275300,"l":"D"},{"p":329000,"e":375300,"l":"C"},{"p":784900,"e":970600,"l":"A"},{"p":439000,"e":445600,"l":"C"},{"p":439000,"e":692300,"l":"B"},{"p":1875000,"e":1584400,"l":"B"},{"p":375000,"e":2243300,"l":"B"},{"p":563427,"e":1241400,"l":"D"},{"p":449000,"e":503000,"l":"C"},{"p":1195000,"e":1581700,"l":"B"},{"p":1099000,"e":1122300,"l":"C"},{"p":3380000,"e":3988200,"l":"C"},{"p":2395000,"e":2177100,"l":"B"},{"p":5995000,"e":1998400,"l":"C"},{"p":949000,"e":993400,"l":"C"},{"p":499500,"e":520200,"l":"B"},{"p":610000,"e":651800,"l":"B"},{"p":1149000,"e":933500,"l":"B"},{"p":649000,"e":677400,"l":"A"},{"p":999999,"e":673200,"l":"D"},{"p":499000,"e":594400,"l":"A"},{"p":1574000,"e":1429300,"l":"A"},{"p":629000,"e":659300,"l":"A"},{"p":799900,"e":807800,"l":"A"},{"p":449000,"e":608800,"l":"B"},{"p":695000,"e":625600,"l":"B"},{"p":499000,"e":1095100,"l":"A"},{"p":1675000,"e":1603700,"l":"B"},{"p":449000,"e":508600,"l":"A"},{"p":1200000,"e":759600,"l":"A"},{"p":399000,"e":532200,"l":"A"},{"p":2289000,"e":473000,"l":"C"},{"p":1359000,"e":1140900,"l":"A"},{"p":299900,"e":503100,"l":"C"},{"p":659900,"e":708000,"l":"B"},{"p":799900,"e":1141500,"l":"A"},{"p":629000,"e":552300,"l":"A"},{"p":732000,"e":758200,"l":"A"},{"p":459000,"e":502300,"l":"C"},{"p":1699000,"e":777900,"l":"B"},{"p":499500,"e":565200,"l":"A"},{"p":659500,"e":672700,"l":"A"},{"p":899000,"e":737600,"l":"B"},{"p":633500,"e":772900,"l":"B"},{"p":799000,"e":847200,"l":"A"},{"p":1730000,"e":1686700,"l":"B"},{"p":499000,"e":620900,"l":"B"},{"p":899000,"e":262000,"l":"C"},{"p":599000,"e":470200,"l":"D"},{"p":295000,"e":180400,"l":"D"},{"p":389000,"e":298700,"l":"C"},{"p":1500000,"e":1318800,"l":"B"},{"p":859000,"e":337500,"l":"B"},{"p":119000,"e":326500,"l":"D"},{"p":2249000,"e":595800,"l":"B"},{"p":154900,"e":505700,"l":"D"},{"p":579900,"e":610100,"l":"B"},{"p":474900,"e":491000,"l":"B"},{"p":1950000,"e":1665400,"l":"D"},{"p":1099000,"e":1167700,"l":"C"},{"p":949900,"e":753700,"l":"B"},{"p":720000,"e":343100,"l":"D"},{"p":725000,"e":440800,"l":"C"},{"p":1250000,"e":480300,"l":"C"},{"p":385000,"e":316900,"l":"C"},{"p":899000,"e":790700,"l":"B"},{"p":99900,"e":255200,"l":"D"},{"p":459000,"e":499400,"l":"B"},{"p":64900,"e":390200,"l":"D"},{"p":59900,"e":258600,"l":"D"},{"p":108538,"e":230900,"l":"D"},{"p":149900,"e":325500,"l":"D"},{"p":149000,"e":489900,"l":"D"},{"p":105000,"e":415800,"l":"D"},{"p":1250000,"e":416400,"l":"C"},{"p":829000,"e":649200,"l":"B"},{"p":120000,"e":416500,"l":"D"},{"p":135000,"e":281000,"l":"D"},{"p":270000,"e":261100,"l":"D"},{"p":749900,"e":254600,"l":"C"},{"p":225000,"e":336400,"l":"D"},{"p":99000,"e":381500,"l":"D"},{"p":74900,"e":391700,"l":"D"},{"p":1495000,"e":569000,"l":"C"},{"p":1998000,"e":3081200,"l":"D"},{"p":275900,"e":1387400,"l":"C"},{"p":1149000,"e":954600,"l":"B"},{"p":335000,"e":386700,"l":"A"},{"p":669000,"e":595800,"l":"B"},{"p":179900,"e":495000,"l":"D"},{"p":749000,"e":374000,"l":"C"},{"p":2600000,"e":862100,"l":"B"},{"p":324000,"e":382400,"l":"C"},{"p":170000,"e":1184600,"l":"B"},{"p":599000,"e":585500,"l":"C"},{"p":195000,"e":326900,"l":"D"},{"p":930000,"e":664500,"l":"B"},{"p":399900,"e":268300,"l":"D"},{"p":137700,"e":912700,"l":"C"},{"p":214000,"e":400600,"l":"D"},{"p":309255,"e":269900,"l":"D"},{"p":99000,"e":256600,"l":"D"},{"p":325000,"e":216000,"l":"C"},{"p":749900,"e":726300,"l":"B"},{"p":995000,"e":969400,"l":"B"},{"p":1995000,"e":1636100,"l":"B"},{"p":259900,"e":214000,"l":"D"},{"p":129000,"e":429600,"l":"D"},{"p":615000,"e":542900,"l":"C"},{"p":979000,"e":396400,"l":"D"},{"p":117000,"e":162800,"l":"D"},{"p":249900,"e":328800,"l":"C"},{"p":449000,"e":401900,"l":"C"},{"p":229000,"e":391700,"l":"D"},{"p":299900,"e":223200,"l":"D"},{"p":635000,"e":601700,"l":"B"},{"p":190000,"e":504200,"l":"D"},{"p":95000,"e":335400,"l":"D"},{"p":1150000,"e":455900,"l":"B"},{"p":119000,"e":229100,"l":"D"},{"p":85000,"e":167800,"l":"D"},{"p":195000,"e":170600,"l":"D"},{"p":399900,"e":206900,"l":"D"},{"p":369800,"e":154300,"l":"D"},{"p":1724900,"e":954100,"l":"B"},{"p":399900,"e":83200,"l":"D"},{"p":120000,"e":214900,"l":"D"},{"p":110000,"e":326600,"l":"D"},{"p":295000,"e":259000,"l":"C"},{"p":59900,"e":101800,"l":"D"},{"p":90000,"e":417900,"l":"B"},{"p":739000,"e":546900,"l":"B"},{"p":399500,"e":415600,"l":"D"},{"p":789000,"e":655400,"l":"B"},{"p":899000,"e":225800,"l":"C"},{"p":199900,"e":339400,"l":"D"},{"p":1095000,"e":812100,"l":"B"},{"p":199999,"e":198700,"l":"D"},{"p":495000,"e":462900,"l":"B"},{"p":559000,"e":499400,"l":"B"},{"p":900000,"e":572700,"l":"B"},{"p":499000,"e":619900,"l":"B"},{"p":415000,"e":390100,"l":"A"},{"p":1275000,"e":657000,"l":"C"},{"p":549900,"e":536600,"l":"A"},{"p":213800,"e":1558900,"l":"C"},{"p":1380000,"e":1088100,"l":"B"},{"p":149000,"e":241300,"l":"D"},{"p":495000,"e":371700,"l":"B"},{"p":2290000,"e":1466400,"l":"B"},{"p":749900,"e":658100,"l":"A"},{"p":125000,"e":856800,"l":"C"},{"p":1249000,"e":914700,"l":"A"},{"p":524000,"e":555500,"l":"A"},{"p":691455,"e":595800,"l":"B"},{"p":650000,"e":546700,"l":"B"},{"p":459000,"e":530100,"l":"B"},{"p":626070,"e":690300,"l":"C"},{"p":485000,"e":536500,"l":"B"},{"p":499900,"e":418300,"l":"A"},{"p":389000,"e":337900,"l":"B"},{"p":1300000,"e":513700,"l":"B"},{"p":699000,"e":632800,"l":"D"},{"p":650000,"e":623600,"l":"A"},{"p":339900,"e":432700,"l":"A"},{"p":459000,"e":554900,"l":"A"},{"p":637800,"e":569100,"l":"A"},{"p":435000,"e":457200,"l":"B"},{"p":659000,"e":645400,"l":"B"},{"p":965000,"e":1038200,"l":"B"},{"p":385000,"e":524700,"l":"A"},{"p":779000,"e":603700,"l":"B"},{"p":925000,"e":800800,"l":"C"},{"p":439000,"e":551900,"l":"A"},{"p":1049000,"e":790200,"l":"A"},{"p":1075000,"e":669800,"l":"D"},{"p":749000,"e":535900,"l":"C"},{"p":775000,"e":656200,"l":"B"},{"p":279000,"e":591800,"l":"D"},{"p":629900,"e":553800,"l":"A"},{"p":449900,"e":476700,"l":"A"},{"p":475000,"e":461100,"l":"B"},{"p":999950,"e":958200,"l":"A"},{"p":799000,"e":766000,"l":"B"},{"p":739291,"e":402200,"l":"B"},{"p":680000,"e":703800,"l":"A"},{"p":899900,"e":751800,"l":"A"},{"p":299900,"e":341100,"l":"B"},{"p":1199000,"e":1092600,"l":"B"},{"p":450000,"e":358400,"l":"C"},{"p":849950,"e":581100,"l":"C"},{"p":699900,"e":536000,"l":"B"},{"p":700000,"e":406600,"l":"C"},{"p":525000,"e":487300,"l":"B"},{"p":599900,"e":582200,"l":"B"},{"p":799000,"e":633500,"l":"C"},{"p":1199000,"e":924200,"l":"C"},{"p":557000,"e":619300,"l":"D"},{"p":319900,"e":421400,"l":"C"},{"p":3499000,"e":2904900,"l":"C"},{"p":869900,"e":776800,"l":"A"},{"p":1289000,"e":1277300,"l":"A"},{"p":1699000,"e":2009600,"l":"C"},{"p":515900,"e":567700,"l":"A"},{"p":1075000,"e":875400,"l":"B"},{"p":549000,"e":533400,"l":"A"},{"p":449000,"e":413600,"l":"B"},{"p":518900,"e":581700,"l":"B"},{"p":1190000,"e":743500,"l":"B"},{"p":1999900,"e":399500,"l":"B"},{"p":339000,"e":474500,"l":"D"},{"p":2200000,"e":689500,"l":"A"},{"p":1290000,"e":854400,"l":"B"},{"p":975000,"e":2695700,"l":"C"},{"p":1249000,"e":864000,"l":"B"},{"p":499000,"e":537800,"l":"A"},{"p":309000,"e":394500,"l":"A"},{"p":899900,"e":1007200,"l":"D"},{"p":829000,"e":399500,"l":"D"},{"p":179000,"e":146300,"l":"C"},{"p":4950000,"e":4040200,"l":"D"},{"p":2199000,"e":2277300,"l":"C"},{"p":699000,"e":559400,"l":"C"},{"p":899000,"e":444800,"l":"C"},{"p":599999,"e":623600,"l":"A"},{"p":399900,"e":431800,"l":"A"},{"p":1900000,"e":446600,"l":"B"},{"p":59900,"e":93400,"l":"D"},{"p":724900,"e":454300,"l":"B"},{"p":524900,"e":646100,"l":"A"},{"p":1174900,"e":1426900,"l":"B"},{"p":244800,"e":360000,"l":"A"},{"p":350000,"e":504600,"l":"A"},{"p":199900,"e":214300,"l":"D"},{"p":1249900,"e":1104900,"l":"A"},{"p":1798000,"e":553600,"l":"D"},{"p":599500,"e":628500,"l":"C"},{"p":419900,"e":459600,"l":"A"},{"p":614900,"e":568300,"l":"B"},{"p":205900,"e":516800,"l":"A"},{"p":6900000,"e":528200,"l":"A"},{"p":294500,"e":406600,"l":"A"},{"p":74900,"e":126100,"l":"D"},{"p":689000,"e":583700,"l":"B"},{"p":175000,"e":245900,"l":"D"},{"p":174900,"e":271500,"l":"C"},{"p":487900,"e":349100,"l":"A"},{"p":339000,"e":473200,"l":"A"},{"p":585000,"e":465900,"l":"B"},{"p":520000,"e":470400,"l":"D"},{"p":299000,"e":387200,"l":"B"},{"p":449000,"e":379200,"l":"C"},{"p":439000,"e":257900,"l":"C"},{"p":129000,"e":289100,"l":"C"},{"p":150000,"e":279500,"l":"D"},{"p":674900,"e":457500,"l":"D"},{"p":599000,"e":369000,"l":"D"},{"p":119500,"e":271900,"l":"D"},{"p":149900,"e":717300,"l":"B"},{"p":54900,"e":209100,"l":"D"},{"p":1075000,"e":1006000,"l":"B"},{"p":1160000,"e":583600,"l":"D"},{"p":359900,"e":394500,"l":"B"},{"p":499900,"e":365400,"l":"B"},{"p":465000,"e":429000,"l":"B"},{"p":795000,"e":479100,"l":"C"},{"p":2200000,"e":2585800,"l":"C"},{"p":495000,"e":541600,"l":"A"},{"p":209000,"e":268800,"l":"C"},{"p":936000,"e":576500,"l":"A"},{"p":1599900,"e":1709100,"l":"C"},{"p":344900,"e":299200,"l":"B"},{"p":585000,"e":523800,"l":"D"},{"p":569000,"e":524600,"l":"B"},{"p":289900,"e":142400,"l":"D"},{"p":5900000,"e":780100,"l":"D"},{"p":994000,"e":685400,"l":"B"},{"p":324900,"e":401700,"l":"B"},{"p":199900,"e":467300,"l":"C"},{"p":119000,"e":456600,"l":"B"},{"p":829500,"e":590800,"l":"D"},{"p":84900,"e":108000,"l":"D"},{"p":109000,"e":188600,"l":"B"},{"p":425000,"e":458300,"l":"B"},{"p":310000,"e":335100,"l":"A"},{"p":69900,"e":208400,"l":"D"},{"p":899000,"e":1014200,"l":"B"},{"p":575000,"e":763700,"l":"D"},{"p":69000,"e":462500,"l":"C"},{"p":389000,"e":433500,"l":"C"},{"p":804000,"e":992000,"l":"B"},{"p":289900,"e":527500,"l":"D"},{"p":688000,"e":620400,"l":"A"},{"p":390000,"e":507700,"l":"A"},{"p":1090000,"e":928700,"l":"D"},{"p":1175000,"e":1132600,"l":"B"},{"p":729900,"e":710300,"l":"B"},{"p":1150000,"e":832400,"l":"A"},{"p":75000,"e":300500,"l":"D"},{"p":1250000,"e":574600,"l":"B"},{"p":517500,"e":928900,"l":"B"},{"p":2100000,"e":1648700,"l":"C"},{"p":230000,"e":161200,"l":"C"},{"p":695000,"e":505000,"l":"C"},{"p":535000,"e":3671700,"l":"D"},{"p":825000,"e":1506400,"l":"A"},{"p":1099000,"e":437100,"l":"B"},{"p":225000,"e":741700,"l":"C"},{"p":599000,"e":537000,"l":"B"},{"p":230000,"e":315300,"l":"D"},{"p":245000,"e":485000,"l":"D"},{"p":1150000,"e":900900,"l":"D"},{"p":825000,"e":653300,"l":"B"},{"p":689000,"e":524100,"l":"B"},{"p":789000,"e":1052000,"l":"C"},{"p":749000,"e":523300,"l":"B"},{"p":569900,"e":533900,"l":"A"},{"p":449000,"e":3970000,"l":"D"},{"p":549000,"e":650200,"l":"A"},{"p":429000,"e":375200,"l":"B"},{"p":729900,"e":722900,"l":"C"},{"p":389000,"e":874400,"l":"B"},{"p":13250000,"e":2461500,"l":"D"},{"p":829000,"e":731300,"l":"A"},{"p":678000,"e":452100,"l":"D"},{"p":695000,"e":746000,"l":"A"},{"p":725000,"e":691800,"l":"B"},{"p":635000,"e":752700,"l":"A"},{"p":60000,"e":797500,"l":"B"},{"p":329000,"e":381900,"l":"A"},{"p":629000,"e":746900,"l":"B"},{"p":1434900,"e":1261100,"l":"B"},{"p":1695000,"e":1679200,"l":"D"},{"p":639900,"e":615500,"l":"A"},{"p":449000,"e":254900,"l":"D"},{"p":329900,"e":334200,"l":"B"},{"p":199000,"e":204600,"l":"C"},{"p":666900,"e":775000,"l":"B"},{"p":1199999,"e":983000,"l":"D"},{"p":589000,"e":610800,"l":"B"},{"p":2888888,"e":2057600,"l":"B"},{"p":469000,"e":476300,"l":"A"},{"p":430000,"e":455700,"l":"A"},{"p":249900,"e":218600,"l":"C"},{"p":1898000,"e":2976300,"l":"D"},{"p":290000,"e":395500,"l":"D"},{"p":378000,"e":988600,"l":"B"},{"p":669000,"e":564900,"l":"B"},{"p":2275000,"e":1024200,"l":"B"},{"p":999000,"e":758900,"l":"B"},{"p":235200,"e":227900,"l":"D"},{"p":374900,"e":356000,"l":"C"},{"p":349900,"e":414500,"l":"A"},{"p":549000,"e":1440900,"l":"A"},{"p":1695000,"e":112200,"l":"D"},{"p":409400,"e":617500,"l":"B"},{"p":267200,"e":538300,"l":"B"},{"p":225000,"e":331700,"l":"D"},{"p":2325000,"e":269000,"l":"D"},{"p":326000,"e":378900,"l":"C"},{"p":675000,"e":199700,"l":"D"},{"p":599000,"e":397300,"l":"C"},{"p":539000,"e":604900,"l":"B"},{"p":799900,"e":258300,"l":"D"},{"p":295000,"e":1262500,"l":"D"},{"p":599000,"e":1081000,"l":"C"},{"p":855100,"e":938100,"l":"A"},{"p":989000,"e":1233800,"l":"B"},{"p":638000,"e":1962200,"l":"D"},{"p":599000,"e":754100,"l":"C"},{"p":445600,"e":591700,"l":"A"},{"p":365000,"e":288100,"l":"B"},{"p":370000,"e":506900,"l":"C"},{"p":180000,"e":338200,"l":"D"},{"p":319000,"e":644400,"l":"D"},{"p":250000,"e":443800,"l":"C"},{"p":60000,"e":398500,"l":"D"},{"p":598000,"e":314000,"l":"B"},{"p":425000,"e":105300,"l":"D"},{"p":199000,"e":208300,"l":"D"},{"p":425000,"e":375900,"l":"A"},{"p":495000,"e":168000,"l":"D"},{"p":329000,"e":467400,"l":"A"},{"p":499000,"e":523600,"l":"A"},{"p":549000,"e":586400,"l":"D"},{"p":799900,"e":838600,"l":"A"},{"p":419000,"e":464400,"l":"A"},{"p":349000,"e":338400,"l":"B"},{"p":499000,"e":427500,"l":"A"},{"p":269000,"e":447800,"l":"D"},{"p":599900,"e":464000,"l":"B"},{"p":429900,"e":235000,"l":"D"},{"p":440000,"e":436400,"l":"B"},{"p":739000,"e":527900,"l":"A"},{"p":349000,"e":312100,"l":"D"},{"p":299000,"e":325500,"l":"B"},{"p":649000,"e":615200,"l":"C"},{"p":289999,"e":298000,"l":"B"},{"p":399500,"e":61800,"l":"D"},{"p":599900,"e":250100,"l":"D"},{"p":195000,"e":282100,"l":"A"},{"p":749000,"e":362200,"l":"B"},{"p":1195000,"e":1137400,"l":"D"},{"p":639000,"e":610000,"l":"B"},{"p":419900,"e":194000,"l":"D"},{"p":199900,"e":768000,"l":"B"},{"p":950000,"e":803000,"l":"C"},{"p":299000,"e":320000,"l":"C"},{"p":229000,"e":280700,"l":"A"},{"p":61967,"e":126000,"l":"D"},{"p":699000,"e":598000,"l":"D"},{"p":395000,"e":460400,"l":"A"},{"p":975000,"e":374700,"l":"D"},{"p":314900,"e":611400,"l":"A"},{"p":679000,"e":640500,"l":"A"},{"p":89900,"e":227900,"l":"D"},{"p":579000,"e":657900,"l":"B"},{"p":499000,"e":427500,"l":"A"},{"p":1775000,"e":297900,"l":"B"},{"p":89500,"e":128200,"l":"D"},{"p":245000,"e":110100,"l":"D"},{"p":1950000,"e":1203500,"l":"D"},{"p":359000,"e":360900,"l":"A"},{"p":949999,"e":963800,"l":"A"},{"p":699000,"e":694200,"l":"B"},{"p":126000,"e":353100,"l":"D"},{"p":239900,"e":262100,"l":"D"},{"p":114500,"e":312600,"l":"D"},{"p":100000,"e":228500,"l":"D"},{"p":692163,"e":751100,"l":"A"},{"p":362960,"e":312700,"l":"A"},{"p":365000,"e":493300,"l":"A"},{"p":59900,"e":147800,"l":"D"},{"p":225000,"e":470500,"l":"D"},{"p":59000,"e":303600,"l":"D"},{"p":349900,"e":137600,"l":"D"},{"p":297500,"e":157200,"l":"D"},{"p":79500,"e":90300,"l":"D"},{"p":749000,"e":467100,"l":"B"},{"p":280000,"e":504900,"l":"A"},{"p":305900,"e":292200,"l":"B"},{"p":389000,"e":231500,"l":"C"},{"p":276000,"e":395500,"l":"C"},{"p":399000,"e":389300,"l":"D"},{"p":1600000,"e":1054500,"l":"D"},{"p":110000,"e":635500,"l":"D"},{"p":1599000,"e":1013300,"l":"B"},{"p":449000,"e":432400,"l":"B"},{"p":419900,"e":477200,"l":"A"},{"p":174000,"e":304800,"l":"D"},{"p":899000,"e":479400,"l":"C"},{"p":499900,"e":620600,"l":"D"},{"p":360000,"e":325300,"l":"B"},{"p":650000,"e":528200,"l":"B"},{"p":549000,"e":676300,"l":"C"},{"p":529000,"e":477500,"l":"B"},{"p":249000,"e":362100,"l":"C"},{"p":299900,"e":287300,"l":"D"},{"p":99500,"e":147600,"l":"D"},{"p":539000,"e":257600,"l":"D"},{"p":2490000,"e":1035800,"l":"D"},{"p":286000,"e":329300,"l":"C"},{"p":999000,"e":138700,"l":"D"},{"p":549000,"e":1093000,"l":"B"},{"p":77000,"e":98000,"l":"D"},{"p":379000,"e":441500,"l":"D"},{"p":184500,"e":295400,"l":"B"},{"p":95000,"e":114900,"l":"D"},{"p":1099000,"e":717900,"l":"B"},{"p":439000,"e":692300,"l":"B"},{"p":2495000,"e":1260200,"l":"C"},{"p":1295000,"e":799900,"l":"C"},{"p":639000,"e":673500,"l":"A"},{"p":199000,"e":314200,"l":"D"},{"p":1539000,"e":2151900,"l":"C"},{"p":284900,"e":346700,"l":"A"},{"p":213900,"e":300000,"l":"B"},{"p":599000,"e":588300,"l":"A"},{"p":174900,"e":393800,"l":"B"},{"p":367500,"e":495800,"l":"C"},{"p":5775000,"e":830700,"l":"A"},{"p":684409,"e":972000,"l":"B"},{"p":375000,"e":483700,"l":"C"},{"p":449000,"e":498400,"l":"B"},{"p":349500,"e":298800,"l":"D"},{"p":795000,"e":457000,"l":"B"},{"p":749900,"e":601300,"l":"A"},{"p":525000,"e":434400,"l":"C"},{"p":637000,"e":797600,"l":"C"},{"p":398500,"e":352800,"l":"B"},{"p":1040000,"e":1107300,"l":"C"},{"p":450000,"e":1035700,"l":"C"},{"p":1800000,"e":709900,"l":"D"},{"p":1075000,"e":343800,"l":"B"},{"p":199000,"e":144900,"l":"C"},{"p":675000,"e":526500,"l":"C"},{"p":89000,"e":175200,"l":"D"},{"p":333200,"e":163800,"l":"C"},{"p":80000,"e":105200,"l":"D"},{"p":397000,"e":356000,"l":"A"},{"p":459900,"e":488400,"l":"A"},{"p":517900,"e":380200,"l":"D"},{"p":1695000,"e":340700,"l":"D"},{"p":850000,"e":256100,"l":"D"},{"p":479000,"e":292800,"l":"C"},{"p":89900,"e":246400,"l":"D"},{"p":1200000,"e":121700,"l":"D"},{"p":378000,"e":376100,"l":"A"},{"p":64990,"e":90400,"l":"D"},{"p":229900,"e":413800,"l":"C"},{"p":369000,"e":347700,"l":"B"},{"p":409000,"e":354300,"l":"C"},{"p":425000,"e":213400,"l":"D"},{"p":395000,"e":400500,"l":"A"},{"p":599000,"e":577700,"l":"B"},{"p":1070000,"e":378200,"l":"D"},{"p":539000,"e":316300,"l":"D"},{"p":838400,"e":402000,"l":"D"},{"p":795000,"e":644100,"l":"D"},{"p":370000,"e":344000,"l":"B"},{"p":349900,"e":3682700,"l":"D"},{"p":875000,"e":379100,"l":"C"},{"p":699500,"e":862900,"l":"B"},{"p":595000,"e":455900,"l":"D"},{"p":109000,"e":304600,"l":"D"},{"p":399000,"e":252500,"l":"B"},{"p":55000,"e":226700,"l":"D"},{"p":800000,"e":360000,"l":"D"},{"p":849000,"e":650200,"l":"B"},{"p":899000,"e":368300,"l":"D"},{"p":399000,"e":378800,"l":"B"},{"p":499999,"e":602100,"l":"A"},{"p":355000,"e":509600,"l":"A"},{"p":315000,"e":464600,"l":"D"},{"p":644900,"e":549900,"l":"D"},{"p":498000,"e":555100,"l":"A"},{"p":799000,"e":605900,"l":"A"},{"p":400000,"e":577300,"l":"B"},{"p":874999,"e":434400,"l":"C"},{"p":1599000,"e":947200,"l":"C"},{"p":489900,"e":917500,"l":"B"},{"p":799000,"e":661600,"l":"C"},{"p":729000,"e":285200,"l":"D"},{"p":424900,"e":514300,"l":"A"},{"p":275000,"e":515300,"l":"C"},{"p":929888,"e":854100,"l":"A"},{"p":539000,"e":329800,"l":"D"},{"p":659000,"e":706900,"l":"A"},{"p":1024900,"e":571600,"l":"B"},{"p":99000,"e":439000,"l":"D"},{"p":68000,"e":284200,"l":"D"},{"p":182000,"e":245200,"l":"D"},{"p":79999,"e":90500,"l":"D"},{"p":975000,"e":535200,"l":"B"},{"p":439000,"e":339800,"l":"D"},{"p":1600000,"e":684800,"l":"B"},{"p":739204,"e":679100,"l":"A"},{"p":1725000,"e":1027000,"l":"B"},{"p":829000,"e":818800,"l":"A"},{"p":849000,"e":604500,"l":"A"},{"p":299900,"e":292500,"l":"A"},{"p":649000,"e":726700,"l":"B"},{"p":499500,"e":408500,"l":"B"},{"p":279000,"e":236400,"l":"C"},{"p":230000,"e":374200,"l":"C"},{"p":1175000,"e":924300,"l":"B"},{"p":869000,"e":473600,"l":"C"},{"p":789000,"e":941500,"l":"B"},{"p":649900,"e":622600,"l":"A"},{"p":425000,"e":903400,"l":"B"},{"p":710000,"e":533600,"l":"A"},{"p":575000,"e":605600,"l":"A"},{"p":650000,"e":519200,"l":"B"},{"p":479000,"e":522000,"l":"B"},{"p":399000,"e":5949200,"l":"D"},{"p":2216000,"e":560900,"l":"C"},{"p":499000,"e":824300,"l":"B"},{"p":925000,"e":942400,"l":"A"},{"p":640000,"e":560900,"l":"C"},{"p":450000,"e":874400,"l":"B"},{"p":380000,"e":534300,"l":"B"},{"p":679900,"e":651900,"l":"A"},{"p":699800,"e":589200,"l":"A"},{"p":499900,"e":517300,"l":"A"},{"p":888400,"e":698000,"l":"A"},{"p":545000,"e":525400,"l":"A"},{"p":359500,"e":508000,"l":"A"},{"p":625000,"e":581500,"l":"A"},{"p":325000,"e":289200,"l":"B"},{"p":553790,"e":605600,"l":"A"},{"p":704000,"e":549800,"l":"B"},{"p":549999,"e":577400,"l":"A"},{"p":860000,"e":844900,"l":"A"},{"p":599900,"e":631400,"l":"B"},{"p":1095000,"e":796400,"l":"A"},{"p":679000,"e":443400,"l":"C"},{"p":1200000,"e":963500,"l":"C"},{"p":555000,"e":394300,"l":"C"},{"p":779000,"e":753800,"l":"A"},{"p":799550,"e":585600,"l":"B"},{"p":654000,"e":549600,"l":"A"},{"p":845000,"e":651900,"l":"A"},{"p":528800,"e":525200,"l":"A"},{"p":315000,"e":354800,"l":"A"},{"p":599750,"e":699600,"l":"B"},{"p":489900,"e":491800,"l":"A"},{"p":574500,"e":544900,"l":"A"},{"p":628900,"e":647400,"l":"A"},{"p":299900,"e":379200,"l":"A"},{"p":690000,"e":554500,"l":"C"},{"p":1194000,"e":856700,"l":"B"},{"p":1249000,"e":403000,"l":"B"},{"p":89000,"e":281300,"l":"D"},{"p":409900,"e":250700,"l":"D"},{"p":585000,"e":721600,"l":"B"},{"p":235000,"e":889300,"l":"B"},{"p":175000,"e":157800,"l":"D"},{"p":497000,"e":453000,"l":"B"},{"p":3125000,"e":888200,"l":"D"},{"p":689000,"e":467000,"l":"D"},{"p":729000,"e":601300,"l":"A"},{"p":700000,"e":550800,"l":"B"},{"p":389700,"e":416300,"l":"A"},{"p":1350000,"e":752100,"l":"A"},{"p":720000,"e":570400,"l":"B"},{"p":549000,"e":334200,"l":"B"},{"p":395000,"e":295000,"l":"A"},{"p":650000,"e":322900,"l":"B"},{"p":260000,"e":130400,"l":"D"},{"p":297000,"e":585500,"l":"D"},{"p":249900,"e":337500,"l":"A"},{"p":599000,"e":729700,"l":"A"},{"p":1195000,"e":630600,"l":"B"},{"p":185000,"e":592300,"l":"B"},{"p":1049000,"e":839800,"l":"B"},{"p":70000,"e":443900,"l":"D"},{"p":549900,"e":516000,"l":"A"},{"p":450000,"e":476800,"l":"C"},{"p":1150000,"e":965800,"l":"A"},{"p":94000,"e":281000,"l":"D"},{"p":125000,"e":360400,"l":"D"},{"p":349900,"e":689300,"l":"A"},{"p":535000,"e":747100,"l":"B"},{"p":1295000,"e":565700,"l":"D"},{"p":369900,"e":416800,"l":"A"},{"p":225000,"e":513300,"l":"B"},{"p":550000,"e":529800,"l":"B"},{"p":629500,"e":1895400,"l":"C"},{"p":199900,"e":473300,"l":"D"},{"p":799000,"e":760900,"l":"D"},{"p":990000,"e":487400,"l":"B"},{"p":674900,"e":647700,"l":"B"},{"p":73000,"e":134100,"l":"D"},{"p":799900,"e":712600,"l":"C"},{"p":549000,"e":554000,"l":"A"},{"p":885000,"e":828800,"l":"B"},{"p":1095000,"e":861400,"l":"B"},{"p":599000,"e":505800,"l":"B"},{"p":315000,"e":370200,"l":"A"},{"p":699000,"e":890300,"l":"B"},{"p":480000,"e":512700,"l":"A"},{"p":359000,"e":380500,"l":"C"},{"p":599000,"e":563400,"l":"A"},{"p":299000,"e":385800,"l":"B"},{"p":599000,"e":461500,"l":"C"},{"p":175000,"e":232300,"l":"D"},{"p":559000,"e":696100,"l":"B"},{"p":289000,"e":266300,"l":"C"},{"p":869000,"e":281200,"l":"D"},{"p":80000,"e":382900,"l":"B"},{"p":399000,"e":360200,"l":"C"},{"p":629900,"e":553800,"l":"A"},{"p":594900,"e":595600,"l":"B"},{"p":260000,"e":257800,"l":"D"},{"p":250000,"e":476600,"l":"A"},{"p":484900,"e":542900,"l":"A"},{"p":499900,"e":526900,"l":"C"},{"p":499999,"e":453400,"l":"C"},{"p":169800,"e":237900,"l":"A"},{"p":225000,"e":211700,"l":"D"},{"p":90000,"e":328700,"l":"C"},{"p":1775000,"e":1022500,"l":"B"},{"p":625000,"e":528200,"l":"B"},{"p":685000,"e":615500,"l":"B"},{"p":699000,"e":610600,"l":"A"},{"p":345000,"e":403600,"l":"A"},{"p":199900,"e":231000,"l":"D"},{"p":699000,"e":657000,"l":"B"},{"p":639500,"e":431200,"l":"B"},{"p":749000,"e":884600,"l":"B"},{"p":639000,"e":426900,"l":"B"},{"p":419000,"e":383000,"l":"D"},{"p":745000,"e":623400,"l":"B"},{"p":575000,"e":657900,"l":"D"},{"p":115000,"e":993000,"l":"D"},{"p":275000,"e":154800,"l":"D"},{"p":6250000,"e":2319300,"l":"C"},{"p":190000,"e":578900,"l":"B"},{"p":3799000,"e":1023100,"l":"A"},{"p":1699000,"e":727300,"l":"D"},{"p":1400000,"e":847700,"l":"C"},{"p":825000,"e":479700,"l":"C"},{"p":929000,"e":655100,"l":"D"},{"p":1949000,"e":1899400,"l":"C"},{"p":1040000,"e":897200,"l":"A"},{"p":750000,"e":496100,"l":"C"},{"p":1590000,"e":880500,"l":"B"},{"p":760000,"e":571400,"l":"C"},{"p":499000,"e":373300,"l":"B"},{"p":345900,"e":327700,"l":"A"},{"p":976000,"e":908700,"l":"A"},{"p":629000,"e":562300,"l":"C"},{"p":350000,"e":177300,"l":"B"},{"p":345000,"e":320500,"l":"D"},{"p":88500,"e":90000,"l":"D"},{"p":789000,"e":813200,"l":"A"},{"p":525000,"e":484300,"l":"C"},{"p":465000,"e":453900,"l":"D"},{"p":380000,"e":303000,"l":"B"},{"p":925000,"e":876500,"l":"A"},{"p":1649000,"e":1138400,"l":"A"},{"p":799000,"e":775500,"l":"A"},{"p":529000,"e":501100,"l":"B"},{"p":779000,"e":368100,"l":"C"},{"p":599000,"e":590800,"l":"A"},{"p":379000,"e":371100,"l":"A"},{"p":669999,"e":610900,"l":"A"},{"p":489900,"e":481000,"l":"B"},{"p":1494500,"e":1188200,"l":"C"},{"p":538900,"e":389900,"l":"D"},{"p":665000,"e":621000,"l":"A"},{"p":869900,"e":320800,"l":"B"},{"p":1249000,"e":1453600,"l":"B"},{"p":469000,"e":431900,"l":"C"},{"p":584900,"e":526900,"l":"A"},{"p":489999,"e":513100,"l":"A"},{"p":1200000,"e":961000,"l":"B"},{"p":412000,"e":575700,"l":"B"},{"p":239000,"e":247800,"l":"D"},{"p":750000,"e":557200,"l":"C"},{"p":480000,"e":418000,"l":"C"},{"p":435000,"e":331500,"l":"D"},{"p":585000,"e":521400,"l":"B"},{"p":575000,"e":524200,"l":"C"},{"p":258000,"e":244100,"l":"B"},{"p":275000,"e":122500,"l":"C"},{"p":699000,"e":712300,"l":"B"},{"p":299000,"e":246000,"l":"D"},{"p":645000,"e":676400,"l":"C"},{"p":649000,"e":425300,"l":"B"},{"p":674000,"e":659500,"l":"B"},{"p":517000,"e":459200,"l":"B"},{"p":749000,"e":550900,"l":"B"},{"p":895000,"e":436600,"l":"D"},{"p":239900,"e":241400,"l":"B"},{"p":775000,"e":852500,"l":"B"},{"p":790000,"e":351500,"l":"C"},{"p":489900,"e":574600,"l":"C"},{"p":430000,"e":381100,"l":"A"},{"p":730000,"e":606000,"l":"B"},{"p":669000,"e":643400,"l":"A"},{"p":599998,"e":567700,"l":"A"},{"p":1850000,"e":2622600,"l":"C"},{"p":820000,"e":497600,"l":"B"},{"p":525000,"e":287000,"l":"C"},{"p":299000,"e":381300,"l":"A"},{"p":517000,"e":460500,"l":"A"},{"p":559000,"e":683300,"l":"B"},{"p":749000,"e":517700,"l":"D"},{"p":375000,"e":423800,"l":"A"},{"p":600000,"e":362400,"l":"A"},{"p":588000,"e":640900,"l":"B"},{"p":322000,"e":358800,"l":"A"},{"p":565000,"e":515700,"l":"B"},{"p":379500,"e":1827100,"l":"D"},{"p":320000,"e":378500,"l":"B"},{"p":305000,"e":348400,"l":"A"},{"p":399000,"e":417800,"l":"A"},{"p":465000,"e":557000,"l":"D"},{"p":809000,"e":962700,"l":"B"},{"p":425000,"e":426600,"l":"B"},{"p":789000,"e":941500,"l":"B"},{"p":450000,"e":508700,"l":"A"},{"p":675000,"e":443400,"l":"C"},{"p":489000,"e":584500,"l":"B"},{"p":269000,"e":754100,"l":"C"},{"p":748000,"e":878500,"l":"B"},{"p":210000,"e":379500,"l":"B"},{"p":449500,"e":476800,"l":"A"},{"p":324900,"e":380300,"l":"A"},{"p":405000,"e":316100,"l":"D"},{"p":539900,"e":593300,"l":"A"},{"p":500000,"e":584800,"l":"C"},{"p":580000,"e":2115900,"l":"D"},{"p":279400,"e":421000,"l":"A"},{"p":649000,"e":756800,"l":"A"},{"p":480000,"e":509000,"l":"C"},{"p":385000,"e":455800,"l":"A"},{"p":595000,"e":495200,"l":"B"},{"p":260000,"e":279200,"l":"A"},{"p":434000,"e":470700,"l":"A"},{"p":337000,"e":400100,"l":"A"},{"p":339000,"e":426600,"l":"A"},{"p":330000,"e":369500,"l":"A"},{"p":616000,"e":532300,"l":"B"},{"p":391510,"e":419500,"l":"B"},{"p":395000,"e":712700,"l":"B"},{"p":135000,"e":370300,"l":"C"},{"p":209000,"e":307300,"l":"D"},{"p":350000,"e":570000,"l":"C"},{"p":149900,"e":426600,"l":"D"},{"p":64900,"e":356100,"l":"D"},{"p":159999,"e":255700,"l":"D"},{"p":850000,"e":208300,"l":"D"},{"p":94000,"e":358100,"l":"D"},{"p":295000,"e":261900,"l":"D"},{"p":115000,"e":195600,"l":"D"},{"p":332000,"e":622300,"l":"D"},{"p":319000,"e":266300,"l":"D"},{"p":339000,"e":152700,"l":"D"},{"p":110250,"e":484300,"l":"D"},{"p":149000,"e":157700,"l":"D"},{"p":189000,"e":212400,"l":"D"},{"p":125000,"e":207800,"l":"D"},{"p":559000,"e":643800,"l":"B"},{"p":145600,"e":732600,"l":"A"},{"p":58800,"e":429200,"l":"D"},{"p":148000,"e":366800,"l":"D"},{"p":105000,"e":460100,"l":"D"},{"p":1059000,"e":477700,"l":"C"},{"p":610000,"e":759400,"l":"B"},{"p":475000,"e":365400,"l":"D"},{"p":489000,"e":468600,"l":"A"},{"p":299500,"e":298600,"l":"B"},{"p":499000,"e":577100,"l":"D"},{"p":165000,"e":106600,"l":"C"},{"p":428000,"e":1569900,"l":"D"},{"p":249000,"e":255200,"l":"A"},{"p":249000,"e":1628900,"l":"C"},{"p":529000,"e":511800,"l":"B"},{"p":150000,"e":388800,"l":"D"},{"p":545000,"e":502000,"l":"A"},{"p":539900,"e":465800,"l":"C"},{"p":368500,"e":338500,"l":"C"},{"p":825000,"e":696600,"l":"A"},{"p":109900,"e":120900,"l":"C"},{"p":319000,"e":294100,"l":"C"},{"p":1195000,"e":884400,"l":"A"},{"p":384900,"e":488900,"l":"A"},{"p":289900,"e":363100,"l":"B"},{"p":749000,"e":736300,"l":"B"},{"p":750000,"e":426200,"l":"C"},{"p":649000,"e":543500,"l":"C"},{"p":304000,"e":393600,"l":"A"},{"p":489000,"e":532000,"l":"A"},{"p":319000,"e":443400,"l":"A"},{"p":349900,"e":473900,"l":"B"},{"p":729000,"e":434300,"l":"C"},{"p":429900,"e":389700,"l":"A"},{"p":459000,"e":330000,"l":"C"},{"p":225000,"e":712800,"l":"D"},{"p":339000,"e":226500,"l":"A"},{"p":1879000,"e":1644100,"l":"B"},{"p":330000,"e":379100,"l":"A"},{"p":299000,"e":325600,"l":"A"},{"p":415000,"e":306400,"l":"B"},{"p":299999,"e":164400,"l":"D"},{"p":229000,"e":254300,"l":"C"},{"p":759900,"e":797600,"l":"A"},{"p":695000,"e":583300,"l":"A"},{"p":349000,"e":289700,"l":"B"},{"p":349900,"e":303200,"l":"B"},{"p":799000,"e":298000,"l":"B"},{"p":734900,"e":660200,"l":"A"},{"p":949900,"e":995900,"l":"B"},{"p":395000,"e":407700,"l":"A"},{"p":69000,"e":427300,"l":"D"},{"p":68000,"e":2274800,"l":"D"},{"p":699000,"e":697800,"l":"C"},{"p":424800,"e":447300,"l":"A"},{"p":474900,"e":512400,"l":"B"},{"p":349000,"e":374600,"l":"B"},{"p":349000,"e":365000,"l":"C"},{"p":569000,"e":590400,"l":"A"},{"p":847500,"e":568500,"l":"B"},{"p":649000,"e":610100,"l":"A"},{"p":450000,"e":424200,"l":"D"},{"p":1360000,"e":1125000,"l":"A"},{"p":599000,"e":568500,"l":"A"},{"p":219900,"e":252600,"l":"B"},{"p":899000,"e":1762500,"l":"D"},{"p":420000,"e":450400,"l":"A"},{"p":674500,"e":351400,"l":"B"},{"p":280000,"e":428600,"l":"C"},{"p":380000,"e":505400,"l":"A"},{"p":289000,"e":393200,"l":"A"},{"p":235000,"e":305200,"l":"C"},{"p":785000,"e":518200,"l":"B"},{"p":55000,"e":249200,"l":"D"},{"p":429000,"e":240600,"l":"C"},{"p":335000,"e":420900,"l":"B"},{"p":899000,"e":922000,"l":"B"},{"p":225000,"e":326700,"l":"D"},{"p":309000,"e":303300,"l":"B"},{"p":1059000,"e":1014000,"l":"A"},{"p":424900,"e":370000,"l":"B"},{"p":1040000,"e":996300,"l":"B"},{"p":369000,"e":411500,"l":"A"},{"p":624800,"e":603600,"l":"A"},{"p":340000,"e":474300,"l":"A"},{"p":798000,"e":1062700,"l":"B"},{"p":289000,"e":335500,"l":"A"},{"p":199900,"e":131300,"l":"D"},{"p":525000,"e":420200,"l":"D"},{"p":72000,"e":293300,"l":"C"},{"p":439000,"e":133300,"l":"C"},{"p":139700,"e":183900,"l":"C"},{"p":549000,"e":567900,"l":"C"},{"p":512000,"e":1302000,"l":"C"},{"p":324900,"e":434000,"l":"A"},{"p":10700000,"e":1788600,"l":"D"},{"p":359000,"e":454400,"l":"A"},{"p":439000,"e":414000,"l":"D"},{"p":650000,"e":546200,"l":"C"},{"p":949500,"e":833600,"l":"A"},{"p":4888888,"e":808600,"l":"C"},{"p":729000,"e":714200,"l":"A"},{"p":995000,"e":499900,"l":"A"},{"p":419000,"e":548200,"l":"B"},{"p":349900,"e":424800,"l":"A"},{"p":224900,"e":321600,"l":"B"},{"p":359900,"e":499600,"l":"A"},{"p":189900,"e":234100,"l":"D"},{"p":799000,"e":682500,"l":"B"},{"p":1149000,"e":1288000,"l":"A"},{"p":349900,"e":324900,"l":"B"},{"p":344900,"e":366800,"l":"A"},{"p":185000,"e":347900,"l":"D"},{"p":179500,"e":256200,"l":"D"},{"p":275000,"e":242900,"l":"B"},{"p":295000,"e":259900,"l":"B"},{"p":749000,"e":272800,"l":"D"},{"p":3900000,"e":223000,"l":"D"},{"p":1324900,"e":442500,"l":"C"},{"p":370000,"e":518300,"l":"B"},{"p":10000000,"e":1343500,"l":"D"},{"p":524900,"e":515700,"l":"A"},{"p":572000,"e":602400,"l":"A"},{"p":599000,"e":796800,"l":"C"},{"p":1349000,"e":1201200,"l":"A"},{"p":159900,"e":188100,"l":"D"},{"p":349000,"e":530300,"l":"B"},{"p":579500,"e":501200,"l":"B"},{"p":899000,"e":951000,"l":"C"},{"p":899900,"e":917200,"l":"B"},{"p":625000,"e":607500,"l":"A"},{"p":710000,"e":685400,"l":"B"},{"p":1875000,"e":1359100,"l":"C"},{"p":555000,"e":866300,"l":"C"},{"p":850000,"e":640100,"l":"B"},{"p":1349000,"e":1097600,"l":"C"},{"p":549000,"e":662400,"l":"A"},{"p":829000,"e":849300,"l":"B"},{"p":978500,"e":1057400,"l":"A"},{"p":674900,"e":640800,"l":"C"},{"p":550000,"e":436000,"l":"C"},{"p":599000,"e":634400,"l":"A"},{"p":975000,"e":1001700,"l":"B"},{"p":375000,"e":294200,"l":"C"},{"p":295000,"e":542800,"l":"C"},{"p":425000,"e":374100,"l":"B"},{"p":548900,"e":404900,"l":"C"},{"p":489900,"e":508800,"l":"A"},{"p":664900,"e":625800,"l":"A"},{"p":309900,"e":329200,"l":"A"},{"p":569900,"e":491800,"l":"A"},{"p":449900,"e":467600,"l":"A"},{"p":784900,"e":755500,"l":"B"},{"p":679000,"e":612800,"l":"C"},{"p":2650000,"e":732400,"l":"D"},{"p":414900,"e":357300,"l":"A"},{"p":1211436,"e":776900,"l":"B"},{"p":229900,"e":185100,"l":"D"},{"p":109000,"e":127700,"l":"D"},{"p":389400,"e":470400,"l":"A"},{"p":289800,"e":554600,"l":"A"},{"p":399900,"e":324500,"l":"C"},{"p":300000,"e":424000,"l":"A"},{"p":1230000,"e":958500,"l":"B"},{"p":359900,"e":370300,"l":"A"},{"p":1999000,"e":297000,"l":"C"},{"p":149900,"e":100200,"l":"D"},{"p":469000,"e":318000,"l":"C"},{"p":209000,"e":192200,"l":"D"},{"p":689900,"e":899200,"l":"A"},{"p":529000,"e":479700,"l":"A"},{"p":299000,"e":294000,"l":"C"},{"p":495000,"e":551000,"l":"A"},{"p":429900,"e":440100,"l":"B"},{"p":389000,"e":423100,"l":"A"},{"p":979000,"e":275300,"l":"D"},{"p":369000,"e":355800,"l":"B"},{"p":474000,"e":343100,"l":"B"},{"p":848000,"e":640700,"l":"D"},{"p":555400,"e":472600,"l":"B"},{"p":389000,"e":414800,"l":"A"},{"p":532000,"e":590100,"l":"A"},{"p":725000,"e":675100,"l":"A"},{"p":268500,"e":289100,"l":"D"},{"p":60000,"e":80100,"l":"D"},{"p":1199000,"e":369200,"l":"D"},{"p":435580,"e":263400,"l":"D"},{"p":149000,"e":345900,"l":"D"},{"p":1698000,"e":266200,"l":"D"},{"p":452273,"e":469900,"l":"A"},{"p":4565000,"e":2961000,"l":"C"},{"p":164900,"e":254900,"l":"D"},{"p":500000,"e":363200,"l":"B"},{"p":449000,"e":804400,"l":"C"},{"p":518500,"e":401300,"l":"D"},{"p":1599000,"e":993900,"l":"D"},{"p":235000,"e":987200,"l":"B"},{"p":595000,"e":625100,"l":"A"},{"p":180000,"e":372000,"l":"D"},{"p":295000,"e":251500,"l":"B"},{"p":295000,"e":314200,"l":"D"},{"p":55000,"e":181500,"l":"D"},{"p":520000,"e":302800,"l":"D"},{"p":349000,"e":306400,"l":"B"},{"p":699500,"e":468300,"l":"C"},{"p":299000,"e":696100,"l":"B"},{"p":5490000,"e":781000,"l":"C"},{"p":349000,"e":400100,"l":"C"},{"p":549000,"e":479800,"l":"B"},{"p":419000,"e":588500,"l":"B"},{"p":362435,"e":1135700,"l":"A"},{"p":427335,"e":3090400,"l":"D"},{"p":730000,"e":518200,"l":"A"},{"p":225000,"e":323500,"l":"D"},{"p":749000,"e":751700,"l":"D"},{"p":885000,"e":347100,"l":"D"},{"p":648000,"e":673000,"l":"A"},{"p":85000,"e":1180300,"l":"B"},{"p":1649000,"e":886500,"l":"D"},{"p":549000,"e":522800,"l":"A"},{"p":599000,"e":598600,"l":"B"},{"p":2195000,"e":2403900,"l":"D"},{"p":1875000,"e":898800,"l":"D"},{"p":249900,"e":1192000,"l":"D"},{"p":1999000,"e":659500,"l":"C"},{"p":2294000,"e":427600,"l":"D"},{"p":698000,"e":908200,"l":"D"},{"p":349900,"e":492300,"l":"A"},{"p":299000,"e":548500,"l":"D"},{"p":575000,"e":1133500,"l":"D"},{"p":285400,"e":335900,"l":"C"},{"p":69000,"e":153400,"l":"D"},{"p":745000,"e":814500,"l":"C"},{"p":899000,"e":1331900,"l":"C"},{"p":1750000,"e":1621700,"l":"C"},{"p":469000,"e":603000,"l":"A"},{"p":1150000,"e":1621700,"l":"C"},{"p":449900,"e":444000,"l":"A"},{"p":1690000,"e":500900,"l":"B"},{"p":500000,"e":551000,"l":"C"},{"p":669900,"e":596300,"l":"C"},{"p":425000,"e":483600,"l":"A"},{"p":799999,"e":668700,"l":"B"},{"p":619999,"e":588400,"l":"D"},{"p":579900,"e":715400,"l":"A"},{"p":315000,"e":431800,"l":"C"},{"p":1335000,"e":1175900,"l":"A"},{"p":539000,"e":544700,"l":"A"},{"p":749000,"e":256600,"l":"D"},{"p":845000,"e":709100,"l":"A"},{"p":349900,"e":403700,"l":"B"},{"p":1490000,"e":424300,"l":"C"},{"p":939000,"e":635400,"l":"C"},{"p":169900,"e":185900,"l":"C"},{"p":1499900,"e":1753100,"l":"D"},{"p":275000,"e":217600,"l":"B"},{"p":540000,"e":559000,"l":"C"},{"p":490900,"e":392600,"l":"A"},{"p":389000,"e":356500,"l":"A"},{"p":396000,"e":340500,"l":"B"},{"p":690000,"e":379400,"l":"C"},{"p":309000,"e":259500,"l":"B"},{"p":80000,"e":252900,"l":"D"},{"p":369000,"e":543500,"l":"A"},{"p":499900,"e":475100,"l":"D"},{"p":749900,"e":676800,"l":"A"},{"p":699000,"e":739900,"l":"B"},{"p":529000,"e":1669400,"l":"C"},{"p":329000,"e":371000,"l":"C"},{"p":449900,"e":392700,"l":"B"},{"p":869900,"e":773600,"l":"D"},{"p":1150000,"e":1174000,"l":"B"},{"p":299900,"e":252800,"l":"D"},{"p":319000,"e":406800,"l":"D"},{"p":425000,"e":380600,"l":"B"},{"p":860000,"e":655800,"l":"B"},{"p":749900,"e":612200,"l":"A"},{"p":1250000,"e":1246500,"l":"C"},{"p":264900,"e":528200,"l":"C"},{"p":179000,"e":241300,"l":"D"},{"p":299000,"e":2959900,"l":"D"},{"p":750000,"e":670400,"l":"A"},{"p":449000,"e":390600,"l":"A"},{"p":424900,"e":395700,"l":"B"},{"p":525000,"e":515100,"l":"B"},{"p":899000,"e":575900,"l":"B"},{"p":435000,"e":376800,"l":"A"},{"p":359900,"e":292500,"l":"D"},{"p":499000,"e":462300,"l":"A"},{"p":1675000,"e":1562200,"l":"A"},{"p":759900,"e":534000,"l":"D"},{"p":938000,"e":1106400,"l":"B"},{"p":250000,"e":127400,"l":"C"},{"p":649900,"e":628600,"l":"C"},{"p":449000,"e":489600,"l":"A"},{"p":799900,"e":845800,"l":"A"},{"p":425000,"e":349700,"l":"C"},{"p":549900,"e":274400,"l":"D"},{"p":314000,"e":316400,"l":"C"},{"p":314500,"e":390300,"l":"A"},{"p":439000,"e":467800,"l":"A"},{"p":548900,"e":537000,"l":"A"},{"p":299000,"e":845100,"l":"D"},{"p":475000,"e":481300,"l":"C"},{"p":669999,"e":874400,"l":"B"},{"p":1649000,"e":1612000,"l":"A"},{"p":399900,"e":390800,"l":"A"},{"p":685000,"e":717200,"l":"B"},{"p":149000,"e":154700,"l":"D"},{"p":415000,"e":339700,"l":"B"},{"p":224900,"e":218700,"l":"D"},{"p":333000,"e":1281000,"l":"B"},{"p":609000,"e":753100,"l":"A"},{"p":369900,"e":412100,"l":"A"},{"p":319000,"e":312800,"l":"B"},{"p":469000,"e":381400,"l":"B"},{"p":31500000,"e":794800,"l":"C"},{"p":5995000,"e":3779400,"l":"D"},{"p":3250000,"e":960100,"l":"B"},{"p":2150000,"e":1851200,"l":"C"},{"p":1600000,"e":1196400,"l":"B"},{"p":1175000,"e":869100,"l":"C"},{"p":899000,"e":922600,"l":"B"},{"p":640000,"e":647800,"l":"B"},{"p":275000,"e":505300,"l":"C"},{"p":382000,"e":593200,"l":"A"},{"p":385000,"e":313700,"l":"B"},{"p":389900,"e":410300,"l":"A"},{"p":579000,"e":299300,"l":"D"},{"p":399000,"e":319400,"l":"D"},{"p":589900,"e":1068000,"l":"D"},{"p":199000,"e":274000,"l":"B"},{"p":349900,"e":381600,"l":"B"},{"p":559000,"e":701800,"l":"B"},{"p":669900,"e":638400,"l":"A"},{"p":399999,"e":520700,"l":"A"},{"p":499900,"e":400000,"l":"B"},{"p":345000,"e":403700,"l":"A"},{"p":889900,"e":845000,"l":"A"},{"p":485000,"e":194100,"l":"D"},{"p":624600,"e":456600,"l":"B"},{"p":949000,"e":749900,"l":"A"},{"p":445000,"e":345600,"l":"D"},{"p":394000,"e":342800,"l":"A"},{"p":1349000,"e":600900,"l":"D"},{"p":299900,"e":365500,"l":"C"},{"p":374900,"e":410000,"l":"A"},{"p":459000,"e":468300,"l":"B"},{"p":180000,"e":190000,"l":"C"},{"p":1025000,"e":940000,"l":"C"},{"p":524900,"e":790000,"l":"C"},{"p":725000,"e":741100,"l":"C"},{"p":389000,"e":486800,"l":"A"},{"p":519000,"e":580500,"l":"C"},{"p":755000,"e":698300,"l":"A"},{"p":577000,"e":1532600,"l":"B"},{"p":699000,"e":955800,"l":"B"},{"p":1329000,"e":622200,"l":"C"},{"p":599000,"e":650200,"l":"A"},{"p":449000,"e":988600,"l":"B"},{"p":149000,"e":376100,"l":"D"},{"p":1025000,"e":656500,"l":"B"},{"p":679000,"e":556800,"l":"C"},{"p":1384800,"e":1517700,"l":"D"},{"p":415000,"e":653300,"l":"B"},{"p":624900,"e":608800,"l":"A"},{"p":650000,"e":64400,"l":"D"},{"p":529000,"e":634800,"l":"B"},{"p":399000,"e":488000,"l":"A"},{"p":399000,"e":320700,"l":"B"},{"p":110000,"e":220400,"l":"D"},{"p":415000,"e":431500,"l":"D"},{"p":685000,"e":1670900,"l":"D"},{"p":449000,"e":476700,"l":"A"},{"p":139000,"e":227400,"l":"B"},{"p":280000,"e":432300,"l":"A"},{"p":599000,"e":559600,"l":"A"},{"p":210000,"e":199500,"l":"C"},{"p":459000,"e":288100,"l":"B"},{"p":679000,"e":726100,"l":"A"},{"p":599900,"e":670100,"l":"B"},{"p":1238000,"e":1057700,"l":"A"},{"p":349900,"e":4374900,"l":"D"},{"p":1099900,"e":807100,"l":"A"},{"p":499900,"e":486400,"l":"A"},{"p":429000,"e":510200,"l":"A"},{"p":699000,"e":463500,"l":"D"},{"p":223999,"e":257100,"l":"B"},{"p":449900,"e":504100,"l":"A"},{"p":595000,"e":249200,"l":"D"},{"p":180000,"e":190000,"l":"C"},{"p":499000,"e":449400,"l":"D"},{"p":309000,"e":314600,"l":"A"},{"p":429000,"e":530800,"l":"A"},{"p":395000,"e":398400,"l":"A"},{"p":309000,"e":242700,"l":"C"},{"p":194900,"e":995400,"l":"C"},{"p":284900,"e":283100,"l":"B"},{"p":350000,"e":560400,"l":"A"},{"p":949000,"e":824800,"l":"A"},{"p":690000,"e":2445600,"l":"D"},{"p":949000,"e":826000,"l":"B"},{"p":569950,"e":426600,"l":"A"},{"p":672000,"e":602200,"l":"A"},{"p":549000,"e":622100,"l":"B"},{"p":247000,"e":191600,"l":"D"},{"p":339900,"e":408300,"l":"A"},{"p":899000,"e":582100,"l":"A"},{"p":325000,"e":358400,"l":"B"},{"p":799000,"e":765400,"l":"A"},{"p":284500,"e":370300,"l":"A"},{"p":659000,"e":406500,"l":"C"},{"p":389000,"e":483100,"l":"C"},{"p":245000,"e":344400,"l":"D"},{"p":435000,"e":296200,"l":"C"},{"p":725000,"e":481900,"l":"D"},{"p":597000,"e":413800,"l":"B"},{"p":159000,"e":189300,"l":"D"},{"p":364900,"e":606500,"l":"B"},{"p":649900,"e":569600,"l":"A"},{"p":449500,"e":393600,"l":"B"},{"p":183000,"e":436100,"l":"C"},{"p":399900,"e":329500,"l":"C"},{"p":545000,"e":693700,"l":"A"},{"p":9999999,"e":4374900,"l":"D"},{"p":310000,"e":254300,"l":"C"},{"p":599900,"e":534400,"l":"C"},{"p":165000,"e":104100,"l":"D"},{"p":549000,"e":545900,"l":"C"},{"p":225000,"e":201000,"l":"C"},{"p":399900,"e":344900,"l":"B"},{"p":399000,"e":412900,"l":"B"},{"p":1650000,"e":398800,"l":"B"},{"p":365000,"e":557500,"l":"B"},{"p":365000,"e":222000,"l":"D"},{"p":534900,"e":580000,"l":"B"},{"p":949000,"e":931300,"l":"A"},{"p":317498,"e":341200,"l":"A"},{"p":304500,"e":300700,"l":"C"},{"p":329000,"e":575800,"l":"B"},{"p":399000,"e":370900,"l":"B"},{"p":1234567,"e":393700,"l":"A"},{"p":549000,"e":581200,"l":"B"},{"p":728000,"e":1378400,"l":"D"},{"p":439900,"e":416400,"l":"B"},{"p":549000,"e":498200,"l":"A"},{"p":299000,"e":285100,"l":"A"},{"p":849900,"e":304300,"l":"D"},{"p":788800,"e":722500,"l":"B"},{"p":2987000,"e":2369300,"l":"C"},{"p":249900,"e":250000,"l":"A"},{"p":299900,"e":365800,"l":"B"},{"p":344000,"e":389500,"l":"A"},{"p":779000,"e":661300,"l":"A"},{"p":125000,"e":438700,"l":"A"},{"p":332500,"e":370800,"l":"C"},{"p":249000,"e":297400,"l":"A"},{"p":589000,"e":531000,"l":"A"},{"p":550000,"e":511100,"l":"B"},{"p":100000,"e":108800,"l":"D"},{"p":595000,"e":224700,"l":"C"},{"p":649000,"e":693600,"l":"B"},{"p":399900,"e":351100,"l":"C"},{"p":530000,"e":318700,"l":"C"},{"p":1400000,"e":306500,"l":"A"},{"p":560000,"e":521700,"l":"B"},{"p":299000,"e":330300,"l":"D"},{"p":879000,"e":926900,"l":"A"},{"p":294900,"e":347900,"l":"A"},{"p":499000,"e":451400,"l":"A"},{"p":499900,"e":355200,"l":"B"},{"p":199900,"e":125300,"l":"D"},{"p":749000,"e":679100,"l":"A"},{"p":699000,"e":652200,"l":"B"},{"p":469900,"e":571100,"l":"A"},{"p":489000,"e":610300,"l":"A"},{"p":525000,"e":506700,"l":"B"},{"p":239000,"e":327400,"l":"B"},{"p":349000,"e":495600,"l":"A"},{"p":299000,"e":378500,"l":"B"},{"p":544900,"e":560100,"l":"A"},{"p":389000,"e":453700,"l":"B"},{"p":899000,"e":755000,"l":"D"},{"p":419000,"e":489800,"l":"A"},{"p":1397000,"e":1039600,"l":"A"},{"p":529900,"e":526700,"l":"C"},{"p":599900,"e":561300,"l":"A"},{"p":625000,"e":541200,"l":"B"},{"p":699900,"e":759200,"l":"B"},{"p":699000,"e":456300,"l":"B"},{"p":1595000,"e":1565600,"l":"A"},{"p":629000,"e":593800,"l":"A"},{"p":298500,"e":270000,"l":"A"},{"p":629900,"e":856800,"l":"A"},{"p":189000,"e":214800,"l":"C"},{"p":309900,"e":280500,"l":"B"},{"p":880000,"e":823100,"l":"B"},{"p":750000,"e":519700,"l":"B"},{"p":435000,"e":348100,"l":"A"},{"p":975000,"e":405200,"l":"C"},{"p":689900,"e":494900,"l":"A"},{"p":310000,"e":601700,"l":"B"},{"p":90000,"e":184200,"l":"D"},{"p":399000,"e":1050700,"l":"D"},{"p":659000,"e":437800,"l":"C"},{"p":449000,"e":413600,"l":"A"},{"p":505000,"e":595600,"l":"A"},{"p":379000,"e":313400,"l":"B"},{"p":799000,"e":657500,"l":"B"},{"p":1099000,"e":537300,"l":"A"},{"p":599900,"e":579300,"l":"A"},{"p":678000,"e":646900,"l":"C"},{"p":329000,"e":381300,"l":"B"},{"p":279000,"e":325900,"l":"D"},{"p":685000,"e":475800,"l":"D"},{"p":949000,"e":599300,"l":"B"},{"p":539900,"e":575200,"l":"A"},{"p":1499900,"e":917400,"l":"B"},{"p":924000,"e":868800,"l":"A"},{"p":672300,"e":1113000,"l":"D"},{"p":369900,"e":456900,"l":"A"},{"p":182500,"e":367000,"l":"D"},{"p":1349000,"e":221400,"l":"D"},{"p":297000,"e":268800,"l":"A"},{"p":390000,"e":321900,"l":"B"},{"p":799000,"e":733200,"l":"B"},{"p":617500,"e":628800,"l":"A"},{"p":179000,"e":696100,"l":"D"},{"p":339000,"e":594200,"l":"A"},{"p":499000,"e":497300,"l":"A"},{"p":184000,"e":399300,"l":"C"},{"p":369000,"e":378600,"l":"B"},{"p":749000,"e":629800,"l":"A"},{"p":719900,"e":554600,"l":"B"},{"p":299900,"e":303800,"l":"B"},{"p":415000,"e":536700,"l":"B"},{"p":858000,"e":588300,"l":"B"},{"p":579000,"e":746100,"l":"B"},{"p":599000,"e":522700,"l":"B"},{"p":625000,"e":518700,"l":"A"},{"p":180000,"e":239200,"l":"D"},{"p":539000,"e":549300,"l":"A"},{"p":699000,"e":536000,"l":"B"},{"p":1119000,"e":1124500,"l":"B"},{"p":599000,"e":528000,"l":"B"},{"p":549000,"e":612700,"l":"A"},{"p":945000,"e":793300,"l":"A"},{"p":479000,"e":509300,"l":"B"},{"p":1200000,"e":949200,"l":"C"},{"p":425000,"e":531600,"l":"B"},{"p":488000,"e":438400,"l":"C"},{"p":340000,"e":224200,"l":"C"},{"p":229900,"e":175600,"l":"C"},{"p":599000,"e":582800,"l":"A"},{"p":1249000,"e":612800,"l":"D"},{"p":749000,"e":547400,"l":"B"},{"p":519000,"e":414200,"l":"A"},{"p":529900,"e":531700,"l":"A"},{"p":539000,"e":566500,"l":"A"},{"p":594000,"e":549100,"l":"B"},{"p":945000,"e":602900,"l":"D"},{"p":380000,"e":479500,"l":"D"},{"p":739900,"e":635700,"l":"A"},{"p":709000,"e":682100,"l":"A"},{"p":749900,"e":580600,"l":"B"},{"p":569000,"e":469500,"l":"B"},{"p":450000,"e":453100,"l":"A"},{"p":599000,"e":4034700,"l":"D"},{"p":129900,"e":176300,"l":"B"},{"p":1199000,"e":838500,"l":"B"},{"p":793900,"e":1301400,"l":"B"}]}
\ No newline at end of file
added src/data/stats-v2.json +1 −0
@@ -0,0 +1 @@
1 +{"generated":"2026-08-19T04:40:35.066Z","units":{"total":3747008,"geoloc":3747008,"with_role":3712054,"with_est2026":3747008,"with_year":2975692,"with_area":2941544,"avg_est2026":536281,"avg_role":564737,"sum_role":2096334562092,"logements":4400551},"valeur_bins":[{"label":"< 100 k$","value":494468},{"label":"100-200 k$","value":276362},{"label":"200-300 k$","value":358115},{"label":"300-400 k$","value":511927},{"label":"400-500 k$","value":520800},{"label":"500-750 k$","value":909027},{"label":"750 k-1 M$","value":360392},{"label":"1-2 M$","value":240257},{"label":"2-5 M$","value":67100},{"label":"5 M$ +","value":8560}],"construction":[{"label":"Avant 1900","n":48075,"total":26591637900,"moyenne":553128},{"label":"1900-1945","n":259477,"total":160491636400,"moyenne":618519},{"label":"1946-1960","n":356934,"total":207856647700,"moyenne":582339},{"label":"1961-1975","n":552501,"total":308533030000,"moyenne":558429},{"label":"1976-1990","n":648157,"total":365316144600,"moyenne":563622},{"label":"1991-2005","n":479490,"total":298924353100,"moyenne":623421},{"label":"2006-2015","n":396193,"total":247187053700,"moyenne":623905},{"label":"2016 +","n":234865,"total":180329195800,"moyenne":767799}],"rec":{"max_role":{"municipalite":"Montréal","v":2265653300,"cubf_libelle":"Service d’hôpital"},"max_est":{"municipalite":"Westmount","v":10449400},"max_terrain":{"municipalite":"Eeyou Istchee Baie-James","m2":331730646000},"max_logements":{"municipalite":"Notre-Dame-de-Pontmain","n":6379},"oldest":{"y":1604,"n":1}},"tx":{"monthly":[{"m":"2021-01","n":2478,"total":832190551,"median":285000},{"m":"2021-02","n":9887,"total":3415302186,"median":279500},{"m":"2021-03","n":12600,"total":4412076364,"median":285000},{"m":"2021-04","n":15613,"total":5492649396,"median":295000},{"m":"2021-05","n":18462,"total":6592052909,"median":300000},{"m":"2021-06","n":28313,"total":10163561453,"median":305000},{"m":"2021-07","n":17268,"total":6402556791,"median":311191},{"m":"2021-08","n":12405,"total":4810397679,"median":320000},{"m":"2021-09","n":12201,"total":4485776751,"median":304696},{"m":"2021-10","n":11676,"total":4721137624,"median":310000},{"m":"2021-11","n":12343,"total":4761685198,"median":319000},{"m":"2021-12","n":12295,"total":4801306198,"median":322500},{"m":"2022-01","n":7457,"total":2969877855,"median":334000},{"m":"2022-02","n":8645,"total":3321101913,"median":315000},{"m":"2022-03","n":10334,"total":4131944336,"median":337240},{"m":"2022-04","n":11926,"total":4807878945,"median":345000},{"m":"2022-05","n":14912,"total":6177385049,"median":357000},{"m":"2022-06","n":22511,"total":9331666784,"median":359900},{"m":"2022-07","n":14530,"total":6070653720,"median":358000},{"m":"2022-08","n":10081,"total":4247942446,"median":362145},{"m":"2022-09","n":10575,"total":4238442146,"median":339000},{"m":"2022-10","n":9077,"total":3633429348,"median":338981},{"m":"2022-11","n":8319,"total":3357880682,"median":336000},{"m":"2022-12","n":9295,"total":3666097604,"median":340180},{"m":"2023-01","n":4841,"total":1956647021,"median":340000},{"m":"2023-02","n":5127,"total":1989599014,"median":325000},{"m":"2023-03","n":7178,"total":2855461815,"median":342000},{"m":"2023-04","n":7068,"total":2878298271,"median":355000},{"m":"2023-05","n":11496,"total":4723272953,"median":360000},{"m":"2023-06","n":16426,"total":6940426017,"median":374500},{"m":"2023-07","n":12910,"total":5410664862,"median":362000},{"m":"2023-08","n":9844,"total":4407282559,"median":385000},{"m":"2023-09","n":8997,"total":3814118335,"median":360000},{"m":"2023-10","n":8421,"total":3584446489,"median":360000},{"m":"2023-11","n":8320,"total":3556644872,"median":360000},{"m":"2023-12","n":7801,"total":3248051010,"median":350000},{"m":"2024-01","n":4638,"total":1942605019,"median":359000},{"m":"2024-02","n":5398,"total":2108465395,"median":340000},{"m":"2024-03","n":6683,"total":2886379564,"median":370000},{"m":"2024-04","n":9012,"total":3959212390,"median":379000},{"m":"2024-05","n":14287,"total":6403688939,"median":394309},{"m":"2024-06","n":18568,"total":8832119684,"median":406000},{"m":"2024-07","n":12940,"total":5870397061,"median":395000},{"m":"2024-08","n":10568,"total":4992753803,"median":415000},{"m":"2024-09","n":9409,"total":4249451886,"median":386000},{"m":"2024-10","n":10575,"total":4892819097,"median":395000},{"m":"2024-11","n":10782,"total":5072359199,"median":400000},{"m":"2024-12","n":10297,"total":4926863012,"median":400000},{"m":"2025-01","n":7406,"total":3500186035,"median":408500},{"m":"2025-02","n":7460,"total":3412787232,"median":398000},{"m":"2025-03","n":7894,"total":3898395707,"median":420000},{"m":"2025-04","n":10471,"total":5045094182,"median":424000},{"m":"2025-05","n":14840,"total":7344199087,"median":435000},{"m":"2025-06","n":19783,"total":9852231651,"median":440000},{"m":"2025-07","n":14742,"total":7389069444,"median":435000},{"m":"2025-08","n":11272,"total":5880367206,"median":450000},{"m":"2025-09","n":10849,"total":5382078317,"median":424500},{"m":"2025-10","n":12006,"total":6057018208,"median":425000},{"m":"2025-11","n":10084,"total":5075627320,"median":435000},{"m":"2025-12","n":10702,"total":5500022138,"median":440000},{"m":"2026-01","n":6791,"total":3550760902,"median":430000},{"m":"2026-02","n":6804,"total":3421246558,"median":425000},{"m":"2026-03","n":7859,"total":4011226615,"median":435000},{"m":"2026-04","n":9900,"total":5053909623,"median":449000},{"m":"2026-05","n":12758,"total":6771122975,"median":460000},{"m":"2026-06","n":18180,"total":9714168795,"median":470000},{"m":"2026-07","n":10529,"total":5540638225,"median":455000}],"monthly_by_type":{"keys":["Unifamiliale","Condo","Plex","Indéterminé"],"points":[{"t":"2021-01","values":[1304,289,358,527]},{"t":"2021-02","values":[5146,1133,1640,1968]},{"t":"2021-03","values":[6535,1554,1853,2658]},{"t":"2021-04","values":[8594,1936,2071,3012]},{"t":"2021-05","values":[10700,2178,2258,3326]},{"t":"2021-06","values":[17370,3278,3066,4599]},{"t":"2021-07","values":[10039,1856,2215,3158]},{"t":"2021-08","values":[7196,1330,1563,2316]},{"t":"2021-09","values":[6848,1277,1582,2494]},{"t":"2021-10","values":[6460,1205,1589,2422]},{"t":"2021-11","values":[6838,1230,1661,2614]},{"t":"2021-12","values":[6340,1321,2009,2625]},{"t":"2022-01","values":[3938,968,1242,1309]},{"t":"2022-02","values":[4467,1059,1347,1772]},{"t":"2022-03","values":[5423,1310,1567,2034]},{"t":"2022-04","values":[6571,1534,1661,2160]},{"t":"2022-05","values":[8483,2024,1888,2517]},{"t":"2022-06","values":[13960,2771,2597,3183]},{"t":"2022-07","values":[8769,1618,1784,2359]},{"t":"2022-08","values":[6325,1072,1129,1555]},{"t":"2022-09","values":[6276,1104,1208,1987]},{"t":"2022-10","values":[5385,880,1055,1757]},{"t":"2022-11","values":[4954,893,916,1556]},{"t":"2022-12","values":[5202,1025,1173,1895]},{"t":"2023-01","values":[2850,872,613,506]},{"t":"2023-02","values":[3000,920,605,602]},{"t":"2023-03","values":[4147,1365,880,786]},{"t":"2023-04","values":[4178,1236,791,863]},{"t":"2023-05","values":[7048,1964,1205,1279]},{"t":"2023-06","values":[10554,2633,1669,1570]},{"t":"2023-07","values":[8139,1973,1350,1448]},{"t":"2023-08","values":[6161,1265,1041,1377]},{"t":"2023-09","values":[5420,1115,1031,1431]},{"t":"2023-10","values":[5138,999,967,1317]},{"t":"2023-11","values":[4939,1080,1004,1297]},{"t":"2023-12","values":[4406,971,1218,1206]},{"t":"2024-01","values":[2708,591,607,732]},{"t":"2024-02","values":[3129,683,716,870]},{"t":"2024-03","values":[3878,1124,862,819]},{"t":"2024-04","values":[5380,1670,1090,872]},{"t":"2024-05","values":[8985,2577,1612,1113]},{"t":"2024-06","values":[11471,3130,2433,1534]},{"t":"2024-07","values":[8245,2067,1331,1297]},{"t":"2024-08","values":[6743,1782,1140,903]},{"t":"2024-09","values":[5763,1493,1070,1083]},{"t":"2024-10","values":[6594,1690,1187,1104]},{"t":"2024-11","values":[6499,1774,1403,1106]},{"t":"2024-12","values":[5914,1624,1656,1103]},{"t":"2025-01","values":[4360,1326,1109,611]},{"t":"2025-02","values":[4349,1283,1072,756]},{"t":"2025-03","values":[4662,1408,1109,715]},{"t":"2025-04","values":[6224,1735,1334,1178]},{"t":"2025-05","values":[9171,2404,1730,1535]},{"t":"2025-06","values":[12778,2933,2141,1931]},{"t":"2025-07","values":[9399,2083,1750,1510]},{"t":"2025-08","values":[7224,1607,1323,1118]},{"t":"2025-09","values":[6680,1528,1394,1247]},{"t":"2025-10","values":[7373,1560,1597,1476]},{"t":"2025-11","values":[6172,1267,1415,1230]},{"t":"2025-12","values":[6125,1290,1772,1515]},{"t":"2026-01","values":[3914,893,1125,859]},{"t":"2026-02","values":[3836,716,1080,1172]},{"t":"2026-03","values":[4434,960,1158,1307]},{"t":"2026-04","values":[5870,1192,1277,1561]},{"t":"2026-05","values":[7903,1450,1527,1878]},{"t":"2026-06","values":[11546,2072,2006,2556]},{"t":"2026-07","values":[6646,1068,1335,1480]}]},"amount_bins":[{"label":"< 200 k$","value":126831},{"label":"200-300 k$","value":135584},{"label":"300-400 k$","value":147919},{"label":"400-500 k$","value":114996},{"label":"500-700 k$","value":126115},{"label":"700 k-1 M$","value":63116},{"label":"1-2 M$","value":26736},{"label":"2 M$ +","value":3822}],"daily":[{"date":"2026-01-26","n":397},{"date":"2026-01-27","n":354},{"date":"2026-01-28","n":305},{"date":"2026-01-29","n":461},{"date":"2026-01-30","n":694},{"date":"2026-02-02","n":501},{"date":"2026-02-03","n":325},{"date":"2026-02-04","n":221},{"date":"2026-02-05","n":272},{"date":"2026-02-06","n":340},{"date":"2026-02-09","n":252},{"date":"2026-02-10","n":228},{"date":"2026-02-11","n":208},{"date":"2026-02-12","n":298},{"date":"2026-02-13","n":405},{"date":"2026-02-16","n":355},{"date":"2026-02-17","n":310},{"date":"2026-02-18","n":246},{"date":"2026-02-19","n":303},{"date":"2026-02-20","n":385},{"date":"2026-02-23","n":295},{"date":"2026-02-24","n":297},{"date":"2026-02-25","n":321},{"date":"2026-02-26","n":515},{"date":"2026-02-27","n":727},{"date":"2026-03-02","n":497},{"date":"2026-03-03","n":330},{"date":"2026-03-04","n":221},{"date":"2026-03-05","n":246},{"date":"2026-03-06","n":301},{"date":"2026-03-09","n":259},{"date":"2026-03-10","n":246},{"date":"2026-03-11","n":254},{"date":"2026-03-12","n":287},{"date":"2026-03-13","n":476},{"date":"2026-03-16","n":433},{"date":"2026-03-17","n":310},{"date":"2026-03-18","n":301},{"date":"2026-03-19","n":327},{"date":"2026-03-20","n":472},{"date":"2026-03-23","n":414},{"date":"2026-03-24","n":368},{"date":"2026-03-25","n":406},{"date":"2026-03-26","n":371},{"date":"2026-03-27","n":336},{"date":"2026-03-30","n":443},{"date":"2026-03-31","n":561},{"date":"2026-04-01","n":893},{"date":"2026-04-02","n":713},{"date":"2026-04-07","n":607},{"date":"2026-04-08","n":312},{"date":"2026-04-09","n":359},{"date":"2026-04-10","n":431},{"date":"2026-04-13","n":314},{"date":"2026-04-14","n":341},{"date":"2026-04-15","n":510},{"date":"2026-04-16","n":509},{"date":"2026-04-17","n":511},{"date":"2026-04-20","n":400},{"date":"2026-04-21","n":355},{"date":"2026-04-22","n":312},{"date":"2026-04-23","n":433},{"date":"2026-04-24","n":497},{"date":"2026-04-27","n":520},{"date":"2026-04-28","n":509},{"date":"2026-04-29","n":466},{"date":"2026-04-30","n":908},{"date":"2026-05-01","n":1447},{"date":"2026-05-04","n":799},{"date":"2026-05-05","n":428},{"date":"2026-05-06","n":330},{"date":"2026-05-07","n":425},{"date":"2026-05-08","n":521},{"date":"2026-05-11","n":476},{"date":"2026-05-12","n":406},{"date":"2026-05-13","n":360},{"date":"2026-05-14","n":531},{"date":"2026-05-15","n":965},{"date":"2026-05-19","n":653},{"date":"2026-05-20","n":464},{"date":"2026-05-21","n":515},{"date":"2026-05-22","n":592},{"date":"2026-05-25","n":628},{"date":"2026-05-26","n":629},{"date":"2026-05-27","n":590},{"date":"2026-05-28","n":868},{"date":"2026-05-29","n":1131},{"date":"2026-06-01","n":1577},{"date":"2026-06-02","n":1133},{"date":"2026-06-03","n":530},{"date":"2026-06-04","n":530},{"date":"2026-06-05","n":724},{"date":"2026-06-08","n":709},{"date":"2026-06-09","n":526},{"date":"2026-06-10","n":533},{"date":"2026-06-11","n":685},{"date":"2026-06-12","n":846},{"date":"2026-06-15","n":1124},{"date":"2026-06-16","n":1001},{"date":"2026-06-17","n":674},{"date":"2026-06-18","n":790},{"date":"2026-06-19","n":1098},{"date":"2026-06-22","n":1000},{"date":"2026-06-23","n":992},{"date":"2026-06-25","n":1021},{"date":"2026-06-26","n":1213},{"date":"2026-06-29","n":2},{"date":"2026-06-30","n":1472},{"date":"2026-07-02","n":1409},{"date":"2026-07-03","n":698},{"date":"2026-07-06","n":628},{"date":"2026-07-07","n":560},{"date":"2026-07-08","n":477},{"date":"2026-07-09","n":540},{"date":"2026-07-10","n":729},{"date":"2026-07-13","n":641},{"date":"2026-07-15","n":884},{"date":"2026-07-16","n":799},{"date":"2026-07-17","n":649},{"date":"2026-07-20","n":512},{"date":"2026-07-21","n":439},{"date":"2026-07-22","n":391},{"date":"2026-07-23","n":407},{"date":"2026-07-24","n":448},{"date":"2026-07-27","n":318}],"max_date":"2026-07-27","max":{"amount":345000000,"city":"Gatineau","date":"2021-10-04"},"best_month":{"m":"2021-06","n":28313},"top_villes":[{"city":"Montréal","n":103061,"moyenne":617863},{"city":"Québec","n":47820,"moyenne":374004},{"city":"Laval","n":28651,"moyenne":539716},{"city":"Gatineau","n":27357,"moyenne":452710},{"city":"Longueuil","n":19851,"moyenne":492210},{"city":"Lévis","n":14499,"moyenne":343601},{"city":"Sherbrooke","n":14350,"moyenne":379443},{"city":"Saguenay","n":12896,"moyenne":287136},{"city":"Trois-Rivières","n":12224,"moyenne":320947},{"city":"Terrebonne","n":10839,"moyenne":484625},{"city":"Brossard","n":9310,"moyenne":540786},{"city":"Saint-Jérôme","n":8614,"moyenne":431547},{"city":"Saint-Jean-sur-Richelieu","n":8560,"moyenne":471788},{"city":"Repentigny","n":7735,"moyenne":461525},{"city":"Drummondville","n":7401,"moyenne":345790},{"city":"Mirabel","n":7142,"moyenne":507843},{"city":"Granby","n":6744,"moyenne":372945},{"city":"Mascouche","n":5884,"moyenne":454663},{"city":"Blainville","n":5549,"moyenne":638530},{"city":"Shawinigan","n":5328,"moyenne":249234}]},"market":{"idx_by_type":[{"label":"Condo","points":[{"t":"2021-01","v":0.746},{"t":"2021-02","v":0.749},{"t":"2021-03","v":0.763},{"t":"2021-04","v":0.763},{"t":"2021-05","v":0.763},{"t":"2021-06","v":0.778},{"t":"2021-07","v":0.808},{"t":"2021-08","v":0.813},{"t":"2021-09","v":0.813},{"t":"2021-10","v":0.813},{"t":"2021-11","v":0.818},{"t":"2021-12","v":0.829},{"t":"2022-01","v":0.829},{"t":"2022-02","v":0.84},{"t":"2022-03","v":0.847},{"t":"2022-04","v":0.857},{"t":"2022-05","v":0.857},{"t":"2022-06","v":0.849},{"t":"2022-07","v":0.849},{"t":"2022-08","v":0.849},{"t":"2022-09","v":0.865},{"t":"2022-10","v":0.865},{"t":"2022-11","v":0.865},{"t":"2022-12","v":0.851},{"t":"2023-01","v":0.851},{"t":"2023-02","v":0.844},{"t":"2023-03","v":0.842},{"t":"2023-04","v":0.842},{"t":"2023-05","v":0.842},{"t":"2023-06","v":0.842},{"t":"2023-07","v":0.852},{"t":"2023-08","v":0.923},{"t":"2023-09","v":0.923},{"t":"2023-10","v":0.923},{"t":"2023-11","v":0.929},{"t":"2023-12","v":0.943},{"t":"2024-01","v":0.929},{"t":"2024-02","v":0.892},{"t":"2024-03","v":0.86},{"t":"2024-04","v":0.858},{"t":"2024-05","v":0.828},{"t":"2024-06","v":0.828},{"t":"2024-07","v":0.861},{"t":"2024-08","v":0.861},{"t":"2024-09","v":0.867},{"t":"2024-10","v":0.87},{"t":"2024-11","v":0.873},{"t":"2024-12","v":0.881},{"t":"2025-01","v":0.893},{"t":"2025-02","v":0.905},{"t":"2025-03","v":0.912},{"t":"2025-04","v":0.913},{"t":"2025-05","v":0.915},{"t":"2025-06","v":0.915},{"t":"2025-07","v":0.915},{"t":"2025-08","v":0.931},{"t":"2025-09","v":0.931},{"t":"2025-10","v":0.931},{"t":"2025-11","v":0.957},{"t":"2025-12","v":0.963},{"t":"2026-01","v":0.978},{"t":"2026-02","v":1.013},{"t":"2026-03","v":1.014},{"t":"2026-04","v":1.013},{"t":"2026-05","v":0.997},{"t":"2026-06","v":0.997},{"t":"2026-07","v":1}]},{"label":"Ensemble","points":[{"t":"2021-01","v":0.508},{"t":"2021-02","v":0.55},{"t":"2021-03","v":0.54},{"t":"2021-04","v":0.62},{"t":"2021-05","v":0.62},{"t":"2021-06","v":0.62},{"t":"2021-07","v":0.612},{"t":"2021-08","v":0.612},{"t":"2021-09","v":0.623},{"t":"2021-10","v":0.623},{"t":"2021-11","v":0.526},{"t":"2021-12","v":0.526},{"t":"2022-01","v":0.526},{"t":"2022-02","v":0.624},{"t":"2022-03","v":0.513},{"t":"2022-04","v":0.624},{"t":"2022-05","v":0.628},{"t":"2022-06","v":0.667},{"t":"2022-07","v":0.628},{"t":"2022-08","v":0.69},{"t":"2022-09","v":0.614},{"t":"2022-10","v":0.676},{"t":"2022-11","v":0.602},{"t":"2022-12","v":0.647},{"t":"2023-01","v":0.495},{"t":"2023-02","v":0.647},{"t":"2023-03","v":0.571},{"t":"2023-04","v":0.571},{"t":"2023-05","v":0.571},{"t":"2023-06","v":0.548},{"t":"2023-07","v":0.769},{"t":"2023-08","v":0.582},{"t":"2023-09","v":0.64},{"t":"2023-10","v":0.6},{"t":"2023-11","v":0.6},{"t":"2023-12","v":0.571},{"t":"2024-01","v":0.571},{"t":"2024-02","v":0.807},{"t":"2024-03","v":0.807},{"t":"2024-04","v":0.813},{"t":"2024-05","v":0.688},{"t":"2024-06","v":0.792},{"t":"2024-07","v":0.792},{"t":"2024-08","v":0.906},{"t":"2024-09","v":0.906},{"t":"2024-10","v":0.92},{"t":"2024-11","v":0.903},{"t":"2024-12","v":0.903},{"t":"2025-01","v":0.903},{"t":"2025-02","v":0.758},{"t":"2025-03","v":0.758},{"t":"2025-04","v":0.796},{"t":"2025-05","v":0.855},{"t":"2025-06","v":0.821},{"t":"2025-07","v":1.027},{"t":"2025-08","v":0.821},{"t":"2025-09","v":1.21},{"t":"2025-10","v":0.784},{"t":"2025-11","v":0.787},{"t":"2025-12","v":0.739},{"t":"2026-01","v":0.787},{"t":"2026-02","v":0.8},{"t":"2026-03","v":0.985},{"t":"2026-04","v":0.992},{"t":"2026-05","v":1.019},{"t":"2026-06","v":1.019},{"t":"2026-07","v":1}]},{"label":"Plex","points":[{"t":"2021-01","v":0.58},{"t":"2021-02","v":0.579},{"t":"2021-03","v":0.579},{"t":"2021-04","v":0.629},{"t":"2021-05","v":0.645},{"t":"2021-06","v":0.645},{"t":"2021-07","v":0.642},{"t":"2021-08","v":0.639},{"t":"2021-09","v":0.575},{"t":"2021-10","v":0.575},{"t":"2021-11","v":0.631},{"t":"2021-12","v":0.643},{"t":"2022-01","v":0.643},{"t":"2022-02","v":0.643},{"t":"2022-03","v":0.655},{"t":"2022-04","v":0.657},{"t":"2022-05","v":0.736},{"t":"2022-06","v":0.736},{"t":"2022-07","v":0.736},{"t":"2022-08","v":0.682},{"t":"2022-09","v":0.682},{"t":"2022-10","v":0.682},{"t":"2022-11","v":0.682},{"t":"2022-12","v":0.623},{"t":"2023-01","v":0.588},{"t":"2023-02","v":0.588},{"t":"2023-03","v":0.597},{"t":"2023-04","v":0.699},{"t":"2023-05","v":0.7},{"t":"2023-06","v":0.728},{"t":"2023-07","v":0.75},{"t":"2023-08","v":0.728},{"t":"2023-09","v":0.719},{"t":"2023-10","v":0.719},{"t":"2023-11","v":0.719},{"t":"2023-12","v":0.719},{"t":"2024-01","v":0.714},{"t":"2024-02","v":0.687},{"t":"2024-03","v":0.687},{"t":"2024-04","v":0.755},{"t":"2024-05","v":0.81},{"t":"2024-06","v":0.81},{"t":"2024-07","v":0.833},{"t":"2024-08","v":0.788},{"t":"2024-09","v":0.784},{"t":"2024-10","v":0.784},{"t":"2024-11","v":0.811},{"t":"2024-12","v":0.813},{"t":"2025-01","v":0.811},{"t":"2025-02","v":0.813},{"t":"2025-03","v":0.835},{"t":"2025-04","v":0.871},{"t":"2025-05","v":0.903},{"t":"2025-06","v":0.929},{"t":"2025-07","v":0.949},{"t":"2025-08","v":0.929},{"t":"2025-09","v":0.917},{"t":"2025-10","v":0.917},{"t":"2025-11","v":0.917},{"t":"2025-12","v":0.917},{"t":"2026-01","v":0.907},{"t":"2026-02","v":0.907},{"t":"2026-03","v":0.911},{"t":"2026-04","v":0.926},{"t":"2026-05","v":1.005},{"t":"2026-06","v":1.005},{"t":"2026-07","v":1}]},{"label":"Unifamiliale","points":[{"t":"2021-01","v":0.572},{"t":"2021-02","v":0.579},{"t":"2021-03","v":0.584},{"t":"2021-04","v":0.606},{"t":"2021-05","v":0.62},{"t":"2021-06","v":0.647},{"t":"2021-07","v":0.647},{"t":"2021-08","v":0.647},{"t":"2021-09","v":0.659},{"t":"2021-10","v":0.659},{"t":"2021-11","v":0.669},{"t":"2021-12","v":0.672},{"t":"2022-01","v":0.672},{"t":"2022-02","v":0.682},{"t":"2022-03","v":0.697},{"t":"2022-04","v":0.724},{"t":"2022-05","v":0.749},{"t":"2022-06","v":0.771},{"t":"2022-07","v":0.775},{"t":"2022-08","v":0.775},{"t":"2022-09","v":0.731},{"t":"2022-10","v":0.729},{"t":"2022-11","v":0.722},{"t":"2022-12","v":0.725},{"t":"2023-01","v":0.719},{"t":"2023-02","v":0.723},{"t":"2023-03","v":0.723},{"t":"2023-04","v":0.754},{"t":"2023-05","v":0.766},{"t":"2023-06","v":0.783},{"t":"2023-07","v":0.797},{"t":"2023-08","v":0.783},{"t":"2023-09","v":0.775},{"t":"2023-10","v":0.769},{"t":"2023-11","v":0.758},{"t":"2023-12","v":0.758},{"t":"2024-01","v":0.734},{"t":"2024-02","v":0.766},{"t":"2024-03","v":0.79},{"t":"2024-04","v":0.809},{"t":"2024-05","v":0.843},{"t":"2024-06","v":0.843},{"t":"2024-07","v":0.859},{"t":"2024-08","v":0.834},{"t":"2024-09","v":0.832},{"t":"2024-10","v":0.832},{"t":"2024-11","v":0.837},{"t":"2024-12","v":0.839},{"t":"2025-01","v":0.839},{"t":"2025-02","v":0.842},{"t":"2025-03","v":0.882},{"t":"2025-04","v":0.883},{"t":"2025-05","v":0.918},{"t":"2025-06","v":0.924},{"t":"2025-07","v":0.95},{"t":"2025-08","v":0.924},{"t":"2025-09","v":0.915},{"t":"2025-10","v":0.915},{"t":"2025-11","v":0.915},{"t":"2025-12","v":0.918},{"t":"2026-01","v":0.915},{"t":"2026-02","v":0.924},{"t":"2026-03","v":0.929},{"t":"2026-04","v":0.961},{"t":"2026-05","v":0.988},{"t":"2026-06","v":0.988},{"t":"2026-07","v":1}]}],"ppm2_by_type":[{"label":"Condo","points":[{"t":"2021-01","v":3692},{"t":"2021-02","v":3704},{"t":"2021-03","v":3775},{"t":"2021-04","v":3775},{"t":"2021-05","v":3775},{"t":"2021-06","v":3847},{"t":"2021-07","v":3997},{"t":"2021-08","v":4025},{"t":"2021-09","v":4025},{"t":"2021-10","v":4025},{"t":"2021-11","v":4046},{"t":"2021-12","v":4102},{"t":"2022-01","v":4102},{"t":"2022-02","v":4159},{"t":"2022-03","v":4191},{"t":"2022-04","v":4239},{"t":"2022-05","v":4239},{"t":"2022-06","v":4201},{"t":"2022-07","v":4201},{"t":"2022-08","v":4201},{"t":"2022-09","v":4279},{"t":"2022-10","v":4279},{"t":"2022-11","v":4279},{"t":"2022-12","v":4208},{"t":"2023-01","v":4208},{"t":"2023-02","v":4175},{"t":"2023-03","v":4168},{"t":"2023-04","v":4167},{"t":"2023-05","v":4167},{"t":"2023-06","v":4167},{"t":"2023-07","v":4215},{"t":"2023-08","v":4567},{"t":"2023-09","v":4567},{"t":"2023-10","v":4567},{"t":"2023-11","v":4598},{"t":"2023-12","v":4665},{"t":"2024-01","v":4598},{"t":"2024-02","v":4412},{"t":"2024-03","v":4257},{"t":"2024-04","v":4247},{"t":"2024-05","v":4096},{"t":"2024-06","v":4096},{"t":"2024-07","v":4262},{"t":"2024-08","v":4262},{"t":"2024-09","v":4289},{"t":"2024-10","v":4303},{"t":"2024-11","v":4319},{"t":"2024-12","v":4359},{"t":"2025-01","v":4418},{"t":"2025-02","v":4478},{"t":"2025-03","v":4514},{"t":"2025-04","v":4518},{"t":"2025-05","v":4528},{"t":"2025-06","v":4526},{"t":"2025-07","v":4528},{"t":"2025-08","v":4605},{"t":"2025-09","v":4605},{"t":"2025-10","v":4605},{"t":"2025-11","v":4737},{"t":"2025-12","v":4764},{"t":"2026-01","v":4838},{"t":"2026-02","v":5014},{"t":"2026-03","v":5017},{"t":"2026-04","v":5014},{"t":"2026-05","v":4934},{"t":"2026-06","v":4934},{"t":"2026-07","v":4948}]},{"label":"Plex","points":[{"t":"2021-01","v":1898},{"t":"2021-02","v":1894},{"t":"2021-03","v":1894},{"t":"2021-04","v":2059},{"t":"2021-05","v":2111},{"t":"2021-06","v":2111},{"t":"2021-07","v":2101},{"t":"2021-08","v":2091},{"t":"2021-09","v":1883},{"t":"2021-10","v":1883},{"t":"2021-11","v":2064},{"t":"2021-12","v":2104},{"t":"2022-01","v":2104},{"t":"2022-02","v":2104},{"t":"2022-03","v":2144},{"t":"2022-04","v":2152},{"t":"2022-05","v":2408},{"t":"2022-06","v":2408},{"t":"2022-07","v":2408},{"t":"2022-08","v":2232},{"t":"2022-09","v":2233},{"t":"2022-10","v":2233},{"t":"2022-11","v":2233},{"t":"2022-12","v":2038},{"t":"2023-01","v":1925},{"t":"2023-02","v":1925},{"t":"2023-03","v":1954},{"t":"2023-04","v":2288},{"t":"2023-05","v":2292},{"t":"2023-06","v":2381},{"t":"2023-07","v":2455},{"t":"2023-08","v":2381},{"t":"2023-09","v":2354},{"t":"2023-10","v":2354},{"t":"2023-11","v":2354},{"t":"2023-12","v":2352},{"t":"2024-01","v":2338},{"t":"2024-02","v":2250},{"t":"2024-03","v":2250},{"t":"2024-04","v":2472},{"t":"2024-05","v":2650},{"t":"2024-06","v":2650},{"t":"2024-07","v":2727},{"t":"2024-08","v":2580},{"t":"2024-09","v":2566},{"t":"2024-10","v":2566},{"t":"2024-11","v":2653},{"t":"2024-12","v":2660},{"t":"2025-01","v":2653},{"t":"2025-02","v":2660},{"t":"2025-03","v":2731},{"t":"2025-04","v":2849},{"t":"2025-05","v":2954},{"t":"2025-06","v":3042},{"t":"2025-07","v":3105},{"t":"2025-08","v":3042},{"t":"2025-09","v":3000},{"t":"2025-10","v":3000},{"t":"2025-11","v":3002},{"t":"2025-12","v":3002},{"t":"2026-01","v":2968},{"t":"2026-02","v":2968},{"t":"2026-03","v":2981},{"t":"2026-04","v":3029},{"t":"2026-05","v":3287},{"t":"2026-06","v":3287},{"t":"2026-07","v":3272}]},{"label":"Unifamiliale","points":[{"t":"2021-01","v":2412},{"t":"2021-02","v":2442},{"t":"2021-03","v":2463},{"t":"2021-04","v":2555},{"t":"2021-05","v":2613},{"t":"2021-06","v":2727},{"t":"2021-07","v":2729},{"t":"2021-08","v":2729},{"t":"2021-09","v":2778},{"t":"2021-10","v":2778},{"t":"2021-11","v":2821},{"t":"2021-12","v":2831},{"t":"2022-01","v":2831},{"t":"2022-02","v":2874},{"t":"2022-03","v":2938},{"t":"2022-04","v":3049},{"t":"2022-05","v":3158},{"t":"2022-06","v":3249},{"t":"2022-07","v":3268},{"t":"2022-08","v":3268},{"t":"2022-09","v":3082},{"t":"2022-10","v":3073},{"t":"2022-11","v":3042},{"t":"2022-12","v":3054},{"t":"2023-01","v":3032},{"t":"2023-02","v":3048},{"t":"2023-03","v":3048},{"t":"2023-04","v":3178},{"t":"2023-05","v":3229},{"t":"2023-06","v":3300},{"t":"2023-07","v":3360},{"t":"2023-08","v":3300},{"t":"2023-09","v":3265},{"t":"2023-10","v":3241},{"t":"2023-11","v":3193},{"t":"2023-12","v":3193},{"t":"2024-01","v":3093},{"t":"2024-02","v":3230},{"t":"2024-03","v":3331},{"t":"2024-04","v":3407},{"t":"2024-05","v":3554},{"t":"2024-06","v":3554},{"t":"2024-07","v":3619},{"t":"2024-08","v":3516},{"t":"2024-09","v":3507},{"t":"2024-10","v":3507},{"t":"2024-11","v":3527},{"t":"2024-12","v":3536},{"t":"2025-01","v":3536},{"t":"2025-02","v":3550},{"t":"2025-03","v":3717},{"t":"2025-04","v":3720},{"t":"2025-05","v":3869},{"t":"2025-06","v":3893},{"t":"2025-07","v":4003},{"t":"2025-08","v":3893},{"t":"2025-09","v":3857},{"t":"2025-10","v":3857},{"t":"2025-11","v":3857},{"t":"2025-12","v":3871},{"t":"2026-01","v":3857},{"t":"2026-02","v":3895},{"t":"2026-03","v":3915},{"t":"2026-04","v":4050},{"t":"2026-05","v":4162},{"t":"2026-06","v":4162},{"t":"2026-07","v":4214}]}]}}
\ No newline at end of file
added src/data/stats.json +1 −0
@@ -0,0 +1 @@
1 +{"generated": "2026-08-08", "unites": 3747008, "valeur_totale_2026": 2009452277400.0, "valeur_role_totale": 2096334562092.0, "valeur_mediane_2026": 444300.0, "croissance_2021_2026_pct": 43.1, "logements": 4400551, "aire_etages_km2": 564.6, "terrain_km2": 581877.5, "municipalites": 1097, "totaux_annee": [{"year": 2021, "total": 1306890374700.0, "n": 3408497}, {"year": 2022, "total": 1536109539000.0, "n": 3483165}, {"year": 2023, "total": 1566510377800.0, "n": 3569968}, {"year": 2024, "total": 1685233201800.0, "n": 3627943}, {"year": 2025, "total": 1868685324500.0, "n": 3685339}, {"year": 2026, "total": 2009452277400.0, "n": 3747008}], "par_type": [{"type": "unifamilial", "n": 2637740, "total": 1582355500700.0, "mediane": 513700.0}, {"type": "autre", "n": 487333, "total": 289766553000.0, "mediane": 204000.0}, {"type": "terrain", "n": 439099, "total": 68104182400.0, "mediane": 82100.0}, {"type": "chalet", "n": 130664, "total": 44286029400.0, "mediane": 239300.0}, {"type": "maison_mobile", "n": 5428, "total": 10101293300.0, "mediane": 930300.0}, {"type": "plex", "n": 44892, "total": 8872346300.0, "mediane": 193100.0}, {"type": "condo_ou_multi", "n": 1852, "total": 5966372300.0, "mediane": 3108150.0}], "par_ville": [{"ville": "Montréal", "n": 437192, "total": 378543241300.0, "mediane": 723500.0}, {"ville": "Québec", "n": 175478, "total": 106043857600.0, "mediane": 483800.0}, {"ville": "Laval", "n": 154876, "total": 103350270300.0, "mediane": 603550.0}, {"ville": "Gatineau", "n": 103287, "total": 58950735900.0, "mediane": 498500.0}, {"ville": "Longueuil", "n": 78904, "total": 53391341100.0, "mediane": 582600.0}, {"ville": "Sherbrooke", "n": 56325, "total": 32585004500.0, "mediane": 443000.0}, {"ville": "Lévis", "n": 59693, "total": 31269499500.0, "mediane": 442300.0}, {"ville": "Terrebonne", "n": 44115, "total": 28152446500.0, "mediane": 571200.0}, {"ville": "Brossard", "n": 33501, "total": 24988016200.0, "mediane": 633500.0}, {"ville": "Trois-Rivières", "n": 52147, "total": 24533800600.0, "mediane": 387100.0}, {"ville": "Saguenay", "n": 60184, "total": 24386369500.0, "mediane": 357500.0}, {"ville": "Saint-Jean-sur-Richelieu", "n": 36779, "total": 23025765300.0, "mediane": 540900.0}, {"ville": "Repentigny", "n": 31045, "total": 19484167300.0, "mediane": 569400.0}, {"ville": "Mirabel", "n": 26219, "total": 17464139500.0, "mediane": 587700.0}, {"ville": "Blainville", "n": 22059, "total": 17115448600.0, "mediane": 682600.0}, {"ville": "Saint-Jérôme", "n": 29857, "total": 17010926200.0, "mediane": 507700.0}, {"ville": "Drummondville", "n": 30202, "total": 15369115700.0, "mediane": 424400.0}, {"ville": "Boucherville", "n": 16829, "total": 14780870900.0, "mediane": 713300.0}, {"ville": "Westmount", "n": 5582, "total": 13334131800.0, "mediane": 2145700.0}, {"ville": "Granby", "n": 23468, "total": 13157676800.0, "mediane": 455850.0}, {"ville": "Saint-Hyacinthe", "n": 18517, "total": 12701191100.0, "mediane": 504200.0}, {"ville": "Dollard-des-Ormeaux", "n": 15258, "total": 12529530600.0, "mediane": 749500.0}, {"ville": "Mascouche", "n": 21469, "total": 12505579100.0, "mediane": 536100.0}, {"ville": "Vaudreuil-Dorion", "n": 17262, "total": 10921247700.0, "mediane": 571000.0}, {"ville": "Pointe-Claire", "n": 11967, "total": 10725842400.0, "mediane": 725500.0}, {"ville": "Saint-Eustache", "n": 17160, "total": 10694331900.0, "mediane": 563250.0}, {"ville": "Mont-Royal", "n": 6325, "total": 10522600100.0, "mediane": 1586500.0}, {"ville": "Châteauguay", "n": 18090, "total": 10425783400.0, "mediane": 523200.0}, {"ville": "Mont-Tremblant", "n": 13958, "total": 10293866700.0, "mediane": 518400.0}, {"ville": "Saint-Bruno-de-Montarville", "n": 10160, "total": 9528608200.0, "mediane": 781750.0}, {"ville": "Salaberry-de-Valleyfield", "n": 18926, "total": 9475394400.0, "mediane": 447800.0}, {"ville": "Magog", "n": 15271, "total": 9425881100.0, "mediane": 450800.0}, {"ville": "Rimouski", "n": 20576, "total": 8669498800.0, "mediane": 365700.0}, {"ville": "Victoriaville", "n": 17536, "total": 8239986800.0, "mediane": 368000.0}, {"ville": "Chambly", "n": 12708, "total": 8149790900.0, "mediane": 591250.0}, {"ville": "Rouyn-Noranda", "n": 21785, "total": 8045508100.0, "mediane": 342500.0}, {"ville": "Shawinigan", "n": 22870, "total": 8024544900.0, "mediane": 298100.0}, {"ville": "Beaconsfield", "n": 6907, "total": 8024079300.0, "mediane": 1047800.0}, {"ville": "Sainte-Julie", "n": 11528, "total": 8015837100.0, "mediane": 655200.0}, {"ville": "Saint-Lambert", "n": 8095, "total": 7822522100.0, "mediane": 906400.0}, {"ville": "Côte-Saint-Luc", "n": 8407, "total": 7529541700.0, "mediane": 807600.0}, {"ville": "La Prairie", "n": 10359, "total": 7305869600.0, "mediane": 647400.0}, {"ville": "Candiac", "n": 8551, "total": 7191005100.0, "mediane": 728800.0}, {"ville": "Saint-Constant", "n": 11119, "total": 7116617100.0, "mediane": 601200.0}, {"ville": "Kirkland", "n": 7082, "total": 7106103600.0, "mediane": 963400.0}, {"ville": "Boisbriand", "n": 10057, "total": 7062318900.0, "mediane": 605500.0}, {"ville": "Saint-Lazare", "n": 9617, "total": 6924414200.0, "mediane": 711400.0}, {"ville": "Sorel-Tracy", "n": 15354, "total": 6807409800.0, "mediane": 403500.0}, {"ville": "Dorval", "n": 7236, "total": 6544338400.0, "mediane": 684250.0}, {"ville": "Bromont", "n": 8231, "total": 6322223300.0, "mediane": 640600.0}, {"ville": "Beloeil", "n": 9353, "total": 6184471500.0, "mediane": 595700.0}, {"ville": "Saint-Augustin-de-Desmaures", "n": 8583, "total": 6112826400.0, "mediane": 625100.0}, {"ville": "Varennes", "n": 8309, "total": 6095997000.0, "mediane": 626800.0}, {"ville": "Mont-Saint-Hilaire", "n": 8507, "total": 6081096400.0, "mediane": 646600.0}, {"ville": "Val-d'Or", "n": 16254, "total": 6029599300.0, "mediane": 345700.0}, {"ville": "L'Assomption", "n": 9400, "total": 5781203200.0, "mediane": 535900.0}, {"ville": "Saint-Georges", "n": 14570, "total": 5634094400.0, "mediane": 306600.0}, {"ville": "Sainte-Thérèse", "n": 8592, "total": 5623203400.0, "mediane": 575000.0}, {"ville": "Saint-Sauveur", "n": 9087, "total": 5450461500.0, "mediane": 532900.0}, {"ville": "Saint-Lin--Laurentides", "n": 10563, "total": 5046480500.0, "mediane": 461700.0}, {"ville": "Alma", "n": 14028, "total": 4980090300.0, "mediane": 301500.0}, {"ville": "Sainte-Marthe-sur-le-Lac", "n": 8769, "total": 4949098800.0, "mediane": 553200.0}, {"ville": "Sainte-Adèle", "n": 10929, "total": 4718764300.0, "mediane": 404300.0}, {"ville": "Rosemère", "n": 5590, "total": 4716372900.0, "mediane": 801400.0}, {"ville": "Val-des-Monts", "n": 11798, "total": 4702280700.0, "mediane": 357850.0}, {"ville": "Saint-Donat", "n": 9690, "total": 4579669500.0, "mediane": 309750.0}, {"ville": "Hampstead", "n": 1887, "total": 4573684200.0, "mediane": 2167700.0}, {"ville": "Saint-Basile-le-Grand", "n": 9730, "total": 4526909500.0, "mediane": 497700.0}, {"ville": "Saint-Colomban", "n": 9180, "total": 4500904200.0, "mediane": 518000.0}, {"ville": "Carignan", "n": 6680, "total": 4478043900.0, "mediane": 676600.0}, {"ville": "Sainte-Sophie", "n": 9813, "total": 4289470900.0, "mediane": 459900.0}, {"ville": "Joliette", "n": 6750, "total": 4284791900.0, "mediane": 467150.0}, {"ville": "Saint-Hippolyte", "n": 9159, "total": 4203089000.0, "mediane": 421800.0}, {"ville": "Sept-Îles", "n": 12061, "total": 4114395400.0, "mediane": 285800.0}, {"ville": "Lac-Brome", "n": 4912, "total": 4052214600.0, "mediane": 588900.0}, {"ville": "Sainte-Agathe-des-Monts", "n": 8965, "total": 3964637500.0, "mediane": 375200.0}, {"ville": "Deux-Montagnes", "n": 6533, "total": 3903773700.0, "mediane": 540800.0}, {"ville": "Bécancour", "n": 9008, "total": 3764203500.0, "mediane": 336300.0}, {"ville": "Sainte-Catherine", "n": 6342, "total": 3755192200.0, "mediane": 555600.0}, {"ville": "Prévost", "n": 6693, "total": 3682089600.0, "mediane": 562700.0}, {"ville": "Thetford Mines", "n": 12344, "total": 3658629700.0, "mediane": 238400.0}, {"ville": "Chelsea", "n": 5088, "total": 3621057200.0, "mediane": 740900.0}, {"ville": "Notre-Dame-de-l'Île-Perrot", "n": 5049, "total": 3584651600.0, "mediane": 656500.0}, {"ville": "Rivière-du-Loup", "n": 7673, "total": 3480962500.0, "mediane": 363200.0}, {"ville": "Lavaltrie", "n": 6433, "total": 3443424200.0, "mediane": 480000.0}, {"ville": "Pincourt", "n": 5640, "total": 3430746500.0, "mediane": 547000.0}, {"ville": "Mercier", "n": 6002, "total": 3426678400.0, "mediane": 559500.0}, {"ville": "L'Ancienne-Lorette", "n": 5947, "total": 3387041400.0, "mediane": 495800.0}, {"ville": "Rawdon", "n": 10221, "total": 3305502300.0, "mediane": 264600.0}, {"ville": "Beauharnois", "n": 6124, "total": 3298911500.0, "mediane": 494700.0}, {"ville": "Cantley", "n": 5962, "total": 3226026500.0, "mediane": 573250.0}, {"ville": "Cowansville", "n": 6191, "total": 3224594600.0, "mediane": 408900.0}, {"ville": "Lachute", "n": 6719, "total": 3189334700.0, "mediane": 395200.0}, {"ville": "Sutton", "n": 4855, "total": 3151861600.0, "mediane": 568400.0}, {"ville": "La Pêche", "n": 8916, "total": 3144534500.0, "mediane": 295850.0}, {"ville": "Sainte-Anne-des-Plaines", "n": 5652, "total": 3140479100.0, "mediane": 500600.0}, {"ville": "Lac-Beauport", "n": 4516, "total": 3129305000.0, "mediane": 623200.0}, {"ville": "Stoneham-et-Tewkesbury", "n": 6528, "total": 3105867300.0, "mediane": 453300.0}, {"ville": "Saint-Charles-Borromée", "n": 4776, "total": 3105592600.0, "mediane": 496650.0}, {"ville": "Lorraine", "n": 3787, "total": 3102163900.0, "mediane": 772700.0}, {"ville": "Saint-Raymond", "n": 7984, "total": 3022955500.0, "mediane": 314500.0}, {"ville": "Les Îles-de-la-Madeleine", "n": 11193, "total": 2944855300.0, "mediane": 231000.0}, {"ville": "Hudson", "n": 3134, "total": 2931519500.0, "mediane": 827950.0}, {"ville": "Saint-Adolphe-d'Howard", "n": 8056, "total": 2929010800.0, "mediane": 217000.0}, {"ville": "L'Ange-Gardien", "n": 6100, "total": 2875773700.0, "mediane": 459500.0}, {"ville": "Orford", "n": 4675, "total": 2804990300.0, "mediane": 547000.0}, {"ville": "Baie-Comeau", "n": 8777, "total": 2804352200.0, "mediane": 277100.0}, {"ville": "Saint-Amable", "n": 5410, "total": 2802059700.0, "mediane": 525350.0}, {"ville": "Sainte-Julienne", "n": 7911, "total": 2774603100.0, "mediane": 319200.0}, {"ville": "Shefford", "n": 4383, "total": 2697787300.0, "mediane": 554300.0}, {"ville": "Mont-Laurier", "n": 7652, "total": 2640677100.0, "mediane": 305750.0}, {"ville": "Marieville", "n": 4290, "total": 2587426900.0, "mediane": 510100.0}, {"ville": "L'Île-Perrot", "n": 4532, "total": 2550034200.0, "mediane": 511600.0}, {"ville": "Bois-des-Filion", "n": 3949, "total": 2508883900.0, "mediane": 559900.0}, {"ville": "Morin-Heights", "n": 4941, "total": 2506908200.0, "mediane": 399100.0}, {"ville": "Saint-Zotique", "n": 4705, "total": 2504637100.0, "mediane": 514400.0}, {"ville": "Contrecoeur", "n": 4970, "total": 2497105400.0, "mediane": 491300.0}, {"ville": "Sainte-Marie", "n": 6060, "total": 2462941700.0, "mediane": 350500.0}, {"ville": "Saint-Philippe", "n": 4881, "total": 2458250300.0, "mediane": 579000.0}, {"ville": "Chertsey", "n": 11299, "total": 2416475000.0, "mediane": 110300.0}, {"ville": "Otterburn Park", "n": 3785, "total": 2415986800.0, "mediane": 603500.0}, {"ville": "Rigaud", "n": 4702, "total": 2360546400.0, "mediane": 431800.0}, {"ville": "Boischatel", "n": 3973, "total": 2343721200.0, "mediane": 543900.0}, {"ville": "Gaspé", "n": 11348, "total": 2326969400.0, "mediane": 156000.0}, {"ville": "Baie-D'Urfé", "n": 1567, "total": 2326304100.0, "mediane": 1152400.0}, {"ville": "Saint-Rémi", "n": 3559, "total": 2242906700.0, "mediane": 525900.0}, {"ville": "Potton", "n": 3617, "total": 2228147500.0, "mediane": 463500.0}, {"ville": "Notre-Dame-des-Prairies", "n": 4126, "total": 2227028500.0, "mediane": 469150.0}, {"ville": "La Tuque", "n": 11684, "total": 2219889200.0, "mediane": 128400.0}, {"ville": "Pont-Rouge", "n": 5299, "total": 2218381400.0, "mediane": 386800.0}, {"ville": "Brownsburg-Chatham", "n": 7572, "total": 2204667800.0, "mediane": 245050.0}, {"ville": "Sainte-Catherine-de-la-Jacques-Cartier", "n": 4692, "total": 2177584500.0, "mediane": 430400.0}, {"ville": "Amos", "n": 6487, "total": 2153817300.0, "mediane": 300100.0}, {"ville": "Farnham", "n": 4542, "total": 2150333600.0, "mediane": 399150.0}, {"ville": "Coteau-du-Lac", "n": 3369, "total": 2095549200.0, "mediane": 580700.0}, {"ville": "Austin", "n": 2674, "total": 2066562500.0, "mediane": 460150.0}, {"ville": "Saint-Denis-de-Brompton", "n": 2807, "total": 2058479100.0, "mediane": 572000.0}, {"ville": "Dolbeau-Mistassini", "n": 6840, "total": 2022751800.0, "mediane": 256300.0}, {"ville": "Delson", "n": 3142, "total": 2019536400.0, "mediane": 565900.0}, {"ville": "Pontiac", "n": 5592, "total": 1995195500.0, "mediane": 225100.0}, {"ville": "Coaticook", "n": 4019, "total": 1987675400.0, "mediane": 371700.0}, {"ville": "Sainte-Anne-des-Lacs", "n": 3385, "total": 1943811100.0, "mediane": 561600.0}, {"ville": "L'Épiphanie", "n": 3889, "total": 1912340500.0, "mediane": 465800.0}, {"ville": "Matane", "n": 6761, "total": 1906342200.0, "mediane": 256600.0}, {"ville": "Saint-Félicien", "n": 6043, "total": 1903303800.0, "mediane": 258300.0}, {"ville": "Mont-Blanc", "n": 4258, "total": 1892223400.0, "mediane": 364400.0}, {"ville": "Les Cèdres", "n": 3572, "total": 1854157500.0, "mediane": 496500.0}, {"ville": "Saint-Joseph-du-Lac", "n": 3313, "total": 1818006000.0, "mediane": 539900.0}, {"ville": "Sainte-Marguerite-du-Lac-Masson", "n": 5226, "total": 1805839200.0, "mediane": 253200.0}, {"ville": "Baie-Saint-Paul", "n": 5249, "total": 1787046900.0, "mediane": 304000.0}, {"ville": "Saint-Ferréol-les-Neiges", "n": 3940, "total": 1782207700.0, "mediane": 406950.0}, {"ville": "Eastman", "n": 3287, "total": 1779553400.0, "mediane": 435000.0}, {"ville": "Saint-Calixte", "n": 6592, "total": 1763106600.0, "mediane": 256150.0}, {"ville": "Montmagny", "n": 5471, "total": 1755773700.0, "mediane": 268200.0}, {"ville": "Saint-Côme", "n": 6307, "total": 1754151400.0, "mediane": 175500.0}, {"ville": "Acton Vale", "n": 3790, "total": 1746214700.0, "mediane": 391150.0}, {"ville": "Val-David", "n": 4471, "total": 1739150400.0, "mediane": 380700.0}, {"ville": "Sainte-Brigitte-de-Laval", "n": 4400, "total": 1728311600.0, "mediane": 400350.0}, {"ville": "Saint-Félix-de-Valois", "n": 4059, "total": 1680833500.0, "mediane": 376000.0}, {"ville": "Saint-Isidore", "n": 3442, "total": 1672899200.0, "mediane": 435400.0}, {"ville": "Saint-Apollinaire", "n": 4221, "total": 1672864200.0, "mediane": 372100.0}, {"ville": "Montréal-Ouest", "n": 1657, "total": 1658458200.0, "mediane": 948600.0}, {"ville": "Stanstead", "n": 3099, "total": 1657297400.0, "mediane": 322400.0}, {"ville": "Saint-Pie", "n": 2872, "total": 1656576900.0, "mediane": 427800.0}, {"ville": "Nicolet", "n": 3857, "total": 1647613500.0, "mediane": 346200.0}, {"ville": "La Malbaie", "n": 5793, "total": 1622737800.0, "mediane": 232000.0}, {"ville": "Dunham", "n": 2378, "total": 1586528200.0, "mediane": 526500.0}, {"ville": "Lanoraie", "n": 3081, "total": 1582203700.0, "mediane": 458100.0}, {"ville": "Piedmont", "n": 3071, "total": 1581514300.0, "mediane": 456900.0}, {"ville": "Hatley", "n": 2308, "total": 1541323900.0, "mediane": 539850.0}, {"ville": "Plessisville", "n": 4134, "total": 1533220800.0, "mediane": 307550.0}, {"ville": "Sainte-Martine", "n": 2532, "total": 1525460500.0, "mediane": 529300.0}, {"ville": "Saint-Roch-de-l'Achigan", "n": 2719, "total": 1514361700.0, "mediane": 457800.0}, {"ville": "Lac-Supérieur", "n": 4267, "total": 1499555000.0, "mediane": 234300.0}, {"ville": "Saint-Lambert-de-Lauzon", "n": 3666, "total": 1484232700.0, "mediane": 385000.0}, {"ville": "Saint-Jean-de-Matha", "n": 4559, "total": 1477286500.0, "mediane": 289700.0}, {"ville": "Verchères", "n": 2625, "total": 1461958900.0, "mediane": 465300.0}, {"ville": "Rivière-Rouge", "n": 4342, "total": 1460596800.0, "mediane": 292650.0}, {"ville": "Saint-Paul", "n": 3023, "total": 1448264100.0, "mediane": 433000.0}, {"ville": "Sainte-Anne-de-Bellevue", "n": 1687, "total": 1444184000.0, "mediane": 726900.0}, {"ville": "Roberval", "n": 4641, "total": 1436357500.0, "mediane": 270100.0}, {"ville": "Richelieu", "n": 2278, "total": 1426768300.0, "mediane": 577700.0}, {"ville": "Saint-Césaire", "n": 2366, "total": 1424764200.0, "mediane": 456100.0}, {"ville": "Beaupré", "n": 3596, "total": 1423864200.0, "mediane": 326400.0}, {"ville": "Wentworth-Nord", "n": 5114, "total": 1415131200.0, "mediane": 134400.0}, {"ville": "Oka", "n": 2616, "total": 1407733100.0, "mediane": 476800.0}, {"ville": "Shannon", "n": 2625, "total": 1405733600.0, "mediane": 527100.0}, {"ville": "Saint-Mathias-sur-Richelieu", "n": 2126, "total": 1399566900.0, "mediane": 570450.0}, {"ville": "Saint-Michel-des-Saints", "n": 5363, "total": 1380227900.0, "mediane": 165100.0}, {"ville": "Louiseville", "n": 3489, "total": 1346008100.0, "mediane": 314700.0}, {"ville": "Saint-Alphonse-Rodriguez", "n": 4539, "total": 1345465900.0, "mediane": 244300.0}, {"ville": "Saint-Jacques", "n": 2276, "total": 1319864500.0, "mediane": 449000.0}, {"ville": "Saint-Anicet", "n": 2817, "total": 1313974400.0, "mediane": 358200.0}, {"ville": "Cookshire-Eaton", "n": 2980, "total": 1310438900.0, "mediane": 366750.0}, {"ville": "Saint-Henri", "n": 2668, "total": 1299775400.0, "mediane": 407500.0}, {"ville": "Charlemagne", "n": 2238, "total": 1286145100.0, "mediane": 529500.0}, {"ville": "Waterloo", "n": 2621, "total": 1273901800.0, "mediane": 407300.0}, {"ville": "Gore", "n": 4631, "total": 1271028700.0, "mediane": 118600.0}, {"ville": "Les Coteaux", "n": 2405, "total": 1263064600.0, "mediane": 487400.0}, {"ville": "Pointe-Calumet", "n": 2920, "total": 1261640500.0, "mediane": 448500.0}]}
\ No newline at end of file
added src/lib/comps.ts +271 −0
@@ -0,0 +1,271 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Atelier « mon évaluation » d'une propriété à vendre : construit le bassin de
4 + * comparables (ventes réelles autour du sujet, + annonces actives voisines) à
5 + * partir duquel l'utilisateur choisit, ajuste et réconcilie SA valeur — puis la
6 + * compare à la mesure Vrai-Prix déjà calculée (table vp_eval).
7 + *
8 + * Chaque candidat porte les ajustements du moteur (marché, superficie, âge) et
9 + * son poids gaussien, en dollars, pour servir de point de départ transparent.
10 + */
11 +import { getCandidates, getMarketIndex, getUnit, type UnitRow } from "./db";
12 +import {
13 + adjustComps,
14 + ageAdjustment,
15 + areaAdjustment,
16 + estimate,
17 + haversineM,
18 + monthsBetween,
19 + timeFactor,
20 + type CompInput,
21 + type Subject,
22 +} from "./engine";
23 +import { candidatesAround, indexType, toComps } from "./estimator";
24 +import { getListing, groupToTypeProp, listingsNear, type ListingDetail } from "./immoka";
25 +
26 +export interface CompCandidate {
27 + id: string;
28 + kind: "sale" | "listing";
29 + date: string; // vente : date de l'acte ; annonce : aujourd'hui (prix demandé courant)
30 + amount: number;
31 + lat: number;
32 + lng: number;
33 + distanceM: number;
34 + monthsAgo: number;
35 + street: string | null;
36 + city: string | null;
37 + propertyType: string | null;
38 + yearBuilt: number | null;
39 + floorArea: number | null; // m²
40 + landArea: number | null; // m²
41 + valeurRole: number | null;
42 + adjTime: number;
43 + adjArea: number;
44 + adjAge: number;
45 + adjustedPrice: number;
46 + weight: number;
47 + engineUsed: boolean; // fait partie des 12 comparables retenus par le moteur
48 + idProvinc?: string | null;
49 + uid?: string; // annonce Immo-Ka
50 + image?: string | null;
51 +}
52 +
53 +export interface CompsSubject {
54 + uid: string;
55 + address: string | null;
56 + city: string | null;
57 + lat: number;
58 + lng: number;
59 + typeProp: string;
60 + floorArea: number | null;
61 + yearBuilt: number | null;
62 + landArea: number | null;
63 + price: number;
64 + unitId: string | null;
65 + fromUnit: boolean; // caractéristiques lues au rôle (unité jumelée) vs à l'annonce
66 +}
67 +
68 +export interface CompsPayload {
69 + subject: CompsSubject;
70 + engine: {
71 + estimate: number | null;
72 + low: number | null;
73 + high: number | null;
74 + modelEstimate: number | null;
75 + compsEstimate: number | null;
76 + modelWeight: number;
77 + nComps: number;
78 + compIds: string[];
79 + };
80 + comps: CompCandidate[];
81 + index: { month: string; idx: number }[];
82 + nowISO: string;
83 + params: { months: number; radiusKm: number; includeListings: boolean; limit: number };
84 +}
85 +
86 +function since(months: number): string {
87 + const d = new Date();
88 + d.setMonth(d.getMonth() - months);
89 + return d.toISOString().slice(0, 10);
90 +}
91 +
92 +const SQFT = 0.092903;
93 +
94 +/** Sujet du calcul : l'unité du rôle jumelée si disponible, sinon l'annonce. */
95 +export function subjectFor(det: ListingDetail, unit: UnitRow | null): CompsSubject | null {
96 + const l = det.listing;
97 + if (unit) {
98 + return {
99 + uid: l.uid,
100 + address: l.address,
101 + city: l.city,
102 + lat: unit.lat,
103 + lng: unit.lng,
104 + typeProp: unit.type_prop,
105 + floorArea: unit.aire_etages_m2,
106 + yearBuilt: unit.annee_construction,
107 + landArea: unit.superficie_terrain_m2,
108 + price: l.price,
109 + unitId: unit.id_provinc,
110 + fromUnit: true,
111 + };
112 + }
113 + if (l.lat == null || l.lng == null) return null;
114 + const area = l.area_sqft && l.area_sqft > 100 && l.area_sqft < 20000 ? l.area_sqft * SQFT : null;
115 + return {
116 + uid: l.uid,
117 + address: l.address,
118 + city: l.city,
119 + lat: l.lat,
120 + lng: l.lng,
121 + typeProp: groupToTypeProp(det.group),
122 + floorArea: area,
123 + yearBuilt: l.year_built && l.year_built > 1600 ? l.year_built : null,
124 + landArea: l.lot_sqft && l.lot_sqft > 50 ? l.lot_sqft * SQFT : null,
125 + price: l.price,
126 + unitId: null,
127 + fromUnit: false,
128 + };
129 +}
130 +
131 +function weightOf(distanceM: number, monthsAgo: number, subjectArea: number | null, compArea: number | null): number {
132 + const areaDiffPct = subjectArea && compArea ? Math.abs(compArea - subjectArea) / subjectArea : 0.15;
133 + return (
134 + Math.exp(-((distanceM / 1500) ** 2)) *
135 + Math.exp(-((monthsAgo / 24) ** 2)) *
136 + Math.exp(-((areaDiffPct / 0.25) ** 2))
137 + );
138 +}
139 +
140 +export function buildComps(
141 + uid: string,
142 + opts: { months?: number; radiusKm?: number; includeListings?: boolean; limit?: number } = {}
143 +): CompsPayload | null {
144 + const det = getListing(uid);
145 + if (!det) return null;
146 + const unit = det.eval?.unit_id ? (getUnit(det.eval.unit_id) ?? null) : null;
147 + const subj = subjectFor(det, unit);
148 + if (!subj) return null;
149 +
150 + const months = Math.min(Math.max(opts.months ?? 24, 6), 60);
151 + const radiusKm = Math.min(Math.max(opts.radiusKm ?? 3, 0.5), 25);
152 + const includeListings = opts.includeListings ?? true;
153 + const limit = Math.min(Math.max(opts.limit ?? 60, 10), 150);
154 + const nowISO = new Date().toISOString().slice(0, 10);
155 + const index = getMarketIndex(indexType(subj.typeProp));
156 +
157 + const subject: Subject = {
158 + lat: subj.lat,
159 + lng: subj.lng,
160 + typeProp: subj.typeProp,
161 + floorArea: subj.floorArea,
162 + yearBuilt: subj.yearBuilt,
163 + landArea: subj.landArea,
164 + modelEstimate: unit?.est_2026 ?? null,
165 + modelP10: unit?.p10 ?? null,
166 + modelP90: unit?.p90 ?? null,
167 + };
168 +
169 + // ce que le moteur retient lui-même (mêmes règles que la fiche /estimation)
170 + const engineRes = estimate(subject, toComps(candidatesAround(subj.lat, subj.lng)), index, nowISO);
171 + const engineIds = new Set(engineRes.comps.map((c) => c.id));
172 +
173 + // bassin élargi de ventes : rayon et fenêtre choisis par l'utilisateur
174 + const halfDeg = (radiusKm / 111) * 1.05;
175 + const rows = getCandidates(subj.lat, subj.lng, halfDeg, since(months), 4000);
176 + const sales: CompCandidate[] = [];
177 + for (const r of rows) {
178 + const distanceM = haversineM(subj.lat, subj.lng, r.lat, r.lng);
179 + if (distanceM > radiusKm * 1000) continue;
180 + const monthsAgo = monthsBetween(r.date, nowISO);
181 + const tf = timeFactor(r.date.slice(0, 7), index);
182 + const adjTime = r.amount * (tf - 1);
183 + const adjArea = areaAdjustment(subj.floorArea, { floorArea: r.floor_area, amount: r.amount });
184 + const adjAge = ageAdjustment(subj.yearBuilt, { yearBuilt: r.year_built, amount: r.amount });
185 + sales.push({
186 + id: r.id,
187 + kind: "sale",
188 + date: r.date,
189 + amount: r.amount,
190 + lat: r.lat,
191 + lng: r.lng,
192 + distanceM: Math.round(distanceM),
193 + monthsAgo: Math.round(monthsAgo * 10) / 10,
194 + street: r.street,
195 + city: r.city,
196 + propertyType: r.property_type,
197 + yearBuilt: r.year_built,
198 + floorArea: r.floor_area,
199 + landArea: r.land_area,
200 + valeurRole: r.valeur_role,
201 + adjTime: Math.round(adjTime),
202 + adjArea: Math.round(adjArea),
203 + adjAge: Math.round(adjAge),
204 + adjustedPrice: Math.round(r.amount + adjTime + adjArea + adjAge),
205 + weight: weightOf(distanceM, monthsAgo, subj.floorArea, r.floor_area),
206 + engineUsed: engineIds.has(r.id),
207 + idProvinc: r.id_provinc,
208 + });
209 + }
210 + // les comparables du moteur d'abord, puis par poids décroissant
211 + sales.sort((a, b) => Number(b.engineUsed) - Number(a.engineUsed) || b.weight - a.weight);
212 + const kept = sales.slice(0, limit);
213 +
214 + const comps: CompCandidate[] = [...kept];
215 + if (includeListings) {
216 + const near = listingsNear(subj.lat, subj.lng, radiusKm, { group: det.group, excludeUid: uid, limit: 30 });
217 + for (const c of near) {
218 + if (c.lat == null || c.lng == null) continue;
219 + const area = c.areaSqft && c.areaSqft > 100 && c.areaSqft < 20000 ? c.areaSqft * SQFT : null;
220 + const adjArea = areaAdjustment(subj.floorArea, { floorArea: area, amount: c.price });
221 + const adjAge = ageAdjustment(subj.yearBuilt, { yearBuilt: c.yearBuilt, amount: c.price });
222 + comps.push({
223 + id: `listing:${c.uid}`,
224 + kind: "listing",
225 + date: nowISO,
226 + amount: c.price,
227 + lat: c.lat,
228 + lng: c.lng,
229 + distanceM: c.distanceM ?? Math.round(haversineM(subj.lat, subj.lng, c.lat, c.lng)),
230 + monthsAgo: 0,
231 + street: c.address,
232 + city: c.city,
233 + propertyType: c.propertyType,
234 + yearBuilt: c.yearBuilt,
235 + floorArea: area,
236 + landArea: c.lotSqft && c.lotSqft > 50 ? c.lotSqft * SQFT : null,
237 + valeurRole: null,
238 + adjTime: 0,
239 + adjArea: Math.round(adjArea),
240 + adjAge: Math.round(adjAge),
241 + adjustedPrice: Math.round(c.price + adjArea + adjAge),
242 + weight: weightOf(c.distanceM ?? 0, 0, subj.floorArea, area),
243 + engineUsed: false,
244 + uid: c.uid,
245 + image: c.image,
246 + });
247 + }
248 + }
249 +
250 + return {
251 + subject: subj,
252 + engine: {
253 + estimate: Number.isFinite(engineRes.estimate) ? engineRes.estimate : null,
254 + low: Number.isFinite(engineRes.low) ? engineRes.low : null,
255 + high: Number.isFinite(engineRes.high) ? engineRes.high : null,
256 + modelEstimate: engineRes.modelEstimate,
257 + compsEstimate: engineRes.compsEstimate,
258 + modelWeight: engineRes.modelWeight,
259 + nComps: engineRes.nCompsUsed,
260 + compIds: [...engineIds],
261 + },
262 + comps,
263 + index,
264 + nowISO,
265 + params: { months, radiusKm, includeListings, limit },
266 + };
267 +}
268 +
269 +// réexport pratique pour les routes
270 +export type { CompInput };
271 +export { adjustComps };
added src/lib/cost/ai/ai.test.ts +172 −0
@@ -0,0 +1,172 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Tests hors réseau de l'analyse multimodale : schéma/réparation, empreinte, fusion, mapping, vecteur, images.
3 +import { describe, expect, it } from "vitest";
4 +import { buildModelSchema, isValid, schemaForApi, validateAndRepair, type ModelOutput } from "./schema";
5 +import { sampleOutput } from "./fixture";
6 +import { inputHash } from "./context";
7 +import { applyOverrides, listingFactsFromDetails, mergeFacts, type ListingFacts, type MamhFacts } from "./merge";
8 +import { mapToCostInput } from "./mapping";
9 +import { EMBEDDING_DIMENSION, canonicalText, cosine, technicalVector } from "./vector";
10 +import { readDimensions, selectImages, sizeAgnosticKey, sniffMediaType } from "./images";
11 +import { deriveQuantities } from "../geometry";
12 +import { ASSEMBLY_SEEDS } from "../seed/assemblies";
13 +
14 +const CODES = ASSEMBLY_SEEDS.map((a) => a.code);
15 +const SCHEMA = buildModelSchema(CODES);
16 +
17 +const listingFacts = (over: Partial<ListingFacts> = {}): ListingFacts => ({
18 + yearBuilt: 1998, areaSqft: 2470, bedrooms: 4, bathrooms: 2, powderRooms: 1, propertyType: "Maison", lotSqft: 7200, buildingType: "detached", stories: null, foundation: null, siding: null, roof: null, roofGeometry: null, windows: null,
19 + heating: null, hasAirConditioning: null, hasAirExchanger: null, basement: null, garage: null, pool: null, units: null, waterSupply: null, wasteSystem: null, ...over,
20 +});
21 +const mamh: MamhFacts = { yearBuilt: 1997, grossFloorAreaSqft: 2300, stories: 2, buildingType: "detached", units: 1, landValue: 182000, buildingValue: 400000, totalValue: 582000, unitId: "123" };
22 +
23 +describe("schema", () => {
24 + it("la fixture est strictement valide et le schéma API ne contient plus de bornes", () => {
25 + expect(isValid(sampleOutput(), SCHEMA)).toBe(true);
26 + expect(JSON.stringify(schemaForApi(SCHEMA))).not.toMatch(/"minimum"|"maximum"/);
27 + expect(JSON.stringify(SCHEMA)).toContain('"KIT-SUPERIOR"');
28 + });
29 + it("répare de façon déterministe : chaîne numérique, énumération inconnue, champ manquant, clé inconnue, confiance bornée", () => {
30 + const bad = JSON.parse(JSON.stringify(sampleOutput())) as Record<string, unknown>;
31 + const prop = bad.property as Record<string, Record<string, unknown>>;
32 + prop.stories.value = "2";
33 + prop.building_type.value = "castle";
34 + delete prop.bedrooms;
35 + (bad.quality as Record<string, unknown>).confidence = 1.7;
36 + bad.extra_key = 1;
37 + const { value, issues } = validateAndRepair(bad, SCHEMA);
38 + const v = value as ModelOutput;
39 + expect(v.property.stories.value).toBe(2);
40 + expect(v.property.building_type.value).toBeNull();
41 + expect(v.property.bedrooms.value).toBeNull();
42 + expect(v.quality.confidence).toBe(1);
43 + expect("extra_key" in (value as object)).toBe(false);
44 + expect(issues.length).toBeGreaterThanOrEqual(5);
45 + expect(issues.every((i) => i.repaired)).toBe(true);
46 + expect(isValid(value, SCHEMA)).toBe(true);
47 + // idempotence
48 + expect(JSON.stringify(validateAndRepair(value, SCHEMA).value)).toBe(JSON.stringify(value));
49 + });
50 +});
51 +
52 +describe("input hash", () => {
53 + it("est stable pour la même entrée et change avec les photos", () => {
54 + const a = inputHash("base", ["h1", "h2"]);
55 + expect(a).toBe(inputHash("base", ["h2", "h1"]));
56 + expect(a).not.toBe(inputHash("base", ["h1", "h3"]));
57 + expect(a).not.toBe(inputHash("base2", ["h1", "h2"]));
58 + expect(a).toMatch(/^[0-9a-f]{64}$/);
59 + });
60 +});
61 +
62 +describe("merge", () => {
63 + it("hiérarchie : MAMH > annonce > IA, avec conflits tracés", () => {
64 + const ai = sampleOutput();
65 + const m = mergeFacts(ai, listingFacts({ roof: "metal" }), mamh);
66 + expect(m.grossFloorAreaSqft.value).toBe(2300);
67 + expect(m.grossFloorAreaSqft.source).toBe("MAMH");
68 + expect(m.yearBuilt.value).toBe(1997);
69 + expect(m.roof.value).toBe("metal"); // annonce > photo
70 + expect(m.roof.source).toBe("listing");
71 + expect(m.conflicts.some((c) => c.field === "roof" && c.valueB.includes("asphalt"))).toBe(true);
72 + expect(m.siding.value.brick).toBeCloseTo(0.6, 5);
73 + expect(m.siding.value.vinyl).toBeCloseTo(0.4, 5);
74 + expect(m.heating.value).toBe("heat_pump");
75 + expect(m.conditions.roof).toBe("average");
76 + expect(m.effectiveAge?.effective).toBe(17);
77 + });
78 + it("sans MAMH ni champ d'annonce, l'IA fournit la valeur (source AI)", () => {
79 + const m = mergeFacts(sampleOutput(), listingFacts({ yearBuilt: null, areaSqft: null, buildingType: null }), null);
80 + expect(m.grossFloorAreaSqft.value).toBe(2470);
81 + expect(m.grossFloorAreaSqft.source).toBe("listing"); // l'IA cite la source « listing »
82 + expect(m.foundation.source).toBe("AI");
83 + expect(m.basement.value).toBe("finished");
84 + });
85 + it("les overrides remplacent la valeur, gardent l'originale et passent en source user", () => {
86 + const m = mergeFacts(sampleOutput(), listingFacts(), null);
87 + const o = applyOverrides(m, [{ fieldPath: "roof", value: "metal" }, { fieldPath: "conditions.roof", value: "new" }]);
88 + expect(o.roof.value).toBe("metal");
89 + expect(o.roof.source).toBe("user");
90 + expect(o.roof.alternatives[0].value).toBe("asphalt_shingle");
91 + expect(o.conditions.roof).toBe("new");
92 + expect(m.roof.value).toBe("asphalt_shingle"); // l'original n'est pas muté
93 + });
94 + it("normalise les détails français d'une annonce", () => {
95 + const f = listingFactsFromDetails({ year_built: null, area_sqft: null, bedrooms: 3, bathrooms: 1, powder_rooms: 1, property_type: "Maison", lot_sqft: null }, { "Type de bâtiment": "Détaché (isolé)", "Revêtement": "Brique, Vinyle", "Toiture": "Bardeaux d'asphalte", "Chauffage": "Plinthes électriques", "Fondation": "Béton coulé", "Sous-sol": "6 pieds et plus, Totalement aménagé", "Stationnement (total)": "Allée (2), Garage (1)", "Piscine": "Creusée", "Année de construction": "1998" });
96 + expect(f.buildingType).toBe("detached");
97 + expect(f.roof).toBe("asphalt_shingle");
98 + expect(f.foundation).toBe("poured_concrete");
99 + expect(f.heating).toBe("electric_baseboard");
100 + expect(f.basement).toBe("finished");
101 + expect(f.garage).toEqual({ type: "attached", spaces: 1 });
102 + expect(f.pool).toBe("inground");
103 + expect(f.yearBuilt).toBe(1998);
104 + expect(f.siding?.brick).toBeCloseTo(0.6, 5);
105 + });
106 +});
107 +
108 +describe("mapping", () => {
109 + const ctx = { listingUid: "t:1", address: "1 rue Test", municipality: "Gatineau", lat: 45.47, lng: -75.7, unitId: "123", landValue: 182000, roll: { landValue: 182000, buildingValue: 400000, totalValue: 582000, year: 2026 }, assemblyCodes: new Set(CODES) };
110 + it("façade hybride 60/40 → deux assemblages de revêtement pondérés ; quantités IA fiables retenues, faibles ignorées", () => {
111 + const ai = sampleOutput();
112 + const m = mergeFacts(ai, listingFacts(), mamh);
113 + const r = mapToCostInput(m, ai, ctx);
114 + expect(r.input.mode).toBe("listing");
115 + expect(r.input.building.siding).toEqual({ brick: 0.6, vinyl: 0.4 });
116 + const { lines } = deriveQuantities(r.input.building, r.input.quantityOverrides, "AI");
117 + const brick = lines.find((l) => l.assemblyCode === "ENV-SIDING-BRICK")!;
118 + const vinyl = lines.find((l) => l.assemblyCode === "ENV-SIDING-VINYL")!;
119 + expect(brick.quantity / (brick.quantity + vinyl.quantity)).toBeCloseTo(0.6, 2);
120 + expect(r.aiQuantities["KIT-SUPERIOR"].quantity).toBe(34);
121 + expect(lines.find((l) => l.assemblyCode === "KIT-SUPERIOR")!.source).toBe("AI");
122 + expect("OPN-DOOR-INT-SOLID" in r.aiQuantities).toBe(false); // confiance 0,55 < 0,6
123 + expect("ROOF-ASPHALT-ARCH" in r.aiQuantities).toBe(false); // géométrie dérivée par le code
124 + expect(r.input.depreciation.method).toBe("components");
125 + expect(r.input.depreciation.componentConditions.roof).toBe("average");
126 + expect(r.input.depreciation.effectiveAge).toBe(17);
127 + expect(r.input.land.value).toBe(182000);
128 + expect(r.input.attributeSources.grossFloorAreaSqft).toBe("MAMH");
129 + expect(r.input.attributeSources.foundation).toBe("AI");
130 + });
131 +});
132 +
133 +describe("vector", () => {
134 + it("déterministe, de dimension fixe, similarité 1 avec lui-même et < 1 avec un bâtiment différent", () => {
135 + const ai = sampleOutput();
136 + const m = mergeFacts(ai, listingFacts(), mamh);
137 + const v1 = technicalVector(m);
138 + const v2 = technicalVector(mergeFacts(sampleOutput(), listingFacts(), mamh));
139 + expect(v1.length).toBe(EMBEDDING_DIMENSION);
140 + expect(v1).toEqual(v2);
141 + expect(cosine(v1, v2)).toBeCloseTo(1, 6);
142 + const ai2 = sampleOutput();
143 + ai2.quality.overall_quality = "economy";
144 + ai2.exterior.siding_shares = { brick_pct: 0, vinyl_pct: 100, fiber_cement_pct: 0, wood_pct: 0, stone_pct: 0, stucco_pct: 0, metal_pct: 0, other_pct: 0 };
145 + const v3 = technicalVector(mergeFacts(ai2, listingFacts({ buildingType: "row" }), null));
146 + expect(cosine(v1, v3)).toBeLessThan(0.95);
147 + expect(canonicalText(m)).toContain("Detached.");
148 + expect(canonicalText(m)).toContain("brick 60%");
149 + });
150 +});
151 +
152 +describe("images", () => {
153 + it("détecte le type et lit les dimensions PNG/JPEG", () => {
154 + const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from("0000000d49484452", "hex"), Buffer.alloc(8)]);
155 + png.writeUInt32BE(1024, 16); png.writeUInt32BE(768, 20);
156 + expect(sniffMediaType(png)).toBe("image/png");
157 + expect(readDimensions(png, "image/png")).toEqual({ width: 1024, height: 768 });
158 + const jpg = Buffer.alloc(30, 0);
159 + jpg[0] = 0xff; jpg[1] = 0xd8; jpg[2] = 0xff; jpg[3] = 0xc0; jpg.writeUInt16BE(17, 4); jpg[6] = 8; jpg.writeUInt16BE(600, 7); jpg.writeUInt16BE(800, 9);
160 + expect(sniffMediaType(jpg)).toBe("image/jpeg");
161 + expect(readDimensions(jpg, "image/jpeg")).toEqual({ width: 800, height: 600 });
162 + expect(sniffMediaType(Buffer.from("hello"))).toBeNull();
163 + });
164 + it("regroupe les tailles d'une même photo et présélectionne en gardant les extérieurs", () => {
165 + expect(sizeAgnosticKey("https://x/pic/abc-lg.jpg?x=1")).toBe(sizeAgnosticKey("https://x/pic/abc-sm.jpg"));
166 + const imgs = Array.from({ length: 50 }, (_, i) => ({ position: i + 1, roomHint: i < 2 ? "exterior" : "unknown", qualityScore: 1, hash: `h${i}` }));
167 + const sel = selectImages(imgs, 40);
168 + expect(sel.length).toBe(40);
169 + expect(sel.filter((i) => i.roomHint === "exterior").length).toBe(2);
170 + expect(new Set(sel.map((i) => i.hash)).size).toBe(40);
171 + });
172 +});
added src/lib/cost/ai/context.ts +78 −0
@@ -0,0 +1,78 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Contexte d'analyse d'une annonce : faits de l'annonce + rôle MAMH jumelé,
4 + * texte déterministe transmis au modèle, empreinte d'entrée (§132, 169).
5 + * Module PUR sauf `loadListingContext` (lecture des bases).
6 + */
7 +import { createHash } from "crypto";
8 +import { getUnit } from "../../db";
9 +import { getListing, type ListingDetail } from "../../immoka";
10 +import { sqftFromM2 } from "../units";
11 +import type { BuildingType, CostInput } from "../types";
12 +import { listingFactsFromDetails, type ListingFacts, type MamhFacts } from "./merge";
13 +import type { AnalysisListing } from "./schema";
14 +
15 +export interface ListingContext {
16 + uid: string;
17 + detail: ListingDetail;
18 + facts: ListingFacts;
19 + mamh: MamhFacts | null;
20 + roll: CostInput["roll"];
21 + analysisListing: AnalysisListing;
22 + imageUrls: string[];
23 + text: string; // contexte textuel déterministe pour le modèle
24 + baseHashInput: string; // partie textuelle de l'empreinte (les hashes d'images s'ajoutent)
25 +}
26 +
27 +function mamhType(u: { type_prop: string; lien_physique: string | null; nb_logements: number | null; aire_etages_m2: number | null }): BuildingType | null {
28 + switch (u.type_prop) {
29 + case "plex": return "plex";
30 + case "chalet": return "chalet";
31 + case "maison_mobile": return "mobile";
32 + case "condo_ou_multi": return (u.nb_logements ?? 1) > 1 && (u.aire_etages_m2 ?? 0) > 400 ? "plex" : "condo";
33 + case "unifamilial": { const l = (u.lien_physique ?? "").toLowerCase(); return l.includes("jumel") ? "semi_detached" : l.includes("rang") || l.includes("intégré") || l.includes("integre") ? "row" : "detached"; }
34 + default: return null;
35 + }
36 +}
37 +
38 +export function loadListingContext(uid: string): ListingContext | null {
39 + const detail = getListing(uid);
40 + if (!detail) return null;
41 + const l = detail.listing;
42 + const facts = listingFactsFromDetails(l, detail.details);
43 + let mamh: MamhFacts | null = null;
44 + let roll: CostInput["roll"] = null;
45 + if (detail.eval?.unit_id) {
46 + const u = getUnit(detail.eval.unit_id);
47 + if (u) {
48 + const g = (u.genre_construction ?? "").toLowerCase();
49 + const stories = u.nb_etages && u.nb_etages > 0 ? Math.max(1, Math.round(u.nb_etages)) : g ? (g.includes("plain-pied") || g.includes("unimodulaire") ? 1 : g.includes("étages entiers") ? 2 : g.includes("mansard") || g.includes("décal") ? 2 : 1) : null;
50 + mamh = { yearBuilt: u.annee_construction && u.annee_construction > 1600 ? u.annee_construction : null, grossFloorAreaSqft: u.aire_etages_m2 && u.aire_etages_m2 > 15 ? Math.round(sqftFromM2(u.aire_etages_m2)) : null, stories, buildingType: mamhType(u), units: u.nb_logements, landValue: u.valeur_terrain, buildingValue: u.valeur_batiment, totalValue: u.valeur_role, unitId: u.id_provinc };
51 + roll = { landValue: u.valeur_terrain, buildingValue: u.valeur_batiment, totalValue: u.valeur_role, year: 2026 };
52 + }
53 + }
54 + const desc = (l.description ?? "").replace(/\s+/g, " ").trim();
55 + const detailsTxt = Object.entries(detail.details).filter(([k]) => !["img_audited", "transaction"].includes(k)).map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join("\n");
56 + const features = detail.features.join("; ");
57 + const text = [
58 + "### Structured listing fields (source: listing)",
59 + `address: ${l.address ?? "—"} | municipality: ${l.city ?? "—"} | sector: ${l.sector ?? "—"} | region: ${l.region ?? "—"}`,
60 + `property_type: ${l.property_type ?? "—"} | asking_price_cad: ${l.price} | year_built: ${l.year_built ?? "unknown"} | living_area_sqft: ${l.area_sqft ?? "unknown"} | lot_area_sqft: ${l.lot_sqft ?? "unknown"}`,
61 + `bedrooms: ${l.bedrooms ?? "unknown"} | bathrooms: ${l.bathrooms ?? "unknown"} | powder_rooms: ${l.powder_rooms ?? "unknown"}`,
62 + detailsTxt ? `### Listing details (source: listing)\n${detailsTxt}` : "",
63 + features ? `### Listing features (source: listing)\n${features}` : "",
64 + desc ? `### Broker description (source: description)\n${desc.slice(0, 6000)}` : "",
65 + mamh ? `### Assessment roll — MAMH (source: MAMH, authoritative)\nyear_built: ${mamh.yearBuilt ?? "unknown"} | gross_floor_area_sqft_excluding_basement: ${mamh.grossFloorAreaSqft ?? "unknown"} | stories: ${mamh.stories ?? "unknown"} | building_type: ${mamh.buildingType ?? "unknown"} | dwelling_units: ${mamh.units ?? "unknown"} | land_value_cad: ${mamh.landValue ?? "unknown"} | building_value_cad: ${mamh.buildingValue ?? "unknown"}` : "### Assessment roll — MAMH\nnot matched",
66 + ].filter(Boolean).join("\n\n");
67 + const analysisListing: AnalysisListing = {
68 + address: l.address, municipality: l.city, region: l.region, asking_price: l.price, listing_date: l.first_seen ? new Date(l.first_seen * 1000).toISOString().slice(0, 10) : null, description_excerpt: desc ? desc.slice(0, 600) : null,
69 + bedrooms: l.bedrooms, bathrooms: l.bathrooms, powder_rooms: l.powder_rooms, lot_area_sqft: l.lot_sqft, living_area_sqft: l.area_sqft, year_built: l.year_built, property_type: l.property_type, url: l.url, unit_id: mamh?.unitId ?? null,
70 + };
71 + const baseHashInput = JSON.stringify({ urls: detail.images, description: l.description, specs: { pt: l.property_type, yb: l.year_built, a: l.area_sqft, lot: l.lot_sqft, bd: l.bedrooms, ba: l.bathrooms, pr: l.powder_rooms, details: detail.details, features: detail.features }, updated: l.updated_at, mamh });
72 + return { uid, detail, facts, mamh, roll, analysisListing, imageUrls: detail.images, text, baseHashInput };
73 +}
74 +
75 +/** Empreinte d'entrée : URLs + hashes d'images + description + champs structurés + updated_at. */
76 +export function inputHash(base: string, imageHashes: string[]): string {
77 + return createHash("sha256").update(base).update("|").update([...imageHashes].sort().join(",")).digest("hex");
78 +}
added src/lib/cost/ai/fixture.ts +47 −0
@@ -0,0 +1,47 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/** Sortie modèle de référence (fixture) : tests hors réseau et fournisseur factice (`FakeProvider`). Module PUR. */
3 +import type { ModelOutput } from "./schema";
4 +import type { AnalyzeInput, AnalyzeResult, PropertyVisionModel } from "./provider";
5 +
6 +const A = <T,>(value: T, confidence = 0.9, source: ModelOutput["property"]["building_type"]["source"] = "photo", evidence: string[] = ["photo_01"]) => ({ value, confidence, source, evidence });
7 +const Q = (value: number | null, unit: string, confidence = 0.7, method: string | null = "visual estimate", source: ModelOutput["property"]["building_type"]["source"] = "ai_estimated") => ({ value, unit, confidence, source, method, evidence: value == null ? [] : ["photo_02"] });
8 +const M = (value: string | null, status: ModelOutput["mechanical"]["heat_source"]["status"] = "inferred", confidence = 0.6) => ({ value, status, confidence, evidence: value ? ["photo_09"] : [] });
9 +
10 +export function sampleOutput(): ModelOutput {
11 + return {
12 + property: { building_type: A("detached", 0.95), year_built: A(1998, 0.9, "listing", ["listing"]), stories: A(2, 0.9), gross_floor_area_sqft: A(2470, 0.99, "listing", ["listing"]), bedrooms: A(4, 0.95, "listing", ["listing"]), bathrooms: A(2, 0.9, "listing", ["listing"]), powder_rooms: A(1, 0.9, "listing", ["listing"]), dwelling_units: A(1, 0.95) },
13 + geometry: { footprint_sqft: A(null, 0.2, "unknown", []), roof_area_sqft: A(null, 0.1, "unknown", []), exterior_wall_area_sqft: A(null, 0.1, "unknown", []), perimeter_ft: A(null, 0.1, "unknown", []), roof_pitch_rise_per_12: A(6, 0.6) },
14 + construction: { foundation: A("poured_concrete", 0.88, "photo", ["photo_14"]), structural_system: A("wood_frame", 0.92, "assumed", []), floor_system: A(null, 0.2, "unknown", []), wall_system: A("2x6", 0.4, "assumed", []), roof_system_type: A("asphalt_shingle", 0.91), roof_geometry: A("gable", 0.9), insulation_level: A(null, 0.2, "unknown", []), building_envelope: A("brick and vinyl", 0.9) },
15 + exterior: {
16 + primary_siding: A("brick", 0.94, "photo", ["photo_01", "photo_03"]), secondary_siding: A("vinyl", 0.9, "photo", ["photo_03"]), siding_shares: { brick_pct: 60, vinyl_pct: 40, fiber_cement_pct: 0, wood_pct: 0, stone_pct: 0, stucco_pct: 0, metal_pct: 0, other_pct: 0 },
17 + windows_type: A("pvc", 0.7), window_count_estimate: Q(18, "unit", 0.65), exterior_doors_count: Q(2, "unit", 0.8), roof_covering: A("asphalt_shingle", 0.91), gutters: A(true, 0.8), balconies_count: Q(0, "unit", 0.7), decks_count: Q(1, "unit", 0.8), exterior_stairs: A("concrete steps", 0.7),
18 + },
19 + interior: { flooring_types: [{ material: "hardwood", share_pct: 60, evidence: ["photo_05"] }, { material: "ceramic_tile", share_pct: 40, evidence: ["photo_06"] }], wall_finish: A("painted drywall", 0.9), ceiling_finish: A("flat painted", 0.8), trim_quality: A("standard", 0.6), door_quality: A("standard", 0.6), stair_quality: A("superior", 0.6), fireplace: A("gas", 0.7), built_ins: A(false, 0.6), ceiling_height_ft: A(9, 0.6) },
20 + kitchens: [{ location: "main floor", quality: "superior", cabinet_type: "painted_mdf", countertop: "quartz", island: true, estimated_cabinet_linear_ft: 34, estimated_countertop_sqft: 68, estimated_backsplash_sqft: 40, condition: "renovated", confidence: 0.82, evidence: ["photo_18", "photo_19"] }],
21 + bathrooms: [{ type: "full", quality: "standard", tub: "alcove", shower: "combined", double_vanity: false, tile_extent: "tub surround", fixtures_quality: "standard", estimated_area_sqft: 45, estimated_tile_sqft: 60, condition: "good", confidence: 0.75, evidence: ["photo_20"] }, { type: "powder", quality: "standard", tub: null, shower: null, double_vanity: false, tile_extent: null, fixtures_quality: "standard", estimated_area_sqft: 20, estimated_tile_sqft: 20, condition: "good", confidence: 0.7, evidence: ["photo_21"] }],
22 + mechanical: { heat_source: M("electricity", "listing", 0.9), heat_distribution: M("forced_air", "inferred", 0.6), heat_pump: M("central", "observed", 0.8), air_conditioning: M("central", "inferred", 0.7), air_exchanger: M("unknown", "unknown", 0.2), water_heater: M("electric tank", "observed", 0.7), fireplace: M("gas", "observed", 0.7), boiler: M("none", "inferred", 0.5), furnace: M("electric", "inferred", 0.5) },
23 + electrical: { panel_type: M("breaker", "observed", 0.7), panel_capacity_amps: A(200, 0.31, "ai_estimated", ["photo_24"]), service_type: M("underground", "unknown", 0.2), visible_age: M(null, "unknown", 0.1), ev_charger: M("absent", "inferred", 0.5), generator: M("absent", "inferred", 0.6), lighting_quality: M("standard", "observed", 0.6), smart_home: M(null, "unknown", 0.1) },
24 + plumbing: { water_supply: M("municipal", "listing", 0.9), waste_system: M("municipal", "listing", 0.9), visible_pipe_type: M("pex", "observed", 0.5), fixtures_quality: M("standard", "observed", 0.6), laundry: M("basement", "observed", 0.7) },
25 + basement: { state: A("finished", 0.85, "photo", ["photo_22"]), estimated_finished_pct: A(90, 0.7), quality: A("standard", 0.6), ceiling_type: A("drywall", 0.6), flooring: A("vinyl_plank", 0.6), bathroom: A(false, 0.5), bedrooms: A(1, 0.5) },
26 + garage: { type: A("attached", 0.9), spaces: A(2, 0.85), area_estimate_sqft: A(480, 0.5, "ai_estimated"), finished: A(false, 0.5), heated: A(null, 0.2, "unknown", []), door_count: A(1, 0.9) },
27 + exterior_improvements: { deck_sqft: Q(200, "pi2", 0.6), patio_sqft: Q(null, "pi2", 0.2, null, "unknown"), pool: A("none", 0.8), spa: A(false, 0.7), shed: A(true, 0.7), fence_linear_ft: Q(120, "pi_lin", 0.5), driveway_type: A("asphalt", 0.9), driveway_sqft: Q(600, "pi2", 0.6), landscaping_quality: A("standard", 0.6), retaining_wall: A(false, 0.6), outdoor_kitchen: A(false, 0.8) },
28 + quality: { exterior_quality: "superior", interior_quality: "superior", kitchen_quality: "superior", bathroom_quality: "standard", mechanical_quality: "standard", overall_quality: "superior", confidence: 0.8 },
29 + condition: { roof: "average", windows: "good", exterior: "good", interior: "very_good", kitchen: "renovated", bathrooms: "good", basement: "excellent", mechanical: "good", electrical: "good", structure: "good", site: "good", confidence: 0.75 },
30 + estimated_effective_age: { chronological_age: 28, effective_age: 17, economic_life: 60, confidence: 0.72, reasoning_summary: ["kitchen renovated", "windows appear newer", "roof appears mid-life"] },
31 + renovations: [{ component: "kitchen", renovation_likelihood: 0.91, estimated_renovation_age_range: [3, 8], evidence: ["photo_18"] }],
32 + estimated_quantities: { window_count: Q(18, "unit", 0.65), kitchen_cabinets_linear_ft: Q(34, "pi_lin", 0.7), countertop_sqft: Q(68, "pi2", 0.7), backsplash_sqft: Q(40, "pi2", 0.6), bathroom_tile_sqft: Q(80, "pi2", 0.5), deck_sqft: Q(200, "pi2", 0.6), driveway_sqft: Q(600, "pi2", 0.6), fence_linear_ft: Q(120, "pi_lin", 0.5), garage_area_sqft: Q(480, "pi2", 0.5), finished_basement_pct: Q(90, "pct", 0.7), interior_door_count: Q(12, "unit", 0.55) },
33 + assemblies: [{ assembly_code: "KIT-SUPERIOR", quantity: 34, unit: "pi_lin", confidence: 0.7, evidence: ["photo_18"] }, { assembly_code: "ROOF-ASPHALT-ARCH", quantity: 1650, unit: "pi2", confidence: 0.65, evidence: ["photo_01"] }, { assembly_code: "EXT-DECK-TREATED", quantity: 200, unit: "pi2", confidence: 0.6, evidence: ["photo_11"] }],
34 + uncertainties: ["listing says metal roof; photos show asphalt shingles"],
35 + privacy: { people_visible: false, documents_visible: false, licence_plates_visible: false },
36 + confidence: { overall: 0.86, construction: 0.85, exterior: 0.9, interior: 0.82, mechanical: 0.6, quantities: 0.66 },
37 + };
38 +}
39 +
40 +/** Fournisseur factice (tests, développement sans clé) : renvoie la fixture sans appel réseau. */
41 +export class FakeProvider implements PropertyVisionModel {
42 + readonly model = "fake-vision-model";
43 + async analyze(input: AnalyzeInput): Promise<AnalyzeResult> {
44 + void input;
45 + return { output: sampleOutput(), usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, estimatedCostUsd: 0, latencyMs: 1, attempts: 1 }, model: this.model, promptVersion: "v1", issues: [] };
46 + }
47 +}
added src/lib/cost/ai/images.ts +210 −0
@@ -0,0 +1,210 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Pipeline d'images d'annonce (§91-93, 160-163, 186-190) — SERVEUR :
4 + * téléchargement borné, type par octets magiques, dimensions par lecture des
5 + * en-têtes (sans dépendance), hash sha256, dédoublonnage, retrait des
6 + * miniatures, présélection ≤ MAX, manifeste transmis au modèle, cache disque
7 + * temporaire purgé selon LISTING_IMAGE_RETENTION_DAYS. Une photo qui échoue
8 + * n'arrête pas l'analyse. Les octets ne sont jamais redistribués.
9 + */
10 +import { createHash } from "crypto";
11 +import fs from "fs";
12 +import path from "path";
13 +
14 +export const MAX_IMAGE_BYTES = 4.5 * 1024 * 1024;
15 +export const MIN_IMAGE_PX = 400;
16 +export const FETCH_TIMEOUT_MS = 20_000;
17 +
18 +export type ImageMediaType = "image/jpeg" | "image/png" | "image/webp" | "image/gif";
19 +
20 +export interface PreparedImage {
21 + id: string; // photo_01…
22 + position: number; // position d'origine dans l'annonce (1-based)
23 + sourceUrl: string;
24 + hash: string;
25 + width: number | null;
26 + height: number | null;
27 + bytes: number;
28 + mediaType: ImageMediaType;
29 + roomHint: string;
30 + qualityScore: number;
31 + data: Buffer;
32 +}
33 +
34 +export interface ImagePipelineResult {
35 + selected: PreparedImage[];
36 + all: Omit<PreparedImage, "data">[];
37 + failed: { url: string; error: string }[];
38 + duplicatesRemoved: number;
39 + thumbnailsRemoved: number;
40 +}
41 +
42 +export function cacheDir(uid: string): string {
43 + return path.join(process.cwd(), "tmp", "listing-images", uid.replace(/[^a-zA-Z0-9_:-]/g, "_"));
44 +}
45 +
46 +/* ------------------------------------------------------------ détection */
47 +
48 +export function sniffMediaType(buf: Buffer): ImageMediaType | null {
49 + if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
50 + if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
51 + if (buf.length >= 12 && buf.subarray(0, 4).toString("ascii") === "RIFF" && buf.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
52 + if (buf.length >= 6 && buf.subarray(0, 3).toString("ascii") === "GIF") return "image/gif";
53 + return null;
54 +}
55 +
56 +/** Dimensions lues dans les en-têtes JPEG (SOF), PNG (IHDR), WebP (VP8/VP8L/VP8X), GIF. */
57 +export function readDimensions(buf: Buffer, type: ImageMediaType): { width: number; height: number } | null {
58 + try {
59 + if (type === "image/png" && buf.length >= 24) return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
60 + if (type === "image/gif" && buf.length >= 10) return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
61 + if (type === "image/webp" && buf.length >= 30) {
62 + const chunk = buf.subarray(12, 16).toString("ascii");
63 + if (chunk === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
64 + if (chunk === "VP8L") { const b0 = buf[21], b1 = buf[22], b2 = buf[23], b3 = buf[24]; return { width: 1 + (((b1 & 0x3f) << 8) | b0), height: 1 + (((b3 & 0xf) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)) }; }
65 + if (chunk === "VP8X") return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
66 + }
67 + if (type === "image/jpeg") {
68 + let i = 2;
69 + while (i + 9 < buf.length) {
70 + if (buf[i] !== 0xff) { i++; continue; }
71 + const marker = buf[i + 1];
72 + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
73 + const len = buf.readUInt16BE(i + 2);
74 + if ((marker >= 0xc0 && marker <= 0xcf) && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
75 + i += 2 + len;
76 + }
77 + }
78 + } catch { /* en-tête tronqué */ }
79 + return null;
80 +}
81 +
82 +export const sha256 = (b: Buffer | string): string => createHash("sha256").update(b).digest("hex");
83 +
84 +/* --------------------------------------------------------------- fetch */
85 +
86 +export async function fetchImage(url: string, timeoutMs = FETCH_TIMEOUT_MS): Promise<{ data: Buffer; mediaType: ImageMediaType }> {
87 + const ctrl = new AbortController();
88 + const t = setTimeout(() => ctrl.abort(), timeoutMs);
89 + try {
90 + const res = await fetch(url, { signal: ctrl.signal, headers: { "User-Agent": "Vrai-Prix/1.0 (analyse du bâtiment; +https://www.vrai-prix.com)", Accept: "image/*" }, redirect: "follow" });
91 + if (!res.ok) throw new Error(`HTTP ${res.status}`);
92 + const len = Number(res.headers.get("content-length") ?? 0);
93 + if (len > MAX_IMAGE_BYTES) throw new Error(`trop volumineuse (${len} octets)`);
94 + const ab = await res.arrayBuffer();
95 + if (ab.byteLength > MAX_IMAGE_BYTES) throw new Error(`trop volumineuse (${ab.byteLength} octets)`);
96 + const data = Buffer.from(ab);
97 + const mediaType = sniffMediaType(data);
98 + if (!mediaType) throw new Error("format d'image non reconnu");
99 + return { data, mediaType };
100 + } finally {
101 + clearTimeout(t);
102 + }
103 +}
104 +
105 +/* ----------------------------------------------------------- heuristiques */
106 +
107 +/** Indice de pièce léger à partir de l'URL et de la position (les photos 1-3 sont presque toujours extérieures). */
108 +export function roomHint(url: string, position: number, total: number): string {
109 + const u = url.toLowerCase();
110 + const has = (...k: string[]) => k.some((x) => u.includes(x));
111 + if (has("facade", "exterieur", "exterior", "front", "ext_")) return "exterior";
112 + if (has("cuisine", "kitchen")) return "kitchen";
113 + if (has("salle-de-bain", "bathroom", "sdb", "bain")) return "bathroom";
114 + if (has("chambre", "bedroom")) return "bedroom";
115 + if (has("salon", "living", "sejour")) return "living";
116 + if (has("sous-sol", "basement")) return "basement";
117 + if (has("garage")) return "garage";
118 + if (has("cour", "backyard", "yard", "terrasse", "deck", "piscine", "pool")) return "exterior_rear";
119 + if (has("plan", "floorplan")) return "floorplan";
120 + if (position <= 2) return "exterior";
121 + if (position === total && total > 8) return "exterior_rear";
122 + return "unknown";
123 +}
124 +
125 +/** Clé de regroupement : même photo servie en plusieurs tailles (-sm/-md/-lg, w=, /thumb/). */
126 +export function sizeAgnosticKey(url: string): string {
127 + return url
128 + .replace(/[?#].*$/, "")
129 + .replace(/-(xs|sm|md|lg|xl|xxl|thumb|thumbnail|small|medium|large|original)(?=\.[a-z]{3,4}$)/i, "")
130 + .replace(/\/(thumb|thumbs|thumbnail|small|medium|large)\//i, "/")
131 + .replace(/_(\d{2,4})x(\d{2,4})(?=\.[a-z]{3,4}$)/i, "")
132 + .toLowerCase();
133 +}
134 +
135 +/** Présélection : garde les extérieurs et une couverture régulière du reste. */
136 +export function selectImages<T extends { roomHint: string; position: number; qualityScore: number }>(imgs: T[], max: number): T[] {
137 + if (imgs.length <= max) return imgs;
138 + const ext = imgs.filter((i) => i.roomHint.startsWith("exterior") || i.roomHint === "floorplan").slice(0, Math.min(6, Math.floor(max / 4)));
139 + const rest = imgs.filter((i) => !ext.includes(i));
140 + const slots = max - ext.length;
141 + const picked: T[] = [];
142 + for (let k = 0; k < slots; k++) picked.push(rest[Math.floor((k * rest.length) / slots)]);
143 + return [...ext, ...picked].sort((a, b) => a.position - b.position);
144 +}
145 +
146 +/* ------------------------------------------------------------- pipeline */
147 +
148 +export async function prepareListingImages(uid: string, urls: string[], max: number, concurrency = 6): Promise<ImagePipelineResult> {
149 + const dir = cacheDir(uid);
150 + fs.mkdirSync(dir, { recursive: true });
151 + const failed: { url: string; error: string }[] = [];
152 + const fetched: (PreparedImage & { key: string })[] = [];
153 + let idx = 0;
154 + const worker = async () => {
155 + while (idx < urls.length) {
156 + const pos = idx++;
157 + const url = urls[pos];
158 + try {
159 + const { data, mediaType } = await fetchImage(url);
160 + const hash = sha256(data);
161 + const dims = readDimensions(data, mediaType);
162 + const px = dims ? Math.min(dims.width, dims.height) : null;
163 + const ext = mediaType.split("/")[1];
164 + const cached = path.join(dir, `${hash}.${ext}`);
165 + if (!fs.existsSync(cached)) fs.writeFileSync(cached, data);
166 + fetched.push({ id: "", position: pos + 1, sourceUrl: url, hash, width: dims?.width ?? null, height: dims?.height ?? null, bytes: data.length, mediaType, roomHint: roomHint(url, pos + 1, urls.length), qualityScore: px ? Math.min(1, px / 1200) : 0.5, data, key: sizeAgnosticKey(url) });
167 + } catch (e) {
168 + failed.push({ url, error: (e as Error).message });
169 + }
170 + }
171 + };
172 + await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, urls.length)) }, worker));
173 + fetched.sort((a, b) => a.position - b.position);
174 + // dédoublonnage : hash identique, puis même clé d'URL (on garde la plus grande)
175 + const byHash = new Map<string, PreparedImage & { key: string }>();
176 + let duplicates = 0;
177 + for (const f of fetched) {
178 + if (byHash.has(f.hash)) { duplicates++; continue; }
179 + byHash.set(f.hash, f);
180 + }
181 + const byKey = new Map<string, PreparedImage & { key: string }>();
182 + for (const f of byHash.values()) {
183 + const cur = byKey.get(f.key);
184 + if (!cur) byKey.set(f.key, f);
185 + else { duplicates++; if ((f.width ?? 0) * (f.height ?? 0) > (cur.width ?? 0) * (cur.height ?? 0)) byKey.set(f.key, f); }
186 + }
187 + let unique = [...byKey.values()].sort((a, b) => a.position - b.position);
188 + const before = unique.length;
189 + unique = unique.filter((i) => i.width == null || Math.min(i.width, i.height ?? i.width) >= MIN_IMAGE_PX);
190 + const thumbs = before - unique.length;
191 + const selected = selectImages(unique, max).map((img, i) => ({ ...img, id: `photo_${String(i + 1).padStart(2, "0")}` }));
192 + const all = unique.map((img) => { const sel = selected.find((s) => s.hash === img.hash); const { data: _d, key: _k, ...rest } = img; void _d; void _k; return { ...rest, id: sel?.id ?? "" }; });
193 + return { selected: selected.map(({ key: _k, ...r }) => { void _k; return r; }), all, failed, duplicatesRemoved: duplicates, thumbnailsRemoved: thumbs };
194 +}
195 +
196 +/** Purge du cache disque au-delà de la rétention (défaut 30 jours). */
197 +export function purgeImageCache(retentionDays = Number(process.env.LISTING_IMAGE_RETENTION_DAYS ?? 30)): number {
198 + const root = path.join(process.cwd(), "tmp", "listing-images");
199 + if (!fs.existsSync(root)) return 0;
200 + const cutoff = Date.now() - retentionDays * 86400000;
201 + let n = 0;
202 + for (const d of fs.readdirSync(root)) {
203 + const p = path.join(root, d);
204 + try {
205 + const st = fs.statSync(p);
206 + if (st.isDirectory() && st.mtimeMs < cutoff) { fs.rmSync(p, { recursive: true, force: true }); n++; }
207 + } catch { /* ignore */ }
208 + }
209 + return n;
210 +}
added src/lib/cost/ai/mapping.ts +123 −0
@@ -0,0 +1,123 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Faits fusionnés → entrée du moteur de coût (§116-118, 124-125, 143-149, 164).
4 + * Le mapping est déterministe : même JSON technique + même instantané de prix
5 + * = même résultat. L'IA n'intervient pas ici. Module PUR.
6 + */
7 +import { defaultInput } from "../estimate-defaults";
8 +import { deriveQuantities } from "../geometry";
9 +import { BUILDING_TYPES, CONDITION_GROUPS } from "../taxonomy";
10 +import type { AttributeSource, CostInput, Quality } from "../types";
11 +import type { MergedFacts } from "./merge";
12 +import type { ModelOutput } from "./schema";
13 +
14 +export const AI_QTY_MIN_CONFIDENCE = 0.6;
15 +
16 +export interface MappingResult {
17 + input: CostInput;
18 + /** quantités d'assemblage retenues depuis l'IA (code → {qty, confidence}) */
19 + aiQuantities: Record<string, { quantity: number; confidence: number; evidence: string[] }>;
20 + notes: string[];
21 +}
22 +
23 +function kitchenAssembly(q: Quality): string {
24 + return q === "economy" ? "KIT-ECONOMY" : q === "superior" ? "KIT-SUPERIOR" : q === "prestige" ? "KIT-PRESTIGE" : "KIT-STANDARD";
25 +}
26 +
27 +export function mapToCostInput(m: MergedFacts, ai: ModelOutput, ctx: { listingUid: string; address: string | null; municipality: string | null; lat: number | null; lng: number | null; unitId: string | null; landValue: number | null; roll: CostInput["roll"]; assemblyCodes: Set<string> }): MappingResult {
28 + const input = defaultInput();
29 + const notes: string[] = [];
30 + input.mode = "listing";
31 + input.listingUid = ctx.listingUid;
32 + input.propertyId = ctx.unitId;
33 + input.address = ctx.address;
34 + input.municipality = ctx.municipality;
35 + input.lat = ctx.lat; input.lng = ctx.lng;
36 + const b = input.building;
37 + const src: Partial<Record<string, AttributeSource>> = {};
38 + const set = <K extends keyof typeof b>(k: K, v: (typeof b)[K], s: AttributeSource) => { b[k] = v; src[k as string] = s; };
39 + set("type", m.buildingType.value, m.buildingType.source);
40 + set("quality", m.quality.value, m.quality.source);
41 + set("grossFloorAreaSqft", Math.round(m.grossFloorAreaSqft.value), m.grossFloorAreaSqft.source);
42 + set("footprintSqft", m.footprintSqft.value, m.footprintSqft.source);
43 + set("stories", Math.max(1, Math.round(m.stories.value)), m.stories.source);
44 + set("yearBuilt", m.yearBuilt.value, m.yearBuilt.source);
45 + set("basement", m.basement.value, m.basement.source);
46 + set("basementFinishedPct", m.basementFinishedPct.value, m.basementFinishedPct.source);
47 + set("garage", { type: m.garage.value.type, spaces: m.garage.value.spaces, areaSqft: m.garage.value.areaSqft }, m.garage.source);
48 + set("structure", m.structure.value, m.structure.source);
49 + set("foundation", m.foundation.value, m.foundation.source);
50 + set("siding", m.siding.value, m.siding.source);
51 + set("roof", m.roof.value, m.roof.source);
52 + set("roofGeometry", m.roofGeometry.value, m.roofGeometry.source);
53 + set("roofPitch", m.roofGeometry.value === "flat" ? 0 : Math.max(2, Math.min(14, m.roofPitch.value || 6)), m.roofPitch.source);
54 + set("windows", m.windows.value, m.windows.source);
55 + set("windowCount", m.windowCount.value != null && m.windowCount.confidence >= AI_QTY_MIN_CONFIDENCE ? Math.round(m.windowCount.value) : null, m.windowCount.source);
56 + set("heating", m.heating.value, m.heating.source);
57 + set("hasAirConditioning", m.hasAirConditioning.value, m.hasAirConditioning.source);
58 + set("hasAirExchanger", m.hasAirExchanger.value, m.hasAirExchanger.source);
59 + set("kitchens", Math.max(1, Math.round(m.kitchens.value)), m.kitchens.source);
60 + set("kitchenQuality", m.kitchenQuality.value, m.kitchenQuality.source);
61 + set("bathrooms", Math.max(0, Math.round(m.bathrooms.value)), m.bathrooms.source);
62 + set("powderRooms", Math.max(0, Math.round(m.powderRooms.value)), m.powderRooms.source);
63 + set("bathroomQuality", m.bathroomQuality.value, m.bathroomQuality.source);
64 + set("bedrooms", m.bedrooms.value, m.bedrooms.source);
65 + set("flooring", m.flooring.value ?? {}, m.flooring.source);
66 + set("deckSqft", Math.round(m.deckSqft.value), m.deckSqft.source);
67 + set("driveway", m.driveway.value, m.driveway.source);
68 + set("drivewaySqft", m.driveway.value === "none" ? 0 : Math.round(m.drivewaySqft.value), m.drivewaySqft.source);
69 + set("fenceLinFt", Math.round(m.fenceLinFt.value), m.fenceLinFt.source);
70 + set("pool", m.pool.value, m.pool.source);
71 + set("units", Math.max(1, Math.round(m.units.value)), m.units.source);
72 + input.attributeSources = src;
73 +
74 + // quantités IA fiables → surcharges d'assemblages (l'IA propose, le moteur calcule le reste)
75 + const aiQuantities: MappingResult["aiQuantities"] = {};
76 + const takeQty = (code: string, f: { value: number | null; confidence: number; evidence: string[] }, scale = 1) => {
77 + if (f.value != null && f.value > 0 && f.confidence >= AI_QTY_MIN_CONFIDENCE && ctx.assemblyCodes.has(code)) {
78 + aiQuantities[code] = { quantity: Math.round(f.value * scale * 100) / 100, confidence: f.confidence, evidence: f.evidence };
79 + input.quantityOverrides[code] = aiQuantities[code].quantity;
80 + }
81 + };
82 + takeQty(kitchenAssembly(m.kitchenQuality.value), m.kitchenLinearFt);
83 + takeQty("OPN-DOOR-INT-" + (m.quality.value === "superior" || m.quality.value === "prestige" ? "SOLID" : "HOLLOW"), m.interiorDoorCount);
84 + // assemblages suggérés par l'IA avec quantité soutenue par une preuve : acceptés seulement s'ils
85 + // font partie des assemblages que le moteur retient pour CE bâtiment (variantes de qualité/type déjà
86 + // choisies : cuisine, salles de bain, fenêtres, portes, chauffage…) ou d'une famille additive.
87 + const engineCodes = new Set(deriveQuantities(b, {}, "AI", input.excludedAssemblies).lines.map((l) => l.assemblyCode));
88 + const ADDITIVE = /^(EXT-DECK|EXT-FENCE|EXT-DRIVEWAY|EXT-POOL|EXT-STEPS|EXT-GUTTERS|OPN-SKYLIGHT|OPN-DOOR-GARAGE|ELE-EV-CHARGER|ELE-LIGHTING-EXT|MEC-HRV|MEC-HEATPUMP-WALL|MEC-CENTRAL-AC|STR-STAIRS|INT-CLOSET)/;
89 + for (const a of ai.assemblies) {
90 + if (a.quantity == null || a.quantity <= 0 || a.confidence < AI_QTY_MIN_CONFIDENCE || !ctx.assemblyCodes.has(a.assembly_code) || a.assembly_code in aiQuantities) continue;
91 + // ne jamais laisser l'IA surcharger la géométrie que le code dérive (murs, toit, fondations, gypse, planchers, cloisons)
92 + if (/^(STR-WALL|STR-FLOOR|STR-ROOF|STR-BEAM|STR-COLUMN|FND-|ROOF-|INT-DRYWALL|INT-PAINT|INT-FLOOR|INT-TRIM|ENV-INSUL|ENV-SIDING|ENV-SOFFIT|SITE-|BSM-|ELE-ROUGH|ELE-SERVICE|MEC-PLUMBING|MEC-DUCTWORK|MEC-DRAINAGE)/.test(a.assembly_code)) { notes.push(`quantité IA ignorée pour ${a.assembly_code} (dérivée par le code)`); continue; }
93 + if (!engineCodes.has(a.assembly_code) && !ADDITIVE.test(a.assembly_code)) { notes.push(`assemblage IA ${a.assembly_code} ignoré (variante non retenue pour ce bâtiment — évite le double compte)`); continue; }
94 + aiQuantities[a.assembly_code] = { quantity: a.quantity, confidence: a.confidence, evidence: a.evidence };
95 + input.quantityOverrides[a.assembly_code] = a.quantity;
96 + }
97 +
98 + // dépréciation par composante : conditions IA → règles métier ; âge effectif = suggestion IA
99 + input.depreciation.method = "components";
100 + input.depreciation.componentConditions = {};
101 + for (const g of CONDITION_GROUPS) { const c = m.conditions[g.key]; if (c) input.depreciation.componentConditions[g.key] = c; }
102 + input.depreciation.economicLife = BUILDING_TYPES.find((t) => t.key === b.type)?.economicLife ?? 60;
103 + if (m.effectiveAge?.effective != null) { input.depreciation.effectiveAge = Math.round(m.effectiveAge.effective); input.depreciation.effectiveAgeSource = "AI"; }
104 + if (m.effectiveAge?.economicLife && m.effectiveAge.economicLife >= 30) input.depreciation.economicLife = Math.round(m.effectiveAge.economicLife);
105 +
106 + // terrain : valeur du rôle si l'annonce est jumelée, sinon inconnue (à saisir)
107 + input.roll = ctx.roll;
108 + input.land = ctx.landValue && ctx.landValue > 0 ? { value: ctx.landValue, source: "role", rollYear: 2026, method: "Valeur du terrain au rôle d'évaluation foncière 2026 (MAMH), via le jumelage de l'annonce" } : { value: null, source: "none", rollYear: null, method: "" };
109 + if (!input.land.value) notes.push("terrain non jumelé : valeur du terrain à saisir");
110 + if (b.type === "condo") notes.push("condo : quote-part des parties communes non incluse");
111 + return { input, aiQuantities, notes };
112 +}
113 +
114 +/** Score combiné (§172-173) : analyse bâtiment · quantités · mapping · données de coût · localisation → globale /100. */
115 +export function combinedConfidence(ai: ModelOutput, m: MergedFacts, est: { coverage: { materialObservedShare: number; pricingCoverage: number }; confidence: { location: number } }, mappedShare: number): { building: number; quantities: number; mapping: number; costData: number; location: number; overall: number } {
116 + const building = Math.round(100 * ai.confidence.overall);
117 + const quantities = Math.round(100 * (0.5 * ai.confidence.quantities + 0.5 * Math.min(1, (m.grossFloorAreaSqft.confidence + m.stories.confidence) / 2)));
118 + const mapping = Math.round(100 * mappedShare);
119 + const costData = Math.round(100 * (0.7 * est.coverage.materialObservedShare + 0.3 * est.coverage.pricingCoverage));
120 + const location = Math.round((100 * est.confidence.location) / 15);
121 + const overall = Math.round(0.3 * building + 0.2 * quantities + 0.15 * mapping + 0.25 * costData + 0.1 * location);
122 + return { building, quantities, mapping, costData, location, overall };
123 +}
added src/lib/cost/ai/merge.ts +254 −0
@@ -0,0 +1,254 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Fusion des sources (§150-153) : rôle MAMH > champs structurés de l'annonce >
4 + * description (lue par l'IA) > observation visuelle > géométrie dérivée >
5 + * hypothèse typique. Aucun écrasement automatique : un désaccord entre sources
6 + * devient un CONFLIT affiché ; la source la plus forte gagne, l'autre est
7 + * conservée dans `alternatives`. Module PUR.
8 + */
9 +import type { AttributeSource, Basement, BuildingType, Condition, Foundation, GarageType, Heating, Quality, RoofGeometry, RoofType, SidingMix, WindowType } from "../types";
10 +import type { ModelOutput, EvidenceSource } from "./schema";
11 +
12 +export interface Fact<T> { value: T; source: AttributeSource; confidence: number; evidence: string[]; alternatives: { source: AttributeSource; value: T; confidence: number }[] }
13 +export interface Conflict { field: string; sourceA: AttributeSource; valueA: string; sourceB: AttributeSource; valueB: string; severity: "low" | "medium" | "high" }
14 +
15 +/** Faits structurés d'une annonce (colonnes + `details` JSON normalisé). */
16 +export interface ListingFacts {
17 + yearBuilt: number | null; areaSqft: number | null; bedrooms: number | null; bathrooms: number | null; powderRooms: number | null; propertyType: string | null; lotSqft: number | null;
18 + buildingType: BuildingType | null; stories: number | null; foundation: Foundation | null; siding: SidingMix | null; roof: RoofType | null; roofGeometry: RoofGeometry | null; windows: WindowType | null;
19 + heating: Heating | null; hasAirConditioning: boolean | null; hasAirExchanger: boolean | null; basement: Basement | null; garage: { type: GarageType; spaces: number } | null; pool: "none" | "above_ground" | "inground" | null;
20 + units: number | null; waterSupply: "municipal" | "well" | null; wasteSystem: "municipal" | "septic" | null;
21 +}
22 +
23 +export interface MamhFacts { yearBuilt: number | null; grossFloorAreaSqft: number | null; stories: number | null; buildingType: BuildingType | null; units: number | null; landValue: number | null; buildingValue: number | null; totalValue: number | null; unitId: string }
24 +
25 +export interface MergedFacts {
26 + buildingType: Fact<BuildingType>; yearBuilt: Fact<number | null>; stories: Fact<number>; grossFloorAreaSqft: Fact<number>; footprintSqft: Fact<number | null>;
27 + foundation: Fact<Foundation>; structure: Fact<"wood_frame" | "steel" | "concrete" | "log" | "masonry">; siding: Fact<SidingMix>; roof: Fact<RoofType>; roofGeometry: Fact<RoofGeometry>; roofPitch: Fact<number>; windows: Fact<WindowType>; windowCount: Fact<number | null>;
28 + heating: Fact<Heating>; hasAirConditioning: Fact<boolean>; hasAirExchanger: Fact<boolean>; basement: Fact<Basement>; basementFinishedPct: Fact<number>;
29 + garage: Fact<{ type: GarageType; spaces: number; areaSqft: number | null }>; kitchens: Fact<number>; kitchenQuality: Fact<Quality>; bathrooms: Fact<number>; powderRooms: Fact<number>; bathroomQuality: Fact<Quality>; bedrooms: Fact<number | null>;
30 + quality: Fact<Quality>; flooring: Fact<Record<string, number>>; deckSqft: Fact<number>; drivewaySqft: Fact<number>; driveway: Fact<"asphalt" | "pavers" | "gravel" | "concrete" | "none">; fenceLinFt: Fact<number>; pool: Fact<"none" | "above_ground" | "inground">; units: Fact<number>;
31 + kitchenLinearFt: Fact<number | null>; countertopSqft: Fact<number | null>; backsplashSqft: Fact<number | null>; bathroomTileSqft: Fact<number | null>; interiorDoorCount: Fact<number | null>;
32 + conditions: Partial<Record<string, Condition>>; effectiveAge: { chronological: number | null; effective: number | null; economicLife: number | null; confidence: number; reasoning: string[] } | null;
33 + conflicts: Conflict[];
34 +}
35 +
36 +const fold = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
37 +
38 +/* ------------------------------------------- normalisation des détails */
39 +
40 +/** Convertit le JSON `details` d'une annonce (clés françaises libres) en faits typés. */
41 +export function listingFactsFromDetails(listing: { year_built: number | null; area_sqft: number | null; bedrooms: number | null; bathrooms: number | null; powder_rooms: number | null; property_type: string | null; lot_sqft: number | null }, details: Record<string, unknown>): ListingFacts {
42 + const entries = Object.entries(details).map(([k, v]) => [fold(k), typeof v === "string" ? fold(v) : String(v ?? "")] as [string, string]);
43 + const get = (...keys: string[]): string | null => { const e = entries.find(([k]) => keys.some((kk) => k.includes(kk))); return e && e[1] && e[1] !== "inconnue" && e[1] !== "inconnu" ? e[1] : null; };
44 + const num = (s: string | null): number | null => { if (!s) return null; const m = s.replace(/\s/g, "").match(/(\d+(?:[.,]\d+)?)/); return m ? Number(m[1].replace(",", ".")) : null; };
45 + const pt = fold(listing.property_type ?? "");
46 + const typeTxt = `${pt} ${get("type de batiment", "type de propriete", "genre de propriete") ?? ""}`;
47 + let buildingType: BuildingType | null = null;
48 + if (/plex|duplex|triplex|quadruplex|quintuplex|multifamil|revenu/.test(typeTxt)) buildingType = "plex";
49 + else if (/condo|copropri|appartement|loft/.test(typeTxt)) buildingType = "condo";
50 + else if (/chalet|villegiature|cottage/.test(typeTxt)) buildingType = "chalet";
51 + else if (/mobile|prefabriqu|usinee/.test(typeTxt)) buildingType = "mobile";
52 + else if (/jumel|semi-detach|semi detach/.test(typeTxt)) buildingType = "semi_detached";
53 + else if (/rangee|en rangee|townhouse|maison de ville/.test(typeTxt)) buildingType = "row";
54 + else if (/detach|isole|maison|bungalow|plain-pied|cottage|paliers|unifamil/.test(typeTxt)) buildingType = "detached";
55 + const storiesTxt = get("nombre d'etages", "nombre d etages", "etages") ?? (/plain-pied|bungalow/.test(typeTxt) ? "1" : /2 etages|cottage|a etages/.test(typeTxt) ? "2" : null);
56 + const yr = listing.year_built && listing.year_built > 1600 ? listing.year_built : num(get("annee de construction")) ;
57 + const fTxt = get("fondation");
58 + const foundation: Foundation | null = !fTxt ? null : /coul|beton/.test(fTxt) && !/bloc/.test(fTxt) ? "poured_concrete" : /bloc/.test(fTxt) ? "concrete_block" : /dalle/.test(fTxt) ? "slab_on_grade" : /pieu|pilot/.test(fTxt) ? "piers" : /pierre/.test(fTxt) ? "stone" : null;
59 + const sTxt = get("revetement", "revetement exterieur", "parement");
60 + let siding: SidingMix | null = null;
61 + if (sTxt) {
62 + const parts: (keyof SidingMix)[] = [];
63 + if (/brique/.test(sTxt)) parts.push("brick"); if (/vinyle/.test(sTxt)) parts.push("vinyl"); if (/canexel|fibro|fibre de ciment|fibrociment/.test(sTxt)) parts.push("fiber_cement"); if (/bois|cedre/.test(sTxt)) parts.push("wood");
64 + if (/pierre/.test(sTxt)) parts.push("stone"); if (/stuc|crepi|acrylique/.test(sTxt)) parts.push("stucco"); if (/aluminium/.test(sTxt)) parts.push("aluminum"); if (/acier|tole/.test(sTxt)) parts.push("steel");
65 + if (parts.length) { siding = {}; parts.forEach((p, i) => { siding![p] = i === 0 ? (parts.length === 1 ? 1 : 0.6) : 0.4 / (parts.length - 1); }); }
66 + }
67 + const rTxt = get("toiture", "toit", "couverture");
68 + const roof: RoofType | null = !rTxt ? null : /bardeau|asphalte/.test(rTxt) ? "asphalt_shingle" : /tole|metal|acier/.test(rTxt) ? "metal" : /membrane|elastom|epdm|tpo|goudron|gravier|asphalte et gravier/.test(rTxt) ? "membrane" : /cedre/.test(rTxt) ? "cedar" : /ardoise|tuile/.test(rTxt) ? "slate_tile" : null;
69 + const roofGeometry: RoofGeometry | null = !rTxt ? null : /plat/.test(rTxt) ? "flat" : /mansard/.test(rTxt) ? "mansard" : /quatre versants|4 versants|croupe/.test(rTxt) ? "hip" : /pignon|deux versants|2 versants/.test(rTxt) ? "gable" : null;
70 + const wTxt = get("fenestration", "fenetre");
71 + const windows: WindowType | null = !wTxt ? null : /hybride/.test(wTxt) ? "hybrid" : /pvc/.test(wTxt) ? "pvc" : /aluminium/.test(wTxt) ? "aluminum" : /bois/.test(wTxt) ? "wood" : null;
72 + const hTxt = `${get("chauffage", "systeme de chauffage", "mode de chauffage") ?? ""} ${get("energie pour le chauffage", "energie") ?? ""}`.trim();
73 + let heating: Heating | null = null;
74 + if (hTxt) {
75 + if (/thermopompe|pompe a chaleur/.test(hTxt) && !/mural/.test(hTxt)) heating = "heat_pump";
76 + else if (/geotherm/.test(hTxt)) heating = "geothermal";
77 + else if (/plinthe|convecteur|electrique/.test(hTxt) && !/air soufflee|air chaud|fournaise/.test(hTxt)) heating = "electric_baseboard";
78 + else if (/air soufflee|air chaud|fournaise/.test(hTxt)) heating = /gaz/.test(hTxt) ? "furnace_gas" : /mazout|huile/.test(hTxt) ? "furnace_oil" : "furnace_electric";
79 + else if (/eau chaude|radiateur|radiant|hydron/.test(hTxt)) heating = "hydronic";
80 + else if (/bois|granule/.test(hTxt)) heating = "wood";
81 + }
82 + const eqTxt = `${get("equipement", "equipement disponible", "climatisation", "systeme de ventilation") ?? ""} ${entries.map(([, v]) => v).join(" ")}`;
83 + const hasAirConditioning = /climatis|thermopompe/.test(eqTxt) ? true : null;
84 + const hasAirExchanger = /echangeur d'air|echangeur d air|vrc|ventilateur recuperateur/.test(eqTxt) ? true : null;
85 + const bTxt = get("sous-sol", "sous sol");
86 + const basement: Basement | null = !bTxt ? null : /aucun|sans sous-sol|vide sanitaire/.test(bTxt) ? (/vide/.test(bTxt) ? "crawl" : "none") : /totalement amenage|entierement amenage|fini|amenage/.test(bTxt) && !/partiellement|non/.test(bTxt) ? "finished" : /partiellement/.test(bTxt) ? "partial" : /non amenage|brut/.test(bTxt) ? "unfinished" : /rez-de-jardin|walk/.test(bTxt) ? "walkout" : /6 pieds|pieds et plus/.test(bTxt) ? "unfinished" : null;
87 + const gTxt = get("garage", "stationnement");
88 + let garage: { type: GarageType; spaces: number } | null = null;
89 + if (gTxt) {
90 + if (/garage/.test(gTxt) || entries.some(([k]) => k === "garage")) {
91 + const type: GarageType = /detach/.test(gTxt) ? "detached" : /integre|sous|au sous-sol/.test(gTxt) ? "integrated" : /attach|attenant/.test(gTxt) ? "attached" : /abri/.test(gTxt) ? "carport" : "attached";
92 + const m = gTxt.match(/garage\s*\((\d+)\)/); const spaces = m ? Number(m[1]) : /double/.test(gTxt) ? 2 : /triple/.test(gTxt) ? 3 : 1;
93 + garage = { type, spaces };
94 + } else if (/allee|exterieur/.test(gTxt)) garage = { type: "none", spaces: 0 };
95 + }
96 + const pTxt = get("piscine");
97 + const pool = !pTxt ? null : /creus/.test(pTxt) ? "inground" : /hors|hors-terre|hors terre/.test(pTxt) ? "above_ground" : /aucun|non/.test(pTxt) ? "none" : null;
98 + const units = num(get("nombre d'unites", "nombre de logements", "logements")) ?? (buildingType === "plex" ? (/duplex/.test(typeTxt) ? 2 : /triplex/.test(typeTxt) ? 3 : /quadruplex/.test(typeTxt) ? 4 : /quintuplex/.test(typeTxt) ? 5 : null) : null);
99 + const waterTxt = get("approvisionnement en eau", "eau");
100 + const wasteTxt = get("systeme d'egout", "systeme d egout", "egout");
101 + return {
102 + yearBuilt: yr, areaSqft: listing.area_sqft && listing.area_sqft > 50 ? listing.area_sqft : num(get("superficie habitable", "superficie du batiment", "superficie batiment")), bedrooms: listing.bedrooms, bathrooms: listing.bathrooms, powderRooms: listing.powder_rooms, propertyType: listing.property_type, lotSqft: listing.lot_sqft,
103 + buildingType, stories: storiesTxt ? num(storiesTxt) : null, foundation, siding, roof, roofGeometry, windows, heating, hasAirConditioning, hasAirExchanger, basement, garage, pool, units,
104 + waterSupply: !waterTxt ? null : /municip/.test(waterTxt) ? "municipal" : /puits/.test(waterTxt) ? "well" : null, wasteSystem: !wasteTxt ? null : /municip/.test(wasteTxt) ? "municipal" : /septi|fosse/.test(wasteTxt) ? "septic" : null,
105 + };
106 +}
107 +
108 +/* ------------------------------------------------------------- fusion */
109 +
110 +const TIER: Record<AttributeSource, number> = { cadastral: 0, MAMH: 0, listing: 1, user: -1, AI: 3, derived: 4, assumed: 5 };
111 +const aiSourceToAttr = (s: EvidenceSource): AttributeSource => (s === "MAMH" ? "MAMH" : s === "listing" ? "listing" : s === "derived" ? "derived" : s === "assumed" || s === "unknown" ? "assumed" : "AI");
112 +
113 +interface Cand<T> { value: T | null | undefined; source: AttributeSource; confidence: number; evidence?: string[] }
114 +
115 +function pick<T>(field: string, cands: Cand<T>[], fallback: T, conflicts: Conflict[], severity: Conflict["severity"] = "medium", eq: (a: T, b: T) => boolean = (a, b) => JSON.stringify(a) === JSON.stringify(b)): Fact<T> {
116 + const valid = cands.filter((c): c is Cand<T> & { value: T } => c.value != null && c.value !== ("" as unknown));
117 + if (!valid.length) return { value: fallback, source: "assumed", confidence: 0.3, evidence: [], alternatives: [] };
118 + // l'IA qui déclare une source « listing »/« MAMH » reste derrière les champs structurés réels
119 + valid.sort((a, b) => TIER[a.source] - TIER[b.source] || b.confidence - a.confidence);
120 + const best = valid[0];
121 + const alternatives = valid.slice(1).map((c) => ({ source: c.source, value: c.value, confidence: c.confidence }));
122 + for (const alt of valid.slice(1)) {
123 + if (!eq(best.value, alt.value) && alt.confidence >= 0.5) {
124 + conflicts.push({ field, sourceA: best.source, valueA: JSON.stringify(best.value), sourceB: alt.source, valueB: JSON.stringify(alt.value), severity });
125 + }
126 + }
127 + return { value: best.value, source: best.source, confidence: best.confidence, evidence: best.evidence ?? [], alternatives };
128 +}
129 +
130 +const q = (v: string | null | undefined): Quality | null => (v === "custom_luxury" ? "prestige" : v === "economy" || v === "standard" || v === "superior" || v === "prestige" ? v : null);
131 +const numClose = (tol: number) => (a: number, b: number) => Math.abs(a - b) <= tol * Math.max(Math.abs(a), Math.abs(b), 1);
132 +
133 +export function mergeFacts(ai: ModelOutput, listing: ListingFacts, mamh: MamhFacts | null): MergedFacts {
134 + const conflicts: Conflict[] = [];
135 + const A = <T,>(a: { value: T | null; confidence: number; source: EvidenceSource; evidence: string[] } | undefined): Cand<T> => ({ value: a?.value ?? null, source: aiSourceToAttr(a?.source ?? "unknown"), confidence: a?.confidence ?? 0, evidence: a?.evidence ?? [] });
136 + const L = <T,>(v: T | null | undefined, conf = 0.9): Cand<T> => ({ value: v, source: "listing", confidence: conf });
137 + const M = <T,>(v: T | null | undefined): Cand<T> => ({ value: v, source: "MAMH", confidence: 0.97 });
138 +
139 + const buildingType = pick<BuildingType>("buildingType", [M(mamh?.buildingType), L(listing.buildingType), A(ai.property.building_type as { value: BuildingType | null; confidence: number; source: EvidenceSource; evidence: string[] })], "detached", conflicts, "high");
140 + const yearBuilt = pick<number | null>("yearBuilt", [M(mamh?.yearBuilt), L(listing.yearBuilt), A(ai.property.year_built)], null, conflicts, "medium", (a, b) => a == null || b == null ? a === b : numClose(0.02)(a, b));
141 + const stories = pick<number>("stories", [M(mamh?.stories), L(listing.stories), A(ai.property.stories)], 1, conflicts, "high", (a, b) => Math.round(a) === Math.round(b));
142 + // MAMH : aire d'étages hors sous-sol (référence) ; annonce : superficie habitable (peut inclure le sous-sol)
143 + const grossFloorAreaSqft = pick<number>("grossFloorAreaSqft", [M(mamh?.grossFloorAreaSqft), L(listing.areaSqft, 0.8), A(ai.property.gross_floor_area_sqft)], 1500, conflicts, "high", numClose(0.25));
144 + const footprintSqft = pick<number | null>("footprintSqft", [A(ai.geometry.footprint_sqft)], null, conflicts, "low");
145 + const foundation = pick<Foundation>("foundation", [L(listing.foundation), A(ai.construction.foundation as Cand<Foundation>["value"] extends never ? never : { value: Foundation | null; confidence: number; source: EvidenceSource; evidence: string[] })], "poured_concrete", conflicts, "medium");
146 + const structure = pick<MergedFacts["structure"]["value"]>("structure", [A(ai.construction.structural_system as { value: MergedFacts["structure"]["value"] | null; confidence: number; source: EvidenceSource; evidence: string[] })], "wood_frame", conflicts, "low");
147 + const aiSiding = sidingFromAi(ai);
148 + const siding = pick<SidingMix>("siding", [L(listing.siding, 0.75), { value: aiSiding.mix, source: "AI", confidence: aiSiding.confidence, evidence: ai.exterior.primary_siding.evidence }], { vinyl: 1 }, conflicts, "medium", sameDominant);
149 + const roof = pick<RoofType>("roof", [L(listing.roof), A(ai.exterior.roof_covering as { value: RoofType | null; confidence: number; source: EvidenceSource; evidence: string[] }), A(ai.construction.roof_system_type as { value: RoofType | null; confidence: number; source: EvidenceSource; evidence: string[] })], "asphalt_shingle", conflicts, "high");
150 + const roofGeometry = pick<RoofGeometry>("roofGeometry", [L(listing.roofGeometry), A(ai.construction.roof_geometry as { value: RoofGeometry | null; confidence: number; source: EvidenceSource; evidence: string[] })], "gable", conflicts, "low");
151 + const roofPitch = pick<number>("roofPitch", [A(ai.geometry.roof_pitch_rise_per_12)], roofGeometry.value === "flat" ? 0 : 6, conflicts, "low");
152 + const windows = pick<WindowType>("windows", [L(listing.windows), A(ai.exterior.windows_type as { value: WindowType | null; confidence: number; source: EvidenceSource; evidence: string[] })], "pvc", conflicts, "low");
153 + const wc = ai.estimated_quantities.window_count.value ?? ai.exterior.window_count_estimate.value;
154 + const windowCount = pick<number | null>("windowCount", [{ value: wc, source: "AI", confidence: Math.max(ai.estimated_quantities.window_count.confidence, ai.exterior.window_count_estimate.confidence), evidence: ai.estimated_quantities.window_count.evidence }], null, conflicts, "low");
155 + const heating = pick<Heating>("heating", [L(listing.heating), { value: heatingFromAi(ai), source: aiSourceFromMech(ai.mechanical.heat_source.status), confidence: Math.min(ai.mechanical.heat_source.confidence, ai.mechanical.heat_distribution.confidence || 0.5), evidence: [...ai.mechanical.heat_source.evidence, ...ai.mechanical.heat_distribution.evidence] }], "electric_baseboard", conflicts, "medium");
156 + const hasAirConditioning = pick<boolean>("hasAirConditioning", [L(listing.hasAirConditioning), { value: ai.mechanical.air_conditioning.value == null || ai.mechanical.air_conditioning.value === "unknown" ? null : ai.mechanical.air_conditioning.value !== "none", source: aiSourceFromMech(ai.mechanical.air_conditioning.status), confidence: ai.mechanical.air_conditioning.confidence, evidence: ai.mechanical.air_conditioning.evidence }], heating.value === "heat_pump", conflicts, "low");
157 + const hasAirExchanger = pick<boolean>("hasAirExchanger", [L(listing.hasAirExchanger), { value: ai.mechanical.air_exchanger.value == null || ai.mechanical.air_exchanger.value === "unknown" ? null : ai.mechanical.air_exchanger.value === "present", source: aiSourceFromMech(ai.mechanical.air_exchanger.status), confidence: ai.mechanical.air_exchanger.confidence, evidence: ai.mechanical.air_exchanger.evidence }], (yearBuilt.value ?? 1990) >= 2000, conflicts, "low");
158 + const aiBasement = basementFromAi(ai.basement.state.value);
159 + const basement = pick<Basement>("basement", [L(listing.basement), { value: aiBasement, source: aiSourceToAttr(ai.basement.state.source), confidence: ai.basement.state.confidence, evidence: ai.basement.state.evidence }], buildingType.value === "condo" || buildingType.value === "mobile" ? "none" : "unfinished", conflicts, "medium");
160 + const pctAi = ai.estimated_quantities.finished_basement_pct.value ?? ai.basement.estimated_finished_pct.value;
161 + const basementFinishedPct = pick<number>("basementFinishedPct", [{ value: pctAi != null ? Math.min(1, pctAi > 1 ? pctAi / 100 : pctAi) : null, source: "AI", confidence: ai.basement.estimated_finished_pct.confidence, evidence: ai.basement.estimated_finished_pct.evidence }], basement.value === "finished" || basement.value === "walkout" ? 0.9 : basement.value === "partial" ? 0.5 : 0, conflicts, "low");
162 + const gAi = ai.garage.type.value && ai.garage.type.value !== "unknown" ? { type: ai.garage.type.value as GarageType, spaces: Math.max(ai.garage.type.value === "none" ? 0 : 1, Math.round(ai.garage.spaces.value ?? 1)), areaSqft: ai.estimated_quantities.garage_area_sqft.value ?? ai.garage.area_estimate_sqft.value } : null;
163 + const garage = pick<MergedFacts["garage"]["value"]>("garage", [L(listing.garage ? { ...listing.garage, areaSqft: gAi?.areaSqft ?? null } : null), { value: gAi, source: aiSourceToAttr(ai.garage.type.source), confidence: ai.garage.type.confidence, evidence: ai.garage.type.evidence }], { type: "none", spaces: 0, areaSqft: null }, conflicts, "medium", (a, b) => a.type === b.type);
164 + const units = pick<number>("units", [M(mamh?.units), L(listing.units), A(ai.property.dwelling_units)], buildingType.value === "plex" ? 2 : 1, conflicts, "medium");
165 + const kitchens = pick<number>("kitchens", [{ value: ai.kitchens.length || null, source: "AI", confidence: 0.7, evidence: ai.kitchens.flatMap((k) => k.evidence) }], Math.max(1, units.value), conflicts, "low");
166 + const bathrooms = pick<number>("bathrooms", [L(listing.bathrooms), A(ai.property.bathrooms), { value: ai.bathrooms.filter((b) => b.type !== "powder").length || null, source: "AI", confidence: 0.6 }], 1, conflicts, "low");
167 + const powderRooms = pick<number>("powderRooms", [L(listing.powderRooms), A(ai.property.powder_rooms), { value: ai.bathrooms.filter((b) => b.type === "powder").length || null, source: "AI", confidence: 0.6 }], 0, conflicts, "low");
168 + const bedrooms = pick<number | null>("bedrooms", [L(listing.bedrooms), A(ai.property.bedrooms)], null, conflicts, "low");
169 + const kq = q(ai.quality.kitchen_quality) ?? q(ai.kitchens[0]?.quality ?? null);
170 + const kitchenQuality = pick<Quality>("kitchenQuality", [{ value: kq, source: "AI", confidence: ai.quality.confidence, evidence: ai.kitchens.flatMap((k) => k.evidence) }], "standard", conflicts, "low");
171 + const bq = q(ai.quality.bathroom_quality) ?? q(ai.bathrooms[0]?.quality ?? null);
172 + const bathroomQuality = pick<Quality>("bathroomQuality", [{ value: bq, source: "AI", confidence: ai.quality.confidence, evidence: ai.bathrooms.flatMap((b) => b.evidence) }], "standard", conflicts, "low");
173 + const quality = pick<Quality>("quality", [{ value: q(ai.quality.overall_quality), source: "AI", confidence: ai.quality.confidence, evidence: [] }], "standard", conflicts, "low");
174 + const flooring = pick<Record<string, number>>("flooring", [{ value: flooringFromAi(ai), source: "AI", confidence: ai.confidence.interior, evidence: ai.interior.flooring_types.flatMap((f) => f.evidence) }], {}, conflicts, "low");
175 + const deckSqft = pick<number>("deckSqft", [{ value: ai.estimated_quantities.deck_sqft.value ?? ai.exterior_improvements.deck_sqft.value, source: "AI", confidence: ai.estimated_quantities.deck_sqft.confidence, evidence: ai.estimated_quantities.deck_sqft.evidence }], 0, conflicts, "low");
176 + const drivewayType = pick<MergedFacts["driveway"]["value"]>("driveway", [{ value: ai.exterior_improvements.driveway_type.value && ai.exterior_improvements.driveway_type.value !== "unknown" ? (ai.exterior_improvements.driveway_type.value as MergedFacts["driveway"]["value"]) : null, source: aiSourceToAttr(ai.exterior_improvements.driveway_type.source), confidence: ai.exterior_improvements.driveway_type.confidence, evidence: ai.exterior_improvements.driveway_type.evidence }], buildingType.value === "condo" ? "none" : "asphalt", conflicts, "low");
177 + const drivewaySqft = pick<number>("drivewaySqft", [{ value: ai.estimated_quantities.driveway_sqft.value ?? ai.exterior_improvements.driveway_sqft.value, source: "AI", confidence: ai.estimated_quantities.driveway_sqft.confidence, evidence: ai.estimated_quantities.driveway_sqft.evidence }], drivewayType.value === "none" ? 0 : 500, conflicts, "low");
178 + const fenceLinFt = pick<number>("fenceLinFt", [{ value: ai.estimated_quantities.fence_linear_ft.value ?? ai.exterior_improvements.fence_linear_ft.value, source: "AI", confidence: ai.estimated_quantities.fence_linear_ft.confidence, evidence: ai.estimated_quantities.fence_linear_ft.evidence }], 0, conflicts, "low");
179 + const pool = pick<MergedFacts["pool"]["value"]>("pool", [L(listing.pool), { value: ai.exterior_improvements.pool.value && ai.exterior_improvements.pool.value !== "unknown" ? (ai.exterior_improvements.pool.value as MergedFacts["pool"]["value"]) : null, source: aiSourceToAttr(ai.exterior_improvements.pool.source), confidence: ai.exterior_improvements.pool.confidence, evidence: ai.exterior_improvements.pool.evidence }], "none", conflicts, "low");
180 + const aiQty = (qv: { value: number | null; confidence: number; evidence: string[] }) => pick<number | null>("qty", [{ value: qv.value, source: "AI", confidence: qv.confidence, evidence: qv.evidence }], null, [], "low");
181 + const conditions: Partial<Record<string, Condition>> = {};
182 + const cmap: Record<string, keyof ModelOutput["condition"]> = { roof: "roof", windows: "windows", exterior: "exterior", interior: "interior", kitchen: "kitchen", bathroom: "bathrooms", mechanical: "mechanical", electrical: "electrical", structure: "structure", site: "site" };
183 + for (const [g, k] of Object.entries(cmap)) { const v = ai.condition[k]; if (v && typeof v === "string") conditions[g] = v as Condition; }
184 + const ea = ai.estimated_effective_age;
185 + return {
186 + buildingType, yearBuilt, stories, grossFloorAreaSqft, footprintSqft, foundation, structure, siding, roof, roofGeometry, roofPitch, windows, windowCount, heating, hasAirConditioning, hasAirExchanger, basement, basementFinishedPct, garage,
187 + kitchens, kitchenQuality, bathrooms, powderRooms, bathroomQuality, bedrooms, quality, flooring, deckSqft, drivewaySqft, driveway: drivewayType, fenceLinFt, pool, units,
188 + kitchenLinearFt: aiQty(ai.estimated_quantities.kitchen_cabinets_linear_ft), countertopSqft: aiQty(ai.estimated_quantities.countertop_sqft), backsplashSqft: aiQty(ai.estimated_quantities.backsplash_sqft), bathroomTileSqft: aiQty(ai.estimated_quantities.bathroom_tile_sqft), interiorDoorCount: aiQty(ai.estimated_quantities.interior_door_count),
189 + conditions, effectiveAge: ea.effective_age != null || ea.chronological_age != null ? { chronological: ea.chronological_age, effective: ea.effective_age, economicLife: ea.economic_life, confidence: ea.confidence, reasoning: ea.reasoning_summary } : null,
190 + conflicts,
191 + };
192 +}
193 +
194 +/* ------------------------------------------------------------ helpers IA */
195 +
196 +function sidingFromAi(ai: ModelOutput): { mix: SidingMix | null; confidence: number } {
197 + const s = ai.exterior.siding_shares;
198 + const raw: Record<keyof SidingMix, number> = { brick: s.brick_pct, vinyl: s.vinyl_pct, fiber_cement: s.fiber_cement_pct, wood: s.wood_pct, stone: s.stone_pct, stucco: s.stucco_pct, steel: s.metal_pct, aluminum: 0 };
199 + const total = Object.values(raw).reduce((a, b) => a + (b > 0 ? b : 0), 0);
200 + if (total > 0) {
201 + const mix: SidingMix = {};
202 + for (const [k, v] of Object.entries(raw) as [keyof SidingMix, number][]) if (v > 0) mix[k] = v / total;
203 + return { mix, confidence: Math.max(ai.exterior.primary_siding.confidence, 0.5) };
204 + }
205 + const p = ai.exterior.primary_siding.value;
206 + if (p && p !== "unknown") { const mix: SidingMix = {}; mix[p as keyof SidingMix] = 1; return { mix, confidence: ai.exterior.primary_siding.confidence }; }
207 + return { mix: null, confidence: 0 };
208 +}
209 +const sameDominant = (a: SidingMix, b: SidingMix) => { const d = (m: SidingMix) => Object.entries(m).sort((x, y) => (y[1] ?? 0) - (x[1] ?? 0))[0]?.[0]; return d(a) === d(b); };
210 +
211 +function heatingFromAi(ai: ModelOutput): Heating | null {
212 + const src = ai.mechanical.heat_source.value, dist = ai.mechanical.heat_distribution.value, hp = ai.mechanical.heat_pump.value;
213 + if (hp === "central") return "heat_pump";
214 + if (src === "geothermal") return "geothermal";
215 + if (src === "wood") return "wood";
216 + if (dist === "forced_air") return src === "natural_gas" || src === "propane" ? "furnace_gas" : src === "oil" ? "furnace_oil" : "furnace_electric";
217 + if (dist === "radiant_floor" || dist === "hydronic_radiators") return "hydronic";
218 + if (dist === "baseboards" || dist === "convectors" || dist === "heat_pump_wall") return "electric_baseboard";
219 + if (src === "natural_gas" || src === "propane") return "furnace_gas";
220 + if (src === "oil") return "furnace_oil";
221 + if (src === "electricity") return "electric_baseboard";
222 + return null;
223 +}
224 +const aiSourceFromMech = (s: string): AttributeSource => (s === "listing" ? "listing" : s === "observed" ? "AI" : s === "inferred" ? "AI" : "assumed");
225 +function basementFromAi(v: string | null): Basement | null {
226 + switch (v) { case "none": return "none"; case "crawl": return "crawl"; case "unfinished": return "unfinished"; case "partially_finished": return "partial"; case "finished": return "finished"; case "walkout": return "walkout"; default: return null; }
227 +}
228 +function flooringFromAi(ai: ModelOutput): Record<string, number> | null {
229 + const map: Record<string, string> = { hardwood: "hardwood", engineered_wood: "engineered", vinyl_plank: "vinyl_plank", laminate_floor: "laminate", ceramic_tile: "ceramic", porcelain_tile: "ceramic", carpet: "carpet" };
230 + const out: Record<string, number> = {};
231 + for (const f of ai.interior.flooring_types) { const k = map[f.material]; if (k && f.share_pct > 0) out[k] = (out[k] ?? 0) + f.share_pct; }
232 + const tot = Object.values(out).reduce((a, b) => a + b, 0);
233 + if (!tot) return null;
234 + for (const k of Object.keys(out)) out[k] = out[k] / tot;
235 + return out;
236 +}
237 +
238 +/** Applique des corrections manuelles (field_path → valeur) sur les faits fusionnés ; source = user. */
239 +export function applyOverrides(m: MergedFacts, overrides: { fieldPath: string; value: unknown }[]): MergedFacts {
240 + const out: MergedFacts = JSON.parse(JSON.stringify(m));
241 + for (const o of overrides) {
242 + const [root, sub] = o.fieldPath.split(".");
243 + if (root === "conditions" && sub) { out.conditions[sub] = o.value as Condition; continue; }
244 + if (root === "effectiveAge") { out.effectiveAge = { ...(out.effectiveAge ?? { chronological: null, effective: null, economicLife: null, confidence: 1, reasoning: [] }), effective: Number(o.value), confidence: 1, reasoning: ["corrigé par l'utilisateur"] }; continue; }
245 + const f = (out as unknown as Record<string, Fact<unknown> | undefined>)[root];
246 + if (!f || typeof f !== "object" || !("value" in f)) continue;
247 + const original = JSON.parse(JSON.stringify(f.value ?? null)) as unknown;
248 + if (sub && f.value && typeof f.value === "object") (f.value as Record<string, unknown>)[sub] = o.value;
249 + else f.value = o.value;
250 + f.alternatives = [{ source: f.source, value: original, confidence: f.confidence }, ...f.alternatives];
251 + f.source = "user"; f.confidence = 1; f.evidence = ["user_override"];
252 + }
253 + return out;
254 +}
added src/lib/cost/ai/provider.ts +110 −0
@@ -0,0 +1,110 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Abstraction du modèle de vision (§181) + fournisseur Claude Sonnet 5 (§94,
4 + * 134-136, 180-182). Sortie structurée via un outil imposé par `tool_choice`
5 + * (schéma JSON complet ; `strict` optionnel — voir note dans analyze), streaming
6 + * + `finalMessage()`, prompt système versionné et mis en cache,
7 + * validation/réparation maison + 1 relance.
8 + * Aucune chaîne de pensée n'est conservée.
9 + */
10 +import Anthropic from "@anthropic-ai/sdk";
11 +import fs from "fs";
12 +import path from "path";
13 +import { buildModelSchema, PROMPT_VERSION, schemaForApi, validateAndRepair, type ModelOutput, type ValidationIssue } from "./schema";
14 +import type { ImageMediaType } from "./images";
15 +
16 +export const DEFAULT_MODEL = "claude-sonnet-5";
17 +export const TOOL_NAME = "submit_property_analysis";
18 +/** Tarif Sonnet 5 (USD / M jetons) — pour l'estimation de coût API (admin). */
19 +export const PRICING_USD = { input: 2.0, output: 10.0, cacheRead: 0.2, cacheWrite: 2.5 };
20 +
21 +export interface AnalyzeImage { id: string; mediaType: ImageMediaType; data: Buffer; roomHint: string; position: number }
22 +export interface AnalyzeInput {
23 + listingContext: string; // texte déterministe : champs structurés + description + MAMH
24 + images: AnalyzeImage[];
25 + assemblyCodes: string[];
26 + assemblyCatalogueText: string; // codes + libellés + unités
27 +}
28 +export interface AnalyzeUsage { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; estimatedCostUsd: number; latencyMs: number; attempts: number }
29 +export interface AnalyzeResult { output: ModelOutput; usage: AnalyzeUsage; model: string; promptVersion: string; issues: ValidationIssue[] }
30 +
31 +export interface PropertyVisionModel {
32 + readonly model: string;
33 + analyze(input: AnalyzeInput): Promise<AnalyzeResult>;
34 +}
35 +
36 +let promptCache: string | null = null;
37 +export function loadSystemPrompt(): string {
38 + if (promptCache) return promptCache;
39 + const p = path.join(process.cwd(), "prompts", `property-cost-analysis-${PROMPT_VERSION}.md`);
40 + promptCache = fs.readFileSync(p, "utf8");
41 + return promptCache;
42 +}
43 +
44 +export function estimateCostUsd(u: { input: number; output: number; cacheRead: number; cacheWrite: number }): number {
45 + return (u.input * PRICING_USD.input + u.output * PRICING_USD.output + u.cacheRead * PRICING_USD.cacheRead + u.cacheWrite * PRICING_USD.cacheWrite) / 1_000_000;
46 +}
47 +
48 +export class ClaudeSonnet5Provider implements PropertyVisionModel {
49 + readonly model: string;
50 + private client: Anthropic;
51 + constructor(model = process.env.PROPERTY_ANALYSIS_MODEL ?? DEFAULT_MODEL, client?: Anthropic) {
52 + this.model = model;
53 + this.client = client ?? new Anthropic();
54 + }
55 +
56 + async analyze(input: AnalyzeInput): Promise<AnalyzeResult> {
57 + const schema = buildModelSchema(input.assemblyCodes);
58 + const system: Anthropic.TextBlockParam[] = [
59 + { type: "text", text: loadSystemPrompt(), cache_control: { type: "ephemeral" } },
60 + { type: "text", text: `## Assembly catalogue (only these codes are valid)\n${input.assemblyCatalogueText}`, cache_control: { type: "ephemeral" } },
61 + ];
62 + const manifest = input.images.map((i) => `${i.id}: position ${i.position}, room_hint=${i.roomHint}`).join("\n");
63 + const content: Anthropic.ContentBlockParam[] = [];
64 + for (const img of input.images) {
65 + content.push({ type: "text", text: `[${img.id}]` });
66 + content.push({ type: "image", source: { type: "base64", media_type: img.mediaType, data: img.data.toString("base64") } });
67 + }
68 + content.push({ type: "text", text: `## Image manifest\n${manifest}\n\n## Listing context\n${input.listingContext}\n\nExtract the building profile now by calling the ${TOOL_NAME} tool. Fill every field; use null when not supported by evidence.` });
69 + // Mode strict de l'API : limité à 16 paramètres nullables/union — notre schéma en compte ~150 (chaque
70 + // inférence est nullable par conception, §94). On force donc l'outil (tool_choice) sans `strict`, et la
71 + // conformité est garantie par le validateur/réparateur local + une relance. STRICT=1 réactive le mode strict.
72 + const strict = process.env.PROPERTY_ANALYSIS_STRICT === "1";
73 + const tools: Anthropic.Tool[] = [{ name: TOOL_NAME, description: "Structured technical profile of the building for the cost engine (no monetary values). Every field is required; use null when evidence is missing.", ...(strict ? { strict: true } : {}), input_schema: schemaForApi(schema) as Anthropic.Tool.InputSchema }];
74 + const messages: Anthropic.MessageParam[] = [{ role: "user", content }];
75 +
76 + const t0 = Date.now();
77 + let inTok = 0, outTok = 0, cacheRead = 0, cacheWrite = 0;
78 + let attempts = 0;
79 + let lastIssues: ValidationIssue[] = [];
80 + let output: ModelOutput | null = null;
81 + while (attempts < 2 && !output) {
82 + attempts++;
83 + const stream = this.client.messages.stream({
84 + model: this.model, max_tokens: 24000, system, messages, tools, tool_choice: { type: "tool", name: TOOL_NAME }, thinking: { type: "disabled" },
85 + });
86 + const msg = await stream.finalMessage();
87 + inTok += msg.usage.input_tokens; outTok += msg.usage.output_tokens;
88 + cacheRead += msg.usage.cache_read_input_tokens ?? 0; cacheWrite += msg.usage.cache_creation_input_tokens ?? 0;
89 + if (msg.stop_reason === "refusal") throw new Error("Le modèle a refusé l'analyse (stop_reason=refusal).");
90 + const tu = msg.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use" && b.name === TOOL_NAME);
91 + if (!tu) {
92 + lastIssues = [{ path: "$", message: `aucun appel d'outil (stop_reason=${msg.stop_reason})`, repaired: false }];
93 + } else {
94 + const { value, issues } = validateAndRepair(tu.input, schema);
95 + const hard = issues.filter((i) => !i.repaired);
96 + lastIssues = issues;
97 + if (!hard.length) { output = value as ModelOutput; break; }
98 + }
99 + // relance unique avec les erreurs de validation
100 + messages.push({ role: "assistant", content: msg.content.map((b) => (b.type === "tool_use" ? { type: "tool_use" as const, id: b.id, name: b.name, input: b.input } : b.type === "text" ? { type: "text" as const, text: b.text } : { type: "text" as const, text: "" })).filter((b) => b.type !== "text" || b.text) });
101 + const toolId = msg.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")?.id;
102 + messages.push({ role: "user", content: toolId ? [{ type: "tool_result", tool_use_id: toolId, is_error: true, content: `Schema validation failed:\n${lastIssues.slice(0, 40).map((i) => `${i.path}: ${i.message}`).join("\n")}\nCall ${TOOL_NAME} again with a fully valid input.` }] : `Schema validation failed. Call ${TOOL_NAME} again with a fully valid input.` });
103 + }
104 + if (!output) throw new Error(`Sortie du modèle invalide après ${attempts} tentative(s) : ${lastIssues.slice(0, 5).map((i) => `${i.path} ${i.message}`).join(" ; ")}`);
105 + return {
106 + output, model: this.model, promptVersion: PROMPT_VERSION, issues: lastIssues,
107 + usage: { inputTokens: inTok, outputTokens: outTok, cacheReadTokens: cacheRead, cacheWriteTokens: cacheWrite, estimatedCostUsd: estimateCostUsd({ input: inTok, output: outTok, cacheRead, cacheWrite }), latencyMs: Date.now() - t0, attempts },
108 + };
109 + }
110 +}
added src/lib/cost/ai/schema.ts +287 −0
@@ -0,0 +1,287 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Property Technical JSON (§95-118) : types TypeScript, JSON Schema STRICT
4 + * (outil Claude, additionalProperties:false, required complet) et validateur /
5 + * réparateur déterministe maison (aucune dépendance). Module PUR.
6 + *
7 + * Le modèle ne produit que la partie TECHNIQUE (`ModelOutput`) ; le code
8 + * ajoute `metadata` et `listing` (données connues), la géométrie dérivée et
9 + * les quantités calculables par formule (§193).
10 + */
11 +import { MATERIALS } from "../taxonomy";
12 +
13 +export const SCHEMA_VERSION = "1.0";
14 +export const PROMPT_VERSION = "v1";
15 +
16 +/* ------------------------------------------------------------------ types */
17 +
18 +export type EvidenceSource = "listing" | "MAMH" | "photo" | "derived" | "ai_estimated" | "assumed" | "unknown";
19 +export type MechStatus = "observed" | "listing" | "inferred" | "unknown";
20 +export type QualityLevel = "economy" | "standard" | "superior" | "prestige" | "custom_luxury";
21 +export type ConditionLevel = "poor" | "below_average" | "average" | "good" | "very_good" | "excellent" | "renovated" | "new";
22 +
23 +export interface StrAttr { value: string | null; confidence: number; source: EvidenceSource; evidence: string[] }
24 +export interface NumAttr { value: number | null; confidence: number; source: EvidenceSource; evidence: string[] }
25 +export interface BoolAttr { value: boolean | null; confidence: number; source: EvidenceSource; evidence: string[] }
26 +export interface Qty { value: number | null; unit: string; confidence: number; source: EvidenceSource; method: string | null; evidence: string[] }
27 +export interface MechAttr { value: string | null; status: MechStatus; confidence: number; evidence: string[] }
28 +
29 +export interface KitchenAnalysis { location: string | null; quality: QualityLevel | null; cabinet_type: string | null; countertop: string | null; island: boolean | null; estimated_cabinet_linear_ft: number | null; estimated_countertop_sqft: number | null; estimated_backsplash_sqft: number | null; condition: ConditionLevel | null; confidence: number; evidence: string[] }
30 +export interface BathroomAnalysis { type: "full" | "powder" | "ensuite" | "unknown"; quality: QualityLevel | null; tub: string | null; shower: string | null; double_vanity: boolean | null; tile_extent: string | null; fixtures_quality: QualityLevel | null; estimated_area_sqft: number | null; estimated_tile_sqft: number | null; condition: ConditionLevel | null; confidence: number; evidence: string[] }
31 +export interface RenovationHint { component: string; renovation_likelihood: number; estimated_renovation_age_range: number[]; evidence: string[] }
32 +export interface AssemblyHint { assembly_code: string; quantity: number | null; unit: string; confidence: number; evidence: string[] }
33 +
34 +export interface ModelOutput {
35 + property: {
36 + building_type: StrAttr; year_built: NumAttr; stories: NumAttr; gross_floor_area_sqft: NumAttr; bedrooms: NumAttr; bathrooms: NumAttr; powder_rooms: NumAttr; dwelling_units: NumAttr;
37 + };
38 + geometry: { footprint_sqft: NumAttr; roof_area_sqft: NumAttr; exterior_wall_area_sqft: NumAttr; perimeter_ft: NumAttr; roof_pitch_rise_per_12: NumAttr };
39 + construction: { foundation: StrAttr; structural_system: StrAttr; floor_system: StrAttr; wall_system: StrAttr; roof_system_type: StrAttr; roof_geometry: StrAttr; insulation_level: StrAttr; building_envelope: StrAttr };
40 + exterior: {
41 + primary_siding: StrAttr; secondary_siding: StrAttr; siding_shares: { brick_pct: number; vinyl_pct: number; fiber_cement_pct: number; wood_pct: number; stone_pct: number; stucco_pct: number; metal_pct: number; other_pct: number };
42 + windows_type: StrAttr; window_count_estimate: Qty; exterior_doors_count: Qty; roof_covering: StrAttr; gutters: BoolAttr; balconies_count: Qty; decks_count: Qty; exterior_stairs: StrAttr;
43 + };
44 + interior: { flooring_types: { material: string; share_pct: number; evidence: string[] }[]; wall_finish: StrAttr; ceiling_finish: StrAttr; trim_quality: StrAttr; door_quality: StrAttr; stair_quality: StrAttr; fireplace: StrAttr; built_ins: BoolAttr; ceiling_height_ft: NumAttr };
45 + kitchens: KitchenAnalysis[];
46 + bathrooms: BathroomAnalysis[];
47 + mechanical: { heat_source: MechAttr; heat_distribution: MechAttr; heat_pump: MechAttr; air_conditioning: MechAttr; air_exchanger: MechAttr; water_heater: MechAttr; fireplace: MechAttr; boiler: MechAttr; furnace: MechAttr };
48 + electrical: { panel_type: MechAttr; panel_capacity_amps: NumAttr; service_type: MechAttr; visible_age: MechAttr; ev_charger: MechAttr; generator: MechAttr; lighting_quality: MechAttr; smart_home: MechAttr };
49 + plumbing: { water_supply: MechAttr; waste_system: MechAttr; visible_pipe_type: MechAttr; fixtures_quality: MechAttr; laundry: MechAttr };
50 + basement: { state: StrAttr; estimated_finished_pct: NumAttr; quality: StrAttr; ceiling_type: StrAttr; flooring: StrAttr; bathroom: BoolAttr; bedrooms: NumAttr };
51 + garage: { type: StrAttr; spaces: NumAttr; area_estimate_sqft: NumAttr; finished: BoolAttr; heated: BoolAttr; door_count: NumAttr };
52 + exterior_improvements: { deck_sqft: Qty; patio_sqft: Qty; pool: StrAttr; spa: BoolAttr; shed: BoolAttr; fence_linear_ft: Qty; driveway_type: StrAttr; driveway_sqft: Qty; landscaping_quality: StrAttr; retaining_wall: BoolAttr; outdoor_kitchen: BoolAttr };
53 + quality: { exterior_quality: QualityLevel | null; interior_quality: QualityLevel | null; kitchen_quality: QualityLevel | null; bathroom_quality: QualityLevel | null; mechanical_quality: QualityLevel | null; overall_quality: QualityLevel | null; confidence: number };
54 + condition: { roof: ConditionLevel | null; windows: ConditionLevel | null; exterior: ConditionLevel | null; interior: ConditionLevel | null; kitchen: ConditionLevel | null; bathrooms: ConditionLevel | null; basement: ConditionLevel | null; mechanical: ConditionLevel | null; electrical: ConditionLevel | null; structure: ConditionLevel | null; site: ConditionLevel | null; confidence: number };
55 + estimated_effective_age: { chronological_age: number | null; effective_age: number | null; economic_life: number | null; confidence: number; reasoning_summary: string[] };
56 + renovations: RenovationHint[];
57 + estimated_quantities: { window_count: Qty; kitchen_cabinets_linear_ft: Qty; countertop_sqft: Qty; backsplash_sqft: Qty; bathroom_tile_sqft: Qty; deck_sqft: Qty; driveway_sqft: Qty; fence_linear_ft: Qty; garage_area_sqft: Qty; finished_basement_pct: Qty; interior_door_count: Qty };
58 + assemblies: AssemblyHint[];
59 + uncertainties: string[];
60 + privacy: { people_visible: boolean; documents_visible: boolean; licence_plates_visible: boolean };
61 + confidence: { overall: number; construction: number; exterior: number; interior: number; mechanical: number; quantities: number };
62 +}
63 +
64 +export interface AnalysisMetadata { analysis_id: string; listing_id: string; listing_source: string; model: string; prompt_version: string; schema_version: string; analysis_timestamp: string; image_count: number; input_hash: string; input_token_estimate: number | null; output_tokens: number | null; latency_ms: number | null }
65 +export interface AnalysisListing { address: string | null; municipality: string | null; region: string | null; asking_price: number; listing_date: string | null; description_excerpt: string | null; bedrooms: number | null; bathrooms: number | null; powder_rooms: number | null; lot_area_sqft: number | null; living_area_sqft: number | null; year_built: number | null; property_type: string | null; url: string | null; unit_id: string | null }
66 +export interface ImageManifestEntry { id: string; position: number; room_hint: string; source_url: string; hash: string; width: number | null; height: number | null }
67 +
68 +export interface PropertyTechnicalAnalysis extends ModelOutput {
69 + metadata: AnalysisMetadata;
70 + listing: AnalysisListing;
71 + images: ImageManifestEntry[];
72 + /** géométrie recalculée par le code (source derived) — remplace les valeurs IA quand une formule existe */
73 + derived_geometry: { footprint_sqft: number; perimeter_ft: number; exposed_perimeter_ft: number; gross_wall_sqft: number; net_wall_sqft: number; roof_sqft: number; window_count: number; basement_floor_sqft: number; basement_finished_sqft: number; garage_sqft: number; formulas: Record<string, string> };
74 +}
75 +
76 +/* ----------------------------------------------------------- JSON Schema */
77 +
78 +type JS = Record<string, unknown>;
79 +const NUM_NULL: JS = { type: ["number", "null"] };
80 +const STR_NULL: JS = { type: ["string", "null"] };
81 +const BOOL_NULL: JS = { type: ["boolean", "null"] };
82 +const CONF: JS = { type: "number", minimum: 0, maximum: 1, description: "0.0 to 1.0" };
83 +const EVID: JS = { type: "array", items: { type: "string" } };
84 +const SRC: JS = { type: "string", enum: ["listing", "MAMH", "photo", "derived", "ai_estimated", "assumed", "unknown"] };
85 +const MECH_STATUS: JS = { type: "string", enum: ["observed", "listing", "inferred", "unknown"] };
86 +export const QUALITY_ENUM = ["economy", "standard", "superior", "prestige", "custom_luxury"] as const;
87 +export const CONDITION_ENUM = ["poor", "below_average", "average", "good", "very_good", "excellent", "renovated", "new"] as const;
88 +const QUAL_NULL: JS = nullableEnum(QUALITY_ENUM);
89 +const COND_NULL: JS = nullableEnum(CONDITION_ENUM);
90 +
91 +/** Énumération nullable en mode strict : `anyOf` (un `enum` mixte string/null est refusé par l'API). */
92 +function nullableEnum(values: readonly string[], desc?: string): JS {
93 + return { anyOf: [{ type: "string", enum: [...values] }, { type: "null" }], ...(desc ? { description: desc } : {}) };
94 +}
95 +function obj(props: Record<string, JS>): JS {
96 + return { type: "object", properties: props, required: Object.keys(props), additionalProperties: false };
97 +}
98 +const strAttr = (values?: readonly string[], desc?: string): JS => obj({ value: values ? nullableEnum(values, desc) : { ...STR_NULL, ...(desc ? { description: desc } : {}) }, confidence: CONF, source: SRC, evidence: EVID });
99 +const numAttr = (desc?: string): JS => obj({ value: { ...NUM_NULL, ...(desc ? { description: desc } : {}) }, confidence: CONF, source: SRC, evidence: EVID });
100 +const boolAttr = (desc?: string): JS => obj({ value: { ...BOOL_NULL, ...(desc ? { description: desc } : {}) }, confidence: CONF, source: SRC, evidence: EVID });
101 +const qty = (unit: string, desc: string): JS => obj({ value: { ...NUM_NULL, description: desc }, unit: { type: "string", enum: [unit] }, confidence: CONF, source: SRC, method: STR_NULL, evidence: EVID });
102 +const mech = (values?: readonly string[]): JS => obj({ value: values ? nullableEnum(values) : STR_NULL, status: MECH_STATUS, confidence: CONF, evidence: EVID });
103 +
104 +export const BUILDING_TYPE_ENUM = ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile", "other"] as const;
105 +export const FOUNDATION_ENUM = ["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone", "unknown"] as const;
106 +export const STRUCTURE_ENUM = ["wood_frame", "steel", "concrete", "log", "masonry", "unknown"] as const;
107 +export const ROOF_TYPE_ENUM = ["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile", "unknown"] as const;
108 +export const ROOF_GEOM_ENUM = ["gable", "hip", "flat", "mansard", "complex", "unknown"] as const;
109 +export const SIDING_ENUM = ["vinyl", "brick", "fiber_cement", "wood", "stone", "stucco", "aluminum", "steel", "unknown"] as const;
110 +export const WINDOW_ENUM = ["pvc", "hybrid", "aluminum", "wood", "unknown"] as const;
111 +export const BASEMENT_ENUM = ["none", "crawl", "unfinished", "partially_finished", "finished", "walkout", "unknown"] as const;
112 +export const GARAGE_ENUM = ["none", "attached", "detached", "integrated", "carport", "unknown"] as const;
113 +export const HEAT_SOURCE_ENUM = ["electricity", "natural_gas", "propane", "oil", "wood", "geothermal", "dual_energy", "unknown"] as const;
114 +export const HEAT_DIST_ENUM = ["baseboards", "forced_air", "radiant_floor", "hydronic_radiators", "heat_pump_wall", "convectors", "unknown"] as const;
115 +export const POOL_ENUM = ["none", "above_ground", "inground", "unknown"] as const;
116 +export const DRIVEWAY_ENUM = ["asphalt", "pavers", "gravel", "concrete", "none", "unknown"] as const;
117 +const FLOOR_MATERIALS = ["hardwood", "engineered_wood", "vinyl_plank", "laminate_floor", "ceramic_tile", "porcelain_tile", "carpet", "concrete_floor", "unknown"] as const;
118 +
119 +export function buildModelSchema(assemblyCodes: string[]): JS {
120 + return obj({
121 + property: obj({
122 + building_type: strAttr(BUILDING_TYPE_ENUM, "Type de bâtiment"), year_built: numAttr("Année de construction"), stories: numAttr("Nombre d'étages hors sous-sol"), gross_floor_area_sqft: numAttr("Aire d'étages hors sous-sol (pi²)"),
123 + bedrooms: numAttr(), bathrooms: numAttr("Salles de bain complètes"), powder_rooms: numAttr("Salles d'eau"), dwelling_units: numAttr("Nombre de logements"),
124 + }),
125 + geometry: obj({ footprint_sqft: numAttr("Empreinte au sol (pi²) — seulement si observable"), roof_area_sqft: numAttr(), exterior_wall_area_sqft: numAttr(), perimeter_ft: numAttr(), roof_pitch_rise_per_12: numAttr("Pente du toit (élévation sur 12)") }),
126 + construction: obj({
127 + foundation: strAttr(FOUNDATION_ENUM), structural_system: strAttr(STRUCTURE_ENUM), floor_system: strAttr(), wall_system: strAttr(), roof_system_type: strAttr(ROOF_TYPE_ENUM, "Type de couverture"), roof_geometry: strAttr(ROOF_GEOM_ENUM), insulation_level: strAttr(), building_envelope: strAttr(),
128 + }),
129 + exterior: obj({
130 + primary_siding: strAttr(SIDING_ENUM), secondary_siding: strAttr(SIDING_ENUM),
131 + siding_shares: obj({ brick_pct: { type: "number" }, vinyl_pct: { type: "number" }, fiber_cement_pct: { type: "number" }, wood_pct: { type: "number" }, stone_pct: { type: "number" }, stucco_pct: { type: "number" }, metal_pct: { type: "number" }, other_pct: { type: "number" } }),
132 + windows_type: strAttr(WINDOW_ENUM), window_count_estimate: qty("unit", "Nombre total de fenêtres estimé"), exterior_doors_count: qty("unit", "Portes extérieures (hors porte-patio et garage)"), roof_covering: strAttr(ROOF_TYPE_ENUM), gutters: boolAttr(), balconies_count: qty("unit", "Balcons"), decks_count: qty("unit", "Terrasses"), exterior_stairs: strAttr(),
133 + }),
134 + interior: obj({
135 + flooring_types: { type: "array", items: obj({ material: { type: "string", enum: [...FLOOR_MATERIALS] }, share_pct: { type: "number" }, evidence: EVID }) },
136 + wall_finish: strAttr(), ceiling_finish: strAttr(), trim_quality: strAttr(), door_quality: strAttr(), stair_quality: strAttr(), fireplace: strAttr(), built_ins: boolAttr(), ceiling_height_ft: numAttr(),
137 + }),
138 + kitchens: { type: "array", items: obj({ location: STR_NULL, quality: QUAL_NULL, cabinet_type: nullableEnum(["melamine", "thermofoil", "painted_mdf", "wood_veneer", "solid_wood", "laminate", "unknown"]), countertop: nullableEnum(["quartz", "granite", "laminate", "butcher_block", "solid_surface", "marble", "unknown"]), island: BOOL_NULL, estimated_cabinet_linear_ft: NUM_NULL, estimated_countertop_sqft: NUM_NULL, estimated_backsplash_sqft: NUM_NULL, condition: COND_NULL, confidence: CONF, evidence: EVID }) },
139 + bathrooms: { type: "array", items: obj({ type: { type: "string", enum: ["full", "powder", "ensuite", "unknown"] }, quality: QUAL_NULL, tub: STR_NULL, shower: STR_NULL, double_vanity: BOOL_NULL, tile_extent: STR_NULL, fixtures_quality: QUAL_NULL, estimated_area_sqft: NUM_NULL, estimated_tile_sqft: NUM_NULL, condition: COND_NULL, confidence: CONF, evidence: EVID }) },
140 + mechanical: obj({ heat_source: mech(HEAT_SOURCE_ENUM), heat_distribution: mech(HEAT_DIST_ENUM), heat_pump: mech(["central", "wall_mounted", "none", "unknown"]), air_conditioning: mech(["central", "wall_unit", "none", "unknown"]), air_exchanger: mech(["present", "absent", "unknown"]), water_heater: mech(), fireplace: mech(), boiler: mech(), furnace: mech() }),
141 + electrical: obj({ panel_type: mech(), panel_capacity_amps: numAttr(), service_type: mech(), visible_age: mech(), ev_charger: mech(["present", "absent", "unknown"]), generator: mech(["present", "absent", "unknown"]), lighting_quality: mech(), smart_home: mech() }),
142 + plumbing: obj({ water_supply: mech(["municipal", "well", "unknown"]), waste_system: mech(["municipal", "septic", "unknown"]), visible_pipe_type: mech(), fixtures_quality: mech(), laundry: mech() }),
143 + basement: obj({ state: strAttr(BASEMENT_ENUM), estimated_finished_pct: numAttr("0-100"), quality: strAttr(QUALITY_ENUM), ceiling_type: strAttr(), flooring: strAttr(), bathroom: boolAttr(), bedrooms: numAttr() }),
144 + garage: obj({ type: strAttr(GARAGE_ENUM), spaces: numAttr(), area_estimate_sqft: numAttr(), finished: boolAttr(), heated: boolAttr(), door_count: numAttr() }),
145 + exterior_improvements: obj({ deck_sqft: qty("pi2", "Terrasse(s), surface totale"), patio_sqft: qty("pi2", "Patio/dalle"), pool: strAttr(POOL_ENUM), spa: boolAttr(), shed: boolAttr(), fence_linear_ft: qty("pi_lin", "Clôture"), driveway_type: strAttr(DRIVEWAY_ENUM), driveway_sqft: qty("pi2", "Entrée"), landscaping_quality: strAttr(QUALITY_ENUM), retaining_wall: boolAttr(), outdoor_kitchen: boolAttr() }),
146 + quality: obj({ exterior_quality: QUAL_NULL, interior_quality: QUAL_NULL, kitchen_quality: QUAL_NULL, bathroom_quality: QUAL_NULL, mechanical_quality: QUAL_NULL, overall_quality: QUAL_NULL, confidence: CONF }),
147 + condition: obj({ roof: COND_NULL, windows: COND_NULL, exterior: COND_NULL, interior: COND_NULL, kitchen: COND_NULL, bathrooms: COND_NULL, basement: COND_NULL, mechanical: COND_NULL, electrical: COND_NULL, structure: COND_NULL, site: COND_NULL, confidence: CONF }),
148 + estimated_effective_age: obj({ chronological_age: NUM_NULL, effective_age: NUM_NULL, economic_life: NUM_NULL, confidence: CONF, reasoning_summary: { type: "array", items: { type: "string" }, description: "Faits observés, factuels et courts (pas de raisonnement interne)" } }),
149 + renovations: { type: "array", items: obj({ component: { type: "string" }, renovation_likelihood: CONF, estimated_renovation_age_range: { type: "array", items: { type: "number" } }, evidence: EVID }) },
150 + estimated_quantities: obj({
151 + window_count: qty("unit", "Fenêtres"), kitchen_cabinets_linear_ft: qty("pi_lin", "Armoires de cuisine (base + haut)"), countertop_sqft: qty("pi2", "Comptoirs"), backsplash_sqft: qty("pi2", "Dosseret"), bathroom_tile_sqft: qty("pi2", "Carrelage des salles de bain"),
152 + deck_sqft: qty("pi2", "Terrasse"), driveway_sqft: qty("pi2", "Entrée"), fence_linear_ft: qty("pi_lin", "Clôture"), garage_area_sqft: qty("pi2", "Garage"), finished_basement_pct: qty("pct", "Part du sous-sol finie"), interior_door_count: qty("unit", "Portes intérieures"),
153 + }),
154 + assemblies: { type: "array", items: obj({ assembly_code: { type: "string", enum: assemblyCodes }, quantity: NUM_NULL, unit: { type: "string" }, confidence: CONF, evidence: EVID }) },
155 + uncertainties: { type: "array", items: { type: "string" } },
156 + privacy: obj({ people_visible: { type: "boolean" }, documents_visible: { type: "boolean" }, licence_plates_visible: { type: "boolean" } }),
157 + confidence: obj({ overall: CONF, construction: CONF, exterior: CONF, interior: CONF, mechanical: CONF, quantities: CONF }),
158 + });
159 +}
160 +
161 +/* ------------------------------------------------- validation / réparation */
162 +
163 +export interface ValidationIssue { path: string; message: string; repaired: boolean }
164 +
165 +/**
166 + * Valide `data` contre un schéma (sous-ensemble JSON Schema : type, enum,
167 + * properties/required/additionalProperties, items, minimum/maximum) et
168 + * répare de façon déterministe : champ manquant → null / défaut, chaîne
169 + * numérique → nombre, énumération inconnue → null, confiance hors [0,1] →
170 + * bornée, clé inconnue → supprimée. Retourne l'objet réparé et les anomalies.
171 + */
172 +export function validateAndRepair(data: unknown, schema: JS, path = "$"): { value: unknown; issues: ValidationIssue[] } {
173 + const issues: ValidationIssue[] = [];
174 + const value = walk(data, schema, path, issues);
175 + return { value, issues };
176 +}
177 +
178 +function typeList(schema: JS): string[] {
179 + const t = schema.type;
180 + return Array.isArray(t) ? (t as string[]) : typeof t === "string" ? [t] : [];
181 +}
182 +
183 +function defaultFor(schema: JS): unknown {
184 + const types = typeList(schema);
185 + if (types.includes("null")) return null;
186 + if (types.includes("object")) return walk({}, schema, "$", []);
187 + if (types.includes("array")) return [];
188 + if (types.includes("number") || types.includes("integer")) return typeof schema.minimum === "number" ? schema.minimum : 0;
189 + if (types.includes("boolean")) return false;
190 + if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
191 + return "";
192 +}
193 +
194 +function walk(v: unknown, schema: JS, path: string, issues: ValidationIssue[]): unknown {
195 + if (Array.isArray(schema.anyOf)) {
196 + const branches = schema.anyOf as JS[];
197 + const nullBranch = branches.find((b) => typeList(b).includes("null"));
198 + const main = branches.find((b) => !typeList(b).includes("null")) ?? branches[0];
199 + if (v == null) { if (nullBranch) return null; issues.push({ path, message: "champ manquant", repaired: true }); return defaultFor(main); }
200 + const before = issues.length;
201 + const out = walk(v, main, path, issues);
202 + if (issues.length > before && nullBranch && Array.isArray(main.enum) && !main.enum.includes(v)) return null;
203 + return out;
204 + }
205 + const types = typeList(schema);
206 + const nullable = types.includes("null");
207 + if (v === undefined) {
208 + issues.push({ path, message: "champ manquant", repaired: true });
209 + return defaultFor(schema);
210 + }
211 + if (v === null) {
212 + if (nullable) return null;
213 + issues.push({ path, message: "null non permis", repaired: true });
214 + return defaultFor(schema);
215 + }
216 + if (types.includes("object")) {
217 + if (typeof v !== "object" || Array.isArray(v)) { issues.push({ path, message: "objet attendu", repaired: true }); return walk({}, schema, path, issues); }
218 + const props = (schema.properties ?? {}) as Record<string, JS>;
219 + const out: Record<string, unknown> = {};
220 + const src = v as Record<string, unknown>;
221 + for (const [k, ps] of Object.entries(props)) out[k] = walk(src[k], ps, `${path}.${k}`, issues);
222 + for (const k of Object.keys(src)) if (!(k in props)) issues.push({ path: `${path}.${k}`, message: "clé inconnue supprimée", repaired: true });
223 + return out;
224 + }
225 + if (types.includes("array")) {
226 + if (!Array.isArray(v)) { issues.push({ path, message: "tableau attendu", repaired: true }); return []; }
227 + const it = schema.items as JS | undefined;
228 + return it ? v.map((x, i) => walk(x, it, `${path}[${i}]`, issues)).filter((x) => x !== undefined) : v;
229 + }
230 + if (types.includes("number") || types.includes("integer")) {
231 + let n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v.replace(",", "."))) ? Number(v.replace(",", ".")) : NaN;
232 + if (typeof v === "string" && Number.isFinite(n)) issues.push({ path, message: `nombre en texte « ${v} » converti`, repaired: true });
233 + if (!Number.isFinite(n)) {
234 + if (nullable) { issues.push({ path, message: `nombre attendu (${JSON.stringify(v)})`, repaired: true }); return null; }
235 + issues.push({ path, message: `nombre attendu (${JSON.stringify(v)}) → 0`, repaired: true });
236 + n = 0;
237 + }
238 + if (typeof schema.minimum === "number" && n < schema.minimum) { issues.push({ path, message: `borne min ${schema.minimum}`, repaired: true }); n = schema.minimum; }
239 + if (typeof schema.maximum === "number" && n > schema.maximum) { issues.push({ path, message: `borne max ${schema.maximum}`, repaired: true }); n = schema.maximum; }
240 + if (types.includes("integer")) n = Math.round(n);
241 + return n;
242 + }
243 + if (types.includes("boolean")) {
244 + if (typeof v === "boolean") return v;
245 + if (v === "true" || v === "false") { issues.push({ path, message: "booléen texte", repaired: true }); return v === "true"; }
246 + issues.push({ path, message: "booléen attendu", repaired: true });
247 + return nullable ? null : false;
248 + }
249 + if (types.includes("string")) {
250 + const s = typeof v === "string" ? v : String(v);
251 + if (typeof v !== "string") issues.push({ path, message: "chaîne attendue (converti)", repaired: true });
252 + if (Array.isArray(schema.enum) && !schema.enum.includes(s)) {
253 + issues.push({ path, message: `valeur hors énumération « ${s} »`, repaired: true });
254 + return nullable ? null : (schema.enum.find((e) => e === "unknown") ?? schema.enum[0]);
255 + }
256 + return s;
257 + }
258 + return v;
259 +}
260 +
261 +/**
262 + * Version du schéma acceptée par l'API en mode strict : `minimum`/`maximum`
263 + * ne sont pas supportés pour les nombres → retirés (le validateur local, lui,
264 + * les applique pour borner les confiances).
265 + */
266 +export function schemaForApi(schema: JS): JS {
267 + const strip = (x: unknown): unknown => {
268 + if (Array.isArray(x)) return x.map(strip);
269 + if (x && typeof x === "object") {
270 + const out: Record<string, unknown> = {};
271 + for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
272 + if (k === "minimum" || k === "maximum") continue;
273 + out[k] = strip(v);
274 + }
275 + return out;
276 + }
277 + return x;
278 + };
279 + return strip(schema) as JS;
280 +}
281 +
282 +/** Vrai si `data` respecte strictement le schéma (aucune réparation nécessaire). */
283 +export function isValid(data: unknown, schema: JS): boolean {
284 + return validateAndRepair(data, schema).issues.length === 0;
285 +}
286 +
287 +export const KNOWN_MATERIALS: readonly string[] = MATERIALS;
added src/lib/cost/ai/service.ts +259 −0
@@ -0,0 +1,259 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Service d'analyse IA des annonces (SERVEUR) : file de travaux en mémoire
4 + * avec états persistés (§168), idempotence par empreinte (§131-132, 169),
5 + * limites/budget (§183-184), pipeline complet :
6 + * photos → Claude Sonnet 5 → validation → fusion des sources → vecteur →
7 + * mapping assemblages → moteur de coût déterministe → estimation sauvegardée.
8 + * Recalcul aux coûts d'aujourd'hui, corrections manuelles et similaires
9 + * techniques ne rappellent JAMAIS le modèle.
10 + */
11 +import { randomUUID } from "crypto";
12 +import { loadAssemblies } from "../catalog";
13 +import { getEstimate, runEstimate } from "../estimate";
14 +import type { CostEstimate } from "../types";
15 +import { inputHash, loadListingContext, type ListingContext } from "./context";
16 +import { prepareListingImages, purgeImageCache, type PreparedImage } from "./images";
17 +import { applyOverrides, mergeFacts, type Conflict, type MergedFacts } from "./merge";
18 +import { combinedConfidence, mapToCostInput, type MappingResult } from "./mapping";
19 +import { ClaudeSonnet5Provider, type PropertyVisionModel } from "./provider";
20 +import { PROMPT_VERSION, SCHEMA_VERSION, type ImageManifestEntry, type ModelOutput, type PropertyTechnicalAnalysis } from "./schema";
21 +import * as store from "./store";
22 +import { EMBEDDING_MODEL, canonicalText, cosine, costProfileVector, technicalVector } from "./vector";
23 +import { computeGeometry } from "../geometry";
24 +
25 +export const MAX_IMAGES = Number(process.env.PROPERTY_ANALYSIS_MAX_IMAGES ?? 40);
26 +export const DAILY_MAX = Number(process.env.AI_ANALYSIS_DAILY_MAX ?? 30);
27 +export const DAILY_BUDGET_USD = Number(process.env.AI_DAILY_BUDGET_USD ?? 15);
28 +
29 +export class AnalysisError extends Error {
30 + constructor(message: string, public status = 400, public code = "bad_request") { super(message); }
31 +}
32 +
33 +/* --------------------------------------------------------------- file */
34 +
35 +const running = new Set<string>();
36 +let provider: PropertyVisionModel | null = null;
37 +export function setProvider(p: PropertyVisionModel | null): void { provider = p; }
38 +function getProvider(): PropertyVisionModel { return provider ?? (provider = new ClaudeSonnet5Provider()); }
39 +
40 +export interface StartResult { analysisId: string; status: store.AnalysisStatus; reused: boolean; version: number }
41 +
42 +/** Démarre (ou réutilise) une analyse. `force` crée une nouvelle version même si l'empreinte est connue. */
43 +export async function startAnalysis(uid: string, opts: { force?: boolean } = {}): Promise<StartResult> {
44 + const ctx = loadListingContext(uid);
45 + if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found");
46 + if (!ctx.imageUrls.length) throw new AnalysisError("Cette annonce n'a aucune photo : l'analyse multimodale n'est pas possible.", 422, "no_images");
47 + if (!process.env.ANTHROPIC_API_KEY) throw new AnalysisError("Modèle indisponible (clé API absente côté serveur).", 503, "model_unavailable");
48 + const active = store.findActive(uid);
49 + if (active) return { analysisId: active.id, status: active.status, reused: true, version: active.version };
50 + // empreinte provisoire (sans hashes d'images) pour la réutilisation rapide ; l'empreinte finale inclut les hashes
51 + const model = getProvider().model;
52 + if (!opts.force) {
53 + // réutilisation sans retéléchargement : si toutes les photos sont déjà connues, l'empreinte finale est calculable
54 + const known = store.knownImageHashes(uid);
55 + if (ctx.imageUrls.every((u) => known.has(u))) {
56 + const full = inputHash(ctx.baseHashInput, ctx.imageUrls.map((u) => known.get(u)!));
57 + const prev = store.findCompleted(uid, full, PROMPT_VERSION, model);
58 + if (prev) return { analysisId: prev.id, status: "completed", reused: true, version: prev.version };
59 + }
60 + }
61 + if (store.analysesStartedToday() >= DAILY_MAX) throw new AnalysisError(`Quota quotidien d'analyses atteint (${DAILY_MAX}).`, 429, "daily_quota");
62 + const u = store.usageToday();
63 + if (u.costUsd >= DAILY_BUDGET_USD) throw new AnalysisError("Budget quotidien d'analyse IA épuisé — réessayez demain.", 429, "budget");
64 + const id = randomUUID();
65 + const row = store.createAnalysis({ id, listingUid: uid, model, promptVersion: PROMPT_VERSION, schemaVersion: SCHEMA_VERSION, inputHash: `${quickHash(ctx)}:pending`, listingJson: JSON.stringify(ctx.analysisListing) });
66 + void runPipeline(id, ctx, opts.force ?? false);
67 + return { analysisId: id, status: row.status, reused: false, version: row.version };
68 +}
69 +
70 +function quickHash(ctx: ListingContext): string { return inputHash(ctx.baseHashInput, []).slice(0, 24); }
71 +
72 +async function runPipeline(id: string, ctx: ListingContext, force: boolean): Promise<void> {
73 + if (running.has(id)) return;
74 + running.add(id);
75 + try {
76 + store.setStage(id, "fetching_images");
77 + const imgs = await prepareListingImages(ctx.uid, ctx.imageUrls, MAX_IMAGES);
78 + if (!imgs.selected.length) throw new Error(`Aucune photo exploitable (${imgs.failed.length} échec(s) : ${imgs.failed.slice(0, 3).map((f) => f.error).join(" ; ")})`);
79 + // empreinte finale : toutes les photos téléchargées (y compris doublons/miniatures écartés)
80 + const fetchedHashes = new Map(imgs.all.map((i) => [i.sourceUrl, i.hash]));
81 + const known = store.knownImageHashes(ctx.uid);
82 + const hash = inputHash(ctx.baseHashInput, ctx.imageUrls.map((u) => fetchedHashes.get(u) ?? known.get(u) ?? `missing:${u}`));
83 + store.db().prepare("UPDATE listing_ai_analyses SET input_hash=? WHERE id=?").run(hash, id);
84 + store.saveImages(ctx.uid, id, imgs.all.map((i) => ({ ...i, selected: !!i.id })));
85 + if (!force) {
86 + const prev = store.findCompleted(ctx.uid, hash, PROMPT_VERSION, getProvider().model);
87 + if (prev && prev.id !== id) {
88 + // même entrée déjà analysée : on réutilise la sortie du modèle sans rappeler Claude
89 + finishFromPrevious(id, prev, ctx, imgs.selected);
90 + return;
91 + }
92 + }
93 + store.setStage(id, "analyzing");
94 + const assemblies = loadAssemblies();
95 + const codes = [...assemblies.keys()];
96 + const catalogue = [...assemblies.values()].map((a) => `${a.code} | ${a.nameEn} (${a.nameFr}) | unit: ${a.unit}`).join("\n");
97 + const res = await getProvider().analyze({ listingContext: ctx.text, images: imgs.selected.map((i) => ({ id: i.id, mediaType: i.mediaType, data: i.data, roomHint: i.roomHint, position: i.position })), assemblyCodes: codes, assemblyCatalogueText: catalogue });
98 + store.recordUsage({ analysisId: id, purpose: "property_analysis", model: res.model, images: imgs.selected.length, inputTokens: res.usage.inputTokens, outputTokens: res.usage.outputTokens, cacheRead: res.usage.cacheReadTokens, costUsd: res.usage.estimatedCostUsd, latencyMs: res.usage.latencyMs });
99 + store.setStage(id, "validating");
100 + const manifest: ImageManifestEntry[] = imgs.selected.map((i) => ({ id: i.id, position: i.position, room_hint: i.roomHint, source_url: i.sourceUrl, hash: i.hash, width: i.width, height: i.height }));
101 + store.saveOutput(id, { outputJson: JSON.stringify(res.output), imageCount: imgs.selected.length, usageJson: JSON.stringify({ ...res.usage, model: res.model, duplicatesRemoved: imgs.duplicatesRemoved, thumbnailsRemoved: imgs.thumbnailsRemoved, failed: imgs.failed }), issuesJson: JSON.stringify(res.issues), imagesJson: JSON.stringify(manifest) });
102 + completeFromOutput(id, ctx, res.output, manifest);
103 + } catch (e) {
104 + store.setFailed(id, (e as Error).message);
105 + } finally {
106 + running.delete(id);
107 + try { purgeImageCache(); } catch { /* ignore */ }
108 + }
109 +}
110 +
111 +function finishFromPrevious(id: string, prev: store.AnalysisRow, ctx: ListingContext, selected: PreparedImage[]): void {
112 + const output = JSON.parse(prev.output_json ?? "null") as ModelOutput | null;
113 + if (!output) { store.setFailed(id, "analyse précédente sans sortie"); return; }
114 + const manifest = (JSON.parse(prev.images_json ?? "[]") as ImageManifestEntry[]).length ? (JSON.parse(prev.images_json!) as ImageManifestEntry[]) : selected.map((i) => ({ id: i.id, position: i.position, room_hint: i.roomHint, source_url: i.sourceUrl, hash: i.hash, width: i.width, height: i.height }));
115 + store.saveOutput(id, { outputJson: JSON.stringify(output), imageCount: manifest.length, usageJson: JSON.stringify({ reusedFrom: prev.id, estimatedCostUsd: 0 }), issuesJson: "[]", imagesJson: JSON.stringify(manifest) });
116 + completeFromOutput(id, ctx, output, manifest);
117 +}
118 +
119 +/** Étapes déterministes après la sortie du modèle : fusion → vecteur → mapping → coût. */
120 +function completeFromOutput(id: string, ctx: ListingContext, output: ModelOutput, manifest: ImageManifestEntry[]): void {
121 + store.setStage(id, "embedding");
122 + const merged = mergeFacts(output, ctx.facts, ctx.mamh);
123 + store.saveConflicts(id, merged.conflicts);
124 + const vec = technicalVector(merged);
125 + const text = canonicalText(merged);
126 + store.saveMergedAndVector(id, JSON.stringify(merged), JSON.stringify(vec));
127 + store.saveEmbedding(ctx.uid, id, EMBEDDING_MODEL, vec, text);
128 + store.setStage(id, "mapping_assemblies");
129 + priceAnalysis(id, ctx, output, merged, manifest, true);
130 +}
131 +
132 +function priceAnalysis(id: string, ctx: ListingContext, output: ModelOutput, mergedBase: MergedFacts, manifest: ImageManifestEntry[], completed: boolean): CostEstimate {
133 + const overrides = store.listOverrides(id).map((o) => ({ fieldPath: o.fieldPath, value: o.value }));
134 + const merged = overrides.length ? applyOverrides(mergedBase, overrides) : mergedBase;
135 + store.saveMergedEffective(id, JSON.stringify(merged));
136 + const assemblies = loadAssemblies();
137 + const l = ctx.detail.listing;
138 + const mapping = mapToCostInput(merged, output, { listingUid: ctx.uid, address: l.address, municipality: l.city, lat: l.lat, lng: l.lng, unitId: ctx.mamh?.unitId ?? null, landValue: ctx.mamh?.landValue ?? null, roll: ctx.roll, assemblyCodes: new Set(assemblies.keys()) });
139 + store.setStage(id, "pricing");
140 + const e = ctx.detail.eval;
141 + const est = runEstimate(mapping.input, { otherReadings: { hedonic: e?.model_est ?? null, comparables: e?.comps_est ?? null, hybrid: e?.est ?? null, askingPrice: l.price, rollValue: ctx.mamh?.totalValue ?? e?.valeur_role ?? null, rollBuilding: ctx.mamh?.buildingValue ?? null, rollLand: ctx.mamh?.landValue ?? null } });
142 + const mappedShare = output.assemblies.length ? output.assemblies.filter((a) => assemblies.has(a.assembly_code)).length / output.assemblies.length : 0.8;
143 + const combined = combinedConfidence(output, merged, est, mappedShare);
144 + const compact = { building_type: mapping.input.building.type, location: est.location.code, gross_floor_area_sqft: mapping.input.building.grossFloorAreaSqft, quality: mapping.input.building.quality, assemblies: est.lines.map((li) => ({ code: li.assemblyCode, qty: li.quantity, unit: li.unit, source: li.quantitySource })), cost_profile: costProfileVector(est.categories) };
145 + store.saveMappingAndEstimate(id, { mappingJson: JSON.stringify({ aiQuantities: mapping.aiQuantities, notes: mapping.notes, overrides, attributeSources: mapping.input.attributeSources }), compactJson: JSON.stringify(compact), estimateId: est.id, snapshotDate: est.priceDate, combinedJson: JSON.stringify(combined), confidence: combined.overall, completed });
146 + void manifest;
147 + return est;
148 +}
149 +
150 +/* ------------------------------------------------------------ lecture */
151 +
152 +export interface AnalysisView {
153 + id: string; listingUid: string; version: number; status: store.AnalysisStatus; stage: string | null; error: string | null; model: string; promptVersion: string; schemaVersion: string; createdAt: string; completedAt: string | null; imageCount: number;
154 + analysis: PropertyTechnicalAnalysis | null; merged: MergedFacts | null; mapping: { aiQuantities: MappingResult["aiQuantities"]; notes: string[]; overrides: { fieldPath: string; value: unknown }[]; attributeSources: Record<string, string> } | null;
155 + estimate: CostEstimate | null; costSnapshotDate: string | null; combined: ReturnType<typeof combinedConfidence> | null; conflicts: (Conflict & { id: number; status: string })[]; overrides: ReturnType<typeof store.listOverrides>;
156 + usage: Record<string, unknown> | null; versions: ReturnType<typeof store.listVersions>; compact: unknown;
157 +}
158 +
159 +export function viewAnalysis(row: store.AnalysisRow): AnalysisView {
160 + const output = row.output_json ? (JSON.parse(row.output_json) as ModelOutput) : null;
161 + const merged = row.merged_json ? (JSON.parse(row.merged_json) as MergedFacts) : null;
162 + const listing = row.listing_json ? (JSON.parse(row.listing_json) as PropertyTechnicalAnalysis["listing"]) : null;
163 + const images = row.images_json ? (JSON.parse(row.images_json) as ImageManifestEntry[]) : [];
164 + const usage = row.usage_json ? (JSON.parse(row.usage_json) as Record<string, unknown>) : null;
165 + const estimate = row.estimate_id ? getEstimate(row.estimate_id) : null;
166 + let analysis: PropertyTechnicalAnalysis | null = null;
167 + if (output && listing) {
168 + const g = estimate ? computeGeometry(estimate.input.building) : null;
169 + analysis = {
170 + ...output,
171 + metadata: { analysis_id: row.id, listing_id: row.listing_uid, listing_source: row.listing_uid.split(":")[0], model: row.model, prompt_version: row.prompt_version, schema_version: row.schema_version, analysis_timestamp: row.completed_at ?? row.created_at, image_count: row.image_count, input_hash: row.input_hash, input_token_estimate: (usage?.inputTokens as number | undefined) ?? null, output_tokens: (usage?.outputTokens as number | undefined) ?? null, latency_ms: (usage?.latencyMs as number | undefined) ?? null },
172 + listing, images,
173 + derived_geometry: g ? { footprint_sqft: g.footprintSqft, perimeter_ft: g.perimeterFt, exposed_perimeter_ft: g.exposedPerimeterFt, gross_wall_sqft: g.grossWallSqft, net_wall_sqft: g.netWallSqft, roof_sqft: g.roofSqft, window_count: g.windowCount, basement_floor_sqft: g.basementFloorSqft, basement_finished_sqft: g.basementFinishedSqft, garage_sqft: g.garageSqft, formulas: Object.fromEntries(estimate!.quantities.map((q) => [q.assemblyCode, q.formula])) } : { footprint_sqft: 0, perimeter_ft: 0, exposed_perimeter_ft: 0, gross_wall_sqft: 0, net_wall_sqft: 0, roof_sqft: 0, window_count: 0, basement_floor_sqft: 0, basement_finished_sqft: 0, garage_sqft: 0, formulas: {} },
174 + };
175 + }
176 + return {
177 + id: row.id, listingUid: row.listing_uid, version: row.version, status: row.status, stage: row.stage, error: row.error, model: row.model, promptVersion: row.prompt_version, schemaVersion: row.schema_version, createdAt: row.created_at, completedAt: row.completed_at, imageCount: row.image_count,
178 + analysis, merged, mapping: row.mapping_json ? (JSON.parse(row.mapping_json) as AnalysisView["mapping"]) : null, estimate, costSnapshotDate: row.cost_snapshot_date, combined: row.combined_json ? (JSON.parse(row.combined_json) as AnalysisView["combined"]) : null,
179 + conflicts: store.listConflicts(row.id), overrides: store.listOverrides(row.id), usage, versions: store.listVersions(row.listing_uid), compact: row.compact_json ? JSON.parse(row.compact_json) : null,
180 + };
181 +}
182 +
183 +export function getLatest(uid: string): AnalysisView | null {
184 + const row = store.latestForListing(uid);
185 + return row ? viewAnalysis(row) : null;
186 +}
187 +export function getById(id: string): AnalysisView | null {
188 + const row = store.getAnalysis(id);
189 + return row ? viewAnalysis(row) : null;
190 +}
191 +
192 +/** Résumé pour la fiche et les cartes (analyse complète la plus récente). */
193 +export function summaryForListing(uid: string): { analysisId: string; version: number; confidence: number | null; rcn: number | null; costValue: number | null; structure: string | null; foundation: string | null; roof: string | null; quality: string | null; condition: string | null; completedAt: string | null } | null {
194 + const row = store.latestCompletedForListing(uid);
195 + if (!row) return null;
196 + const merged = row.merged_json ? (JSON.parse(row.merged_json) as MergedFacts) : null;
197 + const est = row.estimate_id ? getEstimate(row.estimate_id) : null;
198 + const conds = merged ? Object.values(merged.conditions) : [];
199 + const cond: string | null = conds.length ? (conds.filter((c): c is NonNullable<typeof c> => !!c).sort()[Math.floor(conds.length / 2)] ?? null) : null;
200 + return { analysisId: row.id, version: row.version, confidence: row.confidence, rcn: est?.replacementCostNew ?? null, costValue: est?.costApproachValue ?? null, structure: merged?.structure.value ?? null, foundation: merged?.foundation.value ?? null, roof: merged?.roof.value ?? null, quality: merged?.quality.value ?? null, condition: cond, completedAt: row.completed_at };
201 +}
202 +
203 +/* ------------------------------------------------- recalcul / overrides */
204 +
205 +/** Recalcule le coût avec les prix d'aujourd'hui (sans rappeler le modèle). */
206 +export function recalc(analysisId: string): AnalysisView {
207 + const row = store.getAnalysis(analysisId);
208 + if (!row) throw new AnalysisError("Analyse introuvable.", 404, "not_found");
209 + if (row.status !== "completed" || !row.output_json || !row.merged_json) throw new AnalysisError("L'analyse n'est pas terminée.", 409, "not_completed");
210 + const ctx = loadListingContext(row.listing_uid);
211 + if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found");
212 + const output = JSON.parse(row.output_json) as ModelOutput;
213 + const mergedBase = mergeFacts(output, ctx.facts, ctx.mamh); // refusionne (le rôle peut avoir changé) ; overrides réappliqués dans priceAnalysis
214 + store.saveMergedBase(analysisId, JSON.stringify(mergedBase));
215 + priceAnalysis(analysisId, ctx, output, mergedBase, JSON.parse(row.images_json ?? "[]") as ImageManifestEntry[], true);
216 + return viewAnalysis(store.getAnalysis(analysisId)!);
217 +}
218 +
219 +const OVERRIDABLE = new Set(["buildingType", "yearBuilt", "stories", "grossFloorAreaSqft", "footprintSqft", "foundation", "structure", "siding", "roof", "roofGeometry", "roofPitch", "windows", "windowCount", "heating", "hasAirConditioning", "hasAirExchanger", "basement", "basementFinishedPct", "garage", "kitchens", "kitchenQuality", "bathrooms", "powderRooms", "bathroomQuality", "bedrooms", "quality", "flooring", "deckSqft", "drivewaySqft", "driveway", "fenceLinFt", "pool", "units", "kitchenLinearFt", "countertopSqft", "backsplashSqft", "bathroomTileSqft", "interiorDoorCount", "conditions", "effectiveAge"]);
220 +
221 +/** Correction manuelle d'une inférence (§140-142) : conservée, tracée, puis recalcul déterministe. */
222 +export function override(analysisId: string, fieldPath: string, value: unknown, userId: string | null): AnalysisView {
223 + const row = store.getAnalysis(analysisId);
224 + if (!row || !row.merged_json || !row.output_json) throw new AnalysisError("Analyse introuvable ou incomplète.", 404, "not_found");
225 + const root = fieldPath.split(".")[0];
226 + if (!OVERRIDABLE.has(root)) throw new AnalysisError(`Champ non modifiable : ${fieldPath}`, 400, "bad_field");
227 + // base = faits avant corrections (les corrections persistées sont réappliquées dans priceAnalysis)
228 + const merged = JSON.parse(row.merged_base_json ?? row.merged_json) as MergedFacts;
229 + const effective = JSON.parse(row.merged_json) as MergedFacts;
230 + const cur = (merged as unknown as Record<string, { value?: unknown } | undefined>)[root];
231 + const curEff = (effective as unknown as Record<string, { value?: unknown } | undefined>)[root];
232 + void cur;
233 + store.addOverride(analysisId, fieldPath, root === "conditions" ? effective.conditions[fieldPath.split(".")[1]] ?? null : root === "effectiveAge" ? effective.effectiveAge?.effective ?? null : curEff?.value ?? null, value, userId);
234 + const ctx = loadListingContext(row.listing_uid);
235 + if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found");
236 + priceAnalysis(analysisId, ctx, JSON.parse(row.output_json) as ModelOutput, merged, JSON.parse(row.images_json ?? "[]") as ImageManifestEntry[], true);
237 + return viewAnalysis(store.getAnalysis(analysisId)!);
238 +}
239 +
240 +/* ------------------------------------------------------------ similaires */
241 +
242 +export function technicalSimilar(analysisId: string, limit = 6): { listingUid: string; analysisId: string; similarity: number; text: string | null }[] {
243 + const row = store.getAnalysis(analysisId);
244 + if (!row?.vector_json || !row.merged_json) return [];
245 + const v = JSON.parse(row.vector_json) as number[];
246 + const m = JSON.parse(row.merged_json) as MergedFacts;
247 + const all = store.allEmbeddings(EMBEDDING_MODEL).filter((e) => e.listingUid !== row.listing_uid);
248 + const out: { listingUid: string; analysisId: string; similarity: number; text: string | null }[] = [];
249 + for (const e of all) {
250 + const om = store.getAnalysis(e.analysisId)?.merged_json;
251 + if (om) {
252 + const mm = JSON.parse(om) as MergedFacts;
253 + if (mm.buildingType.value !== m.buildingType.value) continue;
254 + if (Math.abs(mm.grossFloorAreaSqft.value - m.grossFloorAreaSqft.value) / m.grossFloorAreaSqft.value > 0.15) continue;
255 + }
256 + out.push({ listingUid: e.listingUid, analysisId: e.analysisId, similarity: Math.round(cosine(v, e.vector) * 1000) / 1000, text: e.text });
257 + }
258 + return out.sort((a, b) => b.similarity - a.similarity).slice(0, limit);
259 +}
added src/lib/cost/ai/store.ts +151 −0
@@ -0,0 +1,151 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Persistance de l'analyse IA des annonces (SERVEUR) : analyses versionnées
4 + * (jamais écrasées), images, overrides, conflits, vecteurs, usage API.
5 + * Colonnes additionnelles ajoutées par `ensureAiSchema` (ALTER tolérant) —
6 + * db.ts n'est pas modifié.
7 + */
8 +import type Database from "better-sqlite3";
9 +import { getCostDb } from "../db";
10 +import type { Conflict } from "./merge";
11 +
12 +let ensured = false;
13 +export function ensureAiSchema(d: Database.Database = getCostDb()): Database.Database {
14 + if (ensured) return d;
15 + const cols: [string, string][] = [
16 + ["listing_ai_analyses", "merged_json TEXT"], ["listing_ai_analyses", "mapping_json TEXT"], ["listing_ai_analyses", "usage_json TEXT"], ["listing_ai_analyses", "issues_json TEXT"],
17 + ["listing_ai_analyses", "images_json TEXT"], ["listing_ai_analyses", "listing_json TEXT"], ["listing_ai_analyses", "combined_json TEXT"], ["listing_ai_analyses", "started_at TEXT"], ["listing_ai_analyses", "merged_base_json TEXT"],
18 + ["listing_images", "analysis_id TEXT"],
19 + ];
20 + for (const [t, c] of cols) { try { d.exec(`ALTER TABLE ${t} ADD COLUMN ${c}`); } catch { /* déjà présent */ } }
21 + d.exec("CREATE INDEX IF NOT EXISTS idx_ai_usage_created ON ai_usage(created_at)");
22 + ensured = true;
23 + return d;
24 +}
25 +
26 +export type AnalysisStatus = "queued" | "fetching_images" | "analyzing" | "validating" | "embedding" | "mapping_assemblies" | "pricing" | "completed" | "failed";
27 +export const STAGES: AnalysisStatus[] = ["queued", "fetching_images", "analyzing", "validating", "embedding", "mapping_assemblies", "pricing", "completed"];
28 +
29 +export interface AnalysisRow {
30 + id: string; listing_uid: string; version: number; model: string; prompt_version: string; schema_version: string; input_hash: string; status: AnalysisStatus; stage: string | null; error: string | null;
31 + image_count: number; output_json: string | null; compact_json: string | null; vector_json: string | null; confidence: number | null; estimate_id: string | null; cost_snapshot_date: string | null;
32 + created_at: string; completed_at: string | null; merged_json: string | null; merged_base_json: string | null; mapping_json: string | null; usage_json: string | null; issues_json: string | null; images_json: string | null; listing_json: string | null; combined_json: string | null; started_at: string | null;
33 +}
34 +
35 +export function db(): Database.Database { return ensureAiSchema(getCostDb()); }
36 +
37 +export function findCompleted(listingUid: string, inputHash: string, promptVersion: string, model: string): AnalysisRow | null {
38 + return (db().prepare("SELECT * FROM listing_ai_analyses WHERE listing_uid=? AND input_hash=? AND prompt_version=? AND model=? AND status='completed' ORDER BY version DESC LIMIT 1").get(listingUid, inputHash, promptVersion, model) as AnalysisRow | undefined) ?? null;
39 +}
40 +export function findActive(listingUid: string): AnalysisRow | null {
41 + return (db().prepare("SELECT * FROM listing_ai_analyses WHERE listing_uid=? AND status NOT IN ('completed','failed') ORDER BY version DESC LIMIT 1").get(listingUid) as AnalysisRow | undefined) ?? null;
42 +}
43 +export function latestForListing(listingUid: string): AnalysisRow | null {
44 + return (db().prepare("SELECT * FROM listing_ai_analyses WHERE listing_uid=? ORDER BY version DESC LIMIT 1").get(listingUid) as AnalysisRow | undefined) ?? null;
45 +}
46 +export function latestCompletedForListing(listingUid: string): AnalysisRow | null {
47 + return (db().prepare("SELECT * FROM listing_ai_analyses WHERE listing_uid=? AND status='completed' ORDER BY version DESC LIMIT 1").get(listingUid) as AnalysisRow | undefined) ?? null;
48 +}
49 +export function getAnalysis(id: string): AnalysisRow | null {
50 + return (db().prepare("SELECT * FROM listing_ai_analyses WHERE id=?").get(id) as AnalysisRow | undefined) ?? null;
51 +}
52 +export function listVersions(listingUid: string): { id: string; version: number; status: string; created_at: string; confidence: number | null; model: string; prompt_version: string }[] {
53 + return db().prepare("SELECT id, version, status, created_at, confidence, model, prompt_version FROM listing_ai_analyses WHERE listing_uid=? ORDER BY version DESC").all(listingUid) as { id: string; version: number; status: string; created_at: string; confidence: number | null; model: string; prompt_version: string }[];
54 +}
55 +/** Analyses complètes par annonce (badges de cartes) — une requête pour une liste d'uids. */
56 +export function completedSummaries(uids: string[]): Map<string, { analysisId: string; rcn: number | null; confidence: number | null }> {
57 + const out = new Map<string, { analysisId: string; rcn: number | null; confidence: number | null }>();
58 + if (!uids.length) return out;
59 + const d = db();
60 + const rows = d.prepare(`SELECT a.listing_uid, a.id, a.confidence, e.replacement_cost_new rcn FROM listing_ai_analyses a LEFT JOIN cost_estimates e ON e.id=a.estimate_id WHERE a.status='completed' AND a.listing_uid IN (${uids.map(() => "?").join(",")}) ORDER BY a.version ASC`).all(...uids) as { listing_uid: string; id: string; confidence: number | null; rcn: number | null }[];
61 + for (const r of rows) out.set(r.listing_uid, { analysisId: r.id, rcn: r.rcn, confidence: r.confidence });
62 + return out;
63 +}
64 +
65 +export function createAnalysis(a: { id: string; listingUid: string; model: string; promptVersion: string; schemaVersion: string; inputHash: string; listingJson: string }): AnalysisRow {
66 + const d = db();
67 + const v = (d.prepare("SELECT COALESCE(MAX(version),0)+1 v FROM listing_ai_analyses WHERE listing_uid=?").get(a.listingUid) as { v: number }).v;
68 + d.prepare("INSERT INTO listing_ai_analyses(id,listing_uid,version,model,prompt_version,schema_version,input_hash,status,stage,listing_json,started_at) VALUES(?,?,?,?,?,?,?,'queued','queued',?,datetime('now'))").run(a.id, a.listingUid, v, a.model, a.promptVersion, a.schemaVersion, a.inputHash, a.listingJson);
69 + return getAnalysis(a.id)!;
70 +}
71 +export function setStage(id: string, status: AnalysisStatus): void {
72 + db().prepare("UPDATE listing_ai_analyses SET status=?, stage=? WHERE id=?").run(status, status, id);
73 +}
74 +export function setFailed(id: string, error: string): void {
75 + db().prepare("UPDATE listing_ai_analyses SET status='failed', stage='failed', error=?, completed_at=datetime('now') WHERE id=?").run(error.slice(0, 2000), id);
76 +}
77 +export function saveOutput(id: string, o: { outputJson: string; imageCount: number; usageJson: string; issuesJson: string; imagesJson: string }): void {
78 + db().prepare("UPDATE listing_ai_analyses SET output_json=?, image_count=?, usage_json=?, issues_json=?, images_json=? WHERE id=?").run(o.outputJson, o.imageCount, o.usageJson, o.issuesJson, o.imagesJson, id);
79 +}
80 +/** Faits fusionnés de BASE (avant corrections manuelles) + vecteur. */
81 +export function saveMergedAndVector(id: string, mergedJson: string, vectorJson: string): void {
82 + db().prepare("UPDATE listing_ai_analyses SET merged_base_json=?, merged_json=?, vector_json=? WHERE id=?").run(mergedJson, mergedJson, vectorJson, id);
83 +}
84 +export function saveMergedBase(id: string, mergedJson: string): void {
85 + db().prepare("UPDATE listing_ai_analyses SET merged_base_json=? WHERE id=?").run(mergedJson, id);
86 +}
87 +/** Faits EFFECTIFS (corrections appliquées) tels que présentés et chiffrés. */
88 +export function saveMergedEffective(id: string, mergedJson: string): void {
89 + db().prepare("UPDATE listing_ai_analyses SET merged_json=? WHERE id=?").run(mergedJson, id);
90 +}
91 +export function saveMappingAndEstimate(id: string, o: { mappingJson: string; compactJson: string; estimateId: string; snapshotDate: string; combinedJson: string; confidence: number; completed: boolean }): void {
92 + db().prepare(`UPDATE listing_ai_analyses SET mapping_json=?, compact_json=?, estimate_id=?, cost_snapshot_date=?, combined_json=?, confidence=?${o.completed ? ", status='completed', stage='completed', completed_at=datetime('now')" : ""} WHERE id=?`)
93 + .run(o.mappingJson, o.compactJson, o.estimateId, o.snapshotDate, o.combinedJson, o.confidence, id);
94 +}
95 +
96 +/** Hashes déjà connus des photos d'une annonce (URL → hash) — permet de recalculer l'empreinte sans retélécharger. */
97 +export function knownImageHashes(listingUid: string): Map<string, string> {
98 + return new Map((db().prepare("SELECT source_url, hash FROM listing_images WHERE listing_uid=? AND hash IS NOT NULL").all(listingUid) as { source_url: string; hash: string }[]).map((r) => [r.source_url, r.hash]));
99 +}
100 +
101 +export function saveImages(listingUid: string, analysisId: string, imgs: { id: string; sourceUrl: string; position: number; width: number | null; height: number | null; bytes: number; hash: string; mediaType: string; roomHint: string; qualityScore: number; selected: boolean }[]): void {
102 + const d = db();
103 + const up = d.prepare(`INSERT INTO listing_images(listing_uid,photo_id,source_url,position,width,height,bytes,hash,media_type,room_guess,quality_score,selected_for_ai,fetched_at,analysis_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),?)
104 + ON CONFLICT(listing_uid, source_url) DO UPDATE SET photo_id=excluded.photo_id, width=excluded.width, height=excluded.height, bytes=excluded.bytes, hash=excluded.hash, media_type=excluded.media_type, room_guess=excluded.room_guess, quality_score=excluded.quality_score, selected_for_ai=excluded.selected_for_ai, fetched_at=excluded.fetched_at, analysis_id=excluded.analysis_id`);
105 + const tx = d.transaction(() => { for (const i of imgs) up.run(listingUid, i.id, i.sourceUrl, i.position, i.width, i.height, i.bytes, i.hash, i.mediaType, i.roomHint, i.qualityScore, i.selected ? 1 : 0, analysisId); });
106 + tx();
107 +}
108 +
109 +export function saveConflicts(analysisId: string, conflicts: Conflict[]): void {
110 + const d = db();
111 + d.prepare("DELETE FROM analysis_conflicts WHERE analysis_id=?").run(analysisId);
112 + const ins = d.prepare("INSERT INTO analysis_conflicts(analysis_id,field,source_a,value_a,source_b,value_b,severity,status) VALUES(?,?,?,?,?,?,?,'open')");
113 + const tx = d.transaction(() => { for (const c of conflicts) ins.run(analysisId, c.field, c.sourceA, c.valueA, c.sourceB, c.valueB, c.severity); });
114 + tx();
115 +}
116 +export function listConflicts(analysisId: string): (Conflict & { id: number; status: string })[] {
117 + return (db().prepare("SELECT id, field, source_a, value_a, source_b, value_b, severity, status FROM analysis_conflicts WHERE analysis_id=?").all(analysisId) as { id: number; field: string; source_a: string; value_a: string; source_b: string; value_b: string; severity: string; status: string }[])
118 + .map((r) => ({ id: r.id, field: r.field, sourceA: r.source_a as Conflict["sourceA"], valueA: r.value_a, sourceB: r.source_b as Conflict["sourceB"], valueB: r.value_b, severity: r.severity as Conflict["severity"], status: r.status }));
119 +}
120 +
121 +export function saveEmbedding(listingUid: string, analysisId: string, model: string, vector: number[], text: string): void {
122 + db().prepare("INSERT INTO property_embeddings(listing_uid,analysis_id,embedding_model,embedding_dimension,embedding_json,canonical_text) VALUES(?,?,?,?,?,?) ON CONFLICT(analysis_id, embedding_model) DO UPDATE SET embedding_json=excluded.embedding_json, canonical_text=excluded.canonical_text, embedding_dimension=excluded.embedding_dimension")
123 + .run(listingUid, analysisId, model, vector.length, JSON.stringify(vector), text);
124 +}
125 +export function allEmbeddings(model: string): { listingUid: string; analysisId: string; vector: number[]; text: string | null }[] {
126 + return (db().prepare(`SELECT e.listing_uid, e.analysis_id, e.embedding_json, e.canonical_text FROM property_embeddings e JOIN listing_ai_analyses a ON a.id=e.analysis_id WHERE e.embedding_model=? AND a.status='completed'
127 + AND a.version = (SELECT MAX(version) FROM listing_ai_analyses b WHERE b.listing_uid=a.listing_uid AND b.status='completed')`).all(model) as { listing_uid: string; analysis_id: string; embedding_json: string; canonical_text: string | null }[])
128 + .map((r) => ({ listingUid: r.listing_uid, analysisId: r.analysis_id, vector: JSON.parse(r.embedding_json) as number[], text: r.canonical_text }));
129 +}
130 +
131 +export function addOverride(analysisId: string, fieldPath: string, original: unknown, value: unknown, userId: string | null): void {
132 + db().prepare("INSERT INTO listing_ai_overrides(analysis_id,field_path,original_value,new_value,user_id) VALUES(?,?,?,?,?)").run(analysisId, fieldPath, JSON.stringify(original ?? null), JSON.stringify(value ?? null), userId);
133 +}
134 +export function listOverrides(analysisId: string): { fieldPath: string; original: unknown; value: unknown; createdAt: string }[] {
135 + const rows = db().prepare("SELECT field_path, original_value, new_value, created_at FROM listing_ai_overrides WHERE analysis_id=? ORDER BY id").all(analysisId) as { field_path: string; original_value: string; new_value: string; created_at: string }[];
136 + // la dernière correction d'un champ gagne
137 + const map = new Map<string, { fieldPath: string; original: unknown; value: unknown; createdAt: string }>();
138 + for (const r of rows) map.set(r.field_path, { fieldPath: r.field_path, original: map.get(r.field_path)?.original ?? JSON.parse(r.original_value), value: JSON.parse(r.new_value), createdAt: r.created_at });
139 + return [...map.values()];
140 +}
141 +
142 +export function recordUsage(u: { analysisId: string | null; purpose: string; model: string; images: number; inputTokens: number; outputTokens: number; cacheRead: number; costUsd: number; latencyMs: number }): void {
143 + db().prepare("INSERT INTO ai_usage(analysis_id,purpose,model,input_images,input_tokens,output_tokens,cache_read_tokens,estimated_cost_usd,latency_ms) VALUES(?,?,?,?,?,?,?,?,?)").run(u.analysisId, u.purpose, u.model, u.images, u.inputTokens, u.outputTokens, u.cacheRead, u.costUsd, u.latencyMs);
144 +}
145 +export function usageToday(): { count: number; costUsd: number } {
146 + const r = db().prepare("SELECT COUNT(*) n, COALESCE(SUM(estimated_cost_usd),0) c FROM ai_usage WHERE created_at >= date('now')").get() as { n: number; c: number };
147 + return { count: r.n, costUsd: r.c };
148 +}
149 +export function analysesStartedToday(): number {
150 + return (db().prepare("SELECT COUNT(*) n FROM listing_ai_analyses WHERE created_at >= date('now')").get() as { n: number }).n;
151 +}
added src/lib/cost/ai/vector.ts +103 −0
@@ -0,0 +1,103 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Property Vector (§119-123, 154-155) : représentation canonique textuelle
4 + * déterministe + vecteur numérique déterministe (one-hot des catégories +
5 + * numériques normalisés). Modèle « vrai-prix-technical-v1 » : aucun appel
6 + * externe, reproductible ; l'interface `Embedder` permet de brancher plus tard
7 + * un modèle d'embedding réel. Module PUR.
8 + */
9 +import type { MergedFacts } from "./merge";
10 +
11 +export const EMBEDDING_MODEL = "vrai-prix-technical-v1";
12 +
13 +export interface Embedder { model: string; embed(canonicalText: string, facts: MergedFacts): Promise<number[]> | number[] }
14 +
15 +const CATS = {
16 + buildingType: ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile", "other"],
17 + quality: ["economy", "standard", "superior", "prestige"],
18 + basement: ["none", "crawl", "unfinished", "partial", "finished", "walkout"],
19 + garage: ["none", "attached", "detached", "integrated", "carport"],
20 + roof: ["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile"],
21 + roofGeometry: ["gable", "hip", "flat", "mansard", "complex"],
22 + windows: ["pvc", "hybrid", "aluminum", "wood"],
23 + heating: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "furnace_oil", "hydronic", "geothermal", "wood"],
24 + foundation: ["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone"],
25 + structure: ["wood_frame", "steel", "concrete", "log", "masonry"],
26 + siding: ["vinyl", "brick", "fiber_cement", "wood", "stone", "stucco", "aluminum", "steel"],
27 + flooring: ["hardwood", "engineered", "vinyl_plank", "laminate", "ceramic", "carpet"],
28 + pool: ["none", "above_ground", "inground"],
29 + condition: ["poor", "below_average", "average", "good", "very_good", "excellent", "renovated", "new"],
30 +} as const;
31 +const CONDITION_GROUPS = ["structure", "roof", "exterior", "windows", "interior", "kitchen", "bathroom", "mechanical", "electrical", "site"];
32 +
33 +/** Dimension fixe du vecteur (documentée) : 8+4×3+6+5+5+5+4+8+5+5+8+6+3 = 80 catégoriels + 12 numériques + 10 conditions = 102. */
34 +export const EMBEDDING_DIMENSION = 8 + 4 * 3 + 6 + 5 + 5 + 5 + 4 + 8 + 5 + 5 + 8 + 6 + 3 + 12 + 10;
35 +
36 +function oneHot(list: readonly string[], v: string | null | undefined): number[] {
37 + return list.map((x) => (x === v ? 1 : 0));
38 +}
39 +function shares(list: readonly string[], m: Record<string, number | undefined> | null): number[] {
40 + return list.map((x) => Math.max(0, Math.min(1, m?.[x] ?? 0)));
41 +}
42 +
43 +export function canonicalText(m: MergedFacts): string {
44 + const t = m.buildingType.value.replace(/_/g, " ");
45 + const parts = [
46 + `${t.charAt(0).toUpperCase()}${t.slice(1)}.`,
47 + m.yearBuilt.value ? `${m.yearBuilt.value}.` : "Year unknown.",
48 + `${Math.round(m.grossFloorAreaSqft.value)} sqft.`,
49 + `${Math.round(m.stories.value)} ${m.stories.value > 1 ? "stories" : "story"}.`,
50 + `${m.structure.value.replace(/_/g, " ")}.`,
51 + `${m.foundation.value.replace(/_/g, " ")} foundation.`,
52 + `${Object.entries(m.siding.value).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)).map(([k, v]) => `${k.replace(/_/g, " ")} ${Math.round((v ?? 0) * 100)}%`).join(", ")} exterior.`,
53 + `${m.roof.value.replace(/_/g, " ")} ${m.roofGeometry.value} roof.`,
54 + m.garage.value.type === "none" ? "No garage." : `${m.garage.value.type} ${m.garage.value.spaces}-car garage.`,
55 + `${m.basement.value} basement${m.basementFinishedPct.value ? ` ${Math.round(m.basementFinishedPct.value * 100)}% finished` : ""}.`,
56 + `${m.kitchenQuality.value} kitchen.`,
57 + `${m.bathrooms.value} bathrooms${m.powderRooms.value ? `, ${m.powderRooms.value} powder rooms` : ""}.`,
58 + `${m.bathroomQuality.value} bathrooms quality.`,
59 + Object.keys(m.flooring.value ?? {}).length ? `${Object.entries(m.flooring.value).sort((a, b) => b[1] - a[1]).map(([k]) => k.replace(/_/g, " ")).join(" and ")} floors.` : "",
60 + `${m.heating.value.replace(/_/g, " ")}${m.hasAirConditioning.value ? ", air conditioning" : ""}${m.hasAirExchanger.value ? ", air exchanger" : ""}.`,
61 + `${m.windows.value} windows.`,
62 + m.deckSqft.value ? `Deck ${Math.round(m.deckSqft.value)} sqft.` : "",
63 + m.pool.value !== "none" ? `${m.pool.value.replace(/_/g, " ")} pool.` : "",
64 + `Overall quality ${m.quality.value}.`,
65 + Object.keys(m.conditions).length ? `Condition ${Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(", ")}.` : "",
66 + ];
67 + return parts.filter(Boolean).join("\n");
68 +}
69 +
70 +export function technicalVector(m: MergedFacts): number[] {
71 + const condIdx = (g: string) => { const c = m.conditions[g]; const i = c ? CATS.condition.indexOf(c as (typeof CATS.condition)[number]) : -1; return i < 0 ? 0.5 : i / (CATS.condition.length - 1); };
72 + const v = [
73 + ...oneHot(CATS.buildingType, m.buildingType.value),
74 + ...oneHot(CATS.quality, m.quality.value), ...oneHot(CATS.quality, m.kitchenQuality.value), ...oneHot(CATS.quality, m.bathroomQuality.value),
75 + ...oneHot(CATS.basement, m.basement.value), ...oneHot(CATS.garage, m.garage.value.type), ...oneHot(CATS.roof, m.roof.value), ...oneHot(CATS.roofGeometry, m.roofGeometry.value),
76 + ...oneHot(CATS.windows, m.windows.value), ...oneHot(CATS.heating, m.heating.value), ...oneHot(CATS.foundation, m.foundation.value), ...oneHot(CATS.structure, m.structure.value),
77 + ...shares(CATS.siding, m.siding.value as Record<string, number | undefined>), ...shares(CATS.flooring, m.flooring.value), ...oneHot(CATS.pool, m.pool.value),
78 + // numériques normalisés
79 + Math.min(1, Math.log10(Math.max(200, m.grossFloorAreaSqft.value)) / 4.5), Math.min(1, m.stories.value / 4), Math.min(1, ((new Date().getFullYear() - (m.yearBuilt.value ?? 1980)) || 0) / 120),
80 + Math.min(1, m.bathrooms.value / 5), Math.min(1, m.powderRooms.value / 3), Math.min(1, (m.bedrooms.value ?? 3) / 6), Math.min(1, m.deckSqft.value / 1000), Math.min(1, m.drivewaySqft.value / 2000),
81 + m.hasAirConditioning.value ? 1 : 0, m.hasAirExchanger.value ? 1 : 0, Math.min(1, m.basementFinishedPct.value), Math.min(1, m.garage.value.spaces / 3),
82 + ...CONDITION_GROUPS.map(condIdx),
83 + ];
84 + if (v.length !== EMBEDDING_DIMENSION) throw new Error(`dimension du vecteur ${v.length} ≠ ${EMBEDDING_DIMENSION}`);
85 + return v.map((x) => Math.round(x * 10000) / 10000);
86 +}
87 +
88 +export const technicalEmbedder: Embedder = { model: EMBEDDING_MODEL, embed: (_t, facts) => technicalVector(facts) };
89 +
90 +export function cosine(a: number[], b: number[]): number {
91 + let dot = 0, na = 0, nb = 0;
92 + for (let i = 0; i < Math.min(a.length, b.length); i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
93 + return na && nb ? dot / Math.sqrt(na * nb) : 0;
94 +}
95 +
96 +/** Vecteur de structure de coût (§201) : parts normalisées par grande famille. */
97 +export function costProfileVector(categories: { category: string; adjusted: number }[]): Record<string, number> {
98 + const fam: Record<string, string[]> = { foundation: ["site", "excavation", "foundation"], structure: ["structure"], envelope: ["roofing", "envelope", "openings", "insulation", "finishes_ext"], finish: ["interior", "kitchen", "bathroom", "basement"], mechanical: ["plumbing", "hvac", "electrical"], exterior: ["garage", "exterior"] };
99 + const total = categories.reduce((s, c) => s + c.adjusted, 0) || 1;
100 + const out: Record<string, number> = {};
101 + for (const [k, cats] of Object.entries(fam)) out[k] = Math.round((1000 * categories.filter((c) => cats.includes(c.category)).reduce((s, c) => s + c.adjusted, 0)) / total) / 1000;
102 + return out;
103 +}
added src/lib/cost/cache.ts +40 −0
@@ -0,0 +1,40 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Cache mémoire simple (par processus) pour les lectures coûteuses de la base
4 + * de coûts : aperçu, assemblages, indices. Invalidé par TTL et par la clé
5 + * `last_ingest` (cost_meta) mise à jour à chaque ingestion.
6 + */
7 +import { metaGet } from "./db";
8 +
9 +interface Entry<T> { value: T; expires: number; ingest: string | null }
10 +const store = new Map<string, Entry<unknown>>();
11 +
12 +export function cached<T>(key: string, ttlMs: number, compute: () => T): T {
13 + let ingest: string | null = null;
14 + try { ingest = metaGet("last_ingest"); } catch { /* base indisponible */ }
15 + const e = store.get(key) as Entry<T> | undefined;
16 + if (e && e.expires > Date.now() && e.ingest === ingest) return e.value;
17 + const value = compute();
18 + store.set(key, { value, expires: Date.now() + ttlMs, ingest });
19 + return value;
20 +}
21 +
22 +export function invalidateCache(): void {
23 + store.clear();
24 +}
25 +
26 +/** Limiteur de débit en mémoire (par clé, fenêtre glissante). */
27 +const hits = new Map<string, number[]>();
28 +export function rateLimit(key: string, max: number, windowMs: number): boolean {
29 + const now = Date.now();
30 + const arr = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
31 + if (arr.length >= max) { hits.set(key, arr); return false; }
32 + arr.push(now);
33 + hits.set(key, arr);
34 + return true;
35 +}
36 +
37 +export function clientKey(req: Request): string {
38 + const xff = req.headers.get("x-forwarded-for");
39 + return (xff ? xff.split(",").pop()!.trim() : req.headers.get("x-real-ip")) ?? "local";
40 +}
added src/lib/cost/catalog.ts +335 −0
@@ -0,0 +1,335 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Accès à la base de coûts (serveur) : articles + prix canoniques, assemblages,
4 + * taux de main-d'œuvre, localisations, indices, benchmarks → PricingContext.
5 + * Toute lecture est un INSTANTANÉ à une date (`asOf`) : reproductible.
6 + */
7 +import type Database from "better-sqlite3";
8 +import { getCostDb } from "./db";
9 +import { canonicalPrice, indexFactor, type PriceObservation } from "./pricing";
10 +import { FALLBACK_HOURLY_RATE, assemblyUnitCost, type BenchmarkRow, type ConditionRule, type PricingContext } from "./engine";
11 +import type { Assembly, CanonicalUnit, Condition, CostItem, CostLocation, CostOverview, IndexSeries, ItemPrice, LabourRate, PriceKind, Quality, UxCategory } from "./types";
12 +import { TRADES } from "./taxonomy";
13 +
14 +type Row = Record<string, unknown>;
15 +const s = (v: unknown) => (v == null ? null : String(v));
16 +const n = (v: unknown) => (v == null ? null : Number(v));
17 +
18 +export const today = (): string => new Date().toISOString().slice(0, 10);
19 +
20 +/* ---------------------------------------------------------------- articles */
21 +
22 +function rowToItem(r: Row): CostItem {
23 + return {
24 + id: Number(r.id), code: String(r.canonical_code), masterformat: String(r.masterformat_code), division: String(r.division), category: r.category as UxCategory, subcategory: s(r.subcategory),
25 + nameFr: String(r.name_fr), nameEn: String(r.name_en), descriptionFr: s(r.description_fr), descriptionEn: s(r.description_en), unit: r.unit as CanonicalUnit, materialClass: s(r.material_class),
26 + trade: s(r.trade), defaultWastePct: Number(r.default_waste_pct), qualityLevel: (r.quality_level as Quality) ?? null, referencePrice: n(r.reference_price), referenceNote: s(r.reference_note), active: Number(r.active) === 1,
27 + };
28 +}
29 +
30 +export function loadItems(d: Database.Database = getCostDb()): Map<string, CostItem> {
31 + const rows = d.prepare("SELECT * FROM cost_items WHERE active=1").all() as Row[];
32 + return new Map(rows.map((r) => [String(r.canonical_code), rowToItem(r)]));
33 +}
34 +
35 +export function getItem(code: string, d: Database.Database = getCostDb()): CostItem | null {
36 + const r = d.prepare("SELECT * FROM cost_items WHERE canonical_code=?").get(code) as Row | undefined;
37 + return r ? rowToItem(r) : null;
38 +}
39 +
40 +export function searchItems(q: string, limit = 20, d: Database.Database = getCostDb()): CostItem[] {
41 + const like = `%${q.trim().normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase()}%`;
42 + const rows = d.prepare(`SELECT * FROM cost_items WHERE active=1 AND (lower(name_fr) LIKE ? OR lower(name_en) LIKE ? OR lower(canonical_code) LIKE ? OR lower(category) LIKE ?) ORDER BY name_fr LIMIT ?`)
43 + .all(like, like, like, like, limit) as Row[];
44 + // repli sans accents : SQLite lower() ignore les accents des noms → seconde passe JS
45 + if (rows.length) return rows.map(rowToItem);
46 + const all = loadItems(d);
47 + const fold = (x: string) => x.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
48 + const needle = fold(q);
49 + return [...all.values()].filter((i) => fold(i.nameFr).includes(needle) || fold(i.nameEn).includes(needle) || fold(i.code).includes(needle)).slice(0, limit);
50 +}
51 +
52 +/* ------------------------------------------------------------------- prix */
53 +
54 +interface PriceRow { code: string; unit: string; source: string; quality: number; total_cost: number; observation_date: string; source_url: string | null; source_unit: string | null; conversion_factor: number; is_regular_price: number; price_kind: string; location_code: string | null; is_outlier: number }
55 +
56 +/** Série d'indice résidentiel utilisée pour l'actualisation (Montréal, bâtiments résidentiels, agrégat). */
57 +export function residentialIndexSeries(d: Database.Database = getCostDb(), code = "statcan:18100289:10:1:1"): { period: string; value: number }[] {
58 + return d.prepare("SELECT period, index_value AS value FROM construction_cost_indices WHERE index_code=? ORDER BY period").all(code) as { period: string; value: number }[];
59 +}
60 +
61 +/**
62 + * Prix canonique de chaque article à la date `asOf` : observations des 120
63 + * derniers jours → médiane robuste ; sinon dernière observation actualisée par
64 + * l'indice StatCan (kind « indexed », ≤ 3 ans) ; sinon prix de référence
65 + * interne (kind « reference », hypothèse).
66 + */
67 +export function loadPrices(asOf: string, locationCode: string | null, d: Database.Database = getCostDb(), items = loadItems(d)): Map<string, ItemPrice> {
68 + const rows = d.prepare(`SELECT i.canonical_code code, i.unit, s.name source, s.quality, p.total_cost, p.observation_date, p.source_url, p.source_unit, p.conversion_factor, p.is_regular_price, p.price_kind, p.location_code, p.is_outlier
69 + FROM cost_item_prices p JOIN cost_items i ON i.id=p.cost_item_id JOIN cost_sources s ON s.id=p.source_id
70 + WHERE p.observation_date <= ? AND s.is_active=1 AND p.is_outlier=0 AND p.price_kind IN ('observed','official','indexed','derived') ORDER BY p.observation_date DESC`).all(asOf) as PriceRow[];
71 + const byItem = new Map<string, PriceObservation[]>();
72 + for (const r of rows) {
73 + const arr = byItem.get(r.code) ?? [];
74 + arr.push({ source: r.source, sourceQuality: r.quality, price: r.total_cost, observedAt: r.observation_date, url: r.source_url, sourceUnit: r.source_unit ?? r.unit, conversionFactor: r.conversion_factor, regular: r.is_regular_price === 1, kind: r.price_kind as PriceKind, locationMatch: !r.location_code || r.location_code === "QC" || r.location_code === locationCode });
75 + byItem.set(r.code, arr);
76 + }
77 + const series = residentialIndexSeries(d);
78 + const out = new Map<string, ItemPrice>();
79 + for (const item of items.values()) {
80 + const obs = byItem.get(item.code) ?? [];
81 + const cp = canonicalPrice(obs, asOf, 120);
82 + if (cp) {
83 + out.set(item.code, {
84 + itemCode: item.code, unit: item.unit, price: cp.price, low: cp.low, high: cp.high, kind: cp.kind, sourceCount: cp.sourceCount, observedAt: cp.observedAt,
85 + sources: cp.used.map((o) => ({ source: o.source, price: o.price, url: o.url, observedAt: o.observedAt, sourceUnit: o.sourceUnit, conversionFactor: o.conversionFactor, regular: o.regular })),
86 + confidence: cp.confidence, indexed: null, note: cp.outliers.length ? `${cp.outliers.length} observation(s) aberrante(s) écartée(s) : ${cp.outliers.map((o) => `${o.obs.source} ${o.obs.price.toFixed(2)} $ (${o.reason})`).join(" ; ")}` : null,
87 + });
88 + continue;
89 + }
90 + // observation ancienne → actualisation par indice
91 + const old = obs.filter((o) => o.observedAt <= asOf).sort((a, b) => (a.observedAt < b.observedAt ? 1 : -1))[0];
92 + if (old && series.length >= 2) {
93 + const f = indexFactor(series, old.observedAt, asOf);
94 + const ageDays = (new Date(asOf).getTime() - new Date(old.observedAt).getTime()) / 86400000;
95 + if (f && ageDays <= 3 * 365) {
96 + const price = old.price * f.factor;
97 + out.set(item.code, {
98 + itemCode: item.code, unit: item.unit, price, low: price * 0.9, high: price * 1.1, kind: "indexed", sourceCount: 1, observedAt: old.observedAt,
99 + sources: [{ source: old.source, price: old.price, url: old.url, observedAt: old.observedAt, sourceUnit: old.sourceUnit, conversionFactor: old.conversionFactor, regular: old.regular }],
100 + confidence: Math.round(45 * Math.max(0.3, 1 - ageDays / 1095)), indexed: { fromDate: old.observedAt, toDate: asOf, factor: f.factor, index: "StatCan 18-10-0289 — bâtiments résidentiels, Montréal" },
101 + note: `Prix du ${old.observedAt} actualisé au ${asOf} par l'indice StatCan (× ${f.factor.toFixed(3)}).`,
102 + });
103 + continue;
104 + }
105 + }
106 + if (item.referencePrice != null) {
107 + out.set(item.code, {
108 + itemCode: item.code, unit: item.unit, price: item.referencePrice, low: item.referencePrice * 0.85, high: item.referencePrice * 1.15, kind: "reference", sourceCount: 0, observedAt: null,
109 + sources: [{ source: "Vrai-Prix — prix de référence (hypothèse)", price: item.referencePrice, url: null, observedAt: "2026-09-01", sourceUnit: item.unit, conversionFactor: 1, regular: true }],
110 + confidence: 35, indexed: null, note: item.referenceNote ?? "Prix de référence interne : aucune observation détaillant pour cet article — hypothèse documentée, non observée.",
111 + });
112 + }
113 + }
114 + return out;
115 +}
116 +
117 +/** Historique des prix (toutes observations) d'un article. */
118 +export function priceHistory(code: string, d: Database.Database = getCostDb()): { date: string; price: number; source: string; url: string | null; regular: boolean; kind: string; outlier: boolean; sourceUnit: string | null; conversionFactor: number }[] {
119 + return (d.prepare(`SELECT p.observation_date date, p.total_cost price, s.name source, p.source_url url, p.is_regular_price regular, p.price_kind kind, p.is_outlier outlier, p.source_unit su, p.conversion_factor cf
120 + FROM cost_item_prices p JOIN cost_items i ON i.id=p.cost_item_id JOIN cost_sources s ON s.id=p.source_id WHERE i.canonical_code=? ORDER BY p.observation_date`).all(code) as Row[])
121 + .map((r) => ({ date: String(r.date), price: Number(r.price), source: String(r.source), url: s(r.url), regular: Number(r.regular) === 1, kind: String(r.kind), outlier: Number(r.outlier) === 1, sourceUnit: s(r.su), conversionFactor: Number(r.cf) }));
122 +}
123 +
124 +/* ----------------------------------------------------------- main-d'œuvre */
125 +
126 +export function loadLabour(asOf: string, d: Database.Database = getCostDb(), sector = "residentiel_leger", region = "QC"): Map<string, LabourRate> {
127 + const rows = d.prepare(`SELECT l.*, s.name source_name FROM labour_rates l LEFT JOIN cost_sources s ON s.id=l.source_id
128 + WHERE l.effective_from <= ? AND l.classification='compagnon' AND l.sector=? AND l.region=? ORDER BY l.effective_from DESC, COALESCE(s.priority, 9) ASC`).all(asOf, sector, region) as Row[];
129 + const out = new Map<string, LabourRate>();
130 + for (const r of rows) {
131 + const code = String(r.trade_code);
132 + if (out.has(code)) continue;
133 + out.set(code, {
134 + tradeCode: code, tradeNameFr: String(r.trade_name_fr), tradeNameEn: s(r.trade_name_en) ?? String(r.trade_name_fr), sector: String(r.sector), classification: String(r.classification), region: String(r.region),
135 + effectiveFrom: String(r.effective_from), effectiveTo: s(r.effective_to), baseWage: Number(r.base_wage), vacationCost: Number(r.vacation_cost), benefitsCost: Number(r.benefits_cost), employerContributions: Number(r.employer_contributions),
136 + otherContributions: Number(r.other_contributions), totalEmployerCost: Number(r.total_employer_cost), source: s(r.source_name) ?? "—", sourceUrl: s(r.source_url), confidence: Number(r.confidence_score ?? 80),
137 + });
138 + }
139 + // alias : poseur de bardeaux → couvreur
140 + if (!out.has("poseur_bardeaux") && out.has("couvreur")) out.set("poseur_bardeaux", { ...out.get("couvreur")!, tradeCode: "poseur_bardeaux" });
141 + return out;
142 +}
143 +
144 +/** Toutes les grilles (historique) d'un métier. */
145 +export function labourHistory(tradeCode: string, d: Database.Database = getCostDb()): LabourRate[] {
146 + const rows = d.prepare(`SELECT l.*, s.name source_name FROM labour_rates l LEFT JOIN cost_sources s ON s.id=l.source_id WHERE l.trade_code=? ORDER BY l.effective_from`).all(tradeCode) as Row[];
147 + return rows.map((r) => ({
148 + tradeCode: String(r.trade_code), tradeNameFr: String(r.trade_name_fr), tradeNameEn: s(r.trade_name_en) ?? String(r.trade_name_fr), sector: String(r.sector), classification: String(r.classification), region: String(r.region),
149 + effectiveFrom: String(r.effective_from), effectiveTo: s(r.effective_to), baseWage: Number(r.base_wage), vacationCost: Number(r.vacation_cost), benefitsCost: Number(r.benefits_cost), employerContributions: Number(r.employer_contributions),
150 + otherContributions: Number(r.other_contributions), totalEmployerCost: Number(r.total_employer_cost), source: s(r.source_name) ?? "—", sourceUrl: s(r.source_url), confidence: Number(r.confidence_score ?? 80),
151 + }));
152 +}
153 +
154 +export function allLabourRates(asOf: string, d: Database.Database = getCostDb()): LabourRate[] {
155 + const out: LabourRate[] = [];
156 + for (const sector of ["residentiel_leger", "residentiel_lourd", "ic"]) {
157 + const rows = d.prepare(`SELECT l.*, s.name source_name FROM labour_rates l LEFT JOIN cost_sources s ON s.id=l.source_id WHERE l.effective_from <= ? AND l.sector=? AND l.region='QC' ORDER BY l.effective_from DESC`).all(asOf, sector) as Row[];
158 + const seen = new Set<string>();
159 + for (const r of rows) {
160 + const k = `${r.trade_code}|${r.classification}`;
161 + if (seen.has(k)) continue;
162 + seen.add(k);
163 + out.push({
164 + tradeCode: String(r.trade_code), tradeNameFr: String(r.trade_name_fr), tradeNameEn: s(r.trade_name_en) ?? String(r.trade_name_fr), sector: String(r.sector), classification: String(r.classification), region: String(r.region),
165 + effectiveFrom: String(r.effective_from), effectiveTo: s(r.effective_to), baseWage: Number(r.base_wage), vacationCost: Number(r.vacation_cost), benefitsCost: Number(r.benefits_cost), employerContributions: Number(r.employer_contributions),
166 + otherContributions: Number(r.other_contributions), totalEmployerCost: Number(r.total_employer_cost), source: s(r.source_name) ?? "—", sourceUrl: s(r.source_url), confidence: Number(r.confidence_score ?? 80),
167 + });
168 + }
169 + }
170 + return out;
171 +}
172 +
173 +/* ------------------------------------------------------------ assemblages */
174 +
175 +export function loadAssemblies(d: Database.Database = getCostDb()): Map<string, Assembly> {
176 + const asms = d.prepare("SELECT * FROM cost_assemblies WHERE active=1").all() as Row[];
177 + const comps = d.prepare(`SELECT c.*, i.canonical_code FROM cost_assembly_components c LEFT JOIN cost_items i ON i.id=c.cost_item_id ORDER BY c.assembly_id, c.sequence`).all() as Row[];
178 + const byAsm = new Map<number, Assembly["components"]>();
179 + for (const c of comps) {
180 + const arr = byAsm.get(Number(c.assembly_id)) ?? [];
181 + arr.push({ itemCode: s(c.canonical_code) ?? "", quantity: Number(c.quantity_per_assembly_unit), wasteFactor: Number(c.waste_factor), labourHours: Number(c.labour_hours), trade: s(c.trade), equipmentCost: Number(c.equipment_cost), sequence: Number(c.sequence), notes: s(c.notes) });
182 + byAsm.set(Number(c.assembly_id), arr);
183 + }
184 + return new Map(asms.map((a) => [String(a.assembly_code), {
185 + code: String(a.assembly_code), masterformat: String(a.masterformat_code), category: a.category as UxCategory, nameFr: String(a.name_fr), nameEn: String(a.name_en), descriptionFr: s(a.description_fr) ?? "", descriptionEn: s(a.description_en) ?? "",
186 + unit: a.unit as CanonicalUnit, buildingType: s(a.building_type), quality: (a.quality_level as Quality) ?? null, version: Number(a.version), economicLife: n(a.economic_life), conditionGroup: s(a.condition_group), components: byAsm.get(Number(a.id)) ?? [], active: true,
187 + } satisfies Assembly]));
188 +}
189 +
190 +/* ----------------------------------------------------------- localisation */
191 +
192 +function rowToLocation(r: Row): CostLocation {
193 + return { code: String(r.code), nameFr: String(r.name_fr), nameEn: String(r.name_en), regionCode: s(r.region_code) ?? "", materialFactor: Number(r.material_factor), labourFactor: Number(r.labour_factor), equipmentFactor: Number(r.equipment_factor), overallFactor: Number(r.overall_factor), effectiveDate: String(r.effective_date), sourceMethod: s(r.source_method) ?? "", confidence: Number(r.confidence_score ?? 50) };
194 +}
195 +
196 +export function loadLocations(d: Database.Database = getCostDb()): CostLocation[] {
197 + return (d.prepare("SELECT * FROM cost_locations ORDER BY code").all() as Row[]).map(rowToLocation);
198 +}
199 +
200 +const fold = (x: string) => x.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
201 +
202 +/** Localisation d'une propriété : code forcé → municipalité → centre le plus proche (rayon) → rural. */
203 +export function resolveLocation(opts: { code?: string | null; municipality?: string | null; lat?: number | null; lng?: number | null }, d: Database.Database = getCostDb()): { location: CostLocation; method: string } {
204 + const rows = d.prepare("SELECT * FROM cost_locations").all() as Row[];
205 + if (opts.code) {
206 + const r = rows.find((x) => x.code === opts.code);
207 + if (r) return { location: rowToLocation(r), method: "code imposé" };
208 + }
209 + if (opts.municipality) {
210 + const m = fold(opts.municipality);
211 + for (const r of rows) {
212 + const munis = JSON.parse(String(r.municipalities_json ?? "[]")) as string[];
213 + if (munis.some((x) => fold(x) === m)) return { location: rowToLocation(r), method: `municipalité « ${opts.municipality} »` };
214 + }
215 + }
216 + if (opts.lat != null && opts.lng != null) {
217 + let best: { r: Row; km: number } | null = null;
218 + for (const r of rows) {
219 + if (!r.latitude || !r.radius_km || Number(r.radius_km) <= 0) continue;
220 + const km = haversineKm(opts.lat, opts.lng, Number(r.latitude), Number(r.longitude));
221 + if (km <= Number(r.radius_km) && (!best || km < best.km)) best = { r, km };
222 + }
223 + if (best) return { location: rowToLocation(best.r), method: `centre le plus proche (${best.km.toFixed(0)} km de ${best.r.name_fr})` };
224 + }
225 + const rural = rows.find((x) => x.code === "QC-RUR") ?? rows[0];
226 + return { location: rowToLocation(rural), method: "défaut rural (aucune correspondance)" };
227 +}
228 +
229 +function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
230 + const R = 6371, dLat = ((lat2 - lat1) * Math.PI) / 180, dLng = ((lng2 - lng1) * Math.PI) / 180;
231 + const a = Math.sin(dLat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
232 + return 2 * R * Math.asin(Math.sqrt(a));
233 +}
234 +
235 +/* ------------------------------------------------- règles, benchmarks, indices */
236 +
237 +export function loadConditionRules(d: Database.Database = getCostDb()): Map<string, Map<Condition, ConditionRule>> {
238 + const rows = d.prepare("SELECT * FROM component_condition_rules").all() as Row[];
239 + const out = new Map<string, Map<Condition, ConditionRule>>();
240 + for (const r of rows) {
241 + const g = String(r.condition_group);
242 + if (!out.has(g)) out.set(g, new Map());
243 + out.get(g)!.set(r.condition as Condition, { effectiveAgeRatio: Number(r.effective_age_ratio), economicLife: Number(r.economic_life) });
244 + }
245 + return out;
246 +}
247 +
248 +export function loadBenchmarks(d: Database.Database = getCostDb()): BenchmarkRow[] {
249 + return (d.prepare("SELECT * FROM cost_benchmarks WHERE is_demo=0 ORDER BY year DESC").all() as Row[]).map((r) => ({ source: String(r.source), buildingType: String(r.building_type), market: String(r.market), unit: String(r.unit), low: Number(r.low), high: Number(r.high), midpoint: n(r.midpoint), year: Number(r.year), notes: s(r.notes) }));
250 +}
251 +
252 +export function loadIndexSeries(d: Database.Database = getCostDb()): IndexSeries[] {
253 + const rows = d.prepare("SELECT * FROM construction_cost_indices ORDER BY index_code, period").all() as Row[];
254 + const map = new Map<string, IndexSeries>();
255 + for (const r of rows) {
256 + const code = String(r.index_code);
257 + if (!map.has(code)) map.set(code, { code, source: String(r.source), geography: String(r.geography), buildingType: String(r.building_type), division: String(r.division), points: [], retrievedAt: s(r.retrieved_at) });
258 + map.get(code)!.points.push({ period: String(r.period), value: Number(r.index_value), pctYoy: n(r.pct_change_yoy) });
259 + }
260 + return [...map.values()];
261 +}
262 +
263 +/* ---------------------------------------------------------------- contexte */
264 +
265 +export function buildContext(opts: { asOf?: string | null; locationCode?: string | null; municipality?: string | null; lat?: number | null; lng?: number | null }, d: Database.Database = getCostDb()): PricingContext & { locationMethod: string } {
266 + const asOf = opts.asOf ?? today();
267 + const items = loadItems(d);
268 + const { location, method } = resolveLocation({ code: opts.locationCode, municipality: opts.municipality, lat: opts.lat, lng: opts.lng }, d);
269 + return {
270 + asOf, items, prices: loadPrices(asOf, location.code, d, items), labour: loadLabour(asOf, d), assemblies: loadAssemblies(d), location,
271 + conditionRules: loadConditionRules(d), benchmarks: loadBenchmarks(d), fallbackHourlyRate: FALLBACK_HOURLY_RATE, locationMethod: method,
272 + };
273 +}
274 +
275 +/* ------------------------------------------------------------- tableau de bord */
276 +
277 +const WATCHED_MATERIALS = ["LUM-2X4-8", "LUM-2X6-8", "OSB-7/16-4X8", "GYP-1/2-4X8", "INS-BATT-R24-2X6", "CONC-BAG-30KG", "SHINGLE-ARCH", "SIDING-VINYL", "PLY-1/2-4X8", "POLY-6MIL", "FLOOR-VINYL-PLANK", "TILE-CERAMIC-12X24"];
278 +const POPULAR_ASSEMBLIES = ["STR-WALL-2X6-EXT", "ENV-INSUL-WALL-R24", "ENV-SIDING-VINYL", "ENV-SIDING-BRICK", "ROOF-ASPHALT-ARCH", "FND-WALL-POURED-8IN", "INT-DRYWALL-1/2-FINISHED", "STR-FLOOR-IJOIST", "OPN-WINDOW-PVC-STD", "KIT-STANDARD", "BATH-STANDARD", "INT-FLOOR-HARDWOOD"];
279 +
280 +export function buildOverview(d: Database.Database = getCostDb()): CostOverview {
281 + const asOf = today();
282 + const ctx = buildContext({ asOf, locationCode: "QC-MTL" }, d);
283 + const indices = loadIndexSeries(d);
284 + const resi = indices.find((i) => i.code === "statcan:18100289:10:1:1") ?? indices.find((i) => i.buildingType.includes("résidentiel")) ?? null;
285 + const wood = indices.find((i) => i.code === "statcan:18100289:10:1:8") ?? null;
286 + const last = (sr: IndexSeries | null) => (sr && sr.points.length ? sr.points[sr.points.length - 1] : null);
287 + const rp = last(resi);
288 + const wp = last(wood);
289 + const carp = ctx.labour.get("charpentier") ?? null;
290 + let labourYoy: number | null = null;
291 + if (carp) {
292 + const hist = labourHistory("charpentier", d).filter((h) => h.sector === carp.sector && h.classification === "compagnon");
293 + const prev = [...hist].reverse().find((h) => h.effectiveFrom <= shiftDays(carp.effectiveFrom, -300));
294 + if (prev && prev.totalEmployerCost > 0) labourYoy = Math.round(((carp.totalEmployerCost / prev.totalEmployerCost) - 1) * 1000) / 10;
295 + }
296 + const obsCount = (d.prepare("SELECT COUNT(*) n FROM cost_item_prices WHERE price_kind IN ('observed','official')").get() as { n: number }).n;
297 + const itemsObserved = (d.prepare("SELECT COUNT(DISTINCT cost_item_id) n FROM cost_item_prices WHERE price_kind IN ('observed','official') AND observation_date >= date('now','-120 days')").get() as { n: number }).n;
298 + const lastSync = (d.prepare("SELECT MAX(last_successful_sync) m FROM cost_sources").get() as { m: string | null }).m;
299 + const materials = WATCHED_MATERIALS.filter((c) => ctx.items.has(c)).map((code) => {
300 + const it = ctx.items.get(code)!;
301 + const p = ctx.prices.get(code) ?? null;
302 + const hist = priceHistory(code, d).filter((h) => !h.outlier && h.kind !== "reference");
303 + // médiane par date
304 + const byDate = new Map<string, number[]>();
305 + for (const h of hist) byDate.set(h.date, [...(byDate.get(h.date) ?? []), h.price]);
306 + const history = [...byDate.entries()].map(([date, ps]) => ({ date, price: Math.round(100 * ps.sort((a, b) => a - b)[Math.floor(ps.length / 2)]) / 100 })).sort((a, b) => a.date.localeCompare(b.date));
307 + return { itemCode: code, nameFr: it.nameFr, nameEn: it.nameEn, unit: it.unit, price: p ? Math.round(p.price * 100) / 100 : null, kind: p?.kind ?? null, sourceCount: p?.sourceCount ?? 0, observedAt: p?.observedAt ?? null, history };
308 + });
309 + const popularAssemblies = POPULAR_ASSEMBLIES.filter((c) => ctx.assemblies.has(c)).map((code) => {
310 + const a = ctx.assemblies.get(code)!;
311 + const u = assemblyUnitCost(a, ctx);
312 + return { code, nameFr: a.nameFr, nameEn: a.nameEn, unit: a.unit, direct: u.direct, material: u.material, labour: u.labour, equipment: u.equipment, observedShare: Math.round(u.observedShare * 100) / 100 };
313 + });
314 + const sources = (d.prepare(`SELECT s.*, (SELECT COUNT(*) FROM cost_item_prices p WHERE p.source_id=s.id) + (SELECT COUNT(*) FROM labour_rates l WHERE l.source_id=s.id) obs FROM cost_sources s ORDER BY s.priority, s.name`).all() as Row[])
315 + .map((r) => ({ name: String(r.name), type: String(r.source_type), license: String(r.license_status), lastSync: s(r.last_successful_sync), active: Number(r.is_active) === 1, observations: Number(r.obs), url: s(r.base_url) }));
316 + return {
317 + generatedAt: new Date().toISOString(), indices,
318 + kpis: {
319 + residentialIndex: rp ? { value: rp.value, period: rp.period, pctYoy: rp.pctYoy, geography: resi!.geography } : null,
320 + materialsProxy: wp ? { value: wp.value, period: wp.period, pctYoy: wp.pctYoy, label: wood!.division } : null,
321 + labour: carp ? { trade: carp.tradeNameFr, totalEmployerCost: carp.totalEmployerCost, effectiveFrom: carp.effectiveFrom, pctYoy: labourYoy, source: carp.source } : null,
322 + observations: obsCount, itemsObserved, itemsTotal: ctx.items.size, lastSync,
323 + },
324 + materials, popularAssemblies, locations: loadLocations(d), sources,
325 + };
326 +}
327 +
328 +function shiftDays(iso: string, days: number): string {
329 + const d = new Date(iso);
330 + d.setDate(d.getDate() + days);
331 + return d.toISOString().slice(0, 10);
332 +}
333 +
334 +/** Libellés des métiers pour l'UI (réexport). */
335 +export const TRADE_LIST = TRADES;
added src/lib/cost/connectors/__fixtures__/apchq-commercial-2026-04-26.json +1 −0
@@ -0,0 +1 @@
1 +{"success":true,"data":{"markdown":"**COÛT HORAIRE DE LA MAIN-D'OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 Coûts horaires de la main-d'œuvre - 26 avril 2026**\n**secteur COMMERCIAL, INSTITUTIONNEL ET INDUSTRIEL**\n Taxe sur Équip. De Prélèv.\n\n| APCHQ | Taux horaire | Indemnité vacances | Avantages sociaux | Taxe sur assur. | Équip. De sécur. | A-E | RQAP | RRQ | FSS | Prélèv. CCQ | AECQ | Fonds d'indemn. | Fonds de formation | CNESST | ACQ +Autres contributions | Total | |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| APCHQ | Valeur Unité | | 13% % | 1 heure | 1 heure | 1 heure | 1,820% % | 0,602% % | 6,300% % | 4,26% % | 0,75% % | 0,03 $ heure | 0,02 $ heure | 0,20 $ heure | | | |\n| | Valeur Unité | | | | | | | | | | | | | | | | |\n| Métiers et spécialités | | | | | | | | | | | | | | | | | |\n| Briqueteur-maçon | | 49,57 $ | 6,44 $ | 8,470 $ | 0,29 $ | 0,60 $ | 1,02 $ | 0,34 $ | 3,62 $ | 2,52 $ | 0,42 $ | 0,03 $ | 0,02 $ | 0,20 $ | 4,43 $ | 0,03 $ | 78,00 |\n| Briqueteur-maçon | 49,57$ | 6,44$ | 8,470$ | 0,29$ | 0,60$ | 1,02$ | 0,34$ | 3,62$ | 2,52$ | 0,42$ | 0,03$ | 0,02$ | 0,20$ | 4,43$ | 0,03$ | 7,550 | |\n| apprenti - période 1 | 29,74$ | 3,87$ | 7,550$ | 0,29$ | 0,60$ | 0,61$ | 0,20$ | 2,21$ | 1,56$ | 0,25$ | 0,03$ | 0,02$ | 0,20$ | 2,75$ | 0,03$ | 49,91 | |\n| apprenti - période 2 | 34,70$ | 4,51$ | 7,550$ | 0,29$ | 0,60$ | 0,71$ | 0,24$ | 2,56$ | 1,80$ | 0,29$ | 0,03$ | 0,02$ | 0,20$ | 3,17$ | 0,03$ | 56,70 | |\n| apprenti - période 3 | 42,13$ | 5,48$ | 7,550$ | 0,29$ | 0,60$ | 0,87$ | 0,29$ | 3,09$ | 2,16$ | 0,36$ | 0,03$ | 0,02$ | 0,20$ | 3,80$ | 0,03$ | 66,90 | |\n| | | | | | | | | | | | | | | | | | |\n| Calorifugeur | 50,79$ | 6,60$ | 8,540$ | 0,30$ | 0,60$ | 1,04$ | 0,35$ | 3,69$ | 2,56$ | 0,43$ | 0,03$ | 0,02$ | 0,20$ | 1,82$ | 0,38$ | 77,35 | |\n| apprenti - période 1 | 30,47$ | 3,96$ | 7,620$ | 0,30$ | 0,60$ | 0,63$ | 0,21$ | 2,24$ | 1,59$ | 0,26$ | 0,03$ | 0,02$ | 0,20$ | 1,13$ | 0,38$ | 49,64 | |\n| apprenti - période 2 | 35,55$ | 4,62$ | 7,620$ | 0,30$ | 0,60$ | 0,73$ | 0,24$ | 2,60$ | 1,83$ | 0,30$ | 0,03$ | 0,02$ | 0,20$ | 1,30$ | 0,38$ | 56,32 | |\n| apprenti - période 3 | 43,17$ | 5,61$ | 7,620$ | 0,30$ | 0,60$ | 0,89$ | 0,29$ | 3,14$ | 2,20$ | 0,37$ | 0,03$ | 0,02$ | 0,20$ | 1,56$ | 0,38$ | 66,38 | |\n| | | | | | | | | | | | | | | | | | |\n| apprenti - période 3 | 43,17 $ | 5,61 $ | 7,620 $ | 0,30 $ | 0,60 $ | 0,89 $ | 0,29 $ | 3,14 $ | 2,20 $ | 0,37 $ | 0,03 $ | 0,02 $ | 0,20 $ | 1,56 $ | 0,38 $ | 66,38 $ | |\n| | | | | | | | | | | | | | | | | | |\n| Carreleur | 50,12 $ | 6,52 $ | 8,470 $ | 0,29 $ | 0,60 $ | 1,03 $ | 0,34 $ | 3,66 $ | 2,54 $ | 0,42 $ | 0,03 $ | 0,02 $ | 0,20 $ | 3,63 $ | 0,03 $ | 77,90 $ | |\n| apprenti - période 1 | 30,07 $ | 3,91 $ | 7,550 $ | 0,29 $ | 0,60 $ | 0,62 $ | 0,20 $ | 2,23 $ | 1,58 $ | 0,25 $ | 0,03 $ | 0,02 $ | 0,20 $ | 2,25 $ | 0,03 $ | 49,83 $ | |\n| apprenti - période 2 | 35,08 $ | 4,56 $ | 7,550 $ | 0,29 $ | 0,60 $ | 0,72 $ | 0,24 $ | 2,59 $ | 1,82 $ | 0,30 $ | 0,03 $ | 0,02 $ | 0,20 $ | 2,60 $ | 0,03 $ | 56,63 $ | |\n| apprenti - période 3 | 42,60 $ | 5,54 $ | 7,550 $ | 0,29 $ | 0,60 $ | 0,88 $ | 0,29 $ | 3,12 $ | 2,18 $ | 0,36 $ | 0,03 $ | 0,02 $ | 0,20 $ | 3,11 $ | 0,03 $ | 66,80 $ | |\n| | | | | | | | | | | | | | | | | | |\n| Charpentier-menuisier | 50,16 $ | 6,52 $ | 8,330 $ | 0,28 $ | 0,75 $ | 1,03 $ | 0,34 $ | 3,65 $ | 2,54 $ | 0,43 $ | 0,03 $ | 0,02 $ | 0,20 $ | 3,63 $ | 0,03 $ | 77,94 $ | |\n| apprenti - période 1 | 30,10 $ | 3,91 $ | 7,410 $ | 0,28 $ | 0,75 $ | 0,62 $ | 0,20 $ | 2,22 $ | 1,57 $ | 0,26 $ | 0,03 $ | 0,02 $ | 0,20 $ | 2,25 $ | 0,03 $ | 49,85 $ | |\n| apprenti - période 2 | 35,11 $ | 4,56 $ | 7,410 $ | 0,28 $ | 0,75 $ | 0,72 $ | 0,24 $ | 2,58 $ | 1,82 $ | 0,30 $ | 0,03 $ | 0,02 $ | 0,20 $ | 2,59 $ | 0,03 $ | 56,64 $ | |\n| apprenti - période 1 | 36,10 $ | 3,91 $ | 7,410 $ | 0,28 $ | 0,75 $ | 0,62 $ | 0,20 $ | 2,22 $ | 1,97 $ | 0,28 $ | 0,03 $ | 0,02 $ | 0,20 $ | 2,23 $ | 0,03 $ | 49,83 $ | |\n| apprenti - période 2 | 35,11 $ | 4,56 $ | 7,410 $ | 0,28 $ | 0,75 $ | 0,72 $ | 0,24 $ | 2,58 $ | 1,82 $ | 0,30 $ | 0,03 $ | 0,02 $ | 0,20 $ | 2,59 $ | 0,03 $ | 56,64 $ | |\n| apprenti - période 3 | 42,64 $ | 5,54 $ | 7,410 $ | 0,28 $ | 0,75 $ | 0,88 $ | 0,29 $ | 3,12 $ | 2,18 $ | 0,36 $ | 0,03 $ | 0,02 $ | 0,20 $ | 3,11 $ | 0,03 $ | 66,84 $ | |\n| | | | | | | | | | | | | | | | | | |\n| Chaudronnier | 50,79 $ | 6,60 $ | 8,640 $ | 0,31 $ | 0,60 $ | 1,04 $ | 0,35 $ | 3,71 $ | 2,58 $ | 0,43 $ | 0,03 $ | 0,02 $ | 0,20 $ | 1,76 $ | 0,06 $ | 77,12 $ | |\n| apprenti - période 1 | 30,47 $ | 3,96 $ | 7,720 $ | 0,31 $ | 0,60 $ | 0,63 $ | 0,21 $ | 2,26 $ | 1,60 $ | 0,26 $ | 0,03 $ | 0,02 $ | 0,20 $ | 1,09 $ | 0,06 $ | 49,42 $ | |\n| apprenti - période 2 | 35,55 $ | 4,62 $ | 7,720 $ | 0,31 $ | 0,60 $ | 0,73 $ | 0,24 $ | 2,63 $ | 1,85 $ | 0,30 $ | 0,03 $ | 0,02 $ | 0,20 $ | 1,26 $ | 0,06 $ | 56,12 $ | |\n| apprenti - période 3 | 43,17 $ | 5,61 $ | 7,720 $ | 0,31 $ | 0,60 $ | 0,89 $ | 0,29 $ | 3,17 $ | 2,21 $ | 0,37 $ | 0,03 $ | 0,02 $ | 0,20 $ | 1,51 $ | 0,06 $ | 66,16 $ | |\n| | | | | | | | | | | | | | | | | | |\n| Cimentier-applicateur | 48,55 $ | 6,31 $ | 8,470 $ | 0,29 $ | 0,60 $ | 1,00 $ | 0,33 $ | 3,54 $ | 2,47 $ | 0,41 $ | 0,03 $ | 0,02 $ | 0,20 $ | 4,18 $ | 0,03 $ | 76,43 $ | |\n| Cimentier-applicateur | 48,55$ | 6,31$ | 8,470$ | 0,29$ | 0,60$ | 1,00$ | 0,33$ | 3,54$ | 2,47$ | 0,41$ | 0,03$ | 0,02$ | 0,20$ | 4,18$ | 0,03$ | 76,43 | |\n| apprenti - période 1 | 33,99$ | 4,42$ | 7,550$ | 0,29$ | 0,60$ | 0,70$ | 0,23$ | 2,51$ | 1,77$ | 0,29$ | 0,03$ | 0,02$ | 0,20$ | 3,00$ | 0,03$ | 55,63 | |\n| apprenti - période 2 | 41,27$ | 5,37$ | 7,550$ | 0,29$ | 0,60$ | 0,85$ | 0,28$ | 3,03$ | 2,12$ | 0,35$ | 0,03$ | 0,02$ | 0,20$ | 3,59$ | 0,03$ | 65,58 | |\n| | | | | | | | | | | | | | | | | | |\n| Coffreur à béton | 50,16$ | 6,52$ | 8,330$ | 0,28$ | 0,75$ | 1,03$ | 0,34$ | 3,65$ | 2,54$ | 0,43$ | 0,03$ | 0,02$ | 0,20$ | 3,63$ | 0,03$ | 77,94 | |\n| apprenti - période 1 | 30,10$ | 3,91$ | 7,410$ | 0,28$ | 0,75$ | 0,62$ | 0,20$ | 2,22$ | 1,57$ | 0,26$ | 0,03$ | 0,02$ | 0,20$ | 2,25$ | 0,03$ | 49,85 | |\n| apprenti - période 2 | 35,11$ | 4,56$ | 7,410$ | 0,28$ | 0,75$ | 0,72$ | 0,24$ | 2,58$ | 1,82$ | 0,30$ | 0,03$ | 0,02$ | 0,20$ | 2,59$ | 0,03$ | 56,64 | |\n| | | | | | | | | | | | | | | | | | |\n| Couvreur | 51,20$ | 6,66$ | 8,763$ | 0,32$ | 0,75$ | 1,05$ | 0,35$ | 3,75$ | 2,61$ | 0,43$ | 0,03$ | 0,02$ | 0,20$ | 4,84$ | 0,03$ | 81,00 | |\n| apprenti - période 1 | 35,84$ | 4,66$ | 7,843$ | 0,32$ | 0,75$ | 0,74$ | 0,24$ | 2,65$ | 1,87$ | 0,30$ | 0,03$ | 0,02$ | 0,20$ | 3,47$ | 0,03$ | 58,97 | |\n| apprenti - période 2 | 43,52$ | 5,66$ | 7,843$ | 0,32$ | 0,75$ | 0,90$ | 0,30$ | 3,20$ | 2,24$ | 0,37$ | 0,03$ | 0,02$ | 0,20$ | 4,16$ | 0,03$ | 69,54 | |\n| | | | | | | | | | | | | | | | | | |\n| Électricien | 50,79$ | 6,60$ | 8,875$ | 0,33$ | 0,80$ | 1,04$ | 0,35$ | 3,72$ | 2,59$ | 0,43$ | 0,03$ | 0,02$ | 0,20$ | 1,93$ | 1,53$ | 79,23 | |\n| apprenti - période 1 | 25,40$ | 3,30$ | 7,955$ | 0,33$ | 0,80$ | 0,52$ | 0,17$ | 1,91$ | 1,37$ | 0,22$ | 0,03$ | 0,02$ | 0,20$ | 1,02$ | 0,81$ | 44,06 | |\n| apprenti - période 2 | 30,47$ | 3,96$ | 7,955$ | 0,33$ | 0,80$ | 0,63$ | 0,21$ | 2,28$ | 1,61$ | 0,26$ | 0,03$ | 0,02$ | 0,20$ | 1,20$ | 0,95$ | 50,90 | |\n| apprenti - période 3 | 35,55$ | 4,62$ | 7,955$ | 0,33$ | 0,80$ | 0,73$ | 0,24$ | 2,64$ | 1,86$ | 0,30$ | 0,03$ | 0,02$ | 0,20$ | 1,38$ | 1,10$ | 57,75 | |\n| apprenti - période 4 | 43,17$ | 5,61$ | 7,955$ | 0,33$ | 0,80$ | 0,89$ | 0,29$ | 3,18$ | 2,22$ | 0,37$ | 0,03$ | 0,02$ | 0,20$ | 1,65$ | 1,31$ | 68,03 | |\n\n---\n\n**COÛT HORAIRE DE LA MAIN-D'OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 Coûts horaires de la main-d'œuvre - 26 avril 2026**\nsecteur COMMERCIAL, INSTITUTIONNEL ET INDUSTRIEL\n\n| Valeur Unité Métiers et spécialités | secteur COMMERCIAL, INSTITUTIONNEL ET INDUSTRIEL | | | | | | | | | | | | | | | | | | |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| Valeur Unité Métiers et spécialités | Taux horaire | Indemnité vacances 13% % | Avantages sociaux 1 heure | Taxe sur assur. 1 heure | Équip. De sécur. 1 heure | | A-E RQAP 1,820% 0,602% % % | | | RRQ 6,300% % | FSS | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | Fonds d&#x27;indemn. | Fonds de formation 0,20 $ heure | CNESST | ACQ +Autres contributions | Total |\n| Valeur Unité Métiers et spécialités | Taux horaire | Indemnité vacances 13% % | Avantages sociaux 1 heure | Taxe sur assur. 1 heure | Équip. De sécur. 1 heure | | A-E RQAP 1,820% 0,602% % % | | | RRQ 6,300% % | 4,26% | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | 0,02 $ | Fonds de formation 0,20 $ heure | CNESST | | Total |\n| Valeur Unité Métiers et spécialités | Taux horaire | Indemnité vacances 13% % | Avantages sociaux 1 heure | Taxe sur assur. 1 heure | Équip. De sécur. 1 heure | | A-E RQAP 1,820% 0,602% % % | | | RRQ 6,300% % | % | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | heure | Fonds de formation 0,20 $ heure | CNESST | | Total |\n| | | | | | | | | | | | | | | | | | | | |\n| Ferblantier | 50,79 $ | 6,60 $ | 8,400 $ | 0,29 $ | 0,65 $ | | 1,04 $ | | 0,35 $ | 3,70 $ | 2,57 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,26 $ | 0,03 $ | 78,36 |\n| apprenti - période 1 | 30,47 $ | 3,96 $ | 7,480 $ | 0,29 $ | 0,65 $ | | 0,63 $ | | 0,21 $ | 2,25 $ | 1,59 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,02 $ | 0,03 $ | 50,09 |\n| apprenti - période 2 apprenti - période 3 | 35,55 $ 43,17 $ | 4,62 $ 5,61 $ | 7,480 $ 7,480 $ | 0,29 $ 0,29 $ | 0,65 $ 0,65 $ | | 0,73 $ 0,89 $ | | 0,24 $ 0,29 $ | 2,61 $ 3,16 $ | 1,84 $ 2,21 $ | 0,30 $ 0,37 $ | | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ 0,20 $ | 2,33 $ 2,79 $ | 0,03 $ 0,03 $ | 56,92 67,19 |\n| | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | |\n| Ferrailleur apprenti - période 1 | 51,31 $ 43,61 $ | 6,67 $ 5,67 $ | 8,140 $ 7,220 $ | 0,26 $ 0,26 $ | 1,25 $ 1,25 $ | | 1,06 $ 0,90 $ | | 0,35 $ 0,30 $ | 3,72 $ 3,17 $ | 2,59 $ 2,22 $ | 0,43 $ 0,37 $ | | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ 0,20 $ | 4,39 $ 3,76 $ | 0,03 $ 0,03 $ | 80,45 69,01 |\n| | | | | | | | | | | | | | | | | | | | |\n| Frigoriste | 50,79 $ | 6,60 $ | 8,800 $ | 0,32 $ | 0,60 $ | | 1,04 $ | | 0,35 $ | 3,72 $ | 2,58 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,69 $ | 0,03 $ | 77,20 |\n| apprenti - période 1 | 25,40 $ | 3,30 $ | 7,882 $ | 0,32 $ | 0,60 $ | | 0,52 $ | | 0,17 $ | 1,91 $ | 1,36 $ | 0,22 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,89 $ | 0,03 $ | 42,85 |\n| apprenti - période 2 | 30,47 $ | 3,96 $ | 7,882 $ | 0,32 $ | 0,60 $ | | 0,63 $ | | 0,21 $ | 2,27 $ | 1,61 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,05 $ | 0,03 $ | 49,54 |\n| apprenti - période 3 | 35,55 $ 43,17 $ | 4,62 $ 5,61 $ | 7,882 $ 7,882 $ | 0,32 $ 0,32 $ | 0,60 $ 0,60 $ | | 0,73 $ 0,89 $ | | 0,24 $ 0,29 $ | 2,63 $ 3,17 $ | 1,85 $ 2,22 $ | 0,30 $ 0,37 $ | | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ 0,20 $ | 1,21 $ 1,45 $ | 0,03 $ 0,03 $ | 56,21 66,26 |\n| | | | | | | | | | | | | | | | | | | | |\n| Grutier - Classe A | 50,79 $ | 6,60 $ | 8,140 $ | 0,26 $ | 0,60 $ | | 1,04 $ | | 0,35 $ | 3,69 $ | 2,56 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,20 $ | 0,03 $ | 76,94 |\n| | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 1 | 35,55 $ | 4,62 $ | 7,220 $ | 0,26 $ | 0,60 $ | | 0,73 $ | | 0,24 $ | 2,60 $ | 1,83 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,57 $ | 0,03 $ | 55,80 |\n| apprenti - période 2 | 43,17 $ | 5,61 $ | 7,220 $ | 0,26 $ | 0,60 $ | | 0,89 $ | | 0,29 $ | 3,14 $ | 2,20 $ | 0,37 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,88 $ | 0,03 $ | 65,91 |\n| | | | | | | | | | | | | | | | | | | | |\n| Grutier - Classe B | 48,58 $ | 6,32 $ | 8,140 $ | 0,26 $ | 0,60 $ | | 1,00 $ | | 0,33 $ | 3,53 $ | 2,46 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,11 $ | 0,03 $ | 74,02 |\n| apprenti - période 1 | 34,01 $ | 4,42 $ | 7,220 $ | 0,26 $ | 0,60 $ | | 0,70 $ | | 0,23 $ | 2,49 $ | 1,76 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,50 $ | 0,03 $ | 53,76 |\n| | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 2 | 41,29 $ | 5,37 $ | 7,220 $ | 0,26 $ | 0,60 $ | | 0,85 $ | | 0,28 $ | 3,01 $ | 2,11 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,80 $ | 0,03 $ | 63,42 |\n| | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | |\n| Installateur de syst. de sécurité | 41,66 $ | 5,42 $ | 8,865 $ | 0,33 $ | 0,60 $ | | 0,86 $ | | 0,28 $ | 3,07 $ | 2,15 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,05 $ | 0,03 $ | 64,92 |\n| apprenti - période 1 | 25,00 $ | 3,25 $ | 7,945 $ | 0,33 $ | 0,60 $ | | 0,51 $ | | 0,17 $ | 1,89 $ | 1,35 $ | 0,21 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,66 $ | 0,03 $ | 42,20 |\n| | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 2 | 29,16 $ | 3,79 $ | 7,945 $ | 0,33 $ | 0,60 $ | | 0,60 $ | | 0,20 $ | 2,18 $ | 1,55 $ | 0,25 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,76 $ | 0,03 $ | 47,64 |\n| | | | | | | | | | | | | | | | | | | | |\n| 35,41 $ 4,60 $ 7,945 $ 0,33 $ 0,60 $ 0,73 $ 0,24 $ 2,63 $ 1,85 $ 0,30 $ 0,03 $ 0,02 $ 0,20 $ 0,90 $ 0,03 $ 55,82 apprenti - période 3 | | | | | | | | | | | | | | | | | | | |\n| 35,41 $ 4,60 $ 7,945 $ 0,33 $ 0,60 $ 0,73 $ 0,24 $ 2,63 $ 1,85 $ 0,30 $ 0,03 $ 0,02 $ 0,20 $ 0,90 $ 0,03 $ 55,82 apprenti - période 3 | | | | | | | | | | | | | | | | | | | |\n| Mécanicien d&#x27;ascenseurs | 56,70 $ | 7,37 $ | 8,140 $ | 0,26 $ | 0,60 $ | | 1,17 $ | | 0,39 $ | 4,11 $ | 2,85 $ | 0,48 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,03 $ | 0,03 $ | 84,38 |\n| apprenti - période 1 | 28,35 $ | 3,69 $ | 7,220 $ | 0,26 $ | 0,60 $ | | 0,58 $ | | 0,19 $ | 2,09 $ | 1,48 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,06 $ | 0,03 $ | 46,04 |\n| | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 2 | 34,02 $ | 4,42 $ | 7,220 $ | 0,26 $ | 0,60 $ | | 0,70 $ | | 0,23 $ | 2,49 $ | 1,76 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,25 $ | 0,03 $ | 53,52 |\n| apprenti - période 3 | 39,69 $ | 5,16 $ | 7,220 $ | 0,26 $ | 0,60 $ | | 0,82 $ | | 0,27 $ | 2,90 $ | 2,03 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,44 $ | 0,03 $ | 61,01 |\n| apprenti - période 4 | 48,20 $ | 6,27 $ | 7,220 $ | 0,26 $ | 0,60 $ | | 0,99 $ | | 0,33 $ | 3,50 $ | 2,44 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,73 $ | 0,03 $ | 72,23 |\n| 48,20 $ 6,27 $ 7,220 $ 0,26 $ 0,60 $ 0,99 $ 0,33 $ 3,50 $ 2,44 $ 0,41 $ 0,03 $ 0,02 $ 0,20 $ 1,73 $ 0,03 $ 72,23 apprenti - période 5 | | | | | | | | | | | | | | | | | | | |\n| Mécanicien de chantier | 50,79 $ | 6,60 $ | 8,695 $ | 0,31 $ | 0,60 $ | | 1,04 $ | | 0,35 $ | 3,71 $ | 2,58 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,84 $ | 0,07 $ | 77,26 |\n| apprenti - période 1 | 30,47 $ | 3,96 $ | 7,775 $ | 0,31 $ | 0,60 $ | | 0,63 $ | | 0,21 $ | 2,27 $ | 1,60 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,14 $ | 0,07 $ | 49,54 |\n| apprenti - période 2 | 35,55 $ | 4,62 $ | 7,775 $ | 0,31 $ | 0,60 $ | | 0,73 $ | | 0,24 $ | 2,63 $ | 1,85 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,32 $ | 0,07 $ | 56,25 |\n| apprenti - période 3 | 43,17 $ | 5,61 $ | 7,775 $ | 0,31 $ | 0,60 $ | | 0,89 $ | | 0,29 $ | 3,17 $ | 2,22 $ | 0,37 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,58 $ | 0,07 $ | 66,31 |\n| Méc. de machines lourdes | 48,94 $ | 6,36 $ | 8,750 $ | 0,32 $ | 0,75 $ | | 1,01 $ | | 0,33 $ | 3,58 $ | 2,50 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,77 $ | 0,03 $ | 75,00 |\n| apprenti - période 1 | 29,36 $ | 3,82 $ | 7,830 $ | 0,29 $ | 0,75 $ | | 0,60 $ | | 0,20 $ | 2,19 $ | 1,55 $ | 0,25 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,10 $ | 0,03 $ | 48,22 |\n| apprenti - période 2 | 34,26 $ | 4,45 $ | 7,830 $ | 0,29 $ | 0,75 $ | | 0,70 $ | | 0,23 $ | 2,54 $ | 1,79 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,27 $ | 0,03 $ | 54,68 |\n| apprenti - période 3 | 41,60 $ | 5,41 $ | 7,830 $ | 0,29 $ | 0,75 $ | | 0,86 $ | | 0,28 $ | 3,06 $ | 2,14 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,52 $ | 0,03 $ | 64,37 |\n\n---\n\n**COÛT HORAIRE DE LA MAIN-D'OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 Coûts horaires de la main-d'œuvre - 26 avril 2026**\nsecteur COMMERCIAL, INSTITUTIONNEL ET INDUSTRIEL\n\n| Valeur Unité Métiers et spécialités | secteur COMMERCIAL, INSTITUTIONNEL ET INDUSTRIEL | | | | | | | | | | | | | | | | |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| Valeur Unité Métiers et spécialités | Taux horaire | Indemnité vacances 13% % | Avantages sociaux 1 heure | | Équip. De sécur. | A-E 1,820% % | RQAP 0,602% % | RRQ 6,300% % | FSS 4,26% % | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | Fonds d&#x27;indemn. | Fonds de formation 0,20 $ heure | CNESST | ACQ +Autres contributions | Total |\n| Valeur Unité Métiers et spécialités | Taux horaire | Indemnité vacances 13% % | Avantages sociaux 1 heure | | 1 | A-E 1,820% % | RQAP 0,602% % | RRQ 6,300% % | FSS 4,26% % | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | 0,02 $ | Fonds de formation 0,20 $ heure | CNESST | ACQ +Autres contributions | Total |\n| Valeur Unité Métiers et spécialités | Taux horaire | Indemnité vacances 13% % | Avantages sociaux 1 heure | | heure | A-E 1,820% % | RQAP 0,602% % | RRQ 6,300% % | FSS 4,26% % | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | heure | Fonds de formation 0,20 $ heure | CNESST | ACQ +Autres contributions | Total |\n| | | | | | | | | | | | | | | | | | |\n| Méc. protection-incendie | 50,79 $ | 6,60 $ | 8,822 $ | 0,32 $ | 0,60 $ | 1,04 $ | 0,35 $ | 3,72 $ | 2,59 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,84 $ | 0,03 $ | 77,38 |\n| apprenti - période 1 | 25,40 $ | 3,30 $ | 7,902 $ | 0,32 $ | 0,60 $ | 0,52 $ | 0,17 $ | 1,91 $ | 1,36 $ | 0,22 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,97 $ | 0,03 $ | 42,95 |\n| apprenti - période 2 | 30,47 $ | 3,96 $ | 7,902 $ | 0,32 $ | 0,60 $ | 0,63 $ | 0,21 $ | 2,27 $ | 1,61 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,14 $ | 0,03 $ | 49,65 |\n| apprenti - période 3 | 35,55 $ | 4,62 $ | 7,902 $ | 0,32 $ | 0,60 $ | 0,73 $ | 0,24 $ | 2,63 $ | 1,85 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,32 $ | 0,03 $ | 56,34 |\n| | | | | | | | | | | | | | | | | | |\n| Monteur-assembleur | 51,31 $ | 6,67 $ | 8,140 $ | 0,26 $ | 1,30 $ | 1,06 $ | 0,35 $ | 3,72 $ | 2,59 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 4,42 $ | 0,06 $ | 80,56 |\n| apprenti - période 1 | 30,79 $ | 4,00 $ | 7,220 $ | 0,26 $ | 1,30 $ | 0,63 $ | 0,19 $ | 2,26 $ | 1,60 $ | 0,18 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,74 $ | 0,06 $ | 51,48 |\n| apprenti - période 2 | 35,92 $ | 4,67 $ | 7,220 $ | 0,26 $ | 1,30 $ | 0,74 $ | 0,24 $ | 2,63 $ | 1,85 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,16 $ | 0,06 $ | 58,60 |\n| | | | | | | | | | | | | | | | | | |\n| Monteur-mécanicien (vitrier) | 49,60 $ | 6,45 $ | 8,390 $ | 0,28 $ | 0,60 $ | 1,02 $ | 0,34 $ | 3,60 $ | 2,51 $ | 0,42 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 4,17 $ | 0,03 $ | 77,66 |\n| apprenti - période 1 | 29,76 $ | 3,87 $ | 7,470 $ | 0,28 $ | 0,60 $ | 0,61 $ | 0,20 $ | 2,19 $ | 1,55 $ | 0,25 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,58 $ | 0,03 $ | 49,64 |\n| apprenti - période 2 | 34,72 $ | 4,51 $ | 7,470 $ | 0,28 $ | 0,60 $ | 0,71 $ | 0,24 $ | 2,54 $ | 1,79 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,98 $ | 0,03 $ | 56,41 |\n| 42,16 $ 5,48 $ 7,470 $ 0,28 $ 0,60 $ 0,87 $ 0,29 $ 3,07 $ 2,15 $ 0,36 $ 0,03 $ 0,02 $ 0,20 $ 3,57 $ 0,03 $ 66,58 apprenti - période 3 | | | | | | | | | | | | | | | | | |\n| 42,16 $ 5,48 $ 7,470 $ 0,28 $ 0,60 $ 0,87 $ 0,29 $ 3,07 $ 2,15 $ 0,36 $ 0,03 $ 0,02 $ 0,20 $ 3,57 $ 0,03 $ 66,58 apprenti - période 3 | | | | | | | | | | | | | | | | | |\n| Op. d&#x27;équip. l. - Classe AA | 48,23 $ | 6,27 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,99 $ | 0,33 $ | 3,53 $ | 2,46 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,17 $ | 0,03 $ | 74,49 |\n| apprenti - période 1 | | | | | | | | | | | | | | | | | |\n| apprenti - période 1 | 41,00 $ | 5,33 $ | 7,830 $ | 0,32 $ | 0,75 $ | 0,84 $ | 0,28 $ | 3,02 $ | 2,11 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,87 $ | 0,03 $ | 63,98 |\n| apprenti - période 1 | | | | | | | | | | | | | | | | | |\n| apprenti - période 1 | | | | | | | | | | | | | | | | | |\n| Op. d&#x27;équip. l. - Classe A | 46,69 $ | 6,07 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,96 $ | 0,32 $ | 3,42 $ | 2,39 $ | 0,40 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,11 $ | 0,03 $ | 72,46 |\n| apprenti - période 1 | 39,69 $ | 5,16 $ | 7,830 $ | 0,32 $ | 0,75 $ | 0,82 $ | 0,27 $ | 2,93 $ | 2,05 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,81 $ | 0,03 $ | 62,25 |\n| | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | |\n| Op. d&#x27;équip. l. - Classe B | 45,49 $ | 5,91 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,94 $ | 0,31 $ | 3,34 $ | 2,33 $ | 0,39 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,06 $ | 0,03 $ | 70,87 |\n| 38,67 $ 5,03 $ 7,830 $ 0,32 $ 0,75 $ 0,80 $ 0,26 $ 2,85 $ 2,00 $ 0,33 $ 0,03 $ 0,02 $ 0,20 $ 1,77 $ 0,03 $ 60,89 apprenti - période 1 | | | | | | | | | | | | | | | | | |\n| 38,67 $ 5,03 $ 7,830 $ 0,32 $ 0,75 $ 0,80 $ 0,26 $ 2,85 $ 2,00 $ 0,33 $ 0,03 $ 0,02 $ 0,20 $ 1,77 $ 0,03 $ 60,89 apprenti - période 1 | | | | | | | | | | | | | | | | | |\n| 38,67 $ 5,03 $ 7,830 $ 0,32 $ 0,75 $ 0,80 $ 0,26 $ 2,85 $ 2,00 $ 0,33 $ 0,03 $ 0,02 $ 0,20 $ 1,77 $ 0,03 $ 60,89 apprenti - période 1 | | | | | | | | | | | | | | | | | |\n| Op. de pelles méc. - Cl. AA | 51,51 $ | 6,70 $ | 8,750 $ | 0,32 $ | 0,75 $ | 1,06 $ | 0,35 $ | 3,77 $ | 2,62 $ | 0,44 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,31 $ | 0,03 $ | 78,86 |\n| apprenti - période 1 | 43,78 $ | 5,69 $ | 7,830 $ | 0,32 $ | 0,75 $ | 0,90 $ | 0,30 $ | 3,22 $ | 2,25 $ | 0,37 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,98 $ | 0,03 $ | 67,67 |\n| | | | | | | | | | | | | | | | | | |\n| Op. de pelles méc. - Cl. A | 49,92 $ | 6,49 $ | 8,750 $ | 0,32 $ | 0,75 $ | 1,03 $ | 0,34 $ | 3,65 $ | 2,54 $ | 0,42 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,24 $ | 0,03 $ | 76,73 |\n| | | | | | | | | | | | | | | | | | |\n| apprenti - période 1 | 42,43 $ | 5,52 $ | 7,830 $ | 0,32 $ | 0,75 $ | 0,87 $ | 0,29 $ | 3,12 $ | 2,18 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,93 $ | 0,03 $ | 65,88 |\n| Op. de pelles méc. - Cl. B | 48,32 $ | 6,28 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,99 $ | 0,33 $ | 3,54 $ | 2,47 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,18 $ | 0,03 $ | 74,62 |\n| 41,07 $ 5,34 $ 7,830 $ 0,32 $ 0,75 $ 0,84 $ 0,28 $ 3,02 $ 2,12 $ 0,35 $ 0,03 $ 0,02 $ 0,20 $ 1,87 $ 0,03 $ 64,07 apprenti - période 1 | | | | | | | | | | | | | | | | | |\n| Op. de pompes à béton - 42 mètres | 45,89 $ | 5,97 $ | 8,140 $ | 0,26 $ | 0,60 $ | 0,94 $ | 0,31 $ | 3,34 $ | 2,33 $ | 0,39 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,95 $ | 0,03 $ | 72,40 |\n| apprenti - période 1 | 39,01 $ | 5,07 $ | 7,220 $ | 0,26 $ | 0,60 $ | 0,80 $ | 0,27 $ | 2,85 $ | 2,00 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,38 $ | 0,03 $ | 62,07 |\n| Op. de pompes à béton + 42 mètres | 47,89 $ | 6,23 $ | 8,140 $ | 0,26 $ | 0,60 $ | 0,98 $ | 0,33 $ | 3,48 $ | 2,42 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 4,11 $ | 0,03 $ | 75,01 |\n| | | | | | | | | | | | | | | | | | |\n| 50,16 $ 6,52 $ 8,330 $ 0,28 $ 0,75 $ 1,03 $ 0,34 $ 3,65 $ 2,54 $ 0,30 $ 0,03 $ 0,02 $ 0,20 $ 3,63 $ 0,03 $ 77,81 Parqueteur-sableur | | | | | | | | | | | | | | | | | |\n| apprenti - période 1 | 30,10 $ | 3,91 $ | 7,410 $ | 0,28 $ | 0,75 $ | 0,62 $ | 0,20 $ | 2,22 $ | 1,57 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,25 $ | 0,03 $ | 49,85 |\n| apprenti - période 2 | 35,11 $ | 4,56 $ | 7,410 $ | 0,28 $ | 0,75 $ | 0,72 $ | 0,24 $ | 2,58 $ | 1,82 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,59 $ | 0,03 $ | 56,64 |\n| apprenti - période 3 | 42,64 $ | 5,54 $ | 7,410 $ | 0,28 $ | 0,75 $ | 0,88 $ | 0,29 $ | 3,12 $ | 2,18 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,11 $ | 0,03 $ | 66,84 |\n\n---\n\n**COÛT HORAIRE DE LA MAIN-D'OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 Coûts horaires de la main-d'œuvre - 26 avril 2026**\n**secteur COMMERCIAL, INSTITUTIONNEL ET INDUSTRIEL**\n Taxe sur Équip. De Prélèv.\n\n| Valeur Unité | Taux horaire | Indemnité vacances | Avantages sociaux 1 heure | Taxe sur assur. 1 heure | Équip. De sécur. | | A-E 1,820% | | RQAP 0,602% % | | RRQ 6,300% % | FSS 4,26% | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | Fonds d&#x27;indemn. | Fonds de formation 0,20 $ heure | CNESST | ACQ +Autres contributions | Total |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| Valeur Unité | Taux horaire | 13% % | Avantages sociaux 1 heure | Taxe sur assur. 1 heure | 1 heure | | A-E 1,820% | | RQAP 0,602% % | | RRQ 6,300% % | FSS 4,26% | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | 0,02 $ heure | Fonds de formation 0,20 $ heure | | ACQ +Autres contributions | Total |\n| % % Métiers et spécialités | | | | | | | | | | | | | | | | | | | | |\n| Peintre | 47,39 $ | 6,16 $ | 8,340 $ | 0,28 $ | 0,50 $ | | 0,97 $ | | 0,32 $ | | 3,46 $ | 2,41 $ | 0,40 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,44 $ | 0,03 $ | 73,95 |\n| apprenti - période 1 | 28,43 $ | 3,70 $ | 7,420 $ | 0,28 $ | 0,50 $ | | 0,58 $ | | 0,19 $ | | 2,11 $ | 1,50 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,13 $ | 0,03 $ | 47,36 |\n| apprenti - période 2 | 33,17 $ | 4,31 $ | 7,420 $ | 0,28 $ | 0,50 $ | | 0,68 $ | | 0,23 $ | | 2,44 $ | 1,72 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,46 $ | 0,03 $ | 53,77 |\n| apprenti - période 3 | 40,28 $ | 5,24 $ | 7,420 $ | 0,28 $ | 0,50 $ | | 0,83 $ | | 0,27 $ | | 2,95 $ | 2,07 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,95 $ | 0,03 $ | 63,41 |\n| Plâtrier | 48,26 $ | 6,27 $ | 8,470 $ | 0,29 $ | 0,60 $ | | 0,99 $ | | 0,33 $ | | 3,52 $ | 2,45 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,50 $ | 0,03 $ | 75,37 |\n| apprenti - période 1 | 28,96 $ | 3,76 $ | 7,550 $ | 0,29 $ | 0,60 $ | | 0,60 $ | | 0,20 $ | | 2,15 $ | 1,53 $ | 0,25 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,18 $ | 0,03 $ | 48,35 |\n| apprenti - période 2 | 33,78 $ | 4,39 $ | 7,550 $ | 0,29 $ | 0,60 $ | | 0,69 $ | | 0,23 $ | | 2,49 $ | 1,76 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,51 $ | 0,03 $ | 54,86 |\n| apprenti - période 3 | 41,02 $ | 5,33 $ | 7,550 $ | 0,29 $ | 0,60 $ | | 0,84 $ | | 0,28 $ | | 3,01 $ | 2,11 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,01 $ | 0,03 $ | 64,67 |\n| | | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 1 | 29,44 $ | 3,83 $ | 7,470 $ | 0,29 $ | 0,60 $ | | 0,61 $ | | 0,20 $ | | 2,18 $ | 1,55 $ | 0,25 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,21 $ | 0,03 $ | 48,91 |\n| apprenti - période 2 | 34,35 $ | 4,47 $ | 7,470 $ | 0,29 $ | 0,60 $ | | 0,71 $ | | 0,23 $ | | 2,53 $ | 1,78 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,54 $ | 0,03 $ | 55,54 |\n| apprenti - période 3 | 41,71 $ | 5,42 $ | 7,470 $ | 0,29 $ | 0,60 $ | | 0,86 $ | | 0,28 $ | | 3,05 $ | 2,14 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,05 $ | 0,03 $ | 65,50 |\n| | | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | | |\n| Poseur de systèmes int. | 50,16 $ | 6,52 $ | 8,310 $ | 0,28 $ | 0,75 $ | | 1,03 $ | | 0,34 $ | | 3,65 $ | 2,54 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,62 $ | 0,18 $ | 78,06 |\n| | | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 1 | 30,10 $ | 3,91 $ | 7,390 $ | 0,28 $ | 0,75 $ | | 0,62 $ | | 0,20 $ | | 2,22 $ | 1,57 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,25 $ | 0,18 $ | 49,98 |\n| apprenti - période 2 | 35,11 $ | 4,56 $ | 7,390 $ | 0,28 $ | 0,75 $ | | 0,72 $ | | 0,24 $ | | 2,58 $ | 1,81 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,59 $ | 0,18 $ | 56,76 |\n| | | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 3 | 42,64 $ | 5,54 $ | 7,390 $ | 0,28 $ | 0,75 $ | | 0,88 $ | | 0,29 $ | | 3,11 $ | 2,18 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,11 $ | 0,18 $ | 66,96 |\n| | | | | | | | | | | | | | | | | | | | | |\n| 0,03 $ 74,19 47,39 $ 6,16 $ 8,470 $ 0,29 $ 0,60 $ 0,97 $ 0,32 $ 3,46 $ 2,41 $ 0,40 $ 0,03 $ 0,02 $ 0,20 $ 3,44 $ Tireur de joints (plâtrier) | | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 1 | 28,43 $ | 3,70 $ | 7,550 $ | 0,29 $ | 0,60 $ | | 0,58 $ | | 0,19 $ | | 2,11 $ | 1,50 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,14 $ | 0,03 $ | 47,61 |\n| | | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 2 | 33,17 $ | 4,31 $ | 7,550 $ | 0,29 $ | 0,60 $ | | 0,68 $ | | 0,23 $ | | 2,45 $ | 1,73 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,47 $ | 0,03 $ | 54,04 |\n| apprenti - période 3 | 40,28 $ | 5,24 $ | 7,550 $ | 0,29 $ | 0,60 $ | | 0,83 $ | | 0,27 $ | | 2,96 $ | 2,07 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,96 $ | 0,03 $ | 63,67 |\n| 47,51 $ 6,18 $ 8,340 $ 0,28 $ 0,65 $ 0,98 $ 0,32 $ 3,46 $ 2,41 $ 0,40 $ 0,03 $ 0,02 $ 0,20 $ 3,45 $ 0,03 $ 74,26 Tireur de joints (peintre) | | | | | | | | | | | | | | | | | | | | |\n| | 28,51 $ | 3,71 $ | 7,420 $ | 0,28 $ | 0,65 $ | | 0,59 $ | | 0,19 $ | | 2,11 $ | 1,50 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,14 $ | 0,03 $ | 47,62 |\n| apprenti - période 1 | | | | | | | | | | | | | | | | | | | | |\n| apprenti - période 2 apprenti - période 3 | 40,38 $ | 5,25 $ | 7,420 $ | 0,28 $ | 0,65 $ | | 0,83 $ | | 0,27 $ | | 2,96 $ | 2,07 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,96 $ | 0,03 $ | 63,69 |\n| | | | | | | | | | | | | | | | | | | | | |\n| Tuyauteur | 50,79 $ | 6,60 $ | 8,902 $ | 0,33 $ | 0,75 $ | | 1,04 $ | | 0,35 $ | | 3,72 $ | 2,59 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,84 $ | 0,03 $ | 77,62 |\n| apprenti - période 1 | 25,40 $ | 3,30 $ | 7,982 $ | 0,33 $ | 0,75 $ | | 0,52 $ | | 0,17 $ | | 1,92 $ | 1,37 $ | 0,22 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,97 $ | 0,03 $ | 43,21 |\n| apprenti - période 2 | 30,47 $ | 3,96 $ | 7,982 $ | 0,33 $ | 0,75 $ | | 0,63 $ | | 0,21 $ | | 2,28 $ | 1,61 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,15 $ | 0,03 $ | 49,91 |\n| apprenti - période 3 | 35,55 $ | 4,62 $ | 7,982 $ | 0,33 $ | 0,75 $ | | 0,73 $ | | 0,24 $ | | 2,64 $ | 1,86 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,32 $ | 0,03 $ | 56,60 |\n| apprenti - période 4 | 43,17 $ | 5,61 $ | 7,982 $ | 0,33 $ | 0,75 $ | | 0,89 $ | | 0,29 $ | | 3,18 $ | 2,22 $ | 0,37 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,58 $ | 0,03 $ | 66,66 |\n\n---\n\n**COÛT HORAIRE DE LA MAIN-D'OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 Coûts horaires de la main-d'œuvre - 26 avril 2026**\n**secteur COMMERCIAL, INSTITUTIONNEL ET INDUSTRIEL**\n Taxe sur Équip. De Prélèv.\n\n| secteur COMMERCIAL, INSTITUTIONNEL ET INDUSTRIEL | | | | | | | | | | | | | | | | | | | |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| | Taux horaire | Indemnité vacances | Avantages sociaux | Taxe sur assur. | Équip. De sécur. | A-E 1,820% % | | RQAP | | RRQ | FSS 4,26% % | Prélèv. CCQ | | AECQ | Fonds d&#x27;indemn. 0,02 $ heure | Fonds de formation | CNESST | ACQ +Autres contributions | Total |\n| Valeur | | 13% | 1 | 1 | 1 | A-E 1,820% % | | 0,602% | | 6,300% | FSS 4,26% % | 0,75% | | 0,03 $ | Fonds d&#x27;indemn. 0,02 $ heure | 0,20 $ | | | |\n| Unité % heure heure heure % % % heure heure | | | | | | | | | | | | | | | | | | | |\n| Occupations | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | |\n| Chauffeur, classe IV | 36,21 $ | 4,71 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,74 $ | | 0,25 $ | | 2,68 $ | 1,88 $ | 0,31 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,69 $ | 0,03 $ | 59,35 |\n| Commis | 27,78 $ | 3,61 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,57 $ | | 0,19 $ | | 2,08 $ | 1,48 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,11 $ | 0,03 $ | 47,94 |\n| Conducteur de camion | | | | | | | | | | | | | | | | | | | |\n| Classe AA | 43,23 $ | 5,62 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,89 $ | | 0,29 $ | | 3,18 $ | 2,22 $ | 0,37 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,17 $ | 0,03 $ | 69,07 |\n| Classe A | 41,67 $ | 5,42 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,86 $ | | 0,28 $ | | 3,07 $ | 2,15 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,06 $ | 0,03 $ | 66,96 |\n| Classe B | 40,94 $ | 5,32 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,84 $ | | 0,28 $ | | 3,01 $ | 2,11 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,01 $ | 0,03 $ | 65,96 |\n| Classe C | 40,41 $ | 5,25 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,83 $ | | 0,27 $ | | 2,98 $ | 2,08 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,98 $ | 0,03 $ | 65,24 |\n| Gardien | 20,75 $ | 2,70 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,43 $ | | 0,14 $ | | 1,58 $ | 1,14 $ | 0,18 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 1,62 $ | 0,03 $ | 38,42 |\n| Homme service machines lourdes | 39,44 $ | 5,13 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,81 $ | | 0,27 $ | | 2,91 $ | 2,04 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,91 $ | 0,03 $ | 63,72 |\n| | | | | | | | | | | | | | | | | | | | |\n| Magasinier | 33,09 $ | 4,30 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,68 $ | | 0,23 $ | | 2,46 $ | 1,73 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,47 $ | 0,03 $ | 55,12 |\n| | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | |\n| Manœuvre (journalier) | 40,19 $ | 5,22 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,83 $ | | 0,27 $ | | 2,96 $ | 2,07 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,96 $ | 0,03 $ | 64,72 |\n| | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | |\n| Manœuvre (pipeline) | 40,83 $ | 5,31 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,84 $ | | 0,28 $ | | 3,01 $ | 2,12 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,00 $ | 0,03 $ | 65,62 |\n| | | | | | | | | | | | | | | | | | | | |\n| Manœuvre (travaux couverture) | 40,45 $ | 5,26 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,83 $ | | 0,28 $ | | 2,98 $ | 2,09 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 2,98 $ | 0,03 $ | 65,09 |\n| | | | | | | | | | | | | | | | | | | | |\n| Manœuvre (maçonnerie) | 41,91 $ | 5,45 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,86 $ | | 0,29 $ | | 3,08 $ | 2,16 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,79 $ | 0,03 $ | 67,78 |\n| | | | | | | | | | | | | | | | | | | | |\n| Manœuvre en décontamination | 45,22 $ | 5,88 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,93 $ | | 0,31 $ | | 3,32 $ | 2,32 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,31 $ | 0,03 $ | 71,55 |\n| | | | | | | | | | | | | | | | | | | | |\n| Manœuvre spécialisé | 40,83 $ | 5,31 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,84 $ | | 0,28 $ | | 3,01 $ | 2,10 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,00 $ | 0,03 $ | 65,60 |\n| | | | | | | | | | | | | | | | | | | | |\n| Manœuvre spécialisé (carreleur) | 42,04 $ | 5,47 $ | 8,470 $ | 0,29 $ | 0,40 $ | 0,86 $ | | 0,29 $ | | 3,08 $ | 2,16 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,08 $ | 0,03 $ | 66,78 |\n| Manœuvre spécialisé (couverture) | 41,09 $ | 5,34 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,85 $ | | 0,28 $ | | 3,03 $ | 2,12 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,02 $ | 0,03 $ | 65,96 |\n| | | | | | | | | | | | | | | | | | | | |\n| Op. Appareils de levage - Cl. A | 44,38 $ | 5,77 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,91 $ | | 0,30 $ | | 3,26 $ | 2,28 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,25 $ | 0,03 $ | 70,63 |\n| Op. Appareils de levage - Cl. B | 42,54 $ | 5,53 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,87 $ | | 0,29 $ | | 3,13 $ | 2,19 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,12 $ | 0,03 $ | 68,13 |\n| Opérateur de génératrice | 41,70 $ | 5,42 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,86 $ | | 0,28 $ | | 3,07 $ | 2,15 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,06 $ | 0,03 $ | 66,99 |\n| | | | | | | | | | | | | | | | | | | | |\n| Op. Pompes et compresseurs | 42,86 $ | 5,57 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,88 $ | | 0,29 $ | | 3,15 $ | 2,20 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,14 $ | 0,03 $ | 68,33 |\n| Op. d&#x27;usines fixes ou mobiles | 43,28 $ | 5,63 $ | 8,750 $ | 0,32 $ | 0,75 $ | 0,89 $ | | 0,29 $ | | 3,18 $ | 2,22 $ | 0,37 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,17 $ | 0,03 $ | 69,13 |\n| Prép. pneus déboss. machines lourdes | 44,47 $ | 5,78 $ | 8,690 $ | 0,31 $ | 0,60 $ | 0,91 $ | | 0,30 $ | | 3,27 $ | 2,28 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,25 $ | 0,03 $ | 70,52 |\n| 47,40 $ 6,16 $ 8,690 $ 0,31 $ 0,60 $ 0,97 $ 0,32 $ 3,47 $ 2,42 $ 0,40 $ 0,03 $ 0,02 $ 0,20 $ 3,46 $ 0,03 $ 74,48 Soudeur | | | | | | | | | | | | | | | | | | | |\n| Soudeur de pipeline et soudeur | | | | | | | | | | | | | | | | | | | |\n| de distribution | 50,79 $ | 6,60 $ | 8,902 $ | 0,33 $ | 0,60 $ | 1,04 $ | | 0,35 $ | | 3,72 $ | 2,59 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,69 $ | 0,03 $ | 79,32 |\n| Soudeur en tuyauterie | 50,79 $ | 6,60 $ | 8,902 $ | 0,33 $ | 0,75 $ | 1,04 $ | | 0,35 $ | | 3,72 $ | 2,59 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,69 $ | 0,03 $ | 79,47 |\n| Spécialiste branch. imm. (gas fitter) | 50,33 $ | 6,54 $ | 8,690 $ | 0,31 $ | 0,60 $ | 1,04 $ | | 0,34 $ | | 3,68 $ | 2,56 $ | 0,43 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 3,66 $ | 0,03 $ | 78,46 |\n\nNote : Pour les métiers de charpentier-menuisier, ferrailleur, monteur d'acier et de structure, opérateur d'équipement lourd, opérateur de pelles mécaniques, parqueteur-sableur et conducteur de \nde camion, le taux de salaire apparaissant à la présente grille ne vaut que pour les travaux effectués entre 7h et 19h. Pour obtenir le taux de salaire pour les travaux effectués entre 19h et 7h, veuillez consulter \nl'annexe C de la convention collective institutionnel/commercial. Le même principe s'applique pour le poseur de système intérieur mais selon un horaire de 5h à 17h et de 17h à 5h.\n\n**Pour plus de renseignements sur ces changements, communiquez avec le Service des relations du travail de l’APCHQ.**","metadata":{"scrapeId":"01a0795b-dc22-74f9-b79f-91046feeeb11","sourceURL":"https://media.apchq.com/d2ZEaie6TJG7PBdUl8qttA/cout-main-d-oeuvre-commercial-26-avril-2026.pdf","url":"https://media.apchq.com/d2ZEaie6TJG7PBdUl8qttA/cout-main-d-oeuvre-commercial-26-avril-2026.pdf","statusCode":200,"numPages":5,"totalPages":5,"title":"Co�ts horaires de la main-d'oeuvre - Commercial - 26 avril 2026","contentType":"application/pdf","proxyUsed":"basic","cacheState":"miss","indexId":"12e7abf8-812c-4694-abba-89fcc220a3a0","creditsUsed":5,"concurrencyLimited":false}}}
\ No newline at end of file
added src/lib/cost/connectors/__fixtures__/apchq-landing.json +1 −0
@@ -0,0 +1 @@
1 +{"success":true,"data":{"markdown":"[Aller au contenu principal](https://www.apchq.com/nos-services/relations-du-travail/paie/couts-horaires-et-paie/#main)\n\nAvis de maintenance\n\nLa connexion à la section Mon compte et à l'Espace membre sera indisponible le samedi 29 août de 7 h à 7 h 30. Merci de votre compréhension.\n\n[Paie](https://www.apchq.com/nos-services/relations-du-travail/paie/)\n\n# Des outils pour calculer les coûts de la main-d’œuvre et vos paies\n\nUtilisez nos grilles de coûts de la main-d’œuvre dans l’industrie de la construction et notre calculatrice pour calculer les coûts de votre main-d’œuvre et vos paies.\n\n## Coûts horaires et paie\n\nUtilisez nos grilles de coûts de la main-d’œuvre dans l’industrie de la construction pour calculer les coûts de votre main-d’œuvre et vos paies.\n\n[Communiquez avec un·e conseiller·ère\\\\\n\\\\\nCommuniquez avec un·e conseiller·ère](https://www.apchq.com/nos-services/relations-du-travail/#joindre-equipe-rt)\n\n### Coûts horaires de la main-d'œuvre en vigueur au 26 avril 2026\n\n**Les grilles en vigueur au 26 avril 2026 tiennent compte du gel des cotisations patronales et salariales à l’assurance (Médic).**\n\n- [Secteur résidentiel léger](https://media.apchq.com/MFtluLSESPql9jsD_jdy2g/cout-main-d-oeuvre-residentiel-leger-26-arvil-2026-temps-simple.pdf)\n- [Secteur résidentiel léger - temps et demi](https://media.apchq.com/NU_NgZ_5RP2nfEoebUJRzg/cout-main-d-oeuvre-residentiel-leger-26-arvil-2026-temps-demi.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/XoCpQtiEQ6WVOzLU3mVuWw/cout-main-d-oeuvre-residentiel-lourd-26-avril-2026-temps-simple.pdf)\n- [Secteur résidentiel lourd - temps et demi](https://media.apchq.com/agd4dab_QDGqA-XkZmSpuA/cout-main-d-oeuvre-residentiel-lourd-26-avril-2026-temps-demi.pdf)\n- [Secteur résidentiel lourd - temps double](https://media.apchq.com/Wv3AYQr_S76XPa5esQaYDQ/cout-main-d-oeuvre-residentiel-lourd-26-avril-2026-temps-double.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/d2ZEaie6TJG7PBdUl8qttA/cout-main-d-oeuvre-commercial-26-avril-2026.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik](https://media.apchq.com/Nnd7YsWWQN6B-rV8thNEiA/secteur-residentiel-chantiers-isoles-ou-situe-sur-le-territoire-de-la-baie-james-et-du-nunavik-26-avril-2026.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi](https://media.apchq.com/dm5JByxKRa2ibExSRvGiyQ/secteur-residentiel-chantiers-isoles-ou-situe-sur-le-territoire-de-la-baie-james-et-du-nunavik-temps-et-demi-26-avril-2026.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double](https://media.apchq.com/KPQz8vt6SP27UBXd0PEMGw/secteur-residentiel-chantiers-isoles-ou-situe-sur-le-territoire-de-la-baie-james-et-du-nunavik-temps-double-26-avril-2026.pdf)\n\n### Coûts horaires de la main-d'œuvre en vigueur au 28 décembre 2025\n\n- [Secteur résidentiel léger](https://media.apchq.com/Ed6jIrZmQ8KIWhL1fBV8DA/cout-main-d-oeuvre-residentiel-leger-28-decembre-2025-temps-simple.pdf)\n- [Secteur résidentiel léger - temps et demi](https://media.apchq.com/B1n6fTUhTNemyMZvLvRTdQ/cout-main-d-oeuvre-residentiel-leger-28-decembre-2025-temps-demi.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/G5FSmt49TDagOPHEOOLavA/cout-main-d-oeuvre-residentiel-lourd-28-decembre-2025-temps-simple.pdf)\n- [Secteur résidentiel lourd - temps et demi](https://media.apchq.com/XPWgQC1ITc6-q53bWPWqXg/cout-main-d-oeuvre-residentiel-lourd-28-decembre-2025-temps-demi.pdf)\n- [Secteur résidentiel lourd - temps double](https://media.apchq.com/V0gRe3yTTOqfKHuPNcG_iw/cout-main-d-oeuvre-residentiel-lourd-28-decembre-2025-temps-double.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/RVuiEcLhQJqD3l2M7a98lw/cout-main-d-oeuvre-commercial-28-decembre-2025.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik](https://media.apchq.com/TYARCpb0QGid5IN-Qf6Diw/cout-main-d-oeuvre-residentiel-r-2-28-decembre-2025-temps-simple.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi](https://media.apchq.com/f_TgwB8SQfC08hQbcLX96w/cout-main-d-oeuvre-residentiel-r-2-28-decembre-temps-demi.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double](https://media.apchq.com/fmrFMtvnQNaxdD42ZFpNLw/cout-main-d-oeuvre-residentiel-r-2-28-decembre-2025-temps-double.pdf)\n\n### Coûts horaires de la main-d’œuvre en vigueur au 27 juillet 2025\n\n- [Secteur résidentiel léger](https://media.apchq.com/PmCRd44VTvCpqO-SvY3BXg/cout-main-d-oeuvre-residentiel-leger-27-juillet-2025.pdf)\n- [Secteur résidentiel léger - temps et demi](https://media.apchq.com/LQmFHciuQW6mycm2JfJrow/cout-main-d-oeuvre-residentiel-leger-27-juillet-2025-temps-demi.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/E-XXkwADTFiPQv3tJsfeuA/cout-main-d-oeuvre-residentiel-lourd-27-juillet-2025.pdf)\n- [Secteur résidentiel lourd - temps et demi](https://media.apchq.com/YB4JKKB3RgSVcRjePUI-ZQ/cout-main-d-oeuvre-residentiel-lourd-27-juillet-2025-temps-demi.pdf)\n- [Secteur résidentiel lourd - temps double](https://media.apchq.com/OARLtrt-TAmXFqmAalNIJw/cout-main-d-oeuvre-residentiel-lourd-27-juillet-2025-temps-double.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/a9U0hhWvRFipPWOy-fGHpQ/cout-main-d-oeuvre-commercial-27-avril-2025.pdf)(mis à jour au 27 avril 2025)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik](https://media.apchq.com/MGlPGgnhTdKTBg3ItLP2bA/cout-main-d-oeuvre-residentiel-r-2-27-juillet-2025.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi](https://media.apchq.com/Dn_WZQDLSNyBQEVMDN97rQ/cout-main-d-oeuvre-residentiel-r-2-27-juillet-2025-temps-demi.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double](https://media.apchq.com/c45tFbKLQTOj8Na5G0GJ5w/cout-main-d-oeuvre-residentiel-r-2-27-juillet-2025-temps-double.pdf)\n\n### Coûts horaires de la main-d’œuvre en vigueur au 27 avril 2025\n\n- [Secteur résidentiel léger](https://media.apchq.com/cU48rp9XSWGkRmmPHOodxg/cout-main-d-oeuvre-residentiel-leger-27-avril-2025-temps-simple.pdf)\n- [Secteur résidentiel léger - temps et demi](https://media.apchq.com/aA3tUOwtS7WWrB5Xpj73gA/cout-main-d-oeuvre-residentiel-leger-27-avril-2025-temps-demi.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/UanWyej1QleQDHTiPpZOxg/cout-main-d-oeuvre-residentiel-lourd-27-avril-2025-temps-simple.pdf)\n- [Secteur résidentiel lourd - temps et demi](https://media.apchq.com/LP4kPYwzT8moDrTnN-xr5w/cout-main-d-oeuvre-residentiel-lourd-27-avril-2025-temps-demi.pdf)\n- [Secteur résidentiel lourd - temps double](https://media.apchq.com/a8g9VQnXQMioum9knAHvYA/cout-main-d-oeuvre-residentiel-lourd-27-avril-2025-temps-double.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/a9U0hhWvRFipPWOy-fGHpQ/cout-main-d-oeuvre-commercial-27-avril-2025.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik](https://media.apchq.com/OC8naYqqQKGhxAnCiIl8Jw/cout-main-d-oeuvre-residentiel-r-2-27-avril-2025-temps-simple.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi](https://media.apchq.com/Iv1mzz9lTj6tx-GcTnEnQw/cout-main-d-oeuvre-residentiel-r-2-27-avril-2025-temps-demi.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double](https://media.apchq.com/E571pj77QN-mEa2b-y7zIw/cout-main-d-oeuvre-residentiel-r-2-27-avril-2025-temps-double.pdf)\n\n### Coûts horaires de la main-d’œuvre en vigueur au 29 décembre 2024\n\n- [Secteur résidentiel léger](https://media.apchq.com/Ra4HAStWTr-37b439YWwSA/cout-main-d-oeuvre-residentiel-leger-29-decembre-2024-temps-simple.pdf)\n- [Secteur résidentiel léger - temps et demi](https://media.apchq.com/IF0WG2rAQGi_LDNu5Qxo5Q/cout-main-d-oeuvre-residentiel-leger-29-decembre-2024-temps-demi.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/bAldUkA5QSioUHCJt6sMAw/cout-main-d-oeuvre-residentiel-lourd-29-decembre-2024-temps-simple.pdf)\n- [Secteur résidentiel Lourd - temps et demi](https://media.apchq.com/KAWwCbKYT2miY0t2KIZSzw/cout-main-d-oeuvre-residentiel-lourd-29-decembre-2024-temps-demi.pdf)\n- [Secteur résidentiel lourd - temps double](https://media.apchq.com/f09CKFxbS0GJqC7dYvUCRg/cout-main-d-oeuvre-residentiel-lourd-29-decembre-2024-temps-double.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik](https://media.apchq.com/H7-mZYFnTe6L4m2_Zhoc-w/cout-main-d-oeuvre-residentiel-r-2-29-decembre-2024-temps-simple.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi](https://media.apchq.com/XkaZmMhIQbeyXWzMGRRXCA/cout-main-d-oeuvre-residentiel-r-2-29-decembre-2024-temps-demi.pdf)\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double](https://media.apchq.com/Rhmyt8wTSRedJndmabDQ_w/cout-main-d-oeuvre-residentiel-r-2-29-decembre-2024-temps-double.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/Z1MQ9Au8SQmHzblgmGGHig/cout-main-d-oeuvre-commercial-29-decembre-2024.pdf)\n\n### Coûts horaires de la main-d’œuvre en vigueur au 28 avril 2024\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722003637-cout-main-d-oeuvre-residentiel-leger-28-avril-2024-temps-simple.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel léger - temps et demi](https://media.apchq.com/121173/1722018758-cout-main-d-oeuvre-residentiel-leger-28-avril-2024-temps-demi.pdf \"Secteur résidentiel léger - temps et demi\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722018762-cout-main-d-oeuvre-residentiel-lourd-28-avril-2024-temps-simple.pdf \"Secteur résidentiel lourd\")\n- [Secteur résidentiel lourd - temps double](https://media.apchq.com/121173/1722018760-cout-main-d-oeuvre-residentiel-lourd-28-avril-2024-temps-double.pdf \"Secteur résidentiel lourd - temps double\")\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik](https://media.apchq.com/121173/1722018766-cout-main-d-oeuvre-residentiel-r-2-28-avril-2024-temps-simple.pdf \"Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik\")\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi](https://media.apchq.com/121173/1722018768-cout-main-d-oeuvre-residentiel-r-2-28-avril-2024-temps-demi.pdf \"Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi\")\n- [Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double](https://media.apchq.com/121173/1722018764-cout-main-d-oeuvre-residentiel-r-2-28-avril-2024-temps-double.pdf \"Secteur résidentiel chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double\")\n- [Secteur institutionnel et commercial](https://media.apchq.com/121173/1722018755-cout-main-d-oeuvre-commercial-28-avril-2024.pdf \"Secteur institutionnel et commercial\")\n\n### Coûts horaires de la main-d’œuvre en vigueur au 1er janvier 2024\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722020185-cout-main-d-oeuvre-residentiel-leger-1er-janvier-2024-temps-simple.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel léger - temps et demi](https://media.apchq.com/121173/1722020186-cout-main-d-oeuvre-residentiel-leger-1er-janvier-2024-temps-demi.pdf \"Secteur résidentiel léger - temps et demi\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722020190-cout-main-d-oeuvre-residentiel-lourd-1er-janvier-2024.pdf \"Secteur résidentiel lourd\")\n- [Secteur résidentiel lourd - temps double](https://media.apchq.com/121173/1722020188-cout-main-d-oeuvre-residentiel-lourd-1er-janvier-2024-temps-double.pdf \"Secteur résidentiel lourd - temps double\")\n- [Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik](https://media.apchq.com/121173/1722020196-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2024.pdf \"Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik\")\n- [Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi](https://media.apchq.com/121173/1722020192-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2024-temps-demi.pdf \"Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi\")\n- [Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double](https://media.apchq.com/121173/1722020194-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2024-temps-double.pdf \"Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double\")\n- [Secteur institutionnel et commercial](https://media.apchq.com/121173/1722020182-cout-main-d-oeuvre-commercial-1er-janvier-2024.pdf \"Secteur institutionnel et commercial\")\n\n### Coûts horaires de la main-d’œuvre en vigueur au 30 avril 2023\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722022353-cout-main-d-oeuvre-residentiel-leger-30-avril-2023-temps-simple.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel léger - temps et demi](https://media.apchq.com/121173/1722022351-cout-main-d-oeuvre-residentiel-leger-30-avril-2023-temps-demi.pdf \"Secteur résidentiel léger - temps et demi\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722022357-cout-main-d-oeuvre-residentiel-lourd-30-avril-2023-temps-simple.pdf \"Secteur résidentiel lourd\")\n- [Secteur résidentiel lourd - temps double](https://media.apchq.com/121173/1722022355-cout-main-d-oeuvre-residentiel-lourd-30-avril-2023-temps-double.pdf \"Secteur résidentiel lourd - temps double\")\n- [Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik](https://media.apchq.com/121173/1722022363-cout-main-d-oeuvre-residentiel-r-2-30-avril-2023-temps-simple.pdf \"Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik\")\n- [Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi](https://media.apchq.com/121173/1722022359-cout-main-d-oeuvre-residentiel-r-2-30-avril-2023-temps-demi.pdf \"Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi\")\n- [Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double](https://media.apchq.com/121173/1722022361-cout-main-d-oeuvre-residentiel-r-2-30-avril-2023-temps-double.pdf \"Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double\")\n- [Secteur institutionnel et commercial](https://media.apchq.com/121173/1722022349-cout-main-d-oeuvre-commercial-30-avril-2023.pdf \"Secteur institutionnel et commercial\")\n\n### Coûts horaires de la main-d’œuvre en vigueur au 1er janvier 2023\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722023080-cout-main-d-oeuvre-residentiel-leger-1er-janvier-2023-simple.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel léger - temps et demi](https://media.apchq.com/121173/1722023082-cout-main-d-oeuvre-residentiel-leger-1er-janvier-2023-temps-demi.pdf \"Secteur résidentiel léger - temps et demi\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722023086-cout-main-d-oeuvre-residentiel-lourd-1er-janvier-2023-temps-simple.pdf \"Secteur résidentiel lourd\")\n- [Secteur résidentiel lourd - temps double](https://media.apchq.com/121173/1722023084-cout-main-d-oeuvre-residentiel-lourd-1er-janvier-2023-temps-double.pdf \"Secteur résidentiel lourd - temps double\")\n- [Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik](https://media.apchq.com/121173/1722023091-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2023-temps-simple.pdf \"Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik\")\n- [Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi](https://media.apchq.com/121173/1722023088-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2023-temps-demi.pdf \"Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps demi\")\n- [Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double](https://media.apchq.com/121173/1722023089-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2023-temps-double.pdf \"Secteur résidentiel en chantiers isolés ou chantiers situés sur le territoire de la Baie-James et du Nunavik - temps double\")\n- [Secteur institutionnel et commercial](https://media.apchq.com/121173/1722023078-cout-main-d-oeuvre-commercial-1er-janvier-2023.pdf \"Secteur institutionnel et commercial\")\n\n[Communiquez avec un·e conseiller·ère\\\\\n\\\\\nCommuniquez avec un·e conseiller·ère](https://www.apchq.com/nos-services/relations-du-travail/#joindre-equipe-rt)\n\n## IC Spécial Paie : votre référence!\n\nConsultez l’IC Spécial Paie pour la production des paies et pour les nouvelles importantes en relations du travail dans le secteur résidentiel.\n\n- [IC Spécial Paie - Avril 2026\\\\\n\\\\\nIC Spécial Paie - Avril 2026 (Ouvre dans un nouvel onglet)](https://media.apchq.com/CcefeYfVTMerzc5l-hdRKg/25668-ea-26-rt-ic-special-paie-edition-avril-2026.pdf)\n\n- [IC Spécial Paie - Janvier 2026\\\\\n\\\\\nIC Spécial Paie - Janvier 2026 (Ouvre dans un nouvel onglet)](https://media.apchq.com/fZmQ_61EQ0iqOv6UJjrl1Q/ic_special_paie_2026_v03_final_compressed.pdf)\n\n- [IC Spécial Paie - Janvier 2025\\\\\n\\\\\nIC Spécial Paie - Janvier 2025 (Ouvre dans un nouvel onglet)](https://media.apchq.com/Klc6Xc7oTveUT4OQlnQmoA/ic-speciale-paie-janvier-2025-lr.pdf)\n\n- [IC Spécial Paie - Janvier 2023\\\\\n\\\\\nIC Spécial Paie - Janvier 2023 (Ouvre dans un nouvel onglet)](https://media.apchq.com/dP579-yvRx2tOsczaDsifA/02d491fb89b8a667aadc5122eec08bb5cf4870f4.pdf)\n\n- [IC Spécial Paie - Avril 2022\\\\\n\\\\\nIC Spécial Paie - Avril 2022 (Ouvre dans un nouvel onglet)](https://media.apchq.com/GBqXBDsJQz2vA2PZ5H71mw/35132378febe4d8b32a6306084e5f4435c5127cc.pdf)\n\n- [IC Spécial Paie - Janvier 2022\\\\\n\\\\\nIC Spécial Paie - Janvier 2022 (Ouvre dans un nouvel onglet)](https://media.apchq.com/XnYU53ehRKSPiGNVGBEopg/2d771ff0f1652853316aaa58cc241b6b8dbdaed8.pdf)\n\n\n![](https://media.apchq.com/GNYAweQuQ--DVSrbzskTfQ/6-09_calculatrice-paie-v2.jpg?auto=format&fit=crop&crop=focalpoint&fp-x=0.51&fp-y=0.48&ar=1:1&w=2400&h=2400&fm=avif)\n\n## Une calculatrice pour calculer les coûts de votre main-d’œuvre\n\nLes grilles de coûts de la main-d’œuvre dans l’industrie de la construction servent, entre autres, à déterminer les coûts horaires de la main-d’œuvre. Elles sont utilisées particulièrement lors de la préparation de soumissions.\n\nBesoin d'aide pour préparer vos soumissions et vos paies? Calculez les coûts horaires de la main-d’œuvre avec cette calculatrice!\n\n[Utilisez la calculatrice\\\\\n\\\\\nUtilisez la calculatrice](https://www.apchq.com/outils/calculatrice-des-couts-horaires/)\n\n## Le calcul des paies\n\nLe calcul de la paie consiste à établir la paie nette de l’employé, ainsi que les coûts de main-d’œuvre pour l’employeur. Lors du calcul d’une paie « CCQ », il faut prêter une attention particulière aux divers éléments propres à l’industrie de la construction. Consultez les exemples de paie ci-dessous afin de les découvrir.\n\n[Communiquez avec un·e conseiller·ère\\\\\n\\\\\nCommuniquez avec un·e conseiller·ère](https://www.apchq.com/nos-services/relations-du-travail/#joindre-equipe-rt)\n\n### Exemples de paie au 26 avril 2026\n\n- [Secteur résidentiel léger](https://media.apchq.com/MvfqDI_bQ2GwNSeBPzgygQ/exemple-de-paie-residentiel-leger-26-avril-2026.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/dN3hfdQKSl2Z2WsAA2f2Uw/exemple-de-paie-residentiel-lourd-26-avril-2026.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/OXPe6LbmSwy2_7dMCGxFyw/exemple-de-paie-commercial-26-avril-2026.pdf)\n- [Secteur résidentiel chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/M6QXbuo7TJiAJwFSlrsbZQ/exemple-de-paie-residentiel-isoles-baie-james-nunavik-26-avril-2026.pdf)\n\n### Exemples de paie au 28 décembre 2025\n\n- [Secteur résidentiel léger](https://media.apchq.com/IpktOlwgTN6UJ7gMJE0CgQ/exemple-de-paie-residentiel-leger-28-decembre-2025.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/Hb8JfF5_RaOrhI0TWZnaQA/exemple-de-paie-residentiel-lourd-28-decembre-2025.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/eN9L14GlTyye6tuAzW6yOw/exemple-de-paie-commercial-28-decembre-2025.pdf)\n- [Secteur résidentiel chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/AewnPZWzRd-YtS94Ce6SzQ/exemple-de-paie-residentiel-isoles-baie-james-nunavik-28-decembre-2025.pdf)\n\n### Exemples de paie au 27 juillet 2025\n\n- [Secteur résidentiel léger](https://media.apchq.com/MlOEvynAQse0niTQUEyT_Q/exemple-de-paie-residentiel-leger-27-juillet-2025.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/KATjaUkzSJWjHFsewNsVww/exemple-de-paie-residentiel-lourd-27-juillet-2025.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/TnD96oUwSdGG4tgIwOM8rg/exemple-de-paie-commercial-27-avril-2025.pdf) (mis à jour le 27 avril 2025)\n- [Secteur résidentiel chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/Yq1rTqrHT9qqdu5IWM8wJA/exemple-de-paie-residentiel-isoles-baie-james-nunavik-27-juillet-2025.pdf)\n\n### Exemples de paie au 27 avril 2025\n\n- [Secteur résidentiel léger](https://media.apchq.com/ELuIuy9pQ_umI6oOCpTwuA/exemple-de-paie-residentiel-leger-27-avril-2025.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/JGPtCT-_Raq4m8tdzr2_Cg/exemple-de-paie-residentiel-lourd-27-avril-2025.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/TnD96oUwSdGG4tgIwOM8rg/exemple-de-paie-commercial-27-avril-2025.pdf)\n- [Secteur résidentiel chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/AgSUitwpT7eii7Wjbn8Vzw/exemple-de-paie-residentiel-isoles-baie-james-nunavik-27-avril-2025.pdf)\n\n### Exemples de paie au 29 décembre 2024\n\n- [Secteur résidentiel léger](https://media.apchq.com/IzLLmuqYS4qbcc4A4J5izg/exemple-de-paie-residentiel-leger-29-decembre-2024.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/K7O3SJNEStSklQ2k-_DLzg/exemple-de-paie-residentiel-lourd-29-decembre-2024.pdf)\n- [Secteur résidentiel chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/H1DNxjeYRWmnl3RjSx9v3Q/exemple-de-paie-residentiel-isoles-baie-james-nunavik-29-decembre-2024.pdf)\n- [Secteur institutionnel et commercial](https://media.apchq.com/Xk_9JssGS1WdPwhYGWB6qw/exemple-de-paie-commercial-29-decembre-2024.pdf)\n\n### Exemples de paie au 28 avril 2024\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722023928-exemple-de-paie-residentiel-leger-28-avril-2024.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722023929-exemple-de-paie-residentiel-lourd-28-avril-2024.pdf \"Secteur résidentiel lourd\")\n- [Secteur résidentiel chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/121173/1722023926-exemple-de-paie-residentiel-isoles-baie-james-nunavik-28-avril-2024.pdf \"Secteur résidentiel chantiers isolés ou région de la Baie-James et du Nunavik\")\n- [Secteur institutionnel et commercial](https://media.apchq.com/121173/1722023923-exemple-de-paie-commercial-28-avril-2024.pdf \"Secteur institutionnel et commercial\")\n\n### Exemples de paie au 1er janvier 2024\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722024160-exemple-de-paie-residentiel-leger-1er-janvier-2024.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722024162-exemple-de-paie-residentiel-lourd-1er-janvier-2024.pdf \"Secteur résidentiel lourd\")\n- [Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/121173/1722024158-exemple-de-paie-residentiel-isoles-baie-james-nunavik-1er-janvier-2024.pdf \"Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik\")\n- [Secteur institutionnel et commercial](https://media.apchq.com/121173/1722024156-exemple-de-paie-commercial-1-janvier-2024.pdf \"Secteur institutionnel et commercial\")\n\n### Exemples de paie au 30 avril 2023\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722024437-exemple-de-paie-residentiel-leger-30-avril-2023-xls.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722024438-exemple-de-paie-residentiel-lourd-1er-janvier-2023.pdf \"Secteur résidentiel lourd\")\n- [Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/121173/1722024435-exemple-de-paie-residentiel-isoles-baie-james-nunavik-30-avril-2023.pdf \"Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik\")\n- [Secteur institutionnel et commercial](https://media.apchq.com/121173/1722024433-exemple-de-paie-commercial-30-avril-2023.pdf \"Secteur institutionnel et commercial\")\n\n### Exemples de paie au 1er janvier 2023\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722024755-exemple-de-paie-residentiel-leger-1er-janvier-2023.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722024757-exemple-de-paie-residentiel-lourd-1er-janvier-2023.pdf \"Secteur résidentiel lourd\")\n- [Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/121173/1722024753-exemple-de-paie-residentiel-isoles-baie-james-nunavik-1er-janvier-2023.pdf \"Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik\")\n- [Secteur institutionnel et commercial](https://media.apchq.com/121173/1722024751-exemple-de-paie-commercial-1er-janvier-2023.pdf \"Secteur institutionnel et commercial\")\n\n[Communiquez avec un·e conseiller·ère\\\\\n\\\\\nCommuniquez avec un·e conseiller·ère](https://www.apchq.com/nos-services/relations-du-travail/#joindre-equipe-rt)\n\n## Avantages imposables\n\nLes avantages imposables représentent la valeur imposable de la contribution de l’employeur pour la carte MÉDIC Construction. Ce montant est utilisé seulement pour calculer l’impôt provincial et les cotisations au Régime des rentes du Québec (RRQ), au Fonds des services de santé (FSS) et à la Commission des normes, de l’équité, de la santé et de la sécurité du travail (CNESST).\n\nLa partie imposable est moindre que le coût de la carte MÉDIC Construction puisque la portion de l’assurance salaire n’est pas considérée comme un avantage imposable. Ce gain n’est pas payable à l’employé·e.\n\n- [Avantage imposables en vigueur au 26 avril 2026\\\\\n\\\\\nAvantage imposables en vigueur au 26 avril 2026 (Ouvre dans un nouvel onglet)](https://media.apchq.com/dMXl9_v3RhislZVL6zByKQ/avantages-imposables-residentiel-2026-2027.pdf)\n\n- [Avantage imposables en vigueur au 27 avril 2025\\\\\n\\\\\nAvantage imposables en vigueur au 27 avril 2025 (Ouvre dans un nouvel onglet)](https://media.apchq.com/SOWk3AD9QR-vNItCzP537w/avantages-imposable-residentiel-2025-2026.pdf)\n\n- [Avantage imposables en vigueur au 28 avril 2024\\\\\n\\\\\nAvantage imposables en vigueur au 28 avril 2024 (Ouvre dans un nouvel onglet)](https://media.apchq.com/GBcWTqV1SVO2eGSgigehPw/avantages-imposables-avril2024.pdf)\n\n- [Avantage imposables en vigueur au 30 avril 2023\\\\\n\\\\\nAvantage imposables en vigueur au 30 avril 2023 (Ouvre dans un nouvel onglet)](https://media.apchq.com/HJzmn9WLRgGr20F104drUg/avantages-imposables-avril2023.pdf)\n\n- [Avantages imposables en vigueur au 1er janvier 2023\\\\\n\\\\\nAvantages imposables en vigueur au 1er janvier 2023 (Ouvre dans un nouvel onglet)](https://media.apchq.com/Dq5ggKwxQHevS2sYUuByaQ/16aa759addf101a226102db97e273dd110f10740.pdf)\n\n\n## Avantages sociaux\n\nLes avantages sociaux sont composés de deux éléments : MÉDIC Construction et le régime de retraite de l’industrie de la construction.\n\n### Avantages sociaux en vigueur depuis le 26 avril 2026\n\n- [Secteur résidentiel léger](https://media.apchq.com/PKpLXdoZRd6cAoCjtGgUfw/avantages-sociaux-leger-26-avril-2026.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/aCz3MMaVSQmtdWpz0ZVLvw/avantages-sociaux-lourd-26-avril-2026.pdf)\n\n### Avantages sociaux en vigueur depuis le 28 décembre 2025\n\n- [Secteur résidentiel léger](https://media.apchq.com/ZpUmYTiKQGW8weL_eDw5rw/avantages-sociaux-leger-28-decembre-2025.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/cGaTMnUGScaczCXev5KBZA/avantages-sociaux-lourd-28-decembre-2025.pdf)\n- [Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/dFEiEsQURfC6cLM0Kov4OQ/avantages-sociaux-chantier-isoles-baie-james-et-nunavik-28-decembre-2025.pdf)\n\n### Avantages sociaux en vigueur depuis le 27 juillet 2025\n\n- [Secteur résidentiel léger](https://media.apchq.com/PphQuH-zSNKZr0AUe0LmaQ/avantages-sociaux-leger-27-juillet-2025.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/GgW2guObRI28V_fGpRDKDg/avantages-sociaux-lourd-27-juillet-2025.pdf)\n- [Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/ILnw-wnBRo-QefG7-o4Yrw/avantages-sociaux-chantier-isoles-baie-james-et-nunavik-27-juillet-2025.pdf)\n\n### Avantages sociaux en vigueur depuis le 27 avril 2025\n\n- [Secteur résidentiel léger](https://media.apchq.com/cKUogPUFRmm0WpOEc8w5TA/avantages-sociaux-leger-27-avril-2025.pdf)\n- [Secteur résidentiel lourd](https://media.apchq.com/S7HYkkS-QbiTYWvCqTrnaQ/avantages-sociaux-lourd-27-avril-2025.pdf)\n- Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik\n\n### Avantages sociaux en vigueur depuis le 30 avril 2023\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722025240-avantages-sociaux-leger-30-avril-2023.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722025243-avantages-sociaux-lourd-30-avril-2023.pdf \"Secteur résidentiel lourd\")\n- Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik\n\n### Avantages sociaux en vigueur depuis le 1er janvier 2023\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722025377-avantages-sociaux-leger-1er-janvier-2023.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722025379-avantages-sociaux-lourd-1er-janvier-2023.pdf \"Secteur résidentiel lourd\")\n- [Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik](https://media.apchq.com/121173/1722025375-avantages-sociaux-isoles-baie-james-nunavik-1er-janvier-2023.pdf \"Secteur résidentiel en chantiers isolés ou région de la Baie-James et du Nunavik\")\n\n### Avantages sociaux en vigueur depuis le 1er mai 2022\n\n- [Secteur résidentiel léger](https://media.apchq.com/121173/1722025535-avantages-sociaux-leger-1er-mai-2022.pdf \"Secteur résidentiel léger\")\n- [Secteur résidentiel lourd](https://media.apchq.com/121173/1722025538-avantages-sociaux-lourd-26-decembre-2021.pdf \"Secteur résidentiel lourd\")\n\n## Taux et documents fiscaux divers\n\n- [Taux pour 2024 : assurance-emploi, RQAP, RRQ, CNESST, Commission des normes du travail et FSS\\\\\n\\\\\nTaux pour 2024 : assurance-emploi, RQAP, RRQ, CNESST, Commission des normes du travail et FSS (Ouvre dans un nouvel onglet)](https://media.apchq.com/eivzBe_ASoyk2BPSrHkQEg/2292f11017ff92cc54ccf23fb07d4bda6c8ca332.pdf)\n\n- [Taux CNESST pour 2024\\\\\n\\\\\nTaux CNESST pour 2024 (Ouvre dans un nouvel onglet)](https://media.apchq.com/eA5A5qDHRs-OpgfYWh6tDw/78486bae8ee6f96afe0a6e3ada32fe616d47fe76.pdf)\n\n- [Relevé 1\\\\\n\\\\\nRelevé 1 (Ouvre dans un nouvel onglet)](https://media.apchq.com/dsfBDAAETb6jfM1fq9E7hA/7ac505e438a6ac2f61c9f9a833b9101e30e5af0f.pdf)\n\n- [T4\\\\\n\\\\\nT4 (Ouvre dans un nouvel onglet)](https://media.apchq.com/J0jk4gltTE698pgj58yugA/11e59e1bfd5013170e7eaaea04ef7abcaceb3e25.pdf)\n\n\n![](https://media.apchq.com/ArwwrfAzTVeWI0Y95QqO1A/6-05_besoin-precisions.jpg?auto=format&fit=crop&crop=focalpoint&fp-x=0.63&fp-y=0.53&ar=16:9&w=2400&h=1350&fm=avif)\n\n## Besoin de précisions?\n\nNotre équipe est là pour vous aider à prendre la bonne décision pour votre entreprise. Si vous avez besoin de plus de détails, communiquez avec nous.\n\n[Parlez à l'équipe-conseil\\\\\n\\\\\nParlez à l'équipe-conseil](https://www.apchq.com/nos-services/relations-du-travail/#joindre-equipe-rt)\n\n[Accueil APCHQ](https://www.apchq.com/)\n\n- À propos\nÀ propos\n\n\n\n\n\n[Consulter la page À propos](https://www.apchq.com/a-propos/)\n\n- Services\nServices\n\n\n\n\n\n[Consultez tous les services](https://www.apchq.com/nos-services/)\n\n- Formations\nFormations\n\n\n\n\n\n[Consulter toutes les formations](https://www.apchq.com/formations/)\n\n- Outils\nOutils\n\n\n\n\n\n[Consulter tous les outils](https://www.apchq.com/outils/)\n\n\nRecherche\n\n[Rechercher](https://www.apchq.com/recherche/?query=)\n\n0 Résultats\n\nAucun résultat\n\n[Voir tous les résultats\\\\\n\\\\\nVoir tous les résultats](https://www.apchq.com/recherche/?query=)","metadata":{"ogDescription":"Utilisez nos grilles de coûts de la main-d’œuvre dans l’industrie de la construction pour calculer les coûts de votre main-d’œuvre et vos paies.","description":"Utilisez nos grilles de coûts de la main-d’œuvre dans l’industrie de la construction pour calculer les coûts de votre main-d’œuvre et vos paies.","robots":"index, follow","AddSearchBot":"index, follow","twitter:description":"Utilisez nos grilles de coûts de la main-d’œuvre dans l’industrie de la construction pour calculer les coûts de votre main-d’œuvre et vos paies.","ogTitle":"Coûts horaires de la main-d’œuvre | APCHQ","title":"Coûts horaires de la main-d’œuvre | APCHQ","og:image":"https://www.datocms-assets.com/121173/1728334896-apchq_og-img_01.png?w=1200","og:title":"Coûts horaires de la main-d’œuvre | APCHQ","ogImage":"https://www.datocms-assets.com/121173/1728334896-apchq_og-img_01.png?w=1200","viewport":"width=device-width, initial-scale=1, viewport-fit=cover","addsearch-custom-field":"pageType=AServicesLabourRelationPayrollSlugRecord;title=Des outils pour calculer les coûts de la main-d’œuvre et vos paies;category=Services;slug=couts-horaires-et-paie","apple-mobile-web-app-title":"APCHQ","og:site_name":"APCHQ","language":"fr","og:description":"Utilisez nos grilles de coûts de la main-d’œuvre dans l’industrie de la construction pour calculer les coûts de votre main-d’œuvre et vos paies.","twitter:image":"https://www.datocms-assets.com/121173/1728334896-apchq_og-img_01.png?w=1200","favicon":"https://www.apchq.com/favicon/favicon.ico","scrapeId":"01a0795b-d9b9-743a-b838-5757868daf77","sourceURL":"https://www.apchq.com/nos-services/relations-du-travail/paie/couts-horaires-et-paie/","url":"https://www.apchq.com/nos-services/relations-du-travail/paie/couts-horaires-et-paie/","statusCode":200,"contentType":"text/html","proxyUsed":"basic","cacheState":"hit","cachedAt":"2026-09-06T23:55:30.818Z","creditsUsed":1,"concurrencyLimited":false},"links":["https://www.apchq.com/nos-services/relations-du-travail/paie/couts-horaires-et-paie/#main","https://www.apchq.com/nos-services/relations-du-travail/paie/","https://www.apchq.com/nos-services/relations-du-travail/#joindre-equipe-rt","https://media.apchq.com/MFtluLSESPql9jsD_jdy2g/cout-main-d-oeuvre-residentiel-leger-26-arvil-2026-temps-simple.pdf","https://media.apchq.com/NU_NgZ_5RP2nfEoebUJRzg/cout-main-d-oeuvre-residentiel-leger-26-arvil-2026-temps-demi.pdf","https://media.apchq.com/XoCpQtiEQ6WVOzLU3mVuWw/cout-main-d-oeuvre-residentiel-lourd-26-avril-2026-temps-simple.pdf","https://media.apchq.com/agd4dab_QDGqA-XkZmSpuA/cout-main-d-oeuvre-residentiel-lourd-26-avril-2026-temps-demi.pdf","https://media.apchq.com/Wv3AYQr_S76XPa5esQaYDQ/cout-main-d-oeuvre-residentiel-lourd-26-avril-2026-temps-double.pdf","https://media.apchq.com/d2ZEaie6TJG7PBdUl8qttA/cout-main-d-oeuvre-commercial-26-avril-2026.pdf","https://media.apchq.com/Nnd7YsWWQN6B-rV8thNEiA/secteur-residentiel-chantiers-isoles-ou-situe-sur-le-territoire-de-la-baie-james-et-du-nunavik-26-avril-2026.pdf","https://media.apchq.com/dm5JByxKRa2ibExSRvGiyQ/secteur-residentiel-chantiers-isoles-ou-situe-sur-le-territoire-de-la-baie-james-et-du-nunavik-temps-et-demi-26-avril-2026.pdf","https://media.apchq.com/KPQz8vt6SP27UBXd0PEMGw/secteur-residentiel-chantiers-isoles-ou-situe-sur-le-territoire-de-la-baie-james-et-du-nunavik-temps-double-26-avril-2026.pdf","https://media.apchq.com/Ed6jIrZmQ8KIWhL1fBV8DA/cout-main-d-oeuvre-residentiel-leger-28-decembre-2025-temps-simple.pdf","https://media.apchq.com/B1n6fTUhTNemyMZvLvRTdQ/cout-main-d-oeuvre-residentiel-leger-28-decembre-2025-temps-demi.pdf","https://media.apchq.com/G5FSmt49TDagOPHEOOLavA/cout-main-d-oeuvre-residentiel-lourd-28-decembre-2025-temps-simple.pdf","https://media.apchq.com/XPWgQC1ITc6-q53bWPWqXg/cout-main-d-oeuvre-residentiel-lourd-28-decembre-2025-temps-demi.pdf","https://media.apchq.com/V0gRe3yTTOqfKHuPNcG_iw/cout-main-d-oeuvre-residentiel-lourd-28-decembre-2025-temps-double.pdf","https://media.apchq.com/RVuiEcLhQJqD3l2M7a98lw/cout-main-d-oeuvre-commercial-28-decembre-2025.pdf","https://media.apchq.com/TYARCpb0QGid5IN-Qf6Diw/cout-main-d-oeuvre-residentiel-r-2-28-decembre-2025-temps-simple.pdf","https://media.apchq.com/f_TgwB8SQfC08hQbcLX96w/cout-main-d-oeuvre-residentiel-r-2-28-decembre-temps-demi.pdf","https://media.apchq.com/fmrFMtvnQNaxdD42ZFpNLw/cout-main-d-oeuvre-residentiel-r-2-28-decembre-2025-temps-double.pdf","https://media.apchq.com/PmCRd44VTvCpqO-SvY3BXg/cout-main-d-oeuvre-residentiel-leger-27-juillet-2025.pdf","https://media.apchq.com/LQmFHciuQW6mycm2JfJrow/cout-main-d-oeuvre-residentiel-leger-27-juillet-2025-temps-demi.pdf","https://media.apchq.com/E-XXkwADTFiPQv3tJsfeuA/cout-main-d-oeuvre-residentiel-lourd-27-juillet-2025.pdf","https://media.apchq.com/YB4JKKB3RgSVcRjePUI-ZQ/cout-main-d-oeuvre-residentiel-lourd-27-juillet-2025-temps-demi.pdf","https://media.apchq.com/OARLtrt-TAmXFqmAalNIJw/cout-main-d-oeuvre-residentiel-lourd-27-juillet-2025-temps-double.pdf","https://media.apchq.com/a9U0hhWvRFipPWOy-fGHpQ/cout-main-d-oeuvre-commercial-27-avril-2025.pdf","https://media.apchq.com/MGlPGgnhTdKTBg3ItLP2bA/cout-main-d-oeuvre-residentiel-r-2-27-juillet-2025.pdf","https://media.apchq.com/Dn_WZQDLSNyBQEVMDN97rQ/cout-main-d-oeuvre-residentiel-r-2-27-juillet-2025-temps-demi.pdf","https://media.apchq.com/c45tFbKLQTOj8Na5G0GJ5w/cout-main-d-oeuvre-residentiel-r-2-27-juillet-2025-temps-double.pdf","https://media.apchq.com/cU48rp9XSWGkRmmPHOodxg/cout-main-d-oeuvre-residentiel-leger-27-avril-2025-temps-simple.pdf","https://media.apchq.com/aA3tUOwtS7WWrB5Xpj73gA/cout-main-d-oeuvre-residentiel-leger-27-avril-2025-temps-demi.pdf","https://media.apchq.com/UanWyej1QleQDHTiPpZOxg/cout-main-d-oeuvre-residentiel-lourd-27-avril-2025-temps-simple.pdf","https://media.apchq.com/LP4kPYwzT8moDrTnN-xr5w/cout-main-d-oeuvre-residentiel-lourd-27-avril-2025-temps-demi.pdf","https://media.apchq.com/a8g9VQnXQMioum9knAHvYA/cout-main-d-oeuvre-residentiel-lourd-27-avril-2025-temps-double.pdf","https://media.apchq.com/OC8naYqqQKGhxAnCiIl8Jw/cout-main-d-oeuvre-residentiel-r-2-27-avril-2025-temps-simple.pdf","https://media.apchq.com/Iv1mzz9lTj6tx-GcTnEnQw/cout-main-d-oeuvre-residentiel-r-2-27-avril-2025-temps-demi.pdf","https://media.apchq.com/E571pj77QN-mEa2b-y7zIw/cout-main-d-oeuvre-residentiel-r-2-27-avril-2025-temps-double.pdf","https://media.apchq.com/Ra4HAStWTr-37b439YWwSA/cout-main-d-oeuvre-residentiel-leger-29-decembre-2024-temps-simple.pdf","https://media.apchq.com/IF0WG2rAQGi_LDNu5Qxo5Q/cout-main-d-oeuvre-residentiel-leger-29-decembre-2024-temps-demi.pdf","https://media.apchq.com/bAldUkA5QSioUHCJt6sMAw/cout-main-d-oeuvre-residentiel-lourd-29-decembre-2024-temps-simple.pdf","https://media.apchq.com/KAWwCbKYT2miY0t2KIZSzw/cout-main-d-oeuvre-residentiel-lourd-29-decembre-2024-temps-demi.pdf","https://media.apchq.com/f09CKFxbS0GJqC7dYvUCRg/cout-main-d-oeuvre-residentiel-lourd-29-decembre-2024-temps-double.pdf","https://media.apchq.com/H7-mZYFnTe6L4m2_Zhoc-w/cout-main-d-oeuvre-residentiel-r-2-29-decembre-2024-temps-simple.pdf","https://media.apchq.com/XkaZmMhIQbeyXWzMGRRXCA/cout-main-d-oeuvre-residentiel-r-2-29-decembre-2024-temps-demi.pdf","https://media.apchq.com/Rhmyt8wTSRedJndmabDQ_w/cout-main-d-oeuvre-residentiel-r-2-29-decembre-2024-temps-double.pdf","https://media.apchq.com/Z1MQ9Au8SQmHzblgmGGHig/cout-main-d-oeuvre-commercial-29-decembre-2024.pdf","https://media.apchq.com/121173/1722003637-cout-main-d-oeuvre-residentiel-leger-28-avril-2024-temps-simple.pdf","https://media.apchq.com/121173/1722018758-cout-main-d-oeuvre-residentiel-leger-28-avril-2024-temps-demi.pdf","https://media.apchq.com/121173/1722018762-cout-main-d-oeuvre-residentiel-lourd-28-avril-2024-temps-simple.pdf","https://media.apchq.com/121173/1722018760-cout-main-d-oeuvre-residentiel-lourd-28-avril-2024-temps-double.pdf","https://media.apchq.com/121173/1722018766-cout-main-d-oeuvre-residentiel-r-2-28-avril-2024-temps-simple.pdf","https://media.apchq.com/121173/1722018768-cout-main-d-oeuvre-residentiel-r-2-28-avril-2024-temps-demi.pdf","https://media.apchq.com/121173/1722018764-cout-main-d-oeuvre-residentiel-r-2-28-avril-2024-temps-double.pdf","https://media.apchq.com/121173/1722018755-cout-main-d-oeuvre-commercial-28-avril-2024.pdf","https://media.apchq.com/121173/1722020185-cout-main-d-oeuvre-residentiel-leger-1er-janvier-2024-temps-simple.pdf","https://media.apchq.com/121173/1722020186-cout-main-d-oeuvre-residentiel-leger-1er-janvier-2024-temps-demi.pdf","https://media.apchq.com/121173/1722020190-cout-main-d-oeuvre-residentiel-lourd-1er-janvier-2024.pdf","https://media.apchq.com/121173/1722020188-cout-main-d-oeuvre-residentiel-lourd-1er-janvier-2024-temps-double.pdf","https://media.apchq.com/121173/1722020196-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2024.pdf","https://media.apchq.com/121173/1722020192-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2024-temps-demi.pdf","https://media.apchq.com/121173/1722020194-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2024-temps-double.pdf","https://media.apchq.com/121173/1722020182-cout-main-d-oeuvre-commercial-1er-janvier-2024.pdf","https://media.apchq.com/121173/1722022353-cout-main-d-oeuvre-residentiel-leger-30-avril-2023-temps-simple.pdf","https://media.apchq.com/121173/1722022351-cout-main-d-oeuvre-residentiel-leger-30-avril-2023-temps-demi.pdf","https://media.apchq.com/121173/1722022357-cout-main-d-oeuvre-residentiel-lourd-30-avril-2023-temps-simple.pdf","https://media.apchq.com/121173/1722022355-cout-main-d-oeuvre-residentiel-lourd-30-avril-2023-temps-double.pdf","https://media.apchq.com/121173/1722022363-cout-main-d-oeuvre-residentiel-r-2-30-avril-2023-temps-simple.pdf","https://media.apchq.com/121173/1722022359-cout-main-d-oeuvre-residentiel-r-2-30-avril-2023-temps-demi.pdf","https://media.apchq.com/121173/1722022361-cout-main-d-oeuvre-residentiel-r-2-30-avril-2023-temps-double.pdf","https://media.apchq.com/121173/1722022349-cout-main-d-oeuvre-commercial-30-avril-2023.pdf","https://media.apchq.com/121173/1722023080-cout-main-d-oeuvre-residentiel-leger-1er-janvier-2023-simple.pdf","https://media.apchq.com/121173/1722023082-cout-main-d-oeuvre-residentiel-leger-1er-janvier-2023-temps-demi.pdf","https://media.apchq.com/121173/1722023086-cout-main-d-oeuvre-residentiel-lourd-1er-janvier-2023-temps-simple.pdf","https://media.apchq.com/121173/1722023084-cout-main-d-oeuvre-residentiel-lourd-1er-janvier-2023-temps-double.pdf","https://media.apchq.com/121173/1722023091-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2023-temps-simple.pdf","https://media.apchq.com/121173/1722023088-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2023-temps-demi.pdf","https://media.apchq.com/121173/1722023089-cout-main-d-oeuvre-residentiel-r-2-1er-janvier-2023-temps-double.pdf","https://media.apchq.com/121173/1722023078-cout-main-d-oeuvre-commercial-1er-janvier-2023.pdf","https://media.apchq.com/CcefeYfVTMerzc5l-hdRKg/25668-ea-26-rt-ic-special-paie-edition-avril-2026.pdf","https://media.apchq.com/fZmQ_61EQ0iqOv6UJjrl1Q/ic_special_paie_2026_v03_final_compressed.pdf","https://media.apchq.com/Klc6Xc7oTveUT4OQlnQmoA/ic-speciale-paie-janvier-2025-lr.pdf","https://media.apchq.com/dP579-yvRx2tOsczaDsifA/02d491fb89b8a667aadc5122eec08bb5cf4870f4.pdf","https://media.apchq.com/GBqXBDsJQz2vA2PZ5H71mw/35132378febe4d8b32a6306084e5f4435c5127cc.pdf","https://media.apchq.com/XnYU53ehRKSPiGNVGBEopg/2d771ff0f1652853316aaa58cc241b6b8dbdaed8.pdf","https://www.apchq.com/outils/calculatrice-des-couts-horaires/","https://media.apchq.com/MvfqDI_bQ2GwNSeBPzgygQ/exemple-de-paie-residentiel-leger-26-avril-2026.pdf","https://media.apchq.com/dN3hfdQKSl2Z2WsAA2f2Uw/exemple-de-paie-residentiel-lourd-26-avril-2026.pdf","https://media.apchq.com/OXPe6LbmSwy2_7dMCGxFyw/exemple-de-paie-commercial-26-avril-2026.pdf","https://media.apchq.com/M6QXbuo7TJiAJwFSlrsbZQ/exemple-de-paie-residentiel-isoles-baie-james-nunavik-26-avril-2026.pdf","https://media.apchq.com/IpktOlwgTN6UJ7gMJE0CgQ/exemple-de-paie-residentiel-leger-28-decembre-2025.pdf","https://media.apchq.com/Hb8JfF5_RaOrhI0TWZnaQA/exemple-de-paie-residentiel-lourd-28-decembre-2025.pdf","https://media.apchq.com/eN9L14GlTyye6tuAzW6yOw/exemple-de-paie-commercial-28-decembre-2025.pdf","https://media.apchq.com/AewnPZWzRd-YtS94Ce6SzQ/exemple-de-paie-residentiel-isoles-baie-james-nunavik-28-decembre-2025.pdf","https://media.apchq.com/MlOEvynAQse0niTQUEyT_Q/exemple-de-paie-residentiel-leger-27-juillet-2025.pdf","https://media.apchq.com/KATjaUkzSJWjHFsewNsVww/exemple-de-paie-residentiel-lourd-27-juillet-2025.pdf","https://media.apchq.com/TnD96oUwSdGG4tgIwOM8rg/exemple-de-paie-commercial-27-avril-2025.pdf","https://media.apchq.com/Yq1rTqrHT9qqdu5IWM8wJA/exemple-de-paie-residentiel-isoles-baie-james-nunavik-27-juillet-2025.pdf","https://media.apchq.com/ELuIuy9pQ_umI6oOCpTwuA/exemple-de-paie-residentiel-leger-27-avril-2025.pdf","https://media.apchq.com/JGPtCT-_Raq4m8tdzr2_Cg/exemple-de-paie-residentiel-lourd-27-avril-2025.pdf","https://media.apchq.com/AgSUitwpT7eii7Wjbn8Vzw/exemple-de-paie-residentiel-isoles-baie-james-nunavik-27-avril-2025.pdf","https://media.apchq.com/IzLLmuqYS4qbcc4A4J5izg/exemple-de-paie-residentiel-leger-29-decembre-2024.pdf","https://media.apchq.com/K7O3SJNEStSklQ2k-_DLzg/exemple-de-paie-residentiel-lourd-29-decembre-2024.pdf","https://media.apchq.com/H1DNxjeYRWmnl3RjSx9v3Q/exemple-de-paie-residentiel-isoles-baie-james-nunavik-29-decembre-2024.pdf","https://media.apchq.com/Xk_9JssGS1WdPwhYGWB6qw/exemple-de-paie-commercial-29-decembre-2024.pdf","https://media.apchq.com/121173/1722023928-exemple-de-paie-residentiel-leger-28-avril-2024.pdf","https://media.apchq.com/121173/1722023929-exemple-de-paie-residentiel-lourd-28-avril-2024.pdf","https://media.apchq.com/121173/1722023926-exemple-de-paie-residentiel-isoles-baie-james-nunavik-28-avril-2024.pdf","https://media.apchq.com/121173/1722023923-exemple-de-paie-commercial-28-avril-2024.pdf","https://media.apchq.com/121173/1722024160-exemple-de-paie-residentiel-leger-1er-janvier-2024.pdf","https://media.apchq.com/121173/1722024162-exemple-de-paie-residentiel-lourd-1er-janvier-2024.pdf","https://media.apchq.com/121173/1722024158-exemple-de-paie-residentiel-isoles-baie-james-nunavik-1er-janvier-2024.pdf","https://media.apchq.com/121173/1722024156-exemple-de-paie-commercial-1-janvier-2024.pdf","https://media.apchq.com/121173/1722024437-exemple-de-paie-residentiel-leger-30-avril-2023-xls.pdf","https://media.apchq.com/121173/1722024438-exemple-de-paie-residentiel-lourd-1er-janvier-2023.pdf","https://media.apchq.com/121173/1722024435-exemple-de-paie-residentiel-isoles-baie-james-nunavik-30-avril-2023.pdf","https://media.apchq.com/121173/1722024433-exemple-de-paie-commercial-30-avril-2023.pdf","https://media.apchq.com/121173/1722024755-exemple-de-paie-residentiel-leger-1er-janvier-2023.pdf","https://media.apchq.com/121173/1722024757-exemple-de-paie-residentiel-lourd-1er-janvier-2023.pdf","https://media.apchq.com/121173/1722024753-exemple-de-paie-residentiel-isoles-baie-james-nunavik-1er-janvier-2023.pdf","https://media.apchq.com/121173/1722024751-exemple-de-paie-commercial-1er-janvier-2023.pdf","https://media.apchq.com/dMXl9_v3RhislZVL6zByKQ/avantages-imposables-residentiel-2026-2027.pdf","https://media.apchq.com/SOWk3AD9QR-vNItCzP537w/avantages-imposable-residentiel-2025-2026.pdf","https://media.apchq.com/GBcWTqV1SVO2eGSgigehPw/avantages-imposables-avril2024.pdf","https://media.apchq.com/HJzmn9WLRgGr20F104drUg/avantages-imposables-avril2023.pdf","https://media.apchq.com/Dq5ggKwxQHevS2sYUuByaQ/16aa759addf101a226102db97e273dd110f10740.pdf","https://media.apchq.com/PKpLXdoZRd6cAoCjtGgUfw/avantages-sociaux-leger-26-avril-2026.pdf","https://media.apchq.com/aCz3MMaVSQmtdWpz0ZVLvw/avantages-sociaux-lourd-26-avril-2026.pdf","https://media.apchq.com/ZpUmYTiKQGW8weL_eDw5rw/avantages-sociaux-leger-28-decembre-2025.pdf","https://media.apchq.com/cGaTMnUGScaczCXev5KBZA/avantages-sociaux-lourd-28-decembre-2025.pdf","https://media.apchq.com/dFEiEsQURfC6cLM0Kov4OQ/avantages-sociaux-chantier-isoles-baie-james-et-nunavik-28-decembre-2025.pdf","https://media.apchq.com/PphQuH-zSNKZr0AUe0LmaQ/avantages-sociaux-leger-27-juillet-2025.pdf","https://media.apchq.com/GgW2guObRI28V_fGpRDKDg/avantages-sociaux-lourd-27-juillet-2025.pdf","https://media.apchq.com/ILnw-wnBRo-QefG7-o4Yrw/avantages-sociaux-chantier-isoles-baie-james-et-nunavik-27-juillet-2025.pdf","https://media.apchq.com/cKUogPUFRmm0WpOEc8w5TA/avantages-sociaux-leger-27-avril-2025.pdf","https://media.apchq.com/S7HYkkS-QbiTYWvCqTrnaQ/avantages-sociaux-lourd-27-avril-2025.pdf","https://media.apchq.com/121173/1722025240-avantages-sociaux-leger-30-avril-2023.pdf","https://media.apchq.com/121173/1722025243-avantages-sociaux-lourd-30-avril-2023.pdf","https://media.apchq.com/121173/1722025377-avantages-sociaux-leger-1er-janvier-2023.pdf","https://media.apchq.com/121173/1722025379-avantages-sociaux-lourd-1er-janvier-2023.pdf","https://media.apchq.com/121173/1722025375-avantages-sociaux-isoles-baie-james-nunavik-1er-janvier-2023.pdf","https://media.apchq.com/121173/1722025535-avantages-sociaux-leger-1er-mai-2022.pdf","https://media.apchq.com/121173/1722025538-avantages-sociaux-lourd-26-decembre-2021.pdf","https://media.apchq.com/eivzBe_ASoyk2BPSrHkQEg/2292f11017ff92cc54ccf23fb07d4bda6c8ca332.pdf","https://media.apchq.com/eA5A5qDHRs-OpgfYWh6tDw/78486bae8ee6f96afe0a6e3ada32fe616d47fe76.pdf","https://media.apchq.com/dsfBDAAETb6jfM1fq9E7hA/7ac505e438a6ac2f61c9f9a833b9101e30e5af0f.pdf","https://media.apchq.com/J0jk4gltTE698pgj58yugA/11e59e1bfd5013170e7eaaea04ef7abcaceb3e25.pdf","https://www.apchq.com/","https://www.apchq.com/a-propos/","https://www.apchq.com/nos-services/","https://www.apchq.com/formations/","https://www.apchq.com/outils/","https://www.apchq.com/recherche/?query="]}}
\ No newline at end of file
added src/lib/cost/connectors/__fixtures__/apchq-leger-2025-04-27.json +1 −0
@@ -0,0 +1 @@
1 +{"success":true,"data":{"markdown":"| Coût horaire de la main-d'œuvre - en vigueur au 27 avril 2025 *** changement avantages sociaux seulement ***Secteur RÉSIDENTIEL LÉGER | | | | | | | | | | | | | | | | | |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| APCHQValeur Unité | Taux horaire | Indemnité vacances | Avantages sociaux | Taxe sur assur. | Équip. de sécurité. | A-E | RQAP | RRQ¹ | FSS | Prélèv. CCQ | AECQ | Fonds d'indemn. | Fonds de formation | Contribution sectorielle | CNESST² | Total | |\n| APCHQValeur Unité | | 13,0% % | | 1 heure | 1 heure | 1,830% % | 0,692% % | 6,400% % | 1,65% % | 0,75% % | 0,03 $ heure | 0,02 $ heure | 0,20 $ heure | 0,043 $ heure | | | |\n| APCHQValeur Unité | | 5,32 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,85 $ | 0,32 $ | 3,03 $ | 0,81 $ | 0,35 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,30 $ | 63,91 $ | |\n| APCHQValeur Unité | Métiers et spécialitésBriqueteur-maçon | 40,89 $ | 5,32 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,85 $ | 0,32 $ | 3,03 $ | 0,81 $ | 0,35 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,30 $ | 63,91 $ |\n| Briqueteur-maçon | 40,89$ | 5,32$ | 7,890$ | 0,26$ | 0,60$ | 0,85$ | 0,32$ | 3,03$ | 0,81$ | 0,35$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 3,30$ | 63,91$ | |\n| Apprenti-période 1 | 24,53$ | 3,19$ | 7,020$ | 0,26$ | 0,60$ | 0,51$ | 0,19$ | 1,85$ | 0,50$ | 0,21$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 2,05$ | 41,20$ | |\n| Apprenti-période 2 | 28,62$ | 3,72$ | 7,020$ | 0,26$ | 0,60$ | 0,59$ | 0,22$ | 2,14$ | 0,58$ | 0,24$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 2,36$ | 46,65$ | |\n| Apprenti-période 3 | 34,76$ | 4,52$ | 7,020$ | 0,26$ | 0,60$ | 0,72$ | 0,27$ | 2,59$ | 0,69$ | 0,29$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 2,83$ | 54,84$ | |\n| Calorifugeur | 42,00$ | 5,46$ | 7,890$ | 0,26$ | 0,60$ | 0,87$ | 0,33$ | 3,11$ | 0,83$ | 0,36$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 1,46$ | 63,46$ | |\n| Apprenti-période 1 | 25,20$ | 3,28$ | 7,020$ | 0,26$ | 0,60$ | 0,52$ | 0,20$ | 1,89$ | 0,52$ | 0,21$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 0,91$ | 40,90$ | |\n| Apprenti-période 2 | 29,40$ | 3,82$ | 7,020$ | 0,26$ | 0,60$ | 0,61$ | 0,23$ | 2,20$ | 0,59$ | 0,25$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 1,04$ | 46,31$ | |\n| Apprenti-période 3 | 35,70$ | 4,64$ | 7,020$ | 0,26$ | 0,60$ | 0,74$ | 0,28$ | 2,65$ | 0,71$ | 0,30$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 1,25$ | 54,44$ | |\n| Apprenti-periode 3 | 35,70 $ | 4,64 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,74 $ | 0,28 $ | 2,65 $ | 0,71 $ | 0,30 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,25 $ | 54,44 $ | |\n| Carreleur | 40,89 $ | 5,32 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,85 $ | 0,32 $ | 3,03 $ | 0,81 $ | 0,35 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,91 $ | 63,52 $ | |\n| Apprenti-période 1 | 24,53 $ | 3,19 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,51 $ | 0,19 $ | 1,85 $ | 0,50 $ | 0,21 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,81 $ | 40,96 $ | |\n| Apprenti-période 2 | 28,62 $ | 3,72 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,59 $ | 0,22 $ | 2,14 $ | 0,58 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,08 $ | 46,37 $ | |\n| Apprenti-période 3 | 34,76 $ | 4,52 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,72 $ | 0,27 $ | 2,59 $ | 0,69 $ | 0,29 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,49 $ | 54,50 $ | |\n| Charpentier-menuisier | 40,16 $ | 5,22 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,83 $ | 0,31 $ | 2,98 $ | 0,79 $ | 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,86 $ | 62,54 $ | |\n| Apprenti-période 1 | 24,10 $ | 3,13 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,50 $ | 0,19 $ | 1,81 $ | 0,50 $ | 0,20 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,78 $ | 40,38 $ | |\n| Apprenti-période 2 | 28,11 $ | 3,65 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,58 $ | 0,22 $ | 2,10 $ | 0,57 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,05 $ | 45,69 $ | |\n| Apprenti-période 3 | 34.14 $ | 4.44 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,71 $ | 0,27 $ | 2,54 $ | 0,68 $ | 0,29 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,45 $ | 53,69 $ | |\n| Apprenti-période 2 | 28,11$ | 3,65$ | 7,020$ | 0,26$ | 0,60$ | 0,58$ | 0,22$ | 2,10$ | 0,57$ | 0,24$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 2,05$ | 45,69$ | |\n| Apprenti-période 3 | 34,14$ | 4,44$ | 7,020$ | 0,26$ | 0,60$ | 0,71$ | 0,27$ | 2,54$ | 0,68$ | 0,29$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 2,45$ | 53,69$ | |\n| Chaudronnier | 42,00$ | 5,46$ | 7,890$ | 0,26$ | 0,60$ | 0,87$ | 0,33$ | 3,11$ | 0,83$ | 0,36$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 1,46$ | 63,46$ | |\n| Apprenti-période 1 | 25,20$ | 3,28$ | 7,020$ | 0,26$ | 0,60$ | 0,52$ | 0,20$ | 1,89$ | 0,52$ | 0,21$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 0,91$ | 40,90$ | |\n| Apprenti-période 2 | 29,40$ | 3,82$ | 7,020$ | 0,26$ | 0,60$ | 0,61$ | 0,23$ | 2,20$ | 0,59$ | 0,25$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 1,04$ | 46,31$ | |\n| Apprenti-période 3 | 35,70$ | 4,64$ | 7,020$ | 0,26$ | 0,60$ | 0,74$ | 0,28$ | 2,65$ | 0,71$ | 0,30$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 1,25$ | 54,44$ | |\n| Cimentier-applicateur | 39,73$ | 5,16$ | 7,890$ | 0,26$ | 0,60$ | 0,82$ | 0,31$ | 2,94$ | 0,79$ | 0,34$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 2,83$ | 61,96$ | |\n| Apprenti-période 1 | 27,81$ | 3,62$ | 7,020$ | 0,26$ | 0,60$ | 0,58$ | 0,22$ | 2,08$ | 0,56$ | 0,24$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 2,03$ | 45,31$ | |\n| Apprenti-période 2 | 29,77$ | 4,22$ | 7,020$ | 0,26$ | 0,60$ | 0,79$ | 0,26$ | 2,51$ | 0,68$ | 0,23$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 2,43$ | 58,81$ | |\n| Apprenti-période 1 | 27,81 $ | 3,62 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,58 $ | 0,22 $ | 2,08 $ | 0,56 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,03 $ | 45,31 $ | |\n| Apprenti-période 2 | 33,77 $ | 4,39 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,70 $ | 0,26 $ | 2,51 $ | 0,68 $ | 0,29 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,43 $ | 53,21 $ | |\n| Coffreur à béton | 40,16 $ | 5,22 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,83 $ | 0,31 $ | 2,98 $ | 0,79 $ | 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,86 $ | 62,54 $ | |\n| Apprenti-période 1 | 24,10 $ | 3,13 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,50 $ | 0,19 $ | 1,81 $ | 0,50 $ | 0,20 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,78 $ | 40,38 $ | |\n| Apprenti-période 2 | 28,11 $ | 3,65 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,58 $ | 0,22 $ | 2,10 $ | 0,57 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,05 $ | 45,69 $ | |\n| Apprenti-période 3 | 34,14 $ | 4,44 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,71 $ | 0,27 $ | 2,54 $ | 0,68 $ | 0,29 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,45 $ | 53,69 $ | |\n| Après avoir complétée 4000 heures, s’il demeure apprenti, le salarié reçoit le taux de salaire de l&#x27;apprenti 3 du métier de charpentier-menuisier | | | | | | | | | | | | | | | | | |\n| Couvreur | 42,00 $ | 5,46 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,87 $ | 0,33 $ | 3,11 $ | 0,83 $ | 0,36 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,50 $ | 65,50 $ | |\n| Couvreur | 42,00$ | 5,46$ | 7,890$ | 0,26$ | 0,60$ | 0,87$ | 0,33$ | 3,11$ | 0,83$ | 0,36$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 3,50$ | 65,50$ | |\n| Apprenti-période 1 | 29,40$ | 3,82$ | 7,020$ | 0,26$ | 0,60$ | 0,61$ | 0,23$ | 2,20$ | 0,59$ | 0,25$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 2,51$ | 47,78$ | |\n| Apprenti-période 2 | 35,70$ | 4,64$ | 7,020$ | 0,26$ | 0,60$ | 0,74$ | 0,28$ | 2,65$ | 0,71$ | 0,30$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 3,01$ | 56,20$ | |\n| Électricien | 42,00$ | 5,46$ | 8,270$ | 0,26$ | 0,60$ | 0,87$ | 0,33$ | 3,13$ | 0,83$ | 0,36$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 1,38$ | 63,78$ | |\n| Apprenti-période 1 | 21,00$ | 2,73$ | 7,400$ | 0,26$ | 0,60$ | 0,43$ | 0,16$ | 1,61$ | 0,44$ | 0,18$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 0,73$ | 35,84$ | |\n| Apprenti-période 2 | 25,20$ | 3,28$ | 7,400$ | 0,26$ | 0,60$ | 0,52$ | 0,20$ | 1,91$ | 0,52$ | 0,21$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 0,86$ | 41,25$ | |\n| Apprenti-période 3 | 29,40$ | 3,82$ | 7,400$ | 0,26$ | 0,60$ | 0,61$ | 0,23$ | 2,21$ | 0,60$ | 0,25$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 0,99$ | 46,66$ | |\n| Apprenti-période 4 | 35,70$ | 4,64$ | 7,400$ | 0,26$ | 0,60$ | 0,74$ | 0,28$ | 2,67$ | 0,72$ | 0,30$ | 0,03$ | 0,02$ | 0,20$ | 0,043$ | 1,19$ | 54,79$ | |\n\n---\n\n| Coût horaire de la main-d'œuvre - en vigueur au 27 avril 2025 *** changement avantages sociaux seulement *** COÛT HORAIRE DE LA MAIN-D'OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 Secteur RÉSIDENTIEL LÉGER | | | | | | | | | | | | | | | | | |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| https://apchq.com/ | Taux horaire | Indemnité vacances | Avantages sociaux | Taxe sur assur. | Équip. de sécur. | A-E | RQAP | 1 RRQ | FSS | Prélèv. CCQ | AECQ | Fonds d'indemn. | Fonds de formation | Contribution sectorielle | CNESST | 2 | Total |\n| | Valeur | | | | | | | | | | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | | |\n| | Unité | 13,0% % | heure | 1 heure | 1 heure | 1,830% % | 0,692% % | 6,400% % | 1,65% % | 0,75% % | heure | heure | | heure | heure | | |\n\n---\n\n| https://apchq.com/ | Taux horaire vacances | Indemnité | Avantages sociaux | Taxe sur assur. | Équip. de sécur. | A-E | RQAP | 1 RRQ | FSS | Prélèv. | AECQ | Fonds d'indemn. | Fonds de formation sectorielle | Contribution | 2 Total CNESST |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| | | | | | | | | | | | | | 0,043 $ | | |\n| | | 13,0% % | heure | 1 heure | 1 heure | 1,830% % | 0,692% 6,400% % % | 1,65% % | 0,75% % | 0,03 $ heure | 0,02 $ heure | 0,20 $ heure | heure | | |\n| Métiers et spécialités | | | | | | | | | | | | | | | |\n| Ferblantier | | | | | | | | | | 0,36 $ 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,69 $ | 64,69 $ |\n| Apprenti-période 1 | 25,20 $ | 3,28 $ | 7,020 $ | 0,26 $ 0,60 $ | 0,52 $ | 0,20 $ | 1,89 $ | 0,52 $ | 0,21 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,68 $ | 41,67 $ |\n| Apprenti-période 2 | 29,40 $ | 3,82 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,61 $ | 0,23 $ 2,20 $ | 0,59 $ | 0,25 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,93 $ | 47,20 $ |\n| Apprenti-période 3 | 35,70 $ | 4,64 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,74 $ | 0,28 $ | 2,65 $ 0,71 $ | 0,30 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,31 $ | 55,50 $ |\n| | 41,12 $ | | 7,890 $ | | 0,60 $ | | 0,32 $ | | 0,35 $ | | 0,02 $ | 0,20 $ | 0,043 $ | 3,24 $ | 64,13 $ |\n| Ferrailleur Apprenti-période 1 | 34,95 $ | 5,35 $ 4,54 $ | 7,020 $ | 0,26 $ 0,26 $ | 0,60 $ | 0,85 $ 0,72 $ | 0,27 $ | 3,05 $ 0,81 $ 2,60 $ 0,70 $ | 0,30 $ | 0,03 $ 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,78 $ | 55,04 $ |\n| | | | | | | | | | | | | | 0,043 $ | | |\n| Frigoriste | 42,00 $ | 5,46 $ | 7,890 $ | 0,26 $ 0,60 $ | 0,87 $ | 0,33 $ | 3,11 $ | 0,83 $ | 0,36 $ | 0,03 $ | 0,02 $ | 0,20 $ | | 1,44 $ | 63,44 $ |\n| Apprenti-période 1 Apprenti-période 2 | 21,00 $ | 2,73 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,43 $ | 0,16 $ 1,59 $ | 0,44 $ | 0,18 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,76 $ | 35,47 $ |\n| Apprenti-période 3 | 25,20 $ | 3,28 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,52 $ | 0,20 $ 1,89 $ | 0,52 $ | 0,21 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,89 $ | 40,88 $ |\n| Apprenti-période 4 | 29,40 $ 35,70 $ | 3,82 $ | 7,020 $ 7,020 $ | 0,26 $ | 0,60 $ | 0,61 $ | 0,23 $ | 2,20 $ 0,59 $ | 0,25 $ | 0,03 $ | 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ 0,043 $ | 1,03 $ 1,23 $ | 46,30 $ 54,42 $ |\n| | | 4,64 $ | | 0,26 $ | 0,60 $ | 0,74 $ | 0,28 $ | 2,65 $ 0,71 $ | 0,30 $ | 0,03 $ | 0,02 $ | | | | |\n| Grutier - Classe A | 40,31 $ | 5,24 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,83 $ | 0,32 $ | 2,99 $ | 0,80 $ 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,70 $ | 61,57 $ |\n| Apprenti-période 1 Apprenti-période 2 | 28,22 $ | 3,67 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,58 $ | 0,22 $ | 2,11 $ 0,57 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,22 $ | 45,00 $ |\n| | 34,26 $ | 4,45 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,71 $ | 0,27 $ | 2,55 $ | 0,68 $ 0,29 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,46 $ | 52,84 $ |\n| Grutier - Classe B | 39,06 $ | 5,08 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,81 $ | 0,31 $ | 2,90 $ | 0,77 $ 0,33 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,65 $ | 59,95 $ |\n| Apprenti-période 1 | 27,34 $ | 3,55 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,57 $ | 0,21 $ | 2,05 $ | 0,56 $ 0,23 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,19 $ | 43,88 $ |\n| Apprenti-période 2 | 33,20 $ | 4,32 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,69 $ | 0,26 $ | 2,47 $ | 0,67 $ | 0,28 $ 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,42 $ | 51,48 $ |\n| Install. de sys. de sécurité | 33,81 $ | | 8,270 $ | 0,26 $ | 0,60 $ | 0,70 $ | 0,26 $ | | 0,68 $ 0,29 $ | | 0,02 $ | 0,20 $ | 0,043 $ | 0,75 $ | 52,85 $ |\n| Apprenti-période 1 | 20,29 $ | 4,40 $ 2,64 $ | 7,400 $ | 0,26 $ | 0,60 $ | 0,42 $ | 0,16 $ | 2,53 $ 1,56 $ | 0,43 $ 0,17 $ | 0,03 $ 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,47 $ | 34,69 $ |\n| Apprenti-période 2 | 23,67 $ | 3,08 $ | 7,400 $ | 0,26 $ | 0,60 $ | 0,49 $ | 0,19 $ | 1,80 $ | 0,49 $ 0,20 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,54 $ | 39,01 $ |\n| Apprenti-période 3 | 28,74 $ | 3,74 $ | 7,400 $ | 0,26 $ | 0,60 $ | 0,59 $ | 0,22 $ | 2,17 $ | 0,59 $ 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,65 $ | 45,50 $ |\n| | | | | | | | | | | | | | | | |\n| Mécanicien d'ascenseurs Apprenti-période 1 | 46,47 $ 23,24 $ | 6,04 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,96 $ | 0,36 $ | 3,43 $ 0,91 $ | 0,39 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ 0,043 $ | 1,60 $ 0,84 $ | 69,21 $ 38,36 $ |\n| Apprenti-période 2 | 27,88 $ | 3,02 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,48 $ | 0,18 $ | 1,75 $ 0,48 $ | 0,20 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | | 44,36 $ |\n| Apprenti-période 3 | 32,53 $ | 3,62 $ | 7,020 $ 7,020 $ | 0,26 $ | 0,60 $ | 0,58 $ | 0,22 $ | 2,09 $ 0,57 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ | 0,99 $ | 50,36 $ |\n| Apprenti-période 4 | 39,50 $ | 4,23 $ 5,14 $ | 7,020 $ | 0,26 $ 0,26 $ | 0,60 $ 0,60 $ | 0,67 $ | 0,25 $ | 2,42 $ 0,65 $ | 0,28 $ 0,33 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,15 $ | 59,36 $ |\n| Apprenti-période 5 | 39,50 $ | 5,14 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,82 $ 0,82 $ | 0,31 $ 0,31 $ | 2,93 $ 0,78 $ | 0,78 $ 0,33 $ | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ | 0,043 $ | 1,38 $ 1,38 $ | 59,36 $ |\n| | | | | | | | | 2,93 $ | | | | | | | |\n| Mécanicien de chantier | 42,00 $ | 5,46 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,87 $ | 0,33 $ | 3,11 $ | 0,83 $ 0,36 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,46 $ | 63,46 $ |\n| Apprenti-période 1 | 25,20 $ | 3,28 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,52 $ | 0,20 $ | 1,89 $ | 0,52 $ 0,21 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,91 $ | 40,90 $ |\n| Apprenti-période 2 | 29,40 $ | 3,82 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,61 $ | 0,23 $ | 2,20 $ | 0,59 $ 0,25 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,04 $ | 46,31 $ |\n| Apprenti-période 3 | 35,70 $ | 4,64 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,74 $ | 0,28 $ | 2,65 $ | 0,71 $ 0,30 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,25 $ | 54,44 $ |\n| | | | | | | | | | | | | | | | |\n| Méc. de machines lourdes | 40,66 $ | 5,29 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,84 $ | 0,32 $ | 3,01 $ 0,80 $ | 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,41 $ | 61,71 $ |\n| Apprenti-période 1 | 24,40 $ | 3,17 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,50 $ | 0,19 $ | 1,84 $ | 0,50 $ 0,21 $ | 0,03 $ | 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ 0,043 $ | 0,88 $ 1,01 $ | 39,86 $ 45,11 $ |\n| Apprenti-période 2 | 28,46 $ 34,56 $ | 3,70 $ 4,49 $ | 7,020 $ 7,020 $ | 0,26 $ 0,26 $ | 0,60 $ 0,60 $ | 0,59 $ 0,71 $ | 0,22 $ 0,27 $ | 2,13 $ 2,57 $ | 0,58 $ 0,69 $ | 0,24 $ 0,03 $ 0,29 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ | 0,043 $ | 1,21 $ | 52,96 $ |\n\n---\n\n| https://apchq.com/ | Taux horaire vacances | Indemnité | Avantages sociaux | Taxe sur assur. | Équip. de sécur. | A-E | RQAP | 1 RRQ | FSS | Prélèv. | Fonds AECQ | Fonds de d'indemn. formation | Contribution sectorielle | CNESST | 2 Total |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| | | 13,0% | | 1 | 1 | 1,830% | 0,692% | 6,400% | 1,65% | 0,75% | 0,03 $ 0,02 $ | 0,20 $ | 0,043 $ | | |\n| | | % | heure | heure | heure | % | % | % | % | heure | heure | heure | heure | | |\n| Métiers et spécialités | 42,00 $ | | 7,890 $ | 0,26 $ | 0,60 $ | 0,87 $ | 0,33 $ 3,11 $ | 0,83 $ | 0,36 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,46 $ | 63,46 $ |\n| Méc. protection-incendie Apprenti-période 1 | 21,00 $ | 5,46 $ 2,73 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,43 $ | 0,16 $ 1,59 $ | 0,44 $ | 0,18 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,77 $ | 35,48 $ |\n| Apprenti-période 2 | 25,20 $ | 3,28 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,52 $ | 0,20 $ 1,89 $ | 0,52 $ | 0,21 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,91 $ | 40,90 $ |\n| Apprenti-période 3 | 29,40 $ | 3,82 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,61 $ | 0,23 $ 2,20 $ | 0,59 $ | 0,25 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,04 $ | 46,31 $ |\n| Apprenti-période 4 | 35,70 $ | 4,64 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,74 $ | 0,28 $ 2,65 $ | 0,71 $ | 0,30 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,25 $ | 54,44 $ |\n| Monteur-assembleur | | | | | | | | | | | | | | | |\n| Apprenti-période 1 | 42,00 $ 25,20 $ | 5,46 $ | 7,890 $ 7,020 $ | 0,26 $ | 0,60 $ | 0,87 $ | 0,33 $ | 3,11 $ 0,83 $ | 0,36 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,83 $ | 65,83 $ 42,38 $ |\n| Apprenti-période 2 | 29,40 $ | 3,28 $ 3,82 $ | 7,020 $ | 0,26 $ 0,26 $ | 0,60 $ 0,60 $ | 0,52 $ | 0,20 $ | 1,89 $ 0,52 $ | 0,21 $ | 0,03 $ | 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ 0,043 $ | 2,39 $ 2,75 $ | 48,02 $ |\n| Apprenti-période 3 | 35,70 $ | 4,64 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,61 $ 0,74 $ | 0,23 $ 0,28 $ | 2,20 $ 0,59 $ 2,65 $ 0,71 $ | 0,25 $ 0,30 $ | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ | 0,043 $ | 3,29 $ | 56,48 $ |\n| | | | | | | | | | | | | | | | |\n| Monteur mécanicien vitrier | 39,56 $ | 5,14 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,82 $ | 0,31 $ | 2,93 $ | 0,78 $ 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,07 $ | 61,99 $ |\n| Apprenti-période 1 | 23,74 $ | 3,09 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,49 $ | 0,19 $ | 1,79 $ | 0,49 $ 0,20 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,91 $ | 40,07 $ |\n| Apprenti-période 2 Apprenti-période 3 | 27,69 $ 33,63 $ | 3,60 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,57 $ | 0,22 $ | 2,07 $ | 0,56 $ 0,23 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,20 $ | 45,31 $ |\n| | | 4,37 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,70 $ | 0,26 $ | 2,50 $ | 0,67 $ | 0,29 $ 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,64 $ | 53,24 $ |\n| Op. d'équip. l. - Classe A | 37,81 $ | 4,92 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,78 $ | 0,30 $ | 2,81 $ | 0,75 $ 0,32 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,60 $ | 58,33 $ |\n| Apprenti-période 1 | 32,14 $ | 4,18 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,66 $ | 0,25 $ | 2,40 $ | 0,65 $ 0,27 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,38 $ | 50,10 $ |\n| Op. d'équip. l. - Classe B | 36,91 $ | 4,80 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,76 $ | 0,29 $ | 2,74 $ | 0,73 $ 0,31 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,57 $ | 57,15 $ |\n| Apprenti-période 1 | 31,37 $ | 4,08 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,65 $ | 0,25 $ | 2,34 $ | 0,63 $ 0,27 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,35 $ | 49,11 $ |\n| Op. de p. méc. - Cl. A | 40,31 $ | 5,24 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | | | | | | | | 61,57 $ |\n| Apprenti-période 1 | 34,26 $ | 4,45 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,83 $ 0,71 $ | 0,32 $ 0,27 $ | 2,99 $ 0,80 $ 2,55 $ 0,68 $ | 0,34 $ 0,29 $ | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ 0,043 $ | 1,70 $ 1,46 $ | 52,84 $ |\n| Opé. de p. méc - Cl. B | | | | | | | | | | | | | | | |\n| Apprenti-période 1 | 39,06 $ 33,20 $ | 5,08 $ 4,32 $ | 7,890 $ 7,020 $ | 0,26 $ 0,26 $ | 0,60 $ 0,60 $ | 0,81 $ 0,69 $ | 0,31 $ 0,26 $ | 2,90 $ 0,77 $ 2,47 $ 0,67 $ | 0,33 $ 0,28 $ | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ 0,043 $ | 1,65 $ 1,42 $ | 59,95 $ 51,48 $ |\n| Op. pompe béton Cl. A mat +42 m. | | | | | | | | | | | | | | | |\n| | 39,03 $ | 5,07 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,81 $ | 0,31 $ | 2,89 $ 0,77 $ | 0,33 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,09 $ | 61,34 $ |\n| Op. pompe béton Cl. B mat -42 m. Apprenti-période 1 | 36,90 $ | 4,80 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,76 $ | 0,29 $ | 2,74 $ 0,73 $ | 0,31 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,93 $ | 58,50 $ |\n| | 33,18 $ | 4,31 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,69 $ | 0,26 $ | 2,47 $ 0,66 $ | 0,28 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,65 $ | 52,67 $ |\n| Parqueteur-sableur | 40,16 $ | 5,22 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,83 $ | 0,31 $ | 2,98 $ 0,79 $ | 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,86 $ | 62,54 $ |\n| Apprenti-période 1 | 24,10 $ | 3,13 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,50 $ | 0,19 $ | 1,81 $ 0,50 $ | 0,20 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,78 $ | 40,38 $ |\n| Apprenti-période 2 | 28,11 $ | 3,65 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,58 $ | 0,22 $ | 2,10 $ 0,57 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,05 $ | 45,69 $ |\n| Apprenti-période 3 | 34,14 $ | 4,44 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,71 $ | 0,27 $ | 2,54 $ | 0,68 $ 0,29 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,45 $ | 53,69 $ |\n| Après avoir complétée 4000 heures, s'il demeure apprenti, le salarié reçoit le taux de salaire de l'apprenti 3 du métier de charpentier-menuisier | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | |\n| Peintre | 37,57 $ | 4,88 $ | 7,890 $ | 0,26 $ | 0,60 $ | 0,78 $ | 0,29 $ | 2,79 $ | 0,75 $ | 0,32 $ 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,68 $ | 59,11 $ |\n| Apprenti-période 1 | 22,54 $ | 2,93 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,47 $ | 0,18 $ | 1,70 $ | 0,47 $ 0,19 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,68 $ | 38,33 $ |\n| Apprenti-période 2 | 26,30 $ | 3,42 $ | 7,020 $ | 0,26 $ | 0,60 $ | 0,54 $ | 0,21 $ | 1,97 $ | 0,54 $ | 0,22 $ 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,93 $ | 43,30 $ |\n\n---\n\n| | | | | | | | | Coût horaire de la main-d&#x27;œuvre - en vigueur au 27 avril 2025 *** changement avantages sociaux seulement *** COÛT HORAIRE DE LA MAIN-D&#x27;OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 Secteur RÉSIDENTIEL LÉGER | | | | | | | | | | | |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| https://apchq.com/ Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | Équip. de sécur. | | A-E | RQAP | | 1 RRQ 6,400% | FSS | Prélèv. CCQ | | AECQ 0,03 $ heure | Fonds d&#x27;indemn. | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |\n| https://apchq.com/ Valeur Unité | | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | | | | | | 1 RRQ 6,400% | | | | AECQ 0,03 $ heure | | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |\n| https://apchq.com/ Valeur Unité | | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | 1 heure | | 1,830% % | 0,692% % | | 1 RRQ 6,400% | 1,65% % | 0,75% % | | AECQ 0,03 $ heure | 0,02 $ heure | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |\n| Métiers et spécialités | | | | | | | | | | | | | | | | | | | |\n| Plâtrier | 39,48 $ | 5,13 $ | 7,890 $ | 0,26 $ | 0,60 $ | | 0,82 $ | 0,31 $ | | 2,93 $ | 0,78 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,81 $ | 61,63 $ |\n| Apprenti-période 1 | 23,69 $ | 3,08 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,49 $ | 0,19 $ | | 1,78 $ | 0,49 $ | 0,20 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,75 $ | 39,84 $ |\n| Apprenti-période 2 | 27,64 $ | 3,59 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,57 $ | 0,22 $ | | 2,07 $ | 0,56 $ | 0,23 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,02 $ | 45,07 $ |\n| Apprenti-période 3 | 33,56 $ | 4,36 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,69 $ | 0,26 $ | | 2,50 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,41 $ | 52,91 $ |\n| Poseur de fondation profonde | 40,16 $ | 5,22 $ | 7,890 $ | 0,26 $ | 0,60 $ | | 0,83 $ | 0,31 $ | | 2,98 $ | 0,79 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,93 $ | 61,61 $ |\n| | | | | | | | | | | | | | | | | | | | |\n| Apprenti-période 1 | 24,10 $ | 3,13 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,50 $ | 0,19 $ | | 1,81 $ | 0,50 $ | 0,20 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,20 $ | 39,80 $ |\n| Apprenti-période 2 | 28,11 $ | 3,65 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,58 $ | 0,22 $ | | 2,10 $ | 0,57 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,39 $ | 45,03 $ |\n| | | | | | | | | | | | | | | | | | | | |\n| Apprenti-période 3 | 34,14 $ | 4,44 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,71 $ | 0,27 $ | | 2,54 $ | 0,68 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,66 $ | 52,90 $ |\n| | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | |\n| Apprenti-période 1 | 21,97 $ | 2,86 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,45 $ | 0,17 $ | | 1,66 $ | 0,46 $ | 0,19 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,64 $ | 37,57 $ |\n| | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | |\n| | | | | | | | | | | | | | | | | | | | |\n| Apprenti-période 2 | 25,63 $ | 3,33 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,53 $ | 0,20 $ | | 1,92 $ | 0,52 $ | 0,22 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,88 $ | 42,40 $ |\n| | | | | | | | | | | | | | | | | | | | |\n| Apprenti-période 3 | 31,12 $ | 4,05 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,64 $ | 0,24 $ | | 2,32 $ | 0,63 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,25 $ | 49,69 $ |\n| | | | | | | | | | | | | | | | | | | | |\n| Poseur de systèmes int. | 40,16 $ | 5,22 $ | 7,890 $ | 0,26 $ | 0,60 $ | | 0,83 $ | 0,31 $ | | 2,98 $ | 0,79 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,86 $ | 62,54 $ |\n| | | | | | | | | | | | | | | | | | | | |\n| Apprenti-période 1 | 24,10 $ | 3,13 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,50 $ | 0,19 $ | | 1,81 $ | 0,50 $ | 0,20 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,78 $ | 40,38 $ |\n| | | | | | | | | | | | | | | | | | | | |\n| Apprenti-période 2 | 28,11 $ | 3,65 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,58 $ | 0,22 $ | | 2,10 $ | 0,57 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,05 $ | 45,69 $ |\n| Apprenti-période 3 | 34,14 $ | 4,44 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,71 $ | 0,27 $ | | 2,54 $ | 0,68 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,45 $ | 53,69 $ |\n| | | | | | | | | | | | | | | | | | | | |\n| Tir. joints (peint./plâtr.) | 38,87 $ | 5,05 $ | 7,890 $ | 0,26 $ | 0,60 $ | | 0,80 $ | 0,30 $ | | 2,88 $ | 0,77 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,77 $ | 60,82 $ |\n| Apprenti-période 1 | 23,32 $ | 3,03 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,48 $ | 0,18 $ | | 1,76 $ | 0,48 $ | 0,20 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,73 $ | 39,36 $ |\n| Apprenti-période 2 | 27,21 $ | 3,54 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,56 $ | 0,21 $ | | 2,04 $ | 0,55 $ | 0,23 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,99 $ | 44,51 $ |\n| Apprenti-période 3 | 33,04 $ | 4,30 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,68 $ | 0,26 $ | | 2,46 $ | 0,66 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,38 $ | 52,23 $ |\n| Tuyauteur | 42,00 $ | 5,46 $ | 7,890 $ | 0,26 $ | 0,60 $ | | 0,87 $ | 0,33 $ | | 3,11 $ | 0,83 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,46 $ | 63,46 $ |\n| Apprenti-période 1 | 21,00 $ | 2,73 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,43 $ | 0,16 $ | | 1,59 $ | 0,44 $ | 0,18 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,77 $ | 35,48 $ |\n| Apprenti-période 2 | 25,20 $ | 3,28 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,52 $ | 0,20 $ | | 1,89 $ | 0,52 $ | 0,21 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,91 $ | 40,90 $ |\n| Apprenti-période 3 | 29,40 $ | 3,82 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,61 $ | 0,23 $ | | 2,20 $ | 0,59 $ | 0,25 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,04 $ | 46,31 $ |\n| Apprenti-période 4 | 35,70 $ | 4,64 $ | 7,020 $ | 0,26 $ | 0,60 $ | | 0,74 $ | 0,28 $ | | 2,65 $ | 0,71 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,25 $ | 54,44 $ |\n\n---\n\n| https://apchq.com/ Valeur | Taux horaire | Indemnité vacances 13,0% | Avantages sociaux | Taxe sur assur. 1 | Équip. de sécur. | | | A-E | RQAP | 0,692% | | 1 RRQ | FSS | Prélèv. CCQ | AECQ 0,03 $ | | Fonds d&#x27;indemn. | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ | 2 CNESST | Total |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| https://apchq.com/ Valeur | | Indemnité vacances 13,0% | Avantages sociaux | Taxe sur assur. 1 | 1 | | | 1,830% | | 0,692% | | 1 RRQ | 1,65% | 0,75% | AECQ 0,03 $ | | 0,02 $ | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ | 2 CNESST | Total |\n| Unité | | % | Avantages sociaux | heure | heure | | | % | % | | | % | % | % | | heure | heure | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ | 2 CNESST | |\n| Métiers et spécialités | | | | | | | | | | | | | | | | | | | | | |\n| Occupations | | | | | | | | | | | | | | | | | | | | | |\n| Boutefeu | 35,18 $ | 4,57 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,73 $ | 0,28 $ | | | 2,62 $ | 0,70 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,71 $ | 55,13 $ |\n| Conducteur de cam. - Classe A | 33,99 $ | 4,42 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,70 $ | 0,27 $ | | | 2,53 $ | 0,68 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,78 $ | 53,70 $ |\n| Conducteur de cam. - Classe B | 33,06 $ | 4,30 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,68 $ | 0,26 $ | | | 2,46 $ | 0,66 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,73 $ | 52,47 $ |\n| Conducteur de cam. Classe C | 32,69 $ | 4,25 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,68 $ | 0,26 $ | | | 2,44 $ | 0,66 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,71 $ | 52,01 $ |\n| Foreur | 36,33 $ | 4,72 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,75 $ | 0,28 $ | | | 2,70 $ | 0,72 $ | 0,31 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,76 $ | 56,62 $ |\n| | | | | | | | | | | | | | | | | | | | | | |\n| Homme d&#x27;inst. (arpenteur) | 37,43 $ | 4,87 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,77 $ | 0,29 $ | | | 2,78 $ | 0,74 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,67 $ | 58,92 $ |\n| Manœuvre (journalier) | 32,46 $ | 4,22 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,67 $ | 0,25 $ | | | 2,42 $ | 0,65 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,34 $ | 52,34 $ |\n| Manœuvre spécialisé | 33,36 $ | 4,34 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,69 $ | 0,26 $ | | | 2,48 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,40 $ | 53,52 $ |\n| Man. spéc. (carreleur) | 33,99 $ | 4,42 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,70 $ | 0,27 $ | | | 2,53 $ | 0,68 $ | 0,29 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,44 $ | 54,36 $ |\n| | | | | | | | | | | | | | | | | | | | | | |\n| Op. d&#x27;appareils de lev. A | 37,14 $ | 4,83 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,77 $ | 0,29 $ | | | 2,76 $ | 0,74 $ | 0,31 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,65 $ | 58,53 $ |\n| | | | | | | | | | | | | | | | | | | | | | |\n| Op. d&#x27;appareils de lev. B | 35,76 $ | 4,65 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,74 $ | 0,28 $ | | | 2,66 $ | 0,71 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,56 $ | 56,70 $ |\n| | | | | | | | | | | | | | | | | | | | | | |\n| Op. de concasseur | 33,36 $ | 4,34 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,69 $ | 0,26 $ | | | 2,48 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,40 $ | 53,52 $ |\n| | | | | | | | | | | | | | | | | | | | | | |\n| Op. pompes compresseurs | 36,33 $ | 4,72 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,75 $ | 0,28 $ | | | 2,70 $ | 0,72 $ | 0,31 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,60 $ | 57,46 $ |\n| Op. usines asphalte | 33,36 $ | 4,34 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,69 $ | 0,26 $ | | | 2,48 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,40 $ | 53,52 $ |\n| | | | | | | | | | | | | | | | | | | | | | |\n| Opérateur usines fixes mobiles | 33,36 $ | 4,34 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,69 $ | 0,26 $ | | | 2,48 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,40 $ | 53,52 $ |\n| Plongeur | 42,97 $ | 5,59 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,89 $ | 0,34 $ | | | 3,18 $ | 0,85 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,04 $ | 66,26 $ |\n| Soudeur | 39,80 $ | 5,17 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,82 $ | 0,31 $ | | | 2,95 $ | 0,79 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,83 $ | 62,05 $ |\n| Soudeur monteur-assembleur | 42,00 $ | 5,46 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,87 $ | 0,33 $ | | | 3,11 $ | 0,83 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,98 $ | 64,98 $ |\n| Soudeur distribution (gaz) | 42,00 $ | 5,46 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,87 $ | 0,33 $ | | | 3,11 $ | 0,83 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,98 $ | 64,98 $ |\n| Soudeur machineries lourdes | 39,80 $ | 5,17 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,82 $ | 0,31 $ | | | 2,95 $ | 0,79 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,83 $ | 62,05 $ |\n| Soudeur tuyauterie | 42,00 $ | 5,46 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,87 $ | 0,33 $ | | | 3,11 $ | 0,83 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,98 $ | 64,98 $ |\n| Spéc. en branch. d&#x27;imm. | 42,00 $ | 5,46 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,87 $ | 0,33 $ | | | 3,11 $ | 0,83 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,98 $ | 64,98 $ |\n| Travailleur souterrain | 37,05 $ | 4,82 $ | 7,890 $ | 0,26 $ | 0,60 $ | | | 0,77 $ | 0,29 $ | | | 2,75 $ | 0,74 $ | 0,31 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,64 $ | 58,41 $ |\n\n1. Une cotisation supplémentaire doit être calculée pour l'excédent du maximun assurable de 71 300$. Voir guide de l'employeur du gouvernement provincial.\n2.Le montant de la cotisation CNESST n'est présenté qu'à titre indicatif. Il est basé sur le taux de l'unité de classification pour l'année 2025","metadata":{"scrapeId":"01a0795b-d991-7333-a67a-126fc0dfb503","sourceURL":"https://media.apchq.com/cU48rp9XSWGkRmmPHOodxg/cout-main-d-oeuvre-residentiel-leger-27-avril-2025-temps-simple.pdf","url":"https://media.apchq.com/cU48rp9XSWGkRmmPHOodxg/cout-main-d-oeuvre-residentiel-leger-27-avril-2025-temps-simple.pdf","statusCode":200,"numPages":6,"totalPages":6,"contentType":"application/pdf","proxyUsed":"basic","cacheState":"miss","indexId":"aacdb578-0ab3-4f83-82f1-a4c198c896f9","creditsUsed":6,"concurrencyLimited":false}}}
\ No newline at end of file
added src/lib/cost/connectors/__fixtures__/apchq-residentiel-leger-2026-04-26.md +226 −0
@@ -0,0 +1,226 @@
1 +| | horaire | Indemnité vacances | Avantages sociaux | Taxe sur assur. | Équip. de sécur. | A-E | RQAP | 1 RRQ | FSS | Prélèv. CCQ | AECQ | Fonds d'indemn. | Fonds de formation | Contribution CNESST sectorielle | 2 Total | | | | | | | |
2 +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
3 +| | | | | | | | | | | 0,03 $ 0,02 $ | | | 0,20 $ 0,043 $ | | | | | | | | | |
4 +| | | % | heure | heure | heure | % | % | % | % | 0,03 $ 0,02 $ | | | % | heure | heure | heure | heure | | | | | |
5 +| Métiers et spécialités | | | | | | | | | | | 0,03 $ | 0,02 $ | 0,20 $ | | 0,043 $ | 4,15 $ | | | | | | |
6 +| Briqueteur-maçon | 46,59 $ 27,96 $ | 6,06 $ | 8,140 $ 7,220 $ | 0,26 $ | 0,65 $ 0,65 $ | 0,96 $ | 0,32 $ 0,19 $ | 3,39 $ | 0,91 $ 0,57 $ | 0,39 $ 0,24 $ | 0,03 $ | | 0,20 $ | 0,043 $ | | 72,11 $ 46,22 $ | | | | | | |
7 +| Apprenti-période 1 | 32,61 $ | 3,63 $ | | 0,26 $ | | 0,57 $ | | 2,06 $ | 0,65 $ | | | 0,02 $ | 0,20 $ | | 2,58 $ 2,97 $ | | | | | | | |
8 +| Apprenti-période 2 Apprenti-période 3 | 39,60 $ | 4,24 $ | 7,220 $ 7,220 $ | 0,26 $ 0,26 $ | 0,65 $ 0,65 $ | 0,67 $ 0,81 $ | 0,22 $ 0,27 $ | 2,39 $ 2,89 $ | 0,78 $ | 0,28 $ 0,34 $ | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ | 0,043 $ 0,043 $ | 3,56 $ | 52,45 $ 61,82 $ | | | | | | |
9 +| | | 5,15 $ | | | | | | | | | | | | | | | | | | | | |
10 +| Calorifugeur | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,65 $ | 0,98 $ | 0,33 $ | 3,48 $ | 0,94 $ | 0,41 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,72 $ | 71,27 $ | | | | | | |
11 +| Apprenti-période 1 | 28,72 $ | 3,73 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,59 $ | 0,20 $ | 2,11 $ | 0,58 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,07 $ | 45,66 $ | | | | | | |
12 +| Apprenti-période 2 Apprenti-période 3 | 33,50 $ | 4,36 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,69 $ | 0,23 $ | 2,45 $ | 0,67 $ | 0,28 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,23 $ | 51,83 $ | | | | | | |
13 +| | 40,68 $ | 5,29 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,84 $ | 0,28 $ | 2,97 $ | 0,80 $ | 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,48 $ | 61,10 $ | | | | | | |
14 +| Carreleur | 46,59 $ | 6,06 $ | 8,140 $ | 0,26 $ | 0,65 $ | 0,96 $ | 0,32 $ | 3,39 $ | 0,91 $ | 0,39 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,37 $ | 71,33 $ | | | | | | |
15 +| Apprenti-période 1 | 27,96 $ | 3,63 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,57 $ | 0,19 $ | 2,06 $ | 0,57 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,09 $ | 45,73 $ | | | | | | |
16 +| Apprenti-période 2 | 32,61 $ | 4,24 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,67 $ | 0,22 $ | 2,39 $ | 0,65 $ | 0,28 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,41 $ | 51,89 $ | | | | | | |
17 +| Apprenti-période 3 | 39,60 $ | 5,15 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,81 $ | 0,27 $ | 2,89 $ | 0,78 $ | 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,89 $ | 61,15 $ | | | | | | |
18 +| Charpentier-menuisier | | | | | | | | | | | | | | | | | | | | | | |
19 +| Apprenti-période 1 | 45,76 $ 27,45 $ | 5,95 $ | 8,140 $ 7,220 $ | 0,26 $ 0,26 $ | 0,65 $ 0,65 $ | 0,94 $ | 0,31 $ 0,19 $ | 3,33 $ | 0,90 $ 0,56 $ | 0,39 $ 0,23 $ | 0,03 $ 0,03 $ | 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ | 3,31 $ 2,06 $ | 70,23 $ 45,06 $ | | | | | | |
20 +| Apprenti-période 2 | 32,03 $ | 3,57 $ 4,16 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,56 $ 0,66 $ | 0,22 $ | 2,02 $ 2,35 $ | 0,64 $ | 0,27 $ | 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ | 0,043 $ 0,043 $ | 2,37 $ | 51,12 $ | | | | | | |
21 +| Apprenti-période 3 | 38,90 $ | 5,06 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,80 $ | 0,26 $ | 2,84 $ | 0,77 $ | 0,33 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,84 $ | 60,23 $ | | | | | | |
22 +| | | | | | | | | | | | | | | | | | | | | | | |
23 +| Apprenti-période 1 | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,65 $ | 0,98 $ | 0,33 $ | 3,48 $ | 0,94 $ | 0,41 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,72 $ | 71,27 $ | | | | | | |
24 +| Apprenti-période 2 | 28,72 $ | 3,73 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,59 $ | 0,20 $ | 2,11 $ | 0,58 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,07 $ | 45,66 $ | | | | | | |
25 +| Apprenti-période 3 | 33,50 $ | 4,36 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,69 $ | 0,23 $ | 2,45 $ | 0,67 $ | 0,28 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,23 $ | 51,83 $ | | | | | | |
26 +| | 40,68 $ | 5,29 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,84 $ | 0,28 $ | 2,97 $ | 0,80 $ | 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,48 $ | 61,10 $ | | | | | | |
27 +| Cimentier-applicateur | 45,27 $ | 5,89 $ | 8,140 $ | 0,26 $ | 0,65 $ | 0,93 $ | 0,31 $ | | 0,89 $ | 0,38 $ | | | | | | 70,20 $ | | | | | | |
28 +| Apprenti-période 1 | 31,69 $ | 4,12 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,65 $ | 0,22 $ | 3,29 $ 2,33 $ | 0,64 $ | 0,27 $ | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ 0,043 $ | 3,90 $ 2,79 $ | 51,13 $ | | | | | | |
29 +| Apprenti-période 2 | 38,48 $ | 5,00 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,79 $ | 0,26 $ | 2,81 $ | 0,76 $ | 0,33 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,34 $ | 60,19 $ | | | | | | |
30 +| Coffreur à béton | | | | | | | | | | | | | | | | | | | | | | |
31 +| Apprenti-période 1 | 45,76 $ | 5,95 $ | 8,140 $ | 0,26 $ | 0,65 $ | 0,94 $ | 0,31 $ | 3,33 $ | 0,90 $ | 0,39 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,31 $ | 70,23 $ | | | | | | |
32 +| Apprenti-période 2 | 27,45 $ | 3,57 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,56 $ | 0,19 $ | 2,02 $ | 0,56 $ | 0,23 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,06 $ | 45,06 $ | | | | | | |
33 +| Apprenti-période 3 | 32,03 $ | 4,16 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,66 $ | 0,22 $ | 2,35 $ | 0,64 $ | 0,27 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,37 $ | 51,12 $ | | | | | | |
34 +| Après avoir complétée 4000 heures, s'il demeure apprenti, le salarié reçoit le taux de salaire de l'apprenti 3 du métier de charpentier-menuisier | 38,90 $ | 5,06 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,80 $ | 0,26 $ | 2,84 $ | 0,77 $ | 0,33 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,84 $ | 60,23 $ | | | | | | |
35 +| | | | | | | | | | | | | | | | | | | | | | | |
36 +| Couvreur | 47,85 $ | | 8,140 $ | 0,26 $ | 0,65 $ | | 0,33 $ | | 0,94 $ | 0,41 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 4,50 $ | 74,05 $ | | | | | | |
37 +| Apprenti-période 1 | 33,50 $ | 6,22 $ 4,36 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,98 $ 0,69 $ | 0,23 $ | 3,48 $ 2,45 $ | 0,67 $ | 0,28 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,22 $ | 53,82 $ | | | | | | |
38 +| Apprenti-période 2 | 40,68 $ | 5,29 $ | 7,220 $ | 0,26 $ | 0,65 $ | 0,84 $ | 0,28 $ | 2,97 $ | 0,80 $ | 0,34 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,86 $ | 63,48 $ | | | | | | |
39 +| | | | | | | | | | | | | | | | | | | | | | | |
40 +| Électricien | 47,85 $ | 6,22 $ | 8,608 $ | 0,26 $ | 0,91 $ | 0,98 $ | 0,33 $ | 3,50 $ | 0,94 $ | 0,41 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,81 $ | 72,11 $ | | | | | | |
41 +| Apprenti-période 1 | 23,93 $ | 3,11 $ | 7,688 $ | 0,26 $ | 0,65 $ | 0,49 $ | 0,16 $ | 1,80 $ | 0,50 $ | 0,20 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,96 $ | 40,04 $ | | | | | | |
42 +| Apprenti-période 2 | 28,72 $ | 3,73 $ | 7,688 $ | 0,26 $ | 0,65 $ | 0,59 $ | 0,20 $ | 2,14 $ | 0,59 $ | 0,24 $ | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,13 $ | 46,23 $ | | | | | | |
43 +| Apprenti-période 3 | 33,50 $ 40,68 $ | 4,36 $ | 7,688 $ 5,29 $ 7,688 $ | 0,26 $ 0,26 $ | 0,65 $ 0,65 $ | 0,69 $ | 0,23 $ 0,84 $ 0,28 $ | 2,48 $ 2,99 $ | 0,68 $ 0,81 $ | 0,28 $ 0,34 $ | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ 0,043 $ | 1,30 $ 1,56 $ | 52,41 $ 61,68 $ | | | | | | |
44 +
45 +---
46 +
47 +| Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | Équip. de sécur. 0,65 | | A-E | RQAP 0,602% % | 1 RRQ 6,300% % | FSS 1,65% | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | Fonds d&#x27;indemn. | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |
48 +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
49 +| Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | Équip. de sécur. 0,65 | | 1,820% | RQAP 0,602% % | 1 RRQ 6,300% % | FSS 1,65% | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | 0,02 $ | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |
50 +| Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | Équip. de sécur. 0,65 | | % | RQAP 0,602% % | 1 RRQ 6,300% % | FSS 1,65% | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | heure | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |
51 +| Métiers et spécialités | | | | | | | | | | | | | | | | | | |
52 +| Ferblantier | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,98 $ | 0,33 $ | 3,48 $ | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,06 $ | 72,61 $ |
53 +| Apprenti-période 1 | 28,72 $ | 3,73 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,59 $ | 0,20 $ | 2,11 $ | 0,58 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,90 $ | 46,49 $ |
54 +| Apprenti-période 2 | 33,50 $ | 4,36 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,69 $ | 0,23 $ | 2,45 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,19 $ | 52,79 $ |
55 +| 40,68 $ 5,29 $ 7,220 $ 0,26 $ 0,65 $ 0,84 $ 0,28 $ 2,97 $ 0,80 $ 0,34 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 2,63 $ 62,25 $ Apprenti-période 3 | | | | | | | | | | | | | | | | | | |
56 +| | | | | | | | | | | | | | | | | | | |
57 +| Poseur d&#x27;armature du béton | 46,85 $ | 6,09 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,96 $ | 0,32 $ | 3,40 $ | 0,92 $ | 0,40 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 4,02 $ | 72,30 $ |
58 +| 39,83 $ 5,18 $ 7,220 $ 0,26 $ 0,65 $ 0,82 $ 0,27 $ 2,91 $ 0,79 $ 0,34 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 3,45 $ 62,01 $ Apprenti-période 1 | | | | | | | | | | | | | | | | | | |
59 +| Frigoriste | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,98 $ | 0,33 $ | 3,48 $ | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,59 $ | 71,14 $ |
60 +| Apprenti-période 1 | 23,93 $ | 3,11 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,49 $ | 0,16 $ | 1,77 $ | 0,49 $ | 0,20 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,83 $ | 39,41 $ |
61 +| Apprenti-période 2 | 28,72 $ | 3,73 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,59 $ | 0,20 $ | 2,11 $ | 0,58 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,98 $ | 45,57 $ |
62 +| Apprenti-période 3 | 33,50 $ | 4,36 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,69 $ | 0,23 $ | 2,45 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,13 $ | 51,73 $ |
63 +| Apprenti-période 4 | 40,68 $ | 5,29 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,84 $ | 0,28 $ | 2,97 $ | 0,80 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,36 $ | 60,98 $ |
64 +| Grutier - Classe A | 45,93 $ | 5,97 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,94 $ | 0,31 $ | 3,34 $ | 0,90 $ | 0,39 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,06 $ | 69,19 $ |
65 +| | | | | | | | | | | | | | | | | | | |
66 +| Apprenti-période 1 Apprenti-période 2 | 32,15 $ 39,04 $ | 4,18 $ 5,08 $ | 7,220 $ 7,220 $ | 0,26 $ 0,26 $ | 0,65 $ 0,65 $ | | 0,66 $ 0,80 $ | 0,22 $ 0,27 $ | 2,36 $ 2,85 $ | 0,65 $ 0,77 $ | 0,27 $ 0,33 $ | | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ 0,043 $ | 1,47 $ 1,76 $ | 50,38 $ 59,32 $ |
67 +| | | | | | | | | | | | | | | | | | | |
68 +| | | | | | | | | | | | | | | | | | | |
69 +| Grutier - Classe B | 44,50 $ | 5,79 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,92 $ | 0,30 $ | 3,24 $ | 0,88 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,00 $ | 67,36 $ |
70 +| Apprenti-période 1 | 31,15 $ | 4,05 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,64 $ | 0,21 $ | 2,29 $ | 0,63 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,43 $ | 49,08 $ |
71 +| | | | | | | | | | | | | | | | | | | |
72 +| Apprenti-période 2 | 37,83 $ | 4,92 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,78 $ | 0,26 $ | 2,76 $ | 0,75 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,71 $ | 57,75 $ |
73 +| | | | | | | | | | | | | | | | | | | |
74 +| | | | | | | | | | | | | | | | | | | |
75 +| Install. de sys. de sécurité | 38,52 $ | 5,01 $ | 8,608 $ | 0,26 $ | 0,65 $ | | 0,79 $ | 0,26 $ | 2,83 $ | 0,77 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,97 $ | 59,29 $ |
76 +| | | | | | | | | | | | | | | | | | | |
77 +| Apprenti-période 1 | 23,12 $ | 3,01 $ | 7,688 $ | 0,26 $ | 0,65 $ | | 0,48 $ | 0,16 $ | 1,74 $ | 0,48 $ | 0,20 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,61 $ | 38,69 $ |
78 +| Apprenti-période 2 | 26,97 $ | 3,51 $ | 7,688 $ | 0,26 $ | 0,65 $ | | 0,55 $ | 0,18 $ | 2,01 $ | 0,55 $ | 0,23 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,70 $ | 43,59 $ |
79 +| Apprenti-période 3 | 32,75 $ | 4,26 $ | 7,688 $ | 0,26 $ | 0,65 $ | | 0,67 $ | 0,22 $ | 2,42 $ | 0,66 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,84 $ | 50,99 $ |
80 +| | | | | | | | | | | | | | | | | | | |
81 +| | | | | | | | | | | | | | | | | | | |
82 +| Mécanicien d&#x27;ascenseurs | 52,95 $ | 6,88 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 1,09 $ | 0,36 $ | 3,84 $ | 1,03 $ | 0,45 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,90 $ | 77,84 $ |
83 +| Apprenti-période 1 | 26,47 $ | 3,44 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,54 $ | 0,18 $ | 1,95 $ | 0,54 $ | 0,22 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,99 $ | 42,75 $ |
84 +| Apprenti-période 2 | 31,77 $ | 4,13 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,65 $ | 0,22 $ | 2,33 $ | 0,64 $ | 0,27 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,17 $ | 49,60 $ |
85 +| Apprenti-période 3 | 37,06 $ | 4,82 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,76 $ | 0,25 $ | 2,71 $ | 0,74 $ | 0,31 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,35 $ | 56,43 $ |
86 +| | | | | | | | | | | | | | | | | | | |
87 +| Apprenti-période 4 Apprenti-période 5 | 45,01 $ 45,01 $ | 5,85 $ 5,85 $ | 7,220 $ 7,220 $ | 0,26 $ 0,26 $ | 0,65 $ 0,65 $ | | 0,93 $ 0,93 $ | 0,31 $ 0,31 $ | 3,27 $ 3,27 $ | 0,89 $ 0,89 $ | 0,38 $ 0,38 $ | | 0,03 $ 0,03 $ | 0,02 $ 0,02 $ | 0,20 $ 0,20 $ | 0,043 $ 0,043 $ | 1,63 $ 1,63 $ | 66,69 $ 66,69 $ |
88 +| | | | | | | | | | | | | | | | | | | |
89 +| Mécanicien de chantier | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,98 $ | 0,33 $ | 3,48 $ | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,72 $ | 71,27 $ |
90 +| Apprenti-période 1 | 28,72 $ | 3,73 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,59 $ | 0,20 $ | 2,11 $ | 0,58 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,07 $ | 45,66 $ |
91 +| Apprenti-période 2 | 33,50 $ | 4,36 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,69 $ | 0,23 $ | 2,45 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,23 $ | 51,83 $ |
92 +| Apprenti-période 3 | | | | | | | | | | | | | | | | | | |
93 +| Méc. de machines lourdes | 46,33 $ | 6,02 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,95 $ | 0,32 $ | 3,37 $ | 0,91 $ | 0,39 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,67 $ | 69,30 $ |
94 +| Apprenti-période 1 | 27,80 $ | 3,61 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,57 $ | 0,19 $ | 2,05 $ | 0,56 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,04 $ | 44,48 $ |
95 +| Apprenti-période 2 | 32,43 $ | 4,22 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,67 $ | 0,22 $ | 2,38 $ | 0,65 $ | 0,27 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,20 $ | 50,46 $ |
96 +| Apprenti-période 3 | 39,38 $ | 5,12 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,81 $ | 0,27 $ | 2,87 $ | 0,78 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,43 $ | 59,41 $ |
97 +
98 +---
99 +
100 +| | | | | | | | | Coût horaire de la main-d&#x27;œuvre - en vigueur au 26 avril 2026 COÛT HORAIRE DE LA MAIN-D&#x27;OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 | | | | | | | | | | |
101 +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
102 +| Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | Équip. de sécur. | | A-E | RQAP 0,602% % | 1 RRQ 6,300% | FSS 1,65% | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | Fonds d&#x27;indemn. | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |
103 +| Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | 0,65 | | 1,820% | RQAP 0,602% % | 1 RRQ 6,300% | FSS 1,65% | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | 0,02 $ | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |
104 +| Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | heure | | % | RQAP 0,602% % | 1 RRQ 6,300% | FSS 1,65% | Prélèv. CCQ 0,75% % | | AECQ 0,03 $ heure | heure | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |
105 +| Métiers et spécialités | | | | | | | | | | | | | | | | | | |
106 +| Méc. protection-incendie | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,98 $ | 0,33 $ | 3,48 $ | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,72 $ | 71,27 $ |
107 +| Apprenti-période 1 | 23,93 $ | 3,11 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,49 $ | 0,16 $ | 1,77 $ | 0,49 $ | 0,20 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,90 $ | 39,48 $ |
108 +| Apprenti-période 2 | 28,72 $ | 3,73 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,59 $ | 0,20 $ | 2,11 $ | 0,58 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,07 $ | 45,66 $ |
109 +| Apprenti-période 3 | 33,50 $ | 4,36 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,69 $ | 0,23 $ | 2,45 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,23 $ | 51,83 $ |
110 +| | | | | | | | | | | | | | | | | | | |
111 +| Monteur-assembleur | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,98 $ | 0,33 $ | 3,48 $ | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 4,14 $ | 73,69 $ |
112 +| Apprenti-période 1 | 28,72 $ | 3,73 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,59 $ | 0,20 $ | 2,11 $ | 0,58 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,57 $ | 47,16 $ |
113 +| Apprenti-période 2 | 33,50 $ | 4,36 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,69 $ | 0,23 $ | 2,45 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,96 $ | 53,56 $ |
114 +| Apprenti-période 3 | 40,68 $ | 5,29 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,84 $ | 0,28 $ | 2,97 $ | 0,80 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,55 $ | 63,17 $ |
115 +| | | | | | | | | | | | | | | | | | | |
116 +| Monteur mécanicien vitrier | 45,07 $ | 5,86 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,93 $ | 0,31 $ | 3,28 $ | 0,89 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,80 $ | 69,86 $ |
117 +| Apprenti-période 1 | 27,04 $ | 3,52 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,56 $ | 0,18 $ | 2,00 $ | 0,55 $ | 0,23 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,36 $ | 44,87 $ |
118 +| Apprenti-période 2 | 31,56 $ | 4,10 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,65 $ | 0,21 $ | 2,32 $ | 0,63 $ | 0,27 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,72 $ | 50,89 $ |
119 +| Apprenti-période 3 | 38,32 $ | 4,98 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,79 $ | 0,26 $ | 2,80 $ | 0,76 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,26 $ | 59,91 $ |
120 +| | | | | | | | | | | | | | | | | | | |
121 +| Op. d&#x27;équip. l. - Classe A | 43,08 $ | 5,60 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,89 $ | 0,29 $ | 3,14 $ | 0,85 $ | 0,37 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,94 $ | 65,51 $ |
122 +| Apprenti-période 1 | 36,62 $ | 4,76 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,75 $ | 0,25 $ | 2,68 $ | 0,73 $ | 0,31 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,66 $ | 56,18 $ |
123 +| | | | | | | | | | | | | | | | | | | |
124 +| 42,06 $ 5,47 $ 8,140 $ 0,26 $ 0,65 $ 0,87 $ 0,29 $ 3,06 $ 0,83 $ 0,36 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 1,89 $ 64,17 $ Op. d&#x27;équip. l. - Classe B | | | | | | | | | | | | | | | | | | |
125 +| 42,06 $ 5,47 $ 8,140 $ 0,26 $ 0,65 $ 0,87 $ 0,29 $ 3,06 $ 0,83 $ 0,36 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 1,89 $ 64,17 $ Op. d&#x27;équip. l. - Classe B | | | | | | | | | | | | | | | | | | |
126 +| Apprenti-période 1 | 35,74 $ | 4,65 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,74 $ | 0,24 $ | 2,61 $ | 0,71 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,62 $ | 55,04 $ |
127 +| Op. de p. méc. - Cl. A | 45,93 $ | 5,97 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,94 $ | 0,31 $ | 3,34 $ | 0,90 $ | 0,39 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,06 $ | 69,19 $ |
128 +| | | | | | | | | | | | | | | | | | | |
129 +| Apprenti-période 1 | 39,04 $ | 5,08 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,80 $ | 0,27 $ | 2,85 $ | 0,77 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,76 $ | 59,32 $ |
130 +| | | | | | | | | | | | | | | | | | | |
131 +| | | | | | | | | | | | | | | | | | | |
132 +| | | | | | | | | | | | | | | | | | | |
133 +| Opé. de p. méc - Cl. B | 44,50 $ | 5,79 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,92 $ | 0,30 $ | 3,24 $ | 0,88 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,00 $ | 67,36 $ |
134 +| Apprenti-période 1 | 37,83 $ | 4,92 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,78 $ | 0,26 $ | 2,76 $ | 0,75 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,71 $ | 57,75 $ |
135 +| 44,47 $ 5,78 $ 8,140 $ 0,26 $ 0,65 $ 0,91 $ 0,30 $ 3,24 $ 0,88 $ 0,38 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 3,83 $ 69,14 $ Op. pompe béton Cl. A mat +42 m. | | | | | | | | | | | | | | | | | | |
136 +| | | | | | | | | | | | | | | | | | | |
137 +| Op. pompe béton Cl. B mat -42 m. | 42,04 $ | 5,47 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,86 $ | 0,29 $ | 3,06 $ | 0,83 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,63 $ | 65,88 $ |
138 +| 37,80 $ 4,91 $ 7,220 $ 0,26 $ 0,65 $ 0,78 $ 0,26 $ 2,76 $ 0,75 $ 0,32 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 3,29 $ 59,29 $ Apprenti-période 1 | | | | | | | | | | | | | | | | | | |
139 +| 37,80 $ 4,91 $ 7,220 $ 0,26 $ 0,65 $ 0,78 $ 0,26 $ 2,76 $ 0,75 $ 0,32 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 3,29 $ 59,29 $ Apprenti-période 1 | | | | | | | | | | | | | | | | | | |
140 +| Parqueteur-sableur | 45,76 $ | 5,95 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,94 $ | 0,31 $ | 3,33 $ | 0,90 $ | 0,39 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,31 $ | 70,23 $ |
141 +| Apprenti-période 1 | 27,45 $ | 3,57 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,56 $ | 0,19 $ | 2,02 $ | 0,56 $ | 0,23 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,06 $ | 45,06 $ |
142 +| Apprenti-période 2 | 32,03 $ | 4,16 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,66 $ | 0,22 $ | 2,35 $ | 0,64 $ | 0,27 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,37 $ | 51,12 $ |
143 +| Apprenti-période 3 | 38,90 $ | 5,06 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,80 $ | 0,26 $ | 2,84 $ | 0,77 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,84 $ | 60,23 $ |
144 +| | | | Après avoir complétée 4000 heures, s&#x27;il demeure apprenti, le salarié reçoit le taux de salaire de l&#x27;apprenti 3 du métier de charpentier-menuisier | | | | | | | | | | | | | | | |
145 +| Peintre | 42,81 $ | 5,57 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,88 $ | 0,29 $ | 3,12 $ | 0,84 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,11 $ | 66,32 $ |
146 +| Apprenti-période 1 | 25,69 $ | 3,34 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,53 $ | 0,17 $ | 1,90 $ | 0,53 $ | 0,22 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,93 $ | 42,74 $ |
147 +| Apprenti-période 2 | 29,96 $ | 3,89 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,62 $ | 0,20 $ | 2,20 $ | 0,60 $ | 0,25 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,23 $ | 48,38 $ |
148 +| 36,39 $ 4,73 $ 7,220 $ 0,26 $ 0,65 $ 0,75 $ 0,25 $ 2,66 $ 0,72 $ 0,31 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 2,67 $ 56,90 $ Apprenti-période 3 | | | | | | | | | | | | | | | | | | |
149 +| Plâtrier | 44,98 $ | 5,85 $ | 8,140 $ | 0,26 $ | 0,65 $ | | 0,93 $ | 0,31 $ | 3,27 $ | 0,88 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,26 $ | 69,20 $ |
150 +| Apprenti-période 1 | 26,99 $ | 3,51 $ | 7,220 $ | 0,26 $ | 0,65 $ | | 0,56 $ | 0,18 $ | 1,99 $ | 0,55 $ | 0,23 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,02 $ | 44,46 $ |
151 +
152 +---
153 +
154 +| | | | | | | | | | Coût horaire de la main-d&#x27;œuvre - en vigueur au 26 avril 2026 COÛT HORAIRE DE LA MAIN-D&#x27;OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 Secteur RÉSIDENTIEL LÉGER | | | | | | | | | | | | |
155 +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
156 +| Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | Équip. de sécur. 0,65 heure | | | A-E | | RQAP 0,602% % | | 1 RRQ | FSS 1,65% % | Prélèv. AECQ CCQ 0,75% % heure | | | Fonds d&#x27;indemn. | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |
157 +| Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | Équip. de sécur. 0,65 heure | | | 1,820% | | RQAP 0,602% % | | 6,300% | FSS 1,65% % | Prélèv. AECQ CCQ 0,75% % heure | | | 0,02 $ | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |
158 +| Valeur Unité | Taux horaire | Indemnité vacances 13,0% % | Avantages sociaux heure | Taxe sur assur. 1 heure | Équip. de sécur. 0,65 heure | | | % | | RQAP 0,602% % | | % | FSS 1,65% % | Prélèv. AECQ CCQ 0,75% % heure | | | heure | Fonds de formation 0,20 $ heure | Contribution sectorielle 0,043 $ heure | 2 CNESST | Total |
159 +| Métiers et spécialités | | | | | | | | | | | | | | | | | | | | | |
160 +| 0,65 $ 0,26 $ 0,65 $ 7,220 $ 4,09 $ 0,21 $ 31,49 $ 2,31 $ 0,63 $ 0,27 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 2,33 $ 50,41 $ Apprenti-période 2 | | | | | | | | | | | | | | | | | | | | | |
161 +| 38,23 $ 4,97 $ 7,220 $ 0,26 $ 0,65 $ 0,79 $ 0,26 $ 2,79 $ 0,76 $ 0,32 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 2,80 $ 59,34 $ Apprenti-période 3 | | | | | | | | | | | | | | | | | | | | | |
162 +| Poseur de fondation profonde | 45,76 $ | 5,95 $ | 8,140 $ | 0,26 $ | 0,65 $ | | | 0,94 $ | | 0,31 $ | | 3,33 $ | 0,90 $ | 0,39 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,37 $ | 69,29 $ |
163 +| Apprenti-période 1 | 27,45 $ | 3,57 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,56 $ | | 0,19 $ | | 2,02 $ | 0,56 $ | 0,23 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,47 $ | 44,47 $ |
164 +| Apprenti-période 2 | 32,03 $ | 4,16 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,66 $ | | 0,22 $ | | 2,35 $ | 0,64 $ | 0,27 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,70 $ | 50,45 $ |
165 +| Apprenti-période 3 | 38,89 $ | 5,06 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,80 $ | | 0,26 $ | | 2,84 $ | 0,77 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,03 $ | 59,41 $ |
166 +| | | | | | | | | | | | | | | | | | | | | | |
167 +| Après avoir complétée 4000 heures, s&#x27;il demeure apprenti, le salarié reçoit le taux de salaire de l&#x27;apprenti 3 du métier de charpentier-menuisier | | | | | | | | | | | | | | | | | | | | | |
168 +| 41,71 $ 5,42 $ 8,140 $ 0,26 $ 0,65 $ 0,86 $ 0,28 $ 3,04 $ 0,82 $ 0,35 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 3,04 $ 64,87 $ Poseur de rev. Souples | | | | | | | | | | | | | | | | | | | | | |
169 +| Apprenti-période 1 | 25,02 $ | 3,25 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,51 $ | | 0,17 $ | | 1,85 $ | 0,51 $ | 0,21 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,89 $ | 41,83 $ |
170 +| Apprenti-période 2 | 29,20 $ | 3,80 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,60 $ | | 0,20 $ | | 2,15 $ | 0,59 $ | 0,25 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,18 $ | 47,39 $ |
171 +| Apprenti-période 3 | 35,46 $ | 4,61 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,73 $ | | 0,24 $ | | 2,59 $ | 0,71 $ | 0,30 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,61 $ | 55,67 $ |
172 +| | | | | | | | | | | | | | | | | | | | | | |
173 +| | | | | | | | | | | | | | | | | | | | | | |
174 +| | | | | | | | | | | | | | | | | | | | | | |
175 +| Apprenti-période 1 | 27,45 $ | 3,57 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,56 $ | | 0,19 $ | | 2,02 $ | 0,56 $ | 0,23 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,06 $ | 45,06 $ |
176 +| Apprenti-période 2 | 32,03 $ | 4,16 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,66 $ | | 0,22 $ | | 2,35 $ | 0,64 $ | 0,27 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,37 $ | 51,12 $ |
177 +| Apprenti-période 3 | 38,90 $ | 5,06 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,80 $ | | 0,26 $ | | 2,84 $ | 0,77 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,84 $ | 60,23 $ |
178 +| Tir. joints (peint./plâtr.) | 44,29 $ | 5,76 $ | 8,140 $ | 0,26 $ | 0,65 $ | | | 0,91 $ | | 0,30 $ | | 3,22 $ | 0,87 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,21 $ | 68,28 $ |
179 +| Apprenti-période 1 | 26,58 $ | 3,46 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,55 $ | | 0,18 $ | | 1,96 $ | 0,54 $ | 0,23 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,00 $ | 43,92 $ |
180 +| Apprenti-période 2 | 31,01 $ | 4,03 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,64 $ | | 0,21 $ | | 2,28 $ | 0,62 $ | 0,26 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,30 $ | 49,77 $ |
181 +| Apprenti-période 3 | 37,64 $ | 4,89 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,77 $ | | 0,26 $ | | 2,75 $ | 0,75 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,76 $ | 58,56 $ |
182 +| Tuyauteur | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,65 $ | | | 0,98 $ | | 0,33 $ | | 3,48 $ | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,72 $ | 71,27 $ |
183 +| Apprenti-période 1 | 23,93 $ | 3,11 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,49 $ | | 0,16 $ | | 1,77 $ | 0,49 $ | 0,20 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 0,90 $ | 39,48 $ |
184 +| Apprenti-période 2 | 28,72 $ | 3,73 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,59 $ | | 0,20 $ | | 2,11 $ | 0,58 $ | 0,24 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,07 $ | 45,66 $ |
185 +| Apprenti-période 3 | 33,50 $ | 4,36 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,69 $ | | 0,23 $ | | 2,45 $ | 0,67 $ | 0,28 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,23 $ | 51,83 $ |
186 +| Apprenti-période 4 | 40,68 $ | 5,29 $ | 7,220 $ | 0,26 $ | 0,65 $ | | | 0,84 $ | | 0,28 $ | | 2,97 $ | 0,80 $ | 0,34 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 1,48 $ | 61,10 $ |
187 +
188 +---
189 +
190 +**COÛT HORAIRE DE LA MAIN-D'OEUVRE DU SECTEUR RÉSIDENTIEL LÉGER EN VIGUEUR AU 27 DÉCEMBRE 2009 Coût horaire de la main-d'œuvre - en vigueur au 26 avril 2026**
191 +**Secteur RÉSIDENTIEL LÉGER**
192 +
193 +| Valeur | Taux horaire | Indemnité vacances 13,0% | Avantages sociaux | Taxe sur assur. 1 | Équip. de sécur. 0,65 | | | A-E 1,820% | | RQAP RRQ 0,602% | | 1 | FSS 1,65% | | Prélèv. CCQ | AECQ 0,03 $ heure | | Fonds d&#x27;indemn. 0,02 $ | Fonds de formation 0,20 $ | Contribution sectorielle 0,043 $ | 2 CNESST | Total |
194 +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
195 +| Valeur | Taux horaire | Indemnité vacances 13,0% | Avantages sociaux | Taxe sur assur. 1 | Équip. de sécur. 0,65 | | | A-E 1,820% | | RQAP RRQ 0,602% | | 6,300% | FSS 1,65% | | 0,75% | AECQ 0,03 $ heure | | Fonds d&#x27;indemn. 0,02 $ | Fonds de formation 0,20 $ | Contribution sectorielle 0,043 $ | 2 CNESST | Total |
196 +| Unité Métiers et spécialités | | % | heure | heure | heure | | | % | | % | | % | | % | % | | | heure | heure | heure | | |
197 +| Occupations | | | | | | | | | | | | | | | | | | | | | | |
198 +| 40,08 $ 5,21 $ 8,140 $ 0,26 $ 0,60 $ 0,82 $ 0,27 $ 2,92 $ 0,79 $ 0,34 $ 0,03 $ 0,02 $ 0,20 $ 0,043 $ 2,09 $ 61,82 $ | | | | | | | | | | | | | | | | | | | | | | |
199 +| Boutefeu | | | | | | | | | | | | | | | | | | | | | | |
200 +| Conducteur de cam. - Classe A | 38,73 $ | 5,03 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,80 $ | | 0,26 $ | | 2,83 $ | | 0,77 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,83 $ | 60,88 $ |
201 +| Conducteur de cam. - Classe B | 37,67 $ | 4,90 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,77 $ | | 0,26 $ | | 2,75 $ | | 0,75 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,76 $ | 59,47 $ |
202 +| Conducteur de cam. Classe C | 37,25 $ | 4,84 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,77 $ | | 0,25 $ | | 2,72 $ | | 0,74 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,73 $ | 58,92 $ |
203 +| Foreur | 41,39 $ | 5,38 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,85 $ | | 0,28 $ | | 3,02 $ | | 0,82 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,01 $ | 64,39 $ |
204 +| Homme d&#x27;inst. (arpenteur) | 42,65 $ | 5,54 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,88 $ | | 0,29 $ | | 3,11 $ | | 0,84 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,10 $ | 66,06 $ |
205 +| Manœuvre (journalier) | 36,98 $ | 4,81 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,76 $ | | 0,25 $ | | 2,70 $ | | 0,74 $ | 0,31 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,71 $ | 58,55 $ |
206 +| Manœuvre spécialisé | 38,01 $ | 4,94 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,78 $ | | 0,26 $ | | 2,78 $ | | 0,75 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,78 $ | 59,91 $ |
207 +| Man. spéc. (carreleur) | 38,73 $ | 5,03 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,80 $ | | 0,26 $ | | 2,83 $ | | 0,77 $ | 0,33 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,83 $ | 60,88 $ |
208 +| Op. d&#x27;appareils de lev. A | 42,32 $ | 5,50 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,87 $ | | 0,29 $ | | 3,08 $ | | 0,84 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,08 $ | 65,63 $ |
209 +| Op. d&#x27;appareils de lev. B | 40,74 $ | 5,30 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,84 $ | | 0,28 $ | | 2,97 $ | | 0,81 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,97 $ | 63,55 $ |
210 +| | | | | | | | | | | | | | | | | | | | | | | |
211 +| Op. de concasseur | 38,01 $ | 4,94 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,78 $ | | 0,26 $ | | 2,78 $ | | 0,75 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,78 $ | 59,91 $ |
212 +| Op. pompes compresseurs | 41,39 $ | 5,38 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,85 $ | | 0,28 $ | | 3,02 $ | | 0,82 $ | 0,35 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,01 $ | 64,39 $ |
213 +| Op. usines asphalte | 38,01 $ | 4,94 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,78 $ | | 0,26 $ | | 2,78 $ | | 0,75 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,78 $ | 59,91 $ |
214 +| | | | | | | | | | | | | | | | | | | | | | | |
215 +| Opérateur usines fixes mobiles | 38,01 $ | 4,94 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,78 $ | | 0,26 $ | | 2,78 $ | | 0,75 $ | 0,32 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 2,78 $ | 59,91 $ |
216 +| Plongeur | 48,96 $ | 6,36 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 1,01 $ | | 0,33 $ | | 3,55 $ | | 0,96 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,53 $ | 74,41 $ |
217 +| Soudeur | 45,35 $ | 5,90 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,93 $ | | 0,31 $ | | 3,30 $ | | 0,89 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,29 $ | 69,64 $ |
218 +| Soudeur monteur-assembleur | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,98 $ | | 0,33 $ | | 3,48 $ | | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,46 $ | 72,96 $ |
219 +| Soudeur distribution (gaz) | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,98 $ | | 0,33 $ | | 3,48 $ | | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,46 $ | 72,96 $ |
220 +| Soudeur machineries lourdes | 45,35 $ | 5,90 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,93 $ | | 0,31 $ | | 3,30 $ | | 0,89 $ | 0,38 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,29 $ | 69,64 $ |
221 +| Soudeur tuyauterie | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,98 $ | | 0,33 $ | | 3,48 $ | | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,46 $ | 72,96 $ |
222 +| Spéc. en branch. d&#x27;imm. | 47,85 $ | 6,22 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,98 $ | | 0,33 $ | | 3,48 $ | | 0,94 $ | 0,41 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,46 $ | 72,96 $ |
223 +| Travailleur souterrain | 42,21 $ | 5,49 $ | 8,140 $ | 0,26 $ | 0,60 $ | | | 0,87 $ | | 0,29 $ | | 3,07 $ | | 0,83 $ | 0,36 $ | | 0,03 $ | 0,02 $ | 0,20 $ | 0,043 $ | 3,07 $ | 65,48 $ |
224 +
225 +1. Une cotisation supplémentaire doit être calculée pour l'excédent du maximun assurable de 74 600$. Voir guide de l'employeur du gouvernement provincial.
226 +2.Le montant de la cotisation CNESST n'est présenté qu'à titre indicatif. Il est basé sur le taux de l'unité de classification pour l'année 2026
\ No newline at end of file
added src/lib/cost/connectors/__fixtures__/bmr-2x6x8.json +1 −0
@@ -0,0 +1 @@
1 +{"success":true,"data":{"markdown":"The store will not work correctly when cookies are disabled.\n\n[BMR](https://www.bmr.ca/fr/search/result/#f-ec_brand=BMR \"BMR\")\n\n# 2\" x 6\" x 8' Épinette Sec 2/M\n\nCode BMR\n\n069-6339\n\nNuméro d'article\n\n265S96A\n\nUPC\n\n069606010103\n\n(0)\n\nÉcrire un avis\n\nChoisissez ce bois d'épinette de qualité pour entreprendre vos travaux de construction.\n\nLongueur8 pi\n\n93 1/4 po\n\n8 pi\n\n104 5/8 po\n\n105 1/4 po\n\n10 pi\n\n14 pi\n\n16 pi\n\n12 pi\n\nModèle2/M\n\nLargeur6 po\n\n6 po\n\n8 po\n\nDimension\n\n2 po x 6 po x 8 pi\n\nÀ partir de\n13,96 $13,96 $\n\nÀ partir de\n6,98 $6,98 $\n\nCombien en avez-vous besoin?\n\nCalculer\n\nPrévoir 10% pour les pertes de coupes et les bris (recommandé)\n\nAjouter au panier\n\nSélectionner un magasin\n\n[Contactez votre magasin :](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#)\n\n[Ajouter à ma liste de souhaits](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html# \"Ajouter à ma liste de souhaits\")\n\n1654 en stocken magasin (mis à jour le 6 septembre à 3:00)\n\nConfigurer votre produit\n\nPour connaître sa disponibilité en ligne et en magasin\n\nMatco St-LéonardRamassage gratuit en magasin Généralement en quelques heures\n\nRamassage gratuit dans un autre magasin\n\nRamassez-le dès aujourd'hui en choisissant le magasin le plus proche.\\*\n\n![loader](https://www.bmr.ca/static/version1788264695/frontend/groupe-bmr/bmr/fr_CA/Magestore_Storelocator/images/ajax-loader.gif)\n\nVérifier le stock dans tous les magasins\n\nLivraison par colis\n\nLivraison par camion\n\nLe magasin vous contactera d’ici 24 h pour planifier votre livraison.\n\nDes frais supplémentaires s’appliqueront si la livraison requiert un camion-grue ou la consignation de palettes. Une surcharge de carburant de 10$ peut aussi s’appliquer. Ces frais seront facturés séparément par le magasin lors de la livraison.\n\nCe produit n'est pas disponible présentement\n\nVoici quelques produits qui pourraient le remplacer\n\nPrevious\n\n[![2\" x 3\" x 12' Épinette Sec 2/Meil.](https://www.bmr.ca/media/catalog/product/cache/44d7daf5ec155b8cee926f467aa994ca/0/6/069-6117_001_1.jpg)](https://www.bmr.ca/fr/bmr-2-x-3-x-12-epinette-sec-2-meil-069-6117.html)\n\nBMR\n\n**[2\" x 3\" x 12' Épinette Sec 2/Meil.](https://www.bmr.ca/fr/bmr-2-x-3-x-12-epinette-sec-2-meil-069-6117.html \"2\\\" x 3\\\" x 12' Épinette Sec 2/Meil.\")**\n\n5,38 $\n\n- En stock\n- Ramassage en magasin\n- Livraison par camion\n\n[![2\" x 3\" x 10' Épinette Sec 2/Meil.](https://www.bmr.ca/media/catalog/product/cache/44d7daf5ec155b8cee926f467aa994ca/0/6/069-6108_001_1.jpg)](https://www.bmr.ca/fr/bmr-2-x-3-x-10-epinette-sec-2-meil-069-6108.html)\n\nBMR\n\n**[2\" x 3\" x 10' Épinette Sec 2/Meil.](https://www.bmr.ca/fr/bmr-2-x-3-x-10-epinette-sec-2-meil-069-6108.html \"2\\\" x 3\\\" x 10' Épinette Sec 2/Meil.\")**\n\n3,88 $\n\n- En stock\n- Ramassage en magasin\n- Livraison par camion\n\n[![2\" x 4\" x 8' Épinette Sec 2/Meil.](https://www.bmr.ca/media/catalog/product/cache/44d7daf5ec155b8cee926f467aa994ca/0/6/069-6199_001_1.jpg)\\\\\n\\\\\n![can](https://www.bmr.ca/media/labels/origin/default/bmr_produit_canadien.png)](https://www.bmr.ca/fr/bmr-2-x-4-x-8-epinette-sec-2-meil-069-6199.html)\n\nBMR\n\n**[2\" x 4\" x 8' Épinette Sec 2/Meil.](https://www.bmr.ca/fr/bmr-2-x-4-x-8-epinette-sec-2-meil-069-6199.html \"2\\\" x 4\\\" x 8' Épinette Sec 2/Meil.\")**\n\n3,78 $\n\n- En stock\n- Ramassage en magasin\n- Livraison par camion\n\nNext\n\n\\*Le prix et l’inventaire des produits offerts sur le site peuvent varier. Le prix peut diverger d’un prix annoncé en raison de conditions variables dans les marchés, d’erreurs ou de situations hors de notre contrôle.\n\n[Skip to the end of the images gallery](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#gallery-next-area)\n\n[Skip to the beginning of the images gallery](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#gallery-prev-area)\n\n[Facebook](https://www.bmr.ca/#facebook) [Messenger](https://www.bmr.ca/#facebook_messenger) [Whatsapp](https://www.bmr.ca/#whatsapp) [Pinterest](https://www.bmr.ca/#pinterest) [Courriel](https://www.bmr.ca/#email)\n\n[![2\" x 6\" x 8' Épinette Sec 2/M](https://www.bmr.ca/media/catalog/product/cache/f85d4dbd52ba995541e0ee48eb8760ff/0/6/069-6339_001_1.jpg)\\\\\n\\\\\n![](<Base64-Image-Removed>)\\\\\n\\\\\nCliquez pour zoomer](https://www.bmr.ca/media/catalog/product/cache/e2ddb9ff65ff8ef4bfb823a7940f5d8b/0/6/069-6339_001_1.jpg)\n\n[![2\" x 6\" x 8' Épinette Sec 2/M](https://www.bmr.ca/media/catalog/product/cache/8cfaa8b419343c729136b6a40196ac6a/0/6/069-6339_001_1.jpg)](https://www.bmr.ca/media/catalog/product/cache/e2ddb9ff65ff8ef4bfb823a7940f5d8b/0/6/069-6339_001_1.jpg \"2\\\" x 6\\\" x 8' Épinette Sec 2/M\")\n\n[Description du produit](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#product.info.description.mobile)\n\n• Aucune garantie.\n\n• Aucun service de réparation, pièces de remplacement, ni guide de réparation n'est offert.\n\n[Caractéristiques du produit](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#product.attributes.mobile)\n\n| Marque de commerce | BMR |\n| Essences de bois | Épinette |\n| Grade | Stud, KD |\n| Type | Madriers d’épinette (SPF) |\n| Dimension | 2 po x 6 po x 8 pi |\n| Épaisseur x Largeur | 2 x 6 po |\n| Largeur | 6 po |\n| Longueur | 8 pi |\n| Épaisseur | 2 po |\n| Modèle | 2/M |\n| Marque de commerce | BMR |\n| Essences de bois | Épinette |\n| Grade | Stud, KD |\n| Type | Madriers d’épinette (SPF) |\n\nPlus d’information\n\n## Avis\n\n## Évaluations\n\n![L'icône marque de confiance qui dévoile une fenêtre contextuelle décrivant l'authenticité de BV](https://apps.bazaarvoice.com/common/images/intelligent_trustmark_icons/trustmark_fr.png)\n\n### Évaluez ce produit\n\nSélectionnez pour évaluer l'article à 1 étoile. Cette action ouvrira le formulaire de soumission.\n\nSélectionnez pour évaluer l'article à 2 étoiles. Cette action ouvrira le formulaire de soumission.\n\nSélectionnez pour évaluer l'article à 3 étoiles. Cette action ouvrira le formulaire de soumission.\n\nSélectionnez pour évaluer l'article à 4 étoiles. Cette action ouvrira le formulaire de soumission.\n\nSélectionnez pour évaluer l'article à 5 étoiles. Cette action ouvrira le formulaire de soumission.\n\nRédiger le premier commentaire\n\n![2\" x 6\" x 92 5/8\" Épinette Sec Stud](https://www.bmr.ca/media/catalog/product/cache/f85d4dbd52ba995541e0ee48eb8760ff/0/6/069-6287_001_1.jpg)\n\n2\" x 6\" x 8' Épinette Sec 2/M\n\nÀ partir de\n13,96 $13,96 $\n\nÀ partir de\n6,98 $6,98 $\n\nAjouter au panier\n\nSélectionner un magasin\n\n[Contactez votre magasin :](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#)\n\n[Ajouter à ma liste de souhaits](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html# \"Ajouter à ma liste de souhaits\")\n\n**Vous pourriez aimer :**\n\nPrevious\n\n[Ajouter à ma liste de souhaits](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#) [![2\" x 3\" x 16' Épinette Sec 2/Meil.](https://www.bmr.ca/media/catalog/product/cache/44d7daf5ec155b8cee926f467aa994ca/0/6/069-6135_001_1.jpg)](https://www.bmr.ca/fr/bmr-2-x-3-x-16-epinette-sec-2-meil-069-6135.html)\n\nBMR\n\n**[2\" x 3\" x 16' Épinette Sec 2/Meil.](https://www.bmr.ca/fr/bmr-2-x-3-x-16-epinette-sec-2-meil-069-6135.html \"2\\\" x 3\\\" x 16' Épinette Sec 2/Meil.\")**\n\n7,88 $\n\n[Voir ce produit](https://www.bmr.ca/fr/bmr-2-x-3-x-16-epinette-sec-2-meil-069-6135.html)\n\n[Ajouter à ma liste de souhaits](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#) [![2\" x 3\" x 8' Épinette Sec Stud](https://www.bmr.ca/media/catalog/product/cache/44d7daf5ec155b8cee926f467aa994ca/0/6/069-6083_001_1.jpg)](https://www.bmr.ca/fr/bmr-2-x-3-x-8-epinette-sec-stud-069-6083.html)\n\nBMR\n\n**[2\" x 3\" x 8' Épinette Sec Stud](https://www.bmr.ca/fr/bmr-2-x-3-x-8-epinette-sec-stud-069-6083.html \"2\\\" x 3\\\" x 8' Épinette Sec Stud\")**\n\n3,28 $\n\n[Voir ce produit](https://www.bmr.ca/fr/bmr-2-x-3-x-8-epinette-sec-stud-069-6083.html)\n\n[Ajouter à ma liste de souhaits](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#) [![2\" x 4\" x 104 5/8\" Épinette Sec Stud](https://www.bmr.ca/media/catalog/product/cache/44d7daf5ec155b8cee926f467aa994ca/0/6/069-6214_001_1.jpg)](https://www.bmr.ca/fr/bmr-2-x-4-x-104-5-8-epinette-sec-stud-069-6214.html)\n\nBMR\n\n**[2\" x 4\" x 104 5/8\" Épinette Sec Stud](https://www.bmr.ca/fr/bmr-2-x-4-x-104-5-8-epinette-sec-stud-069-6214.html \"2\\\" x 4\\\" x 104 5/8\\\" Épinette Sec Stud\")**\n\n4,88 $\n\n[Voir ce produit](https://www.bmr.ca/fr/bmr-2-x-4-x-104-5-8-epinette-sec-stud-069-6214.html)\n\n[Ajouter à ma liste de souhaits](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#) [![2\" x 3\" x 8' Épinette Sec 2/Meil.](https://www.bmr.ca/media/catalog/product/cache/44d7daf5ec155b8cee926f467aa994ca/0/6/069-6092_001_1.jpg)](https://www.bmr.ca/fr/bmr-2-x-3-x-8-epinette-sec-2-meil-069-6092.html)\n\nBMR\n\n**[2\" x 3\" x 8' Épinette Sec 2/Meil.](https://www.bmr.ca/fr/bmr-2-x-3-x-8-epinette-sec-2-meil-069-6092.html \"2\\\" x 3\\\" x 8' Épinette Sec 2/Meil.\")**\n\nVoir le prix en magasin\n\n- En stock\n- Ramassage en magasin\n- Livraison par camion\n\n[Voir ce produit](https://www.bmr.ca/fr/bmr-2-x-3-x-8-epinette-sec-2-meil-069-6092.html)\n\n[Ajouter à ma liste de souhaits](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#) [![2\" x 3\" x 14' Épinette Sec 2/Meil.](https://www.bmr.ca/media/catalog/product/cache/44d7daf5ec155b8cee926f467aa994ca/0/6/069-6126_001_1.jpg)](https://www.bmr.ca/fr/bmr-2-x-3-x-14-epinette-sec-2-meil-069-6126.html)\n\nBMR\n\n**[2\" x 3\" x 14' Épinette Sec 2/Meil.](https://www.bmr.ca/fr/bmr-2-x-3-x-14-epinette-sec-2-meil-069-6126.html \"2\\\" x 3\\\" x 14' Épinette Sec 2/Meil.\")**\n\n6,88 $\n\n[Voir ce produit](https://www.bmr.ca/fr/bmr-2-x-3-x-14-epinette-sec-2-meil-069-6126.html)\n\n[Ajouter à ma liste de souhaits](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#) [![2\" x 3\" x 10' Épinette Sec 2/Meil.](https://www.bmr.ca/media/catalog/product/cache/44d7daf5ec155b8cee926f467aa994ca/0/6/069-6108_001_1.jpg)](https://www.bmr.ca/fr/bmr-2-x-3-x-10-epinette-sec-2-meil-069-6108.html)\n\nBMR\n\n**[2\" x 3\" x 10' Épinette Sec 2/Meil.](https://www.bmr.ca/fr/bmr-2-x-3-x-10-epinette-sec-2-meil-069-6108.html \"2\\\" x 3\\\" x 10' Épinette Sec 2/Meil.\")**\n\n3,88 $\n\n- En stock\n- Ramassage en magasin\n- Livraison par camion\n\nAjouter au panier\n\nNext\n\n© Sollio Groupe Coopératif 2023 tous droits réservés.\n\nIl n'y a aucun magasin à afficher\n\nCopy link\n\n✓\n\nThanks for sharing!\n\nFind any service\n\n[AddToAny](https://www.addtoany.com/ \"Share Buttons\")\n\n[More…](https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html#addtoany \"Show all\")\n\nA2A\n\nmgctlbxN$MZP mgctlbxV$5.3.7 mgctlbxL$C mgctlbxP$magento 2\n\n![](<Base64-Image-Removed>)\n\nmgctlbxN$MSC mgctlbxV$2.0.53 mgctlbxL$C mgctlbxP$magento 2","metadata":{"product:category":"Bois d'œuvre","product:brand":"BMR","twitter:label1":"Price","og:image:height":"300","format-detection":"telephone=no","twitter:description":"Choisissez ce bois d'épinette de qualité pour entreprendre vos travaux de construction.","twitter:url":"https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html","og:url":"https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html","msapplication-square70x70logo":"/build/groupe-bmr/bmr/icons/bmr/tile-small.png?version1788264695","twitter:creator":"GroupeBMR","title":"2\" x 6\" x 92 5/8\" Épinette Sec Stud de BMR | BMR","ogSiteName":"BMR","twitter:title":"2\" x 6\" x 92 5/8\" Épinette Sec Stud","og:site_name":"BMR","og:description":"Choisissez ce bois d'épinette de qualité pour entreprendre vos travaux de construction.","language":"fr-CA","msapplication-square150x150logo":"/build/groupe-bmr/bmr/icons/bmr/tile-medium.png?version1788264695","product:availability":"in stock","og:type":"product.item","twitter:label2":"Availability","ogDescription":"Choisissez ce bois d'épinette de qualité pour entreprendre vos travaux de construction.","twitter:data2":"in stock","product:price:currency":"CAD","foundation-mq":"","priceCurrency":["CAD","CAD"],"msapplication-wide310x150logo":"/build/groupe-bmr/bmr/icons/bmr/tile-wide.png?version1788264695","og:image:width":"240","twitter:card":"summary","ogUrl":"https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html","twitter:image":"https://www.bmr.ca/media/catalog/product/cache/cf934d167e5b3a5f87168431b731ab3b/0/6/069-6287_001_1.jpg","twitter:site":"GroupeBMR","robots":["INDEX,FOLLOW","noindex"],"description":"Choisissez ce bois d'épinette de qualité pour entreprendre vos travaux de construction. Commandez ce produit en ligne dès maintenant!","viewport":"width=device-width, initial-scale=1","p:domain_verify":["GJHlXPhzwmr0GsOmNi8CGOEQWlzNvIt7","3414fbf92b121b40309ccc42ee5d068a","9SRLzVhhWi4OwzdlPSv328ICpkzqeEsN"],"twitter:data1":"6.98","og:title":"2\" x 6\" x 92 5/8\" Épinette Sec Stud","og:image":"https://www.bmr.ca/media/catalog/product/cache/cf934d167e5b3a5f87168431b731ab3b/0/6/069-6287_001_1.jpg","msapplication-square310x310logo":"/build/groupe-bmr/bmr/icons/bmr/tile-large.png?version1788264695","ogTitle":"2\" x 6\" x 92 5/8\" Épinette Sec Stud","product:price:amount":"6.98","ogImage":"https://www.bmr.ca/media/catalog/product/cache/cf934d167e5b3a5f87168431b731ab3b/0/6/069-6287_001_1.jpg","favicon":"https://www.bmr.ca/build/groupe-bmr/bmr/icons/bmr/icon-64.png?version1788264695","scrapeId":"01a0795b-d9bd-708b-b63b-d03ab5c66e5e","sourceURL":"https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html","url":"https://www.bmr.ca/fr/bmr-2-x-6-x-8-epinette-sec-2-m-069-6339.html","statusCode":200,"contentType":"text/html; charset=UTF-8","proxyUsed":"basic","cacheState":"hit","cachedAt":"2026-09-06T23:58:45.916Z","creditsUsed":1,"concurrencyLimited":false}}}
\ No newline at end of file
added src/lib/cost/connectors/__fixtures__/canac-2x6x8.json +1 −0
@@ -0,0 +1 @@
1 +{"success":true,"data":{"markdown":"Skip to Header Skip to Main Content Skip to Footer\n\n![undefined](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/26-08-C36-Bandeau-Promo-Peinture-FR-NEW)\n\n![Épinette de construction 2 po x 6 po x 8 pi sec](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/268S_1) Agrandir l'image\n\n![Épinette de construction 2 po x 6 po x 8 pi sec](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/268S_1)\n\n![Épinette de construction 2 po x 6 po x 8 pi sec](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/268S_2)\n\n# Épinette de construction 2 po x 6 po x 8 pi sec\n\nCode produit: 268S\n\n5,98 $ / Chaque\n\nDisponibilité\n\nVoir la disponibilité dans d'autres magasins\n\n**L’Ancienne-Lorette**\n\nCour à bois\n\n**En magasin \\- 10847 En inventaire**\n\nMéthodes de livraison\n\nCliquer et ramasser dans la cour dès aujourd'hui\n\n -\n+\n\nAjouter au panier[Connectez-vous pour ajouter à la liste de souhaits](https://www.canac.ca/canac/fr/2/sign-in)\n\nLe prix et la disponibilité de l'inventaire peuvent varier en magasin.\n\nConsultez [ce lien](https://www.canac.ca/canac/fr/pieces-de-rechange) pour connaître les renseignements sur les pièces, l'entretien et la réparation de nos produits, conformément à l’ **article 39 de la Loi sur la protection du consommateur du Québec**. La disponibilité des pièces, des services et des renseignements n’est pas garantie.\n\nDescription\n\nBois d'épinette raboté (plané) sur les 4 faces. Dimensions finies approx. : 1-1/2 po x 5-1/2 po. Montant de bois séché plus stable (gauchit moins). S'utilise pour une multitude de travaux de rénovation et de construction.\n\nDimensions finies approx. : 1 1/2 po x 5 1/2 po.\n\n(SPF) : Spruce-Pine-Fir.\n\nCaractéristiques\n\nType de produit Madrier 2 po x 6 po Matériau Bois Essence de bois Épinette Famille de couleur Naturel Classe Stud Longueur 8 pi Largeur nominale 6 po Épaisseur nominale 2 po Largeur finie 5,5 po Épaisseur finie 1,5 po\n\nGarantie\n\nAvertissement: Nous vous avisons que ce produit n’inclut pas de pièces de rechange, de service de réparation ou de renseignement nécessaire à l’entretien ou à la réparation, autre que ce qui est indiqué dans le guide d’installation ou d’utilisation ou la fiche technique ou la garantie fournis à l’achat.\n\nUtile avec cet achat\n\nSpécial\n\n[![Vis à bois traité brune 2 1/2 po n° 8 T.P. Pqt/100](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/107696_BD_A_vis_traite_1)](https://www.canac.ca/canac/fr/2/p/vis-a-bois-traite-brune-2-1-2-po-n-8-t-p-pqt-100/107696)\n\n[Vis à bois traité brune 2 1/2 po n° 8 T.P. Pqt/100](https://www.canac.ca/canac/fr/2/p/vis-a-bois-traite-brune-2-1-2-po-n-8-t-p-pqt-100/107696) Code produit: 107696\n\n**En inventaire \\- 129 En inventaire**\n\n3,79 $ / Chaque ~~4,09 $~~\n\n[![Vis à bois traité brune 3 po n° 8 T.P. Pqt/100](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/107700_BD_A_vis_traite_1)](https://www.canac.ca/canac/fr/2/p/vis-a-bois-traite-brune-3-po-n-8-t-p-pqt-100/107700)\n\n[Vis à bois traité brune 3 po n° 8 T.P. Pqt/100](https://www.canac.ca/canac/fr/2/p/vis-a-bois-traite-brune-3-po-n-8-t-p-pqt-100/107700) Code produit: 107700\n\n**En inventaire \\- 196 En inventaire**\n\n6,19 $ / Chaque\n\n[![Vis à plancher 2 1/2 po n° 8 Pqt/100](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/4126_WH_A_VIS_PLANCHER_1)](https://www.canac.ca/canac/fr/2/p/vis-a-plancher-2-1-2-po-n-8-pqt-100/4126)\n\n[Vis à plancher 2 1/2 po n° 8 Pqt/100](https://www.canac.ca/canac/fr/2/p/vis-a-plancher-2-1-2-po-n-8-pqt-100/4126) Code produit: 4126\n\n**En inventaire \\- 57 En inventaire**\n\n5,29 $ / Chaque\n\n[![Vis à plancher 3 po n° 8 Pqt/500](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/108940_BD_A_vis_plancher_1)](https://www.canac.ca/canac/fr/2/p/vis-a-plancher-3-po-n-8-pqt-500/108940)\n\n[Vis à plancher 3 po n° 8 Pqt/500](https://www.canac.ca/canac/fr/2/p/vis-a-plancher-3-po-n-8-pqt-500/108940) Code produit: 108940\n\n**En inventaire \\- 78 En inventaire**\n\n21,95 $ / Chaque\n\n[![Vis à plancher 2 1/2 po n° 8 Pqt/500](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/4127_BD_A_vis_plancher_1)](https://www.canac.ca/canac/fr/2/p/vis-a-plancher-2-1-2-po-n-8-pqt-500/4127)\n\n[Vis à plancher 2 1/2 po n° 8 Pqt/500](https://www.canac.ca/canac/fr/2/p/vis-a-plancher-2-1-2-po-n-8-pqt-500/4127) Code produit: 4127\n\n**En inventaire \\- 27 En inventaire**\n\n19,95 $ / Chaque\n\n[![Vis à plancher 3 po n° 8 Pqt/100](https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/562100_BD_A_VIS_PLANCHER_1)](https://www.canac.ca/canac/fr/2/p/vis-a-plancher-3-po-n-8-pqt-100/562100)\n\n[Vis à plancher 3 po n° 8 Pqt/100](https://www.canac.ca/canac/fr/2/p/vis-a-plancher-3-po-n-8-pqt-100/562100) Code produit: 562100\n\n**En inventaire \\- 125 En inventaire**\n\n5,49 $ / Chaque\n\nreCAPTCHA\n\nRecaptcha requires verification.\n\nprotected by **reCAPTCHA**","metadata":{"ogImage":"https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/268S_1","robots":"index, follow","og:type":"product.item","og:description":"Magasinez votre Épinette de construction 2 po x 6 po x 8 pi sec chez Canac en magasin ou en ligne. Canac offre tous les indispensables au bon prix","title":"Épinette de construction 2 po x 6 po x 8 pi sec - Canac","language":"fr","ogDescription":"Magasinez votre Épinette de construction 2 po x 6 po x 8 pi sec chez Canac en magasin ou en ligne. Canac offre tous les indispensables au bon prix","format-detection":"telephone=no","ogSiteName":"CANAC","ogUrl":"https://www.canac.ca/canac/fr/2/p/epinette-de-construction-2-po-x-6-po-x-8-pi-sec/268S","viewport":"width=device-width, initial-scale=1, interactive-widget=resizes-content","description":"Magasinez votre Épinette de construction 2 po x 6 po x 8 pi sec chez Canac en magasin ou en ligne. Canac offre tous les indispensables au bon prix","og:image":"https://res.cloudinary.com/canac-prod/image/upload/f_auto,q_auto/268S_1","product:price:currency":"CAD","ogTitle":"Épinette de construction 2 po x 6 po x 8 pi sec - Canac","og:site_name":"CANAC","og:title":"Épinette de construction 2 po x 6 po x 8 pi sec - Canac","product:price:amount":"5,98 $","referrer":"origin-when-cross-origin","occ-backend-base-url":"https://apisapcc.canac.ca","og:url":"https://www.canac.ca/canac/fr/2/p/epinette-de-construction-2-po-x-6-po-x-8-pi-sec/268S","favicon":"https://www.canac.ca/favicon.ico","scrapeId":"01a0795b-d97e-70ac-ad14-4f196b08e29b","sourceURL":"https://www.canac.ca/canac/fr/2/p/epinette-de-construction-2-po-x-6-po-x-8-pi-sec/268S","url":"https://www.canac.ca/canac/fr/2/p/epinette-de-construction-2-po-x-6-po-x-8-pi-sec/268S","statusCode":200,"contentType":"text/html; charset=utf-8","proxyUsed":"basic","cacheState":"hit","cachedAt":"2026-09-06T23:54:53.862Z","creditsUsed":1,"concurrencyLimited":false}}}
\ No newline at end of file
added src/lib/cost/connectors/__fixtures__/pm-2x6x8.json +1 −0
@@ -0,0 +1 @@
1 +{"success":true,"data":{"markdown":"The store will not work correctly when cookies are disabled.\n\n# Bois d'épinette sec stud, 2 po x 6 po x 8 pi\n\nCode produit: EP020608KD\n\n[Comparer](https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd#) [Comparé](https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd# \"Remove from Compare\")\n\n[Ajouter à ma liste](https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd# \"Ajouter à ma liste\")\n\n[Skip to the end of the images gallery](https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd#gallery-next-area)\n\n![Chargement en cours...](https://patrickmorin.com/static/version1788368940/frontend/O2web/patmorin/fr_CA/images/loader-1.gif)\n\n![Bois d'épinette sec stud, 2 po x 6 po x 8 pi](https://patrickmorin.com/media/catalog/product/d/r/dry-spruce-stud-1-EP020608KD-fr.jpg?optimize=medium&bg-color=255,255,255&fit=bounds&height=700&width=680&canvas=680:700)\n\n[Skip to the beginning of the images gallery](https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd#gallery-prev-area)\n\n5,98 $\n\nQuantité\n\nAjouter au panier\n\nAchetez en ligne :\n\nRamassage en magasin\n\n1511\nDisponible à\nLaval\n\nOffert\n\nLivraison\n\nVérifiez la disponibilité pour votre code postal\n\n[Estimer le prix de ma livraison](https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd# \"Estimer le prix de ma livraison\")\n\n[Voir la disponibilité autour de moi](https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd# \"Voir la disponibilité autour de moi\")\n\n[![Expédition par colis gratuite](https://patrickmorin.com/media/wysiwyg/avantage-livraison.png?quality=80)![Expédition par colis gratuite](https://patrickmorin.com/media/wysiwyg/avantage-livraison.png?quality=80)](https://www.patrickmorin.com/fr/commandes-en-ligne \"Expédition par colis gratuite\")\n\n[Options de livraison](https://patrickmorin.com/fr/commandes-en-ligne/)\n\n[![Retours faciles](https://patrickmorin.com/media/wysiwyg/avantage-retours-faciles.png?quality=80)![Retours faciles](https://patrickmorin.com/media/wysiwyg/avantage-retours-faciles.png?quality=80)](https://www.patrickmorin.com/fr/retours-et-remboursements \"Retours faciles\")\n\n[Retours faciles](https://patrickmorin.com/fr/retours-et-remboursements/)\n\n[![Ramassage gratuit en magasin](https://patrickmorin.com/media/wysiwyg/avantage-ramassage-magasin.png?quality=80)![Ramassage gratuit en magasin](https://patrickmorin.com/media/wysiwyg/avantage-ramassage-magasin.png?quality=80)](https://www.patrickmorin.com/fr/commandes-en-ligne \"Ramassage gratuit en magasin\")\n\n[Ramassage gratuit en magasin](https://patrickmorin.com/fr/commandes-en-ligne/)\n\n[![Financement](https://patrickmorin.com/media/wysiwyg/avantage-financement.png?quality=80)![Financement](https://patrickmorin.com/media/wysiwyg/avantage-financement.png?quality=80)](https://www.patrickmorin.com/fr/financement \"Financement\")\n\n[Financement](https://patrickmorin.com/fr/financement/)\n\nDescription\n\nBois d'épinette sec stud 2 x 6 x 8\n\nUPC: 400971020524\n\nCaractéristiques\n\n| Poids | 12.500000 |\n| Disponibilité du plan | Oui |\n| Code UPC | 400971020524 |\n| Largeur Boîte | 5.5000 |\n| Hauteur Boîte | 1.5000 |\n| Longueur Boîte | 96.0000 |\n| Hauteur produit assemblé | 2 |\n| Largeur produit assemblé | 6 |\n| Longueur produit assemblé | 8 |\n| Dimensions | 2 po x 6 po x 8 pi |\n| Longueur (pi) | 8 |\n\nPlus d'information\n\n**Procéder au paiement en tant que nouveau client**\n\nLa création d'un compte possède de nombreux avantages :\n\n- Voir le statut de la commande et de l'expédition\n- Suivi de la commande\n- Commandez plus rapidement\n\n[Créer un compte](https://patrickmorin.com/fr/customer/account/create/)\n\n**Procéder au paiement en utilisant votre compte**\n\nAdresse courriel\n\nMot de passe\n\nSe connecter\n\n[Mot de passe oublié ?](https://patrickmorin.com/fr/customer/account/forgotpassword/)\n\n**AVIS À NOS CLIENTS**\n\n\\*Les produits vendus sur notre site en ligne et en magasins sont généralement couverts par une garantie d'utilisation normale par leur fabricant. Renseignez-vous auprès de votre conseiller avant de procéder à l'achat.\n\n\\*Nous ne pouvons garantir que le prix et les délais des pièces de rechange et d'un service de réparation s'appliquent à 100 % de notre inventaire. Soyez assuré que nous nous efforçons de fournir ces renseignements spécifiques à chaque produit visé au fur et à mesure qu'ils nous sont transmis par leur fabricant.\n\n\\\\* Le produit peut différer de l'illustration et le prix peut varier selon votre région. Voyez le prix disponible dans le magasin près de chez vous en sélectionnant votre succursale.\n\nNotez qu'un délai supplémentaire est à prévoir pour le ramassage et la livraison des produits saisonniers.\n\n\\\\* Attention : Le dernier produit en stock peut être le modèle de démonstration. Contactez le magasin pour vérifier la disponibilité ou l'état du produit avant l'achat.\n\n**\\\\* À noter que les produits en liquidation sont des ventes finales. Aucun retour ni remboursement ne peut être effectué. L'inventaire peut varier selon la disponibilité en succursale et aucun transfert en magasin n'est possible. Quantitée limitée, jusqu'à épuisement des stocks.**\n\n5,98 $\n\nAjouter au panier\n\n![S'abonner à l'infolettre](https://patrickmorin.com/static/version1788368940/frontend/O2web/patmorin/fr_CA/images/newsletter-desktop-fr.jpg)\n\nGagnez votre achat en ligne !\n\nAbonnez-vous à l'infolettre et courez la chance de gagner la valeur de votre achat en carte-cadeau Patrick Morin.\n\nAdresse courriel\n\nM'inscrire\n\n\nGorgias Shopper Engagement\n\nGorgias Floating ChatGorgias Floating Chat\n\nGorgias live chat messengerGorgias Live Chat","metadata":{"og:url":"https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd","ogImage":"https://patrickmorin.com/media/catalog/product/d/r/dry-spruce-stud-1-EP020608KD-fr.jpg?width=800&height=&canvas=800,&optimize=medium&bg-color=255,255,255&fit=bounds","google-site-verification":"gav0LX2TIYBhpsVzVEtWDERBzxSMz40Mf69X8kmjfjQ","og:image":"https://patrickmorin.com/media/catalog/product/d/r/dry-spruce-stud-1-EP020608KD-fr.jpg?width=800&height=&canvas=800,&optimize=medium&bg-color=255,255,255&fit=bounds","robots":"INDEX,FOLLOW","product:availability":"in stock","language":"fr","ogUrl":"https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd","msvalidate.01":"494D2CA3C123DB423D2AE4035DAF6520","description":"Bois d'épinette sec stud 2 x 6 x 8","product:condition":"NewCondition","format-detection":"telephone=no","ogSiteName":"Patrick Morin","facebook-domain-verification":"4yfit48yw6s98sm6s76y6c4l0rmcwz","og:type":"product","product:retailer_item_id":"71002","position":["1","2","3","4","5","6"],"title":"Bois d'épinette sec stud 2 x 6 x 8 | Patrick Morin","og:title":"Bois d'épinette sec stud 2 x 6 x 8 | Patrick Morin","product:price:amount":"5.98","product:price:currency":"CAD","og:site_name":"Patrick Morin","image":"https://patrickmorin.com/media/catalog/product/d/r/dry-spruce-stud-1-EP020608KD-fr.jpg?optimize=medium&bg-color=255,255,255&fit=bounds&height=&width=","ogTitle":"Bois d'épinette sec stud 2 x 6 x 8 | Patrick Morin","viewport":"width=device-width, initial-scale=1","favicon":"https://patrickmorin.com/media/favicon/stores/1/favicon-120_2.jpg","scrapeId":"01a0795b-d99c-75c8-a7fe-d09e999d79e8","sourceURL":"https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd","url":"https://patrickmorin.com/fr/bois-d-epinette-sec-stud-2-po-x-6-po-x-8-pi-ep020608kd","statusCode":200,"contentType":"text/html; charset=UTF-8","proxyUsed":"basic","cacheState":"hit","cachedAt":"2026-09-06T23:58:47.719Z","creditsUsed":1,"concurrencyLimited":false}}}
\ No newline at end of file
added src/lib/cost/connectors/admin.ts +94 −0
@@ -0,0 +1,94 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Administration de la base de coûts (serveur) : authentification par jeton,
4 + * état des connecteurs, anomalies, mappings, lancement en arrière-plan.
5 + */
6 +import { NextResponse } from "next/server";
7 +import type Database from "better-sqlite3";
8 +import { getCostDb } from "../db";
9 +import { clientKey, rateLimit } from "../cache";
10 +import { CONNECTORS, IMPORT_ONLY, isRunnable, type RunnableKey } from "./registry";
11 +import { isDue, runConnector } from "./run";
12 +import type { RunStats } from "./types";
13 +
14 +export function adminAuth(req: Request): NextResponse | null {
15 + const token = process.env.COST_ADMIN_TOKEN;
16 + if (!token) return NextResponse.json({ error: "COST_ADMIN_TOKEN non configuré" }, { status: 503 });
17 + if (!rateLimit(`admin:${clientKey(req)}`, 60, 60_000)) return NextResponse.json({ error: "Trop de requêtes" }, { status: 429 });
18 + const h = req.headers.get("authorization") ?? "";
19 + const bearer = h.toLowerCase().startsWith("bearer ") ? h.slice(7).trim() : null;
20 + const q = new URL(req.url).searchParams.get("token");
21 + if (bearer !== token && q !== token) return NextResponse.json({ error: "non autorisé" }, { status: 401 });
22 + return null;
23 +}
24 +
25 +type Row = Record<string, unknown>;
26 +
27 +export function connectorsStatus(d: Database.Database = getCostDb()) {
28 + const sources = d.prepare(`SELECT s.key, s.name, s.source_type type, s.license_status license, s.is_active active, s.priority, s.refresh_frequency frequency, s.last_successful_sync lastSync, s.last_error lastError,
29 + (SELECT COUNT(*) FROM cost_item_prices p WHERE p.source_id=s.id) prices,
30 + (SELECT COUNT(*) FROM cost_item_prices p WHERE p.source_id=s.id AND p.observation_date >= date('now','-7 days')) pricesWeek,
31 + (SELECT COUNT(DISTINCT cost_item_id) FROM cost_item_prices p WHERE p.source_id=s.id AND p.observation_date >= date('now','-120 days')) itemsRecent,
32 + (SELECT COUNT(*) FROM labour_rates l WHERE l.source_id=s.id) labour,
33 + (SELECT COUNT(*) FROM cost_raw_observations o WHERE o.source_id=s.id) raw,
34 + (SELECT COUNT(*) FROM cost_raw_observations o WHERE o.source_id=s.id AND o.status='rejected') rejected,
35 + (SELECT COUNT(*) FROM cost_item_sources c WHERE c.source_id=s.id AND c.active=1) mappedItems,
36 + (SELECT COUNT(*) FROM product_mappings m WHERE m.source_id=s.id AND m.status='pending') pendingMappings
37 + FROM cost_sources s ORDER BY s.priority, s.name`).all() as Row[];
38 + const runs = d.prepare("SELECT id, connector, status, started_at startedAt, finished_at finishedAt, pages, observations, accepted, rejected, unchanged, errors, duration_ms durationMs FROM connector_runs ORDER BY started_at DESC LIMIT 40").all() as Row[];
39 + const indices = d.prepare("SELECT index_code code, geography, building_type buildingType, division, MAX(period) last, COUNT(*) n, MAX(retrieved_at) retrievedAt FROM construction_cost_indices GROUP BY index_code ORDER BY index_code").all() as Row[];
40 + const running = [...RUNNING.entries()].map(([k, v]) => ({ connector: k, runId: v.runId, startedAt: v.startedAt }));
41 + return {
42 + sources: sources.map((s) => ({ ...s, runnable: isRunnable(String(s.key)), importOnly: IMPORT_ONLY.includes(s.key as never), due: isRunnable(String(s.key)) ? isDue(s.key as RunnableKey, d) : false, refreshDays: isRunnable(String(s.key)) ? CONNECTORS[s.key as RunnableKey].config.refreshDays : null })),
43 + runs, indices, running,
44 + metrics: {
45 + syncSuccessRate: successRate(d), observationsPerSource: Object.fromEntries(sources.map((s) => [String(s.key), Number(s.prices) + Number(s.labour)])),
46 + ageOfLatestPriceDays: ageDays((d.prepare("SELECT MAX(observation_date) m FROM cost_item_prices WHERE price_kind IN ('observed','official')").get() as { m: string | null }).m),
47 + mappingFailureRate: mappingFailureRate(d), outlierRate: outlierRate(d),
48 + aiUsage: d.prepare("SELECT purpose, COUNT(*) n, SUM(input_tokens) input, SUM(output_tokens) output, ROUND(SUM(estimated_cost_usd),4) usd FROM ai_usage GROUP BY purpose").all(),
49 + },
50 + };
51 +}
52 +
53 +function successRate(d: Database.Database): number | null {
54 + const r = d.prepare("SELECT SUM(CASE WHEN status IN ('ok','partial') THEN 1 ELSE 0 END) ok, COUNT(*) n FROM connector_runs WHERE started_at >= datetime('now','-30 days') AND status != 'running'").get() as { ok: number | null; n: number };
55 + return r.n ? Math.round((100 * (r.ok ?? 0)) / r.n) : null;
56 +}
57 +function mappingFailureRate(d: Database.Database): number | null {
58 + const r = d.prepare("SELECT SUM(CASE WHEN status IN ('pending','rejected') THEN 1 ELSE 0 END) bad, COUNT(*) n FROM product_mappings").get() as { bad: number | null; n: number };
59 + return r.n ? Math.round((100 * (r.bad ?? 0)) / r.n) : null;
60 +}
61 +function outlierRate(d: Database.Database): number | null {
62 + const r = d.prepare("SELECT (SELECT COUNT(*) FROM cost_raw_observations WHERE status='rejected' AND reject_reason LIKE 'aberration%') a, (SELECT COUNT(*) FROM cost_item_prices) n").get() as { a: number; n: number };
63 + return r.n ? Math.round((1000 * r.a) / (r.n + r.a)) / 10 : null;
64 +}
65 +function ageDays(iso: string | null): number | null {
66 + return iso ? Math.round((Date.now() - new Date(iso).getTime()) / 86400000) : null;
67 +}
68 +
69 +export function anomalies(d: Database.Database = getCostDb()) {
70 + const outliers = d.prepare(`SELECT o.id, s.name source, o.source_url url, o.raw_title title, o.raw_price price, o.raw_unit unit, o.retrieved_at retrievedAt, o.reject_reason reason FROM cost_raw_observations o JOIN cost_sources s ON s.id=o.source_id WHERE o.status='rejected' ORDER BY o.retrieved_at DESC LIMIT 100`).all();
71 + const pending = d.prepare(`SELECT m.id, s.name source, m.product_url url, m.product_title title, m.proposed_item_code itemCode, m.method, m.confidence, m.rationale, m.pack_qty packQty, m.created_at createdAt FROM product_mappings m JOIN cost_sources s ON s.id=m.source_id WHERE m.status='pending' ORDER BY m.created_at DESC LIMIT 200`).all();
72 + const stale = d.prepare(`SELECT s.key, s.name, s.last_successful_sync lastSync FROM cost_sources s WHERE s.is_active=1 AND s.source_type IN ('retail','labour','index') AND (s.last_successful_sync IS NULL OR s.last_successful_sync < datetime('now','-14 days'))`).all();
73 + // écarts > 30 % entre deux observations consécutives d'un même (article, source)
74 + const jumps = d.prepare(`SELECT i.canonical_code code, s.name source, p1.observation_date d1, p1.total_cost v1, p2.observation_date d2, p2.total_cost v2, ROUND(100.0*(p2.total_cost/p1.total_cost-1),1) pct
75 + FROM cost_item_prices p2 JOIN cost_item_prices p1 ON p1.cost_item_id=p2.cost_item_id AND p1.source_id=p2.source_id AND p1.observation_date = (SELECT MAX(observation_date) FROM cost_item_prices x WHERE x.cost_item_id=p2.cost_item_id AND x.source_id=p2.source_id AND x.observation_date < p2.observation_date)
76 + JOIN cost_items i ON i.id=p2.cost_item_id JOIN cost_sources s ON s.id=p2.source_id
77 + WHERE ABS(p2.total_cost/p1.total_cost-1) > 0.3 ORDER BY p2.observation_date DESC LIMIT 100`).all();
78 + const noPrice = d.prepare(`SELECT i.canonical_code code, i.name_fr name, i.reference_price ref FROM cost_items i WHERE i.active=1 AND i.retail_query IS NOT NULL AND NOT EXISTS (SELECT 1 FROM cost_item_prices p WHERE p.cost_item_id=i.id AND p.observation_date >= date('now','-120 days')) ORDER BY i.canonical_code`).all();
79 + return { outliers, pendingMappings: pending, staleSources: stale, priceJumps: jumps, itemsWithoutObservation: noPrice };
80 +}
81 +
82 +/* ------------------------------------------------ lancement en arrière-plan */
83 +
84 +const RUNNING = new Map<RunnableKey, { runId: number; startedAt: string; promise: Promise<RunStats> }>();
85 +
86 +/** Démarre un connecteur sans bloquer la requête ; renvoie tout de suite l'identifiant du run. */
87 +export function launchConnector(key: RunnableKey, force: boolean, onlyItems?: string[]): { started: boolean; runId: number | null; message: string } {
88 + if (RUNNING.has(key)) return { started: false, runId: RUNNING.get(key)!.runId, message: "déjà en cours" };
89 + const d = getCostDb();
90 + const before = (d.prepare("SELECT COALESCE(MAX(id),0) m FROM connector_runs").get() as { m: number }).m;
91 + const promise = runConnector(key, { force, onlyItems, db: d }).finally(() => RUNNING.delete(key));
92 + RUNNING.set(key, { runId: before + 1, startedAt: new Date().toISOString(), promise });
93 + return { started: true, runId: before + 1, message: "démarré en arrière-plan" };
94 +}
added src/lib/cost/connectors/apchq-ai.ts +136 −0
@@ -0,0 +1,136 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Lecture assistée des grilles APCHQ : Claude Haiku 4.5 lit le PDF (document
4 + * base64) et TRANSCRIT les lignes (métier, classification, montants). L'IA ne
5 + * fixe aucun prix : chaque ligne est ensuite vérifiée doublement —
6 + * 1. cohérence interne : base + vacances + avantages + cotisations ≈ total ;
7 + * 2. les couples (taux horaire, total) doivent exister dans l'extraction
8 + * déterministe (regex) du même document.
9 + * Les lignes qui échouent sont rejetées. Sert à rétablir les libellés que la
10 + * conversion PDF → markdown décale ou fusionne.
11 + */
12 +import Anthropic from "@anthropic-ai/sdk";
13 +import type Database from "better-sqlite3";
14 +import { getCostDb } from "../db";
15 +import type { ApchqRow } from "./apchq";
16 +
17 +const MODEL = "claude-haiku-4-5";
18 +
19 +interface AiRow {
20 + trade: string;
21 + classification: string;
22 + base_wage: number;
23 + vacation: number | null;
24 + benefits: number | null;
25 + total: number;
26 + contributions_sum: number | null;
27 +}
28 +
29 +const TOOL: Anthropic.Tool = {
30 + name: "record_rows",
31 + description: "Transcrit toutes les lignes de la grille de coût horaire (une ligne par métier/occupation et par classification).",
32 + strict: true,
33 + input_schema: {
34 + type: "object",
35 + additionalProperties: false,
36 + properties: {
37 + effective_date: { type: ["string", "null"], description: "Date d'entrée en vigueur (YYYY-MM-DD) lue dans l'en-tête" },
38 + sector: { type: ["string", "null"], description: "Secteur lu dans l'en-tête (résidentiel léger, lourd, institutionnel-commercial…)" },
39 + rows: {
40 + type: "array",
41 + items: {
42 + type: "object",
43 + additionalProperties: false,
44 + properties: {
45 + trade: { type: "string", description: "Libellé exact du métier ou de l'occupation (ligne compagnon), tel qu'imprimé" },
46 + classification: { type: "string", enum: ["compagnon", "apprenti-1", "apprenti-2", "apprenti-3", "apprenti-4", "apprenti-5"] },
47 + base_wage: { type: "number", description: "Taux horaire ($)" },
48 + vacation: { type: ["number", "null"], description: "Indemnité de vacances ($)" },
49 + benefits: { type: ["number", "null"], description: "Avantages sociaux ($)" },
50 + contributions_sum: { type: ["number", "null"], description: "Somme de toutes les autres colonnes entre avantages sociaux et Total ($)" },
51 + total: { type: "number", description: "Colonne Total ($)" },
52 + },
53 + required: ["trade", "classification", "base_wage", "vacation", "benefits", "contributions_sum", "total"],
54 + },
55 + },
56 + },
57 + required: ["effective_date", "sector", "rows"],
58 + },
59 +};
60 +
61 +const SYSTEM = `Tu es un moteur de transcription de tableaux. On te fournit un PDF de l'APCHQ « Coût horaire de la main-d'œuvre » (grille par métier : taux horaire, indemnité de vacances, avantages sociaux, cotisations et prélèvements, Total).
62 +Règles strictes : transcris UNIQUEMENT les nombres imprimés, sans arrondir ni calculer ; une ligne par métier/occupation et par classification (la ligne du métier = compagnon, puis « Apprenti-période n » = apprenti-n) ; si une valeur est illisible, mets null ; ne saute aucune ligne, y compris la section « Occupations » ; n'invente jamais une ligne. Réponds seulement par l'appel de l'outil record_rows.`;
63 +
64 +export interface AiExtractResult {
65 + rows: ApchqRow[];
66 + checked: number;
67 + dropped: number;
68 + usage: { input: number; output: number; cacheRead: number; latencyMs: number };
69 +}
70 +
71 +export async function downloadPdf(url: string, timeoutMs = 60_000): Promise<Buffer> {
72 + const ctrl = new AbortController();
73 + const t = setTimeout(() => ctrl.abort(), timeoutMs);
74 + try {
75 + const res = await fetch(url, { signal: ctrl.signal, headers: { "User-Agent": "Vrai-Prix cost connector (contact@spboucher.ai)" } });
76 + if (!res.ok) throw new Error(`PDF HTTP ${res.status}`);
77 + return Buffer.from(await res.arrayBuffer());
78 + } finally {
79 + clearTimeout(t);
80 + }
81 +}
82 +
83 +/** Croise la transcription IA avec les couples (base, total) trouvés par le parseur déterministe. */
84 +export function reconcile(ai: AiRow[], regexRows: ApchqRow[], fixedOther: number): { rows: ApchqRow[]; dropped: number } {
85 + const pairs = new Set(regexRows.map((r) => `${r.baseWage.toFixed(2)}|${r.total.toFixed(2)}`));
86 + const bases = new Set(regexRows.map((r) => r.baseWage.toFixed(2)));
87 + const totals = new Set(regexRows.map((r) => r.total.toFixed(2)));
88 + const rows: ApchqRow[] = [];
89 + let dropped = 0;
90 + const seen = new Set<string>();
91 + for (const a of ai) {
92 + if (!(a.base_wage > 10 && a.base_wage < 200) || !(a.total > a.base_wage * 1.25 && a.total < a.base_wage * 2.1)) { dropped++; continue; }
93 + const key = `${a.base_wage.toFixed(2)}|${a.total.toFixed(2)}`;
94 + const inRegex = pairs.has(key) || (bases.has(a.base_wage.toFixed(2)) && totals.has(a.total.toFixed(2)));
95 + const vac = a.vacation != null && a.vacation >= a.base_wage * 0.1 && a.vacation <= a.base_wage * 0.16 ? a.vacation : Math.round(a.base_wage * 0.13 * 100) / 100;
96 + const ben = a.benefits != null && a.benefits >= 3 && a.benefits <= 14 ? a.benefits : null;
97 + const sumOk = a.contributions_sum != null && ben != null ? Math.abs(a.base_wage + vac + ben + a.contributions_sum - a.total) <= 0.08 : false;
98 + if (!inRegex && !sumOk) { dropped++; continue; }
99 + const k = `${a.trade.toLowerCase()}|${a.classification}`;
100 + if (seen.has(k)) continue;
101 + seen.add(k);
102 + const benefits = ben ?? 0;
103 + const employer = Math.max(0, Math.round((a.total - a.base_wage - vac - benefits - fixedOther) * 100) / 100);
104 + rows.push({ trade: a.trade.trim(), classification: a.classification, baseWage: a.base_wage, vacation: vac, benefits, otherFixed: fixedOther, employer, total: a.total, exactBreakdown: inRegex && sumOk });
105 + }
106 + return { rows, dropped };
107 +}
108 +
109 +export async function extractApchqWithClaude(pdf: Buffer, regexRows: ApchqRow[], meta: { url: string; sector: string; effectiveDate: string }, d: Database.Database = getCostDb()): Promise<AiExtractResult> {
110 + const client = new Anthropic();
111 + const t0 = Date.now();
112 + const res = await client.messages.create({
113 + model: MODEL,
114 + max_tokens: 16000,
115 + system: SYSTEM,
116 + tools: [TOOL],
117 + tool_choice: { type: "tool", name: "record_rows" },
118 + messages: [{ role: "user", content: [
119 + { type: "document", source: { type: "base64", media_type: "application/pdf", data: pdf.toString("base64") } },
120 + { type: "text", text: `Grille « ${meta.sector} » en vigueur au ${meta.effectiveDate} (${meta.url}). Transcris toutes les lignes.` },
121 + ] }],
122 + });
123 + const latencyMs = Date.now() - t0;
124 + const usage = { input: res.usage.input_tokens, output: res.usage.output_tokens, cacheRead: res.usage.cache_read_input_tokens ?? 0, latencyMs };
125 + try {
126 + d.prepare("INSERT INTO ai_usage(analysis_id,purpose,model,input_images,input_tokens,output_tokens,cache_read_tokens,estimated_cost_usd,latency_ms) VALUES(NULL,'apchq_extract',?,0,?,?,?,?,?)")
127 + .run(MODEL, usage.input, usage.output, usage.cacheRead, (usage.input * 1 + usage.output * 5) / 1e6, latencyMs);
128 + } catch { /* table absente en test */ }
129 + const tu = res.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
130 + if (!tu) return { rows: [], checked: 0, dropped: 0, usage };
131 + const input = tu.input as { rows?: AiRow[] };
132 + const ai = Array.isArray(input.rows) ? input.rows.filter((r) => r && typeof r.trade === "string" && typeof r.base_wage === "number" && typeof r.total === "number") : [];
133 + const fixedOther = regexRows[0]?.otherFixed ?? 1.203;
134 + const { rows, dropped } = reconcile(ai, regexRows, fixedOther);
135 + return { rows, checked: ai.length, dropped, usage };
136 +}
added src/lib/cost/connectors/apchq.ts +311 −0
@@ -0,0 +1,311 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Connecteur APCHQ — grilles publiques « Coût horaire de la main-d'œuvre »
4 + * (PDF, media.apchq.com) : taux horaire conventionné (CCQ), indemnité de
5 + * vacances (13 %), avantages sociaux, cotisations (AE, RQAP, RRQ, FSS, CNESST),
6 + * prélèvements (CCQ, AECQ, fonds), TOTAL = coût employeur complet.
7 + *
8 + * Firecrawl rend le PDF en tableau markdown ; la mise en page fusionne parfois
9 + * deux lignes (compagnon + apprenti 1, ou apprenti 2 + apprenti 3) dans une
10 + * même rangée : le parseur lit les montants cellule par cellule et reconstitue
11 + * un enregistrement par valeur de « Taux horaire ». Les champs exacts sont le
12 + * taux horaire et le total ; la ventilation intermédiaire est lue quand la
13 + * cellule est nette, sinon reconstituée (vacances = 13 % réglementaire,
14 + * cotisations employeur = total − autres postes) et la confiance est réduite.
15 + */
16 +import { TRADES } from "../taxonomy";
17 +import { contentHash, parseMoney, scrape } from "./firecrawl";
18 +import { lastHashFor } from "./store";
19 +import { downloadPdf, extractApchqWithClaude } from "./apchq-ai";
20 +import type { CanonicalObservation, ConnectorConfig, CostConnector, RawDocument, RawObservation, RunOptions, SourceDocument, ValidationResult } from "./types";
21 +import { validateObservations } from "./validate";
22 +import { recentPricesFor, referencePriceFor } from "./store";
23 +
24 +export const APCHQ_PARSER_VERSION = "apchq-1.0";
25 +export const APCHQ_LANDING = "https://www.apchq.com/nos-services/relations-du-travail/paie/couts-horaires-et-paie/";
26 +
27 +export const APCHQ_CONFIG: ConnectorConfig = {
28 + key: "apchq", name: "APCHQ — Coûts horaires de la main-d'œuvre", domain: "apchq.com", maxPages: 40,
29 + allowedPaths: [/^https:\/\/media\.apchq\.com\/.+\.pdf$/i, /^https:\/\/www\.apchq\.com\/nos-services\/relations-du-travail\/paie\//],
30 + excludedPaths: [/temps-(demi|et-demi|double)/i, /exemple-de-paie/i, /avantages-(sociaux|imposable)/i, /residentiel-r-2/i, /chantiers?-isoles/i, /baie-james/i, /special-paie/i, /ic_special/i, /ic-speciale/i],
31 + refreshDays: 7, timeoutMs: 150_000, retries: 2, rateLimitMs: 1500, concurrency: 2,
32 +};
33 +
34 +const MONTHS: [RegExp, string][] = [
35 + [/^jan/, "01"], [/^f[eé]v/, "02"], [/^mar/, "03"], [/^a[vr]r?[iv]?/, "04"], [/^mai/, "05"], [/^juin/, "06"], [/^juil/, "07"], [/^ao[uû]/, "08"], [/^sep/, "09"], [/^oct/, "10"], [/^nov/, "11"], [/^d[eé]c/, "12"],
36 +];
37 +
38 +/** Date d'entrée en vigueur depuis le nom du PDF (« 26-arvil-2026 », « 1er-janvier-2024 »). */
39 +export function dateFromUrl(url: string): string | null {
40 + const m = url.toLowerCase().match(/(\d{1,2})(?:er)?-([a-zéû]+)-(\d{4})/);
41 + if (!m) return null;
42 + const month = MONTHS.find(([re]) => re.test(m[2]))?.[1];
43 + if (!month) return null;
44 + return `${m[3]}-${month}-${m[1].padStart(2, "0")}`;
45 +}
46 +
47 +export function sectorFromUrl(url: string): "residentiel_leger" | "residentiel_lourd" | "ic" | null {
48 + const u = url.toLowerCase();
49 + if (/residentiel-leger/.test(u)) return "residentiel_leger";
50 + if (/residentiel-lourd/.test(u)) return "residentiel_lourd";
51 + if (/commercial/.test(u)) return "ic";
52 + return null;
53 +}
54 +
55 +const fold = (s: string) => s.replace(/œ/g, "oe").replace(/Œ/g, "oe").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
56 +
57 +/** Libellés APCHQ (abrégés dans les PDF) → codes de métier internes. */
58 +const ALIASES: Record<string, string> = {
59 + "charpentier menuisier": "charpentier",
60 + "briqueteur macon": "briqueteur",
61 + carreleur: "carreleur",
62 + "cimentier applicateur": "cimentier",
63 + "coffreur a beton": "coffreur",
64 + couvreur: "couvreur",
65 + electricien: "electricien",
66 + tuyauteur: "plombier",
67 + plombier: "plombier",
68 + ferblantier: "ferblantier",
69 + frigoriste: "frigoriste",
70 + peintre: "peintre",
71 + platrier: "platrier",
72 + "tir joints peint platr": "tireur_joints",
73 + "poseur de systemes interieurs": "poseur_systemes",
74 + "poseur de rev souples": "poseur_revetements",
75 + "poseur de revetements souples": "poseur_revetements",
76 + calorifugeur: "calorifugeur",
77 + "poseur d armature du beton": "ferrailleur",
78 + ferrailleur: "ferrailleur",
79 + "monteur mecanicien vitrier": "vitrier",
80 + "manoeuvre journalier": "manoeuvre",
81 + manoeuvre: "manoeuvre",
82 + "manoeuvre specialise": "manoeuvre_specialise",
83 + "op de p mec cl a": "operateur",
84 + "ope de p mec cl b": "operateur_b",
85 + "op d equip l classe a": "operateur_equipement_lourd",
86 + "op d equip l classe b": "operateur_equipement_lourd_b",
87 + "parqueteur sableur": "parqueteur",
88 + "grutier classe a": "grutier",
89 + "mecanicien de chantier": "mecanicien_chantier",
90 + "monteur assembleur": "monteur_assembleur",
91 + "poseur de fondation profonde": "poseur_fondation",
92 + "install de sys de securite": "installateur_securite",
93 + "mec protection incendie": "mecanicien_protection_incendie",
94 + soudeur: "soudeur",
95 + boutefeu: "boutefeu",
96 + foreur: "foreur",
97 +};
98 +
99 +/** Spécialités de la CCQ rattachées à un métier : le poseur de systèmes intérieurs est une spécialité du charpentier-menuisier. */
100 +const DERIVED: { from: string; to: string; label: string }[] = [
101 + { from: "charpentier", to: "poseur_systemes", label: "Charpentier-menuisier — spécialité poseur de systèmes intérieurs" },
102 + { from: "couvreur", to: "poseur_bardeaux", label: "Couvreur — pose de bardeaux" },
103 +];
104 +
105 +export function tradeCodeFor(label: string): { code: string; matched: boolean } {
106 + const f = fold(label);
107 + if (ALIASES[f]) return { code: ALIASES[f], matched: true };
108 + for (const t of TRADES) if (t.apchq.some((a) => fold(a) === f)) return { code: t.code, matched: true };
109 + for (const [k, v] of Object.entries(ALIASES)) if (f.startsWith(k) || k.startsWith(f)) return { code: v, matched: true };
110 + return { code: f.replace(/ /g, "_").slice(0, 40) || "inconnu", matched: false };
111 +}
112 +
113 +/* ---------------------------------------------------------------- parseur */
114 +
115 +export interface ApchqRow {
116 + trade: string; // libellé APCHQ
117 + classification: string; // compagnon | apprenti-1…
118 + baseWage: number;
119 + vacation: number;
120 + benefits: number;
121 + otherFixed: number;
122 + employer: number;
123 + total: number;
124 + exactBreakdown: boolean;
125 +}
126 +
127 +const FIXED_CANDIDATES = [0.26, 0.6, 0.65, 0.03, 0.02, 0.2, 0.043];
128 +const MONEY_RE = /-?\d[\d\s]*(?:[.,]\d+)?\s*\$/g;
129 +const APPR_RE = /apprenti[- ]p[eé]riode\s*(\d)/gi;
130 +
131 +function cellValues(cell: string): number[] {
132 + return [...cell.matchAll(MONEY_RE)].map((m) => parseMoney(m[0])).filter((v): v is number => v != null);
133 +}
134 +
135 +function cellText(cell: string): string {
136 + return cell.replace(MONEY_RE, " ").replace(/&#x27;|&#39;|’/g, "'").replace(/\s+/g, " ").trim();
137 +}
138 +
139 +/** Parse le markdown d'une grille APCHQ en enregistrements (compagnon + apprentis). */
140 +export function parseApchqMarkdown(md: string): ApchqRow[] {
141 + const rows: ApchqRow[] = [];
142 + let currentTrade: string | null = null;
143 + for (const line of md.split("\n")) {
144 + if (!line.startsWith("|")) continue;
145 + const cells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|");
146 + const text = cells.map(cellText).filter(Boolean).join(" ").trim();
147 + if (!text || /^-+$/.test(text) || /après avoir|valeur unité|métiers et spécialités|occupations|^total$|horaire/i.test(text)) continue;
148 + let valueCells = cells.map(cellValues).filter((v) => v.length > 0);
149 + const apprs = [...text.matchAll(APPR_RE)].map((m) => Number(m[1]));
150 + const nameOnly = text.replace(APPR_RE, " ").replace(/\s+/g, " ").trim();
151 + const looksLikeName = nameOnly.length >= 3 && nameOnly.length <= 60 && !/%|\$/.test(nameOnly);
152 + if (!valueCells.length) {
153 + if (looksLikeName && !apprs.length) currentTrade = nameOnly;
154 + continue;
155 + }
156 + if (looksLikeName) currentTrade = nameOnly;
157 + if (!currentTrade) continue;
158 + // rangée aplatie : toutes les valeurs dans une seule cellule → un seul enregistrement
159 + if (valueCells.length === 1 && valueCells[0].length >= 12) valueCells = valueCells[0].map((v) => [v]);
160 + const first = valueCells[0];
161 + const last = valueCells[valueCells.length - 1];
162 + const k = Math.max(1, first.length);
163 + const classes: string[] = apprs.length ? apprs.map((n) => `apprenti-${n}`) : ["compagnon"];
164 + while (classes.length < k) {
165 + const prev = classes[classes.length - 1];
166 + classes.push(`apprenti-${prev === "compagnon" ? 1 : Number(prev.split("-")[1]) + 1}`);
167 + }
168 + const allValues = valueCells.flat();
169 + const otherFixed = FIXED_CANDIDATES.filter((c) => allValues.some((v) => Math.abs(v - c) < 0.0005)).reduce((s, v) => s + v, 0);
170 + for (let i = 0; i < k; i++) {
171 + const base = first[i];
172 + const total = last.length > i ? last[i] : last.length === 1 && i === 0 ? last[0] : null;
173 + if (base == null || total == null) continue;
174 + if (!(base > 10 && base < 200) || !(total > base * 1.25 && total < base * 2.1)) continue;
175 + const pick = (idx: number, lo: number, hi: number): number | null => {
176 + const c = valueCells[idx];
177 + if (!c) return null;
178 + const v = c.length > i ? c[i] : c.length === 1 ? c[0] : null;
179 + return v != null && v >= lo && v <= hi ? v : null;
180 + };
181 + const vac = pick(1, base * 0.1, base * 0.16);
182 + const ben = pick(2, 3, 14);
183 + const vacation = vac ?? Math.round(base * 0.13 * 100) / 100;
184 + const benefits = ben ?? 0;
185 + const employer = Math.max(0, Math.round((total - base - vacation - benefits - otherFixed) * 100) / 100);
186 + rows.push({ trade: currentTrade, classification: classes[i], baseWage: base, vacation, benefits, otherFixed: Math.round(otherFixed * 1000) / 1000, employer, total, exactBreakdown: vac != null && ben != null });
187 + }
188 + }
189 + return reclassify(rows);
190 +}
191 +
192 +/**
193 + * La mise en page du PDF décale parfois les libellés : on rétablit la
194 + * classification à partir des salaires. Le compagnon est le taux le plus élevé
195 + * du métier ; chaque apprenti reçoit la période dont le ratio conventionnel
196 + * (≈ 50 / 60 / 70 / 85 % du compagnon) est le plus proche, dans un régime à
197 + * 3, 4 ou 5 périodes selon le métier.
198 + */
199 +function reclassify(rows: ApchqRow[]): ApchqRow[] {
200 + const groups = new Map<string, ApchqRow[]>();
201 + for (const r of rows) groups.set(fold(r.trade), [...(groups.get(fold(r.trade)) ?? []), r]);
202 + const out: ApchqRow[] = [];
203 + for (const g of groups.values()) {
204 + const uniq = [...new Map(g.map((r) => [r.baseWage.toFixed(2), r])).values()].sort((a, b) => b.baseWage - a.baseWage);
205 + const comp = uniq[0];
206 + out.push({ ...comp, classification: "compagnon" });
207 + const appr = uniq.slice(1).sort((a, b) => a.baseWage - b.baseWage);
208 + if (!appr.length) continue;
209 + const parsedMax = Math.max(...g.map((r) => Number(r.classification.split("-")[1]) || 0));
210 + const periods = Math.max(appr.length, Math.min(5, parsedMax || 0), 3);
211 + const scheme = periods >= 5 ? [0.5, 0.6, 0.7, 0.85, 0.85] : periods === 4 ? [0.5, 0.6, 0.7, 0.85] : [0.6, 0.7, 0.85];
212 + const used = new Set<number>();
213 + for (const r of appr) {
214 + const ratio = r.baseWage / comp.baseWage;
215 + let best = 1;
216 + let bestD = Infinity;
217 + scheme.forEach((x, idx) => { const d = Math.abs(x - ratio); if (d < bestD && !used.has(idx + 1)) { bestD = d; best = idx + 1; } });
218 + used.add(best);
219 + out.push({ ...r, classification: `apprenti-${best}` });
220 + }
221 + }
222 + return out;
223 +}
224 +
225 +/* --------------------------------------------------------------- connecteur */
226 +
227 +export const apchqConnector: CostConnector = {
228 + config: APCHQ_CONFIG,
229 +
230 + async discover(opts) {
231 + const page = await scrape(APCHQ_LANDING, { links: true, timeoutMs: APCHQ_CONFIG.timeoutMs, retries: APCHQ_CONFIG.retries, rateLimitMs: APCHQ_CONFIG.rateLimitMs });
232 + return discoverFromLinks([...page.links, ...page.markdown.matchAll(/https:\/\/media\.apchq\.com\/[^\s)]+\.pdf/g).map((m) => m[0])], opts.maxPages ?? APCHQ_CONFIG.maxPages);
233 + },
234 +
235 + async fetch(doc, opts) {
236 + const r = await scrape(doc.url, { timeoutMs: APCHQ_CONFIG.timeoutMs, retries: APCHQ_CONFIG.retries, rateLimitMs: APCHQ_CONFIG.rateLimitMs, concurrency: APCHQ_CONFIG.concurrency });
237 + const hash = contentHash(r.markdown);
238 + return { url: doc.url, fetchedAt: new Date().toISOString(), markdown: r.markdown, metadata: r.metadata, statusCode: r.statusCode, contentHash: hash, unchanged: !opts.force && lastHashFor(doc.url, opts.db) === hash, meta: doc.meta };
239 + },
240 +
241 + async extract(raw, opts) {
242 + const effective = dateFromUrl(raw.url);
243 + const sector = sectorFromUrl(raw.url);
244 + if (!effective || !sector) return [];
245 + let rows = parseApchqMarkdown(raw.markdown);
246 + // lecture assistée (Claude Haiku) pour rétablir les libellés décalés par la conversion PDF → markdown ;
247 + // chaque ligne IA est vérifiée contre les couples (taux, total) du parseur déterministe.
248 + if (process.env.ANTHROPIC_API_KEY && !opts.onlyItems?.includes("no-ai")) {
249 + try {
250 + const pdf = await downloadPdf(raw.url, 60_000);
251 + const ai = await extractApchqWithClaude(pdf, rows, { url: raw.url, sector, effectiveDate: effective }, opts.db);
252 + if (ai.rows.length >= Math.min(20, rows.length * 0.6)) {
253 + opts.log?.({ connector: "apchq", url: raw.url, status: "ok", duration: ai.usage.latencyMs, observations: ai.rows.length, validationErrors: ai.dropped, timestamp: new Date().toISOString(), message: `lecture IA : ${ai.rows.length} lignes vérifiées, ${ai.dropped} rejetées (regex : ${rows.length})` });
254 + rows = ai.rows;
255 + } else {
256 + opts.log?.({ connector: "apchq", url: raw.url, status: "skipped", duration: ai.usage.latencyMs, observations: ai.rows.length, validationErrors: ai.dropped, timestamp: new Date().toISOString(), message: `lecture IA insuffisante (${ai.rows.length} lignes) — parseur déterministe conservé` });
257 + }
258 + } catch (e) {
259 + opts.log?.({ connector: "apchq", url: raw.url, status: "skipped", duration: 0, observations: 0, validationErrors: 0, timestamp: new Date().toISOString(), message: `lecture IA indisponible : ${(e as Error).message} — parseur déterministe conservé` });
260 + }
261 + }
262 + return rows.map((r) => ({
263 + externalId: `${sector}|${effective}|${fold(r.trade)}|${r.classification}`, sourceUrl: raw.url, retrievedAt: raw.fetchedAt, effectiveDate: effective,
264 + title: `${r.trade} — ${r.classification} (${sector})`, description: null, unit: "h", price: r.total, regularPrice: null, salePrice: null, currency: "CAD", location: "QC", category: "labour",
265 + payload: { ...r, sector },
266 + }));
267 + },
268 +
269 + async normalize(obs) {
270 + const out: CanonicalObservation[] = [];
271 + for (const o of obs) {
272 + const p = o.payload as unknown as ApchqRow & { sector: string };
273 + const { code, matched } = tradeCodeFor(p.trade);
274 + const base = {
275 + kind: "labour" as const, sector: p.sector, classification: p.classification, region: "QC", effectiveFrom: o.effectiveDate ?? "", baseWage: p.baseWage, vacationCost: p.vacation, benefitsCost: p.benefits,
276 + employerContributions: p.employer, otherContributions: p.otherFixed, totalEmployerCost: p.total, sourceUrl: o.sourceUrl, confidence: (p.exactBreakdown ? 95 : 88) - (matched ? 0 : 10), raw: o,
277 + };
278 + out.push({ ...base, tradeCode: code, tradeNameFr: p.trade });
279 + for (const d of DERIVED) if (d.from === code) out.push({ ...base, tradeCode: d.to, tradeNameFr: d.label, confidence: base.confidence - 5 });
280 + }
281 + return out;
282 + },
283 +
284 + validate(obs, opts): ValidationResult {
285 + return validateObservations(obs, { recentPrices: (c) => recentPricesFor(c, 120, opts.db), referencePrice: (c) => referencePriceFor(c, opts.db) });
286 + },
287 +};
288 +
289 +/** Filtre les liens de la page APCHQ : grilles « coût main-d'œuvre », temps simple, léger/lourd/IC. */
290 +export function discoverFromLinks(links: string[], maxPages: number): SourceDocument[] {
291 + const seen = new Set<string>();
292 + const docs: SourceDocument[] = [];
293 + for (const l of links) {
294 + const url = l.split("#")[0].trim();
295 + if (!/media\.apchq\.com\/.+cout-main-d-oeuvre.+\.pdf$/i.test(url)) continue;
296 + if (APCHQ_CONFIG.excludedPaths.some((re) => re.test(url))) continue;
297 + const d = dateFromUrl(url);
298 + const s = sectorFromUrl(url);
299 + if (!d || !s || seen.has(url)) continue;
300 + seen.add(url);
301 + docs.push({ url, title: `APCHQ ${s} ${d}`, meta: { effectiveDate: d, sector: s } });
302 + }
303 + return docs.sort((a, b) => String(a.meta?.effectiveDate).localeCompare(String(b.meta?.effectiveDate))).slice(0, maxPages);
304 +}
305 +
306 +/** Pour les tests : construit un RawDocument depuis un markdown de fixture. */
307 +export function rawDocFromMarkdown(url: string, markdown: string, meta?: RawDocument["meta"]): RawDocument {
308 + return { url, fetchedAt: new Date().toISOString(), markdown, metadata: {}, statusCode: 200, contentHash: contentHash(markdown), unchanged: false, meta };
309 +}
310 +
311 +export type { RawObservation, RunOptions };
added src/lib/cost/connectors/ccq.ts +62 −0
@@ -0,0 +1,62 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Connecteur CCQ — « Salaire et taux » (Commission de la construction du
4 + * Québec). Source de référence des taux réglementés. Au 2026-09-06 le site est
5 + * en incident de sécurité (services fermés jusqu'au 8 septembre) : la page
6 + * répond mais l'outil de taux n'est pas rendu. Le connecteur enregistre le
7 + * hash de la page (détection de changement) et renvoie l'état `unavailable`
8 + * tant qu'aucune grille tarifaire n'est détectable. Lorsque des tableaux
9 + * « métier | taux » apparaîtront, l'extraction générique ci-dessous les lira ;
10 + * la ventilation (secteur, cotisations) devra être confirmée avant activation.
11 + */
12 +import { contentHash, parseMoney, scrape } from "./firecrawl";
13 +import { lastHashFor, recentPricesFor, referencePriceFor } from "./store";
14 +import type { CanonicalObservation, ConnectorConfig, CostConnector, RawObservation, ValidationResult } from "./types";
15 +import { validateObservations } from "./validate";
16 +
17 +export const CCQ_PARSER_VERSION = "ccq-0.1";
18 +export const CCQ_URLS = ["https://www.ccq.org/fr-CA/avantages-sociaux/salaire-taux", "https://www.ccq.org/fr-CA/loi-r20/conventions-collectives"];
19 +
20 +export const CCQ_CONFIG: ConnectorConfig = {
21 + key: "ccq", name: "CCQ — Salaire et taux", domain: "ccq.org", maxPages: 4, allowedPaths: [/^https:\/\/www\.ccq\.org\/fr-CA\/(avantages-sociaux|loi-r20)\//], excludedPaths: [/Redirections\/SEL/],
22 + refreshDays: 7, timeoutMs: 90_000, retries: 1, rateLimitMs: 2000, concurrency: 1,
23 +};
24 +
25 +/** Vrai si la page contient un tableau de taux exploitable (≥ 5 lignes « libellé | montant $ »). */
26 +export function hasRateTable(md: string): boolean {
27 + const rows = md.split("\n").filter((l) => /^\|\s*[A-Za-zÉéèàçô][^|]{2,60}\|[^|]*\d+[,.]\d{2}\s*\$/.test(l));
28 + return rows.length >= 5;
29 +}
30 +
31 +export function detectIncident(md: string): string | null {
32 + const m = md.match(/incident de sécurité[^.]*\./i);
33 + return m ? m[0].trim() : null;
34 +}
35 +
36 +export const ccqConnector: CostConnector = {
37 + config: CCQ_CONFIG,
38 + async discover() {
39 + return CCQ_URLS.map((url) => ({ url, title: "CCQ" }));
40 + },
41 + async fetch(doc, opts) {
42 + const r = await scrape(doc.url, { timeoutMs: CCQ_CONFIG.timeoutMs, retries: CCQ_CONFIG.retries, rateLimitMs: CCQ_CONFIG.rateLimitMs, concurrency: 1 });
43 + const hash = contentHash(r.markdown);
44 + return { url: doc.url, fetchedAt: new Date().toISOString(), markdown: r.markdown, metadata: { ...r.metadata, incident: detectIncident(r.markdown), hasRateTable: hasRateTable(r.markdown) }, statusCode: r.statusCode, contentHash: hash, unchanged: !opts.force && lastHashFor(doc.url, opts.db) === hash };
45 + },
46 + async extract(raw) {
47 + if (!hasRateTable(raw.markdown)) return [];
48 + // extraction générique prudente : « | Métier | 60,24 $ | … » — conservée comme observation brute seulement
49 + return raw.markdown.split("\n").filter((l) => /^\|\s*[A-Za-zÉ][^|]{2,60}\|[^|]*\d+[,.]\d{2}\s*\$/.test(l)).map((l) => {
50 + const cells = l.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
51 + return { externalId: null, sourceUrl: raw.url, retrievedAt: raw.fetchedAt, effectiveDate: null, title: cells[0], description: null, unit: "h", price: parseMoney(cells[1]), regularPrice: null, salePrice: null, currency: "CAD", location: "QC", category: "labour", payload: { cells } } satisfies RawObservation;
52 + });
53 + },
54 + async normalize(): Promise<CanonicalObservation[]> {
55 + // Tant que la structure officielle (secteur, cotisations) n'est pas confirmée, aucune grille CCQ n'est écrite en base :
56 + // les observations brutes restent consultables dans cost_raw_observations (statut new).
57 + return [];
58 + },
59 + validate(obs, opts): ValidationResult {
60 + return validateObservations(obs, { recentPrices: (c) => recentPricesFor(c, 120, opts.db), referencePrice: (c) => referencePriceFor(c, opts.db) });
61 + },
62 +};
added src/lib/cost/connectors/connectors.test.ts +265 −0
@@ -0,0 +1,265 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Tests des connecteurs sur FIXTURES enregistrées (jamais le Web live).
3 +import fs from "fs";
4 +import path from "path";
5 +import { describe, expect, it } from "vitest";
6 +import { dateFromUrl, discoverFromLinks, parseApchqMarkdown, rawDocFromMarkdown, sectorFromUrl, tradeCodeFor, apchqConnector } from "./apchq";
7 +import { reconcile } from "./apchq-ai";
8 +import { pointsToObservations, seriesList, indexCode, statcanConnector } from "./statcan";
9 +import { detectIncident, hasRateTable } from "./ccq";
10 +import { bmrAdapter, canacAdapter, detectPackQty, detectPromotion, patrickMorinAdapter, unitTextToSourceUnit, makeRetailConnector } from "./retail";
11 +import { deterministicMatch, scoreCandidate } from "./matching";
12 +import { validateObservations } from "./validate";
13 +import { contentHash, parseMoney } from "./firecrawl";
14 +import { normalizeBenchmarkRows, parseCsv } from "./imports";
15 +import { openCostDbAt } from "../db";
16 +import { ensureConnectorSchema, saveRawObservation, lastHashFor } from "./store";
17 +import type { CanonicalPriceObservation } from "./types";
18 +
19 +const FX = path.join(__dirname, "__fixtures__");
20 +const readMd = (f: string) => fs.readFileSync(path.join(FX, f), "utf8");
21 +const readJson = (f: string) => JSON.parse(fs.readFileSync(path.join(FX, f), "utf8")) as { data: { markdown: string; metadata: Record<string, unknown>; links?: string[] } };
22 +
23 +const noCtx = { recentPrices: () => [] as number[], referencePrice: () => null };
24 +
25 +/* ------------------------------------------------------------------ APCHQ */
26 +
27 +describe("APCHQ", () => {
28 + it("lit la date et le secteur depuis l'URL (coquille « arvil » tolérée)", () => {
29 + expect(dateFromUrl("https://media.apchq.com/x/cout-main-d-oeuvre-residentiel-leger-26-arvil-2026-temps-simple.pdf")).toBe("2026-04-26");
30 + expect(dateFromUrl("https://media.apchq.com/x/cout-main-d-oeuvre-residentiel-lourd-1er-janvier-2024.pdf")).toBe("2024-01-01");
31 + expect(dateFromUrl("https://media.apchq.com/x/cout-main-d-oeuvre-commercial-28-decembre-2025.pdf")).toBe("2025-12-28");
32 + expect(sectorFromUrl("…/cout-main-d-oeuvre-commercial-26-avril-2026.pdf")).toBe("ic");
33 + expect(sectorFromUrl("…/cout-main-d-oeuvre-residentiel-lourd-x.pdf")).toBe("residentiel_lourd");
34 + });
35 + it("découvre les grilles temps simple léger/lourd/IC et exclut temps demi, exemples de paie, chantiers isolés", () => {
36 + const j = readJson("apchq-landing.json");
37 + const docs = discoverFromLinks(j.data.links ?? [], 100);
38 + expect(docs.length).toBeGreaterThanOrEqual(12);
39 + expect(docs.every((d) => !/temps-demi|temps-double|exemple|isoles|baie-james/i.test(d.url))).toBe(true);
40 + expect(docs.some((d) => d.url.includes("residentiel-leger-26-arvil-2026-temps-simple"))).toBe(true);
41 + expect(docs.some((d) => d.url.includes("commercial-26-avril-2026"))).toBe(true);
42 + expect(docs.map((d) => String(d.meta?.effectiveDate))).toEqual([...docs.map((d) => String(d.meta?.effectiveDate))].sort());
43 + });
44 + it("parse une grille propre (2025) : compagnons plausibles, total > taux, apprentis ordonnés", () => {
45 + const rows = parseApchqMarkdown(readJson("apchq-leger-2025-04-27.json").data.markdown);
46 + expect(rows.length).toBeGreaterThan(100);
47 + const charp = rows.filter((r) => /charpentier/i.test(r.trade));
48 + const comp = charp.find((r) => r.classification === "compagnon")!;
49 + expect(comp.baseWage).toBeCloseTo(40.16, 2);
50 + expect(comp.total).toBeCloseTo(62.54, 2);
51 + expect(comp.vacation).toBeCloseTo(comp.baseWage * 0.13, 1);
52 + for (const r of rows) { expect(r.total).toBeGreaterThan(r.baseWage * 1.25); expect(r.total).toBeLessThan(r.baseWage * 2.1); }
53 + const appr = charp.filter((r) => r.classification.startsWith("apprenti")).sort((a, b) => a.classification.localeCompare(b.classification));
54 + for (let i = 1; i < appr.length; i++) expect(appr[i].baseWage).toBeGreaterThan(appr[i - 1].baseWage);
55 + });
56 + it("parse une grille aux rangées fusionnées/décalées (2026) : lignes aplaties et compagnon = taux le plus élevé", () => {
57 + const rows = parseApchqMarkdown(readMd("apchq-residentiel-leger-2026-04-26.md"));
58 + const rev = rows.find((r) => /poseur de rev/i.test(r.trade) && r.classification === "compagnon")!; // rangée « valeurs puis libellé » dans une seule cellule
59 + expect(rev.baseWage).toBeCloseTo(41.71, 2);
60 + expect(rev.total).toBeCloseTo(64.87, 2);
61 + const briq = rows.find((r) => /briqueteur/i.test(r.trade) && r.classification === "compagnon")!; // cellule « 46,59 $ 27,96 $ » = compagnon + apprenti 1
62 + expect(briq.baseWage).toBeCloseTo(46.59, 2);
63 + expect(briq.total).toBeCloseTo(72.11, 2);
64 + expect(rows.find((r) => /briqueteur/i.test(r.trade) && r.classification === "apprenti-1")!.baseWage).toBeCloseTo(27.96, 2);
65 + expect(rows.find((r) => /manœuvre \(journalier\)/i.test(r.trade))!.total).toBeCloseTo(58.55, 2);
66 + });
67 + it("associe les libellés APCHQ aux codes de métier (œ, abréviations)", () => {
68 + expect(tradeCodeFor("Charpentier-menuisier").code).toBe("charpentier");
69 + expect(tradeCodeFor("Tuyauteur").code).toBe("plombier");
70 + expect(tradeCodeFor("Manœuvre (journalier)").code).toBe("manoeuvre");
71 + expect(tradeCodeFor("Poseur d&#x27;armature du béton".replace("&#x27;", "'")).code).toBe("ferrailleur");
72 + expect(tradeCodeFor("Poseur de rev. Souples").code).toBe("poseur_revetements");
73 + expect(tradeCodeFor("Op. de p. méc. - Cl. A").code).toBe("operateur");
74 + expect(tradeCodeFor("Métier imaginaire").matched).toBe(false);
75 + });
76 + it("normalise en observations de main-d'œuvre valides (secteur, dérivés, validation)", async () => {
77 + const raw = rawDocFromMarkdown("https://media.apchq.com/x/cout-main-d-oeuvre-residentiel-leger-27-avril-2025-temps-simple.pdf", readJson("apchq-leger-2025-04-27.json").data.markdown);
78 + const obs = await apchqConnector.extract(raw, { onlyItems: ["no-ai"] });
79 + expect(obs.length).toBeGreaterThan(100);
80 + const canon = await apchqConnector.normalize(obs, raw, {});
81 + const lab = canon.filter((c) => c.kind === "labour");
82 + expect(lab.some((l) => l.kind === "labour" && l.tradeCode === "poseur_systemes")).toBe(true); // spécialité dérivée du charpentier
83 + expect(lab.every((l) => l.kind === "labour" && l.sector === "residentiel_leger" && l.effectiveFrom === "2025-04-27")).toBe(true);
84 + const v = validateObservations(canon, noCtx);
85 + expect(v.rejected.length).toBe(0);
86 + });
87 + it("réconciliation IA : accepte les lignes dont (taux, total) existent dans le parseur ou dont la somme boucle ; rejette le reste", () => {
88 + const regex = parseApchqMarkdown(readJson("apchq-leger-2025-04-27.json").data.markdown);
89 + const ok = { trade: "Charpentier-menuisier", classification: "compagnon", base_wage: 40.16, vacation: 5.22, benefits: 7.89, total: 62.54, contributions_sum: 9.27 };
90 + const sumOnly = { trade: "Métier X", classification: "compagnon", base_wage: 50, vacation: 6.5, benefits: 8, total: 74.5, contributions_sum: 10 };
91 + const bad = { trade: "Inventé", classification: "compagnon", base_wage: 44.44, vacation: 5.78, benefits: 8, total: 70.1, contributions_sum: 3 };
92 + const r = reconcile([ok, sumOnly, bad], regex, 1.153);
93 + expect(r.rows.map((x) => x.trade)).toEqual(["Charpentier-menuisier", "Métier X"]);
94 + expect(r.dropped).toBe(1);
95 + });
96 +});
97 +
98 +/* ---------------------------------------------------------------- StatCan */
99 +
100 +describe("StatCan", () => {
101 + it("construit les 40 séries attendues avec les codes lus par le catalogue", () => {
102 + const s = seriesList();
103 + expect(s.length).toBe(16 + 22);
104 + expect(s.map(indexCode)).toContain("statcan:18100289:10:1:1");
105 + expect(s.map(indexCode)).toContain("statcan:18100289:10:1:8");
106 + });
107 + it("calcule les variations trimestrielle et annuelle", () => {
108 + const pts = [{ refPer: "2025-01-01", value: 100 }, { refPer: "2025-04-01", value: 102 }, { refPer: "2025-07-01", value: 104 }, { refPer: "2025-10-01", value: 105 }, { refPer: "2026-01-01", value: 110 }];
109 + const obs = pointsToObservations({ geo: 10, type: 1, division: 1 }, pts, "2026-09-06T00:00:00Z");
110 + expect(obs[0].pctQoq).toBeNull();
111 + expect(obs[1].pctQoq).toBeCloseTo(2, 1);
112 + expect(obs[4].pctYoy).toBeCloseTo(10, 1);
113 + expect(obs[4].geography).toBe("Montréal");
114 + expect(validateObservations(obs, noCtx).rejected.length).toBe(0);
115 + });
116 + it("normalise une réponse WDS enregistrée", async () => {
117 + const wds = [{ status: "SUCCESS", object: { coordinate: "10.1.1.0.0.0.0.0.0.0", vectorDataPoint: [{ refPer: "2026-01-01", value: 112.4 }, { refPer: "2026-04-01", value: 114.8 }] } }, { status: "FAILED", object: { coordinate: "9.9.9.0.0.0.0.0.0.0", vectorDataPoint: [] } }];
118 + const md = JSON.stringify(wds.map((it) => ({ c: it.object.coordinate, s: it.status, p: it.object.vectorDataPoint.map((p) => [p.refPer, p.value]) })));
119 + const raw = { url: "wds", fetchedAt: "2026-09-06T00:00:00Z", markdown: md, metadata: {}, statusCode: 200, contentHash: contentHash(md), unchanged: false };
120 + const obs = await statcanConnector.extract(raw, {});
121 + expect(obs.length).toBe(1);
122 + const canon = await statcanConnector.normalize(obs, raw, {});
123 + expect(canon.length).toBe(2);
124 + expect(canon[1]).toMatchObject({ kind: "index", indexCode: "statcan:18100289:10:1:1", period: "2026-04-01", value: 114.8 });
125 + });
126 +});
127 +
128 +/* -------------------------------------------------------------------- CCQ */
129 +
130 +describe("CCQ", () => {
131 + it("détecte l'incident et l'absence de grille (source indisponible, jamais de valeur inventée)", () => {
132 + const md = "Salaire et taux\n\n**Incident de sécurité informatique en cours – Les services en ligne demeurent fermés. La reprise est prévue le 8 septembre.**\n| Date | Modifications |\n| --- | --- |\n| À compter du 26 avril 2026 | Taux |";
133 + expect(detectIncident(md)).toMatch(/incident de sécurité/i);
134 + expect(hasRateTable(md)).toBe(false);
135 + });
136 +});
137 +
138 +/* ------------------------------------------------------------- détaillants */
139 +
140 +describe("détaillants", () => {
141 + it("Canac : prix et unité depuis la fiche (métadonnée product:price:amount)", () => {
142 + const j = readJson("canac-2x6x8.json");
143 + const p = canacAdapter.parse({ markdown: j.data.markdown, metadata: j.data.metadata, statusCode: 200, links: [] });
144 + expect(p.price).toBeCloseTo(5.98, 2);
145 + expect(p.unitText?.toLowerCase()).toContain("chaque");
146 + expect(p.title).toMatch(/2 po x 6 po x 8 pi/);
147 + expect(p.currency).toBe("CAD");
148 + });
149 + it("BMR : prix depuis les métadonnées malgré les variantes « à partir de »", () => {
150 + const j = readJson("bmr-2x6x8.json");
151 + const p = bmrAdapter.parse({ markdown: j.data.markdown, metadata: j.data.metadata, statusCode: 200, links: [] });
152 + expect(p.price).toBeCloseTo(6.98, 2);
153 + });
154 + it("Patrick Morin : prix depuis les métadonnées", () => {
155 + const j = readJson("pm-2x6x8.json");
156 + const p = patrickMorinAdapter.parse({ markdown: j.data.markdown, metadata: j.data.metadata, statusCode: 200, links: [] });
157 + expect(p.price).toBeCloseTo(5.98, 2);
158 + });
159 + it("promotion : prix régulier préféré, promo non interprétée comme régulier", () => {
160 + expect(detectPromotion("Prix régulier : 7,49 $ Spécial 5,98 $", 5.98)).toEqual({ regular: 7.49, sale: 5.98 });
161 + expect(detectPromotion("Économisez 20 % — 5,98 $ / Chaque", 5.98)).toEqual({ regular: null, sale: 5.98 });
162 + expect(detectPromotion("Rabais de la semaine sur la peinture !\n\n\n\n\n\n\n\n\n\n" + " ".repeat(300) + "# Tuyau\n5,98 $ / Chaque", 5.98)).toEqual({ regular: 5.98, sale: null });
163 + expect(detectPromotion("Prix 5,98 $ / Chaque", 5.98)).toEqual({ regular: 5.98, sale: null });
164 + });
165 + it("emballage : couverture pi², rouleau 100 pi, boîte de 50 lb, paquet de 12", () => {
166 + expect(detectPackQty("Isolant R24 — couvre 39,8 pi²", "pi2")).toBeCloseTo(39.8, 1);
167 + expect(detectPackQty("Panneau de gypse 4 pi x 8 pi", "pi2")).toBe(32);
168 + expect(detectPackQty("Tuyau PEX 1/2 po 100 pi", "pi_lin")).toBe(100);
169 + expect(detectPackQty("Clous 3 1/4 po 50 lb", "lb")).toBe(50);
170 + expect(detectPackQty("Attaches de brique pqt/100", "unit")).toBe(100);
171 + expect(unitTextToSourceUnit("Chaque", "pi2", 32, "feuille")).toBe("feuille");
172 + expect(unitTextToSourceUnit("Chaque", "unit", null, null)).toBe("chaque");
173 + });
174 + it("page sans prix → aucune observation ; conversion par emballage → prix canonique ; unité inconnue → rejet", async () => {
175 + const dbPath = path.join(FX, ".test-retail.db");
176 + fs.rmSync(dbPath, { force: true });
177 + const d = openCostDbAt(dbPath);
178 + ensureConnectorSchema(d);
179 + const c = makeRetailConnector(canacAdapter);
180 + const j = readJson("canac-2x6x8.json");
181 + const noPrice = { url: "https://www.canac.ca/canac/fr/2/p/x/1", fetchedAt: "2026-09-06T10:00:00Z", markdown: "# Produit\nDisponibilité", metadata: { title: "Produit" }, statusCode: 200, contentHash: "h1", unchanged: false, meta: { itemCode: "LUM-2X6-8", packQty: null, packUnit: null } };
182 + expect((await c.extract(noPrice, { db: d })).length).toBe(0);
183 + const gyp = { url: "https://www.canac.ca/canac/fr/2/p/gypse/2", fetchedAt: "2026-09-06T10:00:00Z", markdown: "# Panneau de gypse 1/2 po 4 pi x 8 pi\n17,60 $ / Chaque", metadata: { "product:price:amount": "17,60 $", "product:price:currency": "CAD", ogTitle: "Panneau de gypse 1/2 po 4 pi x 8 pi - Canac" }, statusCode: 200, contentHash: "h2", unchanged: false, meta: { itemCode: "GYP-1/2-4X8", packQty: 32, packUnit: "feuille" } };
184 + const obs = await c.extract(gyp, { db: d });
185 + expect(obs.length).toBe(1);
186 + const canon = await c.normalize(obs, gyp, { db: d });
187 + const p = canon[0] as CanonicalPriceObservation;
188 + expect(p.totalCost).toBeCloseTo(0.55, 3);
189 + expect(p.conversionFactor).toBeCloseTo(1 / 32, 6);
190 + expect(p.sourceUnit).toBe("feuille");
191 + expect(c.validate(canon, { db: d }).rejected.length).toBe(0);
192 + const lum = { url: j.data.metadata.url as string, fetchedAt: "2026-09-06T10:00:00Z", markdown: j.data.markdown, metadata: j.data.metadata, statusCode: 200, contentHash: "h3", unchanged: false, meta: { itemCode: "LUM-2X6-8", packQty: null, packUnit: null } };
193 + const lc = await c.normalize(await c.extract(lum, { db: d }), lum, { db: d });
194 + expect((lc[0] as CanonicalPriceObservation).totalCost).toBeCloseTo(5.98, 2);
195 + // unité inconnue (aucun emballage pour un pi²) → rejet explicite
196 + const weird = { ...gyp, url: "https://www.canac.ca/canac/fr/2/p/gypse/3", metadata: { ...gyp.metadata, ogTitle: "Gypse" }, markdown: "# Gypse\n17,60 $ / Palette", meta: { itemCode: "GYP-1/2-4X8", packQty: null, packUnit: null } };
197 + const wc = await c.normalize(await c.extract(weird, { db: d }), weird, { db: d });
198 + const v = c.validate(wc, { db: d });
199 + expect(v.accepted.length + v.rejected.length).toBe(1);
200 + expect(v.rejected.length).toBe(1);
201 + d.close();
202 + fs.rmSync(dbPath, { force: true });
203 + });
204 + it("erreur 403 / contenu vide → aucune observation, statut d'erreur propre", async () => {
205 + const c = makeRetailConnector(bmrAdapter);
206 + const forbidden = { url: "https://www.bmr.ca/fr/x.html", fetchedAt: "2026-09-06T10:00:00Z", markdown: "Just a moment...", metadata: {}, statusCode: 403, contentHash: "h", unchanged: false, meta: { itemCode: "LUM-2X6-8" } };
207 + expect((await c.extract(forbidden, {})).length).toBe(0);
208 + const empty = { ...forbidden, statusCode: 200, markdown: "" };
209 + expect((await c.extract(empty, {})).length).toBe(0);
210 + });
211 +});
212 +
213 +/* -------------------------------------------------------------- matching */
214 +
215 +describe("matching déterministe", () => {
216 + it("exige les dimensions et privilégie le recouvrement des mots", () => {
217 + expect(scoreCandidate("épinette de construction 2 po x 6 po x 8 pi sec", "Épinette de construction 2 po x 6 po x 8 pi sec - Canac")).toBeGreaterThan(0.85);
218 + expect(scoreCandidate("épinette de construction 2 po x 6 po x 8 pi sec", "Épinette de construction 2 po x 4 po x 8 pi sec")).toBeLessThan(0.4);
219 + const dec = deterministicMatch("panneau de gypse 1/2 po 4 x 8", [{ url: "a", title: "Panneau de gypse 1/2 po 4 pi x 8 pi", description: "" }, { url: "b", title: "Panneau de gypse 5/8 po 4 x 8", description: "" }, { url: "c", title: "Ensemble de vis à gypse", description: "" }]);
220 + expect(dec.candidate?.url).toBe("a");
221 + expect(dec.confidence).toBeGreaterThan(0.8);
222 + });
223 +});
224 +
225 +/* ------------------------------------------------------------- validation */
226 +
227 +describe("validation", () => {
228 + const base = (over: Partial<CanonicalPriceObservation>): CanonicalPriceObservation => ({ kind: "price", itemCode: "LUM-2X6-8", sourceUrl: "u", observationDate: "2026-09-06", sourceUnit: "chaque", canonicalUnit: "unit", conversionFactor: 1, totalCost: 6, isRegularPrice: true, rawPrice: 6, rawRegularPrice: null, rawSalePrice: null, currency: "CAD", locationCode: "QC", title: "t", raw: { externalId: null, sourceUrl: "u", retrievedAt: "2026-09-06T00:00:00Z", effectiveDate: null, title: "t", description: null, unit: "chaque", price: 6, regularPrice: null, salePrice: null, currency: "CAD", location: null, category: null, payload: {} }, ...over });
229 + it("rejette prix ≤ 0, devise ≠ CAD, unité inconnue, hors bornes métier ; marque les aberrations sans effacer", () => {
230 + const ctx = { recentPrices: () => [5.9, 6.1, 6.0], referencePrice: () => 5.98 };
231 + const v = validateObservations([base({}), base({ rawPrice: 0, totalCost: 0 }), base({ currency: "USD" }), base({ sourceUnit: "palette" }), base({ totalCost: 40, rawPrice: 40 }), base({ totalCost: 15, rawPrice: 15 })], ctx);
232 + expect(v.accepted.length).toBe(1);
233 + expect(v.rejected.map((r) => r.reason)).toEqual(expect.arrayContaining([expect.stringMatching(/≤ 0/), expect.stringMatching(/devise/), expect.stringMatching(/unité inconnue/), expect.stringMatching(/bornes métier/), expect.stringMatching(/aberration/)]));
234 + });
235 +});
236 +
237 +/* ---------------------------------------------------------- hash / imports */
238 +
239 +describe("hash et imports", () => {
240 + it("un document inchangé est reconnu par son hash (statut unchanged)", () => {
241 + const dbPath = path.join(FX, ".test-hash.db");
242 + fs.rmSync(dbPath, { force: true });
243 + const d = openCostDbAt(dbPath);
244 + ensureConnectorSchema(d);
245 + const md = "| Charpentier | 60,24 $ | 89,14 $ |";
246 + const raw = rawDocFromMarkdown("https://media.apchq.com/x/y.pdf", md);
247 + expect(lastHashFor(raw.url, d)).toBeNull();
248 + saveRawObservation("apchq", { externalId: "x", sourceUrl: raw.url, retrievedAt: raw.fetchedAt, effectiveDate: "2026-04-26", title: "t", description: null, unit: "h", price: 89.14, regularPrice: null, salePrice: null, currency: "CAD", location: "QC", category: "labour", payload: {} }, raw, "accepted", null, "test", d);
249 + expect(lastHashFor(raw.url, d)).toBe(contentHash(md));
250 + expect(contentHash(md + "\n")).toBe(contentHash(md)); // espaces normalisés
251 + expect(contentHash("| Charpentier | 61,00 $ | 90,00 $ |")).not.toBe(contentHash(md));
252 + d.close();
253 + fs.rmSync(dbPath, { force: true });
254 + });
255 + it("parseMoney et import de benchmarks (CSV)", () => {
256 + expect(parseMoney("5,98 $")).toBeCloseTo(5.98);
257 + expect(parseMoney("$1 234,50")).toBeCloseTo(1234.5);
258 + expect(parseMoney("abc")).toBeNull();
259 + const csv = "source,building_type,market,unit,low,high,year,notes\nAltus Group — Canadian Cost Guide 2026,single_family,Montréal,$/pi2,255,395,2026,custom\nAltus,castle,Montréal,$/pi2,1,2,2026,\n";
260 + expect(parseCsv(csv).length).toBe(2);
261 + const r = normalizeBenchmarkRows(csv);
262 + expect(r.rows.length).toBe(1);
263 + expect(r.errors.length).toBe(1);
264 + });
265 +});
added src/lib/cost/connectors/firecrawl.ts +139 −0
@@ -0,0 +1,139 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Client Firecrawl v2 (scrape / search / map) : timeouts, réessais, rythme par
4 + * domaine, hachage du contenu. Clé côté serveur uniquement (FIRECRAWL_API_KEY).
5 + * Aucun appel n'est fait depuis le chemin utilisateur.
6 + */
7 +import { createHash } from "crypto";
8 +
9 +const API = "https://api.firecrawl.dev/v2";
10 +
11 +export interface ScrapeResult {
12 + markdown: string;
13 + metadata: Record<string, unknown>;
14 + statusCode: number | null;
15 + links: string[];
16 +}
17 +
18 +export interface SearchHit {
19 + url: string;
20 + title: string;
21 + description: string;
22 +}
23 +
24 +export class FirecrawlError extends Error {
25 + constructor(message: string, public readonly status: number | null, public readonly url: string) {
26 + super(message);
27 + this.name = "FirecrawlError";
28 + }
29 +}
30 +
31 +/* --------------------------------------------------------- rythme par domaine */
32 +
33 +const lastCall = new Map<string, number>();
34 +const inflight = new Map<string, number>();
35 +
36 +function domainOf(url: string): string {
37 + try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return "unknown"; }
38 +}
39 +
40 +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
41 +
42 +/** Attend le créneau libre pour un domaine (rythme minimal + concurrence). */
43 +export async function throttle(domain: string, minGapMs: number, concurrency: number): Promise<() => void> {
44 + for (;;) {
45 + const now = Date.now();
46 + const last = lastCall.get(domain) ?? 0;
47 + const busy = inflight.get(domain) ?? 0;
48 + if (busy < concurrency && now - last >= minGapMs) {
49 + lastCall.set(domain, now);
50 + inflight.set(domain, busy + 1);
51 + return () => inflight.set(domain, Math.max(0, (inflight.get(domain) ?? 1) - 1));
52 + }
53 + await sleep(Math.max(50, minGapMs - (now - last)));
54 + }
55 +}
56 +
57 +/* ------------------------------------------------------------------ client */
58 +
59 +export function firecrawlKey(): string {
60 + const k = process.env.FIRECRAWL_API_KEY;
61 + if (!k) throw new FirecrawlError("FIRECRAWL_API_KEY manquante (serveur)", null, API);
62 + return k;
63 +}
64 +
65 +async function post<T>(path: string, body: unknown, timeoutMs: number, retries: number): Promise<T> {
66 + let lastErr: unknown = null;
67 + for (let attempt = 0; attempt <= retries; attempt++) {
68 + const ctrl = new AbortController();
69 + const t = setTimeout(() => ctrl.abort(), timeoutMs);
70 + try {
71 + const res = await fetch(`${API}${path}`, { method: "POST", headers: { Authorization: `Bearer ${firecrawlKey()}`, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: ctrl.signal });
72 + const json = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string; data?: T };
73 + if (res.status === 429 || res.status >= 500) throw new FirecrawlError(`HTTP ${res.status} ${json.error ?? ""}`.trim(), res.status, path);
74 + if (!res.ok || json.success === false) throw new FirecrawlError(json.error ?? `HTTP ${res.status}`, res.status, path);
75 + return json.data as T;
76 + } catch (e) {
77 + lastErr = e;
78 + const status = e instanceof FirecrawlError ? e.status : null;
79 + const retriable = status == null || status === 429 || status >= 500;
80 + if (!retriable || attempt === retries) break;
81 + await sleep(1500 * 2 ** attempt);
82 + } finally {
83 + clearTimeout(t);
84 + }
85 + }
86 + if (lastErr instanceof Error && lastErr.name === "AbortError") throw new FirecrawlError("délai dépassé", null, path);
87 + throw lastErr instanceof Error ? lastErr : new FirecrawlError(String(lastErr), null, path);
88 +}
89 +
90 +export interface ScrapeOptions { timeoutMs?: number; retries?: number; rateLimitMs?: number; concurrency?: number; links?: boolean }
91 +
92 +export async function scrape(url: string, o: ScrapeOptions = {}): Promise<ScrapeResult> {
93 + const release = await throttle(domainOf(url), o.rateLimitMs ?? 1000, o.concurrency ?? 2);
94 + try {
95 + const formats = o.links ? ["markdown", "links"] : ["markdown"];
96 + const data = await post<{ markdown?: string; metadata?: Record<string, unknown>; links?: string[] }>("/scrape", { url, formats, onlyMainContent: true }, o.timeoutMs ?? 90_000, o.retries ?? 2);
97 + const md = data?.metadata ?? {};
98 + const sc = typeof md.statusCode === "number" ? md.statusCode : null;
99 + return { markdown: data?.markdown ?? "", metadata: md, statusCode: sc, links: data?.links ?? [] };
100 + } finally {
101 + release();
102 + }
103 +}
104 +
105 +export async function search(query: string, limit = 5, o: ScrapeOptions = {}): Promise<SearchHit[]> {
106 + const release = await throttle("api.firecrawl.dev/search", o.rateLimitMs ?? 1000, o.concurrency ?? 2);
107 + try {
108 + const data = await post<{ web?: { url: string; title?: string; description?: string }[] }>("/search", { query, limit }, o.timeoutMs ?? 60_000, o.retries ?? 2);
109 + return (data?.web ?? []).map((h) => ({ url: h.url, title: h.title ?? "", description: h.description ?? "" }));
110 + } finally {
111 + release();
112 + }
113 +}
114 +
115 +export async function mapSite(url: string, searchTerm?: string, limit = 50, o: ScrapeOptions = {}): Promise<string[]> {
116 + const release = await throttle(domainOf(url), o.rateLimitMs ?? 1000, o.concurrency ?? 1);
117 + try {
118 + const data = await post<{ links?: { url: string }[] | string[] }>("/map", { url, search: searchTerm, limit }, o.timeoutMs ?? 60_000, o.retries ?? 1);
119 + return (data?.links ?? []).map((l) => (typeof l === "string" ? l : l.url));
120 + } finally {
121 + release();
122 + }
123 +}
124 +
125 +/** Hachage stable du contenu utile (markdown normalisé) pour la détection de changement. */
126 +export function contentHash(markdown: string, extra: Record<string, unknown> = {}): string {
127 + const norm = markdown.replace(/\s+/g, " ").trim();
128 + return createHash("sha256").update(norm).update(JSON.stringify(extra)).digest("hex");
129 +}
130 +
131 +/** Parse « 5,98 $ », « $6.98 », « 1 234,50 » → nombre ; null si impossible. */
132 +export function parseMoney(s: unknown): number | null {
133 + if (typeof s === "number") return Number.isFinite(s) ? s : null;
134 + if (typeof s !== "string") return null;
135 + const m = s.replace(/ /g, " ").match(/-?\d[\d\s]*(?:[.,]\d+)?/);
136 + if (!m) return null;
137 + const v = Number(m[0].replace(/\s/g, "").replace(",", "."));
138 + return Number.isFinite(v) ? v : null;
139 +}
added src/lib/cost/connectors/imports.ts +95 −0
@@ -0,0 +1,95 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Imports manuels (aucun scraping) :
4 + * - Altus Group — Canadian Cost Guide : benchmarks $/pi² saisis après
5 + * téléchargement légal du guide (formulaire) → cost_benchmarks ;
6 + * - RSMeans / Gordian : données sous licence, import CSV/XLSX→CSV autorisé
7 + * seulement → cost_item_prices (price_kind « reference », source rsmeans),
8 + * jamais redistribuées telles quelles (seul le prix canonique agrégé sert).
9 + */
10 +import type Database from "better-sqlite3";
11 +import { getCostDb, sourceIdByKey } from "../db";
12 +import { convertPrice } from "../units";
13 +import type { CanonicalUnit } from "../types";
14 +
15 +export const BENCHMARK_TYPES = ["single_family", "townhouse", "plex", "condo"] as const;
16 +export type BenchmarkType = (typeof BENCHMARK_TYPES)[number];
17 +
18 +export interface BenchmarkImportRow { source: string; building_type: BenchmarkType; market: string; unit?: string; low: number; high: number; year: number; notes?: string | null }
19 +
20 +/** CSV simple (séparateur , ou ;) → objets ; guillemets gérés. */
21 +export function parseCsv(text: string): Record<string, string>[] {
22 + const lines = text.replace(/\r/g, "").split("\n").filter((l) => l.trim());
23 + if (lines.length < 2) return [];
24 + const sep = lines[0].includes(";") && !lines[0].includes(",") ? ";" : ",";
25 + const split = (l: string): string[] => {
26 + const out: string[] = [];
27 + let cur = "";
28 + let q = false;
29 + for (const ch of l) {
30 + if (ch === '"') q = !q;
31 + else if (ch === sep && !q) { out.push(cur.trim()); cur = ""; }
32 + else cur += ch;
33 + }
34 + out.push(cur.trim());
35 + return out;
36 + };
37 + const head = split(lines[0]).map((h) => h.toLowerCase().replace(/\s+/g, "_"));
38 + return lines.slice(1).map((l) => Object.fromEntries(split(l).map((v, i) => [head[i] ?? `c${i}`, v])));
39 +}
40 +
41 +export function normalizeBenchmarkRows(input: unknown): { rows: BenchmarkImportRow[]; errors: string[] } {
42 + const raw: Record<string, unknown>[] = typeof input === "string" ? parseCsv(input) : Array.isArray(input) ? (input as Record<string, unknown>[]) : [];
43 + const rows: BenchmarkImportRow[] = [];
44 + const errors: string[] = [];
45 + raw.forEach((r, i) => {
46 + const bt = String(r.building_type ?? "").trim() as BenchmarkType;
47 + const low = Number(String(r.low ?? "").replace(",", "."));
48 + const high = Number(String(r.high ?? "").replace(",", "."));
49 + const year = Number(r.year);
50 + if (!BENCHMARK_TYPES.includes(bt)) { errors.push(`ligne ${i + 1} : building_type invalide « ${bt} »`); return; }
51 + if (!(low > 0 && high >= low && high < 5000)) { errors.push(`ligne ${i + 1} : bornes invalides`); return; }
52 + if (!(year >= 2015 && year <= 2035)) { errors.push(`ligne ${i + 1} : année invalide`); return; }
53 + if (!r.source || !r.market) { errors.push(`ligne ${i + 1} : source/market requis`); return; }
54 + rows.push({ source: String(r.source).trim(), building_type: bt, market: String(r.market).trim(), unit: String(r.unit ?? "$/pi2"), low, high, year, notes: r.notes ? String(r.notes) : null });
55 + });
56 + return { rows, errors };
57 +}
58 +
59 +export function importBenchmarks(input: unknown, d: Database.Database = getCostDb()): { imported: number; errors: string[] } {
60 + const { rows, errors } = normalizeBenchmarkRows(input);
61 + const altusId = sourceIdByKey("altus", d);
62 + const rsId = sourceIdByKey("rsmeans", d);
63 + const st = d.prepare(`INSERT INTO cost_benchmarks(source,source_id,building_type,market,unit,low,high,midpoint,year,notes,is_demo) VALUES(?,?,?,?,?,?,?,?,?,?,0)
64 + ON CONFLICT(source,building_type,market,year) DO UPDATE SET low=excluded.low, high=excluded.high, midpoint=excluded.midpoint, unit=excluded.unit, notes=excluded.notes, source_id=excluded.source_id`);
65 + const tx = d.transaction(() => { for (const r of rows) st.run(r.source, /altus/i.test(r.source) ? altusId : /rsmeans|gordian/i.test(r.source) ? rsId : null, r.building_type, r.market, r.unit, r.low, r.high, (r.low + r.high) / 2, r.year, r.notes ?? null); });
66 + tx();
67 + if (rows.length) d.prepare("UPDATE cost_sources SET last_successful_sync=datetime('now') WHERE key IN ('altus') AND EXISTS (SELECT 1 FROM cost_benchmarks WHERE source_id=cost_sources.id)").run();
68 + return { imported: rows.length, errors };
69 +}
70 +
71 +/** Import RSMeans/Gordian (CSV : item_code, price, unit, [pack_qty], [date], [notes]) → prix de référence licenciés. */
72 +export function importRsmeans(input: unknown, d: Database.Database = getCostDb()): { imported: number; errors: string[] } {
73 + const raw: Record<string, unknown>[] = typeof input === "string" ? parseCsv(input) : Array.isArray(input) ? (input as Record<string, unknown>[]) : [];
74 + const sid = sourceIdByKey("rsmeans", d);
75 + const errors: string[] = [];
76 + let imported = 0;
77 + const tx = d.transaction(() => {
78 + raw.forEach((r, i) => {
79 + const code = String(r.item_code ?? r.canonical_code ?? "").trim();
80 + const item = d.prepare("SELECT id, unit FROM cost_items WHERE canonical_code=?").get(code) as { id: number; unit: CanonicalUnit } | undefined;
81 + if (!item) { errors.push(`ligne ${i + 1} : article inconnu ${code}`); return; }
82 + const price = Number(String(r.price ?? "").replace(",", "."));
83 + if (!(price > 0)) { errors.push(`ligne ${i + 1} : prix invalide`); return; }
84 + const date = String(r.date ?? new Date().toISOString().slice(0, 10));
85 + try {
86 + const conv = convertPrice(price, String(r.unit ?? item.unit), item.unit, r.pack_qty ? Number(r.pack_qty) : null);
87 + d.prepare(`INSERT INTO cost_item_prices(cost_item_id,source_id,location_code,observation_date,price_kind,material_cost,total_cost,currency,source_unit,conversion_factor,is_regular_price,confidence_score,source_url,verified) VALUES(?,?,'QC',?,'reference',?,?,'CAD',?,?,1,70,?,1)`)
88 + .run(item.id, sid, date, conv.price, conv.price, conv.conversion.sourceUnit, conv.conversion.factor, r.notes ? String(r.notes) : null);
89 + imported++;
90 + } catch (e) { errors.push(`ligne ${i + 1} : ${(e as Error).message}`); }
91 + });
92 + });
93 + tx();
94 + return { imported, errors };
95 +}
added src/lib/cost/connectors/matching.ts +132 −0
@@ -0,0 +1,132 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Correspondance produit détaillant → article canonique.
4 + * produit brut → normalisation déterministe → (si ambigu) classification par
5 + * Claude Haiku 4.5 → confiance → product_mappings → cost_item_sources
6 + * L'IA ne fixe JAMAIS un prix : elle choisit au plus un candidat parmi les
7 + * résultats de recherche et explique son choix.
8 + */
9 +import Anthropic from "@anthropic-ai/sdk";
10 +import type Database from "better-sqlite3";
11 +import { getCostDb, sourceIdByKey } from "../db";
12 +import type { ConnectorKey } from "./types";
13 +
14 +export const MATCH_AUTO_APPROVE = 0.8;
15 +const MODEL = "claude-haiku-4-5";
16 +
17 +export const fold = (s: string) => s.replace(/œ/g, "oe").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[×x]/g, " x ").replace(/["'’]/g, " ").replace(/[^a-z0-9./\s]/g, " ").replace(/\s+/g, " ").trim();
18 +
19 +/** Jetons : dimensions (2, 6, 8, 1/2), unités (po, pi, pi², mm) et mots ≥ 3 lettres (sans mots vides). */
20 +export function tokens(s: string): { nums: string[]; words: string[] } {
21 + const f = fold(s).replace(/(\d)\s*po\b/g, "$1 po").replace(/(\d)\s*pi\b/g, "$1 pi");
22 + const nums = [...f.matchAll(/\b\d+(?:[./]\d+)?\b/g)].map((m) => m[0]);
23 + const STOP = new Set(["de", "des", "du", "la", "le", "les", "et", "en", "pour", "avec", "sans", "par", "sur", "au", "aux", "un", "une", "the", "and", "of", "for", "with", "sec", "kd"]);
24 + const words = f.split(" ").filter((w) => /^[a-z]{3,}$/.test(w) && !STOP.has(w));
25 + return { nums, words };
26 +}
27 +
28 +/** Score déterministe 0-1 : tous les nombres de la requête doivent apparaître ; puis recouvrement des mots. */
29 +export function scoreCandidate(query: string, title: string): number {
30 + const q = tokens(query);
31 + const t = tokens(title);
32 + if (!t.words.length && !t.nums.length) return 0;
33 + const numOk = q.nums.every((n) => t.nums.includes(n));
34 + if (q.nums.length && !numOk) return Math.min(0.35, wordOverlap(q.words, t.words) * 0.5);
35 + const wo = wordOverlap(q.words, t.words);
36 + return Math.min(1, (q.nums.length ? 0.45 : 0) + wo * (q.nums.length ? 0.55 : 1));
37 +}
38 +
39 +function wordOverlap(a: string[], b: string[]): number {
40 + if (!a.length) return 1;
41 + const bs = new Set(b.flatMap((w) => [w, w.replace(/s$/, ""), w.slice(0, 5)]));
42 + const hit = a.filter((w) => bs.has(w) || bs.has(w.replace(/s$/, "")) || bs.has(w.slice(0, 5))).length;
43 + return hit / a.length;
44 +}
45 +
46 +export interface Candidate { url: string; title: string; description: string }
47 +export interface MatchDecision { candidate: Candidate | null; confidence: number; method: "deterministic" | "ai" | "none"; rationale: string }
48 +
49 +/** Détaillant : mots qui trahissent un produit non comparable (accessoire, lot, service). */
50 +const BAD_WORDS = /\b(ensemble de|kit de|lot de|échantillon|echantillon|service|installation|location|garantie|carte cadeau|pièce de rechange)\b/i;
51 +
52 +export function deterministicMatch(query: string, candidates: Candidate[]): MatchDecision {
53 + const scored = candidates.filter((c) => !BAD_WORDS.test(c.title)).map((c) => ({ c, s: scoreCandidate(query, c.title) })).sort((a, b) => b.s - a.s);
54 + if (!scored.length) return { candidate: null, confidence: 0, method: "none", rationale: "aucun candidat" };
55 + const best = scored[0];
56 + const margin = scored.length > 1 ? best.s - scored[1].s : best.s;
57 + if (best.s >= 0.85 && margin >= 0.1) return { candidate: best.c, confidence: Math.min(0.97, best.s), method: "deterministic", rationale: `score ${best.s.toFixed(2)}, marge ${margin.toFixed(2)}` };
58 + if (best.s < 0.35) return { candidate: null, confidence: best.s, method: "none", rationale: `meilleur score ${best.s.toFixed(2)} trop faible` };
59 + return { candidate: best.c, confidence: best.s, method: "deterministic", rationale: `ambigu : score ${best.s.toFixed(2)}, marge ${margin.toFixed(2)}` };
60 +}
61 +
62 +const TOOL: Anthropic.Tool = {
63 + name: "choose_product",
64 + description: "Choisit le produit du détaillant qui correspond à l'article canonique demandé, ou aucun.",
65 + strict: true,
66 + input_schema: {
67 + type: "object", additionalProperties: false,
68 + properties: {
69 + index: { type: ["integer", "null"], description: "Indice (0-based) du candidat retenu, null si aucun ne correspond au même produit économique (mêmes dimensions, même matériau, même usage)" },
70 + confidence: { type: "number", description: "0-1" },
71 + pack_qty: { type: ["number", "null"], description: "Contenu de l'emballage dans l'unité canonique si le titre l'indique (ex. feuille 4x8 → 32 pi²), sinon null" },
72 + rationale: { type: "string" },
73 + },
74 + required: ["index", "confidence", "pack_qty", "rationale"],
75 + },
76 +};
77 +
78 +export async function aiMatch(itemLabel: string, unit: string, query: string, candidates: Candidate[], d: Database.Database = getCostDb()): Promise<MatchDecision & { packQty: number | null }> {
79 + const client = new Anthropic();
80 + const t0 = Date.now();
81 + const list = candidates.map((c, i) => `${i}. ${c.title}\n ${c.description.slice(0, 160)}\n ${c.url}`).join("\n");
82 + const res = await client.messages.create({
83 + model: MODEL, max_tokens: 400,
84 + system: "Tu classes des produits de quincaillerie. Tu ne donnes jamais de prix. Tu choisis le candidat qui est le MÊME produit économique que l'article canonique (dimensions nominales identiques, matériau et usage équivalents ; la marque n'importe pas). En cas de doute réel, index = null.",
85 + tools: [TOOL], tool_choice: { type: "tool", name: "choose_product" },
86 + messages: [{ role: "user", content: `Article canonique : « ${itemLabel} » (unité ${unit}). Requête : « ${query} ».\nCandidats :\n${list}` }],
87 + });
88 + try {
89 + d.prepare("INSERT INTO ai_usage(analysis_id,purpose,model,input_images,input_tokens,output_tokens,cache_read_tokens,estimated_cost_usd,latency_ms) VALUES(NULL,'product_matching',?,0,?,?,?,?,?)")
90 + .run(MODEL, res.usage.input_tokens, res.usage.output_tokens, res.usage.cache_read_input_tokens ?? 0, (res.usage.input_tokens * 1 + res.usage.output_tokens * 5) / 1e6, Date.now() - t0);
91 + } catch { /* test */ }
92 + const tu = res.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
93 + const inp = (tu?.input ?? {}) as { index?: number | null; confidence?: number; pack_qty?: number | null; rationale?: string };
94 + const idx = typeof inp.index === "number" && inp.index >= 0 && inp.index < candidates.length ? inp.index : null;
95 + return { candidate: idx == null ? null : candidates[idx], confidence: Math.max(0, Math.min(1, Number(inp.confidence) || 0)), method: "ai", rationale: `IA : ${inp.rationale ?? ""}`.slice(0, 500), packQty: typeof inp.pack_qty === "number" && inp.pack_qty > 0 ? inp.pack_qty : null };
96 +}
97 +
98 +/* ------------------------------------------------------------ persistance */
99 +
100 +export function upsertMapping(connector: ConnectorKey, itemCode: string, dec: MatchDecision, packQty: number | null, packUnit: string | null, d: Database.Database = getCostDb()): "approved" | "pending" | "rejected" {
101 + const sid = sourceIdByKey(connector, d);
102 + if (!dec.candidate) return "rejected";
103 + const status = dec.confidence >= MATCH_AUTO_APPROVE ? "auto_approved" : "pending";
104 + d.prepare(`INSERT INTO product_mappings(source_id,product_url,product_title,proposed_item_code,pack_qty,pack_unit,method,confidence,rationale,status)
105 + VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(source_id,product_url) DO UPDATE SET product_title=excluded.product_title, proposed_item_code=excluded.proposed_item_code, pack_qty=excluded.pack_qty, pack_unit=excluded.pack_unit,
106 + method=excluded.method, confidence=excluded.confidence, rationale=excluded.rationale, status=CASE WHEN product_mappings.status IN ('approved','rejected') THEN product_mappings.status ELSE excluded.status END`)
107 + .run(sid, dec.candidate.url, dec.candidate.title, itemCode, packQty, packUnit, dec.method, dec.confidence, dec.rationale, status);
108 + const row = d.prepare("SELECT status FROM product_mappings WHERE source_id=? AND product_url=?").get(sid, dec.candidate.url) as { status: string };
109 + if (row.status === "approved" || row.status === "auto_approved") {
110 + const iid = (d.prepare("SELECT id FROM cost_items WHERE canonical_code=?").get(itemCode) as { id: number } | undefined)?.id;
111 + if (iid) d.prepare(`INSERT INTO cost_item_sources(cost_item_id,source_id,product_url,product_title,pack_qty,pack_unit,match_method,match_confidence,approved,active) VALUES(?,?,?,?,?,?,?,?,1,1)
112 + ON CONFLICT(source_id,product_url) DO UPDATE SET cost_item_id=excluded.cost_item_id, product_title=excluded.product_title, pack_qty=COALESCE(excluded.pack_qty, cost_item_sources.pack_qty), pack_unit=COALESCE(excluded.pack_unit, cost_item_sources.pack_unit), match_confidence=excluded.match_confidence, approved=1, active=1`)
113 + .run(iid, sid, dec.candidate.url, dec.candidate.title, packQty, packUnit, dec.method, dec.confidence);
114 + return "approved";
115 + }
116 + return row.status === "rejected" ? "rejected" : "pending";
117 +}
118 +
119 +/** Approuve/rejette un mapping (admin) et synchronise cost_item_sources. */
120 +export function reviewMapping(id: number, decision: "approved" | "rejected", d: Database.Database = getCostDb()): boolean {
121 + const m = d.prepare("SELECT * FROM product_mappings WHERE id=?").get(id) as { id: number; source_id: number; product_url: string; product_title: string | null; proposed_item_code: string | null; pack_qty: number | null; pack_unit: string | null; method: string; confidence: number } | undefined;
122 + if (!m) return false;
123 + d.prepare("UPDATE product_mappings SET status=?, reviewed_at=datetime('now') WHERE id=?").run(decision, id);
124 + if (decision === "approved" && m.proposed_item_code) {
125 + const iid = (d.prepare("SELECT id FROM cost_items WHERE canonical_code=?").get(m.proposed_item_code) as { id: number } | undefined)?.id;
126 + if (iid) d.prepare(`INSERT INTO cost_item_sources(cost_item_id,source_id,product_url,product_title,pack_qty,pack_unit,match_method,match_confidence,approved,active) VALUES(?,?,?,?,?,?,?,?,1,1)
127 + ON CONFLICT(source_id,product_url) DO UPDATE SET cost_item_id=excluded.cost_item_id, approved=1, active=1`).run(iid, m.source_id, m.product_url, m.product_title, m.pack_qty, m.pack_unit, m.method, m.confidence);
128 + } else {
129 + d.prepare("UPDATE cost_item_sources SET active=0, approved=0 WHERE source_id=? AND product_url=?").run(m.source_id, m.product_url);
130 + }
131 + return true;
132 +}
added src/lib/cost/connectors/registry.ts +27 −0
@@ -0,0 +1,27 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/** Registre des connecteurs et fréquences de synchronisation. */
3 +import { apchqConnector } from "./apchq";
4 +import { ccqConnector } from "./ccq";
5 +import { statcanConnector } from "./statcan";
6 +import { bmrAdapter, canacAdapter, homeDepotAdapter, makeRetailConnector, patrickMorinAdapter, ronaAdapter } from "./retail";
7 +import type { ConnectorKey, CostConnector } from "./types";
8 +
9 +export const CONNECTORS: Record<Exclude<ConnectorKey, "altus" | "rsmeans">, CostConnector> = {
10 + apchq: apchqConnector,
11 + ccq: ccqConnector,
12 + statcan: statcanConnector,
13 + canac: makeRetailConnector(canacAdapter),
14 + bmr: makeRetailConnector(bmrAdapter),
15 + patrickmorin: makeRetailConnector(patrickMorinAdapter),
16 + rona: makeRetailConnector(ronaAdapter),
17 + homedepot: makeRetailConnector(homeDepotAdapter),
18 +};
19 +
20 +export type RunnableKey = keyof typeof CONNECTORS;
21 +export const RUNNABLE_KEYS = Object.keys(CONNECTORS) as RunnableKey[];
22 +/** Import manuel seulement (aucun run automatique). */
23 +export const IMPORT_ONLY: ConnectorKey[] = ["altus", "rsmeans"];
24 +
25 +export function isRunnable(k: string): k is RunnableKey {
26 + return k in CONNECTORS;
27 +}
added src/lib/cost/connectors/retail.ts +297 −0
@@ -0,0 +1,297 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Connecteur détaillant générique (Canac, BMR, Patrick Morin, RONA, Home Depot) :
4 + * articles avec `retail_query` → URL produit (cost_item_sources ou découverte
5 + * par recherche + matching) → fiche (Firecrawl) → prix affiché (métadonnées
6 + * product:price:amount ou texte) → conversion vers l'unité canonique →
7 + * observation historisée (une par jour, par article et par source).
8 + * Prix régulier préféré au prix promotionnel ; jamais d'écrasement.
9 + */
10 +import type Database from "better-sqlite3";
11 +import { getCostDb, sourceIdByKey } from "../db";
12 +import { convertPrice, isKnownUnit } from "../units";
13 +import type { CanonicalUnit } from "../types";
14 +import { contentHash, parseMoney, scrape, search, type ScrapeResult } from "./firecrawl";
15 +import { aiMatch, deterministicMatch, upsertMapping, type Candidate } from "./matching";
16 +import { lastHashFor, recentPricesFor, referencePriceFor } from "./store";
17 +import type { CanonicalObservation, ConnectorConfig, ConnectorKey, CostConnector, RawDocument, RawObservation, RunOptions, SourceDocument, ValidationResult } from "./types";
18 +import { validateObservations } from "./validate";
19 +
20 +export const RETAIL_PARSER_VERSION = "retail-1.0";
21 +
22 +export interface RetailAdapter {
23 + key: ConnectorKey;
24 + name: string;
25 + domain: string;
26 + searchPrefix: string; // ex. « canac.ca »
27 + isProductUrl(url: string): boolean;
28 + /** extrait prix affiché / régulier / promo / unité affichée depuis la fiche */
29 + parse(r: ScrapeResult): { title: string; price: number | null; regularPrice: number | null; salePrice: number | null; unitText: string | null; currency: string };
30 + active: boolean;
31 + config: Partial<ConnectorConfig>;
32 +}
33 +
34 +interface ItemRow { id: number; canonical_code: string; name_fr: string; unit: CanonicalUnit; retail_query: string; retail_pack_qty: number | null; retail_pack_unit: string | null; reference_price: number | null }
35 +
36 +/* -------------------------------------------------------- aides d'analyse */
37 +
38 +/** Détecte un prix promotionnel dans le markdown (prix barré, « Spécial », « Économisez », « Prix régulier »). */
39 +export function detectPromotion(mdFull: string, displayed: number | null): { regular: number | null; sale: number | null } {
40 + // seule la zone du produit principal compte (avant les produits associés / la description)
41 + const cut = mdFull.replace(/\u00a0/g, " ").search(/utile avec cet achat|produits (similaires|associ[ée]s|compl[ée]mentaires)|vous aimerez|articles connexes|\n#{1,3}\s*description/i);
42 + const md = cut > 0 ? mdFull.slice(0, cut) : mdFull.replace(/\u00a0/g, " ").slice(0, 3000);
43 + const regMatch = md.match(/(?:prix\s+r[ée]g(?:ulier)?\.?|r[ée]g\.?|was|prix\s+courant)\s*:?\s*(\d[\d\s]*(?:[.,]\d+)?)\s*\$/i) ?? md.match(/~~\s*(\d[\d\s]*(?:[.,]\d+)?)\s*\$\s*~~/);
44 + const regular = regMatch ? parseMoney(regMatch[1]) : null;
45 + // le mot « promo » ne compte que près du prix affiché (bandeaux de site exclus)
46 + const shown = displayed != null ? md.search(new RegExp(`${displayed.toFixed(2).replace(".", "[.,]")}\\s*\\$`)) : -1;
47 + const near = shown >= 0 ? md.slice(Math.max(0, shown - 250), shown + 250) : "";
48 + const promo = /(sp[ée]cial|[ée]conomisez|rabais|en solde|liquidation|prix\s+promo)/i.test(near);
49 + if (regular != null && displayed != null && regular > displayed) return { regular, sale: displayed };
50 + if (promo && displayed != null && regular == null) return { regular: null, sale: displayed };
51 + return { regular: displayed, sale: null };
52 +}
53 +
54 +/** Contenu d'emballage lu sur la fiche, dans l'unité canonique (ex. « couvre 39,8 pi² », « 100 pi », « pqt/50 »). */
55 +export function detectPackQty(text: string, unit: CanonicalUnit): number | null {
56 + const t = text.replace(/ /g, " ");
57 + const num = (s: string) => parseMoney(s);
58 + if (unit === "pi2") {
59 + const m = t.match(/(?:couvr\w*|couverture|superficie)\s*(?:de|:)?\s*(?:jusqu.à\s*)?(\d[\d\s]*(?:[.,]\d+)?)\s*(?:pi|ft)\s*(?:²|2|carr)/i) ?? t.match(/(\d[\d\s]*(?:[.,]\d+)?)\s*(?:pi|ft)\s*(?:²|2)\s*(?:par|\/)\s*(?:paquet|pqt|boîte|boite|rouleau|sac)/i);
60 + if (m) return num(m[1]);
61 + const sheet = t.match(/\b(\d+)\s*(?:pi|')\s*x\s*(\d+)\s*(?:pi|')\b/i);
62 + if (sheet) { const a = Number(sheet[1]) * Number(sheet[2]); if (a >= 8 && a <= 200) return a; }
63 + return null;
64 + }
65 + if (unit === "pi_lin") {
66 + const m = t.match(/(\d[\d\s]*(?:[.,]\d+)?)\s*(?:pi|ft)\b(?!\s*(?:²|2))/i);
67 + if (m) { const v = num(m[1]); if (v != null && v >= 4 && v <= 1000) return v; }
68 + const mm = t.match(/(\d+(?:[.,]\d+)?)\s*m\b/i);
69 + if (mm) { const v = num(mm[1]); if (v != null && v >= 5 && v <= 500) return Math.round(v * 3.28084 * 10) / 10; }
70 + return null;
71 + }
72 + if (unit === "lb") {
73 + const m = t.match(/(\d+(?:[.,]\d+)?)\s*lb\b/i);
74 + if (m) return num(m[1]);
75 + const kg = t.match(/(\d+(?:[.,]\d+)?)\s*kg\b/i);
76 + if (kg) { const v = num(kg[1]); return v != null ? Math.round(v * 2.20462 * 100) / 100 : null; }
77 + return null;
78 + }
79 + if (unit === "unit") {
80 + const m = t.match(/(?:pqt|paquet|bo[iî]te|ens\.?|ensemble|lot)\s*(?:de|\/|:)?\s*(\d{2,4})\b/i) ?? t.match(/\b(\d{2,4})\s*(?:unités|pièces|mcx|pcs|un\.)\b/i);
81 + if (m) { const v = Number(m[1]); return v >= 2 && v <= 5000 ? v : null; }
82 + }
83 + return null;
84 +}
85 +
86 +export function unitTextToSourceUnit(unitText: string | null, unit: CanonicalUnit, packQty: number | null, defaultPackUnit: string | null): string {
87 + const t = (unitText ?? "").toLowerCase();
88 + if (/pi\s*²|pi2|sq\.?\s*ft/.test(t) && unit === "pi2") return "pi2";
89 + if (/pi\s*lin|lin\.?\s*ft|\/\s*pi\b/.test(t) && unit === "pi_lin") return "pi_lin";
90 + if (/m\s*²|m2/.test(t)) return "m2";
91 + if (/m\s*³|m3/.test(t)) return "m3";
92 + if (/\blb\b/.test(t)) return "lb";
93 + if (/\bkg\b/.test(t)) return "kg";
94 + if (/feuille|sheet/.test(t)) return "feuille";
95 + if (/paquet|pqt|pack|ballot|bundle/.test(t)) return "paquet";
96 + if (/rouleau|roll/.test(t)) return "rouleau";
97 + if (/bo[iî]te|box/.test(t)) return "boite";
98 + if (/sac|bag/.test(t)) return "sac";
99 + if (/seau|pail/.test(t)) return "seau";
100 + if (/chaque|each|\/\s*ch\b|unité/.test(t)) return packQty && unit !== "unit" ? (defaultPackUnit && isKnownUnit(defaultPackUnit) ? defaultPackUnit : "paquet") : "chaque";
101 + if (t.trim()) return t.trim(); // unité affichée non reconnue → rejet explicite en validation
102 + if (packQty && unit !== "unit") return defaultPackUnit && isKnownUnit(defaultPackUnit) ? defaultPackUnit : "paquet";
103 + return unit === "unit" ? "chaque" : unit;
104 +}
105 +
106 +/* ------------------------------------------------------------ connecteur */
107 +
108 +/** Normalise les espaces insécables des fiches avant analyse. */
109 +function clean(r: ScrapeResult): ScrapeResult {
110 + return { ...r, markdown: r.markdown.replace(/\u00a0/g, " ") };
111 +}
112 +
113 +export function makeRetailConnector(a: RetailAdapter): CostConnector {
114 + const config: ConnectorConfig = {
115 + key: a.key, name: a.name, domain: a.domain, maxPages: 400, allowedPaths: [new RegExp(`^https?://(www\\.)?${a.domain.replace(/\./g, "\\.")}/`)], excludedPaths: [/\/(recherche|search|panier|cart|checkout|compte|account|sign-in|login)/i],
116 + refreshDays: 1, timeoutMs: 90_000, retries: 2, rateLimitMs: 1200, concurrency: 2, ...a.config,
117 + };
118 +
119 + const itemsWithQuery = (d: Database.Database, only?: string[]): ItemRow[] => {
120 + const rows = d.prepare("SELECT id, canonical_code, name_fr, unit, retail_query, retail_pack_qty, retail_pack_unit, reference_price FROM cost_items WHERE active=1 AND retail_query IS NOT NULL ORDER BY canonical_code").all() as ItemRow[];
121 + return only?.length ? rows.filter((r) => only.includes(r.canonical_code)) : rows;
122 + };
123 +
124 + return {
125 + config,
126 +
127 + /** Une URL produit par article : source connue (cost_item_sources) sinon découverte par recherche + matching. */
128 + async discover(opts) {
129 + const d = opts.db ?? getCostDb();
130 + const sid = sourceIdByKey(a.key, d);
131 + const docs: SourceDocument[] = [];
132 + const known = d.prepare("SELECT s.product_url, s.pack_qty, s.pack_unit, i.canonical_code FROM cost_item_sources s JOIN cost_items i ON i.id=s.cost_item_id WHERE s.source_id=? AND s.active=1 AND s.approved=1").all(sid) as { product_url: string; pack_qty: number | null; pack_unit: string | null; canonical_code: string }[];
133 + const knownByItem = new Map(known.map((k) => [k.canonical_code, k]));
134 + const rejected = new Set((d.prepare("SELECT proposed_item_code FROM product_mappings WHERE source_id=? AND status IN ('pending','rejected') AND created_at >= datetime('now','-7 days')").all(sid) as { proposed_item_code: string | null }[]).map((r) => r.proposed_item_code));
135 + let discoveries = 0;
136 + const max = opts.maxPages ?? config.maxPages;
137 + for (const it of itemsWithQuery(d, opts.onlyItems)) {
138 + if (docs.length >= max) break;
139 + const k = knownByItem.get(it.canonical_code);
140 + if (k) { docs.push({ url: k.product_url, title: it.name_fr, meta: { itemCode: it.canonical_code, packQty: k.pack_qty ?? it.retail_pack_qty, packUnit: k.pack_unit ?? it.retail_pack_unit } }); continue; }
141 + if (rejected.has(it.canonical_code) && !opts.force) { opts.log?.({ connector: a.key, url: null, status: "skipped", duration: 0, observations: 0, validationErrors: 0, timestamp: new Date().toISOString(), message: `${it.canonical_code} : mapping en attente/rejeté récemment` }); continue; }
142 + const t0 = Date.now();
143 + try {
144 + const hits = await search(`${a.searchPrefix} ${it.retail_query}`, 6, { timeoutMs: 60_000, retries: 1, rateLimitMs: config.rateLimitMs });
145 + const cands: Candidate[] = hits.filter((h) => a.isProductUrl(h.url) && !config.excludedPaths.some((re) => re.test(h.url))).map((h) => ({ url: h.url.split("?")[0], title: h.title, description: h.description }));
146 + let dec = deterministicMatch(it.retail_query, cands);
147 + let packQty: number | null = null;
148 + if (dec.candidate && dec.confidence < 0.85 && cands.length && process.env.ANTHROPIC_API_KEY) {
149 + const ai = await aiMatch(it.name_fr, it.unit, it.retail_query, cands, d);
150 + dec = ai.candidate ? { ...ai, confidence: Math.max(ai.confidence, dec.confidence * 0.8) } : { candidate: null, confidence: 0, method: "ai", rationale: ai.rationale };
151 + packQty = ai.packQty;
152 + }
153 + const status = upsertMapping(a.key, it.canonical_code, dec, packQty ?? it.retail_pack_qty, it.retail_pack_unit, d);
154 + discoveries++;
155 + opts.log?.({ connector: a.key, url: dec.candidate?.url ?? null, status: status === "approved" ? "ok" : "skipped", duration: Date.now() - t0, observations: 0, validationErrors: 0, timestamp: new Date().toISOString(), message: `${it.canonical_code} → ${status} (${dec.method}, ${dec.confidence.toFixed(2)}) ${dec.rationale}` });
156 + if (status === "approved" && dec.candidate) docs.push({ url: dec.candidate.url, title: it.name_fr, meta: { itemCode: it.canonical_code, packQty: packQty ?? it.retail_pack_qty, packUnit: it.retail_pack_unit } });
157 + } catch (e) {
158 + opts.log?.({ connector: a.key, url: null, status: "error", duration: Date.now() - t0, observations: 0, validationErrors: 0, timestamp: new Date().toISOString(), message: `${it.canonical_code} : recherche échouée — ${(e as Error).message}` });
159 + }
160 + }
161 + void discoveries;
162 + return docs;
163 + },
164 +
165 + async fetch(doc, opts) {
166 + const r = clean(await scrape(doc.url, { timeoutMs: config.timeoutMs, retries: config.retries, rateLimitMs: config.rateLimitMs, concurrency: config.concurrency }));
167 + const parsed = a.parse(r);
168 + // le hash inclut le prix affiché : un changement de prix = document changé
169 + const hash = contentHash(r.markdown.slice(0, 6000), { price: parsed.price, regular: parsed.regularPrice });
170 + return { url: doc.url, fetchedAt: new Date().toISOString(), markdown: r.markdown, metadata: { ...r.metadata, parsed }, statusCode: r.statusCode, contentHash: hash, unchanged: !opts.force && lastHashFor(doc.url, opts.db) === hash, meta: doc.meta };
171 + },
172 +
173 + async extract(raw) {
174 + const p = (raw.metadata.parsed as ReturnType<RetailAdapter["parse"]>) ?? a.parse(clean({ markdown: raw.markdown, metadata: raw.metadata, statusCode: raw.statusCode, links: [] }));
175 + if (raw.statusCode != null && raw.statusCode >= 400) return [];
176 + if (p.price == null) return [];
177 + const promo = detectPromotion(raw.markdown, p.price);
178 + return [{
179 + externalId: String(raw.metadata["product:retailer_item_id"] ?? raw.metadata.sku ?? raw.url), sourceUrl: raw.url, retrievedAt: raw.fetchedAt, effectiveDate: raw.fetchedAt.slice(0, 10), title: p.title, description: null, unit: p.unitText,
180 + price: p.price, regularPrice: p.regularPrice ?? promo.regular, salePrice: p.salePrice ?? promo.sale, currency: p.currency, location: "QC", category: null,
181 + payload: { itemCode: raw.meta?.itemCode ?? null, packQty: raw.meta?.packQty ?? null, packUnit: raw.meta?.packUnit ?? null, statusCode: raw.statusCode, metaKeys: Object.keys(raw.metadata).slice(0, 30) },
182 + }];
183 + },
184 +
185 + async normalize(obs, raw, opts) {
186 + const d = opts.db ?? getCostDb();
187 + const out: CanonicalObservation[] = [];
188 + for (const o of obs) {
189 + const code = o.payload.itemCode as string | null;
190 + if (!code) continue;
191 + const item = d.prepare("SELECT unit, retail_pack_qty, retail_pack_unit FROM cost_items WHERE canonical_code=?").get(code) as { unit: CanonicalUnit; retail_pack_qty: number | null; retail_pack_unit: string | null } | undefined;
192 + if (!item) continue;
193 + const regular = o.regularPrice != null && o.regularPrice > 0 ? o.regularPrice : null;
194 + const price = regular ?? o.price;
195 + if (price == null || price <= 0) continue;
196 + // contenu d'emballage lu sur le TITRE seulement (le corps de page mêle des produits associés)
197 + const detected = detectPackQty(o.title, item.unit);
198 + const packQty = (o.payload.packQty as number | null) ?? detected ?? item.retail_pack_qty;
199 + const sourceUnit = unitTextToSourceUnit(o.unit, item.unit, packQty, (o.payload.packUnit as string | null) ?? item.retail_pack_unit);
200 + try {
201 + const conv = convertPrice(price, sourceUnit, item.unit, packQty);
202 + out.push({ kind: "price", itemCode: code, sourceUrl: o.sourceUrl, observationDate: o.retrievedAt.slice(0, 10), sourceUnit: conv.conversion.sourceUnit, canonicalUnit: item.unit, conversionFactor: conv.conversion.factor, totalCost: Math.round(conv.price * 10000) / 10000, isRegularPrice: regular != null || o.salePrice == null, rawPrice: price, rawRegularPrice: o.regularPrice, rawSalePrice: o.salePrice, currency: o.currency, locationCode: "QC", title: o.title, raw: o });
203 + } catch (e) {
204 + // unité inconnue → observation canonique invalide (rejet explicite en validation)
205 + out.push({ kind: "price", itemCode: code, sourceUrl: o.sourceUrl, observationDate: o.retrievedAt.slice(0, 10), sourceUnit: isKnownUnit(sourceUnit) ? sourceUnit : `?${sourceUnit}`, canonicalUnit: item.unit, conversionFactor: 0, totalCost: 0, isRegularPrice: true, rawPrice: price, rawRegularPrice: o.regularPrice, rawSalePrice: o.salePrice, currency: o.currency, locationCode: "QC", title: `${o.title} — ${(e as Error).message}`, raw: o });
206 + }
207 + }
208 + return out;
209 + },
210 +
211 + validate(obs, opts): ValidationResult {
212 + return validateObservations(obs, { recentPrices: (c) => recentPricesFor(c, 120, opts.db), referencePrice: (c) => referencePriceFor(c, opts.db) });
213 + },
214 + };
215 +}
216 +
217 +/* ------------------------------------------------------------ adaptateurs */
218 +
219 +function metaPrice(md: Record<string, unknown>): number | null {
220 + for (const k of ["product:price:amount", "og:price:amount", "product:price", "price", "priceAmount"]) {
221 + const v = md[k];
222 + const p = parseMoney(Array.isArray(v) ? v[0] : v);
223 + if (p != null && p > 0) return p;
224 + }
225 + return null;
226 +}
227 +
228 +function metaCurrency(md: Record<string, unknown>): string {
229 + const v = md["product:price:currency"] ?? md["og:price:currency"] ?? md.priceCurrency;
230 + const s = Array.isArray(v) ? v[0] : v;
231 + return typeof s === "string" && s ? s.toUpperCase() : "CAD";
232 +}
233 +
234 +function metaTitle(md: Record<string, unknown>, fallback: string): string {
235 + const t = md.ogTitle ?? md["og:title"] ?? md.title;
236 + return typeof t === "string" && t ? t.replace(/\u00a0/g, " ").replace(/\s*[|\-–]\s*(Canac|BMR|Patrick Morin|RONA|Home Depot Canada)\s*$/i, "").trim() : fallback;
237 +}
238 +
239 +export const canacAdapter: RetailAdapter = {
240 + key: "canac", name: "Canac", domain: "canac.ca", searchPrefix: "canac.ca", active: true, config: { rateLimitMs: 1200 },
241 + isProductUrl: (u) => /canac\.ca\/canac\/fr\/2\/p\//.test(u),
242 + parse(r0) {
243 + const r = clean(r0);
244 + const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$\s*\/\s*(?:Chaque|Feuille|Paquet|Boîte|Sac|Rouleau|Longueur|pi²|pi)/i)?.[1]);
245 + const unit = r.markdown.match(/\$\s*\/\s*([A-Za-zÀ-ÿ²]+(?:\s+de\s+\d+)?)/)?.[1] ?? null;
246 + const reg = r.markdown.match(/Prix\s+r[ée]gulier\s*:?\s*(\d[\d\s]*(?:[.,]\d{2}))\s*\$/i);
247 + return { title: metaTitle(r.metadata, r.markdown.match(/^#\s+(.+)$/m)?.[1] ?? ""), price, regularPrice: reg ? parseMoney(reg[1]) : null, salePrice: reg ? price : null, unitText: unit, currency: metaCurrency(r.metadata) };
248 + },
249 +};
250 +
251 +export const bmrAdapter: RetailAdapter = {
252 + key: "bmr", name: "BMR", domain: "bmr.ca", searchPrefix: "bmr.ca", active: true, config: { rateLimitMs: 1500 },
253 + isProductUrl: (u) => /bmr\.ca\/(fr|en)\/[^/?#]+\.html$/.test(u.split("?")[0]) && !/\/search\//.test(u),
254 + parse(r0) {
255 + const r = clean(r0);
256 + const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$/)?.[1]);
257 + const reg = r.markdown.match(/(?:Prix\s+r[ée]g(?:ulier)?\.?|Rég\.)\s*:?\s*(\d[\d\s]*(?:[.,]\d{2}))\s*\$/i);
258 + const unit = r.markdown.match(/\$\s*\/\s*([A-Za-zÀ-ÿ²]+)/)?.[1] ?? null;
259 + return { title: metaTitle(r.metadata, ""), price, regularPrice: reg ? parseMoney(reg[1]) : null, salePrice: reg ? price : null, unitText: unit, currency: metaCurrency(r.metadata) };
260 + },
261 +};
262 +
263 +export const patrickMorinAdapter: RetailAdapter = {
264 + key: "patrickmorin", name: "Patrick Morin", domain: "patrickmorin.com", searchPrefix: "patrickmorin.com", active: true, config: { rateLimitMs: 1500 },
265 + isProductUrl: (u) => /patrickmorin\.com\/(fr|en)\/[^/?#]+$/.test(u.split("?")[0]) && !/\/(fr|en)\/(recherche|search|catalogsearch)/.test(u),
266 + parse(r0) {
267 + const r = clean(r0);
268 + const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$/)?.[1]);
269 + const reg = r.markdown.match(/(?:Prix\s+r[ée]g(?:ulier)?\.?|Rég\.)\s*:?\s*(\d[\d\s]*(?:[.,]\d{2}))\s*\$/i);
270 + const unit = r.markdown.match(/\$\s*\/\s*([A-Za-zÀ-ÿ²]+)/)?.[1] ?? null;
271 + return { title: metaTitle(r.metadata, ""), price, regularPrice: reg ? parseMoney(reg[1]) : null, salePrice: reg ? price : null, unitText: unit, currency: metaCurrency(r.metadata) };
272 + },
273 +};
274 +
275 +export const ronaAdapter: RetailAdapter = {
276 + key: "rona", name: "RONA", domain: "rona.ca", searchPrefix: "rona.ca", active: false, config: { rateLimitMs: 2500, concurrency: 1, retries: 1 },
277 + isProductUrl: (u) => /rona\.ca\/(fr|en)\/(produit|product)\//.test(u),
278 + parse(r0) {
279 + const r = clean(r0);
280 + const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$/)?.[1]);
281 + const reg = r.markdown.match(/(?:Prix\s+r[ée]g(?:ulier)?\.?|Was)\s*:?\s*\$?\s*(\d[\d\s]*(?:[.,]\d{2}))/i);
282 + return { title: metaTitle(r.metadata, ""), price, regularPrice: reg ? parseMoney(reg[1]) : null, salePrice: reg ? price : null, unitText: null, currency: metaCurrency(r.metadata) };
283 + },
284 +};
285 +
286 +export const homeDepotAdapter: RetailAdapter = {
287 + key: "homedepot", name: "Home Depot Canada", domain: "homedepot.ca", searchPrefix: "homedepot.ca", active: false, config: { rateLimitMs: 2500, concurrency: 1, retries: 1 },
288 + isProductUrl: (u) => /homedepot\.ca\/(produit|product)\//.test(u),
289 + parse(r0) {
290 + const r = clean(r0);
291 + // prix dépendant du magasin sélectionné (défaut hors Québec) : conservé pour l'avenir, connecteur inactif
292 + const price = metaPrice(r.metadata) ?? parseMoney(r.markdown.match(/(\d[\d\s]*(?:[.,]\d{2}))\s*\$/)?.[1]);
293 + return { title: metaTitle(r.metadata, ""), price, regularPrice: null, salePrice: null, unitText: null, currency: metaCurrency(r.metadata) };
294 + },
295 +};
296 +
297 +export type { RawDocument, RawObservation, RunOptions };
added src/lib/cost/connectors/run.ts +122 −0
@@ -0,0 +1,122 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Orchestrateur d'un connecteur : discover → fetch (hash) → extract → normalize
4 + * → validate → persistance, avec journal structuré et statistiques de run.
5 + * Utilisé par scripts/cost-sync.ts (planificateur) et l'API admin.
6 + */
7 +import type Database from "better-sqlite3";
8 +import { getCostDb } from "../db";
9 +import { CONNECTORS, type RunnableKey } from "./registry";
10 +import { closeLabourPeriods, ensureConnectorSchema, finishRun, isSourceActive, lastSuccessfulSync, pushLog, saveIndex, saveLabour, savePrice, saveRawObservation, saveUnchangedMarker, startRun } from "./store";
11 +import type { CanonicalObservation, RawDocument, RunLogEntry, RunOptions, RunStats } from "./types";
12 +import { APCHQ_PARSER_VERSION } from "./apchq";
13 +import { STATCAN_PARSER_VERSION } from "./statcan";
14 +import { CCQ_PARSER_VERSION } from "./ccq";
15 +import { RETAIL_PARSER_VERSION } from "./retail";
16 +
17 +const PARSER_VERSION: Record<RunnableKey, string> = { apchq: APCHQ_PARSER_VERSION, ccq: CCQ_PARSER_VERSION, statcan: STATCAN_PARSER_VERSION, canac: RETAIL_PARSER_VERSION, bmr: RETAIL_PARSER_VERSION, patrickmorin: RETAIL_PARSER_VERSION, rona: RETAIL_PARSER_VERSION, homedepot: RETAIL_PARSER_VERSION };
18 +
19 +/** Le connecteur est-il dû (fréquence nominale écoulée) ? */
20 +export function isDue(key: RunnableKey, d: Database.Database = getCostDb()): boolean {
21 + const last = lastSuccessfulSync(key, d);
22 + if (!last) return true;
23 + return Date.now() - new Date(last).getTime() >= CONNECTORS[key].config.refreshDays * 86400000 - 3600000;
24 +}
25 +
26 +export async function runConnector(key: RunnableKey, opts: RunOptions = {}): Promise<RunStats> {
27 + const d = opts.db ?? getCostDb();
28 + ensureConnectorSchema(d);
29 + const c = CONNECTORS[key];
30 + const run = startRun(key, d);
31 + const log = (e: RunLogEntry) => pushLog(run, e, opts.log);
32 + const ts = () => new Date().toISOString();
33 + if (!isSourceActive(key, d) && !opts.force) {
34 + run.status = "skipped";
35 + run.message = "source inactive (cost_sources.is_active = 0)";
36 + log({ connector: key, url: null, status: "skipped", duration: 0, observations: 0, validationErrors: 0, timestamp: ts(), message: run.message });
37 + return finishRun(run, d);
38 + }
39 + let docs: Awaited<ReturnType<typeof c.discover>> = [];
40 + try {
41 + docs = await c.discover({ ...opts, db: d, log });
42 + log({ connector: key, url: null, status: "ok", duration: 0, observations: docs.length, validationErrors: 0, timestamp: ts(), message: `${docs.length} document(s) découvert(s)` });
43 + } catch (e) {
44 + run.errors++;
45 + run.status = "error";
46 + run.message = `découverte échouée : ${(e as Error).message}`;
47 + log({ connector: key, url: null, status: "error", duration: 0, observations: 0, validationErrors: 0, timestamp: ts(), message: run.message });
48 + return finishRun(run, d);
49 + }
50 + const max = opts.maxPages ?? c.config.maxPages;
51 + const queue = docs.slice(0, max);
52 + const workers = Math.max(1, c.config.concurrency);
53 + let idx = 0;
54 + const worker = async () => {
55 + for (;;) {
56 + const i = idx++;
57 + if (i >= queue.length) return;
58 + const doc = queue[i];
59 + const t0 = Date.now();
60 + let raw: RawDocument | null = null;
61 + try {
62 + raw = await c.fetch(doc, { ...opts, db: d, log });
63 + run.pages++;
64 + if (raw.statusCode != null && raw.statusCode >= 400) {
65 + run.errors++;
66 + log({ connector: key, url: doc.url, status: "error", duration: Date.now() - t0, observations: 0, validationErrors: 0, timestamp: ts(), message: `HTTP ${raw.statusCode}` });
67 + continue;
68 + }
69 + if (raw.unchanged) {
70 + run.unchanged++;
71 + saveUnchangedMarker(key, raw, d);
72 + log({ connector: key, url: doc.url, status: "unchanged", duration: Date.now() - t0, observations: 0, validationErrors: 0, timestamp: ts() });
73 + continue;
74 + }
75 + const rawObs = await c.extract(raw, { ...opts, db: d, log });
76 + if (!rawObs.length) {
77 + const unavailable = key === "ccq" && !(raw.metadata.hasRateTable as boolean | undefined);
78 + if (unavailable) { run.status = "unavailable"; run.message = String(raw.metadata.incident ?? "aucune grille de taux détectable"); }
79 + else run.errors++;
80 + saveRawObservation(key, { externalId: null, sourceUrl: doc.url, retrievedAt: raw.fetchedAt, effectiveDate: null, title: String(raw.metadata.title ?? doc.title ?? ""), description: null, unit: null, price: null, regularPrice: null, salePrice: null, currency: "CAD", location: null, category: null, payload: { statusCode: raw.statusCode, unavailable } }, raw, "rejected", unavailable ? "source indisponible" : "aucune observation extraite (structure inattendue ou page sans prix)", PARSER_VERSION[key], d);
81 + log({ connector: key, url: doc.url, status: unavailable ? "unavailable" : "rejected", duration: Date.now() - t0, observations: 0, validationErrors: 1, timestamp: ts(), message: unavailable ? run.message : "aucune observation extraite" });
82 + continue;
83 + }
84 + const canon = await c.normalize(rawObs, raw, { ...opts, db: d, log });
85 + const { accepted, rejected } = c.validate(canon, { ...opts, db: d, log });
86 + run.observations += canon.length;
87 + const tx = d.transaction(() => {
88 + for (const o of accepted) persist(key, o, raw!, PARSER_VERSION[key], d);
89 + for (const r of rejected) saveRawObservation(key, rawOf(r.obs, raw!, doc.url), raw!, "rejected", r.reason, PARSER_VERSION[key], d);
90 + });
91 + tx();
92 + run.accepted += accepted.length;
93 + run.rejected += rejected.length;
94 + log({ connector: key, url: doc.url, status: accepted.length ? "ok" : "rejected", duration: Date.now() - t0, observations: accepted.length, validationErrors: rejected.length, timestamp: ts(), message: rejected.length ? rejected.slice(0, 3).map((r) => r.reason).join(" ; ") : undefined });
95 + } catch (e) {
96 + run.errors++;
97 + log({ connector: key, url: doc.url, status: "error", duration: Date.now() - t0, observations: 0, validationErrors: 0, timestamp: ts(), message: (e as Error).message });
98 + }
99 + }
100 + };
101 + await Promise.all(Array.from({ length: workers }, worker));
102 + if (key === "apchq" && run.accepted) closeLabourPeriods("apchq", d);
103 + if (run.status === "unavailable" && run.accepted === 0) return finishRun(run, d);
104 + return finishRun(run, d);
105 +}
106 +
107 +function rawOf(o: CanonicalObservation, raw: RawDocument, url: string) {
108 + if (o.kind === "index") return { externalId: o.indexCode, sourceUrl: url, retrievedAt: raw.fetchedAt, effectiveDate: o.period, title: o.indexCode, description: null, unit: "index", price: o.value, regularPrice: null, salePrice: null, currency: "CAD", location: o.geography, category: "index", payload: {} };
109 + return o.raw;
110 +}
111 +
112 +function persist(key: RunnableKey, o: CanonicalObservation, raw: RawDocument, parser: string, d: Database.Database): void {
113 + if (o.kind === "index") { saveIndex(o, d); return; }
114 + if (o.kind === "labour") {
115 + const rawId = saveRawObservation(key, o.raw, raw, "accepted", null, parser, d);
116 + saveLabour(key, o, rawId, d);
117 + return;
118 + }
119 + const rawId = saveRawObservation(key, o.raw, raw, "accepted", null, parser, d);
120 + const conf = Math.round(60 + (o.isRegularPrice ? 15 : 0) + (o.conversionFactor === 1 ? 10 : 5));
121 + savePrice(key, o, rawId, conf, false, null, d);
122 +}
added src/lib/cost/connectors/statcan.ts +114 −0
@@ -0,0 +1,114 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Connecteur Statistique Canada — Indices des prix de la construction de
4 + * bâtiments (tableau 18-10-0289-01, trimestriel, licence ouverte) via l'API
5 + * WDS. Séries : Québec (province), RMR de Québec, Montréal, Ottawa–Gatineau ;
6 + * bâtiments résidentiels, appartements, maison individuelle, maison en rangée ;
7 + * agrégat + divisions (Montréal et Québec résidentiel).
8 + * Code d'indice : statcan:18100289:<geo>:<type>:<division>.
9 + */
10 +import type { CanonicalIndexObservation, CanonicalObservation, ConnectorConfig, CostConnector, RawDocument, RawObservation, SourceDocument, ValidationResult } from "./types";
11 +import { contentHash } from "./firecrawl";
12 +import { validateObservations } from "./validate";
13 +import { recentPricesFor, referencePriceFor } from "./store";
14 +
15 +export const STATCAN_PARSER_VERSION = "statcan-1.0";
16 +const PID = 18100289;
17 +export const WDS = "https://www150.statcan.gc.ca/t1/wds/rest/getDataFromCubePidCoordAndLatestNPeriods";
18 +
19 +export const STATCAN_CONFIG: ConnectorConfig = {
20 + key: "statcan", name: "Statistique Canada — IPCB 18-10-0289-01", domain: "www150.statcan.gc.ca", maxPages: 5,
21 + allowedPaths: [/^https:\/\/www150\.statcan\.gc\.ca\/t1\/wds\/rest\//], excludedPaths: [], refreshDays: 30, timeoutMs: 60_000, retries: 2, rateLimitMs: 1000, concurrency: 1,
22 +};
23 +
24 +export const GEOS: Record<number, string> = { 8: "Québec (province)", 9: "Québec (RMR)", 10: "Montréal", 12: "Ottawa–Gatineau (partie ontarienne)" };
25 +export const TYPES: Record<number, string> = { 1: "Bâtiments résidentiels", 2: "Immeubles d'appartements", 5: "Maison individuelle", 6: "Maison en rangée" };
26 +export const DIVISIONS: Record<number, string> = {
27 + 1: "Agrégat des divisions", 4: "Béton", 5: "Maçonnerie", 8: "Bois, plastiques et composites", 9: "Isolation thermique et étanchéité", 10: "Ouvertures et fermetures", 11: "Revêtements de finition",
28 + 16: "Plomberie", 17: "Chauffage, ventilation et conditionnement d'air", 19: "Électricité", 22: "Terrassements", 23: "Aménagements extérieurs",
29 +};
30 +
31 +export interface SeriesSpec { geo: number; type: number; division: number }
32 +
33 +export function seriesList(): SeriesSpec[] {
34 + const out: SeriesSpec[] = [];
35 + for (const geo of [8, 9, 10, 12]) for (const type of [1, 2, 5, 6]) out.push({ geo, type, division: 1 });
36 + for (const geo of [9, 10]) for (const division of Object.keys(DIVISIONS).map(Number)) if (division !== 1) out.push({ geo, type: 1, division });
37 + return out;
38 +}
39 +
40 +export const coordinate = (s: SeriesSpec) => `${s.geo}.${s.type}.${s.division}.0.0.0.0.0.0.0`;
41 +export const indexCode = (s: SeriesSpec) => `statcan:${PID}:${s.geo}:${s.type}:${s.division}`;
42 +
43 +interface WdsPoint { refPer: string; value: number | null; statusCode?: number }
44 +interface WdsItem { status: string; object: { coordinate: string; vectorId?: number; vectorDataPoint: WdsPoint[] } }
45 +
46 +export async function fetchWds(specs: SeriesSpec[], latestN = 40, timeoutMs = 60_000): Promise<WdsItem[]> {
47 + const out: WdsItem[] = [];
48 + for (let i = 0; i < specs.length; i += 20) {
49 + const batch = specs.slice(i, i + 20).map((s) => ({ productId: PID, coordinate: coordinate(s), latestN }));
50 + const ctrl = new AbortController();
51 + const t = setTimeout(() => ctrl.abort(), timeoutMs);
52 + try {
53 + const res = await fetch(WDS, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(batch), signal: ctrl.signal });
54 + if (!res.ok) throw new Error(`StatCan WDS HTTP ${res.status}`);
55 + out.push(...((await res.json()) as WdsItem[]));
56 + } finally {
57 + clearTimeout(t);
58 + }
59 + if (i + 20 < specs.length) await new Promise((r) => setTimeout(r, 1000));
60 + }
61 + return out;
62 +}
63 +
64 +/** Points → observations d'indice avec variations trimestrielle et annuelle. */
65 +export function pointsToObservations(spec: SeriesSpec, points: WdsPoint[], retrievedAt: string): CanonicalIndexObservation[] {
66 + const pts = points.filter((p) => p.value != null && Number.isFinite(p.value)).sort((a, b) => a.refPer.localeCompare(b.refPer));
67 + return pts.map((p, i) => {
68 + const prev = i > 0 ? pts[i - 1] : null;
69 + const yearAgo = pts.find((q) => q.refPer === shiftYear(p.refPer, -1)) ?? null;
70 + return {
71 + kind: "index", indexCode: indexCode(spec), geography: GEOS[spec.geo] ?? String(spec.geo), buildingType: TYPES[spec.type] ?? String(spec.type), division: DIVISIONS[spec.division] ?? String(spec.division),
72 + period: p.refPer, value: p.value as number, pctQoq: prev?.value ? r1(((p.value as number) / prev.value - 1) * 100) : null, pctYoy: yearAgo?.value ? r1(((p.value as number) / yearAgo.value - 1) * 100) : null, retrievedAt,
73 + };
74 + });
75 +}
76 +
77 +const r1 = (x: number) => Math.round(x * 10) / 10;
78 +function shiftYear(iso: string, dy: number): string {
79 + return `${Number(iso.slice(0, 4)) + dy}${iso.slice(4)}`;
80 +}
81 +
82 +export const statcanConnector: CostConnector = {
83 + config: STATCAN_CONFIG,
84 + async discover() {
85 + return [{ url: WDS, title: "StatCan 18-10-0289-01 (WDS)", meta: { series: seriesList().length } }];
86 + },
87 + async fetch(doc) {
88 + const items = await fetchWds(seriesList(), 40, STATCAN_CONFIG.timeoutMs);
89 + const md = JSON.stringify(items.map((it) => ({ c: it.object?.coordinate, s: it.status, p: it.object?.vectorDataPoint?.map((p) => [p.refPer, p.value]) })));
90 + // les indices changent à chaque diffusion : le hash porte sur les données, pas sur la date de récupération
91 + return { url: doc.url, fetchedAt: new Date().toISOString(), markdown: md, metadata: { items: items.length }, statusCode: 200, contentHash: contentHash(md), unchanged: false, meta: doc.meta };
92 + },
93 + async extract(raw) {
94 + const items = JSON.parse(raw.markdown) as { c: string; s: string; p: [string, number | null][] }[];
95 + return items.filter((it) => it.s === "SUCCESS" && it.c).map((it) => ({
96 + externalId: it.c, sourceUrl: `https://www150.statcan.gc.ca/t1/tbl1/fr/tv.action?pid=${PID}01`, retrievedAt: raw.fetchedAt, effectiveDate: it.p.at(-1)?.[0] ?? null, title: `IPCB ${it.c}`, description: null, unit: "index", price: it.p.at(-1)?.[1] ?? null,
97 + regularPrice: null, salePrice: null, currency: "CAD", location: it.c.split(".")[0], category: "index", payload: { points: it.p },
98 + } satisfies RawObservation));
99 + },
100 + async normalize(obs, raw) {
101 + const out: CanonicalObservation[] = [];
102 + for (const o of obs) {
103 + const [geo, type, division] = (o.externalId ?? "").split(".").map(Number);
104 + const pts = (o.payload.points as [string, number | null][]).map(([refPer, value]) => ({ refPer, value }));
105 + out.push(...pointsToObservations({ geo, type, division }, pts, raw.fetchedAt));
106 + }
107 + return out;
108 + },
109 + validate(obs, opts): ValidationResult {
110 + return validateObservations(obs, { recentPrices: (c) => recentPricesFor(c, 120, opts.db), referencePrice: (c) => referencePriceFor(c, opts.db) });
111 + },
112 +};
113 +
114 +export type { RawDocument, SourceDocument };
added src/lib/cost/connectors/store.ts +153 −0
@@ -0,0 +1,153 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Persistance des connecteurs : observations brutes (historisées, hachées),
4 + * prix canoniques, grilles de main-d'œuvre, indices, runs, sources.
5 + * N'altère pas le schéma de db.ts : `ensureConnectorSchema` ajoute seulement
6 + * ses propres index/colonnes de façon tolérante.
7 + */
8 +import type Database from "better-sqlite3";
9 +import { getCostDb, metaSet, sourceIdByKey } from "../db";
10 +import { invalidateCache } from "../cache";
11 +import type { CanonicalIndexObservation, CanonicalLabourObservation, CanonicalPriceObservation, ConnectorKey, RawDocument, RawObservation, RunLogEntry, RunStats } from "./types";
12 +
13 +export function ensureConnectorSchema(d: Database.Database = getCostDb()): void {
14 + d.exec(`
15 + CREATE INDEX IF NOT EXISTS idx_raw_obs_url ON cost_raw_observations(source_url, retrieved_at);
16 + CREATE INDEX IF NOT EXISTS idx_item_prices_source_date ON cost_item_prices(source_id, observation_date);
17 + CREATE INDEX IF NOT EXISTS idx_labour_trade ON labour_rates(trade_code, sector, effective_from);
18 + CREATE INDEX IF NOT EXISTS idx_connector_runs_conn ON connector_runs(connector, started_at);
19 + `);
20 +}
21 +
22 +const nowIso = () => new Date().toISOString();
23 +const dateOf = (iso: string) => iso.slice(0, 10);
24 +
25 +/* -------------------------------------------------------- documents bruts */
26 +
27 +/** Le dernier hash vu pour une URL (détection de changement). */
28 +export function lastHashFor(url: string, d: Database.Database = getCostDb()): string | null {
29 + const r = d.prepare("SELECT content_hash FROM cost_raw_observations WHERE source_url=? AND content_hash IS NOT NULL ORDER BY retrieved_at DESC LIMIT 1").get(url) as { content_hash: string } | undefined;
30 + return r?.content_hash ?? null;
31 +}
32 +
33 +export function saveRawObservation(connector: ConnectorKey, obs: RawObservation, doc: RawDocument, status: "new" | "accepted" | "rejected" | "unchanged", rejectReason: string | null, parserVersion: string, d: Database.Database = getCostDb()): number {
34 + const sid = sourceIdByKey(connector, d);
35 + const r = d.prepare(`INSERT INTO cost_raw_observations(source_id,external_id,source_url,retrieved_at,effective_date,raw_title,raw_description,raw_unit,raw_price,raw_regular_price,raw_sale_price,raw_currency,raw_location,raw_category,raw_payload_json,content_hash,parser_version,status,reject_reason)
36 + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
37 + sid, obs.externalId, obs.sourceUrl, obs.retrievedAt, obs.effectiveDate, obs.title.slice(0, 500), obs.description?.slice(0, 2000) ?? null, obs.unit, obs.price, obs.regularPrice, obs.salePrice, obs.currency, obs.location, obs.category,
38 + JSON.stringify(obs.payload).slice(0, 20000), doc.contentHash, parserVersion, status, rejectReason,
39 + );
40 + return Number(r.lastInsertRowid);
41 +}
42 +
43 +/** Marque simplement qu'un document a été vu inchangé (une ligne légère par run). */
44 +export function saveUnchangedMarker(connector: ConnectorKey, doc: RawDocument, d: Database.Database = getCostDb()): void {
45 + const sid = sourceIdByKey(connector, d);
46 + d.prepare(`INSERT INTO cost_raw_observations(source_id,source_url,retrieved_at,raw_title,raw_currency,raw_payload_json,content_hash,parser_version,status) VALUES(?,?,?,?,?,?,?,?,'unchanged')`)
47 + .run(sid, doc.url, doc.fetchedAt, String(doc.metadata.title ?? "").slice(0, 500), "CAD", "{}", doc.contentHash, "marker");
48 +}
49 +
50 +/* ---------------------------------------------------------------- prix */
51 +
52 +export function itemIdByCode(code: string, d: Database.Database = getCostDb()): number | null {
53 + const r = d.prepare("SELECT id FROM cost_items WHERE canonical_code=?").get(code) as { id: number } | undefined;
54 + return r?.id ?? null;
55 +}
56 +
57 +/** Une observation par jour et par (article, source) : la seconde du même jour remplace la première (même URL) sinon s'ajoute. */
58 +export function savePrice(connector: ConnectorKey, o: CanonicalPriceObservation, rawId: number | null, confidence: number, isOutlier: boolean, outlierReason: string | null, d: Database.Database = getCostDb()): "inserted" | "updated" {
59 + const sid = sourceIdByKey(connector, d);
60 + const iid = itemIdByCode(o.itemCode, d);
61 + if (!iid) throw new Error(`article inconnu ${o.itemCode}`);
62 + const existing = d.prepare("SELECT id FROM cost_item_prices WHERE cost_item_id=? AND source_id=? AND observation_date=? AND source_url=?").get(iid, sid, o.observationDate, o.sourceUrl) as { id: number } | undefined;
63 + if (existing) {
64 + d.prepare(`UPDATE cost_item_prices SET total_cost=?, material_cost=?, source_unit=?, conversion_factor=?, is_regular_price=?, is_outlier=?, outlier_reason=?, confidence_score=?, raw_observation_id=? WHERE id=?`)
65 + .run(o.totalCost, o.totalCost, o.sourceUnit, o.conversionFactor, o.isRegularPrice ? 1 : 0, isOutlier ? 1 : 0, outlierReason, confidence, rawId, existing.id);
66 + return "updated";
67 + }
68 + d.prepare(`INSERT INTO cost_item_prices(cost_item_id,source_id,location_code,observation_date,price_kind,material_cost,total_cost,currency,source_unit,conversion_factor,is_regular_price,is_outlier,outlier_reason,confidence_score,source_url,raw_observation_id,verified)
69 + VALUES(?,?,?,?,'observed',?,?,?,?,?,?,?,?,?,?,?,0)`).run(iid, sid, o.locationCode, o.observationDate, o.totalCost, o.totalCost, o.currency, o.sourceUnit, o.conversionFactor, o.isRegularPrice ? 1 : 0, isOutlier ? 1 : 0, outlierReason, confidence, o.sourceUrl, rawId);
70 + return "inserted";
71 +}
72 +
73 +/** Historique récent des prix canoniques d'un article (autres sources incluses) — pour la détection d'aberrations. */
74 +export function recentPricesFor(itemCode: string, days = 120, d: Database.Database = getCostDb()): number[] {
75 + return (d.prepare(`SELECT p.total_cost v FROM cost_item_prices p JOIN cost_items i ON i.id=p.cost_item_id WHERE i.canonical_code=? AND p.is_outlier=0 AND p.observation_date >= date('now', ?) ORDER BY p.observation_date DESC LIMIT 60`).all(itemCode, `-${days} days`) as { v: number }[]).map((r) => r.v);
76 +}
77 +
78 +export function referencePriceFor(itemCode: string, d: Database.Database = getCostDb()): number | null {
79 + const r = d.prepare("SELECT reference_price v FROM cost_items WHERE canonical_code=?").get(itemCode) as { v: number | null } | undefined;
80 + return r?.v ?? null;
81 +}
82 +
83 +/* ------------------------------------------------------------ main-d'œuvre */
84 +
85 +export function saveLabour(connector: ConnectorKey, o: CanonicalLabourObservation, rawId: number | null, d: Database.Database = getCostDb()): "inserted" | "updated" {
86 + const sid = sourceIdByKey(connector, d);
87 + const ex = d.prepare("SELECT id FROM labour_rates WHERE trade_code=? AND sector=? AND classification=? AND region=? AND effective_from=? AND source_id=?").get(o.tradeCode, o.sector, o.classification, o.region, o.effectiveFrom, sid) as { id: number } | undefined;
88 + if (ex) {
89 + d.prepare(`UPDATE labour_rates SET trade_name_fr=?, base_wage=?, vacation_cost=?, benefits_cost=?, employer_contributions=?, other_contributions=?, total_employer_cost=?, source_url=?, confidence_score=?, raw_observation_id=? WHERE id=?`)
90 + .run(o.tradeNameFr, o.baseWage, o.vacationCost, o.benefitsCost, o.employerContributions, o.otherContributions, o.totalEmployerCost, o.sourceUrl, o.confidence, rawId, ex.id);
91 + return "updated";
92 + }
93 + d.prepare(`INSERT INTO labour_rates(trade_code,trade_name_fr,trade_name_en,sector,classification,region,effective_from,effective_to,base_wage,vacation_cost,benefits_cost,employer_contributions,other_contributions,total_employer_cost,source_id,source_url,confidence_score,raw_observation_id)
94 + VALUES(?,?,?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?)`).run(o.tradeCode, o.tradeNameFr, null, o.sector, o.classification, o.region, o.effectiveFrom, o.baseWage, o.vacationCost, o.benefitsCost, o.employerContributions, o.otherContributions, o.totalEmployerCost, sid, o.sourceUrl, o.confidence, rawId);
95 + return "inserted";
96 +}
97 +
98 +/** Recalcule effective_to = date de la grille suivante pour chaque série (métier, secteur, classification, région, source). */
99 +export function closeLabourPeriods(connector: ConnectorKey, d: Database.Database = getCostDb()): void {
100 + const sid = sourceIdByKey(connector, d);
101 + d.exec(`UPDATE labour_rates SET effective_to = (
102 + SELECT MIN(l2.effective_from) FROM labour_rates l2
103 + WHERE l2.trade_code=labour_rates.trade_code AND l2.sector=labour_rates.sector AND l2.classification=labour_rates.classification AND l2.region=labour_rates.region AND l2.source_id=labour_rates.source_id AND l2.effective_from > labour_rates.effective_from)
104 + WHERE source_id=${sid}`);
105 +}
106 +
107 +/* -------------------------------------------------------------- indices */
108 +
109 +export function saveIndex(o: CanonicalIndexObservation, d: Database.Database = getCostDb()): void {
110 + d.prepare(`INSERT INTO construction_cost_indices(source,index_code,geography,building_type,division,period,index_value,pct_change_qoq,pct_change_yoy,retrieved_at) VALUES(?,?,?,?,?,?,?,?,?,?)
111 + ON CONFLICT(index_code,period) DO UPDATE SET index_value=excluded.index_value, pct_change_qoq=excluded.pct_change_qoq, pct_change_yoy=excluded.pct_change_yoy, retrieved_at=excluded.retrieved_at`)
112 + .run("Statistique Canada 18-10-0289-01", o.indexCode, o.geography, o.buildingType, o.division, o.period, o.value, o.pctQoq, o.pctYoy, o.retrievedAt);
113 +}
114 +
115 +/* ---------------------------------------------------------------- runs */
116 +
117 +export function startRun(connector: ConnectorKey, d: Database.Database = getCostDb()): RunStats {
118 + const startedAt = nowIso();
119 + const r = d.prepare("INSERT INTO connector_runs(connector,started_at,status) VALUES(?,?,'running')").run(connector, startedAt);
120 + return { connector, runId: Number(r.lastInsertRowid), startedAt, finishedAt: null, status: "running", pages: 0, observations: 0, accepted: 0, rejected: 0, unchanged: 0, errors: 0, log: [] };
121 +}
122 +
123 +export function finishRun(run: RunStats, d: Database.Database = getCostDb()): RunStats {
124 + run.finishedAt = nowIso();
125 + if (run.status === "running") run.status = run.errors > 0 ? (run.accepted > 0 ? "partial" : "error") : "ok";
126 + d.prepare("UPDATE connector_runs SET finished_at=?, status=?, pages=?, observations=?, accepted=?, rejected=?, unchanged=?, errors=?, duration_ms=?, log_json=? WHERE id=?")
127 + .run(run.finishedAt, run.status, run.pages, run.observations, run.accepted, run.rejected, run.unchanged, run.errors, new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime(), JSON.stringify({ message: run.message ?? null, entries: run.log.slice(-400) }), run.runId);
128 + const sid = sourceIdByKey(run.connector, d);
129 + if (run.status === "ok" || run.status === "partial") d.prepare("UPDATE cost_sources SET last_successful_sync=?, last_error=?, updated_at=datetime('now') WHERE id=?").run(run.finishedAt, run.status === "partial" ? `${run.errors} erreur(s) — voir connector_runs #${run.runId}` : null, sid);
130 + else d.prepare("UPDATE cost_sources SET last_error=?, updated_at=datetime('now') WHERE id=?").run(run.message ?? `${run.status} — connector_runs #${run.runId}`, sid);
131 + if (run.accepted > 0) {
132 + metaSet("last_ingest", run.finishedAt, d);
133 + invalidateCache();
134 + }
135 + return run;
136 +}
137 +
138 +export function pushLog(run: RunStats, e: RunLogEntry, cb?: (e: RunLogEntry) => void): void {
139 + run.log.push(e);
140 + cb?.(e);
141 +}
142 +
143 +export function lastSuccessfulSync(connector: ConnectorKey, d: Database.Database = getCostDb()): string | null {
144 + const r = d.prepare("SELECT last_successful_sync v FROM cost_sources WHERE key=?").get(connector) as { v: string | null } | undefined;
145 + return r?.v ?? null;
146 +}
147 +
148 +export function isSourceActive(connector: ConnectorKey, d: Database.Database = getCostDb()): boolean {
149 + const r = d.prepare("SELECT is_active v FROM cost_sources WHERE key=?").get(connector) as { v: number } | undefined;
150 + return (r?.v ?? 0) === 1;
151 +}
152 +
153 +export { dateOf, nowIso };
added src/lib/cost/connectors/types.ts +177 −0
@@ -0,0 +1,177 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Connecteurs de la base de coûts : contrats communs.
4 + *
5 + * Sources externes → discover → fetch → extract → normalize → validate → DB
6 + *
7 + * Chaque connecteur est borné (maxPages, chemins permis, rythme) et ne tourne
8 + * JAMAIS dans le chemin utilisateur : seulement via scripts/cost-sync.ts ou
9 + * l'API admin.
10 + */
11 +import type Database from "better-sqlite3";
12 +import type { CanonicalUnit } from "../types";
13 +
14 +export type ConnectorKey = "apchq" | "ccq" | "statcan" | "canac" | "bmr" | "patrickmorin" | "rona" | "homedepot" | "altus" | "rsmeans";
15 +
16 +export interface ConnectorConfig {
17 + key: ConnectorKey;
18 + name: string;
19 + domain: string | null;
20 + maxPages: number;
21 + allowedPaths: RegExp[];
22 + excludedPaths: RegExp[];
23 + /** fréquence nominale (jours) entre deux synchronisations non forcées */
24 + refreshDays: number;
25 + timeoutMs: number;
26 + retries: number;
27 + /** délai minimal entre deux appels vers le domaine (ms) */
28 + rateLimitMs: number;
29 + /** appels simultanés vers le domaine */
30 + concurrency: number;
31 +}
32 +
33 +export interface SourceDocument {
34 + url: string;
35 + title?: string | null;
36 + /** métadonnées de découverte (ex. date d'entrée en vigueur, secteur, article visé) */
37 + meta?: Record<string, string | number | null>;
38 +}
39 +
40 +export interface RawDocument {
41 + url: string;
42 + fetchedAt: string; // ISO
43 + markdown: string;
44 + metadata: Record<string, unknown>;
45 + statusCode: number | null;
46 + contentHash: string;
47 + /** vrai si le même hash est déjà en base (document inchangé) */
48 + unchanged: boolean;
49 + meta?: Record<string, string | number | null>;
50 +}
51 +
52 +/** Observation brute extraite d'un document (avant normalisation). */
53 +export interface RawObservation {
54 + externalId: string | null;
55 + sourceUrl: string;
56 + retrievedAt: string;
57 + effectiveDate: string | null;
58 + title: string;
59 + description: string | null;
60 + unit: string | null;
61 + price: number | null;
62 + regularPrice: number | null;
63 + salePrice: number | null;
64 + currency: string;
65 + location: string | null;
66 + category: string | null;
67 + payload: Record<string, unknown>;
68 +}
69 +
70 +/** Observation prête pour la base canonique (prix d'article). */
71 +export interface CanonicalPriceObservation {
72 + kind: "price";
73 + itemCode: string;
74 + sourceUrl: string;
75 + observationDate: string; // YYYY-MM-DD
76 + sourceUnit: string;
77 + canonicalUnit: CanonicalUnit;
78 + conversionFactor: number;
79 + totalCost: number; // dans l'unité canonique
80 + isRegularPrice: boolean;
81 + rawPrice: number;
82 + rawRegularPrice: number | null;
83 + rawSalePrice: number | null;
84 + currency: string;
85 + locationCode: string;
86 + title: string;
87 + raw: RawObservation;
88 +}
89 +
90 +export interface CanonicalLabourObservation {
91 + kind: "labour";
92 + tradeCode: string;
93 + tradeNameFr: string;
94 + sector: string;
95 + classification: string;
96 + region: string;
97 + effectiveFrom: string;
98 + baseWage: number;
99 + vacationCost: number;
100 + benefitsCost: number;
101 + employerContributions: number;
102 + otherContributions: number;
103 + totalEmployerCost: number;
104 + sourceUrl: string;
105 + confidence: number;
106 + raw: RawObservation;
107 +}
108 +
109 +export interface CanonicalIndexObservation {
110 + kind: "index";
111 + indexCode: string;
112 + geography: string;
113 + buildingType: string;
114 + division: string;
115 + period: string;
116 + value: number;
117 + pctQoq: number | null;
118 + pctYoy: number | null;
119 + retrievedAt: string;
120 +}
121 +
122 +export type CanonicalObservation = CanonicalPriceObservation | CanonicalLabourObservation | CanonicalIndexObservation;
123 +
124 +export interface ValidationIssue {
125 + index: number;
126 + reason: string;
127 +}
128 +
129 +export interface ValidationResult {
130 + accepted: CanonicalObservation[];
131 + rejected: { obs: CanonicalObservation; reason: string }[];
132 +}
133 +
134 +export interface RunLogEntry {
135 + connector: ConnectorKey;
136 + url: string | null;
137 + status: "ok" | "unchanged" | "rejected" | "error" | "skipped" | "unavailable";
138 + duration: number;
139 + observations: number;
140 + validationErrors: number;
141 + timestamp: string;
142 + message?: string;
143 +}
144 +
145 +export interface RunStats {
146 + connector: ConnectorKey;
147 + runId: number;
148 + startedAt: string;
149 + finishedAt: string | null;
150 + status: "running" | "ok" | "partial" | "error" | "unavailable" | "skipped";
151 + pages: number;
152 + observations: number;
153 + accepted: number;
154 + rejected: number;
155 + unchanged: number;
156 + errors: number;
157 + log: RunLogEntry[];
158 + message?: string;
159 +}
160 +
161 +export interface RunOptions {
162 + force?: boolean;
163 + /** limiter aux articles/urls (tests, admin) */
164 + onlyItems?: string[];
165 + maxPages?: number;
166 + log?: (e: RunLogEntry) => void;
167 + db?: Database.Database;
168 +}
169 +
170 +export interface CostConnector {
171 + readonly config: ConnectorConfig;
172 + discover(opts: RunOptions): Promise<SourceDocument[]>;
173 + fetch(doc: SourceDocument, opts: RunOptions): Promise<RawDocument>;
174 + extract(raw: RawDocument, opts: RunOptions): Promise<RawObservation[]>;
175 + normalize(obs: RawObservation[], raw: RawDocument, opts: RunOptions): Promise<CanonicalObservation[]>;
176 + validate(obs: CanonicalObservation[], opts: RunOptions): ValidationResult;
177 +}
added src/lib/cost/connectors/validate.ts +72 −0
@@ -0,0 +1,72 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Validation stricte des observations canoniques (sans dépendance) :
4 + * prix ≤ 0, devise ≠ CAD, unité inconnue, conversion incohérente, aberration
5 + * par rapport aux prix récents (MAD/IQR) et aux bornes métier (référence
6 + * interne ×0,25…×4). Les rejets sont conservés en base, jamais supprimés.
7 + */
8 +import { isKnownUnit } from "../units";
9 +import { detectOutliers } from "../pricing";
10 +import type { CanonicalObservation, ValidationResult } from "./types";
11 +
12 +export interface ValidateContext {
13 + /** prix récents (unité canonique) par article, autres sources incluses */
14 + recentPrices: (itemCode: string) => number[];
15 + referencePrice: (itemCode: string) => number | null;
16 +}
17 +
18 +const UNIT_MAX: Record<string, number> = { pi2: 400, pi_lin: 300, unit: 200000, lump: 500000, m3: 2000, kg: 500, lb: 500, L: 500, gal: 500, h: 1000, day: 5000, pi3: 500, m2: 5000 };
19 +
20 +export function validateObservations(obs: CanonicalObservation[], ctx: ValidateContext): ValidationResult {
21 + const accepted: CanonicalObservation[] = [];
22 + const rejected: { obs: CanonicalObservation; reason: string }[] = [];
23 + const priceGroups = new Map<string, number[]>();
24 +
25 + for (const o of obs) {
26 + const reason = basicReason(o, ctx);
27 + if (reason) { rejected.push({ obs: o, reason }); continue; }
28 + accepted.push(o);
29 + if (o.kind === "price") priceGroups.set(o.itemCode, [...(priceGroups.get(o.itemCode) ?? []), o.totalCost]);
30 + }
31 + // aberrations statistiques : observation vs prix récents (autres sources) + celles du lot
32 + const final: CanonicalObservation[] = [];
33 + for (const o of accepted) {
34 + if (o.kind !== "price") { final.push(o); continue; }
35 + const pool = [...ctx.recentPrices(o.itemCode), ...(priceGroups.get(o.itemCode) ?? []).filter((v) => v !== o.totalCost)];
36 + if (pool.length >= 2) {
37 + const arr = [...pool, o.totalCost];
38 + const out = detectOutliers(arr);
39 + const hit = out.find((x) => x.idx === arr.length - 1);
40 + if (hit) { rejected.push({ obs: o, reason: `aberration : ${hit.reason}` }); continue; }
41 + }
42 + final.push(o);
43 + }
44 + return { accepted: final, rejected };
45 +}
46 +
47 +function basicReason(o: CanonicalObservation, ctx: ValidateContext): string | null {
48 + if (o.kind === "index") {
49 + if (!Number.isFinite(o.value) || o.value <= 0) return "valeur d'indice invalide";
50 + if (!/^\d{4}-\d{2}-\d{2}$/.test(o.period)) return "période invalide";
51 + return null;
52 + }
53 + if (o.kind === "labour") {
54 + if (!(o.baseWage > 10 && o.baseWage < 200)) return `taux horaire implausible (${o.baseWage})`;
55 + if (!(o.totalEmployerCost >= o.baseWage && o.totalEmployerCost < o.baseWage * 2.2)) return `coût employeur incohérent (${o.totalEmployerCost} vs ${o.baseWage})`;
56 + if (!/^\d{4}-\d{2}-\d{2}$/.test(o.effectiveFrom)) return "date d'entrée en vigueur invalide";
57 + return null;
58 + }
59 + if (o.currency !== "CAD") return `devise impossible (${o.currency})`;
60 + if (!(o.rawPrice > 0)) return "prix ≤ 0";
61 + if (!isKnownUnit(o.sourceUnit)) return `unité inconnue « ${o.sourceUnit} »`;
62 + if (!(o.conversionFactor > 0) || !Number.isFinite(o.conversionFactor)) return "facteur de conversion invalide";
63 + if (!(o.totalCost > 0)) return "prix canonique ≤ 0";
64 + const max = UNIT_MAX[o.canonicalUnit] ?? 1e6;
65 + if (o.totalCost > max) return `prix par ${o.canonicalUnit} hors bornes (${o.totalCost.toFixed(2)} > ${max}) — emballage probablement mal interprété`;
66 + const ref = ctx.referencePrice(o.itemCode);
67 + if (ref != null && ref > 0) {
68 + const ratio = o.totalCost / ref;
69 + if (ratio > 4 || ratio < 0.25) return `hors bornes métier : ${ratio.toFixed(2)}× le prix de référence (${ref})`;
70 + }
71 + return null;
72 +}
added src/lib/cost/db.ts +595 −0
@@ -0,0 +1,595 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Base de coûts `data/cost.db` (SQLite, better-sqlite3) : schéma versionné,
4 + * migrations forward-only, seeds idempotents (articles, assemblages,
5 + * localisations, sources, règles de condition). SERVEUR seulement.
6 + *
7 + * L'interface ne lit que cette base : aucun connecteur web n'est appelé dans
8 + * le chemin utilisateur (scripts/cost-sync.ts alimente la base en arrière-plan).
9 + */
10 +import Database from "better-sqlite3";
11 +import path from "path";
12 +import fs from "fs";
13 +import { ITEM_SEEDS } from "./seed/items";
14 +import { ASSEMBLY_SEEDS, ASSEMBLY_VERSION } from "./seed/assemblies";
15 +import { LOCATION_SEEDS } from "./seed/locations";
16 +import { SOURCE_SEEDS } from "./seed/sources";
17 +import { CONDITIONS, CONDITION_GROUPS, MASTERFORMAT } from "./taxonomy";
18 +
19 +export const COST_DB_SCHEMA_VERSION = 3;
20 +
21 +let db: Database.Database | null = null;
22 +
23 +export function costDbPath(): string {
24 + return process.env.COST_DB ?? path.join(process.cwd(), "data", "cost.db");
25 +}
26 +
27 +export function getCostDb(): Database.Database {
28 + if (db) return db;
29 + const p = costDbPath();
30 + fs.mkdirSync(path.dirname(p), { recursive: true });
31 + db = new Database(p);
32 + db.pragma("journal_mode = WAL");
33 + db.pragma("foreign_keys = ON");
34 + migrate(db);
35 + seed(db);
36 + return db;
37 +}
38 +
39 +/** Pour les tests : base en mémoire isolée. */
40 +export function openCostDbAt(p: string): Database.Database {
41 + const d = new Database(p);
42 + d.pragma("journal_mode = MEMORY");
43 + d.pragma("foreign_keys = ON");
44 + migrate(d);
45 + seed(d);
46 + return d;
47 +}
48 +
49 +/* ----------------------------------------------------------------- schéma */
50 +
51 +const SCHEMA = `
52 +CREATE TABLE IF NOT EXISTS cost_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
53 +
54 +CREATE TABLE IF NOT EXISTS cost_sources (
55 + id INTEGER PRIMARY KEY AUTOINCREMENT,
56 + key TEXT UNIQUE NOT NULL,
57 + name TEXT NOT NULL,
58 + source_type TEXT NOT NULL,
59 + base_url TEXT,
60 + jurisdiction TEXT,
61 + license_status TEXT NOT NULL DEFAULT 'unknown',
62 + license_notes TEXT,
63 + terms_url TEXT,
64 + is_active INTEGER NOT NULL DEFAULT 1,
65 + priority INTEGER NOT NULL DEFAULT 5,
66 + quality REAL NOT NULL DEFAULT 0.5,
67 + refresh_frequency TEXT,
68 + last_successful_sync TEXT,
69 + last_error TEXT,
70 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
71 + updated_at TEXT NOT NULL DEFAULT (datetime('now'))
72 +);
73 +
74 +CREATE TABLE IF NOT EXISTS cost_raw_observations (
75 + id INTEGER PRIMARY KEY AUTOINCREMENT,
76 + source_id INTEGER NOT NULL REFERENCES cost_sources(id),
77 + external_id TEXT,
78 + source_url TEXT,
79 + retrieved_at TEXT NOT NULL,
80 + effective_date TEXT,
81 + raw_title TEXT,
82 + raw_description TEXT,
83 + raw_unit TEXT,
84 + raw_price REAL,
85 + raw_regular_price REAL,
86 + raw_sale_price REAL,
87 + raw_currency TEXT,
88 + raw_location TEXT,
89 + raw_category TEXT,
90 + raw_payload_json TEXT,
91 + content_hash TEXT,
92 + parser_version TEXT,
93 + status TEXT NOT NULL DEFAULT 'new',
94 + reject_reason TEXT,
95 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
96 +);
97 +CREATE INDEX IF NOT EXISTS idx_raw_obs_source_date ON cost_raw_observations(source_id, retrieved_at);
98 +CREATE INDEX IF NOT EXISTS idx_raw_obs_hash ON cost_raw_observations(content_hash);
99 +
100 +CREATE TABLE IF NOT EXISTS cost_items (
101 + id INTEGER PRIMARY KEY AUTOINCREMENT,
102 + canonical_code TEXT UNIQUE NOT NULL,
103 + masterformat_code TEXT NOT NULL,
104 + division TEXT NOT NULL,
105 + category TEXT NOT NULL,
106 + subcategory TEXT,
107 + name_fr TEXT NOT NULL,
108 + name_en TEXT NOT NULL,
109 + description_fr TEXT,
110 + description_en TEXT,
111 + unit TEXT NOT NULL,
112 + material_class TEXT,
113 + trade TEXT,
114 + default_waste_pct REAL NOT NULL DEFAULT 0,
115 + residential_relevance INTEGER NOT NULL DEFAULT 1,
116 + quality_level TEXT,
117 + reference_price REAL,
118 + reference_note TEXT,
119 + retail_query TEXT,
120 + retail_pack_qty REAL,
121 + retail_pack_unit TEXT,
122 + active INTEGER NOT NULL DEFAULT 1,
123 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
124 + updated_at TEXT NOT NULL DEFAULT (datetime('now'))
125 +);
126 +
127 +/* URL produit par détaillant pour un article (découverte par connecteur / matching validé) */
128 +CREATE TABLE IF NOT EXISTS cost_item_sources (
129 + id INTEGER PRIMARY KEY AUTOINCREMENT,
130 + cost_item_id INTEGER NOT NULL REFERENCES cost_items(id),
131 + source_id INTEGER NOT NULL REFERENCES cost_sources(id),
132 + product_url TEXT NOT NULL,
133 + product_title TEXT,
134 + external_id TEXT,
135 + pack_qty REAL,
136 + pack_unit TEXT,
137 + match_method TEXT,
138 + match_confidence REAL,
139 + approved INTEGER NOT NULL DEFAULT 0,
140 + active INTEGER NOT NULL DEFAULT 1,
141 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
142 + UNIQUE(source_id, product_url)
143 +);
144 +
145 +CREATE TABLE IF NOT EXISTS cost_item_prices (
146 + id INTEGER PRIMARY KEY AUTOINCREMENT,
147 + cost_item_id INTEGER NOT NULL REFERENCES cost_items(id),
148 + source_id INTEGER NOT NULL REFERENCES cost_sources(id),
149 + location_code TEXT,
150 + observation_date TEXT NOT NULL,
151 + price_kind TEXT NOT NULL DEFAULT 'observed',
152 + material_cost REAL,
153 + labour_cost REAL,
154 + equipment_cost REAL,
155 + total_cost REAL NOT NULL,
156 + low_cost REAL,
157 + median_cost REAL,
158 + high_cost REAL,
159 + currency TEXT NOT NULL DEFAULT 'CAD',
160 + source_unit TEXT,
161 + conversion_factor REAL NOT NULL DEFAULT 1,
162 + is_regular_price INTEGER NOT NULL DEFAULT 1,
163 + is_outlier INTEGER NOT NULL DEFAULT 0,
164 + outlier_reason TEXT,
165 + confidence_score REAL,
166 + source_url TEXT,
167 + raw_observation_id INTEGER REFERENCES cost_raw_observations(id),
168 + verified INTEGER NOT NULL DEFAULT 0,
169 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
170 +);
171 +CREATE INDEX IF NOT EXISTS idx_item_prices_item_date ON cost_item_prices(cost_item_id, observation_date);
172 +
173 +CREATE TABLE IF NOT EXISTS labour_rates (
174 + id INTEGER PRIMARY KEY AUTOINCREMENT,
175 + trade_code TEXT NOT NULL,
176 + trade_name_fr TEXT NOT NULL,
177 + trade_name_en TEXT,
178 + sector TEXT NOT NULL,
179 + classification TEXT NOT NULL DEFAULT 'compagnon',
180 + region TEXT NOT NULL DEFAULT 'QC',
181 + effective_from TEXT NOT NULL,
182 + effective_to TEXT,
183 + base_wage REAL NOT NULL,
184 + vacation_cost REAL NOT NULL DEFAULT 0,
185 + benefits_cost REAL NOT NULL DEFAULT 0,
186 + employer_contributions REAL NOT NULL DEFAULT 0,
187 + other_contributions REAL NOT NULL DEFAULT 0,
188 + total_employer_cost REAL NOT NULL,
189 + source_id INTEGER REFERENCES cost_sources(id),
190 + source_url TEXT,
191 + confidence_score REAL,
192 + raw_observation_id INTEGER REFERENCES cost_raw_observations(id),
193 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
194 + UNIQUE(trade_code, sector, classification, region, effective_from, source_id)
195 +);
196 +
197 +CREATE TABLE IF NOT EXISTS cost_locations (
198 + id INTEGER PRIMARY KEY AUTOINCREMENT,
199 + code TEXT UNIQUE NOT NULL,
200 + municipality_code TEXT,
201 + municipality_name TEXT,
202 + name_fr TEXT NOT NULL,
203 + name_en TEXT NOT NULL,
204 + region_code TEXT,
205 + region_name TEXT,
206 + latitude REAL,
207 + longitude REAL,
208 + radius_km REAL,
209 + municipalities_json TEXT,
210 + material_factor REAL NOT NULL DEFAULT 1,
211 + labour_factor REAL NOT NULL DEFAULT 1,
212 + equipment_factor REAL NOT NULL DEFAULT 1,
213 + overall_factor REAL NOT NULL DEFAULT 1,
214 + effective_date TEXT NOT NULL,
215 + source_method TEXT,
216 + confidence_score REAL,
217 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
218 +);
219 +
220 +CREATE TABLE IF NOT EXISTS cost_assemblies (
221 + id INTEGER PRIMARY KEY AUTOINCREMENT,
222 + assembly_code TEXT UNIQUE NOT NULL,
223 + masterformat_code TEXT NOT NULL,
224 + category TEXT NOT NULL,
225 + name_fr TEXT NOT NULL,
226 + name_en TEXT NOT NULL,
227 + description_fr TEXT,
228 + description_en TEXT,
229 + unit TEXT NOT NULL,
230 + building_type TEXT,
231 + quality_level TEXT,
232 + economic_life INTEGER,
233 + condition_group TEXT,
234 + version INTEGER NOT NULL DEFAULT 1,
235 + active INTEGER NOT NULL DEFAULT 1,
236 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
237 + updated_at TEXT NOT NULL DEFAULT (datetime('now'))
238 +);
239 +
240 +CREATE TABLE IF NOT EXISTS cost_assembly_components (
241 + id INTEGER PRIMARY KEY AUTOINCREMENT,
242 + assembly_id INTEGER NOT NULL REFERENCES cost_assemblies(id) ON DELETE CASCADE,
243 + cost_item_id INTEGER REFERENCES cost_items(id),
244 + quantity_per_assembly_unit REAL NOT NULL DEFAULT 0,
245 + waste_factor REAL NOT NULL DEFAULT 0,
246 + labour_hours REAL NOT NULL DEFAULT 0,
247 + trade TEXT,
248 + equipment_cost REAL NOT NULL DEFAULT 0,
249 + sequence INTEGER NOT NULL DEFAULT 0,
250 + notes TEXT
251 +);
252 +CREATE INDEX IF NOT EXISTS idx_asm_comp ON cost_assembly_components(assembly_id);
253 +
254 +CREATE TABLE IF NOT EXISTS construction_cost_indices (
255 + id INTEGER PRIMARY KEY AUTOINCREMENT,
256 + source TEXT NOT NULL,
257 + index_code TEXT NOT NULL,
258 + geography TEXT NOT NULL,
259 + building_type TEXT NOT NULL,
260 + division TEXT NOT NULL DEFAULT 'aggregate',
261 + period TEXT NOT NULL,
262 + index_value REAL NOT NULL,
263 + pct_change_qoq REAL,
264 + pct_change_yoy REAL,
265 + retrieved_at TEXT NOT NULL,
266 + UNIQUE(index_code, period)
267 +);
268 +
269 +CREATE TABLE IF NOT EXISTS cost_benchmarks (
270 + id INTEGER PRIMARY KEY AUTOINCREMENT,
271 + source TEXT NOT NULL,
272 + source_id INTEGER REFERENCES cost_sources(id),
273 + building_type TEXT NOT NULL,
274 + market TEXT NOT NULL,
275 + unit TEXT NOT NULL DEFAULT '$/pi2',
276 + low REAL NOT NULL,
277 + high REAL NOT NULL,
278 + midpoint REAL,
279 + year INTEGER NOT NULL,
280 + notes TEXT,
281 + is_demo INTEGER NOT NULL DEFAULT 0,
282 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
283 + UNIQUE(source, building_type, market, year)
284 +);
285 +
286 +CREATE TABLE IF NOT EXISTS component_condition_rules (
287 + id INTEGER PRIMARY KEY AUTOINCREMENT,
288 + condition_group TEXT NOT NULL,
289 + condition TEXT NOT NULL,
290 + effective_age_ratio REAL NOT NULL,
291 + economic_life INTEGER NOT NULL,
292 + notes TEXT,
293 + UNIQUE(condition_group, condition)
294 +);
295 +
296 +CREATE TABLE IF NOT EXISTS cost_estimates (
297 + id TEXT PRIMARY KEY,
298 + property_id TEXT,
299 + listing_uid TEXT,
300 + user_session_id TEXT,
301 + mode TEXT NOT NULL,
302 + location_code TEXT,
303 + municipality TEXT,
304 + estimate_date TEXT NOT NULL,
305 + price_date TEXT NOT NULL,
306 + building_type TEXT,
307 + quality_level TEXT,
308 + area_sqft REAL,
309 + direct_cost REAL,
310 + indirect_cost REAL,
311 + contractor_overhead REAL,
312 + contractor_profit REAL,
313 + contingency REAL,
314 + replacement_cost_new REAL,
315 + rcn_low REAL,
316 + rcn_high REAL,
317 + physical_depreciation REAL,
318 + functional_obsolescence REAL,
319 + external_obsolescence REAL,
320 + depreciated_improvement_value REAL,
321 + land_value REAL,
322 + cost_approach_value REAL,
323 + confidence_score REAL,
324 + confidence_letter TEXT,
325 + method_version TEXT NOT NULL,
326 + cost_database_version TEXT NOT NULL,
327 + assembly_version TEXT NOT NULL,
328 + input_json TEXT NOT NULL,
329 + result_json TEXT NOT NULL,
330 + assumptions_json TEXT,
331 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
332 +);
333 +CREATE INDEX IF NOT EXISTS idx_estimates_prop ON cost_estimates(property_id);
334 +CREATE INDEX IF NOT EXISTS idx_estimates_listing ON cost_estimates(listing_uid);
335 +
336 +CREATE TABLE IF NOT EXISTS cost_estimate_lines (
337 + id INTEGER PRIMARY KEY AUTOINCREMENT,
338 + estimate_id TEXT NOT NULL REFERENCES cost_estimates(id) ON DELETE CASCADE,
339 + assembly_code TEXT NOT NULL,
340 + category TEXT NOT NULL,
341 + quantity REAL NOT NULL,
342 + unit TEXT NOT NULL,
343 + material_cost REAL,
344 + labour_cost REAL,
345 + equipment_cost REAL,
346 + direct_cost REAL,
347 + location_adjustment REAL,
348 + adjusted_cost REAL,
349 + source_summary TEXT,
350 + confidence_score REAL,
351 + calculation_json TEXT
352 +);
353 +CREATE INDEX IF NOT EXISTS idx_est_lines ON cost_estimate_lines(estimate_id);
354 +
355 +/* instantané des prix canoniques utilisés par une estimation (reproductibilité) */
356 +CREATE TABLE IF NOT EXISTS cost_estimate_snapshots (
357 + estimate_id TEXT PRIMARY KEY REFERENCES cost_estimates(id) ON DELETE CASCADE,
358 + snapshot_date TEXT NOT NULL,
359 + prices_json TEXT NOT NULL,
360 + labour_json TEXT NOT NULL,
361 + location_json TEXT NOT NULL,
362 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
363 +);
364 +
365 +CREATE TABLE IF NOT EXISTS connector_runs (
366 + id INTEGER PRIMARY KEY AUTOINCREMENT,
367 + connector TEXT NOT NULL,
368 + started_at TEXT NOT NULL,
369 + finished_at TEXT,
370 + status TEXT NOT NULL DEFAULT 'running',
371 + pages INTEGER NOT NULL DEFAULT 0,
372 + observations INTEGER NOT NULL DEFAULT 0,
373 + accepted INTEGER NOT NULL DEFAULT 0,
374 + rejected INTEGER NOT NULL DEFAULT 0,
375 + unchanged INTEGER NOT NULL DEFAULT 0,
376 + errors INTEGER NOT NULL DEFAULT 0,
377 + duration_ms INTEGER,
378 + log_json TEXT
379 +);
380 +
381 +/* correspondances produit détaillant → article canonique proposées (IA/déterministe) à valider */
382 +CREATE TABLE IF NOT EXISTS product_mappings (
383 + id INTEGER PRIMARY KEY AUTOINCREMENT,
384 + source_id INTEGER NOT NULL REFERENCES cost_sources(id),
385 + product_url TEXT NOT NULL,
386 + product_title TEXT,
387 + proposed_item_code TEXT,
388 + pack_qty REAL,
389 + pack_unit TEXT,
390 + method TEXT NOT NULL,
391 + confidence REAL NOT NULL,
392 + rationale TEXT,
393 + status TEXT NOT NULL DEFAULT 'pending',
394 + reviewed_at TEXT,
395 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
396 + UNIQUE(source_id, product_url)
397 +);
398 +
399 +/* ---------------------------------------------- analyse IA des annonces */
400 +CREATE TABLE IF NOT EXISTS listing_images (
401 + id INTEGER PRIMARY KEY AUTOINCREMENT,
402 + listing_uid TEXT NOT NULL,
403 + photo_id TEXT NOT NULL,
404 + source_url TEXT NOT NULL,
405 + position INTEGER,
406 + width INTEGER,
407 + height INTEGER,
408 + bytes INTEGER,
409 + hash TEXT,
410 + media_type TEXT,
411 + room_guess TEXT,
412 + quality_score REAL,
413 + selected_for_ai INTEGER NOT NULL DEFAULT 0,
414 + fetched_at TEXT NOT NULL,
415 + UNIQUE(listing_uid, source_url)
416 +);
417 +
418 +CREATE TABLE IF NOT EXISTS listing_ai_analyses (
419 + id TEXT PRIMARY KEY,
420 + listing_uid TEXT NOT NULL,
421 + version INTEGER NOT NULL,
422 + model TEXT NOT NULL,
423 + prompt_version TEXT NOT NULL,
424 + schema_version TEXT NOT NULL,
425 + input_hash TEXT NOT NULL,
426 + status TEXT NOT NULL DEFAULT 'queued',
427 + stage TEXT,
428 + error TEXT,
429 + image_count INTEGER NOT NULL DEFAULT 0,
430 + output_json TEXT,
431 + compact_json TEXT,
432 + vector_json TEXT,
433 + confidence REAL,
434 + estimate_id TEXT,
435 + cost_snapshot_date TEXT,
436 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
437 + completed_at TEXT
438 +);
439 +CREATE INDEX IF NOT EXISTS idx_ai_analyses_listing ON listing_ai_analyses(listing_uid, version);
440 +CREATE INDEX IF NOT EXISTS idx_ai_analyses_hash ON listing_ai_analyses(listing_uid, input_hash, prompt_version, model);
441 +
442 +CREATE TABLE IF NOT EXISTS listing_ai_overrides (
443 + id INTEGER PRIMARY KEY AUTOINCREMENT,
444 + analysis_id TEXT NOT NULL REFERENCES listing_ai_analyses(id) ON DELETE CASCADE,
445 + field_path TEXT NOT NULL,
446 + original_value TEXT,
447 + new_value TEXT,
448 + user_id TEXT,
449 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
450 +);
451 +
452 +CREATE TABLE IF NOT EXISTS analysis_conflicts (
453 + id INTEGER PRIMARY KEY AUTOINCREMENT,
454 + analysis_id TEXT NOT NULL REFERENCES listing_ai_analyses(id) ON DELETE CASCADE,
455 + field TEXT NOT NULL,
456 + source_a TEXT NOT NULL,
457 + value_a TEXT,
458 + source_b TEXT NOT NULL,
459 + value_b TEXT,
460 + severity TEXT NOT NULL DEFAULT 'medium',
461 + status TEXT NOT NULL DEFAULT 'open'
462 +);
463 +
464 +CREATE TABLE IF NOT EXISTS property_embeddings (
465 + id INTEGER PRIMARY KEY AUTOINCREMENT,
466 + listing_uid TEXT NOT NULL,
467 + analysis_id TEXT NOT NULL REFERENCES listing_ai_analyses(id) ON DELETE CASCADE,
468 + embedding_model TEXT NOT NULL,
469 + embedding_dimension INTEGER NOT NULL,
470 + embedding_json TEXT NOT NULL,
471 + canonical_text TEXT,
472 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
473 + UNIQUE(analysis_id, embedding_model)
474 +);
475 +
476 +CREATE TABLE IF NOT EXISTS ai_usage (
477 + id INTEGER PRIMARY KEY AUTOINCREMENT,
478 + analysis_id TEXT,
479 + purpose TEXT NOT NULL,
480 + model TEXT NOT NULL,
481 + input_images INTEGER NOT NULL DEFAULT 0,
482 + input_tokens INTEGER NOT NULL DEFAULT 0,
483 + output_tokens INTEGER NOT NULL DEFAULT 0,
484 + cache_read_tokens INTEGER NOT NULL DEFAULT 0,
485 + estimated_cost_usd REAL,
486 + latency_ms INTEGER,
487 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
488 +);
489 +`;
490 +
491 +function migrate(d: Database.Database): void {
492 + d.exec(SCHEMA);
493 + const cur = Number((d.prepare("SELECT value FROM cost_meta WHERE key='schema_version'").get() as { value: string } | undefined)?.value ?? 0);
494 + // migrations incrémentales (forward-only) : ajouter ici les ALTER TABLE futurs
495 + if (cur < 2) {
496 + // v2 : colonnes de prix promotionnel déjà dans SCHEMA (création) — no-op pour bases existantes récentes
497 + }
498 + if (cur < 3) {
499 + // v3 : ai_usage.cache_read_tokens (ajout tolérant)
500 + try { d.exec("ALTER TABLE ai_usage ADD COLUMN cache_read_tokens INTEGER NOT NULL DEFAULT 0"); } catch { /* déjà présent */ }
501 + }
502 + d.prepare("INSERT INTO cost_meta(key,value) VALUES('schema_version',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(COST_DB_SCHEMA_VERSION));
503 +}
504 +
505 +/* ------------------------------------------------------------------ seeds */
506 +
507 +function seed(d: Database.Database): void {
508 + const seedVersion = `${COST_DB_SCHEMA_VERSION}.${ASSEMBLY_VERSION}.${ITEM_SEEDS.length}.${ASSEMBLY_SEEDS.length}.${LOCATION_SEEDS.length}`;
509 + const done = (d.prepare("SELECT value FROM cost_meta WHERE key='seed_version'").get() as { value: string } | undefined)?.value;
510 + if (done === seedVersion) return;
511 +
512 + const tx = d.transaction(() => {
513 + // sources
514 + const upSrc = d.prepare(`INSERT INTO cost_sources(key,name,source_type,base_url,jurisdiction,license_status,license_notes,terms_url,is_active,priority,quality,refresh_frequency)
515 + VALUES(@key,@name,@sourceType,@baseUrl,@jurisdiction,@licenseStatus,@licenseNotes,@termsUrl,@isActive,@priority,@quality,@refreshFrequency)
516 + ON CONFLICT(key) DO UPDATE SET name=excluded.name, source_type=excluded.source_type, base_url=excluded.base_url, license_status=excluded.license_status,
517 + license_notes=excluded.license_notes, terms_url=excluded.terms_url, priority=excluded.priority, quality=excluded.quality, refresh_frequency=excluded.refresh_frequency, updated_at=datetime('now')`);
518 + for (const s of SOURCE_SEEDS) upSrc.run({ ...s, isActive: s.isActive ? 1 : 0 });
519 +
520 + // articles
521 + const upItem = d.prepare(`INSERT INTO cost_items(canonical_code,masterformat_code,division,category,name_fr,name_en,unit,material_class,trade,default_waste_pct,quality_level,reference_price,reference_note,retail_query,retail_pack_qty,retail_pack_unit)
522 + VALUES(@code,@mf,@division,@cat,@fr,@en,@unit,@cls,@trade,@waste,@quality,@ref,@refNote,@rq,@rpack,@rpu)
523 + ON CONFLICT(canonical_code) DO UPDATE SET masterformat_code=excluded.masterformat_code, division=excluded.division, category=excluded.category, name_fr=excluded.name_fr, name_en=excluded.name_en,
524 + unit=excluded.unit, material_class=excluded.material_class, trade=excluded.trade, default_waste_pct=excluded.default_waste_pct, quality_level=excluded.quality_level,
525 + reference_price=excluded.reference_price, reference_note=excluded.reference_note, retail_query=excluded.retail_query, retail_pack_qty=excluded.retail_pack_qty, retail_pack_unit=excluded.retail_pack_unit, updated_at=datetime('now')`);
526 + for (const it of ITEM_SEEDS) {
527 + upItem.run({
528 + code: it.code, mf: it.mf, division: MASTERFORMAT[it.mf]?.fr ?? it.mf, cat: it.cat, fr: it.fr, en: it.en, unit: it.unit,
529 + cls: it.cls ?? "material", trade: it.trade ?? null, waste: it.waste ?? 0, quality: it.quality ?? null,
530 + ref: it.ref, refNote: it.refNote ?? null, rq: it.retail?.q ?? null, rpack: it.retail?.pack ?? null, rpu: it.retail?.packUnit ?? null,
531 + });
532 + }
533 + const itemId = new Map<string, number>((d.prepare("SELECT id, canonical_code FROM cost_items").all() as { id: number; canonical_code: string }[]).map((r) => [r.canonical_code, r.id]));
534 +
535 + // assemblages (remplacement complet des composants à chaque version de seed)
536 + const upAsm = d.prepare(`INSERT INTO cost_assemblies(assembly_code,masterformat_code,category,name_fr,name_en,description_fr,description_en,unit,building_type,quality_level,economic_life,condition_group,version)
537 + VALUES(@code,@mf,@cat,@fr,@en,@descFr,@descEn,@unit,@buildingType,@quality,@life,@group,@version)
538 + ON CONFLICT(assembly_code) DO UPDATE SET masterformat_code=excluded.masterformat_code, category=excluded.category, name_fr=excluded.name_fr, name_en=excluded.name_en,
539 + description_fr=excluded.description_fr, description_en=excluded.description_en, unit=excluded.unit, building_type=excluded.building_type, quality_level=excluded.quality_level,
540 + economic_life=excluded.economic_life, condition_group=excluded.condition_group, version=excluded.version, updated_at=datetime('now')`);
541 + const delComp = d.prepare("DELETE FROM cost_assembly_components WHERE assembly_id = ?");
542 + const insComp = d.prepare(`INSERT INTO cost_assembly_components(assembly_id,cost_item_id,quantity_per_assembly_unit,waste_factor,labour_hours,trade,equipment_cost,sequence,notes)
543 + VALUES(?,?,?,?,?,?,?,?,?)`);
544 + for (const a of ASSEMBLY_SEEDS) {
545 + upAsm.run({ code: a.code, mf: a.mf, cat: a.cat, fr: a.fr, en: a.en, descFr: a.descFr, descEn: a.descEn, unit: a.unit, buildingType: a.buildingType ?? null, quality: a.quality ?? null, life: a.life, group: a.group, version: ASSEMBLY_VERSION });
546 + const aid = (d.prepare("SELECT id FROM cost_assemblies WHERE assembly_code=?").get(a.code) as { id: number }).id;
547 + delComp.run(aid);
548 + for (const c of a.components) {
549 + const iid = c.itemCode ? itemId.get(c.itemCode) : null;
550 + if (c.itemCode && !iid) throw new Error(`Assemblage ${a.code} : article inconnu ${c.itemCode}`);
551 + insComp.run(aid, iid ?? null, c.quantity, c.wasteFactor, c.labourHours, c.trade, c.equipmentCost, c.sequence, c.notes);
552 + }
553 + }
554 +
555 + // localisations
556 + const upLoc = d.prepare(`INSERT INTO cost_locations(code,name_fr,name_en,region_code,latitude,longitude,radius_km,municipalities_json,material_factor,labour_factor,equipment_factor,overall_factor,effective_date,source_method,confidence_score)
557 + VALUES(@code,@nameFr,@nameEn,@regionCode,@lat,@lng,@radiusKm,@munis,@materialFactor,@labourFactor,@equipmentFactor,@overallFactor,'2026-09-01',@method,@conf)
558 + ON CONFLICT(code) DO UPDATE SET name_fr=excluded.name_fr, name_en=excluded.name_en, region_code=excluded.region_code, latitude=excluded.latitude, longitude=excluded.longitude, radius_km=excluded.radius_km,
559 + municipalities_json=excluded.municipalities_json, material_factor=excluded.material_factor, labour_factor=excluded.labour_factor, equipment_factor=excluded.equipment_factor, overall_factor=excluded.overall_factor,
560 + source_method=excluded.source_method, confidence_score=excluded.confidence_score`);
561 + for (const l of LOCATION_SEEDS) {
562 + upLoc.run({ ...l, munis: JSON.stringify(l.municipalities), method: "Main-d'œuvre : conventions collectives CCQ uniformes au Québec (APCHQ) ; matériaux/équipement : surcoût de transport — hypothèse Vrai-Prix", conf: l.labourFactor === 1 ? 75 : 55 });
563 + }
564 +
565 + // règles condition → âge effectif
566 + const upRule = d.prepare(`INSERT INTO component_condition_rules(condition_group,condition,effective_age_ratio,economic_life,notes) VALUES(?,?,?,?,?)
567 + ON CONFLICT(condition_group,condition) DO UPDATE SET effective_age_ratio=excluded.effective_age_ratio, economic_life=excluded.economic_life`);
568 + for (const g of CONDITION_GROUPS) for (const c of CONDITIONS) upRule.run(g.key, c.key, c.effectiveAgeRatio, g.economicLife, "Hypothèse documentée Vrai-Prix — âge effectif = ratio × vie économique de la composante");
569 +
570 + d.prepare("INSERT INTO cost_meta(key,value) VALUES('seed_version',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(seedVersion);
571 + d.prepare("INSERT INTO cost_meta(key,value) VALUES('seeded_at',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(new Date().toISOString());
572 + });
573 + tx();
574 +}
575 +
576 +/** Version de la base de coûts (pour la reproductibilité des estimations). */
577 +export function costDatabaseVersion(d: Database.Database = getCostDb()): string {
578 + const seedV = (d.prepare("SELECT value FROM cost_meta WHERE key='seed_version'").get() as { value: string } | undefined)?.value ?? "0";
579 + const last = (d.prepare("SELECT MAX(observation_date) m, COUNT(*) n FROM cost_item_prices").get() as { m: string | null; n: number });
580 + const lab = (d.prepare("SELECT MAX(effective_from) m FROM labour_rates").get() as { m: string | null });
581 + return `${seedV}|prices:${last.n}@${last.m ?? "none"}|labour:${lab.m ?? "none"}`;
582 +}
583 +
584 +export function sourceIdByKey(key: string, d: Database.Database = getCostDb()): number {
585 + const r = d.prepare("SELECT id FROM cost_sources WHERE key=?").get(key) as { id: number } | undefined;
586 + if (!r) throw new Error(`Source inconnue : ${key}`);
587 + return r.id;
588 +}
589 +
590 +export function metaGet(key: string, d: Database.Database = getCostDb()): string | null {
591 + return (d.prepare("SELECT value FROM cost_meta WHERE key=?").get(key) as { value: string } | undefined)?.value ?? null;
592 +}
593 +export function metaSet(key: string, value: string, d: Database.Database = getCostDb()): void {
594 + d.prepare("INSERT INTO cost_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
595 +}
added src/lib/cost/engine.test.ts +243 −0
@@ -0,0 +1,243 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { describe, expect, it } from "vitest";
3 +import { assemblyUnitCost, computeEstimate, computeDepreciation, type PricingContext } from "./engine";
4 +import { computeGeometry, deriveQuantities } from "./geometry";
5 +import { canonicalPrice, detectOutliers, indexFactor, median, type PriceObservation } from "./pricing";
6 +import { convertPrice, isKnownUnit } from "./units";
7 +import { ITEM_SEEDS } from "./seed/items";
8 +import { ASSEMBLY_SEEDS } from "./seed/assemblies";
9 +import { CONDITIONS, CONDITION_GROUPS, DEFAULT_PARAMS } from "./taxonomy";
10 +import type { Assembly, Condition, CostInput, CostItem, ItemPrice, LabourRate } from "./types";
11 +import { defaultInput } from "./estimate-defaults";
12 +
13 +/* ------------------------------------------------------------ contexte test */
14 +
15 +function ctxFromSeeds(opts: { observed?: boolean } = {}): PricingContext {
16 + const items = new Map<string, CostItem>();
17 + const prices = new Map<string, ItemPrice>();
18 + for (const s of ITEM_SEEDS) {
19 + items.set(s.code, { id: 0, code: s.code, masterformat: s.mf, division: s.mf, category: s.cat, subcategory: null, nameFr: s.fr, nameEn: s.en, descriptionFr: null, descriptionEn: null, unit: s.unit, materialClass: s.cls ?? "material", trade: s.trade ?? null, defaultWastePct: s.waste ?? 0, qualityLevel: s.quality ?? null, referencePrice: s.ref, referenceNote: s.refNote ?? null, active: true });
20 + prices.set(s.code, { itemCode: s.code, unit: s.unit, price: s.ref, low: s.ref * 0.9, high: s.ref * 1.1, kind: opts.observed ? "observed" : "reference", sourceCount: opts.observed ? 2 : 0, observedAt: opts.observed ? "2026-09-01" : null, sources: [], confidence: 50, indexed: null, note: null });
21 + }
22 + const assemblies = new Map<string, Assembly>();
23 + for (const a of ASSEMBLY_SEEDS) assemblies.set(a.code, { code: a.code, masterformat: a.mf, category: a.cat, nameFr: a.fr, nameEn: a.en, descriptionFr: a.descFr, descriptionEn: a.descEn, unit: a.unit, buildingType: a.buildingType ?? null, quality: a.quality ?? null, version: 1, economicLife: a.life, conditionGroup: a.group, components: a.components, active: true });
24 + const labour = new Map<string, LabourRate>();
25 + for (const t of ["charpentier", "briqueteur", "carreleur", "cimentier", "coffreur", "couvreur", "electricien", "plombier", "ferblantier", "frigoriste", "peintre", "platrier", "poseur_systemes", "poseur_revetements", "ferrailleur", "manoeuvre", "operateur", "poseur_bardeaux"]) {
26 + labour.set(t, { tradeCode: t, tradeNameFr: t, tradeNameEn: t, sector: "residentiel_leger", classification: "compagnon", region: "QC", effectiveFrom: "2026-04-26", effectiveTo: null, baseWage: 60, vacationCost: 7.8, benefitsCost: 7.89, employerContributions: 12, otherContributions: 1.5, totalEmployerCost: 89.14, source: "APCHQ (test)", sourceUrl: null, confidence: 90 });
27 + }
28 + const conditionRules = new Map<string, Map<Condition, { effectiveAgeRatio: number; economicLife: number }>>();
29 + for (const g of CONDITION_GROUPS) conditionRules.set(g.key, new Map(CONDITIONS.map((c) => [c.key, { effectiveAgeRatio: c.effectiveAgeRatio, economicLife: g.economicLife }])));
30 + return {
31 + asOf: "2026-09-06", items, prices, labour, assemblies,
32 + location: { code: "QC-MTL", nameFr: "Montréal", nameEn: "Montréal", regionCode: "06", materialFactor: 1, labourFactor: 1, equipmentFactor: 1, overallFactor: 1, effectiveDate: "2026-09-01", sourceMethod: "test", confidence: 75 },
33 + conditionRules, benchmarks: [{ source: "test", buildingType: "single_family", market: "Montréal", unit: "$/pi2", low: 200, high: 400, midpoint: 300, year: 2026, notes: null }], fallbackHourlyRate: 85,
34 + };
35 +}
36 +
37 +const META = { id: "t", createdAt: "2026-09-06T00:00:00Z", costDatabaseVersion: "test", assemblyVersion: "1" };
38 +
39 +function houseInput(): CostInput {
40 + const i = defaultInput();
41 + i.building = { ...i.building, type: "detached", quality: "standard", grossFloorAreaSqft: 2000, stories: 2, yearBuilt: 1998, basement: "finished", basementFinishedPct: 1, garage: { type: "attached", spaces: 2, areaSqft: null }, siding: { brick: 0.6, vinyl: 0.4 }, heating: "heat_pump", bathrooms: 2, powderRooms: 1, deckSqft: 200, drivewaySqft: 600, driveway: "asphalt" };
42 + i.land = { value: 182000, source: "role", rollYear: 2026, method: "rôle" };
43 + i.attributeSources = { grossFloorAreaSqft: "MAMH", stories: "MAMH", yearBuilt: "MAMH", basement: "user", garage: "user", siding: "user", roof: "user", heating: "user", bathrooms: "user", kitchenQuality: "user" };
44 + return i;
45 +}
46 +
47 +/* ------------------------------------------------------------------ unités */
48 +
49 +describe("units", () => {
50 + it("convertit une feuille de gypse 4×8 en $/pi²", () => {
51 + const r = convertPrice(17.6, "feuille", "pi2", 32);
52 + expect(r.price).toBeCloseTo(0.55, 4);
53 + expect(r.conversion.factor).toBeCloseTo(1 / 32, 6);
54 + expect(r.conversion.sourceUnit).toBe("feuille");
55 + });
56 + it("convertit m² → pi² et refuse une unité inconnue sans emballage", () => {
57 + expect(convertPrice(10.764, "m2", "pi2").price).toBeCloseTo(1, 3);
58 + expect(() => convertPrice(5, "zorglub", "pi2")).toThrow();
59 + expect(isKnownUnit("paquet")).toBe(true);
60 + expect(isKnownUnit("chaque")).toBe(true);
61 + });
62 +});
63 +
64 +/* ------------------------------------------------------------------- prix */
65 +
66 +describe("pricing", () => {
67 + const obs = (source: string, price: number, date = "2026-09-01", regular = true): PriceObservation => ({ source, sourceQuality: 0.85, price, observedAt: date, url: null, sourceUnit: "unit", conversionFactor: 1, regular, kind: "observed", locationMatch: true });
68 + it("médiane robuste multi-source, observations conservées", () => {
69 + const cp = canonicalPrice([obs("Canac", 8.94), obs("RONA", 9.22), obs("Home Depot", 9.18)], "2026-09-06")!;
70 + expect(cp.price).toBeCloseTo(9.18, 2);
71 + expect(cp.sourceCount).toBe(3);
72 + expect(cp.low).toBeCloseTo(8.94, 2);
73 + expect(cp.high).toBeCloseTo(9.22, 2);
74 + });
75 + it("préfère le prix régulier à la promotion", () => {
76 + const cp = canonicalPrice([obs("Canac", 5.0, "2026-09-01", false), obs("BMR", 8.0), obs("PM", 8.2)], "2026-09-06")!;
77 + expect(cp.price).toBeCloseTo(8.1, 2);
78 + expect(cp.used.every((o) => o.regular)).toBe(true);
79 + });
80 + it("marque les aberrations sans les effacer", () => {
81 + expect(detectOutliers([8, 8.5, 9, 40]).length).toBe(1);
82 + const cp = canonicalPrice([obs("A", 8), obs("B", 8.5), obs("C", 40)], "2026-09-06")!;
83 + expect(cp.outliers.length).toBe(1);
84 + expect(cp.price).toBeCloseTo(8.25, 2);
85 + });
86 + it("ignore les observations hors fenêtre et actualise par indice", () => {
87 + expect(canonicalPrice([obs("A", 8, "2025-01-01")], "2026-09-06")).toBeNull();
88 + const f = indexFactor([{ period: "2025-01-01", value: 100 }, { period: "2026-01-01", value: 110 }], "2025-03-01", "2026-06-01")!;
89 + expect(f.factor).toBeCloseTo(1.1, 6);
90 + expect(median([1, 3, 2])).toBe(2);
91 + });
92 +});
93 +
94 +/* -------------------------------------------------------------- géométrie */
95 +
96 +describe("geometry", () => {
97 + it("dérive une géométrie cohérente pour une maison de 2 000 pi² sur 2 étages", () => {
98 + const g = computeGeometry(houseInput().building);
99 + expect(g.footprintSqft).toBe(1000);
100 + expect(g.perimeterFt).toBeGreaterThan(120);
101 + expect(g.roofSqft).toBeGreaterThan(g.footprintSqft);
102 + expect(g.netWallSqft).toBeLessThan(g.grossWallSqft + g.gableSqft);
103 + expect(g.garageSqft).toBe(480);
104 + expect(g.basementFinishedSqft).toBeGreaterThan(800);
105 + });
106 + it("produit des quantités pour ≥ 30 assemblages avec formules, et applique les surcharges", () => {
107 + const b = houseInput().building;
108 + const { lines } = deriveQuantities(b);
109 + expect(lines.length).toBeGreaterThanOrEqual(30);
110 + expect(lines.every((l) => l.quantity > 0 && l.formula.length > 3)).toBe(true);
111 + const brick = lines.find((l) => l.assemblyCode === "ENV-SIDING-BRICK")!;
112 + const vinyl = lines.find((l) => l.assemblyCode === "ENV-SIDING-VINYL")!;
113 + expect(brick.quantity / (brick.quantity + vinyl.quantity)).toBeCloseTo(0.6, 2);
114 + const o = deriveQuantities(b, { "ROOF-ASPHALT-ARCH": 1650 });
115 + expect(o.lines.find((l) => l.assemblyCode === "ROOF-ASPHALT-ARCH")!.quantity).toBe(1650);
116 + expect(o.lines.find((l) => l.assemblyCode === "ROOF-ASPHALT-ARCH")!.source).toBe("user");
117 + const ex = deriveQuantities(b, {}, "user", ["EXT-DECK-TREATED"]);
118 + expect(ex.lines.some((l) => l.assemblyCode === "EXT-DECK-TREATED")).toBe(false);
119 + });
120 + it("une dalle sur sol remplace le sous-sol", () => {
121 + const b = { ...houseInput().building, basement: "none" as const, stories: 1 };
122 + const { lines } = deriveQuantities(b);
123 + expect(lines.some((l) => l.assemblyCode === "FND-SLAB-ON-GRADE-INSULATED")).toBe(true);
124 + expect(lines.some((l) => l.assemblyCode === "FND-SLAB-BASEMENT-4IN")).toBe(false);
125 + });
126 +});
127 +
128 +/* ------------------------------------------------------------------ moteur */
129 +
130 +describe("engine", () => {
131 + const ctx = ctxFromSeeds();
132 + it("chaque assemblage du catalogue a un coût direct positif et une décomposition M/MO/É", () => {
133 + for (const a of ctx.assemblies.values()) {
134 + const u = assemblyUnitCost(a, ctx);
135 + expect(u.direct, a.code).toBeGreaterThan(0);
136 + expect(u.material + u.labour + u.equipment, a.code).toBeCloseTo(u.direct, 1);
137 + }
138 + });
139 + it("le mur 2×6 coûte un ordre de grandeur plausible et sépare matériaux / main-d'œuvre", () => {
140 + const u = assemblyUnitCost(ctx.assemblies.get("STR-WALL-2X6-EXT")!, ctx);
141 + expect(u.direct).toBeGreaterThan(6);
142 + expect(u.direct).toBeLessThan(20);
143 + expect(u.labour).toBeCloseTo(0.075 * 89.14, 1);
144 + });
145 + it("invariants : RCN ≥ direct, valeur dépréciée ≥ 0, V = terrain + valeur dépréciée, fourchette encadre le RCN", () => {
146 + const input = houseInput();
147 + const { lines } = deriveQuantities(input.building);
148 + const est = computeEstimate(input, lines, ctx, META);
149 + expect(est.replacementCostNew).toBeGreaterThanOrEqual(est.directCost);
150 + expect(est.depreciation.depreciatedImprovementValue).toBeGreaterThanOrEqual(0);
151 + expect(est.costApproachValue).toBe(est.landValue + est.depreciation.depreciatedImprovementValue);
152 + expect(est.range.p10).toBeLessThan(est.replacementCostNew);
153 + expect(est.range.p90).toBeGreaterThan(est.replacementCostNew);
154 + expect(est.directMaterial + est.directLabour + est.directEquipment).toBeCloseTo(est.directCost, -1);
155 + expect(est.replacementCostNew).toBe(est.directCost + est.indirectCost + est.contractorOverhead + est.contractorProfit + est.contingency);
156 + expect(est.categories.reduce((s, c) => s + c.sharePct, 0)).toBeCloseTo(100, 0);
157 + });
158 + it("le RCN d'une maison standard de 2 000 pi² est dans une plage plausible (150-450 $/pi²)", () => {
159 + const input = houseInput();
160 + const { lines } = deriveQuantities(input.building);
161 + const est = computeEstimate(input, lines, ctx, META);
162 + expect(est.perSqft).toBeGreaterThan(150);
163 + expect(est.perSqft).toBeLessThan(450);
164 + });
165 + it("frais indirects, généraux, profit et contingence suivent les pourcentages saisis", () => {
166 + const input = houseInput();
167 + input.params = { ...DEFAULT_PARAMS, overheadPct: 10, profitPct: 10, contingencyPct: 5 };
168 + const { lines } = deriveQuantities(input.building);
169 + const est = computeEstimate(input, lines, ctx, META);
170 + const ind = Object.values(input.params.indirect).reduce((s, v) => s + v, 0);
171 + expect(est.indirectCost).toBeCloseTo((est.directCost * ind) / 100, -2);
172 + expect(est.contractorOverhead).toBeCloseTo((est.directCost + est.indirectCost) * 0.1, -1);
173 + expect(est.contractorProfit).toBeCloseTo((est.directCost + est.indirectCost + est.contractorOverhead) * 0.1, -1);
174 + expect(est.contingency).toBeCloseTo((est.directCost + est.indirectCost) * 0.05, -1);
175 + });
176 + it("déterminisme : même entrée + même contexte = même résultat", () => {
177 + const input = houseInput();
178 + const { lines } = deriveQuantities(input.building);
179 + const a = computeEstimate(input, lines, ctx, META);
180 + const b = computeEstimate(input, lines, ctx, META);
181 + expect(JSON.stringify(a)).toBe(JSON.stringify(b));
182 + });
183 + it("facteurs régionaux séparés matériaux / main-d'œuvre / équipement", () => {
184 + const input = houseInput();
185 + const { lines } = deriveQuantities(input.building);
186 + const base = computeEstimate(input, lines, ctx, META);
187 + const far = computeEstimate(input, lines, { ...ctx, location: { ...ctx.location, code: "QC-CDN", materialFactor: 1.08, labourFactor: 1.03, equipmentFactor: 1.06 } }, META);
188 + expect(far.directMaterial).toBeCloseTo(base.directMaterial * 1.08, -2);
189 + expect(far.directLabour).toBeCloseTo(base.directLabour * 1.03, -2);
190 + expect(far.directCost).toBeGreaterThan(base.directCost);
191 + });
192 + it("dépréciation âge-vie : 28 ans / 60 ans = 46,7 %, plafonnée à 90 %", () => {
193 + const input = houseInput();
194 + input.depreciation = { ...input.depreciation, method: "age_life", effectiveAge: 28, economicLife: 60 };
195 + const { lines } = deriveQuantities(input.building);
196 + const est = computeEstimate(input, lines, ctx, META);
197 + expect(est.depreciation.physicalPct).toBeCloseTo(46.67, 1);
198 + expect(est.depreciation.physical).toBeCloseTo(est.replacementCostNew * 0.4667, -2);
199 + input.depreciation.effectiveAge = 200;
200 + const capped = computeDepreciation(input, est.lines, est.replacementCostNew, est.directCost, ctx);
201 + expect(capped.physicalPct).toBe(90);
202 + });
203 + it("dépréciation par composante : la toiture « poor » se déprécie plus qu'une toiture « new »", () => {
204 + const input = houseInput();
205 + input.depreciation = { ...input.depreciation, method: "components", componentConditions: { roof: "poor", kitchen: "renovated" } };
206 + const { lines } = deriveQuantities(input.building);
207 + const est = computeEstimate(input, lines, ctx, META);
208 + const roof = est.depreciation.components.find((c) => c.conditionGroup === "roof")!;
209 + const kitchen = est.depreciation.components.find((c) => c.conditionGroup === "kitchen")!;
210 + expect(roof.depreciationPct).toBeCloseTo(85, 0);
211 + expect(kitchen.depreciationPct).toBeCloseTo(12, 0);
212 + expect(est.depreciation.components.reduce((s, c) => s + c.rcn, 0)).toBeCloseTo(est.replacementCostNew, -3);
213 + });
214 + it("désuétudes fonctionnelle et externe s'ajoutent, sans dépasser le RCN", () => {
215 + const input = houseInput();
216 + input.depreciation = { ...input.depreciation, functional: [{ id: "a", type: "sdb", curable: true, costToCure: 17500, valueLoss: 0, notes: "" }, { id: "b", type: "config", curable: false, costToCure: 0, valueLoss: 12000, notes: "" }], externalValueLoss: 5000 };
217 + const { lines } = deriveQuantities(input.building);
218 + const est = computeEstimate(input, lines, ctx, META);
219 + expect(est.depreciation.functional).toBe(29500);
220 + expect(est.depreciation.external).toBe(5000);
221 + expect(est.depreciation.total).toBeLessThanOrEqual(est.replacementCostNew);
222 + });
223 + it("confiance : des prix observés valent mieux que des références ; benchmark évalué", () => {
224 + const input = houseInput();
225 + const { lines } = deriveQuantities(input.building);
226 + const ref = computeEstimate(input, lines, ctx, META);
227 + const obsv = computeEstimate(input, lines, ctxFromSeeds({ observed: true }), META);
228 + expect(obsv.confidence.total).toBeGreaterThan(ref.confidence.total);
229 + expect(ref.coverage.materialObservedShare).toBe(0);
230 + expect(obsv.coverage.materialObservedShare).toBeCloseTo(1, 2);
231 + expect(["within", "below", "above"]).toContain(ref.benchmarks[0].status);
232 + expect(["A", "B", "C", "D"]).toContain(ref.confidence.letter);
233 + });
234 + it("un terrain manquant est signalé et compté à 0", () => {
235 + const input = houseInput();
236 + input.land = { value: null, source: "none", rollYear: null, method: "" };
237 + const { lines } = deriveQuantities(input.building);
238 + const est = computeEstimate(input, lines, ctx, META);
239 + expect(est.landValue).toBe(0);
240 + expect(est.warnings).toContain("land_missing");
241 + expect(est.costApproachValue).toBe(est.depreciation.depreciatedImprovementValue);
242 + });
243 +});
added src/lib/cost/engine.ts +329 −0
@@ -0,0 +1,329 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Moteur de la méthode du coût — calcul PUR et DÉTERMINISTE :
4 + * même entrée + même contexte de prix = même résultat, aucune IA.
5 + *
6 + * matériau_i = Q × qté_i × (1 + pertes_i) × prix_i × FacteurMatériau
7 + * main-d'œuvre = Q × heures_i × taux_métier_i × FacteurMainDœuvre
8 + * équipement = Q × équip_i × FacteurÉquipement
9 + * Direct = Σ lignes ; Indirects = Direct × Σ pct
10 + * FG = (Direct + Indirects) × pct ; Profit = (Direct + Indirects + FG) × pct
11 + * Contingence = (Direct + Indirects) × pct
12 + * RCN = Direct + Indirects + FG + Profit + Contingence
13 + * V_coût = V_terrain + RCN − (D_phys + D_fonct + D_ext)
14 + */
15 +import type {
16 + Assembly, AssemblyUnitCost, BenchmarkCheck, CategoryTotal, Condition, ConfidenceBreakdown, CostEstimate, CostInput, CostItem,
17 + CostLocation, DepreciationComponentRow, DepreciationResult, EstimateLine, ItemPrice, LabourRate, Provenance, QuantityLine, UxCategory,
18 +} from "./types";
19 +import { BUILDING_TYPES, CONDITION_GROUPS, INDIRECT_LABELS, METHOD_VERSION, UX_CATEGORIES, categoryLabel } from "./taxonomy";
20 +
21 +export interface BenchmarkRow {
22 + source: string;
23 + buildingType: string; // single_family | townhouse | plex | condo | cottage
24 + market: string;
25 + unit: string;
26 + low: number;
27 + high: number;
28 + midpoint: number | null;
29 + year: number;
30 + notes: string | null;
31 +}
32 +
33 +export interface ConditionRule {
34 + effectiveAgeRatio: number;
35 + economicLife: number;
36 +}
37 +
38 +/** Contexte de prix : tout ce dont le moteur a besoin, lu une seule fois (instantané). */
39 +export interface PricingContext {
40 + asOf: string;
41 + items: Map<string, CostItem>;
42 + prices: Map<string, ItemPrice>;
43 + labour: Map<string, LabourRate>; // par code de métier (compagnon, résidentiel léger)
44 + assemblies: Map<string, Assembly>;
45 + location: CostLocation;
46 + conditionRules: Map<string, Map<Condition, ConditionRule>>;
47 + benchmarks: BenchmarkRow[];
48 + /** taux horaire de repli (hypothèse) si un métier manque */
49 + fallbackHourlyRate: number;
50 +}
51 +
52 +export const FALLBACK_HOURLY_RATE = 85; // $/h coût employeur — hypothèse si aucune grille
53 +const Z90 = 1.2816;
54 +const MAX_DEPRECIATION_PCT = 0.9;
55 +
56 +const r2 = (x: number) => Math.round(x * 100) / 100;
57 +const r0 = (x: number) => Math.round(x);
58 +
59 +/* ------------------------------------------------------ coût d'assemblage */
60 +
61 +function priceSigmaPct(p: ItemPrice | undefined): number {
62 + if (!p) return 0.2;
63 + if (p.sourceCount >= 2 && p.price > 0) return Math.max(0.04, (p.high - p.low) / (2 * Z90 * p.price));
64 + switch (p.kind) {
65 + case "observed": case "official": return 0.08;
66 + case "indexed": return 0.1;
67 + case "derived": return 0.1;
68 + case "reference": return 0.15;
69 + default: return 0.2;
70 + }
71 +}
72 +
73 +export function assemblyUnitCost(asm: Assembly, ctx: PricingContext): AssemblyUnitCost {
74 + let material = 0, labour = 0, equipment = 0, varSum = 0, observedMaterial = 0;
75 + let freshest: string | null = null;
76 + const components: AssemblyUnitCost["components"] = [];
77 + for (const c of asm.components) {
78 + const item = c.itemCode ? ctx.items.get(c.itemCode) : undefined;
79 + const price = c.itemCode ? ctx.prices.get(c.itemCode) : undefined;
80 + const unitPrice = price?.price ?? 0;
81 + const isEquipItem = item?.materialClass === "equipment" || item?.materialClass === "rental";
82 + const qty = c.quantity * (1 + c.wasteFactor);
83 + const matCost = item && !isEquipItem ? qty * unitPrice : 0;
84 + const eqItem = item && isEquipItem ? qty * unitPrice : 0;
85 + const trade = c.trade ?? item?.trade ?? null;
86 + const rate = trade ? ctx.labour.get(trade) : undefined;
87 + const hourly = c.labourHours > 0 ? (rate?.totalEmployerCost ?? ctx.fallbackHourlyRate) : null;
88 + const labCost = c.labourHours * (hourly ?? 0);
89 + const eqCost = c.equipmentCost + eqItem;
90 + material += matCost;
91 + labour += labCost;
92 + equipment += eqCost;
93 + if (price && (price.kind === "observed" || price.kind === "official" || price.kind === "indexed")) observedMaterial += matCost;
94 + if (price?.observedAt && (!freshest || price.observedAt > freshest)) freshest = price.observedAt;
95 + const sp = priceSigmaPct(price);
96 + varSum += (matCost * sp) ** 2 + (labCost * 0.15) ** 2 + (eqCost * 0.2) ** 2;
97 + const prov: Provenance = price
98 + ? { kind: price.kind, source: price.sources.map((s) => s.source).join(", ") || "—", sourceUrl: price.sources[0]?.url ?? null, observedAt: price.observedAt, confidence: price.confidence, note: price.note }
99 + : { kind: "assumption", source: c.itemCode ? "aucun prix" : "Vrai-Prix — hypothèse de productivité", confidence: c.itemCode ? 0 : 40, note: c.notes };
100 + const rateProv: Provenance | null = c.labourHours > 0
101 + ? rate
102 + ? { kind: "official", source: rate.source, sourceUrl: rate.sourceUrl, effectiveDate: rate.effectiveFrom, confidence: rate.confidence, note: `${rate.tradeNameFr} — ${rate.classification}, ${rate.sector}` }
103 + : { kind: "assumption", source: "Vrai-Prix — taux horaire de repli", confidence: 30, note: `Aucune grille pour le métier « ${trade ?? "?"} » : ${ctx.fallbackHourlyRate} $/h (hypothèse)` }
104 + : null;
105 + components.push({
106 + itemCode: c.itemCode, nameFr: item?.nameFr ?? (c.labourHours ? "Main-d'œuvre" : "Équipement"), nameEn: item?.nameEn ?? (c.labourHours ? "Labour" : "Equipment"), unit: item?.unit ?? "h",
107 + quantity: c.quantity, wasteFactor: c.wasteFactor, unitPrice: r2(unitPrice), materialCost: r2(matCost), labourHours: c.labourHours, trade, hourlyRate: hourly, labourCost: r2(labCost), equipmentCost: r2(eqCost),
108 + provenance: prov, rateProvenance: rateProv,
109 + });
110 + }
111 + const direct = material + labour + equipment;
112 + return {
113 + code: asm.code, unit: asm.unit, material: r2(material), labour: r2(labour), equipment: r2(equipment), direct: r2(direct),
114 + sigmaPct: direct > 0 ? Math.sqrt(varSum) / direct : 0.2, observedShare: material > 0 ? observedMaterial / material : 0, components, freshestObservation: freshest,
115 + };
116 +}
117 +
118 +/* ------------------------------------------------------------- estimation */
119 +
120 +const QTY_UNCERTAINTY: Record<string, number> = { derived: 0.1, AI: 0.15, user: 0.03, listing: 0.05, MAMH: 0.03, cadastral: 0.02, assumed: 0.2 };
121 +
122 +export function computeEstimate(input: CostInput, quantities: QuantityLine[], ctx: PricingContext, meta: { id: string; createdAt: string; costDatabaseVersion: string; assemblyVersion: string }): CostEstimate {
123 + const loc = ctx.location;
124 + const warnings: string[] = [];
125 + const lines: EstimateLine[] = [];
126 + let unpriced = 0;
127 +
128 + for (const q of quantities) {
129 + const asm = ctx.assemblies.get(q.assemblyCode);
130 + if (!asm) { warnings.push(`assemblage inconnu : ${q.assemblyCode}`); continue; }
131 + const u = assemblyUnitCost(asm, ctx);
132 + if (u.direct <= 0) unpriced++;
133 + const material = u.material * q.quantity;
134 + const labour = u.labour * q.quantity;
135 + const equipment = u.equipment * q.quantity;
136 + const direct = material + labour + equipment;
137 + const adjusted = material * loc.materialFactor + labour * loc.labourFactor + equipment * loc.equipmentFactor;
138 + const qUnc = QTY_UNCERTAINTY[q.source] ?? 0.1;
139 + const sigma = Math.sqrt((adjusted * u.sigmaPct) ** 2 + (adjusted * qUnc) ** 2);
140 + const conf = Math.round(100 * (0.6 * u.observedShare + 0.4 * (1 - Math.min(1, u.sigmaPct / 0.3))));
141 + lines.push({
142 + assemblyCode: asm.code, nameFr: asm.nameFr, nameEn: asm.nameEn, category: asm.category, quantity: q.quantity, unit: asm.unit, quantitySource: q.source, quantityFormula: q.formula,
143 + unitCost: u.direct, material: r0(material), labour: r0(labour), equipment: r0(equipment), direct: r0(direct), locationAdjustment: r0(adjusted - direct), adjusted: r0(adjusted),
144 + sigma: r0(sigma), observedShare: u.observedShare, confidence: conf, economicLife: asm.economicLife, conditionGroup: asm.conditionGroup, unitDetail: u,
145 + });
146 + }
147 + lines.sort((a, b) => (UX_CATEGORIES.find((c) => c.key === a.category)?.order ?? 99) - (UX_CATEGORIES.find((c) => c.key === b.category)?.order ?? 99) || a.nameFr.localeCompare(b.nameFr));
148 +
149 + const directMaterial = r0(lines.reduce((s, l) => s + l.material * loc.materialFactor, 0));
150 + const directLabour = r0(lines.reduce((s, l) => s + l.labour * loc.labourFactor, 0));
151 + const directEquipment = r0(lines.reduce((s, l) => s + l.equipment * loc.equipmentFactor, 0));
152 + // le direct est la somme exacte des trois composantes (les lignes sont arrondies individuellement)
153 + const directCost = directMaterial + directLabour + directEquipment;
154 +
155 + const categories: CategoryTotal[] = UX_CATEGORIES.filter((c) => lines.some((l) => l.category === c.key)).map((c) => {
156 + const ls = lines.filter((l) => l.category === c.key);
157 + const adjusted = ls.reduce((s, l) => s + l.adjusted, 0);
158 + return {
159 + category: c.key, labelFr: c.fr, labelEn: c.en,
160 + material: r0(ls.reduce((s, l) => s + l.material * loc.materialFactor, 0)), labour: r0(ls.reduce((s, l) => s + l.labour * loc.labourFactor, 0)), equipment: r0(ls.reduce((s, l) => s + l.equipment * loc.equipmentFactor, 0)),
161 + direct: r0(ls.reduce((s, l) => s + l.direct, 0)), adjusted: r0(adjusted), sharePct: directCost > 0 ? r2((100 * adjusted) / directCost) : 0,
162 + };
163 + });
164 +
165 + const p = input.params;
166 + const indirect = (Object.keys(INDIRECT_LABELS) as (keyof typeof INDIRECT_LABELS)[]).map((key) => ({ key, labelFr: INDIRECT_LABELS[key].fr, labelEn: INDIRECT_LABELS[key].en, pct: p.indirect[key], amount: r0((directCost * p.indirect[key]) / 100) }));
167 + const indirectCost = indirect.reduce((s, i) => s + i.amount, 0);
168 + const contractorOverhead = r0(((directCost + indirectCost) * p.overheadPct) / 100);
169 + const contractorProfit = r0(((directCost + indirectCost + contractorOverhead) * p.profitPct) / 100);
170 + const contingency = r0(((directCost + indirectCost) * p.contingencyPct) / 100);
171 + const replacementCostNew = r0(directCost + indirectCost + contractorOverhead + contractorProfit + contingency);
172 +
173 + // fourchette : composante aléatoire (racine des carrés des lignes) + systématique (localisation 3 %, modèle d'assemblages 5 %)
174 + const randomSigma = Math.sqrt(lines.reduce((s, l) => s + l.sigma ** 2, 0));
175 + const locUnc = loc.confidence >= 75 ? 0.02 : 0.04;
176 + const systematicSigma = directCost * Math.sqrt(locUnc ** 2 + 0.05 ** 2);
177 + const sigmaDirect = Math.sqrt(randomSigma ** 2 + systematicSigma ** 2);
178 + const scale = directCost > 0 ? replacementCostNew / directCost : 1;
179 + const sigma = sigmaDirect * scale;
180 + const range = { p10: r0(replacementCostNew - Z90 * sigma), p90: r0(replacementCostNew + Z90 * sigma), sigma: r0(sigma), low: r0(replacementCostNew - Z90 * sigma), high: r0(replacementCostNew + Z90 * sigma) };
181 +
182 + const gfa = Math.max(1, input.building.grossFloorAreaSqft);
183 + const perSqft = r2(replacementCostNew / gfa);
184 + const perM2 = r2(replacementCostNew / (gfa * 0.092903));
185 +
186 + const depreciation = computeDepreciation(input, lines, replacementCostNew, directCost, ctx);
187 + const landValue = input.land.value ?? 0;
188 + if (input.land.value == null) warnings.push("land_missing");
189 + const costApproachValue = r0(landValue + depreciation.depreciatedImprovementValue);
190 +
191 + const benchmarks = checkBenchmarks(input, ctx, perSqft);
192 + const materialObservedShare = directMaterial > 0 ? lines.reduce((s, l) => s + l.material * loc.materialFactor * l.observedShare, 0) / directMaterial : 0;
193 + const coverage = { materialObservedShare: r2(materialObservedShare), assembliesPriced: lines.length - unpriced, assembliesTotal: lines.length, pricingCoverage: lines.length ? r2((lines.length - unpriced) / lines.length) : 0 };
194 + const confidence = computeConfidence(input, lines, ctx, benchmarks, coverage, directLabour, directCost);
195 + if (unpriced) warnings.push(`${unpriced} assemblage(s) sans prix`);
196 +
197 + return {
198 + id: meta.id, createdAt: meta.createdAt, methodVersion: METHOD_VERSION, costDatabaseVersion: meta.costDatabaseVersion, assemblyVersion: meta.assemblyVersion, priceDate: ctx.asOf,
199 + input, location: loc, quantities, lines, categories,
200 + directCost: r0(directCost), directMaterial: r0(directMaterial), directLabour: r0(directLabour), directEquipment: r0(directEquipment),
201 + indirect, indirectCost: r0(indirectCost), contractorOverhead, contractorProfit, contingency, replacementCostNew, range, perSqft, perM2,
202 + depreciation, landValue: r0(landValue), costApproachValue, confidence, benchmarks, coverage, otherReadings: null, warnings,
203 + };
204 +}
205 +
206 +/* ----------------------------------------------------------- dépréciation */
207 +
208 +export function computeDepreciation(input: CostInput, lines: EstimateLine[], rcn: number, directCost: number, ctx: PricingContext): DepreciationResult {
209 + const d = input.depreciation;
210 + const year = Number(ctx.asOf.slice(0, 4));
211 + const chronologicalAge = input.building.yearBuilt && input.building.yearBuilt > 1600 ? Math.max(0, year - input.building.yearBuilt) : null;
212 + const economicLife = d.economicLife || BUILDING_TYPES.find((t) => t.key === input.building.type)?.economicLife || 60;
213 + const scale = directCost > 0 ? rcn / directCost : 1;
214 + let physical = 0;
215 + let physicalPct = 0;
216 + let effectiveAge: number | null = d.effectiveAge ?? chronologicalAge;
217 + const components: DepreciationComponentRow[] = [];
218 +
219 + if (d.method === "components") {
220 + for (const g of CONDITION_GROUPS) {
221 + const ls = lines.filter((l) => l.conditionGroup === g.key);
222 + if (!ls.length) continue;
223 + const groupRcn = ls.reduce((s, l) => s + l.adjusted, 0) * scale;
224 + const lifeW = ls.reduce((s, l) => s + (l.economicLife ?? g.economicLife) * l.adjusted, 0) / Math.max(1, ls.reduce((s, l) => s + l.adjusted, 0));
225 + const life = Math.max(5, Math.round(lifeW));
226 + const cond = d.componentConditions[g.key] ?? null;
227 + const rule = cond ? ctx.conditionRules.get(g.key)?.get(cond) : undefined;
228 + let effAge: number;
229 + let ruleTxt: string;
230 + if (cond && rule) {
231 + effAge = rule.effectiveAgeRatio * life;
232 + ruleTxt = `condition « ${cond} » → âge effectif = ${rule.effectiveAgeRatio} × vie ${life} ans`;
233 + } else if (d.effectiveAge != null && g.key === "structure") {
234 + effAge = Math.min(d.effectiveAge, life);
235 + ruleTxt = `âge effectif saisi (${d.effectiveAge} ans), plafonné à la vie de la composante`;
236 + } else {
237 + effAge = Math.min(chronologicalAge ?? 0, life);
238 + ruleTxt = chronologicalAge != null ? `âge chronologique (${chronologicalAge} ans) plafonné à la vie ${life} ans — aucune condition saisie` : "âge inconnu → 0";
239 + }
240 + const pct = Math.min(MAX_DEPRECIATION_PCT, life > 0 ? effAge / life : 0);
241 + const dep = groupRcn * pct;
242 + physical += dep;
243 + components.push({ conditionGroup: g.key, labelFr: g.fr, labelEn: g.en, rcn: r0(groupRcn), economicLife: life, condition: cond, effectiveAge: r2(effAge), depreciationPct: r2(pct * 100), depreciation: r0(dep), remaining: r0(groupRcn - dep), rule: ruleTxt });
244 + }
245 + physicalPct = rcn > 0 ? physical / rcn : 0;
246 + effectiveAge = r2(physicalPct * economicLife);
247 + } else {
248 + const ea = effectiveAge ?? 0;
249 + physicalPct = Math.min(MAX_DEPRECIATION_PCT, economicLife > 0 ? ea / economicLife : 0);
250 + physical = rcn * physicalPct;
251 + }
252 + const functionalCurable = d.functional.filter((f) => f.curable).reduce((s, f) => s + (f.costToCure || 0), 0);
253 + const functionalIncurable = d.functional.filter((f) => !f.curable).reduce((s, f) => s + (f.valueLoss || 0), 0);
254 + const functional = functionalCurable + functionalIncurable;
255 + const external = Math.max(0, d.externalValueLoss || 0);
256 + const total = Math.min(rcn, physical + functional + external);
257 + return {
258 + method: d.method, chronologicalAge, effectiveAge, economicLife, physicalPct: r2(physicalPct * 100), physical: r0(physical), components,
259 + functionalCurable: r0(functionalCurable), functionalIncurable: r0(functionalIncurable), functional: r0(functional), external: r0(external), total: r0(total),
260 + depreciatedImprovementValue: r0(Math.max(0, rcn - total)),
261 + };
262 +}
263 +
264 +/* -------------------------------------------------------------- confiance */
265 +
266 +function computeConfidence(input: CostInput, lines: EstimateLine[], ctx: PricingContext, benchmarks: BenchmarkCheck[], coverage: CostEstimate["coverage"], directLabour: number, directCost: number): ConfidenceBreakdown {
267 + const notesFr: string[] = [];
268 + const notesEn: string[] = [];
269 + // fraîcheur : âge moyen pondéré des observations
270 + let wAge = 0, wSum = 0;
271 + for (const l of lines) for (const c of l.unitDetail.components) {
272 + if (c.provenance.observedAt && (c.provenance.kind === "observed" || c.provenance.kind === "official")) {
273 + const age = (new Date(ctx.asOf).getTime() - new Date(c.provenance.observedAt).getTime()) / 86400000;
274 + const w = c.materialCost * l.quantity;
275 + wAge += age * w; wSum += w;
276 + }
277 + }
278 + const freshness = wSum > 0 ? Math.round(20 * Math.max(0, 1 - wAge / wSum / 180)) : 4;
279 + if (wSum === 0) { notesFr.push("Aucun prix observé récent : base de référence seulement."); notesEn.push("No recent observed price: reference base only."); }
280 + const cov = Math.round(20 * (0.7 * coverage.materialObservedShare + 0.3 * coverage.pricingCoverage));
281 + if (coverage.materialObservedShare < 0.5) { notesFr.push(`${Math.round(coverage.materialObservedShare * 100)} % du coût des matériaux repose sur des prix observés ; le reste sur des prix de référence (hypothèses).`); notesEn.push(`${Math.round(coverage.materialObservedShare * 100)} % of material cost rests on observed prices; the rest on reference prices (assumptions).`); }
282 + const location = ctx.location.confidence >= 75 ? 15 : ctx.location.confidence >= 55 ? 11 : 8;
283 + // main-d'œuvre : part des $ de main-d'œuvre appuyée sur une grille officielle
284 + let offLab = 0;
285 + for (const l of lines) for (const c of l.unitDetail.components) if (c.rateProvenance?.kind === "official") offLab += c.labourCost * l.quantity * ctx.location.labourFactor;
286 + const labour = directLabour > 0 ? Math.round(15 * Math.min(1, offLab / directLabour)) : 8;
287 + if (directLabour > 0 && offLab / directLabour < 0.9) { notesFr.push("Certains métiers utilisent le taux horaire de repli (aucune grille APCHQ/CCQ)."); notesEn.push("Some trades use the fallback hourly rate (no APCHQ/CCQ grid)."); }
288 + const withBench = benchmarks.filter((b) => b.status !== "unavailable");
289 + const bench = !withBench.length ? 5 : withBench.some((b) => b.status === "within") ? 10 : 3;
290 + if (!withBench.length) { notesFr.push("Aucun benchmark externe disponible pour ce type/marché."); notesEn.push("No external benchmark available for this type/market."); }
291 + else if (!withBench.some((b) => b.status === "within")) { notesFr.push("Écart important par rapport au benchmark externe."); notesEn.push("Large deviation from the external benchmark."); }
292 + const keys = ["grossFloorAreaSqft", "stories", "yearBuilt", "basement", "garage", "siding", "roof", "heating", "bathrooms", "kitchenQuality"];
293 + const known = keys.filter((k) => { const s = input.attributeSources[k]; return s && s !== "assumed" && s !== "derived"; }).length;
294 + const building = Math.round(20 * (known / keys.length));
295 + if (known < 7) { notesFr.push(`${keys.length - known} caractéristiques du bâtiment sur ${keys.length} sont supposées par défaut.`); notesEn.push(`${keys.length - known} of ${keys.length} building attributes are default assumptions.`); }
296 + const total = Math.max(0, Math.min(100, freshness + cov + location + labour + bench + building));
297 + const letter = total >= 80 ? "A" : total >= 65 ? "B" : total >= 50 ? "C" : "D";
298 + void directCost;
299 + return { freshness, coverage: cov, location, labour, benchmarks: bench, building, total, letter, notesFr, notesEn };
300 +}
301 +
302 +/* ------------------------------------------------------------- benchmarks */
303 +
304 +export function benchmarkTypeFor(t: CostInput["building"]["type"]): string {
305 + switch (t) {
306 + case "detached": case "chalet": return "single_family";
307 + case "semi_detached": case "row": return "townhouse";
308 + case "plex": return "plex";
309 + case "condo": return "condo";
310 + default: return "single_family";
311 + }
312 +}
313 +
314 +export function checkBenchmarks(input: CostInput, ctx: PricingContext, perSqft: number): BenchmarkCheck[] {
315 + const bt = benchmarkTypeFor(input.building.type);
316 + const market = ctx.location.code === "QC-QUE" || ctx.location.code === "QC-LEV" ? "Québec" : ctx.location.code === "QC-GAT" ? "Ottawa-Gatineau" : "Montréal";
317 + const rows = ctx.benchmarks.filter((b) => b.buildingType === bt);
318 + const chosen = rows.filter((b) => b.market === market);
319 + const use = chosen.length ? chosen : rows.filter((b) => b.market === "Montréal");
320 + return use.map((b) => {
321 + const within = perSqft >= b.low && perSqft <= b.high;
322 + const status: BenchmarkCheck["status"] = within ? "within" : perSqft < b.low ? "below" : "above";
323 + const ref = within ? null : perSqft < b.low ? b.low : b.high;
324 + return { source: b.source, buildingType: b.buildingType, market: b.market, unit: b.unit, low: b.low, high: b.high, midpoint: b.midpoint, year: b.year, estimatePerUnit: perSqft, status, deviationPct: ref ? r2(((perSqft - ref) / ref) * 100) : 0, notes: b.notes ? `${b.notes}${b.market !== market ? " — marché de substitution (Montréal)" : ""}` : b.market !== market ? "marché de substitution (Montréal)" : null };
325 + });
326 +}
327 +
328 +/** Libellé de catégorie (réexport pratique). */
329 +export const catLabel = (k: UxCategory, fr: boolean) => categoryLabel(k, fr);
added src/lib/cost/estimate-defaults.ts +22 −0
@@ -0,0 +1,22 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/** Entrées par défaut de la méthode du coût — module PUR (client + serveur + tests). */
3 +import { DEFAULT_PARAMS } from "./taxonomy";
4 +import type { BuildingSpec, CostInput } from "./types";
5 +
6 +export function defaultBuilding(): BuildingSpec {
7 + return {
8 + type: "detached", quality: "standard", grossFloorAreaSqft: 1800, footprintSqft: null, stories: 2, yearBuilt: null, basement: "unfinished", basementFinishedPct: 0,
9 + garage: { type: "none", spaces: 0, areaSqft: null }, structure: "wood_frame", foundation: "poured_concrete", siding: { vinyl: 1 }, roof: "asphalt_shingle", roofGeometry: "gable", roofPitch: 6,
10 + windows: "pvc", windowCount: null, heating: "electric_baseboard", hasAirConditioning: false, hasAirExchanger: true, kitchens: 1, kitchenQuality: "standard", bathrooms: 1, powderRooms: 0, bathroomQuality: "standard",
11 + bedrooms: null, flooring: {}, deckSqft: 0, drivewaySqft: 0, driveway: "none", fenceLinFt: 0, landscapingSqft: 0, pool: "none", units: 1,
12 + };
13 +}
14 +
15 +export function defaultInput(): CostInput {
16 + return {
17 + mode: "construction", propertyId: null, listingUid: null, address: null, municipality: null, lat: null, lng: null, locationCode: null,
18 + building: defaultBuilding(), quantityOverrides: {}, excludedAssemblies: [], params: JSON.parse(JSON.stringify(DEFAULT_PARAMS)),
19 + depreciation: { method: "age_life", effectiveAge: null, economicLife: 60, effectiveAgeSource: "derived", componentConditions: {}, functional: [], externalValueLoss: 0, externalNote: "" },
20 + land: { value: null, source: "none", rollYear: null, method: "" }, attributeSources: {}, roll: null, priceDate: null,
21 + };
22 +}
added src/lib/cost/estimate.ts +218 −0
@@ -0,0 +1,218 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Service d'estimation par la méthode du coût (serveur) :
4 + * - préremplissage depuis une propriété du rôle (MAMH) ;
5 + * - entrée par défaut (mode construction) ;
6 + * - exécution (contexte de prix → quantités → moteur) + sauvegarde
7 + * reproductible (estimation, lignes, instantané des prix) ;
8 + * - relecture.
9 + */
10 +import { randomUUID } from "crypto";
11 +import { getUnit, type UnitRow } from "../db";
12 +import { estimateByUnitId } from "../estimator";
13 +import { costDatabaseVersion, getCostDb } from "./db";
14 +import { buildContext, today } from "./catalog";
15 +import { computeEstimate } from "./engine";
16 +import { deriveQuantities } from "./geometry";
17 +import { ASSEMBLY_VERSION } from "./seed/assemblies";
18 +import { BUILDING_TYPES } from "./taxonomy";
19 +import { sqftFromM2 } from "./units";
20 +import type { AttributeSource, BuildingSpec, BuildingType, CostEstimate, CostInput, Quality } from "./types";
21 +import { defaultInput } from "./estimate-defaults";
22 +
23 +export { defaultBuilding, defaultInput } from "./estimate-defaults";
24 +
25 +/* ------------------------------------------------- préremplissage MAMH */
26 +
27 +function buildingTypeFromUnit(u: UnitRow): BuildingType {
28 + switch (u.type_prop) {
29 + case "plex": return "plex";
30 + case "condo_ou_multi": return (u.nb_logements ?? 1) > 1 && (u.aire_etages_m2 ?? 0) > 400 ? "plex" : "condo";
31 + case "chalet": return "chalet";
32 + case "maison_mobile": return "mobile";
33 + case "unifamilial": {
34 + const lien = (u.lien_physique ?? "").toLowerCase();
35 + if (lien.includes("jumel")) return "semi_detached";
36 + if (lien.includes("rang")) return "row";
37 + if (lien.includes("intégré") || lien.includes("integre")) return "row";
38 + return "detached";
39 + }
40 + default: return "other";
41 + }
42 +}
43 +
44 +export interface PropertyPrefill {
45 + input: CostInput;
46 + unit: { id: string; adresse: string | null; municipalite: string | null; typeProp: string; genre: string | null; lien: string | null; cubf: number | null; cubfLibelle: string | null; nbLogements: number | null; aireM2: number | null; terrainM2: number | null };
47 + missing: string[]; // champs à saisir manuellement
48 +}
49 +
50 +/** Préremplit une entrée de coût depuis une unité d'évaluation (source MAMH). */
51 +export function prefillFromProperty(id: string): PropertyPrefill | null {
52 + const u = getUnit(id);
53 + if (!u) return null;
54 + const input = defaultInput();
55 + const src: Partial<Record<string, AttributeSource>> = {};
56 + const missing: string[] = [];
57 + input.mode = "property";
58 + input.propertyId = u.id_provinc;
59 + input.address = [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", ") || null;
60 + input.municipality = u.municipalite;
61 + input.lat = u.lat;
62 + input.lng = u.lng;
63 + const b = input.building;
64 + b.type = buildingTypeFromUnit(u);
65 + src.type = "MAMH";
66 + if (u.aire_etages_m2 && u.aire_etages_m2 > 15) { b.grossFloorAreaSqft = Math.round(sqftFromM2(u.aire_etages_m2)); src.grossFloorAreaSqft = "MAMH"; } else { missing.push("grossFloorAreaSqft"); src.grossFloorAreaSqft = "assumed"; }
67 + if (u.nb_etages && u.nb_etages > 0) { b.stories = Math.max(1, Math.round(u.nb_etages)); src.stories = "MAMH"; } else {
68 + const g = (u.genre_construction ?? "").toLowerCase();
69 + b.stories = g.includes("plain-pied") || g.includes("unimodulaire") ? 1 : g.includes("étages entiers") ? 2 : g.includes("mansard") ? 1.5 : g.includes("décal") ? 1.5 : 1;
70 + src.stories = g ? "derived" : "assumed";
71 + if (!g) missing.push("stories");
72 + }
73 + if (b.stories === 1.5) b.stories = 2; // étage mansardé / niveaux décalés : 2 niveaux de structure
74 + if (u.annee_construction && u.annee_construction > 1600) { b.yearBuilt = u.annee_construction; src.yearBuilt = u.annee_estimee === "E" ? "derived" : "MAMH"; } else missing.push("yearBuilt");
75 + b.units = Math.max(1, u.nb_logements ?? 1);
76 + src.units = u.nb_logements ? "MAMH" : "assumed";
77 + // hypothèses par défaut (à confirmer par l'utilisateur) — marquées « assumed »
78 + for (const k of ["basement", "garage", "siding", "roof", "heating", "bathrooms", "kitchenQuality", "quality", "windows", "foundation"]) { src[k] = "assumed"; missing.push(k); }
79 + if (b.type === "plex") { b.kitchens = b.units; b.bathrooms = b.units; b.quality = "standard"; }
80 + if (b.type === "mobile") { b.foundation = "piers"; b.basement = "none"; b.stories = 1; }
81 + if (b.type === "condo") { b.basement = "none"; }
82 + if (b.grossFloorAreaSqft > 2600 && b.type === "detached") { b.bathrooms = 2; b.powderRooms = 1; }
83 + if (b.type === "chalet") { b.quality = "standard"; }
84 + // terrain et rôle
85 + input.roll = { landValue: u.valeur_terrain, buildingValue: u.valeur_batiment, totalValue: u.valeur_role, year: 2026 };
86 + if (u.valeur_terrain && u.valeur_terrain > 0) input.land = { value: u.valeur_terrain, source: "role", rollYear: 2026, method: "Valeur du terrain au rôle d'évaluation foncière 2026 (MAMH)" };
87 + else { input.land = { value: null, source: "none", rollYear: null, method: "" }; missing.push("landValue"); }
88 + input.depreciation.economicLife = BUILDING_TYPES.find((t) => t.key === b.type)?.economicLife ?? 60;
89 + input.attributeSources = src;
90 + return {
91 + input,
92 + unit: { id: u.id_provinc, adresse: u.adresse, municipalite: u.municipalite, typeProp: u.type_prop, genre: u.genre_construction, lien: u.lien_physique, cubf: u.cubf, cubfLibelle: u.cubf_libelle, nbLogements: u.nb_logements, aireM2: u.aire_etages_m2, terrainM2: u.superficie_terrain_m2 },
93 + missing,
94 + };
95 +}
96 +
97 +/* --------------------------------------------------------------- exécution */
98 +
99 +export interface RunOptions {
100 + save?: boolean;
101 + sessionId?: string | null;
102 + otherReadings?: CostEstimate["otherReadings"];
103 +}
104 +
105 +export function runEstimate(input: CostInput, opts: RunOptions = {}): CostEstimate {
106 + const d = getCostDb();
107 + const asOf = input.priceDate ?? today();
108 + const ctx = buildContext({ asOf, locationCode: input.locationCode, municipality: input.municipality, lat: input.lat, lng: input.lng }, d);
109 + const derived = deriveQuantities(input.building, input.quantityOverrides, input.mode === "listing" ? "AI" : "user", input.excludedAssemblies);
110 + const id = randomUUID();
111 + const est = computeEstimate(input, derived.lines, ctx, { id, createdAt: new Date().toISOString(), costDatabaseVersion: costDatabaseVersion(d), assemblyVersion: String(ASSEMBLY_VERSION) });
112 + est.warnings.push(...derived.warnings, `localisation : ${ctx.locationMethod}`);
113 + est.otherReadings = opts.otherReadings ?? (input.propertyId ? otherReadingsForProperty(input.propertyId) : null);
114 + if (opts.save !== false) saveEstimate(est, ctx, opts.sessionId ?? null);
115 + return est;
116 +}
117 +
118 +/** Les autres lectures de la valeur pour une propriété du rôle (hédonique, comparables, hybride, rôle). */
119 +export function otherReadingsForProperty(id: string): CostEstimate["otherReadings"] {
120 + try {
121 + const e = estimateByUnitId(id);
122 + if (!e?.unit) return null;
123 + return { hedonic: e.result.modelEstimate ?? null, comparables: e.result.compsEstimate ?? null, hybrid: e.result.estimate ?? null, askingPrice: null, rollValue: e.unit.valeurRole, rollBuilding: e.unit.specs.valeurBatiment, rollLand: e.unit.specs.valeurTerrain };
124 + } catch {
125 + return null;
126 + }
127 +}
128 +
129 +function saveEstimate(est: CostEstimate, ctx: ReturnType<typeof buildContext>, sessionId: string | null): void {
130 + const d = getCostDb();
131 + const tx = d.transaction(() => {
132 + d.prepare(`INSERT INTO cost_estimates(id,property_id,listing_uid,user_session_id,mode,location_code,municipality,estimate_date,price_date,building_type,quality_level,area_sqft,direct_cost,indirect_cost,contractor_overhead,contractor_profit,contingency,replacement_cost_new,rcn_low,rcn_high,physical_depreciation,functional_obsolescence,external_obsolescence,depreciated_improvement_value,land_value,cost_approach_value,confidence_score,confidence_letter,method_version,cost_database_version,assembly_version,input_json,result_json,assumptions_json)
133 + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
134 + est.id, est.input.propertyId, est.input.listingUid, sessionId, est.input.mode, est.location.code, est.input.municipality, est.createdAt, est.priceDate, est.input.building.type, est.input.building.quality, est.input.building.grossFloorAreaSqft,
135 + est.directCost, est.indirectCost, est.contractorOverhead, est.contractorProfit, est.contingency, est.replacementCostNew, est.range.p10, est.range.p90, est.depreciation.physical, est.depreciation.functional, est.depreciation.external,
136 + est.depreciation.depreciatedImprovementValue, est.landValue, est.costApproachValue, est.confidence.total, est.confidence.letter, est.methodVersion, est.costDatabaseVersion, est.assemblyVersion,
137 + JSON.stringify(est.input), JSON.stringify(stripForStorage(est)), JSON.stringify({ params: est.input.params, depreciation: est.input.depreciation, land: est.input.land, attributeSources: est.input.attributeSources, warnings: est.warnings }),
138 + );
139 + const ins = d.prepare(`INSERT INTO cost_estimate_lines(estimate_id,assembly_code,category,quantity,unit,material_cost,labour_cost,equipment_cost,direct_cost,location_adjustment,adjusted_cost,source_summary,confidence_score,calculation_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
140 + for (const l of est.lines) {
141 + ins.run(est.id, l.assemblyCode, l.category, l.quantity, l.unit, l.material, l.labour, l.equipment, l.direct, l.locationAdjustment, l.adjusted,
142 + [...new Set(l.unitDetail.components.map((c) => c.provenance.source))].join(" · "), l.confidence, JSON.stringify({ formula: l.quantityFormula, quantitySource: l.quantitySource, unitCost: l.unitCost, sigma: l.sigma, components: l.unitDetail.components }));
143 + }
144 + const used = new Set(est.lines.flatMap((l) => l.unitDetail.components.map((c) => c.itemCode).filter(Boolean)));
145 + const prices = Object.fromEntries([...ctx.prices.entries()].filter(([k]) => used.has(k)));
146 + d.prepare("INSERT INTO cost_estimate_snapshots(estimate_id,snapshot_date,prices_json,labour_json,location_json) VALUES(?,?,?,?,?)")
147 + .run(est.id, est.priceDate, JSON.stringify(prices), JSON.stringify(Object.fromEntries(ctx.labour)), JSON.stringify(ctx.location));
148 + });
149 + tx();
150 +}
151 +
152 +/** Le résultat complet est stocké tel quel (JSON) — sans doublons volumineux. */
153 +function stripForStorage(est: CostEstimate): CostEstimate {
154 + return est;
155 +}
156 +
157 +export function getEstimate(id: string): CostEstimate | null {
158 + const r = getCostDb().prepare("SELECT result_json FROM cost_estimates WHERE id=?").get(id) as { result_json: string } | undefined;
159 + if (!r) return null;
160 + return JSON.parse(r.result_json) as CostEstimate;
161 +}
162 +
163 +export function recentEstimatesForProperty(propertyId: string, limit = 5): { id: string; createdAt: string; rcn: number; value: number; letter: string }[] {
164 + return (getCostDb().prepare("SELECT id, created_at, replacement_cost_new rcn, cost_approach_value value, confidence_letter letter FROM cost_estimates WHERE property_id=? ORDER BY created_at DESC LIMIT ?").all(propertyId, limit) as Record<string, unknown>[])
165 + .map((r) => ({ id: String(r.id), createdAt: String(r.created_at), rcn: Number(r.rcn), value: Number(r.value), letter: String(r.letter) }));
166 +}
167 +
168 +/** Normalise/valide une entrée reçue du client (bornes, valeurs par défaut). */
169 +export function sanitizeInput(raw: unknown): CostInput {
170 + const base = defaultInput();
171 + if (!raw || typeof raw !== "object") return base;
172 + const r = raw as Partial<CostInput>;
173 + const b: BuildingSpec = { ...base.building, ...(r.building ?? {}) };
174 + b.grossFloorAreaSqft = clamp(Number(b.grossFloorAreaSqft) || 1800, 200, 30000);
175 + b.footprintSqft = b.footprintSqft != null ? clamp(Number(b.footprintSqft), 100, 20000) : null;
176 + b.stories = clamp(Number(b.stories) || 1, 1, 6);
177 + b.units = clamp(Number(b.units) || 1, 1, 12);
178 + b.kitchens = clamp(Number(b.kitchens) || 1, 0, 12);
179 + b.bathrooms = clamp(Number(b.bathrooms) || 0, 0, 12);
180 + b.powderRooms = clamp(Number(b.powderRooms) || 0, 0, 6);
181 + b.roofPitch = clamp(Number(b.roofPitch) || 6, 0, 18);
182 + b.deckSqft = clamp(Number(b.deckSqft) || 0, 0, 3000);
183 + b.drivewaySqft = clamp(Number(b.drivewaySqft) || 0, 0, 10000);
184 + b.fenceLinFt = clamp(Number(b.fenceLinFt) || 0, 0, 2000);
185 + b.landscapingSqft = clamp(Number(b.landscapingSqft) || 0, 0, 40000);
186 + b.basementFinishedPct = clamp(Number(b.basementFinishedPct) || 0, 0, 1);
187 + b.garage = { type: b.garage?.type ?? "none", spaces: clamp(Number(b.garage?.spaces) || 0, 0, 4), areaSqft: b.garage?.areaSqft != null ? clamp(Number(b.garage.areaSqft), 100, 2000) : null };
188 + b.yearBuilt = b.yearBuilt != null && Number(b.yearBuilt) > 1600 ? Math.round(Number(b.yearBuilt)) : null;
189 + b.windowCount = b.windowCount != null ? clamp(Math.round(Number(b.windowCount)), 0, 200) : null;
190 + b.siding = Object.fromEntries(Object.entries(b.siding ?? {}).filter(([, v]) => typeof v === "number" && v > 0));
191 + b.flooring = Object.fromEntries(Object.entries(b.flooring ?? {}).filter(([, v]) => typeof v === "number" && v > 0));
192 + const q = (x: unknown): Quality => (["economy", "standard", "superior", "prestige"].includes(String(x)) ? (x as Quality) : "standard");
193 + b.quality = q(b.quality); b.kitchenQuality = q(b.kitchenQuality); b.bathroomQuality = q(b.bathroomQuality);
194 + const params = { indirect: { ...base.params.indirect, ...(r.params?.indirect ?? {}) }, overheadPct: clamp(Number(r.params?.overheadPct ?? base.params.overheadPct), 0, 40), profitPct: clamp(Number(r.params?.profitPct ?? base.params.profitPct), 0, 40), contingencyPct: clamp(Number(r.params?.contingencyPct ?? base.params.contingencyPct), 0, 40) };
195 + for (const k of Object.keys(params.indirect) as (keyof typeof params.indirect)[]) params.indirect[k] = clamp(Number(params.indirect[k]) || 0, 0, 30);
196 + const dep = { ...base.depreciation, ...(r.depreciation ?? {}) };
197 + dep.economicLife = clamp(Number(dep.economicLife) || 60, 10, 150);
198 + dep.effectiveAge = dep.effectiveAge != null && dep.effectiveAge !== ("" as unknown) ? clamp(Number(dep.effectiveAge), 0, 200) : null;
199 + dep.functional = Array.isArray(dep.functional) ? dep.functional.slice(0, 20).map((f) => ({ id: String(f.id ?? randomUUID()), type: String(f.type ?? ""), curable: !!f.curable, costToCure: clamp(Number(f.costToCure) || 0, 0, 5e6), valueLoss: clamp(Number(f.valueLoss) || 0, 0, 5e6), notes: String(f.notes ?? "").slice(0, 500) })) : [];
200 + dep.externalValueLoss = clamp(Number(dep.externalValueLoss) || 0, 0, 5e6);
201 + dep.componentConditions = dep.componentConditions ?? {};
202 + const land = { ...base.land, ...(r.land ?? {}) };
203 + land.value = land.value != null && land.value !== ("" as unknown) ? clamp(Number(land.value), 0, 5e7) : null;
204 + const overrides: Record<string, number> = {};
205 + for (const [k, v] of Object.entries(r.quantityOverrides ?? {})) if (typeof v === "number" && v >= 0 && /^[A-Z0-9/.-]+$/.test(k)) overrides[k] = clamp(v, 0, 1e6);
206 + return {
207 + ...base, ...r, building: b, params, depreciation: dep, land, quantityOverrides: overrides,
208 + excludedAssemblies: Array.isArray(r.excludedAssemblies) ? r.excludedAssemblies.filter((x) => typeof x === "string").slice(0, 100) : [],
209 + attributeSources: r.attributeSources ?? {}, priceDate: r.priceDate && /^\d{4}-\d{2}-\d{2}$/.test(r.priceDate) ? r.priceDate : null,
210 + mode: r.mode === "property" || r.mode === "listing" ? r.mode : "construction",
211 + propertyId: r.propertyId ? String(r.propertyId).slice(0, 40) : null, listingUid: r.listingUid ? String(r.listingUid).slice(0, 80) : null,
212 + municipality: r.municipality ? String(r.municipality).slice(0, 120) : null, address: r.address ? String(r.address).slice(0, 200) : null,
213 + lat: typeof r.lat === "number" ? r.lat : null, lng: typeof r.lng === "number" ? r.lng : null, locationCode: r.locationCode ? String(r.locationCode).slice(0, 20) : null,
214 + roll: r.roll ?? null,
215 + };
216 +}
217 +
218 +const clamp = (v: number, lo: number, hi: number) => (Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : lo);
added src/lib/cost/geometry.ts +338 −0
@@ -0,0 +1,338 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Moteur de géométrie et de quantités : description du bâtiment → quantités
4 + * d'assemblages, chaque ligne accompagnée de sa formule. Module PUR.
5 + *
6 + * Principe (§193) : tout ce qu'une formule explicite peut calculer est calculé
7 + * ici, jamais par l'IA. Les constantes (hauteur d'étage 9 pi, rapport de forme
8 + * 1,5, 1 fenêtre de 10 pi² par 90 pi² de plancher…) sont des HYPOTHÈSES de
9 + * prise de quantités résidentielle, affichées dans chaque formule.
10 + *
11 + * Coût de REMPLACEMENT (et non de reproduction) : le bâtiment est reconstruit
12 + * avec les composantes modernes équivalentes (2 × 6 R24, R50 au comble,
13 + * poutrelles ajourées), quel que soit son année de construction.
14 + */
15 +import type { BuildingSpec, Quality, QuantityLine, AttributeSource } from "./types";
16 +import { KITCHEN_LINEAR_FT } from "./taxonomy";
17 +
18 +export const GEOMETRY_VERSION = "1.0";
19 +const FT3_TO_M3 = 0.0283168;
20 +export const STORY_HEIGHT_FT = 9; // 8 pi de plafond + structure de plancher
21 +export const ASPECT_RATIO = 1.5; // rectangle longueur/largeur
22 +export const WINDOW_SQFT = 10; // fenêtre type ~30 × 48 po
23 +export const WINDOW_PER_SQFT_FLOOR = 0.011; // ≈ 11 % de la surface de plancher vitrée
24 +export const PARTITION_LF_PER_SQFT = 0.09; // pi lin de cloison par pi² de plancher
25 +export const BASEMENT_WALL_FT = 8;
26 +export const GARAGE_WALL_FT = 10;
27 +
28 +export interface Geometry {
29 + footprintSqft: number;
30 + widthFt: number;
31 + lengthFt: number;
32 + perimeterFt: number;
33 + exposedPerimeterFt: number; // murs extérieurs non mitoyens
34 + exposedShare: number;
35 + wallHeightFt: number;
36 + grossWallSqft: number;
37 + gableSqft: number;
38 + windowCount: number;
39 + extDoors: number;
40 + patioDoors: number;
41 + openingsSqft: number;
42 + netWallSqft: number;
43 + roofSlopeFactor: number;
44 + roofSqft: number;
45 + basementFloorSqft: number;
46 + basementFinishedSqft: number;
47 + garageSqft: number;
48 + garagePerimeterFt: number;
49 + garageExposedPerimeterFt: number;
50 +}
51 +
52 +const r1 = (x: number) => Math.round(x * 10) / 10;
53 +const r0 = (x: number) => Math.round(x);
54 +
55 +export function computeGeometry(b: BuildingSpec): Geometry {
56 + const stories = Math.max(1, b.stories || 1);
57 + const footprint = b.footprintSqft && b.footprintSqft > 100 ? b.footprintSqft : b.grossFloorAreaSqft / stories;
58 + const width = Math.sqrt(footprint / ASPECT_RATIO);
59 + const length = ASPECT_RATIO * width;
60 + const perimeter = 2 * (width + length);
61 + const exposedShare = b.type === "semi_detached" ? 1 - length / perimeter : b.type === "row" || b.type === "condo" ? 1 - (2 * length) / perimeter : 1;
62 + const exposedPerimeter = perimeter * exposedShare;
63 + const grossWall = exposedPerimeter * STORY_HEIGHT_FT * stories;
64 + const pitch = b.roofGeometry === "flat" ? 0 : b.roofPitch || 6;
65 + const gable = b.roofGeometry === "gable" && b.type !== "condo" ? 2 * ((width * width * pitch) / 24) * (b.type === "detached" || b.type === "chalet" ? 1 : 0.5) : 0;
66 + const windowCount = b.windowCount ?? Math.max(4, Math.round(b.grossFloorAreaSqft * WINDOW_PER_SQFT_FLOOR));
67 + const extDoors = b.type === "row" || b.type === "condo" ? 1 : 2;
68 + const patioDoors = b.type === "condo" ? 1 : b.deckSqft > 0 || b.quality !== "economy" ? 1 : 0;
69 + const openings = windowCount * WINDOW_SQFT + extDoors * 20 + patioDoors * 40;
70 + const slope = Math.sqrt(1 + (pitch / 12) ** 2);
71 + const complexity = b.roofGeometry === "hip" ? 1.05 : b.roofGeometry === "complex" ? 1.12 : b.roofGeometry === "mansard" ? 1.15 : 1;
72 + const garageSqft = b.garage.type === "none" || b.garage.type === "carport" ? 0 : b.garage.areaSqft ?? (b.garage.spaces >= 3 ? 700 : b.garage.spaces === 2 ? 480 : 264);
73 + const roofFootprint = footprint + (b.garage.type === "attached" ? garageSqft : 0);
74 + const roofSqft = b.type === "condo" ? 0 : roofFootprint * slope * 1.1 * complexity;
75 + const basementFloor = b.basement === "none" || b.basement === "crawl" ? 0 : footprint * 0.9;
76 + const finishedPct = b.basement === "finished" || b.basement === "walkout" ? Math.max(b.basementFinishedPct, 0.9) : b.basement === "partial" ? Math.max(b.basementFinishedPct, 0.5) : b.basementFinishedPct;
77 + const gw = Math.sqrt(garageSqft / 1.1);
78 + const garagePerimeter = garageSqft ? 2 * (gw + 1.1 * gw) : 0;
79 + const garageExposed = b.garage.type === "detached" ? garagePerimeter : b.garage.type === "attached" ? garagePerimeter * 0.75 : b.garage.type === "integrated" ? garagePerimeter * 0.5 : 0;
80 + return {
81 + footprintSqft: r0(footprint), widthFt: r1(width), lengthFt: r1(length), perimeterFt: r1(perimeter), exposedPerimeterFt: r1(exposedPerimeter), exposedShare: r1(exposedShare * 100) / 100,
82 + wallHeightFt: STORY_HEIGHT_FT, grossWallSqft: r0(grossWall), gableSqft: r0(gable), windowCount, extDoors, patioDoors, openingsSqft: r0(openings),
83 + netWallSqft: r0(Math.max(0, grossWall + gable - openings)), roofSlopeFactor: r1(slope * 100) / 100, roofSqft: r0(roofSqft),
84 + basementFloorSqft: r0(basementFloor), basementFinishedSqft: r0(basementFloor * finishedPct),
85 + garageSqft: r0(garageSqft), garagePerimeterFt: r1(garagePerimeter), garageExposedPerimeterFt: r1(garageExposed),
86 + };
87 +}
88 +
89 +/** Répartition de plancher par défaut selon la qualité (si non fournie). */
90 +export function defaultFlooring(q: Quality): BuildingSpec["flooring"] {
91 + switch (q) {
92 + case "economy": return { vinyl_plank: 0.7, laminate: 0.2, ceramic: 0.1 };
93 + case "standard": return { engineered: 0.5, vinyl_plank: 0.35, ceramic: 0.15 };
94 + case "superior": return { hardwood: 0.6, ceramic: 0.25, vinyl_plank: 0.15 };
95 + default: return { hardwood: 0.65, ceramic: 0.35 };
96 + }
97 +}
98 +
99 +/**
100 + * Quantités d'assemblages à partir de la description du bâtiment.
101 + * `overrides` remplace la quantité d'un assemblage (source « user »/« AI »/« listing »).
102 + */
103 +export function deriveQuantities(b: BuildingSpec, overrides: Record<string, number> = {}, overrideSource: AttributeSource = "user", excluded: string[] = []): { geometry: Geometry; lines: QuantityLine[]; warnings: string[] } {
104 + const g = computeGeometry(b);
105 + const warnings: string[] = [];
106 + const lines: QuantityLine[] = [];
107 + const stories = Math.max(1, b.stories || 1);
108 + const GFA = b.grossFloorAreaSqft;
109 + const F = g.footprintSqft;
110 + const P = g.perimeterFt;
111 + const Pe = g.exposedPerimeterFt;
112 + const units = Math.max(1, b.units || 1);
113 + const finished = g.basementFinishedSqft;
114 + const livable = GFA + finished;
115 +
116 + const add = (code: string, qty: number, formula: string, inputs: Record<string, number | string> = {}, unit: QuantityLine["unit"] = "pi2") => {
117 + if (excluded.includes(code)) return;
118 + if (!(qty > 0)) return;
119 + lines.push({ assemblyCode: code, quantity: Math.round(qty * 100) / 100, unit, source: "derived", formula, inputs });
120 + };
121 +
122 + const isCondo = b.type === "condo";
123 + if (isCondo) warnings.push("condo_common_areas");
124 +
125 + /* ---------------------------------------------------------- site / fondations */
126 + if (!isCondo) {
127 + const hasBasement = g.basementFloorSqft > 0;
128 + const depthFt = hasBasement ? 9 : b.basement === "crawl" ? 5 : b.foundation === "piers" ? 0 : 4.5;
129 + if (depthFt > 0) {
130 + const excM3 = (F + 3 * P) * depthFt * FT3_TO_M3;
131 + add("SITE-EXCAV-BASEMENT", excM3, "(empreinte + 3 pi × périmètre) × profondeur × 0,0283 m³/pi³", { empreinte_pi2: F, perimetre_pi: P, profondeur_pi: depthFt }, "m3");
132 + add("SITE-BACKFILL", P * 3 * (depthFt - 1) * FT3_TO_M3, "périmètre × 3 pi × (profondeur − 1 pi) × 0,0283", { perimetre_pi: P, profondeur_pi: depthFt }, "m3");
133 + add("SITE-DRAIN-FRENCH", P, "périmètre de fondation", { perimetre_pi: P }, "pi_lin");
134 + }
135 + add("SITE-SERVICE-CONNECTIONS", 1, "1 branchement par bâtiment", {}, "lump");
136 + if (b.foundation === "piers") {
137 + add("FND-PIER", Math.ceil(F / 64), "empreinte ÷ 64 pi² (grille 8 × 8 pi)", { empreinte_pi2: F }, "unit");
138 + } else {
139 + add("FND-FOOTING", P, "périmètre de fondation", { perimetre_pi: P }, "pi_lin");
140 + const wallH = hasBasement ? BASEMENT_WALL_FT : b.basement === "crawl" ? 4 : 4;
141 + const wallCode = b.foundation === "concrete_block" || b.foundation === "stone" ? "FND-WALL-BLOCK-8IN" : "FND-WALL-POURED-8IN";
142 + add(wallCode, P * wallH, `périmètre × hauteur de mur (${wallH} pi)`, { perimetre_pi: P, hauteur_pi: wallH });
143 + add("FND-DAMPPROOF-MEMBRANE", P * Math.max(0, wallH - 1), "périmètre × (hauteur − 1 pi hors sol)", { perimetre_pi: P, hauteur_pi: wallH });
144 + if (hasBasement) {
145 + add("FND-SLAB-BASEMENT-4IN", F, "empreinte du bâtiment", { empreinte_pi2: F });
146 + add("OPN-WINDOW-BASEMENT", Math.max(2, Math.round(P / 25)), "périmètre ÷ 25 pi", { perimetre_pi: P }, "unit");
147 + } else if (b.basement === "none") {
148 + add("FND-SLAB-ON-GRADE-INSULATED", F, "empreinte du bâtiment (dalle sur sol)", { empreinte_pi2: F });
149 + }
150 + }
151 + }
152 +
153 + /* ------------------------------------------------------------- structure */
154 + const floorsSqft = isCondo ? GFA : b.basement === "none" && b.foundation !== "piers" ? Math.max(0, GFA - F) : GFA;
155 + add("STR-FLOOR-IJOIST", floorsSqft, isCondo ? "aire de l'unité" : b.basement === "none" ? "aire d'étages − empreinte (rez-de-chaussée sur dalle)" : "aire d'étages (planchers portés)", { aire_etages_pi2: GFA, empreinte_pi2: F });
156 + add("STR-WALL-2X6-EXT", g.grossWallSqft + g.gableSqft, "périmètre exposé × 9 pi × étages + pignons", { perimetre_expose_pi: Pe, etages: stories, pignons_pi2: g.gableSqft });
157 + const partitionsLf = livable * PARTITION_LF_PER_SQFT;
158 + add("STR-WALL-2X4-INT", partitionsLf * STORY_HEIGHT_FT, "(aire habitable × 0,09 pi lin/pi²) × 9 pi", { aire_habitable_pi2: livable, cloisons_pi_lin: Math.round(partitionsLf) });
159 + if (!isCondo) {
160 + add("STR-BEAM-LVL", g.lengthFt, "longueur du bâtiment (poutre centrale)", { longueur_pi: g.lengthFt }, "pi_lin");
161 + add("STR-COLUMN-STEEL", Math.max(2, Math.round(g.lengthFt / 10)), "longueur ÷ 10 pi", { longueur_pi: g.lengthFt }, "unit");
162 + add("STR-ROOF-TRUSS-OSB", g.roofSqft, "(empreinte + garage attenant) × facteur de pente × 1,10 débords × complexité", { empreinte_pi2: F, facteur_pente: g.roofSlopeFactor, geometrie: b.roofGeometry });
163 + }
164 + const stairs = (stories - 1) + (g.basementFloorSqft > 0 ? 1 : 0);
165 + add("STR-STAIRS-INTERIOR", stairs, "(étages − 1) + 1 si sous-sol", { etages: stories }, "unit");
166 +
167 + /* -------------------------------------------------------------- enveloppe */
168 + add("ENV-INSUL-WALL-R24", g.netWallSqft, "mur brut + pignons − ouvertures", { mur_brut_pi2: g.grossWallSqft, ouvertures_pi2: g.openingsSqft });
169 + if (!isCondo) add("ENV-INSUL-ATTIC-R50", F + (b.garage.type === "attached" ? 0 : 0), "empreinte (plafond du dernier étage)", { empreinte_pi2: F });
170 + const mix = normalizeMix(b.siding);
171 + const SIDING: Record<string, string> = { vinyl: "ENV-SIDING-VINYL", brick: "ENV-SIDING-BRICK", fiber_cement: "ENV-SIDING-FIBERCEMENT", wood: "ENV-SIDING-WOOD", stone: "ENV-SIDING-STONE", stucco: "ENV-SIDING-FIBERCEMENT", aluminum: "ENV-SIDING-STEEL", steel: "ENV-SIDING-STEEL" };
172 + const garageWall = g.garageExposedPerimeterFt * GARAGE_WALL_FT;
173 + for (const [k, share] of Object.entries(mix)) {
174 + if (share <= 0) continue;
175 + add(SIDING[k], (g.netWallSqft + garageWall * 0.85) * share, `(mur net + murs de garage) × ${Math.round(share * 100)} %`, { mur_net_pi2: g.netWallSqft, garage_pi2: Math.round(garageWall), part: share });
176 + }
177 + if (!isCondo) {
178 + add("ENV-SOFFIT-FASCIA", P, "périmètre (débords de toit)", { perimetre_pi: P }, "pi_lin");
179 + add("EXT-GUTTERS", P * 0.6, "périmètre × 60 % (côtés d'égouttement)", { perimetre_pi: P }, "pi_lin");
180 + }
181 +
182 + /* ---------------------------------------------------------------- toiture */
183 + if (!isCondo) {
184 + const roofCode = b.roofGeometry === "flat" ? (b.roof === "membrane" || b.roof === "asphalt_shingle" ? "ROOF-MEMBRANE-ELASTO" : "ROOF-EPDM") : b.roof === "metal" ? "ROOF-METAL-STANDING" : b.roof === "membrane" ? "ROOF-MEMBRANE-ELASTO" : "ROOF-ASPHALT-ARCH";
185 + add(roofCode, g.roofSqft, "surface de toit", { toit_pi2: g.roofSqft });
186 + }
187 +
188 + /* ------------------------------------------------------------- ouvertures */
189 + const WIN: Record<string, string> = { pvc: "OPN-WINDOW-PVC-STD", hybrid: "OPN-WINDOW-HYBRID-STD", aluminum: "OPN-WINDOW-ALU-STD", wood: "OPN-WINDOW-WOOD-STD" };
190 + add(WIN[b.windows] ?? "OPN-WINDOW-PVC-STD", g.windowCount, b.windowCount != null ? "nombre de fenêtres fourni" : "aire d'étages × 0,011 fenêtre/pi² (≈ 11 % vitré, 10 pi² chacune)", { aire_etages_pi2: GFA, fenetres: g.windowCount }, "unit");
191 + add(b.quality === "prestige" || b.quality === "superior" ? "OPN-DOOR-EXT-FIBERGLASS" : "OPN-DOOR-EXT-STEEL", g.extDoors, "portes extérieures selon le type de bâtiment", { type: b.type }, "unit");
192 + add("OPN-DOOR-PATIO-6FT", g.patioDoors, "1 porte-patio si terrasse ou qualité ≥ standard", {}, "unit");
193 + const intDoors = Math.max(3, Math.round(livable / 180));
194 + add(b.quality === "superior" || b.quality === "prestige" ? "OPN-DOOR-INT-SOLID" : "OPN-DOOR-INT-HOLLOW", intDoors, "aire habitable ÷ 180 pi²", { aire_habitable_pi2: livable }, "unit");
195 + add("OPN-DOOR-CLOSET-BIFOLD", Math.max(2, Math.round(livable / 350)), "aire habitable ÷ 350 pi²", { aire_habitable_pi2: livable }, "unit");
196 + if (g.garageSqft) add(b.garage.spaces >= 2 ? "OPN-DOOR-GARAGE-16X7" : "OPN-DOOR-GARAGE-9X7", 1, "1 porte de garage", {}, "unit");
197 +
198 + /* ----------------------------------------------------------------- garage */
199 + if (g.garageSqft && !isCondo) {
200 + const Ag = g.garageSqft;
201 + add("FND-SLAB-GARAGE-5IN", Ag, "aire du garage", { garage_pi2: Ag });
202 + if (b.garage.type !== "integrated") {
203 + add("FND-FOOTING", g.garagePerimeterFt, "périmètre du garage", { perimetre_garage_pi: g.garagePerimeterFt }, "pi_lin");
204 + add("FND-WALL-POURED-8IN", g.garageExposedPerimeterFt * 4, "périmètre exposé du garage × 4 pi (mur hors gel)", { perimetre_expose_pi: g.garageExposedPerimeterFt });
205 + }
206 + add("STR-WALL-2X6-EXT", garageWall, "périmètre exposé du garage × 10 pi", { perimetre_expose_pi: g.garageExposedPerimeterFt });
207 + add("ENV-INSUL-WALL-R20", garageWall, "murs de garage", { garage_murs_pi2: Math.round(garageWall) });
208 + add("INT-DRYWALL-1/2-FINISHED", garageWall, "murs de garage (séparation coupe-feu)", { garage_murs_pi2: Math.round(garageWall) });
209 + add("INT-DRYWALL-5/8-CEILING", Ag, "plafond du garage", { garage_pi2: Ag });
210 + add("ELE-LIGHTING-STD", 2, "2 luminaires de garage", {}, "unit");
211 + if (b.garage.type === "detached") {
212 + const roofG = Ag * g.roofSlopeFactor * 1.1;
213 + add("STR-ROOF-TRUSS-OSB", roofG, "aire du garage × facteur de pente × 1,10", { garage_pi2: Ag, facteur_pente: g.roofSlopeFactor });
214 + add(b.roof === "metal" ? "ROOF-METAL-STANDING" : "ROOF-ASPHALT-ARCH", roofG, "toit du garage détaché", { toit_pi2: Math.round(roofG) });
215 + }
216 + }
217 +
218 + /* -------------------------------------------------------------- intérieur */
219 + const drywallWalls = g.netWallSqft + partitionsLf * STORY_HEIGHT_FT * 2 + (finished ? P * BASEMENT_WALL_FT * (finished / Math.max(1, g.basementFloorSqft)) : 0);
220 + add("INT-DRYWALL-1/2-FINISHED", drywallWalls, "face intérieure des murs extérieurs + 2 faces des cloisons + murs du sous-sol fini", { murs_ext_pi2: g.netWallSqft, cloisons_pi2: Math.round(partitionsLf * STORY_HEIGHT_FT * 2), sous_sol_pi2: Math.round(finished ? P * BASEMENT_WALL_FT : 0) });
221 + add("INT-DRYWALL-5/8-CEILING", livable, "plafonds (aire habitable)", { aire_habitable_pi2: livable });
222 + add("INT-PAINT-2-COATS", drywallWalls + livable, "murs + plafonds", { murs_pi2: Math.round(drywallWalls), plafonds_pi2: livable });
223 + const bathFloors = b.bathrooms * (b.bathroomQuality === "superior" || b.bathroomQuality === "prestige" ? 60 : b.bathroomQuality === "standard" ? 45 : 40) + b.powderRooms * 20;
224 + const floorArea = Math.max(0, GFA - bathFloors);
225 + const fmix = normalizeMix(Object.keys(b.flooring).length ? b.flooring : defaultFlooring(b.quality));
226 + const FLOOR: Record<string, string> = { hardwood: "INT-FLOOR-HARDWOOD", engineered: "INT-FLOOR-ENGINEERED", vinyl_plank: "INT-FLOOR-VINYL-PLANK", laminate: "INT-FLOOR-LAMINATE", ceramic: "INT-FLOOR-CERAMIC", carpet: "INT-FLOOR-CARPET" };
227 + for (const [k, share] of Object.entries(fmix)) if (share > 0 && FLOOR[k]) add(FLOOR[k], floorArea * share, `(aire d'étages − planchers de salles de bain) × ${Math.round(share * 100)} %`, { aire_pi2: floorArea, part: share });
228 + if (finished) {
229 + add("BSM-SUBFLOOR-PANELS", finished, "aire finie du sous-sol", { sous_sol_fini_pi2: finished });
230 + add("INT-FLOOR-VINYL-PLANK", finished, "aire finie du sous-sol (vinyle)", { sous_sol_fini_pi2: finished });
231 + }
232 + if (g.basementFloorSqft) add("ENV-INSUL-BASEMENT-R20", P * BASEMENT_WALL_FT, "périmètre × 8 pi (isolation intérieure des murs de sous-sol)", { perimetre_pi: P });
233 + add("INT-TRIM-BASEBOARD", partitionsLf * 2 + Pe * stories + (finished ? P : 0), "2 × cloisons + périmètre exposé × étages + sous-sol fini", { cloisons_pi_lin: Math.round(partitionsLf), perimetre_expose_pi: Pe }, "pi_lin");
234 + add("INT-CLOSET-SHELVING", Math.max(8, livable / 60), "aire habitable ÷ 60", { aire_habitable_pi2: livable }, "pi_lin");
235 +
236 + /* ------------------------------------------------------ cuisine / bains */
237 + const KIT: Record<Quality, string> = { economy: "KIT-ECONOMY", standard: "KIT-STANDARD", superior: "KIT-SUPERIOR", prestige: "KIT-PRESTIGE" };
238 + const sizeFactor = Math.min(1.3, Math.max(0.8, GFA / units / 1800));
239 + add(KIT[b.kitchenQuality], KITCHEN_LINEAR_FT[b.kitchenQuality] * sizeFactor * Math.max(1, b.kitchens), `${KITCHEN_LINEAR_FT[b.kitchenQuality]} pi lin (qualité) × facteur de taille ${sizeFactor.toFixed(2)} × cuisines`, { cuisines: b.kitchens, facteur_taille: Math.round(sizeFactor * 100) / 100 }, "pi_lin");
240 + const nb = Math.max(0, b.bathrooms);
241 + if (nb) {
242 + if (b.bathroomQuality === "prestige") add("BATH-SUPERIOR", nb, "toutes les salles de bain (prestige)", {}, "unit");
243 + else if (b.bathroomQuality === "superior") {
244 + add("BATH-SUPERIOR", Math.min(nb, units), "1 salle de bain principale supérieure par logement", {}, "unit");
245 + add("BATH-STANDARD", Math.max(0, nb - units), "autres salles de bain (standard)", {}, "unit");
246 + } else if (b.bathroomQuality === "standard") add("BATH-STANDARD", nb, "salles de bain standard", {}, "unit");
247 + else add("BATH-ECONOMY", nb, "salles de bain économiques", {}, "unit");
248 + }
249 + add("BATH-POWDER", b.powderRooms, "salles d'eau", {}, "unit");
250 +
251 + /* ------------------------------------------------------------- mécanique */
252 + const fixtures = Math.max(1, b.kitchens) * 2 + nb * 3 + b.powderRooms * 2 + units * 2; // laveuse + chauffe-eau par logement
253 + add("MEC-PLUMBING-ROUGH-IN", fixtures, "2 par cuisine + 3 par salle de bain + 2 par salle d'eau + 2 par logement (laveuse, chauffe-eau)", { cuisines: b.kitchens, sdb: nb, salles_eau: b.powderRooms, logements: units }, "unit");
254 + add("MEC-WATER-HEATER-60", units, "1 par logement", { logements: units }, "unit");
255 + if (!isCondo) add("MEC-DRAINAGE-BASE", 1, "1 par bâtiment", {}, "lump");
256 + const heated = livable;
257 + switch (b.heating) {
258 + case "heat_pump":
259 + case "geothermal":
260 + add("MEC-HEATPUMP-CENTRAL", units, "1 par logement", {}, "unit");
261 + add("MEC-FURNACE-ELECTRIC", units, "fournaise électrique d'appoint", {}, "unit");
262 + add("MEC-DUCTWORK", heated, "aire chauffée", { aire_pi2: heated });
263 + if (b.heating === "geothermal") warnings.push("geothermal_as_heatpump");
264 + break;
265 + case "furnace_electric":
266 + case "hydronic":
267 + add("MEC-FURNACE-ELECTRIC", units, "1 par logement", {}, "unit");
268 + add("MEC-DUCTWORK", heated, "aire chauffée", { aire_pi2: heated });
269 + if (b.hasAirConditioning) add("MEC-CENTRAL-AC", units, "climatisation centrale", {}, "unit");
270 + if (b.heating === "hydronic") warnings.push("hydronic_as_furnace");
271 + break;
272 + case "furnace_gas":
273 + case "furnace_oil":
274 + add("MEC-FURNACE-GAS", units, "1 par logement", {}, "unit");
275 + add("MEC-DUCTWORK", heated, "aire chauffée", { aire_pi2: heated });
276 + if (b.hasAirConditioning) add("MEC-CENTRAL-AC", units, "climatisation centrale", {}, "unit");
277 + break;
278 + default: {
279 + add("MEC-BASEBOARD-ELECTRIC", Math.ceil(heated / 150), "aire chauffée ÷ 150 pi² par plinthe de 1 500 W", { aire_pi2: heated }, "unit");
280 + if (b.hasAirConditioning) add("MEC-HEATPUMP-WALL", units, "thermopompe murale (climatisation)", {}, "unit");
281 + if (b.heating === "wood") warnings.push("wood_as_baseboard");
282 + }
283 + }
284 + if (b.hasAirExchanger) add("MEC-HRV", units, "1 par logement", {}, "unit");
285 +
286 + /* ------------------------------------------------------------ électricité */
287 + add("ELE-SERVICE-200A", units, "1 entrée par logement", { logements: units }, "unit");
288 + add("ELE-ROUGH-WIRING", livable, "aire habitable", { aire_habitable_pi2: livable });
289 + add("ELE-LIGHTING-STD", Math.round(livable / 70), "aire habitable ÷ 70 pi²", { aire_habitable_pi2: livable }, "unit");
290 + if (!isCondo) add("ELE-LIGHTING-EXT", 3 + (g.garageSqft ? 1 : 0), "3 + 1 si garage", {}, "unit");
291 +
292 + /* -------------------------------------------------------------- extérieur */
293 + if (!isCondo) {
294 + if (b.deckSqft > 0) {
295 + add("EXT-DECK-TREATED", b.deckSqft, "aire de terrasse fournie", { terrasse_pi2: b.deckSqft });
296 + const rail = 2.5 * Math.sqrt(b.deckSqft);
297 + add(b.quality === "superior" || b.quality === "prestige" ? "EXT-DECK-RAILING-ALU" : "EXT-DECK-RAILING-WOOD", rail, "2,5 × √aire de terrasse (3 côtés)", { terrasse_pi2: b.deckSqft }, "pi_lin");
298 + }
299 + if (b.drivewaySqft > 0 && b.driveway !== "none") {
300 + const code = b.driveway === "pavers" ? "EXT-DRIVEWAY-PAVERS" : b.driveway === "gravel" ? "EXT-DRIVEWAY-GRAVEL" : "EXT-DRIVEWAY-ASPHALT";
301 + add(code, b.drivewaySqft, "aire d'entrée fournie", { entree_pi2: b.drivewaySqft });
302 + }
303 + if (b.fenceLinFt > 0) add("EXT-FENCE-WOOD", b.fenceLinFt, "longueur de clôture fournie", {}, "pi_lin");
304 + if (b.landscapingSqft > 0) add("EXT-LANDSCAPING-SOD", b.landscapingSqft, "aire aménagée fournie", {}, "pi2");
305 + add("EXT-STEPS-PRECAST", 1, "1 perron", {}, "unit");
306 + if (b.pool === "above_ground") add("EXT-POOL-ABOVEGROUND", 1, "piscine hors terre", {}, "lump");
307 + if (b.pool === "inground") add("EXT-POOL-INGROUND", 1, "piscine creusée", {}, "lump");
308 + }
309 +
310 + /* ----------------------------------------------------- fusion + surcharges */
311 + const merged = new Map<string, QuantityLine>();
312 + for (const l of lines) {
313 + const cur = merged.get(l.assemblyCode);
314 + if (cur) {
315 + cur.quantity = Math.round((cur.quantity + l.quantity) * 100) / 100;
316 + cur.formula = `${cur.formula} + ${l.formula}`;
317 + cur.inputs = { ...cur.inputs, ...l.inputs };
318 + } else merged.set(l.assemblyCode, { ...l });
319 + }
320 + for (const [code, qty] of Object.entries(overrides)) {
321 + if (excluded.includes(code)) continue;
322 + if (!(qty >= 0)) continue;
323 + const cur = merged.get(code);
324 + if (cur) {
325 + cur.formula = `surcharge (${overrideSource}) — calculé : ${cur.quantity}`;
326 + cur.quantity = qty;
327 + cur.source = overrideSource;
328 + } else if (qty > 0) merged.set(code, { assemblyCode: code, quantity: qty, unit: "pi2", source: overrideSource, formula: `quantité fournie (${overrideSource})`, inputs: {} });
329 + }
330 + return { geometry: g, lines: [...merged.values()].filter((l) => l.quantity > 0), warnings };
331 +}
332 +
333 +function normalizeMix(m: object): Record<string, number> {
334 + const entries = Object.entries(m as Record<string, number | undefined>).filter((e): e is [string, number] => typeof e[1] === "number" && e[1] > 0);
335 + const total = entries.reduce((s, [, v]) => s + v, 0);
336 + if (!total) return { vinyl: 1 };
337 + return Object.fromEntries(entries.map(([k, v]) => [k, v / total]));
338 +}
added src/lib/cost/pricing.ts +127 −0
@@ -0,0 +1,127 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Agrégation des prix : observations multi-sources → prix canonique robuste.
4 + * Module PUR (les fonctions reçoivent des listes d'observations déjà lues).
5 + *
6 + * Règles (§52-55) : préférer le prix régulier au prix promotionnel ; médiane
7 + * robuste ; détection d'aberrations (MAD / IQR / règles métier) — jamais de
8 + * suppression silencieuse (outlier = true) ; score de confiance par article.
9 + */
10 +import type { PriceKind } from "./types";
11 +
12 +export interface PriceObservation {
13 + source: string;
14 + sourceQuality: number; // 0-1
15 + price: number; // dans l'unité canonique
16 + observedAt: string; // YYYY-MM-DD
17 + url: string | null;
18 + sourceUnit: string;
19 + conversionFactor: number;
20 + regular: boolean;
21 + kind: PriceKind;
22 + locationMatch: boolean; // observation dans la région d'analyse
23 +}
24 +
25 +export interface CanonicalPrice {
26 + price: number;
27 + low: number;
28 + high: number;
29 + kind: PriceKind;
30 + sourceCount: number;
31 + observedAt: string | null;
32 + confidence: number;
33 + used: PriceObservation[];
34 + outliers: { obs: PriceObservation; reason: string }[];
35 + dispersionPct: number; // (high-low)/price
36 +}
37 +
38 +export function median(xs: number[]): number {
39 + if (!xs.length) return NaN;
40 + const s = [...xs].sort((a, b) => a - b);
41 + const m = Math.floor(s.length / 2);
42 + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
43 +}
44 +
45 +/** Déviation absolue médiane (échelle robuste). */
46 +export function mad(xs: number[]): number {
47 + const m = median(xs);
48 + return median(xs.map((x) => Math.abs(x - m)));
49 +}
50 +
51 +export function daysBetween(a: string, b: string): number {
52 + return Math.round((new Date(b).getTime() - new Date(a).getTime()) / 86400000);
53 +}
54 +
55 +/**
56 + * Détecte les aberrations : écart > 3,5 MAD (si ≥ 4 obs) ou ratio > 2,5× / < 0,4×
57 + * la médiane (règle métier, si ≥ 2 obs). Retourne les indices aberrants.
58 + */
59 +export function detectOutliers(prices: number[]): { idx: number; reason: string }[] {
60 + const out: { idx: number; reason: string }[] = [];
61 + if (prices.length < 2) return out;
62 + const m = median(prices);
63 + const s = mad(prices) * 1.4826; // ≈ σ robuste
64 + prices.forEach((p, i) => {
65 + if (m > 0 && (p > m * 2.5 || p < m * 0.4)) out.push({ idx: i, reason: `ratio ${(p / m).toFixed(2)}× la médiane` });
66 + else if (prices.length >= 4 && s > 0 && Math.abs(p - m) / s > 3.5) out.push({ idx: i, reason: `> 3,5 MAD (${((p - m) / s).toFixed(1)} σ)` });
67 + });
68 + return out;
69 +}
70 +
71 +/**
72 + * Prix canonique d'un article à une date donnée.
73 + * - garde la plus récente observation par source (fenêtre `windowDays`) ;
74 + * - préfère le prix régulier ; les promos ne comptent que s'il n'y a rien d'autre ;
75 + * - médiane robuste ; low/high = min/max des observations conservées ;
76 + * - confiance : fraîcheur 25 · nb de sources 25 · qualité 25 · dispersion 15 · localisation 10.
77 + */
78 +export function canonicalPrice(obs: PriceObservation[], asOf: string, windowDays = 120): CanonicalPrice | null {
79 + const inWindow = obs.filter((o) => o.observedAt <= asOf && daysBetween(o.observedAt, asOf) <= windowDays && o.price > 0);
80 + if (!inWindow.length) return null;
81 + // plus récente par source (et préférence régulier)
82 + const bySource = new Map<string, PriceObservation>();
83 + for (const o of inWindow.sort((a, b) => (a.observedAt < b.observedAt ? 1 : -1))) {
84 + const cur = bySource.get(o.source);
85 + if (!cur || (!cur.regular && o.regular)) bySource.set(o.source, o);
86 + }
87 + let used = [...bySource.values()];
88 + const regulars = used.filter((o) => o.regular);
89 + if (regulars.length) used = regulars;
90 + const prices = used.map((o) => o.price);
91 + const outIdx = detectOutliers(prices);
92 + const outliers = outIdx.map((o) => ({ obs: used[o.idx], reason: o.reason }));
93 + const kept = used.filter((_, i) => !outIdx.some((o) => o.idx === i));
94 + const final = kept.length ? kept : used;
95 + const fp = final.map((o) => o.price);
96 + const price = median(fp);
97 + const low = Math.min(...fp);
98 + const high = Math.max(...fp);
99 + const observedAt = final.map((o) => o.observedAt).sort().at(-1) ?? null;
100 + const ageDays = observedAt ? daysBetween(observedAt, asOf) : 999;
101 + const freshness = 25 * Math.max(0, 1 - ageDays / windowDays);
102 + const nSources = 25 * Math.min(1, final.length / 3);
103 + const quality = 25 * (final.reduce((s, o) => s + o.sourceQuality, 0) / final.length);
104 + const dispersionPct = price > 0 ? (high - low) / price : 1;
105 + const dispersion = 15 * Math.max(0, 1 - dispersionPct / 0.5);
106 + const loc = 10 * (final.some((o) => o.locationMatch) ? 1 : 0.5);
107 + const kinds = new Set(final.map((o) => o.kind));
108 + const kind: PriceKind = kinds.size === 1 ? final[0].kind : kinds.has("observed") ? "observed" : "derived";
109 + return {
110 + price, low, high, kind, sourceCount: final.length, observedAt,
111 + confidence: Math.round(freshness + nSources + quality + dispersion + loc),
112 + used: final, outliers, dispersionPct,
113 + };
114 +}
115 +
116 +/**
117 + * Actualisation par indice : Cost_t1 = Cost_t0 × Index_t1 / Index_t0.
118 + * `series` triée par période croissante ; on prend la dernière valeur ≤ date.
119 + */
120 +export function indexFactor(series: { period: string; value: number }[], fromDate: string, toDate: string): { factor: number; from: { period: string; value: number }; to: { period: string; value: number } } | null {
121 + const at = (d: string) => [...series].filter((p) => p.period <= d).at(-1) ?? series[0];
122 + if (!series.length) return null;
123 + const a = at(fromDate);
124 + const b = at(toDate);
125 + if (!a || !b || a.value <= 0) return null;
126 + return { factor: b.value / a.value, from: a, to: b };
127 +}
added src/lib/cost/seed/assemblies.ts +432 −0
@@ -0,0 +1,432 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Catalogue initial des assemblages résidentiels (cost_assemblies +
4 + * cost_assembly_components). Module PUR.
5 + *
6 + * Chaque composant : quantité d'article par UNITÉ D'ASSEMBLAGE, facteur de
7 + * pertes, heures de main-d'œuvre par unité d'assemblage (métier), coût
8 + * d'équipement par unité. Quantités et productivités = HYPOTHÈSES DOCUMENTÉES
9 + * de prise de quantités résidentielle (16 po c/c, hauteur d'étage 9 pi…),
10 + * modifiables en base ; les prix viennent de la base de coûts, jamais d'ici.
11 + */
12 +import type { AssemblyComponent, CanonicalUnit, Quality, UxCategory } from "../types";
13 +
14 +export const ASSEMBLY_VERSION = 1;
15 +
16 +export interface AssemblySeed {
17 + code: string;
18 + mf: string;
19 + cat: UxCategory;
20 + fr: string;
21 + en: string;
22 + descFr: string;
23 + descEn: string;
24 + unit: CanonicalUnit;
25 + life: number; // vie économique (années)
26 + group: string; // groupe de condition (roof, windows, kitchen…)
27 + quality?: Quality;
28 + buildingType?: string;
29 + components: AssemblyComponent[];
30 +}
31 +
32 +type CO = { w?: number; h?: number; t?: string; e?: number; n?: string };
33 +let seq = 0;
34 +const C = (itemCode: string, quantity: number, o: CO = {}): AssemblyComponent => ({
35 + itemCode,
36 + quantity,
37 + wasteFactor: o.w ?? 0,
38 + labourHours: o.h ?? 0,
39 + trade: o.t ?? null,
40 + equipmentCost: o.e ?? 0,
41 + sequence: ++seq,
42 + notes: o.n ?? null,
43 +});
44 +/** heures seules (aucun article) */
45 +const H = (trade: string, hours: number, n?: string): AssemblyComponent => C("", 0, { h: hours, t: trade, n });
46 +/** équipement seul */
47 +const E = (cost: number, n: string): AssemblyComponent => C("", 0, { e: cost, n });
48 +
49 +const A = (
50 + code: string,
51 + mf: string,
52 + cat: UxCategory,
53 + fr: string,
54 + en: string,
55 + unit: CanonicalUnit,
56 + life: number,
57 + group: string,
58 + descFr: string,
59 + descEn: string,
60 + components: AssemblyComponent[],
61 + o: { quality?: Quality; buildingType?: string } = {}
62 +): AssemblySeed => {
63 + seq = 0;
64 + return { code, mf, cat, fr, en, unit, life, group, descFr, descEn, components: components.map((c, i) => ({ ...c, sequence: i + 1 })), ...o };
65 +};
66 +
67 +export const ASSEMBLY_SEEDS: AssemblySeed[] = [
68 + /* ================================================================ SITE */
69 + A("SITE-EXCAV-BASEMENT", "31", "excavation", "Excavation de sous-sol", "Basement excavation", "m3", 75, "structure",
70 + "Excavation mécanique du volume de sous-sol (pelle 8-14 t), moitié des déblais évacuée, moitié réutilisée en remblai. 22 m³/h.",
71 + "Mechanical excavation of the basement volume (8-14 t excavator), half hauled away, half reused as backfill. 22 m³/h.",
72 + [C("EXCAVATOR-HOUR", 0.045, { n: "22 m³ excavés par heure-machine" }), C("TRUCK-HAUL", 0.5, { n: "50 % des déblais évacués" }), H("operateur", 0.045), H("manoeuvre", 0.02)]),
73 + A("SITE-BACKFILL", "31", "excavation", "Remblai et compaction", "Backfill and compaction", "m3", 75, "structure",
74 + "Remblai périmétrique : 40 % de gravier importé, 60 % de déblais réutilisés, compaction mécanique.",
75 + "Perimeter backfill: 40 % imported gravel, 60 % reused spoil, mechanical compaction.",
76 + [C("BACKFILL-GRAVEL", 0.4), C("EXCAVATOR-HOUR", 0.03), H("operateur", 0.03), H("manoeuvre", 0.03), E(0.8, "plaque vibrante")]),
77 + A("SITE-DRAIN-FRENCH", "33", "excavation", "Drain français", "French drain", "pi_lin", 40, "structure",
78 + "Drain perforé 4 po au pied de la semelle, enrobé de pierre nette (≈ 0,05 m³/pi lin).",
79 + "4 in perforated drain at the footing, wrapped in clear stone (≈ 0.05 m³/lin ft).",
80 + [C("WEEPING-TILE-4IN", 1.05), C("BACKFILL-GRAVEL", 0.05, { n: "pierre nette autour du drain" }), H("manoeuvre", 0.06)]),
81 + A("SITE-SERVICE-CONNECTIONS", "33", "site", "Branchements aqueduc, égout et électricité", "Water, sewer and electrical service connections", "lump", 50, "site",
82 + "Tranchée, conduites de branchement et raccordements aux réseaux municipaux (forfait par bâtiment).",
83 + "Trench, service pipes and connections to municipal networks (lump sum per building).",
84 + [C("SERVICE-CONNECTIONS", 1), C("EXCAVATOR-HOUR", 4), H("operateur", 4), H("plombier", 6), H("manoeuvre", 8)]),
85 +
86 + /* ========================================================= FONDATIONS */
87 + A("FND-FOOTING", "03", "foundation", "Semelle de béton 24 × 10 po", "Concrete footing 24 × 10 in", "pi_lin", 75, "structure",
88 + "Semelle continue coffrée, 2 barres 10M, béton 30 MPa (0,047 m³/pi lin).",
89 + "Continuous formed footing, 2 × 10M bars, 30 MPa concrete (0.047 m³/lin ft).",
90 + [C("CONC-READYMIX-30MPA", 0.047, { w: 0.05 }), C("REBAR-10M-20FT", 0.1), C("LUM-2X10-12", 0.02, { n: "coffrage de semelle (réutilisé 4×)" }), H("coffreur", 0.12), H("ferrailleur", 0.02), H("manoeuvre", 0.05)]),
91 + A("FND-WALL-POURED-8IN", "03", "foundation", "Mur de fondation béton coulé 8 po", "Poured concrete foundation wall 8 in", "pi2", 75, "structure",
92 + "Mur coulé en coffrages modulaires, armature 10M, boulons d'ancrage, pompe à béton (0,0189 m³/pi²).",
93 + "Wall poured in modular forms, 10M rebar, anchor bolts, concrete pump (0.0189 m³/sq ft).",
94 + [C("CONC-READYMIX-30MPA", 0.0189, { w: 0.05 }), C("REBAR-10M-20FT", 0.05), C("FORM-RENTAL", 1), C("ANCHOR-BOLT", 0.02), H("coffreur", 0.09), H("ferrailleur", 0.01), H("manoeuvre", 0.03), E(0.6, "pompe à béton")]),
95 + A("FND-WALL-BLOCK-8IN", "04", "foundation", "Mur de fondation en blocs de béton 8 po", "Concrete-block foundation wall 8 in", "pi2", 70, "structure",
96 + "Blocs 8 × 8 × 16 posés au mortier type N (1,125 bloc/pi²), armature verticale.",
97 + "8 × 8 × 16 blocks laid in type N mortar (1.125 block/sq ft), vertical rebar.",
98 + [C("CONC-BLOCK-8IN", 1.125, { w: 0.04 }), C("MORTAR-TYPE-N-30KG", 0.094), C("REBAR-10M-20FT", 0.02), H("briqueteur", 0.12), H("manoeuvre", 0.06)]),
99 + A("FND-SLAB-BASEMENT-4IN", "03", "foundation", "Dalle de sous-sol 4 po", "Basement slab 4 in", "pi2", 75, "structure",
100 + "Gravier compacté 6 po, pare-vapeur 6 mil, béton 30 MPa 4 po (0,0094 m³/pi²), finition truelle.",
101 + "6 in compacted gravel, 6 mil vapour barrier, 4 in 30 MPa concrete (0.0094 m³/sq ft), trowel finish.",
102 + [C("BACKFILL-GRAVEL", 0.0142), C("POLY-6MIL", 1, { w: 0.1 }), C("CONC-READYMIX-30MPA", 0.0094, { w: 0.05 }), H("cimentier", 0.02), H("manoeuvre", 0.015), E(0.1, "pompe/vibrateur")]),
103 + A("FND-SLAB-ON-GRADE-INSULATED", "03", "foundation", "Dalle sur sol isolée 5 po", "Insulated slab-on-grade 5 in", "pi2", 75, "structure",
104 + "Gravier 8 po, isolant XPS 2 po, pare-vapeur, treillis 6 × 6, béton 5 po, bordure épaissie.",
105 + "8 in gravel, 2 in XPS, vapour barrier, 6 × 6 mesh, 5 in concrete, thickened edge.",
106 + [C("BACKFILL-GRAVEL", 0.02), C("INS-XPS-2IN", 1, { w: 0.05 }), C("POLY-6MIL", 1, { w: 0.1 }), C("WIRE-MESH-6X6", 1, { w: 0.1 }), C("CONC-READYMIX-30MPA", 0.0118, { w: 0.05 }), C("REBAR-10M-20FT", 0.01), H("cimentier", 0.03), H("coffreur", 0.01), H("manoeuvre", 0.02), E(0.15, "pompe")]),
107 + A("FND-SLAB-GARAGE-5IN", "03", "garage", "Dalle de garage 5 po", "Garage slab 5 in", "pi2", 60, "structure",
108 + "Gravier 8 po, pare-vapeur, treillis 6 × 6, béton 5 po avec pente vers la porte.",
109 + "8 in gravel, vapour barrier, 6 × 6 mesh, 5 in concrete sloped to the door.",
110 + [C("BACKFILL-GRAVEL", 0.02), C("POLY-6MIL", 1, { w: 0.1 }), C("WIRE-MESH-6X6", 1, { w: 0.1 }), C("CONC-READYMIX-30MPA", 0.0118, { w: 0.05 }), H("cimentier", 0.025), H("manoeuvre", 0.02), E(0.1, "pompe")]),
111 + A("FND-DAMPPROOF-MEMBRANE", "07", "foundation", "Imperméabilisation et membrane de drainage", "Dampproofing and drainage membrane", "pi2", 40, "structure",
112 + "Enduit bitumineux + membrane alvéolée sur la face extérieure sous le niveau du sol.",
113 + "Bituminous coating + dimpled membrane on the exterior face below grade.",
114 + [C("DAMPPROOF", 1), C("DRAIN-MEMBRANE", 1, { w: 0.05 }), H("manoeuvre", 0.02)]),
115 + A("FND-PIER", "03", "foundation", "Pieu de béton (tube 10 po)", "Concrete pier (10 in tube)", "unit", 50, "structure",
116 + "Tube de coffrage 10 po × 4 pi, béton en sacs, boulon d'ancrage (chalets, galeries).",
117 + "10 in × 4 ft form tube, bagged concrete, anchor bolt (cottages, decks).",
118 + [C("CONCRETE-PIER-TUBE", 1), C("CONC-BAG-30KG", 4), C("ANCHOR-BOLT", 1), H("manoeuvre", 1.0), H("charpentier", 0.3)]),
119 +
120 + /* ========================================================== STRUCTURE */
121 + A("STR-FLOOR-IJOIST", "06", "structure", "Plancher en poutrelles ajourées 11 7/8 po", "I-joist floor 11 7/8 in", "pi2", 75, "structure",
122 + "Poutrelles 16 po c/c (0,8 pi lin/pi² avec solives de rive), poutre LVL (allocation), sous-plancher OSB 5/8 L&R collé-vissé.",
123 + "I-joists 16 in o.c. (0.8 lin ft/sq ft incl. rim), LVL beam allowance, 5/8 T&G OSB subfloor glued and screwed.",
124 + [C("IJOIST-11.875", 0.8, { w: 0.03 }), C("LVL-1.75X11.875", 0.05), C("OSB-5/8-TG-4X8", 1, { w: 0.06 }), C("SUBFLOOR-ADHESIVE", 0.012), C("NAILS-FRAMING", 0.02), C("HANGERS-JOIST", 0.03), H("charpentier", 0.055)]),
125 + A("STR-FLOOR-2X10", "06", "structure", "Plancher en solives 2 × 10", "2 × 10 joist floor", "pi2", 75, "structure",
126 + "Solives 2 × 10 à 16 po c/c avec entretoises, sous-plancher OSB 5/8 collé-vissé (construction courante avant 2000).",
127 + "2 × 10 joists 16 in o.c. with blocking, 5/8 OSB subfloor glued and screwed (typical pre-2000 construction).",
128 + [C("LUM-2X10-12", 0.07, { w: 0.07 }), C("OSB-5/8-TG-4X8", 1, { w: 0.06 }), C("SUBFLOOR-ADHESIVE", 0.012), C("NAILS-FRAMING", 0.025), H("charpentier", 0.06)]),
129 + A("STR-WALL-2X6-EXT", "06", "structure", "Mur extérieur ossature 2 × 6 (charpente + revêtement intermédiaire)", "Exterior 2 × 6 framed wall (framing + sheathing)", "pi2", 75, "structure",
130 + "Montants 2 × 6 à 16 po c/c (0,10/pi² incl. coins, poteaux nains), lisses et sablières doubles, linteaux, OSB 7/16, pare-air. Hors isolation et finitions.",
131 + "2 × 6 studs 16 in o.c. (0.10/sq ft incl. corners, cripples), double plates, headers, 7/16 OSB, house wrap. Excludes insulation and finishes.",
132 + [C("LUM-2X6-92", 0.1, { w: 0.05 }), C("LUM-2X6-8", 0.06, { w: 0.07, n: "lisses, sablières, linteaux" }), C("OSB-7/16-4X8", 1, { w: 0.08 }), C("HOUSEWRAP", 1, { w: 0.1 }), C("NAILS-FRAMING", 0.03), H("charpentier", 0.075)]),
133 + A("STR-WALL-2X4-INT", "06", "structure", "Cloison intérieure ossature 2 × 4", "Interior 2 × 4 partition", "pi2", 75, "structure",
134 + "Montants 2 × 4 à 16 po c/c, lisse et sablière double. Hors gypse.",
135 + "2 × 4 studs 16 in o.c., plate and double top plate. Excludes drywall.",
136 + [C("LUM-2X4-92", 0.1, { w: 0.05 }), C("LUM-2X4-8", 0.05, { w: 0.07 }), C("NAILS-FRAMING", 0.02), H("charpentier", 0.05)]),
137 + A("STR-BEAM-LVL", "06", "structure", "Poutre LVL 2 plis 1 3/4 × 11 7/8 po", "2-ply LVL beam 1 3/4 × 11 7/8 in", "pi_lin", 75, "structure",
138 + "Poutre porteuse composée de deux plis LVL clouée, appuis métalliques.",
139 + "Load-bearing beam of two nailed LVL plies, metal bearings.",
140 + [C("LVL-1.75X11.875", 2, { w: 0.03 }), C("HANGERS-JOIST", 0.1), H("charpentier", 0.15)]),
141 + A("STR-COLUMN-STEEL", "05", "structure", "Colonne d'acier ajustable", "Adjustable steel column", "unit", 75, "structure",
142 + "Colonne réglable sous poutre principale, sur semelle.", "Adjustable column under main beam, on footing.",
143 + [C("STEEL-COLUMN-ADJ", 1), H("charpentier", 0.5)]),
144 + A("STR-ROOF-TRUSS-OSB", "06", "structure", "Toit en fermes préfabriquées + OSB", "Prefabricated truss roof + OSB", "pi2", 75, "structure",
145 + "Fermes 24 po c/c livrées et grutées, contreventement, OSB 7/16 avec agrafes H, attaches anti-soulèvement (par pi² de surface de toit).",
146 + "Trusses 24 in o.c. delivered and craned, bracing, 7/16 OSB with H-clips, hurricane ties (per sq ft roof area).",
147 + [C("TRUSS-ROOF", 1), C("OSB-7/16-4X8", 1, { w: 0.08 }), C("NAILS-FRAMING", 0.025), C("HANGERS-JOIST", 0.03, { n: "attaches anti-soulèvement" }), H("charpentier", 0.05), E(0.35, "grue de levage")]),
148 + A("STR-ROOF-RAFTER-2X8", "06", "structure", "Toit en chevrons 2 × 8 + OSB", "2 × 8 rafter roof + OSB", "pi2", 75, "structure",
149 + "Chevrons 2 × 8 à 16 po c/c, faîtière, entraits, OSB 7/16 (construction traditionnelle, par pi² de toit).",
150 + "2 × 8 rafters 16 in o.c., ridge, collar ties, 7/16 OSB (traditional construction, per sq ft roof).",
151 + [C("LUM-2X8-12", 0.09, { w: 0.07 }), C("OSB-7/16-4X8", 1, { w: 0.08 }), C("NAILS-FRAMING", 0.025), H("charpentier", 0.08)]),
152 + A("STR-STAIRS-INTERIOR", "06", "interior", "Escalier intérieur fini (13 contremarches)", "Finished interior staircase (13 risers)", "unit", 50, "interior",
153 + "Limons 2 × 12, marches de chêne, contremarches contreplaqué, main courante et balustrade 12 pi.",
154 + "2 × 12 stringers, oak treads, plywood risers, 12 ft handrail and balustrade.",
155 + [C("LUM-2X12-12", 3, { w: 0.05 }), C("STAIR-TREADS-OAK", 13), C("PLY-3/4-TG-4X8", 32, { w: 0.06 }), C("STAIR-RAILING", 12), H("charpentier", 16)]),
156 +
157 + /* ================================================ ENVELOPPE / ISOLATION */
158 + A("ENV-INSUL-WALL-R24", "07", "insulation", "Isolation de mur R24 + pare-vapeur", "Wall insulation R24 + vapour barrier", "pi2", 60, "structure",
159 + "Natte de laine minérale R24 dans cavité 2 × 6, polyéthylène 6 mil scellé.",
160 + "R24 mineral-wool batt in 2 × 6 cavity, sealed 6 mil polyethylene.",
161 + [C("INS-BATT-R24-2X6", 1, { w: 0.05 }), C("POLY-6MIL", 1, { w: 0.1 }), C("CAULK-TUBE", 0.005), H("charpentier", 0.012)]),
162 + A("ENV-INSUL-WALL-R20", "07", "insulation", "Isolation de mur R20 + pare-vapeur", "Wall insulation R20 + vapour barrier", "pi2", 60, "structure",
163 + "Natte de fibre de verre R20 dans cavité 2 × 6, polyéthylène 6 mil.", "R20 fibreglass batt in 2 × 6 cavity, 6 mil polyethylene.",
164 + [C("INS-BATT-R20-2X6", 1, { w: 0.05 }), C("POLY-6MIL", 1, { w: 0.1 }), C("CAULK-TUBE", 0.005), H("charpentier", 0.012)]),
165 + A("ENV-INSUL-WALL-R12", "07", "insulation", "Isolation de mur R12 (2 × 4) + pare-vapeur", "Wall insulation R12 (2 × 4) + vapour barrier", "pi2", 60, "structure",
166 + "Natte R12 dans cavité 2 × 4 (bâtiments d'avant 1985), polyéthylène.", "R12 batt in 2 × 4 cavity (pre-1985 buildings), polyethylene.",
167 + [C("INS-BATT-R12-2X4", 1, { w: 0.05 }), C("POLY-6MIL", 1, { w: 0.1 }), H("charpentier", 0.011)]),
168 + A("ENV-INSUL-ATTIC-R50", "07", "insulation", "Isolation de comble R50 (cellulose soufflée)", "Attic insulation R50 (blown cellulose)", "pi2", 40, "structure",
169 + "Pare-vapeur au plafond, cellulose soufflée R50, évents de toit (1 par 500 pi²).",
170 + "Ceiling vapour barrier, R50 blown cellulose, roof vents (1 per 500 sq ft).",
171 + [C("INS-BLOWN-CELLULOSE-R50", 1, { w: 0.03 }), C("POLY-6MIL", 1, { w: 0.1 }), C("ROOF-VENT", 0.002), H("charpentier", 0.01), E(0.08, "souffleuse")]),
172 + A("ENV-INSUL-BASEMENT-R20", "07", "basement", "Isolation de mur de sous-sol R20 (XPS + 2 × 4 + R12)", "Basement wall insulation R20 (XPS + 2 × 4 + R12)", "pi2", 50, "interior",
173 + "XPS 2 po contre le béton, cloison 2 × 4, natte R12, pare-vapeur. Hors gypse.",
174 + "2 in XPS against concrete, 2 × 4 frame, R12 batt, vapour barrier. Excludes drywall.",
175 + [C("INS-XPS-2IN", 1, { w: 0.05 }), C("LUM-2X4-92", 0.1, { w: 0.05 }), C("LUM-2X4-8", 0.05, { w: 0.07 }), C("INS-BATT-R12-2X4", 1, { w: 0.05 }), C("POLY-6MIL", 1, { w: 0.1 }), H("charpentier", 0.06)]),
176 + A("ENV-SIDING-VINYL", "07", "envelope", "Revêtement de vinyle sur fourrures", "Vinyl siding on furring", "pi2", 35, "exterior",
177 + "Fourrures 1 × 3 à 16 po c/c, déclin de vinyle, moulures J et coins.", "1 × 3 furring 16 in o.c., vinyl siding, J-channel and corners.",
178 + [C("SIDING-VINYL", 1, { w: 0.1 }), C("SIDING-VINYL-ACCESS", 0.25), C("LUM-1X3-8", 0.1, { w: 0.08 }), C("NAILS-FRAMING", 0.01), H("charpentier", 0.045)]),
179 + A("ENV-SIDING-BRICK", "04", "envelope", "Placage de brique", "Brick veneer", "pi2", 75, "exterior",
180 + "Brique modulaire (7/pi²), mortier type N, attaches, solins, échafaudage.", "Modular brick (7/sq ft), type N mortar, ties, flashing, scaffolding.",
181 + [C("BRICK-CLAY", 7, { w: 0.05 }), C("MORTAR-TYPE-N-30KG", 0.1), C("BRICK-TIES", 0.5), C("FLASHING-ALU", 0.05), H("briqueteur", 0.16), H("manoeuvre", 0.08), E(0.4, "échafaudage")]),
182 + A("ENV-SIDING-FIBERCEMENT", "07", "envelope", "Revêtement de fibrociment", "Fiber-cement siding", "pi2", 45, "exterior",
183 + "Planches de fibrociment sur fourrures, solins, scellant.", "Fiber-cement planks on furring, flashing, sealant.",
184 + [C("SIDING-FIBERCEMENT", 1, { w: 0.1 }), C("LUM-1X3-8", 0.1, { w: 0.08 }), C("FLASHING-ALU", 0.05), C("NAILS-FRAMING", 0.012), C("CAULK-TUBE", 0.01), H("charpentier", 0.065)]),
185 + A("ENV-SIDING-WOOD", "07", "envelope", "Déclin de bois (cèdre) peint", "Painted wood (cedar) siding", "pi2", 30, "exterior",
186 + "Déclin de cèdre sur fourrures, 2 couches de peinture extérieure.", "Cedar siding on furring, 2 coats of exterior paint.",
187 + [C("SIDING-WOOD-CEDAR", 1, { w: 0.1 }), C("LUM-1X3-8", 0.1, { w: 0.08 }), C("PAINT-EXT-GAL", 0.006), C("NAILS-FRAMING", 0.012), H("charpentier", 0.06), H("peintre", 0.015)]),
188 + A("ENV-SIDING-STONE", "04", "envelope", "Placage de pierre décorative", "Manufactured stone veneer", "pi2", 60, "exterior",
189 + "Pierre reconstituée posée au mortier sur lattis.", "Manufactured stone set in mortar on lath.",
190 + [C("STONE-VENEER", 1, { w: 0.08 }), C("MORTAR-TYPE-N-30KG", 0.08), H("briqueteur", 0.2), H("manoeuvre", 0.08), E(0.3, "échafaudage")]),
191 + A("ENV-SIDING-STEEL", "07", "envelope", "Revêtement d'acier prépeint", "Pre-painted steel siding", "pi2", 45, "exterior",
192 + "Panneaux d'acier sur fourrures, vis de couleur.", "Steel panels on furring, colour-matched screws.",
193 + [C("SIDING-STEEL", 1, { w: 0.07 }), C("LUM-1X3-8", 0.1, { w: 0.08 }), C("SCREWS-DECK", 0.01), H("charpentier", 0.05)]),
194 + A("ENV-SOFFIT-FASCIA", "07", "envelope", "Soffite et bordure de toit aluminium", "Aluminum soffit and fascia", "pi_lin", 35, "exterior",
195 + "Soffite ventilé 18 po et fascia 6 po par pi lin de débord.", "18 in vented soffit and 6 in fascia per lin ft of eave.",
196 + [C("SOFFIT-ALU", 1.5, { w: 0.05 }), C("FASCIA-ALU", 1, { w: 0.05 }), H("charpentier", 0.08)]),
197 + A("EXT-GUTTERS", "07", "exterior", "Gouttières d'aluminium", "Aluminum gutters", "pi_lin", 25, "exterior",
198 + "Gouttière 5 po avec supports et descentes (allocation).", "5 in gutter with hangers and downspouts (allowance).",
199 + [C("GUTTER-ALU", 1.15, { w: 0.05, n: "+15 % descentes" }), H("charpentier", 0.05)]),
200 +
201 + /* ============================================================ TOITURE */
202 + A("ROOF-ASPHALT-ARCH", "07", "roofing", "Couverture bardeau d'asphalte architectural", "Architectural asphalt shingle roofing", "pi2", 22, "roof",
203 + "Sous-couche synthétique, membrane glace/eau aux débords et noues (15 %), larmiers, bardeau architectural, évents.",
204 + "Synthetic underlayment, ice & water at eaves/valleys (15 %), drip edge, architectural shingles, vents.",
205 + [C("SHINGLE-ARCH", 1, { w: 0.1 }), C("ROOF-UNDERLAY-SYNTH", 1, { w: 0.08 }), C("ICE-WATER-SHIELD", 0.15, { w: 0.08 }), C("DRIP-EDGE", 0.05), C("NAILS-FRAMING", 0.012, { n: "clous à toiture" }), C("ROOF-VENT", 0.0015), H("couvreur", 0.022), H("manoeuvre", 0.008), E(0.05, "monte-charge, compresseur")]),
206 + A("ROOF-METAL-STANDING", "07", "roofing", "Couverture de tôle d'acier", "Steel roofing", "pi2", 45, "roof",
207 + "Sous-couche, membrane glace/eau, panneaux d'acier prépeint vissés, solins.", "Underlayment, ice & water, screwed pre-painted steel panels, flashing.",
208 + [C("ROOF-METAL-PANEL", 1, { w: 0.07 }), C("ROOF-UNDERLAY-SYNTH", 1, { w: 0.08 }), C("ICE-WATER-SHIELD", 0.15, { w: 0.08 }), C("SCREWS-DECK", 0.01, { n: "vis de toiture" }), C("FLASHING-ALU", 0.03), H("couvreur", 0.045), H("ferblantier", 0.01), E(0.08, "monte-charge")]),
209 + A("ROOF-MEMBRANE-ELASTO", "07", "roofing", "Toit plat — membrane élastomère 2 couches", "Flat roof — two-ply elastomeric membrane", "pi2", 22, "roof",
210 + "Membrane de base et de finition soudées au chalumeau sur panneau existant.", "Torch-applied base and cap sheets on existing deck.",
211 + [C("ROOF-MEMBRANE-ELASTO", 1, { w: 0.08 }), H("couvreur", 0.06), E(0.15, "chalumeau, monte-charge")]),
212 + A("ROOF-EPDM", "07", "roofing", "Toit plat — membrane EPDM collée", "Flat roof — adhered EPDM membrane", "pi2", 20, "roof",
213 + "Membrane EPDM collée, joints et solins.", "Fully adhered EPDM, seams and flashing.",
214 + [C("EPDM-MEMBRANE-SYSTEM", 1, { w: 0.08 }), H("couvreur", 0.03)]),
215 +
216 + /* ========================================================== OUVERTURES */
217 + A("OPN-WINDOW-PVC-STD", "08", "openings", "Fenêtre PVC à battant (≈ 10 pi²)", "PVC casement window (≈ 10 sq ft)", "unit", 30, "windows",
218 + "Fenêtre à battant PVC double vitrage Low-E, isolation à la mousse, scellant, solin, cadrage intérieur.",
219 + "Low-E double-glazed PVC casement, foam insulation, sealant, flashing, interior casing.",
220 + [C("WIN-PVC-CASEMENT-STD", 1), C("SPRAYFOAM-CAN", 0.5), C("CAULK-TUBE", 0.5), C("FLASHING-ALU", 3), C("TRIM-CASING-MDF", 14, { w: 0.1 }), H("charpentier", 2.2)]),
221 + A("OPN-WINDOW-HYBRID-STD", "08", "openings", "Fenêtre hybride PVC/aluminium (≈ 10 pi²)", "Hybrid PVC/aluminum window (≈ 10 sq ft)", "unit", 35, "windows",
222 + "Fenêtre hybride sur mesure, mousse, scellant, solin, cadrage.", "Custom hybrid window, foam, sealant, flashing, casing.",
223 + [C("WIN-HYBRID-STD", 1), C("SPRAYFOAM-CAN", 0.5), C("CAULK-TUBE", 0.5), C("FLASHING-ALU", 3), C("TRIM-CASING-MDF", 14, { w: 0.1 }), H("charpentier", 2.4)]),
224 + A("OPN-WINDOW-ALU-STD", "08", "openings", "Fenêtre aluminium (≈ 10 pi²)", "Aluminum window (≈ 10 sq ft)", "unit", 30, "windows",
225 + "Fenêtre à cadre d'aluminium, mousse, scellant, solin, cadrage.", "Aluminum-frame window, foam, sealant, flashing, casing.",
226 + [C("WIN-ALU-STD", 1), C("SPRAYFOAM-CAN", 0.5), C("CAULK-TUBE", 0.5), C("FLASHING-ALU", 3), C("TRIM-CASING-MDF", 14, { w: 0.1 }), H("charpentier", 2.2)]),
227 + A("OPN-WINDOW-WOOD-STD", "08", "openings", "Fenêtre bois/aluminium (≈ 10 pi²)", "Wood/aluminum window (≈ 10 sq ft)", "unit", 35, "windows",
228 + "Fenêtre bois intérieur / aluminium extérieur, mousse, scellant, solin, cadrage.", "Wood interior / aluminum exterior window, foam, sealant, flashing, casing.",
229 + [C("WIN-WOOD-STD", 1), C("SPRAYFOAM-CAN", 0.5), C("CAULK-TUBE", 0.5), C("FLASHING-ALU", 3), C("TRIM-CASING-MDF", 14, { w: 0.1 }), H("charpentier", 2.5)]),
230 + A("OPN-WINDOW-BASEMENT", "08", "openings", "Fenêtre de sous-sol coulissante", "Basement slider window", "unit", 30, "windows",
231 + "Fenêtre coulissante PVC 32 × 18 po dans ouverture de béton.", "32 × 18 in PVC slider in concrete opening.",
232 + [C("WIN-BASEMENT-SLIDER", 1), C("SPRAYFOAM-CAN", 0.3), C("CAULK-TUBE", 0.3), H("charpentier", 1.2)]),
233 + A("OPN-DOOR-EXT-STEEL", "08", "openings", "Porte d'entrée en acier", "Steel entry door", "unit", 30, "windows",
234 + "Porte prémontée acier isolée 36 po, quincaillerie d'entrée, mousse, scellant, cadrage.", "36 in insulated prehung steel door, handleset, foam, sealant, casing.",
235 + [C("DOOR-EXT-STEEL-36", 1), C("DOOR-HARDWARE-EXT", 1), C("SPRAYFOAM-CAN", 0.5), C("CAULK-TUBE", 0.5), C("TRIM-CASING-MDF", 17, { w: 0.1 }), H("charpentier", 3)]),
236 + A("OPN-DOOR-EXT-FIBERGLASS", "08", "openings", "Porte d'entrée en fibre de verre", "Fiberglass entry door", "unit", 35, "windows",
237 + "Porte prémontée fibre de verre 36 po, quincaillerie, mousse, scellant, cadrage.", "36 in prehung fiberglass door, handleset, foam, sealant, casing.",
238 + [C("DOOR-EXT-FIBERGLASS", 1), C("DOOR-HARDWARE-EXT", 1), C("SPRAYFOAM-CAN", 0.5), C("CAULK-TUBE", 0.5), C("TRIM-CASING-MDF", 17, { w: 0.1 }), H("charpentier", 3)]),
239 + A("OPN-DOOR-PATIO-6FT", "08", "openings", "Porte-patio 6 pi", "Patio door 6 ft", "unit", 30, "windows",
240 + "Porte-patio coulissante PVC 6 pi, mousse, scellant, cadrage.", "6 ft PVC sliding patio door, foam, sealant, casing.",
241 + [C("DOOR-PATIO-6FT", 1), C("SPRAYFOAM-CAN", 0.7), C("CAULK-TUBE", 0.7), C("TRIM-CASING-MDF", 20, { w: 0.1 }), H("charpentier", 4)]),
242 + A("OPN-DOOR-GARAGE-9X7", "08", "garage", "Porte de garage 9 × 7 pi avec ouvre-porte", "Garage door 9 × 7 ft with opener", "unit", 25, "windows",
243 + "Porte isolée à sections 9 × 7, rails, ressorts, ouvre-porte.", "Insulated sectional door 9 × 7, tracks, springs, opener.",
244 + [C("DOOR-GARAGE-9X7", 1), C("GARAGE-OPENER", 1), H("charpentier", 4.5), H("electricien", 0.5)]),
245 + A("OPN-DOOR-GARAGE-16X7", "08", "garage", "Porte de garage double 16 × 7 pi avec ouvre-porte", "Double garage door 16 × 7 ft with opener", "unit", 25, "windows",
246 + "Porte isolée à sections 16 × 7, rails, ressorts, ouvre-porte.", "Insulated sectional door 16 × 7, tracks, springs, opener.",
247 + [C("DOOR-GARAGE-16X7", 1), C("GARAGE-OPENER", 1), H("charpentier", 6), H("electricien", 0.5)]),
248 + A("OPN-DOOR-INT-HOLLOW", "08", "openings", "Porte intérieure alvéolée", "Hollow-core interior door", "unit", 40, "interior",
249 + "Porte prémontée alvéolée 30 po, poignée, cadrage deux faces.", "30 in prehung hollow-core door, handle, casing both sides.",
250 + [C("DOOR-INT-HOLLOW-PREHUNG", 1), C("DOOR-HARDWARE-INT", 1), C("TRIM-CASING-MDF", 34, { w: 0.1 }), H("charpentier", 1.6)]),
251 + A("OPN-DOOR-INT-SOLID", "08", "openings", "Porte intérieure pleine", "Solid-core interior door", "unit", 40, "interior",
252 + "Porte prémontée âme pleine 30 po, poignée, cadrage deux faces.", "30 in prehung solid-core door, handle, casing both sides.",
253 + [C("DOOR-INT-SOLID-PREHUNG", 1), C("DOOR-HARDWARE-INT", 1), C("TRIM-CASING-MDF", 34, { w: 0.1 }), H("charpentier", 1.8)]),
254 + A("OPN-DOOR-CLOSET-BIFOLD", "08", "openings", "Porte de garde-robe pliante", "Bifold closet door", "unit", 30, "interior",
255 + "Porte pliante 48 po, quincaillerie, cadrage.", "48 in bifold door, hardware, casing.",
256 + [C("DOOR-CLOSET-BIFOLD", 1), C("TRIM-CASING-MDF", 17, { w: 0.1 }), H("charpentier", 1.2)]),
257 + A("OPN-SKYLIGHT", "08", "openings", "Puits de lumière", "Skylight", "unit", 25, "windows",
258 + "Puits de lumière fixe 2 × 4 pi, solins, membrane, finition du puits.", "Fixed 2 × 4 ft skylight, flashing, membrane, shaft finish.",
259 + [C("SKYLIGHT", 1), C("ICE-WATER-SHIELD", 20), C("FLASHING-ALU", 12), H("charpentier", 5), H("couvreur", 1.5)]),
260 +
261 + /* ============================================================ INTÉRIEUR */
262 + A("INT-DRYWALL-1/2-FINISHED", "09", "interior", "Gypse 1/2 po posé et tiré (3 couches)", "1/2 in drywall hung and taped (3 coats)", "pi2", 50, "interior",
263 + "Panneau 1/2 po vissé, ruban, renforts de coin, composé à joints 3 couches, sablage. Prêt à peindre.",
264 + "1/2 in board screwed, tape, corner bead, 3 coats of compound, sanding. Paint-ready.",
265 + [C("GYP-1/2-4X8", 1, { w: 0.1 }), C("SCREWS-DRYWALL", 0.008), C("DRYWALL-TAPE-250FT", 0.0035), C("JOINT-COMPOUND-20KG", 0.005), C("CORNER-BEAD-8FT", 0.01), H("poseur_systemes", 0.016), H("platrier", 0.02), E(0.02, "échafaud roulant, ponceuse")]),
266 + A("INT-DRYWALL-5/8-CEILING", "09", "interior", "Gypse de plafond 5/8 po posé et tiré", "5/8 in ceiling drywall hung and taped", "pi2", 50, "interior",
267 + "Panneau 5/8 po au plafond, ruban, composé 3 couches.", "5/8 in ceiling board, tape, 3 coats of compound.",
268 + [C("GYP-5/8-4X8", 1, { w: 0.1 }), C("SCREWS-DRYWALL", 0.009), C("DRYWALL-TAPE-250FT", 0.0035), C("JOINT-COMPOUND-20KG", 0.005), H("poseur_systemes", 0.02), H("platrier", 0.02), E(0.03, "lève-panneaux")]),
269 + A("INT-PAINT-2-COATS", "09", "interior", "Peinture intérieure (apprêt + 2 couches)", "Interior paint (primer + 2 coats)", "pi2", 8, "interior",
270 + "Apprêt (375 pi²/gal) et 2 couches de latex (400 pi²/gal/couche) sur gypse neuf.", "Primer (375 sq ft/gal) and 2 coats of latex (400 sq ft/gal/coat) on new drywall.",
271 + [C("PRIMER-GAL", 0.0027), C("PAINT-INT-GAL", 0.005), H("peintre", 0.012)]),
272 + A("INT-FLOOR-HARDWOOD", "09", "interior", "Plancher de bois franc préverni", "Prefinished hardwood flooring", "pi2", 40, "interior",
273 + "Chêne 3 1/4 po cloué sur papier, cloueuse pneumatique.", "3 1/4 in oak nailed over paper, pneumatic nailer.",
274 + [C("FLOOR-HARDWOOD-OAK", 1, { w: 0.08 }), C("UNDERLAY-FOAM", 1, { w: 0.05 }), C("NAILS-FRAMING", 0.01, { n: "agrafes" }), H("charpentier", 0.035), E(0.1, "cloueuse")]),
275 + A("INT-FLOOR-ENGINEERED", "09", "interior", "Plancher d'ingénierie flottant", "Floating engineered flooring", "pi2", 30, "interior",
276 + "Lames d'ingénierie sur sous-couche.", "Engineered planks over underlayment.",
277 + [C("FLOOR-ENGINEERED", 1, { w: 0.08 }), C("UNDERLAY-FOAM", 1, { w: 0.05 }), H("charpentier", 0.028)]),
278 + A("INT-FLOOR-VINYL-PLANK", "09", "interior", "Plancher de vinyle SPC", "SPC vinyl plank flooring", "pi2", 20, "interior",
279 + "Planches SPC à sous-couche intégrée, pose flottante.", "SPC planks with attached pad, floating install.",
280 + [C("FLOOR-VINYL-PLANK", 1, { w: 0.08 }), H("poseur_revetements", 0.02)]),
281 + A("INT-FLOOR-LAMINATE", "09", "interior", "Plancher flottant stratifié", "Laminate flooring", "pi2", 18, "interior",
282 + "Stratifié 12 mm sur sous-couche mousse.", "12 mm laminate over foam underlayment.",
283 + [C("FLOOR-LAMINATE", 1, { w: 0.08 }), C("UNDERLAY-FOAM", 1, { w: 0.05 }), H("poseur_revetements", 0.02)]),
284 + A("INT-FLOOR-CERAMIC", "09", "interior", "Plancher de céramique", "Ceramic tile flooring", "pi2", 40, "interior",
285 + "Panneau d'appui 1/4 po, mortier-colle, carreaux 12 × 24, coulis.", "1/4 in backer board, thinset, 12 × 24 tiles, grout.",
286 + [C("TILE-CERAMIC-12X24", 1, { w: 0.1 }), C("TILE-BACKER-1/4", 1, { w: 0.08 }), C("THINSET-22KG", 0.02), C("GROUT-4.5KG", 0.017), H("carreleur", 0.075), E(0.05, "scie à eau")]),
287 + A("INT-FLOOR-CARPET", "09", "interior", "Tapis et sous-tapis", "Carpet and underpad", "pi2", 10, "interior",
288 + "Tapis résidentiel tendu sur sous-tapis.", "Stretched residential carpet over underpad.",
289 + [C("FLOOR-CARPET", 1, { w: 0.1 }), C("UNDERLAY-FOAM", 1, { w: 0.05 }), H("poseur_revetements", 0.012)]),
290 + A("INT-TRIM-BASEBOARD", "09", "interior", "Plinthes MDF peintes", "Painted MDF baseboards", "pi_lin", 25, "interior",
291 + "Plinthe 4 po clouée, calfeutrée, 2 couches de peinture.", "4 in baseboard nailed, caulked, 2 coats of paint.",
292 + [C("TRIM-BASEBOARD-MDF", 1, { w: 0.1 }), C("CAULK-TUBE", 0.01), C("PAINT-INT-GAL", 0.001), H("charpentier", 0.04), H("peintre", 0.01)]),
293 + A("INT-CLOSET-SHELVING", "09", "interior", "Tablettes et tringles de garde-robe", "Closet shelving and rods", "pi_lin", 25, "interior",
294 + "Tablette mélamine 12 po avec tringle.", "12 in melamine shelf with rod.",
295 + [C("CLOSET-SHELVING", 1, { w: 0.05 }), H("charpentier", 0.15)]),
296 + A("BSM-SUBFLOOR-PANELS", "06", "basement", "Sous-plancher isolé de sous-sol", "Insulated basement subfloor", "pi2", 25, "interior",
297 + "Panneaux 2 × 2 à surface OSB sur dalle.", "2 × 2 OSB-faced panels over slab.",
298 + [C("SUBFLOOR-PANEL", 1, { w: 0.05 }), H("charpentier", 0.02)]),
299 +
300 + /* ============================================================== CUISINE */
301 + A("KIT-ECONOMY", "12", "kitchen", "Cuisine économique", "Economy kitchen", "pi_lin", 15, "kitchen",
302 + "Armoires de mélamine, comptoir stratifié postformé, évier inox, robinet, hotte. Par pi lin d'armoires (base + haut).",
303 + "Melamine cabinets, post-formed laminate counter, stainless sink, faucet, hood. Per lin ft of cabinets (base + upper).",
304 + [C("CAB-ECON", 1), C("COUNTER-LAMINATE", 0.65, { w: 0.1 }), C("SINK-KITCHEN-SS", 0.045), C("FAUCET-KITCHEN", 0.045), C("RANGE-HOOD", 0.045), H("charpentier", 0.9), H("plombier", 0.15), H("electricien", 0.08)], { quality: "economy" }),
305 + A("KIT-STANDARD", "12", "kitchen", "Cuisine standard", "Standard kitchen", "pi_lin", 20, "kitchen",
306 + "Armoires thermoplastique/polyester, comptoir de quartz d'entrée de gamme, dosseret céramique, évier inox, robinet, hotte.",
307 + "Thermofoil/polyester cabinets, entry-level quartz counter, ceramic backsplash, stainless sink, faucet, hood.",
308 + [C("CAB-STD", 1), C("COUNTER-QUARTZ", 1.6), C("TILE-WALL-SUBWAY", 1.2, { w: 0.1 }), C("THINSET-22KG", 0.02), C("GROUT-4.5KG", 0.02), C("SINK-KITCHEN-SS", 0.04), C("FAUCET-KITCHEN", 0.04), C("RANGE-HOOD", 0.04), H("charpentier", 1.0), H("carreleur", 0.1), H("plombier", 0.15), H("electricien", 0.1)], { quality: "standard" }),
309 + A("KIT-SUPERIOR", "12", "kitchen", "Cuisine supérieure", "Superior kitchen", "pi_lin", 25, "kitchen",
310 + "Armoires MDF peint/bois à fermeture douce, quartz avec îlot, dosseret, éclairage encastré, évier sous plan, robinet, hotte.",
311 + "Painted MDF/wood soft-close cabinets, quartz with island, backsplash, recessed lighting, undermount sink, faucet, hood.",
312 + [C("CAB-SUP", 1), C("COUNTER-QUARTZ", 1.9), C("TILE-WALL-SUBWAY", 1.3, { w: 0.1 }), C("THINSET-22KG", 0.022), C("GROUT-4.5KG", 0.022), C("LIGHT-FIXTURE-STD", 0.15), C("SINK-KITCHEN-SS", 0.032), C("FAUCET-KITCHEN", 0.032), C("RANGE-HOOD", 0.032), H("charpentier", 1.15), H("carreleur", 0.12), H("plombier", 0.15), H("electricien", 0.15)], { quality: "superior" }),
313 + A("KIT-PRESTIGE", "12", "kitchen", "Cuisine prestige", "Prestige kitchen", "pi_lin", 30, "kitchen",
314 + "Armoires bois massif/laque, granit ou quartz épais avec grand îlot, dosseret pleine hauteur, éclairage encastré et sous-armoires.",
315 + "Solid wood/lacquer cabinets, thick granite or quartz with large island, full-height backsplash, recessed and under-cabinet lighting.",
316 + [C("CAB-PRESTIGE", 1), C("COUNTER-GRANITE", 2.0), C("TILE-WALL-SUBWAY", 1.8, { w: 0.1 }), C("THINSET-22KG", 0.03), C("GROUT-4.5KG", 0.03), C("LIGHT-FIXTURE-STD", 0.2), C("SINK-KITCHEN-SS", 0.025), C("FAUCET-KITCHEN", 0.025), C("RANGE-HOOD", 0.025), H("charpentier", 1.3), H("carreleur", 0.15), H("plombier", 0.15), H("electricien", 0.2)], { quality: "prestige" }),
317 +
318 + /* ======================================================= SALLES DE BAIN */
319 + A("BATH-ECONOMY", "22", "bathroom", "Salle de bain économique", "Economy bathroom", "unit", 20, "bathroom",
320 + "Toilette, meuble-lavabo 36 po, baignoire alcôve avec céramique murale (55 pi²), plancher céramique 40 pi², ventilateur, miroir, accessoires. Finition des appareils (la plomberie brute est comptée à part).",
321 + "Toilet, 36 in vanity, alcove tub with wall tile (55 sq ft), 40 sq ft ceramic floor, fan, mirror, accessories. Fixture finish (rough plumbing counted separately).",
322 + [C("TOILET", 1), C("VANITY-36", 1), C("FAUCET-LAV", 1), C("TUB-ALCOVE-60", 1), C("TUB-SHOWER-FAUCET", 1), C("TILE-WALL-SUBWAY", 55, { w: 0.1 }), C("TILE-CERAMIC-12X24", 40, { w: 0.1 }), C("TILE-BACKER-1/4", 95, { w: 0.08 }), C("THINSET-22KG", 2), C("GROUT-4.5KG", 2), C("EXHAUST-FAN", 1), C("MIRROR", 1), C("BATH-ACCESSORIES", 1), H("plombier", 14), H("carreleur", 9), H("charpentier", 4), H("electricien", 2), H("peintre", 2)], { quality: "economy" }),
323 + A("BATH-STANDARD", "22", "bathroom", "Salle de bain standard", "Standard bathroom", "unit", 22, "bathroom",
324 + "Toilette, meuble-lavabo 36 po, baignoire alcôve, céramique murale jusqu'au plafond (90 pi²), plancher porcelaine 45 pi², ventilateur, miroir, accessoires.",
325 + "Toilet, 36 in vanity, alcove tub, wall tile to ceiling (90 sq ft), 45 sq ft porcelain floor, fan, mirror, accessories.",
326 + [C("TOILET", 1), C("VANITY-36", 1), C("FAUCET-LAV", 1), C("TUB-ALCOVE-60", 1), C("TUB-SHOWER-FAUCET", 1), C("SHOWER-DOOR-GLASS", 1), C("TILE-WALL-SUBWAY", 90, { w: 0.1 }), C("TILE-PORCELAIN", 45, { w: 0.1 }), C("TILE-BACKER-1/4", 135, { w: 0.08 }), C("THINSET-22KG", 3), C("GROUT-4.5KG", 3), C("EXHAUST-FAN", 1), C("MIRROR", 1), C("BATH-ACCESSORIES", 1), H("plombier", 16), H("carreleur", 13), H("charpentier", 5), H("electricien", 2.5), H("peintre", 2)], { quality: "standard" }),
327 + A("BATH-SUPERIOR", "22", "bathroom", "Salle de bain supérieure", "Superior bathroom", "unit", 25, "bathroom",
328 + "Baignoire autoportante, douche en céramique avec porte de verre, meuble double 60 po quartz, toilette, porcelaine au sol (60 pi²) et aux murs (120 pi²), ventilateur, miroirs, accessoires.",
329 + "Freestanding tub, tiled shower with glass door, 60 in double quartz vanity, toilet, porcelain floor (60 sq ft) and walls (120 sq ft), fan, mirrors, accessories.",
330 + [C("TOILET", 1), C("VANITY-60-DOUBLE", 1), C("FAUCET-LAV", 2), C("TUB-FREESTANDING", 1), C("SHOWER-BASE-48", 1), C("SHOWER-DOOR-GLASS", 1), C("TUB-SHOWER-FAUCET", 2), C("TILE-WALL-SUBWAY", 120, { w: 0.1 }), C("TILE-PORCELAIN", 60, { w: 0.1 }), C("TILE-BACKER-1/4", 180, { w: 0.08 }), C("THINSET-22KG", 4), C("GROUT-4.5KG", 4), C("EXHAUST-FAN", 1), C("MIRROR", 2), C("BATH-ACCESSORIES", 1), H("plombier", 22), H("carreleur", 18), H("charpentier", 6), H("electricien", 3), H("peintre", 2.5)], { quality: "superior" }),
331 + A("BATH-POWDER", "22", "bathroom", "Salle d'eau", "Powder room", "unit", 25, "bathroom",
332 + "Toilette, meuble-lavabo, robinet, miroir, ventilateur, plancher céramique 20 pi².", "Toilet, vanity, faucet, mirror, fan, 20 sq ft ceramic floor.",
333 + [C("TOILET", 1), C("VANITY-36", 1), C("FAUCET-LAV", 1), C("MIRROR", 1), C("EXHAUST-FAN", 1), C("TILE-CERAMIC-12X24", 20, { w: 0.1 }), C("TILE-BACKER-1/4", 20, { w: 0.08 }), C("THINSET-22KG", 0.5), C("GROUT-4.5KG", 0.5), H("plombier", 6), H("carreleur", 3), H("charpentier", 1.5), H("electricien", 1)]),
334 +
335 + /* ============================================================ PLOMBERIE */
336 + A("MEC-PLUMBING-ROUGH-IN", "22", "plumbing", "Plomberie brute (par appareil)", "Rough plumbing (per fixture)", "unit", 50, "mechanical",
337 + "Alimentation PEX 1/2 et 3/4, évacuation ABS 3 po et 1 1/2 po, raccords, évents, supports — par appareil sanitaire (toilette, lavabo, bain, douche, évier, laveuse, chauffe-eau…).",
338 + "PEX 1/2 and 3/4 supply, 3 in and 1 1/2 in ABS drains, fittings, vents, hangers — per plumbing fixture (toilet, sink, tub, shower, kitchen sink, washer, water heater…).",
339 + [C("PEX-1/2", 40, { w: 0.05 }), C("PEX-3/4", 10, { w: 0.05 }), C("PEX-FITTINGS", 6), C("ABS-3IN", 8, { w: 0.05 }), C("ABS-1.5IN", 12, { w: 0.05 }), C("ABS-FITTINGS", 6), C("PLUMB-ROUGH-MISC", 1), H("plombier", 6)]),
340 + A("MEC-WATER-HEATER-60", "22", "plumbing", "Chauffe-eau électrique 60 gal installé", "Electric water heater 60 gal installed", "unit", 12, "mechanical",
341 + "Réservoir 60 gal, raccords, soupape, alimentation électrique 240 V.", "60 gal tank, connections, relief valve, 240 V feed.",
342 + [C("WATER-HEATER-60GAL", 1), C("PEX-3/4", 10, { w: 0.05 }), C("PEX-FITTINGS", 4), C("WIRE-12/2", 25, { w: 0.05 }), H("plombier", 3), H("electricien", 1)]),
343 + A("MEC-DRAINAGE-BASE", "22", "plumbing", "Drainage de base (clapet, puisard, robinets extérieurs)", "Base drainage (backwater valve, sump, hose bibs)", "lump", 30, "mechanical",
344 + "Clapet antiretour, pompe et bassin de puisard, 2 robinets extérieurs antigel, drain de plancher et collecteur ABS 3 po (30 pi).",
345 + "Backwater valve, sump pump and pit, 2 frost-free hose bibs, floor drain and 3 in ABS collector (30 ft).",
346 + [C("BACKWATER-VALVE", 1), C("SUMP-PUMP", 1), C("HOSE-BIB", 2), C("ABS-3IN", 30, { w: 0.05 }), C("ABS-FITTINGS", 8), H("plombier", 8)]),
347 +
348 + /* ================================================================= CVCA */
349 + A("MEC-HEATPUMP-CENTRAL", "23", "hvac", "Thermopompe centrale installée", "Central heat pump installed", "unit", 15, "mechanical",
350 + "Unité extérieure 3 tonnes, serpentin, conduites frigorifiques, thermostat central, mise en marche. Conduits comptés à part.",
351 + "3-ton outdoor unit, coil, refrigerant lines, central thermostat, start-up. Ductwork counted separately.",
352 + [C("HEATPUMP-CENTRAL-3T", 1), C("THERMOSTAT-CENTRAL", 1), H("frigoriste", 14), H("electricien", 3), E(150, "pompe à vide, manutention")]),
353 + A("MEC-HEATPUMP-WALL", "23", "hvac", "Thermopompe murale installée", "Wall-mounted heat pump installed", "unit", 15, "mechanical",
354 + "Bibloc mural 12 000 BTU, conduites, support, alimentation électrique.", "12,000 BTU wall split, lines, bracket, electrical feed.",
355 + [C("HEATPUMP-WALL-12K", 1), C("WIRE-12/2", 40, { w: 0.05 }), H("frigoriste", 6), H("electricien", 2), E(60, "pompe à vide")]),
356 + A("MEC-FURNACE-ELECTRIC", "23", "hvac", "Fournaise électrique installée", "Electric furnace installed", "unit", 20, "mechanical",
357 + "Fournaise 20 kW, plénum, thermostat central, alimentation.", "20 kW furnace, plenum, central thermostat, feed.",
358 + [C("FURNACE-ELECTRIC-20KW", 1), C("THERMOSTAT-CENTRAL", 1), H("frigoriste", 8), H("electricien", 4)]),
359 + A("MEC-FURNACE-GAS", "23", "hvac", "Fournaise au gaz installée", "Gas furnace installed", "unit", 20, "mechanical",
360 + "Fournaise à condensation 60 000 BTU, évacuation, tuyauterie de gaz, thermostat.", "60,000 BTU condensing furnace, venting, gas piping, thermostat.",
361 + [C("FURNACE-GAS-60K", 1), C("THERMOSTAT-CENTRAL", 1), H("frigoriste", 10), H("plombier", 4), H("electricien", 2)]),
362 + A("MEC-CENTRAL-AC", "23", "hvac", "Climatiseur central installé", "Central air conditioner installed", "unit", 15, "mechanical",
363 + "Condenseur 2,5 tonnes, serpentin, conduites, mise en marche.", "2.5-ton condenser, coil, lines, start-up.",
364 + [C("CENTRAL-AC-2.5T", 1), H("frigoriste", 10), H("electricien", 2), E(100, "pompe à vide")]),
365 + A("MEC-DUCTWORK", "23", "hvac", "Conduits de distribution d'air", "Air distribution ductwork", "pi2", 40, "mechanical",
366 + "Conduits de tôle, raccords, grilles et diffuseurs — par pi² de plancher desservi.", "Sheet-metal ducts, fittings, grilles and diffusers — per sq ft of floor served.",
367 + [C("DUCT-SHEETMETAL", 1), H("ferblantier", 0.02)]),
368 + A("MEC-HRV", "23", "hvac", "Échangeur d'air (VRC) installé", "HRV installed", "unit", 18, "mechanical",
369 + "VRC, conduits dédiés (allocation), bouches, commande murale.", "HRV, dedicated ducts (allowance), grilles, wall control.",
370 + [C("HRV", 1), C("DUCT-SHEETMETAL", 80, { n: "allocation de conduits dédiés" }), H("ferblantier", 8), H("electricien", 1)]),
371 + A("MEC-BASEBOARD-ELECTRIC", "23", "hvac", "Plinthe électrique 1 500 W avec thermostat", "1,500 W electric baseboard with thermostat", "unit", 25, "mechanical",
372 + "Plinthe 240 V, thermostat électronique, câble 12/2 (35 pi), demi-disjoncteur bipolaire. 1 plinthe par ≈ 150 pi².",
373 + "240 V baseboard, electronic thermostat, 12/2 cable (35 ft), half a 2-pole breaker. 1 baseboard per ≈ 150 sq ft.",
374 + [C("BASEBOARD-1500W", 1), C("THERMOSTAT", 1), C("WIRE-12/2", 35, { w: 0.05 }), C("BREAKER-15A", 1), H("electricien", 1.4)]),
375 +
376 + /* ========================================================== ÉLECTRICITÉ */
377 + A("ELE-SERVICE-200A", "26", "electrical", "Entrée et panneau électrique 200 A", "200 A service entrance and panel", "unit", 40, "electrical",
378 + "Mât, câble d'entrée, embase de compteur, mise à la terre, panneau 40 circuits, 24 disjoncteurs.", "Mast, service cable, meter base, grounding, 40-circuit panel, 24 breakers.",
379 + [C("PANEL-200A-40", 1), C("SERVICE-MAST-200A", 1), C("METER-BASE", 1), C("BREAKER-15A", 24), H("electricien", 14)]),
380 + A("ELE-ROUGH-WIRING", "26", "electrical", "Câblage et dispositifs (par pi² de plancher)", "Wiring and devices (per sq ft floor)", "pi2", 40, "electrical",
381 + "Câble 14/2 (1,4 pi/pi²) et 12/2 (0,4 pi/pi²), boîtes, prises, interrupteurs, DDFT, détecteurs, plaques.",
382 + "14/2 cable (1.4 ft/sq ft) and 12/2 (0.4 ft/sq ft), boxes, receptacles, switches, GFCI, detectors, plates.",
383 + [C("WIRE-14/2", 1.4, { w: 0.05 }), C("WIRE-12/2", 0.4, { w: 0.05 }), C("BOX-DEVICE", 0.02), C("RECEPTACLE", 0.012), C("SWITCH", 0.006), C("GFCI", 0.0015), C("COVER-PLATE", 0.018), C("SMOKE-CO-DETECTOR", 0.0012), H("electricien", 0.045)]),
384 + A("ELE-LIGHTING-STD", "26", "electrical", "Luminaire encastré DEL", "LED recessed light fixture", "unit", 20, "electrical",
385 + "Encastré 4 po, boîte, 15 pi de câble 14/2.", "4 in recessed fixture, box, 15 ft of 14/2 cable.",
386 + [C("LIGHT-FIXTURE-STD", 1), C("BOX-DEVICE", 1), C("WIRE-14/2", 15, { w: 0.05 }), H("electricien", 0.7)]),
387 + A("ELE-LIGHTING-EXT", "26", "electrical", "Luminaire extérieur", "Exterior light fixture", "unit", 20, "electrical",
388 + "Luminaire mural extérieur, boîte, 20 pi de câble.", "Exterior wall fixture, box, 20 ft of cable.",
389 + [C("LIGHT-FIXTURE-EXT", 1), C("BOX-DEVICE", 1), C("WIRE-14/2", 20, { w: 0.05 }), H("electricien", 0.8)]),
390 + A("ELE-EV-CHARGER", "26", "electrical", "Circuit et borne de recharge VÉ", "EV charging circuit and charger", "unit", 15, "electrical",
391 + "Circuit 40 A dédié et borne niveau 2.", "Dedicated 40 A circuit and level 2 charger.",
392 + [C("EV-CHARGER-CIRCUIT", 1), H("electricien", 4)]),
393 +
394 + /* ============================================================ EXTÉRIEUR */
395 + A("EXT-DECK-TREATED", "06", "exterior", "Terrasse en bois traité", "Treated-wood deck", "pi2", 20, "site",
396 + "Solives 2 × 8 traitées 16 po c/c, poteaux 6 × 6 sur pieux, planches 5/4 × 6, vis. Hors rampe.",
397 + "Treated 2 × 8 joists 16 in o.c., 6 × 6 posts on piers, 5/4 × 6 boards, screws. Excludes railing.",
398 + [C("TREATED-5/4X6-12", 0.2, { w: 0.1 }), C("TREATED-2X8-12", 0.09, { w: 0.07 }), C("TREATED-6X6-10", 0.012), C("CONCRETE-PIER-TUBE", 0.012), C("CONC-BAG-30KG", 0.05), C("SCREWS-DECK", 0.03), C("HANGERS-JOIST", 0.05), H("charpentier", 0.09)]),
399 + A("EXT-DECK-RAILING-WOOD", "06", "exterior", "Rampe de terrasse en bois traité", "Treated-wood deck railing", "pi_lin", 15, "site",
400 + "Poteaux 4 × 4, mains courantes 2 × 6, barreaux, vis.", "4 × 4 posts, 2 × 6 rails, balusters, screws.",
401 + [C("TREATED-2X6-12", 0.2, { w: 0.07 }), C("TREATED-4X4-8", 0.2), C("SCREWS-DECK", 0.05), H("charpentier", 0.35)]),
402 + A("EXT-DECK-RAILING-ALU", "05", "exterior", "Rampe d'aluminium", "Aluminum railing", "pi_lin", 30, "site",
403 + "Sections de rampe d'aluminium avec poteaux.", "Aluminum railing sections with posts.",
404 + [C("DECK-RAILING-ALU", 1), H("charpentier", 0.25)]),
405 + A("EXT-DRIVEWAY-ASPHALT", "32", "exterior", "Entrée asphaltée", "Asphalt driveway", "pi2", 18, "site",
406 + "Fondation granulaire 0-3/4 compactée et enrobé bitumineux 2 po, épandage mécanique et compaction.",
407 + "Compacted 0-3/4 granular base and 2 in asphalt, mechanical spreading and compaction.",
408 + [C("ASPHALT-DRIVEWAY", 1), H("manoeuvre", 0.02), H("operateur", 0.01), E(0.9, "finisseur, rouleau")]),
409 + A("EXT-DRIVEWAY-PAVERS", "32", "exterior", "Entrée en pavés de béton", "Concrete-paver driveway", "pi2", 30, "site",
410 + "Fondation 8 po de gravier, lit de sable, pavés, sable polymère, compaction.", "8 in gravel base, sand bed, pavers, polymeric sand, compaction.",
411 + [C("PAVER-STONE", 1, { w: 0.05 }), C("BACKFILL-GRAVEL", 0.02), C("SAND", 0.003), H("manoeuvre", 0.08), H("briqueteur", 0.05), E(0.15, "plaque vibrante")]),
412 + A("EXT-DRIVEWAY-GRAVEL", "32", "exterior", "Entrée de gravier", "Gravel driveway", "pi2", 15, "site",
413 + "Gravier 0-3/4 6 po nivelé et compacté.", "6 in of 0-3/4 gravel graded and compacted.",
414 + [C("BACKFILL-GRAVEL", 0.015), C("EXCAVATOR-HOUR", 0.002), H("operateur", 0.002)]),
415 + A("EXT-FENCE-WOOD", "32", "exterior", "Clôture de bois traité 6 pi", "Treated-wood fence 6 ft", "pi_lin", 18, "site",
416 + "Panneaux 6 × 8, poteaux 4 × 4 aux 8 pi bétonnés.", "6 × 8 panels, 4 × 4 posts every 8 ft set in concrete.",
417 + [C("FENCE-PANEL-WOOD", 1), C("TREATED-4X4-8", 0.125), C("CONC-BAG-30KG", 0.25), C("SCREWS-DECK", 0.02), H("charpentier", 0.2), H("manoeuvre", 0.1)]),
418 + A("EXT-LANDSCAPING-SOD", "32", "exterior", "Terre et gazon en plaques", "Topsoil and sod", "pi2", 25, "site",
419 + "Terre à jardin 4 po nivelée, gazon en plaques.", "4 in graded topsoil, sod.",
420 + [C("SOD", 1, { w: 0.05 }), C("TOPSOIL", 0.0095), H("manoeuvre", 0.008), E(0.02, "tracteur")]),
421 + A("EXT-STEPS-PRECAST", "03", "exterior", "Marches préfabriquées en béton", "Precast concrete steps", "unit", 40, "site",
422 + "Marches préfabriquées livrées et posées à la grue.", "Precast steps delivered and craned into place.",
423 + [C("STEPS-PRECAST", 1), H("manoeuvre", 2), E(150, "grue de livraison")]),
424 + A("EXT-POOL-ABOVEGROUND", "13", "exterior", "Piscine hors terre", "Above-ground pool", "lump", 15, "site",
425 + "Ensemble piscine 21 pi avec filtration, montage.", "21 ft pool kit with filtration, assembly.",
426 + [C("POOL-ABOVEGROUND", 1), H("manoeuvre", 16), H("electricien", 3)]),
427 + A("EXT-POOL-INGROUND", "13", "exterior", "Piscine creusée", "Inground pool", "lump", 25, "site",
428 + "Piscine creusée clé en main 16 × 32 (excavation, coque/béton, filtration, trottoir).", "Turnkey 16 × 32 inground pool (excavation, shell, filtration, deck).",
429 + [C("POOL-INGROUND", 1), H("electricien", 8)]),
430 +];
431 +
432 +export const ASSEMBLY_BY_CODE = new Map(ASSEMBLY_SEEDS.map((a) => [a.code, a]));
added src/lib/cost/seed/items.ts +254 −0
@@ -0,0 +1,254 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Catalogue canonique des articles (cost_items). Module PUR.
4 + *
5 + * `ref` = PRIX DE RÉFÉRENCE INTERNE (hypothèse documentée, $ CA, unité canonique,
6 + * niveau 2026 Québec) utilisé UNIQUEMENT quand aucune observation détaillant /
7 + * source officielle n'existe pour l'article. Il est toujours étiqueté
8 + * « ◇ référence interne (hypothèse) », réduit la couverture observée et donc
9 + * l'indice de confiance. Aucun prix de référence n'est présenté comme observé.
10 + *
11 + * `retail` = indice de recherche pour les connecteurs détaillants (Firecrawl) et
12 + * `pack` = contenu d'un emballage en unités canoniques (ex. feuille 4×8 = 32 pi²).
13 + */
14 +import type { CanonicalUnit, Quality, UxCategory } from "../types";
15 +
16 +export interface ItemSeed {
17 + code: string;
18 + mf: string;
19 + cat: UxCategory;
20 + fr: string;
21 + en: string;
22 + unit: CanonicalUnit;
23 + /** prix de référence interne (hypothèse) */
24 + ref: number;
25 + refNote?: string;
26 + trade?: string;
27 + waste?: number;
28 + cls?: "material" | "equipment" | "service" | "rental";
29 + quality?: Quality;
30 + retail?: { q: string; pack?: number; packUnit?: string };
31 +}
32 +
33 +const I = (
34 + code: string,
35 + mf: string,
36 + cat: UxCategory,
37 + fr: string,
38 + en: string,
39 + unit: CanonicalUnit,
40 + ref: number,
41 + o: Partial<Omit<ItemSeed, "code" | "mf" | "cat" | "fr" | "en" | "unit" | "ref">> = {}
42 +): ItemSeed => ({ code, mf, cat, fr, en, unit, ref, ...o });
43 +
44 +const R = (q: string, pack?: number, packUnit?: string) => ({ q, pack, packUnit });
45 +
46 +export const ITEM_SEEDS: ItemSeed[] = [
47 + /* ------------------------------------------------ 06 bois de charpente */
48 + I("LUM-2X4-8", "06", "structure", "Épinette 2 × 4 × 8 pi (SPF, sec)", "SPF 2 × 4 × 8 ft (KD)", "unit", 3.6, { trade: "charpentier", waste: 0.07, retail: R("épinette de construction 2 po x 4 po x 8 pi sec") }),
49 + I("LUM-2X4-92", "06", "structure", "Montant 2 × 4 × 92 5/8 po (SPF, sec)", "SPF stud 2 × 4 × 92 5/8 in", "unit", 3.9, { trade: "charpentier", waste: 0.05, retail: R("épinette stud 2 po x 4 po x 92 5/8 po sec") }),
50 + I("LUM-2X6-8", "06", "structure", "Épinette 2 × 6 × 8 pi (SPF, sec)", "SPF 2 × 6 × 8 ft (KD)", "unit", 5.98, { trade: "charpentier", waste: 0.07, retail: R("épinette de construction 2 po x 6 po x 8 pi sec") }),
51 + I("LUM-2X6-92", "06", "structure", "Montant 2 × 6 × 92 5/8 po (SPF, sec)", "SPF stud 2 × 6 × 92 5/8 in", "unit", 6.5, { trade: "charpentier", waste: 0.05, retail: R("épinette stud 2 po x 6 po x 92 5/8 po sec") }),
52 + I("LUM-2X8-12", "06", "structure", "Épinette 2 × 8 × 12 pi", "SPF 2 × 8 × 12 ft", "unit", 16.0, { trade: "charpentier", waste: 0.07, retail: R("épinette 2 po x 8 po x 12 pi") }),
53 + I("LUM-2X10-12", "06", "structure", "Épinette 2 × 10 × 12 pi", "SPF 2 × 10 × 12 ft", "unit", 22.0, { trade: "charpentier", waste: 0.07, retail: R("épinette 2 po x 10 po x 12 pi") }),
54 + I("LUM-2X12-12", "06", "structure", "Épinette 2 × 12 × 12 pi", "SPF 2 × 12 × 12 ft", "unit", 30.0, { trade: "charpentier", waste: 0.07, retail: R("épinette 2 po x 12 po x 12 pi") }),
55 + I("LUM-1X3-8", "06", "envelope", "Fourrure 1 × 3 × 8 pi", "Furring strip 1 × 3 × 8 ft", "unit", 2.4, { trade: "charpentier", waste: 0.08, retail: R("fourrure 1 po x 3 po x 8 pi") }),
56 + I("LVL-1.75X11.875", "06", "structure", "Poutre LVL 1 3/4 × 11 7/8 po", "LVL beam 1 3/4 × 11 7/8 in", "pi_lin", 14.0, { trade: "charpentier", waste: 0.03, retail: R("poutre LVL 1 3/4 x 11 7/8", 12, "longueur") }),
57 + I("IJOIST-11.875", "06", "structure", "Poutrelle ajourée 11 7/8 po", "I-joist 11 7/8 in", "pi_lin", 5.5, { trade: "charpentier", waste: 0.03, refNote: "Prix fournisseur de structure (non vendu au détail) — référence 2026." }),
58 + I("OSB-7/16-4X8", "06", "structure", "Panneau OSB 7/16 po 4 × 8 pi", "OSB sheathing 7/16 in 4 × 8 ft", "pi2", 0.95, { trade: "charpentier", waste: 0.08, retail: R("panneau OSB 7/16 po 4 x 8", 32, "feuille") }),
59 + I("OSB-5/8-TG-4X8", "06", "structure", "OSB 5/8 po languette et rainure (sous-plancher)", "OSB 5/8 in T&G subfloor", "pi2", 1.3, { trade: "charpentier", waste: 0.06, retail: R("OSB 5/8 po languette rainure 4 x 8", 32, "feuille") }),
60 + I("PLY-1/2-4X8", "06", "structure", "Contreplaqué 1/2 po 4 × 8 pi", "Plywood 1/2 in 4 × 8 ft", "pi2", 1.75, { trade: "charpentier", waste: 0.08, retail: R("contreplaqué 1/2 po 4 x 8 sapin", 32, "feuille") }),
61 + I("PLY-3/4-TG-4X8", "06", "structure", "Contreplaqué 3/4 po L&R 4 × 8 pi", "Plywood 3/4 in T&G 4 × 8 ft", "pi2", 2.4, { trade: "charpentier", waste: 0.06, retail: R("contreplaqué 3/4 po languette rainure 4 x 8", 32, "feuille") }),
62 + I("TRUSS-ROOF", "06", "structure", "Fermes de toit préfabriquées (par pi² de toit)", "Prefabricated roof trusses (per sq ft roof)", "pi2", 6.5, { trade: "charpentier", refNote: "Prix d'usine de fermes (devis fournisseur) — référence 2026, non vendu au détail." }),
63 + I("STEEL-COLUMN-ADJ", "05", "structure", "Colonne d'acier ajustable", "Adjustable steel column", "unit", 95, { trade: "charpentier", retail: R("colonne d'acier ajustable") }),
64 + I("SUBFLOOR-ADHESIVE", "06", "structure", "Adhésif de sous-plancher (tube)", "Subfloor adhesive (tube)", "unit", 9.0, { retail: R("adhésif de sous-plancher PL400 tube") }),
65 + I("NAILS-FRAMING", "06", "structure", "Clous à charpente 3 1/4 po (lb)", "Framing nails 3 1/4 in (lb)", "lb", 3.2, { retail: R("clous à charpente 3 1/4 po 50 lb", 50, "boîte") }),
66 + I("SCREWS-DECK", "06", "exterior", "Vis à terrasse 3 po (lb)", "Deck screws 3 in (lb)", "lb", 9.0, { retail: R("vis à terrasse 3 po 5 lb", 5, "boîte") }),
67 + I("SCREWS-DRYWALL", "09", "interior", "Vis à gypse 1 1/4 po (lb)", "Drywall screws 1 1/4 in (lb)", "lb", 6.0, { retail: R("vis à gypse 1 1/4 po 5 lb", 5, "boîte") }),
68 + I("HANGERS-JOIST", "06", "structure", "Étrier / attache métallique", "Joist hanger / metal connector", "unit", 1.8, { retail: R("étrier de solive 2 x 10") }),
69 + I("SUBFLOOR-PANEL", "06", "basement", "Panneau de sous-plancher isolé 2 × 2 pi", "Insulated subfloor panel 2 × 2 ft", "pi2", 2.6, { trade: "charpentier", waste: 0.05, retail: R("panneau de sous-plancher DRIcore 2 x 2", 4, "panneau") }),
70 +
71 + /* ------------------------------------------------- 03/04 béton, maçonnerie */
72 + I("CONC-READYMIX-30MPA", "03", "foundation", "Béton préparé 30 MPa livré (m³)", "Ready-mix concrete 30 MPa delivered (m³)", "m3", 265, { trade: "coffreur", waste: 0.05, refNote: "Béton livré par bétonnière (non vendu au détail) — référence 2026, Montréal/Québec." }),
73 + I("CONC-BAG-30KG", "03", "foundation", "Béton prémélangé 30 kg (sac)", "Pre-mixed concrete 30 kg (bag)", "unit", 7.5, { retail: R("béton prémélangé 30 kg") }),
74 + I("REBAR-10M-20FT", "03", "foundation", "Barre d'armature 10M × 20 pi", "Rebar 10M × 20 ft", "unit", 14.0, { trade: "ferrailleur", waste: 0.05, retail: R("barre d'armature 10M 20 pi") }),
75 + I("WIRE-MESH-6X6", "03", "foundation", "Treillis métallique 6 × 6 (pi²)", "Welded wire mesh 6 × 6 (sq ft)", "pi2", 0.45, { waste: 0.1, retail: R("treillis métallique 6 x 6 5 pi x 10 pi", 50, "feuille") }),
76 + I("FORM-RENTAL", "03", "foundation", "Location de coffrages (par pi² de mur)", "Formwork rental (per sq ft wall)", "pi2", 3.5, { cls: "rental", refNote: "Location de coffrages modulaires — hypothèse de référence." }),
77 + I("ANCHOR-BOLT", "03", "foundation", "Boulon d'ancrage 1/2 × 10 po", "Anchor bolt 1/2 × 10 in", "unit", 2.5, { retail: R("boulon d'ancrage 1/2 po x 10 po") }),
78 + I("BRICK-CLAY", "04", "envelope", "Brique d'argile modulaire", "Modular clay brick", "unit", 1.1, { trade: "briqueteur", waste: 0.05, refNote: "Prix par brique (palette de 500) — référence briqueterie 2026.", retail: R("brique d'argile modulaire", 500, "palette") }),
79 + I("MORTAR-TYPE-N-30KG", "04", "envelope", "Mortier type N 30 kg (sac)", "Type N mortar 30 kg (bag)", "unit", 12.0, { retail: R("mortier type N 30 kg") }),
80 + I("BRICK-TIES", "04", "envelope", "Attache de brique", "Brick tie", "unit", 0.45, { retail: R("attache de brique", 100, "boîte") }),
81 + I("CONC-BLOCK-8IN", "04", "foundation", "Bloc de béton 8 × 8 × 16 po", "Concrete block 8 × 8 × 16 in", "unit", 3.5, { trade: "briqueteur", waste: 0.04, retail: R("bloc de béton 8 x 8 x 16") }),
82 + I("STONE-VENEER", "04", "envelope", "Pierre décorative (placage) pi²", "Manufactured stone veneer (sq ft)", "pi2", 14.0, { trade: "briqueteur", waste: 0.08, retail: R("pierre décorative revêtement extérieur pi²") }),
83 + I("DAMPPROOF", "07", "foundation", "Enduit d'imperméabilisation bitumineux (pi² couvert)", "Bituminous dampproofing (sq ft covered)", "pi2", 0.4, { retail: R("imperméabilisant à fondation 17 L", 150, "seau") }),
84 + I("DRAIN-MEMBRANE", "07", "foundation", "Membrane de drainage alvéolée (pi²)", "Dimpled drainage membrane (sq ft)", "pi2", 0.95, { waste: 0.05, retail: R("membrane de drainage Delta-MS", 264, "rouleau") }),
85 + I("WEEPING-TILE-4IN", "33", "excavation", "Drain français perforé 4 po (pi lin)", "Perforated weeping tile 4 in (lin ft)", "pi_lin", 1.2, { retail: R("drain agricole perforé 4 po 100 pi", 100, "rouleau") }),
86 + I("BACKFILL-GRAVEL", "31", "excavation", "Gravier 0-3/4 livré (m³)", "3/4 in crushed gravel delivered (m³)", "m3", 45, { refNote: "Agrégat livré en vrac — référence carrière 2026." }),
87 + I("SAND", "31", "exterior", "Sable à pavé livré (m³)", "Paver sand delivered (m³)", "m3", 42, { refNote: "Vrac livré — référence 2026." }),
88 + I("TOPSOIL", "32", "exterior", "Terre à jardin livrée (m³)", "Topsoil delivered (m³)", "m3", 55, { refNote: "Vrac livré — référence 2026." }),
89 + I("CONCRETE-PIER-TUBE", "03", "exterior", "Tube de coffrage 10 po × 4 pi", "Concrete form tube 10 in × 4 ft", "unit", 22, { retail: R("tube de coffrage 10 po x 4 pi") }),
90 +
91 + /* -------------------------------------------- 07 isolation et étanchéité */
92 + I("INS-BATT-R24-2X6", "07", "insulation", "Isolant natte R24 (mur 2 × 6) pi²", "Batt insulation R24 (2 × 6 wall) sq ft", "pi2", 1.45, { trade: "charpentier", waste: 0.05, retail: R("isolant laine minérale R24 2 x 6", 39.8, "paquet") }),
93 + I("INS-BATT-R20-2X6", "07", "insulation", "Isolant natte R20 (mur 2 × 6) pi²", "Batt insulation R20 (2 × 6 wall) sq ft", "pi2", 1.1, { trade: "charpentier", waste: 0.05, retail: R("isolant fibre de verre R20 15 po", 78.3, "paquet") }),
94 + I("INS-BATT-R12-2X4", "07", "insulation", "Isolant natte R12 (mur 2 × 4) pi²", "Batt insulation R12 (2 × 4 wall) sq ft", "pi2", 0.8, { trade: "charpentier", waste: 0.05, retail: R("isolant fibre de verre R12 15 po", 88, "paquet") }),
95 + I("INS-BLOWN-CELLULOSE-R50", "07", "insulation", "Cellulose soufflée R50 (pi² de plafond)", "Blown cellulose R50 (sq ft ceiling)", "pi2", 1.6, { trade: "charpentier", waste: 0.03, retail: R("isolant cellulose soufflée 25 lb", 15, "sac") }),
96 + I("INS-XPS-2IN", "07", "insulation", "Polystyrène extrudé 2 po (pi²)", "Extruded polystyrene 2 in (sq ft)", "pi2", 1.7, { trade: "charpentier", waste: 0.05, retail: R("isolant polystyrène extrudé 2 po 2 x 8", 16, "feuille") }),
97 + I("INS-EPS-TYPE2-1.5IN", "07", "insulation", "Polystyrène expansé type 2, 1 1/2 po (pi²)", "EPS type 2, 1 1/2 in (sq ft)", "pi2", 0.95, { waste: 0.05, retail: R("isolant polystyrène expansé 1 1/2 po 4 x 8", 32, "feuille") }),
98 + I("POLY-6MIL", "07", "insulation", "Polyéthylène pare-vapeur 6 mil (pi²)", "Polyethylene vapour barrier 6 mil (sq ft)", "pi2", 0.08, { waste: 0.1, retail: R("polyéthylène 6 mil 10 x 100", 1000, "rouleau") }),
99 + I("HOUSEWRAP", "07", "envelope", "Membrane pare-air (pi²)", "House wrap (sq ft)", "pi2", 0.14, { waste: 0.1, retail: R("membrane pare-air 9 x 100", 900, "rouleau") }),
100 + I("SHINGLE-ARCH", "07", "roofing", "Bardeau d'asphalte architectural (pi²)", "Architectural asphalt shingle (sq ft)", "pi2", 1.35, { trade: "couvreur", waste: 0.1, retail: R("bardeaux d'asphalte architecturaux paquet", 32.3, "paquet") }),
101 + I("ROOF-UNDERLAY-SYNTH", "07", "roofing", "Sous-couche synthétique de toiture (pi²)", "Synthetic roof underlayment (sq ft)", "pi2", 0.22, { waste: 0.08, retail: R("sous-couche synthétique toiture 1000 pi²", 1000, "rouleau") }),
102 + I("ICE-WATER-SHIELD", "07", "roofing", "Membrane autocollante glace et eau (pi²)", "Ice & water shield (sq ft)", "pi2", 0.95, { waste: 0.08, retail: R("membrane glace et eau 36 po x 65 pi", 195, "rouleau") }),
103 + I("DRIP-EDGE", "07", "roofing", "Larmier d'aluminium (pi lin)", "Aluminum drip edge (lin ft)", "pi_lin", 0.9, { waste: 0.05, retail: R("larmier aluminium 10 pi", 10, "longueur") }),
104 + I("ROOF-VENT", "07", "roofing", "Évent de toit", "Roof vent", "unit", 25, { retail: R("évent de toit aluminium") }),
105 + I("ROOF-METAL-PANEL", "07", "roofing", "Tôle de toiture en acier prépeint (pi²)", "Pre-painted steel roofing panel (sq ft)", "pi2", 4.2, { trade: "couvreur", waste: 0.07, retail: R("tôle de toiture acier prépeint pi²") }),
106 + I("ROOF-MEMBRANE-ELASTO", "07", "roofing", "Membrane élastomère 2 couches (pi²)", "Two-ply elastomeric membrane (sq ft)", "pi2", 4.8, { trade: "couvreur", waste: 0.08, refNote: "Membrane bitumineuse modifiée (base + finition) — référence fournisseur 2026." }),
107 + I("EPDM-MEMBRANE-SYSTEM", "07", "roofing", "Membrane EPDM et adhésif (pi²)", "EPDM membrane and adhesive (sq ft)", "pi2", 2.8, { trade: "couvreur", waste: 0.08, refNote: "Référence fournisseur 2026." }),
108 + I("CAULK-TUBE", "07", "envelope", "Scellant extérieur (tube)", "Exterior sealant (tube)", "unit", 8.0, { retail: R("scellant extérieur polyuréthane tube") }),
109 + I("SPRAYFOAM-CAN", "07", "openings", "Mousse isolante en aérosol (canette)", "Spray foam can", "unit", 12.0, { retail: R("mousse isolante portes et fenêtres canette") }),
110 + I("FLASHING-ALU", "07", "envelope", "Solin d'aluminium (pi lin)", "Aluminum flashing (lin ft)", "pi_lin", 1.6, { waste: 0.05, retail: R("solin aluminium 8 po x 50 pi", 50, "rouleau") }),
111 + I("SIDING-VINYL", "07", "envelope", "Revêtement de vinyle (pi²)", "Vinyl siding (sq ft)", "pi2", 1.55, { trade: "charpentier", waste: 0.1, retail: R("revêtement extérieur vinyle boîte", 200, "boîte") }),
112 + I("SIDING-VINYL-ACCESS", "07", "envelope", "Accessoires de vinyle (moulures J, coins) pi lin", "Vinyl trim accessories (J-channel, corners) lin ft", "pi_lin", 2.2, { waste: 0.05, retail: R("moulure en J vinyle 12 pi", 12, "longueur") }),
113 + I("SIDING-FIBERCEMENT", "07", "envelope", "Planche de fibrociment (pi² exposé)", "Fiber-cement plank (exposed sq ft)", "pi2", 3.6, { trade: "charpentier", waste: 0.1, retail: R("revêtement fibrociment planche 8 1/4 po x 12 pi", 7.25, "planche") }),
114 + I("SIDING-WOOD-CEDAR", "07", "envelope", "Déclin de cèdre (pi²)", "Cedar wood siding (sq ft)", "pi2", 5.5, { trade: "charpentier", waste: 0.1, retail: R("déclin de cèdre revêtement extérieur pi²") }),
115 + I("SIDING-STEEL", "07", "envelope", "Revêtement d'acier prépeint (pi²)", "Pre-painted steel siding (sq ft)", "pi2", 4.0, { trade: "charpentier", waste: 0.07, retail: R("revêtement extérieur acier prépeint pi²") }),
116 + I("SOFFIT-ALU", "07", "envelope", "Soffite d'aluminium ventilé (pi²)", "Vented aluminum soffit (sq ft)", "pi2", 2.8, { waste: 0.05, retail: R("soffite aluminium ventilé 16 po x 12 pi", 16, "panneau") }),
117 + I("FASCIA-ALU", "07", "envelope", "Bordure de toit (fascia) aluminium (pi lin)", "Aluminum fascia (lin ft)", "pi_lin", 3.2, { waste: 0.05, retail: R("fascia aluminium 6 po x 12 pi", 12, "longueur") }),
118 + I("GUTTER-ALU", "07", "exterior", "Gouttière d'aluminium 5 po (pi lin)", "Aluminum gutter 5 in (lin ft)", "pi_lin", 3.5, { waste: 0.05, retail: R("gouttière aluminium 5 po 10 pi", 10, "longueur") }),
119 +
120 + /* ------------------------------------------------------------ 08 ouvertures */
121 + I("WIN-PVC-CASEMENT-STD", "08", "openings", "Fenêtre à battant PVC ~30 × 48 po (double vitrage Low-E)", "PVC casement window ~30 × 48 in (Low-E double)", "unit", 620, { trade: "charpentier", retail: R("fenêtre à battant PVC 30 x 48") }),
122 + I("WIN-HYBRID-STD", "08", "openings", "Fenêtre hybride PVC/aluminium ~30 × 48 po", "Hybrid PVC/aluminum window ~30 × 48 in", "unit", 950, { trade: "charpentier", refNote: "Fenêtre hybride sur mesure — référence fabricant 2026." }),
123 + I("WIN-ALU-STD", "08", "openings", "Fenêtre aluminium ~30 × 48 po", "Aluminum window ~30 × 48 in", "unit", 780, { trade: "charpentier", refNote: "Référence fabricant 2026." }),
124 + I("WIN-WOOD-STD", "08", "openings", "Fenêtre bois/aluminium ~30 × 48 po", "Wood/aluminum window ~30 × 48 in", "unit", 1150, { trade: "charpentier", refNote: "Référence fabricant 2026." }),
125 + I("WIN-BASEMENT-SLIDER", "08", "openings", "Fenêtre coulissante de sous-sol 32 × 18 po", "Basement slider window 32 × 18 in", "unit", 280, { trade: "charpentier", retail: R("fenêtre coulissante sous-sol PVC 32 x 18") }),
126 + I("DOOR-EXT-STEEL-36", "08", "openings", "Porte d'entrée en acier 36 po (prémontée)", "Steel entry door 36 in (prehung)", "unit", 650, { trade: "charpentier", retail: R("porte d'entrée acier 36 po prémontée") }),
127 + I("DOOR-EXT-FIBERGLASS", "08", "openings", "Porte d'entrée en fibre de verre 36 po", "Fiberglass entry door 36 in", "unit", 1400, { trade: "charpentier", retail: R("porte d'entrée fibre de verre 36 po") }),
128 + I("DOOR-PATIO-6FT", "08", "openings", "Porte-patio coulissante 6 pi (PVC)", "Sliding patio door 6 ft (PVC)", "unit", 1350, { trade: "charpentier", retail: R("porte patio coulissante 6 pi PVC") }),
129 + I("DOOR-GARAGE-9X7", "08", "garage", "Porte de garage isolée 9 × 7 pi", "Insulated garage door 9 × 7 ft", "unit", 1450, { trade: "charpentier", retail: R("porte de garage isolée 9 x 7") }),
130 + I("DOOR-GARAGE-16X7", "08", "garage", "Porte de garage isolée 16 × 7 pi", "Insulated garage door 16 × 7 ft", "unit", 2400, { trade: "charpentier", retail: R("porte de garage isolée 16 x 7") }),
131 + I("GARAGE-OPENER", "26", "garage", "Ouvre-porte de garage", "Garage door opener", "unit", 380, { trade: "electricien", retail: R("ouvre-porte de garage 1/2 HP") }),
132 + I("DOOR-INT-HOLLOW-PREHUNG", "08", "openings", "Porte intérieure alvéolée prémontée 30 po", "Hollow-core prehung interior door 30 in", "unit", 145, { trade: "charpentier", retail: R("porte intérieure prémontée alvéolée 30 po") }),
133 + I("DOOR-INT-SOLID-PREHUNG", "08", "openings", "Porte intérieure pleine prémontée 30 po", "Solid-core prehung interior door 30 in", "unit", 310, { trade: "charpentier", retail: R("porte intérieure prémontée âme pleine 30 po") }),
134 + I("DOOR-CLOSET-BIFOLD", "08", "openings", "Porte pliante de garde-robe 48 po", "Bifold closet door 48 in", "unit", 120, { trade: "charpentier", retail: R("porte pliante garde-robe 48 po") }),
135 + I("DOOR-HARDWARE-INT", "08", "openings", "Poignée de porte intérieure", "Interior door handle", "unit", 32, { retail: R("poignée de porte intérieure passage") }),
136 + I("DOOR-HARDWARE-EXT", "08", "openings", "Poignée et serrure de porte extérieure", "Exterior handleset and deadbolt", "unit", 120, { retail: R("poignée entrée avec pêne dormant") }),
137 + I("SKYLIGHT", "08", "openings", "Puits de lumière fixe 2 × 4 pi", "Fixed skylight 2 × 4 ft", "unit", 900, { trade: "charpentier", retail: R("puits de lumière fixe 2 x 4") }),
138 +
139 + /* ------------------------------------------------------------ 09 finitions */
140 + I("GYP-1/2-4X8", "09", "interior", "Panneau de gypse 1/2 po 4 × 8 pi", "Gypsum board 1/2 in 4 × 8 ft", "pi2", 0.55, { trade: "poseur_systemes", waste: 0.1, retail: R("panneau de gypse 1/2 po 4 x 8", 32, "feuille") }),
141 + I("GYP-5/8-4X8", "09", "interior", "Panneau de gypse 5/8 po type X 4 × 8 pi", "Gypsum board 5/8 in type X 4 × 8 ft", "pi2", 0.68, { trade: "poseur_systemes", waste: 0.1, retail: R("panneau de gypse 5/8 po type X 4 x 8", 32, "feuille") }),
142 + I("GYP-MR-1/2", "09", "bathroom", "Gypse hydrofuge 1/2 po 4 × 8 pi", "Moisture-resistant gypsum 1/2 in 4 × 8 ft", "pi2", 0.85, { trade: "poseur_systemes", waste: 0.1, retail: R("panneau de gypse hydrofuge 1/2 po 4 x 8", 32, "feuille") }),
143 + I("JOINT-COMPOUND-20KG", "09", "interior", "Composé à joints 20 kg (seau)", "Joint compound 20 kg (pail)", "unit", 24, { retail: R("composé à joints tout usage 20 kg") }),
144 + I("DRYWALL-TAPE-250FT", "09", "interior", "Ruban à joints 250 pi (rouleau)", "Joint tape 250 ft (roll)", "unit", 9, { retail: R("ruban à joints papier 250 pi") }),
145 + I("CORNER-BEAD-8FT", "09", "interior", "Renfort de coin 8 pi", "Corner bead 8 ft", "unit", 5, { retail: R("renfort de coin métallique 8 pi") }),
146 + I("PAINT-INT-GAL", "09", "interior", "Peinture intérieure latex 3,78 L", "Interior latex paint 3.78 L", "unit", 52, { trade: "peintre", retail: R("peinture intérieure latex mat 3,78 L") }),
147 + I("PRIMER-GAL", "09", "interior", "Apprêt latex 3,78 L", "Latex primer 3.78 L", "unit", 38, { trade: "peintre", retail: R("apprêt scellant latex 3,78 L") }),
148 + I("PAINT-EXT-GAL", "09", "envelope", "Peinture extérieure 3,78 L", "Exterior paint 3.78 L", "unit", 65, { trade: "peintre", retail: R("peinture extérieure acrylique 3,78 L") }),
149 + I("TRIM-BASEBOARD-MDF", "09", "interior", "Plinthe MDF apprêtée 4 po (pi lin)", "Primed MDF baseboard 4 in (lin ft)", "pi_lin", 1.45, { trade: "charpentier", waste: 0.1, retail: R("plinthe MDF apprêtée 4 po 12 pi", 12, "longueur") }),
150 + I("TRIM-CASING-MDF", "09", "interior", "Cadrage MDF apprêté 2 3/4 po (pi lin)", "Primed MDF casing 2 3/4 in (lin ft)", "pi_lin", 1.3, { trade: "charpentier", waste: 0.1, retail: R("cadrage MDF apprêté 2 3/4 po 7 pi", 7, "longueur") }),
151 + I("FLOOR-HARDWOOD-OAK", "09", "interior", "Plancher de bois franc chêne 3 1/4 po (pi²)", "Oak hardwood flooring 3 1/4 in (sq ft)", "pi2", 6.2, { trade: "charpentier", waste: 0.08, retail: R("plancher bois franc chêne 3 1/4 po", 20, "boîte") }),
152 + I("FLOOR-ENGINEERED", "09", "interior", "Plancher d'ingénierie (pi²)", "Engineered wood flooring (sq ft)", "pi2", 5.4, { trade: "charpentier", waste: 0.08, retail: R("plancher bois d'ingénierie", 20, "boîte") }),
153 + I("FLOOR-VINYL-PLANK", "09", "interior", "Plancher de vinyle SPC (pi²)", "SPC vinyl plank flooring (sq ft)", "pi2", 2.9, { trade: "poseur_revetements", waste: 0.08, retail: R("plancher de vinyle SPC planche", 23.6, "boîte") }),
154 + I("FLOOR-LAMINATE", "09", "interior", "Plancher flottant stratifié (pi²)", "Laminate flooring (sq ft)", "pi2", 1.9, { trade: "poseur_revetements", waste: 0.08, retail: R("plancher flottant stratifié 12 mm", 20, "boîte") }),
155 + I("FLOOR-CARPET", "09", "interior", "Tapis résidentiel (pi²)", "Residential carpet (sq ft)", "pi2", 2.6, { trade: "poseur_revetements", waste: 0.1, retail: R("tapis résidentiel pi²") }),
156 + I("TILE-CERAMIC-12X24", "09", "interior", "Carreau de céramique 12 × 24 po (pi²)", "Ceramic tile 12 × 24 in (sq ft)", "pi2", 2.8, { trade: "carreleur", waste: 0.1, retail: R("carreaux de céramique 12 x 24", 15.5, "boîte") }),
157 + I("TILE-PORCELAIN", "09", "interior", "Carreau de porcelaine (pi²)", "Porcelain tile (sq ft)", "pi2", 4.2, { trade: "carreleur", waste: 0.1, retail: R("carreaux de porcelaine 12 x 24", 15.5, "boîte") }),
158 + I("TILE-WALL-SUBWAY", "09", "kitchen", "Carreau mural (dosseret, douche) pi²", "Wall tile (backsplash, shower) sq ft", "pi2", 3.6, { trade: "carreleur", waste: 0.1, retail: R("carreaux muraux céramique 3 x 6 subway", 10.7, "boîte") }),
159 + I("THINSET-22KG", "09", "interior", "Mortier-colle 22 kg (sac)", "Thinset mortar 22 kg (bag)", "unit", 26, { retail: R("mortier-colle modifié 22 kg") }),
160 + I("GROUT-4.5KG", "09", "interior", "Coulis 4,5 kg (sac)", "Grout 4.5 kg (bag)", "unit", 22, { retail: R("coulis sans sable 4,5 kg") }),
161 + I("TILE-BACKER-1/4", "09", "interior", "Panneau d'appui pour carreaux 1/4 po (pi²)", "Tile backer board 1/4 in (sq ft)", "pi2", 1.2, { waste: 0.08, retail: R("panneau de ciment 1/4 po 3 x 5", 15, "feuille") }),
162 + I("UNDERLAY-FOAM", "09", "interior", "Sous-couche de plancher (pi²)", "Flooring underlayment (sq ft)", "pi2", 0.3, { waste: 0.05, retail: R("sous-couche plancher flottant 100 pi²", 100, "rouleau") }),
163 + I("STAIR-TREADS-OAK", "09", "interior", "Marche de chêne 42 po", "Oak stair tread 42 in", "unit", 65, { trade: "charpentier", retail: R("marche d'escalier chêne 42 po") }),
164 + I("STAIR-RAILING", "09", "interior", "Main courante et balustrade (pi lin)", "Handrail and balustrade (lin ft)", "pi_lin", 45, { trade: "charpentier", refNote: "Système de rampe intérieure bois — référence 2026." }),
165 + I("CLOSET-SHELVING", "09", "interior", "Tablette et tringle de garde-robe (pi lin)", "Closet shelf and rod (lin ft)", "pi_lin", 12, { trade: "charpentier", retail: R("tablette de garde-robe mélamine 12 po x 8 pi", 8, "longueur") }),
166 +
167 + /* ------------------------------------------------------------- 12 cuisine */
168 + I("CAB-ECON", "12", "kitchen", "Armoires de mélamine (base + haut, pi lin)", "Melamine cabinets (base + upper, lin ft)", "pi_lin", 180, { trade: "charpentier", quality: "economy", retail: R("armoire de cuisine base mélamine 30 po", 2.5, "armoire"), refNote: "Pi lin = 1 armoire de base + 1 armoire haute de 12 po." }),
169 + I("CAB-STD", "12", "kitchen", "Armoires thermoplastique/polyester (pi lin)", "Thermofoil/polyester cabinets (lin ft)", "pi_lin", 300, { trade: "charpentier", quality: "standard", refNote: "Devis ébénisterie de série — référence 2026." }),
170 + I("CAB-SUP", "12", "kitchen", "Armoires MDF peint / bois, fermeture douce (pi lin)", "Painted MDF / wood cabinets, soft-close (lin ft)", "pi_lin", 480, { trade: "charpentier", quality: "superior", refNote: "Devis ébénisterie sur mesure — référence 2026." }),
171 + I("CAB-PRESTIGE", "12", "kitchen", "Armoires haut de gamme bois massif/laque (pi lin)", "High-end solid wood/lacquer cabinets (lin ft)", "pi_lin", 750, { trade: "charpentier", quality: "prestige", refNote: "Devis ébénisterie haut de gamme — référence 2026." }),
172 + I("COUNTER-LAMINATE", "12", "kitchen", "Comptoir stratifié postformé (pi lin)", "Post-formed laminate countertop (lin ft)", "pi_lin", 45, { trade: "charpentier", waste: 0.1, retail: R("comptoir stratifié postformé 8 pi", 8, "longueur") }),
173 + I("COUNTER-QUARTZ", "12", "kitchen", "Comptoir de quartz fabriqué et posé (pi²)", "Quartz countertop fabricated and installed (sq ft)", "pi2", 85, { refNote: "Prix fabricant posé — référence 2026 (la pose est incluse dans le prix fabricant)." }),
174 + I("COUNTER-GRANITE", "12", "kitchen", "Comptoir de granit fabriqué et posé (pi²)", "Granite countertop fabricated and installed (sq ft)", "pi2", 95, { refNote: "Prix fabricant posé — référence 2026." }),
175 + I("SINK-KITCHEN-SS", "22", "kitchen", "Évier de cuisine acier inoxydable double", "Double stainless-steel kitchen sink", "unit", 260, { trade: "plombier", retail: R("évier de cuisine acier inoxydable double cuve") }),
176 + I("FAUCET-KITCHEN", "22", "kitchen", "Robinet de cuisine à bec rétractable", "Pull-down kitchen faucet", "unit", 220, { trade: "plombier", retail: R("robinet de cuisine bec rétractable") }),
177 + I("RANGE-HOOD", "23", "kitchen", "Hotte de cuisinière 30 po", "Range hood 30 in", "unit", 250, { trade: "electricien", retail: R("hotte de cuisinière 30 po") }),
178 +
179 + /* ------------------------------------------------------ 22 salle de bain */
180 + I("TOILET", "22", "bathroom", "Toilette 2 pièces 4,8 L", "Two-piece toilet 4.8 L", "unit", 260, { trade: "plombier", retail: R("toilette 2 pièces 4,8 L allongée") }),
181 + I("VANITY-36", "22", "bathroom", "Meuble-lavabo 36 po avec dessus", "Vanity 36 in with top", "unit", 480, { trade: "charpentier", retail: R("meuble-lavabo 36 po avec dessus") }),
182 + I("VANITY-60-DOUBLE", "22", "bathroom", "Meuble-lavabo double 60 po avec dessus quartz", "Double vanity 60 in with quartz top", "unit", 1100, { trade: "charpentier", retail: R("meuble-lavabo double 60 po quartz") }),
183 + I("FAUCET-LAV", "22", "bathroom", "Robinet de lavabo", "Bathroom sink faucet", "unit", 130, { trade: "plombier", retail: R("robinet de lavabo salle de bain") }),
184 + I("TUB-ALCOVE-60", "22", "bathroom", "Baignoire en alcôve 60 po acrylique", "Alcove bathtub 60 in acrylic", "unit", 480, { trade: "plombier", retail: R("baignoire en alcôve 60 po acrylique") }),
185 + I("TUB-FREESTANDING", "22", "bathroom", "Baignoire autoportante acrylique", "Freestanding acrylic bathtub", "unit", 1400, { trade: "plombier", retail: R("baignoire autoportante acrylique 60 po") }),
186 + I("SHOWER-BASE-48", "22", "bathroom", "Base de douche 48 × 32 po", "Shower base 48 × 32 in", "unit", 420, { trade: "plombier", retail: R("base de douche acrylique 48 x 32") }),
187 + I("SHOWER-DOOR-GLASS", "22", "bathroom", "Porte de douche en verre trempé", "Tempered-glass shower door", "unit", 620, { trade: "charpentier", retail: R("porte de douche verre trempé 48 po") }),
188 + I("TUB-SHOWER-FAUCET", "22", "bathroom", "Robinetterie bain-douche", "Tub/shower faucet set", "unit", 260, { trade: "plombier", retail: R("robinet de bain-douche") }),
189 + I("BATH-ACCESSORIES", "10", "bathroom", "Accessoires de salle de bain (ensemble)", "Bathroom accessory set", "unit", 120, { retail: R("ensemble d'accessoires de salle de bain") }),
190 + I("EXHAUST-FAN", "23", "bathroom", "Ventilateur de salle de bain 80 PCM", "Bathroom exhaust fan 80 CFM", "unit", 140, { trade: "electricien", retail: R("ventilateur de salle de bain 80 PCM") }),
191 + I("MIRROR", "10", "bathroom", "Miroir de salle de bain", "Bathroom mirror", "unit", 120, { retail: R("miroir de salle de bain 36 po") }),
192 +
193 + /* ----------------------------------------------------------- 22 plomberie */
194 + I("PEX-1/2", "22", "plumbing", "Tuyau PEX 1/2 po (pi lin)", "PEX pipe 1/2 in (lin ft)", "pi_lin", 0.55, { trade: "plombier", waste: 0.05, retail: R("tuyau PEX 1/2 po 100 pi", 100, "rouleau") }),
195 + I("PEX-3/4", "22", "plumbing", "Tuyau PEX 3/4 po (pi lin)", "PEX pipe 3/4 in (lin ft)", "pi_lin", 0.95, { trade: "plombier", waste: 0.05, retail: R("tuyau PEX 3/4 po 100 pi", 100, "rouleau") }),
196 + I("PEX-FITTINGS", "22", "plumbing", "Raccord PEX", "PEX fitting", "unit", 4.5, { retail: R("raccord PEX 1/2 po coude") }),
197 + I("ABS-3IN", "22", "plumbing", "Tuyau ABS 3 po (pi lin)", "ABS pipe 3 in (lin ft)", "pi_lin", 3.2, { trade: "plombier", waste: 0.05, retail: R("tuyau ABS 3 po 12 pi", 12, "longueur") }),
198 + I("ABS-1.5IN", "22", "plumbing", "Tuyau ABS 1 1/2 po (pi lin)", "ABS pipe 1 1/2 in (lin ft)", "pi_lin", 1.7, { trade: "plombier", waste: 0.05, retail: R("tuyau ABS 1 1/2 po 12 pi", 12, "longueur") }),
199 + I("ABS-FITTINGS", "22", "plumbing", "Raccord ABS", "ABS fitting", "unit", 6.0, { retail: R("raccord ABS 3 po coude") }),
200 + I("PLUMB-ROUGH-MISC", "22", "plumbing", "Fournitures de plomberie brute (par appareil)", "Rough plumbing sundries (per fixture)", "unit", 120, { refNote: "Supports, colle, robinets d'arrêt, siphons — hypothèse par appareil." }),
201 + I("WATER-HEATER-60GAL", "22", "plumbing", "Chauffe-eau électrique 60 gal", "Electric water heater 60 gal", "unit", 900, { trade: "plombier", retail: R("chauffe-eau électrique 60 gallons") }),
202 + I("SUMP-PUMP", "22", "plumbing", "Pompe de puisard", "Sump pump", "unit", 260, { trade: "plombier", retail: R("pompe de puisard 1/3 HP") }),
203 + I("BACKWATER-VALVE", "22", "plumbing", "Clapet antiretour 4 po", "Backwater valve 4 in", "unit", 180, { trade: "plombier", retail: R("clapet anti-retour 4 po") }),
204 + I("HOSE-BIB", "22", "plumbing", "Robinet extérieur antigel", "Frost-free hose bib", "unit", 45, { trade: "plombier", retail: R("robinet extérieur antigel") }),
205 + I("SERVICE-CONNECTIONS", "33", "site", "Raccordements aqueduc/égout/électricité (forfait)", "Water/sewer/electrical service connections (lump)", "lump", 6500, { refNote: "Conduites de branchement et frais municipaux typiques — hypothèse forfaitaire 2026." }),
206 +
207 + /* ----------------------------------------------------------------- 23 CVCA */
208 + I("HEATPUMP-CENTRAL-3T", "23", "hvac", "Thermopompe centrale 3 tonnes (équipement)", "Central heat pump 3 tons (equipment)", "unit", 6800, { trade: "frigoriste", refNote: "Équipement (unité extérieure + serpentin) — référence distributeur 2026." }),
209 + I("HEATPUMP-WALL-12K", "23", "hvac", "Thermopompe murale 12 000 BTU", "Wall-mounted heat pump 12,000 BTU", "unit", 2400, { trade: "frigoriste", retail: R("thermopompe murale 12000 BTU") }),
210 + I("FURNACE-ELECTRIC-20KW", "23", "hvac", "Fournaise électrique 20 kW", "Electric furnace 20 kW", "unit", 1900, { trade: "frigoriste", retail: R("fournaise électrique 20 kW") }),
211 + I("FURNACE-GAS-60K", "23", "hvac", "Fournaise au gaz 60 000 BTU haute efficacité", "Gas furnace 60,000 BTU high-efficiency", "unit", 3200, { trade: "frigoriste", refNote: "Référence distributeur 2026." }),
212 + I("CENTRAL-AC-2.5T", "23", "hvac", "Climatiseur central 2,5 tonnes", "Central air conditioner 2.5 tons", "unit", 4200, { trade: "frigoriste", refNote: "Référence distributeur 2026." }),
213 + I("DUCT-SHEETMETAL", "23", "hvac", "Conduits de ventilation (par pi² de plancher desservi)", "Ductwork (per sq ft floor served)", "pi2", 1.8, { trade: "ferblantier", refNote: "Tôle, raccords, grilles — référence par pi² desservi." }),
214 + I("HRV", "23", "hvac", "Échangeur d'air (VRC)", "Heat-recovery ventilator (HRV)", "unit", 1300, { trade: "ferblantier", retail: R("échangeur d'air VRC") }),
215 + I("BASEBOARD-1500W", "23", "hvac", "Plinthe électrique 1 500 W", "Electric baseboard heater 1,500 W", "unit", 75, { trade: "electricien", retail: R("plinthe électrique 1500 W 240 V") }),
216 + I("THERMOSTAT", "23", "hvac", "Thermostat électronique de plinthe", "Electronic baseboard thermostat", "unit", 45, { trade: "electricien", retail: R("thermostat électronique 240 V") }),
217 + I("THERMOSTAT-CENTRAL", "23", "hvac", "Thermostat central programmable", "Programmable central thermostat", "unit", 180, { trade: "electricien", retail: R("thermostat intelligent central") }),
218 +
219 + /* ---------------------------------------------------------- 26 électricité */
220 + I("PANEL-200A-40", "26", "electrical", "Panneau électrique 200 A, 40 circuits", "Electrical panel 200 A, 40 circuits", "unit", 420, { trade: "electricien", retail: R("panneau électrique 200 A 40 circuits") }),
221 + I("BREAKER-15A", "26", "electrical", "Disjoncteur 15 A", "Breaker 15 A", "unit", 12, { retail: R("disjoncteur unipolaire 15 A") }),
222 + I("WIRE-14/2", "26", "electrical", "Câble NMD90 14/2 (pi lin)", "NMD90 cable 14/2 (lin ft)", "pi_lin", 0.95, { trade: "electricien", waste: 0.05, retail: R("câble électrique NMD90 14/2 75 m", 246, "rouleau") }),
223 + I("WIRE-12/2", "26", "electrical", "Câble NMD90 12/2 (pi lin)", "NMD90 cable 12/2 (lin ft)", "pi_lin", 1.35, { trade: "electricien", waste: 0.05, retail: R("câble électrique NMD90 12/2 75 m", 246, "rouleau") }),
224 + I("BOX-DEVICE", "26", "electrical", "Boîte de dispositif", "Device box", "unit", 1.8, { retail: R("boîte électrique 1 dispositif") }),
225 + I("RECEPTACLE", "26", "electrical", "Prise 15 A inviolable", "Tamper-resistant receptacle 15 A", "unit", 3.5, { retail: R("prise de courant 15 A inviolable") }),
226 + I("SWITCH", "26", "electrical", "Interrupteur unipolaire", "Single-pole switch", "unit", 4.0, { retail: R("interrupteur unipolaire 15 A") }),
227 + I("GFCI", "26", "electrical", "Prise DDFT 15 A", "GFCI receptacle 15 A", "unit", 28, { retail: R("prise DDFT 15 A") }),
228 + I("COVER-PLATE", "26", "electrical", "Plaque murale", "Cover plate", "unit", 1.2, { retail: R("plaque murale simple") }),
229 + I("LIGHT-FIXTURE-STD", "26", "electrical", "Luminaire DEL encastré 4 po", "LED recessed light 4 in", "unit", 65, { trade: "electricien", retail: R("luminaire DEL encastré 4 po") }),
230 + I("LIGHT-FIXTURE-EXT", "26", "electrical", "Luminaire extérieur", "Exterior light fixture", "unit", 85, { trade: "electricien", retail: R("luminaire extérieur mural") }),
231 + I("SMOKE-CO-DETECTOR", "26", "electrical", "Détecteur de fumée et CO", "Smoke/CO detector", "unit", 55, { retail: R("détecteur de fumée et monoxyde de carbone 120 V") }),
232 + I("SERVICE-MAST-200A", "26", "electrical", "Entrée électrique 200 A (mât, câble, mise à la terre)", "200 A service entrance (mast, cable, grounding)", "lump", 950, { trade: "electricien", refNote: "Fournitures d'entrée électrique — hypothèse forfaitaire 2026." }),
233 + I("METER-BASE", "26", "electrical", "Embase de compteur 200 A", "Meter base 200 A", "unit", 180, { retail: R("embase de compteur 200 A") }),
234 + I("EV-CHARGER-CIRCUIT", "26", "electrical", "Circuit de recharge VÉ 40 A (câble, borne)", "EV charging circuit 40 A (cable, charger)", "lump", 900, { trade: "electricien", refNote: "Borne niveau 2 + câble — hypothèse 2026." }),
235 +
236 + /* --------------------------------------------- 31/32 site et extérieur */
237 + I("EXCAVATOR-HOUR", "31", "excavation", "Location pelle mécanique (heure, sans opérateur)", "Excavator rental (hour, bare)", "h", 115, { cls: "equipment", refNote: "Location d'une pelle 8-14 t — référence locateur 2026." }),
238 + I("TRUCK-HAUL", "31", "excavation", "Transport de déblais (m³)", "Spoil hauling (m³)", "m3", 18, { cls: "equipment", refNote: "Camion 10 roues — référence 2026." }),
239 + I("ASPHALT-DRIVEWAY", "32", "exterior", "Enrobé bitumineux et fondation granulaire (pi²)", "Asphalt mix and granular base (sq ft)", "pi2", 2.6, { refNote: "Matériaux seulement (fondation 0-3/4 + enrobé 2 po) — référence 2026." }),
240 + I("PAVER-STONE", "32", "exterior", "Pavé de béton (pi²)", "Concrete paver (sq ft)", "pi2", 4.8, { trade: "briqueteur", waste: 0.05, retail: R("pavé de béton pi²") }),
241 + I("SOD", "32", "exterior", "Gazon en plaques (pi²)", "Sod (sq ft)", "pi2", 0.65, { waste: 0.05, retail: R("gazon en plaques rouleau 10 pi²", 10, "rouleau") }),
242 + I("FENCE-PANEL-WOOD", "32", "exterior", "Panneau de clôture bois 6 × 8 pi (pi lin)", "Wood fence panel 6 × 8 ft (lin ft)", "pi_lin", 12, { trade: "charpentier", retail: R("panneau de clôture bois traité 6 x 8", 8, "panneau") }),
243 + I("DECK-RAILING-ALU", "32", "exterior", "Rampe d'aluminium (pi lin)", "Aluminum railing (lin ft)", "pi_lin", 55, { trade: "charpentier", retail: R("rampe aluminium 6 pi", 6, "section") }),
244 + I("STEPS-PRECAST", "03", "exterior", "Marches préfabriquées en béton (3 marches)", "Precast concrete steps (3 risers)", "unit", 650, { refNote: "Référence fabricant 2026." }),
245 + I("TREATED-2X6-12", "06", "exterior", "Bois traité 2 × 6 × 12 pi", "Treated lumber 2 × 6 × 12 ft", "unit", 18, { trade: "charpentier", waste: 0.07, retail: R("bois traité brun 2 x 6 x 12") }),
246 + I("TREATED-5/4X6-12", "06", "exterior", "Planche de terrasse traitée 5/4 × 6 × 12 pi", "Treated deck board 5/4 × 6 × 12 ft", "unit", 15, { trade: "charpentier", waste: 0.1, retail: R("planche de terrasse bois traité 5/4 x 6 x 12") }),
247 + I("TREATED-2X8-12", "06", "exterior", "Bois traité 2 × 8 × 12 pi", "Treated lumber 2 × 8 × 12 ft", "unit", 24, { trade: "charpentier", waste: 0.07, retail: R("bois traité brun 2 x 8 x 12") }),
248 + I("TREATED-4X4-8", "06", "exterior", "Poteau traité 4 × 4 × 8 pi", "Treated post 4 × 4 × 8 ft", "unit", 16, { trade: "charpentier", waste: 0.03, retail: R("bois traité brun 4 x 4 x 8") }),
249 + I("TREATED-6X6-10", "06", "exterior", "Poteau traité 6 × 6 × 10 pi", "Treated post 6 × 6 × 10 ft", "unit", 45, { trade: "charpentier", waste: 0.03, retail: R("bois traité brun 6 x 6 x 10") }),
250 + I("POOL-ABOVEGROUND", "13", "exterior", "Piscine hors terre 21 pi (ensemble)", "Above-ground pool 21 ft (kit)", "lump", 6500, { refNote: "Ensemble avec filtre/pompe — référence détaillant 2026." }),
251 + I("POOL-INGROUND", "13", "exterior", "Piscine creusée 16 × 32 pi (clé en main)", "Inground pool 16 × 32 ft (turnkey)", "lump", 55000, { refNote: "Devis piscinier clé en main — référence 2026." }),
252 +];
253 +
254 +export const ITEM_BY_CODE = new Map(ITEM_SEEDS.map((i) => [i.code, i]));
added src/lib/cost/seed/locations.ts +51 −0
@@ -0,0 +1,51 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Facteurs de localisation du Québec (cost_locations). Module PUR.
4 + *
5 + * Main-d'œuvre : les conventions collectives de l'industrie de la construction
6 + * (Loi R-20, CCQ) fixent des taux UNIFORMES pour tout le Québec ; seuls les
7 + * chantiers isolés / Baie-James / Nunavik ont des grilles distinctes (APCHQ
8 + * publie ces grilles). Le facteur main-d'œuvre est donc 1,00 partout, sauf
9 + * régions éloignées (primes d'éloignement, hébergement — hypothèse).
10 + *
11 + * Matériaux / équipement : surcoût de transport et de disponibilité par
12 + * rapport à Montréal (référence 1,00) — HYPOTHÈSES documentées,
13 + * modifiables en base ; aucun indice public ne publie de facteur municipal.
14 + */
15 +import type { CostLocation } from "../types";
16 +
17 +export interface LocationSeed extends Omit<CostLocation, "effectiveDate" | "sourceMethod" | "confidence"> {
18 + /** centre approximatif pour l'affectation par distance */
19 + lat: number;
20 + lng: number;
21 + /** rayon (km) à l'intérieur duquel une propriété est affectée à ce centre */
22 + radiusKm: number;
23 + /** municipalités affectées explicitement (nom MAMH) */
24 + municipalities: string[];
25 +}
26 +
27 +const L = (code: string, fr: string, en: string, region: string, m: number, l: number, e: number, lat: number, lng: number, radiusKm: number, municipalities: string[] = []): LocationSeed => ({
28 + code, nameFr: fr, nameEn: en, regionCode: region,
29 + materialFactor: m, labourFactor: l, equipmentFactor: e,
30 + overallFactor: Math.round((0.5 * m + 0.42 * l + 0.08 * e) * 1000) / 1000,
31 + lat, lng, radiusKm, municipalities,
32 +});
33 +
34 +export const LOCATION_SEEDS: LocationSeed[] = [
35 + L("QC-MTL", "Montréal", "Montréal", "06", 1.0, 1.0, 1.0, 45.5019, -73.5674, 25, ["Montréal", "Westmount", "Mont-Royal", "Côte-Saint-Luc", "Hampstead", "Montréal-Est", "Montréal-Ouest", "Dorval", "Pointe-Claire", "Kirkland", "Beaconsfield", "Dollard-Des Ormeaux", "Sainte-Anne-de-Bellevue", "Baie-D'Urfé", "Senneville"]),
36 + L("QC-LAV", "Laval", "Laval", "13", 1.0, 1.0, 1.0, 45.6066, -73.7124, 15, ["Laval"]),
37 + L("QC-LNG", "Longueuil et Rive-Sud", "Longueuil and South Shore", "16", 1.0, 1.0, 1.0, 45.5312, -73.5181, 30, ["Longueuil", "Brossard", "Boucherville", "Saint-Lambert", "Saint-Bruno-de-Montarville", "Sainte-Julie", "Chambly", "La Prairie", "Candiac", "Saint-Constant", "Châteauguay", "Beloeil", "Saint-Hubert"]),
38 + L("QC-LAU-LAN", "Laurentides — Lanaudière (couronne nord)", "Laurentians — Lanaudière (north shore)", "15", 1.01, 1.0, 1.01, 45.75, -73.75, 60, ["Terrebonne", "Repentigny", "Mascouche", "Blainville", "Mirabel", "Saint-Jérôme", "Boisbriand", "Sainte-Thérèse", "Saint-Eustache", "Deux-Montagnes", "L'Assomption", "Joliette"]),
39 + L("QC-QUE", "Québec", "Québec City", "03", 1.01, 1.0, 1.0, 46.8139, -71.208, 30, ["Québec", "L'Ancienne-Lorette", "Saint-Augustin-de-Desmaures"]),
40 + L("QC-LEV", "Lévis — Chaudière-Appalaches", "Lévis — Chaudière-Appalaches", "12", 1.02, 1.0, 1.01, 46.7382, -71.2465, 40, ["Lévis", "Saint-Georges", "Thetford Mines", "Montmagny", "Sainte-Marie"]),
41 + L("QC-GAT", "Gatineau — Outaouais", "Gatineau — Outaouais", "07", 1.02, 1.0, 1.01, 45.4765, -75.7013, 40, ["Gatineau", "Chelsea", "Cantley", "Val-des-Monts", "L'Ange-Gardien", "Pontiac"]),
42 + L("QC-SHE", "Sherbrooke — Estrie", "Sherbrooke — Eastern Townships", "05", 1.02, 1.0, 1.01, 45.4042, -71.8929, 50, ["Sherbrooke", "Magog", "Granby", "Cowansville", "Coaticook"]),
43 + L("QC-TRV", "Trois-Rivières — Mauricie — Centre-du-Québec", "Trois-Rivières — Mauricie — Centre-du-Québec", "04", 1.02, 1.0, 1.01, 46.3432, -72.5477, 60, ["Trois-Rivières", "Shawinigan", "Drummondville", "Victoriaville", "Bécancour", "Nicolet"]),
44 + L("QC-SAG", "Saguenay — Lac-Saint-Jean", "Saguenay — Lac-Saint-Jean", "02", 1.04, 1.0, 1.03, 48.4284, -71.0683, 90, ["Saguenay", "Alma", "Roberval", "Dolbeau-Mistassini", "Saint-Félicien"]),
45 + L("QC-BSL", "Bas-Saint-Laurent", "Lower Saint Lawrence", "01", 1.04, 1.0, 1.03, 48.4489, -68.523, 100, ["Rimouski", "Rivière-du-Loup", "Matane", "La Pocatière", "Amqui"]),
46 + L("QC-ABT", "Abitibi-Témiscamingue", "Abitibi-Témiscamingue", "08", 1.06, 1.02, 1.05, 48.2354, -79.0227, 150, ["Rouyn-Noranda", "Val-d'Or", "Amos", "La Sarre", "Ville-Marie"]),
47 + L("QC-CDN", "Côte-Nord", "North Shore", "09", 1.08, 1.03, 1.06, 49.2181, -68.1518, 250, ["Baie-Comeau", "Sept-Îles", "Port-Cartier", "Forestville"]),
48 + L("QC-GIM", "Gaspésie — Îles-de-la-Madeleine", "Gaspésie — Magdalen Islands", "11", 1.07, 1.02, 1.05, 48.8323, -64.4896, 200, ["Gaspé", "Chandler", "Percé", "Sainte-Anne-des-Monts", "Carleton-sur-Mer", "Les Îles-de-la-Madeleine"]),
49 + L("QC-NDQ", "Nord-du-Québec", "Northern Québec", "10", 1.2, 1.12, 1.15, 51.5, -76.0, 600, ["Chibougamau", "Lebel-sur-Quévillon", "Matagami", "Chapais"]),
50 + L("QC-RUR", "Québec — autres régions (rural)", "Québec — other regions (rural)", "00", 1.03, 1.0, 1.02, 46.5, -72.0, 0),
51 +];
added src/lib/cost/seed/sources.ts +38 −0
@@ -0,0 +1,38 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Registre des sources de coûts (cost_sources). Module PUR.
4 + * license_status : public_open | public_restricted | licensed | manual_import | unknown | disabled
5 + * Une source `unknown` n'est jamais redistribuée publiquement avant validation.
6 + */
7 +export type LicenseStatus = "public_open" | "public_restricted" | "licensed" | "manual_import" | "unknown" | "disabled";
8 +
9 +export interface SourceSeed {
10 + key: string; // clé connecteur (ccq, apchq, statcan, canac…)
11 + name: string;
12 + sourceType: "labour" | "index" | "retail" | "benchmark" | "internal" | "reference";
13 + baseUrl: string | null;
14 + jurisdiction: string;
15 + licenseStatus: LicenseStatus;
16 + licenseNotes: string;
17 + termsUrl: string | null;
18 + isActive: boolean;
19 + priority: number; // 1 = la plus fiable
20 + refreshFrequency: string;
21 + /** qualité de source pour le score de confiance (0-1) */
22 + quality: number;
23 +}
24 +
25 +export const SOURCE_SEEDS: SourceSeed[] = [
26 + { key: "apchq", name: "APCHQ — Coûts horaires de la main-d'œuvre", sourceType: "labour", baseUrl: "https://www.apchq.com/nos-services/relations-du-travail/paie/couts-horaires-et-paie/", jurisdiction: "QC", licenseStatus: "public_open", licenseNotes: "Grilles PDF publiées publiquement par l'APCHQ (résidentiel léger/lourd, IC, chantiers isolés), fondées sur les conventions collectives CCQ. Valeurs numériques et métadonnées conservées, URL citée.", termsUrl: "https://www.apchq.com/conditions-d-utilisation", isActive: true, priority: 1, refreshFrequency: "weekly", quality: 0.95 },
27 + { key: "ccq", name: "CCQ — Salaires et taux conventionnés", sourceType: "labour", baseUrl: "https://www.ccq.org/fr-CA/avantages-sociaux/salaire-taux", jurisdiction: "QC", licenseStatus: "public_open", licenseNotes: "Taux réglementés publiés par la Commission de la construction du Québec. Site en incident de sécurité (services fermés jusqu'au 2026-09-08) : connecteur en détection de changement.", termsUrl: "https://www.ccq.org/fr-CA/conditions-utilisation", isActive: true, priority: 1, refreshFrequency: "weekly", quality: 1.0 },
28 + { key: "statcan", name: "Statistique Canada — Indices des prix de la construction de bâtiments (18-10-0289-01)", sourceType: "index", baseUrl: "https://www150.statcan.gc.ca/t1/tbl1/fr/tv.action?pid=1810028901", jurisdiction: "CA", licenseStatus: "public_open", licenseNotes: "Licence ouverte de Statistique Canada. API WDS.", termsUrl: "https://www.statcan.gc.ca/fr/reference/licence", isActive: true, priority: 1, refreshFrequency: "quarterly", quality: 1.0 },
29 + { key: "canac", name: "Canac", sourceType: "retail", baseUrl: "https://www.canac.ca", jurisdiction: "QC", licenseStatus: "public_open", licenseNotes: "Prix affichés publiquement (fiches produit). robots.txt permissif. Observations de prix + URL, pas de copie de contenu.", termsUrl: "https://www.canac.ca/canac/fr/conditions-utilisation", isActive: true, priority: 2, refreshFrequency: "daily", quality: 0.85 },
30 + { key: "bmr", name: "BMR", sourceType: "retail", baseUrl: "https://www.bmr.ca", jurisdiction: "QC", licenseStatus: "public_open", licenseNotes: "Prix affichés publiquement (fiches produit SEO). Attention aux variantes « à partir de ».", termsUrl: "https://www.bmr.ca/fr/conditions-d-utilisation", isActive: true, priority: 2, refreshFrequency: "daily", quality: 0.85 },
31 + { key: "patrickmorin", name: "Patrick Morin", sourceType: "retail", baseUrl: "https://patrickmorin.com", jurisdiction: "QC", licenseStatus: "public_open", licenseNotes: "Prix affichés publiquement (fiches produit).", termsUrl: "https://patrickmorin.com/fr/conditions-d-utilisation", isActive: true, priority: 2, refreshFrequency: "daily", quality: 0.85 },
32 + { key: "rona", name: "RONA", sourceType: "retail", baseUrl: "https://www.rona.ca", jurisdiction: "QC", licenseStatus: "public_restricted", licenseNotes: "Protection Cloudflare ; recherche interdite par robots.txt (fiches produit permises). Activé seulement si les fiches sont stables.", termsUrl: "https://www.rona.ca/fr/conditions-d-utilisation", isActive: false, priority: 3, refreshFrequency: "daily", quality: 0.8 },
33 + { key: "homedepot", name: "Home Depot Canada", sourceType: "retail", baseUrl: "https://www.homedepot.ca", jurisdiction: "CA", licenseStatus: "public_restricted", licenseNotes: "Prix dépendants du magasin sélectionné (défaut hors Québec) — désactivé tant que la localisation Québec n'est pas fiable.", termsUrl: "https://www.homedepot.ca/fr/accueil/conditions-d-utilisation.html", isActive: false, priority: 3, refreshFrequency: "daily", quality: 0.75 },
34 + { key: "altus", name: "Altus Group — Canadian Cost Guide", sourceType: "benchmark", baseUrl: "https://www.altusgroup.com/featured-insights/canadian-cost-guide/", jurisdiction: "CA", licenseStatus: "public_restricted", licenseNotes: "Guide PDF gratuit derrière un formulaire d'inscription. Benchmarks $/pi² saisis par import manuel après téléchargement légal ; usage informatif seulement (avis de l'éditeur).", termsUrl: "https://www.altusgroup.com/terms-of-use/", isActive: true, priority: 2, refreshFrequency: "yearly", quality: 0.9 },
35 + { key: "rsmeans", name: "RSMeans / Gordian", sourceType: "reference", baseUrl: "https://www.rsmeans.com", jurisdiction: "US/CA", licenseStatus: "licensed", licenseNotes: "Données sous licence payante : import CSV/XLSX autorisé seulement (aucun scraping, aucune redistribution).", termsUrl: "https://www.gordian.com/terms-of-use/", isActive: false, priority: 2, refreshFrequency: "manual", quality: 0.9 },
36 + { key: "vp_internal", name: "Vrai-Prix — base interne (coût calibré, moteur)", sourceType: "internal", baseUrl: null, jurisdiction: "QC", licenseStatus: "public_open", licenseNotes: "Coût implicite calibré par la technique du résiduel sur les ventes réelles (duel des méthodes, /stats/marche). Benchmark de cohérence interne.", termsUrl: null, isActive: true, priority: 3, refreshFrequency: "monthly", quality: 0.7 },
37 + { key: "reference", name: "Vrai-Prix — prix de référence internes (hypothèses)", sourceType: "reference", baseUrl: null, jurisdiction: "QC", licenseStatus: "public_open", licenseNotes: "Prix de référence documentés pour les articles sans observation (béton livré, fermes, fenêtres sur mesure…). Toujours étiquetés « hypothèse », jamais présentés comme observés.", termsUrl: null, isActive: true, priority: 9, refreshFrequency: "manual", quality: 0.4 },
38 +];
added src/lib/cost/taxonomy.ts +182 −0
@@ -0,0 +1,182 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Taxonomie de la méthode du coût : divisions MasterFormat, catégories UX,
4 + * qualités, conditions, matériaux normalisés, métiers, règles de dépréciation.
5 + * Module PUR (client + serveur).
6 + */
7 +import type { Condition, Quality, UxCategory, BuildingType, IndirectParams, CostParams } from "./types";
8 +
9 +export const METHOD_VERSION = "1.0.0";
10 +
11 +/** Divisions CSI MasterFormat retenues. */
12 +export const MASTERFORMAT: Record<string, { fr: string; en: string }> = {
13 + "01": { fr: "Exigences générales", en: "General Requirements" },
14 + "02": { fr: "Conditions existantes", en: "Existing Conditions" },
15 + "03": { fr: "Béton", en: "Concrete" },
16 + "04": { fr: "Maçonnerie", en: "Masonry" },
17 + "05": { fr: "Métaux", en: "Metals" },
18 + "06": { fr: "Bois, plastiques et composites", en: "Wood, Plastics and Composites" },
19 + "07": { fr: "Isolation et étanchéité", en: "Thermal and Moisture Protection" },
20 + "08": { fr: "Ouvertures", en: "Openings" },
21 + "09": { fr: "Finitions", en: "Finishes" },
22 + "10": { fr: "Ouvrages spéciaux", en: "Specialties" },
23 + "11": { fr: "Équipement", en: "Equipment" },
24 + "12": { fr: "Ameublement", en: "Furnishings" },
25 + "21": { fr: "Protection incendie", en: "Fire Suppression" },
26 + "22": { fr: "Plomberie", en: "Plumbing" },
27 + "23": { fr: "CVCA", en: "HVAC" },
28 + "26": { fr: "Électricité", en: "Electrical" },
29 + "31": { fr: "Terrassement", en: "Earthwork" },
30 + "32": { fr: "Aménagements extérieurs", en: "Exterior Improvements" },
31 + "33": { fr: "Services publics", en: "Utilities" },
32 +};
33 +
34 +export const UX_CATEGORIES: { key: UxCategory; fr: string; en: string; order: number }[] = [
35 + { key: "site", fr: "Préparation du site", en: "Site preparation", order: 1 },
36 + { key: "excavation", fr: "Excavation", en: "Excavation", order: 2 },
37 + { key: "foundation", fr: "Fondations", en: "Foundations", order: 3 },
38 + { key: "structure", fr: "Structure", en: "Structure", order: 4 },
39 + { key: "roofing", fr: "Toiture", en: "Roofing", order: 5 },
40 + { key: "envelope", fr: "Enveloppe", en: "Envelope", order: 6 },
41 + { key: "openings", fr: "Portes et fenêtres", en: "Doors and windows", order: 7 },
42 + { key: "insulation", fr: "Isolation", en: "Insulation", order: 8 },
43 + { key: "finishes_ext", fr: "Revêtements", en: "Cladding", order: 9 },
44 + { key: "interior", fr: "Finition intérieure", en: "Interior finishes", order: 10 },
45 + { key: "kitchen", fr: "Cuisine", en: "Kitchen", order: 11 },
46 + { key: "bathroom", fr: "Salle de bain", en: "Bathroom", order: 12 },
47 + { key: "plumbing", fr: "Plomberie", en: "Plumbing", order: 13 },
48 + { key: "hvac", fr: "Chauffage / ventilation", en: "HVAC", order: 14 },
49 + { key: "electrical", fr: "Électricité", en: "Electrical", order: 15 },
50 + { key: "garage", fr: "Garage", en: "Garage", order: 16 },
51 + { key: "basement", fr: "Sous-sol", en: "Basement", order: 17 },
52 + { key: "exterior", fr: "Aménagement extérieur", en: "Exterior improvements", order: 18 },
53 +];
54 +
55 +export const categoryLabel = (key: UxCategory, fr: boolean): string => {
56 + const c = UX_CATEGORIES.find((x) => x.key === key);
57 + return c ? (fr ? c.fr : c.en) : key;
58 +};
59 +
60 +export const QUALITIES: { key: Quality; fr: string; en: string }[] = [
61 + { key: "economy", fr: "Économique", en: "Economy" },
62 + { key: "standard", fr: "Standard", en: "Standard" },
63 + { key: "superior", fr: "Supérieur", en: "Superior" },
64 + { key: "prestige", fr: "Prestige", en: "Prestige" },
65 +];
66 +
67 +export const CONDITIONS: { key: Condition; fr: string; en: string; effectiveAgeRatio: number }[] = [
68 + { key: "poor", fr: "Mauvais", en: "Poor", effectiveAgeRatio: 0.85 },
69 + { key: "below_average", fr: "Sous la moyenne", en: "Below average", effectiveAgeRatio: 0.7 },
70 + { key: "average", fr: "Moyen", en: "Average", effectiveAgeRatio: 0.55 },
71 + { key: "good", fr: "Bon", en: "Good", effectiveAgeRatio: 0.35 },
72 + { key: "very_good", fr: "Très bon", en: "Very good", effectiveAgeRatio: 0.2 },
73 + { key: "excellent", fr: "Excellent", en: "Excellent", effectiveAgeRatio: 0.1 },
74 + { key: "renovated", fr: "Rénové", en: "Renovated", effectiveAgeRatio: 0.12 },
75 + { key: "new", fr: "Neuf", en: "New", effectiveAgeRatio: 0.0 },
76 +];
77 +
78 +/**
79 + * Règles condition → âge effectif d'une composante (component_condition_rules,
80 + * §144). L'âge effectif = ratio × vie économique de la composante. Hypothèses
81 + * documentées, modifiables en base (table `component_condition_rules`).
82 + */
83 +export const CONDITION_GROUPS: { key: string; fr: string; en: string; economicLife: number }[] = [
84 + { key: "structure", fr: "Structure et fondations", en: "Structure and foundations", economicLife: 75 },
85 + { key: "roof", fr: "Toiture", en: "Roof", economicLife: 22 },
86 + { key: "exterior", fr: "Enveloppe et revêtements", en: "Envelope and cladding", economicLife: 40 },
87 + { key: "windows", fr: "Portes et fenêtres", en: "Doors and windows", economicLife: 30 },
88 + { key: "interior", fr: "Finition intérieure", en: "Interior finishes", economicLife: 25 },
89 + { key: "kitchen", fr: "Cuisine", en: "Kitchen", economicLife: 20 },
90 + { key: "bathroom", fr: "Salles de bain", en: "Bathrooms", economicLife: 22 },
91 + { key: "mechanical", fr: "Mécanique (plomberie, CVCA)", en: "Mechanical (plumbing, HVAC)", economicLife: 22 },
92 + { key: "electrical", fr: "Électricité", en: "Electrical", economicLife: 35 },
93 + { key: "site", fr: "Site et extérieur", en: "Site and exterior", economicLife: 25 },
94 +];
95 +
96 +export const BUILDING_TYPES: { key: BuildingType; fr: string; en: string; economicLife: number }[] = [
97 + { key: "detached", fr: "Maison unifamiliale détachée", en: "Detached single-family", economicLife: 60 },
98 + { key: "semi_detached", fr: "Jumelé", en: "Semi-detached", economicLife: 60 },
99 + { key: "row", fr: "Maison en rangée", en: "Row house", economicLife: 60 },
100 + { key: "plex", fr: "Plex (2-5 logements)", en: "Plex (2-5 units)", economicLife: 60 },
101 + { key: "condo", fr: "Condo (unité)", en: "Condo (unit)", economicLife: 60 },
102 + { key: "chalet", fr: "Chalet", en: "Cottage", economicLife: 50 },
103 + { key: "mobile", fr: "Maison mobile", en: "Mobile home", economicLife: 35 },
104 + { key: "other", fr: "Autre", en: "Other", economicLife: 55 },
105 +];
106 +
107 +/** Métiers (codes internes) ↔ libellés APCHQ/CCQ. */
108 +export const TRADES: { code: string; fr: string; en: string; apchq: string[] }[] = [
109 + { code: "charpentier", fr: "Charpentier-menuisier", en: "Carpenter", apchq: ["Charpentier-menuisier"] },
110 + { code: "briqueteur", fr: "Briqueteur-maçon", en: "Bricklayer-mason", apchq: ["Briqueteur-maçon"] },
111 + { code: "carreleur", fr: "Carreleur", en: "Tile setter", apchq: ["Carreleur"] },
112 + { code: "cimentier", fr: "Cimentier-applicateur", en: "Cement finisher", apchq: ["Cimentier-applicateur"] },
113 + { code: "coffreur", fr: "Coffreur à béton", en: "Concrete formworker", apchq: ["Coffreur à béton"] },
114 + { code: "couvreur", fr: "Couvreur", en: "Roofer", apchq: ["Couvreur"] },
115 + { code: "electricien", fr: "Électricien", en: "Electrician", apchq: ["Électricien", "Electricien"] },
116 + { code: "plombier", fr: "Tuyauteur (plombier)", en: "Pipefitter (plumber)", apchq: ["Tuyauteur", "Plombier"] },
117 + { code: "ferblantier", fr: "Ferblantier", en: "Sheet-metal worker", apchq: ["Ferblantier"] },
118 + { code: "frigoriste", fr: "Frigoriste", en: "Refrigeration mechanic", apchq: ["Frigoriste"] },
119 + { code: "peintre", fr: "Peintre", en: "Painter", apchq: ["Peintre"] },
120 + { code: "platrier", fr: "Plâtrier (tireur de joints)", en: "Plasterer (taper)", apchq: ["Plâtrier", "Platrier"] },
121 + { code: "poseur_systemes", fr: "Poseur de systèmes intérieurs", en: "Interior systems installer", apchq: ["Poseur de systèmes intérieurs", "Poseur de systemes interieurs"] },
122 + { code: "poseur_revetements", fr: "Poseur de revêtements souples", en: "Floor-covering installer", apchq: ["Poseur de revêtements souples", "Poseur de revetements souples"] },
123 + { code: "calorifugeur", fr: "Calorifugeur", en: "Insulator", apchq: ["Calorifugeur"] },
124 + { code: "ferrailleur", fr: "Ferrailleur", en: "Rebar worker", apchq: ["Ferrailleur"] },
125 + { code: "vitrier", fr: "Monteur-mécanicien (vitrier)", en: "Glazier", apchq: ["Monteur-mécanicien (vitrier)", "Vitrier"] },
126 + { code: "manoeuvre", fr: "Manœuvre", en: "Labourer", apchq: ["Manœuvre", "Manoeuvre"] },
127 + { code: "operateur", fr: "Opérateur de pelles mécaniques", en: "Excavator operator", apchq: ["Opérateur de pelles mécaniques", "Operateur de pelles mecaniques", "Opérateur de pelles"] },
128 + { code: "poseur_bardeaux", fr: "Poseur de bardeaux", en: "Shingle installer", apchq: ["Couvreur"] },
129 +];
130 +
131 +export const tradeLabel = (code: string | null, fr: boolean): string => {
132 + const t = TRADES.find((x) => x.code === code);
133 + return t ? (fr ? t.fr : t.en) : (code ?? "—");
134 +};
135 +
136 +/**
137 + * Paramètres par défaut — HYPOTHÈSES DOCUMENTÉES (aucun pourcentage n'est
138 + * universel) : visibles, modifiables et expliqués dans l'interface.
139 + */
140 +export const DEFAULT_INDIRECT: IndirectParams = {
141 + plansPct: 3.0,
142 + permitsPct: 0.8,
143 + inspectionPct: 0.5,
144 + insurancePct: 1.5,
145 + mobilizationPct: 1.5,
146 + siteManagementPct: 3.0,
147 + financingPct: 2.0,
148 + adminPct: 1.0,
149 +};
150 +
151 +export const DEFAULT_PARAMS: CostParams = {
152 + indirect: DEFAULT_INDIRECT,
153 + overheadPct: 7.0,
154 + profitPct: 8.0,
155 + contingencyPct: 3.0,
156 +};
157 +
158 +export const INDIRECT_LABELS: Record<keyof IndirectParams, { fr: string; en: string; noteFr: string }> = {
159 + plansPct: { fr: "Plans et professionnels", en: "Plans and professionals", noteFr: "Architecte/technologue, ingénieur, arpenteur — hypothèse 3 % du coût direct." },
160 + permitsPct: { fr: "Permis", en: "Permits", noteFr: "Permis de construction municipal — hypothèse 0,8 %." },
161 + inspectionPct: { fr: "Inspection et essais", en: "Inspection and testing", noteFr: "Inspections, essais de sol/béton — hypothèse 0,5 %." },
162 + insurancePct: { fr: "Assurances chantier", en: "Builder's insurance", noteFr: "Assurance chantier et responsabilité — hypothèse 1,5 %." },
163 + mobilizationPct: { fr: "Mobilisation", en: "Mobilization", noteFr: "Roulotte, clôture, installations temporaires — hypothèse 1,5 %." },
164 + siteManagementPct: { fr: "Gestion de chantier", en: "Site management", noteFr: "Surintendant, coordination — hypothèse 3 %." },
165 + financingPct: { fr: "Financement", en: "Financing", noteFr: "Intérêts intercalaires pendant la construction — hypothèse 2 %." },
166 + adminPct: { fr: "Administration", en: "Administration", noteFr: "Frais administratifs du projet — hypothèse 1 %." },
167 +};
168 +
169 +/** Matériaux normalisés (taxonomie canonique renvoyée par l'IA et l'UI). */
170 +export const MATERIALS = [
171 + "quartz", "granite", "laminate", "butcher_block", "solid_surface", "marble",
172 + "hardwood", "engineered_wood", "vinyl_plank", "laminate_floor", "ceramic_tile", "porcelain_tile", "carpet", "concrete_floor",
173 + "brick", "stone", "vinyl_siding", "fiber_cement", "wood_siding", "stucco", "aluminum_siding", "steel_siding",
174 + "asphalt_shingle", "metal_roof", "elastomeric_membrane", "epdm", "cedar_shake",
175 + "pvc_window", "hybrid_window", "aluminum_window", "wood_window",
176 + "poured_concrete", "concrete_block", "stone_foundation",
177 + "melamine", "thermofoil", "painted_mdf", "wood_veneer", "solid_wood",
178 +] as const;
179 +export type Material = (typeof MATERIALS)[number];
180 +
181 +/** Économie de lecture des cuisines : pieds linéaires d'armoires typiques par qualité. */
182 +export const KITCHEN_LINEAR_FT: Record<Quality, number> = { economy: 20, standard: 26, superior: 32, prestige: 40 };
added src/lib/cost/types.ts +534 −0
@@ -0,0 +1,534 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Méthode du coût — types partagés du moteur, de la base de coûts et
4 + * de l'interface. Module PUR (aucun import serveur) : importable côté client.
5 + *
6 + * Vocabulaire :
7 + * - article (cost item) : matériau ou fourniture canonique (ex. 2x6 SPF 8 pi)
8 + * - assemblage (assembly) : composante réelle de construction (ex. mur 2x6 R24 vinyle)
9 + * - ligne (line) : un assemblage quantifié et chiffré dans une estimation
10 + * - provenance : d'où vient chaque nombre (observé, calculé, indexé, hypothèse…)
11 + */
12 +
13 +/* ----------------------------------------------------------- provenance */
14 +
15 +/** Nature d'un prix affiché (icônes ● ◐ ○ ◇ AI dans l'UI). */
16 +export type PriceKind =
17 + | "observed" // ● observé directement chez un fournisseur
18 + | "derived" // ◐ calculé depuis des composants
19 + | "indexed" // ○ observation ancienne actualisée par un indice
20 + | "reference" // ◇ prix de référence interne (hypothèse documentée, non observé)
21 + | "assumption" // ◇ hypothèse documentée (pourcentage, productivité…)
22 + | "official"; // ● taux réglementé / publication officielle (APCHQ/CCQ, StatCan)
23 +
24 +/** Origine d'une caractéristique du bâtiment. */
25 +export type AttributeSource = "MAMH" | "listing" | "user" | "AI" | "derived" | "assumed" | "cadastral";
26 +
27 +/** Couche d'affichage (§208) : OBSERVÉ · INFÉRÉ PAR IA · CALCULÉ · PRIX SOURCÉ. */
28 +export type Layer = "observed" | "ai_inferred" | "computed" | "sourced_price" | "assumption";
29 +
30 +export interface Provenance {
31 + kind: PriceKind;
32 + source: string; // nom de la source (ex. « Canac », « APCHQ », « Vrai-Prix — hypothèse »)
33 + sourceId?: number;
34 + sourceUrl?: string | null;
35 + observedAt?: string | null; // date de l'observation ou de la publication
36 + effectiveDate?: string | null;
37 + note?: string | null; // ex. « prix de mars 2026 actualisé à septembre 2026 (StatCan 18-10-0289) »
38 + confidence: number; // 0-100
39 +}
40 +
41 +/* ------------------------------------------------------------ taxonomie */
42 +
43 +export type Quality = "economy" | "standard" | "superior" | "prestige";
44 +export type Condition = "poor" | "below_average" | "average" | "good" | "very_good" | "excellent" | "renovated" | "new";
45 +export type BuildingType = "detached" | "semi_detached" | "row" | "plex" | "condo" | "chalet" | "mobile" | "other";
46 +export type Basement = "none" | "crawl" | "unfinished" | "partial" | "finished" | "walkout";
47 +export type Foundation = "poured_concrete" | "concrete_block" | "slab_on_grade" | "piers" | "stone";
48 +export type Structure = "wood_frame" | "steel" | "concrete" | "log" | "masonry";
49 +export type RoofType = "asphalt_shingle" | "metal" | "membrane" | "cedar" | "slate_tile";
50 +export type RoofGeometry = "gable" | "hip" | "flat" | "mansard" | "complex";
51 +export type Siding = "vinyl" | "brick" | "fiber_cement" | "wood" | "stone" | "stucco" | "aluminum" | "steel";
52 +export type WindowType = "pvc" | "hybrid" | "aluminum" | "wood";
53 +export type Heating = "electric_baseboard" | "heat_pump" | "furnace_electric" | "furnace_gas" | "furnace_oil" | "hydronic" | "geothermal" | "wood";
54 +export type GarageType = "none" | "attached" | "detached" | "integrated" | "carport";
55 +
56 +/** Catégories UX du résidentiel québécois (au-dessus des divisions MasterFormat). */
57 +export type UxCategory =
58 + | "site"
59 + | "excavation"
60 + | "foundation"
61 + | "structure"
62 + | "roofing"
63 + | "envelope"
64 + | "openings"
65 + | "insulation"
66 + | "finishes_ext"
67 + | "interior"
68 + | "kitchen"
69 + | "bathroom"
70 + | "plumbing"
71 + | "hvac"
72 + | "electrical"
73 + | "garage"
74 + | "basement"
75 + | "exterior";
76 +
77 +/* ---------------------------------------------------------------- unités */
78 +
79 +export type CanonicalUnit =
80 + | "unit" // chaque / unité
81 + | "pi2" // pied carré
82 + | "pi_lin" // pied linéaire
83 + | "pi3"
84 + | "m2"
85 + | "m3"
86 + | "kg"
87 + | "lb"
88 + | "L"
89 + | "gal"
90 + | "h"
91 + | "day"
92 + | "lump"; // forfait
93 +
94 +/* ---------------------------------------------------------------- articles */
95 +
96 +export interface CostItem {
97 + id: number;
98 + code: string; // canonical_code (ex. LUM-2X6-8)
99 + masterformat: string; // « 06 »
100 + division: string; // libellé MasterFormat
101 + category: UxCategory;
102 + subcategory: string | null;
103 + nameFr: string;
104 + nameEn: string;
105 + descriptionFr: string | null;
106 + descriptionEn: string | null;
107 + unit: CanonicalUnit;
108 + materialClass: string | null;
109 + trade: string | null; // métier associé par défaut
110 + defaultWastePct: number;
111 + qualityLevel: Quality | null;
112 + /** prix de référence interne (hypothèse) si aucune observation — jamais présenté comme observé */
113 + referencePrice: number | null;
114 + referenceNote: string | null;
115 + active: boolean;
116 +}
117 +
118 +/** Prix canonique courant d'un article (résultat de pricing.ts). */
119 +export interface ItemPrice {
120 + itemCode: string;
121 + unit: CanonicalUnit;
122 + price: number; // médiane robuste
123 + low: number;
124 + high: number;
125 + kind: PriceKind;
126 + sourceCount: number;
127 + observedAt: string | null; // date de la plus récente observation
128 + sources: { source: string; price: number; url: string | null; observedAt: string; sourceUnit: string; conversionFactor: number; regular: boolean }[];
129 + confidence: number; // 0-100 (fraîcheur, nb sources, qualité, dispersion, localisation)
130 + indexed?: { fromDate: string; toDate: string; factor: number; index: string } | null;
131 + note: string | null;
132 +}
133 +
134 +/* --------------------------------------------------------- main-d'œuvre */
135 +
136 +export interface LabourRate {
137 + tradeCode: string;
138 + tradeNameFr: string;
139 + tradeNameEn: string;
140 + sector: string; // residentiel_leger | residentiel_lourd | ic
141 + classification: string; // compagnon | apprenti-1…
142 + region: string; // QC (conventions provinciales) | isole
143 + effectiveFrom: string;
144 + effectiveTo: string | null;
145 + baseWage: number;
146 + vacationCost: number;
147 + benefitsCost: number;
148 + employerContributions: number;
149 + otherContributions: number;
150 + totalEmployerCost: number;
151 + source: string;
152 + sourceUrl: string | null;
153 + confidence: number;
154 +}
155 +
156 +/* ------------------------------------------------------------ localisation */
157 +
158 +export interface CostLocation {
159 + code: string; // ex. « QC-MTL », « QC-OUT »
160 + nameFr: string;
161 + nameEn: string;
162 + regionCode: string;
163 + materialFactor: number;
164 + labourFactor: number;
165 + equipmentFactor: number;
166 + overallFactor: number;
167 + effectiveDate: string;
168 + sourceMethod: string;
169 + confidence: number;
170 +}
171 +
172 +/* ------------------------------------------------------------ assemblages */
173 +
174 +export interface AssemblyComponent {
175 + itemCode: string;
176 + /** quantité d'article (dans l'unité de l'article) par unité d'assemblage */
177 + quantity: number;
178 + wasteFactor: number; // 0.07 = 7 % de pertes
179 + labourHours: number; // heures de main-d'œuvre par unité d'assemblage, portées par ce composant
180 + trade: string | null; // métier pour ces heures (défaut : métier de l'article)
181 + equipmentCost: number; // $ d'équipement par unité d'assemblage (hypothèse documentée)
182 + sequence: number;
183 + notes: string | null;
184 +}
185 +
186 +export interface Assembly {
187 + code: string;
188 + masterformat: string;
189 + category: UxCategory;
190 + nameFr: string;
191 + nameEn: string;
192 + descriptionFr: string;
193 + descriptionEn: string;
194 + unit: CanonicalUnit;
195 + buildingType: string | null; // null = tous
196 + quality: Quality | null;
197 + version: number;
198 + /** vie économique typique de la composante (années) — dépréciation par composante */
199 + economicLife: number | null;
200 + /** catégorie de condition (roof, windows, kitchen…) pour les règles condition → âge effectif */
201 + conditionGroup: string | null;
202 + components: AssemblyComponent[];
203 + active: boolean;
204 +}
205 +
206 +/** Coût unitaire d'un assemblage (par unité d'assemblage), avant facteur régional. */
207 +export interface AssemblyUnitCost {
208 + code: string;
209 + unit: CanonicalUnit;
210 + material: number;
211 + labour: number;
212 + equipment: number;
213 + direct: number;
214 + /** écart-type relatif estimé du coût direct (dispersion des prix + heures) */
215 + sigmaPct: number;
216 + /** part du coût matériau provenant de prix observés/officiels (0-1) */
217 + observedShare: number;
218 + components: {
219 + itemCode: string;
220 + nameFr: string;
221 + nameEn: string;
222 + unit: CanonicalUnit;
223 + quantity: number;
224 + wasteFactor: number;
225 + unitPrice: number;
226 + materialCost: number;
227 + labourHours: number;
228 + trade: string | null;
229 + hourlyRate: number | null;
230 + labourCost: number;
231 + equipmentCost: number;
232 + provenance: Provenance;
233 + rateProvenance: Provenance | null;
234 + }[];
235 + freshestObservation: string | null;
236 +}
237 +
238 +/* ------------------------------------------------------------- estimation */
239 +
240 +export type EstimateMode = "property" | "construction" | "listing";
241 +
242 +export interface SidingMix {
243 + vinyl?: number;
244 + brick?: number;
245 + fiber_cement?: number;
246 + wood?: number;
247 + stone?: number;
248 + stucco?: number;
249 + aluminum?: number;
250 + steel?: number;
251 +}
252 +
253 +/** Description du bâtiment — chaque champ peut être MAMH, saisi, IA ou dérivé (attributeSources). */
254 +export interface BuildingSpec {
255 + type: BuildingType;
256 + quality: Quality;
257 + grossFloorAreaSqft: number; // aire d'étages hors sous-sol
258 + footprintSqft: number | null;
259 + stories: number;
260 + yearBuilt: number | null;
261 + basement: Basement;
262 + basementFinishedPct: number; // 0-1
263 + garage: { type: GarageType; spaces: number; areaSqft: number | null };
264 + structure: Structure;
265 + foundation: Foundation;
266 + siding: SidingMix; // parts (somme ≈ 1)
267 + roof: RoofType;
268 + roofGeometry: RoofGeometry;
269 + roofPitch: number; // rise/12
270 + windows: WindowType;
271 + windowCount: number | null;
272 + heating: Heating;
273 + hasAirConditioning: boolean;
274 + hasAirExchanger: boolean;
275 + kitchens: number;
276 + kitchenQuality: Quality;
277 + bathrooms: number;
278 + powderRooms: number;
279 + bathroomQuality: Quality;
280 + bedrooms: number | null;
281 + flooring: { hardwood?: number; engineered?: number; vinyl_plank?: number; ceramic?: number; laminate?: number; carpet?: number };
282 + deckSqft: number;
283 + drivewaySqft: number;
284 + driveway: "asphalt" | "pavers" | "gravel" | "concrete" | "none";
285 + fenceLinFt: number;
286 + landscapingSqft: number;
287 + pool: "none" | "above_ground" | "inground";
288 + units: number; // logements (plex)
289 +}
290 +
291 +export interface IndirectParams {
292 + plansPct: number;
293 + permitsPct: number;
294 + inspectionPct: number;
295 + insurancePct: number;
296 + mobilizationPct: number;
297 + siteManagementPct: number;
298 + financingPct: number;
299 + adminPct: number;
300 +}
301 +
302 +export interface CostParams {
303 + indirect: IndirectParams;
304 + overheadPct: number;
305 + profitPct: number;
306 + contingencyPct: number;
307 +}
308 +
309 +export interface FunctionalObsolescence {
310 + id: string;
311 + type: string;
312 + curable: boolean;
313 + costToCure: number;
314 + valueLoss: number;
315 + notes: string;
316 +}
317 +
318 +export interface DepreciationInput {
319 + method: "age_life" | "components";
320 + effectiveAge: number | null; // années (défaut : âge chronologique)
321 + economicLife: number; // années
322 + effectiveAgeSource: AttributeSource;
323 + /** condition par groupe de composantes (roof, windows, kitchen, bathroom, mechanical, exterior, interior, structure) */
324 + componentConditions: Partial<Record<string, Condition>>;
325 + functional: FunctionalObsolescence[];
326 + externalValueLoss: number;
327 + externalNote: string;
328 +}
329 +
330 +export interface LandInput {
331 + value: number | null;
332 + source: "role" | "user" | "market" | "residual" | "none";
333 + rollYear: number | null;
334 + method: string;
335 +}
336 +
337 +export interface CostInput {
338 + mode: EstimateMode;
339 + propertyId: string | null; // id_provinc MAMH
340 + listingUid: string | null;
341 + address: string | null;
342 + municipality: string | null;
343 + lat: number | null;
344 + lng: number | null;
345 + locationCode: string | null; // forcer une localisation ; sinon dérivée
346 + building: BuildingSpec;
347 + /** surcharges de quantités par assemblage (unité de l'assemblage) */
348 + quantityOverrides: Record<string, number>;
349 + /** assemblages exclus */
350 + excludedAssemblies: string[];
351 + params: CostParams;
352 + depreciation: DepreciationInput;
353 + land: LandInput;
354 + attributeSources: Partial<Record<string, AttributeSource>>;
355 + /** contexte du rôle (mode propriété) */
356 + roll: { landValue: number | null; buildingValue: number | null; totalValue: number | null; year: number } | null;
357 + /** instantané de prix (date) — null = aujourd'hui */
358 + priceDate: string | null;
359 +}
360 +
361 +export interface QuantityLine {
362 + assemblyCode: string;
363 + quantity: number;
364 + unit: CanonicalUnit;
365 + source: AttributeSource; // derived (géométrie), user (surcharge), AI, listing
366 + formula: string; // texte de la formule appliquée
367 + inputs: Record<string, number | string>;
368 +}
369 +
370 +export interface EstimateLine {
371 + assemblyCode: string;
372 + nameFr: string;
373 + nameEn: string;
374 + category: UxCategory;
375 + quantity: number;
376 + unit: CanonicalUnit;
377 + quantitySource: AttributeSource;
378 + quantityFormula: string;
379 + unitCost: number; // direct par unité, avant localisation
380 + material: number;
381 + labour: number;
382 + equipment: number;
383 + direct: number; // avant localisation
384 + locationAdjustment: number; // $ ajouté/retiré par la localisation
385 + adjusted: number; // direct localisé
386 + sigma: number; // $ (écart-type de la ligne)
387 + observedShare: number;
388 + confidence: number;
389 + economicLife: number | null;
390 + conditionGroup: string | null;
391 + unitDetail: AssemblyUnitCost;
392 +}
393 +
394 +export interface CategoryTotal {
395 + category: UxCategory;
396 + labelFr: string;
397 + labelEn: string;
398 + material: number;
399 + labour: number;
400 + equipment: number;
401 + direct: number;
402 + adjusted: number;
403 + sharePct: number;
404 +}
405 +
406 +export interface ConfidenceBreakdown {
407 + freshness: number; // /20
408 + coverage: number; // /20
409 + location: number; // /15
410 + labour: number; // /15
411 + benchmarks: number; // /10
412 + building: number; // /20
413 + total: number; // /100
414 + letter: "A" | "B" | "C" | "D";
415 + notesFr: string[];
416 + notesEn: string[];
417 +}
418 +
419 +export interface DepreciationComponentRow {
420 + conditionGroup: string;
421 + labelFr: string;
422 + labelEn: string;
423 + rcn: number;
424 + economicLife: number;
425 + condition: Condition | null;
426 + effectiveAge: number;
427 + depreciationPct: number;
428 + depreciation: number;
429 + remaining: number;
430 + rule: string;
431 +}
432 +
433 +export interface DepreciationResult {
434 + method: "age_life" | "components";
435 + chronologicalAge: number | null;
436 + effectiveAge: number | null;
437 + economicLife: number;
438 + physicalPct: number;
439 + physical: number;
440 + components: DepreciationComponentRow[];
441 + functionalCurable: number;
442 + functionalIncurable: number;
443 + functional: number;
444 + external: number;
445 + total: number;
446 + depreciatedImprovementValue: number;
447 +}
448 +
449 +export interface BenchmarkCheck {
450 + source: string;
451 + buildingType: string;
452 + market: string;
453 + unit: string;
454 + low: number;
455 + high: number;
456 + midpoint: number | null;
457 + year: number;
458 + estimatePerUnit: number;
459 + status: "within" | "below" | "above" | "unavailable";
460 + deviationPct: number | null;
461 + notes: string | null;
462 +}
463 +
464 +export interface CostEstimate {
465 + id: string;
466 + createdAt: string;
467 + methodVersion: string;
468 + costDatabaseVersion: string;
469 + assemblyVersion: string;
470 + priceDate: string;
471 + input: CostInput;
472 + location: CostLocation;
473 + quantities: QuantityLine[];
474 + lines: EstimateLine[];
475 + categories: CategoryTotal[];
476 + directCost: number;
477 + directMaterial: number;
478 + directLabour: number;
479 + directEquipment: number;
480 + indirect: { key: keyof IndirectParams; labelFr: string; labelEn: string; pct: number; amount: number }[];
481 + indirectCost: number;
482 + contractorOverhead: number;
483 + contractorProfit: number;
484 + contingency: number;
485 + replacementCostNew: number;
486 + range: { p10: number; p90: number; sigma: number; low: number; high: number };
487 + perSqft: number;
488 + perM2: number;
489 + depreciation: DepreciationResult;
490 + landValue: number;
491 + costApproachValue: number;
492 + confidence: ConfidenceBreakdown;
493 + benchmarks: BenchmarkCheck[];
494 + coverage: { materialObservedShare: number; assembliesPriced: number; assembliesTotal: number; pricingCoverage: number };
495 + /** comparaison avec le moteur existant (mode propriété/annonce) */
496 + otherReadings: { hedonic: number | null; comparables: number | null; hybrid: number | null; askingPrice: number | null; rollValue: number | null; rollBuilding: number | null; rollLand: number | null } | null;
497 + warnings: string[];
498 +}
499 +
500 +/* ---------------------------------------------------------- tableau de bord */
501 +
502 +export interface IndexPoint {
503 + period: string; // YYYY-MM-DD (début du trimestre)
504 + value: number;
505 + pctYoy: number | null;
506 +}
507 +
508 +export interface IndexSeries {
509 + code: string;
510 + source: string;
511 + geography: string;
512 + buildingType: string;
513 + division: string;
514 + points: IndexPoint[];
515 + retrievedAt: string | null;
516 +}
517 +
518 +export interface CostOverview {
519 + generatedAt: string;
520 + indices: IndexSeries[];
521 + kpis: {
522 + residentialIndex: { value: number; period: string; pctYoy: number | null; geography: string } | null;
523 + materialsProxy: { value: number; period: string; pctYoy: number | null; label: string } | null;
524 + labour: { trade: string; totalEmployerCost: number; effectiveFrom: number | string; pctYoy: number | null; source: string } | null;
525 + observations: number;
526 + itemsObserved: number;
527 + itemsTotal: number;
528 + lastSync: string | null;
529 + };
530 + materials: { itemCode: string; nameFr: string; nameEn: string; unit: CanonicalUnit; price: number | null; kind: PriceKind | null; sourceCount: number; observedAt: string | null; history: { date: string; price: number }[] }[];
531 + popularAssemblies: { code: string; nameFr: string; nameEn: string; unit: CanonicalUnit; direct: number; material: number; labour: number; equipment: number; observedShare: number }[];
532 + locations: CostLocation[];
533 + sources: { name: string; type: string; license: string; lastSync: string | null; active: boolean; observations: number; url: string | null }[];
534 +}
added src/lib/cost/units.ts +143 −0
@@ -0,0 +1,143 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Moteur de conversion d'unités de la base de coûts. Module PUR.
4 + * Règle : on conserve TOUJOURS l'unité d'origine (source_unit), l'unité
5 + * canonique (canonical_unit) et le facteur de conversion appliqué.
6 + */
7 +import type { CanonicalUnit } from "./types";
8 +
9 +export const SQFT_PER_M2 = 10.7639;
10 +export const M2_PER_SQFT = 0.092903;
11 +export const FT_PER_M = 3.28084;
12 +export const M3_PER_FT3 = 0.0283168;
13 +export const LB_PER_KG = 2.20462;
14 +export const L_PER_GAL_US = 3.78541;
15 +export const L_PER_GAL_IMP = 4.54609;
16 +
17 +/** Libellés FR/EN des unités canoniques. */
18 +export const UNIT_LABELS: Record<CanonicalUnit, { fr: string; en: string }> = {
19 + unit: { fr: "unité", en: "each" },
20 + pi2: { fr: "pi²", en: "sq ft" },
21 + pi_lin: { fr: "pi lin.", en: "lin ft" },
22 + pi3: { fr: "pi³", en: "cu ft" },
23 + m2: { fr: "m²", en: "m²" },
24 + m3: { fr: "m³", en: "m³" },
25 + kg: { fr: "kg", en: "kg" },
26 + lb: { fr: "lb", en: "lb" },
27 + L: { fr: "L", en: "L" },
28 + gal: { fr: "gal", en: "gal" },
29 + h: { fr: "heure", en: "hour" },
30 + day: { fr: "jour", en: "day" },
31 + lump: { fr: "forfait", en: "lump sum" },
32 +};
33 +
34 +/** Unités source reconnues (normalisées en minuscules, sans accents). */
35 +export type SourceUnit =
36 + | CanonicalUnit
37 + | "chaque"
38 + | "each"
39 + | "ea"
40 + | "paquet"
41 + | "pack"
42 + | "boite"
43 + | "box"
44 + | "sac"
45 + | "bag"
46 + | "rouleau"
47 + | "roll"
48 + | "feuille"
49 + | "sheet"
50 + | "seau"
51 + | "pail"
52 + | "planche"
53 + | "board"
54 + | "longueur"
55 + | "length"
56 + | "ballot"
57 + | "bundle"
58 + | "verge3"
59 + | "cu_yd"
60 + | "tonne"
61 + | "kit";
62 +
63 +export interface UnitConversion {
64 + sourceUnit: string;
65 + canonicalUnit: CanonicalUnit;
66 + /** prix canonique = prix source × factor (ex. feuille 32 pi² → factor 1/32) */
67 + factor: number;
68 + method: string;
69 +}
70 +
71 +export function normalizeUnitToken(raw: string): string {
72 + return raw
73 + .normalize("NFD")
74 + .replace(/[̀-ͯ]/g, "")
75 + .toLowerCase()
76 + .replace(/[^a-z0-9/]/g, "")
77 + .trim();
78 +}
79 +
80 +/** Unités « emballage » qui nécessitent une quantité par emballage propre à l'article. */
81 +const PACK_UNITS = new Set(["paquet", "pack", "boite", "box", "sac", "bag", "rouleau", "roll", "feuille", "sheet", "seau", "pail", "planche", "board", "longueur", "length", "ballot", "bundle", "kit", "pqt", "bte"]);
82 +
83 +const DIRECT: Record<string, CanonicalUnit> = {
84 + unit: "unit", chaque: "unit", each: "unit", ea: "unit", ch: "unit", un: "unit", unite: "unit",
85 + pi2: "pi2", pi: "pi_lin", sqft: "pi2", "pi²": "pi2", pieds2: "pi2", piedcarre: "pi2", ft2: "pi2",
86 + pilin: "pi_lin", lf: "pi_lin", linft: "pi_lin", pl: "pi_lin", ft: "pi_lin",
87 + pi3: "pi3", ft3: "pi3", m2: "m2", m3: "m3", kg: "kg", lb: "lb", lbs: "lb", l: "L", litre: "L", liter: "L",
88 + gal: "gal", gallon: "gal", h: "h", heure: "h", hour: "h", hr: "h", day: "day", jour: "day", lump: "lump", forfait: "lump",
89 +};
90 +
91 +/**
92 + * Convertit un prix exprimé dans une unité source vers l'unité canonique de
93 + * l'article. `packQty` = contenu d'un emballage exprimé dans l'unité canonique
94 + * (ex. feuille de gypse 4×8 → 32 pi² ; paquet d'isolant R24 → 78,3 pi²).
95 + */
96 +export function convertPrice(
97 + price: number,
98 + sourceUnitRaw: string,
99 + canonical: CanonicalUnit,
100 + packQty?: number | null
101 +): { price: number; conversion: UnitConversion } {
102 + const tok = normalizeUnitToken(sourceUnitRaw);
103 + const direct = DIRECT[tok];
104 + if (direct === canonical) {
105 + return { price, conversion: { sourceUnit: sourceUnitRaw, canonicalUnit: canonical, factor: 1, method: "identité" } };
106 + }
107 + // conversions métriques ↔ impériales simples
108 + const pair = `${direct ?? tok}->${canonical}`;
109 + const SIMPLE: Record<string, [number, string]> = {
110 + "m2->pi2": [1 / SQFT_PER_M2, "1 m² = 10,764 pi²"],
111 + "pi2->m2": [SQFT_PER_M2, "1 m² = 10,764 pi²"],
112 + "m3->pi3": [M3_PER_FT3, "1 pi³ = 0,0283 m³"],
113 + "pi3->m3": [1 / M3_PER_FT3, "1 pi³ = 0,0283 m³"],
114 + "verge3->m3": [1 / 0.764555, "1 vg³ = 0,7646 m³"],
115 + "cu_yd->m3": [1 / 0.764555, "1 vg³ = 0,7646 m³"],
116 + "kg->lb": [1 / LB_PER_KG, "1 kg = 2,2046 lb"],
117 + "lb->kg": [LB_PER_KG, "1 kg = 2,2046 lb"],
118 + "gal->L": [1 / L_PER_GAL_US, "1 gal US = 3,785 L"],
119 + "L->gal": [L_PER_GAL_US, "1 gal US = 3,785 L"],
120 + "m->pi_lin": [1 / FT_PER_M, "1 m = 3,2808 pi"],
121 + "pi_lin->m": [FT_PER_M, "1 m = 3,2808 pi"],
122 + };
123 + const s = SIMPLE[pair];
124 + if (s) return { price: price * s[0], conversion: { sourceUnit: sourceUnitRaw, canonicalUnit: canonical, factor: s[0], method: s[1] } };
125 + // emballages : besoin de la quantité par emballage
126 + if ((PACK_UNITS.has(tok) || direct === "unit" || tok === "") && packQty && packQty > 0) {
127 + const f = 1 / packQty;
128 + return {
129 + price: price * f,
130 + conversion: { sourceUnit: sourceUnitRaw, canonicalUnit: canonical, factor: f, method: `1 ${sourceUnitRaw || "emballage"} = ${packQty} ${UNIT_LABELS[canonical].fr}` },
131 + };
132 + }
133 + throw new Error(`Conversion impossible : « ${sourceUnitRaw} » → ${canonical}${packQty ? "" : " (quantité par emballage inconnue)"}`);
134 +}
135 +
136 +/** Vrai si l'unité source est reconnue (canonique, métrique/impériale ou emballage). */
137 +export function isKnownUnit(raw: string): boolean {
138 + const tok = normalizeUnitToken(raw);
139 + return tok in DIRECT || PACK_UNITS.has(tok) || ["m", "verge3", "cu_yd", "tonne"].includes(tok);
140 +}
141 +
142 +export const sqftFromM2 = (m2: number): number => m2 * SQFT_PER_M2;
143 +export const m2FromSqft = (sqft: number): number => sqft * M2_PER_SQFT;
modified src/lib/estimator.ts +2 −2
@@ -88,7 +88,7 @@ export function indexType(typeProp: string): string {
88 88 return "unifamilial";
89 89 }
90 90
91 −function toComps(rows: TxRow[]): CompInput[] {
91 +export function toComps(rows: TxRow[]): CompInput[] {
92 92 return rows.map((r) => ({
93 93 id: r.id,
94 94 date: r.date,
@@ -113,7 +113,7 @@ function since(months: number): string {
113 113 return d.toISOString().slice(0, 10);
114 114 }
115 115
116 −function candidatesAround(lat: number, lng: number): TxRow[] {
116 +export function candidatesAround(lat: number, lng: number): TxRow[] {
117 117 // ~2,2 km puis élargit à ~11 km si marché mince
118 118 for (const halfDeg of [0.02, 0.1]) {
119 119 const rows = getCandidates(lat, lng, halfDeg, since(30), 600);
added src/lib/immoka.ts +516 −0
@@ -0,0 +1,516 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Accès à la copie de la base Immo-Ka (data/immoka.db) : annonces réellement
4 + * à vendre au Québec + table `vp_eval` (estimation du moteur pour chaque
5 + * annonce, produite par scripts/eval-immoka.ts → build-marche-stats.mjs).
6 + *
7 + * Sert l'onglet « À vendre » : recherche/filtres, fiche complète, comparables
8 + * choisis par l'utilisateur, comparaison avec la mesure Vrai-Prix.
9 + */
10 +import Database from "better-sqlite3";
11 +import path from "path";
12 +
13 +let db: Database.Database | null = null;
14 +
15 +/** Filtre « annonce vivante » — identique à celui du pipeline eval-immoka.ts. */
16 +const LIVE =
17 + "l.status = 'a-vendre' AND l.published = 1 AND l.active = 1 AND l.dup_hidden = 0 AND l.price > 0";
18 +
19 +export function getImmoDb(): Database.Database {
20 + if (!db) {
21 + const p = process.env.IMMOKA_DB ?? path.join(process.cwd(), "data", "immoka.db");
22 + db = new Database(p, { fileMustExist: true });
23 + db.pragma("journal_mode = WAL");
24 + ensureImmoIndexes(db);
25 + }
26 + return db;
27 +}
28 +
29 +/**
30 + * Index et table FTS nécessaires aux requêtes de l'onglet (no-op si présents).
31 + * Appelé à l'ouverture et par le pipeline de rafraîchissement.
32 + */
33 +export function ensureImmoIndexes(d: Database.Database): void {
34 + d.exec(`
35 + CREATE INDEX IF NOT EXISTS idx_listings_live
36 + ON listings(status, published, active, dup_hidden, price);
37 + CREATE INDEX IF NOT EXISTS idx_listings_city_nocase ON listings(city COLLATE NOCASE);
38 + CREATE INDEX IF NOT EXISTS idx_listings_last_seen ON listings(last_seen);
39 + `);
40 + const hasEval = d
41 + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='vp_eval'")
42 + .get();
43 + if (hasEval) {
44 + d.exec("CREATE INDEX IF NOT EXISTS idx_vp_eval_muni ON vp_eval(municipalite)");
45 + }
46 + const hasFts = d
47 + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='listings_fts'")
48 + .get();
49 + if (!hasFts) rebuildListingsFts(d);
50 +}
51 +
52 +/** Table FTS5 des annonces vivantes (adresse, ville, secteur, titre, MLS). */
53 +export function rebuildListingsFts(d: Database.Database): void {
54 + d.exec(`
55 + DROP TABLE IF EXISTS listings_fts;
56 + CREATE VIRTUAL TABLE listings_fts USING fts5(
57 + uid UNINDEXED, address, city, sector, title, mls,
58 + tokenize = 'unicode61 remove_diacritics 2'
59 + );
60 + INSERT INTO listings_fts(uid, address, city, sector, title, mls)
61 + SELECT l.uid, COALESCE(l.address,''), COALESCE(l.city,''), COALESCE(l.sector,''),
62 + COALESCE(l.title,''), COALESCE(l.mls,'')
63 + FROM listings l WHERE ${LIVE};
64 + `);
65 +}
66 +
67 +/* ------------------------------------------------------------------ types */
68 +
69 +export interface ListingRow {
70 + uid: string;
71 + source: string;
72 + external_id: string;
73 + url: string | null;
74 + title: string | null;
75 + address: string | null;
76 + sector: string | null;
77 + city: string | null;
78 + region: string | null;
79 + property_type: string | null;
80 + price: number;
81 + price_label: string | null;
82 + bedrooms: number | null;
83 + bathrooms: number | null;
84 + powder_rooms: number | null;
85 + area_sqft: number | null;
86 + lot_sqft: number | null;
87 + year_built: number | null;
88 + mls: string | null;
89 + status: string;
90 + broker_name: string | null;
91 + agency: string | null;
92 + description: string | null;
93 + features: string | null;
94 + details: string | null;
95 + images: string | null;
96 + lat: number | null;
97 + lng: number | null;
98 + first_seen: number | null;
99 + last_seen: number | null;
100 + updated_at: number | null;
101 + quality_score: number | null;
102 +}
103 +
104 +export interface EvalRow {
105 + uid: string;
106 + unit_id: string | null;
107 + match_m: number | null;
108 + price: number | null;
109 + est: number | null;
110 + low: number | null;
111 + high: number | null;
112 + confidence_pct: number | null;
113 + confidence: string | null;
114 + model_est: number | null;
115 + comps_est: number | null;
116 + model_weight: number | null;
117 + n_comps: number | null;
118 + cost_est: number | null;
119 + role_est: number | null;
120 + ens_est: number | null;
121 + valeur_role: number | null;
122 + type_prop: string | null;
123 + municipalite: string | null;
124 + ratio: number | null;
125 + diff: number | null;
126 + evaluated_at: string | null;
127 +}
128 +
129 +/** Carte d'annonce (liste, cartes, voisins). */
130 +export interface ListingCard {
131 + uid: string;
132 + source: string;
133 + url: string | null;
134 + address: string | null;
135 + city: string | null;
136 + sector: string | null;
137 + propertyType: string | null;
138 + group: TypeGroupKey;
139 + price: number;
140 + bedrooms: number | null;
141 + bathrooms: number | null;
142 + areaSqft: number | null;
143 + lotSqft: number | null;
144 + yearBuilt: number | null;
145 + image: string | null;
146 + nImages: number;
147 + lat: number | null;
148 + lng: number | null;
149 + firstSeen: number | null;
150 + est: number | null;
151 + low: number | null;
152 + high: number | null;
153 + confidence: string | null;
154 + ratio: number | null; // est / prix demandé
155 + municipalite: string | null;
156 + distanceM?: number;
157 +}
158 +
159 +/* ----------------------------------------------------- groupes de types */
160 +// (module pur src/lib/listing-types.ts — réexporté ici pour le serveur)
161 +import { TYPE_GROUPS, groupToTypeProp, typeGroup, type TypeGroupKey } from "./listing-types";
162 +export { TYPE_GROUPS, groupToTypeProp, typeGroup, type TypeGroupKey };
163 +
164 +let typeMap: Map<TypeGroupKey, string[]> | null = null;
165 +/** Types Immo-Ka bruts rattachés à chaque groupe (calculé une fois sur la copie). */
166 +export function rawTypesByGroup(): Map<TypeGroupKey, string[]> {
167 + if (!typeMap) {
168 + const rows = getImmoDb()
169 + .prepare(`SELECT DISTINCT l.property_type AS t FROM listings l WHERE ${LIVE}`)
170 + .all() as { t: string | null }[];
171 + typeMap = new Map();
172 + for (const { t } of rows) {
173 + const g = typeGroup(t);
174 + if (!typeMap.has(g)) typeMap.set(g, []);
175 + typeMap.get(g)!.push(t ?? "");
176 + }
177 + }
178 + return typeMap;
179 +}
180 +
181 +/* --------------------------------------------------------------- helpers */
182 +
183 +function firstImage(images: string | null): { first: string | null; n: number } {
184 + if (!images) return { first: null, n: 0 };
185 + try {
186 + const arr = JSON.parse(images);
187 + if (Array.isArray(arr) && arr.length) {
188 + const urls = arr.filter((x): x is string => typeof x === "string" && /^https?:/.test(x));
189 + return { first: urls[0] ?? null, n: urls.length };
190 + }
191 + } catch {}
192 + return { first: null, n: 0 };
193 +}
194 +
195 +export function parseImages(images: string | null): string[] {
196 + if (!images) return [];
197 + try {
198 + const arr = JSON.parse(images);
199 + return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string" && /^https?:/.test(x)) : [];
200 + } catch {
201 + return [];
202 + }
203 +}
204 +
205 +type CardSrc = ListingRow & Partial<EvalRow> & { e_est?: number | null };
206 +
207 +function toCard(r: CardSrc & { est?: number | null; low?: number | null; high?: number | null; confidence?: string | null; ratio?: number | null; municipalite?: string | null }): ListingCard {
208 + const img = firstImage(r.images);
209 + return {
210 + uid: r.uid,
211 + source: r.source,
212 + url: r.url,
213 + address: r.address,
214 + city: r.city,
215 + sector: r.sector,
216 + propertyType: r.property_type,
217 + group: typeGroup(r.property_type),
218 + price: r.price,
219 + bedrooms: r.bedrooms,
220 + bathrooms: r.bathrooms,
221 + areaSqft: r.area_sqft,
222 + lotSqft: r.lot_sqft,
223 + yearBuilt: r.year_built,
224 + image: img.first,
225 + nImages: img.n,
226 + lat: r.lat,
227 + lng: r.lng,
228 + firstSeen: r.first_seen,
229 + est: r.est ?? null,
230 + low: r.low ?? null,
231 + high: r.high ?? null,
232 + confidence: r.confidence ?? null,
233 + ratio: r.ratio ?? null,
234 + municipalite: r.municipalite ?? null,
235 + };
236 +}
237 +
238 +const CARD_COLS = `l.uid, l.source, l.url, l.address, l.city, l.sector, l.property_type, l.price,
239 + l.bedrooms, l.bathrooms, l.area_sqft, l.lot_sqft, l.year_built, l.images, l.lat, l.lng, l.first_seen,
240 + e.est, e.low, e.high, e.confidence, e.ratio, e.municipalite`;
241 +
242 +/* -------------------------------------------------------------- recherche */
243 +
244 +export type SortKey = "recent" | "price_asc" | "price_desc" | "deal" | "premium" | "est_desc";
245 +
246 +export interface SearchParams {
247 + q?: string;
248 + muni?: string;
249 + group?: TypeGroupKey | "";
250 + min?: number;
251 + max?: number;
252 + beds?: number;
253 + evalOnly?: boolean;
254 + sort?: SortKey;
255 + page?: number;
256 + per?: number;
257 +}
258 +
259 +export interface SearchResult {
260 + total: number;
261 + page: number;
262 + per: number;
263 + items: ListingCard[];
264 + facets: {
265 + groups: { key: TypeGroupKey; n: number }[];
266 + munis: { name: string; n: number }[];
267 + };
268 + summary: { medianPrice: number | null; medianRatio: number | null; nEval: number };
269 +}
270 +
271 +function ftsQuery(q: string): string | null {
272 + const terms = q
273 + .replace(/[^\p{L}\p{N}\s'-]/gu, " ")
274 + .trim()
275 + .split(/\s+/)
276 + .filter((t) => t.length > 0)
277 + .map((t) => `"${t.replace(/"/g, "")}"*`);
278 + return terms.length ? terms.join(" ") : null;
279 +}
280 +
281 +export function searchListings(p: SearchParams): SearchResult {
282 + const d = getImmoDb();
283 + const per = Math.min(Math.max(p.per ?? 24, 6), 60);
284 + const page = Math.max(p.page ?? 1, 1);
285 + const where: string[] = [LIVE];
286 + const args: unknown[] = [];
287 +
288 + if (p.q && p.q.trim()) {
289 + const fq = ftsQuery(p.q);
290 + if (fq) {
291 + where.push("l.uid IN (SELECT uid FROM listings_fts WHERE listings_fts MATCH ?)");
292 + args.push(fq);
293 + }
294 + }
295 + if (p.muni) {
296 + where.push("(e.municipalite = ? COLLATE NOCASE OR l.city = ? COLLATE NOCASE)");
297 + args.push(p.muni, p.muni);
298 + }
299 + if (p.min && p.min > 0) {
300 + where.push("l.price >= ?");
301 + args.push(p.min);
302 + }
303 + if (p.max && p.max > 0) {
304 + where.push("l.price <= ?");
305 + args.push(p.max);
306 + }
307 + if (p.beds && p.beds > 0) {
308 + where.push("l.bedrooms >= ?");
309 + args.push(p.beds);
310 + }
311 + if (p.evalOnly) where.push("e.est IS NOT NULL AND e.est > 0");
312 +
313 + // le filtre de groupe s'applique via la liste des types bruts du groupe
314 + const groupWhere: string[] = [];
315 + const groupArgs: unknown[] = [];
316 + if (p.group) {
317 + const raws = rawTypesByGroup().get(p.group) ?? [];
318 + if (raws.length) {
319 + const hasNull = raws.includes("");
320 + const nn = raws.filter((r) => r !== "");
321 + const parts: string[] = [];
322 + if (nn.length) {
323 + parts.push(`l.property_type IN (${nn.map(() => "?").join(",")})`);
324 + groupArgs.push(...nn);
325 + }
326 + if (hasNull) parts.push("l.property_type IS NULL OR l.property_type = ''");
327 + groupWhere.push(`(${parts.join(" OR ")})`);
328 + } else {
329 + groupWhere.push("0");
330 + }
331 + }
332 +
333 + const base = `FROM listings l LEFT JOIN vp_eval e ON e.uid = l.uid WHERE ${where.join(" AND ")}`;
334 + const full = groupWhere.length ? `${base} AND ${groupWhere.join(" AND ")}` : base;
335 + const fullArgs = [...args, ...groupArgs];
336 +
337 + const total = (d.prepare(`SELECT COUNT(*) AS n ${full}`).get(...fullArgs) as { n: number }).n;
338 +
339 + const order: Record<SortKey, string> = {
340 + recent: "l.first_seen DESC",
341 + price_asc: "l.price ASC",
342 + price_desc: "l.price DESC",
343 + deal: "CASE WHEN e.ratio IS NULL THEN 1 ELSE 0 END, e.ratio DESC", // estimation ≫ prix demandé
344 + premium: "CASE WHEN e.ratio IS NULL THEN 1 ELSE 0 END, e.ratio ASC",
345 + est_desc: "CASE WHEN e.est IS NULL THEN 1 ELSE 0 END, e.est DESC",
346 + };
347 + const rows = d
348 + .prepare(`SELECT ${CARD_COLS} ${full} ORDER BY ${order[p.sort ?? "recent"]}, l.uid LIMIT ? OFFSET ?`)
349 + .all(...fullArgs, per, (page - 1) * per) as CardSrc[];
350 +
351 + // facettes : groupes (sans le filtre de groupe) et municipalités (avec)
352 + const typeCounts = d
353 + .prepare(`SELECT l.property_type AS t, COUNT(*) AS n ${base} GROUP BY l.property_type`)
354 + .all(...args) as { t: string | null; n: number }[];
355 + const gmap = new Map<TypeGroupKey, number>();
356 + for (const r of typeCounts) {
357 + const g = typeGroup(r.t);
358 + gmap.set(g, (gmap.get(g) ?? 0) + r.n);
359 + }
360 + const groups = TYPE_GROUPS.map((g) => ({ key: g.key, n: gmap.get(g.key) ?? 0 })).filter((g) => g.n > 0);
361 +
362 + const munis = d
363 + .prepare(
364 + `SELECT COALESCE(e.municipalite, l.city) AS name, COUNT(*) AS n ${full}
365 + AND COALESCE(e.municipalite, l.city) IS NOT NULL AND COALESCE(e.municipalite, l.city) != ''
366 + GROUP BY name ORDER BY n DESC LIMIT 60`
367 + )
368 + .all(...fullArgs) as { name: string; n: number }[];
369 +
370 + const summary = d.prepare(`SELECT COUNT(e.est) AS nEval ${full}`).get(...fullArgs) as { nEval: number };
371 +
372 + return {
373 + total,
374 + page,
375 + per,
376 + items: rows.map(toCard),
377 + facets: { groups, munis },
378 + summary: {
379 + medianPrice: medianOf(d, `SELECT l.price AS v ${full}`, fullArgs, total),
380 + medianRatio: medianOf(d, `SELECT e.ratio AS v ${full} AND e.ratio IS NOT NULL`, fullArgs, summary.nEval),
381 + nEval: summary.nEval,
382 + },
383 + };
384 +}
385 +
386 +/** Médiane par OFFSET (évite de charger la colonne entière). */
387 +function medianOf(d: Database.Database, sql: string, args: unknown[], n: number): number | null {
388 + if (!n) return null;
389 + const row = d
390 + .prepare(`${sql} ORDER BY v LIMIT 1 OFFSET ?`)
391 + .get(...args, Math.floor(n / 2)) as { v: number } | undefined;
392 + return row?.v ?? null;
393 +}
394 +
395 +/* ------------------------------------------------------------------ fiche */
396 +
397 +export interface PricePoint {
398 + ts: number;
399 + price: number;
400 +}
401 +
402 +export interface ListingDetail {
403 + listing: ListingRow;
404 + images: string[];
405 + features: string[];
406 + details: Record<string, unknown>;
407 + priceLog: PricePoint[];
408 + eval: EvalRow | null;
409 + group: TypeGroupKey;
410 +}
411 +
412 +export function getListing(uid: string): ListingDetail | null {
413 + const d = getImmoDb();
414 + const l = d.prepare("SELECT * FROM listings l WHERE l.uid = ?").get(uid) as ListingRow | undefined;
415 + if (!l) return null;
416 + const ev = (d.prepare("SELECT * FROM vp_eval WHERE uid = ?").get(uid) as EvalRow | undefined) ?? null;
417 + const priceLog = d
418 + .prepare("SELECT ts, price FROM price_log WHERE uid = ? AND price IS NOT NULL ORDER BY ts")
419 + .all(uid) as PricePoint[];
420 + let features: string[] = [];
421 + try {
422 + const f = l.features ? JSON.parse(l.features) : [];
423 + features = Array.isArray(f) ? f.filter((x): x is string => typeof x === "string") : [];
424 + } catch {}
425 + let details: Record<string, unknown> = {};
426 + try {
427 + const dd = l.details ? JSON.parse(l.details) : {};
428 + if (dd && typeof dd === "object" && !Array.isArray(dd)) details = dd as Record<string, unknown>;
429 + } catch {}
430 + return { listing: l, images: parseImages(l.images), features, details, priceLog, eval: ev, group: typeGroup(l.property_type) };
431 +}
432 +
433 +/** Annonces vivantes autour d'un point (même groupe optionnel), triées par distance. */
434 +export function listingsNear(
435 + lat: number,
436 + lng: number,
437 + radiusKm: number,
438 + opts: { group?: TypeGroupKey; excludeUid?: string; limit?: number } = {}
439 +): ListingCard[] {
440 + const d = getImmoDb();
441 + const halfLat = radiusKm / 111;
442 + const halfLng = radiusKm / (111 * Math.cos((lat * Math.PI) / 180));
443 + const rows = d
444 + .prepare(
445 + `SELECT ${CARD_COLS} FROM listings l LEFT JOIN vp_eval e ON e.uid = l.uid
446 + WHERE ${LIVE} AND l.lat BETWEEN ? AND ? AND l.lng BETWEEN ? AND ?
447 + ORDER BY (l.lat - ?) * (l.lat - ?) + ${Math.cos((lat * Math.PI) / 180) ** 2} * (l.lng - ?) * (l.lng - ?)
448 + LIMIT ?`
449 + )
450 + .all(lat - halfLat, lat + halfLat, lng - halfLng, lng + halfLng, lat, lat, lng, lng, (opts.limit ?? 40) * 4) as CardSrc[];
451 + const out: ListingCard[] = [];
452 + for (const r of rows) {
453 + if (opts.excludeUid && r.uid === opts.excludeUid) continue;
454 + const c = toCard(r);
455 + if (opts.group && c.group !== opts.group) continue;
456 + if (c.lat == null || c.lng == null) continue;
457 + c.distanceM = Math.round(haversine(lat, lng, c.lat, c.lng));
458 + if (c.distanceM > radiusKm * 1000) continue;
459 + out.push(c);
460 + if (out.length >= (opts.limit ?? 40)) break;
461 + }
462 + return out;
463 +}
464 +
465 +function haversine(lat1: number, lng1: number, lat2: number, lng2: number): number {
466 + const R = 6371000;
467 + const toRad = (x: number) => (x * Math.PI) / 180;
468 + const dLat = toRad(lat2 - lat1);
469 + const dLng = toRad(lng2 - lng1);
470 + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
471 + return 2 * R * Math.asin(Math.sqrt(a));
472 +}
473 +
474 +/* ------------------------------------------------------- vue d'ensemble */
475 +
476 +export interface AVendreOverview {
477 + total: number;
478 + evaluated: number;
479 + medianPrice: number | null;
480 + medianRatio: number | null;
481 + copiedAt: string | null;
482 + groups: { key: TypeGroupKey; n: number }[];
483 + topMunis: { name: string; n: number }[];
484 +}
485 +
486 +let overviewCache: { at: number; v: AVendreOverview } | null = null;
487 +export function avendreOverview(): AVendreOverview {
488 + if (overviewCache && Date.now() - overviewCache.at < 10 * 60 * 1000) return overviewCache.v;
489 + const d = getImmoDb();
490 + const base = `FROM listings l LEFT JOIN vp_eval e ON e.uid = l.uid WHERE ${LIVE}`;
491 + const tot = d.prepare(`SELECT COUNT(*) AS n, COUNT(e.est) AS ne ${base}`).get() as { n: number; ne: number };
492 + const typeCounts = d.prepare(`SELECT l.property_type AS t, COUNT(*) AS n ${base} GROUP BY 1`).all() as {
493 + t: string | null;
494 + n: number;
495 + }[];
496 + const gmap = new Map<TypeGroupKey, number>();
497 + for (const r of typeCounts) gmap.set(typeGroup(r.t), (gmap.get(typeGroup(r.t)) ?? 0) + r.n);
498 + const evAt = d.prepare("SELECT MAX(evaluated_at) AS at FROM vp_eval").get() as { at: string | null } | undefined;
499 + const topMunis = d
500 + .prepare(
501 + `SELECT COALESCE(e.municipalite, l.city) AS name, COUNT(*) AS n ${base}
502 + AND COALESCE(e.municipalite, l.city) != '' GROUP BY name ORDER BY n DESC LIMIT 12`
503 + )
504 + .all() as { name: string; n: number }[];
505 + const v: AVendreOverview = {
506 + total: tot.n,
507 + evaluated: tot.ne,
508 + medianPrice: medianOf(d, `SELECT l.price AS v ${base}`, [], tot.n),
509 + medianRatio: medianOf(d, `SELECT e.ratio AS v ${base} AND e.ratio IS NOT NULL`, [], tot.ne),
510 + copiedAt: evAt?.at ?? null,
511 + groups: TYPE_GROUPS.map((g) => ({ key: g.key, n: gmap.get(g.key) ?? 0 })).filter((g) => g.n > 0),
512 + topMunis,
513 + };
514 + overviewCache = { at: Date.now(), v };
515 + return v;
516 +}
modified src/lib/ka/tools.ts +351 −1
@@ -1,6 +1,6 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 /**
3 − * Ka — la panoplie d'outils de l'agent IA de Vrai-Prix.
3 + * Ka — la panoplie d'outils de l'agent IA de Vrai-Prix (évaluation, marché, méthode du coût).
4 4 * Chaque outil est exécuté côté serveur, directement sur la base SQLite
5 5 * (3,75 M unités) et le moteur d'estimation. Les sorties sont des JSON
6 6 * compacts : Ka lit vite, coûte peu, et cite des chiffres exacts.
@@ -15,6 +15,13 @@ import {
15 15 type UnitEstimate,
16 16 } from "../estimator";
17 17 import stats from "../../data/stats.json";
18 +import { prefillFromProperty, runEstimate, getEstimate, sanitizeInput, defaultInput } from "../cost/estimate";
19 +import { buildContext, searchItems, getItem, priceHistory, allLabourRates, labourHistory, loadLocations, today as costToday } from "../cost/catalog";
20 +import { assemblyUnitCost } from "../cost/engine";
21 +import { deriveQuantities } from "../cost/geometry";
22 +import { getCostDb } from "../cost/db";
23 +import { CONDITIONS, TRADES, categoryLabel, tradeLabel } from "../cost/taxonomy";
24 +import type { CostEstimate, CostInput, PriceKind, Quality } from "../cost/types";
18 25
19 26 /* ---------------------------------------------------------------- helpers */
20 27
@@ -160,6 +167,50 @@ function evalCard(e: UnitEstimate) {
160 167 };
161 168 }
162 169
170 +/* ------------------------------------------------- méthode du coût — aides */
171 +
172 +function natureLabel(k: PriceKind): string {
173 + switch (k) {
174 + case "observed": return "● observé";
175 + case "official": return "● officiel";
176 + case "indexed": return "○ indexé (StatCan)";
177 + case "derived": return "◐ calculé";
178 + case "reference": return "◇ référence interne (hypothèse)";
179 + default: return "◇ hypothèse";
180 + }
181 +}
182 +
183 +function coutCard(est: CostEstimate) {
184 + const b = est.input.building;
185 + const d = est.depreciation;
186 + const c = est.confidence;
187 + const e = encodeURIComponent(est.id);
188 + return {
189 + estimation_id: est.id,
190 + date_prix: est.priceDate,
191 + batiment: { type: b.type, qualite: b.quality, aire_pi2: b.grossFloorAreaSqft, etages: b.stories, annee: b.yearBuilt, sous_sol: b.basement, garage: b.garage.type, revetement: b.siding, toiture: b.roof, chauffage: b.heating, salles_de_bain: b.bathrooms, logements: b.units },
192 + localisation: { code: est.location.code, region: est.location.nameFr, facteur_materiaux: est.location.materialFactor, facteur_main_doeuvre: est.location.labourFactor, facteur_equipement: est.location.equipmentFactor },
193 + cout_remplacement_neuf: est.replacementCostNew,
194 + fourchette_rcn: { p10: est.range.p10, p90: est.range.p90 },
195 + rcn_par_pi2: est.perSqft,
196 + rcn_par_m2: est.perM2,
197 + decomposition: { couts_directs: est.directCost, materiaux: est.directMaterial, main_doeuvre: est.directLabour, equipement: est.directEquipment, couts_indirects: est.indirectCost, frais_generaux: est.contractorOverhead, profit: est.contractorProfit, contingence: est.contingency },
198 + hypotheses: { indirects_pct_total: r(est.indirect.reduce((s, i) => s + i.pct, 0), 1), frais_generaux_pct: est.input.params.overheadPct, profit_pct: est.input.params.profitPct, contingence_pct: est.input.params.contingencyPct, vie_economique: d.economicLife, methode_depreciation: d.method },
199 + categories: est.categories.slice().sort((a, b2) => b2.adjusted - a.adjusted).slice(0, 6).map((k) => ({ categorie: k.labelFr, materiaux: k.material, main_doeuvre: k.labour, equipement: k.equipment, total: k.adjusted, part_pct: k.sharePct })),
200 + assemblages_principaux: est.lines.slice().sort((a, b2) => b2.adjusted - a.adjusted).slice(0, 6).map((l) => ({ code: l.assemblyCode, nom: l.nameFr, quantite: l.quantity, unite: l.unit, cout: l.adjusted })),
201 + depreciation: { methode: d.method, age_chronologique: d.chronologicalAge, age_effectif: d.effectiveAge, vie_economique: d.economicLife, physique_pct: d.physicalPct, physique: d.physical, fonctionnelle: d.functional, externe: d.external, total: d.total },
202 + valeur_depreciee_batiment: d.depreciatedImprovementValue,
203 + terrain: { valeur: est.landValue, source: est.input.land.source, methode: est.input.land.method || null },
204 + indication_par_le_cout: est.costApproachValue,
205 + confiance: { total: c.total, niveau: c.letter, fraicheur_20: c.freshness, couverture_20: c.coverage, localisation_15: c.location, main_doeuvre_15: c.labour, benchmarks_10: c.benchmarks, batiment_20: c.building, notes: c.notesFr },
206 + part_materiaux_prix_observes_pct: r(est.coverage.materialObservedShare * 100),
207 + benchmarks: est.benchmarks.length ? est.benchmarks.map((k) => ({ source: k.source, marche: k.market, plage: `${k.low}-${k.high} ${k.unit}`, vrai_prix: k.estimatePerUnit, etat: k.status })) : "donnée non disponible (aucun benchmark importé)",
208 + autres_lectures: est.otherReadings,
209 + avertissements: est.warnings,
210 + liens: { page_cout: `https://www.vrai-prix.com/cout?estimate=${e}`, rapport_pdf: `https://www.vrai-prix.com/api/cost/report?estimate=${e}` },
211 + };
212 +}
213 +
163 214 /* ------------------------------------------------------------ tool defs */
164 215
165 216 export const KA_TOOLS: Anthropic.Tool[] = [
@@ -321,6 +372,124 @@ export const KA_TOOLS: Anthropic.Tool[] = [
321 372 required: ["id"],
322 373 },
323 374 },
375 + /* ------------------------------------------------ méthode du coût */
376 + {
377 + name: "cout_estimer_propriete",
378 + description:
379 + "MÉTHODE DU COÛT pour une propriété du rôle, par son id (chercher_propriete d'abord) : coût de remplacement à neuf (RCN) décomposé par catégorie (matériaux / main-d'œuvre / équipement), coûts indirects, frais généraux, profit, contingence, fourchette P10-P90, $/pi², dépréciation (physique âge-vie ou par composante, fonctionnelle, externe), valeur du terrain au rôle, INDICATION DE VALEUR PAR LE COÛT, indice de confiance A-D décomposé, part des matériaux à prix observés vs prix de référence (hypothèses), autres lectures (hédonique, comparables, hybride, rôle) et liens (page /cout + PDF). Les caractéristiques absentes du rôle (revêtement, toiture, chauffage, sous-sol, qualité…) sont des HYPOTHÈSES par défaut — dis-le. Utilise-le pour « combien coûterait cette maison à reconstruire ? ».",
380 + input_schema: {
381 + type: "object",
382 + properties: {
383 + id: { type: "string", description: "Identifiant provincial de l'unité" },
384 + qualite: { type: "string", enum: ["economy", "standard", "superior", "prestige"], description: "Qualité de construction (défaut standard)" },
385 + sous_sol: { type: "string", enum: ["none", "crawl", "unfinished", "partial", "finished", "walkout"] },
386 + garage: { type: "string", enum: ["none", "attached", "detached", "integrated"] },
387 + revetement: { type: "string", enum: ["vinyl", "brick", "fiber_cement", "wood", "stone", "steel"], description: "Revêtement principal" },
388 + toiture: { type: "string", enum: ["asphalt_shingle", "metal", "membrane"] },
389 + chauffage: { type: "string", enum: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "hydronic"] },
390 + salles_de_bain: { type: "number" },
391 + methode_depreciation: { type: "string", enum: ["age_life", "components"] },
392 + age_effectif: { type: "number", description: "Âge effectif en années (défaut : âge chronologique)" },
393 + },
394 + required: ["id"],
395 + },
396 + },
397 + {
398 + name: "cout_estimer_construction",
399 + description:
400 + "MÉTHODE DU COÛT pour un bâtiment HYPOTHÉTIQUE (sans adresse) : municipalité + type + aire en pi² (défauts : maison détachée standard 1 800 pi², 2 étages, sous-sol non fini, vinyle, bardeau, plinthes électriques). Retourne RCN décomposé, fourchette, $/pi², hypothèses, confiance, liens. Pas de terrain sauf si fourni. Pour « combien coûte construire une maison de 2 000 pi² à Sherbrooke ? ».",
401 + input_schema: {
402 + type: "object",
403 + properties: {
404 + municipalite: { type: "string" },
405 + type: { type: "string", enum: ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile"] },
406 + qualite: { type: "string", enum: ["economy", "standard", "superior", "prestige"] },
407 + aire_pi2: { type: "number", description: "Aire d'étages hors sous-sol (pi²)" },
408 + etages: { type: "number" },
409 + annee_construction: { type: "number" },
410 + sous_sol: { type: "string", enum: ["none", "crawl", "unfinished", "partial", "finished", "walkout"] },
411 + garage: { type: "string", enum: ["none", "attached", "detached", "integrated"] },
412 + places_garage: { type: "number" },
413 + revetement: { type: "string", enum: ["vinyl", "brick", "fiber_cement", "wood", "stone", "steel"] },
414 + toiture: { type: "string", enum: ["asphalt_shingle", "metal", "membrane"] },
415 + chauffage: { type: "string", enum: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "hydronic"] },
416 + salles_de_bain: { type: "number" },
417 + salles_d_eau: { type: "number" },
418 + logements: { type: "number" },
419 + valeur_terrain: { type: "number", description: "Valeur du terrain à ajouter ($), si connue" },
420 + },
421 + required: [],
422 + },
423 + },
424 + {
425 + name: "cout_detail_assemblage",
426 + description:
427 + "Détail d'un assemblage de construction (ex. STR-WALL-2X6-EXT, ROOF-ASPHALT-ARCH, KIT-STANDARD, BATH-STANDARD, FND-WALL-POURED-8IN) : composition (articles, quantités par unité, pertes), prix unitaire de chaque article avec sa nature (observé / indexé / référence-hypothèse), source et date, heures de main-d'œuvre par métier et taux employeur, coût d'équipement, coût direct par unité (matériaux + main-d'œuvre + équipement), vie économique. Pour « pourquoi la toiture coûte-t-elle X ? » (prendre le code dans le résultat de cout_estimer_*).",
428 + input_schema: {
429 + type: "object",
430 + properties: {
431 + code: { type: "string", description: "Code d'assemblage" },
432 + localisation: { type: "string", description: "Code de localisation (défaut QC-MTL), ex. QC-GAT, QC-QUE" },
433 + },
434 + required: ["code"],
435 + },
436 + },
437 + {
438 + name: "cout_chercher_articles",
439 + description:
440 + "Recherche d'articles (coûts unitaires de matériaux) dans la base de coûts : ex. « gypse », « 2x6 », « bardeau », « fenêtre ». Retourne prix canonique courant, unité, fourchette min-max des sources, nombre de sources, date de la dernière observation et NATURE du prix (● observé chez un détaillant, ○ indexé StatCan, ◇ prix de référence interne = hypothèse). Sert aussi à trouver un code d'article pour cout_historique_materiau.",
441 + input_schema: {
442 + type: "object",
443 + properties: { requete: { type: "string" }, limite: { type: "number", description: "Max résultats (défaut 8, max 20)" } },
444 + required: ["requete"],
445 + },
446 + },
447 + {
448 + name: "cout_taux_main_doeuvre",
449 + description:
450 + "Taux de main-d'œuvre de la construction résidentielle au Québec (grilles APCHQ fondées sur les conventions collectives CCQ) : coût horaire EMPLOYEUR complet par métier (salaire de base + indemnité de congés 13 % + avantages sociaux + cotisations AE/RQAP/FSS/CCQ/CNESST…), secteur, date d'entrée en vigueur, source et historique. Sans métier : tous les métiers. Rappelle que le taux employeur n'est pas le salaire horaire.",
451 + input_schema: {
452 + type: "object",
453 + properties: { metier: { type: "string", description: "Code de métier (charpentier, electricien, plombier, briqueteur, couvreur, peintre, carreleur…) — optionnel" } },
454 + required: [],
455 + },
456 + },
457 + {
458 + name: "cout_historique_materiau",
459 + description:
460 + "Série temporelle des prix d'un article (code obtenu via cout_chercher_articles) : chaque observation datée avec sa source (détaillant), son unité d'origine, le facteur de conversion vers l'unité canonique, prix régulier ou promotion, aberrations marquées. Pour « le prix du bois a-t-il monté ? ».",
461 + input_schema: {
462 + type: "object",
463 + properties: { code: { type: "string" } },
464 + required: ["code"],
465 + },
466 + },
467 + {
468 + name: "cout_expliquer_depreciation",
469 + description:
470 + "Explique la dépréciation d'une estimation par la méthode du coût (id d'estimation renvoyé par cout_estimer_*) : âge chronologique vs âge effectif vs vie économique, méthode âge-vie ou par composante (tableau : RCN, vie, condition, âge effectif, % et $ par composante), désuétude fonctionnelle (curable / incurable) et externe, règles condition → âge effectif, et définitions (détérioration physique, désuétude fonctionnelle, désuétude externe, coût de reproduction vs coût de remplacement).",
471 + input_schema: {
472 + type: "object",
473 + properties: { estimation_id: { type: "string", description: "Identifiant (uuid) de l'estimation" } },
474 + required: ["estimation_id"],
475 + },
476 + },
477 + {
478 + name: "cout_comparer_localisations",
479 + description:
480 + "Compare les régions du Québec pour la méthode du coût : facteurs distincts matériaux / main-d'œuvre / équipement (la main-d'œuvre est uniforme au Québec — conventions CCQ — sauf régions éloignées) et coût direct d'une maison type de 1 800 pi² par région. Sans codes : toutes les régions.",
481 + input_schema: {
482 + type: "object",
483 + properties: { codes: { type: "array", items: { type: "string" }, description: "Codes de localisation (QC-MTL, QC-QUE, QC-GAT, QC-SHE, QC-SAG, QC-ABT, QC-CDN, QC-GIM, QC-NDQ…)" } },
484 + required: [],
485 + },
486 + },
487 + {
488 + name: "cout_sources",
489 + description:
490 + "Registre des sources de la base de coûts : nom, type (main-d'œuvre, indice, détaillant, benchmark, référence), statut de licence, active ou non, dernière synchronisation, nombre d'observations. Pour « d'où viennent les prix ? ».",
491 + input_schema: { type: "object", properties: {} },
492 + },
324 493 ];
325 494
326 495 /* ------------------------------------------------------------ execution */
@@ -609,6 +778,187 @@ export function runKaTool(name: string, input: J): unknown {
609 778 };
610 779 }
611 780
781 + /* ------------------------------------------------ méthode du coût */
782 + case "cout_estimer_propriete": {
783 + const id = resolveId(input.id);
784 + const p = prefillFromProperty(id);
785 + if (!p) return { erreur: ID_ERR };
786 + const b = p.input.building;
787 + const src = p.input.attributeSources;
788 + if (input.qualite) { b.quality = String(input.qualite) as Quality; b.kitchenQuality = b.quality; b.bathroomQuality = b.quality; src.quality = "user"; src.kitchenQuality = "user"; }
789 + if (input.sous_sol) { b.basement = String(input.sous_sol) as CostInput["building"]["basement"]; b.basementFinishedPct = b.basement === "finished" || b.basement === "walkout" ? 1 : b.basement === "partial" ? 0.5 : 0; src.basement = "user"; }
790 + if (input.garage) { b.garage = { type: String(input.garage) as CostInput["building"]["garage"]["type"], spaces: input.garage === "none" ? 0 : 1, areaSqft: null }; src.garage = "user"; }
791 + if (input.revetement) { b.siding = { [String(input.revetement)]: 1 }; src.siding = "user"; }
792 + if (input.toiture) { b.roof = String(input.toiture) as CostInput["building"]["roof"]; src.roof = "user"; }
793 + if (input.chauffage) { b.heating = String(input.chauffage) as CostInput["building"]["heating"]; src.heating = "user"; }
794 + if (input.salles_de_bain != null) { b.bathrooms = Number(input.salles_de_bain); src.bathrooms = "user"; }
795 + if (input.methode_depreciation) p.input.depreciation.method = String(input.methode_depreciation) as "age_life" | "components";
796 + if (input.age_effectif != null) { p.input.depreciation.effectiveAge = Number(input.age_effectif); p.input.depreciation.effectiveAgeSource = "user"; }
797 + const est = runEstimate(sanitizeInput(p.input), { save: true });
798 + return {
799 + ...coutCard(est),
800 + caracteristiques_supposees: p.missing.filter((m) => !["landValue"].includes(m)),
801 + note: "Les caractéristiques « supposées » sont des hypothèses par défaut (le rôle MAMH ne les publie pas) : l'utilisateur peut les corriger dans la page /cout. Coût de REMPLACEMENT (composantes modernes équivalentes), pas de reproduction.",
802 + };
803 + }
804 +
805 + case "cout_estimer_construction": {
806 + const inp = defaultInput();
807 + const b = inp.building;
808 + const src = inp.attributeSources;
809 + inp.municipality = input.municipalite ? String(input.municipalite) : null;
810 + if (input.type) { b.type = String(input.type) as CostInput["building"]["type"]; src.type = "user"; }
811 + if (input.qualite) { b.quality = String(input.qualite) as Quality; b.kitchenQuality = b.quality; b.bathroomQuality = b.quality; src.quality = "user"; src.kitchenQuality = "user"; }
812 + if (input.aire_pi2) { b.grossFloorAreaSqft = Number(input.aire_pi2); src.grossFloorAreaSqft = "user"; }
813 + if (input.etages) { b.stories = Number(input.etages); src.stories = "user"; }
814 + if (input.annee_construction) { b.yearBuilt = Number(input.annee_construction); src.yearBuilt = "user"; }
815 + if (input.sous_sol) { b.basement = String(input.sous_sol) as CostInput["building"]["basement"]; b.basementFinishedPct = b.basement === "finished" || b.basement === "walkout" ? 1 : b.basement === "partial" ? 0.5 : 0; src.basement = "user"; }
816 + if (input.garage) { b.garage = { type: String(input.garage) as CostInput["building"]["garage"]["type"], spaces: input.garage === "none" ? 0 : Number(input.places_garage) || 1, areaSqft: null }; src.garage = "user"; }
817 + if (input.revetement) { b.siding = { [String(input.revetement)]: 1 }; src.siding = "user"; }
818 + if (input.toiture) { b.roof = String(input.toiture) as CostInput["building"]["roof"]; src.roof = "user"; }
819 + if (input.chauffage) { b.heating = String(input.chauffage) as CostInput["building"]["heating"]; src.heating = "user"; }
820 + if (input.salles_de_bain != null) { b.bathrooms = Number(input.salles_de_bain); src.bathrooms = "user"; }
821 + if (input.salles_d_eau != null) b.powderRooms = Number(input.salles_d_eau);
822 + if (input.logements) { b.units = Number(input.logements); b.kitchens = b.units; }
823 + if (input.valeur_terrain) inp.land = { value: Number(input.valeur_terrain), source: "user", rollYear: null, method: "valeur fournie par l'utilisateur" };
824 + const est = runEstimate(sanitizeInput(inp), { save: true });
825 + return { ...coutCard(est), note: "Bâtiment hypothétique : tout paramètre non fourni est une hypothèse par défaut. Sans valeur de terrain, l'indication par le coût = valeur dépréciée du bâtiment seulement." };
826 + }
827 +
828 + case "cout_detail_assemblage": {
829 + const code = String(input.code ?? "").toUpperCase().trim();
830 + const ctx = buildContext({ locationCode: input.localisation ? String(input.localisation) : "QC-MTL" });
831 + const a = ctx.assemblies.get(code);
832 + if (!a) {
833 + const near = [...ctx.assemblies.keys()].filter((k) => k.includes(code.split("-")[0] ?? "")).slice(0, 12);
834 + return { erreur: `Assemblage « ${code} » introuvable.`, codes_proches: near };
835 + }
836 + const u = assemblyUnitCost(a, ctx);
837 + const f = ctx.location;
838 + return {
839 + code: a.code, nom: a.nameFr, categorie: categoryLabel(a.category, true), unite: a.unit, description: a.descriptionFr, vie_economique_ans: a.economicLife, groupe_condition: a.conditionGroup,
840 + localisation: { code: f.code, nom: f.nameFr, facteur_materiaux: f.materialFactor, facteur_main_doeuvre: f.labourFactor, facteur_equipement: f.equipmentFactor },
841 + cout_unitaire: { materiaux: u.material, main_doeuvre: u.labour, equipement: u.equipment, direct: u.direct, direct_localise: r(u.material * f.materialFactor + u.labour * f.labourFactor + u.equipment * f.equipmentFactor, 2), incertitude_pct: r(u.sigmaPct * 100, 1), part_materiaux_observes_pct: r(u.observedShare * 100) },
842 + composants: u.components.map((c) => ({
843 + article: c.itemCode || null, nom: c.nameFr, quantite_par_unite: c.itemCode ? r(c.quantity, 4) : undefined, pertes_pct: c.itemCode ? r(c.wasteFactor * 100) : undefined, unite: c.itemCode ? c.unit : undefined,
844 + prix_unitaire: c.itemCode ? r(c.unitPrice, 2) : undefined, cout_materiau: c.itemCode ? r(c.materialCost, 2) : undefined,
845 + nature_prix: c.itemCode ? natureLabel(c.provenance.kind) : undefined, source: c.itemCode ? c.provenance.source : undefined, date: c.itemCode ? c.provenance.observedAt ?? null : undefined, note: c.provenance.note ?? undefined,
846 + heures: c.labourHours || undefined, metier: c.trade ? tradeLabel(c.trade, true) : undefined, taux_employeur: c.hourlyRate ?? undefined, cout_main_doeuvre: c.labourCost || undefined, nature_taux: c.rateProvenance ? natureLabel(c.rateProvenance.kind) + " — " + c.rateProvenance.source : undefined,
847 + equipement: c.equipmentCost || undefined,
848 + })),
849 + legende: "● observé/officiel · ◐ calculé · ○ indexé (StatCan) · ◇ référence interne = hypothèse, pas une observation",
850 + };
851 + }
852 +
853 + case "cout_chercher_articles": {
854 + const q = String(input.requete ?? "");
855 + const limit = Math.min(Number(input.limite) || 8, 20);
856 + const items = searchItems(q, limit);
857 + if (!items.length) return { resultats: [], note: "Aucun article — essayer un mot plus court (gypse, isolant, bardeau, fenêtre, béton…)." };
858 + const ctx = buildContext({ locationCode: "QC-MTL" });
859 + return {
860 + date_prix: ctx.asOf,
861 + resultats: items.map((i) => {
862 + const p = ctx.prices.get(i.code);
863 + return { code: i.code, nom: i.nameFr, unite: i.unit, categorie: categoryLabel(i.category, true), prix: p ? r(p.price, 2) : null, min: p ? r(p.low, 2) : null, max: p ? r(p.high, 2) : null, nature_prix: p ? natureLabel(p.kind) : "donnée non disponible", nb_sources: p?.sourceCount ?? 0, derniere_observation: p?.observedAt ?? null, sources: p?.sources.map((s) => `${s.source} ${r(s.price, 2)} $ (${s.observedAt})`).slice(0, 4), note: p?.note ?? undefined };
864 + }),
865 + legende: "● observé chez un détaillant · ○ indexé par StatCan 18-10-0289 · ◇ prix de référence interne = hypothèse documentée, non observée",
866 + };
867 + }
868 +
869 + case "cout_taux_main_doeuvre": {
870 + const asOf = costToday();
871 + if (input.metier) {
872 + const code = String(input.metier).toLowerCase();
873 + const t = TRADES.find((x) => x.code === code || x.fr.toLowerCase().includes(code));
874 + if (!t) return { erreur: `Métier inconnu — codes : ${TRADES.map((x) => x.code).join(", ")}` };
875 + const hist = labourHistory(t.code);
876 + if (!hist.length) return { metier: t.fr, erreur: "Donnée non disponible : aucune grille synchronisée pour ce métier (le taux de repli 85 $/h — hypothèse — est alors utilisé par le moteur)." };
877 + return {
878 + metier: t.fr, code: t.code,
879 + grilles: hist.map((h) => ({ secteur: h.sector, classification: h.classification, en_vigueur: h.effectiveFrom, salaire_base: h.baseWage, indemnite_vacances: h.vacationCost, avantages_sociaux: h.benefitsCost, cotisations_employeur: h.employerContributions, autres: h.otherContributions, cout_horaire_employeur_total: h.totalEmployerCost, source: h.source, url: h.sourceUrl })),
880 + note: "Le coût employeur complet = salaire conventionné CCQ + congés (13 %) + avantages sociaux + AE, RQAP, RRQ, FSS, CNESST, prélèvement CCQ, AECQ, fonds de formation. C'est ce coût — pas le salaire nu — que le moteur multiplie par les heures.",
881 + };
882 + }
883 + const rates = allLabourRates(asOf).filter((x) => x.classification === "compagnon");
884 + if (!rates.length) return { erreur: "Donnée non disponible : aucune grille de main-d'œuvre synchronisée (le moteur utilise un taux de repli de 85 $/h, hypothèse)." };
885 + return {
886 + date: asOf,
887 + taux: rates.map((h) => ({ metier: h.tradeNameFr, code: h.tradeCode, secteur: h.sector, en_vigueur: h.effectiveFrom, salaire_base: h.baseWage, cout_horaire_employeur_total: h.totalEmployerCost, source: h.source })),
888 + note: "Taux uniformes pour tout le Québec (conventions collectives, Loi R-20) ; secteur résidentiel léger = maisons ≤ 6 logements.",
889 + };
890 + }
891 +
892 + case "cout_historique_materiau": {
893 + const code = String(input.code ?? "").toUpperCase().trim();
894 + const item = getItem(code);
895 + if (!item) return { erreur: `Article « ${code} » introuvable — utiliser cout_chercher_articles.` };
896 + const hist = priceHistory(code);
897 + const obs = hist.filter((h) => h.kind !== "reference");
898 + return {
899 + article: { code: item.code, nom: item.nameFr, unite: item.unit, prix_reference_interne_hypothese: item.referencePrice, note_reference: item.referenceNote },
900 + nb_observations: obs.length,
901 + historique: obs.slice(-40).map((h) => ({ date: h.date, prix: r(h.price, 2), source: h.source, unite_source: h.sourceUnit, facteur_conversion: r(h.conversionFactor, 5), regulier: h.regular, nature: h.kind, aberrant: h.outlier || undefined, url: h.url })),
902 + variation_pct: obs.length >= 2 && obs[0].price > 0 ? r((obs[obs.length - 1].price / obs[0].price - 1) * 100, 1) : null,
903 + note: obs.length ? undefined : "Donnée non disponible : aucune observation détaillant pour cet article — seul le prix de référence interne (hypothèse) existe.",
904 + };
905 + }
906 +
907 + case "cout_expliquer_depreciation": {
908 + const id = String(input.estimation_id ?? "");
909 + const est = getEstimate(id);
910 + if (!est) return { erreur: "Estimation introuvable — relancer cout_estimer_propriete / cout_estimer_construction et reprendre l'id retourné." };
911 + const d = est.depreciation;
912 + return {
913 + estimation_id: est.id, rcn: est.replacementCostNew,
914 + ages: { chronologique: d.chronologicalAge, effectif_retenu: d.effectiveAge, vie_economique: d.economicLife, source_age_effectif: est.input.depreciation.effectiveAgeSource },
915 + methode: d.method === "components" ? "composante par composante" : "âge-vie simple (âge effectif ÷ vie économique, plafonné à 90 %)",
916 + physique: { pct: d.physicalPct, montant: d.physical },
917 + composantes: d.components.map((c) => ({ composante: c.labelFr, rcn: c.rcn, vie: c.economicLife, condition: c.condition, age_effectif: c.effectiveAge, depreciation_pct: c.depreciationPct, depreciation: c.depreciation, restant: c.remaining, regle: c.rule })),
918 + fonctionnelle: { curable: d.functionalCurable, incurable: d.functionalIncurable, total: d.functional, deficiences: est.input.depreciation.functional.map((f) => ({ type: f.type, curable: f.curable, cout_correction: f.costToCure, perte_valeur: f.valueLoss })) },
919 + externe: { montant: d.external, note: est.input.depreciation.externalNote || "aucune preuve saisie → 0 $" },
920 + total: d.total, valeur_depreciee_batiment: d.depreciatedImprovementValue, terrain: est.landValue, indication_par_le_cout: est.costApproachValue,
921 + regles_condition_age_effectif: CONDITIONS.map((c) => `${c.fr} → ${c.effectiveAgeRatio} × vie`).join(" · "),
922 + definitions: {
923 + deterioration_physique: "Usure et vieillissement des composantes (toiture, fenêtres, finis…) ; mesurée par âge-vie (âge effectif ÷ vie économique) ou composante par composante.",
924 + desuetude_fonctionnelle: "Perte de valeur due à la conception ou aux caractéristiques du bâtiment lui-même (trop peu de salles de bain, configuration dépassée, suramélioration) ; curable si le coût de correction < gain de valeur, sinon incurable.",
925 + desuetude_externe: "Perte de valeur due à des facteurs hors de la propriété (nuisance, marché structurellement faible) ; jamais calculée automatiquement sans preuve.",
926 + cout_reproduction_vs_remplacement: "Reproduction = reconstruire à l'identique (mêmes matériaux et méthodes) ; remplacement = bâtiment d'utilité équivalente avec les matériaux et normes actuels. Vrai-Prix calcule le coût de REMPLACEMENT.",
927 + },
928 + };
929 + }
930 +
931 + case "cout_comparer_localisations": {
932 + const codes = (input.codes as string[] | undefined)?.map((c) => String(c).toUpperCase());
933 + const locs = loadLocations().filter((l) => !codes?.length || codes.includes(l.code));
934 + if (!locs.length) return { erreur: "Aucune localisation — codes : " + loadLocations().map((l) => l.code).join(", ") };
935 + const inp = defaultInput();
936 + const { lines } = deriveQuantities(inp.building);
937 + const ctxBase = buildContext({ locationCode: "QC-MTL" });
938 + let baseDirect = 0;
939 + const perLine = lines.map((q) => { const a = ctxBase.assemblies.get(q.assemblyCode); if (!a) return null; const u = assemblyUnitCost(a, ctxBase); baseDirect += u.direct * q.quantity; return { q: q.quantity, u }; }).filter((x): x is { q: number; u: ReturnType<typeof assemblyUnitCost> } => !!x);
940 + return {
941 + maison_type: "détachée standard 1 800 pi², 2 étages, sous-sol non fini, vinyle, bardeau, plinthes électriques (prix au " + ctxBase.asOf + ")",
942 + regions: locs.map((l) => {
943 + const direct = perLine.reduce((s, x) => s + x.q * (x.u.material * l.materialFactor + x.u.labour * l.labourFactor + x.u.equipment * l.equipmentFactor), 0);
944 + return { code: l.code, region: l.nameFr, facteur_materiaux: l.materialFactor, facteur_main_doeuvre: l.labourFactor, facteur_equipement: l.equipmentFactor, facteur_global: l.overallFactor, confiance: l.confidence, cout_direct_maison_type: r(direct), ecart_vs_montreal_pct: baseDirect ? r((direct / baseDirect - 1) * 100, 1) : null };
945 + }),
946 + note: "Main-d'œuvre uniforme au Québec (conventions collectives CCQ) sauf régions éloignées ; facteurs matériaux/équipement = surcoût de transport (hypothèse Vrai-Prix, modifiable).",
947 + };
948 + }
949 +
950 + case "cout_sources": {
951 + const rows = getCostDb().prepare(`SELECT s.name, s.source_type type, s.license_status licence, s.is_active active, s.last_successful_sync sync, s.base_url url,
952 + (SELECT COUNT(*) FROM cost_item_prices p WHERE p.source_id=s.id) + (SELECT COUNT(*) FROM labour_rates l WHERE l.source_id=s.id) obs
953 + FROM cost_sources s ORDER BY s.priority, s.name`).all() as { name: string; type: string; licence: string; active: number; sync: string | null; url: string | null; obs: number }[];
954 + const idx = getCostDb().prepare("SELECT COUNT(DISTINCT index_code) n, MAX(period) last FROM construction_cost_indices").get() as { n: number; last: string | null };
955 + return {
956 + sources: rows.map((x) => ({ source: x.name, type: x.type, licence: x.licence, active: x.active === 1, derniere_synchro: x.sync ?? "jamais", observations: x.obs, url: x.url })),
957 + indices_statcan: { series: idx.n, derniere_periode: idx.last },
958 + note: "Aucun prix n'est inventé : observé (détaillants), officiel (APCHQ/CCQ, StatCan) ou prix de référence interne explicitement étiqueté « hypothèse ». Firecrawl ne tourne qu'en arrière-plan ; l'interface lit la base locale.",
959 + };
960 + }
961 +
612 962 default:
613 963 return { erreur: `Outil inconnu : ${name}` };
614 964 }
added src/lib/listing-types.ts +80 −0
@@ -0,0 +1,80 @@
1 +// Vrai-Prix — groupes de types d'annonces Immo-Ka (module PUR : importable côté client).
2 +export type TypeGroupKey =
3 + | "maison"
4 + | "condo"
5 + | "plex"
6 + | "terrain"
7 + | "chalet"
8 + | "fermette"
9 + | "mobile"
10 + | "commercial"
11 + | "autre";
12 +
13 +export const TYPE_GROUPS: { key: TypeGroupKey; fr: string; en: string }[] = [
14 + { key: "maison", fr: "Maison", en: "House" },
15 + { key: "condo", fr: "Condo", en: "Condo" },
16 + { key: "plex", fr: "Plex", en: "Plex" },
17 + { key: "terrain", fr: "Terrain", en: "Land" },
18 + { key: "chalet", fr: "Chalet", en: "Cottage" },
19 + { key: "fermette", fr: "Fermette / agricole", en: "Hobby farm" },
20 + { key: "mobile", fr: "Maison mobile", en: "Mobile home" },
21 + { key: "commercial", fr: "Commercial", en: "Commercial" },
22 + { key: "autre", fr: "Autre", en: "Other" },
23 +];
24 +
25 +const fold = (s: string | null | undefined) =>
26 + (s ?? "")
27 + .normalize("NFD")
28 + .replace(/[\u0300-\u036f]/g, "")
29 + .toLowerCase()
30 + .trim();
31 +
32 +/** Type Immo-Ka (libre, sale) → groupe stable. Même logique que typeBucket du pipeline. */
33 +export function typeGroup(pt: string | null): TypeGroupKey {
34 + const t = fold(pt);
35 + if (!t) return "autre";
36 + if (t.includes("condo")) return "condo";
37 + if (/(du|tri|quadru|multi)plex/.test(t) || t.includes("plex")) return "plex";
38 + if (t.includes("terrain") || t === "lot") return "terrain";
39 + if (t.includes("commerc") || t.includes("industri") || t.includes("bureau") || t.includes("service"))
40 + return "commercial";
41 + if (t.includes("chalet") || t.includes("recreativ")) return "chalet";
42 + if (t.includes("ferme") || t.includes("agric")) return "fermette";
43 + if (t.includes("mobile") || t.includes("roulotte")) return "mobile";
44 + if (
45 + t.includes("maison") ||
46 + t.includes("jumel") ||
47 + t.includes("detach") ||
48 + t.includes("residen") ||
49 + t === "propriete" ||
50 + t.includes("bungalow") ||
51 + t.includes("cottage")
52 + )
53 + return "maison";
54 + return "autre";
55 +}
56 +
57 +/** groupe → type_prop du rôle (moteur) pour un sujet sans unité jumelée. */
58 +export function groupToTypeProp(g: TypeGroupKey): string {
59 + switch (g) {
60 + case "condo":
61 + return "condo_ou_multi";
62 + case "plex":
63 + return "plex";
64 + case "terrain":
65 + return "terrain";
66 + case "chalet":
67 + return "chalet";
68 + case "mobile":
69 + return "maison_mobile";
70 + case "commercial":
71 + return "autre";
72 + default:
73 + return "unifamilial";
74 + }
75 +}
76 +
77 +export function groupLabel(key: string, fr: boolean): string {
78 + const g = TYPE_GROUPS.find((x) => x.key === key);
79 + return g ? (fr ? g.fr : g.en) : key;
80 +}
added src/lib/marche-stats.ts +39 −0
@@ -0,0 +1,39 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Statistiques « la mesure à l'épreuve du marché » (/stats, /stats/marche).
4 + *
5 + * Deux sources, dans l'ordre :
6 + * 1. data/marche-stats.json — produit chaque nuit par scripts/immoka-sync.sh
7 + * (ré-évaluation de toutes les annonces Immo-Ka vivantes, MARCHE_STATS_OUT) ;
8 + * lu à chaud, jamais versionné, mis en cache 10 min en mémoire ;
9 + * 2. src/data/marche-stats.json — instantané versionné, embarqué au build,
10 + * utilisé si le fichier nocturne est absent ou illisible (laptop, premier
11 + * déploiement).
12 + * Serveur seulement (fs).
13 + */
14 +import fs from "fs";
15 +import path from "path";
16 +import type { MarcheStats } from "@/components/MarcheView";
17 +import bundled from "@/data/marche-stats.json";
18 +
19 +// chemin statique (data/…) : Turbopack ne trace alors pas tout le projet dans la sortie serveur
20 +const LIVE_PATH = path.join(process.cwd(), "data", "marche-stats.json");
21 +const TTL_MS = 10 * 60 * 1000;
22 +
23 +let cache: { at: number; mtimeMs: number; stats: MarcheStats } | null = null;
24 +
25 +export function loadMarcheStats(): MarcheStats {
26 + const now = Date.now();
27 + try {
28 + const st = fs.statSync(LIVE_PATH);
29 + if (cache && cache.mtimeMs === st.mtimeMs && now - cache.at < TTL_MS) return cache.stats;
30 + const parsed = JSON.parse(fs.readFileSync(LIVE_PATH, "utf8")) as MarcheStats;
31 + if (parsed?.global?.n && parsed.source) {
32 + cache = { at: now, mtimeMs: st.mtimeMs, stats: parsed };
33 + return parsed;
34 + }
35 + } catch {
36 + /* fichier nocturne absent ou illisible → instantané versionné */
37 + }
38 + return bundled as unknown as MarcheStats;
39 +}
added src/lib/report-cost.ts +618 −0
@@ -0,0 +1,618 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Rapport PDF « Méthode du coût » — vectoriel (pdfkit), même
4 + * branding que report.ts : bandeau encre, logo Vrai-Prix, cartes, filets,
5 + * format Lettre. Chaque nombre du rapport vient du moteur (CostEstimate) ;
6 + * la nature de chaque prix est marquée : ● observé/officiel · ◐ calculé ·
7 + * ○ indexé · ◇ référence interne (hypothèse).
8 + */
9 +import PDFDocument from "pdfkit";
10 +import path from "path";
11 +import type { CostEstimate, PriceKind } from "./cost/types";
12 +import { INDIRECT_LABELS, QUALITIES, BUILDING_TYPES, tradeLabel } from "./cost/taxonomy";
13 +import { UNIT_LABELS } from "./cost/units";
14 +
15 +const INK = "#141814";
16 +const INK2 = "#4d5551";
17 +const INK3 = "#8b928c";
18 +const PAPER = "#f5f3ee";
19 +const SURFACE2 = "#faf9f5";
20 +const GREEN = "#9e2a25"; // bordeaux Vrai-Prix (--green)
21 +const GREEN_DEEP = "#771f1b";
22 +const LIME = "#ff5148"; // rouge vif Vrai-Prix (--accent-bright)
23 +const LIME_SOFT = "#ffe3e0";
24 +const AMBER_SOFT = "#fdf3e2";
25 +const DANGER = "#b3423a";
26 +const DANGER_SOFT = "#fbe9e7";
27 +const LINE = "#dedcd4";
28 +
29 +const W = 612;
30 +const H = 792;
31 +const M = 44;
32 +const BOTTOM = H - 62; // limite basse du contenu (au-dessus du pied de page)
33 +
34 +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);
35 +
36 +const fmt = (v: number | null | undefined) =>
37 + v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);
38 +const fmt2 = (v: number | null | undefined) =>
39 + v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v);
40 +const num = (v: number | null | undefined, d = 0) =>
41 + v == null || !Number.isFinite(v) ? "—" : v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: 0 });
42 +const pct = (v: number | null | undefined, d = 1) => (v == null || !Number.isFinite(v) ? "—" : `${v.toLocaleString("fr-CA", { maximumFractionDigits: d })} %`);
43 +const neg = (v: number) => (v > 0 ? `−${fmt(v)}` : fmt(0));
44 +
45 +const KIND_ICON: Record<PriceKind, string> = { observed: "●", official: "●", derived: "◐", indexed: "○", reference: "◇", assumption: "◇" };
46 +const KIND_TXT: Record<PriceKind, string> = { observed: "observé", official: "officiel", derived: "calculé", indexed: "indexé", reference: "référence (hypothèse)", assumption: "hypothèse" };
47 +const SRC_TXT: Record<string, string> = { MAMH: "rôle MAMH", user: "saisi", AI: "inféré par IA", derived: "dérivé", assumed: "supposé", listing: "annonce", cadastral: "cadastre" };
48 +const CONF_BG: Record<string, string> = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT };
49 +const CONF_TXT: Record<string, string> = { A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable" };
50 +
51 +type Doc = InstanceType<typeof PDFDocument>;
52 +
53 +/* ------------------------------------------------------------- primitives */
54 +
55 +/** Mot-symbole Vrai-Prix (identique à report.ts) : « Vrai » papier + « Prix » sur pastille rouge inclinée. */
56 +function logo(doc: Doc, x: number, y: number, scale = 1) {
57 + doc.save();
58 + doc.font("SG-Bold").fontSize(24 * scale);
59 + doc.fillColor(PAPER).text("Vrai", x, y, { lineBreak: false });
60 + const w = doc.widthOfString("Vrai");
61 + const bx = x + w + 4 * scale;
62 + doc.save();
63 + doc.rotate(-3, { origin: [bx, y + 12 * scale] });
64 + const bw = doc.widthOfString("Prix") + 12 * scale;
65 + doc.roundedRect(bx, y - 3 * scale, bw, 30 * scale, 5 * scale).fill(LIME);
66 + doc.fillColor(INK).text("Prix", bx + 6 * scale, y, { lineBreak: false });
67 + doc.restore();
68 + doc.restore();
69 +}
70 +
71 +function kicker(doc: Doc, txt: string, x: number, y: number, color = GREEN) {
72 + doc.rect(x, y + 3.5, 20, 2).fill(color);
73 + doc.font("JB-Bold").fontSize(8).fillColor(color).text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false, width: W - 2 * M - 26, height: 12, ellipsis: true });
74 +}
75 +
76 +function pageBg(doc: Doc) {
77 + doc.rect(0, 0, W, H).fill(PAPER);
78 +}
79 +
80 +function pageFooter(doc: Doc, page: number, total: number, generated: string) {
81 + doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK);
82 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(
83 + "VRAI-PRIX.COM · MÉTHODE DU COÛT — ESTIMATION STATISTIQUE À TITRE INDICATIF, NI SOUMISSION NI ÉVALUATION CERTIFIÉE (OEAQ)",
84 + M, H - 39, { width: W - 2 * M - 60, characterSpacing: 0.3, lineBreak: false, height: 9, ellipsis: true }
85 + );
86 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text("SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI", M, H - 30, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false });
87 + doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" });
88 + doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(generated, W - M - 160, H - 30, { width: 160, align: "right" });
89 +}
90 +
91 +function card(doc: Doc, x: number, y: number, w: number, h: number, opts?: { shadow?: boolean; fill?: string }) {
92 + if (opts?.shadow !== false) doc.roundedRect(x + 3.5, y + 3.5, w, h, 8).fill("#e3e1d9");
93 + doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(opts?.fill ?? "#ffffff", INK);
94 +}
95 +
96 +function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) {
97 + doc.lineWidth(0.8);
98 + doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE);
99 + doc.font("JB-Bold").fontSize(5.8).fillColor(INK3).text(k.toUpperCase(), x + 7, y + 6, { width: w - 14, characterSpacing: 0.7, height: 8, ellipsis: true, lineBreak: false });
100 + doc.font("SG-Bold").fontSize(7.4).fillColor(INK).text(v, x + 7, y + 15.5, { width: w - 14, height: h - 18, ellipsis: true, lineGap: -0.5 });
101 + doc.lineWidth(1.3);
102 +}
103 +
104 +/* -------------------------------------------------------- mise en page */
105 +
106 +class Layout {
107 + y = 34;
108 + constructor(readonly doc: Doc) {}
109 + newPage() {
110 + this.doc.addPage({ size: "LETTER", margin: 0 });
111 + pageBg(this.doc);
112 + this.doc.rect(0, 0, W, 8).fill(INK);
113 + this.doc.rect(0, 8, W, 2.5).fill(LIME);
114 + this.y = 34;
115 + }
116 + /** garantit `h` points disponibles, sinon nouvelle page */
117 + ensure(h: number) {
118 + if (this.y + h > BOTTOM) this.newPage();
119 + }
120 + section(title: string) {
121 + this.ensure(60);
122 + kicker(this.doc, title, M, this.y);
123 + this.y += 18;
124 + }
125 + para(txt: string, opts: { size?: number; color?: string; font?: string; gap?: number } = {}) {
126 + const d = this.doc;
127 + d.font(opts.font ?? "Inter").fontSize(opts.size ?? 8);
128 + const h = d.heightOfString(txt, { width: W - 2 * M, lineGap: 1.4 });
129 + this.ensure(h + 4);
130 + d.fillColor(opts.color ?? INK2).text(txt, M, this.y, { width: W - 2 * M, lineGap: 1.4 });
131 + this.y += h + (opts.gap ?? 6);
132 + }
133 + bullet(txt: string) {
134 + const d = this.doc;
135 + d.font("Inter").fontSize(7.8);
136 + const h = d.heightOfString(txt, { width: W - 2 * M - 14, lineGap: 1.2 });
137 + this.ensure(h + 3);
138 + d.rect(M + 2, this.y + 3.5, 4, 4).fill(GREEN);
139 + d.fillColor(INK2).text(txt, M + 14, this.y, { width: W - 2 * M - 14, lineGap: 1.2 });
140 + this.y += h + 3;
141 + }
142 + /** tableau avec en-tête répété à chaque saut de page */
143 + table(cols: { h: string; w: number; align?: "left" | "right"; mono?: boolean }[], rows: string[][], opts: { rowH?: number; total?: string[]; highlight?: (i: number) => boolean } = {}) {
144 + const d = this.doc;
145 + const rowH = opts.rowH ?? 14;
146 + const x0 = M;
147 + const totalW = cols.reduce((s, c) => s + c.w, 0);
148 + const scale = (W - 2 * M) / totalW;
149 + const ws = cols.map((c) => c.w * scale);
150 + const header = () => {
151 + this.ensure(rowH + 4);
152 + d.rect(x0, this.y, W - 2 * M, rowH).fill(INK);
153 + let x = x0;
154 + cols.forEach((c, i) => {
155 + d.font("JB-Bold").fontSize(6.2).fillColor(PAPER).text(c.h.toUpperCase(), x + 5, this.y + 4.2, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true, characterSpacing: 0.4 });
156 + x += ws[i];
157 + });
158 + this.y += rowH;
159 + };
160 + header();
161 + rows.forEach((row, ri) => {
162 + if (this.y + rowH > BOTTOM) { this.newPage(); header(); }
163 + const hl = opts.highlight?.(ri);
164 + if (hl) d.rect(x0, this.y, W - 2 * M, rowH).fill(LIME_SOFT);
165 + else if (ri % 2 === 1) d.rect(x0, this.y, W - 2 * M, rowH).fill(SURFACE2);
166 + let x = x0;
167 + cols.forEach((c, i) => {
168 + d.font(hl ? "SG-Bold" : c.mono ? "JB-Reg" : "Inter").fontSize(c.mono ? 6.6 : 7.2).fillColor(INK)
169 + .text(row[i] ?? "", x + 5, this.y + 3.6, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true });
170 + x += ws[i];
171 + });
172 + d.moveTo(x0, this.y + rowH).lineTo(W - M, this.y + rowH).lineWidth(0.4).stroke(LINE);
173 + this.y += rowH;
174 + });
175 + if (opts.total) {
176 + if (this.y + rowH + 2 > BOTTOM) { this.newPage(); header(); }
177 + d.rect(x0, this.y, W - 2 * M, rowH + 2).fill(INK);
178 + let x = x0;
179 + cols.forEach((c, i) => {
180 + d.font("SG-Bold").fontSize(7.4).fillColor(PAPER).text(opts.total![i] ?? "", x + 5, this.y + 4.2, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true });
181 + x += ws[i];
182 + });
183 + this.y += rowH + 2;
184 + }
185 + this.y += 8;
186 + }
187 + gap(h = 8) { this.y += h; }
188 +}
189 +
190 +/* -------------------------------------------------------------- libellés */
191 +
192 +const BASEMENT: Record<string, string> = { none: "aucun (dalle)", crawl: "vide sanitaire", unfinished: "non fini", partial: "partiellement fini", finished: "fini", walkout: "fini, sortie de plain-pied" };
193 +const FOUND: Record<string, string> = { poured_concrete: "béton coulé", concrete_block: "blocs de béton", slab_on_grade: "dalle sur sol", piers: "pieux", stone: "pierre" };
194 +const ROOF: Record<string, string> = { asphalt_shingle: "bardeau d'asphalte", metal: "tôle", membrane: "membrane", cedar: "cèdre", slate_tile: "ardoise/tuile" };
195 +const GEOM: Record<string, string> = { gable: "à deux versants", hip: "à quatre versants", flat: "plat", mansard: "mansardé", complex: "complexe" };
196 +const WIN: Record<string, string> = { pvc: "PVC", hybrid: "hybride", aluminum: "aluminium", wood: "bois" };
197 +const HEAT: Record<string, string> = { electric_baseboard: "plinthes électriques", heat_pump: "thermopompe centrale", furnace_electric: "fournaise électrique", furnace_gas: "fournaise au gaz", furnace_oil: "fournaise à l'huile", hydronic: "eau chaude", geothermal: "géothermie", wood: "bois" };
198 +const GARAGE: Record<string, string> = { none: "aucun", attached: "attenant", detached: "détaché", integrated: "intégré", carport: "abri d'auto" };
199 +const SIDING: Record<string, string> = { vinyl: "vinyle", brick: "brique", fiber_cement: "fibrociment", wood: "bois", stone: "pierre", stucco: "stuc", aluminum: "aluminium", steel: "acier" };
200 +const MODE: Record<string, string> = { property: "propriété du rôle (MAMH)", construction: "construction hypothétique", listing: "annonce à vendre (analyse IA)" };
201 +const qLabel = (q: string) => QUALITIES.find((x) => x.key === q)?.fr ?? q;
202 +const unitLabel = (u: string) => UNIT_LABELS[u as keyof typeof UNIT_LABELS]?.fr ?? u;
203 +const shortUrl = (u: string | null | undefined) => {
204 + if (!u) return "";
205 + try { const x = new URL(u); return (x.host.replace(/^www\./, "") + x.pathname).slice(0, 60); } catch { return u.slice(0, 60); }
206 +};
207 +
208 +/* ============================================================ rapport */
209 +
210 +export async function buildCostReport(est: CostEstimate): Promise<Buffer> {
211 + const b = est.input.building;
212 + const generated = new Date().toISOString().slice(0, 16).replace("T", " ");
213 + const doc = new PDFDocument({ size: "LETTER", margin: 0, bufferPages: true, info: { Title: `Vrai-Prix — Méthode du coût — ${est.input.address ?? "bâtiment"}`, Author: "Vrai-Prix — Simon-Pierre Boucher (contact@spboucher.ai)" } });
214 + doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));
215 + doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));
216 + doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));
217 + doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));
218 + doc.registerFont("Inter", F("Inter-Regular.ttf"));
219 + const chunks: Buffer[] = [];
220 + doc.on("data", (c: Buffer) => chunks.push(c));
221 + const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));
222 + const L = new Layout(doc);
223 + const src = est.input.attributeSources;
224 + const prov = (k: string) => SRC_TXT[src[k] ?? ""] ?? (src[k] ?? "supposé");
225 +
226 + /* ================================ PAGE 1 — couverture ================================ */
227 + pageBg(doc);
228 + doc.rect(0, 0, W, 96).fill(INK);
229 + logo(doc, M, 30);
230 + doc.font("JB-Reg").fontSize(7).fillColor(LIME).text("ESTIMATION IMMOBILIÈRE TRANSPARENTE · RAPPORT — MÉTHODE DU COÛT", M, 66, { characterSpacing: 1.2, lineBreak: false });
231 + doc.font("JB-Reg").fontSize(6).fillColor("#9aa39c").text("COÛT DE REMPLACEMENT À NEUF · DÉPRÉCIATION · TERRAIN — TROISIÈME LECTURE DE LA VALEUR", M, 78, { characterSpacing: 1, lineBreak: false });
232 + doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")
233 + .text(`ESTIMATION ${est.id.slice(0, 8).toUpperCase()}`, W - M - 220, 34, { width: 220, align: "right" })
234 + .text(`GÉNÉRÉ LE ${generated}`, W - M - 220, 46, { width: 220, align: "right" })
235 + .text(`PRIX AU ${est.priceDate} · MÉTHODE v${est.methodVersion}`, W - M - 220, 58, { width: 220, align: "right" });
236 + doc.rect(0, 96, W, 3).fill(LIME);
237 +
238 + let y = 122;
239 + kicker(doc, "Indication de valeur par la méthode du coût", M, y);
240 + y += 16;
241 + const titre = [est.input.address, est.input.municipality].filter(Boolean).join(" · ") || `${BUILDING_TYPES.find((t) => t.key === b.type)?.fr ?? b.type} — ${num(b.grossFloorAreaSqft)} pi²`;
242 + doc.font("SG-Bold").fontSize(15).fillColor(INK).text(titre.toUpperCase(), M, y, { width: W - 2 * M });
243 + y = doc.y + 6;
244 + doc.font("SG-Bold").fontSize(46).fillColor(INK).text(fmt(est.costApproachValue), M, y);
245 + y = doc.y + 10;
246 +
247 + const cols = [
248 + ["COÛT DE REMPLACEMENT À NEUF", fmt(est.replacementCostNew)],
249 + ["DÉPRÉCIATION TOTALE", neg(est.depreciation.total)],
250 + ["VALEUR DU TERRAIN", fmt(est.landValue)],
251 + ["FOURCHETTE RCN P10-P90", `${fmt(est.range.p10)} → ${fmt(est.range.p90)}`],
252 + ] as const;
253 + let cx0 = M;
254 + for (const [k, v] of cols) {
255 + doc.font("JB-Bold").fontSize(6.3).fillColor(INK3).text(k, cx0, y, { characterSpacing: 0.6, lineBreak: false });
256 + const lw = doc.widthOfString(k) + 6;
257 + doc.font("SG-Bold").fontSize(12).fillColor(INK).text(v, cx0, y + 11, { lineBreak: false });
258 + cx0 += Math.max(doc.widthOfString(v), lw) + 26;
259 + }
260 + const c = est.confidence;
261 + const confTxt = `CONFIANCE ${c.letter} · ${c.total} / 100 — ${CONF_TXT[c.letter].toUpperCase()}`;
262 + doc.font("JB-Bold").fontSize(7.5);
263 + const cw = doc.widthOfString(confTxt) + 20;
264 + doc.roundedRect(M, y + 34, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[c.letter], INK);
265 + doc.fillColor(c.letter === "D" ? DANGER : INK).text(confTxt, M + 10, y + 39.5, { lineBreak: false });
266 + y += 72;
267 +
268 + // équation
269 + card(doc, M, y, W - 2 * M, 58, { fill: INK, shadow: false });
270 + doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("LA FORMULE — JAMAIS MASQUÉE", M + 16, y + 11, { characterSpacing: 1.4 });
271 + doc.font("JB-Reg").fontSize(8.2).fillColor("#e8e6df").text(
272 + `V_coût = V_terrain + RCN − D → ${fmt(est.costApproachValue)} = ${fmt(est.landValue)} + ${fmt(est.replacementCostNew)} − ${fmt(est.depreciation.total)}`,
273 + M + 16, y + 26, { width: W - 2 * M - 32, lineBreak: false, height: 12, ellipsis: true });
274 + doc.font("JB-Reg").fontSize(7).fillColor("#9fb4bd").text(
275 + `RCN = Direct ${fmt(est.directCost)} + Indirects ${fmt(est.indirectCost)} + Frais gén. ${fmt(est.contractorOverhead)} + Profit ${fmt(est.contractorProfit)} + Contingence ${fmt(est.contingency)}`,
276 + M + 16, y + 41, { width: W - 2 * M - 32, lineBreak: false, height: 10, ellipsis: true });
277 + y += 78;
278 +
279 + // fiche du bâtiment (3 cartes)
280 + kicker(doc, "1 · Propriété et bâtiment — chaque caractéristique avec sa provenance", M, y);
281 + y += 16;
282 + const sidingTxt = Object.entries(b.siding).filter(([, v]) => (v ?? 0) > 0).map(([k, v]) => `${SIDING[k] ?? k} ${Math.round((v ?? 0) * 100)} %`).join(", ") || "—";
283 + const groups: [string, [string, string][]][] = [
284 + ["IDENTIFICATION", [
285 + ["Mode", MODE[est.input.mode] ?? est.input.mode],
286 + ["Id / annonce", est.input.propertyId ?? est.input.listingUid ?? "—"],
287 + ["Municipalité", est.input.municipality ?? "—"],
288 + ["Type", `${BUILDING_TYPES.find((t) => t.key === b.type)?.fr ?? b.type} (${prov("type")})`],
289 + ["Qualité", `${qLabel(b.quality)} (${prov("quality")})`],
290 + ["Logements", String(b.units)],
291 + ["Année constr.", `${b.yearBuilt ?? "—"} (${prov("yearBuilt")})`],
292 + ["Localisation", est.location.nameFr],
293 + ]],
294 + ["GÉOMÉTRIE", [
295 + ["Aire d'étages", `${num(b.grossFloorAreaSqft)} pi² (${prov("grossFloorAreaSqft")})`],
296 + ["Étages", `${b.stories} (${prov("stories")})`],
297 + ["Sous-sol", `${BASEMENT[b.basement] ?? b.basement} (${prov("basement")})`],
298 + ["Fondation", `${FOUND[b.foundation] ?? b.foundation} (${prov("foundation")})`],
299 + ["Garage", `${GARAGE[b.garage.type]}${b.garage.spaces ? ` ${b.garage.spaces} pl.` : ""} (${prov("garage")})`],
300 + ["Toit", `${ROOF[b.roof] ?? b.roof}, ${GEOM[b.roofGeometry] ?? b.roofGeometry} ${b.roofPitch}/12 (${prov("roof")})`],
301 + ["Fenêtres", `${WIN[b.windows] ?? b.windows}${b.windowCount != null ? ` × ${b.windowCount}` : ""} (${prov("windows")})`],
302 + ["Revêtement", `${sidingTxt} (${prov("siding")})`],
303 + ]],
304 + ["INTÉRIEUR ET MÉCANIQUE", [
305 + ["Chauffage", `${HEAT[b.heating] ?? b.heating} (${prov("heating")})`],
306 + ["Clim. / VRC", `${b.hasAirConditioning ? "clim." : "—"} / ${b.hasAirExchanger ? "VRC" : "—"}`],
307 + ["Cuisines", `${b.kitchens} · ${qLabel(b.kitchenQuality)} (${prov("kitchenQuality")})`],
308 + ["Salles de bain", `${b.bathrooms} + ${b.powderRooms} s. d'eau · ${qLabel(b.bathroomQuality)} (${prov("bathrooms")})`],
309 + ["Terrasse", b.deckSqft ? `${num(b.deckSqft)} pi²` : "—"],
310 + ["Entrée", b.drivewaySqft ? `${num(b.drivewaySqft)} pi² (${b.driveway})` : "—"],
311 + ["Piscine", b.pool === "none" ? "—" : b.pool === "inground" ? "creusée" : "hors terre"],
312 + ["Aménagement", b.landscapingSqft ? `${num(b.landscapingSqft)} pi²` : "—"],
313 + ]],
314 + ];
315 + const gW = (W - 2 * M - 2 * 14) / 3;
316 + const cellH = 38;
317 + const gH = 4 * (cellH + 6) + 34;
318 + groups.forEach(([title, rows], gi) => {
319 + const gx = M + gi * (gW + 14);
320 + card(doc, gx, y, gW, gH);
321 + doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text(title, gx + 12, y + 11, { characterSpacing: 1.2, lineBreak: false });
322 + doc.moveTo(gx + 12, y + 24).lineTo(gx + gW - 12, y + 24).dash(2, { space: 3 }).lineWidth(0.8).stroke(LINE).undash();
323 + const cw2 = (gW - 24 - 6) / 2;
324 + rows.forEach(([k, v], i) => {
325 + const col = i % 2, row = Math.floor(i / 2);
326 + kvCell(doc, gx + 12 + col * (cw2 + 6), y + 32 + row * (cellH + 6), cw2, cellH, k, v);
327 + });
328 + });
329 + y += gH + 14;
330 + doc.font("JB-Reg").fontSize(6.3).fillColor(INK3).text(
331 + "PROVENANCE : rôle MAMH = registre officiel · saisi = utilisateur · inféré par IA = analyse multimodale · dérivé = formule · supposé = hypothèse par défaut. Légende des prix : ● observé/officiel · ◐ calculé · ○ indexé · ◇ référence interne (hypothèse).",
332 + M, y, { width: W - 2 * M, characterSpacing: 0.2, lineGap: 1 });
333 +
334 + /* ================================ PAGE 2 — hypothèses & localisation ================================ */
335 + L.newPage();
336 + L.section("2 · Hypothèses — chaque pourcentage est visible, modifiable et expliqué");
337 + const p = est.input.params;
338 + L.table(
339 + [{ h: "Poste", w: 40 }, { h: "Taux", w: 10, align: "right", mono: true }, { h: "Base de calcul", w: 30 }, { h: "Explication (hypothèse documentée)", w: 60 }],
340 + [
341 + ...est.indirect.map((i) => [i.labelFr, pct(i.pct, 1), "coût direct", INDIRECT_LABELS[i.key].noteFr]),
342 + ["Frais généraux entrepreneur", pct(p.overheadPct, 1), "direct + indirects", "Frais fixes de l'entreprise (bureau, gestion) — distincts du profit."],
343 + ["Profit entrepreneur", pct(p.profitPct, 1), "direct + indirects + FG", "Rémunération du risque de l'entrepreneur général."],
344 + ["Contingence", pct(p.contingencyPct, 1), "direct + indirects", "Réserve pour imprévus de chantier — ni profit ni frais indirects."],
345 + ],
346 + { rowH: 15 }
347 + );
348 + L.para(`Coût de REMPLACEMENT (et non de reproduction) : le bâtiment est reconstruit avec les composantes modernes équivalentes (murs 2 × 6 R24, comble R50, poutrelles ajourées), quel que soit son année de construction. Constantes de prise de quantités : hauteur d'étage 9 pi, rapport de forme 1,5, 0,011 fenêtre de 10 pi² par pi² de plancher, 0,09 pi lin de cloison par pi², murs de sous-sol 8 pi, garage 10 pi. Dépréciation : méthode « ${est.depreciation.method === "components" ? "composante par composante (vie économique et condition de chaque groupe)" : "âge-vie simple (âge effectif ÷ vie économique)"} », vie économique ${est.depreciation.economicLife} ans, dépréciation physique plafonnée à 90 %.`);
349 + L.para(`Base de coûts version ${est.costDatabaseVersion} · assemblages v${est.assemblyVersion} · géométrie/quantités dérivées automatiquement sauf surcharges (${Object.keys(est.input.quantityOverrides).length}). Une estimation créée aujourd'hui peut être reconstruite plus tard avec les prix de sa date (instantané conservé).`, { size: 7.2, color: INK3 });
350 +
351 + L.section("8 · Localisation — facteurs distincts matériaux / main-d'œuvre / équipement");
352 + const loc = est.location;
353 + L.table(
354 + [{ h: "Région", w: 40 }, { h: "Matériaux", w: 15, align: "right", mono: true }, { h: "Main-d'œuvre", w: 15, align: "right", mono: true }, { h: "Équipement", w: 15, align: "right", mono: true }, { h: "Global", w: 15, align: "right", mono: true }, { h: "Confiance", w: 15, align: "right", mono: true }],
355 + [[`${loc.nameFr} (${loc.code})`, loc.materialFactor.toFixed(3), loc.labourFactor.toFixed(3), loc.equipmentFactor.toFixed(3), loc.overallFactor.toFixed(3), `${loc.confidence}/100`]]
356 + );
357 + L.para(`Méthode : ${loc.sourceMethod}. Ajustement de localisation appliqué au coût direct : ${fmt(est.lines.reduce((s, l) => s + l.locationAdjustment, 0))} (matériaux × ${loc.materialFactor}, main-d'œuvre × ${loc.labourFactor}, équipement × ${loc.equipmentFactor}). Les conventions collectives de la construction (Loi R-20) fixent des taux uniformes pour tout le Québec : le facteur main-d'œuvre est 1,00 sauf régions éloignées.`);
358 +
359 + /* ================================ 4 · direct par catégorie ================================ */
360 + L.section("4 · Coût direct par catégorie — matériaux, main-d'œuvre, équipement");
361 + const maxAdj = Math.max(1, ...est.categories.map((k) => k.adjusted));
362 + L.table(
363 + [{ h: "Catégorie", w: 34 }, { h: "Matériaux", w: 16, align: "right", mono: true }, { h: "Main-d'œuvre", w: 16, align: "right", mono: true }, { h: "Équip.", w: 12, align: "right", mono: true }, { h: "Direct localisé", w: 16, align: "right", mono: true }, { h: "Part", w: 10, align: "right", mono: true }],
364 + est.categories.map((k) => [k.labelFr, fmt(k.material), fmt(k.labour), fmt(k.equipment), fmt(k.adjusted), pct(k.sharePct, 1)]),
365 + { total: ["COÛTS DIRECTS", fmt(est.directMaterial), fmt(est.directLabour), fmt(est.directEquipment), fmt(est.directCost), "100 %"] }
366 + );
367 + // barres
368 + L.ensure(est.categories.length * 11 + 14);
369 + est.categories.forEach((k) => {
370 + const trackX = M + 150, trackW = W - 2 * M - 150 - 70;
371 + doc.font("JB-Reg").fontSize(6.4).fillColor(INK).text(k.labelFr, M, L.y + 1, { width: 145, lineBreak: false, height: 9, ellipsis: true });
372 + doc.rect(trackX, L.y, trackW, 8).fill(SURFACE2);
373 + const wM = (k.material / maxAdj) * trackW, wL = (k.labour / maxAdj) * trackW, wE = (k.equipment / maxAdj) * trackW;
374 + doc.rect(trackX, L.y, wM, 8).fill(INK);
375 + doc.rect(trackX + wM, L.y, wL, 8).fill(GREEN);
376 + doc.rect(trackX + wM + wL, L.y, wE, 8).fill(LIME);
377 + doc.font("JB-Bold").fontSize(6.4).fillColor(INK).text(fmt(k.adjusted), W - M - 66, L.y + 1, { width: 66, align: "right", lineBreak: false });
378 + L.y += 11;
379 + });
380 + doc.font("JB-Reg").fontSize(6.2).fillColor(INK3).text("■ MATÉRIAUX (encre) · ■ MAIN-D'ŒUVRE (bordeaux) · ■ ÉQUIPEMENT (rouge)", M, L.y + 2, { characterSpacing: 0.3 });
381 + L.y += 16;
382 +
383 + /* ================================ 5 · indirects + 9 · RCN ================================ */
384 + L.section("5 · Indirects, frais généraux, profit, contingence → 9 · RCN");
385 + L.table(
386 + [{ h: "Poste", w: 55 }, { h: "Taux", w: 15, align: "right", mono: true }, { h: "Montant", w: 30, align: "right", mono: true }],
387 + [
388 + ["Coûts directs localisés", "", fmt(est.directCost)],
389 + ...est.indirect.map((i) => [`Indirect — ${i.labelFr}`, pct(i.pct, 1), fmt(i.amount)]),
390 + ["Sous-total coûts indirects", pct(est.indirect.reduce((s, i) => s + i.pct, 0), 1), fmt(est.indirectCost)],
391 + ["Frais généraux entrepreneur", pct(p.overheadPct, 1), fmt(est.contractorOverhead)],
392 + ["Profit entrepreneur", pct(p.profitPct, 1), fmt(est.contractorProfit)],
393 + ["Contingence", pct(p.contingencyPct, 1), fmt(est.contingency)],
394 + ],
395 + { total: ["COÛT DE REMPLACEMENT À NEUF (RCN)", "", fmt(est.replacementCostNew)], highlight: (i) => i === 0 || i === est.indirect.length + 1 }
396 + );
397 + L.ensure(70);
398 + const tW3 = (W - 2 * M - 28) / 3;
399 + [["RCN TOTAL", fmt(est.replacementCostNew)], ["RCN PAR PI² D'AIRE D'ÉTAGES", `${fmt2(est.perSqft)} / pi²`], ["RCN PAR M²", `${fmt2(est.perM2)} / m²`]].forEach(([k, v], i) => {
400 + const x = M + i * (tW3 + 14);
401 + card(doc, x, L.y, tW3, 52);
402 + doc.font("JB-Bold").fontSize(6.3).fillColor(INK3).text(k, x + 12, L.y + 10, { characterSpacing: 0.6, lineBreak: false, width: tW3 - 24, height: 9, ellipsis: true });
403 + doc.font("SG-Bold").fontSize(17).fillColor(INK).text(v, x + 12, L.y + 24, { lineBreak: false, width: tW3 - 24, height: 22, ellipsis: true });
404 + });
405 + L.y += 74;
406 +
407 + /* ================================ 13 · fourchette ================================ */
408 + L.section("13 · Fourchette de coût — P10 · central · P90");
409 + L.ensure(60);
410 + {
411 + const r = est.range;
412 + const lo = r.p10 * 0.96, hi = r.p90 * 1.04;
413 + const tx = M, tw = W - 2 * M;
414 + const X = (v: number) => tx + ((v - lo) / (hi - lo)) * tw;
415 + doc.rect(tx, L.y + 14, tw, 10).fill(SURFACE2);
416 + doc.rect(X(r.p10), L.y + 14, X(r.p90) - X(r.p10), 10).fill(LIME_SOFT);
417 + doc.rect(X(r.p10), L.y + 10, 1.5, 18).fill(GREEN);
418 + doc.rect(X(r.p90), L.y + 10, 1.5, 18).fill(GREEN);
419 + doc.rect(X(est.replacementCostNew) - 1.5, L.y + 6, 3, 26).fill(INK);
420 + doc.font("JB-Bold").fontSize(6.5).fillColor(GREEN_DEEP).text(`P10 ${fmt(r.p10)}`, X(r.p10) - 40, L.y + 32, { width: 80, align: "center", lineBreak: false });
421 + doc.text(`P90 ${fmt(r.p90)}`, X(r.p90) - 40, L.y + 32, { width: 80, align: "center", lineBreak: false });
422 + doc.fillColor(INK).text(`RCN ${fmt(est.replacementCostNew)}`, X(est.replacementCostNew) - 50, L.y - 2, { width: 100, align: "center", lineBreak: false });
423 + L.y += 46;
424 + }
425 + L.para(`Écart-type combiné ${fmt(est.range.sigma)} : dispersion des prix de chaque article (observés multi-sources, ou ±8 % observé / ±15 % référence / ±20 % hypothèse), incertitude des heures (±15 %), de l'équipement (±20 %) et des quantités (dérivées ±10 %, saisies ±3 %, IA ±15 %), combinées en racine des carrés, plus une composante systématique (localisation, modèle d'assemblages 5 %). P10/P90 = RCN ∓ 1,28 σ.`);
426 +
427 + /* ================================ 10 · dépréciation ================================ */
428 + L.newPage();
429 + L.section("10 · Dépréciation — physique, fonctionnelle, externe");
430 + const d = est.depreciation;
431 + L.table(
432 + [{ h: "Élément", w: 60 }, { h: "Valeur", w: 40, align: "right", mono: true }],
433 + [
434 + ["Âge chronologique", d.chronologicalAge != null ? `${d.chronologicalAge} ans` : "—"],
435 + ["Âge effectif retenu", d.effectiveAge != null ? `${num(d.effectiveAge, 1)} ans (${SRC_TXT[est.input.depreciation.effectiveAgeSource] ?? est.input.depreciation.effectiveAgeSource})` : "—"],
436 + ["Vie économique", `${d.economicLife} ans`],
437 + ["Méthode", d.method === "components" ? "composante par composante" : "âge-vie simple"],
438 + ["Détérioration physique", `${pct(d.physicalPct, 1)} → ${fmt(d.physical)}`],
439 + ]
440 + );
441 + if (d.method === "components" && d.components.length) {
442 + L.table(
443 + [{ h: "Composante", w: 28 }, { h: "RCN", w: 13, align: "right", mono: true }, { h: "Vie", w: 8, align: "right", mono: true }, { h: "Condition", w: 11 }, { h: "Âge eff.", w: 11, align: "right", mono: true }, { h: "Dépr. %", w: 10, align: "right", mono: true }, { h: "Dépréciation", w: 14, align: "right", mono: true }, { h: "Restant", w: 14, align: "right", mono: true }],
444 + d.components.map((k) => [k.labelFr, fmt(k.rcn), `${k.economicLife} a`, k.condition ?? "— (âge)", num(k.effectiveAge, 1), pct(k.depreciationPct, 0), fmt(k.depreciation), fmt(k.remaining)]),
445 + { total: ["TOTAL PHYSIQUE", fmt(d.components.reduce((s, k) => s + k.rcn, 0)), "", "", "", pct(d.physicalPct, 1), fmt(d.physical), fmt(d.components.reduce((s, k) => s + k.remaining, 0))] }
446 + );
447 + L.para("Règle : âge effectif de la composante = ratio de condition × vie économique (règles documentées modifiables : neuf 0 · excellent 0,10 · rénové 0,12 · très bon 0,20 · bon 0,35 · moyen 0,55 · sous la moyenne 0,70 · mauvais 0,85) ; sans condition saisie, l'âge chronologique plafonné à la vie est utilisé.", { size: 7.2, color: INK3 });
448 + }
449 + const fo = est.input.depreciation.functional;
450 + L.table(
451 + [{ h: "Désuétude", w: 34 }, { h: "Type", w: 16 }, { h: "Détail", w: 30 }, { h: "Montant", w: 20, align: "right", mono: true }],
452 + [
453 + ...fo.map((f) => [`Fonctionnelle — ${f.type || "—"}`, f.curable ? "curable" : "incurable", f.curable ? `coût de correction ${fmt(f.costToCure)}` : `perte de valeur ${fmt(f.valueLoss)}`, neg(f.curable ? f.costToCure : f.valueLoss)]),
454 + ["Désuétude fonctionnelle (total)", "", `${fo.length} déficience(s)`, neg(d.functional)],
455 + ["Désuétude externe", "", est.input.depreciation.externalNote || "aucune preuve saisie → 0 $", neg(d.external)],
456 + ],
457 + { total: ["DÉPRÉCIATION TOTALE", "", `${pct(est.replacementCostNew ? (d.total / est.replacementCostNew) * 100 : 0, 1)} du RCN`, neg(d.total)] }
458 + );
459 +
460 + /* ================================ 11 · terrain + 12 · valeur ================================ */
461 + L.section("11 · Valeur du terrain");
462 + const land = est.input.land;
463 + const LANDSRC: Record<string, string> = { role: `Rôle d'évaluation foncière ${land.rollYear ?? 2026} (MAMH)`, user: "Valeur saisie par l'utilisateur", market: "Valeur marchande (ventes de terrains)", residual: "Technique du résiduel", none: "Aucune valeur — 0 $ (à compléter)" };
464 + L.table([{ h: "Valeur du terrain utilisée", w: 30, align: "right", mono: true }, { h: "Source", w: 35 }, { h: "Méthode", w: 35 }], [[fmt(est.landValue), LANDSRC[land.source] ?? land.source, land.method || "—"]]);
465 + L.para("⚠ La valeur du terrain au rôle n'est pas nécessairement la valeur marchande actuelle du terrain : le rôle est établi 18 à 24 mois avant son entrée en vigueur. Vous pouvez la conserver, la remplacer ou la documenter par une autre méthode.", { color: DANGER, size: 7.4 });
466 +
467 + L.section("12 · Indication de valeur par la méthode du coût");
468 + L.table(
469 + [{ h: "Poste", w: 60 }, { h: "Montant", w: 40, align: "right", mono: true }],
470 + [
471 + ...est.categories.map((k) => [k.labelFr, fmt(k.adjusted)]),
472 + ["Coûts directs", fmt(est.directCost)],
473 + ["Coûts indirects", fmt(est.indirectCost)],
474 + ["Frais généraux", fmt(est.contractorOverhead)],
475 + ["Profit", fmt(est.contractorProfit)],
476 + ["Contingence", fmt(est.contingency)],
477 + ["COÛT DE REMPLACEMENT À NEUF", fmt(est.replacementCostNew)],
478 + ["Détérioration physique", neg(d.physical)],
479 + ["Désuétude fonctionnelle", neg(d.functional)],
480 + ["Désuétude externe", neg(d.external)],
481 + ["Valeur dépréciée du bâtiment", fmt(d.depreciatedImprovementValue)],
482 + ["Valeur du terrain", fmt(est.landValue)],
483 + ],
484 + { total: ["INDICATION PAR LE COÛT", fmt(est.costApproachValue)], highlight: (i) => [est.categories.length, est.categories.length + 5, est.categories.length + 9].includes(i) }
485 + );
486 +
487 + /* ================================ 14 · confiance + benchmarks ================================ */
488 + L.section("14 · Indice de confiance — décomposition");
489 + L.table(
490 + [{ h: "Critère", w: 50 }, { h: "Score", w: 20, align: "right", mono: true }, { h: "Maximum", w: 30, align: "right", mono: true }],
491 + [["Fraîcheur des prix", String(c.freshness), "20"], ["Couverture (prix observés vs référence)", String(c.coverage), "20"], ["Localisation", String(c.location), "15"], ["Main-d'œuvre (grilles officielles)", String(c.labour), "15"], ["Benchmarks externes", String(c.benchmarks), "10"], ["Complétude du bâtiment", String(c.building), "20"]],
492 + { total: [`TOTAL — ${c.letter} · ${CONF_TXT[c.letter]}`, String(c.total), "100"] }
493 + );
494 + c.notesFr.forEach((n) => L.bullet(n));
495 + L.bullet(`Couverture des prix : ${pct(est.coverage.materialObservedShare * 100, 0)} du coût des matériaux repose sur des prix observés/officiels ; ${est.coverage.assembliesPriced}/${est.coverage.assembliesTotal} assemblages chiffrés.`);
496 + L.gap(4);
497 + L.section("Validation externe — benchmarks");
498 + if (est.benchmarks.length) {
499 + L.table(
500 + [{ h: "Source", w: 26 }, { h: "Type / marché", w: 26 }, { h: "Plage", w: 20, align: "right", mono: true }, { h: "Vrai-Prix", w: 14, align: "right", mono: true }, { h: "État", w: 14 }],
501 + est.benchmarks.map((k) => [`${k.source} ${k.year}`, `${k.buildingType} · ${k.market}`, `${fmt(k.low)}–${fmt(k.high)} ${k.unit}`, fmt2(k.estimatePerUnit), k.status === "within" ? "dans la plage" : k.status === "below" ? `⚠ sous la plage (${pct(k.deviationPct, 0)})` : k.status === "above" ? `⚠ au-dessus (${pct(k.deviationPct, 0)})` : "non disponible"])
502 + );
503 + L.para("Le benchmark ne modifie jamais l'estimation : un écart important est signalé, pas corrigé.", { size: 7.2, color: INK3 });
504 + } else {
505 + L.para("Donnée non disponible — aucun benchmark externe importé pour ce type de bâtiment et ce marché (le guide Altus requiert un import manuel après téléchargement légal).", { color: INK3 });
506 + }
507 + if (est.otherReadings) {
508 + const o = est.otherReadings;
509 + L.section("Trois lectures de la valeur — à comparer, jamais à moyenner");
510 + L.table(
511 + [{ h: "Lecture", w: 50 }, { h: "Valeur", w: 25, align: "right", mono: true }, { h: "vs coût", w: 25, align: "right", mono: true }],
512 + [
513 + ["Modèle hédonique (LightGBM)", fmt(o.hedonic), o.hedonic ? pct(((o.hedonic - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],
514 + ["Comparables ajustés", fmt(o.comparables), o.comparables ? pct(((o.comparables - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],
515 + ["Mesure Vrai-Prix (hybride 65/35)", fmt(o.hybrid), o.hybrid ? pct(((o.hybrid - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],
516 + ["Méthode du coût (ce rapport)", fmt(est.costApproachValue), "—"],
517 + ...(o.askingPrice ? [["Prix demandé", fmt(o.askingPrice), pct(((o.askingPrice - est.costApproachValue) / est.costApproachValue) * 100, 1)]] : []),
518 + ["Rôle 2026 — valeur totale", fmt(o.rollValue), o.rollValue ? pct(((o.rollValue - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],
519 + ["Rôle 2026 — bâtiment / terrain", `${fmt(o.rollBuilding)} / ${fmt(o.rollLand)}`, ""],
520 + ]
521 + );
522 + L.para(`RCN vs valeur du bâtiment au rôle : ${fmt(est.replacementCostNew)} vs ${fmt(o.rollBuilding)} ; valeur dépréciée vs rôle bâtiment : ${fmt(d.depreciatedImprovementValue)} vs ${fmt(o.rollBuilding)}. Le but est de comparer les approches, pas de faire une moyenne naïve.`, { size: 7.2, color: INK3 });
523 + }
524 +
525 + /* ================================ 3 + 6 · assemblages ================================ */
526 + L.newPage();
527 + L.section("3 · Assemblages retenus (6 · quantités, coûts unitaires, totaux)");
528 + L.para(`${est.lines.length} assemblages quantifiés par le moteur de géométrie (formule affichée) ou surchargés (${Object.keys(est.input.quantityOverrides).length}). Coût unitaire = matériaux (qté × pertes × prix) + main-d'œuvre (heures × taux employeur) + équipement, avant facteur régional. Prov. = nature dominante des prix des composants.`, { size: 7.2, color: INK3 });
529 + const kindOf = (l: CostEstimate["lines"][number]): string => {
530 + const kinds = l.unitDetail.components.filter((k) => k.itemCode).map((k) => k.provenance.kind);
531 + if (!kinds.length) return "◐";
532 + if (kinds.every((k) => k === "observed" || k === "official")) return "●";
533 + if (kinds.some((k) => k === "observed" || k === "official")) return "◐";
534 + if (kinds.some((k) => k === "indexed")) return "○";
535 + return "◇";
536 + };
537 + L.table(
538 + [{ h: "Assemblage", w: 30 }, { h: "Quantité", w: 11, align: "right", mono: true }, { h: "Formule", w: 27 }, { h: "Unitaire", w: 10, align: "right", mono: true }, { h: "M / MO / É", w: 14, align: "right", mono: true }, { h: "Total loc.", w: 11, align: "right", mono: true }, { h: "P", w: 3 }],
539 + est.lines.map((l) => [l.nameFr, `${num(l.quantity, l.quantity < 10 ? 1 : 0)} ${unitLabel(l.unit)}`, l.quantityFormula.replace(/\s+/g, " "), fmt2(l.unitCost), `${num(l.material / 1000, 1)}k/${num(l.labour / 1000, 1)}k/${num(l.equipment / 1000, 1)}k`, fmt(l.adjusted), kindOf(l)]),
540 + { rowH: 13, total: ["COÛTS DIRECTS LOCALISÉS", "", "", "", `${num(est.directMaterial / 1000, 0)}k/${num(est.directLabour / 1000, 0)}k/${num(est.directEquipment / 1000, 0)}k`, fmt(est.directCost), ""] }
541 + );
542 + // descriptions des assemblages
543 + L.section("Description des assemblages");
544 + const seen = new Set<string>();
545 + for (const l of est.lines) {
546 + if (seen.has(l.assemblyCode)) continue;
547 + seen.add(l.assemblyCode);
548 + const a = est.lines.find((x) => x.assemblyCode === l.assemblyCode)!;
549 + const desc = a.unitDetail.components.filter((k) => k.itemCode).map((k) => `${k.nameFr} ${num(k.quantity, 3)} ${unitLabel(k.unit)}`).join(" · ");
550 + L.para(`${l.nameFr} [${l.assemblyCode}, par ${unitLabel(l.unit)}] — ${desc || "main-d'œuvre/équipement seulement"}.`, { size: 6.8, color: INK2, gap: 3 });
551 + }
552 +
553 + /* ================================ 7 · main-d'œuvre ================================ */
554 + L.newPage();
555 + L.section("7 · Main-d'œuvre — heures et coût par métier (taux employeur complet)");
556 + const trades = new Map<string, { h: number; cost: number; rate: number | null; src: string; date: string; kind: string }>();
557 + for (const l of est.lines) for (const k of l.unitDetail.components) {
558 + if (!k.labourHours) continue;
559 + const t = k.trade ?? "—";
560 + const cur = trades.get(t) ?? { h: 0, cost: 0, rate: k.hourlyRate, src: k.rateProvenance?.source ?? "—", date: k.rateProvenance?.effectiveDate ?? "—", kind: k.rateProvenance?.kind === "official" ? "● officiel" : "◇ repli (hypothèse)" };
561 + cur.h += k.labourHours * l.quantity;
562 + cur.cost += k.labourCost * l.quantity * loc.labourFactor;
563 + trades.set(t, cur);
564 + }
565 + const trows = [...trades.entries()].sort((a, b) => b[1].cost - a[1].cost);
566 + L.table(
567 + [{ h: "Métier", w: 22 }, { h: "Heures", w: 9, align: "right", mono: true }, { h: "Taux employeur", w: 13, align: "right", mono: true }, { h: "Coût localisé", w: 13, align: "right", mono: true }, { h: "Source", w: 33 }, { h: "En vigueur", w: 10, mono: true }],
568 + trows.map(([t, v]) => [tradeLabel(t, true), num(v.h, 0), v.rate != null ? `${fmt2(v.rate)}/h` : "—", fmt(v.cost), `${v.kind} — ${v.src}`, v.date]),
569 + { total: ["TOTAL MAIN-D'ŒUVRE", num(trows.reduce((s, [, v]) => s + v.h, 0), 0), "", fmt(est.directLabour), "", ""] }
570 + );
571 + L.para("Le taux employeur complet inclut le salaire horaire conventionné (CCQ), l'indemnité de congés (13 %), les avantages sociaux, les cotisations (AE, RQAP, RRQ, FSS, CNESST, prélèvement CCQ, AECQ, fonds de formation) — jamais le salaire horaire nu. Les heures par unité d'assemblage sont des hypothèses de productivité résidentielle documentées.", { size: 7.2, color: INK3 });
572 +
573 + /* ================================ 15 · sources ================================ */
574 + L.section("15 · Sources — chaque prix important est retraçable");
575 + const srcMap = new Map<string, { kind: string; date: string; url: string; n: number }>();
576 + for (const l of est.lines) for (const k of l.unitDetail.components) {
577 + if (!k.itemCode) continue;
578 + for (const nm of k.provenance.source.split(", ")) {
579 + const key = nm.trim();
580 + const cur = srcMap.get(key) ?? { kind: `${KIND_ICON[k.provenance.kind]} ${KIND_TXT[k.provenance.kind]}`, date: k.provenance.observedAt ?? k.provenance.effectiveDate ?? "—", url: shortUrl(k.provenance.sourceUrl), n: 0 };
581 + cur.n++;
582 + srcMap.set(key, cur);
583 + }
584 + if (k.rateProvenance) {
585 + const key = k.rateProvenance.source;
586 + const cur = srcMap.get(key) ?? { kind: `${KIND_ICON[k.rateProvenance.kind]} ${KIND_TXT[k.rateProvenance.kind]}`, date: k.rateProvenance.effectiveDate ?? "—", url: shortUrl(k.rateProvenance.sourceUrl), n: 0 };
587 + cur.n++;
588 + srcMap.set(key, cur);
589 + }
590 + }
591 + L.table(
592 + [{ h: "Source", w: 34 }, { h: "Nature", w: 20 }, { h: "Date", w: 12, mono: true }, { h: "Composants", w: 10, align: "right", mono: true }, { h: "URL", w: 24, mono: true }],
593 + [...srcMap.entries()].sort((a, b) => b[1].n - a[1].n).map(([k, v]) => [k, v.kind, v.date, String(v.n), v.url]),
594 + { rowH: 13 }
595 + );
596 + L.para(`Localisation : ${loc.nameFr} — ${loc.sourceMethod}. Indices : Statistique Canada 18-10-0289-01 (actualisation des observations anciennes). Prix de référence internes (◇) : hypothèses documentées pour les articles sans observation détaillant — ils réduisent la couverture et l'indice de confiance.`, { size: 7.2, color: INK3 });
597 +
598 + /* ================================ 16 · limites ================================ */
599 + L.section("16 · Limites");
600 + [
601 + "Estimation indicative : les coûts présentés sont des estimations statistiques et ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ).",
602 + "Les quantités sont dérivées de formules géométriques génériques (rapport de forme, hauteurs, ratios d'ouvertures) et non d'un relevé des plans ; les surcharges saisies priment.",
603 + "Les caractéristiques « supposées » (revêtement, toiture, chauffage, qualité…) sont des valeurs par défaut à confirmer ; leur part abaisse le score « complétude du bâtiment ».",
604 + "Les prix de référence internes (◇) ne sont pas observés ; les prix détaillants observés (●) reflètent le prix affiché au détail, non les escomptes d'entrepreneur.",
605 + "La dépréciation dépend fortement de l'âge effectif et des conditions saisies ; la désuétude externe n'est jamais calculée sans preuve.",
606 + "Le facteur de localisation matériaux/équipement des régions éloignées est une hypothèse ; la main-d'œuvre suit les conventions collectives provinciales (CCQ).",
607 + ...(est.warnings.filter((w) => !w.startsWith("localisation")).map((w) => `Avertissement du moteur : ${w}.`)),
608 + ].forEach((t) => L.bullet(t));
609 +
610 + /* ---- pieds de page avec total ---- */
611 + const range = doc.bufferedPageRange();
612 + for (let i = range.start; i < range.start + range.count; i++) {
613 + doc.switchToPage(i);
614 + pageFooter(doc, i + 1, range.count, generated);
615 + }
616 + doc.end();
617 + return done;
618 +}
619