taks-p.4.3

This commit is contained in:
phaichayon
2026-06-29 11:18:08 +07:00
parent 2540d52670
commit 0c0450b152
20 changed files with 4900 additions and 505 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,582 @@
# Task P.4 Discovery Report
## Scope
This report documents the mandatory discovery phase for `task-p.4.md` before any implementation begins.
Task P.4 requires:
- introducing a dedicated Product Item page between the existing Customer Detail page and Condition + Signature page
- using the existing CRM template management workflow
- creating new template versions only
- preserving backward compatibility for existing quotation template versions
No implementation has been performed at the time of writing this report.
---
## 1. Existing Architecture
### 1.1 CRM Template Module
The CRM template settings page is served by:
- [src/app/dashboard/crm/settings/templates/page.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/settings/templates/page.tsx:9)
This page:
- checks access with `crmDocumentTemplateRead`
- prefetches quotation PDFMe templates via `documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' })`
- renders the admin UI through `TemplateSettings`
The main template admin UI lives in:
- [src/features/foundation/document-template/components/template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:720)
This module already supports:
- template metadata CRUD
- template version CRUD
- raw `schemaJson` editing
- mapping CRUD
- version activation
- template detail inspection
Important internal UI areas:
- template metadata dialog: [template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:241)
- version dialog: [template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:292)
- version JSON editing: [template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:360)
- template detail card and version tabs: [template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:527)
### 1.2 Template APIs
Template/version/mapping APIs already exist and are reused by the CRM settings route group:
- [src/app/api/crm/document-templates/[id]/versions/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/document-templates/[id]/versions/route.ts:1)
- [src/app/api/crm/settings/document-templates/versions/[id]/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/route.ts:1)
- [src/app/api/crm/settings/document-templates/versions/[id]/mappings/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/mappings/route.ts:1)
### 1.3 Document Template Foundation
The main document-template business logic is centralized in:
- [src/features/foundation/document-template/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:1)
Important responsibilities already covered:
- template resolution for runtime documents
- version resolution
- mapping resolution
- mapping-to-input transformation
- active/default template handling
Key functions:
- `resolveTemplateMappings`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:168)
- `createDocumentTemplateVersion`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:424)
- `updateDocumentTemplateVersion`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:449)
- `setDocumentTemplateVersionActive`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:474)
- `resolveTemplateForDocument`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:612)
- `mapDocumentDataToTemplateInput`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:745)
### 1.4 Quotation Document Runtime
Quotation document runtime logic is handled in:
- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:263)
Key functions:
- `buildQuotationDocumentData`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:263)
- `getQuotationDocumentPreviewData`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:661)
- `prepareApprovedQuotationSnapshot`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:718)
Topic-based dynamic layout is currently handled by:
- [src/features/crm/quotations/document/server/pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:66)
Formatting helpers are in:
- [src/features/crm/quotations/document/server/pdfme-transforms.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdfme-transforms.ts:86)
### 1.5 PDF Generation Foundation
PDF generation is centralized in:
- [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:99)
Important capabilities already provided:
- font loading
- PDFMe plugin registration
- preview PDF generation
- download PDF generation
- approved PDF artifact generation and persistence
Key functions:
- `getPdfPlugins`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:44)
- `resolvePdfFonts`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:53)
- `generatePdfFromTemplate`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:99)
- `generateQuotationPdf`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:300)
- `generateQuotationPreviewPdf`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:317)
- `generateApprovedQuotationPdf`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:321)
---
## 2. Dependency Graph
### 2.1 Runtime Flow
Quotation to PDF currently flows as:
1. quotation APIs request document data or document preview
2. quotation server service builds normalized `documentData`
3. runtime resolves the active template and active version
4. runtime resolves mappings for the selected version
5. document data is transformed into PDFMe `templateInput`
6. topic engine clones and expands the topic page dynamically
7. merged template plus merged inputs are passed to PDFMe generator
8. resulting PDF is returned as preview, download, or approved artifact
### 2.2 Route-Level Graph
- document data route: [src/app/api/crm/quotations/[id]/document-data/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/document-data/route.ts:15)
- calls `buildQuotationDocumentData`
- document preview route: [src/app/api/crm/quotations/[id]/document-preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/document-preview/route.ts:15)
- calls `getQuotationDocumentPreviewData`
- PDF preview route: [src/app/api/crm/quotations/[id]/pdf-preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/pdf-preview/route.ts:15)
- calls `generateQuotationPreviewPdf`
- PDF download route: [src/app/api/crm/quotations/[id]/pdf-download/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/pdf-download/route.ts:15)
- calls `generateQuotationPdf`
- approved PDF route: [src/app/api/crm/quotations/[id]/approved-pdf/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/approved-pdf/route.ts:20)
- calls approved artifact retrieval/generation flow
### 2.3 UI Preview Graph
Client document preview uses:
- [src/features/crm/quotations/document/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/service.ts:1)
- [src/features/crm/quotations/document/queries.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/queries.ts:1)
- [src/features/crm/quotations/components/quotation-document-preview.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-document-preview.tsx:1)
This preview currently renders document data and item summaries for inspection, but it is not the PDF layout engine itself.
---
## 3. Current Template Lifecycle
### 3.1 Storage Model
Template entities are stored in:
- `crm_document_templates`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:880)
- `crm_document_template_versions`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:909)
- `crm_document_template_mappings`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:933)
- `crm_document_template_table_columns`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:957)
### 3.2 Lifecycle Rules
Observed lifecycle:
1. create template metadata
2. create template version with raw `schemaJson`
3. add/update mappings for placeholders
4. activate the desired version
5. runtime automatically resolves the active version
### 3.3 Selection Rules at Runtime
Runtime template resolution is implemented in:
- [resolveTemplateForDocument](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:612)
Selection behavior:
- only active templates are considered
- product-type-specific match is preferred
- default product type fallback exists
- active versions are filtered
- the latest active version by creation order is selected
### 3.4 Activation and Default Handling
- default template normalization is handled by `normalizeDefaultTemplate`
- version activation is handled by `setDocumentTemplateVersionActive`
- inactive versions remain stored for traceability
### 3.5 Template Source / Seed Coupling
Current template source loading in seeds and audit utilities still points to:
- `ALLA_template_pdfme_fainal3.json`
- `ONVALLA_template_pdfme_fainal3.json`
References:
- [src/db/seeds/foundation.seed.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts:854)
- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts:667)
- [scripts/reseed-pdf-template-version.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/reseed-pdf-template-version.ts:1)
This means any new product-item template version likely needs seed/audit support updates.
---
## 4. Runtime Rendering Flow
### 4.1 Current Preview Builder
`getQuotationDocumentPreviewData` currently does the following:
1. builds quotation document data
2. resolves the active quotation PDFMe template
3. resolves version mappings
4. maps document data to template inputs
5. normalizes aliases between field names and placeholder tokens
6. runs the topic engine
7. returns merged template input plus rendered schema
Reference:
- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:661)
### 4.2 Topic Engine Behavior
The existing topic engine:
- clones template pages
- assumes the dynamic topic page is at `targetPageIndex: 1`
- looks for `topic` and `data_topic`
- inserts repeated topic blocks
- moves signature/keep-together fields to the last generated page when needed
Key implementation details:
- default options: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:14)
- target page lookup: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:76)
- dynamic field lookup: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:83)
- page break behavior: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:147)
- keep-together footer/signature handling: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:169)
### 4.3 PDF Generator Runtime
The generator currently registers these PDFMe plugins:
- `text`
- `image`
- `line`
- `table`
Reference:
- [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:44)
This is important because Product Item page support should reuse the existing official PDFMe `table` plugin instead of introducing a custom renderer.
---
## 5. Existing PDF Templates
### 5.1 Located Template Files
Current PDFMe template assets found in the repository:
- [src/pdfme_template/ALLA_template_pdfme_fainal3.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ALLA_template_pdfme_fainal3.json)
- [src/pdfme_template/ONVALLA_template_pdfme_fainal3.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ONVALLA_template_pdfme_fainal3.json)
### 5.2 Current Page Count
Both templates currently contain exactly 2 pages.
### 5.3 Page Purposes
For `ALLA_template_pdfme_fainal3.json`:
- Page 1
- company header
- customer detail
- quotation meta
- project fields
- quotation price summary table
- Page 2
- dynamic topic label/table placeholders
- closing message
- signature blocks
Observed markers from template JSON:
- page 1 price table field `quotation_price_data`
- page 2 dynamic topic fields `topic` and `data_topic`
- page 2 static closing/signature fields `Please_do_not`, `yours_faithfuly`, `app1`, `app2`, `app3`
Relevant references:
- quotation price table marker: [ALLA template](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ALLA_template_pdfme_fainal3.json:345)
- topic marker: [ALLA template](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ALLA_template_pdfme_fainal3.json:962)
- topic data marker: [ALLA template](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ALLA_template_pdfme_fainal3.json:1031)
### 5.4 Base PDF Structure
The current templates use PDFMe object-style `basePdf` with width/height/padding and static schema, not an imported fixed PDF file path.
This is favorable for dynamic table layout and page growth.
---
## 6. Product Item Flow
### 6.1 Data Origin
Quotation product items originate from:
- `crm_quotation_items`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:613)
Current persisted fields include:
- `itemNumber`
- `productType`
- `description`
- `quantity`
- `unit`
- `unitPrice`
- `discount`
- `discountType`
- `taxRate`
- `totalPrice`
- `notes`
### 6.2 Server Mapping
Quotation item records are normalized in:
- [src/features/crm/quotations/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/server/service.ts:208)
Quotation document data currently exposes item fields in:
- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:564)
Currently exposed document item fields:
- `itemNumber`
- `productTypeCode`
- `productTypeLabel`
- `description`
- `quantity`
- `unitCode`
- `unitLabel`
- `unitPrice`
- `discount`
- `discountTypeCode`
- `taxRate`
- `totalPrice`
- `notes`
### 6.3 Existing Table Mapping Support
There is already existing seed/audit support for an `items_table` mapping:
- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts:312)
Current seeded columns:
- `Item``itemNumber`
- `Description``description`
- `Qty``quantity`
- `Unit``unitLabel`
- `Unit Price``unitPrice`
- `Total``totalPrice`
This suggests the architecture already anticipated table-based quotation item rendering, even though the currently active production template files are still only 2 pages.
### 6.4 Gaps Against P.4 Requirements
Task P.4 requires these columns or concepts:
- product code
- description
- specification
- quantity
- unit
- unit price
- discount
- amount
- remark
Observed current gap:
- no dedicated `specification` field was found in `crm_quotation_items`
- `remark` appears to correspond most closely to `notes`, unless business rules say otherwise
This is a discovery risk and must be resolved before final implementation behavior is locked in.
---
## 7. Layout Engine Assessment
### 7.1 What Already Exists
The current layout foundation already supports:
- page cloning
- dynamic page generation
- page splitting based on vertical overflow
- repeated topic block creation
- keep-together footer/signature placement
This is provided by:
- [src/features/crm/quotations/document/server/pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:66)
### 7.2 What Does Not Yet Exist Explicitly
No dedicated runtime was found yet for:
- dynamic product item page insertion between page 1 and topic/signature page
- generic dynamic page marker resolution
- generalized page numbering helper
- product-table-specific pagination layer separate from topic engine
### 7.3 Key Constraint
The existing topic engine is hard-wired to `targetPageIndex: 1`, which currently means:
- it expects the dynamic topic page to be page 2
- it expects signature fields on that same logical target page
If a new Product Item page is inserted between the current page 1 and current page 2 without changing runtime behavior, the topic engine will target the wrong page.
This is the most important architecture finding of the discovery phase.
---
## 8. Files Expected to Be Modified
Likely implementation touchpoints:
- [src/features/crm/quotations/document/server/pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:66)
- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:661)
- [src/features/foundation/document-template/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:612)
- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts:312)
- [scripts/reseed-pdf-template-version.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/reseed-pdf-template-version.ts:1)
- possibly [src/db/seeds/foundation.seed.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts:854)
Possible UI/admin edits may be needed only if additional metadata or better preview labeling is required, but no mandatory UI gap was found yet for basic version creation and activation.
---
## 9. New Files Expected to Be Created
Expected new template assets:
- `src/pdfme_template/ALLA_template_pdfme_product_v1.json`
- `src/pdfme_template/ONVALLA_template_pdfme_product_v1.json`
No separate parallel rendering pipeline should be created.
---
## 10. Backward Compatibility Analysis
Backward compatibility is achievable if the implementation follows these rules:
1. do not modify existing `*_fainal3.json` template files
2. create new template versions only
3. keep runtime able to render both old and new version structures
4. make topic-page resolution robust enough to support the new page sequence
5. avoid changing existing approved PDF artifact behavior
Why compatibility is realistic:
- runtime already resolves active versions dynamically
- template mappings are version-specific
- old versions remain stored
- admin UI already supports multiple versions and activation switching
Main compatibility risk:
- current topic engine assumes old page order and must be generalized without breaking old templates
---
## 11. Risk Assessment
### 11.1 High Risk
- topic engine currently assumes page 2 is the dynamic topic/signature page
- inserting a new middle page will break runtime unless page targeting becomes marker-based or otherwise dynamic
### 11.2 Medium Risk
- current quotation item payload does not clearly expose a standalone `specification` field
- page numbering requirement exists in Task P.4, but current templates do not expose obvious page-number fields
- reseed/audit utilities still point to legacy file names and will need alignment for new template assets
### 11.3 Lower Risk
- CRM settings workflow itself is already sufficient for version CRUD
- PDF generator already registers the official PDFMe `table` plugin
- template storage model already supports table mappings
---
## 12. Migration Strategy
Recommended migration path:
1. generalize runtime topic page detection so it is not tied to fixed page index 1
2. create new Product Item template JSON files for ALLA and ONVALLA
3. add or update mappings for the product item table placeholders
4. ensure new versions can be created and activated through the existing template settings flow
5. update reseed/audit helpers to recognize the new product template source files where needed
6. verify all runtime modes
Required verification modes:
- document preview route
- PDF preview route
- PDF download route
- approved PDF generation
- old template version behavior
- new template version behavior
---
## 13. Final Discovery Conclusion
Task P.4 should proceed by extending the current document-template and quotation-document runtime foundations, not by creating a new rendering path.
The critical architecture change is:
- make the topic/signature dynamic-page resolution independent from the old hardcoded second-page layout
Once that is in place, the new Product Item page can be introduced as a new template version while preserving existing templates unchanged.
---
## References Reviewed
### Governance and Standards
- [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/standards/architecture-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/architecture-rules.md)
- [docs/standards/ui-ux-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/ui-ux-rules.md)
- [docs/standards/task-catalog.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-catalog.md)
- [docs/standards/task-review-checklist.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-review-checklist.md)
### ADRs and Business Docs
- [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/0012-approval-signature-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0012-approval-signature-strategy.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/business/pdf-mapping-registry.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/business/pdf-mapping-registry.md)
- [docs/business/pdf-placeholder-registry.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/business/pdf-placeholder-registry.md)
### Implementation Lineage
- [docs/implementation/task-g-document-preview-foundation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-g-document-preview-foundation.md)
- [docs/implementation/task-h-pdf-generation-persistence.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-h-pdf-generation-persistence.md)
- [docs/implementation/pdfme-runtime-audit.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/pdfme-runtime-audit.md)
- [docs/implementation/pdfme-legacy-audit.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/pdfme-legacy-audit.md)

View File

@@ -0,0 +1,457 @@
# Task P.4.1 Verification Report
## Scope
This report completes the runtime verification requested by [task-p.4.1.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.4.1.md).
No production template files were modified.
No new rendering pipeline was created.
No implementation for Task P.4 has started yet.
---
## 1. Verified Runtime Findings
### 1.1 Current quotation PDF runtime chain
The current quotation document routes do not use separate template pipelines.
Verified route chain:
- `document-data` calls `buildQuotationDocumentData`
- [src/app/api/crm/quotations/[id]/document-data/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/document-data/route.ts:39)
- `document-preview` calls `getQuotationDocumentPreviewData`
- [src/app/api/crm/quotations/[id]/document-preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/document-preview/route.ts:39)
- `pdf-preview` calls `generateQuotationPreviewPdf`
- [src/app/api/crm/quotations/[id]/pdf-preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/pdf-preview/route.ts:39)
- `pdf-download` calls `generateQuotationPdf`
- [src/app/api/crm/quotations/[id]/pdf-download/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/pdf-download/route.ts:39)
- `approved-pdf` reads stored artifact first and falls back to generation path when needed
- [src/app/api/crm/quotations/[id]/approved-pdf/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/approved-pdf/route.ts:44)
Verified shared pipeline:
- `generateQuotationPdf()` calls `getQuotationDocumentPreviewData()`
- [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:304)
- `generateQuotationPreviewPdf()` simply delegates to `generateQuotationPdf()`
- [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:317)
- `generateApprovedQuotationPdf()` also calls `getQuotationDocumentPreviewData()`
- [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:326)
Verified preview builder chain:
- `getQuotationDocumentPreviewData()` builds `documentData`
- resolves active template and active version
- resolves version mappings
- maps `documentData` to `templateInput`
- applies topic page expansion
Reference:
- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:661)
### 1.2 Same active template/version/mapping pipeline
Confirmed: `document-preview`, `pdf-preview`, `pdf-download`, and approved PDF generation all depend on the same active template/version/mapping pipeline.
Shared resolution calls:
- `resolveTemplateForDocument()`
- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:669)
- `resolveTemplateMappings()`
- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:674)
- `mapDocumentDataToTemplateInput()`
- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:688)
Conclusion:
- there is one active runtime pipeline for quotation PDF generation
- there is no separate preview-only template source
- there is no separate download-only template source
- approved PDF generation reuses the same active version before persistence
---
## 2. Active Template Runtime Source Verification
### 2.1 Database active versions
Verified against live database:
```json
[
{
"organizationName": "ALLA",
"templateName": "ALLA Quotation Standard",
"version": "1.0",
"filePath": "src/pdfme_template/ALLA_template_pdfme_fainal3.json",
"mappingCount": "20"
},
{
"organizationName": "ONVALLA",
"templateName": "ONVALLA Quotation Standard",
"version": "1.0",
"filePath": "src/pdfme_template/ONVALLA_template_pdfme_fainal3.json",
"mappingCount": "20"
}
]
```
Interpretation:
- runtime selects active version rows from DB
- each active version still keeps `filePath` pointing to the original JSON file source
- mapping count is currently `20` for both active versions
### 2.2 DB schema vs file source
Verified that the active version `schema_json` in DB matches the JSON file on disk exactly for both organizations.
Evidence:
```json
{
"organization": "ALLA",
"filePath": "src/pdfme_template/ALLA_template_pdfme_fainal3.json",
"matches": true
}
{
"organization": "ONVALLA",
"filePath": "src/pdfme_template/ONVALLA_template_pdfme_fainal3.json",
"matches": true
}
```
Conclusion:
- runtime renders from `schema_json` stored in DB
- the current DB value is seeded from the JSON file and currently matches it
- changing only the JSON file will not change runtime behavior unless DB version rows are reseeded or updated
### 2.3 Seed/runtime source conclusion
Runtime uses both, but with different roles:
- DB active template/version/mapping is the actual runtime source
- JSON file is the seed/reseed source of truth for version payload creation
This means P.4 must update:
- new template JSON files
- seed or reseed logic that publishes those JSON files into DB versions
Relevant current source selectors:
- [src/db/seeds/foundation.seed.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts:854)
- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts:667)
---
## 3. Current Template Page Structure
### 3.1 Active page count
Verified from active DB runtime schema:
- `ALLA` active template version has `2` pages
- `ONVALLA` active template version has `2` pages
### 3.2 Schema names per page
Verified runtime schema dump for `ALLA`:
```text
PAGE 1 FIELDS:
company1, company2, company_addr1, company_addr2, company_tel, company_email,
company_tax, line, customer_name, customer_addr, customer_tel, customer_email,
dear_sirs, quotation_price_data, quotation_date_labe, quotation_date_data,
quotation_code_lable, quotation_code_data, customer_att, project_name,
site_location, colon, colon copy, colon_tel, colon_fax, colon_email,
colon_att, colon_project, colon_site, tel_label, email_label,
att_label, project_label, site_label
PAGE 2 FIELDS:
topic, data_topic, Please_do_not, yours_faithfuly,
app1, app1_position, app2, app2_position, app3, app3_position
```
Verified runtime schema dump for `ONVALLA`:
```text
PAGE 1 FIELDS:
company, company_addr1, company_addr2, company_tel, company_email,
company_tax, line, customer_name, customer_addr, customer_tel, customer_email,
dear_sirs, quotation_price_data, quotation_date_labe, quotation_date_data,
quotation_code_lable, quotation_code_data, customer_att, project_name,
site_location, colon, colon copy, colon_tel, colon_fax, colon_email,
colon_att, colon_project, colon_site, tel_label, email_label,
att_label, project_label, site_label
PAGE 2 FIELDS:
topic, data_topic, Please_do_not, yours_faithfuly,
app1, app1_position, app2, app2_position, app3, app3_position
```
### 3.3 Topic/signature page index
Confirmed:
- topic/signature page is currently page index `1` in zero-based runtime terms
- this matches the hardcoded topic engine default
Reference:
- [src/features/crm/quotations/document/server/pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:14)
This is the key verification that explains why inserting a new page in the middle will break the current engine if no runtime change is made first.
---
## 4. PDFMe Table Capability Verification
### 4.1 Verification method
Temporary verification was performed using:
- `@pdfme/generator`
- `@pdfme/schemas` table plugin
- `@pdfme/pdf-lib` for page counting
No production template was modified.
A temporary verification PDF was generated at:
- [rows_30_short.pdf](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/artifacts/task-p4-verification/rows_30_short.pdf)
### 4.2 Runtime source-code evidence for repeat header
The installed table plugin contains explicit repeat-header logic:
```text
node_modules/@pdfme/schemas/dist/dynamicTemplate-CXBCt5wk.js:488
schema.showHead = schema.showHead === false ? false : !schema.__isSplit || schema.repeatHead === true;
node_modules/@pdfme/schemas/dist/dynamicTemplate-CXBCt5wk.js:704
if (!(schema.repeatHead && isBlankPdf(args.basePdf) && headerHeight > 0)) return baseHeights;
```
Interpretation:
- `repeatHead` is a real runtime behavior in the installed table plugin
- repeated header behavior is only applied when using blank/object-style `basePdf`
- this matches the P.4 requirement and also matches the projects current template style
### 4.3 Temporary generation results
Verified generation results:
```json
{"label":"rows_5_short","rowCount":5,"pageCount":1,"pdfBytes":8869}
{"label":"rows_30_short","rowCount":30,"pageCount":2,"pdfBytes":21737}
{"label":"rows_100_short","rowCount":100,"pageCount":4,"pdfBytes":55297}
{"label":"rows_30_long","rowCount":30,"pageCount":3,"pdfBytes":26739}
```
Verified conclusions:
- `5 rows` fits on `1` page
- `30 rows` breaks to `2` pages
- `100 rows` breaks to `4` pages
- `30` long-description rows consume `3` pages instead of `2`
### 4.4 Table behavior conclusions
Confirmed:
- auto page break works
- multi-page table generation works
- row height behavior changes pagination when descriptions become longer
- repeat header is implemented in the installed runtime and tied to `repeatHead: true`
Important nuance:
- visual image-based confirmation was not performed because this environment does not have PDF raster/text inspection tools installed
- however the runtime code path plus successful multi-page generation confirms the feature support exists in the actual installed library
---
## 5. Product Item Mapping Verification
### 5.1 Actual source fields found
Verified live DB columns for `crm_quotation_items`:
```text
id
organization_id
quotation_id
item_number
product_type
description
quantity
unit
unit_price
discount
discount_type
tax_rate
total_price
notes
sort_order
created_at
updated_at
deleted_at
```
### 5.2 Actual document runtime fields exposed
Verified quotation document runtime exposes:
- `itemNumber`
- `productTypeCode`
- `productTypeLabel`
- `description`
- `quantity`
- `unitCode`
- `unitLabel`
- `unitPrice`
- `discount`
- `discountTypeCode`
- `taxRate`
- `totalPrice`
- `notes`
Reference:
- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:564)
- [src/features/crm/quotations/document/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/types.ts:83)
### 5.3 Actual mapping coverage for `items_table`
Verified active mapping rows already include `items_table` for both organizations.
Current columns:
- `Item``itemNumber`
- `Description``description`
- `Qty``quantity`
- `Unit``unitLabel`
- `Unit Price``unitPrice`
- `Total``totalPrice`
### 5.4 Gap analysis against Task P.4.1 required fields
| Required field | Verified source | Status |
| --- | --- | --- |
| product code | no dedicated field found | gap |
| description | `description` | available |
| specification | no dedicated field found | gap |
| quantity | `quantity` | available |
| unit | `unit` / `unitLabel` | available |
| unit price | `unitPrice` | available |
| discount | `discount` | available |
| amount | `totalPrice` | available |
| remark | closest existing field is `notes` | gap / mapping decision needed |
Conclusion:
- `product code` does not exist as a standalone quotation item field in the current model
- `specification` does not exist as a standalone quotation item field in the current model
- `remark` likely maps to `notes`, but this should be treated as a business decision, not assumed silently
---
## 6. Final Implementation Strategy
Recommended implementation order:
1. generalize topic/signature runtime targeting so it no longer depends on hardcoded page index `1`
2. create new template JSON files
- `ALLA_template_pdfme_product_v1.json`
- `ONVALLA_template_pdfme_product_v1.json`
3. place the Product Item page between customer detail and topic/signature pages
4. configure the Product Item page with official PDFMe `table` schema using:
- `showHead: true`
- `repeatHead: true`
- blank/object-style `basePdf`
5. keep old versions untouched and inactive/active-switchable
6. update reseed/audit source selectors so the new JSON files can be published into DB
7. verify runtime behavior for both old and new versions through the existing routes
Important implementation constraint:
- because runtime currently renders from DB `schema_json`, adding only new JSON files is not sufficient
- reseed or explicit version creation in template admin is required for runtime adoption
---
## 7. Rollback Plan
Recommended rollback:
1. do not delete the current active `1.0` versions
2. publish the new Product Item design as a separate version only
3. if regression occurs, deactivate the new version
4. reactivate the current `1.0` version in `/dashboard/crm/settings/templates`
5. if needed, revert reseed script changes independently from runtime code changes
Why rollback is low-risk:
- versioned template storage already exists
- runtime resolves active version dynamically
- current production version remains intact
---
## 8. Regression Test Matrix
### 8.1 Runtime routes
Verify for both `ALLA` and `ONVALLA`:
- `GET /api/crm/quotations/[id]/document-data`
- `GET /api/crm/quotations/[id]/document-preview`
- `GET /api/crm/quotations/[id]/pdf-preview`
- `GET /api/crm/quotations/[id]/pdf-download`
- approved PDF generation flow
- approved PDF retrieval flow after artifact exists
### 8.2 Template versions
Verify both:
- old version `1.0`
- new product-item version
### 8.3 Row-volume cases
Verify with quotation items around:
- 5 rows
- 30 rows
- 100 rows
- long wrapped descriptions
### 8.4 Output expectations
Verify:
- old template still renders exactly as before
- new template inserts Product Item page between page 1 and topic/signature page
- topic/signature block still lands on the correct page
- repeated table header appears when multiple product pages exist
- long descriptions affect row height without layout corruption
- preview and download use the same selected version
- approved PDF persists the expected template version ID
---
## 9. Summary
Task P.4.1 verification confirms:
- quotation PDF runtime uses one shared active template/version/mapping pipeline
- current active runtime template is still the 2-page `*_fainal3.json` structure
- runtime source is DB `schema_json`, while JSON files remain the seed/reseed source
- official PDFMe table support is sufficient for multi-page product item tables
- current quotation item model is missing dedicated `product code` and `specification` fields
- the biggest implementation blocker is still the topic engines hardcoded page targeting, not the table plugin itself

67
plans/task-p.4.1.md Normal file
View File

@@ -0,0 +1,67 @@
# Task P.4.1 - PDFMe Product Item Template Runtime Verification
Before implementing Task P.4, perform runtime verification against the real codebase.
Do not implement yet.
Verify:
1. Current quotation PDF runtime
- document-preview
- pdf-preview
- pdf-download
- approved-pdf
Confirm whether they use the same active template/version/mapping pipeline.
2. Current template page structure
- load active quotation PDFMe template from runtime source
- dump page count
- dump schema names per page
- confirm topic/signature page index
3. PDFMe table capability
Create a temporary verification only.
Test official PDFMe table with:
- 5 rows
- 30 rows
- 100 rows
Confirm:
- repeat header
- auto page break
- multi-page behavior
- row height behavior
4. Product item mapping
Verify actual source fields for:
- product code
- description
- specification
- quantity
- unit
- unit price
- discount
- amount
- remark
If no field exists, document the gap.
5. Seed/runtime source
Confirm whether runtime uses:
- database active template
- JSON file template
- both
Explain whether seed scripts must be updated.
Output required:
- verified runtime findings
- evidence/log snippets
- final implementation strategy
- rollback plan
- regression test matrix
Do not modify production template files.
Do not create a new rendering pipeline.
Do not begin implementation until this verification report is complete.

File diff suppressed because it is too large Load Diff

475
plans/task-p.4.2.md Normal file
View File

@@ -0,0 +1,475 @@
# Task P.4.2 - PDF Runtime Architecture & Section-based Rendering Design
## Objective
Design the next-generation PDF runtime architecture for the CRM document system.
The new runtime must support:
* Dynamic page insertion
* Section-based document composition
* Optional document sections
* Future extensibility
* Full backward compatibility
This is an architecture task only.
Do NOT modify production code.
Do NOT modify template JSON.
Do NOT modify database schema.
---
# Inputs
Reuse verified findings from
* Task P.4 Discovery
* Task P.4.1 Runtime Verification
Do not repeat discovery.
---
# Design Principles
The runtime must become
* page-role driven
* section-based
* version compatible
* renderer independent
* extensible
Business logic must not depend on page indexes.
---
# Core Architecture
The runtime shall assemble a document from independent sections.
Instead of
```text
Template
Mutate Page 2
Generate PDF
```
the runtime shall become
```text
Quotation Document Data
Template Resolver
Mapping Resolver
Compatibility Adapter
Page Resolver
Section Composer
Template Assembler
PDF Generator
```
---
# Section-based Rendering
Introduce the concept of
Document Sections.
Examples
* Customer
* Product Items
* Topics
* Conditions
* Signature
* Attachments
* Appendix
* Warranty
* Cover
A section represents logical document content rather than physical pages.
A section may generate
* zero pages
* one page
* multiple pages
---
# Optional Sections
The runtime must support optional sections.
A section may be
* Required
* Optional
The runtime shall be capable of skipping optional sections without requiring another template version.
Examples
```text
Customer
Required
Product Items
Optional
Topics
Optional
Conditions
Optional
Signature
Required
```
This capability is architectural only.
No UI configuration is required in this task.
---
# Render Policy
Design a Render Policy abstraction.
Example
```ts
interface RenderPolicy {
section: PageRole;
enabled: boolean;
required: boolean;
visibleWhenEmpty: boolean;
}
```
The runtime shall evaluate Render Policy before rendering each section.
The policy source is intentionally undefined in this task.
Future tasks may provide it from
* user selection
* customer preference
* template defaults
* organization defaults
---
# Runtime Pipeline
Design
```text
Document Data
Template Resolver
Mapping Resolver
Compatibility Adapter
Page Resolver
Render Policy Resolver
Section Composer
Customer Section
Product Item Section
Topic Section
Condition Section
Signature Section
Template Assembler
PDF Generator
```
Describe every stage.
---
# Runtime Components
Design responsibilities for
* Template Resolver
* Mapping Resolver
* Compatibility Adapter
* Page Resolver
* Render Policy Resolver
* Customer Section Builder
* Product Item Engine
* Topic Engine
* Condition Engine
* Signature Resolver
* Template Assembler
* PDF Render Gateway
Each component must have a single responsibility.
---
# Page Marker Strategy
Design page discovery using logical markers instead of indexes.
Support
* explicit page markers
* legacy inference
* fallback detection
The runtime must never rely on
```ts
schemas[1]
```
---
# Runtime Contracts
Define contracts for
* ResolvedTemplate
* ResolvedPages
* RenderPolicy
* BuiltSection
* RuntimeIssue
* AssembledTemplate
Document ownership and responsibilities.
---
# Compatibility Strategy
Support
* legacy templates
* future templates
without duplicating runtime logic.
Legacy templates shall work through the Compatibility Adapter.
---
# Future Extensibility
The architecture shall allow adding new document sections without changing existing section builders.
Examples
* Drawing
* Specification
* Gallery
* Warranty
* Appendix
* Inspection Report
A new section should require only
1. PageRole
2. Section Builder
3. Marker Rule
4. Assembly Rule
---
# Error Handling
Design behavior for
* missing marker
* duplicate marker
* missing mappings
* empty optional section
* empty required section
* invalid template
Runtime shall fail gracefully and accumulate Runtime Issues.
---
# Migration Strategy
Design migration from
Current Runtime
Section-based Runtime
without breaking
* Preview
* Download
* Approved PDF
* Existing Template Versions
No database migration.
No API changes.
---
# Sequence Diagrams
Produce
* Current Runtime
* Proposed Runtime
including
* Render Policy
* Section Composition
---
# Component Diagram
Include
* Template Resolver
* Compatibility Adapter
* Page Resolver
* Render Policy Resolver
* Section Composer
* Section Builders
* Template Assembler
* PDF Generator
---
# Implementation Roadmap
Split implementation into phases.
Example
Phase 1
Runtime Contracts
Phase 2
Compatibility Adapter
Phase 3
Page Resolver
Phase 4
Section Composer
Phase 5
Product Item Engine
Phase 6
Template Upgrade
Phase 7
Regression Tests
Each phase must be independently testable.
---
# Constraints
* Architecture only.
* No production implementation.
* No template modification.
* No database changes.
* No API changes.
---
# Acceptance Criteria
* Runtime is section-based rather than page-index-based.
* Optional sections are supported by design.
* Product Item pages are independent from Topic pages.
* Existing template versions remain compatible.
* Future document sections can be added without redesigning the runtime.
* Template Version is used only for structural/layout differences, not for enabling or disabling document sections.
* The design is ready for implementation in Task P.4.3 with minimal architectural decisions remaining.

437
plans/task-p.4.3.md Normal file
View File

@@ -0,0 +1,437 @@
# Task P.4.3 - PDF Runtime Refactoring (Regression-first)
## Objective
Implement the new section-based PDF runtime architecture designed in Task P.4.2.
This task is a **runtime refactor only**.
The primary objective is to replace the current page-index-driven runtime with the new section-based runtime **without changing the rendered output of existing templates**.
This task must not introduce new PDF layouts or Product Item pages yet.
---
# Prerequisites
Complete and reuse:
* Task P.4 Discovery
* Task P.4.1 Runtime Verification
* Task P.4.2 Runtime Design
Do not redesign the architecture.
Implement the approved design only.
---
# Primary Goal
Move from
```text
Hardcoded Page Index
Topic Engine
PDF
```
to
```text
Section-based Runtime
Section Composer
Template Assembler
PDF
```
while maintaining identical output for all existing templates.
---
# Regression-first Rule
This task follows a strict Regression-first strategy.
Before introducing any new rendering capability:
* existing templates must continue producing identical PDFs
* preview, download and approved PDF must remain unchanged
* all regression tests must pass
Feature additions are intentionally deferred.
---
# Scope
Implement only runtime architecture.
Included
* runtime contracts
* compatibility adapter
* page resolver
* section composer
* render context
* topic engine refactor
* template assembler
* runtime diagnostics
Excluded
* Product Item page
* Product Item rendering
* new template JSON
* CRM Template UI
* user render options
* template version changes
* database changes
---
# Implementation Requirements
## Phase 1 — Runtime Contracts
Create runtime contracts.
At minimum
* SectionRole
* RenderContext
* ResolvedTemplate
* ResolvedPages
* BuiltSection
* RuntimeIssue
* AssembledTemplate
Responsibilities must match Task P.4.2.
---
## Phase 2 — Compatibility Adapter
Implement
TemplateCompatibilityAdapter
Responsibilities
* explicit marker support
* legacy marker inference
* normalize legacy templates
* expose Runtime Issues
Legacy templates must continue working without modification.
---
## Phase 3 — Page Resolver
Replace page-index assumptions.
The runtime must never depend on
```ts
schemas[1]
```
Responsibilities
* discover pages
* classify by SectionRole
* detect duplicates
* detect missing markers
* produce insertion anchors
---
## Phase 4 — Render Context
Introduce a shared runtime context.
Example
```ts
interface RenderContext {
documentData;
template;
mappings;
pages;
policies;
issues;
}
```
Every runtime component should consume RenderContext instead of receiving unrelated parameters.
The context must remain immutable where possible.
---
## Phase 5 — Section Registry
Implement a registry-based architecture.
Example
```ts
interface SectionBuilder {
role: SectionRole;
build(context: RenderContext): BuiltSection;
}
```
The runtime must discover builders through registration rather than hardcoded orchestration.
Section Composer must not know implementation details of builders.
---
## Phase 6 — Section Composer
Implement
SectionComposer
Responsibilities
* execute builders
* skip disabled sections
* collect Runtime Issues
* preserve logical order
* return assembled section outputs
The composer must not contain rendering logic.
---
## Phase 7 — Topic Engine Refactor
Refactor Topic Engine.
Current behavior
```text
Page Index
Topic Engine
```
New behavior
```text
Resolved Page
Topic Engine
```
The generated output must remain identical.
---
## Phase 8 — Template Assembler
Implement
TemplateAssembler
Responsibilities
* preserve existing pages
* assemble generated sections
* merge template inputs
* preserve page order
* return final template
No Product Item pages yet.
---
## Phase 9 — PDF Runtime Integration
Wire the new runtime into
* Preview
* Download
* Approved PDF
All three flows must use the same runtime.
No duplicated pipelines.
---
# Runtime Diagnostics
Implement RuntimeIssue collection.
Support
* MISSING_MARKER
* DUPLICATE_MARKER
* INVALID_TEMPLATE
* MISSING_MAPPING
* LEGACY_COMPAT_MODE
Diagnostics must be available during Preview for debugging.
---
# Backward Compatibility
Existing templates must continue working without modification.
Current
* ALLA
* ONVALLA
templates must render exactly as before.
No migration required.
---
# Refactoring Constraints
Do NOT
* modify template JSON
* introduce Product Item pages
* change template mappings
* modify database schema
* change API contracts
* change CRM Template UI
---
# Code Quality Requirements
* Single Responsibility Principle
* Dependency Injection where appropriate
* Immutable runtime contracts where practical
* No duplicated rendering logic
* No duplicated template resolution
* No duplicated mapping resolution
* Strong typing
* Comprehensive inline documentation for new runtime components
---
# Testing Requirements
Regression tests are mandatory.
Verify
## Runtime
* Preview
* Download
* Approved PDF
## Templates
* ALLA
* ONVALLA
## Rendering
* Topic rendering
* Signature placement
* Mapping resolution
* Pagination
Expected result
Rendered PDF must remain visually identical to the previous runtime.
---
# Deliverables
Implementation must include
* Runtime Contracts
* Compatibility Adapter
* Page Resolver
* RenderContext
* Section Registry
* Section Composer
* Refactored Topic Engine
* Template Assembler
* Runtime Diagnostics
* Updated Runtime Integration
* Regression Tests
---
# Acceptance Criteria
* No hardcoded page indexes remain.
* Runtime becomes section-based.
* Topic Engine no longer depends on page position.
* Legacy templates render without modification.
* Preview, Download and Approved PDF use one shared runtime.
* Existing PDF output remains unchanged.
* Runtime is ready for Product Item Engine implementation in Task P.4.4.
* No functional regression is introduced.
---
# Out of Scope
The following work belongs to later tasks:
**Task P.4.4**
* Product Item Engine
* Product Item pagination
* Product Item section rendering
**Task P.4.5**
* New PDF template versions
* Product Item template layout
**Task P.4.6**
* CRM Template integration
* Template activation
* Version publishing
**Task P.4.7**
* Render Policy configuration
* User-selectable section visibility
* Organization default rendering options
---
# Final Success Condition
At the end of Task P.4.3, the internal PDF runtime architecture shall be completely refactored to the new section-based design while producing identical output to the legacy runtime.
No new user-visible functionality is expected in this task. The success metric is architectural modernization with zero regression, providing a stable foundation for subsequent Product Item and optional section features.

326
plans/task-p.4.md Normal file
View File

@@ -0,0 +1,326 @@
# Task P.4 - PDFMe Product Item Table Template Refactoring
## Objective
Redesign the PDFMe quotation template to support dynamic Product Item tables by introducing a dedicated Product Item page between the existing Customer Detail page and Condition & Signature page.
The implementation **must not modify or replace the existing template**. A completely new template version shall be created while maintaining full backward compatibility.
---
# Mandatory Discovery Phase (Required)
Before implementing anything, inspect the actual project structure and produce a discovery report.
Do NOT make architectural assumptions.
The implementation may only begin after the discovery phase is completed.
---
# Discovery Scope
Inspect the current implementation under the CRM Template module.
At minimum review:
## 1. CRM Template Module
Inspect everything related to
```
/dashboard/crm/settings/templates
```
including
* pages
* components
* dialogs
* APIs
* services
* repositories
* database schema
* template versioning
* template preview
* template import/export
* template activation flow
Document how the template lifecycle currently works.
---
## 2. PDF Generation Runtime
Inspect the entire rendering pipeline.
Including
* quotation-preview.tsx
* PDF preview flow
* PDF download flow
* pdfme template loader
* template selector
* font loader
* plugin registration
* input builder
* layout engine
* topic engine
* schema builder
* adapter layer
Produce a dependency graph showing how a quotation becomes a PDF.
---
## 3. Existing PDF Templates
Locate every existing PDFMe template.
Document
* current page count
* page purpose
* schema layout
* static schema
* dynamic schema
* reusable components
Do not assume the current template structure.
---
## 4. Product Item Flow
Locate where quotation product items originate.
Trace
Quotation
Export Payload
Preview Model
Input Builder
PDF Template
PDF Generator
Document every transformation layer.
Do NOT duplicate DTOs or adapters if an existing one already provides the required data.
---
## 5. Layout Engine
Inspect the existing layout implementation.
Determine whether it already supports
* page insertion
* page cloning
* page replacement
* page splitting
* dynamic page generation
* table pagination
* automatic page break
If not supported,
design the extension before implementation.
---
# Discovery Output
Before coding, provide
1. Existing architecture
2. Dependency graph
3. Current template lifecycle
4. Runtime rendering flow
5. Files that will be modified
6. New files that will be created
7. Backward compatibility analysis
8. Risk assessment
9. Migration strategy
Implementation may only begin after this report.
---
# Functional Requirements
Current document
```
Page 1
Customer Detail
Page 2
Condition + Signature
```
shall become
```
Page 1
Customer Detail
Page 2
Product Item Detail Table
Page 3
Condition & Signature
```
---
# Product Item Page
The Product Item page shall contain
* quotation items
* item number
* product code
* description
* specification
* quantity
* unit
* unit price
* discount
* amount
* remark (if available)
The table shall support
* unlimited rows
* automatic page break
* repeated table header
* automatic page numbering if multiple product pages are required
No fixed maximum row count.
---
# PDFMe Table
Use the official PDFMe Table implementation.
Do not create a custom HTML rendering solution.
Support
* automatic pagination
* repeated headers
* dynamic row height
* variable content length
---
# Template Versioning
Do NOT modify
* existing template JSON
* existing production template
* existing preview template
Create a new version.
Example
```
ALLA_template_pdfme_product_v1.json
ONVALLA_template_pdfme_product_v1.json
```
The existing templates must continue working without any behavior changes.
---
# Runtime Compatibility
The preview system must support both
Legacy Template
and
Product Item Template
through template selection.
No breaking changes.
---
# CRM Template Module Compatibility
The implementation must integrate with the existing
```
/dashboard/crm/settings/templates
```
workflow.
Do not bypass
* template storage
* template CRUD
* version management
* preview
* activation
The new template shall behave exactly like existing templates.
---
# Non-Functional Requirements
* No breaking changes
* No duplicated rendering pipeline
* No duplicated adapters
* No duplicated DTOs
* No duplicated services
* Reuse existing layout engine whenever possible
* Extend existing architecture instead of replacing it
---
# Acceptance Criteria
* Discovery report completed before implementation.
* Existing templates remain unchanged.
* New Product Item template added as a separate version.
* Product Item page inserted between Customer Detail and Condition page.
* Unlimited Product Item rows supported.
* Automatic page break works correctly.
* Table headers repeat across pages.
* Preview works correctly.
* PDF download works correctly.
* Existing templates continue functioning without modification.
* `/dashboard/crm/settings/templates` fully supports the new template without regression.

View File

@@ -0,0 +1,52 @@
import type {
CompatibleTemplate,
ResolvedPage,
ResolvedPages,
RuntimeIssue,
SectionRole,
} from './runtime-contracts';
import { SINGLETON_SECTION_ROLES } from './runtime-contracts';
function createByRoleMap(): Partial<Record<SectionRole, ResolvedPage[]>> {
return {};
}
export function resolveTemplatePages(
compatibleTemplate: CompatibleTemplate,
): ResolvedPages {
const byRole = createByRoleMap();
const issues: RuntimeIssue[] = [...compatibleTemplate.issues];
const all: ResolvedPage[] = compatibleTemplate.pages.map((page) => ({ ...page }));
for (const page of all) {
for (const role of page.roles) {
const pages = byRole[role] ?? [];
pages.push(page);
byRole[role] = pages;
}
}
for (const [role, pages] of Object.entries(byRole) as Array<
[SectionRole, ResolvedPage[]]
>) {
if (!SINGLETON_SECTION_ROLES.has(role) || pages.length <= 1) {
continue;
}
issues.push({
code: 'DUPLICATE_MARKER',
severity: 'error',
message: 'PDF template resolves multiple singleton section pages.',
details: {
role,
pageIndexes: pages.map((page) => page.pageIndex),
},
});
}
return {
all,
byRole,
issues,
};
}

View File

@@ -1,6 +1,7 @@
import type { Template } from '@pdfme/common';
import { formatTopicItems } from './pdfme-transforms';
import type { PdfTopic, PdfTopicEngineOptions } from './pdf-topic.type';
import type { RuntimeIssue } from './runtime-contracts';
type TemplateSchema = {
name?: string;
@@ -13,6 +14,7 @@ type TemplateSchema = {
const DEFAULT_TOPIC_ENGINE_OPTIONS: Required<PdfTopicEngineOptions> = {
targetPageIndex: 1,
basePageIndex: 1,
pageStartY: 35,
pageBottomY: 250,
topicSpacing: 10,
@@ -24,81 +26,117 @@ const DEFAULT_TOPIC_ENGINE_OPTIONS: Required<PdfTopicEngineOptions> = {
'app2',
'app2_position',
'app3',
'app3_position'
'app3_position',
],
topicTemplateName: 'topic',
topicDataTemplateName: 'data_topic',
rowHeight: 7,
keepTogetherMinSpace: 60
keepTogetherMinSpace: 60,
};
function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function getFieldY(field: TemplateSchema) {
function getFieldY(field: TemplateSchema): number {
return typeof field.position?.y === 'number' ? field.position.y : 0;
}
function getFieldBottom(field: TemplateSchema) {
function getFieldBottom(field: TemplateSchema): number {
return getFieldY(field) + (typeof field.height === 'number' ? field.height : 0);
}
function setFieldY(field: TemplateSchema, y: number) {
field.position = {
...(field.position ?? {}),
y
};
function setFieldY(field: TemplateSchema, y: number): void {
field.position = { ...(field.position ?? {}), y };
}
function sortTopics(topics: PdfTopic[]) {
function sortTopics(topics: PdfTopic[]): PdfTopic[] {
return [...topics].sort((left, right) => {
const sortDelta = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
const sortDelta =
(left.sortOrder ?? Number.MAX_SAFE_INTEGER) -
(right.sortOrder ?? Number.MAX_SAFE_INTEGER);
if (sortDelta !== 0) {
return sortDelta;
}
return String(left.id ?? left.topicType).localeCompare(String(right.id ?? right.topicType));
return String(left.id ?? left.topicType).localeCompare(
String(right.id ?? right.topicType),
);
});
}
export function buildPdfTopicTemplate(
template: Template,
topics: PdfTopic[],
options?: PdfTopicEngineOptions
): {
template: Template;
topicInputs: Record<string, string[][]>;
} {
const resolvedOptions = { ...DEFAULT_TOPIC_ENGINE_OPTIONS, ...options };
const nextTemplate = cloneTemplate(template);
const pages = Array.isArray(nextTemplate.schemas) ? [...nextTemplate.schemas] : [];
const targetPage = pages[resolvedOptions.targetPageIndex];
function resolveOptions(
options?: PdfTopicEngineOptions,
): Required<PdfTopicEngineOptions> {
const basePageIndex =
options?.basePageIndex ?? options?.targetPageIndex ?? DEFAULT_TOPIC_ENGINE_OPTIONS.basePageIndex;
if (!Array.isArray(targetPage)) {
return { template: nextTemplate, topicInputs: {} };
return {
...DEFAULT_TOPIC_ENGINE_OPTIONS,
...options,
basePageIndex,
targetPageIndex: basePageIndex,
};
}
const topicTemplate = targetPage.find(
(field) => (field as TemplateSchema).name === resolvedOptions.topicTemplateName
) as TemplateSchema | undefined;
const topicDataTemplate = targetPage.find(
(field) => (field as TemplateSchema).name === resolvedOptions.topicDataTemplateName
) as TemplateSchema | undefined;
export function buildPdfTopicPages(
basePage: Template['schemas'][number] | undefined,
topics: PdfTopic[],
options?: PdfTopicEngineOptions,
): {
pages: Template['schemas'];
topicInputs: Record<string, string[][]>;
issues: RuntimeIssue[];
} {
const resolvedOptions = resolveOptions(options);
const issues: RuntimeIssue[] = [];
if (!Array.isArray(basePage)) {
issues.push({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'Topic engine could not find a valid base page schema.',
details: { basePageIndex: resolvedOptions.basePageIndex },
});
return { pages: [], topicInputs: {}, issues };
}
const pageFields = basePage.map((field) => cloneTemplate(field as TemplateSchema));
const topicTemplate =
pageFields.find(
(field) => field.name === resolvedOptions.topicTemplateName,
) ?? null;
const topicDataTemplate =
pageFields.find(
(field) => field.name === resolvedOptions.topicDataTemplateName,
) ?? null;
if (!topicTemplate || !topicDataTemplate) {
console.warn('[pdf-topic-engine] Missing topic or data_topic schema template');
return { template: nextTemplate, topicInputs: {} };
issues.push({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'Topic engine could not find topic/data_topic schema markers.',
details: {
basePageIndex: resolvedOptions.basePageIndex,
topicTemplateName: resolvedOptions.topicTemplateName,
topicDataTemplateName: resolvedOptions.topicDataTemplateName,
},
});
return { pages: [pageFields as Template['schemas'][number]], topicInputs: {}, issues };
}
const keepTogetherSet = new Set(resolvedOptions.keepTogetherNames);
const pageWithoutDynamicFields = targetPage.filter((field) => {
const name = (field as TemplateSchema).name;
const pageWithoutDynamicFields = pageFields.filter((field) => {
const name = field.name;
return (
name !== resolvedOptions.topicTemplateName && name !== resolvedOptions.topicDataTemplateName
name !== resolvedOptions.topicTemplateName &&
name !== resolvedOptions.topicDataTemplateName
);
}) as TemplateSchema[];
});
const keepTogetherFields = pageWithoutDynamicFields
.filter((field) => keepTogetherSet.has(field.name ?? ''))
.map((field) => cloneTemplate(field));
@@ -115,18 +153,22 @@ export function buildPdfTopicTemplate(
: resolvedOptions.pageBottomY;
const keepTogetherHeight =
keepTogetherFields.length > 0
? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) - keepTogetherTop
? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) -
keepTogetherTop
: 0;
let currentPageFields = staticFields.map((field) => cloneTemplate(field));
let currentY = Math.max(
resolvedOptions.pageStartY,
currentPageFields.length > 0
? Math.max(...currentPageFields.map((field) => getFieldBottom(field)), resolvedOptions.pageStartY)
: resolvedOptions.pageStartY
? Math.max(
...currentPageFields.map((field) => getFieldBottom(field)),
resolvedOptions.pageStartY,
)
: resolvedOptions.pageStartY,
);
const pushCurrentPage = () => {
const pushCurrentPage = (): void => {
generatedPages.push(currentPageFields as Template['schemas'][number]);
currentPageFields = [];
currentY = resolvedOptions.pageStartY;
@@ -134,17 +176,24 @@ export function buildPdfTopicTemplate(
for (const [topicIndex, topic] of sortedTopics.entries()) {
const topicRows = formatTopicItems({ items: topic.items ?? [] });
const topicKey = `topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
const itemKey = `item_topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
const topicKey = `topic_${resolvedOptions.basePageIndex}_${topicIndex}`;
const itemKey = `item_topic_${resolvedOptions.basePageIndex}_${topicIndex}`;
const labelHeight =
typeof topicTemplate.height === 'number' ? topicTemplate.height : resolvedOptions.rowHeight;
typeof topicTemplate.height === 'number'
? topicTemplate.height
: resolvedOptions.rowHeight;
const tableHeight = Math.max(
typeof topicDataTemplate.height === 'number' ? topicDataTemplate.height : resolvedOptions.rowHeight,
topicRows.length * resolvedOptions.rowHeight
typeof topicDataTemplate.height === 'number'
? topicDataTemplate.height
: resolvedOptions.rowHeight,
topicRows.length * resolvedOptions.rowHeight,
);
const blockHeight = topicToDataOffset + tableHeight;
if (currentY + blockHeight > resolvedOptions.pageBottomY && currentPageFields.length > 0) {
if (
currentY + blockHeight > resolvedOptions.pageBottomY &&
currentPageFields.length > 0
) {
pushCurrentPage();
}
@@ -160,10 +209,10 @@ export function buildPdfTopicTemplate(
topicDataField.height = tableHeight;
setFieldY(topicDataField, currentY + topicToDataOffset);
topicInputs[topicKey] = [[topic.topicType?.trim() || '-']];
topicInputs[itemKey] = topicRows;
currentPageFields.push(topicField, topicDataField);
currentY = getFieldY(topicDataField) + tableHeight + resolvedOptions.topicSpacing;
topicInputs[topicKey] = [[topic.topicType]];
topicInputs[itemKey] = topicRows;
currentY += blockHeight + resolvedOptions.topicSpacing;
}
if (keepTogetherFields.length > 0) {
@@ -189,16 +238,51 @@ export function buildPdfTopicTemplate(
generatedPages.push(currentPageFields as Template['schemas'][number]);
}
nextTemplate.schemas = [
...pages.slice(0, resolvedOptions.targetPageIndex),
...(generatedPages.length > 0
return {
pages:
generatedPages.length > 0
? generatedPages
: [pageWithoutDynamicFields as Template['schemas'][number]]),
...pages.slice(resolvedOptions.targetPageIndex + 1)
: [pageWithoutDynamicFields as Template['schemas'][number]],
topicInputs,
issues,
};
}
export function buildPdfTopicTemplate(
template: Template,
topics: PdfTopic[],
options?: PdfTopicEngineOptions,
): {
template: Template;
topicInputs: Record<string, string[][]>;
issues: RuntimeIssue[];
} {
const resolvedOptions = resolveOptions(options);
const nextTemplate = cloneTemplate(template);
const pages = nextTemplate.schemas ?? [];
const built = buildPdfTopicPages(
pages[resolvedOptions.basePageIndex],
topics,
resolvedOptions,
);
if (built.pages.length === 0) {
return {
template: nextTemplate,
topicInputs: {},
issues: built.issues,
};
}
nextTemplate.schemas = [
...pages.slice(0, resolvedOptions.basePageIndex),
...built.pages,
...pages.slice(resolvedOptions.basePageIndex + 1),
];
return {
template: nextTemplate,
topicInputs
topicInputs: built.topicInputs,
issues: built.issues,
};
}

View File

@@ -13,6 +13,7 @@ export type PdfTopic = {
export type PdfTopicEngineOptions = {
targetPageIndex?: number;
basePageIndex?: number;
pageStartY?: number;
pageBottomY?: number;
topicSpacing?: number;

View File

@@ -0,0 +1,240 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Template } from '@pdfme/common';
import type { ResolvedDocumentTemplate } from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types';
import { buildQuotationPdfRuntime } from './quotation-pdf-runtime';
import { resolveTemplatePages } from './page-resolver';
import type { ResolvedTemplate } from './runtime-contracts';
import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter';
function createField(name: string, y: number, height = 7) {
return {
name,
type: 'text',
position: { x: 10, y },
width: 100,
height,
content: `{${name}}`,
};
}
function createLegacyTemplate(): Template {
return {
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [
[
createField('customer_name', 20),
createField('quotation_price_data', 40, 20),
createField('quotation_code_data', 70),
],
[
createField('topic', 35),
createField('data_topic', 45, 7),
createField('Please_do_not', 225),
createField('yours_faithfuly', 232),
createField('app1', 240),
createField('app1_position', 247),
createField('app2', 240),
createField('app2_position', 247),
createField('app3', 240),
createField('app3_position', 247),
],
],
} as unknown as Template;
}
function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTemplate {
return {
template: {
id: 'template-1',
organizationId: 'org-1',
documentType: 'quotation',
productType: 'default',
fileType: 'pdfme',
templateName: 'Legacy quotation',
description: null,
isDefault: true,
isActive: true,
createdAt: '2026-06-29T00:00:00.000Z',
updatedAt: '2026-06-29T00:00:00.000Z',
deletedAt: null,
createdBy: 'system',
updatedBy: 'system',
},
version: {
id: 'version-1',
organizationId: 'org-1',
templateId: 'template-1',
version: '1.0',
filePath: null,
schemaJson: schema,
previewImageUrl: null,
isActive: true,
createdAt: '2026-06-29T00:00:00.000Z',
updatedAt: '2026-06-29T00:00:00.000Z',
deletedAt: null,
createdBy: 'system',
},
};
}
function createDocumentData(topicCount: number): QuotationDocumentData {
return {
company: {
id: 'company-1',
name: 'ALLA',
slug: 'alla',
imageUrl: null,
plan: 'pro',
},
branch: null,
customer: {
id: 'customer-1',
code: 'C-001',
name: 'Customer',
address: null,
phone: null,
email: null,
taxId: null,
},
contact: null,
quotation: {
id: 'quotation-1',
code: 'QT-TEST',
quotationDate: '2026-06-29',
validUntil: null,
projectName: null,
projectLocation: null,
attention: null,
reference: null,
revision: 0,
revisionLabel: 'Rev.0',
statusCode: 'approved',
statusLabel: 'Approved',
quotationTypeCode: null,
quotationTypeLabel: null,
currency: 'THB',
currencyCode: 'THB',
currencyLabel: 'Thai Baht',
exchangeRate: 1,
subtotal: 0,
discount: 0,
discountTypeCode: null,
discountTypeLabel: null,
taxRate: 0,
taxAmount: 0,
totalAmount: 0,
notes: null,
},
items: [],
quotationCustomers: [],
topics: {
scope: [],
exclusion: [],
payment: [],
warranty: [],
delivery: [],
other: [],
all: Array.from({ length: topicCount }, (_, index) => ({
id: `topic-${index}`,
topicType: `Topic ${index + 1}`,
sortOrder: index,
items: Array.from({ length: 6 }, (_, itemIndex) => ({
id: `${index}-${itemIndex}`,
content: `Topic ${index + 1} item ${itemIndex + 1}`,
sortOrder: itemIndex,
})),
})),
},
pdfme: {
labels: {
tel: 'Tel',
email: 'Email',
att: 'Att',
project: 'Project',
location: 'Location',
},
quotation_date: '2026-06-29',
quotation_price: '0.00',
quotation_price_data: [],
exclusion_data: [],
topic_inputs: {},
},
approval: {
requestId: null,
workflowName: null,
status: 'approved',
approvedAt: '2026-06-29T00:00:00.000Z',
currentStep: null,
currentStepRoleName: null,
approvers: [],
},
signatures: {
preparedBy: {
name: 'Prepared',
position: 'Sales',
},
approvedBy: {
name: 'Approved',
position: 'Manager',
},
authorizedBy: {
name: 'Authorized',
position: 'Director',
},
},
watermarkStatus: null,
} as QuotationDocumentData;
}
test('legacy template page roles are inferred without page-index assumptions', () => {
const schema = createLegacyTemplate();
const template = createResolvedDocumentTemplate(schema);
const runtimeTemplate: ResolvedTemplate = {
documentTemplate: template,
schema,
};
const compatible = adaptTemplateForSectionRuntime(runtimeTemplate);
const resolved = resolveTemplatePages(compatible);
assert.equal(resolved.byRole.customer?.[0]?.pageIndex, 0);
assert.equal(resolved.byRole.topics?.[0]?.pageIndex, 1);
assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 1);
assert.ok(
compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'),
);
});
test('runtime keeps signature block on the last generated topic page', async () => {
const schema = createLegacyTemplate();
const template = createResolvedDocumentTemplate(schema);
const runtime = await buildQuotationPdfRuntime({
documentData: createDocumentData(12),
template,
mappings: [],
templateInput: {
customer_name: 'Customer',
},
});
assert.ok(runtime.assembled.template.schemas.length > 2);
assert.ok(
Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')),
);
const topicPages = runtime.assembled.template.schemas.slice(1);
const lastPageNames = new Set(
topicPages.at(-1)?.map((field) => String((field as { name?: string }).name ?? '')) ?? [],
);
assert.ok(lastPageNames.has('app1'));
for (const page of topicPages.slice(0, -1)) {
const fieldNames = new Set(
page.map((field) => String((field as { name?: string }).name ?? '')),
);
assert.equal(fieldNames.has('app1'), false);
}
});

View File

@@ -0,0 +1,162 @@
import type { Template } from '@pdfme/common';
import type {
DocumentTemplateMappingWithColumns,
ResolvedDocumentTemplate,
} from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types';
import { buildPdfTopicPages } from './pdf-topic-engine';
import { resolveTemplatePages } from './page-resolver';
import { createRenderContext, resolveRenderPolicies } from './render-context';
import type {
BuiltSection,
QuotationPdfRuntimeResult,
RenderContext,
ResolvedTemplate,
SectionBuilder,
} from './runtime-contracts';
import { composeSections } from './section-composer';
import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter';
import { assembleTemplate } from './template-assembler';
function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function createCustomerSectionBuilder(): SectionBuilder {
return {
role: 'customer',
build(context: RenderContext): BuiltSection<'customer'> {
const page = context.pages.byRole.customer?.[0] ?? null;
const policy = context.policyBySection.get('customer');
if (!page) {
return {
role: 'customer',
enabled: policy?.enabled ?? false,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
issues: [
{
code: 'MISSING_MARKER',
severity: policy?.required ? 'error' : 'warning',
message: 'Customer section page could not be resolved.',
},
],
};
}
return {
role: 'customer',
enabled: true,
rendered: true,
anchorPageIndex: page.pageIndex,
pages: [cloneTemplate(page.schema)],
templateInputPatch: {},
issues: [],
};
},
};
}
function createTopicSectionBuilder(): SectionBuilder {
return {
role: 'topics',
build(context: RenderContext): BuiltSection<'topics'> {
const page = context.pages.byRole.topics?.[0] ?? null;
const policy = context.policyBySection.get('topics');
if (!page) {
return {
role: 'topics',
enabled: policy?.enabled ?? false,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
issues: [
{
code: 'MISSING_MARKER',
severity: policy?.required ? 'error' : 'warning',
message: 'Topic section page could not be resolved.',
},
],
};
}
const built = buildPdfTopicPages(page.schema, context.documentData.topics.all, {
basePageIndex: page.pageIndex,
});
return {
role: 'topics',
enabled: true,
rendered: true,
anchorPageIndex: page.pageIndex,
pages: built.pages,
templateInputPatch: built.topicInputs,
issues: built.issues,
};
},
};
}
function getQuotationSectionBuilders(): SectionBuilder[] {
return [createCustomerSectionBuilder(), createTopicSectionBuilder()];
}
function createResolvedTemplate(
template: ResolvedDocumentTemplate,
): ResolvedTemplate {
return {
documentTemplate: template,
schema: cloneTemplate(template.version.schemaJson as Template),
};
}
function createRuntimeErrorMessage(): string {
return 'Quotation PDF runtime is invalid for the active document template.';
}
export async function buildQuotationPdfRuntime(args: {
documentData: QuotationDocumentData;
template: ResolvedDocumentTemplate;
mappings: DocumentTemplateMappingWithColumns[];
templateInput: Record<string, unknown>;
}): Promise<QuotationPdfRuntimeResult> {
const resolvedTemplate = createResolvedTemplate(args.template);
const compatibleTemplate = adaptTemplateForSectionRuntime(resolvedTemplate);
const pages = resolveTemplatePages(compatibleTemplate);
const policies = resolveRenderPolicies();
const context = createRenderContext({
documentData: args.documentData,
template: resolvedTemplate,
mappings: args.mappings,
pages,
policies,
issues: pages.issues,
});
const composed = await composeSections(context, getQuotationSectionBuilders());
const assembled = assembleTemplate({
template: resolvedTemplate.schema,
templateInput: args.templateInput,
sections: composed.sections,
});
const issues = [...pages.issues, ...composed.issues, ...assembled.issues];
const topicSection = composed.sections.find((section) => section.role === 'topics');
if (issues.some((issue) => issue.severity === 'error')) {
throw new Error(createRuntimeErrorMessage(), { cause: issues });
}
return {
assembled,
topicInputs: topicSection
? (topicSection.templateInputPatch as Record<string, string[][]>)
: {},
issues,
pages,
policies,
};
}

View File

@@ -0,0 +1,64 @@
import type { DocumentTemplateMappingWithColumns } from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types';
import type {
RenderContext,
RenderPolicy,
ResolvedPages,
ResolvedTemplate,
RuntimeIssue,
SectionRole,
} from './runtime-contracts';
import { SECTION_ORDER } from './runtime-contracts';
function buildDefaultPolicy(section: SectionRole): RenderPolicy {
switch (section) {
case 'customer':
return {
section,
enabled: true,
required: true,
visibleWhenEmpty: true,
};
case 'topics':
return {
section,
enabled: true,
required: true,
visibleWhenEmpty: true,
};
default:
return {
section,
enabled: false,
required: false,
visibleWhenEmpty: false,
};
}
}
export function resolveRenderPolicies(): RenderPolicy[] {
return SECTION_ORDER.map((section) => buildDefaultPolicy(section));
}
export function createRenderContext(args: {
documentData: QuotationDocumentData;
template: ResolvedTemplate;
mappings: DocumentTemplateMappingWithColumns[];
pages: ResolvedPages;
policies: RenderPolicy[];
issues?: RuntimeIssue[];
}): RenderContext {
const policyBySection = new Map<SectionRole, RenderPolicy>(
args.policies.map((policy) => [policy.section, policy]),
);
return {
documentData: args.documentData,
template: args.template,
mappings: args.mappings,
pages: args.pages,
policies: [...args.policies],
policyBySection,
issues: [...(args.issues ?? [])],
};
}

View File

@@ -0,0 +1,134 @@
import type { Template } from '@pdfme/common';
import type {
DocumentTemplateMappingWithColumns,
ResolvedDocumentTemplate,
} from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types';
export type SectionRole =
| 'customer'
| 'product_items'
| 'topics'
| 'conditions'
| 'signature'
| 'attachments'
| 'appendix'
| 'warranty'
| 'cover'
| 'unknown';
export type PageResolutionStrategy =
| 'explicit_marker'
| 'legacy_inference'
| 'fallback';
export type RuntimeIssueCode =
| 'MISSING_MARKER'
| 'DUPLICATE_MARKER'
| 'INVALID_TEMPLATE'
| 'MISSING_MAPPING'
| 'EMPTY_OPTIONAL_SECTION'
| 'EMPTY_REQUIRED_SECTION'
| 'LEGACY_COMPAT_MODE';
export interface RuntimeIssue {
code: RuntimeIssueCode;
severity: 'warning' | 'error';
message: string;
details?: Record<string, unknown>;
}
export interface ResolvedTemplate {
documentTemplate: ResolvedDocumentTemplate;
schema: Template;
}
export interface CompatibleTemplatePage {
pageIndex: number;
primaryRole: SectionRole;
roles: SectionRole[];
strategy: PageResolutionStrategy;
schema: Template['schemas'][number];
}
export interface CompatibleTemplate {
template: ResolvedTemplate;
pages: CompatibleTemplatePage[];
issues: RuntimeIssue[];
}
export interface ResolvedPage extends CompatibleTemplatePage {}
export interface ResolvedPages {
all: ResolvedPage[];
byRole: Partial<Record<SectionRole, ResolvedPage[]>>;
issues: RuntimeIssue[];
}
export interface RenderPolicy {
section: SectionRole;
enabled: boolean;
required: boolean;
visibleWhenEmpty: boolean;
}
export interface BuiltSection<TRole extends SectionRole = SectionRole> {
role: TRole;
enabled: boolean;
rendered: boolean;
anchorPageIndex: number | null;
pages: Template['schemas'];
templateInputPatch: Record<string, unknown>;
issues: RuntimeIssue[];
}
export interface RenderContext {
documentData: QuotationDocumentData;
template: ResolvedTemplate;
mappings: DocumentTemplateMappingWithColumns[];
pages: ResolvedPages;
policies: RenderPolicy[];
policyBySection: Map<SectionRole, RenderPolicy>;
issues: RuntimeIssue[];
}
export interface SectionBuilder {
role: SectionRole;
build(context: RenderContext): BuiltSection | Promise<BuiltSection>;
}
export interface AssembledTemplate {
template: Template;
templateInput: Record<string, unknown>;
issues: RuntimeIssue[];
}
export interface QuotationPdfRuntimeResult {
assembled: AssembledTemplate;
topicInputs: Record<string, string[][]>;
issues: RuntimeIssue[];
pages: ResolvedPages;
policies: RenderPolicy[];
}
export const SECTION_ORDER: SectionRole[] = [
'customer',
'product_items',
'topics',
'conditions',
'signature',
'attachments',
'appendix',
'warranty',
'cover',
];
export const SINGLETON_SECTION_ROLES = new Set<SectionRole>([
'customer',
'product_items',
'topics',
'conditions',
'signature',
'warranty',
'cover',
]);

View File

@@ -0,0 +1,45 @@
import type {
BuiltSection,
RenderContext,
RuntimeIssue,
SectionBuilder,
} from './runtime-contracts';
import { SECTION_ORDER } from './runtime-contracts';
export interface SectionComposerResult {
sections: BuiltSection[];
issues: RuntimeIssue[];
}
export async function composeSections(
context: RenderContext,
builders: SectionBuilder[],
): Promise<SectionComposerResult> {
const builderByRole = new Map(builders.map((builder) => [builder.role, builder]));
const sections: BuiltSection[] = [];
const issues: RuntimeIssue[] = [];
for (const role of SECTION_ORDER) {
const policy = context.policyBySection.get(role);
if (!policy?.enabled) {
continue;
}
const builder = builderByRole.get(role);
if (!builder) {
issues.push({
code: 'INVALID_TEMPLATE',
severity: policy.required ? 'error' : 'warning',
message: 'No section builder is registered for an enabled section.',
details: { role },
});
continue;
}
const section = await builder.build(context);
sections.push(section);
issues.push(...section.issues);
}
return { sections, issues };
}

View File

@@ -35,10 +35,9 @@ import {
formatPdfDate,
formatTopicItems,
} from "./pdfme-transforms";
import { buildPdfTopicTemplate } from "./pdf-topic-engine";
import { buildQuotationPdfRuntime } from "./quotation-pdf-runtime";
import { resolveQuotationSignatures } from "./signature-resolver";
import { resolveQuotationTopicTypeMapping } from "./topic-mapping";
import type { Template } from "@pdfme/common";
const DOCUMENT_OPTION_CATEGORIES = {
branch: "crm_branch",
@@ -260,6 +259,17 @@ function normalizePdfmeTemplateInput(
return normalized;
}
function logQuotationPdfRuntimeWarnings(issues: Array<{ message: string }>) {
if (!issues.length) {
return;
}
console.warn(
"[quotation-pdf-runtime]",
issues.map((issue) => issue.message).join(" | "),
);
}
export async function buildQuotationDocumentData(
quotationId: string,
organizationId: string,
@@ -690,16 +700,19 @@ export async function getQuotationDocumentPreviewData(
mappings,
),
);
const { template: renderTemplate, topicInputs } = buildPdfTopicTemplate(
template.version.schemaJson as Template,
documentData.topics.all,
const runtime = await buildQuotationPdfRuntime({
documentData,
template,
mappings,
templateInput,
});
const runtimeWarnings = runtime.issues.filter(
(issue) => issue.severity === "warning",
);
const mergedTemplateInput = {
...templateInput,
...topicInputs,
};
documentData.pdfme.topic_inputs = topicInputs;
logQuotationPdfRuntimeWarnings(runtimeWarnings);
documentData.pdfme.topic_inputs = runtime.topicInputs;
return {
documentData,
@@ -707,11 +720,11 @@ export async function getQuotationDocumentPreviewData(
...template,
version: {
...template.version,
schemaJson: renderTemplate,
schemaJson: runtime.assembled.template,
},
},
mappings,
templateInput: mergedTemplateInput,
templateInput: runtime.assembled.templateInput,
};
}

View File

@@ -0,0 +1,78 @@
import type { Template } from '@pdfme/common';
import type {
AssembledTemplate,
BuiltSection,
RuntimeIssue,
} from './runtime-contracts';
function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export function assembleTemplate(args: {
template: Template;
templateInput: Record<string, unknown>;
sections: BuiltSection[];
}): AssembledTemplate {
const baseTemplate = cloneTemplate(args.template);
const replacementByAnchor = new Map<number, BuiltSection>();
const issues: RuntimeIssue[] = [];
for (const section of args.sections) {
if (section.anchorPageIndex === null) {
issues.push(...section.issues);
continue;
}
if (replacementByAnchor.has(section.anchorPageIndex)) {
issues.push({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'Multiple built sections attempted to replace the same page.',
details: {
anchorPageIndex: section.anchorPageIndex,
roles: [
replacementByAnchor.get(section.anchorPageIndex)?.role,
section.role,
],
},
});
continue;
}
replacementByAnchor.set(section.anchorPageIndex, section);
issues.push(...section.issues);
}
const nextPages: Template['schemas'] = [];
for (const [pageIndex, page] of baseTemplate.schemas.entries()) {
const replacement = replacementByAnchor.get(pageIndex);
if (!replacement) {
nextPages.push(cloneTemplate(page));
continue;
}
if (!replacement.rendered || replacement.pages.length === 0) {
continue;
}
nextPages.push(...cloneTemplate(replacement.pages));
}
baseTemplate.schemas = nextPages;
const templateInput = args.sections.reduce<Record<string, unknown>>(
(accumulator, section) => ({
...accumulator,
...section.templateInputPatch,
}),
{ ...args.templateInput },
);
return {
template: baseTemplate,
templateInput,
issues,
};
}

View File

@@ -0,0 +1,209 @@
import type { Template } from '@pdfme/common';
import type {
CompatibleTemplate,
CompatibleTemplatePage,
ResolvedTemplate,
RuntimeIssue,
SectionRole,
} from './runtime-contracts';
const EXPLICIT_ROLE_PREFIX = '__page_role__';
const LEGACY_MARKERS: Record<Exclude<SectionRole, 'unknown'>, string[]> = {
customer: ['customer_name', 'quotation_price_data', 'quotation_code_data'],
product_items: ['items_table'],
topics: ['topic', 'data_topic'],
conditions: ['conditions', 'condition_data'],
signature: [
'Please_do_not',
'yours_faithfuly',
'app1',
'app1_position',
'app2',
'app2_position',
'app3',
'app3_position',
],
attachments: ['attachment'],
appendix: ['appendix'],
warranty: ['warranty'],
cover: ['cover'],
};
type TemplateField = {
name?: string;
content?: string;
};
function createIssue(issue: RuntimeIssue): RuntimeIssue {
return issue;
}
function isTemplatePageArray(
value: Template['schemas'][number] | unknown,
): value is Template['schemas'][number] {
return Array.isArray(value);
}
function getFieldNames(page: Template['schemas'][number]): string[] {
return page
.map((field) => {
if (typeof field !== 'object' || field === null || !('name' in field)) {
return '';
}
return String((field as TemplateField).name ?? '');
})
.filter(Boolean);
}
function getExplicitRole(page: Template['schemas'][number]): SectionRole | null {
for (const field of page) {
if (!field || typeof field !== 'object') {
continue;
}
const name =
'name' in field && typeof field.name === 'string' ? field.name : null;
const content =
'content' in field && typeof field.content === 'string'
? field.content.trim()
: null;
const byName = name?.startsWith(EXPLICIT_ROLE_PREFIX)
? name.slice(EXPLICIT_ROLE_PREFIX.length)
: null;
const candidate = byName ?? content;
if (candidate && isSectionRole(candidate)) {
return candidate;
}
}
return null;
}
function inferLegacyRoles(page: Template['schemas'][number]): SectionRole[] {
const names = new Set(getFieldNames(page));
const inferred: SectionRole[] = [];
for (const [role, markers] of Object.entries(LEGACY_MARKERS) as Array<
[Exclude<SectionRole, 'unknown'>, string[]]
>) {
if (markers.every((marker) => names.has(marker))) {
inferred.push(role);
}
}
if (!inferred.length && names.has('topic')) {
inferred.push('topics');
}
if (
!inferred.includes('signature') &&
['app1', 'app2', 'app3'].some((name) => names.has(name))
) {
inferred.push('signature');
}
return inferred;
}
function isSectionRole(value: string): value is SectionRole {
return (
value === 'customer' ||
value === 'product_items' ||
value === 'topics' ||
value === 'conditions' ||
value === 'signature' ||
value === 'attachments' ||
value === 'appendix' ||
value === 'warranty' ||
value === 'cover' ||
value === 'unknown'
);
}
export function adaptTemplateForSectionRuntime(
template: ResolvedTemplate,
): CompatibleTemplate {
const issues: RuntimeIssue[] = [];
const schemaPages = template.schema.schemas;
if (!Array.isArray(schemaPages)) {
return {
template,
pages: [],
issues: [
createIssue({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'PDF template schema does not expose a valid schemas array.',
}),
],
};
}
const pages: CompatibleTemplatePage[] = [];
for (const [pageIndex, rawPage] of schemaPages.entries()) {
if (!isTemplatePageArray(rawPage)) {
issues.push(
createIssue({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'PDF template page schema is invalid.',
details: { pageIndex },
}),
);
continue;
}
const explicitRole = getExplicitRole(rawPage);
const inferredRoles = inferLegacyRoles(rawPage);
const primaryRole = explicitRole ?? inferredRoles[0] ?? 'unknown';
const roles = Array.from(
new Set([primaryRole, ...inferredRoles].filter(isSectionRole)),
);
let strategy: CompatibleTemplatePage['strategy'] = 'fallback';
if (explicitRole) {
strategy = 'explicit_marker';
} else if (inferredRoles.length > 0) {
strategy = 'legacy_inference';
issues.push(
createIssue({
code: 'LEGACY_COMPAT_MODE',
severity: 'warning',
message:
'PDF template page role was inferred from legacy field markers.',
details: { pageIndex, roles },
}),
);
} else {
issues.push(
createIssue({
code: 'MISSING_MARKER',
severity: 'warning',
message:
'PDF template page could not be classified by explicit marker or legacy inference.',
details: { pageIndex },
}),
);
}
pages.push({
pageIndex,
primaryRole,
roles: roles.length > 0 ? roles : ['unknown'],
strategy,
schema: rawPage,
});
}
return {
template,
pages,
issues,
};
}