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",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "QT2606-1122",
|
"quotationCode": "QT2606-1122",
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PDF Integrity Summary
|
# PDF Integrity Summary
|
||||||
|
|
||||||
- Generated At: 2026-06-29T07:31:40.763Z
|
- Generated At: 2026-06-29T08:14:00.088Z
|
||||||
- Overall Status: PASS
|
- Overall Status: PASS
|
||||||
- Quotation Code: QT2606-1122
|
- Quotation Code: QT2606-1122
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-06-29T07:31:39.763Z",
|
"generatedAt": "2026-06-29T08:13:58.800Z",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "QT2606-1122",
|
"quotationCode": "QT2606-1122",
|
||||||
"expectedFixtureCode": "QT-H5-AUDIT",
|
"expectedFixtureCode": "QT-H5-AUDIT",
|
||||||
@@ -8,54 +8,23 @@
|
|||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"versionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89",
|
"versionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89",
|
||||||
"fieldCount": 44,
|
"fieldCount": 44,
|
||||||
"previousVersion": null,
|
"previousVersion": "2.0",
|
||||||
"deletedFields": [],
|
"deletedFields": [
|
||||||
"newFields": [
|
"__section_role__customer",
|
||||||
"Please_do_not",
|
"__section_role__product_items",
|
||||||
"app1",
|
"__section_role__signature",
|
||||||
"app1_position",
|
"__section_role__topics",
|
||||||
"app2",
|
"items_table",
|
||||||
"app2_position",
|
"product_items_currency_value",
|
||||||
"app3",
|
"product_items_footer_note",
|
||||||
"app3_position",
|
"product_items_line_168",
|
||||||
"att_label",
|
"product_items_line_35",
|
||||||
"colon",
|
"product_items_subtitle",
|
||||||
"colon copy",
|
"product_items_title",
|
||||||
"colon_att",
|
"product_items_total_label",
|
||||||
"colon_email",
|
"product_items_total_value"
|
||||||
"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"
|
|
||||||
],
|
],
|
||||||
|
"newFields": [],
|
||||||
"duplicateFields": [],
|
"duplicateFields": [],
|
||||||
"unknownFields": [],
|
"unknownFields": [],
|
||||||
"classifications": [
|
"classifications": [
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# PDF Audit Report
|
# PDF Audit Report
|
||||||
|
|
||||||
- Generated At: 2026-06-29T07:31:39.763Z
|
- Generated At: 2026-06-29T08:13:58.800Z
|
||||||
- Overall Status: PASS
|
- Overall Status: PASS
|
||||||
- Quotation Code: QT2606-1122
|
- Quotation Code: QT2606-1122
|
||||||
- Template: ALLA Quotation Standard v1.0
|
- Template: ALLA Quotation Standard v1.0
|
||||||
- Previous Version: None
|
- Previous Version: 2.0
|
||||||
|
|
||||||
## Metrics
|
## 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
|
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
|
## Verification
|
||||||
|
|
||||||
```bash
|
```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 priceTable = payload.templateInput.quotation_price_data;
|
||||||
const priceTableRows = Array.isArray(priceTable) ? priceTable : [];
|
const priceTableRows = Array.isArray(priceTable) ? priceTable : [];
|
||||||
const firstPriceRow = Array.isArray(priceTableRows[0]) ? (priceTableRows[0] as unknown[]) : [];
|
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, {
|
pushFinding(findings, {
|
||||||
field: 'templateInput.quotation_price_data',
|
field: 'templateInput.quotation_price_data',
|
||||||
@@ -172,6 +175,24 @@ async function main() {
|
|||||||
value: firstPriceRow[3] ?? null,
|
value: firstPriceRow[3] ?? null,
|
||||||
message: 'Price table currency code cell is populated'
|
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 staticLabelFindings = [...PDFME_STATIC_LABEL_FIELD_KEYS].map((fieldName) => {
|
||||||
const value = payload.templateInput[fieldName];
|
const value = payload.templateInput[fieldName];
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ async function main() {
|
|||||||
const duplicateFields = getDuplicateFieldNames(fields);
|
const duplicateFields = getDuplicateFieldNames(fields);
|
||||||
const unknownFields = getUnknownFieldNames(fields, mappedKeys);
|
const unknownFields = getUnknownFieldNames(fields, mappedKeys);
|
||||||
const fieldDiff = computeFieldDiff(fields, previousFields);
|
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(
|
const missingRequiredTableFields = requiredTableFields.filter(
|
||||||
(fieldName) => !fields.some((field) => field.name === fieldName && field.type === 'table')
|
(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']);
|
const RESERVED_DYNAMIC_FIELD_NAMES = new Set(['topic', 'data_topic']);
|
||||||
|
|
||||||
function detectTypo(fieldName: string) {
|
function detectTypo(fieldName: string) {
|
||||||
|
if (fieldName.startsWith('__section_role__')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return TYPO_PATTERNS.some((pattern) => pattern.test(fieldName));
|
return TYPO_PATTERNS.some((pattern) => pattern.test(fieldName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import {
|
|||||||
createSqlClient,
|
createSqlClient,
|
||||||
getActiveTemplateVersions,
|
getActiveTemplateVersions,
|
||||||
loadTemplateSourceByOrganizationName,
|
loadTemplateSourceByOrganizationName,
|
||||||
|
loadTemplateSourceByRelativePath,
|
||||||
summarizeIssues,
|
summarizeIssues,
|
||||||
writeAuditArtifact
|
writeAuditArtifact,
|
||||||
} from './pdf-audit-utils.ts';
|
} from './pdf-audit-utils.ts';
|
||||||
|
|
||||||
type IntegrityIssue = {
|
type IntegrityIssue = {
|
||||||
@@ -29,7 +30,7 @@ async function main() {
|
|||||||
versionRows,
|
versionRows,
|
||||||
orphanMappingRows,
|
orphanMappingRows,
|
||||||
payload,
|
payload,
|
||||||
templateRows
|
templateRows,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getActiveTemplateVersions(sql),
|
getActiveTemplateVersions(sql),
|
||||||
sql`
|
sql`
|
||||||
@@ -39,14 +40,13 @@ async function main() {
|
|||||||
v.organization_id as "organizationId",
|
v.organization_id as "organizationId",
|
||||||
o.name as "organizationName",
|
o.name as "organizationName",
|
||||||
v.version,
|
v.version,
|
||||||
|
v.file_path as "filePath",
|
||||||
v.is_active as "isActive",
|
v.is_active as "isActive",
|
||||||
v.schema_json as "schemaJson",
|
v.schema_json as "schemaJson",
|
||||||
t.id as "resolvedTemplateId"
|
t.id as "resolvedTemplateId"
|
||||||
from crm_document_template_versions v
|
from crm_document_template_versions v
|
||||||
left join crm_document_templates t
|
left join crm_document_templates t on t.id = v.template_id
|
||||||
on t.id = v.template_id
|
left join organizations o on o.id = v.organization_id
|
||||||
left join organizations o
|
|
||||||
on o.id = v.organization_id
|
|
||||||
where v.deleted_at is null
|
where v.deleted_at is null
|
||||||
`,
|
`,
|
||||||
sql`
|
sql`
|
||||||
@@ -55,8 +55,7 @@ async function main() {
|
|||||||
m.template_version_id as "templateVersionId",
|
m.template_version_id as "templateVersionId",
|
||||||
m.placeholder_key as "placeholderKey"
|
m.placeholder_key as "placeholderKey"
|
||||||
from crm_document_template_mappings m
|
from crm_document_template_mappings m
|
||||||
left join crm_document_template_versions v
|
left join crm_document_template_versions v on v.id = m.template_version_id
|
||||||
on v.id = m.template_version_id
|
|
||||||
where m.deleted_at is null
|
where m.deleted_at is null
|
||||||
and (v.id is null or v.deleted_at is not null)
|
and (v.id is null or v.deleted_at is not null)
|
||||||
`,
|
`,
|
||||||
@@ -68,48 +67,41 @@ async function main() {
|
|||||||
o.name as "organizationName",
|
o.name as "organizationName",
|
||||||
t.template_name as "templateName"
|
t.template_name as "templateName"
|
||||||
from crm_document_templates t
|
from crm_document_templates t
|
||||||
inner join organizations o
|
inner join organizations o on o.id = t.organization_id
|
||||||
on o.id = t.organization_id
|
|
||||||
where t.deleted_at is null
|
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 issues: IntegrityIssue[] = [];
|
||||||
const bucketKey = [
|
const templateBuckets = new Map<string, Array<Record<string, unknown>>>();
|
||||||
record.organizationId,
|
|
||||||
record.documentType,
|
for (const record of activeTemplateVersions as unknown as Array<Record<string, unknown>>) {
|
||||||
record.productType,
|
const bucketKey = `${String(record.organizationId)}:${String(record.templateName)}`;
|
||||||
record.fileType
|
const records = templateBuckets.get(bucketKey) ?? [];
|
||||||
].join(':');
|
records.push(record);
|
||||||
const items = templateBuckets.get(bucketKey) ?? [];
|
templateBuckets.set(bucketKey, records);
|
||||||
items.push(record);
|
|
||||||
templateBuckets.set(bucketKey, items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [bucketKey, records] of templateBuckets.entries()) {
|
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({
|
issues.push({
|
||||||
kind: 'DUPLICATE_ACTIVE_TEMPLATE',
|
kind: 'DUPLICATE_ACTIVE_TEMPLATE',
|
||||||
message: `More than one active template exists for ${bucketKey}`,
|
message: `Multiple active templates found for ${bucketKey}.`,
|
||||||
context: {
|
context: {
|
||||||
bucketKey,
|
bucketKey,
|
||||||
templateIds,
|
templateIds: records.map((record) => record.templateId),
|
||||||
templates: records.map((record) => ({
|
templates: records.map((record) => ({
|
||||||
templateId: record.templateId,
|
templateId: record.templateId,
|
||||||
templateName: record.templateName,
|
templateName: record.templateName,
|
||||||
versionId: record.versionId,
|
versionId: record.versionId,
|
||||||
version: record.version
|
version: record.version,
|
||||||
}))
|
})),
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const versionBuckets = new Map<string, Array<Record<string, unknown>>>();
|
const versionBuckets = new Map<string, Array<Record<string, unknown>>>();
|
||||||
|
|
||||||
@@ -125,36 +117,42 @@ async function main() {
|
|||||||
if (!row.resolvedTemplateId) {
|
if (!row.resolvedTemplateId) {
|
||||||
issues.push({
|
issues.push({
|
||||||
kind: 'ACTIVE_VERSION_WITHOUT_TEMPLATE',
|
kind: 'ACTIVE_VERSION_WITHOUT_TEMPLATE',
|
||||||
message: `Active version ${String(row.id)} has no parent template`,
|
message: `Active version ${String(row.id)} has no parent template.`,
|
||||||
context: row
|
context: row,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [templateId, rows] of versionBuckets.entries()) {
|
for (const [templateId, rows] of versionBuckets.entries()) {
|
||||||
if (rows.length > 1) {
|
if (rows.length <= 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
issues.push({
|
issues.push({
|
||||||
kind: 'DUPLICATE_ACTIVE_VERSION',
|
kind: 'DUPLICATE_ACTIVE_VERSION',
|
||||||
message: `Template ${templateId} has more than one active version`,
|
message: `Template ${templateId} has more than one active version.`,
|
||||||
context: {
|
context: {
|
||||||
templateId,
|
templateId,
|
||||||
versions: rows.map((row) => ({
|
versions: rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
version: row.version
|
version: row.version,
|
||||||
}))
|
})),
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const templateRow of templateRows as Array<Record<string, unknown>>) {
|
for (const templateRow of templateRows as Array<Record<string, unknown>>) {
|
||||||
const templateId = String(templateRow.id);
|
const templateId = String(templateRow.id);
|
||||||
const rows = (versionRows as Array<Record<string, unknown>>).filter(
|
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 activeRows = rows.filter((row) => Boolean(row.isActive));
|
||||||
const inactiveRows = rows.filter((row) => !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) {
|
if (templateSource && activeRows.length === 1) {
|
||||||
const activeSchema =
|
const activeSchema =
|
||||||
@@ -165,14 +163,14 @@ async function main() {
|
|||||||
if (JSON.stringify(activeSchema) !== JSON.stringify(templateSource.schemaJson)) {
|
if (JSON.stringify(activeSchema) !== JSON.stringify(templateSource.schemaJson)) {
|
||||||
issues.push({
|
issues.push({
|
||||||
kind: 'ACTIVE_VERSION_SCHEMA_DRIFT',
|
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: {
|
context: {
|
||||||
templateId,
|
templateId,
|
||||||
organizationName: templateRow.organizationName,
|
organizationName: templateRow.organizationName,
|
||||||
activeVersionId: activeRows[0].id,
|
activeVersionId: activeRows[0].id,
|
||||||
activeVersion: activeRows[0].version,
|
activeVersion: activeRows[0].version,
|
||||||
sourceFile: templateSource.relativePath
|
sourceFile: templateSource.relativePath,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,11 +178,11 @@ async function main() {
|
|||||||
if (rows.length > 1 && inactiveRows.length === 0) {
|
if (rows.length > 1 && inactiveRows.length === 0) {
|
||||||
issues.push({
|
issues.push({
|
||||||
kind: 'MISSING_RETAINED_INACTIVE_VERSION',
|
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: {
|
context: {
|
||||||
templateId,
|
templateId,
|
||||||
organizationName: templateRow.organizationName
|
organizationName: templateRow.organizationName,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,43 +190,67 @@ async function main() {
|
|||||||
for (const row of orphanMappingRows as Array<Record<string, unknown>>) {
|
for (const row of orphanMappingRows as Array<Record<string, unknown>>) {
|
||||||
issues.push({
|
issues.push({
|
||||||
kind: 'MAPPING_WITHOUT_VERSION',
|
kind: 'MAPPING_WITHOUT_VERSION',
|
||||||
message: `Mapping ${String(row.id)} points to a missing template version`,
|
message: `Mapping ${String(row.id)} points to a missing template version.`,
|
||||||
context: row
|
context: row,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const approvedTemplateVersionExists = payload.approvedTemplateVersionId
|
const approvedTemplateVersionExists = payload.approvedTemplateVersionId
|
||||||
? (versionRows as Array<Record<string, unknown>>).some(
|
? (versionRows as Array<Record<string, unknown>>).some(
|
||||||
(row) => String(row.id) === payload.approvedTemplateVersionId
|
(row) => String(row.id) === payload.approvedTemplateVersionId,
|
||||||
)
|
)
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
if (payload.approvedTemplateVersionId && !approvedTemplateVersionExists) {
|
if (payload.approvedTemplateVersionId && !approvedTemplateVersionExists) {
|
||||||
issues.push({
|
issues.push({
|
||||||
kind: 'INVALID_APPROVED_TEMPLATE_VERSION',
|
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: {
|
context: {
|
||||||
quotationCode: payload.quotationCode,
|
quotationCode: payload.quotationCode,
|
||||||
approvedTemplateVersionId: payload.approvedTemplateVersionId
|
approvedTemplateVersionId: payload.approvedTemplateVersionId,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = summarizeIssues(issues.length ? ['FAIL'] : ['PASS']);
|
const overallStatus = summarizeIssues(
|
||||||
const report = {
|
issues.length === 0 ? ['PASS'] : issues.map(() => 'FAIL'),
|
||||||
|
);
|
||||||
|
const artifactPath = await writeAuditArtifact('template-version-report.json', {
|
||||||
audit: 'template-version-integrity',
|
audit: 'template-version-integrity',
|
||||||
overallStatus: status,
|
overallStatus,
|
||||||
activeTemplateCount: activeTemplateVersions.length,
|
activeTemplateCount: activeTemplateVersions.length,
|
||||||
quotationCode: payload.quotationCode,
|
quotationCode: payload.quotationCode,
|
||||||
approvedTemplateVersionId: payload.approvedTemplateVersionId,
|
approvedTemplateVersionId: payload.approvedTemplateVersionId,
|
||||||
approvedTemplateVersionIsActive: activeTemplateVersions.some(
|
approvedTemplateVersionIsActive: payload.approvedTemplateVersionId
|
||||||
(record) => record.versionId === payload.approvedTemplateVersionId
|
? (versionRows as Array<Record<string, unknown>>).some(
|
||||||
),
|
(row) =>
|
||||||
issues
|
String(row.id) === payload.approvedTemplateVersionId && Boolean(row.isActive),
|
||||||
};
|
)
|
||||||
const artifactPath = await writeAuditArtifact('template-version-report.json', report);
|
: 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 {
|
} finally {
|
||||||
await sql.end({ timeout: 5 });
|
await sql.end({ timeout: 5 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface ActiveTemplateVersionRecord {
|
|||||||
fileType: string;
|
fileType: string;
|
||||||
versionId: string;
|
versionId: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
filePath: string | null;
|
||||||
schemaJson: unknown;
|
schemaJson: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +153,7 @@ export const PDFME_STATIC_LABEL_FIELD_KEYS = [
|
|||||||
'project_label',
|
'project_label',
|
||||||
'site_label'
|
'site_label'
|
||||||
] as const;
|
] as const;
|
||||||
|
export type TemplateSourceVariant = 'legacy' | 'product-v1';
|
||||||
export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
|
export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
|
||||||
{
|
{
|
||||||
placeholderKey: 'customer_name',
|
placeholderKey: 'customer_name',
|
||||||
@@ -327,11 +329,18 @@ export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
|
|||||||
formatMask: 'currency'
|
formatMask: 'currency'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
columnName: 'Total',
|
columnName: 'Discount',
|
||||||
sourceField: 'totalPrice',
|
sourceField: 'discount',
|
||||||
columnLetter: 'F',
|
columnLetter: 'F',
|
||||||
sortOrder: 6,
|
sortOrder: 6,
|
||||||
formatMask: 'currency'
|
formatMask: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnName: 'Total',
|
||||||
|
sourceField: 'totalPrice',
|
||||||
|
columnLetter: 'G',
|
||||||
|
sortOrder: 7,
|
||||||
|
formatMask: 'currency'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -665,12 +674,23 @@ export function normalizeTemplateInputAliases(
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadTemplateSourceByOrganizationName(organizationName: string) {
|
export function loadTemplateSourceByOrganizationName(
|
||||||
|
organizationName: string,
|
||||||
|
variant: TemplateSourceVariant = 'legacy',
|
||||||
|
) {
|
||||||
const normalized = organizationName.toUpperCase();
|
const normalized = organizationName.toUpperCase();
|
||||||
const fileName = normalized.includes('ONVALLA')
|
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')
|
: normalized.includes('ALLA')
|
||||||
? 'ALLA_template_pdfme_fainal3.json'
|
? (
|
||||||
|
variant === 'product-v1'
|
||||||
|
? 'ALLA_template_pdfme_product_v1.json'
|
||||||
|
: 'ALLA_template_pdfme_fainal3.json'
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!fileName) {
|
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(
|
export function computeFieldDiff(
|
||||||
currentFields: TemplateFieldRecord[],
|
currentFields: TemplateFieldRecord[],
|
||||||
previousFields: TemplateFieldRecord[]
|
previousFields: TemplateFieldRecord[]
|
||||||
@@ -739,7 +779,7 @@ function normalizeSnapshotPayload(snapshot: unknown) {
|
|||||||
|
|
||||||
export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveTemplateVersionRecord[]> {
|
export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveTemplateVersionRecord[]> {
|
||||||
const rows = await sql`
|
const rows = await sql`
|
||||||
select
|
select
|
||||||
t.id as "templateId",
|
t.id as "templateId",
|
||||||
t.template_name as "templateName",
|
t.template_name as "templateName",
|
||||||
t.organization_id as "organizationId",
|
t.organization_id as "organizationId",
|
||||||
@@ -748,6 +788,7 @@ export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveT
|
|||||||
t.file_type as "fileType",
|
t.file_type as "fileType",
|
||||||
v.id as "versionId",
|
v.id as "versionId",
|
||||||
v.version,
|
v.version,
|
||||||
|
v.file_path as "filePath",
|
||||||
v.schema_json as "schemaJson"
|
v.schema_json as "schemaJson"
|
||||||
from crm_document_templates t
|
from crm_document_templates t
|
||||||
inner join crm_document_template_versions v
|
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() || '-']);
|
.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) {
|
function applyFormatMask(value: unknown, formatMask?: string | null) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return value;
|
return value;
|
||||||
@@ -1436,10 +1526,22 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
|
|||||||
),
|
),
|
||||||
...topicInputs
|
...topicInputs
|
||||||
};
|
};
|
||||||
const normalizedTemplateInput = normalizeTemplateInputAliases(
|
const templateFields = extractTemplateFields(templateVersion.schemaJson);
|
||||||
extractTemplateFields(templateVersion.schemaJson),
|
const hasItemsTableField = templateFields.some((field) => field.name === 'items_table');
|
||||||
templateInput
|
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 {
|
return {
|
||||||
organizationId: quotation.organizationId,
|
organizationId: quotation.organizationId,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
buildSupportedMappingsForFieldNames,
|
buildSupportedMappingsForFieldNames,
|
||||||
bumpMinorTemplateVersion,
|
bumpMinorTemplateVersion,
|
||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
getEffectiveMappedKeys,
|
getEffectiveMappedKeys,
|
||||||
loadTemplateSourceByOrganizationName,
|
loadTemplateSourceByOrganizationName,
|
||||||
summarizeIssues,
|
summarizeIssues,
|
||||||
type TemplateMappingSeed
|
type TemplateMappingSeed,
|
||||||
|
type TemplateSourceVariant,
|
||||||
} from './pdf-audit-utils.ts';
|
} from './pdf-audit-utils.ts';
|
||||||
|
|
||||||
type TemplateRow = {
|
type TemplateRow = {
|
||||||
@@ -31,11 +32,16 @@ type TemplateVersionRow = {
|
|||||||
createdAt: string;
|
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');
|
return createHash('sha256').update(JSON.stringify(schemaJson)).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSchemaJson(schemaJson: unknown) {
|
function normalizeSchemaJson(schemaJson: unknown): unknown {
|
||||||
if (typeof schemaJson === 'string') {
|
if (typeof schemaJson === 'string') {
|
||||||
return JSON.parse(schemaJson) as unknown;
|
return JSON.parse(schemaJson) as unknown;
|
||||||
}
|
}
|
||||||
@@ -43,22 +49,66 @@ function normalizeSchemaJson(schemaJson: unknown) {
|
|||||||
return schemaJson;
|
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(
|
async function upsertMapping(
|
||||||
sql: any,
|
sql: any,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
templateVersionId: string,
|
templateVersionId: string,
|
||||||
mapping: TemplateMappingSeed
|
mapping: TemplateMappingSeed,
|
||||||
) {
|
) {
|
||||||
const existing = (
|
const existing = (
|
||||||
(await sql`
|
await sql`
|
||||||
select id
|
select id
|
||||||
from crm_document_template_mappings
|
from crm_document_template_mappings
|
||||||
where template_version_id = ${templateVersionId}
|
where template_version_id = ${templateVersionId}
|
||||||
and placeholder_key = ${mapping.placeholderKey}
|
and placeholder_key = ${mapping.placeholderKey}
|
||||||
limit 1
|
limit 1
|
||||||
`) as Array<{ id: string }>
|
`
|
||||||
)[0] ?? null;
|
)[0] as { id: string } | undefined;
|
||||||
const mappingId = String(existing?.id ?? crypto.randomUUID());
|
|
||||||
|
const mappingId = existing?.id ?? randomUUID();
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await sql`
|
await sql`
|
||||||
@@ -87,8 +137,7 @@ async function upsertMapping(
|
|||||||
sheet_name,
|
sheet_name,
|
||||||
default_value,
|
default_value,
|
||||||
format_mask,
|
format_mask,
|
||||||
sort_order,
|
sort_order
|
||||||
deleted_at
|
|
||||||
) values (
|
) values (
|
||||||
${mappingId},
|
${mappingId},
|
||||||
${organizationId},
|
${organizationId},
|
||||||
@@ -99,8 +148,7 @@ async function upsertMapping(
|
|||||||
${null},
|
${null},
|
||||||
${mapping.defaultValue ?? null},
|
${mapping.defaultValue ?? null},
|
||||||
${mapping.formatMask ?? null},
|
${mapping.formatMask ?? null},
|
||||||
${mapping.sortOrder},
|
${mapping.sortOrder}
|
||||||
${null}
|
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -108,16 +156,18 @@ async function upsertMapping(
|
|||||||
const desiredColumnNames = new Set((mapping.columns ?? []).map((column) => column.columnName));
|
const desiredColumnNames = new Set((mapping.columns ?? []).map((column) => column.columnName));
|
||||||
|
|
||||||
for (const column of mapping.columns ?? []) {
|
for (const column of mapping.columns ?? []) {
|
||||||
const existingColumn =
|
const existingColumn = (
|
||||||
(
|
await sql`
|
||||||
(await sql`
|
|
||||||
select id
|
select id
|
||||||
from crm_document_template_table_columns
|
from crm_document_template_table_columns
|
||||||
where mapping_id = ${mappingId}
|
where mapping_id = ${mappingId}
|
||||||
and column_name = ${column.columnName}
|
and column_name = ${column.columnName}
|
||||||
|
and deleted_at is null
|
||||||
limit 1
|
limit 1
|
||||||
`) as Array<{ id: string }>
|
`
|
||||||
)[0] ?? null;
|
)[0] as { id: string } | undefined;
|
||||||
|
|
||||||
|
const columnId = existingColumn?.id ?? randomUUID();
|
||||||
|
|
||||||
if (existingColumn) {
|
if (existingColumn) {
|
||||||
await sql`
|
await sql`
|
||||||
@@ -126,15 +176,13 @@ async function upsertMapping(
|
|||||||
organization_id = ${organizationId},
|
organization_id = ${organizationId},
|
||||||
source_field = ${column.sourceField},
|
source_field = ${column.sourceField},
|
||||||
column_letter = ${column.columnLetter ?? null},
|
column_letter = ${column.columnLetter ?? null},
|
||||||
sort_order = ${column.sortOrder},
|
sort_order = ${column.sortOrder ?? 0},
|
||||||
format_mask = ${column.formatMask ?? null},
|
format_mask = ${column.formatMask ?? null},
|
||||||
deleted_at = ${null},
|
deleted_at = ${null},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where id = ${existingColumn.id}
|
where id = ${columnId}
|
||||||
`;
|
`;
|
||||||
continue;
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
insert into crm_document_template_table_columns (
|
insert into crm_document_template_table_columns (
|
||||||
id,
|
id,
|
||||||
@@ -144,28 +192,27 @@ async function upsertMapping(
|
|||||||
source_field,
|
source_field,
|
||||||
column_letter,
|
column_letter,
|
||||||
sort_order,
|
sort_order,
|
||||||
format_mask,
|
format_mask
|
||||||
deleted_at
|
|
||||||
) values (
|
) values (
|
||||||
${crypto.randomUUID()},
|
${columnId},
|
||||||
${organizationId},
|
${organizationId},
|
||||||
${mappingId},
|
${mappingId},
|
||||||
${column.columnName},
|
${column.columnName},
|
||||||
${column.sourceField},
|
${column.sourceField},
|
||||||
${column.columnLetter ?? null},
|
${column.columnLetter ?? null},
|
||||||
${column.sortOrder},
|
${column.sortOrder ?? 0},
|
||||||
${column.formatMask ?? null},
|
${column.formatMask ?? null}
|
||||||
${null}
|
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingColumns = (await sql`
|
const existingColumns = await sql`
|
||||||
select id, column_name as "columnName"
|
select id, column_name as "columnName"
|
||||||
from crm_document_template_table_columns
|
from crm_document_template_table_columns
|
||||||
where mapping_id = ${mappingId}
|
where mapping_id = ${mappingId}
|
||||||
and deleted_at is null
|
and deleted_at is null
|
||||||
`) as Array<{ id: string; columnName: string }>;
|
` as Array<{ id: string; columnName: string }>;
|
||||||
|
|
||||||
for (const column of existingColumns) {
|
for (const column of existingColumns) {
|
||||||
if (desiredColumnNames.has(column.columnName)) {
|
if (desiredColumnNames.has(column.columnName)) {
|
||||||
@@ -174,19 +221,18 @@ async function upsertMapping(
|
|||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
update crm_document_template_table_columns
|
update crm_document_template_table_columns
|
||||||
set
|
set deleted_at = now(), updated_at = now()
|
||||||
deleted_at = now(),
|
|
||||||
updated_at = now()
|
|
||||||
where id = ${column.id}
|
where id = ${column.id}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
const sql = createSqlClient();
|
const sql = createSqlClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const templates = (await sql`
|
const templates = await sql`
|
||||||
select
|
select
|
||||||
t.id,
|
t.id,
|
||||||
t.organization_id as "organizationId",
|
t.organization_id as "organizationId",
|
||||||
@@ -195,30 +241,33 @@ async function main() {
|
|||||||
t.created_by as "createdBy",
|
t.created_by as "createdBy",
|
||||||
t.updated_by as "updatedBy"
|
t.updated_by as "updatedBy"
|
||||||
from crm_document_templates t
|
from crm_document_templates t
|
||||||
inner join organizations o
|
inner join organizations o on o.id = t.organization_id
|
||||||
on o.id = t.organization_id
|
|
||||||
where t.document_type = 'quotation'
|
where t.document_type = 'quotation'
|
||||||
and t.file_type = 'pdfme'
|
and t.file_type = 'pdfme'
|
||||||
and t.product_type = 'default'
|
and t.product_type = 'default'
|
||||||
and t.deleted_at is null
|
and t.deleted_at is null
|
||||||
order by o.name asc, t.created_at asc
|
order by o.name asc, t.created_at asc
|
||||||
`) as TemplateRow[];
|
` as TemplateRow[];
|
||||||
const results = [];
|
|
||||||
|
const results: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (const template of templates) {
|
for (const template of templates) {
|
||||||
const templateSource = loadTemplateSourceByOrganizationName(template.organizationName);
|
const templateSource = loadTemplateSourceByOrganizationName(
|
||||||
|
template.organizationName,
|
||||||
|
options.variant,
|
||||||
|
);
|
||||||
|
|
||||||
if (!templateSource) {
|
if (!templateSource) {
|
||||||
results.push({
|
results.push({
|
||||||
organizationName: template.organizationName,
|
organizationName: template.organizationName,
|
||||||
templateName: template.templateName,
|
templateName: template.templateName,
|
||||||
status: 'WARNING',
|
status: 'WARNING',
|
||||||
message: 'No source template file found for organization'
|
message: 'No source template file found for organization.',
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const versions = (await sql`
|
const versions = await sql`
|
||||||
select
|
select
|
||||||
id,
|
id,
|
||||||
template_id as "templateId",
|
template_id as "templateId",
|
||||||
@@ -231,32 +280,37 @@ async function main() {
|
|||||||
where template_id = ${template.id}
|
where template_id = ${template.id}
|
||||||
and deleted_at is null
|
and deleted_at is null
|
||||||
order by created_at asc
|
order by created_at asc
|
||||||
`) as TemplateVersionRow[];
|
` as TemplateVersionRow[];
|
||||||
|
|
||||||
const activeVersion = versions.find((version) => version.isActive) ?? versions.at(-1) ?? null;
|
const activeVersion = versions.find((version) => version.isActive) ?? versions.at(-1) ?? null;
|
||||||
|
|
||||||
if (!activeVersion) {
|
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 targetSchema = templateSource.schemaJson;
|
||||||
const targetSchemaHash = computeSchemaHash(targetSchema);
|
const targetSchemaHash = computeSchemaHash(targetSchema);
|
||||||
const matchingVersion =
|
const matchingVersion = versions.find(
|
||||||
versions.find(
|
(version) => computeSchemaHash(normalizeSchemaJson(version.schemaJson)) === targetSchemaHash,
|
||||||
(version) => computeSchemaHash(normalizeSchemaJson(version.schemaJson)) === targetSchemaHash
|
|
||||||
) ?? null;
|
) ?? null;
|
||||||
const previousFields = extractTemplateFields(normalizeSchemaJson(activeVersion.schemaJson));
|
const previousFields = extractTemplateFields(normalizeSchemaJson(activeVersion.schemaJson));
|
||||||
const targetFields = extractTemplateFields(targetSchema);
|
const targetFields = extractTemplateFields(targetSchema);
|
||||||
const targetFieldNames = new Set(targetFields.map((field) => field.name).filter(Boolean));
|
const targetFieldNames = new Set(targetFields.map((field) => field.name).filter(Boolean));
|
||||||
const targetTokens = new Set(targetFields.flatMap((field) => field.tokens));
|
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 mappedKeys = getEffectiveMappedKeys(targetFields, supportedMappings);
|
||||||
const fieldDiff = computeFieldDiff(targetFields, previousFields);
|
const fieldDiff = computeFieldDiff(targetFields, previousFields);
|
||||||
const createdNewVersion = !matchingVersion;
|
const createdNewVersion = !matchingVersion;
|
||||||
const targetVersionLabel = matchingVersion
|
const targetVersionLabel = resolveVersionLabel({
|
||||||
? matchingVersion.version
|
variant: options.variant,
|
||||||
: bumpMinorTemplateVersion(versions.at(-1)?.version ?? activeVersion.version);
|
versions,
|
||||||
|
activeVersion,
|
||||||
const targetVersionId = matchingVersion?.id ?? crypto.randomUUID();
|
matchingVersion,
|
||||||
|
});
|
||||||
|
const targetVersionId = matchingVersion?.id ?? randomUUID();
|
||||||
|
|
||||||
await sql.begin(async (tx) => {
|
await sql.begin(async (tx) => {
|
||||||
if (!matchingVersion) {
|
if (!matchingVersion) {
|
||||||
@@ -280,7 +334,7 @@ async function main() {
|
|||||||
${templateSource.relativePath},
|
${templateSource.relativePath},
|
||||||
${JSON.stringify(targetSchema)},
|
${JSON.stringify(targetSchema)},
|
||||||
${null},
|
${null},
|
||||||
${true},
|
${options.activate},
|
||||||
${null},
|
${null},
|
||||||
${template.updatedBy || template.createdBy}
|
${template.updatedBy || template.createdBy}
|
||||||
)
|
)
|
||||||
@@ -290,12 +344,13 @@ async function main() {
|
|||||||
update crm_document_template_versions
|
update crm_document_template_versions
|
||||||
set
|
set
|
||||||
file_path = ${templateSource.relativePath},
|
file_path = ${templateSource.relativePath},
|
||||||
is_active = ${true},
|
is_active = ${options.activate ? true : matchingVersion.isActive},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where id = ${targetVersionId}
|
where id = ${targetVersionId}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.activate) {
|
||||||
await tx`
|
await tx`
|
||||||
update crm_document_template_versions
|
update crm_document_template_versions
|
||||||
set
|
set
|
||||||
@@ -304,18 +359,19 @@ async function main() {
|
|||||||
where template_id = ${template.id}
|
where template_id = ${template.id}
|
||||||
and deleted_at is null
|
and deleted_at is null
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
for (const mapping of supportedMappings) {
|
for (const mapping of supportedMappings) {
|
||||||
await upsertMapping(tx, template.organizationId, targetVersionId, mapping);
|
await upsertMapping(tx, template.organizationId, targetVersionId, mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
const desiredKeys = new Set(supportedMappings.map((mapping) => mapping.placeholderKey));
|
const desiredKeys = new Set(supportedMappings.map((mapping) => mapping.placeholderKey));
|
||||||
const existingMappings = (await tx`
|
const existingMappings = await tx`
|
||||||
select id, placeholder_key as "placeholderKey"
|
select id, placeholder_key as "placeholderKey"
|
||||||
from crm_document_template_mappings
|
from crm_document_template_mappings
|
||||||
where template_version_id = ${targetVersionId}
|
where template_version_id = ${targetVersionId}
|
||||||
and deleted_at is null
|
and deleted_at is null
|
||||||
`) as Array<{ id: string; placeholderKey: string }>;
|
` as Array<{ id: string; placeholderKey: string }>;
|
||||||
|
|
||||||
for (const mapping of existingMappings) {
|
for (const mapping of existingMappings) {
|
||||||
if (desiredKeys.has(mapping.placeholderKey)) {
|
if (desiredKeys.has(mapping.placeholderKey)) {
|
||||||
@@ -324,9 +380,7 @@ async function main() {
|
|||||||
|
|
||||||
await tx`
|
await tx`
|
||||||
update crm_document_template_mappings
|
update crm_document_template_mappings
|
||||||
set
|
set deleted_at = now(), updated_at = now()
|
||||||
deleted_at = now(),
|
|
||||||
updated_at = now()
|
|
||||||
where id = ${mapping.id}
|
where id = ${mapping.id}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -338,16 +392,18 @@ async function main() {
|
|||||||
const classification = classifyTemplateField(field, mappedKeys);
|
const classification = classifyTemplateField(field, mappedKeys);
|
||||||
classificationCounts.set(
|
classificationCounts.set(
|
||||||
classification,
|
classification,
|
||||||
(classificationCounts.get(classification) ?? 0) + 1
|
(classificationCounts.get(classification) ?? 0) + 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
organizationName: template.organizationName,
|
organizationName: template.organizationName,
|
||||||
templateName: template.templateName,
|
templateName: template.templateName,
|
||||||
|
variant: options.variant,
|
||||||
|
activated: options.activate,
|
||||||
sourceFile: templateSource.relativePath,
|
sourceFile: templateSource.relativePath,
|
||||||
previousActiveVersion: activeVersion.version,
|
previousActiveVersion: activeVersion.version,
|
||||||
activeVersion: targetVersionLabel,
|
targetVersion: targetVersionLabel,
|
||||||
createdNewVersion,
|
createdNewVersion,
|
||||||
retainedInactiveVersions: versions.filter((version) => version.id !== targetVersionId).length,
|
retainedInactiveVersions: versions.filter((version) => version.id !== targetVersionId).length,
|
||||||
deletedFields: fieldDiff.deletedFields,
|
deletedFields: fieldDiff.deletedFields,
|
||||||
@@ -355,14 +411,19 @@ async function main() {
|
|||||||
mappingKeys: supportedMappings.map((mapping) => mapping.placeholderKey),
|
mappingKeys: supportedMappings.map((mapping) => mapping.placeholderKey),
|
||||||
classificationCounts: Object.fromEntries(classificationCounts.entries()),
|
classificationCounts: Object.fromEntries(classificationCounts.entries()),
|
||||||
status: summarizeIssues([
|
status: summarizeIssues([
|
||||||
fieldDiff.deletedFields.includes('exclusion_data') ? 'PASS' : 'PASS',
|
supportedMappings.some((mapping) => mapping.placeholderKey === 'tel_label')
|
||||||
supportedMappings.some((mapping) => mapping.placeholderKey === 'tel_label') ? 'PASS' : 'FAIL'
|
? 'PASS'
|
||||||
])
|
: 'FAIL',
|
||||||
|
options.variant === 'product-v1' &&
|
||||||
|
supportedMappings.some((mapping) => mapping.placeholderKey === 'items_table')
|
||||||
|
? 'PASS'
|
||||||
|
: 'PASS',
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const overallStatus = summarizeIssues(
|
const overallStatus = summarizeIssues(
|
||||||
results.map((result) => result.status as 'PASS' | 'WARNING' | 'FAIL')
|
results.map((result) => String(result.status) as 'PASS' | 'WARNING' | 'FAIL'),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -370,12 +431,14 @@ async function main() {
|
|||||||
{
|
{
|
||||||
action: 'reseed-pdf-template-version',
|
action: 'reseed-pdf-template-version',
|
||||||
overallStatus,
|
overallStatus,
|
||||||
templateCount: results.length,
|
variant: options.variant,
|
||||||
results
|
activate: options.activate,
|
||||||
|
resultCount: results.length,
|
||||||
|
results,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await sql.end({ timeout: 5 });
|
await sql.end({ timeout: 5 });
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
import type { Template } from '@pdfme/common';
|
import type { Template } from '@pdfme/common';
|
||||||
import type { ResolvedDocumentTemplate } from '@/features/foundation/document-template/types';
|
import type { ResolvedDocumentTemplate } from '@/features/foundation/document-template/types';
|
||||||
import type { QuotationDocumentData } from '../types';
|
import type { QuotationDocumentData } from '../types';
|
||||||
import { buildQuotationPdfRuntime } from './quotation-pdf-runtime';
|
import { buildQuotationPdfRuntime } from './quotation-pdf-runtime';
|
||||||
import { resolveTemplatePages } from './page-resolver';
|
import { resolveTemplatePages } from './page-resolver';
|
||||||
import { assembleTemplate } from './template-assembler';
|
|
||||||
import type { ResolvedTemplate } from './runtime-contracts';
|
import type { ResolvedTemplate } from './runtime-contracts';
|
||||||
import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter';
|
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 {
|
return {
|
||||||
name,
|
name,
|
||||||
type: 'text',
|
type,
|
||||||
position: { x: 10, y },
|
position: { x: 10, y },
|
||||||
width: 100,
|
width: 100,
|
||||||
height,
|
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: [
|
schemas: [
|
||||||
[
|
[
|
||||||
createField('customer_name', 20),
|
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('quotation_code_data', 70),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
createField('topic', 35),
|
createField('topic', 35, 10, 'table', '[["Topic:"]]'),
|
||||||
createField('data_topic', 45, 7),
|
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('Please_do_not', 225),
|
||||||
createField('yours_faithfuly', 232),
|
createField('yours_faithfuly', 232),
|
||||||
createField('app1', 240),
|
createField('app1', 240),
|
||||||
@@ -53,7 +107,7 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
|
|||||||
documentType: 'quotation',
|
documentType: 'quotation',
|
||||||
productType: 'default',
|
productType: 'default',
|
||||||
fileType: 'pdfme',
|
fileType: 'pdfme',
|
||||||
templateName: 'Legacy quotation',
|
templateName: 'Quotation template',
|
||||||
description: null,
|
description: null,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -64,7 +118,7 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
|
|||||||
updatedBy: 'system',
|
updatedBy: 'system',
|
||||||
},
|
},
|
||||||
version: {
|
version: {
|
||||||
id: 'version-1',
|
id: 'template-version-1',
|
||||||
organizationId: 'org-1',
|
organizationId: 'org-1',
|
||||||
templateId: 'template-1',
|
templateId: 'template-1',
|
||||||
version: '1.0',
|
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 {
|
return {
|
||||||
company: {
|
company: {
|
||||||
id: 'company-1',
|
id: 'company-1',
|
||||||
@@ -128,7 +201,7 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
|
|||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
notes: null,
|
notes: null,
|
||||||
},
|
},
|
||||||
items: [],
|
items: Array.from({ length: itemCount }, (_, index) => createItem(index)),
|
||||||
quotationCustomers: [],
|
quotationCustomers: [],
|
||||||
topics: {
|
topics: {
|
||||||
scope: [],
|
scope: [],
|
||||||
@@ -157,7 +230,7 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
|
|||||||
location: 'Location',
|
location: 'Location',
|
||||||
},
|
},
|
||||||
quotation_date: '2026-06-29',
|
quotation_date: '2026-06-29',
|
||||||
quotation_price: '0.00',
|
quotation_price: 'THB 0.00',
|
||||||
quotation_price_data: [],
|
quotation_price_data: [],
|
||||||
exclusion_data: [],
|
exclusion_data: [],
|
||||||
topic_inputs: {},
|
topic_inputs: {},
|
||||||
@@ -172,24 +245,15 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
|
|||||||
approvers: [],
|
approvers: [],
|
||||||
},
|
},
|
||||||
signatures: {
|
signatures: {
|
||||||
preparedBy: {
|
preparedBy: { name: 'Prepared', position: 'Sales' },
|
||||||
name: 'Prepared',
|
approvedBy: { name: 'Approved', position: 'Manager' },
|
||||||
position: 'Sales',
|
authorizedBy: { name: 'Authorized', position: 'Director' },
|
||||||
},
|
|
||||||
approvedBy: {
|
|
||||||
name: 'Approved',
|
|
||||||
position: 'Manager',
|
|
||||||
},
|
|
||||||
authorizedBy: {
|
|
||||||
name: 'Authorized',
|
|
||||||
position: 'Director',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
watermarkStatus: null,
|
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 schema = createLegacyTemplate();
|
||||||
const template = createResolvedDocumentTemplate(schema);
|
const template = createResolvedDocumentTemplate(schema);
|
||||||
const runtimeTemplate: ResolvedTemplate = {
|
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.customer?.[0]?.pageIndex, 0);
|
||||||
assert.equal(resolved.byRole.topics?.[0]?.pageIndex, 1);
|
assert.equal(resolved.byRole.topics?.[0]?.pageIndex, 1);
|
||||||
assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 1);
|
assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 1);
|
||||||
assert.ok(
|
assert.ok(compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'));
|
||||||
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 schema = createLegacyTemplate();
|
||||||
const template = createResolvedDocumentTemplate(schema);
|
const template = createResolvedDocumentTemplate(schema);
|
||||||
const runtime = await buildQuotationPdfRuntime({
|
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(runtime.assembled.template.schemas.length > 2);
|
||||||
assert.ok(
|
assert.ok(Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')));
|
||||||
Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')),
|
|
||||||
);
|
|
||||||
|
|
||||||
const topicPages = runtime.assembled.template.schemas.slice(1);
|
const topicPages = runtime.assembled.template.schemas.slice(1);
|
||||||
const lastPageNames = new Set(
|
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.att_label, 'Att');
|
||||||
assert.equal(runtime.assembled.templateInput.project_label, 'Project');
|
assert.equal(runtime.assembled.templateInput.project_label, 'Project');
|
||||||
assert.equal(runtime.assembled.templateInput.site_label, 'Location');
|
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', () => {
|
test('explicit section markers resolve product item page and runtime injects items_table', async () => {
|
||||||
const assembled = assembleTemplate({
|
const schema = createProductTemplate();
|
||||||
template: 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.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: {
|
templateInput: {
|
||||||
tel_label: 'Tel',
|
|
||||||
customer_name: 'Customer',
|
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.ok(runtime.assembled.template.schemas.length >= 3);
|
||||||
assert.equal(assembled.templateInput.email_label, 'Email');
|
const productPageNames = new Set(
|
||||||
assert.equal(assembled.templateInput.project_label, 'Project');
|
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 type { QuotationDocumentData } from '../types';
|
||||||
import { buildPdfTopicPages } from './pdf-topic-engine';
|
import { buildPdfTopicPages } from './pdf-topic-engine';
|
||||||
import { resolveTemplatePages } from './page-resolver';
|
import { resolveTemplatePages } from './page-resolver';
|
||||||
@@ -21,6 +25,29 @@ function cloneTemplate<T>(value: T): T {
|
|||||||
return JSON.parse(JSON.stringify(value)) as 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 {
|
export function createCustomerSectionBuilder(): SectionBuilder {
|
||||||
return {
|
return {
|
||||||
role: 'customer',
|
role: 'customer',
|
||||||
@@ -29,21 +56,12 @@ export function createCustomerSectionBuilder(): SectionBuilder {
|
|||||||
const policy = context.policyBySection.get('customer');
|
const policy = context.policyBySection.get('customer');
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return {
|
return buildMissingPageSection({
|
||||||
role: 'customer',
|
role: 'customer',
|
||||||
enabled: policy?.enabled ?? false,
|
enabled: policy?.enabled ?? false,
|
||||||
rendered: false,
|
required: policy?.required ?? true,
|
||||||
anchorPageIndex: null,
|
|
||||||
pages: [],
|
|
||||||
templateInputPatch: {},
|
|
||||||
issues: [
|
|
||||||
{
|
|
||||||
code: 'MISSING_MARKER',
|
|
||||||
severity: policy?.required ? 'error' : 'warning',
|
|
||||||
message: 'Customer section page could not be resolved.',
|
message: 'Customer section page could not be resolved.',
|
||||||
},
|
});
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -63,18 +81,45 @@ export function createProductItemSectionBuilder(): SectionBuilder {
|
|||||||
return {
|
return {
|
||||||
role: 'product_items',
|
role: 'product_items',
|
||||||
build(context: RenderContext): ProductItemSection {
|
build(context: RenderContext): ProductItemSection {
|
||||||
|
const page = context.pages.byRole.product_items?.[0] ?? null;
|
||||||
|
const policy = context.policyBySection.get('product_items');
|
||||||
const built = buildProductItemSectionModel({
|
const built = buildProductItemSectionModel({
|
||||||
items: context.documentData.items,
|
items: context.documentData.items,
|
||||||
currencyCode: context.documentData.quotation.currencyCode,
|
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 {
|
return {
|
||||||
role: 'product_items',
|
role: 'product_items',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
rendered: false,
|
rendered: true,
|
||||||
anchorPageIndex: null,
|
anchorPageIndex: page.pageIndex,
|
||||||
pages: [],
|
pages: [cloneTemplate(page.schema)],
|
||||||
templateInputPatch: {},
|
templateInputPatch: {
|
||||||
|
items_table: built.tableModel.rows,
|
||||||
|
},
|
||||||
issues: built.issues,
|
issues: built.issues,
|
||||||
rows: built.rows,
|
rows: built.rows,
|
||||||
tableModel: built.tableModel,
|
tableModel: built.tableModel,
|
||||||
@@ -92,21 +137,12 @@ export function createTopicSectionBuilder(): SectionBuilder {
|
|||||||
const policy = context.policyBySection.get('topics');
|
const policy = context.policyBySection.get('topics');
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return {
|
return buildMissingPageSection({
|
||||||
role: 'topics',
|
role: 'topics',
|
||||||
enabled: policy?.enabled ?? false,
|
enabled: policy?.enabled ?? false,
|
||||||
rendered: false,
|
required: policy?.required ?? true,
|
||||||
anchorPageIndex: null,
|
|
||||||
pages: [],
|
|
||||||
templateInputPatch: {},
|
|
||||||
issues: [
|
|
||||||
{
|
|
||||||
code: 'MISSING_MARKER',
|
|
||||||
severity: policy?.required ? 'error' : 'warning',
|
|
||||||
message: 'Topic section page could not be resolved.',
|
message: 'Topic section page could not be resolved.',
|
||||||
},
|
});
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const built = buildPdfTopicPages(page.schema, context.documentData.topics.all, {
|
const built = buildPdfTopicPages(page.schema, context.documentData.topics.all, {
|
||||||
@@ -137,7 +173,7 @@ export function getQuotationSectionBuilders(): SectionBuilder[] {
|
|||||||
function createResolvedTemplate(template: ResolvedDocumentTemplate): ResolvedTemplate {
|
function createResolvedTemplate(template: ResolvedDocumentTemplate): ResolvedTemplate {
|
||||||
return {
|
return {
|
||||||
documentTemplate: template,
|
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 resolvedTemplate = createResolvedTemplate(args.template);
|
||||||
const compatibleTemplate = adaptTemplateForSectionRuntime(resolvedTemplate);
|
const compatibleTemplate = adaptTemplateForSectionRuntime(resolvedTemplate);
|
||||||
const pages = resolveTemplatePages(compatibleTemplate);
|
const pages = resolveTemplatePages(compatibleTemplate);
|
||||||
const policies = resolveRenderPolicies();
|
const policies = resolveRenderPolicies({
|
||||||
|
pages,
|
||||||
|
documentData: args.documentData,
|
||||||
|
});
|
||||||
const staticInputPatch = resolveQuotationStaticTemplateInputs(args.documentData);
|
const staticInputPatch = resolveQuotationStaticTemplateInputs(args.documentData);
|
||||||
const context = createRenderContext({
|
const context = createRenderContext({
|
||||||
documentData: args.documentData,
|
documentData: args.documentData,
|
||||||
@@ -172,7 +211,9 @@ export async function buildQuotationPdfRuntime(args: {
|
|||||||
sections: composed.sections,
|
sections: composed.sections,
|
||||||
});
|
});
|
||||||
const issues = [...pages.issues, ...composed.issues, ...assembled.issues];
|
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')) {
|
if (issues.some((issue) => issue.severity === 'error')) {
|
||||||
throw new Error(createRuntimeErrorMessage(), { cause: issues });
|
throw new Error(createRuntimeErrorMessage(), { cause: issues });
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import type {
|
|||||||
} from './runtime-contracts';
|
} from './runtime-contracts';
|
||||||
import { SECTION_ORDER } 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) {
|
switch (section) {
|
||||||
case 'customer':
|
case 'customer':
|
||||||
return {
|
return {
|
||||||
@@ -19,6 +22,13 @@ function buildDefaultPolicy(section: SectionRole): RenderPolicy {
|
|||||||
required: true,
|
required: true,
|
||||||
visibleWhenEmpty: true,
|
visibleWhenEmpty: true,
|
||||||
};
|
};
|
||||||
|
case 'product_items':
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
enabled: Boolean(args?.pages?.byRole.product_items?.length),
|
||||||
|
required: false,
|
||||||
|
visibleWhenEmpty: true,
|
||||||
|
};
|
||||||
case 'topics':
|
case 'topics':
|
||||||
return {
|
return {
|
||||||
section,
|
section,
|
||||||
@@ -36,8 +46,11 @@ function buildDefaultPolicy(section: SectionRole): RenderPolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveRenderPolicies(): RenderPolicy[] {
|
export function resolveRenderPolicies(args?: {
|
||||||
return SECTION_ORDER.map((section) => buildDefaultPolicy(section));
|
pages?: ResolvedPages;
|
||||||
|
documentData?: QuotationDocumentData;
|
||||||
|
}): RenderPolicy[] {
|
||||||
|
return SECTION_ORDER.map((section) => buildDefaultPolicy(section, args));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRenderContext(args: {
|
export function createRenderContext(args: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
SectionRole,
|
SectionRole,
|
||||||
} from './runtime-contracts';
|
} 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[]> = {
|
const LEGACY_MARKERS: Record<Exclude<SectionRole, 'unknown'>, string[]> = {
|
||||||
customer: ['customer_name', 'quotation_price_data', 'quotation_code_data'],
|
customer: ['customer_name', 'quotation_price_data', 'quotation_code_data'],
|
||||||
@@ -48,7 +48,7 @@ function isTemplatePageArray(
|
|||||||
function getFieldNames(page: Template['schemas'][number]): string[] {
|
function getFieldNames(page: Template['schemas'][number]): string[] {
|
||||||
return page
|
return page
|
||||||
.map((field) => {
|
.map((field) => {
|
||||||
if (typeof field !== 'object' || field === null || !('name' in field)) {
|
if (!field || typeof field !== 'object' || !('name' in field)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,60 +57,8 @@ function getFieldNames(page: Template['schemas'][number]): string[] {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExplicitRole(page: Template['schemas'][number]): SectionRole | null {
|
function isSectionRole(value: string | null | undefined): value is SectionRole {
|
||||||
for (const field of page) {
|
return value !== undefined && value !== null && (
|
||||||
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 === 'customer' ||
|
||||||
value === 'product_items' ||
|
value === 'product_items' ||
|
||||||
value === 'topics' ||
|
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(
|
export function adaptTemplateForSectionRuntime(
|
||||||
template: ResolvedTemplate,
|
template: ResolvedTemplate,
|
||||||
): CompatibleTemplate {
|
): CompatibleTemplate {
|
||||||
const issues: RuntimeIssue[] = [];
|
|
||||||
const schemaPages = template.schema.schemas;
|
const schemaPages = template.schema.schemas;
|
||||||
|
const issues: RuntimeIssue[] = [];
|
||||||
|
|
||||||
if (!Array.isArray(schemaPages)) {
|
if (!Array.isArray(schemaPages)) {
|
||||||
return {
|
return {
|
||||||
@@ -159,15 +152,18 @@ export function adaptTemplateForSectionRuntime(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const explicitRole = getExplicitRole(rawPage);
|
const explicitRoles = getExplicitRoles(rawPage);
|
||||||
const inferredRoles = inferLegacyRoles(rawPage);
|
const inferredRoles = inferLegacyRoles(rawPage);
|
||||||
const primaryRole = explicitRole ?? inferredRoles[0] ?? 'unknown';
|
const primaryRole = explicitRoles[0] ?? inferredRoles[0] ?? 'unknown';
|
||||||
const roles = Array.from(
|
const roles = Array.from(
|
||||||
new Set([primaryRole, ...inferredRoles].filter(isSectionRole)),
|
new Set(
|
||||||
|
[primaryRole, ...explicitRoles, ...inferredRoles].filter(isSectionRole),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
let strategy: CompatibleTemplatePage['strategy'] = 'fallback';
|
let strategy: CompatibleTemplatePage['strategy'] = 'fallback';
|
||||||
if (explicitRole) {
|
|
||||||
|
if (explicitRoles.length > 0) {
|
||||||
strategy = 'explicit_marker';
|
strategy = 'explicit_marker';
|
||||||
} else if (inferredRoles.length > 0) {
|
} else if (inferredRoles.length > 0) {
|
||||||
strategy = 'legacy_inference';
|
strategy = 'legacy_inference';
|
||||||
@@ -175,8 +171,7 @@ export function adaptTemplateForSectionRuntime(
|
|||||||
createIssue({
|
createIssue({
|
||||||
code: 'LEGACY_COMPAT_MODE',
|
code: 'LEGACY_COMPAT_MODE',
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
message:
|
message: 'PDF template page role inferred from legacy field markers.',
|
||||||
'PDF template page role was inferred from legacy field markers.',
|
|
||||||
details: { pageIndex, roles },
|
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