feat: deliver auditable Smart Pakke quote flow and free site geometry (#31)
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
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]>
This commit is contained in:
co-authored by
Claude Sonnet 5
alexpolo1
parent
8b16d35287
commit
f37adae2cb
@@ -0,0 +1,9 @@
|
||||
-- Explicit package-level geometry contract used by the Lego builder.
|
||||
ALTER TABLE material_packages
|
||||
ADD COLUMN IF NOT EXISTS geometry_basis VARCHAR(40) NULL AFTER unit,
|
||||
ADD COLUMN IF NOT EXISTS geometry_factor DECIMAL(10,3) NULL AFTER geometry_basis,
|
||||
ADD COLUMN IF NOT EXISTS default_count DECIMAL(10,3) NULL AFTER geometry_factor,
|
||||
ADD COLUMN IF NOT EXISTS default_quantity DECIMAL(10,3) NULL AFTER default_count;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_material_packages_geometry_basis
|
||||
ON material_packages (geometry_basis);
|
||||
@@ -0,0 +1,387 @@
|
||||
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); });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Versioned Smart Package workspace. The JSON preserves Lego block instances,
|
||||
-- ordering, geometry formulas and manual overrides; flattened project rows remain
|
||||
-- the authority used by Final Review, PDF and exports.
|
||||
ALTER TABLE project_materials
|
||||
ADD COLUMN IF NOT EXISTS material_id INT NULL AFTER project_id,
|
||||
ADD COLUMN IF NOT EXISTS package_instance_id VARCHAR(100) NULL AFTER project_id,
|
||||
ADD COLUMN IF NOT EXISTS source_package_id INT NULL AFTER package_instance_id,
|
||||
ADD COLUMN IF NOT EXISTS price_source VARCHAR(120) NULL AFTER material_source,
|
||||
ADD COLUMN IF NOT EXISTS price_source_updated_at DATETIME NULL AFTER price_source,
|
||||
MODIFY COLUMN material_source ENUM('manual','database','bygma_api','package') DEFAULT 'manual';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_materials_material_id
|
||||
ON project_materials (material_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_materials_package_instance
|
||||
ON project_materials (project_id, package_instance_id);
|
||||
|
||||
ALTER TABLE project_rentals
|
||||
ADD COLUMN IF NOT EXISTS package_instance_id VARCHAR(100) NULL AFTER project_id,
|
||||
ADD COLUMN IF NOT EXISTS source_package_id INT NULL AFTER package_instance_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_rentals_package_instance
|
||||
ON project_rentals (project_id, package_instance_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_smart_package_workspaces (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id INT NOT NULL,
|
||||
version INT NOT NULL DEFAULT 0,
|
||||
workspace_json JSON NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_project_workspace (project_id),
|
||||
CONSTRAINT fk_smart_package_workspace_project
|
||||
FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- project_labor is an upsert target and must contain exactly one row per project.
|
||||
-- Retain the newest legacy row before adding the idempotent unique constraint.
|
||||
DELETE stale
|
||||
FROM project_labor stale
|
||||
JOIN project_labor newest
|
||||
ON newest.project_id = stale.project_id AND newest.id > stale.id;
|
||||
SELECT project_id, COUNT(*) AS remaining_rows
|
||||
FROM project_labor
|
||||
GROUP BY project_id
|
||||
HAVING COUNT(*) > 1;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS unique_project_labor ON project_labor (project_id);
|
||||
@@ -0,0 +1,27 @@
|
||||
const path = require('path');
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../backend/.env') });
|
||||
const { ensureAuthAccountsTable } = require('../../backend/src/services/authAccountSchema');
|
||||
|
||||
async function run() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureAuthAccountsTable(connection);
|
||||
|
||||
console.log('auth_accounts table migration complete');
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(error => {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
const path = require('path');
|
||||
const { createRequire } = require('module');
|
||||
const backendRequire = createRequire(path.join(__dirname, '../../backend/package.json'));
|
||||
|
||||
const loadCliEnvironment = () => {
|
||||
backendRequire('dotenv').config({ path: path.join(__dirname, '../../backend/.env') });
|
||||
};
|
||||
|
||||
const SUPPORTED_ROOF_FORMS = Object.freeze([
|
||||
'gable', 'pult', 'flat', 'fladt_tag', 'skraat_tag', 'mansard', 'hip'
|
||||
]);
|
||||
const PITCHED_ROOF_FORMS = Object.freeze(['gable', 'pult', 'mansard', 'hip']);
|
||||
const ALL_ROOF_FORMS = Object.freeze([...PITCHED_ROOF_FORMS, 'flat']);
|
||||
|
||||
const contract = (catalogKey, exactName, material, allowedRoofForms) => Object.freeze({
|
||||
catalogKey,
|
||||
exactName,
|
||||
replacementScope: 'complete_roof_replacement',
|
||||
compatibleRoofMaterials: Object.freeze([material]),
|
||||
allowedRoofForms,
|
||||
// No manufacturer/source-backed pitch limits are available for these seeds.
|
||||
minPitchDegrees: null,
|
||||
maxPitchDegrees: null,
|
||||
pitchVerificationStatus: 'unverified',
|
||||
pitchReviewRequired: true,
|
||||
geometryBasis: 'roof_area'
|
||||
});
|
||||
|
||||
const COMPLETE_ROOF_PACKAGE_CONTRACTS = Object.freeze([
|
||||
contract('betontag_renovering', 'Komplet tagskift — betontag', 'betontag', PITCHED_ROOF_FORMS),
|
||||
contract('tegl_tag_renovering', 'Komplet tagskift — vingefalstegl', 'tegl', PITCHED_ROOF_FORMS),
|
||||
contract('b7_tag_udskiftning', 'Komplet tagskift — Swisspearl B7', 'eternit', PITCHED_ROOF_FORMS),
|
||||
contract('staaltag_renovering', 'Komplet tagskift — ståltag', 'staal', PITCHED_ROOF_FORMS),
|
||||
contract('tagpap_renovering', 'Komplet tagrenovering — 2-lags tagpap', 'tagpap', ALL_ROOF_FORMS)
|
||||
]);
|
||||
|
||||
const PACKAGE_COLUMNS = Object.freeze([
|
||||
['catalog_key', 'VARCHAR(191) NULL AFTER id'],
|
||||
['geometry_basis', 'VARCHAR(40) NULL AFTER unit'],
|
||||
['replacement_scope', 'VARCHAR(40) NULL AFTER default_quantity'],
|
||||
['compatible_roof_materials', 'JSON NULL AFTER replacement_scope'],
|
||||
['allowed_roof_forms', 'JSON NULL AFTER compatible_roof_materials'],
|
||||
['min_pitch_degrees', 'DECIMAL(5,2) NULL AFTER allowed_roof_forms'],
|
||||
['max_pitch_degrees', 'DECIMAL(5,2) NULL AFTER min_pitch_degrees'],
|
||||
['pitch_verification_status', "VARCHAR(30) NOT NULL DEFAULT 'unverified' AFTER max_pitch_degrees"],
|
||||
['pitch_review_required', 'TINYINT(1) NOT NULL DEFAULT 1 AFTER pitch_verification_status']
|
||||
]);
|
||||
|
||||
const ROOF_GEOMETRY_COLUMNS = Object.freeze([
|
||||
['roof_covering_area', "DECIMAL(10,2) NULL COMMENT 'Faktisk tagbeklædningsareal i m²' AFTER total_area"],
|
||||
['wall_height', 'DECIMAL(8,2) NULL AFTER roof_height'],
|
||||
['geometry_json', 'JSON NULL'],
|
||||
['edges_json', 'JSON NULL'],
|
||||
['planes_json', 'JSON NULL'],
|
||||
['openings_json', 'JSON NULL'],
|
||||
['provenance_json', 'JSON NULL'],
|
||||
['replacement_scope', 'VARCHAR(80) NULL']
|
||||
]);
|
||||
|
||||
const CATALOG_KEY_INDEX = 'uq_material_packages_catalog_key';
|
||||
const PACKAGE_READ_COLUMNS = Object.freeze([
|
||||
'id', 'name', 'catalog_key', 'replacement_scope', 'compatible_roof_materials',
|
||||
'allowed_roof_forms', 'min_pitch_degrees', 'max_pitch_degrees',
|
||||
'pitch_verification_status', 'pitch_review_required', 'geometry_basis'
|
||||
]);
|
||||
|
||||
const readSchema = async connection => {
|
||||
const [columns] = await connection.execute(`
|
||||
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ('material_packages', 'roof_geometry')
|
||||
`);
|
||||
const [indexes] = await connection.execute(`
|
||||
SELECT INDEX_NAME, NON_UNIQUE
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'material_packages'
|
||||
AND INDEX_NAME = ?
|
||||
`, [CATALOG_KEY_INDEX]);
|
||||
return { columns, indexes };
|
||||
};
|
||||
|
||||
const columnsFor = (schema, tableName) => new Set(
|
||||
schema.columns.filter(row => row.TABLE_NAME === tableName).map(row => row.COLUMN_NAME)
|
||||
);
|
||||
|
||||
const assertInspectablePackageSchema = packageColumns => {
|
||||
['id', 'name', 'validation_status', 'is_active'].forEach(column => {
|
||||
if (!packageColumns.has(column)) {
|
||||
throw new Error(`missing required pre-schema column: material_packages.${column}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const packageSelectList = packageColumns => PACKAGE_READ_COLUMNS
|
||||
.map(column => packageColumns.has(column) ? column : `NULL AS ${column}`)
|
||||
.join(', ');
|
||||
|
||||
const readActiveVerifiedPackages = async (connection, packageColumns, { lock = false, catalogKeys = null } = {}) => {
|
||||
assertInspectablePackageSchema(packageColumns);
|
||||
const params = [];
|
||||
let keyClause = '';
|
||||
if (catalogKeys) {
|
||||
keyClause = ` AND catalog_key IN (${catalogKeys.map(() => '?').join(', ')})`;
|
||||
params.push(...catalogKeys);
|
||||
}
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT ${packageSelectList(packageColumns)}
|
||||
FROM material_packages
|
||||
WHERE validation_status = 'verified' AND is_active = 1${keyClause}
|
||||
${lock ? 'FOR UPDATE' : ''}
|
||||
`, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const readPackagesByCatalogKeys = async (connection, packageColumns, { lock = false } = {}) => {
|
||||
assertInspectablePackageSchema(packageColumns);
|
||||
if (!packageColumns.has('catalog_key')) return [];
|
||||
const keys = COMPLETE_ROOF_PACKAGE_CONTRACTS.map(item => item.catalogKey);
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT ${packageSelectList(packageColumns)}
|
||||
FROM material_packages
|
||||
WHERE catalog_key IN (${keys.map(() => '?').join(', ')})
|
||||
${lock ? 'FOR UPDATE' : ''}
|
||||
`, keys);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const migrationCandidates = async (connection, packageColumns, { lock = false } = {}) => {
|
||||
const activeRows = await readActiveVerifiedPackages(connection, packageColumns, { lock });
|
||||
const keyedRows = await readPackagesByCatalogKeys(connection, packageColumns, { lock });
|
||||
return [...new Map([...activeRows, ...keyedRows].map(row => [Number(row.id), row])).values()];
|
||||
};
|
||||
|
||||
const ensureDistinctResolvedRows = resolved => {
|
||||
if (new Set(resolved.map(({ row }) => row.id)).size !== resolved.length) {
|
||||
throw new Error('ambiguous complete-roof identity collision');
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const resolveSeedIdentities = rows => ensureDistinctResolvedRows(
|
||||
COMPLETE_ROOF_PACKAGE_CONTRACTS.map(item => {
|
||||
const keyMatches = rows.filter(row => row.catalog_key === item.catalogKey);
|
||||
if (keyMatches.length > 1) {
|
||||
throw new Error(`ambiguous complete-roof catalog key: ${item.catalogKey}`);
|
||||
}
|
||||
if (keyMatches.length === 1) return { contract: item, row: keyMatches[0], needsCatalogKey: false };
|
||||
|
||||
const nameMatches = rows.filter(row => row.name === item.exactName && !row.catalog_key);
|
||||
if (nameMatches.length > 1) {
|
||||
throw new Error(`ambiguous complete-roof exact seed identity: ${item.exactName}`);
|
||||
}
|
||||
if (nameMatches.length === 0) {
|
||||
throw new Error(`missing complete-roof package: ${item.catalogKey}`);
|
||||
}
|
||||
return { contract: item, row: nameMatches[0], needsCatalogKey: true };
|
||||
})
|
||||
);
|
||||
|
||||
const resolveByCatalogKey = rows => ensureDistinctResolvedRows(
|
||||
COMPLETE_ROOF_PACKAGE_CONTRACTS.map(item => {
|
||||
const matches = rows.filter(row => row.catalog_key === item.catalogKey);
|
||||
if (matches.length > 1) throw new Error(`ambiguous complete-roof catalog key: ${item.catalogKey}`);
|
||||
if (matches.length === 0) throw new Error(`missing complete-roof catalog key: ${item.catalogKey}`);
|
||||
return { contract: item, row: matches[0] };
|
||||
})
|
||||
);
|
||||
|
||||
const enumNeedsUpgrade = schema => {
|
||||
const roofType = schema.columns.find(row => row.TABLE_NAME === 'roof_geometry' && row.COLUMN_NAME === 'roof_type');
|
||||
const currentType = String(roofType?.COLUMN_TYPE || '').toLowerCase();
|
||||
return Boolean(currentType) && SUPPORTED_ROOF_FORMS.some(form => !currentType.includes(`'${form}'`));
|
||||
};
|
||||
|
||||
const plannedSchemaChanges = schema => {
|
||||
const packageColumns = columnsFor(schema, 'material_packages');
|
||||
const roofColumns = columnsFor(schema, 'roof_geometry');
|
||||
const changes = [];
|
||||
PACKAGE_COLUMNS.forEach(([column, definition]) => {
|
||||
if (!packageColumns.has(column)) changes.push({ table: 'material_packages', column, definition });
|
||||
});
|
||||
ROOF_GEOMETRY_COLUMNS.forEach(([column, definition]) => {
|
||||
if (!roofColumns.has(column)) changes.push({ table: 'roof_geometry', column, definition });
|
||||
});
|
||||
if (enumNeedsUpgrade(schema)) changes.push({ table: 'roof_geometry', column: 'roof_type', action: 'replace-supported-enum' });
|
||||
const index = schema.indexes.find(row => row.INDEX_NAME === CATALOG_KEY_INDEX);
|
||||
if (!index) changes.push({ table: 'material_packages', index: CATALOG_KEY_INDEX, unique: true, column: 'catalog_key' });
|
||||
else if (Number(index.NON_UNIQUE) !== 0) throw new Error(`${CATALOG_KEY_INDEX} exists but is not unique`);
|
||||
return changes;
|
||||
};
|
||||
|
||||
const parseJsonArray = value => {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const arraysEqual = (left, right) => (
|
||||
Array.isArray(left)
|
||||
&& left.length === right.length
|
||||
&& left.every((value, index) => value === right[index])
|
||||
);
|
||||
|
||||
const nullableNumberEqual = (value, expected) => (
|
||||
expected === null
|
||||
? value === null || value === undefined || value === ''
|
||||
: Number(value) === expected
|
||||
);
|
||||
|
||||
const rowHasContract = (row, item) => (
|
||||
row.replacement_scope === item.replacementScope
|
||||
&& arraysEqual(parseJsonArray(row.compatible_roof_materials), item.compatibleRoofMaterials)
|
||||
&& arraysEqual(parseJsonArray(row.allowed_roof_forms), item.allowedRoofForms)
|
||||
&& nullableNumberEqual(row.min_pitch_degrees, item.minPitchDegrees)
|
||||
&& nullableNumberEqual(row.max_pitch_degrees, item.maxPitchDegrees)
|
||||
&& row.pitch_verification_status === item.pitchVerificationStatus
|
||||
&& Number(row.pitch_review_required) === Number(item.pitchReviewRequired)
|
||||
&& row.geometry_basis === item.geometryBasis
|
||||
);
|
||||
|
||||
const inspectCompleteRoofMigration = async connection => {
|
||||
const schema = await readSchema(connection);
|
||||
const packageColumns = columnsFor(schema, 'material_packages');
|
||||
const rows = await migrationCandidates(connection, packageColumns);
|
||||
const resolved = resolveSeedIdentities(rows);
|
||||
return {
|
||||
dryRun: true,
|
||||
plannedSchema: plannedSchemaChanges(schema),
|
||||
backfills: resolved.map(({ contract: item, row, needsCatalogKey }) => ({
|
||||
catalogKey: item.catalogKey,
|
||||
packageId: row.id,
|
||||
catalogKeyAction: needsCatalogKey ? 'backfill-exact-seed-identity' : 'already-persisted',
|
||||
contractAction: rowHasContract(row, item) ? 'unchanged' : 'update',
|
||||
pitchVerificationStatus: item.pitchVerificationStatus,
|
||||
pitchReviewRequired: item.pitchReviewRequired
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
const ensureColumn = async (connection, tableName, columnName, definition) => {
|
||||
const [rows] = await connection.execute(
|
||||
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
await connection.execute(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureRoofTypeEnum = async connection => {
|
||||
const [rows] = await connection.execute(
|
||||
`SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'roof_geometry' AND COLUMN_NAME = 'roof_type'`
|
||||
);
|
||||
const currentType = String(rows[0]?.COLUMN_TYPE || '').toLowerCase();
|
||||
if (currentType && SUPPORTED_ROOF_FORMS.some(form => !currentType.includes(`'${form}'`))) {
|
||||
await connection.execute(`
|
||||
ALTER TABLE roof_geometry
|
||||
MODIFY COLUMN roof_type ENUM('gable','pult','flat','fladt_tag','skraat_tag','mansard','hip','komplekst') NOT NULL
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCatalogKeyIndex = async connection => {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT INDEX_NAME, NON_UNIQUE FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'material_packages' AND INDEX_NAME = ?
|
||||
`, [CATALOG_KEY_INDEX]);
|
||||
if (rows.length === 0) {
|
||||
await connection.execute(`CREATE UNIQUE INDEX ${CATALOG_KEY_INDEX} ON material_packages (catalog_key)`);
|
||||
} else if (rows.some(row => Number(row.NON_UNIQUE) !== 0)) {
|
||||
throw new Error(`${CATALOG_KEY_INDEX} exists but is not unique`);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCompleteRoofSchema = async connection => {
|
||||
await ensureRoofTypeEnum(connection);
|
||||
for (const [column, definition] of PACKAGE_COLUMNS) {
|
||||
await ensureColumn(connection, 'material_packages', column, definition);
|
||||
}
|
||||
for (const [column, definition] of ROOF_GEOMETRY_COLUMNS) {
|
||||
await ensureColumn(connection, 'roof_geometry', column, definition);
|
||||
}
|
||||
await ensureCatalogKeyIndex(connection);
|
||||
};
|
||||
|
||||
const completePackageColumnSet = () => new Set([
|
||||
'id', 'name', 'validation_status', 'is_active', 'geometry_basis', ...PACKAGE_COLUMNS.map(([column]) => column)
|
||||
]);
|
||||
|
||||
const applyCompleteRoofPackageBackfill = async connection => {
|
||||
const packageColumns = completePackageColumnSet();
|
||||
const rows = await migrationCandidates(connection, packageColumns, { lock: true });
|
||||
const seedIdentities = resolveSeedIdentities(rows);
|
||||
const catalogKeyWrites = seedIdentities.filter(item => item.needsCatalogKey);
|
||||
|
||||
// Resolve the complete exact-name seed set before the first write. Exact names
|
||||
// are used only for this one-time identity assignment, never for contract updates.
|
||||
for (const { contract: item, row } of catalogKeyWrites) {
|
||||
const [result] = await connection.execute(`
|
||||
UPDATE material_packages
|
||||
SET catalog_key = ?
|
||||
WHERE id = ? AND catalog_key IS NULL
|
||||
AND validation_status = 'verified' AND is_active = 1
|
||||
`, [item.catalogKey, row.id]);
|
||||
if (Number(result.affectedRows || 0) !== 1) {
|
||||
throw new Error(`missing complete-roof package during catalog key backfill: ${item.catalogKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveByCatalogKey(
|
||||
await readPackagesByCatalogKeys(connection, packageColumns, { lock: true })
|
||||
);
|
||||
|
||||
let changedRows = 0;
|
||||
for (const { contract: item, row } of resolved) {
|
||||
if (rowHasContract(row, item)) continue;
|
||||
const [result] = await connection.execute(`
|
||||
UPDATE material_packages
|
||||
SET replacement_scope = ?,
|
||||
compatible_roof_materials = ?,
|
||||
allowed_roof_forms = ?,
|
||||
min_pitch_degrees = ?,
|
||||
max_pitch_degrees = ?,
|
||||
pitch_verification_status = ?,
|
||||
pitch_review_required = ?,
|
||||
geometry_basis = ?
|
||||
WHERE catalog_key = ?
|
||||
`, [
|
||||
item.replacementScope,
|
||||
JSON.stringify(item.compatibleRoofMaterials),
|
||||
JSON.stringify(item.allowedRoofForms),
|
||||
item.minPitchDegrees,
|
||||
item.maxPitchDegrees,
|
||||
item.pitchVerificationStatus,
|
||||
item.pitchReviewRequired ? 1 : 0,
|
||||
item.geometryBasis,
|
||||
item.catalogKey
|
||||
]);
|
||||
if (Number(result.affectedRows || 0) !== 1) {
|
||||
throw new Error(`missing complete-roof package during update: ${item.catalogKey}`);
|
||||
}
|
||||
changedRows += 1;
|
||||
}
|
||||
return {
|
||||
matchedPackageIds: resolved.map(({ row }) => row.id),
|
||||
changedRows,
|
||||
catalogKeysBackfilled: catalogKeyWrites.length
|
||||
};
|
||||
};
|
||||
|
||||
const migrate = async connection => {
|
||||
await ensureCompleteRoofSchema(connection);
|
||||
return applyCompleteRoofPackageBackfill(connection);
|
||||
};
|
||||
|
||||
// Defer the CLI-only database dependency so the exported inspection and
|
||||
// migration helpers remain testable from a backend-only npm installation.
|
||||
const createPool = () => {
|
||||
loadCliEnvironment();
|
||||
return backendRequire('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 run = async ({ dryRun = false, pool = createPool(), log = console.log } = {}) => {
|
||||
const connection = await pool.getConnection();
|
||||
let transactionStarted = false;
|
||||
try {
|
||||
if (dryRun) {
|
||||
const plan = await inspectCompleteRoofMigration(connection);
|
||||
log(JSON.stringify(plan, null, 2));
|
||||
return plan;
|
||||
}
|
||||
|
||||
// MySQL DDL implicitly commits, so schema work completes before the
|
||||
// rollback-safe identity and contract backfills.
|
||||
await ensureCompleteRoofSchema(connection);
|
||||
await connection.beginTransaction();
|
||||
transactionStarted = true;
|
||||
const result = await applyCompleteRoofPackageBackfill(connection);
|
||||
await connection.commit();
|
||||
transactionStarted = false;
|
||||
log(JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (transactionStarted) await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
await pool.end();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
COMPLETE_ROOF_PACKAGE_CONTRACTS,
|
||||
PACKAGE_COLUMNS,
|
||||
ROOF_GEOMETRY_COLUMNS,
|
||||
inspectCompleteRoofMigration,
|
||||
ensureColumn,
|
||||
ensureRoofTypeEnum,
|
||||
ensureCompleteRoofSchema,
|
||||
applyCompleteRoofPackageBackfill,
|
||||
migrate,
|
||||
run
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
run({ dryRun: process.argv.includes('--dry-run') }).catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
const path = require('path');
|
||||
const { createRequire } = require('module');
|
||||
const backendRequire = createRequire(path.join(__dirname, '../../backend/package.json'));
|
||||
backendRequire('dotenv').config({ path: path.join(__dirname, '../../backend/.env') });
|
||||
|
||||
const ROOF_LIFECYCLE_VIOLATION_WHERE = `
|
||||
mp.is_active = 1
|
||||
AND mp.validation_status = 'verified'
|
||||
AND (
|
||||
mp.package_type = 'complete_offer'
|
||||
OR NULLIF(TRIM(mp.replacement_scope), '') IS NOT NULL
|
||||
OR (JSON_TYPE(mp.compatible_roof_materials) = 'ARRAY' AND JSON_LENGTH(mp.compatible_roof_materials) > 0)
|
||||
OR (JSON_TYPE(mp.allowed_roof_forms) = 'ARRAY' AND JSON_LENGTH(mp.allowed_roof_forms) > 0)
|
||||
)
|
||||
AND (
|
||||
COALESCE(mp.pitch_verification_status, '') <> 'verified'
|
||||
OR COALESCE(mp.pitch_review_required, 1) <> 0
|
||||
OR COALESCE(JSON_TYPE(mp.compatible_roof_materials), '') <> 'ARRAY'
|
||||
OR COALESCE(JSON_LENGTH(mp.compatible_roof_materials), 0) = 0
|
||||
OR COALESCE(JSON_TYPE(mp.allowed_roof_forms), '') <> 'ARRAY'
|
||||
OR COALESCE(JSON_LENGTH(mp.allowed_roof_forms), 0) = 0
|
||||
OR mp.min_pitch_degrees IS NULL
|
||||
OR mp.max_pitch_degrees IS NULL
|
||||
OR mp.min_pitch_degrees < 0
|
||||
OR mp.max_pitch_degrees > 90
|
||||
OR mp.min_pitch_degrees > mp.max_pitch_degrees
|
||||
)
|
||||
`;
|
||||
|
||||
const readRoofLifecycleViolations = async (connection, { lock = false } = {}) => {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT mp.id
|
||||
FROM material_packages mp
|
||||
WHERE ${ROOF_LIFECYCLE_VIOLATION_WHERE}
|
||||
ORDER BY mp.id
|
||||
${lock ? 'FOR UPDATE' : ''}
|
||||
`);
|
||||
return rows.map(row => Number(row.id));
|
||||
};
|
||||
|
||||
const inspectRoofLifecycleViolations = async connection => {
|
||||
const violatingPackageIds = await readRoofLifecycleViolations(connection);
|
||||
return {
|
||||
dryRun: true,
|
||||
violatingPackageIds,
|
||||
violationCount: violatingPackageIds.length
|
||||
};
|
||||
};
|
||||
|
||||
const transitionRoofLifecycleViolations = async connection => {
|
||||
const ids = await readRoofLifecycleViolations(connection, { lock: true });
|
||||
if (ids.length > 0) {
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const [result] = await connection.execute(`
|
||||
UPDATE material_packages
|
||||
SET is_active = 0,
|
||||
validation_status = 'needs_review',
|
||||
pitch_review_required = 1,
|
||||
validated_at = NULL,
|
||||
version = COALESCE(version, 0) + 1,
|
||||
validation_notes = CONCAT_WS('\n', NULLIF(validation_notes, ''),
|
||||
'[FAIL_CLOSED_ROOF_LIFECYCLE] Deactivated: verified pitch contract is incomplete or requires review.')
|
||||
WHERE id IN (${placeholders})
|
||||
AND is_active = 1
|
||||
AND validation_status = 'verified'
|
||||
`, ids);
|
||||
if (Number(result.affectedRows || 0) !== ids.length) {
|
||||
throw new Error(`roof lifecycle transition count mismatch: expected ${ids.length}, updated ${result.affectedRows || 0}`);
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = await readRoofLifecycleViolations(connection);
|
||||
if (remaining.length > 0) {
|
||||
throw new Error(`active verified roof lifecycle violations remain: ${remaining.join(',')}`);
|
||||
}
|
||||
return {
|
||||
dryRun: false,
|
||||
transitionedPackageIds: ids,
|
||||
transitionedCount: ids.length,
|
||||
remainingViolationCount: 0
|
||||
};
|
||||
};
|
||||
|
||||
const createPool = () => backendRequire('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 run = async ({ apply = false, pool = createPool(), log = console.log } = {}) => {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
if (!apply) {
|
||||
const report = await inspectRoofLifecycleViolations(connection);
|
||||
log(JSON.stringify(report, null, 2));
|
||||
return report;
|
||||
}
|
||||
await connection.beginTransaction();
|
||||
try {
|
||||
const result = await transitionRoofLifecycleViolations(connection);
|
||||
await connection.commit();
|
||||
log(JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
connection.release();
|
||||
if (pool.end && pool !== connection) await pool.end();
|
||||
}
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
run({ apply: process.argv.includes('--apply') }).catch(error => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ROOF_LIFECYCLE_VIOLATION_WHERE,
|
||||
inspectRoofLifecycleViolations,
|
||||
readRoofLifecycleViolations,
|
||||
run,
|
||||
transitionRoofLifecycleViolations
|
||||
};
|
||||
Reference in New Issue
Block a user