pdf task-h

This commit is contained in:
phaichayon
2026-06-19 15:09:58 +07:00
parent a85d24235c
commit fd01ebf7c7
35 changed files with 3905 additions and 133 deletions

View 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
}

View 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"
}
]
}

View 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
}

View 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"
}
}

View 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

View File

@@ -0,0 +1,8 @@
{
"audit": "template-version-integrity",
"overallStatus": "PASS",
"activeTemplateCount": 2,
"quotationCode": "QT-H5-AUDIT",
"approvedTemplateVersionId": null,
"issues": []
}

View 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": []
}

View 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

View 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

View 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

View 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"
}
}

View 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

View 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.

View File

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

View 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

View File

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

View File

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

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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");
} }
} }

View File

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

View File

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

View File

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