1// UQO Éval — Métré : recettes d'assemblages du module (imbrication, pertes une seule fois, détection de cycles). Module PUR.2/**3 * DirectCost = Σ(MaterialQty × MaterialUnitPrice × (1 + WasteRate))4 * + Σ(LabourHours × LabourRate)5 * + Σ(EquipmentHours × EquipmentRate)6 * + Subcontract + Transport7 * AssemblyUnitCost = DirectCost / AssemblyOutputQuantity8 *9 * Règles : un composant « installé » (prix tout compris) n'ajoute ni main-d'œuvre10 * ni pertes ; un assemblage imbriqué est chiffré par sa propre recette (ses pertes11 * sont déjà incluses : aucune seconde application) ; un cycle A → B → A est refusé.12 */13import type { CustomAssembly, CustomComponent, Provenance } from "./types";14import { fromCents, lineCents, mulCents, sumCents, toCents } from "./money";1516export const MAX_NESTING_DEPTH = 8;1718export interface ItemQuote {19 unitPrice: number; // $ par unité de l'article20 unit: string;21 kind: Provenance["kind"];22 source: string;23 sourceUrl: string | null;24 observedAt: string | null;25 confidence: number;26 isEquipment: boolean;27 defaultTrade: string | null;28}2930export interface AssemblyQuote {31 code: string;32 unit: string;33 material: number;34 labour: number;35 equipment: number;36 other: number;37 direct: number; // par unité de sortie38 observedShare: number;39 confidence: number;40 source: string;41 components: ComponentCost[];42}4344export interface ComponentCost {45 componentId: string;46 refType: "item" | "assembly";47 refCode: string;48 quantity: number;49 wasteRate: number;50 unitPrice: number | null;51 material: number;52 labourHours: number;53 hourlyRate: number | null;54 labour: number;55 equipment: number;56 other: number;57 provenance: Provenance;58 warnings: string[];59}6061export interface AssemblyResolver {62 /** article du catalogue de coûts */63 item(code: string): ItemQuote | null;64 /** taux horaire employeur ($/h) d'un métier (null = inconnu) */65 labourRate(trade: string | null): { rate: number; source: string; sourceUrl: string | null; effectiveFrom: string | null; confidence: number } | null;66 /** assemblage du CATALOGUE (coût unitaire déjà calculé par le moteur de la méthode du coût) */67 catalogueAssembly(code: string): AssemblyQuote | null;68 /** assemblage du MODULE (recette éditable) */69 customAssembly(code: string): CustomAssembly | null;70 fallbackHourlyRate: number;71}7273export class AssemblyCycleError extends Error {74 constructor(public readonly path: string[]) { super(`Assemblage circulaire : ${path.join(" → ")}`); }75}7677/** Détecte un cycle dans les recettes à partir d'un code (sans chiffrer). */78export function detectCycle(code: string, resolver: Pick<AssemblyResolver, "customAssembly">, path: string[] = []): string[] | null {79 if (path.includes(code)) return [...path, code];80 const a = resolver.customAssembly(code);81 if (!a) return null;82 for (const c of a.components) {83 if (c.refType !== "assembly") continue;84 const r = detectCycle(c.refCode, resolver, [...path, code]);85 if (r) return r;86 }87 return null;88}8990/** Chiffre une recette du module (récursif pour les assemblages imbriqués). */91export function quoteCustomAssembly(a: CustomAssembly, resolver: AssemblyResolver, path: string[] = []): AssemblyQuote {92 if (path.includes(a.code)) throw new AssemblyCycleError([...path, a.code]);93 if (path.length >= MAX_NESTING_DEPTH) throw new Error(`Imbrication trop profonde (> ${MAX_NESTING_DEPTH}) : ${[...path, a.code].join(" → ")}`);94 const out = Math.max(0, a.outputQuantity);95 if (!(out > 0)) throw new Error(`Assemblage ${a.code} : quantité de sortie nulle.`);96 const comps: ComponentCost[] = [];97 let mat = 0, lab = 0, eq = 0, oth = 0, obsMat = 0, confW = 0, confSum = 0;98 for (const c of a.components) comps.push(quoteComponent(c, resolver, [...path, a.code]));99 for (const c of comps) {100 mat += toCents(c.material); lab += toCents(c.labour); eq += toCents(c.equipment); oth += toCents(c.other);101 const w = c.material + c.labour + c.equipment + c.other;102 if (c.provenance.kind === "observed" || c.provenance.kind === "official" || c.provenance.kind === "indexed") obsMat += c.material;103 confW += w; confSum += w * c.provenance.confidence;104 }105 const directC = sumCents([mat, lab, eq, oth]);106 const per = (c: number) => fromCents(mulCents(c, 1 / out));107 const matTotal = fromCents(mat);108 return {109 code: a.code, unit: a.unit, material: per(mat), labour: per(lab), equipment: per(eq), other: per(oth), direct: per(directC),110 observedShare: matTotal > 0 ? obsMat / matTotal : 0, confidence: confW > 0 ? Math.round(confSum / confW) : 0,111 source: `recette ${a.code} v${a.version}`, components: comps,112 };113}114115function quoteComponent(c: CustomComponent, resolver: AssemblyResolver, path: string[]): ComponentCost {116 const warnings: string[] = [];117 const base = { componentId: c.id, refType: c.refType, refCode: c.refCode, quantity: c.quantity, wasteRate: c.wasteRate, labourHours: c.labourHours };118 if (c.refType === "assembly") {119 const custom = resolver.customAssembly(c.refCode);120 const q = custom ? quoteCustomAssembly(custom, resolver, path) : resolver.catalogueAssembly(c.refCode);121 if (!q) return { ...base, unitPrice: null, material: 0, hourlyRate: null, labour: 0, equipment: 0, other: 0, provenance: unpriced(`assemblage inconnu ${c.refCode}`), warnings: [`assemblage inconnu : ${c.refCode}`] };122 if (c.wasteRate > 0) warnings.push("pertes ignorées sur un assemblage imbriqué (déjà incluses dans sa recette)");123 if (c.labourHours > 0) warnings.push("heures de main-d'œuvre ignorées sur un assemblage imbriqué (déjà incluses)");124 const q2 = c.quantity;125 return {126 ...base, unitPrice: q.direct, material: fromCents(lineCents(q2, q.material)), hourlyRate: null, labour: fromCents(lineCents(q2, q.labour)), equipment: fromCents(lineCents(q2, q.equipment)), other: fromCents(lineCents(q2, q.other + c.subcontract + c.transport)),127 provenance: { basis: custom ? "custom_assembly" : "catalogue", kind: q.observedShare >= 0.5 ? "observed" : "derived", evidenceClass: null, source: q.source, sourceUrl: null, observedAt: null, note: "assemblage imbriqué", confidence: q.confidence, observedShare: q.observedShare }, warnings,128 };129 }130 const it = resolver.item(c.refCode);131 if (!it) return { ...base, unitPrice: null, material: 0, hourlyRate: null, labour: 0, equipment: 0, other: fromCents(toCents(c.subcontract + c.transport)), provenance: unpriced(`article inconnu ${c.refCode}`), warnings: [`article sans prix : ${c.refCode}`] };132 const waste = c.installed ? 0 : Math.max(0, c.wasteRate);133 if (c.installed && c.wasteRate > 0) warnings.push("pertes ignorées : prix installé (tout compris)");134 const qty = c.quantity * (1 + waste);135 const matC = it.isEquipment ? 0 : lineCents(qty, it.unitPrice);136 const eqItemC = it.isEquipment ? lineCents(qty, it.unitPrice) : 0;137 let labC = 0;138 let hourly: number | null = null;139 let rateSrc: string | null = null;140 if (!c.installed && c.labourHours > 0) {141 const r = resolver.labourRate(c.trade ?? it.defaultTrade);142 hourly = r?.rate ?? resolver.fallbackHourlyRate;143 rateSrc = r ? r.source : "taux de repli (hypothèse)";144 if (!r) warnings.push(`métier « ${c.trade ?? it.defaultTrade ?? "?"} » sans grille : taux de repli ${resolver.fallbackHourlyRate} $/h`);145 labC = lineCents(c.labourHours, hourly);146 } else if (c.installed && c.labourHours > 0) warnings.push("main-d'œuvre ignorée : prix installé (tout compris)");147 const eqC = sumCents([eqItemC, lineCents(c.equipmentHours, c.equipmentRate)]);148 const othC = toCents(c.subcontract + c.transport);149 return {150 ...base, unitPrice: it.unitPrice, material: fromCents(matC), hourlyRate: hourly, labour: fromCents(labC), equipment: fromCents(eqC), other: fromCents(othC),151 provenance: { basis: "catalogue", kind: it.kind, evidenceClass: it.kind === "observed" ? "supplier_quote" : it.kind === "official" ? "published_schedule" : null, source: it.source + (rateSrc ? ` · MO : ${rateSrc}` : ""), sourceUrl: it.sourceUrl, observedAt: it.observedAt, note: c.notes, confidence: it.confidence, observedShare: it.kind === "observed" || it.kind === "official" || it.kind === "indexed" ? 1 : 0 },152 warnings,153 };154}155156function unpriced(note: string): Provenance {157 return { basis: "catalogue", kind: "assumption", evidenceClass: null, source: "aucun prix", sourceUrl: null, observedAt: null, note, confidence: 0, observedShare: 0 };158}159