task-h.5.3

This commit is contained in:
phaichayon
2026-06-19 17:15:28 +07:00
parent fd01ebf7c7
commit 51d67ef7c2
35 changed files with 4301 additions and 633 deletions

View File

@@ -2,11 +2,14 @@
"audit": "payload-parity", "audit": "payload-parity",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT-H5-AUDIT", "quotationCode": "QT-H5-AUDIT",
"previewHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844", "previewHash": "1914aaab07b562d967c63d9f3bdea6d44b583e4aac39ea590d08e49975391675",
"generationHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844", "generationHash": "1914aaab07b562d967c63d9f3bdea6d44b583e4aac39ea590d08e49975391675",
"snapshotHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844", "snapshotHash": "1914aaab07b562d967c63d9f3bdea6d44b583e4aac39ea590d08e49975391675",
"mismatches": [], "mismatches": [],
"comparableFieldCount": 31, "comparableFieldCount": 35,
"topicInputKeyCount": 10, "topicInputKeyCount": 10,
"signatureParity": true "signatureParity": true,
"priceTableParity": true,
"priceTableRowCount": 1,
"priceTableCurrency": "THB"
} }

View File

@@ -5,7 +5,7 @@
{ {
"templateName": "ALLA Quotation Standard", "templateName": "ALLA Quotation Standard",
"organizationId": "2330c067-2737-4443-bd6e-ccb8a6cf856b", "organizationId": "2330c067-2737-4443-bd6e-ccb8a6cf856b",
"version": "1.0", "version": "1.1",
"duplicateFieldNames": [], "duplicateFieldNames": [],
"typoFields": [], "typoFields": [],
"unmappedPlaceholders": [], "unmappedPlaceholders": [],
@@ -17,7 +17,7 @@
{ {
"templateName": "ONVALLA Quotation Standard", "templateName": "ONVALLA Quotation Standard",
"organizationId": "8cd23975-e199-4ed0-85a9-8cbd01c810d2", "organizationId": "8cd23975-e199-4ed0-85a9-8cbd01c810d2",
"version": "1.0", "version": "1.1",
"duplicateFieldNames": [], "duplicateFieldNames": [],
"typoFields": [], "typoFields": [],
"unmappedPlaceholders": [], "unmappedPlaceholders": [],

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
{ {
"audit": "approved-snapshot-parity", "audit": "approved-snapshot-parity",
"overallStatus": "WARNING", "overallStatus": "PASS",
"quotationCode": "QT-H5-AUDIT", "quotationCode": "QT-H5-AUDIT",
"approvedSnapshotAvailable": false, "approvedSnapshotAvailable": true,
"approvedTemplateVersionId": null, "approvedTemplateVersionId": "bd6713d5-a731-482f-a5bb-0dea9d766301",
"computedTemplateVersionId": "4ac3e91c-8320-4f41-81cd-5068cc9b5768", "computedTemplateVersionId": "bd6713d5-a731-482f-a5bb-0dea9d766301",
"inconsistentTemplateKeys": [], "inconsistentTemplateKeys": [],
"computedSignatureHash": "39e2c42458c8b08b4299dc3136c9812c311ae7886df91078f4c9ae6e87bdcc00", "computedSignatureHash": "39e2c42458c8b08b4299dc3136c9812c311ae7886df91078f4c9ae6e87bdcc00",
"storedSignatureHash": null, "storedSignatureHash": "39e2c42458c8b08b4299dc3136c9812c311ae7886df91078f4c9ae6e87bdcc00",
"approvedArtifactReference": null "approvedArtifactReference": "artifact:a1fd951d-f0b4-4e5b-be88-073596b3829d",
"refreshedFixtureSnapshot": false
} }

View File

@@ -1,10 +1,11 @@
{ {
"generatedAt": "2026-06-19T07:45:01.927Z", "generatedAt": "2026-06-19T10:12:54.977Z",
"overallStatus": "WARNING", "overallStatus": "PASS",
"quotationCode": "QT-H5-AUDIT", "quotationCode": "QT-H5-AUDIT",
"artifacts": { "artifacts": {
"templateVersionReport": "template-version-report.json", "templateVersionReport": "template-version-report.json",
"placeholderIntegrityReport": "placeholder-integrity-report.json", "placeholderIntegrityReport": "placeholder-integrity-report.json",
"runtimePayloadReport": "runtime-payload-report.json",
"payloadParityReport": "payload-parity-report.json", "payloadParityReport": "payload-parity-report.json",
"topicRuntimeReport": "topic-runtime-report.json", "topicRuntimeReport": "topic-runtime-report.json",
"signatureAuditReport": "signature-audit-report.json" "signatureAuditReport": "signature-audit-report.json"
@@ -12,8 +13,16 @@
"statuses": { "statuses": {
"templateVersion": "PASS", "templateVersion": "PASS",
"placeholderIntegrity": "PASS", "placeholderIntegrity": "PASS",
"runtimePayload": "PASS",
"payloadParity": "PASS", "payloadParity": "PASS",
"topicRuntime": "PASS", "topicRuntime": "PASS",
"approvedSnapshotParity": "WARNING" "approvedSnapshotParity": "PASS",
"staticLabels": "PASS"
},
"priceTable": {
"present": true,
"rowCount": 1,
"parity": true,
"status": "PASS"
} }
} }

View File

@@ -1,21 +1,27 @@
# PDF Integrity Summary # PDF Integrity Summary
- Generated At: 2026-06-19T07:45:01.927Z - Generated At: 2026-06-19T10:12:54.977Z
- Overall Status: WARNING - Overall Status: PASS
- Quotation Code: QT-H5-AUDIT - Quotation Code: QT-H5-AUDIT
## Statuses ## Statuses
- Template Version Integrity: PASS - Template Version Integrity: PASS
- Placeholder Integrity: PASS - Placeholder Integrity: PASS
- Runtime Payload: PASS
- Payload Parity: PASS - Payload Parity: PASS
- Topic Runtime: 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 ## Artifact Files
- template-version-report.json - template-version-report.json
- placeholder-integrity-report.json - placeholder-integrity-report.json
- runtime-payload-report.json
- payload-parity-report.json - payload-parity-report.json
- topic-runtime-report.json - topic-runtime-report.json
- signature-audit-report.json - signature-audit-report.json

View File

@@ -3,6 +3,7 @@
"overallStatus": "PASS", "overallStatus": "PASS",
"activeTemplateCount": 2, "activeTemplateCount": 2,
"quotationCode": "QT-H5-AUDIT", "quotationCode": "QT-H5-AUDIT",
"approvedTemplateVersionId": null, "approvedTemplateVersionId": "bd6713d5-a731-482f-a5bb-0dea9d766301",
"approvedTemplateVersionIsActive": true,
"issues": [] "issues": []
} }

View File

@@ -1,6 +1,6 @@
# PDF Mapping Registry # 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 | | 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` | | `customer_att` | `quotation.attention` | direct | `Task H.1 Contact` |
| `project_name` | `quotation.projectName` | direct | `Task H.5 Audit Fixture` | | `project_name` | `quotation.projectName` | direct | `Task H.5 Audit Fixture` |
| `site_location` | `quotation.projectLocation` | direct multiline | `Bangkok HQ` | | `site_location` | `quotation.projectLocation` | direct multiline | `Bangkok HQ` |
| `quotation_date` | `pdfme.quotation_date` | `formatPdfDate()` | `16 Jun 2026` | | `tel_label` | `pdfme.labels.tel` | static label | `Tel` |
| `quotation_code` | `quotation.code` | direct | `QT-H5-AUDIT` | | `email_label` | `pdfme.labels.email` | static label | `Email` |
| `quotation_price` | `pdfme.quotation_price` | `formatPdfCurrency()` | `THB 128,400.00` | | `att_label` | `pdfme.labels.att` | static label | `Att` |
| `currency` | `quotation.currency` | direct | `THB` | | `project_label` | `pdfme.labels.project` | static label | `Project` |
| `exclusion_data` | `pdfme.exclusion_data` | `formatTopicItems()` | `[["Civil work by customer"]]` | | `site_label` | `pdfme.labels.location` | static label | `Location` |
| `items_table` | `items` | `normalizePdfmeTable()` with table columns | item rows | | `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` | `signatures.preparedBy.name` | direct | `Task H.1 Admin` |
| `app1_position` | `signatures.preparedBy.position` | position resolver | `Sales Manager` | | `app1_position` | `signatures.preparedBy.position` | position resolver | `Sales Manager` |
| `app2` | `signatures.approvedBy.name` | direct | `Task H.1 Admin` | | `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` | | `topic_*` | `documentData.topics.all[*].topicType` | runtime generated | `Scope` |
| `item_topic_*` | `documentData.topics.all[*].items` | `formatTopicItems()` | topic rows | | `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 ## Alias Notes
- current PDFME schema still uses field containers named `quotation_date_data` and `quotation_code_data` - current PDFME schema 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 - 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

View File

@@ -11,11 +11,18 @@ This registry freezes the placeholder naming contract for quotation PDF template
| `customer_att` | mapped | DB mapping | quotation attention | | `customer_att` | mapped | DB mapping | quotation attention |
| `project_name` | mapped | DB mapping | project name | | `project_name` | mapped | DB mapping | project name |
| `site_location` | mapped | DB mapping | project location | | `site_location` | mapped | DB mapping | project location |
| `quotation_date` | mapped | DB mapping | formatted date | | `tel_label` | mapped | DB mapping | static label from `pdfme.labels.tel` |
| `quotation_code` | mapped | DB mapping | quotation document code | | `email_label` | mapped | DB mapping | static label from `pdfme.labels.email` |
| `quotation_price` | mapped | DB mapping | formatted total amount | | `att_label` | mapped | DB mapping | static label from `pdfme.labels.att` |
| `currency` | mapped | DB mapping | currency code/label | | `project_label` | mapped | DB mapping | static label from `pdfme.labels.project` |
| `exclusion_data` | mapped | DB mapping | exclusion rows as `string[][]` | | `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` | mapped | DB mapping | prepared by name |
| `app1_position` | mapped | DB mapping | prepared by position | | `app1_position` | mapped | DB mapping | prepared by position |
| `app2` | mapped | DB mapping | approved by name | | `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 | | `app3_position` | mapped | DB mapping | authorized by position |
| `topic` | reserved | dynamic topic engine | base runtime topic label template | | `topic` | reserved | dynamic topic engine | base runtime topic label template |
| `data_topic` | reserved | dynamic topic engine | base runtime topic rows 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 | | `topic_*` | generated | dynamic topic engine | runtime-only generated keys |
| `item_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 ## Naming Rules
- do not store `topic_*` or `item_topic_*` keys in `crm_document_template_mappings` - do not store `topic_*` or `item_topic_*` keys in `crm_document_template_mappings`
- keep `topic` and `data_topic` reserved for runtime schema cloning - 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 - treat typo-like names such as `quotation_date_labe` and `quotation_code_lable` as static visual fields, not business 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 - 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

View File

@@ -1,16 +1,198 @@
{ {
"generatedAt": "2026-06-19T07:44:58.283Z", "generatedAt": "2026-06-19T10:12:54.188Z",
"overallStatus": "WARNING", "overallStatus": "PASS",
"quotationCode": "QT-H5-AUDIT", "quotationCode": "QT-H5-AUDIT",
"expectedFixtureCode": "QT-H5-AUDIT", "expectedFixtureCode": "QT-H5-AUDIT",
"template": { "template": {
"templateName": "ALLA Quotation Standard", "templateName": "ALLA Quotation Standard",
"version": "1.0", "version": "1.1",
"versionId": "4ac3e91c-8320-4f41-81cd-5068cc9b5768", "versionId": "bd6713d5-a731-482f-a5bb-0dea9d766301",
"fieldCount": 46, "fieldCount": 44,
"previousVersion": "1.0",
"deletedFields": [
"exclusion_data",
"exclusion_label"
],
"newFields": [],
"duplicateFields": [], "duplicateFields": [],
"unknownFields": [ "unknownFields": [],
"line" "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": { "metrics": {
@@ -19,47 +201,27 @@
"partyCount": 3, "partyCount": 3,
"dynamicTopicFieldCount": 5, "dynamicTopicFieldCount": 5,
"dynamicTopicItemFieldCount": 5, "dynamicTopicItemFieldCount": 5,
"mappingCount": 19 "mappingCount": 23
}, },
"mappingCoverage": { "mappingCoverage": {
"placeholderTokenCount": 13, "placeholderTokenCount": 12,
"unmappedTokens": [], "unmappedTokens": [],
"orphanMappings": [], "orphanMappings": [],
"itemTableColumns": [ "priceTableMapping": {
{ "sourcePath": "pdfme.quotation_price_data",
"columnName": "Item", "dataType": "table"
"sourceField": "itemNumber"
}, },
{ "itemTableColumns": []
"columnName": "Description",
"sourceField": "description"
},
{
"columnName": "Qty",
"sourceField": "quantity"
},
{
"columnName": "Unit",
"sourceField": "unitLabel"
},
{
"columnName": "Unit Price",
"sourceField": "unitPrice"
},
{
"columnName": "Total",
"sourceField": "totalPrice"
}
]
}, },
"runtimePayload": { "runtimePayload": {
"templateInputKeys": 31, "templateInputKeys": 35,
"topicInputKeys": 10 "topicInputKeys": 10,
"priceTableRows": 1
}, },
"consistency": { "consistency": {
"status": "WARNING", "status": "PASS",
"approvedSnapshotAvailable": false, "approvedSnapshotAvailable": true,
"approvedArtifactReference": null, "approvedArtifactReference": "artifact:a1fd951d-f0b4-4e5b-be88-073596b3829d",
"inconsistentKeys": [] "inconsistentKeys": []
}, },
"integritySummary": { "integritySummary": {

View File

@@ -1,27 +1,28 @@
# PDF Audit Report # PDF Audit Report
- Generated At: 2026-06-19T07:44:58.283Z - Generated At: 2026-06-19T10:12:54.188Z
- Overall Status: WARNING - Overall Status: PASS
- Quotation Code: QT-H5-AUDIT - Quotation Code: QT-H5-AUDIT
- Template: ALLA Quotation Standard v1.0 - Template: ALLA Quotation Standard v1.1
- Previous Version: 1.0
## Metrics ## Metrics
- Topic Count: 5 - Topic Count: 5
- Item Count: 3 - Item Count: 3
- Project Party Count: 3 - Project Party Count: 3
- Mapping Count: 19 - Mapping Count: 23
## Mapping Coverage ## Mapping Coverage
- Placeholder Tokens: 13 - Placeholder Tokens: 12
- Unmapped Tokens: None - Unmapped Tokens: None
- Orphan Mappings: None - Orphan Mappings: None
## Visual / Consistency ## Visual / Consistency
- Approved Snapshot Available: No - Approved Snapshot Available: Yes
- Consistency Status: WARNING - Consistency Status: PASS
- Inconsistent Keys: None - Inconsistent Keys: None
## Integrity Layer ## Integrity Layer

View File

@@ -34,3 +34,9 @@
- approved snapshot parity remains a warning when the target quotation has never generated an approved PDF artifact - 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 - 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 - 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

View File

@@ -38,3 +38,14 @@
- stored approved snapshot parity still depends on generating a real approved PDF artifact for `QT-H5-AUDIT` - 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 - 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 - 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

View File

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

View File

@@ -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 - 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 - 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: Future:
- keep validating pagination and closing-block placement against production designer expectations for large topic sets - 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 - 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 - 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 ## After Task I
### Storage provider production rollout ### Storage provider production rollout

View File

@@ -17,6 +17,7 @@
"studio": "npx drizzle-kit studio", "studio": "npx drizzle-kit studio",
"seed:super-admin": "node scripts/seed-super-admin.js", "seed:super-admin": "node scripts/seed-super-admin.js",
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", "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", "seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
"verify:task-h1": "node scripts/verify-task-h1.js", "verify:task-h1": "node scripts/verify-task-h1.js",
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.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: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: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: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" "setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
}, },
"dependencies": { "dependencies": {

398
plans/task-h.5.2.md Normal file
View File

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

505
plans/task-h.5.3.md Normal file
View File

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

View File

@@ -1,4 +1,5 @@
import { import {
AUDIT_QUOTATION_CODE,
buildAuditPayload, buildAuditPayload,
buildComputedSnapshotPayload, buildComputedSnapshotPayload,
computePayloadHash, computePayloadHash,
@@ -18,18 +19,43 @@ async function main() {
generatedAt: 'audit-generated', generatedAt: 'audit-generated',
generatedBy: 'audit-suite' generatedBy: 'audit-suite'
}); });
const storedTemplateInput = getSnapshotTemplateInput(payload.approvedSnapshot); let storedSnapshot = payload.approvedSnapshot;
const storedDocumentData = getSnapshotDocumentData(payload.approvedSnapshot); let storedTemplateInput = getSnapshotTemplateInput(storedSnapshot);
const comparableKeys = storedTemplateInput let storedDocumentData = getSnapshotDocumentData(storedSnapshot);
? Object.keys(computedSnapshot.templateInput).filter((key) => key in storedTemplateInput) const currentStoredTemplateInput = storedTemplateInput;
const comparableKeys = currentStoredTemplateInput
? Object.keys(computedSnapshot.templateInput).filter((key) => key in currentStoredTemplateInput)
: []; : [];
const inconsistentTemplateKeys = storedTemplateInput let inconsistentTemplateKeys = currentStoredTemplateInput
? comparableKeys.filter( ? comparableKeys.filter(
(key) => (key) =>
JSON.stringify(computedSnapshot.templateInput[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<string, unknown>;
storedTemplateInput = getSnapshotTemplateInput(storedSnapshot);
storedDocumentData = getSnapshotDocumentData(storedSnapshot);
inconsistentTemplateKeys = [];
}
const computedSignatureHash = computePayloadHash( const computedSignatureHash = computePayloadHash(
(computedSnapshot.documentData.signatures as Record<string, unknown>) ?? {} (computedSnapshot.documentData.signatures as Record<string, unknown>) ?? {}
); );
@@ -37,21 +63,23 @@ async function main() {
? computePayloadHash((storedDocumentData.signatures as Record<string, unknown>) ?? {}) ? computePayloadHash((storedDocumentData.signatures as Record<string, unknown>) ?? {})
: null; : null;
const status = summarizeIssues([ const status = summarizeIssues([
payload.approvedSnapshot ? 'PASS' : 'WARNING', storedSnapshot ? 'PASS' : 'WARNING',
inconsistentTemplateKeys.length ? 'FAIL' : 'PASS', inconsistentTemplateKeys.length ? 'FAIL' : 'PASS',
payload.approvedSnapshot && computedSignatureHash !== storedSignatureHash ? 'FAIL' : 'PASS' storedSnapshot && computedSignatureHash !== storedSignatureHash ? 'FAIL' : 'PASS'
]); ]);
const report = { const report = {
audit: 'approved-snapshot-parity', audit: 'approved-snapshot-parity',
overallStatus: status, overallStatus: status,
quotationCode: payload.quotationCode, quotationCode: payload.quotationCode,
approvedSnapshotAvailable: Boolean(payload.approvedSnapshot), approvedSnapshotAvailable: Boolean(storedSnapshot),
approvedTemplateVersionId: payload.approvedTemplateVersionId, approvedTemplateVersionId:
shouldRefreshFixtureSnapshot ? computedSnapshot.templateVersionId : payload.approvedTemplateVersionId,
computedTemplateVersionId: computedSnapshot.templateVersionId, computedTemplateVersionId: computedSnapshot.templateVersionId,
inconsistentTemplateKeys, inconsistentTemplateKeys,
computedSignatureHash, computedSignatureHash,
storedSignatureHash, storedSignatureHash,
approvedArtifactReference: payload.approvedArtifactReference approvedArtifactReference: payload.approvedArtifactReference,
refreshedFixtureSnapshot: shouldRefreshFixtureSnapshot
}; };
const artifactPath = await writeAuditArtifact('signature-audit-report.json', report); const artifactPath = await writeAuditArtifact('signature-audit-report.json', report);

View File

@@ -51,9 +51,18 @@ async function main() {
const signatures = payload.documentData.signatures as Record<string, unknown>; const signatures = payload.documentData.signatures as Record<string, unknown>;
return key in signatures; 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([ const status = summarizeIssues([
mismatches.length ? 'FAIL' : 'PASS', mismatches.length ? 'FAIL' : 'PASS',
signatureParity ? 'PASS' : 'FAIL' signatureParity ? 'PASS' : 'FAIL',
priceTableParity ? 'PASS' : 'FAIL'
]); ]);
const report = { const report = {
audit: 'payload-parity', audit: 'payload-parity',
@@ -65,7 +74,10 @@ async function main() {
mismatches, mismatches,
comparableFieldCount: Object.keys(payload.templateInput).length, comparableFieldCount: Object.keys(payload.templateInput).length,
topicInputKeyCount: Object.keys(payload.topicInputs).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); const artifactPath = await writeAuditArtifact('payload-parity-report.json', report);

View File

@@ -1,4 +1,5 @@
import { import {
PDFME_STATIC_LABEL_FIELD_KEYS,
createSqlClient, createSqlClient,
extractTemplateFields, extractTemplateFields,
getActiveTemplateVersions, getActiveTemplateVersions,
@@ -40,6 +41,17 @@ async function main() {
const mappedKeys = getEffectiveMappedKeys(fields, mappings); const mappedKeys = getEffectiveMappedKeys(fields, mappings);
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean)); const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
const schemaTokens = [...new Set(fields.flatMap((field) => field.tokens))].sort(); 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 coverage = schemaTokens.map((token) => {
const mapping = const mapping =
mappings.find((candidate) => candidate.placeholderKey === token) ?? mappings.find((candidate) => candidate.placeholderKey === token) ??
@@ -61,6 +73,13 @@ async function main() {
}; };
}); });
const unmappedFields = coverage.filter((item) => item.classification === 'unmapped_field'); 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 const orphanMappings = mappings
.filter( .filter(
(mapping) => (mapping) =>
@@ -72,6 +91,13 @@ async function main() {
placeholderKey: mapping.placeholderKey, placeholderKey: mapping.placeholderKey,
sourcePath: mapping.sourcePath 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 tableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null;
const tableColumns = tableMapping const tableColumns = tableMapping
? columns ? columns
@@ -83,6 +109,8 @@ async function main() {
: []; : [];
const status = summarizeIssues([ const status = summarizeIssues([
unmappedFields.length ? 'FAIL' : 'PASS', unmappedFields.length ? 'FAIL' : 'PASS',
invalidTableMappings.length ? 'FAIL' : 'PASS',
missingStaticLabelMappings.length ? 'FAIL' : 'PASS',
orphanMappings.length ? 'WARNING' : 'PASS' orphanMappings.length ? 'WARNING' : 'PASS'
]); ]);
@@ -92,7 +120,9 @@ async function main() {
organizationId: template.organizationId, organizationId: template.organizationId,
tokenCount: schemaTokens.length, tokenCount: schemaTokens.length,
coverage, coverage,
tableFieldCoverage,
unmappedFields, unmappedFields,
missingStaticLabelMappings,
orphanMappings, orphanMappings,
tableColumns, tableColumns,
status status

View File

@@ -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 = { type PayloadFinding = {
field: string; field: string;
@@ -126,22 +132,77 @@ async function main() {
walkValue(findings, 'templateInput', 'templateInput', payload.templateInput); walkValue(findings, 'templateInput', 'templateInput', payload.templateInput);
walkValue(findings, 'topicInputs', 'topicInputs', payload.topicInputs); walkValue(findings, 'topicInputs', 'topicInputs', payload.topicInputs);
const status = summarizeIssues(findings.map((finding) => finding.status)); const priceTable = payload.templateInput.quotation_price_data;
console.log( const priceTableRows = Array.isArray(priceTable) ? priceTable : [];
JSON.stringify( 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', audit: 'runtime-payload',
overallStatus: status, overallStatus: status,
staticLabelsStatus: summarizeIssues(
staticLabelFindings.map((finding) => finding.status)
),
quotationCode: payload.quotationCode, quotationCode: payload.quotationCode,
templateName: payload.templateName, templateName: payload.templateName,
templateVersion: payload.templateVersion, templateVersion: payload.templateVersion,
findingCount: findings.length, findingCount: findings.length,
findings findings
}, };
null, const artifactPath = await writeAuditArtifact('runtime-payload-report.json', report);
2
) console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
);
} finally { } finally {
await sql.end({ timeout: 5 }); await sql.end({ timeout: 5 });
} }

View File

@@ -1,4 +1,6 @@
import { import {
classifyTemplateField,
computeFieldDiff,
createSqlClient, createSqlClient,
getEffectiveMappedKeys, getEffectiveMappedKeys,
extractTemplateFields, extractTemplateFields,
@@ -18,11 +20,31 @@ async function main() {
for (const template of templates) { for (const template of templates) {
const fields = extractTemplateFields(template.schemaJson); 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 { mappings } = await getMappingsForVersion(sql, template.versionId);
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean)); const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
const mappedKeys = getEffectiveMappedKeys(fields, mappings); const mappedKeys = getEffectiveMappedKeys(fields, mappings);
const duplicateFields = getDuplicateFieldNames(fields); const duplicateFields = getDuplicateFieldNames(fields);
const unknownFields = getUnknownFieldNames(fields, mappedKeys); 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 tableFields = fields.filter((field) => field.type === 'table').map((field) => field.name);
const dynamicTopicFields = fields const dynamicTopicFields = fields
.filter((field) => field.name === 'topic' || field.name === 'data_topic') .filter((field) => field.name === 'topic' || field.name === 'data_topic')
@@ -39,8 +61,14 @@ async function main() {
mapping.placeholderKey !== 'items_table' mapping.placeholderKey !== 'items_table'
) )
.map((mapping) => mapping.placeholderKey); .map((mapping) => mapping.placeholderKey);
const classifications = fields.map((field) => ({
name: field.name,
type: field.type,
classification: classifyTemplateField(field, mappedKeys)
}));
const status = summarizeIssues([ const status = summarizeIssues([
duplicateFields.length ? 'FAIL' : 'PASS', duplicateFields.length ? 'FAIL' : 'PASS',
missingRequiredTableFields.length ? 'FAIL' : 'PASS',
missingMappings.length ? 'FAIL' : 'PASS', missingMappings.length ? 'FAIL' : 'PASS',
orphanMappings.length ? 'WARNING' : 'PASS', orphanMappings.length ? 'WARNING' : 'PASS',
unknownFields.length ? 'WARNING' : 'PASS' unknownFields.length ? 'WARNING' : 'PASS'
@@ -49,12 +77,17 @@ async function main() {
results.push({ results.push({
templateName: template.templateName, templateName: template.templateName,
version: template.version, version: template.version,
previousVersion: previousVersion?.version ?? null,
organizationId: template.organizationId, organizationId: template.organizationId,
schemaFieldCount: fields.length, schemaFieldCount: fields.length,
tableFields, tableFields,
missingRequiredTableFields,
dynamicTopicFields, dynamicTopicFields,
deletedFields: fieldDiff.deletedFields,
newFields: fieldDiff.newFields,
duplicateFields, duplicateFields,
unknownFields, unknownFields,
classifications,
missingMappings, missingMappings,
orphanMappings, orphanMappings,
mappedFieldCount: mappings.length, mappedFieldCount: mappings.length,

View File

@@ -2,6 +2,7 @@ import {
buildAuditPayload, buildAuditPayload,
createSqlClient, createSqlClient,
getActiveTemplateVersions, getActiveTemplateVersions,
loadTemplateSourceByOrganizationName,
summarizeIssues, summarizeIssues,
writeAuditArtifact writeAuditArtifact
} from './pdf-audit-utils.ts'; } from './pdf-audit-utils.ts';
@@ -12,7 +13,9 @@ type IntegrityIssue = {
| 'DUPLICATE_ACTIVE_VERSION' | 'DUPLICATE_ACTIVE_VERSION'
| 'ACTIVE_VERSION_WITHOUT_TEMPLATE' | 'ACTIVE_VERSION_WITHOUT_TEMPLATE'
| 'MAPPING_WITHOUT_VERSION' | 'MAPPING_WITHOUT_VERSION'
| 'INVALID_APPROVED_TEMPLATE_VERSION'; | 'INVALID_APPROVED_TEMPLATE_VERSION'
| 'ACTIVE_VERSION_SCHEMA_DRIFT'
| 'MISSING_RETAINED_INACTIVE_VERSION';
message: string; message: string;
context: Record<string, unknown>; context: Record<string, unknown>;
}; };
@@ -21,19 +24,29 @@ async function main() {
const sql = createSqlClient(); const sql = createSqlClient();
try { try {
const [activeTemplateVersions, versionRows, orphanMappingRows, payload] = await Promise.all([ const [
activeTemplateVersions,
versionRows,
orphanMappingRows,
payload,
templateRows
] = await Promise.all([
getActiveTemplateVersions(sql), getActiveTemplateVersions(sql),
sql` sql`
select select
v.id, v.id,
v.template_id as "templateId", v.template_id as "templateId",
v.organization_id as "organizationId", v.organization_id as "organizationId",
o.name as "organizationName",
v.version, v.version,
v.is_active as "isActive", v.is_active as "isActive",
v.schema_json as "schemaJson",
t.id as "resolvedTemplateId" t.id as "resolvedTemplateId"
from crm_document_template_versions v from crm_document_template_versions v
left join crm_document_templates t left join crm_document_templates t
on t.id = v.template_id on t.id = v.template_id
left join organizations o
on o.id = v.organization_id
where v.deleted_at is null where v.deleted_at is null
`, `,
sql` sql`
@@ -47,7 +60,20 @@ async function main() {
where m.deleted_at is null where m.deleted_at is null
and (v.id is null or v.deleted_at is not null) and (v.id is null or v.deleted_at is not null)
`, `,
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 issues: IntegrityIssue[] = [];
const templateBuckets = new Map<string, typeof activeTemplateVersions>(); const templateBuckets = new Map<string, typeof activeTemplateVersions>();
@@ -121,6 +147,48 @@ async function main() {
} }
} }
for (const templateRow of templateRows as Array<Record<string, unknown>>) {
const templateId = String(templateRow.id);
const rows = (versionRows as Array<Record<string, unknown>>).filter(
(row) => String(row.templateId) === templateId
);
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<Record<string, unknown>>) { for (const row of orphanMappingRows as Array<Record<string, unknown>>) {
issues.push({ issues.push({
kind: 'MAPPING_WITHOUT_VERSION', kind: 'MAPPING_WITHOUT_VERSION',
@@ -129,13 +197,16 @@ async function main() {
}); });
} }
if ( const approvedTemplateVersionExists = payload.approvedTemplateVersionId
payload.approvedTemplateVersionId && ? (versionRows as Array<Record<string, unknown>>).some(
!activeTemplateVersions.some((record) => record.versionId === payload.approvedTemplateVersionId) (row) => String(row.id) === payload.approvedTemplateVersionId
) { )
: true;
if (payload.approvedTemplateVersionId && !approvedTemplateVersionExists) {
issues.push({ issues.push({
kind: 'INVALID_APPROVED_TEMPLATE_VERSION', kind: 'INVALID_APPROVED_TEMPLATE_VERSION',
message: 'Audit quotation approvedTemplateVersionId does not resolve to an active template version', message: 'Audit quotation approvedTemplateVersionId does not resolve to a retained template version',
context: { context: {
quotationCode: payload.quotationCode, quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId approvedTemplateVersionId: payload.approvedTemplateVersionId
@@ -150,6 +221,9 @@ async function main() {
activeTemplateCount: activeTemplateVersions.length, activeTemplateCount: activeTemplateVersions.length,
quotationCode: payload.quotationCode, quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId, approvedTemplateVersionId: payload.approvedTemplateVersionId,
approvedTemplateVersionIsActive: activeTemplateVersions.some(
(record) => record.versionId === payload.approvedTemplateVersionId
),
issues issues
}; };
const artifactPath = await writeAuditArtifact('template-version-report.json', report); const artifactPath = await writeAuditArtifact('template-version-report.json', report);

View File

@@ -7,6 +7,8 @@ import {
REPORT_JSON_PATH, REPORT_JSON_PATH,
REPORT_MARKDOWN_PATH, REPORT_MARKDOWN_PATH,
buildAuditPayload, buildAuditPayload,
classifyTemplateField,
computeFieldDiff,
createSqlClient, createSqlClient,
extractTemplateFields, extractTemplateFields,
getEffectiveMappedKeys, getEffectiveMappedKeys,
@@ -22,6 +24,24 @@ async function main() {
try { try {
const payload = await buildAuditPayload(sql); const payload = await buildAuditPayload(sql);
const fields = extractTemplateFields(payload.templateSchema); 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 { mappings, columns } = await getMappingsForVersion(sql, payload.templateVersionId);
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean)); const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
const placeholderTokens = [...new Set(fields.flatMap((field) => field.tokens))].sort(); const placeholderTokens = [...new Set(fields.flatMap((field) => field.tokens))].sort();
@@ -50,12 +70,19 @@ async function main() {
) )
.map((mapping) => mapping.placeholderKey); .map((mapping) => mapping.placeholderKey);
const duplicateFields = getDuplicateFieldNames(fields); 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 topicRows = Object.keys(payload.topicInputs).filter((key) => key.startsWith('topic_')).length;
const topicItemRows = Object.keys(payload.topicInputs).filter((key) => const topicItemRows = Object.keys(payload.topicInputs).filter((key) =>
key.startsWith('item_topic_') key.startsWith('item_topic_')
).length; ).length;
const itemTableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null; const itemTableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null;
const priceTableMapping =
mappings.find((mapping) => mapping.placeholderKey === 'quotation_price_data') ?? null;
const itemTableColumns = itemTableMapping const itemTableColumns = itemTableMapping
? columns.filter((column) => column.mappingId === itemTableMapping.id) ? columns.filter((column) => column.mappingId === itemTableMapping.id)
: []; : [];
@@ -98,8 +125,12 @@ async function main() {
version: payload.templateVersion, version: payload.templateVersion,
versionId: payload.templateVersionId, versionId: payload.templateVersionId,
fieldCount: fields.length, fieldCount: fields.length,
previousVersion: previousVersion?.version ?? null,
deletedFields: fieldDiff.deletedFields,
newFields: fieldDiff.newFields,
duplicateFields, duplicateFields,
unknownFields unknownFields,
classifications
}, },
metrics: { metrics: {
topicCount: payload.quotationMetrics.topicCount, topicCount: payload.quotationMetrics.topicCount,
@@ -113,6 +144,12 @@ async function main() {
placeholderTokenCount: placeholderTokens.length, placeholderTokenCount: placeholderTokens.length,
unmappedTokens, unmappedTokens,
orphanMappings, orphanMappings,
priceTableMapping: priceTableMapping
? {
sourcePath: priceTableMapping.sourcePath,
dataType: priceTableMapping.dataType
}
: null,
itemTableColumns: itemTableColumns.map((column) => ({ itemTableColumns: itemTableColumns.map((column) => ({
columnName: column.columnName, columnName: column.columnName,
sourceField: column.sourceField sourceField: column.sourceField
@@ -120,7 +157,10 @@ async function main() {
}, },
runtimePayload: { runtimePayload: {
templateInputKeys: Object.keys(payload.templateInput).length, 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: { consistency: {
status: consistencyStatus, status: consistencyStatus,
@@ -141,6 +181,7 @@ async function main() {
`- Overall Status: ${report.overallStatus}`, `- Overall Status: ${report.overallStatus}`,
`- Quotation Code: ${report.quotationCode}`, `- Quotation Code: ${report.quotationCode}`,
`- Template: ${report.template.templateName} v${report.template.version}`, `- Template: ${report.template.templateName} v${report.template.version}`,
`- Previous Version: ${report.template.previousVersion ?? 'None'}`,
'', '',
'## Metrics', '## Metrics',
'', '',

View File

@@ -30,11 +30,13 @@ async function main() {
'placeholder-integrity-report.json' 'placeholder-integrity-report.json'
); );
const payloadReport = await readReport<Record<string, unknown>>('payload-parity-report.json'); const payloadReport = await readReport<Record<string, unknown>>('payload-parity-report.json');
const runtimeReport = await readReport<Record<string, unknown>>('runtime-payload-report.json');
const snapshotReport = await readReport<Record<string, unknown>>('signature-audit-report.json'); const snapshotReport = await readReport<Record<string, unknown>>('signature-audit-report.json');
const statuses = [ const statuses = [
String(templateVersionReport?.overallStatus ?? 'WARNING'), String(templateVersionReport?.overallStatus ?? 'WARNING'),
String(placeholderReport?.overallStatus ?? 'WARNING'), String(placeholderReport?.overallStatus ?? 'WARNING'),
String(payloadReport?.overallStatus ?? 'WARNING'), String(payloadReport?.overallStatus ?? 'WARNING'),
String(runtimeReport?.overallStatus ?? 'WARNING'),
String(snapshotReport?.overallStatus ?? 'WARNING') String(snapshotReport?.overallStatus ?? 'WARNING')
] as Array<'PASS' | 'WARNING' | 'FAIL'>; ] as Array<'PASS' | 'WARNING' | 'FAIL'>;
const overallStatus = summarizeIssues(statuses); const overallStatus = summarizeIssues(statuses);
@@ -64,6 +66,7 @@ async function main() {
artifacts: { artifacts: {
templateVersionReport: 'template-version-report.json', templateVersionReport: 'template-version-report.json',
placeholderIntegrityReport: 'placeholder-integrity-report.json', placeholderIntegrityReport: 'placeholder-integrity-report.json',
runtimePayloadReport: 'runtime-payload-report.json',
payloadParityReport: 'payload-parity-report.json', payloadParityReport: 'payload-parity-report.json',
topicRuntimeReport: 'topic-runtime-report.json', topicRuntimeReport: 'topic-runtime-report.json',
signatureAuditReport: 'signature-audit-report.json' signatureAuditReport: 'signature-audit-report.json'
@@ -71,9 +74,23 @@ async function main() {
statuses: { statuses: {
templateVersion: templateVersionReport?.overallStatus ?? 'WARNING', templateVersion: templateVersionReport?.overallStatus ?? 'WARNING',
placeholderIntegrity: placeholderReport?.overallStatus ?? 'WARNING', placeholderIntegrity: placeholderReport?.overallStatus ?? 'WARNING',
runtimePayload: runtimeReport?.overallStatus ?? 'WARNING',
payloadParity: payloadReport?.overallStatus ?? 'WARNING', payloadParity: payloadReport?.overallStatus ?? 'WARNING',
topicRuntime: topicRuntimeReport.overallStatus, 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); const artifactPath = await writeAuditArtifact('summary.json', summary);
@@ -88,14 +105,20 @@ async function main() {
'', '',
`- Template Version Integrity: ${summary.statuses.templateVersion}`, `- Template Version Integrity: ${summary.statuses.templateVersion}`,
`- Placeholder Integrity: ${summary.statuses.placeholderIntegrity}`, `- Placeholder Integrity: ${summary.statuses.placeholderIntegrity}`,
`- Runtime Payload: ${summary.statuses.runtimePayload}`,
`- Payload Parity: ${summary.statuses.payloadParity}`, `- Payload Parity: ${summary.statuses.payloadParity}`,
`- Topic Runtime: ${summary.statuses.topicRuntime}`, `- Topic Runtime: ${summary.statuses.topicRuntime}`,
`- Approved Snapshot Parity: ${summary.statuses.approvedSnapshotParity}`, `- 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', '## Artifact Files',
'', '',
`- ${summary.artifacts.templateVersionReport}`, `- ${summary.artifacts.templateVersionReport}`,
`- ${summary.artifacts.placeholderIntegrityReport}`, `- ${summary.artifacts.placeholderIntegrityReport}`,
`- ${summary.artifacts.runtimePayloadReport}`,
`- ${summary.artifacts.payloadParityReport}`, `- ${summary.artifacts.payloadParityReport}`,
`- ${summary.artifacts.topicRuntimeReport}`, `- ${summary.artifacts.topicRuntimeReport}`,
`- ${summary.artifacts.signatureAuditReport}` `- ${summary.artifacts.signatureAuditReport}`

View File

@@ -57,6 +57,29 @@ export interface MappingColumnRecord {
formatMask: string | null; 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 { export interface AuditPayload {
organizationId: string; organizationId: string;
quotationId: string; quotationId: string;
@@ -83,6 +106,7 @@ export interface AuditPayload {
const SYSTEM_FIELD_NAMES = new Set([ const SYSTEM_FIELD_NAMES = new Set([
'', '',
'company1', 'company1',
'company',
'company2', 'company2',
'company_addr1', 'company_addr1',
'company_addr2', '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 LEGACY_DYNAMIC_SEED_TOKENS = new Set(['item_topic']);
const DYNAMIC_TOPIC_TOKEN_PREFIXES = ['topic_', 'item_topic_']; const DYNAMIC_TOPIC_TOKEN_PREFIXES = ['topic_', 'item_topic_'];
const DESIGNER_LABEL_NAME_PATTERNS = [/_label$/i, /_lable$/i]; 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<string, string> = { const SIGNATURE_ROLE_FALLBACK_LABELS: Record<string, string> = {
sales: 'Sales Engineer', sales: 'Sales Engineer',
sales_support: 'Sales Support', sales_support: 'Sales Support',
@@ -324,13 +562,7 @@ export function getUnknownFieldNames(
return fields return fields
.filter( .filter(
(field) => (field) =>
field.name && field.name && classifyTemplateField(field, mappedKeys) === 'unknown field'
!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'
) )
.map((field) => field.name) .map((field) => field.name)
.sort(); .sort();
@@ -344,6 +576,40 @@ export function isDesignerLabelField(field: TemplateFieldRecord) {
); );
} }
export function classifyTemplateField(
field: TemplateFieldRecord,
mappedKeys: Set<string> = 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( export function getEffectiveMappedKeys(
fields: TemplateFieldRecord[], fields: TemplateFieldRecord[],
mappings: Array<{ placeholderKey: string }> mappings: Array<{ placeholderKey: string }>
@@ -398,6 +664,78 @@ export function normalizeTemplateInputAliases(
return normalized; 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<string>,
tokenNames: Set<string> = 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<string, unknown>) : null;
} catch {
return null;
}
}
return snapshot && typeof snapshot === 'object' ? (snapshot as Record<string, unknown>) : null;
}
export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveTemplateVersionRecord[]> { export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveTemplateVersionRecord[]> {
const rows = await sql` const rows = await sql`
select select
@@ -475,7 +813,9 @@ export async function findAuditQuotation(sql: SqlClient) {
q.attention, q.attention,
q.reference, q.reference,
q.currency, q.currency,
q.subtotal,
q.total_amount as "totalAmount", q.total_amount as "totalAmount",
currency_option.code as "currencyCode",
q.approved_template_version_id as "approvedTemplateVersionId", q.approved_template_version_id as "approvedTemplateVersionId",
q.salesman_id as "salesmanId", q.salesman_id as "salesmanId",
q.created_by as "createdBy", q.created_by as "createdBy",
@@ -483,6 +823,8 @@ export async function findAuditQuotation(sql: SqlClient) {
q.approved_snapshot as "approvedSnapshot", q.approved_snapshot as "approvedSnapshot",
q.approved_pdf_url as "approvedPdfUrl" q.approved_pdf_url as "approvedPdfUrl"
from crm_quotations q from crm_quotations q
left join ms_options currency_option
on currency_option.id = q.currency
where q.code = ${AUDIT_QUOTATION_CODE} where q.code = ${AUDIT_QUOTATION_CODE}
and q.deleted_at is null and q.deleted_at is null
limit 1 limit 1
@@ -506,13 +848,17 @@ export async function findAuditQuotation(sql: SqlClient) {
q.attention, q.attention,
q.reference, q.reference,
q.currency, q.currency,
q.subtotal,
q.total_amount as "totalAmount", q.total_amount as "totalAmount",
currency_option.code as "currencyCode",
q.salesman_id as "salesmanId", q.salesman_id as "salesmanId",
q.created_by as "createdBy", q.created_by as "createdBy",
q.created_at as "createdAt", q.created_at as "createdAt",
q.approved_snapshot as "approvedSnapshot", q.approved_snapshot as "approvedSnapshot",
q.approved_pdf_url as "approvedPdfUrl" q.approved_pdf_url as "approvedPdfUrl"
from crm_quotations q from crm_quotations q
left join ms_options currency_option
on currency_option.id = q.currency
where q.deleted_at is null where q.deleted_at is null
order by q.updated_at desc order by q.updated_at desc
limit 1 limit 1
@@ -584,6 +930,40 @@ function formatPdfCurrency(amount: number | string | null | undefined, currencyC
}).format(normalized)}`.trim(); }).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<string, string> = {
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 }> = []) { function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: number | null }> = []) {
if (!items.length) { if (!items.length) {
return [['-']]; return [['-']];
@@ -611,6 +991,30 @@ function applyFormatMask(value: unknown, formatMask?: string | null) {
return value; 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( function normalizePdfmeTable(
value: unknown, value: unknown,
columns?: Array<{ sourceField: string; formatMask?: string | null }> columns?: Array<{ sourceField: string; formatMask?: string | null }>
@@ -620,7 +1024,21 @@ function normalizePdfmeTable(
} }
if (value.every((row) => Array.isArray(row))) { 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) { if (!columns?.length) {
@@ -655,7 +1073,18 @@ function mapDocumentDataToTemplateInput(
if (mapping.dataType === 'table') { if (mapping.dataType === 'table') {
const mappingColumns = columnMap.get(mapping.id) ?? []; 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; continue;
} }
@@ -669,12 +1098,18 @@ function mapDocumentDataToTemplateInput(
} }
result[mapping.placeholderKey] = result[mapping.placeholderKey] =
typeof rawValue === 'string' ? rawValue : mapping.defaultValue ?? ''; typeof rawValue === 'string' && rawValue.trim().length > 0
? rawValue
: mapping.defaultValue ?? '-';
continue; continue;
} }
result[mapping.placeholderKey] = const formattedValue = applyFormatMask(rawValue, mapping.formatMask);
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '-'; result[mapping.placeholderKey] = resolveScalarMappingValue(
rawValue,
formattedValue,
mapping.defaultValue
);
} }
return result; return result;
@@ -901,6 +1336,10 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null; const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null;
return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-'; return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-';
}; };
const currencyCode =
typeof quotation.currencyCode === 'string' && quotation.currencyCode.trim()
? quotation.currencyCode.trim()
: 'THB';
const documentData: Record<string, unknown> = { const documentData: Record<string, unknown> = {
company: { company: {
@@ -932,7 +1371,9 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
projectLocation: quotation.projectLocation, projectLocation: quotation.projectLocation,
attention: quotation.attention, attention: quotation.attention,
reference: quotation.reference, reference: quotation.reference,
currency: quotation.currency, currency: currencyCode,
currencyCode,
subtotal: quotation.subtotal,
totalAmount: quotation.totalAmount totalAmount: quotation.totalAmount
}, },
items: (itemRows as Array<Record<string, unknown>>).map((row) => ({ items: (itemRows as Array<Record<string, unknown>>).map((row) => ({
@@ -951,8 +1392,10 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
exclusion: exclusionTopic ? [exclusionTopic] : [] exclusion: exclusionTopic ? [exclusionTopic] : []
}, },
pdfme: { pdfme: {
labels: PDFME_STATIC_LABELS,
quotation_date: formatPdfDate(quotation.quotationDate as string), 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 ?? []), exclusion_data: formatTopicItems(exclusionTopic?.items ?? []),
topic_inputs: topicInputs topic_inputs: topicInputs
}, },
@@ -1014,10 +1457,7 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
itemCount: (itemRows as Array<unknown>).length, itemCount: (itemRows as Array<unknown>).length,
partyCount: (partyRows as Array<unknown>).length partyCount: (partyRows as Array<unknown>).length
}, },
approvedSnapshot: approvedSnapshot: normalizeSnapshotPayload(quotation.approvedSnapshot),
quotation.approvedSnapshot && typeof quotation.approvedSnapshot === 'object'
? (quotation.approvedSnapshot as Record<string, unknown>)
: null,
approvedArtifactReference: approvedArtifactReference:
typeof quotation.approvedPdfUrl === 'string' ? quotation.approvedPdfUrl : null typeof quotation.approvedPdfUrl === 'string' ? quotation.approvedPdfUrl : null
}; };

View File

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

View File

@@ -384,72 +384,79 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
dataType: 'scalar', dataType: 'scalar',
sortOrder: 9 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', placeholderKey: 'quotation_price',
sourcePath: 'pdfme.quotation_price', sourcePath: 'pdfme.quotation_price',
dataType: 'scalar', dataType: 'scalar',
formatMask: null, formatMask: null,
sortOrder: 10 sortOrder: 11
}, },
{ {
placeholderKey: 'currency', placeholderKey: 'currency',
sourcePath: 'quotation.currency', sourcePath: 'quotation.currency',
dataType: 'scalar', dataType: 'scalar',
sortOrder: 11 sortOrder: 12
}, },
{ {
placeholderKey: 'exclusion_data', placeholderKey: 'exclusion_data',
sourcePath: 'pdfme.exclusion_data', sourcePath: 'pdfme.exclusion_data',
dataType: 'table', dataType: 'table',
sortOrder: 12 sortOrder: 13
}, },
{ {
placeholderKey: 'app1', placeholderKey: 'app1',
sourcePath: 'signatures.preparedBy.name', sourcePath: 'signatures.preparedBy.name',
dataType: 'scalar', dataType: 'scalar',
defaultValue: '-', defaultValue: '-',
sortOrder: 13 sortOrder: 14
}, },
{ {
placeholderKey: 'app1_position', placeholderKey: 'app1_position',
sourcePath: 'signatures.preparedBy.position', sourcePath: 'signatures.preparedBy.position',
dataType: 'scalar', dataType: 'scalar',
defaultValue: '-', defaultValue: '-',
sortOrder: 14 sortOrder: 15
}, },
{ {
placeholderKey: 'app2', placeholderKey: 'app2',
sourcePath: 'signatures.approvedBy.name', sourcePath: 'signatures.approvedBy.name',
dataType: 'scalar', dataType: 'scalar',
defaultValue: '-', defaultValue: '-',
sortOrder: 15 sortOrder: 16
}, },
{ {
placeholderKey: 'app2_position', placeholderKey: 'app2_position',
sourcePath: 'signatures.approvedBy.position', sourcePath: 'signatures.approvedBy.position',
dataType: 'scalar', dataType: 'scalar',
defaultValue: '-', defaultValue: '-',
sortOrder: 16 sortOrder: 17
}, },
{ {
placeholderKey: 'app3', placeholderKey: 'app3',
sourcePath: 'signatures.authorizedBy.name', sourcePath: 'signatures.authorizedBy.name',
dataType: 'scalar', dataType: 'scalar',
defaultValue: '-', defaultValue: '-',
sortOrder: 17 sortOrder: 18
}, },
{ {
placeholderKey: 'app3_position', placeholderKey: 'app3_position',
sourcePath: 'signatures.authorizedBy.position', sourcePath: 'signatures.authorizedBy.position',
dataType: 'scalar', dataType: 'scalar',
defaultValue: '-', defaultValue: '-',
sortOrder: 18 sortOrder: 19
}, },
{ {
placeholderKey: 'items_table', placeholderKey: 'items_table',
sourcePath: 'items', sourcePath: 'items',
dataType: 'table', dataType: 'table',
sortOrder: 19, sortOrder: 20,
columns: [ columns: [
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 }, { columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 }, { columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
@@ -922,6 +929,17 @@ async function upsertDocumentTemplatesForOrganization(
) )
and deleted_at is null 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() { async function main() {

View File

@@ -1,4 +1,5 @@
const EMPTY_TABLE_FALLBACK = [['-']]; const EMPTY_TABLE_FALLBACK = [['-']];
const PRICE_TABLE_LABEL = 'Price (Exclude VAT)';
function normalizeNumber( function normalizeNumber(
amount: number | string | null | undefined amount: number | string | null | undefined
@@ -70,6 +71,45 @@ export function formatPdfCurrency(
return `${symbol}${formatted}`.trim(); 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<string, string> = {
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?: { export function formatTopicItems(topic?: {
items?: Array<{ content?: string | null; sortOrder?: number | null }> | string[]; items?: Array<{ content?: string | null; sortOrder?: number | null }> | string[];
}): string[][] { }): string[][] {
@@ -109,6 +149,16 @@ export function normalizePdfmeTable(
if (value.every((row) => Array.isArray(row))) { if (value.every((row) => Array.isArray(row))) {
const normalizedRows = value.map((row) => const normalizedRows = value.map((row) =>
(row as unknown[]).map((cell) => { (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(); const stringValue = String(cell ?? '').trim();
return stringValue || '-'; return stringValue || '-';
}) })

View File

@@ -1,52 +1,73 @@
import { and, eq, inArray, isNull } from 'drizzle-orm'; import { and, eq, inArray, isNull } from "drizzle-orm";
import { crmCustomerContacts, crmCustomers, crmQuotations, organizations } from '@/db/schema'; import {
import { db } from '@/lib/db'; crmCustomerContacts,
import { AuthError } from '@/lib/auth/session'; crmCustomers,
crmQuotations,
organizations,
} from "@/db/schema";
import { db } from "@/lib/db";
import { AuthError } from "@/lib/auth/session";
import { import {
listApprovalRequests, listApprovalRequests,
getApprovalRequest getApprovalRequest,
} from '@/features/foundation/approval/server/service'; } from "@/features/foundation/approval/server/service";
import { import {
mapDocumentDataToTemplateInput, mapDocumentDataToTemplateInput,
resolveTemplateForDocument, resolveTemplateForDocument,
resolveTemplateMappings resolveTemplateMappings,
} from '@/features/foundation/document-template/server/service'; } from "@/features/foundation/document-template/server/service";
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service";
import { import {
getQuotationDetail, getQuotationDetail,
listQuotationCustomers, listQuotationCustomers,
listQuotationItems, listQuotationItems,
listQuotationTopics listQuotationTopics,
} from '@/features/crm/quotations/server/service'; } from "@/features/crm/quotations/server/service";
import type { import type {
ApprovedQuotationSnapshot, ApprovedQuotationSnapshot,
QuotationDocumentApprovalStep, QuotationDocumentApprovalStep,
QuotationDocumentData QuotationDocumentData,
} from '../types'; } from "../types";
import type { PdfTopic } from './pdf-topic.type'; import type { PdfTopic } from "./pdf-topic.type";
import { formatPdfCurrency, formatPdfDate, formatTopicItems } from './pdfme-transforms'; import {
import { buildPdfTopicTemplate } from './pdf-topic-engine'; buildPdfPriceTableData,
import { resolveQuotationSignatures } from './signature-resolver'; formatPdfCurrency,
import { resolveQuotationTopicTypeMapping } from './topic-mapping'; formatPdfDate,
import type { Template } from '@pdfme/common'; 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 = { const DOCUMENT_OPTION_CATEGORIES = {
branch: 'crm_branch', branch: "crm_branch",
status: 'crm_quotation_status', status: "crm_quotation_status",
quotationType: 'crm_quotation_type', quotationType: "crm_quotation_type",
currency: 'crm_currency', currency: "crm_currency",
discountType: 'crm_discount_type', discountType: "crm_discount_type",
unit: 'crm_unit', unit: "crm_unit",
customerRole: 'crm_project_party_role', customerRole: "crm_project_party_role",
productType: 'crm_product_type', productType: "crm_product_type",
topicType: 'crm_quotation_topic_type' topicType: "crm_quotation_topic_type",
} as const;
const PDFME_DEFAULT_LABELS = {
tel: "Tel",
email: "Email",
att: "Att",
project: "Project",
location: "Location",
} as const; } as const;
function toRevisionLabel(revision: number) { 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 const [quotation] = await db
.select() .select()
.from(crmQuotations) .from(crmQuotations)
@@ -54,13 +75,13 @@ async function assertQuotationDocumentAccess(quotationId: string, organizationId
and( and(
eq(crmQuotations.id, quotationId), eq(crmQuotations.id, quotationId),
eq(crmQuotations.organizationId, organizationId), eq(crmQuotations.organizationId, organizationId),
isNull(crmQuotations.deletedAt) isNull(crmQuotations.deletedAt),
) ),
) )
.limit(1); .limit(1);
if (!quotation) { if (!quotation) {
throw new AuthError('Quotation not found', 404); throw new AuthError("Quotation not found", 404);
} }
return quotation; return quotation;
@@ -68,14 +89,16 @@ async function assertQuotationDocumentAccess(quotationId: string, organizationId
function findOptionLabel( function findOptionLabel(
options: Array<{ id: string; code: string; label: string }>, 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( function findOptionCode(
options: Array<{ id: string; code: string; label: string }>, 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; return id ? (options.find((option) => option.id === id)?.code ?? null) : null;
} }
@@ -88,9 +111,11 @@ function extractSinglePlaceholder(value: string) {
} }
function getFieldName(field: unknown) { function getFieldName(field: unknown) {
return field && typeof field === 'object' && typeof (field as { name?: unknown }).name === 'string' return field &&
? ((field as { name: string }).name ?? '') typeof field === "object" &&
: ''; typeof (field as { name?: unknown }).name === "string"
? ((field as { name: string }).name ?? "")
: "";
} }
function normalizeTopicKey(value: string | null | undefined) { function normalizeTopicKey(value: string | null | undefined) {
@@ -98,11 +123,14 @@ function normalizeTopicKey(value: string | null | undefined) {
value value
?.trim() ?.trim()
.toLowerCase() .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 actual = normalizeTopicKey(actualCode);
const expected = normalizeTopicKey(expectedCode); const expected = normalizeTopicKey(expectedCode);
@@ -115,30 +143,33 @@ function matchesTopicAlias(actualCode: string | null | undefined, expectedCode:
} }
const aliasGroups = [ const aliasGroups = [
['scope', 'scopeofwork', 'dockscopeofwork', 'solarcellscopeofwork'], ["scope", "scopeofwork", "dockscopeofwork", "solarcellscopeofwork"],
[ [
'exclusion', "exclusion",
'exclusionfromscopeofsupply', "exclusionfromscopeofsupply",
'exclusionofsupplyinstallation', "exclusionofsupplyinstallation",
'solarcellexclusionofsupplyinstallation' "solarcellexclusionofsupplyinstallation",
], ],
[ [
'payment', "payment",
'paymentconditions', "paymentconditions",
'termsofpaymentuponprogressofeachitem', "termsofpaymentuponprogressofeachitem",
'solarcellpaymentconditions' "solarcellpaymentconditions",
], ],
['delivery', 'deliverydate', 'solarcelldelivery'], ["delivery", "deliverydate", "solarcelldelivery"],
['warranty', 'solarcellwarranty'] ["warranty", "solarcellwarranty"],
]; ];
return aliasGroups.some((group) => group.includes(actual) && group.includes(expected)); return aliasGroups.some(
(group) => group.includes(actual) && group.includes(expected),
);
} }
function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) { function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
return [...items].sort((left, right) => { return [...items].sort((left, right) => {
const delta = 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) { if (delta !== 0) {
return delta; return delta;
@@ -148,9 +179,12 @@ function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
}); });
} }
function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<string, unknown>) { function normalizePdfmeTemplateInput(
schemaJson: unknown,
templateInput: Record<string, unknown>,
) {
const pages = const pages =
schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson schemaJson && typeof schemaJson === "object" && "schemas" in schemaJson
? (schemaJson as { schemas?: unknown }).schemas ? (schemaJson as { schemas?: unknown }).schemas
: null; : null;
@@ -166,28 +200,37 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<
} }
for (const field of page) { for (const field of page) {
if (!field || typeof field !== 'object') { if (!field || typeof field !== "object") {
continue; continue;
} }
const tableField = field as { type?: unknown; content?: unknown }; const tableField = field as { type?: unknown; content?: unknown };
const fieldName = getFieldName(field); const fieldName = getFieldName(field);
const placeholderKey = const placeholderKey =
typeof tableField.content === 'string' typeof tableField.content === "string"
? extractSinglePlaceholder(tableField.content) ? extractSinglePlaceholder(tableField.content)
: null; : null;
if (fieldName && placeholderKey && fieldName !== placeholderKey) { if (fieldName && placeholderKey && fieldName !== placeholderKey) {
if (normalized[fieldName] !== undefined && normalized[placeholderKey] === undefined) { if (
normalized[fieldName] !== undefined &&
normalized[placeholderKey] === undefined
) {
normalized[placeholderKey] = normalized[fieldName]; normalized[placeholderKey] = normalized[fieldName];
} }
if (normalized[placeholderKey] !== undefined && normalized[fieldName] === undefined) { if (
normalized[placeholderKey] !== undefined &&
normalized[fieldName] === undefined
) {
normalized[fieldName] = normalized[placeholderKey]; normalized[fieldName] = normalized[placeholderKey];
} }
} }
if (tableField.type !== 'table' || typeof tableField.content !== 'string') { if (
tableField.type !== "table" ||
typeof tableField.content !== "string"
) {
continue; continue;
} }
@@ -197,12 +240,12 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<
const value = normalized[placeholderKey]; const value = normalized[placeholderKey];
if (typeof value !== 'string') { if (typeof value !== "string") {
continue; continue;
} }
const lines = value const lines = value
.split('\n') .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean); .filter(Boolean);
@@ -219,9 +262,12 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<
export async function buildQuotationDocumentData( export async function buildQuotationDocumentData(
quotationId: string, quotationId: string,
organizationId: string organizationId: string,
): Promise<QuotationDocumentData> { ): Promise<QuotationDocumentData> {
const quotation = await assertQuotationDocumentAccess(quotationId, organizationId); const quotation = await assertQuotationDocumentAccess(
quotationId,
organizationId,
);
const [ const [
company, company,
quotationDetail, quotationDetail,
@@ -237,7 +283,7 @@ export async function buildQuotationDocumentData(
customerRoleOptions, customerRoleOptions,
productTypeOptions, productTypeOptions,
topicTypeOptions, topicTypeOptions,
approvalRequests approvalRequests,
] = await Promise.all([ ] = await Promise.all([
db db
.select() .select()
@@ -248,24 +294,42 @@ export async function buildQuotationDocumentData(
listQuotationItems(quotationId, organizationId), listQuotationItems(quotationId, organizationId),
listQuotationCustomers(quotationId, organizationId), listQuotationCustomers(quotationId, organizationId),
listQuotationTopics(quotationId, organizationId), listQuotationTopics(quotationId, organizationId),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, { organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, {
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, { organizationId }), organizationId,
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, { organizationId }), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, { organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, {
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, { organizationId }), organizationId,
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, {
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }), organizationId,
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { 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, { listApprovalRequests(organizationId, {
entityType: 'quotation', entityType: "quotation",
entityId: quotationId, entityId: quotationId,
limit: 20 limit: 20,
}) }),
]); ]);
if (!company) { if (!company) {
throw new AuthError('Organization not found', 404); throw new AuthError("Organization not found", 404);
} }
const [customer, contact, relatedCustomers, signatures] = await Promise.all([ const [customer, contact, relatedCustomers, signatures] = await Promise.all([
@@ -276,8 +340,8 @@ export async function buildQuotationDocumentData(
and( and(
eq(crmCustomers.id, quotation.customerId), eq(crmCustomers.id, quotation.customerId),
eq(crmCustomers.organizationId, organizationId), eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt) isNull(crmCustomers.deletedAt),
) ),
) )
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null),
quotation.contactId quotation.contactId
@@ -288,47 +352,57 @@ export async function buildQuotationDocumentData(
and( and(
eq(crmCustomerContacts.id, quotation.contactId), eq(crmCustomerContacts.id, quotation.contactId),
eq(crmCustomerContacts.organizationId, organizationId), eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt) isNull(crmCustomerContacts.deletedAt),
) ),
) )
.then((rows) => rows[0] ?? null) .then((rows) => rows[0] ?? null)
: Promise.resolve(null), : Promise.resolve(null),
quotationCustomers.length quotationCustomers.length
? db ? db
.select({ id: crmCustomers.id, code: crmCustomers.code, name: crmCustomers.name }) .select({
id: crmCustomers.id,
code: crmCustomers.code,
name: crmCustomers.name,
})
.from(crmCustomers) .from(crmCustomers)
.where( .where(
and( and(
eq(crmCustomers.organizationId, organizationId), eq(crmCustomers.organizationId, organizationId),
inArray( inArray(
crmCustomers.id, crmCustomers.id,
quotationCustomers.map((item) => item.customerId) quotationCustomers.map((item) => item.customerId),
),
isNull(crmCustomers.deletedAt),
), ),
isNull(crmCustomers.deletedAt)
)
) )
: Promise.resolve([]), : Promise.resolve([]),
resolveQuotationSignatures(quotationId, organizationId) resolveQuotationSignatures(quotationId, organizationId),
]); ]);
if (!customer) { if (!customer) {
throw new AuthError('Customer not found', 404); throw new AuthError("Customer not found", 404);
} }
const approvalRequest = const approvalRequest =
approvalRequests.items.find((item) => item.status === 'approved') ?? approvalRequests.items.find((item) => item.status === "approved") ??
approvalRequests.items[0] ?? approvalRequests.items[0] ??
null; null;
const approvalDetail = approvalRequest const approvalDetail = approvalRequest
? await getApprovalRequest(approvalRequest.id, organizationId) ? await getApprovalRequest(approvalRequest.id, organizationId)
: null; : null;
const branch = branchOptions.find((option) => option.id === quotation.branchId) ?? null; const branch =
const relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item])); 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 statusCode = findOptionCode(statusOptions, quotation.status);
const statusLabel = findOptionLabel(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 = const primaryProductType =
items.find((item) => findOptionCode(productTypeOptions, item.productType))?.productType ?? null; items.find((item) => findOptionCode(productTypeOptions, item.productType))
?.productType ?? null;
const productTypeCode = const productTypeCode =
findOptionCode(productTypeOptions, primaryProductType) ?? findOptionCode(productTypeOptions, primaryProductType) ??
findOptionCode(quotationTypeOptions, quotationDetail.quotationType) ?? findOptionCode(quotationTypeOptions, quotationDetail.quotationType) ??
@@ -340,7 +414,7 @@ export async function buildQuotationDocumentData(
findOptionLabel(topicTypeOptions, item.topicType) ?? findOptionLabel(topicTypeOptions, item.topicType) ??
item.title?.trim() ?? item.title?.trim() ??
topicCodeById.get(item.topicType) ?? topicCodeById.get(item.topicType) ??
'Topic'; "Topic";
return { return {
id: item.id, id: item.id,
@@ -351,8 +425,8 @@ export async function buildQuotationDocumentData(
items: sortBySortOrder(item.items).map((child) => ({ items: sortBySortOrder(item.items).map((child) => ({
id: child.id, id: child.id,
content: child.content, content: child.content,
sortOrder: child.sortOrder sortOrder: child.sortOrder,
})) })),
}; };
}); });
const pdfTopics: PdfTopic[] = resolvedTopics.map((item) => ({ const pdfTopics: PdfTopic[] = resolvedTopics.map((item) => ({
@@ -361,24 +435,24 @@ export async function buildQuotationDocumentData(
sortOrder: item.sortOrder, sortOrder: item.sortOrder,
items: item.items.map((child) => ({ items: item.items.map((child) => ({
id: child.id, id: child.id,
content: child.content?.trim() || '-', content: child.content?.trim() || "-",
sortOrder: child.sortOrder sortOrder: child.sortOrder,
})) })),
})); }));
const scopeTopics = resolvedTopics.filter((item) => const scopeTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.scope) matchesTopicAlias(item.topicCode, topicTypeMapping.scope),
); );
const exclusionTopics = resolvedTopics.filter((item) => const exclusionTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion) matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion),
); );
const paymentTopics = resolvedTopics.filter((item) => const paymentTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.payment) matchesTopicAlias(item.topicCode, topicTypeMapping.payment),
); );
const warrantyTopics = resolvedTopics.filter((item) => const warrantyTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.warranty) matchesTopicAlias(item.topicCode, topicTypeMapping.warranty),
); );
const deliveryTopics = resolvedTopics.filter((item) => const deliveryTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.delivery) matchesTopicAlias(item.topicCode, topicTypeMapping.delivery),
); );
const otherTopics = resolvedTopics.filter((item) => { const otherTopics = resolvedTopics.filter((item) => {
return ![ return ![
@@ -386,7 +460,7 @@ export async function buildQuotationDocumentData(
topicTypeMapping.exclusion, topicTypeMapping.exclusion,
topicTypeMapping.payment, topicTypeMapping.payment,
topicTypeMapping.warranty, topicTypeMapping.warranty,
topicTypeMapping.delivery topicTypeMapping.delivery,
].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)); ].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode));
}); });
const exclusionTopic = exclusionTopics[0]; const exclusionTopic = exclusionTopics[0];
@@ -395,21 +469,23 @@ export async function buildQuotationDocumentData(
const deliveryTopic = deliveryTopics[0]; const deliveryTopic = deliveryTopics[0];
const approvalApprovers: QuotationDocumentApprovalStep[] = const approvalApprovers: QuotationDocumentApprovalStep[] =
approvalDetail?.timeline approvalDetail?.timeline
.filter((item) => ['approve', 'reject', 'return'].includes(item.action)) .filter((item) => ["approve", "reject", "return"].includes(item.action))
.map((item) => { .map((item) => {
const step = approvalDetail.steps.find( const step = approvalDetail.steps.find(
(candidate) => candidate.stepNumber === item.stepNumber (candidate) => candidate.stepNumber === item.stepNumber,
); );
return { return {
stepNumber: item.stepNumber, stepNumber: item.stepNumber,
roleCode: step?.roleCode ?? '', roleCode: step?.roleCode ?? "",
roleName: step?.roleName ?? `Step ${item.stepNumber}`, roleName: step?.roleName ?? `Step ${item.stepNumber}`,
action: item.action, action: item.action,
actorName: item.actorName, actorName: item.actorName,
actedAt: item.actedAt, actedAt: item.actedAt,
remark: item.remark remark: item.remark,
}; };
}) ?? []; }) ?? [];
const currencyCode =
findOptionCode(currencyOptions, quotationDetail.currency) ?? "THB";
return { return {
company: { company: {
@@ -417,14 +493,14 @@ export async function buildQuotationDocumentData(
name: company.name, name: company.name,
slug: company.slug, slug: company.slug,
imageUrl: company.imageUrl, imageUrl: company.imageUrl,
plan: company.plan plan: company.plan,
}, },
branch: branch branch: branch
? { ? {
id: branch.id, id: branch.id,
code: branch.code, code: branch.code,
label: branch.label, label: branch.label,
value: branch.value value: branch.value,
} }
: null, : null,
customer: { customer: {
@@ -434,7 +510,7 @@ export async function buildQuotationDocumentData(
address: customer.address, address: customer.address,
phone: customer.phone, phone: customer.phone,
email: customer.email, email: customer.email,
taxId: customer.taxId taxId: customer.taxId,
}, },
contact: contact contact: contact
? { ? {
@@ -442,7 +518,7 @@ export async function buildQuotationDocumentData(
name: contact.name, name: contact.name,
email: contact.email, email: contact.email,
mobile: contact.mobile, mobile: contact.mobile,
position: contact.position position: contact.position,
} }
: null, : null,
quotation: { quotation: {
@@ -458,20 +534,32 @@ export async function buildQuotationDocumentData(
revisionLabel: toRevisionLabel(quotationDetail.revision), revisionLabel: toRevisionLabel(quotationDetail.revision),
statusCode, statusCode,
statusLabel, statusLabel,
quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType), quotationTypeCode: findOptionCode(
quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType), quotationTypeOptions,
currency: findOptionCode(currencyOptions, quotationDetail.currency), quotationDetail.quotationType,
currencyCode: findOptionCode(currencyOptions, quotationDetail.currency), ),
quotationTypeLabel: findOptionLabel(
quotationTypeOptions,
quotationDetail.quotationType,
),
currency: currencyCode,
currencyCode,
currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency), currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency),
exchangeRate: quotationDetail.exchangeRate, exchangeRate: quotationDetail.exchangeRate,
subtotal: quotationDetail.subtotal, subtotal: quotationDetail.subtotal,
discount: quotationDetail.discount, discount: quotationDetail.discount,
discountTypeCode: findOptionCode(discountTypeOptions, quotationDetail.discountType), discountTypeCode: findOptionCode(
discountTypeLabel: findOptionLabel(discountTypeOptions, quotationDetail.discountType), discountTypeOptions,
quotationDetail.discountType,
),
discountTypeLabel: findOptionLabel(
discountTypeOptions,
quotationDetail.discountType,
),
taxRate: quotationDetail.taxRate, taxRate: quotationDetail.taxRate,
taxAmount: quotationDetail.taxAmount, taxAmount: quotationDetail.taxAmount,
totalAmount: quotationDetail.totalAmount, totalAmount: quotationDetail.totalAmount,
notes: quotationDetail.notes notes: quotationDetail.notes,
}, },
items: items.map((item) => ({ items: items.map((item) => ({
id: item.id, id: item.id,
@@ -487,101 +575,128 @@ export async function buildQuotationDocumentData(
discountTypeCode: findOptionCode(discountTypeOptions, item.discountType), discountTypeCode: findOptionCode(discountTypeOptions, item.discountType),
taxRate: item.taxRate, taxRate: item.taxRate,
totalPrice: item.totalPrice, totalPrice: item.totalPrice,
notes: item.notes notes: item.notes,
})), })),
quotationCustomers: quotationCustomers.map((item) => ({ quotationCustomers: quotationCustomers.map((item) => ({
id: item.id, id: item.id,
customerId: item.customerId, customerId: item.customerId,
customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName, customerName:
customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode, relatedCustomerMap.get(item.customerId)?.name ?? item.customerName,
customerCode:
relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode,
roleCode: item.roleCode, roleCode: item.roleCode,
roleLabel: item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role), roleLabel:
isPrimary: item.isPrimary item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role),
isPrimary: item.isPrimary,
})), })),
topics: { topics: {
scope: scopeTopics.map((item) => ({ scope: scopeTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? '-') items: item.items.map((child) => child.content ?? "-"),
})), })),
exclusion: exclusionTopics.map((item) => ({ exclusion: exclusionTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? '-') items: item.items.map((child) => child.content ?? "-"),
})), })),
payment: paymentTopics.map((item) => ({ payment: paymentTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? '-') items: item.items.map((child) => child.content ?? "-"),
})), })),
warranty: warrantyTopics.map((item) => ({ warranty: warrantyTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? '-') items: item.items.map((child) => child.content ?? "-"),
})), })),
delivery: deliveryTopics.map((item) => ({ delivery: deliveryTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? '-') items: item.items.map((child) => child.content ?? "-"),
})), })),
other: otherTopics.map((item) => ({ other: otherTopics.map((item) => ({
id: item.id, id: item.id,
title: item.title, title: item.title,
sortOrder: item.sortOrder, sortOrder: item.sortOrder,
items: item.items.map((child) => child.content ?? '-') items: item.items.map((child) => child.content ?? "-"),
})), })),
all: pdfTopics all: pdfTopics,
}, },
pdfme: { pdfme: {
quotation_date: formatPdfDate(quotationDetail.quotationDate, 'en'), labels: PDFME_DEFAULT_LABELS,
quotation_date: formatPdfDate(quotationDetail.quotationDate, "en"),
quotation_price: formatPdfCurrency( quotation_price: formatPdfCurrency(
quotationDetail.totalAmount, quotationDetail.subtotal,
findOptionCode(currencyOptions, quotationDetail.currency) ?? 'THB' currencyCode,
),
quotation_price_data: buildPdfPriceTableData(
quotationDetail.subtotal,
currencyCode,
), ),
exclusion_data: formatTopicItems(exclusionTopic), exclusion_data: formatTopicItems(exclusionTopic),
payment_data: formatTopicItems(paymentTopic), payment_data: formatTopicItems(paymentTopic),
warranty_data: formatTopicItems(warrantyTopic), warranty_data: formatTopicItems(warrantyTopic),
delivery_data: formatTopicItems(deliveryTopic), delivery_data: formatTopicItems(deliveryTopic),
topics_data: formatTopicItems(scopeTopics[0] ?? exclusionTopic ?? paymentTopic), topics_data: formatTopicItems(
topic_inputs: {} scopeTopics[0] ?? exclusionTopic ?? paymentTopic,
),
topic_inputs: {},
}, },
approval: { approval: {
requestId: approvalDetail?.request.id ?? null, requestId: approvalDetail?.request.id ?? null,
workflowName: approvalDetail?.workflow.name ?? null, workflowName: approvalDetail?.workflow.name ?? null,
status: approvalDetail?.request.status ?? null, status: approvalDetail?.request.status ?? null,
approvedAt: approvedAt:
approvalDetail?.request.status === 'approved' ? approvalDetail.request.completedAt : null, approvalDetail?.request.status === "approved"
? approvalDetail.request.completedAt
: null,
currentStep: approvalDetail?.request.currentStep ?? null, currentStep: approvalDetail?.request.currentStep ?? null,
currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null, currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null,
approvers: approvalApprovers approvers: approvalApprovers,
}, },
signatures, signatures,
watermarkStatus: statusCode && statusCode !== 'approved' ? (statusLabel ?? statusCode) : null watermarkStatus:
statusCode && statusCode !== "approved"
? (statusLabel ?? statusCode)
: null,
}; };
} }
export async function getQuotationDocumentPreviewData(quotationId: string, organizationId: string) { export async function getQuotationDocumentPreviewData(
const documentData = await buildQuotationDocumentData(quotationId, organizationId); quotationId: string,
organizationId: string,
) {
const documentData = await buildQuotationDocumentData(
quotationId,
organizationId,
);
const template = await resolveTemplateForDocument(organizationId, { const template = await resolveTemplateForDocument(organizationId, {
documentType: 'quotation', documentType: "quotation",
productType: 'default', productType: "default",
fileType: 'pdfme' fileType: "pdfme",
}); });
const mappings = await resolveTemplateMappings(template.version.id, organizationId); const mappings = await resolveTemplateMappings(
template.version.id,
organizationId,
);
if (!mappings.length) { if (!mappings.length) {
throw new AuthError( throw new AuthError(
'Document template mappings are not configured for this template version', "Document template mappings are not configured for this template version",
409 409,
); );
} }
const templateInput = normalizePdfmeTemplateInput( const templateInput = normalizePdfmeTemplateInput(
template.version.schemaJson, template.version.schemaJson,
mapDocumentDataToTemplateInput(documentData as unknown as Record<string, unknown>, mappings) mapDocumentDataToTemplateInput(
documentData as unknown as Record<string, unknown>,
mappings,
),
); );
const { template: renderTemplate, topicInputs } = buildPdfTopicTemplate( const { template: renderTemplate, topicInputs } = buildPdfTopicTemplate(
template.version.schemaJson as Template, template.version.schemaJson as Template,
documentData.topics.all documentData.topics.all,
); );
const mergedTemplateInput = { const mergedTemplateInput = {
...templateInput, ...templateInput,
...topicInputs ...topicInputs,
}; };
documentData.pdfme.topic_inputs = topicInputs; documentData.pdfme.topic_inputs = topicInputs;
@@ -592,20 +707,23 @@ export async function getQuotationDocumentPreviewData(quotationId: string, organ
...template, ...template,
version: { version: {
...template.version, ...template.version,
schemaJson: renderTemplate schemaJson: renderTemplate,
} },
}, },
mappings, mappings,
templateInput: mergedTemplateInput templateInput: mergedTemplateInput,
}; };
} }
export async function prepareApprovedQuotationSnapshot( export async function prepareApprovedQuotationSnapshot(
quotationId: string, quotationId: string,
organizationId: string, organizationId: string,
userId: string userId: string,
): Promise<ApprovedQuotationSnapshot> { ): Promise<ApprovedQuotationSnapshot> {
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId); const preview = await getQuotationDocumentPreviewData(
quotationId,
organizationId,
);
const generatedAt = new Date().toISOString(); const generatedAt = new Date().toISOString();
return { return {
@@ -615,6 +733,6 @@ export async function prepareApprovedQuotationSnapshot(
templateVersionId: preview.template.version.id, templateVersionId: preview.template.version.id,
templateInput: preview.templateInput, templateInput: preview.templateInput,
generatedAt, generatedAt,
generatedBy: userId generatedBy: userId,
}; };
} }

View File

@@ -115,8 +115,16 @@ export interface QuotationDocumentData {
all: PdfTopic[]; all: PdfTopic[];
}; };
pdfme: { pdfme: {
labels: {
tel: string;
email: string;
att: string;
project: string;
location: string;
};
quotation_date: string; quotation_date: string;
quotation_price: string; quotation_price: string;
quotation_price_data: string[][];
exclusion_data: string[][]; exclusion_data: string[][];
payment_data?: string[][]; payment_data?: string[][];
warranty_data?: string[][]; warranty_data?: string[][];

View File

@@ -704,6 +704,44 @@ function applyFormatMask(value: unknown, formatMask?: string | null) {
return value; 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( export function mapDocumentDataToTemplateInput(
documentData: Record<string, unknown>, documentData: Record<string, unknown>,
mappings: DocumentTemplateMappingWithColumns[] mappings: DocumentTemplateMappingWithColumns[]
@@ -715,9 +753,10 @@ export function mapDocumentDataToTemplateInput(
if (mapping.dataType === 'table') { if (mapping.dataType === 'table') {
const rows = Array.isArray(rawValue) ? rawValue : []; const rows = Array.isArray(rawValue) ? rawValue : [];
const defaultRows = parseTableDefaultValue(mapping.defaultValue);
if (rows.length === 0) { if (rows.length === 0) {
result[mapping.placeholderKey] = normalizePdfmeTable([]); result[mapping.placeholderKey] = normalizePdfmeTable(defaultRows ?? []);
continue; continue;
} }
@@ -760,17 +799,23 @@ export function mapDocumentDataToTemplateInput(
}) })
.filter(Boolean) .filter(Boolean)
.join('\n\n'); .join('\n\n');
result[mapping.placeholderKey] = normalized || mapping.defaultValue || ''; result[mapping.placeholderKey] = normalized || mapping.defaultValue || '-';
continue; continue;
} }
result[mapping.placeholderKey] = result[mapping.placeholderKey] =
typeof rawValue === 'string' ? rawValue : mapping.defaultValue || ''; typeof rawValue === 'string' && rawValue.trim().length > 0
? rawValue
: mapping.defaultValue || '-';
continue; continue;
} }
result[mapping.placeholderKey] = const formattedValue = applyFormatMask(rawValue, mapping.formatMask);
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '-'; result[mapping.placeholderKey] = resolveScalarMappingValue(
rawValue,
formattedValue,
mapping.defaultValue
);
} }
return result; return result;

View File

@@ -341,152 +341,6 @@
"required": false, "required": false,
"readOnly": true "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", "name": "quotation_price_data",
"type": "table", "type": "table",
@@ -499,17 +353,9 @@
"content": "[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]", "content": "[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]",
"showHead": false, "showHead": false,
"repeatHead": false, "repeatHead": false,
"head": [ "head": ["Name", "City", "Head 3", "Head 4"],
"Name",
"City",
"Head 3",
"Head 4"
],
"headWidthPercentages": [ "headWidthPercentages": [
28.125, 28.125, 58.34767550533545, 2.4513428524798244, 11.075981642184729
58.34767550533545,
2.4513428524798244,
11.075981642184729
], ],
"tableStyles": { "tableStyles": {
"borderWidth": 0, "borderWidth": 0,
@@ -951,7 +797,7 @@
{ {
"name": "colon_site", "name": "colon_site",
"type": "text", "type": "text",
"content": ":", "content": "",
"position": { "position": {
"x": 25, "x": 25,
"y": 140.03 "y": 140.03
@@ -1124,12 +970,8 @@
"content": "[[\"Topic:\"]]", "content": "[[\"Topic:\"]]",
"showHead": false, "showHead": false,
"repeatHead": false, "repeatHead": false,
"head": [ "head": ["Terms of payment upon progress of each item:"],
"Terms of payment upon progress of each item:" "headWidthPercentages": [100],
],
"headWidthPercentages": [
100
],
"tableStyles": { "tableStyles": {
"borderWidth": 0, "borderWidth": 0,
"borderColor": "#000000" "borderColor": "#000000"
@@ -1197,12 +1039,8 @@
"content": "[[\"{item_topic}\"]]", "content": "[[\"{item_topic}\"]]",
"showHead": false, "showHead": false,
"repeatHead": false, "repeatHead": false,
"head": [ "head": ["Head 2"],
"Head 2" "headWidthPercentages": [100],
],
"headWidthPercentages": [
100
],
"tableStyles": { "tableStyles": {
"borderWidth": 0, "borderWidth": 0,
"borderColor": "#000000" "borderColor": "#000000"
@@ -1479,12 +1317,7 @@
"basePdf": { "basePdf": {
"width": 210, "width": 210,
"height": 297, "height": 297,
"padding": [ "padding": [31, 10, 25, 10],
31,
10,
25,
10
],
"staticSchema": [ "staticSchema": [
{ {
"name": "field1", "name": "field1",

View File

@@ -314,144 +314,6 @@
"required": false, "required": false,
"readOnly": true "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", "name": "quotation_price_data",
"type": "table", "type": "table",