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 * Méthode du coût — types partagés du moteur, de la base de coûts et4 * 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 estimation10 * - provenance : d'où vient chaque nombre (observé, calculé, indexé, hypothèse…)11 */1213/* ----------------------------------------------------------- provenance */1415/** Nature d'un prix affiché (icônes ● ◐ ○ ◇ AI dans l'UI). */16export type PriceKind =17 | "observed" // ● observé directement chez un fournisseur18 | "derived" // ◐ calculé depuis des composants19 | "indexed" // ○ observation ancienne actualisée par un indice20 | "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)2324/** Origine d'une caractéristique du bâtiment. */25export type AttributeSource = "MAMH" | "listing" | "user" | "AI" | "derived" | "assumed" | "cadastral";2627/** Couche d'affichage (§208) : OBSERVÉ · INFÉRÉ PAR IA · CALCULÉ · PRIX SOURCÉ. */28export type Layer = "observed" | "ai_inferred" | "computed" | "sourced_price" | "assumption";2930export 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 publication36 effectiveDate?: string | null;37 note?: string | null; // ex. « prix de mars 2026 actualisé à septembre 2026 (StatCan 18-10-0289) »38 confidence: number; // 0-10039}4041/* ------------------------------------------------------------ taxonomie */4243export type Quality = "economy" | "standard" | "superior" | "prestige";44export type Condition = "poor" | "below_average" | "average" | "good" | "very_good" | "excellent" | "renovated" | "new";45export type BuildingType = "detached" | "semi_detached" | "row" | "plex" | "condo" | "chalet" | "mobile" | "other";46export type Basement = "none" | "crawl" | "unfinished" | "partial" | "finished" | "walkout";47export type Foundation = "poured_concrete" | "concrete_block" | "slab_on_grade" | "piers" | "stone";48export type Structure = "wood_frame" | "steel" | "concrete" | "log" | "masonry";49export type RoofType = "asphalt_shingle" | "metal" | "membrane" | "cedar" | "slate_tile";50export type RoofGeometry = "gable" | "hip" | "flat" | "mansard" | "complex";51export type Siding = "vinyl" | "brick" | "fiber_cement" | "wood" | "stone" | "stucco" | "aluminum" | "steel";52export type WindowType = "pvc" | "hybrid" | "aluminum" | "wood";53export type Heating = "electric_baseboard" | "heat_pump" | "furnace_electric" | "furnace_gas" | "furnace_oil" | "hydronic" | "geothermal" | "wood";54export type GarageType = "none" | "attached" | "detached" | "integrated" | "carport";5556/** Catégories UX du résidentiel québécois (au-dessus des divisions MasterFormat). */57export 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";7677/* ---------------------------------------------------------------- unités */7879export type CanonicalUnit =80 | "unit" // chaque / unité81 | "pi2" // pied carré82 | "pi_lin" // pied linéaire83 | "pi3"84 | "m2"85 | "m3"86 | "kg"87 | "lb"88 | "L"89 | "gal"90 | "h"91 | "day"92 | "lump"; // forfait9394/* ---------------------------------------------------------------- articles */9596export interface CostItem {97 id: number;98 code: string; // canonical_code (ex. LUM-2X6-8)99 masterformat: string; // « 06 »100 division: string; // libellé MasterFormat101 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éfaut110 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}117118/** Prix canonique courant d'un article (résultat de pricing.ts). */119export interface ItemPrice {120 itemCode: string;121 unit: CanonicalUnit;122 price: number; // médiane robuste123 low: number;124 high: number;125 kind: PriceKind;126 sourceCount: number;127 observedAt: string | null; // date de la plus récente observation128 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}133134/* --------------------------------------------------------- main-d'œuvre */135136export interface LabourRate {137 tradeCode: string;138 tradeNameFr: string;139 tradeNameEn: string;140 sector: string; // residentiel_leger | residentiel_lourd | ic141 classification: string; // compagnon | apprenti-1…142 region: string; // QC (conventions provinciales) | isole143 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}155156/* ------------------------------------------------------------ localisation */157158export 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}171172/* ------------------------------------------------------------ assemblages */173174export 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 pertes179 labourHours: number; // heures de main-d'œuvre par unité d'assemblage, portées par ce composant180 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}185186export 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 = tous196 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}205206/** Coût unitaire d'un assemblage (par unité d'assemblage), avant facteur régional. */207export 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}237238/* ------------------------------------------------------------- estimation */239240export type EstimateMode = "property" | "construction" | "listing";241242export 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}252253/** Description du bâtiment — chaque champ peut être MAMH, saisi, IA ou dérivé (attributeSources). */254export interface BuildingSpec {255 type: BuildingType;256 quality: Quality;257 grossFloorAreaSqft: number; // aire d'étages hors sous-sol258 footprintSqft: number | null;259 stories: number;260 yearBuilt: number | null;261 basement: Basement;262 basementFinishedPct: number; // 0-1263 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/12270 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}290291export 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}301302export interface CostParams {303 indirect: IndirectParams;304 overheadPct: number;305 profitPct: number;306 contingencyPct: number;307}308309export interface FunctionalObsolescence {310 id: string;311 type: string;312 curable: boolean;313 costToCure: number;314 valueLoss: number;315 notes: string;316}317318export interface DepreciationInput {319 method: "age_life" | "components";320 effectiveAge: number | null; // années (défaut : âge chronologique)321 economicLife: number; // années322 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}329330export interface LandInput {331 value: number | null;332 source: "role" | "user" | "market" | "residual" | "none";333 rollYear: number | null;334 method: string;335}336337export interface CostInput {338 mode: EstimateMode;339 propertyId: string | null; // id_provinc MAMH340 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ée346 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}360361export interface QuantityLine {362 assemblyCode: string;363 quantity: number;364 unit: CanonicalUnit;365 source: AttributeSource; // derived (géométrie), user (surcharge), AI, listing366 formula: string; // texte de la formule appliquée367 inputs: Record<string, number | string>;368}369370export 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 localisation380 material: number;381 labour: number;382 equipment: number;383 direct: number; // avant localisation384 locationAdjustment: number; // $ ajouté/retiré par la localisation385 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}393394export 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}405406export interface ConfidenceBreakdown {407 freshness: number; // /20408 coverage: number; // /20409 location: number; // /15410 labour: number; // /15411 benchmarks: number; // /10412 building: number; // /20413 total: number; // /100414 letter: "A" | "B" | "C" | "D";415 notesFr: string[];416 notesEn: string[];417}418419export 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}432433export 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}448449export 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}463464export 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}499500/* ---------------------------------------------------------- tableau de bord */501502export interface IndexPoint {503 period: string; // YYYY-MM-DD (début du trimestre)504 value: number;505 pctYoy: number | null;506}507508export 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}517518export 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}535