Repository Public
1// UQO Éval — Métré : base `data/metre.db` (SQLite, better-sqlite3) — schéma versionné, migrations forward-only. SERVEUR seulement.2/**3 * Base DISTINCTE de cost.db (catalogue de coûts, lue seulement) et de4 * vraiprix.db / immoka.db (jamais touchées) : le module peut être désactivé5 * (METRE_ENABLED=0) ou sa base supprimée sans effet sur le reste d'UQO Éval.6 * Réversibilité : chaque migration est un ALTER/CREATE additif ; la version est7 * dans metre_meta.schema_version ; un retour arrière = restaurer la copie de8 * sauvegarde (docs/construction/SCHEMA_AND_MIGRATIONS.md).9 */10import Database from "better-sqlite3";11import fs from "fs";12import path from "path";1314export const METRE_DB_SCHEMA_VERSION = 4;1516let db: Database.Database | null = null;1718export function metreEnabled(): boolean {19 return process.env.METRE_ENABLED !== "0" && process.env.METRE_ENABLED !== "false";20}2122export function metreDbPath(): string {23 return process.env.METRE_DB ?? path.join(process.cwd(), "data", "metre.db");24}2526/** Répertoire des fichiers (plans, pages PNG, fichiers bruts des sources). */27export function metreFilesDir(): string {28 return process.env.METRE_FILES_DIR ?? path.join(process.cwd(), "data", "metre-files");29}3031export function getMetreDb(): Database.Database {32 if (db) return db;33 const p = metreDbPath();34 fs.mkdirSync(path.dirname(p), { recursive: true });35 db = new Database(p);36 db.pragma("journal_mode = WAL");37 db.pragma("foreign_keys = ON");38 db.pragma("busy_timeout = 5000");39 migrate(db);40 return db;41}4243/** Tests : base isolée (":memory:" ou chemin temporaire). */44export function openMetreDbAt(p: string): Database.Database {45 const d = new Database(p);46 d.pragma("foreign_keys = ON");47 migrate(d);48 return d;49}5051const SCHEMA = `52CREATE TABLE IF NOT EXISTS metre_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);5354/* ---------------------------------------------------------- utilisateurs */55CREATE TABLE IF NOT EXISTS metre_users (56 id TEXT PRIMARY KEY,57 display_name TEXT NOT NULL,58 role TEXT NOT NULL CHECK(role IN ('student','instructor','admin','professional')),59 access_code_id TEXT,60 course TEXT,61 created_at TEXT NOT NULL DEFAULT (datetime('now')),62 last_seen_at TEXT63);64CREATE TABLE IF NOT EXISTS metre_access_codes (65 id TEXT PRIMARY KEY,66 code_hash TEXT UNIQUE NOT NULL,67 code_hint TEXT NOT NULL,68 role TEXT NOT NULL,69 label TEXT,70 course TEXT,71 max_uses INTEGER,72 uses INTEGER NOT NULL DEFAULT 0,73 expires_at TEXT,74 active INTEGER NOT NULL DEFAULT 1,75 created_by TEXT,76 created_at TEXT NOT NULL DEFAULT (datetime('now'))77);78CREATE TABLE IF NOT EXISTS metre_sessions (79 id TEXT PRIMARY KEY,80 token_hash TEXT UNIQUE NOT NULL,81 user_id TEXT NOT NULL REFERENCES metre_users(id) ON DELETE CASCADE,82 created_at TEXT NOT NULL DEFAULT (datetime('now')),83 expires_at TEXT NOT NULL,84 ip TEXT,85 user_agent TEXT86);87CREATE INDEX IF NOT EXISTS idx_metre_sessions_user ON metre_sessions(user_id);8889/* -------------------------------------------------------------- projets */90CREATE TABLE IF NOT EXISTS metre_projects (91 id TEXT PRIMARY KEY,92 owner_id TEXT NOT NULL REFERENCES metre_users(id),93 name TEXT NOT NULL,94 address TEXT,95 municipality TEXT,96 region_code TEXT,97 property_id TEXT,98 valuation_date TEXT NOT NULL,99 building_kind TEXT NOT NULL DEFAULT 'detached',100 stories REAL NOT NULL DEFAULT 1,101 unit_system TEXT NOT NULL DEFAULT 'metric',102 notes TEXT,103 is_template INTEGER NOT NULL DEFAULT 0,104 locked INTEGER NOT NULL DEFAULT 0,105 spec_json TEXT,106 course TEXT,107 deleted_at TEXT,108 created_at TEXT NOT NULL DEFAULT (datetime('now')),109 updated_at TEXT NOT NULL DEFAULT (datetime('now'))110);111CREATE INDEX IF NOT EXISTS idx_metre_projects_owner ON metre_projects(owner_id);112CREATE TABLE IF NOT EXISTS metre_project_members (113 project_id TEXT NOT NULL REFERENCES metre_projects(id) ON DELETE CASCADE,114 user_id TEXT NOT NULL REFERENCES metre_users(id) ON DELETE CASCADE,115 role TEXT NOT NULL CHECK(role IN ('viewer','editor')),116 added_by TEXT,117 created_at TEXT NOT NULL DEFAULT (datetime('now')),118 PRIMARY KEY(project_id, user_id)119);120121/* ---------------------------------------------------------------- plans */122CREATE TABLE IF NOT EXISTS metre_plan_files (123 id TEXT PRIMARY KEY,124 project_id TEXT NOT NULL REFERENCES metre_projects(id) ON DELETE CASCADE,125 filename TEXT NOT NULL,126 mime TEXT NOT NULL,127 bytes INTEGER NOT NULL,128 sha256 TEXT NOT NULL,129 storage_path TEXT NOT NULL,130 page_count INTEGER NOT NULL DEFAULT 0,131 uploaded_by TEXT,132 created_at TEXT NOT NULL DEFAULT (datetime('now'))133);134CREATE INDEX IF NOT EXISTS idx_metre_plan_files_project ON metre_plan_files(project_id);135CREATE TABLE IF NOT EXISTS metre_plan_pages (136 id TEXT PRIMARY KEY,137 file_id TEXT NOT NULL REFERENCES metre_plan_files(id) ON DELETE CASCADE,138 project_id TEXT NOT NULL REFERENCES metre_projects(id) ON DELETE CASCADE,139 page_no INTEGER NOT NULL,140 width_px INTEGER NOT NULL,141 height_px INTEGER NOT NULL,142 image_path TEXT NOT NULL,143 label TEXT,144 current_calibration_id TEXT,145 source TEXT NOT NULL DEFAULT 'import',146 level TEXT,147 meta_json TEXT,148 created_at TEXT NOT NULL DEFAULT (datetime('now')),149 UNIQUE(file_id, page_no)150);151CREATE TABLE IF NOT EXISTS metre_calibrations (152 id TEXT PRIMARY KEY,153 page_id TEXT NOT NULL REFERENCES metre_plan_pages(id) ON DELETE CASCADE,154 revision INTEGER NOT NULL,155 p1_x REAL NOT NULL, p1_y REAL NOT NULL, p2_x REAL NOT NULL, p2_y REAL NOT NULL,156 real_length REAL NOT NULL,157 unit TEXT NOT NULL,158 px_per_meter REAL NOT NULL,159 uncertainty_pct REAL NOT NULL,160 created_by TEXT NOT NULL,161 created_at TEXT NOT NULL DEFAULT (datetime('now')),162 UNIQUE(page_id, revision)163);164CREATE TABLE IF NOT EXISTS metre_plan_objects (165 id TEXT PRIMARY KEY,166 page_id TEXT NOT NULL REFERENCES metre_plan_pages(id) ON DELETE CASCADE,167 project_id TEXT NOT NULL REFERENCES metre_projects(id) ON DELETE CASCADE,168 kind TEXT NOT NULL,169 layer TEXT NOT NULL DEFAULT 'default',170 name TEXT NOT NULL,171 points_json TEXT NOT NULL,172 closed INTEGER NOT NULL DEFAULT 1,173 props_json TEXT NOT NULL DEFAULT '{}',174 deleted_at TEXT,175 created_at TEXT NOT NULL DEFAULT (datetime('now')),176 updated_at TEXT NOT NULL DEFAULT (datetime('now'))177);178CREATE INDEX IF NOT EXISTS idx_metre_objects_page ON metre_plan_objects(page_id);179180/* -------------------------------------------------------------- registre */181CREATE TABLE IF NOT EXISTS metre_measurements (182 id TEXT PRIMARY KEY,183 project_id TEXT NOT NULL REFERENCES metre_projects(id) ON DELETE CASCADE,184 object_id TEXT REFERENCES metre_plan_objects(id) ON DELETE SET NULL,185 page_id TEXT,186 name TEXT NOT NULL,187 formula TEXT NOT NULL,188 formula_text TEXT NOT NULL,189 inputs_json TEXT NOT NULL DEFAULT '{}',190 value REAL NOT NULL,191 unit TEXT NOT NULL,192 calibration_revision INTEGER,193 stale INTEGER NOT NULL DEFAULT 0,194 confidence TEXT NOT NULL DEFAULT 'measured',195 verified_by TEXT,196 verified_at TEXT,197 lineage_json TEXT NOT NULL DEFAULT '[]',198 formula_version TEXT NOT NULL,199 computed_at TEXT NOT NULL,200 deleted_at TEXT,201 created_at TEXT NOT NULL DEFAULT (datetime('now'))202);203CREATE INDEX IF NOT EXISTS idx_metre_measurements_project ON metre_measurements(project_id);204CREATE INDEX IF NOT EXISTS idx_metre_measurements_object ON metre_measurements(object_id);205206/* ------------------------------------------------------------ assemblages */207CREATE TABLE IF NOT EXISTS metre_assemblies (208 id TEXT PRIMARY KEY,209 project_id TEXT REFERENCES metre_projects(id) ON DELETE CASCADE,210 code TEXT NOT NULL,211 name_fr TEXT NOT NULL,212 name_en TEXT NOT NULL,213 unit TEXT NOT NULL,214 output_quantity REAL NOT NULL DEFAULT 1,215 category TEXT NOT NULL DEFAULT 'custom',216 version INTEGER NOT NULL DEFAULT 1,217 components_json TEXT NOT NULL DEFAULT '[]',218 exclusions TEXT,219 created_by TEXT NOT NULL,220 deleted_at TEXT,221 created_at TEXT NOT NULL DEFAULT (datetime('now')),222 updated_at TEXT NOT NULL DEFAULT (datetime('now')),223 UNIQUE(project_id, code)224);225/* historique immuable des versions de recettes */226CREATE TABLE IF NOT EXISTS metre_assembly_versions (227 id TEXT PRIMARY KEY,228 assembly_id TEXT NOT NULL REFERENCES metre_assemblies(id) ON DELETE CASCADE,229 version INTEGER NOT NULL,230 snapshot_json TEXT NOT NULL,231 created_by TEXT NOT NULL,232 created_at TEXT NOT NULL DEFAULT (datetime('now')),233 UNIQUE(assembly_id, version)234);235236/* ------------------------------------------------------------- estimation */237CREATE TABLE IF NOT EXISTS metre_estimates (238 id TEXT PRIMARY KEY,239 project_id TEXT NOT NULL REFERENCES metre_projects(id) ON DELETE CASCADE,240 name TEXT NOT NULL,241 params_json TEXT NOT NULL,242 lines_json TEXT NOT NULL DEFAULT '[]',243 deleted_at TEXT,244 created_by TEXT NOT NULL,245 created_at TEXT NOT NULL DEFAULT (datetime('now')),246 updated_at TEXT NOT NULL DEFAULT (datetime('now'))247);248CREATE INDEX IF NOT EXISTS idx_metre_estimates_project ON metre_estimates(project_id);249/* révisions IMMUABLES (jamais UPDATE/DELETE applicatif) */250CREATE TABLE IF NOT EXISTS metre_estimate_versions (251 id TEXT PRIMARY KEY,252 estimate_id TEXT NOT NULL REFERENCES metre_estimates(id) ON DELETE CASCADE,253 project_id TEXT NOT NULL,254 number INTEGER NOT NULL,255 label TEXT NOT NULL,256 scenario TEXT,257 lines_json TEXT NOT NULL,258 params_json TEXT NOT NULL,259 result_json TEXT NOT NULL,260 pricing_snapshot_json TEXT NOT NULL,261 catalogue_version TEXT NOT NULL,262 formula_version TEXT NOT NULL,263 content_hash TEXT NOT NULL,264 total REAL NOT NULL,265 created_by TEXT NOT NULL,266 created_at TEXT NOT NULL DEFAULT (datetime('now')),267 UNIQUE(estimate_id, number)268);269270/* ------------------------------------------------------------- évaluation */271CREATE TABLE IF NOT EXISTS metre_valuation_cases (272 id TEXT PRIMARY KEY,273 project_id TEXT NOT NULL REFERENCES metre_projects(id) ON DELETE CASCADE,274 estimate_version_id TEXT REFERENCES metre_estimate_versions(id),275 name TEXT NOT NULL,276 input_json TEXT NOT NULL,277 result_json TEXT,278 created_by TEXT NOT NULL,279 deleted_at TEXT,280 created_at TEXT NOT NULL DEFAULT (datetime('now')),281 updated_at TEXT NOT NULL DEFAULT (datetime('now'))282);283284/* --------------------------------------------------------------- sources */285CREATE TABLE IF NOT EXISTS metre_sources (286 key TEXT PRIMARY KEY,287 name TEXT NOT NULL,288 publisher TEXT NOT NULL,289 url TEXT,290 license TEXT NOT NULL,291 license_url TEXT,292 evidence_class TEXT NOT NULL,293 access TEXT NOT NULL, -- api | bulk | manual_import294 notes TEXT,295 active INTEGER NOT NULL DEFAULT 1,296 last_run_at TEXT,297 last_status TEXT,298 last_error TEXT299);300CREATE TABLE IF NOT EXISTS metre_source_documents (301 id TEXT PRIMARY KEY,302 source_key TEXT NOT NULL REFERENCES metre_sources(key),303 url TEXT,304 document_id TEXT,305 title TEXT,306 publisher TEXT,307 publication_date TEXT,308 effective_date TEXT,309 retrieved_at TEXT NOT NULL,310 license TEXT,311 content_hash TEXT NOT NULL,312 raw_path TEXT,313 bytes INTEGER,314 parser_version TEXT NOT NULL,315 qa_status TEXT NOT NULL DEFAULT 'new', -- new | parsed | rejected | published316 qa_notes TEXT,317 records INTEGER NOT NULL DEFAULT 0,318 UNIQUE(source_key, content_hash)319);320CREATE INDEX IF NOT EXISTS idx_metre_docs_source ON metre_source_documents(source_key);321CREATE TABLE IF NOT EXISTS metre_price_observations (322 id TEXT PRIMARY KEY,323 source_key TEXT NOT NULL REFERENCES metre_sources(key),324 document_id TEXT NOT NULL REFERENCES metre_source_documents(id) ON DELETE CASCADE,325 evidence_class TEXT NOT NULL,326 external_id TEXT,327 resource_code TEXT,328 original_description TEXT NOT NULL,329 original_unit TEXT,330 original_amount REAL,331 normalized_unit TEXT,332 normalized_amount REAL,333 currency TEXT NOT NULL DEFAULT 'CAD',334 includes_material INTEGER, includes_labour INTEGER, includes_equipment INTEGER,335 tax_basis TEXT, -- before_tax | after_tax | unknown336 geography TEXT,337 region_code TEXT,338 buyer TEXT,339 classification_scheme TEXT,340 classification_code TEXT,341 classification_label TEXT,342 date_basis TEXT,343 effective_from TEXT,344 effective_to TEXT,345 license TEXT,346 extraction_method TEXT NOT NULL,347 lineage_json TEXT NOT NULL DEFAULT '{}',348 verification_status TEXT NOT NULL DEFAULT 'unverified', -- unverified | verified | rejected349 uncertainty_note TEXT,350 source_url TEXT,351 created_at TEXT NOT NULL DEFAULT (datetime('now')),352 UNIQUE(source_key, external_id)353);354CREATE INDEX IF NOT EXISTS idx_metre_obs_source ON metre_price_observations(source_key, evidence_class);355CREATE INDEX IF NOT EXISTS idx_metre_obs_class ON metre_price_observations(classification_code);356CREATE TABLE IF NOT EXISTS metre_quality_flags (357 id INTEGER PRIMARY KEY AUTOINCREMENT,358 entity_type TEXT NOT NULL,359 entity_id TEXT NOT NULL,360 severity TEXT NOT NULL,361 code TEXT NOT NULL,362 message TEXT NOT NULL,363 status TEXT NOT NULL DEFAULT 'open',364 created_at TEXT NOT NULL DEFAULT (datetime('now')),365 resolved_at TEXT366);367CREATE TABLE IF NOT EXISTS metre_ingest_runs (368 id INTEGER PRIMARY KEY AUTOINCREMENT,369 source_key TEXT NOT NULL,370 started_at TEXT NOT NULL,371 finished_at TEXT,372 status TEXT NOT NULL DEFAULT 'running',373 documents INTEGER NOT NULL DEFAULT 0,374 records INTEGER NOT NULL DEFAULT 0,375 skipped INTEGER NOT NULL DEFAULT 0,376 errors INTEGER NOT NULL DEFAULT 0,377 log_json TEXT,378 triggered_by TEXT379);380381/* ----------------------------------------------------------- enseignement */382CREATE TABLE IF NOT EXISTS metre_assignments (383 id TEXT PRIMARY KEY,384 course TEXT NOT NULL,385 title TEXT NOT NULL,386 instructions TEXT,387 template_project_id TEXT REFERENCES metre_projects(id),388 due_at TEXT,389 answer_key_json TEXT, -- JAMAIS renvoyé à un étudiant390 created_by TEXT NOT NULL,391 active INTEGER NOT NULL DEFAULT 1,392 created_at TEXT NOT NULL DEFAULT (datetime('now'))393);394CREATE TABLE IF NOT EXISTS metre_submissions (395 id TEXT PRIMARY KEY,396 assignment_id TEXT NOT NULL REFERENCES metre_assignments(id) ON DELETE CASCADE,397 user_id TEXT NOT NULL REFERENCES metre_users(id),398 project_id TEXT NOT NULL REFERENCES metre_projects(id),399 estimate_version_id TEXT REFERENCES metre_estimate_versions(id),400 submitted_at TEXT NOT NULL DEFAULT (datetime('now')),401 grade REAL,402 feedback TEXT,403 graded_by TEXT,404 graded_at TEXT,405 UNIQUE(assignment_id, user_id)406);407408/* ------------------------------------------------------------------ audit */409CREATE TABLE IF NOT EXISTS metre_audit_events (410 id INTEGER PRIMARY KEY AUTOINCREMENT,411 at TEXT NOT NULL DEFAULT (datetime('now')),412 user_id TEXT,413 role TEXT,414 action TEXT NOT NULL,415 entity_type TEXT,416 entity_id TEXT,417 project_id TEXT,418 details_json TEXT,419 ip TEXT420);421CREATE INDEX IF NOT EXISTS idx_metre_audit_project ON metre_audit_events(project_id, at);422CREATE TABLE IF NOT EXISTS metre_report_jobs (423 id TEXT PRIMARY KEY,424 project_id TEXT NOT NULL,425 estimate_version_id TEXT,426 kind TEXT NOT NULL,427 lang TEXT NOT NULL DEFAULT 'fr',428 requested_by TEXT NOT NULL,429 status TEXT NOT NULL DEFAULT 'done',430 bytes INTEGER,431 created_at TEXT NOT NULL DEFAULT (datetime('now'))432);433`;434435/* v2 : suggestions d'IA révisables (jamais appliquées automatiquement) */436const SCHEMA_V2 = `437CREATE TABLE IF NOT EXISTS metre_ai_suggestions (438 id TEXT PRIMARY KEY,439 project_id TEXT REFERENCES metre_projects(id) ON DELETE CASCADE,440 kind TEXT NOT NULL, -- assembly_mapping | missing_scope | explain | anomalies | classify441 target_type TEXT,442 target_id TEXT,443 model TEXT NOT NULL,444 prompt_version TEXT NOT NULL,445 input_hash TEXT NOT NULL,446 input_summary TEXT,447 output_json TEXT NOT NULL,448 evidence_json TEXT,449 status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | rejected450 review_note TEXT,451 created_by TEXT NOT NULL,452 reviewed_by TEXT,453 reviewed_at TEXT,454 input_tokens INTEGER NOT NULL DEFAULT 0,455 output_tokens INTEGER NOT NULL DEFAULT 0,456 estimated_cost_usd REAL,457 latency_ms INTEGER,458 created_at TEXT NOT NULL DEFAULT (datetime('now'))459);460CREATE INDEX IF NOT EXISTS idx_metre_ai_project ON metre_ai_suggestions(project_id, created_at);461`;462463function migrate(d: Database.Database): void {464 d.exec(SCHEMA);465 const cur = Number((d.prepare("SELECT value FROM metre_meta WHERE key='schema_version'").get() as { value: string } | undefined)?.value ?? 0);466 // migrations forward-only, additives et tolérantes467 if (cur < 2) d.exec(SCHEMA_V2);468 if (cur < 3) {469 for (const sql of ["ALTER TABLE metre_projects ADD COLUMN locked INTEGER NOT NULL DEFAULT 0", "ALTER TABLE metre_plan_pages ADD COLUMN source TEXT NOT NULL DEFAULT 'import'", "ALTER TABLE metre_plan_pages ADD COLUMN level TEXT", "ALTER TABLE metre_plan_pages ADD COLUMN meta_json TEXT"]) { try { d.exec(sql); } catch { /* déjà présent */ } }470 }471 if (cur < 4) { try { d.exec("ALTER TABLE metre_projects ADD COLUMN spec_json TEXT"); } catch { /* déjà présent */ } }472 d.prepare("INSERT INTO metre_meta(key,value) VALUES('schema_version',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(METRE_DB_SCHEMA_VERSION));473 seedSources(d);474}475476/** Registre des sources (métadonnées de licence) — idempotent. */477function seedSources(d: Database.Database): void {478 const up = d.prepare(`INSERT INTO metre_sources(key,name,publisher,url,license,license_url,evidence_class,access,notes,active)479 VALUES(@key,@name,@publisher,@url,@license,@licenseUrl,@evidenceClass,@access,@notes,@active)480 ON CONFLICT(key) DO UPDATE SET name=excluded.name, publisher=excluded.publisher, url=excluded.url, license=excluded.license, license_url=excluded.license_url, evidence_class=excluded.evidence_class, access=excluded.access, notes=excluded.notes`);481 for (const s of SOURCE_REGISTRY) up.run({ ...s, active: s.active ? 1 : 0 });482}483484export interface SourceRegistryEntry {485 key: string; name: string; publisher: string; url: string | null; license: string; licenseUrl: string | null; evidenceClass: string; access: "api" | "bulk" | "manual_import"; notes: string; active: boolean;486}487488export const SOURCE_REGISTRY: SourceRegistryEntry[] = [489 { key: "seao", name: "SEAO — avis, octrois et contrats (OCDS)", publisher: "Secrétariat du Conseil du trésor (Québec)", url: "https://www.donneesquebec.ca/recherche/dataset/systeme-electronique-dappel-doffres-seao", license: "CC-BY 4.0", licenseUrl: "https://www.donneesquebec.ca/fr/licence/#cc-by", evidenceClass: "awarded_contract_total", access: "bulk", notes: "Fichiers JSON hebdomadaires au standard OCDS 1.1 (releases : tender, awards, contracts, items UNSPSC). Un montant de contrat n'est JAMAIS un prix unitaire : classe d'évidence « total de contrat octroyé ».", active: true },490 { key: "mtl_permits", name: "Ville de Montréal — permis de construction, transformation et démolition", publisher: "Ville de Montréal", url: "https://donnees.montreal.ca/dataset/permis-construction", license: "CC-BY 4.0", licenseUrl: "https://creativecommons.org/licenses/by/4.0/", evidenceClass: "permit_declared_value", access: "bulk", notes: "Valeur des travaux DÉCLARÉE au permis — distincte du coût réel ; sans valeur légale selon la Ville.", active: true },491 { key: "mtl_dtni", name: "Ville de Montréal — DTNI-11A répertoire des prix unitaires / DTNI-11B taux horaires", publisher: "Ville de Montréal (Service des infrastructures)", url: "https://ville.montreal.qc.ca/executiontravaux/", license: "Document public de la Ville (conditions du site) — import manuel", licenseUrl: null, evidenceClass: "published_schedule", access: "manual_import", notes: "Site protégé (HTTP 403 pour les accès automatisés le 2026-10-28) : téléchargement manuel du PDF/XLSX par un administrateur, puis import CSV. Prix d'INFRASTRUCTURE municipale (≠ prix résidentiels installés).", active: true },492 { key: "mtmd", name: "MTMD — prix unitaires et bordereaux (infrastructure routière)", publisher: "Ministère des Transports et de la Mobilité durable", url: "https://www.transports.gouv.qc.ca/", license: "À confirmer — import manuel", licenseUrl: null, evidenceClass: "published_schedule", access: "manual_import", notes: "Aucun jeu de prix unitaires trouvé sur Données Québec (2026-10-28). Import manuel seulement, étiqueté infrastructure.", active: false },493 { key: "statcan_bcpi", name: "Statistique Canada — 18-10-0289-01 indices des prix de la construction de bâtiments", publisher: "Statistique Canada", url: "https://www150.statcan.gc.ca/t1/tbl1/fr/tv.action?pid=1810028901", license: "Licence ouverte de Statistique Canada", licenseUrl: "https://www.statcan.gc.ca/fr/reference/licence", evidenceClass: "published_schedule", access: "api", notes: "Déjà ingéré par le connecteur `statcan` de la base de coûts (cost.db) ; le module l'utilise pour l'actualisation.", active: true },494 { key: "uqo_catalogue", name: "UQO Éval — catalogue de coûts (APCHQ, CCQ, Canac, BMR, Patrick Morin, StatCan, références)", publisher: "UQO Éval (cost.db)", url: "/cout", license: "voir cost_sources (licence par source)", licenseUrl: null, evidenceClass: "supplier_quote", access: "api", notes: "Articles, assemblages, grilles de main-d'œuvre et indices lus en direct dans cost.db (lecture seule).", active: true },495];496497export function metaGet(key: string, d: Database.Database = getMetreDb()): string | null {498 return (d.prepare("SELECT value FROM metre_meta WHERE key=?").get(key) as { value: string } | undefined)?.value ?? null;499}500export function metaSet(key: string, value: string, d: Database.Database = getMetreDb()): void {501 d.prepare("INSERT INTO metre_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);502}503504export function audit(d: Database.Database, e: { userId: string | null; role: string | null; action: string; entityType?: string; entityId?: string; projectId?: string | null; details?: unknown; ip?: string | null }): void {505 d.prepare("INSERT INTO metre_audit_events(user_id,role,action,entity_type,entity_id,project_id,details_json,ip) VALUES(?,?,?,?,?,?,?,?)")506 .run(e.userId, e.role, e.action, e.entityType ?? null, e.entityId ?? null, e.projectId ?? null, e.details == null ? null : JSON.stringify(e.details).slice(0, 4000), e.ip ?? null);507}508509export const nowIso = (): string => new Date().toISOString();510