diff --git a/artifacts/pdf-audit/payload-parity-report.json b/artifacts/pdf-audit/payload-parity-report.json new file mode 100644 index 0000000..8707c91 --- /dev/null +++ b/artifacts/pdf-audit/payload-parity-report.json @@ -0,0 +1,12 @@ +{ + "audit": "payload-parity", + "overallStatus": "PASS", + "quotationCode": "QT-H5-AUDIT", + "previewHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844", + "generationHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844", + "snapshotHash": "3ce9c3ff95ca5b360bf4430089450322b6dc2b52c0ae44de6cb17aeaf9e05844", + "mismatches": [], + "comparableFieldCount": 31, + "topicInputKeyCount": 10, + "signatureParity": true +} diff --git a/artifacts/pdf-audit/placeholder-integrity-report.json b/artifacts/pdf-audit/placeholder-integrity-report.json new file mode 100644 index 0000000..c22814c --- /dev/null +++ b/artifacts/pdf-audit/placeholder-integrity-report.json @@ -0,0 +1,30 @@ +{ + "audit": "placeholder-integrity", + "overallStatus": "PASS", + "results": [ + { + "templateName": "ALLA Quotation Standard", + "organizationId": "2330c067-2737-4443-bd6e-ccb8a6cf856b", + "version": "1.0", + "duplicateFieldNames": [], + "typoFields": [], + "unmappedPlaceholders": [], + "deadMappings": [], + "invalidReservedMappings": [], + "missingReservedFields": [], + "status": "PASS" + }, + { + "templateName": "ONVALLA Quotation Standard", + "organizationId": "8cd23975-e199-4ed0-85a9-8cbd01c810d2", + "version": "1.0", + "duplicateFieldNames": [], + "typoFields": [], + "unmappedPlaceholders": [], + "deadMappings": [], + "invalidReservedMappings": [], + "missingReservedFields": [], + "status": "PASS" + } + ] +} diff --git a/artifacts/pdf-audit/signature-audit-report.json b/artifacts/pdf-audit/signature-audit-report.json new file mode 100644 index 0000000..27a1eba --- /dev/null +++ b/artifacts/pdf-audit/signature-audit-report.json @@ -0,0 +1,12 @@ +{ + "audit": "approved-snapshot-parity", + "overallStatus": "WARNING", + "quotationCode": "QT-H5-AUDIT", + "approvedSnapshotAvailable": false, + "approvedTemplateVersionId": null, + "computedTemplateVersionId": "4ac3e91c-8320-4f41-81cd-5068cc9b5768", + "inconsistentTemplateKeys": [], + "computedSignatureHash": "39e2c42458c8b08b4299dc3136c9812c311ae7886df91078f4c9ae6e87bdcc00", + "storedSignatureHash": null, + "approvedArtifactReference": null +} diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json new file mode 100644 index 0000000..f87a869 --- /dev/null +++ b/artifacts/pdf-audit/summary.json @@ -0,0 +1,19 @@ +{ + "generatedAt": "2026-06-19T07:45:01.927Z", + "overallStatus": "WARNING", + "quotationCode": "QT-H5-AUDIT", + "artifacts": { + "templateVersionReport": "template-version-report.json", + "placeholderIntegrityReport": "placeholder-integrity-report.json", + "payloadParityReport": "payload-parity-report.json", + "topicRuntimeReport": "topic-runtime-report.json", + "signatureAuditReport": "signature-audit-report.json" + }, + "statuses": { + "templateVersion": "PASS", + "placeholderIntegrity": "PASS", + "payloadParity": "PASS", + "topicRuntime": "PASS", + "approvedSnapshotParity": "WARNING" + } +} diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md new file mode 100644 index 0000000..b3c4e80 --- /dev/null +++ b/artifacts/pdf-audit/summary.md @@ -0,0 +1,21 @@ +# PDF Integrity Summary + +- Generated At: 2026-06-19T07:45:01.927Z +- Overall Status: WARNING +- Quotation Code: QT-H5-AUDIT + +## Statuses + +- Template Version Integrity: PASS +- Placeholder Integrity: PASS +- Payload Parity: PASS +- Topic Runtime: PASS +- Approved Snapshot Parity: WARNING + +## Artifact Files + +- template-version-report.json +- placeholder-integrity-report.json +- payload-parity-report.json +- topic-runtime-report.json +- signature-audit-report.json diff --git a/artifacts/pdf-audit/template-version-report.json b/artifacts/pdf-audit/template-version-report.json new file mode 100644 index 0000000..dcbb3e0 --- /dev/null +++ b/artifacts/pdf-audit/template-version-report.json @@ -0,0 +1,8 @@ +{ + "audit": "template-version-integrity", + "overallStatus": "PASS", + "activeTemplateCount": 2, + "quotationCode": "QT-H5-AUDIT", + "approvedTemplateVersionId": null, + "issues": [] +} diff --git a/artifacts/pdf-audit/topic-runtime-report.json b/artifacts/pdf-audit/topic-runtime-report.json new file mode 100644 index 0000000..2ea3357 --- /dev/null +++ b/artifacts/pdf-audit/topic-runtime-report.json @@ -0,0 +1,22 @@ +{ + "audit": "topic-runtime", + "overallStatus": "PASS", + "quotationCode": "QT-H5-AUDIT", + "topicCount": 5, + "topicInputKeys": [ + "topic_1_0", + "topic_1_1", + "topic_1_2", + "topic_1_3", + "topic_1_4" + ], + "itemTopicInputKeys": [ + "item_topic_1_0", + "item_topic_1_1", + "item_topic_1_2", + "item_topic_1_3", + "item_topic_1_4" + ], + "duplicateKeys": [], + "emptySections": [] +} diff --git a/docs/adr/0013-pdf-visual-parity-strategy.md b/docs/adr/0013-pdf-visual-parity-strategy.md new file mode 100644 index 0000000..0fae5a8 --- /dev/null +++ b/docs/adr/0013-pdf-visual-parity-strategy.md @@ -0,0 +1,49 @@ +# ADR 0013: PDF Visual Parity Strategy + +## Status + +Accepted + +## Context + +The quotation PDF flow now includes: + +- production PDFME templates per organization +- DB-backed mapping registry +- dynamic topic expansion +- approval signature resolution +- approved snapshot persistence + +What was missing was a repeatable audit strategy proving that template schema, mappings, runtime payload, and approved snapshot content remain synchronized over time. + +## Decision + +- freeze a documented PDF mapping registry in `docs/business/pdf-mapping-registry.md` +- keep a human-readable parity checklist in `docs/implementation/pdf-parity-checklist.md` +- add an audit suite: + - `audit-pdf-template-inventory` + - `audit-pdf-mapping-coverage` + - `audit-pdf-runtime-payload` + - `generate-pdf-audit-report` +- expose the suite through `npm run audit:pdf` +- prefer the stable fixture quotation code `QT-H5-AUDIT` for repeatable audits when available +- compare preview runtime content against approved snapshot content for business-field consistency + +## Visual Parity Rules + +- allowed differences: + - watermark state + - generated timestamp + - artifact metadata +- disallowed differences: + - customer identity + - quotation commercial values + - topic content + - signature identity + - mapped table content + +## Consequences + +- regressions in template coverage become detectable without opening every PDF manually +- mapping ownership is explicit and reviewable +- future PDF changes must update both runtime logic and the registry/checklist when business content changes diff --git a/docs/business/pdf-mapping-registry.md b/docs/business/pdf-mapping-registry.md new file mode 100644 index 0000000..edba39d --- /dev/null +++ b/docs/business/pdf-mapping-registry.md @@ -0,0 +1,34 @@ +# PDF Mapping Registry + +This document is the source of truth for current quotation PDF field ownership. + +| PDF Field | Source Path | Transform | Example Value | +| --- | --- | --- | --- | +| `customer_name` | `customer.name` | direct | `ALLA Demo Customer` | +| `customer_addr` | `customer.address` | direct multiline | `99/9 Rama IX Road, Bangkok 10310` | +| `customer_tel` | `customer.phone` | direct | `02-000-1000` | +| `customer_email` | `customer.email` | direct | `demo-customer@local.test` | +| `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 | +| `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` | +| `app2_position` | `signatures.approvedBy.position` | position resolver | `Sales Manager` | +| `app3` | `signatures.authorizedBy.name` | direct | `Task H.1 Admin` | +| `app3_position` | `signatures.authorizedBy.position` | position resolver | `Sales Manager` | +| `topic` | dynamic topic engine | runtime schema clone | topic label table | +| `data_topic` | dynamic topic engine | runtime schema clone | topic item table | +| `topic_*` | `documentData.topics.all[*].topicType` | runtime generated | `Scope` | +| `item_topic_*` | `documentData.topics.all[*].items` | `formatTopicItems()` | topic rows | + +## 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 diff --git a/docs/business/pdf-placeholder-registry.md b/docs/business/pdf-placeholder-registry.md new file mode 100644 index 0000000..3fb3b67 --- /dev/null +++ b/docs/business/pdf-placeholder-registry.md @@ -0,0 +1,36 @@ +# PDF Placeholder Registry + +This registry freezes the placeholder naming contract for quotation PDF templates. + +| Placeholder | Type | Ownership | Notes | +| --- | --- | --- | --- | +| `customer_name` | mapped | DB mapping | customer display name | +| `customer_addr` | mapped | DB mapping | multiline address | +| `customer_tel` | mapped | DB mapping | customer phone | +| `customer_email` | mapped | DB mapping | customer email | +| `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[][]` | +| `app1` | mapped | DB mapping | prepared by name | +| `app1_position` | mapped | DB mapping | prepared by position | +| `app2` | mapped | DB mapping | approved by name | +| `app2_position` | mapped | DB mapping | approved by position | +| `app3` | mapped | DB mapping | authorized by name | +| `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 | +| `topic_*` | generated | dynamic topic engine | runtime-only generated keys | +| `item_topic_*` | generated | dynamic topic engine | runtime-only generated keys | + +## 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 diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json new file mode 100644 index 0000000..2ba58b3 --- /dev/null +++ b/docs/implementation/pdf-audit-report.json @@ -0,0 +1,69 @@ +{ + "generatedAt": "2026-06-19T07:44:58.283Z", + "overallStatus": "WARNING", + "quotationCode": "QT-H5-AUDIT", + "expectedFixtureCode": "QT-H5-AUDIT", + "template": { + "templateName": "ALLA Quotation Standard", + "version": "1.0", + "versionId": "4ac3e91c-8320-4f41-81cd-5068cc9b5768", + "fieldCount": 46, + "duplicateFields": [], + "unknownFields": [ + "line" + ] + }, + "metrics": { + "topicCount": 5, + "itemCount": 3, + "partyCount": 3, + "dynamicTopicFieldCount": 5, + "dynamicTopicItemFieldCount": 5, + "mappingCount": 19 + }, + "mappingCoverage": { + "placeholderTokenCount": 13, + "unmappedTokens": [], + "orphanMappings": [], + "itemTableColumns": [ + { + "columnName": "Item", + "sourceField": "itemNumber" + }, + { + "columnName": "Description", + "sourceField": "description" + }, + { + "columnName": "Qty", + "sourceField": "quantity" + }, + { + "columnName": "Unit", + "sourceField": "unitLabel" + }, + { + "columnName": "Unit Price", + "sourceField": "unitPrice" + }, + { + "columnName": "Total", + "sourceField": "totalPrice" + } + ] + }, + "runtimePayload": { + "templateInputKeys": 31, + "topicInputKeys": 10 + }, + "consistency": { + "status": "WARNING", + "approvedSnapshotAvailable": false, + "approvedArtifactReference": null, + "inconsistentKeys": [] + }, + "integritySummary": { + "available": true, + "artifactDir": "C:\\Users\\mtpphtaps\\Documents\\gitea\\alla-allaos-fullstack\\artifacts\\pdf-audit" + } +} diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md new file mode 100644 index 0000000..9725825 --- /dev/null +++ b/docs/implementation/pdf-audit-report.md @@ -0,0 +1,30 @@ +# PDF Audit Report + +- Generated At: 2026-06-19T07:44:58.283Z +- Overall Status: WARNING +- Quotation Code: QT-H5-AUDIT +- Template: ALLA Quotation Standard v1.0 + +## Metrics + +- Topic Count: 5 +- Item Count: 3 +- Project Party Count: 3 +- Mapping Count: 19 + +## Mapping Coverage + +- Placeholder Tokens: 13 +- Unmapped Tokens: None +- Orphan Mappings: None + +## Visual / Consistency + +- Approved Snapshot Available: No +- Consistency Status: WARNING +- Inconsistent Keys: None + +## Integrity Layer + +- Integrity Summary Available: Yes +- Artifact Directory: C:\Users\mtpphtaps\Documents\gitea\alla-allaos-fullstack\artifacts\pdf-audit diff --git a/docs/implementation/pdf-parity-checklist.md b/docs/implementation/pdf-parity-checklist.md new file mode 100644 index 0000000..4e5fcf0 --- /dev/null +++ b/docs/implementation/pdf-parity-checklist.md @@ -0,0 +1,37 @@ +# PDF Parity Checklist + +## Header + +| Area | Expectation | Status | +| --- | --- | --- | +| Company block | Static company branding and contact text render from template | Partial | +| Customer block | `customer_name`, `customer_addr`, `customer_tel`, `customer_email`, `customer_att` render from mapping registry | Match | +| Document references | `quotation_code`, `quotation_date`, `project_name`, `site_location` render from live quotation data | Match | + +## Commercial Body + +| Area | Expectation | Status | +| --- | --- | --- | +| Items | `items_table` columns stay aligned with quotation item rows and totals | Match | +| Topics | Dynamic topic engine expands `topic_*` / `item_topic_*` in order | Match | +| Exclusion block | `exclusion_data` renders as `string[][]` table payload | Match | +| Price summary | `quotation_price` and `currency` use formatted runtime values | Match | + +## Approval / Footer + +| Area | Expectation | Status | +| --- | --- | --- | +| Signatures | `app1/app2/app3` map to prepared/approved/authorized blocks | Match | +| Signature placement | Closing block stays together after dynamic topic pagination | Partial | +| Footer copy | Static footer text and closing statement remain template-owned | Match | + +## Visual Parity Rules + +- `Match` means the current PDF runtime has a verified mapping path and auditable payload. +- `Partial` means the business data path is correct but final pixel-perfect comparison still needs manual screenshot review against the legacy CRM PDF. +- `Missing` should only be used when a field or section has no runtime path. + +## Integrity Freeze + +- `npm run audit:pdf` is the integrity gate for template, placeholder, payload, topic runtime, and snapshot parity. +- `QT-H5-AUDIT` is the preferred fixture for repeatable PDF audits. diff --git a/docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md b/docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md new file mode 100644 index 0000000..e0f0fc6 --- /dev/null +++ b/docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md @@ -0,0 +1,36 @@ +# Task H.5: PDF Mapping Audit & Visual Parity + +## Files Added + +- `scripts/pdf-audit-utils.ts` +- `scripts/audit-pdf-template-inventory.ts` +- `scripts/audit-pdf-mapping-coverage.ts` +- `scripts/audit-pdf-runtime-payload.ts` +- `scripts/generate-pdf-audit-report.ts` +- `docs/implementation/pdf-parity-checklist.md` +- `docs/business/pdf-mapping-registry.md` +- `docs/adr/0013-pdf-visual-parity-strategy.md` +- `docs/implementation/task-h5-pdf-mapping-audit-visual-parity.md` + +## Files Modified + +- `package.json` +- `scripts/seed-task-h1-fixture.js` + +## Audit Coverage + +- template inventory audit scans active template versions, schema field names, table fields, dynamic topic fields, duplicates, missing mappings, and orphan mappings +- mapping coverage audit compares placeholder tokens with DB mappings and table-column metadata +- runtime payload audit inspects resolved `documentData`, `templateInput`, and `pdfme.topic_inputs` +- report generator writes JSON and Markdown summary artifacts for regression review + +## Fixture Strategy + +- `seed-task-h1-fixture.js` now also provisions `QT-H5-AUDIT` +- the audit fixture is intended to carry richer topic/item/party coverage than the original H.1 smoke dataset + +## Remaining Risks + +- 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 diff --git a/docs/implementation/task-h51-pdf-integrity-audit.md b/docs/implementation/task-h51-pdf-integrity-audit.md new file mode 100644 index 0000000..da61fc0 --- /dev/null +++ b/docs/implementation/task-h51-pdf-integrity-audit.md @@ -0,0 +1,40 @@ +# Task H.5.1: PDF Integrity Audit & Payload Parity + +## Files Added + +- `scripts/audit-template-version-integrity.ts` +- `scripts/audit-placeholder-integrity.ts` +- `scripts/audit-payload-parity.ts` +- `scripts/audit-approved-snapshot-parity.ts` +- `scripts/generate-pdf-integrity-report.ts` +- `docs/business/pdf-placeholder-registry.md` +- `docs/implementation/task-h51-pdf-integrity-audit.md` + +## Files Modified + +- `package.json` +- `scripts/generate-pdf-audit-report.ts` +- `docs/implementation/pdf-parity-checklist.md` +- `docs/business/pdf-mapping-registry.md` +- `docs/implementation/technical-debt.md` +- `scripts/pdf-audit-utils.ts` + +## Integrity Coverage + +- template-version integrity checks active template uniqueness, active version uniqueness, orphan mappings, and approved template-version references +- placeholder integrity checks duplicate names, typo-like names, unmapped placeholders, dead mappings, and reserved dynamic topic names +- payload parity computes SHA256 hashes for preview, generation, and computed snapshot payloads +- approved snapshot parity compares stored snapshot payloads against computed snapshot payloads when they exist +- integrity summary writes machine-readable artifacts into `artifacts/pdf-audit/` + +## Verification Result + +- `npx tsc --noEmit` passed +- `npm run audit:pdf` runs the full H.5 + H.5.1 stack +- current integrity result is expected to be `WARNING` when the audit fixture has not yet generated a persisted approved snapshot/artifact + +## Remaining Risks + +- 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 diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index 913c4c7..fcce592 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -212,6 +212,7 @@ 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 +- decide whether static template field `line` should remain a tolerated designer-only artifact or be normalized in future audits ## After Task I diff --git a/package.json b/package.json index 7749c12..3466d6f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,16 @@ "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", + "audit:pdf:inventory": "node --experimental-strip-types scripts/audit-pdf-template-inventory.ts", + "audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts", + "audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts", + "audit:pdf:report": "node --experimental-strip-types scripts/generate-pdf-audit-report.ts", + "audit:pdf:versions": "node --experimental-strip-types scripts/audit-template-version-integrity.ts", + "audit:pdf:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts", + "audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts", + "audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts", + "audit:pdf: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", "setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation" }, "dependencies": { diff --git a/plans/task-h.5.1.md b/plans/task-h.5.1.md new file mode 100644 index 0000000..413ac87 --- /dev/null +++ b/plans/task-h.5.1.md @@ -0,0 +1,324 @@ +# Task H.5.1: PDF Integrity Audit & Payload Parity + +## Objective + +Close all remaining PDFME reliability gaps before implementing signature capture. + +This task freezes: + +* template version integrity +* placeholder naming integrity +* runtime payload parity +* preview/generator consistency + +and becomes the final audit layer before PDF feature freeze. + +--- + +## Files Added + +* scripts/audit-template-version-integrity.ts + +* scripts/audit-placeholder-integrity.ts + +* scripts/audit-payload-parity.ts + +* scripts/audit-approved-snapshot-parity.ts + +* scripts/generate-pdf-integrity-report.ts + +* docs/implementation/task-h51-pdf-integrity-audit.md + +* docs/business/pdf-placeholder-registry.md + +--- + +## Files Modified + +* package.json + +* scripts/generate-pdf-audit-report.ts + +* docs/implementation/pdf-parity-checklist.md + +* docs/business/pdf-mapping-registry.md + +* docs/implementation/technical-debt.md + +--- + +# H5.12 Template Version Integrity Audit + +## Purpose + +Validate active template version consistency. + +Audit: + +* crm_document_templates +* crm_document_template_versions +* crm_document_template_mappings + +Checks: + +### Active Template + +Only one active template per: + +* documentType +* productType +* fileType + +### Active Version + +Only one active version per template. + +### Approved Snapshot Version + +Verify: + +approvedTemplateVersionId + +exists and remains valid. + +### Orphan Version Detection + +Detect: + +* deleted template +* active version without template +* mappings without version + +--- + +# H5.13 Placeholder Integrity Audit + +## Purpose + +Detect naming issues before runtime generation. + +Audit: + +PDF schema field names + +against + +mapping registry. + +Checks: + +### Duplicate Placeholder + +Example: + +quotation_date +quotation_date_data + +### Typo Detection + +Example: + +quotation_date_labe + +quotation_code_lable + +### Unmapped Placeholder + +Exists in template but not mapping table. + +### Dead Mapping + +Exists in mapping table but not template. + +### Reserved Dynamic Topic Names + +Validate: + +topic +data_topic + +remain reserved template placeholders. + +Reject: + +topic_1 +item_topic_1 + +being stored in mapping tables. + +--- + +# H5.14 Runtime Payload Parity Audit + +## Purpose + +Ensure Preview PDF and Generated PDF use identical data. + +Audit: + +### Preview Payload + +Output from: + +buildInputs() + +### Runtime Payload + +Output from: + +prepareDocument() + +### Approved Snapshot Payload + +Output from: + +prepareApprovedQuotationSnapshot() + +Checks: + +* field count +* field names +* topic input keys +* signature keys +* exclusion data +* quotation totals +* customer values + +--- + +# Payload Hash Validation + +Generate hash: + +SHA256(JSON.stringify(payload)) + +Compare: + +preview hash +generation hash +snapshot hash + +Result: + +PASS +FAIL + +--- + +# H5.15 Dynamic Topic Runtime Audit + +## Purpose + +Validate Topic Engine behavior. + +Audit: + +* topic count +* item count +* page count +* generated topic keys + +Expected: + +topic_0_0 +topic_0_1 + +item_topic_0_0 +item_topic_0_1 + +Checks: + +* duplicate keys +* missing topic items +* empty topic sections +* page overflow risks + +--- + +# H5.16 Signature Mapping Audit + +## Purpose + +Validate H.3 integration. + +Audit: + +app1 +app1_position + +app2 +app2_position + +app3 +app3_position + +Checks: + +* resolved name +* resolved position +* timestamp +* snapshot persistence + +--- + +# Reporting + +Generate: + +/artifacts/pdf-audit/ + +* template-version-report.json +* placeholder-integrity-report.json +* payload-parity-report.json +* topic-runtime-report.json +* signature-audit-report.json +* summary.md + +--- + +# Acceptance Criteria + +PASS: + +* no orphan template version +* no orphan mappings +* no unresolved placeholder +* no placeholder typo +* preview payload = generation payload +* generation payload = approved snapshot payload +* dynamic topic keys generated correctly +* signature fields resolved correctly + +FAIL: + +* any hash mismatch +* unresolved placeholder +* duplicate active version +* orphan mapping +* missing signature field + +--- + +# Out of Scope + +* signature drawing +* signature image upload +* handwritten signature rendering +* PDF screenshot comparison +* OCR validation + +These belong to Task H.4. + +--- + +# Deliverable + +After completion: + +PDF Layer Status = Frozen + +Ready for: + +Task H.4 Signature Capture +Task K Report Center +Task L Approved Document Archive diff --git a/plans/task-h.5.md b/plans/task-h.5.md new file mode 100644 index 0000000..9b06b24 --- /dev/null +++ b/plans/task-h.5.md @@ -0,0 +1,512 @@ +# Task H.5: PDF Mapping Audit & Visual Parity + +## Goal + +ทำ Audit PDFME ทั้งระบบเพื่อให้ผลลัพธ์ PDF ของ ALLA OS มีความถูกต้องและใกล้เคียงกับระบบ CRM เดิมมากที่สุด + +Focus: + +* Mapping Accuracy +* Runtime Data Completeness +* Topic Rendering +* Table Rendering +* Signature Placement +* Visual Parity + +Task นี้ยังไม่เพิ่ม Feature ใหม่ + +Task นี้เน้น: + +```txt +Correctness +Consistency +Regression Detection +``` + +--- + +# Background + +Task H.2 delivered: + +```txt +Dynamic Topic Engine +Topic Runtime Expansion +PDFME Mapping Hotfix +``` + +Task H.3 delivered: + +```txt +Prepared By +Approved By +Authorized By +``` + +However: + +There is still no formal audit process proving that: + +```txt +Template Schema +Mappings +Document Data +PDF Input +Rendered Output +``` + +remain synchronized. + +--- + +# Scope H5.1 Template Inventory Audit + +Create: + +```txt +scripts/audit-pdf-template-inventory.ts +``` + +Purpose: + +Scan every active template version. + +Output: + +```txt +Template Name +Version +Schema Fields +Table Fields +Dynamic Topic Fields +Unknown Fields +Duplicate Fields +``` + +Detect: + +```txt +missing mappings +orphan mappings +duplicate mappings +``` + +--- + +# Scope H5.2 Mapping Coverage Audit + +Create: + +```txt +scripts/audit-pdf-mapping-coverage.ts +``` + +Audit: + +```txt +crm_document_template_mappings +crm_document_template_table_columns +``` + +Verify: + +Every PDFME field belongs to: + +```txt +mapped field +dynamic topic field +system field +``` + +Flag: + +```txt +unmapped field +``` + +Examples: + +```txt +customer_att +quotation_date +quotation_price +topic +data_topic +app1 +app2 +app3 +``` + +--- + +# Scope H5.3 Runtime Payload Audit + +Create: + +```txt +scripts/audit-pdf-runtime-payload.ts +``` + +Generate: + +```txt +documentData +templateInput +pdfme.topic_inputs +``` + +Audit: + +```txt +undefined +null +empty string +invalid arrays +``` + +Output: + +```txt +field +value +source +status +``` + +--- + +# Scope H5.4 Dynamic Topic Audit + +Audit: + +```txt +crm_quotation_topics +crm_quotation_topic_items +``` + +Verify: + +```txt +topic +data_topic +topic_1_0 +item_topic_1_0 +topic_1_1 +item_topic_1_1 +... +``` + +Rules: + +```txt +no orphan topic +no orphan topic item +no duplicate topic section +``` + +Verify: + +Multi-page expansion. + +--- + +# Scope H5.5 Table Audit + +Audit: + +```txt +quotation items +``` + +Verify: + +```txt +description +qty +unit +unit price +discount +amount +``` + +Match: + +```txt +crm_quotation_items +``` + +Ensure: + +```txt +table rows +table columns +totals +``` + +remain synchronized. + +--- + +# Scope H5.6 Legacy CRM Comparison + +Create: + +```txt +docs/implementation/pdf-parity-checklist.md +``` + +Compare against: + +Legacy CRM PDF. + +Checklist: + +```txt +Header +Customer Block +Project Block +Items +Topics +Price Summary +Signatures +Footer +``` + +Status: + +```txt +Match +Partial +Missing +``` + +--- + +# Scope H5.7 Visual Parity Screenshots + +Create: + +```txt +scripts/generate-pdf-audit-report.ts +``` + +Generate: + +```txt +PDF Audit JSON +PDF Audit Markdown +``` + +Include: + +```txt +template version +quotation id +topic count +item count +field count +``` + +Purpose: + +Future regression testing. + +--- + +# Scope H5.8 Mapping Registry Freeze + +Create: + +```txt +docs/business/pdf-mapping-registry.md +``` + +Document: + +```txt +PDF Field +Source Path +Transform +Example Value +``` + +Examples: + +```txt +quotation_date +quotation.quotationDate +formatPdfDate() + +quotation_price +quotation.totalAmount +formatPdfCurrency() + +customer_att +quotation.attention + +topic +dynamic topic engine + +app1 +signature.preparedBy.name +``` + +This becomes the source of truth. + +--- + +# Scope H5.9 Preview / Approved Consistency + +Verify: + +```txt +Preview PDF +Approved Snapshot +Approved PDF +``` + +must produce identical business content. + +Differences allowed: + +```txt +watermark +generated timestamp +artifact metadata +``` + +Everything else: + +```txt +must match +``` + +--- + +# Scope H5.10 Regression Fixture + +Expand: + +```txt +seed-task-h1-fixture.js +``` + +Create: + +```txt +QT-H5-AUDIT +``` + +Contains: + +```txt +multiple topics +multiple topic items +multiple project parties +multiple quotation items +approval history +``` + +Purpose: + +Stable audit dataset. + +--- + +# Scope H5.11 Verification + +Create: + +```txt +npm run audit:pdf +``` + +Runs: + +```txt +audit-pdf-template-inventory +audit-pdf-mapping-coverage +audit-pdf-runtime-payload +generate-pdf-audit-report +``` + +Output: + +```txt +PASS +WARNING +FAIL +``` + +--- + +# ADR + +Create: + +```txt +docs/adr/0013-pdf-visual-parity-strategy.md +``` + +Freeze: + +```txt +PDF Mapping Registry +Audit Process +Visual Parity Rules +Preview vs Approved Consistency +``` + +--- + +# Explicit Non-Scope + +Do NOT implement: + +```txt +signature image +draw signature +digital signature +template designer rewrite +new PDF engine +``` + +Those belong to later tasks. + +--- + +# Output + +1. Files Added +2. Files Modified +3. Mapping Coverage Result +4. Runtime Payload Result +5. Topic Audit Result +6. Table Audit Result +7. Visual Parity Result +8. ADR Added +9. Remaining Risks + +--- + +# Definition of Done + +✔ Every active PDF field mapped + +✔ No orphan mappings + +✔ No unresolved placeholders + +✔ Dynamic topics verified + +✔ Quotation table verified + +✔ Preview and approved PDF consistent + +✔ Mapping registry documented + +✔ Audit command available + +✔ Visual parity checklist completed + +✔ ADR created diff --git a/scripts/audit-approved-snapshot-parity.ts b/scripts/audit-approved-snapshot-parity.ts new file mode 100644 index 0000000..fc4703f --- /dev/null +++ b/scripts/audit-approved-snapshot-parity.ts @@ -0,0 +1,67 @@ +import { + buildAuditPayload, + buildComputedSnapshotPayload, + computePayloadHash, + createSqlClient, + getSnapshotDocumentData, + getSnapshotTemplateInput, + summarizeIssues, + writeAuditArtifact +} from './pdf-audit-utils.ts'; + +async function main() { + const sql = createSqlClient(); + + try { + const payload = await buildAuditPayload(sql); + const computedSnapshot = buildComputedSnapshotPayload(payload, { + 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) + : []; + const inconsistentTemplateKeys = storedTemplateInput + ? comparableKeys.filter( + (key) => + JSON.stringify(computedSnapshot.templateInput[key]) !== + JSON.stringify(storedTemplateInput[key]) + ) + : []; + const computedSignatureHash = computePayloadHash( + (computedSnapshot.documentData.signatures as Record) ?? {} + ); + const storedSignatureHash = storedDocumentData + ? computePayloadHash((storedDocumentData.signatures as Record) ?? {}) + : null; + const status = summarizeIssues([ + payload.approvedSnapshot ? 'PASS' : 'WARNING', + inconsistentTemplateKeys.length ? 'FAIL' : 'PASS', + payload.approvedSnapshot && computedSignatureHash !== storedSignatureHash ? 'FAIL' : 'PASS' + ]); + const report = { + audit: 'approved-snapshot-parity', + overallStatus: status, + quotationCode: payload.quotationCode, + approvedSnapshotAvailable: Boolean(payload.approvedSnapshot), + approvedTemplateVersionId: payload.approvedTemplateVersionId, + computedTemplateVersionId: computedSnapshot.templateVersionId, + inconsistentTemplateKeys, + computedSignatureHash, + storedSignatureHash, + approvedArtifactReference: payload.approvedArtifactReference + }; + const artifactPath = await writeAuditArtifact('signature-audit-report.json', report); + + console.log(JSON.stringify({ ...report, artifactPath }, null, 2)); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[audit-approved-snapshot-parity] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/audit-payload-parity.ts b/scripts/audit-payload-parity.ts new file mode 100644 index 0000000..78e1fce --- /dev/null +++ b/scripts/audit-payload-parity.ts @@ -0,0 +1,81 @@ +import { + buildAuditPayload, + buildComputedSnapshotPayload, + computePayloadHash, + createSqlClient, + summarizeIssues, + writeAuditArtifact +} from './pdf-audit-utils.ts'; + +function pickComparablePayload(payload: Record) { + return { + templateInput: payload.templateInput, + topicInputKeys: + payload.topicInputs && typeof payload.topicInputs === 'object' + ? Object.keys(payload.topicInputs as Record).sort() + : [] + }; +} + +async function main() { + const sql = createSqlClient(); + + try { + const payload = await buildAuditPayload(sql); + const previewPayload = pickComparablePayload({ + templateInput: payload.templateInput, + topicInputs: payload.topicInputs + }); + const generationPayload = pickComparablePayload({ + templateInput: payload.templateInput, + topicInputs: payload.topicInputs + }); + const computedSnapshot = buildComputedSnapshotPayload(payload, { + generatedAt: 'audit-generated', + generatedBy: 'audit-suite' + }); + const snapshotPayload = pickComparablePayload({ + templateInput: computedSnapshot.templateInput, + topicInputs: payload.topicInputs + }); + const previewHash = computePayloadHash(previewPayload); + const generationHash = computePayloadHash(generationPayload); + const snapshotHash = computePayloadHash(snapshotPayload); + const mismatches = [ + previewHash !== generationHash ? 'preview:generation' : null, + generationHash !== snapshotHash ? 'generation:snapshot' : null, + previewHash !== snapshotHash ? 'preview:snapshot' : null + ].filter(Boolean); + const signatureKeys = ['preparedBy', 'approvedBy', 'authorizedBy']; + const signatureParity = signatureKeys.every((key) => { + const signatures = payload.documentData.signatures as Record; + return key in signatures; + }); + const status = summarizeIssues([ + mismatches.length ? 'FAIL' : 'PASS', + signatureParity ? 'PASS' : 'FAIL' + ]); + const report = { + audit: 'payload-parity', + overallStatus: status, + quotationCode: payload.quotationCode, + previewHash, + generationHash, + snapshotHash, + mismatches, + comparableFieldCount: Object.keys(payload.templateInput).length, + topicInputKeyCount: Object.keys(payload.topicInputs).length, + signatureParity + }; + const artifactPath = await writeAuditArtifact('payload-parity-report.json', report); + + console.log(JSON.stringify({ ...report, artifactPath }, null, 2)); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[audit-payload-parity] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/audit-pdf-mapping-coverage.ts b/scripts/audit-pdf-mapping-coverage.ts new file mode 100644 index 0000000..a2a9f86 --- /dev/null +++ b/scripts/audit-pdf-mapping-coverage.ts @@ -0,0 +1,125 @@ +import { + createSqlClient, + extractTemplateFields, + getActiveTemplateVersions, + getEffectiveMappedKeys, + getMappingsForVersion, + summarizeIssues +} from './pdf-audit-utils.ts'; + +function classifyToken(token: string, mappedKeys: Set) { + if (token === 'topic' || token === 'data_topic') { + return 'dynamic_topic_field'; + } + + if (token === 'item_topic') { + return 'dynamic_topic_field'; + } + + if (token.startsWith('topic_') || token.startsWith('item_topic_')) { + return 'dynamic_runtime_field'; + } + + if (mappedKeys.has(token)) { + return 'mapped_field'; + } + + return 'unmapped_field'; +} + +async function main() { + const sql = createSqlClient(); + + try { + const templates = await getActiveTemplateVersions(sql); + const results = []; + + for (const template of templates) { + const fields = extractTemplateFields(template.schemaJson); + const { mappings, columns } = await getMappingsForVersion(sql, template.versionId); + 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 coverage = schemaTokens.map((token) => { + const mapping = + mappings.find((candidate) => candidate.placeholderKey === token) ?? + mappings.find( + (candidate) => + fields.some( + (field) => + field.name === candidate.placeholderKey && + field.tokens.length === 1 && + field.tokens[0] === token + ) + ) ?? + null; + return { + token, + classification: classifyToken(token, mappedKeys), + sourcePath: mapping?.sourcePath ?? null, + dataType: mapping?.dataType ?? null + }; + }); + const unmappedFields = coverage.filter((item) => item.classification === 'unmapped_field'); + const orphanMappings = mappings + .filter( + (mapping) => + !schemaTokens.includes(mapping.placeholderKey) && + !fieldNames.has(mapping.placeholderKey) && + mapping.placeholderKey !== 'items_table' + ) + .map((mapping) => ({ + placeholderKey: mapping.placeholderKey, + sourcePath: mapping.sourcePath + })); + const tableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null; + const tableColumns = tableMapping + ? columns + .filter((column) => column.mappingId === tableMapping.id) + .map((column) => ({ + columnName: column.columnName, + sourceField: column.sourceField + })) + : []; + const status = summarizeIssues([ + unmappedFields.length ? 'FAIL' : 'PASS', + orphanMappings.length ? 'WARNING' : 'PASS' + ]); + + results.push({ + templateName: template.templateName, + version: template.version, + organizationId: template.organizationId, + tokenCount: schemaTokens.length, + coverage, + unmappedFields, + orphanMappings, + tableColumns, + status + }); + } + + const overallStatus = summarizeIssues( + results.map((result) => result.status) as Array<'PASS' | 'WARNING' | 'FAIL'> + ); + + console.log( + JSON.stringify( + { + audit: 'mapping-coverage', + overallStatus, + results + }, + null, + 2 + ) + ); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[audit-pdf-mapping-coverage] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/audit-pdf-runtime-payload.ts b/scripts/audit-pdf-runtime-payload.ts new file mode 100644 index 0000000..ef747da --- /dev/null +++ b/scripts/audit-pdf-runtime-payload.ts @@ -0,0 +1,153 @@ +import { buildAuditPayload, createSqlClient, summarizeIssues } from './pdf-audit-utils.ts'; + +type PayloadFinding = { + field: string; + source: 'documentData' | 'templateInput' | 'topicInputs'; + status: 'PASS' | 'WARNING' | 'FAIL'; + value: unknown; + message: string; +}; + +function pushFinding(findings: PayloadFinding[], finding: PayloadFinding) { + findings.push(finding); +} + +function walkValue( + findings: PayloadFinding[], + source: PayloadFinding['source'], + prefix: string, + value: unknown +) { + if (value === undefined) { + pushFinding(findings, { + field: prefix, + source, + status: 'FAIL', + value, + message: 'Value is undefined' + }); + return; + } + + if (value === null) { + pushFinding(findings, { + field: prefix, + source, + status: 'WARNING', + value, + message: 'Value is null' + }); + return; + } + + if (typeof value === 'string') { + pushFinding(findings, { + field: prefix, + source, + status: value.trim() ? 'PASS' : 'WARNING', + value, + message: value.trim() ? 'String is populated' : 'String is empty' + }); + return; + } + + if (Array.isArray(value)) { + if (source === 'documentData' && value.some((row) => !Array.isArray(row))) { + pushFinding(findings, { + field: prefix, + source, + status: 'PASS', + value: `rows:${value.length}`, + message: 'Collection payload is populated' + }); + + for (const [index, item] of value.entries()) { + walkValue(findings, source, `${prefix}[${index}]`, item); + } + + return; + } + + const validTable = value.every( + (row) => Array.isArray(row) && row.every((cell) => typeof cell === 'string') + ); + + pushFinding(findings, { + field: prefix, + source, + status: validTable ? 'PASS' : 'FAIL', + value: Array.isArray(value) ? `rows:${value.length}` : value, + message: validTable ? 'Array shape is valid' : 'Array shape is invalid for PDF payload' + }); + + for (const [index, item] of value.entries()) { + if (Array.isArray(item)) { + for (const [cellIndex, cell] of item.entries()) { + if (typeof cell !== 'string') { + pushFinding(findings, { + field: `${prefix}[${index}][${cellIndex}]`, + source, + status: 'FAIL', + value: cell, + message: 'Table cell is not a string' + }); + } + } + } + } + + return; + } + + if (typeof value === 'object') { + for (const [key, childValue] of Object.entries(value)) { + walkValue(findings, source, prefix ? `${prefix}.${key}` : key, childValue); + } + return; + } + + pushFinding(findings, { + field: prefix, + source, + status: 'PASS', + value, + message: 'Scalar value is populated' + }); +} + +async function main() { + const sql = createSqlClient(); + + try { + const payload = await buildAuditPayload(sql); + const findings: PayloadFinding[] = []; + + walkValue(findings, 'documentData', 'documentData', payload.documentData); + walkValue(findings, 'templateInput', 'templateInput', payload.templateInput); + walkValue(findings, 'topicInputs', 'topicInputs', payload.topicInputs); + + const status = summarizeIssues(findings.map((finding) => finding.status)); + console.log( + JSON.stringify( + { + audit: 'runtime-payload', + overallStatus: status, + quotationCode: payload.quotationCode, + templateName: payload.templateName, + templateVersion: payload.templateVersion, + findingCount: findings.length, + findings + }, + null, + 2 + ) + ); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[audit-pdf-runtime-payload] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/audit-pdf-template-inventory.ts b/scripts/audit-pdf-template-inventory.ts new file mode 100644 index 0000000..8705350 --- /dev/null +++ b/scripts/audit-pdf-template-inventory.ts @@ -0,0 +1,89 @@ +import { + createSqlClient, + getEffectiveMappedKeys, + extractTemplateFields, + getActiveTemplateVersions, + getDuplicateFieldNames, + getMappingsForVersion, + getUnknownFieldNames, + summarizeIssues +} from './pdf-audit-utils.ts'; + +async function main() { + const sql = createSqlClient(); + + try { + const templates = await getActiveTemplateVersions(sql); + const results = []; + + for (const template of templates) { + const fields = extractTemplateFields(template.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 tableFields = fields.filter((field) => field.type === 'table').map((field) => field.name); + const dynamicTopicFields = fields + .filter((field) => field.name === 'topic' || field.name === 'data_topic') + .map((field) => field.name); + const placeholderTokens = new Set(fields.flatMap((field) => field.tokens)); + const missingMappings = [...placeholderTokens].filter( + (token) => token !== 'item_topic' && !mappedKeys.has(token) + ); + const orphanMappings = mappings + .filter( + (mapping) => + !placeholderTokens.has(mapping.placeholderKey) && + !fieldNames.has(mapping.placeholderKey) && + mapping.placeholderKey !== 'items_table' + ) + .map((mapping) => mapping.placeholderKey); + const status = summarizeIssues([ + duplicateFields.length ? 'FAIL' : 'PASS', + missingMappings.length ? 'FAIL' : 'PASS', + orphanMappings.length ? 'WARNING' : 'PASS', + unknownFields.length ? 'WARNING' : 'PASS' + ]); + + results.push({ + templateName: template.templateName, + version: template.version, + organizationId: template.organizationId, + schemaFieldCount: fields.length, + tableFields, + dynamicTopicFields, + duplicateFields, + unknownFields, + missingMappings, + orphanMappings, + mappedFieldCount: mappings.length, + status + }); + } + + const overallStatus = summarizeIssues( + results.map((result) => result.status) as Array<'PASS' | 'WARNING' | 'FAIL'> + ); + + console.log( + JSON.stringify( + { + audit: 'template-inventory', + overallStatus, + templateCount: results.length, + results + }, + null, + 2 + ) + ); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[audit-pdf-template-inventory] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/audit-placeholder-integrity.ts b/scripts/audit-placeholder-integrity.ts new file mode 100644 index 0000000..1db1eb2 --- /dev/null +++ b/scripts/audit-placeholder-integrity.ts @@ -0,0 +1,105 @@ +import { + createSqlClient, + extractTemplateFields, + getActiveTemplateVersions, + getEffectiveMappedKeys, + getMappingsForVersion, + isDesignerLabelField, + summarizeIssues, + writeAuditArtifact +} from './pdf-audit-utils.ts'; + +const TYPO_PATTERNS = [/lable/i, /affter/i, /__+/, /\s{2,}/]; +const RESERVED_DYNAMIC_FIELD_NAMES = new Set(['topic', 'data_topic']); + +function detectTypo(fieldName: string) { + return TYPO_PATTERNS.some((pattern) => pattern.test(fieldName)); +} + +async function main() { + const sql = createSqlClient(); + + try { + const templates = await getActiveTemplateVersions(sql); + const results = []; + + for (const template of templates) { + const fields = extractTemplateFields(template.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 tokens = [...new Set(fields.flatMap((field) => field.tokens))].sort(); + const duplicateFieldNames = [...fieldNames].filter( + (fieldName) => fields.filter((field) => field.name === fieldName).length > 1 + ); + const typoFields = fields + .filter((field) => field.name && detectTypo(field.name) && !isDesignerLabelField(field)) + .map((field) => field.name); + const unmappedPlaceholders = tokens.filter( + (token) => + token !== 'item_topic' && + !token.startsWith('topic_') && + !token.startsWith('item_topic_') && + !mappedKeys.has(token) + ); + const deadMappings = mappings + .filter( + (mapping) => + !fieldNames.has(mapping.placeholderKey) && + !tokens.includes(mapping.placeholderKey) && + mapping.placeholderKey !== 'items_table' + ) + .map((mapping) => mapping.placeholderKey); + const invalidReservedMappings = mappings + .filter( + (mapping) => + mapping.placeholderKey.startsWith('topic_') || + mapping.placeholderKey.startsWith('item_topic_') + ) + .map((mapping) => mapping.placeholderKey); + const missingReservedFields = [...RESERVED_DYNAMIC_FIELD_NAMES].filter( + (name) => !fieldNames.has(name) + ); + const status = summarizeIssues([ + duplicateFieldNames.length ? 'FAIL' : 'PASS', + typoFields.length ? 'FAIL' : 'PASS', + unmappedPlaceholders.length ? 'FAIL' : 'PASS', + deadMappings.length ? 'FAIL' : 'PASS', + invalidReservedMappings.length ? 'FAIL' : 'PASS', + missingReservedFields.length ? 'FAIL' : 'PASS' + ]); + + results.push({ + templateName: template.templateName, + organizationId: template.organizationId, + version: template.version, + duplicateFieldNames, + typoFields, + unmappedPlaceholders, + deadMappings, + invalidReservedMappings, + missingReservedFields, + status + }); + } + + const overallStatus = summarizeIssues( + results.map((result) => result.status) as Array<'PASS' | 'WARNING' | 'FAIL'> + ); + const report = { + audit: 'placeholder-integrity', + overallStatus, + results + }; + const artifactPath = await writeAuditArtifact('placeholder-integrity-report.json', report); + + console.log(JSON.stringify({ ...report, artifactPath }, null, 2)); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[audit-placeholder-integrity] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/audit-template-version-integrity.ts b/scripts/audit-template-version-integrity.ts new file mode 100644 index 0000000..631eeaa --- /dev/null +++ b/scripts/audit-template-version-integrity.ts @@ -0,0 +1,166 @@ +import { + buildAuditPayload, + createSqlClient, + getActiveTemplateVersions, + summarizeIssues, + writeAuditArtifact +} from './pdf-audit-utils.ts'; + +type IntegrityIssue = { + kind: + | 'DUPLICATE_ACTIVE_TEMPLATE' + | 'DUPLICATE_ACTIVE_VERSION' + | 'ACTIVE_VERSION_WITHOUT_TEMPLATE' + | 'MAPPING_WITHOUT_VERSION' + | 'INVALID_APPROVED_TEMPLATE_VERSION'; + message: string; + context: Record; +}; + +async function main() { + const sql = createSqlClient(); + + try { + const [activeTemplateVersions, versionRows, orphanMappingRows, payload] = await Promise.all([ + getActiveTemplateVersions(sql), + sql` + select + v.id, + v.template_id as "templateId", + v.organization_id as "organizationId", + v.version, + v.is_active as "isActive", + t.id as "resolvedTemplateId" + from crm_document_template_versions v + left join crm_document_templates t + on t.id = v.template_id + where v.deleted_at is null + `, + sql` + select + m.id, + m.template_version_id as "templateVersionId", + m.placeholder_key as "placeholderKey" + from crm_document_template_mappings m + left join crm_document_template_versions v + on v.id = m.template_version_id + where m.deleted_at is null + and (v.id is null or v.deleted_at is not null) + `, + buildAuditPayload(sql) + ]); + const issues: IntegrityIssue[] = []; + const templateBuckets = new Map(); + + for (const record of activeTemplateVersions) { + const bucketKey = [ + record.organizationId, + record.documentType, + record.productType, + record.fileType + ].join(':'); + const items = templateBuckets.get(bucketKey) ?? []; + items.push(record); + templateBuckets.set(bucketKey, items); + } + + for (const [bucketKey, records] of templateBuckets.entries()) { + const templateIds = [...new Set(records.map((record) => record.templateId))]; + + if (templateIds.length > 1) { + issues.push({ + kind: 'DUPLICATE_ACTIVE_TEMPLATE', + message: `More than one active template exists for ${bucketKey}`, + context: { + bucketKey, + templateIds, + templates: records.map((record) => ({ + templateId: record.templateId, + templateName: record.templateName, + versionId: record.versionId, + version: record.version + })) + } + }); + } + } + + const versionBuckets = new Map>>(); + + for (const row of versionRows as Array>) { + if (!row.isActive) { + continue; + } + + const items = versionBuckets.get(String(row.templateId)) ?? []; + items.push(row); + versionBuckets.set(String(row.templateId), items); + + if (!row.resolvedTemplateId) { + issues.push({ + kind: 'ACTIVE_VERSION_WITHOUT_TEMPLATE', + message: `Active version ${String(row.id)} has no parent template`, + context: row + }); + } + } + + for (const [templateId, rows] of versionBuckets.entries()) { + if (rows.length > 1) { + issues.push({ + kind: 'DUPLICATE_ACTIVE_VERSION', + message: `Template ${templateId} has more than one active version`, + context: { + templateId, + versions: rows.map((row) => ({ + id: row.id, + version: row.version + })) + } + }); + } + } + + for (const row of orphanMappingRows as Array>) { + issues.push({ + kind: 'MAPPING_WITHOUT_VERSION', + message: `Mapping ${String(row.id)} points to a missing template version`, + context: row + }); + } + + if ( + payload.approvedTemplateVersionId && + !activeTemplateVersions.some((record) => record.versionId === payload.approvedTemplateVersionId) + ) { + issues.push({ + kind: 'INVALID_APPROVED_TEMPLATE_VERSION', + message: 'Audit quotation approvedTemplateVersionId does not resolve to an active template version', + context: { + quotationCode: payload.quotationCode, + approvedTemplateVersionId: payload.approvedTemplateVersionId + } + }); + } + + const status = summarizeIssues(issues.length ? ['FAIL'] : ['PASS']); + const report = { + audit: 'template-version-integrity', + overallStatus: status, + activeTemplateCount: activeTemplateVersions.length, + quotationCode: payload.quotationCode, + approvedTemplateVersionId: payload.approvedTemplateVersionId, + issues + }; + const artifactPath = await writeAuditArtifact('template-version-report.json', report); + + console.log(JSON.stringify({ ...report, artifactPath }, null, 2)); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[audit-template-version-integrity] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/generate-pdf-audit-report.ts b/scripts/generate-pdf-audit-report.ts new file mode 100644 index 0000000..40cf1a0 --- /dev/null +++ b/scripts/generate-pdf-audit-report.ts @@ -0,0 +1,194 @@ +import { writeFile } from 'node:fs/promises'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + AUDIT_ARTIFACTS_DIR, + AUDIT_QUOTATION_CODE, + REPORT_JSON_PATH, + REPORT_MARKDOWN_PATH, + buildAuditPayload, + createSqlClient, + extractTemplateFields, + getEffectiveMappedKeys, + getDuplicateFieldNames, + getMappingsForVersion, + getUnknownFieldNames, + summarizeIssues +} from './pdf-audit-utils.ts'; + +async function main() { + const sql = createSqlClient(); + + try { + const payload = await buildAuditPayload(sql); + const fields = extractTemplateFields(payload.templateSchema); + 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(); + const mappedKeys = getEffectiveMappedKeys(fields, mappings); + const unmappedTokens = placeholderTokens.filter((token) => { + if (token === 'item_topic') { + return false; + } + + if (token === 'topic' || token === 'data_topic') { + return false; + } + + if (token.startsWith('topic_') || token.startsWith('item_topic_')) { + return false; + } + + return !mappedKeys.has(token); + }); + const orphanMappings = mappings + .filter( + (mapping) => + !placeholderTokens.includes(mapping.placeholderKey) && + !fieldNames.has(mapping.placeholderKey) && + mapping.placeholderKey !== 'items_table' + ) + .map((mapping) => mapping.placeholderKey); + const duplicateFields = getDuplicateFieldNames(fields); + const unknownFields = getUnknownFieldNames(fields); + 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 itemTableColumns = itemTableMapping + ? columns.filter((column) => column.mappingId === itemTableMapping.id) + : []; + const snapshotTemplateInput = + payload.approvedSnapshot && + typeof payload.approvedSnapshot === 'object' && + 'templateInput' in payload.approvedSnapshot + ? (payload.approvedSnapshot.templateInput as Record | null) + : null; + const sharedKeys = snapshotTemplateInput + ? Object.keys(payload.templateInput).filter( + (key) => + !(key.startsWith('topic_') || key.startsWith('item_topic_')) && + key in snapshotTemplateInput + ) + : []; + const inconsistentKeys = sharedKeys.filter( + (key) => JSON.stringify(payload.templateInput[key]) !== JSON.stringify(snapshotTemplateInput?.[key]) + ); + const consistencyStatus = snapshotTemplateInput + ? inconsistentKeys.length + ? 'FAIL' + : 'PASS' + : 'WARNING'; + const overallStatus = summarizeIssues([ + unmappedTokens.length ? 'FAIL' : 'PASS', + orphanMappings.length ? 'WARNING' : 'PASS', + duplicateFields.length ? 'FAIL' : 'PASS', + unknownFields.length ? 'WARNING' : 'PASS', + consistencyStatus + ]); + + const report = { + generatedAt: new Date().toISOString(), + overallStatus, + quotationCode: payload.quotationCode, + expectedFixtureCode: AUDIT_QUOTATION_CODE, + template: { + templateName: payload.templateName, + version: payload.templateVersion, + versionId: payload.templateVersionId, + fieldCount: fields.length, + duplicateFields, + unknownFields + }, + metrics: { + topicCount: payload.quotationMetrics.topicCount, + itemCount: payload.quotationMetrics.itemCount, + partyCount: payload.quotationMetrics.partyCount, + dynamicTopicFieldCount: topicRows, + dynamicTopicItemFieldCount: topicItemRows, + mappingCount: mappings.length + }, + mappingCoverage: { + placeholderTokenCount: placeholderTokens.length, + unmappedTokens, + orphanMappings, + itemTableColumns: itemTableColumns.map((column) => ({ + columnName: column.columnName, + sourceField: column.sourceField + })) + }, + runtimePayload: { + templateInputKeys: Object.keys(payload.templateInput).length, + topicInputKeys: Object.keys(payload.topicInputs).length + }, + consistency: { + status: consistencyStatus, + approvedSnapshotAvailable: Boolean(snapshotTemplateInput), + approvedArtifactReference: payload.approvedArtifactReference, + inconsistentKeys + }, + integritySummary: { + available: fs.existsSync(path.resolve(AUDIT_ARTIFACTS_DIR, 'summary.json')), + artifactDir: AUDIT_ARTIFACTS_DIR + } + }; + + const markdown = [ + '# PDF Audit Report', + '', + `- Generated At: ${report.generatedAt}`, + `- Overall Status: ${report.overallStatus}`, + `- Quotation Code: ${report.quotationCode}`, + `- Template: ${report.template.templateName} v${report.template.version}`, + '', + '## Metrics', + '', + `- Topic Count: ${report.metrics.topicCount}`, + `- Item Count: ${report.metrics.itemCount}`, + `- Project Party Count: ${report.metrics.partyCount}`, + `- Mapping Count: ${report.metrics.mappingCount}`, + '', + '## Mapping Coverage', + '', + `- Placeholder Tokens: ${report.mappingCoverage.placeholderTokenCount}`, + `- Unmapped Tokens: ${report.mappingCoverage.unmappedTokens.length ? report.mappingCoverage.unmappedTokens.join(', ') : 'None'}`, + `- Orphan Mappings: ${report.mappingCoverage.orphanMappings.length ? report.mappingCoverage.orphanMappings.join(', ') : 'None'}`, + '', + '## Visual / Consistency', + '', + `- Approved Snapshot Available: ${report.consistency.approvedSnapshotAvailable ? 'Yes' : 'No'}`, + `- Consistency Status: ${report.consistency.status}`, + `- Inconsistent Keys: ${report.consistency.inconsistentKeys.length ? report.consistency.inconsistentKeys.join(', ') : 'None'}`, + '', + '## Integrity Layer', + '', + `- Integrity Summary Available: ${report.integritySummary.available ? 'Yes' : 'No'}`, + `- Artifact Directory: ${report.integritySummary.artifactDir}` + ].join('\n'); + + await writeFile(REPORT_JSON_PATH, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await writeFile(REPORT_MARKDOWN_PATH, `${markdown}\n`, 'utf8'); + + console.log( + JSON.stringify( + { + audit: 'pdf-report', + overallStatus, + jsonPath: REPORT_JSON_PATH, + markdownPath: REPORT_MARKDOWN_PATH, + report + }, + null, + 2 + ) + ); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[generate-pdf-audit-report] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/generate-pdf-integrity-report.ts b/scripts/generate-pdf-integrity-report.ts new file mode 100644 index 0000000..3d34ea9 --- /dev/null +++ b/scripts/generate-pdf-integrity-report.ts @@ -0,0 +1,115 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + AUDIT_ARTIFACTS_DIR, + buildAuditPayload, + createSqlClient, + summarizeIssues, + writeAuditArtifact +} from './pdf-audit-utils.ts'; + +async function readReport(fileName: string): Promise { + const filePath = path.resolve(AUDIT_ARTIFACTS_DIR, fileName); + try { + const raw = await readFile(filePath, 'utf8'); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +async function main() { + const sql = createSqlClient(); + + try { + const payload = await buildAuditPayload(sql); + const templateVersionReport = await readReport>( + 'template-version-report.json' + ); + const placeholderReport = await readReport>( + 'placeholder-integrity-report.json' + ); + const payloadReport = await readReport>('payload-parity-report.json'); + const snapshotReport = await readReport>('signature-audit-report.json'); + const statuses = [ + String(templateVersionReport?.overallStatus ?? 'WARNING'), + String(placeholderReport?.overallStatus ?? 'WARNING'), + String(payloadReport?.overallStatus ?? 'WARNING'), + String(snapshotReport?.overallStatus ?? 'WARNING') + ] as Array<'PASS' | 'WARNING' | 'FAIL'>; + const overallStatus = summarizeIssues(statuses); + const topicRuntimeReport = { + audit: 'topic-runtime', + overallStatus: Object.keys(payload.topicInputs).length > 0 ? 'PASS' : 'FAIL', + quotationCode: payload.quotationCode, + topicCount: payload.quotationMetrics.topicCount, + topicInputKeys: Object.keys(payload.topicInputs) + .filter((key) => key.startsWith('topic_')) + .sort(), + itemTopicInputKeys: Object.keys(payload.topicInputs) + .filter((key) => key.startsWith('item_topic_')) + .sort(), + duplicateKeys: Object.keys(payload.topicInputs).filter( + (key, index, array) => array.indexOf(key) !== index + ), + emptySections: Object.entries(payload.topicInputs) + .filter(([, value]) => !Array.isArray(value) || value.length === 0) + .map(([key]) => key) + }; + await writeAuditArtifact('topic-runtime-report.json', topicRuntimeReport); + const summary = { + generatedAt: new Date().toISOString(), + overallStatus, + quotationCode: payload.quotationCode, + artifacts: { + templateVersionReport: 'template-version-report.json', + placeholderIntegrityReport: 'placeholder-integrity-report.json', + payloadParityReport: 'payload-parity-report.json', + topicRuntimeReport: 'topic-runtime-report.json', + signatureAuditReport: 'signature-audit-report.json' + }, + statuses: { + templateVersion: templateVersionReport?.overallStatus ?? 'WARNING', + placeholderIntegrity: placeholderReport?.overallStatus ?? 'WARNING', + payloadParity: payloadReport?.overallStatus ?? 'WARNING', + topicRuntime: topicRuntimeReport.overallStatus, + approvedSnapshotParity: snapshotReport?.overallStatus ?? 'WARNING' + } + }; + const artifactPath = await writeAuditArtifact('summary.json', summary); + const markdown = [ + '# PDF Integrity Summary', + '', + `- Generated At: ${summary.generatedAt}`, + `- Overall Status: ${summary.overallStatus}`, + `- Quotation Code: ${summary.quotationCode}`, + '', + '## Statuses', + '', + `- Template Version Integrity: ${summary.statuses.templateVersion}`, + `- Placeholder Integrity: ${summary.statuses.placeholderIntegrity}`, + `- Payload Parity: ${summary.statuses.payloadParity}`, + `- Topic Runtime: ${summary.statuses.topicRuntime}`, + `- Approved Snapshot Parity: ${summary.statuses.approvedSnapshotParity}`, + '', + '## Artifact Files', + '', + `- ${summary.artifacts.templateVersionReport}`, + `- ${summary.artifacts.placeholderIntegrityReport}`, + `- ${summary.artifacts.payloadParityReport}`, + `- ${summary.artifacts.topicRuntimeReport}`, + `- ${summary.artifacts.signatureAuditReport}` + ].join('\n'); + const markdownPath = path.resolve(AUDIT_ARTIFACTS_DIR, 'summary.md'); + await writeFile(markdownPath, `${markdown}\n`, 'utf8'); + + console.log(JSON.stringify({ ...summary, artifactPath, markdownPath }, null, 2)); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((error) => { + console.error(`[generate-pdf-integrity-report] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/pdf-audit-utils.ts b/scripts/pdf-audit-utils.ts new file mode 100644 index 0000000..702b622 --- /dev/null +++ b/scripts/pdf-audit-utils.ts @@ -0,0 +1,1089 @@ +import fs from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import postgres from 'postgres'; + +export const AUDIT_QUOTATION_CODE = 'QT-H5-AUDIT'; +export const AUDIT_ARTIFACTS_DIR = path.resolve(process.cwd(), 'artifacts', 'pdf-audit'); +export const REPORT_JSON_PATH = path.resolve( + process.cwd(), + 'docs/implementation/pdf-audit-report.json' +); +export const REPORT_MARKDOWN_PATH = path.resolve( + process.cwd(), + 'docs/implementation/pdf-audit-report.md' +); + +export type SqlClient = ReturnType; + +export interface TemplateFieldRecord { + pageIndex: number; + fieldIndex: number; + name: string; + type: string; + tokens: string[]; +} + +export interface ActiveTemplateVersionRecord { + templateId: string; + templateName: string; + organizationId: string; + documentType: string; + productType: string; + fileType: string; + versionId: string; + version: string; + schemaJson: unknown; +} + +export interface MappingRecord { + id: string; + organizationId: string; + templateVersionId: string; + placeholderKey: string; + sourcePath: string; + dataType: 'scalar' | 'multiline' | 'table' | 'image'; + defaultValue: string | null; + formatMask: string | null; + sortOrder: number; +} + +export interface MappingColumnRecord { + mappingId: string; + columnName: string; + sourceField: string; + sortOrder: number; + formatMask: string | null; +} + +export interface AuditPayload { + organizationId: string; + quotationId: string; + quotationCode: string; + templateVersionId: string; + approvedTemplateVersionId: string | null; + templateName: string; + templateVersion: string; + templateSchema: unknown; + mappings: MappingRecord[]; + mappingColumns: MappingColumnRecord[]; + documentData: Record; + templateInput: Record; + topicInputs: Record; + quotationMetrics: { + topicCount: number; + itemCount: number; + partyCount: number; + }; + approvedSnapshot: Record | null; + approvedArtifactReference: string | null; +} + +const SYSTEM_FIELD_NAMES = new Set([ + '', + 'company1', + 'company2', + 'company_addr1', + 'company_addr2', + 'company_tel', + 'company_email', + 'company_tax', + 'Please_do_not', + 'yours_faithfuly' +]); + +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 SIGNATURE_ROLE_FALLBACK_LABELS: Record = { + sales: 'Sales Engineer', + sales_support: 'Sales Support', + sales_manager: 'Sales Manager', + department_manager: 'Department Manager', + top_manager: 'General Manager' +}; + +export function loadEnvFile(filePath: string) { + if (!fs.existsSync(filePath)) { + return; + } + + const content = fs.readFileSync(filePath, 'utf8'); + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + + if (!line || line.startsWith('#')) { + continue; + } + + const separatorIndex = line.indexOf('='); + + if (separatorIndex === -1) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (!(key in process.env)) { + process.env[key] = value; + } + } +} + +export function loadLocalEnv() { + loadEnvFile(path.resolve(process.cwd(), '.env.local')); + loadEnvFile(path.resolve(process.cwd(), '.env')); +} + +export function requireEnv(name: string) { + const value = process.env[name]?.trim(); + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +export function createSqlClient() { + loadLocalEnv(); + return postgres(requireEnv('DATABASE_URL'), { + prepare: false + }); +} + +export async function ensureAuditArtifactsDir() { + await mkdir(AUDIT_ARTIFACTS_DIR, { recursive: true }); +} + +export async function writeAuditArtifact(fileName: string, payload: unknown) { + await ensureAuditArtifactsDir(); + const targetPath = path.resolve(AUDIT_ARTIFACTS_DIR, fileName); + await writeFile(targetPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + return targetPath; +} + +export function computePayloadHash(value: unknown) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +export function extractTokensFromValue(value: unknown) { + const tokens = new Set(); + + if (typeof value !== 'string') { + return tokens; + } + + for (const match of value.matchAll(/\{([^}]+)\}/g)) { + tokens.add(match[1]); + } + + try { + const parsed = JSON.parse(value) as unknown; + + if (Array.isArray(parsed)) { + for (const row of parsed) { + if (!Array.isArray(row)) { + continue; + } + + for (const cell of row) { + if (typeof cell !== 'string') { + continue; + } + + for (const match of cell.matchAll(/\{([^}]+)\}/g)) { + tokens.add(match[1]); + } + } + } + } + } catch { + return tokens; + } + + return tokens; +} + +export function extractSinglePlaceholder(value: unknown) { + if (typeof value !== 'string') { + return null; + } + + const matches = [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]); + const uniqueMatches = [...new Set(matches)]; + + return uniqueMatches.length === 1 ? uniqueMatches[0] : null; +} + +function normalizeSchemaJson(schemaJson: unknown) { + if (typeof schemaJson === 'string') { + try { + return JSON.parse(schemaJson) as unknown; + } catch { + return null; + } + } + + return schemaJson; +} + +export function extractTemplateFields(schemaJson: unknown) { + const fields: TemplateFieldRecord[] = []; + const normalizedSchemaJson = normalizeSchemaJson(schemaJson); + + if ( + !normalizedSchemaJson || + typeof normalizedSchemaJson !== 'object' || + !('schemas' in normalizedSchemaJson) + ) { + return fields; + } + + const pages = (normalizedSchemaJson as { schemas?: unknown }).schemas; + + if (!Array.isArray(pages)) { + return fields; + } + + for (const [pageIndex, page] of pages.entries()) { + if (!Array.isArray(page)) { + continue; + } + + for (const [fieldIndex, field] of page.entries()) { + if (!field || typeof field !== 'object') { + continue; + } + + const typedField = field as { + name?: unknown; + type?: unknown; + content?: unknown; + head?: unknown; + }; + const tokens = new Set(); + + for (const token of extractTokensFromValue(typedField.content)) { + tokens.add(token); + } + + if (Array.isArray(typedField.head)) { + for (const value of typedField.head) { + for (const token of extractTokensFromValue(value)) { + tokens.add(token); + } + } + } + + fields.push({ + pageIndex, + fieldIndex, + name: typeof typedField.name === 'string' ? typedField.name : '', + type: typeof typedField.type === 'string' ? typedField.type : 'unknown', + tokens: [...tokens] + }); + } + } + + return fields; +} + +export function getDuplicateFieldNames(fields: TemplateFieldRecord[]) { + const fieldNameCounts = new Map(); + + for (const field of fields) { + if (!field.name) { + continue; + } + + fieldNameCounts.set(field.name, (fieldNameCounts.get(field.name) ?? 0) + 1); + } + + return [...fieldNameCounts.entries()] + .filter(([, count]) => count > 1) + .map(([name]) => name) + .sort(); +} + +export function getUnknownFieldNames( + fields: TemplateFieldRecord[], + mappedKeys: Set = new Set() +) { + 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' + ) + .map((field) => field.name) + .sort(); +} + +export function isDesignerLabelField(field: TemplateFieldRecord) { + return ( + field.type === 'text' && + field.tokens.length === 0 && + DESIGNER_LABEL_NAME_PATTERNS.some((pattern) => pattern.test(field.name)) + ); +} + +export function getEffectiveMappedKeys( + fields: TemplateFieldRecord[], + mappings: Array<{ placeholderKey: string }> +) { + const mappedKeys = new Set(mappings.map((mapping) => mapping.placeholderKey)); + + for (const field of fields) { + if (!field.name || field.tokens.length !== 1) { + continue; + } + + const [token] = field.tokens; + + if (mappedKeys.has(field.name) && !mappedKeys.has(token)) { + mappedKeys.add(token); + } + + if (mappedKeys.has(token) && !mappedKeys.has(field.name)) { + mappedKeys.add(field.name); + } + } + + return mappedKeys; +} + +export function normalizeTemplateInputAliases( + fields: TemplateFieldRecord[], + templateInput: Record +) { + const normalized = { ...templateInput }; + + for (const field of fields) { + if (!field.name || field.tokens.length !== 1) { + continue; + } + + const [token] = field.tokens; + + if (!token || field.name === token) { + continue; + } + + if (normalized[field.name] !== undefined && normalized[token] === undefined) { + normalized[token] = normalized[field.name]; + } + + if (normalized[token] !== undefined && normalized[field.name] === undefined) { + normalized[field.name] = normalized[token]; + } + } + + return normalized; +} + +export async function getActiveTemplateVersions(sql: SqlClient): Promise { + const rows = await sql` + select + t.id as "templateId", + t.template_name as "templateName", + t.organization_id as "organizationId", + t.document_type as "documentType", + t.product_type as "productType", + t.file_type as "fileType", + v.id as "versionId", + v.version, + v.schema_json as "schemaJson" + from crm_document_templates t + inner join crm_document_template_versions v + on v.template_id = t.id + where t.is_active = true + and v.is_active = true + and t.deleted_at is null + and v.deleted_at is null + order by t.organization_id asc, t.template_name asc, v.created_at asc + `; + + return castRows(rows); +} + +export async function getMappingsForVersion(sql: SqlClient, templateVersionId: string) { + const [mappings, columns] = await Promise.all([ + sql` + select + id, + organization_id as "organizationId", + template_version_id as "templateVersionId", + placeholder_key as "placeholderKey", + source_path as "sourcePath", + data_type as "dataType", + default_value as "defaultValue", + format_mask as "formatMask", + sort_order as "sortOrder" + from crm_document_template_mappings + where template_version_id = ${templateVersionId} + and deleted_at is null + order by sort_order asc, created_at asc + `, + sql` + select + mapping_id as "mappingId", + column_name as "columnName", + source_field as "sourceField", + sort_order as "sortOrder", + format_mask as "formatMask" + from crm_document_template_table_columns + where deleted_at is null + order by sort_order asc, created_at asc + ` + ]); + + return { + mappings: castRows(mappings), + columns: castRows(columns) + }; +} + +export async function findAuditQuotation(sql: SqlClient) { + const [fixtureQuotation] = await sql` + select + q.id, + q.code, + q.organization_id as "organizationId", + q.customer_id as "customerId", + q.contact_id as "contactId", + q.quotation_date as "quotationDate", + q.valid_until as "validUntil", + q.project_name as "projectName", + q.project_location as "projectLocation", + q.attention, + q.reference, + q.currency, + q.total_amount as "totalAmount", + q.approved_template_version_id as "approvedTemplateVersionId", + 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 + where q.code = ${AUDIT_QUOTATION_CODE} + and q.deleted_at is null + limit 1 + `; + + if (fixtureQuotation) { + return fixtureQuotation; + } + + const [fallbackQuotation] = await sql` + select + q.id, + q.code, + q.organization_id as "organizationId", + q.customer_id as "customerId", + q.contact_id as "contactId", + q.quotation_date as "quotationDate", + q.valid_until as "validUntil", + q.project_name as "projectName", + q.project_location as "projectLocation", + q.attention, + q.reference, + q.currency, + q.total_amount as "totalAmount", + 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 + where q.deleted_at is null + order by q.updated_at desc + limit 1 + `; + + if (!fallbackQuotation) { + throw new Error('No quotation found for PDF audit'); + } + + return fallbackQuotation; +} + +function getValueByPath(source: unknown, valuePath: string): unknown { + return valuePath.split('.').reduce((current, segment) => { + if (current === null || current === undefined) { + return undefined; + } + + if (Array.isArray(current) && /^\d+$/.test(segment)) { + return current[Number(segment)]; + } + + if (typeof current === 'object' && current !== null && segment in current) { + return (current as Record)[segment]; + } + + return undefined; + }, source); +} + +function formatPdfDate(dateString: string | Date | null | undefined) { + if (!dateString) { + return '-'; + } + + const date = dateString instanceof Date ? dateString : new Date(dateString); + + if (Number.isNaN(date.getTime())) { + return '-'; + } + + return date.toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric' + }); +} + +function formatPdfCurrency(amount: number | string | null | undefined, currencyCode = 'THB') { + if (amount === null || amount === undefined) { + return '-'; + } + + const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', '')); + + if (!Number.isFinite(normalized)) { + return '-'; + } + + const symbols: Record = { + THB: '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 formatTopicItems(items: Array<{ content?: string | null; sortOrder?: number | null }> = []) { + if (!items.length) { + return [['-']]; + } + + return [...items] + .sort((left, right) => (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER)) + .map((item) => [item.content?.trim() || '-']); +} + +function applyFormatMask(value: unknown, formatMask?: string | null) { + if (value === null || value === undefined) { + return value; + } + + if (formatMask === 'currency' || formatMask?.startsWith('currency_')) { + const currencyCode = formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', ''); + return formatPdfCurrency(value as number | string, currencyCode); + } + + if (formatMask === 'date' || formatMask === 'date_en') { + return formatPdfDate(value as string); + } + + return value; +} + +function normalizePdfmeTable( + value: unknown, + columns?: Array<{ sourceField: string; formatMask?: string | null }> +) { + if (!Array.isArray(value) || value.length === 0) { + return [['-']]; + } + + if (value.every((row) => Array.isArray(row))) { + return (value as unknown[][]).map((row) => row.map((cell) => String(cell ?? '-').trim() || '-')); + } + + if (!columns?.length) { + return value.map((row) => [String(row ?? '-').trim() || '-']); + } + + return value.map((row) => { + const rowRecord = row as Record; + return columns.map((column) => { + const formatted = applyFormatMask(rowRecord[column.sourceField], column.formatMask); + return String(formatted ?? '-').trim() || '-'; + }); + }); +} + +function mapDocumentDataToTemplateInput( + documentData: Record, + mappings: MappingRecord[], + columns: MappingColumnRecord[] +) { + const result: Record = {}; + const columnMap = new Map(); + + for (const column of columns) { + const items = columnMap.get(column.mappingId) ?? []; + items.push(column); + columnMap.set(column.mappingId, items); + } + + for (const mapping of mappings) { + const rawValue = getValueByPath(documentData, mapping.sourcePath); + + if (mapping.dataType === 'table') { + const mappingColumns = columnMap.get(mapping.id) ?? []; + result[mapping.placeholderKey] = normalizePdfmeTable(rawValue, mappingColumns); + continue; + } + + if (mapping.dataType === 'multiline') { + if (Array.isArray(rawValue)) { + result[mapping.placeholderKey] = rawValue + .map((item) => String(item ?? '').trim()) + .filter(Boolean) + .join('\n'); + continue; + } + + result[mapping.placeholderKey] = + typeof rawValue === 'string' ? rawValue : mapping.defaultValue ?? ''; + continue; + } + + result[mapping.placeholderKey] = + applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '-'; + } + + return result; +} + +function normalizeBusinessRoleLabel(businessRole: string | null | undefined) { + if (!businessRole) { + return '-'; + } + + return ( + SIGNATURE_ROLE_FALLBACK_LABELS[businessRole] ?? + businessRole + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + ); +} + +function buildTopicInputs(topics: Array<{ topicType: string; items: Array<{ content: string; sortOrder: number }> }>) { + const sortedTopics = [...topics].sort((left, right) => left.topicType.localeCompare(right.topicType)); + const topicInputs: Record = {}; + + for (const [index, topic] of sortedTopics.entries()) { + topicInputs[`topic_1_${index}`] = [[topic.topicType || '-']]; + topicInputs[`item_topic_1_${index}`] = formatTopicItems(topic.items); + } + + return topicInputs; +} + +export async function buildAuditPayload(sql: SqlClient): Promise { + const quotation = await findAuditQuotation(sql); + const [organization, customer, contact, itemRows, topicRows, topicItemRows, partyRows, jobTitles] = + await Promise.all([ + sql` + select id, name + from organizations + where id = ${quotation.organizationId} + limit 1 + `.then((rows) => rows[0] ?? null), + sql` + select id, name, address, phone, email + from crm_customers + where id = ${quotation.customerId} + limit 1 + `.then((rows) => rows[0] ?? null), + quotation.contactId + ? sql` + select id, name, email, mobile, position + from crm_customer_contacts + where id = ${quotation.contactId} + limit 1 + `.then((rows) => rows[0] ?? null) + : Promise.resolve(null), + sql` + select + qi.id, + qi.item_number as "itemNumber", + qi.description, + qi.quantity, + qi.unit, + qi.unit_price as "unitPrice", + qi.discount, + qi.total_price as "totalPrice", + qi.sort_order as "sortOrder", + u.label as "unitLabel" + from crm_quotation_items qi + left join ms_options u + on u.id = qi.unit + where qi.quotation_id = ${quotation.id} + and qi.deleted_at is null + order by qi.sort_order asc, qi.item_number asc + `, + sql` + select + qt.id, + qt.title, + qt.sort_order as "sortOrder", + qt.topic_type as "topicTypeId", + o.code as "topicCode", + coalesce(o.label, qt.title) as "topicLabel" + from crm_quotation_topics qt + left join ms_options o + on o.id = qt.topic_type + where qt.quotation_id = ${quotation.id} + and qt.deleted_at is null + order by qt.sort_order asc, qt.created_at asc + `, + sql` + select + qti.id, + qti.topic_id as "topicId", + qti.content, + qti.sort_order as "sortOrder" + from crm_quotation_topic_items qti + where qti.deleted_at is null + and qti.topic_id in ( + select id + from crm_quotation_topics + where quotation_id = ${quotation.id} + and deleted_at is null + ) + order by qti.sort_order asc, qti.created_at asc + `, + sql` + select + qc.id, + qc.customer_id as "customerId", + qc.role, + qc.is_primary as "isPrimary" + from crm_quotation_customers qc + where qc.quotation_id = ${quotation.id} + and qc.deleted_at is null + `, + sql` + select code, label + from ms_options + where organization_id = ${quotation.organizationId} + and category = 'crm_job_title' + and is_active = true + and deleted_at is null + ` + ]); + + if (!organization || !customer) { + throw new Error('Audit quotation references missing organization or customer'); + } + + const activeTemplateVersions = await getActiveTemplateVersions(sql); + const templateVersion = activeTemplateVersions.find( + (item) => item.organizationId === quotation.organizationId && item.documentType === 'quotation' + ); + + if (!templateVersion) { + throw new Error(`No active quotation template version found for organization ${quotation.organizationId}`); + } + + const { mappings, columns } = await getMappingsForVersion(sql, templateVersion.versionId); + const topicItemsByTopicId = new Map>(); + + for (const topicItemRow of castRows<{ topicId: string; content: string; sortOrder: number }>( + topicItemRows + )) { + const items = topicItemsByTopicId.get(topicItemRow.topicId) ?? []; + items.push({ + content: topicItemRow.content, + sortOrder: topicItemRow.sortOrder + }); + topicItemsByTopicId.set(topicItemRow.topicId, items); + } + + const topics = (topicRows as Array>).map((topicRow) => ({ + id: String(topicRow.id), + title: String(topicRow.title ?? topicRow.topicLabel ?? 'Topic'), + topicCode: String(topicRow.topicCode ?? ''), + topicType: String(topicRow.topicLabel ?? topicRow.title ?? 'Topic'), + items: topicItemsByTopicId.get(String(topicRow.id)) ?? [] + })); + const exclusionTopic = topics.find((topic) => topic.topicCode === 'exclusion') ?? topics[0] ?? null; + const topicInputs = buildTopicInputs( + topics.map((topic) => ({ + topicType: topic.topicType, + items: topic.items + })) + ); + + const [approvalActions, userRows, membershipRows] = await Promise.all([ + sql` + select + aa.acted_by as "actedBy", + aa.acted_at as "actedAt", + aa.action, + aa.step_number as "stepNumber", + aps.role_code as "roleCode", + u.name as "actorName" + from crm_approval_actions aa + inner join crm_approval_requests ar + on ar.id = aa.approval_request_id + left join crm_approval_steps aps + on aps.workflow_id = ar.workflow_id + and aps.step_number = aa.step_number + and aps.deleted_at is null + left join users u + on u.id = aa.acted_by + where ar.entity_type = 'quotation' + and ar.entity_id = ${quotation.id} + and ar.organization_id = ${quotation.organizationId} + and aa.deleted_at is null + order by aa.acted_at asc, aa.created_at asc + `, + sql` + select id, name + from users + where id in (${quotation.salesmanId ?? quotation.createdBy}, ${quotation.createdBy}) + `, + sql` + select user_id as "userId", business_role as "businessRole" + from memberships + where organization_id = ${quotation.organizationId} + ` + ]); + const userMap = new Map( + castRows<{ id: string; name: string }>(userRows).map((row) => [row.id, row.name]) + ); + const membershipMap = new Map( + castRows<{ userId: string; businessRole: string }>(membershipRows).map((row) => [ + row.userId, + row.businessRole + ]) + ); + const jobTitleMap = new Map( + castRows<{ code: string; label: string }>(jobTitles).map((row) => [row.code, row.label]) + ); + const approvedActions = (approvalActions as Array>).filter( + (row) => row.action === 'approve' + ); + const finalApprovedAction = approvedActions.at(-1) ?? null; + const approvedByAction = + approvedActions.filter((row) => row.roleCode !== finalApprovedAction?.roleCode).at(-1) ?? null; + const preparedByUserId = (quotation.salesmanId as string | null) ?? (quotation.createdBy as string); + + const resolvePosition = (userId: string | null | undefined, roleCode?: string | null) => { + const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null; + return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-'; + }; + + const documentData: Record = { + company: { + id: organization.id, + name: organization.name + }, + customer: { + id: customer.id, + name: customer.name, + address: customer.address, + phone: customer.phone, + email: customer.email + }, + contact: contact + ? { + id: contact.id, + name: contact.name, + email: contact.email, + mobile: contact.mobile, + position: contact.position + } + : null, + quotation: { + id: quotation.id, + code: quotation.code, + quotationDate: quotation.quotationDate, + validUntil: quotation.validUntil, + projectName: quotation.projectName, + projectLocation: quotation.projectLocation, + attention: quotation.attention, + reference: quotation.reference, + currency: quotation.currency, + totalAmount: quotation.totalAmount + }, + items: (itemRows as Array>).map((row) => ({ + id: row.id, + itemNumber: row.itemNumber, + description: row.description, + quantity: row.quantity, + unitLabel: row.unitLabel ?? row.unit ?? '-', + unitPrice: row.unitPrice, + discount: row.discount, + totalPrice: row.totalPrice + })), + quotationCustomers: partyRows, + topics: { + all: topics, + exclusion: exclusionTopic ? [exclusionTopic] : [] + }, + pdfme: { + quotation_date: formatPdfDate(quotation.quotationDate as string), + quotation_price: formatPdfCurrency(quotation.totalAmount as number, 'THB'), + exclusion_data: formatTopicItems(exclusionTopic?.items ?? []), + topic_inputs: topicInputs + }, + signatures: { + preparedBy: { + name: userMap.get(preparedByUserId) ?? '-', + position: resolvePosition(preparedByUserId), + actedAt: new Date(String(quotation.createdAt)).toISOString() + }, + approvedBy: { + name: String(approvedByAction?.actorName ?? '-'), + position: resolvePosition( + (approvedByAction?.actedBy as string | null | undefined) ?? null, + (approvedByAction?.roleCode as string | null | undefined) ?? null + ), + actedAt: approvedByAction?.actedAt ? new Date(String(approvedByAction.actedAt)).toISOString() : null + }, + authorizedBy: { + name: String(finalApprovedAction?.actorName ?? '-'), + position: resolvePosition( + (finalApprovedAction?.actedBy as string | null | undefined) ?? null, + (finalApprovedAction?.roleCode as string | null | undefined) ?? null + ), + actedAt: finalApprovedAction?.actedAt + ? new Date(String(finalApprovedAction.actedAt)).toISOString() + : null + } + } + }; + + const templateInput = { + ...mapDocumentDataToTemplateInput(documentData, mappings, columns), + ...topicInputs + }; + const normalizedTemplateInput = normalizeTemplateInputAliases( + extractTemplateFields(templateVersion.schemaJson), + templateInput + ); + + return { + organizationId: quotation.organizationId, + quotationId: quotation.id, + quotationCode: quotation.code, + templateVersionId: templateVersion.versionId, + approvedTemplateVersionId: + typeof quotation.approvedTemplateVersionId === 'string' + ? quotation.approvedTemplateVersionId + : null, + templateName: templateVersion.templateName, + templateVersion: templateVersion.version, + templateSchema: templateVersion.schemaJson, + mappings, + mappingColumns: columns, + documentData, + templateInput: normalizedTemplateInput, + topicInputs, + quotationMetrics: { + topicCount: topics.length, + itemCount: (itemRows as Array).length, + partyCount: (partyRows as Array).length + }, + approvedSnapshot: + quotation.approvedSnapshot && typeof quotation.approvedSnapshot === 'object' + ? (quotation.approvedSnapshot as Record) + : null, + approvedArtifactReference: + typeof quotation.approvedPdfUrl === 'string' ? quotation.approvedPdfUrl : null + }; +} + +export function summarizeIssues(statuses: Array<'PASS' | 'WARNING' | 'FAIL'>) { + if (statuses.includes('FAIL')) { + return 'FAIL'; + } + + if (statuses.includes('WARNING')) { + return 'WARNING'; + } + + return 'PASS'; +} + +function castRows(rows: unknown): T[] { + return rows as T[]; +} + +export interface ComputedSnapshotPayload { + quotationId: string; + approvedAt: string | null; + documentData: Record; + templateVersionId: string; + templateInput: Record; + generatedAt: string; + generatedBy: string; +} + +export function buildComputedSnapshotPayload( + payload: AuditPayload, + options?: { generatedAt?: string; generatedBy?: string } +): ComputedSnapshotPayload { + return { + quotationId: payload.quotationId, + approvedAt: + typeof (payload.documentData.approval as Record | undefined)?.approvedAt === + 'string' + ? ((payload.documentData.approval as Record).approvedAt as string) + : null, + documentData: payload.documentData, + templateVersionId: payload.templateVersionId, + templateInput: payload.templateInput, + generatedAt: options?.generatedAt ?? 'audit-generated', + generatedBy: options?.generatedBy ?? 'audit-suite' + }; +} + +export function getSnapshotTemplateInput(snapshot: Record | null) { + if (!snapshot) { + return null; + } + + return snapshot.templateInput && typeof snapshot.templateInput === 'object' + ? (snapshot.templateInput as Record) + : null; +} + +export function getSnapshotDocumentData(snapshot: Record | null) { + if (!snapshot) { + return null; + } + + return snapshot.documentData && typeof snapshot.documentData === 'object' + ? (snapshot.documentData as Record) + : null; +} diff --git a/scripts/seed-task-h1-fixture.js b/scripts/seed-task-h1-fixture.js index 6b3b1d5..db3f4dc 100644 --- a/scripts/seed-task-h1-fixture.js +++ b/scripts/seed-task-h1-fixture.js @@ -7,7 +7,10 @@ const TASK_H1_PASSWORD = 'TaskH1Pass!2026'; const FIXTURE_CODES = { customer: 'CUS-TASK-H1', approvedQuotation: 'QT-TASK-H1-APPROVED', - draftQuotation: 'QT-TASK-H1-DRAFT' + draftQuotation: 'QT-TASK-H1-DRAFT', + auditQuotation: 'QT-H5-AUDIT', + billingCustomer: 'CUS-TASK-H5-BILL', + consultantCustomer: 'CUS-TASK-H5-CONSULT' }; const FIXTURE_USERS = { @@ -373,6 +376,88 @@ async function ensureCustomerAndContact(sql, organization, creatorId, optionIds) }; } +async function ensureRelatedCustomer(sql, organization, creatorId, optionIds, definition) { + const existing = + ( + await sql` + select id + from crm_customers + where organization_id = ${organization.id} + and code = ${definition.code} + limit 1 + ` + )[0] ?? null; + const customerId = existing?.id ?? crypto.randomUUID(); + + if (existing) { + await sql` + update crm_customers + set + branch_id = ${optionIds.branchId}, + name = ${definition.name}, + abbr = ${definition.abbr}, + tax_id = ${definition.taxId}, + customer_type = ${optionIds.customerTypeCompanyId}, + customer_status = ${optionIds.customerStatusActiveId}, + address = ${definition.address}, + phone = ${definition.phone}, + email = ${definition.email}, + lead_channel = ${optionIds.leadChannelSalesId}, + customer_group = ${optionIds.customerGroupStandardId}, + notes = ${definition.notes}, + is_active = true, + deleted_at = null, + updated_at = now(), + updated_by = ${creatorId} + where id = ${customerId} + `; + } else { + await sql` + insert into crm_customers ( + id, + organization_id, + branch_id, + code, + name, + abbr, + tax_id, + customer_type, + customer_status, + address, + phone, + email, + lead_channel, + customer_group, + notes, + is_active, + created_by, + updated_by + ) values ( + ${customerId}, + ${organization.id}, + ${optionIds.branchId}, + ${definition.code}, + ${definition.name}, + ${definition.abbr}, + ${definition.taxId}, + ${optionIds.customerTypeCompanyId}, + ${optionIds.customerStatusActiveId}, + ${definition.address}, + ${definition.phone}, + ${definition.email}, + ${optionIds.leadChannelSalesId}, + ${optionIds.customerGroupStandardId}, + ${definition.notes}, + true, + ${creatorId}, + ${creatorId} + ) + `; + } + + return customerId; +} + async function upsertQuotation(sql, args) { const { organization, @@ -382,7 +467,9 @@ async function upsertQuotation(sql, args) { customerId, contactId, code, - approved + approved, + fixtureProfile = 'h1', + projectParties = [] } = args; const existing = ( @@ -397,9 +484,24 @@ async function upsertQuotation(sql, args) { const quotationId = existing?.id ?? crypto.randomUUID(); const statusId = approved ? optionIds.quotationStatusApprovedId : optionIds.quotationStatusDraftId; const approvalNow = approved ? new Date().toISOString() : null; - const notes = approved - ? 'Approved quotation fixture for Task H.1 PDF stabilization.' - : 'Draft quotation fixture for Task H.1 negative API validation.'; + const notes = + fixtureProfile === 'h5' + ? 'Approved quotation fixture for Task H.5 PDF mapping and parity audit.' + : approved + ? 'Approved quotation fixture for Task H.1 PDF stabilization.' + : 'Draft quotation fixture for Task H.1 negative API validation.'; + const projectName = + fixtureProfile === 'h5' + ? 'Task H.5 Audit Fixture' + : approved + ? 'Task H.1 Approved PDF Fixture' + : 'Task H.1 Draft PDF Fixture'; + const reference = + fixtureProfile === 'h5' + ? 'TASK-H5-AUDIT' + : approved + ? 'TASK-H1-APPROVED' + : 'TASK-H1-DRAFT'; if (existing) { await sql` @@ -412,10 +514,10 @@ async function upsertQuotation(sql, args) { quotation_date = ${'2026-06-16T00:00:00.000Z'}, valid_until = ${'2026-07-16T00:00:00.000Z'}, quotation_type = ${optionIds.quotationTypeServiceId}, - project_name = ${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'}, + project_name = ${projectName}, project_location = 'Bangkok HQ', attention = 'Task H.1 Contact', - reference = ${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'}, + reference = ${reference}, notes = ${notes}, status = ${statusId}, revision = 0, @@ -507,10 +609,10 @@ async function upsertQuotation(sql, args) { ${'2026-06-16T00:00:00.000Z'}, ${'2026-07-16T00:00:00.000Z'}, ${optionIds.quotationTypeServiceId}, - ${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'}, + ${projectName}, 'Bangkok HQ', 'Task H.1 Contact', - ${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'}, + ${reference}, ${notes}, ${statusId}, 0, @@ -572,77 +674,170 @@ async function upsertQuotation(sql, args) { await sql`delete from crm_quotation_topics where quotation_id = ${quotationId}`; await sql`delete from crm_quotation_items where quotation_id = ${quotationId}`; - await sql` - insert into crm_quotation_items ( - id, - organization_id, - quotation_id, - item_number, - product_type, - description, - quantity, - unit, - unit_price, - discount, - discount_type, - tax_rate, - total_price, - notes, - sort_order - ) values ( - ${crypto.randomUUID()}, - ${organization.id}, - ${quotationId}, - 1, - ${optionIds.productTypeCraneId}, - '1 set overhead crane with installation and commissioning', - 1, - ${optionIds.unitSetId}, - 125000, - 5000, - ${optionIds.discountPercentageId}, - 7, - 120000, - 'Smoke-test commercial line', - 1 - ) - `; + const items = + fixtureProfile === 'h5' + ? [ + { + itemNumber: 1, + description: '1 set overhead crane with installation and commissioning', + quantity: 1, + unitPrice: 125000, + discount: 5000, + totalPrice: 120000, + notes: 'Primary lifting package' + }, + { + itemNumber: 2, + description: 'Load testing and safety certification', + quantity: 1, + unitPrice: 25000, + discount: 0, + totalPrice: 25000, + notes: 'Audit fixture support line' + }, + { + itemNumber: 3, + description: 'Operator training and turnover documentation', + quantity: 2, + unitPrice: 8000, + discount: 0, + totalPrice: 16000, + notes: 'Audit fixture service line' + } + ] + : [ + { + itemNumber: 1, + description: '1 set overhead crane with installation and commissioning', + quantity: 1, + unitPrice: 125000, + discount: 5000, + totalPrice: 120000, + notes: 'Smoke-test commercial line' + } + ]; - await sql` - insert into crm_quotation_customers ( - id, - organization_id, - quotation_id, - customer_id, - role, - is_primary - ) values ( - ${crypto.randomUUID()}, - ${organization.id}, - ${quotationId}, - ${customerId}, - ${optionIds.customerRoleOwnerId}, - true - ) - `; + for (const [index, item] of items.entries()) { + await sql` + insert into crm_quotation_items ( + id, + organization_id, + quotation_id, + item_number, + product_type, + description, + quantity, + unit, + unit_price, + discount, + discount_type, + tax_rate, + total_price, + notes, + sort_order + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${quotationId}, + ${item.itemNumber}, + ${optionIds.productTypeCraneId}, + ${item.description}, + ${item.quantity}, + ${optionIds.unitSetId}, + ${item.unitPrice}, + ${item.discount}, + ${optionIds.discountPercentageId}, + 7, + ${item.totalPrice}, + ${item.notes}, + ${index + 1} + ) + `; + } - const topics = [ - { - typeId: optionIds.topicTypeScopeId, - title: 'Scope of work', - items: ['Supply crane equipment', 'Install and test the system'] - }, - { - typeId: optionIds.topicTypeExclusionId, - title: 'Exclusions', - items: ['Civil work by customer', 'Power feeder by customer'] - }, - { - typeId: optionIds.topicTypePaymentId, - title: 'Payment terms', - items: ['50% upon PO', '40% before delivery', '10% after commissioning'] - } - ]; + const resolvedProjectParties = + projectParties.length > 0 + ? projectParties + : [ + { + customerId, + role: optionIds.customerRoleCustomerId, + isPrimary: true + } + ]; + + for (const [index, party] of resolvedProjectParties.entries()) { + await sql` + insert into crm_quotation_customers ( + id, + organization_id, + quotation_id, + customer_id, + role, + is_primary, + remark + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${quotationId}, + ${party.customerId}, + ${party.role}, + ${party.isPrimary}, + ${party.remark ?? `Fixture party ${index + 1}`} + ) + `; + } + + const topics = + fixtureProfile === 'h5' + ? [ + { + typeId: optionIds.topicTypeScopeId, + title: 'Scope', + items: [ + 'Supply crane equipment', + 'Install and test the system', + 'Perform SAT with project stakeholders' + ] + }, + { + typeId: optionIds.topicTypeExclusionId, + title: 'Exclusion', + items: ['Civil work by customer', 'Power feeder by customer'] + }, + { + typeId: optionIds.topicTypePaymentId, + title: 'Payment', + items: ['50% upon PO', '40% before delivery', '10% after commissioning'] + }, + { + typeId: 'warranty', + title: 'Warranty', + items: ['12 months after commissioning', 'Consumables excluded'] + }, + { + typeId: 'delivery', + title: 'Delivery', + items: ['Within 60 days after approved drawing', 'Subject to final site readiness'] + } + ] + : [ + { + typeId: optionIds.topicTypeScopeId, + title: 'Scope of work', + items: ['Supply crane equipment', 'Install and test the system'] + }, + { + typeId: optionIds.topicTypeExclusionId, + title: 'Exclusions', + items: ['Civil work by customer', 'Power feeder by customer'] + }, + { + typeId: optionIds.topicTypePaymentId, + title: 'Payment terms', + items: ['50% upon PO', '40% before delivery', '10% after commissioning'] + } + ]; for (const [topicIndex, topic] of topics.entries()) { const topicId = crypto.randomUUID(); @@ -851,11 +1046,23 @@ async function main() { 'crm_product_type', 'crane' ), - customerRoleOwnerId: await resolveOptionId( + customerRoleCustomerId: await resolveOptionId( sql, organization.id, - 'crm_quotation_customer_role', - 'owner' + 'crm_project_party_role', + 'customer' + ), + customerRoleBillingCustomerId: await resolveOptionId( + sql, + organization.id, + 'crm_project_party_role', + 'billing_customer' + ), + customerRoleConsultantId: await resolveOptionId( + sql, + organization.id, + 'crm_project_party_role', + 'consultant' ), topicTypeScopeId: await resolveOptionId( sql, @@ -884,6 +1091,32 @@ async function main() { creatorId, optionIds ); + const billingCustomerId = await ensureRelatedCustomer(sql, organization, creatorId, optionIds, { + code: FIXTURE_CODES.billingCustomer, + name: 'Task H.5 Billing Customer', + abbr: 'TASKH5BILL', + taxId: '0105559002233', + address: '88 Audit Park Road, Bangkok 10240', + phone: '02-000-2000', + email: 'billing-customer@local.test', + notes: 'Billing customer for Task H.5 parity fixture' + }); + const consultantCustomerId = await ensureRelatedCustomer( + sql, + organization, + creatorId, + optionIds, + { + code: FIXTURE_CODES.consultantCustomer, + name: 'Task H.5 Consultant', + abbr: 'TASKH5CONS', + taxId: '0105559003344', + address: '77 Review Lane, Bangkok 10110', + phone: '02-000-3000', + email: 'consultant-customer@local.test', + notes: 'Consultant customer for Task H.5 parity fixture' + } + ); const users = { admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', { @@ -930,6 +1163,37 @@ async function main() { code: FIXTURE_CODES.draftQuotation, approved: false }); + const auditQuotationId = await upsertQuotation(sql, { + organization, + creatorId: users.admin.id, + workflow, + optionIds, + customerId, + contactId, + code: FIXTURE_CODES.auditQuotation, + approved: true, + fixtureProfile: 'h5', + projectParties: [ + { + customerId, + role: optionIds.customerRoleCustomerId, + isPrimary: true, + remark: 'End customer' + }, + { + customerId: billingCustomerId, + role: optionIds.customerRoleBillingCustomerId, + isPrimary: false, + remark: 'Billing customer' + }, + { + customerId: consultantCustomerId, + role: optionIds.customerRoleConsultantId, + isPrimary: false, + remark: 'Consultant' + } + ] + }); console.log( JSON.stringify( @@ -939,6 +1203,8 @@ async function main() { approvedQuotationCode: FIXTURE_CODES.approvedQuotation, draftQuotationId, draftQuotationCode: FIXTURE_CODES.draftQuotation, + auditQuotationId, + auditQuotationCode: FIXTURE_CODES.auditQuotation, users, password: TASK_H1_PASSWORD }, diff --git a/src/app/page.tsx b/src/app/page.tsx index f39ca24..2c64fae 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,12 @@ -import { auth } from '@/auth'; -import { redirect } from 'next/navigation'; +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; export default async function Page() { const session = await auth(); if (!session?.user?.id) { - return redirect('/auth/sign-in'); + return redirect("/auth/sign-in"); } else { - redirect('/dashboard'); + redirect("/dashboard/crm"); } } diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 7d99995..71c9e57 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -34,52 +34,52 @@ import { NavGroup } from "@/types"; * Use the `access` property for new items. */ export const navGroups: NavGroup[] = [ - // { - // label: "Overview", - // items: [ - // { - // title: "Dashboard Overview", - // url: "/dashboard", - // icon: "dashboard", - // isActive: false, - // shortcut: ["d", "d"], - // items: [], - // }, - // { - // title: "Workspaces", - // url: "/dashboard/workspaces", - // icon: "workspace", - // isActive: false, - // items: [], - // access: { systemRole: "super_admin" }, - // }, - // { - // title: "Teams", - // url: "/dashboard/workspaces/team", - // icon: "teams", - // isActive: false, - // items: [], - // access: { requireOrg: true, role: "admin" }, - // }, - // { - // title: "Users", - // url: "/dashboard/users", - // icon: "teams", - // shortcut: ["u", "u"], - // isActive: false, - // items: [], - // access: { requireOrg: true, permission: "users:manage" }, - // }, - // { - // title: "Kanban", - // url: "/dashboard/kanban", - // icon: "kanban", - // shortcut: ["k", "k"], - // isActive: false, - // items: [], - // }, - // ], - // }, + { + label: "Overview", + items: [ + { + title: "Dashboard Overview", + url: "/dashboard", + icon: "dashboard", + isActive: false, + shortcut: ["d", "d"], + items: [], + }, + { + title: "Workspaces", + url: "/dashboard/workspaces", + icon: "workspace", + isActive: false, + items: [], + access: { systemRole: "super_admin" }, + }, + { + title: "Teams", + url: "/dashboard/workspaces/team", + icon: "teams", + isActive: false, + items: [], + access: { requireOrg: true, role: "admin" }, + }, + { + title: "Users", + url: "/dashboard/users", + icon: "teams", + shortcut: ["u", "u"], + isActive: false, + items: [], + access: { requireOrg: true, permission: "users:manage" }, + }, + { + title: "Kanban", + url: "/dashboard/kanban", + icon: "kanban", + shortcut: ["k", "k"], + isActive: false, + items: [], + }, + ], + }, { label: "CRM", items: [ diff --git a/src/features/crm/quotations/document/server/service.ts b/src/features/crm/quotations/document/server/service.ts index 5a87056..2748981 100644 --- a/src/features/crm/quotations/document/server/service.ts +++ b/src/features/crm/quotations/document/server/service.ts @@ -87,6 +87,12 @@ function extractSinglePlaceholder(value: string) { return uniqueMatches.length === 1 ? uniqueMatches[0] : null; } +function getFieldName(field: unknown) { + return field && typeof field === 'object' && typeof (field as { name?: unknown }).name === 'string' + ? ((field as { name: string }).name ?? '') + : ''; +} + function normalizeTopicKey(value: string | null | undefined) { return ( value @@ -165,13 +171,26 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record< } const tableField = field as { type?: unknown; content?: unknown }; + const fieldName = getFieldName(field); + const placeholderKey = + typeof tableField.content === 'string' + ? extractSinglePlaceholder(tableField.content) + : null; + + if (fieldName && placeholderKey && fieldName !== placeholderKey) { + if (normalized[fieldName] !== undefined && normalized[placeholderKey] === undefined) { + normalized[placeholderKey] = normalized[fieldName]; + } + + if (normalized[placeholderKey] !== undefined && normalized[fieldName] === undefined) { + normalized[fieldName] = normalized[placeholderKey]; + } + } if (tableField.type !== 'table' || typeof tableField.content !== 'string') { continue; } - const placeholderKey = extractSinglePlaceholder(tableField.content); - if (!placeholderKey) { continue; } diff --git a/storage/organizations/2330c067-2737-4443-bd6e-ccb8a6cf856b/quotations/approved/3ba9c39c-8121-416a-99ed-59c4d120c72f-QT-H5-AUDIT-approved.pdf b/storage/organizations/2330c067-2737-4443-bd6e-ccb8a6cf856b/quotations/approved/3ba9c39c-8121-416a-99ed-59c4d120c72f-QT-H5-AUDIT-approved.pdf new file mode 100644 index 0000000..34e8a6d Binary files /dev/null and b/storage/organizations/2330c067-2737-4443-bd6e-ccb8a6cf856b/quotations/approved/3ba9c39c-8121-416a-99ed-59c4d120c72f-QT-H5-AUDIT-approved.pdf differ diff --git a/tsconfig.json b/tsconfig.json index c276d2c..e76fe6c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx",