Repository Public
1// UQO Éval — Métré : service des plans (fichiers, pages, calibrations révisionnées, objets, registre de mesures). SERVEUR seulement.2/**3 * Stockage : data/metre-files/<projet>/<fichier>/original.<ext> + page-N.png.4 * Le rendu PDF → PNG se fait dans le NAVIGATEUR (pdfjs-dist, Apache-2.0) : le5 * client envoie l'original (conservé, hash SHA-256) et une image PNG par page ;6 * le serveur valide les types (signature magique), les tailles et les dimensions.7 */8import { createHash, randomUUID } from "crypto";9import fs from "fs";10import path from "path";11import type Database from "better-sqlite3";12import sharp from "sharp";13import { audit, metreFilesDir, nowIso } from "./db";14import { HttpError, type Ctx } from "./auth";15import { makeCalibration } from "./geometry";16import { FORMULA_VERSION, computeMeasure, defaultFormulaFor, formulasFor, isStale } from "./measure";17import type { Calibration, FormulaKey, LengthUnit, MeasureUnit, Measurement, ObjectKind, ObjectProps, PlanFile, PlanObject, PlanPage, Point } from "./types";1819export const MAX_FILE_BYTES = 60 * 1024 * 1024;20export const MAX_PAGE_BYTES = 25 * 1024 * 1024;21export const MAX_PAGES = 60;22export const MAX_PAGE_PX = 8000;23const KINDS: ObjectKind[] = ["room", "slab", "wall", "opening", "roof", "foundation", "line", "area", "annotation", "dimension", "text", "stairs", "column", "footprint", "lot"];24const LEN_UNITS: LengthUnit[] = ["m", "ft", "mm", "cm", "in"];2526function sniff(buf: Buffer): "application/pdf" | "image/png" | "image/jpeg" | null {27 if (buf.length >= 5 && buf.subarray(0, 5).toString("latin1") === "%PDF-") return "application/pdf";28 if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png";29 if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";30 return null;31}3233const safeName = (s: string) => s.normalize("NFKD").replace(/[^\w.\- ]+/g, "_").slice(0, 120) || "plan";3435function defaultLayer(kind: ObjectKind): string {36 switch (kind) { case "wall": case "column": return "murs"; case "opening": return "ouvertures"; case "room": return "pièces"; case "dimension": case "text": case "annotation": return "cotes"; case "footprint": case "lot": return "site"; case "slab": case "foundation": return "fondations"; case "roof": return "toiture"; default: return "default"; }37}3839/* ---------------------------------------------------------------- fichiers */4041export async function storePlanFile(ctx: Ctx, projectId: string, p: { filename: string; original: Buffer; pages: { pageNo: number; png: Buffer }[] }): Promise<{ file: PlanFile; pages: PlanPage[] }> {42 const mime = sniff(p.original);43 if (!mime) throw new HttpError(415, "Format refusé : PDF, PNG ou JPEG seulement (signature vérifiée).");44 if (p.original.length > MAX_FILE_BYTES) throw new HttpError(413, `Fichier trop volumineux (max ${MAX_FILE_BYTES / 1048576} Mo).`);45 if (!p.pages.length || p.pages.length > MAX_PAGES) throw new HttpError(400, `Entre 1 et ${MAX_PAGES} pages rendues requises.`);46 const sha = createHash("sha256").update(p.original).digest("hex");47 const id = randomUUID();48 const dir = path.join(metreFilesDir(), projectId, id);49 fs.mkdirSync(dir, { recursive: true });50 const ext = mime === "application/pdf" ? "pdf" : mime === "image/png" ? "png" : "jpg";51 const origPath = path.join(dir, `original.${ext}`);52 fs.writeFileSync(origPath, p.original);53 const pages: PlanPage[] = [];54 const tx = ctx.db.transaction(() => {55 ctx.db.prepare("INSERT INTO metre_plan_files(id,project_id,filename,mime,bytes,sha256,storage_path,page_count,uploaded_by) VALUES(?,?,?,?,?,?,?,?,?)")56 .run(id, projectId, safeName(p.filename), mime, p.original.length, sha, origPath, p.pages.length, ctx.user.id);57 });58 tx();59 for (const pg of p.pages.sort((a, b) => a.pageNo - b.pageNo)) {60 if (sniff(pg.png) !== "image/png") throw new HttpError(415, `Page ${pg.pageNo} : PNG attendu.`);61 if (pg.png.length > MAX_PAGE_BYTES) throw new HttpError(413, `Page ${pg.pageNo} trop volumineuse.`);62 // re-encodage par sharp : neutralise tout contenu non image, borne les dimensions63 const meta = await sharp(pg.png).metadata();64 if (!meta.width || !meta.height || meta.width > MAX_PAGE_PX || meta.height > MAX_PAGE_PX) throw new HttpError(400, `Page ${pg.pageNo} : dimensions invalides (max ${MAX_PAGE_PX} px).`);65 const out = await sharp(pg.png).png({ compressionLevel: 8 }).toBuffer();66 const imgPath = path.join(dir, `page-${pg.pageNo}.png`);67 fs.writeFileSync(imgPath, out);68 const pid = randomUUID();69 ctx.db.prepare("INSERT INTO metre_plan_pages(id,file_id,project_id,page_no,width_px,height_px,image_path,label) VALUES(?,?,?,?,?,?,?,?)").run(pid, id, projectId, pg.pageNo, meta.width, meta.height, imgPath, `p. ${pg.pageNo}`);70 pages.push({ id: pid, fileId: id, pageNo: pg.pageNo, widthPx: meta.width, heightPx: meta.height, label: `p. ${pg.pageNo}`, calibration: null });71 }72 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.upload", entityType: "plan_file", entityId: id, projectId, details: { filename: p.filename, bytes: p.original.length, pages: pages.length, sha256: sha }, ip: ctx.ip });73 return { file: getPlanFile(ctx.db, id)!, pages };74}7576/**77 * Import DXF (ASCII) : analyse pure → projection → page raster (SVG → PNG via sharp)78 * → calibration EXACTE si $INSUNITS est connu → objets (lignes, polylignes fermées = aires) par calque.79 * L'original .dxf est conservé et haché comme tout plan.80 */81export async function importDxf(ctx: Ctx, projectId: string, p: { filename: string; text: string }): Promise<{ file: PlanFile; page: PlanPage; calibration: Calibration | null; objects: number; skipped: Record<string, number>; unit: LengthUnit | null }> {82 const { parseDxf, projectDxf, dxfToSvg, DXF_PARSER_VERSION } = await import("./sources/dxf");83 if (p.text.length > 40 * 1024 * 1024) throw new HttpError(413, "DXF trop volumineux (max 40 Mo).");84 if (!/^\s*0\s*\r?\n\s*SECTION/m.test(p.text) && !p.text.includes("ENTITIES")) throw new HttpError(415, "DXF ASCII attendu (les DXF binaires et DWG ne sont pas pris en charge).");85 const drawing = parseDxf(p.text);86 if (!drawing.entities.length) throw new HttpError(400, `Aucune entité LINE/LWPOLYLINE/POLYLINE/CIRCLE lisible (ignorées : ${JSON.stringify(drawing.skipped)}).`);87 const proj = projectDxf(drawing, 4000, 80);88 const png = await sharp(Buffer.from(dxfToSvg(proj))).png({ compressionLevel: 8 }).toBuffer();89 const original = Buffer.from(p.text, "utf8");90 const sha = createHash("sha256").update(original).digest("hex");91 const id = randomUUID();92 const dir = path.join(metreFilesDir(), projectId, id);93 fs.mkdirSync(dir, { recursive: true });94 const origPath = path.join(dir, "original.dxf");95 fs.writeFileSync(origPath, original);96 const imgPath = path.join(dir, "page-1.png");97 fs.writeFileSync(imgPath, png);98 const pid = randomUUID();99 const label = `DXF ${drawing.unit ?? "?"}`;100 ctx.db.prepare("INSERT INTO metre_plan_files(id,project_id,filename,mime,bytes,sha256,storage_path,page_count,uploaded_by) VALUES(?,?,?,?,?,?,?,?,?)").run(id, projectId, safeName(p.filename), "application/dxf", original.length, sha, origPath, 1, ctx.user.id);101 ctx.db.prepare("INSERT INTO metre_plan_pages(id,file_id,project_id,page_no,width_px,height_px,image_path,label) VALUES(?,?,?,?,?,?,?,?)").run(pid, id, projectId, 1, proj.widthPx, proj.heightPx, imgPath, label);102 let cal: Calibration | null = null;103 if (drawing.unit && proj.calibration) {104 cal = calibratePage(ctx, pid, { p1: proj.calibration.p1, p2: proj.calibration.p2, realLength: proj.calibration.realLength, unit: drawing.unit });105 ctx.db.prepare("UPDATE metre_calibrations SET uncertainty_pct=0 WHERE id=?").run(cal.id); // coordonnées exactes, pas de clic106 cal = getCalibration(ctx.db, cal.id);107 }108 let n = 0;109 const ins = ctx.db.prepare("INSERT INTO metre_plan_objects(id,page_id,project_id,kind,layer,name,points_json,closed,props_json) VALUES(?,?,?,?,?,?,?,?,?)");110 const tx = ctx.db.transaction(() => {111 for (const { entity, points } of proj.entities) {112 if (points.length > 500) continue;113 const kind: ObjectKind = entity.closed ? "area" : "line";114 ins.run(randomUUID(), pid, projectId, kind, entity.layer.slice(0, 40) || "0", `${entity.type} ${++n}`, JSON.stringify(points.map((q) => ({ x: Math.round(q.x * 100) / 100, y: Math.round(q.y * 100) / 100 }))), entity.closed ? 1 : 0, JSON.stringify({ note: `DXF ${entity.type} (${DXF_PARSER_VERSION})` }));115 }116 });117 tx();118 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.import_dxf", entityType: "plan_file", entityId: id, projectId, details: { filename: p.filename, entities: n, skipped: drawing.skipped, unit: drawing.unit, insunits: drawing.insunits, sha256: sha }, ip: ctx.ip });119 return { file: getPlanFile(ctx.db, id)!, page: listPages(ctx.db, id)[0], calibration: cal, objects: n, skipped: drawing.skipped, unit: drawing.unit };120}121122export function getPlanFile(d: Database.Database, id: string): PlanFile | null {123 const r = d.prepare("SELECT id, project_id, filename, mime, bytes, sha256, page_count, created_at FROM metre_plan_files WHERE id=?").get(id) as Record<string, unknown> | undefined;124 return r ? { id: String(r.id), projectId: String(r.project_id), filename: String(r.filename), mime: String(r.mime), bytes: Number(r.bytes), sha256: String(r.sha256), pageCount: Number(r.page_count), createdAt: String(r.created_at) } : null;125}126127export function listPlanFiles(d: Database.Database, projectId: string): (PlanFile & { pages: PlanPage[] })[] {128 const files = (d.prepare("SELECT id FROM metre_plan_files WHERE project_id=? ORDER BY created_at").all(projectId) as { id: string }[]).map((r) => getPlanFile(d, r.id)!);129 return files.map((f) => ({ ...f, pages: listPages(d, f.id) }));130}131132export function listPages(d: Database.Database, fileId: string): PlanPage[] {133 return (d.prepare("SELECT id, file_id, page_no, width_px, height_px, label, current_calibration_id, source, level FROM metre_plan_pages WHERE file_id=? ORDER BY page_no").all(fileId) as Record<string, unknown>[])134 .map((r) => ({ id: String(r.id), fileId: String(r.file_id), pageNo: Number(r.page_no), widthPx: Number(r.width_px), heightPx: Number(r.height_px), label: r.label as string | null, calibration: r.current_calibration_id ? getCalibration(d, String(r.current_calibration_id)) : null, source: (r.source as PlanPage["source"]) ?? "import", level: r.level as string | null }));135}136137export function getPage(d: Database.Database, pageId: string): (PlanPage & { projectId: string; imagePath: string; meta: Record<string, unknown> | null }) | null {138 const r = d.prepare("SELECT * FROM metre_plan_pages WHERE id=?").get(pageId) as Record<string, unknown> | undefined;139 if (!r) return null;140 return { id: String(r.id), fileId: String(r.file_id), projectId: String(r.project_id), pageNo: Number(r.page_no), widthPx: Number(r.width_px), heightPx: Number(r.height_px), label: r.label as string | null, calibration: r.current_calibration_id ? getCalibration(d, String(r.current_calibration_id)) : null, source: (r.source as PlanPage["source"]) ?? "import", level: r.level as string | null, imagePath: String(r.image_path), meta: r.meta_json ? JSON.parse(String(r.meta_json)) : null };141}142143export const LEVELS = ["Site", "Sous-sol", "Rez-de-chaussée", "Étage", "Toit"];144145/* ----------------------------------------------- pages créées sans fichier */146147/** Fichier « porteur » d'une page créée dans l'application (vierge, carte, gabarit). */148function virtualFile(ctx: Ctx, projectId: string, filename: string, mime: string, original: Buffer): { id: string; dir: string } {149 const id = randomUUID();150 const dir = path.join(metreFilesDir(), projectId, id);151 fs.mkdirSync(dir, { recursive: true });152 const origPath = path.join(dir, "original.json");153 fs.writeFileSync(origPath, original);154 ctx.db.prepare("INSERT INTO metre_plan_files(id,project_id,filename,mime,bytes,sha256,storage_path,page_count,uploaded_by) VALUES(?,?,?,?,?,?,?,?,?)").run(id, projectId, safeName(filename), mime, original.length, createHash("sha256").update(original).digest("hex"), origPath, 1, ctx.user.id);155 return { id, dir };156}157158/** Grille blanche « papier » (lignes 1 m / 0,1 m) rendue par sharp. */159async function paperPng(widthPx: number, heightPx: number, pxPerMeter: number): Promise<Buffer> {160 const lines: string[] = [];161 const minor = pxPerMeter / 10;162 if (minor >= 6) for (let x = 0; x <= widthPx; x += minor) lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${heightPx}" stroke="#eceff1" stroke-width="0.5"/>`);163 if (minor >= 6) for (let y = 0; y <= heightPx; y += minor) lines.push(`<line x1="0" y1="${y}" x2="${widthPx}" y2="${y}" stroke="#eceff1" stroke-width="0.5"/>`);164 for (let x = 0; x <= widthPx; x += pxPerMeter) lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${heightPx}" stroke="#d3d9dd" stroke-width="0.8"/>`);165 for (let y = 0; y <= heightPx; y += pxPerMeter) lines.push(`<line x1="0" y1="${y}" x2="${widthPx}" y2="${y}" stroke="#d3d9dd" stroke-width="0.8"/>`);166 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${widthPx}" height="${heightPx}"><rect width="100%" height="100%" fill="#ffffff"/>${lines.join("")}</svg>`;167 return sharp(Buffer.from(svg)).png({ compressionLevel: 8 }).toBuffer();168}169170function insertPage(ctx: Ctx, fileId: string, projectId: string, p: { widthPx: number; heightPx: number; imagePath: string; label: string; source: PlanPage["source"]; level: string | null; meta: unknown }): string {171 const pid = randomUUID();172 ctx.db.prepare("INSERT INTO metre_plan_pages(id,file_id,project_id,page_no,width_px,height_px,image_path,label,source,level,meta_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)").run(pid, fileId, projectId, 1, p.widthPx, p.heightPx, p.imagePath, p.label.slice(0, 80), p.source ?? "blank", p.level, JSON.stringify(p.meta ?? null));173 return pid;174}175176function exactCalibration(ctx: Ctx, pageId: string, pxPerMeter: number, lengthM: number): Calibration {177 const cal = calibratePage(ctx, pageId, { p1: { x: 0, y: 0 }, p2: { x: pxPerMeter * lengthM, y: 0 }, realLength: lengthM, unit: "m" });178 ctx.db.prepare("UPDATE metre_calibrations SET uncertainty_pct=0 WHERE id=?").run(cal.id);179 return getCalibration(ctx.db, cal.id)!;180}181182/** Plan vierge à l'échelle : page de `widthM × heightM` à `pxPerMeter` (défaut 50 px/m), calibration exacte. */183export async function createBlankPage(ctx: Ctx, projectId: string, p: { widthM?: number; heightM?: number; pxPerMeter?: number; label?: string; level?: string | null }): Promise<{ page: PlanPage; calibration: Calibration }> {184 const widthM = Math.min(200, Math.max(3, Number(p.widthM) || 24)), heightM = Math.min(200, Math.max(3, Number(p.heightM) || 16));185 const ppm = Math.min(200, Math.max(10, Number(p.pxPerMeter) || 50));186 if (widthM * ppm > MAX_PAGE_PX || heightM * ppm > MAX_PAGE_PX) throw new HttpError(400, `Page trop grande (max ${MAX_PAGE_PX} px de côté) : réduisez l'échelle ou les dimensions.`);187 const widthPx = Math.round(widthM * ppm), heightPx = Math.round(heightM * ppm);188 const level = p.level ?? "Rez-de-chaussée";189 const label = (p.label ?? level).slice(0, 80);190 const f = virtualFile(ctx, projectId, `${label}.plan`, "application/x-metre-plan", Buffer.from(JSON.stringify({ kind: "blank", widthM, heightM, pxPerMeter: ppm, level })));191 const imgPath = path.join(f.dir, "page-1.png");192 fs.writeFileSync(imgPath, await paperPng(widthPx, heightPx, ppm));193 const pid = insertPage(ctx, f.id, projectId, { widthPx, heightPx, imagePath: imgPath, label, source: "blank", level, meta: { widthM, heightM, pxPerMeter: ppm } });194 const cal = exactCalibration(ctx, pid, ppm, Math.min(10, widthM));195 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.blank", entityType: "page", entityId: pid, projectId, details: { widthM, heightM, pxPerMeter: ppm, level }, ip: ctx.ip });196 return { page: getPage(ctx.db, pid)!, calibration: cal };197}198199/** Crée tous les niveaux d'un gabarit généré (templates.ts) dans le projet, pose la fiche technique par défaut du gabarit. */200export async function createTemplateProject(ctx: Ctx, projectId: string, p: { template: string; pxPerMeter?: number }): Promise<{ pages: PlanPage[]; objects: number; template: string }> {201 const { buildTemplate, materializeLevel, defaultSpec } = await import("./templates");202 const t = buildTemplate(p.template);203 if (!t) throw new HttpError(400, "Gabarit inconnu.");204 const ppm = Math.min(120, Math.max(20, Number(p.pxPerMeter) || 50));205 const pages: PlanPage[] = []; let n = 0;206 for (const lv of t.levels) {207 const { page } = await createBlankPage(ctx, projectId, { widthM: lv.widthM, heightM: lv.heightM, pxPerMeter: ppm, label: lv.level, level: lv.level });208 ctx.db.prepare("UPDATE metre_plan_pages SET source='template', meta_json=json_set(COALESCE(meta_json,'{}'),'$.template',?) WHERE id=?").run(t.key, page.id);209 const idMap = new Map<string, string>();210 const objs = materializeLevel(lv, ppm);211 for (const o of objs.filter((x) => x.kind !== "opening")) { const c = createObject(ctx, page.id, { ...o, id: undefined }); idMap.set(o.id, c.id); n++; }212 for (const o of objs.filter((x) => x.kind === "opening")) { createObject(ctx, page.id, { ...o, id: undefined, props: { ...o.props, deductFrom: o.props.deductFrom ? idMap.get(o.props.deductFrom) ?? null : null } }); n++; }213 pages.push(getPage(ctx.db, page.id)!);214 }215 const spec = defaultSpec(t.spec);216 ctx.db.prepare("UPDATE metre_projects SET spec_json=?, stories=?, building_kind=?, updated_at=? WHERE id=?").run(JSON.stringify(spec), t.storeys, t.kind === "plex" ? "plex" : t.kind === "row" ? "row" : t.kind === "garage" ? "other" : t.kind === "condo" ? "condo" : t.kind === "chalet" ? "chalet" : "detached", nowIso(), projectId);217 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.template", entityType: "project", entityId: projectId, projectId, details: { template: t.key, levels: pages.length, objects: n }, ip: ctx.ip });218 return { pages, objects: n, template: t.key };219}220221/** Copie une page (image, calibration, objets) vers un autre projet (duplication de projet). */222export function copyPageTo(ctx: Ctx, pageId: string, targetProjectId: string): string | null {223 const cur = getPage(ctx.db, pageId); if (!cur) return null;224 const f = virtualFile(ctx, targetProjectId, `${cur.label ?? "page"}.plan`, "application/x-metre-plan", Buffer.from(JSON.stringify({ kind: "copy", from: pageId })));225 const imgPath = path.join(f.dir, "page-1.png");226 fs.copyFileSync(cur.imagePath, imgPath);227 const pid = insertPage(ctx, f.id, targetProjectId, { widthPx: cur.widthPx, heightPx: cur.heightPx, imagePath: imgPath, label: cur.label ?? "page", source: cur.source ?? "blank", level: cur.level ?? null, meta: cur.meta });228 if (cur.calibration) { const c = calibratePage(ctx, pid, { p1: cur.calibration.p1, p2: cur.calibration.p2, realLength: cur.calibration.realLength, unit: cur.calibration.unit }); ctx.db.prepare("UPDATE metre_calibrations SET uncertainty_pct=? WHERE id=?").run(cur.calibration.uncertaintyPct, c.id); }229 const idMap = new Map<string, string>(); const objs = listObjects(ctx.db, pageId);230 for (const o of objs.filter((x) => x.kind !== "opening")) { const c = createObject(ctx, pid, { ...o, id: undefined, props: { ...o.props } }); idMap.set(o.id, c.id); }231 for (const o of objs.filter((x) => x.kind === "opening")) createObject(ctx, pid, { ...o, id: undefined, props: { ...o.props, deductFrom: o.props.deductFrom ? idMap.get(o.props.deductFrom) ?? null : null } });232 return pid;233}234235/**236 * Plan de site géoréférencé : image satellite Mapbox (Static Images API) centrée sur (lat, lng) au zoom demandé,237 * calibration EXACTE depuis la résolution Web Mercator : m/px = 40 075 016,686 × cos(lat) / (512 × 2^zoom) (÷ 2 en @2x).238 * L'image sert à tracer le lot et l'empreinte ; les surfaces sont celles de la projection (erreur < 0,1 % à ces échelles).239 */240export async function createMapPage(ctx: Ctx, projectId: string, p: { lat: number; lng: number; zoom?: number; label?: string; style?: string }): Promise<{ page: PlanPage; calibration: Calibration; pxPerMeter: number }> {241 const lat = Number(p.lat), lng = Number(p.lng), zoom = Math.min(20, Math.max(14, Number(p.zoom) || 19));242 if (!(lat >= 41 && lat <= 63 && lng >= -80 && lng <= -57)) throw new HttpError(400, "Coordonnées hors du Québec.");243 const { MAPBOX_TOKEN } = await import("../kamaps");244 const style = p.style === "streets" ? "mapbox/streets-v12" : "mapbox/satellite-streets-v12";245 const W = 1280, H = 960;246 const url = `https://api.mapbox.com/styles/v1/${style}/static/${lng.toFixed(6)},${lat.toFixed(6)},${zoom},0/${W}x${H}@2x?access_token=${MAPBOX_TOKEN}&attribution=true&logo=true`;247 const r = await fetch(url);248 if (!r.ok) throw new HttpError(502, `Image satellite indisponible (Mapbox HTTP ${r.status}).`);249 const buf = Buffer.from(await r.arrayBuffer());250 const mPerPx = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (512 * Math.pow(2, zoom)) / 2; // @2x251 const ppm = 1 / mPerPx;252 const label = (p.label ?? "Site").slice(0, 80);253 const f = virtualFile(ctx, projectId, `${label}.carte`, "application/x-metre-map", Buffer.from(JSON.stringify({ kind: "map", lat, lng, zoom, style, url: url.replace(MAPBOX_TOKEN, "…") })));254 const imgPath = path.join(f.dir, "page-1.png");255 fs.writeFileSync(imgPath, await sharp(buf).png().toBuffer());256 const pid = insertPage(ctx, f.id, projectId, { widthPx: W * 2, heightPx: H * 2, imagePath: imgPath, label, source: "map", level: "Site", meta: { lat, lng, zoom, style, mPerPx, attribution: "© Mapbox © OpenStreetMap © Maxar" } });257 const cal = exactCalibration(ctx, pid, ppm, 20);258 ctx.db.prepare("UPDATE metre_calibrations SET uncertainty_pct=0.1 WHERE id=?").run(cal.id);259 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.map", entityType: "page", entityId: pid, projectId, details: { lat, lng, zoom, pxPerMeter: ppm }, ip: ctx.ip });260 return { page: getPage(ctx.db, pid)!, calibration: getCalibration(ctx.db, cal.id)!, pxPerMeter: ppm };261}262263export function renamePage(ctx: Ctx, pageId: string, p: { label?: string; level?: string | null }): void {264 const cur = getPage(ctx.db, pageId); if (!cur) throw new HttpError(404, "Page introuvable.");265 ctx.db.prepare("UPDATE metre_plan_pages SET label=?, level=? WHERE id=?").run((p.label ?? cur.label ?? "").slice(0, 80), p.level === undefined ? cur.level : p.level, pageId);266}267268export function deletePage(ctx: Ctx, pageId: string): void {269 const cur = getPage(ctx.db, pageId); if (!cur) throw new HttpError(404, "Page introuvable.");270 const n = (ctx.db.prepare("SELECT COUNT(*) n FROM metre_plan_pages WHERE file_id=?").get(cur.fileId) as { n: number }).n;271 if (n <= 1) deletePlanFile(ctx, cur.fileId);272 else { ctx.db.prepare("DELETE FROM metre_plan_pages WHERE id=?").run(pageId); try { fs.rmSync(cur.imagePath, { force: true }); } catch { /* ignore */ } }273 ctx.db.prepare("UPDATE metre_measurements SET stale=1 WHERE page_id=? AND deleted_at IS NULL").run(pageId);274 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.page.delete", entityType: "page", entityId: pageId, projectId: cur.projectId, ip: ctx.ip });275}276277/** Copie d'une page (ex. étage depuis le rez-de-chaussée) : même image, même calibration, objets copiés (sans ouvertures liées à des murs absents). */278export async function duplicatePage(ctx: Ctx, pageId: string, p: { label?: string; level?: string | null; withObjects?: boolean }): Promise<PlanPage> {279 const cur = getPage(ctx.db, pageId); if (!cur) throw new HttpError(404, "Page introuvable.");280 const f = virtualFile(ctx, cur.projectId, `${p.label ?? cur.label ?? "page"}.plan`, "application/x-metre-plan", Buffer.from(JSON.stringify({ kind: "copy", from: pageId })));281 const imgPath = path.join(f.dir, "page-1.png");282 fs.copyFileSync(cur.imagePath, imgPath);283 const pid = insertPage(ctx, f.id, cur.projectId, { widthPx: cur.widthPx, heightPx: cur.heightPx, imagePath: imgPath, label: p.label ?? `${cur.label ?? "page"} (copie)`, source: cur.source ?? "blank", level: p.level ?? cur.level ?? null, meta: cur.meta });284 if (cur.calibration) { const c = calibratePage(ctx, pid, { p1: cur.calibration.p1, p2: cur.calibration.p2, realLength: cur.calibration.realLength, unit: cur.calibration.unit }); ctx.db.prepare("UPDATE metre_calibrations SET uncertainty_pct=? WHERE id=?").run(cur.calibration.uncertaintyPct, c.id); }285 if (p.withObjects !== false) {286 const idMap = new Map<string, string>();287 const objs = listObjects(ctx.db, pageId);288 for (const o of objs.filter((x) => x.kind !== "opening")) { const c = createObject(ctx, pid, { ...o, props: { ...o.props } }); idMap.set(o.id, c.id); }289 for (const o of objs.filter((x) => x.kind === "opening")) createObject(ctx, pid, { ...o, props: { ...o.props, deductFrom: o.props.deductFrom ? idMap.get(o.props.deductFrom) ?? null : null } });290 }291 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.page.duplicate", entityType: "page", entityId: pid, projectId: cur.projectId, details: { from: pageId }, ip: ctx.ip });292 return getPage(ctx.db, pid)!;293}294295/** « Mesurer tout » : toutes les formules pertinentes de chaque objet mesurable de la page. */296export function measurePage(ctx: Ctx, pageId: string): { measured: number; failed: { id: string; name: string; error: string }[] } {297 const objs = listObjects(ctx.db, pageId);298 let n = 0; const failed: { id: string; name: string; error: string }[] = [];299 // d'abord les ouvertures (pour que les murs nets puissent les déduire si elles sont vérifiées)300 const ordered = [...objs.filter((o) => o.kind === "opening"), ...objs.filter((o) => o.kind !== "opening")];301 for (const o of ordered) for (const f of formulasFor(o.kind)) { try { measureObject(ctx, o.id, f); n++; } catch (e) { failed.push({ id: o.id, name: o.name, error: (e as Error).message }); } }302 return { measured: n, failed };303}304305/** Export d'une page : SVG complet (cartouche), PNG (sharp) ou PDF (pdfkit, Lettre paysage, image intégrée). */306export async function exportPage(ctx: Ctx, pageId: string, format: "svg" | "png" | "pdf", opts: { projectName: string; author: string; imperial: boolean; lang: "fr" | "en" }): Promise<{ body: Buffer; mime: string; ext: string }> {307 const { renderPlanSvg } = await import("./plan-render");308 const page = getPage(ctx.db, pageId); if (!page) throw new HttpError(404, "Page introuvable.");309 const objects = listObjects(ctx.db, pageId);310 const k = page.calibration?.pxPerMeter ?? null;311 const scale = k ? `1 : ${Math.round((1 / k) * 1000 / 0.2646)}` : "non calibré"; // approx. échelle papier à 96 dpi (1 px = 0,2646 mm)312 const bg = page.source === "import" || page.source === "map" ? `data:image/png;base64,${fs.readFileSync(page.imagePath).toString("base64")}` : null;313 const svg = renderPlanSvg(objects, { widthPx: page.widthPx, heightPx: page.heightPx, pxPerMeter: k, imperial: opts.imperial, background: bg, grid: page.source === "blank" || page.source === "template", showLabels: true, showDims: true, title: { project: opts.projectName, level: page.level ?? page.label ?? "", scale, date: nowIso().slice(0, 10), author: opts.author } });314 ctx.db.prepare("INSERT INTO metre_report_jobs(id,project_id,estimate_version_id,kind,lang,requested_by,bytes) VALUES(?,?,NULL,?,?,?,?)").run(randomUUID(), page.projectId, `plan-${format}`, opts.lang, ctx.user.id, svg.length);315 if (format === "svg") return { body: Buffer.from(svg), mime: "image/svg+xml", ext: "svg" };316 const png = await sharp(Buffer.from(svg), { density: 144 }).png().toBuffer();317 if (format === "png") return { body: png, mime: "image/png", ext: "png" };318 const PDFDocument = (await import("pdfkit")).default;319 const doc = new PDFDocument({ size: "LETTER", layout: "landscape", margin: 24, info: { Title: `${opts.projectName} — ${page.level ?? page.label ?? "plan"}`, Author: "UQO Éval — Métré" } });320 const chunks: Buffer[] = []; doc.on("data", (c: Buffer) => chunks.push(c));321 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));322 const meta = await sharp(png).metadata();323 const maxW = 792 - 48, maxH = 612 - 48; const s = Math.min(maxW / (meta.width ?? 1), maxH / (meta.height ?? 1));324 doc.image(png, 24 + (maxW - (meta.width ?? 0) * s) / 2, 24, { width: (meta.width ?? 0) * s });325 doc.end();326 return { body: await done, mime: "application/pdf", ext: "pdf" };327}328329export function pageProject(d: Database.Database, pageId: string): { projectId: string; imagePath: string; fileId: string } | null {330 const r = d.prepare("SELECT project_id, image_path, file_id FROM metre_plan_pages WHERE id=?").get(pageId) as { project_id: string; image_path: string; file_id: string } | undefined;331 return r ? { projectId: r.project_id, imagePath: r.image_path, fileId: r.file_id } : null;332}333334export function filePath(d: Database.Database, fileId: string): { projectId: string; storagePath: string; mime: string; filename: string } | null {335 const r = d.prepare("SELECT project_id, storage_path, mime, filename FROM metre_plan_files WHERE id=?").get(fileId) as { project_id: string; storage_path: string; mime: string; filename: string } | undefined;336 return r ? { projectId: r.project_id, storagePath: r.storage_path, mime: r.mime, filename: r.filename } : null;337}338339export function deletePlanFile(ctx: Ctx, fileId: string): void {340 const f = filePath(ctx.db, fileId);341 if (!f) return;342 ctx.db.prepare("DELETE FROM metre_plan_files WHERE id=?").run(fileId);343 try { fs.rmSync(path.dirname(f.storagePath), { recursive: true, force: true }); } catch { /* ignore */ }344 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.delete", entityType: "plan_file", entityId: fileId, projectId: f.projectId, ip: ctx.ip });345}346347/* ------------------------------------------------------------ calibration */348349export function getCalibration(d: Database.Database, id: string): Calibration | null {350 const r = d.prepare("SELECT * FROM metre_calibrations WHERE id=?").get(id) as Record<string, unknown> | undefined;351 if (!r) return null;352 return { id: String(r.id), pageId: String(r.page_id), revision: Number(r.revision), p1: { x: Number(r.p1_x), y: Number(r.p1_y) }, p2: { x: Number(r.p2_x), y: Number(r.p2_y) }, realLength: Number(r.real_length), unit: r.unit as LengthUnit, pxPerMeter: Number(r.px_per_meter), uncertaintyPct: Number(r.uncertainty_pct), createdBy: String(r.created_by), createdAt: String(r.created_at) };353}354355export function currentCalibration(d: Database.Database, pageId: string): Calibration | null {356 const r = d.prepare("SELECT current_calibration_id c FROM metre_plan_pages WHERE id=?").get(pageId) as { c: string | null } | undefined;357 return r?.c ? getCalibration(d, r.c) : null;358}359360/** Nouvelle révision de calibration ; marque périmées toutes les mesures de la page. */361export function calibratePage(ctx: Ctx, pageId: string, p: { p1: Point; p2: Point; realLength: number; unit: string }): Calibration {362 const pg = ctx.db.prepare("SELECT project_id, width_px, height_px FROM metre_plan_pages WHERE id=?").get(pageId) as { project_id: string; width_px: number; height_px: number } | undefined;363 if (!pg) throw new HttpError(404, "Page introuvable.");364 if (!LEN_UNITS.includes(p.unit as LengthUnit)) throw new HttpError(400, "Unité de longueur invalide.");365 const inPage = (q: Point) => Number.isFinite(q.x) && Number.isFinite(q.y) && q.x >= 0 && q.y >= 0 && q.x <= pg.width_px && q.y <= pg.height_px;366 if (!inPage(p.p1) || !inPage(p.p2)) throw new HttpError(400, "Points hors de la page.");367 const rev = ((ctx.db.prepare("SELECT MAX(revision) m FROM metre_calibrations WHERE page_id=?").get(pageId) as { m: number | null }).m ?? 0) + 1;368 let cal: Calibration;369 try { cal = makeCalibration({ id: randomUUID(), pageId, revision: rev, p1: p.p1, p2: p.p2, realLength: Number(p.realLength), unit: p.unit as LengthUnit, createdBy: ctx.user.id, createdAt: nowIso() }); }370 catch (e) { throw new HttpError(400, (e as Error).message); }371 const tx = ctx.db.transaction(() => {372 ctx.db.prepare("INSERT INTO metre_calibrations(id,page_id,revision,p1_x,p1_y,p2_x,p2_y,real_length,unit,px_per_meter,uncertainty_pct,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")373 .run(cal.id, pageId, rev, cal.p1.x, cal.p1.y, cal.p2.x, cal.p2.y, cal.realLength, cal.unit, cal.pxPerMeter, cal.uncertaintyPct, ctx.user.id, cal.createdAt);374 ctx.db.prepare("UPDATE metre_plan_pages SET current_calibration_id=? WHERE id=?").run(cal.id, pageId);375 ctx.db.prepare("UPDATE metre_measurements SET stale=1 WHERE page_id=? AND calibration_revision IS NOT NULL AND calibration_revision<>? AND deleted_at IS NULL").run(pageId, rev);376 });377 tx();378 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "plan.calibrate", entityType: "page", entityId: pageId, projectId: pg.project_id, details: { revision: rev, pxPerMeter: cal.pxPerMeter, uncertaintyPct: cal.uncertaintyPct }, ip: ctx.ip });379 return cal;380}381382export function calibrationHistory(d: Database.Database, pageId: string): Calibration[] {383 return (d.prepare("SELECT id FROM metre_calibrations WHERE page_id=? ORDER BY revision DESC").all(pageId) as { id: string }[]).map((r) => getCalibration(d, r.id)!);384}385386/* ------------------------------------------------------------------ objets */387388function mapObject(r: Record<string, unknown>): PlanObject {389 return { id: String(r.id), pageId: String(r.page_id), kind: r.kind as ObjectKind, layer: String(r.layer), name: String(r.name), points: JSON.parse(String(r.points_json)) as Point[], closed: !!r.closed, props: JSON.parse(String(r.props_json)) as ObjectProps, createdAt: String(r.created_at), updatedAt: String(r.updated_at) };390}391392export function listObjects(d: Database.Database, pageId: string): PlanObject[] {393 return (d.prepare("SELECT * FROM metre_plan_objects WHERE page_id=? AND deleted_at IS NULL ORDER BY created_at").all(pageId) as Record<string, unknown>[]).map(mapObject);394}395396export function getObject(d: Database.Database, id: string): PlanObject | null {397 const r = d.prepare("SELECT * FROM metre_plan_objects WHERE id=? AND deleted_at IS NULL").get(id) as Record<string, unknown> | undefined;398 return r ? mapObject(r) : null;399}400401function sanitizeObject(raw: Record<string, unknown>, pg: { width_px: number; height_px: number }): { kind: ObjectKind; layer: string; name: string; points: Point[]; closed: boolean; props: ObjectProps } {402 const kind = KINDS.includes(raw.kind as ObjectKind) ? (raw.kind as ObjectKind) : "area";403 const pts = Array.isArray(raw.points) ? (raw.points as unknown[]).slice(0, 500).map((p) => { const q = p as { x?: unknown; y?: unknown }; const x = Number(q.x), y = Number(q.y); if (!Number.isFinite(x) || !Number.isFinite(y)) throw new HttpError(400, "Point invalide."); return { x: Math.min(pg.width_px, Math.max(0, x)), y: Math.min(pg.height_px, Math.max(0, y)) }; }) : [];404 const pr = (raw.props ?? {}) as Record<string, unknown>;405 const n = (k: string, max: number) => { const v = pr[k]; if (v == null || v === "") return undefined; const x = Number(v); if (!Number.isFinite(x) || x < 0 || x > max) throw new HttpError(400, `Propriété ${k} invalide.`); return x; };406 const props: ObjectProps = {407 height: n("height", 30), thickness: n("thickness", 5), pitch: n("pitch", 24), openingWidth: n("openingWidth", 20), openingHeight: n("openingHeight", 10), count: n("count", 1000), offset: pr.offset == null ? undefined : Math.max(-2000, Math.min(2000, Number(pr.offset) || 0)), fontSize: n("fontSize", 400), swing: pr.swing == null ? undefined : [-2, -1, 1, 2].includes(Number(pr.swing)) ? Number(pr.swing) : 1,408 deductFrom: typeof pr.deductFrom === "string" && /^[0-9a-f-]{36}$/i.test(pr.deductFrom) ? pr.deductFrom : null, openingKind: ["door", "window", "opening"].includes(String(pr.openingKind)) ? (pr.openingKind as ObjectProps["openingKind"]) : undefined,409 exterior: typeof pr.exterior === "boolean" ? pr.exterior : undefined, text: typeof pr.text === "string" ? pr.text.slice(0, 300) : undefined, usage: typeof pr.usage === "string" ? pr.usage.slice(0, 40) : undefined,410 color: typeof pr.color === "string" ? pr.color.slice(0, 20) : undefined, note: typeof pr.note === "string" ? pr.note.slice(0, 500) : undefined,411 };412 for (const k of Object.keys(props) as (keyof ObjectProps)[]) if (props[k] === undefined) delete props[k];413 const openKinds: ObjectKind[] = ["wall", "line", "dimension"];414 return { kind, layer: typeof raw.layer === "string" && raw.layer.trim() ? raw.layer.trim().slice(0, 40) : defaultLayer(kind), name: typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, 120) : kind, points: pts, closed: raw.closed === true ? true : raw.closed === false ? false : !openKinds.includes(kind) && kind !== "opening" && kind !== "text" && kind !== "annotation" && kind !== "column", props };415}416417export function createObject(ctx: Ctx, pageId: string, raw: Record<string, unknown>): PlanObject {418 const pg = ctx.db.prepare("SELECT project_id, width_px, height_px FROM metre_plan_pages WHERE id=?").get(pageId) as { project_id: string; width_px: number; height_px: number } | undefined;419 if (!pg) throw new HttpError(404, "Page introuvable.");420 const o = sanitizeObject(raw, pg);421 const id = typeof raw.id === "string" && /^[0-9a-f-]{36}$/i.test(raw.id) && !getObject(ctx.db, raw.id) ? raw.id : randomUUID();422 ctx.db.prepare("INSERT INTO metre_plan_objects(id,page_id,project_id,kind,layer,name,points_json,closed,props_json) VALUES(?,?,?,?,?,?,?,?,?)").run(id, pageId, pg.project_id, o.kind, o.layer, o.name, JSON.stringify(o.points), o.closed ? 1 : 0, JSON.stringify(o.props));423 return getObject(ctx.db, id)!;424}425426export function updateObject(ctx: Ctx, id: string, raw: Record<string, unknown>): PlanObject {427 const cur = getObject(ctx.db, id);428 if (!cur) throw new HttpError(404, "Objet introuvable.");429 const pg = ctx.db.prepare("SELECT width_px, height_px FROM metre_plan_pages WHERE id=?").get(cur.pageId) as { width_px: number; height_px: number };430 const o = sanitizeObject({ ...cur, ...raw, props: { ...cur.props, ...((raw.props ?? {}) as object) } }, pg);431 ctx.db.prepare("UPDATE metre_plan_objects SET kind=?,layer=?,name=?,points_json=?,closed=?,props_json=?,updated_at=? WHERE id=?").run(o.kind, o.layer, o.name, JSON.stringify(o.points), o.closed ? 1 : 0, JSON.stringify(o.props), nowIso(), id);432 // la géométrie a changé → mesures liées périmées433 ctx.db.prepare("UPDATE metre_measurements SET stale=1 WHERE (object_id=? OR lineage_json LIKE ?) AND deleted_at IS NULL").run(id, `%"${id}"%`);434 return getObject(ctx.db, id)!;435}436437export function deleteObject(ctx: Ctx, id: string): void {438 ctx.db.prepare("UPDATE metre_plan_objects SET deleted_at=? WHERE id=?").run(nowIso(), id);439 ctx.db.prepare("UPDATE metre_measurements SET stale=1 WHERE object_id=? AND deleted_at IS NULL").run(id);440}441442/* --------------------------------------------------------------- registre */443444function mapMeasure(r: Record<string, unknown>): Measurement {445 return { id: String(r.id), projectId: String(r.project_id), objectId: r.object_id as string | null, pageId: r.page_id as string | null, name: String(r.name), formula: r.formula as FormulaKey, formulaText: String(r.formula_text), inputs: JSON.parse(String(r.inputs_json)), value: Number(r.value), unit: r.unit as MeasureUnit, calibrationRevision: r.calibration_revision == null ? null : Number(r.calibration_revision), stale: !!r.stale, confidence: r.confidence as Measurement["confidence"], verifiedBy: r.verified_by as string | null, verifiedAt: r.verified_at as string | null, computedAt: String(r.computed_at), lineage: JSON.parse(String(r.lineage_json)) };446}447448export function listMeasurements(d: Database.Database, projectId: string): Measurement[] {449 return (d.prepare("SELECT * FROM metre_measurements WHERE project_id=? AND deleted_at IS NULL ORDER BY created_at").all(projectId) as Record<string, unknown>[]).map(mapMeasure);450}451452export function getMeasurement(d: Database.Database, id: string): Measurement | null {453 const r = d.prepare("SELECT * FROM metre_measurements WHERE id=? AND deleted_at IS NULL").get(id) as Record<string, unknown> | undefined;454 return r ? mapMeasure(r) : null;455}456457/** Crée (ou recalcule) la mesure d'un objet avec la calibration courante de sa page. */458export function measureObject(ctx: Ctx, objectId: string, formula?: FormulaKey, existingId?: string): Measurement {459 const obj = getObject(ctx.db, objectId);460 if (!obj) throw new HttpError(404, "Objet introuvable.");461 const cal = currentCalibration(ctx.db, obj.pageId);462 const f = formula ?? defaultFormulaFor(obj.kind);463 const openings = listObjects(ctx.db, obj.pageId).filter((o) => o.kind === "opening" && o.props.deductFrom === obj.id)464 .map((o) => ({ obj: o, verified: !!(ctx.db.prepare("SELECT 1 FROM metre_measurements WHERE object_id=? AND verified_at IS NOT NULL AND deleted_at IS NULL LIMIT 1").get(o.id)) }));465 let m;466 try { m = computeMeasure(obj, f, cal, openings); } catch (e) { throw new HttpError(400, (e as Error).message); }467 const pg = pageProject(ctx.db, obj.pageId)!;468 const id = existingId ?? (ctx.db.prepare("SELECT id FROM metre_measurements WHERE object_id=? AND formula=? AND deleted_at IS NULL").get(objectId, f) as { id: string } | undefined)?.id ?? randomUUID();469 const now = nowIso();470 ctx.db.prepare(`INSERT INTO metre_measurements(id,project_id,object_id,page_id,name,formula,formula_text,inputs_json,value,unit,calibration_revision,stale,confidence,lineage_json,formula_version,computed_at)471 VALUES(?,?,?,?,?,?,?,?,?,?,?,0,'measured',?,?,?)472 ON CONFLICT(id) DO UPDATE SET formula_text=excluded.formula_text, inputs_json=excluded.inputs_json, value=excluded.value, unit=excluded.unit, calibration_revision=excluded.calibration_revision, stale=0, lineage_json=excluded.lineage_json, formula_version=excluded.formula_version, computed_at=excluded.computed_at, verified_by=NULL, verified_at=NULL`)473 .run(id, pg.projectId, objectId, obj.pageId, obj.name, f, m.formulaText, JSON.stringify(m.inputs), m.value, m.unit, cal && f !== "count" ? cal.revision : null, JSON.stringify(m.lineage), FORMULA_VERSION, now);474 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "measure.compute", entityType: "measurement", entityId: id, projectId: pg.projectId, details: { formula: f, value: m.value, unit: m.unit, warnings: m.warnings }, ip: ctx.ip });475 return { ...getMeasurement(ctx.db, id)!, ...(m.warnings.length ? { warnings: m.warnings } : {}) } as Measurement & { warnings?: string[] };476}477478/** Mesure manuelle (saisie) — toujours étiquetée « assumed » ou « estimated ». */479export function createManualMeasurement(ctx: Ctx, projectId: string, p: { name: string; value: number; unit: string; confidence?: "estimated" | "assumed"; note?: string }): Measurement {480 const units: MeasureUnit[] = ["m", "ft", "m2", "ft2", "m3", "ft3", "yd3", "unit", "h", "lump"];481 if (!units.includes(p.unit as MeasureUnit)) throw new HttpError(400, "Unité invalide.");482 const v = Number(p.value);483 if (!Number.isFinite(v) || v < 0) throw new HttpError(400, "Valeur invalide.");484 const id = randomUUID();485 ctx.db.prepare("INSERT INTO metre_measurements(id,project_id,object_id,page_id,name,formula,formula_text,inputs_json,value,unit,calibration_revision,stale,confidence,lineage_json,formula_version,computed_at) VALUES(?,?,NULL,NULL,?,'manual','saisie manuelle',?,?,?,NULL,0,?,'[]',?,?)")486 .run(id, projectId, String(p.name).trim().slice(0, 120) || "mesure", JSON.stringify({ note: p.note ?? null }), v, p.unit, p.confidence === "estimated" ? "estimated" : "assumed", FORMULA_VERSION, nowIso());487 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: "measure.manual", entityType: "measurement", entityId: id, projectId, details: { value: v, unit: p.unit }, ip: ctx.ip });488 return getMeasurement(ctx.db, id)!;489}490491export function verifyMeasurement(ctx: Ctx, id: string, verified: boolean): Measurement {492 const m = getMeasurement(ctx.db, id);493 if (!m) throw new HttpError(404, "Mesure introuvable.");494 if (verified && m.stale) throw new HttpError(409, "Mesure périmée : recalculez-la avant de la vérifier.");495 ctx.db.prepare("UPDATE metre_measurements SET verified_by=?, verified_at=? WHERE id=?").run(verified ? ctx.user.id : null, verified ? nowIso() : null, id);496 audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: verified ? "measure.verify" : "measure.unverify", entityType: "measurement", entityId: id, projectId: m.projectId, ip: ctx.ip });497 return getMeasurement(ctx.db, id)!;498}499500export function deleteMeasurement(ctx: Ctx, id: string): void {501 ctx.db.prepare("UPDATE metre_measurements SET deleted_at=? WHERE id=?").run(nowIso(), id);502}503504/** Recalcule toutes les mesures périmées d'un projet (après recalibration). */505export function recomputeStale(ctx: Ctx, projectId: string): { recomputed: number; failed: { id: string; error: string }[] } {506 const rows = ctx.db.prepare("SELECT id, object_id, formula FROM metre_measurements WHERE project_id=? AND stale=1 AND object_id IS NOT NULL AND deleted_at IS NULL").all(projectId) as { id: string; object_id: string; formula: FormulaKey }[];507 let n = 0; const failed: { id: string; error: string }[] = [];508 for (const r of rows) { try { measureObject(ctx, r.object_id, r.formula, r.id); n++; } catch (e) { failed.push({ id: r.id, error: (e as Error).message }); } }509 return { recomputed: n, failed };510}511512export { isStale };513