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",
"quotationCode": "QT2606-1122",
"artifacts": {

View File

@@ -1,6 +1,6 @@
# PDF Integrity Summary
- Generated At: 2026-06-29T07:31:40.763Z
- Generated At: 2026-06-29T08:14:00.088Z
- Overall Status: PASS
- Quotation Code: QT2606-1122

View File

@@ -1,5 +1,5 @@
{
"generatedAt": "2026-06-29T07:31:39.763Z",
"generatedAt": "2026-06-29T08:13:58.800Z",
"overallStatus": "PASS",
"quotationCode": "QT2606-1122",
"expectedFixtureCode": "QT-H5-AUDIT",
@@ -8,54 +8,23 @@
"version": "1.0",
"versionId": "6c375f16-3f89-4987-88b0-c8ebe48aeb89",
"fieldCount": 44,
"previousVersion": null,
"deletedFields": [],
"newFields": [
"Please_do_not",
"app1",
"app1_position",
"app2",
"app2_position",
"app3",
"app3_position",
"att_label",
"colon",
"colon copy",
"colon_att",
"colon_email",
"colon_fax",
"colon_project",
"colon_site",
"colon_tel",
"company1",
"company2",
"company_addr1",
"company_addr2",
"company_email",
"company_tax",
"company_tel",
"customer_addr",
"customer_att",
"customer_email",
"customer_name",
"customer_tel",
"data_topic",
"dear_sirs",
"email_label",
"line",
"project_label",
"project_name",
"quotation_code_data",
"quotation_code_lable",
"quotation_date_data",
"quotation_date_labe",
"quotation_price_data",
"site_label",
"site_location",
"tel_label",
"topic",
"yours_faithfuly"
"previousVersion": "2.0",
"deletedFields": [
"__section_role__customer",
"__section_role__product_items",
"__section_role__signature",
"__section_role__topics",
"items_table",
"product_items_currency_value",
"product_items_footer_note",
"product_items_line_168",
"product_items_line_35",
"product_items_subtitle",
"product_items_title",
"product_items_total_label",
"product_items_total_value"
],
"newFields": [],
"duplicateFields": [],
"unknownFields": [],
"classifications": [

View File

@@ -1,10 +1,10 @@
# PDF Audit Report
- Generated At: 2026-06-29T07:31:39.763Z
- Generated At: 2026-06-29T08:13:58.800Z
- Overall Status: PASS
- Quotation Code: QT2606-1122
- Template: ALLA Quotation Standard v1.0
- Previous Version: None
- Previous Version: 2.0
## Metrics

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
```
Product Item template version:
```bash
# create product-v1 as inactive version
node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1
# create and activate product-v1 immediately
node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate
# restore legacy 1.0 as active version
npm run seed:pdf-template
```
## Verification
```bash

413
plans/task-p.4.5.md Normal file
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 priceTableRows = Array.isArray(priceTable) ? priceTable : [];
const firstPriceRow = Array.isArray(priceTableRows[0]) ? (priceTableRows[0] as unknown[]) : [];
const itemsTable = payload.templateInput.items_table;
const itemsTableRows = Array.isArray(itemsTable) ? itemsTable : [];
const firstItemsRow = Array.isArray(itemsTableRows[0]) ? (itemsTableRows[0] as unknown[]) : [];
pushFinding(findings, {
field: 'templateInput.quotation_price_data',
@@ -172,6 +175,24 @@ async function main() {
value: firstPriceRow[3] ?? null,
message: 'Price table currency code cell is populated'
});
if (itemsTable !== undefined) {
pushFinding(findings, {
field: 'templateInput.items_table',
source: 'templateInput',
status: Array.isArray(itemsTable) ? 'PASS' : 'FAIL',
value: Array.isArray(itemsTable) ? `rows:${itemsTable.length}` : itemsTable,
message: Array.isArray(itemsTable)
? 'Product items table input is present'
: 'Product items table input is missing or not table'
});
pushFinding(findings, {
field: 'templateInput.items_table[0]',
source: 'templateInput',
status: firstItemsRow.length === 7 ? 'PASS' : 'FAIL',
value: firstItemsRow.length,
message: 'Product items table row shape uses seven columns'
});
}
const staticLabelFindings = [...PDFME_STATIC_LABEL_FIELD_KEYS].map((fieldName) => {
const value = payload.templateInput[fieldName];

View File

@@ -41,7 +41,10 @@ async function main() {
const duplicateFields = getDuplicateFieldNames(fields);
const unknownFields = getUnknownFieldNames(fields, mappedKeys);
const fieldDiff = computeFieldDiff(fields, previousFields);
const requiredTableFields = ['quotation_price_data'];
const hasProductItemsMarker = fieldNames.has('__section_role__product_items');
const requiredTableFields = hasProductItemsMarker
? ['items_table']
: ['quotation_price_data'];
const missingRequiredTableFields = requiredTableFields.filter(
(fieldName) => !fields.some((field) => field.name === fieldName && field.type === 'table')
);

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']);
function detectTypo(fieldName: string) {
if (fieldName.startsWith('__section_role__')) {
return false;
}
return TYPO_PATTERNS.some((pattern) => pattern.test(fieldName));
}

View File

@@ -3,8 +3,9 @@ import {
createSqlClient,
getActiveTemplateVersions,
loadTemplateSourceByOrganizationName,
loadTemplateSourceByRelativePath,
summarizeIssues,
writeAuditArtifact
writeAuditArtifact,
} from './pdf-audit-utils.ts';
type IntegrityIssue = {
@@ -29,7 +30,7 @@ async function main() {
versionRows,
orphanMappingRows,
payload,
templateRows
templateRows,
] = await Promise.all([
getActiveTemplateVersions(sql),
sql`
@@ -39,14 +40,13 @@ async function main() {
v.organization_id as "organizationId",
o.name as "organizationName",
v.version,
v.file_path as "filePath",
v.is_active as "isActive",
v.schema_json as "schemaJson",
t.id as "resolvedTemplateId"
from crm_document_template_versions v
left join crm_document_templates t
on t.id = v.template_id
left join organizations o
on o.id = v.organization_id
left join crm_document_templates t on t.id = v.template_id
left join organizations o on o.id = v.organization_id
where v.deleted_at is null
`,
sql`
@@ -55,8 +55,7 @@ async function main() {
m.template_version_id as "templateVersionId",
m.placeholder_key as "placeholderKey"
from crm_document_template_mappings m
left join crm_document_template_versions v
on v.id = m.template_version_id
left join crm_document_template_versions v on v.id = m.template_version_id
where m.deleted_at is null
and (v.id is null or v.deleted_at is not null)
`,
@@ -68,47 +67,40 @@ async function main() {
o.name as "organizationName",
t.template_name as "templateName"
from crm_document_templates t
inner join organizations o
on o.id = t.organization_id
inner join organizations o on o.id = t.organization_id
where t.deleted_at is null
and t.document_type = 'quotation'
and t.file_type = 'pdfme'
`
`,
]);
const issues: IntegrityIssue[] = [];
const templateBuckets = new Map<string, typeof activeTemplateVersions>();
for (const record of activeTemplateVersions) {
const bucketKey = [
record.organizationId,
record.documentType,
record.productType,
record.fileType
].join(':');
const items = templateBuckets.get(bucketKey) ?? [];
items.push(record);
templateBuckets.set(bucketKey, items);
const issues: IntegrityIssue[] = [];
const templateBuckets = new Map<string, Array<Record<string, unknown>>>();
for (const record of activeTemplateVersions as unknown as Array<Record<string, unknown>>) {
const bucketKey = `${String(record.organizationId)}:${String(record.templateName)}`;
const records = templateBuckets.get(bucketKey) ?? [];
records.push(record);
templateBuckets.set(bucketKey, records);
}
for (const [bucketKey, records] of templateBuckets.entries()) {
const templateIds = [...new Set(records.map((record) => record.templateId))];
if (templateIds.length > 1) {
issues.push({
kind: 'DUPLICATE_ACTIVE_TEMPLATE',
message: `More than one active template exists for ${bucketKey}`,
context: {
bucketKey,
templateIds,
templates: records.map((record) => ({
templateId: record.templateId,
templateName: record.templateName,
versionId: record.versionId,
version: record.version
}))
}
});
if (records.length <= 1) {
continue;
}
issues.push({
kind: 'DUPLICATE_ACTIVE_TEMPLATE',
message: `Multiple active templates found for ${bucketKey}.`,
context: {
bucketKey,
templateIds: records.map((record) => record.templateId),
templates: records.map((record) => ({
templateId: record.templateId,
templateName: record.templateName,
versionId: record.versionId,
version: record.version,
})),
},
});
}
const versionBuckets = new Map<string, Array<Record<string, unknown>>>();
@@ -125,36 +117,42 @@ async function main() {
if (!row.resolvedTemplateId) {
issues.push({
kind: 'ACTIVE_VERSION_WITHOUT_TEMPLATE',
message: `Active version ${String(row.id)} has no parent template`,
context: row
message: `Active version ${String(row.id)} has no parent template.`,
context: row,
});
}
}
for (const [templateId, rows] of versionBuckets.entries()) {
if (rows.length > 1) {
issues.push({
kind: 'DUPLICATE_ACTIVE_VERSION',
message: `Template ${templateId} has more than one active version`,
context: {
templateId,
versions: rows.map((row) => ({
id: row.id,
version: row.version
}))
}
});
if (rows.length <= 1) {
continue;
}
issues.push({
kind: 'DUPLICATE_ACTIVE_VERSION',
message: `Template ${templateId} has more than one active version.`,
context: {
templateId,
versions: rows.map((row) => ({
id: row.id,
version: row.version,
})),
},
});
}
for (const templateRow of templateRows as Array<Record<string, unknown>>) {
const templateId = String(templateRow.id);
const rows = (versionRows as Array<Record<string, unknown>>).filter(
(row) => String(row.templateId) === templateId
(row) => String(row.templateId) === templateId,
);
const activeRows = rows.filter((row) => Boolean(row.isActive));
const inactiveRows = rows.filter((row) => !row.isActive);
const templateSource = loadTemplateSourceByOrganizationName(String(templateRow.organizationName));
const templateSource =
loadTemplateSourceByRelativePath(
(activeRows[0]?.filePath as string | null | undefined) ?? null,
) ??
loadTemplateSourceByOrganizationName(String(templateRow.organizationName));
if (templateSource && activeRows.length === 1) {
const activeSchema =
@@ -165,14 +163,14 @@ async function main() {
if (JSON.stringify(activeSchema) !== JSON.stringify(templateSource.schemaJson)) {
issues.push({
kind: 'ACTIVE_VERSION_SCHEMA_DRIFT',
message: `Active template version does not match source JSON for ${String(templateRow.templateName)}`,
message: `Active template version does not match source JSON for ${String(templateRow.templateName)}.`,
context: {
templateId,
organizationName: templateRow.organizationName,
activeVersionId: activeRows[0].id,
activeVersion: activeRows[0].version,
sourceFile: templateSource.relativePath
}
sourceFile: templateSource.relativePath,
},
});
}
}
@@ -180,11 +178,11 @@ async function main() {
if (rows.length > 1 && inactiveRows.length === 0) {
issues.push({
kind: 'MISSING_RETAINED_INACTIVE_VERSION',
message: `Template ${String(templateRow.templateName)} has no retained inactive historical version`,
message: `Template ${String(templateRow.templateName)} has no retained inactive historical version.`,
context: {
templateId,
organizationName: templateRow.organizationName
}
organizationName: templateRow.organizationName,
},
});
}
}
@@ -192,43 +190,67 @@ async function main() {
for (const row of orphanMappingRows as Array<Record<string, unknown>>) {
issues.push({
kind: 'MAPPING_WITHOUT_VERSION',
message: `Mapping ${String(row.id)} points to a missing template version`,
context: row
message: `Mapping ${String(row.id)} points to a missing template version.`,
context: row,
});
}
const approvedTemplateVersionExists = payload.approvedTemplateVersionId
? (versionRows as Array<Record<string, unknown>>).some(
(row) => String(row.id) === payload.approvedTemplateVersionId
(row) => String(row.id) === payload.approvedTemplateVersionId,
)
: true;
if (payload.approvedTemplateVersionId && !approvedTemplateVersionExists) {
issues.push({
kind: 'INVALID_APPROVED_TEMPLATE_VERSION',
message: 'Audit quotation approvedTemplateVersionId does not resolve to a retained template version',
message: 'Approved snapshot references a template version that no longer exists.',
context: {
quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId
}
approvedTemplateVersionId: payload.approvedTemplateVersionId,
},
});
}
const status = summarizeIssues(issues.length ? ['FAIL'] : ['PASS']);
const report = {
const overallStatus = summarizeIssues(
issues.length === 0 ? ['PASS'] : issues.map(() => 'FAIL'),
);
const artifactPath = await writeAuditArtifact('template-version-report.json', {
audit: 'template-version-integrity',
overallStatus: status,
overallStatus,
activeTemplateCount: activeTemplateVersions.length,
quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId,
approvedTemplateVersionIsActive: activeTemplateVersions.some(
(record) => record.versionId === payload.approvedTemplateVersionId
),
issues
};
const artifactPath = await writeAuditArtifact('template-version-report.json', report);
approvedTemplateVersionIsActive: payload.approvedTemplateVersionId
? (versionRows as Array<Record<string, unknown>>).some(
(row) =>
String(row.id) === payload.approvedTemplateVersionId && Boolean(row.isActive),
)
: false,
issues,
});
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
console.log(
JSON.stringify(
{
audit: 'template-version-integrity',
overallStatus,
activeTemplateCount: activeTemplateVersions.length,
quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId,
approvedTemplateVersionIsActive: payload.approvedTemplateVersionId
? (versionRows as Array<Record<string, unknown>>).some(
(row) =>
String(row.id) === payload.approvedTemplateVersionId && Boolean(row.isActive),
)
: false,
issues,
artifactPath,
},
null,
2,
),
);
} finally {
await sql.end({ timeout: 5 });
}

View File

@@ -35,6 +35,7 @@ export interface ActiveTemplateVersionRecord {
fileType: string;
versionId: string;
version: string;
filePath: string | null;
schemaJson: unknown;
}
@@ -152,6 +153,7 @@ export const PDFME_STATIC_LABEL_FIELD_KEYS = [
'project_label',
'site_label'
] as const;
export type TemplateSourceVariant = 'legacy' | 'product-v1';
export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
{
placeholderKey: 'customer_name',
@@ -327,11 +329,18 @@ export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
formatMask: 'currency'
},
{
columnName: 'Total',
sourceField: 'totalPrice',
columnName: 'Discount',
sourceField: 'discount',
columnLetter: 'F',
sortOrder: 6,
formatMask: 'currency'
},
{
columnName: 'Total',
sourceField: 'totalPrice',
columnLetter: 'G',
sortOrder: 7,
formatMask: 'currency'
}
]
}
@@ -665,12 +674,23 @@ export function normalizeTemplateInputAliases(
return normalized;
}
export function loadTemplateSourceByOrganizationName(organizationName: string) {
export function loadTemplateSourceByOrganizationName(
organizationName: string,
variant: TemplateSourceVariant = 'legacy',
) {
const normalized = organizationName.toUpperCase();
const fileName = normalized.includes('ONVALLA')
? 'ONVALLA_template_pdfme_fainal3.json'
? (
variant === 'product-v1'
? 'ONVALLA_template_pdfme_product_v1.json'
: 'ONVALLA_template_pdfme_fainal3.json'
)
: normalized.includes('ALLA')
? 'ALLA_template_pdfme_fainal3.json'
? (
variant === 'product-v1'
? 'ALLA_template_pdfme_product_v1.json'
: 'ALLA_template_pdfme_fainal3.json'
)
: null;
if (!fileName) {
@@ -686,6 +706,26 @@ export function loadTemplateSourceByOrganizationName(organizationName: string) {
};
}
export function loadTemplateSourceByRelativePath(relativePath: string | null | undefined) {
if (!relativePath) {
return null;
}
const normalizedRelativePath = relativePath.replaceAll('\\', '/');
const absolutePath = path.resolve(process.cwd(), normalizedRelativePath);
if (!fs.existsSync(absolutePath)) {
return null;
}
return {
fileName: path.basename(normalizedRelativePath),
relativePath: normalizedRelativePath,
absolutePath,
schemaJson: JSON.parse(fs.readFileSync(absolutePath, 'utf8')) as unknown
};
}
export function computeFieldDiff(
currentFields: TemplateFieldRecord[],
previousFields: TemplateFieldRecord[]
@@ -739,16 +779,17 @@ function normalizeSnapshotPayload(snapshot: unknown) {
export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveTemplateVersionRecord[]> {
const rows = await sql`
select
t.id as "templateId",
t.template_name as "templateName",
t.organization_id as "organizationId",
t.document_type as "documentType",
t.product_type as "productType",
t.file_type as "fileType",
v.id as "versionId",
v.version,
v.schema_json as "schemaJson"
select
t.id as "templateId",
t.template_name as "templateName",
t.organization_id as "organizationId",
t.document_type as "documentType",
t.product_type as "productType",
t.file_type as "fileType",
v.id as "versionId",
v.version,
v.file_path as "filePath",
v.schema_json as "schemaJson"
from crm_document_templates t
inner join crm_document_template_versions v
on v.template_id = t.id
@@ -975,6 +1016,55 @@ function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: nu
.map((item) => [item.content?.trim() || '-']);
}
function formatPdfDecimal(amount: number | string | null | undefined) {
if (amount === null || amount === undefined) {
return '-';
}
const normalized =
typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
if (!Number.isFinite(normalized)) {
return '-';
}
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 3
}).format(normalized);
}
function buildAuditItemsTable(
items: Array<{
itemNumber?: number | null;
description?: string | null;
quantity?: number | null;
unitLabel?: string | null;
unitPrice?: number | null;
discount?: number | null;
totalPrice?: number | null;
notes?: string | null;
}>,
currencyCode?: string | null
) {
return items.map((item, index) => {
const description = [item.description?.trim(), item.notes?.trim()]
.filter((value): value is string => Boolean(value))
.join('\n')
.trim();
return [
String(item.itemNumber ?? index + 1),
description || '-',
formatPdfDecimal(item.quantity ?? null),
item.unitLabel?.trim() || '-',
formatPdfCurrency(item.unitPrice ?? null, currencyCode?.trim() || 'THB'),
formatPdfCurrency(item.discount ?? 0, currencyCode?.trim() || 'THB'),
formatPdfCurrency(item.totalPrice ?? null, currencyCode?.trim() || 'THB')
];
});
}
function applyFormatMask(value: unknown, formatMask?: string | null) {
if (value === null || value === undefined) {
return value;
@@ -1436,10 +1526,22 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
),
...topicInputs
};
const normalizedTemplateInput = normalizeTemplateInputAliases(
extractTemplateFields(templateVersion.schemaJson),
templateInput
);
const templateFields = extractTemplateFields(templateVersion.schemaJson);
const hasItemsTableField = templateFields.some((field) => field.name === 'items_table');
const normalizedTemplateInput = normalizeTemplateInputAliases(
templateFields,
{
...templateInput,
...(hasItemsTableField
? {
items_table: buildAuditItemsTable(
(documentData as { items: Parameters<typeof buildAuditItemsTable>[0] }).items,
(documentData as { quotation: { currencyCode?: string | null } }).quotation.currencyCode
)
}
: {}),
}
);
return {
organizationId: quotation.organizationId,

View File

@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import {
buildSupportedMappingsForFieldNames,
bumpMinorTemplateVersion,
@@ -9,7 +9,8 @@ import {
getEffectiveMappedKeys,
loadTemplateSourceByOrganizationName,
summarizeIssues,
type TemplateMappingSeed
type TemplateMappingSeed,
type TemplateSourceVariant,
} from './pdf-audit-utils.ts';
type TemplateRow = {
@@ -31,11 +32,16 @@ type TemplateVersionRow = {
createdAt: string;
};
function computeSchemaHash(schemaJson: unknown) {
type ScriptOptions = {
variant: TemplateSourceVariant;
activate: boolean;
};
function computeSchemaHash(schemaJson: unknown): string {
return createHash('sha256').update(JSON.stringify(schemaJson)).digest('hex');
}
function normalizeSchemaJson(schemaJson: unknown) {
function normalizeSchemaJson(schemaJson: unknown): unknown {
if (typeof schemaJson === 'string') {
return JSON.parse(schemaJson) as unknown;
}
@@ -43,22 +49,66 @@ function normalizeSchemaJson(schemaJson: unknown) {
return schemaJson;
}
function parseArgs(argv: string[]): ScriptOptions {
let variant: TemplateSourceVariant = 'legacy';
let activate = false;
for (const arg of argv) {
if (arg === '--activate') {
activate = true;
continue;
}
if (arg.startsWith('--template-variant=')) {
const value = arg.slice('--template-variant='.length);
if (value === 'legacy' || value === 'product-v1') {
variant = value;
}
}
}
if (variant === 'legacy') {
activate = true;
}
return { variant, activate };
}
function resolveVersionLabel(args: {
variant: TemplateSourceVariant;
versions: TemplateVersionRow[];
activeVersion: TemplateVersionRow;
matchingVersion: TemplateVersionRow | null;
}): string {
if (args.matchingVersion) {
return args.matchingVersion.version;
}
if (args.variant === 'product-v1') {
return '2.0';
}
return bumpMinorTemplateVersion(args.versions.at(-1)?.version ?? args.activeVersion.version);
}
async function upsertMapping(
sql: any,
organizationId: string,
templateVersionId: string,
mapping: TemplateMappingSeed
mapping: TemplateMappingSeed,
) {
const existing = (
(await sql`
await sql`
select id
from crm_document_template_mappings
where template_version_id = ${templateVersionId}
and placeholder_key = ${mapping.placeholderKey}
limit 1
`) as Array<{ id: string }>
)[0] ?? null;
const mappingId = String(existing?.id ?? crypto.randomUUID());
`
)[0] as { id: string } | undefined;
const mappingId = existing?.id ?? randomUUID();
if (existing) {
await sql`
@@ -87,8 +137,7 @@ async function upsertMapping(
sheet_name,
default_value,
format_mask,
sort_order,
deleted_at
sort_order
) values (
${mappingId},
${organizationId},
@@ -99,8 +148,7 @@ async function upsertMapping(
${null},
${mapping.defaultValue ?? null},
${mapping.formatMask ?? null},
${mapping.sortOrder},
${null}
${mapping.sortOrder}
)
`;
}
@@ -108,16 +156,18 @@ async function upsertMapping(
const desiredColumnNames = new Set((mapping.columns ?? []).map((column) => column.columnName));
for (const column of mapping.columns ?? []) {
const existingColumn =
(
(await sql`
select id
from crm_document_template_table_columns
where mapping_id = ${mappingId}
and column_name = ${column.columnName}
limit 1
`) as Array<{ id: string }>
)[0] ?? null;
const existingColumn = (
await sql`
select id
from crm_document_template_table_columns
where mapping_id = ${mappingId}
and column_name = ${column.columnName}
and deleted_at is null
limit 1
`
)[0] as { id: string } | undefined;
const columnId = existingColumn?.id ?? randomUUID();
if (existingColumn) {
await sql`
@@ -126,46 +176,43 @@ async function upsertMapping(
organization_id = ${organizationId},
source_field = ${column.sourceField},
column_letter = ${column.columnLetter ?? null},
sort_order = ${column.sortOrder},
sort_order = ${column.sortOrder ?? 0},
format_mask = ${column.formatMask ?? null},
deleted_at = ${null},
updated_at = now()
where id = ${existingColumn.id}
where id = ${columnId}
`;
} else {
await sql`
insert into crm_document_template_table_columns (
id,
organization_id,
mapping_id,
column_name,
source_field,
column_letter,
sort_order,
format_mask
) values (
${columnId},
${organizationId},
${mappingId},
${column.columnName},
${column.sourceField},
${column.columnLetter ?? null},
${column.sortOrder ?? 0},
${column.formatMask ?? null}
)
`;
continue;
}
await sql`
insert into crm_document_template_table_columns (
id,
organization_id,
mapping_id,
column_name,
source_field,
column_letter,
sort_order,
format_mask,
deleted_at
) values (
${crypto.randomUUID()},
${organizationId},
${mappingId},
${column.columnName},
${column.sourceField},
${column.columnLetter ?? null},
${column.sortOrder},
${column.formatMask ?? null},
${null}
)
`;
}
const existingColumns = (await sql`
const existingColumns = await sql`
select id, column_name as "columnName"
from crm_document_template_table_columns
where mapping_id = ${mappingId}
and deleted_at is null
`) as Array<{ id: string; columnName: string }>;
` as Array<{ id: string; columnName: string }>;
for (const column of existingColumns) {
if (desiredColumnNames.has(column.columnName)) {
@@ -174,19 +221,18 @@ async function upsertMapping(
await sql`
update crm_document_template_table_columns
set
deleted_at = now(),
updated_at = now()
set deleted_at = now(), updated_at = now()
where id = ${column.id}
`;
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const sql = createSqlClient();
try {
const templates = (await sql`
const templates = await sql`
select
t.id,
t.organization_id as "organizationId",
@@ -195,30 +241,33 @@ async function main() {
t.created_by as "createdBy",
t.updated_by as "updatedBy"
from crm_document_templates t
inner join organizations o
on o.id = t.organization_id
inner join organizations o on o.id = t.organization_id
where t.document_type = 'quotation'
and t.file_type = 'pdfme'
and t.product_type = 'default'
and t.deleted_at is null
order by o.name asc, t.created_at asc
`) as TemplateRow[];
const results = [];
` as TemplateRow[];
const results: Array<Record<string, unknown>> = [];
for (const template of templates) {
const templateSource = loadTemplateSourceByOrganizationName(template.organizationName);
const templateSource = loadTemplateSourceByOrganizationName(
template.organizationName,
options.variant,
);
if (!templateSource) {
results.push({
organizationName: template.organizationName,
templateName: template.templateName,
status: 'WARNING',
message: 'No source template file found for organization'
message: 'No source template file found for organization.',
});
continue;
}
const versions = (await sql`
const versions = await sql`
select
id,
template_id as "templateId",
@@ -231,32 +280,37 @@ async function main() {
where template_id = ${template.id}
and deleted_at is null
order by created_at asc
`) as TemplateVersionRow[];
` as TemplateVersionRow[];
const activeVersion = versions.find((version) => version.isActive) ?? versions.at(-1) ?? null;
if (!activeVersion) {
throw new Error(`Template ${template.templateName} has no version rows`);
throw new Error(`Template ${template.templateName} has no version rows.`);
}
const targetSchema = templateSource.schemaJson;
const targetSchemaHash = computeSchemaHash(targetSchema);
const matchingVersion =
versions.find(
(version) => computeSchemaHash(normalizeSchemaJson(version.schemaJson)) === targetSchemaHash
) ?? null;
const matchingVersion = versions.find(
(version) => computeSchemaHash(normalizeSchemaJson(version.schemaJson)) === targetSchemaHash,
) ?? null;
const previousFields = extractTemplateFields(normalizeSchemaJson(activeVersion.schemaJson));
const targetFields = extractTemplateFields(targetSchema);
const targetFieldNames = new Set(targetFields.map((field) => field.name).filter(Boolean));
const targetTokens = new Set(targetFields.flatMap((field) => field.tokens));
const supportedMappings = buildSupportedMappingsForFieldNames(targetFieldNames, targetTokens);
const supportedMappings = buildSupportedMappingsForFieldNames(
targetFieldNames,
targetTokens,
);
const mappedKeys = getEffectiveMappedKeys(targetFields, supportedMappings);
const fieldDiff = computeFieldDiff(targetFields, previousFields);
const createdNewVersion = !matchingVersion;
const targetVersionLabel = matchingVersion
? matchingVersion.version
: bumpMinorTemplateVersion(versions.at(-1)?.version ?? activeVersion.version);
const targetVersionId = matchingVersion?.id ?? crypto.randomUUID();
const targetVersionLabel = resolveVersionLabel({
variant: options.variant,
versions,
activeVersion,
matchingVersion,
});
const targetVersionId = matchingVersion?.id ?? randomUUID();
await sql.begin(async (tx) => {
if (!matchingVersion) {
@@ -280,7 +334,7 @@ async function main() {
${templateSource.relativePath},
${JSON.stringify(targetSchema)},
${null},
${true},
${options.activate},
${null},
${template.updatedBy || template.createdBy}
)
@@ -290,32 +344,34 @@ async function main() {
update crm_document_template_versions
set
file_path = ${templateSource.relativePath},
is_active = ${true},
is_active = ${options.activate ? true : matchingVersion.isActive},
updated_at = now()
where id = ${targetVersionId}
`;
}
await tx`
update crm_document_template_versions
set
is_active = case when id = ${targetVersionId} then true else false end,
updated_at = now()
where template_id = ${template.id}
and deleted_at is null
`;
if (options.activate) {
await tx`
update crm_document_template_versions
set
is_active = case when id = ${targetVersionId} then true else false end,
updated_at = now()
where template_id = ${template.id}
and deleted_at is null
`;
}
for (const mapping of supportedMappings) {
await upsertMapping(tx, template.organizationId, targetVersionId, mapping);
}
const desiredKeys = new Set(supportedMappings.map((mapping) => mapping.placeholderKey));
const existingMappings = (await tx`
const existingMappings = await tx`
select id, placeholder_key as "placeholderKey"
from crm_document_template_mappings
where template_version_id = ${targetVersionId}
and deleted_at is null
`) as Array<{ id: string; placeholderKey: string }>;
` as Array<{ id: string; placeholderKey: string }>;
for (const mapping of existingMappings) {
if (desiredKeys.has(mapping.placeholderKey)) {
@@ -324,9 +380,7 @@ async function main() {
await tx`
update crm_document_template_mappings
set
deleted_at = now(),
updated_at = now()
set deleted_at = now(), updated_at = now()
where id = ${mapping.id}
`;
}
@@ -338,16 +392,18 @@ async function main() {
const classification = classifyTemplateField(field, mappedKeys);
classificationCounts.set(
classification,
(classificationCounts.get(classification) ?? 0) + 1
(classificationCounts.get(classification) ?? 0) + 1,
);
}
results.push({
organizationName: template.organizationName,
templateName: template.templateName,
variant: options.variant,
activated: options.activate,
sourceFile: templateSource.relativePath,
previousActiveVersion: activeVersion.version,
activeVersion: targetVersionLabel,
targetVersion: targetVersionLabel,
createdNewVersion,
retainedInactiveVersions: versions.filter((version) => version.id !== targetVersionId).length,
deletedFields: fieldDiff.deletedFields,
@@ -355,14 +411,19 @@ async function main() {
mappingKeys: supportedMappings.map((mapping) => mapping.placeholderKey),
classificationCounts: Object.fromEntries(classificationCounts.entries()),
status: summarizeIssues([
fieldDiff.deletedFields.includes('exclusion_data') ? 'PASS' : 'PASS',
supportedMappings.some((mapping) => mapping.placeholderKey === 'tel_label') ? 'PASS' : 'FAIL'
])
supportedMappings.some((mapping) => mapping.placeholderKey === 'tel_label')
? 'PASS'
: 'FAIL',
options.variant === 'product-v1' &&
supportedMappings.some((mapping) => mapping.placeholderKey === 'items_table')
? 'PASS'
: 'PASS',
]),
});
}
const overallStatus = summarizeIssues(
results.map((result) => result.status as 'PASS' | 'WARNING' | 'FAIL')
results.map((result) => String(result.status) as 'PASS' | 'WARNING' | 'FAIL'),
);
console.log(
@@ -370,12 +431,14 @@ async function main() {
{
action: 'reseed-pdf-template-version',
overallStatus,
templateCount: results.length,
results
variant: options.variant,
activate: options.activate,
resultCount: results.length,
results,
},
null,
2
)
2,
),
);
} finally {
await sql.end({ timeout: 5 });

View File

@@ -1,22 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Template } from '@pdfme/common';
import type { ResolvedDocumentTemplate } from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types';
import { buildQuotationPdfRuntime } from './quotation-pdf-runtime';
import { resolveTemplatePages } from './page-resolver';
import { assembleTemplate } from './template-assembler';
import type { ResolvedTemplate } from './runtime-contracts';
import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter';
function createField(name: string, y: number, height = 7) {
function createField(name: string, y: number, height = 7, type = 'text', content?: string) {
return {
name,
type: 'text',
type,
position: { x: 10, y },
width: 100,
height,
content: `{${name}}`,
content: content ?? `{${name}}`,
};
}
function createMarker(role: string) {
return {
name: `__section_role__${role}`,
type: 'text',
position: { x: 1, y: 1 },
width: 8,
height: 2,
content: role,
readOnly: true,
opacity: 0,
};
}
@@ -26,12 +38,54 @@ function createLegacyTemplate(): Template {
schemas: [
[
createField('customer_name', 20),
createField('quotation_price_data', 40, 20),
createField('quotation_price_data', 40, 20, 'table', '[["Price (Exclude VAT)","-","","THB"]]'),
createField('quotation_code_data', 70),
],
[
createField('topic', 35),
createField('data_topic', 45, 7),
createField('topic', 35, 10, 'table', '[["Topic:"]]'),
createField('data_topic', 45, 10, 'table', '[["{item_topic}"]]'),
createField('Please_do_not', 225),
createField('yours_faithfuly', 232),
createField('app1', 240),
createField('app1_position', 247),
createField('app2', 240),
createField('app2_position', 247),
createField('app3', 240),
createField('app3_position', 247),
],
],
} as unknown as Template;
}
function createProductTemplate(): Template {
return {
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [
[
createMarker('customer'),
createField('customer_name', 20),
createField('quotation_code_data', 30),
],
[
createMarker('product_items'),
{
name: 'items_table',
type: 'table',
position: { x: 10, y: 40 },
width: 180,
height: 120,
content: '[["1","Sample","1","EA","THB 1.00","THB 0.00","THB 1.00"]]',
showHead: true,
repeatHead: true,
head: ['Item', 'Description', 'Qty', 'Unit', 'Unit Price', 'Discount', 'Total'],
headWidthPercentages: [8, 34, 8, 10, 14, 12, 14],
},
],
[
createMarker('topics'),
createMarker('signature'),
createField('topic', 35, 10, 'table', '[["Topic:"]]'),
createField('data_topic', 45, 10, 'table', '[["{item_topic}"]]'),
createField('Please_do_not', 225),
createField('yours_faithfuly', 232),
createField('app1', 240),
@@ -53,7 +107,7 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
documentType: 'quotation',
productType: 'default',
fileType: 'pdfme',
templateName: 'Legacy quotation',
templateName: 'Quotation template',
description: null,
isDefault: true,
isActive: true,
@@ -64,7 +118,7 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
updatedBy: 'system',
},
version: {
id: 'version-1',
id: 'template-version-1',
organizationId: 'org-1',
templateId: 'template-1',
version: '1.0',
@@ -80,7 +134,26 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
};
}
function createDocumentData(topicCount: number): QuotationDocumentData {
function createItem(index: number): QuotationDocumentData['items'][number] {
return {
id: `item-${index}`,
itemNumber: index + 1,
productTypeCode: null,
productTypeLabel: null,
description: `Product item ${index + 1}`,
quantity: 1,
unitCode: 'EA',
unitLabel: 'EA',
unitPrice: 1200 + index,
discount: index * 10,
discountTypeCode: null,
taxRate: 0,
totalPrice: 1200 + index - index * 10,
notes: null,
};
}
function createDocumentData(topicCount: number, itemCount = 0): QuotationDocumentData {
return {
company: {
id: 'company-1',
@@ -128,7 +201,7 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
totalAmount: 0,
notes: null,
},
items: [],
items: Array.from({ length: itemCount }, (_, index) => createItem(index)),
quotationCustomers: [],
topics: {
scope: [],
@@ -157,7 +230,7 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
location: 'Location',
},
quotation_date: '2026-06-29',
quotation_price: '0.00',
quotation_price: 'THB 0.00',
quotation_price_data: [],
exclusion_data: [],
topic_inputs: {},
@@ -172,24 +245,15 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
approvers: [],
},
signatures: {
preparedBy: {
name: 'Prepared',
position: 'Sales',
},
approvedBy: {
name: 'Approved',
position: 'Manager',
},
authorizedBy: {
name: 'Authorized',
position: 'Director',
},
preparedBy: { name: 'Prepared', position: 'Sales' },
approvedBy: { name: 'Approved', position: 'Manager' },
authorizedBy: { name: 'Authorized', position: 'Director' },
},
watermarkStatus: null,
} as QuotationDocumentData;
};
}
test('legacy template page roles are inferred without page-index assumptions', () => {
test('legacy template page roles are still inferred without page-index assumptions', () => {
const schema = createLegacyTemplate();
const template = createResolvedDocumentTemplate(schema);
const runtimeTemplate: ResolvedTemplate = {
@@ -203,12 +267,10 @@ test('legacy template page roles are inferred without page-index assumptions', (
assert.equal(resolved.byRole.customer?.[0]?.pageIndex, 0);
assert.equal(resolved.byRole.topics?.[0]?.pageIndex, 1);
assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 1);
assert.ok(
compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'),
);
assert.ok(compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'));
});
test('runtime keeps signature block on the last generated topic page', async () => {
test('runtime keeps signature block on last generated topic page for legacy templates', async () => {
const schema = createLegacyTemplate();
const template = createResolvedDocumentTemplate(schema);
const runtime = await buildQuotationPdfRuntime({
@@ -221,9 +283,7 @@ test('runtime keeps signature block on the last generated topic page', async ()
});
assert.ok(runtime.assembled.template.schemas.length > 2);
assert.ok(
Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')),
);
assert.ok(Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')));
const topicPages = runtime.assembled.template.schemas.slice(1);
const lastPageNames = new Set(
@@ -257,38 +317,50 @@ test('runtime restores legacy static labels into final template input', async ()
assert.equal(runtime.assembled.templateInput.att_label, 'Att');
assert.equal(runtime.assembled.templateInput.project_label, 'Project');
assert.equal(runtime.assembled.templateInput.site_label, 'Location');
assert.equal(runtime.assembled.templateInput.quotation_date, '2026-06-29');
assert.equal(runtime.assembled.templateInput.quotation_date_data, '2026-06-29');
});
test('template assembler does not overwrite populated values with nullish or blank patches', () => {
const assembled = assembleTemplate({
template: createLegacyTemplate(),
test('explicit section markers resolve product item page and runtime injects items_table', async () => {
const schema = createProductTemplate();
const template = createResolvedDocumentTemplate(schema);
const runtimeTemplate: ResolvedTemplate = {
documentTemplate: template,
schema,
};
const compatible = adaptTemplateForSectionRuntime(runtimeTemplate);
const resolved = resolveTemplatePages(compatible);
assert.equal(resolved.byRole.customer?.[0]?.pageIndex, 0);
assert.equal(resolved.byRole.product_items?.[0]?.pageIndex, 1);
assert.equal(resolved.byRole.topics?.[0]?.pageIndex, 2);
assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 2);
assert.equal(compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'), false);
const runtime = await buildQuotationPdfRuntime({
documentData: createDocumentData(4, 4),
template,
mappings: [],
templateInput: {
tel_label: 'Tel',
customer_name: 'Customer',
},
staticInputPatch: {
tel_label: '',
email_label: 'Email',
},
sections: [
{
role: 'topics',
enabled: true,
rendered: false,
anchorPageIndex: 1,
pages: [],
templateInputPatch: {
tel_label: undefined,
project_label: 'Project',
},
issues: [],
},
],
});
assert.equal(assembled.templateInput.tel_label, 'Tel');
assert.equal(assembled.templateInput.email_label, 'Email');
assert.equal(assembled.templateInput.project_label, 'Project');
assert.ok(runtime.assembled.template.schemas.length >= 3);
const productPageNames = new Set(
runtime.assembled.template.schemas[1]?.map((field) =>
String((field as { name?: string }).name ?? ''),
) ?? [],
);
assert.ok(productPageNames.has('items_table'));
assert.ok(Array.isArray(runtime.assembled.templateInput.items_table));
assert.equal((runtime.assembled.templateInput.items_table as string[][]).length, 4);
assert.deepEqual((runtime.assembled.templateInput.items_table as string[][])[0], [
'1',
'Product item 1',
'1',
'EA',
'THB 1,200.00',
'THB 0.00',
'THB 1,200.00',
]);
});

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 { buildPdfTopicPages } from './pdf-topic-engine';
import { resolveTemplatePages } from './page-resolver';
@@ -21,6 +25,29 @@ function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function buildMissingPageSection<TRole extends BuiltSection['role']>(args: {
role: TRole;
enabled: boolean;
required: boolean;
message: string;
}): BuiltSection<TRole> {
return {
role: args.role,
enabled: args.enabled,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
issues: [
{
code: 'MISSING_MARKER',
severity: args.required ? 'error' : 'warning',
message: args.message,
},
],
};
}
export function createCustomerSectionBuilder(): SectionBuilder {
return {
role: 'customer',
@@ -29,21 +56,12 @@ export function createCustomerSectionBuilder(): SectionBuilder {
const policy = context.policyBySection.get('customer');
if (!page) {
return {
return buildMissingPageSection({
role: 'customer',
enabled: policy?.enabled ?? false,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
issues: [
{
code: 'MISSING_MARKER',
severity: policy?.required ? 'error' : 'warning',
message: 'Customer section page could not be resolved.',
},
],
};
required: policy?.required ?? true,
message: 'Customer section page could not be resolved.',
});
}
return {
@@ -63,18 +81,45 @@ export function createProductItemSectionBuilder(): SectionBuilder {
return {
role: 'product_items',
build(context: RenderContext): ProductItemSection {
const page = context.pages.byRole.product_items?.[0] ?? null;
const policy = context.policyBySection.get('product_items');
const built = buildProductItemSectionModel({
items: context.documentData.items,
currencyCode: context.documentData.quotation.currencyCode,
});
if (!page) {
return {
...buildMissingPageSection({
role: 'product_items',
enabled: policy?.enabled ?? false,
required: policy?.required ?? false,
message: 'Product item section page could not be resolved.',
}),
rows: built.rows,
tableModel: built.tableModel,
pagination: built.pagination,
issues: [
...built.issues,
...buildMissingPageSection({
role: 'product_items',
enabled: policy?.enabled ?? false,
required: policy?.required ?? false,
message: 'Product item section page could not be resolved.',
}).issues,
],
};
}
return {
role: 'product_items',
enabled: true,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
rendered: true,
anchorPageIndex: page.pageIndex,
pages: [cloneTemplate(page.schema)],
templateInputPatch: {
items_table: built.tableModel.rows,
},
issues: built.issues,
rows: built.rows,
tableModel: built.tableModel,
@@ -92,21 +137,12 @@ export function createTopicSectionBuilder(): SectionBuilder {
const policy = context.policyBySection.get('topics');
if (!page) {
return {
return buildMissingPageSection({
role: 'topics',
enabled: policy?.enabled ?? false,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
issues: [
{
code: 'MISSING_MARKER',
severity: policy?.required ? 'error' : 'warning',
message: 'Topic section page could not be resolved.',
},
],
};
required: policy?.required ?? true,
message: 'Topic section page could not be resolved.',
});
}
const built = buildPdfTopicPages(page.schema, context.documentData.topics.all, {
@@ -137,7 +173,7 @@ export function getQuotationSectionBuilders(): SectionBuilder[] {
function createResolvedTemplate(template: ResolvedDocumentTemplate): ResolvedTemplate {
return {
documentTemplate: template,
schema: cloneTemplate(template.version.schemaJson as import('@pdfme/common').Template),
schema: cloneTemplate(template.version.schemaJson as Template),
};
}
@@ -154,7 +190,10 @@ export async function buildQuotationPdfRuntime(args: {
const resolvedTemplate = createResolvedTemplate(args.template);
const compatibleTemplate = adaptTemplateForSectionRuntime(resolvedTemplate);
const pages = resolveTemplatePages(compatibleTemplate);
const policies = resolveRenderPolicies();
const policies = resolveRenderPolicies({
pages,
documentData: args.documentData,
});
const staticInputPatch = resolveQuotationStaticTemplateInputs(args.documentData);
const context = createRenderContext({
documentData: args.documentData,
@@ -172,7 +211,9 @@ export async function buildQuotationPdfRuntime(args: {
sections: composed.sections,
});
const issues = [...pages.issues, ...composed.issues, ...assembled.issues];
const topicSection = composed.sections.find((section) => section.role === 'topics');
const topicSection = composed.sections.find(
(section): section is BuiltSection<'topics'> => section.role === 'topics',
);
if (issues.some((issue) => issue.severity === 'error')) {
throw new Error(createRuntimeErrorMessage(), { cause: issues });

View File

@@ -10,7 +10,10 @@ import type {
} from './runtime-contracts';
import { SECTION_ORDER } from './runtime-contracts';
function buildDefaultPolicy(section: SectionRole): RenderPolicy {
function buildDefaultPolicy(
section: SectionRole,
args?: { pages?: ResolvedPages; documentData?: QuotationDocumentData },
): RenderPolicy {
switch (section) {
case 'customer':
return {
@@ -19,6 +22,13 @@ function buildDefaultPolicy(section: SectionRole): RenderPolicy {
required: true,
visibleWhenEmpty: true,
};
case 'product_items':
return {
section,
enabled: Boolean(args?.pages?.byRole.product_items?.length),
required: false,
visibleWhenEmpty: true,
};
case 'topics':
return {
section,
@@ -36,8 +46,11 @@ function buildDefaultPolicy(section: SectionRole): RenderPolicy {
}
}
export function resolveRenderPolicies(): RenderPolicy[] {
return SECTION_ORDER.map((section) => buildDefaultPolicy(section));
export function resolveRenderPolicies(args?: {
pages?: ResolvedPages;
documentData?: QuotationDocumentData;
}): RenderPolicy[] {
return SECTION_ORDER.map((section) => buildDefaultPolicy(section, args));
}
export function createRenderContext(args: {

View File

@@ -7,7 +7,7 @@ import type {
SectionRole,
} from './runtime-contracts';
const EXPLICIT_ROLE_PREFIX = '__page_role__';
const EXPLICIT_ROLE_PREFIXES = ['__section_role__', '__page_role__'] as const;
const LEGACY_MARKERS: Record<Exclude<SectionRole, 'unknown'>, string[]> = {
customer: ['customer_name', 'quotation_price_data', 'quotation_code_data'],
@@ -48,7 +48,7 @@ function isTemplatePageArray(
function getFieldNames(page: Template['schemas'][number]): string[] {
return page
.map((field) => {
if (typeof field !== 'object' || field === null || !('name' in field)) {
if (!field || typeof field !== 'object' || !('name' in field)) {
return '';
}
@@ -57,60 +57,8 @@ function getFieldNames(page: Template['schemas'][number]): string[] {
.filter(Boolean);
}
function getExplicitRole(page: Template['schemas'][number]): SectionRole | null {
for (const field of page) {
if (!field || typeof field !== 'object') {
continue;
}
const name =
'name' in field && typeof field.name === 'string' ? field.name : null;
const content =
'content' in field && typeof field.content === 'string'
? field.content.trim()
: null;
const byName = name?.startsWith(EXPLICIT_ROLE_PREFIX)
? name.slice(EXPLICIT_ROLE_PREFIX.length)
: null;
const candidate = byName ?? content;
if (candidate && isSectionRole(candidate)) {
return candidate;
}
}
return null;
}
function inferLegacyRoles(page: Template['schemas'][number]): SectionRole[] {
const names = new Set(getFieldNames(page));
const inferred: SectionRole[] = [];
for (const [role, markers] of Object.entries(LEGACY_MARKERS) as Array<
[Exclude<SectionRole, 'unknown'>, string[]]
>) {
if (markers.every((marker) => names.has(marker))) {
inferred.push(role);
}
}
if (!inferred.length && names.has('topic')) {
inferred.push('topics');
}
if (
!inferred.includes('signature') &&
['app1', 'app2', 'app3'].some((name) => names.has(name))
) {
inferred.push('signature');
}
return inferred;
}
function isSectionRole(value: string): value is SectionRole {
return (
function isSectionRole(value: string | null | undefined): value is SectionRole {
return value !== undefined && value !== null && (
value === 'customer' ||
value === 'product_items' ||
value === 'topics' ||
@@ -124,11 +72,56 @@ function isSectionRole(value: string): value is SectionRole {
);
}
function getExplicitRoles(page: Template['schemas'][number]): SectionRole[] {
const roles = new Set<SectionRole>();
for (const field of page) {
if (!field || typeof field !== 'object') {
continue;
}
const name = 'name' in field && typeof field.name === 'string' ? field.name : null;
const content =
'content' in field && typeof field.content === 'string'
? field.content.trim()
: null;
for (const prefix of EXPLICIT_ROLE_PREFIXES) {
const byName = name?.startsWith(prefix) ? name.slice(prefix.length) : null;
if (isSectionRole(byName)) {
roles.add(byName);
}
}
if (isSectionRole(content)) {
roles.add(content);
}
}
return [...roles];
}
function inferLegacyRoles(page: Template['schemas'][number]): SectionRole[] {
const fieldNames = new Set(getFieldNames(page));
const roles: SectionRole[] = [];
for (const [role, markers] of Object.entries(LEGACY_MARKERS) as Array<
[Exclude<SectionRole, 'unknown'>, string[]]
>) {
if (markers.every((marker) => fieldNames.has(marker))) {
roles.push(role);
}
}
return roles;
}
export function adaptTemplateForSectionRuntime(
template: ResolvedTemplate,
): CompatibleTemplate {
const issues: RuntimeIssue[] = [];
const schemaPages = template.schema.schemas;
const issues: RuntimeIssue[] = [];
if (!Array.isArray(schemaPages)) {
return {
@@ -159,15 +152,18 @@ export function adaptTemplateForSectionRuntime(
continue;
}
const explicitRole = getExplicitRole(rawPage);
const explicitRoles = getExplicitRoles(rawPage);
const inferredRoles = inferLegacyRoles(rawPage);
const primaryRole = explicitRole ?? inferredRoles[0] ?? 'unknown';
const primaryRole = explicitRoles[0] ?? inferredRoles[0] ?? 'unknown';
const roles = Array.from(
new Set([primaryRole, ...inferredRoles].filter(isSectionRole)),
new Set(
[primaryRole, ...explicitRoles, ...inferredRoles].filter(isSectionRole),
),
);
let strategy: CompatibleTemplatePage['strategy'] = 'fallback';
if (explicitRole) {
if (explicitRoles.length > 0) {
strategy = 'explicit_marker';
} else if (inferredRoles.length > 0) {
strategy = 'legacy_inference';
@@ -175,8 +171,7 @@ export function adaptTemplateForSectionRuntime(
createIssue({
code: 'LEGACY_COMPAT_MODE',
severity: 'warning',
message:
'PDF template page role was inferred from legacy field markers.',
message: 'PDF template page role inferred from legacy field markers.',
details: { pageIndex, roles },
}),
);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long