From f37adae2cb13ac4e9ea3ff33210461c71248a8cf Mon Sep 17 00:00:00 2001 From: Alex <14327609+alexpolo1@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:39:18 +0200 Subject: [PATCH] feat: deliver auditable Smart Pakke quote flow and free site geometry (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 Co-authored-by: Claude Sonnet 5 --- .github/workflows/ci.yml | 15 + .github/workflows/deploy-test.yml | 8 + ...6-09-04_070621-smart-pakker-legoklodser.md | 896 ++++++ .reports/codemap-diff.txt | 13 + README.md | 6 + backend/__tests__/accountBoundary.test.js | 152 + .../__tests__/authAccountBootstrap.test.js | 92 + .../customerProjectsMaterials.test.js | 196 +- .../__tests__/deployRoofMigrations.test.js | 22 + backend/__tests__/envExampleAuth.test.js | 10 + .../haandvaerkPriserImportRegression.test.js | 6 +- .../haandvaerkPriserImportService.test.js | 49 +- .../__tests__/haandvaerkPriserRoutes.test.js | 10 +- .../__tests__/materialPackageService.test.js | 46 + backend/__tests__/nominatim.test.js | 134 + backend/__tests__/offersRoute.test.js | 1071 ++++++- ...rdrestyringOfferOperationBootstrap.test.js | 14 + .../repositoryStatusDiscordService.test.js | 35 + backend/__tests__/seedCiAuthAccount.test.js | 28 + backend/__tests__/siteGeometry.test.js | 47 + .../__tests__/siteGeometryPersistence.test.js | 129 + backend/__tests__/siteGeometryRoutes.test.js | 69 + .../smartPackageChildVersioning.test.js | 130 + .../smartPackageExcelImportService.test.js | 11 +- .../smartPackageManagementContract.test.js | 268 ++ .../smartPackageMaterialMatchService.test.js | 24 + .../smartPackagesReadAuthorization.test.js | 183 ++ .../smartPackagesRecalculate.test.js | 645 +++- backend/__tests__/usersAuthorization.test.js | 49 + ...20260910_ordrestyring_offer_operations.sql | 18 + backend/routes/offers.js | 690 +++- backend/scripts/seed-ci-auth-account.js | 51 + backend/sql/customer_project_system.sql | 103 +- .../__tests__/advancedGeometryService.test.js | 108 +- .../completeRoofPackageContract.test.js | 314 ++ .../customerDocumentLanguage.test.js | 57 + ...stomerProjectsGeometry.integration.test.js | 277 ++ .../databaseServiceSchemaHelpers.test.js | 43 + .../failClosedRoofLifecycleMigration.test.js | 75 + .../__tests__/genericQuoteDelivery.test.js | 142 + backend/src/__tests__/genericQuoteFixtures.js | 54 + .../src/__tests__/genericQuoteRoutes.test.js | 122 + .../genericQuoteSnapshotService.test.js | 523 ++++ .../__tests__/gutterSmartPackageSeed.test.js | 320 ++ .../__tests__/pdfGenerationService.test.js | 446 ++- .../projectCalculationService.test.js | 23 + .../projectQuoteGenerationService.test.js | 55 + .../__tests__/quoteEconomicsService.test.js | 25 + .../src/__tests__/quoteRealismService.test.js | 119 + .../src/__tests__/roofGeometryService.test.js | 322 +- .../__tests__/roofPitchCompatibility.test.js | 23 + .../__tests__/roofQuoteCompleteness.test.js | 181 ++ .../roofQuoteSnapshotService.test.js | 621 ++++ .../__tests__/roofReplacementGeometry.test.js | 258 ++ .../src/__tests__/roofTypeContract.test.js | 61 + .../__tests__/smartPackageGeometry.test.js | 55 + .../smartPackageIntegrityService.test.js | 119 +- .../smartPackageManagementService.test.js | 334 +- .../smartPackageSiteGeometryTrust.test.js | 290 ++ .../smartPackageWorkspaceService.test.js | 828 +++++ backend/src/domain/roofPitchCompatibility.js | 36 + backend/src/domain/roofQuoteCompleteness.js | 312 ++ backend/src/domain/roofReplacementGeometry.js | 278 ++ backend/src/domain/roofTypeContract.js | 48 + backend/src/domain/smartPackageGeometry.js | 92 + backend/src/dto/publicSmartPackageDto.js | 111 + .../mutations/__tests__/createOffer.test.js | 78 + backend/src/graphql/mutations/createOffer.js | 105 + backend/src/middleware/auth.js | 53 + backend/src/routes/auth.js | 67 - backend/src/routes/customerProjects.js | 323 +- backend/src/routes/enhancedFeatures.js | 64 +- backend/src/routes/genericQuoteRoutes.js | 71 + backend/src/routes/siteGeometryRoutes.js | 28 + backend/src/routes/smartPackagesRoutes.js | 835 +++-- backend/src/routes/users.js | 94 + ...restyringOfferNormalizationService.test.js | 124 + ...drestyringOfferOperationStateStore.test.js | 99 + .../src/services/advancedGeometryService.js | 205 +- backend/src/services/authAccountSchema.js | 48 + .../src/services/customerDocumentLanguage.js | 51 + backend/src/services/databaseService.js | 201 +- .../services/genericQuoteDeliveryService.js | 104 + .../services/genericQuoteSnapshotService.js | 654 ++++ .../services/haandvaerkPriserImportService.js | 56 +- .../src/services/materialPackageService.js | 47 +- backend/src/services/nominatimService.js | 261 ++ .../ordrestyringOfferNormalizationService.js | 228 ++ .../ordrestyringOfferOperationSchema.js | 27 + .../ordrestyringOfferOperationStateStore.js | 180 ++ backend/src/services/pdfGenerationService.js | 571 +++- .../src/services/projectCalculationService.js | 28 +- .../services/projectQuoteGenerationService.js | 97 +- backend/src/services/quoteEconomicsService.js | 7 +- backend/src/services/quoteRealismService.js | 106 +- .../repositoryStatusDiscordService.js | 77 +- backend/src/services/roofGeometryService.js | 448 +-- .../src/services/roofQuoteSnapshotService.js | 622 ++++ backend/src/services/siteGeometryMigration.js | 52 + backend/src/services/siteGeometryService.js | 186 ++ .../smartPackageExcelImportService.js | 74 +- .../services/smartPackageIntegrityService.js | 183 +- .../services/smartPackageManagementService.js | 1422 +++++---- .../smartPackageMaterialMatchService.js | 39 + .../services/smartPackageWorkspaceService.js | 773 +++++ backend/src/services/userService.js | 116 + backend/unified-server.js | 1218 ++------ codemaps/architecture.md | 51 + codemaps/backend.md | 82 + codemaps/data.md | 71 + codemaps/frontend.md | 52 + ...260901_smart_package_geometry_contract.sql | 9 + .../20260902_gutter_meter_packages.js | 387 +++ .../20260904_smart_package_workspace.sql | 46 + database/migrations/20260904_users_table.js | 27 + ...20260910_complete_roof_package_contract.js | 426 +++ .../20260918_fail_closed_roof_lifecycle.js | 130 + docs/PR_31_REVIEWER_GUIDE.md | 157 + docs/THIRD_PARTY_NOTICES.md | 42 + docs/features/GENERIC_QUOTE_SNAPSHOT.md | 61 + frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/App.js | 24 +- frontend/src/App.test.js | 11 + frontend/src/components/CalculationView.js | 30 +- frontend/src/components/EnhancedGeometry.css | 195 ++ frontend/src/components/EnhancedGeometry.js | 2768 ++--------------- .../src/components/EnhancedGeometry.test.js | 328 +- frontend/src/components/FinalReview.css | 360 +++ frontend/src/components/FinalReview.js | 1272 ++++++-- frontend/src/components/FinalReview.test.js | 915 +++++- frontend/src/components/GeometryInput.js | 316 +- .../src/components/InlineSmartPackage.css | 117 + frontend/src/components/InlineSmartPackage.js | 538 ++-- frontend/src/components/LaborInput.js | 9 +- frontend/src/components/MaterialLinkReview.js | 5 +- frontend/src/components/MaterialsManager.js | 20 +- frontend/src/components/ProjectCreation.js | 91 +- .../src/components/ProjectCreation.test.js | 275 ++ frontend/src/components/ProjectFlow.css | 54 + frontend/src/components/ProjectFlow.js | 426 +-- frontend/src/components/ProjectFlow.test.js | 721 ++++- frontend/src/components/QuoteRealismPanel.js | 6 +- .../src/components/QuoteRealismPanel.test.js | 3 +- .../src/components/RoofReplacementScope.js | 109 + .../components/RoofReplacementScope.test.js | 73 + .../src/components/SmartPackagesEnhanced.js | 7 +- frontend/src/components/UsersManagement.js | 230 ++ .../siteGeometry/EditableAreaMap.js | 57 + .../siteGeometry/EditableAreaMap.test.js | 22 + .../siteGeometry/SiteGeometryModal.css | 25 + .../siteGeometry/SiteGeometryModal.js | 160 + .../siteGeometry/SiteGeometryModal.test.js | 118 + .../smartPackages/ComponentsLibrary.js | 76 +- .../smartPackages/ComponentsLibrary.test.js | 60 + .../smartPackages/PackageDetailsDialog.js | 91 +- .../smartPackages/QuickStartDialog.js | 258 +- .../smartPackages/SmartPackageBuilder.css | 1 + .../SmartPackageBuilder.css.test.js | 17 + .../smartPackages/SmartPackageBuilder.js | 948 ++++++ .../SmartPackageBuilder.map.test.js | 133 + .../smartPackages/SmartPackageBuilder.test.js | 688 ++++ .../smartPackages/SmartPackageReviewQueue.js | 89 +- .../SmartPackageReviewQueue.test.js | 58 +- .../smartPackages/SmartPackageWizard.js | 370 ++- .../smartPackages/SmartPackageWizard.test.js | 247 ++ .../components/smartPackages/SmartPackages.js | 223 +- .../SmartPackagesManagement.browser.cjs | 107 + .../SmartPackagesManagement.test.js | 235 ++ .../smartPackages/TaskManagement.js | 3 +- .../protectedSmartPackageCallers.test.js | 22 + .../smartPackages/smartPackageCategories.js | 48 + .../smartPackageCategories.test.js | 45 + .../smartPackageManagementQuery.js | 25 + .../smartPackageManagementQuery.test.js | 26 + .../smartPackages/smartPackageSearch.js | 47 + .../smartPackages/smartPackageSearch.test.js | 44 + frontend/src/contexts/AuthContext.js | 2 +- frontend/src/routes/SmartPackagesRoutes.js | 102 +- frontend/src/services/authenticatedFetch.js | 43 + .../src/services/authenticatedFetch.test.js | 60 + .../src/services/genericQuoteReviewService.js | 119 + .../genericQuoteReviewService.test.js | 100 + .../src/services/genericQuoteSnapshotApi.js | 57 + .../services/genericQuoteSnapshotApi.test.js | 60 + .../src/services/projectHydrationService.js | 7 +- .../services/projectHydrationService.test.js | 8 +- .../src/services/projectLineSyncService.js | 83 + .../services/projectLineSyncService.test.js | 82 + frontend/src/services/roofQuoteSnapshotApi.js | 74 + .../src/services/roofQuoteSnapshotApi.test.js | 66 + .../services/smartPackageLibraryService.js | 30 + .../smartPackageLibraryService.test.js | 33 + frontend/src/utils/coalescedSaver.js | 49 + frontend/src/utils/coalescedSaver.test.js | 28 + frontend/src/utils/pdfSourceSignature.js | 38 +- frontend/src/utils/pdfSourceSignature.test.js | 13 + frontend/src/utils/projectGeometry.js | 79 +- frontend/src/utils/projectGeometry.test.js | 45 +- frontend/src/utils/projectLabor.js | 19 +- frontend/src/utils/projectLabor.test.js | 45 + frontend/src/utils/projectLines.js | 88 +- frontend/src/utils/projectLines.test.js | 105 + frontend/src/utils/projectReadiness.js | 66 +- frontend/src/utils/projectReadiness.test.js | 82 +- .../src/utils/roofPackageCompatibility.js | 122 +- .../utils/roofPackageCompatibility.test.js | 138 +- frontend/src/utils/roofReplacementScope.js | 176 ++ .../src/utils/roofReplacementScope.test.js | 131 + frontend/src/utils/roofTypeContract.js | 76 + frontend/src/utils/roofTypeContract.test.js | 65 + .../src/utils/siteGeometryWorkspace.test.js | 15 + frontend/src/utils/smartPackageSelection.js | 161 +- .../src/utils/smartPackageSelection.test.js | 222 +- frontend/src/utils/smartPackageWorkspace.js | 629 ++++ .../src/utils/smartPackageWorkspace.test.js | 620 ++++ .../00 Start/Aktiv dokumentation.md | 14 +- obsidian-vault/00 Start/Dokumentindeks.md | 14 +- obsidian-vault/00 Start/Kildemanifest.tsv | 10 + .../10 Dokumenter/AI og Codex/CLAUDE.md | 19 +- .../Funktioner/GENERIC_QUOTE_SNAPSHOT.md | 61 + .../PR_31_REVIEWER_GUIDE.md | 157 + .../THIRD_PARTY_NOTICES.md | 42 + .../qa/CARPENTER_WALKTHROUGH_RELEASE_GATE.md | 43 + ...8-18_133633-carpenter-walkthrough-fixes.md | 597 ++++ ...6-09-04_070621-smart-pakker-legoklodser.md | 896 ++++++ .../Projektrod og øvrigt/README.md | 6 + .../codemaps/architecture.md | 51 + .../Projektrod og øvrigt/codemaps/backend.md | 82 + .../Projektrod og øvrigt/codemaps/data.md | 71 + .../Projektrod og øvrigt/codemaps/frontend.md | 52 + tests/api-roof-types.spec.js | 57 +- tests/create-roof-quotes.spec.js | 12 +- tests/degree-text-position.spec.js | 2 +- tests/fixtures/site-area.jsx | 31 + tests/full-roof-quote-flow.spec.js | 29 +- tests/roof-type-quotes.spec.js | 19 +- tests/roof-type-verification.spec.js | 10 +- tests/roof-types-api.spec.js | 57 +- tests/simple-roof-test.spec.js | 16 +- tests/site-area.config.js | 2 + tests/site-area.spec.js | 301 ++ 242 files changed, 35912 insertions(+), 7286 deletions(-) create mode 100644 .hermes/plans/2026-09-04_070621-smart-pakker-legoklodser.md create mode 100644 .reports/codemap-diff.txt create mode 100644 backend/__tests__/accountBoundary.test.js create mode 100644 backend/__tests__/authAccountBootstrap.test.js create mode 100644 backend/__tests__/deployRoofMigrations.test.js create mode 100644 backend/__tests__/envExampleAuth.test.js create mode 100644 backend/__tests__/materialPackageService.test.js create mode 100644 backend/__tests__/nominatim.test.js create mode 100644 backend/__tests__/ordrestyringOfferOperationBootstrap.test.js create mode 100644 backend/__tests__/seedCiAuthAccount.test.js create mode 100644 backend/__tests__/siteGeometry.test.js create mode 100644 backend/__tests__/siteGeometryPersistence.test.js create mode 100644 backend/__tests__/siteGeometryRoutes.test.js create mode 100644 backend/__tests__/smartPackageChildVersioning.test.js create mode 100644 backend/__tests__/smartPackageManagementContract.test.js create mode 100644 backend/__tests__/smartPackagesReadAuthorization.test.js create mode 100644 backend/__tests__/usersAuthorization.test.js create mode 100644 backend/migrations/20260910_ordrestyring_offer_operations.sql create mode 100644 backend/scripts/seed-ci-auth-account.js create mode 100644 backend/src/__tests__/completeRoofPackageContract.test.js create mode 100644 backend/src/__tests__/customerDocumentLanguage.test.js create mode 100644 backend/src/__tests__/customerProjectsGeometry.integration.test.js create mode 100644 backend/src/__tests__/databaseServiceSchemaHelpers.test.js create mode 100644 backend/src/__tests__/failClosedRoofLifecycleMigration.test.js create mode 100644 backend/src/__tests__/genericQuoteDelivery.test.js create mode 100644 backend/src/__tests__/genericQuoteFixtures.js create mode 100644 backend/src/__tests__/genericQuoteRoutes.test.js create mode 100644 backend/src/__tests__/genericQuoteSnapshotService.test.js create mode 100644 backend/src/__tests__/gutterSmartPackageSeed.test.js create mode 100644 backend/src/__tests__/roofPitchCompatibility.test.js create mode 100644 backend/src/__tests__/roofQuoteCompleteness.test.js create mode 100644 backend/src/__tests__/roofQuoteSnapshotService.test.js create mode 100644 backend/src/__tests__/roofReplacementGeometry.test.js create mode 100644 backend/src/__tests__/roofTypeContract.test.js create mode 100644 backend/src/__tests__/smartPackageGeometry.test.js create mode 100644 backend/src/__tests__/smartPackageSiteGeometryTrust.test.js create mode 100644 backend/src/__tests__/smartPackageWorkspaceService.test.js create mode 100644 backend/src/domain/roofPitchCompatibility.js create mode 100644 backend/src/domain/roofQuoteCompleteness.js create mode 100644 backend/src/domain/roofReplacementGeometry.js create mode 100644 backend/src/domain/roofTypeContract.js create mode 100644 backend/src/domain/smartPackageGeometry.js create mode 100644 backend/src/dto/publicSmartPackageDto.js create mode 100644 backend/src/graphql/mutations/__tests__/createOffer.test.js delete mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/genericQuoteRoutes.js create mode 100644 backend/src/routes/siteGeometryRoutes.js create mode 100644 backend/src/routes/users.js create mode 100644 backend/src/services/__tests__/ordrestyringOfferNormalizationService.test.js create mode 100644 backend/src/services/__tests__/ordrestyringOfferOperationStateStore.test.js create mode 100644 backend/src/services/authAccountSchema.js create mode 100644 backend/src/services/customerDocumentLanguage.js create mode 100644 backend/src/services/genericQuoteDeliveryService.js create mode 100644 backend/src/services/genericQuoteSnapshotService.js create mode 100644 backend/src/services/nominatimService.js create mode 100644 backend/src/services/ordrestyringOfferNormalizationService.js create mode 100644 backend/src/services/ordrestyringOfferOperationSchema.js create mode 100644 backend/src/services/ordrestyringOfferOperationStateStore.js create mode 100644 backend/src/services/roofQuoteSnapshotService.js create mode 100644 backend/src/services/siteGeometryMigration.js create mode 100644 backend/src/services/siteGeometryService.js create mode 100644 backend/src/services/smartPackageWorkspaceService.js create mode 100644 backend/src/services/userService.js create mode 100644 codemaps/architecture.md create mode 100644 codemaps/backend.md create mode 100644 codemaps/data.md create mode 100644 codemaps/frontend.md create mode 100644 database/migrations/20260901_smart_package_geometry_contract.sql create mode 100644 database/migrations/20260902_gutter_meter_packages.js create mode 100644 database/migrations/20260904_smart_package_workspace.sql create mode 100644 database/migrations/20260904_users_table.js create mode 100644 database/migrations/20260910_complete_roof_package_contract.js create mode 100644 database/migrations/20260918_fail_closed_roof_lifecycle.js create mode 100644 docs/PR_31_REVIEWER_GUIDE.md create mode 100644 docs/THIRD_PARTY_NOTICES.md create mode 100644 docs/features/GENERIC_QUOTE_SNAPSHOT.md create mode 100644 frontend/src/App.test.js create mode 100644 frontend/src/components/EnhancedGeometry.css create mode 100644 frontend/src/components/ProjectCreation.test.js create mode 100644 frontend/src/components/RoofReplacementScope.js create mode 100644 frontend/src/components/RoofReplacementScope.test.js create mode 100644 frontend/src/components/UsersManagement.js create mode 100644 frontend/src/components/siteGeometry/EditableAreaMap.js create mode 100644 frontend/src/components/siteGeometry/EditableAreaMap.test.js create mode 100644 frontend/src/components/siteGeometry/SiteGeometryModal.css create mode 100644 frontend/src/components/siteGeometry/SiteGeometryModal.js create mode 100644 frontend/src/components/siteGeometry/SiteGeometryModal.test.js create mode 100644 frontend/src/components/smartPackages/ComponentsLibrary.test.js create mode 100644 frontend/src/components/smartPackages/SmartPackageBuilder.css create mode 100644 frontend/src/components/smartPackages/SmartPackageBuilder.css.test.js create mode 100644 frontend/src/components/smartPackages/SmartPackageBuilder.js create mode 100644 frontend/src/components/smartPackages/SmartPackageBuilder.map.test.js create mode 100644 frontend/src/components/smartPackages/SmartPackageBuilder.test.js create mode 100644 frontend/src/components/smartPackages/SmartPackageWizard.test.js create mode 100644 frontend/src/components/smartPackages/SmartPackagesManagement.browser.cjs create mode 100644 frontend/src/components/smartPackages/SmartPackagesManagement.test.js create mode 100644 frontend/src/components/smartPackages/protectedSmartPackageCallers.test.js create mode 100644 frontend/src/components/smartPackages/smartPackageCategories.js create mode 100644 frontend/src/components/smartPackages/smartPackageCategories.test.js create mode 100644 frontend/src/components/smartPackages/smartPackageManagementQuery.js create mode 100644 frontend/src/components/smartPackages/smartPackageManagementQuery.test.js create mode 100644 frontend/src/components/smartPackages/smartPackageSearch.js create mode 100644 frontend/src/components/smartPackages/smartPackageSearch.test.js create mode 100644 frontend/src/services/authenticatedFetch.js create mode 100644 frontend/src/services/authenticatedFetch.test.js create mode 100644 frontend/src/services/genericQuoteReviewService.js create mode 100644 frontend/src/services/genericQuoteReviewService.test.js create mode 100644 frontend/src/services/genericQuoteSnapshotApi.js create mode 100644 frontend/src/services/genericQuoteSnapshotApi.test.js create mode 100644 frontend/src/services/projectLineSyncService.js create mode 100644 frontend/src/services/projectLineSyncService.test.js create mode 100644 frontend/src/services/roofQuoteSnapshotApi.js create mode 100644 frontend/src/services/roofQuoteSnapshotApi.test.js create mode 100644 frontend/src/services/smartPackageLibraryService.js create mode 100644 frontend/src/services/smartPackageLibraryService.test.js create mode 100644 frontend/src/utils/coalescedSaver.js create mode 100644 frontend/src/utils/coalescedSaver.test.js create mode 100644 frontend/src/utils/roofReplacementScope.js create mode 100644 frontend/src/utils/roofReplacementScope.test.js create mode 100644 frontend/src/utils/roofTypeContract.js create mode 100644 frontend/src/utils/roofTypeContract.test.js create mode 100644 frontend/src/utils/siteGeometryWorkspace.test.js create mode 100644 frontend/src/utils/smartPackageWorkspace.js create mode 100644 frontend/src/utils/smartPackageWorkspace.test.js create mode 100644 obsidian-vault/10 Dokumenter/Funktioner/GENERIC_QUOTE_SNAPSHOT.md create mode 100644 obsidian-vault/10 Dokumenter/Projektdokumentation/PR_31_REVIEWER_GUIDE.md create mode 100644 obsidian-vault/10 Dokumenter/Projektdokumentation/THIRD_PARTY_NOTICES.md create mode 100644 obsidian-vault/10 Dokumenter/Projektdokumentation/qa/CARPENTER_WALKTHROUGH_RELEASE_GATE.md create mode 100644 obsidian-vault/10 Dokumenter/Projektrod og øvrigt/.hermes/plans/2026-08-18_133633-carpenter-walkthrough-fixes.md create mode 100644 obsidian-vault/10 Dokumenter/Projektrod og øvrigt/.hermes/plans/2026-09-04_070621-smart-pakker-legoklodser.md create mode 100644 obsidian-vault/10 Dokumenter/Projektrod og øvrigt/codemaps/architecture.md create mode 100644 obsidian-vault/10 Dokumenter/Projektrod og øvrigt/codemaps/backend.md create mode 100644 obsidian-vault/10 Dokumenter/Projektrod og øvrigt/codemaps/data.md create mode 100644 obsidian-vault/10 Dokumenter/Projektrod og øvrigt/codemaps/frontend.md create mode 100644 tests/fixtures/site-area.jsx create mode 100644 tests/site-area.config.js create mode 100644 tests/site-area.spec.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b582e8b..406e168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,6 +197,21 @@ jobs: sleep 1 done + - name: Seed isolated CI login account + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_USER: testuser + DB_PASSWORD: testpassword + DB_NAME: tilbudgivern_test + NODE_ENV: test + CI: true + AUTH_USERNAME: ci-test-user + AUTH_PASSWORD: ci-test-password + run: | + node database/migrations/20260904_users_table.js + node backend/scripts/seed-ci-auth-account.js + - name: Start server env: DB_HOST: 127.0.0.1 diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index 47224e7..7cbe133 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -69,6 +69,7 @@ jobs: mkdir -p deploy-package cp -r frontend/build deploy-package/frontend-build cp -r backend deploy-package/backend + cp -r database deploy-package/database cp ecosystem.config.js deploy-package/ cp package.json deploy-package/ rm -rf deploy-package/backend/node_modules @@ -133,6 +134,13 @@ jobs: # Copy environment file cp $DEPLOY_PATH/shared/.env $RELEASE_PATH/backend/.env + # Apply roof catalog contracts and fail-closed lifecycle changes + # before the release can become current or accept traffic. + cd $RELEASE_PATH + node database/migrations/20260902_gutter_meter_packages.js --apply + node database/migrations/20260910_complete_roof_package_contract.js + node database/migrations/20260918_fail_closed_roof_lifecycle.js --apply + # Copy frontend build to correct location mkdir -p $RELEASE_PATH/backend/../frontend mv $RELEASE_PATH/frontend-build $RELEASE_PATH/frontend/build diff --git a/.hermes/plans/2026-09-04_070621-smart-pakker-legoklodser.md b/.hermes/plans/2026-09-04_070621-smart-pakker-legoklodser.md new file mode 100644 index 0000000..9798cfa --- /dev/null +++ b/.hermes/plans/2026-09-04_070621-smart-pakker-legoklodser.md @@ -0,0 +1,896 @@ +# Smart Pakker som geometridrevne legoklodser — Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Ombyg Smart Pakker til genbrugelige “legoklodser”, hvor sammenhængende opgaver, materialer, referenceydelser og udlejning kan trækkes ind i et projekt, automatisk beregnes fra Geometri, tilrettes live og fjernes igen uden at påvirke andre klodser. + +**Architecture:** Indfør én eksplicit pakke- og linjekontrakt med prisbasis, geometriformel og provenance. En Smart Pakke er en samling af materialelinjer, arbejdsopgaver, referenceydelser og udlejning; hver tilføjet instans får et stabilt `sourcePackageId`/`packageInstanceId`. Geometrimotoren beregner mængder, men manuelle overrides bevares. Ordrestyring, leverandørprislister/materialedatabase og Håndværkpriser holdes som separate datakilder med tydelig confidence og må aldrig blandes til skjulte priser. + +**Tech Stack:** React, Node.js/Express, MySQL/MariaDB, Jest, Playwright, eksisterende Tilbudgivern-services og Ordrestyring-sync. + +--- + +## 1. Produktmodel og ufravigelige regler + +### Smart Pakke + +En Smart Pakke skal kunne indeholde nul eller flere af hver linjetype: + +1. **Arbejdsopgave** + - tidsbasis: `time`, `time/m²`, `time/løbende m`, `time/stk.` eller fast timetal + - timepris: `kr./time` + - samlet arbejdsløn beregnes særskilt +2. **Materiale** + - mængde og eksplicit enhed: eksempelvis `m²`, `løbende m`, `m`, `stk.`, `rulle`, `pakke` eller `sæt` + - pris pr. samme enhed + - link til materialedatabase/prisliste, når det er en rigtig materialelinje +3. **Referenceydelse** + - prisbasis fra kilden: eksempelvis `kr./m²`, `kr./løbende m`, `kr./stk.` eller fast pris + - må ikke få opdigtede timer +4. **Udlejning/service** + - prisbasis: `time`, `dag`, `uge`, `måned`, `gang` eller fast sum + - må ikke valideres som fysisk materiale + +### Pakkeinstans på projektet + +Når en pakke tilføjes, oprettes en selvstændig projektinstans med: + +- `sourcePackageId` +- `sourcePackageVersion` +- `packageInstanceId` +- `geometrySnapshot` +- `geometryFormula` +- beregnede mængder +- eventuelle manuelle overrides +- provenance pr. linje + +Den samme Smart Pakke skal kunne tilføjes flere gange, hvis opgaven kræver det. Fjernelse af én instans må kun fjerne linjer med samme `packageInstanceId`. + +### Geometri-kontrakt + +Tilladte mængdegrundlag: + +- `roof_area`: tagflade i m² +- `building_length`: bygningslængde i løbende meter +- `building_width`: bygningsbredde i løbende meter +- `building_perimeter`: omkreds i løbende meter +- `wall_height`: væghøjde i meter +- `count`: antal stk. +- `count_x_wall_height`: antal × væghøjde +- `roof_sides_x_length`: antal tagsider × bygningslængde +- `fixed`: fast mængde +- `manual`: manuel mængde + +Alle formler skal kunne forklares i UI, f.eks. `2 nedløb × 5,0 m væghøjde = 10,0 løbende m`. + +--- + +## 2. Leverancefaser + +### Fase A — Datakontrakt og migrationsgrundlag + +### Task 1: Kortlæg og fastlås den eksisterende pris- og pakkeadfærd + +**Objective:** Dokumentér eksisterende tabeller, API-shapes og beregningsveje, før schema ændres. + +**Files:** +- Inspect: `backend/src/services/smartPackageManagementService.js` +- Inspect: `frontend/src/components/InlineSmartPackage.js` +- Inspect: `frontend/src/utils/smartPackageSelection.js` +- Inspect: `frontend/src/utils/smartPackageGeometryCalculations.js` +- Inspect: `backend/src/services/projectMaterialService.js` +- Create: `docs/smart-package-line-contract.md` + +**Steps:** +1. Skriv tests, der låser nuværende material-, task-, reference- og rental-shapes. +2. Registrér alle steder hvor `unit`, `unit_price`, `hours_unit`, `time_per_unit`, `geometry_multiplier` og totaler normaliseres. +3. Dokumentér hvilke felter der er kildepris ekskl. moms, og hvor moms påføres. +4. Dokumentér alle legacy-tabeller og fallbackveje. +5. Commit: `test: lock current smart package line contracts`. + +**Verification:** Ingen produktionsændring; eksisterende backend/frontend-tests forbliver grønne. + +--- + +### Task 2: Indfør eksplicit geometriformel og pakkeinstans + +**Objective:** Gør hver tilføjet legoklods sporbar og beregnelig uden at conflere enheder. + +**Files:** +- Create: `database/migrations/_smart_package_instances.sql` +- Modify: `backend/src/services/databaseService.js` +- Create: `backend/src/services/smartPackageInstanceService.js` +- Test: `backend/src/__tests__/smartPackageInstanceService.test.js` + +**Schema:** +- `project_smart_package_instances` + - `id` + - `project_id` + - `source_package_id` + - `source_package_version` + - `instance_key` + - `name` + - `geometry_snapshot JSON` + - `created_at`, `updated_at` +- Udvid projektlinjer med: + - `package_instance_id` + - `geometry_basis` + - `geometry_factor` + - `geometry_formula` + - `calculated_quantity` + - `manual_quantity` + - `quantity_mode ENUM('calculated','manual')` + +**TDD:** +1. Test at to instanser af samme pakke kan eksistere. +2. Test at fjernelse af én instans ikke fjerner den anden. +3. Test at en manuel mængde overlever geometrireberegning. +4. Implementér atomisk oprettelse/fjernelse. +5. Commit: `feat: add project smart package instances`. + +--- + +### Task 3: Etabler én kanonisk linjekontrakt + +**Objective:** Sikr at materialer, timer, referenceydelser og udlejning har hver sin korrekte prisbasis. + +**Files:** +- Create: `backend/src/domain/smartPackageLineContract.js` +- Create: `frontend/src/utils/smartPackageLineContract.js` +- Modify: `backend/src/services/smartPackageManagementService.js` +- Modify: `frontend/src/utils/smartPackageSelection.js` +- Test: `backend/src/__tests__/smartPackageLineContract.test.js` +- Test: `frontend/src/utils/smartPackageLineContract.test.js` + +**Rules:** +- Arbejde: `timeQuantity × hourlyRate`. +- Materiale/reference/service: `quantity × unitPrice`. +- Fast pris: `quantity=1`, `unit='fast pris'`. +- `unitPrice` må aldrig fortolkes som arbejdstimer. +- En referenceydelse med pris pr. løbende meter må ikke blive til en fysisk materialelinje. +- Moms lagres ikke dobbelt; kildeprisens momsstatus skal være eksplicit. + +**Verification:** Property-style tests for alle enheder og linjetyper. + +--- + +## Fase B — Geometrimotor + +### Task 4: Byg en fælles, ren geometriberegner + +**Objective:** Beregn mængder deterministisk fra projektets Geometri. + +**Files:** +- Modify: `frontend/src/utils/smartPackageGeometryCalculations.js` +- Create: `backend/src/domain/smartPackageGeometry.js` +- Test: `frontend/src/utils/smartPackageGeometryCalculations.test.js` +- Test: `backend/src/__tests__/smartPackageGeometry.test.js` + +**Required cases:** +- `roof_area`: faktor × m² +- `roof_sides_x_length`: antal tagsider × længde +- `count_x_wall_height`: antal × væghøjde +- `building_perimeter`: `(længde + bredde) × 2` +- `fixed`: ingen geometrisk skalering +- `manual`: behold manuel mængde + +**Acceptance examples:** +- 2 tagsider × 10 m = 20 løbende m tagrende. +- 2 nedløb × 5 m væghøjde = 10 løbende m nedløbsrør. +- Ændring til 3 nedløb giver 15 løbende m. +- En fast servicepris ændres ikke ved ændret tagareal. + +--- + +### Task 5: Reberegn atomisk og beskyt manuelle overrides + +**Objective:** Opdatér kun beregnede linjer, når Geometri ændres. + +**Files:** +- Create: `backend/src/services/projectPackageRecalculationService.js` +- Modify: relevante project/geometry routes i `backend/unified-server.js` og `backend/src/routes/customerProjects.js` +- Test: `backend/src/__tests__/projectPackageRecalculationService.test.js` + +**Steps:** +1. Skriv failing test for geometriændring med både beregnede og manuelle linjer. +2. Implementér transaction + optimistic version check. +3. Returnér before/after-diff til UI. +4. Kræv eksplicit brugeraccept, før gemte manuelle overrides overskrives. +5. Commit: `feat: recalculate package instances from geometry`. + +--- + +## Fase C — Datakilder og provenance + +### Task 6: Ordrestyring som evidens for udførte opgaver + +**Objective:** Brug rigtige afsluttede opgaver som tids- og scopegrundlag uden at foregive høj confidence ved svage matches. + +**Files:** +- Modify: `backend/src/services/orderSuggestionService.js` +- Modify/Create: Ordrestyring feature/service under `backend/src/services/` +- Test: `backend/src/__tests__/orderSuggestionService.test.js` +- Test: `backend/src/__tests__/historicalSmartPackageSearchService.test.js` + +**Rules:** +- Kun afsluttede, ikke-test cases. +- Match på opgavetype, tagmateriale, geometri og dokumenteret omfang. +- Median/robust interval frem for ét tilfældigt projekt. +- Gem `caseIds`, antal matches og confidence. +- Ordrestyring-timer må ikke blandes med kildeprisen for en referenceydelse. +- Manglende historik skal give `low confidence`, ikke opdigtede timer. + +--- + +### Task 7: Materialedatabase og prislister som materialegrundlag + +**Objective:** Hent aktuel pris pr. materialeenhed og bevar leverandør/provenance. + +**Files:** +- Modify: `backend/src/services/materialPriceStatusService.js` +- Modify: `backend/src/services/smartPackageMaterialMatchService.js` +- Modify: `backend/src/services/pricingDataIntegrationService.js` eller eksisterende tilsvarende service +- Test: relevante material price/match tests + +**Rules:** +- Vælg seneste aktive pris med dokumenteret enhed. +- Omregn kun pakningslængde til meterpris, hvis pakningslængden er dokumenteret. +- `ALUZINK` må ikke mærkes som ren aluminium. +- Manuelle priser må ikke overskrives destruktivt. +- Uaktuelle priser vises med advarsel og dato. +- Enhedskonvertering skal være synlig, f.eks. `290,33 kr./3 m = 96,78 kr./m`. + +--- + +### Task 8: Håndværkpriser som tilføjelige opgaveklodser + +**Objective:** Importér alle tilladte Håndværkpriser-rækker som søgbare Smart Pakker/opgaveklodser med kildeprisbasis. + +**Files:** +- Modify: `backend/src/services/haandvaerkPriserImportService.js` +- Modify: `backend/src/routes/haandvaerkPriserRoutes.js` +- Test: `backend/__tests__/haandvaerkPriserImportService.test.js` +- Test: `backend/__tests__/haandvaerkPriserImportRegression.test.js` + +**Rules:** +- Kildepris inkl. moms konverteres til ekskl. moms før normal momspipeline. +- Importér som `reference_service`/opgaveklods, ikke som falsk arbejdstime. +- Bevar navn, kategori, prisinterval, enhed, URL og importdato. +- En fast parcelhuspris må ikke omskrives til meterpris. +- Hvis en kanonisk meterbaseret klods erstatter en fast reference, vis reference som arkiveret/superseded i management — ikke i projektflowet. +- Gentaget import med uændret kilde er deterministisk. + +--- + +## Fase D — Kategorier, synlighed og søgning + +### Task 9: Opret et normaliseret kategorisystem + +**Objective:** Gør alle typer opgaver findbare uden duplikerede fritekstkategorier. + +**Files:** +- Create: `database/migrations/_smart_package_categories.sql` +- Create: `backend/src/domain/smartPackageCategories.js` +- Modify: `backend/src/services/smartPackageManagementService.js` +- Modify: `frontend/src/components/smartPackages/SmartPackages.js` +- Test: backend/frontend category tests + +**Required categories:** +- Tagbeklædning +- Undertag +- Lægter +- Rygning og grater +- Stern og vindskeder +- Skotrender og inddækninger +- Ovenlys/Velux +- Skorsten +- Isolering +- Tagrender +- Nedløbsrør +- Tømrer og snedker +- VVS og blikkenslager +- Maler +- Murer +- Gulv +- Nedrivning og affald +- Stillads +- Faldsikring +- Udlejning +- Serviceydelser +- Referenceydelser + +**Migration:** Map eksisterende fritekstkategorier til stabile category keys uden at slette original provenance. + +--- + +### Task 10: Vis alle Smart Pakker i managementoversigten + +**Objective:** Gør alle aktive, blokerede og arkiverede pakker tilgængelige for administration med ægte pagination. + +**Files:** +- Modify: `backend/src/services/smartPackageManagementService.js` +- Modify: `backend/src/routes/smartPackagesRoutes.js` +- Modify: `frontend/src/components/smartPackages/SmartPackages.js` +- Test: `backend/src/__tests__/smartPackageManagementService.test.js` +- Test: `frontend/src/components/smartPackages/SmartPackages.test.js` + +**API contract:** +```json +{ + "success": true, + "packages": [], + "total": 654, + "limit": 12, + "offset": 0 +} +``` + +**Filters:** +- søgning +- kategori +- pakketype +- status +- aktiv/arkiveret +- prisbasis +- datakilde + +**Safety:** +- Management sender `includeInactive=true`. +- Projektflowet gør ikke og får fortsat kun aktive, verificerede klodser. +- Paginationens total skal beregnes med præcis samme filtre som resultatsættet. + +--- + +## Fase E — Tagrender og nedløb som referenceimplementering + +### Task 11: Opret fire separate tagrendeklodser + +**Objective:** Lever zink, aluminium, stål og plast som selvstændige meterbaserede pakker. + +**Files:** +- Finalize: `database/migrations/20260902_gutter_meter_packages.js` +- Test: `backend/src/__tests__/gutterSmartPackageSeed.test.js` +- Test: DB integrationstest for `SmartPackageManagementService.getPackageDetails()` + +**Packages:** +1. Tagrender i zink — pris pr. løbende meter +2. Tagrender i aluminium — pris pr. løbende meter +3. Tagrender i stål — pris pr. løbende meter +4. Tagrender i plast — pris pr. løbende meter + +**Each package contains:** +- tagrende: 1 løbende m pr. beregnet meter +- rendejern: dokumenteret antal stk. pr. løbende meter +- seks arbejdsopgaver med samlet 0,40 time/løbende meter +- timepris 600 kr./time +- separat materiale- og arbejdssum +- standardformel `antal tagsider × husets længde` +- nedløb, specialhjørner og særlige tilslutninger som separate tilvalg + +**Data verification:** Hver materialelinje skal have korrekt materialedatabase-id eller dokumenteret ekstern reference med source URL, dato og momsstatus. + +--- + +### Task 12: Opret “kun nedløb”-klods + +**Objective:** Gør nedløbsrør til en selvstændig geometridrevet klods uden tagrender. + +**Files:** +- Finalize: `database/migrations/20260902_gutter_meter_packages.js` +- Test: `backend/src/__tests__/gutterSmartPackageSeed.test.js` +- Test: Playwright flow test + +**Default formula:** +```text +antal nedløb (standard 2 stk.) × husets væghøjde = løbende meter nedløbsrør +``` + +**Package content:** +- nedløbsrør pr. løbende meter +- rørholdere pr. stk. med faktor pr. løbende meter +- seks arbejdsopgaver med samlet 0,18 time/løbende meter +- timepris 600 kr./time +- ingen tagrende eller rendejern +- bøjninger, brøndarbejde og sokkeltilslutning som eksplicitte tilvalg/forbehold + +**Acceptance:** Ved 5 m væghøjde og standard 2 stk. bliver mængden 10 løbende meter. Ændres antal til 3, bliver den 15 løbende meter. + +--- + +## Fase F — Live projektbygger + +### Task 13: Byg det centrale Smart Pakke-builder-UI + +**Objective:** Gør Smart Pakke-skærmen til det primære arbejdsområde, hvor tilbuddet bygges live af opgaveklodser, uden støj eller overflødige administrationsfelter. + +**Files:** +- Modify: `frontend/src/components/InlineSmartPackage.js` +- Create: `frontend/src/components/smartPackages/SmartPackageBuilder.js` +- Create: `frontend/src/components/smartPackages/PackageLibraryPanel.js` +- Create: `frontend/src/components/smartPackages/ProjectPackageCanvas.js` +- Create: `frontend/src/components/smartPackages/PackageInstanceCard.js` +- Create: `frontend/src/components/smartPackages/PackageInstanceEditor.js` +- Create: `frontend/src/components/smartPackages/ProjectLiveTotals.js` +- Create: `frontend/src/components/smartPackages/GeometryBasisSummary.js` +- Test: `frontend/src/components/smartPackages/SmartPackageBuilder.test.js` +- Test: `frontend/src/components/smartPackages/PackageInstanceCard.test.js` + +**Desktop-layout:** + +1. **Venstre: Klodsbibliotek** + - søgefelt øverst + - nødvendige kategorifiltre + - aktive/verificerede klodser + - navn, kort scope, primær prisbasis og kilde på hvert kort + - klodsen kan trækkes ind på projektet eller tilføjes med en tydelig `Tilføj`-knap +2. **Midten: Projektets klodser** + - drop-zone med tilbudets valgte klodser + - klodser kan flyttes for at skabe en logisk rækkefølge + - klodser kan trækkes ud til en synlig `Fjern`-zone + - alternativ tydelig `Fjern`-knap med undo, så drag-and-drop aldrig er eneste betjening +3. **Højre: Live-editor** + - viser kun den valgte klods + - geometriformel, beregnet mængde, prisbasis, enhedspris og samlet pris + - redigering af opgavemængde, antal, materialevalg, timer og relevante forbehold + - avancerede felter skjules under `Flere indstillinger` +4. **Fast bund/topbjælke: Live-total** + - materialer ekskl. moms + - arbejdsløn ekskl. moms + - referenceydelser/udlejning ekskl. moms + - subtotal, moms og total inkl. moms + - totals opdateres umiddelbart efter enhver ændring + +**Mobil/tablet-layout:** +- bibliotek, projektklodser og editor vises som tre tydelige trin/tabs +- `Tilføj` og `Fjern` skal fungere uden drag-and-drop +- live-total skal altid kunne åbnes og må ikke dække redigeringsfelter + +**UI behavior:** +- søg og filtrér alle relevante opgavetyper +- træk en klods fra biblioteket ind i projektet +- tilføj samme klodstype flere gange, når opgaven kræver det +- træk en projektklods ud eller brug `Fjern` +- fortryd seneste fjernelse uden at genindlæse siden +- redigér opgavemængde live, mens tilbuddet bygges +- skift mellem beregnet og manuel mængde +- vis hvilken Geometri klodsen bruger +- vis regnestykket, f.eks. `2 stk. × 5,0 m = 10,0 løbende m` +- markér tydeligt `Beregnet fra Geometri` eller `Manuelt tilrettet` +- vis pris pr. korrekt enhed og klodsens samlet pris +- vis materialer og arbejdsopgaver samlet under klodsen, men med separat økonomi +- klap detaljer sammen, så et stort projekt stadig er overskueligt +- bevar klodsens redigeringsstate ved skift mellem klodser +- vis gemmestatus: `Gemmer`, `Gemt` eller konkret fejl +- fjern én klods uden at påvirke andre +- opdatér projektets total live + +**Keep-the-UI-simple rules:** +- standardvisningen må kun vise felter, som er nødvendige for at bygge tilbuddet +- interne database-id’er, importfelter, confidence-debugdata og rå JSON må ikke vises i builderen +- administration, validering og import hører til managementoversigten — ikke tilbudsbyggeren +- hvert klodskort skal have ét tydeligt primært næste skridt +- brugerens vigtigste oplysninger skal kunne aflæses uden at åbne en dialog: scope, mængde, enhed, pris pr. enhed og linjetotal +- fejl skal stå ved den berørte klods og må ikke kun vises som generel toast +- farver må ikke være eneste statusindikator; brug tekst og ikoner + +**Accessibility:** +- drag-and-drop skal have fuld tastaturvariant: `Tilføj`, `Flyt op`, `Flyt ned`, `Fjern` +- drop-zones og klodser skal have korrekte labels og fokusmarkering +- totals og mængdeændringer skal annonceres passende uden at skabe støj +- alle inputs skal have synlig label med prisbasis/enhed + +**TDD steps:** +1. Skriv failing component test for tilføjelse via knap. +2. Skriv failing component test for drag-and-drop ind i projektet. +3. Skriv failing test for redigering af mængde og øjeblikkelig totalændring. +4. Skriv failing test for manuel override og tydelig status. +5. Skriv failing test for fjernelse og undo. +6. Skriv failing test for tastaturbaseret flytning og fjernelse. +7. Implementér den minimale builderstruktur. +8. Kør tests og verificér grøn. +9. Commit: `feat: build interactive smart package lego workspace`. + +--- + +### Task 14: Live økonomi med eksplicit prisbasis + +**Objective:** Opdatér projektets samlede økonomi ved hver ændring uden dobbelt moms eller enhedsforveksling. + +**Files:** +- Modify: `backend/src/services/quoteEconomicsService.js` +- Modify: `frontend/src/components/InlineSmartPackage.js` +- Modify: `frontend/src/components/FinalReview.js` eller faktisk Final Review-fil +- Test: backend economics tests + frontend calculation tests + +**UI totals:** +- materialer ekskl. moms +- arbejdsløn ekskl. moms +- referenceydelser ekskl. moms +- udlejning ekskl. moms +- subtotal ekskl. moms +- moms +- total inkl. moms + +**Rules:** Beregn hver linje ud fra dens egen prisbasis. Vis f.eks. `10 løbende m × 543,18 kr./løbende m`; vis ikke kun et uigennemsigtigt beløb. + +--- + +### Task 15: Gem, genindlæs, Final Review og PDF + +**Objective:** Bevar alle klodser og deres enheder gennem hele tilbudsflowet. + +**Files:** +- Modify: project save/hydration services +- Modify: `backend/src/services/pdfGenerationService.js` +- Modify: Final Review frontend/backend mapping +- Test: project flow, offer route og PDF tests + +**Acceptance:** +- Samme `packageInstanceId`, mængde, enhed, enhedspris, timefaktor og geometriformel efter reload. +- Samme total i Smart Pakke, Final Review og PDF. +- PDF viser prisbasis pr. linje. +- Ingen dobbelt moms. +- Ingen faste priser skaleres med geometrien. + +--- + +## Fase G — QA, rollout og drift + +### Task 16: End-to-end Lego-flow og usability-gate + +**Objective:** Verificér hele brugerrejsen gennem faktiske UI-klik og drag-and-drop, og bevis at Smart Pakke-builderen er forståelig og brugbar under tilbudsgivning. + +**Test files/artifacts:** +- Create: `frontend/e2e/smart-package-lego-builder.spec.js` eller projektets faktiske Playwright-placering +- Create: `dogfood-output/smart-package-lego-builder-results.json` +- Capture: desktop- og mobil-screenshots af bibliotek, projektcanvas, editor og Final Review + +**Functional test scenario:** +1. Opret projekt gennem UI. +2. Angiv længde 10 m, bredde 8 m, væghøjde 5 m. +3. Åbn Smart Pakke-builderen og kontrollér, at bibliotek, projektområde og live-total er synlige. +4. Find zinktagrender via søgning/kategori. +5. Træk zinktagrende-klodsen ind i projektområdet. +6. Kontrollér `2 tagsider × 10 m = 20 løbende m`. +7. Træk “kun nedløb” ind i projektet. +8. Behold standard 2 stk. og kontrollér `2 × 5 m = 10 løbende m`. +9. Tilføj en Håndværkpriser-opgaveklods og kontrollér dens eksplicitte prisbasis. +10. Tilføj en separat materiale-/arbejdsklods. +11. Ret tagrendemængden manuelt og kontrollér, at live-totalen opdateres uden sidegenindlæsning. +12. Skift tagrendemateriale mellem zink, aluminium, stål og plast og kontrollér, at geometrimængden består, mens meterprisen ændres. +13. Ændr geometrien og kontrollér, at automatisk beregnede linjer genberegnes, mens manuel override bevares. +14. Flyt klodsernes rækkefølge. +15. Træk nedløbsklodsen til `Fjern`, og kontrollér at kun dens linjer forsvinder. +16. Brug `Fortryd`, og kontrollér at klodsen og dens priser gendannes. +17. Gentag tilføj/fjern via knapper og tastatur uden drag-and-drop. +18. Gem og genindlæs projektet. +19. Kontrollér samme klodser, rækkefølge, mængder, enheder, overrides og priser. +20. Kontrollér Final Review og PDF. +21. Slet QA-projektet. + +**Usability assertions:** +- En ny bruger skal kunne tilføje første klods uden at åbne en administrationsdialog. +- Søgning og kategorifilter skal gøre en kendt klods synlig uden at bladre manuelt gennem hundredvis af pakker. +- Hvert valgt klodskort skal uden ekstra klik vise: + - navn/scope + - beregnet eller manuel mængde + - enhed + - pris pr. enhed + - linjetotal + - beregningsstatus +- Den valgte klods skal kunne redigeres på samme skærm som live-totalen. +- Interne databasefelter, importmetadata og rå valideringsdata må ikke være synlige i standardbuilderen. +- Der må ikke være dublerede primære knapper med samme funktion. +- Der må ikke være vandret scroll på almindelig desktopbredde eller mobilbredde. +- Fokus skal flyttes logisk efter tilføjelse, fjernelse og undo. +- En fejl i én klods skal vises på klodsen og må ikke blokere redigering af andre klodser. +- Store tilbud med mindst 20 klodser skal stadig kunne overskues via sammenklapning, søgning og rækkefølge. + +**Visual QA viewports:** +- Desktop: 1440 × 900 +- Laptop: 1280 × 720 +- Tablet: 768 × 1024 +- Mobil: 390 × 844 + +**Performance budgets:** +- Søgning/filter skal føles øjeblikkelig og opdatere inden for 200 ms efter debounce. +- Tilføj, redigér og fjern skal opdatere lokal UI og total uden at vente på fuld refetch. +- Autosave skal vise status og afsluttes eller give konkret fejl; ingen tavs fejl. +- 20 valgte klodser må ikke gøre inputs mærkbart hakkende. + +**Evidence required:** +- Playwright-resultat for både drag-and-drop og knap/tastatur-flow +- JSON med mængder, prisbaser og totaler før/efter hver ændring +- screenshots ved de fire viewports +- browser-console uden nye errors +- database-readback som støtte for gemte instanser +- screenshot/PDF-sammenligning af sluttilbuddet + +**Exit gate:** Tasken er ikke bestået, hvis automatiske tests er grønne, men en bruger ikke tydeligt kan se, tilføje, tilrette og fjerne klodser på Smart Pakke-skærmen. + +--- + +### Task 17: Migration og produktionsrollout + +**Objective:** Ship uden datatab eller prisændringer på eksisterende tilbud. + +**Steps:** +1. Tag databasebackup. +2. Kør migrations-dry-run og kontrollér antal inserts/updates/deactivations. +3. Kør fuld backend- og frontendtest. +4. Kør lint, API inventory og production build. +5. Kør uafhængigt staged review med fokus på prisbasis, SQL-idempotens og atomiske writes. +6. Opret PR og afvent alle CI-checks inklusive Playwright/security. +7. Anvend additiv schema-migration før kode, hvis ny kode kræver schemaet. +8. Merge og deploy. +9. Kør health check. +10. Kør E2E-scenariet gennem UI. +11. Verificér ingen QA-rester og ren `main`. + +--- + +## 3. Filer, der sandsynligvis ændres + +### Backend +- `backend/src/services/smartPackageManagementService.js` +- `backend/src/services/projectMaterialService.js` +- `backend/src/services/orderSuggestionService.js` +- `backend/src/services/haandvaerkPriserImportService.js` +- `backend/src/services/quoteEconomicsService.js` +- `backend/src/services/pdfGenerationService.js` +- `backend/src/routes/smartPackagesRoutes.js` +- `backend/src/routes/customerProjects.js` +- `backend/unified-server.js` + +### Frontend +- `frontend/src/components/InlineSmartPackage.js` +- `frontend/src/components/smartPackages/SmartPackages.js` +- `frontend/src/components/ProjectFlow.js` +- `frontend/src/utils/smartPackageSelection.js` +- `frontend/src/utils/smartPackageGeometryCalculations.js` + +### Database +- `database/migrations/20260902_gutter_meter_packages.js` +- nye additive migrations til pakkeinstanser, kategorier og geometrikontrakt +- `backend/src/services/databaseService.js` +- `backend/sql/customer_project_system.sql` + +### Tests +- `backend/src/__tests__/smartPackageManagementService.test.js` +- `backend/src/__tests__/gutterSmartPackageSeed.test.js` +- `backend/__tests__/haandvaerkPriserImportService.test.js` +- `backend/__tests__/haandvaerkPriserImportRegression.test.js` +- frontend component/util tests +- Playwright E2E for Lego-flowet + +--- + +## 4. Test- og kvalitetsgate + +Kør mindst: + +```bash +cd backend && npm test -- --runInBand +cd ../frontend && CI=true npm test -- --watchAll=false +cd .. && npm run lint +npm run build +``` + +Derudover: + +- migrations-dry-run +- migration replay/idempotens +- database-readback af enheder og provenance +- E2E gennem faktiske UI-klik +- Final Review/PDF-totalmatch +- security scan og PR CI + +--- + +## 5. Risici og beslutninger + +### Risici + +1. **Dobbelt økonomi:** En pakkepris må ikke lægges oven i de materialer og timer, som allerede udgør pakken. +2. **Enhedsforveksling:** `stk.`, `løbende m`, `m²`, time og fast pris må ikke normaliseres til samme felt uden type. +3. **Falsk historisk sikkerhed:** Svage Ordrestyring-matches må ikke blive kanoniske tider/priser. +4. **Destruktiv genberegning:** Geometriændring må ikke slette manuelle overrides. +5. **Importdrift:** Håndværkpriser-import må ikke genaktivere superseded fastprispakker eller overskrive manuelle priser. +6. **Legacy paths:** Både moderne `smart_package_tasks` og nødvendige legacy-readers skal fungere indtil kontrolleret udfasning. +7. **Stale prislister:** UI skal vise prisdato og advarsel. + +### Foreslåede beslutninger + +- En Smart Pakke viser **komponenttotal**, men projektets økonomi summerer kun de konkrete linjer én gang. +- Referenceydelser er rigtige tilføjelige klodser, men uden opdigtede arbejdstimer. +- Geometri er standardberegning; brugeren har altid sidste ord via manuel override. +- Managementoversigten viser alt; projektflowet viser kun aktive/verificerede klodser. +- Første referenceimplementering er tagrender + nedløb; derefter anvendes kontrakten på alle Håndværkpriser-kategorier. + +--- + +## 6. Subagent-fordeling ved udførelse + +Implementeringen køres som parallelle, fil-isolerede spor. Ingen subagent må stage, committe, pushe, deploye eller ændre produktionsdatabasen. Hovedagenten ejer integration, krydstest, review, migration og release. + +### Subagent A — Katalogsikkerhed og management-API + +**Ejer filer:** +- `backend/src/routes/smartPackagesRoutes.js` +- `backend/__tests__/smartPackagesRecalculate.test.js` + +**Ansvar:** +- autentificere alle katalogmutationer +- kræve configured operator +- bevare aktive-only læseendpoints til projektflow +- management list/detail for arkiverede pakker +- route-tests for 401/403/succes og sikre fejlbeskeder + +**Gate:** targeted route tests + backend syntax lint. + +**Definition of Done:** +- alle katalogmutationer returnerer 401 uden token og 403 for ikke-operator +- configured operator kan gennemføre repræsentative create/update/archive/task/step-handlinger +- aktive-only læseendpoints kan ikke omgås med `includeInactive` +- management kan se og åbne arkiverede pakker via autentificerede endpoints +- interne database-/stackfejl eksponeres ikke +- relevante route-tests og syntax lint er grønne + +### Subagent B — Builder UX, accessibility og geometri-recalc + +**Ejer filer:** +- `frontend/src/components/smartPackages/SmartPackageBuilder.js` +- `frontend/src/components/smartPackages/SmartPackageBuilder.css` +- `frontend/src/components/smartPackages/SmartPackageBuilder.test.js` +- `frontend/src/utils/smartPackageWorkspace.js` +- `frontend/src/utils/smartPackageWorkspace.test.js` + +**Ansvar:** +- keyboard-tilgængelig Rediger/Flyt/Fjern +- korrekte list/listitem- og live-region-semantikker +- 44 px touch targets og focus-visible +- dansk komma-/punktum-decimalredigering uden snap til 0 +- reberegning af calculated klodser ved geometriændring +- bevarelse af manual overrides +- separate materiale/reference/rental/labor-totaler + +**Gate:** builder/workspace component- og utiltests + frontend lint. + +**Definition of Done:** +- ét klik på `Tilføj klods` eller ét drag tilføjer hele Smart Pakken med dens sammenhængende opgaver og materialer +- bibliotekskortet viser scope samt antal opgaver/materialer før tilføjelse +- opgaver og materialer skaleres samlet og straks fra samme geometrigrundlag +- klodser kan tilføjes via drag-and-drop, knap og tastatur +- samme kildepakke kan tilføjes flere gange med unik instansidentitet +- Rediger/Flyt/Fjern/Undo fungerer med tastatur og 44 px touch targets +- dansk komma- og punktumdecimal kan indtastes uden at feltet snapper til 0 +- calculated klodser genberegnes ved geometriændring, mens manual overrides bevares +- materialer, referenceydelser, udlejning og labor vises og summeres separat +- readiness kræver positive priser og stemmer med Final Review +- targeted tests og frontend lint er grønne uden warnings/errors + +### Subagent C — PDF-boundary og labor round-trip + +**Ejer filer:** +- `backend/unified-server.js` +- `backend/src/services/pdfGenerationService.js` +- `backend/src/__tests__/pdfGenerationService.test.js` +- eventuel isoleret PDF-helper/test + +**Ansvar:** +- føre packageInstances gennem request-body og DB-fallback +- læse workspace-formler til PDF +- medtage project_rentals/referenceydelser præcis én gang +- acceptere kanonisk og legacy labor-shape +- sikre at scopeforklaring ikke påvirker økonomitotaler + +**Gate:** route/helper- og renderer-tests + backend syntax lint. + +**Definition of Done:** +- packageInstances fra request-body når PDF-rendereren uændret +- databasefallback gendanner klodsnavn, formel, mode, mængde og enhed fra workspace JSON +- project_rentals og referenceydelser medtages præcis én gang +- labor-navn, timer, timepris og linjetotal overlever både kanonisk og legacy shape +- klodsoversigten påvirker ikke material-, rental-, labor-, moms- eller grand total +- route/helper/renderer-tests og syntax lint er grønne + +### Subagent D — Workspace-atomik, ownership og schema + +**Ejer filer:** +- `backend/src/services/smartPackageWorkspaceService.js` +- `backend/src/__tests__/smartPackageWorkspaceService.test.js` +- `database/migrations/20260904_smart_package_workspace.sql` +- `database/migrations/20260904_smart_package_geometry_contract.sql` +- `backend/src/services/databaseService.js` +- `backend/sql/customer_project_system.sql` + +**Ansvar:** +- eksplicit expectedVersion og parent lock +- source package version/status-check før writes +- streng payloadvalidering +- slette kun workspace-ejede rækker +- bevare manuelle/importerede materialer, rentals og labor +- kanonisk labor-shape +- idempotente migrations/bootstrap +- atomisk workflowstatus når workspace er klar + +**Gate:** workspace service tests, migration replay og backend syntax lint. + +**Definition of Done:** +- første og efterfølgende writes serialiseres via parent project lock og eksplicit expectedVersion +- stale workspace eller stale/arkiveret source package giver sikker 409 før nogen DELETE/INSERT +- malformed, uendelige, manglende eller overstore payloadværdier afvises med sikker 400 +- kun rækker med `package_instance_id` erstattes; manuelle/importerede rækker bevares +- referenceServices projekteres som service, ikke materiale +- manuel labor bevares og workspace-labor gemmes i kanonisk shape +- workspace og workflowstatus opdateres atomisk +- migrationer kan replayes uden dubletter eller fejl +- targeted tests og backend syntax lint er grønne + +### UI-subagent — Løbende kliktest og visuel QA + +**Ejer ikke produktionskode.** Må kun oprette midlertidige testartefakter under `/home/alex/dogfood-output` og klart markerede QA-projekter, som slettes igen. + +**Kørsler:** +1. Baseline mod nuværende produktion før builder-deploy. +2. Preview-smoke efter hver samlet integrationsbuild. +3. Fuld desktop/laptop/tablet/mobil dogfood før merge. +4. Post-deploy smoke mod produktion. + +**Ansvar:** +- bruge faktiske UI-klik og drag/drop, ikke kun API-kald +- kontrollere søgning, kategorier, tilføj, duplicate, rediger, flyt, fjern og undo +- kontrollere geometri-formler og manuelle overrides +- kontrollere synlige enheder, enhedspriser og live-totaler +- kontrollere save-status, fejl, retry og navigation til Final Review +- kontrollere reload og PDF +- registrere browser-console errors og visuelle overflowproblemer +- gemme JSON-resultat og screenshots pr. viewport + +**Definition of Done:** +- desktop 1440×900, laptop 1280×720, tablet 768×1024 og mobil 390×844 er kørt +- drag/drop og knap/tastatur-alternativer er faktisk brugt +- zinktagrende giver `2 × huslængde` i løbende meter +- nedløb giver `standard 2 stk. × væghøjde` +- en manuel mængde bevares ved geometriændring +- materialer, referenceydelser, udlejning og labor vises separat med eksplicit prisbasis +- mislykket save blokerer Final Review +- reload, Final Review og PDF bevarer klodsnavn/formel/enhed/pris +- ingen nye console errors eller vandret overflow +- alle QA-projekter er slettet, og evidensstier er rapporteret + +### Hovedagent — Integration og release + +**Ansvar:** +1. Inspicér hver subagents diff mod det aftalte filansvar. +2. Afvis ændringer uden observeret RED→GREEN-test. +3. Kør krydstests for builder → workspace → reload → Final Review → PDF. +4. Kør uafhængigt samlet security/logic-review. +5. Tag DB-backup og replay-test migrations. +6. Anvend gutter/nedløb-seed én gang og verificér idempotens. +7. Kør faktisk UI-dogfood ved desktop, laptop, tablet og mobil. +8. Opret PR, afvent CI, merge og deploy. +9. Kør produktionens health/UI/database-readback og fjern QA-data. + +--- + +## 7. Definition of Done + +Løsningen er først færdig, når: + +- alle Smart Pakker kan ses og administreres +- aktive klodser kan søges og tilføjes på tværs af alle relevante kategorier +- flere klodser kan kombineres, redigeres og fjernes uafhængigt +- Smart Pakke-skærmen fungerer som det centrale tilbudsværksted med bibliotek, projektcanvas, live-editor og altid synlig total +- klodser kan trækkes ind, flyttes og trækkes ud samt betjenes med knapper og tastatur +- opgavemængder kan tilrettes live, og ændringen slår straks igennem i klodsens og projektets total +- standardvisningen viser kun de nødvendige tilbudsfelter; administrations- og debugfelter er skjult +- alle nødvendige oplysninger kan ses direkte: scope, geometriformel, mængde, enhed, pris pr. enhed, materialer, arbejdstid og total +- UI’et er testet ved desktop-, laptop-, tablet- og mobilbredde uden blokerende layoutfejl +- opgaver og materialer hænger sammen i samme pakkeinstans +- geometrien beregner m², løbende meter og antal korrekt +- nedløb som standard beregnes `2 × væghøjde` +- Ordrestyring, prislister/materialedatabase og Håndværkpriser har separat provenance +- alle prisbaser står eksplicit på hver linje +- Smart Pakke, reload, Final Review og PDF har samme tal +- en fuld UI-generalprøve er grøn og dokumenteret diff --git a/.reports/codemap-diff.txt b/.reports/codemap-diff.txt new file mode 100644 index 0000000..651176d --- /dev/null +++ b/.reports/codemap-diff.txt @@ -0,0 +1,13 @@ +codemap-diff report +generated: 2026-09-04 + +Initial version. No prior baseline existed in this repo (no codemaps/ directory +before this run), so there is nothing to diff against and no >30% change +approval gate applies. Future runs of /update-codemaps should diff against +this version. + +Files written: +- codemaps/architecture.md +- codemaps/backend.md +- codemaps/frontend.md +- codemaps/data.md diff --git a/README.md b/README.md index 938de0b..871b193 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,12 @@ Se `/docs/` mappen for detaljeret dokumentation: - [Deployment Guide](docs/deployment/) - [Logging System](docs/LOGGING_SYSTEM.md) +Se `/codemaps/` for arkitektur-diagrammer og systemdesign, verificeret mod den faktiske kode og live database: +- [Systemarkitektur](codemaps/architecture.md) — proces, deployment, eksterne integrationer +- [Backend](codemaps/backend.md) — routes, services, request flow, auth +- [Frontend](codemaps/frontend.md) — komponentstruktur, view-switch pattern +- [Data](codemaps/data.md) — databaseskema, tabeloversigt, migrationsmodel + ## Support Ved spørgsmål eller problemer, opret et issue i projektets GitHub repository. diff --git a/backend/__tests__/accountBoundary.test.js b/backend/__tests__/accountBoundary.test.js new file mode 100644 index 0000000..4d61bef --- /dev/null +++ b/backend/__tests__/accountBoundary.test.js @@ -0,0 +1,152 @@ +process.env.JWT_ACCESS_SECRET = 'boundary-test-secret'; +process.env.JWT_REFRESH_SECRET = 'boundary-refresh-secret'; +process.env.AUTH_USERNAME = 'operator'; +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +jest.mock('uuid', () => ({ v4: () => 'test-id' })); +const db = require('../src/services/databaseService'); +const users = require('../src/services/userService'); +const smart = require('../src/routes/smartPackagesRoutes'); +const projects = require('../src/routes/customerProjects'); +const { createGenericQuoteRouter } = require('../src/routes/genericQuoteRoutes'); +const app = express(); +app.use(express.json()); +app.use('/smart', smart); +app.use('/projects', projects); +app.use('/generic', createGenericQuoteRouter({ service: {} })); +const bearer = claims => `Bearer ${jwt.sign({ id: 7, username: 'operator', ...claims }, process.env.JWT_ACCESS_SECRET)}`; +afterEach(() => jest.restoreAllMocks()); + +const paths = ['/smart/management', '/projects/projects', '/generic/projects/1/generic-quote-snapshot']; +test.each(paths.flatMap(path => [ + ['deleted', null], ['replaced', { id: 8, username: 'operator', role: 'admin' }], + ['renamed', { id: 7, username: 'Operator', role: 'admin' }], + ['demoted', { id: 7, username: 'operator', role: 'user' }] +].map(([name, row]) => [path, name, row])))('%s rejects %s live account', async (path, _name, row) => { + jest.spyOn(users, 'findByUsername').mockResolvedValue(row); + expect((await request(app).get(path).set('Authorization', bearer())).status).toBe(403); +}); +test.each(paths)('%s fails closed on database outage', async path => { + jest.spyOn(users, 'findByUsername').mockRejectedValue(new Error('offline')); + expect((await request(app).get(path).set('Authorization', bearer())).status).toBe(503); +}); +test('unified quote/PDF aliases use the shared live-account guard', () => { + const source = require('fs').readFileSync(require('path').join(__dirname, '../unified-server.js'), 'utf8'); + expect(source).toMatch(/requireConfiguredOperator:\s*requireConfiguredProjectOperator/); + expect(source).not.toMatch(/const requireConfiguredProjectOperator = \(req, res, next\) =>/); + expect(source).toMatch(/app\.post\('\/api\/quotes\/generate-static', verifyToken, requireConfiguredProjectOperator,/); + expect(source).toMatch(/app\.get\('\/api\/customer-projects\/tag-experience-suggestions', verifyToken, requireConfiguredProjectOperator,/); +}); +test('server refuses to listen when account schema bootstrap fails', () => { + const source = require('fs').readFileSync(require('path').join(__dirname, '../unified-server.js'), 'utf8'); + const start = source.slice(source.indexOf('const startServer = async'), source.indexOf('// Export app for testing')); + expect(start).toMatch(/if \(!backendReady\)[\s\S]*throw new Error/); + expect(start.indexOf('if (!backendReady)')).toBeLessThan(start.indexOf('server.listen')); + expect(start).toMatch(/startServer\(\)\.catch/); +}); +test('legacy project, package, and quote prefixes share the operator boundary', () => { + const source = require('fs').readFileSync(require('path').join(__dirname, '../unified-server.js'), 'utf8'); + expect(source).toMatch(/app\.use\(PROTECTED_OPERATOR_PREFIXES, verifyToken/); + for (const prefix of ['/api/customer-projects', '/api/projects', '/api/project-materials', '/api/material-packages', '/api/quotes']) { + expect(source).toContain(`'${prefix}'`); + } + expect(source).toContain("'/api/customer-projects/material-price-status'"); + expect(source).toMatch(/realism-analysis/); +}); +test('shared guard uses live role and exact identity', async () => { + const { verifyToken, requireConfiguredOperator } = require('../src/middleware/auth'); + expect(requireConfiguredOperator).toEqual(expect.any(Function)); + const guarded = express(); + guarded.get('/', verifyToken, requireConfiguredOperator, (req, res) => res.json(req.user)); + jest.spyOn(users, 'findByUsername').mockResolvedValue({ id: 7, username: 'operator', role: 'admin' }); + expect((await request(guarded).get('/').set('Authorization', bearer({ role: 'user' }))).status).toBe(200); +}); + +// A transactional fake models a blocking locking read and committed state visibility. +// Unlocked pool queries deliberately do not serialize competing mutations. +function accountStore() { + let rows = [{ id: 1, role: 'admin' }, { id: 2, role: 'admin' }]; + let tail = Promise.resolve(); + const connections = []; + const execute = async (sql, args = []) => { + if (/SELECT/i.test(sql)) return [rows.map(row => ({ ...row }))]; + if (/DELETE/i.test(sql)) rows = rows.filter(row => row.id !== args[0]); + if (/UPDATE/i.test(sql)) rows = rows.map(row => row.id === args[args.length - 1] ? { ...row, role: args[0] } : row); + return [{ affectedRows: 1 }]; + }; + jest.spyOn(db, 'query').mockImplementation(async (...args) => (await execute(...args))[0]); + db.pool = { getConnection: jest.fn(async () => { + let unlock; + const connection = { + beginTransaction: jest.fn(async () => {}), + execute: jest.fn(async (sql, args) => { + if (/FOR UPDATE/i.test(sql) && !unlock) { + const previous = tail; + tail = new Promise(resolve => { unlock = resolve; }); + await previous; + } + return execute(sql, args); + }), + commit: jest.fn(async () => { if (unlock) unlock(); }), + rollback: jest.fn(async () => { if (unlock) unlock(); }), + release: jest.fn() + }; + connections.push(connection); + return connection; + }) }; + return { rows: () => rows, connections }; +} +test.each(['demote', 'delete', 'mixed'])('concurrent %s attempts preserve one administrator', async mode => { + const store = accountStore(); + const mutate = id => mode === 'delete' || (mode === 'mixed' && id === 2) + ? users.deleteUser(id) : users.updateUser(id, { role: 'user' }); + const results = await Promise.allSettled([mutate(1), mutate(2)]); + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1); + expect(results.find(result => result.status === 'rejected').reason.code).toBe('LAST_ADMIN'); + expect(store.rows().filter(row => row.role === 'admin')).toHaveLength(1); + expect(store.connections).toHaveLength(2); + for (const connection of store.connections) expect(connection.release).toHaveBeenCalledTimes(1); +}); +test('ordinary status summary never exposes history based on a stale operator username', async () => { + const service = require('../src/services/quoteRealismService'); + jest.spyOn(service.prototype, 'getAnalysis').mockResolvedValue({ projectId: 1, approved: true, history: ['private'] }); + const lookup = jest.spyOn(users, 'findByUsername').mockRejectedValue(new Error('offline')); + const response = await request(app).get('/projects/projects/1/realism-analysis').set('Authorization', bearer()); + expect(response.status).toBe(200); + expect(response.body.analysis).not.toHaveProperty('history'); + expect(response.body.analysis.historyRestricted).toBe(true); + expect(lookup).toHaveBeenCalledWith('operator'); +}); +test('every non-public Smart Package route includes the shared guard', () => { + const { requireConfiguredOperator } = require('../src/middleware/auth'); + for (const layer of smart.stack.filter(layer => layer.route)) { + const route = layer.route; + if (route.methods.get && ['/', '/:id'].includes(route.path)) continue; + expect(route.stack.map(handler => handler.handle)).toContain(requireConfiguredOperator); + } +}); +test.each(['delete', 'demote'])('cannot %s the sole admin even when targeting another account', async action => { + const store = accountStore(); + await users.deleteUser(2); + await expect(action === 'delete' ? users.deleteUser(1) : users.updateUser(1, { role: 'user' })) + .rejects.toMatchObject({ code: 'LAST_ADMIN', status: 400 }); + expect(store.rows()).toEqual([{ id: 1, role: 'admin' }]); + expect(store.connections[1].rollback).toHaveBeenCalledTimes(1); +}); +test('mutation failure rolls back and releases the connection', async () => { + const store = accountStore(); + const acquire = db.pool.getConnection; + db.pool.getConnection = async () => { + const connection = await acquire(); + const execute = connection.execute.getMockImplementation(); + connection.execute.mockImplementation((sql, args) => { + if (/DELETE/.test(sql)) throw new Error('write failed'); + return execute(sql, args); + }); + return connection; + }; + await expect(users.deleteUser(1)).rejects.toThrow('write failed'); + expect(store.connections[0].rollback).toHaveBeenCalledTimes(1); + expect(store.connections[0].release).toHaveBeenCalledTimes(1); +}); diff --git a/backend/__tests__/authAccountBootstrap.test.js b/backend/__tests__/authAccountBootstrap.test.js new file mode 100644 index 0000000..6fd38c9 --- /dev/null +++ b/backend/__tests__/authAccountBootstrap.test.js @@ -0,0 +1,92 @@ +jest.mock('mysql2/promise', () => ({ createPool: jest.fn(), createConnection: jest.fn() })); +const mysql = require('mysql2/promise'); +const db = require('../src/services/databaseService'); +afterEach(() => jest.restoreAllMocks()); +test('normal clean startup creates auth schema idempotently without credential writes', async () => { + process.env.DB_PASSWORD = 'test-only'; + const execute = jest.fn(async sql => { + if (/INFORMATION_SCHEMA\.TABLES/i.test(sql)) return [[{ ENGINE: 'InnoDB' }]]; + if (/COUNT\(\*\).*auth_accounts/is.test(sql)) return [[{ account_count: 1 }]]; + return [[]]; + }); + mysql.createPool.mockReturnValue({ execute, getConnection: async () => ({ query: async () => [[]], release() {} }) }); + for (const method of ['ensureSystemSettingsTable', 'removeLegacySupportSecrets', 'syncSupportSettingsFromEnv', 'createTables', 'seedAiFeatureSettings']) jest.spyOn(db, method).mockResolvedValue(); + await db.initialize(); + await db.initialize(); + expect(execute.mock.calls).toHaveLength(8); + for (const [sql] of execute.mock.calls) { + expect(sql).not.toMatch(/INSERT|UPDATE auth_accounts|REPLACE/i); + } + expect(execute.mock.calls.filter(([sql]) => /CREATE TABLE IF NOT EXISTS auth_accounts/i.test(sql))).toHaveLength(2); + expect(execute.mock.calls.filter(([sql]) => /CREATE TABLE IF NOT EXISTS ordrestyring_offer_operations/i.test(sql))).toHaveLength(2); +}); + +test('provisions the first admin once from configured credentials without overwriting accounts', async () => { + const execute = jest.fn() + .mockResolvedValueOnce([[{ account_count: 0 }]]) + .mockResolvedValueOnce([{ affectedRows: 1 }]); + const hashPassword = jest.fn().mockResolvedValue('$2b$12$initial-admin-hash'); + const { provisionInitialAdmin } = require('../src/services/authAccountSchema'); + + await expect(provisionInitialAdmin({ execute }, { + env: { AUTH_USERNAME: 'operator', AUTH_PASSWORD: 'configured-secret' }, + hashPassword + })).resolves.toBe(true); + + expect(hashPassword).toHaveBeenCalledWith('configured-secret', 12); + expect(execute.mock.calls[1]).toEqual([ + expect.stringMatching(/INSERT IGNORE INTO auth_accounts/), + ['operator', '$2b$12$initial-admin-hash', 'admin'] + ]); +}); + +test('fails closed when an empty account table has no initial admin credentials', async () => { + const { provisionInitialAdmin } = require('../src/services/authAccountSchema'); + const execute = jest.fn().mockResolvedValueOnce([[{ account_count: 0 }]]); + + await expect(provisionInitialAdmin({ execute }, { env: {}, hashPassword: jest.fn() })) + .rejects.toMatchObject({ code: 'INITIAL_ADMIN_REQUIRED' }); + expect(execute).toHaveBeenCalledTimes(1); +}); + +test('converts a legacy non-transactional auth table before account mutations can run', async () => { + const execute = jest.fn(async sql => ( + /INFORMATION_SCHEMA\.TABLES/i.test(sql) ? [[{ ENGINE: 'MyISAM' }]] : [[]] + )); + await require('../src/services/authAccountSchema').ensureAuthAccountsTable({ execute }); + expect(execute.mock.calls.map(([sql]) => sql)).toEqual([ + expect.stringMatching(/CREATE TABLE IF NOT EXISTS auth_accounts/i), + expect.stringMatching(/INFORMATION_SCHEMA\.TABLES/i), + expect.stringMatching(/ALTER TABLE auth_accounts ENGINE=InnoDB/i) + ]); +}); +test('migration has no credential seeding or hashing dependency', () => { + const source = require('fs').readFileSync(require('path').join(__dirname, '../../database/migrations/20260904_users_table.js'), 'utf8'); + expect(source).not.toMatch(/bcrypt|AUTH_PASSWORD|INSERT|seedUsers/); +}); +test('migration executes only idempotent DDL even when legacy credentials are configured', async () => { + const fs = require('fs'); + const path = require('path'); + const vm = require('vm'); + const filename = path.join(__dirname, '../../database/migrations/20260904_users_table.js'); + const execute = jest.fn(async sql => ( + /INFORMATION_SCHEMA\.TABLES/i.test(sql) ? [[{ ENGINE: 'InnoDB' }]] : [[]] + )); + let finished; + const done = new Promise(resolve => { finished = resolve; }); + const connection = { execute, end: jest.fn(async () => finished()) }; + const localRequire = name => { + if (name === 'mysql2/promise') return { createConnection: async () => connection }; + if (name === 'dotenv') return { config() {} }; + if (name.includes('authAccountSchema')) return require('../src/services/authAccountSchema'); + return require(name); + }; + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { + require: localRequire, __dirname: path.dirname(filename), console, + process: { env: { AUTH_USERNAME: 'operator', AUTH_PASSWORD: 'must-not-be-written' }, exit: jest.fn() } + }); + await done; + expect(execute).toHaveBeenCalledTimes(2); + expect(execute.mock.calls[0][0]).toMatch(/CREATE TABLE IF NOT EXISTS auth_accounts/); + expect(execute.mock.calls[0]).toHaveLength(1); +}); diff --git a/backend/__tests__/customerProjectsMaterials.test.js b/backend/__tests__/customerProjectsMaterials.test.js index 53793e9..a43ce14 100644 --- a/backend/__tests__/customerProjectsMaterials.test.js +++ b/backend/__tests__/customerProjectsMaterials.test.js @@ -19,6 +19,7 @@ jest.mock('../src/utils/logger', () => ({ const ProjectMaterialService = require('../src/services/projectMaterialService'); const CustomerProjectService = require('../src/services/customerProjectService'); const RoofGeometryService = require('../src/services/roofGeometryService'); +const SmartPackageWorkspaceService = require('../src/services/smartPackageWorkspaceService'); const QuoteRealismService = require('../src/services/quoteRealismService'); const customerProjectsRoutes = require('../src/routes/customerProjects'); @@ -29,18 +30,175 @@ const buildApp = () => { return app; }; -const authHeader = () => `Bearer ${jwt.sign({ id: 1, username: 'test-user' }, process.env.JWT_ACCESS_SECRET)}`; +const authHeader = (username = 'test-user') => `Bearer ${jwt.sign({ id: 1, username }, process.env.JWT_ACCESS_SECRET)}`; describe('customer project material creation routes', () => { afterEach(() => { jest.restoreAllMocks(); }); + test('reads and atomically replaces the Smart Package Lego workspace', async () => { + const getWorkspace = jest.spyOn(SmartPackageWorkspaceService.prototype, 'getWorkspace') + .mockResolvedValue({ projectId: 399, version: 2, instances: [] }); + const replaceWorkspace = jest.spyOn(SmartPackageWorkspaceService.prototype, 'replaceWorkspace') + .mockResolvedValue({ projectId: 399, version: 3, instances: [{ instanceId: 'block-1' }] }); + + const read = await request(buildApp()) + .get('/api/customer-projects/projects/399/smart-package-workspace') + .set('Authorization', authHeader()); + expect(read.status).toBe(200); + expect(read.body.workspace.version).toBe(2); + expect(getWorkspace).toHaveBeenCalledWith(399); + + const write = await request(buildApp()) + .put('/api/customer-projects/projects/399/smart-package-workspace') + .set('Authorization', authHeader()) + .send({ expectedVersion: 2, instances: [{ instanceId: 'block-1' }] }); + expect(write.status).toBe(200); + expect(write.body.workspace.version).toBe(3); + expect(replaceWorkspace).toHaveBeenCalledWith( + 399, + expect.objectContaining({ expectedVersion: 2 }), + { operator: 'test-user' } + ); + }); + + test('allows only the configured operator to read a project workspace', async () => { + const getWorkspace = jest.spyOn(SmartPackageWorkspaceService.prototype, 'getWorkspace') + .mockResolvedValue({ projectId: 399, version: 2, instances: [] }); + const app = buildApp(); + + const unauthenticated = await request(app) + .get('/api/customer-projects/projects/399/smart-package-workspace'); + const nonOperator = await request(app) + .get('/api/customer-projects/projects/399/smart-package-workspace') + .set('Authorization', authHeader('other-user')); + const nonOperatorWrite = await request(app) + .put('/api/customer-projects/projects/399/smart-package-workspace') + .set('Authorization', authHeader('other-user')) + .send({ expectedVersion: 2, instances: [] }); + const operator = await request(app) + .get('/api/customer-projects/projects/399/smart-package-workspace') + .set('Authorization', authHeader()); + + expect([unauthenticated.status, nonOperator.status, nonOperatorWrite.status, operator.status]) + .toEqual([401, 403, 403, 200]); + expect(getWorkspace).toHaveBeenCalledTimes(1); + }); + + test('allows only the configured operator to start project validation AI jobs', async () => { + const projectLookup = jest.spyOn(CustomerProjectService.prototype, 'getProjectWithDetails') + .mockResolvedValue({ project: { id: 399 } }); + const app = buildApp(); + + const unauthenticated = await request(app) + .post('/api/customer-projects/projects/399/validate-flow') + .send({}); + const nonOperator = await request(app) + .post('/api/customer-projects/projects/399/validate-flow') + .set('Authorization', authHeader('other-user')) + .send({}); + + expect([unauthenticated.status, nonOperator.status]).toEqual([401, 403]); + expect(projectLookup).not.toHaveBeenCalled(); + }); + + test('enforces authentication and operator access across project route groups', async () => { + const app = buildApp(); + const routes = [ + ['get', '/api/customer-projects/projects'], + ['post', '/api/customer-projects/projects'], + ['get', '/api/customer-projects/projects/399/labor'], + ['post', '/api/customer-projects/projects/399/labor'], + ['get', '/api/customer-projects/projects/399/materials'], + ['post', '/api/customer-projects/projects/399/materials/match-preview'], + ['get', '/api/customer-projects/projects/399/calculation'], + ['post', '/api/customer-projects/projects/399/calculate'], + ['get', '/api/customer-projects/399/quotes'], + ['put', '/api/customer-projects/quotes/44/status'], + ['get', '/api/customer-projects/project-validation/job-44'] + ]; + + for (const [method, path] of routes) { + const unauthenticated = await request(app)[method](path).send({}); + const nonOperator = await request(app)[method](path) + .set('Authorization', authHeader('other-user')) + .send({}); + expect(unauthenticated.status).toBe(401); + expect(nonOperator.status).toBe(403); + } + }); + + test('returns a safe legacy-labor classification challenge to the configured operator', async () => { + jest.spyOn(SmartPackageWorkspaceService.prototype, 'replaceWorkspace').mockRejectedValue( + Object.assign(new Error('internal labor row'), { + status: 409, + code: 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS', + expectedDigest: 'a'.repeat(64), + ambiguousEntryIndexes: [0, 2] + }) + ); + const response = await request(buildApp()) + .put('/api/customer-projects/projects/399/smart-package-workspace') + .set('Authorization', authHeader()) + .send({ expectedVersion: 0, instances: [] }); + + expect(response.status).toBe(409); + expect(response.body).toEqual({ + success: false, + error: 'Eksisterende arbejdstimer kræver eksplicit klassifikation.', + code: 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS', + legacyLaborClassification: { + expectedDigest: 'a'.repeat(64), + ambiguousEntryIndexes: [0, 2] + } + }); + expect(JSON.stringify(response.body)).not.toContain('internal labor row'); + }); + + test('returns a safe workspace conflict without exposing internals', async () => { + jest.spyOn(SmartPackageWorkspaceService.prototype, 'replaceWorkspace').mockRejectedValue( + Object.assign(new Error('internal row version 4'), { status: 409, code: 'SMART_PACKAGE_WORKSPACE_CONFLICT' }) + ); + const response = await request(buildApp()) + .put('/api/customer-projects/projects/399/smart-package-workspace') + .set('Authorization', authHeader()) + .send({ expectedVersion: 2, instances: [] }); + expect(response.status).toBe(409); + expect(response.body).toEqual({ success: false, error: 'Smart Pakke-arbejdsområdet er ændret. Genindlæs og prøv igen.', code: 'SMART_PACKAGE_WORKSPACE_CONFLICT' }); + }); + + test('returns a safe stale-map conflict without exposing geometry internals', async () => { + jest.spyOn(SmartPackageWorkspaceService.prototype, 'replaceWorkspace').mockRejectedValue( + Object.assign(new Error('signature bbbb does not match project 399 row aaaa'), { + status: 409, code: 'SMART_PACKAGE_SITE_GEOMETRY_STALE' + }) + ); + const response = await request(buildApp()) + .put('/api/customer-projects/projects/399/smart-package-workspace') + .set('Authorization', authHeader()) + .send({ expectedVersion: 2, instances: [] }); + expect(response.status).toBe(409); + expect(response.body).toEqual({ + success: false, + error: 'Kortområdet mangler eller er ændret. Genåbn kortet og prøv igen.', + code: 'SMART_PACKAGE_SITE_GEOMETRY_STALE' + }); + expect(JSON.stringify(response.body)).not.toContain('bbbb'); + }); + test('forwards roof material from Enhanced Geometry to persistence', async () => { - const save = jest.spyOn(RoofGeometryService.prototype, 'saveRoofGeometry').mockResolvedValue({ id: 1 }); + const save = jest.spyOn(RoofGeometryService.prototype, 'saveRoofGeometry').mockImplementation(async (_id, geometry) => { + if (geometry.roofMaterial === 'asbest-ukendt') { + throw Object.assign(new Error('invalid material'), { status: 400, code: 'ROOF_GEOMETRY_INVALID' }); + } + return { id: 1 }; + }); + jest.spyOn(RoofGeometryService.prototype, 'getRoofGeometry').mockResolvedValue({ roofType: 'gable' }); jest.spyOn(CustomerProjectService.prototype, 'updateProjectStatus').mockResolvedValue(true); const res = await request(buildApp()) .post('/api/customer-projects/projects/399/geometry') + .set('Authorization', authHeader()) .send({ roofType: 'skraat_tag', roofMaterial: 'tegl', @@ -54,10 +212,13 @@ describe('customer project material creation routes', () => { const invalid = await request(buildApp()) .post('/api/customer-projects/projects/399/geometry') + .set('Authorization', authHeader()) .send({ roofType: 'skraat_tag', roofMaterial: 'asbest-ukendt', totalArea: 100 }); expect(invalid.status).toBe(400); - expect(invalid.body).toMatchObject({ success: false, error: 'Ugyldigt tagmateriale' }); - expect(save).toHaveBeenCalledTimes(1); + expect(invalid.body).toMatchObject({ + success: false, error: 'Ugyldige taggeometridata', code: 'ROOF_GEOMETRY_INVALID' + }); + expect(save).toHaveBeenCalledTimes(2); }); test('returns a safe 400 response for a material name longer than the database column', async () => { @@ -69,6 +230,7 @@ describe('customer project material creation routes', () => { const res = await request(buildApp()) .post('/api/customer-projects/projects/394/materials/bulk') + .set('Authorization', authHeader()) .send({ materials: [{ materialName: 'x'.repeat(256) }] }); expect(res.status).toBe(400); @@ -87,6 +249,7 @@ describe('customer project material creation routes', () => { const res = await request(buildApp()) .post('/api/customer-projects/projects/394/materials/bulk') + .set('Authorization', authHeader()) .send({ materials: [{ materialName: 'Taglægte' }] }); expect(res.status).toBe(500); @@ -105,6 +268,7 @@ describe('customer project material creation routes', () => { const res = await request(buildApp()) .post('/api/customer-projects/projects/394/materials/bulk') + .set('Authorization', authHeader()) .send({ materials: [{ materialName: 'Taglægte' }] }); expect(res.status).toBe(400); @@ -124,6 +288,7 @@ describe('customer project material creation routes', () => { const res = await request(buildApp()) .post('/api/customer-projects/projects/394/materials') + .set('Authorization', authHeader()) .send({ materials: [{ materialName: 'x'.repeat(256), quantity: 1, @@ -150,6 +315,7 @@ describe('customer project material creation routes', () => { const res = await request(buildApp()) .post('/api/customer-projects/projects/392/materials/match-preview') + .set('Authorization', authHeader()) .send({ materialIds: [501] }); expect(res.status).toBe(200); @@ -170,6 +336,7 @@ describe('customer project material creation routes', () => { const res = await request(buildApp()) .put('/api/customer-projects/projects/392/materials/501/link') + .set('Authorization', authHeader()) .send({ materialId: 10 }); expect(res.status).toBe(200); @@ -182,6 +349,7 @@ describe('customer project material creation routes', () => { const res = await request(buildApp()) .put('/api/customer-projects/projects/392/materials/501/link') + .set('Authorization', authHeader()) .send({}); expect(res.status).toBe(400); @@ -277,9 +445,11 @@ describe('customer project material creation routes', () => { expect(quoteResponse.body.code).toBe('REALISM_APPROVAL_REQUIRED'); }); - test('requires explicit complete approval for Jannick realism check', async () => { - const approved = { projectId: 392, approved: true, readyForFixedPrice: true }; - const approveAnalysis = jest.spyOn(QuoteRealismService.prototype, 'approveAnalysis').mockResolvedValue(approved); + test('requires exact canonical snapshot approval for Jannick realism check', async () => { + const snapshot = { signature: 'abc', approved: true, readyForSubmission: true, artifact: { schema: 'roof_quote_snapshot_v1' } }; + const analysis = { projectId: 392, approved: true, readyForFixedPrice: true }; + const approveSnapshot = jest.spyOn(QuoteRealismService.prototype, 'approveSnapshot').mockResolvedValue(snapshot); + jest.spyOn(QuoteRealismService.prototype, 'getAnalysis').mockResolvedValue(analysis); const payload = { signature: 'abc', acknowledgedClarifications: ['Dørtype'], @@ -290,8 +460,8 @@ describe('customer project material creation routes', () => { .set('Authorization', authHeader()) .send(payload); expect(res.status).toBe(200); - expect(res.body).toEqual({ success: true, analysis: approved }); - expect(approveAnalysis).toHaveBeenCalledWith(392, payload, 'test-user'); + expect(res.body).toEqual({ success: true, snapshot, analysis }); + expect(approveSnapshot).toHaveBeenCalledWith(392, 'abc', 'test-user'); }); test('returns only approval status to non-operator users', async () => { @@ -317,7 +487,7 @@ describe('customer project material creation routes', () => { }); }); - test('returns authenticated Jannick realism analysis for a project', async () => { + test('returns full realism analysis to the live configured operator', async () => { const analysis = { projectId: 392, confidence: 'low', @@ -334,3 +504,9 @@ describe('customer project material creation routes', () => { expect(getAnalysis).toHaveBeenCalledWith(392); }); }); + +// Route fixtures include the live account required by the shared authorization boundary. +beforeEach(() => { + jest.spyOn(require('../src/services/userService'), 'findByUsername').mockImplementation(async username => ({ id: 1, username, role: 'admin' })); +}); +afterEach(() => jest.restoreAllMocks()); diff --git a/backend/__tests__/deployRoofMigrations.test.js b/backend/__tests__/deployRoofMigrations.test.js new file mode 100644 index 0000000..ba08cc5 --- /dev/null +++ b/backend/__tests__/deployRoofMigrations.test.js @@ -0,0 +1,22 @@ +const fs = require('fs'); +const path = require('path'); + +const workflow = fs.readFileSync(path.join(__dirname, '../../.github/workflows/deploy-test.yml'), 'utf8'); +const contractMigration = fs.readFileSync( + path.join(__dirname, '../../database/migrations/20260910_complete_roof_package_contract.js'), + 'utf8' +); + +test('deployment packages and applies fail-closed roof catalog migrations before restart', () => { + expect(workflow).toMatch(/cp -r database deploy-package\/database/); + const gutter = workflow.indexOf('node database/migrations/20260902_gutter_meter_packages.js --apply'); + const contract = workflow.indexOf('node database/migrations/20260910_complete_roof_package_contract.js'); + const lifecycle = workflow.indexOf('node database/migrations/20260918_fail_closed_roof_lifecycle.js --apply'); + const restart = workflow.indexOf('pm2 restart ecosystem.config.js'); + expect(gutter).toBeGreaterThan(-1); + expect(contract).toBeGreaterThan(gutter); + expect(lifecycle).toBeGreaterThan(contract); + expect(restart).toBeGreaterThan(lifecycle); + expect(contractMigration).toMatch(/backendRequire\('dotenv'\)/); + expect(contractMigration).toMatch(/backendRequire\('mysql2\/promise'\)/); +}); diff --git a/backend/__tests__/envExampleAuth.test.js b/backend/__tests__/envExampleAuth.test.js new file mode 100644 index 0000000..0b3608d --- /dev/null +++ b/backend/__tests__/envExampleAuth.test.js @@ -0,0 +1,10 @@ +const fs = require('fs'); +const path = require('path'); + +const example = fs.readFileSync(path.join(__dirname, '../.env.example'), 'utf8'); + +test('documents only authentication accounts that runtime can provision', () => { + expect(example).toContain('AUTH_USERNAME='); + expect(example).toContain('AUTH_PASSWORD='); + expect(example).not.toMatch(/AUTH_USERNAME_DEV|AUTH_PASSWORD_DEV|second login/i); +}); diff --git a/backend/__tests__/haandvaerkPriserImportRegression.test.js b/backend/__tests__/haandvaerkPriserImportRegression.test.js index 5c36b04..0ec5f90 100644 --- a/backend/__tests__/haandvaerkPriserImportRegression.test.js +++ b/backend/__tests__/haandvaerkPriserImportRegression.test.js @@ -60,7 +60,8 @@ describe('haandvaerkpriser imported component shape', () => { execute: jest.fn(async (sql) => { if (sql.includes('SELECT GET_LOCK')) return [[{ acquired: 1 }]]; if (sql.includes('SELECT RELEASE_LOCK')) throw new Error('lock release failed'); - if (sql.includes('SELECT id, name, category')) return [[]]; + if (sql.includes("WHERE package_type = 'component'")) return [[]]; + if (sql.includes('SELECT * FROM material_packages')) return [[{ id: 901, version: 1 }]]; if (sql.includes('SELECT source_key')) return [[]]; if (sql.includes('INSERT INTO material_packages')) return [{ insertId: 901 }]; if (sql.includes('DELETE FROM smart_package_tasks')) return [{}]; @@ -91,7 +92,8 @@ describe('haandvaerkpriser imported component shape', () => { calls.push({ sql, params }); if (sql.includes('SELECT GET_LOCK')) return [[{ acquired: 1 }]]; if (sql.includes('SELECT RELEASE_LOCK')) return [[{ released: 1 }]]; - if (sql.includes('SELECT id, name, category')) return [[]]; + if (sql.includes("WHERE package_type = 'component'")) return [[]]; + if (sql.includes('SELECT * FROM material_packages')) return [[{ id: 901, version: 1 }]]; if (sql.includes('SELECT source_key')) return [[]]; if (sql.includes('INSERT INTO material_packages')) return [{ insertId: 901 }]; if (sql.includes('INSERT INTO haandvaerkpriser_imports')) return [{}]; diff --git a/backend/__tests__/haandvaerkPriserImportService.test.js b/backend/__tests__/haandvaerkPriserImportService.test.js index 8a71044..21612a9 100644 --- a/backend/__tests__/haandvaerkPriserImportService.test.js +++ b/backend/__tests__/haandvaerkPriserImportService.test.js @@ -4,9 +4,27 @@ const { parsePrice, parsePriceRange, normalizeUnit, - normalizeName + normalizeName, + shouldSkipSourceRow } = HaandvaerkPriserImportService; +describe('shouldSkipSourceRow', () => { + test('supersedes the fixed parcel-house gutter reference with meter-priced gutter packages', () => { + expect(shouldSkipSourceRow({ + name: 'Tagrender – reparation og udskiftning', + category: 'VVS & Blikkenslager', + unit: 'fast pris (parcelhus)', + detailUrl: 'https://haandvaerkpriser.dk/vvs/tagrender/tagrender-reparation-og-udskiftning/' + })).toBe(true); + expect(shouldSkipSourceRow({ + name: 'Tagrende – udskiftning komplet', + category: 'Tagrenovering', + unit: 'løbende m', + detailUrl: 'https://haandvaerkpriser.dk/tag/tagrende-udskiftning-komplet/' + })).toBe(false); + }); +}); + const section = (category, rows) => `
@@ -121,7 +139,10 @@ describe('HaandvaerkPriserImportService.import', () => { const execute = jest.fn(async (sql, params) => { if (sql.includes('SELECT GET_LOCK')) return [[{ acquired: 1 }]]; if (sql.includes('SELECT RELEASE_LOCK')) return [[{ released: 1 }]]; - if (sql.includes('SELECT id, name, category, unit_price')) return [existingRows]; + if (sql.includes("WHERE package_type = 'component'")) return [existingRows]; + if (sql.includes('SELECT * FROM material_packages')) { + return [[{ id: params[0], version: 1, name: 'Imported reference' }]]; + } if (sql.includes('SELECT source_key')) return [[]]; if (sql.includes('INSERT INTO material_packages')) { executedInserts.push({ sql, params }); @@ -131,7 +152,7 @@ describe('HaandvaerkPriserImportService.import', () => { if (sql.includes('DELETE FROM smart_package_tasks')) return [{}]; if (sql.includes('UPDATE material_packages') || sql.includes('UPDATE smart_package_tasks')) { executedUpdates.push({ sql, params }); - return [{}]; + return [{ affectedRows: 1 }]; } throw new Error(`Unexpected SQL: ${sql}`); }); @@ -148,7 +169,8 @@ describe('HaandvaerkPriserImportService.import', () => { const result = await service.import(); - expect(result).toEqual({ totalRows: 1, insertedPackages: 1, updatedPackages: 0, matchedExistingPackages: 0 }); + expect(result).toMatchObject({ totalRows: 1, insertedPackages: 1, updatedPackages: 0, matchedExistingPackages: 0 }); + expect(result.packages).toEqual([expect.objectContaining({ id: 901, version: 1 })]); expect(connection.commit).toHaveBeenCalled(); const packageInsert = executedInserts.find((call) => call.sql.includes('INSERT INTO material_packages')); expect(packageInsert.params).toEqual(expect.arrayContaining(['Maling af lejlighed', expect.any(String), 'Maler'])); @@ -169,13 +191,30 @@ describe('HaandvaerkPriserImportService.import', () => { const result = await service.import(); - expect(result).toEqual({ totalRows: 1, insertedPackages: 1, updatedPackages: 0, matchedExistingPackages: 1 }); + expect(result).toMatchObject({ totalRows: 1, insertedPackages: 1, updatedPackages: 0, matchedExistingPackages: 1 }); expect(executedUpdates).toHaveLength(0); const packageInsert = executedInserts.find((call) => call.sql.includes('INSERT INTO material_packages')); // Source 110 inkl. moms = 88 ekskl. moms; (90 + 88) / 2 = 89. expect(packageInsert.params).toEqual(expect.arrayContaining([89])); }); + test('rejects a stale imported target before deleting its child tasks', async () => { + const { connection } = buildConnection([ + { id: 42, version: 4, name: 'Maling af lejlighed', category: 'Maler', unit_price: '90.00', created_by: 'haandvaerkpriser-import', is_active: 1, validation_status: 'verified' } + ]); + const service = new HaandvaerkPriserImportService({ pool: { getConnection: async () => connection } }); + service.fetchRows = jest.fn().mockResolvedValue([ + { category: 'Maler', name: 'Maling af lejlighed', avgPrice: 110, unit: 'm²', rawUnitText: 'pr. m²', detailUrl: 'https://haandvaerkpriser.dk/maler/x/' } + ]); + + await expect(service.import({ 42: 3 })).rejects.toMatchObject({ + status: 409, + code: 'SMART_PACKAGE_VERSION_CONFLICT' + }); + expect(connection.execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM smart_package_tasks'))).toBe(false); + expect(connection.rollback).toHaveBeenCalledTimes(1); + }); + test('rolls back and rethrows on a database error', async () => { const { connection } = buildConnection([]); connection.execute = jest.fn() diff --git a/backend/__tests__/haandvaerkPriserRoutes.test.js b/backend/__tests__/haandvaerkPriserRoutes.test.js index 123d3f6..8064f3e 100644 --- a/backend/__tests__/haandvaerkPriserRoutes.test.js +++ b/backend/__tests__/haandvaerkPriserRoutes.test.js @@ -71,11 +71,11 @@ describe('haandvaerkpriser routes', () => { const response = await request(buildApp()) .post('/api/smart-packages/haandvaerkpriser-import') .set('Authorization', authHeader()) - .send({ confirm: true }); + .send({ confirm: true, expectedVersions: { 42: 3 } }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ success: true, totalRows: 195 }); - expect(mockImport).toHaveBeenCalledTimes(1); + expect(mockImport).toHaveBeenCalledWith({ 42: 3 }); }); test('rejects overlapping imports', async () => { @@ -121,3 +121,9 @@ describe('haandvaerkpriser routes', () => { expect(JSON.stringify(imported.body)).not.toContain('private_schema'); }); }); + +// Route fixtures include the live account required by the shared authorization boundary. +beforeEach(() => { + jest.spyOn(require('../src/services/userService'), 'findByUsername').mockImplementation(async username => ({ id: 1, username, role: 'admin' })); +}); +afterEach(() => jest.restoreAllMocks()); diff --git a/backend/__tests__/materialPackageService.test.js b/backend/__tests__/materialPackageService.test.js new file mode 100644 index 0000000..b40cc60 --- /dev/null +++ b/backend/__tests__/materialPackageService.test.js @@ -0,0 +1,46 @@ +const MaterialPackageService = require('../src/services/materialPackageService'); + +jest.mock('../src/utils/logger', () => ({ info: jest.fn(), error: jest.fn() })); + +describe('MaterialPackageService aggregate creation', () => { + function fixture({ failChild = false } = {}) { + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn(async sql => { + if (sql.includes('INSERT INTO material_packages')) return [{ insertId: 7 }]; + if (failChild && sql.includes('INSERT INTO package_materials')) throw new Error('material failed'); + if (sql.includes('INSERT INTO package_materials')) return [{ insertId: 11 }]; + if (sql.includes('SELECT * FROM material_packages')) return [[{ id: 7, name: 'Package', version: 1 }]]; + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + return { + connection, + service: new MaterialPackageService({ pool: { getConnection: async () => connection } }) + }; + } + + test('creates the package and all materials atomically and returns its current version', async () => { + const { service, connection } = fixture(); + const result = await service.createPackage({ + name: 'Package', description: 'Description', category: 'Roof', createdBy: 'operator', + materials: [{ materialName: 'Tile', materialCategory: 'Roof', quantity: 2, unit: 'stk', unitPrice: 10 }] + }); + + expect(result).toMatchObject({ id: 7, version: 1, package: { id: 7, version: 1 } }); + expect(connection.beginTransaction).toHaveBeenCalledTimes(1); + expect(connection.commit).toHaveBeenCalledTimes(1); + expect(connection.rollback).not.toHaveBeenCalled(); + expect(connection.release).toHaveBeenCalledTimes(1); + }); + + test('rolls the parent back if a material insert fails', async () => { + const { service, connection } = fixture({ failChild: true }); + await expect(service.createPackage({ + name: 'Package', description: 'Description', category: 'Roof', createdBy: 'operator', + materials: [{ materialName: 'Tile', materialCategory: 'Roof', quantity: 2, unit: 'stk', unitPrice: 10 }] + })).rejects.toThrow('material failed'); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.commit).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/nominatim.test.js b/backend/__tests__/nominatim.test.js new file mode 100644 index 0000000..317afaa --- /dev/null +++ b/backend/__tests__/nominatim.test.js @@ -0,0 +1,134 @@ +const { Nominatim } = require('../src/services/nominatimService'); + +const providerData = [{ display_name: 'Aarhus', lat: '56', lon: '10', place_id: 7 }]; + +function sharedHarness({ renewalAffected = 1, completionAffected = 1, cacheWriteError = null } = {}) { + const events = []; + let retained = false; + const connection = { + execute: jest.fn(async (sql, params) => { + if (sql.includes('GET_LOCK')) { retained = true; events.push('lock'); return [[{ acquired: 1 }]]; } + if (sql.includes('nominatim_cache') && sql.includes('SELECT')) return [[]]; + if (sql.includes('INSERT IGNORE INTO nominatim_rate_gate')) return [{ affectedRows: 0 }]; + if (sql.includes('SET reservation_token')) { events.push(['reserve', params[0]]); return [{ affectedRows: 1 }]; } + if (sql.includes('RELEASE_LOCK')) { events.push('unlock'); return [[{ released: 1 }]]; } + throw new Error(`Unexpected connection SQL: ${sql}`); + }), + release: jest.fn(() => { retained = false; events.push('release'); }) + }; + const pool = { + getConnection: jest.fn(async () => connection), + execute: jest.fn(async (sql, params) => { + if (sql.includes('SET reservation_expires_at')) { + events.push(['renew', params[0]]); + return [{ affectedRows: renewalAffected }]; + } + if (sql.includes('SET next_request_at')) { + events.push(['complete', params[0]]); + return [{ affectedRows: completionAffected }]; + } + if (sql.includes('INSERT INTO nominatim_cache')) { + events.push('cache-write'); + if (cacheWriteError) throw cacheWriteError; + return [{ affectedRows: 1 }]; + } + throw new Error(`Unexpected pool SQL: ${sql}`); + }) + }; + return { pool, connection, events, isRetained: () => retained }; +} + +test('bounded explicit search identifies app, times out, caches and never returns measured area', async () => { + const http = { get: jest.fn().mockResolvedValue({ data: Array.from({ length: 9 }, (_, i) => ({ display_name: `Adresse ${i}`, lat: '56', lon: '10', place_id: i, boundingbox: [1, 2, 3, 4] })) }) }; + const service = new Nominatim({ http, now: () => 2000 }); + const results = await service.search(' Aarhus '); + expect(results).toHaveLength(5); + expect(results[0]).toEqual({ formattedAddress: 'Adresse 0', lat: 56, lng: 10, providerPlaceId: '0', provider: 'openstreetmap' }); + expect(http.get).toHaveBeenCalledWith('https://nominatim.openstreetmap.org/search', expect.objectContaining({ timeout: 5000, headers: { 'User-Agent': expect.stringContaining('Tilbudsgivern') }, params: { q: 'Aarhus', format: 'jsonv2', limit: 5, addressdetails: 0 } })); + expect(await service.search('Aarhus')).toEqual(results); + expect(http.get).toHaveBeenCalledTimes(1); + await expect(service.search('Odense')).rejects.toMatchObject({ status: 429 }); +}); + +test('rejects unbounded queries and handles provider errors without exposing upstream details', async () => { + const http = { get: jest.fn().mockRejectedValue(new Error('private upstream details')) }; + const service = new Nominatim({ http }); + for (const query of ['', 'ab', 'a'.repeat(201), {}, null]) await expect(service.search(query)).rejects.toMatchObject({ status: 400 }); + expect(http.get).not.toHaveBeenCalled(); + await expect(service.search('Aarhus')).rejects.toMatchObject({ status: 502, message: 'Adressesøgning er ikke tilgængelig. Prøv igen eller tegn selv.' }); +}); + +test('releases advisory lock and its connection before token CAS and provider I/O', async () => { + const harness = sharedHarness(); + const http = { get: jest.fn(async () => { + expect(harness.isRetained()).toBe(false); + expect(harness.events).toEqual([ + 'lock', ['reserve', 'owner-token'], 'unlock', 'release', ['renew', 'owner-token'] + ]); + return { data: providerData }; + }) }; + const service = new Nominatim({ db: { pool: harness.pool }, http, tokenFactory: () => 'owner-token' }); + + await expect(service.search('Aarhus')).resolves.toEqual([ + { formattedAddress: 'Aarhus', lat: 56, lng: 10, providerPlaceId: '7', provider: 'openstreetmap' } + ]); + expect(harness.connection.release).toHaveBeenCalledTimes(1); + expect(harness.events).toEqual([ + 'lock', ['reserve', 'owner-token'], 'unlock', 'release', ['renew', 'owner-token'], + ['complete', 'owner-token'], 'cache-write' + ]); +}); + +test('fails closed before provider I/O when delayed renewal has lost token ownership', async () => { + const harness = sharedHarness({ renewalAffected: 0 }); + const http = { get: jest.fn() }; + const service = new Nominatim({ db: { pool: harness.pool }, http, tokenFactory: () => 'stale-token' }); + + await expect(service.search('Aarhus')).rejects.toMatchObject({ status: 429, code: 'GEOCODE_RATE_LIMIT' }); + expect(http.get).not.toHaveBeenCalled(); + expect(harness.pool.execute.mock.calls[0][0]).toContain('reservation_token = ?'); + expect(harness.pool.execute.mock.calls[0][0]).toContain('reservation_expires_at > NOW(6)'); +}); + +test('completion is token-scoped and advances DB time by one second after provider returns', async () => { + const harness = sharedHarness(); + const http = { get: jest.fn(async () => { + expect(harness.events.some(event => Array.isArray(event) && event[0] === 'complete')).toBe(false); + return { data: providerData }; + }) }; + const service = new Nominatim({ db: { pool: harness.pool }, http, tokenFactory: () => 'only-owner' }); + await service.search('Aarhus'); + + const completion = harness.pool.execute.mock.calls.find(([sql]) => sql.includes('SET next_request_at')); + expect(completion[0]).toContain('DATE_ADD(NOW(6), INTERVAL 1 SECOND)'); + expect(completion[0]).toContain('WHERE id = 1 AND reservation_token = ?'); + expect(completion[1]).toEqual(['only-owner']); +}); + +test('does not invoke provider if lock release fails', async () => { + const harness = sharedHarness(); + harness.connection.execute.mockImplementation(async (sql, params) => { + if (sql.includes('GET_LOCK')) return [[{ acquired: 1 }]]; + if (sql.includes('nominatim_cache') && sql.includes('SELECT')) return [[]]; + if (sql.includes('INSERT IGNORE INTO nominatim_rate_gate')) return [{ affectedRows: 0 }]; + if (sql.includes('SET reservation_token')) return [{ affectedRows: 1 }]; + if (sql.includes('RELEASE_LOCK')) throw new Error('connection failed during release'); + throw new Error(`Unexpected SQL: ${sql} ${params}`); + }); + const http = { get: jest.fn() }; + const service = new Nominatim({ db: { pool: harness.pool }, http, tokenFactory: () => 'owner' }); + + await expect(service.search('Aarhus')).rejects.toMatchObject({ status: 502 }); + expect(harness.connection.release).toHaveBeenCalledTimes(1); + expect(http.get).not.toHaveBeenCalled(); + expect(harness.pool.execute).not.toHaveBeenCalled(); +}); + +test('cache write errors do not discard a valid provider result', async () => { + const harness = sharedHarness({ cacheWriteError: new Error('cache unavailable') }); + const http = { get: jest.fn().mockResolvedValue({ data: providerData }) }; + const service = new Nominatim({ db: { pool: harness.pool }, http, tokenFactory: () => 'owner' }); + + await expect(service.search('Aarhus')).resolves.toHaveLength(1); + expect(http.get).toHaveBeenCalledTimes(1); +}); diff --git a/backend/__tests__/offersRoute.test.js b/backend/__tests__/offersRoute.test.js index 4ee13ba..dfd8dfc 100644 --- a/backend/__tests__/offersRoute.test.js +++ b/backend/__tests__/offersRoute.test.js @@ -1,119 +1,1000 @@ -jest.mock('../src/services/graphqlClient', () => ({ - request: jest.fn() -})); +process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'offers-test-access-secret'; +process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'offers-test-refresh-secret'; +process.env.AUTH_USERNAME = 'configured-operator'; + +jest.mock('../src/services/graphqlClient', () => ({ request: jest.fn() })); const express = require('express'); const request = require('supertest'); +const jwt = require('jsonwebtoken'); const graphqlClient = require('../src/services/graphqlClient'); -const offersRouter = require('../routes/offers'); +const userService = require('../src/services/userService'); +const { createOffersRouter } = require('../routes/offers'); +const findByUsername = jest.spyOn(userService, 'findByUsername'); -describe('Offers route', () => { - let app; +beforeEach(() => { + findByUsername.mockReset().mockResolvedValue({ + id: 1, + username: process.env.AUTH_USERNAME, + role: 'admin' + }); +}); + +const authHeader = (username = process.env.AUTH_USERNAME) => ( + `Bearer ${jwt.sign({ id: 1, username }, process.env.JWT_ACCESS_SECRET)}` +); + +const canonicalSnapshot = (overrides = {}) => { + const snapshot = { + signature: 'sig-1', + artifact: { + customerProject: { + id: 7, + project_name: 'Tagrenovering', + project_description: 'Udskiftning af tag', + customer_number: 'C100', + customer_name: 'Mikael Holck', + customer_email: 'mail@example.dk', + customer_phone: '42468110', + customer_address: 'Hornumvej 7, 4600 Køge' + }, + lines: { + materials: [{ name: 'B7 plader', quantity: 0, unit: 'plade', unitPrice: 100, lineTotal: 0 }], + tasks: [{ description: 'Montering', totalHours: 2, timeUnit: 'timer', rate: 500, lineTotal: 1000 }], + rentals: [{ name: 'Stillads', quantity: 1, unit: 'uge', unitPrice: 200, lineTotal: 200 }], + references: [{ name: 'Affald', quantity: 1, unit: 'læs', unitPrice: 100, lineTotal: 100 }] + }, + economics: { + materialTotal: 0, + laborTotal: 1000, + rentalTotal: 200, + referenceTotal: 100, + subtotal: 1300, + overheadAmount: 195, + profitAmount: 299, + totalExclVat: 1794, + vatAmount: 448.5, + totalInclVat: 2242.5 + }, + quoteText: 'Kanonisk tilbudstekst' + }, + readiness: { ready: true }, + approval: { approved: true } + }; + return { ...snapshot, ...overrides }; +}; + +const createAtomicOperationStore = (persisted = new Map()) => ({ + persisted, + renew: jest.fn(async () => {}), + claim: jest.fn(async (key, initialState, { ownerId }) => { + const existing = persisted.get(key); + if (!existing) { + const claimed = { ...initialState, leaseOwner: ownerId }; + persisted.set(key, claimed); + return { acquired: true, state: claimed }; + } + if (!existing.leaseOwner && existing.status !== 'completed') { + const claimed = { ...existing, leaseOwner: ownerId }; + persisted.set(key, claimed); + return { acquired: true, state: claimed }; + } + return { acquired: false, state: existing }; + }), + get: jest.fn(async key => persisted.get(key)), + set: jest.fn(async (key, state, { ownerId } = {}) => { + const existing = persisted.get(key); + if (ownerId && existing?.leaseOwner !== ownerId) { + const error = new Error('operation lease lost'); + error.code = 'ORDRESTYRING_OPERATION_LEASE_LOST'; + throw error; + } + const terminal = ['completed', 'failed', 'compensated'].includes(state.status); + const saved = { ...state, leaseOwner: terminal ? null : (ownerId || existing?.leaseOwner) }; + persisted.set(key, saved); + return saved; + }) +}); + +let quoteRealismService; + +const buildApp = (roofQuoteSnapshotService, options = {}) => { + const app = express(); + app.use(express.json()); + app.use('/api/ordrestyring/offers', createOffersRouter({ + graphqlClient, + roofQuoteSnapshotService, + quoteRealismService: options.quoteRealismService || quoteRealismService, + operationStateStore: options.operationStateStore || createAtomicOperationStore(), + operationWaitTimeoutMs: options.operationWaitTimeoutMs || 500, + operationPollIntervalMs: options.operationPollIntervalMs || 2, + ...options + })); + return app; +}; + +const successfulGraphql = () => { + graphqlClient.request + .mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } }) + .mockImplementationOnce((query, variables) => ({ + createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line })) + })); +}; + +describe('Offers route canonical boundary', () => { + let roofQuoteSnapshotService; beforeEach(() => { - app = express(); - app.use(express.json()); - app.use('/api/ordrestyring/offers', offersRouter); jest.clearAllMocks(); + graphqlClient.request.mockReset(); + delete graphqlClient.deleteOffer; + delete graphqlClient.getOfferLines; + delete graphqlClient.getOfferTotals; + delete graphqlClient.verifyPersistedLines; + quoteRealismService = { + requireApprovedAnalysis: jest.fn().mockResolvedValue({ approved: true, readyForFixedPrice: true }) + }; + roofQuoteSnapshotService = { + buildFromProject: jest.fn().mockResolvedValue(canonicalSnapshot()), + assertExpectedSignature: jest.fn().mockResolvedValue(undefined), + markSent: jest.fn() + }; }); - test('maps FinalReview payload into customer, offer, and line mutations', async () => { - graphqlClient.request - .mockResolvedValueOnce({ - createCustomer: { - id: 42, - name: 'Mikael Holck', - email: 'mail@example.dk' - } - }) - .mockResolvedValueOnce({ - createOffer: { - id: 88, - number: 'T-1001' - } - }) - .mockResolvedValueOnce({ - createOfferLines: [ - { id: 1 }, - { id: 2 } - ] - }); - - const payload = { - project: { - id: 7, - name: 'Tagrenovering', - customer: 'Mikael Holck', - customerNumber: 'C100', - customerEmail: 'mail@example.dk', - customerPhone: '42468110', - customerAddress: 'Hornumvej 7, 4600 Køge', - description: 'Renovering af tag' - }, - geometry: { - roofArea: 132 - }, - package: { - materials: [ - { - name: 'B7 plader', - quantity: 10, - unitPrice: 100, - varenr: 'B7-1' - } - ], - laborTasks: [ - { - name: 'Montage', - description: 'Montering af plader', - totalHours: 4, - rate: 580 - } - ], - totals: { - total: 3320 - } - }, - quote: { - validUntil: '2026-04-30T00:00:00.000Z' - } - }; + test('default router construction cannot reach the Ordrestyring transport', async () => { + graphqlClient.request.mockResolvedValue({ createCustomer: { id: 42 } }); + graphqlClient.deleteOffer = jest.fn().mockResolvedValue({ id: 88, deleted: true }); + graphqlClient.updateOffer = jest.fn().mockResolvedValue({ id: 88 }); + const app = express(); + app.use(express.json()); + app.use('/api/ordrestyring/offers', createOffersRouter({ + roofQuoteSnapshotService, + quoteRealismService, + operationStateStore: createAtomicOperationStore() + })); const response = await request(app) .post('/api/ordrestyring/offers/create') - .send(payload); + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(503); + expect(response.body.code).toBe('ORDRESTYRING_TRANSPORT_NOT_INJECTED'); + expect(graphqlClient.request).not.toHaveBeenCalled(); + expect(graphqlClient.updateOffer).not.toHaveBeenCalled(); + expect(graphqlClient.deleteOffer).not.toHaveBeenCalled(); + }); + + test('rejects an unauthenticated request before loading a snapshot', async () => { + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(401); + expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled(); + }); + + test('rejects an authenticated non-operator before loading a snapshot', async () => { + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader('another-user')) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(403); + expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled(); + }); + + test.each([ + ['deleted', null, 403], + ['demoted', { id: 1, username: process.env.AUTH_USERNAME, role: 'user' }, 403] + ])('rejects a configured-username JWT for a %s live account', async (_label, current, status) => { + userService.findByUsername.mockResolvedValue(current); + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(response.status).toBe(status); + expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled(); + }); + + test('fails closed before loading a quote when live account lookup is unavailable', async () => { + userService.findByUsername.mockRejectedValue(new Error('database unavailable')); + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(response.status).toBe(503); + expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled(); + }); + + test('accepts only projectId and expectedSnapshotSignature for a canonical submission', async () => { + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1', totals: { total: 1 } }); + + expect(response.status).toBe(400); + expect(roofQuoteSnapshotService.buildFromProject).not.toHaveBeenCalled(); + }); + + test('rejects a stale expected signature before any Ordrestyring mutation', async () => { + const stale = Object.assign(new Error('Snapshot signature mismatch'), { + status: 409, + code: 'SNAPSHOT_SIGNATURE_MISMATCH' + }); + roofQuoteSnapshotService.assertExpectedSignature.mockRejectedValue(stale); + + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'stale' }); + + expect(response.status).toBe(409); + expect(response.body.code).toBe('SNAPSHOT_SIGNATURE_MISMATCH'); + expect(roofQuoteSnapshotService.buildFromProject).toHaveBeenCalledWith(7); + expect(roofQuoteSnapshotService.assertExpectedSignature) + .toHaveBeenCalledWith(expect.any(Object), 'stale'); + expect(graphqlClient.request).not.toHaveBeenCalled(); + }); + + test.each([ + ['unready', { readiness: { ready: false } }, 'SNAPSHOT_NOT_READY'], + ['unapproved', { approval: { approved: false } }, 'SNAPSHOT_NOT_APPROVED'] + ])('rejects an %s server snapshot', async (_label, patch, code) => { + roofQuoteSnapshotService.buildFromProject.mockResolvedValue(canonicalSnapshot(patch)); + + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(422); + expect(response.body.code).toBe(code); + expect(graphqlClient.request).not.toHaveBeenCalled(); + }); + + test('rejects changed realism immediately before claiming idempotency or mutating Ordrestyring', async () => { + const stale = Object.assign(new Error('Realismegodkendelsen er ændret'), { + status: 409, + code: 'REALISM_APPROVAL_STALE' + }); + quoteRealismService.requireApprovedAnalysis.mockRejectedValue(stale); + const operationStateStore = createAtomicOperationStore(); + + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(409); + expect(response.body.code).toBe('REALISM_APPROVAL_STALE'); + expect(quoteRealismService.requireApprovedAnalysis).toHaveBeenCalledWith(7, 'sig-1'); + expect(operationStateStore.claim).not.toHaveBeenCalled(); + expect(graphqlClient.request).not.toHaveBeenCalled(); + }); + + test('includes every signed reservation in the Ordrestyring offer remark', async () => { + const snapshot = canonicalSnapshot(); + snapshot.artifact.reservations = [ + { id: 'weather', text: 'Arbejdet forudsætter tørvejr.' }, + 'Skjulte rådskader er ikke inkluderet.' + ]; + roofQuoteSnapshotService.buildFromProject.mockResolvedValue(snapshot); + successfulGraphql(); + + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); expect(response.status).toBe(200); - expect(response.body).toMatchObject({ - success: true, - offerNumber: 'T-1001', - offerId: 88, - customerId: 42, - lineCount: 2 - }); + const offerInput = graphqlClient.request.mock.calls[1][1].input; + expect(offerInput.remark).toContain('Kanonisk tilbudstekst'); + expect(offerInput.remark).toContain('Arbejdet forudsætter tørvejr.'); + expect(offerInput.remark).toContain('Skjulte rådskader er ikke inkluderet.'); + expect(offerInput.remark).not.toContain('[object Object]'); + }); + test('uses only the recomputed snapshot, preserves zero quantities and units, and sends all categories exactly', async () => { + successfulGraphql(); + + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ success: true, offerId: 88, lineCount: 6 }); + + const inputs = graphqlClient.request.mock.calls[2][1].inputs; + expect(inputs.map(line => line.productNumber)).toEqual([ + 'MATERIAL', 'LABOR', 'RENTAL', 'REFERENCE_SERVICE', 'OVERHEAD', 'PROFIT' + ]); + expect(inputs.map(line => line.unit)).toEqual(['plade', 'timer', 'uge', 'læs', 'sum', 'sum']); + expect(inputs[0].quantity).toBe(0); + expect(inputs.reduce((sum, line) => sum + Math.round(line.quantity * line.salesPrice * 100), 0)) + .toBe(179400); + expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled(); + }); + + test('rejects sub-cent price drift that changes extended line economics', async () => { + const snapshot = canonicalSnapshot(); + snapshot.artifact.lines.tasks[0] = { description: 'Montering', totalHours: 100000, rate: 0.01, lineTotal: 1000 }; + roofQuoteSnapshotService.buildFromProject.mockResolvedValue(snapshot); + graphqlClient.request.mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88 } }) + .mockImplementationOnce((query, { inputs }) => ({ createOfferLines: inputs.map((line, i) => ({ + ...line, id: i + 1, salesPrice: line.salesPrice + (i === 1 ? 0.000001 : 0) + })) })); + const response = await request(buildApp(roofQuoteSnapshotService)).post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(response.body.code).toBe('LINE_RECONCILIATION_FAILED'); + }); + + test('rejects remote aggregate VAT totals that disagree with the approved snapshot', async () => { + successfulGraphql(); + graphqlClient.getOfferTotals = jest.fn().mockResolvedValue({ salesPrice: 1794, salesPriceWithVat: 2803.13 }); + const response = await request(buildApp(roofQuoteSnapshotService)).post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(response.body.code).toBe('OFFER_TOTAL_RECONCILIATION_FAILED'); + }); + + test.each([[false, 'customer'], [true, 'customer'], [false, 'offer'], [true, 'offer'], [false, 'lines'], [true, 'lines']])( + 'renews throughout a slow remote mutation and stops on lease loss=%s during %s', async (lost, phase) => { + const store = createAtomicOperationStore(); + let slowMutation = false; + store.renew = jest.fn(async () => { + if (lost && slowMutation) throw Object.assign(new Error('lost'), { + code: 'ORDRESTYRING_OPERATION_LEASE_LOST', status: 503 + }); + }); + graphqlClient.request.mockImplementation(async (query, variables) => { + const currentPhase = query.includes('CreateCustomer') ? 'customer' : query.includes('mutation CreateOffer(') ? 'offer' : 'lines'; + if (currentPhase === phase) { + slowMutation = true; + await new Promise(resolve => setTimeout(resolve, 65)); + slowMutation = false; + } + if (query.includes('CreateCustomer')) { + return { createCustomer: { id: 42 } }; + } + if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88 } }; + return { createOfferLines: variables.inputs.map((line, i) => ({ ...line, id: i + 1 })) }; + }); + graphqlClient.deleteOffer = jest.fn(); + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore: store, operationLeaseMs: 30 })) + .post('/api/ordrestyring/offers/create').set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(store.renew.mock.calls.length).toBeGreaterThan(1); + expect(response.status).toBe(lost ? 503 : 200); + if (lost) { + expect(graphqlClient.request).toHaveBeenCalledTimes({ customer: 1, offer: 2, lines: 3 }[phase]); + expect(graphqlClient.deleteOffer).not.toHaveBeenCalled(); + expect(store.persisted.get('7:sig-1').status).toBe(`${phase}_creating`); + } + }); + + test('rejects irreconcilable VAT before creating a remote customer or offer', async () => { + const snapshot = canonicalSnapshot(); + snapshot.artifact.economics.vatAmount = 0; + snapshot.artifact.economics.totalInclVat = snapshot.artifact.economics.totalExclVat; + roofQuoteSnapshotService.buildFromProject.mockResolvedValue(snapshot); + successfulGraphql(); + const response = await request(buildApp(roofQuoteSnapshotService)).post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(response.status).toBe(422); + expect(graphqlClient.request).not.toHaveBeenCalled(); + }); + + test('never resumes an offer after a deletion timeout', async () => { + const store = createAtomicOperationStore(); + graphqlClient.request.mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88 } }).mockResolvedValueOnce({ createOfferLines: [] }); + graphqlClient.deleteOffer = jest.fn().mockRejectedValue(new Error('timeout')); + const app = buildApp(roofQuoteSnapshotService, { operationStateStore: store }); + const submit = () => request(app).post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + await submit(); + const calls = graphqlClient.request.mock.calls.length; + const response = await submit(); + expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STATE_UNCERTAIN'); + expect(graphqlClient.request).toHaveBeenCalledTimes(calls); + expect(graphqlClient.deleteOffer).toHaveBeenCalledTimes(1); + }); + + test('checks the exposed VAT aggregate even when net and gross match', async () => { + successfulGraphql(); + graphqlClient.getOfferTotals = jest.fn().mockResolvedValue({ salesPrice: 1794, salesPriceWithVat: 2242.5, vat: 0 }); + const response = await request(buildApp(roofQuoteSnapshotService)).post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(response.body.code).toBe('OFFER_TOTAL_RECONCILIATION_FAILED'); + }); + + test.each(['customer_creating', 'offer_creating', 'compensation_pending'])( + 'retains unknown state across repeated attempts from %s', async status => { + const store = createAtomicOperationStore(new Map([['7:sig-1', { + status, projectId: 7, snapshotSignature: 'sig-1', createdLines: [], leaseOwner: null + }]])); + const app = buildApp(roofQuoteSnapshotService, { operationStateStore: store }); + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await request(app).post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()).send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STATE_UNCERTAIN'); + } + expect(graphqlClient.request).not.toHaveBeenCalled(); + } + ); + + test('refreshes persisted lines before recovery when the client supports authoritative readback', async () => { + const { buildOrdrestyringOfferLines } = require('../src/services/ordrestyringOfferNormalizationService'); + const lines = buildOrdrestyringOfferLines(canonicalSnapshot(), { offerId: 88 }).map((line, i) => ({ ...line, id: i + 1 })); + const store = createAtomicOperationStore(new Map([['7:sig-1', { + status: 'failed', customerId: 42, offer: { id: 88 }, offerId: 88, createdLines: lines, leaseOwner: null + }]])); + graphqlClient.verifyPersistedLines = true; + graphqlClient.getOfferLines = jest.fn().mockResolvedValue([...lines, { ...lines[0], id: 99 }]); + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore: store })) + .post('/api/ordrestyring/offers/create').set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(response.body.code).toBe('LINE_RECONCILIATION_FAILED'); + expect(graphqlClient.request).not.toHaveBeenCalled(); + }); + + test('reuses the completed project-and-snapshot operation for a duplicate request', async () => { + successfulGraphql(); + const app = buildApp(roofQuoteSnapshotService); + const payload = { projectId: 7, expectedSnapshotSignature: 'sig-1' }; + + const first = await request(app) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send(payload); + const duplicate = await request(app) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send(payload); + + expect(first.status).toBe(200); + expect(duplicate.status).toBe(200); + expect(duplicate.body).toMatchObject({ success: true, offerId: 88, idempotentReplay: true }); expect(graphqlClient.request).toHaveBeenCalledTimes(3); + }); - const createOfferCall = graphqlClient.request.mock.calls[1]; - expect(createOfferCall[1].input).toMatchObject({ + test('coalesces concurrent duplicate requests before creating customer or offer twice', async () => { + graphqlClient.request.mockImplementation(async (query, variables) => { + if (query.includes('mutation CreateCustomer')) { + await new Promise(resolve => setTimeout(resolve, 20)); + return { createCustomer: { id: 42 } }; + } + if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88, number: 'T-1001' } }; + return { createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line })) }; + }); + const app = buildApp(roofQuoteSnapshotService); + const submit = () => request(app) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + const [first, duplicate] = await Promise.all([submit(), submit()]); + + expect(first.status).toBe(200); + expect(duplicate.status).toBe(200); + expect([first.body.offerId, duplicate.body.offerId]).toEqual([88, 88]); + expect(graphqlClient.request).toHaveBeenCalledTimes(3); + }); + + test('reuses completed state persisted by another router instance', async () => { + successfulGraphql(); + const persisted = new Map(); + const operationStateStore = createAtomicOperationStore(persisted); + const payload = { projectId: 7, expectedSnapshotSignature: 'sig-1' }; + + const first = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send(payload); + const duplicate = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send(payload); + + expect(first.status).toBe(200); + expect(duplicate.body).toMatchObject({ success: true, offerId: 88, idempotentReplay: true }); + expect(operationStateStore.set).toHaveBeenCalledWith( + '7:sig-1', expect.objectContaining({ status: 'completed' }), expect.objectContaining({ ownerId: expect.any(String) }) + ); + expect(graphqlClient.request).toHaveBeenCalledTimes(3); + }); + + test('reuses customer_id from the canonical snapshot without creating a duplicate customer', async () => { + const snapshot = canonicalSnapshot(); + snapshot.artifact.customerProject.customer_id = 77; + roofQuoteSnapshotService.buildFromProject.mockResolvedValue(snapshot); + graphqlClient.request + .mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } }) + .mockImplementationOnce((_query, variables) => ({ + createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line })) + })); + + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(200); + expect(response.body.customerId).toBe(77); + expect(graphqlClient.request).toHaveBeenCalledTimes(2); + expect(graphqlClient.request.mock.calls[0][0]).toContain('mutation CreateOffer('); + expect(graphqlClient.request.mock.calls[0][1].input.customerId).toBe(77); + }); + + test.each([ + [ + 'customer', + () => graphqlClient.request.mockResolvedValueOnce({ createCustomer: null }), + 'CUSTOMER_CREATION_FAILED' + ], + [ + 'offer', + () => graphqlClient.request + .mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: null }), + 'OFFER_CREATION_FAILED' + ] + ])('returns failure when %s creation does not return a created record', async (_kind, arrange, code) => { + arrange(); + + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(502); + expect(response.body).toMatchObject({ success: false, code }); + expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled(); + }); + + test('returns failure when line creation fails and never marks the project sent', async () => { + graphqlClient.request + .mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } }) + .mockRejectedValueOnce(new Error('line write failed')); + + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(502); + expect(response.body.success).toBe(false); + expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled(); + }); + + test('returns failure when Ordrestyring reports only a partial line creation', async () => { + graphqlClient.request + .mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } }) + .mockResolvedValueOnce({ createOfferLines: [{ id: 1 }] }); + + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(502); + expect(response.body.code).toBe('PARTIAL_LINE_CREATION'); + expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled(); + }); + + test('fails exact reconciliation when Ordrestyring returns a changed line', async () => { + const persisted = new Map(); + const operationStateStore = createAtomicOperationStore(persisted); + graphqlClient.request + .mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } }) + .mockImplementationOnce((_query, variables) => ({ + createOfferLines: variables.inputs.map((line, index) => ({ + id: index + 1, + ...line, + ...(index === 0 ? { salesPrice: line.salesPrice + 0.01 } : {}) + })) + })); + + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(502); + expect(response.body).toMatchObject({ success: false, code: 'LINE_RECONCILIATION_FAILED' }); + expect(persisted.get('7:sig-1')).toMatchObject({ status: 'failed', offerId: 88, customerId: 42 }); + expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled(); + }); + + test.each([ + ['unit', line => `${line.unit}-ændret`], + ['discount', line => line.discount + 1], + ['taskId', line => line.taskId + 1], + ['sortOrder', line => line.sortOrder + 1] + ])('fails exact reconciliation when Ordrestyring alters line %s', async (field, alteredValue) => { + graphqlClient.request + .mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } }) + .mockImplementationOnce((_query, variables) => ({ + createOfferLines: variables.inputs.map((line, index) => ({ + id: index + 1, + ...line, + ...(index === 0 ? { [field]: alteredValue(line) } : {}) + })) + })); + + const response = await request(buildApp(roofQuoteSnapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(502); + expect(response.body.code).toBe('LINE_RECONCILIATION_FAILED'); + }); + + test('performs and verifies compensating offer deletion after partial line creation', async () => { + const persisted = new Map(); + const operationStateStore = createAtomicOperationStore(persisted); + graphqlClient.request + .mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } }) + .mockImplementationOnce((_query, variables) => ({ + createOfferLines: [{ id: 501, ...variables.inputs[0] }] + })); + graphqlClient.deleteOffer = jest.fn().mockResolvedValue({ id: 88, deleted: true }); + + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(502); + expect(response.body).toMatchObject({ success: false, code: 'PARTIAL_LINE_CREATION' }); + expect(graphqlClient.deleteOffer).toHaveBeenCalledWith(88); + expect(persisted.get('7:sig-1')).toMatchObject({ + status: 'compensated', customerId: 42, - description: 'Tagrenovering', - reference: 'TG-C100-7' + compensatedOfferId: 88 + }); + expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled(); + }); + + test('resumes a known partial operation on the same offer and creates only missing lines', async () => { + const persisted = new Map(); + const operationStateStore = createAtomicOperationStore(persisted); + let lineAttempt = 0; + graphqlClient.request.mockImplementation((query, variables) => { + if (query.includes('mutation CreateCustomer')) return { createCustomer: { id: 42 } }; + if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88, number: 'T-1001' } }; + lineAttempt += 1; + if (lineAttempt === 1) { + return { createOfferLines: variables.inputs.slice(0, 2).map((line, index) => ({ id: 500 + index, ...line })) }; + } + return { createOfferLines: variables.inputs.map((line, index) => ({ id: 600 + index, ...line })) }; + }); + const app = buildApp(roofQuoteSnapshotService, { operationStateStore }); + const submit = () => request(app) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + const failed = await submit(); + const resumed = await submit(); + + expect(failed.status).toBe(502); + expect(resumed.status).toBe(200); + expect(resumed.body).toMatchObject({ success: true, offerId: 88, lineCount: 6 }); + expect(graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateCustomer'))).toHaveLength(1); + expect(graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateOffer('))).toHaveLength(1); + const lineCalls = graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateOfferLines')); + expect(lineCalls.map(([, variables]) => variables.inputs.length)).toEqual([6, 4]); + expect(persisted.get('7:sig-1')).toMatchObject({ status: 'completed' }); + }); + + test('reconciles uncertain remote lines before resuming a failed write on the same offer', async () => { + const persisted = new Map(); + const operationStateStore = createAtomicOperationStore(persisted); + let lineAttempt = 0; + let firstDesiredLines; + graphqlClient.request.mockImplementation((query, variables) => { + if (query.includes('mutation CreateCustomer')) return { createCustomer: { id: 42 } }; + if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88, number: 'T-1001' } }; + lineAttempt += 1; + if (lineAttempt === 1) { + firstDesiredLines = variables.inputs; + throw new Error('connection lost after remote write'); + } + return { createOfferLines: variables.inputs.map((line, index) => ({ id: 700 + index, ...line })) }; + }); + graphqlClient.getOfferLines = jest.fn(() => ( + firstDesiredLines.slice(0, 2).map((line, index) => ({ id: 650 + index, ...line })) + )); + const app = buildApp(roofQuoteSnapshotService, { operationStateStore }); + const submit = () => request(app) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + const failed = await submit(); + const resumed = await submit(); + + expect(failed.status).toBe(502); + expect(persisted.get('7:sig-1')).toMatchObject({ status: 'completed', offerId: 88, customerId: 42 }); + expect(resumed.status).toBe(200); + expect(graphqlClient.getOfferLines).toHaveBeenCalledWith(88); + const offerMutations = graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateOffer(')); + const customerMutations = graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateCustomer')); + expect(offerMutations).toHaveLength(1); + expect(customerMutations).toHaveLength(1); + }); + + test('keeps remote ids when compensating deletion cannot be verified', async () => { + const persisted = new Map(); + const operationStateStore = createAtomicOperationStore(persisted); + graphqlClient.request + .mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } }) + .mockResolvedValueOnce({ createOfferLines: [] }); + graphqlClient.deleteOffer = jest.fn().mockResolvedValue({ id: 999, deleted: true }); + + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(502); + expect(response.body.success).toBe(false); + expect(persisted.get('7:sig-1')).toMatchObject({ + status: 'failed', customerId: 42, offerId: 88, + offer: { id: 88, number: 'T-1001' } + }); + expect(roofQuoteSnapshotService.markSent).not.toHaveBeenCalled(); + }); + + test('atomically coalesces concurrent requests across independent router instances', async () => { + const persisted = new Map(); + const operationStateStore = createAtomicOperationStore(persisted); + graphqlClient.request.mockImplementation(async (query, variables) => { + if (query.includes('mutation CreateCustomer')) { + await new Promise(resolve => setTimeout(resolve, 30)); + return { createCustomer: { id: 42 } }; + } + if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88, number: 'T-1001' } }; + return { createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line })) }; + }); + const options = { operationStateStore, operationWaitTimeoutMs: 1000 }; + const apps = [buildApp(roofQuoteSnapshotService, options), buildApp(roofQuoteSnapshotService, options)]; + const submit = app => request(app) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + const responses = await Promise.all(apps.map(submit)); + + expect(responses.map(response => response.status)).toEqual([200, 200]); + expect(responses.map(response => response.body.offerId)).toEqual([88, 88]); + expect(responses.filter(response => response.body.idempotentReplay)).toHaveLength(1); + expect(graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateCustomer'))).toHaveLength(1); + expect(graphqlClient.request.mock.calls.filter(([query]) => query.includes('mutation CreateOffer('))).toHaveLength(1); + }); + + test('fails closed before remote mutation when the operation store is unavailable', async () => { + const unavailable = Object.assign(new Error('database unavailable'), { code: 'ECONNREFUSED' }); + const operationStateStore = { + claim: jest.fn().mockRejectedValue(unavailable), + get: jest.fn(), + set: jest.fn(), + renew: jest.fn() + }; + + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(503); + expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STORE_UNAVAILABLE'); + expect(graphqlClient.request).not.toHaveBeenCalled(); + }); + + test.each(['customer_creating', 'offer_creating'])( + 'does not repeat a remote mutation after recovering the %s crash window', + async status => { + const persisted = new Map([['7:sig-1', { + idempotencyKey: '7:sig-1', projectId: 7, snapshotSignature: 'sig-1', status, + ...(status === 'offer_creating' ? { customerId: 42 } : {}), + leaseOwner: null + }]]); + const operationStateStore = createAtomicOperationStore(persisted); + + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(503); + expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STATE_UNCERTAIN'); + expect(graphqlClient.request).not.toHaveBeenCalled(); + expect(persisted.get('7:sig-1')).toMatchObject({ status: 'failed' }); + } + ); + + test('does not mutate a remote offer after a crash during compensation', async () => { + const persisted = new Map([['7:sig-1', { + idempotencyKey: '7:sig-1', projectId: 7, snapshotSignature: 'sig-1', + status: 'compensation_pending', customerId: 42, offerId: 88, + offer: { id: 88, number: 'T-1001' }, leaseOwner: null + }]]); + const operationStateStore = createAtomicOperationStore(persisted); + + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(503); + expect(response.body.code).toBe('ORDRESTYRING_OPERATION_STATE_UNCERTAIN'); + expect(graphqlClient.request).not.toHaveBeenCalled(); + expect(graphqlClient.deleteOffer).toBeUndefined(); + expect(persisted.get('7:sig-1')).toMatchObject({ status: 'failed', offerId: 88 }); + }); + + test('persists pending states before every remote mutation', async () => { + const events = []; + const operationStateStore = createAtomicOperationStore(); + const originalSet = operationStateStore.set; + operationStateStore.set = jest.fn(async (...args) => { + events.push(`state:${args[1].status}`); + return originalSet(...args); + }); + graphqlClient.request.mockImplementation((query, variables) => { + if (query.includes('mutation CreateCustomer')) { + events.push('remote:customer'); + return { createCustomer: { id: 42 } }; + } + if (query.includes('mutation CreateOffer(')) { + events.push('remote:offer'); + return { createOffer: { id: 88, number: 'T-1001' } }; + } + events.push('remote:lines'); + return { createOfferLines: variables.inputs.map((line, index) => ({ id: index + 1, ...line })) }; }); - const createLinesCall = graphqlClient.request.mock.calls[2]; - expect(createLinesCall[1].inputs).toHaveLength(2); - expect(createLinesCall[1].inputs[0]).toMatchObject({ - offerId: 88, - description: 'B7 plader', - quantity: 10, - salesPrice: 100 - }); - expect(createLinesCall[1].inputs[1]).toMatchObject({ - offerId: 88, - description: 'Montering af plader', - quantity: 4, - salesPrice: 580 + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(200); + expect(events.indexOf('state:customer_creating')).toBeLessThan(events.indexOf('remote:customer')); + expect(events.indexOf('state:offer_creating')).toBeLessThan(events.indexOf('remote:offer')); + expect(events.indexOf('state:lines_creating')).toBeLessThan(events.indexOf('remote:lines')); + }); + + test('compensates a remotely created offer when persisting its id fails', async () => { + const persisted = new Map(); + const operationStateStore = createAtomicOperationStore(persisted); + const originalSet = operationStateStore.set; + let failedOnce = false; + operationStateStore.set = jest.fn(async (key, state, options) => { + if (state.status === 'offer_ready' && !failedOnce) { + failedOnce = true; + throw Object.assign(new Error('write failed'), { code: 'ECONNRESET' }); + } + return originalSet(key, state, options); }); + graphqlClient.request + .mockResolvedValueOnce({ createCustomer: { id: 42 } }) + .mockResolvedValueOnce({ createOffer: { id: 88, number: 'T-1001' } }); + graphqlClient.deleteOffer = jest.fn().mockResolvedValue({ id: 88, deleted: true }); + + const response = await request(buildApp(roofQuoteSnapshotService, { operationStateStore })) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + + expect(response.status).toBe(502); + expect(graphqlClient.deleteOffer).toHaveBeenCalledWith(88); + expect(persisted.get('7:sig-1')).toMatchObject({ status: 'compensated', compensatedOfferId: 88 }); + }); +}); + +describe('Offers route legacy boundary', () => { + beforeEach(() => jest.clearAllMocks()); + + test.each([ + ['implicit', { project: { id: 1 }, package: { materials: [] } }], + ['caller-classified', { + submissionType: 'legacy_non_roof', + project: { id: 1, name: 'Carport', projectType: 'carport', customer: 'Kunde' }, + package: { materials: [], laborTasks: [], totals: { total: 0 } } + }] + ])('rejects an %s arbitrary legacy payload', async (_label, payload) => { + const snapshotService = { buildFromProject: jest.fn() }; + const response = await request(buildApp(snapshotService)) + .post('/api/ordrestyring/offers/create') + .set('Authorization', authHeader()) + .send(payload); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('INVALID_CANONICAL_PAYLOAD'); + expect(snapshotService.buildFromProject).not.toHaveBeenCalled(); + expect(graphqlClient.request).not.toHaveBeenCalled(); + }); +}); + +describe('production offer recovery transport', () => { + const productionClient = transport => require('../src/graphql/mutations/createOffer').createProductionOfferClient(transport); + test('reads every task and page, converts Money cents, and retains units from remote descriptions', async () => { + const raw = { id: 1, offer: { id: 88 }, description: 'Arbejde [timer]', quantity: 2, salesPrice: 50000, + discount: 0, productNumber: 'LABOR', sortOrder: 1 }; + const transport = { request: jest.fn().mockResolvedValueOnce({ offer: { id: 88, tasks: [{ id: 91, number: 1 }] } }) + .mockResolvedValueOnce({ offerLines: { items: [raw], hasMorePages: true, nextCursor: 'next', total: 2 } }) + .mockResolvedValueOnce({ offerLines: { items: [{ ...raw, id: 2 }], hasMorePages: false, total: 2 } }) }; + const result = await productionClient(transport).getOfferLines(88); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ taskId: 1, unit: 'timer', description: 'Arbejde', salesPrice: 500 }); + expect(transport.request.mock.calls[2][1]).toMatchObject({ taskId: 91, cursor: 'next' }); + }); + test.each([null, { id: 88 }, undefined])('verifies deletion by a fresh offer read: %j', async remaining => { + const transport = { request: jest.fn().mockResolvedValueOnce({ deleteOffer: null }) + .mockResolvedValueOnce(remaining === undefined ? {} : { offer: remaining }) }; + const result = await productionClient(transport).deleteOffer(88); + expect(result).toEqual({ id: 88, deleted: remaining === null }); + expect(transport.request.mock.calls[0][1]).toEqual({ ids: [88] }); + expect(transport.request.mock.calls[1][1]).toEqual({ id: 88 }); + }); + test('converts native Money aggregates from cents', async () => { + const transport = { request: jest.fn().mockResolvedValue({ offer: { id: 88, + totals: { salesPrice: 179400, salesPriceWithVat: 224250, vat: 44850 } } }) }; + expect(await productionClient(transport).getOfferTotals(88)).toEqual({ salesPrice: 1794, salesPriceWithVat: 2242.5, vat: 448.5 }); + }); + test('serializes native Money and unit descriptions without unsupported input fields', async () => { + const transport = { request: jest.fn().mockResolvedValueOnce({ createOfferLines: [] }) + .mockResolvedValueOnce({ offer: { id: 88, tasks: [] } }) }; + const { CREATE_OFFER_LINES_MUTATION } = require('../src/graphql/mutations/createOffer'); + await productionClient(transport).request(CREATE_OFFER_LINES_MUTATION, { inputs: [ + { offerId: 88, taskId: 1, description: 'Arbejde', unit: 'timer', salesPrice: 500, quantity: 2, discount: 0, sortOrder: 1 } + ] }); + expect(transport.request.mock.calls[0][1].inputs[0]).toEqual({ offerId: 88, taskId: 1, + description: 'Arbejde [timer]', salesPrice: 50000, quantity: 2, discount: 0, sortOrder: 1 }); + }); +}); + +describe('production transport through authenticated route', () => { + test.each([false, true])('verifies native totals and the complete remote line set, extra line=%s', async extra => { + let remoteLines = []; + let deleted = false; + const totals = { salesPrice: 179400, salesPriceWithVat: 224250, vat: 44850 }; + const transport = { request: jest.fn(async (query, variables) => { + if (query.includes('CreateCustomer')) return { createCustomer: { id: 42 } }; + if (query.includes('mutation CreateOffer(')) return { createOffer: { id: 88 } }; + if (query.includes('CreateOfferLines')) { + remoteLines = variables.inputs.map((line, i) => ({ ...line, id: i + 1, sortOrder: i + 1, offer: { id: 88 } })); + if (extra) remoteLines.push({ ...remoteLines[0], id: 99, sortOrder: 99 }); + return { createOfferLines: remoteLines }; + } + if (query.includes('OfferLinesRecovery')) return { offerLines: { items: remoteLines, hasMorePages: false, total: remoteLines.length } }; + if (query.includes('OfferRecovery')) return { offer: { id: 88, tasks: [{ id: 91, number: 1 }], totals } }; + if (query.includes('DeleteOffer')) { deleted = true; return { deleteOffer: null }; } + if (query.includes('OfferExists')) return { offer: deleted ? null : { id: 88 } }; + throw new Error('Unexpected query'); + }) }; + const { createProductionOfferClient } = require('../src/graphql/mutations/createOffer'); + const snapshotService = { buildFromProject: jest.fn().mockResolvedValue(canonicalSnapshot()), assertExpectedSignature: jest.fn() }; + const response = await request(buildApp(snapshotService, { + graphqlClient: createProductionOfferClient(transport), + quoteRealismService: { requireApprovedAnalysis: jest.fn() } + })).post('/api/ordrestyring/offers/create').set('Authorization', authHeader()) + .send({ projectId: 7, expectedSnapshotSignature: 'sig-1' }); + expect(response.status).toBe(extra ? 502 : 200); + if (extra) expect(response.body.code).toBe('LINE_RECONCILIATION_FAILED'); + expect(deleted).toBe(extra); }); }); diff --git a/backend/__tests__/ordrestyringOfferOperationBootstrap.test.js b/backend/__tests__/ordrestyringOfferOperationBootstrap.test.js new file mode 100644 index 0000000..9ad8669 --- /dev/null +++ b/backend/__tests__/ordrestyringOfferOperationBootstrap.test.js @@ -0,0 +1,14 @@ +const { ensureOrdrestyringOfferOperationsTable } = require('../src/services/ordrestyringOfferOperationSchema'); + +test('runtime bootstrap installs the durable offer operation store idempotently', async () => { + const pool = { execute: jest.fn().mockResolvedValue([{}]) }; + + await ensureOrdrestyringOfferOperationsTable(pool); + + const sql = pool.execute.mock.calls[0][0]; + expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS ordrestyring_offer_operations/); + expect(sql).toMatch(/idempotency_key VARCHAR\(255\).*PRIMARY KEY/s); + expect(sql).toMatch(/UNIQUE KEY uq_ordrestyring_offer_operation_project_signature/); + expect(sql).toMatch(/lease_owner VARCHAR\(64\)/); + expect(sql).toMatch(/lease_expires_at DATETIME\(6\)/); +}); diff --git a/backend/__tests__/repositoryStatusDiscordService.test.js b/backend/__tests__/repositoryStatusDiscordService.test.js index 1b18aaf..587270c 100644 --- a/backend/__tests__/repositoryStatusDiscordService.test.js +++ b/backend/__tests__/repositoryStatusDiscordService.test.js @@ -1,5 +1,6 @@ const { buildDiscordPayload, + discordPayloadViolations, normalizeGitHubUrl, parsePorcelain, postDiscordStatus, @@ -52,6 +53,40 @@ describe('Repository Discord status', () => { expect(JSON.stringify(payload)).not.toContain('webhook'); }); + test('fits hostile repository text inside Discord webhook embed limits', () => { + const huge = 'x'.repeat(5000); + const payload = buildDiscordPayload({ + repositoryUrl: 'https://github.com/alexpolo1/tilbudgivern', + branch: huge, + upstream: huge, + ahead: 999, + behind: 999, + worktree: { changed: 999, staged: 999, modified: 999, untracked: 999 }, + latestCommit: { hash: huge, subject: huge }, + pullRequests: Array.from({ length: 20 }, (_, index) => ({ number: index + 1, url: 'https://example.com', title: huge, statusCheckRollup: [] })), + issues: Array.from({ length: 20 }, (_, index) => ({ number: index + 1, url: 'https://example.com', title: huge })), + checks: { passed: 999, pending: 999, failed: 999 }, + health: { ok: false, label: huge } + }); + + expect(discordPayloadViolations(payload)).toEqual([]); + const embed = payload.embeds[0]; + const aggregate = (embed.title || '').length + (embed.description || '').length + + (embed.footer?.text || '').length + + embed.fields.reduce((sum, field) => sum + field.name.length + field.value.length, 0); + expect(aggregate).toBeLessThanOrEqual(6000); + }); + + test('refuses to post a payload that exceeds Discord bot limits', async () => { + const fetchImpl = jest.fn(); + await expect(postDiscordStatus({ + webhookUrl: 'https://discord.example/webhook-id/token', + payload: { content: 'x'.repeat(2001), embeds: [] }, + fetchImpl + })).rejects.toThrow(/Discord payload exceeds limits/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + test('posts JSON with wait enabled without embedding the webhook in payload', async () => { const fetchImpl = jest.fn().mockResolvedValue({ ok: true, diff --git a/backend/__tests__/seedCiAuthAccount.test.js b/backend/__tests__/seedCiAuthAccount.test.js new file mode 100644 index 0000000..4d7f446 --- /dev/null +++ b/backend/__tests__/seedCiAuthAccount.test.js @@ -0,0 +1,28 @@ +const { seedCiAuthAccount } = require('../scripts/seed-ci-auth-account'); + +test('refuses to seed outside an explicit CI test environment', async () => { + const db = { execute: jest.fn() }; + await expect(seedCiAuthAccount({ + env: { NODE_ENV: 'production', CI: 'true', AUTH_USERNAME: 'ci', AUTH_PASSWORD: 'secret' }, + db, + hashPassword: jest.fn() + })).rejects.toThrow(/CI test environment/); + expect(db.execute).not.toHaveBeenCalled(); +}); + +test('creates the isolated CI account with a bcrypt hash', async () => { + const db = { execute: jest.fn().mockResolvedValue([{ affectedRows: 1 }]) }; + const hashPassword = jest.fn().mockResolvedValue('$2b$12$test-hash'); + + await seedCiAuthAccount({ + env: { NODE_ENV: 'test', CI: 'true', AUTH_USERNAME: 'ci-user', AUTH_PASSWORD: 'ci-password' }, + db, + hashPassword + }); + + expect(hashPassword).toHaveBeenCalledWith('ci-password', 12); + expect(db.execute).toHaveBeenCalledWith( + expect.stringMatching(/INSERT INTO auth_accounts/), + ['ci-user', '$2b$12$test-hash', 'admin'] + ); +}); diff --git a/backend/__tests__/siteGeometry.test.js b/backend/__tests__/siteGeometry.test.js new file mode 100644 index 0000000..3b08d63 --- /dev/null +++ b/backend/__tests__/siteGeometry.test.js @@ -0,0 +1,47 @@ +const { normalizeGeometry } = require('../src/services/siteGeometryService'); +const ring = [[10,56],[10.001,56],[10.001,56.001],[10,56.001],[10,56]]; +const input = coordinates => ({ geometry: { type: 'Polygon', coordinates: [coordinates] }, totals: { groundAreaM2: 1 } }); +test('server calculates geodesic totals and fixes provenance instead of trusting client totals', () => { + const result = normalizeGeometry(input(ring)); + expect(result.schema).toBe('site_geometry_v1'); + expect(result.totals.groundAreaM2).toBeCloseTo(6914, -1); + expect(result.totals.perimeterM).toBeCloseTo(346.7, 0); + expect(result.areas[0].source).toEqual({ type: 'user_drawn', provider: 'openstreetmap' }); + expect(result.areas[0].quality.grade).toBe('unverified'); + expect(result.totals.roofSlopedAreaM2).toBeNull(); +}); +test('accepts and normalizes a redundant midpoint inserted on a valid edge', () => { + const withMidpoint = [[10,56],[10.0005,56],[10.001,56],[10.001,56.001],[10,56.001],[10,56]]; + const result = normalizeGeometry(input(withMidpoint)); + expect(result.totals.groundAreaM2).toBeCloseTo(6914, -1); + expect(result.areas[0].geometry.coordinates[0]).toHaveLength(5); +}); +test.each([ + ring.slice(0,-1), + [[10,56],[10.001,56.001],[10,56.001],[10.001,56],[10,56]], + [[181,56],[10,56],[10,57],[181,56]], + [[10,91],[11,56],[10,57],[10,91]], + [[10,56],[10,56],[10,57],[10,56]], + [[10,56],[11,56],[12,56],[10,56]], + [[10,56],['11',56],[10,57],[10,56]] +])('rejects invalid polygon %j', coordinates => { + expect(() => normalizeGeometry(input(coordinates))).toThrow(expect.objectContaining({ code: 'SITE_GEOMETRY_INVALID', status: 400 })); +}); + +test.each([ + ['country-scale extent', [[8,54.5],[13,54.5],[13,57.8],[8,57.8],[8,54.5]]], + ['continent-scale extent', [[-10,35],[30,35],[30,60],[-10,60],[-10,35]]], + ['polar extent', [[10,88],[10.001,88],[10.001,88.001],[10,88.001],[10,88]]], + ['outside supported Danish longitude', [[-100,56],[-99.999,56],[-99.999,56.001],[-100,56.001],[-100,56]]], + ['excessive perimeter despite a narrow box', [[10,56],[10.04,56],[10.04,56.04],[10,56.04],[10,56]]] +])('rejects non-local work geometry: %s', (_label, coordinates) => { + expect(() => normalizeGeometry(input(coordinates))).toThrow(expect.objectContaining({ + code: 'SITE_GEOMETRY_OUT_OF_BOUNDS', status: 400 + })); +}); + +test('retains an ordinary Danish property polygon within documented local-work bounds', () => { + const result = normalizeGeometry(input([[12.567,55.676],[12.568,55.676],[12.568,55.677],[12.567,55.677],[12.567,55.676]])); + expect(result.totals.groundAreaM2).toBeGreaterThan(6000); + expect(result.totals.groundAreaM2).toBeLessThan(8000); +}); diff --git a/backend/__tests__/siteGeometryPersistence.test.js b/backend/__tests__/siteGeometryPersistence.test.js new file mode 100644 index 0000000..bf9a098 --- /dev/null +++ b/backend/__tests__/siteGeometryPersistence.test.js @@ -0,0 +1,129 @@ +const { SiteGeometryService } = require('../src/services/siteGeometryService'); +const polygon = { geometry: { type: 'Polygon', coordinates: [[[10,56],[10.001,56],[10.001,56.001],[10,56]]] } }; +function database() { + let row; let events = []; let tail = Promise.resolve(); + const execute = async (sql, args) => { + if (sql.startsWith('SELECT id')) return [[{ id: 7 }]]; + if (sql.startsWith('SELECT')) return [row ? [row] : []]; + if (sql.startsWith('INSERT INTO project_site_geometry_audit')) { events.push(args); return [{}]; } + if (sql.startsWith('INSERT INTO project_site_geometry ')) { row = { revision: args[1], geometry_json: args[2] }; return [{}]; } + throw new Error(sql); + }; + return { events, query: async (...args) => (await execute(...args))[0], pool: { getConnection: async () => { + let release; + return { execute, beginTransaction: async () => { const prior = tail; tail = new Promise(r => { release=r; }); await prior; }, commit: async () => release(), rollback: async () => release(), release: () => {} }; + } } }; +} +test('serializes simultaneous initial writes, reads authoritative revision and audits operator', async () => { + const db = database(); const service = new SiteGeometryService(db); + expect(await service.get(7)).toBeNull(); + const results = await Promise.allSettled(['alex','other'].map(operator => service.save(7, { ...polygon, expectedRevision: 0 }, operator))); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1); + expect(results.find(r => r.status === 'rejected').reason).toMatchObject({ status: 409, code: 'GEOMETRY_REVISION_CONFLICT' }); + const saved = await service.get(7); + expect(saved).toMatchObject({ revision: 1, audit: { createdBy: 'alex', updatedBy: 'alex' } }); + const next = await service.save(7, { ...polygon, expectedRevision: 1 }, 'alex'); + expect(next.revision).toBe(2); + expect(db.events).toHaveLength(2); + expect(next.signature).not.toBe(saved.signature); +}); +test('requires explicit valid revision and operator', async () => { + const service = new SiteGeometryService(database()); + await expect(service.save(7, polygon, 'alex')).rejects.toMatchObject({ status: 400 }); + await expect(service.save(7, { ...polygon, expectedRevision: 0 }, '')).rejects.toMatchObject({ status: 400 }); +}); + +test('deletes with optimistic concurrency, retains a revision tombstone and appends audit evidence', async () => { + const db = database(); + const service = new SiteGeometryService(db); + const saved = await service.save(7, { ...polygon, expectedRevision: 0 }, 'alex'); + + const deleted = await service.delete(7, { expectedRevision: saved.revision }, 'alex'); + + expect(deleted).toMatchObject({ geometry: null, revision: 2, deleted: true }); + expect(await service.get(7)).toBeNull(); + expect(await service.getState(7)).toMatchObject({ geometry: null, revision: 2 }); + expect(db.events).toHaveLength(2); + expect(db.events[1]).toEqual(expect.arrayContaining([ + 7, 2, 'alex', 'deleted', expect.any(Date), saved.signature, expect.any(String), expect.any(String) + ])); + const tombstone = JSON.parse(db.events[1][7]); + expect(tombstone).toMatchObject({ schema: 'site_geometry_tombstone_v1', revision: 2, deleted: true }); + expect(tombstone.signature).toBe(db.events[1][6]); +}); + +test('refuses absent and stale deletion without appending evidence or changing geometry', async () => { + const db = database(); + const service = new SiteGeometryService(db); + await expect(service.delete(7, { expectedRevision: 0 }, 'alex')).rejects.toMatchObject({ + status: 404, code: 'SITE_GEOMETRY_NOT_FOUND' + }); + expect(db.events).toHaveLength(0); + + const saved = await service.save(7, { ...polygon, expectedRevision: 0 }, 'alex'); + await expect(service.delete(7, { expectedRevision: 0 }, 'alex')).rejects.toMatchObject({ + status: 409, code: 'GEOMETRY_REVISION_CONFLICT' + }); + expect(await service.get(7)).toEqual(saved); + expect(db.events).toHaveLength(1); +}); + +test('requires deletion revision and operator and restores only from the tombstone revision', async () => { + const db = database(); + const service = new SiteGeometryService(db); + const saved = await service.save(7, { ...polygon, expectedRevision: 0 }, 'alex'); + await expect(service.delete(7, {}, 'alex')).rejects.toMatchObject({ status: 400 }); + await expect(service.delete(7, { expectedRevision: saved.revision }, '')).rejects.toMatchObject({ status: 400 }); + const deleted = await service.delete(7, { expectedRevision: saved.revision }, 'alex'); + await expect(service.save(7, { ...polygon, expectedRevision: 0 }, 'alex')).rejects.toMatchObject({ status: 409 }); + const restored = await service.save(7, { ...polygon, expectedRevision: deleted.revision }, 'alex'); + expect(restored.revision).toBe(3); + expect(db.events[2][3]).toBe('restored'); +}); + +test('runtime migration installs dedicated canonical and audit tables', async () => { + const { migrateSiteGeometry } = require('../src/services/siteGeometryMigration'); + const db = { query: jest.fn().mockResolvedValue([]) }; + await migrateSiteGeometry(db); + expect(db.query.mock.calls.map(c => c[0]).join('\n')).toMatch(/CREATE TABLE IF NOT EXISTS project_site_geometry /); + expect(db.query.mock.calls.map(c => c[0]).join('\n')).toMatch(/CREATE TABLE IF NOT EXISTS project_site_geometry_audit /); +}); + +test('runtime migration skips task geometry basis when the task table is not installed yet', async () => { + const { migrateSiteGeometry } = require('../src/services/siteGeometryMigration'); + const db = { + query: jest.fn(async sql => { + if (/INFORMATION_SCHEMA\.TABLES/.test(sql)) return []; + if (/ALTER TABLE smart_package_tasks/.test(sql)) { + throw new Error('must not alter a missing table'); + } + return []; + }) + }; + + await expect(migrateSiteGeometry(db)).resolves.toBeUndefined(); + expect(db.query.mock.calls.map(call => call[0]).join('\n')).not.toMatch(/ALTER TABLE smart_package_tasks/); +}); + +test('runtime migration installs durable global geocoder gate and expiring cache', async () => { + const { migrateSiteGeometry } = require('../src/services/siteGeometryMigration'); + const db = { + query: jest.fn(async sql => ( + /INFORMATION_SCHEMA\.TABLES/.test(sql) ? [{ exists: 1 }] : [] + )) + }; + await migrateSiteGeometry(db); + const sql = db.query.mock.calls.map(c => c[0]).join('\n'); + expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS nominatim_rate_gate/); + expect(sql).toMatch(/next_request_at DATETIME\(6\) NOT NULL/); + expect(sql).toMatch(/reservation_token CHAR\(64\) NULL/); + expect(sql).toMatch(/reservation_expires_at DATETIME\(6\) NULL/); + expect(sql).toMatch(/INSERT IGNORE INTO nominatim_rate_gate/); + expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS nominatim_cache/); + expect(sql).toMatch(/query_key VARBINARY\(800\) PRIMARY KEY/); + expect(sql).toMatch(/results_json JSON NOT NULL/); + expect(sql).toMatch(/expires_at DATETIME\(6\) NOT NULL/); + expect(sql).toMatch(/INDEX cache_expiry \(expires_at\)/); + expect(sql).toMatch(/ALTER TABLE smart_package_tasks/); + expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS geometry_basis VARCHAR\(50\) NULL/); +}); diff --git a/backend/__tests__/siteGeometryRoutes.test.js b/backend/__tests__/siteGeometryRoutes.test.js new file mode 100644 index 0000000..fae5970 --- /dev/null +++ b/backend/__tests__/siteGeometryRoutes.test.js @@ -0,0 +1,69 @@ +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +process.env.JWT_ACCESS_SECRET = 'site-geometry-test-access'; +process.env.JWT_REFRESH_SECRET = 'site-geometry-test-refresh'; +process.env.AUTH_USERNAME = 'operator'; +jest.mock('uuid', () => ({ v4: () => 'test-id' })); +const { SiteGeometryService } = require('../src/services/siteGeometryService'); +const { Nominatim } = require('../src/services/nominatimService'); +const routes = require('../src/routes/customerProjects'); +const app = express(); app.use(express.json()); app.use('/api/customer-projects',routes); +const base = '/api/customer-projects/projects/7/site-geometry'; +const auth = username => `Bearer ${jwt.sign({id: 1, username},process.env.JWT_ACCESS_SECRET)}`; +afterEach(() => jest.restoreAllMocks()); +test.each([['get',''],['put',''],['delete',''],['post','/geocode']])('protects %s %s with configured operator', async (method,suffix) => { + expect((await request(app)[method](base+suffix)).status).toBe(401); + expect((await request(app)[method](base+suffix).set('Authorization',auth('other'))).status).toBe(403); +}); +test('GET/PUT return canonical resource and authenticated actor; conflict is explicit',async () => { + jest.spyOn(SiteGeometryService.prototype,'getState').mockResolvedValue({geometry:{revision:1},revision:1}); + const save = jest.spyOn(SiteGeometryService.prototype,'save').mockResolvedValue({revision:2}); + const result = await request(app).get(base).set('Authorization',auth('operator')); + expect(result.status).toBe(200); expect(result.body.geometry.revision).toBe(1); + expect(result.headers['cache-control']).toContain('no-store'); + expect((await request(app).put(base).set('Authorization',auth('operator')).send({expectedRevision:1})).body.geometry.revision).toBe(2); + expect(save).toHaveBeenCalledWith(7,{expectedRevision:1},'operator'); + save.mockRejectedValue(Object.assign(new Error('Konflikt'),{status:409,code:'GEOMETRY_REVISION_CONFLICT'})); + expect((await request(app).put(base).set('Authorization',auth('operator')).send({})).status).toBe(409); +}); +test('DELETE requires expected revision, forwards the configured operator and returns tombstone revision', async () => { + const remove = jest.spyOn(SiteGeometryService.prototype, 'delete').mockResolvedValue({ geometry: null, revision: 2, deleted: true }); + const response = await request(app).delete(base).set('Authorization', auth('operator')).send({ expectedRevision: 1 }); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ success: true, geometry: null, revision: 2, deleted: true }); + expect(response.headers['cache-control']).toContain('no-store'); + expect(remove).toHaveBeenCalledWith(7, { expectedRevision: 1 }, 'operator'); + + remove.mockRejectedValue(Object.assign(new Error('Området mangler'), { status: 404, code: 'SITE_GEOMETRY_NOT_FOUND' })); + const absent = await request(app).delete(base).set('Authorization', auth('operator')).send({ expectedRevision: 0 }); + expect(absent.status).toBe(404); + expect(absent.body.code).toBe('SITE_GEOMETRY_NOT_FOUND'); +}); +test('geocodes only on explicit request, validates id and does not write geometry',async () => { + jest.spyOn(SiteGeometryService.prototype,'get').mockResolvedValue(null); + const save = jest.spyOn(SiteGeometryService.prototype,'save'); + const search = jest.spyOn(Nominatim.prototype,'search').mockResolvedValue([{lat:56,lng:10}]); + const result = await request(app).post(base+'/geocode').set('Authorization',auth('operator')).send({query:'Aarhus'}); + expect(result.status).toBe(200); expect(result.body.candidates).toEqual([{lat:56,lng:10}]); + expect(search).toHaveBeenCalledWith('Aarhus'); expect(save).not.toHaveBeenCalled(); + expect((await request(app).get(base.replace('/7/','/invalid/')).set('Authorization',auth('operator'))).status).toBe(400); +}); +test('route supplies its database to the geocoder', async () => { + const db = { marker: 'route database' }; + const router = require('../src/routes/siteGeometryRoutes')(db); + const local = express();local.use(express.json());local.use(router); + jest.spyOn(SiteGeometryService.prototype,'get').mockResolvedValue(null); + jest.spyOn(Nominatim.prototype,'search').mockImplementation(async function () { + expect(this.db).toBe(db); + return []; + }); + const result = await request(local).post('/7/site-geometry/geocode').send({query:'Aarhus'}); + expect(result.status).toBe(200); +}); + +// Route fixtures include the live account required by the shared authorization boundary. +beforeEach(() => { + jest.spyOn(require('../src/services/userService'), 'findByUsername').mockImplementation(async username => ({ id: 1, username, role: 'admin' })); +}); +afterEach(() => jest.restoreAllMocks()); diff --git a/backend/__tests__/smartPackageChildVersioning.test.js b/backend/__tests__/smartPackageChildVersioning.test.js new file mode 100644 index 0000000..a53527b --- /dev/null +++ b/backend/__tests__/smartPackageChildVersioning.test.js @@ -0,0 +1,130 @@ +const Service = require('../src/services/smartPackageManagementService'); + +jest.mock('../src/utils/logger', () => ({ info: jest.fn(), error: jest.fn() })); + +const PACKAGE_ID = 7; +const VERSION = 3; + +function fixture({ stale = false, failOn = null } = {}) { + const calls = []; + const connection = { + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn(), + execute: jest.fn(async (sql, params = []) => { + calls.push({ sql, params }); + if (failOn && sql.includes(failOn)) throw new Error('child write failed'); + if (sql.includes('FROM smart_package_steps') && sql.includes('JOIN smart_package_tasks')) { + return [[{ step_id: 31, task_id: 21, package_id: PACKAGE_ID, version: stale ? VERSION + 1 : VERSION }]]; + } + if (sql.includes('FROM smart_package_tasks') && sql.includes('JOIN material_packages')) { + return [[{ + task_id: 21, + package_id: PACKAGE_ID, + hours: 2, + version: stale ? VERSION + 1 : VERSION + }]]; + } + if (sql.includes('FROM material_packages') && sql.includes('FOR UPDATE')) { + return [[{ id: PACKAGE_ID, version: stale ? VERSION + 1 : VERSION, is_active: 1 }]]; + } + if (sql.includes('MAX(task_order)')) return [[{ next_order: 4 }]]; + if (sql.includes('MAX(step_order)')) return [[{ next_order: 5 }]]; + if (sql.includes('INSERT INTO smart_package_tasks')) return [{ insertId: 21, affectedRows: 1 }]; + if (sql.includes('INSERT INTO smart_package_steps')) return [{ insertId: 31, affectedRows: 1 }]; + return [{ affectedRows: 1 }]; + }) + }; + const service = new Service({ pool: { getConnection: async () => connection } }); + service.readManagementResult = jest.fn(async () => ({ id: PACKAGE_ID, version: VERSION + 1, name: 'Current' })); + return { service, connection, calls }; +} + +const cases = [ + ['add task', (service) => service.addTaskToPackage(PACKAGE_ID, { name: 'Task', hours: 1 }, VERSION)], + ['update task', (service) => service.updateTask(21, { name: 'Renamed', hours: 2, rate: 725, timeUnit: 'per_meter', timePerUnit: 0.25 }, VERSION)], + ['delete task', (service) => service.deleteTask(21, VERSION)], + ['reorder tasks', (service) => service.reorderTasks(PACKAGE_ID, [{ taskId: 21, order: 1 }], VERSION)], + ['add custom task', (service) => service.createCustomTask({ packageId: PACKAGE_ID, name: 'Custom', timeUnit: 'per_meter', timePerUnit: 0.2, expectedVersion: VERSION })], + ['add step', (service) => service.addStepToTask(21, { title: 'Step' }, VERSION)], + ['update step', (service) => service.updateStep(31, { title: 'Renamed' }, VERSION)], + ['delete step', (service) => service.deleteStep(31, VERSION)], + ['reorder steps', (service) => service.reorderSteps(21, [{ stepId: 31, order: 1 }], VERSION)] +]; + +describe('transactional package child mutations', () => { + test.each(cases)('%s locks the parent, bumps its version exactly once and returns the current package', async (_name, mutate) => { + const { service, connection, calls } = fixture(); + + await expect(mutate(service)).resolves.toMatchObject({ + package: { id: PACKAGE_ID, version: VERSION + 1 }, + version: VERSION + 1 + }); + + expect(connection.beginTransaction).toHaveBeenCalledTimes(1); + expect(connection.commit).toHaveBeenCalledTimes(1); + expect(connection.rollback).not.toHaveBeenCalled(); + expect(connection.release).toHaveBeenCalledTimes(1); + expect(calls.some(({ sql }) => sql.includes('FOR UPDATE'))).toBe(true); + const parentWrites = calls.filter(({ sql }) => /^\s*UPDATE material_packages\b/.test(sql)); + expect(parentWrites).toHaveLength(1); + expect(parentWrites[0].sql).toContain('version = version + 1'); + }); + + test.each(cases)('%s rejects a stale expectedVersion before any child write', async (_name, mutate) => { + const { service, connection, calls } = fixture({ stale: true }); + + await expect(mutate(service)).rejects.toMatchObject({ + status: 409, + code: 'SMART_PACKAGE_VERSION_CONFLICT' + }); + + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.commit).not.toHaveBeenCalled(); + expect(calls.some(({ sql }) => /^(\s*)(INSERT|UPDATE|DELETE) (smart_package_tasks|smart_package_steps)/.test(sql))).toBe(false); + expect(calls.some(({ sql }) => /^\s*UPDATE material_packages\b/.test(sql))).toBe(false); + }); + + test('a child write failure rolls the task mutation and parent version back atomically', async () => { + const { service, connection, calls } = fixture({ failOn: 'UPDATE smart_package_tasks' }); + + await expect(service.updateTask(21, { name: 'Renamed', hours: 2 }, VERSION)) + .rejects.toThrow('child write failed'); + + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.commit).not.toHaveBeenCalled(); + expect(calls.some(({ sql }) => /^\s*UPDATE material_packages\b/.test(sql))).toBe(false); + }); + + test('name, rate, basis and dependency-only task changes still advance the aggregate version', async () => { + const { service, calls } = fixture(); + + await service.updateTask(21, { + name: 'Only metadata changed', + hours: 2, + rate: 725, + timeUnit: 'per_meter', + timePerUnit: 0.25, + dependsOn: 20 + }, VERSION); + + expect(calls.filter(({ sql }) => /^\s*UPDATE material_packages\b/.test(sql))).toHaveLength(1); + }); + + test.each([ + ['addTaskToPackage', service => service.addTaskToPackage(PACKAGE_ID, { name: 'Task' })], + ['updateTask', service => service.updateTask(21, { name: 'Task' })], + ['deleteTask', service => service.deleteTask(21)], + ['reorderTasks', service => service.reorderTasks(PACKAGE_ID, [])], + ['createCustomTask', service => service.createCustomTask({ packageId: PACKAGE_ID, name: 'Custom', timeUnit: 'per_meter', timePerUnit: 1 })], + ['addStepToTask', service => service.addStepToTask(21, { title: 'Step' })], + ['updateStep', service => service.updateStep(31, { title: 'Step' })], + ['deleteStep', service => service.deleteStep(31)], + ['reorderSteps', service => service.reorderSteps(21, [])] + ])('%s fails closed when expectedVersion is absent', async (_method, mutate) => { + const { service, connection } = fixture(); + await expect(mutate(service)).rejects.toMatchObject({ status: 400 }); + expect(connection.beginTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/smartPackageExcelImportService.test.js b/backend/__tests__/smartPackageExcelImportService.test.js index 0cdfa5c..19ea661 100644 --- a/backend/__tests__/smartPackageExcelImportService.test.js +++ b/backend/__tests__/smartPackageExcelImportService.test.js @@ -1,7 +1,8 @@ const { parseNumber, isRentalDuplicate, - parseWorkbook + parseWorkbook, + validateExpectedVersions } = require('../src/services/smartPackageExcelImportService'); const XLSX = require('xlsx'); const fs = require('fs'); @@ -79,6 +80,14 @@ describe('SmartPackageExcelImportService', () => { fs.rmSync(directory, { recursive: true, force: true }); }); + test('requires exact versions for every locked package before synchronization writes', () => { + const packages = [{ id: 7, version: 3 }, { id: 8, version: 5 }]; + expect(() => validateExpectedVersions(packages, { 7: 3 })).toThrow('package 8'); + expect(() => validateExpectedVersions(packages, { 7: 2, 8: 5 })) + .toThrow(expect.objectContaining({ code: 'SMART_PACKAGE_VERSION_CONFLICT' })); + expect(() => validateExpectedVersions(packages, { 7: 3, 8: 5 })).not.toThrow(); + }); + test('preserves the real Excel sheet and row when blank rows occur', () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-row-')); const filePath = path.join(directory, 'custom.xlsx'); diff --git a/backend/__tests__/smartPackageManagementContract.test.js b/backend/__tests__/smartPackageManagementContract.test.js new file mode 100644 index 0000000..68a2e1a --- /dev/null +++ b/backend/__tests__/smartPackageManagementContract.test.js @@ -0,0 +1,268 @@ +const Service = require('../src/services/smartPackageManagementService'); +jest.mock('../src/utils/logger', () => ({ info: jest.fn(), error: jest.fn() })); + +const contract = { + name: 'Tag', description: 'Beskrivelse', package_type: 'rental_service', + validation_status: 'blocked', is_active: 0, unit: 'm²', unit_price: 10, + price_per_unit: 11, standard_price: 12, price_source: 'manual', + price_source_value: 'Prisliste 2026', price_basis_note: 'Ekskl. moms', + geometry_basis: 'roof_area', geometry_factor: 1.1, default_count: 2, + default_quantity: 3, replacement_scope: 'full', compatible_roof_materials: ['tegl'], + allowed_roof_forms: ['gable'], min_pitch_degrees: 20, max_pitch_degrees: 45, + pitch_verification_status: 'verified', pitch_review_required: false, + time_per_unit: 0.25, + materials: [], tasks: [] +}; + +// Stateful SQL fixture: assertions inspect persisted values, not echoed input. +function fixture(initial = {}) { + let row = { id: 7, version: 3, name: 'Arkiv', package_type: 'rental_service', validation_status: 'blocked', is_active: 0, ...initial }; + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn(async (sql, params = []) => { + if (/SELECT .*FROM material_packages/s.test(sql)) { + if (sql.includes('is_active = 1') && !row.is_active && row.validation_status !== 'blocked') return [[]]; + return [[{ ...row }]]; + } + if (/INSERT INTO material_packages/.test(sql)) { + const columns = sql.match(/material_packages\s*\(([^)]+)\)/)[1].split(',').map(s => s.trim().replace(/`/g, '')); + row = { id: 8, version: 1, is_active: 1, ...Object.fromEntries(columns.map((key, i) => [key, params[i]])) }; + return [{ insertId: 8 }]; + } + if (/UPDATE material_packages SET updated_at/.test(sql)) { + if (params.at(-1) !== row.version) return [{ affectedRows: 0 }]; + row.version++; + } + if (/UPDATE material_packages/.test(sql)) { + const columns = [...sql.split('WHERE')[0].matchAll(/(?:SET |,\s*)(\w+) = \?/g)].map(m => m[1]); + columns.forEach((key, i) => { row[key] = params[i]; }); + return [{ affectedRows: 1 }]; + } + if (/SELECT/.test(sql)) return [[]]; + return [{ insertId: 20, affectedRows: 1 }]; + }) + }; + return { service: new Service({ pool: { ...connection, getConnection: async () => connection } }), connection, row: () => row }; +} + +describe('management package contract regressions', () => { + test('POST persists and returns the complete contract', async () => { + const { service } = fixture(); + expect(await service.createPackage(contract)).toMatchObject({ ...contract, validation_status: 'needs_review', is_active: 0, pitch_review_required: 0, id: 8, version: 1 }); + }); + test('PUT edits archived packages without activating them and returns persisted version', async () => { + const { service } = fixture({ validation_status: 'archived' }); + const result = await service.updatePackage(7, { expectedVersion: 3, name: 'Rettet' }); + expect(result).toMatchObject({ id: 7, name: 'Rettet', version: 4, is_active: 0, validation_status: 'archived' }); + }); + test('PUT cannot reactivate a persisted archived package without a lifecycle transition', async () => { + const { service, connection } = fixture({ validation_status: 'archived', is_active: 0 }); + await expect(service.updatePackage(7, { expectedVersion: 3, is_active: 1 })).rejects.toMatchObject({ status: 400 }); + expect(connection.commit).not.toHaveBeenCalled(); + }); + test('PUT cannot activate a complete offer without at least one persisted task', async () => { + const { service, connection } = fixture({ + package_type: 'complete_offer', validation_status: 'needs_review', is_active: 0, + pitch_verification_status: 'verified', pitch_review_required: 0, + compatible_roof_materials: '["tegl"]', allowed_roof_forms: '["gable"]', + min_pitch_degrees: 20, max_pitch_degrees: 45 + }); + const originalExecute = connection.execute.getMockImplementation(); + connection.execute.mockImplementation(async (sql, params) => { + if (sql.includes('SELECT * FROM package_materials')) return [[{ material_id: 42, material_name: 'Tegl', unit_price: 25 }]]; + if (sql.includes('FROM materials')) return [[{ id: 42, sku: 'SKU-42', name: 'Tegl' }]]; + if (sql.includes('COUNT(*) AS task_count')) return [[{ task_count: 0 }]]; + return originalExecute(sql, params); + }); + await expect(service.updatePackage(7, { + expectedVersion: 3, validation_status: 'verified', is_active: 1 + })).rejects.toMatchObject({ status: 400 }); + expect(connection.commit).not.toHaveBeenCalled(); + }); + test('PUT persists the complete contract including explicit lifecycle', async () => { + const { service } = fixture(); + expect(await service.updatePackage(7, { ...contract, expectedVersion: 3 })).toMatchObject({ ...contract, pitch_review_required: 0, version: 4 }); + }); + test('even an empty PUT checks optimistic version', async () => { + const { service, connection } = fixture(); + await expect(service.updatePackage(7, { expectedVersion: 2 })).rejects.toMatchObject({ status: 409 }); + expect(connection.commit).not.toHaveBeenCalled(); + }); + test.each([ + { unit_price: -1 }, { geometry_factor: 'bad' }, { default_count: -1 }, + { is_active: 'false' }, { validation_status: 'bogus' }, { package_type: 'bogus' }, + { materials: {} }, { tasks: [null] }, { compatible_roof_materials: 'tegl' }, + { pitch_verification_status: 'bogus' }, { min_pitch_degrees: 50, max_pitch_degrees: 20 }, + { compatible_roof_materials: [] }, { allowed_roof_forms: [] }, + { min_pitch_degrees: null }, { pitch_review_required: true } + ])('rejects invalid contract before persistence: %j', async patch => { + const { service, connection } = fixture(); + await expect(service.createPackage({ ...contract, ...patch })).rejects.toMatchObject({ status: 400 }); + expect(connection.commit).not.toHaveBeenCalled(); + }); + test.each([-1, 'bad'])('rejects invalid task timePerUnit alias: %p', async timePerUnit => { + const { service, connection } = fixture(); + await expect(service.createPackage({ + ...contract, + tasks: [{ name: 'Alias task', hours: 1, timeUnit: 'per_meter', timePerUnit }] + })).rejects.toMatchObject({ status: 400 }); + expect(connection.commit).not.toHaveBeenCalled(); + }); + test('active verified roof packages require verified cleared pitch evidence', () => { + const { service } = fixture(); + expect(() => service.managementContract({ + ...contract, + package_type: 'complete_offer', + validation_status: 'verified', + is_active: 1, + pitch_verification_status: 'unverified', + pitch_review_required: true + })).toThrow(/pitch|hældning|verified/i); + }); + + test('PUT cannot erase compatibility while retaining verified pitch', async () => { + const { service } = fixture(contract); + await expect(service.updatePackage(7, { expectedVersion: 3, allowed_roof_forms: [] })).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe('atomic duplication', () => { + function cloneFixture(fail = false) { + const source = { id: 7, catalog_key: 'unique-key', version: 3, name: 'Arkiv', is_active: 0, + validation_status: 'archived', validated_at: new Date(), created_by: 'original', validation_notes: 'Original audit' }; + const f = fixture(source); + const original = f.connection.execute.getMockImplementation(); + f.connection.execute.mockImplementation(async (sql, params) => { + if (sql.includes('SELECT * FROM smart_package_tasks')) return [[{ id: 10, package_id: 7, name: 'Task', depends_on: null }, { id: 11, package_id: 7, name: 'Next', depends_on: 10 }]]; + if (sql.includes('SELECT * FROM smart_package_steps')) return [[{ id: 12, task_id: 10, title: 'Step' }]]; + if (sql.includes('SELECT * FROM package_materials')) return [[{ id: 13, package_id: 7, material_id: null, material_name: 'Legacy', geometry_multiplier: 'eaves' }]]; + if (fail && sql.includes('INSERT INTO smart_package_steps')) throw new Error('child failure'); + return original(sql, params); + }); + f.service.readManagementResult = jest.fn(async () => f.row()); + return f; + } + test('clones archived identity and children, resets approval, and audits the source', async () => { + const { service, connection } = cloneFixture(); + const result = await service.duplicatePackage(7, {}, 'operator'); + expect(result).toMatchObject({ id: 8, name: 'Arkiv (kopi)', version: 1, is_active: 0, validation_status: 'needs_review', validated_at: null, catalog_key: null }); + expect(result.created_by).toBe('operator'); + expect(result.validation_notes).toContain('7'); + expect(result.validation_notes).toContain('3'); + expect(result.validation_notes).toContain('Original audit'); + const writes = connection.execute.mock.calls.filter(([sql]) => sql.includes('INSERT INTO')); + expect(writes.some(([sql, values]) => sql.includes('package_materials') && values.includes('eaves'))).toBe(true); + expect(writes.some(([sql]) => sql.includes('smart_package_steps'))).toBe(true); + expect(connection.execute.mock.calls.some(([sql, values]) => sql.includes('UPDATE smart_package_tasks SET depends_on') && values[0] === 20)).toBe(true); + expect(connection.commit).toHaveBeenCalledTimes(1); + }); + test('rolls back every clone write if a child fails', async () => { + const { service, connection } = cloneFixture(true); + await expect(service.duplicatePackage(7, { name: 'Ny' }, 'operator')).rejects.toThrow('child failure'); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.commit).not.toHaveBeenCalled(); + expect(connection.release).toHaveBeenCalledTimes(1); + }); + test('rejects overrides other than name at the service boundary', async () => { + const { service } = cloneFixture(); + await expect(service.duplicatePackage(7, { version: 99 }, 'operator')).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe('management contract edge cases', () => { + test('creation retains validation notes for pitch and lifecycle audit', async () => { + const { service } = fixture(); + expect(await service.createPackage({ ...contract, validation_notes: 'Manufacturer clearance: 20–45 degrees' })) + .toMatchObject({ validation_notes: 'Manufacturer clearance: 20–45 degrees' }); + }); + test.each(['create', 'update'])('%s retains material and task calculation metadata', async action => { + const { service, connection } = fixture(); + const data = { ...contract, expectedVersion: 3, + materials: [{ name: 'Material', quantity: 2, unit_price: 10, material_category: 'Tag', + geometry_multiplier: 'eaves', base_quantity: 1.5, waste_factor: 1.1, + excel_source_sheet: 'Tag', excel_source_row: 22, source_line_order: 4, + raw_line: 'original material row', item_code: 'MAT-22', quantity_text: '2 stk', + unit_price_text: '10,00', price_note: 'Leverandørpris', excel_raw_data: '{"row":22}' }], + tasks: [{ name: 'Task', hours: 2, time_unit: 'per_meter', time_per_unit: 0.2, + geometry_basis: 'eaves', price_basis_note: 'Timer pr. meter', is_custom: 1, + material_varenr: 'MAT-22', excel_source_sheet: 'Tag', excel_source_row: 23, + source_line_order: 5, raw_line: 'original task row', excel_raw_data: '{"row":23}' }] }; + await (action === 'create' ? service.createPackage(data) : service.updatePackage(7, data)); + const sql = connection.execute.mock.calls.map(call => call[0]).join('\n'); + expect(sql).toContain('geometry_multiplier'); + expect(sql).toContain('time_per_unit'); + expect(sql).toContain('item_code'); + expect(sql).toContain('unit_price_text'); + expect(sql).toContain('is_custom'); + expect(sql).toContain('material_varenr'); + expect(sql).toContain('excel_raw_data'); + expect(connection.execute.mock.calls.some(([, values]) => values?.includes('eaves'))).toBe(true); + expect(connection.execute.mock.calls.some(([, values]) => values?.includes('original material row'))).toBe(true); + expect(connection.execute.mock.calls.some(([, values]) => values?.includes('original task row'))).toBe(true); + }); + test('legacy dependencies belong to the copy without treating the global component catalog as a package relation', async () => { + const { service, connection } = fixture(); + const original = connection.execute.getMockImplementation(); + connection.execute.mockImplementation(async (sql, values) => { + if (sql.includes('SELECT * FROM package_tasks')) return [[{ id: 14, package_id: 7, task_name: 'A', depends_on_task_id: null }, { id: 15, package_id: 7, task_name: 'B', depends_on_task_id: 14 }]]; + return original(sql, values); + }); + service.readManagementResult = jest.fn(async () => ({})); + await service.duplicatePackage(7, {}, 'operator'); + expect(connection.execute.mock.calls.some(([sql, values]) => sql.includes('UPDATE package_tasks SET depends_on_task_id') && values[0] === 20)).toBe(true); + expect(connection.execute.mock.calls.some(([sql]) => sql.includes('smart_package_components') && sql.includes('parent_package_id'))).toBe(false); + }); + test('changing rental to a material package validates its existing material links', async () => { + const { service, connection } = fixture(); + await expect(service.updatePackage(7, { expectedVersion: 3, package_type: 'component' })).rejects.toMatchObject({ status: 400 }); + expect(connection.commit).not.toHaveBeenCalled(); + }); +}); + +describe('round-trip safeguards', () => { + test('retaining verified pitch rejects invalid stored evidence', async () => { + const { service } = fixture({ ...contract, min_pitch_degrees: 'not-a-number' }); + await expect(service.updatePackage(7, { expectedVersion: 3, name: 'Edit' })).rejects.toMatchObject({ status: 400 }); + }); + test.each(['create', 'update'])('%s remaps task dependencies and accepts task timing aliases', async action => { + const { service, connection } = fixture(); + const data = { ...contract, expectedVersion: 3, tasks: [ + { id: 30, name: 'First', hours: 1, timeUnit: 'per_meter', timePerUnit: 0.2 }, + { id: 31, name: 'Second', hours: 1, depends_on: 30 } + ] }; + await (action === 'create' ? service.createPackage(data) : service.updatePackage(7, data)); + expect(connection.execute.mock.calls.some(([sql, values]) => sql.includes('UPDATE smart_package_tasks SET depends_on') && values[0] === 20)).toBe(true); + expect(connection.execute.mock.calls.some(([sql, values]) => sql.includes('time_per_unit') && values.includes(0.2))).toBe(true); + }); + test.each(['create', 'update'])('%s ignores blank task dependencies from the UI', async action => { + const { service } = fixture(); + const data = { + ...contract, + expectedVersion: 3, + tasks: [{ id: 30, name: 'Independent task', hours: 1, depends_on: '' }] + }; + await expect(action === 'create' ? service.createPackage(data) : service.updatePackage(7, data)).resolves.toBeDefined(); + }); + test('project geometry calculation rejects archived packages at the database boundary', async () => { + const { service, connection } = fixture({ validation_status: 'archived', is_active: 0 }); + await expect(service.calculatePackageMaterialsWithGeometry(7, { total_area: 100 }, 20)) + .rejects.toMatchObject({ status: 404, code: 'SMART_PACKAGE_NOT_FOUND' }); + expect(connection.execute.mock.calls[0][0]).toMatch(/WHERE id = \? AND is_active = 1/); + }); + test('project geometry calculation rejects an active roof package with unverified pitch', async () => { + const { service } = fixture({ + package_type: 'complete_offer', validation_status: 'verified', is_active: 1, + pitch_verification_status: 'unverified', pitch_review_required: 1, + compatible_roof_materials: '["tegl"]', allowed_roof_forms: '["gable"]', + min_pitch_degrees: 20, max_pitch_degrees: 45 + }); + await expect(service.calculatePackageMaterialsWithGeometry(7, { total_area: 100 }, 20)) + .rejects.toMatchObject({ status: 404, code: 'SMART_PACKAGE_NOT_FOUND' }); + }); + test('management list returns the price basis note', async () => { + const { service, connection } = fixture(); + connection.execute.mockResolvedValue([[]]); + await service.getPackages({ includeInactive: true }); + expect(connection.execute.mock.calls[0][0]).toContain('mp.price_basis_note'); + }); +}); diff --git a/backend/__tests__/smartPackageMaterialMatchService.test.js b/backend/__tests__/smartPackageMaterialMatchService.test.js index f7d45ba..f6c252b 100644 --- a/backend/__tests__/smartPackageMaterialMatchService.test.js +++ b/backend/__tests__/smartPackageMaterialMatchService.test.js @@ -1,5 +1,6 @@ const { findBestMaterialMatch, + rankMaterialCandidates, scoreCandidate } = require('../src/services/smartPackageMaterialMatchService'); @@ -24,6 +25,29 @@ describe('smartPackageMaterialMatchService', () => { expect(match.status).toBe('matched_name'); }); + test('matches Danish compound material names against catalog roots', () => { + const line = { name: 'Gipsplader', unit: 'm²' }; + const board = { name: 'RAW STANDARD GIPS AK 13 X 900 X 2400 MM', unit: 'PL' }; + const tool = { name: 'BOSCH AKKUGIPSSKRUETRÆKKER GTB 18V', unit: 'STK' }; + + expect(rankMaterialCandidates(line.name, line.unit, [tool, board])[0]).toBe(board); + }); + + test('keeps fuzzy roots suggestion-only instead of auto-matching imports', () => { + const match = findBestMaterialMatch({ name: 'Gipsplader', unit: 'm²' }, [ + { id: 700, name: 'RAW STANDARD GIPS AK 13 X 900 X 2400 MM', unit: 'PL' } + ]); + expect(match.material).toBeNull(); + }); + + test('ranks sheet goods above unrelated products for an area-based board query', () => { + const candidates = rankMaterialCandidates('Gipsplader', 'm²', [ + { id: 1901, name: 'KOBLINGSDÅSE T/GIPS', unit: 'STK' }, + { id: 700, name: 'RAW STANDARD GIPS AK 13 X 900 X 2400 MM', unit: 'PL' } + ]); + expect(candidates.map(item => item.id)).toEqual([700, 1901]); + }); + test('scores a dimensionally matching name above a generic alternative', () => { const line = { name: 'Taglægte 38x73 mm', unit: 'lbm' }; expect(scoreCandidate(line, catalog[0])).toBeGreaterThan(scoreCandidate(line, catalog[2])); diff --git a/backend/__tests__/smartPackagesReadAuthorization.test.js b/backend/__tests__/smartPackagesReadAuthorization.test.js new file mode 100644 index 0000000..ba06acb --- /dev/null +++ b/backend/__tests__/smartPackagesReadAuthorization.test.js @@ -0,0 +1,183 @@ +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); + +process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'smart-package-route-inventory-secret'; +process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'smart-package-route-inventory-refresh-secret'; +process.env.AUTH_USERNAME = 'configured-operator'; + +jest.mock('uuid', () => ({ v4: () => 'route-inventory-correlation-id' })); +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + logInfo: jest.fn() +})); + +const smartPackagesRoutes = require('../src/routes/smartPackagesRoutes'); + +const BASE_PATH = '/api/smart-packages'; +const PUBLIC_READ_ROUTES = [ + { method: 'get', path: '/', requestPath: '' }, + { method: 'get', path: '/:id', requestPath: '/7' } +]; +const INTERNAL_READ_ROUTES = [ + { method: 'get', path: '/management', requestPath: '/management' }, + { method: 'get', path: '/tasks', requestPath: '/tasks' }, + { method: 'get', path: '/categories', requestPath: '/categories' }, + { method: 'get', path: '/integrity-report', requestPath: '/integrity-report' }, + { method: 'get', path: '/haandvaerkpriser-preview', requestPath: '/haandvaerkpriser-preview' }, + { method: 'get', path: '/review-queue', requestPath: '/review-queue' }, + { method: 'get', path: '/material-master-search', requestPath: '/material-master-search?q=tagrende' }, + { method: 'get', path: '/excel-uploads', requestPath: '/excel-uploads' }, + { method: 'get', path: '/excel-uploads/:filename/packages', requestPath: '/excel-uploads/example.xlsx/packages' }, + { method: 'get', path: '/excel-mapping/:jobId', requestPath: '/excel-mapping/job-1' }, + { method: 'post', path: '/excel-standard-preview', requestPath: '/excel-standard-preview' }, + { method: 'get', path: '/excel-validation/:jobId', requestPath: '/excel-validation/job-1' }, + { method: 'post', path: '/history-search', requestPath: '/history-search' }, + { method: 'post', path: '/combined-history-search', requestPath: '/combined-history-search' }, + { method: 'get', path: '/components', requestPath: '/components' }, + { method: 'get', path: '/statistics', requestPath: '/statistics' }, + { method: 'get', path: '/export', requestPath: '/export' }, + { method: 'get', path: '/templates', requestPath: '/templates' }, + { method: 'get', path: '/management/:id', requestPath: '/management/7' }, + { method: 'get', path: '/custom-tasks', requestPath: '/custom-tasks' }, + { method: 'get', path: '/:id/work-description', requestPath: '/7/work-description' }, + { method: 'get', path: '/:id/related', requestPath: '/7/related' }, + { method: 'get', path: '/custom-packages', requestPath: '/custom-packages' } +]; +const READ_LIKE_POST_PATHS = new Set([ + '/excel-standard-preview', + '/history-search', + '/combined-history-search' +]); + +const buildApp = () => { + const app = express(); + app.use(express.json()); + app.use(BASE_PATH, smartPackagesRoutes); + return app; +}; + +const authHeader = username => ( + `Bearer ${jwt.sign({ id: 1, username }, process.env.JWT_ACCESS_SECRET)}` +); + +const routeKey = ({ method, path }) => `${method.toUpperCase()} ${path}`; + +const inventoryReadRoutes = () => smartPackagesRoutes.stack + .filter(layer => layer.route) + .flatMap(layer => Object.keys(layer.route.methods) + .filter(method => method === 'get' || (method === 'post' && READ_LIKE_POST_PATHS.has(layer.route.path))) + .map(method => ({ method, path: layer.route.path }))) + .sort((left, right) => routeKey(left).localeCompare(routeKey(right))); + +const findRoute = ({ method, path }) => smartPackagesRoutes.stack.find(layer => ( + layer.route?.path === path && layer.route.methods[method] +)); + +const runAuthChain = (route, authorization) => { + const middleware = findRoute(route).route.stack.slice(0, -1).map(layer => layer.handle); + const req = { headers: { authorization } }; + const res = { + locals: {}, + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + }; + + return new Promise((resolve, reject) => { + let index = 0; + const next = error => { + if (error) return reject(error); + if (index === middleware.length) return resolve({ req, res }); + const handler = middleware[index++]; + try { + return handler(req, res, next); + } catch (caught) { + return reject(caught); + } + }; + next(); + }); +}; + +describe('Smart Package route authorization inventory', () => { + afterEach(() => { + delete global.smartPackageManagementService; + }); + + test('inventories every read route and allowlists only the active verified composition list/detail', () => { + const expected = [...PUBLIC_READ_ROUTES, ...INTERNAL_READ_ROUTES] + .map(({ method, path }) => ({ method, path })) + .sort((left, right) => routeKey(left).localeCompare(routeKey(right))); + + expect(inventoryReadRoutes()).toEqual(expected); + }); + + test.each(INTERNAL_READ_ROUTES)('$method $path rejects unauthenticated and ordinary JWT requests', async route => { + const app = buildApp(); + const unauthenticated = await request(app)[route.method](`${BASE_PATH}${route.requestPath}`).send({}); + const ordinaryJwt = await request(app)[route.method](`${BASE_PATH}${route.requestPath}`) + .set('Authorization', authHeader('ordinary-user')) + .send({}); + + expect(unauthenticated.status).toBe(401); + expect(ordinaryJwt.status).toBe(403); + }); + + test.each(INTERNAL_READ_ROUTES)('$method $path admits the configured operator through the complete auth chain', async route => { + const { req, res } = await runAuthChain(route, authHeader(process.env.AUTH_USERNAME)); + + expect(req.user).toEqual(expect.objectContaining({ username: process.env.AUTH_USERNAME })); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); + + test('keeps the active verified public composition list and detail unauthenticated and allowlisted', async () => { + const internalFields = { + created_by: 'excel-import', + excel_source_sheet: 'Internal prices', + validation_notes: 'internal review', + search_text: 'internal aggregate' + }; + global.smartPackageManagementService = { + getPackages: jest.fn().mockResolvedValue([{ + id: 7, + name: 'Tagrende', + validation_status: 'verified', + is_active: 1, + total_count: 1, + ...internalFields + }]), + getPackageDetails: jest.fn().mockResolvedValue({ + id: 7, + name: 'Tagrende', + validation_status: 'verified', + is_active: 1, + materials: [{ id: 11, name: 'Zink', raw_line: 'internal raw line' }], + ...internalFields + }) + }; + + const app = buildApp(); + const list = await request(app).get(BASE_PATH); + const detail = await request(app).get(`${BASE_PATH}/7`); + + expect(list.status).toBe(200); + expect(detail.status).toBe(200); + expect(list.body.packages).toEqual([{ id: 7, name: 'Tagrende', validation_status: 'verified' }]); + expect(detail.body.package).toEqual({ + id: 7, + name: 'Tagrende', + validation_status: 'verified', + materials: [{ id: 11, name: 'Zink' }] + }); + }); +}); + +// Route fixtures include the live account required by the shared authorization boundary. +beforeEach(() => { + jest.spyOn(require('../src/services/userService'), 'findByUsername').mockImplementation(async username => ({ id: 1, username, role: 'admin' })); +}); +afterEach(() => jest.restoreAllMocks()); diff --git a/backend/__tests__/smartPackagesRecalculate.test.js b/backend/__tests__/smartPackagesRecalculate.test.js index 6de4fec..bce5036 100644 --- a/backend/__tests__/smartPackagesRecalculate.test.js +++ b/backend/__tests__/smartPackagesRecalculate.test.js @@ -1,8 +1,12 @@ const express = require('express'); +const fs = require('fs'); +const path = require('path'); const request = require('supertest'); +const jwt = require('jsonwebtoken'); process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret'; process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret'; +process.env.AUTH_USERNAME = 'test-user'; // Mock uuid to avoid ESM import issues under Jest jest.mock('uuid', () => ({ v4: () => 'test-corr-id-uuid' })); @@ -30,6 +34,7 @@ jest.mock('../src/services/roofGeometryService', () => { const correlationId = require('../src/middleware/correlationId'); const { errorHandler } = require('../src/middleware/errorHandler'); +const AiFeatureFlagService = require('../src/services/aiFeatureFlagService'); const smartPackagesRoutes = require('../src/routes/smartPackagesRoutes'); const buildApp = () => { @@ -41,12 +46,281 @@ const buildApp = () => { return app; }; +const authHeader = (username = process.env.AUTH_USERNAME) => ( + `Bearer ${jwt.sign({ id: 1, username }, process.env.JWT_ACCESS_SECRET)}` +); + +describe('GET /api/smart-packages management boundary', () => { + afterEach(() => { + delete global.smartPackageManagementService; + }); + + test('keeps the ordinary package endpoint active and verified even when visibility is overridden', async () => { + const getPackages = jest.fn().mockResolvedValue([{ id: 1, total_count: 1 }]); + global.smartPackageManagementService = { getPackages }; + const response = await request(buildApp()) + .get('/api/smart-packages?includeInactive=true&validationStatus=needs_review&limit=12'); + expect(response.status).toBe(200); + expect(getPackages).toHaveBeenCalledWith(expect.objectContaining({ + includeInactive: false, + validationStatus: 'verified' + })); + }); + + test('serves an unauthenticated composition allowlist without internal catalog provenance', async () => { + const getPackages = jest.fn().mockResolvedValue([{ + id: 7, + name: 'Tagrende', + description: 'Levering og montage', + category: 'Tagrender', + package_type: 'component', + unit: 'løbende m', + unit_price: '543.18', + geometry_basis: 'roof_sides_x_length', + material_count: 1, + task_count: 1, + total_count: 1, + search_text: 'SKU-SECRET imported raw child text', + created_by: 'excel-import', + excel_source_sheet: 'Intern prisliste', + excel_source_row: 42, + validation_notes: 'Kun til intern kontrol', + validated_at: '2026-09-20T00:00:00.000Z', + is_active: 1, + raw_line: 'rå importlinje' + }]); + global.smartPackageManagementService = { getPackages }; + + const response = await request(buildApp()).get('/api/smart-packages?search=tagrende'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + success: true, + packages: [{ + id: 7, + name: 'Tagrende', + description: 'Levering og montage', + category: 'Tagrender', + package_type: 'component', + unit: 'løbende m', + unit_price: '543.18', + geometry_basis: 'roof_sides_x_length', + material_count: 1, + task_count: 1 + }], + total: 1, + limit: 50, + offset: 0 + }); + expect(getPackages).toHaveBeenCalledWith(expect.objectContaining({ + search: 'tagrende', includeInactive: false, validationStatus: 'verified' + })); + }); + + test('requires authentication for archived package management', async () => { + global.smartPackageManagementService = { getPackages: jest.fn() }; + const response = await request(buildApp()).get('/api/smart-packages/management?limit=12'); + expect(response.status).toBe(401); + }); + + test('opens inactive details only through the configured operator management route', async () => { + const getPackageDetails = jest.fn().mockResolvedValue({ id: 9, is_active: 0 }); + global.smartPackageManagementService = { getPackageDetails }; + const unauthenticated = await request(buildApp()).get('/api/smart-packages/management/9'); + expect(unauthenticated.status).toBe(401); + const ordinaryJwt = await request(buildApp()) + .get('/api/smart-packages/management/9') + .set('Authorization', authHeader('other-user')); + expect(ordinaryJwt.status).toBe(403); + const authenticated = await request(buildApp()) + .get('/api/smart-packages/management/9') + .set('Authorization', authHeader()); + expect(authenticated.status).toBe(200); + expect(getPackageDetails).toHaveBeenCalledWith('9', { includeInactive: true }); + }); + + test('serves unauthenticated public details through a deep composition allowlist', async () => { + const getPackageDetails = jest.fn().mockResolvedValue({ + id: 7, + name: 'Tagrende', + description: 'Levering og montage', + category: 'Tagrender', + package_type: 'component', + validation_status: 'verified', + version: 3, + is_active: 1, + created_by: 'haandvaerkpriser-import', + excel_source_sheet: 'Intern prisliste', + validation_notes: 'intern note', + search_text: 'raw aggregate', + materials: [{ + id: 11, package_id: 7, material_id: 99, material_name: 'Zinktagrende', name: 'Zinktagrende', + material_category: 'Tagrender', quantity: 2, unit: 'løbende m', unit_price: 245, unitPrice: 245, + price: 245, geometry_multiplier: 'eaves', base_quantity: 1, waste_factor: 1.1, + raw_line: 'hemmelig rå række', excel_source_sheet: 'Tag', excel_source_row: 12, + excel_raw_data: JSON.stringify({ + componentType: 'holder', + lengthPerPieceMeters: 3, + physicalQuantityPerPrimary: 3, + piecesPerPurchaseUnit: 2, + physicalUnit: 'stk', + secret: true + }) + }], + projectLines: [{ + id: 11, package_id: 7, material_id: 99, material_name: 'Zinktagrende', name: 'Zinktagrende', + material_category: 'Tagrender', quantity: 2, unit: 'løbende m', unit_price: 245, + unitPrice: 245, price: 245, isRental: false, raw_line: 'hemmelig rå række' + }], + tasks: [{ + id: 21, package_id: 7, name: 'Montage', description: 'Montér tagrende', hours: 2, rate: 600, + task_order: 1, time_unit: 'per_meter', time_per_unit: 0.4, raw_line: 'hemmelig opgaverække', + excel_source_row: 13, + steps: [{ id: 31, task_id: 21, title: 'Fastgør', minutes: 15, step_order: 1, created_at: 'internal' }] + }], + installation_manuals: [{ id: 55, internal_path: '/secret' }], + usage_count: 9, + totalMaterialPrice: 490, + totalHours: 2, + totalLaborCost: 1200, + totalPrice: 1690 + }); + global.smartPackageManagementService = { getPackageDetails }; + + const response = await request(buildApp()).get('/api/smart-packages/7'); + + expect(response.status).toBe(200); + expect(response.body.package).toEqual({ + id: 7, + name: 'Tagrende', + description: 'Levering og montage', + category: 'Tagrender', + package_type: 'component', + composition_type: 'reference_service', + validation_status: 'verified', + version: 3, + materials: [{ + id: 11, material_id: 99, material_name: 'Zinktagrende', name: 'Zinktagrende', + material_category: 'Tagrender', quantity: 2, unit: 'løbende m', unit_price: 245, + unitPrice: 245, price: 245, geometry_multiplier: 'eaves', base_quantity: 1, waste_factor: 1.1, + componentType: 'holder', lengthPerPieceMeters: 3, physicalQuantityPerPrimary: 3, + piecesPerPurchaseUnit: 2, physicalUnit: 'stk' + }], + projectLines: [{ + id: 11, material_id: 99, material_name: 'Zinktagrende', name: 'Zinktagrende', + material_category: 'Tagrender', quantity: 2, unit: 'løbende m', unit_price: 245, + unitPrice: 245, price: 245, isRental: false + }], + tasks: [{ + id: 21, name: 'Montage', description: 'Montér tagrende', hours: 2, rate: 600, + task_order: 1, time_unit: 'per_meter', time_per_unit: 0.4, + steps: [{ id: 31, title: 'Fastgør', minutes: 15, step_order: 1 }] + }], + totalMaterialPrice: 490, + totalHours: 2, + totalLaborCost: 1200, + totalPrice: 1690 + }); + }); + + test('denies active but unverified details on the public route', async () => { + const getPackageDetails = jest.fn().mockResolvedValue({ + id: 10, is_active: 1, validation_status: 'needs_review' + }); + global.smartPackageManagementService = { getPackageDetails }; + + const response = await request(buildApp()).get('/api/smart-packages/10'); + + expect(response.status).toBe(404); + }); + + test.each([ + '/management', + '/management/9', + '/categories', + '/review-queue', + '/integrity-report', + '/statistics', + '/export' + ])('denies ordinary JWT access to management read %s', async routePath => { + global.smartPackageManagementService = { + getPackages: jest.fn(), + getPackageDetails: jest.fn(), + getPackageCategories: jest.fn() + }; + + const response = await request(buildApp()) + .get(`/api/smart-packages${routePath}`) + .set('Authorization', authHeader('other-user')); + + expect(response.status).toBe(403); + }); + + test('denies blocked package details on the public route', async () => { + const getPackageDetails = jest.fn().mockResolvedValue({ id: 9, is_active: 0, validation_status: 'blocked' }); + global.smartPackageManagementService = { getPackageDetails }; + + const response = await request(buildApp()).get('/api/smart-packages/9'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ success: false, error: 'Smart pakke ikke fundet' }); + }); + + test.each([ + '?limit=abc', + '?limit=0', + '?offset=-1', + '?offset=12oops' + ])('rejects malformed pagination %s', async (query) => { + global.smartPackageManagementService = { getPackages: jest.fn() }; + const response = await request(buildApp()).get(`/api/smart-packages${query}`); + expect(response.status).toBe(400); + expect(response.body).toEqual({ success: false, error: 'Ugyldig paginering' }); + }); + + test('applies a bounded default limit when only offset is supplied', async () => { + const getPackages = jest.fn().mockResolvedValue([]); + global.smartPackageManagementService = { getPackages }; + const response = await request(buildApp()).get('/api/smart-packages?offset=12'); + expect(response.status).toBe(200); + expect(getPackages.mock.calls[0][0]).toMatchObject({ limit: 50, offset: 12 }); + }); + + test('returns the filtered total when an authenticated page is empty', async () => { + const getPackages = jest.fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 1, total_count: 654 }]); + global.smartPackageManagementService = { getPackages }; + const response = await request(buildApp()) + .get('/api/smart-packages/management?limit=12&offset=660') + .set('Authorization', authHeader()); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ total: 654, packages: [], limit: 12, offset: 660 }); + expect(getPackages.mock.calls[0][0]).toMatchObject({ includeInactive: true, limit: 12, offset: 660 }); + expect(getPackages.mock.calls[1][0]).toMatchObject({ includeInactive: true, limit: 1, offset: 0 }); + }); +}); + describe('POST /api/smart-packages/recalculate', () => { + test('blocks unauthenticated and non-operator recalculation before writes', async () => { + const unauthenticated = await request(buildApp()) + .post('/api/smart-packages/recalculate') + .send({ projectId: 123, geometry: { total_area: 100 } }); + const nonOperator = await request(buildApp()) + .post('/api/smart-packages/recalculate') + .set('Authorization', authHeader('other-user')) + .send({ projectId: 123, geometry: { total_area: 100 } }); + + expect(unauthenticated.status).toBe(401); + expect(nonOperator.status).toBe(403); + }); + test('returns recalculated estimates with correlation ID', async () => { const app = buildApp(); const res = await request(app) .post('/api/smart-packages/recalculate') .set('X-Correlation-ID', 'test-corr-id-1234') + .set('Authorization', authHeader()) .send({ projectId: 123, geometry: { total_area: 100, length_main: 20 } }); expect(res.status).toBe(200); @@ -63,6 +337,7 @@ describe('POST /api/smart-packages/recalculate', () => { const app = buildApp(); const res = await request(app) .post('/api/smart-packages/recalculate') + .set('Authorization', authHeader()) .send({ geometry: { total_area: 50 } }); expect(res.status).toBe(400); @@ -89,7 +364,8 @@ describe('POST /api/smart-packages/:id/tasks', () => { const res = await request(buildApp()) .post('/api/smart-packages/999/tasks') - .send({ name: 'Ny opgave' }); + .set('Authorization', authHeader()) + .send({ name: 'Ny opgave', expectedVersion: 7 }); expect(res.status).toBe(404); expect(res.body).toEqual({ @@ -111,7 +387,8 @@ describe('POST /api/smart-packages/:id/tasks', () => { const res = await request(buildApp()) .post('/api/smart-packages/136/tasks') - .send({ name: 'Ny opgave' }); + .set('Authorization', authHeader()) + .send({ name: 'Ny opgave', expectedVersion: 7 }); expect(res.status).toBe(500); expect(res.body).toEqual({ @@ -132,7 +409,8 @@ describe('POST /api/smart-packages/:id/tasks', () => { const res = await request(buildApp()) .post('/api/smart-packages/136/tasks') - .send({ name: 'Ny opgave' }); + .set('Authorization', authHeader()) + .send({ name: 'Ny opgave', expectedVersion: 7 }); expect(res.status).toBe(409); expect(res.body).toEqual({ @@ -142,3 +420,364 @@ describe('POST /api/smart-packages/:id/tasks', () => { expect(consoleError).not.toHaveBeenCalled(); }); }); + +describe('Smart Package catalog mutation authorization', () => { + afterEach(() => { + delete global.smartPackageManagementService; + delete global.databaseService; + jest.restoreAllMocks(); + }); + + test.each([ + ['post', '/', { name: 'Pakke', description: 'Beskrivelse' }], + ['put', '/1', { name: 'Pakke' }], + ['delete', '/1', {}], + ['post', '/1/verify', {}], + ['post', '/components', { name: 'Komponent', category: 'Tag' }], + ['put', '/components/1', { name: 'Komponent', category: 'Tag' }], + ['delete', '/components/1', {}], + ['post', '/1/tasks', { name: 'Task' }], + ['put', '/tasks/1', { name: 'Task' }], + ['delete', '/tasks/1', {}], + ['put', '/1/reorder-tasks', { taskOrderings: [] }], + ['post', '/custom-tasks', { packageId: 1, name: 'Task', timeUnit: 'hour', timePerUnit: 1 }], + ['post', '/tasks/1/steps', { title: 'Step' }], + ['put', '/steps/1', { title: 'Step' }], + ['delete', '/steps/1', {}], + ['put', '/tasks/1/reorder-steps', { stepOrderings: [] }], + ['post', '/import', { packages: [] }], + ['post', '/excel-import', { confirmed: true }], + ['post', '/create-kran-arbejde', {}], + ['post', '/create-byggepladshegn', {}], + ['post', '/1/add-to-project', { projectId: 2 }], + ['post', '/1/calculate-with-geometry', { geometry: { length: 10 } }] + ])('blocks unauthenticated %s %s before mutation', async (method, routePath, body) => { + const response = await request(buildApp())[method](`/api/smart-packages${routePath}`).send(body); + expect(response.status).toBe(401); + }); + + test('blocks an authenticated non-operator before package mutation', async () => { + const createPackage = jest.fn(); + global.smartPackageManagementService = { createPackage }; + const response = await request(buildApp()) + .post('/api/smart-packages') + .set('Authorization', authHeader('other-user')) + .send({ name: 'Pakke', description: 'Beskrivelse' }); + + expect(response.status).toBe(403); + expect(createPackage).not.toHaveBeenCalled(); + }); + + test('allows the configured operator to create a package', async () => { + const createPackage = jest.fn().mockResolvedValue({ id: 11 }); + global.smartPackageManagementService = { createPackage }; + const response = await request(buildApp()) + .post('/api/smart-packages') + .set('Authorization', authHeader()) + .send({ name: 'Pakke', description: 'Beskrivelse' }); + + expect(response.status).toBe(200); + expect(createPackage).toHaveBeenCalledTimes(1); + }); + + test('allows the configured operator to create a component', async () => { + const execute = jest.fn().mockResolvedValue([{ insertId: 7 }]); + global.databaseService = { pool: { execute } }; + const response = await request(buildApp()) + .post('/api/smart-packages/components') + .set('Authorization', authHeader()) + .send({ name: 'Komponent', category: 'Tag' }); + + expect(response.status).toBe(200); + expect(execute).toHaveBeenCalledTimes(1); + }); + + test('calculates for the authenticated operator with roof covering area and canonical edges', async () => { + const calculatePackageMaterialsWithGeometry = jest.fn().mockResolvedValue({ + package: { id: 917, name: 'Komplet tagskift', description: 'Tag', category: 'Tag', hourly_rate: 580 }, + materials: [{ quantity: 110.85, price: 2 }], + laborHours: 10, + geometry_used: { roofArea: 110.85, eaves: 24, ridge: 12 } + }); + global.smartPackageManagementService = { calculatePackageMaterialsWithGeometry }; + + const response = await request(buildApp()) + .post('/api/smart-packages/917/calculate-with-geometry') + .set('Authorization', authHeader()) + .send({ + geometry: { + total_area: 96, + roof_covering_area: 110.85, + length_main: 12, + width_main: 8, + edges_json: { ridge: 12, eaves: 24 } + }, + materialMarkupPercent: 0, + finalReviewMarkupPercent: 0 + }); + + expect(response.status).toBe(200); + expect(calculatePackageMaterialsWithGeometry).toHaveBeenCalledWith('917', expect.objectContaining({ + total_area: 96, + roof_covering_area: 110.85 + }), 0); + expect(response.body.calculation_info.geometry_used).toMatchObject({ + tagflade_m2: 110.85, + tagfod_m: 24, + rygning_m: 12 + }); + }); + + test('allows the configured operator to mutate tasks, steps, and project state', async () => { + const addTaskToPackage = jest.fn().mockResolvedValue({ taskId: 3, order: 1 }); + const addStepToTask = jest.fn().mockResolvedValue({ stepId: 4, order: 1 }); + const addPackageToProject = jest.fn().mockResolvedValue({ projectPackageId: 5 }); + global.smartPackageManagementService = { addTaskToPackage, addStepToTask, addPackageToProject }; + const app = buildApp(); + + const task = await request(app).post('/api/smart-packages/1/tasks') + .set('Authorization', authHeader()).send({ name: 'Task', expectedVersion: 7 }); + const step = await request(app).post('/api/smart-packages/tasks/3/steps') + .set('Authorization', authHeader()).send({ title: 'Step', expectedVersion: 8 }); + const project = await request(app).post('/api/smart-packages/1/add-to-project') + .set('Authorization', authHeader()).send({ projectId: 2 }); + + expect([task.status, step.status, project.status]).toEqual([200, 200, 200]); + expect(addTaskToPackage).toHaveBeenCalledTimes(1); + expect(addStepToTask).toHaveBeenCalledTimes(1); + expect(addPackageToProject).toHaveBeenCalledTimes(1); + }); + + test('validates and forwards expectedVersion for management package updates', async () => { + const updatePackage = jest.fn().mockResolvedValue({ success: true }); + global.smartPackageManagementService = { updatePackage }; + const app = buildApp(); + + const missing = await request(app).put('/api/smart-packages/1') + .set('Authorization', authHeader()).send({ name: 'Pakke' }); + const malformed = await request(app).put('/api/smart-packages/1') + .set('Authorization', authHeader()).send({ name: 'Pakke', expectedVersion: '7oops' }); + const nonOperator = await request(app).put('/api/smart-packages/1') + .set('Authorization', authHeader('other-user')).send({ name: 'Pakke', expectedVersion: 7 }); + const valid = await request(app).put('/api/smart-packages/1') + .set('Authorization', authHeader()).send({ name: 'Pakke', expectedVersion: 7 }); + + expect([missing.status, malformed.status, nonOperator.status, valid.status]).toEqual([400, 400, 403, 200]); + expect(updatePackage).toHaveBeenCalledTimes(1); + expect(updatePackage).toHaveBeenCalledWith('1', expect.objectContaining({ expectedVersion: 7 })); + }); + + test('returns a sanitized 409 for a management version conflict', async () => { + global.smartPackageManagementService = { + updatePackage: jest.fn().mockRejectedValue(Object.assign( + new Error('Expected version 7 but found private row version 8'), + { status: 409, code: 'SMART_PACKAGE_VERSION_CONFLICT' } + )) + }; + const response = await request(buildApp()).put('/api/smart-packages/1') + .set('Authorization', authHeader()).send({ name: 'Pakke', expectedVersion: 7 }); + + expect(response.status).toBe(409); + expect(response.body).toEqual({ + success: false, + error: 'Smart Pakken er ændret. Genindlæs og prøv igen.', + code: 'SMART_PACKAGE_VERSION_CONFLICT' + }); + }); + + test('does not expose internal package mutation failures', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + global.smartPackageManagementService = { + createPackage: jest.fn().mockRejectedValue(new Error('ER_BAD_FIELD_ERROR private_schema.secret_column')) + }; + const response = await request(buildApp()) + .post('/api/smart-packages') + .set('Authorization', authHeader()) + .send({ name: 'Pakke', description: 'Beskrivelse' }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ success: false, error: 'Fejl ved oprettelse af smart pakke' }); + expect(JSON.stringify(response.body)).not.toContain('private_schema'); + }); + + test.each([ + ['post', '/1/tasks', 'addTaskToPackage', { name: 'Task' }], + ['put', '/tasks/21', 'updateTask', { name: 'Task' }], + ['delete', '/tasks/21', 'deleteTask', {}], + ['put', '/1/reorder-tasks', 'reorderTasks', { taskOrderings: [{ taskId: 21, order: 1 }] }], + ['post', '/custom-tasks', 'createCustomTask', { packageId: 1, name: 'Task', timeUnit: 'per_meter', timePerUnit: 1 }], + ['post', '/tasks/21/steps', 'addStepToTask', { title: 'Step' }], + ['put', '/steps/31', 'updateStep', { title: 'Step' }], + ['delete', '/steps/31', 'deleteStep', {}], + ['put', '/tasks/21/reorder-steps', 'reorderSteps', { stepOrderings: [{ stepId: 31, order: 1 }] }] + ])('requires and forwards exact expectedVersion for %s %s', async (method, routePath, serviceMethod, body) => { + const mutation = jest.fn().mockResolvedValue({ package: { id: 1, version: 8 }, version: 8 }); + global.smartPackageManagementService = { [serviceMethod]: mutation }; + const app = buildApp(); + + const missing = await request(app)[method](`/api/smart-packages${routePath}`) + .set('Authorization', authHeader()).send(body); + const malformed = await request(app)[method](`/api/smart-packages${routePath}`) + .set('Authorization', authHeader()).send({ ...body, expectedVersion: '7oops' }); + const valid = await request(app)[method](`/api/smart-packages${routePath}`) + .set('Authorization', authHeader()).send({ ...body, expectedVersion: 7 }); + + expect([missing.status, malformed.status, valid.status]).toEqual([400, 400, 200]); + expect(mutation).toHaveBeenCalledTimes(1); + const args = mutation.mock.calls[0]; + if (serviceMethod === 'createCustomTask') expect(args[0]).toEqual(expect.objectContaining({ expectedVersion: 7 })); + else expect(args.at(-1)).toBe(7); + expect(valid.body).toMatchObject({ success: true, package: { id: 1, version: 8 }, version: 8 }); + }); + + test('returns a sanitized conflict for stale package-child mutations', async () => { + global.smartPackageManagementService = { + updateTask: jest.fn().mockRejectedValue(Object.assign(new Error('private current version is 8'), { + status: 409, + code: 'SMART_PACKAGE_VERSION_CONFLICT' + })) + }; + const response = await request(buildApp()).put('/api/smart-packages/tasks/21') + .set('Authorization', authHeader()).send({ name: 'Task', expectedVersion: 7 }); + + expect(response.status).toBe(409); + expect(response.body).toEqual({ + success: false, + error: 'Smart Pakken er ændret. Genindlæs og prøv igen.', + code: 'SMART_PACKAGE_VERSION_CONFLICT' + }); + }); + + test('routes existing package imports through the versioned aggregate service', async () => { + const execute = jest.fn().mockResolvedValue([[{ id: 1 }]]); + const updatePackage = jest.fn().mockResolvedValue({ id: 1, version: 8 }); + global.databaseService = { pool: { execute } }; + global.smartPackageManagementService = { updatePackage }; + const app = buildApp(); + + const batch = await request(app).post('/api/smart-packages/import') + .set('Authorization', authHeader()).send({ + packages: [ + { name: 'First', expectedVersion: 7 }, + { name: 'Second', expectedVersion: 4 } + ] + }); + const missing = await request(app).post('/api/smart-packages/import') + .set('Authorization', authHeader()).send({ packages: [{ name: 'Existing', tasks: [] }] }); + const valid = await request(app).post('/api/smart-packages/import') + .set('Authorization', authHeader()).send({ packages: [{ name: 'Existing', tasks: [], expectedVersion: 7 }] }); + + expect(batch.status).toBe(400); + expect(missing.status).toBe(400); + expect(valid.status).toBe(200); + expect(updatePackage).toHaveBeenCalledTimes(1); + expect(updatePackage).toHaveBeenCalledWith(1, expect.objectContaining({ expectedVersion: 7, tasks: [] })); + expect(valid.body.packages).toEqual([{ id: 1, version: 8 }]); + }); + + test.each(['/excel-upload', '/excel-map', '/excel-validate'])( + 'allows only the configured operator to reach POST %s', + async (routePath) => { + if (routePath === '/excel-map') { + jest.spyOn(AiFeatureFlagService.prototype, 'isEnabled').mockResolvedValue(true); + } + const app = buildApp(); + const unauthenticated = await request(app).post(`/api/smart-packages${routePath}`).send({}); + const nonOperator = await request(app).post(`/api/smart-packages${routePath}`) + .set('Authorization', authHeader('other-user')) + .send({}); + const operator = await request(app).post(`/api/smart-packages${routePath}`) + .set('Authorization', authHeader()) + .send({}); + + expect(unauthenticated.status).toBe(401); + expect(nonOperator.status).toBe(403); + expect(operator.status).toBe(400); + } + ); + + test.each(['/excel-mapping/job-1', '/excel-validation/job-1'])( + 'allows only the configured operator to poll GET %s', + async (routePath) => { + const app = buildApp(); + const unauthenticated = await request(app).get(`/api/smart-packages${routePath}`); + const nonOperator = await request(app).get(`/api/smart-packages${routePath}`) + .set('Authorization', authHeader('other-user')); + const operator = await request(app).get(`/api/smart-packages${routePath}`) + .set('Authorization', authHeader()); + expect(unauthenticated.status).toBe(401); + expect(nonOperator.status).toBe(403); + expect(operator.status).toBe(404); + } + ); + + test.each([ + ['/excel-map', 'Excel-mapping kunne ikke startes'], + ['/excel-validate', 'Excel-validering kunne ikke startes'] + ])('sanitizes errors from POST %s', async (routePath, safeError) => { + if (routePath === '/excel-map') { + jest.spyOn(AiFeatureFlagService.prototype, 'isEnabled').mockResolvedValue(true); + } + const response = await request(buildApp()) + .post(`/api/smart-packages${routePath}`) + .set('Authorization', authHeader()) + .send({ filename: '../private-schema.xlsx' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ success: false, error: safeError }); + expect(JSON.stringify(response.body)).not.toContain('filnavn'); + }); +}); + +describe('project_labor one-row schema contract', () => { + test.each([ + ['bootstrap SQL', path.join(__dirname, '../sql/customer_project_system.sql')], + ['workspace migration', path.join(__dirname, '../../database/migrations/20260904_smart_package_workspace.sql')], + ['runtime bootstrap', path.join(__dirname, '../src/services/databaseService.js')] + ])('%s safely deduplicates and enforces a unique project_id', (_label, filename) => { + const source = fs.readFileSync(filename, 'utf8'); + expect(source).toMatch(/DELETE[\s\S]+FROM project_labor[\s\S]+project_id/i); + expect(source).toMatch(/unique_project_labor[\s\S]+project_id/i); + }); +}); + +describe('management writes return the complete package', () => { + afterEach(() => { delete global.smartPackageManagementService; }); + test.each(['post', 'put'])('%s forwards contract fields and returns saved package', async method => { + const body = { name: 'Pakke', description: 'Test', expectedVersion: 7, is_active: 0, + unit: 'm', unit_price: 12, price_per_unit: 13, standard_price: 14, + price_source: 'manual', price_source_value: 'Liste', price_basis_note: 'Note', + geometry_basis: 'eaves', geometry_factor: 2, default_count: 1, default_quantity: 4, + replacement_scope: 'complete_roof_replacement', compatible_roof_materials: ['tegl'], + allowed_roof_forms: ['gable'], min_pitch_degrees: 20, max_pitch_degrees: 45, + pitch_verification_status: 'verified', pitch_review_required: false, + validation_status: 'needs_review', package_type: 'component', materials: [], tasks: [] }; + const saved = { ...body, id: 1, version: 8 }; + const write = jest.fn().mockResolvedValue(saved); + global.smartPackageManagementService = { createPackage: write, updatePackage: write }; + const response = await request(buildApp())[method](`/api/smart-packages${method === 'put' ? '/management/1' : ''}`) + .set('Authorization', authHeader()).send(body); + expect(response.status).toBe(200); + expect(write.mock.calls[0][method === 'put' ? 1 : 0]).toMatchObject(body); + expect(response.body.package).toEqual(saved); + }); + test('duplicate requires the configured operator and accepts only a name override', async () => { + const duplicatePackage = jest.fn().mockResolvedValue({ id: 2, version: 1, is_active: 0 }); + global.smartPackageManagementService = { duplicatePackage }; + const url = '/api/smart-packages/management/1/duplicate'; + expect((await request(buildApp()).post(url).send({})).status).toBe(401); + expect((await request(buildApp()).post(url).set('Authorization', authHeader('other')).send({})).status).toBe(403); + expect((await request(buildApp()).post(url).set('Authorization', authHeader()).send({ is_active: 1 })).status).toBe(400); + const response = await request(buildApp()).post(url).set('Authorization', authHeader()).send({ name: 'Kopi' }); + expect(response.status).toBe(200); + expect(response.body.package).toMatchObject({ id: 2, version: 1, is_active: 0 }); + expect(duplicatePackage).toHaveBeenCalledTimes(1); + expect(duplicatePackage).toHaveBeenCalledWith('1', { name: 'Kopi' }, 'test-user'); + }); +}); + +// Route fixtures include the live account required by the shared authorization boundary. +beforeEach(() => { + jest.spyOn(require('../src/services/userService'), 'findByUsername').mockImplementation(async username => ({ id: 1, username, role: 'admin' })); +}); +afterEach(() => jest.restoreAllMocks()); diff --git a/backend/__tests__/usersAuthorization.test.js b/backend/__tests__/usersAuthorization.test.js new file mode 100644 index 0000000..8bd22d4 --- /dev/null +++ b/backend/__tests__/usersAuthorization.test.js @@ -0,0 +1,49 @@ +process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'users-authorization-access-test-secret'; +process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'users-authorization-refresh-test-secret'; + +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const userService = require('../src/services/userService'); +const usersRouter = require('../src/routes/users'); + +const app = express(); +app.use(express.json()); +app.use('/api/users', usersRouter); + +const token = (claims = {}) => `Bearer ${jwt.sign({ id: 7, username: 'operator', role: 'admin', ...claims }, process.env.JWT_ACCESS_SECRET)}`; + +afterEach(() => jest.restoreAllMocks()); + +test.each([ + ['deleted account', null], + ['demoted account', { id: 7, username: 'operator', role: 'user' }], + ['different account id', { id: 8, username: 'operator', role: 'admin' }] +])('rejects an otherwise valid admin JWT for a %s', async (_label, current) => { + jest.spyOn(userService, 'findByUsername').mockResolvedValue(current); + const list = jest.spyOn(userService, 'listUsers').mockResolvedValue([]); + + const response = await request(app).get('/api/users').set('Authorization', token()); + + expect(response.status).toBe(403); + expect(list).not.toHaveBeenCalled(); +}); + +test('uses the current database role rather than the JWT role for privileged requests', async () => { + jest.spyOn(userService, 'findByUsername').mockResolvedValue({ id: 7, username: 'operator', role: 'admin' }); + jest.spyOn(userService, 'listUsers').mockResolvedValue([{ id: 7, username: 'operator', role: 'admin' }]); + + const response = await request(app).get('/api/users') + .set('Authorization', token({ role: 'user' })); + + expect(response.status).toBe(200); + expect(response.body.users).toHaveLength(1); +}); + +test('fails closed when current account state cannot be loaded', async () => { + jest.spyOn(userService, 'findByUsername').mockRejectedValue(new Error('database unavailable')); + + const response = await request(app).get('/api/users').set('Authorization', token()); + + expect(response.status).toBe(503); +}); diff --git a/backend/migrations/20260910_ordrestyring_offer_operations.sql b/backend/migrations/20260910_ordrestyring_offer_operations.sql new file mode 100644 index 0000000..214502b --- /dev/null +++ b/backend/migrations/20260910_ordrestyring_offer_operations.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS ordrestyring_offer_operations ( + idempotency_key VARCHAR(255) NOT NULL PRIMARY KEY, + project_id INT NOT NULL, + snapshot_signature VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + customer_id BIGINT NULL, + offer_id BIGINT NULL, + state_json JSON NOT NULL, + lease_owner VARCHAR(64) NULL, + lease_expires_at DATETIME(6) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_ordrestyring_offer_operation_project_signature (project_id, snapshot_signature), + INDEX idx_ordrestyring_offer_operation_project (project_id), + INDEX idx_ordrestyring_offer_operation_lease (lease_expires_at), + INDEX idx_ordrestyring_offer_operation_customer (customer_id), + INDEX idx_ordrestyring_offer_operation_offer (offer_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/routes/offers.js b/backend/routes/offers.js index 8b5ee12..2a0207d 100644 --- a/backend/routes/offers.js +++ b/backend/routes/offers.js @@ -1,15 +1,23 @@ /** - * Offers Routes - GraphQL Integration - * Handles offer creation using Ordrestyring GraphQL API + * Authenticated Ordrestyring offer boundary. + * Roof-replacement offers are always rebuilt from a canonical server snapshot. */ - const express = require('express'); -const router = express.Router(); -const graphqlClient = require('../src/services/graphqlClient'); +const { randomUUID } = require('crypto'); +const defaultDatabaseService = require('../src/services/databaseService'); +const OrdrestyringOfferOperationStateStore = require('../src/services/ordrestyringOfferOperationStateStore'); +const QuoteRealismService = require('../src/services/quoteRealismService'); +const { verifyToken, requireConfiguredOperator } = require('../src/middleware/auth'); const { CREATE_OFFER_MUTATION, CREATE_OFFER_LINES_MUTATION } = require('../src/graphql/mutations/createOffer'); +const { + OfferNormalizationError, + cents, + lineNetCents, + buildOrdrestyringOfferLines +} = require('../src/services/ordrestyringOfferNormalizationService'); const CREATE_CUSTOMER_MUTATION = ` mutation CreateCustomer($input: CreateCustomerInput!) { @@ -21,186 +29,560 @@ const CREATE_CUSTOMER_MUTATION = ` } `; +class OfferBoundaryError extends Error { + constructor(message, status = 400, code = 'INVALID_OFFER_SUBMISSION') { + super(message); + this.name = 'OfferBoundaryError'; + this.status = status; + this.code = code; + } +} + function parseAddress(address) { - if (!address) { - return { - address: '', - postalCode: '', - city: '' - }; - } + if (!address) return { address: '', postalCode: '', city: '' }; + const match = String(address).match(/^(.+?),?\s*(\d{4})\s+(.+)$/); + if (!match) return { address: String(address), postalCode: '', city: '' }; + return { address: match[1].trim(), postalCode: match[2].trim(), city: match[3].trim() }; +} - const match = address.match(/^(.+?),?\s*(\d{4})\s+(.+)$/); - if (!match) { - return { - address, - postalCode: '', - city: '' - }; - } +const firstDefined = (...values) => values.find(value => value !== undefined && value !== null); + +function canonicalCustomerInfo(snapshot) { + const artifact = snapshot.artifact || snapshot; + const project = artifact.customerProject || artifact.project || {}; + const customer = snapshot.customer || project.customer || {}; return { - address: match[1].trim(), - postalCode: match[2].trim(), - city: match[3].trim() + customerId: firstDefined( + customer.customerId, customer.ordrestyringCustomerId, project.customerId, + project.customer_id, project.ordrestyring_customer_id + ), + name: typeof customer === 'string' + ? customer + : firstDefined(customer.name, project.customerName, project.customer_name, project.customer), + email: firstDefined(customer.email, project.customerEmail, project.customer_email), + phone: firstDefined(customer.phone, customer.phoneNumber, project.customerPhone, project.customer_phone), + address: firstDefined(customer.address, project.customerAddress, project.customer_address) }; } -function normalizePayload(body = {}) { - if (body.project && body.package) { - return { - customerInfo: { - name: body.project.customer || body.project.customer_name || '', - customerNumber: body.project.customerNumber || null, - email: body.project.customerEmail || null, - phone: body.project.customerPhone || null, - address: body.project.customerAddress || null, - customerId: body.project.customerId || null - }, - projectInfo: { - projectId: body.project.id || null, - title: body.project.name || 'Tilbud fra Tilbudsgiveren', - description: body.project.description || '', - reference: body.project.customerNumber - ? `TG-${body.project.customerNumber}-${body.project.id || Date.now()}` - : `TG-${body.project.id || Date.now()}`, - notes: body.geometry ? JSON.stringify(body.geometry) : '' - }, - materials: Array.isArray(body.package.materials) ? body.package.materials : [], - labor: Array.isArray(body.package.laborTasks) ? body.package.laborTasks : [], - totals: body.package.totals || {}, - notes: body.quote - ? `Tilbud gyldigt til ${body.quote.validUntil || 'ukendt'}` - : '' - }; +function canonicalReservationTexts(artifact = {}) { + if (artifact.reservations === undefined || artifact.reservations === null) return []; + if (!Array.isArray(artifact.reservations)) { + throw new OfferBoundaryError('Kanoniske forbehold skal være en liste', 422, 'INVALID_RESERVATIONS'); } + return artifact.reservations.map(reservation => { + const text = typeof reservation === 'string' ? reservation : reservation?.text; + if (typeof text !== 'string' || !text.trim()) { + throw new OfferBoundaryError('Et kanonisk forbehold mangler tekst', 422, 'INVALID_RESERVATION'); + } + return text.trim(); + }); +} +function canonicalProjectInfo(snapshot, projectId) { + const artifact = snapshot.artifact || snapshot; + const project = artifact.customerProject || artifact.project || {}; + const customerNumber = firstDefined(project.customerNumber, project.customer_number); + const quoteText = firstDefined(artifact.quoteText, snapshot.notes, project.notes, ''); + const reservations = canonicalReservationTexts(artifact); + const reservationNote = reservations.length > 0 + ? `Forbehold:\n${reservations.map(text => `- ${text}`).join('\n')}` + : ''; return { - customerInfo: body.customerInfo || {}, - projectInfo: body.projectInfo || {}, - materials: Array.isArray(body.materials) ? body.materials : [], - labor: Array.isArray(body.labor) ? body.labor : [], - totals: body.totals || {}, - notes: body.notes || '' + title: firstDefined(project.name, project.title, project.project_name, 'Tagudskiftning'), + description: firstDefined(project.description, project.project_description, ''), + reference: firstDefined(project.reference, customerNumber + ? `TG-${customerNumber}-${projectId}` + : `TG-${projectId}`), + notes: [quoteText, reservationNote].filter(Boolean).join('\n\n') }; } -async function resolveCustomerId(customerInfo = {}) { - if (customerInfo.customerId) { - return parseInt(customerInfo.customerId, 10); +async function resolveCustomerId(graphqlClient, customerInfo = {}) { + if (customerInfo.customerId !== undefined && customerInfo.customerId !== null) { + const existingId = Number.parseInt(customerInfo.customerId, 10); + if (!Number.isInteger(existingId)) { + throw new OfferBoundaryError('Ugyldigt eksisterende kunde-id', 422, 'INVALID_CUSTOMER_ID'); + } + return existingId; } const parsedAddress = parseAddress(customerInfo.address); - const customerInput = { - name: customerInfo.name || 'Ukendt kunde', - email: customerInfo.email || null, - phoneNumber: customerInfo.phone || null, - address: parsedAddress.address, - postalCode: parsedAddress.postalCode, - city: parsedAddress.city - }; - - const customerData = await graphqlClient.request(CREATE_CUSTOMER_MUTATION, { input: customerInput }); - return customerData.createCustomer.id; + const customerData = await graphqlClient.request(CREATE_CUSTOMER_MUTATION, { + input: { + name: customerInfo.name || 'Ukendt kunde', + email: customerInfo.email || null, + phoneNumber: customerInfo.phone || null, + address: parsedAddress.address, + postalCode: parsedAddress.postalCode, + city: parsedAddress.city + } + }); + const customerId = Number.parseInt(customerData?.createCustomer?.id, 10); + if (!Number.isInteger(customerId)) { + throw new OfferBoundaryError('Ordrestyring oprettede ikke kunden', 502, 'CUSTOMER_CREATION_FAILED'); + } + return customerId; } -function buildOfferLines(offerId, materials, labor) { - const taskId = 1; +const SIGNIFICANT_LINE_FIELDS = [ + 'description', 'quantity', 'unit', 'salesPrice', 'discount', 'productNumber', 'taskId', 'sortOrder' +]; - return [ - ...materials.map((material, index) => ({ - offerId, - taskId, - description: material.name || material.material_name || material.description || 'Materiale', - quantity: parseFloat(material.quantity) || 1, - productNumber: material.varenr || material.productNumber || '', - salesPrice: parseFloat(material.unitPrice || material.unit_price || material.price || 0), - discount: parseFloat(material.discount) || 0, - sortOrder: index + 1 - })), - ...labor.map((laborItem, index) => ({ - offerId, - taskId, - description: laborItem.description || laborItem.name || 'Arbejdstime', - quantity: parseFloat(laborItem.totalHours || laborItem.hours) || 1, - productNumber: 'LABOR', - salesPrice: parseFloat(laborItem.hourlyRate || laborItem.rate || 0), - discount: 0, - sortOrder: materials.length + index + 1 - })) - ]; +const lineFingerprint = line => JSON.stringify([ + String(line?.description || ''), + Number(line?.quantity), + String(line?.unit || ''), + Number(line?.quantity) === 0 ? String(Number(line?.salesPrice)) : lineNetCents(line), + Number(line?.discount), + String(line?.productNumber || ''), + Number(line?.taskId), + Number(line?.sortOrder) +]); + +function missingLines(desiredLines, createdLines) { + const unmatched = desiredLines.map((line, index) => ({ line, index, fingerprint: lineFingerprint(line) })); + for (const created of createdLines) { + const missingSignificantField = SIGNIFICANT_LINE_FIELDS.some( + field => created?.[field] === undefined || created?.[field] === null + ); + if (!created?.id || missingSignificantField) { + throw new OfferBoundaryError('Ordrestyring returnerede en ufuldstændig tilbudslinje', 502, 'PARTIAL_LINE_CREATION'); + } + const index = unmatched.findIndex(candidate => candidate.fingerprint === lineFingerprint(created)); + if (index === -1) { + throw new OfferBoundaryError( + 'Ordrestyring-linjer kan ikke afstemmes nøjagtigt med snapshot', + 502, + 'LINE_RECONCILIATION_FAILED' + ); + } + unmatched.splice(index, 1); + } + return unmatched.map(candidate => candidate.line); } -/** - * POST /api/ordrestyring/offers/create - * Create a new offer in Ordrestyring via GraphQL - * - * Supports both the legacy payload format and the FinalReview payload format. - */ -router.post('/create', async (req, res) => { +const deletionWasVerified = (result, offerId) => { + const deletion = result?.deleteOffer || result; + return Boolean( + deletion?.deleted === true && Number(deletion.id) === Number(offerId) + ); +}; + +async function runOfferOperation({ + graphqlClient, customerInfo, projectInfo, buildLines, initialState, saveState, expectedTotals +}) { + let state = { ...initialState }; + if (state.status === 'compensated') { + state = { ...state, status: 'customer_ready', offer: null, offerId: null, createdLines: [] }; + } + + const uncertainCreation = ( + (state.status === 'customer_creating' && !state.customerId) || + (state.status === 'offer_creating' && !state.offer?.id) || + state.status === 'compensation_pending' || + state.compensationStateUncertain === true || + state.customerStateUncertain === true || + state.offerStateUncertain === true + ); + if (uncertainCreation) { + const error = new OfferBoundaryError( + 'En tidligere Ordrestyring-mutation har ukendt resultat og må ikke gentages automatisk', + 503, + 'ORDRESTYRING_OPERATION_STATE_UNCERTAIN' + ); + state = { ...state, status: 'failed', + customerStateUncertain: state.customerStateUncertain || state.status === 'customer_creating', + offerStateUncertain: state.offerStateUncertain || state.status === 'offer_creating', + compensationStateUncertain: state.compensationStateUncertain || state.status === 'compensation_pending', + error: { code: error.code, message: error.message } }; + await saveState(state); + throw error; + } + try { - const { - customerInfo, - projectInfo, - materials, - labor, - totals, - notes - } = normalizePayload(req.body); + if (!state.customerId) { + if (customerInfo.customerId !== undefined && customerInfo.customerId !== null) { + state.customerId = await resolveCustomerId(graphqlClient, customerInfo); + } else { + state = { ...state, status: 'customer_creating', customerStateUncertain: true }; + await saveState(state); + state.customerId = await resolveCustomerId(graphqlClient, customerInfo); + state.customerStateUncertain = false; + } + state.status = 'customer_ready'; + await saveState(state); + } - console.log('Creating offer via GraphQL:', { - customer: customerInfo?.name, - customerNumber: customerInfo?.customerNumber, - materialCount: materials.length, - laborCount: labor.length, - total: totals?.total - }); + if (!state.offer?.id) { + state = { ...state, status: 'offer_creating', offerStateUncertain: true }; + await saveState(state); + const offerData = await graphqlClient.request(CREATE_OFFER_MUTATION, { + input: { + customerId: state.customerId, + description: projectInfo.title || projectInfo.description || 'Tilbud fra Tilbudsgiveren', + reference: projectInfo.reference || '', + remark: projectInfo.notes || '' + } + }); + const offer = offerData?.createOffer; + if (!offer || offer.id === undefined || offer.id === null) { + throw new OfferBoundaryError('Ordrestyring oprettede ikke tilbuddet', 502, 'OFFER_CREATION_FAILED'); + } + state = { + ...state, offer, offerId: offer.id, createdLines: [], status: 'offer_ready', offerStateUncertain: false + }; + await saveState(state); + } - const customerId = await resolveCustomerId(customerInfo); - const offerInput = { - customerId: parseInt(customerId, 10), - description: projectInfo?.title || projectInfo?.description || 'Tilbud fra Tilbudsgiveren', - reference: projectInfo?.reference || '', - remark: notes || projectInfo?.notes || '' - }; + const desiredLines = buildLines(state.offer.id); + let createdLines = Array.isArray(state.createdLines) ? state.createdLines : []; + let linesToCreate = missingLines(desiredLines, createdLines); - const offerData = await graphqlClient.request(CREATE_OFFER_MUTATION, { input: offerInput }); - const offer = offerData.createOffer; + if (state.lineStateUncertain || graphqlClient.verifyPersistedLines === true) { + if (typeof graphqlClient.getOfferLines !== 'function') { + throw new OfferBoundaryError( + 'Tilbuddets fjernlinjer skal afstemmes før sikkert genforsøg', + 502, + 'REMOTE_LINE_STATE_UNKNOWN' + ); + } + state = { ...state, status: 'lines_reconciling' }; + await saveState(state); + createdLines = await graphqlClient.getOfferLines(state.offer.id); + if (!Array.isArray(createdLines)) { + throw new OfferBoundaryError('Kunne ikke hente tilbudslinjer til afstemning', 502, 'REMOTE_LINE_STATE_UNKNOWN'); + } + linesToCreate = missingLines(desiredLines, createdLines); + state = { ...state, createdLines, lineStateUncertain: false, status: 'offer_ready' }; + await saveState(state); + } - const lineInputs = buildOfferLines(offer.id, materials, labor); - let createdLines = []; - - if (lineInputs.length > 0) { - try { - const linesData = await graphqlClient.request(CREATE_OFFER_LINES_MUTATION, { - inputs: lineInputs - }); - createdLines = linesData.createOfferLines || []; - } catch (lineError) { - console.error('Error creating offer lines (offer still created):', lineError.message); + if (linesToCreate.length > 0) { + state = { ...state, status: 'lines_creating', lineStateUncertain: true }; + await saveState(state); + const linesData = await graphqlClient.request(CREATE_OFFER_LINES_MUTATION, { inputs: linesToCreate }); + const newlyCreated = linesData?.createOfferLines; + if (Array.isArray(newlyCreated)) createdLines = [...createdLines, ...newlyCreated]; + if (Array.isArray(linesData?.offerLines)) createdLines = linesData.offerLines; + state = { ...state, createdLines, lineStateUncertain: !Array.isArray(newlyCreated) }; + await saveState(state); + const remaining = missingLines(desiredLines, createdLines); + if (remaining.length > 0) { + throw new OfferBoundaryError( + 'Ordrestyring oprettede ikke alle tilbudslinjer', + 502, + 'PARTIAL_LINE_CREATION' + ); } } - res.json({ - success: true, - message: 'Tilbud oprettet i Ordrestyring', - offerNumber: offer.number, - offerId: offer.id, - customerId: parseInt(customerId, 10), - lineCount: createdLines.length, - viewUrl: `https://app.ordrestyring.dk/offers/${offer.id}`, - offer - }); + if (typeof graphqlClient.getOfferTotals === 'function') { + const totals = await graphqlClient.getOfferTotals(state.offer.id); + if (!totals || cents(totals.salesPrice) !== cents(expectedTotals.totalExclVat) || + cents(totals.salesPriceWithVat) !== cents(expectedTotals.totalInclVat) || + (Object.prototype.hasOwnProperty.call(totals, 'vat') && cents(totals.vat) !== cents(expectedTotals.vatAmount))) { + throw new OfferBoundaryError('Ordrestyring totaler stemmer ikke med godkendt snapshot', 502, + 'OFFER_TOTAL_RECONCILIATION_FAILED'); + } + } + + state = { ...state, createdLines, status: 'remote_completed', lineStateUncertain: false }; + await saveState(state); + return { customerId: state.customerId, offer: state.offer, createdLines, state }; } catch (error) { - console.error('Error creating offer:', error.message); - console.error('Error details:', error.response?.errors || error); + if (error.code === 'ORDRESTYRING_OPERATION_LEASE_LOST') throw error; + const failedState = { + ...state, + status: 'failed', + error: { code: error.code || 'ORDRESTYRING_REQUEST_FAILED', message: error.message } + }; - res.status(500).json({ - success: false, - error: error.message, - details: error.response?.errors || [] - }); + if (state.offer?.id && typeof graphqlClient.deleteOffer === 'function') { + try { + state = { ...failedState, status: 'compensation_pending', compensationStateUncertain: true }; + await saveState(state); + const deletion = await graphqlClient.deleteOffer(state.offer.id); + if (deletionWasVerified(deletion, state.offer.id)) { + state = { + ...state, + status: 'compensated', + compensationStateUncertain: false, + compensatedOfferId: state.offer.id, + offer: null, + offerId: null, + createdLines: [], + lineStateUncertain: false, + offerStateUncertain: false + }; + await saveState(state); + throw error; + } + } catch (compensationError) { + if (compensationError === error) throw error; + if (compensationError.code === 'ORDRESTYRING_OPERATION_LEASE_LOST') throw compensationError; + // Fall through and retain durable remote ids for manual repair. + } + } + + state = { ...failedState, offer: state.offer, offerId: state.offerId, + compensationStateUncertain: state.compensationStateUncertain === true }; + await saveState(state); + throw error; } -}); +} +function assertCanonicalRequest(body) { + const keys = Object.keys(body || {}).sort(); + const expectedKeys = ['expectedSnapshotSignature', 'projectId']; + if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) { + throw new OfferBoundaryError( + 'Kanonisk tagtilbud accepterer kun projectId og expectedSnapshotSignature', + 400, + 'INVALID_CANONICAL_PAYLOAD' + ); + } + const projectId = Number(body.projectId); + if (!Number.isInteger(projectId) || projectId <= 0) { + throw new OfferBoundaryError('projectId skal være et positivt heltal', 400, 'INVALID_PROJECT_ID'); + } + if (typeof body.expectedSnapshotSignature !== 'string' || !body.expectedSnapshotSignature.trim()) { + throw new OfferBoundaryError('expectedSnapshotSignature er påkrævet', 400, 'SIGNATURE_REQUIRED'); + } + return { projectId, expectedSnapshotSignature: body.expectedSnapshotSignature.trim() }; +} + +const snapshotIsReady = snapshot => ( + snapshot?.readiness?.ready === true || + snapshot?.readiness?.status === 'ready' || + snapshot?.readyForSubmission === true || + snapshot?.readyForOrdrestyring === true +); + +const snapshotIsApproved = snapshot => ( + snapshot?.approval?.approved === true || + snapshot?.approval?.status === 'approved' || + snapshot?.approved === true +); + +/** + * Construct the Ordrestyring offer route. + * + * Fail-closed by default: callers that intend to mutate Ordrestyring must + * explicitly inject a mutation-capable client. Production does that only in + * unified-server.js, where the application bootstrap owns the live transport. + */ +function createOffersRouter({ + graphqlClient = null, + roofQuoteSnapshotService = null, + quoteRealismService = null, + operationStateStore = null, + operationWaitTimeoutMs = 5000, + operationPollIntervalMs = 25, + operationLeaseMs = 30000 +} = {}) { + const router = express.Router(); + router.use((req, res, next) => { + res.locals.operatorDeniedMessage = 'Ingen adgang til at oprette Ordrestyring-tilbud'; + res.locals.operatorDeniedCode = 'OPERATOR_ACCESS_REQUIRED'; + next(); + }); + const stateStore = operationStateStore || new OrdrestyringOfferOperationStateStore(defaultDatabaseService); + if (typeof stateStore.claim !== 'function' || typeof stateStore.get !== 'function' || + typeof stateStore.set !== 'function' || typeof stateStore.renew !== 'function') { + throw new Error('Ordrestyring operation store must provide atomic claim, get, set, and renew operations'); + } + + const waitForCompletedOperation = async idempotencyKey => { + const deadline = Date.now() + operationWaitTimeoutMs; + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, operationPollIntervalMs)); + let state; + try { + state = await stateStore.get(idempotencyKey); + } catch (error) { + throw new OfferBoundaryError( + 'Ordrestyring operation store is unavailable', + 503, + 'ORDRESTYRING_OPERATION_STORE_UNAVAILABLE' + ); + } + if (state?.status === 'completed') return state; + if (state && ['failed', 'compensated'].includes(state.status)) { + throw new OfferBoundaryError( + state.error?.message || 'Den samtidige Ordrestyring-operation mislykkedes', + 502, + state.error?.code || 'ORDRESTYRING_OPERATION_FAILED' + ); + } + } + throw new OfferBoundaryError( + 'En identisk Ordrestyring-operation er stadig i gang', + 409, + 'ORDRESTYRING_OPERATION_IN_PROGRESS' + ); + }; + + router.post('/create', verifyToken, requireConfiguredOperator, async (req, res) => { + try { + const { projectId, expectedSnapshotSignature } = assertCanonicalRequest(req.body); + const snapshotService = roofQuoteSnapshotService || global.roofQuoteSnapshotService; + if (!snapshotService || typeof snapshotService.buildFromProject !== 'function' || + typeof snapshotService.assertExpectedSignature !== 'function') { + throw new OfferBoundaryError( + 'Kanonisk tagsnapshot-service er ikke konfigureret', + 503, + 'SNAPSHOT_SERVICE_UNAVAILABLE' + ); + } + + const snapshot = await snapshotService.buildFromProject(projectId); + if (!snapshot) { + throw new OfferBoundaryError('Projektets tagsnapshot blev ikke fundet', 404, 'SNAPSHOT_NOT_FOUND'); + } + const signatureResult = await snapshotService.assertExpectedSignature(snapshot, expectedSnapshotSignature); + if (signatureResult === false) { + throw new OfferBoundaryError('Snapshot signature mismatch', 409, 'SNAPSHOT_SIGNATURE_MISMATCH'); + } + if (!snapshotIsReady(snapshot)) { + throw new OfferBoundaryError('Tagsnapshot er ikke klar til Ordrestyring', 422, 'SNAPSHOT_NOT_READY'); + } + if (!snapshotIsApproved(snapshot)) { + throw new OfferBoundaryError('Tagsnapshot er ikke godkendt', 422, 'SNAPSHOT_NOT_APPROVED'); + } + + buildOrdrestyringOfferLines(snapshot, { offerId: 0 }); + + const idempotencyKey = `${projectId}:${snapshot.signature || expectedSnapshotSignature}`; + const ownerId = randomUUID(); + const initialState = { + idempotencyKey, + projectId, + snapshotSignature: snapshot.signature || expectedSnapshotSignature, + status: 'started', + createdLines: [] + }; + const realismService = quoteRealismService || global.quoteRealismService || + new QuoteRealismService(defaultDatabaseService, snapshotService); + if (typeof realismService.requireApprovedAnalysis !== 'function') { + throw new OfferBoundaryError( + 'Tilbuddets realismecheck-service er ikke konfigureret', + 503, + 'REALISM_SERVICE_UNAVAILABLE' + ); + } + await realismService.requireApprovedAnalysis(projectId, snapshot.signature); + if (!graphqlClient || typeof graphqlClient.request !== 'function') { + throw new OfferBoundaryError( + 'Ordrestyring mutation transport er ikke eksplicit konfigureret', + 503, + 'ORDRESTYRING_TRANSPORT_NOT_INJECTED' + ); + } + let claim; + try { + claim = await stateStore.claim(idempotencyKey, initialState, { + ownerId, + leaseMs: operationLeaseMs + }); + } catch (error) { + throw new OfferBoundaryError( + 'Ordrestyring operation store is unavailable', + 503, + 'ORDRESTYRING_OPERATION_STORE_UNAVAILABLE' + ); + } + + if (!claim.acquired) { + if (claim.state?.status === 'completed') { + return res.json({ ...claim.state.response, idempotentReplay: true }); + } + const completed = await waitForCompletedOperation(idempotencyKey); + return res.json({ ...completed.response, idempotentReplay: true }); + } + + let leaseError; + const checkLease = () => { if (leaseError) throw leaseError; }; + const renew = async () => { + checkLease(); + try { + await stateStore.renew(idempotencyKey, { ownerId, leaseMs: operationLeaseMs }); + } catch (error) { + leaseError = new OfferBoundaryError('Ordrestyring operation lease could not be renewed', 503, + 'ORDRESTYRING_OPERATION_LEASE_LOST'); + throw leaseError; + } + }; + const guardedClient = new Proxy(graphqlClient, { + get(target, property) { + const method = target[property]; + if (typeof method !== 'function') return method; + return async (...args) => { + await renew(); + let pending = Promise.resolve(); + const timer = setInterval(() => { + pending = pending.then(renew).catch(() => {}); + }, Math.max(1, Math.floor(operationLeaseMs / 3))); + try { + const result = await method.apply(target, args); + await pending; + await renew(); + return result; + } finally { + clearInterval(timer); + await pending; + checkLease(); + } + }; + } + }); + const saveOperation = async state => { + checkLease(); + await stateStore.set(idempotencyKey, state, { ownerId, leaseMs: operationLeaseMs }); + return state; + }; + const result = await runOfferOperation({ + graphqlClient: guardedClient, + customerInfo: canonicalCustomerInfo(snapshot), + projectInfo: canonicalProjectInfo(snapshot, projectId), + buildLines: offerId => buildOrdrestyringOfferLines(snapshot, { offerId }), + expectedTotals: (snapshot.artifact || snapshot).economics || (snapshot.artifact || snapshot).totals, + initialState: claim.state, + saveState: saveOperation + }); + + const response = { + success: true, + message: 'Tilbud oprettet i Ordrestyring', + offerNumber: result.offer.number, + offerId: result.offer.id, + customerId: result.customerId, + lineCount: result.createdLines.length, + viewUrl: `https://app.ordrestyring.dk/offers/${result.offer.id}`, + offer: result.offer + }; + await saveOperation({ ...result.state, status: 'completed', response }); + return res.json(response); + } catch (error) { + console.error('Error creating offer:', error.message); + const knownStatus = Number(error.status); + const status = Number.isInteger(knownStatus) + ? knownStatus + : (error instanceof OfferNormalizationError ? 422 : 502); + return res.status(status).json({ + success: false, + error: error.message, + code: error.code || (status === 502 ? 'ORDRESTYRING_REQUEST_FAILED' : 'OFFER_CREATION_FAILED'), + details: error.response?.errors || [] + }); + } + }); + + return router; +} + +const router = createOffersRouter(); +router.createOffersRouter = createOffersRouter; +router.OfferBoundaryError = OfferBoundaryError; module.exports = router; diff --git a/backend/scripts/seed-ci-auth-account.js b/backend/scripts/seed-ci-auth-account.js new file mode 100644 index 0000000..8c8e63b --- /dev/null +++ b/backend/scripts/seed-ci-auth-account.js @@ -0,0 +1,51 @@ +'use strict'; + +const seedCiAuthAccount = async ({ env, db, hashPassword }) => { + if (env.NODE_ENV !== 'test' || String(env.CI).toLowerCase() !== 'true') { + throw new Error('CI auth seeding is allowed only in an explicit CI test environment'); + } + const username = String(env.AUTH_USERNAME || '').trim(); + const password = String(env.AUTH_PASSWORD || ''); + if (!username || !password) { + throw new Error('AUTH_USERNAME and AUTH_PASSWORD are required for CI auth seeding'); + } + + const passwordHash = await hashPassword(password, 12); + await db.execute( + `INSERT INTO auth_accounts (username, password_hash, role) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), role = VALUES(role)`, + [username, passwordHash, 'admin'] + ); +}; + +const main = async () => { + const mysql = require('mysql2/promise'); + const bcrypt = require('bcryptjs'); + const connection = await mysql.createConnection({ + 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 + }); + try { + await seedCiAuthAccount({ + env: process.env, + db: connection, + hashPassword: bcrypt.hash + }); + console.log('Seeded isolated CI auth account'); + } finally { + await connection.end(); + } +}; + +module.exports = { seedCiAuthAccount }; + +if (require.main === module) { + main().catch(error => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/backend/sql/customer_project_system.sql b/backend/sql/customer_project_system.sql index 0603c1a..0660892 100644 --- a/backend/sql/customer_project_system.sql +++ b/backend/sql/customer_project_system.sql @@ -43,11 +43,13 @@ CREATE TABLE IF NOT EXISTS customer_projects ( CREATE TABLE IF NOT EXISTS roof_geometry ( id INT AUTO_INCREMENT PRIMARY KEY, project_id INT NOT NULL, - roof_type ENUM('fladt_tag', 'skraat_tag', 'mansard', 'komplekst') NOT NULL, + roof_type ENUM('gable', 'pult', 'flat', 'fladt_tag', 'skraat_tag', 'mansard', 'komplekst') NOT NULL, roof_material ENUM('tegl','tagpap','eternit','betontag','staal','andet') NULL, total_area DECIMAL(10,2) NOT NULL COMMENT 'Samlet areal i m²', + roof_covering_area DECIMAL(10,2) NULL COMMENT 'Faktisk tagbeklædningsareal i m²', roof_pitch DECIMAL(5,2) COMMENT 'Taghældning i grader', roof_height DECIMAL(8,2) COMMENT 'Taghøjde i meter', + wall_height DECIMAL(8,2) NULL COMMENT 'Væghøjde i meter', complexity_factor DECIMAL(3,2) DEFAULT 1.0 COMMENT 'Kompleksitetsfaktor 1.0-2.0', -- Detaljerede målinger @@ -63,6 +65,13 @@ CREATE TABLE IF NOT EXISTS roof_geometry ( -- Beregnet data estimated_work_hours DECIMAL(8,2) COMMENT 'Estimerede arbejdstimer', estimated_carpenters INT COMMENT 'Anbefalede antal tømrere', + + 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, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -93,13 +102,41 @@ CREATE TABLE IF NOT EXISTS project_labor ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE, - INDEX idx_project (project_id) + UNIQUE KEY unique_project_labor (project_id) +); + +-- Existing installs may contain duplicates from before saveProjectLabor used upsert. +-- Keep the newest row, verify the cleanup, then enforce the one-row contract. +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); + +-- Versioneret Smart Pakke-builder: klodser, rækkefølge, geometri og overrides. +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 ); -- Projekt materialer CREATE TABLE IF NOT EXISTS project_materials ( id INT AUTO_INCREMENT PRIMARY KEY, project_id INT NOT NULL, + package_instance_id VARCHAR(100) NULL, + source_package_id INT NULL, + material_id INT NULL, material_name VARCHAR(255) NOT NULL, material_category VARCHAR(100), quantity DECIMAL(10,3) NOT NULL, @@ -107,16 +144,74 @@ CREATE TABLE IF NOT EXISTS project_materials ( unit_price DECIMAL(10,2) NOT NULL, total_price DECIMAL(12,2) NOT NULL, supplier VARCHAR(255), - material_source ENUM('manual', 'database', 'bygma_api') DEFAULT 'manual', + material_source ENUM('manual', 'database', 'bygma_api', 'package') DEFAULT 'manual', + price_source VARCHAR(120) NULL, + price_source_updated_at DATETIME NULL, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE, INDEX idx_project (project_id), - INDEX idx_category (material_category) + INDEX idx_category (material_category), + INDEX idx_project_materials_package_instance (project_id, package_instance_id), + INDEX idx_project_materials_material_id (material_id) ); +-- Projektudlejning og referenceydelser med Smart Pakke-ejerskab. +CREATE TABLE IF NOT EXISTS project_rentals ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL, + package_instance_id VARCHAR(100) NULL, + source_package_id INT NULL, + rental_name VARCHAR(255) NOT NULL, + rental_category VARCHAR(100), + quantity DECIMAL(10,3) NOT NULL, + unit VARCHAR(50) NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + total_price DECIMAL(12,2) NOT NULL, + supplier VARCHAR(255), + rental_source ENUM('manual', 'database', 'import') DEFAULT 'manual', + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE, + INDEX idx_project (project_id), + INDEX idx_category (rental_category), + INDEX idx_project_rentals_package_instance (project_id, package_instance_id) +); + +-- Idempotente opgraderinger når bootstrap køres mod eksisterende tabeller. +ALTER TABLE project_materials + 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 material_id INT NULL AFTER source_package_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'; +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; +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, + ADD COLUMN IF NOT EXISTS replacement_scope VARCHAR(40) NULL AFTER default_quantity, + ADD COLUMN IF NOT EXISTS compatible_roof_materials JSON NULL AFTER replacement_scope, + ADD COLUMN IF NOT EXISTS allowed_roof_forms JSON NULL AFTER compatible_roof_materials, + ADD COLUMN IF NOT EXISTS min_pitch_degrees DECIMAL(5,2) NULL AFTER allowed_roof_forms, + ADD COLUMN IF NOT EXISTS max_pitch_degrees DECIMAL(5,2) NULL AFTER min_pitch_degrees; +ALTER TABLE roof_geometry + ADD COLUMN IF NOT EXISTS roof_covering_area DECIMAL(10,2) NULL AFTER total_area, + ADD COLUMN IF NOT EXISTS wall_height DECIMAL(8,2) NULL AFTER roof_height, + ADD COLUMN IF NOT EXISTS geometry_json JSON NULL, + ADD COLUMN IF NOT EXISTS edges_json JSON NULL, + ADD COLUMN IF NOT EXISTS planes_json JSON NULL, + ADD COLUMN IF NOT EXISTS openings_json JSON NULL, + ADD COLUMN IF NOT EXISTS provenance_json JSON NULL, + ADD COLUMN IF NOT EXISTS replacement_scope VARCHAR(80) NULL, + MODIFY COLUMN roof_type ENUM('gable', 'pult', 'flat', 'fladt_tag', 'skraat_tag', 'mansard', 'komplekst') NOT NULL; + -- Projekt beregninger (råtilbud) CREATE TABLE IF NOT EXISTS project_calculations ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/backend/src/__tests__/advancedGeometryService.test.js b/backend/src/__tests__/advancedGeometryService.test.js index b83ae5f..598d21c 100644 --- a/backend/src/__tests__/advancedGeometryService.test.js +++ b/backend/src/__tests__/advancedGeometryService.test.js @@ -1,32 +1,92 @@ const AdvancedGeometryService = require('../services/advancedGeometryService'); describe('AdvancedGeometryService', () => { - test('calculates carpenter-useful geometry and labor hours for every site roof type', async () => { + test.each([ + ['sadeltag', { width: 8, length: 12, roofPitch: 30 }, 'gable', 110.85125168440814, 12], + ['pulttag', { width: 8, length: 12, roofPitch: 15 }, 'pult', 99.38651331936796, 0], + ['fladt_tag', { width: 8, length: 12, falls: true, drainCount: 2 }, 'flat', 96, 0], + ['valmtag', { width: 8, length: 12, roofPitch: 30 }, 'hip', 110.85125168440814, 4], + ['mansardtag', { + width: 8, + length: 12, + lowerRun: 2.5, + lowerPitch: 60, + upperRun: 1.5, + upperPitch: 30 + }, 'mansard', 161.56921938165306, 12] + ])('delegates %s to canonical roof replacement geometry', async ( + roofType, + input, + canonicalType, + expectedArea, + expectedRidge + ) => { const service = new AdvancedGeometryService({}); - const roofTypes = [ - { value: 'sadeltag', expectedHours: 96, minComplexity: 1 }, - { value: 'valmtag', expectedHours: 120, minComplexity: 1.2 }, - { value: 'koebenhavnertag', expectedHours: 86.4, minComplexity: 1.3 }, - { value: 'fladt_tag', expectedHours: 67.2, minComplexity: 0.8 }, - { value: 'pulttag', expectedHours: 81.6, minComplexity: 0.9 }, - { value: 'tag_med_kviste', expectedHours: 158.4, minComplexity: 1.4 }, - { value: 'mansardtag', expectedHours: 172.8, minComplexity: 1.4 } - ]; + const result = await service.calculateAdvancedGeometry({ roofType, ...input }); - for (const roofType of roofTypes) { - const result = await service.calculateAdvancedGeometry({ - width: 8, - length: 12, - roofType: roofType.value, - roofPitch: roofType.value === 'fladt_tag' ? 3 : 30 - }); + expect(result.roofGeometry.roofType).toBe(canonicalType); + expect(result.roofGeometry.area.roofSurface).toBeCloseTo(expectedArea, 10); + expect(result.roofGeometry.lengths.ridge).toBeCloseTo(expectedRidge, 10); + expect(result.roofGeometry.formula.id).toMatch(/-v1$/); + expect(result.roofGeometry.provenance.engine).toBe('roofReplacementGeometry'); - expect(result.basicDimensions.roofType).toBe(roofType.value); - expect(result.basicDimensions.baseArea).toBe(96); - expect(result.estimatedWorkHours).toBeCloseTo(roofType.expectedHours, 1); - expect(result.complexity).toBeGreaterThanOrEqual(roofType.minComplexity); - expect(result.materialQuantities.totalCoverage).toBeGreaterThan(0); - expect(result.svgIllustration.svg).toContain(' { + const service = new AdvancedGeometryService({}); + expect(service.calculateComplexityFactor({ roofType, roofPitch: 30 })).toBe(complexity); + expect(service.estimateWorkHours(100, roofType, false)).toBe(hours); + }); + + test('fails closed when estimate helpers receive an unsupported roof type', () => { + const service = new AdvancedGeometryService({}); + expect(() => service.calculateComplexityFactor({ roofType: 'komplekst' })) + .toThrow('Unsupported roof type'); + expect(() => service.estimateWorkHours(100, 'komplekst', false)) + .toThrow('Unsupported roof type'); + }); + + test('rejects unsupported roof geometry instead of applying a gable default', async () => { + const service = new AdvancedGeometryService({}); + await expect(service.calculateAdvancedGeometry({ + roofType: 'unknown-roof', width: 8, length: 12, roofPitch: 30 + })).rejects.toThrow('Unsupported roof type'); + }); + + test('passes overhangs through to canonical geometry and derives aliases from the extended field', async () => { + const service = new AdvancedGeometryService({}); + const result = await service.calculateAdvancedGeometry({ + roofType: 'sadeltag', width: 8, length: 12, roofPitch: 30, + eaveOverhang: 0.4, gableOverhang: 0.3 + }); + + expect(result.roofGeometry.area).toEqual({ + plan: 110.88000000000001, + roofSurface: 128.0331956954914 + }); + expect(result.roofGeometry.lengths).toMatchObject({ ridge: 12.6, eaves: 25.2 }); + expect(result.basicDimensions.baseArea).toBe(110.88); + expect(result.heightCalculations).toMatchObject({ + rafterLength: 5.08, + slopeArea: 128.03 + }); + expect(result.windboardCalculations).toMatchObject({ + eavesBoards: { totalLength: 25.2 }, + ridgeBoard: { length: 12.6 } + }); }); }); diff --git a/backend/src/__tests__/completeRoofPackageContract.test.js b/backend/src/__tests__/completeRoofPackageContract.test.js new file mode 100644 index 0000000..122e558 --- /dev/null +++ b/backend/src/__tests__/completeRoofPackageContract.test.js @@ -0,0 +1,314 @@ +const fs = require('fs'); +const path = require('path'); + +const migrationPath = path.join( + __dirname, + '../../../database/migrations/20260910_complete_roof_package_contract.js' +); + +const expectedMaterials = new Map([ + ['betontag_renovering', 'betontag'], + ['tegl_tag_renovering', 'tegl'], + ['b7_tag_udskiftning', 'eternit'], + ['staaltag_renovering', 'staal'], + ['tagpap_renovering', 'tagpap'] +]); + +const seedRows = [ + { id: 917, name: 'Komplet tagskift — betontag', catalog_key: null }, + { id: 923, name: 'Komplet tagskift — vingefalstegl', catalog_key: null }, + { id: 941, name: 'Komplet tagskift — Swisspearl B7', catalog_key: null }, + { id: 955, name: 'Komplet tagskift — ståltag', catalog_key: null }, + { id: 972, name: 'Komplet tagrenovering — 2-lags tagpap', catalog_key: null } +]; + +const targetColumns = [ + 'catalog_key', 'replacement_scope', 'compatible_roof_materials', 'allowed_roof_forms', + 'min_pitch_degrees', 'max_pitch_degrees', 'pitch_verification_status', 'pitch_review_required' +]; + +const schemaRows = ({ complete = false } = {}) => [ + ...['id', 'name', 'validation_status', 'is_active', 'validation_notes', 'geometry_basis'] + .map(COLUMN_NAME => ({ TABLE_NAME: 'material_packages', COLUMN_NAME })), + ...(complete ? targetColumns.map(COLUMN_NAME => ({ TABLE_NAME: 'material_packages', COLUMN_NAME })) : []), + ...['id', 'roof_type'].map(COLUMN_NAME => ({ + TABLE_NAME: 'roof_geometry', + COLUMN_NAME, + ...(COLUMN_NAME === 'roof_type' + ? { COLUMN_TYPE: "enum('gable','pult','flat','fladt_tag','skraat_tag','mansard','hip')" } + : {}) + })) +]; + +const isMutation = sql => /^\s*(?:ALTER|UPDATE|INSERT|DELETE|CREATE|DROP|REPLACE|TRUNCATE)\b/i.test(sql); + +const createInspectionConnection = ({ rows = seedRows, completeSchema = false, uniqueIndex = false } = {}) => ({ + execute: jest.fn(async sql => { + if (sql.includes('INFORMATION_SCHEMA.COLUMNS')) return [schemaRows({ complete: completeSchema })]; + if (sql.includes('INFORMATION_SCHEMA.STATISTICS')) { + return [uniqueIndex ? [{ INDEX_NAME: 'uq_material_packages_catalog_key', NON_UNIQUE: 0 }] : []]; + } + if (sql.includes('FROM material_packages')) return [rows]; + throw new Error(`Unexpected SQL: ${sql}`); + }) +}); + +const contractRows = contracts => seedRows.map((row, index) => { + const contract = contracts[index]; + return { + ...row, + catalog_key: contract.catalogKey, + replacement_scope: contract.replacementScope, + compatible_roof_materials: JSON.stringify(contract.compatibleRoofMaterials), + allowed_roof_forms: JSON.stringify(contract.allowedRoofForms), + min_pitch_degrees: contract.minPitchDegrees, + max_pitch_degrees: contract.maxPitchDegrees, + pitch_verification_status: contract.pitchVerificationStatus, + pitch_review_required: contract.pitchReviewRequired ? 1 : 0, + geometry_basis: contract.geometryBasis + }; +}); + +describe('complete-roof package contract migration', () => { + test('uses persisted catalog identities, supported forms, and review-required pitch evidence', () => { + const { COMPLETE_ROOF_PACKAGE_CONTRACTS, PACKAGE_COLUMNS } = require(migrationPath); + + expect(COMPLETE_ROOF_PACKAGE_CONTRACTS.map(contract => contract.catalogKey)).toEqual([...expectedMaterials.keys()]); + COMPLETE_ROOF_PACKAGE_CONTRACTS.forEach(contract => { + expect(contract.id).toBeUndefined(); + expect(contract.exactName).toEqual(expect.any(String)); + expect(contract).toMatchObject({ + replacementScope: 'complete_roof_replacement', + geometryBasis: 'roof_area', + compatibleRoofMaterials: [expectedMaterials.get(contract.catalogKey)], + minPitchDegrees: null, + maxPitchDegrees: null, + pitchVerificationStatus: 'unverified', + pitchReviewRequired: true + }); + expect(contract.allowedRoofForms).toContain('hip'); + expect(contract.allowedRoofForms).not.toContain('komplekst'); + }); + expect(PACKAGE_COLUMNS.map(([column]) => column)).toEqual(expect.arrayContaining(targetColumns)); + }); + + test('dry-run inspects a pre-migration schema and reports schema plus exactly five resolved backfills without writes', async () => { + const { run } = require(migrationPath); + const connection = createInspectionConnection(); + const pool = { + getConnection: jest.fn().mockResolvedValue(connection), + end: jest.fn().mockResolvedValue() + }; + connection.release = jest.fn(); + const log = jest.fn(); + + const result = await run({ dryRun: true, pool, log }); + + expect(result).toMatchObject({ dryRun: true }); + expect(result.plannedSchema.some(item => item.column === 'catalog_key')).toBe(true); + expect(result.plannedSchema.some(item => item.index === 'uq_material_packages_catalog_key')).toBe(true); + expect(result.backfills).toHaveLength(5); + expect(result.backfills.map(item => item.catalogKey)).toEqual([...expectedMaterials.keys()]); + expect(result.backfills.map(item => item.packageId)).toEqual(seedRows.map(row => row.id)); + expect(connection.execute.mock.calls.map(([sql]) => sql).some(isMutation)).toBe(false); + expect(connection.execute.mock.calls.map(([sql]) => sql).some(sql => /WHERE catalog_key IN/i.test(sql))).toBe(false); + expect(connection.beginTransaction).toBeUndefined(); + expect(log).toHaveBeenCalledWith(JSON.stringify(result, null, 2)); + }); + + test.each([ + ['missing', seedRows.slice(0, 4)], + ['ambiguous exact seed identity', [...seedRows, { ...seedRows[0], id: 1999 }]], + ['ambiguous persisted key', [ + { ...seedRows[0], catalog_key: 'betontag_renovering' }, + { ...seedRows[0], id: 1999, catalog_key: 'betontag_renovering' }, + ...seedRows.slice(1) + ]] + ])('dry-run fails without writes when a backfill identity is %s', async (_label, rows) => { + const { inspectCompleteRoofMigration } = require(migrationPath); + const connection = createInspectionConnection({ rows, completeSchema: true, uniqueIndex: true }); + + await expect(inspectCompleteRoofMigration(connection)).rejects.toThrow(/missing|ambiguous/i); + expect(connection.execute.mock.calls.map(([sql]) => sql).some(isMutation)).toBe(false); + }); + + test('dry-run treats a fully supported enum that preserves legacy komplekst as complete', async () => { + const connection = createInspectionConnection({ completeSchema: true, rows: contractRows(require(migrationPath).COMPLETE_ROOF_PACKAGE_CONTRACTS), uniqueIndex: true }); + const originalExecute = connection.execute; + connection.execute = jest.fn(async (sql, params) => { + const result = await originalExecute(sql, params); + if (sql.includes('INFORMATION_SCHEMA.COLUMNS')) { + result[0] = result[0].map(row => row.TABLE_NAME === 'roof_geometry' && row.COLUMN_NAME === 'roof_type' + ? { ...row, COLUMN_TYPE: "enum('gable','pult','flat','fladt_tag','skraat_tag','mansard','hip','komplekst')" } + : row); + } + return result; + }); + + const result = await require(migrationPath).inspectCompleteRoofMigration(connection); + + expect(result.plannedSchema).not.toContainEqual(expect.objectContaining({ table: 'roof_geometry', column: 'roof_type' })); + expect(result.backfills.every(item => item.contractAction === 'unchanged')).toBe(true); + }); + + test('backfills exact seed catalog keys once, then resolves and updates contracts solely by catalog_key', async () => { + const { applyCompleteRoofPackageBackfill, COMPLETE_ROOF_PACKAGE_CONTRACTS } = require(migrationPath); + let rows = seedRows.map(row => ({ ...row })); + const connection = { + execute: jest.fn(async (sql, params) => { + if (sql.includes('FROM material_packages')) { + if (sql.includes('catalog_key IN')) { + return [rows.filter(row => params.includes(row.catalog_key))]; + } + return [rows]; + } + if (sql.includes('SET catalog_key = ?')) { + const [catalogKey, id] = params; + const row = rows.find(candidate => candidate.id === id && candidate.catalog_key === null); + if (!row) return [{ affectedRows: 0 }]; + row.catalog_key = catalogKey; + return [{ affectedRows: 1 }]; + } + if (sql.includes('UPDATE material_packages')) return [{ affectedRows: 1 }]; + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + + const result = await applyCompleteRoofPackageBackfill(connection); + + expect(result).toEqual({ matchedPackageIds: seedRows.map(row => row.id), changedRows: 5, catalogKeysBackfilled: 5 }); + const keyWrites = connection.execute.mock.calls.filter(([sql]) => sql.includes('SET catalog_key = ?')); + expect(keyWrites).toHaveLength(5); + const keyedRead = connection.execute.mock.calls.find(([sql]) => sql.includes('catalog_key IN')); + expect(keyedRead).toBeDefined(); + expect(keyedRead[1]).toEqual(COMPLETE_ROOF_PACKAGE_CONTRACTS.map(contract => contract.catalogKey)); + const contractWrites = connection.execute.mock.calls.filter(([sql]) => sql.includes('replacement_scope = ?')); + expect(contractWrites).toHaveLength(5); + contractWrites.forEach(([sql]) => { + expect(sql).toContain('catalog_key = ?'); + expect(sql).not.toMatch(/package_materials|package_tasks|smart_package_tasks|version\s*=/i); + }); + }); + + test('replays after lifecycle transition using persisted catalog identities', async () => { + const { applyCompleteRoofPackageBackfill, COMPLETE_ROOF_PACKAGE_CONTRACTS } = require(migrationPath); + const rows = contractRows(COMPLETE_ROOF_PACKAGE_CONTRACTS).map(row => ({ + ...row, is_active: 0, validation_status: 'needs_review' + })); + const connection = { + execute: jest.fn(async (sql, params = []) => { + if (sql.includes('FROM material_packages')) { + if (/validation_status\s*=\s*'verified'.*is_active\s*=\s*1/is.test(sql)) return [[]]; + if (sql.includes('catalog_key IN')) return [rows.filter(row => params.includes(row.catalog_key))]; + return [rows]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + + await expect(applyCompleteRoofPackageBackfill(connection)).resolves.toEqual({ + matchedPackageIds: seedRows.map(row => row.id), changedRows: 0, catalogKeysBackfilled: 0 + }); + expect(connection.execute.mock.calls.map(([sql]) => sql).some(isMutation)).toBe(false); + }); + + test('unchanged apply replay performs no writes, version changes, or child changes', async () => { + const { applyCompleteRoofPackageBackfill, COMPLETE_ROOF_PACKAGE_CONTRACTS } = require(migrationPath); + const rows = contractRows(COMPLETE_ROOF_PACKAGE_CONTRACTS); + const connection = createInspectionConnection({ rows, completeSchema: true, uniqueIndex: true }); + + const result = await applyCompleteRoofPackageBackfill(connection); + + expect(result).toEqual({ matchedPackageIds: seedRows.map(row => row.id), changedRows: 0, catalogKeysBackfilled: 0 }); + expect(connection.execute).toHaveBeenCalledTimes(3); + expect(connection.execute.mock.calls.map(([sql]) => sql).some(isMutation)).toBe(false); + connection.execute.mock.calls.forEach(([sql]) => { + expect(sql).not.toMatch(/package_materials|package_tasks|smart_package_tasks|version\s*=/i); + }); + }); + + test('CLI recognizes --dry-run and returns nonzero through its rejected runner', () => { + const source = fs.readFileSync(migrationPath, 'utf8'); + expect(source).toContain("process.argv.includes('--dry-run')"); + expect(source).toMatch(/process\.exitCode\s*=\s*1/); + }); +}); + +describe('runtime persistence schema contract', () => { + test('persists a unique material package catalog key and supported hip roof form', () => { + const source = fs.readFileSync(path.join(__dirname, '../services/databaseService.js'), 'utf8'); + expect(source).toContain('catalog_key VARCHAR(191) NULL'); + expect(source).toContain('CREATE UNIQUE INDEX uq_material_packages_catalog_key'); + expect(source).toContain("'hip'"); + expect(source).toMatch(/ENUM\([^)]*'komplekst'/); + ['pitch_verification_status', 'pitch_review_required'].forEach(column => expect(source).toContain(column)); + }); + + test('runtime enum upgrade is a no-op once every supported roof form exists', async () => { + const databaseService = require('../services/databaseService'); + const previousPool = databaseService.pool; + const query = jest.fn().mockResolvedValue([[ + { COLUMN_TYPE: "enum('gable','pult','flat','fladt_tag','skraat_tag','mansard','hip')" } + ]]); + databaseService.pool = { query }; + try { + await databaseService.ensureRoofGeometryTypeEnum(); + expect(query).toHaveBeenCalledTimes(1); + expect(query.mock.calls[0][0]).toContain('INFORMATION_SCHEMA.COLUMNS'); + } finally { + databaseService.pool = previousPool; + } + }); +}); + +describe('legacy complex roof enum safety', () => { + const legacyConnection = () => ({ + execute: jest.fn(async sql => { + const legacyRoofType = { + TABLE_NAME: 'roof_geometry', COLUMN_NAME: 'roof_type', + COLUMN_TYPE: "enum('fladt_tag','skraat_tag','mansard','komplekst')" + }; + if (sql.includes("COLUMN_NAME = 'roof_type'")) return [[legacyRoofType]]; + if (sql.includes('INFORMATION_SCHEMA.COLUMNS')) return [schemaRows().map(row => ( + row.TABLE_NAME === 'roof_geometry' && row.COLUMN_NAME === 'roof_type' + ? legacyRoofType + : row + ))]; + if (sql.includes('INFORMATION_SCHEMA.STATISTICS')) return [[]]; + if (/^\s*(ALTER|CREATE)/i.test(sql)) return [{}]; + if (/FROM material_packages/i.test(sql)) return [seedRows]; + throw new Error(`Unexpected SQL: ${sql}`); + }) + }); + + test('enum upgrade preserves komplekst while adding supported forms', async () => { + const connection = legacyConnection(); + await require(migrationPath).ensureRoofTypeEnum(connection); + expect(connection.execute.mock.calls).toHaveLength(2); + expect(connection.execute.mock.calls[1][0]).toMatch(/ALTER TABLE roof_geometry/); + expect(connection.execute.mock.calls[1][0]).toMatch(/ENUM\([^)]*'komplekst'/); + expect(connection.execute.mock.calls.some(([sql]) => /WHERE roof_type = 'komplekst'/i.test(sql))).toBe(false); + }); + + test('dry-run remains read-only with legacy complex rows', async () => { + const connection = legacyConnection(); + await require(migrationPath).inspectCompleteRoofMigration(connection); + expect(connection.execute.mock.calls.every(([sql]) => /^\s*SELECT\b/i.test(sql))).toBe(true); + expect(connection.execute.mock.calls.some(([sql]) => /WHERE roof_type = 'komplekst'/i.test(sql))).toBe(false); + }); + + test('runtime bootstrap preserves komplekst while adding supported forms', async () => { + const databaseService = require('../services/databaseService'); + const previousPool = databaseService.pool; + const connection = legacyConnection(); + databaseService.pool = { query: connection.execute }; + try { + await databaseService.ensureRoofGeometryTypeEnum(); + expect(connection.execute.mock.calls).toHaveLength(2); + expect(connection.execute.mock.calls[1][0]).toMatch(/ENUM\([^)]*'komplekst'/); + expect(connection.execute.mock.calls.some(([sql]) => /WHERE roof_type = 'komplekst'/i.test(sql))).toBe(false); + } finally { + databaseService.pool = previousPool; + } + }); +}); diff --git a/backend/src/__tests__/customerDocumentLanguage.test.js b/backend/src/__tests__/customerDocumentLanguage.test.js new file mode 100644 index 0000000..1cdc31b --- /dev/null +++ b/backend/src/__tests__/customerDocumentLanguage.test.js @@ -0,0 +1,57 @@ +const { containsInternalCustomerLanguage } = require('../services/customerDocumentLanguage'); + +describe('customer document language boundary', () => { + test.each([ + 'kildepakke 10', + 'pakke-id 10', + 'package id 10', + 'source package floor-1', + 'pakkeversion 3', + 'package version 3', + 'version 3', + 'prisversion 6', + 'price version 6', + 'mængdegrundlag: 2 huse', + 'mængdeaudit udført', + 'quantity basis: per house', + 'beregningsformel: 2 × 20', + 'calculation formula: 2 * 20', + 'signatur abc123', + 'signature abc123', + 'godkendelse registreret', + 'godkendt af operatør', + 'approver: operator', + 'approval recorded', + 'canonical snapshot', + 'manual override', + 'mode: per_house', + 'modes: calculated', + 'Smart Pakke tag', + 'Lego package', + 'klods 4', + 'overhead 12%', + 'dækningsbidrag 20%' + ])('rejects the internal concept %p', value => { + expect(containsInternalCustomerLanguage(value)).toBe(true); + }); + + test.each([ + 'Godkendte materialer monteres efter producentens anvisninger.', + 'Vi leverer den valgte version af Velux-vinduet.', + 'Kunden underskriver efter gennemgang.', + 'Klodset murværk udbedres.', + 'Dækningsbrættet bevares.', + 'Arbejdet udføres i etaper.' + ])('allows nearby innocent Danish customer wording %p', value => { + expect(containsInternalCustomerLanguage(value)).toBe(false); + }); + + test.each([ + 'kildepakke 10', + 'source-package floor-1', + 'mængde_audit', + 'manualoverride' + ])('rejects internal concepts split by stored HTML %p', value => { + expect(containsInternalCustomerLanguage(value)).toBe(true); + }); +}); diff --git a/backend/src/__tests__/customerProjectsGeometry.integration.test.js b/backend/src/__tests__/customerProjectsGeometry.integration.test.js new file mode 100644 index 0000000..ff567c2 --- /dev/null +++ b/backend/src/__tests__/customerProjectsGeometry.integration.test.js @@ -0,0 +1,277 @@ +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); + +process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'geometry-test-access-secret'; +process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'geometry-test-refresh-secret'; +process.env.AUTH_USERNAME = 'configured-operator'; + +const mockRows = new Map(); +const mockExecuteImplementation = async (sql, params = []) => { + if (sql.includes('INSERT INTO roof_geometry')) { + const geometryJson = params.find(value => typeof value === 'string' && value.includes('"roofGeometry"')); + mockRows.set(params[0], { id: params[0], project_id: params[0], geometry_json: geometryJson }); + return [{ insertId: params[0], affectedRows: 1 }]; + } + if (sql.includes('SELECT * FROM roof_geometry')) return [[mockRows.get(params[0])].filter(Boolean)]; + return [[]]; +}; +const mockExecute = jest.fn(mockExecuteImplementation); + +jest.mock('../services/databaseService', () => ({ + pool: { execute: mockExecute }, + query: jest.fn() +})); +jest.mock('../utils/logger', () => ({ + info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), logInfo: jest.fn(), logError: jest.fn() +})); +jest.mock('uuid', () => ({ v4: () => 'geometry-test-correlation-id' })); + +const CustomerProjectService = require('../services/customerProjectService'); +const QuoteRealismService = require('../services/quoteRealismService'); +const { RoofQuoteSnapshotService } = require('../services/roofQuoteSnapshotService'); +const enhancedRoutes = require('../routes/enhancedFeatures'); +const customerProjectRoutes = require('../routes/customerProjects'); + +const buildApp = () => { + const app = express(); + app.use(express.json()); + app.locals.databaseService = require('../services/databaseService'); + app.use('/api/enhanced', enhancedRoutes); + app.use('/api/customer-projects', customerProjectRoutes); + return app; +}; + +const authHeader = (username = 'configured-operator') => ( + `Bearer ${jwt.sign({ id: 1, username }, process.env.JWT_ACCESS_SECRET)}` +); + +const inputs = [ + ['gable', { pitch: 30 }], + ['pult', { pitch: 15 }], + ['flat', { falls: true, drainCount: 2 }], + ['hip', { pitch: 30 }], + ['mansard', { lowerRun: 2.5, lowerPitch: 60, upperRun: 1.5, upperPitch: 30 }] +]; + +describe('authenticated canonical roof geometry HTTP persistence', () => { + beforeEach(() => { + mockRows.clear(); + mockExecute.mockReset().mockImplementation(mockExecuteImplementation); + jest.spyOn(CustomerProjectService.prototype, 'updateProjectStatus').mockResolvedValue(true); + }); + + afterEach(() => jest.restoreAllMocks()); + + test.each(inputs)('calculate -> save -> GET is deeply equal for %s', async (roofType, formFields) => { + const app = buildApp(); + const input = { + roofType, roofMaterial: 'tegl', scope: 'roof_replacement', width: 8, length: 12, + eaveOverhang: 0.4, gableOverhang: 0.3, wallHeight: 2.5, ...formFields + }; + const calculated = await request(app).post('/api/enhanced/geometry/calculate').send(input); + expect(calculated.status).toBe(200); + const roofGeometry = calculated.body.geometry.roofGeometry; + const canonical = { + ...input, + roofReplacementScope: null, + hasDormers: false, + hasChimneys: false, + hasSkylights: false, + accessDifficulty: 'medium', + pitch: input.pitch ?? null, + falls: input.falls ?? null, + drainCount: input.drainCount ?? null, + lowerRun: input.lowerRun ?? null, + lowerPitch: input.lowerPitch ?? null, + upperRun: input.upperRun ?? null, + upperPitch: input.upperPitch ?? null, + baseArea: roofGeometry.area.plan, + roofCoveringArea: roofGeometry.area.roofSurface, + ridgeHeight: roofGeometry.rise, + edges: roofGeometry.lengths, + planes: [], + openings: [], + provenance: roofGeometry.provenance, + roofGeometry + }; + + const saved = await request(app) + .post('/api/customer-projects/399/geometry') + .set('Authorization', authHeader()) + .send(canonical); + const reloaded = await request(app) + .get('/api/customer-projects/399/geometry') + .set('Authorization', authHeader()); + + expect(saved.status).toBe(200); + expect(reloaded.status).toBe(200); + expect(saved.body.geometry).toEqual(canonical); + expect(reloaded.body.geometry).toEqual(canonical); + }); + + test('requires authentication and the configured operator for both save and GET', async () => { + const app = buildApp(); + const unauthenticatedSave = await request(app).post('/api/customer-projects/399/geometry').send({}); + const unauthenticatedRead = await request(app).get('/api/customer-projects/399/geometry'); + const otherSave = await request(app).post('/api/customer-projects/399/geometry') + .set('Authorization', authHeader('other-user')).send({}); + const otherRead = await request(app).get('/api/customer-projects/399/geometry') + .set('Authorization', authHeader('other-user')); + + expect([unauthenticatedSave.status, unauthenticatedRead.status, otherSave.status, otherRead.status]) + .toEqual([401, 401, 403, 403]); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + test('requires authentication and the configured operator before recalculating geometry', async () => { + const recalculate = jest.spyOn(require('../services/roofGeometryService').prototype, 'recalculateEstimates') + .mockResolvedValue({ totalArea: 100 }); + const app = buildApp(); + + const unauthenticated = await request(app) + .post('/api/customer-projects/projects/399/geometry/recalculate'); + const other = await request(app) + .post('/api/customer-projects/projects/399/geometry/recalculate') + .set('Authorization', authHeader('other-user')); + const operator = await request(app) + .post('/api/customer-projects/projects/399/geometry/recalculate') + .set('Authorization', authHeader()); + + expect([unauthenticated.status, other.status, operator.status]).toEqual([401, 403, 200]); + expect(recalculate).toHaveBeenCalledTimes(1); + expect(recalculate).toHaveBeenCalledWith(399); + }); + + test('returns safe validation and storage errors without project or customer enumeration', async () => { + const app = buildApp(); + const invalid = await request(app).post('/api/customer-projects/not-an-id/geometry') + .set('Authorization', authHeader()).send({ projectName: 'Secret project', customerName: 'Secret customer' }); + expect(invalid.status).toBe(400); + expect(JSON.stringify(invalid.body)).not.toMatch(/not-an-id|Secret project|Secret customer/i); + + mockExecute.mockRejectedValueOnce(Object.assign(new Error('customer Secret customer, project Secret project'), { + code: 'ER_NO_REFERENCED_ROW_2' + })); + const failed = await request(app).post('/api/customer-projects/999/geometry') + .set('Authorization', authHeader()).send({ + roofType: 'gable', roofMaterial: 'tegl', scope: 'roof_replacement', width: 8, length: 12, + pitch: 30, eaveOverhang: 0.4, gableOverhang: 0.3, wallHeight: 2.5, + baseArea: 96, roofCoveringArea: 110.85, ridgeHeight: 2.31, + edges: {}, planes: [], openings: [], provenance: {}, roofGeometry: {} + }); + expect(failed.status).toBe(500); + expect(failed.body).toEqual({ success: false, error: 'Taggeometrien kunne ikke gemmes' }); + }); + + test('exposes the rebuilt canonical snapshot only to the configured operator', async () => { + const snapshot = { signature: 'a'.repeat(64), artifact: { schema: 'roof_quote_snapshot_v1' } }; + const build = jest.spyOn(RoofQuoteSnapshotService.prototype, 'buildFromProject').mockResolvedValue(snapshot); + const app = buildApp(); + + const unauthenticated = await request(app).get('/api/customer-projects/projects/399/roof-quote-snapshot'); + const other = await request(app) + .get('/api/customer-projects/projects/399/roof-quote-snapshot') + .set('Authorization', authHeader('other-user')); + const operator = await request(app) + .get('/api/customer-projects/projects/399/roof-quote-snapshot') + .set('Authorization', authHeader()); + + expect([unauthenticated.status, other.status, operator.status]).toEqual([401, 403, 200]); + expect(operator.body).toEqual({ success: true, snapshot }); + expect(build).toHaveBeenCalledTimes(1); + expect(build).toHaveBeenCalledWith(399); + }); + + test('returns deterministic snapshot blockers without allowing an approval bypass', async () => { + jest.spyOn(RoofQuoteSnapshotService.prototype, 'buildFromProject').mockRejectedValue(Object.assign( + new Error('Roof quote is incomplete'), + { status: 422, code: 'ROOF_QUOTE_INCOMPLETE', blockers: [{ code: 'STALE_PACKAGE_FORMULA', deterministic: true }] } + )); + + const response = await request(buildApp()) + .get('/api/customer-projects/projects/399/roof-quote-snapshot') + .set('Authorization', authHeader()); + + expect(response.status).toBe(422); + expect(response.body).toMatchObject({ + success: false, + code: 'ROOF_QUOTE_INCOMPLETE', + blockers: [{ code: 'STALE_PACKAGE_FORMULA', deterministic: true }] + }); + }); + + test('legacy realism approval alias surfaces deterministic blockers from canonical approval', async () => { + jest.spyOn(QuoteRealismService.prototype, 'approveSnapshot').mockRejectedValue(Object.assign( + new Error('Roof quote is incomplete'), + { status: 422, code: 'ROOF_QUOTE_INCOMPLETE', blockers: [{ code: 'STALE_LINE_PRICE', deterministic: true }] } + )); + + const response = await request(buildApp()) + .post('/api/customer-projects/projects/399/realism-approval') + .set('Authorization', authHeader()) + .send({ signature: 'old', acceptedBlockers: ['STALE_LINE_PRICE'] }); + + expect(response.status).toBe(422); + expect(response.body).toMatchObject({ + code: 'ROOF_QUOTE_INCOMPLETE', + blockers: [{ code: 'STALE_LINE_PRICE', deterministic: true }] + }); + expect(QuoteRealismService.prototype.approveSnapshot).toHaveBeenCalledWith(399, 'old', 'configured-operator'); + }); + + test('exposes a real canonical approval endpoint only to the configured operator', async () => { + const snapshot = { signature: 'a'.repeat(64), artifact: { schema: 'roof_quote_snapshot_v1' }, approved: true }; + const approve = jest.spyOn(QuoteRealismService.prototype, 'approveSnapshot') + .mockResolvedValue(snapshot); + const app = buildApp(); + const body = { expectedSnapshotSignature: snapshot.signature }; + + const unauthenticated = await request(app) + .post('/api/customer-projects/projects/399/roof-quote-snapshot/approve').send(body); + const other = await request(app) + .post('/api/customer-projects/projects/399/roof-quote-snapshot/approve') + .set('Authorization', authHeader('other-user')).send(body); + const operator = await request(app) + .post('/api/customer-projects/projects/399/roof-quote-snapshot/approve') + .set('Authorization', authHeader()).send(body); + + expect([unauthenticated.status, other.status, operator.status]).toEqual([401, 403, 200]); + expect(approve).toHaveBeenCalledTimes(1); + expect(approve).toHaveBeenCalledWith(399, snapshot.signature, 'configured-operator'); + expect(operator.body).toEqual({ success: true, snapshot }); + }); + + test('unified server contains no duplicate direct geometry handlers', () => { + const source = require('fs').readFileSync(require('path').join(__dirname, '../../unified-server.js'), 'utf8'); + expect(source).not.toMatch(/app\.(?:get|post)\('\/api\/customer-projects\/:projectId\/geometry'/); + }); + + test('PDF generation is operator-only and has no client-owned legacy payload path', () => { + const source = require('fs').readFileSync(require('path').join(__dirname, '../../unified-server.js'), 'utf8'); + expect(source).toMatch(/app\.post\('\/api\/pdf\/generate', verifyToken, requireConfiguredProjectOperator/); + expect(source).not.toMatch(/legacy_non_roof/); + expect(source).not.toMatch(/Using data from request body for PDF generation/); + }); + + test('legacy quote PDF download denies non-operators and uses one signed canonical snapshot', () => { + const source = require('fs').readFileSync(require('path').join(__dirname, '../../unified-server.js'), 'utf8'); + const start = source.indexOf("app.get('/api/quotes/:quoteId/pdf'"); + const end = source.indexOf('// PDF generation service with Python backend', start); + const handler = source.slice(start, end); + + expect(handler).toMatch(/^app\.get\('\/api\/quotes\/:quoteId\/pdf', verifyToken, requireConfiguredProjectOperator,/); + expect(handler).toMatch(/SELECT project_id FROM generated_quotes WHERE id = \?/); + expect(handler).toMatch(/roofQuoteSnapshotService\.buildFromProject\(projectId\)/); + expect(handler).toMatch(/assertExpectedSignature\(snapshot, expectedSnapshotSignature\)/); + expect(handler).toMatch(/requireApprovedAnalysis\(projectId, snapshot\.signature\)/); + expect(handler).toMatch(/buildPdfDataFromSnapshot\(snapshot\)/); + expect(handler).not.toMatch(/SELECT \* FROM (?:customer_projects|roof_geometry|project_labor|project_materials)/); + expect(handler).not.toMatch(/new PackageServiceClass/); + }); +}); +// Route fixtures include the live account required by the shared authorization boundary. +beforeEach(() => { + jest.spyOn(require('../services/userService'), 'findByUsername').mockImplementation(async username => ({ id: 1, username, role: 'admin' })); +}); +afterEach(() => jest.restoreAllMocks()); diff --git a/backend/src/__tests__/databaseServiceSchemaHelpers.test.js b/backend/src/__tests__/databaseServiceSchemaHelpers.test.js new file mode 100644 index 0000000..5ec592a --- /dev/null +++ b/backend/src/__tests__/databaseServiceSchemaHelpers.test.js @@ -0,0 +1,43 @@ +const databaseService = require('../services/databaseService'); + +afterEach(() => { + databaseService.pool = null; +}); + +test('ensureTableIndex creates a missing index when the table exists', async () => { + const createSql = 'CREATE INDEX idx_example ON example_table (value)'; + const pool = { + query: jest.fn() + .mockResolvedValueOnce([[]]) + .mockResolvedValueOnce([[{ TABLE_NAME: 'example_table' }]]) + .mockResolvedValueOnce([{}]) + }; + databaseService.pool = pool; + + await databaseService.ensureTableIndex('example_table', 'idx_example', createSql); + + expect(pool.query).toHaveBeenNthCalledWith(3, createSql); +}); + +test('ensureTableIndex skips an optional table that is not installed', async () => { + const pool = { + query: jest.fn() + .mockResolvedValueOnce([[]]) + .mockResolvedValueOnce([[]]) + }; + databaseService.pool = pool; + + await expect(databaseService.ensureTableIndex( + 'material_packages', + 'uq_material_packages_catalog_key', + 'CREATE UNIQUE INDEX uq_material_packages_catalog_key ON material_packages (catalog_key)', + { unique: true } + )).resolves.toBeUndefined(); + + expect(pool.query).toHaveBeenCalledTimes(2); + expect(pool.query.mock.calls[1][0]).toMatch(/INFORMATION_SCHEMA\.TABLES/); + expect(pool.query).not.toHaveBeenCalledWith( + expect.stringMatching(/^CREATE UNIQUE INDEX/), + expect.anything() + ); +}); diff --git a/backend/src/__tests__/failClosedRoofLifecycleMigration.test.js b/backend/src/__tests__/failClosedRoofLifecycleMigration.test.js new file mode 100644 index 0000000..04ffd71 --- /dev/null +++ b/backend/src/__tests__/failClosedRoofLifecycleMigration.test.js @@ -0,0 +1,75 @@ +const path = require('path'); + +const migrationPath = path.join(__dirname, '../../../database/migrations/20260918_fail_closed_roof_lifecycle.js'); +const isMutation = sql => /^\s*(UPDATE|INSERT|DELETE|ALTER|CREATE|DROP|TRUNCATE)\b/i.test(sql); + +const violations = [700, 701, 702, 703, 704].map(id => ({ id })); + +const connectionFixture = ({ remaining = [] } = {}) => { + let violationReads = 0; + return { + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn(), + execute: jest.fn(async (sql, params = []) => { + if (/SELECT mp\.id[\s\S]+FROM material_packages mp/i.test(sql)) { + violationReads += 1; + return [violationReads === 1 ? violations : remaining]; + } + if (/UPDATE material_packages/i.test(sql)) { + expect(sql).toContain("validation_status = 'needs_review'"); + expect(sql).toContain('is_active = 0'); + expect(sql).toContain("pitch_review_required = 1"); + expect(sql).toContain('validated_at = NULL'); + expect(params).toEqual([700, 701, 702, 703, 704]); + return [{ affectedRows: 5 }]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; +}; + +describe('fail-closed active verified roof lifecycle migration', () => { + test('dry-run reports current violations without any writes', async () => { + const connection = connectionFixture(); + const { inspectRoofLifecycleViolations } = require(migrationPath); + + await expect(inspectRoofLifecycleViolations(connection)).resolves.toEqual({ + dryRun: true, + violatingPackageIds: [700, 701, 702, 703, 704], + violationCount: 5 + }); + expect(connection.execute.mock.calls.every(([sql]) => !isMutation(sql))).toBe(true); + }); + + test('atomically transitions every violation to inactive needs_review and verifies zero remain', async () => { + const connection = connectionFixture(); + const pool = { getConnection: jest.fn().mockResolvedValue(connection) }; + const { run } = require(migrationPath); + + await expect(run({ apply: true, pool, log: jest.fn() })).resolves.toEqual({ + dryRun: false, + transitionedPackageIds: [700, 701, 702, 703, 704], + transitionedCount: 5, + remainingViolationCount: 0 + }); + expect(connection.beginTransaction).toHaveBeenCalledTimes(1); + expect(connection.commit).toHaveBeenCalledTimes(1); + expect(connection.rollback).not.toHaveBeenCalled(); + expect(connection.release).toHaveBeenCalledTimes(1); + expect(connection.execute.mock.calls[0][0]).toContain('FOR UPDATE'); + }); + + test('rolls back if the postcondition still finds an active verified roof violation', async () => { + const connection = connectionFixture({ remaining: [{ id: 704 }] }); + const pool = { getConnection: jest.fn().mockResolvedValue(connection) }; + const { run } = require(migrationPath); + + await expect(run({ apply: true, pool, log: jest.fn() })) + .rejects.toThrow('active verified roof lifecycle violations remain: 704'); + expect(connection.commit).not.toHaveBeenCalled(); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/__tests__/genericQuoteDelivery.test.js b/backend/src/__tests__/genericQuoteDelivery.test.js new file mode 100644 index 0000000..57299d0 --- /dev/null +++ b/backend/src/__tests__/genericQuoteDelivery.test.js @@ -0,0 +1,142 @@ +const { GenericQuoteSnapshotService } = require('../services/genericQuoteSnapshotService'); +const { customerDocument, ordrestyringPayload, GenericQuotePdfService } = require('../services/genericQuoteDeliveryService'); +const { fixture, database } = require('./genericQuoteFixtures'); + +const approvedFixture = async () => { + const input = fixture(); const storage = database(input); + const service = new GenericQuoteSnapshotService({ databaseService: storage.db }); + const current = await service.buildFromProject(7); + const snapshot = await service.approveSnapshot(7, current.signature, 'operator'); + return { input, service, snapshot, ...storage }; +}; + +test('customer document and HTML expose detailed, reconciled prices without audit or internal language', async () => { + const { snapshot } = await approvedFixture(); + const doc = customerDocument(snapshot); + const forbidden = /generic_quote_snapshot|signature|approval|approvedAt|approvedBy|floor-1|sourcePackage|packageId|priceVersion|formula|quantityBasis|per_house|Smart Pakke|Lego|Dækningsbidrag|Overhead/i; + + expect(doc).not.toHaveProperty('metadata'); + expect(JSON.stringify(doc)).not.toMatch(forbidden); + expect(doc.lines.materials[0]).toEqual({ name: 'Brædder', quantity: 40, unit: 'm2', unitPrice: 100, lineTotal: 4000 }); + expect(doc.lines.tasks[0]).toEqual({ name: 'Montage', totalHours: 8, rate: 500, lineTotal: 4000 }); + expect(doc.subtotals).toEqual({ materials: 4000, labor: 4000, rentals: 200, references: 300, lineTotal: 8500 }); + expect(doc.customerPriceAdjustment).toEqual(expect.objectContaining({ amount: 3230 })); + expect(doc.subtotals.lineTotal + doc.customerPriceAdjustment.amount).toBe(doc.totals.totalExclVat); + + const html = await new GenericQuotePdfService().generatePdfHtml(doc); + expect(html).not.toMatch(forbidden); + expect(html).toContain('Tilbud – Gulv'); + expect(html).toMatch(/40<\/td>m2<\/td>/); + expect(html).toContain('100,00 kr.'); + expect(html).toContain('4.000,00 kr.'); + expect(html).toContain('8 timer'); + expect(html).toContain('500,00 kr.'); + expect(html).toContain('Linjer i alt'); + expect(html).toContain('Aftalt pristilpasning'); + expect(html).toContain('11.730,00 kr.'); + expect(html).toContain('Moms (25%)'); + expect(html).toContain('2.932,50 kr.'); + expect(html).toContain('14.662,50 kr.'); + expect(html).toContain('Levering og montering af gulv.'); + + const payload = ordrestyringPayload(snapshot); + expect(payload).toMatchObject({ customerNumber: 'C9', projectId: 7, snapshotSignature: snapshot.signature, + snapshotSchema: 'generic_quote_snapshot_v1', approval: snapshot.approval, + totalExclVat: 11730, totalInclVat: 14662.5, description: doc.quoteText }); + expect(payload.lines).toHaveLength(4); +}); + +test('blocks unapproved/tampered documents and internal customer text', async () => { + const { snapshot } = await approvedFixture(); + expect(() => customerDocument({ ...snapshot, approved: false })).toThrow(); + const tampered = JSON.parse(JSON.stringify(snapshot)); tampered.artifact.quoteText = 'Changed'; + expect(() => customerDocument(tampered)).toThrow(); + for (const internalText of [ + 'Smart Pakke Lego ID: 10', + 'packageId: 10', + 'version 3', + 'formula: 2 * 20', + 'mode: per_house', + 'approvedBy: operator', + 'Kildepakke: Gulvpakke (pakke-id 10, pakkeversion 3). Prisversion 6. Mængdegrundlag: 2 huse.', + 'kildepakke: 10; mængdeaudit og beregningsformel 2 × 20', + 'Source package: floor-1 / package version: 3 / price version: 6', + 'Godkendelse: godkendt af operatør; signatur abc123', + 'Canonical snapshot uses manual override modes.', + 'Overhead og dækningsbidrag vises internt.' + ]) { + const input = fixture(); input.quoteText = internalText; + const { db } = database(input); const service = new GenericQuoteSnapshotService({ databaseService: db }); + const current = await service.buildFromProject(7); + const internal = await service.approveSnapshot(7, current.signature, 'operator'); + expect(() => customerDocument(internal)).toThrow(expect.objectContaining({ code: 'INTERNAL_PDF_LANGUAGE' })); + } +}); + +test('HTML and PDF rendering fail closed on Danish internal-language bypasses', async () => { + const { snapshot } = await approvedFixture(); + const doc = customerDocument(snapshot); + const pdf = new GenericQuotePdfService(); + const htmlBypass = { ...doc, quoteText: 'kildepakke: 10, pakke-id 10, pakkeversion 3 og prisversion 6' }; + await expect(pdf.generatePdfHtml(htmlBypass)).rejects.toMatchObject({ code: 'INTERNAL_PDF_LANGUAGE' }); + + const pdfBypass = { ...doc, reservations: ['Mængdegrundlag og mængdeaudit; beregningsformel 2 × 20; godkendt af operatør.'] }; + await expect(pdf.generateQuotePdf(pdfBypass)).rejects.toMatchObject({ code: 'INTERNAL_PDF_LANGUAGE' }); +}); + +test('valid Danish customer copy with nearby innocent words renders as HTML and a real PDF', async () => { + const { snapshot } = await approvedFixture(); + const doc = customerDocument(snapshot); + doc.project.project_name = 'Godkendte materialer til tilbygning'; + doc.quoteText = 'Vi leverer den valgte version af Velux-vinduet og aftaler arbejdet med kunden.'; + doc.lines.materials[0].name = 'Klodset murværk udbedres'; + doc.reservations = ['Kunden underskriver efter gennemgang. Dækningsbrættet bevares.']; + + const pdf = new GenericQuotePdfService(); + const html = await pdf.generatePdfHtml(doc); + expect(html).toContain('Godkendte materialer til tilbygning'); + expect(html).toContain('valgte version af Velux-vinduet'); + expect(html).toContain('Klodset murværk udbedres'); + expect(html).toContain('Kunden underskriver efter gennemgang'); + + const bytes = await pdf.generateQuotePdf(doc); + const parsed = await require('pdf-parse')(bytes); + expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); + expect(parsed.text).toContain('Godkendte materialer til tilbygning'); + expect(parsed.text).toContain('valgte version af Velux-vinduet'); + expect(parsed.text).toContain('Klodset murværk udbedres'); +}, 30000); + +test('delivery revalidates and requires approval before invoking PDF renderer or mocked transport', async () => { + const { service, snapshot, input, connection } = await approvedFixture(); + const render = jest.fn().mockResolvedValue(Buffer.from('%PDF-test')); + await expect(service.withApprovedSnapshot(7, snapshot.signature, s => render(customerDocument(s)))).resolves.toEqual(Buffer.from('%PDF-test')); + expect(render).toHaveBeenCalledTimes(1); + input.materialPrices[0].price_version++; + await expect(service.withApprovedSnapshot(7, snapshot.signature, render)).rejects.toMatchObject({ status: 422 }); + expect(render).toHaveBeenCalledTimes(1); + expect(connection.rollback).toHaveBeenCalled(); +}); + +test('generated PDF text and metadata contain customer prices but no audit or internal data', async () => { + const { snapshot } = await approvedFixture(); + const bytes = await new GenericQuotePdfService().generateQuotePdf(customerDocument(snapshot)); + expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); + const parsed = await require('pdf-parse')(bytes); + const forbidden = /generic_quote_snapshot|signature|approval|approvedAt|approvedBy|floor-1|sourcePackage|packageId|priceVersion|formula|quantityBasis|per_house|Smart Pakke|Lego|Dækningsbidrag|Overhead/i; + expect(parsed.info.Title).toBe('Tilbud – Gulv'); + expect(JSON.stringify(parsed.info)).not.toMatch(forbidden); + expect(parsed.text).not.toMatch(forbidden); + expect(parsed.text).toContain('Levering og montering af gulv.'); + expect(parsed.text).toContain('Brædder'); + expect(parsed.text).toContain('Brædder40m2'); + expect(parsed.text).toContain('100,00 kr.'); + expect(parsed.text).toContain('4.000,00 kr.'); + expect(parsed.text).toContain('8 timer'); + expect(parsed.text).toContain('500,00 kr.'); + expect(parsed.text).toContain('Linjer i alt'); + expect(parsed.text).toContain('Aftalt pristilpasning'); + expect(parsed.text).toContain('11.730,00 kr.'); + expect(parsed.text).toContain('Moms (25%)'); + expect(parsed.text).toContain('14.662,50 kr.'); +}, 30000); diff --git a/backend/src/__tests__/genericQuoteFixtures.js b/backend/src/__tests__/genericQuoteFixtures.js new file mode 100644 index 0000000..baa5530 --- /dev/null +++ b/backend/src/__tests__/genericQuoteFixtures.js @@ -0,0 +1,54 @@ +const fixture = () => ({ + now: '2026-09-20T12:00:00.000Z', + project: { id: 7, customer_number: 'C9', customer_name: 'Anne', project_name: 'Gulv' }, + workspace: { projectId: 7, version: 2, instances: [{ + instanceId: 'floor-1', sourcePackageId: 10, sourcePackageVersion: 3, + quantityBasis: { mode: 'per_house', houseCount: 2, quantityPerHouse: 20, unit: 'm2' }, + materials: [{ id: 101, materialId: 4, name: 'Brædder', quantity: 40, unit: 'm2', unitPrice: 100, priceVersion: 6, formula: '2 * 20' }], + tasks: [{ id: 201, name: 'Montage', totalHours: 8, rate: 500 }], + rentals: [{ id: 301, name: 'Maskine', quantity: 1, unit: 'dag', unitPrice: 200 }], + referenceServices: [{ id: 401, name: 'Levering', quantity: 1, unit: 'stk', unitPrice: 300 }] + }] }, + sourcePackages: [{ id: 10, version: 3, is_active: 1, validation_status: 'verified', category: 'Gulv', catalog_key: null }], + sourcePackageLines: [ + { id: 101, package_id: 10, kind: 'materials', material_id: 4, name: 'Brædder', quantity: 40, unit: 'm2' }, + { id: 201, package_id: 10, kind: 'tasks', name: 'Montage', hours: 8, rate: 500, time_unit: 'total', source_date: '2026-09-18T12:00:00.000Z' }, + { id: 301, package_id: 10, kind: 'rentals', name: 'Maskine', quantity: 1, unit: 'dag', unit_price: 200, source_date: '2026-09-18T12:00:00.000Z' }, + { id: 401, package_id: 10, kind: 'referenceServices', name: 'Levering', quantity: 1, unit: 'stk', unit_price: 300, source_date: '2026-09-18T12:00:00.000Z' } + ], + materialPrices: [{ material_id: 4, price_version: 6, current_unit_price: 100, current_unit: 'm2', source_date: '2026-09-18T12:00:00.000Z', price_source: 'Bygma' }], + quoteText: 'Levering og montering af gulv.', reservations: [] +}); + +const database = input => { + const state = { approval: null, genericQuoteText: input.genericQuoteText }; + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn(async (sql, params) => { + if (sql.startsWith('UPDATE') && sql.includes('genericQuoteText')) { + state.genericQuoteText = JSON.parse(params[0]); + state.approval = null; + return [{ affectedRows: 1 }]; + } + if (sql.startsWith('UPDATE')) { + state.approval = JSON.parse(params[0]); return [{ affectedRows: 1 }]; + } + if (sql.includes('FROM customer_projects')) return [[{ ...input.project, input_normalization_log: JSON.stringify({ + genericQuoteApproval: state.approval, + ...(state.genericQuoteText === undefined ? {} : { genericQuoteText: state.genericQuoteText }) + }) }]]; + if (sql.includes('FROM project_smart_package_workspaces')) return [[{ version: input.workspace.version, workspace_json: JSON.stringify(input.workspace) }]]; + if (sql.includes('FROM generated_quotes')) return [[{ quote_text: input.quoteText }]]; + if (sql.includes('FROM project_site_geometry')) return [input.siteGeometryRows || []]; + if (sql.includes('FROM material_packages')) return [input.sourcePackages]; + if (sql.includes('FROM package_materials')) return [input.sourcePackageLines.filter(line => line.kind !== 'tasks')]; + if (sql.includes('FROM smart_package_tasks')) return [input.sourcePackageLines.filter(line => line.kind === 'tasks')]; + if (sql.includes('FROM material_prices')) return [input.materialPrices]; + if (sql.includes('FROM project_')) return [[]]; + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + return { state, connection, db: { pool: { getConnection: jest.fn(async () => connection) } } }; +}; + +module.exports = { fixture, database }; diff --git a/backend/src/__tests__/genericQuoteRoutes.test.js b/backend/src/__tests__/genericQuoteRoutes.test.js new file mode 100644 index 0000000..933e3e4 --- /dev/null +++ b/backend/src/__tests__/genericQuoteRoutes.test.js @@ -0,0 +1,122 @@ +process.env.JWT_ACCESS_SECRET = 'generic-test-access-only'; +process.env.JWT_REFRESH_SECRET = 'generic-test-refresh-only'; +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const { createGenericQuoteRouter } = require('../routes/genericQuoteRoutes'); +const { GenericQuoteSnapshotService } = require('../services/genericQuoteSnapshotService'); +const { fixture, database } = require('./genericQuoteFixtures'); +const base = '/projects/7/generic-quote-snapshot'; +const textBase = '/projects/7/generic-quote-text'; +const token = username => `Bearer ${jwt.sign({ id: 1, username }, process.env.JWT_ACCESS_SECRET)}`; +const setup = (transportEnabled = true) => { + process.env.AUTH_USERNAME = 'operator'; + const input = fixture(); const { db, connection } = database(input); + const service = new GenericQuoteSnapshotService({ databaseService: db }); + const pdfService = { generateQuotePdf: jest.fn().mockResolvedValue(Buffer.from('%PDF-mocked')) }; + const transport = { send: jest.fn().mockResolvedValue({ id: 'mock-offer-1' }) }; + const app = express(); app.use(express.json()); + app.use(createGenericQuoteRouter({ service, pdfService, ...(transportEnabled ? { transport } : {}) })); + return { app, input, service, pdfService, transport, connection }; +}; + +test('authenticated load/approve/PDF/send uses only persisted snapshot and mocked transport', async () => { + const { app, transport, pdfService } = setup(); + const loaded = await request(app).get(base).set('Authorization', token('operator')).expect(200); + const signature = loaded.body.snapshot.signature; + const body = { projectId: 7, expectedSnapshotSignature: signature }; + await request(app).post(`${base}/send`).set('Authorization', token('operator')).send(body).expect(409); + await request(app).post(`${base}/approve`).set('Authorization', token('operator')).send(body).expect(200); + const pdf = await request(app).post(`${base}/pdf`).set('Authorization', token('operator')).send(body).expect(200); + expect(pdf.headers['content-type']).toContain('application/pdf'); + expect(pdf.headers['x-quote-snapshot-signature']).toBe(signature); + const renderedDocument = pdfService.generateQuotePdf.mock.calls[0][0]; + expect(renderedDocument).not.toHaveProperty('metadata'); + expect(renderedDocument).toMatchObject({ + subtotals: { lineTotal: 8500 }, + customerPriceAdjustment: { label: 'Aftalt pristilpasning', amount: 3230 }, + totals: { totalExclVat: 11730, totalInclVat: 14662.5 } + }); + expect(JSON.stringify(renderedDocument)).not.toContain(signature); + await request(app).post(`${base}/send`).set('Authorization', token('operator')).send(body).expect(200); + expect(transport.send).toHaveBeenCalledWith(expect.objectContaining({ customerNumber: 'C9', snapshotSignature: signature }), { idempotencyKey: signature }); +}); + +test.each(['', '/approve', '/pdf', '/send'])('requires authenticated configured operator for %s', async suffix => { + const { app, connection, transport } = setup(); + const action = auth => { + const req = suffix ? request(app).post(base + suffix).send({}) : request(app).get(base); + return auth ? req.set('Authorization', auth) : req; + }; + await action().expect(401); + await action(token('someone-else')).expect(403); + expect(connection.beginTransaction).not.toHaveBeenCalled(); + expect(transport.send).not.toHaveBeenCalled(); +}); + +test('customer text endpoint requires the configured operator and rejects internal language without writes', async () => { + const { app, connection } = setup(); + const body = { projectId: 7, genericQuoteText: 'Levering og montering af tagrender.' }; + await request(app).post(textBase).send(body).expect(401); + await request(app).post(textBase).set('Authorization', token('someone-else')).send(body).expect(403); + await request(app).post(textBase).set('Authorization', token('operator')) + .send({ ...body, genericQuoteText: 'Intern smart-pakke version 4.' }).expect(422) + .expect(({ body: response }) => expect(response.code).toBe('INTERNAL_PDF_LANGUAGE')); + expect(connection.execute.mock.calls.some(([sql]) => sql.startsWith('UPDATE'))).toBe(false); +}); + +test('customer text endpoint accepts only project identity and bounded text, then snapshot loads saved text', async () => { + const { app } = setup(); + const body = { projectId: 7, genericQuoteText: 'Levering og montering af tagrender.' }; + await request(app).post(textBase).set('Authorization', token('operator')).send({ ...body, artifact: {} }).expect(400); + await request(app).post(textBase).set('Authorization', token('operator')) + .send({ ...body, genericQuoteText: ' ' }).expect(400); + await request(app).post(textBase).set('Authorization', token('operator')) + .send({ ...body, genericQuoteText: 'x'.repeat(5001) }).expect(400); + await request(app).post(textBase).set('Authorization', token('operator')).send(body).expect(200); + const loaded = await request(app).get(base).set('Authorization', token('operator')).expect(200); + expect(loaded.body.snapshot.artifact.quoteText).toBe(body.genericQuoteText); +}); + +test('missing customer text is distinguished without weakening other snapshot prerequisites', async () => { + const { app, input } = setup(); + input.quoteText = ''; + await request(app).get(base).set('Authorization', token('operator')).expect(422) + .expect(({ body }) => expect(body).toMatchObject({ + code: 'GENERIC_QUOTE_INCOMPLETE', reason: 'MISSING_CUSTOMER_TEXT' + })); + + input.sourcePackages[0].is_active = 0; + await request(app).get(base).set('Authorization', token('operator')).expect(422) + .expect(({ body }) => { + expect(body.code).toBe('STALE_PACKAGE_VERSION'); + expect(body).not.toHaveProperty('reason'); + }); +}); + + +test('rejects changed input, client overrides, stale signature and missing transport', async () => { + const { app, service, input, transport } = setup(); + const snapshot = await service.buildFromProject(7); + const body = { projectId: 7, expectedSnapshotSignature: snapshot.signature }; + await service.approveSnapshot(7, snapshot.signature, 'operator'); + for (const extra of [{ projectId: 8 }, { quoteText: 'forged' }, { artifact: {} }, { expectedSnapshotSignature: 'bad' }]) { + await request(app).post(`${base}/send`).set('Authorization', token('operator')).send({ ...body, ...extra }).expect(400); + } + input.quoteText += ' changed'; + await request(app).post(`${base}/send`).set('Authorization', token('operator')).send(body).expect(409); + input.sourcePackages[0].is_active = 0; + await request(app).post(`${base}/pdf`).set('Authorization', token('operator')).send(body).expect(422); + expect(transport.send).not.toHaveBeenCalled(); + const disabled = setup(false); + const current = await disabled.service.buildFromProject(7); + await disabled.service.approveSnapshot(7, current.signature, 'operator'); + await request(disabled.app).post(`${base}/send`).set('Authorization', token('operator')) + .send({ projectId: 7, expectedSnapshotSignature: current.signature }).expect(503); +}); + +// Route fixtures include the live account required by the shared authorization boundary. +beforeEach(() => { + jest.spyOn(require('../services/userService'), 'findByUsername').mockImplementation(async username => ({ id: 1, username, role: 'admin' })); +}); +afterEach(() => jest.restoreAllMocks()); diff --git a/backend/src/__tests__/genericQuoteSnapshotService.test.js b/backend/src/__tests__/genericQuoteSnapshotService.test.js new file mode 100644 index 0000000..3d3ba6c --- /dev/null +++ b/backend/src/__tests__/genericQuoteSnapshotService.test.js @@ -0,0 +1,523 @@ +const { buildSnapshot } = require('../services/genericQuoteSnapshotService'); +const { normalizeGeometry } = require('../services/siteGeometryService'); +const { createHash } = require('crypto'); + +const { fixture, database } = require('./genericQuoteFixtures'); + +const signedSiteGeometry = (revision = 4, coordinates = [ + [10,56],[10.001,56],[10.001,56.001],[10,56.001],[10,56] +]) => { + const site = { + ...normalizeGeometry({ geometry: { type: 'Polygon', coordinates: [coordinates] } }), + revision + }; + site.signature = createHash('sha256').update(JSON.stringify(site)).digest('hex'); + return site; +}; + +const bindSiteGeometryTask = (input, site = signedSiteGeometry()) => { + const area = site.totals.groundAreaM2; + Object.assign(input.sourcePackageLines[1], { + hours: 1, time_per_unit: 1, time_unit: 'per_m2', geometry_basis: 'ground_area' + }); + Object.assign(input.workspace.instances[0], { + geometryData: { siteGeometry: site, baseArea: area }, + siteGeometryTrust: { + source: 'project_site_geometry', projectId: 7, revision: site.revision, + signature: site.signature, groundAreaM2: area + } + }); + Object.assign(input.workspace.instances[0].tasks[0], { + totalHours: area, + quantityProvenance: { + source: 'authoritative_package_child', sourceLineId: '201', sourcePackageId: 10, + sourcePackageVersion: 3, siteGeometryProjectId: 7, siteGeometryRevision: site.revision, + siteGeometrySignature: site.signature + } + }); + input.authoritativeSiteGeometry = { + project_id: 7, revision: site.revision, geometry_json: JSON.stringify(site) + }; + input.siteGeometryRows = [input.authoritativeSiteGeometry]; + return site; +}; + +describe('generic canonical snapshot', () => { + test('binds non-roof quantities, all costs, customer text and provenance deterministically', () => { + const input = fixture(); + const snapshot = buildSnapshot(input); + expect(snapshot.artifact.schema).toBe('generic_quote_snapshot_v1'); + expect(snapshot.artifact.economics).toMatchObject({ subtotal: 8500, totalInclVat: 14662.5 }); + expect(snapshot.artifact.workspace.instances[0].quantityBasis.houseCount).toBe(2); + expect(buildSnapshot(JSON.parse(JSON.stringify(input))).signature).toBe(snapshot.signature); + for (const mutate of [ + x => { x.project.customer_number = 'other'; }, + x => { x.workspace.version++; }, + x => { x.workspace.instances[0].quantityBasis.houseCount++; }, + x => { x.quoteText += ' Ekstra'; }, + x => { x.sourcePackageLines[0].name = 'Andre brædder'; } + ]) { + const changed = fixture(); mutate(changed); + expect(buildSnapshot(changed).signature).not.toBe(snapshot.signature); + } + }); + test.each([ + x => { x.sourcePackages = []; }, + x => { x.sourcePackages[0].is_active = 0; }, + x => { x.sourcePackages[0].validation_status = 'draft'; }, + x => { x.sourcePackages[0].version++; }, + x => { delete x.workspace.instances[0].sourcePackageVersion; }, + x => { x.materialPrices = []; }, + x => { x.materialPrices[0].price_version++; }, + x => { x.materialPrices[0].current_unit_price++; }, + x => { x.materialPrices[0].current_unit = 'pakke'; }, + x => { x.workspace.instances[0].materials[0].quantity = NaN; }, + x => { x.workspace.instances[0].quantityBasis.houseCount = 0; }, + x => { x.workspace.projectId = 8; }, + x => { x.sourcePackages[0].compatible_roof_materials = '["tegl"]'; }, + x => { x.quoteText = ''; } + ])('fails closed for invalid or stale input %#', mutate => { + const input = fixture(); mutate(input); + expect(() => buildSnapshot(input)).toThrow(); + }); + test('treats tagrender as generic scope when no explicit roof contract exists', () => { + const input = fixture(); + input.sourcePackages[0].category = 'Tagrender'; + expect(buildSnapshot(input).signature).toMatch(/^[a-f0-9]{64}$/); + }); + + test('binds the current server price version when the workspace line omits it', () => { + const input = fixture(); + delete input.workspace.instances[0].materials[0].priceVersion; + const snapshot = buildSnapshot(input); + expect(snapshot.artifact.lines.materials[0].priceVersion).toBe(6); + }); + + test('accepts an auditable per-house breakdown with non-uniform quantities', () => { + const input = fixture(); + input.workspace.instances[0].quantityBasis = { + mode: 'per_house_breakdown', + unit: 'løbende m', + houses: [ + { id: 'h1', name: 'Hus 1', quantity: 18 }, + { id: 'h2', name: 'Hus 2', quantity: 22 } + ], + total: 40 + }; + const snapshot = buildSnapshot(input); + expect(snapshot.artifact.workspace.instances[0].quantityBasis).toMatchObject({ + mode: 'per_house_breakdown', total: 40, unit: 'løbende m' + }); + expect(snapshot.artifact.workspace.instances[0].quantityBasis.houses).toHaveLength(2); + }); + + test('requires exactly six unique named houses for stable gutter/downpipe catalog identities', () => { + const input = fixture(); + input.sourcePackages[0].catalog_key = 'gutter-aluminium'; + input.workspace.instances[0].quantityBasis = { + mode: 'per_house_breakdown', unit: 'løbende m', + houses: [{ id: 'h1', name: 'Hus 1', quantity: 18 }, { id: 'h2', name: 'Hus 2', quantity: 22 }], total: 40 + }; + input.workspace.instances[0].sixHouseBasis = { + kind: 'six_house_gutter_downpipe', version: 1, + houses: [{ id: 'h1', name: 'Hus 1', gutterLength: 18, downpipeCount: 2 }, { id: 'h2', name: 'Hus 2', gutterLength: 22, downpipeCount: 2 }], + subtotals: { gutterLength: 40, downpipeCount: 4 } + }; + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ code: 'INVALID_SIX_HOUSE_BASIS' })); + }); + + test('scales gutter children and labor from the reconciled six-house basis', () => { + const input = fixture(); + const houses = Array.from({ length: 6 }, (_, index) => ({ + id: `H${index + 1}`, name: `Hus ${index + 1}`, gutterLength: index + 5, downpipeCount: 1 + })); + const total = houses.reduce((sum, house) => sum + house.gutterLength, 0); + input.sourcePackages[0].price_source_value = 'gutter-aluminium'; + input.sourcePackageLines[0].quantity = 1; + input.sourcePackageLines[0].base_quantity = 1; + input.workspace.instances[0].materials[0].quantity = total; + input.workspace.instances[0].quantityBasis = { mode: 'per_house_breakdown', unit: 'løbende m', + houses: houses.map(house => ({ id: house.id, name: house.name, quantity: house.gutterLength })), total }; + input.workspace.instances[0].sixHouseBasis = { kind: 'six_house_gutter_downpipe', version: 1, houses, + subtotals: { gutterLength: total, downpipeCount: 6 } }; + expect(buildSnapshot(input).artifact.lines.materials[0]).toMatchObject({ quantity: total, baseQuantity: 1 }); + }); + + test('uses the downpipe basis and authoritative pack conversion for each of six houses', () => { + const input = fixture(); + const houses = Array.from({ length: 6 }, (_, index) => ({ + id: `H${index + 1}`, name: `Hus ${index + 1}`, gutterLength: 10, downpipeCount: 1 + })); + input.sourcePackages[0].catalog_key = 'downpipe-plastic'; + Object.assign(input.sourcePackageLines[0], { + quantity: 2, base_quantity: 2, unit: 'sæt', + excel_raw_data: JSON.stringify({ physicalQuantityPerPrimary: 3, piecesPerPurchaseUnit: 2 }) + }); + Object.assign(input.workspace.instances[0].materials[0], { quantity: 9, unit: 'sæt' }); + Object.assign(input.materialPrices[0], { current_unit: 'sæt' }); + input.workspace.instances[0].quantityBasis = { mode: 'per_house_breakdown', unit: 'stk', + houses: houses.map(house => ({ id: house.id, name: house.name, quantity: house.downpipeCount })), total: 6 }; + input.workspace.instances[0].sixHouseBasis = { kind: 'six_house_gutter_downpipe', version: 1, houses, + subtotals: { gutterLength: 60, downpipeCount: 6 } }; + expect(buildSnapshot(input).artifact.lines.materials[0]).toMatchObject({ + quantity: 9, basisInherited: true, geometryBasis: 'six_house_downpipe_count', + baseQuantity: 2, physicalQuantityPerPrimary: 3, piecesPerPurchaseUnit: 2, + physicalQuantity: 18, physicalUnit: 'stk' + }); + }); + + test('rejects fractional drift in an integer downpipe subtotal', () => { + const input = fixture(); + const houses = Array.from({ length: 6 }, (_, index) => ({ + id: `H${index + 1}`, name: `Hus ${index + 1}`, gutterLength: 10, downpipeCount: 1 + })); + input.sourcePackages[0].catalog_key = 'downpipe-aluminium'; + input.workspace.instances[0].quantityBasis = { mode: 'per_house_breakdown', unit: 'stk', + houses: houses.map(house => ({ id: house.id, name: house.name, quantity: house.downpipeCount })), total: 6.0009 }; + input.workspace.instances[0].sixHouseBasis = { kind: 'six_house_gutter_downpipe', version: 1, houses, + subtotals: { gutterLength: 60, downpipeCount: 6.0009 } }; + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ code: 'INVALID_SIX_HOUSE_BASIS' })); + }); + + test('rejects fractional per-house downpipe quantities even when their total is an integer', () => { + const input = fixture(); + const houses = Array.from({ length: 6 }, (_, index) => ({ + id: `H${index + 1}`, name: `Hus ${index + 1}`, gutterLength: 10, downpipeCount: 1 + })); + input.sourcePackages[0].price_source_value = 'downpipe-aluminium'; + input.workspace.instances[0].quantityBasis = { mode: 'per_house_breakdown', unit: 'stk', + houses: houses.map((house, index) => ({ id: house.id, name: house.name, + quantity: index === 0 ? 1.0009 : index === 1 ? 0.9991 : house.downpipeCount })), total: 6 }; + input.workspace.instances[0].sixHouseBasis = { kind: 'six_house_gutter_downpipe', version: 1, houses, + subtotals: { gutterLength: 60, downpipeCount: 6 } }; + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ code: 'INVALID_SIX_HOUSE_BASIS' })); + }); + + test('accepts the stable rental identity emitted by the package catalog', () => { + const input = fixture(); + input.sourcePackageLines[2].id = 'rental-package-10'; + input.workspace.instances[0].rentals[0].id = 'rental-package-10'; + expect(buildSnapshot(input).artifact.lines.rentals[0]).toMatchObject({ id: 'rental-package-10', unitPrice: 200 }); + }); + + test.each([ + ['fabricated labor', input => { input.workspace.instances[0].tasks.push({ id: 999, name: 'Fake', totalHours: 999, rate: 999 }); }], + ['fabricated rental', input => { input.workspace.instances[0].rentals.push({ id: 999, name: 'Fake rental', quantity: 1, unit: 'dag', unitPrice: 99999 }); }], + ['removed child', input => { input.sourcePackageLines = input.sourcePackageLines.filter(line => line.id !== 201); }], + ['unknown child', input => { input.workspace.instances[0].materials[0].id = 999; }] + ])('rejects %s instead of signing persisted workspace lines', (_label, mutate) => { + const input = fixture(); mutate(input); + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ code: 'STALE_PACKAGE_CHILDREN' })); + }); + + test.each([undefined, '2020-01-01T00:00:00.000Z', '2026-09-21T12:00:00.000Z']) + ('rejects missing, ancient, or future current material source dates: %s', sourceDate => { + const input = fixture(); input.materialPrices[0].source_date = sourceDate; + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ code: 'STALE_MATERIAL_PRICE' })); + }); + + test('rejects unaudited 999-hour/999-rate changes to a real labor child', () => { + const input = fixture(); + Object.assign(input.workspace.instances[0].tasks[0], { totalHours: 999, rate: 999 }); + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ code: 'INVALID_MANUAL_OVERRIDE' })); + }); + + test('rejects a 99,999 rental price even when it uses a real child identity', () => { + const input = fixture(); input.workspace.instances[0].rentals[0].unitPrice = 99999; + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ code: 'STALE_SOURCE_PRICE' })); + }); + + test('signs an override only when persistence supplied server-owned provenance', () => { + const input = fixture(); + const audit = { + source: 'workspace_server', reason: 'Measured on site', requestedValue: 41, + author: 'operator', timestamp: '2026-09-19T09:00:00.000Z' + }; + Object.assign(input.workspace.instances[0].materials[0], { + quantity: 41, + quantityProvenance: audit, + manualOverride: audit + }); + const line = buildSnapshot(input).artifact.lines.materials[0]; + expect(line).toMatchObject({ quantity: 41, quantityMode: 'manual', manualOverride: audit }); + }); + + test('refuses to sign attacker-authored override provenance', () => { + const input = fixture(); + Object.assign(input.workspace.instances[0].materials[0], { + quantity: 41, + quantityProvenance: { + source: 'manual', reason: 'Measured on site', author: 'attacker', + timestamp: '2026-09-19T09:00:00.000Z' + } + }); + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ code: 'INVALID_MANUAL_OVERRIDE' })); + }); + + test('accepts direct quantities without any roof geometry', () => { + const input = fixture(); + input.workspace.instances[0].quantityBasis = { mode: 'direct', quantity: 40, unit: 'm2' }; + expect(buildSnapshot(input).signature).toMatch(/^[a-f0-9]{64}$/); + }); + + test('does not misclassify roof geometry provenance as a site-map claim', () => { + const input = fixture(); + Object.assign(input.workspace.instances[0].materials[0], { + quantityProvenance: { + source: 'canonical_geometry', geometryEngine: 'roofReplacementGeometry', + geometryVersion: 9, geometrySignature: 'roof-signature' + } + }); + expect(buildSnapshot(input).signature).toMatch(/^[a-f0-9]{64}$/); + }); + + test('signs only internally consistent server-stamped site-geometry provenance', () => { + const input = fixture(); + const site = signedSiteGeometry(); + const area = site.totals.groundAreaM2; + Object.assign(input.sourcePackages[0], { geometry_basis: 'base_area', geometry_factor: 1 }); + Object.assign(input.sourcePackageLines[0], { quantity: 1, base_quantity: 1, geometry_multiplier: 'base_area' }); + Object.assign(input.workspace.instances[0], { + quantityBasis: { mode: 'direct', quantity: area, unit: 'm2' }, + geometryData: { siteGeometry: site, baseArea: area }, + siteGeometryTrust: { + source: 'project_site_geometry', projectId: 7, revision: 4, + signature: site.signature, groundAreaM2: area + } + }); + Object.assign(input.workspace.instances[0].materials[0], { + quantity: area, geometryBasis: 'base_area', measuredValue: area, + quantityProvenance: { + source: 'authoritative_package_child', sourceLineId: '101', sourcePackageId: 10, + sourcePackageVersion: 3, siteGeometryProjectId: 7, siteGeometryRevision: 4, + siteGeometrySignature: site.signature + } + }); + input.authoritativeSiteGeometry = { + project_id: 7, revision: site.revision, geometry_json: JSON.stringify(site) + }; + + const snapshot = buildSnapshot(input); + expect(snapshot.artifact.lines.materials[0].quantityProvenance).toMatchObject({ + siteGeometryProjectId: 7, siteGeometryRevision: 4, siteGeometrySignature: site.signature + }); + + for (const mutate of [ + value => { value.workspace.instances[0].siteGeometryTrust.signature = 'b'.repeat(64); }, + value => { value.workspace.instances[0].siteGeometryTrust.revision = 3; }, + value => { value.workspace.instances[0].siteGeometryTrust.groundAreaM2 += 1; }, + value => { value.workspace.instances[0].siteGeometryTrust.groundAreaM2 += 0.0005; }, + value => { value.workspace.instances[0].quantityBasis.quantity += 1; }, + value => { value.workspace.instances[0].materials[0].quantityProvenance.siteGeometrySignature = 'c'.repeat(64); }, + value => { value.workspace.instances[0].materials[0].quantityProvenance.geometrySignature = 'a'.repeat(64); value.workspace.instances[0].materials[0].quantityProvenance.siteGeometrySignature = 'c'.repeat(64); }, + value => { value.workspace.instances[0].materials[0].quantityProvenance.geometryVersion = 4; value.workspace.instances[0].materials[0].quantityProvenance.siteGeometryRevision = 3; }, + value => { value.workspace.instances[0].materials[0].quantity_provenance = { geometryVersion: 3, geometrySignature: 'c'.repeat(64), siteGeometryProjectId: 8 }; }, + value => { value.workspace.instances[0].materials[0].geometryVersion = 3; value.workspace.instances[0].materials[0].geometrySignature = 'c'.repeat(64); }, + value => { value.workspace.instances[0].siteGeometryRevision = 3; value.workspace.instances[0].siteGeometrySignature = 'c'.repeat(64); }, + value => { value.workspace.instances[0].geometryData.baseArea += 1; }, + value => { value.workspace.instances[0].geometryData.siteGeometry.projectId = 8; } + ]) { + const forged = JSON.parse(JSON.stringify(input)); + mutate(forged); + expect(() => buildSnapshot(forged)).toThrow(expect.objectContaining({ code: 'STALE_SITE_GEOMETRY' })); + } + }); + + test('blocks a snapshot when map-derived quantities refer to deleted site geometry', () => { + const input = fixture(); + const site = bindSiteGeometryTask(input); + input.authoritativeSiteGeometry = { + project_id: 7, + revision: site.revision + 1, + geometry_json: JSON.stringify({ + schema: 'site_geometry_tombstone_v1', revision: site.revision + 1, + deleted: true, signature: 'd'.repeat(64) + }) + }; + + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ + code: 'STALE_SITE_GEOMETRY', status: 409 + })); + }); + + test('signs a persisted manual base-area override without calculated map provenance', () => { + const input = fixture(); + const canonicalArea = normalizeGeometry({ geometry: { type: 'Polygon', coordinates: [[ + [10,56],[10.001,56],[10.001,56.001],[10,56.001],[10,56] + ]] } }).totals.groundAreaM2; + const audit = { + source: 'workspace_server', reason: 'manual_numeric', requestedValue: 8000, + author: 'operator', timestamp: '2026-09-20T11:00:00.000Z' + }; + Object.assign(input.sourcePackages[0], { geometry_basis: 'base_area', geometry_factor: 1 }); + Object.assign(input.sourcePackageLines[0], { quantity: 1, base_quantity: 1, geometry_multiplier: 'base_area' }); + Object.assign(input.workspace.instances[0], { + quantityBasis: { mode: 'direct', quantity: canonicalArea, unit: 'm2' }, + geometryData: { siteGeometryMethod: 'manual_numeric', baseArea: 8000 } + }); + Object.assign(input.workspace.instances[0].materials[0], { + quantity: 8000, geometryBasis: 'base_area', measuredValue: 8000, + quantityMode: 'manual', quantityProvenance: audit, manualOverride: audit + }); + + const line = buildSnapshot(input).artifact.lines.materials[0]; + expect(line).toMatchObject({ + quantity: 8000, quantityMode: 'manual', manualOverride: audit, + measuredValue: 8000, geometryBasis: 'base_area', formula: '8000 × 1 = 8000' + }); + expect(line.quantityProvenance).not.toHaveProperty('siteGeometrySignature'); + }); +}); + +const { GenericQuoteSnapshotService } = require('../services/genericQuoteSnapshotService'); + +describe('generic persisted lifecycle', () => { + test('saves bounded customer text, invalidates approval atomically and loads it before legacy generated text', async () => { + const input = fixture(); + const { db, connection, state } = database(input); + const service = new GenericQuoteSnapshotService({ databaseService: db }); + const original = await service.buildFromProject(7); + await service.approveSnapshot(7, original.signature, 'operator'); + + await expect(service.saveCustomerText(7, ' Levering og montering af nye tagrender. ')) + .resolves.toMatchObject({ genericQuoteText: 'Levering og montering af nye tagrender.' }); + + expect(state.approval).toBeNull(); + expect(connection.execute.mock.calls.some(([sql]) => sql.includes('genericQuoteText') + && sql.includes('genericQuoteApproval') && !sql.includes('project_description'))).toBe(true); + const reloaded = await service.buildFromProject(7); + expect(reloaded.artifact.quoteText).toBe('Levering og montering af nye tagrender.'); + expect(reloaded.approved).toBe(false); + }); + + test('commits approved snapshot locks before running an external action', async () => { + const input = fixture(); + const { db, connection } = database(input); + const service = new (require('../services/genericQuoteSnapshotService').GenericQuoteSnapshotService)({ databaseService: db }); + const snapshot = await service.buildFromProject(7); + await service.approveSnapshot(7, snapshot.signature, 'operator'); + const commitsBeforeAction = connection.commit.mock.calls.length; + + const result = await service.withApprovedSnapshot(7, snapshot.signature, async approved => { + expect(approved.approved).toBe(true); + expect(connection.commit).toHaveBeenCalledTimes(commitsBeforeAction + 1); + expect(connection.release).toHaveBeenCalledTimes(commitsBeforeAction + 1); + return 'rendered'; + }); + + expect(result).toBe('rendered'); + }); + + test('loads persisted inputs in one transaction, approves exact signature, and invalidates changed text', async () => { + const input = fixture(); const { db, connection } = database(input); + const service = new GenericQuoteSnapshotService({ databaseService: db }); + const snapshot = await service.buildFromProject(7); + expect(snapshot.approved).toBe(false); + expect(snapshot.realityCheck).toMatchObject({ ready: true, blockers: [] }); + const approved = await service.approveSnapshot(7, snapshot.signature, 'operator'); + expect(approved.approved).toBe(true); + expect(approved.approval).toMatchObject({ signature: snapshot.signature, approvedBy: 'operator' }); + expect(connection.execute.mock.calls.some(([sql]) => /roof_geometry/.test(sql))).toBe(false); + expect(connection.execute.mock.calls.some(([sql]) => /FOR UPDATE/.test(sql))).toBe(true); + const taskRead = connection.execute.mock.calls.find(([sql]) => sql.includes('FROM smart_package_tasks')); + expect(taskRead[0]).toContain('task.geometry_basis'); + input.quoteText += ' Ændring'; + expect((await service.buildFromProject(7)).approved).toBe(false); + await expect(service.approveSnapshot(7, snapshot.signature, 'operator')).rejects.toMatchObject({ status: 409 }); + expect(connection.rollback).toHaveBeenCalled(); + expect(connection.release).toHaveBeenCalled(); + }); + + test('locks and uses the authoritative site-geometry row, then rejects deleted or changed rows', async () => { + const input = fixture(); + const site = bindSiteGeometryTask(input); + const { db, connection } = database(input); + const service = new GenericQuoteSnapshotService({ databaseService: db }); + + const snapshot = await service.buildFromProject(7); + expect(snapshot.artifact.lines.tasks[0].quantityProvenance).toMatchObject({ + siteGeometryProjectId: 7, + siteGeometryRevision: site.revision, + siteGeometrySignature: site.signature + }); + const geometryRead = connection.execute.mock.calls.find(([sql]) => sql.includes('FROM project_site_geometry')); + expect(geometryRead).toBeDefined(); + expect(geometryRead[0]).toMatch(/WHERE project_id = \? FOR UPDATE/); + expect(geometryRead[1]).toEqual([7]); + + input.siteGeometryRows = []; + await expect(service.buildFromProject(7)).rejects.toMatchObject({ code: 'STALE_SITE_GEOMETRY', status: 409 }); + + const changed = signedSiteGeometry(5, [ + [10,56],[10.002,56],[10.002,56.001],[10,56.001],[10,56] + ]); + input.siteGeometryRows = [{ project_id: 7, revision: changed.revision, geometry_json: JSON.stringify(changed) }]; + await expect(service.buildFromProject(7)).rejects.toMatchObject({ code: 'STALE_SITE_GEOMETRY', status: 409 }); + + const corruptedInput = fixture(); + const corrupted = signedSiteGeometry(); + corrupted.totals.groundAreaM2 += 0.01; + corrupted.areas[0].groundAreaM2 += 0.01; + bindSiteGeometryTask(corruptedInput, corrupted); + const corruptedDb = database(corruptedInput); + await expect(new GenericQuoteSnapshotService({ databaseService: corruptedDb.db }).buildFromProject(7)) + .rejects.toMatchObject({ code: 'STALE_SITE_GEOMETRY', status: 409 }); + }); + + test.each(['package', 'price'])('revalidates %s dependencies after approval', async dependency => { + const input = fixture(); const { db } = database(input); + const service = new GenericQuoteSnapshotService({ databaseService: db }); + const snapshot = await service.buildFromProject(7); + await service.approveSnapshot(7, snapshot.signature, 'operator'); + if (dependency === 'package') input.sourcePackages[0].validation_status = 'draft'; + else input.materialPrices[0].price_version++; + await expect(service.buildFromProject(7)).rejects.toMatchObject({ status: 422 }); + }); + test('rejects bad identities, missing project and malformed storage without writes', async () => { + const { db, connection } = database(fixture()); + const service = new GenericQuoteSnapshotService({ databaseService: db }); + await expect(service.buildFromProject('bad')).rejects.toMatchObject({ status: 400 }); + connection.execute.mockResolvedValueOnce([[]]); + await expect(service.buildFromProject(7)).rejects.toMatchObject({ status: 404 }); + connection.execute.mockResolvedValueOnce([[fixture().project]]).mockResolvedValueOnce([[{ version: 2, workspace_json: '{' }]]); + await expect(service.buildFromProject(7)).rejects.toMatchObject({ status: 422 }); + expect(connection.execute.mock.calls.some(([sql]) => sql.startsWith('UPDATE'))).toBe(false); + }); +}); + +describe('generic fail-closed boundary', () => { + test('accepts empty SQL JSON roof compatibility lists for a non-roof package', () => { + const input = fixture(); + input.sourcePackages[0].compatible_roof_materials = '[]'; + input.sourcePackages[0].allowed_roof_forms = []; + expect(buildSnapshot(input).signature).toHaveLength(64); + }); + test.each([ + x => { x.sourcePackages[0].allowed_roof_forms = '{broken'; }, + x => { x.workspace.instances[0].materials[0].measuredValue = Infinity; }, + x => { x.workspace.instances[0].quantityBasis.houseCount = 1e9; x.workspace.instances[0].quantityBasis.quantityPerHouse = 1e9; }, + x => { x.reservations = [{}]; }, + x => { x.workspace.instances[0].materials[0].sourcePackageVersion = 99; } + ])('rejects ambiguous or unrepresentable signed inputs %#', mutate => { + const input = fixture(); mutate(input); + expect(() => buildSnapshot(input)).toThrow(); + }); + test.each(['project_materials', 'project_rentals', 'project_labor'])('does not silently omit standalone %s costs', async table => { + const { db, connection } = database(fixture()); + const execute = connection.execute.getMockImplementation(); + connection.execute.mockImplementation(async (sql, params) => { + if (sql.includes(`FROM ${table} `)) return [[table === 'project_labor' + ? { work_breakdown: JSON.stringify([{ name: 'Extra labor', hours: 4 }]) } + : { id: 123, quantity: 1, unit_price: 100, package_instance_id: null }]]; + return execute(sql, params); + }); + await expect(new GenericQuoteSnapshotService({ databaseService: db }).buildFromProject(7)) + .rejects.toMatchObject({ code: 'UNBOUND_PROJECT_LINES' }); + }); + test('rejects selected source packages absent from the authoritative workspace', async () => { + const input = fixture(); input.project.selected_packages = '[99]'; + const { db } = database(input); + await expect(new GenericQuoteSnapshotService({ databaseService: db }).buildFromProject(7)) + .rejects.toMatchObject({ code: 'UNBOUND_SOURCE_PACKAGE' }); + }); +}); diff --git a/backend/src/__tests__/gutterSmartPackageSeed.test.js b/backend/src/__tests__/gutterSmartPackageSeed.test.js new file mode 100644 index 0000000..31ec73c --- /dev/null +++ b/backend/src/__tests__/gutterSmartPackageSeed.test.js @@ -0,0 +1,320 @@ +const { buildGutterPackageDefinitions, ensureMaterial, materialProvenanceNote, definitionContentHash, isUnchangedSeed, resolveApplyMode, seed } = require('../../../database/migrations/20260902_gutter_meter_packages'); +const fs = require('fs'); +const path = require('path'); + +describe('gutter Smart Package seed definitions', () => { + test('defines four gutter and four three-metre downpipe packages with explicit units', () => { + const packages = buildGutterPackageDefinitions(); + expect(packages).toHaveLength(8); + expect(packages.map(pkg => pkg.key)).toEqual([ + 'gutter-zinc', 'gutter-aluminium', 'gutter-steel', 'gutter-plastic', + 'downpipe-zinc', 'downpipe-aluminium', 'downpipe-steel', 'downpipe-plastic' + ]); + packages.forEach(pkg => { + expect(pkg.unitPrice).toBeGreaterThan(0); + expect(pkg.materials.every(line => line.quantity > 0 && line.unitPrice > 0)).toBe(true); + expect(pkg.tasks.every(task => task.timePerUnit > 0 && task.rate === 600)).toBe(true); + }); + packages.slice(0, 4).forEach(pkg => { + expect(pkg.name).toMatch(/Tagrender i/); + expect(pkg.unit).toBe('løbende m'); + expect(pkg.timeUnit).toBe('per_meter'); + expect(pkg.geometryBasis).toBe('fixed'); + expect(pkg.defaultQuantity).toBe(1); + expect(pkg.description).toContain('samlede opmålte længde'); + expect(pkg.tasks.reduce((sum, task) => sum + task.timePerUnit, 0)).toBeCloseTo(0.4, 6); + }); + packages.slice(4).forEach(pkg => { + expect(pkg.name).toMatch(/Nedløbsrør i/); + expect(pkg.unit).toBe('stk'); + expect(pkg.timeUnit).toBe('per_piece'); + expect(pkg.geometryBasis).toBe('fixed'); + expect(pkg.defaultQuantity).toBe(1); + expect(pkg.pipeLengthMetersPerPiece).toBe(3); + expect(pkg.materials).toHaveLength(3); + expect(pkg.materials.find(line => line.componentType === 'pipe')).toMatchObject({ quantity: 1, unit: 'stk', lengthPerPieceMeters: 3 }); + expect(pkg.materials.find(line => line.componentType === 'outlet')).toMatchObject({ quantity: 1, unit: 'stk' }); + expect(pkg.tasks.reduce((sum, task) => sum + task.timePerUnit, 0)).toBeCloseTo(0.54, 6); + expect(pkg.laborTotal).toBe(324); + expect(pkg.priceBasisNote).toContain('3 m rør pr. stk'); + }); + packages.filter(pkg => pkg.key !== 'downpipe-plastic').slice(4).forEach(pkg => { + expect(pkg.materials.find(line => line.componentType === 'holder')).toMatchObject({ quantity: 3, unit: 'stk' }); + }); + expect(packages.slice(4).map(pkg => pkg.materialTotal)).toEqual([477.56, 782.4, 579.59, 493.62]); + expect(packages.find(pkg => pkg.key === 'downpipe-aluminium').materials.map(line => line.sku)).toEqual(['275850460', '275806450', '277871460']); + expect(packages.filter(pkg => pkg.isActive === 1 && pkg.validationStatus === 'verified').map(pkg => pkg.key)) + .toEqual(['gutter-aluminium', 'downpipe-aluminium']); + expect(packages.filter(pkg => pkg.validationStatus === 'needs_review').map(pkg => pkg.key)) + .toEqual(['gutter-zinc', 'gutter-steel', 'gutter-plastic', 'downpipe-zinc', 'downpipe-steel', 'downpipe-plastic']); + expect(packages.find(pkg => pkg.key === 'downpipe-plastic').materials.map(line => line.sku)).toEqual(['8621575', '2231769', '2400968']); + expect(packages.find(pkg => pkg.key === 'downpipe-plastic').materials.find(line => line.componentType === 'holder')).toMatchObject({ + quantity: 2, + unit: 'sæt', + physicalQuantityPerPrimary: 3, + piecesPerPurchaseUnit: 2, + physicalUnit: 'stk' + }); + }); + + test('shows the same split installation time and labor price for every gutter material', () => { + const gutterPackages = buildGutterPackageDefinitions().slice(0, 4); + const expectedTasks = [ + { name: 'Opmåling', timePerUnit: 0.06, rate: 600, order: 1 }, + { name: 'Montage af rendejern', timePerUnit: 0.10, rate: 600, order: 2 }, + { name: 'Montage af tagrender og fittings', timePerUnit: 0.21, rate: 600, order: 3 }, + { name: 'Kontrol af fald', timePerUnit: 0.03, rate: 600, order: 4 } + ]; + + gutterPackages.forEach(pkg => { + expect(pkg.tasks).toEqual(expectedTasks); + expect(pkg.laborTotal).toBe(240); + expect(pkg.unitPrice).toBeCloseTo(pkg.materialTotal + pkg.laborTotal, 2); + expect(pkg.vatStatus).toBe('excl_vat'); + expect(pkg.priceBasisNote).toContain(`Materialer ${pkg.materialTotal} kr./løbende m ekskl. moms`); + expect(pkg.priceBasisNote).toContain('montage 240 kr./løbende m ekskl. moms'); + expect(pkg.priceBasisNote).toContain('0.4 t/løbende m × 600 kr./time ekskl. moms'); + expect(pkg.materials.every(line => line.vatStatus && line.unit && line.unitPrice > 0)).toBe(true); + }); + expect(gutterPackages.map(pkg => pkg.materialTotal)).toEqual([303.18, 186.4, 204.58, 173.12]); + expect(gutterPackages.find(pkg => pkg.key === 'gutter-plastic').materials.map(line => line.sku)).toEqual(['8621252', '1093335']); + expect(gutterPackages.map(pkg => pkg.name)).toEqual([ + 'Tagrender i zink – levering og montering', + 'Tagrender i aluminium – levering og montering', + 'Tagrender i stål – levering og montering', + 'Tagrender i plastik – levering og montering' + ]); + }); + + test('uses a stable content hash and no-ops an unchanged verified seed', () => { + const definition = buildGutterPackageDefinitions()[1]; + const hash = definitionContentHash(definition); + expect(hash).toMatch(/^[a-f0-9]{64}$/); + expect(definitionContentHash(buildGutterPackageDefinitions()[1])).toBe(hash); + expect(isUnchangedSeed({ validation_notes: `[GUTTER_METER:${definition.key}] [CONTENT_HASH:${hash}]`, is_active: definition.isActive, validation_status: definition.validationStatus }, definition)).toBe(true); + expect(isUnchangedSeed({ validation_notes: `[GUTTER_METER:${definition.key}] [CONTENT_HASH:old]`, is_active: definition.isActive, validation_status: definition.validationStatus }, definition)).toBe(false); + }); + + test('keeps derived VAT and price-basis display metadata outside package identity', () => { + const definition = buildGutterPackageDefinitions()[4]; + const changedDisplayMetadata = { + ...definition, + vatStatus: 'display-only-change', + priceBasisNote: 'display-only-change' + }; + + expect(definitionContentHash(changedDisplayMetadata)).toBe(definitionContentHash(definition)); + }); + + test('formats auditable price provenance on every material line', () => { + const line = buildGutterPackageDefinitions()[0].materials[0]; + expect(materialProvenanceNote(line)).toContain('Bygma'); + expect(materialProvenanceNote(line)).toContain('RENDE-150Z'); + expect(materialProvenanceNote(line)).toContain('2025-10-15'); + expect(materialProvenanceNote(line)).toContain('ekskl. moms'); + }); + + test('reuses an existing material SKU without writing a synthetic price row', async () => { + const execute = jest.fn().mockResolvedValueOnce([[{ id: 40 }]]); + await expect(ensureMaterial({ execute }, { sku: 'RENDE-150Z', unitPrice: 245 })) + .resolves.toBe(40); + expect(execute).toHaveBeenCalledTimes(1); + }); + + test('chronological migration order creates package geometry before the gutter seed', () => { + const migrationsDirectory = path.join(__dirname, '../../../database/migrations'); + const migrations = fs.readdirSync(migrationsDirectory).sort(); + const geometryIndex = migrations.findIndex(name => name.endsWith('_smart_package_geometry_contract.sql')); + const gutterIndex = migrations.indexOf('20260902_gutter_meter_packages.js'); + + expect(geometryIndex).toBeGreaterThanOrEqual(0); + expect(gutterIndex).toBeGreaterThan(geometryIndex); + }); + + test('--dry-run always selects read-only mode, even when APPLY is set', () => { + expect(resolveApplyMode(['--dry-run'], { APPLY: '1' })).toBe(false); + expect(resolveApplyMode(['--apply'], {})).toBe(true); + }); + + test('dry-run is read-only and reports every row it would create', async () => { + const connection = { + execute: jest.fn().mockResolvedValue([[]]), + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn() + }; + + const result = await seed({ apply: false, connection }); + const statements = connection.execute.mock.calls.map(([sql]) => sql.trim()); + + expect(statements.length).toBeGreaterThan(0); + statements.forEach(sql => expect(sql).toMatch(/^(SELECT|SHOW|DESCRIBE|EXPLAIN)\b/i)); + expect(statements.join('\n')).not.toMatch(/\b(FOR UPDATE|INSERT|UPDATE|DELETE|REPLACE|LOCK|AUTO_INCREMENT)\b/i); + expect(connection.beginTransaction).not.toHaveBeenCalled(); + expect(connection.commit).not.toHaveBeenCalled(); + expect(connection.rollback).not.toHaveBeenCalled(); + expect(result.mode).toBe('dry-run'); + expect(result.planned.materials).toHaveLength(20); + expect(result.planned.packages).toHaveLength(8); + expect(result.planned.packageVersions).toHaveLength(8); + expect(result.planned.packageVersions.every(row => row.version === 1 && row.action === 'insert')).toBe(true); + }); + + test('apply serializes inspection with an advisory lock and releases it on no-op', async () => { + const definition = buildGutterPackageDefinitions()[1]; + const hash = definitionContentHash(definition); + const connection = { + execute: jest.fn(async (sql) => { + if (/GET_LOCK/i.test(sql)) return [[{ acquired: 1 }]]; + if (/RELEASE_LOCK/i.test(sql)) return [[{ released: 1 }]]; + if (/^\s*SELECT id FROM materials/i.test(sql)) return [[{ id: 40 }]]; + if (/^\s*SELECT id,validation_notes,is_active,validation_status,version/i.test(sql)) { + return [[{ id: 7, validation_notes: `[GUTTER_METER:${definition.key}] [CONTENT_HASH:${hash}]`, is_active: definition.isActive, validation_status: definition.validationStatus, version: 3 }]]; + } + throw new Error(`Unexpected statement: ${sql}`); + }), + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() + }; + + await seed({ apply: true, connection, definitions: [definition] }); + expect(connection.execute.mock.calls[0][0]).toMatch(/GET_LOCK/i); + expect(connection.execute.mock.calls.at(-1)[0]).toMatch(/RELEASE_LOCK/i); + }); + + test('fails closed when a package family already has multiple active versions', async () => { + const definition = buildGutterPackageDefinitions()[0]; + const connection = { + execute: jest.fn(async (sql) => { + if (/^\s*SELECT id FROM materials/i.test(sql)) return [[{ id: 40 }]]; + if (/^\s*SELECT id,validation_notes,is_active,validation_status,version/i.test(sql)) { + return [[ + { id: 9, validation_notes: '[GUTTER_METER:gutter-zinc]', is_active: 1, validation_status: 'verified', version: 4 }, + { id: 7, validation_notes: '[GUTTER_METER:gutter-zinc]', is_active: 1, validation_status: 'verified', version: 3 } + ]]; + } + throw new Error(`Unexpected statement: ${sql}`); + }), + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() + }; + + await expect(seed({ apply: false, connection, definitions: [definition] })) + .rejects.toThrow('multiple active versions'); + }); + + test('archives an active legacy predecessor even when a newer marker row is archived', async () => { + const definition = buildGutterPackageDefinitions()[0]; + const materialIds = new Map(definition.materials.map((line, index) => [line.sku, 40 + index])); + const connection = { + execute: jest.fn(async (sql, params = []) => { + if (/GET_LOCK/i.test(sql)) return [[{ acquired: 1 }]]; + if (/RELEASE_LOCK/i.test(sql)) return [[{ released: 1 }]]; + if (/^\s*SELECT id FROM materials/i.test(sql)) return [[{ id: materialIds.get(params[0]) }]]; + if (/validation_notes LIKE/i.test(sql)) return [[{ id: 9, validation_notes: '[GUTTER_METER:gutter-zinc]', is_active: 0, validation_status: 'archived', version: 2 }]]; + if (/created_by='excel-import'/i.test(sql)) return [[{ id: 7, validation_notes: '', is_active: 1, validation_status: 'verified', version: 1 }]]; + if (/^\s*UPDATE material_packages/i.test(sql)) return [{ affectedRows: 1 }]; + if (/^\s*INSERT INTO material_packages/i.test(sql)) return [{ insertId: 99 }]; + return [{ insertId: 1 }]; + }), + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() + }; + + await seed({ apply: true, connection, definitions: [definition] }); + const archiveCall = connection.execute.mock.calls.find(([sql]) => /^\s*UPDATE material_packages/i.test(sql)); + expect(archiveCall[1]).toEqual([7]); + }); + + test('changed definitions create a new immutable package version and children', async () => { + const definition = buildGutterPackageDefinitions()[0]; + const materialIds = new Map(definition.materials.map((line, index) => [line.sku, 40 + index])); + const connection = { + execute: jest.fn(async (sql, params = []) => { + if (/GET_LOCK/i.test(sql)) return [[{ acquired: 1 }]]; + if (/RELEASE_LOCK/i.test(sql)) return [[{ released: 1 }]]; + if (/^\s*SELECT id FROM materials/i.test(sql)) return [[{ id: materialIds.get(params[0]) }]]; + if (/^\s*SELECT id FROM material_prices/i.test(sql)) return [[{ id: 70 }]]; + if (/^\s*SELECT id,validation_notes,is_active,validation_status,version/i.test(sql)) { + return [[{ id: 7, validation_notes: '[GUTTER_METER:gutter-zinc] [CONTENT_HASH:old]', is_active: 1, validation_status: 'verified', version: 3 }]]; + } + if (/^\s*UPDATE material_packages/i.test(sql)) return [{ affectedRows: 1 }]; + if (/^\s*INSERT INTO material_packages/i.test(sql)) return [{ insertId: 99 }]; + return [{ insertId: 1 }]; + }), + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn() + }; + + const result = await seed({ apply: true, connection, definitions: [definition] }); + const mutationCalls = connection.execute.mock.calls.filter(([sql]) => /^\s*(INSERT|UPDATE|DELETE|REPLACE)\b/i.test(sql)); + const packageArchive = mutationCalls.find(([sql]) => /^\s*UPDATE material_packages/i.test(sql)); + const packageInsert = mutationCalls.find(([sql]) => /^\s*INSERT INTO material_packages/i.test(sql)); + + expect(connection.beginTransaction).toHaveBeenCalledTimes(1); + expect(connection.commit).toHaveBeenCalledTimes(1); + mutationCalls.forEach(([sql, params]) => { + expect((sql.match(/\?/g) || [])).toHaveLength(params.length); + }); + expect(mutationCalls.some(([sql]) => /^\s*DELETE\b/i.test(sql))).toBe(false); + expect(packageArchive).toBeDefined(); + expect(packageArchive[0]).toMatch(/SET is_active\s*=\s*0,\s*validation_status\s*=\s*'archived'/i); + expect(packageArchive[1]).toEqual([7]); + expect(packageInsert).toBeDefined(); + expect(packageInsert[1]).toContain(4); + expect(packageInsert[1]).toContainEqual(expect.stringContaining('[CATALOG_ID:gutter-zinc:v4]')); + expect(mutationCalls.filter(([sql]) => /INSERT INTO (package_materials|smart_package_tasks|package_tasks)/i.test(sql)) + .every(([, params]) => params[0] === 99)).toBe(true); + expect(result.packages[0]).toMatchObject({ packageId: 99, previousPackageId: 7, version: 4, unchanged: false }); + }); + + test('applying an unchanged inactive review definition is a no-op', async () => { + const definition = buildGutterPackageDefinitions()[0]; + const hash = definitionContentHash(definition); + const connection = { + execute: jest.fn(async (sql) => { + if (/GET_LOCK/i.test(sql)) return [[{ acquired: 1 }]]; + if (/RELEASE_LOCK/i.test(sql)) return [[{ released: 1 }]]; + if (/^\s*SELECT id FROM materials/i.test(sql)) return [[{ id: 40 }]]; + if (/^\s*SELECT id,validation_notes,is_active,validation_status,version/i.test(sql)) { + return [[{ id: 8, validation_notes: `[GUTTER_METER:${definition.key}] [CONTENT_HASH:${hash}]`, is_active: 0, validation_status: 'needs_review', version: 4 }]]; + } + throw new Error(`Unexpected statement: ${sql}`); + }), + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() + }; + + const result = await seed({ apply: true, connection, definitions: [definition] }); + expect(connection.beginTransaction).not.toHaveBeenCalled(); + expect(result.packages[0]).toMatchObject({ packageId: 8, version: 4, unchanged: true }); + }); + + test('applying an unchanged definition is a no-op', async () => { + const definition = buildGutterPackageDefinitions()[1]; + const hash = definitionContentHash(definition); + const connection = { + execute: jest.fn(async (sql) => { + if (/GET_LOCK/i.test(sql)) return [[{ acquired: 1 }]]; + if (/RELEASE_LOCK/i.test(sql)) return [[{ released: 1 }]]; + if (/^\s*SELECT id FROM materials/i.test(sql)) return [[{ id: 40 }]]; + if (/^\s*SELECT id FROM material_prices/i.test(sql)) return [[{ id: 70 }]]; + if (/^\s*SELECT id,validation_notes,is_active,validation_status,version/i.test(sql)) { + return [[{ id: 7, validation_notes: `[GUTTER_METER:${definition.key}] [CONTENT_HASH:${hash}]`, is_active: definition.isActive, validation_status: definition.validationStatus, version: 3 }]]; + } + throw new Error(`Unexpected statement: ${sql}`); + }), + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn() + }; + + const result = await seed({ apply: true, connection, definitions: [definition] }); + + expect(connection.execute.mock.calls.every(([sql]) => /^\s*SELECT\b/i.test(sql))).toBe(true); + expect(connection.beginTransaction).not.toHaveBeenCalled(); + expect(connection.commit).not.toHaveBeenCalled(); + expect(result.planned).toEqual({ materials: [], materialPrices: [], packages: [], packageVersions: [] }); + expect(result.packages[0]).toMatchObject({ packageId: 7, version: 3, unchanged: true }); + }); +}); diff --git a/backend/src/__tests__/pdfGenerationService.test.js b/backend/src/__tests__/pdfGenerationService.test.js index 392f684..d3e51df 100644 --- a/backend/src/__tests__/pdfGenerationService.test.js +++ b/backend/src/__tests__/pdfGenerationService.test.js @@ -13,6 +13,7 @@ const buildData = () => ({ customer_name: 'Testkunde', customer_number: '3352', customer_address: 'Røsevangen 44, 3520 Farum', + created_at: '2026-09-13T06:00:00.000Z', description: 'Eksisterende betontag udskiftes efter de valgte poster.' }, geometry: { roofArea: 69.28, roofPitch: 30 }, @@ -33,15 +34,308 @@ describe('PdfGenerationService P0 contract', () => { jest.spyOn(service, 'loadLogos').mockResolvedValue({ mikhaelLogo: '', bygGarantiLogo: '', eliteLogo: '' }); }); + test('renders the exact approved quote text from the canonical artifact, with safe HTML escaping', async () => { + const data = buildData(); + const quoteText = ' Godkendt tilbud: & "arbejde".\n\nKun aftalt omfang. '; + const snapshot = { artifact: { + schema: 'roof_quote_snapshot_v1', customerProject: data.project, + geometry: data.geometry, economics: data.totals, + lines: { materials: data.materials, tasks: data.tasks, rentals: data.rentals }, + quoteText + } }; + const pdfData = service.buildPdfDataFromSnapshot(snapshot); + expect(await service.generateWorkDescription(pdfData)).toBe(quoteText); + const html = await service.generatePdfHtml(pdfData); + expect(html).toContain(' Godkendt tilbud: <tag> & "arbejde".

Kun aftalt omfang.
'); + expect(html).not.toContain(data.project.description); + expect(html).not.toContain(''); + }); + + test('adds a visible DRAFT / DEMO watermark only to explicitly marked demo PDFs', async () => { + const demo = buildData(); + demo.project.project_name = 'DRAFT / DEMO Røsevangen 44'; + demo.quoteText = 'Kun demonstrationsudkast.'; + expect(await service.generatePdfHtml(demo)).toContain('class="draft-demo-watermark">DRAFT / DEMO
'); + expect(await service.generatePdfHtml(buildData())).not.toContain('class="draft-demo-watermark">'); + }); + + test.each([ + { quoteText: 'Tilbuddet er bygget af Smart Pakke-klodser.' }, + { materialName: 'Smart Pakke klods' }, + { taskName: 'canonical_geometry kontrol' }, + { reservation: 'Intern manual_override' }, + { quoteText: 'Kildepakke: Tagpakke (pakke-id 10, pakkeversion 3). Prisversion 6. Mængdegrundlag: 69,3 m².' }, + { materialName: 'kildepakke: 10; mængdeaudit' }, + { taskName: 'Beregningsformel og manual override modes' }, + { reservation: 'Godkendelse: godkendt af operatør; signatur abc123' }, + { projectName: 'Source package roof-1 / package version 3 / price version 6' }, + { customerName: 'Canonical approver approval' }, + { customerAddress: 'Overhead og dækningsbidrag' }, + { quoteText: 'DRAFT / DEMO – kildepakke 10' } + ])('rejects internal workflow jargon at the customer PDF boundary: %p', input => { + const data = buildData(); + expect(() => service.buildPdfDataFromSnapshot({ artifact: { + schema: 'roof_quote_snapshot_v1', + customerProject: { + ...data.project, + project_name: input.projectName || data.project.project_name, + customer_name: input.customerName || data.project.customer_name, + customer_address: input.customerAddress || data.project.customer_address + }, + geometry: data.geometry, + lines: { + materials: [{ ...data.materials[0], name: input.materialName || data.materials[0].name }], + tasks: [{ ...data.tasks[0], name: input.taskName || data.tasks[0].name }], + rentals: data.rentals + }, + economics: data.totals, + reservations: input.reservation ? [{ text: input.reservation }] : [], + quoteText: input.quoteText || 'Kundetilbud på aftalt arbejde.' + } })).toThrow(expect.objectContaining({ code: 'INTERNAL_PDF_LANGUAGE' })); + }); + + test('rejects Danish internal language when HTML rendering is called directly', async () => { + const data = buildData(); + data.quoteText = 'kildepakke: tag-1; pakke-id 10; pakkeversion 3; prisversion 6'; + await expect(service.generatePdfHtml(data)).rejects.toMatchObject({ code: 'INTERNAL_PDF_LANGUAGE' }); + }); + + test.each([undefined, null, '', ' '])('rejects canonical snapshots with missing quote text (%p)', quoteText => { + expect(() => service.buildPdfDataFromSnapshot({ artifact: { + schema: 'roof_quote_snapshot_v1', customerProject: buildData().project, quoteText + } })).toThrow(expect.objectContaining({ code: 'INVALID_ROOF_QUOTE_SNAPSHOT' })); + }); + + test('maps only a verified canonical snapshot artifact into PDF data', () => { + const snapshot = { + signature: 'snapshot-signature', + artifact: { + schema: 'roof_quote_snapshot_v1', + customerProject: { id: 392, project_name: 'Server project', sourcePackageId: 99, approval: 'internal' }, + geometry: { roofType: 'gable' }, + lines: { + materials: [{ name: 'Server material', quantity: 2, unit: 'stk', unitPrice: 10, lineTotal: 20, sourcePackageId: 99, formula: '2 * 10' }], + rentals: [{ name: 'Server rental', quantity: 1, unit: 'dag', unitPrice: 30, lineTotal: 30, priceVersion: 7 }], + references: [{ name: 'Server reference', quantity: 1, unit: 'sum', unitPrice: 40, lineTotal: 40, quantityBasis: 'internal' }], + tasks: [{ name: 'Server task', totalHours: 2, rate: 500, lineTotal: 1000, approver: 'operator' }] + }, + economics: { materialTotal: 20, rentalTotal: 30, referenceTotal: 40, laborTotal: 1000, total: 1880.63 }, + quoteText: 'Server text', + reservations: [{ text: 'Server reservation' }], + packages: [{ instanceId: 'roof-main', packageFormula: { expression: 'area * 1.1' } }] + } + }; + + const pdfData = service.buildPdfDataFromSnapshot(snapshot); + expect(pdfData).toEqual(expect.objectContaining({ + project: { id: 392, project_name: 'Server project' }, + geometry: snapshot.artifact.geometry, + materials: [{ name: 'Server material', quantity: 2, unit: 'stk', unitPrice: 10, lineTotal: 20 }], + rentals: [{ name: 'Server rental', quantity: 1, unit: 'dag', unitPrice: 30, lineTotal: 30 }], + referenceServices: [{ name: 'Server reference', quantity: 1, unit: 'sum', unitPrice: 40, lineTotal: 40 }], + labor: [{ name: 'Server task', totalHours: 2, rate: 500, lineTotal: 1000 }], + tasks: [{ name: 'Server task', totalHours: 2, rate: 500, lineTotal: 1000 }], + totals: { ...snapshot.artifact.economics, enterpriseCosts: 0 }, + quoteText: 'Server text', + reservations: [{ text: 'Server reservation' }], + packageInstances: [] + })); + expect(JSON.stringify(pdfData)).not.toMatch(/sourcePackageId|priceVersion|quantityBasis|formula|approver|approval/); + }); + + test('keeps reconciled manual and package labor totals identical in the PDF projection', () => { + const snapshot = { artifact: { + schema: 'roof_quote_snapshot_v1', + customerProject: { id: 392, project_name: 'Afstemt projekt' }, + geometry: { roofType: 'gable' }, + lines: { + materials: [], rentals: [], references: [], + tasks: [ + { name: 'Tilsyn', totalHours: 1, rate: 700, lineTotal: 700 }, + { name: 'Montage', totalHours: 3, rate: 600, lineTotal: 1800 } + ] + }, + economics: calculateQuoteEconomics({ laborTotal: 2500 }), + quoteText: 'Afstemt arbejde.', + reservations: [] + } }; + + const pdfData = service.buildPdfDataFromSnapshot(snapshot); + + expect(pdfData.tasks.map(task => task.name)).toEqual(['Tilsyn', 'Montage']); + expect(pdfData.tasks.reduce((sum, task) => sum + task.lineTotal, 0)).toBe(2500); + expect(pdfData.totals.laborTotal).toBe(2500); + }); + + test('production roof PDF boundary rejects arbitrary client quote fields', () => { + const source = require('fs').readFileSync(require('path').join(__dirname, '../../unified-server.js'), 'utf8'); + const wholeRoute = source.slice(source.indexOf("app.post('/api/pdf/generate'"), source.indexOf('// Auth login endpoint')); + const route = wholeRoute.slice(0, wholeRoute.indexOf('// Explicit authenticated legacy_non_roof boundary.')); + expect(route).toContain("['expectedSnapshotSignature', 'projectId']"); + expect(route).toContain('roofQuoteSnapshotService.buildFromProject(projectId)'); + expect(route).toContain('buildPdfDataFromSnapshot(snapshot)'); + expect(route).toContain('requireApprovedAnalysis(projectId, snapshot.signature)'); + expect(route).not.toMatch(/const \{[^}]*\b(?:project|materials|totals|quoteText|reservations)\b[^}]*\} = req\.body/); + }); + + test('builds the service payload with request-body package instances intact', () => { + const packageInstances = [{ instanceId: 'gutter-1', formula: '2 × 10 m = 20 m' }]; + + const referenceServices = [{ name: 'Tagrendemaling', quantity: 10, unitPrice: 160 }]; + const payload = service.buildPdfData({ + projectData: { id: 392 }, + geometryData: { roofArea: 69.28 }, + materialsData: [], + rentalsData: [], + referenceServicesData: referenceServices, + laborData: [], + totalsData: { total: 100 }, + packageInstancesData: packageInstances + }); + + expect(payload.packageInstances).toBe(packageInstances); + expect(payload.referenceServices).toBe(referenceServices); + expect(payload.tasks).toBe(payload.labor); + }); + + test('normalizes package scope from a persisted workspace_json string', () => { + const workspaceJson = JSON.stringify({ + instances: [{ + instanceId: 'gutter-1', + name: 'Tagrender i zink', + unit: 'fallback-unit', + geometry: { + formula: ' 2 tagsider × 10 m = 20 løbende m ', + quantityMode: 'calculated', + calculatedQuantity: '20', + unit: 'løbende m' + } + }] + }); + + expect(service.normalizePackageInstances(workspaceJson)).toEqual([{ + instanceId: 'gutter-1', + name: 'Tagrender i zink', + formula: '2 tagsider × 10 m = 20 løbende m', + mode: 'Beregnet fra Geometri', + quantity: 20, + unit: 'løbende m' + }]); + }); + + test('partitions persisted reference services out of rentals exactly once', () => { + const duplicatedReference = { + material_name: 'Gulvlægning reference', + material_category: 'Referenceydelse', + quantity: 10, + unit: 'm²', + unit_price: 500, + total_price: 5000, + package_instance_id: 'floor-1' + }; + const persistedReference = { + rental_name: 'Gulvlægning reference', + rental_category: 'Referenceydelse', + quantity: 10, + unit: 'm²', + unit_price: 500, + total_price: 5000, + package_instance_id: 'floor-1', + notes: JSON.stringify({ lineType: 'reference_service', packageInstanceId: 'floor-1' }) + }; + + const result = service.partitionPdfLines([ + { material_name: 'Gulvbræt', quantity: 10, unit: 'm²', unit_price: 200 }, + duplicatedReference, + { material_name: 'Stillads', material_category: 'Udlejning', quantity: 1, unit: 'sum', unit_price: 1000 } + ], [persistedReference]); + + expect(result.materials.map(line => line.material_name)).toEqual(['Gulvbræt']); + expect(result.rentals).toEqual([ + expect.objectContaining({ material_name: 'Stillads' }) + ]); + expect(result.referenceServices).toEqual([ + expect.objectContaining({ rental_name: 'Gulvlægning reference' }) + ]); + }); + + test('normalizes canonical labor breakdown fields without losing totalCost', () => { + expect(service.normalizeLaborBreakdown([{ + name: 'Montage', + totalHours: '8', + rate: '600', + totalCost: '4800', + description: 'Montering på tagfladen' + }], { hourly_rate: 580 })).toEqual([{ + task_name: 'Montage', + name: 'Montage', + description: 'Montering på tagfladen', + totalHours: 8, + hours: 8, + rate: 600, + hourly_rate: 600, + totalCost: 4800, + total: 4800 + }]); + }); + + test('normalizes legacy labor breakdown aliases from persisted JSON', () => { + const breakdown = JSON.stringify([{ + task: 'Nedtagning', + hours: '4.5', + hourly_rate: '620', + cost: '2790', + notes: 'Forsigtig demontering' + }]); + + expect(service.normalizeLaborBreakdown(breakdown, { hourly_rate: 580 })).toEqual([ + expect.objectContaining({ + name: 'Nedtagning', + description: 'Forsigtig demontering', + totalHours: 4.5, + rate: 620, + totalCost: 2790, + total: 2790 + }) + ]); + }); + + test('renders canonical labor name, totalHours, rate and totalCost', async () => { + const data = buildData(); + data.labor = data.tasks = [{ + name: 'Kanonisk montage', + totalHours: 8, + rate: 600, + totalCost: 4800 + }]; + + const html = await service.generatePdfHtml(data); + + expect(html).toContain('Kanonisk montage'); + expect(html).toContain('8 timer'); + expect(html).toContain('600,00 kr'); + expect(html).toContain('4.800,00 kr'); + }); + + test('renders reference services and their economic total separately exactly once', async () => { + const data = buildData(); + data.referenceServices = [{ name: 'Tagrendemaling', quantity: 10, unit: 'm', unitPrice: 160 }]; + data.totals.referenceTotal = 1600; + + const html = await service.generatePdfHtml(data); + + expect((html.match(/Tagrendemaling/g) || [])).toHaveLength(1); + expect(html).toContain('Øvrige ydelser'); + expect(html).toContain('Øvrige ydelser1.600,00 kr'); + }); + test('renders every economic component so the total is transparent', async () => { const html = await service.generatePdfHtml(buildData()); expect(html).toContain('Materialer44.981,80 kr'); - expect(html).toContain('Udlejning og øvrige ydelser14.000,00 kr'); - expect(html).toContain('Arbejdsløn44.080,00 kr'); - expect(html).toContain('Direkte subtotal103.061,80 kr'); - expect(html).toContain('Overhead (15%)15.459,27 kr'); - expect(html).toContain('Dækningsbidrag (20%)23.704,21 kr'); + expect(html).toContain('Leje og materiel14.000,00 kr'); + expect(html).toContain('Arbejde44.080,00 kr'); + expect(html).toContain('Entrepriseomkostninger (fast)39.163,48 kr'); expect(html).toContain('Pris ekskl. moms142.225,28 kr'); expect(html).toContain('Moms (25%)35.556,32 kr'); expect(html).toContain('177.781,60 kr'); @@ -64,13 +358,20 @@ describe('PdfGenerationService P0 contract', () => { const html = await service.generatePdfHtml(data); expect(html).toContain('Røsevangen 44, 3520 Farum'); - expect(html).toContain('Kundenr. ........... 3352'); - expect(html).not.toContain('Kundenr. ........... 392'); + expect(html).toContain('Kundenr.: 3352'); expect(html).toContain('Tagbeklædningsareal (prisgrundlag): 69.3 m²'); - expect(html).toContain('OPGAVE 1: Montering af nye betontagsten og fastgørelse'); - expect(html).toContain('Ca. 69,3 m² tagflade (18.0 timer)'); - expect(html).toContain('Baneundertag (74.8 m²)'); - expect(html).toContain('Taglægter (59.6 lbm)'); + expect(html).toContain('Montering af nye betontagsten og fastgørelse'); + expect(html).toContain('18 timer'); + expect(html).toContain('74,8 m²'); + expect(html).toContain('59,6 lbm'); + }); + + test('renders canonical reservation objects by their persisted text', async () => { + const data = buildData(); + data.reservations = [{ id: 'scope', text: 'Stillads er eksplicit udeladt.' }]; + const html = await service.generatePdfHtml(data); + expect(html).toContain('Stillads er eksplicit udeladt.'); + expect(html).not.toContain('[object Object]'); }); test('contains selected scope and does not invent unselected work', async () => { @@ -93,7 +394,108 @@ describe('PdfGenerationService P0 contract', () => { total_price: 14000 }]; const html = await service.generatePdfHtml(data); - expect(html).toContain('Stillads og kollektiv faldsikring (1.0 sum) - 14.000,00 kr'); + expect(html).toContain('Stillads og kollektiv faldsikring'); + expect(html).toContain('1 sum'); + }); + + test('escapes all operator-controlled PDF text to prevent stored HTML and SSRF', async () => { + const data = buildData(); + const payload = ''; + data.project.project_name = payload; + data.project.customer_name = payload; + data.project.customer_address = payload; + data.project.description = payload; + data.materials[0].name = payload; + data.rentals[0].name = payload; + data.labor[0].name = payload; + data.tasks = data.labor; + data.reservations = [payload]; + data.packageInstances = [{ instanceId: 'x', name: payload, formula: payload, mode: 'Manuel', quantity: 1, unit: 'stk' }]; + const html = await service.generatePdfHtml(data); + expect(html).not.toContain(''); + expect(html).not.toContain('src="http://127.0.0.1:9999/secret"'); + expect(html).toContain('<img src="http://127.0.0.1:9999/secret">'); + }); + + test('keeps internal Lego block metadata out of the customer PDF without changing totals', async () => { + const data = buildData(); + data.packageInstances = [{ + instanceId: 'gutter-1', + name: 'Tagrender i zink', + formula: '2 tagsider × 10 m = 20 løbende m', + mode: 'Beregnet fra Geometri', + quantity: 20, + unit: 'løbende m', + total: 10863.6 + }]; + const html = await service.generatePdfHtml(data); + expect(html).not.toContain('VALGTE SMART PAKKE-KLODSER'); + expect(html).not.toContain('2 tagsider × 10 m = 20 løbende m'); + expect(html).not.toContain('Beregnet fra Geometri'); + expect(html).toContain('177.781,60 kr'); + expect(html).not.toContain('10.863,60 kr'); + }); + + test('renders a customer-ready sales layout without internal audit jargon or margin labels', async () => { + const data = buildData(); + data.materials[0] = { + ...data.materials[0], + geometryBasis: 'roof_area', + formula: '69,28 × 10,1 = 700', + quantityProvenance: { source: 'canonical_geometry' } + }; + const html = await service.generatePdfHtml(data); + + expect(html).toContain('Tilbud P-392'); + expect(html).toContain('Materialer og produkter'); + expect(html).toContain('Antal'); + expect(html).toContain('Enhedspris ekskl. moms'); + expect(html).toContain('64,25971 kr'); + expect(html).toContain('Arbejde'); + expect(html).toContain('76 timer'); + expect(html).toContain('580,00 kr'); + expect(html).toContain('Entrepriseomkostninger'); + expect(html).toContain('Accept af tilbud'); + expect(html).toContain('gyldigt til 13.10.2026'); + expect(html).not.toContain('MÆNGDEGRUNDLAG'); + expect(html).not.toContain('roof_area'); + expect(html).not.toContain('canonical_geometry'); + expect(html).not.toContain('Overhead'); + expect(html).not.toContain('Dækningsbidrag'); + }); + + test('keeps displayed three-decimal quantities arithmetically consistent with unit price and line total', async () => { + const data = buildData(); + data.materials = [{ name: 'Edge line', quantity: 54.73224986986657, unit: 'stk', unitPrice: 912.8335743892882, lineTotal: 49961.44 }]; + const html = await service.generatePdfHtml(data); + expect(html).toContain('54,732 stk'); + expect(html).toContain('912,8378 kr'); + expect(html).toContain('49.961,44 kr'); + }); + + test('escapes the stable quote number in the HTML title', async () => { + const data = buildData(); + data.project.project_number = ''; + const html = await service.generatePdfHtml(data); + expect(html).not.toContain(' { + const data = buildData(); + data.materials[0] = { + ...data.materials[0], + geometryBasis: 'roof_area', measuredValue: 110.85, measuredUnit: 'm²', + baseQuantity: 0.85, wasteFactor: 1.06, + rounding: { method: 'ceil', decimals: 0 }, + formula: '110,85 × 0,85 × 1,06 → 100', + quantityMode: 'calculated', quantityProvenance: { source: 'geometry', geometrySignature: 'geo-5' } + }; + const html = await service.generatePdfHtml(data); + expect(html).not.toContain('MÆNGDEGRUNDLAG'); + expect(html).not.toContain('Basis: roof_area'); + expect(html).not.toContain('110,85 × 0,85 × 1,06 → 100'); + expect(html).not.toContain('calculated / geometry'); }); test('uses a compact task flow without forcing every task onto a new page', async () => { @@ -108,12 +510,28 @@ describe('PdfGenerationService P0 contract', () => { const html = await service.generatePdfHtml(data); - expect(html).toContain('OPGAVER OG ARBEJDSLØN'); - expect(html).toContain('OPGAVE 8: Opgave 8'); + expect(html).toContain('

Arbejde

'); + expect(html).toContain('Opgave 8'); expect(html).not.toContain('page-break-before: always'); expect(html).not.toContain('Ingen materialelinjer registreret'); }); + test('blocks external HTTP requests in the PDF browser', async () => { + let requestHandler; + const page = { + setRequestInterception: jest.fn().mockResolvedValue(undefined), + on: jest.fn((event, handler) => { if (event === 'request') requestHandler = handler; }) + }; + await service.configurePdfPage(page); + const external = { url: () => 'https://example.test/track', abort: jest.fn(), continue: jest.fn() }; + const inline = { url: () => 'data:image/png;base64,abc', abort: jest.fn(), continue: jest.fn() }; + requestHandler(external); + requestHandler(inline); + expect(external.abort).toHaveBeenCalled(); + expect(external.continue).not.toHaveBeenCalled(); + expect(inline.continue).toHaveBeenCalled(); + }); + test('uses a configured Chrome executable for Puppeteer', () => { const fs = require('fs'); const previousPath = process.env.PUPPETEER_EXECUTABLE_PATH; diff --git a/backend/src/__tests__/projectCalculationService.test.js b/backend/src/__tests__/projectCalculationService.test.js index 9658bdd..52622d0 100644 --- a/backend/src/__tests__/projectCalculationService.test.js +++ b/backend/src/__tests__/projectCalculationService.test.js @@ -1,6 +1,29 @@ const ProjectCalculationService = require('../services/projectCalculationService'); describe('ProjectCalculationService shared economics', () => { + test('accepts a generic measured Smart Package quote without roof geometry', () => { + const service = new ProjectCalculationService({}); + const projectData = { + geometry: null, + labor: { total_labor_cost: '32688.00', total_work_hours: '54.48', carpenter_count: 2 }, + materials: [{ id: 1 }], + materialTotals: { totalCost: 31756.8, materialCount: 5 }, + rentalTotals: { totalCost: 0, materialCount: 0 } + }; + + expect(() => service.validateProjectData(projectData)).not.toThrow(); + const calculations = service.performCalculations(projectData, { materialTotal: 31756.8, laborTotal: 32688 }); + expect(calculations).toMatchObject({ + materialTotal: 31756.8, + laborTotal: 32688, + totalArea: null, + pricingArea: null, + areaBasis: 'direct_scope', + areaLabel: 'Direkte mængdegrundlag' + }); + expect(() => service.createDetailedBreakdown({ ...projectData, project: { project_name: 'Tagrender' } }, calculations)).not.toThrow(); + }); + test('uses quoteEconomics inputs and roof covering area', () => { const service = new ProjectCalculationService({}); const calculations = service.performCalculations({ diff --git a/backend/src/__tests__/projectQuoteGenerationService.test.js b/backend/src/__tests__/projectQuoteGenerationService.test.js index e208cdf..26e4e14 100644 --- a/backend/src/__tests__/projectQuoteGenerationService.test.js +++ b/backend/src/__tests__/projectQuoteGenerationService.test.js @@ -62,6 +62,31 @@ describe('ProjectQuoteGenerationService', () => { expect(result.ordrestyringMetadata).toBeNull(); }); + test('customer overview reconciles full canonical economics and uses Danish labels', async () => { + const service = new ProjectQuoteGenerationService({}, null); + const result = await service.generateStructuredQuoteDraft(413, { + mode: 'static', + quoteData: { + project: { id: 413, project_name: 'Nyt tag', customer_name: 'Kunde' }, + geometry: { roof_type: 'gable', total_area: 84.96 }, + materials: [{ material_name: 'Tagsten', quantity: 1, unit: 'stk', unit_price: 44037.83 }], + labor: [{ task_name: 'Montage', estimated_hours: 123.88, hourly_rate: 580, totalCost: 72061.4 }], + totals: { + materialTotal: 44037.83, laborTotal: 72061.4, subtotal: 116099.23, + overheadAmount: 17414.88, profitAmount: 26702.82, + totalExclVat: 160216.93, vatAmount: 40054.23, totalInclVat: 200271.16 + } + } + }); + + expect(result.quoteText).toContain('Tagtype: Sadeltag'); + expect(result.quoteText).toContain('123,9 timer'); + expect(result.quoteText).toContain('Entrepriseomkostninger: 44.117,70 kr. ekskl. moms'); + expect(result.quoteText).toContain('Pris ekskl. moms: 160.216,93 kr.'); + expect(result.quoteText).toContain('Moms (25%): 40.054,23 kr.'); + expect(result.quoteText).toContain('Samlet pris: 200.271,16 kr. inkl. moms'); + }); + test('falls back to static draft when Codex introduces new numbers or content', async () => { const codexRunner = jest.fn().mockResolvedValue({ quoteText: 'TAGRENOVERING\n\nProjekt\n • Kunde: Erik Andersen\n • Adresse: Opmålt hos kunde\n • Tagtype: skraat_tag\n • Tagareal: 132 m²\n • Anbefalet løsning: B7 Tag Udskiftning - Komplet\n\nArbejdet omfatter\n • Tagplader montering - Montering af nye plader (32.0 timer)\n • Ekstra stilladsleje (12.0 timer)\n\nMaterialer\n • Eternit B7 tagplader: 145.2 m²\n\nPris\n • Materialer: 35.574,00 kr. ekskl. moms\n • Arbejde: 18.560,00 kr. ekskl. moms\n • Subtotal: 54.134,00 kr. ekskl. moms\n • Moms (25%): 13.533,50 kr.\n • Samlet pris: 77.667,50 kr. inkl. moms', @@ -172,6 +197,36 @@ describe('ProjectQuoteGenerationService', () => { expect(result.quoteText).toContain('Eternit B7 tagplader'); }); + test('normalizes Danish thousands and decimal separators for the AI fact lock', () => { + const service = new ProjectQuoteGenerationService({}, null); + expect(service.extractNumericTokens('Samlet pris 200.271,16 kr.')).toContain('200271.16'); + expect(service.extractNumericTokens('Moms 40.054,23 kr.')).toContain('40054.23'); + }); + + test.each([ + 'Vi udfører tagarbejdet og maler hele facaden. Samlet pris: 850,00 kr. inkl. moms.', + 'Vi udfører tagarbejdet og monterer solceller. Samlet pris: 850,00 kr. inkl. moms.', + 'Vi udfører tagarbejdet og giver 25 års garanti. Samlet pris: 850,00 kr. inkl. moms.' + ])('rejects AI customer overview that invents non-numeric scope: %s', async quoteText => { + const codexRunner = jest.fn().mockResolvedValue({ + quoteText, + _codex: { usage: { inputTokens: 10, outputTokens: 10 } } + }); + const service = new ProjectQuoteGenerationService({}, null, { codexRunner }); + const result = await service.generateStructuredQuoteDraft(7, { + mode: 'ai', + quoteData: { + project: { id: 7, project_name: 'Tagrenovering', customer_name: 'Kunde' }, + geometry: { roof_type: 'gable', total_area: 10 }, + materials: [{ material_name: 'Tagsten', quantity: 1, unit: 'stk', unit_price: 100 }], + labor: [{ task_name: 'Montering af tagsten', estimated_hours: 1, hourly_rate: 580 }], + totals: { materials: 100, labor: 580, subtotal: 680, tax: 170, total: 850 } + } + }); + expect(result.method).toBe('static_guardrail_fallback'); + expect(result.quoteText).not.toContain('facaden'); + }); + test('returns the deterministic draft when Codex CLI is unavailable', async () => { const codexRunner = jest.fn().mockRejectedValue(new Error('Codex er ikke logget ind')); const service = new ProjectQuoteGenerationService({}, null, { codexRunner }); diff --git a/backend/src/__tests__/quoteEconomicsService.test.js b/backend/src/__tests__/quoteEconomicsService.test.js index 8b879df..f621999 100644 --- a/backend/src/__tests__/quoteEconomicsService.test.js +++ b/backend/src/__tests__/quoteEconomicsService.test.js @@ -24,6 +24,31 @@ describe('quoteEconomicsService', () => { }); }); + test('includes reference services exactly once in subtotal and VAT', () => { + expect(calculateQuoteEconomics({ + materialTotal: 100, + rentalTotal: 200, + referenceTotal: 300, + laborTotal: 400 + })).toEqual({ + model: 'standard_overhead_profit_v1', + materialTotal: 100, + rentalTotal: 200, + referenceTotal: 300, + laborTotal: 400, + subtotal: 1000, + overheadPercentage: 15, + overheadAmount: 150, + subtotalWithOverhead: 1150, + profitPercentage: 20, + profitAmount: 230, + totalExclVat: 1380, + vatPercentage: 25, + vatAmount: 345, + totalInclVat: 1725 + }); + }); + test('normalizes invalid inputs and rounds each monetary step', () => { expect(calculateQuoteEconomics({ materialTotal: '10.999', rentalTotal: null, laborTotal: 'x' })) .toMatchObject({ materialTotal: 11, rentalTotal: 0, laborTotal: 0, subtotal: 11 }); diff --git a/backend/src/__tests__/quoteRealismService.test.js b/backend/src/__tests__/quoteRealismService.test.js index bbf58de..f81a7c1 100644 --- a/backend/src/__tests__/quoteRealismService.test.js +++ b/backend/src/__tests__/quoteRealismService.test.js @@ -208,6 +208,125 @@ describe('QuoteRealismService', () => { await expect(service.requireApprovedAnalysis(392)).resolves.toMatchObject({ approved: true }); }); + test('never allows acknowledgements to clear deterministic snapshot blockers', () => { + const service = new QuoteRealismService({ pool: {} }); + const analysis = { + signature: 'canonical-signature', + blockers: [], + deterministicBlockers: [{ code: 'STALE_PACKAGE_FORMULA', deterministic: true }], + approvalRequirements: { clarifications: [], blockers: [] } + }; + + expect(service.applyStoredApproval(analysis, { + signature: 'canonical-signature', + acknowledgedClarifications: [], + acceptedBlockers: ['STALE_PACKAGE_FORMULA'] + })).toMatchObject({ approved: false, readyForFixedPrice: false }); + }); + + test('requires explicit approval even when an analysis has no heuristic blockers', async () => { + const service = new QuoteRealismService({ pool: {} }); + jest.spyOn(service, 'getAnalysis').mockResolvedValue({ + signature: 'snapshot-1', readyForFixedPrice: true, approved: false, blockers: [] + }); + + await expect(service.requireApprovedAnalysis(392, 'snapshot-1')).rejects.toMatchObject({ + status: 409, code: 'REALISM_APPROVAL_REQUIRED' + }); + + service.getAnalysis.mockResolvedValue({ + signature: 'snapshot-2', readyForFixedPrice: true, approved: true, blockers: [] + }); + await expect(service.requireApprovedAnalysis(392, 'snapshot-1')).rejects.toMatchObject({ + status: 409, code: 'REALISM_APPROVAL_STALE' + }); + }); + + test('routes legacy analysis approval through the atomic canonical snapshot approval', async () => { + const snapshotService = {}; + const service = new QuoteRealismService({ pool: {} }, snapshotService); + const approvedSnapshot = { signature: 'canonical-snapshot', approved: true }; + jest.spyOn(service, 'approveSnapshot').mockResolvedValue(approvedSnapshot); + + await expect(service.approveAnalysis(392, { signature: 'canonical-snapshot' }, 'jannick')) + .resolves.toBe(approvedSnapshot); + expect(service.approveSnapshot).toHaveBeenCalledWith(392, 'canonical-snapshot', 'jannick'); + }); + + test('loads locked approval analysis history through the transaction connection', async () => { + const poolExecute = jest.fn(() => { throw new Error('pool escape'); }); + const connection = { + execute: jest.fn(async sql => { + if (sql.includes('FROM customer_projects')) return [[{ + id: 392, project_name: 'Betontag', customer_name: 'Kunde', customer_number: '1' + }]]; + if (sql.includes('FROM roof_geometry')) return [[{ roof_covering_area: 100, roof_name: 'Betontag' }]]; + if (sql.includes('FROM project_materials')) return [[{ material_id: 1, total_price: 100 }]]; + if (sql.includes('FROM project_labor')) return [[{ total_work_hours: 10, total_labor_cost: 5800 }]]; + if (sql.includes('FROM project_rentals')) return [[]]; + if (sql.includes('FROM ordrestyring_reference_cases')) return [[]]; + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + const service = new QuoteRealismService({ pool: { execute: poolExecute } }); + + await expect(service.getLockedAnalysis(392, connection)).resolves.toMatchObject({ projectId: 392 }); + expect(poolExecute).not.toHaveBeenCalled(); + expect(connection.execute.mock.calls.some(([sql]) => sql.includes('FROM ordrestyring_reference_cases'))).toBe(true); + }); + + test('uses one locked transaction to rebuild, verify, approve, and return the approved snapshot', async () => { + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn().mockResolvedValue([{ affectedRows: 1 }]) + }; + const before = { signature: 'canonical-snapshot', approved: false, artifact: { schema: 'roof_quote_snapshot_v1' } }; + const after = { ...before, approved: true, approval: { approved: true, approvedBy: 'jannick' } }; + const snapshotService = { + buildFromProject: jest.fn().mockResolvedValueOnce(before).mockResolvedValueOnce(after), + assertExpectedSignature: jest.fn().mockReturnValue(before) + }; + const service = new QuoteRealismService({ pool: { getConnection: jest.fn().mockResolvedValue(connection) } }, snapshotService); + jest.spyOn(service, 'getLockedAnalysis').mockResolvedValue({ + approvalRequirements: { clarifications: ['confirm'], blockers: ['reservation'] } + }); + + await expect(service.approveSnapshot(392, before.signature, 'jannick')).resolves.toEqual(after); + + expect(connection.beginTransaction).toHaveBeenCalledTimes(1); + expect(snapshotService.buildFromProject).toHaveBeenNthCalledWith(1, 392, { + connection, forUpdate: true, manageTransaction: false + }); + expect(snapshotService.assertExpectedSignature).toHaveBeenCalledWith(before, before.signature); + expect(connection.execute.mock.calls[0][0]).toContain('JSON_SET'); + expect(connection.execute.mock.calls[0][1][1]).toBe(392); + expect(snapshotService.buildFromProject).toHaveBeenNthCalledWith(2, 392, { + connection, forUpdate: true, manageTransaction: false + }); + expect(connection.commit).toHaveBeenCalledTimes(1); + expect(connection.rollback).not.toHaveBeenCalled(); + expect(connection.release).toHaveBeenCalledTimes(1); + }); + + test('rolls back canonical approval before any write when the expected signature is stale', async () => { + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn() + }; + const mismatch = Object.assign(new Error('mismatch'), { status: 409, code: 'ROOF_QUOTE_SIGNATURE_MISMATCH' }); + const snapshotService = { + buildFromProject: jest.fn().mockResolvedValue({ signature: 'current' }), + assertExpectedSignature: jest.fn(() => { throw mismatch; }) + }; + const service = new QuoteRealismService({ pool: { getConnection: jest.fn().mockResolvedValue(connection) } }, snapshotService); + + await expect(service.approveSnapshot(392, 'stale', 'jannick')).rejects.toBe(mismatch); + expect(connection.execute).not.toHaveBeenCalled(); + expect(connection.commit).not.toHaveBeenCalled(); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.release).toHaveBeenCalledTimes(1); + }); + test('drops unusable historical rows instead of inventing a benchmark', () => { const service = new QuoteRealismService({ pool: {} }); expect(service.cleanHistoryRows([ diff --git a/backend/src/__tests__/roofGeometryService.test.js b/backend/src/__tests__/roofGeometryService.test.js index 7783647..dfdc063 100644 --- a/backend/src/__tests__/roofGeometryService.test.js +++ b/backend/src/__tests__/roofGeometryService.test.js @@ -1,6 +1,110 @@ const RoofGeometryService = require('../services/roofGeometryService'); +const SUPPORTED_ESTIMATES = [ + ['gable', 1.2, 1.2, 144], + ['pult', 0.9, 0.85, 76.5], + ['flat', 1.0, 0.8, 80], + ['hip', 1.2, 1.25, 150], + ['mansard', 1.5, 1.8, 270] +]; + describe('RoofGeometryService roof material persistence', () => { + test.each(SUPPORTED_ESTIMATES)( + 'uses the canonical %s complexity and labor rates', + (roofType, expectedComplexity, expectedHoursPerM2, expectedHours) => { + const service = new RoofGeometryService({}); + const complexity = service.calculateComplexityFactor({ + roofType, + accessDifficulty: 'let', + roofPitch: roofType === 'flat' ? null : 30 + }); + + expect(complexity).toBe(expectedComplexity); + expect(service.estimateWorkHours(100, complexity, roofType)).toBe(expectedHours); + expect(service.estimateWorkHours(100, 1, roofType)).toBe(100 * expectedHoursPerM2); + } + ); + + test.each(['flat', 'mansard'])( + 'ignores retained pitch when estimating canonical %s roofs', roofType => { + const service = new RoofGeometryService({}); + expect(service.calculateComplexityFactor({ + roofType, + accessDifficulty: 'let', + roofPitch: 40 + })).toBe(roofType === 'flat' ? 1 : 1.5); + } + ); + + test.each(['komplekst', 'unknown-roof', undefined])( + 'fails closed when estimating unsupported roof type %p', + roofType => { + const service = new RoofGeometryService({}); + expect(() => service.calculateComplexityFactor({ roofType })).toThrow('Unsupported roof type'); + expect(() => service.estimateWorkHours(100, 1, roofType)).toThrow('Unsupported roof type'); + } + ); + + test('reads persisted legacy komplekst geometry without treating it as gable', async () => { + const execute = jest.fn().mockResolvedValue([[ + { + project_id: 399, + roof_type: 'komplekst', + total_area: '100.00', + roof_covering_area: '140.00', + length_main: '12.00', + width_main: '8.00', + roof_pitch: '35.00', + geometry_json: null + } + ]]); + const service = new RoofGeometryService({ pool: { execute } }); + + await expect(service.getRoofGeometry(399)).resolves.toMatchObject({ + roofType: 'komplekst', + baseArea: 100, + roofCoveringArea: 140 + }); + }); + + test.each(['constructor', '__proto__', 'toString', 'unknown-roof'])( + 'reads persisted unsupported value %p as text without a prototype lookup', + async roofType => { + const execute = jest.fn().mockResolvedValue([[ + { roof_type: roofType, total_area: '100.00', roof_pitch: '30.00', geometry_json: null } + ]]); + const service = new RoofGeometryService({ pool: { execute } }); + + await expect(service.getRoofGeometry(399)).resolves.toMatchObject({ roofType }); + } + ); + + test('recalculates canonical estimates from persisted geometry modifiers', async () => { + const row = { + roof_type: 'gable', + total_area: '100.00', + roof_pitch: '30.00', + has_dormers: 1, + has_chimneys: 1, + has_skylights: 1, + access_difficulty: 'svær', + geometry_json: null + }; + const execute = jest.fn(async sql => ( + sql.includes('SELECT * FROM roof_geometry') ? [[row]] : [{ affectedRows: 1 }] + )); + const service = new RoofGeometryService({ pool: { execute } }); + + await expect(service.recalculateEstimates(399)).resolves.toEqual({ + complexityFactor: 2, + estimatedWorkHours: 240, + estimatedCarpenters: 5 + }); + expect(execute).toHaveBeenLastCalledWith(expect.stringContaining('UPDATE roof_geometry'), [ + 2, 240, 5, 399 + ]); + }); + test('persists the selected roof material with geometry', async () => { const execute = jest.fn().mockResolvedValue([{ insertId: 1, affectedRows: 1 }]); const service = new RoofGeometryService({ pool: { execute } }); @@ -9,6 +113,7 @@ describe('RoofGeometryService roof material persistence', () => { roofType: 'skraat_tag', roofMaterial: 'tegl', totalArea: 100, + roofCoveringArea: 115.47, roofPitch: 30, lengthMain: 10, widthMain: 8 @@ -21,8 +126,223 @@ describe('RoofGeometryService roof material persistence', () => { await service.saveRoofGeometry(400, { roofType: 'fladt_tag', roof_material: 'tagpap', - totalArea: 80 + totalArea: 80, + roofCoveringArea: 80, + roofPitch: 0, + falls: true, + drainCount: 1, + lengthMain: 10, + widthMain: 8 }); expect(execute.mock.calls[1][1]).toContain('tagpap'); }); + + test('ignores forged calculated geometry and persists a server-recomputed canonical envelope', async () => { + const execute = jest.fn().mockResolvedValue([{ insertId: 7, affectedRows: 1 }]); + const service = new RoofGeometryService({ pool: { execute } }); + + const saved = await service.saveRoofGeometry(399, { + roofType: 'gable', roofMaterial: 'tegl', replacementScope: 'roof_replacement', + width: 8, length: 12, roofPitch: 30, eaveOverhang: 0.4, gableOverhang: 0.3, + wallHeight: 2.6, + baseArea: 1, totalArea: 2, roofCoveringArea: 3, ridgeHeight: 999, + edges: { ridge: 999, eaves: 999 }, + formula: { id: 'forged', area: 'trust me' }, + provenance: { engine: 'browser', version: 999 }, + roofGeometry: { + area: { plan: 4, roofSurface: 5 }, lengths: { ridge: 777 }, + formula: { id: 'also-forged' }, provenance: { engine: 'client' } + } + }); + const [, params] = execute.mock.calls[0]; + + expect(saved).toMatchObject({ + roofType: 'gable', roofMaterial: 'tegl', width: 8, length: 12, pitch: 30, + wallHeight: 2.6, + provenance: { engine: 'roofReplacementGeometry', version: 1 }, + edges: { ridge: 12.6, eaves: 25.2 } + }); + expect(saved.baseArea).toBeCloseTo(110.88, 10); + expect(saved.roofCoveringArea).toBeCloseTo(128.0331956954914, 10); + expect(saved.ridgeHeight).toBeCloseTo(2.5403411844343533, 10); + expect(saved).not.toHaveProperty('signature'); + expect(saved.roofGeometry).toMatchObject({ + area: { plan: saved.baseArea, roofSurface: saved.roofCoveringArea }, + lengths: saved.edges, formula: { id: 'gable-v1' }, + provenance: saved.provenance, version: 1, + signature: expect.stringMatching(/^[a-f0-9]{64}$/) + }); + expect(params).toContain(saved.baseArea); + expect(params).toContain(saved.roofCoveringArea); + expect(params).toContain(JSON.stringify(saved.edges)); + expect(params).not.toContain(999); + expect(params).not.toContain(JSON.stringify({ ridge: 999, eaves: 999 })); + }); + + test('hydrates a canonical geometry round-trip from database JSON columns', async () => { + const canonical = { + roofType: 'pult', roofMaterial: 'staal', scope: 'roof_replacement', + baseArea: 60, roofCoveringArea: 62.12, width: 6, length: 10, + pitch: 15, wallHeight: 2.5, ridgeHeight: 4.11, + edges: { lowJunction: 10, highJunction: 10, verges: 12.42 }, + planes: [{ id: 'main', area: 62.12 }], + openings: [{ type: 'chimney', count: 1 }], + provenance: { engine: 'roofReplacementGeometry', version: 1 } + }; + const row = { + id: 9, project_id: 399, roof_type: 'pult', roof_material: 'staal', + total_area: '60.00', roof_covering_area: '62.12', length_main: '10.00', + width_main: '6.00', roof_pitch: '15.00', wall_height: '2.50', roof_height: '4.11', + replacement_scope: 'roof_replacement', geometry_json: JSON.stringify(canonical), + edges_json: JSON.stringify(canonical.edges), planes_json: canonical.planes, + openings_json: JSON.stringify(canonical.openings), provenance_json: canonical.provenance + }; + const execute = jest.fn().mockResolvedValue([[row]]); + const service = new RoofGeometryService({ pool: { execute } }); + + await expect(service.getRoofGeometry(399)).resolves.toMatchObject(canonical); + }); + + test('round-trips recomputed hip geometry and measured canonical fields', async () => { + const canonical = { + roofType: 'hip', + roofMaterial: 'tegl', + scope: 'roof_replacement', + roofReplacementScope: null, + width: 8, + length: 12, + pitch: 30, + eaveOverhang: 0.4, + gableOverhang: 0.3, + wallHeight: 2.5, + falls: null, + drainCount: null, + lowerRun: null, + lowerPitch: null, + upperRun: null, + upperPitch: null, + baseArea: 96, + roofCoveringArea: 110.85125168440814, + ridgeHeight: 2.309401076758503, + edges: { ridge: 4, hips: { count: 4, each: 5.163977794943222, total: 20.65591117977289 }, eaves: 40 }, + planes: [{ id: 'north', area: 27.712812921102035 }], + openings: [{ id: 'window-1', type: 'skylight', count: 1 }], + provenance: { engine: 'roofReplacementGeometry', version: 1 }, + roofGeometry: { + roofType: 'hip', + area: { plan: 96, roofSurface: 110.85125168440814 }, + rise: 2.309401076758503, + lengths: { ridge: 4, hips: { count: 4, each: 5.163977794943222, total: 20.65591117977289 }, eaves: 40 }, + formula: { id: 'equal-pitch-hip-v1' }, + provenance: { engine: 'roofReplacementGeometry', version: 1 } + } + }; + let storedRow; + const execute = jest.fn(async (sql, params) => { + if (sql.includes('INSERT INTO roof_geometry')) { + const geometryJson = params.find(value => typeof value === 'string' && value.includes('"roofGeometry"')); + storedRow = { id: 17, project_id: 399, geometry_json: geometryJson }; + return [{ insertId: 17, affectedRows: 1 }]; + } + return [[storedRow]]; + }); + const service = new RoofGeometryService({ pool: { execute } }); + + await service.saveRoofGeometry(399, canonical); + const reloaded = await service.getRoofGeometry(399); + + expect(reloaded).toMatchObject({ + roofType: 'hip', roofMaterial: 'tegl', scope: 'roof_replacement', + width: 8, length: 12, pitch: 30, eaveOverhang: 0.4, + gableOverhang: 0.3, wallHeight: 2.5, + provenance: { engine: 'roofReplacementGeometry', version: 1 } + }); + expect(reloaded.baseArea).toBeCloseTo(110.88, 10); + expect(reloaded.roofCoveringArea).toBeCloseTo(128.0331956954914, 10); + expect(reloaded.edges.ridge).toBeCloseTo(3.8, 10); + expect(reloaded.planes).toEqual([]); + expect(reloaded).not.toHaveProperty('roofPitch'); + expect(reloaded.roofGeometry).toMatchObject({ + version: 1, + signature: expect.stringMatching(/^[a-f0-9]{64}$/), + formula: { id: 'equal-pitch-hip-v1' } + }); + }); + + test('accepts legacy aliases only on input and emits canonical names', async () => { + let storedRow; + const execute = jest.fn(async (sql, params) => { + if (sql.includes('INSERT INTO roof_geometry')) { + const geometryJson = params.find(value => typeof value === 'string' && value.includes('"roofType"')); + storedRow = { geometry_json: geometryJson }; + return [{ insertId: 2, affectedRows: 1 }]; + } + return [[storedRow]]; + }); + const service = new RoofGeometryService({ pool: { execute } }); + + await service.saveRoofGeometry(399, { + roofType: 'valmtag', roof_material: 'tegl', replacementScope: 'roof_replacement', + roofWidth: 8, roofLength: 12, roofPitch: 30, eave_overhang: 0.4, + gable_overhang: 0.3, wall_height: 2.5, totalArea: 96, + roof_covering_area: 110.85, roofHeight: 2.31, + edges: { ridge: 4, hips: { count: 4, total: 20.65 } }, planes: [], openings: [] + }); + + const reloaded = await service.getRoofGeometry(399); + expect(reloaded).toMatchObject({ + roofType: 'hip', roofMaterial: 'tegl', scope: 'roof_replacement', + width: 8, length: 12, pitch: 30, eaveOverhang: 0.4, + gableOverhang: 0.3, wallHeight: 2.5 + }); + ['roof_type', 'roof_material', 'replacementScope', 'roofWidth', 'roofLength', 'roofPitch', 'totalArea'] + .forEach(alias => expect(reloaded).not.toHaveProperty(alias)); + }); + + test.each([ + ['missing width', { length: 10, baseArea: 80, roofCoveringArea: 90, roofPitch: 30 }], + ['negative length', { width: 8, length: -10, baseArea: 80, roofCoveringArea: 90, roofPitch: 30 }], + ['pitched roof at zero degrees', { width: 8, length: 10, baseArea: 80, roofCoveringArea: 80, roofPitch: 0 }], + ['pitched roof at ninety degrees', { width: 8, length: 10, baseArea: 80, roofCoveringArea: 80, roofPitch: 90 }], + ['flat roof above ten degrees', { roofType: 'flat', width: 8, length: 10, baseArea: 80, roofCoveringArea: 80, roofPitch: 11 }], + ['negative opening count', { width: 8, length: 10, baseArea: 80, roofCoveringArea: 90, roofPitch: 30, openings: [{ type: 'skylight', count: -1 }] }] + ])('rejects invalid canonical geometry: %s', async (_label, overrides) => { + const execute = jest.fn(); + const service = new RoofGeometryService({ pool: { execute } }); + const input = { roofType: 'gable', roofMaterial: 'tegl', ...overrides }; + + await expect(service.saveRoofGeometry(399, input)).rejects.toMatchObject({ + status: 400, + code: 'ROOF_GEOMETRY_INVALID' + }); + expect(execute).not.toHaveBeenCalled(); + }); + + test('persists normalized modifiers supplied through canonicalGeometryInput', async () => { + const execute = jest.fn().mockResolvedValue([{ insertId: 1, affectedRows: 1 }]); + const service = new RoofGeometryService({ pool: { execute } }); + + await expect(service.saveRoofGeometry(399, { + canonicalGeometryInput: { + roofType: 'gable', width: 10, length: 10, pitch: 30, + hasDormers: true, hasChimneys: true, hasSkylights: true, accessDifficulty: 'svær' + } + })).resolves.toMatchObject({ + hasDormers: true, + hasChimneys: true, + hasSkylights: true, + accessDifficulty: 'svær' + }); + }); + + test('requires a roof material when the scope replaces the roof covering', async () => { + const execute = jest.fn(); + const service = new RoofGeometryService({ pool: { execute } }); + + await expect(service.saveRoofGeometry(399, { + roofType: 'gable', replacementScope: 'roof_replacement', + width: 8, length: 10, baseArea: 80, roofCoveringArea: 92, roofPitch: 30 + })).rejects.toMatchObject({ status: 400, code: 'ROOF_GEOMETRY_INVALID' }); + expect(execute).not.toHaveBeenCalled(); + }); }); diff --git a/backend/src/__tests__/roofPitchCompatibility.test.js b/backend/src/__tests__/roofPitchCompatibility.test.js new file mode 100644 index 0000000..d92148d --- /dev/null +++ b/backend/src/__tests__/roofPitchCompatibility.test.js @@ -0,0 +1,23 @@ +const { roofPitchMatchesContract } = require('../domain/roofPitchCompatibility'); + +test('flat roofs do not require a scalar pitch but reject malformed package bounds', () => { + expect(roofPitchMatchesContract({ form: 'flat', pitch: NaN, minPitch: 0, maxPitch: 10 })).toBe(true); + expect(roofPitchMatchesContract({ form: 'flat', pitch: NaN, minPitch: 20, maxPitch: 10 })).toBe(false); + expect(roofPitchMatchesContract({ form: 'flat', pitch: NaN, minPitch: false, maxPitch: 10 })).toBe(false); +}); + +test('mansard roofs validate both profile pitches', () => { + expect(roofPitchMatchesContract({ form: 'mansard', lowerPitch: 60, upperPitch: 30, minPitch: 20, maxPitch: 70 })).toBe(true); + expect(roofPitchMatchesContract({ form: 'mansard', lowerPitch: 80, upperPitch: 30, minPitch: 20, maxPitch: 70 })).toBe(false); + expect(roofPitchMatchesContract({ form: 'mansard', lowerPitch: null, upperPitch: 30, minPitch: 20, maxPitch: 70 })).toBe(false); + expect(roofPitchMatchesContract({ form: 'mansard', lowerPitch: null, upperPitch: 30, minPitch: null, maxPitch: null })).toBe(false); +}); + +test('ordinary pitched roofs still require a bounded scalar pitch', () => { + expect(roofPitchMatchesContract({ form: 'gable', pitch: 30, minPitch: 20, maxPitch: 45 })).toBe(true); + expect(roofPitchMatchesContract({ form: 'gable', pitch: NaN, minPitch: 20, maxPitch: 45 })).toBe(false); + expect(roofPitchMatchesContract({ form: 'gable', pitch: 10, minPitch: 20, maxPitch: 45 })).toBe(false); + expect(roofPitchMatchesContract({ form: 'gable', pitch: false, minPitch: 0, maxPitch: 45 })).toBe(false); + expect(roofPitchMatchesContract({ form: 'gable', pitch: ' ', minPitch: 0, maxPitch: 45 })).toBe(false); + expect(roofPitchMatchesContract({ form: 'gable', pitch: '30deg', minPitch: 0, maxPitch: 45 })).toBe(false); +}); diff --git a/backend/src/__tests__/roofQuoteCompleteness.test.js b/backend/src/__tests__/roofQuoteCompleteness.test.js new file mode 100644 index 0000000..281c205 --- /dev/null +++ b/backend/src/__tests__/roofQuoteCompleteness.test.js @@ -0,0 +1,181 @@ +const { + REQUIRED_SCOPE_DECISIONS, + validateRoofReplacementCompleteness, + assertRoofReplacementComplete +} = require('../domain/roofQuoteCompleteness'); + +const detailedCompleteScope = () => ({ + version: 1, + replacementType: 'complete_replacement', + existingCovering: 'Tegl', + newCoveringSystem: 'Betontagsten', + components: Object.fromEntries([ + 'covering', 'battens', 'counterBattens', 'underlay', 'deck', 'vaporControl', + 'insulation', 'gutters', 'fascia', 'windboards', 'flashings', 'interior' + ].map(key => [key, 'include'])), + demolition: 'include', + disposal: { decision: 'include', asbestosClassification: 'not_suspect' }, + weatherProtection: 'include', + siteLogistics: Object.fromEntries([ + 'scaffold', 'edgeProtection', 'lift', 'crane', 'permits', 'access' + ].map(key => [key, 'include'])), + penetrations: { status: 'none', items: [] }, + flatRoof: { falls: null, drains: null, parapets: null, upstands: null } +}); + +const completeInput = () => ({ + project: { id: 42, project_name: 'Nyt tag', customer_name: 'Ada' }, + geometry: { + roofType: 'gable', + area: { roofSurface: 100 }, + formula: { id: 'gable-v1', area: 'area formula' }, + provenance: { engine: 'roofReplacementGeometry', version: 1 }, + signature: 'geometry-signature-1' + }, + workspace: { + version: 3, + instances: [{ + instanceId: 'roof', + sourcePackageId: 700, + sourcePackageVersion: 2, + packageFormula: { id: 'complete-roof-v2', version: 2, signature: 'formula-signature-2', expression: 'roof area * factor' }, + geometry: { basis: 'roof_area', formula: '100 m2 * 1.1', calculatedQuantity: 110, sourceGeometryEngine: 'roofReplacementGeometry', sourceGeometryVersion: 1, sourceGeometrySignature: 'geometry-signature-1' }, + materials: [{ id: 'm1', name: 'Tagsten', quantity: 110, unit: 'm2', unitPrice: 100, priceSource: 'catalogue', priceVersion: 8, geometryBasis: 'roof_area', measuredValue: 100, baseQuantity: 1, wasteFactor: 1.1, rounding: { method: 'round', decimals: 3 }, formula: '100 × 1 × 1.1', quantityMode: 'calculated', quantityProvenance: { source: 'geometry' } }], + tasks: [{ id: 't1', name: 'Montage', totalHours: 20, rate: 600, timeUnit: 'total', geometryBasis: 'fixed', measuredValue: 20, baseQuantity: 1, wasteFactor: 1, rounding: { method: 'round', decimals: 3 }, formula: '20 timer', quantityMode: 'calculated', quantityProvenance: { source: 'fixed' } }] + }] + }, + scopeDecisions: detailedCompleteScope(), + reservations: [], + quoteText: 'Vi udskifter taget komplet.' +}); + +describe('roofQuoteCompleteness', () => { + test('is fail-closed and reports every required deterministic blocker', () => { + const result = validateRoofReplacementCompleteness({ approval: { approved: true, acceptedBlockers: ['*'] } }); + const codes = result.blockers.map(blocker => blocker.code); + + expect(result.complete).toBe(false); + expect(codes).toEqual(expect.arrayContaining([ + 'MISSING_PROJECT', 'MISSING_CUSTOMER', 'MISSING_GEOMETRY', + 'MISSING_WORKSPACE', 'MISSING_MATERIALS', 'MISSING_LABOR', + 'MISSING_DETAILED_SCOPE', 'MISSING_RESERVATIONS', 'MISSING_QUOTE_TEXT' + ])); + }); + + test('approval never clears a missing or invalid deterministic dependency', () => { + const input = completeInput(); + delete input.geometry.formula; + input.approval = { approved: true, acceptedBlockers: ['MISSING_GEOMETRY_FORMULA'] }; + + expect(validateRoofReplacementCompleteness(input)).toMatchObject({ + complete: false, + blockers: expect.arrayContaining([expect.objectContaining({ code: 'MISSING_GEOMETRY_FORMULA', deterministic: true })]) + }); + expect(() => assertRoofReplacementComplete(input)).toThrow(expect.objectContaining({ + code: 'ROOF_QUOTE_INCOMPLETE', blockers: expect.any(Array) + })); + }); + + test('accepts an explicitly decided, priced, canonical complete roof replacement', () => { + expect(validateRoofReplacementCompleteness(completeInput())).toEqual({ complete: true, blockers: [] }); + }); + + test('rejects the six-field legacy scope for a complete roof replacement', () => { + const input = completeInput(); + input.scopeDecisions = Object.fromEntries( + REQUIRED_SCOPE_DECISIONS.map(key => [key, { decision: 'included' }]) + ); + + expect(validateRoofReplacementCompleteness(input).blockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'MISSING_DETAILED_SCOPE', path: 'scopeDecisions' }) + ])); + }); + + test.each([ + ['missing', []], + ['message mismatch', [{ + id: 'roof_scope_component_insulation', type: 'explicit_exclusion', source: 'roof_scope', + label: 'Isolering', message: 'Et frit formuleret forbehold.' + }]], + ['arbitrary extra', [ + { + id: 'roof_scope_component_insulation', type: 'explicit_exclusion', source: 'roof_scope', + label: 'Isolering', message: 'Isolering er eksplicit udeladt af tilbuddet.' + }, + { id: 'custom', message: 'Vilkårligt ekstra forbehold' } + ]] + ])('rejects %s reservations instead of deriving exclusions deterministically', (_label, reservations) => { + const input = completeInput(); + input.scopeDecisions.components.insulation = 'exclude'; + input.reservations = reservations; + + expect(validateRoofReplacementCompleteness(input).blockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'RESERVATION_SCOPE_MISMATCH', path: 'reservations' }) + ])); + }); + + test('validates the persisted detailed roof replacement scope fail-closed', () => { + const input = completeInput(); + input.scopeDecisions = { + version: 1, + replacementType: 'complete_replacement', + existingCovering: 'Tegl', + newCoveringSystem: 'Betontagsten', + components: Object.fromEntries([ + 'covering', 'battens', 'counterBattens', 'underlay', 'deck', 'vaporControl', + 'insulation', 'gutters', 'fascia', 'windboards', 'flashings', 'interior' + ].map(key => [key, 'include'])), + demolition: 'include', + disposal: { decision: 'include', asbestosClassification: 'not_suspect' }, + weatherProtection: 'include', + siteLogistics: Object.fromEntries([ + 'scaffold', 'edgeProtection', 'lift', 'crane', 'permits', 'access' + ].map(key => [key, 'include'])), + penetrations: { status: 'none', items: [] }, + flatRoof: { falls: null, drains: null, parapets: null, upstands: null } + }; + + expect(validateRoofReplacementCompleteness(input)).toEqual({ complete: true, blockers: [] }); + delete input.scopeDecisions.siteLogistics.access; + expect(validateRoofReplacementCompleteness(input).blockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'MISSING_SCOPE_DECISION_ACCESS' }) + ])); + }); + + test('requires actual material lines and roof labor tasks; rentals and references never substitute', () => { + const input = completeInput(); + input.workspace.instances[0].materials = []; + input.workspace.instances[0].tasks = []; + input.workspace.instances[0].rentals = [{ name: 'Lift', quantity: 1, unit: 'dag', unitPrice: 900 }]; + input.workspace.instances[0].referenceServices = [{ name: 'Reference', quantity: 1, unit: 'stk', unitPrice: 500 }]; + expect(validateRoofReplacementCompleteness(input).blockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'MISSING_MATERIALS' }), + expect.objectContaining({ code: 'MISSING_LABOR' }) + ])); + }); + + test('blocks every positive active line without positive price, valid unit, or audit metadata', () => { + const input = completeInput(); + const material = input.workspace.instances[0].materials[0]; + material.unitPrice = 0; + material.unit = ' '; + delete material.measuredValue; + expect(validateRoofReplacementCompleteness(input).blockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'MISSING_LINE_PRICE' }), + expect.objectContaining({ code: 'MISSING_LINE_UNIT' }), + expect.objectContaining({ code: 'MISSING_LINE_AUDIT' }) + ])); + }); + + test.each([ + ['package formula signature', input => { delete input.workspace.instances[0].packageFormula.signature; }, 'MISSING_PACKAGE_FORMULA_DEPENDENCY'], + ['geometry engine', input => { delete input.workspace.instances[0].geometry.sourceGeometryEngine; }, 'MISSING_GEOMETRY_DEPENDENCY'], + ['stale geometry engine version', input => { input.workspace.instances[0].geometry.sourceGeometryVersion = 2; }, 'STALE_GEOMETRY'] + ])('blocks missing or stale sourced dependency: %s', (_label, mutate, code) => { + const input = completeInput(); + mutate(input); + expect(validateRoofReplacementCompleteness(input).blockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ code }) + ])); + }); +}); diff --git a/backend/src/__tests__/roofQuoteSnapshotService.test.js b/backend/src/__tests__/roofQuoteSnapshotService.test.js new file mode 100644 index 0000000..83f7760 --- /dev/null +++ b/backend/src/__tests__/roofQuoteSnapshotService.test.js @@ -0,0 +1,621 @@ +const { calculateRoofReplacementGeometry } = require('../domain/roofReplacementGeometry'); +const { + RoofQuoteSnapshotService, + stableStringify, + buildSnapshot, + assertExpectedSignature +} = require('../services/roofQuoteSnapshotService'); + +test('stableStringify preserves mysql2 Date values as ISO strings', () => { + expect(stableStringify({ created_at: new Date('2026-09-13T06:00:00.000Z') })) + .toBe('{"created_at":"2026-09-13T06:00:00.000Z"}'); +}); + +const audit = (basis, measuredValue, source = basis === 'manual' ? 'manual' : 'geometry') => ({ + geometryBasis: basis, + measuredValue, + measuredUnit: basis === 'roof_area' ? 'm²' : 'stk', + baseQuantity: 1, + wasteFactor: 1, + rounding: { method: 'round', decimals: 3 }, + formula: `${measuredValue} × 1 × 1`, + quantityMode: source === 'manual' ? 'manual' : 'calculated', + quantityProvenance: { source } +}); + +const validSource = (id = 700) => ({ + id, version: 2, is_active: 1, validation_status: 'verified', + package_type: 'complete_offer', replacement_scope: 'complete_roof_replacement', + compatible_roof_materials: ['tegl'], allowed_roof_forms: ['gable'], + min_pitch_degrees: 15, max_pitch_degrees: 60, + pitch_verification_status: 'verified', pitch_review_required: 0 +}); + +const validInput = () => ({ + sourcePackages: [validSource()], + project: { + id: 42, + project_name: 'Nyt tag', + project_number: 'P-42', + customer_id: 7, + customer_name: 'Ada Lovelace', + customer_address: 'Tagvej 1' + }, + geometry: { + version: 5, + roofType: 'gable', + roofMaterial: 'tegl', + pitch: 30, + wallHeight: 2.6, + area: { plan: 96, roofSurface: 110.85 }, + lengths: { ridge: 12, eaves: 24 }, + formula: { id: 'gable-v1', area: '2 * length * rafter' }, + provenance: { engine: 'roofReplacementGeometry', version: 1, sourceRevision: 'geo-5' }, + signature: 'geo-signature-5' + }, + workspace: { + version: 3, + geometryVersion: 5, + instances: [{ + instanceId: 'roof-main', + sourcePackageId: 700, + sourcePackageVersion: 2, + currentSourcePackageVersion: 2, + name: 'Komplet tag', + packageFormula: { id: 'roof-area-v2', version: 2, signature: 'formula-signature-2', expression: 'roofSurface * wasteFactor' }, + geometry: { basis: 'roof_area', formula: '110.85 * 1.1', calculatedQuantity: 121.935, sourceGeometryEngine: 'roofReplacementGeometry', sourceGeometryEngineVersion: 1, sourceGeometryVersion: 5, sourceGeometrySignature: 'geo-signature-5' }, + materials: [ + { id: 'm2', name: 'Lægter', quantity: 10, unit: 'stk', unitPrice: 20, total: 1, priceSource: 'catalogue', priceVersion: 4, currentPriceVersion: 4, ...audit('fixed', 10, 'fixed') }, + { id: 'm1', materialId: 701, name: 'Tagsten', quantity: 100, unit: 'stk', unitPrice: 12.345, totalPrice: 999999, priceSource: 'catalogue', priceVersion: 8, currentPriceVersion: 8, ...audit('roof_area', 110.85) } + ], + tasks: [ + { id: 't2', name: 'Oprydning', totalHours: 2, rate: 500, totalCost: 1, timeUnit: 'total', ...audit('fixed', 2, 'fixed') }, + { id: 't1', name: 'Montage', totalHours: 20, rate: 600, totalCost: 1, timeUnit: 'total', ...audit('roof_area', 110.85) } + ], + rentals: [{ id: 'r1', name: 'Lift', quantity: 2, unit: 'dag', unitPrice: 900, total: 1, priceSource: 'supplier', priceVersion: 2, currentPriceVersion: 2, ...audit('fixed', 2, 'fixed') }], + referenceServices: [{ id: 'x1', name: 'Miljøprøve', quantity: 1, unit: 'stk', unitPrice: 1500, total: 1, priceSource: 'history', priceVersion: 1, currentPriceVersion: 1, ...audit('fixed', 1, 'fixed') }] + }] + }, + lines: { + materials: [{ id: 'manual-m', name: 'Skruer', quantity: 2, unit: 'pakke', unitPrice: 50, total: 0, priceSource: 'manual', priceVersion: 1, currentPriceVersion: 1, ...audit('manual', 2) }], + tasks: [{ id: 'manual-t', name: 'Tilsyn', totalHours: 1, rate: 700, totalCost: 0, timeUnit: 'total', ...audit('manual', 1) }], + rentals: [], + references: [] + }, + scopeDecisions: { + replacementType: 'complete_replacement', + existingCovering: 'tegl', + newCoveringSystem: 'tegl', + components: Object.fromEntries([ + 'covering', 'battens', 'counterBattens', 'underlay', 'deck', 'vaporControl', + 'insulation', 'gutters', 'fascia', 'windboards', 'flashings', 'interior' + ].map(key => [key, 'include'])), + demolition: 'include', + disposal: { decision: 'include' }, + weatherProtection: 'include', + siteLogistics: Object.fromEntries([ + 'scaffold', 'edgeProtection', 'lift', 'crane', 'permits', 'access' + ].map(key => [key, 'include'])), + penetrations: { status: 'none', items: [] } + }, + reservations: [], + quoteText: 'Vi udfører komplet tagudskiftning.' +}); + +const reverseArrays = value => { + if (Array.isArray(value)) return value.slice().reverse().map(reverseArrays); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.entries(value).reverse().map(([key, child]) => [key, reverseArrays(child)])); +}; + +describe('roofQuoteSnapshotService', () => { + test.each([ + ['inactive', source => { source.is_active = 0; }], + ['non-verified', source => { source.validation_status = 'draft'; }], + ['pitch unverified', source => { source.pitch_verification_status = 'unverified'; }], + ['pitch review required', source => { source.pitch_review_required = 1; }], + ['missing review clearance', source => { delete source.pitch_review_required; }], + ['incompatible material', source => { source.compatible_roof_materials = ['staal']; }], + ['incompatible form', source => { source.allowed_roof_forms = ['flat']; }], + ['incompatible pitch', source => { source.min_pitch_degrees = 40; }], + ['stale version', source => { source.version = 3; }], + ['missing version', source => { delete source.version; }] + ])('fails closed for a %s source even with an approved signature', (_label, mutate) => { + const input = validInput(); + const approved = buildSnapshot(input); + mutate(input.sourcePackages[0]); + expect(() => assertExpectedSignature(input, approved.signature)).toThrow( + expect.objectContaining({ code: 'ROOF_QUOTE_INCOMPLETE' }) + ); + }); + + test('fails closed when current source state is missing', () => { + const input = validInput(); + input.sourcePackages = []; + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ code: 'ROOF_QUOTE_INCOMPLETE' })); + }); + + test('does not ignore a scalar selected package identity without a version', () => { + const input = validInput(); + input.project.selected_packages = JSON.stringify(701); + input.sourcePackages.push(validSource(701)); + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ + code: 'ROOF_QUOTE_INCOMPLETE', + blockers: expect.arrayContaining([expect.objectContaining({ code: 'STALE_PACKAGE_VERSION' })]) + })); + }); + + test('signs compatible changes to source state canonically', () => { + const input = validInput(); + const before = buildSnapshot(input); + input.sourcePackages[0].max_pitch_degrees = 65; + const after = buildSnapshot(input); + expect(after.artifact.sourcePackages).toEqual(input.sourcePackages); + expect(after.signature).not.toBe(before.signature); + expect(buildSnapshot(reverseArrays(input))).toEqual(after); + }); + + test.each(['workspace', 'selected_packages', 'selected_packages_object', 'material', 'rental', 'labor']) ( + 'loads and revalidates %s-only package sources before honoring approval', async origin => { + const input = validInput(); + const project = { ...input.project }; + const extra = validSource(701); + const rows = [validSource(), extra]; + const materials = []; + const rentals = []; + const tasks = []; + if (origin === 'workspace') { + input.workspace.instances.push({ ...input.workspace.instances[0], instanceId: 'labor-only', + sourcePackageId: 701, materials: [], rentals: [], referenceServices: [] }); + } else if (origin === 'selected_packages') { + project.selected_packages = JSON.stringify([{ id: 701, version: 2 }]); + } else if (origin === 'selected_packages_object') { + project.selected_packages = JSON.stringify({ id: 701, version: 2 }); + } else if (origin === 'labor') { + tasks.push({ ...input.lines.tasks[0], sourcePackageId: 701, sourcePackageVersion: 2 }); + } else { + (origin === 'material' ? materials : rentals).push({ + id: 99, source_package_id: 701, source_package_version: 2, + material_name: 'Extra', quantity: 1, unit: 'stk', unit_price: 10, + notes: JSON.stringify({ lineAudit: audit('manual', 1) }) + }); + } + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn(async (sql, params) => { + if (sql.includes('FROM customer_projects')) return [[project]]; + if (sql.includes('FROM roof_geometry')) return [[{ geometry_json: JSON.stringify({ + ...input.geometry, roofReplacementScope: input.scopeDecisions + }) }]]; + if (sql.includes('FROM project_smart_package_workspaces')) return [[{ + version: input.workspace.version, workspace_json: JSON.stringify(input.workspace) + }]]; + // Model the actual selection: only IDs supplied to this query can be loaded. + if (sql.includes('FROM material_packages')) return [rows.filter(row => params.includes(row.id))]; + if (sql.includes('FROM material_prices')) return [[]]; + if (sql.includes('FROM project_materials')) return [materials]; + if (sql.includes('FROM project_rentals')) return [rentals]; + if (sql.includes('FROM project_labor')) return [[{ work_breakdown: JSON.stringify(tasks) }]]; + if (sql.includes('FROM generated_quotes')) return [[{ quote_text: input.quoteText }]]; + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + const service = new RoofQuoteSnapshotService({ databaseService: { + pool: { getConnection: jest.fn().mockResolvedValue(connection) } + } }); + const baseline = await service.buildFromProject(42); + expect(baseline.readiness.ready).toBe(true); + project.input_normalization_log = JSON.stringify({ quoteRealismApproval: { signature: baseline.signature } }); + expect((await service.buildFromProject(42)).approved).toBe(true); + extra.max_pitch_degrees = 65; + const changed = await service.buildFromProject(42); + expect(changed.signature).not.toBe(baseline.signature); + expect(changed.approved).toBe(false); + for (const patch of [ + { is_active: 0 }, { validation_status: 'draft' }, + { pitch_verification_status: 'unverified' }, { pitch_review_required: 1 }, + { compatible_roof_materials: ['staal'] }, { allowed_roof_forms: ['flat'] }, + { max_pitch_degrees: 20 }, { version: 3 } + ]) { + Object.assign(extra, validSource(701), patch); + await expect(service.buildFromProject(42)).rejects.toMatchObject({ code: 'ROOF_QUOTE_INCOMPLETE' }); + } + rows.pop(); + await expect(service.buildFromProject(42)).rejects.toMatchObject({ code: 'ROOF_QUOTE_INCOMPLETE' }); + expect(connection.rollback).toHaveBeenCalled(); + } + ); + + test('builds one stable canonical signature regardless of input ordering', () => { + const first = buildSnapshot(validInput()); + const reordered = buildSnapshot(reverseArrays(validInput())); + + expect(first.signature).toMatch(/^[a-f0-9]{64}$/); + expect(reordered).toEqual(first); + expect(first.serializedArtifact).toBe(JSON.stringify(first.artifact)); + }); + + test.each([ + ['quote text', input => { input.quoteText += ' Ændret'; }], + ['geometry formula', input => { input.geometry.formula.area += ' + 0'; }], + ['roof material', input => { input.geometry.roofMaterial = 'staal'; input.sourcePackages[0].compatible_roof_materials.push('staal'); }], + ['wall height', input => { input.geometry.wallHeight = 2.7; }], + ['unit price', input => { input.workspace.instances[0].materials[0].unitPrice += 1; }], + ['scope decision', input => { input.scopeDecisions.newCoveringSystem = 'staal'; }] + ])('invalidates the signature when %s changes', (_label, mutate) => { + const before = buildSnapshot(validInput()); + const changed = validInput(); + mutate(changed); + + expect(buildSnapshot(changed).signature).not.toBe(before.signature); + expect(() => assertExpectedSignature(changed, before.signature)).toThrow(expect.objectContaining({ + code: 'ROOF_QUOTE_SIGNATURE_MISMATCH', status: 409, + expectedSignature: before.signature + })); + }); + + test('assertExpectedSignature rehashes the exact artifact instead of trusting a supplied signature', () => { + const snapshot = buildSnapshot(validInput()); + const tampered = JSON.parse(JSON.stringify(snapshot)); + tampered.artifact.quoteText = 'Manipuleret'; + + expect(() => assertExpectedSignature(tampered, snapshot.signature)).toThrow(expect.objectContaining({ + code: 'ROOF_QUOTE_SIGNATURE_MISMATCH' + })); + }); + + test('recomputes every line and all economics without trusting client totals', () => { + const result = buildSnapshot(validInput()); + const material = result.artifact.lines.materials.find(line => line.id === 'm1'); + + expect(material.lineTotal).toBe(1234.5); + expect(material).not.toHaveProperty('total'); + expect(material).not.toHaveProperty('totalPrice'); + expect(result.artifact.economics).toMatchObject({ + materialTotal: 1534.5, + rentalTotal: 1800, + referenceTotal: 1500, + laborTotal: 13700, + subtotal: 18534.5 + }); + }); + + test('persists and signs every per-line quantity audit field', () => { + const input = validInput(); + const before = buildSnapshot(input); + const line = before.artifact.lines.materials.find(item => item.id === 'm1'); + expect(line).toMatchObject({ + geometryBasis: 'roof_area', measuredValue: 110.85, measuredUnit: 'm²', + baseQuantity: 1, wasteFactor: 1, rounding: { method: 'round', decimals: 3 }, + formula: '110.85 × 1 × 1', quantityMode: 'calculated', + quantityProvenance: { source: 'geometry' } + }); + + input.workspace.instances[0].materials[1].measuredValue = 111; + expect(buildSnapshot(input).signature).not.toBe(before.signature); + }); + + test('accepts pitchless flat and profiled mansard source compatibility', () => { + const applyGeometry = (input, geometry) => { + input.geometry = { ...geometry, roofMaterial: 'tegl', wallHeight: 2.6 }; + input.workspace.geometryVersion = geometry.version; + Object.assign(input.workspace.instances[0].geometry, { + sourceGeometryEngine: geometry.provenance.engine, + sourceGeometryEngineVersion: geometry.provenance.version, + sourceGeometryVersion: geometry.version, + sourceGeometrySignature: geometry.signature + }); + }; + + const flat = validInput(); + applyGeometry(flat, calculateRoofReplacementGeometry({ + roofType: 'flat', length: 12, width: 8, falls: true, drainCount: 2, + eaveOverhang: 0, gableOverhang: 0 + })); + flat.sourcePackages[0].allowed_roof_forms = ['flat']; + flat.sourcePackages[0].min_pitch_degrees = 0; + flat.sourcePackages[0].max_pitch_degrees = 10; + flat.scopeDecisions.flatRoof = { + falls: 'recorded', drains: 'recorded', parapets: 'none', upstands: 'none' + }; + expect(() => buildSnapshot(flat)).not.toThrow(); + + const mansard = validInput(); + applyGeometry(mansard, calculateRoofReplacementGeometry({ + roofType: 'mansard', length: 12, width: 8, + lowerRun: 2.5, lowerPitch: 60, upperRun: 1.5, upperPitch: 30, + eaveOverhang: 0, gableOverhang: 0 + })); + mansard.sourcePackages[0].allowed_roof_forms = ['mansard']; + mansard.sourcePackages[0].min_pitch_degrees = 20; + mansard.sourcePackages[0].max_pitch_degrees = 70; + expect(() => buildSnapshot(mansard)).not.toThrow(); + }); + + test('accepts a normally sourced package using the canonical geometry signature', () => { + const input = validInput(); + const geometry = calculateRoofReplacementGeometry({ + roofType: 'gable', length: 12, width: 8, pitch: 30, + eaveOverhang: 0, gableOverhang: 0 + }); + input.geometry = { ...geometry, roofMaterial: 'tegl', wallHeight: 2.6 }; + input.workspace.geometryVersion = geometry.version; + Object.assign(input.workspace.instances[0].geometry, { + sourceGeometryEngine: geometry.provenance.engine, + sourceGeometryEngineVersion: geometry.provenance.version, + sourceGeometryVersion: geometry.version, + sourceGeometrySignature: geometry.signature + }); + + expect(buildSnapshot(input).artifact.geometry.signature).toBe(geometry.signature); + }); + + test('omits inactive lines from validation, signed output, and economics', () => { + const input = validInput(); + input.workspace.instances[0].materials[0].active = true; + input.workspace.instances[0].tasks[0].isActive = true; + input.workspace.instances[0].materials.push({ + id: 'inactive-material', active: false, quantity: -99, unitPrice: 999999 + }); + input.workspace.instances[0].tasks.push({ + id: 'inactive-task', isActive: false, totalHours: -10, rate: 999999 + }); + input.lines.rentals.push({ + id: 'inactive-rental', active: false, quantity: -2, unitPrice: 999999 + }); + input.lines.references.push({ + id: 'inactive-reference', active: 0, quantity: -2, unitPrice: 999999 + }); + + const snapshot = buildSnapshot(input); + const allLines = Object.values(snapshot.artifact.lines).flat(); + expect(allLines.map(line => line.id)).not.toEqual(expect.arrayContaining([ + 'inactive-material', 'inactive-task', 'inactive-rental', 'inactive-reference' + ])); + expect(allLines.find(line => line.id === 'm2')).toMatchObject({ active: true }); + expect(allLines.find(line => line.id === 't2')).toMatchObject({ isActive: true }); + expect(snapshot.artifact.economics.subtotal).toBe(18534.5); + + input.workspace.instances[0].materials.at(-1).unitPrice = 1; + expect(buildSnapshot(input).signature).toBe(snapshot.signature); + }); + + test.each([ + ['missing price', input => { delete input.workspace.instances[0].materials[0].unitPrice; }, 'MISSING_LINE_PRICE'], + ['missing unit', input => { delete input.workspace.instances[0].materials[0].unit; }, 'MISSING_LINE_UNIT'], + ['stale price', input => { input.workspace.instances[0].materials[0].currentPriceVersion = 5; }, 'STALE_LINE_PRICE'], + ['changed current price', input => { input.workspace.instances[0].materials[0].currentUnitPrice = 21; }, 'STALE_LINE_PRICE'], + ['stale unit', input => { input.workspace.instances[0].materials[0].currentUnit = 'meter'; }, 'STALE_LINE_UNIT'], + ['missing package formula', input => { delete input.workspace.instances[0].packageFormula; }, 'MISSING_PACKAGE_FORMULA'], + ['stale package formula', input => { input.workspace.instances[0].packageFormula.version = 2; input.workspace.instances[0].currentFormulaVersion = 3; }, 'STALE_PACKAGE_FORMULA'], + ['stale package version', input => { input.workspace.instances[0].currentSourcePackageVersion = 3; }, 'STALE_PACKAGE_VERSION'], + ['stale geometry', input => { input.workspace.geometryVersion = 4; }, 'STALE_GEOMETRY'], + ['missing geometry dependency', input => { delete input.workspace.instances[0].geometry.sourceGeometryVersion; }, 'MISSING_GEOMETRY_DEPENDENCY'] + ])('fails closed for %s', (_label, mutate, code) => { + const input = validInput(); + mutate(input); + input.approval = { approved: true, acceptedBlockers: [code] }; + + expect(() => buildSnapshot(input)).toThrow(expect.objectContaining({ + code: 'ROOF_QUOTE_INCOMPLETE', + blockers: expect.arrayContaining([expect.objectContaining({ code, deterministic: true })]) + })); + }); + + test('buildFromProject loads only persisted canonical inputs and overlays current dependencies', async () => { + const input = validInput(); + const project = { + ...input.project, + project_description: 'Persisted fallback text' + }; + const geometry = { + ...input.geometry, + roofReplacementScope: input.scopeDecisions, + roofGeometry: { + version: input.geometry.version, + signature: input.geometry.signature, + roofType: input.geometry.roofType, + area: input.geometry.area, + lengths: input.geometry.lengths, + formula: input.geometry.formula, + provenance: input.geometry.provenance + } + }; + const workspace = JSON.parse(JSON.stringify(input.workspace)); + const manualMaterial = { + id: 901, material_id: 9901, material_name: 'Skruer', quantity: 2, unit: 'pakke', + unit_price: 50, package_instance_id: null, price_source: 'manual', price_version: 11, + notes: JSON.stringify({ lineAudit: audit('manual', 2), lineNotes: 'Manuel linje' }), + ...audit('manual', 2) + }; + const packagedDuplicate = { ...manualMaterial, id: 902, package_instance_id: 'roof-main' }; + let persistedQuoteText = input.quoteText; + let currentTagstenPrice = 12.345; + const execute = jest.fn(async sql => { + if (sql.includes('FROM customer_projects')) return [[project]]; + if (sql.includes('FROM roof_geometry')) return [[{ + geometry_json: JSON.stringify(geometry) + }]]; + if (sql.includes('FROM project_smart_package_workspaces')) return [[{ + version: workspace.version, + workspace_json: JSON.stringify({ instances: workspace.instances }) + }]]; + if (sql.includes('FROM material_packages')) return [[validSource()]]; + if (sql.includes('FROM material_prices')) return [[ + { material_id: 701, price_version: 81, current_unit_price: currentTagstenPrice, current_unit: 'stk' }, + { material_id: 9901, price_version: 11, current_unit_price: 50, current_unit: 'pakke' } + ]]; + if (sql.includes('FROM project_materials')) return [[manualMaterial, packagedDuplicate]]; + if (sql.includes('FROM project_rentals')) return [[{ + id: 71, rental_name: 'Manuel lift', rental_category: 'Udlejning', quantity: 1, + unit: 'dag', unit_price: 800, package_instance_id: null, + notes: JSON.stringify({ lineAudit: audit('fixed', 1, 'fixed') }), ...audit('manual', 1) + }]]; + if (sql.includes('FROM project_labor')) return [[{ + work_breakdown: JSON.stringify([ + { id: 'manual-task', name: 'Tilsyn', totalHours: 1, rate: 700, timeUnit: 'total', ...audit('manual', 1) }, + { id: 'packaged-task', packageInstanceId: 'roof-main', name: 'Duplikat', totalHours: 99, rate: 99, timeUnit: 'total' } + ]) + }]]; + if (sql.includes('FROM generated_quotes')) return [[{ quote_text: persistedQuoteText }]]; + throw new Error(`Unexpected SQL: ${sql}`); + }); + const connection = { + execute, + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn() + }; + const service = new RoofQuoteSnapshotService({ + databaseService: { pool: { getConnection: jest.fn().mockResolvedValue(connection) } }, + roofGeometryService: { getRoofGeometry: jest.fn().mockResolvedValue(geometry) }, + workspaceService: { getWorkspace: jest.fn().mockResolvedValue(workspace) } + }); + + const snapshot = await service.buildFromProject(42); + + expect(snapshot.signature).toMatch(/^[a-f0-9]{64}$/); + expect(snapshot.artifact.customerProject).toEqual(project); + expect(snapshot.artifact.geometry).toMatchObject({ + roofType: 'gable', roofMaterial: 'tegl', wallHeight: 2.6, + signature: 'geo-signature-5', + roofGeometry: { signature: 'geo-signature-5', formula: { id: 'gable-v1' } } + }); + expect(snapshot.artifact.quoteText).toBe(input.quoteText); + expect(snapshot.artifact.scopeDecisions).toEqual(expect.objectContaining(input.scopeDecisions)); + expect(snapshot.artifact.reservations).toEqual(input.scopeDecisions.reservations || []); + expect(snapshot.artifact.packages[0]).toMatchObject({ sourcePackageId: 700, sourcePackageVersion: 2 }); + expect(snapshot.artifact.lines.materials.filter(line => line.id === 902)).toHaveLength(0); + expect(snapshot.artifact.lines.tasks.filter(line => line.id === 'packaged-task')).toHaveLength(0); + expect(snapshot.artifact.lines.tasks.map(line => line.name)).toEqual(['Tilsyn', 'Montage', 'Oprydning']); + expect(snapshot.artifact.economics.laborTotal).toBe(13700); + expect(snapshot.artifact.lines.materials.find(line => line.id === 'm1')).toMatchObject({ + unitPrice: 12.345, priceVersion: 81 + }); + expect(execute.mock.calls.filter(([sql]) => !sql.includes('FROM material_packages')).every(([, params]) => params?.[0] === 42)).toBe(true); + + const baseline = snapshot.signature; + persistedQuoteText += ' ændret'; + expect((await service.buildFromProject(42)).signature).not.toBe(baseline); + persistedQuoteText = input.quoteText; + + workspace.instances[0].packageFormula.expression += ' + 0'; + expect((await service.buildFromProject(42)).signature).not.toBe(baseline); + workspace.instances[0].packageFormula.expression = input.workspace.instances[0].packageFormula.expression; + + currentTagstenPrice = 13; + expect((await service.buildFromProject(42)).signature).not.toBe(baseline); + currentTagstenPrice = 12.345; + + geometry.roofReplacementScope.newCoveringSystem = 'staal'; + expect((await service.buildFromProject(42)).signature).not.toBe(baseline); + geometry.roofReplacementScope.newCoveringSystem = 'tegl'; + + project.input_normalization_log = JSON.stringify({ + quoteRealismApproval: { signature: baseline, approvedAt: '2026-09-10T10:00:00.000Z', approvedBy: 'jannick' } + }); + const approvedSnapshot = await service.buildFromProject(42); + expect(approvedSnapshot.signature).toBe(baseline); + expect(approvedSnapshot).toMatchObject({ + approved: true, + approval: { approved: true, signature: baseline, approvedBy: 'jannick' } + }); + }); + + test('buildFromProject reads every signed input on one transaction and includes persisted identities', async () => { + const input = validInput(); + const project = { + ...input.project, + project_description: 'Persisted text', + input_normalization_log: '{}' + }; + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn(async sql => { + if (sql.includes('FROM customer_projects')) return [[project]]; + if (sql.includes('FROM roof_geometry')) return [[{ + geometry_json: JSON.stringify({ ...input.geometry, roofReplacementScope: input.scopeDecisions }) + }]]; + if (sql.includes('FROM project_smart_package_workspaces')) return [[{ + version: input.workspace.version, + workspace_json: JSON.stringify({ instances: input.workspace.instances }) + }]]; + if (sql.includes('FROM material_packages')) return [[validSource()]]; + if (sql.includes('FROM material_prices')) return [[{ material_id: 701, price_version: 8, current_unit_price: 12.345, current_unit: 'stk' }]]; + if (sql.includes('FROM project_materials')) return [[]]; + if (sql.includes('FROM project_rentals')) return [[]]; + if (sql.includes('FROM project_labor')) return [[{ work_breakdown: '[]' }]]; + if (sql.includes('FROM generated_quotes')) return [[{ quote_text: input.quoteText }]]; + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + const poolExecute = jest.fn(); + const service = new RoofQuoteSnapshotService({ + databaseService: { pool: { getConnection: jest.fn().mockResolvedValue(connection), execute: poolExecute } }, + roofGeometryService: { getRoofGeometry: jest.fn() }, + workspaceService: { getWorkspace: jest.fn() } + }); + + const snapshot = await service.buildFromProject(42); + + expect(snapshot.artifact.customerProject).toMatchObject({ customer_id: 7, project_number: 'P-42' }); + expect(snapshot.readiness).toEqual({ ready: true, blockers: [] }); + expect(connection.beginTransaction).toHaveBeenCalledTimes(1); + expect(connection.commit).toHaveBeenCalledTimes(1); + expect(connection.rollback).not.toHaveBeenCalled(); + expect(connection.release).toHaveBeenCalledTimes(1); + expect(poolExecute).not.toHaveBeenCalled(); + expect(service.roofGeometryService.getRoofGeometry).not.toHaveBeenCalled(); + expect(service.workspaceService.getWorkspace).not.toHaveBeenCalled(); + + connection.execute.mockClear(); + await service.buildFromProject(42, { connection, forUpdate: true, manageTransaction: false }); + expect(connection.execute.mock.calls).not.toHaveLength(0); + expect(connection.execute.mock.calls.every(([sql]) => /FOR UPDATE\s*$/.test(sql))).toBe(true); + expect(connection.beginTransaction).toHaveBeenCalledTimes(1); + expect(connection.commit).toHaveBeenCalledTimes(1); + expect(connection.release).toHaveBeenCalledTimes(1); + }); + + test('rolls back the snapshot read transaction when persisted readiness is incomplete', async () => { + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn(async sql => { + if (sql.includes('FROM customer_projects')) return [[{ id: 42, customer_id: 7 }]]; + if (sql.includes('FROM roof_geometry')) return [[]]; + if (sql.includes('FROM project_smart_package_workspaces')) return [[]]; + return [[]]; + }) + }; + const service = new RoofQuoteSnapshotService({ + databaseService: { pool: { getConnection: jest.fn().mockResolvedValue(connection) } }, + roofGeometryService: {}, workspaceService: {} + }); + + await expect(service.buildFromProject(42)).rejects.toMatchObject({ code: 'ROOF_QUOTE_INCOMPLETE' }); + expect(connection.commit).not.toHaveBeenCalled(); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.release).toHaveBeenCalledTimes(1); + }); + + test('accepts normalized flat lines without workspace instances', () => { + const input = validInput(); + input.workspace.instances = []; + delete input.workspace.geometryVersion; + expect(buildSnapshot(input).artifact.lines.materials).toHaveLength(1); + }); + + test('accepts top-level persisted flat lines and signs all detailed scope fields', () => { + const input = validInput(); + input.materials = input.lines.materials; + input.laborTasks = input.lines.tasks; + delete input.lines; + delete input.workspace; + const first = buildSnapshot(input); + input.scopeDecisions.newCoveringSystem = 'staal'; + + expect(first.artifact.lines.materials).toHaveLength(1); + expect(buildSnapshot(input).signature).not.toBe(first.signature); + }); +}); diff --git a/backend/src/__tests__/roofReplacementGeometry.test.js b/backend/src/__tests__/roofReplacementGeometry.test.js new file mode 100644 index 0000000..aa54988 --- /dev/null +++ b/backend/src/__tests__/roofReplacementGeometry.test.js @@ -0,0 +1,258 @@ +const { + calculateRoofReplacementGeometry, + TOLERANCES +} = require('../domain/roofReplacementGeometry'); + +describe('roof replacement geometry', () => { + test('calculates a deterministic gable roof', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'gable', + length: 12, + width: 8, + pitch: 30 + }); + + expect(result.version).toBe(1); + expect(result.signature).toMatch(/^[a-f0-9]{64}$/); + expect(result.roofType).toBe('gable'); + expect(result.area.plan).toBeCloseTo(96, 10); + expect(result.area.roofSurface).toBeCloseTo(110.85125168440814, 10); + expect(result.rise).toBeCloseTo(2.309401076758503, 10); + expect(result.lengths.rafter).toBeCloseTo(4.618802153517006, 10); + expect(result.lengths.ridge).toBeCloseTo(12, 10); + expect(result.lengths.eaves).toBeCloseTo(24, 10); + expect(result.lengths.verges).toBeCloseTo(18.475208614068023, 10); + expect(result.formula.id).toBe('gable-v1'); + expect(result.provenance.engine).toBe('roofReplacementGeometry'); + }); + + test('calculates a deterministic mono-pitch roof', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'pult', + length: 12, + width: 8, + pitch: 15 + }); + + expect(result.area.roofSurface).toBeCloseTo(99.38651331936796, 10); + expect(result.rise).toBeCloseTo(2.1435935394489816, 10); + expect(result.lengths.rafter).toBeCloseTo(8.282209443280664, 10); + expect(result.lengths.ridge).toBeCloseTo(0, 10); + expect(result.lengths.lowJunction).toBeCloseTo(12, 10); + expect(result.lengths.highJunction).toBeCloseTo(12, 10); + expect(result.lengths.verges).toBeCloseTo(16.564418886561327, 10); + expect(result.formula.id).toBe('pult-v1'); + }); + + test('calculates a deterministic flat roof and reports quote readiness', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'flat', + length: 12, + width: 8, + falls: true, + drainCount: 2 + }); + + expect(result.area.plan).toBeCloseTo(96, 10); + expect(result.area.roofSurface).toBeCloseTo(96, 10); + expect(result.rise).toBeCloseTo(0, 10); + expect(result.lengths.perimeter).toBeCloseTo(40, 10); + expect(result.lengths.ridge).toBeCloseTo(0, 10); + expect(result.quoteReadiness).toEqual({ ready: true, missing: [] }); + expect(result.formula.id).toBe('flat-v1'); + }); + + test('calculates a deterministic equal-pitch hip roof', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'hip', + length: 12, + width: 8, + pitch: 30 + }); + + expect(result.area.roofSurface).toBeCloseTo(110.85125168440814, 10); + expect(result.rise).toBeCloseTo(2.309401076758503, 10); + expect(result.lengths.ridge).toBeCloseTo(4, 10); + expect(result.lengths.hips.count).toBe(4); + expect(result.lengths.hips.each).toBeCloseTo(6.110100926607787, 10); + expect(result.lengths.hips.total).toBeCloseTo(24.440403706431148, 10); + expect(result.lengths.eaves).toBeCloseTo(40, 10); + expect(result.formula.id).toBe('equal-pitch-hip-v1'); + }); + + test('calculates a deterministic symmetric mansard from explicit runs and pitches', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'mansard', + length: 12, + width: 8, + lowerRun: 2.5, + lowerPitch: 60, + upperRun: 1.5, + upperPitch: 30 + }); + + expect(result.area.roofSurface).toBeCloseTo(161.56921938165306, 10); + expect(result.rise).toBeCloseTo(5.19615242270663, 10); + expect(result.lengths.lowerSlope).toBeCloseTo(5, 10); + expect(result.lengths.upperSlope).toBeCloseTo(1.7320508075688772, 10); + expect(result.lengths.ridge).toBeCloseTo(12, 10); + expect(result.lengths.eaves).toBeCloseTo(24, 10); + expect(result.lengths.mansardBreaks).toBeCloseTo(24, 10); + expect(result.lengths.verges).toBeCloseTo(26.928203230275507, 10); + expect(result.formula.id).toBe('symmetric-mansard-v1'); + }); + + test.each([ + [{ roofType: 'gable', length: 12, pitch: 30 }, 'width'], + [{ roofType: 'gable', length: -12, width: 8, pitch: 30 }, 'length'], + [{ roofType: 'pult', length: 12, width: 8 }, 'pitch'], + [{ roofType: 'gable', length: 12, width: 8, pitch: 90 }, 'pitch'], + [{ roofType: 'flat', length: 12, width: 8, falls: true, drainCount: -1 }, 'drainCount'], + [{ roofType: 'hip', length: 7, width: 8, pitch: 30 }, 'length must be greater than or equal to width'], + [{ + roofType: 'mansard', length: 12, width: 8, + lowerRun: 2, lowerPitch: 60, upperRun: 1, upperPitch: 30 + }, 'runs must sum to width / 2'] + ])('rejects missing, negative, or contradictory input %#', (input, message) => { + expect(() => calculateRoofReplacementGeometry(input)).toThrow(message); + }); + + test('applies the declared linear tolerance at the hip length boundary', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'hip', + length: 8 - TOLERANCES.linear / 2, + width: 8, + pitch: 30 + }); + + expect(result.lengths.ridge).toBe(0); + expect(result.provenance.tolerances).toEqual(TOLERANCES); + }); + + test('marks a flat roof without documented falls and drains as not quote-ready', () => { + const result = calculateRoofReplacementGeometry({ roofType: 'flat', length: 12, width: 8 }); + expect(result.quoteReadiness).toEqual({ ready: false, missing: ['falls', 'drainCount'] }); + }); + + test('signs only the exact normalized canonical input and computed envelope', () => { + const canonical = calculateRoofReplacementGeometry({ + roofType: 'flat', length: 12, width: 8, eaveOverhang: 0, gableOverhang: 0, + falls: true, drainCount: 1, area: { roofSurface: 1 }, edges: { ridge: 999 } + }); + const reorderedAndForged = calculateRoofReplacementGeometry({ + provenance: { engine: 'client' }, drainCount: 1, falls: true, + width: 8, roofType: 'flat', length: 12, gableOverhang: 0, eaveOverhang: 0 + }); + const changedMeasuredInput = calculateRoofReplacementGeometry({ + roofType: 'flat', length: 12, width: 8, eaveOverhang: 0, gableOverhang: 0, + falls: true, drainCount: 2 + }); + + expect(reorderedAndForged.signature).toBe(canonical.signature); + expect(changedMeasuredInput.signature).not.toBe(canonical.signature); + expect(canonical.input).toEqual({ + roofType: 'flat', length: 12, width: 8, eaveOverhang: 0, gableOverhang: 0, + falls: true, drainCount: 1 + }); + }); + + describe('canonical overhang geometry', () => { + const overhangs = { eaveOverhang: 0.4, gableOverhang: 0.3 }; + + test('extends gable length and horizontal half-run', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'gable', length: 12, width: 8, pitch: 30, ...overhangs + }); + + expect(result.area.plan).toBeCloseTo(110.88, 10); + expect(result.area.roofSurface).toBeCloseTo(128.0331956954914, 10); + expect(result.rise).toBeCloseTo(2.5403411844343533, 10); + expect(result.lengths).toMatchObject({ ridge: 12.6, eaves: 25.2 }); + expect(result.lengths.rafter).toBeCloseTo(5.080682368868707, 10); + expect(result.lengths.verges).toBeCloseTo(20.322729475474826, 10); + expect(result.formula).toMatchObject({ + plan: '(length + 2 * gableOverhang) * (width + 2 * eaveOverhang)', + area: '2 * (length + 2 * gableOverhang) * ((width / 2 + eaveOverhang) / cos(pitch))' + }); + expect(result.provenance.effectiveDimensions).toEqual({ length: 12.6, width: 8.8 }); + }); + + test('extends pult length and full horizontal run', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'pult', length: 12, width: 8, pitch: 15, ...overhangs + }); + + expect(result.area.plan).toBeCloseTo(110.88, 10); + expect(result.area.roofSurface).toBeCloseTo(114.79142288387001, 10); + expect(result.rise).toBeCloseTo(2.35795289339388, 10); + expect(result.lengths).toMatchObject({ ridge: 0, lowJunction: 12.6, highJunction: 12.6 }); + expect(result.lengths.rafter).toBeCloseTo(9.110430387608732, 10); + expect(result.lengths.verges).toBeCloseTo(18.220860775217464, 10); + expect(result.formula.area).toBe( + '(length + 2 * gableOverhang) * ((width + 2 * eaveOverhang) / cos(pitch))' + ); + }); + + test('uses both extended dimensions for a flat rectangular field with documented falls', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'flat', length: 12, width: 8, falls: true, drainCount: 2, ...overhangs + }); + + expect(result.area).toEqual({ plan: 110.88000000000001, roofSurface: 110.88000000000001 }); + expect(result.lengths).toEqual({ perimeter: 42.8, ridge: 0 }); + expect(result.formula).toMatchObject({ + area: '(length + 2 * gableOverhang) * (width + 2 * eaveOverhang)', + fall: 'documented falls; no numeric gradient, so roofSurface equals rectangular plan field' + }); + expect(result.provenance.fall).toBe('documented'); + }); + + test('uses extended length and width for equal-pitch hip geometry', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'hip', length: 12, width: 8, pitch: 30, ...overhangs + }); + + expect(result.area.plan).toBeCloseTo(110.88, 10); + expect(result.area.roofSurface).toBeCloseTo(128.0331956954914, 10); + expect(result.rise).toBeCloseTo(2.5403411844343533, 10); + expect(result.lengths.ridge).toBeCloseTo(3.8, 10); + expect(result.lengths.hips.each).toBeCloseTo(6.721111019268566, 10); + expect(result.lengths.hips.total).toBeCloseTo(26.884444077074264, 10); + expect(result.lengths.eaves).toBeCloseTo(42.8, 10); + expect(result.formula.area).toBe( + '(length + 2 * gableOverhang) * (width + 2 * eaveOverhang) / cos(pitch)' + ); + }); + + test('extends the explicit mansard lower profile run at its pitch', () => { + const result = calculateRoofReplacementGeometry({ + roofType: 'mansard', length: 12, width: 8, + lowerRun: 2.5, lowerPitch: 60, upperRun: 1.5, upperPitch: 30, + ...overhangs + }); + + expect(result.area.plan).toBeCloseTo(110.88, 10); + expect(result.area.roofSurface).toBeCloseTo(189.80768035073567, 10); + expect(result.rise).toBeCloseTo(5.888972745734181, 10); + expect(result.lengths.lowerSlope).toBeCloseTo(5.8, 10); + expect(result.lengths.upperSlope).toBeCloseTo(1.7320508075688772, 10); + expect(result.lengths).toMatchObject({ ridge: 12.6, eaves: 25.2, mansardBreaks: 25.2 }); + expect(result.lengths.verges).toBeCloseTo(30.128203230275503, 10); + expect(result.formula.overhangPolicy).toBe( + 'gableOverhang extends length; eaveOverhang extends lowerRun at lowerPitch' + ); + expect(result.provenance.overhangPolicy).toBe('extend-mansard-lower-run'); + }); + + test.each([ + ['negative eave overhang', { eaveOverhang: -0.1 }, 'eaveOverhang'], + ['non-finite eave overhang', { eaveOverhang: Infinity }, 'eaveOverhang'], + ['negative gable overhang', { gableOverhang: -0.1 }, 'gableOverhang'], + ['non-finite gable overhang', { gableOverhang: NaN }, 'gableOverhang'] + ])('rejects %s', (_label, invalid, message) => { + expect(() => calculateRoofReplacementGeometry({ + roofType: 'gable', length: 12, width: 8, pitch: 30, ...invalid + })).toThrow(message); + }); + }); +}); diff --git a/backend/src/__tests__/roofTypeContract.test.js b/backend/src/__tests__/roofTypeContract.test.js new file mode 100644 index 0000000..0c5c3e6 --- /dev/null +++ b/backend/src/__tests__/roofTypeContract.test.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); +const { + CANONICAL_ROOF_TYPE_OPTIONS, + ROOF_TYPE_ESTIMATES, + estimateCanonicalRoofLabor +} = require('../domain/roofTypeContract'); + +describe('canonical roof-type cross-layer contract', () => { + test('advertises every and only safely supported canonical roof type with estimate metadata', () => { + expect(CANONICAL_ROOF_TYPE_OPTIONS).toEqual([ + { value: 'gable', label: 'Sadeltag', complexity: 1.2, baseComplexity: 1.2, baseHoursPerM2: 1.2 }, + { value: 'pult', label: 'Pulttag', complexity: 0.9, baseComplexity: 0.9, baseHoursPerM2: 0.85 }, + { value: 'flat', label: 'Fladt tag', complexity: 1, baseComplexity: 1, baseHoursPerM2: 0.8 }, + { value: 'hip', label: 'Valmtag', complexity: 1.2, baseComplexity: 1.2, baseHoursPerM2: 1.25 }, + { value: 'mansard', label: 'Mansardtag', complexity: 1.5, baseComplexity: 1.5, baseHoursPerM2: 1.8 } + ]); + expect(Object.keys(ROOF_TYPE_ESTIMATES)).toEqual( + CANONICAL_ROOF_TYPE_OPTIONS.map(option => option.value) + ); + expect(JSON.stringify(CANONICAL_ROOF_TYPE_OPTIONS)).not.toContain('komplekst'); + }); + + test('customer API and frontend choices consume the canonical contract and do not advertise komplekst', () => { + const customerRoute = fs.readFileSync( + path.join(__dirname, '../routes/customerProjects.js'), + 'utf8' + ); + const frontend = fs.readFileSync( + path.join(__dirname, '../../../frontend/src/components/GeometryInput.js'), + 'utf8' + ); + + expect(customerRoute).toContain('CANONICAL_ROOF_TYPE_OPTIONS'); + expect(customerRoute).toContain('roofTypes: CANONICAL_ROOF_TYPE_OPTIONS'); + expect(frontend).toContain('CANONICAL_ROOF_TYPES'); + expect(frontend).not.toContain("'komplekst'"); + expect(frontend).not.toContain('Auto-selecting skraat_tag'); + }); + + test.each([ + ['gable', 120], + ['pult', 85], + ['flat', 80], + ['hip', 125], + ['mansard', 180] + ])('fallback API labor uses the canonical %s value', (roofType, expectedHours) => { + expect(estimateCanonicalRoofLabor(100, 1, roofType)).toEqual({ + hoursPerM2: ROOF_TYPE_ESTIMATES[roofType].baseHoursPerM2, + estimatedHours: expectedHours + }); + }); + + test.each(['komplekst', 'constructor', '__proto__', 'toString', null, undefined])( + 'fallback API labor never defaults unsupported roof type %p to gable', + roofType => { + expect(() => estimateCanonicalRoofLabor(100, 1, roofType)) + .toThrow('Unsupported roof type'); + } + ); +}); \ No newline at end of file diff --git a/backend/src/__tests__/smartPackageGeometry.test.js b/backend/src/__tests__/smartPackageGeometry.test.js new file mode 100644 index 0000000..57e97db --- /dev/null +++ b/backend/src/__tests__/smartPackageGeometry.test.js @@ -0,0 +1,55 @@ +const { calculateGeometryQuantity } = require('../domain/smartPackageGeometry'); + +describe('calculateGeometryQuantity', () => { + const geometry = { roofArea: 120, length: 10, width: 8, wallHeight: 5 }; + + test.each([ + ['roof_area', { factor: 1.1 }, 132, '120 m² × 1,1 = 132 m²'], + ['roof_sides_x_length', { factor: 2 }, 20, '2 tagsider × 10 m = 20 løbende m'], + ['building_perimeter', { factor: 1 }, 36, '(10 m + 8 m) × 2 = 36 løbende m'], + ['count_x_wall_height', { count: 2 }, 10, '2 stk. × 5 m = 10 løbende m'], + ['fixed', { fixedQuantity: 1 }, 1, 'Fast mængde: 1'], + ['manual', { calculatedQuantity: 20, manualQuantity: 17 }, 17, 'Manuelt tilrettet: 17'] + ])('%s preserves its explicit pricing basis', (basis, options, expectedQuantity, expectedFormula) => { + expect(calculateGeometryQuantity({ basis, geometry, ...options })).toEqual({ + quantity: expectedQuantity, + formula: expectedFormula, + quantityMode: basis === 'manual' ? 'manual' : 'calculated' + }); + }); + + test('prefers roof covering area and supports every canonical geometry basis', () => { + const complete = { + roof_covering_area: 140, + total_area: 80, + length: 10, + width: 8, + wallHeight: 5, + wall_area: 180, + ridge: 10, + eaves: 20, + verges: 12, + hips: 8, + valleys: 6 + }; + const cases = [ + ['roof_area', 140], ['base_area', 80], ['building_length', 10], ['building_width', 8], + ['perimeter', 36], ['wall_height', 5], ['wall_area', 180], ['ridge', 10], + ['eaves', 20], ['verges', 12], ['hips', 8], ['valleys', 6] + ]; + cases.forEach(([basis, expected]) => { + expect(calculateGeometryQuantity({ basis, geometry: complete, factor: 1 })).toMatchObject({ + quantity: expected, + quantityMode: 'calculated' + }); + }); + expect(calculateGeometryQuantity({ basis: 'count', geometry: complete, count: 3, factor: 2 }).quantity).toBe(6); + }); + + test('fails closed when required geometry is missing or an explicit basis is unknown', () => { + expect(() => calculateGeometryQuantity({ basis: 'wall_height', geometry: {} })) + .toThrow('Væghøjde mangler i Geometri'); + expect(() => calculateGeometryQuantity({ basis: 'mystery', geometry })) + .toThrow('Ukendt geometribasis: mystery'); + }); +}); diff --git a/backend/src/__tests__/smartPackageIntegrityService.test.js b/backend/src/__tests__/smartPackageIntegrityService.test.js index 5ff53c7..ac044c5 100644 --- a/backend/src/__tests__/smartPackageIntegrityService.test.js +++ b/backend/src/__tests__/smartPackageIntegrityService.test.js @@ -54,10 +54,21 @@ describe('SmartPackageIntegrityService', () => { release: jest.fn(), execute: jest.fn(async sql => { if (sql.includes('FROM material_packages')) { - expect(sql).toContain("is_active = 1 OR validation_status = 'blocked'"); - return [[{ id: 7, package_type: 'complete_offer' }]]; + expect(sql).toContain("validation_status IN ('blocked', 'needs_review')"); + return [[{ + id: 7, + version: 3, + package_type: 'complete_offer', + pitch_verification_status: 'verified', + pitch_review_required: 0, + compatible_roof_materials: '["tegl"]', + allowed_roof_forms: '["gable"]', + min_pitch_degrees: 20, + max_pitch_degrees: 45 + }]]; } if (sql.includes('FROM package_materials')) return [[{ material_id: 42, material_name: 'Taglægte', unit_price: 25 }]]; + if (sql.includes('FROM materials m')) return [[{ id: 42, sku: 'SKU-42', name: 'Taglægte', unit: 'm', price: 25 }]]; if (sql.includes('FROM materials')) return [[{ id: 42, sku: 'SKU-42', name: 'Taglægte' }]]; if (sql.includes('FROM smart_package_tasks')) return [[{ task_count: 5 }]]; if (sql.includes('UPDATE material_packages')) { @@ -69,15 +80,68 @@ describe('SmartPackageIntegrityService', () => { }; const service = new SmartPackageIntegrityService({ pool: { getConnection: async () => connection } }); - await expect(service.verifyPackage(7, 'tømrer')).resolves.toEqual({ + await expect(service.verifyPackage(7, 'tømrer', 3)).resolves.toEqual({ packageId: 7, - validationStatus: 'verified' + validationStatus: 'verified', + version: 4 }); expect(connection.commit).toHaveBeenCalled(); // Et blokeret/arkiveret Excel-import skal blive valgbart igen efter verificering. expect(updateSql).toContain('is_active = 1'); }); + test('rejects verification when expectedVersion is stale', async () => { + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn(async sql => { + if (sql.includes('FROM material_packages')) return [[{ + id: 7, version: 4, package_type: 'component', replacement_scope: null, + compatible_roof_materials: '[]', allowed_roof_forms: '[]' + }]]; + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + const service = new SmartPackageIntegrityService({ pool: { getConnection: async () => connection } }); + + await expect(service.verifyPackage(7, 'tømrer', 3)).rejects.toMatchObject({ + status: 409, + code: 'SMART_PACKAGE_VERSION_CONFLICT' + }); + expect(connection.rollback).toHaveBeenCalled(); + expect(connection.commit).not.toHaveBeenCalled(); + }); + + test.each([PACKAGE_TYPES.COMPLETE_OFFER, PACKAGE_TYPES.COMPONENT])( + 'refuses to verify and activate a roof package without cleared pitch evidence: %s', + async packageType => { + const connection = { + beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(), + execute: jest.fn(async sql => { + if (sql.includes('FROM material_packages')) return [[{ + id: 8, + version: 3, + package_type: packageType, + pitch_verification_status: 'unverified', + pitch_review_required: 1, + compatible_roof_materials: '["tegl"]', + allowed_roof_forms: '["gable"]', + min_pitch_degrees: 20, + max_pitch_degrees: 45 + }]]; + throw new Error(`Unexpected SQL: ${sql}`); + }) + }; + const service = new SmartPackageIntegrityService({ pool: { getConnection: async () => connection } }); + + await expect(service.verifyPackage(8, 'tømrer', 3)).rejects.toMatchObject({ + name: 'SmartPackageIntegrityError', + status: 400 + }); + expect(connection.commit).not.toHaveBeenCalled(); + expect(connection.rollback).toHaveBeenCalled(); + expect(connection.execute.mock.calls.some(([sql]) => sql.includes('UPDATE material_packages'))).toBe(false); + }); + describe('listReviewQueue', () => { test('returns an empty list when no packages are archived by the integrity check', async () => { const execute = jest.fn().mockResolvedValueOnce([[]]); @@ -91,14 +155,14 @@ describe('SmartPackageIntegrityService', () => { const execute = jest.fn(async sql => { if (sql.includes('FROM material_packages')) { return [[{ - id: 265, name: 'Montage af taglægter', category: 'Tag', package_type: 'component', + id: 265, version: 3, name: 'Montage af taglægter', category: 'Tag', package_type: 'component', validation_status: 'blocked', validation_notes: 'Arkiveret af integritetskontrol', excel_source_file: 'smartpakker.xlsx', excel_source_sheet: 'Tømrer opgaver', updated_at: '2026-08-10' }]]; } if (sql.includes('FROM package_materials')) { return [[ - { id: 1, package_id: 265, material_id: null, material_name: 'Taglægter', material_category: 'Træ', quantity: 1, unit: 'm', unit_price: 0 } + { id: 1, package_id: 265, material_id: null, material_name: 'Taglægter', material_category: 'Træ', quantity: 1, unit: 'm', unit_price: 0, item_code: 'LAEGT-1', raw_line: 'Taglægter 38x73 mm' } ]]; } if (sql.includes('FROM smart_package_tasks')) return [[{ package_id: 265, task_count: 6 }]]; @@ -111,6 +175,7 @@ describe('SmartPackageIntegrityService', () => { expect(result.packages).toHaveLength(1); expect(result.packages[0]).toMatchObject({ id: 265, + version: 3, name: 'Montage af taglægter', taskCount: 6 }); @@ -118,7 +183,47 @@ describe('SmartPackageIntegrityService', () => { name: 'Taglægter', linked: false }); - expect(result.packages[0].materials[0].suggestedMaterial).toMatchObject({ id: 9, name: 'Taglægter 38x73mm C24' }); + expect(result.packages[0].materials[0].suggestedMaterial).toMatchObject({ id: 9, name: 'Taglægter 38x73mm C24', score: 1 }); + }); + + test('links review materials in place with exact optimistic concurrency', async () => { + const connection = { + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn(), + execute: jest.fn(async (sql, params) => { + if (sql.includes('FROM material_packages') && sql.includes('FOR UPDATE')) { + return [[{ id: 265, version: 3, is_active: 0, validation_status: 'blocked' }]]; + } + if (sql.startsWith('SELECT * FROM material_packages')) { + return [[{ id: 265, version: 4, is_active: 0, validation_status: 'blocked' }]]; + } + if (sql.includes('FROM materials m')) { + return [[{ id: 9, name: 'Taglægter 38x73mm C24', unit: 'm', price: 12.5 }]]; + } + if (sql.startsWith('UPDATE package_materials')) return [{ affectedRows: 1 }]; + if (sql.startsWith('UPDATE material_packages')) return [{ affectedRows: 1 }]; + throw new Error(`Unexpected SQL: ${sql} ${JSON.stringify(params)}`); + }) + }; + const service = new SmartPackageIntegrityService({ pool: { getConnection: jest.fn().mockResolvedValue(connection) } }); + + await expect(service.linkReviewMaterials(265, 3, [{ lineId: 1, materialId: 9 }], 'jannick')) + .resolves.toEqual({ + version: 4, + linkedCount: 1, + package: { id: 265, version: 4, is_active: 0, validation_status: 'blocked' } + }); + + const lineUpdate = connection.execute.mock.calls.find(([sql]) => sql.startsWith('UPDATE package_materials')); + expect(lineUpdate[0]).toContain('WHERE id = ? AND package_id = ?'); + expect(lineUpdate[1].slice(0, 7)).toEqual([9, 'manual_verified', 1, 'm', 12.5, 12.5, '']); + expect(lineUpdate[1][7]).toMatch(/^Manuelt koblet af jannick .*servervalgt aktuel katalogpris$/); + expect(lineUpdate[1].slice(8)).toEqual([1, 265]); + const packageUpdate = connection.execute.mock.calls.find(([sql]) => sql.startsWith('UPDATE material_packages')); + expect(packageUpdate[0]).toContain('SUM(total_price)'); + expect(connection.commit).toHaveBeenCalled(); }); }); }); diff --git a/backend/src/__tests__/smartPackageManagementService.test.js b/backend/src/__tests__/smartPackageManagementService.test.js index 2b448ce..47cf3bd 100644 --- a/backend/src/__tests__/smartPackageManagementService.test.js +++ b/backend/src/__tests__/smartPackageManagementService.test.js @@ -3,15 +3,201 @@ const SmartPackageManagementService = require('../services/smartPackageManagemen describe('SmartPackageManagementService', () => { test('classifies an inactive package as not found before adding a task', async () => { const execute = jest.fn().mockResolvedValueOnce([[]]); - const service = new SmartPackageManagementService({ pool: { execute } }); + const connection = { + execute, + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn() + }; + const service = new SmartPackageManagementService({ pool: { getConnection: async () => connection } }); - await expect(service.addTaskToPackage(999, { name: 'Ny opgave' })) + await expect(service.addTaskToPackage(999, { name: 'Ny opgave' }, 3)) .rejects.toMatchObject({ message: 'Package not found or inactive', status: 404, code: 'SMART_PACKAGE_NOT_FOUND' }); expect(execute).toHaveBeenCalledTimes(1); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.release).toHaveBeenCalledTimes(1); + }); + + test('getPackages supports management pagination across active and archived packages', async () => { + const execute = jest.fn().mockResolvedValue([[{ + id: 1, name: 'Pakke', total_count: 654, + smart_task_count: 0, smart_task_hours: 0, smart_task_labor_cost: 0, + legacy_task_count: 0, legacy_task_hours: 0, legacy_task_labor_cost: 0, + material_count: 0, material_cost: 100 + }]]); + const service = new SmartPackageManagementService({ pool: { execute } }); + + const managementRows = await service.getPackages({ includeInactive: true, limit: 12, offset: 12 }); + expect(managementRows[0].total_count).toBe(654); + expect(execute.mock.calls[0][0]).toContain('WHERE 1 = 1'); + expect(execute.mock.calls[0][0]).not.toContain('WHERE mp.is_active = 1'); + + await service.getPackages({ limit: 12 }); + expect(execute.mock.calls[1][0]).toContain('WHERE mp.is_active = 1'); + }); + + test('public package lists require active and verified lifecycle state', async () => { + const execute = jest.fn().mockResolvedValue([[]]); + const service = new SmartPackageManagementService({ pool: { execute } }); + + await service.getPackages(); + expect(execute.mock.calls[0][0]).toContain("WHERE mp.is_active = 1 AND mp.validation_status = 'verified'"); + + await service.getPackages({ includeInactive: true }); + expect(execute.mock.calls[1][0]).toContain('WHERE 1 = 1'); + }); + + test('server search covers metadata, SKU, material and task text without selecting an aggregate search DTO', async () => { + const execute = jest.fn().mockResolvedValue([[]]); + const service = new SmartPackageManagementService({ pool: { execute } }); + + await service.getPackages({ includeInactive: true, search: 'Tømrer ØÅ' }); + + const [sql, params] = execute.mock.calls[0]; + expect(sql).toContain("REPLACE(REPLACE(REPLACE(LOWER(COALESCE(mp.package_type, '')), 'æ', 'ae'), 'ø', 'o'), 'å', 'a') LIKE ?"); + expect(sql).toContain("REPLACE(REPLACE(REPLACE(LOWER(COALESCE(mp.price_source, '')), 'æ', 'ae'), 'ø', 'o'), 'å', 'a') LIKE ?"); + expect(sql).toContain("REPLACE(REPLACE(REPLACE(LOWER(COALESCE(mp.price_source_value, '')), 'æ', 'ae'), 'ø', 'o'), 'å', 'a') LIKE ?"); + expect(sql).toMatch(/EXISTS[\s\S]+package_materials[\s\S]+item_code[\s\S]+LIKE \?/); + expect(sql).toMatch(/EXISTS[\s\S]+smart_package_tasks[\s\S]+description[\s\S]+LIKE \?/); + expect(sql).toMatch(/EXISTS[\s\S]+package_tasks[\s\S]+task_description[\s\S]+LIKE \?/); + expect(sql).toMatch(/search_material\.raw_line[\s\S]+LIKE \?/); + expect(sql).toMatch(/search_task\.raw_line[\s\S]+LIKE \?/); + expect(sql).not.toContain('AS search_text'); + expect(sql).not.toContain('JSON_ARRAYAGG'); + expect(params.slice(0, -1).every(value => value === '%tomrer oa%')).toBe(true); + }); + + test('categories include deterministic type counts across all management lifecycle states', async () => { + const execute = jest.fn() + .mockResolvedValueOnce([[{ category: 'Tag' }, { category: 'leje' }]]) + .mockResolvedValueOnce([[ + { type: 'complete_offer', count: 5 }, + { type: 'component', count: 198 }, + { type: 'rental_service', count: 414 } + ]]); + const service = new SmartPackageManagementService({ pool: { execute } }); + + await expect(service.getPackageCategories()).resolves.toEqual({ + categories: ['Tag', 'leje'], + types: [ + { type: 'complete_offer', count: 5 }, + { type: 'component', count: 198 }, + { type: 'rental_service', count: 414 } + ] + }); + expect(execute.mock.calls[1][0]).toContain('GROUP BY package_type'); + expect(execute.mock.calls[1][0]).toContain('ORDER BY package_type'); + expect(execute.mock.calls[1][0]).not.toContain('is_active'); + }); + + test('management category filters preserve exact source categories instead of broad Tag matching', async () => { + const execute = jest.fn().mockResolvedValue([[]]); + const service = new SmartPackageManagementService({ pool: { execute } }); + + await service.getPackages({ includeInactive: true, category: 'Tag' }); + + expect(execute.mock.calls[0][0]).toContain('AND mp.category = ?'); + expect(execute.mock.calls[0][0]).not.toContain("mp.category LIKE '%Tag%'"); + expect(execute.mock.calls[0][1][0]).toBe('Tag'); + }); + + test('getPackages exposes explicit complete-roof compatibility metadata', async () => { + const execute = jest.fn().mockResolvedValue([[ + { + id: 700, + name: 'Komplet tagskift — betontag', + replacement_scope: 'complete_roof_replacement', + compatible_roof_materials: '["betontag"]', + allowed_roof_forms: '["gable","pult"]', + min_pitch_degrees: '1.00', + max_pitch_degrees: '89.00', + geometry_basis: 'roof_area', + smart_task_count: 0, + smart_task_hours: 0, + smart_task_labor_cost: 0, + legacy_task_count: 0, + legacy_task_hours: 0, + legacy_task_labor_cost: 0, + material_cost: 0 + } + ]]); + const service = new SmartPackageManagementService({ pool: { execute } }); + + const [result] = await service.getPackages(); + const sql = execute.mock.calls[0][0]; + + [ + 'mp.replacement_scope', 'mp.compatible_roof_materials', 'mp.allowed_roof_forms', + 'mp.min_pitch_degrees', 'mp.max_pitch_degrees', 'mp.geometry_basis' + ].forEach(column => expect(sql).toContain(column)); + expect(result).toMatchObject({ + replacement_scope: 'complete_roof_replacement', + compatible_roof_materials: ['betontag'], + allowed_roof_forms: ['gable', 'pult'], + min_pitch_degrees: 1, + max_pitch_degrees: 89, + geometry_basis: 'roof_area' + }); + }); + + test.each([['verified', 0], ['unverified', 1], ['verified', 1]])( + 'getPackages preserves pitch evidence (%s, review=%s) read-only', + async (status, reviewRequired) => { + const execute = jest.fn(async sql => { + const row = { id: 700 }; + if (sql.includes('mp.pitch_verification_status')) row.pitch_verification_status = status; + if (sql.includes('mp.pitch_review_required')) row.pitch_review_required = reviewRequired; + return [[row]]; + }); + const service = new SmartPackageManagementService({ pool: { execute } }); + const [result] = await service.getPackages(); + expect(result).toMatchObject({ + pitch_verification_status: status, pitch_review_required: reviewRequired + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0][0]).toMatch(/^\s*SELECT\b/); + } + ); + + test('getPackages preserves null metadata so legacy consumers may use heuristics', async () => { + const execute = jest.fn().mockResolvedValue([[ + { + id: 12, name: 'Legacy tegltag', compatible_roof_materials: null, allowed_roof_forms: null, + min_pitch_degrees: null, max_pitch_degrees: null, + smart_task_count: 0, smart_task_hours: 0, smart_task_labor_cost: 0, + legacy_task_count: 0, legacy_task_hours: 0, legacy_task_labor_cost: 0, material_cost: 0 + } + ]]); + const service = new SmartPackageManagementService({ pool: { execute } }); + + await expect(service.getPackages()).resolves.toEqual([ + expect.objectContaining({ compatible_roof_materials: null, allowed_roof_forms: null }) + ]); + }); + + test('getPackages bounds offset-only pagination and uses a deterministic id tiebreaker', async () => { + const execute = jest.fn().mockResolvedValue([[]]); + const service = new SmartPackageManagementService({ pool: { execute } }); + + await service.getPackages({ offset: 12, sortBy: 'name', sortDir: 'asc' }); + + expect(execute.mock.calls[0][0]).toContain('ORDER BY mp.name ASC, mp.id ASC'); + expect(execute.mock.calls[0][0]).toContain('LIMIT ? OFFSET ?'); + expect(execute.mock.calls[0][1]).toEqual([50, 12]); + }); + + test('getPackages rejects malformed pagination at the service boundary', async () => { + const execute = jest.fn(); + const service = new SmartPackageManagementService({ pool: { execute } }); + + await expect(service.getPackages({ limit: '12oops' })).rejects.toMatchObject({ status: 400 }); + await expect(service.getPackages({ offset: -1 })).rejects.toMatchObject({ status: 400 }); + expect(execute).not.toHaveBeenCalled(); }); test('getPackages uses legacy package_tasks totals when smart_package_tasks are missing', async () => { @@ -153,13 +339,12 @@ describe('SmartPackageManagementService', () => { }); }); - test('getPackageDetails can still fetch a package the integrity check archived, for admin repair', async () => { + test('getPackageDetails can fetch any inactive package only in authenticated management mode', async () => { const execute = jest.fn(async sql => { if (sql.includes('SELECT * FROM material_packages WHERE id = ?')) { - // Admin needs to reach blocked/inactive Excel-import packages to relink - // materials and re-verify them; deleted packages must stay unreachable. - expect(sql).toContain("is_active = 1 OR validation_status = 'blocked'"); - return [[{ id: 265, name: 'Montage af taglægter', is_active: 0, validation_status: 'blocked' }]]; + expect(sql).toContain('WHERE id = ?'); + expect(sql).not.toContain("is_active = 1 OR validation_status = 'blocked'"); + return [[{ id: 266, name: 'Arkiveret', is_active: 0, validation_status: 'archived' }]]; } if (sql.includes('SELECT * FROM smart_package_tasks')) return [[]]; if (sql.includes('SELECT * FROM package_tasks')) return [[]]; @@ -168,19 +353,52 @@ describe('SmartPackageManagementService', () => { if (sql.includes('FROM smart_package_usage')) return [[{ usage_count: 0 }]]; throw new Error(`Unexpected SQL: ${sql}`); }); + const service = new SmartPackageManagementService({ pool: { execute } }); + await expect(service.getPackageDetails(266, { includeInactive: true })).resolves.toMatchObject({ id: 266, is_active: 0 }); + }); + + test('getPackageDetails excludes blocked inactive packages outside management mode', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT * FROM material_packages WHERE id = ?')) { + expect(sql).toContain('is_active = 1'); + expect(sql).not.toContain("validation_status = 'blocked'"); + return [[]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }); const service = new SmartPackageManagementService({ pool: { execute } }); - const result = await service.getPackageDetails(265); - expect(result.id).toBe(265); + await expect(service.getPackageDetails(265)).rejects.toMatchObject({ status: 404 }); + expect(execute).toHaveBeenCalledTimes(1); + }); + + test('getPackageDetails excludes active packages that are not verified outside management mode', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT * FROM material_packages WHERE id = ?')) { + expect(sql).toContain("is_active = 1 AND validation_status = 'verified'"); + return [[]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }); + const service = new SmartPackageManagementService({ pool: { execute } }); + + await expect(service.getPackageDetails(264)).rejects.toMatchObject({ status: 404 }); }); test('updatePackage can save material links on a blocked/archived package', async () => { const execute = jest.fn(async sql => { - if (sql.includes('SELECT package_type, validation_status FROM material_packages')) { - expect(sql).toContain("is_active = 1 OR validation_status = 'blocked'"); - return [[{ package_type: 'component', validation_status: 'blocked' }]]; + if (sql.includes('SELECT package_type, validation_status')) { + expect(sql).not.toContain("is_active = 1"); + expect(sql).toContain("FOR UPDATE"); + return [[{ package_type: 'component', validation_status: 'blocked', version: 7 }]]; } + if (sql.includes('SELECT * FROM material_packages')) return [[{ id: 265, version: 8, is_active: 0 }]]; if (sql.includes('SELECT id, sku, name FROM materials')) return [[{ id: 9, sku: 'LAEGT-1', name: 'Taglægter' }]]; + if (sql.includes('SELECT')) return [[]]; + if (sql.includes('UPDATE material_packages SET updated_at = NOW()')) { + expect(sql).toContain('version = ?'); + return [{ affectedRows: 1 }]; + } if (sql.includes('UPDATE material_packages')) return [{ affectedRows: 1 }]; if (sql.includes('DELETE FROM package_materials')) return [{}]; if (sql.includes('DELETE FROM smart_package_tasks')) return [{}]; @@ -191,11 +409,38 @@ describe('SmartPackageManagementService', () => { const service = new SmartPackageManagementService({ pool: { getConnection: async () => connection } }); await expect(service.updatePackage(265, { + expectedVersion: 7, materials: [{ material_id: 9, name: 'Taglægter', quantity: 1, unit: 'm', unit_price: 12.5 }] })).resolves.toBeDefined(); expect(connection.commit).toHaveBeenCalled(); }); + test('updatePackage rolls back with 409 before child writes when the version changed', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT package_type, validation_status')) { + return [[{ package_type: 'component', validation_status: 'verified', version: 8 }]]; + } + if (sql.includes('UPDATE material_packages SET updated_at = NOW()')) return [{ affectedRows: 0 }]; + throw new Error(`Unexpected child write: ${sql}`); + }); + const connection = { + execute, + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn() + }; + const service = new SmartPackageManagementService({ pool: { getConnection: async () => connection } }); + + await expect(service.updatePackage(265, { + expectedVersion: 7, + name: 'Samtidig ændring' + })).rejects.toMatchObject({ status: 409, code: 'SMART_PACKAGE_VERSION_CONFLICT' }); + expect(connection.rollback).toHaveBeenCalledTimes(1); + expect(connection.commit).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledTimes(2); + }); + test('buildProjectLines restores Excel quantities while keeping unknown material prices editable', () => { const service = new SmartPackageManagementService({ pool: {} }); const lines = service.buildProjectLines({ @@ -283,43 +528,58 @@ describe('SmartPackageManagementService', () => { }); }); - test('calculates verified package materials and labor directly from database geometry rules', async () => { + test('uses roof covering area and persisted canonical edge bases for package materials and labor', async () => { const execute = jest.fn(async sql => { if (sql.includes('SELECT * FROM material_packages')) { - return [[{ id: 700, name: 'Komplet tagskift — betontag', hours_unit: 'per_m2', time_per_sqm: 1.1 }]]; + return [[{ id: 917, name: 'Komplet tagskift — betontag', geometry_basis: 'roof_area', hours_unit: 'per_m2', time_per_sqm: 1.1 }]]; } if (sql.includes('FROM package_materials pm')) { - return [[{ - material_id: 775, - sku: '1873196', - master_name: 'B&C CLASSIC TAGSTEN SORT', - unit: 'STK', - geometry_multiplier: 'area', - base_quantity: 10.7, - waste_factor: 1.08, - current_price: 16.82 - }]]; + return [[ + { + material_id: 775, + sku: '1873196', + master_name: 'B&C CLASSIC TAGSTEN SORT', + unit: 'm²', + geometry_multiplier: 'area', + base_quantity: 1, + waste_factor: 1, + current_price: 16.82 + }, + { + material_id: 917, + sku: 'TAGFOD', + master_name: 'Tagfod', + unit: 'lbm', + geometry_multiplier: 'eaves', + base_quantity: 1.05, + waste_factor: 1, + current_price: 10 + } + ]]; } if (sql.includes('FROM smart_package_tasks')) { - return [[{ time_unit: 'per_sqm', time_per_unit: 1.1, hours: 0 }]]; + return [[ + { time_unit: 'per_sqm', geometry_basis: 'roof_area', time_per_unit: 0.5, hours: 0 }, + { time_unit: 'per_meter', geometry_basis: 'eaves', time_per_unit: 0.1, hours: 0 } + ]]; } throw new Error(`Unexpected SQL: ${sql}`); }); const service = new SmartPackageManagementService({ pool: { execute } }); - const result = await service.calculatePackageMaterialsWithGeometry(700, { - total_area: 69.28, - length_main: 10, - width_main: 6 + const result = await service.calculatePackageMaterialsWithGeometry(917, { + total_area: 96, + roof_covering_area: 110.85, + length_main: 12, + width_main: 8, + edges_json: JSON.stringify({ ridge: 12, eaves: 24, verges: 18.48, hips: { total: 20.65 } }) }, 20); - expect(result.materials[0]).toMatchObject({ - materialId: 775, - varenr: '1873196', - quantity: 801, - base_price: 16.82, - price: 20.184 - }); - expect(result.laborHours).toBe(76.21); + expect(result.materials).toEqual([ + expect.objectContaining({ materialId: 775, quantity: 110.85, base_price: 16.82, price: 20.184 }), + expect.objectContaining({ materialId: 917, quantity: 25.21 }) + ]); + expect(result.laborHours).toBe(57.83); + expect(result.geometry_used).toMatchObject({ roofArea: 110.85, eaves: 24, ridge: 12, hips: 20.65 }); }); }); diff --git a/backend/src/__tests__/smartPackageSiteGeometryTrust.test.js b/backend/src/__tests__/smartPackageSiteGeometryTrust.test.js new file mode 100644 index 0000000..d4a83ad --- /dev/null +++ b/backend/src/__tests__/smartPackageSiteGeometryTrust.test.js @@ -0,0 +1,290 @@ +const SmartPackageWorkspaceService = require('../services/smartPackageWorkspaceService'); +const { normalizeGeometry } = require('../services/siteGeometryService'); + +const NOW = new Date('2026-09-20T12:00:00.000Z'); +const ring = [[10,56],[10.001,56],[10.001,56.001],[10,56.001],[10,56]]; + +const canonicalSiteGeometry = () => ({ + ...normalizeGeometry({ geometry: { type: 'Polygon', coordinates: [ring] } }), + revision: 4, + audit: { createdAt: '2026-09-19T10:00:00.000Z', createdBy: 'operator', updatedAt: '2026-09-19T10:00:00.000Z', updatedBy: 'operator' }, + signature: 'a'.repeat(64) +}); + +const mapInstance = siteGeometry => { + const submittedSiteGeometry = JSON.parse(JSON.stringify(siteGeometry)); + const area = submittedSiteGeometry.totals.groundAreaM2; + return { + instanceId: 'ground-area', sourcePackageId: 10, sourcePackageVersion: 1, + name: 'Belægning', + quantityBasis: { mode: 'direct', quantity: area, unit: 'm²' }, + geometry: { + basis: 'base_area', factor: 1, measuredValue: area, calculatedQuantity: area, + quantityMode: 'calculated', sourceGeometryVersion: null, sourceGeometrySignature: null + }, + geometryData: { siteGeometry: submittedSiteGeometry, baseArea: area }, + materials: [{ + id: 101, materialId: 4, priceVersion: 6, name: 'Fliser', quantity: area, + unit: 'm²', unitPrice: 100, geometryBasis: 'base_area', measuredValue: area, + quantityMode: 'calculated', quantityProvenance: { + source: 'user_drawn', provider: 'openstreetmap', geometryEngine: 'siteGeometry', + geometryVersion: submittedSiteGeometry.revision, geometrySignature: submittedSiteGeometry.signature + } + }] + }; +}; + +function harness({ storedGeometry = canonicalSiteGeometry(), packageBasis = 'base_area' } = {}) { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 7 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[]]; + if (sql.includes('FROM project_site_geometry')) return [storedGeometry ? [{ + project_id: 7, revision: storedGeometry.revision, geometry_json: JSON.stringify(storedGeometry) + }] : []]; + if (sql.includes('FROM roof_geometry')) return [[]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[{ + id: 10, version: 1, is_active: 1, validation_status: 'verified', package_type: 'component', + geometry_basis: packageBasis, geometry_factor: 1 + }]]; + if (sql.includes('FROM package_materials')) return [[{ + id: 101, package_id: 10, kind: 'materials', material_id: 4, name: 'Fliser', + quantity: 1, base_quantity: 1, unit: 'm²', geometry_multiplier: packageBasis, waste_factor: 1 + }]]; + if (sql.includes('FROM smart_package_tasks')) return [[]]; + if (sql.includes('FROM material_prices')) return [[{ + material_id: 4, price_version: 6, current_unit_price: 100, current_unit: 'm²', + source_date: '2026-09-19T12:00:00.000Z', price_source: 'Bygma' + }]]; + if (sql.includes('SELECT total_work_hours')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { + execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() + }; + return { + execute, + connection, + service: new SmartPackageWorkspaceService( + { pool: { getConnection: async () => connection } }, + { now: () => NOW } + ) + }; +} + +const expectNoProjectWrite = execute => { + expect(execute.mock.calls.some(([sql]) => /^(DELETE|INSERT|UPDATE) /.test(sql.trim()))).toBe(false); +}; + +describe('map-derived workspace trust boundary', () => { + test('does not misclassify canonical roof geometry provenance as a site-map claim', async () => { + const instance = mapInstance(canonicalSiteGeometry()); + instance.quantityBasis.quantity = 100; + instance.geometry = { + ...instance.geometry, + basis: 'roof_area', measuredValue: 100, calculatedQuantity: 100, + sourceGeometryEngine: 'roofReplacementGeometry', + sourceGeometryVersion: 9, sourceGeometrySignature: 'roof-signature' + }; + instance.geometryData = { roofArea: 100 }; + Object.assign(instance.materials[0], { + quantity: 100, geometryBasis: 'roof_area', measuredValue: 100, + quantityProvenance: { + source: 'canonical_geometry', geometryEngine: 'roofReplacementGeometry', + geometryVersion: 9, geometrySignature: 'roof-signature' + } + }); + const { service, execute } = harness({ storedGeometry: null, packageBasis: 'roof_area' }); + + await expect(service.replaceWorkspace(7, { + expectedVersion: 0, instances: [instance] + }, { operator: 'operator' })).resolves.toBeDefined(); + expect(execute.mock.calls.some(([sql]) => sql.includes('FROM project_site_geometry'))).toBe(false); + }); + + test('locks canonical site geometry and saves a canonical map-derived area', async () => { + const canonical = canonicalSiteGeometry(); + const { service, execute, connection } = harness({ storedGeometry: canonical }); + + const result = await service.replaceWorkspace(7, { + expectedVersion: 0, instances: [mapInstance(canonical)] + }, { operator: 'operator' }); + + expect(connection.commit).toHaveBeenCalled(); + const lock = execute.mock.calls.find(([sql]) => sql.includes('FROM project_site_geometry')); + expect(lock[0]).toContain('project_id=?'); + expect(lock[0]).toContain('FOR UPDATE'); + expect(lock[1]).toEqual([7]); + expect(result.instances[0]).toMatchObject({ + quantityBasis: { mode: 'direct', quantity: canonical.totals.groundAreaM2, unit: 'm²' }, + siteGeometryTrust: { + source: 'project_site_geometry', projectId: 7, revision: 4, + signature: canonical.signature, groundAreaM2: canonical.totals.groundAreaM2 + } + }); + expect(result.instances[0].geometryData.siteGeometry).toEqual(canonical); + expect(result.instances[0].materials[0]).toMatchObject({ + quantity: canonical.totals.groundAreaM2, + quantityMode: 'calculated', + quantityProvenance: expect.objectContaining({ + source: 'authoritative_package_child', siteGeometryRevision: 4, + siteGeometrySignature: canonical.signature + }) + }); + }); + + test.each([ + ['forged area', instance => { instance.geometryData.siteGeometry.totals.groundAreaM2 += 1; }], + ['sub-millimetre forged area drift', instance => { instance.geometryData.siteGeometry.totals.groundAreaM2 += 0.0005; }], + ['stale revision', instance => { instance.geometryData.siteGeometry.revision -= 1; instance.materials[0].quantityProvenance.geometryVersion -= 1; }], + ['forged signature', instance => { instance.geometryData.siteGeometry.signature = 'b'.repeat(64); instance.materials[0].quantityProvenance.geometrySignature = 'b'.repeat(64); }], + ['cross-project id', instance => { instance.geometryData.siteGeometry.projectId = 8; }], + ['forged server trust stamp', instance => { instance.siteGeometryTrust = { source: 'project_site_geometry', projectId: 8, revision: 3, signature: 'b'.repeat(64), groundAreaM2: 1 }; }], + ['forged line revision', instance => { instance.materials[0].quantityProvenance.geometryVersion = 3; }], + ['conflicting line revision alias', instance => { instance.materials[0].quantityProvenance.siteGeometryRevision = 3; }], + ['forged line signature', instance => { instance.materials[0].quantityProvenance.geometrySignature = 'c'.repeat(64); }], + ['conflicting line signature alias', instance => { instance.materials[0].quantityProvenance.siteGeometrySignature = 'c'.repeat(64); }], + ['conflicting line project aliases', instance => { instance.materials[0].quantityProvenance.projectId = 7; instance.materials[0].quantityProvenance.siteGeometryProjectId = 8; }], + ['conflicting snake provenance', instance => { instance.materials[0].quantity_provenance = { geometryVersion: 3, geometrySignature: 'd'.repeat(64), siteGeometryProjectId: 8 }; }], + ['forged top-level line claim', instance => { instance.materials[0].geometryVersion = 3; instance.materials[0].geometrySignature = 'e'.repeat(64); }], + ['forged top-level instance claim', instance => { instance.siteGeometryRevision = 3; instance.siteGeometrySignature = 'f'.repeat(64); }] + ])('rejects %s before any project line deletion/write', async (_label, mutate) => { + const canonical = canonicalSiteGeometry(); + const instance = mapInstance(canonical); + mutate(instance); + const { service, execute, connection } = harness({ storedGeometry: canonical }); + + await expect(service.replaceWorkspace(7, { expectedVersion: 0, instances: [instance] }, { operator: 'operator' })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_SITE_GEOMETRY_STALE', status: 409 }); + expectNoProjectWrite(execute); + expect(connection.rollback).toHaveBeenCalled(); + }); + + test('rejects a missing canonical row before any project line deletion/write', async () => { + const canonical = canonicalSiteGeometry(); + const { service, execute } = harness({ storedGeometry: null }); + await expect(service.replaceWorkspace(7, { expectedVersion: 0, instances: [mapInstance(canonical)] }, { operator: 'operator' })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_SITE_GEOMETRY_STALE', status: 409 }); + expectNoProjectWrite(execute); + }); + + test('rejects map-derived quantities after the canonical geometry is deleted', async () => { + const canonical = canonicalSiteGeometry(); + const tombstone = { + schema: 'site_geometry_tombstone_v1', revision: canonical.revision + 1, deleted: true, + signature: 'd'.repeat(64), audit: { deletedAt: NOW.toISOString(), deletedBy: 'operator' } + }; + const { service, execute } = harness({ storedGeometry: tombstone }); + + await expect(service.replaceWorkspace(7, { + expectedVersion: 0, instances: [mapInstance(canonical)] + }, { operator: 'operator' })).rejects.toMatchObject({ + code: 'SMART_PACKAGE_SITE_GEOMETRY_STALE', status: 409 + }); + expectNoProjectWrite(execute); + }); + + test('derives the base-area quantity basis server-side instead of trusting a forged direct quantity', async () => { + const canonical = canonicalSiteGeometry(); + const instance = mapInstance(canonical); + instance.quantityBasis.quantity = 999999; + const { service } = harness({ storedGeometry: canonical }); + + const result = await service.replaceWorkspace(7, { expectedVersion: 0, instances: [instance] }, { operator: 'operator' }); + expect(result.instances[0].quantityBasis.quantity).toBe(canonical.totals.groundAreaM2); + expect(result.instances[0].materials[0].quantity).toBe(canonical.totals.groundAreaM2); + }); + + test('rejects a forged calculated line quantity instead of laundering it as map-derived', async () => { + const canonical = canonicalSiteGeometry(); + const instance = mapInstance(canonical); + instance.materials[0].quantity = 999999; + const { service, execute } = harness({ storedGeometry: canonical }); + + await expect(service.replaceWorkspace(7, { expectedVersion: 0, instances: [instance] }, { operator: 'operator' })) + .rejects.toMatchObject({ code: 'INVALID_MANUAL_OVERRIDE' }); + expectNoProjectWrite(execute); + }); + + test('stamps an intentional noncanonical base area as a manual override, never map provenance', async () => { + const canonical = canonicalSiteGeometry(); + const instance = mapInstance(canonical); + delete instance.geometryData.siteGeometry; + instance.geometryData.siteGeometryMethod = 'manual_numeric'; + instance.geometryData.baseArea = 8000; + instance.quantityBasis.quantity = 8000; + instance.geometry = { ...instance.geometry, basis: 'base_area', measuredValue: 8000, calculatedQuantity: 8000, quantityMode: 'manual' }; + Object.assign(instance.materials[0], { + quantity: 8000, measuredValue: 8000, quantityMode: 'manual', + quantityProvenance: { source: 'manual', reason: 'manual_numeric' } + }); + const { service } = harness({ storedGeometry: canonical }); + + const result = await service.replaceWorkspace(7, { expectedVersion: 0, instances: [instance] }, { operator: 'operator' }); + expect(result.instances[0].materials[0]).toMatchObject({ + quantity: 8000, + quantityMode: 'manual', + quantityProvenance: expect.objectContaining({ source: 'workspace_server', author: 'operator' }), + manualOverride: expect.objectContaining({ source: 'workspace_server', requestedValue: 8000, author: 'operator' }) + }); + expect(result.instances[0].materials[0].quantityProvenance).not.toHaveProperty('siteGeometrySignature'); + + const roundTrip = await service.replaceWorkspace(7, { + expectedVersion: 0, + instances: result.instances + }, { operator: 'operator' }); + expect(roundTrip.instances[0].materials[0]).toMatchObject({ + quantity: 8000, + quantityMode: 'manual', + manualOverride: expect.objectContaining({ source: 'workspace_server', requestedValue: 8000 }) + }); + + const changedSite = { + ...normalizeGeometry({ geometry: { type: 'Polygon', coordinates: [[ + [10,56],[10.0012,56],[10.0012,56.001],[10,56.001],[10,56] + ]] } }), + revision: 5, + signature: 'b'.repeat(64) + }; + const changedHarness = harness({ storedGeometry: changedSite }); + const afterMapEdit = await changedHarness.service.replaceWorkspace(7, { + expectedVersion: 0, + instances: result.instances + }, { operator: 'operator' }); + expect(afterMapEdit.instances[0].materials[0]).toMatchObject({ + quantity: 8000, + quantityMode: 'manual', + manualOverride: expect.objectContaining({ source: 'workspace_server', requestedValue: 8000 }) + }); + + const deletedHarness = harness({ storedGeometry: { + schema: 'site_geometry_tombstone_v1', revision: 6, deleted: true, signature: 'd'.repeat(64) + } }); + await expect(deletedHarness.service.replaceWorkspace(7, { + expectedVersion: 0, + instances: result.instances + }, { operator: 'operator' })).resolves.toMatchObject({ + instances: [expect.objectContaining({ + geometryData: expect.objectContaining({ siteGeometryMethod: 'manual_numeric', baseArea: 8000 }) + })] + }); + }); + + test('rejects a manual numeric area that avoids the requested-area override when no canonical row exists', async () => { + const canonical = canonicalSiteGeometry(); + const instance = mapInstance(canonical); + delete instance.geometryData.siteGeometry; + instance.geometryData.siteGeometryMethod = 'manual_numeric'; + instance.geometryData.baseArea = 8000; + instance.quantityBasis.quantity = 8000; + instance.geometry = { ...instance.geometry, basis: 'base_area', measuredValue: 8000, calculatedQuantity: 8000, quantityMode: 'manual' }; + Object.assign(instance.materials[0], { + quantity: 1, measuredValue: 8000, quantityMode: 'manual', + quantityProvenance: { source: 'manual', reason: 'manual_numeric' } + }); + const { service, execute } = harness({ storedGeometry: null }); + + await expect(service.replaceWorkspace(7, { expectedVersion: 0, instances: [instance] }, { operator: 'operator' })) + .rejects.toMatchObject({ code: 'INVALID_MANUAL_OVERRIDE' }); + expectNoProjectWrite(execute); + }); +}); diff --git a/backend/src/__tests__/smartPackageWorkspaceService.test.js b/backend/src/__tests__/smartPackageWorkspaceService.test.js new file mode 100644 index 0000000..d31659b --- /dev/null +++ b/backend/src/__tests__/smartPackageWorkspaceService.test.js @@ -0,0 +1,828 @@ +const SmartPackageWorkspaceService = require('../services/smartPackageWorkspaceService'); +const databaseService = require('../services/databaseService'); +const { createHash } = require('crypto'); + +const laborDigest = entries => createHash('sha256').update(JSON.stringify(entries)).digest('hex'); + +describe('SmartPackageWorkspaceService', () => { + const connectionForValidation = () => ({ + execute: jest.fn(), + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn() + }); + + test.each([ + ['missing expectedVersion', {}, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['string expectedVersion', { expectedVersion: '0', instances: [] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['oversized expectedVersion', { expectedVersion: 2_147_483_647, instances: [] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['string source id', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: '12', sourcePackageVersion: 1, name: 'Pakke' }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['whitespace instance id', { expectedVersion: 0, instances: [{ instanceId: 'not exact', sourcePackageId: null, name: 'Pakke' }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['mismatched line owner', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: 12, sourcePackageVersion: 1, name: 'Pakke', materials: [{ name: 'Linje', quantity: 1, unit: 'stk', unitPrice: 1, packageInstanceId: 'other', sourcePackageId: 12 }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['mismatched line source', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: 12, sourcePackageVersion: 1, name: 'Pakke', materials: [{ name: 'Linje', quantity: 1, unit: 'stk', unitPrice: 1, packageInstanceId: 'one', sourcePackageId: 13 }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['numeric string quantity', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', materials: [{ name: 'Linje', quantity: '1', unit: 'stk', unitPrice: 1 }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['non-finite rate', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', tasks: [{ name: 'Tid', totalHours: 1, rate: Infinity, timeUnit: 'total' }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['unsupported time unit', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', tasks: [{ name: 'Tid', totalHours: 1, rate: 500, timeUnit: 'minutes' }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['empty explicit time unit', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', tasks: [{ name: 'Tid', totalHours: 1, rate: 500, timeUnit: '' }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['oversized payload', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', geometry: { formula: 'x'.repeat(1_000_001) } }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['unit price beyond DECIMAL(10,2)', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', materials: [{ name: 'Linje', quantity: 1, unit: 'stk', unitPrice: 100_000_000 }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['line total beyond DECIMAL(12,2)', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', materials: [{ name: 'Linje', quantity: 1_000_000, unit: 'stk', unitPrice: 10_000 }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['hours beyond DECIMAL(8,2)', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', tasks: [{ name: 'Tid', totalHours: 1_000_000, rate: 1, timeUnit: 'total' }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['aggregate hours beyond DECIMAL(8,2)', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', tasks: [{ name: 'A', totalHours: 500_000, rate: 1, timeUnit: 'total' }, { name: 'B', totalHours: 500_000, rate: 1, timeUnit: 'total' }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['invalid provenance timestamp', { expectedVersion: 0, instances: [{ instanceId: 'one', sourcePackageId: null, name: 'Pakke', materials: [{ name: 'Linje', quantity: 1, unit: 'stk', unitPrice: 1, priceSourceUpdatedAt: 'not-a-datetime' }] }] }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['incomplete labor classification', { expectedVersion: 0, instances: [], legacyLaborClassification: { expectedDigest: 'a'.repeat(64), packageEntryIndexes: [] } }, 'SMART_PACKAGE_WORKSPACE_INVALID'], + ['overlapping labor classification', { expectedVersion: 0, instances: [], legacyLaborClassification: { expectedDigest: 'a'.repeat(64), packageEntryIndexes: [0], manualEntryIndexes: [0] } }, 'SMART_PACKAGE_WORKSPACE_INVALID'] + ])('rejects malformed input: %s', async (_label, payload, code) => { + const connection = connectionForValidation(); + const getConnection = jest.fn(async () => connection); + const service = new SmartPackageWorkspaceService({ pool: { getConnection } }); + + await expect(service.replaceWorkspace(99, payload)).rejects.toMatchObject({ code, status: 400 }); + expect(getConnection).not.toHaveBeenCalled(); + }); + + test.each([ + ['material', { materials: [{ name: 'Fabricated material', quantity: 1, unit: 'stk', unitPrice: 999 }] }], + ['labor', { tasks: [{ name: 'Fabricated labor', totalHours: 99, rate: 999, timeUnit: 'total' }] }], + ['rental', { rentals: [{ name: 'Fabricated rental', quantity: 1, unit: 'dag', unitPrice: 999 }] }], + ['reference', { referenceServices: [{ name: 'Fabricated reference', quantity: 1, unit: 'stk', unitPrice: 999 }] }] + ])('rejects a sourcePackageId null workspace with a fabricated %s before opening a transaction', async (_label, lines) => { + const getConnection = jest.fn(); + const service = new SmartPackageWorkspaceService({ pool: { getConnection } }); + const instance = { instanceId: 'fabricated', sourcePackageId: null, name: 'Fabricated', ...lines }; + + await expect(service.replaceWorkspace(99, { expectedVersion: 0, instances: [instance] }, + { operator: 'configured-operator' })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_WORKSPACE_INVALID', status: 400 }); + expect(getConnection).not.toHaveBeenCalled(); + }); + + test.each([ + ['material', { materials: [{ id: 999, name: 'Fabricated material', quantity: 1, unit: 'stk', unitPrice: 999 }] }], + ['labor', { tasks: [{ id: 999, name: 'Fabricated labor', totalHours: 99, rate: 999, timeUnit: 'total' }] }], + ['rental', { rentals: [{ id: 999, name: 'Fabricated rental', quantity: 1, unit: 'dag', unitPrice: 999 }] }], + ['reference', { referenceServices: [{ id: 999, name: 'Fabricated reference', quantity: 1, unit: 'stk', unitPrice: 999 }] }] + ])('atomically rejects an arbitrary sourced %s line before any project write', async (_label, lines) => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[]]; + if (sql.includes('FROM roof_geometry')) return [[]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[{ + id: 10, version: 1, is_active: 1, validation_status: 'verified', package_type: 'component' + }]]; + if (sql.includes('FROM package_materials') || sql.includes('FROM smart_package_tasks')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + const instance = { + instanceId: 'bound', sourcePackageId: 10, sourcePackageVersion: 1, name: 'Bound', + quantityBasis: { mode: 'direct', quantity: 1, unit: 'stk' }, ...lines + }; + + await expect(service.replaceWorkspace(99, { expectedVersion: 0, instances: [instance] }, + { operator: 'configured-operator' })).rejects.toMatchObject({ code: 'STALE_PACKAGE_CHILDREN' }); + expect(execute.mock.calls.some(([sql]) => /^(DELETE|INSERT|UPDATE) /.test(sql.trim()))).toBe(false); + expect(connection.commit).not.toHaveBeenCalled(); + expect(connection.rollback).toHaveBeenCalled(); + }); + + test('replaces attacker override authorship and time with the authenticated operator and transaction time', async () => { + const transactionTime = new Date('2026-09-20T12:34:56.789Z'); + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[]]; + if (sql.includes('FROM roof_geometry')) return [[]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[{ + id: 10, version: 1, is_active: 1, validation_status: 'verified', package_type: 'component' + }]]; + if (sql.includes('FROM package_materials')) return [[{ + id: 101, package_id: 10, kind: 'materials', material_id: 4, name: 'Real material', + quantity: 1, unit: 'stk' + }]]; + if (sql.includes('FROM smart_package_tasks')) return [[]]; + if (sql.includes('FROM material_prices')) return [[{ + material_id: 4, price_version: 6, current_unit_price: 100, current_unit: 'stk', + source_date: '2026-09-19T12:00:00.000Z', price_source: 'Bygma' + }]]; + if (sql.includes('SELECT total_work_hours')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService( + { pool: { getConnection: async () => connection } }, + { now: () => transactionTime } + ); + const instances = [{ + instanceId: 'bound', sourcePackageId: 10, sourcePackageVersion: 1, name: 'Bound', + quantityBasis: { mode: 'direct', quantity: 1, unit: 'stk' }, + materials: [{ + id: 101, materialId: 4, name: 'Real material', quantity: 2, unit: 'stk', unitPrice: 100, + priceVersion: 6, + quantityProvenance: { + source: 'manual', reason: 'Counted on site', author: 'attacker', + timestamp: '2001-01-01T00:00:00.000Z' + } + }] + }]; + + const result = await service.replaceWorkspace(99, { expectedVersion: 0, instances }, + { operator: 'configured-operator' }); + + expect(result.instances[0].materials[0]).toMatchObject({ + quantity: 2, + manualOverride: { + source: 'workspace_server', reason: 'Counted on site', requestedValue: 2, + author: 'configured-operator', timestamp: transactionTime.toISOString() + } + }); + const workspaceCall = execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_smart_package_workspaces')); + const persisted = JSON.parse(workspaceCall[1][2]); + expect(JSON.stringify(persisted)).not.toContain('attacker'); + expect(JSON.stringify(persisted)).not.toContain('2001-01-01'); + }); + + test('atomically stores multiple instances of the same package and projects explicit units', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[]]; + if (sql.includes('FROM roof_geometry')) return [[{ + roof_material: 'tegl', roof_type: 'gable', roof_pitch: 30, geometry_json: null + }]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[ + { + id: 278, version: 2, is_active: 1, validation_status: 'verified', + catalog_key: null, validated_at: '2026-09-18T12:00:00.000Z', + package_type: 'component', replacement_scope: null, + compatible_roof_materials: ['tegl'], allowed_roof_forms: ['gable'], + min_pitch_degrees: 20, max_pitch_degrees: 60, + pitch_verification_status: 'verified', pitch_review_required: 0 + } + ]]; + if (sql.includes('FROM package_materials')) return [[{ + id: 501, package_id: 278, kind: 'materials', material_id: 40, name: 'Zinktagrende', + quantity: 1, unit: 'løbende m', geometry_multiplier: 'building_length', waste_factor: 1 + }]]; + if (sql.includes('FROM smart_package_tasks')) return [[{ + id: 601, package_id: 278, kind: 'tasks', name: 'Montage', hours: 0.4, rate: 600, + time_unit: 'per_meter', source_date: '2026-09-18T12:00:00.000Z' + }]]; + if (sql.includes('FROM material_prices')) return [[{ + material_id: 40, price_version: 70, current_unit_price: 245, current_unit: 'løbende m', + source_date: '2026-09-18T12:00:00.000Z', price_source: 'Bygma' + }]]; + if (sql.includes('SELECT total_work_hours')) return [[]]; + if (sql.includes('INSERT INTO project_smart_package_workspaces')) return [{ affectedRows: 1 }]; + return [{ affectedRows: 1 }]; + }); + const connection = { + execute, + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + release: jest.fn() + }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + const instances = [ + { + instanceId: 'gutter-a', sourcePackageId: 278, sourcePackageVersion: 2, name: 'Tagrender i zink', position: 0, + quantityBasis: { mode: 'direct', quantity: 20, unit: 'løbende m' }, + geometry: { basis: 'roof_sides_x_length', factor: 2, formula: '2 tagsider × 10 m = 20 løbende m', calculatedQuantity: 20, manualQuantity: null, quantityMode: 'calculated' }, + materials: [{ id: 501, name: 'Zinktagrende', quantity: 20, unit: 'løbende m', unitPrice: 245, priceVersion: 70, materialId: 40, priceSource: 'Bygma', priceSourceUpdatedAt: '2026-09-18T12:53:04.000Z', geometryBasis: 'building_length', measuredValue: 10, measuredUnit: 'm', baseQuantity: 2, wasteFactor: 1, rounding: { method: 'round', decimals: 3 }, formula: '10 × 2', quantityMode: 'calculated', quantityProvenance: { source: 'geometry' } }], + tasks: [{ id: 601, name: 'Montage', totalHours: 8, rate: 600, timeBasis: 0.4, timeUnit: 'per_meter', geometryBasis: 'building_length', measuredValue: 20, measuredUnit: 'm', baseQuantity: 0.4, wasteFactor: 1, rounding: { method: 'round', decimals: 3 }, formula: '20 × 0.4', quantityMode: 'calculated', quantityProvenance: { source: 'geometry' } }], + rentals: [] + }, + { + instanceId: 'gutter-b', sourcePackageId: 278, sourcePackageVersion: 2, name: 'Tagrender i zink, garage', position: 1, + quantityBasis: { mode: 'direct', quantity: 6, unit: 'løbende m' }, + geometry: { basis: 'manual', factor: 1, formula: 'Manuelt 6 løbende m', calculatedQuantity: 10, manualQuantity: 6, quantityMode: 'manual' }, + materials: [{ id: 501, name: 'Zinktagrende', quantity: 6, unit: 'løbende m', unitPrice: 245, priceVersion: 70, materialId: 40, priceSource: 'Bygma' }], + tasks: [{ id: 601, name: 'Montage', totalHours: 2.4, rate: 600, timeBasis: 0.4, timeUnit: 'per_meter' }], + rentals: [] + } + ]; + + const result = await service.replaceWorkspace(99, { expectedVersion: 0, instances }); + + expect(result.version).toBe(1); + expect(connection.beginTransaction).toHaveBeenCalled(); + expect(connection.commit).toHaveBeenCalled(); + expect(connection.rollback).not.toHaveBeenCalled(); + const materialDelete = execute.mock.calls.find(([sql]) => sql.includes('DELETE FROM project_materials')); + const rentalDelete = execute.mock.calls.find(([sql]) => sql.includes('DELETE FROM project_rentals')); + expect(materialDelete[0]).toContain('package_instance_id IS NOT NULL'); + expect(rentalDelete[0]).toContain('package_instance_id IS NOT NULL'); + const workspaceCall = execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_smart_package_workspaces')); + const payload = JSON.parse(workspaceCall[1][2]); + expect(payload.instances).toHaveLength(2); + expect(payload.instances[1].geometry).toMatchObject({ quantityMode: 'manual', manualQuantity: 6 }); + const materialCalls = execute.mock.calls.filter(([sql]) => sql.includes('INSERT INTO project_materials')); + expect(materialCalls).toHaveLength(2); + expect(materialCalls[0][1]).toEqual(expect.arrayContaining(['løbende m', 245])); + expect(materialCalls[0][1]).toContain('2026-09-18 12:00:00'); + const materialNotes = JSON.parse(materialCalls[0][1][materialCalls[0][1].length - 1]); + expect(materialNotes.lineAudit).toMatchObject({ geometryBasis: 'building_length', measuredValue: 20, baseQuantity: 1, formula: '20 × 1 = 20' }); + const laborUpsert = execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_labor')); + const laborBreakdown = JSON.parse(laborUpsert[1].find(value => typeof value === 'string' && value.startsWith('['))); + expect(laborBreakdown[0]).toMatchObject({ quantityMode: 'calculated', measuredValue: 20, formula: '20 × 0.4 = 8' }); + }); + + test('rejects two-house gutter persistence for stable catalog identities before writes', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[{ version: 1 }]]; + if (sql.includes('FROM roof_geometry')) return [[]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[{ + id: 278, catalog_key: 'gutter-aluminium', version: 2, is_active: 1, + validation_status: 'verified', package_type: 'component' + }]]; + if (sql.includes('FROM package_materials') || sql.includes('FROM smart_package_tasks')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + const houses = [ + { id: 'H1', name: 'Hus 1', gutterLength: 18, downpipeCount: 2 }, + { id: 'H2', name: 'Hus 2', gutterLength: 22, downpipeCount: 2 } + ]; + const instance = { + instanceId: 'gutter', sourcePackageId: 278, sourcePackageVersion: 2, name: 'Tagrender', + quantityBasis: { mode: 'per_house_breakdown', unit: 'løbende m', + houses: houses.map(house => ({ id: house.id, name: house.name, quantity: house.gutterLength })), total: 40 }, + sixHouseBasis: { kind: 'six_house_gutter_downpipe', version: 1, houses, + subtotals: { gutterLength: 40, downpipeCount: 4 } } + }; + + await expect(service.replaceWorkspace(99, { expectedVersion: 1, instances: [instance] })) + .rejects.toMatchObject({ code: 'INVALID_SIX_HOUSE_BASIS' }); + expect(execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM project_materials'))).toBe(false); + expect(execute.mock.calls.some(([sql]) => sql.includes('INSERT INTO project_smart_package_workspaces'))).toBe(false); + }); + + test('preserves manual labor entries while replacing only workspace-generated labor', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[{ version: 1 }]]; + if (sql.includes('SELECT total_work_hours')) return [[{ + work_breakdown: JSON.stringify([{ name: 'Manuel rådgivning', totalHours: 2, rate: 700 }]) + }]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + await service.replaceWorkspace(99, { expectedVersion: 1, instances: [] }); + expect(execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM project_labor'))).toBe(false); + const laborUpsert = execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_labor')); + expect(laborUpsert).toBeDefined(); + const breakdown = JSON.parse(laborUpsert[1].find(value => typeof value === 'string' && value.startsWith('['))); + expect(breakdown).toEqual([expect.objectContaining({ name: 'Manuel rådgivning', totalHours: 2, rate: 700 })]); + }); + + test('rejects a stale workspace version before deleting project lines', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[{ version: 3 }]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + + await expect(service.replaceWorkspace(99, { expectedVersion: 2, instances: [] })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_WORKSPACE_CONFLICT', status: 409 }); + expect(execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM project_materials'))).toBe(false); + expect(connection.rollback).toHaveBeenCalled(); + }); + + test('locks the parent project before checking a first-write workspace version', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[]]; + if (sql.includes('SELECT total_work_hours')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + + await service.replaceWorkspace(99, { expectedVersion: 0, instances: [] }); + + const projectLockIndex = execute.mock.calls.findIndex(([sql]) => sql.includes('SELECT id FROM customer_projects') && sql.includes('FOR UPDATE')); + const workspaceLockIndex = execute.mock.calls.findIndex(([sql]) => sql.includes('SELECT version FROM project_smart_package_workspaces') && sql.includes('FOR UPDATE')); + expect(projectLockIndex).toBeGreaterThanOrEqual(0); + expect(workspaceLockIndex).toBeGreaterThan(projectLockIndex); + }); + + test('rejects a nonexistent parent project before creating or deleting anything', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + + await expect(service.replaceWorkspace(404, { expectedVersion: 0, instances: [] })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_PROJECT_NOT_FOUND', status: 404 }); + expect(execute).toHaveBeenCalledTimes(1); + expect(connection.rollback).toHaveBeenCalled(); + }); + + test.each([ + ['material-incompatible', { compatible_roof_materials: '["eternit"]' }], + ['form-incompatible', { allowed_roof_forms: '["flat"]' }], + ['pitch-incompatible', { min_pitch_degrees: 35 }], + ['pitch-unverified', { pitch_verification_status: 'unverified' }], + ['pitch-review-required', { pitch_review_required: 1 }], + ['pitch-review-status-missing', { pitch_review_required: null }] + ])('rejects a %s source package against persisted canonical geometry before deletion', async (_label, override) => { + const packageRow = { + id: 10, + version: 4, + is_active: 1, + validation_status: 'verified', + package_type: 'complete_offer', + replacement_scope: 'complete_roof_replacement', + compatible_roof_materials: '["tegl"]', + allowed_roof_forms: '["gable"]', + min_pitch_degrees: 20, + max_pitch_degrees: 60, + pitch_verification_status: 'verified', + pitch_review_required: 0, + ...override + }; + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[{ version: 1 }]]; + if (sql.includes('FROM roof_geometry')) return [[{ + roof_material: 'tegl', roof_type: 'gable', roof_pitch: 30, + geometry_json: JSON.stringify({ input: { roofMaterial: 'eternit', roofType: 'flat', pitch: 1 } }) + }]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[packageRow]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + const instances = [{ instanceId: 'roof', sourcePackageId: 10, sourcePackageVersion: 4, name: 'Tagpakke' }]; + + await expect(service.replaceWorkspace(99, { expectedVersion: 1, instances })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_SOURCE_STALE', status: 409 }); + expect(execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM project_materials'))).toBe(false); + expect(execute.mock.calls.some(([sql]) => sql.includes('INSERT INTO project_smart_package_workspaces'))).toBe(false); + const geometryRead = execute.mock.calls.find(([sql]) => sql.includes('FROM roof_geometry')); + expect(geometryRead[0]).toContain('FOR UPDATE'); + expect(connection.rollback).toHaveBeenCalled(); + }); + + test.each([ + ['flat', { roof_type: 'flat', roof_pitch: null, geometry_json: JSON.stringify({ input: { roofType: 'flat', falls: true, drainCount: 2 } }) }, '["flat"]', 0, 10], + ['mansard', { roof_type: 'mansard', roof_pitch: null, geometry_json: JSON.stringify({ input: { roofType: 'mansard', lowerPitch: 60, upperPitch: 30 } }) }, '["mansard"]', 20, 70] + ])('accepts a compatible pitchless %s package before workspace writes', async (_label, geometryRow, allowedForms, minPitch, maxPitch) => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[{ version: 1 }]]; + if (sql.includes('FROM roof_geometry')) return [[{ roof_material: 'tegl', ...geometryRow }]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[{ + id: 10, version: 4, is_active: 1, validation_status: 'verified', + package_type: 'complete_offer', replacement_scope: 'complete_roof_replacement', + compatible_roof_materials: '["tegl"]', allowed_roof_forms: allowedForms, + min_pitch_degrees: minPitch, max_pitch_degrees: maxPitch, + pitch_verification_status: 'verified', pitch_review_required: 0 + }]]; + if (sql.includes('FROM package_materials') || sql.includes('FROM smart_package_tasks')) return [[]]; + if (sql.includes('SELECT total_work_hours')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + + await expect(service.replaceWorkspace(99, { + expectedVersion: 1, + instances: [{ + instanceId: 'roof', sourcePackageId: 10, sourcePackageVersion: 4, name: 'Tagpakke', + quantityBasis: { mode: 'direct', quantity: 1, unit: 'stk' } + }] + })).resolves.toBeDefined(); + expect(connection.commit).toHaveBeenCalled(); + }); + + test('rejects any inactive, unverified, missing, or version-stale source before deletion', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[{ version: 1 }]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[ + { id: 10, version: 4, is_active: 1, validation_status: 'verified' }, + { id: 11, version: 2, is_active: 0, validation_status: 'verified' } + ]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + const instances = [ + { instanceId: 'ok', sourcePackageId: 10, sourcePackageVersion: 4, name: 'Aktiv' }, + { instanceId: 'stale', sourcePackageId: 11, sourcePackageVersion: 2, name: 'Arkiveret' }, + { instanceId: 'missing', sourcePackageId: 12, sourcePackageVersion: 1, name: 'Mangler' } + ]; + + await expect(service.replaceWorkspace(99, { expectedVersion: 1, instances })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_SOURCE_STALE', status: 409 }); + expect(execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM project_materials'))).toBe(false); + expect(execute.mock.calls.some(([sql]) => sql.includes('DELETE FROM project_rentals'))).toBe(false); + }); + + test('treats a sourced reference-only workspace as ready without invented labor', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[]]; + if (sql.includes('FROM roof_geometry')) return [[]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[{ + id: 10, version: 1, is_active: 1, validation_status: 'verified', package_type: 'component', + created_by: 'haandvaerkpriser-import', name: 'Underentreprise', unit: 'stk', unit_price: 1500, + validated_at: '2026-09-19T12:00:00.000Z', price_source: 'Historik' + }]]; + if (sql.includes('FROM package_materials') || sql.includes('FROM smart_package_tasks')) return [[]]; + if (sql.includes('SELECT total_work_hours')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + const instances = [{ + instanceId: 'reference-one', sourcePackageId: 10, sourcePackageVersion: 1, name: 'Reference', + quantityBasis: { mode: 'direct', quantity: 1, unit: 'stk' }, + referenceServices: [{ id: 'haandvaerkpriser-10', name: 'Underentreprise', quantity: 1, unit: 'stk', unitPrice: 1500, priceSource: 'Historik' }] + }]; + + await service.replaceWorkspace(99, { expectedVersion: 0, instances }); + + const materialDelete = execute.mock.calls.find(([sql]) => sql.includes('DELETE FROM project_materials')); + const rentalDelete = execute.mock.calls.find(([sql]) => sql.includes('DELETE FROM project_rentals')); + expect(materialDelete[0]).toMatch(/project_id=\? AND package_instance_id IS NOT NULL/); + expect(rentalDelete[0]).toMatch(/project_id=\? AND package_instance_id IS NOT NULL/); + const referenceInsert = execute.mock.calls.find(([sql], index) => sql.includes('INSERT INTO project_rentals') + && execute.mock.calls[index][1]?.includes('Underentreprise')); + expect(referenceInsert[1]).toEqual(expect.arrayContaining(['reference-one', 'Underentreprise', 'Referenceydelse', 1, 'stk', 1500, 1500])); + const statusUpdate = execute.mock.calls.find(([sql]) => sql.includes('UPDATE customer_projects') && sql.includes("SET project_status='smart_package_complete'")); + expect(statusUpdate).toBeDefined(); + const workspaceWriteIndex = execute.mock.calls.findIndex(([sql]) => sql.includes('INSERT INTO project_smart_package_workspaces')); + const statusUpdateIndex = execute.mock.calls.findIndex(([sql]) => sql.includes('UPDATE customer_projects')); + expect(statusUpdateIndex).toBeGreaterThan(workspaceWriteIndex); + }); + + test('an empty workspace save issues only the guarded package-stage reset', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[]]; + if (sql.includes('SELECT total_work_hours')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + + await service.replaceWorkspace(99, { expectedVersion: 0, instances: [] }); + + const statusUpdate = execute.mock.calls.find(([sql]) => sql.includes('UPDATE customer_projects')); + expect(statusUpdate[0]).toContain("SET project_status='geometry_complete'"); + expect(statusUpdate[0]).toContain("project_status='smart_package_complete'"); + }); + + test('advances a ready workspace only from pre-package statuses', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99, project_status: 'geometry_complete' }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[]]; + if (sql.includes('FROM roof_geometry')) return [[]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[{ + id: 10, version: 1, is_active: 1, validation_status: 'verified', package_type: 'component' + }]]; + if (sql.includes('FROM package_materials')) return [[{ + id: 101, package_id: 10, kind: 'materials', material_id: 4, name: 'Tagsten', quantity: 1, unit: 'stk' + }]]; + if (sql.includes('FROM smart_package_tasks')) return [[{ + id: 201, package_id: 10, kind: 'tasks', name: 'Montage', hours: 1, rate: 600, + time_unit: 'total', source_date: '2026-09-19T12:00:00.000Z' + }]]; + if (sql.includes('FROM material_prices')) return [[{ + material_id: 4, price_version: 6, current_unit_price: 1500, current_unit: 'stk', + source_date: '2026-09-19T12:00:00.000Z', price_source: 'Bygma' + }]]; + if (sql.includes('SELECT total_work_hours')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + const instances = [{ + instanceId: 'roof-one', sourcePackageId: 10, sourcePackageVersion: 1, name: 'Tagarbejde', + quantityBasis: { mode: 'direct', quantity: 1, unit: 'stk' }, + materials: [{ id: 101, materialId: 4, priceVersion: 6, name: 'Tagsten', quantity: 1, unit: 'stk', unitPrice: 1500 }], + tasks: [{ id: 201, name: 'Montage', totalHours: 1, rate: 600, timeUnit: 'total' }] + }]; + + await service.replaceWorkspace(99, { expectedVersion: 0, instances }); + + const statusUpdate = execute.mock.calls.find(([sql]) => sql.includes('UPDATE customer_projects')); + expect(statusUpdate[0]).toContain("SET project_status='smart_package_complete'"); + expect(statusUpdate[0]).toMatch(/project_status IN \('draft','geometry_complete'\)/); + const whereClause = statusUpdate[0].slice(statusUpdate[0].indexOf('WHERE')); + ['smart_package_complete', 'review_pending', 'ready_for_ordrestyring', 'sent_to_ordrestyring', 'sent', 'accepted', 'rejected', 'quote_generated'] + .forEach(status => expect(whereClause).not.toContain(`'${status}'`)); + }); + + test('an unready save can clear only smart_package_complete and cannot regress later statuses', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99, project_status: 'sent_to_ordrestyring' }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[]]; + if (sql.includes('SELECT total_work_hours')) return [[]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + + await service.replaceWorkspace(99, { expectedVersion: 0, instances: [] }); + + const statusUpdate = execute.mock.calls.find(([sql]) => sql.includes('UPDATE customer_projects')); + expect(statusUpdate[0]).toMatch(/SET project_status='geometry_complete' WHERE id=\? AND project_status='smart_package_complete'/); + ['review_pending', 'ready_for_ordrestyring', 'sent_to_ordrestyring', 'sent', 'accepted', 'rejected', 'quote_generated'] + .forEach(status => expect(statusUpdate[0]).not.toContain(`'${status}'`)); + }); + + test('runtime bootstrap widens an existing project material source enum to package', async () => { + const originalPool = databaseService.pool; + const query = jest.fn() + .mockResolvedValueOnce([[{ COLUMN_TYPE: "enum('manual','database','bygma_api')" }]]) + .mockResolvedValueOnce([{ affectedRows: 0 }]); + databaseService.pool = { query }; + try { + await databaseService.ensureProjectMaterialsSourceEnum(); + } finally { + databaseService.pool = originalPool; + } + + expect(query).toHaveBeenCalledTimes(2); + expect(query.mock.calls[1][0]).toMatch(/ALTER TABLE project_materials\s+MODIFY COLUMN material_source ENUM\('manual', 'database', 'bygma_api', 'package'\) DEFAULT 'manual'/); + }); + + test('replaces workspace labor with canonical aliases while preserving manual and imported labor', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('SELECT id FROM customer_projects')) return [[{ id: 99 }]]; + if (sql.includes('SELECT version FROM project_smart_package_workspaces')) return [[{ version: 1 }]]; + if (sql.includes('FROM roof_geometry')) return [[]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[{ + id: 10, version: 1, is_active: 1, validation_status: 'verified', package_type: 'component' + }]]; + if (sql.includes('FROM package_materials')) return [[{ + id: 101, package_id: 10, kind: 'materials', material_id: 4, name: 'Vare', quantity: 1, unit: 'stk' + }]]; + if (sql.includes('FROM smart_package_tasks')) return [[{ + id: 201, package_id: 10, kind: 'tasks', name: 'Montage', hours: 3, rate: 600, + time_unit: 'total', source_date: '2026-09-19T12:00:00.000Z' + }]]; + if (sql.includes('FROM material_prices')) return [[{ + material_id: 4, price_version: 6, current_unit_price: 1, current_unit: 'stk', + source_date: '2026-09-19T12:00:00.000Z', price_source: 'Bygma' + }]]; + if (sql.includes('SELECT total_work_hours')) return [[{ + work_breakdown: JSON.stringify([ + { name: 'Manuel', totalHours: 2, rate: 700, totalCost: 1400 }, + { name: 'Importeret', totalHours: 1, rate: 800, totalCost: 800, source: 'import' }, + { packageInstanceId: 'old', name: 'Gammel workspace', totalHours: 99, rate: 1, totalCost: 99 } + ]) + }]]; + return [{ affectedRows: 1 }]; + }); + const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + const instances = [{ + instanceId: 'new', sourcePackageId: 10, sourcePackageVersion: 1, name: 'Ny', + quantityBasis: { mode: 'direct', quantity: 1, unit: 'stk' }, + tasks: [{ id: 201, name: 'Montage', totalHours: 3, rate: 600, timeBasis: 1, timeUnit: 'total' }], + materials: [{ id: 101, materialId: 4, priceVersion: 6, name: 'Vare', quantity: 1, unit: 'stk', unitPrice: 1 }] + }]; + + await service.replaceWorkspace(99, { expectedVersion: 1, instances }); + + const laborUpsert = execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_labor')); + const breakdown = JSON.parse(laborUpsert[1].find(value => typeof value === 'string' && value.startsWith('['))); + expect(breakdown.map(entry => entry.name)).toEqual(['Manuel', 'Importeret', 'Montage']); + expect(breakdown[2]).toEqual(expect.objectContaining({ + packageInstanceId: 'new', sourcePackageId: 10, + name: 'Montage', totalHours: 3, rate: 600, totalCost: 1800, + quantityProvenance: expect.objectContaining({ + source: 'authoritative_package_child', sourceLineId: '201', + sourcePackageId: 10, sourcePackageVersion: 1 + }) + })); + expect(breakdown[2]).not.toHaveProperty('estimatedHours'); + expect(breakdown[2]).not.toHaveProperty('hourlyRate'); + }); + + const canonicalLaborFixture = ({ + selectedPackages = null, + workspace = null, + laborBreakdown = [], + totalWorkHours, + hourlyRate = 580 + } = {}) => { + const writes = []; + const execute = jest.fn(async (sql, params) => { + if (sql.includes('SELECT id') && sql.includes('FROM customer_projects')) { + return [[{ id: 99 }]]; + } + if (sql.includes('SELECT selected_packages FROM customer_projects')) return [[{ selected_packages: selectedPackages }]]; + if (sql.includes('SELECT version') && sql.includes('FROM project_smart_package_workspaces')) { + return [workspace ? [{ version: workspace.version }] : []]; + } + if (sql.includes('SELECT workspace_json FROM project_smart_package_workspaces')) { + return [workspace ? [{ workspace_json: JSON.stringify({ instances: workspace.instances }) }] : []]; + } + if (sql.includes('FROM roof_geometry')) return [[]]; + if (sql.includes('FROM material_packages WHERE id IN')) return [[{ + id: 10, version: 1, is_active: 1, validation_status: 'verified', package_type: 'component' + }]]; + if (sql.includes('FROM package_materials')) return [[{ + id: 101, package_id: 10, kind: 'materials', material_id: 4, name: 'Tagsten', quantity: 1, unit: 'stk' + }]]; + if (sql.includes('FROM smart_package_tasks')) return [[{ + id: 201, package_id: 10, kind: 'tasks', name: 'Montage', hours: 3, rate: 600, + time_unit: 'total', source_date: '2026-09-19T12:00:00.000Z' + }]]; + if (sql.includes('FROM material_prices')) return [[{ + material_id: 4, price_version: 6, current_unit_price: 100, current_unit: 'stk', + source_date: '2026-09-19T12:00:00.000Z', price_source: 'Bygma' + }]]; + if (sql.includes('SELECT total_work_hours')) return [[{ + total_work_hours: totalWorkHours, + hourly_rate: hourlyRate, + work_breakdown: JSON.stringify(laborBreakdown) + }]]; + writes.push([sql, params]); + return [{ affectedRows: 1 }]; + }); + const connection = { + execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() + }; + const service = new SmartPackageWorkspaceService({ pool: { getConnection: async () => connection } }); + const instance = { + instanceId: 'roof-one', sourcePackageId: 10, sourcePackageVersion: 1, name: 'Tagarbejde', + quantityBasis: { mode: 'direct', quantity: 1, unit: 'stk' }, + materials: [{ id: 101, materialId: 4, priceVersion: 6, name: 'Tagsten', quantity: 1, unit: 'stk', unitPrice: 100 }], + tasks: [{ id: 201, name: 'Montage', totalHours: 3, rate: 600, timeUnit: 'total' }] + }; + return { service, connection, execute, writes, instance }; + }; + + test('first canonical save requires explicit collision classification and cannot double count manual labor', async () => { + const laborBreakdown = [ + { task: 'Montage', hours: 3, rate: 600, cost: 1800, notes: '' }, + { task: 'Byggeledelse', hours: 2, rate: 700, cost: 1400, source: 'manual' } + ]; + const fixture = canonicalLaborFixture({ + selectedPackages: JSON.stringify([{ id: 10, name: 'Tagarbejde' }]), + totalWorkHours: 5, + laborBreakdown + }); + + await expect(fixture.service.replaceWorkspace(99, { expectedVersion: 0, instances: [fixture.instance] })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS', status: 409 }); + await fixture.service.replaceWorkspace(99, { + expectedVersion: 0, + instances: [fixture.instance], + legacyLaborClassification: { + expectedDigest: laborDigest(laborBreakdown), packageEntryIndexes: [0], manualEntryIndexes: [] + } + }); + + const laborUpsert = fixture.execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_labor')); + const breakdown = JSON.parse(laborUpsert[1].find(value => typeof value === 'string' && value.startsWith('['))); + expect(breakdown.map(entry => entry.name || entry.task)).toEqual(['Byggeledelse', 'Montage']); + expect(breakdown.filter(entry => entry.name === 'Montage' || entry.task === 'Montage')).toHaveLength(1); + expect(laborUpsert[1]).toEqual(expect.arrayContaining([5, 5, 640, 3200])); + }); + + test('fails closed before project writes when an untagged row matches package labor without persisted package selection evidence', async () => { + const fixture = canonicalLaborFixture({ + selectedPackages: null, + totalWorkHours: 3, + laborBreakdown: [{ task: 'Montage', hours: 3, rate: 600, cost: 1800 }] + }); + + await expect(fixture.service.replaceWorkspace(99, { expectedVersion: 0, instances: [fixture.instance] })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS', status: 409 }); + expect(fixture.connection.commit).not.toHaveBeenCalled(); + expect(fixture.connection.rollback).toHaveBeenCalled(); + expect(fixture.execute.mock.calls.some(([sql]) => /^(DELETE|INSERT|UPDATE) /.test(sql.trim()))).toBe(false); + }); + + test('replay removes an untagged legacy copy using the locked prior workspace and remains idempotent', async () => { + const priorInstance = { + instanceId: 'roof-old', sourcePackageId: 10, sourcePackageVersion: 1, name: 'Tagarbejde', + tasks: [{ packageInstanceId: 'roof-old', sourcePackageId: 10, name: 'Montage', totalHours: 3, rate: 600 }] + }; + const fixture = canonicalLaborFixture({ + workspace: { version: 1, instances: [priorInstance] }, + totalWorkHours: 7, + laborBreakdown: [ + { task: 'Montage', hours: 3, rate: 600, cost: 1800 }, + { name: 'Tilsyn', totalHours: 1, rate: 700, totalCost: 700, source: 'manual' }, + { packageInstanceId: 'roof-old', sourcePackageId: 10, name: 'Montage', totalHours: 3, rate: 600, totalCost: 1800 } + ] + }); + + await fixture.service.replaceWorkspace(99, { + expectedVersion: 1, + instances: [fixture.instance], + legacyLaborClassification: { + expectedDigest: laborDigest([ + { task: 'Montage', hours: 3, rate: 600, cost: 1800 }, + { name: 'Tilsyn', totalHours: 1, rate: 700, totalCost: 700, source: 'manual' }, + { packageInstanceId: 'roof-old', sourcePackageId: 10, name: 'Montage', totalHours: 3, rate: 600, totalCost: 1800 } + ]), + packageEntryIndexes: [0], + manualEntryIndexes: [] + } + }); + + const laborUpsert = fixture.execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_labor')); + const breakdown = JSON.parse(laborUpsert[1].find(value => typeof value === 'string' && value.startsWith('['))); + expect(breakdown.map(entry => entry.name || entry.task)).toEqual(['Tilsyn', 'Montage']); + expect(laborUpsert[1]).toEqual(expect.arrayContaining([4, 4, 625, 2500])); + + const replay = canonicalLaborFixture({ + workspace: { version: 2, instances: [fixture.instance] }, + totalWorkHours: 4, + hourlyRate: 625, + laborBreakdown: breakdown + }); + await replay.service.replaceWorkspace(99, { expectedVersion: 2, instances: [replay.instance] }); + const replayUpsert = replay.execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_labor')); + const replayBreakdown = JSON.parse(replayUpsert[1].find(value => typeof value === 'string' && value.startsWith('['))); + expect(replayBreakdown).toEqual(breakdown); + expect(replayUpsert[1]).toEqual(expect.arrayContaining([4, 4, 625, 2500])); + }); + + test('fails closed when legacy labor has a package task name but changed hours or rate', async () => { + const fixture = canonicalLaborFixture({ + selectedPackages: JSON.stringify([{ id: 10 }]), + totalWorkHours: 2, + laborBreakdown: [{ task: 'Montage', hours: 2, rate: 650, cost: 1300 }] + }); + + await expect(fixture.service.replaceWorkspace(99, { expectedVersion: 0, instances: [fixture.instance] })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS', status: 409 }); + expect(fixture.execute.mock.calls.some(([sql]) => /^(DELETE|INSERT|UPDATE) /.test(sql.trim()))).toBe(false); + }); + + test('rejects an explicit labor classification bound to a stale row digest', async () => { + const laborBreakdown = [{ task: 'Montage', hours: 3, rate: 600, cost: 1800 }]; + const fixture = canonicalLaborFixture({ totalWorkHours: 3, laborBreakdown }); + + await expect(fixture.service.replaceWorkspace(99, { + expectedVersion: 0, + instances: [fixture.instance], + legacyLaborClassification: { + expectedDigest: 'a'.repeat(64), packageEntryIndexes: [0], manualEntryIndexes: [] + } + })).rejects.toMatchObject({ + code: 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS', + status: 409, + expectedDigest: laborDigest(laborBreakdown), + ambiguousEntryIndexes: [0] + }); + expect(fixture.execute.mock.calls.some(([sql]) => /^(DELETE|INSERT|UPDATE) /.test(sql.trim()))).toBe(false); + }); + + test('persists an explicit manual collision so a second save cannot delete it', async () => { + const collision = [ + { task: 'Montage', hours: 3, rate: 600, cost: 1800 }, + { task: 'Montage', hours: 3, rate: 600, cost: 1800 } + ]; + const first = canonicalLaborFixture({ totalWorkHours: 6, laborBreakdown: collision }); + await first.service.replaceWorkspace(99, { + expectedVersion: 0, + instances: [first.instance], + legacyLaborClassification: { + expectedDigest: laborDigest(collision), packageEntryIndexes: [0], manualEntryIndexes: [1] + } + }); + const firstUpsert = first.execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_labor')); + const firstBreakdown = JSON.parse(firstUpsert[1].find(value => typeof value === 'string' && value.startsWith('['))); + expect(firstBreakdown[0]).toMatchObject({ task: 'Montage', source: 'manual' }); + + const replay = canonicalLaborFixture({ + workspace: { version: 1, instances: [first.instance] }, totalWorkHours: 6, laborBreakdown: firstBreakdown + }); + await replay.service.replaceWorkspace(99, { expectedVersion: 1, instances: [replay.instance] }); + const replayUpsert = replay.execute.mock.calls.find(([sql]) => sql.includes('INSERT INTO project_labor')); + const replayBreakdown = JSON.parse(replayUpsert[1].find(value => typeof value === 'string' && value.startsWith('['))); + expect(replayBreakdown).toEqual(firstBreakdown); + expect(replayUpsert[1]).toEqual(expect.arrayContaining([6, 6, 600, 3600])); + }); + + test('fails closed on aggregate-only legacy labor when package and manual hours cannot be separated', async () => { + const fixture = canonicalLaborFixture({ + selectedPackages: JSON.stringify([{ id: 10 }]), + totalWorkHours: 8, + laborBreakdown: [] + }); + + await expect(fixture.service.replaceWorkspace(99, { expectedVersion: 0, instances: [fixture.instance] })) + .rejects.toMatchObject({ code: 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS', status: 409 }); + expect(fixture.execute.mock.calls.some(([sql]) => /^(DELETE|INSERT|UPDATE) /.test(sql.trim()))).toBe(false); + }); +}); diff --git a/backend/src/domain/roofPitchCompatibility.js b/backend/src/domain/roofPitchCompatibility.js new file mode 100644 index 0000000..0667dd5 --- /dev/null +++ b/backend/src/domain/roofPitchCompatibility.js @@ -0,0 +1,36 @@ +'use strict'; + +const NUMERIC_TEXT = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; + +const parsePitchValue = value => { + if (typeof value === 'number') return Number.isFinite(value) ? value : NaN; + if (typeof value !== 'string') return NaN; + const trimmed = value.trim(); + if (!trimmed || !NUMERIC_TEXT.test(trimmed)) return NaN; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : NaN; +}; + +const within = (value, minimum, maximum) => { + const parsed = parsePitchValue(value); + const parsedMinimum = minimum == null ? null : parsePitchValue(minimum); + const parsedMaximum = maximum == null ? null : parsePitchValue(maximum); + return Number.isFinite(parsed) + && (parsedMinimum === null || Number.isFinite(parsedMinimum) && parsed >= parsedMinimum) + && (parsedMaximum === null || Number.isFinite(parsedMaximum) && parsed <= parsedMaximum); +}; + +const roofPitchMatchesContract = ({ form, pitch, lowerPitch, upperPitch, minPitch, maxPitch }) => { + const parsedMinimum = minPitch == null ? null : parsePitchValue(minPitch); + const parsedMaximum = maxPitch == null ? null : parsePitchValue(maxPitch); + if ((parsedMinimum !== null && !Number.isFinite(parsedMinimum)) + || (parsedMaximum !== null && !Number.isFinite(parsedMaximum)) + || (parsedMinimum !== null && parsedMaximum !== null && parsedMinimum > parsedMaximum)) return false; + if (form === 'flat') return true; + if (form === 'mansard') { + return within(lowerPitch, parsedMinimum, parsedMaximum) && within(upperPitch, parsedMinimum, parsedMaximum); + } + return within(pitch, parsedMinimum, parsedMaximum); +}; + +module.exports = { parsePitchValue, roofPitchMatchesContract }; diff --git a/backend/src/domain/roofQuoteCompleteness.js b/backend/src/domain/roofQuoteCompleteness.js new file mode 100644 index 0000000..18992f3 --- /dev/null +++ b/backend/src/domain/roofQuoteCompleteness.js @@ -0,0 +1,312 @@ +'use strict'; + +const REQUIRED_SCOPE_DECISIONS = Object.freeze([ + 'demolition', + 'disposal', + 'access', + 'scaffold', + 'penetrations', + 'drainage' +]); + +const BINARY_SCOPE_DECISIONS = new Set(['include', 'exclude']); +const ROOF_COMPONENTS = Object.freeze([ + 'covering', 'battens', 'counterBattens', 'underlay', 'deck', 'vaporControl', + 'insulation', 'gutters', 'fascia', 'windboards', 'flashings', 'interior' +]); +const SITE_LOGISTICS = Object.freeze(['scaffold', 'edgeProtection', 'lift', 'crane', 'permits', 'access']); +const SCOPE_LABELS = Object.freeze({ + covering: 'Tagbeklædning', battens: 'Lægter', counterBattens: 'Kontralægter', underlay: 'Undertag', + deck: 'Tagdæk', vaporControl: 'Dampspærre', insulation: 'Isolering', gutters: 'Tagrender', + fascia: 'Stern', windboards: 'Vindskeder', flashings: 'Inddækninger', interior: 'Indvendige arbejder', + demolition: 'Nedrivning', disposal: 'Bortskaffelse', weatherProtection: 'Vejrligssikring', + scaffold: 'Stillads', edgeProtection: 'Kantsikring', lift: 'Lift', crane: 'Kran', + permits: 'Tilladelser', access: 'Adgangsforhold' +}); +const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object || {}, key); +const finite = value => typeof value === 'number' && Number.isFinite(value); +const text = value => typeof value === 'string' && value.trim().length > 0; +const isPlainAuditRounding = value => value && typeof value === 'object' && !Array.isArray(value) + && text(value.method) && Number.isInteger(value.decimals) && value.decimals >= 0; + +const blocker = (code, message, path) => ({ code, message, path, deterministic: true }); + +const exclusionReservation = (id, label) => ({ + id, + type: 'explicit_exclusion', + source: 'roof_scope', + label, + message: `${label} er eksplicit udeladt af tilbuddet.` +}); +const requiredScopeReservations = scope => { + const reservations = []; + ROOF_COMPONENTS.forEach(key => { + if (scope?.components?.[key] === 'exclude') { + reservations.push(exclusionReservation(`roof_scope_component_${key}`, SCOPE_LABELS[key])); + } + }); + if (scope?.demolition === 'exclude') reservations.push(exclusionReservation('roof_scope_demolition', SCOPE_LABELS.demolition)); + if (scope?.disposal?.decision === 'exclude') reservations.push(exclusionReservation('roof_scope_disposal', SCOPE_LABELS.disposal)); + if (scope?.weatherProtection === 'exclude') reservations.push(exclusionReservation('roof_scope_weather_protection', SCOPE_LABELS.weatherProtection)); + SITE_LOGISTICS.forEach(key => { + if (scope?.siteLogistics?.[key] === 'exclude') reservations.push(exclusionReservation(`roof_scope_${key}`, SCOPE_LABELS[key])); + }); + return reservations; +}; +const reservationsMatchScope = (reservations, scope) => { + if (!Array.isArray(reservations)) return false; + const required = requiredScopeReservations(scope); + if (reservations.length !== required.length) return false; + const fields = ['id', 'type', 'source', 'label', 'message']; + return required.every((expected, index) => fields.every(field => reservations[index]?.[field] === expected[field])); +}; + +const getInstances = input => input?.workspace?.instances ?? input?.instances ?? []; +const getFlatLines = input => input?.lines || input || {}; +const instanceLines = (instances, key) => instances.flatMap(instance => Array.isArray(instance?.[key]) ? instance[key] : []); +const allLines = (input, instanceKey, ...flatKeys) => { + const flat = getFlatLines(input); + const supplied = flatKeys.flatMap(key => Array.isArray(flat[key]) ? flat[key] : []); + return [...instanceLines(getInstances(input), instanceKey), ...supplied]; +}; + +const lineIdentity = (line, index) => line?.id ?? line?.materialId ?? line?.material_id ?? line?.name ?? index; + +function validateDetailedScope(scope, geometry, add) { + if (scope.replacementType !== 'complete_replacement') { + add('INVALID_REPLACEMENT_SCOPE', 'A roof replacement quote requires complete_replacement scope', 'scopeDecisions.replacementType'); + } + if (!text(scope.existingCovering)) add('MISSING_EXISTING_COVERING', 'Existing covering must be recorded', 'scopeDecisions.existingCovering'); + if (!text(scope.newCoveringSystem)) add('MISSING_NEW_COVERING_SYSTEM', 'New covering system must be recorded', 'scopeDecisions.newCoveringSystem'); + ROOF_COMPONENTS.forEach(key => { + if (!BINARY_SCOPE_DECISIONS.has(scope.components?.[key])) { + add(`MISSING_SCOPE_COMPONENT_${key.toUpperCase()}`, `Roof component ${key} must be included or excluded`, `scopeDecisions.components.${key}`); + } + }); + if (!BINARY_SCOPE_DECISIONS.has(scope.demolition)) { + add('MISSING_SCOPE_DECISION_DEMOLITION', 'Demolition must be included or excluded', 'scopeDecisions.demolition'); + } + if (!BINARY_SCOPE_DECISIONS.has(scope.disposal?.decision)) { + add('MISSING_SCOPE_DECISION_DISPOSAL', 'Disposal must be included or excluded', 'scopeDecisions.disposal.decision'); + } + if (!BINARY_SCOPE_DECISIONS.has(scope.weatherProtection)) { + add('MISSING_SCOPE_DECISION_WEATHER_PROTECTION', 'Weather protection must be included or excluded', 'scopeDecisions.weatherProtection'); + } + SITE_LOGISTICS.forEach(key => { + if (!BINARY_SCOPE_DECISIONS.has(scope.siteLogistics?.[key])) { + add(`MISSING_SCOPE_DECISION_${key.toUpperCase()}`, `${key} must be included or excluded`, `scopeDecisions.siteLogistics.${key}`); + } + }); + if (!['none', 'recorded'].includes(scope.penetrations?.status)) { + add('MISSING_SCOPE_DECISION_PENETRATIONS', 'Penetrations must be explicitly recorded or marked none', 'scopeDecisions.penetrations.status'); + } else if (scope.penetrations.status === 'recorded') { + if (!Array.isArray(scope.penetrations.items) || scope.penetrations.items.length === 0) { + add('MISSING_PENETRATION_INVENTORY', 'Recorded penetrations require an inventory', 'scopeDecisions.penetrations.items'); + } + (scope.penetrations.items || []).forEach((item, index) => { + if (!text(item?.type) || !finite(item?.width) || item.width <= 0 + || !finite(item?.height) || item.height <= 0 || !finite(item?.perimeter) || item.perimeter <= 0) { + add('INCOMPLETE_PENETRATION', 'Every penetration requires type, width, height, and perimeter', `scopeDecisions.penetrations.items[${index}]`); + } + }); + } + const roofType = geometry?.roofType ?? geometry?.roof_type; + if (roofType === 'flat' || roofType === 'fladt_tag') { + ['falls', 'drains', 'parapets', 'upstands'].forEach(key => { + if (!['none', 'recorded', 'included'].includes(scope.flatRoof?.[key])) { + add('MISSING_SCOPE_DECISION_DRAINAGE', `Flat-roof ${key} must be decided`, `scopeDecisions.flatRoof.${key}`); + } + }); + } + if (/eternit/i.test(scope.existingCovering || '')) { + if (!['not_suspect', 'suspect', 'confirmed'].includes(scope.disposal?.asbestosClassification)) { + add('MISSING_ASBESTOS_CLASSIFICATION', 'Eternit requires asbestos classification', 'scopeDecisions.disposal.asbestosClassification'); + } else if (['suspect', 'confirmed'].includes(scope.disposal.asbestosClassification) && !text(scope.disposal.route)) { + add('MISSING_ASBESTOS_DISPOSAL_ROUTE', 'Suspected or confirmed asbestos requires a disposal route', 'scopeDecisions.disposal.route'); + } + } +} + +function validateRoofReplacementCompleteness(input = {}) { + const blockers = []; + const add = (code, message, path) => blockers.push(blocker(code, message, path)); + const project = input.project; + const geometry = input.geometry; + const workspaceProvided = Boolean(input.workspace || input.instances || input.lines + || input.materials || input.tasks || input.laborTasks || input.rentals + || input.references || input.referenceServices); + const instances = getInstances(input); + const materials = allLines(input, 'materials', 'materials'); + const tasks = allLines(input, 'tasks', 'tasks', 'laborTasks'); + const rentals = allLines(input, 'rentals', 'rentals'); + const references = allLines(input, 'referenceServices', 'references', 'referenceServices'); + + if (!project || typeof project !== 'object' || project.id === undefined || project.id === null) { + add('MISSING_PROJECT', 'A persisted project is required', 'project'); + } + const customer = project?.customer || {}; + if (!(project?.customer_id ?? project?.customerId ?? customer.id) && !text(project?.customer_name ?? project?.customerName ?? customer.name)) { + add('MISSING_CUSTOMER', 'A persisted customer is required', 'project.customer'); + } + + if (!geometry || typeof geometry !== 'object') { + add('MISSING_GEOMETRY', 'Canonical roof replacement geometry is required', 'geometry'); + } else { + if (!text(geometry.roofType ?? geometry.roof_type)) add('MISSING_ROOF_TYPE', 'Canonical roof type is required', 'geometry.roofType'); + if (!finite(geometry?.area?.roofSurface ?? geometry?.roofCoveringArea ?? geometry?.roof_covering_area)) { + add('MISSING_GEOMETRY_AREA', 'Canonical roof surface area is required', 'geometry.area.roofSurface'); + } + if (!text(geometry?.formula?.id) || !text(geometry?.formula?.area)) { + add('MISSING_GEOMETRY_FORMULA', 'Canonical geometry formula id and expression are required', 'geometry.formula'); + } + if (!text(geometry?.provenance?.engine) || geometry?.provenance?.version === undefined) { + add('MISSING_GEOMETRY_PROVENANCE', 'Canonical geometry provenance is required', 'geometry.provenance'); + } + if (geometry?.quoteReadiness?.ready === false) { + add('GEOMETRY_NOT_QUOTE_READY', 'Geometry reports unresolved quote dependencies', 'geometry.quoteReadiness'); + } + } + + if (!workspaceProvided) add('MISSING_WORKSPACE', 'Persisted workspace or flat lines are required', 'workspace'); + const activePositiveMaterials = materials.filter(line => line?.active !== false && line?.isActive !== false && finite(line?.quantity) && line.quantity > 0); + const activePositiveTasks = tasks.filter(line => line?.active !== false && line?.isActive !== false && finite(line?.totalHours) && line.totalHours > 0); + if (activePositiveMaterials.length === 0) { + add('MISSING_MATERIALS', 'At least one actual positive-quantity material line is required', 'lines.materials'); + } + if (activePositiveTasks.length === 0) { + add('MISSING_LABOR', 'At least one positive-hours roof labor task is required', 'lines.tasks'); + } + + const detailedScope = input.scopeDecisions && typeof input.scopeDecisions === 'object' + && !Array.isArray(input.scopeDecisions) + && (hasOwn(input.scopeDecisions, 'replacementType') || hasOwn(input.scopeDecisions, 'siteLogistics')); + if (detailedScope) { + validateDetailedScope(input.scopeDecisions, geometry, add); + } else { + add('MISSING_DETAILED_SCOPE', 'A complete roof replacement requires detailed structured scope decisions', 'scopeDecisions'); + } + if (!hasOwn(input, 'reservations') || !Array.isArray(input.reservations)) { + add('MISSING_RESERVATIONS', 'Reservations must be explicitly supplied, including an empty list', 'reservations'); + } else if (detailedScope && !reservationsMatchScope(input.reservations, input.scopeDecisions)) { + add('RESERVATION_SCOPE_MISMATCH', 'Reservations must exactly match deterministic scope exclusions', 'reservations'); + } + if (!text(input.quoteText)) add('MISSING_QUOTE_TEXT', 'Customer-facing quote text is required', 'quoteText'); + + if (geometry && input.workspace?.geometryVersion !== undefined + && geometry.version !== undefined && input.workspace.geometryVersion !== geometry.version) { + add('STALE_GEOMETRY', 'Workspace geometry version does not match canonical geometry', 'workspace.geometryVersion'); + } + + instances.forEach((instance, instanceIndex) => { + const base = `workspace.instances[${instanceIndex}]`; + if (instance?.sourcePackageId !== undefined && instance?.sourcePackageId !== null) { + const packageFormula = instance.packageFormula ?? instance.formulaDefinition; + if (!packageFormula || (!text(packageFormula.id) && !text(packageFormula.expression) && !text(packageFormula.formula))) { + add('MISSING_PACKAGE_FORMULA', 'Sourced packages require their persisted formula', `${base}.packageFormula`); + } + if (!text(packageFormula?.signature) || packageFormula?.version === undefined || packageFormula?.version === null) { + add('MISSING_PACKAGE_FORMULA_DEPENDENCY', 'Sourced packages require formula version and signature dependencies', `${base}.packageFormula`); + } + if (instance.sourcePackageVersion === undefined || instance.sourcePackageVersion === null) { + add('MISSING_PACKAGE_VERSION', 'Sourced packages require a persisted version', `${base}.sourcePackageVersion`); + } + const current = instance.currentSourcePackageVersion ?? instance.currentPackageVersion; + if (current !== undefined && current !== instance.sourcePackageVersion) { + add('STALE_PACKAGE_VERSION', 'Persisted package version is stale', `${base}.sourcePackageVersion`); + } + const formulaVersion = packageFormula?.version ?? instance.formulaVersion; + const currentFormulaVersion = instance.currentFormulaVersion ?? instance.currentPackageFormulaVersion; + if (currentFormulaVersion !== undefined && currentFormulaVersion !== formulaVersion) { + add('STALE_PACKAGE_FORMULA', 'Persisted package formula is stale', `${base}.packageFormula`); + } + } + if (!instance?.geometry || !text(instance.geometry.formula) || !text(instance.geometry.basis)) { + add('MISSING_INSTANCE_GEOMETRY', 'Package instance geometry formula and basis are required', `${base}.geometry`); + } + if (instance?.sourcePackageId !== undefined && instance?.sourcePackageId !== null) { + const engineVersion = instance?.geometry?.sourceGeometryEngineVersion ?? instance?.geometry?.sourceGeometryVersion; + if (!text(instance?.geometry?.sourceGeometryEngine) || engineVersion === undefined || !text(instance?.geometry?.sourceGeometrySignature)) { + add('MISSING_GEOMETRY_DEPENDENCY', 'Sourced package geometry requires engine, engine version, and signature dependencies', `${base}.geometry`); + } else { + if (instance.geometry.sourceGeometryEngine !== geometry?.provenance?.engine + || engineVersion !== geometry?.provenance?.version) { + add('STALE_GEOMETRY', 'Package geometry engine dependency is stale', `${base}.geometry`); + } + } + } + if (geometry?.version !== undefined) { + if (instance?.geometry?.sourceGeometryVersion === undefined) { + add('MISSING_GEOMETRY_DEPENDENCY', 'Package geometry must identify its source geometry version', `${base}.geometry.sourceGeometryVersion`); + } else if (instance.geometry.sourceGeometryVersion !== geometry.version) { + add('STALE_GEOMETRY', 'Package geometry was calculated from stale geometry', `${base}.geometry.sourceGeometryVersion`); + } + } + const sourceGeometrySignature = instance?.geometry?.sourceGeometrySignature; + const currentGeometrySignature = geometry?.signature ?? geometry?.sourceSignature; + if (currentGeometrySignature !== undefined) { + if (sourceGeometrySignature === undefined) { + add('MISSING_GEOMETRY_DEPENDENCY', 'Package geometry must identify its source geometry signature', `${base}.geometry.sourceGeometrySignature`); + } else if (sourceGeometrySignature !== currentGeometrySignature) { + add('STALE_GEOMETRY', 'Package geometry was calculated from stale geometry', `${base}.geometry.sourceGeometrySignature`); + } + } + }); + + const pricedKinds = [ + ['materials', materials, 'quantity', 'unitPrice'], + ['rentals', rentals, 'quantity', 'unitPrice'], + ['references', references, 'quantity', 'unitPrice'], + ['tasks', tasks, 'totalHours', 'rate'] + ]; + pricedKinds.forEach(([kind, lines, quantityKey, priceKey]) => lines.forEach((line, index) => { + const path = `lines.${kind}[${lineIdentity(line, index)}]`; + const active = line?.active !== false && line?.isActive !== false; + const positiveQuantity = finite(line?.[quantityKey]) && line[quantityKey] > 0; + if (!finite(line?.[quantityKey]) || line[quantityKey] < 0) add('MISSING_LINE_QUANTITY', 'Line quantity must be a non-negative finite number', `${path}.${quantityKey}`); + if (active && positiveQuantity && (!finite(line?.[priceKey]) || line[priceKey] <= 0)) add('MISSING_LINE_PRICE', 'Every positive active line requires a positive price', `${path}.${priceKey}`); + if (active && positiveQuantity && kind !== 'tasks' && !text(line?.unit)) add('MISSING_LINE_UNIT', 'Priced lines require an explicit unit', `${path}.unit`); + if (active && positiveQuantity && kind === 'tasks' && !text(line?.timeUnit ?? line?.unit)) add('MISSING_LINE_UNIT', 'Labor lines require an explicit time unit', `${path}.timeUnit`); + if (active && positiveQuantity) { + const auditComplete = text(line?.geometryBasis ?? line?.basis) + && finite(line?.measuredValue) + && finite(line?.baseQuantity) + && finite(line?.wasteFactor) + && isPlainAuditRounding(line?.rounding) + && text(line?.formula) + && text(line?.quantityMode) + && line?.quantityProvenance && typeof line.quantityProvenance === 'object' && text(line.quantityProvenance.source); + if (!auditComplete) add('MISSING_LINE_AUDIT', 'Positive active lines require complete quantity audit metadata', path); + } + if (line?.priceStale === true || line?.isPriceStale === true + || (line?.currentPriceVersion !== undefined && line.currentPriceVersion !== line.priceVersion) + || (line?.expectedPriceVersion !== undefined && line.expectedPriceVersion !== line.priceVersion) + || (line?.currentUnitPrice !== undefined && line.currentUnitPrice !== line?.[priceKey])) { + add('STALE_LINE_PRICE', 'Line price dependency is stale', path); + } + if (line?.currentUnit !== undefined && line.currentUnit !== (line?.timeUnit ?? line?.unit)) { + add('STALE_LINE_UNIT', 'Line unit dependency is stale', path); + } + })); + + return { complete: blockers.length === 0, blockers }; +} + +function assertRoofReplacementComplete(input) { + const result = validateRoofReplacementCompleteness(input); + if (!result.complete) { + const error = Object.assign(new Error('Roof quote is incomplete'), { + name: 'RoofQuoteCompletenessError', + code: 'ROOF_QUOTE_INCOMPLETE', + status: 422, + blockers: result.blockers + }); + throw error; + } + return result; +} + +module.exports = { + REQUIRED_SCOPE_DECISIONS, + validateRoofReplacementCompleteness, + assertRoofReplacementComplete +}; diff --git a/backend/src/domain/roofReplacementGeometry.js b/backend/src/domain/roofReplacementGeometry.js new file mode 100644 index 0000000..8c08ebd --- /dev/null +++ b/backend/src/domain/roofReplacementGeometry.js @@ -0,0 +1,278 @@ +'use strict'; + +const crypto = require('crypto'); + +const ENGINE_NAME = 'roofReplacementGeometry'; +const ENGINE_VERSION = 1; +const TOLERANCES = Object.freeze({ linear: 1e-9, area: 1e-9, angleDegrees: 1e-9 }); +const SUPPORTED_ROOF_TYPES = Object.freeze(['gable', 'pult', 'flat', 'hip', 'mansard']); + +function requirePositiveNumber(value, field) { + if (!Number.isFinite(value) || value <= 0) { + throw new TypeError(`${field} must be a positive finite number`); + } +} + +function requireNonNegativeNumber(value, field) { + if (!Number.isFinite(value) || value < 0) { + throw new TypeError(`${field} must be a non-negative finite number`); + } +} + +function requirePitch(value, field = 'pitch') { + requirePositiveNumber(value, field); + if (value >= 90) throw new RangeError(`${field} must be less than 90 degrees`); +} + +function overhang(value) { + return value === undefined ? 0 : value; +} + +function validateInput(input) { + if (!input || typeof input !== 'object') throw new TypeError('input is required'); + const { roofType, length, width } = input; + if (typeof roofType !== 'string' || !roofType) throw new TypeError('roofType is required'); + requirePositiveNumber(length, 'length'); + requirePositiveNumber(width, 'width'); + requireNonNegativeNumber(overhang(input.eaveOverhang), 'eaveOverhang'); + requireNonNegativeNumber(overhang(input.gableOverhang), 'gableOverhang'); + + if (roofType === 'flat') { + if (input.falls !== undefined && typeof input.falls !== 'boolean') { + throw new TypeError('falls must be a boolean when provided'); + } + if (input.drainCount !== undefined && (!Number.isInteger(input.drainCount) || input.drainCount < 0)) { + throw new TypeError('drainCount must be a non-negative integer when provided'); + } + return; + } + + if (roofType === 'mansard') { + requirePositiveNumber(input.lowerRun, 'lowerRun'); + requirePositiveNumber(input.upperRun, 'upperRun'); + requirePitch(input.lowerPitch, 'lowerPitch'); + requirePitch(input.upperPitch, 'upperPitch'); + if (Math.abs(input.lowerRun + input.upperRun - width / 2) > TOLERANCES.linear) { + throw new RangeError('mansard runs must sum to width / 2'); + } + return; + } + + requirePitch(input.pitch); + const effectiveLength = length + 2 * overhang(input.gableOverhang); + const effectiveWidth = width + 2 * overhang(input.eaveOverhang); + if (roofType === 'hip' && effectiveWidth - effectiveLength > TOLERANCES.linear) { + throw new RangeError('hip length must be greater than or equal to width'); + } +} + +function provenance(input, effectiveLength, effectiveWidth, overhangPolicy, extra = {}) { + return { + engine: ENGINE_NAME, + version: ENGINE_VERSION, + tolerances: TOLERANCES, + overhangs: { + eave: overhang(input.eaveOverhang), + gable: overhang(input.gableOverhang) + }, + structuralDimensions: { length: input.length, width: input.width }, + effectiveDimensions: { length: effectiveLength, width: effectiveWidth }, + overhangPolicy, + ...extra + }; +} + +function calculateCanonicalEnvelope(input) { + validateInput(input); + const { roofType, length, width, pitch } = input; + if (!SUPPORTED_ROOF_TYPES.includes(roofType)) throw new Error(`Unsupported roof type: ${roofType}`); + + const eaveOverhang = overhang(input.eaveOverhang); + const gableOverhang = overhang(input.gableOverhang); + const effectiveLength = length + 2 * gableOverhang; + const effectiveWidth = width + 2 * eaveOverhang; + const planArea = effectiveLength * effectiveWidth; + + if (roofType === 'flat') { + return { + roofType, + area: { plan: planArea, roofSurface: planArea }, + rise: 0, + lengths: { perimeter: 2 * (effectiveLength + effectiveWidth), ridge: 0 }, + quoteReadiness: { + ready: input.falls === true && Number.isInteger(input.drainCount) && input.drainCount > 0, + missing: [ + ...(input.falls === true ? [] : ['falls']), + ...(Number.isInteger(input.drainCount) && input.drainCount > 0 ? [] : ['drainCount']) + ] + }, + formula: { + id: 'flat-v1', + area: '(length + 2 * gableOverhang) * (width + 2 * eaveOverhang)', + fall: 'documented falls; no numeric gradient, so roofSurface equals rectangular plan field' + }, + provenance: provenance( + input, + effectiveLength, + effectiveWidth, + 'extend-rectangular-field', + { fall: input.falls === true ? 'documented' : 'not-documented' } + ) + }; + } + + if (roofType === 'mansard') { + const lowerRadians = input.lowerPitch * Math.PI / 180; + const upperRadians = input.upperPitch * Math.PI / 180; + const effectiveLowerRun = input.lowerRun + eaveOverhang; + const lowerSlope = effectiveLowerRun / Math.cos(lowerRadians); + const upperSlope = input.upperRun / Math.cos(upperRadians); + const sideSlope = lowerSlope + upperSlope; + return { + roofType, + area: { plan: planArea, roofSurface: 2 * effectiveLength * sideSlope }, + rise: effectiveLowerRun * Math.tan(lowerRadians) + input.upperRun * Math.tan(upperRadians), + lengths: { + horizontalRun: effectiveLowerRun + input.upperRun, + lowerSlope, + upperSlope, + ridge: effectiveLength, + eaves: 2 * effectiveLength, + mansardBreaks: 2 * effectiveLength, + verges: 4 * sideSlope + }, + formula: { + id: 'symmetric-mansard-v1', + plan: '(length + 2 * gableOverhang) * (width + 2 * eaveOverhang)', + area: '2 * (length + 2 * gableOverhang) * (((lowerRun + eaveOverhang) / cos(lowerPitch)) + (upperRun / cos(upperPitch)))', + overhangPolicy: 'gableOverhang extends length; eaveOverhang extends lowerRun at lowerPitch' + }, + provenance: provenance(input, effectiveLength, effectiveWidth, 'extend-mansard-lower-run', { + explicitProfileRuns: { lower: input.lowerRun, upper: input.upperRun }, + effectiveProfileRuns: { lower: effectiveLowerRun, upper: input.upperRun } + }) + }; + } + + const radians = pitch * Math.PI / 180; + + if (roofType === 'hip') { + const halfWidth = effectiveWidth / 2; + const rise = halfWidth * Math.tan(radians); + const hip = Math.sqrt(halfWidth ** 2 + halfWidth ** 2 + rise ** 2); + return { + roofType, + area: { plan: planArea, roofSurface: planArea / Math.cos(radians) }, + rise, + lengths: { + ridge: Math.max(0, effectiveLength - effectiveWidth), + hips: { count: 4, each: hip, total: 4 * hip }, + eaves: 2 * (effectiveLength + effectiveWidth) + }, + formula: { + id: 'equal-pitch-hip-v1', + plan: '(length + 2 * gableOverhang) * (width + 2 * eaveOverhang)', + area: '(length + 2 * gableOverhang) * (width + 2 * eaveOverhang) / cos(pitch)', + hip: 'sqrt((effectiveWidth / 2)^2 + (effectiveWidth / 2)^2 + rise^2)' + }, + provenance: provenance(input, effectiveLength, effectiveWidth, 'extend-length-and-width') + }; + } + + const run = roofType === 'gable' ? width / 2 + eaveOverhang : effectiveWidth; + const rafter = run / Math.cos(radians); + + if (roofType === 'pult') { + return { + roofType, + area: { plan: planArea, roofSurface: effectiveLength * rafter }, + rise: run * Math.tan(radians), + lengths: { + horizontalRun: run, + rafter, + ridge: 0, + lowJunction: effectiveLength, + highJunction: effectiveLength, + eaves: 2 * effectiveLength, + verges: 2 * rafter + }, + formula: { + id: 'pult-v1', + plan: '(length + 2 * gableOverhang) * (width + 2 * eaveOverhang)', + area: '(length + 2 * gableOverhang) * ((width + 2 * eaveOverhang) / cos(pitch))' + }, + provenance: provenance(input, effectiveLength, effectiveWidth, 'extend-length-and-full-run') + }; + } + + return { + roofType, + area: { plan: planArea, roofSurface: 2 * effectiveLength * rafter }, + rise: run * Math.tan(radians), + lengths: { + horizontalRun: run, + rafter, + ridge: effectiveLength, + eaves: 2 * effectiveLength, + verges: 4 * rafter + }, + formula: { + id: 'gable-v1', + plan: '(length + 2 * gableOverhang) * (width + 2 * eaveOverhang)', + area: '2 * (length + 2 * gableOverhang) * ((width / 2 + eaveOverhang) / cos(pitch))' + }, + provenance: provenance(input, effectiveLength, effectiveWidth, 'extend-length-and-half-run') + }; +} + +function stableCanonicalize(value) { + if (Array.isArray(value)) return value.map(stableCanonicalize); + if (value && typeof value === 'object') { + return Object.keys(value).sort().reduce((result, key) => { + if (value[key] !== undefined) result[key] = stableCanonicalize(value[key]); + return result; + }, {}); + } + if (typeof value === 'number' && Object.is(value, -0)) return 0; + return value; +} + +function canonicalGeometryInput(input) { + const canonical = { + roofType: input.roofType, + length: input.length, + width: input.width, + eaveOverhang: overhang(input.eaveOverhang), + gableOverhang: overhang(input.gableOverhang) + }; + if (input.roofType === 'flat') { + if (input.falls !== undefined) canonical.falls = input.falls; + if (input.drainCount !== undefined) canonical.drainCount = input.drainCount; + } else if (input.roofType === 'mansard') { + Object.assign(canonical, { + lowerRun: input.lowerRun, + lowerPitch: input.lowerPitch, + upperRun: input.upperRun, + upperPitch: input.upperPitch + }); + } else { + canonical.pitch = input.pitch; + } + return canonical; +} + +function calculateRoofReplacementGeometry(input) { + const envelope = { + version: ENGINE_VERSION, + input: canonicalGeometryInput(input), + ...calculateCanonicalEnvelope(input) + }; + return { + ...envelope, + signature: crypto.createHash('sha256') + .update(JSON.stringify(stableCanonicalize(envelope)), 'utf8') + .digest('hex') + }; +} + +module.exports = { calculateRoofReplacementGeometry, TOLERANCES }; diff --git a/backend/src/domain/roofTypeContract.js b/backend/src/domain/roofTypeContract.js new file mode 100644 index 0000000..a2fc0e6 --- /dev/null +++ b/backend/src/domain/roofTypeContract.js @@ -0,0 +1,48 @@ +const ROOF_TYPE_ESTIMATES = Object.freeze({ + gable: Object.freeze({ baseComplexity: 1.2, baseHoursPerM2: 1.2 }), + pult: Object.freeze({ baseComplexity: 0.9, baseHoursPerM2: 0.85 }), + flat: Object.freeze({ baseComplexity: 1.0, baseHoursPerM2: 0.8 }), + hip: Object.freeze({ baseComplexity: 1.2, baseHoursPerM2: 1.25 }), + mansard: Object.freeze({ baseComplexity: 1.5, baseHoursPerM2: 1.8 }) +}); + +const LABELS = Object.freeze({ + gable: 'Sadeltag', + pult: 'Pulttag', + flat: 'Fladt tag', + hip: 'Valmtag', + mansard: 'Mansardtag' +}); + +const CANONICAL_ROOF_TYPE_OPTIONS = Object.freeze( + Object.entries(ROOF_TYPE_ESTIMATES).map(([value, estimate]) => Object.freeze({ + value, + label: LABELS[value], + complexity: estimate.baseComplexity, + ...estimate + })) +); + +const requireRoofTypeEstimate = roofType => { + const estimate = typeof roofType === 'string' + && Object.prototype.hasOwnProperty.call(ROOF_TYPE_ESTIMATES, roofType) + ? ROOF_TYPE_ESTIMATES[roofType] + : null; + if (!estimate) throw new TypeError(`Unsupported roof type: ${roofType}`); + return estimate; +}; + +const estimateCanonicalRoofLabor = (area, complexityFactor, roofType) => { + const { baseHoursPerM2: hoursPerM2 } = requireRoofTypeEstimate(roofType); + return { + hoursPerM2, + estimatedHours: Math.round(area * hoursPerM2 * complexityFactor) + }; +}; + +module.exports = { + CANONICAL_ROOF_TYPE_OPTIONS, + ROOF_TYPE_ESTIMATES, + requireRoofTypeEstimate, + estimateCanonicalRoofLabor +}; diff --git a/backend/src/domain/smartPackageGeometry.js b/backend/src/domain/smartPackageGeometry.js new file mode 100644 index 0000000..6bfaec0 --- /dev/null +++ b/backend/src/domain/smartPackageGeometry.js @@ -0,0 +1,92 @@ +const finite = value => Number.isFinite(Number(value)) ? Number(value) : null; +const rounded = value => Math.round(value * 1000) / 1000; +const da = value => rounded(value).toLocaleString('da-DK', { maximumFractionDigits: 3 }); + +const required = (value, message) => { + const parsed = finite(value); + if (parsed === null || parsed <= 0) throw new Error(message); + return parsed; +}; + +const first = (geometry, keys) => { + for (const key of keys) { + const value = finite(geometry?.[key]); + if (value !== null && value > 0) return value; + } + return null; +}; + +const GEOMETRY_VALUES = { + roof_area: geometry => [first(geometry, ['roofCoveringArea', 'roof_covering_area', 'roofArea', 'area_m2', 'total_area']), 'Tagareal mangler i Geometri', 'm²'], + base_area: geometry => [first(geometry, ['baseArea', 'base_area', 'totalArea', 'total_area', 'area_m2']), 'Grundareal mangler i Geometri', 'm²'], + building_length: geometry => [first(geometry, ['length', 'length_m', 'length_main', 'building_length']), 'Bygningslængde mangler i Geometri', 'm'], + building_width: geometry => [first(geometry, ['width', 'width_m', 'width_main', 'building_width']), 'Bygningsbredde mangler i Geometri', 'm'], + wall_height: geometry => [first(geometry, ['wallHeight', 'wall_height']), 'Væghøjde mangler i Geometri', 'm'], + wall_area: geometry => [first(geometry, ['wallArea', 'wall_area', 'wall_area_m2']), 'Vægareal mangler i Geometri', 'm²'], + ridge: geometry => [first(geometry, ['ridge', 'ridgeLength', 'ridge_length']), 'Kiplængde mangler i Geometri', 'm'], + eaves: geometry => [first(geometry, ['eaves', 'eavesLength', 'eaves_length']), 'Tagfodslængde mangler i Geometri', 'm'], + verges: geometry => [first(geometry, ['verges', 'vergeLength', 'verge_length']), 'Vindskedelængde mangler i Geometri', 'm'], + hips: geometry => [first(geometry, ['hips', 'hipLength', 'hip_length']), 'Gratlængde mangler i Geometri', 'm'], + valleys: geometry => [first(geometry, ['valleys', 'valleyLength', 'valley_length']), 'Skotrendelængde mangler i Geometri', 'm'] +}; + +const calculateGeometryQuantity = ({ + basis, + geometry = {}, + factor = 1, + count, + fixedQuantity, + calculatedQuantity, + manualQuantity +}) => { + if (basis === 'manual') { + const fallback = finite(calculatedQuantity); + const manual = finite(manualQuantity); + const quantity = manual !== null ? manual : (fallback !== null ? fallback : 0); + return { quantity, formula: `Manuelt tilrettet: ${da(quantity)}`, quantityMode: 'manual' }; + } + if (basis === 'fixed') { + const quantity = required(fixedQuantity, 'Fast mængde mangler'); + return { quantity, formula: `Fast mængde: ${da(quantity)}`, quantityMode: 'calculated' }; + } + + const normalizedFactor = required(factor, 'Geometrifaktor skal være større end nul'); + if (basis === 'roof_sides_x_length') { + const length = required(first(geometry, ['length', 'length_m', 'length_main', 'building_length']), 'Bygningslængde mangler i Geometri'); + const quantity = rounded(normalizedFactor * length); + return { quantity, formula: `${da(normalizedFactor)} tagsider × ${da(length)} m = ${da(quantity)} løbende m`, quantityMode: 'calculated' }; + } + if (basis === 'count_x_wall_height') { + const wallHeight = required(first(geometry, ['wallHeight', 'wall_height']), 'Væghøjde mangler i Geometri'); + const itemCount = required(count, 'Antal mangler'); + const quantity = rounded(itemCount * wallHeight * normalizedFactor); + return { quantity, formula: `${da(itemCount)} stk. × ${da(wallHeight)} m = ${da(quantity)} løbende m`, quantityMode: 'calculated' }; + } + if (basis === 'building_perimeter' || basis === 'perimeter') { + const explicit = first(geometry, ['perimeter', 'buildingPerimeter', 'building_perimeter']); + const length = first(geometry, ['length', 'length_m', 'length_main', 'building_length']); + const width = first(geometry, ['width', 'width_m', 'width_main', 'building_width']); + const perimeter = explicit || ((length && width) ? 2 * (length + width) : null); + const value = required(perimeter, 'Omkreds mangler i Geometri'); + const quantity = rounded(value * normalizedFactor); + const formula = explicit + ? `${da(value)} m × ${da(normalizedFactor)} = ${da(quantity)} m` + : `(${da(length)} m + ${da(width)} m) × 2 = ${da(quantity)} løbende m`; + return { quantity, formula, quantityMode: 'calculated' }; + } + if (basis === 'count') { + const itemCount = required(count, 'Antal mangler'); + const quantity = rounded(itemCount * normalizedFactor); + return { quantity, formula: `${da(itemCount)} stk. × ${da(normalizedFactor)} = ${da(quantity)} stk.`, quantityMode: 'calculated' }; + } + const definition = GEOMETRY_VALUES[basis]; + if (definition) { + const [rawValue, missingMessage, unit] = definition(geometry); + const value = required(rawValue, missingMessage); + const quantity = rounded(value * normalizedFactor); + return { quantity, formula: `${da(value)} ${unit} × ${da(normalizedFactor)} = ${da(quantity)} ${unit}`, quantityMode: 'calculated' }; + } + throw new Error(`Ukendt geometribasis: ${basis || 'mangler'}`); +}; + +module.exports = { calculateGeometryQuantity }; diff --git a/backend/src/dto/publicSmartPackageDto.js b/backend/src/dto/publicSmartPackageDto.js new file mode 100644 index 0000000..b69a9c5 --- /dev/null +++ b/backend/src/dto/publicSmartPackageDto.js @@ -0,0 +1,111 @@ +const PACKAGE_FIELDS = [ + 'id', 'name', 'description', 'category', 'package_type', 'composition_type', + 'validation_status', 'version', + 'estimated_hours', 'hourly_rate', 'installation_notes', 'area_based', 'time_per_sqm', + 'time_per_unit', 'hours_unit', 'auto_calculated', 'is_template', 'popularity_score', + 'unit', 'unit_price', 'price_per_unit', 'standard_price', 'price_basis_note', + 'price_source', 'price_source_value', 'geometry_basis', 'geometry_factor', 'default_count', + 'default_quantity', 'replacement_scope', 'compatible_roof_materials', 'allowed_roof_forms', + 'min_pitch_degrees', 'max_pitch_degrees', 'pitch_verification_status', + 'pitch_review_required', 'material_count', 'task_count', 'task_hours', 'labor_cost', + 'material_cost', 'total_package_price', 'effectiveEstimatedHours', 'hoursSource' +]; + +const MATERIAL_FIELDS = [ + 'id', 'material_id', 'materialId', 'name', 'material_name', 'category', + 'material_category', 'description', 'material_description', 'quantity', 'unit', 'price', + 'unit_price', 'unitPrice', 'total_price', 'supplier', 'notes', 'geometry_multiplier', + 'geometryMultiplier', 'geometry_basis', 'geometryBasis', 'geometry_factor', 'geometryFactor', + 'base_quantity', 'baseQuantity', 'waste_factor', 'wasteFactor', 'custom_price', 'isRental', + 'lineType', 'active', 'isActive', 'quantityMode', 'quantity_mode', 'count', 'default_count', + 'default_quantity', 'priceSource', 'componentType', 'lengthPerPieceMeters', + 'physicalQuantityPerPrimary', 'piecesPerPurchaseUnit', 'physicalUnit' +]; + +const TASK_FIELDS = [ + 'id', 'name', 'description', 'hours', 'rate', 'optional', 'depends_on', 'task_order', + 'task_phase', 'required_skills', 'required_tools', 'skills', 'tools', 'time_unit', + 'timeUnit', 'time_per_unit', 'timePerUnit', 'geometry_basis', 'geometryBasis', + 'geometry_factor', 'geometryFactor', 'default_count', 'count', 'quantityMode', + 'quantity_mode', 'default_time_applied', 'source', 'active', 'isActive' +]; + +const STEP_FIELDS = [ + 'id', 'step_order', 'title', 'description', 'minutes', 'image', 'video', 'safety', + 'quality', 'tips' +]; + +const DETAIL_TOTAL_FIELDS = [ + 'totalMaterialPrice', 'totalHours', 'totalLaborCost', 'totalPrice' +]; + +const pick = (source, fields) => Object.fromEntries( + fields.filter(field => Object.prototype.hasOwnProperty.call(source || {}, field)) + .map(field => [field, source[field]]) +); + +const parseObject = value => { + if (value && typeof value === 'object' && !Array.isArray(value)) return value; + if (typeof value !== 'string' || !value.trim()) return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (_error) { + return {}; + } +}; + +const toPublicMaterial = line => { + const rawData = parseObject(line?.excel_raw_data); + const compositionMetadata = { + componentType: line?.componentType ?? rawData.componentType, + lengthPerPieceMeters: line?.lengthPerPieceMeters ?? line?.length_per_piece_meters + ?? rawData.lengthPerPieceMeters, + physicalQuantityPerPrimary: line?.physicalQuantityPerPrimary ?? line?.physical_quantity_per_primary + ?? rawData.physicalQuantityPerPrimary, + piecesPerPurchaseUnit: line?.piecesPerPurchaseUnit ?? line?.pieces_per_purchase_unit + ?? rawData.piecesPerPurchaseUnit, + physicalUnit: line?.physicalUnit ?? line?.physical_unit ?? rawData.physicalUnit + }; + return { + ...pick(line, MATERIAL_FIELDS), + ...Object.fromEntries(Object.entries(compositionMetadata).filter(([, value]) => value !== undefined)) + }; +}; + +const mapLines = (lines, fields) => Array.isArray(lines) + ? lines.map(line => fields === MATERIAL_FIELDS ? toPublicMaterial(line) : pick(line, fields)) + : undefined; + +const toPublicPackageSummary = packageRow => { + const summary = pick(packageRow, PACKAGE_FIELDS); + if (packageRow?.created_by === 'haandvaerkpriser-import') { + summary.composition_type = 'reference_service'; + } + return summary; +}; + +const toPublicPackageDetail = packageRow => { + const detail = { + ...toPublicPackageSummary(packageRow), + ...pick(packageRow, DETAIL_TOTAL_FIELDS) + }; + if (Array.isArray(packageRow?.materials)) detail.materials = mapLines(packageRow.materials, MATERIAL_FIELDS); + if (Array.isArray(packageRow?.projectLines)) detail.projectLines = mapLines(packageRow.projectLines, MATERIAL_FIELDS); + if (Array.isArray(packageRow?.rentals)) detail.rentals = mapLines(packageRow.rentals, MATERIAL_FIELDS); + if (Array.isArray(packageRow?.referenceServices)) { + detail.referenceServices = mapLines(packageRow.referenceServices, MATERIAL_FIELDS); + } + if (Array.isArray(packageRow?.tasks)) { + detail.tasks = packageRow.tasks.map(task => ({ + ...pick(task, TASK_FIELDS), + ...(Array.isArray(task.steps) ? { steps: mapLines(task.steps, STEP_FIELDS) } : {}) + })); + } + return detail; +}; + +module.exports = { + toPublicPackageSummary, + toPublicPackageDetail +}; diff --git a/backend/src/graphql/mutations/__tests__/createOffer.test.js b/backend/src/graphql/mutations/__tests__/createOffer.test.js new file mode 100644 index 0000000..18c1612 --- /dev/null +++ b/backend/src/graphql/mutations/__tests__/createOffer.test.js @@ -0,0 +1,78 @@ +const { + CREATE_OFFER_LINE_MUTATION, + CREATE_OFFER_LINES_MUTATION, + createProductionOfferClient +} = require('../createOffer'); + +const SIGNIFICANT_FIELDS = [ + 'description', + 'quantity', + 'unit', + 'salesPrice', + 'discount', + 'productNumber', + 'taskId', + 'sortOrder' +]; + +describe('Ordrestyring create-offer mutations', () => { + test.each([ + ['single-line', CREATE_OFFER_LINE_MUTATION], + ['batch-line', CREATE_OFFER_LINES_MUTATION] + ])('requests every submitted significant field from the %s response', (_label, mutation) => { + for (const field of SIGNIFICANT_FIELDS) { + expect(mutation).toMatch(new RegExp(`\\n\\s+${field}\\s*(?:\\n|\\})`)); + } + }); + + test('production batch adapter preserves the canonical sort order in the remote mutation input', async () => { + const requests = []; + const transport = { + request: jest.fn(async (query, variables) => { + requests.push({ query, variables }); + if (query.includes('mutation CreateOfferLines')) return { createOfferLines: [] }; + if (query.includes('query OfferRecovery')) { + return { offer: { id: 88, tasks: [{ id: 1, number: 1 }], totals: { salesPrice: 100, salesPriceWithVat: 125, vat: 25 } } }; + } + if (query.includes('query OfferLinesRecovery')) { + return { + offerLines: { + items: [{ + id: 9, + description: 'Testlinje [stk]', + quantity: 1, + salesPrice: 100, + discount: 0, + productNumber: 'TEST', + sortOrder: 7, + offer: { id: 88 } + }], + total: 1, + hasMorePages: false, + nextCursor: null + } + }; + } + throw new Error(`Unexpected query: ${query}`); + }) + }; + const client = createProductionOfferClient(transport); + + await client.request(CREATE_OFFER_LINES_MUTATION, { + inputs: [{ + offerId: 88, + taskId: 1, + description: 'Testlinje', + quantity: 1, + unit: 'stk', + salesPrice: 1, + discount: 0, + productNumber: 'TEST', + sortOrder: 7 + }] + }); + + const mutation = requests.find(request => request.query.includes('mutation CreateOfferLines')); + expect(mutation.variables.inputs[0].sortOrder).toBe(7); + }); +}); diff --git a/backend/src/graphql/mutations/createOffer.js b/backend/src/graphql/mutations/createOffer.js index 1b96b67..2cfb257 100644 --- a/backend/src/graphql/mutations/createOffer.js +++ b/backend/src/graphql/mutations/createOffer.js @@ -31,8 +31,12 @@ const CREATE_OFFER_LINE_MUTATION = ` id description quantity + unit salesPrice + discount productNumber + taskId + sortOrder } } `; @@ -43,8 +47,12 @@ const CREATE_OFFER_LINES_MUTATION = ` id description quantity + unit salesPrice + discount productNumber + taskId + sortOrder } } `; @@ -54,3 +62,100 @@ module.exports = { CREATE_OFFER_LINE_MUTATION, CREATE_OFFER_LINES_MUTATION }; + +// Ordrestyring schema: Money is cents with two decimals; units are not a +// native field. Keep units visible in descriptions and recover them from there. +const OFFER_RECOVERY_QUERY = `query OfferRecovery($id: Int!) { + offer(id: $id) { id tasks { id number } totals { salesPrice salesPriceWithVat vat } } +}`; +const OFFER_LINES_RECOVERY_QUERY = `query OfferLinesRecovery($taskId: Int!, $cursor: String) { + offerLines(offerTaskId: $taskId, pagination: { limit: 200, cursor: $cursor }) { + items { id description quantity salesPrice discount productNumber sortOrder offer { id } } + total hasMorePages nextCursor + } +}`; +const DELETE_OFFER_MUTATION = `mutation DeleteOffer($ids: [Int!]) { deleteOffer(id: $ids) }`; +const OFFER_EXISTS_QUERY = `query OfferExists($id: Int!) { offer(id: $id) { id } }`; + +function createProductionOfferClient(transport) { + const { lineNetCents, nativeMoney, OfferNormalizationError } = require('../../services/ordrestyringOfferNormalizationService'); + const unknown = () => Object.assign(new Error('Ordrestyring remote state could not be verified'), { + code: 'REMOTE_LINE_STATE_UNKNOWN', status: 502 + }); + const readOffer = async id => { + const data = await transport.request(OFFER_RECOVERY_QUERY, { id }); + if (Number(data?.offer?.id) !== Number(id)) throw unknown(); + return data.offer; + }; + const decodeLine = (line, taskId, offerId) => { + const match = line?.description?.match(/^([\s\S]*) \[([^\[\]]+)\]$/); + if (!match || Number(line.offer?.id) !== Number(offerId) || line.salesPrice == null) throw unknown(); + return { ...line, description: match[1], unit: match[2], taskId, salesPrice: Number(line.salesPrice) / 100 }; + }; + const client = { + verifyPersistedLines: true, + async request(query, variables) { + if (query !== CREATE_OFFER_LINES_MUTATION) return transport.request(query, variables); + const inputs = variables.inputs.map(({ unit, sortOrder, ...line }) => { + if (typeof unit !== 'string' || !unit || /[\[\]]/.test(unit)) throw unknown(); + const salesPrice = nativeMoney(line.salesPrice); + if (lineNetCents(line) !== lineNetCents({ ...line, salesPrice: salesPrice / 100 })) { + throw new OfferNormalizationError('Ordrestyring Money precision cannot preserve approved line total'); + } + return { ...line, sortOrder, salesPrice, description: `${line.description} [${unit}]` }; + }); + // The current response schema has neither unit nor taskId. Read back task + // membership and all persisted lines instead of inventing response fields. + const nativeQuery = query.replace(/^ (unit|taskId)\n/gm, '').replace(' sortOrder\n', ' sortOrder\n offer { id }\n'); + await transport.request(nativeQuery, { inputs }); + const lines = await client.getOfferLines(inputs[0].offerId); + const desiredOrders = new Set(variables.inputs.map(line => line.sortOrder)); + return { createOfferLines: lines.filter(line => desiredOrders.has(line.sortOrder)), offerLines: lines }; + }, + async getOfferLines(id) { + const offer = await readOffer(id); + if (!Array.isArray(offer.tasks)) throw unknown(); + const lines = []; + const ids = new Set(); + for (const task of offer.tasks) { + if (!Number.isInteger(task.id) || !Number.isInteger(task.number)) throw unknown(); + let cursor = null; + const cursors = new Set(); + let count = 0; + do { + const data = await transport.request(OFFER_LINES_RECOVERY_QUERY, { taskId: task.id, cursor }); + const page = data?.offerLines; + if (!Array.isArray(page?.items) || typeof page.hasMorePages !== 'boolean' || !Number.isInteger(page.total)) throw unknown(); + for (const line of page.items) { + if (!line?.id || ids.has(line.id)) throw unknown(); + ids.add(line.id); + lines.push(decodeLine(line, task.number, id)); + count += 1; + } + if (!page.hasMorePages) { + if (count !== page.total) throw unknown(); + break; + } + cursor = page.nextCursor; + if (!cursor || cursors.has(cursor) || page.items.length === 0) throw unknown(); + cursors.add(cursor); + } while (true); + } + return lines; + }, + async getOfferTotals(id) { + const offer = await readOffer(id); + const totals = offer.totals; + if (!totals || ['salesPrice', 'salesPriceWithVat', 'vat'].some(key => totals[key] == null || !Number.isFinite(Number(totals[key])))) throw unknown(); + return Object.fromEntries(['salesPrice', 'salesPriceWithVat', 'vat'].map(key => [key, Number(totals[key]) / 100])); + }, + async deleteOffer(id) { + await transport.request(DELETE_OFFER_MUTATION, { ids: [id] }); + const data = await transport.request(OFFER_EXISTS_QUERY, { id }); + return { id, deleted: data?.offer === null }; + } + }; + return client; +} + +module.exports.createProductionOfferClient = createProductionOfferClient; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 747d4c7..0f01fae 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -106,8 +106,61 @@ function verifyRefreshToken(token) { } } +/** + * Middleware to require an admin role. Must run after verifyToken. + */ +async function requireAdmin(req, res, next) { + try { + const userService = require('../services/userService'); + const current = req.user?.username ? await userService.findByUsername(req.user.username) : null; + if (!current || Number(current.id) !== Number(req.user?.id) || current.username !== req.user?.username || current.role !== 'admin') { + return res.status(403).json({ + success: false, + message: 'Admin access required' + }); + } + req.user = { ...req.user, id: current.id, username: current.username, role: current.role }; + next(); + } catch (_error) { + return res.status(503).json({ + success: false, + message: 'Admin authorization unavailable' + }); + } +} + +async function findConfiguredOperator(user) { + const configuredUsername = String(process.env.AUTH_USERNAME || '').trim(); + if (!configuredUsername || user?.username !== configuredUsername) return null; + const current = await require('../services/userService').findByUsername(user.username); + if (!current || current.username !== user.username + || Number(current.id) !== Number(user.id) || current.role !== 'admin') return null; + return current; +} + +// Must follow verifyToken. Never trust roles or identity from a stale JWT. +async function requireConfiguredOperator(req, res, next) { + const message = res.locals.operatorDeniedMessage || 'Ingen adgang til projektdata'; + const denied = () => res.status(403).json({ + success: false, + error: message, + ...(res.locals.operatorDeniedCode ? { code: res.locals.operatorDeniedCode } : {}) + }); + try { + const current = await findConfiguredOperator(req.user); + if (!current) return denied(); + req.user = { ...req.user, id: current.id, username: current.username, role: current.role }; + return next(); + } catch (_error) { + return res.status(503).json({ success: false, error: 'Operator authorization unavailable' }); + } +} + module.exports = { verifyToken, + requireAdmin, + findConfiguredOperator, + requireConfiguredOperator, generateAccessToken, generateRefreshToken, verifyRefreshToken diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js deleted file mode 100644 index 20b87dd..0000000 --- a/backend/src/routes/auth.js +++ /dev/null @@ -1,67 +0,0 @@ -const express = require('express'); -const Joi = require('joi'); -const logger = require('../utils/logger'); - -const router = express.Router(); - -// Credentials must be set in environment variables (.env file) -// SECURITY: No fallback credentials - app will fail if not configured -if (!process.env.AUTH_USERNAME || !process.env.AUTH_PASSWORD) { - throw new Error('AUTH_USERNAME and AUTH_PASSWORD must be set in environment variables'); -} - -const VALID_CREDENTIALS = { - username: process.env.AUTH_USERNAME, - password: process.env.AUTH_PASSWORD -}; - -const loginSchema = Joi.object({ - username: Joi.string().required(), - password: Joi.string().required() -}); - -router.post('/login', async (req, res) => { - try { - // Validate request - const { error, value } = loginSchema.validate(req.body); - if (error) { - return res.status(400).json({ - success: false, - error: 'Ugyldig anmodning' - }); - } - - const { username, password } = value; - - // Check credentials - if (username === VALID_CREDENTIALS.username && password === VALID_CREDENTIALS.password) { - logger.info('Successful login attempt', { - username: username, - ip: req.ip - }); - - res.json({ - success: true, - message: 'Login successful' - }); - } else { - logger.warn('Failed login attempt', { - username: username, - ip: req.ip - }); - - res.status(401).json({ - success: false, - error: 'Forkert brugernavn eller adgangskode' - }); - } - } catch (error) { - logger.error('Login error:', error); - res.status(500).json({ - success: false, - error: 'Der opstod en fejl' - }); - } -}); - -module.exports = router; diff --git a/backend/src/routes/customerProjects.js b/backend/src/routes/customerProjects.js index 359e75e..fe80a0c 100644 --- a/backend/src/routes/customerProjects.js +++ b/backend/src/routes/customerProjects.js @@ -11,20 +11,32 @@ const OrderSuggestionService = require('../services/orderSuggestionService'); const MaterialPriceStatusService = require('../services/materialPriceStatusService'); const ProjectFlowValidationService = require('../services/projectFlowValidationService'); const QuoteRealismService = require('../services/quoteRealismService'); +const { RoofQuoteSnapshotService } = require('../services/roofQuoteSnapshotService'); +const { CANONICAL_ROOF_TYPE_OPTIONS } = require('../domain/roofTypeContract'); +const SmartPackageWorkspaceService = require('../services/smartPackageWorkspaceService'); const aiValidationJobs = require('../services/aiValidationJobService'); const bygmaScraperService = require('../services/bygmaScraperService'); const databaseService = require('../services/databaseService'); const openaiService = require('../services/openaiService'); const logger = require('../utils/logger'); -const { verifyToken } = require('../middleware/auth'); +const { verifyToken, findConfiguredOperator, requireConfiguredOperator } = require('../middleware/auth'); -const requireConfiguredProjectOperator = (req, res, next) => { - const configuredUsername = String(process.env.AUTH_USERNAME || '').trim(); - if (!configuredUsername || req.user?.username !== configuredUsername) { - return res.status(403).json({ success: false, error: 'Ingen adgang til at ændre projektudlejning' }); - } - next(); -}; +const isSafeAuthenticatedStatusSummary = req => ( + req.method === 'GET' + && ( + req.path === '/material-price-status' + || /^\/projects\/\d+\/realism-analysis$/.test(req.path) + ) +); + +// Every route in this router is private. In the single-operator deployment, +// only deliberately narrow status summaries may be read by another valid user. +router.use(verifyToken); +router.use((req, res, next) => { + if (isSafeAuthenticatedStatusSummary(req)) return next(); + res.locals.operatorDeniedMessage = 'Ingen adgang til at ændre projektdata'; + return requireConfiguredOperator(req, res, next); +}); const { PROJECT_STATUS, ALL_PROJECT_STATUSES, @@ -42,12 +54,23 @@ const projectExperienceService = new ProjectExperienceService(databaseService); const orderSuggestionService = new OrderSuggestionService(databaseService); const materialPriceStatusService = new MaterialPriceStatusService(databaseService); const projectFlowValidationService = new ProjectFlowValidationService(); -const quoteRealismService = new QuoteRealismService(databaseService); +const smartPackageWorkspaceService = new SmartPackageWorkspaceService(databaseService); +const roofQuoteSnapshotService = new RoofQuoteSnapshotService({ + databaseService, + roofGeometryService, + workspaceService: smartPackageWorkspaceService +}); +const quoteRealismService = new QuoteRealismService(databaseService, roofQuoteSnapshotService); +const { GenericQuoteSnapshotService } = require('../services/genericQuoteSnapshotService'); +const { createGenericQuoteRouter } = require('./genericQuoteRoutes'); +router.use(createGenericQuoteRouter({ service: new GenericQuoteSnapshotService({ databaseService }) })); aiValidationJobs.registerHandler('project-flow', ({ projectId, data }) => ( projectFlowValidationService.validate(projectId, data) )); +router.use(require('./siteGeometryRoutes')(databaseService)); + // ==================== KUNDE PROJEKT ENDPOINTS ==================== // Opret nyt kunde projekt @@ -145,7 +168,7 @@ router.get('/projects/:id', async (req, res) => { } }); -router.post('/projects/:id/validate-flow', async (req, res) => { +router.post('/projects/:id/validate-flow', verifyToken, requireConfiguredOperator, async (req, res) => { const projectId = parseInt(req.params.id, 10); if (!projectId) { return res.status(400).json({ success: false, error: 'Ugyldigt projekt ID' }); @@ -210,6 +233,51 @@ router.get('/projects/:id/order-suggestions', async (req, res) => { } }); +router.get('/projects/:id/roof-quote-snapshot', verifyToken, requireConfiguredOperator, async (req, res) => { + try { + const projectId = Number(req.params.id); + if (!Number.isInteger(projectId) || projectId <= 0) { + return res.status(400).json({ success: false, error: 'Ugyldigt projekt-ID' }); + } + const snapshot = await roofQuoteSnapshotService.buildFromProject(projectId); + return res.json({ success: true, snapshot }); + } catch (error) { + const status = [400, 404, 422].includes(error.status) ? error.status : 500; + logger.error('Error building canonical roof quote snapshot:', error); + return res.status(status).json({ + success: false, + error: status < 500 ? error.message : 'Tagsnapshot kunne ikke opbygges', + ...(error.code ? { code: error.code } : {}), + ...(Array.isArray(error.blockers) ? { blockers: error.blockers } : {}) + }); + } +}); + +router.post('/projects/:id/roof-quote-snapshot/approve', verifyToken, requireConfiguredOperator, async (req, res) => { + try { + const projectId = Number(req.params.id); + const expectedSignature = String(req.body?.expectedSnapshotSignature || '').trim(); + if (!Number.isInteger(projectId) || projectId <= 0 || !expectedSignature) { + return res.status(400).json({ success: false, error: 'Gyldigt projekt-ID og forventet snapshotsignatur er påkrævet' }); + } + const snapshot = await quoteRealismService.approveSnapshot( + projectId, + expectedSignature, + req.user?.username || 'ukendt' + ); + return res.json({ success: true, snapshot }); + } catch (error) { + const status = [400, 404, 409, 422].includes(error.status) ? error.status : 500; + logger.error('Error approving canonical roof quote snapshot:', error); + return res.status(status).json({ + success: false, + error: status < 500 ? error.message : 'Tagsnapshot kunne ikke godkendes', + ...(error.code ? { code: error.code } : {}), + ...(Array.isArray(error.blockers) ? { blockers: error.blockers } : {}) + }); + } +}); + router.get('/projects/:id/realism-analysis', verifyToken, async (req, res) => { try { const projectId = Number(req.params.id); @@ -217,20 +285,22 @@ router.get('/projects/:id/realism-analysis', verifyToken, async (req, res) => { return res.status(400).json({ success: false, error: 'Ugyldigt projekt-ID' }); } const analysis = await quoteRealismService.getAnalysis(projectId); - const configuredUsername = String(process.env.AUTH_USERNAME || '').trim(); - if (!configuredUsername || req.user?.username !== configuredUsername) { - return res.json({ - success: true, - analysis: { - projectId: analysis.projectId, - approved: Boolean(analysis.approved), - readyForFixedPrice: Boolean(analysis.readyForFixedPrice), - approvalMode: analysis.approvalMode || null, - historyRestricted: true - } - }); + try { + if (await findConfiguredOperator(req.user)) return res.json({ success: true, analysis }); + } catch (_error) { + // Status remains available during account-database outages, but privileged + // analysis fields fail closed to the ordinary authenticated summary. } - return res.json({ success: true, analysis }); + return res.json({ + success: true, + analysis: { + projectId: analysis.projectId, + approved: Boolean(analysis.approved), + readyForFixedPrice: Boolean(analysis.readyForFixedPrice), + approvalMode: analysis.approvalMode || null, + historyRestricted: true + } + }); } catch (error) { const status = error.status === 404 ? 404 : 500; logger.error('Error building quote realism analysis:', error); @@ -241,25 +311,33 @@ router.get('/projects/:id/realism-analysis', verifyToken, async (req, res) => { } }); -router.post('/projects/:id/realism-approval', verifyToken, requireConfiguredProjectOperator, async (req, res) => { +router.post('/projects/:id/realism-approval', verifyToken, requireConfiguredOperator, async (req, res) => { try { const projectId = Number(req.params.id); if (!Number.isInteger(projectId) || projectId <= 0) { return res.status(400).json({ success: false, error: 'Ugyldigt projekt-ID' }); } - const analysis = await quoteRealismService.approveAnalysis( + const expectedSignature = String( + req.body?.expectedSnapshotSignature || req.body?.signature || '' + ).trim(); + if (!expectedSignature) { + return res.status(400).json({ success: false, error: 'Forventet snapshotsignatur er påkrævet' }); + } + const snapshot = await quoteRealismService.approveSnapshot( projectId, - req.body || {}, + expectedSignature, req.user?.username || 'ukendt' ); - return res.json({ success: true, analysis }); + const analysis = await quoteRealismService.getAnalysis(projectId); + return res.json({ success: true, snapshot, analysis }); } catch (error) { - const status = [400, 404].includes(error.status) ? error.status : 500; + const status = [400, 404, 409, 422].includes(error.status) ? error.status : 500; logger.error('Error approving quote realism analysis:', error); return res.status(status).json({ success: false, error: status < 500 ? error.message : 'Fejl ved godkendelse af realismecheck', - ...(status === 400 && error.code ? { code: error.code } : {}) + ...(error.code ? { code: error.code } : {}), + ...(Array.isArray(error.blockers) ? { blockers: error.blockers } : {}) }); } }); @@ -375,93 +453,55 @@ router.get('/material-price-status', async (req, res) => { // ==================== TAG GEOMETRI ENDPOINTS ==================== -// Gem tag geometri -router.post('/projects/:id/geometry', async (req, res) => { +const geometryPaths = ['/:id/geometry', '/projects/:id/geometry']; + +// The configured operator is the only principal allowed to persist or inspect project geometry. +router.post(geometryPaths, verifyToken, requireConfiguredOperator, async (req, res) => { + const projectId = Number(req.params.id); + if (!Number.isInteger(projectId) || projectId <= 0) { + return res.status(400).json({ success: false, error: 'Ugyldigt projekt-ID' }); + } + try { - const projectId = parseInt(req.params.id); - const requestData = req.body; - - console.log('📐 [GEOMETRY] Received request for project:', projectId); - console.log('📐 [GEOMETRY] Request data:', JSON.stringify(requestData, null, 2)); - - // Map frontend field names to backend expected names - const geometryData = { - roofType: requestData.roofType, - roofMaterial: requestData.roofMaterial || requestData.roof_material || null, - totalArea: requestData.totalArea || (requestData.roofWidth * requestData.roofLength), - roofPitch: requestData.roofPitch, - roofHeight: requestData.ridgeHeight || requestData.roofHeight, - sternToRidgeHeight: requestData.sternToRidgeHeight || requestData.wallHeight, - roofCoveringLength: requestData.roofCoveringLength, - wallHeight: requestData.wallHeight, // Legacy support - lengthMain: requestData.roofLength || requestData.lengthMain, - widthMain: requestData.roofWidth || requestData.widthMain, - hasDormers: requestData.hasDormers || false, - hasChimneys: requestData.hasChimneys || false, - hasSkylights: requestData.hasSkylights || false, - accessDifficulty: requestData.accessDifficulty || 'medium', - notes: requestData.notes - }; - - console.log('📐 [GEOMETRY] Mapped geometry data:', JSON.stringify(geometryData, null, 2)); - - // Validering - const allowedRoofMaterials = new Set(['tegl', 'tagpap', 'eternit', 'betontag', 'staal', 'andet']); - if (geometryData.roofMaterial && !allowedRoofMaterials.has(geometryData.roofMaterial)) { - return res.status(400).json({ success: false, error: 'Ugyldigt tagmateriale' }); - } - if (!geometryData.roofType || !geometryData.totalArea) { - console.log('❌ [GEOMETRY] Validation failed:', { roofType: geometryData.roofType, totalArea: geometryData.totalArea }); + await roofGeometryService.saveRoofGeometry(projectId, req.body || {}); + const geometry = await roofGeometryService.getRoofGeometry(projectId); + await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.GEOMETRY_COMPLETE); + return res.json({ success: true, geometry, message: 'Taggeometri gemt' }); + } catch (error) { + logger.error('Error saving roof geometry:', error); + if (error.status === 400 && error.code === 'ROOF_GEOMETRY_INVALID') { return res.status(400).json({ success: false, - error: 'Tag type og areal er påkrævet' + error: 'Ugyldige taggeometridata', + code: 'ROOF_GEOMETRY_INVALID' }); } - - console.log('✅ [GEOMETRY] Saving geometry to database...'); - const result = await roofGeometryService.saveRoofGeometry(projectId, geometryData); - console.log('✅ [GEOMETRY] Saved successfully:', result); - - // Opdater projekt status - await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.GEOMETRY_COMPLETE); - - res.json({ - success: true, - geometry: result, - message: 'Tag geometri gemt succesfuldt' - }); - } catch (error) { - console.log('❌ [GEOMETRY] Error:', error); - logger.error('Error saving roof geometry:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved gem af tag geometri' - }); + return res.status(500).json({ success: false, error: 'Taggeometrien kunne ikke gemmes' }); } }); -// Hent tag geometri -router.get('/projects/:id/geometry', async (req, res) => { +router.get(geometryPaths, verifyToken, requireConfiguredOperator, async (req, res) => { + const projectId = Number(req.params.id); + if (!Number.isInteger(projectId) || projectId <= 0) { + return res.status(400).json({ success: false, error: 'Ugyldigt projekt-ID' }); + } + + res.set({ + 'Cache-Control': 'no-store, no-cache, must-revalidate, private', + Pragma: 'no-cache', + Expires: '0' + }); try { - const projectId = parseInt(req.params.id); - const geometry = await roofGeometryService.getRoofGeometry(projectId); - - res.json({ - success: true, - geometry: geometry - }); + return res.json({ success: true, geometry }); } catch (error) { logger.error('Error getting roof geometry:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved hentning af tag geometri' - }); + return res.status(500).json({ success: false, error: 'Taggeometrien kunne ikke hentes' }); } }); // Genberegn geometri estimater -router.post('/projects/:id/geometry/recalculate', async (req, res) => { +router.post('/projects/:id/geometry/recalculate', verifyToken, requireConfiguredOperator, async (req, res) => { try { const projectId = parseInt(req.params.id); @@ -481,6 +521,70 @@ router.post('/projects/:id/geometry/recalculate', async (req, res) => { } }); +// ==================== SMART PAKKE WORKSPACE ==================== + +router.get('/projects/:id/smart-package-workspace', verifyToken, requireConfiguredOperator, async (req, res) => { + const projectId = Number(req.params.id); + if (!Number.isInteger(projectId) || projectId <= 0) { + return res.status(400).json({ success: false, error: 'Ugyldigt projekt-id' }); + } + try { + const workspace = await smartPackageWorkspaceService.getWorkspace(projectId); + return res.json({ success: true, workspace }); + } catch (error) { + logger.error('Error reading Smart Package workspace:', error); + return res.status(500).json({ success: false, error: 'Smart Pakke-arbejdsområdet kunne ikke hentes' }); + } +}); + +router.put('/projects/:id/smart-package-workspace', verifyToken, requireConfiguredOperator, async (req, res) => { + const projectId = Number(req.params.id); + if (!Number.isInteger(projectId) || projectId <= 0) { + return res.status(400).json({ success: false, error: 'Ugyldigt projekt-id' }); + } + try { + const workspace = await smartPackageWorkspaceService.replaceWorkspace( + projectId, + req.body || {}, + { operator: req.user.username } + ); + return res.json({ success: true, workspace }); + } catch (error) { + logger.error('Error saving Smart Package workspace:', error); + if (error.code === 'SMART_PACKAGE_LEGACY_LABOR_AMBIGUOUS') { + const expectedDigest = /^[a-f0-9]{64}$/.test(error.expectedDigest || '') ? error.expectedDigest : null; + const ambiguousEntryIndexes = Array.isArray(error.ambiguousEntryIndexes) + ? error.ambiguousEntryIndexes.filter(index => Number.isInteger(index) && index >= 0).slice(0, 500) + : []; + return res.status(409).json({ + success: false, + error: 'Eksisterende arbejdstimer kræver eksplicit klassifikation.', + code: error.code, + legacyLaborClassification: { expectedDigest, ambiguousEntryIndexes } + }); + } + if (['SMART_PACKAGE_WORKSPACE_CONFLICT', 'SMART_PACKAGE_SOURCE_STALE', + 'SMART_PACKAGE_SITE_GEOMETRY_STALE'].includes(error.code)) { + return res.status(409).json({ + success: false, + error: error.code === 'SMART_PACKAGE_SOURCE_STALE' + ? 'En valgt Smart Pakke er ændret eller arkiveret. Genindlæs klodsbiblioteket.' + : error.code === 'SMART_PACKAGE_SITE_GEOMETRY_STALE' + ? 'Kortområdet mangler eller er ændret. Genåbn kortet og prøv igen.' + : 'Smart Pakke-arbejdsområdet er ændret. Genindlæs og prøv igen.', + code: error.code + }); + } + if (error.code === 'SMART_PACKAGE_PROJECT_NOT_FOUND') { + return res.status(404).json({ success: false, error: 'Projekt ikke fundet', code: error.code }); + } + if (error.code === 'SMART_PACKAGE_WORKSPACE_INVALID') { + return res.status(400).json({ success: false, error: error.message, code: error.code }); + } + return res.status(500).json({ success: false, error: 'Smart Pakke-arbejdsområdet kunne ikke gemmes' }); + } +}); + // ==================== ARBEJDSTIMER ENDPOINTS ==================== // Gem arbejdstimer og tømrere @@ -960,7 +1064,7 @@ router.get('/projects/:id/materials/costs', async (req, res) => { // ==================== UDLEJNING ENDPOINTS ==================== -router.post('/projects/:id/rentals', verifyToken, requireConfiguredProjectOperator, async (req, res) => { +router.post('/projects/:id/rentals', verifyToken, requireConfiguredOperator, async (req, res) => { try { const projectId = Number(req.params.id); if (!Number.isInteger(projectId) || projectId <= 0) { @@ -1026,7 +1130,7 @@ router.get('/projects/:id/rentals', async (req, res) => { } }); -router.put('/projects/:id/rentals', verifyToken, requireConfiguredProjectOperator, async (req, res) => { +router.put('/projects/:id/rentals', verifyToken, requireConfiguredOperator, async (req, res) => { try { const projectId = Number(req.params.id); if (!Number.isInteger(projectId) || projectId <= 0) { @@ -1054,7 +1158,7 @@ router.put('/projects/:id/rentals', verifyToken, requireConfiguredProjectOperato } }); -router.put('/projects/:projectId/rentals/:rentalId', verifyToken, requireConfiguredProjectOperator, async (req, res) => { +router.put('/projects/:projectId/rentals/:rentalId', verifyToken, requireConfiguredOperator, async (req, res) => { try { const projectId = Number(req.params.projectId); const rentalId = Number(req.params.rentalId); @@ -1078,7 +1182,7 @@ router.put('/projects/:projectId/rentals/:rentalId', verifyToken, requireConfigu } }); -router.delete('/projects/:projectId/rentals/:rentalId', verifyToken, requireConfiguredProjectOperator, async (req, res) => { +router.delete('/projects/:projectId/rentals/:rentalId', verifyToken, requireConfiguredOperator, async (req, res) => { try { const projectId = Number(req.params.projectId); const rentalId = Number(req.params.rentalId); @@ -1100,7 +1204,7 @@ router.delete('/projects/:projectId/rentals/:rentalId', verifyToken, requireConf } }); -router.post('/projects/:id/rentals/bulk', verifyToken, requireConfiguredProjectOperator, async (req, res) => { +router.post('/projects/:id/rentals/bulk', verifyToken, requireConfiguredOperator, async (req, res) => { try { const projectId = Number(req.params.id); if (!Number.isInteger(projectId) || projectId <= 0) { @@ -1236,13 +1340,6 @@ router.post('/projects/:id/generate-quote-draft', verifyToken, async (req, res) // Hent tag typer og muligheder router.get('/roof-types', (req, res) => { - const roofTypes = [ - { value: 'fladt_tag', label: 'Fladt tag', baseComplexity: 1.0 }, - { value: 'skraat_tag', label: 'Skråt tag', baseComplexity: 1.2 }, - { value: 'mansard', label: 'Mansardtag', baseComplexity: 1.5 }, - { value: 'komplekst', label: 'Komplekst tag', baseComplexity: 1.8 } - ]; - const accessDifficulties = [ { value: 'let', label: 'Let adgang', factor: 0.0 }, { value: 'medium', label: 'Medium adgang', factor: 0.1 }, @@ -1251,7 +1348,7 @@ router.get('/roof-types', (req, res) => { res.json({ success: true, - roofTypes, + roofTypes: CANONICAL_ROOF_TYPE_OPTIONS, accessDifficulties }); }); diff --git a/backend/src/routes/enhancedFeatures.js b/backend/src/routes/enhancedFeatures.js index 0d01573..4474dcf 100644 --- a/backend/src/routes/enhancedFeatures.js +++ b/backend/src/routes/enhancedFeatures.js @@ -4,6 +4,7 @@ const logger = require('../utils/logger'); const PackageService = require('../services/packageService'); const AdvancedGeometryService = require('../services/advancedGeometryService'); const TimeCalculatorService = require('../services/timeCalculatorService'); +const { CANONICAL_ROOF_TYPE_OPTIONS } = require('../domain/roofTypeContract'); // Initialize services (will be injected with database service) let packageService, advancedGeometryService, timeCalculatorService; @@ -606,69 +607,10 @@ router.post('/projects/:projectId/full-calculation', async (req, res) => { // GET /api/enhanced/roof-types // Få liste over understøttede tagtyper med beskrivelser router.get('/roof-types', (req, res) => { - const roofTypes = [ - { - value: 'sadeltag', - label: 'Sadeltag/Skråttag', - description: 'Standard skråtag med to tagflader', - complexity: 1.0, - recommendedPitch: '25-45°', - packages: ['betontegl_basic', 'betontegl_premium'] - }, - { - value: 'valmtag', - label: 'Valmtag', - description: 'Trekantede tagender', - complexity: 1.3, - recommendedPitch: '25-50°', - packages: ['betontegl_basic', 'vingetegl_classic'] - }, - { - value: 'koebenhavnertag', - label: 'Københavnertag', - description: 'Fladt tag med lille hældning', - complexity: 0.8, - recommendedPitch: '5-15°', - packages: ['b7_basic', 'b6_basic'] - }, - { - value: 'fladt_tag', - label: 'Fladtag', - description: 'Helt fladt tag', - complexity: 0.7, - recommendedPitch: '0-5°', - packages: ['fladt_tag_basic'] - }, - { - value: 'pulttag', - label: 'Pulttag', - description: 'Enkelt skråning', - complexity: 0.9, - recommendedPitch: '15-30°', - packages: ['b7_basic', 'betontegl_basic'] - }, - { - value: 'tag_med_kviste', - label: 'Tag med kviste', - description: 'Tag med kviste', - complexity: 1.8, - recommendedPitch: '30-50°', - packages: ['betontegl_premium', 'vingetegl_classic'] - }, - { - value: 'mansardtag', - label: 'Mansardtag', - description: 'Mansardtag med varierende hældninger', - complexity: 2.2, - recommendedPitch: '30-70°', - packages: ['betontegl_premium', 'vingetegl_classic'] - } - ]; - res.json({ success: true, - roofTypes, - count: roofTypes.length + roofTypes: CANONICAL_ROOF_TYPE_OPTIONS, + count: CANONICAL_ROOF_TYPE_OPTIONS.length }); }); diff --git a/backend/src/routes/genericQuoteRoutes.js b/backend/src/routes/genericQuoteRoutes.js new file mode 100644 index 0000000..a3a149c --- /dev/null +++ b/backend/src/routes/genericQuoteRoutes.js @@ -0,0 +1,71 @@ +'use strict'; + +const express = require('express'); +const { verifyToken, requireConfiguredOperator } = require('../middleware/auth'); +const { customerDocument, ordrestyringPayload, GenericQuotePdfService } = require('../services/genericQuoteDeliveryService'); +const { MAX_GENERIC_QUOTE_TEXT_LENGTH } = require('../services/genericQuoteSnapshotService'); + +// This generic path intentionally has no production Ordrestyring adapter. Tests +// inject transport only to verify the payload contract; the separate roof offer +// boundary remains live and must not be reused here without delivery review. +function createGenericQuoteRouter({ service, pdfService = new GenericQuotePdfService(), transport } = {}) { + const router = express.Router(); + const path = '/projects/:id/generic-quote-snapshot'; + + const handle = (action, validateSnapshotAction = true) => async (req, res) => { + try { + if (!/^\d+$/.test(req.params.id) || Number(req.params.id) < 1) { + return res.status(400).json({ code: 'INVALID_PROJECT_ID', error: 'Invalid project id' }); + } + if (req.method === 'POST' && validateSnapshotAction) { + const body = req.body || {}; + if (Object.keys(body).some(key => !['projectId', 'expectedSnapshotSignature'].includes(key)) + || body.projectId !== Number(req.params.id) || !/^[a-f0-9]{64}$/.test(body.expectedSnapshotSignature || '')) { + return res.status(400).json({ code: 'INVALID_SNAPSHOT_ACTION', error: 'Project id and expected snapshot signature are required' }); + } + } + await action(req, res); + } catch (error) { + const status = [400, 404, 409, 422, 503].includes(error.status) ? error.status : 500; + res.status(status).json({ success: false, code: error.code || 'GENERIC_QUOTE_FAILED', + ...(error.reason ? { reason: error.reason } : {}), + error: status === 500 ? 'Quote action failed' : error.message }); + } + }; + router.get(path, verifyToken, requireConfiguredOperator, handle(async (req, res) => { + res.json({ success: true, snapshot: await service.buildFromProject(req.params.id) }); + })); + router.post('/projects/:id/generic-quote-text', verifyToken, requireConfiguredOperator, handle(async (req, res) => { + const body = req.body || {}; + if (Object.keys(body).some(key => !['projectId', 'genericQuoteText'].includes(key)) + || body.projectId !== Number(req.params.id) || typeof body.genericQuoteText !== 'string' + || !body.genericQuoteText.trim() || body.genericQuoteText.trim().length > MAX_GENERIC_QUOTE_TEXT_LENGTH) { + return res.status(400).json({ code: 'INVALID_GENERIC_QUOTE_TEXT', + error: `Project id and 1-${MAX_GENERIC_QUOTE_TEXT_LENGTH} characters of customer text are required` }); + } + res.json({ success: true, ...(await service.saveCustomerText(req.params.id, body.genericQuoteText)) }); + }, false)); + router.post(`${path}/approve`, verifyToken, requireConfiguredOperator, handle(async (req, res) => { + const snapshot = await service.approveSnapshot(req.params.id, req.body.expectedSnapshotSignature, req.user.username); + res.json({ success: true, snapshot }); + })); + router.post(`${path}/pdf`, verifyToken, requireConfiguredOperator, handle(async (req, res) => { + const result = await service.withApprovedSnapshot(req.params.id, req.body.expectedSnapshotSignature, async snapshot => ({ + signature: snapshot.signature, bytes: await pdfService.generateQuotePdf(customerDocument(snapshot)) + })); + res.set('X-Quote-Snapshot-Signature', result.signature); + res.set('Content-Disposition', `attachment; filename="tilbud-${Number(req.params.id)}.pdf"`); + res.type('application/pdf').send(result.bytes); + })); + router.post(`${path}/send`, verifyToken, requireConfiguredOperator, handle(async (req, res) => { + const result = await service.withApprovedSnapshot(req.params.id, req.body.expectedSnapshotSignature, async snapshot => { + const payload = ordrestyringPayload(snapshot); + if (!transport?.send) throw Object.assign(new Error('Ordrestyring transport is not enabled'), { status: 503, code: 'GENERIC_TRANSPORT_DISABLED' }); + return transport.send(payload, { idempotencyKey: snapshot.signature }); + }); + res.json({ success: true, result }); + })); + return router; +} + +module.exports = { createGenericQuoteRouter }; diff --git a/backend/src/routes/siteGeometryRoutes.js b/backend/src/routes/siteGeometryRoutes.js new file mode 100644 index 0000000..cfa06fe --- /dev/null +++ b/backend/src/routes/siteGeometryRoutes.js @@ -0,0 +1,28 @@ +const express = require('express'); +const { SiteGeometryService } = require('../services/siteGeometryService'); +const { Nominatim } = require('../services/nominatimService'); +// Mounted inside the authenticated configured-operator customerProjects router. +module.exports = function createSiteGeometryRouter(db) { + const router = express.Router(); + const service = new SiteGeometryService(db); + const geocoder = new Nominatim({ db }); + const paths = ['/projects/:id/site-geometry', '/:id/site-geometry']; + const handle = action => async (req,res) => { + const id = Number(req.params.id); + res.set('Cache-Control','no-store, private'); + if (!Number.isSafeInteger(id) || id <= 0) return res.status(400).json({error:'Ugyldigt projekt-ID'}); + try { return res.json({ success: true, ...await action(id,req) }); } + catch (error) { + if (error.status === 429) res.set('Retry-After','2'); + return res.status(error.status || 500).json({success:false, error: error.status ? error.message : 'Kortområdet kunne ikke behandles.', code:error.code || 'SITE_GEOMETRY_ERROR'}); + } + }; + router.get(paths,handle(async id => service.getState(id))); + router.put(paths,handle(async (id,req) => ({geometry:await service.save(id,req.body,req.user.username)}))); + router.delete(paths,handle(async (id,req) => service.delete(id,req.body,req.user.username))); + router.post(paths.map(path => path+'/geocode'),handle(async (id,req) => { + await service.get(id); + return {candidates:await geocoder.search(req.body?.query)}; + })); + return router; +}; diff --git a/backend/src/routes/smartPackagesRoutes.js b/backend/src/routes/smartPackagesRoutes.js index b7f0741..23b733a 100644 --- a/backend/src/routes/smartPackagesRoutes.js +++ b/backend/src/routes/smartPackagesRoutes.js @@ -4,23 +4,42 @@ const fs = require('fs'); const path = require('path'); const multer = require('multer'); const router = express.Router(); +router.use((req, res, next) => { + res.locals.operatorDeniedMessage = 'Ingen adgang til SmartPakke-administration'; + next(); +}); const HistoricalSmartPackageSearchService = require('../services/historicalSmartPackageSearchService'); const SmartPackageExcelImportService = require('../services/smartPackageExcelImportService'); const SmartPackageExcelValidationService = require('../services/smartPackageExcelValidationService'); const ExcelMappingService = require('../services/excelMappingService'); const AiFeatureFlagService = require('../services/aiFeatureFlagService'); -const { verifyToken } = require('../middleware/auth'); +const { verifyToken, requireConfiguredOperator } = require('../middleware/auth'); const aiValidationJobs = require('../services/aiValidationJobService'); const { SmartPackageIntegrityService } = require('../services/smartPackageIntegrityService'); +const { loadMaterialCatalog, rankMaterialCandidates } = require('../services/smartPackageMaterialMatchService'); const HaandvaerkPriserImportService = require('../services/haandvaerkPriserImportService'); +const { toPublicPackageSummary, toPublicPackageDetail } = require('../dto/publicSmartPackageDto'); let haandvaerkImportInProgress = false; +const MATERIAL_SEARCH_CACHE_TTL_MS = 60_000; +let materialSearchCatalogCache = { expiresAt: 0, rows: null, pending: null }; -const requireConfiguredPriceOperator = (req, res, next) => { - const configuredUsername = String(process.env.AUTH_USERNAME || '').trim(); - if (!configuredUsername || req.user?.username !== configuredUsername) { - return res.status(403).json({ success: false, error: 'Ingen adgang til prisimport' }); +const getMaterialSearchCatalog = async () => { + const now = Date.now(); + if (materialSearchCatalogCache.rows && materialSearchCatalogCache.expiresAt > now) { + return materialSearchCatalogCache.rows; } - next(); + if (!materialSearchCatalogCache.pending) { + materialSearchCatalogCache.pending = loadMaterialCatalog(global.databaseService.pool) + .then(rows => { + materialSearchCatalogCache = { rows, expiresAt: Date.now() + MATERIAL_SEARCH_CACHE_TTL_MS, pending: null }; + return rows; + }) + .catch(error => { + materialSearchCatalogCache.pending = null; + throw error; + }); + } + return materialSearchCatalogCache.pending; }; const excelUploadDir = path.join(__dirname, '../../uploads/smart-packages/excel'); @@ -72,6 +91,32 @@ const getService = () => { return global.smartPackageManagementService; }; +const getExpectedVersion = body => { + const expectedVersion = body?.expectedVersion; + if (!Number.isInteger(expectedVersion) || expectedVersion < 0) { + throw Object.assign(new Error('expectedVersion skal være et ikke-negativt heltal'), { status: 400 }); + } + return expectedVersion; +}; + +const sendChildMutationError = (res, error, clientFallback, serverFallback = clientFallback) => { + if (error.code === 'SMART_PACKAGE_VERSION_CONFLICT') { + return res.status(409).json({ + success: false, + error: 'Smart Pakken er ændret. Genindlæs og prøv igen.', + code: error.code + }); + } + const status = error.status >= 400 && error.status < 500 ? error.status : 500; + if (status >= 500) console.error(serverFallback, error); + const exposeMissing = status === 404 && error.code === 'SMART_PACKAGE_NOT_FOUND'; + return res.status(status).json({ + success: false, + error: exposeMissing ? error.message : status === 400 ? error.message : status < 500 ? clientFallback : serverFallback, + ...(exposeMissing ? { code: error.code } : {}) + }); +}; + const getHistoricalSearchService = () => { if (!global.databaseService) { throw new Error('Database service ikke initialiseret'); @@ -151,14 +196,10 @@ const findImportedDuplicate = async (filename, fileHash) => { // ======================================== // Opret en ny smart pakke med tasks og steps -router.post('/', async (req, res) => { +router.post('/', verifyToken, requireConfiguredOperator, async (req, res) => { try { - const { - name, description, category, package_type, validation_status, - estimated_hours, hourly_rate, hours_unit, time_per_sqm, installation_notes, is_template, - materials, tasks, created_by - } = req.body; - + const { name, description } = req.body; + if (!name || !description) { return res.status(400).json({ success: false, @@ -167,20 +208,8 @@ router.post('/', async (req, res) => { } const result = await getService().createPackage({ - name, - description, - category, - package_type, - validation_status, - estimated_hours, - hourly_rate, - hours_unit, - time_per_sqm, - installation_notes, - is_template, - materials, - tasks, - created_by: created_by || 'system' + ...req.body, + created_by: req.user.username }); res.json({ @@ -190,50 +219,72 @@ router.post('/', async (req, res) => { }); } catch (error) { console.error('Error creating smart package:', error); - res.status(error.status || 500).json({ - success: false, - error: error.message, - details: error.details || undefined - }); + const status = error.status >= 400 && error.status < 500 ? error.status : 500; + res.status(status).json({ success: false, error: status < 500 ? 'Kunne ikke oprette smart pakke' : 'Fejl ved oprettelse af smart pakke' }); } }); -// Hent alle smart pakker med filtrering -router.get('/', async (req, res) => { - try { - const { - packageType, validationStatus, category, search, difficulty, isTemplate, - sortBy, sortDir, limit, offset - } = req.query; - - const packages = await getService().getPackages({ - packageType, - validationStatus, - category, - search, - difficulty, - isTemplate: isTemplate === 'true' ? true : (isTemplate === 'false' ? false : undefined), - sortBy, - sortDir, - limit: limit ? parseInt(limit) : undefined, - offset: offset ? parseInt(offset) : undefined - }); +const parsePaginationValue = (value, { defaultValue, min, max = Number.MAX_SAFE_INTEGER }) => { + if (value === undefined) return defaultValue; + if (typeof value !== 'string' || !/^\d+$/.test(value)) { + throw Object.assign(new Error('Invalid pagination'), { status: 400 }); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) { + throw Object.assign(new Error('Invalid pagination'), { status: 400 }); + } + return parsed; +}; - res.json({ +const packageFiltersFromQuery = (query, { includeInactive = false } = {}) => ({ + packageType: query.packageType, + validationStatus: includeInactive ? query.validationStatus : 'verified', + category: query.category, + search: query.search, + difficulty: query.difficulty, + isTemplate: query.isTemplate === 'true' ? true : (query.isTemplate === 'false' ? false : undefined), + sortBy: query.sortBy, + sortDir: query.sortDir, + includeInactive, + limit: parsePaginationValue(query.limit, { defaultValue: 50, min: 1, max: 500 }), + offset: parsePaginationValue(query.offset, { defaultValue: 0, min: 0 }) +}); + +const sendPackagePage = async (req, res, { includeInactive = false, publicResponse = false } = {}) => { + try { + const filters = packageFiltersFromQuery(req.query, { includeInactive }); + const packages = await getService().getPackages(filters); + let total = Number(packages[0]?.total_count || 0); + if (packages.length === 0 && Number(filters.offset || 0) > 0) { + const firstMatchingRow = await getService().getPackages({ ...filters, limit: 1, offset: 0 }); + total = Number(firstMatchingRow[0]?.total_count || 0); + } + return res.json({ success: true, - packages + packages: publicResponse ? packages.map(toPublicPackageSummary) : packages, + total, + limit: filters.limit, + offset: filters.offset || 0 }); } catch (error) { - console.error('Error getting smart packages:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved hentning af smart pakker: ' + error.message - }); + if (error.status === 400) { + return res.status(400).json({ success: false, error: 'Ugyldig paginering' }); + } + console.error('Error fetching smart packages:', error); + return res.status(500).json({ success: false, error: 'Fejl ved hentning af smart pakker' }); } -}); +}; + +// Management kan se aktive og arkiverede pakker. Kræver konfigureret operatør. +router.get('/management', verifyToken, requireConfiguredOperator, + (req, res) => sendPackagePage(req, res, { includeInactive: true })); + +// Projektflow/offentlige læsninger forbliver aktive-only, også hvis en klient prøver +// at sende includeInactive i query string. +router.get('/', (req, res) => sendPackagePage(req, res, { publicResponse: true })); // Hent alle tasks - SKAL VÆRE FØR /:id ROUTE -router.get('/tasks', async (req, res) => { +router.get('/tasks', verifyToken, requireConfiguredOperator, async (req, res) => { try { const tasks = await getService().getAllTasks(); @@ -251,7 +302,7 @@ router.get('/tasks', async (req, res) => { }); // Hent pakke kategorier og typer - SKAL VÆRE FØR /:id ROUTE -router.get('/categories', async (req, res) => { +router.get('/categories', verifyToken, requireConfiguredOperator, async (req, res) => { try { const categoriesData = await getService().getPackageCategories(); @@ -269,7 +320,7 @@ router.get('/categories', async (req, res) => { }); // Permanent audit endpoint used by the monthly carpenter review. -router.get('/integrity-report', async (req, res) => { +router.get('/integrity-report', verifyToken, requireConfiguredOperator, async (req, res) => { try { const report = await new SmartPackageIntegrityService(global.databaseService).getReport(); res.json({ success: true, ...report }); @@ -281,7 +332,7 @@ router.get('/integrity-report', async (req, res) => { // Referencepriser fra haandvaerkpriser.dk (support ticket #396994). Begge // endpoints kræver en autentificeret bruger; importen kræver desuden en // eksplicit bekræftelse i request body. -router.get('/haandvaerkpriser-preview', verifyToken, requireConfiguredPriceOperator, async (req, res) => { +router.get('/haandvaerkpriser-preview', verifyToken, requireConfiguredOperator, async (req, res) => { try { const preview = await new HaandvaerkPriserImportService(global.databaseService).preview(); res.json({ success: true, ...preview }); @@ -291,7 +342,7 @@ router.get('/haandvaerkpriser-preview', verifyToken, requireConfiguredPriceOpera } }); -router.post('/haandvaerkpriser-import', verifyToken, requireConfiguredPriceOperator, async (req, res) => { +router.post('/haandvaerkpriser-import', verifyToken, requireConfiguredOperator, async (req, res) => { if (req.body?.confirm !== true) { return res.status(400).json({ success: false, error: 'Importen skal bekræftes eksplicit' }); } @@ -300,11 +351,20 @@ router.post('/haandvaerkpriser-import', verifyToken, requireConfiguredPriceOpera } haandvaerkImportInProgress = true; try { - const result = await new HaandvaerkPriserImportService(global.databaseService).import(); + const result = await new HaandvaerkPriserImportService(global.databaseService) + .import(req.body?.expectedVersions); res.json({ success: true, ...result }); } catch (error) { + if (error.code === 'SMART_PACKAGE_VERSION_CONFLICT') { + return res.status(409).json({ + success: false, + error: 'Smart Pakken er ændret. Genindlæs og prøv igen.', + code: error.code + }); + } console.error('Error importing from haandvaerkpriser.dk:', error); - res.status(500).json({ success: false, error: 'Importen kunne ikke gennemføres' }); + const status = error.status >= 400 && error.status < 500 ? error.status : 500; + res.status(status).json({ success: false, error: status < 500 ? error.message : 'Importen kunne ikke gennemføres' }); } finally { haandvaerkImportInProgress = false; } @@ -312,7 +372,7 @@ router.post('/haandvaerkpriser-import', verifyToken, requireConfiguredPriceOpera // Pakker som integritetskontrollen har arkiveret (blokeret + inaktiv) og som // venter på manuel materialekobling, med forslag til match fra materialedatabasen. -router.get('/review-queue', verifyToken, async (req, res) => { +router.get('/review-queue', verifyToken, requireConfiguredOperator, async (req, res) => { try { const queue = await new SmartPackageIntegrityService(global.databaseService).listReviewQueue(); res.json({ success: true, ...queue }); @@ -322,52 +382,46 @@ router.get('/review-queue', verifyToken, async (req, res) => { } }); -router.post('/:id/verify', verifyToken, async (req, res) => { +router.put('/:id/material-links', verifyToken, requireConfiguredOperator, async (req, res) => { try { + const { expectedVersion, links } = req.body || {}; const result = await new SmartPackageIntegrityService(global.databaseService) - .verifyPackage(req.params.id, req.user?.username || 'authenticated-user'); - res.json({ success: true, ...result }); + .linkReviewMaterials(req.params.id, expectedVersion, links, req.user?.username || 'authenticated-user'); + return res.json({ success: true, ...result }); } catch (error) { - res.status(error.status || 500).json({ + const status = error.status >= 400 && error.status < 500 ? error.status : 500; + return res.status(status).json({ success: false, - error: error.message, - details: error.details || undefined + error: status === 409 ? 'Pakken er ændret af en anden bruger. Genindlæs og prøv igen.' + : status < 500 ? error.message : 'Materialekoblingen kunne ikke gemmes.' }); } }); +router.post('/:id/verify', verifyToken, requireConfiguredOperator, async (req, res) => { + try { + const result = await new SmartPackageIntegrityService(global.databaseService) + .verifyPackage(req.params.id, req.user?.username || 'authenticated-user', req.body?.expectedVersion); + res.json({ success: true, ...result }); + } catch (error) { + const status = error.status >= 400 && error.status < 500 ? error.status : 500; + res.status(status).json({ success: false, error: status < 500 ? 'Kunne ikke verificere smart pakke' : 'Fejl ved verificering af smart pakke' }); + } +}); + // Søg i master-materialedatabasen med det materiale-id, som pakkelinjen gemmer. -router.get('/material-master-search', async (req, res) => { +router.get('/material-master-search', verifyToken, requireConfiguredOperator, async (req, res) => { try { const query = String(req.query?.q || '').trim(); if (query.length < 2) return res.json({ success: true, materials: [] }); - const search = `%${query}%`; - const [materials] = await global.databaseService.pool.execute(` - SELECT m.id, m.sku, m.name, m.description, - COALESCE(NULLIF(m.unit, ''), stark.unit, bygma.enhed) AS unit, - COALESCE(NULLIF(m.category, ''), stark.category, bygma.varegrp) AS category, - COALESCE(latest.price, stark.price, bygma.current_netto_pris) AS price, - COALESCE(latest.supplier_name, - CASE WHEN stark.id IS NOT NULL THEN 'Stark A/S' END, - CASE WHEN bygma.id IS NOT NULL THEN 'Bygma' END) AS supplier - FROM materials m - LEFT JOIN material_prices latest ON latest.id = ( - SELECT mp.id FROM material_prices mp - WHERE mp.material_id = m.id AND mp.is_active = 1 - ORDER BY mp.valid_from DESC, mp.id DESC LIMIT 1 - ) - LEFT JOIN stark_materials_cache stark ON stark.product_id = m.sku - LEFT JOIN bygma_materials_mapping bmm ON bmm.material_id = m.id - LEFT JOIN bygma_products bygma ON bygma.id = bmm.bygma_product_id AND bygma.is_active = 1 - WHERE m.name LIKE ? OR m.sku LIKE ? OR m.description LIKE ? - ORDER BY (latest.price IS NOT NULL) DESC, m.name - LIMIT 50 - `, [search, search, search]); + const catalog = await getMaterialSearchCatalog(); + const materials = rankMaterialCandidates(query, String(req.query?.unit || ''), catalog, 50); return res.json({ success: true, - materials: materials.map(material => ({ + materials: materials.map(({ _searchTokens, supplier_name: supplierName, ...material }) => ({ ...material, - price: parseFloat(material.price || 0) + price: parseFloat(material.price || 0), + supplier: supplierName || material.supplier || null })) }); } catch (error) { @@ -376,7 +430,7 @@ router.get('/material-master-search', async (req, res) => { }); // Upload Excel-fil til senere SmartPakker-import -router.get('/excel-uploads', verifyToken, async (req, res) => { +router.get('/excel-uploads', verifyToken, requireConfiguredOperator, async (req, res) => { try { fs.mkdirSync(excelUploadDir, { recursive: true }); const metadata = readExcelMetadata(); @@ -447,7 +501,7 @@ router.get('/excel-uploads', verifyToken, async (req, res) => { } }); -router.get('/excel-uploads/:filename/packages', verifyToken, async (req, res) => { +router.get('/excel-uploads/:filename/packages', verifyToken, requireConfiguredOperator, async (req, res) => { try { const filename = path.basename(req.params.filename || ''); if (!filename || filename !== req.params.filename) throw new Error('Ugyldigt Excel-filnavn'); @@ -471,12 +525,12 @@ router.get('/excel-uploads/:filename/packages', verifyToken, async (req, res) => } }); -router.post('/excel-upload', verifyToken, (req, res) => { +router.post('/excel-upload', verifyToken, requireConfiguredOperator, (req, res) => { excelUpload.single('file')(req, res, async (error) => { if (error) { return res.status(400).json({ success: false, - error: error.message + error: 'Excel-upload fejlede' }); } @@ -501,7 +555,7 @@ router.post('/excel-upload', verifyToken, (req, res) => { try { standardValidation = getExcelValidationService().validateStandard(req.file.path); } catch (error) { - standardError = error.message; + standardError = 'Standardkontrollen kunne ikke gennemføres'; } res.json({ success: true, @@ -525,13 +579,13 @@ router.post('/excel-upload', verifyToken, (req, res) => { res.status(400).json({ success: false, file: { filename: req.file.filename, originalName: req.file.originalname }, - error: validationError.message + error: 'Excel-upload kunne ikke behandles' }); } }); }); -router.post('/excel-map', verifyToken, async (req, res) => { +router.post('/excel-map', verifyToken, requireConfiguredOperator, async (req, res) => { try { if (!await new AiFeatureFlagService(global.databaseService).isEnabled('excel_mapping')) { return res.status(403).json({ success: false, error: 'AI-kolonnemapping er ikke aktiveret endnu' }); @@ -556,17 +610,17 @@ router.post('/excel-map', verifyToken, async (req, res) => { }); res.status(202).json({ success: true, job }); } catch (error) { - res.status(400).json({ success: false, error: error.message }); + res.status(400).json({ success: false, error: 'Excel-mapping kunne ikke startes' }); } }); -router.get('/excel-mapping/:jobId', verifyToken, (req, res) => { +router.get('/excel-mapping/:jobId', verifyToken, requireConfiguredOperator, (req, res) => { const job = aiValidationJobs.getJob(req.params.jobId, 'excel-mapping'); if (!job) return res.status(404).json({ success: false, error: 'Mappingjobbet blev ikke fundet' }); return res.json({ success: true, job }); }); -router.post('/excel-standard-preview', verifyToken, (req, res) => { +router.post('/excel-standard-preview', verifyToken, requireConfiguredOperator, (req, res) => { try { const filePath = getExcelFilePath(req.body?.filename); const fileHash = getFileHash(filePath); @@ -579,7 +633,7 @@ router.post('/excel-standard-preview', verifyToken, (req, res) => { } }); -router.post('/excel-validate', verifyToken, async (req, res) => { +router.post('/excel-validate', verifyToken, requireConfiguredOperator, async (req, res) => { try { const filePath = getExcelFilePath(req.body?.filename); const fileHash = getFileHash(filePath); @@ -602,12 +656,12 @@ router.post('/excel-validate', verifyToken, async (req, res) => { success: false, error: quotaExceeded ? 'AI-validering er midlertidigt utilgængelig, fordi OpenAI-kontoen mangler kvote. Standardkontrollen er gennemført; prøv AI-valideringen igen, når kvoten er aktiv.' - : (error.message || 'Excel-validering fejlede') + : 'Excel-validering kunne ikke startes' }); } }); -router.get('/excel-validation/:jobId', verifyToken, (req, res) => { +router.get('/excel-validation/:jobId', verifyToken, requireConfiguredOperator, (req, res) => { const job = aiValidationJobs.getJob(req.params.jobId, 'smart-package-excel'); if (!job) { return res.status(404).json({ @@ -618,7 +672,7 @@ router.get('/excel-validation/:jobId', verifyToken, (req, res) => { return res.json({ success: true, job }); }); -router.post('/excel-import', verifyToken, async (req, res) => { +router.post('/excel-import', verifyToken, requireConfiguredOperator, async (req, res) => { try { if (req.body?.confirmed !== true) { return res.status(400).json({ @@ -650,7 +704,8 @@ router.post('/excel-import', verifyToken, async (req, res) => { const result = await new SmartPackageExcelImportService(global.databaseService).import(filePath, { synchronize: true, - mapping + mapping, + expectedVersions: req.body?.expectedVersions }); updateExcelMetadata(req.body.filename, { importedAt: new Date().toISOString(), @@ -660,16 +715,21 @@ router.post('/excel-import', verifyToken, async (req, res) => { }); res.json({ success: true, result, message: 'SmartPakker importeret' }); } catch (error) { + if (error.code === 'SMART_PACKAGE_VERSION_CONFLICT') { + return res.status(409).json({ + success: false, + error: 'Smart Pakken er ændret. Genindlæs og prøv igen.', + code: error.code + }); + } console.error('Error importing SmartPakker Excel:', error); - res.status(400).json({ - success: false, - error: error.message || 'Excel-import fejlede' - }); + const status = error.status >= 400 && error.status < 500 ? error.status : 400; + return res.status(status).json({ success: false, error: 'Excel-import fejlede' }); } }); // Søg i historiske tilbud/projekter og byg redigerbart smart-pakke udkast -router.post('/history-search', async (req, res) => { +router.post('/history-search', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { query, limit = 5 } = req.body || {}; @@ -689,7 +749,7 @@ router.post('/history-search', async (req, res) => { }); // Kombineret søgning på tværs af Ordrestyring + lokale tilbud/projekter -router.post('/combined-history-search', async (req, res) => { +router.post('/combined-history-search', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { query, limit = 5 } = req.body || {}; @@ -714,7 +774,7 @@ router.post('/combined-history-search', async (req, res) => { // ======================================== // Hent alle komponenter -router.get('/components', async (req, res) => { +router.get('/components', verifyToken, requireConfiguredOperator, async (req, res) => { try { const db = global.databaseService; const [components] = await db.pool.execute(` @@ -743,7 +803,7 @@ router.get('/components', async (req, res) => { }); // Opret ny komponent -router.post('/components', async (req, res) => { +router.post('/components', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { name, description, category, base_hours, base_price, unit, complexity_factors } = req.body; @@ -778,7 +838,7 @@ router.post('/components', async (req, res) => { console.error('Error creating component:', error); res.status(500).json({ success: false, - error: 'Fejl ved oprettelse af komponent: ' + error.message + error: 'Fejl ved oprettelse af komponent' }); } }); @@ -787,7 +847,7 @@ router.post('/components', async (req, res) => { // STATISTIK API ENDPOINT - FØR /:id // ======================================== -router.get('/statistics', async (req, res) => { +router.get('/statistics', verifyToken, requireConfiguredOperator, async (req, res) => { try { const db = global.databaseService; @@ -861,7 +921,7 @@ router.get('/statistics', async (req, res) => { // ======================================== // Eksporter alle pakker -router.get('/export', async (req, res) => { +router.get('/export', verifyToken, requireConfiguredOperator, async (req, res) => { try { const db = global.databaseService; @@ -906,139 +966,68 @@ router.get('/export', async (req, res) => { } }); -// Importer pakker -router.post('/import', async (req, res) => { +// Import packages through the same aggregate service used by management edits. +router.post('/import', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { packages } = req.body; - - if (!packages || !Array.isArray(packages)) { + if (!Array.isArray(packages)) { + return res.status(400).json({ success: false, error: 'Ugyldig import data' }); + } + if (packages.length > 1) { return res.status(400).json({ success: false, - error: 'Ugyldig import data' + error: 'Importer én pakke ad gangen, så ændringen kan gennemføres atomisk' }); } - const db = global.databaseService; + const savedPackages = []; let imported = 0; let updated = 0; - for (const pkg of packages) { - try { - // Tjek om pakke allerede eksisterer - const [existing] = await db.pool.execute( - 'SELECT id FROM material_packages WHERE name = ?', - [pkg.name] - ); - - if (existing.length > 0) { - // Opdater eksisterende pakke - const packageId = existing[0].id; - - await db.pool.execute(` - UPDATE material_packages - SET description = ?, category = ?, package_type = ?, - estimated_hours = ?, hourly_rate = ?, - base_price = ?, total_hours = ?, installation_notes = ?, - is_template = ?, updated_at = NOW() - WHERE id = ? - `, [ - pkg.description, pkg.category, pkg.package_type, - pkg.estimated_hours, pkg.hourly_rate, - pkg.base_price, pkg.total_hours, pkg.installation_notes, - pkg.is_template, packageId - ]); - - // Slet gamle materialer og opgaver - await db.pool.execute('DELETE FROM smart_package_materials WHERE package_id = ?', [packageId]); - await db.pool.execute('DELETE FROM smart_package_tasks WHERE package_id = ?', [packageId]); - - // Indsæt nye materialer - if (pkg.materials && pkg.materials.length > 0) { - for (const material of pkg.materials) { - await db.pool.execute(` - INSERT INTO smart_package_materials - (package_id, name, quantity, unit, price, description) - VALUES (?, ?, ?, ?, ?, ?) - `, [packageId, material.name, material.quantity, material.unit, material.price, material.description]); - } - } - - // Indsæt nye opgaver - if (pkg.tasks && pkg.tasks.length > 0) { - for (const task of pkg.tasks) { - await db.pool.execute(` - INSERT INTO smart_package_tasks - (package_id, title, description, hours, task_order) - VALUES (?, ?, ?, ?, ?) - `, [packageId, task.title, task.description, task.hours, task.task_order]); - } - } - - updated++; - } else { - // Opret ny pakke - const [result] = await db.pool.execute(` - INSERT INTO material_packages - (name, description, category, package_type, - estimated_hours, hourly_rate, base_price, total_hours, - installation_notes, is_template, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, [ - pkg.name, pkg.description, pkg.category, pkg.package_type, - pkg.estimated_hours, pkg.hourly_rate, - pkg.base_price, pkg.total_hours, pkg.installation_notes, - pkg.is_template, 'import' - ]); - - const packageId = result.insertId; - - // Indsæt materialer - if (pkg.materials && pkg.materials.length > 0) { - for (const material of pkg.materials) { - await db.pool.execute(` - INSERT INTO smart_package_materials - (package_id, name, quantity, unit, price, description) - VALUES (?, ?, ?, ?, ?, ?) - `, [packageId, material.name, material.quantity, material.unit, material.price, material.description]); - } - } - - // Indsæt opgaver - if (pkg.tasks && pkg.tasks.length > 0) { - for (const task of pkg.tasks) { - await db.pool.execute(` - INSERT INTO smart_package_tasks - (package_id, title, description, hours, task_order) - VALUES (?, ?, ?, ?, ?) - `, [packageId, task.title, task.description, task.hours, task.task_order]); - } - } - - imported++; - } - } catch (pkgError) { - console.error(`Error importing package ${pkg.name}:`, pkgError); + if (!pkg || typeof pkg !== 'object' || !pkg.name) { + return res.status(400).json({ success: false, error: 'Alle importerede pakker skal have et navn' }); + } + const [existing] = await global.databaseService.pool.execute( + 'SELECT id FROM material_packages WHERE name = ?', + [pkg.name] + ); + if (existing[0]) { + getExpectedVersion(pkg); + savedPackages.push(await getService().updatePackage(existing[0].id, pkg)); + updated += 1; + } else { + savedPackages.push(await getService().createPackage({ ...pkg, created_by: req.user.username })); + imported += 1; } } - res.json({ + return res.json({ success: true, imported, updated, - total: imported + updated, + total: savedPackages.length, + packages: savedPackages, message: `${imported} pakker importeret, ${updated} pakker opdateret` }); } catch (error) { - console.error('Error importing packages:', error); - res.status(500).json({ + if (error.code === 'SMART_PACKAGE_VERSION_CONFLICT') { + return res.status(409).json({ + success: false, + error: 'Smart Pakken er ændret. Genindlæs og prøv igen.', + code: error.code + }); + } + const status = error.status >= 400 && error.status < 500 ? error.status : 500; + if (status >= 500) console.error('Error importing packages:', error); + return res.status(status).json({ success: false, - error: 'Fejl ved import af pakker: ' + error.message + error: status < 500 ? error.message : 'Fejl ved import af pakker' }); } }); // Beregn pakke med geometri - NYT ENDPOINT -router.post('/:id/calculate-with-geometry', async (req, res) => { +router.post('/:id/calculate-with-geometry', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; const { projectId, geometry, materialMarkupPercent, finalReviewMarkupPercent } = req.body; @@ -1137,9 +1126,18 @@ router.post('/:id/calculate-with-geometry', async (req, res) => { }, calculation_info: { geometry_used: { - facadelængde: geometryData.length_main, - husbredde: geometryData.width_main, - tagflade_m2: geometryData.total_area + facadelængde: result.geometry_used?.buildingLength ?? geometryData.length_main, + husbredde: result.geometry_used?.buildingWidth ?? geometryData.width_main, + tagflade_m2: result.geometry_used?.roofArea + ?? geometryData.roof_covering_area + ?? geometryData.roofCoveringArea + ?? geometryData.total_area, + tagfod_m: result.geometry_used?.eaves ?? null, + rygning_m: result.geometry_used?.ridge ?? null, + gavlkanter_m: result.geometry_used?.verges ?? null, + hoftekanter_m: result.geometry_used?.hips ?? null, + skotrender_m: result.geometry_used?.valleys ?? null, + perimeter_m: result.geometry_used?.perimeter ?? null }, markup_info: `Materialer inkl. ${materialMarkup}% avance. Total inkl. ${finalReviewMarkup}% final review markup.` } @@ -1154,20 +1152,91 @@ router.post('/:id/calculate-with-geometry', async (req, res) => { }); // Pakke skabeloner (must be before /:id to avoid conflict) -router.get('/templates', async (req, res) => { +router.get('/templates', verifyToken, requireConfiguredOperator, async (req, res) => { res.json({ success: true, templates: [] }); }); +// Interne task- og specialpakkebiblioteker skal registreres før /:id. +router.get('/custom-tasks', verifyToken, requireConfiguredOperator, async (req, res) => { + try { + const { packageId, materialVarenr } = req.query; + + const tasks = await getService().getCustomTasks({ + packageId: packageId ? parseInt(packageId) : undefined, + materialVarenr + }); + + res.json({ + success: true, + tasks, + count: tasks.length + }); + } catch (error) { + console.error('Error fetching custom tasks:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved hentning af custom tasks: ' + error.message + }); + } +}); + +router.get('/custom-packages', verifyToken, requireConfiguredOperator, async (req, res) => { + try { + const packages = await getService().getPackagesByCategory(['Kranleje', 'Sikkerhed']); + + res.json({ + success: true, + packages, + message: `${packages.length} custom smartpakker fundet` + }); + } catch (error) { + console.error('Fejl ved hentning af custom packages:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved hentning af custom packages' + }); + } +}); + +// Managementdetaljer kan åbne alle statusser, men kræver login. +router.post('/management/:id/duplicate', verifyToken, requireConfiguredOperator, async (req, res) => { + try { + if (!req.body || Array.isArray(req.body) || Object.keys(req.body).some(key => key !== 'name') || + (req.body.name !== undefined && (typeof req.body.name !== 'string' || !req.body.name.trim()))) { + return res.status(400).json({ success: false, error: 'Kun navn kan angives ved kopiering' }); + } + const result = await getService().duplicatePackage(req.params.id, req.body, req.user.username); + return res.json({ success: true, package: result }); + } catch (error) { + const status = error.status >= 400 && error.status < 500 ? error.status : 500; + return res.status(status).json({ success: false, error: 'Kunne ikke kopiere smart pakke' }); + } +}); + +router.get('/management/:id', verifyToken, requireConfiguredOperator, async (req, res) => { + try { + const packageDetails = await getService().getPackageDetails(req.params.id, { includeInactive: true }); + return res.json({ success: true, package: packageDetails }); + } catch (error) { + console.error('Error fetching management package details:', error); + const status = error.status === 404 ? 404 : 500; + return res.status(status).json({ success: false, error: status === 404 ? 'Smart Pakke ikke fundet' : 'Fejl ved hentning af Smart Pakke' }); + } +}); + // Hent en specifik smart pakke med detaljer router.get('/:id', async (req, res) => { try { const { id } = req.params; - const packageDetails = await getService().getPackageDetails(id); + const packageDetails = await getService().getPackageDetails(id, { includeInactive: false }); + if (Number(packageDetails?.is_active) !== 1 || packageDetails?.validation_status !== 'verified') { + return res.status(404).json({ success: false, error: 'Smart pakke ikke fundet' }); + } res.json({ success: true, - package: packageDetails + package: toPublicPackageDetail(packageDetails) }); } catch (error) { if (error.status === 404) { @@ -1186,48 +1255,38 @@ router.get('/:id', async (req, res) => { }); // Opdater en smart pakke -router.put('/:id', async (req, res) => { +router.put(['/management/:id', '/:id'], verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; - const { - name, description, category, package_type, validation_status, validation_notes, - estimated_hours, hourly_rate, hours_unit, time_per_sqm, installation_notes, is_template, - materials, tasks - } = req.body; + const { expectedVersion } = req.body; - await getService().updatePackage(id, { - name, - description, - category, - package_type, - validation_status, - validation_notes, - estimated_hours, - hourly_rate, - hours_unit, - time_per_sqm, - installation_notes, - is_template, - materials, - tasks - }); + if (!Number.isInteger(expectedVersion) || expectedVersion < 0) { + return res.status(400).json({ success: false, error: 'expectedVersion skal være et ikke-negativt heltal' }); + } + + const result = await getService().updatePackage(id, req.body); res.json({ success: true, + package: result, message: 'Smart pakke opdateret succesfuldt' }); } catch (error) { console.error('Error updating smart package:', error); - res.status(error.status || 500).json({ - success: false, - error: error.message, - details: error.details || undefined - }); + if (error.code === 'SMART_PACKAGE_VERSION_CONFLICT') { + return res.status(409).json({ + success: false, + error: 'Smart Pakken er ændret. Genindlæs og prøv igen.', + code: error.code + }); + } + const status = error.status >= 400 && error.status < 500 ? error.status : 500; + return res.status(status).json({ success: false, error: status < 500 ? 'Kunne ikke opdatere smart pakke' : 'Fejl ved opdatering af smart pakke' }); } }); // Slet en smart pakke -router.delete('/:id', async (req, res) => { +router.delete('/:id', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; @@ -1241,15 +1300,16 @@ router.delete('/:id', async (req, res) => { console.error('Error deleting smart package:', error); res.status(500).json({ success: false, - error: 'Fejl ved sletning af smart pakke: ' + error.message + error: 'Fejl ved sletning af smart pakke' }); } }); // Tilføj en task til en smart pakke -router.post('/:id/tasks', async (req, res) => { +router.post('/:id/tasks', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; + const expectedVersion = getExpectedVersion(req.body); const { name, description, hours, rate, skills, tools, optional, dependsOn, installationManualId, @@ -1275,41 +1335,31 @@ router.post('/:id/tasks', async (req, res) => { installationManualId, timeUnit, timePerUnit - }); + }, expectedVersion); - res.json({ - success: true, - taskId: result.taskId, - order: result.order, - message: 'Task tilføjet til smart pakke succesfuldt' - }); + res.json({ success: true, ...result, message: 'Task tilføjet til smart pakke succesfuldt' }); } catch (error) { - const status = error.status || 500; - const isPublicMissingPackageError = status === 404 && error.code === 'SMART_PACKAGE_NOT_FOUND'; - if (status >= 500) { - console.error('Error adding task to smart package:', error); - } - res.status(status).json({ - success: false, - error: isPublicMissingPackageError - ? error.message - : status < 500 ? 'Kunne ikke tilføje task til smart pakke' : 'Fejl ved tilføjelse af task til smart pakke', - ...(isPublicMissingPackageError ? { code: error.code } : {}) - }); + return sendChildMutationError( + res, + error, + 'Kunne ikke tilføje task til smart pakke', + 'Fejl ved tilføjelse af task til smart pakke' + ); } }); // Opdater en task -router.put('/tasks/:taskId', async (req, res) => { +router.put('/tasks/:taskId', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { taskId } = req.params; + const expectedVersion = getExpectedVersion(req.body); const { name, description, hours, rate, skills, tools, optional, dependsOn, installationManualId, timeUnit, timePerUnit, isCustom, materialVarenr } = req.body; - await getService().updateTask(taskId, { + const result = await getService().updateTask(taskId, { name, description, hours, @@ -1323,45 +1373,31 @@ router.put('/tasks/:taskId', async (req, res) => { timePerUnit, isCustom, materialVarenr - }); + }, expectedVersion); - res.json({ - success: true, - message: 'Task opdateret succesfuldt' - }); + res.json({ success: true, ...result, message: 'Task opdateret succesfuldt' }); } catch (error) { - console.error('Error updating task:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved opdatering af task: ' + error.message - }); + return sendChildMutationError(res, error, 'Fejl ved opdatering af task'); } }); // Slet en task -router.delete('/tasks/:taskId', async (req, res) => { +router.delete('/tasks/:taskId', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { taskId } = req.params; - - await getService().deleteTask(taskId); - - res.json({ - success: true, - message: 'Task slettet succesfuldt' - }); + const expectedVersion = getExpectedVersion(req.body); + const result = await getService().deleteTask(taskId, expectedVersion); + res.json({ success: true, ...result, message: 'Task slettet succesfuldt' }); } catch (error) { - console.error('Error deleting task:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved sletning af task: ' + error.message - }); + return sendChildMutationError(res, error, 'Fejl ved sletning af task'); } }); // Ændre rækkefølgen af tasks -router.put('/:id/reorder-tasks', async (req, res) => { +router.put('/:id/reorder-tasks', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; + const expectedVersion = getExpectedVersion(req.body); const { taskOrderings } = req.body; if (!taskOrderings || !Array.isArray(taskOrderings)) { @@ -1371,18 +1407,10 @@ router.put('/:id/reorder-tasks', async (req, res) => { }); } - await getService().reorderTasks(id, taskOrderings); - - res.json({ - success: true, - message: 'Task rækkefølge opdateret succesfuldt' - }); + const result = await getService().reorderTasks(id, taskOrderings, expectedVersion); + res.json({ success: true, ...result, message: 'Task rækkefølge opdateret succesfuldt' }); } catch (error) { - console.error('Error reordering tasks:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved opdatering af task rækkefølge: ' + error.message - }); + return sendChildMutationError(res, error, 'Fejl ved opdatering af task rækkefølge'); } }); @@ -1391,8 +1419,9 @@ router.put('/:id/reorder-tasks', async (req, res) => { // ======================================== // Opret en custom/manuel installation opgave -router.post('/custom-tasks', async (req, res) => { +router.post('/custom-tasks', verifyToken, requireConfiguredOperator, async (req, res) => { try { + const expectedVersion = getExpectedVersion(req.body); const { packageId, name, @@ -1423,50 +1452,21 @@ router.post('/custom-tasks', async (req, res) => { materialVarenr, tools, skills, - optional + optional, + expectedVersion }); - res.json({ - success: true, - ...result - }); + res.json({ success: true, ...result }); } catch (error) { - console.error('Error creating custom task:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved oprettelse af custom task: ' + error.message - }); - } -}); - -// Hent alle custom tasks med filtrering -router.get('/custom-tasks', async (req, res) => { - try { - const { packageId, materialVarenr } = req.query; - - const tasks = await getService().getCustomTasks({ - packageId: packageId ? parseInt(packageId) : undefined, - materialVarenr - }); - - res.json({ - success: true, - tasks, - count: tasks.length - }); - } catch (error) { - console.error('Error fetching custom tasks:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved hentning af custom tasks: ' + error.message - }); + return sendChildMutationError(res, error, 'Fejl ved oprettelse af custom task'); } }); // Tilføj et step til en task -router.post('/tasks/:taskId/steps', async (req, res) => { +router.post('/tasks/:taskId/steps', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { taskId } = req.params; + const expectedVersion = getExpectedVersion(req.body); const { title, description, minutes, image, video, safety, quality, tips @@ -1488,33 +1488,25 @@ router.post('/tasks/:taskId/steps', async (req, res) => { safety, quality, tips - }); + }, expectedVersion); - res.json({ - success: true, - stepId: result.stepId, - order: result.order, - message: 'Step tilføjet til task succesfuldt' - }); + res.json({ success: true, ...result, message: 'Step tilføjet til task succesfuldt' }); } catch (error) { - console.error('Error adding step to task:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved tilføjelse af step til task: ' + error.message - }); + return sendChildMutationError(res, error, 'Fejl ved tilføjelse af step til task'); } }); // Opdater et step -router.put('/steps/:stepId', async (req, res) => { +router.put('/steps/:stepId', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { stepId } = req.params; + const expectedVersion = getExpectedVersion(req.body); const { title, description, minutes, image, video, safety, quality, tips } = req.body; - await getService().updateStep(stepId, { + const result = await getService().updateStep(stepId, { title, description, minutes, @@ -1523,45 +1515,31 @@ router.put('/steps/:stepId', async (req, res) => { safety, quality, tips - }); + }, expectedVersion); - res.json({ - success: true, - message: 'Step opdateret succesfuldt' - }); + res.json({ success: true, ...result, message: 'Step opdateret succesfuldt' }); } catch (error) { - console.error('Error updating step:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved opdatering af step: ' + error.message - }); + return sendChildMutationError(res, error, 'Fejl ved opdatering af step'); } }); // Slet et step -router.delete('/steps/:stepId', async (req, res) => { +router.delete('/steps/:stepId', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { stepId } = req.params; - - await getService().deleteStep(stepId); - - res.json({ - success: true, - message: 'Step slettet succesfuldt' - }); + const expectedVersion = getExpectedVersion(req.body); + const result = await getService().deleteStep(stepId, expectedVersion); + res.json({ success: true, ...result, message: 'Step slettet succesfuldt' }); } catch (error) { - console.error('Error deleting step:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved sletning af step: ' + error.message - }); + return sendChildMutationError(res, error, 'Fejl ved sletning af step'); } }); // Ændre rækkefølgen af steps -router.put('/tasks/:taskId/reorder-steps', async (req, res) => { +router.put('/tasks/:taskId/reorder-steps', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { taskId } = req.params; + const expectedVersion = getExpectedVersion(req.body); const { stepOrderings } = req.body; if (!stepOrderings || !Array.isArray(stepOrderings)) { @@ -1571,23 +1549,15 @@ router.put('/tasks/:taskId/reorder-steps', async (req, res) => { }); } - await getService().reorderSteps(taskId, stepOrderings); - - res.json({ - success: true, - message: 'Step rækkefølge opdateret succesfuldt' - }); + const result = await getService().reorderSteps(taskId, stepOrderings, expectedVersion); + res.json({ success: true, ...result, message: 'Step rækkefølge opdateret succesfuldt' }); } catch (error) { - console.error('Error reordering steps:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved opdatering af step rækkefølge: ' + error.message - }); + return sendChildMutationError(res, error, 'Fejl ved opdatering af step rækkefølge'); } }); // Tilføj smart pakke til projekt -router.post('/:id/add-to-project', async (req, res) => { +router.post('/:id/add-to-project', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; const { projectId, multiplier, geometry, userId } = req.body; @@ -1614,13 +1584,13 @@ router.post('/:id/add-to-project', async (req, res) => { console.error('Error adding package to project:', error); res.status(500).json({ success: false, - error: 'Fejl ved tilføjelse af smart pakke til projekt: ' + error.message + error: 'Fejl ved tilføjelse af smart pakke til projekt' }); } }); // Generer arbejdsbeskrivelse for en pakke -router.get('/:id/work-description', async (req, res) => { +router.get('/:id/work-description', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; @@ -1640,7 +1610,7 @@ router.get('/:id/work-description', async (req, res) => { }); // Få relaterede pakker baseret på type, kategori, etc. -router.get('/:id/related', async (req, res) => { +router.get('/:id/related', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; const { limit } = req.query; @@ -1668,7 +1638,7 @@ router.get('/:id/related', async (req, res) => { // ======================================== // Opdater komponent -router.put('/components/:id', async (req, res) => { +router.put('/components/:id', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; const { name, description, category, base_hours, base_price, unit, complexity_factors } = req.body; @@ -1699,13 +1669,13 @@ router.put('/components/:id', async (req, res) => { console.error('Error updating component:', error); res.status(500).json({ success: false, - error: 'Fejl ved opdatering af komponent: ' + error.message + error: 'Fejl ved opdatering af komponent' }); } }); // Slet komponent -router.delete('/components/:id', async (req, res) => { +router.delete('/components/:id', verifyToken, requireConfiguredOperator, async (req, res) => { try { const { id } = req.params; @@ -1720,7 +1690,7 @@ router.delete('/components/:id', async (req, res) => { console.error('Error deleting component:', error); res.status(500).json({ success: false, - error: 'Fejl ved sletning af komponent: ' + error.message + error: 'Fejl ved sletning af komponent' }); } }); @@ -1730,7 +1700,7 @@ router.delete('/components/:id', async (req, res) => { // ======================================== // Opret Kran arbejde smartpakke (Task 7) -router.post('/create-kran-arbejde', async (req, res) => { +router.post('/create-kran-arbejde', verifyToken, requireConfiguredOperator, async (req, res) => { try { const kranPakke = { name: 'Kran arbejde', @@ -1789,13 +1759,13 @@ router.post('/create-kran-arbejde', async (req, res) => { console.error('Fejl ved oprettelse af Kran arbejde:', error); res.status(500).json({ success: false, - error: 'Fejl ved oprettelse af Kran arbejde smartpakke: ' + error.message + error: 'Fejl ved oprettelse af Kran arbejde smartpakke' }); } }); // Opret Byggepladshegn smartpakke (Task 8) -router.post('/create-byggepladshegn', async (req, res) => { +router.post('/create-byggepladshegn', verifyToken, requireConfiguredOperator, async (req, res) => { try { const hegnPakke = { name: 'Byggepladshegn med leje', @@ -1851,26 +1821,7 @@ router.post('/create-byggepladshegn', async (req, res) => { console.error('Fejl ved oprettelse af Byggepladshegn:', error); res.status(500).json({ success: false, - error: 'Fejl ved oprettelse af Byggepladshegn smartpakke: ' + error.message - }); - } -}); - -// Hent alle custom smartpakkker (inkl. Kran arbejde og Byggepladshegn) -router.get('/custom-packages', async (req, res) => { - try { - const packages = await getService().getPackagesByCategory(['Kranleje', 'Sikkerhed']); - - res.json({ - success: true, - packages, - message: `${packages.length} custom smartpakker fundet` - }); - } catch (error) { - console.error('Fejl ved hentning af custom packages:', error); - res.status(500).json({ - success: false, - error: 'Fejl ved hentning af custom packages' + error: 'Fejl ved oprettelse af Byggepladshegn smartpakke' }); } }); @@ -1879,7 +1830,7 @@ router.get('/custom-packages', async (req, res) => { const { asyncHandler } = require('../middleware/errorHandler'); const logger = require('../utils/logger'); -router.post('/recalculate', asyncHandler(async (req, res) => { +router.post('/recalculate', verifyToken, requireConfiguredOperator, asyncHandler(async (req, res) => { const correlationId = req.correlationId; const { projectId, geometry } = req.body || {}; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..abaf6bc --- /dev/null +++ b/backend/src/routes/users.js @@ -0,0 +1,94 @@ +const express = require('express'); +const Joi = require('joi'); +const router = express.Router(); +const userService = require('../services/userService'); +const logger = require('../utils/logger'); +const { verifyToken, requireAdmin } = require('../middleware/auth'); + +router.use(verifyToken, requireAdmin); + +const createSchema = Joi.object({ + username: Joi.string().min(3).max(100).required(), + password: Joi.string().min(6).required(), + role: Joi.string().valid('admin', 'user').default('user') +}); + +const updateSchema = Joi.object({ + username: Joi.string().min(3).max(100), + password: Joi.string().min(6), + role: Joi.string().valid('admin', 'user') +}).min(1); + +router.get('/', async (req, res) => { + try { + const users = await userService.listUsers(); + res.json({ success: true, users }); + } catch (error) { + logger.error('Failed to list users:', error); + res.status(500).json({ success: false, error: 'Kunne ikke hente brugere' }); + } +}); + +router.post('/', async (req, res) => { + try { + const { error, value } = createSchema.validate(req.body); + if (error) { + return res.status(400).json({ success: false, error: error.details[0].message }); + } + + const existing = await userService.findByUsername(value.username); + if (existing) { + return res.status(409).json({ success: false, error: 'Brugernavn er allerede i brug' }); + } + + const user = await userService.createUser(value); + logger.info('User created', { username: user.username, role: user.role, createdBy: req.user.username }); + res.status(201).json({ success: true, user }); + } catch (error) { + logger.error('Failed to create user:', error); + res.status(500).json({ success: false, error: 'Kunne ikke oprette bruger' }); + } +}); + +router.patch('/:id', async (req, res) => { + try { + const { error, value } = updateSchema.validate(req.body); + if (error) { + return res.status(400).json({ success: false, error: error.details[0].message }); + } + + const id = Number(req.params.id); + + const user = await userService.updateUser(id, value); + if (!user) { + return res.status(404).json({ success: false, error: 'Bruger ikke fundet' }); + } + + logger.info('User updated', { id, updatedBy: req.user.username }); + res.json({ success: true, user }); + } catch (error) { + if (error.code === 'LAST_ADMIN') return res.status(400).json({ success: false, error: error.message }); + logger.error('Failed to update user:', error); + res.status(500).json({ success: false, error: 'Kunne ikke opdatere bruger' }); + } +}); + +router.delete('/:id', async (req, res) => { + try { + const id = Number(req.params.id); + + if (req.user.id === id) { + return res.status(400).json({ success: false, error: 'Kan ikke slette din egen bruger' }); + } + + await userService.deleteUser(id); + logger.info('User deleted', { id, deletedBy: req.user.username }); + res.json({ success: true }); + } catch (error) { + if (error.code === 'LAST_ADMIN') return res.status(400).json({ success: false, error: error.message }); + logger.error('Failed to delete user:', error); + res.status(500).json({ success: false, error: 'Kunne ikke slette bruger' }); + } +}); + +module.exports = router; diff --git a/backend/src/services/__tests__/ordrestyringOfferNormalizationService.test.js b/backend/src/services/__tests__/ordrestyringOfferNormalizationService.test.js new file mode 100644 index 0000000..ce4a25d --- /dev/null +++ b/backend/src/services/__tests__/ordrestyringOfferNormalizationService.test.js @@ -0,0 +1,124 @@ +const { + buildOrdrestyringOfferLines, + OfferNormalizationError +} = require('../ordrestyringOfferNormalizationService'); + +const lineTotalInCents = line => Math.round(Number(line.quantity) * Number(line.salesPrice) * 100); + +describe('ordrestyringOfferNormalizationService', () => { + test('builds every canonical cost category and reconciles exactly to total excluding VAT with native 25% VAT', () => { + const snapshot = { + materials: [{ name: 'Tagsten', quantity: 3, unit: 'stk', unitPrice: 3.33, total: 10 }], + labor: [{ description: 'Montage', hours: 2, unit: 'timer', hourlyRate: 50, total: 100 }], + rentals: [{ name: 'Stillads', quantity: 1, unit: 'uge', unitPrice: 20, total: 20 }], + referenceServices: [{ name: 'Affald', quantity: 2, unit: 'læs', unitPrice: 5, total: 10 }], + totals: { + materialTotal: 10, + laborTotal: 100, + rentalTotal: 20, + referenceTotal: 10, + subtotal: 140, + overheadAmount: 21, + profitAmount: 32.2, + totalExclVat: 193.2, + vatAmount: 48.3, + totalInclVat: 241.5 + } + }; + + const lines = buildOrdrestyringOfferLines(snapshot, { offerId: 88 }); + + expect(lines.map(line => line.productNumber)).toEqual([ + 'MATERIAL', + 'LABOR', + 'RENTAL', + 'REFERENCE_SERVICE', + 'OVERHEAD', + 'PROFIT' + ]); + expect(lines.map(line => line.unit)).toEqual(['stk', 'timer', 'uge', 'læs', 'sum', 'sum']); + expect(lines.every(line => line.offerId === 88)).toBe(true); + expect(lines.reduce((sum, line) => sum + lineTotalInCents(line), 0)).toBe(19320); + expect(lines.find(line => line.productNumber === 'MATERIAL')).toMatchObject({ + quantity: 3, + salesPrice: 10 / 3 + }); + }); + + test('preserves an explicit zero quantity instead of converting it to one', () => { + const snapshot = { + materials: [{ name: 'Ikke anvendt', quantity: 0, unit: 'stk', unitPrice: 99, total: 0 }], + labor: [], + rentals: [], + referenceServices: [], + totals: { + materialTotal: 0, + laborTotal: 0, + rentalTotal: 0, + referenceTotal: 0, + subtotal: 0, + overheadAmount: 0, + profitAmount: 0, + totalExclVat: 0, + vatAmount: 0, + totalInclVat: 0 + } + }; + + const lines = buildOrdrestyringOfferLines(snapshot, { offerId: 9 }); + + expect(lines[0]).toMatchObject({ quantity: 0, unit: 'stk', salesPrice: 99 }); + }); + + test('rejects a canonical snapshot whose category or grand totals do not reconcile', () => { + const snapshot = { + materials: [{ name: 'Tagsten', quantity: 1, unit: 'stk', unitPrice: 10, total: 10 }], + totals: { + materialTotal: 11, + laborTotal: 0, + rentalTotal: 0, + referenceTotal: 0, + subtotal: 11, + overheadAmount: 0, + profitAmount: 0, + totalExclVat: 11, + vatAmount: 0, + totalInclVat: 11 + } + }; + + expect(() => buildOrdrestyringOfferLines(snapshot, { offerId: 1 })) + .toThrow(OfferNormalizationError); + }); +}); + +const economicsSnapshot = (material, net = 10, vat = 2.5) => ({ + materials: [material], + totals: { materialTotal: net, laborTotal: 0, rentalTotal: 0, referenceTotal: 0, + subtotal: net, overheadAmount: 0, profitAmount: 0, totalExclVat: net, + vatAmount: vat, totalInclVat: net + vat } +}); + +test('rejects VAT that cannot reconcile with native 25% VAT', () => { + expect(() => buildOrdrestyringOfferLines(economicsSnapshot({ quantity: 1, total: 10 }, 10, 1), { offerId: 1 })) + .toThrow(/VAT|moms/i); +}); + +test('does not apply an already included discount twice', () => { + const lines = buildOrdrestyringOfferLines(economicsSnapshot({ quantity: 2, unitPrice: 10, discount: 50, total: 10 }), { offerId: 1 }); + expect(lines[0].quantity * lines[0].salesPrice * (1 - lines[0].discount / 100)).toBe(10); +}); + +test('integer-safe line economics catches sub-cent unit price drift at large quantities', () => { + const { lineNetCents } = require('../ordrestyringOfferNormalizationService'); + expect(lineNetCents({ quantity: '1000000', salesPrice: '0.010001', discount: '0' })).toBe(1000100); + expect(lineNetCents({ quantity: '3', salesPrice: '0.335', discount: '0' })).toBe(101); + expect(() => lineNetCents({ quantity: '9007199254740991', salesPrice: '100', discount: 0 })).toThrow(); +}); + +test('rounds native Money to hundredths of a cent using decimal precision', () => { + const { nativeMoney } = require('../ordrestyringOfferNormalizationService'); + expect(nativeMoney('0.00015')).toBe(0.02); + expect(nativeMoney('-0.00015')).toBe(-0.02); + expect(nativeMoney('500')).toBe(50000); +}); diff --git a/backend/src/services/__tests__/ordrestyringOfferOperationStateStore.test.js b/backend/src/services/__tests__/ordrestyringOfferOperationStateStore.test.js new file mode 100644 index 0000000..de05dfd --- /dev/null +++ b/backend/src/services/__tests__/ordrestyringOfferOperationStateStore.test.js @@ -0,0 +1,99 @@ +const OrdrestyringOfferOperationStateStore = require('../ordrestyringOfferOperationStateStore'); + +const initialState = { + idempotencyKey: '7:sig-1', + projectId: 7, + snapshotSignature: 'sig-1', + status: 'started', + createdLines: [] +}; + +describe('OrdrestyringOfferOperationStateStore', () => { + test('fails closed without a database executor and never uses memory fallback', async () => { + const store = new OrdrestyringOfferOperationStateStore({}); + + await expect(store.get('7:sig-1')).rejects.toMatchObject({ + code: 'ORDRESTYRING_OPERATION_STORE_UNAVAILABLE' + }); + await expect(store.claim('7:sig-1', initialState, { ownerId: 'router-a' })).rejects.toMatchObject({ + code: 'ORDRESTYRING_OPERATION_STORE_UNAVAILABLE' + }); + }); + + test('claims a new project and signature with one atomic insert', async () => { + const execute = jest.fn() + .mockResolvedValueOnce([{ affectedRows: 1 }]) + .mockResolvedValueOnce([[{ + state_json: JSON.stringify(initialState), lease_owner: 'router-a', + lease_expires_at: new Date(Date.now() + 30_000) + }]]); + const store = new OrdrestyringOfferOperationStateStore({ pool: { execute } }); + + const claim = await store.claim('7:sig-1', initialState, { ownerId: 'router-a', leaseMs: 30_000 }); + + expect(claim).toMatchObject({ acquired: true, state: initialState }); + expect(execute.mock.calls[0][0]).toContain('INSERT INTO ordrestyring_offer_operations'); + expect(execute.mock.calls[0][0]).not.toContain('IGNORE'); + expect(execute.mock.calls[0][0]).not.toContain('ON DUPLICATE KEY UPDATE'); + }); + + test('does not acquire an operation while another process owns its live lease', async () => { + const executing = { ...initialState, status: 'customer_creating' }; + const execute = jest.fn() + .mockRejectedValueOnce(Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY' })) + .mockResolvedValueOnce([{ affectedRows: 0 }]) + .mockResolvedValueOnce([[{ + state_json: JSON.stringify(executing), lease_owner: 'router-a', + lease_expires_at: new Date(Date.now() + 30_000) + }]]); + const store = new OrdrestyringOfferOperationStateStore({ pool: { execute } }); + + const claim = await store.claim('7:sig-1', initialState, { ownerId: 'router-b', leaseMs: 30_000 }); + + expect(claim).toMatchObject({ acquired: false, state: executing }); + expect(execute.mock.calls[1][0]).toContain('lease_expires_at <= CURRENT_TIMESTAMP(6)'); + }); + + test('rejects state writes by a process that no longer owns the lease', async () => { + const execute = jest.fn().mockResolvedValue([{ affectedRows: 0 }]); + const store = new OrdrestyringOfferOperationStateStore({ pool: { execute } }); + + await expect(store.set('7:sig-1', initialState, { ownerId: 'router-b' })).rejects.toMatchObject({ + code: 'ORDRESTYRING_OPERATION_LEASE_LOST' + }); + expect(execute.mock.calls[0][0]).toContain('AND lease_owner = ?'); + }); + + test.each(['ER_NO_SUCH_TABLE', 'ECONNREFUSED'])( + 'surfaces %s as store unavailable instead of falling back', + async databaseCode => { + const execute = jest.fn().mockRejectedValue(Object.assign(new Error('db failed'), { code: databaseCode })); + const store = new OrdrestyringOfferOperationStateStore({ pool: { execute } }); + + await expect(store.get('7:sig-1')).rejects.toMatchObject({ + code: 'ORDRESTYRING_OPERATION_STORE_UNAVAILABLE' + }); + } + ); +}); + +test('renewal is owner and expiry guarded and fails closed after lease loss', async () => { + const execute = jest.fn().mockResolvedValueOnce([{ affectedRows: 1 }]).mockResolvedValueOnce([{ affectedRows: 0 }]); + const store = new OrdrestyringOfferOperationStateStore({ pool: { execute } }); + await store.renew('7:sig-1', { ownerId: 'a', leaseMs: 50 }); + expect(execute.mock.calls[0][0]).toContain('lease_owner = ?'); + expect(execute.mock.calls[0][0]).toContain('lease_expires_at > CURRENT_TIMESTAMP(6)'); + await expect(store.renew('7:sig-1', { ownerId: 'a' })).rejects.toMatchObject({ code: 'ORDRESTYRING_OPERATION_LEASE_LOST' }); +}); + +test('expired owners cannot write or permit takeover of an in-flight mutation', async () => { + const execute = jest.fn().mockResolvedValueOnce([{ affectedRows: 1 }]); + const store = new OrdrestyringOfferOperationStateStore({ pool: { execute } }); + await store.set('7:sig-1', initialState, { ownerId: 'a' }); + expect(execute.mock.calls[0][0]).toContain('lease_expires_at > CURRENT_TIMESTAMP(6)'); + execute.mockRejectedValueOnce(Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY' })) + .mockResolvedValueOnce([{ affectedRows: 0 }]).mockResolvedValueOnce([[{ state_json: initialState }]]); + await store.claim('7:sig-1', initialState, { ownerId: 'b' }); + expect(execute.mock.calls[2][0]).toContain("'lines_creating'"); + expect(execute.mock.calls[2][0]).toContain("'compensation_pending'"); +}); diff --git a/backend/src/services/advancedGeometryService.js b/backend/src/services/advancedGeometryService.js index 6f43459..8acc2b3 100644 --- a/backend/src/services/advancedGeometryService.js +++ b/backend/src/services/advancedGeometryService.js @@ -1,4 +1,30 @@ const logger = require('../utils/logger'); +const { calculateRoofReplacementGeometry } = require('../domain/roofReplacementGeometry'); +const { requireRoofTypeEstimate } = require('../domain/roofTypeContract'); + +const ROOF_TYPE_GEOMETRY = Object.freeze({ + gable: 'gable', + sadeltag: 'gable', + skraat_tag: 'gable', + tag_med_kviste: 'gable', + betontegl: 'gable', + b7: 'gable', + b6: 'gable', + vingetegl: 'gable', + røde_teglsten: 'gable', + pult: 'pult', + pulttag: 'pult', + flat: 'flat', + fladt_tag: 'flat', + fladtag: 'flat', + hip: 'hip', + valmtag: 'hip', + mansard: 'mansard', + mansardtag: 'mansard', + koebenhavnertag: 'mansard' +}); + +const round2 = value => Math.round(value * 100) / 100; /** * AdvancedGeometryService - Avancerede geometriberegninger for tagarbejde @@ -15,37 +41,47 @@ class AdvancedGeometryService { async calculateAdvancedGeometry(geometryInput) { try { const { - roofType = 'betontegl', // Default tagtype + roofType, width, length, - roofLength = length, - roofWidth = width, - roofPitch = 30, - pitch = roofPitch, + roofLength, + roofWidth, + roofPitch, + pitch, ridgeHeight = null, // Højde fra stern til kip (lodret mål) wallHeight = 0, // Væghøjde til tagkant hasComplexFeatures = false } = geometryInput; - // Normalisér parametre - const normalizedLength = roofLength || length; - const normalizedWidth = roofWidth || width; - const normalizedPitch = pitch || roofPitch; + const normalizedLength = roofLength ?? length; + const normalizedWidth = roofWidth ?? width; + const normalizedPitch = pitch ?? roofPitch; + const canonicalRoofType = ROOF_TYPE_GEOMETRY[roofType?.toLowerCase()]; + if (!canonicalRoofType) throw new Error(`Unsupported roof type: ${roofType}`); - // Basis areal beregning - const baseArea = normalizedLength * normalizedWidth; - - // Beregn forskellige højder og længder - const heightCalculations = this.calculateHeights( - normalizedWidth, normalizedLength, normalizedPitch, ridgeHeight, wallHeight + const roofGeometry = calculateRoofReplacementGeometry({ + roofType: canonicalRoofType, + length: normalizedLength, + width: normalizedWidth, + pitch: normalizedPitch, + eaveOverhang: geometryInput.eaveOverhang, + gableOverhang: geometryInput.gableOverhang, + falls: geometryInput.falls, + drainCount: geometryInput.drainCount, + lowerRun: geometryInput.lowerRun, + lowerPitch: geometryInput.lowerPitch, + upperRun: geometryInput.upperRun, + upperPitch: geometryInput.upperPitch + }); + const baseArea = roofGeometry.area.plan; + const heightCalculations = this.toHeightCalculationAliases( + roofGeometry, normalizedLength, normalizedWidth, normalizedPitch, wallHeight, ridgeHeight + ); + const windboardCalculations = this.toWindboardAliases( + roofGeometry, roofType, heightCalculations ); - // Beregn vindskedelængder baseret på geometri - const windboardCalculations = this.calculateWindboards( - normalizedLength, normalizedWidth, roofType, heightCalculations - ); - - // Beregn materialekvantiteter baseret på geometri + // Beregn materialekvantiteter baseret på den kanoniske tagflade. const materialQuantities = this.calculateMaterialQuantities( baseArea, heightCalculations, windboardCalculations, roofType ); @@ -56,19 +92,20 @@ class AdvancedGeometryService { ); const result = { + roofGeometry, basicDimensions: { length: normalizedLength, width: normalizedWidth, - baseArea: Math.round(baseArea * 100) / 100, + baseArea: round2(baseArea), roofType: roofType, - pitch: normalizedPitch + pitch: normalizedPitch ?? null }, heightCalculations, windboardCalculations, materialQuantities, svgIllustration, - complexity: this.calculateComplexityFactor(geometryInput), - estimatedWorkHours: this.estimateWorkHours(baseArea, roofType, hasComplexFeatures), + complexity: this.calculateComplexityFactor({ ...geometryInput, roofType: canonicalRoofType }), + estimatedWorkHours: this.estimateWorkHours(baseArea, canonicalRoofType, hasComplexFeatures), recommendations: this.generateRecommendations(baseArea, roofType, heightCalculations) }; @@ -85,6 +122,87 @@ class AdvancedGeometryService { } } + /** + * UI-compatible height aliases derived only from canonical geometry. + */ + toHeightCalculationAliases(roofGeometry, roofLength, roofWidth, pitch, wallHeight, suppliedRidgeHeight) { + if (suppliedRidgeHeight !== null && suppliedRidgeHeight !== undefined) { + if (!Number.isFinite(suppliedRidgeHeight) || suppliedRidgeHeight < 0) { + throw new TypeError('ridgeHeight must be a non-negative finite number'); + } + if (Math.abs(suppliedRidgeHeight - roofGeometry.rise) > roofGeometry.provenance.tolerances.linear) { + throw new RangeError('ridgeHeight contradicts the canonical pitch geometry'); + } + } + + const effectiveLength = roofGeometry.provenance.effectiveDimensions.length; + let rafterLength = roofGeometry.lengths.rafter; + if (rafterLength === undefined && roofGeometry.roofType === 'flat') { + rafterLength = roofGeometry.area.roofSurface / effectiveLength; + } else if (rafterLength === undefined) { + rafterLength = roofGeometry.area.roofSurface / (2 * effectiveLength); + } + + const run = roofGeometry.lengths.horizontalRun + ?? (roofGeometry.roofType === 'flat' + ? roofGeometry.provenance.effectiveDimensions.width + : roofWidth / 2); + + return { + ridgeHeight: round2(roofGeometry.rise), + totalRidgeHeight: round2(wallHeight + roofGeometry.rise), + eaveHeight: round2(wallHeight), + rafterLength: round2(rafterLength), + slopeArea: round2(roofGeometry.area.roofSurface), + pitchAngle: pitch ?? null, + measurements: { + wallHeight, + roofSpan: roofWidth, + roofRun: roofLength, + rise: roofGeometry.rise, + run + } + }; + } + + /** + * UI-compatible edge aliases derived only from canonical geometry. + */ + toWindboardAliases(roofGeometry, roofType, heightCalculations) { + const { lengths } = roofGeometry; + const vergeTotal = lengths.verges ?? lengths.hips?.total ?? 0; + const explicitEaves = lengths.eaves + ?? ((lengths.lowJunction ?? 0) + (lengths.highJunction ?? 0)); + const eavesTotal = explicitEaves || lengths.perimeter || 0; + const ridgeLength = lengths.ridge; + const additionalJunctions = lengths.mansardBreaks ?? 0; + const totalLength = vergeTotal + eavesTotal + ridgeLength + additionalJunctions; + + return { + gableBoards: { + lengthPerGable: round2(vergeTotal / 2), + totalLength: round2(vergeTotal), + description: 'Vindskeder langs gavle/valmkanter' + }, + eavesBoards: { + lengthPerSide: round2(eavesTotal / 2), + totalLength: round2(eavesTotal), + description: 'Vindskeder langs tagkanterne' + }, + ridgeBoard: { + length: round2(ridgeLength), + description: 'Vindskede langs tagryggen' + }, + totalLength: round2(totalLength), + recommendedDimensions: this.getRecommendedWindboardDimensions(roofType), + cuttingAngles: { + gableBoardAngle: heightCalculations.pitchAngle, + eavesBoardAngle: 0, + ridgeBoardAngle: 0 + } + }; + } + /** * Beregn forskellige højder på taget */ @@ -425,23 +543,9 @@ class AdvancedGeometryService { * Beregn kompleksitetsfaktor */ calculateComplexityFactor(geometryInput) { - let complexity = 1.0; + let complexity = requireRoofTypeEstimate(geometryInput.roofType).baseComplexity; const { roofType, roofPitch, hasComplexFeatures } = geometryInput; - // Tagtype påvirkning - switch (roofType?.toLowerCase()) { - case 'fladt_tag': - case 'fladtag': complexity *= 0.8; break; - case 'sadeltag': - case 'skraat_tag': complexity *= 1.0; break; - case 'valmtag': complexity *= 1.2; break; - case 'koebenhavnertag': complexity *= 1.3; break; - case 'pulttag': complexity *= 0.9; break; - case 'tag_med_kviste': complexity *= 1.4; break; - case 'mansard': - case 'mansardtag': complexity *= 1.4; break; - } - // Hældning påvirkning if (roofPitch > 45) complexity *= 1.3; else if (roofPitch > 35) complexity *= 1.1; @@ -457,26 +561,7 @@ class AdvancedGeometryService { * Estimer arbejdstimer */ estimateWorkHours(area, roofType, hasComplexFeatures) { - let hoursPerSqm = 0.8; // Basis timer per m² - - // Juster baseret på tagtype - switch (roofType?.toLowerCase()) { - case 'betontegl': hoursPerSqm = 1.2; break; - case 'b7': hoursPerSqm = 0.9; break; - case 'b6': hoursPerSqm = 1.0; break; - case 'vingetegl': hoursPerSqm = 1.4; break; - case 'røde_teglsten': hoursPerSqm = 1.5; break; - case 'fladt_tag': - case 'fladtag': hoursPerSqm = 0.7; break; - case 'sadeltag': - case 'skraat_tag': hoursPerSqm = 1.0; break; - case 'valmtag': hoursPerSqm = 1.25; break; - case 'koebenhavnertag': hoursPerSqm = 0.9; break; - case 'pulttag': hoursPerSqm = 0.85; break; - case 'tag_med_kviste': hoursPerSqm = 1.65; break; - case 'mansard': - case 'mansardtag': hoursPerSqm = 1.8; break; - } + let hoursPerSqm = requireRoofTypeEstimate(roofType).baseHoursPerM2; if (hasComplexFeatures) hoursPerSqm *= 1.3; diff --git a/backend/src/services/authAccountSchema.js b/backend/src/services/authAccountSchema.js new file mode 100644 index 0000000..8f8c148 --- /dev/null +++ b/backend/src/services/authAccountSchema.js @@ -0,0 +1,48 @@ +// Schema only: account provisioning is an explicit administrative operation. +async function ensureAuthAccountsTable(connection) { + await connection.execute(` + CREATE TABLE IF NOT EXISTS auth_accounts ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role ENUM('admin', 'user') NOT NULL DEFAULT 'user', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + last_login_at TIMESTAMP NULL + ) ENGINE=InnoDB + `); + + const [tables] = await connection.execute(` + SELECT ENGINE + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'auth_accounts' + `); + if (String(tables[0]?.ENGINE || '').toUpperCase() !== 'INNODB') { + await connection.execute('ALTER TABLE auth_accounts ENGINE=InnoDB'); + } +} + +async function provisionInitialAdmin(connection, { env = process.env, hashPassword } = {}) { + const [rows] = await connection.execute('SELECT COUNT(*) AS account_count FROM auth_accounts'); + if (Number(rows[0]?.account_count || 0) > 0) return false; + + const username = String(env.AUTH_USERNAME || '').trim(); + const password = String(env.AUTH_PASSWORD || ''); + if (!username || !password) { + throw Object.assign(new Error('Initial administrator credentials are required'), { + code: 'INITIAL_ADMIN_REQUIRED' + }); + } + + const hash = hashPassword || require('bcryptjs').hash; + const passwordHash = await hash(password, 12); + const [result] = await connection.execute( + `INSERT IGNORE INTO auth_accounts (username, password_hash, role) + VALUES (?, ?, ?)`, + [username, passwordHash, 'admin'] + ); + return Number(result.affectedRows || 0) === 1; +} + +module.exports = { ensureAuthAccountsTable, provisionInitialAdmin }; diff --git a/backend/src/services/customerDocumentLanguage.js b/backend/src/services/customerDocumentLanguage.js new file mode 100644 index 0000000..31fcaba --- /dev/null +++ b/backend/src/services/customerDocumentLanguage.js @@ -0,0 +1,51 @@ +'use strict'; + +// Customer documents are assembled from allowlisted fields, but those fields +// still contain persisted operator/customer copy. Reject internal audit terms +// rather than silently rewriting signed text. +const INTERNAL_CUSTOMER_LANGUAGE = [ + /\bsmart[\s_-]*pakke\b/iu, + /\b(?:lego|klods(?:er)?)\b/iu, + /\b(?:kilde[\s_-]*pakke|source[\s_-]*package)\b/iu, + /\b(?:pakke|package)[\s_-]*(?:id|version)\b/iu, + /\b(?:pris[\s_-]*version|price[\s_-]*version)\b/iu, + /\b(?:mængde[\s_-]*(?:grundlag|audit)|quantity[\s_-]*(?:basis|audit))\b/iu, + /\b(?:beregnings[\s_-]*formel|calculation[\s_-]*formula|formula)\b/iu, + /\b(?:signatur|signature)\b/iu, + /\b(?:godkendelse|godkendt\s+af|approver|approval|approved[\s_-]*(?:by|at))\b/iu, + /\bcanonical(?:[\s_-]|\b)/iu, + /\bmanual[\s_-]*override\b/iu, + /\b(?:quantity[\s_-]*mode|mode|modes)\b/iu, + /\b(?:overhead|dækningsbidrag)\b/iu, + /\broof[\s_-]*area\b/iu, + /\bschema\b/iu, + /\bversion\s*(?=[:#=]|\d)/iu +]; + +function normalizeCustomerText(value) { + return String(value ?? '') + .normalize('NFKC') + .replace(/<[^>]*>/g, ' ') + .replace(/&(?:#\d+|#x[\da-f]+|[a-z]+);/giu, ' ') + .replace(/[\s_-]+/g, ' '); +} + +function containsInternalCustomerLanguage(value) { + const normalized = normalizeCustomerText(value); + return INTERNAL_CUSTOMER_LANGUAGE.some(pattern => pattern.test(normalized)); +} + +function assertCustomerDocumentLanguage(values) { + if (values.some(value => typeof value === 'object' || containsInternalCustomerLanguage(value))) { + throw Object.assign(new Error('Customer text contains internal workflow language'), { + status: 422, + code: 'INTERNAL_PDF_LANGUAGE' + }); + } +} + +module.exports = { + assertCustomerDocumentLanguage, + containsInternalCustomerLanguage, + normalizeCustomerText +}; diff --git a/backend/src/services/databaseService.js b/backend/src/services/databaseService.js index 3132638..6797cba 100644 --- a/backend/src/services/databaseService.js +++ b/backend/src/services/databaseService.js @@ -2,6 +2,8 @@ const mysql = require('mysql2/promise'); const logger = require('../utils/logger'); const { ALL_PROJECT_STATUSES } = require('../constants/projectStatuses'); +const ROOF_GEOMETRY_TYPES = ['gable', 'pult', 'flat', 'fladt_tag', 'skraat_tag', 'mansard', 'hip']; + class DatabaseService { constructor() { this.pool = null; @@ -33,7 +35,39 @@ class DatabaseService { ); if (columns.length === 0) { - await this.pool.query(alterSql); + const [tables] = await this.pool.query( + `SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ?`, + [tableName] + ); + if (tables.length > 0) await this.pool.query(alterSql); + } + } + + async ensureTableIndex(tableName, indexName, createSql, { unique = false } = {}) { + const [indexes] = await this.pool.query( + `SELECT INDEX_NAME, NON_UNIQUE + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND INDEX_NAME = ?`, + [tableName, indexName] + ); + if (indexes.length === 0) { + const [tables] = await this.pool.query( + `SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ?`, + [tableName] + ); + if (tables.length > 0) await this.pool.query(createSql); + return; + } + if (unique && indexes.some(index => Number(index.NON_UNIQUE) !== 0)) { + throw new Error(`Index ${indexName} on ${tableName} exists but is not unique`); } } @@ -59,6 +93,41 @@ class DatabaseService { } } + async ensureProjectMaterialsSourceEnum() { + const [columns] = await this.pool.query( + `SELECT COLUMN_TYPE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'project_materials' + AND COLUMN_NAME = 'material_source'` + ); + + const currentType = String(columns[0]?.COLUMN_TYPE || '').toLowerCase(); + if (currentType && !currentType.includes("'package'")) { + await this.pool.query(` + ALTER TABLE project_materials + MODIFY COLUMN material_source ENUM('manual', 'database', 'bygma_api', 'package') DEFAULT 'manual' + `); + } + } + + async ensureRoofGeometryTypeEnum() { + const [columns] = await this.pool.query( + `SELECT COLUMN_TYPE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'roof_geometry' + AND COLUMN_NAME = 'roof_type'` + ); + const currentType = String(columns[0]?.COLUMN_TYPE || '').toLowerCase(); + if (currentType && ROOF_GEOMETRY_TYPES.some(type => !currentType.includes(`'${type}'`))) { + await this.pool.query(` + ALTER TABLE roof_geometry + MODIFY COLUMN roof_type ENUM('gable', 'pult', 'flat', 'fladt_tag', 'skraat_tag', 'mansard', 'hip', 'komplekst') NOT NULL + `); + } + } + async initialize() { try { // SECURITY: Require database password in environment variables @@ -82,6 +151,10 @@ class DatabaseService { await connection.query('SELECT NOW()'); connection.release(); + const authAccountSchema = require('./authAccountSchema'); + await authAccountSchema.ensureAuthAccountsTable(this.pool); + await authAccountSchema.provisionInitialAdmin(this.pool); + await require('./ordrestyringOfferOperationSchema').ensureOrdrestyringOfferOperationsTable(this.pool); await this.ensureSystemSettingsTable(); await this.removeLegacySupportSecrets(); await this.syncSupportSettingsFromEnv(); @@ -859,6 +932,8 @@ class DatabaseService { ) `); + await require('./siteGeometryMigration').migrateSiteGeometry(this); + await this.ensureCustomerProjectsColumn( 'customer_number', 'ALTER TABLE customer_projects ADD COLUMN customer_number VARCHAR(50) AFTER customer_name' @@ -881,11 +956,13 @@ class DatabaseService { CREATE TABLE IF NOT EXISTS roof_geometry ( id INT AUTO_INCREMENT PRIMARY KEY, project_id INT NOT NULL, - roof_type ENUM('fladt_tag', 'skraat_tag', 'mansard', 'komplekst') NOT NULL, + roof_type ENUM('gable', 'pult', 'flat', 'fladt_tag', 'skraat_tag', 'mansard', 'hip') NOT NULL, roof_material ENUM('tegl','tagpap','eternit','betontag','staal','andet') NULL, total_area DECIMAL(10,2) NOT NULL COMMENT 'Samlet areal i m²', + roof_covering_area DECIMAL(10,2) NULL COMMENT 'Faktisk tagbeklædningsareal i m²', roof_pitch DECIMAL(5,2) COMMENT 'Taghældning i grader', roof_height DECIMAL(8,2) COMMENT 'Taghøjde i meter', + wall_height DECIMAL(8,2) NULL COMMENT 'Væghøjde i meter', complexity_factor DECIMAL(3,2) DEFAULT 1.0 COMMENT 'Kompleksitetsfaktor 1.0-2.0', length_main DECIMAL(8,2) COMMENT 'Hovedlængde i meter', @@ -898,6 +975,13 @@ class DatabaseService { estimated_work_hours DECIMAL(8,2) COMMENT 'Estimerede arbejdstimer', estimated_carpenters INT COMMENT 'Anbefalede antal tømrere', + + 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, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -928,7 +1012,43 @@ class DatabaseService { updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE, - INDEX idx_project (project_id) + UNIQUE KEY unique_project_labor (project_id) + ) + `); + + await this.pool.query(` + DELETE stale + FROM project_labor stale + JOIN project_labor newest + ON newest.project_id = stale.project_id AND newest.id > stale.id + `); + const [duplicateLaborRows] = await this.pool.query(` + SELECT project_id, COUNT(*) AS remaining_rows + FROM project_labor + GROUP BY project_id + HAVING COUNT(*) > 1 + `); + if (duplicateLaborRows.length > 0) { + throw new Error('project_labor still contains duplicate project rows after cleanup'); + } + await this.ensureTableIndex( + 'project_labor', + 'unique_project_labor', + 'CREATE UNIQUE INDEX unique_project_labor ON project_labor (project_id)', + { unique: true } + ); + + await this.pool.execute(` + 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 ) `); @@ -936,6 +1056,9 @@ class DatabaseService { CREATE TABLE IF NOT EXISTS project_materials ( id INT AUTO_INCREMENT PRIMARY KEY, project_id INT NOT NULL, + package_instance_id VARCHAR(100) NULL, + source_package_id INT NULL, + material_id INT NULL, material_name VARCHAR(255) NOT NULL, material_category VARCHAR(100), quantity DECIMAL(10,3) NOT NULL, @@ -943,21 +1066,29 @@ class DatabaseService { unit_price DECIMAL(10,2) NOT NULL, total_price DECIMAL(12,2) NOT NULL, supplier VARCHAR(255), - material_source ENUM('manual', 'database', 'bygma_api') DEFAULT 'manual', + material_source ENUM('manual', 'database', 'bygma_api', 'package') DEFAULT 'manual', + price_source VARCHAR(120) NULL, + price_source_updated_at DATETIME NULL, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE, INDEX idx_project (project_id), - INDEX idx_category (material_category) + INDEX idx_category (material_category), + INDEX idx_project_materials_package_instance (project_id, package_instance_id), + INDEX idx_project_materials_material_id (material_id) ) `); + await this.ensureProjectMaterialsSourceEnum(); + await this.pool.execute(` CREATE TABLE IF NOT EXISTS project_rentals ( id INT AUTO_INCREMENT PRIMARY KEY, project_id INT NOT NULL, + package_instance_id VARCHAR(100) NULL, + source_package_id INT NULL, rental_name VARCHAR(255) NOT NULL, rental_category VARCHAR(100), quantity DECIMAL(10,3) NOT NULL, @@ -972,10 +1103,68 @@ class DatabaseService { FOREIGN KEY (project_id) REFERENCES customer_projects(id) ON DELETE CASCADE, INDEX idx_project (project_id), - INDEX idx_category (rental_category) + INDEX idx_category (rental_category), + INDEX idx_project_rentals_package_instance (project_id, package_instance_id) ) `); + const workspaceColumns = [ + ['project_materials', 'package_instance_id', 'ALTER TABLE project_materials ADD COLUMN package_instance_id VARCHAR(100) NULL AFTER project_id'], + ['project_materials', 'source_package_id', 'ALTER TABLE project_materials ADD COLUMN source_package_id INT NULL AFTER package_instance_id'], + ['project_materials', 'material_id', 'ALTER TABLE project_materials ADD COLUMN material_id INT NULL AFTER source_package_id'], + ['project_materials', 'price_source', 'ALTER TABLE project_materials ADD COLUMN price_source VARCHAR(120) NULL AFTER material_source'], + ['project_materials', 'price_source_updated_at', 'ALTER TABLE project_materials ADD COLUMN price_source_updated_at DATETIME NULL AFTER price_source'], + ['project_rentals', 'package_instance_id', 'ALTER TABLE project_rentals ADD COLUMN package_instance_id VARCHAR(100) NULL AFTER project_id'], + ['project_rentals', 'source_package_id', 'ALTER TABLE project_rentals ADD COLUMN source_package_id INT NULL AFTER package_instance_id'], + ['material_packages', 'catalog_key', 'ALTER TABLE material_packages ADD COLUMN catalog_key VARCHAR(191) NULL AFTER id'], + ['material_packages', 'geometry_basis', 'ALTER TABLE material_packages ADD COLUMN geometry_basis VARCHAR(40) NULL AFTER unit'], + ['material_packages', 'geometry_factor', 'ALTER TABLE material_packages ADD COLUMN geometry_factor DECIMAL(10,3) NULL AFTER geometry_basis'], + ['material_packages', 'default_count', 'ALTER TABLE material_packages ADD COLUMN default_count DECIMAL(10,3) NULL AFTER geometry_factor'], + ['material_packages', 'default_quantity', 'ALTER TABLE material_packages ADD COLUMN default_quantity DECIMAL(10,3) NULL AFTER default_count'], + ['material_packages', 'replacement_scope', 'ALTER TABLE material_packages ADD COLUMN replacement_scope VARCHAR(40) NULL AFTER default_quantity'], + ['material_packages', 'compatible_roof_materials', 'ALTER TABLE material_packages ADD COLUMN compatible_roof_materials JSON NULL AFTER replacement_scope'], + ['material_packages', 'allowed_roof_forms', 'ALTER TABLE material_packages ADD COLUMN allowed_roof_forms JSON NULL AFTER compatible_roof_materials'], + ['material_packages', 'min_pitch_degrees', 'ALTER TABLE material_packages ADD COLUMN min_pitch_degrees DECIMAL(5,2) NULL AFTER allowed_roof_forms'], + ['material_packages', 'max_pitch_degrees', 'ALTER TABLE material_packages ADD COLUMN max_pitch_degrees DECIMAL(5,2) NULL AFTER min_pitch_degrees'], + ['material_packages', 'pitch_verification_status', "ALTER TABLE material_packages ADD COLUMN pitch_verification_status VARCHAR(30) NOT NULL DEFAULT 'unverified' AFTER max_pitch_degrees"], + ['material_packages', 'pitch_review_required', 'ALTER TABLE material_packages ADD COLUMN pitch_review_required TINYINT(1) NOT NULL DEFAULT 1 AFTER pitch_verification_status'], + ['roof_geometry', 'roof_covering_area', 'ALTER TABLE roof_geometry ADD COLUMN roof_covering_area DECIMAL(10,2) NULL AFTER total_area'], + ['roof_geometry', 'wall_height', 'ALTER TABLE roof_geometry ADD COLUMN wall_height DECIMAL(8,2) NULL AFTER roof_height'], + ['roof_geometry', 'geometry_json', 'ALTER TABLE roof_geometry ADD COLUMN geometry_json JSON NULL'], + ['roof_geometry', 'edges_json', 'ALTER TABLE roof_geometry ADD COLUMN edges_json JSON NULL'], + ['roof_geometry', 'planes_json', 'ALTER TABLE roof_geometry ADD COLUMN planes_json JSON NULL'], + ['roof_geometry', 'openings_json', 'ALTER TABLE roof_geometry ADD COLUMN openings_json JSON NULL'], + ['roof_geometry', 'provenance_json', 'ALTER TABLE roof_geometry ADD COLUMN provenance_json JSON NULL'], + ['roof_geometry', 'replacement_scope', 'ALTER TABLE roof_geometry ADD COLUMN replacement_scope VARCHAR(80) NULL'] + ]; + for (const [tableName, columnName, alterSql] of workspaceColumns) { + await this.ensureTableColumn(tableName, columnName, alterSql); + } + + await this.ensureRoofGeometryTypeEnum(); + + await this.ensureTableIndex( + 'material_packages', + 'uq_material_packages_catalog_key', + 'CREATE UNIQUE INDEX uq_material_packages_catalog_key ON material_packages (catalog_key)', + { unique: true } + ); + await this.ensureTableIndex( + 'project_materials', + 'idx_project_materials_package_instance', + 'CREATE INDEX idx_project_materials_package_instance ON project_materials (project_id, package_instance_id)' + ); + await this.ensureTableIndex( + 'project_materials', + 'idx_project_materials_material_id', + 'CREATE INDEX idx_project_materials_material_id ON project_materials (material_id)' + ); + await this.ensureTableIndex( + 'project_rentals', + 'idx_project_rentals_package_instance', + 'CREATE INDEX idx_project_rentals_package_instance ON project_rentals (project_id, package_instance_id)' + ); + await this.pool.execute(` CREATE TABLE IF NOT EXISTS project_calculations ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/backend/src/services/genericQuoteDeliveryService.js b/backend/src/services/genericQuoteDeliveryService.js new file mode 100644 index 0000000..a6310b9 --- /dev/null +++ b/backend/src/services/genericQuoteDeliveryService.js @@ -0,0 +1,104 @@ +'use strict'; + +const PdfGenerationService = require('./pdfGenerationService'); +const { assertCustomerDocumentLanguage } = require('./customerDocumentLanguage'); +const { SCHEMA, GenericQuoteSnapshotService } = require('./genericQuoteSnapshotService'); +const pick = (object, keys) => Object.fromEntries(keys.filter(key => object[key] !== undefined).map(key => [key, object[key]])); +const error = (code, message) => { throw Object.assign(new Error(message), { status: 422, code }); }; + +function customerDocument(snapshot) { + if (snapshot?.artifact?.schema !== SCHEMA || !snapshot.approved || snapshot.approval?.signature !== snapshot.signature) { + error('GENERIC_QUOTE_APPROVAL_REQUIRED', 'Approved generic snapshot required'); + } + new GenericQuoteSnapshotService().assertExpectedSignature(snapshot, snapshot.signature); + const artifact = snapshot.artifact; + const lines = Object.fromEntries(Object.entries(artifact.lines).map(([kind, rows]) => [kind, rows.map(line => + pick(line, kind === 'tasks' ? ['name', 'totalHours', 'rate', 'lineTotal'] : ['name', 'quantity', 'unit', 'unitPrice', 'lineTotal']))])); + const categoryTotal = kind => Math.round((lines[kind] || []).reduce((sum, line) => sum + Number(line.lineTotal || 0), 0) * 100) / 100; + const subtotals = { + materials: categoryTotal('materials'), + labor: categoryTotal('tasks'), + rentals: categoryTotal('rentals'), + references: categoryTotal('references') + }; + subtotals.lineTotal = Math.round(Object.values(subtotals).reduce((sum, value) => sum + value, 0) * 100) / 100; + const totals = pick(artifact.economics, ['totalExclVat', 'vatPercentage', 'vatAmount', 'totalInclVat']); + const customerPriceAdjustment = { + label: 'Aftalt pristilpasning', + explanation: 'Forskellen mellem de viste linjer og den aftalte samlede pris for projektets levering og udførelse.', + amount: Math.round((totals.totalExclVat - subtotals.lineTotal) * 100) / 100 + }; + const document = { + project: pick(artifact.customerProject, ['id', 'project_name', 'customer_number', 'customer_name', 'customer_address', 'customer_email']), + quoteText: artifact.quoteText, reservations: artifact.reservations, lines, subtotals, customerPriceAdjustment, totals + }; + // Do not silently rewrite signed customer text or expose internal labels through it. + const customerText = [document.quoteText, ...Object.values(document.project), ...document.reservations, + ...Object.values(lines).flat().map(line => line.name)]; + assertCustomerDocumentLanguage(customerText); + return document; +} + +function ordrestyringPayload(snapshot) { + const document = customerDocument(snapshot); + return { + projectId: document.project.id, customerNumber: document.project.customer_number, + description: document.quoteText, reservations: document.reservations, + lines: Object.entries(document.lines).flatMap(([type, rows]) => rows.map(line => ({ type, ...line }))), + ...document.totals, snapshotSignature: snapshot.signature, + snapshotSchema: SCHEMA, approval: snapshot.approval + }; +} + +const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' +}[char])); + +const formatMoney = value => `${Number(value || 0).toLocaleString('da-DK', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 +})} kr.`; + +class GenericQuotePdfService extends PdfGenerationService { + async generatePdfHtml(document) { + const { project, lines, totals, subtotals, customerPriceAdjustment, quoteText, reservations } = document; + assertCustomerDocumentLanguage([ + quoteText, + ...Object.values(project || {}), + ...(Array.isArray(reservations) ? reservations : []), + ...Object.values(lines || {}).flat().map(line => line?.name) + ]); + const sections = { materials: 'Materialer', tasks: 'Arbejde', rentals: 'Leje', references: 'Øvrige ydelser' }; + const rows = Object.entries(lines).map(([kind, items]) => { + if (!items.length) return ''; + const labor = kind === 'tasks'; + const heading = labor + ? 'BeskrivelseTimerTimepris ekskl. momsLinjetotal ekskl. moms' + : 'BeskrivelseAntalEnhedEnhedspris ekskl. momsLinjetotal ekskl. moms'; + const body = items.map(line => labor + ? `${escapeHtml(line.name)}${escapeHtml(line.totalHours)} timer${formatMoney(line.rate)}${formatMoney(line.lineTotal)}` + : `${escapeHtml(line.name)}${escapeHtml(line.quantity)}${escapeHtml(line.unit)}${formatMoney(line.unitPrice)}${formatMoney(line.lineTotal)}`).join(''); + return `

${sections[kind]}

${heading}${body}
`; + }).join(''); + const subtotalRows = [ + ['Materialer', subtotals.materials], ['Arbejde', subtotals.labor], ['Leje', subtotals.rentals], + ['Øvrige ydelser', subtotals.references], ['Linjer i alt', subtotals.lineTotal] + ].map(([label, value]) => `${label}${formatMoney(value)}`).join(''); + return `Tilbud – ${escapeHtml(project.project_name)} + +

Tilbud: ${escapeHtml(project.project_name)}

${escapeHtml(project.customer_name)}\n${escapeHtml(project.customer_address)}

+

${escapeHtml(quoteText)}

${rows} +

Prisoversigt

${subtotalRows} +
${escapeHtml(customerPriceAdjustment.label)}${formatMoney(customerPriceAdjustment.amount)}
+

${escapeHtml(customerPriceAdjustment.explanation)}

+ + +
Samlet pris ekskl. moms${formatMoney(totals.totalExclVat)}
Moms (${escapeHtml(totals.vatPercentage)}%)${formatMoney(totals.vatAmount)}
I alt inkl. moms${formatMoney(totals.totalInclVat)}
+ ${reservations.length ? `

Forbehold

${reservations.map(escapeHtml).join('\n')}

` : ''}`; + } +} + +module.exports = { customerDocument, ordrestyringPayload, GenericQuotePdfService }; diff --git a/backend/src/services/genericQuoteSnapshotService.js b/backend/src/services/genericQuoteSnapshotService.js new file mode 100644 index 0000000..ccac894 --- /dev/null +++ b/backend/src/services/genericQuoteSnapshotService.js @@ -0,0 +1,654 @@ +'use strict'; + +const crypto = require('crypto'); +const { stableStringify } = require('./roofQuoteSnapshotService'); +const { calculateQuoteEconomics } = require('./quoteEconomicsService'); +const { assertCustomerDocumentLanguage } = require('./customerDocumentLanguage'); +const { normalizeGeometry } = require('./siteGeometryService'); + +const SCHEMA = 'generic_quote_snapshot_v1'; +const WORKSPACE_OVERRIDE_SOURCE = 'workspace_server'; +const MAX_SOURCE_AGE_DAYS = 30; +const DAY_MS = 86400000; +const MAX_GENERIC_QUOTE_TEXT_LENGTH = 5000; +const fail = (code, message, status = 422, details = {}) => { + throw Object.assign(new Error(message), { code, status, ...details }); +}; +const positive = value => typeof value === 'number' && Number.isFinite(value) && value > 0 && value <= 1e9; +const text = value => typeof value === 'string' && value.trim().length > 0; +const money = value => Math.round(value * 100) / 100; +const sign = artifact => { + const serializedArtifact = stableStringify(artifact); + return { artifact: JSON.parse(serializedArtifact), serializedArtifact, + signature: crypto.createHash('sha256').update(serializedArtifact).digest('hex') }; +}; +const active = line => ![false, 0, '0', 'false'].includes(line.active) + && ![false, 0, '0', 'false'].includes(line.isActive ?? line.is_active); +const sameNumber = (left, right) => Math.abs(Number(left) - Number(right)) <= 0.001; +const sameSiteArea = (left, right) => typeof left === 'number' && Number.isFinite(left) + && typeof right === 'number' && Number.isFinite(right) && Math.abs(left - right) <= 0.000001; +const sameThousandth = (left, right) => Math.round(Number(left) * 1000) === Math.round(Number(right) * 1000); +const lineId = line => String(line?.sourceLineId ?? line?.source_line_id ?? line?.id ?? '').trim(); +const sourcePackageId = line => Number(line?.package_id ?? line?.sourcePackageId ?? line?.source_package_id); +const lineName = line => line?.name ?? line?.material_name ?? line?.task_name; +const sourceKind = line => line?.kind; +const packageCatalogKey = source => String(source?.catalog_key || source?.price_source_value || '').trim(); +const isBaseAreaBasis = value => ['base_area', 'ground_area'].includes(String(value || '').trim().toLowerCase()); +const storedSiteGeometry = row => { + if (!row) return null; + try { + return typeof row.geometry_json === 'string' ? JSON.parse(row.geometry_json) : row.geometry_json; + } catch (_error) { + return null; + } +}; +const validateSiteGeometryTrust = (instance, projectId, source, sourceLines, authoritativeRow) => { + const trust = instance?.siteGeometryTrust; + const embedded = instance?.geometryData?.siteGeometry; + const lines = ['materials', 'tasks', 'rentals', 'referenceServices'].flatMap(kind => instance?.[kind] || []); + const lineProvenances = line => [line?.quantityProvenance, line?.quantity_provenance] + .filter(value => value && typeof value === 'object' && !Array.isArray(value)); + const lineClaims = lines.filter(line => lineProvenances(line).some(provenance => ( + provenance.source === 'user_drawn' || provenance.geometryEngine === 'siteGeometry' + || provenance.provider === 'openstreetmap' + || provenance.siteGeometryRevision != null || provenance.siteGeometrySignature != null + || provenance.siteGeometryProjectId != null + )) || line?.siteGeometryRevision != null || line?.siteGeometrySignature != null + || line?.siteGeometryProjectId != null); + const requiresSiteGeometry = isBaseAreaBasis(source?.geometry_basis) + || sourceLines.some(line => isBaseAreaBasis(line.geometry_basis ?? line.geometry_multiplier)); + const stale = () => fail('STALE_SITE_GEOMETRY', 'Persisted site geometry provenance is stale or inconsistent', 409); + if (!trust && !embedded && lineClaims.length === 0 + && instance?.geometryData?.siteGeometryMethod === 'manual_numeric' && requiresSiteGeometry) { + const requestedArea = instance.geometryData.baseArea; + const baselineArea = instance.quantityBasis?.mode === 'direct' ? instance.quantityBasis.quantity : null; + if (!positive(requestedArea) || !positive(baselineArea)) stale(); + return { area: baselineArea, trust: null, manualArea: true, requestedArea }; + } + const authoritative = storedSiteGeometry(authoritativeRow); + if (requiresSiteGeometry || trust || embedded || lineClaims.length) { + const rowRevision = Number(authoritativeRow?.revision); + if (!authoritative || Number(authoritativeRow?.project_id) !== Number(projectId) + || !Number.isSafeInteger(rowRevision) || rowRevision < 1 + || rowRevision !== Number(authoritative.revision) + || authoritative.schema !== 'site_geometry_v1' || !text(authoritative.signature)) stale(); + let recomputedAuthoritative; + try { recomputedAuthoritative = normalizeGeometry({ geometry: authoritative.areas?.[0]?.geometry }); } + catch (_error) { stale(); } + const recalculatedArea = recomputedAuthoritative?.totals?.groundAreaM2; + const recalculatedPerimeter = recomputedAuthoritative?.totals?.perimeterM; + if (!sameSiteArea(recalculatedArea, authoritative.totals?.groundAreaM2) + || !sameSiteArea(recalculatedArea, authoritative.areas?.[0]?.groundAreaM2) + || !sameSiteArea(recalculatedPerimeter, authoritative.totals?.perimeterM) + || !sameSiteArea(recalculatedPerimeter, authoritative.areas?.[0]?.perimeterM)) stale(); + } + if (!trust && !embedded && lineClaims.length === 0) { + if (requiresSiteGeometry) stale(); + return null; + } + if (trust?.source !== 'project_site_geometry' || Number(trust.projectId) !== Number(projectId) + || embedded?.schema !== 'site_geometry_v1' + || embedded.projectId !== undefined && Number(embedded.projectId) !== Number(projectId) + || trust.revision !== embedded.revision || trust.signature !== embedded.signature + || instance.geometryData?.baseArea !== undefined && !sameSiteArea(instance.geometryData.baseArea, trust.groundAreaM2) + || instance.siteGeometryRevision !== undefined && instance.siteGeometryRevision !== trust.revision + || instance.siteGeometrySignature !== undefined && instance.siteGeometrySignature !== trust.signature + || instance.siteGeometryProjectId !== undefined && Number(instance.siteGeometryProjectId) !== Number(projectId) + || Number(authoritativeRow.revision) !== Number(trust.revision) + || authoritative.signature !== trust.signature + || stableStringify(authoritative) !== stableStringify(embedded)) stale(); + let recomputed; + try { recomputed = normalizeGeometry({ geometry: embedded.areas?.[0]?.geometry }); } + catch (_error) { stale(); } + const area = recomputed?.totals?.groundAreaM2; + if (!sameSiteArea(area, embedded.totals?.groundAreaM2) + || !sameSiteArea(area, embedded.areas?.[0]?.groundAreaM2) + || !sameSiteArea(area, trust.groundAreaM2)) stale(); + const factor = Number(source?.geometry_factor); + const expectedBasis = area * (Number.isFinite(factor) && factor > 0 ? factor : 1); + if (isBaseAreaBasis(source?.geometry_basis) + && (instance.quantityBasis?.mode !== 'direct' || !sameSiteArea(instance.quantityBasis.quantity, expectedBasis))) stale(); + for (const line of lineClaims) { + const provenances = lineProvenances(line); + const projects = [line.siteGeometryProjectId, + ...provenances.flatMap(provenance => [provenance.projectId, provenance.siteGeometryProjectId])] + .filter(value => value != null); + const revisions = [line.geometryVersion, line.siteGeometryRevision, + ...provenances.flatMap(provenance => [provenance.geometryVersion, provenance.siteGeometryRevision])] + .filter(value => value != null); + const signatures = [line.geometrySignature, line.siteGeometrySignature, + ...provenances.flatMap(provenance => [provenance.geometrySignature, provenance.siteGeometrySignature])] + .filter(value => value != null); + if (projects.some(value => Number(value) !== Number(projectId)) + || !revisions.length || revisions.some(value => value !== trust.revision) + || !signatures.length || signatures.some(value => value !== trust.signature)) stale(); + } + return { area, trust: { + source: 'project_site_geometry', projectId: Number(projectId), revision: trust.revision, + signature: trust.signature, groundAreaM2: area + } }; +}; + +const assertFreshSourceDate = (value, now, code = 'STALE_MATERIAL_PRICE') => { + const timestamp = value instanceof Date ? value.getTime() : Date.parse(value); + const current = now instanceof Date ? now.getTime() : Date.parse(now); + if (!Number.isFinite(timestamp) || !Number.isFinite(current) || timestamp > current + || current - timestamp > MAX_SOURCE_AGE_DAYS * DAY_MS) { + fail(code, `Price source date must be present, non-future and at most ${MAX_SOURCE_AGE_DAYS} days old`); + } + return new Date(timestamp).toISOString(); +}; + +const manualAudit = (line, now, serverAudit, requestedValue) => { + const provenance = line?.quantityProvenance ?? line?.quantity_provenance ?? line?.overrideProvenance ?? {}; + const reason = provenance.reason ?? line?.overrideReason ?? line?.override_reason; + const audit = serverAudit || line?.manualOverride || {}; + const author = serverAudit?.author ?? audit.author; + const timestamp = serverAudit?.timestamp ?? audit.timestamp; + const parsedTimestamp = Date.parse(timestamp); + const currentTimestamp = now instanceof Date ? now.getTime() : Date.parse(now); + const serverOwned = Boolean(serverAudit) || audit.source === WORKSPACE_OVERRIDE_SOURCE + && provenance.source === WORKSPACE_OVERRIDE_SOURCE + && audit.reason === provenance.reason + && audit.author === provenance.author + && audit.timestamp === provenance.timestamp + && sameNumber(audit.requestedValue, provenance.requestedValue) + && sameNumber(audit.requestedValue, requestedValue); + return serverOwned && text(reason) && text(author) && text(timestamp) && Number.isFinite(parsedTimestamp) + && Number.isFinite(currentTimestamp) && parsedTimestamp <= currentTimestamp + ? { + source: WORKSPACE_OVERRIDE_SOURCE, + reason: reason.trim(), + requestedValue: Number(requestedValue), + author: author.trim(), + timestamp: new Date(timestamp).toISOString() + } + : null; +}; + +const assertSixHouseBasis = (source, instance, normalizedBasis) => { + const key = packageCatalogKey(source); + const metric = key.startsWith('gutter-') + ? { field: 'gutterLength', unit: 'løbende m' } + : key.startsWith('downpipe-') ? { field: 'downpipeCount', unit: 'stk' } : null; + if (!metric) return; + const basis = instance.sixHouseBasis; + if (normalizedBasis.mode !== 'per_house_breakdown' || normalizedBasis.houses.length !== 6 + || basis?.kind !== 'six_house_gutter_downpipe' || basis?.version !== 1 || !Array.isArray(basis.houses) + || basis.houses.length !== 6 || normalizedBasis.unit !== metric.unit) { + fail('INVALID_SIX_HOUSE_BASIS', 'Gutter and downpipe packages require exactly six named houses'); + } + const ids = new Set(); const names = new Set(); + let total = 0; + basis.houses.forEach((house, index) => { + const id = String(house?.id || '').trim(); + const name = String(house?.name || '').trim(); + const value = house?.[metric.field]; + const normalizedId = id.toLocaleLowerCase('da-DK'); + const normalizedName = name.toLocaleLowerCase('da-DK'); + const quantityHouse = normalizedBasis.houses[index]; + if (!id || !name || ids.has(normalizedId) || names.has(normalizedName) || !Number.isFinite(value) + || value < 0 || metric.field === 'downpipeCount' && (!Number.isInteger(value) + || !Number.isInteger(quantityHouse.quantity) || quantityHouse.quantity !== value) + || quantityHouse.id !== id || quantityHouse.name !== name + || metric.field !== 'downpipeCount' && !sameThousandth(quantityHouse.quantity, value)) { + fail('INVALID_SIX_HOUSE_BASIS', 'Six-house identities, names and package quantities must match exactly'); + } + ids.add(normalizedId); names.add(normalizedName); total += value; + }); + const sourceTotal = basis.subtotals?.[metric.field]; + if (!positive(total) || !sameThousandth(total, sourceTotal) || !sameThousandth(total, normalizedBasis.total) + || metric.field === 'downpipeCount' && (!Number.isInteger(sourceTotal) || !Number.isInteger(normalizedBasis.total))) { + fail('INVALID_SIX_HOUSE_BASIS', 'Six-house package totals must reconcile exactly'); + } +}; + +const canonicalizeLines = ({ instance, source, sourcePackageLines, materialPrices, now, overrideAudit, siteGeometry }) => { + const authoritative = sourcePackageLines.filter(line => sourcePackageId(line) === Number(source.id)); + const submitted = new Map(); + for (const kind of ['materials', 'tasks', 'rentals', 'referenceServices']) { + for (const line of (instance[kind] || []).filter(active)) { + if (line.packageInstanceId !== undefined && line.packageInstanceId !== instance.instanceId + || line.sourcePackageId !== undefined && Number(line.sourcePackageId) !== Number(instance.sourcePackageId) + || line.sourcePackageVersion !== undefined && Number(line.sourcePackageVersion) !== Number(instance.sourcePackageVersion)) { + fail('INVALID_LINE_SOURCE', 'Line source does not match its workspace instance'); + } + const key = `${kind}:${lineId(line)}`; + if (!lineId(line) || submitted.has(key)) fail('STALE_PACKAGE_CHILDREN', 'Package child identities must be unique and current'); + submitted.set(key, line); + } + } + const authoritativeKeys = new Set(authoritative.map(line => `${sourceKind(line)}:${lineId(line)}`)); + if (authoritative.length !== submitted.size || [...submitted.keys()].some(key => !authoritativeKeys.has(key))) { + fail('STALE_PACKAGE_CHILDREN', 'Package children changed or no longer exist; reload the workspace'); + } + const result = { materials: [], tasks: [], rentals: [], references: [] }; + const sixHousePackage = /^(gutter|downpipe)-/.test(packageCatalogKey(source)); + const instanceBasisQuantity = instance.quantityBasis?.mode === 'per_house_breakdown' + ? instance.quantityBasis.total : instance.quantityBasis?.mode === 'per_house' + ? instance.quantityBasis.houseCount * instance.quantityBasis.quantityPerHouse : instance.quantityBasis?.quantity; + const primaryUsesSiteArea = isBaseAreaBasis(source.geometry_basis); + authoritative.forEach(sourceLine => { + const kind = sourceKind(sourceLine); + if (!['materials', 'tasks', 'rentals', 'referenceServices'].includes(kind)) { + fail('STALE_PACKAGE_CHILDREN', 'Unknown authoritative package child type'); + } + const workspaceLine = submitted.get(`${kind}:${lineId(sourceLine)}`); + if (!workspaceLine) fail('STALE_PACKAGE_CHILDREN', 'A current package child is missing from the workspace'); + const labor = kind === 'tasks'; + const timeUnit = sourceLine.time_unit || 'total'; + const explicitSiteArea = isBaseAreaBasis(sourceLine.geometry_basis ?? sourceLine.geometry_multiplier); + const lineScalesFromPrimary = labor + ? ['per_meter', 'per_m2', 'per_sqm', 'per_piece'].includes(timeUnit) + : Boolean(sourceLine.geometry_multiplier && sourceLine.geometry_multiplier !== 'fixed'); + const usesSiteArea = Boolean(siteGeometry && (explicitSiteArea || primaryUsesSiteArea && lineScalesFromPrimary)); + const basisQuantity = explicitSiteArea ? siteGeometry?.area : instanceBasisQuantity; + const sourceQuantity = labor + ? Number(['per_meter', 'per_m2', 'per_sqm', 'per_piece'].includes(timeUnit) + ? sourceLine.time_per_unit ?? sourceLine.hours ?? sourceLine.estimated_hours + : sourceLine.hours ?? sourceLine.estimated_hours ?? sourceLine.time_per_unit) + : Number(sourceLine.base_quantity ?? sourceLine.quantity); + const sourceUnitPrice = labor ? Number(sourceLine.rate) + : kind === 'materials' ? Number(workspaceLine.unitPrice) : Number(sourceLine.unit_price); + const submittedQuantity = labor ? workspaceLine.totalHours : workspaceLine.quantity; + const submittedPrice = labor ? workspaceLine.rate : workspaceLine.unitPrice; + if (!positive(sourceQuantity) || !positive(sourceUnitPrice) || !positive(submittedQuantity) || !positive(submittedPrice)) { + fail('INVALID_LINE', 'Authoritative package children require valid quantity and price data'); + } + let rawData = {}; + if (kind === 'materials') { + try { rawData = typeof sourceLine.excel_raw_data === 'string' ? JSON.parse(sourceLine.excel_raw_data) : sourceLine.excel_raw_data || {}; } + catch (_error) { fail('STALE_PACKAGE_CHILDREN', 'Authoritative package child conversion data is invalid'); } + } + const physicalPerPrimary = Number(rawData.physicalQuantityPerPrimary); + const piecesPerUnit = Number(rawData.piecesPerPurchaseUnit); + const scaleFromBasis = primaryBasis => { + if (labor && ['per_meter', 'per_m2', 'per_sqm', 'per_piece'].includes(timeUnit) && positive(primaryBasis)) { + return sourceQuantity * primaryBasis; + } + if (kind === 'materials' && sixHousePackage && positive(primaryBasis)) { + return positive(physicalPerPrimary) && positive(piecesPerUnit) + ? Math.ceil(primaryBasis * physicalPerPrimary / piecesPerUnit) + : sourceQuantity * primaryBasis * Number(sourceLine.waste_factor || 1); + } + if (!labor && sourceLine.geometry_multiplier && sourceLine.geometry_multiplier !== 'fixed' && positive(primaryBasis)) { + return sourceQuantity * primaryBasis * Number(sourceLine.waste_factor || 1); + } + return sourceQuantity; + }; + let quantity = scaleFromBasis(basisQuantity); + let override = null; + let effectiveBasisQuantity = basisQuantity; + if (siteGeometry?.manualArea && usesSiteArea) { + const factor = Number(source.geometry_factor); + const requestedBasisQuantity = explicitSiteArea + ? siteGeometry.requestedArea + : siteGeometry.requestedArea * (Number.isFinite(factor) && factor > 0 ? factor : 1); + const requestedQuantity = scaleFromBasis(requestedBasisQuantity); + if (!sameNumber(submittedQuantity, requestedQuantity)) { + fail('INVALID_MANUAL_OVERRIDE', 'Manual site-area lines must match the requested noncanonical area'); + } + override = manualAudit(workspaceLine, now, overrideAudit, submittedQuantity); + if (!override) fail('INVALID_MANUAL_OVERRIDE', 'Manual quantity/hour overrides require a reason and server-owned audit'); + quantity = Number(submittedQuantity); + effectiveBasisQuantity = requestedBasisQuantity; + } else if (!sameNumber(submittedQuantity, quantity)) { + override = manualAudit(workspaceLine, now, overrideAudit, submittedQuantity); + if (!override) fail('INVALID_MANUAL_OVERRIDE', 'Manual quantity/hour overrides require a reason and server-owned audit'); + quantity = Number(submittedQuantity); + } + if (labor && !sameNumber(submittedPrice, sourceUnitPrice)) { + fail('INVALID_MANUAL_OVERRIDE', 'Labor rates are server-owned and cannot be overridden'); + } + if (!labor && kind !== 'materials' && !sameNumber(submittedPrice, sourceUnitPrice)) { + fail('STALE_SOURCE_PRICE', 'Rental and reference prices are server-owned and must be current'); + } + let unitPrice = sourceUnitPrice; + let priceVersion = null; + let sourceDate = sourceLine.source_date; + let unit = sourceLine.unit; + let priceSource = sourceLine.price_source || null; + if (kind === 'materials') { + const current = materialPrices.find(row => Number(row.material_id) === Number(sourceLine.material_id)); + if (!current || !positive(current.price_version) || !positive(Number(current.current_unit_price)) + || !text(current.current_unit) || workspaceLine.priceVersion != null + && Number(workspaceLine.priceVersion) !== Number(current.price_version)) { + fail('STALE_MATERIAL_PRICE', 'Material price version must be current'); + } + sourceDate = current.source_date; + unitPrice = Number(current.current_unit_price); + priceVersion = Number(current.price_version); + unit = current.current_unit; + priceSource = current.price_source || current.supplier_name || null; + if (!sameNumber(submittedPrice, unitPrice) || workspaceLine.materialId != null + && Number(workspaceLine.materialId) !== Number(sourceLine.material_id)) { + fail('STALE_MATERIAL_PRICE', 'Workspace material price or identity is stale'); + } + } + if (!labor && workspaceLine.unit !== unit) { + fail(kind === 'materials' ? 'STALE_MATERIAL_PRICE' : 'STALE_SOURCE_PRICE', + 'Workspace pricing unit is stale'); + } + const priceSourceUpdatedAt = assertFreshSourceDate(sourceDate, now, + kind === 'materials' ? 'STALE_MATERIAL_PRICE' : 'STALE_SOURCE_PRICE'); + const geometryBasis = sixHousePackage + ? (packageCatalogKey(source).startsWith('gutter-') ? 'six_house_gutter_length' : 'six_house_downpipe_count') + : sourceLine.geometry_basis || sourceLine.geometry_multiplier + || (labor && timeUnit !== 'total' ? instance.quantityBasis.mode : 'fixed'); + const basisInherited = sixHousePackage || labor && timeUnit !== 'total' + || Boolean(sourceLine.geometry_multiplier && sourceLine.geometry_multiplier !== 'fixed'); + const physicalQuantity = positive(physicalPerPrimary) ? effectiveBasisQuantity * physicalPerPrimary : null; + const calculatedProvenance = { + source: 'authoritative_package_child', sourceLineId: lineId(sourceLine), + sourcePackageId: Number(source.id), sourcePackageVersion: Number(source.version), + ...(usesSiteArea && siteGeometry.trust ? { + siteGeometryProjectId: siteGeometry.trust.projectId, + siteGeometryRevision: siteGeometry.trust.revision, + siteGeometrySignature: siteGeometry.trust.signature + } : {}) + }; + const canonical = { + id: /^\d+$/.test(lineId(sourceLine)) ? Number(lineId(sourceLine)) : lineId(sourceLine), + name: lineName(sourceLine), packageInstanceId: instance.instanceId, + sourcePackageId: instance.sourcePackageId, sourcePackageVersion: instance.sourcePackageVersion, + ...(labor + ? { totalHours: quantity, rate: unitPrice, timeUnit, lineTotal: money(quantity * unitPrice) } + : { quantity, unit, unitPrice, priceSource, priceSourceUpdatedAt, lineTotal: money(quantity * unitPrice) }), + ...(sourceLine.material_id != null ? { materialId: Number(sourceLine.material_id), priceVersion } : {}), + geometryBasis, measuredValue: positive(effectiveBasisQuantity) ? effectiveBasisQuantity : sourceQuantity, + measuredUnit: instance.quantityBasis.unit, baseQuantity: sourceQuantity, basisInherited, + wasteFactor: Number(sourceLine.waste_factor || 1), + ...(labor ? { timeBasis: sourceQuantity } : {}), + ...(positive(physicalPerPrimary) && positive(piecesPerUnit) ? { + physicalQuantityPerPrimary: physicalPerPrimary, piecesPerPurchaseUnit: piecesPerUnit, + physicalQuantity, physicalUnit: rawData.physicalUnit || 'stk' + } : {}), + ...(rawData.componentType ? { componentType: rawData.componentType } : {}), + ...(positive(Number(rawData.lengthPerPieceMeters)) ? { lengthPerPieceMeters: Number(rawData.lengthPerPieceMeters) } : {}), + quantityMode: override ? 'manual' : 'calculated', + quantityProvenance: override ? { source: 'manual', ...override } : calculatedProvenance, + formula: positive(physicalPerPrimary) && positive(piecesPerUnit) + ? `${effectiveBasisQuantity} × ${physicalPerPrimary} = ${physicalQuantity} ${rawData.physicalUnit || 'stk'}; ${quantity} × ${piecesPerUnit} pr. indkøbsenhed` + : positive(effectiveBasisQuantity) && !sameNumber(effectiveBasisQuantity, 1) + ? `${effectiveBasisQuantity} × ${sourceQuantity} = ${quantity}` : `Fast mængde: ${quantity}`, + ...(override ? { manualOverride: override } : {}) + }; + result[kind === 'referenceServices' ? 'references' : kind].push(canonical); + }); + return result; +}; + +const canonicalizeWorkspaceInstance = ({ instance, source, sourcePackageLines, materialPrices, + now = new Date(), overrideAudit, siteGeometry = null }) => { + const quantityBasis = normalizeQuantityBasis(instance.quantityBasis); + assertSixHouseBasis(source, instance, quantityBasis); + const canonical = canonicalizeLines({ instance: { ...instance, quantityBasis }, source, + sourcePackageLines, materialPrices, now, overrideAudit, siteGeometry }); + return { ...instance, quantityBasis, materials: canonical.materials, tasks: canonical.tasks, + rentals: canonical.rentals, referenceServices: canonical.references }; +}; + +const assertFiniteTree = value => { + if (typeof value === 'number' && !Number.isFinite(value)) fail('INVALID_NUMBER', 'Signed values must be finite'); + if (value && typeof value === 'object') Object.values(value).forEach(assertFiniteTree); +}; +const roofList = value => { + if (value == null || value === '') return []; + const parsed = parseStored(value, []); + if (!Array.isArray(parsed)) fail('INVALID_SOURCE_PACKAGE', 'Invalid source compatibility data'); + return parsed; +}; +const normalizeQuantityBasis = basis => { + if (!basis || !text(basis.unit)) fail('INVALID_QUANTITY_BASIS', 'A direct or per-house quantity basis is required'); + if (basis.mode === 'direct' && positive(basis.quantity)) { + return { mode: 'direct', quantity: basis.quantity, unit: basis.unit.trim() }; + } + if (basis.mode === 'per_house' && Number.isInteger(basis.houseCount) && positive(basis.houseCount) + && positive(basis.quantityPerHouse) && positive(basis.houseCount * basis.quantityPerHouse)) { + return { mode: 'per_house', houseCount: basis.houseCount, quantityPerHouse: basis.quantityPerHouse, unit: basis.unit.trim() }; + } + if (basis.mode === 'per_house_breakdown' && Array.isArray(basis.houses) && basis.houses.length > 0) { + const ids = new Set(); + const houses = basis.houses.map(house => { + const id = String(house?.id || '').trim(); + const name = String(house?.name || '').trim(); + const quantity = house?.quantity; + const normalizedId = id.toLocaleLowerCase('da-DK'); + if (!id || !name || ids.has(normalizedId) + || typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity < 0 || quantity > 1e9) { + fail('INVALID_QUANTITY_BASIS', 'Every house needs a unique identity, name and non-negative quantity'); + } + ids.add(normalizedId); + return { id, name, quantity }; + }); + const total = houses.reduce((sum, house) => sum + house.quantity, 0); + if (!positive(total) || !positive(basis.total) || Math.abs(total - basis.total) > 0.001) { + fail('INVALID_QUANTITY_BASIS', 'Per-house quantities must reconcile exactly to the total'); + } + return { mode: 'per_house_breakdown', houses, total: basis.total, unit: basis.unit.trim() }; + } + fail('INVALID_QUANTITY_BASIS', 'A direct or per-house quantity basis is required'); +}; + +function buildSnapshot(input = {}) { + assertFiniteTree(input); + const { project, workspace, sourcePackages = [], sourcePackageLines = [], materialPrices = [], quoteText, + reservations = [], now = new Date(), authoritativeSiteGeometry = null } = input; + const missingCustomerText = !text(quoteText); + const otherIncomplete = !positive(project?.id) || !text(project?.customer_number) || !text(project?.customer_name) + || workspace?.projectId !== project.id || !Number.isInteger(workspace?.version) || workspace.version < 1 + || !Array.isArray(workspace.instances) || !workspace.instances.length + || !Array.isArray(reservations) || reservations.some(value => !text(value)); + if (otherIncomplete) { + fail('GENERIC_QUOTE_INCOMPLETE', 'Project, customer, workspace and customer text are required'); + } + const lines = { materials: [], tasks: [], rentals: [], references: [] }; + const usedSources = new Map(); + const ids = new Set(); + const instances = workspace.instances.map(instance => { + if (!text(instance.instanceId) || ids.has(instance.instanceId)) fail('INVALID_INSTANCE', 'Unique workspace instance required'); + ids.add(instance.instanceId); + const source = sourcePackages.find(row => Number(row.id) === instance.sourcePackageId); + if (!source || Number(source.is_active) !== 1 || source.validation_status !== 'verified' + || !Number.isInteger(instance.sourcePackageVersion) || instance.sourcePackageVersion < 1 + || Number(source.version) !== instance.sourcePackageVersion) { + fail('STALE_PACKAGE_VERSION', 'Source package must be active, verified and current'); + } + if (source.package_type === 'complete_offer' + || source.replacement_scope || roofList(source.compatible_roof_materials).length || roofList(source.allowed_roof_forms).length) { + fail('ROOF_PACKAGE_REQUIRES_ROOF_SNAPSHOT', 'Roof packages require the strict roof snapshot'); + } + usedSources.set(source.id, source); + for (const kind of ['materials', 'tasks', 'rentals', 'referenceServices']) { + if (instance[kind] !== undefined && !Array.isArray(instance[kind])) fail('INVALID_LINES', 'Lines must be arrays'); + } + const authoritativeLines = sourcePackageLines.filter(line => sourcePackageId(line) === Number(source.id)); + const siteGeometry = validateSiteGeometryTrust(instance, project.id, source, authoritativeLines, + authoritativeSiteGeometry); + const normalized = canonicalizeWorkspaceInstance({ instance, source, sourcePackageLines, materialPrices, now, siteGeometry }); + const canonical = { materials: normalized.materials, tasks: normalized.tasks, rentals: normalized.rentals, + references: normalized.referenceServices }; + Object.keys(lines).forEach(kind => lines[kind].push(...canonical[kind])); + return normalized; + }); + if (!Object.values(lines).some(rows => rows.length)) fail('EMPTY_QUOTE', 'Quote needs active lines'); + const sum = rows => money(rows.reduce((total, line) => total + line.lineTotal, 0)); + const economics = calculateQuoteEconomics({ materialTotal: sum(lines.materials), laborTotal: sum(lines.tasks), + rentalTotal: sum(lines.rentals), referenceTotal: sum(lines.references) }); + if (missingCustomerText) { + fail('GENERIC_QUOTE_INCOMPLETE', 'Project, customer, workspace and customer text are required', 422, + { reason: 'MISSING_CUSTOMER_TEXT' }); + } + return sign({ schema: SCHEMA, customerProject: project, workspace: { ...workspace, instances }, + sourcePackages: [...usedSources.values()], lines, economics, quoteText, reservations }); +} + +const parseStored = (value, fallback) => { + if (value == null || value === '') return fallback; + if (typeof value === 'object') return value; + try { return JSON.parse(value); } catch (_error) { fail('INVALID_STORED_QUOTE', 'Stored quote data is invalid'); } +}; + +class GenericQuoteSnapshotService { + constructor({ databaseService } = {}) { this.db = databaseService; } + + async transaction(projectId, action) { + const id = Number(projectId); + if (!Number.isInteger(id) || id <= 0) fail('INVALID_PROJECT_ID', 'Invalid project id', 400); + if (!this.db?.pool?.getConnection) fail('GENERIC_SNAPSHOT_UNAVAILABLE', 'Snapshot storage unavailable', 503); + const connection = await this.db.pool.getConnection(); + try { + await connection.beginTransaction(); + const result = await action(connection, id); + await connection.commit(); + return result; + } catch (error) { + await connection.rollback(); + throw error; + } finally { connection.release(); } + } + + async load(connection, id) { + const [projects] = await connection.execute(`SELECT id, project_name, customer_name, customer_number, + customer_email, customer_phone, customer_address, project_description, created_at, selected_packages, input_normalization_log + FROM customer_projects WHERE id = ? FOR UPDATE`, [id]); + if (!projects.length) fail('PROJECT_NOT_FOUND', 'Project not found', 404); + const { input_normalization_log: log, ...project } = projects[0]; + const normalizationLog = parseStored(log, {}); + const [rows] = await connection.execute(`SELECT version, workspace_json FROM project_smart_package_workspaces + WHERE project_id = ? FOR UPDATE`, [id]); + const stored = parseStored(rows[0]?.workspace_json, {}); + const workspace = { ...stored, projectId: id, version: Number(rows[0]?.version) }; + if (!Array.isArray(workspace.instances)) fail('INVALID_STORED_QUOTE', 'Workspace instances are required'); + const instanceIds = new Set(workspace.instances.map(instance => instance.instanceId)); + const selected = parseStored(project.selected_packages, []); + const selectedItems = Array.isArray(selected) ? selected : [selected]; + if (selectedItems.some(item => { + const sourceId = typeof item === 'object' && item ? item.sourcePackageId ?? item.id : item; + const version = typeof item === 'object' && item ? item.sourcePackageVersion ?? item.version : null; + return !workspace.instances.some(instance => instance.sourcePackageId === Number(sourceId) + && (version == null || instance.sourcePackageVersion === Number(version))); + })) fail('UNBOUND_SOURCE_PACKAGE', 'Selected source is not bound to the workspace'); + for (const table of ['project_materials', 'project_rentals', 'project_labor']) { + const [projectRows] = await connection.execute(`SELECT * FROM ${table} WHERE project_id = ? FOR UPDATE`, [id]); + const projectedLines = table === 'project_labor' + ? projectRows.flatMap(row => parseStored(row.work_breakdown, [])) : projectRows; + if (projectedLines.filter(active).some(line => !instanceIds.has(line.packageInstanceId ?? line.package_instance_id))) { + fail('UNBOUND_PROJECT_LINES', 'Move standalone project lines into the workspace before creating a generic quote'); + } + if (table === 'project_labor' && projectRows.some(row => Number(row.total_work_hours) > 0 + && !parseStored(row.work_breakdown, []).length)) { + fail('UNBOUND_PROJECT_LINES', 'Labor needs a workspace line breakdown'); + } + } + let quoteText = normalizationLog.genericQuoteText; + if (!text(quoteText)) { + const [quotes] = await connection.execute(`SELECT quote_text FROM generated_quotes + WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 1 FOR UPDATE`, [id]); + quoteText = quotes[0]?.quote_text; + } + const sourceIds = [...new Set((workspace.instances || []).map(instance => instance.sourcePackageId))]; + const [sourcePackages] = sourceIds.length ? await connection.execute(`SELECT id, catalog_key, version, is_active, + validation_status, validated_at, updated_at, category, name, created_by, package_type, replacement_scope, + compatible_roof_materials, allowed_roof_forms, unit, unit_price, price_per_unit, standard_price, + total_estimated_price, hourly_rate, geometry_basis, geometry_factor, price_source, price_source_value + FROM material_packages WHERE id IN (${sourceIds.map(() => '?').join(',')}) FOR UPDATE`, sourceIds) : [[]]; + const [packageMaterials] = sourceIds.length ? await connection.execute(`SELECT pm.id, pm.package_id, + 'materials' AS kind, pm.material_id, pm.material_name AS name, pm.quantity, pm.unit, + pm.geometry_multiplier, pm.base_quantity, pm.waste_factor, pm.excel_raw_data + FROM package_materials pm WHERE pm.package_id IN (${sourceIds.map(() => '?').join(',')}) + ORDER BY pm.package_id, pm.id FOR UPDATE`, sourceIds) : [[]]; + const [packageTasks] = sourceIds.length ? await connection.execute(`SELECT task.id, task.package_id, + 'tasks' AS kind, task.name, task.hours, COALESCE(NULLIF(task.rate, 0), pkg.hourly_rate) AS rate, + task.time_unit, task.time_per_unit, task.geometry_basis, + pkg.validated_at AS source_date + FROM smart_package_tasks task JOIN material_packages pkg ON pkg.id = task.package_id + WHERE task.package_id IN (${sourceIds.map(() => '?').join(',')}) + ORDER BY task.package_id, task.id FOR UPDATE`, sourceIds) : [[]]; + const syntheticPrices = sourcePackages.filter(row => row.package_type === 'rental_service' + || row.created_by === 'haandvaerkpriser-import').map(row => { + const reference = row.created_by === 'haandvaerkpriser-import' + || /håndværker|haandvaerk|reference/i.test(`${row.price_source || ''} ${row.category || ''}`); + const unitPrice = [row.unit_price, row.price_per_unit, row.standard_price, row.total_estimated_price] + .map(Number).find(positive); + return { id: reference ? `haandvaerkpriser-${row.id}` : `rental-package-${row.id}`, package_id: row.id, + kind: reference ? 'referenceServices' : 'rentals', name: row.name, quantity: 1, unit: row.unit, + unit_price: unitPrice, price_source: row.price_source, source_date: row.validated_at }; + }); + const sourcePackageLines = [...packageMaterials, ...packageTasks, ...syntheticPrices]; + const [siteGeometryRows] = await connection.execute(`SELECT project_id, revision, geometry_json + FROM project_site_geometry WHERE project_id = ? FOR UPDATE`, [id]); + const authoritativeSiteGeometry = siteGeometryRows[0] || null; + + const materialIds = [...new Set(packageMaterials.map(line => line.material_id).filter(value => value != null))]; + const [materialPrices] = materialIds.length ? await connection.execute(`SELECT mp.material_id, + mp.id AS price_version, mp.price AS current_unit_price, m.unit AS current_unit, + COALESCE(mp.document_date, mp.valid_from) AS source_date, mp.supplier_name AS price_source + FROM material_prices mp JOIN materials m ON m.id = mp.material_id + WHERE mp.material_id IN (${materialIds.map(() => '?').join(',')}) AND mp.is_active = 1 + AND mp.id = (SELECT latest.id FROM material_prices latest WHERE latest.material_id = mp.material_id + AND latest.is_active = 1 ORDER BY latest.valid_from DESC, latest.id DESC LIMIT 1) FOR UPDATE`, materialIds) : [[]]; + const snapshot = buildSnapshot({ project, workspace, sourcePackages, sourcePackageLines, materialPrices, + authoritativeSiteGeometry, + quoteText, reservations: stored.reservations || [] }); + const approval = normalizationLog.genericQuoteApproval || null; + const approved = approval?.signature === snapshot.signature && text(approval?.approvedBy) && text(approval?.approvedAt); + return { ...snapshot, approved: Boolean(approved), approval, + realityCheck: { mode: 'generic_deterministic', ready: true, blockers: [] }, readyForSubmission: true }; + } + + assertExpectedSignature(snapshot, expected) { + if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected) + || sign(snapshot.artifact).signature !== expected) { + fail('GENERIC_QUOTE_SIGNATURE_MISMATCH', 'Quote changed; reload before continuing', 409); + } + return snapshot; + } + + buildFromProject(projectId) { + return this.transaction(projectId, (connection, id) => this.load(connection, id)); + } + + saveCustomerText(projectId, genericQuoteText) { + return this.transaction(projectId, async (connection, id) => { + if (typeof genericQuoteText !== 'string' || !genericQuoteText.trim() + || genericQuoteText.trim().length > MAX_GENERIC_QUOTE_TEXT_LENGTH) { + fail('INVALID_GENERIC_QUOTE_TEXT', `Customer text must be between 1 and ${MAX_GENERIC_QUOTE_TEXT_LENGTH} characters`, 400); + } + const normalizedText = genericQuoteText.trim(); + assertCustomerDocumentLanguage([normalizedText]); + const [result] = await connection.execute(`UPDATE customer_projects SET input_normalization_log = JSON_SET( + JSON_REMOVE(COALESCE(NULLIF(input_normalization_log, ''), JSON_OBJECT()), '$.genericQuoteApproval'), + '$.genericQuoteText', JSON_EXTRACT(?, '$')) WHERE id = ?`, [JSON.stringify(normalizedText), id]); + if (result.affectedRows !== 1) fail('PROJECT_NOT_FOUND', 'Project not found', 404); + return { projectId: id, genericQuoteText: normalizedText, approvalInvalidated: true }; + }); + } + + async withApprovedSnapshot(projectId, expected, action) { + const snapshot = await this.transaction(projectId, async (connection, id) => { + const current = this.assertExpectedSignature(await this.load(connection, id), expected); + if (!current.approved) fail('GENERIC_QUOTE_APPROVAL_REQUIRED', 'Approve the current quote first', 409); + return current; + }); + return action(snapshot); + } + + approveSnapshot(projectId, expected, approvedBy) { + return this.transaction(projectId, async (connection, id) => { + if (!text(approvedBy)) fail('INVALID_APPROVER', 'Authenticated approver required', 400); + const snapshot = this.assertExpectedSignature(await this.load(connection, id), expected); + const approval = { signature: snapshot.signature, approvedBy, approvedAt: new Date().toISOString() }; + const [result] = await connection.execute(`UPDATE customer_projects SET input_normalization_log = JSON_SET( + COALESCE(NULLIF(input_normalization_log, ''), JSON_OBJECT()), '$.genericQuoteApproval', JSON_EXTRACT(?, '$')) + WHERE id = ?`, [JSON.stringify(approval), id]); + if (result.affectedRows !== 1) fail('APPROVAL_NOT_PERSISTED', 'Approval could not be saved', 409); + const approved = this.assertExpectedSignature(await this.load(connection, id), expected); + if (!approved.approved) fail('APPROVAL_NOT_PERSISTED', 'Approval could not be verified', 409); + return approved; + }); + } +} + +module.exports = { SCHEMA, MAX_SOURCE_AGE_DAYS, MAX_GENERIC_QUOTE_TEXT_LENGTH, buildSnapshot, + canonicalizeWorkspaceInstance, GenericQuoteSnapshotService }; diff --git a/backend/src/services/haandvaerkPriserImportService.js b/backend/src/services/haandvaerkPriserImportService.js index e9278a6..fc39cf7 100644 --- a/backend/src/services/haandvaerkPriserImportService.js +++ b/backend/src/services/haandvaerkPriserImportService.js @@ -75,6 +75,12 @@ const sourceKeyFor = row => crypto .update(`${row.category}\u0000${normalizeName(row.name)}`) .digest('hex'); +const shouldSkipSourceRow = row => ( + normalizeName(row?.name) === 'tagrender reparation og udskiftning' + && normalizeName(row?.unit || row?.rawUnitText).includes('fast pris parcelhus') + && String(row?.detailUrl || '').includes('/vvs/tagrender/tagrender-reparation-og-udskiftning/') +); + const parseHaandvaerkPriserHtml = (html, { categories = ALLOWED_CATEGORIES } = {}) => { const $ = cheerio.load(html); const rows = []; @@ -127,7 +133,7 @@ class HaandvaerkPriserImportService { timeout: 15000, maxContentLength: 2 * 1024 * 1024 }); - return parseHaandvaerkPriserHtml(response.data); + return parseHaandvaerkPriserHtml(response.data).filter(row => !shouldSkipSourceRow(row)); } async preview() { @@ -137,7 +143,7 @@ class HaandvaerkPriserImportService { return { sourceUrl: SOURCE_URL, totalTasks: rows.length, byCategory, sample: rows.slice(0, 5) }; } - async import() { + async import(expectedVersions = {}) { const rows = await this.fetchRows(); if (rows.length === 0) throw new Error('Kilden returnerede ingen gyldige referencepriser'); const connection = await this.db.pool.getConnection(); @@ -152,12 +158,13 @@ class HaandvaerkPriserImportService { await connection.beginTransaction(); transactionStarted = true; const [packages] = await connection.execute(` - SELECT id, name, category, unit_price, created_by, is_active, validation_status + SELECT id, version, name, category, unit_price, created_by, is_active, validation_status FROM material_packages WHERE package_type = 'component' AND is_active = 1 AND validation_status = 'verified' ORDER BY id ASC + FOR UPDATE `); const [historyRows] = await connection.execute(` SELECT source_key, matched_package_id, baseline_price_excl_vat @@ -176,6 +183,7 @@ class HaandvaerkPriserImportService { let insertedPackages = 0; let updatedPackages = 0; let matchedExistingPackages = 0; + const affectedPackageIds = new Set(); for (const row of rows) { const sourceKey = sourceKeyFor(row); @@ -206,7 +214,17 @@ class HaandvaerkPriserImportService { let packageId; if (target) { packageId = Number(target.id); - await connection.execute(` + const expectedVersion = expectedVersions[String(packageId)]; + if (!Number.isInteger(expectedVersion) || expectedVersion < 0) { + throw Object.assign(new Error(`Missing expectedVersion for package ${packageId}`), { status: 400 }); + } + if (Number(target.version) !== expectedVersion) { + throw Object.assign(new Error('Smart Package version conflict'), { + status: 409, + code: 'SMART_PACKAGE_VERSION_CONFLICT' + }); + } + const [updated] = await connection.execute(` UPDATE material_packages SET description = ?, unit = ?, estimated_hours = 0, hourly_rate = 0, total_estimated_price = ?, area_based = 0, time_per_sqm = 0, @@ -214,13 +232,19 @@ class HaandvaerkPriserImportService { unit_price = ?, price_per_unit = ?, standard_price = ?, price_basis_note = ?, price_source = ?, price_source_value = ?, is_active = 1, validation_status = 'verified', validated_at = NOW(), version = version + 1 - WHERE id = ? AND created_by = 'haandvaerkpriser-import' + WHERE id = ? AND created_by = 'haandvaerkpriser-import' AND version = ? `, [ `Ekstern referenceydelse fra ${SOURCE_NAME}.`, row.unit, decision.effectivePriceExVat, decision.effectivePriceExVat, decision.effectivePriceExVat, decision.effectivePriceExVat, - priceBasisNote, SOURCE_NAME, row.detailUrl, packageId + priceBasisNote, SOURCE_NAME, row.detailUrl, packageId, expectedVersion ]); + if (updated.affectedRows !== 1) { + throw Object.assign(new Error('Smart Package version conflict'), { + status: 409, + code: 'SMART_PACKAGE_VERSION_CONFLICT' + }); + } updatedPackages += 1; } else { const [result] = await connection.execute(` @@ -250,6 +274,7 @@ class HaandvaerkPriserImportService { packagesByKey.set(normalizedKey, [...exactMatches, newPackage]); insertedPackages += 1; } + affectedPackageIds.add(packageId); // Fjern eventuelle gamle pseudo-arbejdstimer fra den første implementation. await connection.execute( @@ -280,8 +305,24 @@ class HaandvaerkPriserImportService { ]); } + let currentPackages = []; + if (affectedPackageIds.size > 0) { + const ids = [...affectedPackageIds]; + [currentPackages] = await connection.execute( + `SELECT * FROM material_packages + WHERE id IN (${ids.map(() => '?').join(', ')}) + ORDER BY id`, + ids + ); + } await connection.commit(); - const result = { totalRows: rows.length, insertedPackages, updatedPackages, matchedExistingPackages }; + const result = { + totalRows: rows.length, + insertedPackages, + updatedPackages, + matchedExistingPackages, + packages: currentPackages + }; logger.info('Haandvaerkpriser.dk import complete', result); return result; } catch (error) { @@ -310,5 +351,6 @@ module.exports.normalizeName = normalizeName; module.exports.priceInclVatToExVat = priceInclVatToExVat; module.exports.buildPriceDecision = buildPriceDecision; module.exports.sourceKeyFor = sourceKeyFor; +module.exports.shouldSkipSourceRow = shouldSkipSourceRow; module.exports.ALLOWED_CATEGORIES = ALLOWED_CATEGORIES; module.exports.SOURCE_URL = SOURCE_URL; diff --git a/backend/src/services/materialPackageService.js b/backend/src/services/materialPackageService.js index 2d9245a..520cc54 100644 --- a/backend/src/services/materialPackageService.js +++ b/backend/src/services/materialPackageService.js @@ -5,49 +5,45 @@ class MaterialPackageService { this.db = databaseService; } - // Create a new material package + // Create a new material package and its children atomically. async createPackage(packageData) { + const { name, description, category, materials, createdBy } = packageData; + const totalPrice = materials.reduce((sum, material) => + sum + (parseFloat(material.quantity) * parseFloat(material.unitPrice)), 0); + const connection = await this.db.pool.getConnection(); try { - const { name, description, category, materials, createdBy } = packageData; - - // Calculate total estimated price - const totalPrice = materials.reduce((sum, material) => - sum + (parseFloat(material.quantity) * parseFloat(material.unitPrice)), 0); - - // Insert package - const [packageResult] = await this.db.pool.execute(` + await connection.beginTransaction(); + const [packageResult] = await connection.execute(` INSERT INTO material_packages (name, description, category, created_by, total_estimated_price) VALUES (?, ?, ?, ?, ?) `, [name, description, category, createdBy, totalPrice]); - const packageId = packageResult.insertId; - // Insert package materials for (const material of materials) { const totalMaterialPrice = parseFloat(material.quantity) * parseFloat(material.unitPrice); - - await this.db.pool.execute(` + await connection.execute(` INSERT INTO package_materials ( - package_id, material_name, material_category, quantity, unit, + package_id, material_name, material_category, quantity, unit, unit_price, total_price, supplier, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ - packageId, - material.materialName, - material.materialCategory, - material.quantity, - material.unit, - material.unitPrice, - totalMaterialPrice, - material.supplier || '', - material.notes || '' + packageId, material.materialName, material.materialCategory, + material.quantity, material.unit, material.unitPrice, + totalMaterialPrice, material.supplier || '', material.notes || '' ]); } + const [currentRows] = await connection.execute( + 'SELECT * FROM material_packages WHERE id = ?', + [packageId] + ); + const currentPackage = currentRows[0]; + await connection.commit(); logger.info('Material package created', { packageId, name, materialsCount: materials.length }); - return { id: packageId, + version: Number(currentPackage.version), + package: currentPackage, name, description, category, @@ -56,8 +52,11 @@ class MaterialPackageService { createdBy }; } catch (error) { + await connection.rollback(); logger.error('Error creating material package:', error); throw error; + } finally { + connection.release(); } } diff --git a/backend/src/services/nominatimService.js b/backend/src/services/nominatimService.js new file mode 100644 index 0000000..6ec8c81 --- /dev/null +++ b/backend/src/services/nominatimService.js @@ -0,0 +1,261 @@ +const axios = require('axios'); +const crypto = require('crypto'); +const { fail } = require('./siteGeometryService'); + +const LOCK_NAME = 'tilbudgivern:nominatim'; +const RATE_LIMIT_MESSAGE = 'Vent et øjeblik før næste adressesøgning.'; +const UNAVAILABLE_MESSAGE = 'Adressesøgning er ikke tilgængelig. Prøv igen eller tegn selv.'; +const CACHE_TTL_MS = 86400000; +const RESERVATION_LEASE_SECONDS = 30; + +class Nominatim { + constructor({ http = axios, now = Date.now, db = null, tokenFactory = () => crypto.randomBytes(32).toString('hex') } = {}) { + this.http = http; + this.now = now; + this.db = db; + this.tokenFactory = tokenFactory; + this.cache = new Map(); + this.nextRequest = 0; + this.busy = false; + } + + rateLimitError() { + return fail(RATE_LIMIT_MESSAGE, 429, 'GEOCODE_RATE_LIMIT'); + } + + unavailableError() { + return fail(UNAVAILABLE_MESSAGE, 502, 'GEOCODE_UNAVAILABLE'); + } + + getPool() { + return this.db?.pool || (this.db && typeof this.db.getConnection === 'function' ? this.db : null); + } + + readMemoryCache(key) { + const cached = this.cache.get(key); + return cached && cached.expires > this.now() ? cached.results : null; + } + + writeMemoryCache(key, results) { + if (this.cache.size >= 500) this.cache.delete(this.cache.keys().next().value); + this.cache.set(key, { expires: this.now() + CACHE_TTL_MS, results }); + } + + normalizeResults(data) { + if (!Array.isArray(data)) throw new Error('Invalid provider response'); + return data.slice(0, 5) + .filter(item => Number.isFinite(Number(item.lat)) + && Number.isFinite(Number(item.lon)) + && Math.abs(Number(item.lat)) <= 90 + && Math.abs(Number(item.lon)) <= 180 + && typeof item.display_name === 'string') + .map(item => ({ + formattedAddress: item.display_name.slice(0, 500), + lat: Number(item.lat), + lng: Number(item.lon), + providerPlaceId: String(item.place_id).slice(0, 100), + provider: 'openstreetmap' + })); + } + + async dispatch(q) { + const { data } = await this.http.get( + process.env.NOMINATIM_SEARCH_URL || 'https://nominatim.openstreetmap.org/search', + { + params: { q, format: 'jsonv2', limit: 5, addressdetails: 0 }, + headers: { 'User-Agent': 'Tilbudsgivern-SiteGeometry/1.0 (+https://tilbudsgiveren.alw.dk)' }, + timeout: 5000, + maxContentLength: 100000, + maxRedirects: 0 + } + ); + return this.normalizeResults(data); + } + + async searchLocal(q, key) { + const time = this.now(); + if (this.busy || time < this.nextRequest) throw this.rateLimitError(); + this.nextRequest = time + 1100; + this.busy = true; + try { + const results = await this.dispatch(q); + this.writeMemoryCache(key, results); + return results; + } catch (_error) { + throw this.unavailableError(); + } finally { + this.busy = false; + } + } + + async reserveAndDispatch(q, key) { + const pool = this.getPool(); + const token = this.tokenFactory(); + const cacheKey = crypto.createHash('sha256').update(key).digest('hex'); + let connection; + let lockAcquired = false; + let reserved = false; + let sharedCached = null; + let releaseSucceeded = false; + + try { + connection = await pool.getConnection(); + const [lockRows] = await connection.execute('SELECT GET_LOCK(?, 0) AS acquired', [LOCK_NAME]); + lockAcquired = Number(lockRows[0]?.acquired) === 1; + if (!lockAcquired) throw this.rateLimitError(); + + // Recheck both caches after taking the cross-process lock. Another request may + // have populated either while this request was obtaining its connection/lock. + const memoryCached = this.readMemoryCache(key); + if (memoryCached) sharedCached = memoryCached; + if (!sharedCached) { + const [cacheRows] = await connection.execute( + 'SELECT results_json FROM nominatim_cache WHERE query_key = ? AND expires_at > NOW(6)', + [cacheKey] + ); + if (cacheRows.length) { + try { + const parsed = typeof cacheRows[0].results_json === 'string' + ? JSON.parse(cacheRows[0].results_json) + : cacheRows[0].results_json; + if (Array.isArray(parsed)) sharedCached = parsed; + } catch (_error) { + // A malformed cache value is a miss, never provider data. + } + } + } + + if (!sharedCached) { + await connection.execute( + `INSERT IGNORE INTO nominatim_rate_gate + (id, next_request_at, reservation_token, reservation_expires_at) + VALUES (1, '1970-01-01 00:00:00.000000', NULL, NULL)` + ); + const [reservation] = await connection.execute( + `UPDATE nominatim_rate_gate + SET reservation_token = ?, + reservation_expires_at = DATE_ADD(NOW(6), INTERVAL ${RESERVATION_LEASE_SECONDS} SECOND) + WHERE id = 1 + AND next_request_at <= NOW(6) + AND (reservation_token IS NULL OR reservation_expires_at <= NOW(6))`, + [token] + ); + reserved = Number(reservation.affectedRows) === 1; + if (!reserved) throw this.rateLimitError(); + } + } finally { + let releaseError = null; + if (connection && lockAcquired) { + try { + const [rows] = await connection.execute('SELECT RELEASE_LOCK(?) AS released', [LOCK_NAME]); + if (Number(rows[0]?.released) !== 1) releaseError = new Error('Nominatim advisory lock was not released'); + } catch (error) { + releaseError = error; + } + } + if (connection) { + try { + // Never return a connection that may still own the advisory lock to + // the pool. Destroying it makes MySQL release all connection locks. + if (releaseError && typeof connection.destroy === 'function') { + await Promise.resolve(connection.destroy()); + } else { + await Promise.resolve(connection.release()); + } + } catch (error) { + releaseError = releaseError || error; + if (typeof connection.destroy === 'function') { + try { await Promise.resolve(connection.destroy()); } catch (_destroyError) { /* fail below */ } + } + } + } + releaseSucceeded = !releaseError; + if (releaseError) throw this.unavailableError(); + } + + // Nothing outside this point can run until RELEASE_LOCK completed and the + // dedicated connection was returned. In particular, provider I/O is below. + if (sharedCached) { + this.writeMemoryCache(key, sharedCached); + return sharedCached; + } + if (!releaseSucceeded || !reserved) throw this.rateLimitError(); + + // Renew with a token-scoped CAS on a pool-managed statement. A delayed lock + // release or renewal therefore loses the expired reservation and fails closed. + let ownership; + try { + [ownership] = await pool.execute( + `UPDATE nominatim_rate_gate + SET reservation_expires_at = DATE_ADD(NOW(6), INTERVAL ${RESERVATION_LEASE_SECONDS} SECOND) + WHERE id = 1 + AND reservation_token = ? + AND reservation_expires_at > NOW(6)`, + [token] + ); + } catch (_error) { + throw this.unavailableError(); + } + if (Number(ownership.affectedRows) !== 1) throw this.rateLimitError(); + + let results; + let providerError = null; + try { + results = await this.dispatch(q); + } catch (error) { + providerError = error; + } + + // Advance from DB time after provider I/O. Delayed DB operations can only + // lengthen the interval, never bunch starts. The token predicate prevents a + // stale owner from clearing or advancing somebody else's reservation. + try { + const [completion] = await pool.execute( + `UPDATE nominatim_rate_gate + SET next_request_at = DATE_ADD(NOW(6), INTERVAL 1 SECOND), + reservation_token = NULL, + reservation_expires_at = NULL + WHERE id = 1 AND reservation_token = ?`, + [token] + ); + if (Number(completion.affectedRows) !== 1) throw new Error('Nominatim reservation ownership was lost'); + } catch (_error) { + throw this.unavailableError(); + } + + if (providerError) throw this.unavailableError(); + + this.writeMemoryCache(key, results); + try { + await pool.execute( + `INSERT INTO nominatim_cache (query_key, results_json, expires_at) + VALUES (?, ?, DATE_ADD(NOW(6), INTERVAL 1 DAY)) + ON DUPLICATE KEY UPDATE results_json = VALUES(results_json), expires_at = VALUES(expires_at)`, + [cacheKey, JSON.stringify(results)] + ); + } catch (_error) { + // Cache durability is best-effort; a valid provider result remains valid. + } + return results; + } + + async search(query) { + if (typeof query !== 'string' || query.trim().length < 3 || query.length > 200) { + throw fail('Skriv en adresse på 3–200 tegn.', 400, 'GEOCODE_QUERY_INVALID'); + } + const q = query.trim(); + const key = q.toLocaleLowerCase('da-DK'); + const cached = this.readMemoryCache(key); + if (cached) return cached; + + if (!this.getPool()) return this.searchLocal(q, key); + try { + return await this.reserveAndDispatch(q, key); + } catch (error) { + if (error?.status) throw error; + throw this.unavailableError(); + } + } +} + +module.exports = { Nominatim }; diff --git a/backend/src/services/ordrestyringOfferNormalizationService.js b/backend/src/services/ordrestyringOfferNormalizationService.js new file mode 100644 index 0000000..c184c22 --- /dev/null +++ b/backend/src/services/ordrestyringOfferNormalizationService.js @@ -0,0 +1,228 @@ +class OfferNormalizationError extends Error { + constructor(message, code = 'INVALID_CANONICAL_SNAPSHOT') { + super(message); + this.name = 'OfferNormalizationError'; + this.code = code; + this.status = 422; + } +} + +// Decimal rationals avoid binary floating point and unit-price cent rounding. +const decimal = value => { + if (!['string', 'number'].includes(typeof value) || !Number.isFinite(Number(value))) { + throw new OfferNormalizationError('Ugyldigt decimaltal'); + } + const match = String(value).match(/^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i); + if (!match) throw new OfferNormalizationError('Ugyldigt decimaltal'); + const scale = (match[3] || '').length - Number(match[4] || 0); + if (Math.abs(scale) > 100) throw new OfferNormalizationError('Decimaltal uden for interval'); + const numerator = BigInt(`${match[1]}${match[2]}${match[3] || ''}`); + return scale >= 0 ? [numerator, 10n ** BigInt(scale)] : [numerator * 10n ** BigInt(-scale), 1n]; +}; +const rounded = (n, d) => { + const sign = n < 0n ? -1n : 1n; + const result = sign * ((2n * (n * sign) + d) / (2n * d)); + if (result > BigInt(Number.MAX_SAFE_INTEGER) || result < BigInt(Number.MIN_SAFE_INTEGER)) { + throw new OfferNormalizationError('Pengebeløb uden for sikkert interval'); + } + return Number(result); +}; +const cents = value => { + const [n, d] = decimal(value); + return rounded(n * 100n, d); +}; +const nativeMoney = value => { + const [n, d] = decimal(value); + return rounded(n * 10000n, d) / 100; +}; +const lineNetCents = line => { + const [q, qd] = decimal(line.quantity); + const [p, pd] = decimal(line.salesPrice); + const [discount, dd] = decimal(line.discount ?? 0); + if (discount < 0n || discount > 100n * dd) throw new OfferNormalizationError('Ugyldig rabat'); + return rounded(q * p * (100n * dd - discount), qd * pd * dd); +}; + +const number = (value, label) => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new OfferNormalizationError(`Ugyldigt tal for ${label}`); + } + return parsed; +}; + +const firstDefined = (...values) => values.find(value => value !== undefined && value !== null); + +const sourceLineTotalCents = (item, quantity, unitPrice) => { + const explicitTotal = firstDefined(item.total, item.totalPrice, item.totalCost, item.lineTotal); + return explicitTotal === undefined + ? lineNetCents({ quantity, salesPrice: unitPrice, discount: item.discount ?? 0 }) + : cents(explicitTotal); +}; + +const buildSourceLine = ({ item, offerId, sortOrder, productNumber, defaults }) => { + const quantity = number(firstDefined(...defaults.quantity.map(key => item[key]), 0), `${productNumber}.quantity`); + const suppliedUnitPrice = number(firstDefined(...defaults.price.map(key => item[key]), 0), `${productNumber}.salesPrice`); + const totalCents = sourceLineTotalCents(item, quantity, suppliedUnitPrice); + + if (quantity === 0 && totalCents !== 0) { + throw new OfferNormalizationError(`${productNumber} har nul antal men et beløb forskelligt fra nul`); + } + + return { + input: { + offerId, + taskId: 1, + description: firstDefined(...defaults.description.map(key => item[key]), defaults.fallbackDescription), + quantity, + unit: firstDefined(...defaults.unit.map(key => item[key]), defaults.fallbackUnit), + productNumber: firstDefined(...defaults.productNumber.map(key => item[key]), productNumber), + salesPrice: quantity === 0 ? suppliedUnitPrice : totalCents / 100 / quantity, + // Explicit canonical totals already include any source discount. + discount: 0, + sortOrder + }, + totalCents + }; +}; + +const CATEGORY_DEFINITIONS = [ + { + source: 'materials', total: 'materialTotal', productNumber: 'MATERIAL', + defaults: { + description: ['name', 'materialName', 'material_name', 'description'], + quantity: ['quantity'], price: ['unitPrice', 'unit_price', 'price'], unit: ['unit'], + productNumber: ['productNumber', 'varenr', 'sku'], fallbackDescription: 'Materiale', fallbackUnit: 'stk' + } + }, + { + source: 'labor', total: 'laborTotal', productNumber: 'LABOR', + defaults: { + description: ['description', 'name'], quantity: ['hours', 'totalHours', 'estimatedHours'], + price: ['hourlyRate', 'rate', 'unitPrice'], unit: ['unit', 'hoursUnit', 'timeUnit'], productNumber: [], + fallbackDescription: 'Arbejde', fallbackUnit: 'timer' + } + }, + { + source: 'rentals', total: 'rentalTotal', productNumber: 'RENTAL', + defaults: { + description: ['name', 'rentalName', 'description'], quantity: ['quantity'], + price: ['unitPrice', 'unit_price', 'price'], unit: ['unit'], productNumber: ['productNumber', 'varenr'], + fallbackDescription: 'Leje', fallbackUnit: 'stk' + } + }, + { + source: 'referenceServices', total: 'referenceTotal', productNumber: 'REFERENCE_SERVICE', + defaults: { + description: ['name', 'description'], quantity: ['quantity'], + price: ['unitPrice', 'unit_price', 'price'], unit: ['unit'], productNumber: ['productNumber', 'varenr'], + fallbackDescription: 'Referenceydelse', fallbackUnit: 'sum' + } + } +]; + +const requireTotal = (totals, key) => { + if (!Object.prototype.hasOwnProperty.call(totals, key)) { + throw new OfferNormalizationError(`Kanonisk snapshot mangler totals.${key}`); + } + return cents(totals[key]); +}; + +function buildOrdrestyringOfferLines(snapshot = {}, { offerId } = {}) { + if (offerId === undefined || offerId === null) { + throw new OfferNormalizationError('Ordrestyring offerId mangler'); + } + + const artifact = snapshot.artifact || snapshot; + const canonicalLines = artifact.lines || artifact; + const normalizedSnapshot = { + materials: firstDefined(canonicalLines.materials, []), + labor: firstDefined(canonicalLines.tasks, canonicalLines.labor, canonicalLines.laborTasks, []), + rentals: firstDefined(canonicalLines.rentals, []), + referenceServices: firstDefined(canonicalLines.references, canonicalLines.referenceServices, []) + }; + const totals = artifact.economics || artifact.totals || {}; + const lines = []; + const categoryCents = {}; + let sortOrder = 1; + + for (const definition of CATEGORY_DEFINITIONS) { + const source = normalizedSnapshot[definition.source]; + if (!Array.isArray(source)) { + throw new OfferNormalizationError(`${definition.source} skal være en liste`); + } + + let sum = 0; + for (const item of source) { + const built = buildSourceLine({ + item: item || {}, offerId, sortOrder, productNumber: definition.productNumber, defaults: definition.defaults + }); + lines.push(built.input); + sum += built.totalCents; + sortOrder += 1; + } + categoryCents[definition.total] = sum; + + const expected = requireTotal(totals, definition.total); + if (sum !== expected) { + throw new OfferNormalizationError(`${definition.total} stemmer ikke med de kanoniske linjer`); + } + } + + const subtotal = requireTotal(totals, 'subtotal'); + const directTotal = Object.values(categoryCents).reduce((sum, value) => sum + value, 0); + if (subtotal !== directTotal) { + throw new OfferNormalizationError('subtotal stemmer ikke med materialer, arbejde, leje og referenceydelser'); + } + + const summaryDefinitions = [ + ['overheadAmount', 'OVERHEAD', 'Overhead'], + ['profitAmount', 'PROFIT', 'Fortjeneste'] + ]; + const summaryCents = {}; + for (const [key, productNumber, description] of summaryDefinitions) { + const amount = requireTotal(totals, key); + summaryCents[key] = amount; + lines.push({ + offerId, + taskId: 1, + description, + quantity: 1, + unit: 'sum', + productNumber, + salesPrice: amount / 100, + discount: 0, + sortOrder + }); + sortOrder += 1; + } + + const totalExclVat = requireTotal(totals, 'totalExclVat'); + if (totalExclVat !== subtotal + summaryCents.overheadAmount + summaryCents.profitAmount) { + throw new OfferNormalizationError('totalExclVat stemmer ikke med subtotal, overhead og fortjeneste'); + } + + const totalInclVat = requireTotal(totals, 'totalInclVat'); + summaryCents.vatAmount = requireTotal(totals, 'vatAmount'); + if (summaryCents.vatAmount !== rounded(BigInt(totalExclVat), 4n)) { + throw new OfferNormalizationError('Kanonisk moms kan ikke afstemmes med Ordrestyring 25% moms'); + } + if (totalInclVat !== totalExclVat + summaryCents.vatAmount) { + throw new OfferNormalizationError('totalInclVat stemmer ikke med total ekskl. moms og moms'); + } + + const lineTotal = lines.reduce((sum, line) => sum + lineNetCents(line), 0); + if (lineTotal !== totalExclVat) { + throw new OfferNormalizationError('Ordrestyring-linjerne stemmer ikke med total ekskl. moms'); + } + + return lines; +} + +module.exports = { + OfferNormalizationError, + cents, + lineNetCents, + nativeMoney, + buildOrdrestyringOfferLines +}; diff --git a/backend/src/services/ordrestyringOfferOperationSchema.js b/backend/src/services/ordrestyringOfferOperationSchema.js new file mode 100644 index 0000000..b0620b3 --- /dev/null +++ b/backend/src/services/ordrestyringOfferOperationSchema.js @@ -0,0 +1,27 @@ +'use strict'; + +const ensureOrdrestyringOfferOperationsTable = async pool => { + if (!pool || typeof pool.execute !== 'function') { + throw new Error('Database pool is required for the offer operation schema'); + } + await pool.execute(`CREATE TABLE IF NOT EXISTS ordrestyring_offer_operations ( + idempotency_key VARCHAR(255) NOT NULL PRIMARY KEY, + project_id INT NOT NULL, + snapshot_signature VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + customer_id BIGINT NULL, + offer_id BIGINT NULL, + state_json JSON NOT NULL, + lease_owner VARCHAR(64) NULL, + lease_expires_at DATETIME(6) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_ordrestyring_offer_operation_project_signature (project_id, snapshot_signature), + INDEX idx_ordrestyring_offer_operation_project (project_id), + INDEX idx_ordrestyring_offer_operation_lease (lease_expires_at), + INDEX idx_ordrestyring_offer_operation_customer (customer_id), + INDEX idx_ordrestyring_offer_operation_offer (offer_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); +}; + +module.exports = { ensureOrdrestyringOfferOperationsTable }; diff --git a/backend/src/services/ordrestyringOfferOperationStateStore.js b/backend/src/services/ordrestyringOfferOperationStateStore.js new file mode 100644 index 0000000..c489a43 --- /dev/null +++ b/backend/src/services/ordrestyringOfferOperationStateStore.js @@ -0,0 +1,180 @@ +class OrdrestyringOperationStoreError extends Error { + constructor(message, code, cause) { + super(message); + this.name = 'OrdrestyringOperationStoreError'; + this.code = code; + this.status = 503; + this.cause = cause; + } +} + +class OrdrestyringOfferOperationStateStore { + constructor(databaseService) { + this.databaseService = databaseService; + } + + get execute() { + return this.databaseService?.pool?.execute?.bind(this.databaseService.pool); + } + + requireExecute() { + const execute = this.execute; + if (!execute) { + throw new OrdrestyringOperationStoreError( + 'Ordrestyring operation store is unavailable', + 'ORDRESTYRING_OPERATION_STORE_UNAVAILABLE' + ); + } + return execute; + } + + static unavailable(error) { + if (error?.code === 'ORDRESTYRING_OPERATION_LEASE_LOST') return error; + if (error?.code === 'ORDRESTYRING_OPERATION_STORE_UNAVAILABLE') return error; + return new OrdrestyringOperationStoreError( + 'Ordrestyring operation store is unavailable', + 'ORDRESTYRING_OPERATION_STORE_UNAVAILABLE', + error + ); + } + + static parseRow(row) { + if (!row) return undefined; + const value = row.state_json; + const state = typeof value === 'string' ? JSON.parse(value) : value; + return { + ...state, + leaseOwner: row.lease_owner ?? null, + leaseExpiresAt: row.lease_expires_at ?? null + }; + } + + async get(idempotencyKey) { + try { + const execute = this.requireExecute(); + const [rows] = await execute( + `SELECT state_json, lease_owner, lease_expires_at + FROM ordrestyring_offer_operations + WHERE idempotency_key = ? LIMIT 1`, + [idempotencyKey] + ); + return OrdrestyringOfferOperationStateStore.parseRow(rows?.[0]); + } catch (error) { + throw OrdrestyringOfferOperationStateStore.unavailable(error); + } + } + + async claim(idempotencyKey, initialState, { ownerId, leaseMs = 30000 } = {}) { + if (!ownerId) { + throw new OrdrestyringOperationStoreError( + 'An owner id is required to claim an Ordrestyring operation', + 'ORDRESTYRING_OPERATION_STORE_UNAVAILABLE' + ); + } + try { + const execute = this.requireExecute(); + const serialized = JSON.stringify(initialState); + let acquired = false; + try { + await execute( + `INSERT INTO ordrestyring_offer_operations + (idempotency_key, project_id, snapshot_signature, status, customer_id, offer_id, + state_json, lease_owner, lease_expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? MICROSECOND))`, + [ + idempotencyKey, + initialState.projectId, + initialState.snapshotSignature, + initialState.status, + initialState.customerId ?? null, + initialState.offerId ?? null, + serialized, + ownerId, + leaseMs * 1000 + ] + ); + acquired = true; + } catch (error) { + if (error?.code !== 'ER_DUP_ENTRY') throw error; + } + + if (!acquired) { + const [takeover] = await execute( + `UPDATE ordrestyring_offer_operations + SET lease_owner = ?, + lease_expires_at = DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? MICROSECOND), + updated_at = CURRENT_TIMESTAMP + WHERE idempotency_key = ? + AND status NOT IN ('completed', 'customer_creating', 'offer_creating', 'lines_creating', 'compensation_pending') + AND (lease_owner IS NULL OR lease_expires_at <= CURRENT_TIMESTAMP(6))`, + [ownerId, leaseMs * 1000, idempotencyKey] + ); + acquired = takeover?.affectedRows === 1; + } + + const state = await this.get(idempotencyKey); + if (!state) { + throw new Error('Atomic claim did not produce an operation row'); + } + return { acquired, state }; + } catch (error) { + throw OrdrestyringOfferOperationStateStore.unavailable(error); + } + } + + async renew(idempotencyKey, { ownerId, leaseMs = 30000 } = {}) { + try { + if (!ownerId) throw new OrdrestyringOperationStoreError('Missing lease owner', 'ORDRESTYRING_OPERATION_LEASE_LOST'); + const [result] = await this.requireExecute()( + `UPDATE ordrestyring_offer_operations + SET lease_expires_at = DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? MICROSECOND) + WHERE idempotency_key = ? AND lease_owner = ? + AND lease_expires_at > CURRENT_TIMESTAMP(6)`, + [leaseMs * 1000, idempotencyKey, ownerId] + ); + if (result?.affectedRows !== 1) { + throw new OrdrestyringOperationStoreError('Ordrestyring operation lease was lost', 'ORDRESTYRING_OPERATION_LEASE_LOST'); + } + } catch (error) { + throw OrdrestyringOfferOperationStateStore.unavailable(error); + } + } + + async set(idempotencyKey, state, { ownerId, leaseMs = 30000 } = {}) { + if (!ownerId) { + throw new OrdrestyringOperationStoreError( + 'An owner id is required to persist an Ordrestyring operation', + 'ORDRESTYRING_OPERATION_LEASE_LOST' + ); + } + try { + const execute = this.requireExecute(); + const terminal = ['completed', 'failed', 'compensated'].includes(state.status); + const [result] = await execute( + `UPDATE ordrestyring_offer_operations + SET status = ?, customer_id = ?, offer_id = ?, state_json = ?, + lease_owner = ${terminal ? 'NULL' : '?'}, + lease_expires_at = ${terminal ? 'NULL' : 'DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? MICROSECOND)'}, + updated_at = CURRENT_TIMESTAMP + WHERE idempotency_key = ? AND lease_owner = ? + AND lease_expires_at > CURRENT_TIMESTAMP(6)`, + terminal + ? [state.status, state.customerId ?? null, state.offerId ?? null, JSON.stringify(state), idempotencyKey, ownerId] + : [state.status, state.customerId ?? null, state.offerId ?? null, JSON.stringify(state), ownerId, + leaseMs * 1000, idempotencyKey, ownerId] + ); + if (result?.affectedRows !== 1) { + throw new OrdrestyringOperationStoreError( + 'Ordrestyring operation lease was lost', + 'ORDRESTYRING_OPERATION_LEASE_LOST' + ); + } + return state; + } catch (error) { + throw OrdrestyringOfferOperationStateStore.unavailable(error); + } + } +} + +module.exports = OrdrestyringOfferOperationStateStore; +module.exports.OrdrestyringOperationStoreError = OrdrestyringOperationStoreError; diff --git a/backend/src/services/pdfGenerationService.js b/backend/src/services/pdfGenerationService.js index 887ef55..0e4b019 100644 --- a/backend/src/services/pdfGenerationService.js +++ b/backend/src/services/pdfGenerationService.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const puppeteer = require('puppeteer'); +const { assertCustomerDocumentLanguage } = require('./customerDocumentLanguage'); class PdfGenerationService { constructor() { @@ -69,6 +70,7 @@ class PdfGenerationService { // Generate work description based on installation manuals and materials async generateWorkDescription(data) { + if (typeof data?.quoteText === 'string') return data.quoteText; // Customer PDFs are fact-locked: only explicit project text and selected // task lines may become scope. The legacy inference code below is kept for // compatibility history, but this return deliberately prevents it from @@ -273,18 +275,245 @@ class PdfGenerationService { return Number.isFinite(parsed) ? parsed : 0; } + buildPdfDataFromSnapshot(snapshot) { + const artifact = snapshot?.artifact; + if (!artifact || artifact.schema !== 'roof_quote_snapshot_v1' + || typeof artifact.quoteText !== 'string' || !artifact.quoteText.trim()) { + throw Object.assign(new Error('Canonical roof quote snapshot is required'), { + status: 422, + code: 'INVALID_ROOF_QUOTE_SNAPSHOT' + }); + } + const lines = artifact.lines || {}; + const pickFields = (value, keys) => Object.fromEntries(keys + .filter(key => value?.[key] !== undefined) + .map(key => [key, value[key]])); + const projectFieldNames = [ + 'id', 'project_name', 'project_number', 'projectNumber', + 'customer_name', 'customer_number', 'customerNumber', 'customer_address', + 'project_address', 'customer_email', 'created_at', 'createdAt', + 'description', 'project_description' + ]; + const lineFieldNames = [ + 'name', 'material_name', 'task_name', 'taskName', 'rental_name', + 'description', 'product_text', 'labor_description', 'quantity', 'unit', + 'unitPrice', 'unit_price', 'price', 'lineTotal', 'line_total', 'total', + 'total_price', 'totalCost', 'totalHours', 'hours', 'rate', 'hourlyRate', 'hourly_rate' + ]; + const customerProject = pickFields(artifact.customerProject, projectFieldNames); + const customerLines = kind => (Array.isArray(lines[kind]) ? lines[kind] : []) + .map(line => pickFields(line, lineFieldNames)); + const materials = customerLines('materials'); + const rentals = customerLines('rentals'); + const references = customerLines('references'); + const tasks = customerLines('tasks'); + const reservations = (Array.isArray(artifact.reservations) ? artifact.reservations : []).map(value => { + if (typeof value !== 'object' || value === null) return value; + return pickFields(value, ['text', 'message']); + }); + const rawEconomics = artifact.economics || {}; + const enterpriseCosts = this.toNumber(rawEconomics.overheadAmount ?? rawEconomics.overhead_amount) + + this.toNumber(rawEconomics.profitAmount ?? rawEconomics.profit_amount ?? rawEconomics.totalProfit); + const economics = { + ...pickFields(rawEconomics, [ + 'materialTotal', 'materials', 'laborTotal', 'labor', 'rentalTotal', 'rentals', + 'referenceTotal', 'references', 'totalExclVat', 'total_excl_vat', 'subtotalWithProfit', + 'vatPercentage', 'vat_percentage', 'vatAmount', 'vat_amount', 'vat', 'tax', + 'totalInclVat', 'total_incl_vat', 'total' + ]), + enterpriseCosts + }; + const customerVisibleValues = [ + artifact.quoteText, + ...Object.values(customerProject), + ...materials, + ...tasks, + ...rentals, + ...references, + ...reservations + ].flatMap(value => { + if (value == null) return []; + if (typeof value !== 'object') return [String(value)]; + return ['name', 'material_name', 'task_name', 'rental_name', 'description', 'text', 'message'] + .map(key => value[key]) + .filter(item => typeof item === 'string'); + }); + assertCustomerDocumentLanguage(customerVisibleValues); + return { + project: customerProject, + geometry: pickFields(artifact.geometry, [ + 'roof_covering_area', 'roofArea', 'total_area', 'roof_pitch', 'roofPitch', 'roof_type', 'roofType' + ]), + materials, + rentals, + referenceServices: references, + labor: tasks, + tasks, + totals: economics, + quoteText: artifact.quoteText, + reservations, + packageInstances: [] + }; + } + + buildPdfData({ + projectData, + geometryData, + materialsData, + rentalsData, + referenceServicesData, + laborData, + totalsData, + quoteText, + reservations, + packageInstancesData + } = {}) { + const labor = Array.isArray(laborData) ? laborData : []; + return { + project: projectData || {}, + geometry: geometryData || {}, + materials: Array.isArray(materialsData) ? materialsData : [], + rentals: Array.isArray(rentalsData) ? rentalsData : [], + referenceServices: Array.isArray(referenceServicesData) ? referenceServicesData : [], + labor, + tasks: labor, + totals: totalsData || { laborTotal: 0, materialTotal: 0, subtotal: 0, vat: 0, total: 0 }, + quoteText, + reservations: reservations || projectData?.reservations || [], + packageInstances: Array.isArray(packageInstancesData) ? packageInstancesData : [] + }; + } + + normalizePackageInstances(workspaceJson) { + let workspace = workspaceJson; + if (typeof workspace === 'string') { + try { + workspace = JSON.parse(workspace); + } catch (error) { + return []; + } + } + const instances = Array.isArray(workspace) ? workspace : workspace?.instances; + if (!Array.isArray(instances)) return []; + + return instances.map(instance => { + const geometry = instance?.geometry || {}; + const manual = geometry.quantityMode === 'manual'; + const rawFormula = geometry.formula ?? instance?.formula ?? 'Ingen geometriformel'; + return { + instanceId: instance?.instanceId, + name: instance?.name, + formula: String(rawFormula).trim().replace(/\s+/g, ' '), + mode: manual ? 'Manuelt tilrettet' : 'Beregnet fra Geometri', + quantity: this.toNumber(manual ? geometry.manualQuantity : geometry.calculatedQuantity), + unit: geometry.unit || instance?.unit || 'stk' + }; + }); + } + + partitionPdfLines(materialLines = [], persistedRentals = []) { + const metadataFor = line => { + try { + const metadata = typeof line?.notes === 'string' ? JSON.parse(line.notes) : line?.notes; + return metadata && typeof metadata === 'object' ? metadata : {}; + } catch (error) { + return {}; + } + }; + const searchableText = line => ( + `${line?.material_name || line?.rental_name || line?.name || ''} ${line?.material_category || line?.rental_category || line?.category || ''}` + ); + const isReferenceService = line => { + const metadata = metadataFor(line); + const lineType = line?.lineType || line?.line_type || metadata.lineType || metadata.line_type; + return lineType === 'reference_service' || /referenceydelse|reference service/i.test(searchableText(line)); + }; + const isRental = line => !isReferenceService(line) + && /stillads|faldsikring|udlejning|\bleje\b/i.test(searchableText(line)); + const sourceMaterials = Array.isArray(materialLines) ? materialLines : []; + const sourcePersisted = Array.isArray(persistedRentals) ? persistedRentals : []; + const materials = sourceMaterials.filter(line => !isRental(line) && !isReferenceService(line)); + const keyFor = line => [ + line?.package_instance_id || line?.packageInstanceId || metadataFor(line).packageInstanceId || '', + line?.rental_name || line?.material_name || line?.name || '', + line?.rental_category || line?.material_category || line?.category || '', + this.toNumber(line?.quantity), + line?.unit || '', + this.toNumber(line?.unit_price ?? line?.unitPrice) + ].map(value => String(value).trim().toLocaleLowerCase('da-DK')).join('|'); + const unique = lines => { + const seen = new Set(); + return lines.filter(line => { + const key = keyFor(line); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }; + const rentals = unique([ + ...sourcePersisted.filter(isRental), + ...sourceMaterials.filter(isRental) + ]); + const referenceServices = unique([ + ...sourcePersisted.filter(isReferenceService), + ...sourceMaterials.filter(isReferenceService) + ]); + return { materials, rentals, referenceServices }; + } + + normalizeLaborBreakdown(workBreakdown, laborRecord = {}) { + let entries = workBreakdown; + if (typeof entries === 'string') { + try { + entries = JSON.parse(entries); + } catch (error) { + entries = []; + } + } + if (!Array.isArray(entries)) return []; + + return entries.map((entry, index) => { + const name = entry?.name || entry?.task || entry?.task_name || entry?.taskName || `Arbejdsopgave ${index + 1}`; + const hours = this.toNumber(entry?.totalHours ?? entry?.hours ?? entry?.estimatedHours); + const rate = this.toNumber(entry?.rate ?? entry?.hourlyRate ?? entry?.hourly_rate) + || this.toNumber(laborRecord?.hourly_rate ?? laborRecord?.hourlyRate) + || 580; + const rawTotalCost = entry?.totalCost ?? entry?.cost ?? entry?.total; + const totalCost = rawTotalCost !== undefined && rawTotalCost !== null + ? this.toNumber(rawTotalCost) + : hours * rate; + return { + task_name: name, + name, + description: entry?.description || entry?.notes || '', + totalHours: hours, + hours, + rate, + hourly_rate: rate, + totalCost, + total: totalCost + }; + }); + } + getLineTotal(line = {}) { + if (line.totalCost !== undefined && line.totalCost !== null) { + return this.toNumber(line.totalCost); + } if (line.total !== undefined && line.total !== null) { return this.toNumber(line.total); } + if (line.lineTotal !== undefined && line.lineTotal !== null) { + return this.toNumber(line.lineTotal); + } if (line.line_total !== undefined && line.line_total !== null) { return this.toNumber(line.line_total); } if (line.total_price !== undefined && line.total_price !== null) { return this.toNumber(line.total_price); } - const quantity = this.toNumber(line.quantity || line.hours || line.totalHours || 0); - const unitPrice = this.toNumber(line.unit_price || line.hourly_rate || line.hourlyRate || 0); + const quantity = this.toNumber(line.quantity ?? line.hours ?? line.totalHours ?? 0); + const unitPrice = this.toNumber(line.unit_price ?? line.unitPrice ?? line.hourly_rate ?? line.hourlyRate ?? line.rate ?? 0); return quantity * unitPrice; } @@ -316,7 +545,7 @@ class PdfGenerationService { block.laborHours += hours; block.laborTotal += lineTotal; block.laborLines.push({ - description: line.description || line.task_name || line.taskName || 'Arbejdsopgave', + description: line.description || line.task_name || line.taskName || line.name || 'Arbejdsopgave', hours, lineTotal }); @@ -407,17 +636,51 @@ class PdfGenerationService { return (pdfSource.match(/\/Type\s*\/Page\b/g) || []).length; } + assertCustomerSafeData(data = {}) { + const project = data.project || {}; + const projectKeys = [ + 'project_name', 'project_number', 'projectNumber', 'customer_name', + 'customer_number', 'customerNumber', 'customer_address', 'project_address', + 'customer_email', 'description', 'project_description' + ]; + const lineKeys = ['name', 'material_name', 'task_name', 'taskName', 'rental_name', 'description', 'text', 'message']; + const lineGroups = ['materials', 'labor', 'tasks', 'rentals', 'referenceServices']; + const values = [ + data.quoteText, + ...projectKeys.map(key => project[key]), + ...(Array.isArray(data.reservations) ? data.reservations : []), + ...lineGroups.flatMap(group => (Array.isArray(data[group]) ? data[group] : [])) + ].flatMap(value => { + if (value == null) return []; + if (typeof value !== 'object') return [value]; + return lineKeys.map(key => value[key]).filter(item => typeof item === 'string'); + }); + assertCustomerDocumentLanguage(values); + } + // Generate HTML for PDF async generatePdfHtml(data) { try { + this.assertCustomerSafeData(data); // Load logos const logos = await this.loadLogos(); // Prepare data const { project, geometry, labor, materials, totals } = data; const rentals = Array.isArray(data.rentals) ? data.rentals : []; - const quoteNumber = `${Date.now().toString().slice(-4)}`; - const currentDate = new Date().toLocaleDateString('da-DK'); + const referenceServices = Array.isArray(data.referenceServices) ? data.referenceServices : []; + + const escapeHtml = value => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const quoteNumber = String(project.project_number || project.projectNumber || `P-${project.id}`); + const issuedAt = new Date(project.created_at || project.createdAt || Date.now()); + const validUntil = new Date(issuedAt.getTime() + (30 * 24 * 60 * 60 * 1000)); + const currentDate = issuedAt.toLocaleDateString('da-DK'); + const validUntilDate = validUntil.toLocaleDateString('da-DK'); // Company information const COMPANY_INFO = { @@ -432,24 +695,22 @@ class PdfGenerationService { website: "www.mikaelholck.dk" }; - const taskBlocks = this.getTaskBlocks(data); - const standardText = this.getStandardTextModules(); const reservations = Array.isArray(data.reservations) ? data.reservations : (Array.isArray(project.reservations) ? project.reservations : []); const workDescription = ((await this.generateWorkDescription(data)) || '') - .split('\n') - .filter(Boolean); + .split('\n'); // Format customer address properly const customerAddress = project.customer_address || project.project_address || ''; + const isDraftDemo = /\b(?:draft|demo)\b/i.test([ + project.project_name, project.customer_name, project.project_description, data.quoteText + ].filter(Boolean).join(' ')); const laborTotal = this.toNumber(totals?.laborTotal ?? totals?.labor); const materialTotal = this.toNumber(totals?.materialTotal ?? totals?.materials); const rentalTotal = this.toNumber(totals?.rentalTotal ?? totals?.rentals); - const subtotal = this.toNumber(totals?.subtotal); - const overheadPercentage = this.toNumber(totals?.overheadPercentage ?? totals?.overhead_percentage); + const referenceTotal = this.toNumber(totals?.referenceTotal ?? totals?.references); const overheadAmount = this.toNumber(totals?.overheadAmount ?? totals?.overhead_amount); - const profitPercentage = this.toNumber(totals?.profitPercentage ?? totals?.profit_percentage); const profitAmount = this.toNumber(totals?.profitAmount ?? totals?.profit_amount ?? totals?.totalProfit); const totalExclVat = this.toNumber(totals?.totalExclVat ?? totals?.total_excl_vat ?? totals?.subtotalWithProfit); const vatPercentage = this.toNumber(totals?.vatPercentage ?? totals?.vat_percentage) || 25; @@ -458,56 +719,84 @@ class PdfGenerationService { const logoLeft = logos.mikhaelLogo ? `Mikael Holck Logo` : ''; const logoRight = logos.bygGarantiLogo ? `Byg Garanti Logo` : ''; - const taskBlocksHtml = taskBlocks.length > 0 - ? taskBlocks.map((block) => { - const laborLinesHtml = block.laborLines.length > 0 - ? block.laborLines.map((line) => ` -
  • - ${line.description} (${line.hours.toFixed(1)} timer) - ${this.formatCurrency(line.lineTotal)} kr -
  • - `).join('') - : ''; - - const materialLinesHtml = block.materialLines.length > 0 - ? block.materialLines.slice(0, 12).map((line) => ` -
  • - ${line.description} (${line.quantity.toFixed(1)} ${line.unit}) - ${this.formatCurrency(line.lineTotal)} kr -
  • - `).join('') - : ''; - - return ` -
    -
    -

    OPGAVE ${block.index}: ${block.title}

    - ${this.formatCurrency(block.subtotal)} kr -
    - ${laborLinesHtml ? `
      ${laborLinesHtml}
    ` : ''} - ${materialLinesHtml ? ` -
    -

    Materialer

    -
      ${materialLinesHtml}
    -
    - ` : ''} -
    - `; - }).join('\n') - : '

    Ingen opgaveblokke fundet. Tilbuddet er opbygget på samlet beregning.

    '; - - const rentalsHtml = rentals.length > 0 - ? `

    UDLEJNING OG ØVRIGE YDELSER

      ${rentals.map(line => ( - `
    • ${line.name || line.rental_name || line.material_name || 'Ydelse'} (${this.toNumber(line.quantity).toFixed(1)} ${line.unit || 'enhed'}) - ${this.formatCurrency(this.getLineTotal(line))} kr
    • ` - )).join('')}
    ` - : ''; + const formatQuantity = value => this.toNumber(value).toLocaleString('da-DK', { + minimumFractionDigits: 0, + maximumFractionDigits: 3 + }); + const formatUnitPrice = (quantity, rawUnitPrice, lineTotal) => { + const qty = Number(this.toNumber(quantity).toFixed(3)); + const totalValue = this.toNumber(lineTotal); + const exactPrice = qty ? totalValue / qty : this.toNumber(rawUnitPrice); + for (let decimals = 2; decimals <= 6; decimals += 1) { + const displayed = Number(exactPrice.toFixed(decimals)); + if (Math.round(qty * displayed * 100) / 100 === Math.round(totalValue * 100) / 100) { + return displayed.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: decimals }); + } + } + return exactPrice.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 6 }); + }; + const salesTable = ({ title, rows, quantityFor, unitFor, priceFor, totalFor, nameFor }) => { + if (!rows.length) return ''; + return `
    +

    ${escapeHtml(title)}

    + + + ${rows.map(line => { + const quantity = this.toNumber(quantityFor(line)); + const unit = unitFor(line); + const unitPrice = this.toNumber(priceFor(line)); + const lineTotal = this.toNumber(totalFor(line)); + return ` + + + + + `; + }).join('')} +
    BeskrivelseAntalEnhedspris ekskl. momsBeløb ekskl. moms
    ${escapeHtml(nameFor(line))}${formatQuantity(quantity)} ${escapeHtml(unit)}${formatUnitPrice(quantity, unitPrice, lineTotal)} kr${this.formatCurrency(lineTotal)} kr
    +
    `; + }; + const materialTableHtml = salesTable({ + title: 'Materialer og produkter', rows: materials, + quantityFor: line => line.quantity, + unitFor: line => line.unit || 'stk', + priceFor: line => line.unitPrice ?? line.unit_price ?? line.price, + totalFor: line => this.getLineTotal(line), + nameFor: line => line.name || line.material_name || line.description || 'Materiale' + }); + const laborTableHtml = salesTable({ + title: 'Arbejde', rows: Array.isArray(labor) ? labor : [], + quantityFor: line => line.totalHours ?? line.hours ?? line.quantity, + unitFor: () => 'timer', + priceFor: line => line.rate ?? line.hourlyRate ?? line.hourly_rate, + totalFor: line => this.getLineTotal(line), + nameFor: line => line.name || line.task_name || line.description || 'Arbejde' + }); + const rentalTableHtml = salesTable({ + title: 'Leje og materiel', rows: rentals, + quantityFor: line => line.quantity, + unitFor: line => line.unit || 'stk', + priceFor: line => line.unitPrice ?? line.unit_price ?? line.price, + totalFor: line => this.getLineTotal(line), + nameFor: line => line.name || line.rental_name || line.material_name || 'Leje' + }); + const referenceTableHtml = salesTable({ + title: 'Øvrige ydelser', rows: referenceServices, + quantityFor: line => line.quantity, + unitFor: line => line.unit || 'sum', + priceFor: line => line.unitPrice ?? line.unit_price ?? line.price, + totalFor: line => this.getLineTotal(line), + nameFor: line => line.name || line.rental_name || line.material_name || 'Ydelse' + }); + const enterpriseCosts = this.toNumber(totals?.enterpriseCosts) + || overheadAmount + profitAmount; const html = ` - Tilbud ${quoteNumber} + Tilbud ${escapeHtml(quoteNumber)} + ${isDraftDemo ? '
    DRAFT / DEMO
    ' : ''}
    ${logoLeft}
    ${COMPANY_INFO.name}
    @@ -686,68 +1059,62 @@ class PdfGenerationService {
    - ${project.customer_name}
    - ${customerAddress} + ${escapeHtml(project.customer_name)}
    + ${escapeHtml(customerAddress)}
    -

    TILBUD

    +

    Tilbud ${escapeHtml(quoteNumber)}

    -
    Tilbudnr. ............ ${quoteNumber}
    -
    Tilbudsdato ........ ${currentDate}
    -
    Kundenr. ........... ${project.customer_number || project.customerNumber || 'Ikke angivet'}
    -
    Side ............... Se footer
    -
    -
    Rekvirent: .........
    -
    Reference: .........
    +
    Dato: ${currentDate}
    + ${(project.customer_number || project.customerNumber) ? `
    Kundenr.: ${escapeHtml(project.customer_number || project.customerNumber)}
    ` : ''}
    -
    ${customerAddress}
    -
    ${project.project_name}
    +
    ${escapeHtml(customerAddress)}
    +
    ${escapeHtml(project.project_name)}
    - ARBEJDE DER UDFØRES
    - ${workDescription.map((line) => `${line}
    `).join('')} + Det aftalte arbejde
    + ${workDescription.map((line) => `${escapeHtml(line)}
    `).join('')} ${(geometry?.roof_covering_area || geometry?.roofArea || geometry?.total_area) ? `
    Tagbeklædningsareal (prisgrundlag): ${this.toNumber(geometry.roof_covering_area || geometry.roofArea || geometry.total_area).toFixed(1)} m²` : ''} ${(geometry?.roof_pitch || geometry?.roofPitch) ? `
    Taghældning: ${this.toNumber(geometry.roof_pitch || geometry.roofPitch).toFixed(0)}°` : ''}
    -
    - OPGAVER OG ARBEJDSLØN - ${taskBlocksHtml} - ${rentalsHtml} -
    + ${materialTableHtml} + ${laborTableHtml} + ${rentalTableHtml} + ${referenceTableHtml}
    - PRISSPECIFIKATION + Prisoversigt
    Materialer${this.formatCurrency(materialTotal)} kr
    -
    Udlejning og øvrige ydelser${this.formatCurrency(rentalTotal)} kr
    -
    Arbejdsløn${this.formatCurrency(laborTotal)} kr
    -
    Direkte subtotal${this.formatCurrency(subtotal)} kr
    -
    Overhead (${overheadPercentage}%)${this.formatCurrency(overheadAmount)} kr
    -
    Dækningsbidrag (${profitPercentage}%)${this.formatCurrency(profitAmount)} kr
    +
    Arbejde${this.formatCurrency(laborTotal)} kr
    + ${rentalTotal ? `
    Leje og materiel${this.formatCurrency(rentalTotal)} kr
    ` : ''} + ${referenceTotal ? `
    Øvrige ydelser${this.formatCurrency(referenceTotal)} kr
    ` : ''} + ${enterpriseCosts ? `
    Entrepriseomkostninger (fast)${this.formatCurrency(enterpriseCosts)} kr
    ` : ''}
    Pris ekskl. moms${this.formatCurrency(totalExclVat)} kr
    Moms (${vatPercentage}%)${this.formatCurrency(vat)} kr
    -
    SAMLET PRIS INKL. MOMS${this.formatCurrency(total)} kr
    -
    Momspligtigt beløb: ${this.formatCurrency(totalExclVat)} kr
    +
    Samlet pris inkl. moms${this.formatCurrency(total)} kr
    +
    Alle priser er i danske kroner. Moms er opgjort særskilt med ${vatPercentage}%.
    - VORES LØFTE TIL DIG -
      - ${standardText.promisePoints.map((line) => `
    • ${line}
    • `).join('')} -
    -
    - GARANTIER OG SERVICE -
      - ${standardText.guarantees.map((line) => `
    • ${line}
    • `).join('')} -
    -

    ${standardText.validity}

    - ${reservations.length > 0 ? `FORBEHOLD
      ${reservations.map(line => `
    • ${line}
    • `).join('')}
    ` : ''} +

    Vilkår og forbehold

    +

    Tilbuddet er gyldigt til ${validUntilDate}. Arbejdet planlægges efter skriftlig accept.

    + ${reservations.length > 0 ? `
      ${reservations.map(line => `
    • ${escapeHtml(line?.text ?? line?.message ?? line)}
    • `).join('')}
    ` : '

    Der er ikke registreret yderligere forbehold.

    '}
    +
    + Accept af tilbud +

    Jeg accepterer tilbuddet med det beskrevne omfang, de anførte priser og forbehold.

    +
    +
    Dato
    +
    Kundens navn og underskrift
    +
    +
    +