// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Service d'estimation par la méthode du coût (serveur) : * - préremplissage depuis une propriété du rôle (MAMH) ; * - entrée par défaut (mode construction) ; * - exécution (contexte de prix → quantités → moteur) + sauvegarde * reproductible (estimation, lignes, instantané des prix) ; * - relecture. */ import { randomUUID } from "crypto"; import { getUnit, type UnitRow } from "../db"; import { estimateByUnitId } from "../estimator"; import { costDatabaseVersion, getCostDb } from "./db"; import { buildContext, today } from "./catalog"; import { computeEstimate } from "./engine"; import { deriveQuantities } from "./geometry"; import { ASSEMBLY_VERSION } from "./seed/assemblies"; import { BUILDING_TYPES } from "./taxonomy"; import { sqftFromM2 } from "./units"; import type { AttributeSource, BuildingSpec, BuildingType, CostEstimate, CostInput, Quality } from "./types"; import { defaultInput } from "./estimate-defaults"; export { defaultBuilding, defaultInput } from "./estimate-defaults"; /* ------------------------------------------------- préremplissage MAMH */ function buildingTypeFromUnit(u: UnitRow): BuildingType { switch (u.type_prop) { case "plex": return "plex"; case "condo_ou_multi": return (u.nb_logements ?? 1) > 1 && (u.aire_etages_m2 ?? 0) > 400 ? "plex" : "condo"; case "chalet": return "chalet"; case "maison_mobile": return "mobile"; case "unifamilial": { const lien = (u.lien_physique ?? "").toLowerCase(); if (lien.includes("jumel")) return "semi_detached"; if (lien.includes("rang")) return "row"; if (lien.includes("intégré") || lien.includes("integre")) return "row"; return "detached"; } default: return "other"; } } export interface PropertyPrefill { input: CostInput; 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 }; missing: string[]; // champs à saisir manuellement } /** Préremplit une entrée de coût depuis une unité d'évaluation (source MAMH). */ export function prefillFromProperty(id: string): PropertyPrefill | null { const u = getUnit(id); if (!u) return null; const input = defaultInput(); const src: Partial> = {}; const missing: string[] = []; input.mode = "property"; input.propertyId = u.id_provinc; input.address = [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", ") || null; input.municipality = u.municipalite; input.lat = u.lat; input.lng = u.lng; const b = input.building; b.type = buildingTypeFromUnit(u); src.type = "MAMH"; 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"; } if (u.nb_etages && u.nb_etages > 0) { b.stories = Math.max(1, Math.round(u.nb_etages)); src.stories = "MAMH"; } else { const g = (u.genre_construction ?? "").toLowerCase(); 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; src.stories = g ? "derived" : "assumed"; if (!g) missing.push("stories"); } if (b.stories === 1.5) b.stories = 2; // étage mansardé / niveaux décalés : 2 niveaux de structure 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"); b.units = Math.max(1, u.nb_logements ?? 1); src.units = u.nb_logements ? "MAMH" : "assumed"; // hypothèses par défaut (à confirmer par l'utilisateur) — marquées « assumed » for (const k of ["basement", "garage", "siding", "roof", "heating", "bathrooms", "kitchenQuality", "quality", "windows", "foundation"]) { src[k] = "assumed"; missing.push(k); } if (b.type === "plex") { b.kitchens = b.units; b.bathrooms = b.units; b.quality = "standard"; } if (b.type === "mobile") { b.foundation = "piers"; b.basement = "none"; b.stories = 1; } if (b.type === "condo") { b.basement = "none"; } if (b.grossFloorAreaSqft > 2600 && b.type === "detached") { b.bathrooms = 2; b.powderRooms = 1; } if (b.type === "chalet") { b.quality = "standard"; } // terrain et rôle input.roll = { landValue: u.valeur_terrain, buildingValue: u.valeur_batiment, totalValue: u.valeur_role, year: 2026 }; 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)" }; else { input.land = { value: null, source: "none", rollYear: null, method: "" }; missing.push("landValue"); } input.depreciation.economicLife = BUILDING_TYPES.find((t) => t.key === b.type)?.economicLife ?? 60; input.attributeSources = src; return { input, 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 }, missing, }; } /* --------------------------------------------------------------- exécution */ export interface RunOptions { save?: boolean; sessionId?: string | null; otherReadings?: CostEstimate["otherReadings"]; } export function runEstimate(input: CostInput, opts: RunOptions = {}): CostEstimate { const d = getCostDb(); const asOf = input.priceDate ?? today(); const ctx = buildContext({ asOf, locationCode: input.locationCode, municipality: input.municipality, lat: input.lat, lng: input.lng }, d); const derived = deriveQuantities(input.building, input.quantityOverrides, input.mode === "listing" ? "AI" : "user", input.excludedAssemblies); const id = randomUUID(); const est = computeEstimate(input, derived.lines, ctx, { id, createdAt: new Date().toISOString(), costDatabaseVersion: costDatabaseVersion(d), assemblyVersion: String(ASSEMBLY_VERSION) }); est.warnings.push(...derived.warnings, `localisation : ${ctx.locationMethod}`); est.otherReadings = opts.otherReadings ?? (input.propertyId ? otherReadingsForProperty(input.propertyId) : null); if (opts.save !== false) saveEstimate(est, ctx, opts.sessionId ?? null); return est; } /** Les autres lectures de la valeur pour une propriété du rôle (hédonique, comparables, hybride, rôle). */ export function otherReadingsForProperty(id: string): CostEstimate["otherReadings"] { try { const e = estimateByUnitId(id); if (!e?.unit) return null; 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 }; } catch { return null; } } function saveEstimate(est: CostEstimate, ctx: ReturnType, sessionId: string | null): void { const d = getCostDb(); const tx = d.transaction(() => { 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) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( 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, 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, est.depreciation.depreciatedImprovementValue, est.landValue, est.costApproachValue, est.confidence.total, est.confidence.letter, est.methodVersion, est.costDatabaseVersion, est.assemblyVersion, 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 }), ); 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`); for (const l of est.lines) { ins.run(est.id, l.assemblyCode, l.category, l.quantity, l.unit, l.material, l.labour, l.equipment, l.direct, l.locationAdjustment, l.adjusted, [...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 })); } const used = new Set(est.lines.flatMap((l) => l.unitDetail.components.map((c) => c.itemCode).filter(Boolean))); const prices = Object.fromEntries([...ctx.prices.entries()].filter(([k]) => used.has(k))); d.prepare("INSERT INTO cost_estimate_snapshots(estimate_id,snapshot_date,prices_json,labour_json,location_json) VALUES(?,?,?,?,?)") .run(est.id, est.priceDate, JSON.stringify(prices), JSON.stringify(Object.fromEntries(ctx.labour)), JSON.stringify(ctx.location)); }); tx(); } /** Le résultat complet est stocké tel quel (JSON) — sans doublons volumineux. */ function stripForStorage(est: CostEstimate): CostEstimate { return est; } export function getEstimate(id: string): CostEstimate | null { const r = getCostDb().prepare("SELECT result_json FROM cost_estimates WHERE id=?").get(id) as { result_json: string } | undefined; if (!r) return null; return JSON.parse(r.result_json) as CostEstimate; } export function recentEstimatesForProperty(propertyId: string, limit = 5): { id: string; createdAt: string; rcn: number; value: number; letter: string }[] { 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[]) .map((r) => ({ id: String(r.id), createdAt: String(r.created_at), rcn: Number(r.rcn), value: Number(r.value), letter: String(r.letter) })); } /** Normalise/valide une entrée reçue du client (bornes, valeurs par défaut). */ export function sanitizeInput(raw: unknown): CostInput { const base = defaultInput(); if (!raw || typeof raw !== "object") return base; const r = raw as Partial; const b: BuildingSpec = { ...base.building, ...(r.building ?? {}) }; b.grossFloorAreaSqft = clamp(Number(b.grossFloorAreaSqft) || 1800, 200, 30000); b.footprintSqft = b.footprintSqft != null ? clamp(Number(b.footprintSqft), 100, 20000) : null; b.stories = clamp(Number(b.stories) || 1, 1, 6); b.units = clamp(Number(b.units) || 1, 1, 12); b.kitchens = clamp(Number(b.kitchens) || 1, 0, 12); b.bathrooms = clamp(Number(b.bathrooms) || 0, 0, 12); b.powderRooms = clamp(Number(b.powderRooms) || 0, 0, 6); b.roofPitch = clamp(Number(b.roofPitch) || 6, 0, 18); b.deckSqft = clamp(Number(b.deckSqft) || 0, 0, 3000); b.drivewaySqft = clamp(Number(b.drivewaySqft) || 0, 0, 10000); b.fenceLinFt = clamp(Number(b.fenceLinFt) || 0, 0, 2000); b.landscapingSqft = clamp(Number(b.landscapingSqft) || 0, 0, 40000); b.basementFinishedPct = clamp(Number(b.basementFinishedPct) || 0, 0, 1); 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 }; b.yearBuilt = b.yearBuilt != null && Number(b.yearBuilt) > 1600 ? Math.round(Number(b.yearBuilt)) : null; b.windowCount = b.windowCount != null ? clamp(Math.round(Number(b.windowCount)), 0, 200) : null; b.siding = Object.fromEntries(Object.entries(b.siding ?? {}).filter(([, v]) => typeof v === "number" && v > 0)); b.flooring = Object.fromEntries(Object.entries(b.flooring ?? {}).filter(([, v]) => typeof v === "number" && v > 0)); const q = (x: unknown): Quality => (["economy", "standard", "superior", "prestige"].includes(String(x)) ? (x as Quality) : "standard"); b.quality = q(b.quality); b.kitchenQuality = q(b.kitchenQuality); b.bathroomQuality = q(b.bathroomQuality); 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) }; for (const k of Object.keys(params.indirect) as (keyof typeof params.indirect)[]) params.indirect[k] = clamp(Number(params.indirect[k]) || 0, 0, 30); const dep = { ...base.depreciation, ...(r.depreciation ?? {}) }; dep.economicLife = clamp(Number(dep.economicLife) || 60, 10, 150); dep.effectiveAge = dep.effectiveAge != null && dep.effectiveAge !== ("" as unknown) ? clamp(Number(dep.effectiveAge), 0, 200) : null; 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) })) : []; dep.externalValueLoss = clamp(Number(dep.externalValueLoss) || 0, 0, 5e6); dep.componentConditions = dep.componentConditions ?? {}; const land = { ...base.land, ...(r.land ?? {}) }; land.value = land.value != null && land.value !== ("" as unknown) ? clamp(Number(land.value), 0, 5e7) : null; const overrides: Record = {}; 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); return { ...base, ...r, building: b, params, depreciation: dep, land, quantityOverrides: overrides, excludedAssemblies: Array.isArray(r.excludedAssemblies) ? r.excludedAssemblies.filter((x) => typeof x === "string").slice(0, 100) : [], attributeSources: r.attributeSources ?? {}, priceDate: r.priceDate && /^\d{4}-\d{2}-\d{2}$/.test(r.priceDate) ? r.priceDate : null, mode: r.mode === "property" || r.mode === "listing" ? r.mode : "construction", propertyId: r.propertyId ? String(r.propertyId).slice(0, 40) : null, listingUid: r.listingUid ? String(r.listingUid).slice(0, 80) : null, municipality: r.municipality ? String(r.municipality).slice(0, 120) : null, address: r.address ? String(r.address).slice(0, 200) : null, 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, roll: r.roll ?? null, }; } const clamp = (v: number, lo: number, hi: number) => (Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : lo);