task-p.7
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"audit": "template-version-integrity",
|
||||
"overallStatus": "PASS",
|
||||
"activeTemplateCount": 1,
|
||||
"quotationCode": "CR2606-1124",
|
||||
"quotationCode": "CRA2606-1108",
|
||||
"approvedTemplateVersionId": null,
|
||||
"approvedTemplateVersionIsActive": false,
|
||||
"issues": []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"audit": "topic-runtime",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "CR2606-1124",
|
||||
"quotationCode": "CRA2606-1108",
|
||||
"topicCount": 3,
|
||||
"topicInputKeys": [
|
||||
"topic_1_0",
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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/image-types/global" />
|
||||
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.
|
||||
|
||||
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;
|
||||
entityId: string;
|
||||
documentType: string;
|
||||
artifactType: 'preview' | 'approved_pdf' | 'export';
|
||||
artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export';
|
||||
templateVersionId?: string | null;
|
||||
storageProvider: 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