diff --git a/artifacts/pdf-audit/runtime-payload-report.json b/artifacts/pdf-audit/runtime-payload-report.json index 3aa742a..c4bf3bf 100644 --- a/artifacts/pdf-audit/runtime-payload-report.json +++ b/artifacts/pdf-audit/runtime-payload-report.json @@ -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" }, { diff --git a/artifacts/pdf-audit/signature-audit-report.json b/artifacts/pdf-audit/signature-audit-report.json index a35bc82..a1d1ea1 100644 --- a/artifacts/pdf-audit/signature-audit-report.json +++ b/artifacts/pdf-audit/signature-audit-report.json @@ -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", diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json index 6ab87a5..15f2bd8 100644 --- a/artifacts/pdf-audit/summary.json +++ b/artifacts/pdf-audit/summary.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-30T02:56:03.783Z", + "generatedAt": "2026-06-30T05:56:31.314Z", "overallStatus": "PASS", "quotationCode": "CR2606-1124", "artifacts": { diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md index a5b06c7..d185cbd 100644 --- a/artifacts/pdf-audit/summary.md +++ b/artifacts/pdf-audit/summary.md @@ -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 diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json index bb681f0..ae2e14b 100644 --- a/docs/implementation/pdf-audit-report.json +++ b/docs/implementation/pdf-audit-report.json @@ -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": [], diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md index 76807ff..b95bb9d 100644 --- a/docs/implementation/pdf-audit-report.md +++ b/docs/implementation/pdf-audit-report.md @@ -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 diff --git a/drizzle/0008_document_sequence_scope_refactor.sql b/drizzle/0008_document_sequence_scope_refactor.sql new file mode 100644 index 0000000..16d5865 --- /dev/null +++ b/drizzle/0008_document_sequence_scope_refactor.sql @@ -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" +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bd76f2d..bdd14c8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/plans/task-p.6.1.md b/plans/task-p.6.1.md new file mode 100644 index 0000000..9d0e673 --- /dev/null +++ b/plans/task-p.6.1.md @@ -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. diff --git a/src/app/api/crm/settings/document-sequences/[id]/route.ts b/src/app/api/crm/settings/document-sequences/[id]/route.ts index 6675cc8..b17bd62 100644 --- a/src/app/api/crm/settings/document-sequences/[id]/route.ts +++ b/src/app/api/crm/settings/document-sequences/[id]/route.ts @@ -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() }); diff --git a/src/app/api/crm/settings/document-sequences/route.ts b/src/app/api/crm/settings/document-sequences/route.ts index 48f9c1c..0bf26ea 100644 --- a/src/app/api/crm/settings/document-sequences/route.ts +++ b/src/app/api/crm/settings/document-sequences/route.ts @@ -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() }); diff --git a/src/app/dashboard/crm/settings/document-sequences/page.tsx b/src/app/dashboard/crm/settings/document-sequences/page.tsx index 78820c7..2448805 100644 --- a/src/app/dashboard/crm/settings/document-sequences/page.tsx +++ b/src/app/dashboard/crm/settings/document-sequences/page.tsx @@ -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 ( {canRead ? ( - + ) : null} diff --git a/src/db/schema.ts b/src/db/schema.ts index e7b1db8..d501890 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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 + ) }) ); diff --git a/src/db/seeds/crm-uat.seed.ts b/src/db/seeds/crm-uat.seed.ts index fc26aa8..7d1b8ba 100644 --- a/src/db/seeds/crm-uat.seed.ts +++ b/src/db/seeds/crm-uat.seed.ts @@ -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; 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 = { - 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(); + + 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 { return sql` - 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, storyOpportunityMap: Map, storyCustomerMap: Map, @@ -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, + 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(); + + 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`, diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 09223b6..7df2ba3 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -1,8 +1,8 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const { createHash } = require('node:crypto'); -const { PDFDocument, StandardFonts, rgb } = require('@pdfme/pdf-lib'); -const postgres = require('postgres'); +const fs = require("node:fs"); +const path = require("node:path"); +const { createHash } = require("node:crypto"); +const { PDFDocument, StandardFonts, rgb } = require("@pdfme/pdf-lib"); +const postgres = require("postgres"); type SqlClient = ReturnType; @@ -33,7 +33,7 @@ type TemplateColumnSeed = { type TemplateMappingSeed = { placeholderKey: string; sourcePath: string; - dataType: 'scalar' | 'multiline' | 'table' | 'image'; + dataType: "scalar" | "multiline" | "table" | "image"; defaultValue?: string | null; formatMask?: string | null; sortOrder: number; @@ -49,7 +49,7 @@ type ReportDefinitionSeed = { type DocumentLibraryVersionSeed = { version: string; - status: 'draft' | 'published' | 'active' | 'archived'; + status: "draft" | "published" | "active" | "archived"; fileName: string; summary: string; }; @@ -70,16 +70,16 @@ function loadEnvFile(filePath: string) { return; } - const content = fs.readFileSync(filePath, 'utf8'); + const content = fs.readFileSync(filePath, "utf8"); for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); - if (!line || line.startsWith('#')) { + if (!line || line.startsWith("#")) { continue; } - const separatorIndex = line.indexOf('='); + const separatorIndex = line.indexOf("="); if (separatorIndex === -1) { continue; @@ -102,8 +102,8 @@ function loadEnvFile(filePath: string) { } function loadLocalEnv() { - loadEnvFile(path.resolve(process.cwd(), '.env.local')); - loadEnvFile(path.resolve(process.cwd(), '.env')); + loadEnvFile(path.resolve(process.cwd(), ".env.local")); + loadEnvFile(path.resolve(process.cwd(), ".env")); } function requireEnv(name: string) { @@ -118,676 +118,891 @@ function requireEnv(name: string) { function getCurrentPeriod(date = new Date()) { const year = String(date.getFullYear()).slice(-2); - const month = String(date.getMonth() + 1).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, "0"); return `${year}${month}`; } const FOUNDATION_OPTIONS = { crm_branch: [ - { code: 'head_office', label: 'สำนักงานใหญ่', value: 'head_office', sortOrder: 1 }, - { code: 'factory', label: 'Factory', value: 'factory', sortOrder: 2 }, - { code: 'service', label: 'Service', value: 'service', sortOrder: 3 } + { + code: "head_office", + label: "สำนักงานใหญ่", + value: "head_office", + sortOrder: 1, + }, ], crm_customer_status: [ - { code: 'active', label: 'Active', value: 'active', sortOrder: 1 }, - { code: 'inactive', label: 'Inactive', value: 'inactive', sortOrder: 2 }, - { code: 'suspended', label: 'Suspended', value: 'suspended', sortOrder: 3 } + { code: "active", label: "Active", value: "active", sortOrder: 1 }, + { code: "inactive", label: "Inactive", value: "inactive", sortOrder: 2 }, + { code: "suspended", label: "Suspended", value: "suspended", sortOrder: 3 }, ], crm_customer_type: [ - { code: 'company', label: 'Company', value: 'company', sortOrder: 1 }, - { code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 } + { code: "company", label: "Company", value: "company", sortOrder: 1 }, + { + code: "individual", + label: "Individual", + value: "individual", + sortOrder: 2, + }, ], crm_customer_group: [ - { code: 'industrial', label: 'Industrial', value: 'industrial', sortOrder: 1 }, - { code: 'construction', label: 'Construction', value: 'construction', sortOrder: 2 }, - { code: 'government', label: 'Government', value: 'government', sortOrder: 3 }, - { code: 'dealer', label: 'Dealer', value: 'dealer', sortOrder: 4 }, - { code: 'other', label: 'Other', value: 'other', sortOrder: 5 } + { + code: "industrial", + label: "Industrial", + value: "industrial", + sortOrder: 1, + }, + { + code: "construction", + label: "Construction", + value: "construction", + sortOrder: 2, + }, + { + code: "government", + label: "Government", + value: "government", + sortOrder: 3, + }, + { code: "dealer", label: "Dealer", value: "dealer", sortOrder: 4 }, + { code: "other", label: "Other", value: "other", sortOrder: 5 }, ], crm_customer_sub_group: [ { - code: 'factory', - label: 'Factory', - value: 'factory', + code: "factory", + label: "Factory", + value: "factory", sortOrder: 1, - parentCode: 'industrial', - parentCategory: 'crm_customer_group' + parentCode: "industrial", + parentCategory: "crm_customer_group", }, { - code: 'warehouse', - label: 'Warehouse', - value: 'warehouse', + code: "warehouse", + label: "Warehouse", + value: "warehouse", sortOrder: 2, - parentCode: 'industrial', - parentCategory: 'crm_customer_group' + parentCode: "industrial", + parentCategory: "crm_customer_group", }, { - code: 'logistics', - label: 'Logistics', - value: 'logistics', + code: "logistics", + label: "Logistics", + value: "logistics", sortOrder: 3, - parentCode: 'industrial', - parentCategory: 'crm_customer_group' + parentCode: "industrial", + parentCategory: "crm_customer_group", }, { - code: 'contractor', - label: 'Contractor', - value: 'contractor', + code: "contractor", + label: "Contractor", + value: "contractor", sortOrder: 4, - parentCode: 'construction', - parentCategory: 'crm_customer_group' + parentCode: "construction", + parentCategory: "crm_customer_group", }, { - code: 'consultant', - label: 'Consultant', - value: 'consultant', + code: "consultant", + label: "Consultant", + value: "consultant", sortOrder: 5, - parentCode: 'construction', - parentCategory: 'crm_customer_group' + parentCode: "construction", + parentCategory: "crm_customer_group", }, { - code: 'state_enterprise', - label: 'State Enterprise', - value: 'state_enterprise', + code: "state_enterprise", + label: "State Enterprise", + value: "state_enterprise", sortOrder: 6, - parentCode: 'government', - parentCategory: 'crm_customer_group' + parentCode: "government", + parentCategory: "crm_customer_group", }, { - code: 'government_agency', - label: 'Government Agency', - value: 'government_agency', + code: "government_agency", + label: "Government Agency", + value: "government_agency", sortOrder: 7, - parentCode: 'government', - parentCategory: 'crm_customer_group' + parentCode: "government", + parentCategory: "crm_customer_group", }, { - code: 'reseller', - label: 'Reseller', - value: 'reseller', + code: "reseller", + label: "Reseller", + value: "reseller", sortOrder: 8, - parentCode: 'dealer', - parentCategory: 'crm_customer_group' + parentCode: "dealer", + parentCategory: "crm_customer_group", }, { - code: 'other', - label: 'Other', - value: 'other', + code: "other", + label: "Other", + value: "other", sortOrder: 9, - parentCode: 'other', - parentCategory: 'crm_customer_group' - } + parentCode: "other", + parentCategory: "crm_customer_group", + }, ], crm_opportunity_status: [ - { code: 'new', label: 'New', value: 'new', sortOrder: 1 }, - { code: 'qualification', label: 'Qualification', value: 'qualification', sortOrder: 2 }, + { code: "new", label: "New", value: "new", sortOrder: 1 }, { - code: 'requirement_gathering', - label: 'Requirement Gathering', - value: 'requirement_gathering', - sortOrder: 3 - }, - { code: 'site_survey', label: 'Site Survey', value: 'site_survey', sortOrder: 4 }, - { - code: 'proposal_preparation', - label: 'Proposal Preparation', - value: 'proposal_preparation', - sortOrder: 5 + code: "qualification", + label: "Qualification", + value: "qualification", + sortOrder: 2, }, { - code: 'quotation_created', - label: 'Quotation Created', - value: 'quotation_created', - sortOrder: 6 + code: "requirement_gathering", + label: "Requirement Gathering", + value: "requirement_gathering", + sortOrder: 3, }, - { code: 'negotiation', label: 'Negotiation', value: 'negotiation', sortOrder: 7 }, - { code: 'closed', label: 'Closed', value: 'closed', sortOrder: 8 } + { + code: "site_survey", + label: "Site Survey", + value: "site_survey", + sortOrder: 4, + }, + { + code: "proposal_preparation", + label: "Proposal Preparation", + value: "proposal_preparation", + sortOrder: 5, + }, + { + code: "quotation_created", + label: "Quotation Created", + value: "quotation_created", + sortOrder: 6, + }, + { + code: "negotiation", + label: "Negotiation", + value: "negotiation", + sortOrder: 7, + }, + { code: "closed", label: "Closed", value: "closed", sortOrder: 8 }, ], crm_opportunity_stage: [ - { code: 'new', label: 'New', value: 'new', sortOrder: 1 }, - { code: 'qualification', label: 'Qualification', value: 'qualification', sortOrder: 2 }, + { code: "new", label: "New", value: "new", sortOrder: 1 }, { - code: 'requirement_gathering', - label: 'Requirement Gathering', - value: 'requirement_gathering', - sortOrder: 3 - }, - { code: 'site_survey', label: 'Site Survey', value: 'site_survey', sortOrder: 4 }, - { - code: 'proposal_preparation', - label: 'Proposal Preparation', - value: 'proposal_preparation', - sortOrder: 5 + code: "qualification", + label: "Qualification", + value: "qualification", + sortOrder: 2, }, { - code: 'quotation_created', - label: 'Quotation Created', - value: 'quotation_created', - sortOrder: 6 + code: "requirement_gathering", + label: "Requirement Gathering", + value: "requirement_gathering", + sortOrder: 3, }, - { code: 'negotiation', label: 'Negotiation', value: 'negotiation', sortOrder: 7 }, - { code: 'closed', label: 'Closed', value: 'closed', sortOrder: 8 } + { + code: "site_survey", + label: "Site Survey", + value: "site_survey", + sortOrder: 4, + }, + { + code: "proposal_preparation", + label: "Proposal Preparation", + value: "proposal_preparation", + sortOrder: 5, + }, + { + code: "quotation_created", + label: "Quotation Created", + value: "quotation_created", + sortOrder: 6, + }, + { + code: "negotiation", + label: "Negotiation", + value: "negotiation", + sortOrder: 7, + }, + { code: "closed", label: "Closed", value: "closed", sortOrder: 8 }, ], crm_opportunity_outcome_status: [ - { code: 'open', label: 'Open', value: 'open', sortOrder: 1 }, - { code: 'won', label: 'Won', value: 'won', sortOrder: 2 }, - { code: 'lost', label: 'Lost', value: 'lost', sortOrder: 3 }, - { code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 4 }, - { code: 'no_quotation', label: 'No Quotation', value: 'no_quotation', sortOrder: 5 } + { code: "open", label: "Open", value: "open", sortOrder: 1 }, + { code: "won", label: "Won", value: "won", sortOrder: 2 }, + { code: "lost", label: "Lost", value: "lost", sortOrder: 3 }, + { code: "cancelled", label: "Cancelled", value: "cancelled", sortOrder: 4 }, + { + code: "no_quotation", + label: "No Quotation", + value: "no_quotation", + sortOrder: 5, + }, ], crm_quotation_status: [ - { code: 'draft', label: 'Draft', value: 'draft', sortOrder: 1 }, + { code: "draft", label: "Draft", value: "draft", sortOrder: 1 }, { - code: 'pending_approval', - label: 'Pending Approval', - value: 'pending_approval', - sortOrder: 2 + code: "pending_approval", + label: "Pending Approval", + value: "pending_approval", + sortOrder: 2, }, - { code: 'approved', label: 'Approved', value: 'approved', sortOrder: 3 }, - { code: 'rejected', label: 'Rejected', value: 'rejected', sortOrder: 4 }, - { code: 'sent', label: 'Sent', value: 'sent', sortOrder: 5 }, - { code: 'accepted', label: 'Accepted', value: 'accepted', sortOrder: 6 }, - { code: 'lost', label: 'Lost', value: 'lost', sortOrder: 7 }, - { code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 8 }, - { code: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 } + { code: "approved", label: "Approved", value: "approved", sortOrder: 3 }, + { code: "rejected", label: "Rejected", value: "rejected", sortOrder: 4 }, + { code: "sent", label: "Sent", value: "sent", sortOrder: 5 }, + { code: "accepted", label: "Accepted", value: "accepted", sortOrder: 6 }, + { code: "lost", label: "Lost", value: "lost", sortOrder: 7 }, + { code: "cancelled", label: "Cancelled", value: "cancelled", sortOrder: 8 }, + { code: "revised", label: "Revised", value: "revised", sortOrder: 9 }, ], crm_quotation_type: [ - { code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 }, - { code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 }, - { code: 'solarcell', label: 'Solar Cell', value: 'solarcell', sortOrder: 3 }, - { code: 'service', label: 'Service', value: 'service', sortOrder: 4 }, - { code: 'other', label: 'Other', value: 'other', sortOrder: 5 } + { code: "crane", label: "Crane", value: "crane", sortOrder: 1 }, + { code: "dockdoor", label: "Dock Door", value: "dockdoor", sortOrder: 2 }, + { + code: "solarcell", + label: "Solar Cell", + value: "solarcell", + sortOrder: 3, + }, + { code: "service", label: "Service", value: "service", sortOrder: 4 }, + { code: "other", label: "Other", value: "other", sortOrder: 5 }, ], crm_product_type: [ - { code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 }, - { code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 }, - { code: 'solarcell', label: 'Solar Cell', value: 'solarcell', sortOrder: 3 } + { code: "crane", label: "Crane", value: "crane", sortOrder: 1 }, + { code: "dockdoor", label: "Dock Door", value: "dockdoor", sortOrder: 2 }, + { + code: "solarcell", + label: "Solar Cell", + value: "solarcell", + sortOrder: 3, + }, + { code: "service", label: "Service", value: "service", sortOrder: 4 }, + { + code: "sparepart", + label: "Spare Part", + value: "sparepart", + sortOrder: 5, + }, ], crm_currency: [ - { code: 'THB', label: 'THB', value: 'THB', sortOrder: 1 }, - { code: 'USD', label: 'USD', value: 'USD', sortOrder: 2 }, - { code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 } + { code: "THB", label: "THB", value: "THB", sortOrder: 1 }, + { code: "USD", label: "USD", value: "USD", sortOrder: 2 }, + { code: "EUR", label: "EUR", value: "EUR", sortOrder: 3 }, ], crm_discount_type: [ - { code: 'fixed', label: 'Fixed', value: 'fixed', sortOrder: 1 }, - { code: 'percentage', label: 'Percentage', value: 'percentage', sortOrder: 2 } + { code: "fixed", label: "Fixed", value: "fixed", sortOrder: 1 }, + { + code: "percentage", + label: "Percentage", + value: "percentage", + sortOrder: 2, + }, ], crm_unit: [ - { code: 'pcs', label: 'PCS', value: 'pcs', sortOrder: 1 }, - { code: 'set', label: 'Set', value: 'set', sortOrder: 2 }, - { code: 'lot', label: 'Lot', value: 'lot', sortOrder: 3 }, - { code: 'job', label: 'Job', value: 'job', sortOrder: 4 } + { code: "pcs", label: "PCS", value: "pcs", sortOrder: 1 }, + { code: "set", label: "Set", value: "set", sortOrder: 2 }, + { code: "lot", label: "Lot", value: "lot", sortOrder: 3 }, + { code: "job", label: "Job", value: "job", sortOrder: 4 }, ], crm_sent_via: [ - { code: 'email', label: 'Email', value: 'email', sortOrder: 1 }, - { code: 'manual', label: 'Manual', value: 'manual', sortOrder: 2 }, - { code: 'system', label: 'System', value: 'system', sortOrder: 3 } + { code: "email", label: "Email", value: "email", sortOrder: 1 }, + { code: "manual", label: "Manual", value: "manual", sortOrder: 2 }, + { code: "system", label: "System", value: "system", sortOrder: 3 }, ], crm_project_party_role: [ { - code: 'billing_customer', - label: 'Billing Customer', - value: 'billing_customer', - sortOrder: 1 + code: "billing_customer", + label: "Billing Customer", + value: "billing_customer", + sortOrder: 1, }, - { code: 'customer', label: 'Customer', value: 'customer', sortOrder: 2 }, - { code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 3 }, - { code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 4 }, - { code: 'end_customer', label: 'End Customer', value: 'end_customer', sortOrder: 5 }, - { code: 'other', label: 'Other', value: 'other', sortOrder: 6 } + { code: "customer", label: "Customer", value: "customer", sortOrder: 2 }, + { + code: "consultant", + label: "Consultant", + value: "consultant", + sortOrder: 3, + }, + { + code: "contractor", + label: "Contractor", + value: "contractor", + sortOrder: 4, + }, + { + code: "end_customer", + label: "End Customer", + value: "end_customer", + sortOrder: 5, + }, + { code: "other", label: "Other", value: "other", sortOrder: 6 }, ], crm_quotation_topic_type: [ - { code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 }, - { code: 'exclusion', label: 'Exclusion', value: 'exclusion', sortOrder: 2 }, - { code: 'payment', label: 'Payment', value: 'payment', sortOrder: 3 } + { code: "scope", label: "Scope", value: "scope", sortOrder: 1 }, + { code: "exclusion", label: "Exclusion", value: "exclusion", sortOrder: 2 }, + { code: "payment", label: "Payment", value: "payment", sortOrder: 3 }, ], crm_payment_term: [ - { code: 'cash', label: 'Cash', value: 'cash', sortOrder: 1 }, - { code: 'credit_30', label: 'Credit 30', value: 'credit_30', sortOrder: 2 }, - { code: 'credit_60', label: 'Credit 60', value: 'credit_60', sortOrder: 3 } + { code: "cash", label: "Cash", value: "cash", sortOrder: 1 }, + { code: "credit_30", label: "Credit 30", value: "credit_30", sortOrder: 2 }, + { code: "credit_60", label: "Credit 60", value: "credit_60", sortOrder: 3 }, ], crm_priority: [ - { code: 'low', label: 'Low', value: 'low', sortOrder: 1 }, - { code: 'normal', label: 'Normal', value: 'normal', sortOrder: 2 }, - { code: 'high', label: 'High', value: 'high', sortOrder: 3 }, - { code: 'urgent', label: 'Urgent', value: 'urgent', sortOrder: 4 } + { code: "low", label: "Low", value: "low", sortOrder: 1 }, + { code: "normal", label: "Normal", value: "normal", sortOrder: 2 }, + { code: "high", label: "High", value: "high", sortOrder: 3 }, + { code: "urgent", label: "Urgent", value: "urgent", sortOrder: 4 }, ], crm_followup_type: [ - { code: 'call', label: 'Call', value: 'call', sortOrder: 1 }, - { code: 'meeting', label: 'Meeting', value: 'meeting', sortOrder: 2 }, - { code: 'site_visit', label: 'Site Visit', value: 'site_visit', sortOrder: 3 }, - { code: 'email', label: 'Email', value: 'email', sortOrder: 4 }, - { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 } + { code: "call", label: "Call", value: "call", sortOrder: 1 }, + { code: "meeting", label: "Meeting", value: "meeting", sortOrder: 2 }, + { + code: "site_visit", + label: "Site Visit", + value: "site_visit", + sortOrder: 3, + }, + { code: "email", label: "Email", value: "email", sortOrder: 4 }, + { code: "line", label: "LINE", value: "line", sortOrder: 5 }, ], crm_lost_reason: [ - { code: 'competitor_won', label: 'Competitor Won', value: 'competitor_won', sortOrder: 1 }, { - code: 'budget_cancelled', - label: 'Budget Cancelled', - value: 'budget_cancelled', - sortOrder: 2 + code: "competitor_won", + label: "Competitor Won", + value: "competitor_won", + sortOrder: 1, }, { - code: 'timeline_not_match', - label: 'Timeline Not Match', - value: 'timeline_not_match', - sortOrder: 3 + code: "budget_cancelled", + label: "Budget Cancelled", + value: "budget_cancelled", + sortOrder: 2, }, { - code: 'spec_not_match', - label: 'Spec Not Match', - value: 'spec_not_match', - sortOrder: 4 + code: "timeline_not_match", + label: "Timeline Not Match", + value: "timeline_not_match", + sortOrder: 3, }, { - code: 'customer_no_response', - label: 'Customer No Response', - value: 'customer_no_response', - sortOrder: 5 + code: "spec_not_match", + label: "Spec Not Match", + value: "spec_not_match", + sortOrder: 4, }, - { code: 'price_too_high', label: 'Price Too High', value: 'price_too_high', sortOrder: 6 }, - { code: 'timeline_not_fit', label: 'Timeline Not Fit', value: 'timeline_not_fit', sortOrder: 7 }, - { code: 'other', label: 'Other', value: 'other', sortOrder: 8 } + { + code: "customer_no_response", + label: "Customer No Response", + value: "customer_no_response", + sortOrder: 5, + }, + { + code: "price_too_high", + label: "Price Too High", + value: "price_too_high", + sortOrder: 6, + }, + { + code: "timeline_not_fit", + label: "Timeline Not Fit", + value: "timeline_not_fit", + sortOrder: 7, + }, + { code: "other", label: "Other", value: "other", sortOrder: 8 }, ], crm_opportunity_lost_reason: [ - { code: 'competitor_won', label: 'Competitor Won', value: 'competitor_won', sortOrder: 1 }, { - code: 'budget_cancelled', - label: 'Budget Cancelled', - value: 'budget_cancelled', - sortOrder: 2 + code: "competitor_won", + label: "Competitor Won", + value: "competitor_won", + sortOrder: 1, }, { - code: 'timeline_not_match', - label: 'Timeline Not Match', - value: 'timeline_not_match', - sortOrder: 3 + code: "budget_cancelled", + label: "Budget Cancelled", + value: "budget_cancelled", + sortOrder: 2, }, { - code: 'spec_not_match', - label: 'Spec Not Match', - value: 'spec_not_match', - sortOrder: 4 + code: "timeline_not_match", + label: "Timeline Not Match", + value: "timeline_not_match", + sortOrder: 3, }, { - code: 'customer_no_response', - label: 'Customer No Response', - value: 'customer_no_response', - sortOrder: 5 + code: "spec_not_match", + label: "Spec Not Match", + value: "spec_not_match", + sortOrder: 4, }, - { code: 'price_too_high', label: 'Price Too High', value: 'price_too_high', sortOrder: 6 }, - { code: 'timeline_not_fit', label: 'Timeline Not Fit', value: 'timeline_not_fit', sortOrder: 7 }, - { code: 'other', label: 'Other', value: 'other', sortOrder: 8 } + { + code: "customer_no_response", + label: "Customer No Response", + value: "customer_no_response", + sortOrder: 5, + }, + { + code: "price_too_high", + label: "Price Too High", + value: "price_too_high", + sortOrder: 6, + }, + { + code: "timeline_not_fit", + label: "Timeline Not Fit", + value: "timeline_not_fit", + sortOrder: 7, + }, + { code: "other", label: "Other", value: "other", sortOrder: 8 }, ], crm_opportunity_cancel_reason: [ { - code: 'customer_cancelled', - label: 'Customer Cancelled', - value: 'customer_cancelled', - sortOrder: 1 + code: "customer_cancelled", + label: "Customer Cancelled", + value: "customer_cancelled", + sortOrder: 1, }, { - code: 'internal_hold', - label: 'Internal Hold', - value: 'internal_hold', - sortOrder: 2 + code: "internal_hold", + label: "Internal Hold", + value: "internal_hold", + sortOrder: 2, }, { - code: 'scope_changed', - label: 'Scope Changed', - value: 'scope_changed', - sortOrder: 3 + code: "scope_changed", + label: "Scope Changed", + value: "scope_changed", + sortOrder: 3, }, - { code: 'other', label: 'Other', value: 'other', sortOrder: 4 } + { code: "other", label: "Other", value: "other", sortOrder: 4 }, ], crm_opportunity_no_quotation_reason: [ { - code: 'budget_not_ready', - label: 'Budget Not Ready', - value: 'budget_not_ready', - sortOrder: 1 + code: "budget_not_ready", + label: "Budget Not Ready", + value: "budget_not_ready", + sortOrder: 1, }, { - code: 'qualification_failed', - label: 'Qualification Failed', - value: 'qualification_failed', - sortOrder: 2 + code: "qualification_failed", + label: "Qualification Failed", + value: "qualification_failed", + sortOrder: 2, }, { - code: 'duplicate_opportunity', - label: 'Duplicate Opportunity', - value: 'duplicate_opportunity', - sortOrder: 3 + code: "duplicate_opportunity", + label: "Duplicate Opportunity", + value: "duplicate_opportunity", + sortOrder: 3, }, { - code: 'customer_stopped', - label: 'Customer Stopped', - value: 'customer_stopped', - sortOrder: 4 + code: "customer_stopped", + label: "Customer Stopped", + value: "customer_stopped", + sortOrder: 4, }, - { code: 'other', label: 'Other', value: 'other', sortOrder: 5 } + { code: "other", label: "Other", value: "other", sortOrder: 5 }, ], crm_job_title: [ - { code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 }, - { code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 }, - { code: 'sales_manager', label: 'Sales Manager', value: 'sales_manager', sortOrder: 3 }, + { code: "sales", label: "Sales Engineer", value: "sales", sortOrder: 1 }, { - code: 'department_manager', - label: 'Department Manager', - value: 'department_manager', - sortOrder: 4 + code: "sales_support", + label: "Sales Support", + value: "sales_support", + sortOrder: 2, + }, + { + code: "sales_manager", + label: "Sales Manager", + value: "sales_manager", + sortOrder: 3, + }, + { + code: "department_manager", + label: "Department Manager", + value: "department_manager", + sortOrder: 4, + }, + { + code: "top_manager", + label: "General Manager", + value: "top_manager", + sortOrder: 5, }, - { code: 'top_manager', label: 'General Manager', value: 'top_manager', sortOrder: 5 } ], crm_lead_channel: [ - { code: 'website', label: 'Website', value: 'website', sortOrder: 1 }, - { code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 }, - { code: 'sales', label: 'Sales', value: 'sales', sortOrder: 3 }, - { code: 'facebook', label: 'Facebook', value: 'facebook', sortOrder: 4 }, - { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }, - { code: 'other', label: 'Other', value: 'other', sortOrder: 6 } + { code: "website", label: "Website", value: "website", sortOrder: 1 }, + { code: "referral", label: "Referral", value: "referral", sortOrder: 2 }, + { code: "sales", label: "Sales", value: "sales", sortOrder: 3 }, + { code: "facebook", label: "Facebook", value: "facebook", sortOrder: 4 }, + { code: "line", label: "LINE", value: "line", sortOrder: 5 }, + { code: "other", label: "Other", value: "other", sortOrder: 6 }, ], crm_lead_awareness: [ { - code: 'google_search_website', - label: 'Google Search / Website', - value: 'google_search_website', - sortOrder: 1 - }, - { code: 'social_media', label: 'Social Media', value: 'social_media', sortOrder: 2 }, - { - code: 'email_marketing', - label: 'Email Marketing', - value: 'email_marketing', - sortOrder: 3 - }, - { code: 'visit', label: 'Visit', value: 'visit', sortOrder: 4 }, - { - code: 'exhibition_event', - label: 'Exhibition / Event', - value: 'exhibition_event', - sortOrder: 5 + code: "google_search_website", + label: "Google Search / Website", + value: "google_search_website", + sortOrder: 1, }, { - code: 'brochure_catalog', - label: 'Brochure / Catalog', - value: 'brochure_catalog', - sortOrder: 6 + code: "social_media", + label: "Social Media", + value: "social_media", + sortOrder: 2, }, { - code: 'word_of_mouth', - label: 'Word of Mouth', - value: 'word_of_mouth', - sortOrder: 7 + code: "email_marketing", + label: "Email Marketing", + value: "email_marketing", + sortOrder: 3, }, - { code: 'corporate_car', label: 'Corporate Car', value: 'corporate_car', sortOrder: 8 }, - { code: 'product_label', label: 'Product Label', value: 'product_label', sortOrder: 9 }, - { code: 'repurchasing', label: 'Re-purchasing', value: 'repurchasing', sortOrder: 10 }, - { code: 'introducing', label: 'Introducing', value: 'introducing', sortOrder: 11 }, - { code: 'bci', label: 'BCI', value: 'bci', sortOrder: 12 } + { code: "visit", label: "Visit", value: "visit", sortOrder: 4 }, + { + code: "exhibition_event", + label: "Exhibition / Event", + value: "exhibition_event", + sortOrder: 5, + }, + { + code: "brochure_catalog", + label: "Brochure / Catalog", + value: "brochure_catalog", + sortOrder: 6, + }, + { + code: "word_of_mouth", + label: "Word of Mouth", + value: "word_of_mouth", + sortOrder: 7, + }, + { + code: "corporate_car", + label: "Corporate Car", + value: "corporate_car", + sortOrder: 8, + }, + { + code: "product_label", + label: "Product Label", + value: "product_label", + sortOrder: 9, + }, + { + code: "repurchasing", + label: "Re-purchasing", + value: "repurchasing", + sortOrder: 10, + }, + { + code: "introducing", + label: "Introducing", + value: "introducing", + sortOrder: 11, + }, + { code: "bci", label: "BCI", value: "bci", sortOrder: 12 }, ], crm_lead_status: [ - { code: 'new_job', label: 'New Job', value: 'new_job', sortOrder: 1 }, - { code: 'follow_up', label: 'Follow Up', value: 'follow_up', sortOrder: 2 }, - { code: 'assigned', label: 'Assigned', value: 'assigned', sortOrder: 3 }, - { code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 4 }, - { code: 'cancel', label: 'Cancel', value: 'cancel', sortOrder: 5 } + { code: "new_job", label: "New Job", value: "new_job", sortOrder: 1 }, + { code: "follow_up", label: "Follow Up", value: "follow_up", sortOrder: 2 }, + { code: "assigned", label: "Assigned", value: "assigned", sortOrder: 3 }, + { + code: "closed_lost", + label: "Closed Lost", + value: "closed_lost", + sortOrder: 4, + }, + { code: "cancel", label: "Cancel", value: "cancel", sortOrder: 5 }, ], crm_lead_followup_status: [ { - code: 'budget_pending', - label: 'Budget Pending', - value: 'budget_pending', - sortOrder: 1 + code: "budget_pending", + label: "Budget Pending", + value: "budget_pending", + sortOrder: 1, }, { - code: 'project_under_review', - label: 'Project Under Review', - value: 'project_under_review', - sortOrder: 2 + code: "project_under_review", + label: "Project Under Review", + value: "project_under_review", + sortOrder: 2, }, { - code: 'spec_clarification', - label: 'Spec Clarification', - value: 'spec_clarification', - sortOrder: 3 + code: "spec_clarification", + label: "Spec Clarification", + value: "spec_clarification", + sortOrder: 3, }, { - code: 'site_survey_waiting', - label: 'Site Survey Waiting', - value: 'site_survey_waiting', - sortOrder: 4 + code: "site_survey_waiting", + label: "Site Survey Waiting", + value: "site_survey_waiting", + sortOrder: 4, }, { - code: 'drawing_followup', - label: 'Drawing Follow-up', - value: 'drawing_followup', - sortOrder: 5 + code: "drawing_followup", + label: "Drawing Follow-up", + value: "drawing_followup", + sortOrder: 5, }, { - code: 'quotation_preparation', - label: 'Quotation Preparation', - value: 'quotation_preparation', - sortOrder: 6 + code: "quotation_preparation", + label: "Quotation Preparation", + value: "quotation_preparation", + sortOrder: 6, + }, + { + code: "waiting_po", + label: "Waiting PO", + value: "waiting_po", + sortOrder: 7, }, - { code: 'waiting_po', label: 'Waiting PO', value: 'waiting_po', sortOrder: 7 } ], crm_lead_lost_reason: [ - { code: 'price', label: 'Price', value: 'price', sortOrder: 1 }, - { code: 'delivery_time', label: 'Delivery Time', value: 'delivery_time', sortOrder: 2 }, - { code: 'sales_person', label: 'Sales Person', value: 'sales_person', sortOrder: 3 }, - { code: 'service', label: 'Service', value: 'service', sortOrder: 4 }, - { code: 'customer', label: 'Customer', value: 'customer', sortOrder: 5 }, + { code: "price", label: "Price", value: "price", sortOrder: 1 }, { - code: 'product_quality', - label: 'Product Quality', - value: 'product_quality', - sortOrder: 6 + code: "delivery_time", + label: "Delivery Time", + value: "delivery_time", + sortOrder: 2, }, - { code: 'other', label: 'Other', value: 'other', sortOrder: 7 }, - { code: 'transfer', label: 'Transfer', value: 'transfer', sortOrder: 8 } + { + code: "sales_person", + label: "Sales Person", + value: "sales_person", + sortOrder: 3, + }, + { code: "service", label: "Service", value: "service", sortOrder: 4 }, + { code: "customer", label: "Customer", value: "customer", sortOrder: 5 }, + { + code: "product_quality", + label: "Product Quality", + value: "product_quality", + sortOrder: 6, + }, + { code: "other", label: "Other", value: "other", sortOrder: 7 }, + { code: "transfer", label: "Transfer", value: "transfer", sortOrder: 8 }, ], crm_report_category: [ - { code: 'pipeline', label: 'Pipeline', value: 'pipeline', sortOrder: 1 }, - { code: 'sales', label: 'Sales', value: 'sales', sortOrder: 2 }, - { code: 'revenue', label: 'Revenue', value: 'revenue', sortOrder: 3 }, - { code: 'customer', label: 'Customer', value: 'customer', sortOrder: 4 }, - { code: 'quotation', label: 'Quotation', value: 'quotation', sortOrder: 5 }, - { code: 'approval', label: 'Approval', value: 'approval', sortOrder: 6 }, - { code: 'outcome', label: 'Outcome', value: 'outcome', sortOrder: 7 }, - { code: 'executive', label: 'Executive', value: 'executive', sortOrder: 8 } - ] + { code: "pipeline", label: "Pipeline", value: "pipeline", sortOrder: 1 }, + { code: "sales", label: "Sales", value: "sales", sortOrder: 2 }, + { code: "revenue", label: "Revenue", value: "revenue", sortOrder: 3 }, + { code: "customer", label: "Customer", value: "customer", sortOrder: 4 }, + { code: "quotation", label: "Quotation", value: "quotation", sortOrder: 5 }, + { code: "approval", label: "Approval", value: "approval", sortOrder: 6 }, + { code: "outcome", label: "Outcome", value: "outcome", sortOrder: 7 }, + { code: "executive", label: "Executive", value: "executive", sortOrder: 8 }, + ], }; const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [ { - code: 'lead_pipeline', - name: 'Lead Pipeline', - description: 'Operational report for marketing lead intake, assignment, and conversion status.', - category: 'pipeline' + code: "lead_pipeline", + name: "Lead Pipeline", + description: + "Operational report for marketing lead intake, assignment, and conversion status.", + category: "pipeline", }, { - code: 'lead_aging', - name: 'Lead Aging', - description: 'Operational report for stagnant lead monitoring and follow-up prioritization.', - category: 'pipeline' + code: "lead_aging", + name: "Lead Aging", + description: + "Operational report for stagnant lead monitoring and follow-up prioritization.", + category: "pipeline", }, { - code: 'opportunity_pipeline', - name: 'Opportunity Pipeline', - description: 'Operational report for open opportunities, quotation progress, and sales pipeline health.', - category: 'pipeline' + code: "opportunity_pipeline", + name: "Opportunity Pipeline", + description: + "Operational report for open opportunities, quotation progress, and sales pipeline health.", + category: "pipeline", }, { - code: 'opportunity_aging', - name: 'Opportunity Aging', - description: 'Operational report for stalled opportunities and aging opportunity follow-ups.', - category: 'pipeline' + code: "opportunity_aging", + name: "Opportunity Aging", + description: + "Operational report for stalled opportunities and aging opportunity follow-ups.", + category: "pipeline", }, { - code: 'pipeline_funnel', - name: 'Pipeline Funnel', - description: 'Lifecycle funnel from lead through quotation to won and lost outcomes.', - category: 'pipeline' + code: "pipeline_funnel", + name: "Pipeline Funnel", + description: + "Lifecycle funnel from lead through quotation to won and lost outcomes.", + category: "pipeline", }, { - code: 'lead_conversion', - name: 'Lead Conversion', - description: 'Marketing conversion report from lead creation into active opportunities.', - category: 'pipeline' + code: "lead_conversion", + name: "Lead Conversion", + description: + "Marketing conversion report from lead creation into active opportunities.", + category: "pipeline", }, { - code: 'opportunity_conversion', - name: 'Opportunity Conversion', - description: 'Sales conversion report from opportunity creation into quotation generation.', - category: 'pipeline' + code: "opportunity_conversion", + name: "Opportunity Conversion", + description: + "Sales conversion report from opportunity creation into quotation generation.", + category: "pipeline", }, { - code: 'sales_performance', - name: 'Sales Performance', - description: 'Foundation catalog entry for salesperson KPI and ranking reports.', - category: 'sales' + code: "sales_performance", + name: "Sales Performance", + description: + "Foundation catalog entry for salesperson KPI and ranking reports.", + category: "sales", }, { - code: 'lost_analysis', - name: 'Lost Analysis', - description: 'Foundation catalog entry for lost reason and competitor analysis.', - category: 'outcome' + code: "lost_analysis", + name: "Lost Analysis", + description: + "Foundation catalog entry for lost reason and competitor analysis.", + category: "outcome", }, { - code: 'revenue_by_customer', - name: 'Revenue By Customer', - description: 'Foundation catalog entry for customer revenue attribution reports.', - category: 'revenue' - } + code: "revenue_by_customer", + name: "Revenue By Customer", + description: + "Foundation catalog entry for customer revenue attribution reports.", + category: "revenue", + }, ]; const DOCUMENT_LIBRARY_SEEDS: DocumentLibrarySeed[] = [ { - code: 'SLA_ALLA_CRANE_TH', - name: 'SLA - ALLA - Crane - TH', - documentType: 'sla', - description: 'Seeded service level agreement pack for ALLA crane quotations.', - brand: 'alla', - language: 'th', - productType: 'crane', + code: "SLA_ALLA_CRANE_TH", + name: "SLA - ALLA - Crane - TH", + documentType: "sla", + description: + "Seeded service level agreement pack for ALLA crane quotations.", + brand: "alla", + language: "th", + productType: "crane", versions: [ { - version: '1.0', - status: 'archived', - fileName: 'sla-alla-crane-th-v1.0.pdf', - summary: 'Archived baseline SLA kept for historical reference.' + version: "1.0", + status: "archived", + fileName: "sla-alla-crane-th-v1.0.pdf", + summary: "Archived baseline SLA kept for historical reference.", }, { - version: '1.1', - status: 'active', - fileName: 'sla-alla-crane-th-v1.1.pdf', - summary: 'Current active SLA version used as the default append document.' - } - ] + version: "1.1", + status: "active", + fileName: "sla-alla-crane-th-v1.1.pdf", + summary: + "Current active SLA version used as the default append document.", + }, + ], }, { - code: 'WARRANTY_ONVALLA_DOCK_DOOR_EN', - name: 'Warranty - ONVALLA - Dock Door - EN', - documentType: 'warranty', - description: 'Seeded warranty booklet for ONVALLA dock door opportunities.', - brand: 'onvalla', - language: 'en', - productType: 'dock_door', + code: "WARRANTY_ONVALLA_DOCK_DOOR_EN", + name: "Warranty - ONVALLA - Dock Door - EN", + documentType: "warranty", + description: "Seeded warranty booklet for ONVALLA dock door opportunities.", + brand: "onvalla", + language: "en", + productType: "dock_door", versions: [ { - version: '1.0', - status: 'active', - fileName: 'warranty-onvalla-dock-door-en-v1.0.pdf', - summary: 'Active warranty issue for English dock door quotations.' - } - ] + version: "1.0", + status: "active", + fileName: "warranty-onvalla-dock-door-en-v1.0.pdf", + summary: "Active warranty issue for English dock door quotations.", + }, + ], }, { - code: 'COMPANY_PROFILE_ALLA_ALL_TH', - name: 'Company Profile - ALLA - All Product Types - TH', - documentType: 'company_profile', - description: 'Seeded company profile for Thai proposals across all ALLA product families.', - brand: 'alla', - language: 'th', - productType: 'all', + code: "COMPANY_PROFILE_ALLA_ALL_TH", + name: "Company Profile - ALLA - All Product Types - TH", + documentType: "company_profile", + description: + "Seeded company profile for Thai proposals across all ALLA product families.", + brand: "alla", + language: "th", + productType: "all", versions: [ { - version: '2025.12', - status: 'active', - fileName: 'company-profile-alla-all-th-2025.12.pdf', - summary: 'Current active company profile packaged with selected quotations.' + version: "2025.12", + status: "active", + fileName: "company-profile-alla-all-th-2025.12.pdf", + summary: + "Current active company profile packaged with selected quotations.", }, { - version: '2026.01', - status: 'published', - fileName: 'company-profile-alla-all-th-2026.01.pdf', - summary: 'Published refresh ready for operator activation review.' - } - ] + version: "2026.01", + status: "published", + fileName: "company-profile-alla-all-th-2026.01.pdf", + summary: "Published refresh ready for operator activation review.", + }, + ], }, { - code: 'INSTALLATION_MANUAL_GENERIC_SERVICE_EN', - name: 'Installation Manual - Generic - Service - EN', - documentType: 'installation_manual', - description: 'Seeded draft service installation manual for future operator review.', - brand: 'generic', - language: 'en', - productType: 'service', + code: "INSTALLATION_MANUAL_GENERIC_SERVICE_EN", + name: "Installation Manual - Generic - Service - EN", + documentType: "installation_manual", + description: + "Seeded draft service installation manual for future operator review.", + brand: "generic", + language: "en", + productType: "service", versions: [ { - version: '0.9-draft', - status: 'draft', - fileName: 'installation-manual-generic-service-en-v0.9-draft.pdf', - summary: 'Draft-only technical manual left unpublished to exercise lifecycle controls.' - } - ] - } + version: "0.9-draft", + status: "draft", + fileName: "installation-manual-generic-service-en-v0.9-draft.pdf", + summary: + "Draft-only technical manual left unpublished to exercise lifecycle controls.", + }, + ], + }, ]; function sha256(buffer: Buffer) { - return createHash('sha256').update(buffer).digest('hex'); + return createHash("sha256").update(buffer).digest("hex"); } function sanitizeStorageSegment(value: string) { return value .trim() - .replace(/\\/g, '/') - .replace(/^\/+|\/+$/g, '') - .replace(/[^a-zA-Z0-9._/-]+/g, '-'); + .replace(/\\/g, "/") + .replace(/^\/+|\/+$/g, "") + .replace(/[^a-zA-Z0-9._/-]+/g, "-"); } -function buildOrganizationStorageKey(organizationId: string, segments: string[]) { - return ['organizations', organizationId, ...segments.map(sanitizeStorageSegment).filter(Boolean)].join('/'); +function buildOrganizationStorageKey( + organizationId: string, + segments: string[], +) { + return [ + "organizations", + organizationId, + ...segments.map(sanitizeStorageSegment).filter(Boolean), + ].join("/"); } -function resolveLifecycleTimestamps(status: DocumentLibraryVersionSeed['status']) { +function resolveLifecycleTimestamps( + status: DocumentLibraryVersionSeed["status"], +) { switch (status) { - case 'draft': + case "draft": return { publishedAt: null, - archivedAt: null + archivedAt: null, }; - case 'published': + case "published": return { - publishedAt: new Date('2026-01-20T02:00:00.000Z'), - archivedAt: null + publishedAt: new Date("2026-01-20T02:00:00.000Z"), + archivedAt: null, }; - case 'active': + case "active": return { - publishedAt: new Date('2026-01-10T02:00:00.000Z'), - archivedAt: null + publishedAt: new Date("2026-01-10T02:00:00.000Z"), + archivedAt: null, }; - case 'archived': + case "archived": return { - publishedAt: new Date('2025-12-15T02:00:00.000Z'), - archivedAt: new Date('2026-01-05T02:00:00.000Z') + publishedAt: new Date("2025-12-15T02:00:00.000Z"), + archivedAt: new Date("2026-01-05T02:00:00.000Z"), }; default: return { publishedAt: null, - archivedAt: null + archivedAt: null, }; } } @@ -814,7 +1029,7 @@ async function createDocumentLibrarySeedPdf(input: { y: cursorY, size: 18, font: titleFont, - color: rgb(0.1, 0.16, 0.24) + color: rgb(0.1, 0.16, 0.24), }); cursorY -= 32; @@ -826,10 +1041,10 @@ async function createDocumentLibrarySeedPdf(input: { `Language: ${input.language}`, `Product Type: ${input.productType}`, `Version: ${input.version}`, - '', + "", input.summary, - '', - 'Seeded by foundation.seed.ts for Document Library management verification.' + "", + "Seeded by foundation.seed.ts for Document Library management verification.", ]; for (const line of lines) { @@ -838,10 +1053,10 @@ async function createDocumentLibrarySeedPdf(input: { y: cursorY, size: line === input.summary ? 12 : 11, font: line === input.summary ? titleFont : bodyFont, - color: rgb(0.23, 0.27, 0.33) + color: rgb(0.23, 0.27, 0.33), }); - cursorY -= line === '' ? 18 : 20; + cursorY -= line === "" ? 18 : 20; } const bytes = await pdf.save(); @@ -850,54 +1065,66 @@ async function createDocumentLibrarySeedPdf(input: { } async function getSeedStorageProvider() { - const storageModule = await import('../../features/foundation/storage/server/provider.ts'); + const storageModule = + await import("../../features/foundation/storage/server/provider.ts"); return storageModule.resolveStorageProvider(); } +async function getDocumentSequenceConfig() { + return import("../../features/foundation/document-sequence/config.ts"); +} + const DOCUMENT_SEQUENCES = [ - { documentType: 'customer', prefix: 'CUS' }, - { documentType: 'contact', prefix: 'CON' }, - { documentType: 'opportunity', prefix: 'ENQ' }, - { documentType: 'crm_lead', prefix: 'LD' }, - { documentType: 'crm_opportunity', prefix: 'EN' }, - { documentType: 'quotation', prefix: 'QT' }, - { documentType: 'approval', prefix: 'APV' } + { documentType: "customer", prefix: "CUS" }, + { documentType: "contact", prefix: "CON" }, + { documentType: "opportunity", prefix: "ENQ" }, + { documentType: "crm_lead", prefix: "LD" }, + { documentType: "crm_opportunity", prefix: "EN" }, + { documentType: "approval", prefix: "APV" }, +]; + +const QUOTATION_SEQUENCE_PRODUCT_TYPES = [ + "crane", + "dockdoor", + "solarcell", + "service", + "sparepart", ]; const APPROVAL_WORKFLOWS = [ { - code: 'quotation_standard_approval', - name: 'Quotation Standard Approval', - entityType: 'quotation', - description: 'Default sequential quotation approval workflow', + code: "quotation_standard_approval", + name: "Quotation Standard Approval", + entityType: "quotation", + description: "Default sequential quotation approval workflow", isSystem: true, steps: [ { stepNumber: 1, - roleCode: 'sales_manager', - roleName: 'Sales Manager', - approvalMode: 'sequential' + roleCode: "sales_manager", + roleName: "Sales Manager", + approvalMode: "sequential", }, { stepNumber: 2, - roleCode: 'department_manager', - roleName: 'Department Manager', - approvalMode: 'sequential' + roleCode: "department_manager", + roleName: "Department Manager", + approvalMode: "sequential", }, { stepNumber: 3, - roleCode: 'top_manager', - roleName: 'Top Manager', - approvalMode: 'sequential' - } - ] - } + roleCode: "top_manager", + roleName: "Top Manager", + approvalMode: "sequential", + }, + ], + }, ]; const APPROVAL_MATRIX_DEFAULTS = [ { - entityType: 'quotation', - workflowCode: 'quotation_standard_approval', + entityType: "quotation", + workflowCode: "quotation_standard_approval", branchId: null, productType: null, minAmount: null, @@ -906,168 +1133,188 @@ const APPROVAL_MATRIX_DEFAULTS = [ priority: 999, isDefault: true, isActive: true, - description: 'Default quotation approval matrix' - } + description: "Default quotation approval matrix", + }, ]; const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [ { - placeholderKey: 'customer_name', - sourcePath: 'customer.name', - dataType: 'scalar', - sortOrder: 1 + placeholderKey: "customer_name", + sourcePath: "customer.name", + dataType: "scalar", + sortOrder: 1, }, { - placeholderKey: 'customer_addr', - sourcePath: 'customer.address', - dataType: 'multiline', - sortOrder: 2 + placeholderKey: "customer_addr", + sourcePath: "customer.address", + dataType: "multiline", + sortOrder: 2, }, { - placeholderKey: 'customer_tel', - sourcePath: 'customer.phone', - dataType: 'scalar', - sortOrder: 3 + placeholderKey: "customer_tel", + sourcePath: "customer.phone", + dataType: "scalar", + sortOrder: 3, }, { - placeholderKey: 'customer_email', - sourcePath: 'customer.email', - dataType: 'scalar', - sortOrder: 4 + placeholderKey: "customer_email", + sourcePath: "customer.email", + dataType: "scalar", + sortOrder: 4, }, { - placeholderKey: 'customer_att', - sourcePath: 'quotation.attention', - dataType: 'scalar', - sortOrder: 5 + placeholderKey: "customer_att", + sourcePath: "quotation.attention", + dataType: "scalar", + sortOrder: 5, }, { - placeholderKey: 'project_name', - sourcePath: 'quotation.projectName', - dataType: 'scalar', - sortOrder: 6 + placeholderKey: "project_name", + sourcePath: "quotation.projectName", + dataType: "scalar", + sortOrder: 6, }, { - placeholderKey: 'site_location', - sourcePath: 'quotation.projectLocation', - dataType: 'multiline', - sortOrder: 7 + placeholderKey: "site_location", + sourcePath: "quotation.projectLocation", + dataType: "multiline", + sortOrder: 7, }, { - placeholderKey: 'quotation_date', - sourcePath: 'pdfme.quotation_date', - dataType: 'scalar', + placeholderKey: "quotation_date", + sourcePath: "pdfme.quotation_date", + dataType: "scalar", formatMask: null, - sortOrder: 8 + sortOrder: 8, }, { - placeholderKey: 'quotation_code', - sourcePath: 'quotation.code', - dataType: 'scalar', - sortOrder: 9 + placeholderKey: "quotation_code", + sourcePath: "quotation.code", + dataType: "scalar", + sortOrder: 9, }, { - placeholderKey: 'quotation_price_data', - sourcePath: 'pdfme.quotation_price_data', - dataType: 'table', - defaultValue: JSON.stringify([['Price (Exclude VAT)', '-', ' ', 'THB']]), - sortOrder: 10 + placeholderKey: "quotation_price_data", + sourcePath: "pdfme.quotation_price_data", + dataType: "table", + defaultValue: JSON.stringify([["Price (Exclude VAT)", "-", " ", "THB"]]), + sortOrder: 10, }, { - placeholderKey: 'quotation_price', - sourcePath: 'pdfme.quotation_price', - dataType: 'scalar', + placeholderKey: "quotation_price", + sourcePath: "pdfme.quotation_price", + dataType: "scalar", formatMask: null, - sortOrder: 11 + sortOrder: 11, }, { - placeholderKey: 'currency', - sourcePath: 'quotation.currency', - dataType: 'scalar', - sortOrder: 12 + placeholderKey: "currency", + sourcePath: "quotation.currency", + dataType: "scalar", + sortOrder: 12, }, { - placeholderKey: 'exclusion_data', - sourcePath: 'pdfme.exclusion_data', - dataType: 'table', - sortOrder: 13 + placeholderKey: "exclusion_data", + sourcePath: "pdfme.exclusion_data", + dataType: "table", + sortOrder: 13, }, { - placeholderKey: 'app1', - sourcePath: 'signatures.preparedBy.name', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 14 + placeholderKey: "app1", + sourcePath: "signatures.preparedBy.name", + dataType: "scalar", + defaultValue: "-", + sortOrder: 14, }, { - placeholderKey: 'app1_position', - sourcePath: 'signatures.preparedBy.position', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 15 + placeholderKey: "app1_position", + sourcePath: "signatures.preparedBy.position", + dataType: "scalar", + defaultValue: "-", + sortOrder: 15, }, { - placeholderKey: 'app2', - sourcePath: 'signatures.approvedBy.name', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 16 + placeholderKey: "app2", + sourcePath: "signatures.approvedBy.name", + dataType: "scalar", + defaultValue: "-", + sortOrder: 16, }, { - placeholderKey: 'app2_position', - sourcePath: 'signatures.approvedBy.position', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 17 + placeholderKey: "app2_position", + sourcePath: "signatures.approvedBy.position", + dataType: "scalar", + defaultValue: "-", + sortOrder: 17, }, { - placeholderKey: 'app3', - sourcePath: 'signatures.authorizedBy.name', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 18 + placeholderKey: "app3", + sourcePath: "signatures.authorizedBy.name", + dataType: "scalar", + defaultValue: "-", + sortOrder: 18, }, { - placeholderKey: 'app3_position', - sourcePath: 'signatures.authorizedBy.position', - dataType: 'scalar', - defaultValue: '-', - sortOrder: 19 + placeholderKey: "app3_position", + sourcePath: "signatures.authorizedBy.position", + dataType: "scalar", + defaultValue: "-", + sortOrder: 19, }, { - placeholderKey: 'items_table', - sourcePath: 'items', - dataType: 'table', + placeholderKey: "items_table", + sourcePath: "items", + dataType: "table", sortOrder: 20, columns: [ - { columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 }, - { columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 }, - { columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 }, - { columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 }, { - columnName: 'Unit Price', - sourceField: 'unitPrice', - columnLetter: 'E', - sortOrder: 5, - formatMask: 'currency' + columnName: "Item", + sourceField: "itemNumber", + columnLetter: "A", + sortOrder: 1, }, { - columnName: 'Total', - sourceField: 'totalPrice', - columnLetter: 'F', + columnName: "Description", + sourceField: "description", + columnLetter: "B", + sortOrder: 2, + }, + { + columnName: "Qty", + sourceField: "quantity", + columnLetter: "C", + sortOrder: 3, + }, + { + columnName: "Unit", + sourceField: "unitLabel", + columnLetter: "D", + sortOrder: 4, + }, + { + columnName: "Unit Price", + sourceField: "unitPrice", + columnLetter: "E", + sortOrder: 5, + formatMask: "currency", + }, + { + columnName: "Total", + sourceField: "totalPrice", + columnLetter: "F", sortOrder: 6, - formatMask: 'currency' - } - ] - } + formatMask: "currency", + }, + ], + }, ]; function loadTemplateSchemaByOrganizationName(organizationName: string) { const normalized = organizationName.toUpperCase(); - const fileName = normalized.includes('ONVALLA') - ? 'ONVALLA_template_pdfme_fainal3.json' - : normalized.includes('ALLA') - ? 'ALLA_template_pdfme_fainal3.json' + const fileName = normalized.includes("ONVALLA") + ? "ONVALLA_template_pdfme_fainal3.json" + : normalized.includes("ALLA") + ? "ALLA_template_pdfme_fainal3.json" : null; if (!fileName) { @@ -1075,26 +1322,31 @@ function loadTemplateSchemaByOrganizationName(organizationName: string) { filePath: null, templateName: `${organizationName} Quotation Standard`, schemaJson: { - basePdf: '', + basePdf: "", schemas: [], - pdfmeVersion: 'fallback' - } + pdfmeVersion: "fallback", + }, }; } - const absolutePath = path.resolve(process.cwd(), 'src', 'pdfme_template', fileName); - const raw = fs.readFileSync(absolutePath, 'utf8'); + const absolutePath = path.resolve( + process.cwd(), + "src", + "pdfme_template", + fileName, + ); + const raw = fs.readFileSync(absolutePath, "utf8"); return { filePath: `src/pdfme_template/${fileName}`, templateName: `${organizationName} Quotation Standard`, - schemaJson: JSON.parse(raw) + schemaJson: JSON.parse(raw), }; } async function upsertMasterOptionsForOrganization( sql: SqlClient, - organizationId: string + organizationId: string, ): Promise { const branchRows: BranchSeedRow[] = []; @@ -1155,7 +1407,7 @@ async function upsertMasterOptionsForOrganization( returning id, category, code, label `; - if (category === 'crm_branch') { + if (category === "crm_branch") { branchRows.push(row); } } @@ -1167,11 +1419,11 @@ async function upsertMasterOptionsForOrganization( async function upsertDocumentSequencesForOrganization( sql: SqlClient, organizationId: string, - branchRows: BranchSeedRow[] + branchRows: BranchSeedRow[], ) { if (!branchRows.length) { throw new Error( - `No crm_branch options were found for organization ${organizationId}. Foundation branch seed must run first.` + `No crm_branch options were found for organization ${organizationId}. Foundation branch seed must run first.`, ); } @@ -1212,7 +1464,120 @@ async function upsertDocumentSequencesForOrganization( } } -async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizationId: string) { +async function upsertDocumentSequencesForOrganizationV2( + sql: SqlClient, + organization: { id: string; slug: string }, + branchRows: BranchSeedRow[], +) { + if (!branchRows.length) { + throw new Error( + `No crm_branch options found for organization ${organization.id}. Foundation branch seed must run first.`, + ); + } + + const period = getCurrentPeriod(); + const sequenceConfig = await getDocumentSequenceConfig(); + const organizationCode = + sequenceConfig.resolveDocumentSequenceOrganizationCode(organization.slug); + + for (const branch of branchRows) { + for (const sequence of DOCUMENT_SEQUENCES) { + await sql` + insert into document_sequences ( + id, + organization_id, + branch_id, + product_type, + document_type, + prefix, + period, + current_number, + padding_length, + format, + reset_policy, + is_active + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${branch.id}, + ${"all"}, + ${sequence.documentType}, + ${sequence.prefix}, + ${period}, + ${0}, + ${3}, + ${"{prefix}{period}-{running}"}, + ${"period"}, + ${true} + ) + on conflict (organization_id, branch_id, product_type, document_type, period) do update + set + prefix = excluded.prefix, + padding_length = excluded.padding_length, + format = excluded.format, + reset_policy = excluded.reset_policy, + is_active = excluded.is_active, + updated_at = now() + `; + } + + if (!organizationCode) { + continue; + } + + for (const productType of QUOTATION_SEQUENCE_PRODUCT_TYPES) { + const productTypeCode = + sequenceConfig.resolveDocumentSequenceProductTypeCode(productType); + + if (!productTypeCode) { + continue; + } + + await sql` + insert into document_sequences ( + id, + organization_id, + branch_id, + product_type, + document_type, + prefix, + period, + current_number, + padding_length, + format, + reset_policy, + is_active + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${branch.id}, + ${productType}, + ${"quotation"}, + ${`${productTypeCode}${organizationCode}`}, + ${period}, + ${0}, + ${3}, + ${"{prefix}{period}-{running}"}, + ${"period"}, + ${true} + ) + on conflict (organization_id, branch_id, product_type, document_type, period) do update + set + prefix = excluded.prefix, + padding_length = excluded.padding_length, + format = excluded.format, + reset_policy = excluded.reset_policy, + is_active = excluded.is_active, + updated_at = now() + `; + } + } +} + +async function upsertApprovalWorkflowsForOrganization( + sql: SqlClient, + organizationId: string, +) { for (const workflow of APPROVAL_WORKFLOWS) { const [workflowRow] = await sql` insert into crm_approval_workflows ( @@ -1277,7 +1642,7 @@ async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizati async function upsertApprovalMatricesForOrganization( sql: SqlClient, - organization: { id: string; createdBy: string } + organization: { id: string; createdBy: string }, ) { const workflows = await sql` select id, code @@ -1287,7 +1652,10 @@ async function upsertApprovalMatricesForOrganization( `; const workflowIdByCode = new Map( - workflows.map((workflow: { code: string; id: string }) => [workflow.code, workflow.id]) + workflows.map((workflow: { code: string; id: string }) => [ + workflow.code, + workflow.id, + ]), ); for (const matrix of APPROVAL_MATRIX_DEFAULTS) { @@ -1366,7 +1734,7 @@ async function upsertApprovalMatricesForOrganization( async function upsertDocumentTemplatesForOrganization( sql: SqlClient, - organization: { id: string; name: string; createdBy: string } + organization: { id: string; name: string; createdBy: string }, ) { const templateSeed = loadTemplateSchemaByOrganizationName(organization.name); const [templateRow] = await sql` @@ -1386,11 +1754,11 @@ async function upsertDocumentTemplatesForOrganization( ) values ( ${crypto.randomUUID()}, ${organization.id}, - ${'quotation'}, - ${'default'}, - ${'pdfme'}, + ${"quotation"}, + ${"default"}, + ${"pdfme"}, ${templateSeed.templateName}, - ${'Seeded quotation template metadata for production preview foundation.'}, + ${"Seeded quotation template metadata for production preview foundation."}, ${true}, ${true}, ${null}, @@ -1408,9 +1776,9 @@ async function upsertDocumentTemplatesForOrganization( select id from crm_document_templates where organization_id = ${organization.id} - and document_type = ${'quotation'} - and product_type = ${'default'} - and file_type = ${'pdfme'} + and document_type = ${"quotation"} + and product_type = ${"default"} + and file_type = ${"pdfme"} and deleted_at is null order by is_default desc, created_at asc limit 1 @@ -1418,7 +1786,9 @@ async function upsertDocumentTemplatesForOrganization( )[0]?.id; if (!resolvedTemplateId) { - throw new Error(`Unable to resolve template id for organization ${organization.id}`); + throw new Error( + `Unable to resolve template id for organization ${organization.id}`, + ); } const [versionRow] = await sql` @@ -1437,7 +1807,7 @@ async function upsertDocumentTemplatesForOrganization( ${crypto.randomUUID()}, ${organization.id}, ${resolvedTemplateId}, - ${'1.0'}, + ${"1.0"}, ${templateSeed.filePath}, ${JSON.stringify(templateSeed.schemaJson)}, ${null}, @@ -1457,7 +1827,7 @@ async function upsertDocumentTemplatesForOrganization( from crm_document_template_versions where organization_id = ${organization.id} and template_id = ${resolvedTemplateId} - and version = ${'1.0'} + and version = ${"1.0"} and deleted_at is null order by created_at asc limit 1 @@ -1465,7 +1835,9 @@ async function upsertDocumentTemplatesForOrganization( )[0]?.id; if (!resolvedVersionId) { - throw new Error(`Unable to resolve template version id for organization ${organization.id}`); + throw new Error( + `Unable to resolve template version id for organization ${organization.id}`, + ); } for (const mapping of DOCUMENT_TEMPLATE_MAPPINGS) { @@ -1593,7 +1965,7 @@ async function upsertDocumentTemplatesForOrganization( where organization_id = ${organization.id} and template_version_id = ${resolvedVersionId} and ( - placeholder_key in (${'topic'}, ${'data_topic'}, ${'item_topic'}) + placeholder_key in (${"topic"}, ${"data_topic"}, ${"item_topic"}) or placeholder_key like 'topic\_%' escape '\' or placeholder_key like 'data_topic\_%' escape '\' or placeholder_key like 'item_topic\_%' escape '\' @@ -1608,14 +1980,14 @@ async function upsertDocumentTemplatesForOrganization( updated_at = now() where organization_id = ${organization.id} and template_version_id = ${resolvedVersionId} - and placeholder_key in (${'exclusion_data_'}) + and placeholder_key in (${"exclusion_data_"}) and deleted_at is null `; } async function upsertDocumentLibraryForOrganization( sql: SqlClient, - organization: { id: string; name: string; createdBy: string } + organization: { id: string; name: string; createdBy: string }, ) { const storageProvider = await getSeedStorageProvider(); @@ -1645,7 +2017,7 @@ async function upsertDocumentLibraryForOrganization( ${librarySeed.brand}, ${librarySeed.language}, ${librarySeed.productType}, - ${'active'}, + ${"active"}, ${true}, ${organization.createdBy}, ${organization.createdBy} @@ -1666,7 +2038,9 @@ async function upsertDocumentLibraryForOrganization( returning id `; - const hasSeededActiveVersion = librarySeed.versions.some((version) => version.status === 'active'); + const hasSeededActiveVersion = librarySeed.versions.some( + (version) => version.status === "active", + ); if (hasSeededActiveVersion) { await sql` @@ -1699,25 +2073,27 @@ async function upsertDocumentLibraryForOrganization( language: librarySeed.language, productType: librarySeed.productType, version: versionSeed.version, - summary: versionSeed.summary + summary: versionSeed.summary, }); const storageKey = buildOrganizationStorageKey(organization.id, [ - 'document-library', + "document-library", libraryRow.id, versionId, - versionSeed.fileName + versionSeed.fileName, ]); const storedObject = await storageProvider.putObject({ key: storageKey, body: pdfBuffer, - contentType: 'application/pdf', - fileName: versionSeed.fileName + contentType: "application/pdf", + fileName: versionSeed.fileName, }); - const lifecycleTimestamps = resolveLifecycleTimestamps(versionSeed.status); - const isActiveVersion = versionSeed.status === 'active'; + const lifecycleTimestamps = resolveLifecycleTimestamps( + versionSeed.status, + ); + const isActiveVersion = versionSeed.status === "active"; await sql` insert into crm_document_library_versions ( @@ -1748,7 +2124,7 @@ async function upsertDocumentLibraryForOrganization( ${versionSeed.fileName}, ${storedObject.provider}, ${storedObject.key}, - ${'application/pdf'}, + ${"application/pdf"}, ${pdfBuffer.byteLength}, ${sha256(pdfBuffer)}, ${1}, @@ -1785,7 +2161,7 @@ async function upsertDocumentLibraryForOrganization( async function upsertReportDefinitionsForOrganization( sql: SqlClient, - organization: { id: string } + organization: { id: string }, ) { for (const definition of REPORT_DEFINITIONS) { await sql` @@ -1822,67 +2198,72 @@ async function upsertReportDefinitionsForOrganization( async function upsertNotificationTemplatesForOrganization( sql: SqlClient, - organization: { id: string; createdBy: string } + organization: { id: string; createdBy: string }, ) { const templates = [ { - eventType: 'approval.requested', - titleTemplate: 'Approval Required: {{quotationCode}}', + eventType: "approval.requested", + titleTemplate: "Approval Required: {{quotationCode}}", bodyTemplate: - '{{actorName}} submitted {{quotationCode}} for approval in {{workflowName}}.', - linkTemplate: '{{entityLink}}' + "{{actorName}} submitted {{quotationCode}} for approval in {{workflowName}}.", + linkTemplate: "{{entityLink}}", }, { - eventType: 'approval.step.approved', - titleTemplate: 'Approval Updated: {{quotationCode}}', - bodyTemplate: '{{actorName}} approved {{quotationCode}}. Next step: {{currentStepRoleName}}.', - linkTemplate: '{{entityLink}}' - }, - { - eventType: 'approval.step.rejected', - titleTemplate: 'Approval Rejected: {{quotationCode}}', - bodyTemplate: '{{actorName}} rejected {{quotationCode}}. Reason: {{remark}}', - linkTemplate: '{{entityLink}}' - }, - { - eventType: 'approval.completed', - titleTemplate: 'Approval Completed: {{quotationCode}}', - bodyTemplate: '{{quotationCode}} has been fully approved in {{workflowName}}.', - linkTemplate: '{{entityLink}}' - }, - { - eventType: 'approval.cancelled', - titleTemplate: 'Approval Cancelled: {{quotationCode}}', - bodyTemplate: '{{actorName}} cancelled the approval request for {{quotationCode}}.', - linkTemplate: '{{entityLink}}' - }, - { - eventType: 'approval.returned', - titleTemplate: 'Approval Returned: {{quotationCode}}', - bodyTemplate: '{{actorName}} returned {{quotationCode}} for revision. Reason: {{remark}}', - linkTemplate: '{{entityLink}}' - }, - { - eventType: 'approval.reminder', - titleTemplate: 'Approval Reminder: {{workflowName}}', + eventType: "approval.step.approved", + titleTemplate: "Approval Updated: {{quotationCode}}", bodyTemplate: - '{{quotationCode}} is still waiting on {{currentStepRoleName}}. Please review it.', - linkTemplate: '{{entityLink}}' + "{{actorName}} approved {{quotationCode}}. Next step: {{currentStepRoleName}}.", + linkTemplate: "{{entityLink}}", }, { - eventType: 'approval.escalated', - titleTemplate: 'Approval Escalated: {{workflowName}}', + eventType: "approval.step.rejected", + titleTemplate: "Approval Rejected: {{quotationCode}}", bodyTemplate: - '{{quotationCode}} exceeded its escalation threshold at {{currentStepRoleName}}.', - linkTemplate: '{{entityLink}}' + "{{actorName}} rejected {{quotationCode}}. Reason: {{remark}}", + linkTemplate: "{{entityLink}}", }, { - eventType: 'approval.timeout', - titleTemplate: 'Approval Timeout: {{workflowName}}', + eventType: "approval.completed", + titleTemplate: "Approval Completed: {{quotationCode}}", bodyTemplate: - '{{quotationCode}} reached timeout handling at {{currentStepRoleName}}. {{remark}}', - linkTemplate: '{{entityLink}}' - } + "{{quotationCode}} has been fully approved in {{workflowName}}.", + linkTemplate: "{{entityLink}}", + }, + { + eventType: "approval.cancelled", + titleTemplate: "Approval Cancelled: {{quotationCode}}", + bodyTemplate: + "{{actorName}} cancelled the approval request for {{quotationCode}}.", + linkTemplate: "{{entityLink}}", + }, + { + eventType: "approval.returned", + titleTemplate: "Approval Returned: {{quotationCode}}", + bodyTemplate: + "{{actorName}} returned {{quotationCode}} for revision. Reason: {{remark}}", + linkTemplate: "{{entityLink}}", + }, + { + eventType: "approval.reminder", + titleTemplate: "Approval Reminder: {{workflowName}}", + bodyTemplate: + "{{quotationCode}} is still waiting on {{currentStepRoleName}}. Please review it.", + linkTemplate: "{{entityLink}}", + }, + { + eventType: "approval.escalated", + titleTemplate: "Approval Escalated: {{workflowName}}", + bodyTemplate: + "{{quotationCode}} exceeded its escalation threshold at {{currentStepRoleName}}.", + linkTemplate: "{{entityLink}}", + }, + { + eventType: "approval.timeout", + titleTemplate: "Approval Timeout: {{workflowName}}", + bodyTemplate: + "{{quotationCode}} reached timeout handling at {{currentStepRoleName}}. {{remark}}", + linkTemplate: "{{entityLink}}", + }, ]; for (const template of templates) { @@ -1904,12 +2285,12 @@ async function upsertNotificationTemplatesForOrganization( ${crypto.randomUUID()}, ${organization.id}, ${template.eventType}, - ${'in_app'}, + ${"in_app"}, ${template.titleTemplate}, ${template.bodyTemplate}, ${template.linkTemplate}, ${true}, - ${JSON.stringify({ seededBy: 'foundation.seed' })}, + ${JSON.stringify({ seededBy: "foundation.seed" })}, ${null}, ${organization.createdBy}, ${organization.createdBy} @@ -1931,33 +2312,42 @@ async function upsertNotificationTemplatesForOrganization( async function main() { loadLocalEnv(); - const databaseUrl = requireEnv('DATABASE_URL'); + const databaseUrl = requireEnv("DATABASE_URL"); const sql = postgres(databaseUrl, { prepare: false }); try { const organizations = await sql` - select id, name, created_by as "createdBy" + select id, name, slug, created_by as "createdBy" from organizations order by name asc `; if (organizations.length === 0) { throw new Error( - 'No organizations found. Create at least one organization before running foundation seed.' + "No organizations found. Create at least one organization before running foundation seed.", ); } - for (const organization of organizations) { - const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id); - await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows); - await upsertApprovalWorkflowsForOrganization(sql, organization.id); - await upsertApprovalMatricesForOrganization(sql, organization); - await upsertDocumentTemplatesForOrganization(sql, organization); - await upsertDocumentLibraryForOrganization(sql, organization); - await upsertNotificationTemplatesForOrganization(sql, organization); - await upsertReportDefinitionsForOrganization(sql, organization); - console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); - } + for (const organization of organizations) { + const branchRows = await upsertMasterOptionsForOrganization( + sql, + organization.id, + ); + await upsertDocumentSequencesForOrganizationV2( + sql, + organization, + branchRows, + ); + await upsertApprovalWorkflowsForOrganization(sql, organization.id); + await upsertApprovalMatricesForOrganization(sql, organization); + await upsertDocumentTemplatesForOrganization(sql, organization); + await upsertDocumentLibraryForOrganization(sql, organization); + await upsertNotificationTemplatesForOrganization(sql, organization); + await upsertReportDefinitionsForOrganization(sql, organization); + console.log( + `[seed-foundation] Seeded foundation data for ${organization.name}`, + ); + } } finally { await sql.end(); } @@ -1965,8 +2355,7 @@ async function main() { if (require.main === module) { main().catch((error) => { - console.error('[seed-foundation] Failed:', error.message); + console.error("[seed-foundation] Failed:", error.message); process.exitCode = 1; }); } - diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts index 49b0ac6..37586a9 100644 --- a/src/features/crm/quotations/server/service.ts +++ b/src/features/crm/quotations/server/service.ts @@ -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 diff --git a/src/features/foundation/document-sequence/components/document-sequence-settings.tsx b/src/features/foundation/document-sequence/components/document-sequence-settings.tsx index c91088e..d87453a 100644 --- a/src/features/foundation/document-sequence/components/document-sequence-settings.tsx +++ b/src/features/foundation/document-sequence/components/document-sequence-settings.tsx @@ -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(toState(sequence)); + const [state, setState] = React.useState(() => 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) { 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 ( - + {sequence ? 'Edit Sequence' : 'Create Sequence'} Preview never increments the counter. Generation remains server-side only. +
- - setState((current) => ({ ...current, documentType: event.target.value })) - } - /> +
+ + setState((current) => ({ ...current, documentType: event.target.value })) + } + /> + +
+
setState((current) => ({ ...current, period: event.target.value })) } />
+
+ setState((current) => ({ ...current, format: event.target.value })) + } + /> + + setState((current) => ({ ...current, resetPolicy: event.target.value })) + } + /> +
+
Active
- Inactive sequences can stay preserved for legacy numbering. + Inactive sequences stay preserved for legacy numbering history.
+ - {data.items.length === 0 ? ( +
+
+
Organization
+
{organizationName}
+
+ + + + setFilters((current) => ({ ...current, documentType: event.target.value })) + } + /> + + setFilters((current) => ({ ...current, period: event.target.value })) + } + /> + { + setFilters((current) => ({ + ...current, + prefix: event.target.value + })); + }} + /> +
+ + {items.length === 0 ? (
- No document sequences configured yet. + No document sequences match the current filters.
) : ( - data.items.map((sequence) => ( + items.map((sequence) => (
{sequence.documentType}
- {sequence.prefix} {sequence.period} {'\u2022'}{' '} - - {sequence.branchDisplayName ?? 'All Branches'} - + {sequence.prefix} {sequence.period} {'\u2022'} {sequence.branchDisplayName}{' '} + {'\u2022'} {sequence.productTypeLabel ?? sequence.productType}
@@ -305,36 +465,31 @@ export function DocumentSequenceSettings({ branches }: { branches: FoundationBra
-
-
-
Branch
-
- {sequence.branchDisplayName ?? 'All Branches'} -
-
-
-
Current Number
-
{sequence.currentNumber}
-
-
-
Padding Length
-
{sequence.paddingLength}
-
-
-
Updated At
-
{formatDateTime(sequence.updatedAt)}
-
+
+ + + + + + + +
); } + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/features/foundation/document-sequence/config.test.ts b/src/features/foundation/document-sequence/config.test.ts new file mode 100644 index 0000000..7d81f03 --- /dev/null +++ b/src/features/foundation/document-sequence/config.test.ts @@ -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'); +}); diff --git a/src/features/foundation/document-sequence/config.ts b/src/features/foundation/document-sequence/config.ts new file mode 100644 index 0000000..2993a48 --- /dev/null +++ b/src/features/foundation/document-sequence/config.ts @@ -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 = { + 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 = { + 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; +} diff --git a/src/features/foundation/document-sequence/service.ts b/src/features/foundation/document-sequence/service.ts index bf352d2..284f938 100644 --- a/src/features/foundation/document-sequence/service.ts +++ b/src/features/foundation/document-sequence/service.ts @@ -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 = { customer: 'CUS', contact: 'CON', @@ -23,26 +35,54 @@ const DEFAULT_DOCUMENT_PREFIXES: Record = { 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 { + 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 ) { - 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 { + 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 { - 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 { + 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 ): Promise { - 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 { - 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; + } } diff --git a/src/features/foundation/document-sequence/types.ts b/src/features/foundation/document-sequence/types.ts index 2c07885..ec3a689 100644 --- a/src/features/foundation/document-sequence/types.ts +++ b/src/features/foundation/document-sequence/types.ts @@ -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; } diff --git a/src/features/foundation/storage/server/provider.ts b/src/features/foundation/storage/server/provider.ts index 7a31867..747c1ad 100644 --- a/src/features/foundation/storage/server/provider.ts +++ b/src/features/foundation/storage/server/provider.ts @@ -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: diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/04ae4d0f-978a-4380-a721-4ca5a368e29b/0e6d00e1-7fc2-4eda-8cbf-e568fedc1a20/company-profile-alla-all-th-2025.12.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/04ae4d0f-978a-4380-a721-4ca5a368e29b/0e6d00e1-7fc2-4eda-8cbf-e568fedc1a20/company-profile-alla-all-th-2025.12.pdf new file mode 100644 index 0000000..23c43de Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/04ae4d0f-978a-4380-a721-4ca5a368e29b/0e6d00e1-7fc2-4eda-8cbf-e568fedc1a20/company-profile-alla-all-th-2025.12.pdf differ diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/04ae4d0f-978a-4380-a721-4ca5a368e29b/8caa962b-43b9-48bc-bbc0-cf1c59190d45/company-profile-alla-all-th-2026.01.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/04ae4d0f-978a-4380-a721-4ca5a368e29b/8caa962b-43b9-48bc-bbc0-cf1c59190d45/company-profile-alla-all-th-2026.01.pdf new file mode 100644 index 0000000..11aabc9 Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/04ae4d0f-978a-4380-a721-4ca5a368e29b/8caa962b-43b9-48bc-bbc0-cf1c59190d45/company-profile-alla-all-th-2026.01.pdf differ diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/7a1994f0-be00-498e-adc8-750c5a969739/10a687c4-b625-4e67-86de-6a70f53745b4/warranty-onvalla-dock-door-en-v1.0.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/7a1994f0-be00-498e-adc8-750c5a969739/10a687c4-b625-4e67-86de-6a70f53745b4/warranty-onvalla-dock-door-en-v1.0.pdf new file mode 100644 index 0000000..9b26b05 Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/7a1994f0-be00-498e-adc8-750c5a969739/10a687c4-b625-4e67-86de-6a70f53745b4/warranty-onvalla-dock-door-en-v1.0.pdf differ diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b03289f3-40e7-4ef2-90ec-bc902b08478f/2e00975c-c2bb-4052-a577-f6c794d60e58/sla-alla-crane-th-v1.1.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b03289f3-40e7-4ef2-90ec-bc902b08478f/2e00975c-c2bb-4052-a577-f6c794d60e58/sla-alla-crane-th-v1.1.pdf new file mode 100644 index 0000000..91f3245 Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b03289f3-40e7-4ef2-90ec-bc902b08478f/2e00975c-c2bb-4052-a577-f6c794d60e58/sla-alla-crane-th-v1.1.pdf differ diff --git a/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b03289f3-40e7-4ef2-90ec-bc902b08478f/cf179814-69a4-401c-9eb6-3469792f41ec/sla-alla-crane-th-v1.0.pdf b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b03289f3-40e7-4ef2-90ec-bc902b08478f/cf179814-69a4-401c-9eb6-3469792f41ec/sla-alla-crane-th-v1.0.pdf new file mode 100644 index 0000000..a8d423a Binary files /dev/null and b/storage/organizations/daf796fc-4588-5240-a27e-9bfef0739658/document-library/b03289f3-40e7-4ef2-90ec-bc902b08478f/cf179814-69a4-401c-9eb6-3469792f41ec/sla-alla-crane-th-v1.0.pdf differ