task-p.7.1.1

This commit is contained in:
phaichayon
2026-06-30 20:42:28 +07:00
parent e5766ea311
commit 8feaee61cb
29 changed files with 2673 additions and 875 deletions

View File

@@ -1,13 +1,13 @@
{ {
"audit": "payload-parity", "audit": "payload-parity",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "CRA2606-1107", "quotationCode": "CRA2606-1121",
"previewHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6", "previewHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e",
"generationHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6", "generationHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e",
"snapshotHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6", "snapshotHash": "f50ad995d51a33c5ae169f788591f9efe6e70b7a4f96d6861b549b0c6f71cd2e",
"mismatches": [], "mismatches": [],
"comparableFieldCount": 31, "comparableFieldCount": 25,
"topicInputKeyCount": 6, "topicInputKeyCount": 0,
"signatureParity": true, "signatureParity": true,
"priceTableParity": true, "priceTableParity": true,
"priceTableRowCount": 1, "priceTableRowCount": 1,

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
{ {
"audit": "approved-snapshot-parity", "audit": "approved-snapshot-parity",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "CRA2606-1107", "quotationCode": "CRA2606-1121",
"approvedSnapshotAvailable": true, "approvedSnapshotAvailable": true,
"approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", "approvedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
"computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406", "computedTemplateVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
"inconsistentTemplateKeys": [], "inconsistentTemplateKeys": [],
"computedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8", "computedSignatureHash": "ef7a8fc3429f1c5b5a74ac0452d3de809558842f5431d0d5b02122980a286117",
"storedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8", "storedSignatureHash": "ef7a8fc3429f1c5b5a74ac0452d3de809558842f5431d0d5b02122980a286117",
"approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581", "approvedArtifactReference": null,
"refreshedFixtureSnapshot": true, "refreshedFixtureSnapshot": true,
"usedAuditFixture": false "usedAuditFixture": false
} }

View File

@@ -1,7 +1,7 @@
{ {
"generatedAt": "2026-06-30T09:16:22.613Z", "generatedAt": "2026-06-30T13:14:19.935Z",
"overallStatus": "PASS", "overallStatus": "FAIL",
"quotationCode": "CRA2606-1107", "quotationCode": "CRA2606-1121",
"artifacts": { "artifacts": {
"templateVersionReport": "template-version-report.json", "templateVersionReport": "template-version-report.json",
"placeholderIntegrityReport": "placeholder-integrity-report.json", "placeholderIntegrityReport": "placeholder-integrity-report.json",
@@ -11,11 +11,11 @@
"signatureAuditReport": "signature-audit-report.json" "signatureAuditReport": "signature-audit-report.json"
}, },
"statuses": { "statuses": {
"templateVersion": "PASS", "templateVersion": "FAIL",
"placeholderIntegrity": "PASS", "placeholderIntegrity": "PASS",
"runtimePayload": "PASS", "runtimePayload": "PASS",
"payloadParity": "PASS", "payloadParity": "PASS",
"topicRuntime": "PASS", "topicRuntime": "FAIL",
"approvedSnapshotParity": "PASS", "approvedSnapshotParity": "PASS",
"staticLabels": "PASS" "staticLabels": "PASS"
}, },

View File

@@ -1,16 +1,16 @@
# PDF Integrity Summary # PDF Integrity Summary
- Generated At: 2026-06-30T09:16:22.613Z - Generated At: 2026-06-30T13:14:19.935Z
- Overall Status: PASS - Overall Status: FAIL
- Quotation Code: CRA2606-1107 - Quotation Code: CRA2606-1121
## Statuses ## Statuses
- Template Version Integrity: PASS - Template Version Integrity: FAIL
- Placeholder Integrity: PASS - Placeholder Integrity: PASS
- Runtime Payload: PASS - Runtime Payload: PASS
- Payload Parity: PASS - Payload Parity: PASS
- Topic Runtime: PASS - Topic Runtime: FAIL
- Approved Snapshot Parity: PASS - Approved Snapshot Parity: PASS
- Static Labels: PASS - Static Labels: PASS
- Price Table Present: Yes - Price Table Present: Yes

View File

@@ -1,9 +1,21 @@
{ {
"audit": "template-version-integrity", "audit": "template-version-integrity",
"overallStatus": "PASS", "overallStatus": "FAIL",
"activeTemplateCount": 1, "activeTemplateCount": 1,
"quotationCode": "CRA2606-1107", "quotationCode": "CRA2606-1121",
"approvedTemplateVersionId": null, "approvedTemplateVersionId": null,
"approvedTemplateVersionIsActive": false, "approvedTemplateVersionIsActive": false,
"issues": [] "issues": [
{
"kind": "ACTIVE_VERSION_SCHEMA_DRIFT",
"message": "Active template version does not match source JSON for ALLA Demo Organization Quotation Standard.",
"context": {
"templateId": "83928eee-0b3b-4dcb-9756-726dc2337e6c",
"organizationName": "ALLA Demo Organization",
"activeVersionId": "adf930b4-3d63-4ee1-bec9-3e82854ca406",
"activeVersion": "1.0",
"sourceFile": "src/pdfme_template/ALLA_template_pdfme_fainal3.json"
}
}
]
} }

View File

@@ -1,18 +1,10 @@
{ {
"audit": "topic-runtime", "audit": "topic-runtime",
"overallStatus": "PASS", "overallStatus": "FAIL",
"quotationCode": "CRA2606-1107", "quotationCode": "CRA2606-1121",
"topicCount": 3, "topicCount": 0,
"topicInputKeys": [ "topicInputKeys": [],
"topic_1_0", "itemTopicInputKeys": [],
"topic_1_1",
"topic_1_2"
],
"itemTopicInputKeys": [
"item_topic_1_0",
"item_topic_1_1",
"item_topic_1_2"
],
"duplicateKeys": [], "duplicateKeys": [],
"emptySections": [] "emptySections": []
} }

View File

@@ -1,7 +1,7 @@
{ {
"generatedAt": "2026-06-30T09:16:20.352Z", "generatedAt": "2026-06-30T13:14:09.584Z",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "CRA2606-1107", "quotationCode": "CRA2606-1121",
"expectedFixtureCode": "QT-H5-AUDIT", "expectedFixtureCode": "QT-H5-AUDIT",
"template": { "template": {
"templateName": "ALLA Demo Organization Quotation Standard", "templateName": "ALLA Demo Organization Quotation Standard",
@@ -238,11 +238,11 @@
] ]
}, },
"metrics": { "metrics": {
"topicCount": 3, "topicCount": 0,
"itemCount": 4, "itemCount": 0,
"partyCount": 1, "partyCount": 2,
"dynamicTopicFieldCount": 3, "dynamicTopicFieldCount": 0,
"dynamicTopicItemFieldCount": 3, "dynamicTopicItemFieldCount": 0,
"mappingCount": 23 "mappingCount": 23
}, },
"mappingCoverage": { "mappingCoverage": {
@@ -256,14 +256,14 @@
"itemTableColumns": [] "itemTableColumns": []
}, },
"runtimePayload": { "runtimePayload": {
"templateInputKeys": 31, "templateInputKeys": 25,
"topicInputKeys": 6, "topicInputKeys": 0,
"priceTableRows": 1 "priceTableRows": 1
}, },
"consistency": { "consistency": {
"status": "PASS", "status": "PASS",
"approvedSnapshotAvailable": true, "approvedSnapshotAvailable": true,
"approvedArtifactReference": "artifact:6dfd7a74-8e9a-48ad-a4fe-dbb520edf581", "approvedArtifactReference": null,
"inconsistentKeys": [] "inconsistentKeys": []
}, },
"integritySummary": { "integritySummary": {

View File

@@ -1,16 +1,16 @@
# PDF Audit Report # PDF Audit Report
- Generated At: 2026-06-30T09:16:20.352Z - Generated At: 2026-06-30T13:14:09.584Z
- Overall Status: PASS - Overall Status: PASS
- Quotation Code: CRA2606-1107 - Quotation Code: CRA2606-1121
- Template: ALLA Demo Organization Quotation Standard v1.0 - Template: ALLA Demo Organization Quotation Standard v1.0
- Previous Version: None - Previous Version: None
## Metrics ## Metrics
- Topic Count: 3 - Topic Count: 0
- Item Count: 4 - Item Count: 0
- Project Party Count: 1 - Project Party Count: 2
- Mapping Count: 23 - Mapping Count: 23
## Mapping Coverage ## Mapping Coverage

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

379
plans/task-d.4.6.md Normal file
View 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
View 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
View 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)

View File

@@ -8,6 +8,8 @@ import {
import { PERMISSIONS } from '@/lib/auth/rbac'; import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export { POST } from './generate/route';
type Params = { params: Promise<{ id: string }> }; type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) { export async function GET(_request: NextRequest, { params }: Params) {

View File

@@ -96,11 +96,14 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId && (!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' || (session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfPreview))); session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfPreview)));
const canPreviewCustomerPackage = canPreviewPdf;
const canDownloadPdf = const canDownloadPdf =
session?.user?.systemRole === 'super_admin' || session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId && (!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' || (session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfDownload))); session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfDownload)));
const canGenerateCustomerPackage = canDownloadPdf;
const canDownloadCustomerPackage = canDownloadPdf;
const canGenerateApprovedPdf = const canGenerateApprovedPdf =
session?.user?.systemRole === 'super_admin' || session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId && (!!session?.user?.activeOrganizationId &&
@@ -165,7 +168,10 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
currentUserId={session?.user?.id ?? ''} currentUserId={session?.user?.id ?? ''}
canPreviewDocument={canPreviewDocument} canPreviewDocument={canPreviewDocument}
canPreviewPdf={canPreviewPdf} canPreviewPdf={canPreviewPdf}
canPreviewCustomerPackage={canPreviewCustomerPackage}
canDownloadPdf={canDownloadPdf} canDownloadPdf={canDownloadPdf}
canGenerateCustomerPackage={canGenerateCustomerPackage}
canDownloadCustomerPackage={canDownloadCustomerPackage}
canGenerateApprovedPdf={canGenerateApprovedPdf} canGenerateApprovedPdf={canGenerateApprovedPdf}
/> />
</HydrationBoundary> </HydrationBoundary>

View File

@@ -5,6 +5,7 @@ import {
createQuotation, createQuotation,
createQuotationAttachment, createQuotationAttachment,
createQuotationCustomer, createQuotationCustomer,
generateQuotationCustomerPackage,
createQuotationFollowup, createQuotationFollowup,
createQuotationItem, createQuotationItem,
createQuotationRevision, createQuotationRevision,
@@ -374,3 +375,13 @@ export const createQuotationRevisionMutation = mutationOptions({
} }
} }
}); });
export const generateQuotationCustomerPackageMutation = mutationOptions({
mutationFn: ({ quotationId }: { quotationId: string }) =>
generateQuotationCustomerPackage(quotationId),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateQuotationHeaderQueries(variables.quotationId);
}
}
});

View File

@@ -3,6 +3,7 @@ import type {
MutationSuccessResponse, MutationSuccessResponse,
QuotationAttachmentMutationPayload, QuotationAttachmentMutationPayload,
QuotationAttachmentsResponse, QuotationAttachmentsResponse,
QuotationCustomerPackageGenerateResponse,
QuotationCustomerMutationPayload, QuotationCustomerMutationPayload,
QuotationCustomersResponse, QuotationCustomersResponse,
QuotationDetailResponse, QuotationDetailResponse,
@@ -41,6 +42,37 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
return apiClient<QuotationDetailResponse>(`/crm/quotations/${id}`); return apiClient<QuotationDetailResponse>(`/crm/quotations/${id}`);
} }
export async function generateQuotationCustomerPackage(
quotationId: string
): Promise<QuotationCustomerPackageGenerateResponse> {
return apiClient<QuotationCustomerPackageGenerateResponse>(
`/crm/quotations/${quotationId}/assembled-pdf`,
{
method: 'POST'
}
);
}
export async function downloadQuotationCustomerPackage(quotationId: string): Promise<{
blob: Blob;
fileName: string;
}> {
const response = await fetch(`/api/crm/quotations/${quotationId}/assembled-pdf/download`);
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(payload?.message ?? 'Failed to download customer package');
}
const contentDisposition = response.headers.get('content-disposition') ?? '';
const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/i);
return {
blob: await response.blob(),
fileName: fileNameMatch?.[1] ?? `${quotationId}-customer-package.pdf`
};
}
export async function createQuotation(data: QuotationMutationPayload) { export async function createQuotation(data: QuotationMutationPayload) {
return apiClient<MutationSuccessResponse>('/crm/quotations', { return apiClient<MutationSuccessResponse>('/crm/quotations', {
method: 'POST', method: 'POST',
@@ -212,4 +244,3 @@ export async function createQuotationRevision(id: string, revisionRemark?: strin
body: JSON.stringify({ revisionRemark }) body: JSON.stringify({ revisionRemark })
}); });
} }

View File

@@ -59,6 +59,37 @@ export interface QuotationApprovedArtifactSummary {
lockedByName: string | null; lockedByName: string | null;
} }
export interface QuotationCustomerPackageSource {
role: string;
sourceType: 'generated' | 'document_library';
fileName: string;
checksum: string | null;
fileSize: number | null;
pageCount: number | null;
metadata: Record<string, unknown> | null;
}
export interface QuotationCustomerPackageWarning {
code: string;
role: string;
message: string;
}
export interface QuotationCustomerPackageSummary {
artifactId: string;
fileName: string;
contentType: string;
fileSize: number | null;
checksum: string | null;
status: string;
pageCount: number | null;
generatedAt: string;
generatedBy: string;
generatedByName: string | null;
includedDocuments: QuotationCustomerPackageSource[];
warnings: QuotationCustomerPackageWarning[];
}
export interface QuotationRecord { export interface QuotationRecord {
id: string; id: string;
organizationId: string; organizationId: string;
@@ -103,6 +134,7 @@ export interface QuotationRecord {
approvedSnapshot: unknown | null; approvedSnapshot: unknown | null;
approvedTemplateVersionId: string | null; approvedTemplateVersionId: string | null;
approvedArtifact: QuotationApprovedArtifactSummary | null; approvedArtifact: QuotationApprovedArtifactSummary | null;
customerPackage: QuotationCustomerPackageSummary | null;
hasLegacyApprovedPdf: boolean; hasLegacyApprovedPdf: boolean;
acceptedAt: string | null; acceptedAt: string | null;
rejectedAt: string | null; rejectedAt: string | null;
@@ -432,3 +464,20 @@ export interface MutationSuccessResponse {
success: boolean; success: boolean;
message: string; message: string;
} }
export interface QuotationCustomerPackageGenerateResponse {
success: boolean;
fileName: string;
checksum: string;
fileSize: number;
pageCount: number;
warnings: QuotationCustomerPackageWarning[];
artifact: {
artifactId: string;
storageKey: string;
fileName: string;
checksum: string;
fileSize: number;
pageCount: number;
} | null;
}

View File

@@ -40,6 +40,7 @@ import {
createQuotationAttachmentMutation, createQuotationAttachmentMutation,
createQuotationCustomerMutation, createQuotationCustomerMutation,
createQuotationFollowupMutation, createQuotationFollowupMutation,
generateQuotationCustomerPackageMutation,
createQuotationItemMutation, createQuotationItemMutation,
createQuotationRevisionMutation, createQuotationRevisionMutation,
createQuotationTopicMutation, createQuotationTopicMutation,
@@ -64,10 +65,12 @@ import {
quotationRevisionsOptions, quotationRevisionsOptions,
quotationTopicsOptions quotationTopicsOptions
} from '../api/queries'; } from '../api/queries';
import { downloadQuotationCustomerPackage } from '../api/service';
import type { import type {
QuotationAttachmentMutationPayload, QuotationAttachmentMutationPayload,
QuotationAttachmentRecord, QuotationAttachmentRecord,
QuotationCustomerListItem, QuotationCustomerListItem,
QuotationCustomerPackageSource,
QuotationCustomerMutationPayload, QuotationCustomerMutationPayload,
QuotationFollowupMutationPayload, QuotationFollowupMutationPayload,
QuotationFollowupRecord, QuotationFollowupRecord,
@@ -79,6 +82,11 @@ import type {
} from '../api/types'; } from '../api/types';
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations'; import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries'; import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
import {
getCustomerPackageRoleLabel,
getCustomerPackageUiStatus,
mapCustomerPackageErrorMessage
} from '../customer-package';
import { QuotationDocumentPreview } from './quotation-document-preview'; import { QuotationDocumentPreview } from './quotation-document-preview';
import { QuotationFormSheet } from './quotation-form-sheet'; import { QuotationFormSheet } from './quotation-form-sheet';
import { QuotationApprovalTab } from './quotation-approval-tab'; import { QuotationApprovalTab } from './quotation-approval-tab';
@@ -102,6 +110,27 @@ function EmptyState({ message }: { message: string }) {
); );
} }
function CustomerPackageSourceItem({
item
}: {
item: QuotationCustomerPackageSource;
}) {
return (
<div className='rounded-lg border p-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{getCustomerPackageRoleLabel(item.role)}</span>
<Badge variant='outline'>{item.sourceType === 'generated' ? 'Generated' : 'Library'}</Badge>
</div>
<div className='text-muted-foreground mt-2 text-sm'>{item.fileName}</div>
<div className='text-muted-foreground mt-1 text-xs'>
{item.pageCount ? `${formatNumber(item.pageCount)} page(s)` : 'Page count unavailable'}
{item.fileSize ? `${formatNumber(item.fileSize)} bytes` : ''}
{item.checksum ? `${item.checksum.slice(0, 16)}` : ''}
</div>
</div>
);
}
function ItemDialog({ function ItemDialog({
open, open,
onOpenChange, onOpenChange,
@@ -673,7 +702,10 @@ export function QuotationDetail({
currentUserId, currentUserId,
canPreviewDocument, canPreviewDocument,
canPreviewPdf, canPreviewPdf,
canPreviewCustomerPackage,
canDownloadPdf, canDownloadPdf,
canGenerateCustomerPackage,
canDownloadCustomerPackage,
canGenerateApprovedPdf canGenerateApprovedPdf
}: { }: {
quotationId: string; quotationId: string;
@@ -694,7 +726,10 @@ export function QuotationDetail({
currentUserId: string; currentUserId: string;
canPreviewDocument: boolean; canPreviewDocument: boolean;
canPreviewPdf: boolean; canPreviewPdf: boolean;
canPreviewCustomerPackage: boolean;
canDownloadPdf: boolean; canDownloadPdf: boolean;
canGenerateCustomerPackage: boolean;
canDownloadCustomerPackage: boolean;
canGenerateApprovedPdf: boolean; canGenerateApprovedPdf: boolean;
}) { }) {
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId)); const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
@@ -705,6 +740,12 @@ export function QuotationDetail({
const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId)); const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId));
const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId)); const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId));
const quotation = data.quotation; const quotation = data.quotation;
const [customerPackageError, setCustomerPackageError] = useState<string | null>(null);
const [confirmRegenerateOpen, setConfirmRegenerateOpen] = useState(false);
const customerPackageStatus = getCustomerPackageUiStatus({
customerPackage: quotation.customerPackage,
lastErrorMessage: customerPackageError
});
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [itemOpen, setItemOpen] = useState(false); const [itemOpen, setItemOpen] = useState(false);
@@ -844,6 +885,44 @@ export function QuotationDetail({
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval') toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
}); });
const generateCustomerPackage = useMutation({
...generateQuotationCustomerPackageMutation,
onSuccess: () => {
setCustomerPackageError(null);
setConfirmRegenerateOpen(false);
toast.success('Customer package generated successfully');
},
onError: (error) => {
const message = mapCustomerPackageErrorMessage(
error instanceof Error ? error.message : null
);
setCustomerPackageError(message);
setConfirmRegenerateOpen(false);
toast.error(message);
}
});
const downloadCustomerPackage = useMutation({
mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
onSuccess: async ({ blob, fileName }) => {
setCustomerPackageError(null);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
toast.success('Customer package downloaded successfully');
},
onError: (error) => {
const message = mapCustomerPackageErrorMessage(
error instanceof Error ? error.message : null
);
setCustomerPackageError(message);
toast.error(message);
}
});
const generateApprovedPdf = useMutation({ const generateApprovedPdf = useMutation({
mutationFn: async () => { mutationFn: async () => {
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, { const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
@@ -925,6 +1004,20 @@ export function QuotationDetail({
const customer = customerMap.get(quotation.customerId); const customer = customerMap.get(quotation.customerId);
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null; const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
const status = statusMap.get(quotation.status); const status = statusMap.get(quotation.status);
const 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( const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
status?.code ?? '' status?.code ?? ''
); );
@@ -1075,6 +1168,31 @@ export function QuotationDetail({
onOpenChange={setEditOpen} onOpenChange={setEditOpen}
referenceData={referenceData} referenceData={referenceData}
/> />
<Dialog open={confirmRegenerateOpen} onOpenChange={setConfirmRegenerateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate Customer Package?</DialogTitle>
<DialogDescription>
A new assembled package artifact will replace the current active package for this quotation.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant='outline'
onClick={() => setConfirmRegenerateOpen(false)}
disabled={generateCustomerPackage.isPending}
>
Cancel
</Button>
<Button
isLoading={generateCustomerPackage.isPending}
onClick={() => generateCustomerPackage.mutate({ quotationId })}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'> <div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
<div className='space-y-3'> <div className='space-y-3'>
@@ -1162,6 +1280,131 @@ export function QuotationDetail({
</div> </div>
</div> </div>
<Card>
<CardHeader className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<CardTitle>Customer Package</CardTitle>
<CardDescription>
Generate and download the assembled customer-facing quotation package.
</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
{canGenerateCustomerPackage ? (
<Button
variant='outline'
isLoading={generateCustomerPackage.isPending}
onClick={() =>
customerPackage
? setConfirmRegenerateOpen(true)
: generateCustomerPackage.mutate({ quotationId })
}
>
<Icons.badgeCheck className='mr-2 h-4 w-4' />
{customerPackageActionLabel}
</Button>
) : null}
{canPreviewCustomerPackage ? (
<Button asChild variant='outline' disabled={!customerPackage}>
<Link
href={`/api/crm/quotations/${quotationId}/assembled-pdf`}
target='_blank'
aria-disabled={!customerPackage}
onClick={(event) => {
if (!customerPackage) {
event.preventDefault();
}
}}
>
<Icons.eyeOff className='mr-2 h-4 w-4' /> View Customer Package
</Link>
</Button>
) : null}
{canDownloadCustomerPackage ? (
<Button
variant='outline'
disabled={!customerPackage}
isLoading={downloadCustomerPackage.isPending}
onClick={() => downloadCustomerPackage.mutate()}
>
<Icons.download className='mr-2 h-4 w-4' /> Download Customer Package
</Button>
) : null}
</div>
</CardHeader>
<CardContent className='space-y-4'>
{!canGenerateCustomerPackage && !canDownloadCustomerPackage && !canPreviewCustomerPackage ? (
<EmptyState message='You do not have permission to generate or download customer package.' />
) : (
<>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Status' value={customerPackageStatusLabel} />
<FieldItem
label='Generated At'
value={customerPackage?.generatedAt ? formatDateTime(customerPackage.generatedAt) : null}
/>
<FieldItem
label='Generated By'
value={customerPackage?.generatedByName ?? customerPackage?.generatedBy ?? null}
/>
<FieldItem
label='Page Count'
value={
customerPackage?.pageCount !== null && customerPackage?.pageCount !== undefined
? String(customerPackage.pageCount)
: null
}
/>
<FieldItem
label='File Size'
value={customerPackage?.fileSize ? `${formatNumber(customerPackage.fileSize)} bytes` : null}
/>
<FieldItem
label='Checksum'
value={customerPackage?.checksum ? customerPackage.checksum.slice(0, 16) : null}
/>
<FieldItem label='File Name' value={customerPackage?.fileName ?? null} />
<FieldItem
label='Included Documents'
value={
customerPackage?.includedDocuments.length
? String(customerPackage.includedDocuments.length)
: null
}
/>
</div>
<div className='rounded-lg border border-dashed p-4 text-sm'>
{customerPackageStatusDescription}
</div>
{customerPackage?.warnings.length ? (
<div className='space-y-2'>
<div className='text-sm font-medium'>Warnings</div>
{customerPackage.warnings.map((warning) => (
<div key={`${warning.code}-${warning.role}`} className='rounded-lg border p-3 text-sm'>
{warning.message}
</div>
))}
</div>
) : null}
<div className='space-y-3'>
<div className='text-sm font-medium'>Included Documents</div>
{!customerPackage?.includedDocuments.length ? (
<EmptyState message='No assembled package has been generated yet.' />
) : (
<div className='grid gap-3 md:grid-cols-2'>
{customerPackage.includedDocuments.map((item) => (
<CustomerPackageSourceItem
key={`${item.role}-${item.fileName}-${item.checksum ?? 'no-checksum'}`}
item={item}
/>
))}
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
<div className='grid gap-6 lg:grid-cols-3'> <div className='grid gap-6 lg:grid-cols-3'>
<div className='space-y-6 lg:col-span-2'> <div className='space-y-6 lg:col-span-2'>
<Card> <Card>

View 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'
);
});

View 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';
}

View File

@@ -19,6 +19,10 @@ import { AuthError } from '@/lib/auth/session';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { listAuditLogs } from '@/features/foundation/audit-log/service'; import { listAuditLogs } from '@/features/foundation/audit-log/service';
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
import {
getActiveArtifactForEntity,
type DocumentArtifactRecord
} from '@/features/foundation/document-artifact/server/service';
import { import {
type CrmSecurityContext, type CrmSecurityContext,
canAccessScopedCrmRecord canAccessScopedCrmRecord
@@ -47,6 +51,9 @@ import type {
QuotationApprovedArtifactSummary, QuotationApprovedArtifactSummary,
QuotationBranchOption, QuotationBranchOption,
QuotationContactLookup, QuotationContactLookup,
QuotationCustomerPackageSource,
QuotationCustomerPackageSummary,
QuotationCustomerPackageWarning,
QuotationCustomerListItem, QuotationCustomerListItem,
QuotationCustomerLookup, QuotationCustomerLookup,
QuotationCustomerMutationPayload, QuotationCustomerMutationPayload,
@@ -133,10 +140,99 @@ function mapApprovedArtifactSummary(
}; };
} }
function toCustomerPackageSource(
value: unknown
): QuotationCustomerPackageSource | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const source = value as Record<string, unknown>;
if (typeof source.role !== 'string' || typeof source.fileName !== 'string') {
return null;
}
if (source.sourceType !== 'generated' && source.sourceType !== 'document_library') {
return null;
}
return {
role: source.role,
sourceType: source.sourceType,
fileName: source.fileName,
checksum: typeof source.checksum === 'string' ? source.checksum : null,
fileSize: typeof source.fileSize === 'number' ? source.fileSize : null,
pageCount: typeof source.pageCount === 'number' ? source.pageCount : null,
metadata:
source.metadata && typeof source.metadata === 'object' && !Array.isArray(source.metadata)
? (source.metadata as Record<string, unknown>)
: null
};
}
function toCustomerPackageWarning(
value: unknown
): QuotationCustomerPackageWarning | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const warning = value as Record<string, unknown>;
if (
typeof warning.code !== 'string' ||
typeof warning.role !== 'string' ||
typeof warning.message !== 'string'
) {
return null;
}
return {
code: warning.code,
role: warning.role,
message: warning.message
};
}
function mapCustomerPackageSummary(
artifact: DocumentArtifactRecord
): QuotationCustomerPackageSummary {
const metadata =
artifact.metadata && typeof artifact.metadata === 'object' && !Array.isArray(artifact.metadata)
? (artifact.metadata as Record<string, unknown>)
: null;
const includedDocuments = Array.isArray(metadata?.sourceArtifacts)
? metadata.sourceArtifacts
.map((item) => toCustomerPackageSource(item))
.filter((item): item is QuotationCustomerPackageSource => item !== null)
: [];
const warnings = Array.isArray(metadata?.warnings)
? metadata.warnings
.map((item) => toCustomerPackageWarning(item))
.filter((item): item is QuotationCustomerPackageWarning => item !== null)
: [];
return {
artifactId: artifact.id,
fileName: artifact.fileName,
contentType: artifact.contentType,
fileSize: artifact.fileSize,
checksum: artifact.checksum,
status: artifact.status,
pageCount: typeof metadata?.pageCount === 'number' ? metadata.pageCount : null,
generatedAt: artifact.generatedAt,
generatedBy: artifact.generatedBy,
generatedByName: artifact.generatedByName,
includedDocuments,
warnings
};
}
function mapQuotationRecord( function mapQuotationRecord(
row: typeof crmQuotations.$inferSelect, row: typeof crmQuotations.$inferSelect,
options?: { options?: {
approvedArtifact?: QuotationApprovedArtifactSummary | null; approvedArtifact?: QuotationApprovedArtifactSummary | null;
customerPackage?: QuotationCustomerPackageSummary | null;
branchName?: string | null; branchName?: string | null;
branchCode?: string | null; branchCode?: string | null;
salesmanName?: string | null; salesmanName?: string | null;
@@ -188,6 +284,7 @@ function mapQuotationRecord(
approvedSnapshot: row.approvedSnapshot ?? null, approvedSnapshot: row.approvedSnapshot ?? null,
approvedTemplateVersionId: row.approvedTemplateVersionId, approvedTemplateVersionId: row.approvedTemplateVersionId,
approvedArtifact: options?.approvedArtifact ?? null, approvedArtifact: options?.approvedArtifact ?? null,
customerPackage: options?.customerPackage ?? null,
hasLegacyApprovedPdf: hasLegacyApprovedPdf:
!row.approvedArtifactId && !row.approvedArtifactId &&
typeof row.approvedPdfUrl === 'string' && typeof row.approvedPdfUrl === 'string' &&
@@ -1240,6 +1337,12 @@ export async function getQuotationDetail(
}) })
]); ]);
const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null; const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null;
const customerPackageArtifactPromise = getActiveArtifactForEntity({
organizationId,
entityType: 'crm_quotation',
entityId: id,
artifactType: 'assembled_pdf'
});
const baseDisplayOptions = { const baseDisplayOptions = {
branchName: branchDisplay?.name ?? null, branchName: branchDisplay?.name ?? null,
branchCode: branchDisplay?.code ?? null, branchCode: branchDisplay?.code ?? null,
@@ -1256,7 +1359,13 @@ export async function getQuotationDetail(
: null); : null);
if (!approvedArtifactId) { if (!approvedArtifactId) {
return mapQuotationRecord(quotation, baseDisplayOptions); const customerPackageArtifact = await customerPackageArtifactPromise;
return mapQuotationRecord(quotation, {
...baseDisplayOptions,
customerPackage: customerPackageArtifact
? mapCustomerPackageSummary(customerPackageArtifact)
: null
});
} }
const [artifact] = await db const [artifact] = await db
@@ -1272,10 +1381,19 @@ export async function getQuotationDetail(
.limit(1); .limit(1);
if (!artifact) { if (!artifact) {
return mapQuotationRecord(quotation); const customerPackageArtifact = await customerPackageArtifactPromise;
return mapQuotationRecord(quotation, {
...baseDisplayOptions,
customerPackage: customerPackageArtifact
? mapCustomerPackageSummary(customerPackageArtifact)
: null
});
} }
const artifactActorMap = await resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]); const [artifactActorMap, customerPackageArtifact] = await Promise.all([
resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]),
customerPackageArtifactPromise
]);
return mapQuotationRecord(quotation, { return mapQuotationRecord(quotation, {
...baseDisplayOptions, ...baseDisplayOptions,
@@ -1284,7 +1402,10 @@ export async function getQuotationDetail(
lockedByName: artifact.lockedBy lockedByName: artifact.lockedBy
? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null) ? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null)
: null : null
}) }),
customerPackage: customerPackageArtifact
? mapCustomerPackageSummary(customerPackageArtifact)
: null
}); });
} }

View File

@@ -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'
});
});

View File

@@ -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
};
}

View File

@@ -1,38 +1,43 @@
import { and, asc, eq, ne, sql } from 'drizzle-orm'; import { and, asc, eq, ne, sql } from "drizzle-orm";
import { documentSequences, organizations } from '@/db/schema'; import { documentSequences, organizations } from "@/db/schema";
import { auditAction } from '@/features/foundation/audit-log/service'; import { auditAction } from "@/features/foundation/audit-log/service";
import { resolveBranchDisplays } from '@/features/foundation/display/server/display-resolver'; import { resolveBranchDisplays } from "@/features/foundation/display/server/display-resolver";
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service";
import { getCurrentOrganization } from '@/features/foundation/organization-context/service'; import { getCurrentOrganization } from "@/features/foundation/organization-context/service";
import { db } from '@/lib/db'; import { db } from "@/lib/db";
import { AuthError } from '@/lib/auth/session'; import { AuthError } from "@/lib/auth/session";
import { import {
DOCUMENT_SEQUENCE_DEFAULT_FORMAT, DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY, DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE, DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE,
normalizeDocumentSequenceProductType, normalizeDocumentSequenceProductType,
resolveDocumentSequenceOrganizationCode, resolveDocumentSequenceOrganizationCode,
resolveDocumentSequenceProductTypeCode resolveDocumentSequenceProductTypeCode,
} from './config'; } from "./config";
import {
resolveDocumentSequenceProductTypeInput,
type DocumentSequenceProductTypeLookupOption,
} from "./product-type-resolver";
import type { import type {
DocumentSequenceInput, DocumentSequenceInput,
DocumentSequenceListItem, DocumentSequenceListItem,
DocumentSequenceMutationPayload, DocumentSequenceMutationPayload,
DocumentSequenceRecord, DocumentSequenceRecord,
DocumentSequenceResetPayload, DocumentSequenceResetPayload,
DocumentSequenceResult DocumentSequenceResult,
} from './types'; } from "./types";
const PRODUCT_TYPE_CATEGORY = 'crm_product_type'; const PRODUCT_TYPE_CATEGORY = "crm_product_type";
const QUOTATION_TYPE_CATEGORY = "crm_quotation_type";
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = { const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
customer: 'CUS', customer: "CUS",
contact: 'CON', contact: "CON",
opportunity: 'ENQ', opportunity: "ENQ",
crm_lead: 'LD', crm_lead: "LD",
crm_opportunity: 'EN', crm_opportunity: "EN",
quotation: 'QT', quotation: "QT",
approval: 'APV' approval: "APV",
}; };
type OrganizationRecord = { type OrganizationRecord = {
@@ -43,19 +48,19 @@ type OrganizationRecord = {
function getCurrentPeriod(date = new Date()) { function getCurrentPeriod(date = new Date()) {
const year = String(date.getFullYear()).slice(-2); const year = String(date.getFullYear()).slice(-2);
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}${month}`; return `${year}${month}`;
} }
function normalizeBranchId(branchId?: string | null) { function normalizeBranchId(branchId?: string | null) {
return branchId?.trim() || ''; return branchId?.trim() || "";
} }
function normalizeDocumentType(documentType: string) { function normalizeDocumentType(documentType: string) {
const normalized = documentType.trim(); const normalized = documentType.trim();
if (!normalized) { if (!normalized) {
throw new AuthError('Document type is required.', 400); throw new AuthError("Document type is required.", 400);
} }
return normalized; return normalized;
@@ -65,11 +70,11 @@ function normalizePeriod(period?: string | null) {
const normalized = period?.trim(); const normalized = period?.trim();
if (!normalized) { if (!normalized) {
throw new AuthError('Period is required.', 400); throw new AuthError("Period is required.", 400);
} }
if (!/^\d{4}$/.test(normalized)) { if (!/^\d{4}$/.test(normalized)) {
throw new AuthError('Period must use YYMM format.', 400); throw new AuthError("Period must use YYMM format.", 400);
} }
return normalized; return normalized;
@@ -89,7 +94,7 @@ function formatBranchDisplayName(input: {
branchName?: string | null; branchName?: string | null;
}) { }) {
if (!input.branchId) { if (!input.branchId) {
return 'All Branches'; return "All Branches";
} }
if (input.branchCode && input.branchName) { if (input.branchCode && input.branchName) {
@@ -108,28 +113,30 @@ function buildDocumentCode(
prefix: string, prefix: string,
period: string, period: string,
nextNumber: number, nextNumber: number,
paddingLength: number paddingLength: number,
) { ) {
const running = String(nextNumber).padStart(paddingLength, '0'); const running = String(nextNumber).padStart(paddingLength, "0");
return format return format
.replaceAll('{prefix}', prefix) .replaceAll("{prefix}", prefix)
.replaceAll('{period}', period) .replaceAll("{period}", period)
.replaceAll('{running}', running); .replaceAll("{running}", running);
} }
async function resolveOrganizationRecord(organizationId?: string): Promise<OrganizationRecord> { async function resolveOrganizationRecord(
organizationId?: string,
): Promise<OrganizationRecord> {
if (!organizationId) { if (!organizationId) {
const organization = await getCurrentOrganization(); const organization = await getCurrentOrganization();
if (!organization) { if (!organization) {
throw new AuthError('Active organization required.', 400); throw new AuthError("Active organization required.", 400);
} }
return { return {
id: organization.id, id: organization.id,
name: organization.name, name: organization.name,
slug: organization.slug slug: organization.slug,
}; };
} }
@@ -137,21 +144,21 @@ async function resolveOrganizationRecord(organizationId?: string): Promise<Organ
.select({ .select({
id: organizations.id, id: organizations.id,
name: organizations.name, name: organizations.name,
slug: organizations.slug slug: organizations.slug,
}) })
.from(organizations) .from(organizations)
.where(eq(organizations.id, organizationId)) .where(eq(organizations.id, organizationId))
.limit(1); .limit(1);
if (!organization) { if (!organization) {
throw new AuthError('Organization not found.', 404); throw new AuthError("Organization not found.", 404);
} }
return organization; return organization;
} }
async function resolveProductTypeMap(organizationId: string) { async function resolveOptionMap(category: string, organizationId: string) {
const options = await getActiveOptionsByCategory(PRODUCT_TYPE_CATEGORY, { organizationId }); const options = await getActiveOptionsByCategory(category, { organizationId });
return new Map( return new Map(
options.map((option) => [ options.map((option) => [
@@ -159,45 +166,105 @@ async function resolveProductTypeMap(organizationId: string) {
{ {
id: option.id, id: option.id,
code: normalizeDocumentSequenceProductType(option.code), code: normalizeDocumentSequenceProductType(option.code),
label: option.label label: option.label,
} },
]) ]),
); );
} }
async function resolveProductTypeMap(organizationId: string) {
return resolveOptionMap(PRODUCT_TYPE_CATEGORY, organizationId);
}
async function resolveQuotationTypeMap(organizationId: string) {
return resolveOptionMap(QUOTATION_TYPE_CATEGORY, organizationId);
}
function buildProductTypeResolutionDetails(input: {
organizationId: string;
documentType: string;
requestedProductType: string | null;
normalizedRequestedProductType: string | null;
productTypeOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
referenceOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
}) {
return {
organizationId: input.organizationId,
documentType: input.documentType,
requestedProductType: input.requestedProductType,
normalizedRequestedProductType: input.normalizedRequestedProductType,
productTypeOptionIds: input.productTypeOptions.map((option) => option.id),
productTypeOptionCodes: input.productTypeOptions.map((option) => option.code),
referenceOptionIds: input.referenceOptions.map((option) => option.id),
referenceOptionCodes: input.referenceOptions.map((option) => option.code),
};
}
function buildProductTypeResolutionError(input: {
organizationId: string;
documentType: string;
requestedProductType: string | null;
normalizedRequestedProductType: string | null;
productTypeOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
referenceOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
}) {
const error = new AuthError(
"Missing product type code configuration for quotation type.",
400,
) as AuthError & {
details: ReturnType<typeof buildProductTypeResolutionDetails>;
};
error.details = buildProductTypeResolutionDetails(input);
return error;
}
async function resolveScopedProductType(input: { async function resolveScopedProductType(input: {
organizationId: string; organizationId: string;
documentType: string; documentType: string;
productType?: string | null; productType?: string | null;
}) { }) {
const normalizedDocumentType = normalizeDocumentType(input.documentType); const normalizedDocumentType = normalizeDocumentType(input.documentType);
const requestedProductType = input.productType?.trim(); const requestedProductType = input.productType?.trim() ?? null;
if (normalizedDocumentType !== 'quotation') { if (normalizedDocumentType !== "quotation") {
return requestedProductType return requestedProductType
? normalizeDocumentSequenceProductType(requestedProductType) ? normalizeDocumentSequenceProductType(requestedProductType)
: DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE; : DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE;
} }
if (!requestedProductType) { if (!requestedProductType) {
throw new AuthError('Product type is required for quotation document sequences.', 400); throw new AuthError(
"Product type required for quotation document sequences.",
400,
);
} }
const productTypeMap = await resolveProductTypeMap(input.organizationId); const productTypeOptions = [
const normalizedRequestedProductType = ...(await resolveProductTypeMap(input.organizationId)).values(),
normalizeDocumentSequenceProductType(requestedProductType); ];
const referenceOptions = [
...(await resolveQuotationTypeMap(input.organizationId)).values(),
];
const resolution = resolveDocumentSequenceProductTypeInput({
documentType: normalizedDocumentType,
productType: requestedProductType,
options: productTypeOptions,
referenceOptions,
});
const byCode = productTypeMap.get(normalizedRequestedProductType); if (resolution.productType) {
if (byCode) { return resolution.productType;
return byCode.code;
} }
const byId = [...productTypeMap.values()].find((option) => option.id === requestedProductType); throw buildProductTypeResolutionError({
if (byId) { organizationId: input.organizationId,
return byId.code; documentType: normalizedDocumentType,
} requestedProductType,
normalizedRequestedProductType: resolution.normalizedRequestedProductType,
throw new AuthError('Missing product type code configuration for this quotation type.', 400); productTypeOptions,
referenceOptions,
});
} }
async function resolvePrefix(input: { async function resolvePrefix(input: {
@@ -211,21 +278,31 @@ async function resolvePrefix(input: {
return manualPrefix; return manualPrefix;
} }
if (input.documentType !== 'quotation') { if (input.documentType !== "quotation") {
return DEFAULT_DOCUMENT_PREFIXES[input.documentType] ?? input.documentType.slice(0, 3).toUpperCase(); return (
} DEFAULT_DOCUMENT_PREFIXES[input.documentType] ??
input.documentType.slice(0, 3).toUpperCase()
const organizationCode = resolveDocumentSequenceOrganizationCode(input.organization.slug);
if (!organizationCode) {
throw new AuthError(
`Missing organization code configuration for ${input.organization.slug}.`,
400
); );
} }
const productTypeCode = resolveDocumentSequenceProductTypeCode(input.productType); const organizationCode = resolveDocumentSequenceOrganizationCode(
input.organization.slug,
);
if (!organizationCode) {
throw new AuthError(
`Missing organization code configuration for ${input.organization.slug}.`,
400,
);
}
const productTypeCode = resolveDocumentSequenceProductTypeCode(
input.productType,
);
if (!productTypeCode) { if (!productTypeCode) {
throw new AuthError(`Missing product type code configuration for ${input.productType}.`, 400); throw new AuthError(
`Missing product type code configuration for ${input.productType}.`,
400,
);
} }
return `${productTypeCode}${organizationCode}`; return `${productTypeCode}${organizationCode}`;
@@ -233,11 +310,14 @@ async function resolvePrefix(input: {
async function assertSequence(id: string, organizationId: string) { async function assertSequence(id: string, organizationId: string) {
const row = await db.query.documentSequences.findFirst({ const row = await db.query.documentSequences.findFirst({
where: and(eq(documentSequences.id, id), eq(documentSequences.organizationId, organizationId)) where: and(
eq(documentSequences.id, id),
eq(documentSequences.organizationId, organizationId),
),
}); });
if (!row) { if (!row) {
throw new AuthError('Document sequence not found.', 404); throw new AuthError("Document sequence not found.", 404);
} }
return row; return row;
@@ -258,36 +338,42 @@ async function assertUniqueScope(input: {
eq(documentSequences.productType, input.productType), eq(documentSequences.productType, input.productType),
eq(documentSequences.documentType, input.documentType), eq(documentSequences.documentType, input.documentType),
eq(documentSequences.period, input.period), eq(documentSequences.period, input.period),
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : []) ...(input.currentId ? [ne(documentSequences.id, input.currentId)] : []),
) ),
}); });
if (existing) { if (existing) {
throw new AuthError( throw new AuthError(
'Duplicate document sequence scope for organization, branch, product type, document type, and period.', "Duplicate document sequence scope for organization, branch, product type, document type, and period.",
409 409,
); );
} }
} }
async function mapSequenceRecords( async function mapSequenceRecords(
organization: OrganizationRecord, organization: OrganizationRecord,
rows: Array<typeof documentSequences.$inferSelect> rows: Array<typeof documentSequences.$inferSelect>,
) { ) {
const [branchDisplayMap, productTypeMap] = await Promise.all([ const [branchDisplayMap, productTypeMap] = await Promise.all([
resolveBranchDisplays({ resolveBranchDisplays({
organizationId: organization.id, organizationId: organization.id,
branchIds: rows.map((row) => row.branchId || null) branchIds: rows.map((row) => row.branchId || null),
}), }),
resolveProductTypeMap(organization.id) resolveProductTypeMap(organization.id),
]); ]);
const organizationCode = resolveDocumentSequenceOrganizationCode(organization.slug); const organizationCode = resolveDocumentSequenceOrganizationCode(
organization.slug,
);
return rows.map((row) => { return rows.map((row) => {
const branchId = row.branchId || null; const branchId = row.branchId || null;
const branchDisplay = branchId ? (branchDisplayMap.get(branchId) ?? null) : null; const branchDisplay = branchId
const normalizedProductType = normalizeDocumentSequenceProductType(row.productType); ? (branchDisplayMap.get(branchId) ?? null)
: null;
const normalizedProductType = normalizeDocumentSequenceProductType(
row.productType,
);
const productType = productTypeMap.get(normalizedProductType) ?? null; const productType = productTypeMap.get(normalizedProductType) ?? null;
return { return {
@@ -301,17 +387,17 @@ async function mapSequenceRecords(
branchDisplayName: formatBranchDisplayName({ branchDisplayName: formatBranchDisplayName({
branchId, branchId,
branchCode: branchDisplay?.code ?? null, branchCode: branchDisplay?.code ?? null,
branchName: branchDisplay?.name ?? null branchName: branchDisplay?.name ?? null,
}), }),
productType: normalizedProductType, productType: normalizedProductType,
productTypeLabel: productTypeLabel:
normalizedProductType === DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE normalizedProductType === DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE
? 'All Product Types' ? "All Product Types"
: normalizedProductType === 'legacy' : normalizedProductType === "legacy"
? 'Legacy' ? "Legacy"
: productType?.label ?? null, : (productType?.label ?? null),
productTypeSequenceCode: productTypeSequenceCode:
normalizedProductType === 'legacy' normalizedProductType === "legacy"
? null ? null
: resolveDocumentSequenceProductTypeCode(normalizedProductType), : resolveDocumentSequenceProductTypeCode(normalizedProductType),
documentType: row.documentType, documentType: row.documentType,
@@ -323,19 +409,21 @@ async function mapSequenceRecords(
resetPolicy: row.resetPolicy, resetPolicy: row.resetPolicy,
isActive: row.isActive, isActive: row.isActive,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString() updatedAt: row.updatedAt.toISOString(),
} satisfies DocumentSequenceRecord; } satisfies DocumentSequenceRecord;
}); });
} }
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult { function toSequenceResult(
row: typeof documentSequences.$inferSelect,
): DocumentSequenceResult {
return { return {
code: buildDocumentCode( code: buildDocumentCode(
row.format, row.format,
row.prefix, row.prefix,
row.period, row.period,
row.currentNumber + 1, row.currentNumber + 1,
row.paddingLength row.paddingLength,
), ),
documentType: row.documentType, documentType: row.documentType,
productType: row.productType, productType: row.productType,
@@ -343,12 +431,12 @@ function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentS
currentNumber: row.currentNumber, currentNumber: row.currentNumber,
nextNumber: row.currentNumber + 1, nextNumber: row.currentNumber + 1,
period: row.period, period: row.period,
prefix: row.prefix prefix: row.prefix,
}; };
} }
export async function listDocumentSequences( export async function listDocumentSequences(
organizationId: string organizationId: string,
): Promise<DocumentSequenceListItem[]> { ): Promise<DocumentSequenceListItem[]> {
const organization = await resolveOrganizationRecord(organizationId); const organization = await resolveOrganizationRecord(organizationId);
const rows = await db const rows = await db
@@ -359,7 +447,7 @@ export async function listDocumentSequences(
asc(documentSequences.documentType), asc(documentSequences.documentType),
asc(documentSequences.productType), asc(documentSequences.productType),
asc(documentSequences.period), asc(documentSequences.period),
asc(documentSequences.branchId) asc(documentSequences.branchId),
); );
const records = await mapSequenceRecords(organization, rows); const records = await mapSequenceRecords(organization, rows);
@@ -371,14 +459,14 @@ export async function listDocumentSequences(
record.prefix, record.prefix,
record.period, record.period,
record.currentNumber + 1, record.currentNumber + 1,
record.paddingLength record.paddingLength,
) ),
})); }));
} }
export async function getDocumentSequence( export async function getDocumentSequence(
id: string, id: string,
organizationId: string organizationId: string,
): Promise<DocumentSequenceRecord> { ): Promise<DocumentSequenceRecord> {
const organization = await resolveOrganizationRecord(organizationId); const organization = await resolveOrganizationRecord(organizationId);
const row = await assertSequence(id, organization.id); const row = await assertSequence(id, organization.id);
@@ -389,7 +477,7 @@ export async function getDocumentSequence(
export async function createDocumentSequence( export async function createDocumentSequence(
organizationId: string, organizationId: string,
payload: DocumentSequenceMutationPayload payload: DocumentSequenceMutationPayload,
): Promise<DocumentSequenceRecord> { ): Promise<DocumentSequenceRecord> {
const organization = await resolveOrganizationRecord(organizationId); const organization = await resolveOrganizationRecord(organizationId);
const documentType = normalizeDocumentType(payload.documentType); const documentType = normalizeDocumentType(payload.documentType);
@@ -397,7 +485,7 @@ export async function createDocumentSequence(
const productType = await resolveScopedProductType({ const productType = await resolveScopedProductType({
organizationId: organization.id, organizationId: organization.id,
documentType, documentType,
productType: payload.productType productType: payload.productType,
}); });
const period = normalizePeriod(payload.period); const period = normalizePeriod(payload.period);
@@ -406,7 +494,7 @@ export async function createDocumentSequence(
branchId, branchId,
productType, productType,
documentType, documentType,
period period,
}); });
const [created] = await db const [created] = await db
@@ -421,7 +509,7 @@ export async function createDocumentSequence(
organization, organization,
documentType, documentType,
productType, productType,
prefix: payload.prefix prefix: payload.prefix,
}), }),
period, period,
currentNumber: payload.currentNumber ?? 0, currentNumber: payload.currentNumber ?? 0,
@@ -429,7 +517,7 @@ export async function createDocumentSequence(
format: normalizeFormat(payload.format), format: normalizeFormat(payload.format),
resetPolicy: normalizeResetPolicy(payload.resetPolicy), resetPolicy: normalizeResetPolicy(payload.resetPolicy),
isActive: payload.isActive ?? true, isActive: payload.isActive ?? true,
updatedAt: new Date() updatedAt: new Date(),
}) })
.returning(); .returning();
@@ -440,17 +528,21 @@ export async function createDocumentSequence(
export async function updateDocumentSequence( export async function updateDocumentSequence(
id: string, id: string,
organizationId: string, organizationId: string,
payload: Partial<DocumentSequenceMutationPayload> payload: Partial<DocumentSequenceMutationPayload>,
): Promise<DocumentSequenceRecord> { ): Promise<DocumentSequenceRecord> {
const organization = await resolveOrganizationRecord(organizationId); const organization = await resolveOrganizationRecord(organizationId);
const current = await assertSequence(id, organization.id); const current = await assertSequence(id, organization.id);
const documentType = normalizeDocumentType(payload.documentType ?? current.documentType); const documentType = normalizeDocumentType(
payload.documentType ?? current.documentType,
);
const branchId = const branchId =
payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId); payload.branchId === undefined
? current.branchId
: normalizeBranchId(payload.branchId);
const productType = await resolveScopedProductType({ const productType = await resolveScopedProductType({
organizationId: organization.id, organizationId: organization.id,
documentType, documentType,
productType: payload.productType ?? current.productType productType: payload.productType ?? current.productType,
}); });
const period = normalizePeriod(payload.period ?? current.period); const period = normalizePeriod(payload.period ?? current.period);
@@ -460,7 +552,7 @@ export async function updateDocumentSequence(
productType, productType,
documentType, documentType,
period, period,
currentId: id currentId: id,
}); });
const [updated] = await db const [updated] = await db
@@ -473,15 +565,17 @@ export async function updateDocumentSequence(
organization, organization,
documentType, documentType,
productType, productType,
prefix: payload.prefix ?? current.prefix prefix: payload.prefix ?? current.prefix,
}), }),
period, period,
currentNumber: payload.currentNumber ?? current.currentNumber, currentNumber: payload.currentNumber ?? current.currentNumber,
paddingLength: payload.paddingLength ?? current.paddingLength, paddingLength: payload.paddingLength ?? current.paddingLength,
format: normalizeFormat(payload.format ?? current.format), format: normalizeFormat(payload.format ?? current.format),
resetPolicy: normalizeResetPolicy(payload.resetPolicy ?? current.resetPolicy), resetPolicy: normalizeResetPolicy(
payload.resetPolicy ?? current.resetPolicy,
),
isActive: payload.isActive ?? current.isActive, isActive: payload.isActive ?? current.isActive,
updatedAt: new Date() updatedAt: new Date(),
}) })
.where(eq(documentSequences.id, id)) .where(eq(documentSequences.id, id))
.returning(); .returning();
@@ -493,7 +587,7 @@ export async function updateDocumentSequence(
export async function resetDocumentSequence( export async function resetDocumentSequence(
id: string, id: string,
organizationId: string, organizationId: string,
payload: DocumentSequenceResetPayload payload: DocumentSequenceResetPayload,
): Promise<DocumentSequenceRecord> { ): Promise<DocumentSequenceRecord> {
const organization = await resolveOrganizationRecord(organizationId); const organization = await resolveOrganizationRecord(organizationId);
await assertSequence(id, organization.id); await assertSequence(id, organization.id);
@@ -502,7 +596,7 @@ export async function resetDocumentSequence(
.update(documentSequences) .update(documentSequences)
.set({ .set({
currentNumber: payload.currentNumber, currentNumber: payload.currentNumber,
updatedAt: new Date() updatedAt: new Date(),
}) })
.where(eq(documentSequences.id, id)) .where(eq(documentSequences.id, id))
.returning(); .returning();
@@ -511,7 +605,10 @@ export async function resetDocumentSequence(
return record; return record;
} }
export async function deleteDocumentSequence(id: string, organizationId: string) { export async function deleteDocumentSequence(
id: string,
organizationId: string,
) {
const organization = await resolveOrganizationRecord(organizationId); const organization = await resolveOrganizationRecord(organizationId);
const sequence = await assertSequence(id, organization.id); const sequence = await assertSequence(id, organization.id);
await db.delete(documentSequences).where(eq(documentSequences.id, id)); await db.delete(documentSequences).where(eq(documentSequences.id, id));
@@ -520,7 +617,10 @@ export async function deleteDocumentSequence(id: string, organizationId: string)
return record; return record;
} }
export async function previewDocumentSequenceById(id: string, organizationId: string) { export async function previewDocumentSequenceById(
id: string,
organizationId: string,
) {
const row = await assertSequence(id, organizationId); const row = await assertSequence(id, organizationId);
return toSequenceResult(row); return toSequenceResult(row);
} }
@@ -532,7 +632,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
const productType = await resolveScopedProductType({ const productType = await resolveScopedProductType({
organizationId: organization.id, organizationId: organization.id,
documentType, documentType,
productType: input.productType productType: input.productType,
}); });
const period = normalizePeriod(input.period ?? getCurrentPeriod()); const period = normalizePeriod(input.period ?? getCurrentPeriod());
@@ -544,8 +644,8 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
eq(documentSequences.branchId, branchId), eq(documentSequences.branchId, branchId),
eq(documentSequences.productType, productType), eq(documentSequences.productType, productType),
eq(documentSequences.documentType, documentType), eq(documentSequences.documentType, documentType),
eq(documentSequences.period, period) eq(documentSequences.period, period),
) ),
}); });
if (!sequence) { if (!sequence) {
@@ -560,14 +660,14 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
prefix: await resolvePrefix({ prefix: await resolvePrefix({
organization, organization,
documentType, documentType,
productType productType,
}), }),
period, period,
currentNumber: 0, currentNumber: 0,
paddingLength: 3, paddingLength: 3,
format: DOCUMENT_SEQUENCE_DEFAULT_FORMAT, format: DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
resetPolicy: DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY, resetPolicy: DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
isActive: true isActive: true,
}) })
.onConflictDoNothing(); .onConflictDoNothing();
@@ -577,24 +677,24 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
eq(documentSequences.branchId, branchId), eq(documentSequences.branchId, branchId),
eq(documentSequences.productType, productType), eq(documentSequences.productType, productType),
eq(documentSequences.documentType, documentType), eq(documentSequences.documentType, documentType),
eq(documentSequences.period, period) eq(documentSequences.period, period),
) ),
}); });
} }
if (!sequence) { if (!sequence) {
throw new AuthError('Unable to initialize document sequence.', 500); throw new AuthError("Unable to initialize document sequence.", 500);
} }
if (!sequence.isActive) { if (!sequence.isActive) {
throw new AuthError('Document sequence is inactive.', 400); throw new AuthError("Document sequence is inactive.", 400);
} }
const [updated] = await tx const [updated] = await tx
.update(documentSequences) .update(documentSequences)
.set({ .set({
currentNumber: sql`${documentSequences.currentNumber} + 1`, currentNumber: sql`${documentSequences.currentNumber} + 1`,
updatedAt: new Date() updatedAt: new Date(),
}) })
.where(eq(documentSequences.id, sequence.id)) .where(eq(documentSequences.id, sequence.id))
.returning(); .returning();
@@ -607,7 +707,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
updated.prefix, updated.prefix,
updated.period, updated.period,
updated.currentNumber, updated.currentNumber,
updated.paddingLength updated.paddingLength,
), ),
documentType: updated.documentType, documentType: updated.documentType,
productType: updated.productType, productType: updated.productType,
@@ -615,35 +715,42 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
currentNumber: updated.currentNumber, currentNumber: updated.currentNumber,
nextNumber: updated.currentNumber + 1, nextNumber: updated.currentNumber + 1,
period: updated.period, period: updated.period,
prefix: updated.prefix prefix: updated.prefix,
} satisfies DocumentSequenceResult } satisfies DocumentSequenceResult,
}; };
}); });
await auditAction({ await auditAction({
organizationId: organization.id, organizationId: organization.id,
entityType: 'crm_document_sequence', entityType: "crm_document_sequence",
entityId: result.sequenceId, entityId: result.sequenceId,
action: 'generate', action: "generate",
branchId: branchId || null, branchId: branchId || null,
afterData: result.result afterData: result.result,
}); });
return result.result; return result.result;
} catch (error) { } catch (error) {
try { try {
const errorDetails =
error && typeof error === "object" && "details" in error
? ((error as { details?: unknown }).details ?? null)
: null;
await auditAction({ await auditAction({
organizationId: organization.id, organizationId: organization.id,
entityType: 'crm_document_sequence', entityType: "crm_document_sequence",
entityId: `${documentType}:${branchId}:${productType}:${period}`, entityId: `${documentType}:${branchId}:${productType}:${period}`,
action: 'generate_failed', action: "generate_failed",
branchId: branchId || null, branchId: branchId || null,
afterData: { afterData: {
message: error instanceof Error ? error.message : 'Unknown error', message: error instanceof Error ? error.message : "Unknown error",
documentType, documentType,
productType, productType,
period requestedProductType: input.productType?.trim() ?? null,
} period,
details: errorDetails,
},
}); });
} catch { } catch {
// Keep original error as the primary failure signal. // Keep original error as the primary failure signal.

View File

@@ -1,32 +1,94 @@
const BASE_URL = '/api'; const BASE_URL = "/api";
export async function apiClient<T>(endpoint: string, options?: RequestInit): Promise<T> { interface ApiErrorPayload {
const isServer = typeof window === 'undefined'; message?: string;
const requestHeaders = isServer ? await (await import('next/headers')).headers() : null; error?: string;
const host = requestHeaders?.get('x-forwarded-host') ?? requestHeaders?.get('host'); }
const protocol = requestHeaders?.get('x-forwarded-proto') ?? 'http';
const origin = isServer && host ? `${protocol}://${host}` : ''; export class ApiClientError extends Error {
status: number;
statusText: string;
payload: unknown;
constructor(
message: string,
input: { status: number; statusText: string; payload: unknown },
) {
super(message);
this.name = "ApiClientError";
this.status = input.status;
this.statusText = input.statusText;
this.payload = input.payload;
}
}
async function readErrorPayload(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return response.json().catch(() => null);
}
return response.text().catch(() => null);
}
function extractApiErrorMessage(payload: unknown, response: Response): string {
if (payload && typeof payload === "object") {
const candidate = payload as ApiErrorPayload;
if (typeof candidate.message === "string" && candidate.message.trim()) {
return candidate.message.trim();
}
if (typeof candidate.error === "string" && candidate.error.trim()) {
return candidate.error.trim();
}
}
if (typeof payload === "string" && payload.trim()) {
return payload.trim();
}
return `API error: ${response.status} ${response.statusText}`;
}
export async function apiClient<T>(
endpoint: string,
options?: RequestInit,
): Promise<T> {
const isServer = typeof window === "undefined";
const requestHeaders = isServer
? await (await import("next/headers")).headers()
: null;
const host =
requestHeaders?.get("x-forwarded-host") ?? requestHeaders?.get("host");
const protocol = requestHeaders?.get("x-forwarded-proto") ?? "http";
const origin = isServer && host ? `${protocol}://${host}` : "";
const optionHeaders = options?.headers; const optionHeaders = options?.headers;
const restOptions = options ? { ...options } : null; const restOptions = options ? { ...options } : null;
if (restOptions && 'headers' in restOptions) { if (restOptions && "headers" in restOptions) {
delete restOptions.headers; delete restOptions.headers;
} }
const res = await fetch(`${origin}${BASE_URL}${endpoint}`, { const response = await fetch(`${origin}${BASE_URL}${endpoint}`, {
...restOptions, ...restOptions,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
...(isServer && requestHeaders?.get('cookie') ...(isServer && requestHeaders?.get("cookie")
? { cookie: requestHeaders.get('cookie') as string } ? { cookie: requestHeaders.get("cookie") as string }
: {}), : {}),
...optionHeaders ...optionHeaders,
} },
}); });
if (!res.ok) { if (!response.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`); const payload = await readErrorPayload(response);
const message = extractApiErrorMessage(payload, response);
throw new ApiClientError(message, {
status: response.status,
statusText: response.statusText,
payload,
});
} }
return res.json() as Promise<T>; return response.json() as Promise<T>;
} }