diff --git a/artifacts/pdf-audit/payload-parity-report.json b/artifacts/pdf-audit/payload-parity-report.json index 944007a..16491da 100644 --- a/artifacts/pdf-audit/payload-parity-report.json +++ b/artifacts/pdf-audit/payload-parity-report.json @@ -1,13 +1,13 @@ { "audit": "payload-parity", "overallStatus": "PASS", - "quotationCode": "CRA2606-1107", - "previewHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6", - "generationHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6", - "snapshotHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6", + "quotationCode": "CRA2606-1121", + "previewHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e", + "generationHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e", + "snapshotHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e", "mismatches": [], - "comparableFieldCount": 31, - "topicInputKeyCount": 6, + "comparableFieldCount": 25, + "topicInputKeyCount": 0, "signatureParity": true, "priceTableParity": true, "priceTableRowCount": 1, diff --git a/artifacts/pdf-audit/runtime-payload-report.json b/artifacts/pdf-audit/runtime-payload-report.json index c8bab22..92ebdff 100644 --- a/artifacts/pdf-audit/runtime-payload-report.json +++ b/artifacts/pdf-audit/runtime-payload-report.json @@ -2,10 +2,10 @@ "audit": "runtime-payload", "overallStatus": "PASS", "staticLabelsStatus": "PASS", - "quotationCode": "CRA2606-1107", + "quotationCode": "CRA2606-1121", "templateName": "ALLA Demo Organization Quotation Standard", "templateVersion": "1.0", - "findingCount": 168, + "findingCount": 86, "findings": [ { "field": "documentData.company.id", @@ -25,28 +25,28 @@ "field": "documentData.customer.id", "source": "documentData", "status": "PASS", - "value": "11bf503b-9ae7-4f67-b800-2772fee2131c", + "value": "a6d9dec1-ff9d-4749-9542-f16d8efcf761", "message": "String is populated" }, { "field": "documentData.customer.name", "source": "documentData", "status": "PASS", - "value": "บริษัท นอร์ธเทิร์น แมชชีนเนอรี่ ดีลเลอร์ จำกัด", + "value": "บริษัท แอดวานซ์ โปรเจค คอนซัลแทนท์ จำกัด", "message": "String is populated" }, { "field": "documentData.customer.address", "source": "documentData", "status": "PASS", - "value": "Mueang Chiang Mai, Chiang Mai", + "value": "Bang Pakong, Chachoengsao", "message": "String is populated" }, { "field": "documentData.customer.phone", "source": "documentData", "status": "PASS", - "value": "02-400824", + "value": "02-600832", "message": "String is populated" }, { @@ -60,14 +60,14 @@ "field": "documentData.contact.id", "source": "documentData", "status": "PASS", - "value": "a2cec2b0-23cc-4c49-8063-1d1fc9777cd0", + "value": "df4b3f5f-a4e0-42d7-9181-d56319b23275", "message": "String is populated" }, { "field": "documentData.contact.name", "source": "documentData", "status": "PASS", - "value": "คุณกิตติพงศ์ สุวรรณ", + "value": "คุณวราภรณ์ จิรโรจน์", "message": "String is populated" }, { @@ -81,56 +81,56 @@ "field": "documentData.contact.mobile", "source": "documentData", "status": "PASS", - "value": "0810000220", + "value": "0810000303", "message": "String is populated" }, { "field": "documentData.contact.position", "source": "documentData", "status": "PASS", - "value": "Plant Manager", + "value": "Maintenance Supervisor", "message": "String is populated" }, { "field": "documentData.quotation.id", "source": "documentData", "status": "PASS", - "value": "02d6b4b3-b11c-4a6e-9518-1258c8fcf4a1", + "value": "16ea6fca-ef77-4cb9-8079-0b8dbcdbff57", "message": "String is populated" }, { "field": "documentData.quotation.code", "source": "documentData", "status": "PASS", - "value": "CRA2606-1107", + "value": "CRA2606-1121", "message": "String is populated" }, { "field": "documentData.quotation.projectName", "source": "documentData", "status": "PASS", - "value": "Dealer Stock Crane Package", + "value": "บริษัท แอดวานซ์ โปรเจค คอนซัลแทนท์ จำกัด - Phase 2", "message": "String is populated" }, { "field": "documentData.quotation.projectLocation", "source": "documentData", "status": "PASS", - "value": "Chiang Mai Service Warehouse", + "value": "Bang Pakong, Chachoengsao", "message": "String is populated" }, { "field": "documentData.quotation.attention", "source": "documentData", "status": "PASS", - "value": "คุณกิตติพงศ์ สุวรรณ", + "value": "Attention", "message": "String is populated" }, { "field": "documentData.quotation.reference", "source": "documentData", "status": "PASS", - "value": "crm-uat-seed-v1:dealer-channel-approved", + "value": "Reference", "message": "String is populated" }, { @@ -151,279 +151,83 @@ "field": "documentData.quotation.subtotal", "source": "documentData", "status": "PASS", - "value": 4418000, + "value": 0, "message": "Scalar value is populated" }, { "field": "documentData.quotation.totalAmount", "source": "documentData", "status": "PASS", - "value": 4585442, + "value": 0, "message": "Scalar value is populated" }, { "field": "documentData.items", "source": "documentData", "status": "PASS", - "value": "rows:4", - "message": "Collection payload is populated" - }, - { - "field": "documentData.items[0].id", - "source": "documentData", - "status": "PASS", - "value": "e150f933-729b-4ec1-bd3e-8514c1e05766", - "message": "String is populated" - }, - { - "field": "documentData.items[0].itemNumber", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[0].description", - "source": "documentData", - "status": "PASS", - "value": "Main equipment supply for Dealer Stock Crane Package", - "message": "String is populated" - }, - { - "field": "documentData.items[0].quantity", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[0].unitLabel", - "source": "documentData", - "status": "PASS", - "value": "Set", - "message": "String is populated" - }, - { - "field": "documentData.items[0].unitPrice", - "source": "documentData", - "status": "PASS", - "value": 2521993, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[0].discount", - "source": "documentData", - "status": "PASS", - "value": 0, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[0].totalPrice", - "source": "documentData", - "status": "PASS", - "value": 2521993, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[1].id", - "source": "documentData", - "status": "PASS", - "value": "efcbc2e2-c561-478f-bfad-98dcbbd30ea6", - "message": "String is populated" - }, - { - "field": "documentData.items[1].itemNumber", - "source": "documentData", - "status": "PASS", - "value": 2, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[1].description", - "source": "documentData", - "status": "PASS", - "value": "Installation and commissioning for Dealer Stock Crane Package", - "message": "String is populated" - }, - { - "field": "documentData.items[1].quantity", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[1].unitLabel", - "source": "documentData", - "status": "PASS", - "value": "Job", - "message": "String is populated" - }, - { - "field": "documentData.items[1].unitPrice", - "source": "documentData", - "status": "PASS", - "value": 1283924, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[1].discount", - "source": "documentData", - "status": "PASS", - "value": 0, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[1].totalPrice", - "source": "documentData", - "status": "PASS", - "value": 1283924, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[2].id", - "source": "documentData", - "status": "PASS", - "value": "d574bc39-ddd2-4f29-96fa-0091439c8abf", - "message": "String is populated" - }, - { - "field": "documentData.items[2].itemNumber", - "source": "documentData", - "status": "PASS", - "value": 3, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[2].description", - "source": "documentData", - "status": "PASS", - "value": "Operator training and documentation for Dealer Stock Crane Package", - "message": "String is populated" - }, - { - "field": "documentData.items[2].quantity", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[2].unitLabel", - "source": "documentData", - "status": "PASS", - "value": "Job", - "message": "String is populated" - }, - { - "field": "documentData.items[2].unitPrice", - "source": "documentData", - "status": "PASS", - "value": 458544, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[2].discount", - "source": "documentData", - "status": "PASS", - "value": 0, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[2].totalPrice", - "source": "documentData", - "status": "PASS", - "value": 458544, - "message": "Scalar value is populated" - }, - { - "field": "documentData.items[3].id", - "source": "documentData", - "status": "PASS", - "value": "0baeeec0-f2b0-4ea0-82d6-f80aa9de5aef", - "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 Dealer Stock Crane Package", - "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": 320981, - "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": 320981, - "message": "Scalar value is populated" + "value": "rows:0", + "message": "Array shape is valid" }, { "field": "documentData.quotationCustomers", "source": "documentData", "status": "PASS", - "value": "rows:1", + "value": "rows:2", "message": "Collection payload is populated" }, { "field": "documentData.quotationCustomers[0].id", "source": "documentData", "status": "PASS", - "value": "81752ed4-508c-4946-9bd0-1450df258ffa", + "value": "8eac2230-5fc4-4e14-9fa4-b7457efe5660", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].customerId", "source": "documentData", "status": "PASS", - "value": "11bf503b-9ae7-4f67-b800-2772fee2131c", + "value": "05ae3d52-1157-499e-b6b2-d7e5ab509e4e", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].role", "source": "documentData", "status": "PASS", - "value": "a6170538-f685-4f8d-bf77-36ae82f87376", + "value": "a3b51c9b-e1e6-48b9-95ba-f6e35ba5406e", "message": "String is populated" }, { "field": "documentData.quotationCustomers[0].isPrimary", "source": "documentData", "status": "PASS", + "value": false, + "message": "Scalar value is populated" + }, + { + "field": "documentData.quotationCustomers[1].id", + "source": "documentData", + "status": "PASS", + "value": "a8a589b5-5de5-403a-a6bb-a6d9c55efe71", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[1].customerId", + "source": "documentData", + "status": "PASS", + "value": "a6d9dec1-ff9d-4749-9542-f16d8efcf761", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[1].role", + "source": "documentData", + "status": "PASS", + "value": "cfaf96fd-39a2-4074-b295-a4798cf23253", + "message": "String is populated" + }, + { + "field": "documentData.quotationCustomers[1].isPrimary", + "source": "documentData", + "status": "PASS", "value": true, "message": "Scalar value is populated" }, @@ -431,267 +235,15 @@ "field": "documentData.topics.all", "source": "documentData", "status": "PASS", - "value": "rows:3", - "message": "Collection payload is populated" - }, - { - "field": "documentData.topics.all[0].id", - "source": "documentData", - "status": "PASS", - "value": "2f5b2486-9527-4a34-b5bc-319a76f26a97", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[0].title", - "source": "documentData", - "status": "PASS", - "value": "Scope of Work", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[0].topicCode", - "source": "documentData", - "status": "PASS", - "value": "scope", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[0].topicType", - "source": "documentData", - "status": "PASS", - "value": "Scope", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[0].items", - "source": "documentData", - "status": "PASS", - "value": "rows:2", - "message": "Collection payload is populated" - }, - { - "field": "documentData.topics.all[0].items[0].content", - "source": "documentData", - "status": "PASS", - "value": "Supply and execute dealer stock crane package according to approved drawing.", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[0].items[0].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.all[0].items[1].content", - "source": "documentData", - "status": "PASS", - "value": "Include testing, commissioning, and handover documentation.", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[0].items[1].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 2, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.all[1].id", - "source": "documentData", - "status": "PASS", - "value": "6ea81a68-390f-4015-bd80-19f792eb57b6", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[1].title", - "source": "documentData", - "status": "PASS", - "value": "Exclusions", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[1].topicCode", - "source": "documentData", - "status": "PASS", - "value": "exclusion", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[1].topicType", - "source": "documentData", - "status": "PASS", - "value": "Exclusion", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[1].items", - "source": "documentData", - "status": "PASS", - "value": "rows:2", - "message": "Collection payload is populated" - }, - { - "field": "documentData.topics.all[1].items[0].content", - "source": "documentData", - "status": "PASS", - "value": "Civil work, main power cable, and customer standby crew are excluded.", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[1].items[0].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.all[1].items[1].content", - "source": "documentData", - "status": "PASS", - "value": "Work outside normal business hours is not included.", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[1].items[1].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 2, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.all[2].id", - "source": "documentData", - "status": "PASS", - "value": "2b9042e6-150f-4701-91c7-f490ff48de55", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[2].title", - "source": "documentData", - "status": "PASS", - "value": "Payment Terms", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[2].topicCode", - "source": "documentData", - "status": "PASS", - "value": "payment", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[2].topicType", - "source": "documentData", - "status": "PASS", - "value": "Payment", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[2].items", - "source": "documentData", - "status": "PASS", - "value": "rows:2", - "message": "Collection payload is populated" - }, - { - "field": "documentData.topics.all[2].items[0].content", - "source": "documentData", - "status": "PASS", - "value": "40% deposit, 50% after delivery, 10% after commissioning.", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[2].items[0].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.all[2].items[1].content", - "source": "documentData", - "status": "PASS", - "value": "Price validity 30 days and delivery 60-90 days after order confirmation.", - "message": "String is populated" - }, - { - "field": "documentData.topics.all[2].items[1].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 2, - "message": "Scalar value is populated" + "value": "rows:0", + "message": "Array shape is valid" }, { "field": "documentData.topics.exclusion", "source": "documentData", "status": "PASS", - "value": "rows:1", - "message": "Collection payload is populated" - }, - { - "field": "documentData.topics.exclusion[0].id", - "source": "documentData", - "status": "PASS", - "value": "6ea81a68-390f-4015-bd80-19f792eb57b6", - "message": "String is populated" - }, - { - "field": "documentData.topics.exclusion[0].title", - "source": "documentData", - "status": "PASS", - "value": "Exclusions", - "message": "String is populated" - }, - { - "field": "documentData.topics.exclusion[0].topicCode", - "source": "documentData", - "status": "PASS", - "value": "exclusion", - "message": "String is populated" - }, - { - "field": "documentData.topics.exclusion[0].topicType", - "source": "documentData", - "status": "PASS", - "value": "Exclusion", - "message": "String is populated" - }, - { - "field": "documentData.topics.exclusion[0].items", - "source": "documentData", - "status": "PASS", - "value": "rows:2", - "message": "Collection payload is populated" - }, - { - "field": "documentData.topics.exclusion[0].items[0].content", - "source": "documentData", - "status": "PASS", - "value": "Civil work, main power cable, and customer standby crew are excluded.", - "message": "String is populated" - }, - { - "field": "documentData.topics.exclusion[0].items[0].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 1, - "message": "Scalar value is populated" - }, - { - "field": "documentData.topics.exclusion[0].items[1].content", - "source": "documentData", - "status": "PASS", - "value": "Work outside normal business hours is not included.", - "message": "String is populated" - }, - { - "field": "documentData.topics.exclusion[0].items[1].sortOrder", - "source": "documentData", - "status": "PASS", - "value": 2, - "message": "Scalar value is populated" + "value": "rows:0", + "message": "Array shape is valid" }, { "field": "documentData.pdfme.labels.tel", @@ -732,14 +284,14 @@ "field": "documentData.pdfme.quotation_date", "source": "documentData", "status": "PASS", - "value": "Jun 3, 2026", + "value": "Jun 30, 2026", "message": "String is populated" }, { "field": "documentData.pdfme.quotation_price", "source": "documentData", "status": "PASS", - "value": "THB 4,418,000.00", + "value": "THB 0.00", "message": "String is populated" }, { @@ -753,51 +305,9 @@ "field": "documentData.pdfme.exclusion_data", "source": "documentData", "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, - { - "field": "documentData.pdfme.topic_inputs.topic_1_0", - "source": "documentData", - "status": "PASS", "value": "rows:1", "message": "Array shape is valid" }, - { - "field": "documentData.pdfme.topic_inputs.item_topic_1_0", - "source": "documentData", - "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, - { - "field": "documentData.pdfme.topic_inputs.topic_1_1", - "source": "documentData", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "documentData.pdfme.topic_inputs.item_topic_1_1", - "source": "documentData", - "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, - { - "field": "documentData.pdfme.topic_inputs.topic_1_2", - "source": "documentData", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "documentData.pdfme.topic_inputs.item_topic_1_2", - "source": "documentData", - "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, { "field": "documentData.signatures.preparedBy.name", "source": "documentData", @@ -816,70 +326,70 @@ "field": "documentData.signatures.preparedBy.actedAt", "source": "documentData", "status": "PASS", - "value": "2026-06-03T09:00:00.000Z", + "value": "2026-06-30T11:21:06.000Z", "message": "String is populated" }, { "field": "documentData.signatures.approvedBy.name", "source": "documentData", "status": "PASS", - "value": "Sales Manager UAT", + "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-06T10:00:00.000Z", - "message": "String is populated" + "status": "WARNING", + "value": null, + "message": "Value is null" }, { "field": "documentData.signatures.authorizedBy.name", "source": "documentData", "status": "PASS", - "value": "CEO UAT", + "value": "-", "message": "String is populated" }, { "field": "documentData.signatures.authorizedBy.position", "source": "documentData", "status": "PASS", - "value": "General Manager", + "value": "-", "message": "String is populated" }, { "field": "documentData.signatures.authorizedBy.actedAt", "source": "documentData", - "status": "PASS", - "value": "2026-06-07T09:00:00.000Z", - "message": "String is populated" + "status": "WARNING", + "value": null, + "message": "Value is null" }, { "field": "templateInput.customer_name", "source": "templateInput", "status": "PASS", - "value": "บริษัท นอร์ธเทิร์น แมชชีนเนอรี่ ดีลเลอร์ จำกัด", + "value": "บริษัท แอดวานซ์ โปรเจค คอนซัลแทนท์ จำกัด", "message": "String is populated" }, { "field": "templateInput.customer_addr", "source": "templateInput", "status": "PASS", - "value": "Mueang Chiang Mai, Chiang Mai", + "value": "Bang Pakong, Chachoengsao", "message": "String is populated" }, { "field": "templateInput.customer_tel", "source": "templateInput", "status": "PASS", - "value": "02-400824", + "value": "02-600832", "message": "String is populated" }, { @@ -893,35 +403,35 @@ "field": "templateInput.customer_att", "source": "templateInput", "status": "PASS", - "value": "คุณกิตติพงศ์ สุวรรณ", + "value": "Attention", "message": "String is populated" }, { "field": "templateInput.project_name", "source": "templateInput", "status": "PASS", - "value": "Dealer Stock Crane Package", + "value": "บริษัท แอดวานซ์ โปรเจค คอนซัลแทนท์ จำกัด - Phase 2", "message": "String is populated" }, { "field": "templateInput.site_location", "source": "templateInput", "status": "PASS", - "value": "Chiang Mai Service Warehouse", + "value": "Bang Pakong, Chachoengsao", "message": "String is populated" }, { "field": "templateInput.quotation_date_data", "source": "templateInput", "status": "PASS", - "value": "Jun 3, 2026", + "value": "Jun 30, 2026", "message": "String is populated" }, { "field": "templateInput.quotation_code_data", "source": "templateInput", "status": "PASS", - "value": "CRA2606-1107", + "value": "CRA2606-1121", "message": "String is populated" }, { @@ -935,7 +445,7 @@ "field": "templateInput.quotation_price", "source": "templateInput", "status": "PASS", - "value": "THB 4,418,000.00", + "value": "THB 0.00", "message": "String is populated" }, { @@ -998,128 +508,44 @@ "field": "templateInput.app2", "source": "templateInput", "status": "PASS", - "value": "Sales Manager UAT", + "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": "CEO UAT", + "value": "-", "message": "String is populated" }, { "field": "templateInput.app3_position", "source": "templateInput", "status": "PASS", - "value": "General Manager", + "value": "-", "message": "String is populated" }, { "field": "templateInput.quotation_date", "source": "templateInput", "status": "PASS", - "value": "Jun 3, 2026", + "value": "Jun 30, 2026", "message": "String is populated" }, - { - "field": "templateInput.topic_1_0", - "source": "templateInput", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "templateInput.item_topic_1_0", - "source": "templateInput", - "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, - { - "field": "templateInput.topic_1_1", - "source": "templateInput", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "templateInput.item_topic_1_1", - "source": "templateInput", - "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, - { - "field": "templateInput.topic_1_2", - "source": "templateInput", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "templateInput.item_topic_1_2", - "source": "templateInput", - "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, { "field": "templateInput.quotation_code", "source": "templateInput", "status": "PASS", - "value": "CRA2606-1107", + "value": "CRA2606-1121", "message": "String is populated" }, - { - "field": "topicInputs.topic_1_0", - "source": "topicInputs", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "topicInputs.item_topic_1_0", - "source": "topicInputs", - "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, - { - "field": "topicInputs.topic_1_1", - "source": "topicInputs", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "topicInputs.item_topic_1_1", - "source": "topicInputs", - "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, - { - "field": "topicInputs.topic_1_2", - "source": "topicInputs", - "status": "PASS", - "value": "rows:1", - "message": "Array shape is valid" - }, - { - "field": "topicInputs.item_topic_1_2", - "source": "topicInputs", - "status": "PASS", - "value": "rows:2", - "message": "Array shape is valid" - }, { "field": "templateInput.quotation_price_data", "source": "templateInput", @@ -1138,7 +564,7 @@ "field": "templateInput.quotation_price_data[0][1]", "source": "templateInput", "status": "PASS", - "value": "฿4,418,000.00", + "value": "฿0.00", "message": "Price table amount cell is formatted" }, { diff --git a/artifacts/pdf-audit/signature-audit-report.json b/artifacts/pdf-audit/signature-audit-report.json index c99056b..fd8347a 100644 --- a/artifacts/pdf-audit/signature-audit-report.json +++ b/artifacts/pdf-audit/signature-audit-report.json @@ -1,14 +1,14 @@ { "audit": "approved-snapshot-parity", "overallStatus": "PASS", - "quotationCode": "CRA2606-1107", + "quotationCode": "CRA2606-1121", "approvedSnapshotAvailable": true, "approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", "computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", "inconsistentTemplateKeys": [], - "computedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8", - "storedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8", - "approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581", + "computedSignatureHash": "ef7a8fc3429f1c5b5a74ac0452d3de809558842f5431d0d5b02122980a286117", + "storedSignatureHash": "ef7a8fc3429f1c5b5a74ac0452d3de809558842f5431d0d5b02122980a286117", + "approvedArtifactReference": null, "refreshedFixtureSnapshot": true, "usedAuditFixture": false } diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json index ea1fdf1..39fa9a7 100644 --- a/artifacts/pdf-audit/summary.json +++ b/artifacts/pdf-audit/summary.json @@ -1,7 +1,7 @@ { - "generatedAt": "2026-06-30T09:16:22.613Z", - "overallStatus": "PASS", - "quotationCode": "CRA2606-1107", + "generatedAt": "2026-06-30T13:14:19.935Z", + "overallStatus": "FAIL", + "quotationCode": "CRA2606-1121", "artifacts": { "templateVersionReport": "template-version-report.json", "placeholderIntegrityReport": "placeholder-integrity-report.json", @@ -11,11 +11,11 @@ "signatureAuditReport": "signature-audit-report.json" }, "statuses": { - "templateVersion": "PASS", + "templateVersion": "FAIL", "placeholderIntegrity": "PASS", "runtimePayload": "PASS", "payloadParity": "PASS", - "topicRuntime": "PASS", + "topicRuntime": "FAIL", "approvedSnapshotParity": "PASS", "staticLabels": "PASS" }, diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md index 5352f0c..2666133 100644 --- a/artifacts/pdf-audit/summary.md +++ b/artifacts/pdf-audit/summary.md @@ -1,16 +1,16 @@ # PDF Integrity Summary -- Generated At: 2026-06-30T09:16:22.613Z -- Overall Status: PASS -- Quotation Code: CRA2606-1107 +- Generated At: 2026-06-30T13:14:19.935Z +- Overall Status: FAIL +- Quotation Code: CRA2606-1121 ## Statuses -- Template Version Integrity: PASS +- Template Version Integrity: FAIL - Placeholder Integrity: PASS - Runtime Payload: PASS - Payload Parity: PASS -- Topic Runtime: PASS +- Topic Runtime: FAIL - Approved Snapshot Parity: PASS - Static Labels: PASS - Price Table Present: Yes diff --git a/artifacts/pdf-audit/template-version-report.json b/artifacts/pdf-audit/template-version-report.json index 0be0b51..d3522b3 100644 --- a/artifacts/pdf-audit/template-version-report.json +++ b/artifacts/pdf-audit/template-version-report.json @@ -1,9 +1,21 @@ { "audit": "template-version-integrity", - "overallStatus": "PASS", + "overallStatus": "FAIL", "activeTemplateCount": 1, - "quotationCode": "CRA2606-1107", + "quotationCode": "CRA2606-1121", "approvedTemplateVersionId": null, "approvedTemplateVersionIsActive": false, - "issues": [] + "issues": [ + { + "kind": "ACTIVE_VERSION_SCHEMA_DRIFT", + "message": "Active template version does not match source JSON for ALLA Demo Organization Quotation Standard.", + "context": { + "templateId": "83928eee-0b3b-4dcb-9756-726dc2337e6c", + "organizationName": "ALLA Demo Organization", + "activeVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", + "activeVersion": "1.0", + "sourceFile": "src/pdfme_template/ALLA_template_pdfme_fainal3.json" + } + } + ] } diff --git a/artifacts/pdf-audit/topic-runtime-report.json b/artifacts/pdf-audit/topic-runtime-report.json index ca4af66..37393fd 100644 --- a/artifacts/pdf-audit/topic-runtime-report.json +++ b/artifacts/pdf-audit/topic-runtime-report.json @@ -1,18 +1,10 @@ { "audit": "topic-runtime", - "overallStatus": "PASS", - "quotationCode": "CRA2606-1107", - "topicCount": 3, - "topicInputKeys": [ - "topic_1_0", - "topic_1_1", - "topic_1_2" - ], - "itemTopicInputKeys": [ - "item_topic_1_0", - "item_topic_1_1", - "item_topic_1_2" - ], + "overallStatus": "FAIL", + "quotationCode": "CRA2606-1121", + "topicCount": 0, + "topicInputKeys": [], + "itemTopicInputKeys": [], "duplicateKeys": [], "emptySections": [] } diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json index bda7c06..913b977 100644 --- a/docs/implementation/pdf-audit-report.json +++ b/docs/implementation/pdf-audit-report.json @@ -1,7 +1,7 @@ { - "generatedAt": "2026-06-30T09:16:20.352Z", + "generatedAt": "2026-06-30T13:14:09.584Z", "overallStatus": "PASS", - "quotationCode": "CRA2606-1107", + "quotationCode": "CRA2606-1121", "expectedFixtureCode": "QT-H5-AUDIT", "template": { "templateName": "ALLA Demo Organization Quotation Standard", @@ -238,11 +238,11 @@ ] }, "metrics": { - "topicCount": 3, - "itemCount": 4, - "partyCount": 1, - "dynamicTopicFieldCount": 3, - "dynamicTopicItemFieldCount": 3, + "topicCount": 0, + "itemCount": 0, + "partyCount": 2, + "dynamicTopicFieldCount": 0, + "dynamicTopicItemFieldCount": 0, "mappingCount": 23 }, "mappingCoverage": { @@ -256,14 +256,14 @@ "itemTableColumns": [] }, "runtimePayload": { - "templateInputKeys": 31, - "topicInputKeys": 6, + "templateInputKeys": 25, + "topicInputKeys": 0, "priceTableRows": 1 }, "consistency": { "status": "PASS", "approvedSnapshotAvailable": true, - "approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581", + "approvedArtifactReference": null, "inconsistentKeys": [] }, "integritySummary": { diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md index 5125518..7f303f2 100644 --- a/docs/implementation/pdf-audit-report.md +++ b/docs/implementation/pdf-audit-report.md @@ -1,16 +1,16 @@ # PDF Audit Report -- Generated At: 2026-06-30T09:16:20.352Z +- Generated At: 2026-06-30T13:14:09.584Z - Overall Status: PASS -- Quotation Code: CRA2606-1107 +- Quotation Code: CRA2606-1121 - Template: ALLA Demo Organization Quotation Standard v1.0 - Previous Version: None ## Metrics -- Topic Count: 3 -- Item Count: 4 -- Project Party Count: 1 +- Topic Count: 0 +- Item Count: 0 +- Project Party Count: 2 - Mapping Count: 23 ## Mapping Coverage diff --git a/docs/implementation/quotation-create-400-investigation-2026-06-30.md b/docs/implementation/quotation-create-400-investigation-2026-06-30.md new file mode 100644 index 0000000..9162b57 --- /dev/null +++ b/docs/implementation/quotation-create-400-investigation-2026-06-30.md @@ -0,0 +1,56 @@ +# Quotation Create 400 Investigation - 2026-06-30 + +## Scope +Investigate why quotation creation fails with `POST /api/crm/quotations -> 400`. + +## Summary +The failure is reproducible on the running local app and the API returns: + +```json +{"message":"Missing product type code configuration quotation type."} +``` + +This happens inside the document-sequence layer during quotation code generation, not in the quotation form schema itself. + +## Reproduction +1. Sign in with seeded UAT admin account: + - `admin.uat@alla.local` + - `UatDemo123!` +2. Submit a valid-looking quotation payload to `POST /api/crm/quotations`. +3. The API returns `400` with message: + - `Missing product type code configuration quotation type.` + +Reproduced against the local app at `http://localhost:3000` with authenticated session. + +## Findings +1. Quotation creation reaches [createQuotation()](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/server/service.ts:1329), passes quotation payload validation, and then calls `generateNextDocumentCode()` for document code allocation. +2. The failure occurs inside the document-sequence product-type resolution path in [src/features/foundation/document-sequence/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-sequence/service.ts:152) and [generateNextDocumentCode()](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-sequence/service.ts:527). +3. Database inspection shows the organization does have valid master options for both: + - `crm_quotation_type`: `crane`, `dockdoor`, `solarcell`, `service`, `other` + - `crm_product_type`: `crane`, `dockdoor`, `solarcell`, `service`, `sparepart` +4. Database inspection also shows quotation document sequences already exist for: + - `crane` + - `dockdoor` + - `solarcell` + - `service` +5. Because both option categories and document-sequence rows are present, the `400` is not caused by missing seed data in `ms_options` or missing quotation sequence rows. +6. The remaining failure point is the runtime mapping logic that resolves a quotation type id/code into the product-type code used by document sequences. That logic is currently rejecting a valid quotation type before sequence generation completes. + +## Likely Root Cause +The product-type map resolution in [src/features/foundation/document-sequence/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-sequence/service.ts:152) is not successfully resolving the incoming quotation type id/code to a usable document-sequence product type, even though: +- the quotation type option exists +- the product type option exists +- the document sequence row exists + +In other words, this is a mapping bug in the document-sequence resolution path, not a missing reference-data problem in the quotation form. + +## Secondary Observation +The frontend currently surfaces quotation create failures through [apiClient](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/api-client.ts:1) as generic `API error: 400 Bad Request`, which hides the backend message unless the request is inspected manually. That makes this class of failure harder to diagnose from the UI alone. + +## Status +- Investigation completed +- Root cause area identified +- No production code fix applied in this investigation round + +## Recommended Next Step +Patch the document-sequence product-type resolution so quotation type ids from quotation creation resolve consistently to the corresponding `crm_product_type` code before `generateNextDocumentCode()` runs. diff --git a/docs/implementation/task-d.4.6-quotation-document-sequence-fix-2026-06-30.md b/docs/implementation/task-d.4.6-quotation-document-sequence-fix-2026-06-30.md new file mode 100644 index 0000000..d4cbb24 --- /dev/null +++ b/docs/implementation/task-d.4.6-quotation-document-sequence-fix-2026-06-30.md @@ -0,0 +1,54 @@ +# Task D.4.6 Implementation Report - 2026-06-30 + +## Scope +- Fix `POST /api/crm/quotations` returning `400` during document-number generation. +- Preserve existing quotation, sequence, branch, organization, and approval rules. +- Improve frontend propagation of backend error messages. + +## Root Cause +- Quotation create sends `payload.quotationType` as a master-option `id` from category `crm_quotation_type`. +- Document-sequence resolution was looking up only `crm_product_type` options. +- Because `crm_quotation_type.id` and `crm_product_type.id` are different records, valid quotation-type ids never resolved to a product-type code even though the codes and document-sequence rows already existed. +- Result: document-sequence generation failed before quotation insert with message `Missing product type code configuration for quotation type.` + +## Implementation +- Added shared resolver [product-type-resolver.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-sequence/product-type-resolver.ts) to normalize and resolve: + - product-type option ids + - product-type codes + - document-sequence aliases such as `dock_door` and `solar` + - quotation-type ids/codes via reference-option translation into product-type codes +- Updated [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-sequence/service.ts) so quotation document-sequence generation now resolves: + - `crm_quotation_type` option -> code + - code -> matching `crm_product_type` + - product type -> document sequence / prefix +- Added internal diagnostic details to failed document-sequence audit payloads: + - requested product type + - normalized requested value + - available product-type ids/codes + - available quotation-type ids/codes +- Updated [api-client.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/api-client.ts) to surface backend `message`/`error` payloads instead of always throwing generic `API error: 400 Bad Request`. + +## Tests +- Added [product-type-resolver.test.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-sequence/product-type-resolver.test.ts) covering: + - quotation-type id resolution + - direct code resolution + - alias resolution + - non-quotation fallback + - unresolved input handling + +## Verification +- `npm exec tsc --noEmit` +- `node --import tsx --test src/features/foundation/document-sequence/config.test.ts src/features/foundation/document-sequence/product-type-resolver.test.ts` +- Live API verification against local app on `http://localhost:3000` on `2026-06-30` with authenticated UAT admin session: + - `crane` -> `CRA2606-002` + - `dockdoor` -> `DKA2606-001` + - `solarcell` -> `SCA2606-001` + - `service` -> `SVA2606-001` + +## Regression Notes +- No schema changes +- No seed changes +- No duplicate `ms_options` +- No duplicate `document_sequences` +- Existing organization and branch scoping preserved +- Existing document prefix format preserved diff --git a/docs/implementation/task-p.7.1.1-customer-package-ui-integration-2026-06-30.md b/docs/implementation/task-p.7.1.1-customer-package-ui-integration-2026-06-30.md new file mode 100644 index 0000000..b04d26c --- /dev/null +++ b/docs/implementation/task-p.7.1.1-customer-package-ui-integration-2026-06-30.md @@ -0,0 +1,63 @@ +# Task P.7.1.1 Implementation Report - 2026-06-30 + +## Scope +- Add quotation-detail UI actions for customer package generation, preview, and download. +- Reuse existing assembled PDF foundation from Task P.7 instead of creating a new document flow. +- Expose package status and included-document metadata in quotation detail. + +## Review Summary +Reviewed before implementation: +- [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-review-checklist.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-review-checklist.md) +- [docs/standards/task-catalog.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-catalog.md) +- [docs/business/crm-terminology.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/business/crm-terminology.md) +- [docs/implementation/task-p.7-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7-implementation.md) +- [docs/implementation/task-p.7.1-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7.1-implementation.md) +- [plans/task-p.7.1.1.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.7.1.1.md) + +Foundations reused: +- assembled quotation package generation and retrieval from [src/features/foundation/document-assembly/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly/server/service.ts) +- artifact lifecycle from [src/features/foundation/document-artifact/server/service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-artifact/server/service.ts) +- quotation detail page and React Query integration from [src/features/crm/quotations/components/quotation-detail.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-detail.tsx) + +## Implementation +- Added `POST` alias on [assembled-pdf route](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts) so the UI can call `POST /api/crm/quotations/[id]/assembled-pdf` exactly as the task specified while preserving the existing generate handler implementation. +- Extended quotation detail payload in [quotation types](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/api/types.ts) and [quotation server service](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/server/service.ts) with `customerPackage` summary metadata sourced from the active `assembled_pdf` artifact. +- Added client helpers in [quotation api service](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/api/service.ts) for: + - `generateQuotationCustomerPackage()` + - `downloadQuotationCustomerPackage()` +- Added [React Query mutation support](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/api/mutations.ts) that invalidates quotation detail/header and document queries after package generation. +- Added [customer package UI helpers](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/customer-package.ts) and [tests](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/customer-package.test.ts) for: + - package status mapping + - role labels for included documents + - business-readable error mapping for forbidden, not-generated, and missing-required-document cases +- Added quotation-detail UI in [quotation-detail.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-detail.tsx): + - `Customer Package` card + - generate / regenerate action with confirmation dialog + - preview action + - blob-based download action with toast-driven errors + - generated-at / generated-by / page-count / checksum display + - included-document list and warning display +- Reused existing quotation PDF permissions in [detail page route](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/quotations/[id]/page.tsx): + - preview customer package -> `crmQuotationPdfPreview` + - generate/download customer package -> `crmQuotationPdfDownload` + +## Design Notes +- No separate package-status endpoint was added. The UI reads status from the quotation detail payload because the active assembled artifact already provides the required metadata. +- Download intentionally uses `fetch` plus `Blob` instead of a raw anchor download so API errors can be converted into business-readable toast messages. +- Warning rows are displayed as server-provided messages because they may represent optional append-document gaps, not only fatal errors. + +## Verification +- `npm exec tsc --noEmit` -> PASS +- `node --import tsx --test src/features/foundation/document-sequence/config.test.ts src/features/crm/quotations/customer-package.test.ts` -> PASS +- `npm run build` -> PASS +- `npm run audit:pdf` -> FAIL on existing baseline issues unrelated to this task: + - `template-version-integrity`: `ACTIVE_VERSION_SCHEMA_DRIFT` + - `audit:pdf:integrity`: overall `FAIL` + - `topicRuntime`: `FAIL` + +## Outcome +Task P.7.1.1 now gives the quotation detail page a usable customer-package workflow on top of the existing assembly foundation, including generation, preview, download, package metadata visibility, and clearer failure handling without introducing email delivery behavior. diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/plans/task-d.4.6.md b/plans/task-d.4.6.md new file mode 100644 index 0000000..cd3968f --- /dev/null +++ b/plans/task-d.4.6.md @@ -0,0 +1,379 @@ +# Task D.4.6: Fix Quotation Document Sequence Product-Type Resolution + +## Objective + +Fix quotation creation failure caused by incorrect quotation-type to product-type resolution during document number generation. + +This task must correct the runtime mapping logic inside the document sequence service without introducing duplicate master data or changing existing business rules. + +--- + +## Background + +Creating a quotation currently fails with: + +```json +{ + "message": "Missing product type code configuration quotation type." +} +``` + +The failure occurs after quotation validation succeeds and immediately before document number allocation. + +Investigation confirmed this is **not** caused by missing seed data. + +--- + +## Current Findings + +Verified: + +- Quotation payload validation succeeds. +- `createQuotation()` reaches document code generation. +- `generateNextDocumentCode()` throws before allocating a sequence. +- Database already contains: + +### Master Options + +Category: + +``` +crm_quotation_type +``` + +Values: + +- crane +- dockdoor +- solarcell +- service +- other + +Category: + +``` +crm_product_type +``` + +Values: + +- crane +- dockdoor +- solarcell +- service +- sparepart + +### Document Sequences + +Quotation sequences already exist for: + +- crane +- dockdoor +- solarcell +- service + +Therefore the failure is **not** caused by missing: + +- master option +- document sequence +- seed data + +--- + +## Root Cause + +The document sequence service is failing to consistently resolve: + +Quotation Type + +↓ + +Product Type + +↓ + +Document Sequence + +The runtime mapping rejects a valid quotation type before sequence generation. + +This is a logic bug. + +--- + +## Scope + +Review: + +``` +src/features/crm/quotations/server/service.ts +``` + +``` +src/features/foundation/document-sequence/service.ts +``` + +``` +src/features/foundation/master-options/* +``` + +``` +src/db/seeds/* +``` + +Do **not** redesign the quotation module. + +Do **not** modify document numbering rules. + +--- + +## Requirements + +### 1. Trace Runtime Resolution + +Document the complete resolution path. + +Expected flow: + +``` +Quotation + +↓ + +quotationTypeId + +↓ + +Master Option + +↓ + +productTypeCode + +↓ + +Document Sequence + +↓ + +Document Number +``` + +Verify every transformation. + +--- + +### 2. Normalize Product Type Resolution + +Support all valid inputs. + +The resolver should accept: + +- option id +- option code +- normalized product type code + +It must not depend on display labels. + +--- + +### 3. Standardize Mapping + +Support at minimum: + +| Quotation Type | Product Type | Prefix | +|----------------|-------------|--------| +| crane | crane | CR | +| dockdoor | dockdoor | DK | +| solarcell | solarcell | SC | +| service | service | SV | +| sparepart | sparepart | SP | + +Future product types should require configuration only, not new code branches. + +--- + +### 4. Remove Duplicate Lookup Logic + +If quotation service and document sequence service both perform mapping independently: + +Refactor into a shared resolver. + +Avoid duplicated switch statements. + +--- + +### 5. Improve Error Messages + +Replace generic runtime errors with diagnostic information. + +Example: + +``` +Unable to resolve product type for quotation. + +quotationTypeId: +xxxxxxxx + +quotationTypeCode: +crane + +organizationId: +... + +branchId: +... + +resolvedProductType: +null +``` + +This should only be returned internally/logged during development. + +User-facing messages should remain business friendly. + +--- + +### 6. Preserve Existing Business Rules + +Do not modify: + +- organization isolation +- branch isolation +- document numbering format +- document sequence locking +- approval workflow +- quotation schema + +--- + +### 7. Frontend Error Handling + +Improve API error handling. + +Current UI displays: + +``` +API Error 400 Bad Request +``` + +Instead display backend messages when available. + +Example: + +``` +Missing product type code configuration quotation type. +``` + +Fallback to generic messages only when backend provides none. + +--- + +## Validation + +Verify quotation creation succeeds for: + +- Crane +- Dock Door +- Solar Cell +- Service + +Each must generate the correct sequence. + +Example: + +``` +CRA2606-001 +``` + +``` +DKA2606-001 +``` + +``` +SCA2606-001 +``` + +``` +SVA2606-001 +``` + +Verify the sequence increments correctly. + +--- + +## Regression Checklist + +Confirm no regression for: + +- Document sequence generation +- Branch-specific numbering +- Organization-specific numbering +- Product-specific numbering +- Existing document types +- Existing sequence rows +- Existing seeds + +--- + +## Technical Constraints + +- No database schema changes unless absolutely required. +- No duplicate `ms_options`. +- No duplicate `document_sequences`. +- Keep seed scripts idempotent. +- Preserve current APIs. + +--- + +## Deliverables + +- Fixed runtime product-type resolution. +- Shared resolver (if duplication exists). +- Improved backend diagnostics. +- Improved frontend API error display. +- Updated unit/integration tests where applicable. +- Verification report summarizing: + - root cause + - implementation + - validation results + - regression results + +--- + +## Acceptance Criteria + +- Quotation creation succeeds without 400 errors for all configured product types. +- Document numbers are generated correctly. +- Existing numbering rules remain unchanged. +- No additional seed data is required. +- TypeScript passes. + +```bash +npm exec tsc --noEmit +``` + +- Existing test suite passes. +- No regression in document sequence generation. + +--- + +## Implementation Status - 2026-06-30 + +- Status: Completed +- Implementation report: + - [task-d.4.6-quotation-document-sequence-fix-2026-06-30.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-d.4.6-quotation-document-sequence-fix-2026-06-30.md) + +### Result Summary +- Fixed quotation-type to product-type resolution in document sequence generation. +- Added a shared resolver for option id/code/alias handling plus quotation-type reference mapping. +- Preserved current numbering rules and existing sequence rows. +- Improved API client error handling so backend messages can be shown in the UI. + +### Validation Summary +- `npm exec tsc --noEmit` passed. +- `node --import tsx --test src/features/foundation/document-sequence/config.test.ts src/features/foundation/document-sequence/product-type-resolver.test.ts` passed. +- Authenticated API verification on `http://localhost:3000` passed for: + - `crane` -> `CRA2606-002` + - `dockdoor` -> `DKA2606-001` + - `solarcell` -> `SCA2606-001` + - `service` -> `SVA2606-001` diff --git a/plans/task-d.4.7.md b/plans/task-d.4.7.md new file mode 100644 index 0000000..c5b24bf --- /dev/null +++ b/plans/task-d.4.7.md @@ -0,0 +1,421 @@ +# Task D.4.7 – Organization-aware Document Sequence & Prefix Strategy + +## Objective + +Redesign the document-sequence strategy so document prefixes are configuration-driven and organization-aware, eliminating hardcoded prefix logic from the application. + +The new design must support multiple companies, branches, product types, and document types without requiring source code changes. + +--- + +# Background + +Current document numbering works correctly after Task D.4.6 but still assumes product prefixes are fixed inside the application. + +Example: + +``` +CRA2606-001 +DKA2606-001 +SCA2606-001 +``` + +However, the business requires different companies (organizations) to use different prefixes while keeping the same product types. + +Example: + +## ALLA + +``` +CRA2606-001 +DKA2606-001 +SCA2606-001 +SVA2606-001 +``` + +## ONVALLA + +``` +CRO2606-001 +DKO2606-001 +SCO2606-001 +SVO2606-001 +``` + +The application should support additional organizations in the future without code modifications. + +--- + +# Business Requirements + +Document numbers must be isolated by: + +``` +Organization + ↓ +Branch + ↓ +Document Type + ↓ +Product Type + ↓ +Running Number +``` + +Running numbers must never overlap across organizations. + +Example + +ALLA + +``` +CRA2606-001 +CRA2606-002 +``` + +ONVALLA + +``` +CRO2606-001 +CRO2606-002 +``` + +Both organizations maintain independent counters. + +--- + +# Scope + +Review: + +``` +src/features/foundation/document-sequence/* +``` + +``` +src/features/foundation/master-options/* +``` + +``` +src/db/schema.ts +``` + +``` +src/db/seeds/* +``` + +Review existing document sequence configuration before introducing new structures. + +Avoid duplicate concepts. + +--- + +# Requirements + +## 1. Remove Hardcoded Prefix Logic + +No document prefix may be hardcoded. + +Avoid patterns such as: + +```ts +switch(productType){ + case "crane": + return "CRA"; +} +``` + +or + +```ts +if(productType==="crane") +``` + +Prefix generation must be configuration-driven. + +--- + +## 2. Introduce Organization-aware Prefix Configuration + +Design a configuration that determines: + +``` +Organization + +↓ + +Document Type + +↓ + +Product Type + +↓ + +Prefix +``` + +Example + +| Organization | Document | Product | Prefix | +|--------------|----------|----------|--------| +| ALLA | quotation | crane | CRA | +| ALLA | quotation | dockdoor | DKA | +| ALLA | quotation | solarcell | SCA | +| ALLA | quotation | service | SVA | +| ONVALLA | quotation | crane | CRO | +| ONVALLA | quotation | dockdoor | DKO | +| ONVALLA | quotation | solarcell | SCO | +| ONVALLA | quotation | service | SVO | + +Future organizations must be configurable without code changes. + +--- + +## 3. Preserve Existing Sequence Strategy + +Current uniqueness must remain: + +``` +Organization + ++ + +Branch + ++ + +Document Type + ++ + +Product Type + ++ + +Period +``` + +Running numbers remain independent. + +--- + +## 4. Keep Product Resolver Generic + +Task D.4.6 introduced a shared product-type resolver. + +Do not move organization logic into the resolver. + +Responsibilities should become: + +### Product Resolver + +Responsible only for + +``` +Quotation Type + +↓ + +Product Type +``` + +### Document Sequence + +Responsible only for + +``` +Organization + +↓ + +Prefix + +↓ + +Running Number +``` + +Keep these responsibilities separated. + +--- + +## 5. Centralize Prefix Configuration + +Avoid scattered constants. + +Design a single source of truth. + +Example + +``` +Document Prefix Configuration + +↓ + +Resolver + +↓ + +Document Sequence +``` + +No module should define its own prefixes. + +--- + +## 6. Support Future Document Types + +The strategy must support future modules. + +Examples + +``` +Quotation + +Delivery Note + +Sales Contract + +Purchase Order + +Invoice + +Service Report + +Work Order +``` + +Each document type may define its own prefixes. + +--- + +## 7. Preserve Backward Compatibility + +Existing document numbers remain valid. + +Do not change + +- existing quotations +- revisions +- approved PDFs +- audit logs + +Only affect newly generated numbers. + +--- + +## 8. Improve Configuration Validation + +During startup or seed validation verify: + +- duplicate prefixes +- missing prefix +- missing organization mapping +- invalid product type +- invalid document type + +Configuration errors should fail early with clear messages. + +--- + +# Database Considerations + +Review whether existing `document_sequences` already contains sufficient information. + +If additional configuration is required: + +- prefer extending existing structures +- avoid introducing duplicate master tables +- justify any schema change + +Migration must remain backward compatible. + +--- + +# Tests + +Add coverage for: + +- ALLA quotation +- ONVALLA quotation +- branch isolation +- organization isolation +- period rollover +- configuration validation +- missing prefix +- duplicate configuration + +--- + +# Verification + +Verify: + +ALLA + +``` +CRA2606-001 +CRA2606-002 +``` + +``` +DKA2606-001 +``` + +ONVALLA + +``` +CRO2606-001 +CRO2606-002 +``` + +``` +DKO2606-001 +``` + +Ensure counters remain independent. + +--- + +# Regression Checklist + +Confirm no regression for: + +- Quotation creation +- Revision creation +- Approval workflow +- PDF generation +- Document preview +- Audit log +- Existing APIs +- Existing seed data + +--- + +# Deliverables + +- Organization-aware prefix strategy +- Configuration-driven prefix resolution +- Removal of hardcoded prefix logic +- Updated seed/configuration (if required) +- Updated unit tests +- Verification report +- Architecture notes describing the new numbering strategy + +--- + +# Acceptance Criteria + +- No hardcoded document prefixes remain. +- Prefixes are configurable per organization. +- Running numbers remain isolated by organization, branch, document type, product type, and period. +- Existing document numbers remain valid. +- Future organizations can be added through configuration without modifying application logic. +- TypeScript passes. + +```bash +npm exec tsc --noEmit +``` + +- All document-sequence tests pass. diff --git a/plans/task-p.7.1.1.md b/plans/task-p.7.1.1.md new file mode 100644 index 0000000..b31ed52 --- /dev/null +++ b/plans/task-p.7.1.1.md @@ -0,0 +1,366 @@ +# Task P.7.1.1 - Customer Package UI Integration + +## Objective + +Add user-facing UI controls for generating, viewing, and downloading the assembled customer document package from the quotation detail page. + +This task connects the existing Document Assembly API to the CRM UI. + +It does not introduce SMTP, email sending, or notification automation. + +--- + +# Background + +Completed: + +* P.7 Document Assembly Framework +* P.7.1 Notification Foundation + +Current backend APIs exist: + +```text +POST /api/crm/quotations/[id]/assembled-pdf +GET /api/crm/quotations/[id]/assembled-pdf/download +``` + +However, users currently have no UI button to generate the assembled PDF before download. + +This causes: + +```text +Assembled quotation PDF has not been generated yet. +``` + +when the download endpoint is called before package generation. + +--- + +# Scope + +Included: + +* Quotation detail UI integration +* Generate Customer Package action +* Download Customer Package action +* Package status display +* Included document list display +* Error handling for missing SLA or required documents +* Loading and success states +* Query/mutation integration +* Permission guard + +Excluded: + +* SMTP / Email provider configuration +* Sending customer email +* Notification event automation +* Render configuration +* User-selectable append documents +* Assembly policy editor + +--- + +# UI Placement + +Add a section in quotation detail page. + +Suggested label: + +```text +Customer Package +``` + +Suggested actions: + +```text +Generate Customer Package +Download Customer Package +Regenerate Package +``` + +Suggested display: + +```text +Status: Not Generated / Generated / Failed +Generated At +Generated By +Included Documents: +- Quotation +- SLA +- Warranty +- Datasheet +Page Count +File Size +Checksum +``` + +--- + +# API Usage + +## Generate + +Call: + +```http +POST /api/crm/quotations/[id]/assembled-pdf +``` + +Expected behavior: + +* generate quotation PDF if needed +* resolve active SLA / append documents +* merge PDFs +* store final artifact +* return package metadata + +--- + +## Download + +Call: + +```http +GET /api/crm/quotations/[id]/assembled-pdf/download +``` + +If package is not generated: + +* show user-friendly message +* do not show raw API error only +* suggest generating package first + +--- + +# Client Service + +Add client service methods. + +Example: + +```ts +generateQuotationCustomerPackage(quotationId: string) +downloadQuotationCustomerPackage(quotationId: string) +getQuotationCustomerPackageStatus(quotationId: string) +``` + +If a status endpoint does not exist yet, either: + +* add one, or +* return status from existing quotation/detail payload if already available + +--- + +# React Query + +Add mutation/query support: + +* generate customer package mutation +* package status query +* invalidate quotation detail after successful generation +* invalidate document/artifact query if applicable + +--- + +# Permissions + +Use existing or add permission: + +```text +crmQuotationGenerateCustomerPackage +crmQuotationDownloadCustomerPackage +``` + +If permission model already has quotation PDF permissions, reuse only if semantically correct. + +--- + +# Error Handling + +Handle: + +## Not Generated + +Show: + +```text +Customer package has not been generated yet. Please generate it first. +``` + +## Missing Required Document + +Example: + +```text +Cannot generate package because required SLA document is missing. +``` + +## Assembly Failure + +Show: + +```text +Customer package generation failed. Please check document library configuration. +``` + +## Permission Denied + +Show: + +```text +You do not have permission to generate or download customer package. +``` + +--- + +# UX Rules + +* Disable download button until package exists. +* Show loading state while generating. +* Allow regenerate after package exists. +* Confirm before regenerate if it overwrites or creates a new artifact version. +* Do not automatically email after generation. +* Do not automatically activate any template or library document. + +--- + +# Verification Scenarios + +Test manually: + +## Scenario 1 - No Package + +Open quotation detail. + +Expected: + +* status = Not Generated +* Download disabled +* Generate enabled + +## Scenario 2 - Generate Package + +Click Generate Customer Package. + +Expected: + +* package generated +* included documents visible +* SLA listed if policy requires it +* Download enabled + +## Scenario 3 - Download Package + +Click Download. + +Expected: + +* PDF downloads +* PDF includes quotation + SLA + +## Scenario 4 - Missing Required SLA + +Temporarily remove/inactivate SLA. + +Click Generate. + +Expected: + +* business-readable error +* no broken artifact + +## Scenario 5 - Regenerate + +Click Regenerate. + +Expected: + +* new artifact created or existing artifact updated according to backend behavior +* UI status refreshes + +--- + +# Testing Requirements + +Run: + +```bash +npm run typecheck +npm run build +npm run audit:pdf +``` + +Add tests where practical for: + +* mutation service +* UI state helper +* permission visibility +* error message mapping + +--- + +# Acceptance Criteria + +* Quotation detail page has Customer Package section. +* User can generate assembled package from UI. +* User can download assembled package after generation. +* UI clearly shows package status. +* UI shows included documents such as SLA. +* Missing required SLA displays clear error. +* Download before generation is prevented in UI. +* Existing approved PDF actions remain unchanged. +* No email is sent in this task. +* Build and typecheck pass. + +--- + +# Out of Scope + +## P.7.2 + +* SMTP configuration +* Email provider settings +* Real email delivery verification + +## P.7.3 + +* Approval notification automation +* Request approval email +* Approve/reject email + +## P.7.4 + +* Send Customer Package Email +* Customer recipient selection +* Email history from quotation detail + +--- + +# Final Success Condition + +Task P.7.1.1 is complete when users can generate and download the assembled Customer Package directly from quotation detail, confirm that SLA is included, and proceed to P.7.2 email configuration with a usable package artifact already available. + +--- + +## Implementation Status - 2026-06-30 + +Status: Completed + +Implemented: +- Added `Customer Package` section on quotation detail page with generate, regenerate, preview, and download actions. +- Reused assembled quotation package APIs and existing quotation PDF permissions instead of adding a separate permission model. +- Extended quotation detail payload with active assembled artifact metadata so UI can show package status, generator, checksum, file size, page count, included documents, and warnings. +- Added customer-package error/status helpers and focused tests. +- Added `POST /api/crm/quotations/[id]/assembled-pdf` support by exporting the existing generate handler from the assembled-pdf route. + +Verification: +- `npm exec tsc --noEmit` -> PASS +- `node --import tsx --test src/features/foundation/document-sequence/config.test.ts src/features/crm/quotations/customer-package.test.ts` -> PASS +- `npm run build` -> PASS +- `npm run audit:pdf` -> baseline FAIL unrelated to this task: + - `ACTIVE_VERSION_SCHEMA_DRIFT` + - `topicRuntime` / integrity summary failure + +Implementation report: +- [task-p.7.1.1-customer-package-ui-integration-2026-06-30.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7.1.1-customer-package-ui-integration-2026-06-30.md) diff --git a/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts b/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts index 0c80228..b5076be 100644 --- a/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts +++ b/src/app/api/crm/quotations/[id]/assembled-pdf/route.ts @@ -8,6 +8,8 @@ import { import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; +export { POST } from './generate/route'; + type Params = { params: Promise<{ id: string }> }; export async function GET(_request: NextRequest, { params }: Params) { diff --git a/src/app/dashboard/crm/quotations/[id]/page.tsx b/src/app/dashboard/crm/quotations/[id]/page.tsx index 27f4877..f8d0949 100644 --- a/src/app/dashboard/crm/quotations/[id]/page.tsx +++ b/src/app/dashboard/crm/quotations/[id]/page.tsx @@ -96,11 +96,14 @@ export default async function QuotationDetailRoute({ params }: PageProps) { (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfPreview))); + const canPreviewCustomerPackage = canPreviewPdf; const canDownloadPdf = session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfDownload))); + const canGenerateCustomerPackage = canDownloadPdf; + const canDownloadCustomerPackage = canDownloadPdf; const canGenerateApprovedPdf = session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && @@ -165,7 +168,10 @@ export default async function QuotationDetailRoute({ params }: PageProps) { currentUserId={session?.user?.id ?? ''} canPreviewDocument={canPreviewDocument} canPreviewPdf={canPreviewPdf} + canPreviewCustomerPackage={canPreviewCustomerPackage} canDownloadPdf={canDownloadPdf} + canGenerateCustomerPackage={canGenerateCustomerPackage} + canDownloadCustomerPackage={canDownloadCustomerPackage} canGenerateApprovedPdf={canGenerateApprovedPdf} /> diff --git a/src/features/crm/quotations/api/mutations.ts b/src/features/crm/quotations/api/mutations.ts index 26d0c04..74fe8df 100644 --- a/src/features/crm/quotations/api/mutations.ts +++ b/src/features/crm/quotations/api/mutations.ts @@ -5,6 +5,7 @@ import { createQuotation, createQuotationAttachment, createQuotationCustomer, + generateQuotationCustomerPackage, createQuotationFollowup, createQuotationItem, createQuotationRevision, @@ -374,3 +375,13 @@ export const createQuotationRevisionMutation = mutationOptions({ } } }); + +export const generateQuotationCustomerPackageMutation = mutationOptions({ + mutationFn: ({ quotationId }: { quotationId: string }) => + generateQuotationCustomerPackage(quotationId), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateQuotationHeaderQueries(variables.quotationId); + } + } +}); diff --git a/src/features/crm/quotations/api/service.ts b/src/features/crm/quotations/api/service.ts index 9e48d61..05ef2f4 100644 --- a/src/features/crm/quotations/api/service.ts +++ b/src/features/crm/quotations/api/service.ts @@ -3,6 +3,7 @@ import type { MutationSuccessResponse, QuotationAttachmentMutationPayload, QuotationAttachmentsResponse, + QuotationCustomerPackageGenerateResponse, QuotationCustomerMutationPayload, QuotationCustomersResponse, QuotationDetailResponse, @@ -41,6 +42,37 @@ export async function getQuotationById(id: string): Promise(`/crm/quotations/${id}`); } +export async function generateQuotationCustomerPackage( + quotationId: string +): Promise { + return apiClient( + `/crm/quotations/${quotationId}/assembled-pdf`, + { + method: 'POST' + } + ); +} + +export async function downloadQuotationCustomerPackage(quotationId: string): Promise<{ + blob: Blob; + fileName: string; +}> { + const response = await fetch(`/api/crm/quotations/${quotationId}/assembled-pdf/download`); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? 'Failed to download customer package'); + } + + const contentDisposition = response.headers.get('content-disposition') ?? ''; + const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/i); + + return { + blob: await response.blob(), + fileName: fileNameMatch?.[1] ?? `${quotationId}-customer-package.pdf` + }; +} + export async function createQuotation(data: QuotationMutationPayload) { return apiClient('/crm/quotations', { method: 'POST', @@ -212,4 +244,3 @@ export async function createQuotationRevision(id: string, revisionRemark?: strin body: JSON.stringify({ revisionRemark }) }); } - diff --git a/src/features/crm/quotations/api/types.ts b/src/features/crm/quotations/api/types.ts index 9a64b93..eca6ab4 100644 --- a/src/features/crm/quotations/api/types.ts +++ b/src/features/crm/quotations/api/types.ts @@ -59,6 +59,37 @@ export interface QuotationApprovedArtifactSummary { lockedByName: string | null; } +export interface QuotationCustomerPackageSource { + role: string; + sourceType: 'generated' | 'document_library'; + fileName: string; + checksum: string | null; + fileSize: number | null; + pageCount: number | null; + metadata: Record | null; +} + +export interface QuotationCustomerPackageWarning { + code: string; + role: string; + message: string; +} + +export interface QuotationCustomerPackageSummary { + artifactId: string; + fileName: string; + contentType: string; + fileSize: number | null; + checksum: string | null; + status: string; + pageCount: number | null; + generatedAt: string; + generatedBy: string; + generatedByName: string | null; + includedDocuments: QuotationCustomerPackageSource[]; + warnings: QuotationCustomerPackageWarning[]; +} + export interface QuotationRecord { id: string; organizationId: string; @@ -103,6 +134,7 @@ export interface QuotationRecord { approvedSnapshot: unknown | null; approvedTemplateVersionId: string | null; approvedArtifact: QuotationApprovedArtifactSummary | null; + customerPackage: QuotationCustomerPackageSummary | null; hasLegacyApprovedPdf: boolean; acceptedAt: string | null; rejectedAt: string | null; @@ -432,3 +464,20 @@ export interface MutationSuccessResponse { success: boolean; message: string; } + +export interface QuotationCustomerPackageGenerateResponse { + success: boolean; + fileName: string; + checksum: string; + fileSize: number; + pageCount: number; + warnings: QuotationCustomerPackageWarning[]; + artifact: { + artifactId: string; + storageKey: string; + fileName: string; + checksum: string; + fileSize: number; + pageCount: number; + } | null; +} diff --git a/src/features/crm/quotations/components/quotation-detail.tsx b/src/features/crm/quotations/components/quotation-detail.tsx index 5ab56af..bb62412 100644 --- a/src/features/crm/quotations/components/quotation-detail.tsx +++ b/src/features/crm/quotations/components/quotation-detail.tsx @@ -40,6 +40,7 @@ import { createQuotationAttachmentMutation, createQuotationCustomerMutation, createQuotationFollowupMutation, + generateQuotationCustomerPackageMutation, createQuotationItemMutation, createQuotationRevisionMutation, createQuotationTopicMutation, @@ -64,10 +65,12 @@ import { quotationRevisionsOptions, quotationTopicsOptions } from '../api/queries'; +import { downloadQuotationCustomerPackage } from '../api/service'; import type { QuotationAttachmentMutationPayload, QuotationAttachmentRecord, QuotationCustomerListItem, + QuotationCustomerPackageSource, QuotationCustomerMutationPayload, QuotationFollowupMutationPayload, QuotationFollowupRecord, @@ -79,6 +82,11 @@ import type { } from '../api/types'; import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations'; import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries'; +import { + getCustomerPackageRoleLabel, + getCustomerPackageUiStatus, + mapCustomerPackageErrorMessage +} from '../customer-package'; import { QuotationDocumentPreview } from './quotation-document-preview'; import { QuotationFormSheet } from './quotation-form-sheet'; import { QuotationApprovalTab } from './quotation-approval-tab'; @@ -102,6 +110,27 @@ function EmptyState({ message }: { message: string }) { ); } +function CustomerPackageSourceItem({ + item +}: { + item: QuotationCustomerPackageSource; +}) { + return ( +
+
+ {getCustomerPackageRoleLabel(item.role)} + {item.sourceType === 'generated' ? 'Generated' : 'Library'} +
+
{item.fileName}
+
+ {item.pageCount ? `${formatNumber(item.pageCount)} page(s)` : 'Page count unavailable'} + {item.fileSize ? ` • ${formatNumber(item.fileSize)} bytes` : ''} + {item.checksum ? ` • ${item.checksum.slice(0, 16)}` : ''} +
+
+ ); +} + function ItemDialog({ open, onOpenChange, @@ -673,7 +702,10 @@ export function QuotationDetail({ currentUserId, canPreviewDocument, canPreviewPdf, + canPreviewCustomerPackage, canDownloadPdf, + canGenerateCustomerPackage, + canDownloadCustomerPackage, canGenerateApprovedPdf }: { quotationId: string; @@ -694,7 +726,10 @@ export function QuotationDetail({ currentUserId: string; canPreviewDocument: boolean; canPreviewPdf: boolean; + canPreviewCustomerPackage: boolean; canDownloadPdf: boolean; + canGenerateCustomerPackage: boolean; + canDownloadCustomerPackage: boolean; canGenerateApprovedPdf: boolean; }) { const { data } = useSuspenseQuery(quotationByIdOptions(quotationId)); @@ -705,6 +740,12 @@ export function QuotationDetail({ const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId)); const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId)); const quotation = data.quotation; + const [customerPackageError, setCustomerPackageError] = useState(null); + const [confirmRegenerateOpen, setConfirmRegenerateOpen] = useState(false); + const customerPackageStatus = getCustomerPackageUiStatus({ + customerPackage: quotation.customerPackage, + lastErrorMessage: customerPackageError + }); const [editOpen, setEditOpen] = useState(false); const [itemOpen, setItemOpen] = useState(false); @@ -844,6 +885,44 @@ export function QuotationDetail({ onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to submit for approval') }); + const generateCustomerPackage = useMutation({ + ...generateQuotationCustomerPackageMutation, + onSuccess: () => { + setCustomerPackageError(null); + setConfirmRegenerateOpen(false); + toast.success('Customer package generated successfully'); + }, + onError: (error) => { + const message = mapCustomerPackageErrorMessage( + error instanceof Error ? error.message : null + ); + setCustomerPackageError(message); + setConfirmRegenerateOpen(false); + toast.error(message); + } + }); + const downloadCustomerPackage = useMutation({ + mutationFn: async () => downloadQuotationCustomerPackage(quotationId), + onSuccess: async ({ blob, fileName }) => { + setCustomerPackageError(null); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + toast.success('Customer package downloaded successfully'); + }, + onError: (error) => { + const message = mapCustomerPackageErrorMessage( + error instanceof Error ? error.message : null + ); + setCustomerPackageError(message); + toast.error(message); + } + }); const generateApprovedPdf = useMutation({ mutationFn: async () => { const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, { @@ -918,16 +997,30 @@ export function QuotationDetail({ () => new Map(referenceData.topicTypes.map((item) => [item.id, item])), [referenceData.topicTypes] ); - const followupTypeMap = useMemo( - () => new Map(referenceData.followupTypes.map((item) => [item.id, item])), - [referenceData.followupTypes] - ); - const customer = customerMap.get(quotation.customerId); - const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null; - const status = statusMap.get(quotation.status); - const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes( - status?.code ?? '' - ); +const followupTypeMap = useMemo( + () => new Map(referenceData.followupTypes.map((item) => [item.id, item])), + [referenceData.followupTypes] +); +const customer = customerMap.get(quotation.customerId); +const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null; +const status = statusMap.get(quotation.status); +const customerPackage = quotation.customerPackage; +const customerPackageActionLabel = customerPackage ? 'Regenerate Package' : 'Generate Customer Package'; +const customerPackageStatusLabel = + customerPackageStatus === 'generated' + ? 'Generated' + : customerPackageStatus === 'failed' + ? 'Failed' + : 'Not Generated'; +const customerPackageStatusDescription = + customerPackageStatus === 'generated' + ? 'Customer-facing assembled PDF is ready.' + : customerPackageStatus === 'failed' + ? customerPackageError + : 'Generate the package before downloading or previewing it.'; +const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes( + status?.code ?? '' +); const canSubmitCurrentQuotation = canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? ''); const canGenerateApprovedNow = canGenerateApprovedPdf && (status?.code ?? '') === 'approved'; @@ -1075,6 +1168,31 @@ export function QuotationDetail({ onOpenChange={setEditOpen} referenceData={referenceData} /> + + + + Regenerate Customer Package? + + A new assembled package artifact will replace the current active package for this quotation. + + + + + + + +
@@ -1162,6 +1280,131 @@ export function QuotationDetail({
+ + +
+ Customer Package + + Generate and download the assembled customer-facing quotation package. + +
+
+ {canGenerateCustomerPackage ? ( + + ) : null} + {canPreviewCustomerPackage ? ( + + ) : null} + {canDownloadCustomerPackage ? ( + + ) : null} +
+
+ + {!canGenerateCustomerPackage && !canDownloadCustomerPackage && !canPreviewCustomerPackage ? ( + + ) : ( + <> +
+ + + + + + + + +
+
+ {customerPackageStatusDescription} +
+ {customerPackage?.warnings.length ? ( +
+
Warnings
+ {customerPackage.warnings.map((warning) => ( +
+ {warning.message} +
+ ))} +
+ ) : null} +
+
Included Documents
+ {!customerPackage?.includedDocuments.length ? ( + + ) : ( +
+ {customerPackage.includedDocuments.map((item) => ( + + ))} +
+ )} +
+ + )} +
+
+
diff --git a/src/features/crm/quotations/customer-package.test.ts b/src/features/crm/quotations/customer-package.test.ts new file mode 100644 index 0000000..9e89e4c --- /dev/null +++ b/src/features/crm/quotations/customer-package.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + getCustomerPackageRoleLabel, + getCustomerPackageUiStatus, + mapCustomerPackageErrorMessage +} from './customer-package'; + +test('mapCustomerPackageErrorMessage rewrites not-generated error', () => { + assert.equal( + mapCustomerPackageErrorMessage('Assembled quotation PDF has not been generated yet.'), + 'Customer package has not been generated yet. Please generate it first.' + ); +}); + +test('mapCustomerPackageErrorMessage rewrites missing document error', () => { + assert.equal( + mapCustomerPackageErrorMessage( + 'Missing SLA document (SLA, brand generic, language th, product type crane).' + ), + 'Cannot generate package because required SLA document is missing.' + ); +}); + +test('mapCustomerPackageErrorMessage rewrites forbidden error', () => { + assert.equal( + mapCustomerPackageErrorMessage('Forbidden'), + 'You do not have permission to generate or download customer package.' + ); +}); + +test('getCustomerPackageRoleLabel falls back to humanized labels', () => { + assert.equal(getCustomerPackageRoleLabel('main'), 'Quotation'); + assert.equal(getCustomerPackageRoleLabel('custom_appendix'), 'Custom Appendix'); +}); + +test('getCustomerPackageUiStatus reflects generated, failed, and not-generated states', () => { + assert.equal( + getCustomerPackageUiStatus({ + customerPackage: { + artifactId: 'artifact-1', + fileName: 'pkg.pdf', + contentType: 'application/pdf', + fileSize: 100, + checksum: 'abc', + status: 'active', + pageCount: 2, + generatedAt: '2026-06-30T00:00:00.000Z', + generatedBy: 'user-1', + generatedByName: 'User', + includedDocuments: [], + warnings: [] + }, + lastErrorMessage: 'Something failed before' + }), + 'generated' + ); + assert.equal( + getCustomerPackageUiStatus({ + customerPackage: null, + lastErrorMessage: 'Customer package generation failed.' + }), + 'failed' + ); + assert.equal( + getCustomerPackageUiStatus({ + customerPackage: null, + lastErrorMessage: null + }), + 'not_generated' + ); +}); diff --git a/src/features/crm/quotations/customer-package.ts b/src/features/crm/quotations/customer-package.ts new file mode 100644 index 0000000..7605e24 --- /dev/null +++ b/src/features/crm/quotations/customer-package.ts @@ -0,0 +1,65 @@ +import type { QuotationCustomerPackageSummary } from './api/types'; + +const CUSTOMER_PACKAGE_ROLE_LABELS: Record = { + main: 'Quotation', + sla: 'SLA', + warranty: 'Warranty', + datasheet: 'Datasheet', + drawing: 'Drawing' +}; + +export type CustomerPackageUiStatus = 'not_generated' | 'generated' | 'failed'; + +export function getCustomerPackageRoleLabel(role: string): string { + return ( + CUSTOMER_PACKAGE_ROLE_LABELS[role] ?? + role + .split(/[_\s-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + ); +} + +export function mapCustomerPackageErrorMessage(message?: string | null): string { + const normalized = message?.trim(); + + if (!normalized) { + return 'Customer package generation failed. Please check document library configuration.'; + } + + if ( + normalized === 'Forbidden' || + normalized.toLowerCase().includes('permission') || + normalized.toLowerCase().includes('forbidden') + ) { + return 'You do not have permission to generate or download customer package.'; + } + + if (normalized === 'Assembled quotation PDF has not been generated yet.') { + return 'Customer package has not been generated yet. Please generate it first.'; + } + + const missingRequiredDocument = normalized.match(/^Missing ([A-Z_ ]+) document/i); + if (missingRequiredDocument) { + const role = getCustomerPackageRoleLabel(missingRequiredDocument[1].toLowerCase()); + return `Cannot generate package because required ${role} document is missing.`; + } + + return normalized; +} + +export function getCustomerPackageUiStatus(input: { + customerPackage: QuotationCustomerPackageSummary | null; + lastErrorMessage?: string | null; +}): CustomerPackageUiStatus { + if (input.customerPackage) { + return 'generated'; + } + + if (input.lastErrorMessage) { + return 'failed'; + } + + return 'not_generated'; +} diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts index 37586a9..578845a 100644 --- a/src/features/crm/quotations/server/service.ts +++ b/src/features/crm/quotations/server/service.ts @@ -19,6 +19,10 @@ import { AuthError } from '@/lib/auth/session'; import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; import { listAuditLogs } from '@/features/foundation/audit-log/service'; import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; +import { + getActiveArtifactForEntity, + type DocumentArtifactRecord +} from '@/features/foundation/document-artifact/server/service'; import { type CrmSecurityContext, canAccessScopedCrmRecord @@ -47,6 +51,9 @@ import type { QuotationApprovedArtifactSummary, QuotationBranchOption, QuotationContactLookup, + QuotationCustomerPackageSource, + QuotationCustomerPackageSummary, + QuotationCustomerPackageWarning, QuotationCustomerListItem, QuotationCustomerLookup, QuotationCustomerMutationPayload, @@ -133,10 +140,99 @@ function mapApprovedArtifactSummary( }; } +function toCustomerPackageSource( + value: unknown +): QuotationCustomerPackageSource | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + const source = value as Record; + if (typeof source.role !== 'string' || typeof source.fileName !== 'string') { + return null; + } + + if (source.sourceType !== 'generated' && source.sourceType !== 'document_library') { + return null; + } + + return { + role: source.role, + sourceType: source.sourceType, + fileName: source.fileName, + checksum: typeof source.checksum === 'string' ? source.checksum : null, + fileSize: typeof source.fileSize === 'number' ? source.fileSize : null, + pageCount: typeof source.pageCount === 'number' ? source.pageCount : null, + metadata: + source.metadata && typeof source.metadata === 'object' && !Array.isArray(source.metadata) + ? (source.metadata as Record) + : null + }; +} + +function toCustomerPackageWarning( + value: unknown +): QuotationCustomerPackageWarning | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + const warning = value as Record; + if ( + typeof warning.code !== 'string' || + typeof warning.role !== 'string' || + typeof warning.message !== 'string' + ) { + return null; + } + + return { + code: warning.code, + role: warning.role, + message: warning.message + }; +} + +function mapCustomerPackageSummary( + artifact: DocumentArtifactRecord +): QuotationCustomerPackageSummary { + const metadata = + artifact.metadata && typeof artifact.metadata === 'object' && !Array.isArray(artifact.metadata) + ? (artifact.metadata as Record) + : null; + + const includedDocuments = Array.isArray(metadata?.sourceArtifacts) + ? metadata.sourceArtifacts + .map((item) => toCustomerPackageSource(item)) + .filter((item): item is QuotationCustomerPackageSource => item !== null) + : []; + const warnings = Array.isArray(metadata?.warnings) + ? metadata.warnings + .map((item) => toCustomerPackageWarning(item)) + .filter((item): item is QuotationCustomerPackageWarning => item !== null) + : []; + + return { + artifactId: artifact.id, + fileName: artifact.fileName, + contentType: artifact.contentType, + fileSize: artifact.fileSize, + checksum: artifact.checksum, + status: artifact.status, + pageCount: typeof metadata?.pageCount === 'number' ? metadata.pageCount : null, + generatedAt: artifact.generatedAt, + generatedBy: artifact.generatedBy, + generatedByName: artifact.generatedByName, + includedDocuments, + warnings + }; +} + function mapQuotationRecord( row: typeof crmQuotations.$inferSelect, options?: { approvedArtifact?: QuotationApprovedArtifactSummary | null; + customerPackage?: QuotationCustomerPackageSummary | null; branchName?: string | null; branchCode?: string | null; salesmanName?: string | null; @@ -188,6 +284,7 @@ function mapQuotationRecord( approvedSnapshot: row.approvedSnapshot ?? null, approvedTemplateVersionId: row.approvedTemplateVersionId, approvedArtifact: options?.approvedArtifact ?? null, + customerPackage: options?.customerPackage ?? null, hasLegacyApprovedPdf: !row.approvedArtifactId && typeof row.approvedPdfUrl === 'string' && @@ -1240,6 +1337,12 @@ export async function getQuotationDetail( }) ]); const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null; + const customerPackageArtifactPromise = getActiveArtifactForEntity({ + organizationId, + entityType: 'crm_quotation', + entityId: id, + artifactType: 'assembled_pdf' + }); const baseDisplayOptions = { branchName: branchDisplay?.name ?? null, branchCode: branchDisplay?.code ?? null, @@ -1256,7 +1359,13 @@ export async function getQuotationDetail( : null); if (!approvedArtifactId) { - return mapQuotationRecord(quotation, baseDisplayOptions); + const customerPackageArtifact = await customerPackageArtifactPromise; + return mapQuotationRecord(quotation, { + ...baseDisplayOptions, + customerPackage: customerPackageArtifact + ? mapCustomerPackageSummary(customerPackageArtifact) + : null + }); } const [artifact] = await db @@ -1272,10 +1381,19 @@ export async function getQuotationDetail( .limit(1); if (!artifact) { - return mapQuotationRecord(quotation); + const customerPackageArtifact = await customerPackageArtifactPromise; + return mapQuotationRecord(quotation, { + ...baseDisplayOptions, + customerPackage: customerPackageArtifact + ? mapCustomerPackageSummary(customerPackageArtifact) + : null + }); } - const artifactActorMap = await resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]); + const [artifactActorMap, customerPackageArtifact] = await Promise.all([ + resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]), + customerPackageArtifactPromise + ]); return mapQuotationRecord(quotation, { ...baseDisplayOptions, @@ -1284,7 +1402,10 @@ export async function getQuotationDetail( lockedByName: artifact.lockedBy ? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null) : null - }) + }), + customerPackage: customerPackageArtifact + ? mapCustomerPackageSummary(customerPackageArtifact) + : null }); } diff --git a/src/features/foundation/document-sequence/product-type-resolver.test.ts b/src/features/foundation/document-sequence/product-type-resolver.test.ts new file mode 100644 index 0000000..b0eddec --- /dev/null +++ b/src/features/foundation/document-sequence/product-type-resolver.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + resolveDocumentSequenceProductTypeInput, + type DocumentSequenceProductTypeLookupOption +} from './product-type-resolver'; + +const productTypeOptions: DocumentSequenceProductTypeLookupOption[] = [ + { id: 'product-crane', code: 'crane', label: 'Crane' }, + { id: 'product-dockdoor', code: 'dockdoor', label: 'Dock Door' }, + { id: 'product-solarcell', code: 'solarcell', label: 'Solar Cell' }, + { id: 'product-service', code: 'service', label: 'Service' }, + { id: 'product-sparepart', code: 'sparepart', label: 'Spare Part' } +]; + +const quotationTypeOptions: DocumentSequenceProductTypeLookupOption[] = [ + { id: 'quotation-crane', code: 'crane', label: 'Crane' }, + { id: 'quotation-dockdoor', code: 'dockdoor', label: 'Dock Door' }, + { id: 'quotation-solarcell', code: 'solarcell', label: 'Solar Cell' }, + { id: 'quotation-service', code: 'service', label: 'Service' } +]; + +test('resolveDocumentSequenceProductTypeInput resolves quotation type option ids', () => { + const resolution = resolveDocumentSequenceProductTypeInput({ + documentType: 'quotation', + productType: 'quotation-crane', + options: productTypeOptions, + referenceOptions: quotationTypeOptions + }); + + assert.deepEqual(resolution, { + productType: 'crane', + matchedBy: 'option-id', + normalizedRequestedProductType: 'quotation_crane' + }); +}); + +test('resolveDocumentSequenceProductTypeInput resolves quotation type codes directly', () => { + const resolution = resolveDocumentSequenceProductTypeInput({ + documentType: 'quotation', + productType: 'service', + options: productTypeOptions, + referenceOptions: quotationTypeOptions + }); + + assert.deepEqual(resolution, { + productType: 'service', + matchedBy: 'option-code', + normalizedRequestedProductType: 'service' + }); +}); + +test('resolveDocumentSequenceProductTypeInput resolves document-sequence aliases', () => { + const dockDoorResolution = resolveDocumentSequenceProductTypeInput({ + documentType: 'quotation', + productType: 'dock_door', + options: productTypeOptions, + referenceOptions: quotationTypeOptions + }); + const solarResolution = resolveDocumentSequenceProductTypeInput({ + documentType: 'quotation', + productType: 'solar', + options: productTypeOptions, + referenceOptions: quotationTypeOptions + }); + + assert.deepEqual(dockDoorResolution, { + productType: 'dockdoor', + matchedBy: 'sequence-alias', + normalizedRequestedProductType: 'dock_door' + }); + assert.deepEqual(solarResolution, { + productType: 'solarcell', + matchedBy: 'sequence-alias', + normalizedRequestedProductType: 'solar' + }); +}); + +test('resolveDocumentSequenceProductTypeInput returns generic sequence fallback for non-quotation documents', () => { + const resolution = resolveDocumentSequenceProductTypeInput({ + documentType: 'customer', + productType: undefined, + options: productTypeOptions + }); + + assert.deepEqual(resolution, { + productType: 'all', + matchedBy: 'generic', + normalizedRequestedProductType: null + }); +}); + +test('resolveDocumentSequenceProductTypeInput reports unresolved quotation inputs', () => { + const resolution = resolveDocumentSequenceProductTypeInput({ + documentType: 'quotation', + productType: 'other', + options: productTypeOptions, + referenceOptions: quotationTypeOptions + }); + + assert.deepEqual(resolution, { + productType: null, + matchedBy: 'generic', + normalizedRequestedProductType: 'other' + }); +}); diff --git a/src/features/foundation/document-sequence/product-type-resolver.ts b/src/features/foundation/document-sequence/product-type-resolver.ts new file mode 100644 index 0000000..dfbe740 --- /dev/null +++ b/src/features/foundation/document-sequence/product-type-resolver.ts @@ -0,0 +1,154 @@ +import { + normalizeDocumentSequenceProductType, + resolveDocumentSequenceProductTypeCode +} from './config'; + +export interface DocumentSequenceProductTypeLookupOption { + id: string; + code: string; + label?: string | null; +} + +export type DocumentSequenceProductTypeMatch = + | 'generic' + | 'option-id' + | 'option-code' + | 'sequence-alias'; + +export interface DocumentSequenceProductTypeResolution { + productType: string | null; + matchedBy: DocumentSequenceProductTypeMatch; + normalizedRequestedProductType: string | null; +} + +function resolveProductTypeOptionByCode( + options: ReadonlyArray, + requestedProductType: string +): + | { + productType: string; + matchedBy: Exclude; + } + | null { + const normalizedRequestedProductType = + normalizeDocumentSequenceProductType(requestedProductType); + + const byCode = options.find( + (option) => + normalizeDocumentSequenceProductType(option.code) === normalizedRequestedProductType + ); + if (byCode) { + return { + productType: normalizeDocumentSequenceProductType(byCode.code), + matchedBy: 'option-code' + }; + } + + const requestedSequenceCode = + resolveDocumentSequenceProductTypeCode(normalizedRequestedProductType); + if (!requestedSequenceCode) { + return null; + } + + const bySequenceAlias = options.find( + (option) => + resolveDocumentSequenceProductTypeCode(option.code) === requestedSequenceCode + ); + if (!bySequenceAlias) { + return null; + } + + return { + productType: normalizeDocumentSequenceProductType(bySequenceAlias.code), + matchedBy: 'sequence-alias' + }; +} + +export function resolveDocumentSequenceProductTypeInput(input: { + documentType: string; + productType?: string | null; + options: ReadonlyArray; + referenceOptions?: ReadonlyArray; +}): DocumentSequenceProductTypeResolution { + const normalizedDocumentType = input.documentType.trim(); + const requestedProductType = input.productType?.trim() ?? null; + + if (normalizedDocumentType !== 'quotation') { + return { + productType: requestedProductType + ? normalizeDocumentSequenceProductType(requestedProductType) + : 'all', + matchedBy: 'generic', + normalizedRequestedProductType: requestedProductType + ? normalizeDocumentSequenceProductType(requestedProductType) + : null + }; + } + + if (!requestedProductType) { + return { + productType: null, + matchedBy: 'generic', + normalizedRequestedProductType: null + }; + } + + const normalizedRequestedProductType = + normalizeDocumentSequenceProductType(requestedProductType); + + const byId = input.options.find((option) => option.id === requestedProductType); + if (byId) { + return { + productType: normalizeDocumentSequenceProductType(byId.code), + matchedBy: 'option-id', + normalizedRequestedProductType + }; + } + + const directCodeMatch = resolveProductTypeOptionByCode( + input.options, + requestedProductType + ); + if (directCodeMatch) { + return { + productType: directCodeMatch.productType, + matchedBy: directCodeMatch.matchedBy, + normalizedRequestedProductType + }; + } + + const referenceOptionById = input.referenceOptions?.find( + (option) => option.id === requestedProductType + ); + if (referenceOptionById) { + const translatedMatch = resolveProductTypeOptionByCode( + input.options, + referenceOptionById.code + ); + + return { + productType: translatedMatch?.productType ?? null, + matchedBy: 'option-id', + normalizedRequestedProductType + }; + } + + const translatedCode = input.referenceOptions?.find( + (option) => + normalizeDocumentSequenceProductType(option.code) === normalizedRequestedProductType + )?.code; + if (!translatedCode) { + return { + productType: null, + matchedBy: 'generic', + normalizedRequestedProductType + }; + } + + const translatedMatch = resolveProductTypeOptionByCode(input.options, translatedCode); + return { + productType: translatedMatch?.productType ?? null, + matchedBy: translatedMatch?.matchedBy ?? 'generic', + normalizedRequestedProductType + }; +} diff --git a/src/features/foundation/document-sequence/service.ts b/src/features/foundation/document-sequence/service.ts index 284f938..23c2370 100644 --- a/src/features/foundation/document-sequence/service.ts +++ b/src/features/foundation/document-sequence/service.ts @@ -1,38 +1,43 @@ -import { and, asc, eq, ne, sql } from 'drizzle-orm'; -import { documentSequences, organizations } from '@/db/schema'; -import { auditAction } from '@/features/foundation/audit-log/service'; -import { resolveBranchDisplays } from '@/features/foundation/display/server/display-resolver'; -import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; -import { getCurrentOrganization } from '@/features/foundation/organization-context/service'; -import { db } from '@/lib/db'; -import { AuthError } from '@/lib/auth/session'; +import { and, asc, eq, ne, sql } from "drizzle-orm"; +import { documentSequences, organizations } from "@/db/schema"; +import { auditAction } from "@/features/foundation/audit-log/service"; +import { resolveBranchDisplays } from "@/features/foundation/display/server/display-resolver"; +import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service"; +import { getCurrentOrganization } from "@/features/foundation/organization-context/service"; +import { db } from "@/lib/db"; +import { AuthError } from "@/lib/auth/session"; import { DOCUMENT_SEQUENCE_DEFAULT_FORMAT, DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY, DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE, normalizeDocumentSequenceProductType, resolveDocumentSequenceOrganizationCode, - resolveDocumentSequenceProductTypeCode -} from './config'; + resolveDocumentSequenceProductTypeCode, +} from "./config"; +import { + resolveDocumentSequenceProductTypeInput, + type DocumentSequenceProductTypeLookupOption, +} from "./product-type-resolver"; import type { DocumentSequenceInput, DocumentSequenceListItem, DocumentSequenceMutationPayload, DocumentSequenceRecord, DocumentSequenceResetPayload, - DocumentSequenceResult -} from './types'; + DocumentSequenceResult, +} from "./types"; -const PRODUCT_TYPE_CATEGORY = 'crm_product_type'; +const PRODUCT_TYPE_CATEGORY = "crm_product_type"; +const QUOTATION_TYPE_CATEGORY = "crm_quotation_type"; const DEFAULT_DOCUMENT_PREFIXES: Record = { - customer: 'CUS', - contact: 'CON', - opportunity: 'ENQ', - crm_lead: 'LD', - crm_opportunity: 'EN', - quotation: 'QT', - approval: 'APV' + customer: "CUS", + contact: "CON", + opportunity: "ENQ", + crm_lead: "LD", + crm_opportunity: "EN", + quotation: "QT", + approval: "APV", }; type OrganizationRecord = { @@ -43,19 +48,19 @@ type OrganizationRecord = { function getCurrentPeriod(date = new Date()) { const year = String(date.getFullYear()).slice(-2); - const month = String(date.getMonth() + 1).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); return `${year}${month}`; } function normalizeBranchId(branchId?: string | null) { - return branchId?.trim() || ''; + return branchId?.trim() || ""; } function normalizeDocumentType(documentType: string) { const normalized = documentType.trim(); if (!normalized) { - throw new AuthError('Document type is required.', 400); + throw new AuthError("Document type is required.", 400); } return normalized; @@ -65,11 +70,11 @@ function normalizePeriod(period?: string | null) { const normalized = period?.trim(); if (!normalized) { - throw new AuthError('Period is required.', 400); + throw new AuthError("Period is required.", 400); } if (!/^\d{4}$/.test(normalized)) { - throw new AuthError('Period must use YYMM format.', 400); + throw new AuthError("Period must use YYMM format.", 400); } return normalized; @@ -89,7 +94,7 @@ function formatBranchDisplayName(input: { branchName?: string | null; }) { if (!input.branchId) { - return 'All Branches'; + return "All Branches"; } if (input.branchCode && input.branchName) { @@ -108,28 +113,30 @@ function buildDocumentCode( prefix: string, period: string, nextNumber: number, - paddingLength: number + paddingLength: number, ) { - const running = String(nextNumber).padStart(paddingLength, '0'); + const running = String(nextNumber).padStart(paddingLength, "0"); return format - .replaceAll('{prefix}', prefix) - .replaceAll('{period}', period) - .replaceAll('{running}', running); + .replaceAll("{prefix}", prefix) + .replaceAll("{period}", period) + .replaceAll("{running}", running); } -async function resolveOrganizationRecord(organizationId?: string): Promise { +async function resolveOrganizationRecord( + organizationId?: string, +): Promise { if (!organizationId) { const organization = await getCurrentOrganization(); if (!organization) { - throw new AuthError('Active organization required.', 400); + throw new AuthError("Active organization required.", 400); } return { id: organization.id, name: organization.name, - slug: organization.slug + slug: organization.slug, }; } @@ -137,21 +144,21 @@ async function resolveOrganizationRecord(organizationId?: string): Promise [ @@ -159,45 +166,105 @@ async function resolveProductTypeMap(organizationId: string) { { id: option.id, code: normalizeDocumentSequenceProductType(option.code), - label: option.label - } - ]) + label: option.label, + }, + ]), ); } +async function resolveProductTypeMap(organizationId: string) { + return resolveOptionMap(PRODUCT_TYPE_CATEGORY, organizationId); +} + +async function resolveQuotationTypeMap(organizationId: string) { + return resolveOptionMap(QUOTATION_TYPE_CATEGORY, organizationId); +} + +function buildProductTypeResolutionDetails(input: { + organizationId: string; + documentType: string; + requestedProductType: string | null; + normalizedRequestedProductType: string | null; + productTypeOptions: ReadonlyArray; + referenceOptions: ReadonlyArray; +}) { + return { + organizationId: input.organizationId, + documentType: input.documentType, + requestedProductType: input.requestedProductType, + normalizedRequestedProductType: input.normalizedRequestedProductType, + productTypeOptionIds: input.productTypeOptions.map((option) => option.id), + productTypeOptionCodes: input.productTypeOptions.map((option) => option.code), + referenceOptionIds: input.referenceOptions.map((option) => option.id), + referenceOptionCodes: input.referenceOptions.map((option) => option.code), + }; +} + +function buildProductTypeResolutionError(input: { + organizationId: string; + documentType: string; + requestedProductType: string | null; + normalizedRequestedProductType: string | null; + productTypeOptions: ReadonlyArray; + referenceOptions: ReadonlyArray; +}) { + const error = new AuthError( + "Missing product type code configuration for quotation type.", + 400, + ) as AuthError & { + details: ReturnType; + }; + + error.details = buildProductTypeResolutionDetails(input); + return error; +} + async function resolveScopedProductType(input: { organizationId: string; documentType: string; productType?: string | null; }) { const normalizedDocumentType = normalizeDocumentType(input.documentType); - const requestedProductType = input.productType?.trim(); + const requestedProductType = input.productType?.trim() ?? null; - if (normalizedDocumentType !== 'quotation') { + if (normalizedDocumentType !== "quotation") { return requestedProductType ? normalizeDocumentSequenceProductType(requestedProductType) : DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE; } if (!requestedProductType) { - throw new AuthError('Product type is required for quotation document sequences.', 400); + throw new AuthError( + "Product type required for quotation document sequences.", + 400, + ); } - const productTypeMap = await resolveProductTypeMap(input.organizationId); - const normalizedRequestedProductType = - normalizeDocumentSequenceProductType(requestedProductType); + const productTypeOptions = [ + ...(await resolveProductTypeMap(input.organizationId)).values(), + ]; + const referenceOptions = [ + ...(await resolveQuotationTypeMap(input.organizationId)).values(), + ]; + const resolution = resolveDocumentSequenceProductTypeInput({ + documentType: normalizedDocumentType, + productType: requestedProductType, + options: productTypeOptions, + referenceOptions, + }); - const byCode = productTypeMap.get(normalizedRequestedProductType); - if (byCode) { - return byCode.code; + if (resolution.productType) { + return resolution.productType; } - const byId = [...productTypeMap.values()].find((option) => option.id === requestedProductType); - if (byId) { - return byId.code; - } - - throw new AuthError('Missing product type code configuration for this quotation type.', 400); + throw buildProductTypeResolutionError({ + organizationId: input.organizationId, + documentType: normalizedDocumentType, + requestedProductType, + normalizedRequestedProductType: resolution.normalizedRequestedProductType, + productTypeOptions, + referenceOptions, + }); } async function resolvePrefix(input: { @@ -211,21 +278,31 @@ async function resolvePrefix(input: { return manualPrefix; } - if (input.documentType !== 'quotation') { - return DEFAULT_DOCUMENT_PREFIXES[input.documentType] ?? input.documentType.slice(0, 3).toUpperCase(); - } - - const organizationCode = resolveDocumentSequenceOrganizationCode(input.organization.slug); - if (!organizationCode) { - throw new AuthError( - `Missing organization code configuration for ${input.organization.slug}.`, - 400 + if (input.documentType !== "quotation") { + return ( + DEFAULT_DOCUMENT_PREFIXES[input.documentType] ?? + input.documentType.slice(0, 3).toUpperCase() ); } - const productTypeCode = resolveDocumentSequenceProductTypeCode(input.productType); + const organizationCode = resolveDocumentSequenceOrganizationCode( + input.organization.slug, + ); + if (!organizationCode) { + throw new AuthError( + `Missing organization code configuration for ${input.organization.slug}.`, + 400, + ); + } + + const productTypeCode = resolveDocumentSequenceProductTypeCode( + input.productType, + ); if (!productTypeCode) { - throw new AuthError(`Missing product type code configuration for ${input.productType}.`, 400); + throw new AuthError( + `Missing product type code configuration for ${input.productType}.`, + 400, + ); } return `${productTypeCode}${organizationCode}`; @@ -233,11 +310,14 @@ async function resolvePrefix(input: { async function assertSequence(id: string, organizationId: string) { const row = await db.query.documentSequences.findFirst({ - where: and(eq(documentSequences.id, id), eq(documentSequences.organizationId, organizationId)) + where: and( + eq(documentSequences.id, id), + eq(documentSequences.organizationId, organizationId), + ), }); if (!row) { - throw new AuthError('Document sequence not found.', 404); + throw new AuthError("Document sequence not found.", 404); } return row; @@ -258,36 +338,42 @@ async function assertUniqueScope(input: { eq(documentSequences.productType, input.productType), eq(documentSequences.documentType, input.documentType), eq(documentSequences.period, input.period), - ...(input.currentId ? [ne(documentSequences.id, input.currentId)] : []) - ) + ...(input.currentId ? [ne(documentSequences.id, input.currentId)] : []), + ), }); if (existing) { throw new AuthError( - 'Duplicate document sequence scope for organization, branch, product type, document type, and period.', - 409 + "Duplicate document sequence scope for organization, branch, product type, document type, and period.", + 409, ); } } async function mapSequenceRecords( organization: OrganizationRecord, - rows: Array + rows: Array, ) { const [branchDisplayMap, productTypeMap] = await Promise.all([ resolveBranchDisplays({ organizationId: organization.id, - branchIds: rows.map((row) => row.branchId || null) + branchIds: rows.map((row) => row.branchId || null), }), - resolveProductTypeMap(organization.id) + resolveProductTypeMap(organization.id), ]); - const organizationCode = resolveDocumentSequenceOrganizationCode(organization.slug); + const organizationCode = resolveDocumentSequenceOrganizationCode( + organization.slug, + ); return rows.map((row) => { const branchId = row.branchId || null; - const branchDisplay = branchId ? (branchDisplayMap.get(branchId) ?? null) : null; - const normalizedProductType = normalizeDocumentSequenceProductType(row.productType); + const branchDisplay = branchId + ? (branchDisplayMap.get(branchId) ?? null) + : null; + const normalizedProductType = normalizeDocumentSequenceProductType( + row.productType, + ); const productType = productTypeMap.get(normalizedProductType) ?? null; return { @@ -301,17 +387,17 @@ async function mapSequenceRecords( branchDisplayName: formatBranchDisplayName({ branchId, branchCode: branchDisplay?.code ?? null, - branchName: branchDisplay?.name ?? null + branchName: branchDisplay?.name ?? null, }), productType: normalizedProductType, productTypeLabel: normalizedProductType === DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE - ? 'All Product Types' - : normalizedProductType === 'legacy' - ? 'Legacy' - : productType?.label ?? null, + ? "All Product Types" + : normalizedProductType === "legacy" + ? "Legacy" + : (productType?.label ?? null), productTypeSequenceCode: - normalizedProductType === 'legacy' + normalizedProductType === "legacy" ? null : resolveDocumentSequenceProductTypeCode(normalizedProductType), documentType: row.documentType, @@ -323,19 +409,21 @@ async function mapSequenceRecords( resetPolicy: row.resetPolicy, isActive: row.isActive, createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString() + updatedAt: row.updatedAt.toISOString(), } satisfies DocumentSequenceRecord; }); } -function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult { +function toSequenceResult( + row: typeof documentSequences.$inferSelect, +): DocumentSequenceResult { return { code: buildDocumentCode( row.format, row.prefix, row.period, row.currentNumber + 1, - row.paddingLength + row.paddingLength, ), documentType: row.documentType, productType: row.productType, @@ -343,12 +431,12 @@ function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentS currentNumber: row.currentNumber, nextNumber: row.currentNumber + 1, period: row.period, - prefix: row.prefix + prefix: row.prefix, }; } export async function listDocumentSequences( - organizationId: string + organizationId: string, ): Promise { const organization = await resolveOrganizationRecord(organizationId); const rows = await db @@ -359,7 +447,7 @@ export async function listDocumentSequences( asc(documentSequences.documentType), asc(documentSequences.productType), asc(documentSequences.period), - asc(documentSequences.branchId) + asc(documentSequences.branchId), ); const records = await mapSequenceRecords(organization, rows); @@ -371,14 +459,14 @@ export async function listDocumentSequences( record.prefix, record.period, record.currentNumber + 1, - record.paddingLength - ) + record.paddingLength, + ), })); } export async function getDocumentSequence( id: string, - organizationId: string + organizationId: string, ): Promise { const organization = await resolveOrganizationRecord(organizationId); const row = await assertSequence(id, organization.id); @@ -389,7 +477,7 @@ export async function getDocumentSequence( export async function createDocumentSequence( organizationId: string, - payload: DocumentSequenceMutationPayload + payload: DocumentSequenceMutationPayload, ): Promise { const organization = await resolveOrganizationRecord(organizationId); const documentType = normalizeDocumentType(payload.documentType); @@ -397,7 +485,7 @@ export async function createDocumentSequence( const productType = await resolveScopedProductType({ organizationId: organization.id, documentType, - productType: payload.productType + productType: payload.productType, }); const period = normalizePeriod(payload.period); @@ -406,7 +494,7 @@ export async function createDocumentSequence( branchId, productType, documentType, - period + period, }); const [created] = await db @@ -421,7 +509,7 @@ export async function createDocumentSequence( organization, documentType, productType, - prefix: payload.prefix + prefix: payload.prefix, }), period, currentNumber: payload.currentNumber ?? 0, @@ -429,7 +517,7 @@ export async function createDocumentSequence( format: normalizeFormat(payload.format), resetPolicy: normalizeResetPolicy(payload.resetPolicy), isActive: payload.isActive ?? true, - updatedAt: new Date() + updatedAt: new Date(), }) .returning(); @@ -440,17 +528,21 @@ export async function createDocumentSequence( export async function updateDocumentSequence( id: string, organizationId: string, - payload: Partial + payload: Partial, ): Promise { const organization = await resolveOrganizationRecord(organizationId); const current = await assertSequence(id, organization.id); - const documentType = normalizeDocumentType(payload.documentType ?? current.documentType); + const documentType = normalizeDocumentType( + payload.documentType ?? current.documentType, + ); const branchId = - payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId); + payload.branchId === undefined + ? current.branchId + : normalizeBranchId(payload.branchId); const productType = await resolveScopedProductType({ organizationId: organization.id, documentType, - productType: payload.productType ?? current.productType + productType: payload.productType ?? current.productType, }); const period = normalizePeriod(payload.period ?? current.period); @@ -460,7 +552,7 @@ export async function updateDocumentSequence( productType, documentType, period, - currentId: id + currentId: id, }); const [updated] = await db @@ -473,15 +565,17 @@ export async function updateDocumentSequence( organization, documentType, productType, - prefix: payload.prefix ?? current.prefix + prefix: payload.prefix ?? current.prefix, }), period, currentNumber: payload.currentNumber ?? current.currentNumber, paddingLength: payload.paddingLength ?? current.paddingLength, format: normalizeFormat(payload.format ?? current.format), - resetPolicy: normalizeResetPolicy(payload.resetPolicy ?? current.resetPolicy), + resetPolicy: normalizeResetPolicy( + payload.resetPolicy ?? current.resetPolicy, + ), isActive: payload.isActive ?? current.isActive, - updatedAt: new Date() + updatedAt: new Date(), }) .where(eq(documentSequences.id, id)) .returning(); @@ -493,7 +587,7 @@ export async function updateDocumentSequence( export async function resetDocumentSequence( id: string, organizationId: string, - payload: DocumentSequenceResetPayload + payload: DocumentSequenceResetPayload, ): Promise { const organization = await resolveOrganizationRecord(organizationId); await assertSequence(id, organization.id); @@ -502,7 +596,7 @@ export async function resetDocumentSequence( .update(documentSequences) .set({ currentNumber: payload.currentNumber, - updatedAt: new Date() + updatedAt: new Date(), }) .where(eq(documentSequences.id, id)) .returning(); @@ -511,7 +605,10 @@ export async function resetDocumentSequence( return record; } -export async function deleteDocumentSequence(id: string, organizationId: string) { +export async function deleteDocumentSequence( + id: string, + organizationId: string, +) { const organization = await resolveOrganizationRecord(organizationId); const sequence = await assertSequence(id, organization.id); await db.delete(documentSequences).where(eq(documentSequences.id, id)); @@ -520,7 +617,10 @@ export async function deleteDocumentSequence(id: string, organizationId: string) return record; } -export async function previewDocumentSequenceById(id: string, organizationId: string) { +export async function previewDocumentSequenceById( + id: string, + organizationId: string, +) { const row = await assertSequence(id, organizationId); return toSequenceResult(row); } @@ -532,7 +632,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) { const productType = await resolveScopedProductType({ organizationId: organization.id, documentType, - productType: input.productType + productType: input.productType, }); const period = normalizePeriod(input.period ?? getCurrentPeriod()); @@ -544,8 +644,8 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) { eq(documentSequences.branchId, branchId), eq(documentSequences.productType, productType), eq(documentSequences.documentType, documentType), - eq(documentSequences.period, period) - ) + eq(documentSequences.period, period), + ), }); if (!sequence) { @@ -560,14 +660,14 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) { prefix: await resolvePrefix({ organization, documentType, - productType + productType, }), period, currentNumber: 0, paddingLength: 3, format: DOCUMENT_SEQUENCE_DEFAULT_FORMAT, resetPolicy: DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY, - isActive: true + isActive: true, }) .onConflictDoNothing(); @@ -577,24 +677,24 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) { eq(documentSequences.branchId, branchId), eq(documentSequences.productType, productType), eq(documentSequences.documentType, documentType), - eq(documentSequences.period, period) - ) + eq(documentSequences.period, period), + ), }); } if (!sequence) { - throw new AuthError('Unable to initialize document sequence.', 500); + throw new AuthError("Unable to initialize document sequence.", 500); } if (!sequence.isActive) { - throw new AuthError('Document sequence is inactive.', 400); + throw new AuthError("Document sequence is inactive.", 400); } const [updated] = await tx .update(documentSequences) .set({ currentNumber: sql`${documentSequences.currentNumber} + 1`, - updatedAt: new Date() + updatedAt: new Date(), }) .where(eq(documentSequences.id, sequence.id)) .returning(); @@ -607,7 +707,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) { updated.prefix, updated.period, updated.currentNumber, - updated.paddingLength + updated.paddingLength, ), documentType: updated.documentType, productType: updated.productType, @@ -615,35 +715,42 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) { currentNumber: updated.currentNumber, nextNumber: updated.currentNumber + 1, period: updated.period, - prefix: updated.prefix - } satisfies DocumentSequenceResult + prefix: updated.prefix, + } satisfies DocumentSequenceResult, }; }); await auditAction({ organizationId: organization.id, - entityType: 'crm_document_sequence', + entityType: "crm_document_sequence", entityId: result.sequenceId, - action: 'generate', + action: "generate", branchId: branchId || null, - afterData: result.result + afterData: result.result, }); return result.result; } catch (error) { try { + const errorDetails = + error && typeof error === "object" && "details" in error + ? ((error as { details?: unknown }).details ?? null) + : null; + await auditAction({ organizationId: organization.id, - entityType: 'crm_document_sequence', + entityType: "crm_document_sequence", entityId: `${documentType}:${branchId}:${productType}:${period}`, - action: 'generate_failed', + action: "generate_failed", branchId: branchId || null, afterData: { - message: error instanceof Error ? error.message : 'Unknown error', + message: error instanceof Error ? error.message : "Unknown error", documentType, productType, - period - } + requestedProductType: input.productType?.trim() ?? null, + period, + details: errorDetails, + }, }); } catch { // Keep original error as the primary failure signal. diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 5cc6739..641eb1c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,32 +1,94 @@ -const BASE_URL = '/api'; +const BASE_URL = "/api"; -export async function apiClient(endpoint: string, options?: RequestInit): Promise { - const isServer = typeof window === 'undefined'; - const requestHeaders = isServer ? await (await import('next/headers')).headers() : null; - const host = requestHeaders?.get('x-forwarded-host') ?? requestHeaders?.get('host'); - const protocol = requestHeaders?.get('x-forwarded-proto') ?? 'http'; - const origin = isServer && host ? `${protocol}://${host}` : ''; +interface ApiErrorPayload { + message?: string; + error?: string; +} + +export class ApiClientError extends Error { + status: number; + statusText: string; + payload: unknown; + + constructor( + message: string, + input: { status: number; statusText: string; payload: unknown }, + ) { + super(message); + this.name = "ApiClientError"; + this.status = input.status; + this.statusText = input.statusText; + this.payload = input.payload; + } +} + +async function readErrorPayload(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("application/json")) { + return response.json().catch(() => null); + } + + return response.text().catch(() => null); +} + +function extractApiErrorMessage(payload: unknown, response: Response): string { + if (payload && typeof payload === "object") { + const candidate = payload as ApiErrorPayload; + if (typeof candidate.message === "string" && candidate.message.trim()) { + return candidate.message.trim(); + } + if (typeof candidate.error === "string" && candidate.error.trim()) { + return candidate.error.trim(); + } + } + + if (typeof payload === "string" && payload.trim()) { + return payload.trim(); + } + + return `API error: ${response.status} ${response.statusText}`; +} + +export async function apiClient( + endpoint: string, + options?: RequestInit, +): Promise { + const isServer = typeof window === "undefined"; + const requestHeaders = isServer + ? await (await import("next/headers")).headers() + : null; + const host = + requestHeaders?.get("x-forwarded-host") ?? requestHeaders?.get("host"); + const protocol = requestHeaders?.get("x-forwarded-proto") ?? "http"; + const origin = isServer && host ? `${protocol}://${host}` : ""; const optionHeaders = options?.headers; const restOptions = options ? { ...options } : null; - if (restOptions && 'headers' in restOptions) { + if (restOptions && "headers" in restOptions) { delete restOptions.headers; } - const res = await fetch(`${origin}${BASE_URL}${endpoint}`, { + const response = await fetch(`${origin}${BASE_URL}${endpoint}`, { ...restOptions, headers: { - 'Content-Type': 'application/json', - ...(isServer && requestHeaders?.get('cookie') - ? { cookie: requestHeaders.get('cookie') as string } + "Content-Type": "application/json", + ...(isServer && requestHeaders?.get("cookie") + ? { cookie: requestHeaders.get("cookie") as string } : {}), - ...optionHeaders - } + ...optionHeaders, + }, }); - if (!res.ok) { - throw new Error(`API error: ${res.status} ${res.statusText}`); + if (!response.ok) { + const payload = await readErrorPayload(response); + const message = extractApiErrorMessage(payload, response); + throw new ApiClientError(message, { + status: response.status, + statusText: response.statusText, + payload, + }); } - return res.json() as Promise; + return response.json() as Promise; }