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%
24.4 KB · 336 lines typescript
Raw Blame History
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