task-p.7
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"audit": "payload-parity",
|
"audit": "payload-parity",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CRA2606-1108",
|
||||||
"previewHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7",
|
"previewHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26",
|
||||||
"generationHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7",
|
"generationHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26",
|
||||||
"snapshotHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7",
|
"snapshotHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26",
|
||||||
"mismatches": [],
|
"mismatches": [],
|
||||||
"comparableFieldCount": 31,
|
"comparableFieldCount": 31,
|
||||||
"topicInputKeyCount": 6,
|
"topicInputKeyCount": 6,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"audit": "runtime-payload",
|
"audit": "runtime-payload",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"staticLabelsStatus": "PASS",
|
"staticLabelsStatus": "PASS",
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CRA2606-1108",
|
||||||
"templateName": "ALLA Demo Organization Quotation Standard",
|
"templateName": "ALLA Demo Organization Quotation Standard",
|
||||||
"templateVersion": "1.0",
|
"templateVersion": "1.0",
|
||||||
"findingCount": 168,
|
"findingCount": 168,
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"field": "documentData.customer.id",
|
"field": "documentData.customer.id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a",
|
"value": "85928568-22f2-424c-8896-a8e107ce3738",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"field": "documentData.contact.id",
|
"field": "documentData.contact.id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "951023ad-0424-4c05-9db1-c5004d525e23",
|
"value": "307bcf2f-46df-4ab7-b090-872b99338bf1",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,14 +95,14 @@
|
|||||||
"field": "documentData.quotation.id",
|
"field": "documentData.quotation.id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "9a7333a0-bb43-4d6e-b178-aae196456a31",
|
"value": "f9290e40-0836-431d-9d9f-0ff25fa2bcc3",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "documentData.quotation.code",
|
"field": "documentData.quotation.code",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "CR2606-1124",
|
"value": "CRA2606-1108",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
"field": "documentData.items[0].id",
|
"field": "documentData.items[0].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "81cf1a1a-0ae0-4f6d-bb24-1315b0de5363",
|
"value": "2822652d-cab6-4b00-8b21-93a8b0c3d33f",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
"field": "documentData.items[1].id",
|
"field": "documentData.items[1].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "3cfad159-e274-49f4-a195-0e3ae39ee5f9",
|
"value": "d080c242-c39b-4192-adab-f28fc0171389",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
"field": "documentData.items[2].id",
|
"field": "documentData.items[2].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "3f48546a-2685-462e-a21d-e25ccc1348fd",
|
"value": "de54b922-db69-42e3-a93c-940330852362",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -340,7 +340,7 @@
|
|||||||
"field": "documentData.items[3].id",
|
"field": "documentData.items[3].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "9f0ef323-dbfc-4710-a90f-501f185a165a",
|
"value": "c903ac48-45c5-43b1-8948-1a196ec0d576",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -403,21 +403,21 @@
|
|||||||
"field": "documentData.quotationCustomers[0].id",
|
"field": "documentData.quotationCustomers[0].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "fefd5c1a-cc0a-4365-9492-e00feef8d38e",
|
"value": "c15f26c7-2f23-44fc-84e2-0a435901f887",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "documentData.quotationCustomers[0].customerId",
|
"field": "documentData.quotationCustomers[0].customerId",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a",
|
"value": "85928568-22f2-424c-8896-a8e107ce3738",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "documentData.quotationCustomers[0].role",
|
"field": "documentData.quotationCustomers[0].role",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "ed72c8c9-a690-4c93-afb7-9720a5fc7b3d",
|
"value": "a6170538-f685-4f8d-bf77-36ae82f87376",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -438,7 +438,7 @@
|
|||||||
"field": "documentData.topics.all[0].id",
|
"field": "documentData.topics.all[0].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "e6e4b61e-24a4-4ca9-a1d0-2cd3981ab59c",
|
"value": "396189b7-ca6c-4fc4-80dd-04f3f903f77b",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -501,7 +501,7 @@
|
|||||||
"field": "documentData.topics.all[1].id",
|
"field": "documentData.topics.all[1].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9",
|
"value": "41b30c9e-2adb-4407-b5f6-f803cc08bdb4",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -564,7 +564,7 @@
|
|||||||
"field": "documentData.topics.all[2].id",
|
"field": "documentData.topics.all[2].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "85d82f36-3e8f-4e46-8357-84879c728a49",
|
"value": "1ef3d0b6-b722-42e1-b8c1-e328fbb1bb57",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -634,7 +634,7 @@
|
|||||||
"field": "documentData.topics.exclusion[0].id",
|
"field": "documentData.topics.exclusion[0].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9",
|
"value": "41b30c9e-2adb-4407-b5f6-f803cc08bdb4",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -921,7 +921,7 @@
|
|||||||
"field": "templateInput.quotation_code_data",
|
"field": "templateInput.quotation_code_data",
|
||||||
"source": "templateInput",
|
"source": "templateInput",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "CR2606-1124",
|
"value": "CRA2606-1108",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1075,7 +1075,7 @@
|
|||||||
"field": "templateInput.quotation_code",
|
"field": "templateInput.quotation_code",
|
||||||
"source": "templateInput",
|
"source": "templateInput",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "CR2606-1124",
|
"value": "CRA2606-1108",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"audit": "approved-snapshot-parity",
|
"audit": "approved-snapshot-parity",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CRA2606-1108",
|
||||||
"approvedSnapshotAvailable": true,
|
"approvedSnapshotAvailable": true,
|
||||||
"approvedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
|
"approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
||||||
"computedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
|
"computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
||||||
"inconsistentTemplateKeys": [],
|
"inconsistentTemplateKeys": [],
|
||||||
"computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
"computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
||||||
"storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
"storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-06-30T05:56:31.314Z",
|
"generatedAt": "2026-06-30T08:23:40.205Z",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CRA2606-1108",
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"templateVersionReport": "template-version-report.json",
|
"templateVersionReport": "template-version-report.json",
|
||||||
"placeholderIntegrityReport": "placeholder-integrity-report.json",
|
"placeholderIntegrityReport": "placeholder-integrity-report.json",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PDF Integrity Summary
|
# PDF Integrity Summary
|
||||||
|
|
||||||
- Generated At: 2026-06-30T05:56:31.314Z
|
- Generated At: 2026-06-30T08:23:40.205Z
|
||||||
- Overall Status: PASS
|
- Overall Status: PASS
|
||||||
- Quotation Code: CR2606-1124
|
- Quotation Code: CRA2606-1108
|
||||||
|
|
||||||
## Statuses
|
## Statuses
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"audit": "template-version-integrity",
|
"audit": "template-version-integrity",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"activeTemplateCount": 1,
|
"activeTemplateCount": 1,
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CRA2606-1108",
|
||||||
"approvedTemplateVersionId": null,
|
"approvedTemplateVersionId": null,
|
||||||
"approvedTemplateVersionIsActive": false,
|
"approvedTemplateVersionIsActive": false,
|
||||||
"issues": []
|
"issues": []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"audit": "topic-runtime",
|
"audit": "topic-runtime",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CRA2606-1108",
|
||||||
"topicCount": 3,
|
"topicCount": 3,
|
||||||
"topicInputKeys": [
|
"topicInputKeys": [
|
||||||
"topic_1_0",
|
"topic_1_0",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-06-30T05:56:30.085Z",
|
"generatedAt": "2026-06-30T08:23:36.291Z",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CRA2606-1108",
|
||||||
"expectedFixtureCode": "QT-H5-AUDIT",
|
"expectedFixtureCode": "QT-H5-AUDIT",
|
||||||
"template": {
|
"template": {
|
||||||
"templateName": "ALLA Demo Organization Quotation Standard",
|
"templateName": "ALLA Demo Organization Quotation Standard",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"versionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
|
"versionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
||||||
"fieldCount": 44,
|
"fieldCount": 44,
|
||||||
"previousVersion": null,
|
"previousVersion": null,
|
||||||
"deletedFields": [],
|
"deletedFields": [],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PDF Audit Report
|
# PDF Audit Report
|
||||||
|
|
||||||
- Generated At: 2026-06-30T05:56:30.085Z
|
- Generated At: 2026-06-30T08:23:36.291Z
|
||||||
- Overall Status: PASS
|
- Overall Status: PASS
|
||||||
- Quotation Code: CR2606-1124
|
- Quotation Code: CRA2606-1108
|
||||||
- Template: ALLA Demo Organization Quotation Standard v1.0
|
- Template: ALLA Demo Organization Quotation Standard v1.0
|
||||||
- Previous Version: None
|
- Previous Version: None
|
||||||
|
|
||||||
|
|||||||
110
docs/implementation/task-p.7-implementation.md
Normal file
110
docs/implementation/task-p.7-implementation.md
Normal file
@@ -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.
|
||||||
70
docs/implementation/task-p.7-verification-report.md
Normal file
70
docs/implementation/task-p.7-verification-report.md
Normal file
@@ -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
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
364
plans/task-p.7.md
Normal file
364
plans/task-p.7.md
Normal file
@@ -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.
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/app/api/crm/quotations/[id]/assembled-pdf/route.ts
Normal file
57
src/app/api/crm/quotations/[id]/assembled-pdf/route.ts
Normal file
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ export async function createArtifact(input: {
|
|||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
artifactType: 'preview' | 'approved_pdf' | 'export';
|
artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export';
|
||||||
templateVersionId?: string | null;
|
templateVersionId?: string | null;
|
||||||
storageProvider: string;
|
storageProvider: string;
|
||||||
storageKey: string;
|
storageKey: string;
|
||||||
|
|||||||
89
src/features/foundation/document-assembly/server/helpers.ts
Normal file
89
src/features/foundation/document-assembly/server/helpers.ts
Normal file
@@ -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<number>();
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -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<ResolvedAssemblySource> {
|
||||||
|
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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
661
src/features/foundation/document-assembly/server/service.ts
Normal file
661
src/features/foundation/document-assembly/server/service.ts
Normal file
@@ -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<ResolvedAssemblySource | null>;
|
||||||
|
auditEntityType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuotationAssemblyMetadata = {
|
||||||
|
documentType: string;
|
||||||
|
sourceDocumentType: string;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
sourceDocumentCode: string;
|
||||||
|
assemblyPolicy: AssemblyPolicy;
|
||||||
|
sourceArtifacts: Array<Record<string, unknown>>;
|
||||||
|
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<ResolvedDocumentLibraryCandidate | null> {
|
||||||
|
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<DocumentAssemblyResult> {
|
||||||
|
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<AssemblyPolicy> {
|
||||||
|
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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
65
src/features/foundation/document-assembly/types.ts
Normal file
65
src/features/foundation/document-assembly/types.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user