This commit is contained in:
phaichayon
2026-06-30 15:53:11 +07:00
parent 47eac3badc
commit a4fd166c51
26 changed files with 1670 additions and 38 deletions

View File

@@ -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,

View File

@@ -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"
}, },
{ {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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": []

View File

@@ -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",

View File

@@ -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": [],

View File

@@ -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

View 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.

View 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
View File

@@ -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
View 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.

View File

@@ -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 },
);
}
}

View File

@@ -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 },
);
}
}

View 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 },
);
}
}

View File

@@ -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;

View 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());
}

View File

@@ -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/,
);
});

View 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`,
};
}

View 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;
}