{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 (
+
+ );
+}
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