taks-p.4.3
This commit is contained in:
File diff suppressed because it is too large
Load Diff
BIN
artifacts/task-p4-verification/rows_30_short.pdf
Normal file
BIN
artifacts/task-p4-verification/rows_30_short.pdf
Normal file
Binary file not shown.
582
plans/task-p.4-discovery-report.md
Normal file
582
plans/task-p.4-discovery-report.md
Normal 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)
|
||||||
457
plans/task-p.4.1-verification-report.md
Normal file
457
plans/task-p.4.1-verification-report.md
Normal 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 project’s 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 engine’s hardcoded page targeting, not the table plugin itself
|
||||||
67
plans/task-p.4.1.md
Normal file
67
plans/task-p.4.1.md
Normal 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.
|
||||||
1228
plans/task-p.4.2-runtime-design.md
Normal file
1228
plans/task-p.4.2-runtime-design.md
Normal file
File diff suppressed because it is too large
Load Diff
475
plans/task-p.4.2.md
Normal file
475
plans/task-p.4.2.md
Normal 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
437
plans/task-p.4.3.md
Normal 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
326
plans/task-p.4.md
Normal 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.
|
||||||
52
src/features/crm/quotations/document/server/page-resolver.ts
Normal file
52
src/features/crm/quotations/document/server/page-resolver.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Template } from '@pdfme/common';
|
import type { Template } from '@pdfme/common';
|
||||||
import { formatTopicItems } from './pdfme-transforms';
|
import { formatTopicItems } from './pdfme-transforms';
|
||||||
import type { PdfTopic, PdfTopicEngineOptions } from './pdf-topic.type';
|
import type { PdfTopic, PdfTopicEngineOptions } from './pdf-topic.type';
|
||||||
|
import type { RuntimeIssue } from './runtime-contracts';
|
||||||
|
|
||||||
type TemplateSchema = {
|
type TemplateSchema = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -13,6 +14,7 @@ type TemplateSchema = {
|
|||||||
|
|
||||||
const DEFAULT_TOPIC_ENGINE_OPTIONS: Required<PdfTopicEngineOptions> = {
|
const DEFAULT_TOPIC_ENGINE_OPTIONS: Required<PdfTopicEngineOptions> = {
|
||||||
targetPageIndex: 1,
|
targetPageIndex: 1,
|
||||||
|
basePageIndex: 1,
|
||||||
pageStartY: 35,
|
pageStartY: 35,
|
||||||
pageBottomY: 250,
|
pageBottomY: 250,
|
||||||
topicSpacing: 10,
|
topicSpacing: 10,
|
||||||
@@ -24,81 +26,117 @@ const DEFAULT_TOPIC_ENGINE_OPTIONS: Required<PdfTopicEngineOptions> = {
|
|||||||
'app2',
|
'app2',
|
||||||
'app2_position',
|
'app2_position',
|
||||||
'app3',
|
'app3',
|
||||||
'app3_position'
|
'app3_position',
|
||||||
],
|
],
|
||||||
topicTemplateName: 'topic',
|
topicTemplateName: 'topic',
|
||||||
topicDataTemplateName: 'data_topic',
|
topicDataTemplateName: 'data_topic',
|
||||||
rowHeight: 7,
|
rowHeight: 7,
|
||||||
keepTogetherMinSpace: 60
|
keepTogetherMinSpace: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
function cloneTemplate<T>(value: T): T {
|
function cloneTemplate<T>(value: T): T {
|
||||||
return JSON.parse(JSON.stringify(value)) as 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;
|
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);
|
return getFieldY(field) + (typeof field.height === 'number' ? field.height : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFieldY(field: TemplateSchema, y: number) {
|
function setFieldY(field: TemplateSchema, y: number): void {
|
||||||
field.position = {
|
field.position = { ...(field.position ?? {}), y };
|
||||||
...(field.position ?? {}),
|
|
||||||
y
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortTopics(topics: PdfTopic[]) {
|
function sortTopics(topics: PdfTopic[]): PdfTopic[] {
|
||||||
return [...topics].sort((left, right) => {
|
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) {
|
if (sortDelta !== 0) {
|
||||||
return sortDelta;
|
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(
|
function resolveOptions(
|
||||||
template: Template,
|
options?: PdfTopicEngineOptions,
|
||||||
topics: PdfTopic[],
|
): Required<PdfTopicEngineOptions> {
|
||||||
options?: PdfTopicEngineOptions
|
const basePageIndex =
|
||||||
): {
|
options?.basePageIndex ?? options?.targetPageIndex ?? DEFAULT_TOPIC_ENGINE_OPTIONS.basePageIndex;
|
||||||
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];
|
|
||||||
|
|
||||||
if (!Array.isArray(targetPage)) {
|
return {
|
||||||
return { template: nextTemplate, topicInputs: {} };
|
...DEFAULT_TOPIC_ENGINE_OPTIONS,
|
||||||
|
...options,
|
||||||
|
basePageIndex,
|
||||||
|
targetPageIndex: basePageIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 topicTemplate = targetPage.find(
|
const pageFields = basePage.map((field) => cloneTemplate(field as TemplateSchema));
|
||||||
(field) => (field as TemplateSchema).name === resolvedOptions.topicTemplateName
|
const topicTemplate =
|
||||||
) as TemplateSchema | undefined;
|
pageFields.find(
|
||||||
const topicDataTemplate = targetPage.find(
|
(field) => field.name === resolvedOptions.topicTemplateName,
|
||||||
(field) => (field as TemplateSchema).name === resolvedOptions.topicDataTemplateName
|
) ?? null;
|
||||||
) as TemplateSchema | undefined;
|
const topicDataTemplate =
|
||||||
|
pageFields.find(
|
||||||
|
(field) => field.name === resolvedOptions.topicDataTemplateName,
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
if (!topicTemplate || !topicDataTemplate) {
|
if (!topicTemplate || !topicDataTemplate) {
|
||||||
console.warn('[pdf-topic-engine] Missing topic or data_topic schema template');
|
issues.push({
|
||||||
return { template: nextTemplate, topicInputs: {} };
|
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 keepTogetherSet = new Set(resolvedOptions.keepTogetherNames);
|
||||||
const pageWithoutDynamicFields = targetPage.filter((field) => {
|
const pageWithoutDynamicFields = pageFields.filter((field) => {
|
||||||
const name = (field as TemplateSchema).name;
|
const name = field.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
name !== resolvedOptions.topicTemplateName && name !== resolvedOptions.topicDataTemplateName
|
name !== resolvedOptions.topicTemplateName &&
|
||||||
|
name !== resolvedOptions.topicDataTemplateName
|
||||||
);
|
);
|
||||||
}) as TemplateSchema[];
|
});
|
||||||
|
|
||||||
const keepTogetherFields = pageWithoutDynamicFields
|
const keepTogetherFields = pageWithoutDynamicFields
|
||||||
.filter((field) => keepTogetherSet.has(field.name ?? ''))
|
.filter((field) => keepTogetherSet.has(field.name ?? ''))
|
||||||
.map((field) => cloneTemplate(field));
|
.map((field) => cloneTemplate(field));
|
||||||
@@ -115,18 +153,22 @@ export function buildPdfTopicTemplate(
|
|||||||
: resolvedOptions.pageBottomY;
|
: resolvedOptions.pageBottomY;
|
||||||
const keepTogetherHeight =
|
const keepTogetherHeight =
|
||||||
keepTogetherFields.length > 0
|
keepTogetherFields.length > 0
|
||||||
? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) - keepTogetherTop
|
? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) -
|
||||||
|
keepTogetherTop
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
let currentPageFields = staticFields.map((field) => cloneTemplate(field));
|
let currentPageFields = staticFields.map((field) => cloneTemplate(field));
|
||||||
let currentY = Math.max(
|
let currentY = Math.max(
|
||||||
resolvedOptions.pageStartY,
|
resolvedOptions.pageStartY,
|
||||||
currentPageFields.length > 0
|
currentPageFields.length > 0
|
||||||
? Math.max(...currentPageFields.map((field) => getFieldBottom(field)), resolvedOptions.pageStartY)
|
? Math.max(
|
||||||
: resolvedOptions.pageStartY
|
...currentPageFields.map((field) => getFieldBottom(field)),
|
||||||
|
resolvedOptions.pageStartY,
|
||||||
|
)
|
||||||
|
: resolvedOptions.pageStartY,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pushCurrentPage = () => {
|
const pushCurrentPage = (): void => {
|
||||||
generatedPages.push(currentPageFields as Template['schemas'][number]);
|
generatedPages.push(currentPageFields as Template['schemas'][number]);
|
||||||
currentPageFields = [];
|
currentPageFields = [];
|
||||||
currentY = resolvedOptions.pageStartY;
|
currentY = resolvedOptions.pageStartY;
|
||||||
@@ -134,17 +176,24 @@ export function buildPdfTopicTemplate(
|
|||||||
|
|
||||||
for (const [topicIndex, topic] of sortedTopics.entries()) {
|
for (const [topicIndex, topic] of sortedTopics.entries()) {
|
||||||
const topicRows = formatTopicItems({ items: topic.items ?? [] });
|
const topicRows = formatTopicItems({ items: topic.items ?? [] });
|
||||||
const topicKey = `topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
|
const topicKey = `topic_${resolvedOptions.basePageIndex}_${topicIndex}`;
|
||||||
const itemKey = `item_topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
|
const itemKey = `item_topic_${resolvedOptions.basePageIndex}_${topicIndex}`;
|
||||||
const labelHeight =
|
const labelHeight =
|
||||||
typeof topicTemplate.height === 'number' ? topicTemplate.height : resolvedOptions.rowHeight;
|
typeof topicTemplate.height === 'number'
|
||||||
|
? topicTemplate.height
|
||||||
|
: resolvedOptions.rowHeight;
|
||||||
const tableHeight = Math.max(
|
const tableHeight = Math.max(
|
||||||
typeof topicDataTemplate.height === 'number' ? topicDataTemplate.height : resolvedOptions.rowHeight,
|
typeof topicDataTemplate.height === 'number'
|
||||||
topicRows.length * resolvedOptions.rowHeight
|
? topicDataTemplate.height
|
||||||
|
: resolvedOptions.rowHeight,
|
||||||
|
topicRows.length * resolvedOptions.rowHeight,
|
||||||
);
|
);
|
||||||
const blockHeight = topicToDataOffset + tableHeight;
|
const blockHeight = topicToDataOffset + tableHeight;
|
||||||
|
|
||||||
if (currentY + blockHeight > resolvedOptions.pageBottomY && currentPageFields.length > 0) {
|
if (
|
||||||
|
currentY + blockHeight > resolvedOptions.pageBottomY &&
|
||||||
|
currentPageFields.length > 0
|
||||||
|
) {
|
||||||
pushCurrentPage();
|
pushCurrentPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,10 +209,10 @@ export function buildPdfTopicTemplate(
|
|||||||
topicDataField.height = tableHeight;
|
topicDataField.height = tableHeight;
|
||||||
setFieldY(topicDataField, currentY + topicToDataOffset);
|
setFieldY(topicDataField, currentY + topicToDataOffset);
|
||||||
|
|
||||||
topicInputs[topicKey] = [[topic.topicType?.trim() || '-']];
|
|
||||||
topicInputs[itemKey] = topicRows;
|
|
||||||
currentPageFields.push(topicField, topicDataField);
|
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) {
|
if (keepTogetherFields.length > 0) {
|
||||||
@@ -189,16 +238,51 @@ export function buildPdfTopicTemplate(
|
|||||||
generatedPages.push(currentPageFields as Template['schemas'][number]);
|
generatedPages.push(currentPageFields as Template['schemas'][number]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages:
|
||||||
|
generatedPages.length > 0
|
||||||
|
? generatedPages
|
||||||
|
: [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 = [
|
nextTemplate.schemas = [
|
||||||
...pages.slice(0, resolvedOptions.targetPageIndex),
|
...pages.slice(0, resolvedOptions.basePageIndex),
|
||||||
...(generatedPages.length > 0
|
...built.pages,
|
||||||
? generatedPages
|
...pages.slice(resolvedOptions.basePageIndex + 1),
|
||||||
: [pageWithoutDynamicFields as Template['schemas'][number]]),
|
|
||||||
...pages.slice(resolvedOptions.targetPageIndex + 1)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template: nextTemplate,
|
template: nextTemplate,
|
||||||
topicInputs
|
topicInputs: built.topicInputs,
|
||||||
|
issues: built.issues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type PdfTopic = {
|
|||||||
|
|
||||||
export type PdfTopicEngineOptions = {
|
export type PdfTopicEngineOptions = {
|
||||||
targetPageIndex?: number;
|
targetPageIndex?: number;
|
||||||
|
basePageIndex?: number;
|
||||||
pageStartY?: number;
|
pageStartY?: number;
|
||||||
pageBottomY?: number;
|
pageBottomY?: number;
|
||||||
topicSpacing?: number;
|
topicSpacing?: number;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 ?? [])],
|
||||||
|
};
|
||||||
|
}
|
||||||
134
src/features/crm/quotations/document/server/runtime-contracts.ts
Normal file
134
src/features/crm/quotations/document/server/runtime-contracts.ts
Normal 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',
|
||||||
|
]);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -35,10 +35,9 @@ import {
|
|||||||
formatPdfDate,
|
formatPdfDate,
|
||||||
formatTopicItems,
|
formatTopicItems,
|
||||||
} from "./pdfme-transforms";
|
} from "./pdfme-transforms";
|
||||||
import { buildPdfTopicTemplate } from "./pdf-topic-engine";
|
import { buildQuotationPdfRuntime } from "./quotation-pdf-runtime";
|
||||||
import { resolveQuotationSignatures } from "./signature-resolver";
|
import { resolveQuotationSignatures } from "./signature-resolver";
|
||||||
import { resolveQuotationTopicTypeMapping } from "./topic-mapping";
|
import { resolveQuotationTopicTypeMapping } from "./topic-mapping";
|
||||||
import type { Template } from "@pdfme/common";
|
|
||||||
|
|
||||||
const DOCUMENT_OPTION_CATEGORIES = {
|
const DOCUMENT_OPTION_CATEGORIES = {
|
||||||
branch: "crm_branch",
|
branch: "crm_branch",
|
||||||
@@ -260,6 +259,17 @@ function normalizePdfmeTemplateInput(
|
|||||||
return normalized;
|
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(
|
export async function buildQuotationDocumentData(
|
||||||
quotationId: string,
|
quotationId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
@@ -690,16 +700,19 @@ export async function getQuotationDocumentPreviewData(
|
|||||||
mappings,
|
mappings,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const { template: renderTemplate, topicInputs } = buildPdfTopicTemplate(
|
const runtime = await buildQuotationPdfRuntime({
|
||||||
template.version.schemaJson as Template,
|
documentData,
|
||||||
documentData.topics.all,
|
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 {
|
return {
|
||||||
documentData,
|
documentData,
|
||||||
@@ -707,11 +720,11 @@ export async function getQuotationDocumentPreviewData(
|
|||||||
...template,
|
...template,
|
||||||
version: {
|
version: {
|
||||||
...template.version,
|
...template.version,
|
||||||
schemaJson: renderTemplate,
|
schemaJson: runtime.assembled.template,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mappings,
|
mappings,
|
||||||
templateInput: mergedTemplateInput,
|
templateInput: runtime.assembled.templateInput,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user