Files
tilbudgivern/database/migrations/20260902_gutter_meter_packages.js
T
f37adae2cb
CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
feat: deliver auditable Smart Pakke quote flow and free site geometry (#31)
* feat: move login credentials to a DB-backed users table with an admin management page

Replaces the hardcoded AUTH_USERNAME/AUTH_PASSWORD login check with a new
auth_accounts table (bcrypt-hashed passwords, admin/user roles). Adds
admin-only /api/users CRUD routes and a "Brugere" admin page in the
frontend for managing logins without redeploying. Removes the unused,
unmounted duplicate login route in src/routes/auth.js.

* docs: add architecture codemaps with diagrams for the whole system

Adds codemaps/architecture.md, backend.md, frontend.md, and data.md —
Mermaid-diagrammed design documentation verified against the live
codebase and database rather than assumed from CLAUDE.md. Covers the
unified-server.js request flow (mounted routers + ~183 inline routes),
68 backend services grouped by domain, the frontend's state-driven
view-switch (no React Router in practice despite BrowserRouter being
present), and the full 122-table DB schema with the auth_accounts vs
unrelated users table naming trap flagged explicitly. Links added from
the root README.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* feat: ship canonical roof quote workflow

* fix: keep migration dry-run idempotent

* [verified] feat: complete Smart Pakker management

* [verified] fix: ignore blank task dependencies

* [verified] fix: align package duplication with schema

* [verified] fix: enforce Discord status limits

* [verified] fix: link Smart Pakke materials safely

* [verified] fix: harden material link review

* [verified] feat: improve material matching

* fix: scope pitch validation to roof packages

* fix: support canonical snapshots on production schema

* [verified] fix: hide internal package metadata from PDF

* [verified] feat: deliver sales-ready customer PDF

* [verified] feat: ship sales-ready PDF with AI overview

* [verified] fix: authenticate project list requests

* [verified] fix: refresh project-list authentication

* [verified] fix: open existing project details

* [verified] fix: keep roof components searchable in builder

* [verified] fix: expose all Smart Package categories

* [verified] fix: authenticate project creation

* [verified] feat: make Smart Pakker the universal project flow

* [verified] feat: preview Smart Package contents

* [verified] test: keep generic release isolated from downpipe work

* feat: add first-class Smart Pakke rentals

* [verified] feat: add gutter and downpipe smart packages

* [verified] fix: prepare six-house gutter quote flow

* [verified] fix: open generic quotes without roof geometry

* [verified] fix: review generic quotes with authenticated APIs

* [verified] fix: calculate generic Smart Package quotes

* [verified] fix: return generic calculation breakdown

* feat: checkpoint generic signed snapshot validation with red-green tests

* feat: complete fail-closed generic quote approval and customer PDF flow

* feat: use generic signed snapshot in final review

* feat: redesign generic quote final review

* fix: harden generic review summaries

* feat: add auditable six-house package basis

* [verified] feat: finish auditable Smart Pakke UI

* [verified] fix: bind auditable quantity and price bases

* [verified] fix: keep six-house basis across package versions

* [verified] fix: complete smart package discovery management

* [verified] fix: simplify composition and generic scope

* [verified] test: keep explicit roof contracts fail closed

* [verified] fix: harden generic quote snapshots

* fix: make generic quote delivery customer safe

* [verified] fix: secure package catalog reads

* [verified] fix: close workspace provenance blockers

* fix: harden customer document language boundary

* [verified] fix: secure smart package internal reads

* fix: version package child mutations atomically

* feat: add generic customer quote text flow

* [verified] fix: allow manual customer numbers

* [verified] fix: expose optional roof geometry

* [verified] fix: rebase hydrated packages after geometry edits

* [verified] feat: add free editable site area map

* [verified] fix: harden map recovery and geocoding gate

* fix: bind map quantities to authoritative geometry

* fix: release geocoder lock before dispatch

* fix: separate roof and site geometry provenance

* fix: revoke stale admin authorization

* fix: migrate task geometry basis

* fix: make backend CI dependency-complete

* ci: seed isolated e2e login account

* fix: allow clean database bootstrap

* fix: skip indexes for optional tables

* test: use canonical mansard geometry in e2e

* [verified] fix(auth): enforce live operator boundary

* fix: fail close Ordrestyring offer transport

* fix(frontend): authenticate customer project requests

* fix: align canonical roof type contract

* [verified] fix: reconcile legacy package labor safely

* [verified] fix: audit site geometry deletion

* docs: add PR 31 reviewer guide

* docs: synchronize Obsidian vault

* docs: sync integrated reviewer guide to Obsidian

* ci: seed isolated auth account explicitly

* fix: close offer bootstrap and service readiness gaps

* fix: authenticate protected package callers

* fix: provision initial admin and disable generic send

* [verified] fix: close final quote release blockers

* [verified] fix: seed gutter packages before deployment

---------

Co-authored-by: alexpolo1 <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-09-26 22:39:18 +02:00

388 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const crypto = require('crypto');
const loadCliEnvironment = () => {
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../../backend/.env') });
};
const roundMoney = value => Math.round(Number(value || 0) * 100) / 100;
const APPLY_LOCK_NAME = 'tilbudgivern:gutter-meter-packages:v1';
const materialProvenanceNote = line => [
line.sourceNote,
`Kilde: ${line.supplier || 'ukendt'} (${line.sourceUrl || line.sku || 'ingen reference'})`,
`SKU: ${line.sku || 'mangler'}`,
`Pris observeret: ${line.sourceDate || 'ukendt dato'}`,
line.vatStatus === 'converted_incl_to_excl_vat'
? 'Momsstatus: konverteret fra inkl. moms til ekskl. moms'
: 'Momsstatus: ekskl. moms'
].filter(Boolean).join(' · ');
const gutterTasks = [
['Opmåling', 0.06],
['Montage af rendejern', 0.10],
['Montage af tagrender og fittings', 0.21],
['Kontrol af fald', 0.03]
].map(([name, timePerUnit], index) => ({ name, timePerUnit, rate: 600, order: index + 1 }));
const downpipeTasks = [
['Opmåling', 0.09],
['Tilpasning af 3 m nedløbsrør', 0.09],
['Montage af 3 nedløbsholdere', 0.12],
['Montage af 3 m nedløbsrør', 0.12],
['Montage af tudstykke og tilslutning', 0.09],
['Kontrol', 0.03]
].map(([name, timePerUnit], index) => ({ name, timePerUnit, rate: 600, order: index + 1 }));
const finalize = definition => {
const materialTotal = definition.materials.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
const laborHours = definition.tasks.reduce((sum, task) => sum + task.timePerUnit, 0);
const laborTotal = definition.tasks.reduce((sum, task) => sum + task.timePerUnit * task.rate, 0);
const roundedMaterialTotal = roundMoney(materialTotal);
const roundedLaborTotal = roundMoney(laborTotal);
const hourlyRate = definition.tasks[0]?.rate || 0;
const unit = definition.unit || 'løbende m';
const unitLabel = unit === 'stk' ? 'stk' : 'løbende m';
const pipeBasis = definition.pipeLengthMetersPerPiece ? ` · ${definition.pipeLengthMetersPerPiece} m rør pr. stk` : '';
const validationStatus = definition.validationStatus || 'needs_review';
return {
...definition,
unit,
unitPrice: roundMoney(materialTotal + laborTotal),
materialTotal: roundedMaterialTotal,
laborTotal: roundedLaborTotal,
vatStatus: 'excl_vat',
validationStatus,
isActive: validationStatus === 'verified' ? 1 : 0,
priceBasisNote: `Materialer ${roundedMaterialTotal} kr./${unitLabel} ekskl. moms + montage ${roundedLaborTotal} kr./${unitLabel} ekskl. moms (${roundMoney(laborHours)} t/${unitLabel} × ${hourlyRate} kr./time ekskl. moms)${pipeBasis}`
};
};
const buildGutterPackageDefinitions = () => [
finalize({
key: 'gutter-zinc', legacyName: 'Tagrende med rendejern', name: 'Tagrender i zink – levering og montering',
unit: 'løbende m', timeUnit: 'per_meter',
geometryBasis: 'fixed', defaultQuantity: 1,
priceSource: 'Materialedatabase + Ordrestyring',
description: 'Indtast den samlede opmålte længde tagrende for alle bygninger. Zinktagrende og rendejern monteres med fald. Montagetid for standardfittings er inkluderet; fittingsmaterialer, nedløbsrør og specialhjørner er tilvalg. Pris pr. løbende meter.',
materials: [
{ sku: 'RENDE-150Z', name: 'Tagrender halvrund zink 150 mm', category: 'Tagmaterialer', quantity: 1, unit: 'løbende m', unitPrice: 245, supplier: 'Bygma', sourceUrl: 'material-database://materials/RENDE-150Z', sourceDate: '2025-10-15', vatStatus: 'excl_vat' },
{ sku: '5253218', name: 'RHEINZINK rendejern galvaniseret 280 mm', category: 'Tagmaterialer', quantity: 2, unit: 'stk', unitPrice: 29.09, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5253218', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: gutterTasks
}),
finalize({
key: 'gutter-aluminium', name: 'Tagrender i aluminium – levering og montering',
validationStatus: 'verified',
unit: 'løbende m', timeUnit: 'per_meter',
geometryBasis: 'fixed', defaultQuantity: 1,
priceSource: 'Wattoo-reference + Ordrestyring',
description: 'Indtast den samlede opmålte længde tagrende for alle bygninger. PREFA aluminiumstagrende og aluminium-rendejern monteres med fald. Montagetid for standardfittings er inkluderet; fittingsmaterialer, nedløbsrør og specialhjørner er tilvalg. Pris pr. løbende meter.',
materials: [
{ sku: 'MANUAL-ALU-GUTTER-280', name: 'PREFA aluminiumstagrende 280 mm', category: 'Tagmaterialer', quantity: 1, unit: 'løbende m', unitPrice: 100, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/vvs/tag-tagrender/tagrender/aluminium-tagrender?p=2', sourceDate: '2026-09-04', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '375 kr inkl. moms for 3 m = 100 kr/m ekskl. moms' },
{ sku: 'MANUAL-ALU-HANGER-280', name: 'PREFA aluminium-rendejern 280 mm', category: 'Tagmaterialer', quantity: 2, unit: 'stk', unitPrice: 43.20, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/vvs/tag-tagrender/tagrender/aluminium-tagrender?p=2', sourceDate: '2026-09-04', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '54 kr inkl. moms pr. stk. = 43,20 kr ekskl. moms' }
], tasks: gutterTasks
}),
finalize({
key: 'gutter-steel', name: 'Tagrender i stål – levering og montering',
unit: 'løbende m', timeUnit: 'per_meter',
geometryBasis: 'fixed', defaultQuantity: 1,
priceSource: 'Materialedatabase + Ordrestyring',
description: 'Indtast den samlede opmålte længde tagrende for alle bygninger. Stålmetallic tagrende og stål-rendejern monteres med fald. Montagetid for standardfittings er inkluderet; fittingsmaterialer, nedløbsrør og specialhjørner er tilvalg. Pris pr. løbende meter.',
materials: [
{ sku: '5842085', name: 'LP tagrende stålmetallic 125 mm', category: 'Tagmaterialer', quantity: 1, unit: 'løbende m', unitPrice: 96.78, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5842085', sourceDate: '2025-11-26', vatStatus: 'excl_vat', sourceNote: '290,33 kr ekskl. moms for 3 m = 96,78 kr/m' },
{ sku: '5842056', name: 'LP rendejern stålmetallic 125 mm', category: 'Tagmaterialer', quantity: 2, unit: 'stk', unitPrice: 53.90, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5842056', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: gutterTasks
}),
finalize({
key: 'gutter-plastic', name: 'Tagrender i plastik – levering og montering',
unit: 'løbende m', timeUnit: 'per_meter',
geometryBasis: 'fixed', defaultQuantity: 1,
priceSource: 'Materialedatabase + Ordrestyring',
description: 'Indtast den samlede opmålte længde tagrende for alle bygninger. Grå plasttagrende og galvaniserede rendejern monteres med fald. Montagetid for standardfittings er inkluderet; fittingsmaterialer, nedløbsrør og specialhjørner er tilvalg. Pris pr. løbende meter.',
materials: [
{ sku: '8621252', name: 'PLASTMO tagrende nr. 11 grå', category: 'Tagmaterialer', quantity: 1, unit: 'løbende m', unitPrice: 101.76, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/8621252', sourceDate: '2025-11-26', vatStatus: 'excl_vat', sourceNote: '305,28 kr ekskl. moms for 3 m = 101,76 kr/m' },
{ sku: '1093335', name: 'PLASTMO rendejern kort galvaniseret', category: 'Tagmaterialer', quantity: 2, unit: 'stk', unitPrice: 35.68, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/1093335', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: gutterTasks
}),
finalize({
key: 'downpipe-zinc', legacyName: 'Nedløb i zink', name: 'Nedløbsrør i zink – 3 m med tudstykke og 3 holdere',
unit: 'stk', timeUnit: 'per_piece', geometryBasis: 'fixed', defaultQuantity: 1, pipeLengthMetersPerPiece: 3,
priceSource: 'STARK materialedatabase + Ordrestyring',
description: 'Én klods er ét 3 m RHEINZINK-nedløbsrør med ét tudstykke, tre rørholdere og montage. Antal 3 giver 3 × 3 m = 9 m rør. Bøjninger samt brønd- og sokkeltilslutning er tilvalg.',
materials: [
{ componentType: 'pipe', lengthPerPieceMeters: 3, sku: '5252831', name: 'RHEINZINK nedløbsrør blank Ø76 × 3000 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 351.12, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5252831', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'outlet', sku: '5252910', name: 'RHEINZINK tudstykke blank 280/76 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 24.05, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5252910', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'holder', sku: '2142336', name: 'RHEINZINK rørholder Ø76 mm', category: 'Tagmaterialer', quantity: 3, unit: 'stk', unitPrice: 34.13, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/2142336', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: downpipeTasks
}),
finalize({
key: 'downpipe-aluminium', name: 'Nedløbsrør i aluminium – 3 m med tudstykke og 3 holdere',
validationStatus: 'verified',
unit: 'stk', timeUnit: 'per_piece', geometryBasis: 'fixed', defaultQuantity: 1, pipeLengthMetersPerPiece: 3,
priceSource: 'Wattoo reference + Ordrestyring',
description: 'Én klods er ét 3 m sort PREFA-aluminiumsnedløbsrør Ø100 med ét tudstykke, tre rørbøjler og montage. Antal 3 giver 3 × 3 m = 9 m rør. Dorn/stift, bøjninger samt brønd- og sokkeltilslutning er tilvalg.',
materials: [
{ componentType: 'pipe', lengthPerPieceMeters: 3, sku: '275850460', name: 'PREFA aluminiumsnedløbsrør sort Ø100 × 3000 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 384, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/nedloebsroer-alu-100mm-sort-3m-3-meter-275850460', sourceDate: '2026-09-16', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '480,00 kr inkl. moms pr. 3 m stk. = 384,00 kr ekskl. moms' },
{ componentType: 'outlet', sku: '275806450', name: 'PREFA aluminiumtudstykke sort 280/100 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 196.80, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/tudstykke-alu-280-100-sort-275806450', sourceDate: '2026-09-16', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '246,00 kr inkl. moms pr. stk. = 196,80 kr ekskl. moms' },
{ componentType: 'holder', sku: '277871460', name: 'PREFA aluminiumrørbøjle sort Ø100 mm uden dorn', category: 'Tagmaterialer', quantity: 3, unit: 'stk', unitPrice: 67.20, supplier: 'Wattoo reference', sourceUrl: 'https://www.wattoo.dk/roerboejle-alu-100mm-sort-t-nedloeb-u-dorn-277871460', sourceDate: '2026-09-16', vatStatus: 'converted_incl_to_excl_vat', sourceNote: '84,00 kr inkl. moms pr. stk. = 67,20 kr ekskl. moms' }
], tasks: downpipeTasks
}),
finalize({
key: 'downpipe-steel', name: 'Nedløbsrør i stål – 3 m med tudstykke og 3 holdere',
unit: 'stk', timeUnit: 'per_piece', geometryBasis: 'fixed', defaultQuantity: 1, pipeLengthMetersPerPiece: 3,
priceSource: 'STARK materialedatabase + Ordrestyring',
description: 'Én klods er ét 3 m LP Rainline stålmetallic nedløbsrør Ø75 med ét tudstykke, tre rørholdere og montage. Antal 3 giver 3 × 3 m = 9 m rør. Bøjninger samt brønd- og sokkeltilslutning er tilvalg.',
materials: [
{ componentType: 'pipe', lengthPerPieceMeters: 3, sku: '5842106', name: 'LP nedløbsrør stålmetallic Ø75 × 3000 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 344.92, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/5842106', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'outlet', sku: '1533565', name: 'LP Rainline tudstykke stålmetallic Ø75/125 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 110.77, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/1533565', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'holder', sku: '1886362', name: 'LP Rainline rørholder stålmetallic Ø75 mm', category: 'Tagmaterialer', quantity: 3, unit: 'stk', unitPrice: 41.30, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/1886362', sourceDate: '2025-11-26', vatStatus: 'excl_vat' }
], tasks: downpipeTasks
}),
finalize({
key: 'downpipe-plastic', name: 'Nedløbsrør i plastik – 3 m med tudstykke og 3 holdere',
unit: 'stk', timeUnit: 'per_piece', geometryBasis: 'fixed', defaultQuantity: 1, pipeLengthMetersPerPiece: 3,
priceSource: 'STARK materialedatabase + Ordrestyring',
description: 'Én klods er ét 3 m gråt PLASTMO plastnedløbsrør Ø75 med ét tudstykke, tre plastrørholdere og montage. Antal 3 giver 3 × 3 m = 9 m rør. Holderprisen er omregnet fra 2 stk./sæt; indkøb afrundes særskilt. Bøjninger samt brønd- og sokkeltilslutning er tilvalg.',
materials: [
{ componentType: 'pipe', lengthPerPieceMeters: 3, sku: '8621575', name: 'PLASTMO nedløbsrør grå Ø75 × 3000 mm', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 213.12, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/8621575', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'outlet', sku: '2231769', name: 'PLASTMO tudstykke grå Ø75 nr. 11', category: 'Tagmaterialer', quantity: 1, unit: 'stk', unitPrice: 114.62, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/2231769', sourceDate: '2025-11-26', vatStatus: 'excl_vat' },
{ componentType: 'holder', physicalQuantityPerPrimary: 3, piecesPerPurchaseUnit: 2, physicalUnit: 'stk', sku: '2400968', name: 'PLASTMO plastrørholder grå Ø75 mm – 2 stk./sæt', category: 'Tagmaterialer', quantity: 2, unit: 'sæt', unitPrice: 82.94, supplier: 'Stark A/S', sourceUrl: 'material-database://stark/2400968', sourceDate: '2025-11-26', vatStatus: 'excl_vat', sourceNote: 'Katalogenheden er ét sæt med 2 holdere. Fysisk forbrug er 3 holdere pr. nedløb; indkøb afrundes op til hele sæt på tværs af den valgte mængde.' }
], tasks: downpipeTasks
})
];
const definitionContentHash = definition => crypto.createHash('sha256').update(JSON.stringify({
key: definition.key,
name: definition.name,
description: definition.description,
unit: definition.unit,
unitPrice: definition.unitPrice,
geometryBasis: definition.geometryBasis,
geometryFactor: definition.geometryFactor ?? null,
defaultCount: definition.defaultCount ?? null,
defaultQuantity: definition.defaultQuantity ?? null,
timeUnit: definition.timeUnit,
pipeLengthMetersPerPiece: definition.pipeLengthMetersPerPiece ?? null,
priceSource: definition.priceSource,
validationStatus: definition.validationStatus,
isActive: definition.isActive,
materials: definition.materials.map(({ materialId, ...line }) => line),
tasks: definition.tasks
})).digest('hex');
const isUnchangedSeed = (row, definition) => Boolean(
row
&& Number(row.is_active) === Number(definition.isActive)
&& row.validation_status === definition.validationStatus
&& String(row.validation_notes || '').includes(`[CONTENT_HASH:${definitionContentHash(definition)}]`)
);
const findMaterial = async (connection, line) => {
const [rows] = await connection.execute('SELECT id FROM materials WHERE sku = ? LIMIT 1', [line.sku]);
return rows[0]?.id || null;
};
const ensureMaterial = async (connection, line) => {
let materialId = await findMaterial(connection, line);
if (materialId) return materialId;
const [insert] = await connection.execute(`
INSERT INTO materials (sku, name, description, unit, category, specifications)
VALUES (?, ?, ?, ?, ?, ?)
`, [line.sku, line.name, materialProvenanceNote(line), line.unit, line.category, JSON.stringify({ sourceUrl: line.sourceUrl, sourceDate: line.sourceDate, vatStatus: line.vatStatus, sourceNote: line.sourceNote })]);
materialId = insert.insertId;
await connection.execute(`
INSERT INTO material_prices (name,material_id,price,currency,valid_from,confidence_score,source_document,parse_log,is_active,category,unit,supplier_name,sku,confidence,document_date)
VALUES (?,?,?,'DKK',?,0.50,?,?,1,?,?,?,?,0.50,?)
`, [line.name, materialId, line.unitPrice, line.sourceDate, line.sourceUrl, materialProvenanceNote(line), line.category, line.unit, line.supplier, line.sku, line.sourceDate]);
return materialId;
};
const inspectDefinition = async (connection, definition) => {
const materials = [];
for (const line of definition.materials) {
const materialId = await findMaterial(connection, line);
materials.push({ line, materialId, action: materialId ? 'reuse' : 'insert' });
}
const marker = `[GUTTER_METER:${definition.key}]`;
const [existingRows] = await connection.execute(`
SELECT id,validation_notes,is_active,validation_status,version
FROM material_packages
WHERE validation_notes LIKE ?
ORDER BY version DESC,id DESC
`, [`%${marker}%`]);
let identityRows = existingRows;
if (definition.legacyName) {
const [legacyRows] = await connection.execute(`
SELECT id,validation_notes,is_active,validation_status,version
FROM material_packages
WHERE name=? AND created_by='excel-import' AND package_type='component'
AND unit IN ('lbm','løbende m')
ORDER BY version DESC,id DESC
`, [definition.legacyName]);
identityRows = [...new Map([...existingRows, ...legacyRows].map(row => [Number(row.id), row])).values()]
.sort((a, b) => Number(b.version || 0) - Number(a.version || 0) || Number(b.id || 0) - Number(a.id || 0));
}
const previous = identityRows[0] || null;
const activeRows = identityRows.filter(row => Number(row.is_active) === 1);
if (activeRows.length > 1) throw new Error(`Gutter package ${definition.key} has multiple active versions`);
const activePrevious = activeRows[0] || null;
const desiredPrevious = definition.isActive ? activePrevious : previous;
const unchanged = Boolean(desiredPrevious && isUnchangedSeed(desiredPrevious, definition));
return {
definition,
materials,
previous,
activePrevious,
unchanged,
version: unchanged ? Number(desiredPrevious.version || 1) : Number(previous?.version || 0) + 1
};
};
const packageSummary = (inspection, packageId = inspection.previous?.id || null) => ({
key: inspection.definition.key,
packageId,
previousPackageId: inspection.previous?.id || null,
name: inspection.definition.name,
unit: inspection.definition.unit,
unitPrice: inspection.definition.unitPrice,
vatStatus: inspection.definition.vatStatus,
priceBasisNote: inspection.definition.priceBasisNote,
materials: inspection.definition.materials.length,
tasks: inspection.definition.tasks.length,
version: inspection.version,
unchanged: inspection.unchanged
});
const seed = async ({ apply = false, connection: suppliedConnection, definitions: suppliedDefinitions } = {}) => {
// Keep the migration's pure planning helpers importable in backend-only
// installs. The CLI database driver is needed only when no connection is
// injected (tests and callers supply one explicitly).
if (!suppliedConnection) loadCliEnvironment();
const pool = suppliedConnection ? null : require('mysql2/promise').createPool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT || 3306), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, connectionLimit: 1 });
const connection = suppliedConnection || await pool.getConnection();
const definitions = suppliedDefinitions || buildGutterPackageDefinitions();
const planned = { materials: [], materialPrices: [], packages: [], packageVersions: [] };
const result = [];
let transactionStarted = false;
let advisoryLockAcquired = false;
try {
if (apply) {
const [lockRows] = await connection.execute('SELECT GET_LOCK(?, 10) AS acquired', [APPLY_LOCK_NAME]);
if (Number(lockRows[0]?.acquired) !== 1) throw new Error('Could not acquire gutter package apply lock');
advisoryLockAcquired = true;
}
const inspections = [];
for (const definition of definitions) {
const inspection = await inspectDefinition(connection, definition);
inspections.push(inspection);
if (!inspection.unchanged) {
for (const material of inspection.materials.filter(item => item.action === 'insert')) {
planned.materials.push({ sku: material.line.sku, name: material.line.name, action: 'insert' });
planned.materialPrices.push({ sku: material.line.sku, price: material.line.unitPrice, action: 'insert' });
}
const versionPlan = { key: definition.key, previousPackageId: inspection.previous?.id || null, version: inspection.version, action: 'insert' };
planned.packages.push({ ...versionPlan, name: definition.name });
planned.packageVersions.push(versionPlan);
}
}
if (!apply) {
return {
mode: 'dry-run',
packages: inspections.map(inspection => packageSummary(inspection)),
planned
};
}
if (inspections.every(inspection => inspection.unchanged)) {
return {
mode: 'apply',
packages: inspections.map(inspection => packageSummary(inspection)),
planned
};
}
await connection.beginTransaction();
transactionStarted = true;
for (const inspection of inspections) {
const { definition } = inspection;
if (inspection.unchanged) {
result.push(packageSummary(inspection));
continue;
}
const materialIds = new Map();
for (const material of inspection.materials) {
const materialId = material.materialId || await ensureMaterial(connection, material.line);
materialIds.set(material.line.sku, materialId);
}
const marker = `[GUTTER_METER:${definition.key}]`;
const contentMarker = `[CONTENT_HASH:${definitionContentHash(definition)}]`;
const catalogIdentity = `[CATALOG_ID:${definition.key}:v${inspection.version}]`;
const validationNotes = `${marker} ${catalogIdentity} ${contentMarker}`;
const laborHours = definition.tasks.reduce((sum, task) => sum + task.timePerUnit, 0);
if (inspection.activePrevious) {
const [archive] = await connection.execute(`
UPDATE material_packages
SET is_active=0, validation_status='archived'
WHERE id=? AND is_active=1
`, [inspection.activePrevious.id]);
if (Number(archive.affectedRows) !== 1) {
throw new Error(`Gutter package version conflict for ${definition.key}`);
}
}
const [insert] = await connection.execute(`
INSERT INTO material_packages (name,description,category,estimated_hours,hourly_rate,total_estimated_price,is_active,area_based,time_per_sqm,hours_unit,package_type,validation_status,validation_notes,validated_at,version,unit,geometry_basis,geometry_factor,default_count,default_quantity,time_per_unit,unit_price,price_per_unit,standard_price,price_basis_note,price_source,price_source_value,created_by)
VALUES (?,?,?, ?,600,?,?,0,0,?,'component',?,?,NOW(),?,?,?,?,?,?,?,?,?,?,?,?,?,'system-gutter-meter-packages')
`, [definition.name, definition.description, definition.category || (definition.unit === 'stk' ? 'Nedløbsrør' : 'Tagrender'), laborHours, definition.unitPrice, definition.isActive, definition.timeUnit, definition.validationStatus, validationNotes, inspection.version, definition.unit, definition.geometryBasis, definition.geometryFactor ?? null, definition.defaultCount ?? null, definition.defaultQuantity ?? null, laborHours, definition.unitPrice, definition.unitPrice, definition.unitPrice, definition.priceBasisNote, definition.priceSource, definition.key]);
const packageId = insert.insertId;
for (const line of definition.materials) {
await connection.execute(`
INSERT INTO package_materials (package_id,material_id,material_match_status,material_match_score,material_name,material_category,quantity,unit,unit_price,total_price,supplier,notes,geometry_multiplier,base_quantity,waste_factor,item_code,price_note,excel_raw_data)
VALUES (?,?,'manual_verified',1,?,?,?,?,?,?,?, ?, ?,?,1,?,?,?)
`, [packageId, materialIds.get(line.sku), line.name, line.category, line.quantity, line.unit, line.unitPrice, roundMoney(line.quantity * line.unitPrice), line.supplier, materialProvenanceNote(line), line.geometryMultiplier ?? null, line.quantity, line.sku, materialProvenanceNote(line), JSON.stringify({ componentType: line.componentType || null, lengthPerPieceMeters: line.lengthPerPieceMeters || null, physicalQuantityPerPrimary: line.physicalQuantityPerPrimary || null, piecesPerPurchaseUnit: line.piecesPerPurchaseUnit || null, physicalUnit: line.physicalUnit || null })]);
}
for (const task of definition.tasks) {
await connection.execute(`
INSERT INTO smart_package_tasks (package_id,name,description,hours,rate,task_order,time_unit,time_per_unit,price_basis_note)
VALUES (?,?,?,?,?,?,?,?,?)
`, [packageId, task.name, `${task.name} pr. ${definition.unit}`, task.timePerUnit, task.rate, task.order, definition.timeUnit, task.timePerUnit, `Prisgrundlag pr. ${definition.unit}`]);
await connection.execute(`
INSERT INTO package_tasks (package_id,task_name,task_description,estimated_hours,task_order,task_phase)
VALUES (?,?,?,?,?,?)
`, [packageId, task.name, `${task.name} pr. ${definition.unit}`, task.timePerUnit, task.order, task.order === 1 ? 'forberedelse' : (task.order === definition.tasks.length ? 'inspektion' : 'hovedarbejde')]);
}
result.push(packageSummary(inspection, packageId));
}
await connection.commit();
return { mode: 'apply', packages: result, planned };
} catch (error) {
if (transactionStarted) await connection.rollback();
throw error;
} finally {
if (advisoryLockAcquired) await connection.execute('SELECT RELEASE_LOCK(?) AS released', [APPLY_LOCK_NAME]);
if (pool) {
connection.release();
await pool.end();
}
}
};
const resolveApplyMode = (argv = process.argv.slice(2), env = process.env) => {
if (argv.includes('--dry-run')) return false;
return argv.includes('--apply') || env.APPLY === '1';
};
module.exports = { buildGutterPackageDefinitions, definitionContentHash, isUnchangedSeed, ensureMaterial, materialProvenanceNote, resolveApplyMode, seed };
if (require.main === module) {
seed({ apply: resolveApplyMode() })
.then(result => console.log(JSON.stringify(result, null, 2)))
.catch(error => { console.error(error); process.exit(1); });
}