task-p.7.1.1
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"audit": "payload-parity",
|
"audit": "payload-parity",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1107",
|
"quotationCode": "CRA2606-1121",
|
||||||
"previewHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6",
|
"previewHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e",
|
||||||
"generationHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6",
|
"generationHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e",
|
||||||
"snapshotHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6",
|
"snapshotHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e",
|
||||||
"mismatches": [],
|
"mismatches": [],
|
||||||
"comparableFieldCount": 31,
|
"comparableFieldCount": 25,
|
||||||
"topicInputKeyCount": 6,
|
"topicInputKeyCount": 0,
|
||||||
"signatureParity": true,
|
"signatureParity": true,
|
||||||
"priceTableParity": true,
|
"priceTableParity": true,
|
||||||
"priceTableRowCount": 1,
|
"priceTableRowCount": 1,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"audit": "approved-snapshot-parity",
|
"audit": "approved-snapshot-parity",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1107",
|
"quotationCode": "CRA2606-1121",
|
||||||
"approvedSnapshotAvailable": true,
|
"approvedSnapshotAvailable": true,
|
||||||
"approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
"approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
||||||
"computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
"computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
||||||
"inconsistentTemplateKeys": [],
|
"inconsistentTemplateKeys": [],
|
||||||
"computedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8",
|
"computedSignatureHash": "ef7a8fc3429f1c5b5a74ac0452d3de809558842f5431d0d5b02122980a286117",
|
||||||
"storedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8",
|
"storedSignatureHash": "ef7a8fc3429f1c5b5a74ac0452d3de809558842f5431d0d5b02122980a286117",
|
||||||
"approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581",
|
"approvedArtifactReference": null,
|
||||||
"refreshedFixtureSnapshot": true,
|
"refreshedFixtureSnapshot": true,
|
||||||
"usedAuditFixture": false
|
"usedAuditFixture": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-06-30T09:16:22.613Z",
|
"generatedAt": "2026-06-30T13:14:19.935Z",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "FAIL",
|
||||||
"quotationCode": "CRA2606-1107",
|
"quotationCode": "CRA2606-1121",
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"templateVersionReport": "template-version-report.json",
|
"templateVersionReport": "template-version-report.json",
|
||||||
"placeholderIntegrityReport": "placeholder-integrity-report.json",
|
"placeholderIntegrityReport": "placeholder-integrity-report.json",
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
"signatureAuditReport": "signature-audit-report.json"
|
"signatureAuditReport": "signature-audit-report.json"
|
||||||
},
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
"templateVersion": "PASS",
|
"templateVersion": "FAIL",
|
||||||
"placeholderIntegrity": "PASS",
|
"placeholderIntegrity": "PASS",
|
||||||
"runtimePayload": "PASS",
|
"runtimePayload": "PASS",
|
||||||
"payloadParity": "PASS",
|
"payloadParity": "PASS",
|
||||||
"topicRuntime": "PASS",
|
"topicRuntime": "FAIL",
|
||||||
"approvedSnapshotParity": "PASS",
|
"approvedSnapshotParity": "PASS",
|
||||||
"staticLabels": "PASS"
|
"staticLabels": "PASS"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# PDF Integrity Summary
|
# PDF Integrity Summary
|
||||||
|
|
||||||
- Generated At: 2026-06-30T09:16:22.613Z
|
- Generated At: 2026-06-30T13:14:19.935Z
|
||||||
- Overall Status: PASS
|
- Overall Status: FAIL
|
||||||
- Quotation Code: CRA2606-1107
|
- Quotation Code: CRA2606-1121
|
||||||
|
|
||||||
## Statuses
|
## Statuses
|
||||||
|
|
||||||
- Template Version Integrity: PASS
|
- Template Version Integrity: FAIL
|
||||||
- Placeholder Integrity: PASS
|
- Placeholder Integrity: PASS
|
||||||
- Runtime Payload: PASS
|
- Runtime Payload: PASS
|
||||||
- Payload Parity: PASS
|
- Payload Parity: PASS
|
||||||
- Topic Runtime: PASS
|
- Topic Runtime: FAIL
|
||||||
- Approved Snapshot Parity: PASS
|
- Approved Snapshot Parity: PASS
|
||||||
- Static Labels: PASS
|
- Static Labels: PASS
|
||||||
- Price Table Present: Yes
|
- Price Table Present: Yes
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
{
|
{
|
||||||
"audit": "template-version-integrity",
|
"audit": "template-version-integrity",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "FAIL",
|
||||||
"activeTemplateCount": 1,
|
"activeTemplateCount": 1,
|
||||||
"quotationCode": "CRA2606-1107",
|
"quotationCode": "CRA2606-1121",
|
||||||
"approvedTemplateVersionId": null,
|
"approvedTemplateVersionId": null,
|
||||||
"approvedTemplateVersionIsActive": false,
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
{
|
{
|
||||||
"audit": "topic-runtime",
|
"audit": "topic-runtime",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "FAIL",
|
||||||
"quotationCode": "CRA2606-1107",
|
"quotationCode": "CRA2606-1121",
|
||||||
"topicCount": 3,
|
"topicCount": 0,
|
||||||
"topicInputKeys": [
|
"topicInputKeys": [],
|
||||||
"topic_1_0",
|
"itemTopicInputKeys": [],
|
||||||
"topic_1_1",
|
|
||||||
"topic_1_2"
|
|
||||||
],
|
|
||||||
"itemTopicInputKeys": [
|
|
||||||
"item_topic_1_0",
|
|
||||||
"item_topic_1_1",
|
|
||||||
"item_topic_1_2"
|
|
||||||
],
|
|
||||||
"duplicateKeys": [],
|
"duplicateKeys": [],
|
||||||
"emptySections": []
|
"emptySections": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-06-30T09:16:20.352Z",
|
"generatedAt": "2026-06-30T13:14:09.584Z",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1107",
|
"quotationCode": "CRA2606-1121",
|
||||||
"expectedFixtureCode": "QT-H5-AUDIT",
|
"expectedFixtureCode": "QT-H5-AUDIT",
|
||||||
"template": {
|
"template": {
|
||||||
"templateName": "ALLA Demo Organization Quotation Standard",
|
"templateName": "ALLA Demo Organization Quotation Standard",
|
||||||
@@ -238,11 +238,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"topicCount": 3,
|
"topicCount": 0,
|
||||||
"itemCount": 4,
|
"itemCount": 0,
|
||||||
"partyCount": 1,
|
"partyCount": 2,
|
||||||
"dynamicTopicFieldCount": 3,
|
"dynamicTopicFieldCount": 0,
|
||||||
"dynamicTopicItemFieldCount": 3,
|
"dynamicTopicItemFieldCount": 0,
|
||||||
"mappingCount": 23
|
"mappingCount": 23
|
||||||
},
|
},
|
||||||
"mappingCoverage": {
|
"mappingCoverage": {
|
||||||
@@ -256,14 +256,14 @@
|
|||||||
"itemTableColumns": []
|
"itemTableColumns": []
|
||||||
},
|
},
|
||||||
"runtimePayload": {
|
"runtimePayload": {
|
||||||
"templateInputKeys": 31,
|
"templateInputKeys": 25,
|
||||||
"topicInputKeys": 6,
|
"topicInputKeys": 0,
|
||||||
"priceTableRows": 1
|
"priceTableRows": 1
|
||||||
},
|
},
|
||||||
"consistency": {
|
"consistency": {
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"approvedSnapshotAvailable": true,
|
"approvedSnapshotAvailable": true,
|
||||||
"approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581",
|
"approvedArtifactReference": null,
|
||||||
"inconsistentKeys": []
|
"inconsistentKeys": []
|
||||||
},
|
},
|
||||||
"integritySummary": {
|
"integritySummary": {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# PDF Audit Report
|
# PDF Audit Report
|
||||||
|
|
||||||
- Generated At: 2026-06-30T09:16:20.352Z
|
- Generated At: 2026-06-30T13:14:09.584Z
|
||||||
- Overall Status: PASS
|
- Overall Status: PASS
|
||||||
- Quotation Code: CRA2606-1107
|
- Quotation Code: CRA2606-1121
|
||||||
- Template: ALLA Demo Organization Quotation Standard v1.0
|
- Template: ALLA Demo Organization Quotation Standard v1.0
|
||||||
- Previous Version: None
|
- Previous Version: None
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
- Topic Count: 3
|
- Topic Count: 0
|
||||||
- Item Count: 4
|
- Item Count: 0
|
||||||
- Project Party Count: 1
|
- Project Party Count: 2
|
||||||
- Mapping Count: 23
|
- Mapping Count: 23
|
||||||
|
|
||||||
## Mapping Coverage
|
## Mapping Coverage
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
379
plans/task-d.4.6.md
Normal file
379
plans/task-d.4.6.md
Normal file
@@ -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`
|
||||||
421
plans/task-d.4.7.md
Normal file
421
plans/task-d.4.7.md
Normal file
@@ -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.
|
||||||
366
plans/task-p.7.1.1.md
Normal file
366
plans/task-p.7.1.1.md
Normal file
@@ -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)
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export { POST } from './generate/route';
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
|||||||
@@ -96,11 +96,14 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfPreview)));
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfPreview)));
|
||||||
|
const canPreviewCustomerPackage = canPreviewPdf;
|
||||||
const canDownloadPdf =
|
const canDownloadPdf =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfDownload)));
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfDownload)));
|
||||||
|
const canGenerateCustomerPackage = canDownloadPdf;
|
||||||
|
const canDownloadCustomerPackage = canDownloadPdf;
|
||||||
const canGenerateApprovedPdf =
|
const canGenerateApprovedPdf =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
@@ -165,7 +168,10 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
currentUserId={session?.user?.id ?? ''}
|
currentUserId={session?.user?.id ?? ''}
|
||||||
canPreviewDocument={canPreviewDocument}
|
canPreviewDocument={canPreviewDocument}
|
||||||
canPreviewPdf={canPreviewPdf}
|
canPreviewPdf={canPreviewPdf}
|
||||||
|
canPreviewCustomerPackage={canPreviewCustomerPackage}
|
||||||
canDownloadPdf={canDownloadPdf}
|
canDownloadPdf={canDownloadPdf}
|
||||||
|
canGenerateCustomerPackage={canGenerateCustomerPackage}
|
||||||
|
canDownloadCustomerPackage={canDownloadCustomerPackage}
|
||||||
canGenerateApprovedPdf={canGenerateApprovedPdf}
|
canGenerateApprovedPdf={canGenerateApprovedPdf}
|
||||||
/>
|
/>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createQuotation,
|
createQuotation,
|
||||||
createQuotationAttachment,
|
createQuotationAttachment,
|
||||||
createQuotationCustomer,
|
createQuotationCustomer,
|
||||||
|
generateQuotationCustomerPackage,
|
||||||
createQuotationFollowup,
|
createQuotationFollowup,
|
||||||
createQuotationItem,
|
createQuotationItem,
|
||||||
createQuotationRevision,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
MutationSuccessResponse,
|
MutationSuccessResponse,
|
||||||
QuotationAttachmentMutationPayload,
|
QuotationAttachmentMutationPayload,
|
||||||
QuotationAttachmentsResponse,
|
QuotationAttachmentsResponse,
|
||||||
|
QuotationCustomerPackageGenerateResponse,
|
||||||
QuotationCustomerMutationPayload,
|
QuotationCustomerMutationPayload,
|
||||||
QuotationCustomersResponse,
|
QuotationCustomersResponse,
|
||||||
QuotationDetailResponse,
|
QuotationDetailResponse,
|
||||||
@@ -41,6 +42,37 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
|
|||||||
return apiClient<QuotationDetailResponse>(`/crm/quotations/${id}`);
|
return apiClient<QuotationDetailResponse>(`/crm/quotations/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateQuotationCustomerPackage(
|
||||||
|
quotationId: string
|
||||||
|
): Promise<QuotationCustomerPackageGenerateResponse> {
|
||||||
|
return apiClient<QuotationCustomerPackageGenerateResponse>(
|
||||||
|
`/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) {
|
export async function createQuotation(data: QuotationMutationPayload) {
|
||||||
return apiClient<MutationSuccessResponse>('/crm/quotations', {
|
return apiClient<MutationSuccessResponse>('/crm/quotations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -212,4 +244,3 @@ export async function createQuotationRevision(id: string, revisionRemark?: strin
|
|||||||
body: JSON.stringify({ revisionRemark })
|
body: JSON.stringify({ revisionRemark })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,37 @@ export interface QuotationApprovedArtifactSummary {
|
|||||||
lockedByName: string | null;
|
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<string, unknown> | 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 {
|
export interface QuotationRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -103,6 +134,7 @@ export interface QuotationRecord {
|
|||||||
approvedSnapshot: unknown | null;
|
approvedSnapshot: unknown | null;
|
||||||
approvedTemplateVersionId: string | null;
|
approvedTemplateVersionId: string | null;
|
||||||
approvedArtifact: QuotationApprovedArtifactSummary | null;
|
approvedArtifact: QuotationApprovedArtifactSummary | null;
|
||||||
|
customerPackage: QuotationCustomerPackageSummary | null;
|
||||||
hasLegacyApprovedPdf: boolean;
|
hasLegacyApprovedPdf: boolean;
|
||||||
acceptedAt: string | null;
|
acceptedAt: string | null;
|
||||||
rejectedAt: string | null;
|
rejectedAt: string | null;
|
||||||
@@ -432,3 +464,20 @@ export interface MutationSuccessResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
createQuotationAttachmentMutation,
|
createQuotationAttachmentMutation,
|
||||||
createQuotationCustomerMutation,
|
createQuotationCustomerMutation,
|
||||||
createQuotationFollowupMutation,
|
createQuotationFollowupMutation,
|
||||||
|
generateQuotationCustomerPackageMutation,
|
||||||
createQuotationItemMutation,
|
createQuotationItemMutation,
|
||||||
createQuotationRevisionMutation,
|
createQuotationRevisionMutation,
|
||||||
createQuotationTopicMutation,
|
createQuotationTopicMutation,
|
||||||
@@ -64,10 +65,12 @@ import {
|
|||||||
quotationRevisionsOptions,
|
quotationRevisionsOptions,
|
||||||
quotationTopicsOptions
|
quotationTopicsOptions
|
||||||
} from '../api/queries';
|
} from '../api/queries';
|
||||||
|
import { downloadQuotationCustomerPackage } from '../api/service';
|
||||||
import type {
|
import type {
|
||||||
QuotationAttachmentMutationPayload,
|
QuotationAttachmentMutationPayload,
|
||||||
QuotationAttachmentRecord,
|
QuotationAttachmentRecord,
|
||||||
QuotationCustomerListItem,
|
QuotationCustomerListItem,
|
||||||
|
QuotationCustomerPackageSource,
|
||||||
QuotationCustomerMutationPayload,
|
QuotationCustomerMutationPayload,
|
||||||
QuotationFollowupMutationPayload,
|
QuotationFollowupMutationPayload,
|
||||||
QuotationFollowupRecord,
|
QuotationFollowupRecord,
|
||||||
@@ -79,6 +82,11 @@ import type {
|
|||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
||||||
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
||||||
|
import {
|
||||||
|
getCustomerPackageRoleLabel,
|
||||||
|
getCustomerPackageUiStatus,
|
||||||
|
mapCustomerPackageErrorMessage
|
||||||
|
} from '../customer-package';
|
||||||
import { QuotationDocumentPreview } from './quotation-document-preview';
|
import { QuotationDocumentPreview } from './quotation-document-preview';
|
||||||
import { QuotationFormSheet } from './quotation-form-sheet';
|
import { QuotationFormSheet } from './quotation-form-sheet';
|
||||||
import { QuotationApprovalTab } from './quotation-approval-tab';
|
import { QuotationApprovalTab } from './quotation-approval-tab';
|
||||||
@@ -102,6 +110,27 @@ function EmptyState({ message }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CustomerPackageSourceItem({
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
item: QuotationCustomerPackageSource;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className='rounded-lg border p-3'>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<span className='font-medium'>{getCustomerPackageRoleLabel(item.role)}</span>
|
||||||
|
<Badge variant='outline'>{item.sourceType === 'generated' ? 'Generated' : 'Library'}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground mt-2 text-sm'>{item.fileName}</div>
|
||||||
|
<div className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{item.pageCount ? `${formatNumber(item.pageCount)} page(s)` : 'Page count unavailable'}
|
||||||
|
{item.fileSize ? ` • ${formatNumber(item.fileSize)} bytes` : ''}
|
||||||
|
{item.checksum ? ` • ${item.checksum.slice(0, 16)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ItemDialog({
|
function ItemDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -673,7 +702,10 @@ export function QuotationDetail({
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
canPreviewDocument,
|
canPreviewDocument,
|
||||||
canPreviewPdf,
|
canPreviewPdf,
|
||||||
|
canPreviewCustomerPackage,
|
||||||
canDownloadPdf,
|
canDownloadPdf,
|
||||||
|
canGenerateCustomerPackage,
|
||||||
|
canDownloadCustomerPackage,
|
||||||
canGenerateApprovedPdf
|
canGenerateApprovedPdf
|
||||||
}: {
|
}: {
|
||||||
quotationId: string;
|
quotationId: string;
|
||||||
@@ -694,7 +726,10 @@ export function QuotationDetail({
|
|||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
canPreviewDocument: boolean;
|
canPreviewDocument: boolean;
|
||||||
canPreviewPdf: boolean;
|
canPreviewPdf: boolean;
|
||||||
|
canPreviewCustomerPackage: boolean;
|
||||||
canDownloadPdf: boolean;
|
canDownloadPdf: boolean;
|
||||||
|
canGenerateCustomerPackage: boolean;
|
||||||
|
canDownloadCustomerPackage: boolean;
|
||||||
canGenerateApprovedPdf: boolean;
|
canGenerateApprovedPdf: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||||
@@ -705,6 +740,12 @@ export function QuotationDetail({
|
|||||||
const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId));
|
const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId));
|
||||||
const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId));
|
const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId));
|
||||||
const quotation = data.quotation;
|
const quotation = data.quotation;
|
||||||
|
const [customerPackageError, setCustomerPackageError] = useState<string | null>(null);
|
||||||
|
const [confirmRegenerateOpen, setConfirmRegenerateOpen] = useState(false);
|
||||||
|
const customerPackageStatus = getCustomerPackageUiStatus({
|
||||||
|
customerPackage: quotation.customerPackage,
|
||||||
|
lastErrorMessage: customerPackageError
|
||||||
|
});
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
const [itemOpen, setItemOpen] = useState(false);
|
const [itemOpen, setItemOpen] = useState(false);
|
||||||
@@ -844,6 +885,44 @@ export function QuotationDetail({
|
|||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
|
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({
|
const generateApprovedPdf = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
|
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])),
|
() => new Map(referenceData.topicTypes.map((item) => [item.id, item])),
|
||||||
[referenceData.topicTypes]
|
[referenceData.topicTypes]
|
||||||
);
|
);
|
||||||
const followupTypeMap = useMemo(
|
const followupTypeMap = useMemo(
|
||||||
() => new Map(referenceData.followupTypes.map((item) => [item.id, item])),
|
() => new Map(referenceData.followupTypes.map((item) => [item.id, item])),
|
||||||
[referenceData.followupTypes]
|
[referenceData.followupTypes]
|
||||||
);
|
);
|
||||||
const customer = customerMap.get(quotation.customerId);
|
const customer = customerMap.get(quotation.customerId);
|
||||||
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
||||||
const status = statusMap.get(quotation.status);
|
const status = statusMap.get(quotation.status);
|
||||||
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
const customerPackage = quotation.customerPackage;
|
||||||
status?.code ?? ''
|
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 =
|
const canSubmitCurrentQuotation =
|
||||||
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
||||||
const canGenerateApprovedNow = canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
|
const canGenerateApprovedNow = canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
|
||||||
@@ -1075,6 +1168,31 @@ export function QuotationDetail({
|
|||||||
onOpenChange={setEditOpen}
|
onOpenChange={setEditOpen}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
/>
|
/>
|
||||||
|
<Dialog open={confirmRegenerateOpen} onOpenChange={setConfirmRegenerateOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Regenerate Customer Package?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
A new assembled package artifact will replace the current active package for this quotation.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => setConfirmRegenerateOpen(false)}
|
||||||
|
disabled={generateCustomerPackage.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={generateCustomerPackage.isPending}
|
||||||
|
onClick={() => generateCustomerPackage.mutate({ quotationId })}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
|
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
@@ -1162,6 +1280,131 @@ export function QuotationDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Customer Package</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate and download the assembled customer-facing quotation package.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{canGenerateCustomerPackage ? (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
isLoading={generateCustomerPackage.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
customerPackage
|
||||||
|
? setConfirmRegenerateOpen(true)
|
||||||
|
: generateCustomerPackage.mutate({ quotationId })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.badgeCheck className='mr-2 h-4 w-4' />
|
||||||
|
{customerPackageActionLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canPreviewCustomerPackage ? (
|
||||||
|
<Button asChild variant='outline' disabled={!customerPackage}>
|
||||||
|
<Link
|
||||||
|
href={`/api/crm/quotations/${quotationId}/assembled-pdf`}
|
||||||
|
target='_blank'
|
||||||
|
aria-disabled={!customerPackage}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!customerPackage) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.eyeOff className='mr-2 h-4 w-4' /> View Customer Package
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canDownloadCustomerPackage ? (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
disabled={!customerPackage}
|
||||||
|
isLoading={downloadCustomerPackage.isPending}
|
||||||
|
onClick={() => downloadCustomerPackage.mutate()}
|
||||||
|
>
|
||||||
|
<Icons.download className='mr-2 h-4 w-4' /> Download Customer Package
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
{!canGenerateCustomerPackage && !canDownloadCustomerPackage && !canPreviewCustomerPackage ? (
|
||||||
|
<EmptyState message='You do not have permission to generate or download customer package.' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
|
<FieldItem label='Status' value={customerPackageStatusLabel} />
|
||||||
|
<FieldItem
|
||||||
|
label='Generated At'
|
||||||
|
value={customerPackage?.generatedAt ? formatDateTime(customerPackage.generatedAt) : null}
|
||||||
|
/>
|
||||||
|
<FieldItem
|
||||||
|
label='Generated By'
|
||||||
|
value={customerPackage?.generatedByName ?? customerPackage?.generatedBy ?? null}
|
||||||
|
/>
|
||||||
|
<FieldItem
|
||||||
|
label='Page Count'
|
||||||
|
value={
|
||||||
|
customerPackage?.pageCount !== null && customerPackage?.pageCount !== undefined
|
||||||
|
? String(customerPackage.pageCount)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldItem
|
||||||
|
label='File Size'
|
||||||
|
value={customerPackage?.fileSize ? `${formatNumber(customerPackage.fileSize)} bytes` : null}
|
||||||
|
/>
|
||||||
|
<FieldItem
|
||||||
|
label='Checksum'
|
||||||
|
value={customerPackage?.checksum ? customerPackage.checksum.slice(0, 16) : null}
|
||||||
|
/>
|
||||||
|
<FieldItem label='File Name' value={customerPackage?.fileName ?? null} />
|
||||||
|
<FieldItem
|
||||||
|
label='Included Documents'
|
||||||
|
value={
|
||||||
|
customerPackage?.includedDocuments.length
|
||||||
|
? String(customerPackage.includedDocuments.length)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg border border-dashed p-4 text-sm'>
|
||||||
|
{customerPackageStatusDescription}
|
||||||
|
</div>
|
||||||
|
{customerPackage?.warnings.length ? (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Warnings</div>
|
||||||
|
{customerPackage.warnings.map((warning) => (
|
||||||
|
<div key={`${warning.code}-${warning.role}`} className='rounded-lg border p-3 text-sm'>
|
||||||
|
{warning.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='text-sm font-medium'>Included Documents</div>
|
||||||
|
{!customerPackage?.includedDocuments.length ? (
|
||||||
|
<EmptyState message='No assembled package has been generated yet.' />
|
||||||
|
) : (
|
||||||
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
|
{customerPackage.includedDocuments.map((item) => (
|
||||||
|
<CustomerPackageSourceItem
|
||||||
|
key={`${item.role}-${item.fileName}-${item.checksum ?? 'no-checksum'}`}
|
||||||
|
item={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className='grid gap-6 lg:grid-cols-3'>
|
<div className='grid gap-6 lg:grid-cols-3'>
|
||||||
<div className='space-y-6 lg:col-span-2'>
|
<div className='space-y-6 lg:col-span-2'>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
72
src/features/crm/quotations/customer-package.test.ts
Normal file
72
src/features/crm/quotations/customer-package.test.ts
Normal file
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
65
src/features/crm/quotations/customer-package.ts
Normal file
65
src/features/crm/quotations/customer-package.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { QuotationCustomerPackageSummary } from './api/types';
|
||||||
|
|
||||||
|
const CUSTOMER_PACKAGE_ROLE_LABELS: Record<string, string> = {
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ import { AuthError } from '@/lib/auth/session';
|
|||||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||||
|
import {
|
||||||
|
getActiveArtifactForEntity,
|
||||||
|
type DocumentArtifactRecord
|
||||||
|
} from '@/features/foundation/document-artifact/server/service';
|
||||||
import {
|
import {
|
||||||
type CrmSecurityContext,
|
type CrmSecurityContext,
|
||||||
canAccessScopedCrmRecord
|
canAccessScopedCrmRecord
|
||||||
@@ -47,6 +51,9 @@ import type {
|
|||||||
QuotationApprovedArtifactSummary,
|
QuotationApprovedArtifactSummary,
|
||||||
QuotationBranchOption,
|
QuotationBranchOption,
|
||||||
QuotationContactLookup,
|
QuotationContactLookup,
|
||||||
|
QuotationCustomerPackageSource,
|
||||||
|
QuotationCustomerPackageSummary,
|
||||||
|
QuotationCustomerPackageWarning,
|
||||||
QuotationCustomerListItem,
|
QuotationCustomerListItem,
|
||||||
QuotationCustomerLookup,
|
QuotationCustomerLookup,
|
||||||
QuotationCustomerMutationPayload,
|
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<string, unknown>;
|
||||||
|
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<string, unknown>)
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCustomerPackageWarning(
|
||||||
|
value: unknown
|
||||||
|
): QuotationCustomerPackageWarning | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warning = value as Record<string, unknown>;
|
||||||
|
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<string, unknown>)
|
||||||
|
: 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(
|
function mapQuotationRecord(
|
||||||
row: typeof crmQuotations.$inferSelect,
|
row: typeof crmQuotations.$inferSelect,
|
||||||
options?: {
|
options?: {
|
||||||
approvedArtifact?: QuotationApprovedArtifactSummary | null;
|
approvedArtifact?: QuotationApprovedArtifactSummary | null;
|
||||||
|
customerPackage?: QuotationCustomerPackageSummary | null;
|
||||||
branchName?: string | null;
|
branchName?: string | null;
|
||||||
branchCode?: string | null;
|
branchCode?: string | null;
|
||||||
salesmanName?: string | null;
|
salesmanName?: string | null;
|
||||||
@@ -188,6 +284,7 @@ function mapQuotationRecord(
|
|||||||
approvedSnapshot: row.approvedSnapshot ?? null,
|
approvedSnapshot: row.approvedSnapshot ?? null,
|
||||||
approvedTemplateVersionId: row.approvedTemplateVersionId,
|
approvedTemplateVersionId: row.approvedTemplateVersionId,
|
||||||
approvedArtifact: options?.approvedArtifact ?? null,
|
approvedArtifact: options?.approvedArtifact ?? null,
|
||||||
|
customerPackage: options?.customerPackage ?? null,
|
||||||
hasLegacyApprovedPdf:
|
hasLegacyApprovedPdf:
|
||||||
!row.approvedArtifactId &&
|
!row.approvedArtifactId &&
|
||||||
typeof row.approvedPdfUrl === 'string' &&
|
typeof row.approvedPdfUrl === 'string' &&
|
||||||
@@ -1240,6 +1337,12 @@ export async function getQuotationDetail(
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null;
|
const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null;
|
||||||
|
const customerPackageArtifactPromise = getActiveArtifactForEntity({
|
||||||
|
organizationId,
|
||||||
|
entityType: 'crm_quotation',
|
||||||
|
entityId: id,
|
||||||
|
artifactType: 'assembled_pdf'
|
||||||
|
});
|
||||||
const baseDisplayOptions = {
|
const baseDisplayOptions = {
|
||||||
branchName: branchDisplay?.name ?? null,
|
branchName: branchDisplay?.name ?? null,
|
||||||
branchCode: branchDisplay?.code ?? null,
|
branchCode: branchDisplay?.code ?? null,
|
||||||
@@ -1256,7 +1359,13 @@ export async function getQuotationDetail(
|
|||||||
: null);
|
: null);
|
||||||
|
|
||||||
if (!approvedArtifactId) {
|
if (!approvedArtifactId) {
|
||||||
return mapQuotationRecord(quotation, baseDisplayOptions);
|
const customerPackageArtifact = await customerPackageArtifactPromise;
|
||||||
|
return mapQuotationRecord(quotation, {
|
||||||
|
...baseDisplayOptions,
|
||||||
|
customerPackage: customerPackageArtifact
|
||||||
|
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||||
|
: null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [artifact] = await db
|
const [artifact] = await db
|
||||||
@@ -1272,10 +1381,19 @@ export async function getQuotationDetail(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!artifact) {
|
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, {
|
return mapQuotationRecord(quotation, {
|
||||||
...baseDisplayOptions,
|
...baseDisplayOptions,
|
||||||
@@ -1284,7 +1402,10 @@ export async function getQuotationDetail(
|
|||||||
lockedByName: artifact.lockedBy
|
lockedByName: artifact.lockedBy
|
||||||
? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null)
|
? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null)
|
||||||
: null
|
: null
|
||||||
})
|
}),
|
||||||
|
customerPackage: customerPackageArtifact
|
||||||
|
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||||
|
: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DocumentSequenceProductTypeLookupOption>,
|
||||||
|
requestedProductType: string
|
||||||
|
):
|
||||||
|
| {
|
||||||
|
productType: string;
|
||||||
|
matchedBy: Exclude<DocumentSequenceProductTypeMatch, 'generic' | 'option-id'>;
|
||||||
|
}
|
||||||
|
| 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<DocumentSequenceProductTypeLookupOption>;
|
||||||
|
referenceOptions?: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
|
||||||
|
}): 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,38 +1,43 @@
|
|||||||
import { and, asc, eq, ne, sql } from 'drizzle-orm';
|
import { and, asc, eq, ne, sql } from "drizzle-orm";
|
||||||
import { documentSequences, organizations } from '@/db/schema';
|
import { documentSequences, organizations } from "@/db/schema";
|
||||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
import { auditAction } from "@/features/foundation/audit-log/service";
|
||||||
import { resolveBranchDisplays } from '@/features/foundation/display/server/display-resolver';
|
import { resolveBranchDisplays } from "@/features/foundation/display/server/display-resolver";
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service";
|
||||||
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
|
import { getCurrentOrganization } from "@/features/foundation/organization-context/service";
|
||||||
import { db } from '@/lib/db';
|
import { db } from "@/lib/db";
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from "@/lib/auth/session";
|
||||||
import {
|
import {
|
||||||
DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
||||||
DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
||||||
DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE,
|
DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE,
|
||||||
normalizeDocumentSequenceProductType,
|
normalizeDocumentSequenceProductType,
|
||||||
resolveDocumentSequenceOrganizationCode,
|
resolveDocumentSequenceOrganizationCode,
|
||||||
resolveDocumentSequenceProductTypeCode
|
resolveDocumentSequenceProductTypeCode,
|
||||||
} from './config';
|
} from "./config";
|
||||||
|
import {
|
||||||
|
resolveDocumentSequenceProductTypeInput,
|
||||||
|
type DocumentSequenceProductTypeLookupOption,
|
||||||
|
} from "./product-type-resolver";
|
||||||
import type {
|
import type {
|
||||||
DocumentSequenceInput,
|
DocumentSequenceInput,
|
||||||
DocumentSequenceListItem,
|
DocumentSequenceListItem,
|
||||||
DocumentSequenceMutationPayload,
|
DocumentSequenceMutationPayload,
|
||||||
DocumentSequenceRecord,
|
DocumentSequenceRecord,
|
||||||
DocumentSequenceResetPayload,
|
DocumentSequenceResetPayload,
|
||||||
DocumentSequenceResult
|
DocumentSequenceResult,
|
||||||
} from './types';
|
} 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<string, string> = {
|
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
|
||||||
customer: 'CUS',
|
customer: "CUS",
|
||||||
contact: 'CON',
|
contact: "CON",
|
||||||
opportunity: 'ENQ',
|
opportunity: "ENQ",
|
||||||
crm_lead: 'LD',
|
crm_lead: "LD",
|
||||||
crm_opportunity: 'EN',
|
crm_opportunity: "EN",
|
||||||
quotation: 'QT',
|
quotation: "QT",
|
||||||
approval: 'APV'
|
approval: "APV",
|
||||||
};
|
};
|
||||||
|
|
||||||
type OrganizationRecord = {
|
type OrganizationRecord = {
|
||||||
@@ -43,19 +48,19 @@ type OrganizationRecord = {
|
|||||||
|
|
||||||
function getCurrentPeriod(date = new Date()) {
|
function getCurrentPeriod(date = new Date()) {
|
||||||
const year = String(date.getFullYear()).slice(-2);
|
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}`;
|
return `${year}${month}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBranchId(branchId?: string | null) {
|
function normalizeBranchId(branchId?: string | null) {
|
||||||
return branchId?.trim() || '';
|
return branchId?.trim() || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDocumentType(documentType: string) {
|
function normalizeDocumentType(documentType: string) {
|
||||||
const normalized = documentType.trim();
|
const normalized = documentType.trim();
|
||||||
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new AuthError('Document type is required.', 400);
|
throw new AuthError("Document type is required.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -65,11 +70,11 @@ function normalizePeriod(period?: string | null) {
|
|||||||
const normalized = period?.trim();
|
const normalized = period?.trim();
|
||||||
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new AuthError('Period is required.', 400);
|
throw new AuthError("Period is required.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^\d{4}$/.test(normalized)) {
|
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;
|
return normalized;
|
||||||
@@ -89,7 +94,7 @@ function formatBranchDisplayName(input: {
|
|||||||
branchName?: string | null;
|
branchName?: string | null;
|
||||||
}) {
|
}) {
|
||||||
if (!input.branchId) {
|
if (!input.branchId) {
|
||||||
return 'All Branches';
|
return "All Branches";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.branchCode && input.branchName) {
|
if (input.branchCode && input.branchName) {
|
||||||
@@ -108,28 +113,30 @@ function buildDocumentCode(
|
|||||||
prefix: string,
|
prefix: string,
|
||||||
period: string,
|
period: string,
|
||||||
nextNumber: number,
|
nextNumber: number,
|
||||||
paddingLength: number
|
paddingLength: number,
|
||||||
) {
|
) {
|
||||||
const running = String(nextNumber).padStart(paddingLength, '0');
|
const running = String(nextNumber).padStart(paddingLength, "0");
|
||||||
|
|
||||||
return format
|
return format
|
||||||
.replaceAll('{prefix}', prefix)
|
.replaceAll("{prefix}", prefix)
|
||||||
.replaceAll('{period}', period)
|
.replaceAll("{period}", period)
|
||||||
.replaceAll('{running}', running);
|
.replaceAll("{running}", running);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOrganizationRecord(organizationId?: string): Promise<OrganizationRecord> {
|
async function resolveOrganizationRecord(
|
||||||
|
organizationId?: string,
|
||||||
|
): Promise<OrganizationRecord> {
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
const organization = await getCurrentOrganization();
|
const organization = await getCurrentOrganization();
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new AuthError('Active organization required.', 400);
|
throw new AuthError("Active organization required.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: organization.id,
|
id: organization.id,
|
||||||
name: organization.name,
|
name: organization.name,
|
||||||
slug: organization.slug
|
slug: organization.slug,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,21 +144,21 @@ async function resolveOrganizationRecord(organizationId?: string): Promise<Organ
|
|||||||
.select({
|
.select({
|
||||||
id: organizations.id,
|
id: organizations.id,
|
||||||
name: organizations.name,
|
name: organizations.name,
|
||||||
slug: organizations.slug
|
slug: organizations.slug,
|
||||||
})
|
})
|
||||||
.from(organizations)
|
.from(organizations)
|
||||||
.where(eq(organizations.id, organizationId))
|
.where(eq(organizations.id, organizationId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new AuthError('Organization not found.', 404);
|
throw new AuthError("Organization not found.", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return organization;
|
return organization;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveProductTypeMap(organizationId: string) {
|
async function resolveOptionMap(category: string, organizationId: string) {
|
||||||
const options = await getActiveOptionsByCategory(PRODUCT_TYPE_CATEGORY, { organizationId });
|
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||||
|
|
||||||
return new Map(
|
return new Map(
|
||||||
options.map((option) => [
|
options.map((option) => [
|
||||||
@@ -159,45 +166,105 @@ async function resolveProductTypeMap(organizationId: string) {
|
|||||||
{
|
{
|
||||||
id: option.id,
|
id: option.id,
|
||||||
code: normalizeDocumentSequenceProductType(option.code),
|
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<DocumentSequenceProductTypeLookupOption>;
|
||||||
|
referenceOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
|
||||||
|
}) {
|
||||||
|
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<DocumentSequenceProductTypeLookupOption>;
|
||||||
|
referenceOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
|
||||||
|
}) {
|
||||||
|
const error = new AuthError(
|
||||||
|
"Missing product type code configuration for quotation type.",
|
||||||
|
400,
|
||||||
|
) as AuthError & {
|
||||||
|
details: ReturnType<typeof buildProductTypeResolutionDetails>;
|
||||||
|
};
|
||||||
|
|
||||||
|
error.details = buildProductTypeResolutionDetails(input);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveScopedProductType(input: {
|
async function resolveScopedProductType(input: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
productType?: string | null;
|
productType?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const normalizedDocumentType = normalizeDocumentType(input.documentType);
|
const normalizedDocumentType = normalizeDocumentType(input.documentType);
|
||||||
const requestedProductType = input.productType?.trim();
|
const requestedProductType = input.productType?.trim() ?? null;
|
||||||
|
|
||||||
if (normalizedDocumentType !== 'quotation') {
|
if (normalizedDocumentType !== "quotation") {
|
||||||
return requestedProductType
|
return requestedProductType
|
||||||
? normalizeDocumentSequenceProductType(requestedProductType)
|
? normalizeDocumentSequenceProductType(requestedProductType)
|
||||||
: DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE;
|
: DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!requestedProductType) {
|
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 productTypeOptions = [
|
||||||
const normalizedRequestedProductType =
|
...(await resolveProductTypeMap(input.organizationId)).values(),
|
||||||
normalizeDocumentSequenceProductType(requestedProductType);
|
];
|
||||||
|
const referenceOptions = [
|
||||||
|
...(await resolveQuotationTypeMap(input.organizationId)).values(),
|
||||||
|
];
|
||||||
|
const resolution = resolveDocumentSequenceProductTypeInput({
|
||||||
|
documentType: normalizedDocumentType,
|
||||||
|
productType: requestedProductType,
|
||||||
|
options: productTypeOptions,
|
||||||
|
referenceOptions,
|
||||||
|
});
|
||||||
|
|
||||||
const byCode = productTypeMap.get(normalizedRequestedProductType);
|
if (resolution.productType) {
|
||||||
if (byCode) {
|
return resolution.productType;
|
||||||
return byCode.code;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const byId = [...productTypeMap.values()].find((option) => option.id === requestedProductType);
|
throw buildProductTypeResolutionError({
|
||||||
if (byId) {
|
organizationId: input.organizationId,
|
||||||
return byId.code;
|
documentType: normalizedDocumentType,
|
||||||
}
|
requestedProductType,
|
||||||
|
normalizedRequestedProductType: resolution.normalizedRequestedProductType,
|
||||||
throw new AuthError('Missing product type code configuration for this quotation type.', 400);
|
productTypeOptions,
|
||||||
|
referenceOptions,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePrefix(input: {
|
async function resolvePrefix(input: {
|
||||||
@@ -211,21 +278,31 @@ async function resolvePrefix(input: {
|
|||||||
return manualPrefix;
|
return manualPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.documentType !== 'quotation') {
|
if (input.documentType !== "quotation") {
|
||||||
return DEFAULT_DOCUMENT_PREFIXES[input.documentType] ?? input.documentType.slice(0, 3).toUpperCase();
|
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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
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}`;
|
return `${productTypeCode}${organizationCode}`;
|
||||||
@@ -233,11 +310,14 @@ async function resolvePrefix(input: {
|
|||||||
|
|
||||||
async function assertSequence(id: string, organizationId: string) {
|
async function assertSequence(id: string, organizationId: string) {
|
||||||
const row = await db.query.documentSequences.findFirst({
|
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) {
|
if (!row) {
|
||||||
throw new AuthError('Document sequence not found.', 404);
|
throw new AuthError("Document sequence not found.", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -258,36 +338,42 @@ async function assertUniqueScope(input: {
|
|||||||
eq(documentSequences.productType, input.productType),
|
eq(documentSequences.productType, input.productType),
|
||||||
eq(documentSequences.documentType, input.documentType),
|
eq(documentSequences.documentType, input.documentType),
|
||||||
eq(documentSequences.period, input.period),
|
eq(documentSequences.period, input.period),
|
||||||
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : [])
|
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : []),
|
||||||
)
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
'Duplicate document sequence scope for organization, branch, product type, document type, and period.',
|
"Duplicate document sequence scope for organization, branch, product type, document type, and period.",
|
||||||
409
|
409,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapSequenceRecords(
|
async function mapSequenceRecords(
|
||||||
organization: OrganizationRecord,
|
organization: OrganizationRecord,
|
||||||
rows: Array<typeof documentSequences.$inferSelect>
|
rows: Array<typeof documentSequences.$inferSelect>,
|
||||||
) {
|
) {
|
||||||
const [branchDisplayMap, productTypeMap] = await Promise.all([
|
const [branchDisplayMap, productTypeMap] = await Promise.all([
|
||||||
resolveBranchDisplays({
|
resolveBranchDisplays({
|
||||||
organizationId: organization.id,
|
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) => {
|
return rows.map((row) => {
|
||||||
const branchId = row.branchId || null;
|
const branchId = row.branchId || null;
|
||||||
const branchDisplay = branchId ? (branchDisplayMap.get(branchId) ?? null) : null;
|
const branchDisplay = branchId
|
||||||
const normalizedProductType = normalizeDocumentSequenceProductType(row.productType);
|
? (branchDisplayMap.get(branchId) ?? null)
|
||||||
|
: null;
|
||||||
|
const normalizedProductType = normalizeDocumentSequenceProductType(
|
||||||
|
row.productType,
|
||||||
|
);
|
||||||
const productType = productTypeMap.get(normalizedProductType) ?? null;
|
const productType = productTypeMap.get(normalizedProductType) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -301,17 +387,17 @@ async function mapSequenceRecords(
|
|||||||
branchDisplayName: formatBranchDisplayName({
|
branchDisplayName: formatBranchDisplayName({
|
||||||
branchId,
|
branchId,
|
||||||
branchCode: branchDisplay?.code ?? null,
|
branchCode: branchDisplay?.code ?? null,
|
||||||
branchName: branchDisplay?.name ?? null
|
branchName: branchDisplay?.name ?? null,
|
||||||
}),
|
}),
|
||||||
productType: normalizedProductType,
|
productType: normalizedProductType,
|
||||||
productTypeLabel:
|
productTypeLabel:
|
||||||
normalizedProductType === DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE
|
normalizedProductType === DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE
|
||||||
? 'All Product Types'
|
? "All Product Types"
|
||||||
: normalizedProductType === 'legacy'
|
: normalizedProductType === "legacy"
|
||||||
? 'Legacy'
|
? "Legacy"
|
||||||
: productType?.label ?? null,
|
: (productType?.label ?? null),
|
||||||
productTypeSequenceCode:
|
productTypeSequenceCode:
|
||||||
normalizedProductType === 'legacy'
|
normalizedProductType === "legacy"
|
||||||
? null
|
? null
|
||||||
: resolveDocumentSequenceProductTypeCode(normalizedProductType),
|
: resolveDocumentSequenceProductTypeCode(normalizedProductType),
|
||||||
documentType: row.documentType,
|
documentType: row.documentType,
|
||||||
@@ -323,19 +409,21 @@ async function mapSequenceRecords(
|
|||||||
resetPolicy: row.resetPolicy,
|
resetPolicy: row.resetPolicy,
|
||||||
isActive: row.isActive,
|
isActive: row.isActive,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString()
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
} satisfies DocumentSequenceRecord;
|
} satisfies DocumentSequenceRecord;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
|
function toSequenceResult(
|
||||||
|
row: typeof documentSequences.$inferSelect,
|
||||||
|
): DocumentSequenceResult {
|
||||||
return {
|
return {
|
||||||
code: buildDocumentCode(
|
code: buildDocumentCode(
|
||||||
row.format,
|
row.format,
|
||||||
row.prefix,
|
row.prefix,
|
||||||
row.period,
|
row.period,
|
||||||
row.currentNumber + 1,
|
row.currentNumber + 1,
|
||||||
row.paddingLength
|
row.paddingLength,
|
||||||
),
|
),
|
||||||
documentType: row.documentType,
|
documentType: row.documentType,
|
||||||
productType: row.productType,
|
productType: row.productType,
|
||||||
@@ -343,12 +431,12 @@ function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentS
|
|||||||
currentNumber: row.currentNumber,
|
currentNumber: row.currentNumber,
|
||||||
nextNumber: row.currentNumber + 1,
|
nextNumber: row.currentNumber + 1,
|
||||||
period: row.period,
|
period: row.period,
|
||||||
prefix: row.prefix
|
prefix: row.prefix,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDocumentSequences(
|
export async function listDocumentSequences(
|
||||||
organizationId: string
|
organizationId: string,
|
||||||
): Promise<DocumentSequenceListItem[]> {
|
): Promise<DocumentSequenceListItem[]> {
|
||||||
const organization = await resolveOrganizationRecord(organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -359,7 +447,7 @@ export async function listDocumentSequences(
|
|||||||
asc(documentSequences.documentType),
|
asc(documentSequences.documentType),
|
||||||
asc(documentSequences.productType),
|
asc(documentSequences.productType),
|
||||||
asc(documentSequences.period),
|
asc(documentSequences.period),
|
||||||
asc(documentSequences.branchId)
|
asc(documentSequences.branchId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const records = await mapSequenceRecords(organization, rows);
|
const records = await mapSequenceRecords(organization, rows);
|
||||||
@@ -371,14 +459,14 @@ export async function listDocumentSequences(
|
|||||||
record.prefix,
|
record.prefix,
|
||||||
record.period,
|
record.period,
|
||||||
record.currentNumber + 1,
|
record.currentNumber + 1,
|
||||||
record.paddingLength
|
record.paddingLength,
|
||||||
)
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDocumentSequence(
|
export async function getDocumentSequence(
|
||||||
id: string,
|
id: string,
|
||||||
organizationId: string
|
organizationId: string,
|
||||||
): Promise<DocumentSequenceRecord> {
|
): Promise<DocumentSequenceRecord> {
|
||||||
const organization = await resolveOrganizationRecord(organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
const row = await assertSequence(id, organization.id);
|
const row = await assertSequence(id, organization.id);
|
||||||
@@ -389,7 +477,7 @@ export async function getDocumentSequence(
|
|||||||
|
|
||||||
export async function createDocumentSequence(
|
export async function createDocumentSequence(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
payload: DocumentSequenceMutationPayload
|
payload: DocumentSequenceMutationPayload,
|
||||||
): Promise<DocumentSequenceRecord> {
|
): Promise<DocumentSequenceRecord> {
|
||||||
const organization = await resolveOrganizationRecord(organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
const documentType = normalizeDocumentType(payload.documentType);
|
const documentType = normalizeDocumentType(payload.documentType);
|
||||||
@@ -397,7 +485,7 @@ export async function createDocumentSequence(
|
|||||||
const productType = await resolveScopedProductType({
|
const productType = await resolveScopedProductType({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
documentType,
|
documentType,
|
||||||
productType: payload.productType
|
productType: payload.productType,
|
||||||
});
|
});
|
||||||
const period = normalizePeriod(payload.period);
|
const period = normalizePeriod(payload.period);
|
||||||
|
|
||||||
@@ -406,7 +494,7 @@ export async function createDocumentSequence(
|
|||||||
branchId,
|
branchId,
|
||||||
productType,
|
productType,
|
||||||
documentType,
|
documentType,
|
||||||
period
|
period,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
@@ -421,7 +509,7 @@ export async function createDocumentSequence(
|
|||||||
organization,
|
organization,
|
||||||
documentType,
|
documentType,
|
||||||
productType,
|
productType,
|
||||||
prefix: payload.prefix
|
prefix: payload.prefix,
|
||||||
}),
|
}),
|
||||||
period,
|
period,
|
||||||
currentNumber: payload.currentNumber ?? 0,
|
currentNumber: payload.currentNumber ?? 0,
|
||||||
@@ -429,7 +517,7 @@ export async function createDocumentSequence(
|
|||||||
format: normalizeFormat(payload.format),
|
format: normalizeFormat(payload.format),
|
||||||
resetPolicy: normalizeResetPolicy(payload.resetPolicy),
|
resetPolicy: normalizeResetPolicy(payload.resetPolicy),
|
||||||
isActive: payload.isActive ?? true,
|
isActive: payload.isActive ?? true,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -440,17 +528,21 @@ export async function createDocumentSequence(
|
|||||||
export async function updateDocumentSequence(
|
export async function updateDocumentSequence(
|
||||||
id: string,
|
id: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
payload: Partial<DocumentSequenceMutationPayload>
|
payload: Partial<DocumentSequenceMutationPayload>,
|
||||||
): Promise<DocumentSequenceRecord> {
|
): Promise<DocumentSequenceRecord> {
|
||||||
const organization = await resolveOrganizationRecord(organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
const current = await assertSequence(id, organization.id);
|
const current = await assertSequence(id, organization.id);
|
||||||
const documentType = normalizeDocumentType(payload.documentType ?? current.documentType);
|
const documentType = normalizeDocumentType(
|
||||||
|
payload.documentType ?? current.documentType,
|
||||||
|
);
|
||||||
const branchId =
|
const branchId =
|
||||||
payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId);
|
payload.branchId === undefined
|
||||||
|
? current.branchId
|
||||||
|
: normalizeBranchId(payload.branchId);
|
||||||
const productType = await resolveScopedProductType({
|
const productType = await resolveScopedProductType({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
documentType,
|
documentType,
|
||||||
productType: payload.productType ?? current.productType
|
productType: payload.productType ?? current.productType,
|
||||||
});
|
});
|
||||||
const period = normalizePeriod(payload.period ?? current.period);
|
const period = normalizePeriod(payload.period ?? current.period);
|
||||||
|
|
||||||
@@ -460,7 +552,7 @@ export async function updateDocumentSequence(
|
|||||||
productType,
|
productType,
|
||||||
documentType,
|
documentType,
|
||||||
period,
|
period,
|
||||||
currentId: id
|
currentId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
@@ -473,15 +565,17 @@ export async function updateDocumentSequence(
|
|||||||
organization,
|
organization,
|
||||||
documentType,
|
documentType,
|
||||||
productType,
|
productType,
|
||||||
prefix: payload.prefix ?? current.prefix
|
prefix: payload.prefix ?? current.prefix,
|
||||||
}),
|
}),
|
||||||
period,
|
period,
|
||||||
currentNumber: payload.currentNumber ?? current.currentNumber,
|
currentNumber: payload.currentNumber ?? current.currentNumber,
|
||||||
paddingLength: payload.paddingLength ?? current.paddingLength,
|
paddingLength: payload.paddingLength ?? current.paddingLength,
|
||||||
format: normalizeFormat(payload.format ?? current.format),
|
format: normalizeFormat(payload.format ?? current.format),
|
||||||
resetPolicy: normalizeResetPolicy(payload.resetPolicy ?? current.resetPolicy),
|
resetPolicy: normalizeResetPolicy(
|
||||||
|
payload.resetPolicy ?? current.resetPolicy,
|
||||||
|
),
|
||||||
isActive: payload.isActive ?? current.isActive,
|
isActive: payload.isActive ?? current.isActive,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(documentSequences.id, id))
|
.where(eq(documentSequences.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -493,7 +587,7 @@ export async function updateDocumentSequence(
|
|||||||
export async function resetDocumentSequence(
|
export async function resetDocumentSequence(
|
||||||
id: string,
|
id: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
payload: DocumentSequenceResetPayload
|
payload: DocumentSequenceResetPayload,
|
||||||
): Promise<DocumentSequenceRecord> {
|
): Promise<DocumentSequenceRecord> {
|
||||||
const organization = await resolveOrganizationRecord(organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
await assertSequence(id, organization.id);
|
await assertSequence(id, organization.id);
|
||||||
@@ -502,7 +596,7 @@ export async function resetDocumentSequence(
|
|||||||
.update(documentSequences)
|
.update(documentSequences)
|
||||||
.set({
|
.set({
|
||||||
currentNumber: payload.currentNumber,
|
currentNumber: payload.currentNumber,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(documentSequences.id, id))
|
.where(eq(documentSequences.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -511,7 +605,10 @@ export async function resetDocumentSequence(
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDocumentSequence(id: string, organizationId: string) {
|
export async function deleteDocumentSequence(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
) {
|
||||||
const organization = await resolveOrganizationRecord(organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
const sequence = await assertSequence(id, organization.id);
|
const sequence = await assertSequence(id, organization.id);
|
||||||
await db.delete(documentSequences).where(eq(documentSequences.id, id));
|
await db.delete(documentSequences).where(eq(documentSequences.id, id));
|
||||||
@@ -520,7 +617,10 @@ export async function deleteDocumentSequence(id: string, organizationId: string)
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewDocumentSequenceById(id: string, organizationId: string) {
|
export async function previewDocumentSequenceById(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
) {
|
||||||
const row = await assertSequence(id, organizationId);
|
const row = await assertSequence(id, organizationId);
|
||||||
return toSequenceResult(row);
|
return toSequenceResult(row);
|
||||||
}
|
}
|
||||||
@@ -532,7 +632,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
|||||||
const productType = await resolveScopedProductType({
|
const productType = await resolveScopedProductType({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
documentType,
|
documentType,
|
||||||
productType: input.productType
|
productType: input.productType,
|
||||||
});
|
});
|
||||||
const period = normalizePeriod(input.period ?? getCurrentPeriod());
|
const period = normalizePeriod(input.period ?? getCurrentPeriod());
|
||||||
|
|
||||||
@@ -544,8 +644,8 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
|||||||
eq(documentSequences.branchId, branchId),
|
eq(documentSequences.branchId, branchId),
|
||||||
eq(documentSequences.productType, productType),
|
eq(documentSequences.productType, productType),
|
||||||
eq(documentSequences.documentType, documentType),
|
eq(documentSequences.documentType, documentType),
|
||||||
eq(documentSequences.period, period)
|
eq(documentSequences.period, period),
|
||||||
)
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sequence) {
|
if (!sequence) {
|
||||||
@@ -560,14 +660,14 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
|||||||
prefix: await resolvePrefix({
|
prefix: await resolvePrefix({
|
||||||
organization,
|
organization,
|
||||||
documentType,
|
documentType,
|
||||||
productType
|
productType,
|
||||||
}),
|
}),
|
||||||
period,
|
period,
|
||||||
currentNumber: 0,
|
currentNumber: 0,
|
||||||
paddingLength: 3,
|
paddingLength: 3,
|
||||||
format: DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
format: DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
||||||
resetPolicy: DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
resetPolicy: DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
||||||
isActive: true
|
isActive: true,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
@@ -577,24 +677,24 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
|||||||
eq(documentSequences.branchId, branchId),
|
eq(documentSequences.branchId, branchId),
|
||||||
eq(documentSequences.productType, productType),
|
eq(documentSequences.productType, productType),
|
||||||
eq(documentSequences.documentType, documentType),
|
eq(documentSequences.documentType, documentType),
|
||||||
eq(documentSequences.period, period)
|
eq(documentSequences.period, period),
|
||||||
)
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sequence) {
|
if (!sequence) {
|
||||||
throw new AuthError('Unable to initialize document sequence.', 500);
|
throw new AuthError("Unable to initialize document sequence.", 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sequence.isActive) {
|
if (!sequence.isActive) {
|
||||||
throw new AuthError('Document sequence is inactive.', 400);
|
throw new AuthError("Document sequence is inactive.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
.update(documentSequences)
|
.update(documentSequences)
|
||||||
.set({
|
.set({
|
||||||
currentNumber: sql`${documentSequences.currentNumber} + 1`,
|
currentNumber: sql`${documentSequences.currentNumber} + 1`,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(documentSequences.id, sequence.id))
|
.where(eq(documentSequences.id, sequence.id))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -607,7 +707,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
|||||||
updated.prefix,
|
updated.prefix,
|
||||||
updated.period,
|
updated.period,
|
||||||
updated.currentNumber,
|
updated.currentNumber,
|
||||||
updated.paddingLength
|
updated.paddingLength,
|
||||||
),
|
),
|
||||||
documentType: updated.documentType,
|
documentType: updated.documentType,
|
||||||
productType: updated.productType,
|
productType: updated.productType,
|
||||||
@@ -615,35 +715,42 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
|||||||
currentNumber: updated.currentNumber,
|
currentNumber: updated.currentNumber,
|
||||||
nextNumber: updated.currentNumber + 1,
|
nextNumber: updated.currentNumber + 1,
|
||||||
period: updated.period,
|
period: updated.period,
|
||||||
prefix: updated.prefix
|
prefix: updated.prefix,
|
||||||
} satisfies DocumentSequenceResult
|
} satisfies DocumentSequenceResult,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await auditAction({
|
await auditAction({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
entityType: 'crm_document_sequence',
|
entityType: "crm_document_sequence",
|
||||||
entityId: result.sequenceId,
|
entityId: result.sequenceId,
|
||||||
action: 'generate',
|
action: "generate",
|
||||||
branchId: branchId || null,
|
branchId: branchId || null,
|
||||||
afterData: result.result
|
afterData: result.result,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.result;
|
return result.result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
|
const errorDetails =
|
||||||
|
error && typeof error === "object" && "details" in error
|
||||||
|
? ((error as { details?: unknown }).details ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
await auditAction({
|
await auditAction({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
entityType: 'crm_document_sequence',
|
entityType: "crm_document_sequence",
|
||||||
entityId: `${documentType}:${branchId}:${productType}:${period}`,
|
entityId: `${documentType}:${branchId}:${productType}:${period}`,
|
||||||
action: 'generate_failed',
|
action: "generate_failed",
|
||||||
branchId: branchId || null,
|
branchId: branchId || null,
|
||||||
afterData: {
|
afterData: {
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
message: error instanceof Error ? error.message : "Unknown error",
|
||||||
documentType,
|
documentType,
|
||||||
productType,
|
productType,
|
||||||
period
|
requestedProductType: input.productType?.trim() ?? null,
|
||||||
}
|
period,
|
||||||
|
details: errorDetails,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Keep original error as the primary failure signal.
|
// Keep original error as the primary failure signal.
|
||||||
|
|||||||
@@ -1,32 +1,94 @@
|
|||||||
const BASE_URL = '/api';
|
const BASE_URL = "/api";
|
||||||
|
|
||||||
export async function apiClient<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
interface ApiErrorPayload {
|
||||||
const isServer = typeof window === 'undefined';
|
message?: string;
|
||||||
const requestHeaders = isServer ? await (await import('next/headers')).headers() : null;
|
error?: string;
|
||||||
const host = requestHeaders?.get('x-forwarded-host') ?? requestHeaders?.get('host');
|
}
|
||||||
const protocol = requestHeaders?.get('x-forwarded-proto') ?? 'http';
|
|
||||||
const origin = isServer && host ? `${protocol}://${host}` : '';
|
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<unknown> {
|
||||||
|
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<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
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 optionHeaders = options?.headers;
|
||||||
const restOptions = options ? { ...options } : null;
|
const restOptions = options ? { ...options } : null;
|
||||||
|
|
||||||
if (restOptions && 'headers' in restOptions) {
|
if (restOptions && "headers" in restOptions) {
|
||||||
delete restOptions.headers;
|
delete restOptions.headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${origin}${BASE_URL}${endpoint}`, {
|
const response = await fetch(`${origin}${BASE_URL}${endpoint}`, {
|
||||||
...restOptions,
|
...restOptions,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
...(isServer && requestHeaders?.get('cookie')
|
...(isServer && requestHeaders?.get("cookie")
|
||||||
? { cookie: requestHeaders.get('cookie') as string }
|
? { cookie: requestHeaders.get("cookie") as string }
|
||||||
: {}),
|
: {}),
|
||||||
...optionHeaders
|
...optionHeaders,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
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<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user