task-p.7.1.1
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"audit": "payload-parity",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "CRA2606-1107",
|
||||
"previewHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6",
|
||||
"generationHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6",
|
||||
"snapshotHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6",
|
||||
"quotationCode": "CRA2606-1121",
|
||||
"previewHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e",
|
||||
"generationHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e",
|
||||
"snapshotHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e",
|
||||
"mismatches": [],
|
||||
"comparableFieldCount": 31,
|
||||
"topicInputKeyCount": 6,
|
||||
"comparableFieldCount": 25,
|
||||
"topicInputKeyCount": 0,
|
||||
"signatureParity": true,
|
||||
"priceTableParity": true,
|
||||
"priceTableRowCount": 1,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"audit": "approved-snapshot-parity",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "CRA2606-1107",
|
||||
"quotationCode": "CRA2606-1121",
|
||||
"approvedSnapshotAvailable": true,
|
||||
"approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
||||
"computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
||||
"inconsistentTemplateKeys": [],
|
||||
"computedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8",
|
||||
"storedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8",
|
||||
"approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581",
|
||||
"computedSignatureHash": "ef7a8fc3429f1c5b5a74ac0452d3de809558842f5431d0d5b02122980a286117",
|
||||
"storedSignatureHash": "ef7a8fc3429f1c5b5a74ac0452d3de809558842f5431d0d5b02122980a286117",
|
||||
"approvedArtifactReference": null,
|
||||
"refreshedFixtureSnapshot": true,
|
||||
"usedAuditFixture": false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"generatedAt": "2026-06-30T09:16:22.613Z",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "CRA2606-1107",
|
||||
"generatedAt": "2026-06-30T13:14:19.935Z",
|
||||
"overallStatus": "FAIL",
|
||||
"quotationCode": "CRA2606-1121",
|
||||
"artifacts": {
|
||||
"templateVersionReport": "template-version-report.json",
|
||||
"placeholderIntegrityReport": "placeholder-integrity-report.json",
|
||||
@@ -11,11 +11,11 @@
|
||||
"signatureAuditReport": "signature-audit-report.json"
|
||||
},
|
||||
"statuses": {
|
||||
"templateVersion": "PASS",
|
||||
"templateVersion": "FAIL",
|
||||
"placeholderIntegrity": "PASS",
|
||||
"runtimePayload": "PASS",
|
||||
"payloadParity": "PASS",
|
||||
"topicRuntime": "PASS",
|
||||
"topicRuntime": "FAIL",
|
||||
"approvedSnapshotParity": "PASS",
|
||||
"staticLabels": "PASS"
|
||||
},
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# PDF Integrity Summary
|
||||
|
||||
- Generated At: 2026-06-30T09:16:22.613Z
|
||||
- Overall Status: PASS
|
||||
- Quotation Code: CRA2606-1107
|
||||
- Generated At: 2026-06-30T13:14:19.935Z
|
||||
- Overall Status: FAIL
|
||||
- Quotation Code: CRA2606-1121
|
||||
|
||||
## Statuses
|
||||
|
||||
- Template Version Integrity: PASS
|
||||
- Template Version Integrity: FAIL
|
||||
- Placeholder Integrity: PASS
|
||||
- Runtime Payload: PASS
|
||||
- Payload Parity: PASS
|
||||
- Topic Runtime: PASS
|
||||
- Topic Runtime: FAIL
|
||||
- Approved Snapshot Parity: PASS
|
||||
- Static Labels: PASS
|
||||
- Price Table Present: Yes
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
{
|
||||
"audit": "template-version-integrity",
|
||||
"overallStatus": "PASS",
|
||||
"overallStatus": "FAIL",
|
||||
"activeTemplateCount": 1,
|
||||
"quotationCode": "CRA2606-1107",
|
||||
"quotationCode": "CRA2606-1121",
|
||||
"approvedTemplateVersionId": null,
|
||||
"approvedTemplateVersionIsActive": false,
|
||||
"issues": []
|
||||
"issues": [
|
||||
{
|
||||
"kind": "ACTIVE_VERSION_SCHEMA_DRIFT",
|
||||
"message": "Active template version does not match source JSON for ALLA Demo Organization Quotation Standard.",
|
||||
"context": {
|
||||
"templateId": "83928eee-0b3b-4dcb-9756-726dc2337e6c",
|
||||
"organizationName": "ALLA Demo Organization",
|
||||
"activeVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
|
||||
"activeVersion": "1.0",
|
||||
"sourceFile": "src/pdfme_template/ALLA_template_pdfme_fainal3.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
{
|
||||
"audit": "topic-runtime",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "CRA2606-1107",
|
||||
"topicCount": 3,
|
||||
"topicInputKeys": [
|
||||
"topic_1_0",
|
||||
"topic_1_1",
|
||||
"topic_1_2"
|
||||
],
|
||||
"itemTopicInputKeys": [
|
||||
"item_topic_1_0",
|
||||
"item_topic_1_1",
|
||||
"item_topic_1_2"
|
||||
],
|
||||
"overallStatus": "FAIL",
|
||||
"quotationCode": "CRA2606-1121",
|
||||
"topicCount": 0,
|
||||
"topicInputKeys": [],
|
||||
"itemTopicInputKeys": [],
|
||||
"duplicateKeys": [],
|
||||
"emptySections": []
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"generatedAt": "2026-06-30T09:16:20.352Z",
|
||||
"generatedAt": "2026-06-30T13:14:09.584Z",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "CRA2606-1107",
|
||||
"quotationCode": "CRA2606-1121",
|
||||
"expectedFixtureCode": "QT-H5-AUDIT",
|
||||
"template": {
|
||||
"templateName": "ALLA Demo Organization Quotation Standard",
|
||||
@@ -238,11 +238,11 @@
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"topicCount": 3,
|
||||
"itemCount": 4,
|
||||
"partyCount": 1,
|
||||
"dynamicTopicFieldCount": 3,
|
||||
"dynamicTopicItemFieldCount": 3,
|
||||
"topicCount": 0,
|
||||
"itemCount": 0,
|
||||
"partyCount": 2,
|
||||
"dynamicTopicFieldCount": 0,
|
||||
"dynamicTopicItemFieldCount": 0,
|
||||
"mappingCount": 23
|
||||
},
|
||||
"mappingCoverage": {
|
||||
@@ -256,14 +256,14 @@
|
||||
"itemTableColumns": []
|
||||
},
|
||||
"runtimePayload": {
|
||||
"templateInputKeys": 31,
|
||||
"topicInputKeys": 6,
|
||||
"templateInputKeys": 25,
|
||||
"topicInputKeys": 0,
|
||||
"priceTableRows": 1
|
||||
},
|
||||
"consistency": {
|
||||
"status": "PASS",
|
||||
"approvedSnapshotAvailable": true,
|
||||
"approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581",
|
||||
"approvedArtifactReference": null,
|
||||
"inconsistentKeys": []
|
||||
},
|
||||
"integritySummary": {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# PDF Audit Report
|
||||
|
||||
- Generated At: 2026-06-30T09:16:20.352Z
|
||||
- Generated At: 2026-06-30T13:14:09.584Z
|
||||
- Overall Status: PASS
|
||||
- Quotation Code: CRA2606-1107
|
||||
- Quotation Code: CRA2606-1121
|
||||
- Template: ALLA Demo Organization Quotation Standard v1.0
|
||||
- Previous Version: None
|
||||
|
||||
## Metrics
|
||||
|
||||
- Topic Count: 3
|
||||
- Item Count: 4
|
||||
- Project Party Count: 1
|
||||
- Topic Count: 0
|
||||
- Item Count: 0
|
||||
- Project Party Count: 2
|
||||
- Mapping Count: 23
|
||||
|
||||
## Mapping Coverage
|
||||
|
||||
@@ -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/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
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 { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export { POST } from './generate/route';
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
|
||||
@@ -96,11 +96,14 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfPreview)));
|
||||
const canPreviewCustomerPackage = canPreviewPdf;
|
||||
const canDownloadPdf =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfDownload)));
|
||||
const canGenerateCustomerPackage = canDownloadPdf;
|
||||
const canDownloadCustomerPackage = canDownloadPdf;
|
||||
const canGenerateApprovedPdf =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
@@ -165,7 +168,10 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
||||
currentUserId={session?.user?.id ?? ''}
|
||||
canPreviewDocument={canPreviewDocument}
|
||||
canPreviewPdf={canPreviewPdf}
|
||||
canPreviewCustomerPackage={canPreviewCustomerPackage}
|
||||
canDownloadPdf={canDownloadPdf}
|
||||
canGenerateCustomerPackage={canGenerateCustomerPackage}
|
||||
canDownloadCustomerPackage={canDownloadCustomerPackage}
|
||||
canGenerateApprovedPdf={canGenerateApprovedPdf}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createQuotation,
|
||||
createQuotationAttachment,
|
||||
createQuotationCustomer,
|
||||
generateQuotationCustomerPackage,
|
||||
createQuotationFollowup,
|
||||
createQuotationItem,
|
||||
createQuotationRevision,
|
||||
@@ -374,3 +375,13 @@ export const createQuotationRevisionMutation = mutationOptions({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const generateQuotationCustomerPackageMutation = mutationOptions({
|
||||
mutationFn: ({ quotationId }: { quotationId: string }) =>
|
||||
generateQuotationCustomerPackage(quotationId),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateQuotationHeaderQueries(variables.quotationId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MutationSuccessResponse,
|
||||
QuotationAttachmentMutationPayload,
|
||||
QuotationAttachmentsResponse,
|
||||
QuotationCustomerPackageGenerateResponse,
|
||||
QuotationCustomerMutationPayload,
|
||||
QuotationCustomersResponse,
|
||||
QuotationDetailResponse,
|
||||
@@ -41,6 +42,37 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
|
||||
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) {
|
||||
return apiClient<MutationSuccessResponse>('/crm/quotations', {
|
||||
method: 'POST',
|
||||
@@ -212,4 +244,3 @@ export async function createQuotationRevision(id: string, revisionRemark?: strin
|
||||
body: JSON.stringify({ revisionRemark })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,37 @@ export interface QuotationApprovedArtifactSummary {
|
||||
lockedByName: string | null;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageSource {
|
||||
role: string;
|
||||
sourceType: 'generated' | 'document_library';
|
||||
fileName: string;
|
||||
checksum: string | null;
|
||||
fileSize: number | null;
|
||||
pageCount: number | null;
|
||||
metadata: Record<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 {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -103,6 +134,7 @@ export interface QuotationRecord {
|
||||
approvedSnapshot: unknown | null;
|
||||
approvedTemplateVersionId: string | null;
|
||||
approvedArtifact: QuotationApprovedArtifactSummary | null;
|
||||
customerPackage: QuotationCustomerPackageSummary | null;
|
||||
hasLegacyApprovedPdf: boolean;
|
||||
acceptedAt: string | null;
|
||||
rejectedAt: string | null;
|
||||
@@ -432,3 +464,20 @@ export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageGenerateResponse {
|
||||
success: boolean;
|
||||
fileName: string;
|
||||
checksum: string;
|
||||
fileSize: number;
|
||||
pageCount: number;
|
||||
warnings: QuotationCustomerPackageWarning[];
|
||||
artifact: {
|
||||
artifactId: string;
|
||||
storageKey: string;
|
||||
fileName: string;
|
||||
checksum: string;
|
||||
fileSize: number;
|
||||
pageCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
createQuotationAttachmentMutation,
|
||||
createQuotationCustomerMutation,
|
||||
createQuotationFollowupMutation,
|
||||
generateQuotationCustomerPackageMutation,
|
||||
createQuotationItemMutation,
|
||||
createQuotationRevisionMutation,
|
||||
createQuotationTopicMutation,
|
||||
@@ -64,10 +65,12 @@ import {
|
||||
quotationRevisionsOptions,
|
||||
quotationTopicsOptions
|
||||
} from '../api/queries';
|
||||
import { downloadQuotationCustomerPackage } from '../api/service';
|
||||
import type {
|
||||
QuotationAttachmentMutationPayload,
|
||||
QuotationAttachmentRecord,
|
||||
QuotationCustomerListItem,
|
||||
QuotationCustomerPackageSource,
|
||||
QuotationCustomerMutationPayload,
|
||||
QuotationFollowupMutationPayload,
|
||||
QuotationFollowupRecord,
|
||||
@@ -79,6 +82,11 @@ import type {
|
||||
} from '../api/types';
|
||||
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
||||
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
||||
import {
|
||||
getCustomerPackageRoleLabel,
|
||||
getCustomerPackageUiStatus,
|
||||
mapCustomerPackageErrorMessage
|
||||
} from '../customer-package';
|
||||
import { QuotationDocumentPreview } from './quotation-document-preview';
|
||||
import { QuotationFormSheet } from './quotation-form-sheet';
|
||||
import { QuotationApprovalTab } from './quotation-approval-tab';
|
||||
@@ -102,6 +110,27 @@ function EmptyState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerPackageSourceItem({
|
||||
item
|
||||
}: {
|
||||
item: QuotationCustomerPackageSource;
|
||||
}) {
|
||||
return (
|
||||
<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({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -673,7 +702,10 @@ export function QuotationDetail({
|
||||
currentUserId,
|
||||
canPreviewDocument,
|
||||
canPreviewPdf,
|
||||
canPreviewCustomerPackage,
|
||||
canDownloadPdf,
|
||||
canGenerateCustomerPackage,
|
||||
canDownloadCustomerPackage,
|
||||
canGenerateApprovedPdf
|
||||
}: {
|
||||
quotationId: string;
|
||||
@@ -694,7 +726,10 @@ export function QuotationDetail({
|
||||
currentUserId: string;
|
||||
canPreviewDocument: boolean;
|
||||
canPreviewPdf: boolean;
|
||||
canPreviewCustomerPackage: boolean;
|
||||
canDownloadPdf: boolean;
|
||||
canGenerateCustomerPackage: boolean;
|
||||
canDownloadCustomerPackage: boolean;
|
||||
canGenerateApprovedPdf: boolean;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||
@@ -705,6 +740,12 @@ export function QuotationDetail({
|
||||
const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId));
|
||||
const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId));
|
||||
const quotation = data.quotation;
|
||||
const [customerPackageError, setCustomerPackageError] = useState<string | null>(null);
|
||||
const [confirmRegenerateOpen, setConfirmRegenerateOpen] = useState(false);
|
||||
const customerPackageStatus = getCustomerPackageUiStatus({
|
||||
customerPackage: quotation.customerPackage,
|
||||
lastErrorMessage: customerPackageError
|
||||
});
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const [itemOpen, setItemOpen] = useState(false);
|
||||
@@ -844,6 +885,44 @@ export function QuotationDetail({
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
|
||||
});
|
||||
const generateCustomerPackage = useMutation({
|
||||
...generateQuotationCustomerPackageMutation,
|
||||
onSuccess: () => {
|
||||
setCustomerPackageError(null);
|
||||
setConfirmRegenerateOpen(false);
|
||||
toast.success('Customer package generated successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = mapCustomerPackageErrorMessage(
|
||||
error instanceof Error ? error.message : null
|
||||
);
|
||||
setCustomerPackageError(message);
|
||||
setConfirmRegenerateOpen(false);
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
const downloadCustomerPackage = useMutation({
|
||||
mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
|
||||
onSuccess: async ({ blob, fileName }) => {
|
||||
setCustomerPackageError(null);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Customer package downloaded successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = mapCustomerPackageErrorMessage(
|
||||
error instanceof Error ? error.message : null
|
||||
);
|
||||
setCustomerPackageError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
const generateApprovedPdf = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
|
||||
@@ -925,6 +1004,20 @@ export function QuotationDetail({
|
||||
const customer = customerMap.get(quotation.customerId);
|
||||
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
||||
const status = statusMap.get(quotation.status);
|
||||
const customerPackage = quotation.customerPackage;
|
||||
const customerPackageActionLabel = customerPackage ? 'Regenerate Package' : 'Generate Customer Package';
|
||||
const customerPackageStatusLabel =
|
||||
customerPackageStatus === 'generated'
|
||||
? 'Generated'
|
||||
: customerPackageStatus === 'failed'
|
||||
? 'Failed'
|
||||
: 'Not Generated';
|
||||
const customerPackageStatusDescription =
|
||||
customerPackageStatus === 'generated'
|
||||
? 'Customer-facing assembled PDF is ready.'
|
||||
: customerPackageStatus === 'failed'
|
||||
? customerPackageError
|
||||
: 'Generate the package before downloading or previewing it.';
|
||||
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||
status?.code ?? ''
|
||||
);
|
||||
@@ -1075,6 +1168,31 @@ export function QuotationDetail({
|
||||
onOpenChange={setEditOpen}
|
||||
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='space-y-3'>
|
||||
@@ -1162,6 +1280,131 @@ export function QuotationDetail({
|
||||
</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='space-y-6 lg:col-span-2'>
|
||||
<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 { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import {
|
||||
getActiveArtifactForEntity,
|
||||
type DocumentArtifactRecord
|
||||
} from '@/features/foundation/document-artifact/server/service';
|
||||
import {
|
||||
type CrmSecurityContext,
|
||||
canAccessScopedCrmRecord
|
||||
@@ -47,6 +51,9 @@ import type {
|
||||
QuotationApprovedArtifactSummary,
|
||||
QuotationBranchOption,
|
||||
QuotationContactLookup,
|
||||
QuotationCustomerPackageSource,
|
||||
QuotationCustomerPackageSummary,
|
||||
QuotationCustomerPackageWarning,
|
||||
QuotationCustomerListItem,
|
||||
QuotationCustomerLookup,
|
||||
QuotationCustomerMutationPayload,
|
||||
@@ -133,10 +140,99 @@ function mapApprovedArtifactSummary(
|
||||
};
|
||||
}
|
||||
|
||||
function toCustomerPackageSource(
|
||||
value: unknown
|
||||
): QuotationCustomerPackageSource | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = value as Record<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(
|
||||
row: typeof crmQuotations.$inferSelect,
|
||||
options?: {
|
||||
approvedArtifact?: QuotationApprovedArtifactSummary | null;
|
||||
customerPackage?: QuotationCustomerPackageSummary | null;
|
||||
branchName?: string | null;
|
||||
branchCode?: string | null;
|
||||
salesmanName?: string | null;
|
||||
@@ -188,6 +284,7 @@ function mapQuotationRecord(
|
||||
approvedSnapshot: row.approvedSnapshot ?? null,
|
||||
approvedTemplateVersionId: row.approvedTemplateVersionId,
|
||||
approvedArtifact: options?.approvedArtifact ?? null,
|
||||
customerPackage: options?.customerPackage ?? null,
|
||||
hasLegacyApprovedPdf:
|
||||
!row.approvedArtifactId &&
|
||||
typeof row.approvedPdfUrl === 'string' &&
|
||||
@@ -1240,6 +1337,12 @@ export async function getQuotationDetail(
|
||||
})
|
||||
]);
|
||||
const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null;
|
||||
const customerPackageArtifactPromise = getActiveArtifactForEntity({
|
||||
organizationId,
|
||||
entityType: 'crm_quotation',
|
||||
entityId: id,
|
||||
artifactType: 'assembled_pdf'
|
||||
});
|
||||
const baseDisplayOptions = {
|
||||
branchName: branchDisplay?.name ?? null,
|
||||
branchCode: branchDisplay?.code ?? null,
|
||||
@@ -1256,7 +1359,13 @@ export async function getQuotationDetail(
|
||||
: null);
|
||||
|
||||
if (!approvedArtifactId) {
|
||||
return mapQuotationRecord(quotation, baseDisplayOptions);
|
||||
const customerPackageArtifact = await customerPackageArtifactPromise;
|
||||
return mapQuotationRecord(quotation, {
|
||||
...baseDisplayOptions,
|
||||
customerPackage: customerPackageArtifact
|
||||
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
const [artifact] = await db
|
||||
@@ -1272,10 +1381,19 @@ export async function getQuotationDetail(
|
||||
.limit(1);
|
||||
|
||||
if (!artifact) {
|
||||
return mapQuotationRecord(quotation);
|
||||
const customerPackageArtifact = await customerPackageArtifactPromise;
|
||||
return mapQuotationRecord(quotation, {
|
||||
...baseDisplayOptions,
|
||||
customerPackage: customerPackageArtifact
|
||||
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
const artifactActorMap = await resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]);
|
||||
const [artifactActorMap, customerPackageArtifact] = await Promise.all([
|
||||
resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]),
|
||||
customerPackageArtifactPromise
|
||||
]);
|
||||
|
||||
return mapQuotationRecord(quotation, {
|
||||
...baseDisplayOptions,
|
||||
@@ -1284,7 +1402,10 @@ export async function getQuotationDetail(
|
||||
lockedByName: artifact.lockedBy
|
||||
? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null)
|
||||
: null
|
||||
})
|
||||
}),
|
||||
customerPackage: customerPackageArtifact
|
||||
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { documentSequences, organizations } from '@/db/schema';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { resolveBranchDisplays } from '@/features/foundation/display/server/display-resolver';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { and, asc, eq, ne, sql } from "drizzle-orm";
|
||||
import { documentSequences, organizations } from "@/db/schema";
|
||||
import { auditAction } from "@/features/foundation/audit-log/service";
|
||||
import { resolveBranchDisplays } from "@/features/foundation/display/server/display-resolver";
|
||||
import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service";
|
||||
import { getCurrentOrganization } from "@/features/foundation/organization-context/service";
|
||||
import { db } from "@/lib/db";
|
||||
import { AuthError } from "@/lib/auth/session";
|
||||
import {
|
||||
DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
||||
DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
||||
DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE,
|
||||
normalizeDocumentSequenceProductType,
|
||||
resolveDocumentSequenceOrganizationCode,
|
||||
resolveDocumentSequenceProductTypeCode
|
||||
} from './config';
|
||||
resolveDocumentSequenceProductTypeCode,
|
||||
} from "./config";
|
||||
import {
|
||||
resolveDocumentSequenceProductTypeInput,
|
||||
type DocumentSequenceProductTypeLookupOption,
|
||||
} from "./product-type-resolver";
|
||||
import type {
|
||||
DocumentSequenceInput,
|
||||
DocumentSequenceListItem,
|
||||
DocumentSequenceMutationPayload,
|
||||
DocumentSequenceRecord,
|
||||
DocumentSequenceResetPayload,
|
||||
DocumentSequenceResult
|
||||
} from './types';
|
||||
DocumentSequenceResult,
|
||||
} from "./types";
|
||||
|
||||
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
|
||||
const PRODUCT_TYPE_CATEGORY = "crm_product_type";
|
||||
const QUOTATION_TYPE_CATEGORY = "crm_quotation_type";
|
||||
|
||||
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
|
||||
customer: 'CUS',
|
||||
contact: 'CON',
|
||||
opportunity: 'ENQ',
|
||||
crm_lead: 'LD',
|
||||
crm_opportunity: 'EN',
|
||||
quotation: 'QT',
|
||||
approval: 'APV'
|
||||
customer: "CUS",
|
||||
contact: "CON",
|
||||
opportunity: "ENQ",
|
||||
crm_lead: "LD",
|
||||
crm_opportunity: "EN",
|
||||
quotation: "QT",
|
||||
approval: "APV",
|
||||
};
|
||||
|
||||
type OrganizationRecord = {
|
||||
@@ -43,19 +48,19 @@ type OrganizationRecord = {
|
||||
|
||||
function getCurrentPeriod(date = new Date()) {
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
}
|
||||
|
||||
function normalizeBranchId(branchId?: string | null) {
|
||||
return branchId?.trim() || '';
|
||||
return branchId?.trim() || "";
|
||||
}
|
||||
|
||||
function normalizeDocumentType(documentType: string) {
|
||||
const normalized = documentType.trim();
|
||||
|
||||
if (!normalized) {
|
||||
throw new AuthError('Document type is required.', 400);
|
||||
throw new AuthError("Document type is required.", 400);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -65,11 +70,11 @@ function normalizePeriod(period?: string | null) {
|
||||
const normalized = period?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
throw new AuthError('Period is required.', 400);
|
||||
throw new AuthError("Period is required.", 400);
|
||||
}
|
||||
|
||||
if (!/^\d{4}$/.test(normalized)) {
|
||||
throw new AuthError('Period must use YYMM format.', 400);
|
||||
throw new AuthError("Period must use YYMM format.", 400);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -89,7 +94,7 @@ function formatBranchDisplayName(input: {
|
||||
branchName?: string | null;
|
||||
}) {
|
||||
if (!input.branchId) {
|
||||
return 'All Branches';
|
||||
return "All Branches";
|
||||
}
|
||||
|
||||
if (input.branchCode && input.branchName) {
|
||||
@@ -108,28 +113,30 @@ function buildDocumentCode(
|
||||
prefix: string,
|
||||
period: string,
|
||||
nextNumber: number,
|
||||
paddingLength: number
|
||||
paddingLength: number,
|
||||
) {
|
||||
const running = String(nextNumber).padStart(paddingLength, '0');
|
||||
const running = String(nextNumber).padStart(paddingLength, "0");
|
||||
|
||||
return format
|
||||
.replaceAll('{prefix}', prefix)
|
||||
.replaceAll('{period}', period)
|
||||
.replaceAll('{running}', running);
|
||||
.replaceAll("{prefix}", prefix)
|
||||
.replaceAll("{period}", period)
|
||||
.replaceAll("{running}", running);
|
||||
}
|
||||
|
||||
async function resolveOrganizationRecord(organizationId?: string): Promise<OrganizationRecord> {
|
||||
async function resolveOrganizationRecord(
|
||||
organizationId?: string,
|
||||
): Promise<OrganizationRecord> {
|
||||
if (!organizationId) {
|
||||
const organization = await getCurrentOrganization();
|
||||
|
||||
if (!organization) {
|
||||
throw new AuthError('Active organization required.', 400);
|
||||
throw new AuthError("Active organization required.", 400);
|
||||
}
|
||||
|
||||
return {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug
|
||||
slug: organization.slug,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,21 +144,21 @@ async function resolveOrganizationRecord(organizationId?: string): Promise<Organ
|
||||
.select({
|
||||
id: organizations.id,
|
||||
name: organizations.name,
|
||||
slug: organizations.slug
|
||||
slug: organizations.slug,
|
||||
})
|
||||
.from(organizations)
|
||||
.where(eq(organizations.id, organizationId))
|
||||
.limit(1);
|
||||
|
||||
if (!organization) {
|
||||
throw new AuthError('Organization not found.', 404);
|
||||
throw new AuthError("Organization not found.", 404);
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
async function resolveProductTypeMap(organizationId: string) {
|
||||
const options = await getActiveOptionsByCategory(PRODUCT_TYPE_CATEGORY, { organizationId });
|
||||
async function resolveOptionMap(category: string, organizationId: string) {
|
||||
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||
|
||||
return new Map(
|
||||
options.map((option) => [
|
||||
@@ -159,45 +166,105 @@ async function resolveProductTypeMap(organizationId: string) {
|
||||
{
|
||||
id: option.id,
|
||||
code: normalizeDocumentSequenceProductType(option.code),
|
||||
label: option.label
|
||||
}
|
||||
])
|
||||
label: option.label,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveProductTypeMap(organizationId: string) {
|
||||
return resolveOptionMap(PRODUCT_TYPE_CATEGORY, organizationId);
|
||||
}
|
||||
|
||||
async function resolveQuotationTypeMap(organizationId: string) {
|
||||
return resolveOptionMap(QUOTATION_TYPE_CATEGORY, organizationId);
|
||||
}
|
||||
|
||||
function buildProductTypeResolutionDetails(input: {
|
||||
organizationId: string;
|
||||
documentType: string;
|
||||
requestedProductType: string | null;
|
||||
normalizedRequestedProductType: string | null;
|
||||
productTypeOptions: ReadonlyArray<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: {
|
||||
organizationId: string;
|
||||
documentType: string;
|
||||
productType?: string | null;
|
||||
}) {
|
||||
const normalizedDocumentType = normalizeDocumentType(input.documentType);
|
||||
const requestedProductType = input.productType?.trim();
|
||||
const requestedProductType = input.productType?.trim() ?? null;
|
||||
|
||||
if (normalizedDocumentType !== 'quotation') {
|
||||
if (normalizedDocumentType !== "quotation") {
|
||||
return requestedProductType
|
||||
? normalizeDocumentSequenceProductType(requestedProductType)
|
||||
: DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE;
|
||||
}
|
||||
|
||||
if (!requestedProductType) {
|
||||
throw new AuthError('Product type is required for quotation document sequences.', 400);
|
||||
throw new AuthError(
|
||||
"Product type required for quotation document sequences.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const productTypeMap = await resolveProductTypeMap(input.organizationId);
|
||||
const normalizedRequestedProductType =
|
||||
normalizeDocumentSequenceProductType(requestedProductType);
|
||||
const productTypeOptions = [
|
||||
...(await resolveProductTypeMap(input.organizationId)).values(),
|
||||
];
|
||||
const referenceOptions = [
|
||||
...(await resolveQuotationTypeMap(input.organizationId)).values(),
|
||||
];
|
||||
const resolution = resolveDocumentSequenceProductTypeInput({
|
||||
documentType: normalizedDocumentType,
|
||||
productType: requestedProductType,
|
||||
options: productTypeOptions,
|
||||
referenceOptions,
|
||||
});
|
||||
|
||||
const byCode = productTypeMap.get(normalizedRequestedProductType);
|
||||
if (byCode) {
|
||||
return byCode.code;
|
||||
if (resolution.productType) {
|
||||
return resolution.productType;
|
||||
}
|
||||
|
||||
const byId = [...productTypeMap.values()].find((option) => option.id === requestedProductType);
|
||||
if (byId) {
|
||||
return byId.code;
|
||||
}
|
||||
|
||||
throw new AuthError('Missing product type code configuration for this quotation type.', 400);
|
||||
throw buildProductTypeResolutionError({
|
||||
organizationId: input.organizationId,
|
||||
documentType: normalizedDocumentType,
|
||||
requestedProductType,
|
||||
normalizedRequestedProductType: resolution.normalizedRequestedProductType,
|
||||
productTypeOptions,
|
||||
referenceOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePrefix(input: {
|
||||
@@ -211,21 +278,31 @@ async function resolvePrefix(input: {
|
||||
return manualPrefix;
|
||||
}
|
||||
|
||||
if (input.documentType !== 'quotation') {
|
||||
return DEFAULT_DOCUMENT_PREFIXES[input.documentType] ?? input.documentType.slice(0, 3).toUpperCase();
|
||||
}
|
||||
|
||||
const organizationCode = resolveDocumentSequenceOrganizationCode(input.organization.slug);
|
||||
if (!organizationCode) {
|
||||
throw new AuthError(
|
||||
`Missing organization code configuration for ${input.organization.slug}.`,
|
||||
400
|
||||
if (input.documentType !== "quotation") {
|
||||
return (
|
||||
DEFAULT_DOCUMENT_PREFIXES[input.documentType] ??
|
||||
input.documentType.slice(0, 3).toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
const productTypeCode = resolveDocumentSequenceProductTypeCode(input.productType);
|
||||
const organizationCode = resolveDocumentSequenceOrganizationCode(
|
||||
input.organization.slug,
|
||||
);
|
||||
if (!organizationCode) {
|
||||
throw new AuthError(
|
||||
`Missing organization code configuration for ${input.organization.slug}.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const productTypeCode = resolveDocumentSequenceProductTypeCode(
|
||||
input.productType,
|
||||
);
|
||||
if (!productTypeCode) {
|
||||
throw new AuthError(`Missing product type code configuration for ${input.productType}.`, 400);
|
||||
throw new AuthError(
|
||||
`Missing product type code configuration for ${input.productType}.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return `${productTypeCode}${organizationCode}`;
|
||||
@@ -233,11 +310,14 @@ async function resolvePrefix(input: {
|
||||
|
||||
async function assertSequence(id: string, organizationId: string) {
|
||||
const row = await db.query.documentSequences.findFirst({
|
||||
where: and(eq(documentSequences.id, id), eq(documentSequences.organizationId, organizationId))
|
||||
where: and(
|
||||
eq(documentSequences.id, id),
|
||||
eq(documentSequences.organizationId, organizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new AuthError('Document sequence not found.', 404);
|
||||
throw new AuthError("Document sequence not found.", 404);
|
||||
}
|
||||
|
||||
return row;
|
||||
@@ -258,36 +338,42 @@ async function assertUniqueScope(input: {
|
||||
eq(documentSequences.productType, input.productType),
|
||||
eq(documentSequences.documentType, input.documentType),
|
||||
eq(documentSequences.period, input.period),
|
||||
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : [])
|
||||
)
|
||||
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : []),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new AuthError(
|
||||
'Duplicate document sequence scope for organization, branch, product type, document type, and period.',
|
||||
409
|
||||
"Duplicate document sequence scope for organization, branch, product type, document type, and period.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function mapSequenceRecords(
|
||||
organization: OrganizationRecord,
|
||||
rows: Array<typeof documentSequences.$inferSelect>
|
||||
rows: Array<typeof documentSequences.$inferSelect>,
|
||||
) {
|
||||
const [branchDisplayMap, productTypeMap] = await Promise.all([
|
||||
resolveBranchDisplays({
|
||||
organizationId: organization.id,
|
||||
branchIds: rows.map((row) => row.branchId || null)
|
||||
branchIds: rows.map((row) => row.branchId || null),
|
||||
}),
|
||||
resolveProductTypeMap(organization.id)
|
||||
resolveProductTypeMap(organization.id),
|
||||
]);
|
||||
|
||||
const organizationCode = resolveDocumentSequenceOrganizationCode(organization.slug);
|
||||
const organizationCode = resolveDocumentSequenceOrganizationCode(
|
||||
organization.slug,
|
||||
);
|
||||
|
||||
return rows.map((row) => {
|
||||
const branchId = row.branchId || null;
|
||||
const branchDisplay = branchId ? (branchDisplayMap.get(branchId) ?? null) : null;
|
||||
const normalizedProductType = normalizeDocumentSequenceProductType(row.productType);
|
||||
const branchDisplay = branchId
|
||||
? (branchDisplayMap.get(branchId) ?? null)
|
||||
: null;
|
||||
const normalizedProductType = normalizeDocumentSequenceProductType(
|
||||
row.productType,
|
||||
);
|
||||
const productType = productTypeMap.get(normalizedProductType) ?? null;
|
||||
|
||||
return {
|
||||
@@ -301,17 +387,17 @@ async function mapSequenceRecords(
|
||||
branchDisplayName: formatBranchDisplayName({
|
||||
branchId,
|
||||
branchCode: branchDisplay?.code ?? null,
|
||||
branchName: branchDisplay?.name ?? null
|
||||
branchName: branchDisplay?.name ?? null,
|
||||
}),
|
||||
productType: normalizedProductType,
|
||||
productTypeLabel:
|
||||
normalizedProductType === DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE
|
||||
? 'All Product Types'
|
||||
: normalizedProductType === 'legacy'
|
||||
? 'Legacy'
|
||||
: productType?.label ?? null,
|
||||
? "All Product Types"
|
||||
: normalizedProductType === "legacy"
|
||||
? "Legacy"
|
||||
: (productType?.label ?? null),
|
||||
productTypeSequenceCode:
|
||||
normalizedProductType === 'legacy'
|
||||
normalizedProductType === "legacy"
|
||||
? null
|
||||
: resolveDocumentSequenceProductTypeCode(normalizedProductType),
|
||||
documentType: row.documentType,
|
||||
@@ -323,19 +409,21 @@ async function mapSequenceRecords(
|
||||
resetPolicy: row.resetPolicy,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
} satisfies DocumentSequenceRecord;
|
||||
});
|
||||
}
|
||||
|
||||
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
|
||||
function toSequenceResult(
|
||||
row: typeof documentSequences.$inferSelect,
|
||||
): DocumentSequenceResult {
|
||||
return {
|
||||
code: buildDocumentCode(
|
||||
row.format,
|
||||
row.prefix,
|
||||
row.period,
|
||||
row.currentNumber + 1,
|
||||
row.paddingLength
|
||||
row.paddingLength,
|
||||
),
|
||||
documentType: row.documentType,
|
||||
productType: row.productType,
|
||||
@@ -343,12 +431,12 @@ function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentS
|
||||
currentNumber: row.currentNumber,
|
||||
nextNumber: row.currentNumber + 1,
|
||||
period: row.period,
|
||||
prefix: row.prefix
|
||||
prefix: row.prefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDocumentSequences(
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<DocumentSequenceListItem[]> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const rows = await db
|
||||
@@ -359,7 +447,7 @@ export async function listDocumentSequences(
|
||||
asc(documentSequences.documentType),
|
||||
asc(documentSequences.productType),
|
||||
asc(documentSequences.period),
|
||||
asc(documentSequences.branchId)
|
||||
asc(documentSequences.branchId),
|
||||
);
|
||||
|
||||
const records = await mapSequenceRecords(organization, rows);
|
||||
@@ -371,14 +459,14 @@ export async function listDocumentSequences(
|
||||
record.prefix,
|
||||
record.period,
|
||||
record.currentNumber + 1,
|
||||
record.paddingLength
|
||||
)
|
||||
record.paddingLength,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const row = await assertSequence(id, organization.id);
|
||||
@@ -389,7 +477,7 @@ export async function getDocumentSequence(
|
||||
|
||||
export async function createDocumentSequence(
|
||||
organizationId: string,
|
||||
payload: DocumentSequenceMutationPayload
|
||||
payload: DocumentSequenceMutationPayload,
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const documentType = normalizeDocumentType(payload.documentType);
|
||||
@@ -397,7 +485,7 @@ export async function createDocumentSequence(
|
||||
const productType = await resolveScopedProductType({
|
||||
organizationId: organization.id,
|
||||
documentType,
|
||||
productType: payload.productType
|
||||
productType: payload.productType,
|
||||
});
|
||||
const period = normalizePeriod(payload.period);
|
||||
|
||||
@@ -406,7 +494,7 @@ export async function createDocumentSequence(
|
||||
branchId,
|
||||
productType,
|
||||
documentType,
|
||||
period
|
||||
period,
|
||||
});
|
||||
|
||||
const [created] = await db
|
||||
@@ -421,7 +509,7 @@ export async function createDocumentSequence(
|
||||
organization,
|
||||
documentType,
|
||||
productType,
|
||||
prefix: payload.prefix
|
||||
prefix: payload.prefix,
|
||||
}),
|
||||
period,
|
||||
currentNumber: payload.currentNumber ?? 0,
|
||||
@@ -429,7 +517,7 @@ export async function createDocumentSequence(
|
||||
format: normalizeFormat(payload.format),
|
||||
resetPolicy: normalizeResetPolicy(payload.resetPolicy),
|
||||
isActive: payload.isActive ?? true,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -440,17 +528,21 @@ export async function createDocumentSequence(
|
||||
export async function updateDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
payload: Partial<DocumentSequenceMutationPayload>
|
||||
payload: Partial<DocumentSequenceMutationPayload>,
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const current = await assertSequence(id, organization.id);
|
||||
const documentType = normalizeDocumentType(payload.documentType ?? current.documentType);
|
||||
const documentType = normalizeDocumentType(
|
||||
payload.documentType ?? current.documentType,
|
||||
);
|
||||
const branchId =
|
||||
payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId);
|
||||
payload.branchId === undefined
|
||||
? current.branchId
|
||||
: normalizeBranchId(payload.branchId);
|
||||
const productType = await resolveScopedProductType({
|
||||
organizationId: organization.id,
|
||||
documentType,
|
||||
productType: payload.productType ?? current.productType
|
||||
productType: payload.productType ?? current.productType,
|
||||
});
|
||||
const period = normalizePeriod(payload.period ?? current.period);
|
||||
|
||||
@@ -460,7 +552,7 @@ export async function updateDocumentSequence(
|
||||
productType,
|
||||
documentType,
|
||||
period,
|
||||
currentId: id
|
||||
currentId: id,
|
||||
});
|
||||
|
||||
const [updated] = await db
|
||||
@@ -473,15 +565,17 @@ export async function updateDocumentSequence(
|
||||
organization,
|
||||
documentType,
|
||||
productType,
|
||||
prefix: payload.prefix ?? current.prefix
|
||||
prefix: payload.prefix ?? current.prefix,
|
||||
}),
|
||||
period,
|
||||
currentNumber: payload.currentNumber ?? current.currentNumber,
|
||||
paddingLength: payload.paddingLength ?? current.paddingLength,
|
||||
format: normalizeFormat(payload.format ?? current.format),
|
||||
resetPolicy: normalizeResetPolicy(payload.resetPolicy ?? current.resetPolicy),
|
||||
resetPolicy: normalizeResetPolicy(
|
||||
payload.resetPolicy ?? current.resetPolicy,
|
||||
),
|
||||
isActive: payload.isActive ?? current.isActive,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(documentSequences.id, id))
|
||||
.returning();
|
||||
@@ -493,7 +587,7 @@ export async function updateDocumentSequence(
|
||||
export async function resetDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
payload: DocumentSequenceResetPayload
|
||||
payload: DocumentSequenceResetPayload,
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
await assertSequence(id, organization.id);
|
||||
@@ -502,7 +596,7 @@ export async function resetDocumentSequence(
|
||||
.update(documentSequences)
|
||||
.set({
|
||||
currentNumber: payload.currentNumber,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(documentSequences.id, id))
|
||||
.returning();
|
||||
@@ -511,7 +605,10 @@ export async function resetDocumentSequence(
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function deleteDocumentSequence(id: string, organizationId: string) {
|
||||
export async function deleteDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const sequence = await assertSequence(id, organization.id);
|
||||
await db.delete(documentSequences).where(eq(documentSequences.id, id));
|
||||
@@ -520,7 +617,10 @@ export async function deleteDocumentSequence(id: string, organizationId: string)
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function previewDocumentSequenceById(id: string, organizationId: string) {
|
||||
export async function previewDocumentSequenceById(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const row = await assertSequence(id, organizationId);
|
||||
return toSequenceResult(row);
|
||||
}
|
||||
@@ -532,7 +632,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
const productType = await resolveScopedProductType({
|
||||
organizationId: organization.id,
|
||||
documentType,
|
||||
productType: input.productType
|
||||
productType: input.productType,
|
||||
});
|
||||
const period = normalizePeriod(input.period ?? getCurrentPeriod());
|
||||
|
||||
@@ -544,8 +644,8 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
eq(documentSequences.branchId, branchId),
|
||||
eq(documentSequences.productType, productType),
|
||||
eq(documentSequences.documentType, documentType),
|
||||
eq(documentSequences.period, period)
|
||||
)
|
||||
eq(documentSequences.period, period),
|
||||
),
|
||||
});
|
||||
|
||||
if (!sequence) {
|
||||
@@ -560,14 +660,14 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
prefix: await resolvePrefix({
|
||||
organization,
|
||||
documentType,
|
||||
productType
|
||||
productType,
|
||||
}),
|
||||
period,
|
||||
currentNumber: 0,
|
||||
paddingLength: 3,
|
||||
format: DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
||||
resetPolicy: DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
@@ -577,24 +677,24 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
eq(documentSequences.branchId, branchId),
|
||||
eq(documentSequences.productType, productType),
|
||||
eq(documentSequences.documentType, documentType),
|
||||
eq(documentSequences.period, period)
|
||||
)
|
||||
eq(documentSequences.period, period),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!sequence) {
|
||||
throw new AuthError('Unable to initialize document sequence.', 500);
|
||||
throw new AuthError("Unable to initialize document sequence.", 500);
|
||||
}
|
||||
|
||||
if (!sequence.isActive) {
|
||||
throw new AuthError('Document sequence is inactive.', 400);
|
||||
throw new AuthError("Document sequence is inactive.", 400);
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(documentSequences)
|
||||
.set({
|
||||
currentNumber: sql`${documentSequences.currentNumber} + 1`,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(documentSequences.id, sequence.id))
|
||||
.returning();
|
||||
@@ -607,7 +707,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
updated.prefix,
|
||||
updated.period,
|
||||
updated.currentNumber,
|
||||
updated.paddingLength
|
||||
updated.paddingLength,
|
||||
),
|
||||
documentType: updated.documentType,
|
||||
productType: updated.productType,
|
||||
@@ -615,35 +715,42 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
currentNumber: updated.currentNumber,
|
||||
nextNumber: updated.currentNumber + 1,
|
||||
period: updated.period,
|
||||
prefix: updated.prefix
|
||||
} satisfies DocumentSequenceResult
|
||||
prefix: updated.prefix,
|
||||
} satisfies DocumentSequenceResult,
|
||||
};
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
entityType: 'crm_document_sequence',
|
||||
entityType: "crm_document_sequence",
|
||||
entityId: result.sequenceId,
|
||||
action: 'generate',
|
||||
action: "generate",
|
||||
branchId: branchId || null,
|
||||
afterData: result.result
|
||||
afterData: result.result,
|
||||
});
|
||||
|
||||
return result.result;
|
||||
} catch (error) {
|
||||
try {
|
||||
const errorDetails =
|
||||
error && typeof error === "object" && "details" in error
|
||||
? ((error as { details?: unknown }).details ?? null)
|
||||
: null;
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
entityType: 'crm_document_sequence',
|
||||
entityType: "crm_document_sequence",
|
||||
entityId: `${documentType}:${branchId}:${productType}:${period}`,
|
||||
action: 'generate_failed',
|
||||
action: "generate_failed",
|
||||
branchId: branchId || null,
|
||||
afterData: {
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
documentType,
|
||||
productType,
|
||||
period
|
||||
}
|
||||
requestedProductType: input.productType?.trim() ?? null,
|
||||
period,
|
||||
details: errorDetails,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Keep original error as the primary failure signal.
|
||||
|
||||
@@ -1,32 +1,94 @@
|
||||
const BASE_URL = '/api';
|
||||
const BASE_URL = "/api";
|
||||
|
||||
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}` : '';
|
||||
interface ApiErrorPayload {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status: number;
|
||||
statusText: string;
|
||||
payload: unknown;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
input: { status: number; statusText: string; payload: unknown },
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiClientError";
|
||||
this.status = input.status;
|
||||
this.statusText = input.statusText;
|
||||
this.payload = input.payload;
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorPayload(response: Response): Promise<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 restOptions = options ? { ...options } : null;
|
||||
|
||||
if (restOptions && 'headers' in restOptions) {
|
||||
if (restOptions && "headers" in restOptions) {
|
||||
delete restOptions.headers;
|
||||
}
|
||||
|
||||
const res = await fetch(`${origin}${BASE_URL}${endpoint}`, {
|
||||
const response = await fetch(`${origin}${BASE_URL}${endpoint}`, {
|
||||
...restOptions,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(isServer && requestHeaders?.get('cookie')
|
||||
? { cookie: requestHeaders.get('cookie') as string }
|
||||
"Content-Type": "application/json",
|
||||
...(isServer && requestHeaders?.get("cookie")
|
||||
? { cookie: requestHeaders.get("cookie") as string }
|
||||
: {}),
|
||||
...optionHeaders
|
||||
}
|
||||
...optionHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
if (!response.ok) {
|
||||
const payload = await readErrorPayload(response);
|
||||
const message = extractApiErrorMessage(payload, response);
|
||||
throw new ApiClientError(message, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user