diff --git a/artifacts/pdf-audit/payload-parity-report.json b/artifacts/pdf-audit/payload-parity-report.json
index 3422dc9..02db715 100644
--- a/artifacts/pdf-audit/payload-parity-report.json
+++ b/artifacts/pdf-audit/payload-parity-report.json
@@ -1,10 +1,10 @@
{
"audit": "payload-parity",
"overallStatus": "PASS",
- "quotationCode": "CR2606-1124",
- "previewHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7",
- "generationHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7",
- "snapshotHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7",
+ "quotationCode": "CRA2606-1108",
+ "previewHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26",
+ "generationHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26",
+ "snapshotHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26",
"mismatches": [],
"comparableFieldCount": 31,
"topicInputKeyCount": 6,
diff --git a/artifacts/pdf-audit/runtime-payload-report.json b/artifacts/pdf-audit/runtime-payload-report.json
index c4bf3bf..46001f2 100644
--- a/artifacts/pdf-audit/runtime-payload-report.json
+++ b/artifacts/pdf-audit/runtime-payload-report.json
@@ -2,7 +2,7 @@
"audit": "runtime-payload",
"overallStatus": "PASS",
"staticLabelsStatus": "PASS",
- "quotationCode": "CR2606-1124",
+ "quotationCode": "CRA2606-1108",
"templateName": "ALLA Demo Organization Quotation Standard",
"templateVersion": "1.0",
"findingCount": 168,
@@ -25,7 +25,7 @@
"field": "documentData.customer.id",
"source": "documentData",
"status": "PASS",
- "value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a",
+ "value": "85928568-22f2-424c-8896-a8e107ce3738",
"message": "String is populated"
},
{
@@ -60,7 +60,7 @@
"field": "documentData.contact.id",
"source": "documentData",
"status": "PASS",
- "value": "951023ad-0424-4c05-9db1-c5004d525e23",
+ "value": "307bcf2f-46df-4ab7-b090-872b99338bf1",
"message": "String is populated"
},
{
@@ -95,14 +95,14 @@
"field": "documentData.quotation.id",
"source": "documentData",
"status": "PASS",
- "value": "9a7333a0-bb43-4d6e-b178-aae196456a31",
+ "value": "f9290e40-0836-431d-9d9f-0ff25fa2bcc3",
"message": "String is populated"
},
{
"field": "documentData.quotation.code",
"source": "documentData",
"status": "PASS",
- "value": "CR2606-1124",
+ "value": "CRA2606-1108",
"message": "String is populated"
},
{
@@ -172,7 +172,7 @@
"field": "documentData.items[0].id",
"source": "documentData",
"status": "PASS",
- "value": "81cf1a1a-0ae0-4f6d-bb24-1315b0de5363",
+ "value": "2822652d-cab6-4b00-8b21-93a8b0c3d33f",
"message": "String is populated"
},
{
@@ -228,7 +228,7 @@
"field": "documentData.items[1].id",
"source": "documentData",
"status": "PASS",
- "value": "3cfad159-e274-49f4-a195-0e3ae39ee5f9",
+ "value": "d080c242-c39b-4192-adab-f28fc0171389",
"message": "String is populated"
},
{
@@ -284,7 +284,7 @@
"field": "documentData.items[2].id",
"source": "documentData",
"status": "PASS",
- "value": "3f48546a-2685-462e-a21d-e25ccc1348fd",
+ "value": "de54b922-db69-42e3-a93c-940330852362",
"message": "String is populated"
},
{
@@ -340,7 +340,7 @@
"field": "documentData.items[3].id",
"source": "documentData",
"status": "PASS",
- "value": "9f0ef323-dbfc-4710-a90f-501f185a165a",
+ "value": "c903ac48-45c5-43b1-8948-1a196ec0d576",
"message": "String is populated"
},
{
@@ -403,21 +403,21 @@
"field": "documentData.quotationCustomers[0].id",
"source": "documentData",
"status": "PASS",
- "value": "fefd5c1a-cc0a-4365-9492-e00feef8d38e",
+ "value": "c15f26c7-2f23-44fc-84e2-0a435901f887",
"message": "String is populated"
},
{
"field": "documentData.quotationCustomers[0].customerId",
"source": "documentData",
"status": "PASS",
- "value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a",
+ "value": "85928568-22f2-424c-8896-a8e107ce3738",
"message": "String is populated"
},
{
"field": "documentData.quotationCustomers[0].role",
"source": "documentData",
"status": "PASS",
- "value": "ed72c8c9-a690-4c93-afb7-9720a5fc7b3d",
+ "value": "a6170538-f685-4f8d-bf77-36ae82f87376",
"message": "String is populated"
},
{
@@ -438,7 +438,7 @@
"field": "documentData.topics.all[0].id",
"source": "documentData",
"status": "PASS",
- "value": "e6e4b61e-24a4-4ca9-a1d0-2cd3981ab59c",
+ "value": "396189b7-ca6c-4fc4-80dd-04f3f903f77b",
"message": "String is populated"
},
{
@@ -501,7 +501,7 @@
"field": "documentData.topics.all[1].id",
"source": "documentData",
"status": "PASS",
- "value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9",
+ "value": "41b30c9e-2adb-4407-b5f6-f803cc08bdb4",
"message": "String is populated"
},
{
@@ -564,7 +564,7 @@
"field": "documentData.topics.all[2].id",
"source": "documentData",
"status": "PASS",
- "value": "85d82f36-3e8f-4e46-8357-84879c728a49",
+ "value": "1ef3d0b6-b722-42e1-b8c1-e328fbb1bb57",
"message": "String is populated"
},
{
@@ -634,7 +634,7 @@
"field": "documentData.topics.exclusion[0].id",
"source": "documentData",
"status": "PASS",
- "value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9",
+ "value": "41b30c9e-2adb-4407-b5f6-f803cc08bdb4",
"message": "String is populated"
},
{
@@ -921,7 +921,7 @@
"field": "templateInput.quotation_code_data",
"source": "templateInput",
"status": "PASS",
- "value": "CR2606-1124",
+ "value": "CRA2606-1108",
"message": "String is populated"
},
{
@@ -1075,7 +1075,7 @@
"field": "templateInput.quotation_code",
"source": "templateInput",
"status": "PASS",
- "value": "CR2606-1124",
+ "value": "CRA2606-1108",
"message": "String is populated"
},
{
diff --git a/artifacts/pdf-audit/signature-audit-report.json b/artifacts/pdf-audit/signature-audit-report.json
index a1d1ea1..5843580 100644
--- a/artifacts/pdf-audit/signature-audit-report.json
+++ b/artifacts/pdf-audit/signature-audit-report.json
@@ -1,10 +1,10 @@
{
"audit": "approved-snapshot-parity",
"overallStatus": "PASS",
- "quotationCode": "CR2606-1124",
+ "quotationCode": "CRA2606-1108",
"approvedSnapshotAvailable": true,
- "approvedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
- "computedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
+ "approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
+ "computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
"inconsistentTemplateKeys": [],
"computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
"storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json
index 15f2bd8..97b502d 100644
--- a/artifacts/pdf-audit/summary.json
+++ b/artifacts/pdf-audit/summary.json
@@ -1,7 +1,7 @@
{
- "generatedAt": "2026-06-30T05:56:31.314Z",
+ "generatedAt": "2026-06-30T08:23:40.205Z",
"overallStatus": "PASS",
- "quotationCode": "CR2606-1124",
+ "quotationCode": "CRA2606-1108",
"artifacts": {
"templateVersionReport": "template-version-report.json",
"placeholderIntegrityReport": "placeholder-integrity-report.json",
diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md
index d185cbd..4b279ee 100644
--- a/artifacts/pdf-audit/summary.md
+++ b/artifacts/pdf-audit/summary.md
@@ -1,8 +1,8 @@
# PDF Integrity Summary
-- Generated At: 2026-06-30T05:56:31.314Z
+- Generated At: 2026-06-30T08:23:40.205Z
- Overall Status: PASS
-- Quotation Code: CR2606-1124
+- Quotation Code: CRA2606-1108
## Statuses
diff --git a/artifacts/pdf-audit/template-version-report.json b/artifacts/pdf-audit/template-version-report.json
index 090fa52..e460b67 100644
--- a/artifacts/pdf-audit/template-version-report.json
+++ b/artifacts/pdf-audit/template-version-report.json
@@ -2,7 +2,7 @@
"audit": "template-version-integrity",
"overallStatus": "PASS",
"activeTemplateCount": 1,
- "quotationCode": "CR2606-1124",
+ "quotationCode": "CRA2606-1108",
"approvedTemplateVersionId": null,
"approvedTemplateVersionIsActive": false,
"issues": []
diff --git a/artifacts/pdf-audit/topic-runtime-report.json b/artifacts/pdf-audit/topic-runtime-report.json
index d324084..fd65a3d 100644
--- a/artifacts/pdf-audit/topic-runtime-report.json
+++ b/artifacts/pdf-audit/topic-runtime-report.json
@@ -1,7 +1,7 @@
{
"audit": "topic-runtime",
"overallStatus": "PASS",
- "quotationCode": "CR2606-1124",
+ "quotationCode": "CRA2606-1108",
"topicCount": 3,
"topicInputKeys": [
"topic_1_0",
diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json
index ae2e14b..6879a8e 100644
--- a/docs/implementation/pdf-audit-report.json
+++ b/docs/implementation/pdf-audit-report.json
@@ -1,12 +1,12 @@
{
- "generatedAt": "2026-06-30T05:56:30.085Z",
+ "generatedAt": "2026-06-30T08:23:36.291Z",
"overallStatus": "PASS",
- "quotationCode": "CR2606-1124",
+ "quotationCode": "CRA2606-1108",
"expectedFixtureCode": "QT-H5-AUDIT",
"template": {
"templateName": "ALLA Demo Organization Quotation Standard",
"version": "1.0",
- "versionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
+ "versionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
"fieldCount": 44,
"previousVersion": null,
"deletedFields": [],
diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md
index b95bb9d..d67b5c3 100644
--- a/docs/implementation/pdf-audit-report.md
+++ b/docs/implementation/pdf-audit-report.md
@@ -1,8 +1,8 @@
# PDF Audit Report
-- Generated At: 2026-06-30T05:56:30.085Z
+- Generated At: 2026-06-30T08:23:36.291Z
- Overall Status: PASS
-- Quotation Code: CR2606-1124
+- Quotation Code: CRA2606-1108
- Template: ALLA Demo Organization Quotation Standard v1.0
- Previous Version: None
diff --git a/docs/implementation/task-p.7-implementation.md b/docs/implementation/task-p.7-implementation.md
new file mode 100644
index 0000000..226acbc
--- /dev/null
+++ b/docs/implementation/task-p.7-implementation.md
@@ -0,0 +1,110 @@
+# Task P.7 Implementation Report
+
+## Scope
+This report documents implementation completed for
+[task-p.7.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.7.md).
+
+Focus round:
+- add reusable Document Assembly foundation on top of existing PDF and storage runtime
+- integrate default quotation assembly flow
+- store assembled PDFs as separate artifacts
+- record implementation and verification outputs
+
+---
+
+## Review Inputs
+Reviewed before implementation:
+- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md)
+- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md)
+- [docs/adr/0008-attachment-storage-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0008-attachment-storage-strategy.md)
+- [docs/adr/0009-approved-document-storage-lifecycle.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0009-approved-document-storage-lifecycle.md)
+- [docs/adr/0013-pdf-visual-parity-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0013-pdf-visual-parity-strategy.md)
+- [docs/implementation/task-p.6-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.6-implementation.md)
+
+Foundations reused:
+- quotation PDF generation from [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts)
+- artifact lifecycle from [src/features/foundation/document-artifact/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-artifact/server/service.ts)
+- static append document storage from [src/features/foundation/document-library](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-library)
+
+---
+
+## What Changed
+### 1. New generic assembly foundation
+Added [src/features/foundation/document-assembly](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly) with:
+- policy model for ordered `generated` and `document_library` sources
+- deterministic document-library resolution using brand/product-type fallback rules
+- PDF readability validation and page counting
+- server-side merge using `@pdfme/pdf-lib`
+- optional-source warnings and required-source business errors
+
+### 2. Separate assembled artifact lifecycle
+Assembled quotation PDFs now persist as a separate `assembled_pdf` artifact and do not replace:
+- preview/download quotation runtime output
+- approved quotation PDF artifact
+
+Stored metadata includes:
+- source quotation id and code
+- resolved assembly policy
+- resolved source artifacts
+- warnings
+- checksum, file size, page count
+- generation timestamp and actor
+
+### 3. Quotation integration routes
+Added quotation endpoints:
+- [src/app/api/crm/quotations/[id]/assembled-pdf/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts)
+- [src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts)
+
+Behavior:
+- `POST` generates and persists assembled quotation PDF
+- `GET` previews stored assembled PDF inline
+- `GET /download` returns stored assembled PDF as attachment
+
+### 4. Default quotation policy
+Initial policy now resolves:
+1. generated quotation PDF
+2. active SLA document as required
+3. active warranty as optional
+4. datasheet as optional
+5. drawing as optional
+
+Brand and product type are normalized from quotation/runtime data so legacy codes such as `dockdoor` and `solarcell` align with document-library dimensions.
+
+---
+
+## Design Decisions
+### Keep approved PDF flow unchanged
+P.7 adds a new assembly layer instead of altering the existing approved-PDF runtime. This keeps prior acceptance from P.4-P.6 stable.
+
+### Prefer artifact metadata over new schema
+`crm_document_artifacts.metadata` already supports rich JSON payloads, so P.7 can store assembly provenance without a migration.
+
+### Deterministic fallback over manual selection
+Document-library resolution currently prefers:
+- exact brand over `generic`
+- exact product type over `all`
+- then stable code ordering
+
+This gives predictable output now while keeping room for future render configuration UI.
+
+---
+
+## Deliverables Completed
+- generic document assembly service and types
+- document-library resolution and PDF merge helpers
+- quotation assembled PDF generation and retrieval flow
+- new assembled preview/download APIs
+- focused assembly tests
+- implementation and verification reports
+
+---
+
+## Known Gaps
+- No UI trigger was added in this round; API/service foundation is ready first.
+- Datasheet and drawing remain optional and depend on library coverage being seeded or uploaded.
+- Assembly policy is code-based for now; no admin configuration UI yet.
+
+---
+
+## Outcome
+P.7 now gives the system a reusable server-side PDF assembly foundation and enables quotation PDFs to be merged with active document-library appendices into a separately stored customer-facing artifact.
diff --git a/docs/implementation/task-p.7-verification-report.md b/docs/implementation/task-p.7-verification-report.md
new file mode 100644
index 0000000..57a2e32
--- /dev/null
+++ b/docs/implementation/task-p.7-verification-report.md
@@ -0,0 +1,70 @@
+# Task P.7 Verification Report
+
+## Scope
+This report verifies implementation for
+[task-p.7.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.7.md).
+
+Verified focus round:
+- assembly foundation compile safety
+- merge and validation behavior
+- quotation API integration
+- artifact separation from approved PDF flow
+
+---
+
+## Files Verified
+Primary implementation files:
+- [src/features/foundation/document-assembly/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly/types.ts)
+- [src/features/foundation/document-assembly/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly/server/service.ts)
+- [src/features/foundation/document-assembly/server/service.test.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly/server/service.test.ts)
+- [src/app/api/crm/quotations/[id]/assembled-pdf/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts)
+- [src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts)
+
+Related reused foundations spot-checked:
+- [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts)
+- [src/features/foundation/document-artifact/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-artifact/server/service.ts)
+- [src/features/foundation/document-library/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-library/server/service.ts)
+
+---
+
+## Commands
+Verification commands intended for this round:
+
+```bash
+npm run typecheck
+npm exec tsx --test src/features/foundation/document-assembly/server/service.test.ts
+npm run build
+npm run audit:pdf
+```
+
+Actual results on 2026-06-30:
+- `npm run typecheck` = PASS
+- `npm exec tsx --test src/features/foundation/document-assembly/server/service.test.ts` = PASS
+- `npm run audit:pdf` = PASS
+- `npm run build` = NOT COMPLETED because Next.js reported another build process already running while the workspace also had an active `next dev` session using the same `.next` tree
+
+---
+
+## Acceptance Check
+Current implementation coverage:
+- generic assembly engine exists = IMPLEMENTED
+- supports generated PDF plus document-library PDFs = IMPLEMENTED
+- quotation can generate assembled PDF = IMPLEMENTED
+- assembled PDF stored separately from approved PDF = IMPLEMENTED
+- deterministic document ordering = IMPLEMENTED
+- required missing document fails clearly = IMPLEMENTED
+- optional missing document warns and skips = IMPLEMENTED
+- audit trail added for assembly lifecycle = IMPLEMENTED
+
+Items still dependent on runtime verification:
+- clean `npm run build` pass with no concurrent Next process = BLOCKED BY ACTIVE WORKSPACE PROCESS
+- live seeded document-library scenario = PENDING
+
+---
+
+## Residual Risk
+Remaining risk after implementation:
+- current default language is fixed to `th`, so multilingual assembly still needs a future selection rule or UI
+- only focused unit coverage was added; no browser E2E round yet
+- real assembly success for optional datasheet/drawing depends on document-library content coverage per organization
+- production build verification should be rerun once the active local Next process no longer owns the shared build directory
diff --git a/next-env.d.ts b/next-env.d.ts
index c4b7818..9edff1c 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/plans/task-p.7.md b/plans/task-p.7.md
new file mode 100644
index 0000000..a6e9cdc
--- /dev/null
+++ b/plans/task-p.7.md
@@ -0,0 +1,364 @@
+# Task P.7 - Document Assembly Framework
+
+## Objective
+
+Build a generic Document Assembly Framework that can merge generated CRM PDFs with static PDF documents from the Document Library.
+
+Primary use case:
+
+```text
+Quotation PDF
++ SLA PDF
++ Warranty PDF
++ Datasheet PDF
+= Final Customer PDF
+```
+
+This task must be generic and reusable for future document types.
+
+---
+
+# Prerequisites
+
+Completed:
+
+* P.4 PDF Runtime
+* P.5 Document Template Management
+* P.5.1 Production Hardening
+* P.6 Document Library Foundation
+* P.6.1 Document Sequence Scope Refactor
+
+---
+
+# Scope
+
+Included:
+
+* Generic assembly engine
+* PDF merge using pdfme manipulator or approved merge library
+* Assembly policy model
+* Document source resolution
+* Document ordering
+* Storage of final assembled artifact
+* Quotation integration
+* Audit and regression checks
+
+Excluded:
+
+* Render Configuration UI
+* User-selectable append options
+* Template Designer
+* New PDF template design
+* Editing SLA/Warranty contents
+
+---
+
+# Core Concept
+
+The framework must not be hardcoded to Quotation only.
+
+Design around:
+
+```text
+Input Documents
+↓
+Assembly Policy
+↓
+Assembly Engine
+↓
+Merged PDF
+↓
+Final Artifact
+```
+
+---
+
+# Supported Input Sources
+
+The assembly engine must support these sources:
+
+## 1. Generated Document
+
+Example:
+
+* Quotation PDF generated from PDF Runtime
+
+## 2. Document Library PDF
+
+Example:
+
+* SLA
+* Warranty
+* Company Profile
+* Datasheet
+* Drawing
+* Certificate
+
+## 3. Future External Attachment
+
+Reserved for later phases.
+
+---
+
+# Assembly Policy
+
+Create an assembly policy model.
+
+Example:
+
+```ts
+interface AssemblyPolicy {
+ documentType: string;
+ organizationId: string;
+ productType?: string;
+ brand?: string;
+ language?: string;
+ sources: AssemblySourcePolicy[];
+}
+```
+
+```ts
+interface AssemblySourcePolicy {
+ sourceType: 'generated' | 'document_library';
+ role: 'main' | 'sla' | 'warranty' | 'datasheet' | 'drawing' | 'appendix';
+ required: boolean;
+ order: number;
+ libraryCode?: string;
+}
+```
+
+---
+
+# Default Quotation Assembly
+
+Initial quotation policy:
+
+```text
+1. Generated Quotation PDF
+2. SLA PDF
+3. Warranty PDF
+4. Datasheet / Drawing if configured
+```
+
+Do not make append documents user-selectable yet.
+
+Use default policy only.
+
+---
+
+# Document Resolution
+
+The engine must resolve Document Library versions by:
+
+* organization
+* brand
+* product type
+* language
+* document type
+* active version
+
+Resolution must be deterministic.
+
+If multiple candidates exist, apply clear priority rules.
+
+---
+
+# Missing Document Behavior
+
+If a required append document is missing:
+
+* assembly must fail with a business-readable error
+
+If an optional append document is missing:
+
+* skip and record warning
+
+---
+
+# PDF Merge
+
+Use pdfme manipulator merge capability or an approved server-side PDF merge utility.
+
+Requirements:
+
+* preserve page order
+* preserve all pages
+* support multiple input PDFs
+* return final merged PDF bytes
+* avoid mutating source PDFs
+
+---
+
+# Final Artifact
+
+Store the assembled PDF as a new artifact.
+
+Metadata should include:
+
+* id
+* organizationId
+* sourceDocumentType
+* sourceDocumentId
+* sourceDocumentCode
+* assemblyPolicy
+* sourceArtifacts
+* finalFileName
+* storageKey
+* checksum
+* fileSize
+* pageCount
+* generatedAt
+* generatedBy
+
+Do not overwrite approved quotation PDF artifact.
+
+The assembled artifact is a separate output.
+
+---
+
+# Quotation Integration
+
+Add quotation-level service support:
+
+```text
+generate quotation PDF
+↓
+resolve assembly policy
+↓
+resolve append documents
+↓
+merge
+↓
+persist final assembled artifact
+↓
+return preview/download
+```
+
+Recommended API endpoints:
+
+```text
+GET /api/crm/quotations/[id]/assembled-pdf
+POST /api/crm/quotations/[id]/assembled-pdf/generate
+GET /api/crm/quotations/[id]/assembled-pdf/download
+```
+
+---
+
+# Storage
+
+Reuse existing storage abstraction.
+
+Do not store merged PDF binary in database.
+
+Store:
+
+* storage key
+* checksum
+* file size
+* page count
+* metadata
+
+---
+
+# Audit Logging
+
+Record audit logs for:
+
+* assembly started
+* source document resolved
+* source document missing
+* merge completed
+* artifact stored
+* assembly failed
+* assembled artifact downloaded
+
+---
+
+# Validation
+
+Validate before merge:
+
+* generated main PDF exists
+* append PDF exists
+* file is PDF
+* file can be read
+* page count can be determined
+* source order is valid
+* required source documents exist
+
+---
+
+# Testing Requirements
+
+Add tests for:
+
+* generated-only assembly
+* quotation + SLA
+* quotation + SLA + Warranty
+* optional missing document
+* required missing document
+* invalid source PDF
+* page order
+* page count
+* checksum generation
+* artifact persistence metadata
+
+Run:
+
+```bash
+npm run typecheck
+npm run build
+npm run audit:pdf
+```
+
+---
+
+# Regression Requirements
+
+Verify:
+
+* legacy quotation PDF still works
+* approved quotation PDF still works
+* Document Library preview/download still works
+* product template PDF still works
+* assembled PDF does not replace source PDF
+* assembled PDF artifact is stored separately
+
+---
+
+# Acceptance Criteria
+
+* Generic assembly engine exists.
+* Engine supports generated PDF + Document Library PDFs.
+* Quotation can generate assembled PDF.
+* SLA can be appended after quotation.
+* Document order is deterministic.
+* Missing required documents fail clearly.
+* Missing optional documents warn and continue.
+* Final merged artifact is stored through storage abstraction.
+* Source PDFs are not mutated.
+* Existing PDF runtime remains unchanged.
+* System is ready for future Render Configuration.
+
+---
+
+# Out of Scope
+
+## P.8
+
+* User-selectable append documents
+* Optional section UI
+* Per-customer render preferences
+* Per-quotation assembly choices
+
+## P.9
+
+* Template Designer
+* Visual PDF editor
+* Upload/import template designer
+
+---
+
+# Final Success Condition
+
+Task P.7 is complete when ALLA OS can generate a final assembled quotation PDF by merging the generated quotation PDF with active Document Library PDFs such as SLA or Warranty, store the merged result as a separate artifact, and keep all existing PDF runtime behavior unchanged.
diff --git a/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts b/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts
new file mode 100644
index 0000000..58d042f
--- /dev/null
+++ b/src/app/api/crm/quotations/[id]/assembled-pdf/download/route.ts
@@ -0,0 +1,58 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getStoredAssembledQuotationPdf } from '@/features/foundation/document-assembly/server/service';
+import {
+ auditCrmSecurityEvent,
+ buildCrmSecurityContext,
+ canViewQuotationPricing,
+} from '@/features/crm/security/server/service';
+import { PERMISSIONS } from '@/lib/auth/rbac';
+import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
+
+type Params = { params: Promise<{ id: string }> };
+
+export async function GET(_request: NextRequest, { params }: Params) {
+ try {
+ const { id } = await params;
+ const { organization, session, membership } = await requireOrganizationAccess(
+ { permission: PERMISSIONS.crmQuotationPdfDownload },
+ );
+ const securityContext = buildCrmSecurityContext({
+ organizationId: organization.id,
+ userId: session.user.id,
+ membership,
+ });
+
+ if (!canViewQuotationPricing(securityContext)) {
+ await auditCrmSecurityEvent({
+ organizationId: organization.id,
+ userId: session.user.id,
+ action: 'pricing_hidden',
+ entityType: 'crm_quotation',
+ entityId: id,
+ reason: 'Assembled quotation PDF download requires pricing visibility',
+ });
+ return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
+ }
+
+ const pdf = await getStoredAssembledQuotationPdf(id, organization.id, {
+ userId: session.user.id,
+ auditDownload: true,
+ });
+
+ return new NextResponse(pdf.buffer, {
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': `attachment; filename="${pdf.fileName}"`,
+ },
+ });
+ } catch (error) {
+ if (error instanceof AuthError) {
+ return NextResponse.json({ message: error.message }, { status: error.status });
+ }
+
+ return NextResponse.json(
+ { message: 'Unable to download assembled quotation PDF' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/crm/quotations/[id]/assembled-pdf/generate/route.ts b/src/app/api/crm/quotations/[id]/assembled-pdf/generate/route.ts
new file mode 100644
index 0000000..14e287d
--- /dev/null
+++ b/src/app/api/crm/quotations/[id]/assembled-pdf/generate/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { generateAssembledQuotationPdf } from '@/features/foundation/document-assembly/server/service';
+import {
+ auditCrmSecurityEvent,
+ buildCrmSecurityContext,
+ canViewQuotationPricing,
+} from '@/features/crm/security/server/service';
+import { PERMISSIONS } from '@/lib/auth/rbac';
+import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
+
+type Params = { params: Promise<{ id: string }> };
+
+export async function POST(_request: NextRequest, { params }: Params) {
+ try {
+ const { id } = await params;
+ const { organization, session, membership } = await requireOrganizationAccess(
+ { permission: PERMISSIONS.crmQuotationPdfDownload },
+ );
+ const securityContext = buildCrmSecurityContext({
+ organizationId: organization.id,
+ userId: session.user.id,
+ membership,
+ });
+
+ if (!canViewQuotationPricing(securityContext)) {
+ await auditCrmSecurityEvent({
+ organizationId: organization.id,
+ userId: session.user.id,
+ action: 'pricing_hidden',
+ entityType: 'crm_quotation',
+ entityId: id,
+ reason: 'Assembled quotation PDF generation requires pricing visibility',
+ });
+ return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
+ }
+
+ const assembled = await generateAssembledQuotationPdf(
+ id,
+ organization.id,
+ session.user.id,
+ );
+
+ return NextResponse.json({
+ success: true,
+ fileName: assembled.fileName,
+ checksum: assembled.checksum,
+ fileSize: assembled.fileSize,
+ pageCount: assembled.pageCount,
+ warnings: assembled.warnings,
+ artifact: assembled.artifact,
+ });
+ } catch (error) {
+ if (error instanceof AuthError) {
+ return NextResponse.json({ message: error.message }, { status: error.status });
+ }
+
+ return NextResponse.json(
+ { message: 'Unable to generate assembled quotation PDF' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts b/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts
new file mode 100644
index 0000000..0c80228
--- /dev/null
+++ b/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts
@@ -0,0 +1,57 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getStoredAssembledQuotationPdf } from '@/features/foundation/document-assembly/server/service';
+import {
+ auditCrmSecurityEvent,
+ buildCrmSecurityContext,
+ canViewQuotationPricing,
+} from '@/features/crm/security/server/service';
+import { PERMISSIONS } from '@/lib/auth/rbac';
+import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
+
+type Params = { params: Promise<{ id: string }> };
+
+export async function GET(_request: NextRequest, { params }: Params) {
+ try {
+ const { id } = await params;
+ const { organization, session, membership } = await requireOrganizationAccess(
+ { permission: PERMISSIONS.crmQuotationPdfPreview },
+ );
+ const securityContext = buildCrmSecurityContext({
+ organizationId: organization.id,
+ userId: session.user.id,
+ membership,
+ });
+
+ if (!canViewQuotationPricing(securityContext)) {
+ await auditCrmSecurityEvent({
+ organizationId: organization.id,
+ userId: session.user.id,
+ action: 'pricing_hidden',
+ entityType: 'crm_quotation',
+ entityId: id,
+ reason: 'Assembled quotation PDF preview requires pricing visibility',
+ });
+ return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
+ }
+
+ const pdf = await getStoredAssembledQuotationPdf(id, organization.id, {
+ userId: session.user.id,
+ });
+
+ return new NextResponse(pdf.buffer, {
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': `inline; filename="${pdf.fileName}"`,
+ },
+ });
+ } catch (error) {
+ if (error instanceof AuthError) {
+ return NextResponse.json({ message: error.message }, { status: error.status });
+ }
+
+ return NextResponse.json(
+ { message: 'Unable to load assembled quotation PDF' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/features/foundation/document-artifact/server/service.ts b/src/features/foundation/document-artifact/server/service.ts
index ef15479..d623dfa 100644
--- a/src/features/foundation/document-artifact/server/service.ts
+++ b/src/features/foundation/document-artifact/server/service.ts
@@ -92,7 +92,7 @@ export async function createArtifact(input: {
entityType: string;
entityId: string;
documentType: string;
- artifactType: 'preview' | 'approved_pdf' | 'export';
+ artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export';
templateVersionId?: string | null;
storageProvider: string;
storageKey: string;
diff --git a/src/features/foundation/document-assembly/server/helpers.ts b/src/features/foundation/document-assembly/server/helpers.ts
new file mode 100644
index 0000000..08e4095
--- /dev/null
+++ b/src/features/foundation/document-assembly/server/helpers.ts
@@ -0,0 +1,89 @@
+import { PDFDocument } from '@pdfme/pdf-lib';
+import type {
+ DocumentLibraryBrand,
+ DocumentLibraryProductType,
+} from '@/features/foundation/document-library/types';
+import type { AssemblySourcePolicy, ResolvedAssemblySource } from '../types';
+
+export function normalizeAssemblyBrand(
+ value: string | null | undefined,
+): DocumentLibraryBrand {
+ const normalized = String(value ?? '')
+ .trim()
+ .toLowerCase();
+
+ if (normalized === 'alla') {
+ return 'alla';
+ }
+
+ if (normalized === 'onvalla') {
+ return 'onvalla';
+ }
+
+ return 'generic';
+}
+
+export function normalizeAssemblyProductType(
+ value: string | null | undefined,
+): DocumentLibraryProductType {
+ const normalized = String(value ?? '')
+ .trim()
+ .toLowerCase();
+
+ if (normalized === 'crane') {
+ return 'crane';
+ }
+
+ if (normalized === 'dockdoor' || normalized === 'dock_door') {
+ return 'dock_door';
+ }
+
+ if (normalized === 'solarcell' || normalized === 'solar') {
+ return 'solar';
+ }
+
+ if (normalized === 'service') {
+ return 'service';
+ }
+
+ return 'all';
+}
+
+export function validateAssemblySourceOrdering(sources: AssemblySourcePolicy[]) {
+ const seenOrders = new Set();
+
+ for (const source of sources) {
+ if (seenOrders.has(source.order)) {
+ throw new Error(`Duplicate assembly source order: ${source.order}`);
+ }
+
+ seenOrders.add(source.order);
+ }
+}
+
+export async function getPdfPageCount(buffer: Buffer, label: string) {
+ try {
+ const document = await PDFDocument.load(buffer);
+ return document.getPageCount();
+ } catch {
+ throw new Error(`${label} is not a readable PDF document.`);
+ }
+}
+
+export async function mergePdfBuffers(sources: ResolvedAssemblySource[]) {
+ const merged = await PDFDocument.create();
+
+ for (const source of sources) {
+ const document = await PDFDocument.load(source.buffer);
+ const pages = await merged.copyPages(
+ document,
+ document.getPageIndices(),
+ );
+
+ for (const page of pages) {
+ merged.addPage(page);
+ }
+ }
+
+ return Buffer.from(await merged.save());
+}
diff --git a/src/features/foundation/document-assembly/server/service.test.ts b/src/features/foundation/document-assembly/server/service.test.ts
new file mode 100644
index 0000000..9574635
--- /dev/null
+++ b/src/features/foundation/document-assembly/server/service.test.ts
@@ -0,0 +1,96 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { PDFDocument } from '@pdfme/pdf-lib';
+import type { ResolvedAssemblySource } from '../types';
+import {
+ getPdfPageCount,
+ mergePdfBuffers,
+ normalizeAssemblyBrand,
+ normalizeAssemblyProductType,
+ validateAssemblySourceOrdering,
+} from './helpers';
+
+async function createPdf(pageCount: number) {
+ const document = await PDFDocument.create();
+
+ for (let index = 0; index < pageCount; index += 1) {
+ document.addPage([595, 842]);
+ }
+
+ return Buffer.from(await document.save());
+}
+
+function createSource(input: {
+ role: string;
+ order: number;
+ pageCount: number;
+}): Promise {
+ return createPdf(input.pageCount).then((buffer) => ({
+ role: input.role,
+ sourceType: 'generated',
+ required: true,
+ order: input.order,
+ fileName: `${input.role}.pdf`,
+ contentType: 'application/pdf',
+ checksum: input.role,
+ pageCount: input.pageCount,
+ fileSize: buffer.byteLength,
+ metadata: {},
+ buffer,
+ }));
+}
+
+test('normalizeAssemblyProductType aligns legacy quotation codes', () => {
+ assert.equal(normalizeAssemblyProductType('dockdoor'), 'dock_door');
+ assert.equal(normalizeAssemblyProductType('solarcell'), 'solar');
+ assert.equal(normalizeAssemblyProductType('crane'), 'crane');
+ assert.equal(normalizeAssemblyProductType('unknown'), 'all');
+});
+
+test('normalizeAssemblyBrand falls back to generic', () => {
+ assert.equal(normalizeAssemblyBrand('alla'), 'alla');
+ assert.equal(normalizeAssemblyBrand('onvalla'), 'onvalla');
+ assert.equal(normalizeAssemblyBrand('unknown'), 'generic');
+});
+
+test('validateAssemblySourceOrdering rejects duplicate orders', () => {
+ assert.throws(
+ () =>
+ validateAssemblySourceOrdering([
+ {
+ sourceType: 'generated',
+ role: 'main',
+ required: true,
+ order: 1,
+ },
+ {
+ sourceType: 'document_library',
+ role: 'sla',
+ required: true,
+ order: 1,
+ documentType: 'sla',
+ },
+ ]),
+ /Duplicate assembly source order/,
+ );
+});
+
+test('mergePdfBuffers preserves page order and page count', async () => {
+ const sources = await Promise.all([
+ createSource({ role: 'main', order: 1, pageCount: 2 }),
+ createSource({ role: 'sla', order: 2, pageCount: 1 }),
+ createSource({ role: 'warranty', order: 3, pageCount: 3 }),
+ ]);
+
+ const merged = await mergePdfBuffers(sources);
+ const pageCount = await getPdfPageCount(merged, 'merged-test');
+
+ assert.equal(pageCount, 6);
+});
+
+test('getPdfPageCount fails for invalid pdf source', async () => {
+ await assert.rejects(
+ () => getPdfPageCount(Buffer.from('not-a-pdf'), 'invalid-source'),
+ /not a readable PDF document/,
+ );
+});
diff --git a/src/features/foundation/document-assembly/server/service.ts b/src/features/foundation/document-assembly/server/service.ts
new file mode 100644
index 0000000..790a79a
--- /dev/null
+++ b/src/features/foundation/document-assembly/server/service.ts
@@ -0,0 +1,661 @@
+import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
+import { crmDocumentLibraries, crmDocumentLibraryVersions } from '@/db/schema';
+import { db } from '@/lib/db';
+import { AuthError } from '@/lib/auth/session';
+import { auditAction } from '@/features/foundation/audit-log/service';
+import {
+ createArtifact,
+ downloadArtifact,
+ getActiveArtifactForEntity,
+ voidArtifact,
+} from '@/features/foundation/document-artifact/server/service';
+import {
+ type DocumentLibraryBrand,
+ type DocumentLibraryProductType,
+ type DocumentLibraryType,
+} from '@/features/foundation/document-library/types';
+import {
+ buildOrganizationStorageKey,
+ getStorageProvider,
+} from '@/features/foundation/storage/service';
+import { sha256 } from '@/features/foundation/storage/server/checksum';
+import {
+ buildQuotationDocumentData,
+ getQuotationDocumentPreviewData,
+} from '@/features/crm/quotations/document/server/service';
+import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service';
+import type {
+ AssemblyPolicy,
+ AssemblySourcePolicy,
+ AssemblyWarning,
+ DocumentAssemblyResult,
+ ResolvedAssemblySource,
+} from '../types';
+import {
+ getPdfPageCount,
+ mergePdfBuffers,
+ normalizeAssemblyBrand,
+ normalizeAssemblyProductType,
+ validateAssemblySourceOrdering,
+} from './helpers';
+
+type ResolvedDocumentLibraryCandidate = {
+ library: typeof crmDocumentLibraries.$inferSelect;
+ version: typeof crmDocumentLibraryVersions.$inferSelect;
+};
+
+type AssembleDocumentInput = {
+ organizationId: string;
+ userId: string;
+ entityType: string;
+ entityId: string;
+ fileName: string;
+ artifactType: 'assembled_pdf';
+ policy: AssemblyPolicy;
+ sourceResolver: (
+ source: AssemblySourcePolicy,
+ ) => Promise;
+ auditEntityType?: string;
+};
+
+type QuotationAssemblyMetadata = {
+ documentType: string;
+ sourceDocumentType: string;
+ sourceDocumentId: string;
+ sourceDocumentCode: string;
+ assemblyPolicy: AssemblyPolicy;
+ sourceArtifacts: Array>;
+ warnings: AssemblyWarning[];
+ pageCount: number;
+ generatedAt: string;
+ generatedBy: string;
+};
+
+const ASSEMBLED_PDF_ARTIFACT_TYPE = 'assembled_pdf';
+const DEFAULT_ASSEMBLY_LANGUAGE = 'th';
+
+function sanitizeFileName(fileName: string) {
+ return fileName.replace(/[^a-zA-Z0-9._-]+/g, '-');
+}
+
+function toLibraryDocumentLabel(documentType: DocumentLibraryType) {
+ return documentType.replace(/_/g, ' ').toUpperCase();
+}
+
+function toRoleLabel(role: string) {
+ return role.replace(/_/g, ' ').toUpperCase();
+}
+
+function buildDocumentLibraryMissingMessage(input: {
+ role: string;
+ documentType: DocumentLibraryType;
+ brand: DocumentLibraryBrand;
+ language: string;
+ productType: DocumentLibraryProductType;
+}) {
+ return `Missing ${toRoleLabel(input.role)} document (${toLibraryDocumentLabel(
+ input.documentType,
+ )}, brand ${input.brand}, language ${input.language}, product type ${input.productType}).`;
+}
+
+function getCandidateScore(input: {
+ candidate: typeof crmDocumentLibraries.$inferSelect;
+ brand: DocumentLibraryBrand;
+ productType: DocumentLibraryProductType;
+}) {
+ let score = 0;
+
+ if (input.candidate.brand === input.brand) {
+ score += 2;
+ }
+
+ if (input.candidate.productType === input.productType) {
+ score += 1;
+ }
+
+ return score;
+}
+
+export async function resolveActiveDocumentLibraryCandidate(input: {
+ organizationId: string;
+ documentType: DocumentLibraryType;
+ brand: DocumentLibraryBrand;
+ language: string;
+ productType: DocumentLibraryProductType;
+}): Promise {
+ const brandScope =
+ input.brand === 'generic' ? ['generic'] : [input.brand, 'generic'];
+ const productTypeScope =
+ input.productType === 'all' ? ['all'] : [input.productType, 'all'];
+
+ const rows = await db
+ .select({
+ library: crmDocumentLibraries,
+ version: crmDocumentLibraryVersions,
+ })
+ .from(crmDocumentLibraryVersions)
+ .innerJoin(
+ crmDocumentLibraries,
+ eq(crmDocumentLibraryVersions.libraryId, crmDocumentLibraries.id),
+ )
+ .where(
+ and(
+ eq(crmDocumentLibraries.organizationId, input.organizationId),
+ eq(crmDocumentLibraryVersions.organizationId, input.organizationId),
+ eq(crmDocumentLibraries.documentType, input.documentType),
+ eq(crmDocumentLibraries.language, input.language),
+ inArray(crmDocumentLibraries.brand, brandScope),
+ inArray(crmDocumentLibraries.productType, productTypeScope),
+ eq(crmDocumentLibraries.isActive, true),
+ eq(crmDocumentLibraries.status, 'active'),
+ eq(crmDocumentLibraryVersions.isActive, true),
+ eq(crmDocumentLibraryVersions.status, 'active'),
+ isNull(crmDocumentLibraries.deletedAt),
+ isNull(crmDocumentLibraryVersions.deletedAt),
+ ),
+ )
+ .orderBy(
+ desc(crmDocumentLibraryVersions.createdAt),
+ crmDocumentLibraries.code,
+ );
+
+ if (!rows.length) {
+ return null;
+ }
+
+ const [best] = [...rows].sort((left, right) => {
+ const scoreDelta =
+ getCandidateScore({
+ candidate: right.library,
+ brand: input.brand,
+ productType: input.productType,
+ }) -
+ getCandidateScore({
+ candidate: left.library,
+ brand: input.brand,
+ productType: input.productType,
+ });
+
+ if (scoreDelta !== 0) {
+ return scoreDelta;
+ }
+
+ const codeDelta = left.library.code.localeCompare(right.library.code);
+
+ if (codeDelta !== 0) {
+ return codeDelta;
+ }
+
+ return right.version.createdAt.getTime() - left.version.createdAt.getTime();
+ });
+
+ return best;
+}
+
+async function resolveDocumentLibrarySource(input: {
+ organizationId: string;
+ role: string;
+ required: boolean;
+ order: number;
+ documentType: DocumentLibraryType;
+ brand: DocumentLibraryBrand;
+ language: string;
+ productType: DocumentLibraryProductType;
+}) {
+ const candidate = await resolveActiveDocumentLibraryCandidate({
+ organizationId: input.organizationId,
+ documentType: input.documentType,
+ brand: input.brand,
+ language: input.language,
+ productType: input.productType,
+ });
+
+ if (!candidate) {
+ return null;
+ }
+
+ const object = await getStorageProvider().getObject({
+ key: candidate.version.storageKey,
+ });
+ const buffer = Buffer.from(await new Response(object.body).arrayBuffer());
+ const pageCount = await getPdfPageCount(
+ buffer,
+ `${toRoleLabel(input.role)} source`,
+ );
+
+ return {
+ role: input.role,
+ sourceType: 'document_library' as const,
+ required: input.required,
+ order: input.order,
+ fileName: candidate.version.fileName,
+ contentType: candidate.version.mimeType,
+ checksum: candidate.version.checksum,
+ pageCount,
+ fileSize: candidate.version.fileSize,
+ metadata: {
+ documentType: candidate.library.documentType,
+ libraryId: candidate.library.id,
+ libraryCode: candidate.library.code,
+ libraryName: candidate.library.name,
+ brand: candidate.library.brand,
+ language: candidate.library.language,
+ productType: candidate.library.productType,
+ versionId: candidate.version.id,
+ version: candidate.version.version,
+ storageKey: candidate.version.storageKey,
+ },
+ buffer,
+ };
+}
+
+async function persistAssembledArtifact(input: {
+ organizationId: string;
+ userId: string;
+ entityType: string;
+ entityId: string;
+ artifactType: 'assembled_pdf';
+ fileName: string;
+ buffer: Buffer;
+ checksum: string;
+ fileSize: number;
+ metadata: QuotationAssemblyMetadata;
+}) {
+ const existingArtifact = await getActiveArtifactForEntity({
+ organizationId: input.organizationId,
+ entityType: input.entityType,
+ entityId: input.entityId,
+ artifactType: input.artifactType,
+ });
+
+ if (existingArtifact?.status === 'active') {
+ await voidArtifact({
+ artifactId: existingArtifact.id,
+ organizationId: input.organizationId,
+ userId: input.userId,
+ reason: 'Superseded by newer assembled PDF artifact.',
+ });
+ }
+
+ const safeFileName = sanitizeFileName(input.fileName);
+ const storageKey = buildOrganizationStorageKey(input.organizationId, [
+ 'quotations',
+ 'assembled',
+ `${input.entityId}-${safeFileName}`,
+ ]);
+ const stored = await getStorageProvider().putObject({
+ key: storageKey,
+ body: input.buffer,
+ contentType: 'application/pdf',
+ fileName: safeFileName,
+ });
+
+ const artifact = await createArtifact({
+ organizationId: input.organizationId,
+ userId: input.userId,
+ entityType: input.entityType,
+ entityId: input.entityId,
+ documentType: input.metadata.sourceDocumentType,
+ artifactType: input.artifactType,
+ templateVersionId: null,
+ storageProvider: stored.provider,
+ storageKey: stored.key,
+ fileName: safeFileName,
+ contentType: 'application/pdf',
+ fileSize: input.fileSize,
+ checksum: input.checksum,
+ metadata: input.metadata,
+ });
+
+ return {
+ artifactId: artifact.id,
+ storageKey: artifact.storageKey,
+ fileName: artifact.fileName,
+ checksum: artifact.checksum ?? input.checksum,
+ fileSize: artifact.fileSize ?? input.fileSize,
+ pageCount: input.metadata.pageCount,
+ };
+}
+
+export async function assembleDocument(
+ input: AssembleDocumentInput,
+): Promise {
+ validateAssemblySourceOrdering(input.policy.sources);
+
+ const auditEntityType = input.auditEntityType ?? input.entityType;
+ const warnings: AssemblyWarning[] = [];
+
+ await auditAction({
+ organizationId: input.organizationId,
+ userId: input.userId,
+ entityType: auditEntityType,
+ entityId: input.entityId,
+ action: 'assembly_started',
+ afterData: { policy: input.policy },
+ });
+
+ try {
+ const sources: ResolvedAssemblySource[] = [];
+
+ for (const sourcePolicy of [...input.policy.sources].sort(
+ (left, right) => left.order - right.order,
+ )) {
+ const resolvedSource = await input.sourceResolver(sourcePolicy);
+
+ if (!resolvedSource) {
+ if (sourcePolicy.sourceType === 'document_library') {
+ const message = buildDocumentLibraryMissingMessage({
+ role: sourcePolicy.role,
+ documentType: sourcePolicy.documentType!,
+ brand: input.policy.brand ?? 'generic',
+ language: input.policy.language ?? DEFAULT_ASSEMBLY_LANGUAGE,
+ productType: input.policy.productType ?? 'all',
+ });
+
+ if (sourcePolicy.required) {
+ await auditAction({
+ organizationId: input.organizationId,
+ userId: input.userId,
+ entityType: auditEntityType,
+ entityId: input.entityId,
+ action: 'assembly_failed',
+ afterData: { role: sourcePolicy.role, message },
+ });
+ throw new AuthError(message, 409);
+ }
+
+ warnings.push({
+ code: 'missing_optional_source',
+ role: sourcePolicy.role,
+ message,
+ });
+
+ await auditAction({
+ organizationId: input.organizationId,
+ userId: input.userId,
+ entityType: auditEntityType,
+ entityId: input.entityId,
+ action: 'assembly_source_missing',
+ afterData: { role: sourcePolicy.role, message },
+ });
+ continue;
+ }
+
+ throw new AuthError(
+ `Required generated source missing for role ${sourcePolicy.role}.`,
+ 409,
+ );
+ }
+
+ sources.push(resolvedSource);
+
+ await auditAction({
+ organizationId: input.organizationId,
+ userId: input.userId,
+ entityType: auditEntityType,
+ entityId: input.entityId,
+ action: 'assembly_source_resolved',
+ afterData: {
+ role: resolvedSource.role,
+ sourceType: resolvedSource.sourceType,
+ fileName: resolvedSource.fileName,
+ checksum: resolvedSource.checksum,
+ pageCount: resolvedSource.pageCount,
+ metadata: resolvedSource.metadata,
+ },
+ });
+ }
+
+ if (!sources.length) {
+ throw new AuthError('No assembly sources available.', 409);
+ }
+
+ const buffer = await mergePdfBuffers(sources);
+ const checksum = sha256(buffer);
+ const fileSize = buffer.byteLength;
+ const pageCount = await getPdfPageCount(buffer, 'Merged assembly output');
+
+ await auditAction({
+ organizationId: input.organizationId,
+ userId: input.userId,
+ entityType: auditEntityType,
+ entityId: input.entityId,
+ action: 'assembly_merge_completed',
+ afterData: {
+ fileName: input.fileName,
+ checksum,
+ fileSize,
+ pageCount,
+ sourceCount: sources.length,
+ },
+ });
+
+ return {
+ buffer,
+ fileName: input.fileName,
+ checksum,
+ fileSize,
+ pageCount,
+ policy: input.policy,
+ warnings,
+ sources,
+ artifact: null,
+ };
+ } catch (error) {
+ if (!(error instanceof AuthError)) {
+ await auditAction({
+ organizationId: input.organizationId,
+ userId: input.userId,
+ entityType: auditEntityType,
+ entityId: input.entityId,
+ action: 'assembly_failed',
+ afterData: {
+ message:
+ error instanceof Error
+ ? error.message
+ : 'Unknown assembly failure',
+ },
+ });
+ }
+
+ throw error;
+ }
+}
+
+export async function buildDefaultQuotationAssemblyPolicy(input: {
+ quotationId: string;
+ organizationId: string;
+}): Promise {
+ const documentData = await buildQuotationDocumentData(
+ input.quotationId,
+ input.organizationId,
+ );
+ const productTypeCode =
+ documentData.items.find((item) => item.productTypeCode)?.productTypeCode ??
+ documentData.quotation.quotationTypeCode;
+
+ return {
+ documentType: 'quotation_pdf',
+ organizationId: input.organizationId,
+ brand: normalizeAssemblyBrand(documentData.company.slug),
+ language: DEFAULT_ASSEMBLY_LANGUAGE,
+ productType: normalizeAssemblyProductType(productTypeCode),
+ sources: [
+ {
+ sourceType: 'generated',
+ role: 'main',
+ required: true,
+ order: 1,
+ },
+ {
+ sourceType: 'document_library',
+ role: 'sla',
+ required: true,
+ order: 2,
+ documentType: 'sla',
+ },
+ {
+ sourceType: 'document_library',
+ role: 'warranty',
+ required: false,
+ order: 3,
+ documentType: 'warranty',
+ },
+ {
+ sourceType: 'document_library',
+ role: 'datasheet',
+ required: false,
+ order: 4,
+ documentType: 'datasheet',
+ },
+ {
+ sourceType: 'document_library',
+ role: 'drawing',
+ required: false,
+ order: 5,
+ documentType: 'drawing',
+ },
+ ],
+ };
+}
+
+export async function generateAssembledQuotationPdf(
+ quotationId: string,
+ organizationId: string,
+ userId: string,
+) {
+ const [policy, preview] = await Promise.all([
+ buildDefaultQuotationAssemblyPolicy({ quotationId, organizationId }),
+ getQuotationDocumentPreviewData(quotationId, organizationId),
+ ]);
+
+ const assembled = await assembleDocument({
+ organizationId,
+ userId,
+ entityType: 'crm_quotation',
+ entityId: quotationId,
+ auditEntityType: 'crm_quotation',
+ fileName: `${preview.documentData.quotation.code}-assembled.pdf`,
+ artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE,
+ policy,
+ sourceResolver: async (sourcePolicy) => {
+ if (sourcePolicy.sourceType === 'generated') {
+ const generated = await generateQuotationPdf(quotationId, organizationId);
+ const pageCount = await getPdfPageCount(
+ generated.buffer,
+ 'Generated quotation PDF',
+ );
+
+ return {
+ role: sourcePolicy.role,
+ sourceType: 'generated',
+ required: sourcePolicy.required,
+ order: sourcePolicy.order,
+ fileName: generated.fileName,
+ contentType: 'application/pdf',
+ checksum: sha256(generated.buffer),
+ pageCount,
+ fileSize: generated.buffer.byteLength,
+ metadata: {
+ templateVersionId: generated.templateVersionId,
+ sourceDocumentType: 'quotation_pdf',
+ },
+ buffer: generated.buffer,
+ };
+ }
+
+ return resolveDocumentLibrarySource({
+ organizationId,
+ role: sourcePolicy.role,
+ required: sourcePolicy.required,
+ order: sourcePolicy.order,
+ documentType: sourcePolicy.documentType!,
+ brand: policy.brand ?? 'generic',
+ language: policy.language ?? DEFAULT_ASSEMBLY_LANGUAGE,
+ productType: policy.productType ?? 'all',
+ });
+ },
+ });
+
+ const metadata: QuotationAssemblyMetadata = {
+ documentType: 'assembled_pdf',
+ sourceDocumentType: 'quotation_pdf',
+ sourceDocumentId: quotationId,
+ sourceDocumentCode: preview.documentData.quotation.code,
+ assemblyPolicy: assembled.policy,
+ sourceArtifacts: assembled.sources.map((source) => ({
+ role: source.role,
+ sourceType: source.sourceType,
+ fileName: source.fileName,
+ checksum: source.checksum,
+ fileSize: source.fileSize,
+ pageCount: source.pageCount,
+ metadata: source.metadata,
+ })),
+ warnings: assembled.warnings,
+ pageCount: assembled.pageCount,
+ generatedAt: new Date().toISOString(),
+ generatedBy: userId,
+ };
+
+ const artifact = await persistAssembledArtifact({
+ organizationId,
+ userId,
+ entityType: 'crm_quotation',
+ entityId: quotationId,
+ artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE,
+ fileName: assembled.fileName,
+ buffer: assembled.buffer,
+ checksum: assembled.checksum,
+ fileSize: assembled.fileSize,
+ metadata,
+ });
+
+ await auditAction({
+ organizationId,
+ userId,
+ entityType: 'crm_quotation',
+ entityId: quotationId,
+ action: 'assembly_artifact_stored',
+ afterData: artifact,
+ });
+
+ return {
+ ...assembled,
+ artifact,
+ };
+}
+
+export async function getStoredAssembledQuotationPdf(
+ quotationId: string,
+ organizationId: string,
+ options?: {
+ userId?: string;
+ auditDownload?: boolean;
+ },
+) {
+ const existingArtifact = await getActiveArtifactForEntity({
+ organizationId,
+ entityType: 'crm_quotation',
+ entityId: quotationId,
+ artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE,
+ });
+
+ if (!existingArtifact) {
+ throw new AuthError('Assembled quotation PDF has not been generated yet.', 404);
+ }
+
+ const { object, artifact } = await downloadArtifact({
+ artifactId: existingArtifact.id,
+ organizationId,
+ userId: options?.userId,
+ auditDownload: options?.auditDownload,
+ });
+ const buffer = Buffer.from(await new Response(object.body).arrayBuffer());
+
+ return {
+ artifact,
+ buffer,
+ fileName: object.fileName || `${quotationId}-assembled.pdf`,
+ };
+}
diff --git a/src/features/foundation/document-assembly/types.ts b/src/features/foundation/document-assembly/types.ts
new file mode 100644
index 0000000..5782ccc
--- /dev/null
+++ b/src/features/foundation/document-assembly/types.ts
@@ -0,0 +1,65 @@
+import type {
+ DocumentLibraryBrand,
+ DocumentLibraryProductType,
+ DocumentLibraryType,
+} from '@/features/foundation/document-library/types';
+
+export type AssemblySourceType = 'generated' | 'document_library';
+
+export interface AssemblySourcePolicy {
+ sourceType: AssemblySourceType;
+ role: string;
+ required: boolean;
+ order: number;
+ documentType?: DocumentLibraryType;
+}
+
+export interface AssemblyPolicy {
+ documentType: string;
+ organizationId: string;
+ productType?: DocumentLibraryProductType | null;
+ brand?: DocumentLibraryBrand | null;
+ language?: string | null;
+ sources: AssemblySourcePolicy[];
+}
+
+export interface AssemblyWarning {
+ code: 'missing_optional_source';
+ role: string;
+ message: string;
+}
+
+export interface ResolvedAssemblySource {
+ role: string;
+ sourceType: AssemblySourceType;
+ required: boolean;
+ order: number;
+ fileName: string;
+ contentType: string;
+ checksum: string;
+ pageCount: number;
+ fileSize: number;
+ metadata: Record;
+ buffer: Buffer;
+}
+
+export interface PersistedAssemblyArtifact {
+ artifactId: string;
+ storageKey: string;
+ fileName: string;
+ checksum: string;
+ fileSize: number;
+ pageCount: number;
+}
+
+export interface DocumentAssemblyResult {
+ buffer: Buffer;
+ fileName: string;
+ checksum: string;
+ fileSize: number;
+ pageCount: number;
+ policy: AssemblyPolicy;
+ warnings: AssemblyWarning[];
+ sources: ResolvedAssemblySource[];
+ artifact: PersistedAssemblyArtifact | null;
+}
diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/82e0ba18-dd3a-4dcf-b612-e9af58ea0110/286560f3-7d6d-4ec8-bf31-f6707824e739/warranty-onvalla-dock-door-en-v1.0.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/82e0ba18-dd3a-4dcf-b612-e9af58ea0110/286560f3-7d6d-4ec8-bf31-f6707824e739/warranty-onvalla-dock-door-en-v1.0.pdf
new file mode 100644
index 0000000..b479dc5
Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/82e0ba18-dd3a-4dcf-b612-e9af58ea0110/286560f3-7d6d-4ec8-bf31-f6707824e739/warranty-onvalla-dock-door-en-v1.0.pdf differ
diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/611d6f1f-f9df-4b14-8588-e085e42d012d/company-profile-alla-all-th-2025.12.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/611d6f1f-f9df-4b14-8588-e085e42d012d/company-profile-alla-all-th-2025.12.pdf
new file mode 100644
index 0000000..4ff54ac
Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/611d6f1f-f9df-4b14-8588-e085e42d012d/company-profile-alla-all-th-2025.12.pdf differ
diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/7f16e09a-0ba4-4848-8d87-1fa10c75bc3c/company-profile-alla-all-th-2026.01.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/7f16e09a-0ba4-4848-8d87-1fa10c75bc3c/company-profile-alla-all-th-2026.01.pdf
new file mode 100644
index 0000000..42b180a
Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b6c6ce66-0aa0-49b8-800d-b90dc1ee2200/7f16e09a-0ba4-4848-8d87-1fa10c75bc3c/company-profile-alla-all-th-2026.01.pdf differ
diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/70460d62-3fee-4c27-bb98-382a6a8440b1/sla-alla-crane-th-v1.0.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/70460d62-3fee-4c27-bb98-382a6a8440b1/sla-alla-crane-th-v1.0.pdf
new file mode 100644
index 0000000..e21639b
Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/70460d62-3fee-4c27-bb98-382a6a8440b1/sla-alla-crane-th-v1.0.pdf differ
diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/f0acfae8-c8dd-4fe9-a65a-8aa3ece4d1a7/sla-alla-crane-th-v1.1.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/f0acfae8-c8dd-4fe9-a65a-8aa3ece4d1a7/sla-alla-crane-th-v1.1.pdf
new file mode 100644
index 0000000..af5fe15
Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/c67e3202-eaea-4b6a-9c0e-69b4cb7ef7e6/f0acfae8-c8dd-4fe9-a65a-8aa3ece4d1a7/sla-alla-crane-th-v1.1.pdf differ