From 0c0450b152ac6d3784c69788b9f217e007ee1c81 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 29 Jun 2026 11:18:08 +0700 Subject: [PATCH] taks-p.4.3 --- .../pdf-audit/runtime-payload-report.json | 611 +++----- .../task-p4-verification/rows_30_short.pdf | Bin 0 -> 21736 bytes plans/task-p.4-discovery-report.md | 582 ++++++++ plans/task-p.4.1-verification-report.md | 457 ++++++ plans/task-p.4.1.md | 67 + plans/task-p.4.2-runtime-design.md | 1228 +++++++++++++++++ plans/task-p.4.2.md | 475 +++++++ plans/task-p.4.3.md | 437 ++++++ plans/task-p.4.md | 326 +++++ .../document/server/page-resolver.ts | 52 + .../document/server/pdf-topic-engine.ts | 200 ++- .../document/server/pdf-topic.type.ts | 1 + .../server/quotation-pdf-runtime.test.ts | 240 ++++ .../document/server/quotation-pdf-runtime.ts | 162 +++ .../document/server/render-context.ts | 64 + .../document/server/runtime-contracts.ts | 134 ++ .../document/server/section-composer.ts | 45 + .../crm/quotations/document/server/service.ts | 37 +- .../document/server/template-assembler.ts | 78 ++ .../server/template-compatibility-adapter.ts | 209 +++ 20 files changed, 4900 insertions(+), 505 deletions(-) create mode 100644 artifacts/task-p4-verification/rows_30_short.pdf create mode 100644 plans/task-p.4-discovery-report.md create mode 100644 plans/task-p.4.1-verification-report.md create mode 100644 plans/task-p.4.1.md create mode 100644 plans/task-p.4.2-runtime-design.md create mode 100644 plans/task-p.4.2.md create mode 100644 plans/task-p.4.3.md create mode 100644 plans/task-p.4.md create mode 100644 src/features/crm/quotations/document/server/page-resolver.ts create mode 100644 src/features/crm/quotations/document/server/quotation-pdf-runtime.test.ts create mode 100644 src/features/crm/quotations/document/server/quotation-pdf-runtime.ts create mode 100644 src/features/crm/quotations/document/server/render-context.ts create mode 100644 src/features/crm/quotations/document/server/runtime-contracts.ts create mode 100644 src/features/crm/quotations/document/server/section-composer.ts create mode 100644 src/features/crm/quotations/document/server/template-assembler.ts create mode 100644 src/features/crm/quotations/document/server/template-compatibility-adapter.ts diff --git a/artifacts/pdf-audit/runtime-payload-report.json b/artifacts/pdf-audit/runtime-payload-report.json index d8283cf..ac849ce 100644 --- a/artifacts/pdf-audit/runtime-payload-report.json +++ b/artifacts/pdf-audit/runtime-payload-report.json @@ -1,17 +1,17 @@ { "audit": "runtime-payload", - "overallStatus": "PASS", - "staticLabelsStatus": "PASS", - "quotationCode": "QT-H5-AUDIT", + "overallStatus": "FAIL", + "staticLabelsStatus": "FAIL", + "quotationCode": "QT2606-1126", "templateName": "ALLA Quotation Standard", - "templateVersion": "1.1", - "findingCount": 202, + "templateVersion": "1.0", + "findingCount": 165, "findings": [ { "field": "documentData.company.id", "source": "documentData", "status": "PASS", - "value": "2330c067-2737-4443-bd6e-ccb8a6cf856b", + "value": "d5c46139-9af4-421b-9395-d83620c62f9b", "message": "String is populated" }, { @@ -25,63 +25,63 @@ "field": "documentData.customer.id", "source": "documentData", "status": "PASS", - "value": "d181ede5-a7b3-4411-8d72-b78c57fc576a", + "value": "ba10e844-997e-45ed-8696-7ca96730a859", "message": "String is populated" }, { "field": "documentData.customer.name", "source": "documentData", "status": "PASS", - "value": "Task H.1 Demo Customer", + "value": "มหาวิทยาลัยเทคโนโลยีพระจอมเกล้าพระนครเหนือ", "message": "String is populated" }, { "field": "documentData.customer.address", "source": "documentData", "status": "PASS", - "value": "99/9 Rama IX Road, Bangkok 10310", + "value": "Bang Sue, Bangkok", "message": "String is populated" }, { "field": "documentData.customer.phone", "source": "documentData", "status": "PASS", - "value": "02-000-1000", + "value": "02-400827", "message": "String is populated" }, { "field": "documentData.customer.email", "source": "documentData", "status": "PASS", - "value": "demo-customer@local.test", + "value": "@example.co.th", "message": "String is populated" }, { "field": "documentData.contact.id", "source": "documentData", "status": "PASS", - "value": "2266d861-4a18-460a-9c14-96f4080090f0", + "value": "2591dec2-b7c9-486a-9810-8ab9bf0f4926", "message": "String is populated" }, { "field": "documentData.contact.name", "source": "documentData", "status": "PASS", - "value": "Task H.1 Contact", + "value": "คุณวราภรณ์ เกษมศรี", "message": "String is populated" }, { "field": "documentData.contact.email", "source": "documentData", "status": "PASS", - "value": "demo-contact@local.test", + "value": "@.co.th", "message": "String is populated" }, { "field": "documentData.contact.mobile", "source": "documentData", "status": "PASS", - "value": "081-000-1001", + "value": "0810000250", "message": "String is populated" }, { @@ -95,42 +95,42 @@ "field": "documentData.quotation.id", "source": "documentData", "status": "PASS", - "value": "3ba9c39c-8121-416a-99ed-59c4d120c72f", + "value": "c964ec57-c036-404b-93c5-6826d6539d1e", "message": "String is populated" }, { "field": "documentData.quotation.code", "source": "documentData", "status": "PASS", - "value": "QT-H5-AUDIT", + "value": "QT2606-1126", "message": "String is populated" }, { "field": "documentData.quotation.projectName", "source": "documentData", "status": "PASS", - "value": "Task H.5 Audit Fixture", + "value": "Material Handling Lab Crane", "message": "String is populated" }, { "field": "documentData.quotation.projectLocation", "source": "documentData", "status": "PASS", - "value": "Bangkok HQ", + "value": "Bang Sue Engineering Faculty", "message": "String is populated" }, { "field": "documentData.quotation.attention", "source": "documentData", "status": "PASS", - "value": "Task H.1 Contact", + "value": "คุณวราภรณ์ เกษมศรี", "message": "String is populated" }, { "field": "documentData.quotation.reference", "source": "documentData", "status": "PASS", - "value": "TASK-H5-AUDIT", + "value": "crm-uat-seed-v1:university-lab-crane-approved", "message": "String is populated" }, { @@ -151,28 +151,28 @@ "field": "documentData.quotation.subtotal", "source": "documentData", "status": "PASS", - "value": 125000, + "value": 3008000, "message": "Scalar value is populated" }, { "field": "documentData.quotation.totalAmount", "source": "documentData", "status": "PASS", - "value": 128400, + "value": 3122003, "message": "Scalar value is populated" }, { "field": "documentData.items", "source": "documentData", "status": "PASS", - "value": "rows:3", + "value": "rows:4", "message": "Collection payload is populated" }, { "field": "documentData.items[0].id", "source": "documentData", "status": "PASS", - "value": "12ee11d8-1404-4cf9-9944-097f184296df", + "value": "4b151f4b-ff48-4dcc-b092-d891ff6f5a29", "message": "String is populated" }, { @@ -186,7 +186,7 @@ "field": "documentData.items[0].description", "source": "documentData", "status": "PASS", - "value": "1 set overhead crane with installation and commissioning", + "value": "Main equipment supply for Material Handling Lab Crane", "message": "String is populated" }, { @@ -207,28 +207,28 @@ "field": "documentData.items[0].unitPrice", "source": "documentData", "status": "PASS", - "value": 125000, + "value": 1717102, "message": "Scalar value is populated" }, { "field": "documentData.items[0].discount", "source": "documentData", "status": "PASS", - "value": 5000, + "value": 0, "message": "Scalar value is populated" }, { "field": "documentData.items[0].totalPrice", "source": "documentData", "status": "PASS", - "value": 120000, + "value": 1717102, "message": "Scalar value is populated" }, { "field": "documentData.items[1].id", "source": "documentData", "status": "PASS", - "value": "b68d6a34-229d-4a62-ad59-faef2108874a", + "value": "43c6d8c7-4af3-4e35-80af-5bb1beeffdc7", "message": "String is populated" }, { @@ -242,7 +242,7 @@ "field": "documentData.items[1].description", "source": "documentData", "status": "PASS", - "value": "Load testing and safety certification", + "value": "Installation and commissioning for Material Handling Lab Crane", "message": "String is populated" }, { @@ -256,14 +256,14 @@ "field": "documentData.items[1].unitLabel", "source": "documentData", "status": "PASS", - "value": "Set", + "value": "Job", "message": "String is populated" }, { "field": "documentData.items[1].unitPrice", "source": "documentData", "status": "PASS", - "value": 25000, + "value": 874161, "message": "Scalar value is populated" }, { @@ -277,14 +277,14 @@ "field": "documentData.items[1].totalPrice", "source": "documentData", "status": "PASS", - "value": 25000, + "value": 874161, "message": "Scalar value is populated" }, { "field": "documentData.items[2].id", "source": "documentData", "status": "PASS", - "value": "a4b633ef-6fbb-4476-9a1b-5957f2dd64d9", + "value": "b2aca42a-0592-4725-9ecd-89c8287b4b44", "message": "String is populated" }, { @@ -298,28 +298,28 @@ "field": "documentData.items[2].description", "source": "documentData", "status": "PASS", - "value": "Operator training and turnover documentation", + "value": "Operator training and documentation for Material Handling Lab Crane", "message": "String is populated" }, { "field": "documentData.items[2].quantity", "source": "documentData", "status": "PASS", - "value": 2, + "value": 1, "message": "Scalar value is populated" }, { "field": "documentData.items[2].unitLabel", "source": "documentData", "status": "PASS", - "value": "Set", + "value": "Job", "message": "String is populated" }, { "field": "documentData.items[2].unitPrice", "source": "documentData", "status": "PASS", - "value": 8000, + "value": 312200, "message": "Scalar value is populated" }, { @@ -333,35 +333,91 @@ "field": "documentData.items[2].totalPrice", "source": "documentData", "status": "PASS", - "value": 16000, + "value": 312200, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[3].id", + "source": "documentData", + "status": "PASS", + "value": "bb721763-f87f-4a92-8899-36e690eede66", + "message": "String is populated" + }, + { + "field": "documentData.items[3].itemNumber", + "source": "documentData", + "status": "PASS", + "value": 4, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[3].description", + "source": "documentData", + "status": "PASS", + "value": "Project management and testing for Material Handling Lab Crane", + "message": "String is populated" + }, + { + "field": "documentData.items[3].quantity", + "source": "documentData", + "status": "PASS", + "value": 1, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[3].unitLabel", + "source": "documentData", + "status": "PASS", + "value": "Job", + "message": "String is populated" + }, + { + "field": "documentData.items[3].unitPrice", + "source": "documentData", + "status": "PASS", + "value": 218540, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[3].discount", + "source": "documentData", + "status": "PASS", + "value": 0, + "message": "Scalar value is populated" + }, + { + "field": "documentData.items[3].totalPrice", + "source": "documentData", + "status": "PASS", + "value": 218540, "message": "Scalar value is populated" }, { "field": "documentData.quotationCustomers", "source": "documentData", "status": "PASS", - "value": "rows:3", + "value": "rows:1", "message": "Collection payload is populated" }, { "field": "documentData.quotationCustomers[0].id", "source": "documentData", "status": "PASS", - "value": "6b0d1e2c-ce89-4bfd-8d7a-8ef7eafe88d1", + "value": "8de4be97-45d0-48f7-b6a6-962077b63389", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].customerId", "source": "documentData", "status": "PASS", - "value": "d181ede5-a7b3-4411-8d72-b78c57fc576a", + "value": "ba10e844-997e-45ed-8696-7ca96730a859", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].role", "source": "documentData", "status": "PASS", - "value": "ba173997-2078-45a9-bbf5-000c6a09c3ad", + "value": "09320e31-e8f9-42cd-a3ea-577210f29501", "message": "String is populated" }, { @@ -371,81 +427,25 @@ "value": true, "message": "Scalar value is populated" }, - { - "field": "documentData.quotationCustomers[1].id", - "source": "documentData", - "status": "PASS", - "value": "fc5165f3-2642-4e28-92ab-63be6f4f5232", - "message": "String is populated" - }, - { - "field": "documentData.quotationCustomers[1].customerId", - "source": "documentData", - "status": "PASS", - "value": "41c80a46-cf20-493d-b759-4193f4e336d0", - "message": "String is populated" - }, - { - "field": "documentData.quotationCustomers[1].role", - "source": "documentData", - "status": "PASS", - "value": "9e2e9f37-f400-4379-a72a-c78bea00581c", - "message": "String is populated" - }, - { - "field": "documentData.quotationCustomers[1].isPrimary", - "source": "documentData", - "status": "PASS", - "value": false, - "message": "Scalar value is populated" - }, - { - "field": "documentData.quotationCustomers[2].id", - "source": "documentData", - "status": "PASS", - "value": "3cd96a36-b8bd-487a-9069-db728502be66", - "message": "String is populated" - }, - { - "field": "documentData.quotationCustomers[2].customerId", - "source": "documentData", - "status": "PASS", - "value": "5416445a-ce22-4b3c-803d-0d6e591ee1fa", - "message": "String is populated" - }, - { - "field": "documentData.quotationCustomers[2].role", - "source": "documentData", - "status": "PASS", - "value": "e6d0dd91-60c0-480e-8412-8daca7e50aa5", - "message": "String is populated" - }, - { - "field": "documentData.quotationCustomers[2].isPrimary", - "source": "documentData", - "status": "PASS", - "value": false, - "message": "Scalar value is populated" - }, { "field": "documentData.topics.all", "source": "documentData", "status": "PASS", - "value": "rows:5", + "value": "rows:3", "message": "Collection payload is populated" }, { "field": "documentData.topics.all[0].id", "source": "documentData", "status": "PASS", - "value": "f07d5ccd-784d-4c87-9ded-60ccb85bd0b0", + "value": "1c97eb84-131e-49ad-97ca-a59547223c8d", "message": "String is populated" }, { "field": "documentData.topics.all[0].title", "source": "documentData", "status": "PASS", - "value": "Scope", + "value": "Scope of Work", "message": "String is populated" }, { @@ -466,14 +466,14 @@ "field": "documentData.topics.all[0].items", "source": "documentData", "status": "PASS", - "value": "rows:3", + "value": "rows:2", "message": "Collection payload is populated" }, { "field": "documentData.topics.all[0].items[0].content", "source": "documentData", "status": "PASS", - "value": "Supply crane equipment", + "value": "Supply and execute material handling lab crane according to approved drawing.", "message": "String is populated" }, { @@ -487,7 +487,7 @@ "field": "documentData.topics.all[0].items[1].content", "source": "documentData", "status": "PASS", - "value": "Install and test the system", + "value": "Include testing, commissioning, and handover documentation.", "message": "String is populated" }, { @@ -497,32 +497,18 @@ "value": 2, "message": "Scalar value is populated" }, - { - "field": "documentData.topics.all[0].items[2].content", - "source": "documentData", - "status": "PASS", - "value": "Perform SAT with project stakeholders", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[0].items[2].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 3, - "message": "Scalar value is populated" - }, { "field": "documentData.topics.all[1].id", "source": "documentData", "status": "PASS", - "value": "07337fe5-7ca5-419f-837c-27e9e6bc05f1", + "value": "49935121-792a-405c-8a6f-fbdbc8bdda65", "message": "String is populated" }, { "field": "documentData.topics.all[1].title", "source": "documentData", "status": "PASS", - "value": "Exclusion", + "value": "Exclusions", "message": "String is populated" }, { @@ -550,7 +536,7 @@ "field": "documentData.topics.all[1].items[0].content", "source": "documentData", "status": "PASS", - "value": "Civil work by customer", + "value": "Civil work, main power cable, and customer standby crew are excluded.", "message": "String is populated" }, { @@ -564,7 +550,7 @@ "field": "documentData.topics.all[1].items[1].content", "source": "documentData", "status": "PASS", - "value": "Power feeder by customer", + "value": "Work outside normal business hours is not included.", "message": "String is populated" }, { @@ -578,14 +564,14 @@ "field": "documentData.topics.all[2].id", "source": "documentData", "status": "PASS", - "value": "e821ccb0-23fc-4c8a-828c-8ababe0f4896", + "value": "5685dff7-add1-407e-9e6a-e16d6ea8d1be", "message": "String is populated" }, { "field": "documentData.topics.all[2].title", "source": "documentData", "status": "PASS", - "value": "Payment", + "value": "Payment Terms", "message": "String is populated" }, { @@ -606,14 +592,14 @@ "field": "documentData.topics.all[2].items", "source": "documentData", "status": "PASS", - "value": "rows:3", + "value": "rows:2", "message": "Collection payload is populated" }, { "field": "documentData.topics.all[2].items[0].content", "source": "documentData", "status": "PASS", - "value": "50% upon PO", + "value": "40% deposit, 50% after delivery, 10% after commissioning.", "message": "String is populated" }, { @@ -627,7 +613,7 @@ "field": "documentData.topics.all[2].items[1].content", "source": "documentData", "status": "PASS", - "value": "40% before delivery", + "value": "Price validity 30 days and delivery 60-90 days after order confirmation.", "message": "String is populated" }, { @@ -637,146 +623,6 @@ "value": 2, "message": "Scalar value is populated" }, - { - "field": "documentData.topics.all[2].items[2].content", - "source": "documentData", - "status": "PASS", - "value": "10% after commissioning", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[2].items[2].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 3, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.all[3].id", - "source": "documentData", - "status": "PASS", - "value": "4754e4b5-0dd7-44d6-b7c2-1006ec5b526d", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[3].title", - "source": "documentData", - "status": "PASS", - "value": "Warranty", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[3].topicCode", - "source": "documentData", - "status": "WARNING", - "value": "", - "message": "String is empty" - }, - { - "field": "documentData.topics.all[3].topicType", - "source": "documentData", - "status": "PASS", - "value": "Warranty", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[3].items", - "source": "documentData", - "status": "PASS", - "value": "rows:2", - "message": "Collection payload is populated" - }, - { - "field": "documentData.topics.all[3].items[0].content", - "source": "documentData", - "status": "PASS", - "value": "12 months after commissioning", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[3].items[0].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.all[3].items[1].content", - "source": "documentData", - "status": "PASS", - "value": "Consumables excluded", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[3].items[1].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 2, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.all[4].id", - "source": "documentData", - "status": "PASS", - "value": "6fd51401-11cd-4388-91e4-734ba6b3de6c", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[4].title", - "source": "documentData", - "status": "PASS", - "value": "Delivery", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[4].topicCode", - "source": "documentData", - "status": "WARNING", - "value": "", - "message": "String is empty" - }, - { - "field": "documentData.topics.all[4].topicType", - "source": "documentData", - "status": "PASS", - "value": "Delivery", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[4].items", - "source": "documentData", - "status": "PASS", - "value": "rows:2", - "message": "Collection payload is populated" - }, - { - "field": "documentData.topics.all[4].items[0].content", - "source": "documentData", - "status": "PASS", - "value": "Within 60 days after approved drawing", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[4].items[0].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.all[4].items[1].content", - "source": "documentData", - "status": "PASS", - "value": "Subject to final site readiness", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[4].items[1].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 2, - "message": "Scalar value is populated" - }, { "field": "documentData.topics.exclusion", "source": "documentData", @@ -788,14 +634,14 @@ "field": "documentData.topics.exclusion[0].id", "source": "documentData", "status": "PASS", - "value": "07337fe5-7ca5-419f-837c-27e9e6bc05f1", + "value": "49935121-792a-405c-8a6f-fbdbc8bdda65", "message": "String is populated" }, { "field": "documentData.topics.exclusion[0].title", "source": "documentData", "status": "PASS", - "value": "Exclusion", + "value": "Exclusions", "message": "String is populated" }, { @@ -823,7 +669,7 @@ "field": "documentData.topics.exclusion[0].items[0].content", "source": "documentData", "status": "PASS", - "value": "Civil work by customer", + "value": "Civil work, main power cable, and customer standby crew are excluded.", "message": "String is populated" }, { @@ -837,7 +683,7 @@ "field": "documentData.topics.exclusion[0].items[1].content", "source": "documentData", "status": "PASS", - "value": "Power feeder by customer", + "value": "Work outside normal business hours is not included.", "message": "String is populated" }, { @@ -886,14 +732,14 @@ "field": "documentData.pdfme.quotation_date", "source": "documentData", "status": "PASS", - "value": "Jun 16, 2026", + "value": "Jun 15, 2026", "message": "String is populated" }, { "field": "documentData.pdfme.quotation_price", "source": "documentData", "status": "PASS", - "value": "THB 125,000.00", + "value": "THB 3,008,000.00", "message": "String is populated" }, { @@ -949,34 +795,6 @@ "field": "documentData.pdfme.topic_inputs.item_topic_1_2", "source": "documentData", "status": "PASS", - "value": "rows:3", - "message": "Array shape is valid" - }, - { - "field": "documentData.pdfme.topic_inputs.topic_1_3", - "source": "documentData", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "documentData.pdfme.topic_inputs.item_topic_1_3", - "source": "documentData", - "status": "PASS", - "value": "rows:3", - "message": "Array shape is valid" - }, - { - "field": "documentData.pdfme.topic_inputs.topic_1_4", - "source": "documentData", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "documentData.pdfme.topic_inputs.item_topic_1_4", - "source": "documentData", - "status": "PASS", "value": "rows:2", "message": "Array shape is valid" }, @@ -984,7 +802,7 @@ "field": "documentData.signatures.preparedBy.name", "source": "documentData", "status": "PASS", - "value": "Task H.1 Admin", + "value": "Super Admin", "message": "String is populated" }, { @@ -998,35 +816,35 @@ "field": "documentData.signatures.preparedBy.actedAt", "source": "documentData", "status": "PASS", - "value": "2026-06-19T07:03:05.000Z", + "value": "2026-06-15T09:00:00.000Z", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.name", "source": "documentData", "status": "PASS", - "value": "Task H.1 Admin", + "value": "-", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.position", "source": "documentData", "status": "PASS", - "value": "Sales Manager", + "value": "-", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.actedAt", "source": "documentData", - "status": "PASS", - "value": "2026-06-16T01:10:00.000Z", - "message": "String is populated" + "status": "WARNING", + "value": null, + "message": "Value is null" }, { "field": "documentData.signatures.authorizedBy.name", "source": "documentData", "status": "PASS", - "value": "Task H.1 Admin", + "value": "Super Admin", "message": "String is populated" }, { @@ -1040,70 +858,70 @@ "field": "documentData.signatures.authorizedBy.actedAt", "source": "documentData", "status": "PASS", - "value": "2026-06-16T01:20:00.000Z", + "value": "2026-06-17T11:00:00.000Z", "message": "String is populated" }, { "field": "templateInput.customer_name", "source": "templateInput", "status": "PASS", - "value": "Task H.1 Demo Customer", + "value": "มหาวิทยาลัยเทคโนโลยีพระจอมเกล้าพระนครเหนือ", "message": "String is populated" }, { "field": "templateInput.customer_addr", "source": "templateInput", "status": "PASS", - "value": "99/9 Rama IX Road, Bangkok 10310", + "value": "Bang Sue, Bangkok", "message": "String is populated" }, { "field": "templateInput.customer_tel", "source": "templateInput", "status": "PASS", - "value": "02-000-1000", + "value": "02-400827", "message": "String is populated" }, { "field": "templateInput.customer_email", "source": "templateInput", "status": "PASS", - "value": "demo-customer@local.test", + "value": "@example.co.th", "message": "String is populated" }, { "field": "templateInput.customer_att", "source": "templateInput", "status": "PASS", - "value": "Task H.1 Contact", + "value": "คุณวราภรณ์ เกษมศรี", "message": "String is populated" }, { "field": "templateInput.project_name", "source": "templateInput", "status": "PASS", - "value": "Task H.5 Audit Fixture", + "value": "Material Handling Lab Crane", "message": "String is populated" }, { "field": "templateInput.site_location", "source": "templateInput", "status": "PASS", - "value": "Bangkok HQ", + "value": "Bang Sue Engineering Faculty", "message": "String is populated" }, { - "field": "templateInput.quotation_date_data", + "field": "templateInput.quotation_date", "source": "templateInput", "status": "PASS", - "value": "Jun 16, 2026", + "value": "Jun 15, 2026", "message": "String is populated" }, { - "field": "templateInput.quotation_code_data", + "field": "templateInput.quotation_code", "source": "templateInput", "status": "PASS", - "value": "QT-H5-AUDIT", + "value": "QT2606-1126", "message": "String is populated" }, { @@ -1117,7 +935,7 @@ "field": "templateInput.quotation_price", "source": "templateInput", "status": "PASS", - "value": "THB 125,000.00", + "value": "THB 3,008,000.00", "message": "String is populated" }, { @@ -1128,45 +946,17 @@ "message": "String is populated" }, { - "field": "templateInput.tel_label", + "field": "templateInput.exclusion_data", "source": "templateInput", "status": "PASS", - "value": "Tel", - "message": "String is populated" - }, - { - "field": "templateInput.email_label", - "source": "templateInput", - "status": "PASS", - "value": "Email", - "message": "String is populated" - }, - { - "field": "templateInput.att_label", - "source": "templateInput", - "status": "PASS", - "value": "Att", - "message": "String is populated" - }, - { - "field": "templateInput.project_label", - "source": "templateInput", - "status": "PASS", - "value": "Project", - "message": "String is populated" - }, - { - "field": "templateInput.site_label", - "source": "templateInput", - "status": "PASS", - "value": "Location", - "message": "String is populated" + "value": "rows:2", + "message": "Array shape is valid" }, { "field": "templateInput.app1", "source": "templateInput", "status": "PASS", - "value": "Task H.1 Admin", + "value": "Super Admin", "message": "String is populated" }, { @@ -1180,21 +970,21 @@ "field": "templateInput.app2", "source": "templateInput", "status": "PASS", - "value": "Task H.1 Admin", + "value": "-", "message": "String is populated" }, { "field": "templateInput.app2_position", "source": "templateInput", "status": "PASS", - "value": "Sales Manager", + "value": "-", "message": "String is populated" }, { "field": "templateInput.app3", "source": "templateInput", "status": "PASS", - "value": "Task H.1 Admin", + "value": "Super Admin", "message": "String is populated" }, { @@ -1204,6 +994,13 @@ "value": "Sales Manager", "message": "String is populated" }, + { + "field": "templateInput.items_table", + "source": "templateInput", + "status": "PASS", + "value": "rows:4", + "message": "Array shape is valid" + }, { "field": "templateInput.topic_1_0", "source": "templateInput", @@ -1243,49 +1040,21 @@ "field": "templateInput.item_topic_1_2", "source": "templateInput", "status": "PASS", - "value": "rows:3", - "message": "Array shape is valid" - }, - { - "field": "templateInput.topic_1_3", - "source": "templateInput", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "templateInput.item_topic_1_3", - "source": "templateInput", - "status": "PASS", - "value": "rows:3", - "message": "Array shape is valid" - }, - { - "field": "templateInput.topic_1_4", - "source": "templateInput", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "templateInput.item_topic_1_4", - "source": "templateInput", - "status": "PASS", "value": "rows:2", "message": "Array shape is valid" }, { - "field": "templateInput.quotation_date", + "field": "templateInput.quotation_date_data", "source": "templateInput", "status": "PASS", - "value": "Jun 16, 2026", + "value": "Jun 15, 2026", "message": "String is populated" }, { - "field": "templateInput.quotation_code", + "field": "templateInput.quotation_code_data", "source": "templateInput", "status": "PASS", - "value": "QT-H5-AUDIT", + "value": "QT2606-1126", "message": "String is populated" }, { @@ -1327,34 +1096,6 @@ "field": "topicInputs.item_topic_1_2", "source": "topicInputs", "status": "PASS", - "value": "rows:3", - "message": "Array shape is valid" - }, - { - "field": "topicInputs.topic_1_3", - "source": "topicInputs", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "topicInputs.item_topic_1_3", - "source": "topicInputs", - "status": "PASS", - "value": "rows:3", - "message": "Array shape is valid" - }, - { - "field": "topicInputs.topic_1_4", - "source": "topicInputs", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "topicInputs.item_topic_1_4", - "source": "topicInputs", - "status": "PASS", "value": "rows:2", "message": "Array shape is valid" }, @@ -1376,7 +1117,7 @@ "field": "templateInput.quotation_price_data[0][1]", "source": "templateInput", "status": "PASS", - "value": "฿125,000.00", + "value": "฿3,008,000.00", "message": "Price table amount cell is formatted" }, { @@ -1389,37 +1130,37 @@ { "field": "templateInput.tel_label", "source": "templateInput", - "status": "PASS", - "value": "Tel", - "message": "Static label is populated" + "status": "FAIL", + "value": null, + "message": "Static label is empty" }, { "field": "templateInput.email_label", "source": "templateInput", - "status": "PASS", - "value": "Email", - "message": "Static label is populated" + "status": "FAIL", + "value": null, + "message": "Static label is empty" }, { "field": "templateInput.att_label", "source": "templateInput", - "status": "PASS", - "value": "Att", - "message": "Static label is populated" + "status": "FAIL", + "value": null, + "message": "Static label is empty" }, { "field": "templateInput.project_label", "source": "templateInput", - "status": "PASS", - "value": "Project", - "message": "Static label is populated" + "status": "FAIL", + "value": null, + "message": "Static label is empty" }, { "field": "templateInput.site_label", "source": "templateInput", - "status": "PASS", - "value": "Location", - "message": "Static label is populated" + "status": "FAIL", + "value": null, + "message": "Static label is empty" } ] } diff --git a/artifacts/task-p4-verification/rows_30_short.pdf b/artifacts/task-p4-verification/rows_30_short.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f2ee945ca0b429be48d7894b277c3990c0b5328f GIT binary patch literal 21736 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 literal 0 HcmV?d00001 diff --git a/plans/task-p.4-discovery-report.md b/plans/task-p.4-discovery-report.md new file mode 100644 index 0000000..13c5b27 --- /dev/null +++ b/plans/task-p.4-discovery-report.md @@ -0,0 +1,582 @@ +# Task P.4 Discovery Report + +## Scope + +This report documents the mandatory discovery phase for `task-p.4.md` before any implementation begins. + +Task P.4 requires: + +- introducing a dedicated Product Item page between the existing Customer Detail page and Condition + Signature page +- using the existing CRM template management workflow +- creating new template versions only +- preserving backward compatibility for existing quotation template versions + +No implementation has been performed at the time of writing this report. + +--- + +## 1. Existing Architecture + +### 1.1 CRM Template Module + +The CRM template settings page is served by: + +- [src/app/dashboard/crm/settings/templates/page.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/settings/templates/page.tsx:9) + +This page: + +- checks access with `crmDocumentTemplateRead` +- prefetches quotation PDFMe templates via `documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' })` +- renders the admin UI through `TemplateSettings` + +The main template admin UI lives in: + +- [src/features/foundation/document-template/components/template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:720) + +This module already supports: + +- template metadata CRUD +- template version CRUD +- raw `schemaJson` editing +- mapping CRUD +- version activation +- template detail inspection + +Important internal UI areas: + +- template metadata dialog: [template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:241) +- version dialog: [template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:292) +- version JSON editing: [template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:360) +- template detail card and version tabs: [template-settings.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/components/template-settings.tsx:527) + +### 1.2 Template APIs + +Template/version/mapping APIs already exist and are reused by the CRM settings route group: + +- [src/app/api/crm/document-templates/[id]/versions/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/document-templates/[id]/versions/route.ts:1) +- [src/app/api/crm/settings/document-templates/versions/[id]/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/route.ts:1) +- [src/app/api/crm/settings/document-templates/versions/[id]/mappings/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/document-templates/versions/[id]/mappings/route.ts:1) + +### 1.3 Document Template Foundation + +The main document-template business logic is centralized in: + +- [src/features/foundation/document-template/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:1) + +Important responsibilities already covered: + +- template resolution for runtime documents +- version resolution +- mapping resolution +- mapping-to-input transformation +- active/default template handling + +Key functions: + +- `resolveTemplateMappings`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:168) +- `createDocumentTemplateVersion`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:424) +- `updateDocumentTemplateVersion`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:449) +- `setDocumentTemplateVersionActive`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:474) +- `resolveTemplateForDocument`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:612) +- `mapDocumentDataToTemplateInput`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:745) + +### 1.4 Quotation Document Runtime + +Quotation document runtime logic is handled in: + +- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:263) + +Key functions: + +- `buildQuotationDocumentData`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:263) +- `getQuotationDocumentPreviewData`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:661) +- `prepareApprovedQuotationSnapshot`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:718) + +Topic-based dynamic layout is currently handled by: + +- [src/features/crm/quotations/document/server/pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:66) + +Formatting helpers are in: + +- [src/features/crm/quotations/document/server/pdfme-transforms.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdfme-transforms.ts:86) + +### 1.5 PDF Generation Foundation + +PDF generation is centralized in: + +- [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:99) + +Important capabilities already provided: + +- font loading +- PDFMe plugin registration +- preview PDF generation +- download PDF generation +- approved PDF artifact generation and persistence + +Key functions: + +- `getPdfPlugins`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:44) +- `resolvePdfFonts`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:53) +- `generatePdfFromTemplate`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:99) +- `generateQuotationPdf`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:300) +- `generateQuotationPreviewPdf`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:317) +- `generateApprovedQuotationPdf`: [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:321) + +--- + +## 2. Dependency Graph + +### 2.1 Runtime Flow + +Quotation to PDF currently flows as: + +1. quotation APIs request document data or document preview +2. quotation server service builds normalized `documentData` +3. runtime resolves the active template and active version +4. runtime resolves mappings for the selected version +5. document data is transformed into PDFMe `templateInput` +6. topic engine clones and expands the topic page dynamically +7. merged template plus merged inputs are passed to PDFMe generator +8. resulting PDF is returned as preview, download, or approved artifact + +### 2.2 Route-Level Graph + +- document data route: [src/app/api/crm/quotations/[id]/document-data/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/document-data/route.ts:15) + - calls `buildQuotationDocumentData` +- document preview route: [src/app/api/crm/quotations/[id]/document-preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/document-preview/route.ts:15) + - calls `getQuotationDocumentPreviewData` +- PDF preview route: [src/app/api/crm/quotations/[id]/pdf-preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/pdf-preview/route.ts:15) + - calls `generateQuotationPreviewPdf` +- PDF download route: [src/app/api/crm/quotations/[id]/pdf-download/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/pdf-download/route.ts:15) + - calls `generateQuotationPdf` +- approved PDF route: [src/app/api/crm/quotations/[id]/approved-pdf/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/approved-pdf/route.ts:20) + - calls approved artifact retrieval/generation flow + +### 2.3 UI Preview Graph + +Client document preview uses: + +- [src/features/crm/quotations/document/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/service.ts:1) +- [src/features/crm/quotations/document/queries.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/queries.ts:1) +- [src/features/crm/quotations/components/quotation-document-preview.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-document-preview.tsx:1) + +This preview currently renders document data and item summaries for inspection, but it is not the PDF layout engine itself. + +--- + +## 3. Current Template Lifecycle + +### 3.1 Storage Model + +Template entities are stored in: + +- `crm_document_templates`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:880) +- `crm_document_template_versions`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:909) +- `crm_document_template_mappings`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:933) +- `crm_document_template_table_columns`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:957) + +### 3.2 Lifecycle Rules + +Observed lifecycle: + +1. create template metadata +2. create template version with raw `schemaJson` +3. add/update mappings for placeholders +4. activate the desired version +5. runtime automatically resolves the active version + +### 3.3 Selection Rules at Runtime + +Runtime template resolution is implemented in: + +- [resolveTemplateForDocument](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:612) + +Selection behavior: + +- only active templates are considered +- product-type-specific match is preferred +- default product type fallback exists +- active versions are filtered +- the latest active version by creation order is selected + +### 3.4 Activation and Default Handling + +- default template normalization is handled by `normalizeDefaultTemplate` +- version activation is handled by `setDocumentTemplateVersionActive` +- inactive versions remain stored for traceability + +### 3.5 Template Source / Seed Coupling + +Current template source loading in seeds and audit utilities still points to: + +- `ALLA_template_pdfme_fainal3.json` +- `ONVALLA_template_pdfme_fainal3.json` + +References: + +- [src/db/seeds/foundation.seed.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts:854) +- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts:667) +- [scripts/reseed-pdf-template-version.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/reseed-pdf-template-version.ts:1) + +This means any new product-item template version likely needs seed/audit support updates. + +--- + +## 4. Runtime Rendering Flow + +### 4.1 Current Preview Builder + +`getQuotationDocumentPreviewData` currently does the following: + +1. builds quotation document data +2. resolves the active quotation PDFMe template +3. resolves version mappings +4. maps document data to template inputs +5. normalizes aliases between field names and placeholder tokens +6. runs the topic engine +7. returns merged template input plus rendered schema + +Reference: + +- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:661) + +### 4.2 Topic Engine Behavior + +The existing topic engine: + +- clones template pages +- assumes the dynamic topic page is at `targetPageIndex: 1` +- looks for `topic` and `data_topic` +- inserts repeated topic blocks +- moves signature/keep-together fields to the last generated page when needed + +Key implementation details: + +- default options: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:14) +- target page lookup: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:76) +- dynamic field lookup: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:83) +- page break behavior: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:147) +- keep-together footer/signature handling: [pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:169) + +### 4.3 PDF Generator Runtime + +The generator currently registers these PDFMe plugins: + +- `text` +- `image` +- `line` +- `table` + +Reference: + +- [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:44) + +This is important because Product Item page support should reuse the existing official PDFMe `table` plugin instead of introducing a custom renderer. + +--- + +## 5. Existing PDF Templates + +### 5.1 Located Template Files + +Current PDFMe template assets found in the repository: + +- [src/pdfme_template/ALLA_template_pdfme_fainal3.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ALLA_template_pdfme_fainal3.json) +- [src/pdfme_template/ONVALLA_template_pdfme_fainal3.json](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ONVALLA_template_pdfme_fainal3.json) + +### 5.2 Current Page Count + +Both templates currently contain exactly 2 pages. + +### 5.3 Page Purposes + +For `ALLA_template_pdfme_fainal3.json`: + +- Page 1 + - company header + - customer detail + - quotation meta + - project fields + - quotation price summary table +- Page 2 + - dynamic topic label/table placeholders + - closing message + - signature blocks + +Observed markers from template JSON: + +- page 1 price table field `quotation_price_data` +- page 2 dynamic topic fields `topic` and `data_topic` +- page 2 static closing/signature fields `Please_do_not`, `yours_faithfuly`, `app1`, `app2`, `app3` + +Relevant references: + +- quotation price table marker: [ALLA template](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ALLA_template_pdfme_fainal3.json:345) +- topic marker: [ALLA template](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ALLA_template_pdfme_fainal3.json:962) +- topic data marker: [ALLA template](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/pdfme_template/ALLA_template_pdfme_fainal3.json:1031) + +### 5.4 Base PDF Structure + +The current templates use PDFMe object-style `basePdf` with width/height/padding and static schema, not an imported fixed PDF file path. + +This is favorable for dynamic table layout and page growth. + +--- + +## 6. Product Item Flow + +### 6.1 Data Origin + +Quotation product items originate from: + +- `crm_quotation_items`: [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts:613) + +Current persisted fields include: + +- `itemNumber` +- `productType` +- `description` +- `quantity` +- `unit` +- `unitPrice` +- `discount` +- `discountType` +- `taxRate` +- `totalPrice` +- `notes` + +### 6.2 Server Mapping + +Quotation item records are normalized in: + +- [src/features/crm/quotations/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/server/service.ts:208) + +Quotation document data currently exposes item fields in: + +- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:564) + +Currently exposed document item fields: + +- `itemNumber` +- `productTypeCode` +- `productTypeLabel` +- `description` +- `quantity` +- `unitCode` +- `unitLabel` +- `unitPrice` +- `discount` +- `discountTypeCode` +- `taxRate` +- `totalPrice` +- `notes` + +### 6.3 Existing Table Mapping Support + +There is already existing seed/audit support for an `items_table` mapping: + +- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts:312) + +Current seeded columns: + +- `Item` → `itemNumber` +- `Description` → `description` +- `Qty` → `quantity` +- `Unit` → `unitLabel` +- `Unit Price` → `unitPrice` +- `Total` → `totalPrice` + +This suggests the architecture already anticipated table-based quotation item rendering, even though the currently active production template files are still only 2 pages. + +### 6.4 Gaps Against P.4 Requirements + +Task P.4 requires these columns or concepts: + +- product code +- description +- specification +- quantity +- unit +- unit price +- discount +- amount +- remark + +Observed current gap: + +- no dedicated `specification` field was found in `crm_quotation_items` +- `remark` appears to correspond most closely to `notes`, unless business rules say otherwise + +This is a discovery risk and must be resolved before final implementation behavior is locked in. + +--- + +## 7. Layout Engine Assessment + +### 7.1 What Already Exists + +The current layout foundation already supports: + +- page cloning +- dynamic page generation +- page splitting based on vertical overflow +- repeated topic block creation +- keep-together footer/signature placement + +This is provided by: + +- [src/features/crm/quotations/document/server/pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:66) + +### 7.2 What Does Not Yet Exist Explicitly + +No dedicated runtime was found yet for: + +- dynamic product item page insertion between page 1 and topic/signature page +- generic dynamic page marker resolution +- generalized page numbering helper +- product-table-specific pagination layer separate from topic engine + +### 7.3 Key Constraint + +The existing topic engine is hard-wired to `targetPageIndex: 1`, which currently means: + +- it expects the dynamic topic page to be page 2 +- it expects signature fields on that same logical target page + +If a new Product Item page is inserted between the current page 1 and current page 2 without changing runtime behavior, the topic engine will target the wrong page. + +This is the most important architecture finding of the discovery phase. + +--- + +## 8. Files Expected to Be Modified + +Likely implementation touchpoints: + +- [src/features/crm/quotations/document/server/pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:66) +- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:661) +- [src/features/foundation/document-template/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-template/server/service.ts:612) +- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts:312) +- [scripts/reseed-pdf-template-version.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/reseed-pdf-template-version.ts:1) +- possibly [src/db/seeds/foundation.seed.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts:854) + +Possible UI/admin edits may be needed only if additional metadata or better preview labeling is required, but no mandatory UI gap was found yet for basic version creation and activation. + +--- + +## 9. New Files Expected to Be Created + +Expected new template assets: + +- `src/pdfme_template/ALLA_template_pdfme_product_v1.json` +- `src/pdfme_template/ONVALLA_template_pdfme_product_v1.json` + +No separate parallel rendering pipeline should be created. + +--- + +## 10. Backward Compatibility Analysis + +Backward compatibility is achievable if the implementation follows these rules: + +1. do not modify existing `*_fainal3.json` template files +2. create new template versions only +3. keep runtime able to render both old and new version structures +4. make topic-page resolution robust enough to support the new page sequence +5. avoid changing existing approved PDF artifact behavior + +Why compatibility is realistic: + +- runtime already resolves active versions dynamically +- template mappings are version-specific +- old versions remain stored +- admin UI already supports multiple versions and activation switching + +Main compatibility risk: + +- current topic engine assumes old page order and must be generalized without breaking old templates + +--- + +## 11. Risk Assessment + +### 11.1 High Risk + +- topic engine currently assumes page 2 is the dynamic topic/signature page +- inserting a new middle page will break runtime unless page targeting becomes marker-based or otherwise dynamic + +### 11.2 Medium Risk + +- current quotation item payload does not clearly expose a standalone `specification` field +- page numbering requirement exists in Task P.4, but current templates do not expose obvious page-number fields +- reseed/audit utilities still point to legacy file names and will need alignment for new template assets + +### 11.3 Lower Risk + +- CRM settings workflow itself is already sufficient for version CRUD +- PDF generator already registers the official PDFMe `table` plugin +- template storage model already supports table mappings + +--- + +## 12. Migration Strategy + +Recommended migration path: + +1. generalize runtime topic page detection so it is not tied to fixed page index 1 +2. create new Product Item template JSON files for ALLA and ONVALLA +3. add or update mappings for the product item table placeholders +4. ensure new versions can be created and activated through the existing template settings flow +5. update reseed/audit helpers to recognize the new product template source files where needed +6. verify all runtime modes + +Required verification modes: + +- document preview route +- PDF preview route +- PDF download route +- approved PDF generation +- old template version behavior +- new template version behavior + +--- + +## 13. Final Discovery Conclusion + +Task P.4 should proceed by extending the current document-template and quotation-document runtime foundations, not by creating a new rendering path. + +The critical architecture change is: + +- make the topic/signature dynamic-page resolution independent from the old hardcoded second-page layout + +Once that is in place, the new Product Item page can be introduced as a new template version while preserving existing templates unchanged. + +--- + +## References Reviewed + +### Governance and Standards + +- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md) +- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md) +- [docs/standards/architecture-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/architecture-rules.md) +- [docs/standards/ui-ux-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/ui-ux-rules.md) +- [docs/standards/task-catalog.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-catalog.md) +- [docs/standards/task-review-checklist.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-review-checklist.md) + +### ADRs and Business Docs + +- [docs/adr/0008-attachment-storage-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0008-attachment-storage-strategy.md) +- [docs/adr/0009-approved-document-storage-lifecycle.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0009-approved-document-storage-lifecycle.md) +- [docs/adr/0012-approval-signature-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0012-approval-signature-strategy.md) +- [docs/adr/0013-pdf-visual-parity-strategy.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0013-pdf-visual-parity-strategy.md) +- [docs/business/pdf-mapping-registry.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/business/pdf-mapping-registry.md) +- [docs/business/pdf-placeholder-registry.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/business/pdf-placeholder-registry.md) + +### Implementation Lineage + +- [docs/implementation/task-g-document-preview-foundation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-g-document-preview-foundation.md) +- [docs/implementation/task-h-pdf-generation-persistence.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-h-pdf-generation-persistence.md) +- [docs/implementation/pdfme-runtime-audit.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/pdfme-runtime-audit.md) +- [docs/implementation/pdfme-legacy-audit.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/pdfme-legacy-audit.md) diff --git a/plans/task-p.4.1-verification-report.md b/plans/task-p.4.1-verification-report.md new file mode 100644 index 0000000..49bce15 --- /dev/null +++ b/plans/task-p.4.1-verification-report.md @@ -0,0 +1,457 @@ +# Task P.4.1 Verification Report + +## Scope + +This report completes the runtime verification requested by [task-p.4.1.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.4.1.md). + +No production template files were modified. +No new rendering pipeline was created. +No implementation for Task P.4 has started yet. + +--- + +## 1. Verified Runtime Findings + +### 1.1 Current quotation PDF runtime chain + +The current quotation document routes do not use separate template pipelines. + +Verified route chain: + +- `document-data` calls `buildQuotationDocumentData` + - [src/app/api/crm/quotations/[id]/document-data/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/document-data/route.ts:39) +- `document-preview` calls `getQuotationDocumentPreviewData` + - [src/app/api/crm/quotations/[id]/document-preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/document-preview/route.ts:39) +- `pdf-preview` calls `generateQuotationPreviewPdf` + - [src/app/api/crm/quotations/[id]/pdf-preview/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/pdf-preview/route.ts:39) +- `pdf-download` calls `generateQuotationPdf` + - [src/app/api/crm/quotations/[id]/pdf-download/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/pdf-download/route.ts:39) +- `approved-pdf` reads stored artifact first and falls back to generation path when needed + - [src/app/api/crm/quotations/[id]/approved-pdf/route.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/approved-pdf/route.ts:44) + +Verified shared pipeline: + +- `generateQuotationPdf()` calls `getQuotationDocumentPreviewData()` + - [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:304) +- `generateQuotationPreviewPdf()` simply delegates to `generateQuotationPdf()` + - [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:317) +- `generateApprovedQuotationPdf()` also calls `getQuotationDocumentPreviewData()` + - [src/features/foundation/pdf-generator/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts:326) + +Verified preview builder chain: + +- `getQuotationDocumentPreviewData()` builds `documentData` +- resolves active template and active version +- resolves version mappings +- maps `documentData` to `templateInput` +- applies topic page expansion + +Reference: + +- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:661) + +### 1.2 Same active template/version/mapping pipeline + +Confirmed: `document-preview`, `pdf-preview`, `pdf-download`, and approved PDF generation all depend on the same active template/version/mapping pipeline. + +Shared resolution calls: + +- `resolveTemplateForDocument()` + - [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:669) +- `resolveTemplateMappings()` + - [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:674) +- `mapDocumentDataToTemplateInput()` + - [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:688) + +Conclusion: + +- there is one active runtime pipeline for quotation PDF generation +- there is no separate preview-only template source +- there is no separate download-only template source +- approved PDF generation reuses the same active version before persistence + +--- + +## 2. Active Template Runtime Source Verification + +### 2.1 Database active versions + +Verified against live database: + +```json +[ + { + "organizationName": "ALLA", + "templateName": "ALLA Quotation Standard", + "version": "1.0", + "filePath": "src/pdfme_template/ALLA_template_pdfme_fainal3.json", + "mappingCount": "20" + }, + { + "organizationName": "ONVALLA", + "templateName": "ONVALLA Quotation Standard", + "version": "1.0", + "filePath": "src/pdfme_template/ONVALLA_template_pdfme_fainal3.json", + "mappingCount": "20" + } +] +``` + +Interpretation: + +- runtime selects active version rows from DB +- each active version still keeps `filePath` pointing to the original JSON file source +- mapping count is currently `20` for both active versions + +### 2.2 DB schema vs file source + +Verified that the active version `schema_json` in DB matches the JSON file on disk exactly for both organizations. + +Evidence: + +```json +{ + "organization": "ALLA", + "filePath": "src/pdfme_template/ALLA_template_pdfme_fainal3.json", + "matches": true +} +{ + "organization": "ONVALLA", + "filePath": "src/pdfme_template/ONVALLA_template_pdfme_fainal3.json", + "matches": true +} +``` + +Conclusion: + +- runtime renders from `schema_json` stored in DB +- the current DB value is seeded from the JSON file and currently matches it +- changing only the JSON file will not change runtime behavior unless DB version rows are reseeded or updated + +### 2.3 Seed/runtime source conclusion + +Runtime uses both, but with different roles: + +- DB active template/version/mapping is the actual runtime source +- JSON file is the seed/reseed source of truth for version payload creation + +This means P.4 must update: + +- new template JSON files +- seed or reseed logic that publishes those JSON files into DB versions + +Relevant current source selectors: + +- [src/db/seeds/foundation.seed.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts:854) +- [scripts/pdf-audit-utils.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/scripts/pdf-audit-utils.ts:667) + +--- + +## 3. Current Template Page Structure + +### 3.1 Active page count + +Verified from active DB runtime schema: + +- `ALLA` active template version has `2` pages +- `ONVALLA` active template version has `2` pages + +### 3.2 Schema names per page + +Verified runtime schema dump for `ALLA`: + +```text +PAGE 1 FIELDS: +company1, company2, company_addr1, company_addr2, company_tel, company_email, +company_tax, line, customer_name, customer_addr, customer_tel, customer_email, +dear_sirs, quotation_price_data, quotation_date_labe, quotation_date_data, +quotation_code_lable, quotation_code_data, customer_att, project_name, +site_location, colon, colon copy, colon_tel, colon_fax, colon_email, +colon_att, colon_project, colon_site, tel_label, email_label, +att_label, project_label, site_label + +PAGE 2 FIELDS: +topic, data_topic, Please_do_not, yours_faithfuly, +app1, app1_position, app2, app2_position, app3, app3_position +``` + +Verified runtime schema dump for `ONVALLA`: + +```text +PAGE 1 FIELDS: +company, company_addr1, company_addr2, company_tel, company_email, +company_tax, line, customer_name, customer_addr, customer_tel, customer_email, +dear_sirs, quotation_price_data, quotation_date_labe, quotation_date_data, +quotation_code_lable, quotation_code_data, customer_att, project_name, +site_location, colon, colon copy, colon_tel, colon_fax, colon_email, +colon_att, colon_project, colon_site, tel_label, email_label, +att_label, project_label, site_label + +PAGE 2 FIELDS: +topic, data_topic, Please_do_not, yours_faithfuly, +app1, app1_position, app2, app2_position, app3, app3_position +``` + +### 3.3 Topic/signature page index + +Confirmed: + +- topic/signature page is currently page index `1` in zero-based runtime terms +- this matches the hardcoded topic engine default + +Reference: + +- [src/features/crm/quotations/document/server/pdf-topic-engine.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/pdf-topic-engine.ts:14) + +This is the key verification that explains why inserting a new page in the middle will break the current engine if no runtime change is made first. + +--- + +## 4. PDFMe Table Capability Verification + +### 4.1 Verification method + +Temporary verification was performed using: + +- `@pdfme/generator` +- `@pdfme/schemas` table plugin +- `@pdfme/pdf-lib` for page counting + +No production template was modified. + +A temporary verification PDF was generated at: + +- [rows_30_short.pdf](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/artifacts/task-p4-verification/rows_30_short.pdf) + +### 4.2 Runtime source-code evidence for repeat header + +The installed table plugin contains explicit repeat-header logic: + +```text +node_modules/@pdfme/schemas/dist/dynamicTemplate-CXBCt5wk.js:488 +schema.showHead = schema.showHead === false ? false : !schema.__isSplit || schema.repeatHead === true; + +node_modules/@pdfme/schemas/dist/dynamicTemplate-CXBCt5wk.js:704 +if (!(schema.repeatHead && isBlankPdf(args.basePdf) && headerHeight > 0)) return baseHeights; +``` + +Interpretation: + +- `repeatHead` is a real runtime behavior in the installed table plugin +- repeated header behavior is only applied when using blank/object-style `basePdf` +- this matches the P.4 requirement and also matches the project’s current template style + +### 4.3 Temporary generation results + +Verified generation results: + +```json +{"label":"rows_5_short","rowCount":5,"pageCount":1,"pdfBytes":8869} +{"label":"rows_30_short","rowCount":30,"pageCount":2,"pdfBytes":21737} +{"label":"rows_100_short","rowCount":100,"pageCount":4,"pdfBytes":55297} +{"label":"rows_30_long","rowCount":30,"pageCount":3,"pdfBytes":26739} +``` + +Verified conclusions: + +- `5 rows` fits on `1` page +- `30 rows` breaks to `2` pages +- `100 rows` breaks to `4` pages +- `30` long-description rows consume `3` pages instead of `2` + +### 4.4 Table behavior conclusions + +Confirmed: + +- auto page break works +- multi-page table generation works +- row height behavior changes pagination when descriptions become longer +- repeat header is implemented in the installed runtime and tied to `repeatHead: true` + +Important nuance: + +- visual image-based confirmation was not performed because this environment does not have PDF raster/text inspection tools installed +- however the runtime code path plus successful multi-page generation confirms the feature support exists in the actual installed library + +--- + +## 5. Product Item Mapping Verification + +### 5.1 Actual source fields found + +Verified live DB columns for `crm_quotation_items`: + +```text +id +organization_id +quotation_id +item_number +product_type +description +quantity +unit +unit_price +discount +discount_type +tax_rate +total_price +notes +sort_order +created_at +updated_at +deleted_at +``` + +### 5.2 Actual document runtime fields exposed + +Verified quotation document runtime exposes: + +- `itemNumber` +- `productTypeCode` +- `productTypeLabel` +- `description` +- `quantity` +- `unitCode` +- `unitLabel` +- `unitPrice` +- `discount` +- `discountTypeCode` +- `taxRate` +- `totalPrice` +- `notes` + +Reference: + +- [src/features/crm/quotations/document/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/server/service.ts:564) +- [src/features/crm/quotations/document/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/document/types.ts:83) + +### 5.3 Actual mapping coverage for `items_table` + +Verified active mapping rows already include `items_table` for both organizations. + +Current columns: + +- `Item` → `itemNumber` +- `Description` → `description` +- `Qty` → `quantity` +- `Unit` → `unitLabel` +- `Unit Price` → `unitPrice` +- `Total` → `totalPrice` + +### 5.4 Gap analysis against Task P.4.1 required fields + +| Required field | Verified source | Status | +| --- | --- | --- | +| product code | no dedicated field found | gap | +| description | `description` | available | +| specification | no dedicated field found | gap | +| quantity | `quantity` | available | +| unit | `unit` / `unitLabel` | available | +| unit price | `unitPrice` | available | +| discount | `discount` | available | +| amount | `totalPrice` | available | +| remark | closest existing field is `notes` | gap / mapping decision needed | + +Conclusion: + +- `product code` does not exist as a standalone quotation item field in the current model +- `specification` does not exist as a standalone quotation item field in the current model +- `remark` likely maps to `notes`, but this should be treated as a business decision, not assumed silently + +--- + +## 6. Final Implementation Strategy + +Recommended implementation order: + +1. generalize topic/signature runtime targeting so it no longer depends on hardcoded page index `1` +2. create new template JSON files + - `ALLA_template_pdfme_product_v1.json` + - `ONVALLA_template_pdfme_product_v1.json` +3. place the Product Item page between customer detail and topic/signature pages +4. configure the Product Item page with official PDFMe `table` schema using: + - `showHead: true` + - `repeatHead: true` + - blank/object-style `basePdf` +5. keep old versions untouched and inactive/active-switchable +6. update reseed/audit source selectors so the new JSON files can be published into DB +7. verify runtime behavior for both old and new versions through the existing routes + +Important implementation constraint: + +- because runtime currently renders from DB `schema_json`, adding only new JSON files is not sufficient +- reseed or explicit version creation in template admin is required for runtime adoption + +--- + +## 7. Rollback Plan + +Recommended rollback: + +1. do not delete the current active `1.0` versions +2. publish the new Product Item design as a separate version only +3. if regression occurs, deactivate the new version +4. reactivate the current `1.0` version in `/dashboard/crm/settings/templates` +5. if needed, revert reseed script changes independently from runtime code changes + +Why rollback is low-risk: + +- versioned template storage already exists +- runtime resolves active version dynamically +- current production version remains intact + +--- + +## 8. Regression Test Matrix + +### 8.1 Runtime routes + +Verify for both `ALLA` and `ONVALLA`: + +- `GET /api/crm/quotations/[id]/document-data` +- `GET /api/crm/quotations/[id]/document-preview` +- `GET /api/crm/quotations/[id]/pdf-preview` +- `GET /api/crm/quotations/[id]/pdf-download` +- approved PDF generation flow +- approved PDF retrieval flow after artifact exists + +### 8.2 Template versions + +Verify both: + +- old version `1.0` +- new product-item version + +### 8.3 Row-volume cases + +Verify with quotation items around: + +- 5 rows +- 30 rows +- 100 rows +- long wrapped descriptions + +### 8.4 Output expectations + +Verify: + +- old template still renders exactly as before +- new template inserts Product Item page between page 1 and topic/signature page +- topic/signature block still lands on the correct page +- repeated table header appears when multiple product pages exist +- long descriptions affect row height without layout corruption +- preview and download use the same selected version +- approved PDF persists the expected template version ID + +--- + +## 9. Summary + +Task P.4.1 verification confirms: + +- quotation PDF runtime uses one shared active template/version/mapping pipeline +- current active runtime template is still the 2-page `*_fainal3.json` structure +- runtime source is DB `schema_json`, while JSON files remain the seed/reseed source +- official PDFMe table support is sufficient for multi-page product item tables +- current quotation item model is missing dedicated `product code` and `specification` fields +- the biggest implementation blocker is still the topic engine’s hardcoded page targeting, not the table plugin itself diff --git a/plans/task-p.4.1.md b/plans/task-p.4.1.md new file mode 100644 index 0000000..88af037 --- /dev/null +++ b/plans/task-p.4.1.md @@ -0,0 +1,67 @@ +# Task P.4.1 - PDFMe Product Item Template Runtime Verification + +Before implementing Task P.4, perform runtime verification against the real codebase. + +Do not implement yet. + +Verify: + +1. Current quotation PDF runtime +- document-preview +- pdf-preview +- pdf-download +- approved-pdf + +Confirm whether they use the same active template/version/mapping pipeline. + +2. Current template page structure +- load active quotation PDFMe template from runtime source +- dump page count +- dump schema names per page +- confirm topic/signature page index + +3. PDFMe table capability +Create a temporary verification only. +Test official PDFMe table with: +- 5 rows +- 30 rows +- 100 rows + +Confirm: +- repeat header +- auto page break +- multi-page behavior +- row height behavior + +4. Product item mapping +Verify actual source fields for: +- product code +- description +- specification +- quantity +- unit +- unit price +- discount +- amount +- remark + +If no field exists, document the gap. + +5. Seed/runtime source +Confirm whether runtime uses: +- database active template +- JSON file template +- both + +Explain whether seed scripts must be updated. + +Output required: +- verified runtime findings +- evidence/log snippets +- final implementation strategy +- rollback plan +- regression test matrix + +Do not modify production template files. +Do not create a new rendering pipeline. +Do not begin implementation until this verification report is complete. diff --git a/plans/task-p.4.2-runtime-design.md b/plans/task-p.4.2-runtime-design.md new file mode 100644 index 0000000..8601b8a --- /dev/null +++ b/plans/task-p.4.2-runtime-design.md @@ -0,0 +1,1228 @@ +# Task P.4.2 Runtime Design + +## Scope + +This document defines the revised architecture design for the next-generation CRM PDF runtime requested by [task-p.4.2.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.4.2.md). + +This is architecture work only. + +- no production code changes +- no template JSON changes +- no database changes +- no API changes + +This design reuses verified findings from: + +- [task-p.4-discovery-report.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.4-discovery-report.md) +- [task-p.4.1-verification-report.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.4.1-verification-report.md) + +--- + +## 1. Current Architecture Summary + +### 1.1 Current runtime shape + +The current quotation PDF runtime is effectively: + +```text +Quotation Document Data + -> Active Template Resolution + -> Mapping Resolution + -> Mutate Topic Page + -> PDF Generator +``` + +The same underlying pipeline is used by: + +- document preview +- PDF preview +- PDF download +- approved PDF generation + +### 1.2 Current strengths + +The existing foundation already gives us: + +- DB-backed active template versioning +- DB-backed placeholder mappings +- shared quotation document data builder +- shared PDF generation gateway +- working topic pagination +- working PDFMe table support +- safe rollback through template version activation + +### 1.3 Current architectural problem + +The main problem is not PDF generation itself. +The problem is that rendering behavior depends on page order. + +Today the topic runtime assumes: + +```ts +schemas[1] +``` + +is always the topic/signature page. + +That means business logic currently depends on: + +- physical page indexes + +instead of: + +- logical document roles +- logical sections + +This makes page insertion fragile and blocks optional section composition. + +--- + +## 2. Proposed Architecture + +### 2.1 Architecture principles + +The new runtime must be: + +- page-role driven +- section-based +- version compatible +- renderer independent +- extensible + +### 2.2 Architectural shift + +The runtime should move from: + +```text +Template + -> Mutate Page 2 + -> Generate PDF +``` + +to: + +```text +Document Data + -> Template Resolver + -> Mapping Resolver + -> Compatibility Adapter + -> Page Resolver + -> Render Policy Resolver + -> Section Composer + -> Template Assembler + -> PDF Generator +``` + +### 2.3 Main design idea + +The core design change is: + +- treat document content as logical sections +- treat pages as render targets discovered by role +- let render policy decide whether each section should render +- let the assembler compose the final template from section outputs + +This means future changes are made by adding: + +- a new section role +- a new section builder +- a new marker rule +- a new assembly rule + +instead of rewriting the whole runtime. + +--- + +## 3. Section-Based Rendering Model + +### 3.1 Section concept + +A `Document Section` represents logical document content, not fixed physical pages. + +Example sections: + +- `customer` +- `product_items` +- `topics` +- `conditions` +- `signature` +- `attachments` +- `appendix` +- `warranty` +- `cover` + +### 3.2 Section output behavior + +A section may generate: + +- zero pages +- one page +- multiple pages + +This is important because: + +- product items may paginate to many pages +- topics may paginate to many pages +- optional sections may render nothing + +### 3.3 Separation rule + +Each section builder must own only its own content domain. + +Examples: + +- Product Item rendering must not know topic rendering +- Topic rendering must not know product rendering +- Signature placement must not know product table layout internals + +--- + +## 4. Optional Sections and Render Policy + +### 4.1 Motivation + +The new requirement adds optional section support. + +That means the runtime must support skipping logical sections without requiring: + +- another template version +- another runtime pipeline + +### 4.2 Section requirement model + +Each section should be evaluated through an explicit render policy. + +Proposed contract: + +```ts +type PageRole = + | 'customer' + | 'product_items' + | 'topics' + | 'conditions' + | 'signature' + | 'attachments' + | 'appendix' + | 'warranty' + | 'cover' + | 'unknown'; + +interface RenderPolicy { + section: PageRole; + enabled: boolean; + required: boolean; + visibleWhenEmpty: boolean; +} +``` + +### 4.3 Policy meaning + +- `enabled` + - whether the runtime should attempt to render the section +- `required` + - whether missing output should be treated as error +- `visibleWhenEmpty` + - whether an enabled section should still render a shell/placeholder when it has no content + +### 4.4 Architectural examples + +Example policy set: + +```text +Customer: required +Product Items: optional +Topics: optional +Conditions: optional +Signature: required +``` + +### 4.5 Policy source + +Policy source is intentionally undefined in this task. + +Future tasks may supply it from: + +- user selection +- customer preference +- template defaults +- organization defaults + +The architecture only needs a clean slot for the policy, not a persistence/UI decision yet. + +### 4.6 Important constraint + +Template versions should be used for: + +- structural differences +- layout differences + +Template versions should not be used solely for: + +- enabling or disabling logical sections + +That responsibility belongs to render policy. + +--- + +## 5. Runtime Pipeline + +### 5.1 Proposed pipeline + +```text +Document Data + -> Template Resolver + -> Mapping Resolver + -> Compatibility Adapter + -> Page Resolver + -> Render Policy Resolver + -> Section Composer + -> Customer Section Builder + -> Product Item Engine + -> Topic Engine + -> Condition Engine + -> Signature Resolver + -> Template Assembler + -> PDF Generator +``` + +### 5.2 Stage descriptions + +#### Stage 1: Document Data + +Builds normalized business data: + +- quotation fields +- customer fields +- product items +- topics +- signatures +- approval metadata + +Does not know: + +- page structure +- template markers +- physical page ordering + +#### Stage 2: Template Resolver + +Finds: + +- active template +- active version +- runtime `schemaJson` +- template metadata + +Does not know: + +- business sections +- page roles +- render policy + +#### Stage 3: Mapping Resolver + +Finds: + +- placeholder mappings +- table column mappings +- defaults +- format masks + +Does not know: + +- page placement +- optionality rules + +#### Stage 4: Compatibility Adapter + +Normalizes legacy and future template models into one page-role-aware runtime model. + +Owns: + +- legacy page-role inference +- explicit marker interpretation +- marker validation +- compatibility warnings + +#### Stage 5: Page Resolver + +Builds a page inventory from the normalized template. + +Owns: + +- page lookup by role +- duplicate detection +- fallback ordering +- insertion anchors + +#### Stage 6: Render Policy Resolver + +Produces the effective policy for each section. + +Owns: + +- section enablement state +- required/optional state +- visible-when-empty behavior + +Does not own: + +- section rendering itself + +#### Stage 7: Section Composer + +Coordinates section builders in policy order. + +Owns: + +- builder orchestration +- passing section inputs +- collecting built sections +- skipping disabled sections + +Does not own: + +- detailed rendering logic of each section + +#### Stage 8+: Section Builders + +Each builder produces section output only for its own section. + +Builders include: + +- Customer Section Builder +- Product Item Engine +- Topic Engine +- Condition Engine +- Signature Resolver + +#### Final Stage: Template Assembler + +Combines: + +- preserved template pages +- generated section pages +- section input patches + +into one final assembled template. + +#### Final Stage: PDF Generator + +Renders the assembled result through the existing PDFMe gateway. + +--- + +## 6. Runtime Components + +### 6.1 Template Resolver + +Responsibility: + +- resolve active template/version +- return raw template payload + +Input: + +- organization id +- document type +- product type +- file type + +Output: + +- `ResolvedTemplate` + +### 6.2 Mapping Resolver + +Responsibility: + +- resolve version mappings and table columns + +Input: + +- `ResolvedTemplate` + +Output: + +- `ResolvedTemplateMappings` + +### 6.3 Compatibility Adapter + +Responsibility: + +- convert raw runtime template into normalized page-role-aware model +- handle legacy inference +- expose compatibility issues + +Input: + +- `ResolvedTemplate` +- `ResolvedTemplateMappings` + +Output: + +- `CompatibleTemplate` + +### 6.4 Page Resolver + +Responsibility: + +- resolve page identities +- build role-based lookup +- determine insertion anchors + +Input: + +- `CompatibleTemplate` + +Output: + +- `ResolvedPages` + +### 6.5 Render Policy Resolver + +Responsibility: + +- determine which sections should render +- determine required/optional behavior +- determine empty-state display rules + +Input: + +- document data +- template metadata +- default runtime policy + +Output: + +- `ResolvedRenderPolicies` + +### 6.6 Section Composer + +Responsibility: + +- orchestrate section builders +- pass shared context +- collect built section outputs + +Input: + +- document data +- resolved pages +- resolved render policies + +Output: + +- `BuiltSections` + +### 6.7 Customer Section Builder + +Responsibility: + +- build customer section only +- preserve customer-page behavior + +Input: + +- customer data +- target customer page + +Output: + +- `BuiltSection<'customer'>` + +### 6.8 Product Item Engine + +Responsibility: + +- build product item pages only +- paginate table +- repeat headers +- handle dynamic row height + +Input: + +- items +- product-items base page +- product section render policy + +Output: + +- `BuiltSection<'product_items'>` + +### 6.9 Topic Engine + +Responsibility: + +- build topic pages only +- paginate topics + +Input: + +- topics +- topic base page +- topic section render policy + +Output: + +- `BuiltSection<'topics'>` + +### 6.10 Condition Engine + +Responsibility: + +- build condition/terms section only + +Input: + +- condition data or condition template markers +- condition policy + +Output: + +- `BuiltSection<'conditions'>` + +### 6.11 Signature Resolver + +Responsibility: + +- build signature section only +- handle keep-together logic +- last-page placement policy + +Input: + +- signatures +- signature page +- prior assembled content +- signature policy + +Output: + +- `BuiltSection<'signature'>` + +### 6.12 Template Assembler + +Responsibility: + +- combine preserved and generated pages +- apply assembly order +- merge input patches + +Input: + +- compatible template +- built sections + +Output: + +- `AssembledTemplate` + +### 6.13 PDF Render Gateway + +Responsibility: + +- font loading +- plugin registration +- final PDFMe rendering + +Input: + +- `AssembledTemplate` + +Output: + +- rendered PDF bytes + +--- + +## 7. Page Marker Strategy + +### 7.1 Goal + +The runtime must never depend on: + +```ts +schemas[1] +``` + +The runtime must resolve pages by logical markers and fallbacks. + +### 7.2 Explicit marker strategy + +Future templates should include explicit role markers. + +Example: + +```ts +{ + name: '__page_role__product_items', + type: 'text', + content: 'product_items' +} +``` + +or + +```ts +{ + name: '__page_role__topics', + type: 'text', + content: 'topics' +} +``` + +### 7.3 Legacy inference strategy + +Existing templates do not have explicit markers. + +Legacy inference rules should be centralized in the compatibility adapter. + +Examples: + +- page containing customer identity fields => `customer` +- page containing `topic` + `data_topic` => `topics` +- page containing `app1`/`app2`/`app3` and closing signature text => `signature` +- future page containing `items_table` => `product_items` +- future page containing dedicated condition markers => `conditions` + +### 7.4 Fallback detection + +If no explicit marker or legacy inference succeeds: + +- mark page as `unknown` +- record a runtime issue +- optionally continue if page is non-required + +### 7.5 Marker precedence + +Priority order: + +1. explicit marker +2. legacy inference +3. fallback guess +4. unknown + +--- + +## 8. Runtime Contracts + +### 8.1 Core contracts + +```ts +type PageRole = + | 'customer' + | 'product_items' + | 'topics' + | 'conditions' + | 'signature' + | 'attachments' + | 'appendix' + | 'warranty' + | 'cover' + | 'unknown'; + +interface ResolvedTemplate { + templateId: string; + templateVersionId: string; + templateName: string; + version: string; + schemaJson: Template; +} + +interface ResolvedTemplateMappings { + mappings: DocumentTemplateMappingWithColumns[]; +} + +interface ResolvedPage { + pageIndex: number; + role: PageRole; + strategy: 'explicit_marker' | 'legacy_inference' | 'fallback'; + schema: Template['schemas'][number]; +} + +interface ResolvedPages { + all: ResolvedPage[]; + byRole: Partial>; + issues: RuntimeIssue[]; +} + +interface RenderPolicy { + section: PageRole; + enabled: boolean; + required: boolean; + visibleWhenEmpty: boolean; +} + +interface BuiltSection { + role: TRole; + enabled: boolean; + rendered: boolean; + pages: Template['schemas']; + templateInputPatch: Record; + issues: RuntimeIssue[]; +} + +interface RuntimeIssue { + code: + | 'MISSING_MARKER' + | 'DUPLICATE_MARKER' + | 'INVALID_TEMPLATE' + | 'MISSING_MAPPING' + | 'EMPTY_OPTIONAL_SECTION' + | 'EMPTY_REQUIRED_SECTION' + | 'LEGACY_COMPAT_MODE'; + severity: 'warning' | 'error'; + message: string; + details?: Record; +} + +interface AssembledTemplate { + template: Template; + templateInput: Record; + issues: RuntimeIssue[]; +} +``` + +### 8.2 Ownership boundaries + +- `ResolvedTemplate` belongs to template resolution +- `ResolvedTemplateMappings` belongs to mapping resolution +- `ResolvedPages` belongs to page resolution +- `RenderPolicy` belongs to policy resolution +- `BuiltSection` belongs to section builders +- `AssembledTemplate` belongs to assembly only +- `RuntimeIssue` is shared, accumulative diagnostic output + +--- + +## 9. Section Composer Design + +### 9.1 Purpose + +The section composer is a new first-class component introduced by the updated requirement. + +Its job is to coordinate section rendering independently from page index assumptions. + +### 9.2 Responsibilities + +- evaluate effective render policy +- call only enabled section builders +- preserve required section order +- collect built outputs +- collect runtime issues + +### 9.3 Example composition order + +Suggested default composition order: + +1. `customer` +2. `product_items` +3. `topics` +4. `conditions` +5. `signature` +6. `attachments` +7. `appendix` + +This order is logical, not tied to page indexes. + +### 9.4 Composer interface + +```ts +interface SectionComposerInput { + documentData: QuotationDocumentData; + resolvedPages: ResolvedPages; + policies: RenderPolicy[]; +} + +interface SectionComposerOutput { + sections: BuiltSection[]; + issues: RuntimeIssue[]; +} +``` + +--- + +## 10. Compatibility Strategy + +### 10.1 Objective + +Support: + +- legacy templates +- future marker-based templates + +without duplicating runtime logic. + +### 10.2 Design rule + +Legacy support must be isolated in: + +- `Compatibility Adapter` + +Every component after that should consume normalized contracts only. + +### 10.3 Why this matters + +Without this layer, optional sections and page-role logic would leak branching across: + +- topic engine +- product item engine +- assembler +- signature logic + +That would make the runtime harder to extend and harder to verify. + +### 10.4 Compatibility outcome + +Legacy templates stay valid because: + +- their pages can still be inferred by field patterns +- their mappings remain unchanged +- their active versions remain selectable + +Future templates become simpler because: + +- markers are explicit +- sections are additive +- optionality is policy-driven, not template-version-driven + +--- + +## 11. Future Extensibility + +### 11.1 Additive extension model + +A new document section should require only: + +1. a `PageRole` +2. a section builder +3. a marker rule +4. an assembly rule + +### 11.2 Supported future examples + +- drawing +- specification +- gallery +- warranty +- appendix +- inspection report + +### 11.3 Builder isolation rule + +Existing section builders must not need modification when a new section is added, unless a shared contract truly changes. + +That keeps the runtime additive instead of fragile. + +--- + +## 12. Error Handling Strategy + +### 12.1 Missing marker + +Behavior: + +- try explicit marker +- try legacy inference +- try fallback guess +- otherwise emit `MISSING_MARKER` + +### 12.2 Duplicate marker + +Behavior: + +- duplicate singleton section like `customer` or `signature` => error +- duplicate repeatable section like `attachments` or `appendix` => warning only if ambiguous + +### 12.3 Missing mappings + +Behavior: + +- if required mapped section cannot render => error +- if optional section cannot render => warning and skip if policy allows + +### 12.4 Empty optional section + +Behavior: + +- emit `EMPTY_OPTIONAL_SECTION` +- skip rendering when `visibleWhenEmpty = false` +- render placeholder shell when `visibleWhenEmpty = true` + +### 12.5 Empty required section + +Behavior: + +- emit `EMPTY_REQUIRED_SECTION` +- fail gracefully before final PDF generation if policy requires output + +### 12.6 Invalid template + +Behavior: + +- stop assembly +- return `INVALID_TEMPLATE` + +### 12.7 Issue accumulation + +All stages append to `RuntimeIssue[]`. + +This supports: + +- preview diagnostics +- audit tooling +- rollout safety + +This remains consistent with ADR-0013 parity/audit discipline. + +--- + +## 13. Migration Strategy + +### 13.1 Migration goal + +Move from the current runtime to a section-based runtime without breaking: + +- preview +- download +- approved PDF +- existing template versions + +### 13.2 Constraints + +- no database migration +- no API changes +- no approved artifact rewrite + +### 13.3 Safe migration path + +1. introduce runtime contracts +2. introduce compatibility adapter +3. introduce page resolver +4. introduce render policy resolver +5. introduce section composer +6. refactor topic engine to consume resolved topic page +7. add product item engine +8. add assembler-driven final composition +9. publish new structural template versions only when needed + +### 13.4 Approved PDF safety + +Already stored approved PDFs remain safe because retrieval uses stored artifact binaries, not live re-rendering. + +Only future generated documents will use the refactored runtime. + +--- + +## 14. Sequence Diagrams + +### 14.1 Current Runtime + +```mermaid +sequenceDiagram + participant U as User/Route + participant DS as Document Service + participant TR as Template Resolver + participant MR as Mapping Resolver + participant TE as Topic Engine + participant PG as PDF Generator + + U->>DS: Request preview/download + DS->>DS: buildQuotationDocumentData() + DS->>TR: resolveTemplateForDocument() + DS->>MR: resolveTemplateMappings() + DS->>DS: mapDocumentDataToTemplateInput() + DS->>TE: buildPdfTopicTemplate() + TE-->>DS: mutated template + topic inputs + DS-->>PG: final template + templateInput + PG-->>U: PDF bytes +``` + +### 14.2 Proposed Runtime + +```mermaid +sequenceDiagram + participant U as User/Route + participant DD as Document Data + participant TR as Template Resolver + participant MR as Mapping Resolver + participant CA as Compatibility Adapter + participant PR as Page Resolver + participant RP as Render Policy Resolver + participant SC as Section Composer + participant CB as Customer Builder + participant PI as Product Item Engine + participant TO as Topic Engine + participant CO as Condition Engine + participant SG as Signature Resolver + participant TA as Template Assembler + participant PG as PDF Generator + + U->>DD: Request preview/download + DD-->>TR: documentData + TR-->>MR: resolvedTemplate + MR-->>CA: mappings + template + CA-->>PR: compatibleTemplate + PR-->>RP: resolvedPages + RP-->>SC: render policies + SC->>CB: build customer section + SC->>PI: build product section + SC->>TO: build topic section + SC->>CO: build condition section + SC->>SG: build signature section + CB-->>SC: built customer section + PI-->>SC: built product section + TO-->>SC: built topic section + CO-->>SC: built condition section + SG-->>SC: built signature section + SC-->>TA: built sections + TA-->>PG: assembled template + input + PG-->>U: PDF bytes +``` + +--- + +## 15. Component Diagram + +```mermaid +classDiagram + class TemplateResolver + class MappingResolver + class CompatibilityAdapter + class PageResolver + class RenderPolicyResolver + class SectionComposer + class CustomerSectionBuilder + class ProductItemEngine + class TopicEngine + class ConditionEngine + class SignatureResolver + class TemplateAssembler + class PdfRenderGateway + + TemplateResolver --> MappingResolver + MappingResolver --> CompatibilityAdapter + CompatibilityAdapter --> PageResolver + PageResolver --> RenderPolicyResolver + RenderPolicyResolver --> SectionComposer + SectionComposer --> CustomerSectionBuilder + SectionComposer --> ProductItemEngine + SectionComposer --> TopicEngine + SectionComposer --> ConditionEngine + SectionComposer --> SignatureResolver + SectionComposer --> TemplateAssembler + TemplateAssembler --> PdfRenderGateway +``` + +--- + +## 16. Risk Assessment + +### 16.1 Main risks + +- legacy inference becomes too magical if not centralized +- optional section policy becomes entangled with template versioning +- section builders may start leaking logic into each other +- assembler may become implicit if order rules are not declared clearly + +### 16.2 Mitigations + +- keep all legacy branching inside compatibility adapter +- keep all enable/disable logic inside render policy resolver +- keep all orchestration inside section composer +- keep all page order rules inside assembler +- keep template versions for layout/structure, not section toggles + +--- + +## 17. Implementation Roadmap + +### Phase 1: Runtime contracts + +Deliverables: + +- `PageRole` +- `RenderPolicy` +- `BuiltSection` +- `RuntimeIssue` +- `AssembledTemplate` + +### Phase 2: Compatibility adapter + +Deliverables: + +- explicit marker support +- legacy inference support +- normalized compatible template output + +### Phase 3: Page resolver + +Deliverables: + +- role-based page inventory +- duplicate/missing marker diagnostics + +### Phase 4: Render policy resolver + +Deliverables: + +- effective section policy resolution +- required/optional rules +- visible-when-empty rules + +### Phase 5: Section composer + +Deliverables: + +- builder orchestration +- section collection +- policy-driven skipping + +### Phase 6: Product item engine + +Deliverables: + +- item table rendering +- pagination +- repeated headers + +### Phase 7: Template upgrade + +Deliverables: + +- structural template versions for new layouts only + +### Phase 8: Regression verification + +Deliverables: + +- preview/download/approved regression checks +- old/new version parity checks + +Each phase must be independently testable. + +--- + +## 18. Final Recommendation + +Task P.4.3 should implement a `section-based runtime` with `render policy` and `page-role resolution` as first-class architecture concepts. + +The most important architectural order is: + +1. contracts +2. compatibility adapter +3. page resolver +4. render policy resolver +5. section composer +6. section builders +7. assembler + +This keeps the runtime: + +- backward compatible +- optional-section capable +- future-extensible +- independent from page indexes + +and leaves minimal architectural ambiguity for implementation. diff --git a/plans/task-p.4.2.md b/plans/task-p.4.2.md new file mode 100644 index 0000000..d68fbf6 --- /dev/null +++ b/plans/task-p.4.2.md @@ -0,0 +1,475 @@ +# Task P.4.2 - PDF Runtime Architecture & Section-based Rendering Design + +## Objective + +Design the next-generation PDF runtime architecture for the CRM document system. + +The new runtime must support: + +* Dynamic page insertion +* Section-based document composition +* Optional document sections +* Future extensibility +* Full backward compatibility + +This is an architecture task only. + +Do NOT modify production code. + +Do NOT modify template JSON. + +Do NOT modify database schema. + +--- + +# Inputs + +Reuse verified findings from + +* Task P.4 Discovery +* Task P.4.1 Runtime Verification + +Do not repeat discovery. + +--- + +# Design Principles + +The runtime must become + +* page-role driven +* section-based +* version compatible +* renderer independent +* extensible + +Business logic must not depend on page indexes. + +--- + +# Core Architecture + +The runtime shall assemble a document from independent sections. + +Instead of + +```text +Template + +↓ + +Mutate Page 2 + +↓ + +Generate PDF +``` + +the runtime shall become + +```text +Quotation Document Data + +↓ + +Template Resolver + +↓ + +Mapping Resolver + +↓ + +Compatibility Adapter + +↓ + +Page Resolver + +↓ + +Section Composer + +↓ + +Template Assembler + +↓ + +PDF Generator +``` + +--- + +# Section-based Rendering + +Introduce the concept of + +Document Sections. + +Examples + +* Customer +* Product Items +* Topics +* Conditions +* Signature +* Attachments +* Appendix +* Warranty +* Cover + +A section represents logical document content rather than physical pages. + +A section may generate + +* zero pages +* one page +* multiple pages + +--- + +# Optional Sections + +The runtime must support optional sections. + +A section may be + +* Required +* Optional + +The runtime shall be capable of skipping optional sections without requiring another template version. + +Examples + +```text +Customer +Required + +Product Items +Optional + +Topics +Optional + +Conditions +Optional + +Signature +Required +``` + +This capability is architectural only. + +No UI configuration is required in this task. + +--- + +# Render Policy + +Design a Render Policy abstraction. + +Example + +```ts +interface RenderPolicy { + section: PageRole; + + enabled: boolean; + + required: boolean; + + visibleWhenEmpty: boolean; +} +``` + +The runtime shall evaluate Render Policy before rendering each section. + +The policy source is intentionally undefined in this task. + +Future tasks may provide it from + +* user selection +* customer preference +* template defaults +* organization defaults + +--- + +# Runtime Pipeline + +Design + +```text +Document Data + +↓ + +Template Resolver + +↓ + +Mapping Resolver + +↓ + +Compatibility Adapter + +↓ + +Page Resolver + +↓ + +Render Policy Resolver + +↓ + +Section Composer + +↓ + +Customer Section + +↓ + +Product Item Section + +↓ + +Topic Section + +↓ + +Condition Section + +↓ + +Signature Section + +↓ + +Template Assembler + +↓ + +PDF Generator +``` + +Describe every stage. + +--- + +# Runtime Components + +Design responsibilities for + +* Template Resolver +* Mapping Resolver +* Compatibility Adapter +* Page Resolver +* Render Policy Resolver +* Customer Section Builder +* Product Item Engine +* Topic Engine +* Condition Engine +* Signature Resolver +* Template Assembler +* PDF Render Gateway + +Each component must have a single responsibility. + +--- + +# Page Marker Strategy + +Design page discovery using logical markers instead of indexes. + +Support + +* explicit page markers +* legacy inference +* fallback detection + +The runtime must never rely on + +```ts +schemas[1] +``` + +--- + +# Runtime Contracts + +Define contracts for + +* ResolvedTemplate +* ResolvedPages +* RenderPolicy +* BuiltSection +* RuntimeIssue +* AssembledTemplate + +Document ownership and responsibilities. + +--- + +# Compatibility Strategy + +Support + +* legacy templates +* future templates + +without duplicating runtime logic. + +Legacy templates shall work through the Compatibility Adapter. + +--- + +# Future Extensibility + +The architecture shall allow adding new document sections without changing existing section builders. + +Examples + +* Drawing +* Specification +* Gallery +* Warranty +* Appendix +* Inspection Report + +A new section should require only + +1. PageRole +2. Section Builder +3. Marker Rule +4. Assembly Rule + +--- + +# Error Handling + +Design behavior for + +* missing marker +* duplicate marker +* missing mappings +* empty optional section +* empty required section +* invalid template + +Runtime shall fail gracefully and accumulate Runtime Issues. + +--- + +# Migration Strategy + +Design migration from + +Current Runtime + +↓ + +Section-based Runtime + +without breaking + +* Preview +* Download +* Approved PDF +* Existing Template Versions + +No database migration. + +No API changes. + +--- + +# Sequence Diagrams + +Produce + +* Current Runtime +* Proposed Runtime + +including + +* Render Policy +* Section Composition + +--- + +# Component Diagram + +Include + +* Template Resolver +* Compatibility Adapter +* Page Resolver +* Render Policy Resolver +* Section Composer +* Section Builders +* Template Assembler +* PDF Generator + +--- + +# Implementation Roadmap + +Split implementation into phases. + +Example + +Phase 1 + +Runtime Contracts + +Phase 2 + +Compatibility Adapter + +Phase 3 + +Page Resolver + +Phase 4 + +Section Composer + +Phase 5 + +Product Item Engine + +Phase 6 + +Template Upgrade + +Phase 7 + +Regression Tests + +Each phase must be independently testable. + +--- + +# Constraints + +* Architecture only. +* No production implementation. +* No template modification. +* No database changes. +* No API changes. + +--- + +# Acceptance Criteria + +* Runtime is section-based rather than page-index-based. +* Optional sections are supported by design. +* Product Item pages are independent from Topic pages. +* Existing template versions remain compatible. +* Future document sections can be added without redesigning the runtime. +* Template Version is used only for structural/layout differences, not for enabling or disabling document sections. +* The design is ready for implementation in Task P.4.3 with minimal architectural decisions remaining. diff --git a/plans/task-p.4.3.md b/plans/task-p.4.3.md new file mode 100644 index 0000000..6e143be --- /dev/null +++ b/plans/task-p.4.3.md @@ -0,0 +1,437 @@ +# Task P.4.3 - PDF Runtime Refactoring (Regression-first) + +## Objective + +Implement the new section-based PDF runtime architecture designed in Task P.4.2. + +This task is a **runtime refactor only**. + +The primary objective is to replace the current page-index-driven runtime with the new section-based runtime **without changing the rendered output of existing templates**. + +This task must not introduce new PDF layouts or Product Item pages yet. + +--- + +# Prerequisites + +Complete and reuse: + +* Task P.4 Discovery +* Task P.4.1 Runtime Verification +* Task P.4.2 Runtime Design + +Do not redesign the architecture. + +Implement the approved design only. + +--- + +# Primary Goal + +Move from + +```text +Hardcoded Page Index + +↓ + +Topic Engine + +↓ + +PDF +``` + +to + +```text +Section-based Runtime + +↓ + +Section Composer + +↓ + +Template Assembler + +↓ + +PDF +``` + +while maintaining identical output for all existing templates. + +--- + +# Regression-first Rule + +This task follows a strict Regression-first strategy. + +Before introducing any new rendering capability: + +* existing templates must continue producing identical PDFs +* preview, download and approved PDF must remain unchanged +* all regression tests must pass + +Feature additions are intentionally deferred. + +--- + +# Scope + +Implement only runtime architecture. + +Included + +* runtime contracts +* compatibility adapter +* page resolver +* section composer +* render context +* topic engine refactor +* template assembler +* runtime diagnostics + +Excluded + +* Product Item page +* Product Item rendering +* new template JSON +* CRM Template UI +* user render options +* template version changes +* database changes + +--- + +# Implementation Requirements + +## Phase 1 — Runtime Contracts + +Create runtime contracts. + +At minimum + +* SectionRole +* RenderContext +* ResolvedTemplate +* ResolvedPages +* BuiltSection +* RuntimeIssue +* AssembledTemplate + +Responsibilities must match Task P.4.2. + +--- + +## Phase 2 — Compatibility Adapter + +Implement + +TemplateCompatibilityAdapter + +Responsibilities + +* explicit marker support +* legacy marker inference +* normalize legacy templates +* expose Runtime Issues + +Legacy templates must continue working without modification. + +--- + +## Phase 3 — Page Resolver + +Replace page-index assumptions. + +The runtime must never depend on + +```ts +schemas[1] +``` + +Responsibilities + +* discover pages +* classify by SectionRole +* detect duplicates +* detect missing markers +* produce insertion anchors + +--- + +## Phase 4 — Render Context + +Introduce a shared runtime context. + +Example + +```ts +interface RenderContext { + documentData; + template; + mappings; + pages; + policies; + issues; +} +``` + +Every runtime component should consume RenderContext instead of receiving unrelated parameters. + +The context must remain immutable where possible. + +--- + +## Phase 5 — Section Registry + +Implement a registry-based architecture. + +Example + +```ts +interface SectionBuilder { + role: SectionRole; + + build(context: RenderContext): BuiltSection; +} +``` + +The runtime must discover builders through registration rather than hardcoded orchestration. + +Section Composer must not know implementation details of builders. + +--- + +## Phase 6 — Section Composer + +Implement + +SectionComposer + +Responsibilities + +* execute builders +* skip disabled sections +* collect Runtime Issues +* preserve logical order +* return assembled section outputs + +The composer must not contain rendering logic. + +--- + +## Phase 7 — Topic Engine Refactor + +Refactor Topic Engine. + +Current behavior + +```text +Page Index + +↓ + +Topic Engine +``` + +New behavior + +```text +Resolved Page + +↓ + +Topic Engine +``` + +The generated output must remain identical. + +--- + +## Phase 8 — Template Assembler + +Implement + +TemplateAssembler + +Responsibilities + +* preserve existing pages +* assemble generated sections +* merge template inputs +* preserve page order +* return final template + +No Product Item pages yet. + +--- + +## Phase 9 — PDF Runtime Integration + +Wire the new runtime into + +* Preview +* Download +* Approved PDF + +All three flows must use the same runtime. + +No duplicated pipelines. + +--- + +# Runtime Diagnostics + +Implement RuntimeIssue collection. + +Support + +* MISSING_MARKER +* DUPLICATE_MARKER +* INVALID_TEMPLATE +* MISSING_MAPPING +* LEGACY_COMPAT_MODE + +Diagnostics must be available during Preview for debugging. + +--- + +# Backward Compatibility + +Existing templates must continue working without modification. + +Current + +* ALLA +* ONVALLA + +templates must render exactly as before. + +No migration required. + +--- + +# Refactoring Constraints + +Do NOT + +* modify template JSON +* introduce Product Item pages +* change template mappings +* modify database schema +* change API contracts +* change CRM Template UI + +--- + +# Code Quality Requirements + +* Single Responsibility Principle +* Dependency Injection where appropriate +* Immutable runtime contracts where practical +* No duplicated rendering logic +* No duplicated template resolution +* No duplicated mapping resolution +* Strong typing +* Comprehensive inline documentation for new runtime components + +--- + +# Testing Requirements + +Regression tests are mandatory. + +Verify + +## Runtime + +* Preview +* Download +* Approved PDF + +## Templates + +* ALLA +* ONVALLA + +## Rendering + +* Topic rendering +* Signature placement +* Mapping resolution +* Pagination + +Expected result + +Rendered PDF must remain visually identical to the previous runtime. + +--- + +# Deliverables + +Implementation must include + +* Runtime Contracts +* Compatibility Adapter +* Page Resolver +* RenderContext +* Section Registry +* Section Composer +* Refactored Topic Engine +* Template Assembler +* Runtime Diagnostics +* Updated Runtime Integration +* Regression Tests + +--- + +# Acceptance Criteria + +* No hardcoded page indexes remain. +* Runtime becomes section-based. +* Topic Engine no longer depends on page position. +* Legacy templates render without modification. +* Preview, Download and Approved PDF use one shared runtime. +* Existing PDF output remains unchanged. +* Runtime is ready for Product Item Engine implementation in Task P.4.4. +* No functional regression is introduced. + +--- + +# Out of Scope + +The following work belongs to later tasks: + +**Task P.4.4** + +* Product Item Engine +* Product Item pagination +* Product Item section rendering + +**Task P.4.5** + +* New PDF template versions +* Product Item template layout + +**Task P.4.6** + +* CRM Template integration +* Template activation +* Version publishing + +**Task P.4.7** + +* Render Policy configuration +* User-selectable section visibility +* Organization default rendering options + +--- + +# Final Success Condition + +At the end of Task P.4.3, the internal PDF runtime architecture shall be completely refactored to the new section-based design while producing identical output to the legacy runtime. + +No new user-visible functionality is expected in this task. The success metric is architectural modernization with zero regression, providing a stable foundation for subsequent Product Item and optional section features. diff --git a/plans/task-p.4.md b/plans/task-p.4.md new file mode 100644 index 0000000..9eb26a9 --- /dev/null +++ b/plans/task-p.4.md @@ -0,0 +1,326 @@ +# Task P.4 - PDFMe Product Item Table Template Refactoring + +## Objective + +Redesign the PDFMe quotation template to support dynamic Product Item tables by introducing a dedicated Product Item page between the existing Customer Detail page and Condition & Signature page. + +The implementation **must not modify or replace the existing template**. A completely new template version shall be created while maintaining full backward compatibility. + +--- + +# Mandatory Discovery Phase (Required) + +Before implementing anything, inspect the actual project structure and produce a discovery report. + +Do NOT make architectural assumptions. + +The implementation may only begin after the discovery phase is completed. + +--- + +# Discovery Scope + +Inspect the current implementation under the CRM Template module. + +At minimum review: + +## 1. CRM Template Module + +Inspect everything related to + +``` +/dashboard/crm/settings/templates +``` + +including + +* pages +* components +* dialogs +* APIs +* services +* repositories +* database schema +* template versioning +* template preview +* template import/export +* template activation flow + +Document how the template lifecycle currently works. + +--- + +## 2. PDF Generation Runtime + +Inspect the entire rendering pipeline. + +Including + +* quotation-preview.tsx +* PDF preview flow +* PDF download flow +* pdfme template loader +* template selector +* font loader +* plugin registration +* input builder +* layout engine +* topic engine +* schema builder +* adapter layer + +Produce a dependency graph showing how a quotation becomes a PDF. + +--- + +## 3. Existing PDF Templates + +Locate every existing PDFMe template. + +Document + +* current page count +* page purpose +* schema layout +* static schema +* dynamic schema +* reusable components + +Do not assume the current template structure. + +--- + +## 4. Product Item Flow + +Locate where quotation product items originate. + +Trace + +Quotation + +↓ + +Export Payload + +↓ + +Preview Model + +↓ + +Input Builder + +↓ + +PDF Template + +↓ + +PDF Generator + +Document every transformation layer. + +Do NOT duplicate DTOs or adapters if an existing one already provides the required data. + +--- + +## 5. Layout Engine + +Inspect the existing layout implementation. + +Determine whether it already supports + +* page insertion +* page cloning +* page replacement +* page splitting +* dynamic page generation +* table pagination +* automatic page break + +If not supported, + +design the extension before implementation. + +--- + +# Discovery Output + +Before coding, provide + +1. Existing architecture + +2. Dependency graph + +3. Current template lifecycle + +4. Runtime rendering flow + +5. Files that will be modified + +6. New files that will be created + +7. Backward compatibility analysis + +8. Risk assessment + +9. Migration strategy + +Implementation may only begin after this report. + +--- + +# Functional Requirements + +Current document + +``` +Page 1 +Customer Detail + +Page 2 +Condition + Signature +``` + +shall become + +``` +Page 1 +Customer Detail + +Page 2 +Product Item Detail Table + +Page 3 +Condition & Signature +``` + +--- + +# Product Item Page + +The Product Item page shall contain + +* quotation items +* item number +* product code +* description +* specification +* quantity +* unit +* unit price +* discount +* amount +* remark (if available) + +The table shall support + +* unlimited rows +* automatic page break +* repeated table header +* automatic page numbering if multiple product pages are required + +No fixed maximum row count. + +--- + +# PDFMe Table + +Use the official PDFMe Table implementation. + +Do not create a custom HTML rendering solution. + +Support + +* automatic pagination +* repeated headers +* dynamic row height +* variable content length + +--- + +# Template Versioning + +Do NOT modify + +* existing template JSON +* existing production template +* existing preview template + +Create a new version. + +Example + +``` +ALLA_template_pdfme_product_v1.json + +ONVALLA_template_pdfme_product_v1.json +``` + +The existing templates must continue working without any behavior changes. + +--- + +# Runtime Compatibility + +The preview system must support both + +Legacy Template + +and + +Product Item Template + +through template selection. + +No breaking changes. + +--- + +# CRM Template Module Compatibility + +The implementation must integrate with the existing + +``` +/dashboard/crm/settings/templates +``` + +workflow. + +Do not bypass + +* template storage +* template CRUD +* version management +* preview +* activation + +The new template shall behave exactly like existing templates. + +--- + +# Non-Functional Requirements + +* No breaking changes +* No duplicated rendering pipeline +* No duplicated adapters +* No duplicated DTOs +* No duplicated services +* Reuse existing layout engine whenever possible +* Extend existing architecture instead of replacing it + +--- + +# Acceptance Criteria + +* Discovery report completed before implementation. +* Existing templates remain unchanged. +* New Product Item template added as a separate version. +* Product Item page inserted between Customer Detail and Condition page. +* Unlimited Product Item rows supported. +* Automatic page break works correctly. +* Table headers repeat across pages. +* Preview works correctly. +* PDF download works correctly. +* Existing templates continue functioning without modification. +* `/dashboard/crm/settings/templates` fully supports the new template without regression. diff --git a/src/features/crm/quotations/document/server/page-resolver.ts b/src/features/crm/quotations/document/server/page-resolver.ts new file mode 100644 index 0000000..e4a6e3b --- /dev/null +++ b/src/features/crm/quotations/document/server/page-resolver.ts @@ -0,0 +1,52 @@ +import type { + CompatibleTemplate, + ResolvedPage, + ResolvedPages, + RuntimeIssue, + SectionRole, +} from './runtime-contracts'; +import { SINGLETON_SECTION_ROLES } from './runtime-contracts'; + +function createByRoleMap(): Partial> { + return {}; +} + +export function resolveTemplatePages( + compatibleTemplate: CompatibleTemplate, +): ResolvedPages { + const byRole = createByRoleMap(); + const issues: RuntimeIssue[] = [...compatibleTemplate.issues]; + const all: ResolvedPage[] = compatibleTemplate.pages.map((page) => ({ ...page })); + + for (const page of all) { + for (const role of page.roles) { + const pages = byRole[role] ?? []; + pages.push(page); + byRole[role] = pages; + } + } + + for (const [role, pages] of Object.entries(byRole) as Array< + [SectionRole, ResolvedPage[]] + >) { + if (!SINGLETON_SECTION_ROLES.has(role) || pages.length <= 1) { + continue; + } + + issues.push({ + code: 'DUPLICATE_MARKER', + severity: 'error', + message: 'PDF template resolves multiple singleton section pages.', + details: { + role, + pageIndexes: pages.map((page) => page.pageIndex), + }, + }); + } + + return { + all, + byRole, + issues, + }; +} diff --git a/src/features/crm/quotations/document/server/pdf-topic-engine.ts b/src/features/crm/quotations/document/server/pdf-topic-engine.ts index 013be81..cbc9f59 100644 --- a/src/features/crm/quotations/document/server/pdf-topic-engine.ts +++ b/src/features/crm/quotations/document/server/pdf-topic-engine.ts @@ -1,6 +1,7 @@ import type { Template } from '@pdfme/common'; import { formatTopicItems } from './pdfme-transforms'; import type { PdfTopic, PdfTopicEngineOptions } from './pdf-topic.type'; +import type { RuntimeIssue } from './runtime-contracts'; type TemplateSchema = { name?: string; @@ -13,6 +14,7 @@ type TemplateSchema = { const DEFAULT_TOPIC_ENGINE_OPTIONS: Required = { targetPageIndex: 1, + basePageIndex: 1, pageStartY: 35, pageBottomY: 250, topicSpacing: 10, @@ -24,81 +26,117 @@ const DEFAULT_TOPIC_ENGINE_OPTIONS: Required = { 'app2', 'app2_position', 'app3', - 'app3_position' + 'app3_position', ], topicTemplateName: 'topic', topicDataTemplateName: 'data_topic', rowHeight: 7, - keepTogetherMinSpace: 60 + keepTogetherMinSpace: 60, }; function cloneTemplate(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } -function getFieldY(field: TemplateSchema) { +function getFieldY(field: TemplateSchema): number { return typeof field.position?.y === 'number' ? field.position.y : 0; } -function getFieldBottom(field: TemplateSchema) { +function getFieldBottom(field: TemplateSchema): number { return getFieldY(field) + (typeof field.height === 'number' ? field.height : 0); } -function setFieldY(field: TemplateSchema, y: number) { - field.position = { - ...(field.position ?? {}), - y - }; +function setFieldY(field: TemplateSchema, y: number): void { + field.position = { ...(field.position ?? {}), y }; } -function sortTopics(topics: PdfTopic[]) { +function sortTopics(topics: PdfTopic[]): PdfTopic[] { return [...topics].sort((left, right) => { - const sortDelta = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER); - + const sortDelta = + (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - + (right.sortOrder ?? Number.MAX_SAFE_INTEGER); if (sortDelta !== 0) { return sortDelta; } - return String(left.id ?? left.topicType).localeCompare(String(right.id ?? right.topicType)); + return String(left.id ?? left.topicType).localeCompare( + String(right.id ?? right.topicType), + ); }); } -export function buildPdfTopicTemplate( - template: Template, - topics: PdfTopic[], - options?: PdfTopicEngineOptions -): { - template: Template; - topicInputs: Record; -} { - const resolvedOptions = { ...DEFAULT_TOPIC_ENGINE_OPTIONS, ...options }; - const nextTemplate = cloneTemplate(template); - const pages = Array.isArray(nextTemplate.schemas) ? [...nextTemplate.schemas] : []; - const targetPage = pages[resolvedOptions.targetPageIndex]; +function resolveOptions( + options?: PdfTopicEngineOptions, +): Required { + const basePageIndex = + options?.basePageIndex ?? options?.targetPageIndex ?? DEFAULT_TOPIC_ENGINE_OPTIONS.basePageIndex; - if (!Array.isArray(targetPage)) { - return { template: nextTemplate, topicInputs: {} }; + return { + ...DEFAULT_TOPIC_ENGINE_OPTIONS, + ...options, + basePageIndex, + targetPageIndex: basePageIndex, + }; +} + +export function buildPdfTopicPages( + basePage: Template['schemas'][number] | undefined, + topics: PdfTopic[], + options?: PdfTopicEngineOptions, +): { + pages: Template['schemas']; + topicInputs: Record; + issues: RuntimeIssue[]; +} { + const resolvedOptions = resolveOptions(options); + const issues: RuntimeIssue[] = []; + + if (!Array.isArray(basePage)) { + issues.push({ + code: 'INVALID_TEMPLATE', + severity: 'error', + message: 'Topic engine could not find a valid base page schema.', + details: { basePageIndex: resolvedOptions.basePageIndex }, + }); + + return { pages: [], topicInputs: {}, issues }; } - const topicTemplate = targetPage.find( - (field) => (field as TemplateSchema).name === resolvedOptions.topicTemplateName - ) as TemplateSchema | undefined; - const topicDataTemplate = targetPage.find( - (field) => (field as TemplateSchema).name === resolvedOptions.topicDataTemplateName - ) as TemplateSchema | undefined; + const pageFields = basePage.map((field) => cloneTemplate(field as TemplateSchema)); + const topicTemplate = + pageFields.find( + (field) => field.name === resolvedOptions.topicTemplateName, + ) ?? null; + const topicDataTemplate = + pageFields.find( + (field) => field.name === resolvedOptions.topicDataTemplateName, + ) ?? null; if (!topicTemplate || !topicDataTemplate) { - console.warn('[pdf-topic-engine] Missing topic or data_topic schema template'); - return { template: nextTemplate, topicInputs: {} }; + issues.push({ + code: 'INVALID_TEMPLATE', + severity: 'error', + message: 'Topic engine could not find topic/data_topic schema markers.', + details: { + basePageIndex: resolvedOptions.basePageIndex, + topicTemplateName: resolvedOptions.topicTemplateName, + topicDataTemplateName: resolvedOptions.topicDataTemplateName, + }, + }); + + return { pages: [pageFields as Template['schemas'][number]], topicInputs: {}, issues }; } const keepTogetherSet = new Set(resolvedOptions.keepTogetherNames); - const pageWithoutDynamicFields = targetPage.filter((field) => { - const name = (field as TemplateSchema).name; + const pageWithoutDynamicFields = pageFields.filter((field) => { + const name = field.name; + return ( - name !== resolvedOptions.topicTemplateName && name !== resolvedOptions.topicDataTemplateName + name !== resolvedOptions.topicTemplateName && + name !== resolvedOptions.topicDataTemplateName ); - }) as TemplateSchema[]; + }); + const keepTogetherFields = pageWithoutDynamicFields .filter((field) => keepTogetherSet.has(field.name ?? '')) .map((field) => cloneTemplate(field)); @@ -115,18 +153,22 @@ export function buildPdfTopicTemplate( : resolvedOptions.pageBottomY; const keepTogetherHeight = keepTogetherFields.length > 0 - ? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) - keepTogetherTop + ? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) - + keepTogetherTop : 0; let currentPageFields = staticFields.map((field) => cloneTemplate(field)); let currentY = Math.max( resolvedOptions.pageStartY, currentPageFields.length > 0 - ? Math.max(...currentPageFields.map((field) => getFieldBottom(field)), resolvedOptions.pageStartY) - : resolvedOptions.pageStartY + ? Math.max( + ...currentPageFields.map((field) => getFieldBottom(field)), + resolvedOptions.pageStartY, + ) + : resolvedOptions.pageStartY, ); - const pushCurrentPage = () => { + const pushCurrentPage = (): void => { generatedPages.push(currentPageFields as Template['schemas'][number]); currentPageFields = []; currentY = resolvedOptions.pageStartY; @@ -134,17 +176,24 @@ export function buildPdfTopicTemplate( for (const [topicIndex, topic] of sortedTopics.entries()) { const topicRows = formatTopicItems({ items: topic.items ?? [] }); - const topicKey = `topic_${resolvedOptions.targetPageIndex}_${topicIndex}`; - const itemKey = `item_topic_${resolvedOptions.targetPageIndex}_${topicIndex}`; + const topicKey = `topic_${resolvedOptions.basePageIndex}_${topicIndex}`; + const itemKey = `item_topic_${resolvedOptions.basePageIndex}_${topicIndex}`; const labelHeight = - typeof topicTemplate.height === 'number' ? topicTemplate.height : resolvedOptions.rowHeight; + typeof topicTemplate.height === 'number' + ? topicTemplate.height + : resolvedOptions.rowHeight; const tableHeight = Math.max( - typeof topicDataTemplate.height === 'number' ? topicDataTemplate.height : resolvedOptions.rowHeight, - topicRows.length * resolvedOptions.rowHeight + typeof topicDataTemplate.height === 'number' + ? topicDataTemplate.height + : resolvedOptions.rowHeight, + topicRows.length * resolvedOptions.rowHeight, ); const blockHeight = topicToDataOffset + tableHeight; - if (currentY + blockHeight > resolvedOptions.pageBottomY && currentPageFields.length > 0) { + if ( + currentY + blockHeight > resolvedOptions.pageBottomY && + currentPageFields.length > 0 + ) { pushCurrentPage(); } @@ -160,10 +209,10 @@ export function buildPdfTopicTemplate( topicDataField.height = tableHeight; setFieldY(topicDataField, currentY + topicToDataOffset); - topicInputs[topicKey] = [[topic.topicType?.trim() || '-']]; - topicInputs[itemKey] = topicRows; currentPageFields.push(topicField, topicDataField); - currentY = getFieldY(topicDataField) + tableHeight + resolvedOptions.topicSpacing; + topicInputs[topicKey] = [[topic.topicType]]; + topicInputs[itemKey] = topicRows; + currentY += blockHeight + resolvedOptions.topicSpacing; } if (keepTogetherFields.length > 0) { @@ -189,16 +238,51 @@ export function buildPdfTopicTemplate( generatedPages.push(currentPageFields as Template['schemas'][number]); } + return { + pages: + generatedPages.length > 0 + ? generatedPages + : [pageWithoutDynamicFields as Template['schemas'][number]], + topicInputs, + issues, + }; +} + +export function buildPdfTopicTemplate( + template: Template, + topics: PdfTopic[], + options?: PdfTopicEngineOptions, +): { + template: Template; + topicInputs: Record; + issues: RuntimeIssue[]; +} { + const resolvedOptions = resolveOptions(options); + const nextTemplate = cloneTemplate(template); + const pages = nextTemplate.schemas ?? []; + const built = buildPdfTopicPages( + pages[resolvedOptions.basePageIndex], + topics, + resolvedOptions, + ); + + if (built.pages.length === 0) { + return { + template: nextTemplate, + topicInputs: {}, + issues: built.issues, + }; + } + nextTemplate.schemas = [ - ...pages.slice(0, resolvedOptions.targetPageIndex), - ...(generatedPages.length > 0 - ? generatedPages - : [pageWithoutDynamicFields as Template['schemas'][number]]), - ...pages.slice(resolvedOptions.targetPageIndex + 1) + ...pages.slice(0, resolvedOptions.basePageIndex), + ...built.pages, + ...pages.slice(resolvedOptions.basePageIndex + 1), ]; return { template: nextTemplate, - topicInputs + topicInputs: built.topicInputs, + issues: built.issues, }; } diff --git a/src/features/crm/quotations/document/server/pdf-topic.type.ts b/src/features/crm/quotations/document/server/pdf-topic.type.ts index d562bcb..c41e772 100644 --- a/src/features/crm/quotations/document/server/pdf-topic.type.ts +++ b/src/features/crm/quotations/document/server/pdf-topic.type.ts @@ -13,6 +13,7 @@ export type PdfTopic = { export type PdfTopicEngineOptions = { targetPageIndex?: number; + basePageIndex?: number; pageStartY?: number; pageBottomY?: number; topicSpacing?: number; diff --git a/src/features/crm/quotations/document/server/quotation-pdf-runtime.test.ts b/src/features/crm/quotations/document/server/quotation-pdf-runtime.test.ts new file mode 100644 index 0000000..e697b4f --- /dev/null +++ b/src/features/crm/quotations/document/server/quotation-pdf-runtime.test.ts @@ -0,0 +1,240 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { Template } from '@pdfme/common'; +import type { ResolvedDocumentTemplate } from '@/features/foundation/document-template/types'; +import type { QuotationDocumentData } from '../types'; +import { buildQuotationPdfRuntime } from './quotation-pdf-runtime'; +import { resolveTemplatePages } from './page-resolver'; +import type { ResolvedTemplate } from './runtime-contracts'; +import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter'; + +function createField(name: string, y: number, height = 7) { + return { + name, + type: 'text', + position: { x: 10, y }, + width: 100, + height, + content: `{${name}}`, + }; +} + +function createLegacyTemplate(): Template { + return { + basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] }, + schemas: [ + [ + createField('customer_name', 20), + createField('quotation_price_data', 40, 20), + createField('quotation_code_data', 70), + ], + [ + createField('topic', 35), + createField('data_topic', 45, 7), + createField('Please_do_not', 225), + createField('yours_faithfuly', 232), + createField('app1', 240), + createField('app1_position', 247), + createField('app2', 240), + createField('app2_position', 247), + createField('app3', 240), + createField('app3_position', 247), + ], + ], + } as unknown as Template; +} + +function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTemplate { + return { + template: { + id: 'template-1', + organizationId: 'org-1', + documentType: 'quotation', + productType: 'default', + fileType: 'pdfme', + templateName: 'Legacy quotation', + description: null, + isDefault: true, + isActive: true, + createdAt: '2026-06-29T00:00:00.000Z', + updatedAt: '2026-06-29T00:00:00.000Z', + deletedAt: null, + createdBy: 'system', + updatedBy: 'system', + }, + version: { + id: 'version-1', + organizationId: 'org-1', + templateId: 'template-1', + version: '1.0', + filePath: null, + schemaJson: schema, + previewImageUrl: null, + isActive: true, + createdAt: '2026-06-29T00:00:00.000Z', + updatedAt: '2026-06-29T00:00:00.000Z', + deletedAt: null, + createdBy: 'system', + }, + }; +} + +function createDocumentData(topicCount: number): QuotationDocumentData { + return { + company: { + id: 'company-1', + name: 'ALLA', + slug: 'alla', + imageUrl: null, + plan: 'pro', + }, + branch: null, + customer: { + id: 'customer-1', + code: 'C-001', + name: 'Customer', + address: null, + phone: null, + email: null, + taxId: null, + }, + contact: null, + quotation: { + id: 'quotation-1', + code: 'QT-TEST', + quotationDate: '2026-06-29', + validUntil: null, + projectName: null, + projectLocation: null, + attention: null, + reference: null, + revision: 0, + revisionLabel: 'Rev.0', + statusCode: 'approved', + statusLabel: 'Approved', + quotationTypeCode: null, + quotationTypeLabel: null, + currency: 'THB', + currencyCode: 'THB', + currencyLabel: 'Thai Baht', + exchangeRate: 1, + subtotal: 0, + discount: 0, + discountTypeCode: null, + discountTypeLabel: null, + taxRate: 0, + taxAmount: 0, + totalAmount: 0, + notes: null, + }, + items: [], + quotationCustomers: [], + topics: { + scope: [], + exclusion: [], + payment: [], + warranty: [], + delivery: [], + other: [], + all: Array.from({ length: topicCount }, (_, index) => ({ + id: `topic-${index}`, + topicType: `Topic ${index + 1}`, + sortOrder: index, + items: Array.from({ length: 6 }, (_, itemIndex) => ({ + id: `${index}-${itemIndex}`, + content: `Topic ${index + 1} item ${itemIndex + 1}`, + sortOrder: itemIndex, + })), + })), + }, + pdfme: { + labels: { + tel: 'Tel', + email: 'Email', + att: 'Att', + project: 'Project', + location: 'Location', + }, + quotation_date: '2026-06-29', + quotation_price: '0.00', + quotation_price_data: [], + exclusion_data: [], + topic_inputs: {}, + }, + approval: { + requestId: null, + workflowName: null, + status: 'approved', + approvedAt: '2026-06-29T00:00:00.000Z', + currentStep: null, + currentStepRoleName: null, + approvers: [], + }, + signatures: { + preparedBy: { + name: 'Prepared', + position: 'Sales', + }, + approvedBy: { + name: 'Approved', + position: 'Manager', + }, + authorizedBy: { + name: 'Authorized', + position: 'Director', + }, + }, + watermarkStatus: null, + } as QuotationDocumentData; +} + +test('legacy template page roles are inferred without page-index assumptions', () => { + const schema = createLegacyTemplate(); + const template = createResolvedDocumentTemplate(schema); + const runtimeTemplate: ResolvedTemplate = { + documentTemplate: template, + schema, + }; + + const compatible = adaptTemplateForSectionRuntime(runtimeTemplate); + const resolved = resolveTemplatePages(compatible); + + assert.equal(resolved.byRole.customer?.[0]?.pageIndex, 0); + assert.equal(resolved.byRole.topics?.[0]?.pageIndex, 1); + assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 1); + assert.ok( + compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'), + ); +}); + +test('runtime keeps signature block on the last generated topic page', async () => { + const schema = createLegacyTemplate(); + const template = createResolvedDocumentTemplate(schema); + const runtime = await buildQuotationPdfRuntime({ + documentData: createDocumentData(12), + template, + mappings: [], + templateInput: { + customer_name: 'Customer', + }, + }); + + assert.ok(runtime.assembled.template.schemas.length > 2); + assert.ok( + Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')), + ); + + const topicPages = runtime.assembled.template.schemas.slice(1); + const lastPageNames = new Set( + topicPages.at(-1)?.map((field) => String((field as { name?: string }).name ?? '')) ?? [], + ); + + assert.ok(lastPageNames.has('app1')); + + for (const page of topicPages.slice(0, -1)) { + const fieldNames = new Set( + page.map((field) => String((field as { name?: string }).name ?? '')), + ); + assert.equal(fieldNames.has('app1'), false); + } +}); diff --git a/src/features/crm/quotations/document/server/quotation-pdf-runtime.ts b/src/features/crm/quotations/document/server/quotation-pdf-runtime.ts new file mode 100644 index 0000000..41f2410 --- /dev/null +++ b/src/features/crm/quotations/document/server/quotation-pdf-runtime.ts @@ -0,0 +1,162 @@ +import type { Template } from '@pdfme/common'; +import type { + DocumentTemplateMappingWithColumns, + ResolvedDocumentTemplate, +} from '@/features/foundation/document-template/types'; +import type { QuotationDocumentData } from '../types'; +import { buildPdfTopicPages } from './pdf-topic-engine'; +import { resolveTemplatePages } from './page-resolver'; +import { createRenderContext, resolveRenderPolicies } from './render-context'; +import type { + BuiltSection, + QuotationPdfRuntimeResult, + RenderContext, + ResolvedTemplate, + SectionBuilder, +} from './runtime-contracts'; +import { composeSections } from './section-composer'; +import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter'; +import { assembleTemplate } from './template-assembler'; + +function cloneTemplate(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function createCustomerSectionBuilder(): SectionBuilder { + return { + role: 'customer', + build(context: RenderContext): BuiltSection<'customer'> { + const page = context.pages.byRole.customer?.[0] ?? null; + const policy = context.policyBySection.get('customer'); + + if (!page) { + return { + role: 'customer', + enabled: policy?.enabled ?? false, + rendered: false, + anchorPageIndex: null, + pages: [], + templateInputPatch: {}, + issues: [ + { + code: 'MISSING_MARKER', + severity: policy?.required ? 'error' : 'warning', + message: 'Customer section page could not be resolved.', + }, + ], + }; + } + + return { + role: 'customer', + enabled: true, + rendered: true, + anchorPageIndex: page.pageIndex, + pages: [cloneTemplate(page.schema)], + templateInputPatch: {}, + issues: [], + }; + }, + }; +} + +function createTopicSectionBuilder(): SectionBuilder { + return { + role: 'topics', + build(context: RenderContext): BuiltSection<'topics'> { + const page = context.pages.byRole.topics?.[0] ?? null; + const policy = context.policyBySection.get('topics'); + + if (!page) { + return { + role: 'topics', + enabled: policy?.enabled ?? false, + rendered: false, + anchorPageIndex: null, + pages: [], + templateInputPatch: {}, + issues: [ + { + code: 'MISSING_MARKER', + severity: policy?.required ? 'error' : 'warning', + message: 'Topic section page could not be resolved.', + }, + ], + }; + } + + const built = buildPdfTopicPages(page.schema, context.documentData.topics.all, { + basePageIndex: page.pageIndex, + }); + + return { + role: 'topics', + enabled: true, + rendered: true, + anchorPageIndex: page.pageIndex, + pages: built.pages, + templateInputPatch: built.topicInputs, + issues: built.issues, + }; + }, + }; +} + +function getQuotationSectionBuilders(): SectionBuilder[] { + return [createCustomerSectionBuilder(), createTopicSectionBuilder()]; +} + +function createResolvedTemplate( + template: ResolvedDocumentTemplate, +): ResolvedTemplate { + return { + documentTemplate: template, + schema: cloneTemplate(template.version.schemaJson as Template), + }; +} + +function createRuntimeErrorMessage(): string { + return 'Quotation PDF runtime is invalid for the active document template.'; +} + +export async function buildQuotationPdfRuntime(args: { + documentData: QuotationDocumentData; + template: ResolvedDocumentTemplate; + mappings: DocumentTemplateMappingWithColumns[]; + templateInput: Record; +}): Promise { + const resolvedTemplate = createResolvedTemplate(args.template); + const compatibleTemplate = adaptTemplateForSectionRuntime(resolvedTemplate); + const pages = resolveTemplatePages(compatibleTemplate); + const policies = resolveRenderPolicies(); + const context = createRenderContext({ + documentData: args.documentData, + template: resolvedTemplate, + mappings: args.mappings, + pages, + policies, + issues: pages.issues, + }); + const composed = await composeSections(context, getQuotationSectionBuilders()); + const assembled = assembleTemplate({ + template: resolvedTemplate.schema, + templateInput: args.templateInput, + sections: composed.sections, + }); + const issues = [...pages.issues, ...composed.issues, ...assembled.issues]; + const topicSection = composed.sections.find((section) => section.role === 'topics'); + + if (issues.some((issue) => issue.severity === 'error')) { + throw new Error(createRuntimeErrorMessage(), { cause: issues }); + } + + return { + assembled, + topicInputs: topicSection + ? (topicSection.templateInputPatch as Record) + : {}, + issues, + pages, + policies, + }; +} diff --git a/src/features/crm/quotations/document/server/render-context.ts b/src/features/crm/quotations/document/server/render-context.ts new file mode 100644 index 0000000..a4d00c5 --- /dev/null +++ b/src/features/crm/quotations/document/server/render-context.ts @@ -0,0 +1,64 @@ +import type { DocumentTemplateMappingWithColumns } from '@/features/foundation/document-template/types'; +import type { QuotationDocumentData } from '../types'; +import type { + RenderContext, + RenderPolicy, + ResolvedPages, + ResolvedTemplate, + RuntimeIssue, + SectionRole, +} from './runtime-contracts'; +import { SECTION_ORDER } from './runtime-contracts'; + +function buildDefaultPolicy(section: SectionRole): RenderPolicy { + switch (section) { + case 'customer': + return { + section, + enabled: true, + required: true, + visibleWhenEmpty: true, + }; + case 'topics': + return { + section, + enabled: true, + required: true, + visibleWhenEmpty: true, + }; + default: + return { + section, + enabled: false, + required: false, + visibleWhenEmpty: false, + }; + } +} + +export function resolveRenderPolicies(): RenderPolicy[] { + return SECTION_ORDER.map((section) => buildDefaultPolicy(section)); +} + +export function createRenderContext(args: { + documentData: QuotationDocumentData; + template: ResolvedTemplate; + mappings: DocumentTemplateMappingWithColumns[]; + pages: ResolvedPages; + policies: RenderPolicy[]; + issues?: RuntimeIssue[]; +}): RenderContext { + const policyBySection = new Map( + args.policies.map((policy) => [policy.section, policy]), + ); + + return { + documentData: args.documentData, + template: args.template, + mappings: args.mappings, + pages: args.pages, + policies: [...args.policies], + policyBySection, + issues: [...(args.issues ?? [])], + }; +} diff --git a/src/features/crm/quotations/document/server/runtime-contracts.ts b/src/features/crm/quotations/document/server/runtime-contracts.ts new file mode 100644 index 0000000..962761d --- /dev/null +++ b/src/features/crm/quotations/document/server/runtime-contracts.ts @@ -0,0 +1,134 @@ +import type { Template } from '@pdfme/common'; +import type { + DocumentTemplateMappingWithColumns, + ResolvedDocumentTemplate, +} from '@/features/foundation/document-template/types'; +import type { QuotationDocumentData } from '../types'; + +export type SectionRole = + | 'customer' + | 'product_items' + | 'topics' + | 'conditions' + | 'signature' + | 'attachments' + | 'appendix' + | 'warranty' + | 'cover' + | 'unknown'; + +export type PageResolutionStrategy = + | 'explicit_marker' + | 'legacy_inference' + | 'fallback'; + +export type RuntimeIssueCode = + | 'MISSING_MARKER' + | 'DUPLICATE_MARKER' + | 'INVALID_TEMPLATE' + | 'MISSING_MAPPING' + | 'EMPTY_OPTIONAL_SECTION' + | 'EMPTY_REQUIRED_SECTION' + | 'LEGACY_COMPAT_MODE'; + +export interface RuntimeIssue { + code: RuntimeIssueCode; + severity: 'warning' | 'error'; + message: string; + details?: Record; +} + +export interface ResolvedTemplate { + documentTemplate: ResolvedDocumentTemplate; + schema: Template; +} + +export interface CompatibleTemplatePage { + pageIndex: number; + primaryRole: SectionRole; + roles: SectionRole[]; + strategy: PageResolutionStrategy; + schema: Template['schemas'][number]; +} + +export interface CompatibleTemplate { + template: ResolvedTemplate; + pages: CompatibleTemplatePage[]; + issues: RuntimeIssue[]; +} + +export interface ResolvedPage extends CompatibleTemplatePage {} + +export interface ResolvedPages { + all: ResolvedPage[]; + byRole: Partial>; + issues: RuntimeIssue[]; +} + +export interface RenderPolicy { + section: SectionRole; + enabled: boolean; + required: boolean; + visibleWhenEmpty: boolean; +} + +export interface BuiltSection { + role: TRole; + enabled: boolean; + rendered: boolean; + anchorPageIndex: number | null; + pages: Template['schemas']; + templateInputPatch: Record; + issues: RuntimeIssue[]; +} + +export interface RenderContext { + documentData: QuotationDocumentData; + template: ResolvedTemplate; + mappings: DocumentTemplateMappingWithColumns[]; + pages: ResolvedPages; + policies: RenderPolicy[]; + policyBySection: Map; + issues: RuntimeIssue[]; +} + +export interface SectionBuilder { + role: SectionRole; + build(context: RenderContext): BuiltSection | Promise; +} + +export interface AssembledTemplate { + template: Template; + templateInput: Record; + issues: RuntimeIssue[]; +} + +export interface QuotationPdfRuntimeResult { + assembled: AssembledTemplate; + topicInputs: Record; + issues: RuntimeIssue[]; + pages: ResolvedPages; + policies: RenderPolicy[]; +} + +export const SECTION_ORDER: SectionRole[] = [ + 'customer', + 'product_items', + 'topics', + 'conditions', + 'signature', + 'attachments', + 'appendix', + 'warranty', + 'cover', +]; + +export const SINGLETON_SECTION_ROLES = new Set([ + 'customer', + 'product_items', + 'topics', + 'conditions', + 'signature', + 'warranty', + 'cover', +]); diff --git a/src/features/crm/quotations/document/server/section-composer.ts b/src/features/crm/quotations/document/server/section-composer.ts new file mode 100644 index 0000000..d8d7e28 --- /dev/null +++ b/src/features/crm/quotations/document/server/section-composer.ts @@ -0,0 +1,45 @@ +import type { + BuiltSection, + RenderContext, + RuntimeIssue, + SectionBuilder, +} from './runtime-contracts'; +import { SECTION_ORDER } from './runtime-contracts'; + +export interface SectionComposerResult { + sections: BuiltSection[]; + issues: RuntimeIssue[]; +} + +export async function composeSections( + context: RenderContext, + builders: SectionBuilder[], +): Promise { + const builderByRole = new Map(builders.map((builder) => [builder.role, builder])); + const sections: BuiltSection[] = []; + const issues: RuntimeIssue[] = []; + + for (const role of SECTION_ORDER) { + const policy = context.policyBySection.get(role); + if (!policy?.enabled) { + continue; + } + + const builder = builderByRole.get(role); + if (!builder) { + issues.push({ + code: 'INVALID_TEMPLATE', + severity: policy.required ? 'error' : 'warning', + message: 'No section builder is registered for an enabled section.', + details: { role }, + }); + continue; + } + + const section = await builder.build(context); + sections.push(section); + issues.push(...section.issues); + } + + return { sections, issues }; +} diff --git a/src/features/crm/quotations/document/server/service.ts b/src/features/crm/quotations/document/server/service.ts index b1e33cf..0826935 100644 --- a/src/features/crm/quotations/document/server/service.ts +++ b/src/features/crm/quotations/document/server/service.ts @@ -35,10 +35,9 @@ import { formatPdfDate, formatTopicItems, } from "./pdfme-transforms"; -import { buildPdfTopicTemplate } from "./pdf-topic-engine"; +import { buildQuotationPdfRuntime } from "./quotation-pdf-runtime"; import { resolveQuotationSignatures } from "./signature-resolver"; import { resolveQuotationTopicTypeMapping } from "./topic-mapping"; -import type { Template } from "@pdfme/common"; const DOCUMENT_OPTION_CATEGORIES = { branch: "crm_branch", @@ -260,6 +259,17 @@ function normalizePdfmeTemplateInput( return normalized; } +function logQuotationPdfRuntimeWarnings(issues: Array<{ message: string }>) { + if (!issues.length) { + return; + } + + console.warn( + "[quotation-pdf-runtime]", + issues.map((issue) => issue.message).join(" | "), + ); +} + export async function buildQuotationDocumentData( quotationId: string, organizationId: string, @@ -690,16 +700,19 @@ export async function getQuotationDocumentPreviewData( mappings, ), ); - const { template: renderTemplate, topicInputs } = buildPdfTopicTemplate( - template.version.schemaJson as Template, - documentData.topics.all, + const runtime = await buildQuotationPdfRuntime({ + documentData, + template, + mappings, + templateInput, + }); + const runtimeWarnings = runtime.issues.filter( + (issue) => issue.severity === "warning", ); - const mergedTemplateInput = { - ...templateInput, - ...topicInputs, - }; - documentData.pdfme.topic_inputs = topicInputs; + logQuotationPdfRuntimeWarnings(runtimeWarnings); + + documentData.pdfme.topic_inputs = runtime.topicInputs; return { documentData, @@ -707,11 +720,11 @@ export async function getQuotationDocumentPreviewData( ...template, version: { ...template.version, - schemaJson: renderTemplate, + schemaJson: runtime.assembled.template, }, }, mappings, - templateInput: mergedTemplateInput, + templateInput: runtime.assembled.templateInput, }; } diff --git a/src/features/crm/quotations/document/server/template-assembler.ts b/src/features/crm/quotations/document/server/template-assembler.ts new file mode 100644 index 0000000..26a3b43 --- /dev/null +++ b/src/features/crm/quotations/document/server/template-assembler.ts @@ -0,0 +1,78 @@ +import type { Template } from '@pdfme/common'; +import type { + AssembledTemplate, + BuiltSection, + RuntimeIssue, +} from './runtime-contracts'; + +function cloneTemplate(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function assembleTemplate(args: { + template: Template; + templateInput: Record; + sections: BuiltSection[]; +}): AssembledTemplate { + const baseTemplate = cloneTemplate(args.template); + const replacementByAnchor = new Map(); + const issues: RuntimeIssue[] = []; + + for (const section of args.sections) { + if (section.anchorPageIndex === null) { + issues.push(...section.issues); + continue; + } + + if (replacementByAnchor.has(section.anchorPageIndex)) { + issues.push({ + code: 'INVALID_TEMPLATE', + severity: 'error', + message: 'Multiple built sections attempted to replace the same page.', + details: { + anchorPageIndex: section.anchorPageIndex, + roles: [ + replacementByAnchor.get(section.anchorPageIndex)?.role, + section.role, + ], + }, + }); + continue; + } + + replacementByAnchor.set(section.anchorPageIndex, section); + issues.push(...section.issues); + } + + const nextPages: Template['schemas'] = []; + + for (const [pageIndex, page] of baseTemplate.schemas.entries()) { + const replacement = replacementByAnchor.get(pageIndex); + if (!replacement) { + nextPages.push(cloneTemplate(page)); + continue; + } + + if (!replacement.rendered || replacement.pages.length === 0) { + continue; + } + + nextPages.push(...cloneTemplate(replacement.pages)); + } + + baseTemplate.schemas = nextPages; + + const templateInput = args.sections.reduce>( + (accumulator, section) => ({ + ...accumulator, + ...section.templateInputPatch, + }), + { ...args.templateInput }, + ); + + return { + template: baseTemplate, + templateInput, + issues, + }; +} diff --git a/src/features/crm/quotations/document/server/template-compatibility-adapter.ts b/src/features/crm/quotations/document/server/template-compatibility-adapter.ts new file mode 100644 index 0000000..769b0b5 --- /dev/null +++ b/src/features/crm/quotations/document/server/template-compatibility-adapter.ts @@ -0,0 +1,209 @@ +import type { Template } from '@pdfme/common'; +import type { + CompatibleTemplate, + CompatibleTemplatePage, + ResolvedTemplate, + RuntimeIssue, + SectionRole, +} from './runtime-contracts'; + +const EXPLICIT_ROLE_PREFIX = '__page_role__'; + +const LEGACY_MARKERS: Record, string[]> = { + customer: ['customer_name', 'quotation_price_data', 'quotation_code_data'], + product_items: ['items_table'], + topics: ['topic', 'data_topic'], + conditions: ['conditions', 'condition_data'], + signature: [ + 'Please_do_not', + 'yours_faithfuly', + 'app1', + 'app1_position', + 'app2', + 'app2_position', + 'app3', + 'app3_position', + ], + attachments: ['attachment'], + appendix: ['appendix'], + warranty: ['warranty'], + cover: ['cover'], +}; + +type TemplateField = { + name?: string; + content?: string; +}; + +function createIssue(issue: RuntimeIssue): RuntimeIssue { + return issue; +} + +function isTemplatePageArray( + value: Template['schemas'][number] | unknown, +): value is Template['schemas'][number] { + return Array.isArray(value); +} + +function getFieldNames(page: Template['schemas'][number]): string[] { + return page + .map((field) => { + if (typeof field !== 'object' || field === null || !('name' in field)) { + return ''; + } + + return String((field as TemplateField).name ?? ''); + }) + .filter(Boolean); +} + +function getExplicitRole(page: Template['schemas'][number]): SectionRole | null { + for (const field of page) { + if (!field || typeof field !== 'object') { + continue; + } + + const name = + 'name' in field && typeof field.name === 'string' ? field.name : null; + const content = + 'content' in field && typeof field.content === 'string' + ? field.content.trim() + : null; + + const byName = name?.startsWith(EXPLICIT_ROLE_PREFIX) + ? name.slice(EXPLICIT_ROLE_PREFIX.length) + : null; + const candidate = byName ?? content; + + if (candidate && isSectionRole(candidate)) { + return candidate; + } + } + + return null; +} + +function inferLegacyRoles(page: Template['schemas'][number]): SectionRole[] { + const names = new Set(getFieldNames(page)); + const inferred: SectionRole[] = []; + + for (const [role, markers] of Object.entries(LEGACY_MARKERS) as Array< + [Exclude, string[]] + >) { + if (markers.every((marker) => names.has(marker))) { + inferred.push(role); + } + } + + if (!inferred.length && names.has('topic')) { + inferred.push('topics'); + } + + if ( + !inferred.includes('signature') && + ['app1', 'app2', 'app3'].some((name) => names.has(name)) + ) { + inferred.push('signature'); + } + + return inferred; +} + +function isSectionRole(value: string): value is SectionRole { + return ( + value === 'customer' || + value === 'product_items' || + value === 'topics' || + value === 'conditions' || + value === 'signature' || + value === 'attachments' || + value === 'appendix' || + value === 'warranty' || + value === 'cover' || + value === 'unknown' + ); +} + +export function adaptTemplateForSectionRuntime( + template: ResolvedTemplate, +): CompatibleTemplate { + const issues: RuntimeIssue[] = []; + const schemaPages = template.schema.schemas; + + if (!Array.isArray(schemaPages)) { + return { + template, + pages: [], + issues: [ + createIssue({ + code: 'INVALID_TEMPLATE', + severity: 'error', + message: 'PDF template schema does not expose a valid schemas array.', + }), + ], + }; + } + + const pages: CompatibleTemplatePage[] = []; + + for (const [pageIndex, rawPage] of schemaPages.entries()) { + if (!isTemplatePageArray(rawPage)) { + issues.push( + createIssue({ + code: 'INVALID_TEMPLATE', + severity: 'error', + message: 'PDF template page schema is invalid.', + details: { pageIndex }, + }), + ); + continue; + } + + const explicitRole = getExplicitRole(rawPage); + const inferredRoles = inferLegacyRoles(rawPage); + const primaryRole = explicitRole ?? inferredRoles[0] ?? 'unknown'; + const roles = Array.from( + new Set([primaryRole, ...inferredRoles].filter(isSectionRole)), + ); + + let strategy: CompatibleTemplatePage['strategy'] = 'fallback'; + if (explicitRole) { + strategy = 'explicit_marker'; + } else if (inferredRoles.length > 0) { + strategy = 'legacy_inference'; + issues.push( + createIssue({ + code: 'LEGACY_COMPAT_MODE', + severity: 'warning', + message: + 'PDF template page role was inferred from legacy field markers.', + details: { pageIndex, roles }, + }), + ); + } else { + issues.push( + createIssue({ + code: 'MISSING_MARKER', + severity: 'warning', + message: + 'PDF template page could not be classified by explicit marker or legacy inference.', + details: { pageIndex }, + }), + ); + } + + pages.push({ + pageIndex, + primaryRole, + roles: roles.length > 0 ? roles : ['unknown'], + strategy, + schema: rawPage, + }); + } + + return { + template, + pages, + issues, + }; +}