uat-doc-perfix
This commit is contained in:
@@ -25,7 +25,7 @@
|
|||||||
"field": "documentData.customer.id",
|
"field": "documentData.customer.id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "96954c53-fd37-4d97-80e0-1809756a015d",
|
"value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"field": "documentData.contact.id",
|
"field": "documentData.contact.id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "3fc91684-ebed-49a9-b50e-0fb6fdaae87f",
|
"value": "951023ad-0424-4c05-9db1-c5004d525e23",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"field": "documentData.quotation.id",
|
"field": "documentData.quotation.id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "bb3ebe66-a0ed-4b48-b125-795b549fa798",
|
"value": "9a7333a0-bb43-4d6e-b178-aae196456a31",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
"field": "documentData.items[0].id",
|
"field": "documentData.items[0].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "ab4102fc-7ad9-4338-aee2-b847c10dbf15",
|
"value": "81cf1a1a-0ae0-4f6d-bb24-1315b0de5363",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
"field": "documentData.items[1].id",
|
"field": "documentData.items[1].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "9c320ba4-cf4b-4b3b-b72e-c588c8723384",
|
"value": "3cfad159-e274-49f4-a195-0e3ae39ee5f9",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
"field": "documentData.items[2].id",
|
"field": "documentData.items[2].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "7f64760c-65d6-4b23-9921-4a33d43a2efc",
|
"value": "3f48546a-2685-462e-a21d-e25ccc1348fd",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -340,7 +340,7 @@
|
|||||||
"field": "documentData.items[3].id",
|
"field": "documentData.items[3].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "4b5a4cef-3005-4253-9638-8c3cc9fade15",
|
"value": "9f0ef323-dbfc-4710-a90f-501f185a165a",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -403,21 +403,21 @@
|
|||||||
"field": "documentData.quotationCustomers[0].id",
|
"field": "documentData.quotationCustomers[0].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "04ff8d80-b200-4c01-8081-24d548a2a576",
|
"value": "fefd5c1a-cc0a-4365-9492-e00feef8d38e",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "documentData.quotationCustomers[0].customerId",
|
"field": "documentData.quotationCustomers[0].customerId",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "96954c53-fd37-4d97-80e0-1809756a015d",
|
"value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "documentData.quotationCustomers[0].role",
|
"field": "documentData.quotationCustomers[0].role",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "a5d93b6c-131b-4e9e-9240-ac94fe12f3d8",
|
"value": "ed72c8c9-a690-4c93-afb7-9720a5fc7b3d",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -438,7 +438,7 @@
|
|||||||
"field": "documentData.topics.all[0].id",
|
"field": "documentData.topics.all[0].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "58c2d002-1c6f-40c4-b68a-c11ece1fff33",
|
"value": "e6e4b61e-24a4-4ca9-a1d0-2cd3981ab59c",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -501,7 +501,7 @@
|
|||||||
"field": "documentData.topics.all[1].id",
|
"field": "documentData.topics.all[1].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "5a069f2d-dfb1-4ec0-9f51-3f9b657e7731",
|
"value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -564,7 +564,7 @@
|
|||||||
"field": "documentData.topics.all[2].id",
|
"field": "documentData.topics.all[2].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "d16b5a95-d811-4860-9670-596bef0c13df",
|
"value": "85d82f36-3e8f-4e46-8357-84879c728a49",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -634,7 +634,7 @@
|
|||||||
"field": "documentData.topics.exclusion[0].id",
|
"field": "documentData.topics.exclusion[0].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "5a069f2d-dfb1-4ec0-9f51-3f9b657e7731",
|
"value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CR2606-1124",
|
||||||
"approvedSnapshotAvailable": true,
|
"approvedSnapshotAvailable": true,
|
||||||
"approvedTemplateVersionId": "850b13a2-c9ae-4992-89e7-a43f50c34378",
|
"approvedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
|
||||||
"computedTemplateVersionId": "850b13a2-c9ae-4992-89e7-a43f50c34378",
|
"computedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
|
||||||
"inconsistentTemplateKeys": [],
|
"inconsistentTemplateKeys": [],
|
||||||
"computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
"computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
||||||
"storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
"storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-06-30T02:56:03.783Z",
|
"generatedAt": "2026-06-30T05:56:31.314Z",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CR2606-1124",
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PDF Integrity Summary
|
# PDF Integrity Summary
|
||||||
|
|
||||||
- Generated At: 2026-06-30T02:56:03.783Z
|
- Generated At: 2026-06-30T05:56:31.314Z
|
||||||
- Overall Status: PASS
|
- Overall Status: PASS
|
||||||
- Quotation Code: CR2606-1124
|
- Quotation Code: CR2606-1124
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-06-30T02:56:00.835Z",
|
"generatedAt": "2026-06-30T05:56:30.085Z",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CR2606-1124",
|
"quotationCode": "CR2606-1124",
|
||||||
"expectedFixtureCode": "QT-H5-AUDIT",
|
"expectedFixtureCode": "QT-H5-AUDIT",
|
||||||
"template": {
|
"template": {
|
||||||
"templateName": "ALLA Demo Organization Quotation Standard",
|
"templateName": "ALLA Demo Organization Quotation Standard",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"versionId": "850b13a2-c9ae-4992-89e7-a43f50c34378",
|
"versionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
|
||||||
"fieldCount": 44,
|
"fieldCount": 44,
|
||||||
"previousVersion": null,
|
"previousVersion": null,
|
||||||
"deletedFields": [],
|
"deletedFields": [],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# PDF Audit Report
|
# PDF Audit Report
|
||||||
|
|
||||||
- Generated At: 2026-06-30T02:56:00.835Z
|
- Generated At: 2026-06-30T05:56:30.085Z
|
||||||
- Overall Status: PASS
|
- Overall Status: PASS
|
||||||
- Quotation Code: CR2606-1124
|
- Quotation Code: CR2606-1124
|
||||||
- Template: ALLA Demo Organization Quotation Standard v1.0
|
- Template: ALLA Demo Organization Quotation Standard v1.0
|
||||||
|
|||||||
28
drizzle/0008_document_sequence_scope_refactor.sql
Normal file
28
drizzle/0008_document_sequence_scope_refactor.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
ALTER TABLE "document_sequences"
|
||||||
|
ADD COLUMN "product_type" text DEFAULT 'all' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_sequences"
|
||||||
|
ADD COLUMN "format" text DEFAULT '{prefix}{period}-{running}' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_sequences"
|
||||||
|
ADD COLUMN "reset_policy" text DEFAULT 'period' NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "document_sequences"
|
||||||
|
SET
|
||||||
|
"product_type" = CASE
|
||||||
|
WHEN "document_type" = 'quotation' THEN 'legacy'
|
||||||
|
ELSE 'all'
|
||||||
|
END,
|
||||||
|
"format" = '{prefix}{period}-{running}',
|
||||||
|
"reset_policy" = 'period';
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP INDEX "document_sequences_org_doc_period_branch_idx";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "document_sequences_org_branch_product_doc_period_idx"
|
||||||
|
ON "document_sequences" USING btree (
|
||||||
|
"organization_id",
|
||||||
|
"branch_id",
|
||||||
|
"product_type",
|
||||||
|
"document_type",
|
||||||
|
"period"
|
||||||
|
);
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
"when": 1782910000000,
|
"when": 1782910000000,
|
||||||
"tag": "0007_document_library_foundation",
|
"tag": "0007_document_library_foundation",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782913600000,
|
||||||
|
"tag": "0008_document_sequence_scope_refactor",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/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.
|
||||||
|
|||||||
389
plans/task-p.6.1.md
Normal file
389
plans/task-p.6.1.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# Task P.6.1 - Document Sequence Scope Refactor
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Refactor document sequence generation so document numbers are scoped by:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Organization + Branch + Product Type + Document Type + Period
|
||||||
|
```
|
||||||
|
|
||||||
|
This task must be completed before P.7 Document Assembly, because generated/approved/merged documents depend on stable and correct document numbers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Background
|
||||||
|
|
||||||
|
Current document sequence behavior is not sufficient for CRM quotation numbering.
|
||||||
|
|
||||||
|
The required numbering examples are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ALLA
|
||||||
|
CRA2606-001
|
||||||
|
DKA2606-001
|
||||||
|
|
||||||
|
ONVALLA
|
||||||
|
CRO2606-001
|
||||||
|
DKO2606-001
|
||||||
|
SCO2606-001
|
||||||
|
```
|
||||||
|
|
||||||
|
Meaning:
|
||||||
|
|
||||||
|
* `CR` = Product Type code, e.g. Crane
|
||||||
|
* `DK` = Product Type code, e.g. Dock Door
|
||||||
|
* `SC` = Product Type code, e.g. Solar Cell
|
||||||
|
* `A` / `O` = Organization or company code
|
||||||
|
* `2606` = YYMM period
|
||||||
|
* `001` = running number within the sequence scope
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Required Sequence Scope
|
||||||
|
|
||||||
|
Document sequence must be unique per:
|
||||||
|
|
||||||
|
```text
|
||||||
|
organizationId
|
||||||
|
branchId
|
||||||
|
productType
|
||||||
|
documentType
|
||||||
|
period
|
||||||
|
```
|
||||||
|
|
||||||
|
The same period may have separate counters for different product types.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ALLA + Branch A + Crane + Quotation + 2606 = CRA2606-001
|
||||||
|
ALLA + Branch A + Dock Door + Quotation + 2606 = DKA2606-001
|
||||||
|
ONVALLA + Branch O + Crane + Quotation + 2606 = CRO2606-001
|
||||||
|
ONVALLA + Branch O + Dock Door + Quotation + 2606 = DKO2606-001
|
||||||
|
ONVALLA + Branch O + Solar Cell + Quotation + 2606 = SCO2606-001
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Discovery Requirement
|
||||||
|
|
||||||
|
Before implementation, inspect the actual current implementation.
|
||||||
|
|
||||||
|
Review at minimum:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/features/foundation/document-sequence
|
||||||
|
src/db/schema.ts
|
||||||
|
drizzle migrations
|
||||||
|
foundation seed
|
||||||
|
CRM quotation create flow
|
||||||
|
CRM document sequence settings UI
|
||||||
|
/dashboard/crm/settings/document-sequences
|
||||||
|
```
|
||||||
|
|
||||||
|
Identify:
|
||||||
|
|
||||||
|
* current table structure
|
||||||
|
* current unique indexes
|
||||||
|
* current sequence service behavior
|
||||||
|
* current seed data
|
||||||
|
* current UI assumptions
|
||||||
|
* current document code format
|
||||||
|
* current quotation create integration
|
||||||
|
|
||||||
|
Do not assume the current implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Functional Requirements
|
||||||
|
|
||||||
|
## 1. Schema Refactor
|
||||||
|
|
||||||
|
Update `document_sequences` to support:
|
||||||
|
|
||||||
|
* organizationId
|
||||||
|
* branchId
|
||||||
|
* productType
|
||||||
|
* documentType
|
||||||
|
* period
|
||||||
|
* prefix
|
||||||
|
* currentNumber
|
||||||
|
* padding
|
||||||
|
* format
|
||||||
|
* resetPolicy
|
||||||
|
* isActive
|
||||||
|
|
||||||
|
Required uniqueness:
|
||||||
|
|
||||||
|
```text
|
||||||
|
organizationId + branchId + productType + documentType + period
|
||||||
|
```
|
||||||
|
|
||||||
|
If product type is optional for some future document types, represent this explicitly.
|
||||||
|
|
||||||
|
Do not collapse product type into prefix only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Product Type Code Resolution
|
||||||
|
|
||||||
|
Resolve product type code from master data or mapping.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Product Type | Code |
|
||||||
|
| ------------ | ---- |
|
||||||
|
| Crane | CR |
|
||||||
|
| Dock Door | DK |
|
||||||
|
| Solar Cell | SC |
|
||||||
|
| Service | SV |
|
||||||
|
| Spare Part | SP |
|
||||||
|
|
||||||
|
The implementation must not hardcode business labels throughout the codebase.
|
||||||
|
|
||||||
|
Preferred source:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ms_options / master options
|
||||||
|
```
|
||||||
|
|
||||||
|
or a centralized CRM product type code map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Organization / Company Code Resolution
|
||||||
|
|
||||||
|
Resolve organization/company code for document number middle segment.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Organization | Code |
|
||||||
|
| ------------ | ---- |
|
||||||
|
| ALLA | A |
|
||||||
|
| ONVALLA | O |
|
||||||
|
|
||||||
|
The source should be centralized.
|
||||||
|
|
||||||
|
Do not infer by taking the first letter of the organization name unless explicitly configured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Number Format
|
||||||
|
|
||||||
|
Required quotation number format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{productTypeCode}{organizationCode}{YYMM}-{running}
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CRA2606-001
|
||||||
|
DKA2606-001
|
||||||
|
CRO2606-001
|
||||||
|
DKO2606-001
|
||||||
|
SCO2606-001
|
||||||
|
```
|
||||||
|
|
||||||
|
Document type should still be part of the sequence scope even if it does not appear visibly in the final code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Period Handling
|
||||||
|
|
||||||
|
Use period format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
YYMM
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-06 => 2606
|
||||||
|
```
|
||||||
|
|
||||||
|
Sequence resets per period.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quotation Create Flow
|
||||||
|
|
||||||
|
Quotation creation must request the next sequence using:
|
||||||
|
|
||||||
|
* organizationId
|
||||||
|
* branchId
|
||||||
|
* productType
|
||||||
|
* documentType = quotation
|
||||||
|
* quotation date / created date period
|
||||||
|
|
||||||
|
The generated code must be stored on the quotation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Settings UI
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/dashboard/crm/settings/document-sequences
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI must show and filter by:
|
||||||
|
|
||||||
|
* Organization
|
||||||
|
* Branch
|
||||||
|
* Product Type
|
||||||
|
* Document Type
|
||||||
|
* Period
|
||||||
|
* Prefix / Format
|
||||||
|
* Current Number
|
||||||
|
* Next Number
|
||||||
|
* Active Status
|
||||||
|
|
||||||
|
It must be clear that Branch means internal company branch, not customer branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Seed Data
|
||||||
|
|
||||||
|
Update seed data to create realistic document sequence rows for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ALLA + Crane
|
||||||
|
ALLA + Dock Door
|
||||||
|
ONVALLA + Crane
|
||||||
|
ONVALLA + Dock Door
|
||||||
|
ONVALLA + Solar Cell
|
||||||
|
```
|
||||||
|
|
||||||
|
At minimum seed quotation sequence examples for the current period.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Backward Compatibility
|
||||||
|
|
||||||
|
Existing generated quotations must keep their current codes.
|
||||||
|
|
||||||
|
Do not retroactively change old quotation codes.
|
||||||
|
|
||||||
|
Only new generated codes use the new sequence logic.
|
||||||
|
|
||||||
|
If existing `document_sequences` rows lack product type, migration must either:
|
||||||
|
|
||||||
|
* backfill product type where determinable, or
|
||||||
|
* mark them as legacy/inactive, or
|
||||||
|
* keep them compatible without being used for new CRM quotation generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Concurrency Requirement
|
||||||
|
|
||||||
|
Sequence generation must be concurrency-safe.
|
||||||
|
|
||||||
|
When two users create quotations at the same time under the same scope, they must not receive duplicate codes.
|
||||||
|
|
||||||
|
Preferred approach:
|
||||||
|
|
||||||
|
* database transaction
|
||||||
|
* row-level lock
|
||||||
|
* atomic increment
|
||||||
|
|
||||||
|
Do not generate next number purely in client code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Error Handling
|
||||||
|
|
||||||
|
Handle:
|
||||||
|
|
||||||
|
* missing product type
|
||||||
|
* missing branch
|
||||||
|
* missing organization code
|
||||||
|
* missing product type code
|
||||||
|
* inactive sequence
|
||||||
|
* duplicate sequence row
|
||||||
|
* unsupported document type
|
||||||
|
* period mismatch
|
||||||
|
|
||||||
|
Errors should be business-readable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Audit Logging
|
||||||
|
|
||||||
|
Record audit logs for:
|
||||||
|
|
||||||
|
* sequence created
|
||||||
|
* sequence updated
|
||||||
|
* sequence activated/deactivated
|
||||||
|
* next number generated
|
||||||
|
* sequence generation failed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing Requirements
|
||||||
|
|
||||||
|
Add tests for:
|
||||||
|
|
||||||
|
* separate counter per product type
|
||||||
|
* separate counter per organization
|
||||||
|
* separate counter per branch
|
||||||
|
* monthly reset
|
||||||
|
* concurrent generation safety if practical
|
||||||
|
* missing product type code
|
||||||
|
* missing organization code
|
||||||
|
* quotation create integration
|
||||||
|
* backward compatibility with existing quotation codes
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
npm run audit:pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
* Document sequence scope includes Organization + Branch + Product Type + Document Type + Period.
|
||||||
|
* Quotation number format matches `{productTypeCode}{organizationCode}{YYMM}-{running}`.
|
||||||
|
* ALLA Crane can generate `CRA2606-001`.
|
||||||
|
* ALLA Dock Door can generate `DKA2606-001`.
|
||||||
|
* ONVALLA Crane can generate `CRO2606-001`.
|
||||||
|
* ONVALLA Dock Door can generate `DKO2606-001`.
|
||||||
|
* ONVALLA Solar Cell can generate `SCO2606-001`.
|
||||||
|
* Different product types do not share counters.
|
||||||
|
* Different organizations do not share counters.
|
||||||
|
* Different branches do not share counters.
|
||||||
|
* Existing quotation codes remain unchanged.
|
||||||
|
* Settings UI shows product type and branch clearly.
|
||||||
|
* Sequence generation is concurrency-safe.
|
||||||
|
* Runtime PDF audit still passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Out of Scope
|
||||||
|
|
||||||
|
## P.7
|
||||||
|
|
||||||
|
* PDF merge
|
||||||
|
* Document Assembly
|
||||||
|
* SLA append rules
|
||||||
|
* Final merged artifact
|
||||||
|
|
||||||
|
## P.8
|
||||||
|
|
||||||
|
* Render configuration
|
||||||
|
* Optional sections
|
||||||
|
* User-selectable product table visibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Final Success Condition
|
||||||
|
|
||||||
|
Task P.6.1 is complete when new CRM quotations generate document numbers using the correct Organization + Branch + Product Type + Document Type + Period scope, while existing documents remain unchanged and the system is safe to proceed to P.7 Document Assembly.
|
||||||
@@ -15,11 +15,14 @@ type Params = {
|
|||||||
|
|
||||||
const sequenceSchema = z.object({
|
const sequenceSchema = z.object({
|
||||||
documentType: z.string().min(1).optional(),
|
documentType: z.string().min(1).optional(),
|
||||||
prefix: z.string().min(1).optional(),
|
productType: z.string().min(1).optional(),
|
||||||
|
prefix: z.string().optional(),
|
||||||
period: z.string().min(1).optional(),
|
period: z.string().min(1).optional(),
|
||||||
branchId: z.string().optional().nullable(),
|
branchId: z.string().optional().nullable(),
|
||||||
currentNumber: z.number().int().min(0).optional(),
|
currentNumber: z.number().int().min(0).optional(),
|
||||||
paddingLength: z.number().int().min(1).optional(),
|
paddingLength: z.number().int().min(1).optional(),
|
||||||
|
format: z.string().optional(),
|
||||||
|
resetPolicy: z.string().optional(),
|
||||||
isActive: z.boolean().optional()
|
isActive: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
|||||||
|
|
||||||
const sequenceSchema = z.object({
|
const sequenceSchema = z.object({
|
||||||
documentType: z.string().min(1),
|
documentType: z.string().min(1),
|
||||||
prefix: z.string().min(1),
|
productType: z.string().min(1),
|
||||||
|
prefix: z.string().optional().default(''),
|
||||||
period: z.string().min(1),
|
period: z.string().min(1),
|
||||||
branchId: z.string().optional().nullable(),
|
branchId: z.string().optional().nullable(),
|
||||||
currentNumber: z.number().int().min(0).optional(),
|
currentNumber: z.number().int().min(0).optional(),
|
||||||
paddingLength: z.number().int().min(1),
|
paddingLength: z.number().int().min(1),
|
||||||
|
format: z.string().optional(),
|
||||||
|
resetPolicy: z.string().optional(),
|
||||||
isActive: z.boolean().optional()
|
isActive: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { auth } from '@/auth';
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||||
|
import { resolveDocumentSequenceProductTypeCode } from '@/features/foundation/document-sequence/config';
|
||||||
import { DocumentSequenceSettings } from '@/features/foundation/document-sequence/components/document-sequence-settings';
|
import { DocumentSequenceSettings } from '@/features/foundation/document-sequence/components/document-sequence-settings';
|
||||||
import { documentSequencesQueryOptions } from '@/features/foundation/document-sequence/queries';
|
import { documentSequencesQueryOptions } from '@/features/foundation/document-sequence/queries';
|
||||||
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
@@ -24,6 +26,21 @@ export default async function DocumentSequencesRoute() {
|
|||||||
canRead && session?.user?.activeOrganizationId
|
canRead && session?.user?.activeOrganizationId
|
||||||
? await getUserBranches(session.user.activeOrganizationId)
|
? await getUserBranches(session.user.activeOrganizationId)
|
||||||
: [];
|
: [];
|
||||||
|
const productTypes =
|
||||||
|
canRead && session?.user?.activeOrganizationId
|
||||||
|
? (
|
||||||
|
await getActiveOptionsByCategory('crm_product_type', {
|
||||||
|
organizationId: session.user.activeOrganizationId
|
||||||
|
})
|
||||||
|
).map((option) => ({
|
||||||
|
code: option.code,
|
||||||
|
label: option.label,
|
||||||
|
sequenceCode: resolveDocumentSequenceProductTypeCode(option.code)
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const organizationName =
|
||||||
|
session?.user?.organizations.find((item) => item.id === session.user.activeOrganizationId)?.name ??
|
||||||
|
'Unknown Organization';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
@@ -38,7 +55,11 @@ export default async function DocumentSequencesRoute() {
|
|||||||
>
|
>
|
||||||
{canRead ? (
|
{canRead ? (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<DocumentSequenceSettings branches={branches} />
|
<DocumentSequenceSettings
|
||||||
|
branches={branches}
|
||||||
|
productTypes={productTypes}
|
||||||
|
organizationName={organizationName}
|
||||||
|
/>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
) : null}
|
) : null}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -158,19 +158,28 @@ export const documentSequences = pgTable(
|
|||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
branchId: text('branch_id').default('').notNull(),
|
branchId: text('branch_id').default('').notNull(),
|
||||||
|
productType: text('product_type').default('all').notNull(),
|
||||||
documentType: text('document_type').notNull(),
|
documentType: text('document_type').notNull(),
|
||||||
prefix: text('prefix').notNull(),
|
prefix: text('prefix').notNull(),
|
||||||
period: text('period').notNull(),
|
period: text('period').notNull(),
|
||||||
currentNumber: integer('current_number').default(0).notNull(),
|
currentNumber: integer('current_number').default(0).notNull(),
|
||||||
paddingLength: integer('padding_length').default(3).notNull(),
|
paddingLength: integer('padding_length').default(3).notNull(),
|
||||||
|
format: text('format').default('{prefix}{period}-{running}').notNull(),
|
||||||
|
resetPolicy: text('reset_policy').default('period').notNull(),
|
||||||
isActive: boolean('is_active').default(true).notNull(),
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
organizationDocumentPeriodBranchIdx: uniqueIndex(
|
organizationBranchProductDocumentPeriodIdx: uniqueIndex(
|
||||||
'document_sequences_org_doc_period_branch_idx'
|
'document_sequences_org_branch_product_doc_period_idx'
|
||||||
).on(table.organizationId, table.documentType, table.period, table.branchId)
|
).on(
|
||||||
|
table.organizationId,
|
||||||
|
table.branchId,
|
||||||
|
table.productType,
|
||||||
|
table.documentType,
|
||||||
|
table.period
|
||||||
|
)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ import path from "node:path";
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
import {
|
||||||
|
formatDocumentSequencePeriod,
|
||||||
|
normalizeDocumentSequenceProductType,
|
||||||
|
resolveDocumentSequenceOrganizationCode,
|
||||||
|
resolveDocumentSequenceProductTypeCode,
|
||||||
|
} from "../../features/foundation/document-sequence/config.ts";
|
||||||
|
|
||||||
type SqlClient = ReturnType<typeof postgres>;
|
type SqlClient = ReturnType<typeof postgres>;
|
||||||
|
|
||||||
type OrganizationRow = {
|
type OrganizationRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
slug: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -891,16 +898,40 @@ function buildDocumentCode(prefix: string, number: number) {
|
|||||||
return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`;
|
return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQuotationCode(productTypeCode: string, number: number) {
|
function buildQuotationCode(input: {
|
||||||
const prefixByProductType: Record<string, string> = {
|
organizationSlug: string;
|
||||||
crane: "CR",
|
productTypeCode: string;
|
||||||
dockdoor: "DK",
|
number: number;
|
||||||
solarcell: "SOL",
|
quotationDate?: Date;
|
||||||
service: "SV",
|
}) {
|
||||||
spare_part: "SP",
|
const normalizedProductType = normalizeDocumentSequenceProductType(
|
||||||
};
|
input.productTypeCode
|
||||||
|
);
|
||||||
|
const productTypePrefix =
|
||||||
|
resolveDocumentSequenceProductTypeCode(normalizedProductType);
|
||||||
|
const organizationCode = resolveDocumentSequenceOrganizationCode(
|
||||||
|
input.organizationSlug
|
||||||
|
);
|
||||||
|
|
||||||
return buildDocumentCode(prefixByProductType[productTypeCode] ?? "QT", number);
|
if (!productTypePrefix) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing quotation product type sequence code for ${normalizedProductType}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organizationCode) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing quotation organization sequence code for ${input.organizationSlug}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = formatDocumentSequencePeriod(
|
||||||
|
input.quotationDate ?? new Date("2026-06-25T00:00:00.000Z")
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${productTypePrefix}${organizationCode}${period}-${String(
|
||||||
|
input.number
|
||||||
|
).padStart(3, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickSalesUser(
|
function pickSalesUser(
|
||||||
@@ -919,6 +950,17 @@ function seededNote(scope: string) {
|
|||||||
return `[${SEED_TAG}:${scope}]`;
|
return `[${SEED_TAG}:${scope}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createQuotationSequenceAllocator(start = 1101) {
|
||||||
|
const counters = new Map<string, number>();
|
||||||
|
|
||||||
|
return (productTypeCode: string) => {
|
||||||
|
const normalized = normalizeDocumentSequenceProductType(productTypeCode);
|
||||||
|
const nextNumber = counters.get(normalized) ?? start;
|
||||||
|
counters.set(normalized, nextNumber + 1);
|
||||||
|
return nextNumber;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function daysAgo(days: number, hour = 9) {
|
function daysAgo(days: number, hour = 9) {
|
||||||
return new Date(Date.UTC(2026, 5, 25 - days, hour, 0, 0));
|
return new Date(Date.UTC(2026, 5, 25 - days, hour, 0, 0));
|
||||||
}
|
}
|
||||||
@@ -979,7 +1021,7 @@ function getOptionalOptionId(
|
|||||||
|
|
||||||
async function loadOrganizations(sql: SqlClient): Promise<OrganizationRow[]> {
|
async function loadOrganizations(sql: SqlClient): Promise<OrganizationRow[]> {
|
||||||
return sql<OrganizationRow[]>`
|
return sql<OrganizationRow[]>`
|
||||||
select id, name, created_by as "createdBy"
|
select id, name, slug, created_by as "createdBy"
|
||||||
from organizations
|
from organizations
|
||||||
order by name asc
|
order by name asc
|
||||||
`;
|
`;
|
||||||
@@ -2036,6 +2078,7 @@ function buildLeadAndOpportunityData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildQuotationData(
|
function buildQuotationData(
|
||||||
|
organization: OrganizationRow,
|
||||||
optionMap: Map<string, OptionRow>,
|
optionMap: Map<string, OptionRow>,
|
||||||
storyOpportunityMap: Map<string, SeedOpportunity>,
|
storyOpportunityMap: Map<string, SeedOpportunity>,
|
||||||
storyCustomerMap: Map<string, SeedCustomer>,
|
storyCustomerMap: Map<string, SeedCustomer>,
|
||||||
@@ -2051,7 +2094,7 @@ function buildQuotationData(
|
|||||||
const approvalRequests: SeedApprovalRequest[] = [];
|
const approvalRequests: SeedApprovalRequest[] = [];
|
||||||
const approvalActions: SeedApprovalAction[] = [];
|
const approvalActions: SeedApprovalAction[] = [];
|
||||||
|
|
||||||
let quotationSequence = 1101;
|
const nextQuotationSequence = createQuotationSequenceAllocator(1101);
|
||||||
|
|
||||||
const acceptedStories = new Set([
|
const acceptedStories = new Set([
|
||||||
"website-warehouse-crane-win",
|
"website-warehouse-crane-win",
|
||||||
@@ -2124,10 +2167,16 @@ function buildQuotationData(
|
|||||||
const taxRate = 7;
|
const taxRate = 7;
|
||||||
const taxAmount = Math.round((subtotal - discount) * (taxRate / 100));
|
const taxAmount = Math.round((subtotal - discount) * (taxRate / 100));
|
||||||
const totalAmount = subtotal - discount + taxAmount;
|
const totalAmount = subtotal - discount + taxAmount;
|
||||||
|
const quotationNumber = nextQuotationSequence(story.productTypeCode);
|
||||||
|
|
||||||
quotations.push({
|
quotations.push({
|
||||||
id: quotationId,
|
id: quotationId,
|
||||||
code: buildQuotationCode(story.productTypeCode, quotationSequence++),
|
code: buildQuotationCode({
|
||||||
|
organizationSlug: organization.slug,
|
||||||
|
productTypeCode: story.productTypeCode,
|
||||||
|
number: quotationNumber,
|
||||||
|
quotationDate,
|
||||||
|
}),
|
||||||
opportunityId: opportunity.id,
|
opportunityId: opportunity.id,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
contactId: contact?.id ?? null,
|
contactId: contact?.id ?? null,
|
||||||
@@ -2463,12 +2512,19 @@ function buildQuotationData(
|
|||||||
const taxAmount = Math.round((subtotal - discount) * 0.07);
|
const taxAmount = Math.round((subtotal - discount) * 0.07);
|
||||||
const totalAmount = subtotal - discount + taxAmount;
|
const totalAmount = subtotal - discount + taxAmount;
|
||||||
const quotationId = crypto.randomUUID();
|
const quotationId = crypto.randomUUID();
|
||||||
|
const supplementalProductTypeCode = choose(
|
||||||
|
["crane", "dockdoor", "solarcell"],
|
||||||
|
quotations.length
|
||||||
|
);
|
||||||
|
const quotationNumber = nextQuotationSequence(supplementalProductTypeCode);
|
||||||
quotations.push({
|
quotations.push({
|
||||||
id: quotationId,
|
id: quotationId,
|
||||||
code: buildQuotationCode(
|
code: buildQuotationCode({
|
||||||
choose(["crane", "dockdoor", "solarcell"], quotations.length),
|
organizationSlug: organization.slug,
|
||||||
quotationSequence++
|
productTypeCode: supplementalProductTypeCode,
|
||||||
),
|
number: quotationNumber,
|
||||||
|
quotationDate,
|
||||||
|
}),
|
||||||
opportunityId: null,
|
opportunityId: null,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
contactId: contact?.id ?? null,
|
contactId: contact?.id ?? null,
|
||||||
@@ -2478,7 +2534,7 @@ function buildQuotationData(
|
|||||||
quotationTypeId: getOptionId(
|
quotationTypeId: getOptionId(
|
||||||
optionMap,
|
optionMap,
|
||||||
"crm_quotation_type",
|
"crm_quotation_type",
|
||||||
choose(["crane", "dockdoor", "solarcell"], quotations.length),
|
supplementalProductTypeCode,
|
||||||
),
|
),
|
||||||
projectName: `${customer.name} Supplemental Quotation ${quotations.length + 1}`,
|
projectName: `${customer.name} Supplemental Quotation ${quotations.length + 1}`,
|
||||||
projectLocation: `${customer.district}, ${customer.province}`,
|
projectLocation: `${customer.district}, ${customer.province}`,
|
||||||
@@ -2866,12 +2922,13 @@ async function bumpDocumentSequences(
|
|||||||
sql: SqlClient,
|
sql: SqlClient,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
branches: BranchRow[],
|
branches: BranchRow[],
|
||||||
|
optionMap: Map<string, OptionRow>,
|
||||||
|
quotations: SeedQuotation[],
|
||||||
) {
|
) {
|
||||||
const documentTypes = [
|
const documentTypes = [
|
||||||
["customer", 850],
|
["customer", 850],
|
||||||
["crm_lead", 980],
|
["crm_lead", 980],
|
||||||
["crm_opportunity", 1050],
|
["crm_opportunity", 1050],
|
||||||
["quotation", 1160],
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const branch of branches) {
|
for (const branch of branches) {
|
||||||
@@ -2881,11 +2938,46 @@ async function bumpDocumentSequences(
|
|||||||
set current_number = greatest(current_number, ${currentNumber}), updated_at = now()
|
set current_number = greatest(current_number, ${currentNumber}), updated_at = now()
|
||||||
where organization_id = ${organizationId}
|
where organization_id = ${organizationId}
|
||||||
and branch_id = ${branch.id}
|
and branch_id = ${branch.id}
|
||||||
|
and product_type = ${"all"}
|
||||||
and document_type = ${documentType}
|
and document_type = ${documentType}
|
||||||
and period = ${PERIOD}
|
and period = ${PERIOD}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const optionById = new Map(
|
||||||
|
[...optionMap.values()].map((option) => [option.id, option])
|
||||||
|
);
|
||||||
|
const quotationMaxByScope = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const quotation of quotations) {
|
||||||
|
const option = optionById.get(quotation.quotationTypeId);
|
||||||
|
const productTypeCode = normalizeDocumentSequenceProductType(option?.code ?? "");
|
||||||
|
const branchId = quotation.branchId ?? "";
|
||||||
|
const runningNumber = Number(quotation.code.split("-").at(-1) ?? "0");
|
||||||
|
|
||||||
|
if (!productTypeCode || Number.isNaN(runningNumber)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeKey = `${branchId}:${productTypeCode}`;
|
||||||
|
const current = quotationMaxByScope.get(scopeKey) ?? 0;
|
||||||
|
quotationMaxByScope.set(scopeKey, Math.max(current, runningNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [scopeKey, currentNumber] of quotationMaxByScope) {
|
||||||
|
const [branchId, productTypeCode] = scopeKey.split(":");
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update document_sequences
|
||||||
|
set current_number = greatest(current_number, ${currentNumber}), updated_at = now()
|
||||||
|
where organization_id = ${organizationId}
|
||||||
|
and branch_id = ${branchId}
|
||||||
|
and product_type = ${productTypeCode}
|
||||||
|
and document_type = ${"quotation"}
|
||||||
|
and period = ${PERIOD}
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
|
async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
|
||||||
@@ -2916,6 +3008,7 @@ async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
|
|||||||
users,
|
users,
|
||||||
);
|
);
|
||||||
const quotationData = buildQuotationData(
|
const quotationData = buildQuotationData(
|
||||||
|
organization,
|
||||||
optionMap,
|
optionMap,
|
||||||
leadData.storyOpportunityMap,
|
leadData.storyOpportunityMap,
|
||||||
storyCustomerMap,
|
storyCustomerMap,
|
||||||
@@ -2944,7 +3037,13 @@ async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
|
|||||||
quotationData.approvalActions,
|
quotationData.approvalActions,
|
||||||
);
|
);
|
||||||
|
|
||||||
await bumpDocumentSequences(sql, organization.id, branches);
|
await bumpDocumentSequences(
|
||||||
|
sql,
|
||||||
|
organization.id,
|
||||||
|
branches,
|
||||||
|
optionMap,
|
||||||
|
quotationData.quotations
|
||||||
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[seed:crm-uat] ${organization.name}: ${customers.length} customers, ${leadData.leads.length} leads, ${leadData.opportunities.length} opportunities, ${quotationData.quotations.length} quotations`,
|
`[seed:crm-uat] ${organization.name}: ${customers.length} customers, ${leadData.leads.length} leads, ${leadData.opportunities.length} opportunities, ${quotationData.quotations.length} quotations`,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import {
|
|||||||
type CrmSecurityContext,
|
type CrmSecurityContext,
|
||||||
canAccessScopedCrmRecord
|
canAccessScopedCrmRecord
|
||||||
} from '@/features/crm/security/server/service';
|
} from '@/features/crm/security/server/service';
|
||||||
|
import { formatDocumentSequencePeriod } from '@/features/foundation/document-sequence/config';
|
||||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
import {
|
import {
|
||||||
resolveBranchDisplays,
|
resolveBranchDisplays,
|
||||||
@@ -1344,11 +1345,13 @@ export async function createQuotation(
|
|||||||
opportunityId: payload.opportunityId,
|
opportunityId: payload.opportunityId,
|
||||||
customerId: payload.customerId
|
customerId: payload.customerId
|
||||||
});
|
});
|
||||||
const documentCode = await generateNextDocumentCode({
|
const documentCode = await generateNextDocumentCode({
|
||||||
organizationId,
|
organizationId,
|
||||||
documentType: 'quotation',
|
documentType: 'quotation',
|
||||||
branchId: payload.branchId ?? opportunity?.branchId ?? ''
|
branchId: payload.branchId ?? opportunity?.branchId ?? '',
|
||||||
});
|
productType: payload.quotationType,
|
||||||
|
period: formatDocumentSequencePeriod(new Date(payload.quotationDate))
|
||||||
|
});
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const [created] = await tx
|
const [created] = await tx
|
||||||
@@ -2229,11 +2232,13 @@ export async function createQuotationRevision(
|
|||||||
const currentItems = await listQuotationItems(quotationId, organizationId);
|
const currentItems = await listQuotationItems(quotationId, organizationId);
|
||||||
const currentCustomers = await listQuotationCustomers(quotationId, organizationId);
|
const currentCustomers = await listQuotationCustomers(quotationId, organizationId);
|
||||||
const currentTopics = await listQuotationTopics(quotationId, organizationId);
|
const currentTopics = await listQuotationTopics(quotationId, organizationId);
|
||||||
const documentCode = await generateNextDocumentCode({
|
const documentCode = await generateNextDocumentCode({
|
||||||
organizationId,
|
organizationId,
|
||||||
documentType: 'quotation',
|
documentType: 'quotation',
|
||||||
branchId: parent.branchId ?? ''
|
branchId: parent.branchId ?? '',
|
||||||
});
|
productType: parent.quotationType,
|
||||||
|
period: formatDocumentSequencePeriod(new Date())
|
||||||
|
});
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const [created] = await tx
|
const [created] = await tx
|
||||||
|
|||||||
@@ -15,16 +15,15 @@ import {
|
|||||||
DialogTitle
|
DialogTitle
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { formatDateTime } from '@/lib/date-format';
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
import type { FoundationBranch } from '@/features/foundation/branch-scope/types';
|
import type { FoundationBranch } from '@/features/foundation/branch-scope/types';
|
||||||
|
import {
|
||||||
|
DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
||||||
|
DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
||||||
|
DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE
|
||||||
|
} from '../config';
|
||||||
import {
|
import {
|
||||||
createDocumentSequenceMutation,
|
createDocumentSequenceMutation,
|
||||||
deleteDocumentSequenceMutation,
|
deleteDocumentSequenceMutation,
|
||||||
@@ -35,19 +34,31 @@ import { documentSequencesQueryOptions } from '../queries';
|
|||||||
import type {
|
import type {
|
||||||
DocumentSequenceBranchOption,
|
DocumentSequenceBranchOption,
|
||||||
DocumentSequenceListItem,
|
DocumentSequenceListItem,
|
||||||
DocumentSequenceMutationPayload
|
DocumentSequenceMutationPayload,
|
||||||
|
DocumentSequenceProductTypeOption
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
type SequenceState = {
|
type SequenceState = {
|
||||||
documentType: string;
|
documentType: string;
|
||||||
|
productType: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
period: string;
|
period: string;
|
||||||
branchId: string;
|
branchId: string;
|
||||||
currentNumber: string;
|
currentNumber: string;
|
||||||
paddingLength: string;
|
paddingLength: string;
|
||||||
|
format: string;
|
||||||
|
resetPolicy: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SequenceFilters = {
|
||||||
|
branchId: string;
|
||||||
|
productType: string;
|
||||||
|
documentType: string;
|
||||||
|
period: string;
|
||||||
|
prefix: string;
|
||||||
|
};
|
||||||
|
|
||||||
function toBranchOptions(branches: FoundationBranch[]): DocumentSequenceBranchOption[] {
|
function toBranchOptions(branches: FoundationBranch[]): DocumentSequenceBranchOption[] {
|
||||||
return branches.map((branch) => ({
|
return branches.map((branch) => ({
|
||||||
id: branch.id,
|
id: branch.id,
|
||||||
@@ -60,23 +71,29 @@ function toBranchOptions(branches: FoundationBranch[]): DocumentSequenceBranchOp
|
|||||||
function toState(sequence?: DocumentSequenceListItem): SequenceState {
|
function toState(sequence?: DocumentSequenceListItem): SequenceState {
|
||||||
return {
|
return {
|
||||||
documentType: sequence?.documentType ?? 'quotation',
|
documentType: sequence?.documentType ?? 'quotation',
|
||||||
prefix: sequence?.prefix ?? 'QT',
|
productType: sequence?.productType ?? 'crane',
|
||||||
|
prefix: sequence?.prefix ?? '',
|
||||||
period: sequence?.period ?? '',
|
period: sequence?.period ?? '',
|
||||||
branchId: sequence?.branchId ?? '__all__',
|
branchId: sequence?.branchId ?? '__all__',
|
||||||
currentNumber: String(sequence?.currentNumber ?? 0),
|
currentNumber: String(sequence?.currentNumber ?? 0),
|
||||||
paddingLength: String(sequence?.paddingLength ?? 3),
|
paddingLength: String(sequence?.paddingLength ?? 3),
|
||||||
|
format: sequence?.format ?? DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
||||||
|
resetPolicy: sequence?.resetPolicy ?? DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
||||||
isActive: sequence?.isActive ?? true
|
isActive: sequence?.isActive ?? true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload(state: SequenceState): DocumentSequenceMutationPayload {
|
function buildPayload(state: SequenceState): DocumentSequenceMutationPayload {
|
||||||
return {
|
return {
|
||||||
documentType: state.documentType,
|
documentType: state.documentType.trim(),
|
||||||
prefix: state.prefix,
|
productType: state.productType,
|
||||||
period: state.period,
|
prefix: state.prefix.trim(),
|
||||||
|
period: state.period.trim(),
|
||||||
branchId: state.branchId === '__all__' ? null : state.branchId,
|
branchId: state.branchId === '__all__' ? null : state.branchId,
|
||||||
currentNumber: Number(state.currentNumber || 0),
|
currentNumber: Number(state.currentNumber),
|
||||||
paddingLength: Number(state.paddingLength || 3),
|
paddingLength: Number(state.paddingLength),
|
||||||
|
format: state.format.trim(),
|
||||||
|
resetPolicy: state.resetPolicy.trim(),
|
||||||
isActive: state.isActive
|
isActive: state.isActive
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -85,14 +102,21 @@ function SequenceDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
sequence,
|
sequence,
|
||||||
branches
|
branches,
|
||||||
|
productTypes
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
sequence?: DocumentSequenceListItem;
|
sequence?: DocumentSequenceListItem;
|
||||||
branches: DocumentSequenceBranchOption[];
|
branches: DocumentSequenceBranchOption[];
|
||||||
|
productTypes: DocumentSequenceProductTypeOption[];
|
||||||
}) {
|
}) {
|
||||||
const [state, setState] = React.useState<SequenceState>(toState(sequence));
|
const [state, setState] = React.useState<SequenceState>(() => toState(sequence));
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setState(toState(sequence));
|
||||||
|
}, [sequence]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...createDocumentSequenceMutation,
|
...createDocumentSequenceMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -100,7 +124,7 @@ function SequenceDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error instanceof Error ? error.message : 'Create failed');
|
toast.error(error instanceof Error ? error.message : 'Unable to save sequence');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
@@ -110,40 +134,35 @@ function SequenceDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error instanceof Error ? error.message : 'Update failed');
|
toast.error(error instanceof Error ? error.message : 'Unable to save sequence');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
React.useEffect(() => {
|
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
if (open) {
|
|
||||||
setState(toState(sequence));
|
|
||||||
}
|
|
||||||
}, [open, sequence]);
|
|
||||||
|
|
||||||
async function onSubmit(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const payload = buildPayload(state);
|
const payload = buildPayload(state);
|
||||||
|
|
||||||
if (sequence) {
|
if (sequence) {
|
||||||
await updateMutation.mutateAsync({ id: sequence.id, values: payload });
|
updateMutation.mutate({ id: sequence.id, values: payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await createMutation.mutateAsync(payload);
|
createMutation.mutate(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className='max-w-2xl'>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{sequence ? 'Edit Sequence' : 'Create Sequence'}</DialogTitle>
|
<DialogTitle>{sequence ? 'Edit Sequence' : 'Create Sequence'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Preview never increments the counter. Generation remains server-side only.
|
Preview never increments the counter. Generation remains server-side only.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className='grid gap-4'>
|
<form onSubmit={onSubmit} className='grid gap-4'>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<Input
|
<Input
|
||||||
placeholder='Document type'
|
placeholder='Document type'
|
||||||
value={state.documentType}
|
value={state.documentType}
|
||||||
@@ -151,6 +170,27 @@ function SequenceDialog({
|
|||||||
setState((current) => ({ ...current, documentType: event.target.value }))
|
setState((current) => ({ ...current, documentType: event.target.value }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
value={state.productType}
|
||||||
|
onValueChange={(value) => setState((current) => ({ ...current, productType: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select product type scope' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE}>
|
||||||
|
All Product Types
|
||||||
|
</SelectItem>
|
||||||
|
{productTypes.map((productType) => (
|
||||||
|
<SelectItem key={productType.code} value={productType.code}>
|
||||||
|
{productType.label}
|
||||||
|
{productType.sequenceCode ? ` (${productType.sequenceCode})` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<Input
|
<Input
|
||||||
placeholder='Prefix'
|
placeholder='Prefix'
|
||||||
@@ -160,19 +200,18 @@ function SequenceDialog({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder='Period'
|
placeholder='Period (YYMM)'
|
||||||
value={state.period}
|
value={state.period}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setState((current) => ({ ...current, period: event.target.value }))
|
setState((current) => ({ ...current, period: event.target.value }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-4 md:grid-cols-3'>
|
<div className='grid gap-4 md:grid-cols-3'>
|
||||||
<Select
|
<Select
|
||||||
value={state.branchId}
|
value={state.branchId}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => setState((current) => ({ ...current, branchId: value }))}
|
||||||
setState((current) => ({ ...current, branchId: value }))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select branch scope' />
|
<SelectValue placeholder='Select branch scope' />
|
||||||
@@ -201,11 +240,29 @@ function SequenceDialog({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<Input
|
||||||
|
placeholder='Format'
|
||||||
|
value={state.format}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, format: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder='Reset policy'
|
||||||
|
value={state.resetPolicy}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, resetPolicy: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||||
<div>
|
<div>
|
||||||
<div className='font-medium'>Active</div>
|
<div className='font-medium'>Active</div>
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className='text-muted-foreground text-sm'>
|
||||||
Inactive sequences can stay preserved for legacy numbering.
|
Inactive sequences stay preserved for legacy numbering history.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -215,6 +272,7 @@ function SequenceDialog({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -229,16 +287,28 @@ function SequenceDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentSequenceSettings({ branches }: { branches: FoundationBranch[] }) {
|
export function DocumentSequenceSettings({
|
||||||
|
branches,
|
||||||
|
productTypes,
|
||||||
|
organizationName
|
||||||
|
}: {
|
||||||
|
branches: FoundationBranch[];
|
||||||
|
productTypes: DocumentSequenceProductTypeOption[];
|
||||||
|
organizationName: string;
|
||||||
|
}) {
|
||||||
const { data } = useSuspenseQuery(documentSequencesQueryOptions());
|
const { data } = useSuspenseQuery(documentSequencesQueryOptions());
|
||||||
const [dialogState, setDialogState] = React.useState<{
|
const [dialogState, setDialogState] = React.useState<{ open: boolean; sequence?: DocumentSequenceListItem }>({
|
||||||
open: boolean;
|
|
||||||
sequence?: DocumentSequenceListItem;
|
|
||||||
}>({
|
|
||||||
open: false
|
open: false
|
||||||
});
|
});
|
||||||
const branchOptions = React.useMemo(() => toBranchOptions(branches), [branches]);
|
const [filters, setFilters] = React.useState<SequenceFilters>({
|
||||||
|
branchId: '__all__',
|
||||||
|
productType: '__all__',
|
||||||
|
documentType: '',
|
||||||
|
period: '',
|
||||||
|
prefix: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const branchOptions = React.useMemo(() => toBranchOptions(branches), [branches]);
|
||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
...resetDocumentSequenceMutation,
|
...resetDocumentSequenceMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -258,14 +328,48 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const items = React.useMemo(() => {
|
||||||
|
return data.items.filter((sequence) => {
|
||||||
|
if (filters.branchId !== '__all__' && (sequence.branchId ?? '__all__') !== filters.branchId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.productType !== '__all__' && sequence.productType !== filters.productType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
filters.documentType.trim() &&
|
||||||
|
!sequence.documentType.toLowerCase().includes(filters.documentType.trim().toLowerCase())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.period.trim() && sequence.period !== filters.period.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
filters.prefix.trim() &&
|
||||||
|
!`${sequence.prefix} ${sequence.format}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(filters.prefix.trim().toLowerCase())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [data.items, filters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
<div className='flex flex-wrap items-center justify-between gap-4 rounded-lg border p-4'>
|
||||||
<div>
|
<div>
|
||||||
<div className='font-medium'>Document Sequence Admin</div>
|
<div className='font-medium'>Document Sequence Admin</div>
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className='text-muted-foreground text-sm'>
|
||||||
Maintain organization-scoped numbering strategy without rewriting historic document
|
Maintain running numbers by organization, branch, product type, document type, and
|
||||||
codes.
|
period without rewriting historical codes.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setDialogState({ open: true })}>
|
<Button onClick={() => setDialogState({ open: true })}>
|
||||||
@@ -274,27 +378,83 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.items.length === 0 ? (
|
<div className='grid gap-4 md:grid-cols-6'>
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Organization</div>
|
||||||
|
<div className='mt-1 text-sm font-medium'>{organizationName}</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={filters.branchId}
|
||||||
|
onValueChange={(value) => setFilters((current) => ({ ...current, branchId: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Filter branch' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__all__'>All Branches</SelectItem>
|
||||||
|
{branchOptions.map((branch) => (
|
||||||
|
<SelectItem key={branch.id} value={branch.id}>
|
||||||
|
{branch.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={filters.productType}
|
||||||
|
onValueChange={(value) => setFilters((current) => ({ ...current, productType: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Filter product type' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__all__'>All Product Types</SelectItem>
|
||||||
|
{productTypes.map((productType) => (
|
||||||
|
<SelectItem key={productType.code} value={productType.code}>
|
||||||
|
{productType.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value='legacy'>Legacy</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder='Filter document type'
|
||||||
|
value={filters.documentType}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({ ...current, documentType: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder='Filter period (YYMM)'
|
||||||
|
value={filters.period}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({ ...current, period: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder='Filter prefix / format'
|
||||||
|
value={filters.prefix}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
prefix: event.target.value
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
No document sequences configured yet.
|
No document sequences match the current filters.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
data.items.map((sequence) => (
|
items.map((sequence) => (
|
||||||
<div key={sequence.id} className='rounded-xl border p-5'>
|
<div key={sequence.id} className='rounded-xl border p-5'>
|
||||||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||||
<div>
|
<div>
|
||||||
<div className='text-xl font-semibold'>{sequence.documentType}</div>
|
<div className='text-xl font-semibold'>{sequence.documentType}</div>
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className='text-muted-foreground text-sm'>
|
||||||
{sequence.prefix} {sequence.period} {'\u2022'}{' '}
|
{sequence.prefix} {sequence.period} {'\u2022'} {sequence.branchDisplayName}{' '}
|
||||||
<span
|
{'\u2022'} {sequence.productTypeLabel ?? sequence.productType}
|
||||||
className={
|
|
||||||
sequence.branchName || !sequence.branchId
|
|
||||||
? undefined
|
|
||||||
: 'text-muted-foreground italic'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{sequence.branchDisplayName ?? 'All Branches'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
@@ -305,36 +465,31 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-4 grid gap-4 md:grid-cols-4'>
|
<div className='mt-4 grid gap-4 md:grid-cols-4 xl:grid-cols-8'>
|
||||||
<div className='rounded-lg bg-muted/40 p-4'>
|
<MetricCard label='Organization' value={sequence.organizationName ?? '-'} />
|
||||||
<div className='text-muted-foreground text-xs'>Branch</div>
|
<MetricCard label='Branch' value={sequence.branchDisplayName ?? 'All Branches'} />
|
||||||
<div className='mt-1 text-sm font-medium'>
|
<MetricCard
|
||||||
{sequence.branchDisplayName ?? 'All Branches'}
|
label='Product Type'
|
||||||
</div>
|
value={sequence.productTypeLabel ?? sequence.productType}
|
||||||
</div>
|
/>
|
||||||
<div className='rounded-lg bg-muted/40 p-4'>
|
<MetricCard label='Period' value={sequence.period} />
|
||||||
<div className='text-muted-foreground text-xs'>Current Number</div>
|
<MetricCard label='Prefix' value={sequence.prefix} />
|
||||||
<div className='mt-1 text-sm font-medium'>{sequence.currentNumber}</div>
|
<MetricCard label='Format' value={sequence.format} />
|
||||||
</div>
|
<MetricCard label='Current Number' value={String(sequence.currentNumber)} />
|
||||||
<div className='rounded-lg bg-muted/40 p-4'>
|
<MetricCard label='Updated At' value={formatDateTime(sequence.updatedAt)} />
|
||||||
<div className='text-muted-foreground text-xs'>Padding Length</div>
|
|
||||||
<div className='mt-1 text-sm font-medium'>{sequence.paddingLength}</div>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-lg bg-muted/40 p-4'>
|
|
||||||
<div className='text-muted-foreground text-xs'>Updated At</div>
|
|
||||||
<div className='mt-1 text-sm font-medium'>{formatDateTime(sequence.updatedAt)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-4 flex flex-wrap gap-2'>
|
<div className='mt-4 flex flex-wrap gap-2'>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
onClick={() => setDialogState({ open: true, sequence })}
|
onClick={() => setDialogState({ open: true, sequence })}
|
||||||
>
|
>
|
||||||
Edit Sequence
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
resetMutation.mutate({
|
resetMutation.mutate({
|
||||||
id: sequence.id,
|
id: sequence.id,
|
||||||
@@ -343,10 +498,11 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
|
|||||||
}
|
}
|
||||||
isLoading={resetMutation.isPending}
|
isLoading={resetMutation.isPending}
|
||||||
>
|
>
|
||||||
Reset Counter
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
onClick={() => deleteMutation.mutate(sequence.id)}
|
onClick={() => deleteMutation.mutate(sequence.id)}
|
||||||
isLoading={deleteMutation.isPending}
|
isLoading={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -362,7 +518,17 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
|
|||||||
onOpenChange={(open) => setDialogState((current) => ({ ...current, open }))}
|
onOpenChange={(open) => setDialogState((current) => ({ ...current, open }))}
|
||||||
sequence={dialogState.sequence}
|
sequence={dialogState.sequence}
|
||||||
branches={branchOptions}
|
branches={branchOptions}
|
||||||
|
productTypes={productTypes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className='rounded-lg bg-muted/40 p-4'>
|
||||||
|
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||||
|
<div className='mt-1 text-sm font-medium'>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
33
src/features/foundation/document-sequence/config.test.ts
Normal file
33
src/features/foundation/document-sequence/config.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
formatDocumentSequencePeriod,
|
||||||
|
normalizeDocumentSequenceProductType,
|
||||||
|
resolveDocumentSequenceOrganizationCode,
|
||||||
|
resolveDocumentSequenceProductTypeCode
|
||||||
|
} from './config';
|
||||||
|
|
||||||
|
test('resolveDocumentSequenceProductTypeCode supports current CRM product type aliases', () => {
|
||||||
|
assert.equal(resolveDocumentSequenceProductTypeCode('crane'), 'CR');
|
||||||
|
assert.equal(resolveDocumentSequenceProductTypeCode('dockdoor'), 'DK');
|
||||||
|
assert.equal(resolveDocumentSequenceProductTypeCode('dock_door'), 'DK');
|
||||||
|
assert.equal(resolveDocumentSequenceProductTypeCode('solarcell'), 'SC');
|
||||||
|
assert.equal(resolveDocumentSequenceProductTypeCode('service'), 'SV');
|
||||||
|
assert.equal(resolveDocumentSequenceProductTypeCode('sparepart'), 'SP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDocumentSequenceOrganizationCode uses centralized slug mapping', () => {
|
||||||
|
assert.equal(resolveDocumentSequenceOrganizationCode('alla'), 'A');
|
||||||
|
assert.equal(resolveDocumentSequenceOrganizationCode('alla-demo'), 'A');
|
||||||
|
assert.equal(resolveDocumentSequenceOrganizationCode('onvalla'), 'O');
|
||||||
|
assert.equal(resolveDocumentSequenceOrganizationCode('unknown-org'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatDocumentSequencePeriod returns YYMM format', () => {
|
||||||
|
assert.equal(formatDocumentSequencePeriod(new Date('2026-06-15T00:00:00.000Z')), '2606');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeDocumentSequenceProductType keeps explicit generic fallback', () => {
|
||||||
|
assert.equal(normalizeDocumentSequenceProductType('Dock Door'), 'dock_door');
|
||||||
|
assert.equal(normalizeDocumentSequenceProductType(''), 'all');
|
||||||
|
});
|
||||||
40
src/features/foundation/document-sequence/config.ts
Normal file
40
src/features/foundation/document-sequence/config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export const DOCUMENT_SEQUENCE_DEFAULT_FORMAT = '{prefix}{period}-{running}';
|
||||||
|
export const DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY = 'period';
|
||||||
|
export const DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE = 'all';
|
||||||
|
export const DOCUMENT_SEQUENCE_LEGACY_PRODUCT_TYPE = 'legacy';
|
||||||
|
|
||||||
|
export const DOCUMENT_SEQUENCE_PRODUCT_TYPE_CODES: Record<string, string> = {
|
||||||
|
crane: 'CR',
|
||||||
|
dockdoor: 'DK',
|
||||||
|
dock_door: 'DK',
|
||||||
|
solarcell: 'SC',
|
||||||
|
solar_cell: 'SC',
|
||||||
|
solar: 'SC',
|
||||||
|
service: 'SV',
|
||||||
|
sparepart: 'SP',
|
||||||
|
spare_part: 'SP'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOCUMENT_SEQUENCE_ORGANIZATION_CODES: Record<string, string> = {
|
||||||
|
alla: 'A',
|
||||||
|
'alla-demo': 'A',
|
||||||
|
onvalla: 'O'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeDocumentSequenceProductType(value?: string | null) {
|
||||||
|
return value?.trim().toLowerCase().replace(/[\s-]+/g, '_') || DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDocumentSequencePeriod(date: Date) {
|
||||||
|
const year = String(date.getFullYear()).slice(-2);
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
return `${year}${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocumentSequenceProductTypeCode(productType: string) {
|
||||||
|
return DOCUMENT_SEQUENCE_PRODUCT_TYPE_CODES[normalizeDocumentSequenceProductType(productType)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocumentSequenceOrganizationCode(slug?: string | null) {
|
||||||
|
return slug ? DOCUMENT_SEQUENCE_ORGANIZATION_CODES[slug.trim().toLowerCase()] ?? null : null;
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
import { and, asc, eq, ne, sql } from 'drizzle-orm';
|
||||||
import { documentSequences } from '@/db/schema';
|
import { documentSequences, organizations } from '@/db/schema';
|
||||||
|
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 { 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 {
|
||||||
|
DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
||||||
|
DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
||||||
|
DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE,
|
||||||
|
normalizeDocumentSequenceProductType,
|
||||||
|
resolveDocumentSequenceOrganizationCode,
|
||||||
|
resolveDocumentSequenceProductTypeCode
|
||||||
|
} from './config';
|
||||||
import type {
|
import type {
|
||||||
DocumentSequenceInput,
|
DocumentSequenceInput,
|
||||||
DocumentSequenceListItem,
|
DocumentSequenceListItem,
|
||||||
@@ -13,6 +23,8 @@ import type {
|
|||||||
DocumentSequenceResult
|
DocumentSequenceResult
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
|
||||||
|
|
||||||
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
|
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
|
||||||
customer: 'CUS',
|
customer: 'CUS',
|
||||||
contact: 'CON',
|
contact: 'CON',
|
||||||
@@ -23,26 +35,54 @@ const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
|
|||||||
approval: 'APV'
|
approval: 'APV'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OrganizationRecord = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
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 buildDocumentCode(
|
|
||||||
prefix: string,
|
|
||||||
period: string,
|
|
||||||
nextNumber: number,
|
|
||||||
paddingLength: number
|
|
||||||
) {
|
|
||||||
return `${prefix}${period}-${String(nextNumber).padStart(paddingLength, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBranchId(branchId?: string | null) {
|
function normalizeBranchId(branchId?: string | null) {
|
||||||
return branchId?.trim() || '';
|
return branchId?.trim() || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentType(documentType: string) {
|
||||||
|
const normalized = documentType.trim();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new AuthError('Document type is required.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePeriod(period?: string | null) {
|
||||||
|
const normalized = period?.trim();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new AuthError('Period is required.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d{4}$/.test(normalized)) {
|
||||||
|
throw new AuthError('Period must use YYMM format.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFormat(value?: string | null) {
|
||||||
|
return value?.trim() || DOCUMENT_SEQUENCE_DEFAULT_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResetPolicy(value?: string | null) {
|
||||||
|
return value?.trim() || DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY;
|
||||||
|
}
|
||||||
|
|
||||||
function formatBranchDisplayName(input: {
|
function formatBranchDisplayName(input: {
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
branchCode?: string | null;
|
branchCode?: string | null;
|
||||||
@@ -63,104 +103,198 @@ function formatBranchDisplayName(input: {
|
|||||||
return `Unknown Branch (${input.branchId})`;
|
return `Unknown Branch (${input.branchId})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOrganizationId(organizationId?: string) {
|
function buildDocumentCode(
|
||||||
if (organizationId) {
|
format: string,
|
||||||
return organizationId;
|
prefix: string,
|
||||||
}
|
period: string,
|
||||||
|
nextNumber: number,
|
||||||
|
paddingLength: number
|
||||||
|
) {
|
||||||
|
const running = String(nextNumber).padStart(paddingLength, '0');
|
||||||
|
|
||||||
const organization = await getCurrentOrganization();
|
return format
|
||||||
if (!organization) {
|
.replaceAll('{prefix}', prefix)
|
||||||
throw new AuthError('Active organization required', 400);
|
.replaceAll('{period}', period)
|
||||||
}
|
.replaceAll('{running}', running);
|
||||||
|
|
||||||
return organization.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
|
async function resolveOrganizationRecord(organizationId?: string): Promise<OrganizationRecord> {
|
||||||
const nextNumber = row.currentNumber + 1;
|
if (!organizationId) {
|
||||||
|
const organization = await getCurrentOrganization();
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new AuthError('Active organization required.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: buildDocumentCode(row.prefix, row.period, nextNumber, row.paddingLength),
|
id: organization.id,
|
||||||
documentType: row.documentType,
|
name: organization.name,
|
||||||
branchId: row.branchId || null,
|
slug: organization.slug
|
||||||
currentNumber: row.currentNumber,
|
|
||||||
nextNumber,
|
|
||||||
period: row.period,
|
|
||||||
prefix: row.prefix
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [organization] = await db
|
||||||
|
.select({
|
||||||
|
id: organizations.id,
|
||||||
|
name: organizations.name,
|
||||||
|
slug: organizations.slug
|
||||||
|
})
|
||||||
|
.from(organizations)
|
||||||
|
.where(eq(organizations.id, organizationId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new AuthError('Organization not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProductTypeMap(organizationId: string) {
|
||||||
|
const options = await getActiveOptionsByCategory(PRODUCT_TYPE_CATEGORY, { organizationId });
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
options.map((option) => [
|
||||||
|
normalizeDocumentSequenceProductType(option.code),
|
||||||
|
{
|
||||||
|
id: option.id,
|
||||||
|
code: normalizeDocumentSequenceProductType(option.code),
|
||||||
|
label: option.label
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveScopedProductType(input: {
|
||||||
|
organizationId: string;
|
||||||
|
documentType: string;
|
||||||
|
productType?: string | null;
|
||||||
|
}) {
|
||||||
|
const normalizedDocumentType = normalizeDocumentType(input.documentType);
|
||||||
|
const requestedProductType = input.productType?.trim();
|
||||||
|
|
||||||
|
if (normalizedDocumentType !== 'quotation') {
|
||||||
|
return requestedProductType
|
||||||
|
? normalizeDocumentSequenceProductType(requestedProductType)
|
||||||
|
: DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestedProductType) {
|
||||||
|
throw new AuthError('Product type is required for quotation document sequences.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const productTypeMap = await resolveProductTypeMap(input.organizationId);
|
||||||
|
const normalizedRequestedProductType =
|
||||||
|
normalizeDocumentSequenceProductType(requestedProductType);
|
||||||
|
|
||||||
|
const byCode = productTypeMap.get(normalizedRequestedProductType);
|
||||||
|
if (byCode) {
|
||||||
|
return byCode.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = [...productTypeMap.values()].find((option) => option.id === requestedProductType);
|
||||||
|
if (byId) {
|
||||||
|
return byId.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthError('Missing product type code configuration for this quotation type.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePrefix(input: {
|
||||||
|
organization: OrganizationRecord;
|
||||||
|
documentType: string;
|
||||||
|
productType: string;
|
||||||
|
prefix?: string | null;
|
||||||
|
}) {
|
||||||
|
const manualPrefix = input.prefix?.trim();
|
||||||
|
if (manualPrefix) {
|
||||||
|
return manualPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.documentType !== 'quotation') {
|
||||||
|
return DEFAULT_DOCUMENT_PREFIXES[input.documentType] ?? input.documentType.slice(0, 3).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationCode = resolveDocumentSequenceOrganizationCode(input.organization.slug);
|
||||||
|
if (!organizationCode) {
|
||||||
|
throw new AuthError(
|
||||||
|
`Missing organization code configuration for ${input.organization.slug}.`,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const productTypeCode = resolveDocumentSequenceProductTypeCode(input.productType);
|
||||||
|
if (!productTypeCode) {
|
||||||
|
throw new AuthError(`Missing product type code configuration for ${input.productType}.`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${productTypeCode}${organizationCode}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
where: and(eq(documentSequences.id, id), eq(documentSequences.organizationId, organizationId))
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSequenceRow(
|
async function assertUniqueScope(input: {
|
||||||
organizationId: string,
|
organizationId: string;
|
||||||
documentType: string,
|
branchId: string;
|
||||||
period: string,
|
productType: string;
|
||||||
branchId: string
|
documentType: string;
|
||||||
) {
|
period: string;
|
||||||
|
currentId?: string;
|
||||||
|
}) {
|
||||||
const existing = await db.query.documentSequences.findFirst({
|
const existing = await db.query.documentSequences.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(documentSequences.organizationId, organizationId),
|
eq(documentSequences.organizationId, input.organizationId),
|
||||||
eq(documentSequences.documentType, documentType),
|
eq(documentSequences.branchId, input.branchId),
|
||||||
eq(documentSequences.period, period),
|
eq(documentSequences.productType, input.productType),
|
||||||
eq(documentSequences.branchId, branchId)
|
eq(documentSequences.documentType, input.documentType),
|
||||||
|
eq(documentSequences.period, input.period),
|
||||||
|
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : [])
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
throw new AuthError(
|
||||||
|
'Duplicate document sequence scope for organization, branch, product type, document type, and period.',
|
||||||
|
409
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix =
|
|
||||||
DEFAULT_DOCUMENT_PREFIXES[documentType] ?? documentType.slice(0, 3).toUpperCase();
|
|
||||||
|
|
||||||
const [created] = await db
|
|
||||||
.insert(documentSequences)
|
|
||||||
.values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
organizationId,
|
|
||||||
branchId,
|
|
||||||
documentType,
|
|
||||||
period,
|
|
||||||
prefix,
|
|
||||||
currentNumber: 0,
|
|
||||||
paddingLength: 3,
|
|
||||||
isActive: true
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return created;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapSequenceRecords(
|
async function mapSequenceRecords(
|
||||||
organizationId: string,
|
organization: OrganizationRecord,
|
||||||
rows: Array<typeof documentSequences.$inferSelect>
|
rows: Array<typeof documentSequences.$inferSelect>
|
||||||
) {
|
) {
|
||||||
const branchDisplayMap = await resolveBranchDisplays({
|
const [branchDisplayMap, productTypeMap] = await Promise.all([
|
||||||
organizationId,
|
resolveBranchDisplays({
|
||||||
|
organizationId: organization.id,
|
||||||
branchIds: rows.map((row) => row.branchId || null)
|
branchIds: rows.map((row) => row.branchId || null)
|
||||||
});
|
}),
|
||||||
|
resolveProductTypeMap(organization.id)
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 ? (branchDisplayMap.get(branchId) ?? null) : null;
|
||||||
|
const normalizedProductType = normalizeDocumentSequenceProductType(row.productType);
|
||||||
|
const productType = productTypeMap.get(normalizedProductType) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
|
organizationName: organization.name,
|
||||||
|
organizationCode,
|
||||||
branchId,
|
branchId,
|
||||||
branchCode: branchDisplay?.code ?? null,
|
branchCode: branchDisplay?.code ?? null,
|
||||||
branchName: branchDisplay?.name ?? null,
|
branchName: branchDisplay?.name ?? null,
|
||||||
@@ -169,11 +303,24 @@ async function mapSequenceRecords(
|
|||||||
branchCode: branchDisplay?.code ?? null,
|
branchCode: branchDisplay?.code ?? null,
|
||||||
branchName: branchDisplay?.name ?? null
|
branchName: branchDisplay?.name ?? null
|
||||||
}),
|
}),
|
||||||
|
productType: normalizedProductType,
|
||||||
|
productTypeLabel:
|
||||||
|
normalizedProductType === DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE
|
||||||
|
? 'All Product Types'
|
||||||
|
: normalizedProductType === 'legacy'
|
||||||
|
? 'Legacy'
|
||||||
|
: productType?.label ?? null,
|
||||||
|
productTypeSequenceCode:
|
||||||
|
normalizedProductType === 'legacy'
|
||||||
|
? null
|
||||||
|
: resolveDocumentSequenceProductTypeCode(normalizedProductType),
|
||||||
documentType: row.documentType,
|
documentType: row.documentType,
|
||||||
prefix: row.prefix,
|
prefix: row.prefix,
|
||||||
period: row.period,
|
period: row.period,
|
||||||
currentNumber: row.currentNumber,
|
currentNumber: row.currentNumber,
|
||||||
paddingLength: row.paddingLength,
|
paddingLength: row.paddingLength,
|
||||||
|
format: row.format,
|
||||||
|
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()
|
||||||
@@ -181,24 +328,46 @@ async function mapSequenceRecords(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
|
||||||
|
return {
|
||||||
|
code: buildDocumentCode(
|
||||||
|
row.format,
|
||||||
|
row.prefix,
|
||||||
|
row.period,
|
||||||
|
row.currentNumber + 1,
|
||||||
|
row.paddingLength
|
||||||
|
),
|
||||||
|
documentType: row.documentType,
|
||||||
|
productType: row.productType,
|
||||||
|
branchId: row.branchId || null,
|
||||||
|
currentNumber: row.currentNumber,
|
||||||
|
nextNumber: row.currentNumber + 1,
|
||||||
|
period: row.period,
|
||||||
|
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 rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(documentSequences)
|
.from(documentSequences)
|
||||||
.where(eq(documentSequences.organizationId, organizationId))
|
.where(eq(documentSequences.organizationId, organization.id))
|
||||||
.orderBy(
|
.orderBy(
|
||||||
asc(documentSequences.documentType),
|
asc(documentSequences.documentType),
|
||||||
|
asc(documentSequences.productType),
|
||||||
asc(documentSequences.period),
|
asc(documentSequences.period),
|
||||||
asc(documentSequences.branchId)
|
asc(documentSequences.branchId)
|
||||||
);
|
);
|
||||||
|
|
||||||
const records = await mapSequenceRecords(organizationId, rows);
|
const records = await mapSequenceRecords(organization, rows);
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
...record,
|
...record,
|
||||||
nextPreview: buildDocumentCode(
|
nextPreview: buildDocumentCode(
|
||||||
|
record.format,
|
||||||
record.prefix,
|
record.prefix,
|
||||||
record.period,
|
record.period,
|
||||||
record.currentNumber + 1,
|
record.currentNumber + 1,
|
||||||
@@ -211,8 +380,9 @@ export async function getDocumentSequence(
|
|||||||
id: string,
|
id: string,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
): Promise<DocumentSequenceRecord> {
|
): Promise<DocumentSequenceRecord> {
|
||||||
const row = await assertSequence(id, organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
const [record] = await mapSequenceRecords(organizationId, [row]);
|
const row = await assertSequence(id, organization.id);
|
||||||
|
const [record] = await mapSequenceRecords(organization, [row]);
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
@@ -221,24 +391,49 @@ export async function createDocumentSequence(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
payload: DocumentSequenceMutationPayload
|
payload: DocumentSequenceMutationPayload
|
||||||
): Promise<DocumentSequenceRecord> {
|
): Promise<DocumentSequenceRecord> {
|
||||||
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
|
const documentType = normalizeDocumentType(payload.documentType);
|
||||||
const branchId = normalizeBranchId(payload.branchId);
|
const branchId = normalizeBranchId(payload.branchId);
|
||||||
|
const productType = await resolveScopedProductType({
|
||||||
|
organizationId: organization.id,
|
||||||
|
documentType,
|
||||||
|
productType: payload.productType
|
||||||
|
});
|
||||||
|
const period = normalizePeriod(payload.period);
|
||||||
|
|
||||||
|
await assertUniqueScope({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId,
|
||||||
|
productType,
|
||||||
|
documentType,
|
||||||
|
period
|
||||||
|
});
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(documentSequences)
|
.insert(documentSequences)
|
||||||
.values({
|
.values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
organizationId,
|
organizationId: organization.id,
|
||||||
branchId,
|
branchId,
|
||||||
documentType: payload.documentType.trim(),
|
productType,
|
||||||
prefix: payload.prefix.trim(),
|
documentType,
|
||||||
period: payload.period.trim(),
|
prefix: await resolvePrefix({
|
||||||
|
organization,
|
||||||
|
documentType,
|
||||||
|
productType,
|
||||||
|
prefix: payload.prefix
|
||||||
|
}),
|
||||||
|
period,
|
||||||
currentNumber: payload.currentNumber ?? 0,
|
currentNumber: payload.currentNumber ?? 0,
|
||||||
paddingLength: payload.paddingLength,
|
paddingLength: payload.paddingLength,
|
||||||
|
format: normalizeFormat(payload.format),
|
||||||
|
resetPolicy: normalizeResetPolicy(payload.resetPolicy),
|
||||||
isActive: payload.isActive ?? true,
|
isActive: payload.isActive ?? true,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const [record] = await mapSequenceRecords(organizationId, [created]);
|
const [record] = await mapSequenceRecords(organization, [created]);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,26 +442,51 @@ export async function updateDocumentSequence(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
payload: Partial<DocumentSequenceMutationPayload>
|
payload: Partial<DocumentSequenceMutationPayload>
|
||||||
): Promise<DocumentSequenceRecord> {
|
): Promise<DocumentSequenceRecord> {
|
||||||
const current = await assertSequence(id, organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
|
const current = await assertSequence(id, organization.id);
|
||||||
|
const documentType = normalizeDocumentType(payload.documentType ?? current.documentType);
|
||||||
|
const branchId =
|
||||||
|
payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId);
|
||||||
|
const productType = await resolveScopedProductType({
|
||||||
|
organizationId: organization.id,
|
||||||
|
documentType,
|
||||||
|
productType: payload.productType ?? current.productType
|
||||||
|
});
|
||||||
|
const period = normalizePeriod(payload.period ?? current.period);
|
||||||
|
|
||||||
|
await assertUniqueScope({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId,
|
||||||
|
productType,
|
||||||
|
documentType,
|
||||||
|
period,
|
||||||
|
currentId: id
|
||||||
|
});
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(documentSequences)
|
.update(documentSequences)
|
||||||
.set({
|
.set({
|
||||||
documentType: payload.documentType?.trim() ?? current.documentType,
|
branchId,
|
||||||
prefix: payload.prefix?.trim() ?? current.prefix,
|
productType,
|
||||||
period: payload.period?.trim() ?? current.period,
|
documentType,
|
||||||
branchId:
|
prefix: await resolvePrefix({
|
||||||
payload.branchId === undefined
|
organization,
|
||||||
? current.branchId
|
documentType,
|
||||||
: normalizeBranchId(payload.branchId),
|
productType,
|
||||||
|
prefix: payload.prefix ?? current.prefix
|
||||||
|
}),
|
||||||
|
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),
|
||||||
|
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();
|
||||||
|
|
||||||
const [record] = await mapSequenceRecords(organizationId, [updated]);
|
const [record] = await mapSequenceRecords(organization, [updated]);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +495,9 @@ export async function resetDocumentSequence(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
payload: DocumentSequenceResetPayload
|
payload: DocumentSequenceResetPayload
|
||||||
): Promise<DocumentSequenceRecord> {
|
): Promise<DocumentSequenceRecord> {
|
||||||
await assertSequence(id, organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
|
await assertSequence(id, organization.id);
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(documentSequences)
|
.update(documentSequences)
|
||||||
.set({
|
.set({
|
||||||
@@ -285,15 +507,16 @@ export async function resetDocumentSequence(
|
|||||||
.where(eq(documentSequences.id, id))
|
.where(eq(documentSequences.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const [record] = await mapSequenceRecords(organizationId, [updated]);
|
const [record] = await mapSequenceRecords(organization, [updated]);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDocumentSequence(id: string, organizationId: string) {
|
export async function deleteDocumentSequence(id: string, organizationId: string) {
|
||||||
const sequence = await assertSequence(id, organizationId);
|
const organization = await resolveOrganizationRecord(organizationId);
|
||||||
|
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));
|
||||||
|
const [record] = await mapSequenceRecords(organization, [sequence]);
|
||||||
|
|
||||||
const [record] = await mapSequenceRecords(organizationId, [sequence]);
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,17 +526,69 @@ export async function previewDocumentSequenceById(id: string, organizationId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||||
const organizationId = await resolveOrganizationId(input.organizationId);
|
const organization = await resolveOrganizationRecord(input.organizationId);
|
||||||
const period = input.period ?? getCurrentPeriod();
|
const documentType = normalizeDocumentType(input.documentType);
|
||||||
const branchId = normalizeBranchId(input.branchId);
|
const branchId = normalizeBranchId(input.branchId);
|
||||||
|
const productType = await resolveScopedProductType({
|
||||||
|
organizationId: organization.id,
|
||||||
|
documentType,
|
||||||
|
productType: input.productType
|
||||||
|
});
|
||||||
|
const period = normalizePeriod(input.period ?? getCurrentPeriod());
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
try {
|
||||||
const sequence = await ensureSequenceRow(
|
const result = await db.transaction(async (tx) => {
|
||||||
organizationId,
|
let sequence = await tx.query.documentSequences.findFirst({
|
||||||
input.documentType.trim(),
|
where: and(
|
||||||
|
eq(documentSequences.organizationId, organization.id),
|
||||||
|
eq(documentSequences.branchId, branchId),
|
||||||
|
eq(documentSequences.productType, productType),
|
||||||
|
eq(documentSequences.documentType, documentType),
|
||||||
|
eq(documentSequences.period, period)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sequence) {
|
||||||
|
await tx
|
||||||
|
.insert(documentSequences)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId,
|
||||||
|
productType,
|
||||||
|
documentType,
|
||||||
|
prefix: await resolvePrefix({
|
||||||
|
organization,
|
||||||
|
documentType,
|
||||||
|
productType
|
||||||
|
}),
|
||||||
period,
|
period,
|
||||||
branchId
|
currentNumber: 0,
|
||||||
);
|
paddingLength: 3,
|
||||||
|
format: DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
||||||
|
resetPolicy: DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
||||||
|
isActive: true
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
sequence = await tx.query.documentSequences.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(documentSequences.organizationId, organization.id),
|
||||||
|
eq(documentSequences.branchId, branchId),
|
||||||
|
eq(documentSequences.productType, productType),
|
||||||
|
eq(documentSequences.documentType, documentType),
|
||||||
|
eq(documentSequences.period, period)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sequence) {
|
||||||
|
throw new AuthError('Unable to initialize document sequence.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sequence.isActive) {
|
||||||
|
throw new AuthError('Document sequence is inactive.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
.update(documentSequences)
|
.update(documentSequences)
|
||||||
@@ -325,18 +600,55 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
sequenceId: updated.id,
|
||||||
|
result: {
|
||||||
code: buildDocumentCode(
|
code: buildDocumentCode(
|
||||||
|
updated.format,
|
||||||
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,
|
||||||
branchId: updated.branchId || null,
|
branchId: updated.branchId || null,
|
||||||
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({
|
||||||
|
organizationId: organization.id,
|
||||||
|
entityType: 'crm_document_sequence',
|
||||||
|
entityId: result.sequenceId,
|
||||||
|
action: 'generate',
|
||||||
|
branchId: branchId || null,
|
||||||
|
afterData: result.result
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.result;
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
entityType: 'crm_document_sequence',
|
||||||
|
entityId: `${documentType}:${branchId}:${productType}:${period}`,
|
||||||
|
action: 'generate_failed',
|
||||||
|
branchId: branchId || null,
|
||||||
|
afterData: {
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
documentType,
|
||||||
|
productType,
|
||||||
|
period
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep original error as the primary failure signal.
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface DocumentSequenceInput {
|
export interface DocumentSequenceInput {
|
||||||
documentType: string;
|
documentType: string;
|
||||||
branchId?: string | null;
|
branchId?: string | null;
|
||||||
|
productType?: string | null;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
period?: string;
|
period?: string;
|
||||||
}
|
}
|
||||||
@@ -8,15 +9,22 @@ export interface DocumentSequenceInput {
|
|||||||
export interface DocumentSequenceRecord {
|
export interface DocumentSequenceRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
organizationName: string | null;
|
||||||
|
organizationCode: string | null;
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
branchCode: string | null;
|
branchCode: string | null;
|
||||||
branchName: string | null;
|
branchName: string | null;
|
||||||
branchDisplayName: string | null;
|
branchDisplayName: string | null;
|
||||||
|
productType: string;
|
||||||
|
productTypeLabel: string | null;
|
||||||
|
productTypeSequenceCode: string | null;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
period: string;
|
period: string;
|
||||||
currentNumber: number;
|
currentNumber: number;
|
||||||
paddingLength: number;
|
paddingLength: number;
|
||||||
|
format: string;
|
||||||
|
resetPolicy: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -25,6 +33,7 @@ export interface DocumentSequenceRecord {
|
|||||||
export interface DocumentSequenceResult {
|
export interface DocumentSequenceResult {
|
||||||
code: string;
|
code: string;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
|
productType: string;
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
currentNumber: number;
|
currentNumber: number;
|
||||||
nextNumber: number;
|
nextNumber: number;
|
||||||
@@ -43,6 +52,12 @@ export interface DocumentSequenceBranchOption {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocumentSequenceProductTypeOption {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
sequenceCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocumentSequenceListResponse {
|
export interface DocumentSequenceListResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
@@ -66,11 +81,14 @@ export interface DocumentSequencePreviewResponse {
|
|||||||
|
|
||||||
export interface DocumentSequenceMutationPayload {
|
export interface DocumentSequenceMutationPayload {
|
||||||
documentType: string;
|
documentType: string;
|
||||||
|
productType: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
period: string;
|
period: string;
|
||||||
branchId?: string | null;
|
branchId?: string | null;
|
||||||
currentNumber?: number;
|
currentNumber?: number;
|
||||||
paddingLength: number;
|
paddingLength: number;
|
||||||
|
format?: string;
|
||||||
|
resetPolicy?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { StorageProvider } from '../types';
|
import type { StorageProvider } from "../types";
|
||||||
import { createLocalStorageProvider } from './local-provider';
|
import { createLocalStorageProvider } from "./local-provider.ts";
|
||||||
import { createS3StorageProvider } from './s3-provider';
|
import { createS3StorageProvider } from "./s3-provider.ts";
|
||||||
|
|
||||||
let cachedProvider: StorageProvider | null = null;
|
let cachedProvider: StorageProvider | null = null;
|
||||||
|
|
||||||
@@ -9,13 +9,13 @@ export function resolveStorageProvider(): StorageProvider {
|
|||||||
return cachedProvider;
|
return cachedProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = process.env.STORAGE_PROVIDER?.trim() || 'local';
|
const provider = process.env.STORAGE_PROVIDER?.trim() || "local";
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case 'local':
|
case "local":
|
||||||
cachedProvider = createLocalStorageProvider();
|
cachedProvider = createLocalStorageProvider();
|
||||||
return cachedProvider;
|
return cachedProvider;
|
||||||
case 's3':
|
case "s3":
|
||||||
cachedProvider = createS3StorageProvider();
|
cachedProvider = createS3StorageProvider();
|
||||||
return cachedProvider;
|
return cachedProvider;
|
||||||
default:
|
default:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user