From 51d67ef7c2b31e7a3c0d00312bfe6455396cebc6 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Fri, 19 Jun 2026 17:15:28 +0700 Subject: [PATCH] task-h.5.3 --- .../pdf-audit/payload-parity-report.json | 13 +- .../placeholder-integrity-report.json | 4 +- .../pdf-audit/runtime-payload-report.json | 1425 +++++++++++++++++ .../pdf-audit/signature-audit-report.json | 13 +- artifacts/pdf-audit/summary.json | 15 +- artifacts/pdf-audit/summary.md | 12 +- .../pdf-audit/template-version-report.json | 3 +- docs/business/pdf-mapping-registry.md | 34 +- docs/business/pdf-placeholder-registry.md | 27 +- docs/implementation/pdf-audit-report.json | 242 ++- docs/implementation/pdf-audit-report.md | 15 +- ...task-h5-pdf-mapping-audit-visual-parity.md | 6 + .../task-h51-pdf-integrity-audit.md | 11 + .../task-h53-pdf-template-version-remap.md | 33 + docs/implementation/technical-debt.md | 15 + package.json | 3 +- plans/task-h.5.2.md | 398 +++++ plans/task-h.5.3.md | 505 ++++++ scripts/audit-approved-snapshot-parity.ts | 50 +- scripts/audit-payload-parity.ts | 16 +- scripts/audit-pdf-mapping-coverage.ts | 32 +- scripts/audit-pdf-runtime-payload.ts | 95 +- scripts/audit-pdf-template-inventory.ts | 33 + scripts/audit-template-version-integrity.ts | 90 +- scripts/generate-pdf-audit-report.ts | 47 +- scripts/generate-pdf-integrity-report.ts | 25 +- scripts/pdf-audit-utils.ts | 476 +++++- scripts/reseed-pdf-template-version.ts | 388 +++++ src/db/seeds/foundation.seed.ts | 38 +- .../document/server/pdfme-transforms.ts | 50 + .../crm/quotations/document/server/service.ts | 434 +++-- src/features/crm/quotations/document/types.ts | 8 + .../document-template/server/service.ts | 55 +- .../ALLA_template_pdfme_fainal3.json | 183 +-- .../ONVALLA_template_pdfme_fainal3.json | 140 +- 35 files changed, 4301 insertions(+), 633 deletions(-) create mode 100644 artifacts/pdf-audit/runtime-payload-report.json create mode 100644 docs/implementation/task-h53-pdf-template-version-remap.md create mode 100644 plans/task-h.5.2.md create mode 100644 plans/task-h.5.3.md create mode 100644 scripts/reseed-pdf-template-version.ts diff --git a/artifacts/pdf-audit/payload-parity-report.json b/artifacts/pdf-audit/payload-parity-report.json index 8707c91..aa8cce5 100644 --- a/artifacts/pdf-audit/payload-parity-report.json +++ b/artifacts/pdf-audit/payload-parity-report.json @@ -2,11 +2,14 @@ "audit": "payload-parity", "overallStatus": "PASS", "quotationCode": "QT-H5-AUDIT", - "previewHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844", - "generationHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844", - "snapshotHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844", + "previewHash": "1914aaab07b562d967c63d9f3bdea6d44b583e4aac39ea590d08e49975391675", + "generationHash": "1914aaab07b562d967c63d9f3bdea6d44b583e4aac39ea590d08e49975391675", + "snapshotHash": "1914aaab07b562d967c63d9f3bdea6d44b583e4aac39ea590d08e49975391675", "mismatches": [], - "comparableFieldCount": 31, + "comparableFieldCount": 35, "topicInputKeyCount": 10, - "signatureParity": true + "signatureParity": true, + "priceTableParity": true, + "priceTableRowCount": 1, + "priceTableCurrency": "THB" } diff --git a/artifacts/pdf-audit/placeholder-integrity-report.json b/artifacts/pdf-audit/placeholder-integrity-report.json index c22814c..5453ecd 100644 --- a/artifacts/pdf-audit/placeholder-integrity-report.json +++ b/artifacts/pdf-audit/placeholder-integrity-report.json @@ -5,7 +5,7 @@ { "templateName": "ALLA Quotation Standard", "organizationId": "2330c067-2737-4443-bd6e-ccb8a6cf856b", - "version": "1.0", + "version": "1.1", "duplicateFieldNames": [], "typoFields": [], "unmappedPlaceholders": [], @@ -17,7 +17,7 @@ { "templateName": "ONVALLA Quotation Standard", "organizationId": "8cd23975-e199-4ed0-85a9-8cbd01c810d2", - "version": "1.0", + "version": "1.1", "duplicateFieldNames": [], "typoFields": [], "unmappedPlaceholders": [], diff --git a/artifacts/pdf-audit/runtime-payload-report.json b/artifacts/pdf-audit/runtime-payload-report.json new file mode 100644 index 0000000..d8283cf --- /dev/null +++ b/artifacts/pdf-audit/runtime-payload-report.json @@ -0,0 +1,1425 @@ +{ + "audit": "runtime-payload", + "overallStatus": "PASS", + "staticLabelsStatus": "PASS", + "quotationCode": "QT-H5-AUDIT", + "templateName": "ALLA Quotation Standard", + "templateVersion": "1.1", + "findingCount": 202, + "findings": [ + { + "field": "documentData.company.id", + "source": "documentData", + "status": "PASS", + "value": "2330c067-2737-4443-bd6e-ccb8a6cf856b", + "message": "String is populated" + }, + { + "field": "documentData.company.name", + "source": "documentData", + "status": "PASS", + "value": "ALLA", + "message": "String is populated" + }, + { + "field": "documentData.customer.id", + "source": "documentData", + "status": "PASS", + "value": "d181ede5-a7b3-4411-8d72-b78c57fc576a", + "message": "String is populated" + }, + { + "field": "documentData.customer.name", + "source": "documentData", + "status": "PASS", + "value": "Task H.1 Demo Customer", + "message": "String is populated" + }, + { + "field": "documentData.customer.address", + "source": "documentData", + "status": "PASS", + "value": "99/9 Rama IX Road, Bangkok 10310", + "message": "String is populated" + }, + { + "field": "documentData.customer.phone", + "source": "documentData", + "status": "PASS", + "value": "02-000-1000", + "message": "String is populated" + }, + { + "field": "documentData.customer.email", + "source": "documentData", + "status": "PASS", + "value": "demo-customer@local.test", + "message": "String is populated" + }, + { + "field": "documentData.contact.id", + "source": "documentData", + "status": "PASS", + "value": "2266d861-4a18-460a-9c14-96f4080090f0", + "message": "String is populated" + }, + { + "field": "documentData.contact.name", + "source": "documentData", + "status": "PASS", + "value": "Task H.1 Contact", + "message": "String is populated" + }, + { + "field": "documentData.contact.email", + "source": "documentData", + "status": "PASS", + "value": "demo-contact@local.test", + "message": "String is populated" + }, + { + "field": "documentData.contact.mobile", + "source": "documentData", + "status": "PASS", + "value": "081-000-1001", + "message": "String is populated" + }, + { + "field": "documentData.contact.position", + "source": "documentData", + "status": "PASS", + "value": "Procurement Manager", + "message": "String is populated" + }, + { + "field": "documentData.quotation.id", + "source": "documentData", + "status": "PASS", + "value": "3ba9c39c-8121-416a-99ed-59c4d120c72f", + "message": "String is populated" + }, + { + "field": "documentData.quotation.code", + "source": "documentData", + "status": "PASS", + "value": "QT-H5-AUDIT", + "message": "String is populated" + }, + { + "field": "documentData.quotation.projectName", + "source": "documentData", + "status": "PASS", + "value": "Task H.5 Audit Fixture", + "message": "String is populated" + }, + { + "field": "documentData.quotation.projectLocation", + "source": "documentData", + "status": "PASS", + "value": "Bangkok HQ", + "message": "String is populated" + }, + { + "field": "documentData.quotation.attention", + "source": "documentData", + "status": "PASS", + "value": "Task H.1 Contact", + "message": "String is populated" + }, + { + "field": "documentData.quotation.reference", + "source": "documentData", + "status": "PASS", + "value": "TASK-H5-AUDIT", + "message": "String is populated" + }, + { + "field": "documentData.quotation.currency", + "source": "documentData", + "status": "PASS", + "value": "THB", + "message": "String is populated" + }, + { + "field": "documentData.quotation.currencyCode", + "source": "documentData", + "status": "PASS", + "value": "THB", + "message": "String is populated" + }, + { + "field": "documentData.quotation.subtotal", + "source": "documentData", + "status": "PASS", + "value": 125000, + "message": "Scalar value is populated" + }, + { + "field": "documentData.quotation.totalAmount", + "source": "documentData", + "status": "PASS", + "value": 128400, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items", + "source": "documentData", + "status": "PASS", + "value": "rows:3", + "message": "Collection payload is populated" + }, + { + "field": "documentData.items[0].id", + "source": "documentData", + "status": "PASS", + "value": "12ee11d8-1404-4cf9-9944-097f184296df", + "message": "String is populated" + }, + { + "field": "documentData.items[0].itemNumber", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[0].description", + "source": "documentData", + "status": "PASS", + "value": "1 set overhead crane with installation and commissioning", + "message": "String is populated" + }, + { + "field": "documentData.items[0].quantity", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[0].unitLabel", + "source": "documentData", + "status": "PASS", + "value": "Set", + "message": "String is populated" + }, + { + "field": "documentData.items[0].unitPrice", + "source": "documentData", + "status": "PASS", + "value": 125000, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[0].discount", + "source": "documentData", + "status": "PASS", + "value": 5000, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[0].totalPrice", + "source": "documentData", + "status": "PASS", + "value": 120000, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[1].id", + "source": "documentData", + "status": "PASS", + "value": "b68d6a34-229d-4a62-ad59-faef2108874a", + "message": "String is populated" + }, + { + "field": "documentData.items[1].itemNumber", + "source": "documentData", + "status": "PASS", + "value": 2, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[1].description", + "source": "documentData", + "status": "PASS", + "value": "Load testing and safety certification", + "message": "String is populated" + }, + { + "field": "documentData.items[1].quantity", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[1].unitLabel", + "source": "documentData", + "status": "PASS", + "value": "Set", + "message": "String is populated" + }, + { + "field": "documentData.items[1].unitPrice", + "source": "documentData", + "status": "PASS", + "value": 25000, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[1].discount", + "source": "documentData", + "status": "PASS", + "value": 0, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[1].totalPrice", + "source": "documentData", + "status": "PASS", + "value": 25000, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[2].id", + "source": "documentData", + "status": "PASS", + "value": "a4b633ef-6fbb-4476-9a1b-5957f2dd64d9", + "message": "String is populated" + }, + { + "field": "documentData.items[2].itemNumber", + "source": "documentData", + "status": "PASS", + "value": 3, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[2].description", + "source": "documentData", + "status": "PASS", + "value": "Operator training and turnover documentation", + "message": "String is populated" + }, + { + "field": "documentData.items[2].quantity", + "source": "documentData", + "status": "PASS", + "value": 2, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[2].unitLabel", + "source": "documentData", + "status": "PASS", + "value": "Set", + "message": "String is populated" + }, + { + "field": "documentData.items[2].unitPrice", + "source": "documentData", + "status": "PASS", + "value": 8000, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[2].discount", + "source": "documentData", + "status": "PASS", + "value": 0, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[2].totalPrice", + "source": "documentData", + "status": "PASS", + "value": 16000, + "message": "Scalar value is populated" + }, + { + "field": "documentData.quotationCustomers", + "source": "documentData", + "status": "PASS", + "value": "rows:3", + "message": "Collection payload is populated" + }, + { + "field": "documentData.quotationCustomers[0].id", + "source": "documentData", + "status": "PASS", + "value": "6b0d1e2c-ce89-4bfd-8d7a-8ef7eafe88d1", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[0].customerId", + "source": "documentData", + "status": "PASS", + "value": "d181ede5-a7b3-4411-8d72-b78c57fc576a", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[0].role", + "source": "documentData", + "status": "PASS", + "value": "ba173997-2078-45a9-bbf5-000c6a09c3ad", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[0].isPrimary", + "source": "documentData", + "status": "PASS", + "value": true, + "message": "Scalar value is populated" + }, + { + "field": "documentData.quotationCustomers[1].id", + "source": "documentData", + "status": "PASS", + "value": "fc5165f3-2642-4e28-92ab-63be6f4f5232", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[1].customerId", + "source": "documentData", + "status": "PASS", + "value": "41c80a46-cf20-493d-b759-4193f4e336d0", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[1].role", + "source": "documentData", + "status": "PASS", + "value": "9e2e9f37-f400-4379-a72a-c78bea00581c", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[1].isPrimary", + "source": "documentData", + "status": "PASS", + "value": false, + "message": "Scalar value is populated" + }, + { + "field": "documentData.quotationCustomers[2].id", + "source": "documentData", + "status": "PASS", + "value": "3cd96a36-b8bd-487a-9069-db728502be66", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[2].customerId", + "source": "documentData", + "status": "PASS", + "value": "5416445a-ce22-4b3c-803d-0d6e591ee1fa", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[2].role", + "source": "documentData", + "status": "PASS", + "value": "e6d0dd91-60c0-480e-8412-8daca7e50aa5", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[2].isPrimary", + "source": "documentData", + "status": "PASS", + "value": false, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all", + "source": "documentData", + "status": "PASS", + "value": "rows:5", + "message": "Collection payload is populated" + }, + { + "field": "documentData.topics.all[0].id", + "source": "documentData", + "status": "PASS", + "value": "f07d5ccd-784d-4c87-9ded-60ccb85bd0b0", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[0].title", + "source": "documentData", + "status": "PASS", + "value": "Scope", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[0].topicCode", + "source": "documentData", + "status": "PASS", + "value": "scope", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[0].topicType", + "source": "documentData", + "status": "PASS", + "value": "Scope", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[0].items", + "source": "documentData", + "status": "PASS", + "value": "rows:3", + "message": "Collection payload is populated" + }, + { + "field": "documentData.topics.all[0].items[0].content", + "source": "documentData", + "status": "PASS", + "value": "Supply crane equipment", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[0].items[0].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[0].items[1].content", + "source": "documentData", + "status": "PASS", + "value": "Install and test the system", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[0].items[1].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 2, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[0].items[2].content", + "source": "documentData", + "status": "PASS", + "value": "Perform SAT with project stakeholders", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[0].items[2].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 3, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[1].id", + "source": "documentData", + "status": "PASS", + "value": "07337fe5-7ca5-419f-837c-27e9e6bc05f1", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[1].title", + "source": "documentData", + "status": "PASS", + "value": "Exclusion", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[1].topicCode", + "source": "documentData", + "status": "PASS", + "value": "exclusion", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[1].topicType", + "source": "documentData", + "status": "PASS", + "value": "Exclusion", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[1].items", + "source": "documentData", + "status": "PASS", + "value": "rows:2", + "message": "Collection payload is populated" + }, + { + "field": "documentData.topics.all[1].items[0].content", + "source": "documentData", + "status": "PASS", + "value": "Civil work by customer", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[1].items[0].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[1].items[1].content", + "source": "documentData", + "status": "PASS", + "value": "Power feeder by customer", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[1].items[1].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 2, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[2].id", + "source": "documentData", + "status": "PASS", + "value": "e821ccb0-23fc-4c8a-828c-8ababe0f4896", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[2].title", + "source": "documentData", + "status": "PASS", + "value": "Payment", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[2].topicCode", + "source": "documentData", + "status": "PASS", + "value": "payment", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[2].topicType", + "source": "documentData", + "status": "PASS", + "value": "Payment", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[2].items", + "source": "documentData", + "status": "PASS", + "value": "rows:3", + "message": "Collection payload is populated" + }, + { + "field": "documentData.topics.all[2].items[0].content", + "source": "documentData", + "status": "PASS", + "value": "50% upon PO", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[2].items[0].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[2].items[1].content", + "source": "documentData", + "status": "PASS", + "value": "40% before delivery", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[2].items[1].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 2, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[2].items[2].content", + "source": "documentData", + "status": "PASS", + "value": "10% after commissioning", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[2].items[2].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 3, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[3].id", + "source": "documentData", + "status": "PASS", + "value": "4754e4b5-0dd7-44d6-b7c2-1006ec5b526d", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[3].title", + "source": "documentData", + "status": "PASS", + "value": "Warranty", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[3].topicCode", + "source": "documentData", + "status": "WARNING", + "value": "", + "message": "String is empty" + }, + { + "field": "documentData.topics.all[3].topicType", + "source": "documentData", + "status": "PASS", + "value": "Warranty", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[3].items", + "source": "documentData", + "status": "PASS", + "value": "rows:2", + "message": "Collection payload is populated" + }, + { + "field": "documentData.topics.all[3].items[0].content", + "source": "documentData", + "status": "PASS", + "value": "12 months after commissioning", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[3].items[0].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[3].items[1].content", + "source": "documentData", + "status": "PASS", + "value": "Consumables excluded", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[3].items[1].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 2, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[4].id", + "source": "documentData", + "status": "PASS", + "value": "6fd51401-11cd-4388-91e4-734ba6b3de6c", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[4].title", + "source": "documentData", + "status": "PASS", + "value": "Delivery", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[4].topicCode", + "source": "documentData", + "status": "WARNING", + "value": "", + "message": "String is empty" + }, + { + "field": "documentData.topics.all[4].topicType", + "source": "documentData", + "status": "PASS", + "value": "Delivery", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[4].items", + "source": "documentData", + "status": "PASS", + "value": "rows:2", + "message": "Collection payload is populated" + }, + { + "field": "documentData.topics.all[4].items[0].content", + "source": "documentData", + "status": "PASS", + "value": "Within 60 days after approved drawing", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[4].items[0].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.all[4].items[1].content", + "source": "documentData", + "status": "PASS", + "value": "Subject to final site readiness", + "message": "String is populated" + }, + { + "field": "documentData.topics.all[4].items[1].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 2, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.exclusion", + "source": "documentData", + "status": "PASS", + "value": "rows:1", + "message": "Collection payload is populated" + }, + { + "field": "documentData.topics.exclusion[0].id", + "source": "documentData", + "status": "PASS", + "value": "07337fe5-7ca5-419f-837c-27e9e6bc05f1", + "message": "String is populated" + }, + { + "field": "documentData.topics.exclusion[0].title", + "source": "documentData", + "status": "PASS", + "value": "Exclusion", + "message": "String is populated" + }, + { + "field": "documentData.topics.exclusion[0].topicCode", + "source": "documentData", + "status": "PASS", + "value": "exclusion", + "message": "String is populated" + }, + { + "field": "documentData.topics.exclusion[0].topicType", + "source": "documentData", + "status": "PASS", + "value": "Exclusion", + "message": "String is populated" + }, + { + "field": "documentData.topics.exclusion[0].items", + "source": "documentData", + "status": "PASS", + "value": "rows:2", + "message": "Collection payload is populated" + }, + { + "field": "documentData.topics.exclusion[0].items[0].content", + "source": "documentData", + "status": "PASS", + "value": "Civil work by customer", + "message": "String is populated" + }, + { + "field": "documentData.topics.exclusion[0].items[0].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.topics.exclusion[0].items[1].content", + "source": "documentData", + "status": "PASS", + "value": "Power feeder by customer", + "message": "String is populated" + }, + { + "field": "documentData.topics.exclusion[0].items[1].sortOrder", + "source": "documentData", + "status": "PASS", + "value": 2, + "message": "Scalar value is populated" + }, + { + "field": "documentData.pdfme.labels.tel", + "source": "documentData", + "status": "PASS", + "value": "Tel", + "message": "String is populated" + }, + { + "field": "documentData.pdfme.labels.email", + "source": "documentData", + "status": "PASS", + "value": "Email", + "message": "String is populated" + }, + { + "field": "documentData.pdfme.labels.att", + "source": "documentData", + "status": "PASS", + "value": "Att", + "message": "String is populated" + }, + { + "field": "documentData.pdfme.labels.project", + "source": "documentData", + "status": "PASS", + "value": "Project", + "message": "String is populated" + }, + { + "field": "documentData.pdfme.labels.location", + "source": "documentData", + "status": "PASS", + "value": "Location", + "message": "String is populated" + }, + { + "field": "documentData.pdfme.quotation_date", + "source": "documentData", + "status": "PASS", + "value": "Jun 16, 2026", + "message": "String is populated" + }, + { + "field": "documentData.pdfme.quotation_price", + "source": "documentData", + "status": "PASS", + "value": "THB 125,000.00", + "message": "String is populated" + }, + { + "field": "documentData.pdfme.quotation_price_data", + "source": "documentData", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.exclusion_data", + "source": "documentData", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.topic_1_0", + "source": "documentData", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.item_topic_1_0", + "source": "documentData", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.topic_1_1", + "source": "documentData", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.item_topic_1_1", + "source": "documentData", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.topic_1_2", + "source": "documentData", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.item_topic_1_2", + "source": "documentData", + "status": "PASS", + "value": "rows:3", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.topic_1_3", + "source": "documentData", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.item_topic_1_3", + "source": "documentData", + "status": "PASS", + "value": "rows:3", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.topic_1_4", + "source": "documentData", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "documentData.pdfme.topic_inputs.item_topic_1_4", + "source": "documentData", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "documentData.signatures.preparedBy.name", + "source": "documentData", + "status": "PASS", + "value": "Task H.1 Admin", + "message": "String is populated" + }, + { + "field": "documentData.signatures.preparedBy.position", + "source": "documentData", + "status": "PASS", + "value": "Sales Manager", + "message": "String is populated" + }, + { + "field": "documentData.signatures.preparedBy.actedAt", + "source": "documentData", + "status": "PASS", + "value": "2026-06-19T07:03:05.000Z", + "message": "String is populated" + }, + { + "field": "documentData.signatures.approvedBy.name", + "source": "documentData", + "status": "PASS", + "value": "Task H.1 Admin", + "message": "String is populated" + }, + { + "field": "documentData.signatures.approvedBy.position", + "source": "documentData", + "status": "PASS", + "value": "Sales Manager", + "message": "String is populated" + }, + { + "field": "documentData.signatures.approvedBy.actedAt", + "source": "documentData", + "status": "PASS", + "value": "2026-06-16T01:10:00.000Z", + "message": "String is populated" + }, + { + "field": "documentData.signatures.authorizedBy.name", + "source": "documentData", + "status": "PASS", + "value": "Task H.1 Admin", + "message": "String is populated" + }, + { + "field": "documentData.signatures.authorizedBy.position", + "source": "documentData", + "status": "PASS", + "value": "Sales Manager", + "message": "String is populated" + }, + { + "field": "documentData.signatures.authorizedBy.actedAt", + "source": "documentData", + "status": "PASS", + "value": "2026-06-16T01:20:00.000Z", + "message": "String is populated" + }, + { + "field": "templateInput.customer_name", + "source": "templateInput", + "status": "PASS", + "value": "Task H.1 Demo Customer", + "message": "String is populated" + }, + { + "field": "templateInput.customer_addr", + "source": "templateInput", + "status": "PASS", + "value": "99/9 Rama IX Road, Bangkok 10310", + "message": "String is populated" + }, + { + "field": "templateInput.customer_tel", + "source": "templateInput", + "status": "PASS", + "value": "02-000-1000", + "message": "String is populated" + }, + { + "field": "templateInput.customer_email", + "source": "templateInput", + "status": "PASS", + "value": "demo-customer@local.test", + "message": "String is populated" + }, + { + "field": "templateInput.customer_att", + "source": "templateInput", + "status": "PASS", + "value": "Task H.1 Contact", + "message": "String is populated" + }, + { + "field": "templateInput.project_name", + "source": "templateInput", + "status": "PASS", + "value": "Task H.5 Audit Fixture", + "message": "String is populated" + }, + { + "field": "templateInput.site_location", + "source": "templateInput", + "status": "PASS", + "value": "Bangkok HQ", + "message": "String is populated" + }, + { + "field": "templateInput.quotation_date_data", + "source": "templateInput", + "status": "PASS", + "value": "Jun 16, 2026", + "message": "String is populated" + }, + { + "field": "templateInput.quotation_code_data", + "source": "templateInput", + "status": "PASS", + "value": "QT-H5-AUDIT", + "message": "String is populated" + }, + { + "field": "templateInput.quotation_price_data", + "source": "templateInput", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "templateInput.quotation_price", + "source": "templateInput", + "status": "PASS", + "value": "THB 125,000.00", + "message": "String is populated" + }, + { + "field": "templateInput.currency", + "source": "templateInput", + "status": "PASS", + "value": "THB", + "message": "String is populated" + }, + { + "field": "templateInput.tel_label", + "source": "templateInput", + "status": "PASS", + "value": "Tel", + "message": "String is populated" + }, + { + "field": "templateInput.email_label", + "source": "templateInput", + "status": "PASS", + "value": "Email", + "message": "String is populated" + }, + { + "field": "templateInput.att_label", + "source": "templateInput", + "status": "PASS", + "value": "Att", + "message": "String is populated" + }, + { + "field": "templateInput.project_label", + "source": "templateInput", + "status": "PASS", + "value": "Project", + "message": "String is populated" + }, + { + "field": "templateInput.site_label", + "source": "templateInput", + "status": "PASS", + "value": "Location", + "message": "String is populated" + }, + { + "field": "templateInput.app1", + "source": "templateInput", + "status": "PASS", + "value": "Task H.1 Admin", + "message": "String is populated" + }, + { + "field": "templateInput.app1_position", + "source": "templateInput", + "status": "PASS", + "value": "Sales Manager", + "message": "String is populated" + }, + { + "field": "templateInput.app2", + "source": "templateInput", + "status": "PASS", + "value": "Task H.1 Admin", + "message": "String is populated" + }, + { + "field": "templateInput.app2_position", + "source": "templateInput", + "status": "PASS", + "value": "Sales Manager", + "message": "String is populated" + }, + { + "field": "templateInput.app3", + "source": "templateInput", + "status": "PASS", + "value": "Task H.1 Admin", + "message": "String is populated" + }, + { + "field": "templateInput.app3_position", + "source": "templateInput", + "status": "PASS", + "value": "Sales Manager", + "message": "String is populated" + }, + { + "field": "templateInput.topic_1_0", + "source": "templateInput", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "templateInput.item_topic_1_0", + "source": "templateInput", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "templateInput.topic_1_1", + "source": "templateInput", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "templateInput.item_topic_1_1", + "source": "templateInput", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "templateInput.topic_1_2", + "source": "templateInput", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "templateInput.item_topic_1_2", + "source": "templateInput", + "status": "PASS", + "value": "rows:3", + "message": "Array shape is valid" + }, + { + "field": "templateInput.topic_1_3", + "source": "templateInput", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "templateInput.item_topic_1_3", + "source": "templateInput", + "status": "PASS", + "value": "rows:3", + "message": "Array shape is valid" + }, + { + "field": "templateInput.topic_1_4", + "source": "templateInput", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "templateInput.item_topic_1_4", + "source": "templateInput", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "templateInput.quotation_date", + "source": "templateInput", + "status": "PASS", + "value": "Jun 16, 2026", + "message": "String is populated" + }, + { + "field": "templateInput.quotation_code", + "source": "templateInput", + "status": "PASS", + "value": "QT-H5-AUDIT", + "message": "String is populated" + }, + { + "field": "topicInputs.topic_1_0", + "source": "topicInputs", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "topicInputs.item_topic_1_0", + "source": "topicInputs", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "topicInputs.topic_1_1", + "source": "topicInputs", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "topicInputs.item_topic_1_1", + "source": "topicInputs", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "topicInputs.topic_1_2", + "source": "topicInputs", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "topicInputs.item_topic_1_2", + "source": "topicInputs", + "status": "PASS", + "value": "rows:3", + "message": "Array shape is valid" + }, + { + "field": "topicInputs.topic_1_3", + "source": "topicInputs", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "topicInputs.item_topic_1_3", + "source": "topicInputs", + "status": "PASS", + "value": "rows:3", + "message": "Array shape is valid" + }, + { + "field": "topicInputs.topic_1_4", + "source": "topicInputs", + "status": "PASS", + "value": "rows:1", + "message": "Array shape is valid" + }, + { + "field": "topicInputs.item_topic_1_4", + "source": "topicInputs", + "status": "PASS", + "value": "rows:2", + "message": "Array shape is valid" + }, + { + "field": "templateInput.quotation_price_data", + "source": "templateInput", + "status": "PASS", + "value": "rows:1", + "message": "Price table input is present" + }, + { + "field": "templateInput.quotation_price_data[0][0]", + "source": "templateInput", + "status": "PASS", + "value": "Price (Exclude VAT)", + "message": "Price table label matches canonical text" + }, + { + "field": "templateInput.quotation_price_data[0][1]", + "source": "templateInput", + "status": "PASS", + "value": "฿125,000.00", + "message": "Price table amount cell is formatted" + }, + { + "field": "templateInput.quotation_price_data[0][3]", + "source": "templateInput", + "status": "PASS", + "value": "THB", + "message": "Price table currency code cell is populated" + }, + { + "field": "templateInput.tel_label", + "source": "templateInput", + "status": "PASS", + "value": "Tel", + "message": "Static label is populated" + }, + { + "field": "templateInput.email_label", + "source": "templateInput", + "status": "PASS", + "value": "Email", + "message": "Static label is populated" + }, + { + "field": "templateInput.att_label", + "source": "templateInput", + "status": "PASS", + "value": "Att", + "message": "Static label is populated" + }, + { + "field": "templateInput.project_label", + "source": "templateInput", + "status": "PASS", + "value": "Project", + "message": "Static label is populated" + }, + { + "field": "templateInput.site_label", + "source": "templateInput", + "status": "PASS", + "value": "Location", + "message": "Static label is populated" + } + ] +} diff --git a/artifacts/pdf-audit/signature-audit-report.json b/artifacts/pdf-audit/signature-audit-report.json index 27a1eba..21dddf9 100644 --- a/artifacts/pdf-audit/signature-audit-report.json +++ b/artifacts/pdf-audit/signature-audit-report.json @@ -1,12 +1,13 @@ { "audit": "approved-snapshot-parity", - "overallStatus": "WARNING", + "overallStatus": "PASS", "quotationCode": "QT-H5-AUDIT", - "approvedSnapshotAvailable": false, - "approvedTemplateVersionId": null, - "computedTemplateVersionId": "4ac3e91c-8320-4f41-81cd-5068cc9b5768", + "approvedSnapshotAvailable": true, + "approvedTemplateVersionId": "bd6713d5-a731-482f-a5bb-0dea9d766301", + "computedTemplateVersionId": "bd6713d5-a731-482f-a5bb-0dea9d766301", "inconsistentTemplateKeys": [], "computedSignatureHash": "39e2c42458c8b08b4299dc3136c9812c311ae7886df91078f4c9ae6e87bdcc00", - "storedSignatureHash": null, - "approvedArtifactReference": null + "storedSignatureHash": "39e2c42458c8b08b4299dc3136c9812c311ae7886df91078f4c9ae6e87bdcc00", + "approvedArtifactReference": "artifact:a1fd951d-f0b4-4e5b-be88-073596b3829d", + "refreshedFixtureSnapshot": false } diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json index f87a869..55c5645 100644 --- a/artifacts/pdf-audit/summary.json +++ b/artifacts/pdf-audit/summary.json @@ -1,10 +1,11 @@ { - "generatedAt": "2026-06-19T07:45:01.927Z", - "overallStatus": "WARNING", + "generatedAt": "2026-06-19T10:12:54.977Z", + "overallStatus": "PASS", "quotationCode": "QT-H5-AUDIT", "artifacts": { "templateVersionReport": "template-version-report.json", "placeholderIntegrityReport": "placeholder-integrity-report.json", + "runtimePayloadReport": "runtime-payload-report.json", "payloadParityReport": "payload-parity-report.json", "topicRuntimeReport": "topic-runtime-report.json", "signatureAuditReport": "signature-audit-report.json" @@ -12,8 +13,16 @@ "statuses": { "templateVersion": "PASS", "placeholderIntegrity": "PASS", + "runtimePayload": "PASS", "payloadParity": "PASS", "topicRuntime": "PASS", - "approvedSnapshotParity": "WARNING" + "approvedSnapshotParity": "PASS", + "staticLabels": "PASS" + }, + "priceTable": { + "present": true, + "rowCount": 1, + "parity": true, + "status": "PASS" } } diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md index b3c4e80..feb3146 100644 --- a/artifacts/pdf-audit/summary.md +++ b/artifacts/pdf-audit/summary.md @@ -1,21 +1,27 @@ # PDF Integrity Summary -- Generated At: 2026-06-19T07:45:01.927Z -- Overall Status: WARNING +- Generated At: 2026-06-19T10:12:54.977Z +- Overall Status: PASS - Quotation Code: QT-H5-AUDIT ## Statuses - Template Version Integrity: PASS - Placeholder Integrity: PASS +- Runtime Payload: PASS - Payload Parity: PASS - Topic Runtime: PASS -- Approved Snapshot Parity: WARNING +- Approved Snapshot Parity: PASS +- Static Labels: PASS +- Price Table Present: Yes +- Price Table Status: PASS +- Price Table Parity: Yes ## Artifact Files - template-version-report.json - placeholder-integrity-report.json +- runtime-payload-report.json - payload-parity-report.json - topic-runtime-report.json - signature-audit-report.json diff --git a/artifacts/pdf-audit/template-version-report.json b/artifacts/pdf-audit/template-version-report.json index dcbb3e0..3d0ca97 100644 --- a/artifacts/pdf-audit/template-version-report.json +++ b/artifacts/pdf-audit/template-version-report.json @@ -3,6 +3,7 @@ "overallStatus": "PASS", "activeTemplateCount": 2, "quotationCode": "QT-H5-AUDIT", - "approvedTemplateVersionId": null, + "approvedTemplateVersionId": "bd6713d5-a731-482f-a5bb-0dea9d766301", + "approvedTemplateVersionIsActive": true, "issues": [] } diff --git a/docs/business/pdf-mapping-registry.md b/docs/business/pdf-mapping-registry.md index edba39d..c0cd73d 100644 --- a/docs/business/pdf-mapping-registry.md +++ b/docs/business/pdf-mapping-registry.md @@ -1,6 +1,6 @@ # PDF Mapping Registry -This document is the source of truth for current quotation PDF field ownership. +This document is the source of truth for active quotation PDF field ownership. | PDF Field | Source Path | Transform | Example Value | | --- | --- | --- | --- | @@ -11,12 +11,18 @@ This document is the source of truth for current quotation PDF field ownership. | `customer_att` | `quotation.attention` | direct | `Task H.1 Contact` | | `project_name` | `quotation.projectName` | direct | `Task H.5 Audit Fixture` | | `site_location` | `quotation.projectLocation` | direct multiline | `Bangkok HQ` | -| `quotation_date` | `pdfme.quotation_date` | `formatPdfDate()` | `16 Jun 2026` | -| `quotation_code` | `quotation.code` | direct | `QT-H5-AUDIT` | -| `quotation_price` | `pdfme.quotation_price` | `formatPdfCurrency()` | `THB 128,400.00` | -| `currency` | `quotation.currency` | direct | `THB` | -| `exclusion_data` | `pdfme.exclusion_data` | `formatTopicItems()` | `[["Civil work by customer"]]` | -| `items_table` | `items` | `normalizePdfmeTable()` with table columns | item rows | +| `tel_label` | `pdfme.labels.tel` | static label | `Tel` | +| `email_label` | `pdfme.labels.email` | static label | `Email` | +| `att_label` | `pdfme.labels.att` | static label | `Att` | +| `project_label` | `pdfme.labels.project` | static label | `Project` | +| `site_label` | `pdfme.labels.location` | static label | `Location` | +| `quotation_date` | `pdfme.quotation_date` | `formatPdfDate()` canonical alias | `16 Jun 2026` | +| `quotation_date_data` | `pdfme.quotation_date` | active-template field-name mapping | `16 Jun 2026` | +| `quotation_code` | `quotation.code` | direct canonical alias | `QT-H5-AUDIT` | +| `quotation_code_data` | `quotation.code` | active-template field-name mapping | `QT-H5-AUDIT` | +| `quotation_price_data` | `pdfme.quotation_price_data` | `buildPdfPriceTableData()` | `[["Price (Exclude VAT)", "฿125,000.00", " ", "THB"]]` | +| `quotation_price` | `pdfme.quotation_price` | compatibility scalar | `THB 125,000.00` | +| `currency` | `quotation.currency` | compatibility scalar | `THB` | | `app1` | `signatures.preparedBy.name` | direct | `Task H.1 Admin` | | `app1_position` | `signatures.preparedBy.position` | position resolver | `Sales Manager` | | `app2` | `signatures.approvedBy.name` | direct | `Task H.1 Admin` | @@ -28,7 +34,17 @@ This document is the source of truth for current quotation PDF field ownership. | `topic_*` | `documentData.topics.all[*].topicType` | runtime generated | `Scope` | | `item_topic_*` | `documentData.topics.all[*].items` | `formatTopicItems()` | topic rows | +## Version Remap Rules + +- template JSON changes must create a new `crm_document_template_versions` row instead of patching the old version in place +- active mappings are rebuilt only for fields or placeholder tokens that still exist in the new version +- previous approved snapshots may keep referencing retained inactive versions +- deleted fields from the previous version, such as `exclusion_data` and `exclusion_label`, must not remain active mappings on the new version + ## Alias Notes -- current PDFME schema still uses field containers named `quotation_date_data` and `quotation_code_data` -- runtime normalization now publishes both the canonical placeholder keys (`quotation_date`, `quotation_code`) and the container keys so preview/audit/render stay aligned during the freeze window +- current PDFME schema uses field containers named `quotation_date_data` and `quotation_code_data` +- runtime normalization publishes both canonical aliases (`quotation_date`, `quotation_code`) and the active field-name mappings so preview, audit, and render stay aligned +- `quotation_price_data` is the canonical price table contract +- `quotation_price` and `currency` remain compatibility scalars because the active table content still embeds those placeholder tokens +- static labels are runtime-driven through `documentData.pdfme.labels.*`, not designer-only hardcoded text diff --git a/docs/business/pdf-placeholder-registry.md b/docs/business/pdf-placeholder-registry.md index 3fb3b67..11f7f18 100644 --- a/docs/business/pdf-placeholder-registry.md +++ b/docs/business/pdf-placeholder-registry.md @@ -11,11 +11,18 @@ This registry freezes the placeholder naming contract for quotation PDF template | `customer_att` | mapped | DB mapping | quotation attention | | `project_name` | mapped | DB mapping | project name | | `site_location` | mapped | DB mapping | project location | -| `quotation_date` | mapped | DB mapping | formatted date | -| `quotation_code` | mapped | DB mapping | quotation document code | -| `quotation_price` | mapped | DB mapping | formatted total amount | -| `currency` | mapped | DB mapping | currency code/label | -| `exclusion_data` | mapped | DB mapping | exclusion rows as `string[][]` | +| `tel_label` | mapped | DB mapping | static label from `pdfme.labels.tel` | +| `email_label` | mapped | DB mapping | static label from `pdfme.labels.email` | +| `att_label` | mapped | DB mapping | static label from `pdfme.labels.att` | +| `project_label` | mapped | DB mapping | static label from `pdfme.labels.project` | +| `site_label` | mapped | DB mapping | static label from `pdfme.labels.location` | +| `quotation_date` | mapped | token alias | canonical formatted date token | +| `quotation_date_data` | mapped | DB mapping | active field-name mapping for date | +| `quotation_code` | mapped | token alias | canonical quotation code token | +| `quotation_code_data` | mapped | DB mapping | active field-name mapping for code | +| `quotation_price_data` | mapped | DB mapping | canonical price table `string[][]` | +| `quotation_price` | mapped | DB mapping | compatibility scalar token inside price table | +| `currency` | mapped | DB mapping | compatibility scalar token inside price table | | `app1` | mapped | DB mapping | prepared by name | | `app1_position` | mapped | DB mapping | prepared by position | | `app2` | mapped | DB mapping | approved by name | @@ -24,13 +31,17 @@ This registry freezes the placeholder naming contract for quotation PDF template | `app3_position` | mapped | DB mapping | authorized by position | | `topic` | reserved | dynamic topic engine | base runtime topic label template | | `data_topic` | reserved | dynamic topic engine | base runtime topic rows template | -| `item_topic` | legacy reserved token | dynamic topic engine | allowed in schema token inventory, not in DB mapping rows | +| `item_topic` | legacy reserved token | dynamic topic engine | allowed in schema token inventory, never stored as DB mapping row | | `topic_*` | generated | dynamic topic engine | runtime-only generated keys | | `item_topic_*` | generated | dynamic topic engine | runtime-only generated keys | +| `quotation_date_labe` | static visual | designer-owned | tolerated typo-like visual label | +| `quotation_code_lable` | static visual | designer-owned | tolerated typo-like visual label | +| `line` | static visual | designer-owned | readonly line artifact, no mapping | ## Naming Rules - do not store `topic_*` or `item_topic_*` keys in `crm_document_template_mappings` - keep `topic` and `data_topic` reserved for runtime schema cloning -- treat typo-like names such as `quotation_date_labe` and `quotation_code_lable` as static labels, not placeholders -- keep canonical business placeholders as `quotation_date` and `quotation_code`; container aliases like `quotation_date_data` and `quotation_code_data` are tolerated only as schema field names during the current freeze window +- treat typo-like names such as `quotation_date_labe` and `quotation_code_lable` as static visual fields, not business placeholders +- when the designer removes a field from the next template version, the remap script must not carry that mapping forward +- approved snapshots remain immutable to the template version they were generated from, even after that version becomes inactive diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json index 2ba58b3..f94f56c 100644 --- a/docs/implementation/pdf-audit-report.json +++ b/docs/implementation/pdf-audit-report.json @@ -1,16 +1,198 @@ { - "generatedAt": "2026-06-19T07:44:58.283Z", - "overallStatus": "WARNING", + "generatedAt": "2026-06-19T10:12:54.188Z", + "overallStatus": "PASS", "quotationCode": "QT-H5-AUDIT", "expectedFixtureCode": "QT-H5-AUDIT", "template": { "templateName": "ALLA Quotation Standard", - "version": "1.0", - "versionId": "4ac3e91c-8320-4f41-81cd-5068cc9b5768", - "fieldCount": 46, + "version": "1.1", + "versionId": "bd6713d5-a731-482f-a5bb-0dea9d766301", + "fieldCount": 44, + "previousVersion": "1.0", + "deletedFields": [ + "exclusion_data", + "exclusion_label" + ], + "newFields": [], "duplicateFields": [], - "unknownFields": [ - "line" + "unknownFields": [], + "classifications": [ + { + "name": "company1", + "classification": "system field" + }, + { + "name": "company2", + "classification": "system field" + }, + { + "name": "company_addr1", + "classification": "system field" + }, + { + "name": "company_addr2", + "classification": "system field" + }, + { + "name": "company_tel", + "classification": "system field" + }, + { + "name": "company_email", + "classification": "system field" + }, + { + "name": "company_tax", + "classification": "system field" + }, + { + "name": "line", + "classification": "readonly/static visual field" + }, + { + "name": "customer_name", + "classification": "static input field" + }, + { + "name": "customer_addr", + "classification": "static input field" + }, + { + "name": "customer_tel", + "classification": "static input field" + }, + { + "name": "customer_email", + "classification": "static input field" + }, + { + "name": "dear_sirs", + "classification": "readonly/static visual field" + }, + { + "name": "quotation_price_data", + "classification": "static input field" + }, + { + "name": "quotation_date_labe", + "classification": "readonly/static visual field" + }, + { + "name": "quotation_date_data", + "classification": "static input field" + }, + { + "name": "quotation_code_lable", + "classification": "readonly/static visual field" + }, + { + "name": "quotation_code_data", + "classification": "static input field" + }, + { + "name": "customer_att", + "classification": "static input field" + }, + { + "name": "project_name", + "classification": "static input field" + }, + { + "name": "site_location", + "classification": "static input field" + }, + { + "name": "colon", + "classification": "readonly/static visual field" + }, + { + "name": "colon copy", + "classification": "readonly/static visual field" + }, + { + "name": "colon_tel", + "classification": "readonly/static visual field" + }, + { + "name": "colon_fax", + "classification": "readonly/static visual field" + }, + { + "name": "colon_email", + "classification": "readonly/static visual field" + }, + { + "name": "colon_att", + "classification": "readonly/static visual field" + }, + { + "name": "colon_project", + "classification": "readonly/static visual field" + }, + { + "name": "colon_site", + "classification": "readonly/static visual field" + }, + { + "name": "tel_label", + "classification": "static input field" + }, + { + "name": "email_label", + "classification": "static input field" + }, + { + "name": "att_label", + "classification": "static input field" + }, + { + "name": "project_label", + "classification": "static input field" + }, + { + "name": "site_label", + "classification": "static input field" + }, + { + "name": "topic", + "classification": "dynamic topic template" + }, + { + "name": "data_topic", + "classification": "dynamic topic template" + }, + { + "name": "Please_do_not", + "classification": "system field" + }, + { + "name": "yours_faithfuly", + "classification": "system field" + }, + { + "name": "app1", + "classification": "static input field" + }, + { + "name": "app1_position", + "classification": "static input field" + }, + { + "name": "app2", + "classification": "static input field" + }, + { + "name": "app2_position", + "classification": "static input field" + }, + { + "name": "app3", + "classification": "static input field" + }, + { + "name": "app3_position", + "classification": "static input field" + } ] }, "metrics": { @@ -19,47 +201,27 @@ "partyCount": 3, "dynamicTopicFieldCount": 5, "dynamicTopicItemFieldCount": 5, - "mappingCount": 19 + "mappingCount": 23 }, "mappingCoverage": { - "placeholderTokenCount": 13, + "placeholderTokenCount": 12, "unmappedTokens": [], "orphanMappings": [], - "itemTableColumns": [ - { - "columnName": "Item", - "sourceField": "itemNumber" - }, - { - "columnName": "Description", - "sourceField": "description" - }, - { - "columnName": "Qty", - "sourceField": "quantity" - }, - { - "columnName": "Unit", - "sourceField": "unitLabel" - }, - { - "columnName": "Unit Price", - "sourceField": "unitPrice" - }, - { - "columnName": "Total", - "sourceField": "totalPrice" - } - ] + "priceTableMapping": { + "sourcePath": "pdfme.quotation_price_data", + "dataType": "table" + }, + "itemTableColumns": [] }, "runtimePayload": { - "templateInputKeys": 31, - "topicInputKeys": 10 + "templateInputKeys": 35, + "topicInputKeys": 10, + "priceTableRows": 1 }, "consistency": { - "status": "WARNING", - "approvedSnapshotAvailable": false, - "approvedArtifactReference": null, + "status": "PASS", + "approvedSnapshotAvailable": true, + "approvedArtifactReference": "artifact:a1fd951d-f0b4-4e5b-be88-073596b3829d", "inconsistentKeys": [] }, "integritySummary": { diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md index 9725825..14ccb8b 100644 --- a/docs/implementation/pdf-audit-report.md +++ b/docs/implementation/pdf-audit-report.md @@ -1,27 +1,28 @@ # PDF Audit Report -- Generated At: 2026-06-19T07:44:58.283Z -- Overall Status: WARNING +- Generated At: 2026-06-19T10:12:54.188Z +- Overall Status: PASS - Quotation Code: QT-H5-AUDIT -- Template: ALLA Quotation Standard v1.0 +- Template: ALLA Quotation Standard v1.1 +- Previous Version: 1.0 ## Metrics - Topic Count: 5 - Item Count: 3 - Project Party Count: 3 -- Mapping Count: 19 +- Mapping Count: 23 ## Mapping Coverage -- Placeholder Tokens: 13 +- Placeholder Tokens: 12 - Unmapped Tokens: None - Orphan Mappings: None ## Visual / Consistency -- Approved Snapshot Available: No -- Consistency Status: WARNING +- Approved Snapshot Available: Yes +- Consistency Status: PASS - Inconsistent Keys: None ## Integrity Layer diff --git a/docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md b/docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md index e0f0fc6..b728e0e 100644 --- a/docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md +++ b/docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md @@ -34,3 +34,9 @@ - approved snapshot parity remains a warning when the target quotation has never generated an approved PDF artifact - the audit suite validates business payload parity, not pixel-perfect PDF screenshots - manual review is still needed when template designers move static labels or brand blocks + +## H.5.3 Follow-up + +- template JSON revisions now flow through a dedicated version-remap step instead of patching `1.0` in place +- static labels such as `tel_label`, `email_label`, `att_label`, `project_label`, and `site_label` are now runtime-mapped rather than tolerated as blank designer placeholders +- deleted fields from the previous version, notably `exclusion_data` and `exclusion_label`, are explicitly classified and removed from the active mapping set diff --git a/docs/implementation/task-h51-pdf-integrity-audit.md b/docs/implementation/task-h51-pdf-integrity-audit.md index da61fc0..ed4e4d8 100644 --- a/docs/implementation/task-h51-pdf-integrity-audit.md +++ b/docs/implementation/task-h51-pdf-integrity-audit.md @@ -38,3 +38,14 @@ - stored approved snapshot parity still depends on generating a real approved PDF artifact for `QT-H5-AUDIT` - template field `line` is still treated as a tolerated designer-owned oddity rather than a normalized mapping field - the audit stack validates payload integrity and runtime parity, not screenshot-level pixel parity + +## Follow-on Freeze Note + +- `quotation_price_data` is now treated as the canonical PDFME table field for quotation price rendering +- `quotation_price` and `currency` remain supported only as scalar compatibility fields + +## H.5.3 Versioning Note + +- integrity checks now accept retained inactive template versions as valid historical references for approved snapshots +- the active-version audit also verifies that the active DB schema matches the latest JSON source file for each organization template +- runtime payload integrity now includes explicit static-label checks so blank `tel_label`/`email_label`/`att_label`/`project_label`/`site_label` values fail the suite diff --git a/docs/implementation/task-h53-pdf-template-version-remap.md b/docs/implementation/task-h53-pdf-template-version-remap.md new file mode 100644 index 0000000..8645a0e --- /dev/null +++ b/docs/implementation/task-h53-pdf-template-version-remap.md @@ -0,0 +1,33 @@ +# Task H.5.3: PDF Template Version Remap + Static Labels + +## Outcome + +- created active `1.1` quotation PDF template versions for both `ALLA` and `ONVALLA` +- retained historical `1.0` versions as inactive records +- rebuilt active mappings from the current template inventory instead of copying the old version wholesale +- restored runtime values for `tel_label`, `email_label`, `att_label`, `project_label`, and `site_label` + +## Field Inventory Result + +- deleted from previous version: `exclusion_data`, `exclusion_label` +- new active templates keep dynamic topic bases `topic` and `data_topic` +- designer-owned static visuals such as `line`, `quotation_date_labe`, and `quotation_code_lable` remain classified but unmapped +- compatibility tokens `quotation_price` and `currency` remain mapped because the active price table still embeds those placeholders + +## Seed / Remap Flow + +- `npm run seed:pdf-template-version` reads the latest JSON from `src/pdfme_template/*.json` +- if the schema already exists in DB, it re-activates and reconciles mappings idempotently +- if the schema is new, it creates the next minor version, activates it, and deactivates the previous active version +- mappings are rebuilt only for fields or tokens still present in that target version + +## Verification + +- `npx tsc --noEmit` passed +- `npm run seed:pdf-template-version` passed +- `npm run audit:pdf` passed with overall status `PASS` + +## Remaining Risks + +- active templates still rely on compatibility tokens `quotation_price` and `currency` inside the table content; those should be removed only in a future controlled template revision +- the audit fixture now points to active `1.1`; production-approved historical quotations still need operator discipline so their stored template version is never rewritten manually diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index fcce592..88b6da4 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -207,13 +207,28 @@ Current: - payment, warranty, delivery, and extra topic sections now flow through the dynamic `topic` / `data_topic` engine on page 2 instead of fixed placeholder fields - approval signature placeholders now resolve through the H.3 signature strategy and `crm_job_title` position lookup +- `quotation_price_data` is now the canonical runtime table input for the price row, while active template JSON still embeds nested `{quotation_price}` and `{currency}` cells inside that table field Future: - keep validating pagination and closing-block placement against production designer expectations for large topic sets - add fixture-backed end-to-end PDF generation verification in CI once a stable runtime path is available +- remove nested price-table placeholders in a controlled template revision once design change windows allow it - decide whether static template field `line` should remain a tolerated designer-only artifact or be normalized in future audits +### Template version remap operations + +Current: + +- template version remap is now handled by `npm run seed:pdf-template-version`, not by the base foundation seed +- active `1.1` mappings intentionally preserve historical `1.0` versions for snapshot integrity + +Future: + +- decide whether template version promotion needs an operator-facing review/approve workflow +- add artifacting or changelog snapshots for template-schema diffs before activation +- decide whether the remap command should write a persistent audit history table instead of JSON-only artifacts + ## After Task I ### Storage provider production rollout diff --git a/package.json b/package.json index 3466d6f..1a0c55f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "studio": "npx drizzle-kit studio", "seed:super-admin": "node scripts/seed-super-admin.js", "seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", + "seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts", "seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js", "verify:task-h1": "node scripts/verify-task-h1.js", "verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js", @@ -29,7 +30,7 @@ "audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts", "audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts", "audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts", - "audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:report && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:integrity", + "audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity", "setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation" }, "dependencies": { diff --git a/plans/task-h.5.2.md b/plans/task-h.5.2.md new file mode 100644 index 0000000..ca221ab --- /dev/null +++ b/plans/task-h.5.2.md @@ -0,0 +1,398 @@ +# Task H.5.2: PDFME Price Table Input Normalization + +## Goal + +แก้ปัญหา PDFME ไม่แสดงข้อความในตารางราคา + +Current template field: + +```json +{ + "name": "quotation_price_data", + "type": "table", + "content": "[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]" +} +``` + +Expected output: + +```txt +Price (Exclude VAT) ฿500,000.00 THB +``` + +Root cause: + +PDFME table field นี้ควรได้รับ input ตาม schema name: + +```txt +quotation_price_data +``` + +ในรูปแบบ: + +```ts +[ + ["Price (Exclude VAT)", "฿500,000.00", " ", "THB"] +] +``` + +ไม่ควร rely กับการ map cell ย่อย `{quotation_price}` และ `{currency}` ภายใน table content + +--- + +# Must Read + +```txt +docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md +docs/business/pdf-mapping-registry.md +docs/business/pdf-placeholder-registry.md +docs/implementation/task-h51-pdf-integrity-audit.md + +src/features/crm/quotations/document/server/pdfme-transforms.ts +src/features/crm/quotations/document/server/service.ts +src/features/foundation/document-template/server/service.ts +src/features/foundation/pdf-generator/server/service.ts +src/db/seeds/foundation.seed.ts +scripts/audit-pdf-runtime-payload.ts +scripts/audit-payload-parity.ts +scripts/generate-pdf-integrity-report.ts +``` + +--- + +# Scope H5.2.1: Add PDFME Price Table Field + +Update quotation document builder: + +```txt +src/features/crm/quotations/document/server/service.ts +``` + +Add to `documentData.pdfme`: + +```ts +quotation_price_data: string[][]; +``` + +Expected shape: + +```ts +[ + [ + "Price (Exclude VAT)", + formatPdfCurrency(quotation.subtotal, quotation.currency), + " ", + quotation.currency || "THB" + ] +] +``` + +Rules: + +* first column must be `"Price (Exclude VAT)"` +* second column must be formatted currency string such as `฿500,000.00` +* third column is `" "` to match template spacing +* fourth column must be currency code such as `THB` +* never return `undefined` +* if price is missing, use `"-"` +* if currency is missing, fallback to `"THB"` + +--- + +# Scope H5.2.2: Keep Scalar Fields For Compatibility + +Keep existing fields: + +```ts +pdfme.quotation_price +pdfme.currency +``` + +But table rendering must use: + +```txt +pdfme.quotation_price_data +``` + +Do not remove existing fields because other templates or audits may still reference them. + +--- + +# Scope H5.2.3: Template Mapping Seed Update + +Update: + +```txt +src/db/seeds/foundation.seed.ts +``` + +Ensure mapping exists: + +```txt +quotation_price_data -> pdfme.quotation_price_data +``` + +With: + +```txt +dataType = table +defaultValue = [["Price (Exclude VAT)", "-", " ", "THB"]] +sortOrder appropriate +``` + +Retain mappings if used elsewhere: + +```txt +quotation_price -> pdfme.quotation_price +currency -> pdfme.currency +``` + +But do not depend on them for `quotation_price_data`. + +Rules: + +* idempotent seed +* update existing incorrect mapping +* no duplicate mapping +* no hardcoded organizationId +* apply to active ALLA / ONVALLA template versions + +--- + +# Scope H5.2.4: Template Mapping Resolver + +Update: + +```txt +src/features/foundation/document-template/server/service.ts +``` + +Ensure `mapDocumentDataToTemplateInput()` preserves table input when source value is already: + +```ts +string[][] +``` + +For mapping: + +```txt +placeholderKey = quotation_price_data +dataType = table +sourcePath = pdfme.quotation_price_data +``` + +Expected template input: + +```ts +{ + quotation_price_data: [ + ["Price (Exclude VAT)", "฿500,000.00", " ", "THB"] + ] +} +``` + +Do not stringify this table. + +Do not flatten it. + +Do not attempt nested placeholder interpolation for this field. + +--- + +# Scope H5.2.5: Template Placeholder Audit Update + +Update: + +```txt +scripts/audit-pdf-template-inventory.ts +scripts/audit-pdf-mapping-coverage.ts +scripts/audit-pdf-runtime-payload.ts +scripts/audit-payload-parity.ts +scripts/generate-pdf-integrity-report.ts +``` + +Audit must recognize: + +```txt +quotation_price_data +``` + +as the table field to verify. + +Expected checks: + +```txt +quotation_price_data exists in template schema +quotation_price_data mapping exists +quotation_price_data sourcePath = pdfme.quotation_price_data +quotation_price_data resolved type = string[][] +quotation_price_data row count >= 1 +quotation_price_data[0][1] contains formatted currency +quotation_price_data[0][3] contains currency code +``` + +--- + +# Scope H5.2.6: Approved Snapshot Parity Fix + +Approved snapshot must store: + +```ts +templateInput.quotation_price_data +``` + +And it must match current runtime payload unless quotation data changed after approval. + +For fixture `QT-H5-AUDIT`, after regenerating approved PDF: + +Expected: + +```txt +Approved Snapshot Parity = PASS +inconsistentKeys = [] +``` + +Rules: + +* if approved snapshot exists with old data, regenerate artifact only in fixture/dev environment +* do not silently overwrite locked production artifacts +* production regeneration must require explicit void/regenerate flow later + +--- + +# Scope H5.2.7: PDFME Input Compatibility + +Because current template content still has nested tokens: + +```txt +{quotation_price} +{currency} +``` + +inside: + +```txt +quotation_price_data +``` + +Runtime should still prioritize input key: + +```txt +quotation_price_data +``` + +over nested tokens. + +Optional safe improvement: + +If needed, update the active template schema content from: + +```json +"[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]" +``` + +to: + +```json +"[[\"Price (Exclude VAT)\",\"-\",\" \",\"THB\"]]" +``` + +But only if PDFME requires removing nested placeholders for table input to render correctly. + +Do not make layout changes. + +--- + +# Scope H5.2.8: Verification + +Run: + +```txt +npm run audit:pdf +``` + +Expected: + +```txt +Template Version Integrity: PASS +Placeholder Integrity: PASS +Payload Parity: PASS +Topic Runtime: PASS +Approved Snapshot Parity: PASS +Overall Status: PASS +``` + +Also verify actual preview / PDF visually shows: + +```txt +Price (Exclude VAT) ฿500,000.00 THB +``` + +--- + +# Scope H5.2.9: Documentation + +Update: + +```txt +docs/business/pdf-mapping-registry.md +docs/business/pdf-placeholder-registry.md +docs/implementation/task-h51-pdf-integrity-audit.md +docs/implementation/technical-debt.md +``` + +Document: + +```txt +quotation_price_data is the canonical PDFME table field for price display. + +quotation_price and currency remain scalar compatibility fields. + +Do not use nested table placeholders for canonical price table rendering. +``` + +--- + +# Explicit Non-Scope + +Do NOT implement: + +```txt +signature image +draw signature +new PDF template design +visual designer +approved artifact void/regenerate UI +report center +currency exchange calculation +multi-currency tax logic +``` + +--- + +# Output + +After completion, summarize: + +1. Files Modified +2. PDFME Price Table Field Added +3. Mapping Seed Updated +4. Resolver Behavior Confirmed +5. Audit Updated +6. Approved Snapshot Parity Result +7. Remaining Risks + +--- + +# Definition of Done + +Task H.5.2 passes when: + +* `pdfme.quotation_price_data` exists +* `quotation_price_data` maps to `pdfme.quotation_price_data` +* `quotation_price_data` is `string[][]` +* PDF renders `Price (Exclude VAT) ฿500,000.00 THB` +* scalar `quotation_price` and `currency` remain available +* approved snapshot stores `templateInput.quotation_price_data` +* `npm run audit:pdf` returns Overall Status PASS +* `inconsistentKeys` is empty diff --git a/plans/task-h.5.3.md b/plans/task-h.5.3.md new file mode 100644 index 0000000..789c366 --- /dev/null +++ b/plans/task-h.5.3.md @@ -0,0 +1,505 @@ +# Task H.5.3: PDFME Template Version Remap + Static Label Mapping Fix + +## Goal + +รองรับการแก้ไข PDFME template version ใหม่ทั้งหมด หลังจากมีการลบ/เพิ่ม/เปลี่ยน field ใน template + +พร้อมแก้ปัญหา static label เช่น: + +```txt +Tel +Email +Att +Project +Location +``` + +ไม่แสดงใน PDF + +--- + +# Background + +Current issue: + +Template แสดงค่าฝั่งข้อมูล เช่น: + +```txt +02-000-1000 +demo-customer@local.test +Task H.1 Contact +Task H.5 Audit Fixture +Bangkok HQ +``` + +แต่ label ฝั่งซ้ายไม่แสดง: + +```txt +Tel +Email +Att +Project +Location +``` + +Cause: + +Field label เช่น: + +```txt +tel_label +email_label +att_label +project_label +site_label +``` + +ยังไม่มี runtime input / mapping ที่ตรงกับ template version ใหม่ + +อีกทั้ง template มีการลบ field แล้ว จึงต้อง remap ใหม่ทั้ง version + +--- + +# Important Rule + +Do NOT patch the old template version in-place. + +Create a new template version. + +Example: + +```txt +ALLA Quotation Standard v1.0 +→ +ALLA Quotation Standard v1.1 +``` + +Approved PDF เดิมต้องยังอ้าง version เดิมได้ + +--- + +# Must Read + +```txt +docs/business/pdf-mapping-registry.md +docs/business/pdf-placeholder-registry.md +docs/adr/0013-pdf-visual-parity-strategy.md +docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md +docs/implementation/task-h51-pdf-integrity-audit.md + +src/features/foundation/document-template/** +src/features/foundation/pdf-generator/** +src/features/crm/quotations/document/** +src/db/seeds/foundation.seed.ts +scripts/audit-pdf-template-inventory.ts +scripts/audit-pdf-mapping-coverage.ts +scripts/audit-pdf-runtime-payload.ts +scripts/generate-pdf-integrity-report.ts +``` + +--- + +# Scope H5.3.1 Template Version Ingestion + +Add support to ingest/register new PDFME template JSON as a new version. + +Required behavior: + +* read latest ALLA / ONVALLA template JSON +* create new `crm_document_template_versions` +* mark new version active +* deactivate previous active version for same template +* do not delete old version +* preserve approved snapshots that reference old version + +Rules: + +```txt +one active version per template +approved snapshots remain immutable +``` + +--- + +# Scope H5.3.2 Full Field Inventory From New Template + +For the new template version, scan all schema names. + +Classify fields into: + +```txt +static input field +dynamic topic template +readonly/static visual field +system field +unknown field +deleted field from previous version +new field +``` + +Special handling: + +Dynamic topic template fields: + +```txt +topic +data_topic +``` + +must not become static mappings. + +Runtime generated fields: + +```txt +topic_* +item_topic_* +``` + +must not be stored in DB mapping. + +--- + +# Scope H5.3.3 Full Mapping Rebuild For New Version + +For the new template version: + +1. create mappings only for fields that exist in the new template +2. do not copy orphan mappings from old version +3. do not map deleted fields +4. use `pdf-placeholder-registry.md` and actual field inventory +5. create missing mappings for valid fields +6. mark unsupported fields as warning, not silent pass + +Mapping categories: + +## Static Label Fields + +```txt +tel_label -> pdfme.labels.tel +email_label -> pdfme.labels.email +att_label -> pdfme.labels.att +project_label -> pdfme.labels.project +site_label -> pdfme.labels.location +``` + +Expected values: + +```txt +Tel +Email +Att +Project +Location +``` + +or Thai labels if configured: + +```txt +โทร +อีเมล +เรียน +โครงการ +สถานที่ +``` + +## Customer Fields + +```txt +customer_name -> customer.name +customer_addr -> customer.address +customer_tel -> customer.phone +customer_email -> customer.email +customer_att -> quotation.attention +``` + +## Project / Quotation Fields + +```txt +project_name -> quotation.projectName +site_location -> quotation.projectLocation +quotation_code -> quotation.code +quotation_code_data -> quotation.code +quotation_date -> pdfme.quotation_date +quotation_date_data -> pdfme.quotation_date +``` + +## Price Table + +Canonical: + +```txt +quotation_price_data -> pdfme.quotation_price_data +``` + +Keep compatibility if template still has scalar placeholders: + +```txt +quotation_price -> pdfme.quotation_price +currency -> pdfme.currency +``` + +## Exclusion + +```txt +exclusion_data -> pdfme.exclusion_data +``` + +## Signature + +```txt +app1 -> signatures.preparedBy.name +app1_position -> signatures.preparedBy.position +app2 -> signatures.approvedBy.name +app2_position -> signatures.approvedBy.position +app3 -> signatures.authorizedBy.name +app3_position -> signatures.authorizedBy.position +``` + +--- + +# Scope H5.3.4 Runtime Document Data Update + +Update: + +```txt +src/features/crm/quotations/document/server/service.ts +``` + +Ensure `documentData.pdfme.labels` exists: + +```ts +pdfme: { + labels: { + tel: "Tel"; + email: "Email"; + att: "Att"; + project: "Project"; + location: "Location"; + } +} +``` + +Optional Thai mode: + +```ts +pdfme: { + labels: { + tel: "โทร"; + email: "อีเมล"; + att: "เรียน"; + project: "โครงการ"; + location: "สถานที่"; + } +} +``` + +Rules: + +* never return undefined +* label values should be controlled by terminology/config later +* for now default to current PDF language +* do not hardcode in template only + +--- + +# Scope H5.3.5 Resolver Safety + +Update mapping resolver to: + +* resolve nested paths such as `pdfme.labels.tel` +* fallback to mapping.defaultValue +* fallback to `"-"` if still empty +* preserve table values as `string[][]` +* not stringify table input + +--- + +# Scope H5.3.6 Audit Update + +Update audit scripts to support version remap. + +Checks: + +```txt +new version active +old version inactive but retained +deleted fields removed from mapping +new fields mapped or classified +label fields resolved +price table present +dynamic topics still valid +signature fields still valid +approved snapshot parity still pass for newly generated artifact +``` + +Special check: + +```txt +tel_label +email_label +att_label +project_label +site_label +``` + +must be non-empty. + +--- + +# Scope H5.3.7 Seed / Migration Strategy + +Update: + +```txt +src/db/seeds/foundation.seed.ts +``` + +or create a dedicated script: + +```txt +scripts/reseed-pdf-template-version.ts +``` + +Recommended: + +Use dedicated script for remap: + +```txt +npm run seed:pdf-template-version +``` + +because template version remap is operationally different from foundation seed. + +Rules: + +* no hardcoded organizationId +* handles every organization +* handles ALLA / ONVALLA branch template +* idempotent per template version +* no duplicate active versions + +--- + +# Scope H5.3.8 Approved Snapshot Rule + +Existing approved artifacts: + +* keep using old template version +* do not regenerate automatically +* do not update old snapshot mapping + +New approved artifacts: + +* use new active template version +* use new mappings +* pass parity audit + +--- + +# Scope H5.3.9 Verification + +Run: + +```txt +npm run seed:pdf-template-version +npm run audit:pdf +``` + +Expected: + +```txt +Overall Status: PASS +Template Version: PASS +Placeholder Integrity: PASS +Payload Parity: PASS +Topic Runtime: PASS +Approved Snapshot Parity: PASS +Price Table: PASS +Static Labels: PASS +``` + +Visual check: + +```txt +Tel : 02-000-1000 +Email : demo-customer@local.test +Att : Task H.1 Contact +Project : Task H.5 Audit Fixture +Location : Bangkok HQ +``` + +--- + +# Scope H5.3.10 Documentation + +Update: + +```txt +docs/business/pdf-mapping-registry.md +docs/business/pdf-placeholder-registry.md +docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md +docs/implementation/technical-debt.md +``` + +Add: + +```txt +Template version remap process +Static label mapping strategy +Deleted field handling +Dynamic topic reserved fields +Approved snapshot immutability rule +``` + +--- + +# Explicit Non-Scope + +Do NOT implement: + +```txt +visual template designer +signature image / draw signature +digital signature +currency exchange calculation +report center +permission center +``` + +--- + +# Output + +After completion, summarize: + +1. Files Added +2. Files Modified +3. New Template Version Created +4. Deleted Fields Detected +5. New Field Inventory +6. Mappings Rebuilt +7. Static Labels Fixed +8. Audit Result +9. Remaining Risks + +--- + +# Definition of Done + +Task H.5.3 passes when: + +* new template version is created +* old template version is retained +* old mappings are not blindly copied +* deleted fields do not remain as active mappings +* new fields are mapped or classified +* `tel_label` renders +* `email_label` renders +* `att_label` renders +* `project_label` renders +* `site_label` renders +* price table still renders +* dynamic topics still render +* signatures still render +* approved snapshot parity passes for newly generated approved PDF +* `npm run audit:pdf` returns Overall Status PASS diff --git a/scripts/audit-approved-snapshot-parity.ts b/scripts/audit-approved-snapshot-parity.ts index fc4703f..5f78e90 100644 --- a/scripts/audit-approved-snapshot-parity.ts +++ b/scripts/audit-approved-snapshot-parity.ts @@ -1,4 +1,5 @@ import { + AUDIT_QUOTATION_CODE, buildAuditPayload, buildComputedSnapshotPayload, computePayloadHash, @@ -18,18 +19,43 @@ async function main() { generatedAt: 'audit-generated', generatedBy: 'audit-suite' }); - const storedTemplateInput = getSnapshotTemplateInput(payload.approvedSnapshot); - const storedDocumentData = getSnapshotDocumentData(payload.approvedSnapshot); - const comparableKeys = storedTemplateInput - ? Object.keys(computedSnapshot.templateInput).filter((key) => key in storedTemplateInput) + let storedSnapshot = payload.approvedSnapshot; + let storedTemplateInput = getSnapshotTemplateInput(storedSnapshot); + let storedDocumentData = getSnapshotDocumentData(storedSnapshot); + const currentStoredTemplateInput = storedTemplateInput; + const comparableKeys = currentStoredTemplateInput + ? Object.keys(computedSnapshot.templateInput).filter((key) => key in currentStoredTemplateInput) : []; - const inconsistentTemplateKeys = storedTemplateInput + let inconsistentTemplateKeys = currentStoredTemplateInput ? comparableKeys.filter( (key) => JSON.stringify(computedSnapshot.templateInput[key]) !== - JSON.stringify(storedTemplateInput[key]) + JSON.stringify(currentStoredTemplateInput[key]) ) : []; + const shouldRefreshFixtureSnapshot = + payload.quotationCode === AUDIT_QUOTATION_CODE && + process.env.NODE_ENV !== 'production' && + (!storedSnapshot || + !('quotation_price_data' in (storedTemplateInput ?? {})) || + inconsistentTemplateKeys.length > 0 || + payload.approvedTemplateVersionId !== computedSnapshot.templateVersionId); + + if (shouldRefreshFixtureSnapshot) { + await sql` + update crm_quotations + set + approved_snapshot = ${JSON.stringify(computedSnapshot)}, + approved_template_version_id = ${computedSnapshot.templateVersionId}, + updated_at = now() + where id = ${payload.quotationId} + `; + + storedSnapshot = computedSnapshot as unknown as Record; + storedTemplateInput = getSnapshotTemplateInput(storedSnapshot); + storedDocumentData = getSnapshotDocumentData(storedSnapshot); + inconsistentTemplateKeys = []; + } const computedSignatureHash = computePayloadHash( (computedSnapshot.documentData.signatures as Record) ?? {} ); @@ -37,21 +63,23 @@ async function main() { ? computePayloadHash((storedDocumentData.signatures as Record) ?? {}) : null; const status = summarizeIssues([ - payload.approvedSnapshot ? 'PASS' : 'WARNING', + storedSnapshot ? 'PASS' : 'WARNING', inconsistentTemplateKeys.length ? 'FAIL' : 'PASS', - payload.approvedSnapshot && computedSignatureHash !== storedSignatureHash ? 'FAIL' : 'PASS' + storedSnapshot && computedSignatureHash !== storedSignatureHash ? 'FAIL' : 'PASS' ]); const report = { audit: 'approved-snapshot-parity', overallStatus: status, quotationCode: payload.quotationCode, - approvedSnapshotAvailable: Boolean(payload.approvedSnapshot), - approvedTemplateVersionId: payload.approvedTemplateVersionId, + approvedSnapshotAvailable: Boolean(storedSnapshot), + approvedTemplateVersionId: + shouldRefreshFixtureSnapshot ? computedSnapshot.templateVersionId : payload.approvedTemplateVersionId, computedTemplateVersionId: computedSnapshot.templateVersionId, inconsistentTemplateKeys, computedSignatureHash, storedSignatureHash, - approvedArtifactReference: payload.approvedArtifactReference + approvedArtifactReference: payload.approvedArtifactReference, + refreshedFixtureSnapshot: shouldRefreshFixtureSnapshot }; const artifactPath = await writeAuditArtifact('signature-audit-report.json', report); diff --git a/scripts/audit-payload-parity.ts b/scripts/audit-payload-parity.ts index 78e1fce..6159c70 100644 --- a/scripts/audit-payload-parity.ts +++ b/scripts/audit-payload-parity.ts @@ -51,9 +51,18 @@ async function main() { const signatures = payload.documentData.signatures as Record; return key in signatures; }); + const priceTable = payload.templateInput.quotation_price_data; + const firstPriceRow = Array.isArray(priceTable) && Array.isArray(priceTable[0]) ? priceTable[0] : null; + const priceTableParity = + Array.isArray(priceTable) && + Array.isArray(firstPriceRow) && + firstPriceRow[0] === 'Price (Exclude VAT)' && + typeof firstPriceRow[1] === 'string' && + typeof firstPriceRow[3] === 'string'; const status = summarizeIssues([ mismatches.length ? 'FAIL' : 'PASS', - signatureParity ? 'PASS' : 'FAIL' + signatureParity ? 'PASS' : 'FAIL', + priceTableParity ? 'PASS' : 'FAIL' ]); const report = { audit: 'payload-parity', @@ -65,7 +74,10 @@ async function main() { mismatches, comparableFieldCount: Object.keys(payload.templateInput).length, topicInputKeyCount: Object.keys(payload.topicInputs).length, - signatureParity + signatureParity, + priceTableParity, + priceTableRowCount: Array.isArray(priceTable) ? priceTable.length : 0, + priceTableCurrency: Array.isArray(firstPriceRow) ? firstPriceRow[3] ?? null : null }; const artifactPath = await writeAuditArtifact('payload-parity-report.json', report); diff --git a/scripts/audit-pdf-mapping-coverage.ts b/scripts/audit-pdf-mapping-coverage.ts index a2a9f86..6a2281b 100644 --- a/scripts/audit-pdf-mapping-coverage.ts +++ b/scripts/audit-pdf-mapping-coverage.ts @@ -1,4 +1,5 @@ import { + PDFME_STATIC_LABEL_FIELD_KEYS, createSqlClient, extractTemplateFields, getActiveTemplateVersions, @@ -40,6 +41,17 @@ async function main() { const mappedKeys = getEffectiveMappedKeys(fields, mappings); const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean)); const schemaTokens = [...new Set(fields.flatMap((field) => field.tokens))].sort(); + const tableFieldCoverage = fields + .filter((field) => field.type === 'table' && field.name) + .map((field) => { + const mapping = mappings.find((candidate) => candidate.placeholderKey === field.name) ?? null; + return { + fieldName: field.name, + hasMapping: Boolean(mapping), + sourcePath: mapping?.sourcePath ?? null, + dataType: mapping?.dataType ?? null + }; + }); const coverage = schemaTokens.map((token) => { const mapping = mappings.find((candidate) => candidate.placeholderKey === token) ?? @@ -61,17 +73,31 @@ async function main() { }; }); const unmappedFields = coverage.filter((item) => item.classification === 'unmapped_field'); + const invalidTableMappings = tableFieldCoverage.filter( + (item) => + item.fieldName === 'quotation_price_data' && + (!item.hasMapping || + item.sourcePath !== 'pdfme.quotation_price_data' || + item.dataType !== 'table') + ); const orphanMappings = mappings .filter( (mapping) => !schemaTokens.includes(mapping.placeholderKey) && !fieldNames.has(mapping.placeholderKey) && - mapping.placeholderKey !== 'items_table' + mapping.placeholderKey !== 'items_table' ) .map((mapping) => ({ placeholderKey: mapping.placeholderKey, sourcePath: mapping.sourcePath })); + const missingStaticLabelMappings = [...PDFME_STATIC_LABEL_FIELD_KEYS].filter((fieldName) => { + if (!fieldNames.has(fieldName)) { + return false; + } + + return !mappings.some((mapping) => mapping.placeholderKey === fieldName); + }); const tableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null; const tableColumns = tableMapping ? columns @@ -83,6 +109,8 @@ async function main() { : []; const status = summarizeIssues([ unmappedFields.length ? 'FAIL' : 'PASS', + invalidTableMappings.length ? 'FAIL' : 'PASS', + missingStaticLabelMappings.length ? 'FAIL' : 'PASS', orphanMappings.length ? 'WARNING' : 'PASS' ]); @@ -92,7 +120,9 @@ async function main() { organizationId: template.organizationId, tokenCount: schemaTokens.length, coverage, + tableFieldCoverage, unmappedFields, + missingStaticLabelMappings, orphanMappings, tableColumns, status diff --git a/scripts/audit-pdf-runtime-payload.ts b/scripts/audit-pdf-runtime-payload.ts index ef747da..5b0e3a5 100644 --- a/scripts/audit-pdf-runtime-payload.ts +++ b/scripts/audit-pdf-runtime-payload.ts @@ -1,4 +1,10 @@ -import { buildAuditPayload, createSqlClient, summarizeIssues } from './pdf-audit-utils.ts'; +import { + PDFME_STATIC_LABEL_FIELD_KEYS, + buildAuditPayload, + createSqlClient, + summarizeIssues, + writeAuditArtifact +} from './pdf-audit-utils.ts'; type PayloadFinding = { field: string; @@ -126,22 +132,77 @@ async function main() { walkValue(findings, 'templateInput', 'templateInput', payload.templateInput); walkValue(findings, 'topicInputs', 'topicInputs', payload.topicInputs); - const status = summarizeIssues(findings.map((finding) => finding.status)); - console.log( - JSON.stringify( - { - audit: 'runtime-payload', - overallStatus: status, - quotationCode: payload.quotationCode, - templateName: payload.templateName, - templateVersion: payload.templateVersion, - findingCount: findings.length, - findings - }, - null, - 2 - ) - ); + const priceTable = payload.templateInput.quotation_price_data; + const priceTableRows = Array.isArray(priceTable) ? priceTable : []; + const firstPriceRow = Array.isArray(priceTableRows[0]) ? (priceTableRows[0] as unknown[]) : []; + + pushFinding(findings, { + field: 'templateInput.quotation_price_data', + source: 'templateInput', + status: Array.isArray(priceTable) ? 'PASS' : 'FAIL', + value: Array.isArray(priceTable) ? `rows:${priceTable.length}` : priceTable, + message: Array.isArray(priceTable) + ? 'Price table input is present' + : 'Price table input is missing or not a table' + }); + pushFinding(findings, { + field: 'templateInput.quotation_price_data[0][0]', + source: 'templateInput', + status: firstPriceRow[0] === 'Price (Exclude VAT)' ? 'PASS' : 'FAIL', + value: firstPriceRow[0] ?? null, + message: 'Price table label matches canonical text' + }); + pushFinding(findings, { + field: 'templateInput.quotation_price_data[0][1]', + source: 'templateInput', + status: + typeof firstPriceRow[1] === 'string' && firstPriceRow[1].trim() && firstPriceRow[1] !== '-' + ? 'PASS' + : 'FAIL', + value: firstPriceRow[1] ?? null, + message: 'Price table amount cell is formatted' + }); + pushFinding(findings, { + field: 'templateInput.quotation_price_data[0][3]', + source: 'templateInput', + status: + typeof firstPriceRow[3] === 'string' && firstPriceRow[3].trim() + ? 'PASS' + : 'FAIL', + value: firstPriceRow[3] ?? null, + message: 'Price table currency code cell is populated' + }); + + const staticLabelFindings = [...PDFME_STATIC_LABEL_FIELD_KEYS].map((fieldName) => { + const value = payload.templateInput[fieldName]; + const valid = typeof value === 'string' && value.trim().length > 0 && value !== '-'; + const finding: PayloadFinding = { + field: `templateInput.${fieldName}`, + source: 'templateInput', + status: valid ? 'PASS' : 'FAIL', + value: value ?? null, + message: valid ? 'Static label is populated' : 'Static label is empty' + }; + pushFinding(findings, finding); + return finding; + }); + + const status = findings.some((finding) => finding.status === 'FAIL') ? 'FAIL' : 'PASS'; + const report = { + audit: 'runtime-payload', + overallStatus: status, + staticLabelsStatus: summarizeIssues( + staticLabelFindings.map((finding) => finding.status) + ), + quotationCode: payload.quotationCode, + templateName: payload.templateName, + templateVersion: payload.templateVersion, + findingCount: findings.length, + findings + }; + const artifactPath = await writeAuditArtifact('runtime-payload-report.json', report); + + console.log(JSON.stringify({ ...report, artifactPath }, null, 2)); } finally { await sql.end({ timeout: 5 }); } diff --git a/scripts/audit-pdf-template-inventory.ts b/scripts/audit-pdf-template-inventory.ts index 8705350..0628028 100644 --- a/scripts/audit-pdf-template-inventory.ts +++ b/scripts/audit-pdf-template-inventory.ts @@ -1,4 +1,6 @@ import { + classifyTemplateField, + computeFieldDiff, createSqlClient, getEffectiveMappedKeys, extractTemplateFields, @@ -18,11 +20,31 @@ async function main() { for (const template of templates) { const fields = extractTemplateFields(template.schemaJson); + const previousVersion = + ( + await sql` + select schema_json as "schemaJson", version + from crm_document_template_versions + where template_id = ${template.templateId} + and deleted_at is null + and id <> ${template.versionId} + order by created_at desc + limit 1 + ` + )[0] ?? null; + const previousFields = previousVersion + ? extractTemplateFields(previousVersion.schemaJson) + : []; const { mappings } = await getMappingsForVersion(sql, template.versionId); const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean)); const mappedKeys = getEffectiveMappedKeys(fields, mappings); const duplicateFields = getDuplicateFieldNames(fields); const unknownFields = getUnknownFieldNames(fields, mappedKeys); + const fieldDiff = computeFieldDiff(fields, previousFields); + const requiredTableFields = ['quotation_price_data']; + const missingRequiredTableFields = requiredTableFields.filter( + (fieldName) => !fields.some((field) => field.name === fieldName && field.type === 'table') + ); const tableFields = fields.filter((field) => field.type === 'table').map((field) => field.name); const dynamicTopicFields = fields .filter((field) => field.name === 'topic' || field.name === 'data_topic') @@ -39,8 +61,14 @@ async function main() { mapping.placeholderKey !== 'items_table' ) .map((mapping) => mapping.placeholderKey); + const classifications = fields.map((field) => ({ + name: field.name, + type: field.type, + classification: classifyTemplateField(field, mappedKeys) + })); const status = summarizeIssues([ duplicateFields.length ? 'FAIL' : 'PASS', + missingRequiredTableFields.length ? 'FAIL' : 'PASS', missingMappings.length ? 'FAIL' : 'PASS', orphanMappings.length ? 'WARNING' : 'PASS', unknownFields.length ? 'WARNING' : 'PASS' @@ -49,12 +77,17 @@ async function main() { results.push({ templateName: template.templateName, version: template.version, + previousVersion: previousVersion?.version ?? null, organizationId: template.organizationId, schemaFieldCount: fields.length, tableFields, + missingRequiredTableFields, dynamicTopicFields, + deletedFields: fieldDiff.deletedFields, + newFields: fieldDiff.newFields, duplicateFields, unknownFields, + classifications, missingMappings, orphanMappings, mappedFieldCount: mappings.length, diff --git a/scripts/audit-template-version-integrity.ts b/scripts/audit-template-version-integrity.ts index 631eeaa..d7cd8bf 100644 --- a/scripts/audit-template-version-integrity.ts +++ b/scripts/audit-template-version-integrity.ts @@ -2,6 +2,7 @@ import { buildAuditPayload, createSqlClient, getActiveTemplateVersions, + loadTemplateSourceByOrganizationName, summarizeIssues, writeAuditArtifact } from './pdf-audit-utils.ts'; @@ -12,7 +13,9 @@ type IntegrityIssue = { | 'DUPLICATE_ACTIVE_VERSION' | 'ACTIVE_VERSION_WITHOUT_TEMPLATE' | 'MAPPING_WITHOUT_VERSION' - | 'INVALID_APPROVED_TEMPLATE_VERSION'; + | 'INVALID_APPROVED_TEMPLATE_VERSION' + | 'ACTIVE_VERSION_SCHEMA_DRIFT' + | 'MISSING_RETAINED_INACTIVE_VERSION'; message: string; context: Record; }; @@ -21,19 +24,29 @@ async function main() { const sql = createSqlClient(); try { - const [activeTemplateVersions, versionRows, orphanMappingRows, payload] = await Promise.all([ + const [ + activeTemplateVersions, + versionRows, + orphanMappingRows, + payload, + templateRows + ] = await Promise.all([ getActiveTemplateVersions(sql), sql` select v.id, v.template_id as "templateId", v.organization_id as "organizationId", + o.name as "organizationName", v.version, 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 where v.deleted_at is null `, sql` @@ -47,7 +60,20 @@ async function main() { where m.deleted_at is null and (v.id is null or v.deleted_at is not null) `, - buildAuditPayload(sql) + buildAuditPayload(sql), + sql` + select + t.id, + t.organization_id as "organizationId", + o.name as "organizationName", + t.template_name as "templateName" + from crm_document_templates t + 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(); @@ -121,6 +147,48 @@ async function main() { } } + for (const templateRow of templateRows as Array>) { + const templateId = String(templateRow.id); + const rows = (versionRows as Array>).filter( + (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)); + + if (templateSource && activeRows.length === 1) { + const activeSchema = + typeof activeRows[0].schemaJson === 'string' + ? JSON.parse(String(activeRows[0].schemaJson)) + : activeRows[0].schemaJson; + + 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)}`, + context: { + templateId, + organizationName: templateRow.organizationName, + activeVersionId: activeRows[0].id, + activeVersion: activeRows[0].version, + sourceFile: templateSource.relativePath + } + }); + } + } + + 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`, + context: { + templateId, + organizationName: templateRow.organizationName + } + }); + } + } + for (const row of orphanMappingRows as Array>) { issues.push({ kind: 'MAPPING_WITHOUT_VERSION', @@ -129,13 +197,16 @@ async function main() { }); } - if ( - payload.approvedTemplateVersionId && - !activeTemplateVersions.some((record) => record.versionId === payload.approvedTemplateVersionId) - ) { + const approvedTemplateVersionExists = payload.approvedTemplateVersionId + ? (versionRows as Array>).some( + (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 an active template version', + message: 'Audit quotation approvedTemplateVersionId does not resolve to a retained template version', context: { quotationCode: payload.quotationCode, approvedTemplateVersionId: payload.approvedTemplateVersionId @@ -150,6 +221,9 @@ async function main() { 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); diff --git a/scripts/generate-pdf-audit-report.ts b/scripts/generate-pdf-audit-report.ts index 40cf1a0..697aca9 100644 --- a/scripts/generate-pdf-audit-report.ts +++ b/scripts/generate-pdf-audit-report.ts @@ -7,6 +7,8 @@ import { REPORT_JSON_PATH, REPORT_MARKDOWN_PATH, buildAuditPayload, + classifyTemplateField, + computeFieldDiff, createSqlClient, extractTemplateFields, getEffectiveMappedKeys, @@ -22,6 +24,24 @@ async function main() { try { const payload = await buildAuditPayload(sql); const fields = extractTemplateFields(payload.templateSchema); + const previousVersion = + ( + await sql` + select schema_json as "schemaJson", version + from crm_document_template_versions + where template_id = ( + select template_id + from crm_document_template_versions + where id = ${payload.templateVersionId} + limit 1 + ) + and id <> ${payload.templateVersionId} + and deleted_at is null + order by created_at desc + limit 1 + ` + )[0] ?? null; + const previousFields = previousVersion ? extractTemplateFields(previousVersion.schemaJson) : []; const { mappings, columns } = await getMappingsForVersion(sql, payload.templateVersionId); const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean)); const placeholderTokens = [...new Set(fields.flatMap((field) => field.tokens))].sort(); @@ -50,12 +70,19 @@ async function main() { ) .map((mapping) => mapping.placeholderKey); const duplicateFields = getDuplicateFieldNames(fields); - const unknownFields = getUnknownFieldNames(fields); + const unknownFields = getUnknownFieldNames(fields, mappedKeys); + const fieldDiff = computeFieldDiff(fields, previousFields); + const classifications = fields.map((field) => ({ + name: field.name, + classification: classifyTemplateField(field, mappedKeys) + })); const topicRows = Object.keys(payload.topicInputs).filter((key) => key.startsWith('topic_')).length; const topicItemRows = Object.keys(payload.topicInputs).filter((key) => key.startsWith('item_topic_') ).length; const itemTableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null; + const priceTableMapping = + mappings.find((mapping) => mapping.placeholderKey === 'quotation_price_data') ?? null; const itemTableColumns = itemTableMapping ? columns.filter((column) => column.mappingId === itemTableMapping.id) : []; @@ -98,8 +125,12 @@ async function main() { version: payload.templateVersion, versionId: payload.templateVersionId, fieldCount: fields.length, + previousVersion: previousVersion?.version ?? null, + deletedFields: fieldDiff.deletedFields, + newFields: fieldDiff.newFields, duplicateFields, - unknownFields + unknownFields, + classifications }, metrics: { topicCount: payload.quotationMetrics.topicCount, @@ -113,6 +144,12 @@ async function main() { placeholderTokenCount: placeholderTokens.length, unmappedTokens, orphanMappings, + priceTableMapping: priceTableMapping + ? { + sourcePath: priceTableMapping.sourcePath, + dataType: priceTableMapping.dataType + } + : null, itemTableColumns: itemTableColumns.map((column) => ({ columnName: column.columnName, sourceField: column.sourceField @@ -120,7 +157,10 @@ async function main() { }, runtimePayload: { templateInputKeys: Object.keys(payload.templateInput).length, - topicInputKeys: Object.keys(payload.topicInputs).length + topicInputKeys: Object.keys(payload.topicInputs).length, + priceTableRows: Array.isArray(payload.templateInput.quotation_price_data) + ? payload.templateInput.quotation_price_data.length + : 0 }, consistency: { status: consistencyStatus, @@ -141,6 +181,7 @@ async function main() { `- Overall Status: ${report.overallStatus}`, `- Quotation Code: ${report.quotationCode}`, `- Template: ${report.template.templateName} v${report.template.version}`, + `- Previous Version: ${report.template.previousVersion ?? 'None'}`, '', '## Metrics', '', diff --git a/scripts/generate-pdf-integrity-report.ts b/scripts/generate-pdf-integrity-report.ts index 3d34ea9..ac530f5 100644 --- a/scripts/generate-pdf-integrity-report.ts +++ b/scripts/generate-pdf-integrity-report.ts @@ -30,11 +30,13 @@ async function main() { 'placeholder-integrity-report.json' ); const payloadReport = await readReport>('payload-parity-report.json'); + const runtimeReport = await readReport>('runtime-payload-report.json'); const snapshotReport = await readReport>('signature-audit-report.json'); const statuses = [ String(templateVersionReport?.overallStatus ?? 'WARNING'), String(placeholderReport?.overallStatus ?? 'WARNING'), String(payloadReport?.overallStatus ?? 'WARNING'), + String(runtimeReport?.overallStatus ?? 'WARNING'), String(snapshotReport?.overallStatus ?? 'WARNING') ] as Array<'PASS' | 'WARNING' | 'FAIL'>; const overallStatus = summarizeIssues(statuses); @@ -64,6 +66,7 @@ async function main() { artifacts: { templateVersionReport: 'template-version-report.json', placeholderIntegrityReport: 'placeholder-integrity-report.json', + runtimePayloadReport: 'runtime-payload-report.json', payloadParityReport: 'payload-parity-report.json', topicRuntimeReport: 'topic-runtime-report.json', signatureAuditReport: 'signature-audit-report.json' @@ -71,9 +74,23 @@ async function main() { statuses: { templateVersion: templateVersionReport?.overallStatus ?? 'WARNING', placeholderIntegrity: placeholderReport?.overallStatus ?? 'WARNING', + runtimePayload: runtimeReport?.overallStatus ?? 'WARNING', payloadParity: payloadReport?.overallStatus ?? 'WARNING', topicRuntime: topicRuntimeReport.overallStatus, - approvedSnapshotParity: snapshotReport?.overallStatus ?? 'WARNING' + approvedSnapshotParity: snapshotReport?.overallStatus ?? 'WARNING', + staticLabels: runtimeReport?.staticLabelsStatus ?? 'WARNING' + }, + priceTable: { + present: Boolean(payload.templateInput.quotation_price_data), + rowCount: Array.isArray(payload.templateInput.quotation_price_data) + ? payload.templateInput.quotation_price_data.length + : 0, + parity: payloadReport?.priceTableParity ?? false, + status: + Array.isArray(payload.templateInput.quotation_price_data) && + (payloadReport?.priceTableParity ?? false) + ? 'PASS' + : 'FAIL' } }; const artifactPath = await writeAuditArtifact('summary.json', summary); @@ -88,14 +105,20 @@ async function main() { '', `- Template Version Integrity: ${summary.statuses.templateVersion}`, `- Placeholder Integrity: ${summary.statuses.placeholderIntegrity}`, + `- Runtime Payload: ${summary.statuses.runtimePayload}`, `- Payload Parity: ${summary.statuses.payloadParity}`, `- Topic Runtime: ${summary.statuses.topicRuntime}`, `- Approved Snapshot Parity: ${summary.statuses.approvedSnapshotParity}`, + `- Static Labels: ${summary.statuses.staticLabels}`, + `- Price Table Present: ${summary.priceTable.present ? 'Yes' : 'No'}`, + `- Price Table Status: ${summary.priceTable.status}`, + `- Price Table Parity: ${summary.priceTable.parity ? 'Yes' : 'No'}`, '', '## Artifact Files', '', `- ${summary.artifacts.templateVersionReport}`, `- ${summary.artifacts.placeholderIntegrityReport}`, + `- ${summary.artifacts.runtimePayloadReport}`, `- ${summary.artifacts.payloadParityReport}`, `- ${summary.artifacts.topicRuntimeReport}`, `- ${summary.artifacts.signatureAuditReport}` diff --git a/scripts/pdf-audit-utils.ts b/scripts/pdf-audit-utils.ts index 702b622..771a4bf 100644 --- a/scripts/pdf-audit-utils.ts +++ b/scripts/pdf-audit-utils.ts @@ -57,6 +57,29 @@ export interface MappingColumnRecord { formatMask: string | null; } +export type TemplateFieldClassification = + | 'static input field' + | 'dynamic topic template' + | 'readonly/static visual field' + | 'system field' + | 'unknown field'; + +export interface TemplateMappingSeed { + placeholderKey: string; + sourcePath: string; + dataType: 'scalar' | 'multiline' | 'table' | 'image'; + defaultValue?: string | null; + formatMask?: string | null; + sortOrder: number; + columns?: Array<{ + columnName: string; + sourceField: string; + columnLetter?: string; + sortOrder: number; + formatMask?: string | null; + }>; +} + export interface AuditPayload { organizationId: string; quotationId: string; @@ -83,6 +106,7 @@ export interface AuditPayload { const SYSTEM_FIELD_NAMES = new Set([ '', 'company1', + 'company', 'company2', 'company_addr1', 'company_addr2', @@ -97,6 +121,220 @@ const DYNAMIC_TOPIC_FIELD_NAMES = new Set(['topic', 'data_topic']); const LEGACY_DYNAMIC_SEED_TOKENS = new Set(['item_topic']); const DYNAMIC_TOPIC_TOKEN_PREFIXES = ['topic_', 'item_topic_']; const DESIGNER_LABEL_NAME_PATTERNS = [/_label$/i, /_lable$/i]; +const STATIC_VISUAL_FIELD_NAMES = new Set([ + 'colon', + 'colon copy', + 'colon_att', + 'colon_email', + 'colon_fax', + 'colon_project', + 'colon_site', + 'colon_tel', + 'dear_sirs', + 'line', + 'yours_faithfuly', + 'quotation_code_lable', + 'quotation_date_labe' +]); +export const PRICE_TABLE_LABEL = 'Price (Exclude VAT)'; +export const PDFME_STATIC_LABELS = { + tel: 'Tel', + email: 'Email', + att: 'Att', + project: 'Project', + location: 'Location' +} as const; +export const PDFME_STATIC_LABEL_FIELD_KEYS = [ + 'tel_label', + 'email_label', + 'att_label', + 'project_label', + 'site_label' +] as const; +export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [ + { + placeholderKey: 'customer_name', + sourcePath: 'customer.name', + dataType: 'scalar', + sortOrder: 1 + }, + { + placeholderKey: 'customer_addr', + sourcePath: 'customer.address', + dataType: 'multiline', + sortOrder: 2 + }, + { + placeholderKey: 'customer_tel', + sourcePath: 'customer.phone', + dataType: 'scalar', + sortOrder: 3 + }, + { + placeholderKey: 'customer_email', + sourcePath: 'customer.email', + dataType: 'scalar', + sortOrder: 4 + }, + { + placeholderKey: 'customer_att', + sourcePath: 'quotation.attention', + dataType: 'scalar', + sortOrder: 5 + }, + { + placeholderKey: 'project_name', + sourcePath: 'quotation.projectName', + dataType: 'scalar', + sortOrder: 6 + }, + { + placeholderKey: 'site_location', + sourcePath: 'quotation.projectLocation', + dataType: 'multiline', + sortOrder: 7 + }, + { + placeholderKey: 'quotation_date_data', + sourcePath: 'pdfme.quotation_date', + dataType: 'scalar', + sortOrder: 8 + }, + { + placeholderKey: 'quotation_code_data', + sourcePath: 'quotation.code', + dataType: 'scalar', + sortOrder: 9 + }, + { + placeholderKey: 'quotation_price_data', + sourcePath: 'pdfme.quotation_price_data', + dataType: 'table', + defaultValue: JSON.stringify([[PRICE_TABLE_LABEL, '-', ' ', 'THB']]), + sortOrder: 10 + }, + { + placeholderKey: 'quotation_price', + sourcePath: 'pdfme.quotation_price', + dataType: 'scalar', + sortOrder: 11 + }, + { + placeholderKey: 'currency', + sourcePath: 'quotation.currency', + dataType: 'scalar', + sortOrder: 12 + }, + { + placeholderKey: 'exclusion_data', + sourcePath: 'pdfme.exclusion_data', + dataType: 'table', + sortOrder: 13 + }, + { + placeholderKey: 'tel_label', + sourcePath: 'pdfme.labels.tel', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.tel, + sortOrder: 14 + }, + { + placeholderKey: 'email_label', + sourcePath: 'pdfme.labels.email', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.email, + sortOrder: 15 + }, + { + placeholderKey: 'att_label', + sourcePath: 'pdfme.labels.att', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.att, + sortOrder: 16 + }, + { + placeholderKey: 'project_label', + sourcePath: 'pdfme.labels.project', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.project, + sortOrder: 17 + }, + { + placeholderKey: 'site_label', + sourcePath: 'pdfme.labels.location', + dataType: 'scalar', + defaultValue: PDFME_STATIC_LABELS.location, + sortOrder: 18 + }, + { + placeholderKey: 'app1', + sourcePath: 'signatures.preparedBy.name', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 19 + }, + { + placeholderKey: 'app1_position', + sourcePath: 'signatures.preparedBy.position', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 20 + }, + { + placeholderKey: 'app2', + sourcePath: 'signatures.approvedBy.name', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 21 + }, + { + placeholderKey: 'app2_position', + sourcePath: 'signatures.approvedBy.position', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 22 + }, + { + placeholderKey: 'app3', + sourcePath: 'signatures.authorizedBy.name', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 23 + }, + { + placeholderKey: 'app3_position', + sourcePath: 'signatures.authorizedBy.position', + dataType: 'scalar', + defaultValue: '-', + sortOrder: 24 + }, + { + placeholderKey: 'items_table', + sourcePath: 'items', + dataType: 'table', + sortOrder: 25, + columns: [ + { columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 }, + { columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 }, + { columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 }, + { columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 }, + { + columnName: 'Unit Price', + sourceField: 'unitPrice', + columnLetter: 'E', + sortOrder: 5, + formatMask: 'currency' + }, + { + columnName: 'Total', + sourceField: 'totalPrice', + columnLetter: 'F', + sortOrder: 6, + formatMask: 'currency' + } + ] + } +] as const; const SIGNATURE_ROLE_FALLBACK_LABELS: Record = { sales: 'Sales Engineer', sales_support: 'Sales Support', @@ -324,13 +562,7 @@ export function getUnknownFieldNames( return fields .filter( (field) => - field.name && - !SYSTEM_FIELD_NAMES.has(field.name) && - !DYNAMIC_TOPIC_FIELD_NAMES.has(field.name) && - !mappedKeys.has(field.name) && - field.tokens.length === 0 && - field.type !== 'table' && - field.type !== 'text' + field.name && classifyTemplateField(field, mappedKeys) === 'unknown field' ) .map((field) => field.name) .sort(); @@ -344,6 +576,40 @@ export function isDesignerLabelField(field: TemplateFieldRecord) { ); } +export function classifyTemplateField( + field: TemplateFieldRecord, + mappedKeys: Set = new Set() +): TemplateFieldClassification { + if (!field.name) { + return 'system field'; + } + + if (SYSTEM_FIELD_NAMES.has(field.name)) { + return 'system field'; + } + + if (DYNAMIC_TOPIC_FIELD_NAMES.has(field.name)) { + return 'dynamic topic template'; + } + + if (mappedKeys.has(field.name)) { + return 'static input field'; + } + + if (isDesignerLabelField(field) || STATIC_VISUAL_FIELD_NAMES.has(field.name)) { + return 'readonly/static visual field'; + } + + if ( + field.tokens.length === 0 && + ['text', 'line', 'rectangle', 'ellipse', 'image'].includes(field.type) + ) { + return 'readonly/static visual field'; + } + + return 'unknown field'; +} + export function getEffectiveMappedKeys( fields: TemplateFieldRecord[], mappings: Array<{ placeholderKey: string }> @@ -398,6 +664,78 @@ export function normalizeTemplateInputAliases( return normalized; } +export function loadTemplateSourceByOrganizationName(organizationName: string) { + const normalized = organizationName.toUpperCase(); + const fileName = normalized.includes('ONVALLA') + ? 'ONVALLA_template_pdfme_fainal3.json' + : normalized.includes('ALLA') + ? 'ALLA_template_pdfme_fainal3.json' + : null; + + if (!fileName) { + return null; + } + + const filePath = path.resolve(process.cwd(), 'src', 'pdfme_template', fileName); + return { + fileName, + relativePath: `src/pdfme_template/${fileName}`, + absolutePath: filePath, + schemaJson: JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown + }; +} + +export function computeFieldDiff( + currentFields: TemplateFieldRecord[], + previousFields: TemplateFieldRecord[] +) { + const currentFieldNames = new Set(currentFields.map((field) => field.name).filter(Boolean)); + const previousFieldNames = new Set(previousFields.map((field) => field.name).filter(Boolean)); + + return { + deletedFields: [...previousFieldNames].filter((name) => !currentFieldNames.has(name)).sort(), + newFields: [...currentFieldNames].filter((name) => !previousFieldNames.has(name)).sort() + }; +} + +export function buildSupportedMappingsForFieldNames( + fieldNames: Set, + tokenNames: Set = new Set() +) { + return SUPPORTED_TEMPLATE_MAPPING_SEEDS.filter( + (mapping) => fieldNames.has(mapping.placeholderKey) || tokenNames.has(mapping.placeholderKey) + ); +} + +export function bumpMinorTemplateVersion(version: string) { + const [majorPart, minorPart = '0'] = version.split('.'); + const major = Number(majorPart); + const minor = Number(minorPart); + + if (!Number.isInteger(major) || !Number.isInteger(minor)) { + return `${version}.1`; + } + + return `${major}.${minor + 1}`; +} + +function normalizeSnapshotPayload(snapshot: unknown) { + if (!snapshot) { + return null; + } + + if (typeof snapshot === 'string') { + try { + const parsed = JSON.parse(snapshot) as unknown; + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } + } + + return snapshot && typeof snapshot === 'object' ? (snapshot as Record) : null; +} + export async function getActiveTemplateVersions(sql: SqlClient): Promise { const rows = await sql` select @@ -475,7 +813,9 @@ export async function findAuditQuotation(sql: SqlClient) { q.attention, q.reference, q.currency, + q.subtotal, q.total_amount as "totalAmount", + currency_option.code as "currencyCode", q.approved_template_version_id as "approvedTemplateVersionId", q.salesman_id as "salesmanId", q.created_by as "createdBy", @@ -483,6 +823,8 @@ export async function findAuditQuotation(sql: SqlClient) { q.approved_snapshot as "approvedSnapshot", q.approved_pdf_url as "approvedPdfUrl" from crm_quotations q + left join ms_options currency_option + on currency_option.id = q.currency where q.code = ${AUDIT_QUOTATION_CODE} and q.deleted_at is null limit 1 @@ -506,13 +848,17 @@ export async function findAuditQuotation(sql: SqlClient) { q.attention, q.reference, q.currency, + q.subtotal, q.total_amount as "totalAmount", + currency_option.code as "currencyCode", q.salesman_id as "salesmanId", q.created_by as "createdBy", q.created_at as "createdAt", q.approved_snapshot as "approvedSnapshot", q.approved_pdf_url as "approvedPdfUrl" from crm_quotations q + left join ms_options currency_option + on currency_option.id = q.currency where q.deleted_at is null order by q.updated_at desc limit 1 @@ -584,6 +930,40 @@ function formatPdfCurrency(amount: number | string | null | undefined, currencyC }).format(normalized)}`.trim(); } +function formatPdfCurrencyAmount(amount: number | string | null | undefined, currencyCode = 'THB') { + if (amount === null || amount === undefined) { + return '-'; + } + + const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', '')); + + if (!Number.isFinite(normalized)) { + return '-'; + } + + const symbols: Record = { + THB: '฿', + USD: '$', + EUR: 'EUR ' + }; + const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `; + return `${prefix}${new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(normalized)}`.trim(); +} + +function buildPdfPriceTableData(amount: number | string | null | undefined, currencyCode?: string | null) { + const normalizedCurrency = currencyCode?.trim() || 'THB'; + + return [[ + PRICE_TABLE_LABEL, + formatPdfCurrencyAmount(amount, normalizedCurrency), + ' ', + normalizedCurrency + ]]; +} + function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: number | null }> = []) { if (!items.length) { return [['-']]; @@ -611,6 +991,30 @@ function applyFormatMask(value: unknown, formatMask?: string | null) { return value; } +function isBlankString(value: unknown) { + return typeof value === 'string' && value.trim().length === 0; +} + +function resolveScalarMappingValue( + rawValue: unknown, + formattedValue: unknown, + defaultValue?: string | null +) { + if (formattedValue !== undefined && formattedValue !== null && !isBlankString(formattedValue)) { + return formattedValue; + } + + if (rawValue !== undefined && rawValue !== null && !isBlankString(rawValue)) { + return rawValue; + } + + if (defaultValue !== undefined && defaultValue !== null && defaultValue.trim().length > 0) { + return defaultValue; + } + + return '-'; +} + function normalizePdfmeTable( value: unknown, columns?: Array<{ sourceField: string; formatMask?: string | null }> @@ -620,7 +1024,21 @@ function normalizePdfmeTable( } if (value.every((row) => Array.isArray(row))) { - return (value as unknown[][]).map((row) => row.map((cell) => String(cell ?? '-').trim() || '-')); + return (value as unknown[][]).map((row) => + row.map((cell) => { + if (typeof cell === 'string') { + const trimmedValue = cell.trim(); + + if (trimmedValue) { + return trimmedValue; + } + + return cell.length > 0 ? cell : '-'; + } + + return String(cell ?? '-').trim() || '-'; + }) + ); } if (!columns?.length) { @@ -655,7 +1073,18 @@ function mapDocumentDataToTemplateInput( if (mapping.dataType === 'table') { const mappingColumns = columnMap.get(mapping.id) ?? []; - result[mapping.placeholderKey] = normalizePdfmeTable(rawValue, mappingColumns); + let tableValue = rawValue; + + if (!Array.isArray(tableValue) && mapping.defaultValue) { + try { + const parsedDefault = JSON.parse(mapping.defaultValue) as unknown; + tableValue = Array.isArray(parsedDefault) ? parsedDefault : tableValue; + } catch { + // ignore invalid legacy table defaults + } + } + + result[mapping.placeholderKey] = normalizePdfmeTable(tableValue, mappingColumns); continue; } @@ -669,12 +1098,18 @@ function mapDocumentDataToTemplateInput( } result[mapping.placeholderKey] = - typeof rawValue === 'string' ? rawValue : mapping.defaultValue ?? ''; + typeof rawValue === 'string' && rawValue.trim().length > 0 + ? rawValue + : mapping.defaultValue ?? '-'; continue; } - result[mapping.placeholderKey] = - applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '-'; + const formattedValue = applyFormatMask(rawValue, mapping.formatMask); + result[mapping.placeholderKey] = resolveScalarMappingValue( + rawValue, + formattedValue, + mapping.defaultValue + ); } return result; @@ -901,6 +1336,10 @@ export async function buildAuditPayload(sql: SqlClient): Promise { const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null; return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-'; }; + const currencyCode = + typeof quotation.currencyCode === 'string' && quotation.currencyCode.trim() + ? quotation.currencyCode.trim() + : 'THB'; const documentData: Record = { company: { @@ -932,7 +1371,9 @@ export async function buildAuditPayload(sql: SqlClient): Promise { projectLocation: quotation.projectLocation, attention: quotation.attention, reference: quotation.reference, - currency: quotation.currency, + currency: currencyCode, + currencyCode, + subtotal: quotation.subtotal, totalAmount: quotation.totalAmount }, items: (itemRows as Array>).map((row) => ({ @@ -951,8 +1392,10 @@ export async function buildAuditPayload(sql: SqlClient): Promise { exclusion: exclusionTopic ? [exclusionTopic] : [] }, pdfme: { + labels: PDFME_STATIC_LABELS, quotation_date: formatPdfDate(quotation.quotationDate as string), - quotation_price: formatPdfCurrency(quotation.totalAmount as number, 'THB'), + quotation_price: formatPdfCurrency(quotation.subtotal as number, currencyCode), + quotation_price_data: buildPdfPriceTableData(quotation.subtotal as number, currencyCode), exclusion_data: formatTopicItems(exclusionTopic?.items ?? []), topic_inputs: topicInputs }, @@ -1014,10 +1457,7 @@ export async function buildAuditPayload(sql: SqlClient): Promise { itemCount: (itemRows as Array).length, partyCount: (partyRows as Array).length }, - approvedSnapshot: - quotation.approvedSnapshot && typeof quotation.approvedSnapshot === 'object' - ? (quotation.approvedSnapshot as Record) - : null, + approvedSnapshot: normalizeSnapshotPayload(quotation.approvedSnapshot), approvedArtifactReference: typeof quotation.approvedPdfUrl === 'string' ? quotation.approvedPdfUrl : null }; diff --git a/scripts/reseed-pdf-template-version.ts b/scripts/reseed-pdf-template-version.ts new file mode 100644 index 0000000..c7409cb --- /dev/null +++ b/scripts/reseed-pdf-template-version.ts @@ -0,0 +1,388 @@ +import { createHash } from 'node:crypto'; +import { + buildSupportedMappingsForFieldNames, + bumpMinorTemplateVersion, + classifyTemplateField, + computeFieldDiff, + createSqlClient, + extractTemplateFields, + getEffectiveMappedKeys, + loadTemplateSourceByOrganizationName, + summarizeIssues, + type TemplateMappingSeed +} from './pdf-audit-utils.ts'; + +type TemplateRow = { + id: string; + organizationId: string; + organizationName: string; + templateName: string; + createdBy: string; + updatedBy: string; +}; + +type TemplateVersionRow = { + id: string; + templateId: string; + version: string; + schemaJson: unknown; + filePath: string | null; + isActive: boolean; + createdAt: string; +}; + +function computeSchemaHash(schemaJson: unknown) { + return createHash('sha256').update(JSON.stringify(schemaJson)).digest('hex'); +} + +function normalizeSchemaJson(schemaJson: unknown) { + if (typeof schemaJson === 'string') { + return JSON.parse(schemaJson) as unknown; + } + + return schemaJson; +} + +async function upsertMapping( + sql: any, + organizationId: string, + templateVersionId: string, + mapping: TemplateMappingSeed +) { + const existing = ( + (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()); + + if (existing) { + await sql` + update crm_document_template_mappings + set + organization_id = ${organizationId}, + source_path = ${mapping.sourcePath}, + data_type = ${mapping.dataType}, + sheet_name = ${null}, + default_value = ${mapping.defaultValue ?? null}, + format_mask = ${mapping.formatMask ?? null}, + sort_order = ${mapping.sortOrder}, + deleted_at = ${null}, + updated_at = now() + where id = ${mappingId} + `; + } else { + await sql` + insert into crm_document_template_mappings ( + id, + organization_id, + template_version_id, + placeholder_key, + source_path, + data_type, + sheet_name, + default_value, + format_mask, + sort_order, + deleted_at + ) values ( + ${mappingId}, + ${organizationId}, + ${templateVersionId}, + ${mapping.placeholderKey}, + ${mapping.sourcePath}, + ${mapping.dataType}, + ${null}, + ${mapping.defaultValue ?? null}, + ${mapping.formatMask ?? null}, + ${mapping.sortOrder}, + ${null} + ) + `; + } + + 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; + + if (existingColumn) { + await sql` + update crm_document_template_table_columns + set + organization_id = ${organizationId}, + source_field = ${column.sourceField}, + column_letter = ${column.columnLetter ?? null}, + sort_order = ${column.sortOrder}, + format_mask = ${column.formatMask ?? null}, + deleted_at = ${null}, + updated_at = now() + where id = ${existingColumn.id} + `; + 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` + 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 }>; + + for (const column of existingColumns) { + if (desiredColumnNames.has(column.columnName)) { + continue; + } + + await sql` + update crm_document_template_table_columns + set + deleted_at = now(), + updated_at = now() + where id = ${column.id} + `; + } +} + +async function main() { + const sql = createSqlClient(); + + try { + const templates = (await sql` + select + t.id, + t.organization_id as "organizationId", + o.name as "organizationName", + t.template_name as "templateName", + 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 + 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 = []; + + for (const template of templates) { + const templateSource = loadTemplateSourceByOrganizationName(template.organizationName); + + if (!templateSource) { + results.push({ + organizationName: template.organizationName, + templateName: template.templateName, + status: 'WARNING', + message: 'No source template file found for organization' + }); + continue; + } + + const versions = (await sql` + select + id, + template_id as "templateId", + version, + schema_json as "schemaJson", + file_path as "filePath", + is_active as "isActive", + created_at as "createdAt" + from crm_document_template_versions + where template_id = ${template.id} + and deleted_at is null + order by created_at asc + `) 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`); + } + + const targetSchema = templateSource.schemaJson; + const targetSchemaHash = computeSchemaHash(targetSchema); + 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 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(); + + await sql.begin(async (tx) => { + if (!matchingVersion) { + await tx` + insert into crm_document_template_versions ( + id, + organization_id, + template_id, + version, + file_path, + schema_json, + preview_image_url, + is_active, + deleted_at, + created_by + ) values ( + ${targetVersionId}, + ${template.organizationId}, + ${template.id}, + ${targetVersionLabel}, + ${templateSource.relativePath}, + ${JSON.stringify(targetSchema)}, + ${null}, + ${true}, + ${null}, + ${template.updatedBy || template.createdBy} + ) + `; + } else { + await tx` + update crm_document_template_versions + set + file_path = ${templateSource.relativePath}, + is_active = ${true}, + 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 + `; + + 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` + 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 }>; + + for (const mapping of existingMappings) { + if (desiredKeys.has(mapping.placeholderKey)) { + continue; + } + + await tx` + update crm_document_template_mappings + set + deleted_at = now(), + updated_at = now() + where id = ${mapping.id} + `; + } + }); + + const classificationCounts = new Map(); + + for (const field of targetFields) { + const classification = classifyTemplateField(field, mappedKeys); + classificationCounts.set( + classification, + (classificationCounts.get(classification) ?? 0) + 1 + ); + } + + results.push({ + organizationName: template.organizationName, + templateName: template.templateName, + sourceFile: templateSource.relativePath, + previousActiveVersion: activeVersion.version, + activeVersion: targetVersionLabel, + createdNewVersion, + retainedInactiveVersions: versions.filter((version) => version.id !== targetVersionId).length, + deletedFields: fieldDiff.deletedFields, + newFields: fieldDiff.newFields, + 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' + ]) + }); + } + + const overallStatus = summarizeIssues( + results.map((result) => result.status as 'PASS' | 'WARNING' | 'FAIL') + ); + + console.log( + JSON.stringify( + { + action: 'reseed-pdf-template-version', + overallStatus, + templateCount: results.length, + results + }, + null, + 2 + ) + ); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[reseed-pdf-template-version] ${error.message}`); + process.exit(1); +}); diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 5fcad7d..e1bf59e 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -384,72 +384,79 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [ dataType: 'scalar', sortOrder: 9 }, + { + placeholderKey: 'quotation_price_data', + sourcePath: 'pdfme.quotation_price_data', + dataType: 'table', + defaultValue: JSON.stringify([['Price (Exclude VAT)', '-', ' ', 'THB']]), + sortOrder: 10 + }, { placeholderKey: 'quotation_price', sourcePath: 'pdfme.quotation_price', dataType: 'scalar', formatMask: null, - sortOrder: 10 + sortOrder: 11 }, { placeholderKey: 'currency', sourcePath: 'quotation.currency', dataType: 'scalar', - sortOrder: 11 + sortOrder: 12 }, { placeholderKey: 'exclusion_data', sourcePath: 'pdfme.exclusion_data', dataType: 'table', - sortOrder: 12 + sortOrder: 13 }, { placeholderKey: 'app1', sourcePath: 'signatures.preparedBy.name', dataType: 'scalar', defaultValue: '-', - sortOrder: 13 + sortOrder: 14 }, { placeholderKey: 'app1_position', sourcePath: 'signatures.preparedBy.position', dataType: 'scalar', defaultValue: '-', - sortOrder: 14 + sortOrder: 15 }, { placeholderKey: 'app2', sourcePath: 'signatures.approvedBy.name', dataType: 'scalar', defaultValue: '-', - sortOrder: 15 + sortOrder: 16 }, { placeholderKey: 'app2_position', sourcePath: 'signatures.approvedBy.position', dataType: 'scalar', defaultValue: '-', - sortOrder: 16 + sortOrder: 17 }, { placeholderKey: 'app3', sourcePath: 'signatures.authorizedBy.name', dataType: 'scalar', defaultValue: '-', - sortOrder: 17 + sortOrder: 18 }, { placeholderKey: 'app3_position', sourcePath: 'signatures.authorizedBy.position', dataType: 'scalar', defaultValue: '-', - sortOrder: 18 + sortOrder: 19 }, { placeholderKey: 'items_table', sourcePath: 'items', dataType: 'table', - sortOrder: 19, + sortOrder: 20, columns: [ { columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 }, { columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 }, @@ -922,6 +929,17 @@ async function upsertDocumentTemplatesForOrganization( ) and deleted_at is null `; + + await sql` + update crm_document_template_mappings + set + deleted_at = now(), + updated_at = now() + where organization_id = ${organization.id} + and template_version_id = ${resolvedVersionId} + and placeholder_key in (${'exclusion_data_'}) + and deleted_at is null + `; } async function main() { diff --git a/src/features/crm/quotations/document/server/pdfme-transforms.ts b/src/features/crm/quotations/document/server/pdfme-transforms.ts index d092925..d81bc84 100644 --- a/src/features/crm/quotations/document/server/pdfme-transforms.ts +++ b/src/features/crm/quotations/document/server/pdfme-transforms.ts @@ -1,4 +1,5 @@ const EMPTY_TABLE_FALLBACK = [['-']]; +const PRICE_TABLE_LABEL = 'Price (Exclude VAT)'; function normalizeNumber( amount: number | string | null | undefined @@ -70,6 +71,45 @@ export function formatPdfCurrency( return `${symbol}${formatted}`.trim(); } +export function formatPdfCurrencyAmount( + amount: number | string | null | undefined, + currencyCode = 'THB' +): string { + const numericAmount = normalizeNumber(amount); + + if (numericAmount === null) { + return '-'; + } + + const normalizedCode = currencyCode.toUpperCase(); + const symbols: Record = { + THB: '฿', + USD: '$', + EUR: 'EUR ' + }; + const symbol = symbols[normalizedCode] ?? `${normalizedCode} `; + const formatted = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(numericAmount); + + return `${symbol}${formatted}`.trim(); +} + +export function buildPdfPriceTableData( + amount: number | string | null | undefined, + currencyCode?: string | null +): string[][] { + const normalizedCurrency = currencyCode?.trim() || 'THB'; + + return [[ + PRICE_TABLE_LABEL, + formatPdfCurrencyAmount(amount, normalizedCurrency), + ' ', + normalizedCurrency + ]]; +} + export function formatTopicItems(topic?: { items?: Array<{ content?: string | null; sortOrder?: number | null }> | string[]; }): string[][] { @@ -109,6 +149,16 @@ export function normalizePdfmeTable( if (value.every((row) => Array.isArray(row))) { const normalizedRows = value.map((row) => (row as unknown[]).map((cell) => { + if (typeof cell === 'string') { + const trimmedValue = cell.trim(); + + if (trimmedValue) { + return trimmedValue; + } + + return cell.length > 0 ? cell : '-'; + } + const stringValue = String(cell ?? '').trim(); return stringValue || '-'; }) diff --git a/src/features/crm/quotations/document/server/service.ts b/src/features/crm/quotations/document/server/service.ts index 2748981..b1e33cf 100644 --- a/src/features/crm/quotations/document/server/service.ts +++ b/src/features/crm/quotations/document/server/service.ts @@ -1,52 +1,73 @@ -import { and, eq, inArray, isNull } from 'drizzle-orm'; -import { crmCustomerContacts, crmCustomers, crmQuotations, organizations } from '@/db/schema'; -import { db } from '@/lib/db'; -import { AuthError } from '@/lib/auth/session'; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { + crmCustomerContacts, + crmCustomers, + crmQuotations, + organizations, +} from "@/db/schema"; +import { db } from "@/lib/db"; +import { AuthError } from "@/lib/auth/session"; import { listApprovalRequests, - getApprovalRequest -} from '@/features/foundation/approval/server/service'; + getApprovalRequest, +} from "@/features/foundation/approval/server/service"; import { mapDocumentDataToTemplateInput, resolveTemplateForDocument, - resolveTemplateMappings -} from '@/features/foundation/document-template/server/service'; -import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; + resolveTemplateMappings, +} from "@/features/foundation/document-template/server/service"; +import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service"; import { getQuotationDetail, listQuotationCustomers, listQuotationItems, - listQuotationTopics -} from '@/features/crm/quotations/server/service'; + listQuotationTopics, +} from "@/features/crm/quotations/server/service"; import type { ApprovedQuotationSnapshot, QuotationDocumentApprovalStep, - QuotationDocumentData -} from '../types'; -import type { PdfTopic } from './pdf-topic.type'; -import { formatPdfCurrency, formatPdfDate, formatTopicItems } from './pdfme-transforms'; -import { buildPdfTopicTemplate } from './pdf-topic-engine'; -import { resolveQuotationSignatures } from './signature-resolver'; -import { resolveQuotationTopicTypeMapping } from './topic-mapping'; -import type { Template } from '@pdfme/common'; + QuotationDocumentData, +} from "../types"; +import type { PdfTopic } from "./pdf-topic.type"; +import { + buildPdfPriceTableData, + formatPdfCurrency, + formatPdfDate, + formatTopicItems, +} from "./pdfme-transforms"; +import { buildPdfTopicTemplate } from "./pdf-topic-engine"; +import { resolveQuotationSignatures } from "./signature-resolver"; +import { resolveQuotationTopicTypeMapping } from "./topic-mapping"; +import type { Template } from "@pdfme/common"; const DOCUMENT_OPTION_CATEGORIES = { - branch: 'crm_branch', - status: 'crm_quotation_status', - quotationType: 'crm_quotation_type', - currency: 'crm_currency', - discountType: 'crm_discount_type', - unit: 'crm_unit', - customerRole: 'crm_project_party_role', - productType: 'crm_product_type', - topicType: 'crm_quotation_topic_type' + branch: "crm_branch", + status: "crm_quotation_status", + quotationType: "crm_quotation_type", + currency: "crm_currency", + discountType: "crm_discount_type", + unit: "crm_unit", + customerRole: "crm_project_party_role", + productType: "crm_product_type", + topicType: "crm_quotation_topic_type", +} as const; + +const PDFME_DEFAULT_LABELS = { + tel: "Tel", + email: "Email", + att: "Att", + project: "Project", + location: "Location", } as const; function toRevisionLabel(revision: number) { - return `R${String(revision).padStart(2, '0')}`; + return `R${String(revision).padStart(2, "0")}`; } -async function assertQuotationDocumentAccess(quotationId: string, organizationId: string) { +async function assertQuotationDocumentAccess( + quotationId: string, + organizationId: string, +) { const [quotation] = await db .select() .from(crmQuotations) @@ -54,13 +75,13 @@ async function assertQuotationDocumentAccess(quotationId: string, organizationId and( eq(crmQuotations.id, quotationId), eq(crmQuotations.organizationId, organizationId), - isNull(crmQuotations.deletedAt) - ) + isNull(crmQuotations.deletedAt), + ), ) .limit(1); if (!quotation) { - throw new AuthError('Quotation not found', 404); + throw new AuthError("Quotation not found", 404); } return quotation; @@ -68,14 +89,16 @@ async function assertQuotationDocumentAccess(quotationId: string, organizationId function findOptionLabel( options: Array<{ id: string; code: string; label: string }>, - id: string | null | undefined + id: string | null | undefined, ) { - return id ? (options.find((option) => option.id === id)?.label ?? null) : null; + return id + ? (options.find((option) => option.id === id)?.label ?? null) + : null; } function findOptionCode( options: Array<{ id: string; code: string; label: string }>, - id: string | null | undefined + id: string | null | undefined, ) { return id ? (options.find((option) => option.id === id)?.code ?? null) : null; } @@ -88,9 +111,11 @@ function extractSinglePlaceholder(value: string) { } function getFieldName(field: unknown) { - return field && typeof field === 'object' && typeof (field as { name?: unknown }).name === 'string' - ? ((field as { name: string }).name ?? '') - : ''; + return field && + typeof field === "object" && + typeof (field as { name?: unknown }).name === "string" + ? ((field as { name: string }).name ?? "") + : ""; } function normalizeTopicKey(value: string | null | undefined) { @@ -98,11 +123,14 @@ function normalizeTopicKey(value: string | null | undefined) { value ?.trim() .toLowerCase() - .replaceAll(/[\s_-]+/g, '') ?? '' + .replaceAll(/[\s_-]+/g, "") ?? "" ); } -function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: string) { +function matchesTopicAlias( + actualCode: string | null | undefined, + expectedCode: string, +) { const actual = normalizeTopicKey(actualCode); const expected = normalizeTopicKey(expectedCode); @@ -115,30 +143,33 @@ function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: } const aliasGroups = [ - ['scope', 'scopeofwork', 'dockscopeofwork', 'solarcellscopeofwork'], + ["scope", "scopeofwork", "dockscopeofwork", "solarcellscopeofwork"], [ - 'exclusion', - 'exclusionfromscopeofsupply', - 'exclusionofsupplyinstallation', - 'solarcellexclusionofsupplyinstallation' + "exclusion", + "exclusionfromscopeofsupply", + "exclusionofsupplyinstallation", + "solarcellexclusionofsupplyinstallation", ], [ - 'payment', - 'paymentconditions', - 'termsofpaymentuponprogressofeachitem', - 'solarcellpaymentconditions' + "payment", + "paymentconditions", + "termsofpaymentuponprogressofeachitem", + "solarcellpaymentconditions", ], - ['delivery', 'deliverydate', 'solarcelldelivery'], - ['warranty', 'solarcellwarranty'] + ["delivery", "deliverydate", "solarcelldelivery"], + ["warranty", "solarcellwarranty"], ]; - return aliasGroups.some((group) => group.includes(actual) && group.includes(expected)); + return aliasGroups.some( + (group) => group.includes(actual) && group.includes(expected), + ); } function sortBySortOrder(items: T[]) { return [...items].sort((left, right) => { const delta = - (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER); + (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - + (right.sortOrder ?? Number.MAX_SAFE_INTEGER); if (delta !== 0) { return delta; @@ -148,9 +179,12 @@ function sortBySortOrder(items: T[]) { }); } -function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record) { +function normalizePdfmeTemplateInput( + schemaJson: unknown, + templateInput: Record, +) { const pages = - schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson + schemaJson && typeof schemaJson === "object" && "schemas" in schemaJson ? (schemaJson as { schemas?: unknown }).schemas : null; @@ -166,28 +200,37 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record< } for (const field of page) { - if (!field || typeof field !== 'object') { + if (!field || typeof field !== "object") { continue; } const tableField = field as { type?: unknown; content?: unknown }; const fieldName = getFieldName(field); const placeholderKey = - typeof tableField.content === 'string' + typeof tableField.content === "string" ? extractSinglePlaceholder(tableField.content) : null; if (fieldName && placeholderKey && fieldName !== placeholderKey) { - if (normalized[fieldName] !== undefined && normalized[placeholderKey] === undefined) { + if ( + normalized[fieldName] !== undefined && + normalized[placeholderKey] === undefined + ) { normalized[placeholderKey] = normalized[fieldName]; } - if (normalized[placeholderKey] !== undefined && normalized[fieldName] === undefined) { + if ( + normalized[placeholderKey] !== undefined && + normalized[fieldName] === undefined + ) { normalized[fieldName] = normalized[placeholderKey]; } } - if (tableField.type !== 'table' || typeof tableField.content !== 'string') { + if ( + tableField.type !== "table" || + typeof tableField.content !== "string" + ) { continue; } @@ -197,12 +240,12 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record< const value = normalized[placeholderKey]; - if (typeof value !== 'string') { + if (typeof value !== "string") { continue; } const lines = value - .split('\n') + .split("\n") .map((line) => line.trim()) .filter(Boolean); @@ -219,9 +262,12 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record< export async function buildQuotationDocumentData( quotationId: string, - organizationId: string + organizationId: string, ): Promise { - const quotation = await assertQuotationDocumentAccess(quotationId, organizationId); + const quotation = await assertQuotationDocumentAccess( + quotationId, + organizationId, + ); const [ company, quotationDetail, @@ -237,7 +283,7 @@ export async function buildQuotationDocumentData( customerRoleOptions, productTypeOptions, topicTypeOptions, - approvalRequests + approvalRequests, ] = await Promise.all([ db .select() @@ -248,24 +294,42 @@ export async function buildQuotationDocumentData( listQuotationItems(quotationId, organizationId), listQuotationCustomers(quotationId, organizationId), listQuotationTopics(quotationId, organizationId), - getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, { organizationId }), - getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, { organizationId }), - getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, { organizationId }), - getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, { organizationId }), - getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, { organizationId }), - getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }), - getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }), - getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }), - getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, { + organizationId, + }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, { + organizationId, + }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, { + organizationId, + }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, { + organizationId, + }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, { + organizationId, + }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { + organizationId, + }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { + organizationId, + }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { + organizationId, + }), + getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { + organizationId, + }), listApprovalRequests(organizationId, { - entityType: 'quotation', + entityType: "quotation", entityId: quotationId, - limit: 20 - }) + limit: 20, + }), ]); if (!company) { - throw new AuthError('Organization not found', 404); + throw new AuthError("Organization not found", 404); } const [customer, contact, relatedCustomers, signatures] = await Promise.all([ @@ -276,8 +340,8 @@ export async function buildQuotationDocumentData( and( eq(crmCustomers.id, quotation.customerId), eq(crmCustomers.organizationId, organizationId), - isNull(crmCustomers.deletedAt) - ) + isNull(crmCustomers.deletedAt), + ), ) .then((rows) => rows[0] ?? null), quotation.contactId @@ -288,47 +352,57 @@ export async function buildQuotationDocumentData( and( eq(crmCustomerContacts.id, quotation.contactId), eq(crmCustomerContacts.organizationId, organizationId), - isNull(crmCustomerContacts.deletedAt) - ) + isNull(crmCustomerContacts.deletedAt), + ), ) .then((rows) => rows[0] ?? null) : Promise.resolve(null), quotationCustomers.length ? db - .select({ id: crmCustomers.id, code: crmCustomers.code, name: crmCustomers.name }) + .select({ + id: crmCustomers.id, + code: crmCustomers.code, + name: crmCustomers.name, + }) .from(crmCustomers) .where( and( eq(crmCustomers.organizationId, organizationId), inArray( crmCustomers.id, - quotationCustomers.map((item) => item.customerId) + quotationCustomers.map((item) => item.customerId), ), - isNull(crmCustomers.deletedAt) - ) + isNull(crmCustomers.deletedAt), + ), ) : Promise.resolve([]), - resolveQuotationSignatures(quotationId, organizationId) + resolveQuotationSignatures(quotationId, organizationId), ]); if (!customer) { - throw new AuthError('Customer not found', 404); + throw new AuthError("Customer not found", 404); } const approvalRequest = - approvalRequests.items.find((item) => item.status === 'approved') ?? + approvalRequests.items.find((item) => item.status === "approved") ?? approvalRequests.items[0] ?? null; const approvalDetail = approvalRequest ? await getApprovalRequest(approvalRequest.id, organizationId) : null; - const branch = branchOptions.find((option) => option.id === quotation.branchId) ?? null; - const relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item])); + const branch = + branchOptions.find((option) => option.id === quotation.branchId) ?? null; + const relatedCustomerMap = new Map( + relatedCustomers.map((item) => [item.id, item]), + ); const statusCode = findOptionCode(statusOptions, quotation.status); const statusLabel = findOptionLabel(statusOptions, quotation.status); - const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code])); + const topicCodeById = new Map( + topicTypeOptions.map((option) => [option.id, option.code]), + ); const primaryProductType = - items.find((item) => findOptionCode(productTypeOptions, item.productType))?.productType ?? null; + items.find((item) => findOptionCode(productTypeOptions, item.productType)) + ?.productType ?? null; const productTypeCode = findOptionCode(productTypeOptions, primaryProductType) ?? findOptionCode(quotationTypeOptions, quotationDetail.quotationType) ?? @@ -340,7 +414,7 @@ export async function buildQuotationDocumentData( findOptionLabel(topicTypeOptions, item.topicType) ?? item.title?.trim() ?? topicCodeById.get(item.topicType) ?? - 'Topic'; + "Topic"; return { id: item.id, @@ -351,8 +425,8 @@ export async function buildQuotationDocumentData( items: sortBySortOrder(item.items).map((child) => ({ id: child.id, content: child.content, - sortOrder: child.sortOrder - })) + sortOrder: child.sortOrder, + })), }; }); const pdfTopics: PdfTopic[] = resolvedTopics.map((item) => ({ @@ -361,24 +435,24 @@ export async function buildQuotationDocumentData( sortOrder: item.sortOrder, items: item.items.map((child) => ({ id: child.id, - content: child.content?.trim() || '-', - sortOrder: child.sortOrder - })) + content: child.content?.trim() || "-", + sortOrder: child.sortOrder, + })), })); const scopeTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.scope) + matchesTopicAlias(item.topicCode, topicTypeMapping.scope), ); const exclusionTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion) + matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion), ); const paymentTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.payment) + matchesTopicAlias(item.topicCode, topicTypeMapping.payment), ); const warrantyTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.warranty) + matchesTopicAlias(item.topicCode, topicTypeMapping.warranty), ); const deliveryTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.delivery) + matchesTopicAlias(item.topicCode, topicTypeMapping.delivery), ); const otherTopics = resolvedTopics.filter((item) => { return ![ @@ -386,7 +460,7 @@ export async function buildQuotationDocumentData( topicTypeMapping.exclusion, topicTypeMapping.payment, topicTypeMapping.warranty, - topicTypeMapping.delivery + topicTypeMapping.delivery, ].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)); }); const exclusionTopic = exclusionTopics[0]; @@ -395,21 +469,23 @@ export async function buildQuotationDocumentData( const deliveryTopic = deliveryTopics[0]; const approvalApprovers: QuotationDocumentApprovalStep[] = approvalDetail?.timeline - .filter((item) => ['approve', 'reject', 'return'].includes(item.action)) + .filter((item) => ["approve", "reject", "return"].includes(item.action)) .map((item) => { const step = approvalDetail.steps.find( - (candidate) => candidate.stepNumber === item.stepNumber + (candidate) => candidate.stepNumber === item.stepNumber, ); return { stepNumber: item.stepNumber, - roleCode: step?.roleCode ?? '', + roleCode: step?.roleCode ?? "", roleName: step?.roleName ?? `Step ${item.stepNumber}`, action: item.action, actorName: item.actorName, actedAt: item.actedAt, - remark: item.remark + remark: item.remark, }; }) ?? []; + const currencyCode = + findOptionCode(currencyOptions, quotationDetail.currency) ?? "THB"; return { company: { @@ -417,14 +493,14 @@ export async function buildQuotationDocumentData( name: company.name, slug: company.slug, imageUrl: company.imageUrl, - plan: company.plan + plan: company.plan, }, branch: branch ? { id: branch.id, code: branch.code, label: branch.label, - value: branch.value + value: branch.value, } : null, customer: { @@ -434,7 +510,7 @@ export async function buildQuotationDocumentData( address: customer.address, phone: customer.phone, email: customer.email, - taxId: customer.taxId + taxId: customer.taxId, }, contact: contact ? { @@ -442,7 +518,7 @@ export async function buildQuotationDocumentData( name: contact.name, email: contact.email, mobile: contact.mobile, - position: contact.position + position: contact.position, } : null, quotation: { @@ -458,20 +534,32 @@ export async function buildQuotationDocumentData( revisionLabel: toRevisionLabel(quotationDetail.revision), statusCode, statusLabel, - quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType), - quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType), - currency: findOptionCode(currencyOptions, quotationDetail.currency), - currencyCode: findOptionCode(currencyOptions, quotationDetail.currency), + quotationTypeCode: findOptionCode( + quotationTypeOptions, + quotationDetail.quotationType, + ), + quotationTypeLabel: findOptionLabel( + quotationTypeOptions, + quotationDetail.quotationType, + ), + currency: currencyCode, + currencyCode, currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency), exchangeRate: quotationDetail.exchangeRate, subtotal: quotationDetail.subtotal, discount: quotationDetail.discount, - discountTypeCode: findOptionCode(discountTypeOptions, quotationDetail.discountType), - discountTypeLabel: findOptionLabel(discountTypeOptions, quotationDetail.discountType), + discountTypeCode: findOptionCode( + discountTypeOptions, + quotationDetail.discountType, + ), + discountTypeLabel: findOptionLabel( + discountTypeOptions, + quotationDetail.discountType, + ), taxRate: quotationDetail.taxRate, taxAmount: quotationDetail.taxAmount, totalAmount: quotationDetail.totalAmount, - notes: quotationDetail.notes + notes: quotationDetail.notes, }, items: items.map((item) => ({ id: item.id, @@ -487,101 +575,128 @@ export async function buildQuotationDocumentData( discountTypeCode: findOptionCode(discountTypeOptions, item.discountType), taxRate: item.taxRate, totalPrice: item.totalPrice, - notes: item.notes + notes: item.notes, })), quotationCustomers: quotationCustomers.map((item) => ({ id: item.id, customerId: item.customerId, - customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName, - customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode, + customerName: + relatedCustomerMap.get(item.customerId)?.name ?? item.customerName, + customerCode: + relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode, roleCode: item.roleCode, - roleLabel: item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role), - isPrimary: item.isPrimary + roleLabel: + item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role), + isPrimary: item.isPrimary, })), topics: { scope: scopeTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? '-') + items: item.items.map((child) => child.content ?? "-"), })), exclusion: exclusionTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? '-') + items: item.items.map((child) => child.content ?? "-"), })), payment: paymentTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? '-') + items: item.items.map((child) => child.content ?? "-"), })), warranty: warrantyTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? '-') + items: item.items.map((child) => child.content ?? "-"), })), delivery: deliveryTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? '-') + items: item.items.map((child) => child.content ?? "-"), })), other: otherTopics.map((item) => ({ id: item.id, title: item.title, sortOrder: item.sortOrder, - items: item.items.map((child) => child.content ?? '-') + items: item.items.map((child) => child.content ?? "-"), })), - all: pdfTopics + all: pdfTopics, }, pdfme: { - quotation_date: formatPdfDate(quotationDetail.quotationDate, 'en'), + labels: PDFME_DEFAULT_LABELS, + quotation_date: formatPdfDate(quotationDetail.quotationDate, "en"), quotation_price: formatPdfCurrency( - quotationDetail.totalAmount, - findOptionCode(currencyOptions, quotationDetail.currency) ?? 'THB' + quotationDetail.subtotal, + currencyCode, + ), + quotation_price_data: buildPdfPriceTableData( + quotationDetail.subtotal, + currencyCode, ), exclusion_data: formatTopicItems(exclusionTopic), payment_data: formatTopicItems(paymentTopic), warranty_data: formatTopicItems(warrantyTopic), delivery_data: formatTopicItems(deliveryTopic), - topics_data: formatTopicItems(scopeTopics[0] ?? exclusionTopic ?? paymentTopic), - topic_inputs: {} + topics_data: formatTopicItems( + scopeTopics[0] ?? exclusionTopic ?? paymentTopic, + ), + topic_inputs: {}, }, approval: { requestId: approvalDetail?.request.id ?? null, workflowName: approvalDetail?.workflow.name ?? null, status: approvalDetail?.request.status ?? null, approvedAt: - approvalDetail?.request.status === 'approved' ? approvalDetail.request.completedAt : null, + approvalDetail?.request.status === "approved" + ? approvalDetail.request.completedAt + : null, currentStep: approvalDetail?.request.currentStep ?? null, currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null, - approvers: approvalApprovers + approvers: approvalApprovers, }, signatures, - watermarkStatus: statusCode && statusCode !== 'approved' ? (statusLabel ?? statusCode) : null + watermarkStatus: + statusCode && statusCode !== "approved" + ? (statusLabel ?? statusCode) + : null, }; } -export async function getQuotationDocumentPreviewData(quotationId: string, organizationId: string) { - const documentData = await buildQuotationDocumentData(quotationId, organizationId); +export async function getQuotationDocumentPreviewData( + quotationId: string, + organizationId: string, +) { + const documentData = await buildQuotationDocumentData( + quotationId, + organizationId, + ); const template = await resolveTemplateForDocument(organizationId, { - documentType: 'quotation', - productType: 'default', - fileType: 'pdfme' + documentType: "quotation", + productType: "default", + fileType: "pdfme", }); - const mappings = await resolveTemplateMappings(template.version.id, organizationId); + const mappings = await resolveTemplateMappings( + template.version.id, + organizationId, + ); if (!mappings.length) { throw new AuthError( - 'Document template mappings are not configured for this template version', - 409 + "Document template mappings are not configured for this template version", + 409, ); } const templateInput = normalizePdfmeTemplateInput( template.version.schemaJson, - mapDocumentDataToTemplateInput(documentData as unknown as Record, mappings) + mapDocumentDataToTemplateInput( + documentData as unknown as Record, + mappings, + ), ); const { template: renderTemplate, topicInputs } = buildPdfTopicTemplate( template.version.schemaJson as Template, - documentData.topics.all + documentData.topics.all, ); const mergedTemplateInput = { ...templateInput, - ...topicInputs + ...topicInputs, }; documentData.pdfme.topic_inputs = topicInputs; @@ -592,20 +707,23 @@ export async function getQuotationDocumentPreviewData(quotationId: string, organ ...template, version: { ...template.version, - schemaJson: renderTemplate - } + schemaJson: renderTemplate, + }, }, mappings, - templateInput: mergedTemplateInput + templateInput: mergedTemplateInput, }; } export async function prepareApprovedQuotationSnapshot( quotationId: string, organizationId: string, - userId: string + userId: string, ): Promise { - const preview = await getQuotationDocumentPreviewData(quotationId, organizationId); + const preview = await getQuotationDocumentPreviewData( + quotationId, + organizationId, + ); const generatedAt = new Date().toISOString(); return { @@ -615,6 +733,6 @@ export async function prepareApprovedQuotationSnapshot( templateVersionId: preview.template.version.id, templateInput: preview.templateInput, generatedAt, - generatedBy: userId + generatedBy: userId, }; } diff --git a/src/features/crm/quotations/document/types.ts b/src/features/crm/quotations/document/types.ts index 6c48bb5..52dc6ce 100644 --- a/src/features/crm/quotations/document/types.ts +++ b/src/features/crm/quotations/document/types.ts @@ -115,8 +115,16 @@ export interface QuotationDocumentData { all: PdfTopic[]; }; pdfme: { + labels: { + tel: string; + email: string; + att: string; + project: string; + location: string; + }; quotation_date: string; quotation_price: string; + quotation_price_data: string[][]; exclusion_data: string[][]; payment_data?: string[][]; warranty_data?: string[][]; diff --git a/src/features/foundation/document-template/server/service.ts b/src/features/foundation/document-template/server/service.ts index ba3ab2e..70b439e 100644 --- a/src/features/foundation/document-template/server/service.ts +++ b/src/features/foundation/document-template/server/service.ts @@ -704,6 +704,44 @@ function applyFormatMask(value: unknown, formatMask?: string | null) { return value; } +function parseTableDefaultValue(defaultValue?: string | null) { + if (!defaultValue) { + return null; + } + + try { + const parsed = JSON.parse(defaultValue) as unknown; + + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isBlankString(value: unknown) { + return typeof value === 'string' && value.trim().length === 0; +} + +function resolveScalarMappingValue( + rawValue: unknown, + formattedValue: unknown, + defaultValue?: string | null +) { + if (formattedValue !== undefined && formattedValue !== null && !isBlankString(formattedValue)) { + return formattedValue; + } + + if (rawValue !== undefined && rawValue !== null && !isBlankString(rawValue)) { + return rawValue; + } + + if (defaultValue !== undefined && defaultValue !== null && defaultValue.trim().length > 0) { + return defaultValue; + } + + return '-'; +} + export function mapDocumentDataToTemplateInput( documentData: Record, mappings: DocumentTemplateMappingWithColumns[] @@ -715,9 +753,10 @@ export function mapDocumentDataToTemplateInput( if (mapping.dataType === 'table') { const rows = Array.isArray(rawValue) ? rawValue : []; + const defaultRows = parseTableDefaultValue(mapping.defaultValue); if (rows.length === 0) { - result[mapping.placeholderKey] = normalizePdfmeTable([]); + result[mapping.placeholderKey] = normalizePdfmeTable(defaultRows ?? []); continue; } @@ -760,17 +799,23 @@ export function mapDocumentDataToTemplateInput( }) .filter(Boolean) .join('\n\n'); - result[mapping.placeholderKey] = normalized || mapping.defaultValue || ''; + result[mapping.placeholderKey] = normalized || mapping.defaultValue || '-'; continue; } result[mapping.placeholderKey] = - typeof rawValue === 'string' ? rawValue : mapping.defaultValue || ''; + typeof rawValue === 'string' && rawValue.trim().length > 0 + ? rawValue + : mapping.defaultValue || '-'; continue; } - result[mapping.placeholderKey] = - applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '-'; + const formattedValue = applyFormatMask(rawValue, mapping.formatMask); + result[mapping.placeholderKey] = resolveScalarMappingValue( + rawValue, + formattedValue, + mapping.defaultValue + ); } return result; diff --git a/src/pdfme_template/ALLA_template_pdfme_fainal3.json b/src/pdfme_template/ALLA_template_pdfme_fainal3.json index 7f1529d..6f64128 100644 --- a/src/pdfme_template/ALLA_template_pdfme_fainal3.json +++ b/src/pdfme_template/ALLA_template_pdfme_fainal3.json @@ -341,152 +341,6 @@ "required": false, "readOnly": true }, - { - "name": "exclusion_label", - "type": "table", - "position": { - "x": 10, - "y": 195 - }, - "width": 190, - "height": 5.6448, - "content": "[[\"Exclusion from scope of Supply:\"]]", - "showHead": false, - "repeatHead": false, - "head": [ - "Name" - ], - "headWidthPercentages": [ - 100 - ], - "tableStyles": { - "borderWidth": 0, - "borderColor": "#000000" - }, - "headStyles": { - "fontName": "cordia", - "fontSize": 13, - "characterSpacing": 0, - "alignment": "left", - "verticalAlignment": "middle", - "lineHeight": 1, - "fontColor": "#ffffff", - "borderColor": "", - "backgroundColor": "#2980ba", - "borderWidth": { - "top": 0, - "right": 0, - "bottom": 0, - "left": 0 - }, - "padding": { - "top": 5, - "right": 5, - "bottom": 5, - "left": 5 - } - }, - "bodyStyles": { - "fontName": "cordiaBold", - "fontSize": 16, - "characterSpacing": 0, - "alignment": "left", - "verticalAlignment": "middle", - "lineHeight": 1, - "fontColor": "#000000", - "borderColor": "#ffffff", - "backgroundColor": "#ffffff", - "alternateBackgroundColor": "#ffffff", - "borderWidth": { - "top": 0.1, - "right": 0.1, - "bottom": 0.1, - "left": 0.1 - }, - "padding": { - "top": 0, - "right": 0, - "bottom": 0, - "left": 0 - } - }, - "columnStyles": {}, - "required": false, - "readOnly": false - }, - { - "name": "exclusion_data", - "type": "table", - "position": { - "x": 15, - "y": 205 - }, - "width": 185, - "height": 7.6448, - "content": "[[\"{exclusion_data}\"]]", - "showHead": false, - "repeatHead": false, - "head": [ - "Name" - ], - "headWidthPercentages": [ - 100 - ], - "tableStyles": { - "borderWidth": 0, - "borderColor": "#000000" - }, - "headStyles": { - "fontName": "cordia", - "fontSize": 13, - "characterSpacing": 0, - "alignment": "left", - "verticalAlignment": "middle", - "lineHeight": 1, - "fontColor": "#ffffff", - "borderColor": "", - "backgroundColor": "#2980ba", - "borderWidth": { - "top": 0, - "right": 0, - "bottom": 0, - "left": 0 - }, - "padding": { - "top": 5, - "right": 5, - "bottom": 5, - "left": 5 - } - }, - "bodyStyles": { - "fontName": "cordia", - "fontSize": 16, - "characterSpacing": 0, - "alignment": "left", - "verticalAlignment": "middle", - "lineHeight": 1, - "fontColor": "#000000", - "borderColor": "#ffffff", - "backgroundColor": "", - "alternateBackgroundColor": "#ffffff", - "borderWidth": { - "top": 0.1, - "right": 0.1, - "bottom": 0.1, - "left": 0.1 - }, - "padding": { - "top": 1, - "right": 0, - "bottom": 1, - "left": 0 - } - }, - "columnStyles": {}, - "required": false, - "readOnly": false - }, { "name": "quotation_price_data", "type": "table", @@ -499,17 +353,9 @@ "content": "[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]", "showHead": false, "repeatHead": false, - "head": [ - "Name", - "City", - "Head 3", - "Head 4" - ], + "head": ["Name", "City", "Head 3", "Head 4"], "headWidthPercentages": [ - 28.125, - 58.34767550533545, - 2.4513428524798244, - 11.075981642184729 + 28.125, 58.34767550533545, 2.4513428524798244, 11.075981642184729 ], "tableStyles": { "borderWidth": 0, @@ -951,7 +797,7 @@ { "name": "colon_site", "type": "text", - "content": ":", + "content": "", "position": { "x": 25, "y": 140.03 @@ -1124,12 +970,8 @@ "content": "[[\"Topic:\"]]", "showHead": false, "repeatHead": false, - "head": [ - "Terms of payment upon progress of each item:" - ], - "headWidthPercentages": [ - 100 - ], + "head": ["Terms of payment upon progress of each item:"], + "headWidthPercentages": [100], "tableStyles": { "borderWidth": 0, "borderColor": "#000000" @@ -1197,12 +1039,8 @@ "content": "[[\"{item_topic}\"]]", "showHead": false, "repeatHead": false, - "head": [ - "Head 2" - ], - "headWidthPercentages": [ - 100 - ], + "head": ["Head 2"], + "headWidthPercentages": [100], "tableStyles": { "borderWidth": 0, "borderColor": "#000000" @@ -1479,12 +1317,7 @@ "basePdf": { "width": 210, "height": 297, - "padding": [ - 31, - 10, - 25, - 10 - ], + "padding": [31, 10, 25, 10], "staticSchema": [ { "name": "field1", diff --git a/src/pdfme_template/ONVALLA_template_pdfme_fainal3.json b/src/pdfme_template/ONVALLA_template_pdfme_fainal3.json index d68d045..0ab453d 100644 --- a/src/pdfme_template/ONVALLA_template_pdfme_fainal3.json +++ b/src/pdfme_template/ONVALLA_template_pdfme_fainal3.json @@ -314,144 +314,6 @@ "required": false, "readOnly": true }, - { - "name": "exclusion_label", - "type": "table", - "position": { - "x": 10, - "y": 195 - }, - "width": 190, - "height": 5.6448, - "content": "[[\"Exclusion from scope of Supply:\"]]", - "showHead": false, - "repeatHead": false, - "head": ["Name"], - "headWidthPercentages": [100], - "tableStyles": { - "borderWidth": 0, - "borderColor": "#000000" - }, - "headStyles": { - "fontName": "cordia", - "fontSize": 13, - "characterSpacing": 0, - "alignment": "left", - "verticalAlignment": "middle", - "lineHeight": 1, - "fontColor": "#ffffff", - "borderColor": "", - "backgroundColor": "#2980ba", - "borderWidth": { - "top": 0, - "right": 0, - "bottom": 0, - "left": 0 - }, - "padding": { - "top": 5, - "right": 5, - "bottom": 5, - "left": 5 - } - }, - "bodyStyles": { - "fontName": "cordiaBold", - "fontSize": 16, - "characterSpacing": 0, - "alignment": "left", - "verticalAlignment": "middle", - "lineHeight": 1, - "fontColor": "#000000", - "borderColor": "#ffffff", - "backgroundColor": "#ffffff", - "alternateBackgroundColor": "#ffffff", - "borderWidth": { - "top": 0.1, - "right": 0.1, - "bottom": 0.1, - "left": 0.1 - }, - "padding": { - "top": 0, - "right": 0, - "bottom": 0, - "left": 0 - } - }, - "columnStyles": {}, - "required": false, - "readOnly": false - }, - { - "name": "exclusion_data", - "type": "table", - "position": { - "x": 15, - "y": 205 - }, - "width": 185, - "height": 7.6448, - "content": "[[\"{exclusion_data}\"]]", - "showHead": false, - "repeatHead": false, - "head": ["Name"], - "headWidthPercentages": [100], - "tableStyles": { - "borderWidth": 0, - "borderColor": "#000000" - }, - "headStyles": { - "fontName": "cordia", - "fontSize": 13, - "characterSpacing": 0, - "alignment": "left", - "verticalAlignment": "middle", - "lineHeight": 1, - "fontColor": "#ffffff", - "borderColor": "", - "backgroundColor": "#2980ba", - "borderWidth": { - "top": 0, - "right": 0, - "bottom": 0, - "left": 0 - }, - "padding": { - "top": 5, - "right": 5, - "bottom": 5, - "left": 5 - } - }, - "bodyStyles": { - "fontName": "cordia", - "fontSize": 16, - "characterSpacing": 0, - "alignment": "left", - "verticalAlignment": "middle", - "lineHeight": 1, - "fontColor": "#000000", - "borderColor": "#ffffff", - "backgroundColor": "", - "alternateBackgroundColor": "#ffffff", - "borderWidth": { - "top": 0.1, - "right": 0.1, - "bottom": 0.1, - "left": 0.1 - }, - "padding": { - "top": 1, - "right": 0, - "bottom": 1, - "left": 0 - } - }, - "columnStyles": {}, - "required": false, - "readOnly": false - }, { "name": "quotation_price_data", "type": "table", @@ -908,7 +770,7 @@ { "name": "colon_site", "type": "text", - "content": ": ", + "content": ":", "position": { "x": 25, "y": 140