Repository Public
1// UQO Éval — Quantity Survey : données RSMeans — unités, prix extraits vs par facteur, substitution de MO, localisation d'un projet, recherche,2// devis d'une ligne d'estimation (coûts bare → majorations du module). Base RSMeans de test construite ici avec le schéma de scripts/rsmeans-build.py.3import { afterAll, beforeAll, describe, expect, it } from "vitest";4import Database from "better-sqlite3";5import fs from "fs";6import os from "os";7import path from "path";89const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rsm-"));10process.env.RSMEANS_DB = path.join(tmp, "rsmeans.db");11process.env.METRE_DB = path.join(tmp, "metre.db"); process.env.METRE_FILES_DIR = path.join(tmp, "files"); process.env.COST_DB = path.join(tmp, "cost.db");1213function fixture() {14 const d = new Database(process.env.RSMEANS_DB!);15 d.exec(`CREATE TABLE rsm_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);16CREATE TABLE rsm_books(id TEXT PRIMARY KEY, name TEXT NOT NULL, labor_types TEXT NOT NULL);17CREATE TABLE rsm_locations(id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE, name TEXT NOT NULL, cci_code TEXT, country TEXT NOT NULL, state TEXT NOT NULL, full INTEGER NOT NULL, lat REAL, lng REAL);18CREATE TABLE rsm_lines(line TEXT PRIMARY KEY, mf95 TEXT, division TEXT NOT NULL, short_desc TEXT NOT NULL, full_desc TEXT NOT NULL, unit TEXT, crew TEXT, daily_output REAL, labor_hours REAL, format TEXT, percent_line INTEGER NOT NULL DEFAULT 0, green INTEGER NOT NULL DEFAULT 0, indent INTEGER NOT NULL DEFAULT 0, books TEXT NOT NULL, refs TEXT);19CREATE TABLE rsm_prices(line TEXT NOT NULL, location_id INTEGER NOT NULL, labor TEXT NOT NULL, book TEXT NOT NULL, material REAL, material_op REAL, labor_cost REAL, labor_op REAL, equipment REAL, equipment_op REAL, total REAL, total_op REAL, total_inhouse REAL, install REAL, install_op REAL, hourly_oper REAL, rent_day REAL, rent_week REAL, rent_month REAL, crew_equip REAL, PRIMARY KEY(line, location_id, labor));20CREATE TABLE rsm_factors(location_id INTEGER NOT NULL, labor TEXT NOT NULL, division TEXT NOT NULL, material_f REAL, labor_f REAL, equipment_f REAL, n_material INTEGER, n_labor INTEGER, n_equipment INTEGER, sd_material REAL, sd_labor REAL, sd_equipment REAL, method TEXT NOT NULL, PRIMARY KEY(location_id, labor, division));21CREATE VIRTUAL TABLE rsm_fts USING fts5(line UNINDEXED, text, tokenize='unicode61 remove_diacritics 2');`);22 d.prepare("INSERT INTO rsm_meta VALUES (?,?)").run("release", JSON.stringify("Year 2026 Quarter 4"));23 d.prepare("INSERT INTO rsm_meta VALUES (?,?)").run("extracted_at", JSON.stringify("2026-10-28"));24 d.prepare("INSERT INTO rsm_locations VALUES (?,?,?,?,?,?,?,?,?)").run(431, "NA", "National Average", null, "NA", "US", 1, null, null);25 d.prepare("INSERT INTO rsm_locations VALUES (?,?,?,?,?,?,?,?,?)").run(238, "GATINEAU", "GATINEAU (C57)", "C57", "CA", "QC", 1, 45.47, -75.7);26 d.prepare("INSERT INTO rsm_locations VALUES (?,?,?,?,?,?,?,?,?)").run(586, "SHERBROOKE", "SHERBROOKE (C59)", "C59", "CA", "QC", 0, 45.4, -71.9);27 const L = d.prepare("INSERT INTO rsm_lines VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)");28 L.run("061110400010", null, "06", "WALL FRAMING", "Wall framing", "", null, null, null, "H", 0, 0, 0, "12", null);29 L.run("061110400100", "061104001000", "06", "2\" x 6\" studs, 16\" O.C.", "Wood framing, wall, studs, 2 x 6, 16 in. O.C., 8 ft high", "S.F.", "2 Carp", 1000, 0.016, "M", 0, 0, 1, "3,12,14", "R061110-30");30 L.run("073113100400", null, "07", "Asphalt shingles, architectural", "Roofing, asphalt shingles, architectural, laminated", "Sq.", "1 Rofc", 5, 1.6, "M", 0, 0, 1, "3,12", null);31 L.run("033053404300", null, "03", "Footings, 2' x 1'", "Concrete in place, footings, continuous, 2 ft x 1 ft", "C.Y.", "C14C", 55, 2.1, "M", 0, 0, 2, "12", null);32 L.run("011131100060", null, "01", "Minimum", "Architectural fees, minimum", "Project", null, null, null, "M", 1, 0, 2, "12", null);33 for (const l of ["061110400100", "073113100400", "033053404300"]) d.prepare("INSERT INTO rsm_fts VALUES (?,?)").run(l, `${l} ${(d.prepare("SELECT short_desc, full_desc FROM rsm_lines WHERE line=?").get(l) as { short_desc: string; full_desc: string }).full_desc}`);34 const P = d.prepare("INSERT INTO rsm_prices(line, location_id, labor, book, material, material_op, labor_cost, labor_op, equipment, equipment_op, total, total_op) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");35 P.run("061110400100", 431, "STD", "12", 1.00, 1.10, 1.20, 1.80, null, 0, 2.20, 2.90);36 P.run("061110400100", 431, "RES", "3", 1.00, 1.10, 1.00, 1.50, null, 0, 2.00, 2.60);37 P.run("061110400100", 238, "STD", "12", 1.45, 1.60, 1.05, 1.58, null, 0, 2.50, 3.18);38 P.run("073113100400", 431, "STD", "12", 150, 165, 100, 150, null, 0, 250, 315);39 P.run("073113100400", 238, "STD", "12", 210, 231, 90, 135, null, 0, 300, 366);40 P.run("033053404300", 431, "STD", "12", 200, 220, 80, 120, 10, 11, 290, 351);41 d.prepare("INSERT INTO rsm_factors VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(586, "STD", "06", 1.40, 0.90, 1.02, 120, 110, 20, 0.02, 0.02, 0.01, "sample");42 d.prepare("INSERT INTO rsm_factors VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(586, "STD", "07", 1.35, 0.88, null, 60, 55, 2, 0.03, 0.02, null, "sample");43 d.close();44}4546beforeAll(() => fixture());47afterAll(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } });4849describe("RSMeans — unités", () => {50 it("convertit les unités RSMeans en unités du module avec diviseur", async () => {51 const { rsmUnit } = await import("./rsmeans");52 expect(rsmUnit("S.F.")).toEqual({ unit: "ft2", divisor: 1, note: null });53 expect(rsmUnit("Sq.")!.divisor).toBe(100); expect(rsmUnit("Sq.")!.unit).toBe("ft2");54 expect(rsmUnit("M.S.F.")!.divisor).toBe(1000); expect(rsmUnit("C.L.F.")).toEqual({ unit: "ft", divisor: 100, note: "100 pi lin." });55 expect(rsmUnit("L.C.Y.")!.unit).toBe("yd3"); expect(rsmUnit("Ea.")!.unit).toBe("unit"); expect(rsmUnit("Ton")!.note).toContain("Ton");56 expect(rsmUnit("%")).toBeNull(); expect(rsmUnit("")).toBeNull(); expect(rsmUnit("Hr.")!.unit).toBe("h");57 });58});5960describe("RSMeans — prix et localisation", () => {61 it("ville extraite en entier : prix tel quel ; MO demandée absente → substitution signalée", async () => {62 const { rsmPrice } = await import("./rsmeans");63 const p = rsmPrice("061110400100", 238, "STD")!; expect(p.method).toBe("full"); expect(p.material).toBe(1.45); expect(p.total).toBe(2.5); expect(p.totalOp).toBe(3.18);64 const r = rsmPrice("061110400100", 431, "RES")!; expect(r.labor).toBe("RES"); expect(r.labor_cost).toBe(1.0);65 const f = rsmPrice("061110400100", 431, "FMR")!; expect(f.labor).toBe("STD"); expect(f.laborRequested).toBe("FMR");66 expect(rsmPrice("000000000000", 238, "STD")).toBeNull();67 });68 it("ville échantillonnée : national × facteur de division (matériau, MO, équipement), repli sur la moyenne des facteurs", async () => {69 const { rsmPrice } = await import("./rsmeans");70 const p = rsmPrice("061110400100", 586, "STD")!; expect(p.method).toBe("factor"); expect(p.material).toBe(1.4); expect(p.labor_cost).toBe(1.08); expect(p.total).toBe(2.48); expect(p.factor!.material).toBe(1.4);71 const r = rsmPrice("073113100400", 586, "STD")!; expect(r.material).toBeCloseTo(202.5, 2); expect(r.equipment).toBeNull();72 const res = rsmPrice("061110400100", 586, "RES")!; expect(res.labor).toBe("RES"); expect(res.material).toBe(1.4); // facteur STD réutilisé faute de facteur RES73 const c = rsmPrice("033053404300", 586, "STD")!; expect(c.method).toBe("factor"); expect(c.material).toBeCloseTo(200 * 1.375, 2); // division 03 absente → moyenne des facteurs74 });75 it("localise un projet par municipalité, puis région, sinon Montréal", async () => {76 const { rsmLocationForProject } = await import("./rsmeans");77 expect(rsmLocationForProject({ municipality: "Gatineau", regionCode: null, address: null })).toEqual({ id: 238, reason: "municipality" });78 expect(rsmLocationForProject({ municipality: "Hull", regionCode: "QC-GAT", address: null }).id).toBe(722);79 expect(rsmLocationForProject({ municipality: null, regionCode: "QC-QUE", address: "12 rue X, Lévis" }).id).toBe(516);80 expect(rsmLocationForProject({ municipality: null, regionCode: "QC-SHE", address: null })).toEqual({ id: 586, reason: "region" });81 expect(rsmLocationForProject({ municipality: "Val-d'Or", regionCode: "QC-XYZ", address: null })).toEqual({ id: 419, reason: "default" });82 });83 it("recherche plein texte et par numéro ; en-têtes et lignes % exclus par défaut", async () => {84 const { rsmSearch } = await import("./rsmeans");85 expect(rsmSearch("wall studs 2 x 6").map((l) => l.line)).toEqual(["061110400100"]);86 expect(rsmSearch("0611").map((l) => l.line)).toEqual(["061110400100"]);87 expect(rsmSearch("", { division: "06", priced: false }).length).toBe(2);88 expect(rsmSearch("", { division: "01" }).length).toBe(0);89 });90});9192describe("RSMeans — devis d'une ligne d'estimation", () => {93 it("coûts bare par unité du module (division par le diviseur d'unité), provenance « barème publié », ville auto du projet", async () => {94 const { rsmeansQuote } = await import("./estimates");95 const { defaultParams } = await import("./estimate-engine");96 const params = defaultParams("2026-10-28", "QC-GAT");97 const q = rsmeansQuote({ id: "x", wbs: "07", description: "", assemblyCode: null, measurementId: null, quantity: 100, unit: "m2", adjustmentFactor: 1, basis: "rsmeans", unitPrice: null, installed: false, assumptionNote: null, excluded: false, sortOrder: 0, rsmeansLine: "073113100400" }, { municipality: "Gatineau", regionCode: null, address: null }, params)!;98 expect(q.unit).toBe("ft2"); expect(q.material).toBeCloseTo(2.1, 6); expect(q.labour).toBeCloseTo(0.9, 6); expect(q.provenance.basis).toBe("rsmeans"); expect(q.provenance.evidenceClass).toBe("published_schedule"); expect(q.provenance.source).toContain("GATINEAU"); expect(q.warnings).toContain("square (toiture) = 100 pi²");99 const s = rsmeansQuote({ id: "y", wbs: "06", description: "", assemblyCode: null, measurementId: null, quantity: 1, unit: "ft2", adjustmentFactor: 1, basis: "rsmeans", unitPrice: null, installed: false, assumptionNote: null, excluded: false, sortOrder: 0, rsmeansLine: "061110400100" }, null, { ...params, rsmeansLocationId: 586, rsmeansLabor: "FMR" })!;100 expect(s.provenance.source).toContain("facteur"); expect(s.warnings!.some((w) => w.includes("substitué"))).toBe(true); expect(s.provenance.confidence).toBe(72);101 expect(rsmeansQuote({ id: "z", wbs: "01", description: "", assemblyCode: null, measurementId: null, quantity: 1, unit: "unit", adjustmentFactor: 1, basis: "rsmeans", unitPrice: null, installed: false, assumptionNote: null, excluded: false, sortOrder: 0, rsmeansLine: "011131100060" }, null, params)).toBeNull(); // ligne % sans prix102 });103 it("moteur complet : ligne rsmeans en m² convertie en pi², majorations appliquées sur le bare", async () => {104 const db = await import("./db"); const auth = await import("./auth"); const projects = await import("./projects"); const estimates = await import("./estimates");105 const d = db.getMetreDb(); const ctx = { db: d, user: auth.anonymousLogin(d, { ip: null, userAgent: null }).user, ip: null };106 const p = projects.createProject(ctx, { name: "RSM", regionCode: "QC-GAT", municipality: "Gatineau", valuationDate: "2026-10-28" });107 const e = estimates.createEstimate(ctx, p.id, "E", "QC-GAT", "2026-10-28");108 const upd = estimates.updateEstimate(ctx, e.id, { params: { generalConditionsPct: 0, overheadPct: 0, profitPct: 0, contingencyPct: 0 }, lines: [{ wbs: "07", description: "Bardeaux", basis: "rsmeans", rsmeansLine: "073113100400", quantity: 100, unit: "m2", adjustmentFactor: 1 }] });109 const { result } = estimates.computeEstimateRow(d, upd);110 const l = result.lines[0]; expect(l.basis).toBe("rsmeans"); expect(l.unit).toBe("ft2"); expect(l.quantity).toBeCloseTo(1076.39, 1); expect(l.unitDirect).toBe(3); // (210 + 90) / 100 pi²111 expect(result.totals.direct).toBeCloseTo(3229.17, 0); expect(result.coverage.linesPriced).toBe(1);112 });113});114