// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Méthode du coût — types partagés du moteur, de la base de coûts et * de l'interface. Module PUR (aucun import serveur) : importable côté client. * * Vocabulaire : * - article (cost item) : matériau ou fourniture canonique (ex. 2x6 SPF 8 pi) * - assemblage (assembly) : composante réelle de construction (ex. mur 2x6 R24 vinyle) * - ligne (line) : un assemblage quantifié et chiffré dans une estimation * - provenance : d'où vient chaque nombre (observé, calculé, indexé, hypothèse…) */ /* ----------------------------------------------------------- provenance */ /** Nature d'un prix affiché (icônes ● ◐ ○ ◇ AI dans l'UI). */ export type PriceKind = | "observed" // ● observé directement chez un fournisseur | "derived" // ◐ calculé depuis des composants | "indexed" // ○ observation ancienne actualisée par un indice | "reference" // ◇ prix de référence interne (hypothèse documentée, non observé) | "assumption" // ◇ hypothèse documentée (pourcentage, productivité…) | "official"; // ● taux réglementé / publication officielle (APCHQ/CCQ, StatCan) /** Origine d'une caractéristique du bâtiment. */ export type AttributeSource = "MAMH" | "listing" | "user" | "AI" | "derived" | "assumed" | "cadastral"; /** Couche d'affichage (§208) : OBSERVÉ · INFÉRÉ PAR IA · CALCULÉ · PRIX SOURCÉ. */ export type Layer = "observed" | "ai_inferred" | "computed" | "sourced_price" | "assumption"; export interface Provenance { kind: PriceKind; source: string; // nom de la source (ex. « Canac », « APCHQ », « Vrai-Prix — hypothèse ») sourceId?: number; sourceUrl?: string | null; observedAt?: string | null; // date de l'observation ou de la publication effectiveDate?: string | null; note?: string | null; // ex. « prix de mars 2026 actualisé à septembre 2026 (StatCan 18-10-0289) » confidence: number; // 0-100 } /* ------------------------------------------------------------ taxonomie */ export type Quality = "economy" | "standard" | "superior" | "prestige"; export type Condition = "poor" | "below_average" | "average" | "good" | "very_good" | "excellent" | "renovated" | "new"; export type BuildingType = "detached" | "semi_detached" | "row" | "plex" | "condo" | "chalet" | "mobile" | "other"; export type Basement = "none" | "crawl" | "unfinished" | "partial" | "finished" | "walkout"; export type Foundation = "poured_concrete" | "concrete_block" | "slab_on_grade" | "piers" | "stone"; export type Structure = "wood_frame" | "steel" | "concrete" | "log" | "masonry"; export type RoofType = "asphalt_shingle" | "metal" | "membrane" | "cedar" | "slate_tile"; export type RoofGeometry = "gable" | "hip" | "flat" | "mansard" | "complex"; export type Siding = "vinyl" | "brick" | "fiber_cement" | "wood" | "stone" | "stucco" | "aluminum" | "steel"; export type WindowType = "pvc" | "hybrid" | "aluminum" | "wood"; export type Heating = "electric_baseboard" | "heat_pump" | "furnace_electric" | "furnace_gas" | "furnace_oil" | "hydronic" | "geothermal" | "wood"; export type GarageType = "none" | "attached" | "detached" | "integrated" | "carport"; /** Catégories UX du résidentiel québécois (au-dessus des divisions MasterFormat). */ export type UxCategory = | "site" | "excavation" | "foundation" | "structure" | "roofing" | "envelope" | "openings" | "insulation" | "finishes_ext" | "interior" | "kitchen" | "bathroom" | "plumbing" | "hvac" | "electrical" | "garage" | "basement" | "exterior"; /* ---------------------------------------------------------------- unités */ export type CanonicalUnit = | "unit" // chaque / unité | "pi2" // pied carré | "pi_lin" // pied linéaire | "pi3" | "m2" | "m3" | "kg" | "lb" | "L" | "gal" | "h" | "day" | "lump"; // forfait /* ---------------------------------------------------------------- articles */ export interface CostItem { id: number; code: string; // canonical_code (ex. LUM-2X6-8) masterformat: string; // « 06 » division: string; // libellé MasterFormat category: UxCategory; subcategory: string | null; nameFr: string; nameEn: string; descriptionFr: string | null; descriptionEn: string | null; unit: CanonicalUnit; materialClass: string | null; trade: string | null; // métier associé par défaut defaultWastePct: number; qualityLevel: Quality | null; /** prix de référence interne (hypothèse) si aucune observation — jamais présenté comme observé */ referencePrice: number | null; referenceNote: string | null; active: boolean; } /** Prix canonique courant d'un article (résultat de pricing.ts). */ export interface ItemPrice { itemCode: string; unit: CanonicalUnit; price: number; // médiane robuste low: number; high: number; kind: PriceKind; sourceCount: number; observedAt: string | null; // date de la plus récente observation sources: { source: string; price: number; url: string | null; observedAt: string; sourceUnit: string; conversionFactor: number; regular: boolean }[]; confidence: number; // 0-100 (fraîcheur, nb sources, qualité, dispersion, localisation) indexed?: { fromDate: string; toDate: string; factor: number; index: string } | null; note: string | null; } /* --------------------------------------------------------- main-d'œuvre */ export interface LabourRate { tradeCode: string; tradeNameFr: string; tradeNameEn: string; sector: string; // residentiel_leger | residentiel_lourd | ic classification: string; // compagnon | apprenti-1… region: string; // QC (conventions provinciales) | isole effectiveFrom: string; effectiveTo: string | null; baseWage: number; vacationCost: number; benefitsCost: number; employerContributions: number; otherContributions: number; totalEmployerCost: number; source: string; sourceUrl: string | null; confidence: number; } /* ------------------------------------------------------------ localisation */ export interface CostLocation { code: string; // ex. « QC-MTL », « QC-OUT » nameFr: string; nameEn: string; regionCode: string; materialFactor: number; labourFactor: number; equipmentFactor: number; overallFactor: number; effectiveDate: string; sourceMethod: string; confidence: number; } /* ------------------------------------------------------------ assemblages */ export interface AssemblyComponent { itemCode: string; /** quantité d'article (dans l'unité de l'article) par unité d'assemblage */ quantity: number; wasteFactor: number; // 0.07 = 7 % de pertes labourHours: number; // heures de main-d'œuvre par unité d'assemblage, portées par ce composant trade: string | null; // métier pour ces heures (défaut : métier de l'article) equipmentCost: number; // $ d'équipement par unité d'assemblage (hypothèse documentée) sequence: number; notes: string | null; } export interface Assembly { code: string; masterformat: string; category: UxCategory; nameFr: string; nameEn: string; descriptionFr: string; descriptionEn: string; unit: CanonicalUnit; buildingType: string | null; // null = tous quality: Quality | null; version: number; /** vie économique typique de la composante (années) — dépréciation par composante */ economicLife: number | null; /** catégorie de condition (roof, windows, kitchen…) pour les règles condition → âge effectif */ conditionGroup: string | null; components: AssemblyComponent[]; active: boolean; } /** Coût unitaire d'un assemblage (par unité d'assemblage), avant facteur régional. */ export interface AssemblyUnitCost { code: string; unit: CanonicalUnit; material: number; labour: number; equipment: number; direct: number; /** écart-type relatif estimé du coût direct (dispersion des prix + heures) */ sigmaPct: number; /** part du coût matériau provenant de prix observés/officiels (0-1) */ observedShare: number; components: { itemCode: string; nameFr: string; nameEn: string; unit: CanonicalUnit; quantity: number; wasteFactor: number; unitPrice: number; materialCost: number; labourHours: number; trade: string | null; hourlyRate: number | null; labourCost: number; equipmentCost: number; provenance: Provenance; rateProvenance: Provenance | null; }[]; freshestObservation: string | null; } /* ------------------------------------------------------------- estimation */ export type EstimateMode = "property" | "construction" | "listing"; export interface SidingMix { vinyl?: number; brick?: number; fiber_cement?: number; wood?: number; stone?: number; stucco?: number; aluminum?: number; steel?: number; } /** Description du bâtiment — chaque champ peut être MAMH, saisi, IA ou dérivé (attributeSources). */ export interface BuildingSpec { type: BuildingType; quality: Quality; grossFloorAreaSqft: number; // aire d'étages hors sous-sol footprintSqft: number | null; stories: number; yearBuilt: number | null; basement: Basement; basementFinishedPct: number; // 0-1 garage: { type: GarageType; spaces: number; areaSqft: number | null }; structure: Structure; foundation: Foundation; siding: SidingMix; // parts (somme ≈ 1) roof: RoofType; roofGeometry: RoofGeometry; roofPitch: number; // rise/12 windows: WindowType; windowCount: number | null; heating: Heating; hasAirConditioning: boolean; hasAirExchanger: boolean; kitchens: number; kitchenQuality: Quality; bathrooms: number; powderRooms: number; bathroomQuality: Quality; bedrooms: number | null; flooring: { hardwood?: number; engineered?: number; vinyl_plank?: number; ceramic?: number; laminate?: number; carpet?: number }; deckSqft: number; drivewaySqft: number; driveway: "asphalt" | "pavers" | "gravel" | "concrete" | "none"; fenceLinFt: number; landscapingSqft: number; pool: "none" | "above_ground" | "inground"; units: number; // logements (plex) } export interface IndirectParams { plansPct: number; permitsPct: number; inspectionPct: number; insurancePct: number; mobilizationPct: number; siteManagementPct: number; financingPct: number; adminPct: number; } export interface CostParams { indirect: IndirectParams; overheadPct: number; profitPct: number; contingencyPct: number; } export interface FunctionalObsolescence { id: string; type: string; curable: boolean; costToCure: number; valueLoss: number; notes: string; } export interface DepreciationInput { method: "age_life" | "components"; effectiveAge: number | null; // années (défaut : âge chronologique) economicLife: number; // années effectiveAgeSource: AttributeSource; /** condition par groupe de composantes (roof, windows, kitchen, bathroom, mechanical, exterior, interior, structure) */ componentConditions: Partial>; functional: FunctionalObsolescence[]; externalValueLoss: number; externalNote: string; } export interface LandInput { value: number | null; source: "role" | "user" | "market" | "residual" | "none"; rollYear: number | null; method: string; } export interface CostInput { mode: EstimateMode; propertyId: string | null; // id_provinc MAMH listingUid: string | null; address: string | null; municipality: string | null; lat: number | null; lng: number | null; locationCode: string | null; // forcer une localisation ; sinon dérivée building: BuildingSpec; /** surcharges de quantités par assemblage (unité de l'assemblage) */ quantityOverrides: Record; /** assemblages exclus */ excludedAssemblies: string[]; params: CostParams; depreciation: DepreciationInput; land: LandInput; attributeSources: Partial>; /** contexte du rôle (mode propriété) */ roll: { landValue: number | null; buildingValue: number | null; totalValue: number | null; year: number } | null; /** instantané de prix (date) — null = aujourd'hui */ priceDate: string | null; } export interface QuantityLine { assemblyCode: string; quantity: number; unit: CanonicalUnit; source: AttributeSource; // derived (géométrie), user (surcharge), AI, listing formula: string; // texte de la formule appliquée inputs: Record; } export interface EstimateLine { assemblyCode: string; nameFr: string; nameEn: string; category: UxCategory; quantity: number; unit: CanonicalUnit; quantitySource: AttributeSource; quantityFormula: string; unitCost: number; // direct par unité, avant localisation material: number; labour: number; equipment: number; direct: number; // avant localisation locationAdjustment: number; // $ ajouté/retiré par la localisation adjusted: number; // direct localisé sigma: number; // $ (écart-type de la ligne) observedShare: number; confidence: number; economicLife: number | null; conditionGroup: string | null; unitDetail: AssemblyUnitCost; } export interface CategoryTotal { category: UxCategory; labelFr: string; labelEn: string; material: number; labour: number; equipment: number; direct: number; adjusted: number; sharePct: number; } export interface ConfidenceBreakdown { freshness: number; // /20 coverage: number; // /20 location: number; // /15 labour: number; // /15 benchmarks: number; // /10 building: number; // /20 total: number; // /100 letter: "A" | "B" | "C" | "D"; notesFr: string[]; notesEn: string[]; } export interface DepreciationComponentRow { conditionGroup: string; labelFr: string; labelEn: string; rcn: number; economicLife: number; condition: Condition | null; effectiveAge: number; depreciationPct: number; depreciation: number; remaining: number; rule: string; } export interface DepreciationResult { method: "age_life" | "components"; chronologicalAge: number | null; effectiveAge: number | null; economicLife: number; physicalPct: number; physical: number; components: DepreciationComponentRow[]; functionalCurable: number; functionalIncurable: number; functional: number; external: number; total: number; depreciatedImprovementValue: number; } export interface BenchmarkCheck { source: string; buildingType: string; market: string; unit: string; low: number; high: number; midpoint: number | null; year: number; estimatePerUnit: number; status: "within" | "below" | "above" | "unavailable"; deviationPct: number | null; notes: string | null; } export interface CostEstimate { id: string; createdAt: string; methodVersion: string; costDatabaseVersion: string; assemblyVersion: string; priceDate: string; input: CostInput; location: CostLocation; quantities: QuantityLine[]; lines: EstimateLine[]; categories: CategoryTotal[]; directCost: number; directMaterial: number; directLabour: number; directEquipment: number; indirect: { key: keyof IndirectParams; labelFr: string; labelEn: string; pct: number; amount: number }[]; indirectCost: number; contractorOverhead: number; contractorProfit: number; contingency: number; replacementCostNew: number; range: { p10: number; p90: number; sigma: number; low: number; high: number }; perSqft: number; perM2: number; depreciation: DepreciationResult; landValue: number; costApproachValue: number; confidence: ConfidenceBreakdown; benchmarks: BenchmarkCheck[]; coverage: { materialObservedShare: number; assembliesPriced: number; assembliesTotal: number; pricingCoverage: number }; /** comparaison avec le moteur existant (mode propriété/annonce) */ otherReadings: { hedonic: number | null; comparables: number | null; hybrid: number | null; askingPrice: number | null; rollValue: number | null; rollBuilding: number | null; rollLand: number | null } | null; warnings: string[]; } /* ---------------------------------------------------------- tableau de bord */ export interface IndexPoint { period: string; // YYYY-MM-DD (début du trimestre) value: number; pctYoy: number | null; } export interface IndexSeries { code: string; source: string; geography: string; buildingType: string; division: string; points: IndexPoint[]; retrievedAt: string | null; } export interface CostOverview { generatedAt: string; indices: IndexSeries[]; kpis: { residentialIndex: { value: number; period: string; pctYoy: number | null; geography: string } | null; materialsProxy: { value: number; period: string; pctYoy: number | null; label: string } | null; labour: { trade: string; totalEmployerCost: number; effectiveFrom: number | string; pctYoy: number | null; source: string } | null; observations: number; itemsObserved: number; itemsTotal: number; lastSync: string | null; }; 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 }[] }[]; popularAssemblies: { code: string; nameFr: string; nameEn: string; unit: CanonicalUnit; direct: number; material: number; labour: number; equipment: number; observedShare: number }[]; locations: CostLocation[]; sources: { name: string; type: string; license: string; lastSync: string | null; active: boolean; observations: number; url: string | null }[]; }