Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**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 */7import type Database from "better-sqlite3";8import { getCostDb } from "./db";9import { canonicalPrice, indexFactor, type PriceObservation } from "./pricing";10import { FALLBACK_HOURLY_RATE, assemblyUnitCost, type BenchmarkRow, type ConditionRule, type PricingContext } from "./engine";11import type { Assembly, CanonicalUnit, Condition, CostItem, CostLocation, CostOverview, IndexSeries, ItemPrice, LabourRate, PriceKind, Quality, UxCategory } from "./types";12import { TRADES } from "./taxonomy";1314type Row = Record<string, unknown>;15const s = (v: unknown) => (v == null ? null : String(v));16const n = (v: unknown) => (v == null ? null : Number(v));1718export const today = (): string => new Date().toISOString().slice(0, 10);1920/* ---------------------------------------------------------------- articles */2122function 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}2930export 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}3435export 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}3940export 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 JS45 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}5152/* ------------------------------------------------------------------- prix */5354interface 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 }5556/** Série d'indice résidentiel utilisée pour l'actualisation (Montréal, bâtiments résidentiels, agrégat). */57export 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}6061/**62 * Prix canonique de chaque article à la date `asOf` : observations des 12063 * derniers jours → médiane robuste ; sinon dernière observation actualisée par64 * l'indice StatCan (kind « indexed », ≤ 3 ans) ; sinon prix de référence65 * interne (kind « reference », hypothèse).66 */67export 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_outlier69 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_id70 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 indice91 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}116117/** Historique des prix (toutes observations) d'un article. */118export 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 cf120 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}123124/* ----------------------------------------------------------- main-d'œuvre */125126export 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_id128 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 → couvreur140 if (!out.has("poseur_bardeaux") && out.has("couvreur")) out.set("poseur_bardeaux", { ...out.get("couvreur")!, tradeCode: "poseur_bardeaux" });141 return out;142}143144/** Toutes les grilles (historique) d'un métier. */145export 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}153154export 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}172173/* ------------------------------------------------------------ assemblages */174175export 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}189190/* ----------------------------------------------------------- localisation */191192function 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}195196export function loadLocations(d: Database.Database = getCostDb()): CostLocation[] {197 return (d.prepare("SELECT * FROM cost_locations ORDER BY code").all() as Row[]).map(rowToLocation);198}199200const fold = (x: string) => x.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();201202/** Localisation d'une propriété : code forcé → municipalité → centre le plus proche (rayon) → rural. */203export 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}228229function 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}234235/* ------------------------------------------------- règles, benchmarks, indices */236237export 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}247248export 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}251252export 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}262263/* ---------------------------------------------------------------- contexte */264265export 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}274275/* ------------------------------------------------------------- tableau de bord */276277const 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"];278const 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"];279280export 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 date304 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}327328function 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}333334/** Libellés des métiers pour l'UI (réexport). */335export const TRADE_LIST = TRADES;336