uat-doc-perfix

This commit is contained in:
phaichayon
2026-06-30 14:48:34 +07:00
parent 9698228c51
commit 47eac3badc
28 changed files with 2518 additions and 996 deletions

View File

@@ -25,7 +25,7 @@
"field": "documentData.customer.id",
"source": "documentData",
"status": "PASS",
"value": "96954c53-fd37-4d97-80e0-1809756a015d",
"value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a",
"message": "String is populated"
},
{
@@ -60,7 +60,7 @@
"field": "documentData.contact.id",
"source": "documentData",
"status": "PASS",
"value": "3fc91684-ebed-49a9-b50e-0fb6fdaae87f",
"value": "951023ad-0424-4c05-9db1-c5004d525e23",
"message": "String is populated"
},
{
@@ -95,7 +95,7 @@
"field": "documentData.quotation.id",
"source": "documentData",
"status": "PASS",
"value": "bb3ebe66-a0ed-4b48-b125-795b549fa798",
"value": "9a7333a0-bb43-4d6e-b178-aae196456a31",
"message": "String is populated"
},
{
@@ -172,7 +172,7 @@
"field": "documentData.items[0].id",
"source": "documentData",
"status": "PASS",
"value": "ab4102fc-7ad9-4338-aee2-b847c10dbf15",
"value": "81cf1a1a-0ae0-4f6d-bb24-1315b0de5363",
"message": "String is populated"
},
{
@@ -228,7 +228,7 @@
"field": "documentData.items[1].id",
"source": "documentData",
"status": "PASS",
"value": "9c320ba4-cf4b-4b3b-b72e-c588c8723384",
"value": "3cfad159-e274-49f4-a195-0e3ae39ee5f9",
"message": "String is populated"
},
{
@@ -284,7 +284,7 @@
"field": "documentData.items[2].id",
"source": "documentData",
"status": "PASS",
"value": "7f64760c-65d6-4b23-9921-4a33d43a2efc",
"value": "3f48546a-2685-462e-a21d-e25ccc1348fd",
"message": "String is populated"
},
{
@@ -340,7 +340,7 @@
"field": "documentData.items[3].id",
"source": "documentData",
"status": "PASS",
"value": "4b5a4cef-3005-4253-9638-8c3cc9fade15",
"value": "9f0ef323-dbfc-4710-a90f-501f185a165a",
"message": "String is populated"
},
{
@@ -403,21 +403,21 @@
"field": "documentData.quotationCustomers[0].id",
"source": "documentData",
"status": "PASS",
"value": "04ff8d80-b200-4c01-8081-24d548a2a576",
"value": "fefd5c1a-cc0a-4365-9492-e00feef8d38e",
"message": "String is populated"
},
{
"field": "documentData.quotationCustomers[0].customerId",
"source": "documentData",
"status": "PASS",
"value": "96954c53-fd37-4d97-80e0-1809756a015d",
"value": "f4f1fca0-f4d6-4035-9062-af2a5e04c48a",
"message": "String is populated"
},
{
"field": "documentData.quotationCustomers[0].role",
"source": "documentData",
"status": "PASS",
"value": "a5d93b6c-131b-4e9e-9240-ac94fe12f3d8",
"value": "ed72c8c9-a690-4c93-afb7-9720a5fc7b3d",
"message": "String is populated"
},
{
@@ -438,7 +438,7 @@
"field": "documentData.topics.all[0].id",
"source": "documentData",
"status": "PASS",
"value": "58c2d002-1c6f-40c4-b68a-c11ece1fff33",
"value": "e6e4b61e-24a4-4ca9-a1d0-2cd3981ab59c",
"message": "String is populated"
},
{
@@ -501,7 +501,7 @@
"field": "documentData.topics.all[1].id",
"source": "documentData",
"status": "PASS",
"value": "5a069f2d-dfb1-4ec0-9f51-3f9b657e7731",
"value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9",
"message": "String is populated"
},
{
@@ -564,7 +564,7 @@
"field": "documentData.topics.all[2].id",
"source": "documentData",
"status": "PASS",
"value": "d16b5a95-d811-4860-9670-596bef0c13df",
"value": "85d82f36-3e8f-4e46-8357-84879c728a49",
"message": "String is populated"
},
{
@@ -634,7 +634,7 @@
"field": "documentData.topics.exclusion[0].id",
"source": "documentData",
"status": "PASS",
"value": "5a069f2d-dfb1-4ec0-9f51-3f9b657e7731",
"value": "83a4d1ea-bd15-425f-ba45-7116fe59a0d9",
"message": "String is populated"
},
{

View File

@@ -3,8 +3,8 @@
"overallStatus": "PASS",
"quotationCode": "CR2606-1124",
"approvedSnapshotAvailable": true,
"approvedTemplateVersionId": "850b13a2-c9ae-4992-89e7-a43f50c34378",
"computedTemplateVersionId": "850b13a2-c9ae-4992-89e7-a43f50c34378",
"approvedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
"computedTemplateVersionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
"inconsistentTemplateKeys": [],
"computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
"storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",

View File

@@ -1,5 +1,5 @@
{
"generatedAt": "2026-06-30T02:56:03.783Z",
"generatedAt": "2026-06-30T05:56:31.314Z",
"overallStatus": "PASS",
"quotationCode": "CR2606-1124",
"artifacts": {

View File

@@ -1,6 +1,6 @@
# PDF Integrity Summary
- Generated At: 2026-06-30T02:56:03.783Z
- Generated At: 2026-06-30T05:56:31.314Z
- Overall Status: PASS
- Quotation Code: CR2606-1124

View File

@@ -1,12 +1,12 @@
{
"generatedAt": "2026-06-30T02:56:00.835Z",
"generatedAt": "2026-06-30T05:56:30.085Z",
"overallStatus": "PASS",
"quotationCode": "CR2606-1124",
"expectedFixtureCode": "QT-H5-AUDIT",
"template": {
"templateName": "ALLA Demo Organization Quotation Standard",
"version": "1.0",
"versionId": "850b13a2-c9ae-4992-89e7-a43f50c34378",
"versionId": "0cf95872-525b-49fd-ae0c-4609cb4f05e9",
"fieldCount": 44,
"previousVersion": null,
"deletedFields": [],

View File

@@ -1,6 +1,6 @@
# PDF Audit Report
- Generated At: 2026-06-30T02:56:00.835Z
- Generated At: 2026-06-30T05:56:30.085Z
- Overall Status: PASS
- Quotation Code: CR2606-1124
- Template: ALLA Demo Organization Quotation Standard v1.0

View 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"
);

View File

@@ -57,6 +57,13 @@
"when": 1782910000000,
"tag": "0007_document_library_foundation",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1782913600000,
"tag": "0008_document_sequence_scope_refactor",
"breakpoints": true
}
]
}

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <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
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

389
plans/task-p.6.1.md Normal file
View 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.

View File

@@ -15,11 +15,14 @@ type Params = {
const sequenceSchema = z.object({
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(),
branchId: z.string().optional().nullable(),
currentNumber: z.number().int().min(0).optional(),
paddingLength: z.number().int().min(1).optional(),
format: z.string().optional(),
resetPolicy: z.string().optional(),
isActive: z.boolean().optional()
});

View File

@@ -10,11 +10,14 @@ import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const sequenceSchema = z.object({
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),
branchId: z.string().optional().nullable(),
currentNumber: z.number().int().min(0).optional(),
paddingLength: z.number().int().min(1),
format: z.string().optional(),
resetPolicy: z.string().optional(),
isActive: z.boolean().optional()
});

View File

@@ -2,8 +2,10 @@ import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
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 { documentSequencesQueryOptions } from '@/features/foundation/document-sequence/queries';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
@@ -24,6 +26,21 @@ export default async function DocumentSequencesRoute() {
canRead && 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 (
<PageContainer
@@ -38,7 +55,11 @@ export default async function DocumentSequencesRoute() {
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<DocumentSequenceSettings branches={branches} />
<DocumentSequenceSettings
branches={branches}
productTypes={productTypes}
organizationName={organizationName}
/>
</HydrationBoundary>
) : null}
</PageContainer>

View File

@@ -158,19 +158,28 @@ export const documentSequences = pgTable(
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id').default('').notNull(),
productType: text('product_type').default('all').notNull(),
documentType: text('document_type').notNull(),
prefix: text('prefix').notNull(),
period: text('period').notNull(),
currentNumber: integer('current_number').default(0).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(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
organizationDocumentPeriodBranchIdx: uniqueIndex(
'document_sequences_org_doc_period_branch_idx'
).on(table.organizationId, table.documentType, table.period, table.branchId)
organizationBranchProductDocumentPeriodIdx: uniqueIndex(
'document_sequences_org_branch_product_doc_period_idx'
).on(
table.organizationId,
table.branchId,
table.productType,
table.documentType,
table.period
)
})
);

View File

@@ -3,12 +3,19 @@ import path from "node:path";
import crypto from "node:crypto";
import { pathToFileURL } from "node:url";
import postgres from "postgres";
import {
formatDocumentSequencePeriod,
normalizeDocumentSequenceProductType,
resolveDocumentSequenceOrganizationCode,
resolveDocumentSequenceProductTypeCode,
} from "../../features/foundation/document-sequence/config.ts";
type SqlClient = ReturnType<typeof postgres>;
type OrganizationRow = {
id: string;
name: string;
slug: string;
createdBy: string;
};
@@ -891,16 +898,40 @@ function buildDocumentCode(prefix: string, number: number) {
return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`;
}
function buildQuotationCode(productTypeCode: string, number: number) {
const prefixByProductType: Record<string, string> = {
crane: "CR",
dockdoor: "DK",
solarcell: "SOL",
service: "SV",
spare_part: "SP",
};
function buildQuotationCode(input: {
organizationSlug: string;
productTypeCode: string;
number: number;
quotationDate?: Date;
}) {
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(
@@ -919,6 +950,17 @@ function seededNote(scope: string) {
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) {
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[]> {
return sql<OrganizationRow[]>`
select id, name, created_by as "createdBy"
select id, name, slug, created_by as "createdBy"
from organizations
order by name asc
`;
@@ -2036,6 +2078,7 @@ function buildLeadAndOpportunityData(
}
function buildQuotationData(
organization: OrganizationRow,
optionMap: Map<string, OptionRow>,
storyOpportunityMap: Map<string, SeedOpportunity>,
storyCustomerMap: Map<string, SeedCustomer>,
@@ -2051,7 +2094,7 @@ function buildQuotationData(
const approvalRequests: SeedApprovalRequest[] = [];
const approvalActions: SeedApprovalAction[] = [];
let quotationSequence = 1101;
const nextQuotationSequence = createQuotationSequenceAllocator(1101);
const acceptedStories = new Set([
"website-warehouse-crane-win",
@@ -2124,10 +2167,16 @@ function buildQuotationData(
const taxRate = 7;
const taxAmount = Math.round((subtotal - discount) * (taxRate / 100));
const totalAmount = subtotal - discount + taxAmount;
const quotationNumber = nextQuotationSequence(story.productTypeCode);
quotations.push({
id: quotationId,
code: buildQuotationCode(story.productTypeCode, quotationSequence++),
code: buildQuotationCode({
organizationSlug: organization.slug,
productTypeCode: story.productTypeCode,
number: quotationNumber,
quotationDate,
}),
opportunityId: opportunity.id,
customerId: customer.id,
contactId: contact?.id ?? null,
@@ -2458,28 +2507,35 @@ function buildQuotationData(
quotations.length,
);
const subtotal = 980000 + (quotations.length % 8) * 240000;
const discount = quotations.length % 3 === 0 ? 25000 : 0;
const taxRate = 7;
const taxAmount = Math.round((subtotal - discount) * 0.07);
const totalAmount = subtotal - discount + taxAmount;
const quotationId = crypto.randomUUID();
quotations.push({
id: quotationId,
code: buildQuotationCode(
choose(["crane", "dockdoor", "solarcell"], quotations.length),
quotationSequence++
),
opportunityId: null,
customerId: customer.id,
contactId: contact?.id ?? null,
branchId: customer.branchId,
const discount = quotations.length % 3 === 0 ? 25000 : 0;
const taxRate = 7;
const taxAmount = Math.round((subtotal - discount) * 0.07);
const totalAmount = subtotal - discount + taxAmount;
const quotationId = crypto.randomUUID();
const supplementalProductTypeCode = choose(
["crane", "dockdoor", "solarcell"],
quotations.length
);
const quotationNumber = nextQuotationSequence(supplementalProductTypeCode);
quotations.push({
id: quotationId,
code: buildQuotationCode({
organizationSlug: organization.slug,
productTypeCode: supplementalProductTypeCode,
number: quotationNumber,
quotationDate,
validUntil: addDays(quotationDate, 30),
quotationTypeId: getOptionId(
optionMap,
"crm_quotation_type",
choose(["crane", "dockdoor", "solarcell"], quotations.length),
),
}),
opportunityId: null,
customerId: customer.id,
contactId: contact?.id ?? null,
branchId: customer.branchId,
quotationDate,
validUntil: addDays(quotationDate, 30),
quotationTypeId: getOptionId(
optionMap,
"crm_quotation_type",
supplementalProductTypeCode,
),
projectName: `${customer.name} Supplemental Quotation ${quotations.length + 1}`,
projectLocation: `${customer.district}, ${customer.province}`,
attention: contact?.name ?? null,
@@ -2866,12 +2922,13 @@ async function bumpDocumentSequences(
sql: SqlClient,
organizationId: string,
branches: BranchRow[],
optionMap: Map<string, OptionRow>,
quotations: SeedQuotation[],
) {
const documentTypes = [
["customer", 850],
["crm_lead", 980],
["crm_opportunity", 1050],
["quotation", 1160],
] as const;
for (const branch of branches) {
@@ -2881,11 +2938,46 @@ async function bumpDocumentSequences(
set current_number = greatest(current_number, ${currentNumber}), updated_at = now()
where organization_id = ${organizationId}
and branch_id = ${branch.id}
and product_type = ${"all"}
and document_type = ${documentType}
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) {
@@ -2916,6 +3008,7 @@ async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
users,
);
const quotationData = buildQuotationData(
organization,
optionMap,
leadData.storyOpportunityMap,
storyCustomerMap,
@@ -2944,7 +3037,13 @@ async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
quotationData.approvalActions,
);
await bumpDocumentSequences(sql, organization.id, branches);
await bumpDocumentSequences(
sql,
organization.id,
branches,
optionMap,
quotationData.quotations
);
console.log(
`[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

View File

@@ -23,6 +23,7 @@ import {
type CrmSecurityContext,
canAccessScopedCrmRecord
} from '@/features/crm/security/server/service';
import { formatDocumentSequencePeriod } from '@/features/foundation/document-sequence/config';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import {
resolveBranchDisplays,
@@ -1344,11 +1345,13 @@ export async function createQuotation(
opportunityId: payload.opportunityId,
customerId: payload.customerId
});
const documentCode = await generateNextDocumentCode({
organizationId,
documentType: 'quotation',
branchId: payload.branchId ?? opportunity?.branchId ?? ''
});
const documentCode = await generateNextDocumentCode({
organizationId,
documentType: 'quotation',
branchId: payload.branchId ?? opportunity?.branchId ?? '',
productType: payload.quotationType,
period: formatDocumentSequencePeriod(new Date(payload.quotationDate))
});
return db.transaction(async (tx) => {
const [created] = await tx
@@ -2229,11 +2232,13 @@ export async function createQuotationRevision(
const currentItems = await listQuotationItems(quotationId, organizationId);
const currentCustomers = await listQuotationCustomers(quotationId, organizationId);
const currentTopics = await listQuotationTopics(quotationId, organizationId);
const documentCode = await generateNextDocumentCode({
organizationId,
documentType: 'quotation',
branchId: parent.branchId ?? ''
});
const documentCode = await generateNextDocumentCode({
organizationId,
documentType: 'quotation',
branchId: parent.branchId ?? '',
productType: parent.quotationType,
period: formatDocumentSequencePeriod(new Date())
});
return db.transaction(async (tx) => {
const [created] = await tx

View File

@@ -15,16 +15,15 @@ import {
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { formatDateTime } from '@/lib/date-format';
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 {
createDocumentSequenceMutation,
deleteDocumentSequenceMutation,
@@ -35,19 +34,31 @@ import { documentSequencesQueryOptions } from '../queries';
import type {
DocumentSequenceBranchOption,
DocumentSequenceListItem,
DocumentSequenceMutationPayload
DocumentSequenceMutationPayload,
DocumentSequenceProductTypeOption
} from '../types';
type SequenceState = {
documentType: string;
productType: string;
prefix: string;
period: string;
branchId: string;
currentNumber: string;
paddingLength: string;
format: string;
resetPolicy: string;
isActive: boolean;
};
type SequenceFilters = {
branchId: string;
productType: string;
documentType: string;
period: string;
prefix: string;
};
function toBranchOptions(branches: FoundationBranch[]): DocumentSequenceBranchOption[] {
return branches.map((branch) => ({
id: branch.id,
@@ -60,23 +71,29 @@ function toBranchOptions(branches: FoundationBranch[]): DocumentSequenceBranchOp
function toState(sequence?: DocumentSequenceListItem): SequenceState {
return {
documentType: sequence?.documentType ?? 'quotation',
prefix: sequence?.prefix ?? 'QT',
productType: sequence?.productType ?? 'crane',
prefix: sequence?.prefix ?? '',
period: sequence?.period ?? '',
branchId: sequence?.branchId ?? '__all__',
currentNumber: String(sequence?.currentNumber ?? 0),
paddingLength: String(sequence?.paddingLength ?? 3),
format: sequence?.format ?? DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
resetPolicy: sequence?.resetPolicy ?? DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
isActive: sequence?.isActive ?? true
};
}
function buildPayload(state: SequenceState): DocumentSequenceMutationPayload {
return {
documentType: state.documentType,
prefix: state.prefix,
period: state.period,
documentType: state.documentType.trim(),
productType: state.productType,
prefix: state.prefix.trim(),
period: state.period.trim(),
branchId: state.branchId === '__all__' ? null : state.branchId,
currentNumber: Number(state.currentNumber || 0),
paddingLength: Number(state.paddingLength || 3),
currentNumber: Number(state.currentNumber),
paddingLength: Number(state.paddingLength),
format: state.format.trim(),
resetPolicy: state.resetPolicy.trim(),
isActive: state.isActive
};
}
@@ -85,14 +102,21 @@ function SequenceDialog({
open,
onOpenChange,
sequence,
branches
branches,
productTypes
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sequence?: DocumentSequenceListItem;
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({
...createDocumentSequenceMutation,
onSuccess: () => {
@@ -100,7 +124,7 @@ function SequenceDialog({
onOpenChange(false);
},
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({
@@ -110,47 +134,63 @@ function SequenceDialog({
onOpenChange(false);
},
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(() => {
if (open) {
setState(toState(sequence));
}
}, [open, sequence]);
async function onSubmit(event: React.FormEvent) {
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const payload = buildPayload(state);
if (sequence) {
await updateMutation.mutateAsync({ id: sequence.id, values: payload });
updateMutation.mutate({ id: sequence.id, values: payload });
return;
}
await createMutation.mutateAsync(payload);
createMutation.mutate(payload);
}
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>{sequence ? 'Edit Sequence' : 'Create Sequence'}</DialogTitle>
<DialogDescription>
Preview never increments the counter. Generation remains server-side only.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<Input
placeholder='Document type'
value={state.documentType}
onChange={(event) =>
setState((current) => ({ ...current, documentType: event.target.value }))
}
/>
<div className='grid gap-4 md:grid-cols-2'>
<Input
placeholder='Document type'
value={state.documentType}
onChange={(event) =>
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'>
<Input
placeholder='Prefix'
@@ -160,19 +200,18 @@ function SequenceDialog({
}
/>
<Input
placeholder='Period'
placeholder='Period (YYMM)'
value={state.period}
onChange={(event) =>
setState((current) => ({ ...current, period: event.target.value }))
}
/>
</div>
<div className='grid gap-4 md:grid-cols-3'>
<Select
value={state.branchId}
onValueChange={(value) =>
setState((current) => ({ ...current, branchId: value }))
}
onValueChange={(value) => setState((current) => ({ ...current, branchId: value }))}
>
<SelectTrigger>
<SelectValue placeholder='Select branch scope' />
@@ -201,11 +240,29 @@ function SequenceDialog({
}
/>
</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>
<div className='font-medium'>Active</div>
<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>
<Switch
@@ -215,6 +272,7 @@ function SequenceDialog({
}
/>
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
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 [dialogState, setDialogState] = React.useState<{
open: boolean;
sequence?: DocumentSequenceListItem;
}>({
const [dialogState, setDialogState] = React.useState<{ open: boolean; sequence?: DocumentSequenceListItem }>({
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({
...resetDocumentSequenceMutation,
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 (
<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 className='font-medium'>Document Sequence Admin</div>
<div className='text-muted-foreground text-sm'>
Maintain organization-scoped numbering strategy without rewriting historic document
codes.
Maintain running numbers by organization, branch, product type, document type, and
period without rewriting historical codes.
</div>
</div>
<Button onClick={() => setDialogState({ open: true })}>
@@ -274,27 +378,83 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
</Button>
</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'>
No document sequences configured yet.
No document sequences match the current filters.
</div>
) : (
data.items.map((sequence) => (
items.map((sequence) => (
<div key={sequence.id} className='rounded-xl border p-5'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div>
<div className='text-xl font-semibold'>{sequence.documentType}</div>
<div className='text-muted-foreground text-sm'>
{sequence.prefix} {sequence.period} {'\u2022'}{' '}
<span
className={
sequence.branchName || !sequence.branchId
? undefined
: 'text-muted-foreground italic'
}
>
{sequence.branchDisplayName ?? 'All Branches'}
</span>
{sequence.prefix} {sequence.period} {'\u2022'} {sequence.branchDisplayName}{' '}
{'\u2022'} {sequence.productTypeLabel ?? sequence.productType}
</div>
</div>
<div className='flex gap-2'>
@@ -305,36 +465,31 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
</div>
</div>
<div className='mt-4 grid gap-4 md:grid-cols-4'>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Branch</div>
<div className='mt-1 text-sm font-medium'>
{sequence.branchDisplayName ?? 'All Branches'}
</div>
</div>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Current Number</div>
<div className='mt-1 text-sm font-medium'>{sequence.currentNumber}</div>
</div>
<div className='rounded-lg bg-muted/40 p-4'>
<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 className='mt-4 grid gap-4 md:grid-cols-4 xl:grid-cols-8'>
<MetricCard label='Organization' value={sequence.organizationName ?? '-'} />
<MetricCard label='Branch' value={sequence.branchDisplayName ?? 'All Branches'} />
<MetricCard
label='Product Type'
value={sequence.productTypeLabel ?? sequence.productType}
/>
<MetricCard label='Period' value={sequence.period} />
<MetricCard label='Prefix' value={sequence.prefix} />
<MetricCard label='Format' value={sequence.format} />
<MetricCard label='Current Number' value={String(sequence.currentNumber)} />
<MetricCard label='Updated At' value={formatDateTime(sequence.updatedAt)} />
</div>
<div className='mt-4 flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => setDialogState({ open: true, sequence })}
>
Edit Sequence
Edit
</Button>
<Button
variant='outline'
size='sm'
onClick={() =>
resetMutation.mutate({
id: sequence.id,
@@ -343,10 +498,11 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
}
isLoading={resetMutation.isPending}
>
Reset Counter
Reset
</Button>
<Button
variant='outline'
size='sm'
onClick={() => deleteMutation.mutate(sequence.id)}
isLoading={deleteMutation.isPending}
>
@@ -362,7 +518,17 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
onOpenChange={(open) => setDialogState((current) => ({ ...current, open }))}
sequence={dialogState.sequence}
branches={branchOptions}
productTypes={productTypes}
/>
</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>
);
}

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

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

View File

@@ -1,9 +1,19 @@
import { and, asc, eq, sql } from 'drizzle-orm';
import { documentSequences } from '@/db/schema';
import { and, asc, eq, ne, sql } from 'drizzle-orm';
import { documentSequences, organizations } from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service';
import { resolveBranchDisplays } from '@/features/foundation/display/server/display-resolver';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE,
normalizeDocumentSequenceProductType,
resolveDocumentSequenceOrganizationCode,
resolveDocumentSequenceProductTypeCode
} from './config';
import type {
DocumentSequenceInput,
DocumentSequenceListItem,
@@ -13,6 +23,8 @@ import type {
DocumentSequenceResult
} from './types';
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
customer: 'CUS',
contact: 'CON',
@@ -23,26 +35,54 @@ const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
approval: 'APV'
};
type OrganizationRecord = {
id: string;
name: string;
slug: string;
};
function getCurrentPeriod(date = new Date()) {
const year = String(date.getFullYear()).slice(-2);
const month = String(date.getMonth() + 1).padStart(2, '0');
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) {
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: {
branchId: string | null;
branchCode?: string | null;
@@ -63,104 +103,198 @@ function formatBranchDisplayName(input: {
return `Unknown Branch (${input.branchId})`;
}
async function resolveOrganizationId(organizationId?: string) {
if (organizationId) {
return organizationId;
}
function buildDocumentCode(
format: string,
prefix: string,
period: string,
nextNumber: number,
paddingLength: number
) {
const running = String(nextNumber).padStart(paddingLength, '0');
const organization = await getCurrentOrganization();
if (!organization) {
throw new AuthError('Active organization required', 400);
}
return organization.id;
return format
.replaceAll('{prefix}', prefix)
.replaceAll('{period}', period)
.replaceAll('{running}', running);
}
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
const nextNumber = row.currentNumber + 1;
async function resolveOrganizationRecord(organizationId?: string): Promise<OrganizationRecord> {
if (!organizationId) {
const organization = await getCurrentOrganization();
return {
code: buildDocumentCode(row.prefix, row.period, nextNumber, row.paddingLength),
documentType: row.documentType,
branchId: row.branchId || null,
currentNumber: row.currentNumber,
nextNumber,
period: row.period,
prefix: row.prefix
};
if (!organization) {
throw new AuthError('Active organization required.', 400);
}
return {
id: organization.id,
name: organization.name,
slug: organization.slug
};
}
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) {
const row = await db.query.documentSequences.findFirst({
where: and(
eq(documentSequences.id, id),
eq(documentSequences.organizationId, organizationId)
)
where: and(eq(documentSequences.id, id), eq(documentSequences.organizationId, organizationId))
});
if (!row) {
throw new AuthError('Document sequence not found', 404);
throw new AuthError('Document sequence not found.', 404);
}
return row;
}
async function ensureSequenceRow(
organizationId: string,
documentType: string,
period: string,
branchId: string
) {
async function assertUniqueScope(input: {
organizationId: string;
branchId: string;
productType: string;
documentType: string;
period: string;
currentId?: string;
}) {
const existing = await db.query.documentSequences.findFirst({
where: and(
eq(documentSequences.organizationId, organizationId),
eq(documentSequences.documentType, documentType),
eq(documentSequences.period, period),
eq(documentSequences.branchId, branchId)
eq(documentSequences.organizationId, input.organizationId),
eq(documentSequences.branchId, input.branchId),
eq(documentSequences.productType, input.productType),
eq(documentSequences.documentType, input.documentType),
eq(documentSequences.period, input.period),
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : [])
)
});
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(
organizationId: string,
organization: OrganizationRecord,
rows: Array<typeof documentSequences.$inferSelect>
) {
const branchDisplayMap = await resolveBranchDisplays({
organizationId,
branchIds: rows.map((row) => row.branchId || null)
});
const [branchDisplayMap, productTypeMap] = await Promise.all([
resolveBranchDisplays({
organizationId: organization.id,
branchIds: rows.map((row) => row.branchId || null)
}),
resolveProductTypeMap(organization.id)
]);
const organizationCode = resolveDocumentSequenceOrganizationCode(organization.slug);
return rows.map((row) => {
const branchId = row.branchId || null;
const branchDisplay = branchId ? (branchDisplayMap.get(branchId) ?? null) : null;
const normalizedProductType = normalizeDocumentSequenceProductType(row.productType);
const productType = productTypeMap.get(normalizedProductType) ?? null;
return {
id: row.id,
organizationId: row.organizationId,
organizationName: organization.name,
organizationCode,
branchId,
branchCode: branchDisplay?.code ?? null,
branchName: branchDisplay?.name ?? null,
@@ -169,11 +303,24 @@ async function mapSequenceRecords(
branchCode: branchDisplay?.code ?? 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,
prefix: row.prefix,
period: row.period,
currentNumber: row.currentNumber,
paddingLength: row.paddingLength,
format: row.format,
resetPolicy: row.resetPolicy,
isActive: row.isActive,
createdAt: row.createdAt.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(
organizationId: string
): Promise<DocumentSequenceListItem[]> {
const organization = await resolveOrganizationRecord(organizationId);
const rows = await db
.select()
.from(documentSequences)
.where(eq(documentSequences.organizationId, organizationId))
.where(eq(documentSequences.organizationId, organization.id))
.orderBy(
asc(documentSequences.documentType),
asc(documentSequences.productType),
asc(documentSequences.period),
asc(documentSequences.branchId)
);
const records = await mapSequenceRecords(organizationId, rows);
const records = await mapSequenceRecords(organization, rows);
return records.map((record) => ({
...record,
nextPreview: buildDocumentCode(
record.format,
record.prefix,
record.period,
record.currentNumber + 1,
@@ -211,8 +380,9 @@ export async function getDocumentSequence(
id: string,
organizationId: string
): Promise<DocumentSequenceRecord> {
const row = await assertSequence(id, organizationId);
const [record] = await mapSequenceRecords(organizationId, [row]);
const organization = await resolveOrganizationRecord(organizationId);
const row = await assertSequence(id, organization.id);
const [record] = await mapSequenceRecords(organization, [row]);
return record;
}
@@ -221,24 +391,49 @@ export async function createDocumentSequence(
organizationId: string,
payload: DocumentSequenceMutationPayload
): Promise<DocumentSequenceRecord> {
const organization = await resolveOrganizationRecord(organizationId);
const documentType = normalizeDocumentType(payload.documentType);
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
.insert(documentSequences)
.values({
id: crypto.randomUUID(),
organizationId,
organizationId: organization.id,
branchId,
documentType: payload.documentType.trim(),
prefix: payload.prefix.trim(),
period: payload.period.trim(),
productType,
documentType,
prefix: await resolvePrefix({
organization,
documentType,
productType,
prefix: payload.prefix
}),
period,
currentNumber: payload.currentNumber ?? 0,
paddingLength: payload.paddingLength,
format: normalizeFormat(payload.format),
resetPolicy: normalizeResetPolicy(payload.resetPolicy),
isActive: payload.isActive ?? true,
updatedAt: new Date()
})
.returning();
const [record] = await mapSequenceRecords(organizationId, [created]);
const [record] = await mapSequenceRecords(organization, [created]);
return record;
}
@@ -247,26 +442,51 @@ export async function updateDocumentSequence(
organizationId: string,
payload: Partial<DocumentSequenceMutationPayload>
): 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
.update(documentSequences)
.set({
documentType: payload.documentType?.trim() ?? current.documentType,
prefix: payload.prefix?.trim() ?? current.prefix,
period: payload.period?.trim() ?? current.period,
branchId:
payload.branchId === undefined
? current.branchId
: normalizeBranchId(payload.branchId),
branchId,
productType,
documentType,
prefix: await resolvePrefix({
organization,
documentType,
productType,
prefix: payload.prefix ?? current.prefix
}),
period,
currentNumber: payload.currentNumber ?? current.currentNumber,
paddingLength: payload.paddingLength ?? current.paddingLength,
format: normalizeFormat(payload.format ?? current.format),
resetPolicy: normalizeResetPolicy(payload.resetPolicy ?? current.resetPolicy),
isActive: payload.isActive ?? current.isActive,
updatedAt: new Date()
})
.where(eq(documentSequences.id, id))
.returning();
const [record] = await mapSequenceRecords(organizationId, [updated]);
const [record] = await mapSequenceRecords(organization, [updated]);
return record;
}
@@ -275,7 +495,9 @@ export async function resetDocumentSequence(
organizationId: string,
payload: DocumentSequenceResetPayload
): Promise<DocumentSequenceRecord> {
await assertSequence(id, organizationId);
const organization = await resolveOrganizationRecord(organizationId);
await assertSequence(id, organization.id);
const [updated] = await db
.update(documentSequences)
.set({
@@ -285,15 +507,16 @@ export async function resetDocumentSequence(
.where(eq(documentSequences.id, id))
.returning();
const [record] = await mapSequenceRecords(organizationId, [updated]);
const [record] = await mapSequenceRecords(organization, [updated]);
return record;
}
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));
const [record] = await mapSequenceRecords(organization, [sequence]);
const [record] = await mapSequenceRecords(organizationId, [sequence]);
return record;
}
@@ -303,40 +526,129 @@ export async function previewDocumentSequenceById(id: string, organizationId: st
}
export async function generateNextDocumentCode(input: DocumentSequenceInput) {
const organizationId = await resolveOrganizationId(input.organizationId);
const period = input.period ?? getCurrentPeriod();
const organization = await resolveOrganizationRecord(input.organizationId);
const documentType = normalizeDocumentType(input.documentType);
const branchId = normalizeBranchId(input.branchId);
return db.transaction(async (tx) => {
const sequence = await ensureSequenceRow(
organizationId,
input.documentType.trim(),
period,
branchId
);
const [updated] = await tx
.update(documentSequences)
.set({
currentNumber: sql`${documentSequences.currentNumber} + 1`,
updatedAt: new Date()
})
.where(eq(documentSequences.id, sequence.id))
.returning();
return {
code: buildDocumentCode(
updated.prefix,
updated.period,
updated.currentNumber,
updated.paddingLength
),
documentType: updated.documentType,
branchId: updated.branchId || null,
currentNumber: updated.currentNumber,
nextNumber: updated.currentNumber + 1,
period: updated.period,
prefix: updated.prefix
} satisfies DocumentSequenceResult;
const productType = await resolveScopedProductType({
organizationId: organization.id,
documentType,
productType: input.productType
});
const period = normalizePeriod(input.period ?? getCurrentPeriod());
try {
const result = await db.transaction(async (tx) => {
let 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) {
await tx
.insert(documentSequences)
.values({
id: crypto.randomUUID(),
organizationId: organization.id,
branchId,
productType,
documentType,
prefix: await resolvePrefix({
organization,
documentType,
productType
}),
period,
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
.update(documentSequences)
.set({
currentNumber: sql`${documentSequences.currentNumber} + 1`,
updatedAt: new Date()
})
.where(eq(documentSequences.id, sequence.id))
.returning();
return {
sequenceId: updated.id,
result: {
code: buildDocumentCode(
updated.format,
updated.prefix,
updated.period,
updated.currentNumber,
updated.paddingLength
),
documentType: updated.documentType,
productType: updated.productType,
branchId: updated.branchId || null,
currentNumber: updated.currentNumber,
nextNumber: updated.currentNumber + 1,
period: updated.period,
prefix: updated.prefix
} 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;
}
}

View File

@@ -1,6 +1,7 @@
export interface DocumentSequenceInput {
documentType: string;
branchId?: string | null;
productType?: string | null;
organizationId?: string;
period?: string;
}
@@ -8,15 +9,22 @@ export interface DocumentSequenceInput {
export interface DocumentSequenceRecord {
id: string;
organizationId: string;
organizationName: string | null;
organizationCode: string | null;
branchId: string | null;
branchCode: string | null;
branchName: string | null;
branchDisplayName: string | null;
productType: string;
productTypeLabel: string | null;
productTypeSequenceCode: string | null;
documentType: string;
prefix: string;
period: string;
currentNumber: number;
paddingLength: number;
format: string;
resetPolicy: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
@@ -25,6 +33,7 @@ export interface DocumentSequenceRecord {
export interface DocumentSequenceResult {
code: string;
documentType: string;
productType: string;
branchId: string | null;
currentNumber: number;
nextNumber: number;
@@ -43,6 +52,12 @@ export interface DocumentSequenceBranchOption {
displayName: string;
}
export interface DocumentSequenceProductTypeOption {
code: string;
label: string;
sequenceCode: string | null;
}
export interface DocumentSequenceListResponse {
success: boolean;
time: string;
@@ -66,11 +81,14 @@ export interface DocumentSequencePreviewResponse {
export interface DocumentSequenceMutationPayload {
documentType: string;
productType: string;
prefix: string;
period: string;
branchId?: string | null;
currentNumber?: number;
paddingLength: number;
format?: string;
resetPolicy?: string;
isActive?: boolean;
}

View File

@@ -1,6 +1,6 @@
import type { StorageProvider } from '../types';
import { createLocalStorageProvider } from './local-provider';
import { createS3StorageProvider } from './s3-provider';
import type { StorageProvider } from "../types";
import { createLocalStorageProvider } from "./local-provider.ts";
import { createS3StorageProvider } from "./s3-provider.ts";
let cachedProvider: StorageProvider | null = null;
@@ -9,13 +9,13 @@ export function resolveStorageProvider(): StorageProvider {
return cachedProvider;
}
const provider = process.env.STORAGE_PROVIDER?.trim() || 'local';
const provider = process.env.STORAGE_PROVIDER?.trim() || "local";
switch (provider) {
case 'local':
case "local":
cachedProvider = createLocalStorageProvider();
return cachedProvider;
case 's3':
case "s3":
cachedProvider = createS3StorageProvider();
return cachedProvider;
default: