From a4fd166c51df352b7abbfd37980c28ef5b370a4b Mon Sep 17 00:00:00 2001 From: phaichayon Date: Tue, 30 Jun 2026 15:53:11 +0700 Subject: [PATCH] task-p.7 --- .../pdf-audit/payload-parity-report.json | 8 +- .../pdf-audit/runtime-payload-report.json | 36 +- .../pdf-audit/signature-audit-report.json | 6 +- artifacts/pdf-audit/summary.json | 4 +- artifacts/pdf-audit/summary.md | 4 +- .../pdf-audit/template-version-report.json | 2 +- artifacts/pdf-audit/topic-runtime-report.json | 2 +- docs/implementation/pdf-audit-report.json | 6 +- docs/implementation/pdf-audit-report.md | 4 +- .../implementation/task-p.7-implementation.md | 110 +++ .../task-p.7-verification-report.md | 70 ++ next-env.d.ts | 2 +- plans/task-p.7.md | 364 ++++++++++ .../[id]/assembled-pdf/download/route.ts | 58 ++ .../[id]/assembled-pdf/generate/route.ts | 62 ++ .../quotations/[id]/assembled-pdf/route.ts | 57 ++ .../document-artifact/server/service.ts | 2 +- .../document-assembly/server/helpers.ts | 89 +++ .../document-assembly/server/service.test.ts | 96 +++ .../document-assembly/server/service.ts | 661 ++++++++++++++++++ .../foundation/document-assembly/types.ts | 65 ++ .../warranty-onvalla-dock-door-en-v1.0.pdf | Bin 0 -> 1345 bytes .../company-profile-alla-all-th-2025.12.pdf | Bin 0 -> 1359 bytes .../company-profile-alla-all-th-2026.01.pdf | Bin 0 -> 1354 bytes .../sla-alla-crane-th-v1.0.pdf | Bin 0 -> 1332 bytes .../sla-alla-crane-th-v1.1.pdf | Bin 0 -> 1334 bytes 26 files changed, 1670 insertions(+), 38 deletions(-) create mode 100644 docs/implementation/task-p.7-implementation.md create mode 100644 docs/implementation/task-p.7-verification-report.md create mode 100644 plans/task-p.7.md create mode 100644 src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts create mode 100644 src/app/api/crm/quotations/[id]/assembled-pdf/generate/route.ts create mode 100644 src/app/api/crm/quotations/[id]/assembled-pdf/route.ts create mode 100644 src/features/foundation/document-assembly/server/helpers.ts create mode 100644 src/features/foundation/document-assembly/server/service.test.ts create mode 100644 src/features/foundation/document-assembly/server/service.ts create mode 100644 src/features/foundation/document-assembly/types.ts create mode 100644 storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/82e0ba18-dd3a-4dcf-b612-e9af58ea0110/286560f3-7d6d-4ec8-bf31-f6707824e739/warranty-onvalla-dock-door-en-v1.0.pdf create mode 100644 storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/611d6f1f-f9df-4b14-8588-e085e42d012d/company-profile-alla-all-th-2025.12.pdf create mode 100644 storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/7f16e09a-0ba4-4848-8d87-1fa10c75bc3c/company-profile-alla-all-th-2026.01.pdf create mode 100644 storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/70460d62-3fee-4c27-bb98-382a6a8440b1/sla-alla-crane-th-v1.0.pdf create mode 100644 storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/f0acfae8-c8dd-4fe9-a65a-8aa3ece4d1a7/sla-alla-crane-th-v1.1.pdf diff --git a/artifacts/pdf-audit/payload-parity-report.json b/artifacts/pdf-audit/payload-parity-report.json index 3422dc9..02db715 100644 --- a/artifacts/pdf-audit/payload-parity-report.json +++ b/artifacts/pdf-audit/payload-parity-report.json @@ -1,10 +1,10 @@ { "audit": "payload-parity", "overallStatus": "PASS", - "quotationCode": "CR2606-1124", - "previewHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7", - "generationHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7", - "snapshotHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7", + "quotationCode": "CRA2606-1108", + "previewHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26", + "generationHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26", + "snapshotHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26", "mismatches": [], "comparableFieldCount": 31, "topicInputKeyCount": 6, diff --git a/artifacts/pdf-audit/runtime-payload-report.json b/artifacts/pdf-audit/runtime-payload-report.json index c4bf3bf..46001f2 100644 --- a/artifacts/pdf-audit/runtime-payload-report.json +++ b/artifacts/pdf-audit/runtime-payload-report.json @@ -2,7 +2,7 @@ "audit": "runtime-payload", "overallStatus": "PASS", "staticLabelsStatus": "PASS", - "quotationCode": "CR2606-1124", + "quotationCode": "CRA2606-1108", "templateName": "ALLA Demo Organization Quotation Standard", "templateVersion": "1.0", "findingCount": 168, @@ -25,7 +25,7 @@ "field": "documentData.customer.id", "source": "documentData", "status": "PASS", - "value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a", + "value": "85928568-22f2-424c-8896-a8e107ce3738", "message": "String is populated" }, { @@ -60,7 +60,7 @@ "field": "documentData.contact.id", "source": "documentData", "status": "PASS", - "value": "951023ad-0424-4c05-9db1-c5004d525e23", + "value": "307bcf2f-46df-4ab7-b090-872b99338bf1", "message": "String is populated" }, { @@ -95,14 +95,14 @@ "field": "documentData.quotation.id", "source": "documentData", "status": "PASS", - "value": "9a7333a0-bb43-4d6e-b178-aae196456a31", + "value": "f9290e40-0836-431d-9d9f-0ff25fa2bcc3", "message": "String is populated" }, { "field": "documentData.quotation.code", "source": "documentData", "status": "PASS", - "value": "CR2606-1124", + "value": "CRA2606-1108", "message": "String is populated" }, { @@ -172,7 +172,7 @@ "field": "documentData.items[0].id", "source": "documentData", "status": "PASS", - "value": "81cf1a1a-0ae0-4f6d-bb24-1315b0de5363", + "value": "2822652d-cab6-4b00-8b21-93a8b0c3d33f", "message": "String is populated" }, { @@ -228,7 +228,7 @@ "field": "documentData.items[1].id", "source": "documentData", "status": "PASS", - "value": "3cfad159-e274-49f4-a195-0e3ae39ee5f9", + "value": "d080c242-c39b-4192-adab-f28fc0171389", "message": "String is populated" }, { @@ -284,7 +284,7 @@ "field": "documentData.items[2].id", "source": "documentData", "status": "PASS", - "value": "3f48546a-2685-462e-a21d-e25ccc1348fd", + "value": "de54b922-db69-42e3-a93c-940330852362", "message": "String is populated" }, { @@ -340,7 +340,7 @@ "field": "documentData.items[3].id", "source": "documentData", "status": "PASS", - "value": "9f0ef323-dbfc-4710-a90f-501f185a165a", + "value": "c903ac48-45c5-43b1-8948-1a196ec0d576", "message": "String is populated" }, { @@ -403,21 +403,21 @@ "field": "documentData.quotationCustomers[0].id", "source": "documentData", "status": "PASS", - "value": "fefd5c1a-cc0a-4365-9492-e00feef8d38e", + "value": "c15f26c7-2f23-44fc-84e2-0a435901f887", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].customerId", "source": "documentData", "status": "PASS", - "value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a", + "value": "85928568-22f2-424c-8896-a8e107ce3738", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].role", "source": "documentData", "status": "PASS", - "value": "ed72c8c9-a690-4c93-afb7-9720a5fc7b3d", + "value": "a6170538-f685-4f8d-bf77-36ae82f87376", "message": "String is populated" }, { @@ -438,7 +438,7 @@ "field": "documentData.topics.all[0].id", "source": "documentData", "status": "PASS", - "value": "e6e4b61e-24a4-4ca9-a1d0-2cd3981ab59c", + "value": "396189b7-ca6c-4fc4-80dd-04f3f903f77b", "message": "String is populated" }, { @@ -501,7 +501,7 @@ "field": "documentData.topics.all[1].id", "source": "documentData", "status": "PASS", - "value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9", + "value": "41b30c9e-2adb-4407-b5f6-f803cc08bdb4", "message": "String is populated" }, { @@ -564,7 +564,7 @@ "field": "documentData.topics.all[2].id", "source": "documentData", "status": "PASS", - "value": "85d82f36-3e8f-4e46-8357-84879c728a49", + "value": "1ef3d0b6-b722-42e1-b8c1-e328fbb1bb57", "message": "String is populated" }, { @@ -634,7 +634,7 @@ "field": "documentData.topics.exclusion[0].id", "source": "documentData", "status": "PASS", - "value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9", + "value": "41b30c9e-2adb-4407-b5f6-f803cc08bdb4", "message": "String is populated" }, { @@ -921,7 +921,7 @@ "field": "templateInput.quotation_code_data", "source": "templateInput", "status": "PASS", - "value": "CR2606-1124", + "value": "CRA2606-1108", "message": "String is populated" }, { @@ -1075,7 +1075,7 @@ "field": "templateInput.quotation_code", "source": "templateInput", "status": "PASS", - "value": "CR2606-1124", + "value": "CRA2606-1108", "message": "String is populated" }, { diff --git a/artifacts/pdf-audit/signature-audit-report.json b/artifacts/pdf-audit/signature-audit-report.json index a1d1ea1..5843580 100644 --- a/artifacts/pdf-audit/signature-audit-report.json +++ b/artifacts/pdf-audit/signature-audit-report.json @@ -1,10 +1,10 @@ { "audit": "approved-snapshot-parity", "overallStatus": "PASS", - "quotationCode": "CR2606-1124", + "quotationCode": "CRA2606-1108", "approvedSnapshotAvailable": true, - "approvedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9", - "computedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9", + "approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", + "computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", "inconsistentTemplateKeys": [], "computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229", "storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229", diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json index 15f2bd8..97b502d 100644 --- a/artifacts/pdf-audit/summary.json +++ b/artifacts/pdf-audit/summary.json @@ -1,7 +1,7 @@ { - "generatedAt": "2026-06-30T05:56:31.314Z", + "generatedAt": "2026-06-30T08:23:40.205Z", "overallStatus": "PASS", - "quotationCode": "CR2606-1124", + "quotationCode": "CRA2606-1108", "artifacts": { "templateVersionReport": "template-version-report.json", "placeholderIntegrityReport": "placeholder-integrity-report.json", diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md index d185cbd..4b279ee 100644 --- a/artifacts/pdf-audit/summary.md +++ b/artifacts/pdf-audit/summary.md @@ -1,8 +1,8 @@ # PDF Integrity Summary -- Generated At: 2026-06-30T05:56:31.314Z +- Generated At: 2026-06-30T08:23:40.205Z - Overall Status: PASS -- Quotation Code: CR2606-1124 +- Quotation Code: CRA2606-1108 ## Statuses diff --git a/artifacts/pdf-audit/template-version-report.json b/artifacts/pdf-audit/template-version-report.json index 090fa52..e460b67 100644 --- a/artifacts/pdf-audit/template-version-report.json +++ b/artifacts/pdf-audit/template-version-report.json @@ -2,7 +2,7 @@ "audit": "template-version-integrity", "overallStatus": "PASS", "activeTemplateCount": 1, - "quotationCode": "CR2606-1124", + "quotationCode": "CRA2606-1108", "approvedTemplateVersionId": null, "approvedTemplateVersionIsActive": false, "issues": [] diff --git a/artifacts/pdf-audit/topic-runtime-report.json b/artifacts/pdf-audit/topic-runtime-report.json index d324084..fd65a3d 100644 --- a/artifacts/pdf-audit/topic-runtime-report.json +++ b/artifacts/pdf-audit/topic-runtime-report.json @@ -1,7 +1,7 @@ { "audit": "topic-runtime", "overallStatus": "PASS", - "quotationCode": "CR2606-1124", + "quotationCode": "CRA2606-1108", "topicCount": 3, "topicInputKeys": [ "topic_1_0", diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json index ae2e14b..6879a8e 100644 --- a/docs/implementation/pdf-audit-report.json +++ b/docs/implementation/pdf-audit-report.json @@ -1,12 +1,12 @@ { - "generatedAt": "2026-06-30T05:56:30.085Z", + "generatedAt": "2026-06-30T08:23:36.291Z", "overallStatus": "PASS", - "quotationCode": "CR2606-1124", + "quotationCode": "CRA2606-1108", "expectedFixtureCode": "QT-H5-AUDIT", "template": { "templateName": "ALLA Demo Organization Quotation Standard", "version": "1.0", - "versionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9", + "versionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", "fieldCount": 44, "previousVersion": null, "deletedFields": [], diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md index b95bb9d..d67b5c3 100644 --- a/docs/implementation/pdf-audit-report.md +++ b/docs/implementation/pdf-audit-report.md @@ -1,8 +1,8 @@ # PDF Audit Report -- Generated At: 2026-06-30T05:56:30.085Z +- Generated At: 2026-06-30T08:23:36.291Z - Overall Status: PASS -- Quotation Code: CR2606-1124 +- Quotation Code: CRA2606-1108 - Template: ALLA Demo Organization Quotation Standard v1.0 - Previous Version: None diff --git a/docs/implementation/task-p.7-implementation.md b/docs/implementation/task-p.7-implementation.md new file mode 100644 index 0000000..226acbc --- /dev/null +++ b/docs/implementation/task-p.7-implementation.md @@ -0,0 +1,110 @@ +# Task P.7 Implementation Report + +## Scope +This report documents implementation completed for +[task-p.7.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.7.md). + +Focus round: +- add reusable Document Assembly foundation on top of existing PDF and storage runtime +- integrate default quotation assembly flow +- store assembled PDFs as separate artifacts +- record implementation and verification outputs + +--- + +## Review Inputs +Reviewed before implementation: +- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md) +- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md) +- [docs/adr/0008-attachment-storage-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0008-attachment-storage-strategy.md) +- [docs/adr/0009-approved-document-storage-lifecycle.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0009-approved-document-storage-lifecycle.md) +- [docs/adr/0013-pdf-visual-parity-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0013-pdf-visual-parity-strategy.md) +- [docs/implementation/task-p.6-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.6-implementation.md) + +Foundations reused: +- quotation PDF generation from [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts) +- artifact lifecycle from [src/features/foundation/document-artifact/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-artifact/server/service.ts) +- static append document storage from [src/features/foundation/document-library](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-library) + +--- + +## What Changed +### 1. New generic assembly foundation +Added [src/features/foundation/document-assembly](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly) with: +- policy model for ordered `generated` and `document_library` sources +- deterministic document-library resolution using brand/product-type fallback rules +- PDF readability validation and page counting +- server-side merge using `@pdfme/pdf-lib` +- optional-source warnings and required-source business errors + +### 2. Separate assembled artifact lifecycle +Assembled quotation PDFs now persist as a separate `assembled_pdf` artifact and do not replace: +- preview/download quotation runtime output +- approved quotation PDF artifact + +Stored metadata includes: +- source quotation id and code +- resolved assembly policy +- resolved source artifacts +- warnings +- checksum, file size, page count +- generation timestamp and actor + +### 3. Quotation integration routes +Added quotation endpoints: +- [src/app/api/crm/quotations/[id]/assembled-pdf/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts) +- [src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts) + +Behavior: +- `POST` generates and persists assembled quotation PDF +- `GET` previews stored assembled PDF inline +- `GET /download` returns stored assembled PDF as attachment + +### 4. Default quotation policy +Initial policy now resolves: +1. generated quotation PDF +2. active SLA document as required +3. active warranty as optional +4. datasheet as optional +5. drawing as optional + +Brand and product type are normalized from quotation/runtime data so legacy codes such as `dockdoor` and `solarcell` align with document-library dimensions. + +--- + +## Design Decisions +### Keep approved PDF flow unchanged +P.7 adds a new assembly layer instead of altering the existing approved-PDF runtime. This keeps prior acceptance from P.4-P.6 stable. + +### Prefer artifact metadata over new schema +`crm_document_artifacts.metadata` already supports rich JSON payloads, so P.7 can store assembly provenance without a migration. + +### Deterministic fallback over manual selection +Document-library resolution currently prefers: +- exact brand over `generic` +- exact product type over `all` +- then stable code ordering + +This gives predictable output now while keeping room for future render configuration UI. + +--- + +## Deliverables Completed +- generic document assembly service and types +- document-library resolution and PDF merge helpers +- quotation assembled PDF generation and retrieval flow +- new assembled preview/download APIs +- focused assembly tests +- implementation and verification reports + +--- + +## Known Gaps +- No UI trigger was added in this round; API/service foundation is ready first. +- Datasheet and drawing remain optional and depend on library coverage being seeded or uploaded. +- Assembly policy is code-based for now; no admin configuration UI yet. + +--- + +## Outcome +P.7 now gives the system a reusable server-side PDF assembly foundation and enables quotation PDFs to be merged with active document-library appendices into a separately stored customer-facing artifact. diff --git a/docs/implementation/task-p.7-verification-report.md b/docs/implementation/task-p.7-verification-report.md new file mode 100644 index 0000000..57a2e32 --- /dev/null +++ b/docs/implementation/task-p.7-verification-report.md @@ -0,0 +1,70 @@ +# Task P.7 Verification Report + +## Scope +This report verifies implementation for +[task-p.7.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.7.md). + +Verified focus round: +- assembly foundation compile safety +- merge and validation behavior +- quotation API integration +- artifact separation from approved PDF flow + +--- + +## Files Verified +Primary implementation files: +- [src/features/foundation/document-assembly/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly/types.ts) +- [src/features/foundation/document-assembly/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly/server/service.ts) +- [src/features/foundation/document-assembly/server/service.test.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly/server/service.test.ts) +- [src/app/api/crm/quotations/[id]/assembled-pdf/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts) +- [src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts) + +Related reused foundations spot-checked: +- [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts) +- [src/features/foundation/document-artifact/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-artifact/server/service.ts) +- [src/features/foundation/document-library/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-library/server/service.ts) + +--- + +## Commands +Verification commands intended for this round: + +```bash +npm run typecheck +npm exec tsx --test src/features/foundation/document-assembly/server/service.test.ts +npm run build +npm run audit:pdf +``` + +Actual results on 2026-06-30: +- `npm run typecheck` = PASS +- `npm exec tsx --test src/features/foundation/document-assembly/server/service.test.ts` = PASS +- `npm run audit:pdf` = PASS +- `npm run build` = NOT COMPLETED because Next.js reported another build process already running while the workspace also had an active `next dev` session using the same `.next` tree + +--- + +## Acceptance Check +Current implementation coverage: +- generic assembly engine exists = IMPLEMENTED +- supports generated PDF plus document-library PDFs = IMPLEMENTED +- quotation can generate assembled PDF = IMPLEMENTED +- assembled PDF stored separately from approved PDF = IMPLEMENTED +- deterministic document ordering = IMPLEMENTED +- required missing document fails clearly = IMPLEMENTED +- optional missing document warns and skips = IMPLEMENTED +- audit trail added for assembly lifecycle = IMPLEMENTED + +Items still dependent on runtime verification: +- clean `npm run build` pass with no concurrent Next process = BLOCKED BY ACTIVE WORKSPACE PROCESS +- live seeded document-library scenario = PENDING + +--- + +## Residual Risk +Remaining risk after implementation: +- current default language is fixed to `th`, so multilingual assembly still needs a future selection rule or UI +- only focused unit coverage was added; no browser E2E round yet +- real assembly success for optional datasheet/drawing depends on document-library content coverage per organization +- production build verification should be rerun once the active local Next process no longer owns the shared build directory diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/plans/task-p.7.md b/plans/task-p.7.md new file mode 100644 index 0000000..a6e9cdc --- /dev/null +++ b/plans/task-p.7.md @@ -0,0 +1,364 @@ +# Task P.7 - Document Assembly Framework + +## Objective + +Build a generic Document Assembly Framework that can merge generated CRM PDFs with static PDF documents from the Document Library. + +Primary use case: + +```text +Quotation PDF ++ SLA PDF ++ Warranty PDF ++ Datasheet PDF += Final Customer PDF +``` + +This task must be generic and reusable for future document types. + +--- + +# Prerequisites + +Completed: + +* P.4 PDF Runtime +* P.5 Document Template Management +* P.5.1 Production Hardening +* P.6 Document Library Foundation +* P.6.1 Document Sequence Scope Refactor + +--- + +# Scope + +Included: + +* Generic assembly engine +* PDF merge using pdfme manipulator or approved merge library +* Assembly policy model +* Document source resolution +* Document ordering +* Storage of final assembled artifact +* Quotation integration +* Audit and regression checks + +Excluded: + +* Render Configuration UI +* User-selectable append options +* Template Designer +* New PDF template design +* Editing SLA/Warranty contents + +--- + +# Core Concept + +The framework must not be hardcoded to Quotation only. + +Design around: + +```text +Input Documents +↓ +Assembly Policy +↓ +Assembly Engine +↓ +Merged PDF +↓ +Final Artifact +``` + +--- + +# Supported Input Sources + +The assembly engine must support these sources: + +## 1. Generated Document + +Example: + +* Quotation PDF generated from PDF Runtime + +## 2. Document Library PDF + +Example: + +* SLA +* Warranty +* Company Profile +* Datasheet +* Drawing +* Certificate + +## 3. Future External Attachment + +Reserved for later phases. + +--- + +# Assembly Policy + +Create an assembly policy model. + +Example: + +```ts +interface AssemblyPolicy { + documentType: string; + organizationId: string; + productType?: string; + brand?: string; + language?: string; + sources: AssemblySourcePolicy[]; +} +``` + +```ts +interface AssemblySourcePolicy { + sourceType: 'generated' | 'document_library'; + role: 'main' | 'sla' | 'warranty' | 'datasheet' | 'drawing' | 'appendix'; + required: boolean; + order: number; + libraryCode?: string; +} +``` + +--- + +# Default Quotation Assembly + +Initial quotation policy: + +```text +1. Generated Quotation PDF +2. SLA PDF +3. Warranty PDF +4. Datasheet / Drawing if configured +``` + +Do not make append documents user-selectable yet. + +Use default policy only. + +--- + +# Document Resolution + +The engine must resolve Document Library versions by: + +* organization +* brand +* product type +* language +* document type +* active version + +Resolution must be deterministic. + +If multiple candidates exist, apply clear priority rules. + +--- + +# Missing Document Behavior + +If a required append document is missing: + +* assembly must fail with a business-readable error + +If an optional append document is missing: + +* skip and record warning + +--- + +# PDF Merge + +Use pdfme manipulator merge capability or an approved server-side PDF merge utility. + +Requirements: + +* preserve page order +* preserve all pages +* support multiple input PDFs +* return final merged PDF bytes +* avoid mutating source PDFs + +--- + +# Final Artifact + +Store the assembled PDF as a new artifact. + +Metadata should include: + +* id +* organizationId +* sourceDocumentType +* sourceDocumentId +* sourceDocumentCode +* assemblyPolicy +* sourceArtifacts +* finalFileName +* storageKey +* checksum +* fileSize +* pageCount +* generatedAt +* generatedBy + +Do not overwrite approved quotation PDF artifact. + +The assembled artifact is a separate output. + +--- + +# Quotation Integration + +Add quotation-level service support: + +```text +generate quotation PDF +↓ +resolve assembly policy +↓ +resolve append documents +↓ +merge +↓ +persist final assembled artifact +↓ +return preview/download +``` + +Recommended API endpoints: + +```text +GET /api/crm/quotations/[id]/assembled-pdf +POST /api/crm/quotations/[id]/assembled-pdf/generate +GET /api/crm/quotations/[id]/assembled-pdf/download +``` + +--- + +# Storage + +Reuse existing storage abstraction. + +Do not store merged PDF binary in database. + +Store: + +* storage key +* checksum +* file size +* page count +* metadata + +--- + +# Audit Logging + +Record audit logs for: + +* assembly started +* source document resolved +* source document missing +* merge completed +* artifact stored +* assembly failed +* assembled artifact downloaded + +--- + +# Validation + +Validate before merge: + +* generated main PDF exists +* append PDF exists +* file is PDF +* file can be read +* page count can be determined +* source order is valid +* required source documents exist + +--- + +# Testing Requirements + +Add tests for: + +* generated-only assembly +* quotation + SLA +* quotation + SLA + Warranty +* optional missing document +* required missing document +* invalid source PDF +* page order +* page count +* checksum generation +* artifact persistence metadata + +Run: + +```bash +npm run typecheck +npm run build +npm run audit:pdf +``` + +--- + +# Regression Requirements + +Verify: + +* legacy quotation PDF still works +* approved quotation PDF still works +* Document Library preview/download still works +* product template PDF still works +* assembled PDF does not replace source PDF +* assembled PDF artifact is stored separately + +--- + +# Acceptance Criteria + +* Generic assembly engine exists. +* Engine supports generated PDF + Document Library PDFs. +* Quotation can generate assembled PDF. +* SLA can be appended after quotation. +* Document order is deterministic. +* Missing required documents fail clearly. +* Missing optional documents warn and continue. +* Final merged artifact is stored through storage abstraction. +* Source PDFs are not mutated. +* Existing PDF runtime remains unchanged. +* System is ready for future Render Configuration. + +--- + +# Out of Scope + +## P.8 + +* User-selectable append documents +* Optional section UI +* Per-customer render preferences +* Per-quotation assembly choices + +## P.9 + +* Template Designer +* Visual PDF editor +* Upload/import template designer + +--- + +# Final Success Condition + +Task P.7 is complete when ALLA OS can generate a final assembled quotation PDF by merging the generated quotation PDF with active Document Library PDFs such as SLA or Warranty, store the merged result as a separate artifact, and keep all existing PDF runtime behavior unchanged. diff --git a/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts b/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts new file mode 100644 index 0000000..58d042f --- /dev/null +++ b/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStoredAssembledQuotationPdf } from '@/features/foundation/document-assembly/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing, +} from '@/features/crm/security/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess( + { permission: PERMISSIONS.crmQuotationPdfDownload }, + ); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership, + }); + + if (!canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: 'crm_quotation', + entityId: id, + reason: 'Assembled quotation PDF download requires pricing visibility', + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const pdf = await getStoredAssembledQuotationPdf(id, organization.id, { + userId: session.user.id, + auditDownload: true, + }); + + return new NextResponse(pdf.buffer, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${pdf.fileName}"`, + }, + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to download assembled quotation PDF' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/crm/quotations/[id]/assembled-pdf/generate/route.ts b/src/app/api/crm/quotations/[id]/assembled-pdf/generate/route.ts new file mode 100644 index 0000000..14e287d --- /dev/null +++ b/src/app/api/crm/quotations/[id]/assembled-pdf/generate/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { generateAssembledQuotationPdf } from '@/features/foundation/document-assembly/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing, +} from '@/features/crm/security/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess( + { permission: PERMISSIONS.crmQuotationPdfDownload }, + ); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership, + }); + + if (!canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: 'crm_quotation', + entityId: id, + reason: 'Assembled quotation PDF generation requires pricing visibility', + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const assembled = await generateAssembledQuotationPdf( + id, + organization.id, + session.user.id, + ); + + return NextResponse.json({ + success: true, + fileName: assembled.fileName, + checksum: assembled.checksum, + fileSize: assembled.fileSize, + pageCount: assembled.pageCount, + warnings: assembled.warnings, + artifact: assembled.artifact, + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to generate assembled quotation PDF' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts b/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts new file mode 100644 index 0000000..0c80228 --- /dev/null +++ b/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStoredAssembledQuotationPdf } from '@/features/foundation/document-assembly/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing, +} from '@/features/crm/security/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess( + { permission: PERMISSIONS.crmQuotationPdfPreview }, + ); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership, + }); + + if (!canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: 'crm_quotation', + entityId: id, + reason: 'Assembled quotation PDF preview requires pricing visibility', + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const pdf = await getStoredAssembledQuotationPdf(id, organization.id, { + userId: session.user.id, + }); + + return new NextResponse(pdf.buffer, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="${pdf.fileName}"`, + }, + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to load assembled quotation PDF' }, + { status: 500 }, + ); + } +} diff --git a/src/features/foundation/document-artifact/server/service.ts b/src/features/foundation/document-artifact/server/service.ts index ef15479..d623dfa 100644 --- a/src/features/foundation/document-artifact/server/service.ts +++ b/src/features/foundation/document-artifact/server/service.ts @@ -92,7 +92,7 @@ export async function createArtifact(input: { entityType: string; entityId: string; documentType: string; - artifactType: 'preview' | 'approved_pdf' | 'export'; + artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export'; templateVersionId?: string | null; storageProvider: string; storageKey: string; diff --git a/src/features/foundation/document-assembly/server/helpers.ts b/src/features/foundation/document-assembly/server/helpers.ts new file mode 100644 index 0000000..08e4095 --- /dev/null +++ b/src/features/foundation/document-assembly/server/helpers.ts @@ -0,0 +1,89 @@ +import { PDFDocument } from '@pdfme/pdf-lib'; +import type { + DocumentLibraryBrand, + DocumentLibraryProductType, +} from '@/features/foundation/document-library/types'; +import type { AssemblySourcePolicy, ResolvedAssemblySource } from '../types'; + +export function normalizeAssemblyBrand( + value: string | null | undefined, +): DocumentLibraryBrand { + const normalized = String(value ?? '') + .trim() + .toLowerCase(); + + if (normalized === 'alla') { + return 'alla'; + } + + if (normalized === 'onvalla') { + return 'onvalla'; + } + + return 'generic'; +} + +export function normalizeAssemblyProductType( + value: string | null | undefined, +): DocumentLibraryProductType { + const normalized = String(value ?? '') + .trim() + .toLowerCase(); + + if (normalized === 'crane') { + return 'crane'; + } + + if (normalized === 'dockdoor' || normalized === 'dock_door') { + return 'dock_door'; + } + + if (normalized === 'solarcell' || normalized === 'solar') { + return 'solar'; + } + + if (normalized === 'service') { + return 'service'; + } + + return 'all'; +} + +export function validateAssemblySourceOrdering(sources: AssemblySourcePolicy[]) { + const seenOrders = new Set(); + + for (const source of sources) { + if (seenOrders.has(source.order)) { + throw new Error(`Duplicate assembly source order: ${source.order}`); + } + + seenOrders.add(source.order); + } +} + +export async function getPdfPageCount(buffer: Buffer, label: string) { + try { + const document = await PDFDocument.load(buffer); + return document.getPageCount(); + } catch { + throw new Error(`${label} is not a readable PDF document.`); + } +} + +export async function mergePdfBuffers(sources: ResolvedAssemblySource[]) { + const merged = await PDFDocument.create(); + + for (const source of sources) { + const document = await PDFDocument.load(source.buffer); + const pages = await merged.copyPages( + document, + document.getPageIndices(), + ); + + for (const page of pages) { + merged.addPage(page); + } + } + + return Buffer.from(await merged.save()); +} diff --git a/src/features/foundation/document-assembly/server/service.test.ts b/src/features/foundation/document-assembly/server/service.test.ts new file mode 100644 index 0000000..9574635 --- /dev/null +++ b/src/features/foundation/document-assembly/server/service.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PDFDocument } from '@pdfme/pdf-lib'; +import type { ResolvedAssemblySource } from '../types'; +import { + getPdfPageCount, + mergePdfBuffers, + normalizeAssemblyBrand, + normalizeAssemblyProductType, + validateAssemblySourceOrdering, +} from './helpers'; + +async function createPdf(pageCount: number) { + const document = await PDFDocument.create(); + + for (let index = 0; index < pageCount; index += 1) { + document.addPage([595, 842]); + } + + return Buffer.from(await document.save()); +} + +function createSource(input: { + role: string; + order: number; + pageCount: number; +}): Promise { + return createPdf(input.pageCount).then((buffer) => ({ + role: input.role, + sourceType: 'generated', + required: true, + order: input.order, + fileName: `${input.role}.pdf`, + contentType: 'application/pdf', + checksum: input.role, + pageCount: input.pageCount, + fileSize: buffer.byteLength, + metadata: {}, + buffer, + })); +} + +test('normalizeAssemblyProductType aligns legacy quotation codes', () => { + assert.equal(normalizeAssemblyProductType('dockdoor'), 'dock_door'); + assert.equal(normalizeAssemblyProductType('solarcell'), 'solar'); + assert.equal(normalizeAssemblyProductType('crane'), 'crane'); + assert.equal(normalizeAssemblyProductType('unknown'), 'all'); +}); + +test('normalizeAssemblyBrand falls back to generic', () => { + assert.equal(normalizeAssemblyBrand('alla'), 'alla'); + assert.equal(normalizeAssemblyBrand('onvalla'), 'onvalla'); + assert.equal(normalizeAssemblyBrand('unknown'), 'generic'); +}); + +test('validateAssemblySourceOrdering rejects duplicate orders', () => { + assert.throws( + () => + validateAssemblySourceOrdering([ + { + sourceType: 'generated', + role: 'main', + required: true, + order: 1, + }, + { + sourceType: 'document_library', + role: 'sla', + required: true, + order: 1, + documentType: 'sla', + }, + ]), + /Duplicate assembly source order/, + ); +}); + +test('mergePdfBuffers preserves page order and page count', async () => { + const sources = await Promise.all([ + createSource({ role: 'main', order: 1, pageCount: 2 }), + createSource({ role: 'sla', order: 2, pageCount: 1 }), + createSource({ role: 'warranty', order: 3, pageCount: 3 }), + ]); + + const merged = await mergePdfBuffers(sources); + const pageCount = await getPdfPageCount(merged, 'merged-test'); + + assert.equal(pageCount, 6); +}); + +test('getPdfPageCount fails for invalid pdf source', async () => { + await assert.rejects( + () => getPdfPageCount(Buffer.from('not-a-pdf'), 'invalid-source'), + /not a readable PDF document/, + ); +}); diff --git a/src/features/foundation/document-assembly/server/service.ts b/src/features/foundation/document-assembly/server/service.ts new file mode 100644 index 0000000..790a79a --- /dev/null +++ b/src/features/foundation/document-assembly/server/service.ts @@ -0,0 +1,661 @@ +import { and, desc, eq, inArray, isNull } from 'drizzle-orm'; +import { crmDocumentLibraries, crmDocumentLibraryVersions } from '@/db/schema'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { + createArtifact, + downloadArtifact, + getActiveArtifactForEntity, + voidArtifact, +} from '@/features/foundation/document-artifact/server/service'; +import { + type DocumentLibraryBrand, + type DocumentLibraryProductType, + type DocumentLibraryType, +} from '@/features/foundation/document-library/types'; +import { + buildOrganizationStorageKey, + getStorageProvider, +} from '@/features/foundation/storage/service'; +import { sha256 } from '@/features/foundation/storage/server/checksum'; +import { + buildQuotationDocumentData, + getQuotationDocumentPreviewData, +} from '@/features/crm/quotations/document/server/service'; +import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service'; +import type { + AssemblyPolicy, + AssemblySourcePolicy, + AssemblyWarning, + DocumentAssemblyResult, + ResolvedAssemblySource, +} from '../types'; +import { + getPdfPageCount, + mergePdfBuffers, + normalizeAssemblyBrand, + normalizeAssemblyProductType, + validateAssemblySourceOrdering, +} from './helpers'; + +type ResolvedDocumentLibraryCandidate = { + library: typeof crmDocumentLibraries.$inferSelect; + version: typeof crmDocumentLibraryVersions.$inferSelect; +}; + +type AssembleDocumentInput = { + organizationId: string; + userId: string; + entityType: string; + entityId: string; + fileName: string; + artifactType: 'assembled_pdf'; + policy: AssemblyPolicy; + sourceResolver: ( + source: AssemblySourcePolicy, + ) => Promise; + auditEntityType?: string; +}; + +type QuotationAssemblyMetadata = { + documentType: string; + sourceDocumentType: string; + sourceDocumentId: string; + sourceDocumentCode: string; + assemblyPolicy: AssemblyPolicy; + sourceArtifacts: Array>; + warnings: AssemblyWarning[]; + pageCount: number; + generatedAt: string; + generatedBy: string; +}; + +const ASSEMBLED_PDF_ARTIFACT_TYPE = 'assembled_pdf'; +const DEFAULT_ASSEMBLY_LANGUAGE = 'th'; + +function sanitizeFileName(fileName: string) { + return fileName.replace(/[^a-zA-Z0-9._-]+/g, '-'); +} + +function toLibraryDocumentLabel(documentType: DocumentLibraryType) { + return documentType.replace(/_/g, ' ').toUpperCase(); +} + +function toRoleLabel(role: string) { + return role.replace(/_/g, ' ').toUpperCase(); +} + +function buildDocumentLibraryMissingMessage(input: { + role: string; + documentType: DocumentLibraryType; + brand: DocumentLibraryBrand; + language: string; + productType: DocumentLibraryProductType; +}) { + return `Missing ${toRoleLabel(input.role)} document (${toLibraryDocumentLabel( + input.documentType, + )}, brand ${input.brand}, language ${input.language}, product type ${input.productType}).`; +} + +function getCandidateScore(input: { + candidate: typeof crmDocumentLibraries.$inferSelect; + brand: DocumentLibraryBrand; + productType: DocumentLibraryProductType; +}) { + let score = 0; + + if (input.candidate.brand === input.brand) { + score += 2; + } + + if (input.candidate.productType === input.productType) { + score += 1; + } + + return score; +} + +export async function resolveActiveDocumentLibraryCandidate(input: { + organizationId: string; + documentType: DocumentLibraryType; + brand: DocumentLibraryBrand; + language: string; + productType: DocumentLibraryProductType; +}): Promise { + const brandScope = + input.brand === 'generic' ? ['generic'] : [input.brand, 'generic']; + const productTypeScope = + input.productType === 'all' ? ['all'] : [input.productType, 'all']; + + const rows = await db + .select({ + library: crmDocumentLibraries, + version: crmDocumentLibraryVersions, + }) + .from(crmDocumentLibraryVersions) + .innerJoin( + crmDocumentLibraries, + eq(crmDocumentLibraryVersions.libraryId, crmDocumentLibraries.id), + ) + .where( + and( + eq(crmDocumentLibraries.organizationId, input.organizationId), + eq(crmDocumentLibraryVersions.organizationId, input.organizationId), + eq(crmDocumentLibraries.documentType, input.documentType), + eq(crmDocumentLibraries.language, input.language), + inArray(crmDocumentLibraries.brand, brandScope), + inArray(crmDocumentLibraries.productType, productTypeScope), + eq(crmDocumentLibraries.isActive, true), + eq(crmDocumentLibraries.status, 'active'), + eq(crmDocumentLibraryVersions.isActive, true), + eq(crmDocumentLibraryVersions.status, 'active'), + isNull(crmDocumentLibraries.deletedAt), + isNull(crmDocumentLibraryVersions.deletedAt), + ), + ) + .orderBy( + desc(crmDocumentLibraryVersions.createdAt), + crmDocumentLibraries.code, + ); + + if (!rows.length) { + return null; + } + + const [best] = [...rows].sort((left, right) => { + const scoreDelta = + getCandidateScore({ + candidate: right.library, + brand: input.brand, + productType: input.productType, + }) - + getCandidateScore({ + candidate: left.library, + brand: input.brand, + productType: input.productType, + }); + + if (scoreDelta !== 0) { + return scoreDelta; + } + + const codeDelta = left.library.code.localeCompare(right.library.code); + + if (codeDelta !== 0) { + return codeDelta; + } + + return right.version.createdAt.getTime() - left.version.createdAt.getTime(); + }); + + return best; +} + +async function resolveDocumentLibrarySource(input: { + organizationId: string; + role: string; + required: boolean; + order: number; + documentType: DocumentLibraryType; + brand: DocumentLibraryBrand; + language: string; + productType: DocumentLibraryProductType; +}) { + const candidate = await resolveActiveDocumentLibraryCandidate({ + organizationId: input.organizationId, + documentType: input.documentType, + brand: input.brand, + language: input.language, + productType: input.productType, + }); + + if (!candidate) { + return null; + } + + const object = await getStorageProvider().getObject({ + key: candidate.version.storageKey, + }); + const buffer = Buffer.from(await new Response(object.body).arrayBuffer()); + const pageCount = await getPdfPageCount( + buffer, + `${toRoleLabel(input.role)} source`, + ); + + return { + role: input.role, + sourceType: 'document_library' as const, + required: input.required, + order: input.order, + fileName: candidate.version.fileName, + contentType: candidate.version.mimeType, + checksum: candidate.version.checksum, + pageCount, + fileSize: candidate.version.fileSize, + metadata: { + documentType: candidate.library.documentType, + libraryId: candidate.library.id, + libraryCode: candidate.library.code, + libraryName: candidate.library.name, + brand: candidate.library.brand, + language: candidate.library.language, + productType: candidate.library.productType, + versionId: candidate.version.id, + version: candidate.version.version, + storageKey: candidate.version.storageKey, + }, + buffer, + }; +} + +async function persistAssembledArtifact(input: { + organizationId: string; + userId: string; + entityType: string; + entityId: string; + artifactType: 'assembled_pdf'; + fileName: string; + buffer: Buffer; + checksum: string; + fileSize: number; + metadata: QuotationAssemblyMetadata; +}) { + const existingArtifact = await getActiveArtifactForEntity({ + organizationId: input.organizationId, + entityType: input.entityType, + entityId: input.entityId, + artifactType: input.artifactType, + }); + + if (existingArtifact?.status === 'active') { + await voidArtifact({ + artifactId: existingArtifact.id, + organizationId: input.organizationId, + userId: input.userId, + reason: 'Superseded by newer assembled PDF artifact.', + }); + } + + const safeFileName = sanitizeFileName(input.fileName); + const storageKey = buildOrganizationStorageKey(input.organizationId, [ + 'quotations', + 'assembled', + `${input.entityId}-${safeFileName}`, + ]); + const stored = await getStorageProvider().putObject({ + key: storageKey, + body: input.buffer, + contentType: 'application/pdf', + fileName: safeFileName, + }); + + const artifact = await createArtifact({ + organizationId: input.organizationId, + userId: input.userId, + entityType: input.entityType, + entityId: input.entityId, + documentType: input.metadata.sourceDocumentType, + artifactType: input.artifactType, + templateVersionId: null, + storageProvider: stored.provider, + storageKey: stored.key, + fileName: safeFileName, + contentType: 'application/pdf', + fileSize: input.fileSize, + checksum: input.checksum, + metadata: input.metadata, + }); + + return { + artifactId: artifact.id, + storageKey: artifact.storageKey, + fileName: artifact.fileName, + checksum: artifact.checksum ?? input.checksum, + fileSize: artifact.fileSize ?? input.fileSize, + pageCount: input.metadata.pageCount, + }; +} + +export async function assembleDocument( + input: AssembleDocumentInput, +): Promise { + validateAssemblySourceOrdering(input.policy.sources); + + const auditEntityType = input.auditEntityType ?? input.entityType; + const warnings: AssemblyWarning[] = []; + + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: auditEntityType, + entityId: input.entityId, + action: 'assembly_started', + afterData: { policy: input.policy }, + }); + + try { + const sources: ResolvedAssemblySource[] = []; + + for (const sourcePolicy of [...input.policy.sources].sort( + (left, right) => left.order - right.order, + )) { + const resolvedSource = await input.sourceResolver(sourcePolicy); + + if (!resolvedSource) { + if (sourcePolicy.sourceType === 'document_library') { + const message = buildDocumentLibraryMissingMessage({ + role: sourcePolicy.role, + documentType: sourcePolicy.documentType!, + brand: input.policy.brand ?? 'generic', + language: input.policy.language ?? DEFAULT_ASSEMBLY_LANGUAGE, + productType: input.policy.productType ?? 'all', + }); + + if (sourcePolicy.required) { + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: auditEntityType, + entityId: input.entityId, + action: 'assembly_failed', + afterData: { role: sourcePolicy.role, message }, + }); + throw new AuthError(message, 409); + } + + warnings.push({ + code: 'missing_optional_source', + role: sourcePolicy.role, + message, + }); + + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: auditEntityType, + entityId: input.entityId, + action: 'assembly_source_missing', + afterData: { role: sourcePolicy.role, message }, + }); + continue; + } + + throw new AuthError( + `Required generated source missing for role ${sourcePolicy.role}.`, + 409, + ); + } + + sources.push(resolvedSource); + + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: auditEntityType, + entityId: input.entityId, + action: 'assembly_source_resolved', + afterData: { + role: resolvedSource.role, + sourceType: resolvedSource.sourceType, + fileName: resolvedSource.fileName, + checksum: resolvedSource.checksum, + pageCount: resolvedSource.pageCount, + metadata: resolvedSource.metadata, + }, + }); + } + + if (!sources.length) { + throw new AuthError('No assembly sources available.', 409); + } + + const buffer = await mergePdfBuffers(sources); + const checksum = sha256(buffer); + const fileSize = buffer.byteLength; + const pageCount = await getPdfPageCount(buffer, 'Merged assembly output'); + + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: auditEntityType, + entityId: input.entityId, + action: 'assembly_merge_completed', + afterData: { + fileName: input.fileName, + checksum, + fileSize, + pageCount, + sourceCount: sources.length, + }, + }); + + return { + buffer, + fileName: input.fileName, + checksum, + fileSize, + pageCount, + policy: input.policy, + warnings, + sources, + artifact: null, + }; + } catch (error) { + if (!(error instanceof AuthError)) { + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: auditEntityType, + entityId: input.entityId, + action: 'assembly_failed', + afterData: { + message: + error instanceof Error + ? error.message + : 'Unknown assembly failure', + }, + }); + } + + throw error; + } +} + +export async function buildDefaultQuotationAssemblyPolicy(input: { + quotationId: string; + organizationId: string; +}): Promise { + const documentData = await buildQuotationDocumentData( + input.quotationId, + input.organizationId, + ); + const productTypeCode = + documentData.items.find((item) => item.productTypeCode)?.productTypeCode ?? + documentData.quotation.quotationTypeCode; + + return { + documentType: 'quotation_pdf', + organizationId: input.organizationId, + brand: normalizeAssemblyBrand(documentData.company.slug), + language: DEFAULT_ASSEMBLY_LANGUAGE, + productType: normalizeAssemblyProductType(productTypeCode), + sources: [ + { + sourceType: 'generated', + role: 'main', + required: true, + order: 1, + }, + { + sourceType: 'document_library', + role: 'sla', + required: true, + order: 2, + documentType: 'sla', + }, + { + sourceType: 'document_library', + role: 'warranty', + required: false, + order: 3, + documentType: 'warranty', + }, + { + sourceType: 'document_library', + role: 'datasheet', + required: false, + order: 4, + documentType: 'datasheet', + }, + { + sourceType: 'document_library', + role: 'drawing', + required: false, + order: 5, + documentType: 'drawing', + }, + ], + }; +} + +export async function generateAssembledQuotationPdf( + quotationId: string, + organizationId: string, + userId: string, +) { + const [policy, preview] = await Promise.all([ + buildDefaultQuotationAssemblyPolicy({ quotationId, organizationId }), + getQuotationDocumentPreviewData(quotationId, organizationId), + ]); + + const assembled = await assembleDocument({ + organizationId, + userId, + entityType: 'crm_quotation', + entityId: quotationId, + auditEntityType: 'crm_quotation', + fileName: `${preview.documentData.quotation.code}-assembled.pdf`, + artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE, + policy, + sourceResolver: async (sourcePolicy) => { + if (sourcePolicy.sourceType === 'generated') { + const generated = await generateQuotationPdf(quotationId, organizationId); + const pageCount = await getPdfPageCount( + generated.buffer, + 'Generated quotation PDF', + ); + + return { + role: sourcePolicy.role, + sourceType: 'generated', + required: sourcePolicy.required, + order: sourcePolicy.order, + fileName: generated.fileName, + contentType: 'application/pdf', + checksum: sha256(generated.buffer), + pageCount, + fileSize: generated.buffer.byteLength, + metadata: { + templateVersionId: generated.templateVersionId, + sourceDocumentType: 'quotation_pdf', + }, + buffer: generated.buffer, + }; + } + + return resolveDocumentLibrarySource({ + organizationId, + role: sourcePolicy.role, + required: sourcePolicy.required, + order: sourcePolicy.order, + documentType: sourcePolicy.documentType!, + brand: policy.brand ?? 'generic', + language: policy.language ?? DEFAULT_ASSEMBLY_LANGUAGE, + productType: policy.productType ?? 'all', + }); + }, + }); + + const metadata: QuotationAssemblyMetadata = { + documentType: 'assembled_pdf', + sourceDocumentType: 'quotation_pdf', + sourceDocumentId: quotationId, + sourceDocumentCode: preview.documentData.quotation.code, + assemblyPolicy: assembled.policy, + sourceArtifacts: assembled.sources.map((source) => ({ + role: source.role, + sourceType: source.sourceType, + fileName: source.fileName, + checksum: source.checksum, + fileSize: source.fileSize, + pageCount: source.pageCount, + metadata: source.metadata, + })), + warnings: assembled.warnings, + pageCount: assembled.pageCount, + generatedAt: new Date().toISOString(), + generatedBy: userId, + }; + + const artifact = await persistAssembledArtifact({ + organizationId, + userId, + entityType: 'crm_quotation', + entityId: quotationId, + artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE, + fileName: assembled.fileName, + buffer: assembled.buffer, + checksum: assembled.checksum, + fileSize: assembled.fileSize, + metadata, + }); + + await auditAction({ + organizationId, + userId, + entityType: 'crm_quotation', + entityId: quotationId, + action: 'assembly_artifact_stored', + afterData: artifact, + }); + + return { + ...assembled, + artifact, + }; +} + +export async function getStoredAssembledQuotationPdf( + quotationId: string, + organizationId: string, + options?: { + userId?: string; + auditDownload?: boolean; + }, +) { + const existingArtifact = await getActiveArtifactForEntity({ + organizationId, + entityType: 'crm_quotation', + entityId: quotationId, + artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE, + }); + + if (!existingArtifact) { + throw new AuthError('Assembled quotation PDF has not been generated yet.', 404); + } + + const { object, artifact } = await downloadArtifact({ + artifactId: existingArtifact.id, + organizationId, + userId: options?.userId, + auditDownload: options?.auditDownload, + }); + const buffer = Buffer.from(await new Response(object.body).arrayBuffer()); + + return { + artifact, + buffer, + fileName: object.fileName || `${quotationId}-assembled.pdf`, + }; +} diff --git a/src/features/foundation/document-assembly/types.ts b/src/features/foundation/document-assembly/types.ts new file mode 100644 index 0000000..5782ccc --- /dev/null +++ b/src/features/foundation/document-assembly/types.ts @@ -0,0 +1,65 @@ +import type { + DocumentLibraryBrand, + DocumentLibraryProductType, + DocumentLibraryType, +} from '@/features/foundation/document-library/types'; + +export type AssemblySourceType = 'generated' | 'document_library'; + +export interface AssemblySourcePolicy { + sourceType: AssemblySourceType; + role: string; + required: boolean; + order: number; + documentType?: DocumentLibraryType; +} + +export interface AssemblyPolicy { + documentType: string; + organizationId: string; + productType?: DocumentLibraryProductType | null; + brand?: DocumentLibraryBrand | null; + language?: string | null; + sources: AssemblySourcePolicy[]; +} + +export interface AssemblyWarning { + code: 'missing_optional_source'; + role: string; + message: string; +} + +export interface ResolvedAssemblySource { + role: string; + sourceType: AssemblySourceType; + required: boolean; + order: number; + fileName: string; + contentType: string; + checksum: string; + pageCount: number; + fileSize: number; + metadata: Record; + buffer: Buffer; +} + +export interface PersistedAssemblyArtifact { + artifactId: string; + storageKey: string; + fileName: string; + checksum: string; + fileSize: number; + pageCount: number; +} + +export interface DocumentAssemblyResult { + buffer: Buffer; + fileName: string; + checksum: string; + fileSize: number; + pageCount: number; + policy: AssemblyPolicy; + warnings: AssemblyWarning[]; + sources: ResolvedAssemblySource[]; + artifact: PersistedAssemblyArtifact | null; +} diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/82e0ba18-dd3a-4dcf-b612-e9af58ea0110/286560f3-7d6d-4ec8-bf31-f6707824e739/warranty-onvalla-dock-door-en-v1.0.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/82e0ba18-dd3a-4dcf-b612-e9af58ea0110/286560f3-7d6d-4ec8-bf31-f6707824e739/warranty-onvalla-dock-door-en-v1.0.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b479dc5ad51c061a994d5a89d34a964e7b89d8f5 GIT binary patch literal 1345 zcmY!laBXIoDD##wWZS5JKz8?&$dY3Ph;9}I2QiCCIf^|_x5ms!mnow!w~Gj3V?%@>>r zXQVAx*Kw~(5`7T7kM)DFtw50V%{v|YWA;y0SU$z;QdG`l1^%YHqWK?g$vjz7aB*_x z;uqoX-hJ~FW=WXuE0uXcLP3ppn`YVJ_sVQ=%$ESGmbdUnX~ zfYPiLD!0Niw=rckow~`=@bgxd^v5eV-E6*JGMKYH_~Y4tTO3Pg9aKL5TqEs~=JL4> zQ4wb>p64DA+%#AEyLLkXqXzd*w@1^w%E!T^Y5i`{?^KucktY9 zv3WPmM0ysh_#=r=*SzwMJ>}j!@2=pBIazm8;=fye^?MatvhLG7;}EH|eJ;P;U&r&_ z4nD<|nwJ8NW*`fcHn=PZC6bWJf>Z^4|D>$ol3Xr*KLs;TVk#;wQ7|?^q#!d(loWI| z*gN}wP|1Om#gGUO)BO55_U%6 z{2B9E$CoEMG&WCBI1n*m0Sh~8PY|yZzk0&P6(^3z+uKZdo%a5C*Pi#g1@|dUJH%VF z`h=RCGgFxE^sCBcmzO*f_vD0*C*66{BJGC) z-Q~Cc=udj$c_EEuX;SOHDxutEGv4S&ly=>J*cma`@HVIR+a>=`&Mtnt%=(A-*Bz;@ z>yozTPHGRoZ9blVklK zEw*@6AG<1O`}6r+`#)b+=FjdfwJ8yAv+6!7_4>oXsXN>oj3;HEp1SP^*ZiNKF6e}w zdsRID@JWxi7N*bb7W0HVz1V4?Et)!Q(@u-khSL+Tow>3|XL?kzVp`2o&o*5xy_tc} zeWkM3CiA3QU+)kOvU=yIH^=hlpNH>u=bzTuylc0Aetxi8rcui6sqcTK$-XbN)vP53M_t^K7ls_T#%luH>YMMw&0y@3DI=@|rXE z&94pYpH}j(Hu%QhR=?+4t8_i{{Ezzkup}Kzq@)v^S(U0_Xuzc(l%HRsU<8y2;?nob zOUqXua6aj5EG&DBj KQdM>JcLM+~Xjdly literal 0 HcmV?d00001 diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/611d6f1f-f9df-4b14-8588-e085e42d012d/company-profile-alla-all-th-2025.12.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/611d6f1f-f9df-4b14-8588-e085e42d012d/company-profile-alla-all-th-2025.12.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4ff54ac48ffe3caa05896bdd7ceea2aed175288a GIT binary patch literal 1359 zcmY!laBi>3hk7sAD_C_I zDJehtUOnmEPJOlRX==W5Q&$D6FMp-^=Gla};3EcqA|Aibe|YwH`|3oi>8Y_pZ{=emv7mJloa>%bBcq zJCtrvebB!-BYe&Clj|eypNaDGXrIjQxKva`+2^r?b;rL6ZqkP<%g!m8Jv(qpD(`-? zWrV_n`^M}aqeBBt)U`T~{hX=G=sw9NeYfeBhim6{@v~;GR@m(L^G(sTg&Vd^{HdX` z$FM+eO`47o-yW{q#gFgFahz4$Jn6Z|pH1aedHip89(<*hv_WHS*`2834YQv;G(Wp| ze#hHs&&}_YACL85Fh0n)X4n3wJQX>IUWLrfP>t5U;;lKAEDyerm7*&NQIX|2``SIfQ(`ID8o^`jY`u47uvMZKk?X9T&SpH(_)(cge zKCN?%*v3BLv`6^Pf6NwdV#~Qw^HQLZ4rGB+3YP_;Bok6ukgA~XpOh6`lFOy7WIdz%Tb z)7~HN+Vg(5;69~khj?pNpHP!?W(w1tepR{b@{(r)->-_W=rqdJ(ysdtJ0s>A-saSP zyX61L*~M>{S^x0KNB?i%8B{NSINrME zQ1LSJ$u>X!uyLJ!a;*QO#TJk1V^;-je?FgU|L4of{Mp^5HYMV1R^3OXUVk_^b%%R{ z@uck2Q@8!#n*a0D1)b1yuZrg%KI!q+!t}Y_VxDlP7dtJqMN_A3+G(-caC+jkGglVr zOphv7OsiSy*`}+dH#6|LuT=KhWS(^E>m9;DR`2}u=2-sx^YGp7{L?y{ckTAi&kt71 zG)lQW_5H6j+4qIEnsujEdgmSVD(ehB78=_s5FB@J(>6uL%t+(qp>>CHo~>2detdVx zm7EmONb|+|J$A1}UUTNY`L%)l(@Or;2H*JG>i2wWm9A%=|51M*mZW2elyrhKt5OvV z4Y>4!^7Bg+jDRvhT>74QY55Aq5H{B02$HrUf>P5ENy(T?KU^VN!4PPOkwPrUkd)L4 zpnw6;cpx9S*b+Utk(a@MgJom0fTpT-s(u*zzQW`Mw<{HVc33DKF`YDbzso^}EKX(> aERHWONh~Uq`BG z1jhxg8G?^~{+v`+mX=VgvFei9%Ix}oN6#hih<2*-6g*sz+-bkRB|QJVeBJ&3zkYo@ zeD-dCyTAFotcjEMT=7uVo6me^S~+9=!$lgZRi1@E<>I`z-Rzs!lk@46=VarsGpl5M zEI&@1JVB=5VrH9uySjJb);CQ%D_Z5h?zy$nqf&T!eqyEIGSxl&{i{pP`EGkW?Q{Gs z?LQHTTZKC3P4B&!dYXxh2>McE=A>NOfYX` z`hGLIg7qf1o$sR9!0R{HuXw*~8RyG_PYYLU=}c5Tn zmpkLw$gn@1??d(KMICBdoyUI8T+8ULT6wN4KjUzmwWz$m&9c$T zOF*^9?{M)ucgF92(cvrJa{jro^Y^v-y;Jw@`g||%OOThUlX@5qF`)- zNJ?gwC`svRuy^(y1A%?N!_REARjJy4YSRY&?Ey~CK^d7Z4{kjAXp_g$Ytz2mFIUNz znpDd9CG3pC`7`FTjxSGiXl$OMa3EsB0v2}Go*-T+e)WWnD^47bx3`(_I_>@Ou08K} z3+_{zc8IrT^$9gOXQnXS=~tD@E-!f|@cpU?i%zq=z;)+0eQV_t0^h%y736F<*X81r zPrCD@McNMqy323>(Vz6h^FkWS(xldXRYJMTX1vjlDDArcurp$=;cZUsw@dz?oL&5O zne`9vuRBs**ClPwozxzF+k8$@tdPXfoqs+_9XI@;cJ%-Dok8{ThvThl4izsmpKSBv z4;$C%C&&6fT5R#CK6X{m_UH4t_J6*t%%9y|YEvTKX4QRE>h*_%Q+K#G7*EPRJ$2g; zuK7PdUC;?V_o{gQ;gcS3Eli)=E#?V#da=_&TQqgrrkxh64W}nwJ9A}`&h)5a#k88G zo^85XdNTu``$}c6P3B3rzTP1mWcAKZZ;s{9KM&vS&OfcQdDm|L{QO|GOrweIW1+FF0>N?jHf>W>%#1W%9$I%O=h<4N?Zw&3X9{5OA?Dp TDvE$M8ycFJa;d7i`nv%D1I=9( literal 0 HcmV?d00001 diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/70460d62-3fee-4c27-bb98-382a6a8440b1/sla-alla-crane-th-v1.0.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/70460d62-3fee-4c27-bb98-382a6a8440b1/sla-alla-crane-th-v1.0.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e21639bf77e9c0006d03cf6b9a2296be7a7fa732 GIT binary patch literal 1332 zcmY!laBB#5LmY&iYXBDq%b?%iq|J~TQ&ZFIMs`sf19{p*5K4(`|7gzuP{q6eQ<^AIA z^55s{@7XdzLrypN$wzOe8$awGiquU}-Q<4q@%NIYp?yv~@U(7P&|MK|8ZN^L9-|w6;2>$V{c!kq_#a-X>9>>Y= zxRU$oZGZjL@8@Q@yC^$3ZhvzzEH2sqbC|2t_OpA=osMPyWbovoNPhPlJ=dw)}se>5}f;W0#-TEMFP9d}rk1nr-V8&!@cX`qF>R{>@qUVy@J@6llZ(S)kOw zWkD!egj5!!D(L$sWd)bya_Rdin1PZ{QE`cau?bR|u|!ETSA)H??|{-w_?eBiDpmVW zZQ7u}J;2F1C?oUb!Hq{BZSpvJZQ7Un)@#TpQjm=XO4n#~? zz{1Yj6T~aUub!}R#fjtb_BInZ>@Yn;QLpzf}9QKx?G&{Nq3&KNc*8cclqr<`jeh`UPxnEn$)_l zN+@^Pj5qobrCs+Qc1FxKyv?cocFF&fvy0y@v;N`zbw{e}x}@#7liI^?o6jkV6_Pl* z^T#KtJIk?<4M`4r*8YfHUH6N7w9{g>;q=67XRa*LnI2WFm{zmYvrSh^Z)V_gU#aZ1$vo-S*E@uRtls(Q z&9VIX=i$5E`KNU@@7nF3pC7E2X_Rt%>ib`5vhNFRHS12T^v*lzRn{4NEHt)NAUN*c zrfrIfnUTiJL+cLZJX@=@{rK*XD>*5mk>-o_d+c6|yynb(^J@e9r!|u7y)I1xb!{q(()CIA#AKg4kT?w1f`}S zk`gdgg)2lW7y=D3Qiuf^l9E~h6fgi959A{kP00xfKmO0}Y;101bTMe+P!l-FqZHx5 uqOIJ_z#QNv>#;$_(13v<<0{h|EUqsuNh~U{)RPPfTP2^7eeBb}&Jpb)RU59Cv^oD>^U$-~BJoH0jjGM`EC0>4iHK{TfLA8QoP;l7bT}>^}Nz}CHWv%YNHhIJf<^NFqj-7b2tx8A)vQE1EK*S;S% zH9fw?m6xzl`W|C?l7`E!wV}1G(pEECqdJuIxgx)p-H+s|Xw0o$#l2w41#?Yyg}Kwi z;*S;R?{SOgy<^tyfBm8G)fK*9KBPY3C|I(WRm?riby4nt(ivrH@20gGY*A?TWUH~Z z)|tll*v#^W&5A(VtbYc%VRdGE=63XLJ`%U(xU?>B{>NK3%Rl;+L~M%8+?#p%#(&$b zN8{LM?GA6q_#Ad|$+YhxYok=3b(I>ge^|xLR{GLtw!)Tz6pndoylvuiEpACkTIZ?0 z3cRs?>E_*vG9g=MUz=9bZg=Re@9`B&Sp`>ad|`U2^P2jrd)~jfQu9)vQ43^&(gT+T zp@b1qS&*ur@1K+vT$0PB@26k}NCYnJ*7+Jo;#p$I)xkzT7WY$(Nc`%K0VijKcXd=Ch73PjqN(o}zFdV!{Fz zcGjLCUMYU{gpDgs9FMoRneaO8{qe3n?{^FCQ<`>&w`TPTH92RdFx}}_mCG(Kc_#4v zstAitv%J7{=Qn+8dv&9&as7pW7|w33qz2 z(?VM`b=sz#7OM@XCtf>qWs%PGsA9#mnx&p?x>|ZO1E2d!Wv@-b&@jlZpa&$m|T zdgl2b_4i>(I+jRDCpfbzRl(4JOFt+-zeK?ZC=TS0Owgl-~a#s literal 0 HcmV?d00001