SPB Git

spb/uqo-eval

Public
8.3 KB · 159 lines typescript
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
Files · main
Menu

Theme