pdf task-h
This commit is contained in:
12
artifacts/pdf-audit/payload-parity-report.json
Normal file
12
artifacts/pdf-audit/payload-parity-report.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
30
artifacts/pdf-audit/placeholder-integrity-report.json
Normal file
30
artifacts/pdf-audit/placeholder-integrity-report.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
artifacts/pdf-audit/signature-audit-report.json
Normal file
12
artifacts/pdf-audit/signature-audit-report.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
19
artifacts/pdf-audit/summary.json
Normal file
19
artifacts/pdf-audit/summary.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
artifacts/pdf-audit/summary.md
Normal file
21
artifacts/pdf-audit/summary.md
Normal file
@@ -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
|
||||||
8
artifacts/pdf-audit/template-version-report.json
Normal file
8
artifacts/pdf-audit/template-version-report.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"audit": "template-version-integrity",
|
||||||
|
"overallStatus": "PASS",
|
||||||
|
"activeTemplateCount": 2,
|
||||||
|
"quotationCode": "QT-H5-AUDIT",
|
||||||
|
"approvedTemplateVersionId": null,
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
22
artifacts/pdf-audit/topic-runtime-report.json
Normal file
22
artifacts/pdf-audit/topic-runtime-report.json
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
49
docs/adr/0013-pdf-visual-parity-strategy.md
Normal file
49
docs/adr/0013-pdf-visual-parity-strategy.md
Normal file
@@ -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
|
||||||
34
docs/business/pdf-mapping-registry.md
Normal file
34
docs/business/pdf-mapping-registry.md
Normal file
@@ -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
|
||||||
36
docs/business/pdf-placeholder-registry.md
Normal file
36
docs/business/pdf-placeholder-registry.md
Normal file
@@ -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
|
||||||
69
docs/implementation/pdf-audit-report.json
Normal file
69
docs/implementation/pdf-audit-report.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
docs/implementation/pdf-audit-report.md
Normal file
30
docs/implementation/pdf-audit-report.md
Normal file
@@ -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
|
||||||
37
docs/implementation/pdf-parity-checklist.md
Normal file
37
docs/implementation/pdf-parity-checklist.md
Normal file
@@ -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.
|
||||||
@@ -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
|
||||||
40
docs/implementation/task-h51-pdf-integrity-audit.md
Normal file
40
docs/implementation/task-h51-pdf-integrity-audit.md
Normal file
@@ -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
|
||||||
@@ -212,6 +212,7 @@ Future:
|
|||||||
|
|
||||||
- keep validating pagination and closing-block placement against production designer expectations for large topic sets
|
- keep validating pagination and closing-block placement against production designer expectations for large topic sets
|
||||||
- add fixture-backed end-to-end PDF generation verification in CI once a stable runtime path is available
|
- add fixture-backed end-to-end PDF generation verification in CI once a stable runtime path is available
|
||||||
|
- decide whether static template field `line` should remain a tolerated designer-only artifact or be normalized in future audits
|
||||||
|
|
||||||
## After Task I
|
## After Task I
|
||||||
|
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -20,6 +20,16 @@
|
|||||||
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
||||||
"verify:task-h1": "node scripts/verify-task-h1.js",
|
"verify:task-h1": "node scripts/verify-task-h1.js",
|
||||||
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
||||||
|
"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"
|
"setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
324
plans/task-h.5.1.md
Normal file
324
plans/task-h.5.1.md
Normal file
@@ -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
|
||||||
512
plans/task-h.5.md
Normal file
512
plans/task-h.5.md
Normal file
@@ -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
|
||||||
67
scripts/audit-approved-snapshot-parity.ts
Normal file
67
scripts/audit-approved-snapshot-parity.ts
Normal file
@@ -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<string, unknown>) ?? {}
|
||||||
|
);
|
||||||
|
const storedSignatureHash = storedDocumentData
|
||||||
|
? computePayloadHash((storedDocumentData.signatures as Record<string, unknown>) ?? {})
|
||||||
|
: 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);
|
||||||
|
});
|
||||||
81
scripts/audit-payload-parity.ts
Normal file
81
scripts/audit-payload-parity.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
buildAuditPayload,
|
||||||
|
buildComputedSnapshotPayload,
|
||||||
|
computePayloadHash,
|
||||||
|
createSqlClient,
|
||||||
|
summarizeIssues,
|
||||||
|
writeAuditArtifact
|
||||||
|
} from './pdf-audit-utils.ts';
|
||||||
|
|
||||||
|
function pickComparablePayload(payload: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
templateInput: payload.templateInput,
|
||||||
|
topicInputKeys:
|
||||||
|
payload.topicInputs && typeof payload.topicInputs === 'object'
|
||||||
|
? Object.keys(payload.topicInputs as Record<string, unknown>).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<string, unknown>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
125
scripts/audit-pdf-mapping-coverage.ts
Normal file
125
scripts/audit-pdf-mapping-coverage.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
createSqlClient,
|
||||||
|
extractTemplateFields,
|
||||||
|
getActiveTemplateVersions,
|
||||||
|
getEffectiveMappedKeys,
|
||||||
|
getMappingsForVersion,
|
||||||
|
summarizeIssues
|
||||||
|
} from './pdf-audit-utils.ts';
|
||||||
|
|
||||||
|
function classifyToken(token: string, mappedKeys: Set<string>) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
153
scripts/audit-pdf-runtime-payload.ts
Normal file
153
scripts/audit-pdf-runtime-payload.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
89
scripts/audit-pdf-template-inventory.ts
Normal file
89
scripts/audit-pdf-template-inventory.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
105
scripts/audit-placeholder-integrity.ts
Normal file
105
scripts/audit-placeholder-integrity.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
166
scripts/audit-template-version-integrity.ts
Normal file
166
scripts/audit-template-version-integrity.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, typeof activeTemplateVersions>();
|
||||||
|
|
||||||
|
for (const record of activeTemplateVersions) {
|
||||||
|
const bucketKey = [
|
||||||
|
record.organizationId,
|
||||||
|
record.documentType,
|
||||||
|
record.productType,
|
||||||
|
record.fileType
|
||||||
|
].join(':');
|
||||||
|
const items = templateBuckets.get(bucketKey) ?? [];
|
||||||
|
items.push(record);
|
||||||
|
templateBuckets.set(bucketKey, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, Array<Record<string, unknown>>>();
|
||||||
|
|
||||||
|
for (const row of versionRows as Array<Record<string, unknown>>) {
|
||||||
|
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<Record<string, unknown>>) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
194
scripts/generate-pdf-audit-report.ts
Normal file
194
scripts/generate-pdf-audit-report.ts
Normal file
@@ -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<string, unknown> | 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);
|
||||||
|
});
|
||||||
115
scripts/generate-pdf-integrity-report.ts
Normal file
115
scripts/generate-pdf-integrity-report.ts
Normal file
@@ -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<T>(fileName: string): Promise<T | null> {
|
||||||
|
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<Record<string, unknown>>(
|
||||||
|
'template-version-report.json'
|
||||||
|
);
|
||||||
|
const placeholderReport = await readReport<Record<string, unknown>>(
|
||||||
|
'placeholder-integrity-report.json'
|
||||||
|
);
|
||||||
|
const payloadReport = await readReport<Record<string, unknown>>('payload-parity-report.json');
|
||||||
|
const snapshotReport = await readReport<Record<string, unknown>>('signature-audit-report.json');
|
||||||
|
const statuses = [
|
||||||
|
String(templateVersionReport?.overallStatus ?? 'WARNING'),
|
||||||
|
String(placeholderReport?.overallStatus ?? 'WARNING'),
|
||||||
|
String(payloadReport?.overallStatus ?? 'WARNING'),
|
||||||
|
String(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);
|
||||||
|
});
|
||||||
1089
scripts/pdf-audit-utils.ts
Normal file
1089
scripts/pdf-audit-utils.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,10 @@ const TASK_H1_PASSWORD = 'TaskH1Pass!2026';
|
|||||||
const FIXTURE_CODES = {
|
const FIXTURE_CODES = {
|
||||||
customer: 'CUS-TASK-H1',
|
customer: 'CUS-TASK-H1',
|
||||||
approvedQuotation: 'QT-TASK-H1-APPROVED',
|
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 = {
|
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) {
|
async function upsertQuotation(sql, args) {
|
||||||
const {
|
const {
|
||||||
organization,
|
organization,
|
||||||
@@ -382,7 +467,9 @@ async function upsertQuotation(sql, args) {
|
|||||||
customerId,
|
customerId,
|
||||||
contactId,
|
contactId,
|
||||||
code,
|
code,
|
||||||
approved
|
approved,
|
||||||
|
fixtureProfile = 'h1',
|
||||||
|
projectParties = []
|
||||||
} = args;
|
} = args;
|
||||||
const existing =
|
const existing =
|
||||||
(
|
(
|
||||||
@@ -397,9 +484,24 @@ async function upsertQuotation(sql, args) {
|
|||||||
const quotationId = existing?.id ?? crypto.randomUUID();
|
const quotationId = existing?.id ?? crypto.randomUUID();
|
||||||
const statusId = approved ? optionIds.quotationStatusApprovedId : optionIds.quotationStatusDraftId;
|
const statusId = approved ? optionIds.quotationStatusApprovedId : optionIds.quotationStatusDraftId;
|
||||||
const approvalNow = approved ? new Date().toISOString() : null;
|
const approvalNow = approved ? new Date().toISOString() : null;
|
||||||
const notes = approved
|
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.'
|
? 'Approved quotation fixture for Task H.1 PDF stabilization.'
|
||||||
: 'Draft quotation fixture for Task H.1 negative API validation.';
|
: '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) {
|
if (existing) {
|
||||||
await sql`
|
await sql`
|
||||||
@@ -412,10 +514,10 @@ async function upsertQuotation(sql, args) {
|
|||||||
quotation_date = ${'2026-06-16T00:00:00.000Z'},
|
quotation_date = ${'2026-06-16T00:00:00.000Z'},
|
||||||
valid_until = ${'2026-07-16T00:00:00.000Z'},
|
valid_until = ${'2026-07-16T00:00:00.000Z'},
|
||||||
quotation_type = ${optionIds.quotationTypeServiceId},
|
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',
|
project_location = 'Bangkok HQ',
|
||||||
attention = 'Task H.1 Contact',
|
attention = 'Task H.1 Contact',
|
||||||
reference = ${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
|
reference = ${reference},
|
||||||
notes = ${notes},
|
notes = ${notes},
|
||||||
status = ${statusId},
|
status = ${statusId},
|
||||||
revision = 0,
|
revision = 0,
|
||||||
@@ -507,10 +609,10 @@ async function upsertQuotation(sql, args) {
|
|||||||
${'2026-06-16T00:00:00.000Z'},
|
${'2026-06-16T00:00:00.000Z'},
|
||||||
${'2026-07-16T00:00:00.000Z'},
|
${'2026-07-16T00:00:00.000Z'},
|
||||||
${optionIds.quotationTypeServiceId},
|
${optionIds.quotationTypeServiceId},
|
||||||
${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'},
|
${projectName},
|
||||||
'Bangkok HQ',
|
'Bangkok HQ',
|
||||||
'Task H.1 Contact',
|
'Task H.1 Contact',
|
||||||
${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
|
${reference},
|
||||||
${notes},
|
${notes},
|
||||||
${statusId},
|
${statusId},
|
||||||
0,
|
0,
|
||||||
@@ -572,6 +674,50 @@ async function upsertQuotation(sql, args) {
|
|||||||
await sql`delete from crm_quotation_topics where quotation_id = ${quotationId}`;
|
await sql`delete from crm_quotation_topics where quotation_id = ${quotationId}`;
|
||||||
await sql`delete from crm_quotation_items where quotation_id = ${quotationId}`;
|
await sql`delete from crm_quotation_items where quotation_id = ${quotationId}`;
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
await sql`
|
await sql`
|
||||||
insert into crm_quotation_items (
|
insert into crm_quotation_items (
|
||||||
id,
|
id,
|
||||||
@@ -593,21 +739,34 @@ async function upsertQuotation(sql, args) {
|
|||||||
${crypto.randomUUID()},
|
${crypto.randomUUID()},
|
||||||
${organization.id},
|
${organization.id},
|
||||||
${quotationId},
|
${quotationId},
|
||||||
1,
|
${item.itemNumber},
|
||||||
${optionIds.productTypeCraneId},
|
${optionIds.productTypeCraneId},
|
||||||
'1 set overhead crane with installation and commissioning',
|
${item.description},
|
||||||
1,
|
${item.quantity},
|
||||||
${optionIds.unitSetId},
|
${optionIds.unitSetId},
|
||||||
125000,
|
${item.unitPrice},
|
||||||
5000,
|
${item.discount},
|
||||||
${optionIds.discountPercentageId},
|
${optionIds.discountPercentageId},
|
||||||
7,
|
7,
|
||||||
120000,
|
${item.totalPrice},
|
||||||
'Smoke-test commercial line',
|
${item.notes},
|
||||||
1
|
${index + 1}
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedProjectParties =
|
||||||
|
projectParties.length > 0
|
||||||
|
? projectParties
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
customerId,
|
||||||
|
role: optionIds.customerRoleCustomerId,
|
||||||
|
isPrimary: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, party] of resolvedProjectParties.entries()) {
|
||||||
await sql`
|
await sql`
|
||||||
insert into crm_quotation_customers (
|
insert into crm_quotation_customers (
|
||||||
id,
|
id,
|
||||||
@@ -615,18 +774,54 @@ async function upsertQuotation(sql, args) {
|
|||||||
quotation_id,
|
quotation_id,
|
||||||
customer_id,
|
customer_id,
|
||||||
role,
|
role,
|
||||||
is_primary
|
is_primary,
|
||||||
|
remark
|
||||||
) values (
|
) values (
|
||||||
${crypto.randomUUID()},
|
${crypto.randomUUID()},
|
||||||
${organization.id},
|
${organization.id},
|
||||||
${quotationId},
|
${quotationId},
|
||||||
${customerId},
|
${party.customerId},
|
||||||
${optionIds.customerRoleOwnerId},
|
${party.role},
|
||||||
true
|
${party.isPrimary},
|
||||||
|
${party.remark ?? `Fixture party ${index + 1}`}
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
const topics = [
|
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,
|
typeId: optionIds.topicTypeScopeId,
|
||||||
title: 'Scope of work',
|
title: 'Scope of work',
|
||||||
@@ -851,11 +1046,23 @@ async function main() {
|
|||||||
'crm_product_type',
|
'crm_product_type',
|
||||||
'crane'
|
'crane'
|
||||||
),
|
),
|
||||||
customerRoleOwnerId: await resolveOptionId(
|
customerRoleCustomerId: await resolveOptionId(
|
||||||
sql,
|
sql,
|
||||||
organization.id,
|
organization.id,
|
||||||
'crm_quotation_customer_role',
|
'crm_project_party_role',
|
||||||
'owner'
|
'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(
|
topicTypeScopeId: await resolveOptionId(
|
||||||
sql,
|
sql,
|
||||||
@@ -884,6 +1091,32 @@ async function main() {
|
|||||||
creatorId,
|
creatorId,
|
||||||
optionIds
|
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 = {
|
const users = {
|
||||||
admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', {
|
admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', {
|
||||||
@@ -930,6 +1163,37 @@ async function main() {
|
|||||||
code: FIXTURE_CODES.draftQuotation,
|
code: FIXTURE_CODES.draftQuotation,
|
||||||
approved: false
|
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(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -939,6 +1203,8 @@ async function main() {
|
|||||||
approvedQuotationCode: FIXTURE_CODES.approvedQuotation,
|
approvedQuotationCode: FIXTURE_CODES.approvedQuotation,
|
||||||
draftQuotationId,
|
draftQuotationId,
|
||||||
draftQuotationCode: FIXTURE_CODES.draftQuotation,
|
draftQuotationCode: FIXTURE_CODES.draftQuotation,
|
||||||
|
auditQuotationId,
|
||||||
|
auditQuotationCode: FIXTURE_CODES.auditQuotation,
|
||||||
users,
|
users,
|
||||||
password: TASK_H1_PASSWORD
|
password: TASK_H1_PASSWORD
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { auth } from '@/auth';
|
import { auth } from "@/auth";
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return redirect('/auth/sign-in');
|
return redirect("/auth/sign-in");
|
||||||
} else {
|
} else {
|
||||||
redirect('/dashboard');
|
redirect("/dashboard/crm");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,52 +34,52 @@ import { NavGroup } from "@/types";
|
|||||||
* Use the `access` property for new items.
|
* Use the `access` property for new items.
|
||||||
*/
|
*/
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
// {
|
{
|
||||||
// label: "Overview",
|
label: "Overview",
|
||||||
// items: [
|
items: [
|
||||||
// {
|
{
|
||||||
// title: "Dashboard Overview",
|
title: "Dashboard Overview",
|
||||||
// url: "/dashboard",
|
url: "/dashboard",
|
||||||
// icon: "dashboard",
|
icon: "dashboard",
|
||||||
// isActive: false,
|
isActive: false,
|
||||||
// shortcut: ["d", "d"],
|
shortcut: ["d", "d"],
|
||||||
// items: [],
|
items: [],
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// title: "Workspaces",
|
title: "Workspaces",
|
||||||
// url: "/dashboard/workspaces",
|
url: "/dashboard/workspaces",
|
||||||
// icon: "workspace",
|
icon: "workspace",
|
||||||
// isActive: false,
|
isActive: false,
|
||||||
// items: [],
|
items: [],
|
||||||
// access: { systemRole: "super_admin" },
|
access: { systemRole: "super_admin" },
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// title: "Teams",
|
title: "Teams",
|
||||||
// url: "/dashboard/workspaces/team",
|
url: "/dashboard/workspaces/team",
|
||||||
// icon: "teams",
|
icon: "teams",
|
||||||
// isActive: false,
|
isActive: false,
|
||||||
// items: [],
|
items: [],
|
||||||
// access: { requireOrg: true, role: "admin" },
|
access: { requireOrg: true, role: "admin" },
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// title: "Users",
|
title: "Users",
|
||||||
// url: "/dashboard/users",
|
url: "/dashboard/users",
|
||||||
// icon: "teams",
|
icon: "teams",
|
||||||
// shortcut: ["u", "u"],
|
shortcut: ["u", "u"],
|
||||||
// isActive: false,
|
isActive: false,
|
||||||
// items: [],
|
items: [],
|
||||||
// access: { requireOrg: true, permission: "users:manage" },
|
access: { requireOrg: true, permission: "users:manage" },
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// title: "Kanban",
|
title: "Kanban",
|
||||||
// url: "/dashboard/kanban",
|
url: "/dashboard/kanban",
|
||||||
// icon: "kanban",
|
icon: "kanban",
|
||||||
// shortcut: ["k", "k"],
|
shortcut: ["k", "k"],
|
||||||
// isActive: false,
|
isActive: false,
|
||||||
// items: [],
|
items: [],
|
||||||
// },
|
},
|
||||||
// ],
|
],
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
label: "CRM",
|
label: "CRM",
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ function extractSinglePlaceholder(value: string) {
|
|||||||
return uniqueMatches.length === 1 ? uniqueMatches[0] : null;
|
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) {
|
function normalizeTopicKey(value: string | null | undefined) {
|
||||||
return (
|
return (
|
||||||
value
|
value
|
||||||
@@ -165,13 +171,26 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tableField = field as { type?: unknown; content?: unknown };
|
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') {
|
if (tableField.type !== 'table' || typeof tableField.content !== 'string') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholderKey = extractSinglePlaceholder(tableField.content);
|
|
||||||
|
|
||||||
if (!placeholderKey) {
|
if (!placeholderKey) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user