uat-doc-perfix

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

View File

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

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
formatDocumentSequencePeriod,
normalizeDocumentSequenceProductType,
resolveDocumentSequenceOrganizationCode,
resolveDocumentSequenceProductTypeCode
} from './config';
test('resolveDocumentSequenceProductTypeCode supports current CRM product type aliases', () => {
assert.equal(resolveDocumentSequenceProductTypeCode('crane'), 'CR');
assert.equal(resolveDocumentSequenceProductTypeCode('dockdoor'), 'DK');
assert.equal(resolveDocumentSequenceProductTypeCode('dock_door'), 'DK');
assert.equal(resolveDocumentSequenceProductTypeCode('solarcell'), 'SC');
assert.equal(resolveDocumentSequenceProductTypeCode('service'), 'SV');
assert.equal(resolveDocumentSequenceProductTypeCode('sparepart'), 'SP');
});
test('resolveDocumentSequenceOrganizationCode uses centralized slug mapping', () => {
assert.equal(resolveDocumentSequenceOrganizationCode('alla'), 'A');
assert.equal(resolveDocumentSequenceOrganizationCode('alla-demo'), 'A');
assert.equal(resolveDocumentSequenceOrganizationCode('onvalla'), 'O');
assert.equal(resolveDocumentSequenceOrganizationCode('unknown-org'), null);
});
test('formatDocumentSequencePeriod returns YYMM format', () => {
assert.equal(formatDocumentSequencePeriod(new Date('2026-06-15T00:00:00.000Z')), '2606');
});
test('normalizeDocumentSequenceProductType keeps explicit generic fallback', () => {
assert.equal(normalizeDocumentSequenceProductType('Dock Door'), 'dock_door');
assert.equal(normalizeDocumentSequenceProductType(''), 'all');
});

View File

@@ -0,0 +1,40 @@
export const DOCUMENT_SEQUENCE_DEFAULT_FORMAT = '{prefix}{period}-{running}';
export const DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY = 'period';
export const DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE = 'all';
export const DOCUMENT_SEQUENCE_LEGACY_PRODUCT_TYPE = 'legacy';
export const DOCUMENT_SEQUENCE_PRODUCT_TYPE_CODES: Record<string, string> = {
crane: 'CR',
dockdoor: 'DK',
dock_door: 'DK',
solarcell: 'SC',
solar_cell: 'SC',
solar: 'SC',
service: 'SV',
sparepart: 'SP',
spare_part: 'SP'
};
export const DOCUMENT_SEQUENCE_ORGANIZATION_CODES: Record<string, string> = {
alla: 'A',
'alla-demo': 'A',
onvalla: 'O'
};
export function normalizeDocumentSequenceProductType(value?: string | null) {
return value?.trim().toLowerCase().replace(/[\s-]+/g, '_') || DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE;
}
export function formatDocumentSequencePeriod(date: Date) {
const year = String(date.getFullYear()).slice(-2);
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${year}${month}`;
}
export function resolveDocumentSequenceProductTypeCode(productType: string) {
return DOCUMENT_SEQUENCE_PRODUCT_TYPE_CODES[normalizeDocumentSequenceProductType(productType)] ?? null;
}
export function resolveDocumentSequenceOrganizationCode(slug?: string | null) {
return slug ? DOCUMENT_SEQUENCE_ORGANIZATION_CODES[slug.trim().toLowerCase()] ?? null : null;
}

View File

@@ -1,9 +1,19 @@
import { and, asc, eq, sql } from 'drizzle-orm';
import { documentSequences } from '@/db/schema';
import { and, asc, eq, ne, sql } from 'drizzle-orm';
import { documentSequences, organizations } from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service';
import { resolveBranchDisplays } from '@/features/foundation/display/server/display-resolver';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE,
normalizeDocumentSequenceProductType,
resolveDocumentSequenceOrganizationCode,
resolveDocumentSequenceProductTypeCode
} from './config';
import type {
DocumentSequenceInput,
DocumentSequenceListItem,
@@ -13,6 +23,8 @@ import type {
DocumentSequenceResult
} from './types';
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
customer: 'CUS',
contact: 'CON',
@@ -23,26 +35,54 @@ const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
approval: 'APV'
};
type OrganizationRecord = {
id: string;
name: string;
slug: string;
};
function getCurrentPeriod(date = new Date()) {
const year = String(date.getFullYear()).slice(-2);
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${year}${month}`;
}
function buildDocumentCode(
prefix: string,
period: string,
nextNumber: number,
paddingLength: number
) {
return `${prefix}${period}-${String(nextNumber).padStart(paddingLength, '0')}`;
}
function normalizeBranchId(branchId?: string | null) {
return branchId?.trim() || '';
}
function normalizeDocumentType(documentType: string) {
const normalized = documentType.trim();
if (!normalized) {
throw new AuthError('Document type is required.', 400);
}
return normalized;
}
function normalizePeriod(period?: string | null) {
const normalized = period?.trim();
if (!normalized) {
throw new AuthError('Period is required.', 400);
}
if (!/^\d{4}$/.test(normalized)) {
throw new AuthError('Period must use YYMM format.', 400);
}
return normalized;
}
function normalizeFormat(value?: string | null) {
return value?.trim() || DOCUMENT_SEQUENCE_DEFAULT_FORMAT;
}
function normalizeResetPolicy(value?: string | null) {
return value?.trim() || DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY;
}
function formatBranchDisplayName(input: {
branchId: string | null;
branchCode?: string | null;
@@ -63,104 +103,198 @@ function formatBranchDisplayName(input: {
return `Unknown Branch (${input.branchId})`;
}
async function resolveOrganizationId(organizationId?: string) {
if (organizationId) {
return organizationId;
}
function buildDocumentCode(
format: string,
prefix: string,
period: string,
nextNumber: number,
paddingLength: number
) {
const running = String(nextNumber).padStart(paddingLength, '0');
const organization = await getCurrentOrganization();
if (!organization) {
throw new AuthError('Active organization required', 400);
}
return organization.id;
return format
.replaceAll('{prefix}', prefix)
.replaceAll('{period}', period)
.replaceAll('{running}', running);
}
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
const nextNumber = row.currentNumber + 1;
async function resolveOrganizationRecord(organizationId?: string): Promise<OrganizationRecord> {
if (!organizationId) {
const organization = await getCurrentOrganization();
return {
code: buildDocumentCode(row.prefix, row.period, nextNumber, row.paddingLength),
documentType: row.documentType,
branchId: row.branchId || null,
currentNumber: row.currentNumber,
nextNumber,
period: row.period,
prefix: row.prefix
};
if (!organization) {
throw new AuthError('Active organization required.', 400);
}
return {
id: organization.id,
name: organization.name,
slug: organization.slug
};
}
const [organization] = await db
.select({
id: organizations.id,
name: organizations.name,
slug: organizations.slug
})
.from(organizations)
.where(eq(organizations.id, organizationId))
.limit(1);
if (!organization) {
throw new AuthError('Organization not found.', 404);
}
return organization;
}
async function resolveProductTypeMap(organizationId: string) {
const options = await getActiveOptionsByCategory(PRODUCT_TYPE_CATEGORY, { organizationId });
return new Map(
options.map((option) => [
normalizeDocumentSequenceProductType(option.code),
{
id: option.id,
code: normalizeDocumentSequenceProductType(option.code),
label: option.label
}
])
);
}
async function resolveScopedProductType(input: {
organizationId: string;
documentType: string;
productType?: string | null;
}) {
const normalizedDocumentType = normalizeDocumentType(input.documentType);
const requestedProductType = input.productType?.trim();
if (normalizedDocumentType !== 'quotation') {
return requestedProductType
? normalizeDocumentSequenceProductType(requestedProductType)
: DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE;
}
if (!requestedProductType) {
throw new AuthError('Product type is required for quotation document sequences.', 400);
}
const productTypeMap = await resolveProductTypeMap(input.organizationId);
const normalizedRequestedProductType =
normalizeDocumentSequenceProductType(requestedProductType);
const byCode = productTypeMap.get(normalizedRequestedProductType);
if (byCode) {
return byCode.code;
}
const byId = [...productTypeMap.values()].find((option) => option.id === requestedProductType);
if (byId) {
return byId.code;
}
throw new AuthError('Missing product type code configuration for this quotation type.', 400);
}
async function resolvePrefix(input: {
organization: OrganizationRecord;
documentType: string;
productType: string;
prefix?: string | null;
}) {
const manualPrefix = input.prefix?.trim();
if (manualPrefix) {
return manualPrefix;
}
if (input.documentType !== 'quotation') {
return DEFAULT_DOCUMENT_PREFIXES[input.documentType] ?? input.documentType.slice(0, 3).toUpperCase();
}
const organizationCode = resolveDocumentSequenceOrganizationCode(input.organization.slug);
if (!organizationCode) {
throw new AuthError(
`Missing organization code configuration for ${input.organization.slug}.`,
400
);
}
const productTypeCode = resolveDocumentSequenceProductTypeCode(input.productType);
if (!productTypeCode) {
throw new AuthError(`Missing product type code configuration for ${input.productType}.`, 400);
}
return `${productTypeCode}${organizationCode}`;
}
async function assertSequence(id: string, organizationId: string) {
const row = await db.query.documentSequences.findFirst({
where: and(
eq(documentSequences.id, id),
eq(documentSequences.organizationId, organizationId)
)
where: and(eq(documentSequences.id, id), eq(documentSequences.organizationId, organizationId))
});
if (!row) {
throw new AuthError('Document sequence not found', 404);
throw new AuthError('Document sequence not found.', 404);
}
return row;
}
async function ensureSequenceRow(
organizationId: string,
documentType: string,
period: string,
branchId: string
) {
async function assertUniqueScope(input: {
organizationId: string;
branchId: string;
productType: string;
documentType: string;
period: string;
currentId?: string;
}) {
const existing = await db.query.documentSequences.findFirst({
where: and(
eq(documentSequences.organizationId, organizationId),
eq(documentSequences.documentType, documentType),
eq(documentSequences.period, period),
eq(documentSequences.branchId, branchId)
eq(documentSequences.organizationId, input.organizationId),
eq(documentSequences.branchId, input.branchId),
eq(documentSequences.productType, input.productType),
eq(documentSequences.documentType, input.documentType),
eq(documentSequences.period, input.period),
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : [])
)
});
if (existing) {
return existing;
throw new AuthError(
'Duplicate document sequence scope for organization, branch, product type, document type, and period.',
409
);
}
const prefix =
DEFAULT_DOCUMENT_PREFIXES[documentType] ?? documentType.slice(0, 3).toUpperCase();
const [created] = await db
.insert(documentSequences)
.values({
id: crypto.randomUUID(),
organizationId,
branchId,
documentType,
period,
prefix,
currentNumber: 0,
paddingLength: 3,
isActive: true
})
.returning();
return created;
}
async function mapSequenceRecords(
organizationId: string,
organization: OrganizationRecord,
rows: Array<typeof documentSequences.$inferSelect>
) {
const branchDisplayMap = await resolveBranchDisplays({
organizationId,
branchIds: rows.map((row) => row.branchId || null)
});
const [branchDisplayMap, productTypeMap] = await Promise.all([
resolveBranchDisplays({
organizationId: organization.id,
branchIds: rows.map((row) => row.branchId || null)
}),
resolveProductTypeMap(organization.id)
]);
const organizationCode = resolveDocumentSequenceOrganizationCode(organization.slug);
return rows.map((row) => {
const branchId = row.branchId || null;
const branchDisplay = branchId ? (branchDisplayMap.get(branchId) ?? null) : null;
const normalizedProductType = normalizeDocumentSequenceProductType(row.productType);
const productType = productTypeMap.get(normalizedProductType) ?? null;
return {
id: row.id,
organizationId: row.organizationId,
organizationName: organization.name,
organizationCode,
branchId,
branchCode: branchDisplay?.code ?? null,
branchName: branchDisplay?.name ?? null,
@@ -169,11 +303,24 @@ async function mapSequenceRecords(
branchCode: branchDisplay?.code ?? null,
branchName: branchDisplay?.name ?? null
}),
productType: normalizedProductType,
productTypeLabel:
normalizedProductType === DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE
? 'All Product Types'
: normalizedProductType === 'legacy'
? 'Legacy'
: productType?.label ?? null,
productTypeSequenceCode:
normalizedProductType === 'legacy'
? null
: resolveDocumentSequenceProductTypeCode(normalizedProductType),
documentType: row.documentType,
prefix: row.prefix,
period: row.period,
currentNumber: row.currentNumber,
paddingLength: row.paddingLength,
format: row.format,
resetPolicy: row.resetPolicy,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
@@ -181,24 +328,46 @@ async function mapSequenceRecords(
});
}
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
return {
code: buildDocumentCode(
row.format,
row.prefix,
row.period,
row.currentNumber + 1,
row.paddingLength
),
documentType: row.documentType,
productType: row.productType,
branchId: row.branchId || null,
currentNumber: row.currentNumber,
nextNumber: row.currentNumber + 1,
period: row.period,
prefix: row.prefix
};
}
export async function listDocumentSequences(
organizationId: string
): Promise<DocumentSequenceListItem[]> {
const organization = await resolveOrganizationRecord(organizationId);
const rows = await db
.select()
.from(documentSequences)
.where(eq(documentSequences.organizationId, organizationId))
.where(eq(documentSequences.organizationId, organization.id))
.orderBy(
asc(documentSequences.documentType),
asc(documentSequences.productType),
asc(documentSequences.period),
asc(documentSequences.branchId)
);
const records = await mapSequenceRecords(organizationId, rows);
const records = await mapSequenceRecords(organization, rows);
return records.map((record) => ({
...record,
nextPreview: buildDocumentCode(
record.format,
record.prefix,
record.period,
record.currentNumber + 1,
@@ -211,8 +380,9 @@ export async function getDocumentSequence(
id: string,
organizationId: string
): Promise<DocumentSequenceRecord> {
const row = await assertSequence(id, organizationId);
const [record] = await mapSequenceRecords(organizationId, [row]);
const organization = await resolveOrganizationRecord(organizationId);
const row = await assertSequence(id, organization.id);
const [record] = await mapSequenceRecords(organization, [row]);
return record;
}
@@ -221,24 +391,49 @@ export async function createDocumentSequence(
organizationId: string,
payload: DocumentSequenceMutationPayload
): Promise<DocumentSequenceRecord> {
const organization = await resolveOrganizationRecord(organizationId);
const documentType = normalizeDocumentType(payload.documentType);
const branchId = normalizeBranchId(payload.branchId);
const productType = await resolveScopedProductType({
organizationId: organization.id,
documentType,
productType: payload.productType
});
const period = normalizePeriod(payload.period);
await assertUniqueScope({
organizationId: organization.id,
branchId,
productType,
documentType,
period
});
const [created] = await db
.insert(documentSequences)
.values({
id: crypto.randomUUID(),
organizationId,
organizationId: organization.id,
branchId,
documentType: payload.documentType.trim(),
prefix: payload.prefix.trim(),
period: payload.period.trim(),
productType,
documentType,
prefix: await resolvePrefix({
organization,
documentType,
productType,
prefix: payload.prefix
}),
period,
currentNumber: payload.currentNumber ?? 0,
paddingLength: payload.paddingLength,
format: normalizeFormat(payload.format),
resetPolicy: normalizeResetPolicy(payload.resetPolicy),
isActive: payload.isActive ?? true,
updatedAt: new Date()
})
.returning();
const [record] = await mapSequenceRecords(organizationId, [created]);
const [record] = await mapSequenceRecords(organization, [created]);
return record;
}
@@ -247,26 +442,51 @@ export async function updateDocumentSequence(
organizationId: string,
payload: Partial<DocumentSequenceMutationPayload>
): Promise<DocumentSequenceRecord> {
const current = await assertSequence(id, organizationId);
const organization = await resolveOrganizationRecord(organizationId);
const current = await assertSequence(id, organization.id);
const documentType = normalizeDocumentType(payload.documentType ?? current.documentType);
const branchId =
payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId);
const productType = await resolveScopedProductType({
organizationId: organization.id,
documentType,
productType: payload.productType ?? current.productType
});
const period = normalizePeriod(payload.period ?? current.period);
await assertUniqueScope({
organizationId: organization.id,
branchId,
productType,
documentType,
period,
currentId: id
});
const [updated] = await db
.update(documentSequences)
.set({
documentType: payload.documentType?.trim() ?? current.documentType,
prefix: payload.prefix?.trim() ?? current.prefix,
period: payload.period?.trim() ?? current.period,
branchId:
payload.branchId === undefined
? current.branchId
: normalizeBranchId(payload.branchId),
branchId,
productType,
documentType,
prefix: await resolvePrefix({
organization,
documentType,
productType,
prefix: payload.prefix ?? current.prefix
}),
period,
currentNumber: payload.currentNumber ?? current.currentNumber,
paddingLength: payload.paddingLength ?? current.paddingLength,
format: normalizeFormat(payload.format ?? current.format),
resetPolicy: normalizeResetPolicy(payload.resetPolicy ?? current.resetPolicy),
isActive: payload.isActive ?? current.isActive,
updatedAt: new Date()
})
.where(eq(documentSequences.id, id))
.returning();
const [record] = await mapSequenceRecords(organizationId, [updated]);
const [record] = await mapSequenceRecords(organization, [updated]);
return record;
}
@@ -275,7 +495,9 @@ export async function resetDocumentSequence(
organizationId: string,
payload: DocumentSequenceResetPayload
): Promise<DocumentSequenceRecord> {
await assertSequence(id, organizationId);
const organization = await resolveOrganizationRecord(organizationId);
await assertSequence(id, organization.id);
const [updated] = await db
.update(documentSequences)
.set({
@@ -285,15 +507,16 @@ export async function resetDocumentSequence(
.where(eq(documentSequences.id, id))
.returning();
const [record] = await mapSequenceRecords(organizationId, [updated]);
const [record] = await mapSequenceRecords(organization, [updated]);
return record;
}
export async function deleteDocumentSequence(id: string, organizationId: string) {
const sequence = await assertSequence(id, organizationId);
const organization = await resolveOrganizationRecord(organizationId);
const sequence = await assertSequence(id, organization.id);
await db.delete(documentSequences).where(eq(documentSequences.id, id));
const [record] = await mapSequenceRecords(organization, [sequence]);
const [record] = await mapSequenceRecords(organizationId, [sequence]);
return record;
}
@@ -303,40 +526,129 @@ export async function previewDocumentSequenceById(id: string, organizationId: st
}
export async function generateNextDocumentCode(input: DocumentSequenceInput) {
const organizationId = await resolveOrganizationId(input.organizationId);
const period = input.period ?? getCurrentPeriod();
const organization = await resolveOrganizationRecord(input.organizationId);
const documentType = normalizeDocumentType(input.documentType);
const branchId = normalizeBranchId(input.branchId);
return db.transaction(async (tx) => {
const sequence = await ensureSequenceRow(
organizationId,
input.documentType.trim(),
period,
branchId
);
const [updated] = await tx
.update(documentSequences)
.set({
currentNumber: sql`${documentSequences.currentNumber} + 1`,
updatedAt: new Date()
})
.where(eq(documentSequences.id, sequence.id))
.returning();
return {
code: buildDocumentCode(
updated.prefix,
updated.period,
updated.currentNumber,
updated.paddingLength
),
documentType: updated.documentType,
branchId: updated.branchId || null,
currentNumber: updated.currentNumber,
nextNumber: updated.currentNumber + 1,
period: updated.period,
prefix: updated.prefix
} satisfies DocumentSequenceResult;
const productType = await resolveScopedProductType({
organizationId: organization.id,
documentType,
productType: input.productType
});
const period = normalizePeriod(input.period ?? getCurrentPeriod());
try {
const result = await db.transaction(async (tx) => {
let sequence = await tx.query.documentSequences.findFirst({
where: and(
eq(documentSequences.organizationId, organization.id),
eq(documentSequences.branchId, branchId),
eq(documentSequences.productType, productType),
eq(documentSequences.documentType, documentType),
eq(documentSequences.period, period)
)
});
if (!sequence) {
await tx
.insert(documentSequences)
.values({
id: crypto.randomUUID(),
organizationId: organization.id,
branchId,
productType,
documentType,
prefix: await resolvePrefix({
organization,
documentType,
productType
}),
period,
currentNumber: 0,
paddingLength: 3,
format: DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
resetPolicy: DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
isActive: true
})
.onConflictDoNothing();
sequence = await tx.query.documentSequences.findFirst({
where: and(
eq(documentSequences.organizationId, organization.id),
eq(documentSequences.branchId, branchId),
eq(documentSequences.productType, productType),
eq(documentSequences.documentType, documentType),
eq(documentSequences.period, period)
)
});
}
if (!sequence) {
throw new AuthError('Unable to initialize document sequence.', 500);
}
if (!sequence.isActive) {
throw new AuthError('Document sequence is inactive.', 400);
}
const [updated] = await tx
.update(documentSequences)
.set({
currentNumber: sql`${documentSequences.currentNumber} + 1`,
updatedAt: new Date()
})
.where(eq(documentSequences.id, sequence.id))
.returning();
return {
sequenceId: updated.id,
result: {
code: buildDocumentCode(
updated.format,
updated.prefix,
updated.period,
updated.currentNumber,
updated.paddingLength
),
documentType: updated.documentType,
productType: updated.productType,
branchId: updated.branchId || null,
currentNumber: updated.currentNumber,
nextNumber: updated.currentNumber + 1,
period: updated.period,
prefix: updated.prefix
} satisfies DocumentSequenceResult
};
});
await auditAction({
organizationId: organization.id,
entityType: 'crm_document_sequence',
entityId: result.sequenceId,
action: 'generate',
branchId: branchId || null,
afterData: result.result
});
return result.result;
} catch (error) {
try {
await auditAction({
organizationId: organization.id,
entityType: 'crm_document_sequence',
entityId: `${documentType}:${branchId}:${productType}:${period}`,
action: 'generate_failed',
branchId: branchId || null,
afterData: {
message: error instanceof Error ? error.message : 'Unknown error',
documentType,
productType,
period
}
});
} catch {
// Keep original error as the primary failure signal.
}
throw error;
}
}

View File

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

View File

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