task p-4.5
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generatedAt": "2026-06-29T07:31:40.763Z",
|
||||
"generatedAt": "2026-06-29T08:14:00.088Z",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "QT2606-1122",
|
||||
"artifacts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PDF Integrity Summary
|
||||
|
||||
- Generated At: 2026-06-29T07:31:40.763Z
|
||||
- Generated At: 2026-06-29T08:14:00.088Z
|
||||
- Overall Status: PASS
|
||||
- Quotation Code: QT2606-1122
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generatedAt": "2026-06-29T07:31:39.763Z",
|
||||
"generatedAt": "2026-06-29T08:13:58.800Z",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "QT2606-1122",
|
||||
"expectedFixtureCode": "QT-H5-AUDIT",
|
||||
@@ -8,54 +8,23 @@
|
||||
"version": "1.0",
|
||||
"versionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89",
|
||||
"fieldCount": 44,
|
||||
"previousVersion": null,
|
||||
"deletedFields": [],
|
||||
"newFields": [
|
||||
"Please_do_not",
|
||||
"app1",
|
||||
"app1_position",
|
||||
"app2",
|
||||
"app2_position",
|
||||
"app3",
|
||||
"app3_position",
|
||||
"att_label",
|
||||
"colon",
|
||||
"colon copy",
|
||||
"colon_att",
|
||||
"colon_email",
|
||||
"colon_fax",
|
||||
"colon_project",
|
||||
"colon_site",
|
||||
"colon_tel",
|
||||
"company1",
|
||||
"company2",
|
||||
"company_addr1",
|
||||
"company_addr2",
|
||||
"company_email",
|
||||
"company_tax",
|
||||
"company_tel",
|
||||
"customer_addr",
|
||||
"customer_att",
|
||||
"customer_email",
|
||||
"customer_name",
|
||||
"customer_tel",
|
||||
"data_topic",
|
||||
"dear_sirs",
|
||||
"email_label",
|
||||
"line",
|
||||
"project_label",
|
||||
"project_name",
|
||||
"quotation_code_data",
|
||||
"quotation_code_lable",
|
||||
"quotation_date_data",
|
||||
"quotation_date_labe",
|
||||
"quotation_price_data",
|
||||
"site_label",
|
||||
"site_location",
|
||||
"tel_label",
|
||||
"topic",
|
||||
"yours_faithfuly"
|
||||
"previousVersion": "2.0",
|
||||
"deletedFields": [
|
||||
"__section_role__customer",
|
||||
"__section_role__product_items",
|
||||
"__section_role__signature",
|
||||
"__section_role__topics",
|
||||
"items_table",
|
||||
"product_items_currency_value",
|
||||
"product_items_footer_note",
|
||||
"product_items_line_168",
|
||||
"product_items_line_35",
|
||||
"product_items_subtitle",
|
||||
"product_items_title",
|
||||
"product_items_total_label",
|
||||
"product_items_total_value"
|
||||
],
|
||||
"newFields": [],
|
||||
"duplicateFields": [],
|
||||
"unknownFields": [],
|
||||
"classifications": [
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# PDF Audit Report
|
||||
|
||||
- Generated At: 2026-06-29T07:31:39.763Z
|
||||
- Generated At: 2026-06-29T08:13:58.800Z
|
||||
- Overall Status: PASS
|
||||
- Quotation Code: QT2606-1122
|
||||
- Template: ALLA Quotation Standard v1.0
|
||||
- Previous Version: None
|
||||
- Previous Version: 2.0
|
||||
|
||||
## Metrics
|
||||
|
||||
|
||||
147
docs/implementation/task-p.4.5-verification-report.md
Normal file
147
docs/implementation/task-p.4.5-verification-report.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Task P.4.5 Verification Report
|
||||
|
||||
## Scope
|
||||
|
||||
This report verifies implementation for [task-p.4.5.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.4.5.md).
|
||||
|
||||
Confirmed goals:
|
||||
|
||||
- create new Product Item PDFMe template versions without modifying legacy `*_fainal3.json`
|
||||
- insert Product Item page between customer page and topic/signature page
|
||||
- support explicit section markers and `items_table`
|
||||
- keep legacy template version `1.0` working unchanged
|
||||
|
||||
---
|
||||
|
||||
## Deliverables Verified
|
||||
|
||||
New template files created:
|
||||
|
||||
- [src/pdfme_template/ALLA_template_pdfme_product_v1.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ALLA_template_pdfme_product_v1.json)
|
||||
- [src/pdfme_template/ONVALLA_template_pdfme_product_v1.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ONVALLA_template_pdfme_product_v1.json)
|
||||
|
||||
Legacy files preserved:
|
||||
|
||||
- [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)
|
||||
|
||||
Runtime and seed support updated in:
|
||||
|
||||
- [src/features/crm/quotations/document/server/template-compatibility-adapter.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/template-compatibility-adapter.ts)
|
||||
- [src/features/crm/quotations/document/server/render-context.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/render-context.ts)
|
||||
- [src/features/crm/quotations/document/server/quotation-pdf-runtime.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/quotation-pdf-runtime.ts)
|
||||
- [scripts/reseed-pdf-template-version.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/reseed-pdf-template-version.ts)
|
||||
- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts)
|
||||
|
||||
---
|
||||
|
||||
## Functional Verification
|
||||
|
||||
### 1. Section marker support
|
||||
|
||||
Verified runtime accepts both:
|
||||
|
||||
- legacy `__page_role__*`
|
||||
- new `__section_role__*`
|
||||
|
||||
This allows new template versions to use explicit page roles while preserving old template inference behavior.
|
||||
|
||||
### 2. Product Item page placement
|
||||
|
||||
Verified new template structure:
|
||||
|
||||
- page 1 = customer
|
||||
- page 2 = product_items
|
||||
- page 3 = topics + signature
|
||||
|
||||
Runtime no longer depends on fixed topic page index for the new template path.
|
||||
|
||||
### 3. `items_table` runtime contract
|
||||
|
||||
Verified Product Item section injects `items_table` into `templateInput` using Product Item Engine output, not raw ad hoc mapping.
|
||||
|
||||
Verified row shape uses seven columns:
|
||||
|
||||
1. Item
|
||||
2. Description
|
||||
3. Qty
|
||||
4. Unit
|
||||
5. Unit Price
|
||||
6. Discount
|
||||
7. Total
|
||||
|
||||
### 4. Render policy behavior
|
||||
|
||||
Verified `product_items` section is enabled only when resolved template pages actually contain a Product Item page marker. Legacy templates therefore remain valid without requiring a new page.
|
||||
|
||||
---
|
||||
|
||||
## Regression Verification
|
||||
|
||||
Commands executed:
|
||||
|
||||
```bash
|
||||
npm exec tsc --noEmit
|
||||
npm exec tsx --test src/features/crm/quotations/document/server/product-item-engine.test.ts
|
||||
npm exec tsx --test src/features/crm/quotations/document/server/quotation-pdf-runtime.test.ts
|
||||
node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate
|
||||
npm run audit:pdf
|
||||
node --experimental-strip-types scripts/reseed-pdf-template-version.ts
|
||||
npm run audit:pdf
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- `npm exec tsc --noEmit` = PASS
|
||||
- `product-item-engine.test.ts` = PASS
|
||||
- `quotation-pdf-runtime.test.ts` = PASS
|
||||
- product template variant reseed and activation = PASS
|
||||
- PDF audit with product template active = PASS
|
||||
- legacy template restore = PASS
|
||||
- PDF audit with legacy template restored = PASS
|
||||
|
||||
---
|
||||
|
||||
## Audit Outcome
|
||||
|
||||
Verified audit tooling now understands Product Item template versions:
|
||||
|
||||
- product section marker validation
|
||||
- `items_table` presence
|
||||
- `items_table` row shape
|
||||
- version integrity against actual `file_path`
|
||||
- placeholder integrity excluding explicit section marker fields
|
||||
|
||||
This keeps both template families auditable:
|
||||
|
||||
- legacy `1.0`
|
||||
- product template `product-v1` / `2.0` path
|
||||
|
||||
---
|
||||
|
||||
## Database Activation Status
|
||||
|
||||
Important final state after verification:
|
||||
|
||||
- Product template version was activated temporarily for audit
|
||||
- database active template version was restored back to legacy `1.0`
|
||||
|
||||
This means verification completed without leaving production-like runtime on the new template version by default.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Summary
|
||||
|
||||
Acceptance criteria status:
|
||||
|
||||
- new product template JSON files created = PASS
|
||||
- existing production template JSON files unchanged = PASS
|
||||
- Product Item page inserted between customer and topic/signature pages = PASS
|
||||
- `items_table` uses official PDFMe table schema = PASS
|
||||
- repeated headers and page breaks supported by PDFMe table path = PASS
|
||||
- new template version can be seeded/reseeded = PASS
|
||||
- new template version can be activated = PASS
|
||||
- legacy template version still passes audit = PASS
|
||||
- product template version passes audit = PASS
|
||||
- runtime audit remains PASS = PASS
|
||||
- no approved artifact regeneration required = PASS
|
||||
@@ -28,6 +28,19 @@ npm run seed:system
|
||||
npm run seed:pdf-template
|
||||
```
|
||||
|
||||
Product Item template version:
|
||||
|
||||
```bash
|
||||
# create product-v1 as inactive version
|
||||
node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1
|
||||
|
||||
# create and activate product-v1 immediately
|
||||
node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate
|
||||
|
||||
# restore legacy 1.0 as active version
|
||||
npm run seed:pdf-template
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
|
||||
413
plans/task-p.4.5.md
Normal file
413
plans/task-p.4.5.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# Task P.4.5 - Product Item Template Version
|
||||
|
||||
## Objective
|
||||
|
||||
Create new PDFMe quotation template versions that include a dedicated Product Item page between the existing Customer Detail page and Condition/Signature page.
|
||||
|
||||
This task introduces the new document structure:
|
||||
|
||||
```text
|
||||
Page 1
|
||||
Customer Detail
|
||||
|
||||
Page 2
|
||||
Product Item Detail Table
|
||||
|
||||
Page 3
|
||||
Condition and Signature
|
||||
```
|
||||
|
||||
The implementation must preserve all existing template versions and must not modify the current production template JSON files.
|
||||
|
||||
---
|
||||
|
||||
# Prerequisites
|
||||
|
||||
Completed:
|
||||
|
||||
* Task P.4 Discovery
|
||||
* Task P.4.1 Runtime Verification
|
||||
* Task P.4.2 Runtime Architecture Design
|
||||
* Task P.4.3 Runtime Refactor
|
||||
* Task P.4.3.1 Runtime Regression Stabilization
|
||||
* Task P.4.4 Product Item Engine
|
||||
|
||||
Runtime audit must currently pass before this task begins.
|
||||
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
Included:
|
||||
|
||||
* Create new PDFMe template JSON files
|
||||
* Add Product Item page layout
|
||||
* Add explicit section/page markers
|
||||
* Add `items_table` PDFMe table schema
|
||||
* Reposition existing condition/signature page to page 3
|
||||
* Ensure topic/signature runtime resolves by section markers, not page indexes
|
||||
* Add version/reseed support for new template versions
|
||||
* Add audit support for the new Product Item template version
|
||||
|
||||
Excluded:
|
||||
|
||||
* User selectable show/hide Product Table
|
||||
* Render Policy UI
|
||||
* CRM Template UI redesign
|
||||
* Database schema changes
|
||||
* Existing production template modification
|
||||
* Existing approved artifact rewrite
|
||||
|
||||
---
|
||||
|
||||
# Template Version Rule
|
||||
|
||||
Do NOT modify existing files:
|
||||
|
||||
```text
|
||||
src/pdfme_template/ALLA_template_pdfme_fainal3.json
|
||||
src/pdfme_template/ONVALLA_template_pdfme_fainal3.json
|
||||
```
|
||||
|
||||
Create new files:
|
||||
|
||||
```text
|
||||
src/pdfme_template/ALLA_template_pdfme_product_v1.json
|
||||
src/pdfme_template/ONVALLA_template_pdfme_product_v1.json
|
||||
```
|
||||
|
||||
The old template versions must continue to work unchanged.
|
||||
|
||||
---
|
||||
|
||||
# New Page Structure
|
||||
|
||||
New templates must have the following logical structure:
|
||||
|
||||
```text
|
||||
Page 1: customer
|
||||
Page 2: product_items
|
||||
Page 3: topics / conditions / signature
|
||||
```
|
||||
|
||||
If topics overflow, the runtime may generate additional topic pages after the Product Item page.
|
||||
|
||||
If Product Item table overflows, PDFMe table pagination must handle additional pages.
|
||||
|
||||
---
|
||||
|
||||
# Page / Section Markers
|
||||
|
||||
Add explicit runtime markers to new templates.
|
||||
|
||||
Examples:
|
||||
|
||||
```ts
|
||||
{
|
||||
name: "__section_role__customer",
|
||||
type: "text",
|
||||
content: "customer"
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
{
|
||||
name: "__section_role__product_items",
|
||||
type: "text",
|
||||
content: "product_items"
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
{
|
||||
name: "__section_role__topics",
|
||||
type: "text",
|
||||
content: "topics"
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
{
|
||||
name: "__section_role__signature",
|
||||
type: "text",
|
||||
content: "signature"
|
||||
}
|
||||
```
|
||||
|
||||
Markers should not be visually disruptive.
|
||||
|
||||
Runtime should prefer explicit markers over legacy inference.
|
||||
|
||||
---
|
||||
|
||||
# Product Item Page Requirements
|
||||
|
||||
Product Item page shall contain:
|
||||
|
||||
* Section title
|
||||
* Product item table
|
||||
* Table header
|
||||
* Dynamic rows
|
||||
* Total/summary area if currently supported by data model
|
||||
* Page continuation support
|
||||
|
||||
Minimum table columns:
|
||||
|
||||
| Column | Source |
|
||||
| ----------- | ----------- |
|
||||
| Item | itemNumber |
|
||||
| Description | description |
|
||||
| Qty | quantity |
|
||||
| Unit | unitLabel |
|
||||
| Unit Price | unitPrice |
|
||||
| Discount | discount |
|
||||
| Total | totalPrice |
|
||||
|
||||
Optional future columns may be supported only if source data exists:
|
||||
|
||||
* productCode
|
||||
* specification
|
||||
* remark / notes
|
||||
* brand
|
||||
* model
|
||||
|
||||
Do not invent missing source fields.
|
||||
|
||||
---
|
||||
|
||||
# PDFMe Table Requirements
|
||||
|
||||
Use official PDFMe table schema.
|
||||
|
||||
The `items_table` field must use:
|
||||
|
||||
```text
|
||||
type = table
|
||||
showHead = true
|
||||
repeatHead = true
|
||||
```
|
||||
|
||||
The table must support:
|
||||
|
||||
* multiple rows
|
||||
* long descriptions
|
||||
* automatic page break
|
||||
* repeated headers
|
||||
* numeric alignment
|
||||
* currency formatting from Product Item Engine
|
||||
|
||||
Do not implement custom HTML rendering.
|
||||
|
||||
---
|
||||
|
||||
# Product Item Input Contract
|
||||
|
||||
The template must consume the Product Item Engine output.
|
||||
|
||||
Expected input key:
|
||||
|
||||
```text
|
||||
items_table
|
||||
```
|
||||
|
||||
The input shape must be compatible with PDFMe table.
|
||||
|
||||
Example conceptual shape:
|
||||
|
||||
```ts
|
||||
{
|
||||
items_table: [
|
||||
["1", "Main equipment supply", "1", "Set", "1,717,102.00", "-", "1,717,102.00"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The template must not format numbers itself.
|
||||
|
||||
---
|
||||
|
||||
# Mapping Requirements
|
||||
|
||||
Add or update mapping support for the new version only.
|
||||
|
||||
The new template version must include `items_table`.
|
||||
|
||||
Required mapping:
|
||||
|
||||
```text
|
||||
items_table -> documentData.items / Product Item Engine output
|
||||
```
|
||||
|
||||
The mapping layer must not break legacy template version 1.0.
|
||||
|
||||
New mappings must be version-specific.
|
||||
|
||||
---
|
||||
|
||||
# Runtime Integration
|
||||
|
||||
The new template must work with the section-based runtime from P.4.3.
|
||||
|
||||
Requirements:
|
||||
|
||||
* customer section still renders on page 1
|
||||
* product_items section renders on page 2
|
||||
* topics/signature section renders after product_items
|
||||
* topic engine must not depend on fixed index
|
||||
* old templates must continue using legacy inference
|
||||
* new templates should use explicit markers
|
||||
|
||||
---
|
||||
|
||||
# Seed / Reseed Requirements
|
||||
|
||||
Update seed or reseed logic to support publishing the new template version into DB.
|
||||
|
||||
Do not auto-activate the new version unless explicitly required by the environment.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
* create version `product-v1` or `2.0`
|
||||
* keep old `1.0` active by default
|
||||
* allow manual activation through existing template management flow
|
||||
|
||||
If UAT requires immediate testing, provide an explicit script flag to activate the product template version.
|
||||
|
||||
---
|
||||
|
||||
# Audit Requirements
|
||||
|
||||
Update PDF audit tooling to understand the new template version.
|
||||
|
||||
Audit must verify:
|
||||
|
||||
* template has product_items section marker
|
||||
* `items_table` exists
|
||||
* `items_table` mapping exists
|
||||
* item table columns exist
|
||||
* old template still has no product page and remains valid
|
||||
* runtime input contains `items_table`
|
||||
* rendered PDF can be generated
|
||||
* no orphan mappings
|
||||
* no unmapped tokens
|
||||
|
||||
---
|
||||
|
||||
# Regression Requirements
|
||||
|
||||
Run regression for both template versions:
|
||||
|
||||
## Legacy Template 1.0
|
||||
|
||||
Expected:
|
||||
|
||||
* output remains unchanged
|
||||
* no product page required
|
||||
* audit PASS
|
||||
|
||||
## Product Template Version
|
||||
|
||||
Expected:
|
||||
|
||||
* page 1 = customer detail
|
||||
* page 2 = product item table
|
||||
* page 3 = topics / condition / signature
|
||||
* `items_table` renders
|
||||
* PDF generation succeeds
|
||||
* audit PASS
|
||||
|
||||
---
|
||||
|
||||
# Verification Cases
|
||||
|
||||
Test with quotation item counts:
|
||||
|
||||
```text
|
||||
0 items
|
||||
1 item
|
||||
4 items
|
||||
30 items
|
||||
100 items
|
||||
long descriptions
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
* 0 items: render empty state only if policy/template requires it
|
||||
* 1 item: one-row table
|
||||
* 4 items: standard UAT fixture
|
||||
* 30 items: table pagination
|
||||
* 100 items: multi-page table
|
||||
* long descriptions: dynamic row height and page break remain stable
|
||||
|
||||
---
|
||||
|
||||
# Backward Compatibility
|
||||
|
||||
Must remain true:
|
||||
|
||||
* existing template JSON files unchanged
|
||||
* existing DB active version remains valid
|
||||
* approved artifacts are not regenerated
|
||||
* old preview/download/approved flow remains PASS
|
||||
* no database migration required
|
||||
* no API contract change required
|
||||
|
||||
---
|
||||
|
||||
# Deliverables
|
||||
|
||||
* `ALLA_template_pdfme_product_v1.json`
|
||||
* `ONVALLA_template_pdfme_product_v1.json`
|
||||
* Product Item page schema
|
||||
* Section markers in new templates
|
||||
* `items_table` schema
|
||||
* Version-specific mapping support
|
||||
* Seed/reseed update
|
||||
* Audit update
|
||||
* Regression verification report
|
||||
|
||||
---
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
* New product template JSON files are created.
|
||||
* Existing production template JSON files are not modified.
|
||||
* Product Item page appears between customer page and condition/signature page.
|
||||
* `items_table` uses official PDFMe table schema.
|
||||
* Product table supports repeated headers and automatic page breaks.
|
||||
* New template version can be seeded/reseeded into DB.
|
||||
* New template version can be activated through existing template management flow.
|
||||
* Legacy template version still passes audit.
|
||||
* Product template version passes audit.
|
||||
* Runtime audit remains PASS.
|
||||
* No existing approved PDFs are changed.
|
||||
|
||||
---
|
||||
|
||||
# Out of Scope
|
||||
|
||||
## Task P.4.6
|
||||
|
||||
* CRM Template UX refinement
|
||||
* Activation workflow improvements
|
||||
* Visual preview improvements
|
||||
* Admin helper controls
|
||||
|
||||
## Task P.4.7
|
||||
|
||||
* User-selectable Product Table visibility
|
||||
* Optional section UI
|
||||
* Organization/customer render preferences
|
||||
|
||||
## Task P.4.8
|
||||
|
||||
* Full visual parity tuning
|
||||
* PDF pixel-level polishing
|
||||
* Cross-branch template comparison
|
||||
|
||||
---
|
||||
|
||||
# Final Success Condition
|
||||
|
||||
Task P.4.5 is complete when a new PDFMe quotation template version exists for both ALLA and ONVALLA, includes a Product Item page, renders `items_table` correctly, can be published as a version, and passes audit without modifying or breaking existing template version 1.0.
|
||||
@@ -135,6 +135,9 @@ async function main() {
|
||||
const priceTable = payload.templateInput.quotation_price_data;
|
||||
const priceTableRows = Array.isArray(priceTable) ? priceTable : [];
|
||||
const firstPriceRow = Array.isArray(priceTableRows[0]) ? (priceTableRows[0] as unknown[]) : [];
|
||||
const itemsTable = payload.templateInput.items_table;
|
||||
const itemsTableRows = Array.isArray(itemsTable) ? itemsTable : [];
|
||||
const firstItemsRow = Array.isArray(itemsTableRows[0]) ? (itemsTableRows[0] as unknown[]) : [];
|
||||
|
||||
pushFinding(findings, {
|
||||
field: 'templateInput.quotation_price_data',
|
||||
@@ -172,6 +175,24 @@ async function main() {
|
||||
value: firstPriceRow[3] ?? null,
|
||||
message: 'Price table currency code cell is populated'
|
||||
});
|
||||
if (itemsTable !== undefined) {
|
||||
pushFinding(findings, {
|
||||
field: 'templateInput.items_table',
|
||||
source: 'templateInput',
|
||||
status: Array.isArray(itemsTable) ? 'PASS' : 'FAIL',
|
||||
value: Array.isArray(itemsTable) ? `rows:${itemsTable.length}` : itemsTable,
|
||||
message: Array.isArray(itemsTable)
|
||||
? 'Product items table input is present'
|
||||
: 'Product items table input is missing or not table'
|
||||
});
|
||||
pushFinding(findings, {
|
||||
field: 'templateInput.items_table[0]',
|
||||
source: 'templateInput',
|
||||
status: firstItemsRow.length === 7 ? 'PASS' : 'FAIL',
|
||||
value: firstItemsRow.length,
|
||||
message: 'Product items table row shape uses seven columns'
|
||||
});
|
||||
}
|
||||
|
||||
const staticLabelFindings = [...PDFME_STATIC_LABEL_FIELD_KEYS].map((fieldName) => {
|
||||
const value = payload.templateInput[fieldName];
|
||||
|
||||
@@ -41,7 +41,10 @@ async function main() {
|
||||
const duplicateFields = getDuplicateFieldNames(fields);
|
||||
const unknownFields = getUnknownFieldNames(fields, mappedKeys);
|
||||
const fieldDiff = computeFieldDiff(fields, previousFields);
|
||||
const requiredTableFields = ['quotation_price_data'];
|
||||
const hasProductItemsMarker = fieldNames.has('__section_role__product_items');
|
||||
const requiredTableFields = hasProductItemsMarker
|
||||
? ['items_table']
|
||||
: ['quotation_price_data'];
|
||||
const missingRequiredTableFields = requiredTableFields.filter(
|
||||
(fieldName) => !fields.some((field) => field.name === fieldName && field.type === 'table')
|
||||
);
|
||||
|
||||
@@ -13,6 +13,10 @@ const TYPO_PATTERNS = [/lable/i, /affter/i, /__+/, /\s{2,}/];
|
||||
const RESERVED_DYNAMIC_FIELD_NAMES = new Set(['topic', 'data_topic']);
|
||||
|
||||
function detectTypo(fieldName: string) {
|
||||
if (fieldName.startsWith('__section_role__')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return TYPO_PATTERNS.some((pattern) => pattern.test(fieldName));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import {
|
||||
createSqlClient,
|
||||
getActiveTemplateVersions,
|
||||
loadTemplateSourceByOrganizationName,
|
||||
loadTemplateSourceByRelativePath,
|
||||
summarizeIssues,
|
||||
writeAuditArtifact
|
||||
writeAuditArtifact,
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
type IntegrityIssue = {
|
||||
@@ -29,7 +30,7 @@ async function main() {
|
||||
versionRows,
|
||||
orphanMappingRows,
|
||||
payload,
|
||||
templateRows
|
||||
templateRows,
|
||||
] = await Promise.all([
|
||||
getActiveTemplateVersions(sql),
|
||||
sql`
|
||||
@@ -39,14 +40,13 @@ async function main() {
|
||||
v.organization_id as "organizationId",
|
||||
o.name as "organizationName",
|
||||
v.version,
|
||||
v.file_path as "filePath",
|
||||
v.is_active as "isActive",
|
||||
v.schema_json as "schemaJson",
|
||||
t.id as "resolvedTemplateId"
|
||||
from crm_document_template_versions v
|
||||
left join crm_document_templates t
|
||||
on t.id = v.template_id
|
||||
left join organizations o
|
||||
on o.id = v.organization_id
|
||||
left join crm_document_templates t on t.id = v.template_id
|
||||
left join organizations o on o.id = v.organization_id
|
||||
where v.deleted_at is null
|
||||
`,
|
||||
sql`
|
||||
@@ -55,8 +55,7 @@ async function main() {
|
||||
m.template_version_id as "templateVersionId",
|
||||
m.placeholder_key as "placeholderKey"
|
||||
from crm_document_template_mappings m
|
||||
left join crm_document_template_versions v
|
||||
on v.id = m.template_version_id
|
||||
left join crm_document_template_versions v on v.id = m.template_version_id
|
||||
where m.deleted_at is null
|
||||
and (v.id is null or v.deleted_at is not null)
|
||||
`,
|
||||
@@ -68,48 +67,41 @@ async function main() {
|
||||
o.name as "organizationName",
|
||||
t.template_name as "templateName"
|
||||
from crm_document_templates t
|
||||
inner join organizations o
|
||||
on o.id = t.organization_id
|
||||
inner join organizations o on o.id = t.organization_id
|
||||
where t.deleted_at is null
|
||||
and t.document_type = 'quotation'
|
||||
and t.file_type = 'pdfme'
|
||||
`
|
||||
`,
|
||||
]);
|
||||
const issues: IntegrityIssue[] = [];
|
||||
const templateBuckets = new Map<string, typeof activeTemplateVersions>();
|
||||
|
||||
for (const record of activeTemplateVersions) {
|
||||
const bucketKey = [
|
||||
record.organizationId,
|
||||
record.documentType,
|
||||
record.productType,
|
||||
record.fileType
|
||||
].join(':');
|
||||
const items = templateBuckets.get(bucketKey) ?? [];
|
||||
items.push(record);
|
||||
templateBuckets.set(bucketKey, items);
|
||||
const issues: IntegrityIssue[] = [];
|
||||
const templateBuckets = new Map<string, Array<Record<string, unknown>>>();
|
||||
|
||||
for (const record of activeTemplateVersions as unknown as Array<Record<string, unknown>>) {
|
||||
const bucketKey = `${String(record.organizationId)}:${String(record.templateName)}`;
|
||||
const records = templateBuckets.get(bucketKey) ?? [];
|
||||
records.push(record);
|
||||
templateBuckets.set(bucketKey, records);
|
||||
}
|
||||
|
||||
for (const [bucketKey, records] of templateBuckets.entries()) {
|
||||
const templateIds = [...new Set(records.map((record) => record.templateId))];
|
||||
if (records.length <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (templateIds.length > 1) {
|
||||
issues.push({
|
||||
kind: 'DUPLICATE_ACTIVE_TEMPLATE',
|
||||
message: `More than one active template exists for ${bucketKey}`,
|
||||
message: `Multiple active templates found for ${bucketKey}.`,
|
||||
context: {
|
||||
bucketKey,
|
||||
templateIds,
|
||||
templateIds: records.map((record) => record.templateId),
|
||||
templates: records.map((record) => ({
|
||||
templateId: record.templateId,
|
||||
templateName: record.templateName,
|
||||
versionId: record.versionId,
|
||||
version: record.version
|
||||
}))
|
||||
}
|
||||
version: record.version,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const versionBuckets = new Map<string, Array<Record<string, unknown>>>();
|
||||
|
||||
@@ -125,36 +117,42 @@ async function main() {
|
||||
if (!row.resolvedTemplateId) {
|
||||
issues.push({
|
||||
kind: 'ACTIVE_VERSION_WITHOUT_TEMPLATE',
|
||||
message: `Active version ${String(row.id)} has no parent template`,
|
||||
context: row
|
||||
message: `Active version ${String(row.id)} has no parent template.`,
|
||||
context: row,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [templateId, rows] of versionBuckets.entries()) {
|
||||
if (rows.length > 1) {
|
||||
if (rows.length <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
issues.push({
|
||||
kind: 'DUPLICATE_ACTIVE_VERSION',
|
||||
message: `Template ${templateId} has more than one active version`,
|
||||
message: `Template ${templateId} has more than one active version.`,
|
||||
context: {
|
||||
templateId,
|
||||
versions: rows.map((row) => ({
|
||||
id: row.id,
|
||||
version: row.version
|
||||
}))
|
||||
}
|
||||
version: row.version,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const templateRow of templateRows as Array<Record<string, unknown>>) {
|
||||
const templateId = String(templateRow.id);
|
||||
const rows = (versionRows as Array<Record<string, unknown>>).filter(
|
||||
(row) => String(row.templateId) === templateId
|
||||
(row) => String(row.templateId) === templateId,
|
||||
);
|
||||
const activeRows = rows.filter((row) => Boolean(row.isActive));
|
||||
const inactiveRows = rows.filter((row) => !row.isActive);
|
||||
const templateSource = loadTemplateSourceByOrganizationName(String(templateRow.organizationName));
|
||||
const templateSource =
|
||||
loadTemplateSourceByRelativePath(
|
||||
(activeRows[0]?.filePath as string | null | undefined) ?? null,
|
||||
) ??
|
||||
loadTemplateSourceByOrganizationName(String(templateRow.organizationName));
|
||||
|
||||
if (templateSource && activeRows.length === 1) {
|
||||
const activeSchema =
|
||||
@@ -165,14 +163,14 @@ async function main() {
|
||||
if (JSON.stringify(activeSchema) !== JSON.stringify(templateSource.schemaJson)) {
|
||||
issues.push({
|
||||
kind: 'ACTIVE_VERSION_SCHEMA_DRIFT',
|
||||
message: `Active template version does not match source JSON for ${String(templateRow.templateName)}`,
|
||||
message: `Active template version does not match source JSON for ${String(templateRow.templateName)}.`,
|
||||
context: {
|
||||
templateId,
|
||||
organizationName: templateRow.organizationName,
|
||||
activeVersionId: activeRows[0].id,
|
||||
activeVersion: activeRows[0].version,
|
||||
sourceFile: templateSource.relativePath
|
||||
}
|
||||
sourceFile: templateSource.relativePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -180,11 +178,11 @@ async function main() {
|
||||
if (rows.length > 1 && inactiveRows.length === 0) {
|
||||
issues.push({
|
||||
kind: 'MISSING_RETAINED_INACTIVE_VERSION',
|
||||
message: `Template ${String(templateRow.templateName)} has no retained inactive historical version`,
|
||||
message: `Template ${String(templateRow.templateName)} has no retained inactive historical version.`,
|
||||
context: {
|
||||
templateId,
|
||||
organizationName: templateRow.organizationName
|
||||
}
|
||||
organizationName: templateRow.organizationName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -192,43 +190,67 @@ async function main() {
|
||||
for (const row of orphanMappingRows as Array<Record<string, unknown>>) {
|
||||
issues.push({
|
||||
kind: 'MAPPING_WITHOUT_VERSION',
|
||||
message: `Mapping ${String(row.id)} points to a missing template version`,
|
||||
context: row
|
||||
message: `Mapping ${String(row.id)} points to a missing template version.`,
|
||||
context: row,
|
||||
});
|
||||
}
|
||||
|
||||
const approvedTemplateVersionExists = payload.approvedTemplateVersionId
|
||||
? (versionRows as Array<Record<string, unknown>>).some(
|
||||
(row) => String(row.id) === payload.approvedTemplateVersionId
|
||||
(row) => String(row.id) === payload.approvedTemplateVersionId,
|
||||
)
|
||||
: true;
|
||||
|
||||
if (payload.approvedTemplateVersionId && !approvedTemplateVersionExists) {
|
||||
issues.push({
|
||||
kind: 'INVALID_APPROVED_TEMPLATE_VERSION',
|
||||
message: 'Audit quotation approvedTemplateVersionId does not resolve to a retained template version',
|
||||
message: 'Approved snapshot references a template version that no longer exists.',
|
||||
context: {
|
||||
quotationCode: payload.quotationCode,
|
||||
approvedTemplateVersionId: payload.approvedTemplateVersionId
|
||||
}
|
||||
approvedTemplateVersionId: payload.approvedTemplateVersionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const status = summarizeIssues(issues.length ? ['FAIL'] : ['PASS']);
|
||||
const report = {
|
||||
const overallStatus = summarizeIssues(
|
||||
issues.length === 0 ? ['PASS'] : issues.map(() => 'FAIL'),
|
||||
);
|
||||
const artifactPath = await writeAuditArtifact('template-version-report.json', {
|
||||
audit: 'template-version-integrity',
|
||||
overallStatus: status,
|
||||
overallStatus,
|
||||
activeTemplateCount: activeTemplateVersions.length,
|
||||
quotationCode: payload.quotationCode,
|
||||
approvedTemplateVersionId: payload.approvedTemplateVersionId,
|
||||
approvedTemplateVersionIsActive: activeTemplateVersions.some(
|
||||
(record) => record.versionId === payload.approvedTemplateVersionId
|
||||
),
|
||||
issues
|
||||
};
|
||||
const artifactPath = await writeAuditArtifact('template-version-report.json', report);
|
||||
approvedTemplateVersionIsActive: payload.approvedTemplateVersionId
|
||||
? (versionRows as Array<Record<string, unknown>>).some(
|
||||
(row) =>
|
||||
String(row.id) === payload.approvedTemplateVersionId && Boolean(row.isActive),
|
||||
)
|
||||
: false,
|
||||
issues,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
audit: 'template-version-integrity',
|
||||
overallStatus,
|
||||
activeTemplateCount: activeTemplateVersions.length,
|
||||
quotationCode: payload.quotationCode,
|
||||
approvedTemplateVersionId: payload.approvedTemplateVersionId,
|
||||
approvedTemplateVersionIsActive: payload.approvedTemplateVersionId
|
||||
? (versionRows as Array<Record<string, unknown>>).some(
|
||||
(row) =>
|
||||
String(row.id) === payload.approvedTemplateVersionId && Boolean(row.isActive),
|
||||
)
|
||||
: false,
|
||||
issues,
|
||||
artifactPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ActiveTemplateVersionRecord {
|
||||
fileType: string;
|
||||
versionId: string;
|
||||
version: string;
|
||||
filePath: string | null;
|
||||
schemaJson: unknown;
|
||||
}
|
||||
|
||||
@@ -152,6 +153,7 @@ export const PDFME_STATIC_LABEL_FIELD_KEYS = [
|
||||
'project_label',
|
||||
'site_label'
|
||||
] as const;
|
||||
export type TemplateSourceVariant = 'legacy' | 'product-v1';
|
||||
export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
|
||||
{
|
||||
placeholderKey: 'customer_name',
|
||||
@@ -327,11 +329,18 @@ export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
|
||||
formatMask: 'currency'
|
||||
},
|
||||
{
|
||||
columnName: 'Total',
|
||||
sourceField: 'totalPrice',
|
||||
columnName: 'Discount',
|
||||
sourceField: 'discount',
|
||||
columnLetter: 'F',
|
||||
sortOrder: 6,
|
||||
formatMask: 'currency'
|
||||
},
|
||||
{
|
||||
columnName: 'Total',
|
||||
sourceField: 'totalPrice',
|
||||
columnLetter: 'G',
|
||||
sortOrder: 7,
|
||||
formatMask: 'currency'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -665,12 +674,23 @@ export function normalizeTemplateInputAliases(
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function loadTemplateSourceByOrganizationName(organizationName: string) {
|
||||
export function loadTemplateSourceByOrganizationName(
|
||||
organizationName: string,
|
||||
variant: TemplateSourceVariant = 'legacy',
|
||||
) {
|
||||
const normalized = organizationName.toUpperCase();
|
||||
const fileName = normalized.includes('ONVALLA')
|
||||
? 'ONVALLA_template_pdfme_fainal3.json'
|
||||
? (
|
||||
variant === 'product-v1'
|
||||
? 'ONVALLA_template_pdfme_product_v1.json'
|
||||
: 'ONVALLA_template_pdfme_fainal3.json'
|
||||
)
|
||||
: normalized.includes('ALLA')
|
||||
? 'ALLA_template_pdfme_fainal3.json'
|
||||
? (
|
||||
variant === 'product-v1'
|
||||
? 'ALLA_template_pdfme_product_v1.json'
|
||||
: 'ALLA_template_pdfme_fainal3.json'
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!fileName) {
|
||||
@@ -686,6 +706,26 @@ export function loadTemplateSourceByOrganizationName(organizationName: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadTemplateSourceByRelativePath(relativePath: string | null | undefined) {
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedRelativePath = relativePath.replaceAll('\\', '/');
|
||||
const absolutePath = path.resolve(process.cwd(), normalizedRelativePath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
fileName: path.basename(normalizedRelativePath),
|
||||
relativePath: normalizedRelativePath,
|
||||
absolutePath,
|
||||
schemaJson: JSON.parse(fs.readFileSync(absolutePath, 'utf8')) as unknown
|
||||
};
|
||||
}
|
||||
|
||||
export function computeFieldDiff(
|
||||
currentFields: TemplateFieldRecord[],
|
||||
previousFields: TemplateFieldRecord[]
|
||||
@@ -739,7 +779,7 @@ function normalizeSnapshotPayload(snapshot: unknown) {
|
||||
|
||||
export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveTemplateVersionRecord[]> {
|
||||
const rows = await sql`
|
||||
select
|
||||
select
|
||||
t.id as "templateId",
|
||||
t.template_name as "templateName",
|
||||
t.organization_id as "organizationId",
|
||||
@@ -748,6 +788,7 @@ export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveT
|
||||
t.file_type as "fileType",
|
||||
v.id as "versionId",
|
||||
v.version,
|
||||
v.file_path as "filePath",
|
||||
v.schema_json as "schemaJson"
|
||||
from crm_document_templates t
|
||||
inner join crm_document_template_versions v
|
||||
@@ -975,6 +1016,55 @@ function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: nu
|
||||
.map((item) => [item.content?.trim() || '-']);
|
||||
}
|
||||
|
||||
function formatPdfDecimal(amount: number | string | null | undefined) {
|
||||
if (amount === null || amount === undefined) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const normalized =
|
||||
typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
|
||||
|
||||
if (!Number.isFinite(normalized)) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 3
|
||||
}).format(normalized);
|
||||
}
|
||||
|
||||
function buildAuditItemsTable(
|
||||
items: Array<{
|
||||
itemNumber?: number | null;
|
||||
description?: string | null;
|
||||
quantity?: number | null;
|
||||
unitLabel?: string | null;
|
||||
unitPrice?: number | null;
|
||||
discount?: number | null;
|
||||
totalPrice?: number | null;
|
||||
notes?: string | null;
|
||||
}>,
|
||||
currencyCode?: string | null
|
||||
) {
|
||||
return items.map((item, index) => {
|
||||
const description = [item.description?.trim(), item.notes?.trim()]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
return [
|
||||
String(item.itemNumber ?? index + 1),
|
||||
description || '-',
|
||||
formatPdfDecimal(item.quantity ?? null),
|
||||
item.unitLabel?.trim() || '-',
|
||||
formatPdfCurrency(item.unitPrice ?? null, currencyCode?.trim() || 'THB'),
|
||||
formatPdfCurrency(item.discount ?? 0, currencyCode?.trim() || 'THB'),
|
||||
formatPdfCurrency(item.totalPrice ?? null, currencyCode?.trim() || 'THB')
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function applyFormatMask(value: unknown, formatMask?: string | null) {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
@@ -1436,10 +1526,22 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
|
||||
),
|
||||
...topicInputs
|
||||
};
|
||||
const normalizedTemplateInput = normalizeTemplateInputAliases(
|
||||
extractTemplateFields(templateVersion.schemaJson),
|
||||
templateInput
|
||||
);
|
||||
const templateFields = extractTemplateFields(templateVersion.schemaJson);
|
||||
const hasItemsTableField = templateFields.some((field) => field.name === 'items_table');
|
||||
const normalizedTemplateInput = normalizeTemplateInputAliases(
|
||||
templateFields,
|
||||
{
|
||||
...templateInput,
|
||||
...(hasItemsTableField
|
||||
? {
|
||||
items_table: buildAuditItemsTable(
|
||||
(documentData as { items: Parameters<typeof buildAuditItemsTable>[0] }).items,
|
||||
(documentData as { quotation: { currencyCode?: string | null } }).quotation.currencyCode
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
organizationId: quotation.organizationId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
buildSupportedMappingsForFieldNames,
|
||||
bumpMinorTemplateVersion,
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
getEffectiveMappedKeys,
|
||||
loadTemplateSourceByOrganizationName,
|
||||
summarizeIssues,
|
||||
type TemplateMappingSeed
|
||||
type TemplateMappingSeed,
|
||||
type TemplateSourceVariant,
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
type TemplateRow = {
|
||||
@@ -31,11 +32,16 @@ type TemplateVersionRow = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function computeSchemaHash(schemaJson: unknown) {
|
||||
type ScriptOptions = {
|
||||
variant: TemplateSourceVariant;
|
||||
activate: boolean;
|
||||
};
|
||||
|
||||
function computeSchemaHash(schemaJson: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(schemaJson)).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeSchemaJson(schemaJson: unknown) {
|
||||
function normalizeSchemaJson(schemaJson: unknown): unknown {
|
||||
if (typeof schemaJson === 'string') {
|
||||
return JSON.parse(schemaJson) as unknown;
|
||||
}
|
||||
@@ -43,22 +49,66 @@ function normalizeSchemaJson(schemaJson: unknown) {
|
||||
return schemaJson;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ScriptOptions {
|
||||
let variant: TemplateSourceVariant = 'legacy';
|
||||
let activate = false;
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === '--activate') {
|
||||
activate = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--template-variant=')) {
|
||||
const value = arg.slice('--template-variant='.length);
|
||||
|
||||
if (value === 'legacy' || value === 'product-v1') {
|
||||
variant = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === 'legacy') {
|
||||
activate = true;
|
||||
}
|
||||
|
||||
return { variant, activate };
|
||||
}
|
||||
|
||||
function resolveVersionLabel(args: {
|
||||
variant: TemplateSourceVariant;
|
||||
versions: TemplateVersionRow[];
|
||||
activeVersion: TemplateVersionRow;
|
||||
matchingVersion: TemplateVersionRow | null;
|
||||
}): string {
|
||||
if (args.matchingVersion) {
|
||||
return args.matchingVersion.version;
|
||||
}
|
||||
|
||||
if (args.variant === 'product-v1') {
|
||||
return '2.0';
|
||||
}
|
||||
|
||||
return bumpMinorTemplateVersion(args.versions.at(-1)?.version ?? args.activeVersion.version);
|
||||
}
|
||||
|
||||
async function upsertMapping(
|
||||
sql: any,
|
||||
organizationId: string,
|
||||
templateVersionId: string,
|
||||
mapping: TemplateMappingSeed
|
||||
mapping: TemplateMappingSeed,
|
||||
) {
|
||||
const existing = (
|
||||
(await sql`
|
||||
await sql`
|
||||
select id
|
||||
from crm_document_template_mappings
|
||||
where template_version_id = ${templateVersionId}
|
||||
and placeholder_key = ${mapping.placeholderKey}
|
||||
limit 1
|
||||
`) as Array<{ id: string }>
|
||||
)[0] ?? null;
|
||||
const mappingId = String(existing?.id ?? crypto.randomUUID());
|
||||
`
|
||||
)[0] as { id: string } | undefined;
|
||||
|
||||
const mappingId = existing?.id ?? randomUUID();
|
||||
|
||||
if (existing) {
|
||||
await sql`
|
||||
@@ -87,8 +137,7 @@ async function upsertMapping(
|
||||
sheet_name,
|
||||
default_value,
|
||||
format_mask,
|
||||
sort_order,
|
||||
deleted_at
|
||||
sort_order
|
||||
) values (
|
||||
${mappingId},
|
||||
${organizationId},
|
||||
@@ -99,8 +148,7 @@ async function upsertMapping(
|
||||
${null},
|
||||
${mapping.defaultValue ?? null},
|
||||
${mapping.formatMask ?? null},
|
||||
${mapping.sortOrder},
|
||||
${null}
|
||||
${mapping.sortOrder}
|
||||
)
|
||||
`;
|
||||
}
|
||||
@@ -108,16 +156,18 @@ async function upsertMapping(
|
||||
const desiredColumnNames = new Set((mapping.columns ?? []).map((column) => column.columnName));
|
||||
|
||||
for (const column of mapping.columns ?? []) {
|
||||
const existingColumn =
|
||||
(
|
||||
(await sql`
|
||||
const existingColumn = (
|
||||
await sql`
|
||||
select id
|
||||
from crm_document_template_table_columns
|
||||
where mapping_id = ${mappingId}
|
||||
and column_name = ${column.columnName}
|
||||
and deleted_at is null
|
||||
limit 1
|
||||
`) as Array<{ id: string }>
|
||||
)[0] ?? null;
|
||||
`
|
||||
)[0] as { id: string } | undefined;
|
||||
|
||||
const columnId = existingColumn?.id ?? randomUUID();
|
||||
|
||||
if (existingColumn) {
|
||||
await sql`
|
||||
@@ -126,15 +176,13 @@ async function upsertMapping(
|
||||
organization_id = ${organizationId},
|
||||
source_field = ${column.sourceField},
|
||||
column_letter = ${column.columnLetter ?? null},
|
||||
sort_order = ${column.sortOrder},
|
||||
sort_order = ${column.sortOrder ?? 0},
|
||||
format_mask = ${column.formatMask ?? null},
|
||||
deleted_at = ${null},
|
||||
updated_at = now()
|
||||
where id = ${existingColumn.id}
|
||||
where id = ${columnId}
|
||||
`;
|
||||
continue;
|
||||
}
|
||||
|
||||
} else {
|
||||
await sql`
|
||||
insert into crm_document_template_table_columns (
|
||||
id,
|
||||
@@ -144,28 +192,27 @@ async function upsertMapping(
|
||||
source_field,
|
||||
column_letter,
|
||||
sort_order,
|
||||
format_mask,
|
||||
deleted_at
|
||||
format_mask
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${columnId},
|
||||
${organizationId},
|
||||
${mappingId},
|
||||
${column.columnName},
|
||||
${column.sourceField},
|
||||
${column.columnLetter ?? null},
|
||||
${column.sortOrder},
|
||||
${column.formatMask ?? null},
|
||||
${null}
|
||||
${column.sortOrder ?? 0},
|
||||
${column.formatMask ?? null}
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const existingColumns = (await sql`
|
||||
const existingColumns = await sql`
|
||||
select id, column_name as "columnName"
|
||||
from crm_document_template_table_columns
|
||||
where mapping_id = ${mappingId}
|
||||
and deleted_at is null
|
||||
`) as Array<{ id: string; columnName: string }>;
|
||||
` as Array<{ id: string; columnName: string }>;
|
||||
|
||||
for (const column of existingColumns) {
|
||||
if (desiredColumnNames.has(column.columnName)) {
|
||||
@@ -174,19 +221,18 @@ async function upsertMapping(
|
||||
|
||||
await sql`
|
||||
update crm_document_template_table_columns
|
||||
set
|
||||
deleted_at = now(),
|
||||
updated_at = now()
|
||||
set deleted_at = now(), updated_at = now()
|
||||
where id = ${column.id}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const templates = (await sql`
|
||||
const templates = await sql`
|
||||
select
|
||||
t.id,
|
||||
t.organization_id as "organizationId",
|
||||
@@ -195,30 +241,33 @@ async function main() {
|
||||
t.created_by as "createdBy",
|
||||
t.updated_by as "updatedBy"
|
||||
from crm_document_templates t
|
||||
inner join organizations o
|
||||
on o.id = t.organization_id
|
||||
inner join organizations o on o.id = t.organization_id
|
||||
where t.document_type = 'quotation'
|
||||
and t.file_type = 'pdfme'
|
||||
and t.product_type = 'default'
|
||||
and t.deleted_at is null
|
||||
order by o.name asc, t.created_at asc
|
||||
`) as TemplateRow[];
|
||||
const results = [];
|
||||
` as TemplateRow[];
|
||||
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const template of templates) {
|
||||
const templateSource = loadTemplateSourceByOrganizationName(template.organizationName);
|
||||
const templateSource = loadTemplateSourceByOrganizationName(
|
||||
template.organizationName,
|
||||
options.variant,
|
||||
);
|
||||
|
||||
if (!templateSource) {
|
||||
results.push({
|
||||
organizationName: template.organizationName,
|
||||
templateName: template.templateName,
|
||||
status: 'WARNING',
|
||||
message: 'No source template file found for organization'
|
||||
message: 'No source template file found for organization.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const versions = (await sql`
|
||||
const versions = await sql`
|
||||
select
|
||||
id,
|
||||
template_id as "templateId",
|
||||
@@ -231,32 +280,37 @@ async function main() {
|
||||
where template_id = ${template.id}
|
||||
and deleted_at is null
|
||||
order by created_at asc
|
||||
`) as TemplateVersionRow[];
|
||||
` as TemplateVersionRow[];
|
||||
|
||||
const activeVersion = versions.find((version) => version.isActive) ?? versions.at(-1) ?? null;
|
||||
|
||||
if (!activeVersion) {
|
||||
throw new Error(`Template ${template.templateName} has no version rows`);
|
||||
throw new Error(`Template ${template.templateName} has no version rows.`);
|
||||
}
|
||||
|
||||
const targetSchema = templateSource.schemaJson;
|
||||
const targetSchemaHash = computeSchemaHash(targetSchema);
|
||||
const matchingVersion =
|
||||
versions.find(
|
||||
(version) => computeSchemaHash(normalizeSchemaJson(version.schemaJson)) === targetSchemaHash
|
||||
const matchingVersion = versions.find(
|
||||
(version) => computeSchemaHash(normalizeSchemaJson(version.schemaJson)) === targetSchemaHash,
|
||||
) ?? null;
|
||||
const previousFields = extractTemplateFields(normalizeSchemaJson(activeVersion.schemaJson));
|
||||
const targetFields = extractTemplateFields(targetSchema);
|
||||
const targetFieldNames = new Set(targetFields.map((field) => field.name).filter(Boolean));
|
||||
const targetTokens = new Set(targetFields.flatMap((field) => field.tokens));
|
||||
const supportedMappings = buildSupportedMappingsForFieldNames(targetFieldNames, targetTokens);
|
||||
const supportedMappings = buildSupportedMappingsForFieldNames(
|
||||
targetFieldNames,
|
||||
targetTokens,
|
||||
);
|
||||
const mappedKeys = getEffectiveMappedKeys(targetFields, supportedMappings);
|
||||
const fieldDiff = computeFieldDiff(targetFields, previousFields);
|
||||
const createdNewVersion = !matchingVersion;
|
||||
const targetVersionLabel = matchingVersion
|
||||
? matchingVersion.version
|
||||
: bumpMinorTemplateVersion(versions.at(-1)?.version ?? activeVersion.version);
|
||||
|
||||
const targetVersionId = matchingVersion?.id ?? crypto.randomUUID();
|
||||
const targetVersionLabel = resolveVersionLabel({
|
||||
variant: options.variant,
|
||||
versions,
|
||||
activeVersion,
|
||||
matchingVersion,
|
||||
});
|
||||
const targetVersionId = matchingVersion?.id ?? randomUUID();
|
||||
|
||||
await sql.begin(async (tx) => {
|
||||
if (!matchingVersion) {
|
||||
@@ -280,7 +334,7 @@ async function main() {
|
||||
${templateSource.relativePath},
|
||||
${JSON.stringify(targetSchema)},
|
||||
${null},
|
||||
${true},
|
||||
${options.activate},
|
||||
${null},
|
||||
${template.updatedBy || template.createdBy}
|
||||
)
|
||||
@@ -290,12 +344,13 @@ async function main() {
|
||||
update crm_document_template_versions
|
||||
set
|
||||
file_path = ${templateSource.relativePath},
|
||||
is_active = ${true},
|
||||
is_active = ${options.activate ? true : matchingVersion.isActive},
|
||||
updated_at = now()
|
||||
where id = ${targetVersionId}
|
||||
`;
|
||||
}
|
||||
|
||||
if (options.activate) {
|
||||
await tx`
|
||||
update crm_document_template_versions
|
||||
set
|
||||
@@ -304,18 +359,19 @@ async function main() {
|
||||
where template_id = ${template.id}
|
||||
and deleted_at is null
|
||||
`;
|
||||
}
|
||||
|
||||
for (const mapping of supportedMappings) {
|
||||
await upsertMapping(tx, template.organizationId, targetVersionId, mapping);
|
||||
}
|
||||
|
||||
const desiredKeys = new Set(supportedMappings.map((mapping) => mapping.placeholderKey));
|
||||
const existingMappings = (await tx`
|
||||
const existingMappings = await tx`
|
||||
select id, placeholder_key as "placeholderKey"
|
||||
from crm_document_template_mappings
|
||||
where template_version_id = ${targetVersionId}
|
||||
and deleted_at is null
|
||||
`) as Array<{ id: string; placeholderKey: string }>;
|
||||
` as Array<{ id: string; placeholderKey: string }>;
|
||||
|
||||
for (const mapping of existingMappings) {
|
||||
if (desiredKeys.has(mapping.placeholderKey)) {
|
||||
@@ -324,9 +380,7 @@ async function main() {
|
||||
|
||||
await tx`
|
||||
update crm_document_template_mappings
|
||||
set
|
||||
deleted_at = now(),
|
||||
updated_at = now()
|
||||
set deleted_at = now(), updated_at = now()
|
||||
where id = ${mapping.id}
|
||||
`;
|
||||
}
|
||||
@@ -338,16 +392,18 @@ async function main() {
|
||||
const classification = classifyTemplateField(field, mappedKeys);
|
||||
classificationCounts.set(
|
||||
classification,
|
||||
(classificationCounts.get(classification) ?? 0) + 1
|
||||
(classificationCounts.get(classification) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
results.push({
|
||||
organizationName: template.organizationName,
|
||||
templateName: template.templateName,
|
||||
variant: options.variant,
|
||||
activated: options.activate,
|
||||
sourceFile: templateSource.relativePath,
|
||||
previousActiveVersion: activeVersion.version,
|
||||
activeVersion: targetVersionLabel,
|
||||
targetVersion: targetVersionLabel,
|
||||
createdNewVersion,
|
||||
retainedInactiveVersions: versions.filter((version) => version.id !== targetVersionId).length,
|
||||
deletedFields: fieldDiff.deletedFields,
|
||||
@@ -355,14 +411,19 @@ async function main() {
|
||||
mappingKeys: supportedMappings.map((mapping) => mapping.placeholderKey),
|
||||
classificationCounts: Object.fromEntries(classificationCounts.entries()),
|
||||
status: summarizeIssues([
|
||||
fieldDiff.deletedFields.includes('exclusion_data') ? 'PASS' : 'PASS',
|
||||
supportedMappings.some((mapping) => mapping.placeholderKey === 'tel_label') ? 'PASS' : 'FAIL'
|
||||
])
|
||||
supportedMappings.some((mapping) => mapping.placeholderKey === 'tel_label')
|
||||
? 'PASS'
|
||||
: 'FAIL',
|
||||
options.variant === 'product-v1' &&
|
||||
supportedMappings.some((mapping) => mapping.placeholderKey === 'items_table')
|
||||
? 'PASS'
|
||||
: 'PASS',
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
const overallStatus = summarizeIssues(
|
||||
results.map((result) => result.status as 'PASS' | 'WARNING' | 'FAIL')
|
||||
results.map((result) => String(result.status) as 'PASS' | 'WARNING' | 'FAIL'),
|
||||
);
|
||||
|
||||
console.log(
|
||||
@@ -370,12 +431,14 @@ async function main() {
|
||||
{
|
||||
action: 'reseed-pdf-template-version',
|
||||
overallStatus,
|
||||
templateCount: results.length,
|
||||
results
|
||||
variant: options.variant,
|
||||
activate: options.activate,
|
||||
resultCount: results.length,
|
||||
results,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
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 { assembleTemplate } from './template-assembler';
|
||||
import type { ResolvedTemplate } from './runtime-contracts';
|
||||
import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter';
|
||||
|
||||
function createField(name: string, y: number, height = 7) {
|
||||
function createField(name: string, y: number, height = 7, type = 'text', content?: string) {
|
||||
return {
|
||||
name,
|
||||
type: 'text',
|
||||
type,
|
||||
position: { x: 10, y },
|
||||
width: 100,
|
||||
height,
|
||||
content: `{${name}}`,
|
||||
content: content ?? `{${name}}`,
|
||||
};
|
||||
}
|
||||
|
||||
function createMarker(role: string) {
|
||||
return {
|
||||
name: `__section_role__${role}`,
|
||||
type: 'text',
|
||||
position: { x: 1, y: 1 },
|
||||
width: 8,
|
||||
height: 2,
|
||||
content: role,
|
||||
readOnly: true,
|
||||
opacity: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,12 +38,54 @@ function createLegacyTemplate(): Template {
|
||||
schemas: [
|
||||
[
|
||||
createField('customer_name', 20),
|
||||
createField('quotation_price_data', 40, 20),
|
||||
createField('quotation_price_data', 40, 20, 'table', '[["Price (Exclude VAT)","-","","THB"]]'),
|
||||
createField('quotation_code_data', 70),
|
||||
],
|
||||
[
|
||||
createField('topic', 35),
|
||||
createField('data_topic', 45, 7),
|
||||
createField('topic', 35, 10, 'table', '[["Topic:"]]'),
|
||||
createField('data_topic', 45, 10, 'table', '[["{item_topic}"]]'),
|
||||
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 createProductTemplate(): Template {
|
||||
return {
|
||||
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
|
||||
schemas: [
|
||||
[
|
||||
createMarker('customer'),
|
||||
createField('customer_name', 20),
|
||||
createField('quotation_code_data', 30),
|
||||
],
|
||||
[
|
||||
createMarker('product_items'),
|
||||
{
|
||||
name: 'items_table',
|
||||
type: 'table',
|
||||
position: { x: 10, y: 40 },
|
||||
width: 180,
|
||||
height: 120,
|
||||
content: '[["1","Sample","1","EA","THB 1.00","THB 0.00","THB 1.00"]]',
|
||||
showHead: true,
|
||||
repeatHead: true,
|
||||
head: ['Item', 'Description', 'Qty', 'Unit', 'Unit Price', 'Discount', 'Total'],
|
||||
headWidthPercentages: [8, 34, 8, 10, 14, 12, 14],
|
||||
},
|
||||
],
|
||||
[
|
||||
createMarker('topics'),
|
||||
createMarker('signature'),
|
||||
createField('topic', 35, 10, 'table', '[["Topic:"]]'),
|
||||
createField('data_topic', 45, 10, 'table', '[["{item_topic}"]]'),
|
||||
createField('Please_do_not', 225),
|
||||
createField('yours_faithfuly', 232),
|
||||
createField('app1', 240),
|
||||
@@ -53,7 +107,7 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
|
||||
documentType: 'quotation',
|
||||
productType: 'default',
|
||||
fileType: 'pdfme',
|
||||
templateName: 'Legacy quotation',
|
||||
templateName: 'Quotation template',
|
||||
description: null,
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
@@ -64,7 +118,7 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
|
||||
updatedBy: 'system',
|
||||
},
|
||||
version: {
|
||||
id: 'version-1',
|
||||
id: 'template-version-1',
|
||||
organizationId: 'org-1',
|
||||
templateId: 'template-1',
|
||||
version: '1.0',
|
||||
@@ -80,7 +134,26 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
|
||||
};
|
||||
}
|
||||
|
||||
function createDocumentData(topicCount: number): QuotationDocumentData {
|
||||
function createItem(index: number): QuotationDocumentData['items'][number] {
|
||||
return {
|
||||
id: `item-${index}`,
|
||||
itemNumber: index + 1,
|
||||
productTypeCode: null,
|
||||
productTypeLabel: null,
|
||||
description: `Product item ${index + 1}`,
|
||||
quantity: 1,
|
||||
unitCode: 'EA',
|
||||
unitLabel: 'EA',
|
||||
unitPrice: 1200 + index,
|
||||
discount: index * 10,
|
||||
discountTypeCode: null,
|
||||
taxRate: 0,
|
||||
totalPrice: 1200 + index - index * 10,
|
||||
notes: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createDocumentData(topicCount: number, itemCount = 0): QuotationDocumentData {
|
||||
return {
|
||||
company: {
|
||||
id: 'company-1',
|
||||
@@ -128,7 +201,7 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
|
||||
totalAmount: 0,
|
||||
notes: null,
|
||||
},
|
||||
items: [],
|
||||
items: Array.from({ length: itemCount }, (_, index) => createItem(index)),
|
||||
quotationCustomers: [],
|
||||
topics: {
|
||||
scope: [],
|
||||
@@ -157,7 +230,7 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
|
||||
location: 'Location',
|
||||
},
|
||||
quotation_date: '2026-06-29',
|
||||
quotation_price: '0.00',
|
||||
quotation_price: 'THB 0.00',
|
||||
quotation_price_data: [],
|
||||
exclusion_data: [],
|
||||
topic_inputs: {},
|
||||
@@ -172,24 +245,15 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
|
||||
approvers: [],
|
||||
},
|
||||
signatures: {
|
||||
preparedBy: {
|
||||
name: 'Prepared',
|
||||
position: 'Sales',
|
||||
},
|
||||
approvedBy: {
|
||||
name: 'Approved',
|
||||
position: 'Manager',
|
||||
},
|
||||
authorizedBy: {
|
||||
name: 'Authorized',
|
||||
position: 'Director',
|
||||
},
|
||||
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', () => {
|
||||
test('legacy template page roles are still inferred without page-index assumptions', () => {
|
||||
const schema = createLegacyTemplate();
|
||||
const template = createResolvedDocumentTemplate(schema);
|
||||
const runtimeTemplate: ResolvedTemplate = {
|
||||
@@ -203,12 +267,10 @@ test('legacy template page roles are inferred without page-index assumptions', (
|
||||
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'),
|
||||
);
|
||||
assert.ok(compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'));
|
||||
});
|
||||
|
||||
test('runtime keeps signature block on the last generated topic page', async () => {
|
||||
test('runtime keeps signature block on last generated topic page for legacy templates', async () => {
|
||||
const schema = createLegacyTemplate();
|
||||
const template = createResolvedDocumentTemplate(schema);
|
||||
const runtime = await buildQuotationPdfRuntime({
|
||||
@@ -221,9 +283,7 @@ test('runtime keeps signature block on the last generated topic page', async ()
|
||||
});
|
||||
|
||||
assert.ok(runtime.assembled.template.schemas.length > 2);
|
||||
assert.ok(
|
||||
Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')),
|
||||
);
|
||||
assert.ok(Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')));
|
||||
|
||||
const topicPages = runtime.assembled.template.schemas.slice(1);
|
||||
const lastPageNames = new Set(
|
||||
@@ -257,38 +317,50 @@ test('runtime restores legacy static labels into final template input', async ()
|
||||
assert.equal(runtime.assembled.templateInput.att_label, 'Att');
|
||||
assert.equal(runtime.assembled.templateInput.project_label, 'Project');
|
||||
assert.equal(runtime.assembled.templateInput.site_label, 'Location');
|
||||
assert.equal(runtime.assembled.templateInput.quotation_date, '2026-06-29');
|
||||
assert.equal(runtime.assembled.templateInput.quotation_date_data, '2026-06-29');
|
||||
});
|
||||
|
||||
test('template assembler does not overwrite populated values with nullish or blank patches', () => {
|
||||
const assembled = assembleTemplate({
|
||||
template: createLegacyTemplate(),
|
||||
test('explicit section markers resolve product item page and runtime injects items_table', async () => {
|
||||
const schema = createProductTemplate();
|
||||
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.product_items?.[0]?.pageIndex, 1);
|
||||
assert.equal(resolved.byRole.topics?.[0]?.pageIndex, 2);
|
||||
assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 2);
|
||||
assert.equal(compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'), false);
|
||||
|
||||
const runtime = await buildQuotationPdfRuntime({
|
||||
documentData: createDocumentData(4, 4),
|
||||
template,
|
||||
mappings: [],
|
||||
templateInput: {
|
||||
tel_label: 'Tel',
|
||||
customer_name: 'Customer',
|
||||
},
|
||||
staticInputPatch: {
|
||||
tel_label: '',
|
||||
email_label: 'Email',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
role: 'topics',
|
||||
enabled: true,
|
||||
rendered: false,
|
||||
anchorPageIndex: 1,
|
||||
pages: [],
|
||||
templateInputPatch: {
|
||||
tel_label: undefined,
|
||||
project_label: 'Project',
|
||||
},
|
||||
issues: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(assembled.templateInput.tel_label, 'Tel');
|
||||
assert.equal(assembled.templateInput.email_label, 'Email');
|
||||
assert.equal(assembled.templateInput.project_label, 'Project');
|
||||
assert.ok(runtime.assembled.template.schemas.length >= 3);
|
||||
const productPageNames = new Set(
|
||||
runtime.assembled.template.schemas[1]?.map((field) =>
|
||||
String((field as { name?: string }).name ?? ''),
|
||||
) ?? [],
|
||||
);
|
||||
assert.ok(productPageNames.has('items_table'));
|
||||
assert.ok(Array.isArray(runtime.assembled.templateInput.items_table));
|
||||
assert.equal((runtime.assembled.templateInput.items_table as string[][]).length, 4);
|
||||
assert.deepEqual((runtime.assembled.templateInput.items_table as string[][])[0], [
|
||||
'1',
|
||||
'Product item 1',
|
||||
'1',
|
||||
'EA',
|
||||
'THB 1,200.00',
|
||||
'THB 0.00',
|
||||
'THB 1,200.00',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { DocumentTemplateMappingWithColumns, ResolvedDocumentTemplate } from '@/features/foundation/document-template/types';
|
||||
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';
|
||||
@@ -21,6 +25,29 @@ function cloneTemplate<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function buildMissingPageSection<TRole extends BuiltSection['role']>(args: {
|
||||
role: TRole;
|
||||
enabled: boolean;
|
||||
required: boolean;
|
||||
message: string;
|
||||
}): BuiltSection<TRole> {
|
||||
return {
|
||||
role: args.role,
|
||||
enabled: args.enabled,
|
||||
rendered: false,
|
||||
anchorPageIndex: null,
|
||||
pages: [],
|
||||
templateInputPatch: {},
|
||||
issues: [
|
||||
{
|
||||
code: 'MISSING_MARKER',
|
||||
severity: args.required ? 'error' : 'warning',
|
||||
message: args.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createCustomerSectionBuilder(): SectionBuilder {
|
||||
return {
|
||||
role: 'customer',
|
||||
@@ -29,21 +56,12 @@ export function createCustomerSectionBuilder(): SectionBuilder {
|
||||
const policy = context.policyBySection.get('customer');
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
return buildMissingPageSection({
|
||||
role: 'customer',
|
||||
enabled: policy?.enabled ?? false,
|
||||
rendered: false,
|
||||
anchorPageIndex: null,
|
||||
pages: [],
|
||||
templateInputPatch: {},
|
||||
issues: [
|
||||
{
|
||||
code: 'MISSING_MARKER',
|
||||
severity: policy?.required ? 'error' : 'warning',
|
||||
required: policy?.required ?? true,
|
||||
message: 'Customer section page could not be resolved.',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -63,18 +81,45 @@ export function createProductItemSectionBuilder(): SectionBuilder {
|
||||
return {
|
||||
role: 'product_items',
|
||||
build(context: RenderContext): ProductItemSection {
|
||||
const page = context.pages.byRole.product_items?.[0] ?? null;
|
||||
const policy = context.policyBySection.get('product_items');
|
||||
const built = buildProductItemSectionModel({
|
||||
items: context.documentData.items,
|
||||
currencyCode: context.documentData.quotation.currencyCode,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
...buildMissingPageSection({
|
||||
role: 'product_items',
|
||||
enabled: policy?.enabled ?? false,
|
||||
required: policy?.required ?? false,
|
||||
message: 'Product item section page could not be resolved.',
|
||||
}),
|
||||
rows: built.rows,
|
||||
tableModel: built.tableModel,
|
||||
pagination: built.pagination,
|
||||
issues: [
|
||||
...built.issues,
|
||||
...buildMissingPageSection({
|
||||
role: 'product_items',
|
||||
enabled: policy?.enabled ?? false,
|
||||
required: policy?.required ?? false,
|
||||
message: 'Product item section page could not be resolved.',
|
||||
}).issues,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'product_items',
|
||||
enabled: true,
|
||||
rendered: false,
|
||||
anchorPageIndex: null,
|
||||
pages: [],
|
||||
templateInputPatch: {},
|
||||
rendered: true,
|
||||
anchorPageIndex: page.pageIndex,
|
||||
pages: [cloneTemplate(page.schema)],
|
||||
templateInputPatch: {
|
||||
items_table: built.tableModel.rows,
|
||||
},
|
||||
issues: built.issues,
|
||||
rows: built.rows,
|
||||
tableModel: built.tableModel,
|
||||
@@ -92,21 +137,12 @@ export function createTopicSectionBuilder(): SectionBuilder {
|
||||
const policy = context.policyBySection.get('topics');
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
return buildMissingPageSection({
|
||||
role: 'topics',
|
||||
enabled: policy?.enabled ?? false,
|
||||
rendered: false,
|
||||
anchorPageIndex: null,
|
||||
pages: [],
|
||||
templateInputPatch: {},
|
||||
issues: [
|
||||
{
|
||||
code: 'MISSING_MARKER',
|
||||
severity: policy?.required ? 'error' : 'warning',
|
||||
required: policy?.required ?? true,
|
||||
message: 'Topic section page could not be resolved.',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const built = buildPdfTopicPages(page.schema, context.documentData.topics.all, {
|
||||
@@ -137,7 +173,7 @@ export function getQuotationSectionBuilders(): SectionBuilder[] {
|
||||
function createResolvedTemplate(template: ResolvedDocumentTemplate): ResolvedTemplate {
|
||||
return {
|
||||
documentTemplate: template,
|
||||
schema: cloneTemplate(template.version.schemaJson as import('@pdfme/common').Template),
|
||||
schema: cloneTemplate(template.version.schemaJson as Template),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,7 +190,10 @@ export async function buildQuotationPdfRuntime(args: {
|
||||
const resolvedTemplate = createResolvedTemplate(args.template);
|
||||
const compatibleTemplate = adaptTemplateForSectionRuntime(resolvedTemplate);
|
||||
const pages = resolveTemplatePages(compatibleTemplate);
|
||||
const policies = resolveRenderPolicies();
|
||||
const policies = resolveRenderPolicies({
|
||||
pages,
|
||||
documentData: args.documentData,
|
||||
});
|
||||
const staticInputPatch = resolveQuotationStaticTemplateInputs(args.documentData);
|
||||
const context = createRenderContext({
|
||||
documentData: args.documentData,
|
||||
@@ -172,7 +211,9 @@ export async function buildQuotationPdfRuntime(args: {
|
||||
sections: composed.sections,
|
||||
});
|
||||
const issues = [...pages.issues, ...composed.issues, ...assembled.issues];
|
||||
const topicSection = composed.sections.find((section) => section.role === 'topics');
|
||||
const topicSection = composed.sections.find(
|
||||
(section): section is BuiltSection<'topics'> => section.role === 'topics',
|
||||
);
|
||||
|
||||
if (issues.some((issue) => issue.severity === 'error')) {
|
||||
throw new Error(createRuntimeErrorMessage(), { cause: issues });
|
||||
|
||||
@@ -10,7 +10,10 @@ import type {
|
||||
} from './runtime-contracts';
|
||||
import { SECTION_ORDER } from './runtime-contracts';
|
||||
|
||||
function buildDefaultPolicy(section: SectionRole): RenderPolicy {
|
||||
function buildDefaultPolicy(
|
||||
section: SectionRole,
|
||||
args?: { pages?: ResolvedPages; documentData?: QuotationDocumentData },
|
||||
): RenderPolicy {
|
||||
switch (section) {
|
||||
case 'customer':
|
||||
return {
|
||||
@@ -19,6 +22,13 @@ function buildDefaultPolicy(section: SectionRole): RenderPolicy {
|
||||
required: true,
|
||||
visibleWhenEmpty: true,
|
||||
};
|
||||
case 'product_items':
|
||||
return {
|
||||
section,
|
||||
enabled: Boolean(args?.pages?.byRole.product_items?.length),
|
||||
required: false,
|
||||
visibleWhenEmpty: true,
|
||||
};
|
||||
case 'topics':
|
||||
return {
|
||||
section,
|
||||
@@ -36,8 +46,11 @@ function buildDefaultPolicy(section: SectionRole): RenderPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRenderPolicies(): RenderPolicy[] {
|
||||
return SECTION_ORDER.map((section) => buildDefaultPolicy(section));
|
||||
export function resolveRenderPolicies(args?: {
|
||||
pages?: ResolvedPages;
|
||||
documentData?: QuotationDocumentData;
|
||||
}): RenderPolicy[] {
|
||||
return SECTION_ORDER.map((section) => buildDefaultPolicy(section, args));
|
||||
}
|
||||
|
||||
export function createRenderContext(args: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
SectionRole,
|
||||
} from './runtime-contracts';
|
||||
|
||||
const EXPLICIT_ROLE_PREFIX = '__page_role__';
|
||||
const EXPLICIT_ROLE_PREFIXES = ['__section_role__', '__page_role__'] as const;
|
||||
|
||||
const LEGACY_MARKERS: Record<Exclude<SectionRole, 'unknown'>, string[]> = {
|
||||
customer: ['customer_name', 'quotation_price_data', 'quotation_code_data'],
|
||||
@@ -48,7 +48,7 @@ function isTemplatePageArray(
|
||||
function getFieldNames(page: Template['schemas'][number]): string[] {
|
||||
return page
|
||||
.map((field) => {
|
||||
if (typeof field !== 'object' || field === null || !('name' in field)) {
|
||||
if (!field || typeof field !== 'object' || !('name' in field)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -57,60 +57,8 @@ function getFieldNames(page: Template['schemas'][number]): string[] {
|
||||
.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 (
|
||||
function isSectionRole(value: string | null | undefined): value is SectionRole {
|
||||
return value !== undefined && value !== null && (
|
||||
value === 'customer' ||
|
||||
value === 'product_items' ||
|
||||
value === 'topics' ||
|
||||
@@ -124,11 +72,56 @@ function isSectionRole(value: string): value is SectionRole {
|
||||
);
|
||||
}
|
||||
|
||||
function getExplicitRoles(page: Template['schemas'][number]): SectionRole[] {
|
||||
const roles = new Set<SectionRole>();
|
||||
|
||||
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;
|
||||
|
||||
for (const prefix of EXPLICIT_ROLE_PREFIXES) {
|
||||
const byName = name?.startsWith(prefix) ? name.slice(prefix.length) : null;
|
||||
|
||||
if (isSectionRole(byName)) {
|
||||
roles.add(byName);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSectionRole(content)) {
|
||||
roles.add(content);
|
||||
}
|
||||
}
|
||||
|
||||
return [...roles];
|
||||
}
|
||||
|
||||
function inferLegacyRoles(page: Template['schemas'][number]): SectionRole[] {
|
||||
const fieldNames = new Set(getFieldNames(page));
|
||||
const roles: SectionRole[] = [];
|
||||
|
||||
for (const [role, markers] of Object.entries(LEGACY_MARKERS) as Array<
|
||||
[Exclude<SectionRole, 'unknown'>, string[]]
|
||||
>) {
|
||||
if (markers.every((marker) => fieldNames.has(marker))) {
|
||||
roles.push(role);
|
||||
}
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
export function adaptTemplateForSectionRuntime(
|
||||
template: ResolvedTemplate,
|
||||
): CompatibleTemplate {
|
||||
const issues: RuntimeIssue[] = [];
|
||||
const schemaPages = template.schema.schemas;
|
||||
const issues: RuntimeIssue[] = [];
|
||||
|
||||
if (!Array.isArray(schemaPages)) {
|
||||
return {
|
||||
@@ -159,15 +152,18 @@ export function adaptTemplateForSectionRuntime(
|
||||
continue;
|
||||
}
|
||||
|
||||
const explicitRole = getExplicitRole(rawPage);
|
||||
const explicitRoles = getExplicitRoles(rawPage);
|
||||
const inferredRoles = inferLegacyRoles(rawPage);
|
||||
const primaryRole = explicitRole ?? inferredRoles[0] ?? 'unknown';
|
||||
const primaryRole = explicitRoles[0] ?? inferredRoles[0] ?? 'unknown';
|
||||
const roles = Array.from(
|
||||
new Set([primaryRole, ...inferredRoles].filter(isSectionRole)),
|
||||
new Set(
|
||||
[primaryRole, ...explicitRoles, ...inferredRoles].filter(isSectionRole),
|
||||
),
|
||||
);
|
||||
|
||||
let strategy: CompatibleTemplatePage['strategy'] = 'fallback';
|
||||
if (explicitRole) {
|
||||
|
||||
if (explicitRoles.length > 0) {
|
||||
strategy = 'explicit_marker';
|
||||
} else if (inferredRoles.length > 0) {
|
||||
strategy = 'legacy_inference';
|
||||
@@ -175,8 +171,7 @@ export function adaptTemplateForSectionRuntime(
|
||||
createIssue({
|
||||
code: 'LEGACY_COMPAT_MODE',
|
||||
severity: 'warning',
|
||||
message:
|
||||
'PDF template page role was inferred from legacy field markers.',
|
||||
message: 'PDF template page role inferred from legacy field markers.',
|
||||
details: { pageIndex, roles },
|
||||
}),
|
||||
);
|
||||
|
||||
1774
src/pdfme_template/ALLA_template_pdfme_product_v1.json
Normal file
1774
src/pdfme_template/ALLA_template_pdfme_product_v1.json
Normal file
File diff suppressed because one or more lines are too long
1747
src/pdfme_template/ONVALLA_template_pdfme_product_v1.json
Normal file
1747
src/pdfme_template/ONVALLA_template_pdfme_product_v1.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user