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",
"overallStatus": "PASS",
"quotationCode": "QT-H5-AUDIT",
"previewHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844",
"generationHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844",
"snapshotHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844",
"previewHash": "1914aaab07b562d967c63d9f3bdea6d44b583e4aac39ea590d08e49975391675",
"generationHash": "1914aaab07b562d967c63d9f3bdea6d44b583e4aac39ea590d08e49975391675",
"snapshotHash": "1914aaab07b562d967c63d9f3bdea6d44b583e4aac39ea590d08e49975391675",
"mismatches": [],
"comparableFieldCount": 31,
"comparableFieldCount": 35,
"topicInputKeyCount": 10,
"signatureParity": true
"signatureParity": true,
"priceTableParity": true,
"priceTableRowCount": 1,
"priceTableCurrency": "THB"
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,21 +1,27 @@
# PDF Integrity Summary
- Generated At: 2026-06-19T07:45:01.927Z
- Overall Status: WARNING
- Generated At: 2026-06-19T10:12:54.977Z
- Overall Status: PASS
- Quotation Code: QT-H5-AUDIT
## Statuses
- Template Version Integrity: PASS
- Placeholder Integrity: PASS
- Runtime Payload: PASS
- Payload Parity: PASS
- Topic Runtime: PASS
- Approved Snapshot Parity: WARNING
- Approved Snapshot Parity: PASS
- Static Labels: PASS
- Price Table Present: Yes
- Price Table Status: PASS
- Price Table Parity: Yes
## Artifact Files
- template-version-report.json
- placeholder-integrity-report.json
- runtime-payload-report.json
- payload-parity-report.json
- topic-runtime-report.json
- signature-audit-report.json

View File

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

View File

@@ -1,6 +1,6 @@
# PDF Mapping Registry
This document is the source of truth for current quotation PDF field ownership.
This document is the source of truth for active quotation PDF field ownership.
| PDF Field | Source Path | Transform | Example Value |
| --- | --- | --- | --- |
@@ -11,12 +11,18 @@ This document is the source of truth for current quotation PDF field ownership.
| `customer_att` | `quotation.attention` | direct | `Task H.1 Contact` |
| `project_name` | `quotation.projectName` | direct | `Task H.5 Audit Fixture` |
| `site_location` | `quotation.projectLocation` | direct multiline | `Bangkok HQ` |
| `quotation_date` | `pdfme.quotation_date` | `formatPdfDate()` | `16 Jun 2026` |
| `quotation_code` | `quotation.code` | direct | `QT-H5-AUDIT` |
| `quotation_price` | `pdfme.quotation_price` | `formatPdfCurrency()` | `THB 128,400.00` |
| `currency` | `quotation.currency` | direct | `THB` |
| `exclusion_data` | `pdfme.exclusion_data` | `formatTopicItems()` | `[["Civil work by customer"]]` |
| `items_table` | `items` | `normalizePdfmeTable()` with table columns | item rows |
| `tel_label` | `pdfme.labels.tel` | static label | `Tel` |
| `email_label` | `pdfme.labels.email` | static label | `Email` |
| `att_label` | `pdfme.labels.att` | static label | `Att` |
| `project_label` | `pdfme.labels.project` | static label | `Project` |
| `site_label` | `pdfme.labels.location` | static label | `Location` |
| `quotation_date` | `pdfme.quotation_date` | `formatPdfDate()` canonical alias | `16 Jun 2026` |
| `quotation_date_data` | `pdfme.quotation_date` | active-template field-name mapping | `16 Jun 2026` |
| `quotation_code` | `quotation.code` | direct canonical alias | `QT-H5-AUDIT` |
| `quotation_code_data` | `quotation.code` | active-template field-name mapping | `QT-H5-AUDIT` |
| `quotation_price_data` | `pdfme.quotation_price_data` | `buildPdfPriceTableData()` | `[["Price (Exclude VAT)", "฿125,000.00", " ", "THB"]]` |
| `quotation_price` | `pdfme.quotation_price` | compatibility scalar | `THB 125,000.00` |
| `currency` | `quotation.currency` | compatibility scalar | `THB` |
| `app1` | `signatures.preparedBy.name` | direct | `Task H.1 Admin` |
| `app1_position` | `signatures.preparedBy.position` | position resolver | `Sales Manager` |
| `app2` | `signatures.approvedBy.name` | direct | `Task H.1 Admin` |
@@ -28,7 +34,17 @@ This document is the source of truth for current quotation PDF field ownership.
| `topic_*` | `documentData.topics.all[*].topicType` | runtime generated | `Scope` |
| `item_topic_*` | `documentData.topics.all[*].items` | `formatTopicItems()` | topic rows |
## Version Remap Rules
- template JSON changes must create a new `crm_document_template_versions` row instead of patching the old version in place
- active mappings are rebuilt only for fields or placeholder tokens that still exist in the new version
- previous approved snapshots may keep referencing retained inactive versions
- deleted fields from the previous version, such as `exclusion_data` and `exclusion_label`, must not remain active mappings on the new version
## Alias Notes
- current PDFME schema still uses field containers named `quotation_date_data` and `quotation_code_data`
- runtime normalization now publishes both the canonical placeholder keys (`quotation_date`, `quotation_code`) and the container keys so preview/audit/render stay aligned during the freeze window
- current PDFME schema uses field containers named `quotation_date_data` and `quotation_code_data`
- runtime normalization publishes both canonical aliases (`quotation_date`, `quotation_code`) and the active field-name mappings so preview, audit, and render stay aligned
- `quotation_price_data` is the canonical price table contract
- `quotation_price` and `currency` remain compatibility scalars because the active table content still embeds those placeholder tokens
- static labels are runtime-driven through `documentData.pdfme.labels.*`, not designer-only hardcoded text

View File

@@ -11,11 +11,18 @@ This registry freezes the placeholder naming contract for quotation PDF template
| `customer_att` | mapped | DB mapping | quotation attention |
| `project_name` | mapped | DB mapping | project name |
| `site_location` | mapped | DB mapping | project location |
| `quotation_date` | mapped | DB mapping | formatted date |
| `quotation_code` | mapped | DB mapping | quotation document code |
| `quotation_price` | mapped | DB mapping | formatted total amount |
| `currency` | mapped | DB mapping | currency code/label |
| `exclusion_data` | mapped | DB mapping | exclusion rows as `string[][]` |
| `tel_label` | mapped | DB mapping | static label from `pdfme.labels.tel` |
| `email_label` | mapped | DB mapping | static label from `pdfme.labels.email` |
| `att_label` | mapped | DB mapping | static label from `pdfme.labels.att` |
| `project_label` | mapped | DB mapping | static label from `pdfme.labels.project` |
| `site_label` | mapped | DB mapping | static label from `pdfme.labels.location` |
| `quotation_date` | mapped | token alias | canonical formatted date token |
| `quotation_date_data` | mapped | DB mapping | active field-name mapping for date |
| `quotation_code` | mapped | token alias | canonical quotation code token |
| `quotation_code_data` | mapped | DB mapping | active field-name mapping for code |
| `quotation_price_data` | mapped | DB mapping | canonical price table `string[][]` |
| `quotation_price` | mapped | DB mapping | compatibility scalar token inside price table |
| `currency` | mapped | DB mapping | compatibility scalar token inside price table |
| `app1` | mapped | DB mapping | prepared by name |
| `app1_position` | mapped | DB mapping | prepared by position |
| `app2` | mapped | DB mapping | approved by name |
@@ -24,13 +31,17 @@ This registry freezes the placeholder naming contract for quotation PDF template
| `app3_position` | mapped | DB mapping | authorized by position |
| `topic` | reserved | dynamic topic engine | base runtime topic label template |
| `data_topic` | reserved | dynamic topic engine | base runtime topic rows template |
| `item_topic` | legacy reserved token | dynamic topic engine | allowed in schema token inventory, not in DB mapping rows |
| `item_topic` | legacy reserved token | dynamic topic engine | allowed in schema token inventory, never stored as DB mapping row |
| `topic_*` | generated | dynamic topic engine | runtime-only generated keys |
| `item_topic_*` | generated | dynamic topic engine | runtime-only generated keys |
| `quotation_date_labe` | static visual | designer-owned | tolerated typo-like visual label |
| `quotation_code_lable` | static visual | designer-owned | tolerated typo-like visual label |
| `line` | static visual | designer-owned | readonly line artifact, no mapping |
## Naming Rules
- do not store `topic_*` or `item_topic_*` keys in `crm_document_template_mappings`
- keep `topic` and `data_topic` reserved for runtime schema cloning
- treat typo-like names such as `quotation_date_labe` and `quotation_code_lable` as static labels, not placeholders
- keep canonical business placeholders as `quotation_date` and `quotation_code`; container aliases like `quotation_date_data` and `quotation_code_data` are tolerated only as schema field names during the current freeze window
- treat typo-like names such as `quotation_date_labe` and `quotation_code_lable` as static visual fields, not business placeholders
- when the designer removes a field from the next template version, the remap script must not carry that mapping forward
- approved snapshots remain immutable to the template version they were generated from, even after that version becomes inactive

View File

@@ -1,16 +1,198 @@
{
"generatedAt": "2026-06-19T07:44:58.283Z",
"overallStatus": "WARNING",
"generatedAt": "2026-06-19T10:12:54.188Z",
"overallStatus": "PASS",
"quotationCode": "QT-H5-AUDIT",
"expectedFixtureCode": "QT-H5-AUDIT",
"template": {
"templateName": "ALLA Quotation Standard",
"version": "1.0",
"versionId": "4ac3e91c-8320-4f41-81cd-5068cc9b5768",
"fieldCount": 46,
"version": "1.1",
"versionId": "bd6713d5-a731-482f-a5bb-0dea9d766301",
"fieldCount": 44,
"previousVersion": "1.0",
"deletedFields": [
"exclusion_data",
"exclusion_label"
],
"newFields": [],
"duplicateFields": [],
"unknownFields": [
"line"
"unknownFields": [],
"classifications": [
{
"name": "company1",
"classification": "system field"
},
{
"name": "company2",
"classification": "system field"
},
{
"name": "company_addr1",
"classification": "system field"
},
{
"name": "company_addr2",
"classification": "system field"
},
{
"name": "company_tel",
"classification": "system field"
},
{
"name": "company_email",
"classification": "system field"
},
{
"name": "company_tax",
"classification": "system field"
},
{
"name": "line",
"classification": "readonly/static visual field"
},
{
"name": "customer_name",
"classification": "static input field"
},
{
"name": "customer_addr",
"classification": "static input field"
},
{
"name": "customer_tel",
"classification": "static input field"
},
{
"name": "customer_email",
"classification": "static input field"
},
{
"name": "dear_sirs",
"classification": "readonly/static visual field"
},
{
"name": "quotation_price_data",
"classification": "static input field"
},
{
"name": "quotation_date_labe",
"classification": "readonly/static visual field"
},
{
"name": "quotation_date_data",
"classification": "static input field"
},
{
"name": "quotation_code_lable",
"classification": "readonly/static visual field"
},
{
"name": "quotation_code_data",
"classification": "static input field"
},
{
"name": "customer_att",
"classification": "static input field"
},
{
"name": "project_name",
"classification": "static input field"
},
{
"name": "site_location",
"classification": "static input field"
},
{
"name": "colon",
"classification": "readonly/static visual field"
},
{
"name": "colon copy",
"classification": "readonly/static visual field"
},
{
"name": "colon_tel",
"classification": "readonly/static visual field"
},
{
"name": "colon_fax",
"classification": "readonly/static visual field"
},
{
"name": "colon_email",
"classification": "readonly/static visual field"
},
{
"name": "colon_att",
"classification": "readonly/static visual field"
},
{
"name": "colon_project",
"classification": "readonly/static visual field"
},
{
"name": "colon_site",
"classification": "readonly/static visual field"
},
{
"name": "tel_label",
"classification": "static input field"
},
{
"name": "email_label",
"classification": "static input field"
},
{
"name": "att_label",
"classification": "static input field"
},
{
"name": "project_label",
"classification": "static input field"
},
{
"name": "site_label",
"classification": "static input field"
},
{
"name": "topic",
"classification": "dynamic topic template"
},
{
"name": "data_topic",
"classification": "dynamic topic template"
},
{
"name": "Please_do_not",
"classification": "system field"
},
{
"name": "yours_faithfuly",
"classification": "system field"
},
{
"name": "app1",
"classification": "static input field"
},
{
"name": "app1_position",
"classification": "static input field"
},
{
"name": "app2",
"classification": "static input field"
},
{
"name": "app2_position",
"classification": "static input field"
},
{
"name": "app3",
"classification": "static input field"
},
{
"name": "app3_position",
"classification": "static input field"
}
]
},
"metrics": {
@@ -19,47 +201,27 @@
"partyCount": 3,
"dynamicTopicFieldCount": 5,
"dynamicTopicItemFieldCount": 5,
"mappingCount": 19
"mappingCount": 23
},
"mappingCoverage": {
"placeholderTokenCount": 13,
"placeholderTokenCount": 12,
"unmappedTokens": [],
"orphanMappings": [],
"itemTableColumns": [
{
"columnName": "Item",
"sourceField": "itemNumber"
"priceTableMapping": {
"sourcePath": "pdfme.quotation_price_data",
"dataType": "table"
},
{
"columnName": "Description",
"sourceField": "description"
},
{
"columnName": "Qty",
"sourceField": "quantity"
},
{
"columnName": "Unit",
"sourceField": "unitLabel"
},
{
"columnName": "Unit Price",
"sourceField": "unitPrice"
},
{
"columnName": "Total",
"sourceField": "totalPrice"
}
]
"itemTableColumns": []
},
"runtimePayload": {
"templateInputKeys": 31,
"topicInputKeys": 10
"templateInputKeys": 35,
"topicInputKeys": 10,
"priceTableRows": 1
},
"consistency": {
"status": "WARNING",
"approvedSnapshotAvailable": false,
"approvedArtifactReference": null,
"status": "PASS",
"approvedSnapshotAvailable": true,
"approvedArtifactReference": "artifact:a1fd951d-f0b4-4e5b-be88-073596b3829d",
"inconsistentKeys": []
},
"integritySummary": {

View File

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

View File

@@ -34,3 +34,9 @@
- approved snapshot parity remains a warning when the target quotation has never generated an approved PDF artifact
- the audit suite validates business payload parity, not pixel-perfect PDF screenshots
- manual review is still needed when template designers move static labels or brand blocks
## H.5.3 Follow-up
- template JSON revisions now flow through a dedicated version-remap step instead of patching `1.0` in place
- static labels such as `tel_label`, `email_label`, `att_label`, `project_label`, and `site_label` are now runtime-mapped rather than tolerated as blank designer placeholders
- deleted fields from the previous version, notably `exclusion_data` and `exclusion_label`, are explicitly classified and removed from the active mapping set

View File

@@ -38,3 +38,14 @@
- stored approved snapshot parity still depends on generating a real approved PDF artifact for `QT-H5-AUDIT`
- template field `line` is still treated as a tolerated designer-owned oddity rather than a normalized mapping field
- the audit stack validates payload integrity and runtime parity, not screenshot-level pixel parity
## Follow-on Freeze Note
- `quotation_price_data` is now treated as the canonical PDFME table field for quotation price rendering
- `quotation_price` and `currency` remain supported only as scalar compatibility fields
## H.5.3 Versioning Note
- integrity checks now accept retained inactive template versions as valid historical references for approved snapshots
- the active-version audit also verifies that the active DB schema matches the latest JSON source file for each organization template
- runtime payload integrity now includes explicit static-label checks so blank `tel_label`/`email_label`/`att_label`/`project_label`/`site_label` values fail the suite

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
- approval signature placeholders now resolve through the H.3 signature strategy and `crm_job_title` position lookup
- `quotation_price_data` is now the canonical runtime table input for the price row, while active template JSON still embeds nested `{quotation_price}` and `{currency}` cells inside that table field
Future:
- keep validating pagination and closing-block placement against production designer expectations for large topic sets
- add fixture-backed end-to-end PDF generation verification in CI once a stable runtime path is available
- remove nested price-table placeholders in a controlled template revision once design change windows allow it
- decide whether static template field `line` should remain a tolerated designer-only artifact or be normalized in future audits
### Template version remap operations
Current:
- template version remap is now handled by `npm run seed:pdf-template-version`, not by the base foundation seed
- active `1.1` mappings intentionally preserve historical `1.0` versions for snapshot integrity
Future:
- decide whether template version promotion needs an operator-facing review/approve workflow
- add artifacting or changelog snapshots for template-schema diffs before activation
- decide whether the remap command should write a persistent audit history table instead of JSON-only artifacts
## After Task I
### Storage provider production rollout

View File

@@ -17,6 +17,7 @@
"studio": "npx drizzle-kit studio",
"seed:super-admin": "node scripts/seed-super-admin.js",
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
"seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
"verify:task-h1": "node scripts/verify-task-h1.js",
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
@@ -29,7 +30,7 @@
"audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts",
"audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts",
"audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts",
"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:report && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:integrity",
"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity",
"setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
},
"dependencies": {

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

View File

@@ -51,9 +51,18 @@ async function main() {
const signatures = payload.documentData.signatures as Record<string, unknown>;
return key in signatures;
});
const priceTable = payload.templateInput.quotation_price_data;
const firstPriceRow = Array.isArray(priceTable) && Array.isArray(priceTable[0]) ? priceTable[0] : null;
const priceTableParity =
Array.isArray(priceTable) &&
Array.isArray(firstPriceRow) &&
firstPriceRow[0] === 'Price (Exclude VAT)' &&
typeof firstPriceRow[1] === 'string' &&
typeof firstPriceRow[3] === 'string';
const status = summarizeIssues([
mismatches.length ? 'FAIL' : 'PASS',
signatureParity ? 'PASS' : 'FAIL'
signatureParity ? 'PASS' : 'FAIL',
priceTableParity ? 'PASS' : 'FAIL'
]);
const report = {
audit: 'payload-parity',
@@ -65,7 +74,10 @@ async function main() {
mismatches,
comparableFieldCount: Object.keys(payload.templateInput).length,
topicInputKeyCount: Object.keys(payload.topicInputs).length,
signatureParity
signatureParity,
priceTableParity,
priceTableRowCount: Array.isArray(priceTable) ? priceTable.length : 0,
priceTableCurrency: Array.isArray(firstPriceRow) ? firstPriceRow[3] ?? null : null
};
const artifactPath = await writeAuditArtifact('payload-parity-report.json', report);

View File

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

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 = {
field: string;
@@ -126,22 +132,77 @@ async function main() {
walkValue(findings, 'templateInput', 'templateInput', payload.templateInput);
walkValue(findings, 'topicInputs', 'topicInputs', payload.topicInputs);
const status = summarizeIssues(findings.map((finding) => finding.status));
console.log(
JSON.stringify(
{
const priceTable = payload.templateInput.quotation_price_data;
const priceTableRows = Array.isArray(priceTable) ? priceTable : [];
const firstPriceRow = Array.isArray(priceTableRows[0]) ? (priceTableRows[0] as unknown[]) : [];
pushFinding(findings, {
field: 'templateInput.quotation_price_data',
source: 'templateInput',
status: Array.isArray(priceTable) ? 'PASS' : 'FAIL',
value: Array.isArray(priceTable) ? `rows:${priceTable.length}` : priceTable,
message: Array.isArray(priceTable)
? 'Price table input is present'
: 'Price table input is missing or not a table'
});
pushFinding(findings, {
field: 'templateInput.quotation_price_data[0][0]',
source: 'templateInput',
status: firstPriceRow[0] === 'Price (Exclude VAT)' ? 'PASS' : 'FAIL',
value: firstPriceRow[0] ?? null,
message: 'Price table label matches canonical text'
});
pushFinding(findings, {
field: 'templateInput.quotation_price_data[0][1]',
source: 'templateInput',
status:
typeof firstPriceRow[1] === 'string' && firstPriceRow[1].trim() && firstPriceRow[1] !== '-'
? 'PASS'
: 'FAIL',
value: firstPriceRow[1] ?? null,
message: 'Price table amount cell is formatted'
});
pushFinding(findings, {
field: 'templateInput.quotation_price_data[0][3]',
source: 'templateInput',
status:
typeof firstPriceRow[3] === 'string' && firstPriceRow[3].trim()
? 'PASS'
: 'FAIL',
value: firstPriceRow[3] ?? null,
message: 'Price table currency code cell is populated'
});
const staticLabelFindings = [...PDFME_STATIC_LABEL_FIELD_KEYS].map((fieldName) => {
const value = payload.templateInput[fieldName];
const valid = typeof value === 'string' && value.trim().length > 0 && value !== '-';
const finding: PayloadFinding = {
field: `templateInput.${fieldName}`,
source: 'templateInput',
status: valid ? 'PASS' : 'FAIL',
value: value ?? null,
message: valid ? 'Static label is populated' : 'Static label is empty'
};
pushFinding(findings, finding);
return finding;
});
const status = findings.some((finding) => finding.status === 'FAIL') ? 'FAIL' : 'PASS';
const report = {
audit: 'runtime-payload',
overallStatus: status,
staticLabelsStatus: summarizeIssues(
staticLabelFindings.map((finding) => finding.status)
),
quotationCode: payload.quotationCode,
templateName: payload.templateName,
templateVersion: payload.templateVersion,
findingCount: findings.length,
findings
},
null,
2
)
);
};
const artifactPath = await writeAuditArtifact('runtime-payload-report.json', report);
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
} finally {
await sql.end({ timeout: 5 });
}

View File

@@ -1,4 +1,6 @@
import {
classifyTemplateField,
computeFieldDiff,
createSqlClient,
getEffectiveMappedKeys,
extractTemplateFields,
@@ -18,11 +20,31 @@ async function main() {
for (const template of templates) {
const fields = extractTemplateFields(template.schemaJson);
const previousVersion =
(
await sql`
select schema_json as "schemaJson", version
from crm_document_template_versions
where template_id = ${template.templateId}
and deleted_at is null
and id <> ${template.versionId}
order by created_at desc
limit 1
`
)[0] ?? null;
const previousFields = previousVersion
? extractTemplateFields(previousVersion.schemaJson)
: [];
const { mappings } = await getMappingsForVersion(sql, template.versionId);
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
const mappedKeys = getEffectiveMappedKeys(fields, mappings);
const duplicateFields = getDuplicateFieldNames(fields);
const unknownFields = getUnknownFieldNames(fields, mappedKeys);
const fieldDiff = computeFieldDiff(fields, previousFields);
const requiredTableFields = ['quotation_price_data'];
const missingRequiredTableFields = requiredTableFields.filter(
(fieldName) => !fields.some((field) => field.name === fieldName && field.type === 'table')
);
const tableFields = fields.filter((field) => field.type === 'table').map((field) => field.name);
const dynamicTopicFields = fields
.filter((field) => field.name === 'topic' || field.name === 'data_topic')
@@ -39,8 +61,14 @@ async function main() {
mapping.placeholderKey !== 'items_table'
)
.map((mapping) => mapping.placeholderKey);
const classifications = fields.map((field) => ({
name: field.name,
type: field.type,
classification: classifyTemplateField(field, mappedKeys)
}));
const status = summarizeIssues([
duplicateFields.length ? 'FAIL' : 'PASS',
missingRequiredTableFields.length ? 'FAIL' : 'PASS',
missingMappings.length ? 'FAIL' : 'PASS',
orphanMappings.length ? 'WARNING' : 'PASS',
unknownFields.length ? 'WARNING' : 'PASS'
@@ -49,12 +77,17 @@ async function main() {
results.push({
templateName: template.templateName,
version: template.version,
previousVersion: previousVersion?.version ?? null,
organizationId: template.organizationId,
schemaFieldCount: fields.length,
tableFields,
missingRequiredTableFields,
dynamicTopicFields,
deletedFields: fieldDiff.deletedFields,
newFields: fieldDiff.newFields,
duplicateFields,
unknownFields,
classifications,
missingMappings,
orphanMappings,
mappedFieldCount: mappings.length,

View File

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

View File

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

View File

@@ -30,11 +30,13 @@ async function main() {
'placeholder-integrity-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 statuses = [
String(templateVersionReport?.overallStatus ?? 'WARNING'),
String(placeholderReport?.overallStatus ?? 'WARNING'),
String(payloadReport?.overallStatus ?? 'WARNING'),
String(runtimeReport?.overallStatus ?? 'WARNING'),
String(snapshotReport?.overallStatus ?? 'WARNING')
] as Array<'PASS' | 'WARNING' | 'FAIL'>;
const overallStatus = summarizeIssues(statuses);
@@ -64,6 +66,7 @@ async function main() {
artifacts: {
templateVersionReport: 'template-version-report.json',
placeholderIntegrityReport: 'placeholder-integrity-report.json',
runtimePayloadReport: 'runtime-payload-report.json',
payloadParityReport: 'payload-parity-report.json',
topicRuntimeReport: 'topic-runtime-report.json',
signatureAuditReport: 'signature-audit-report.json'
@@ -71,9 +74,23 @@ async function main() {
statuses: {
templateVersion: templateVersionReport?.overallStatus ?? 'WARNING',
placeholderIntegrity: placeholderReport?.overallStatus ?? 'WARNING',
runtimePayload: runtimeReport?.overallStatus ?? 'WARNING',
payloadParity: payloadReport?.overallStatus ?? 'WARNING',
topicRuntime: topicRuntimeReport.overallStatus,
approvedSnapshotParity: snapshotReport?.overallStatus ?? 'WARNING'
approvedSnapshotParity: snapshotReport?.overallStatus ?? 'WARNING',
staticLabels: runtimeReport?.staticLabelsStatus ?? 'WARNING'
},
priceTable: {
present: Boolean(payload.templateInput.quotation_price_data),
rowCount: Array.isArray(payload.templateInput.quotation_price_data)
? payload.templateInput.quotation_price_data.length
: 0,
parity: payloadReport?.priceTableParity ?? false,
status:
Array.isArray(payload.templateInput.quotation_price_data) &&
(payloadReport?.priceTableParity ?? false)
? 'PASS'
: 'FAIL'
}
};
const artifactPath = await writeAuditArtifact('summary.json', summary);
@@ -88,14 +105,20 @@ async function main() {
'',
`- Template Version Integrity: ${summary.statuses.templateVersion}`,
`- Placeholder Integrity: ${summary.statuses.placeholderIntegrity}`,
`- Runtime Payload: ${summary.statuses.runtimePayload}`,
`- Payload Parity: ${summary.statuses.payloadParity}`,
`- Topic Runtime: ${summary.statuses.topicRuntime}`,
`- Approved Snapshot Parity: ${summary.statuses.approvedSnapshotParity}`,
`- Static Labels: ${summary.statuses.staticLabels}`,
`- Price Table Present: ${summary.priceTable.present ? 'Yes' : 'No'}`,
`- Price Table Status: ${summary.priceTable.status}`,
`- Price Table Parity: ${summary.priceTable.parity ? 'Yes' : 'No'}`,
'',
'## Artifact Files',
'',
`- ${summary.artifacts.templateVersionReport}`,
`- ${summary.artifacts.placeholderIntegrityReport}`,
`- ${summary.artifacts.runtimePayloadReport}`,
`- ${summary.artifacts.payloadParityReport}`,
`- ${summary.artifacts.topicRuntimeReport}`,
`- ${summary.artifacts.signatureAuditReport}`

View File

@@ -57,6 +57,29 @@ export interface MappingColumnRecord {
formatMask: string | null;
}
export type TemplateFieldClassification =
| 'static input field'
| 'dynamic topic template'
| 'readonly/static visual field'
| 'system field'
| 'unknown field';
export interface TemplateMappingSeed {
placeholderKey: string;
sourcePath: string;
dataType: 'scalar' | 'multiline' | 'table' | 'image';
defaultValue?: string | null;
formatMask?: string | null;
sortOrder: number;
columns?: Array<{
columnName: string;
sourceField: string;
columnLetter?: string;
sortOrder: number;
formatMask?: string | null;
}>;
}
export interface AuditPayload {
organizationId: string;
quotationId: string;
@@ -83,6 +106,7 @@ export interface AuditPayload {
const SYSTEM_FIELD_NAMES = new Set([
'',
'company1',
'company',
'company2',
'company_addr1',
'company_addr2',
@@ -97,6 +121,220 @@ const DYNAMIC_TOPIC_FIELD_NAMES = new Set(['topic', 'data_topic']);
const LEGACY_DYNAMIC_SEED_TOKENS = new Set(['item_topic']);
const DYNAMIC_TOPIC_TOKEN_PREFIXES = ['topic_', 'item_topic_'];
const DESIGNER_LABEL_NAME_PATTERNS = [/_label$/i, /_lable$/i];
const STATIC_VISUAL_FIELD_NAMES = new Set([
'colon',
'colon copy',
'colon_att',
'colon_email',
'colon_fax',
'colon_project',
'colon_site',
'colon_tel',
'dear_sirs',
'line',
'yours_faithfuly',
'quotation_code_lable',
'quotation_date_labe'
]);
export const PRICE_TABLE_LABEL = 'Price (Exclude VAT)';
export const PDFME_STATIC_LABELS = {
tel: 'Tel',
email: 'Email',
att: 'Att',
project: 'Project',
location: 'Location'
} as const;
export const PDFME_STATIC_LABEL_FIELD_KEYS = [
'tel_label',
'email_label',
'att_label',
'project_label',
'site_label'
] as const;
export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
{
placeholderKey: 'customer_name',
sourcePath: 'customer.name',
dataType: 'scalar',
sortOrder: 1
},
{
placeholderKey: 'customer_addr',
sourcePath: 'customer.address',
dataType: 'multiline',
sortOrder: 2
},
{
placeholderKey: 'customer_tel',
sourcePath: 'customer.phone',
dataType: 'scalar',
sortOrder: 3
},
{
placeholderKey: 'customer_email',
sourcePath: 'customer.email',
dataType: 'scalar',
sortOrder: 4
},
{
placeholderKey: 'customer_att',
sourcePath: 'quotation.attention',
dataType: 'scalar',
sortOrder: 5
},
{
placeholderKey: 'project_name',
sourcePath: 'quotation.projectName',
dataType: 'scalar',
sortOrder: 6
},
{
placeholderKey: 'site_location',
sourcePath: 'quotation.projectLocation',
dataType: 'multiline',
sortOrder: 7
},
{
placeholderKey: 'quotation_date_data',
sourcePath: 'pdfme.quotation_date',
dataType: 'scalar',
sortOrder: 8
},
{
placeholderKey: 'quotation_code_data',
sourcePath: 'quotation.code',
dataType: 'scalar',
sortOrder: 9
},
{
placeholderKey: 'quotation_price_data',
sourcePath: 'pdfme.quotation_price_data',
dataType: 'table',
defaultValue: JSON.stringify([[PRICE_TABLE_LABEL, '-', ' ', 'THB']]),
sortOrder: 10
},
{
placeholderKey: 'quotation_price',
sourcePath: 'pdfme.quotation_price',
dataType: 'scalar',
sortOrder: 11
},
{
placeholderKey: 'currency',
sourcePath: 'quotation.currency',
dataType: 'scalar',
sortOrder: 12
},
{
placeholderKey: 'exclusion_data',
sourcePath: 'pdfme.exclusion_data',
dataType: 'table',
sortOrder: 13
},
{
placeholderKey: 'tel_label',
sourcePath: 'pdfme.labels.tel',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.tel,
sortOrder: 14
},
{
placeholderKey: 'email_label',
sourcePath: 'pdfme.labels.email',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.email,
sortOrder: 15
},
{
placeholderKey: 'att_label',
sourcePath: 'pdfme.labels.att',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.att,
sortOrder: 16
},
{
placeholderKey: 'project_label',
sourcePath: 'pdfme.labels.project',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.project,
sortOrder: 17
},
{
placeholderKey: 'site_label',
sourcePath: 'pdfme.labels.location',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.location,
sortOrder: 18
},
{
placeholderKey: 'app1',
sourcePath: 'signatures.preparedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 19
},
{
placeholderKey: 'app1_position',
sourcePath: 'signatures.preparedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 20
},
{
placeholderKey: 'app2',
sourcePath: 'signatures.approvedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 21
},
{
placeholderKey: 'app2_position',
sourcePath: 'signatures.approvedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 22
},
{
placeholderKey: 'app3',
sourcePath: 'signatures.authorizedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 23
},
{
placeholderKey: 'app3_position',
sourcePath: 'signatures.authorizedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 24
},
{
placeholderKey: 'items_table',
sourcePath: 'items',
dataType: 'table',
sortOrder: 25,
columns: [
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
{ columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 },
{ columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 },
{
columnName: 'Unit Price',
sourceField: 'unitPrice',
columnLetter: 'E',
sortOrder: 5,
formatMask: 'currency'
},
{
columnName: 'Total',
sourceField: 'totalPrice',
columnLetter: 'F',
sortOrder: 6,
formatMask: 'currency'
}
]
}
] as const;
const SIGNATURE_ROLE_FALLBACK_LABELS: Record<string, string> = {
sales: 'Sales Engineer',
sales_support: 'Sales Support',
@@ -324,13 +562,7 @@ export function getUnknownFieldNames(
return fields
.filter(
(field) =>
field.name &&
!SYSTEM_FIELD_NAMES.has(field.name) &&
!DYNAMIC_TOPIC_FIELD_NAMES.has(field.name) &&
!mappedKeys.has(field.name) &&
field.tokens.length === 0 &&
field.type !== 'table' &&
field.type !== 'text'
field.name && classifyTemplateField(field, mappedKeys) === 'unknown field'
)
.map((field) => field.name)
.sort();
@@ -344,6 +576,40 @@ export function isDesignerLabelField(field: TemplateFieldRecord) {
);
}
export function classifyTemplateField(
field: TemplateFieldRecord,
mappedKeys: Set<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(
fields: TemplateFieldRecord[],
mappings: Array<{ placeholderKey: string }>
@@ -398,6 +664,78 @@ export function normalizeTemplateInputAliases(
return normalized;
}
export function loadTemplateSourceByOrganizationName(organizationName: string) {
const normalized = organizationName.toUpperCase();
const fileName = normalized.includes('ONVALLA')
? 'ONVALLA_template_pdfme_fainal3.json'
: normalized.includes('ALLA')
? 'ALLA_template_pdfme_fainal3.json'
: null;
if (!fileName) {
return null;
}
const filePath = path.resolve(process.cwd(), 'src', 'pdfme_template', fileName);
return {
fileName,
relativePath: `src/pdfme_template/${fileName}`,
absolutePath: filePath,
schemaJson: JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown
};
}
export function computeFieldDiff(
currentFields: TemplateFieldRecord[],
previousFields: TemplateFieldRecord[]
) {
const currentFieldNames = new Set(currentFields.map((field) => field.name).filter(Boolean));
const previousFieldNames = new Set(previousFields.map((field) => field.name).filter(Boolean));
return {
deletedFields: [...previousFieldNames].filter((name) => !currentFieldNames.has(name)).sort(),
newFields: [...currentFieldNames].filter((name) => !previousFieldNames.has(name)).sort()
};
}
export function buildSupportedMappingsForFieldNames(
fieldNames: Set<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[]> {
const rows = await sql`
select
@@ -475,7 +813,9 @@ export async function findAuditQuotation(sql: SqlClient) {
q.attention,
q.reference,
q.currency,
q.subtotal,
q.total_amount as "totalAmount",
currency_option.code as "currencyCode",
q.approved_template_version_id as "approvedTemplateVersionId",
q.salesman_id as "salesmanId",
q.created_by as "createdBy",
@@ -483,6 +823,8 @@ export async function findAuditQuotation(sql: SqlClient) {
q.approved_snapshot as "approvedSnapshot",
q.approved_pdf_url as "approvedPdfUrl"
from crm_quotations q
left join ms_options currency_option
on currency_option.id = q.currency
where q.code = ${AUDIT_QUOTATION_CODE}
and q.deleted_at is null
limit 1
@@ -506,13 +848,17 @@ export async function findAuditQuotation(sql: SqlClient) {
q.attention,
q.reference,
q.currency,
q.subtotal,
q.total_amount as "totalAmount",
currency_option.code as "currencyCode",
q.salesman_id as "salesmanId",
q.created_by as "createdBy",
q.created_at as "createdAt",
q.approved_snapshot as "approvedSnapshot",
q.approved_pdf_url as "approvedPdfUrl"
from crm_quotations q
left join ms_options currency_option
on currency_option.id = q.currency
where q.deleted_at is null
order by q.updated_at desc
limit 1
@@ -584,6 +930,40 @@ function formatPdfCurrency(amount: number | string | null | undefined, currencyC
}).format(normalized)}`.trim();
}
function formatPdfCurrencyAmount(amount: number | string | null | undefined, currencyCode = 'THB') {
if (amount === null || amount === undefined) {
return '-';
}
const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
if (!Number.isFinite(normalized)) {
return '-';
}
const symbols: Record<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 }> = []) {
if (!items.length) {
return [['-']];
@@ -611,6 +991,30 @@ function applyFormatMask(value: unknown, formatMask?: string | null) {
return value;
}
function isBlankString(value: unknown) {
return typeof value === 'string' && value.trim().length === 0;
}
function resolveScalarMappingValue(
rawValue: unknown,
formattedValue: unknown,
defaultValue?: string | null
) {
if (formattedValue !== undefined && formattedValue !== null && !isBlankString(formattedValue)) {
return formattedValue;
}
if (rawValue !== undefined && rawValue !== null && !isBlankString(rawValue)) {
return rawValue;
}
if (defaultValue !== undefined && defaultValue !== null && defaultValue.trim().length > 0) {
return defaultValue;
}
return '-';
}
function normalizePdfmeTable(
value: unknown,
columns?: Array<{ sourceField: string; formatMask?: string | null }>
@@ -620,7 +1024,21 @@ function normalizePdfmeTable(
}
if (value.every((row) => Array.isArray(row))) {
return (value as unknown[][]).map((row) => row.map((cell) => String(cell ?? '-').trim() || '-'));
return (value as unknown[][]).map((row) =>
row.map((cell) => {
if (typeof cell === 'string') {
const trimmedValue = cell.trim();
if (trimmedValue) {
return trimmedValue;
}
return cell.length > 0 ? cell : '-';
}
return String(cell ?? '-').trim() || '-';
})
);
}
if (!columns?.length) {
@@ -655,7 +1073,18 @@ function mapDocumentDataToTemplateInput(
if (mapping.dataType === 'table') {
const mappingColumns = columnMap.get(mapping.id) ?? [];
result[mapping.placeholderKey] = normalizePdfmeTable(rawValue, mappingColumns);
let tableValue = rawValue;
if (!Array.isArray(tableValue) && mapping.defaultValue) {
try {
const parsedDefault = JSON.parse(mapping.defaultValue) as unknown;
tableValue = Array.isArray(parsedDefault) ? parsedDefault : tableValue;
} catch {
// ignore invalid legacy table defaults
}
}
result[mapping.placeholderKey] = normalizePdfmeTable(tableValue, mappingColumns);
continue;
}
@@ -669,12 +1098,18 @@ function mapDocumentDataToTemplateInput(
}
result[mapping.placeholderKey] =
typeof rawValue === 'string' ? rawValue : mapping.defaultValue ?? '';
typeof rawValue === 'string' && rawValue.trim().length > 0
? rawValue
: mapping.defaultValue ?? '-';
continue;
}
result[mapping.placeholderKey] =
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '-';
const formattedValue = applyFormatMask(rawValue, mapping.formatMask);
result[mapping.placeholderKey] = resolveScalarMappingValue(
rawValue,
formattedValue,
mapping.defaultValue
);
}
return result;
@@ -901,6 +1336,10 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null;
return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-';
};
const currencyCode =
typeof quotation.currencyCode === 'string' && quotation.currencyCode.trim()
? quotation.currencyCode.trim()
: 'THB';
const documentData: Record<string, unknown> = {
company: {
@@ -932,7 +1371,9 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
projectLocation: quotation.projectLocation,
attention: quotation.attention,
reference: quotation.reference,
currency: quotation.currency,
currency: currencyCode,
currencyCode,
subtotal: quotation.subtotal,
totalAmount: quotation.totalAmount
},
items: (itemRows as Array<Record<string, unknown>>).map((row) => ({
@@ -951,8 +1392,10 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
exclusion: exclusionTopic ? [exclusionTopic] : []
},
pdfme: {
labels: PDFME_STATIC_LABELS,
quotation_date: formatPdfDate(quotation.quotationDate as string),
quotation_price: formatPdfCurrency(quotation.totalAmount as number, 'THB'),
quotation_price: formatPdfCurrency(quotation.subtotal as number, currencyCode),
quotation_price_data: buildPdfPriceTableData(quotation.subtotal as number, currencyCode),
exclusion_data: formatTopicItems(exclusionTopic?.items ?? []),
topic_inputs: topicInputs
},
@@ -1014,10 +1457,7 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
itemCount: (itemRows as Array<unknown>).length,
partyCount: (partyRows as Array<unknown>).length
},
approvedSnapshot:
quotation.approvedSnapshot && typeof quotation.approvedSnapshot === 'object'
? (quotation.approvedSnapshot as Record<string, unknown>)
: null,
approvedSnapshot: normalizeSnapshotPayload(quotation.approvedSnapshot),
approvedArtifactReference:
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',
sortOrder: 9
},
{
placeholderKey: 'quotation_price_data',
sourcePath: 'pdfme.quotation_price_data',
dataType: 'table',
defaultValue: JSON.stringify([['Price (Exclude VAT)', '-', ' ', 'THB']]),
sortOrder: 10
},
{
placeholderKey: 'quotation_price',
sourcePath: 'pdfme.quotation_price',
dataType: 'scalar',
formatMask: null,
sortOrder: 10
sortOrder: 11
},
{
placeholderKey: 'currency',
sourcePath: 'quotation.currency',
dataType: 'scalar',
sortOrder: 11
sortOrder: 12
},
{
placeholderKey: 'exclusion_data',
sourcePath: 'pdfme.exclusion_data',
dataType: 'table',
sortOrder: 12
sortOrder: 13
},
{
placeholderKey: 'app1',
sourcePath: 'signatures.preparedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 13
sortOrder: 14
},
{
placeholderKey: 'app1_position',
sourcePath: 'signatures.preparedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 14
sortOrder: 15
},
{
placeholderKey: 'app2',
sourcePath: 'signatures.approvedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 15
sortOrder: 16
},
{
placeholderKey: 'app2_position',
sourcePath: 'signatures.approvedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 16
sortOrder: 17
},
{
placeholderKey: 'app3',
sourcePath: 'signatures.authorizedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 17
sortOrder: 18
},
{
placeholderKey: 'app3_position',
sourcePath: 'signatures.authorizedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 18
sortOrder: 19
},
{
placeholderKey: 'items_table',
sourcePath: 'items',
dataType: 'table',
sortOrder: 19,
sortOrder: 20,
columns: [
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
@@ -922,6 +929,17 @@ async function upsertDocumentTemplatesForOrganization(
)
and deleted_at is null
`;
await sql`
update crm_document_template_mappings
set
deleted_at = now(),
updated_at = now()
where organization_id = ${organization.id}
and template_version_id = ${resolvedVersionId}
and placeholder_key in (${'exclusion_data_'})
and deleted_at is null
`;
}
async function main() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -341,152 +341,6 @@
"required": false,
"readOnly": true
},
{
"name": "exclusion_label",
"type": "table",
"position": {
"x": 10,
"y": 195
},
"width": 190,
"height": 5.6448,
"content": "[[\"Exclusion from scope of Supply:\"]]",
"showHead": false,
"repeatHead": false,
"head": [
"Name"
],
"headWidthPercentages": [
100
],
"tableStyles": {
"borderWidth": 0,
"borderColor": "#000000"
},
"headStyles": {
"fontName": "cordia",
"fontSize": 13,
"characterSpacing": 0,
"alignment": "left",
"verticalAlignment": "middle",
"lineHeight": 1,
"fontColor": "#ffffff",
"borderColor": "",
"backgroundColor": "#2980ba",
"borderWidth": {
"top": 0,
"right": 0,
"bottom": 0,
"left": 0
},
"padding": {
"top": 5,
"right": 5,
"bottom": 5,
"left": 5
}
},
"bodyStyles": {
"fontName": "cordiaBold",
"fontSize": 16,
"characterSpacing": 0,
"alignment": "left",
"verticalAlignment": "middle",
"lineHeight": 1,
"fontColor": "#000000",
"borderColor": "#ffffff",
"backgroundColor": "#ffffff",
"alternateBackgroundColor": "#ffffff",
"borderWidth": {
"top": 0.1,
"right": 0.1,
"bottom": 0.1,
"left": 0.1
},
"padding": {
"top": 0,
"right": 0,
"bottom": 0,
"left": 0
}
},
"columnStyles": {},
"required": false,
"readOnly": false
},
{
"name": "exclusion_data",
"type": "table",
"position": {
"x": 15,
"y": 205
},
"width": 185,
"height": 7.6448,
"content": "[[\"{exclusion_data}\"]]",
"showHead": false,
"repeatHead": false,
"head": [
"Name"
],
"headWidthPercentages": [
100
],
"tableStyles": {
"borderWidth": 0,
"borderColor": "#000000"
},
"headStyles": {
"fontName": "cordia",
"fontSize": 13,
"characterSpacing": 0,
"alignment": "left",
"verticalAlignment": "middle",
"lineHeight": 1,
"fontColor": "#ffffff",
"borderColor": "",
"backgroundColor": "#2980ba",
"borderWidth": {
"top": 0,
"right": 0,
"bottom": 0,
"left": 0
},
"padding": {
"top": 5,
"right": 5,
"bottom": 5,
"left": 5
}
},
"bodyStyles": {
"fontName": "cordia",
"fontSize": 16,
"characterSpacing": 0,
"alignment": "left",
"verticalAlignment": "middle",
"lineHeight": 1,
"fontColor": "#000000",
"borderColor": "#ffffff",
"backgroundColor": "",
"alternateBackgroundColor": "#ffffff",
"borderWidth": {
"top": 0.1,
"right": 0.1,
"bottom": 0.1,
"left": 0.1
},
"padding": {
"top": 1,
"right": 0,
"bottom": 1,
"left": 0
}
},
"columnStyles": {},
"required": false,
"readOnly": false
},
{
"name": "quotation_price_data",
"type": "table",
@@ -499,17 +353,9 @@
"content": "[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]",
"showHead": false,
"repeatHead": false,
"head": [
"Name",
"City",
"Head 3",
"Head 4"
],
"head": ["Name", "City", "Head 3", "Head 4"],
"headWidthPercentages": [
28.125,
58.34767550533545,
2.4513428524798244,
11.075981642184729
28.125, 58.34767550533545, 2.4513428524798244, 11.075981642184729
],
"tableStyles": {
"borderWidth": 0,
@@ -951,7 +797,7 @@
{
"name": "colon_site",
"type": "text",
"content": ":",
"content": "",
"position": {
"x": 25,
"y": 140.03
@@ -1124,12 +970,8 @@
"content": "[[\"Topic:\"]]",
"showHead": false,
"repeatHead": false,
"head": [
"Terms of payment upon progress of each item:"
],
"headWidthPercentages": [
100
],
"head": ["Terms of payment upon progress of each item:"],
"headWidthPercentages": [100],
"tableStyles": {
"borderWidth": 0,
"borderColor": "#000000"
@@ -1197,12 +1039,8 @@
"content": "[[\"{item_topic}\"]]",
"showHead": false,
"repeatHead": false,
"head": [
"Head 2"
],
"headWidthPercentages": [
100
],
"head": ["Head 2"],
"headWidthPercentages": [100],
"tableStyles": {
"borderWidth": 0,
"borderColor": "#000000"
@@ -1479,12 +1317,7 @@
"basePdf": {
"width": 210,
"height": 297,
"padding": [
31,
10,
25,
10
],
"padding": [31, 10, 25, 10],
"staticSchema": [
{
"name": "field1",

View File

@@ -314,144 +314,6 @@
"required": false,
"readOnly": true
},
{
"name": "exclusion_label",
"type": "table",
"position": {
"x": 10,
"y": 195
},
"width": 190,
"height": 5.6448,
"content": "[[\"Exclusion from scope of Supply:\"]]",
"showHead": false,
"repeatHead": false,
"head": ["Name"],
"headWidthPercentages": [100],
"tableStyles": {
"borderWidth": 0,
"borderColor": "#000000"
},
"headStyles": {
"fontName": "cordia",
"fontSize": 13,
"characterSpacing": 0,
"alignment": "left",
"verticalAlignment": "middle",
"lineHeight": 1,
"fontColor": "#ffffff",
"borderColor": "",
"backgroundColor": "#2980ba",
"borderWidth": {
"top": 0,
"right": 0,
"bottom": 0,
"left": 0
},
"padding": {
"top": 5,
"right": 5,
"bottom": 5,
"left": 5
}
},
"bodyStyles": {
"fontName": "cordiaBold",
"fontSize": 16,
"characterSpacing": 0,
"alignment": "left",
"verticalAlignment": "middle",
"lineHeight": 1,
"fontColor": "#000000",
"borderColor": "#ffffff",
"backgroundColor": "#ffffff",
"alternateBackgroundColor": "#ffffff",
"borderWidth": {
"top": 0.1,
"right": 0.1,
"bottom": 0.1,
"left": 0.1
},
"padding": {
"top": 0,
"right": 0,
"bottom": 0,
"left": 0
}
},
"columnStyles": {},
"required": false,
"readOnly": false
},
{
"name": "exclusion_data",
"type": "table",
"position": {
"x": 15,
"y": 205
},
"width": 185,
"height": 7.6448,
"content": "[[\"{exclusion_data}\"]]",
"showHead": false,
"repeatHead": false,
"head": ["Name"],
"headWidthPercentages": [100],
"tableStyles": {
"borderWidth": 0,
"borderColor": "#000000"
},
"headStyles": {
"fontName": "cordia",
"fontSize": 13,
"characterSpacing": 0,
"alignment": "left",
"verticalAlignment": "middle",
"lineHeight": 1,
"fontColor": "#ffffff",
"borderColor": "",
"backgroundColor": "#2980ba",
"borderWidth": {
"top": 0,
"right": 0,
"bottom": 0,
"left": 0
},
"padding": {
"top": 5,
"right": 5,
"bottom": 5,
"left": 5
}
},
"bodyStyles": {
"fontName": "cordia",
"fontSize": 16,
"characterSpacing": 0,
"alignment": "left",
"verticalAlignment": "middle",
"lineHeight": 1,
"fontColor": "#000000",
"borderColor": "#ffffff",
"backgroundColor": "",
"alternateBackgroundColor": "#ffffff",
"borderWidth": {
"top": 0.1,
"right": 0.1,
"bottom": 0.1,
"left": 0.1
},
"padding": {
"top": 1,
"right": 0,
"bottom": 1,
"left": 0
}
},
"columnStyles": {},
"required": false,
"readOnly": false
},
{
"name": "quotation_price_data",
"type": "table",
@@ -908,7 +770,7 @@
{
"name": "colon_site",
"type": "text",
"content": ": ",
"content": ":",
"position": {
"x": 25,
"y": 140