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: 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]>
388 lines
26 KiB
JavaScript
388 lines
26 KiB
JavaScript
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); });
|
||
}
|