task p-4.5

This commit is contained in:
phaichayon
2026-06-29 16:07:03 +07:00
parent aa73b9aed2
commit a1cac84e2b
19 changed files with 4812 additions and 413 deletions

View File

@@ -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": {

View File

@@ -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

View File

@@ -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": [

View File

@@ -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

View 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

View File

@@ -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
View 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.

View File

@@ -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];

View File

@@ -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')
); );

View File

@@ -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));
} }

View File

@@ -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 });
} }

View File

@@ -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[]
@@ -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,9 +1526,21 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
), ),
...topicInputs ...topicInputs
}; };
const templateFields = extractTemplateFields(templateVersion.schemaJson);
const hasItemsTableField = templateFields.some((field) => field.name === 'items_table');
const normalizedTemplateInput = normalizeTemplateInputAliases( const normalizedTemplateInput = normalizeTemplateInputAliases(
extractTemplateFields(templateVersion.schemaJson), templateFields,
templateInput {
...templateInput,
...(hasItemsTableField
? {
items_table: buildAuditItemsTable(
(documentData as { items: Parameters<typeof buildAuditItemsTable>[0] }).items,
(documentData as { quotation: { currencyCode?: string | null } }).quotation.currencyCode
)
}
: {}),
}
); );
return { return {

View File

@@ -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 });

View File

@@ -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',
]);
}); });

View File

@@ -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 });

View File

@@ -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: {

View File

@@ -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 },
}), }),
); );

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long