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 0000000..f2ee945 Binary files /dev/null and b/artifacts/task-p4-verification/rows_30_short.pdf differ 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, + }; +}