uat-doc-perfix
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/features/foundation/document-sequence/config.test.ts
Normal file
33
src/features/foundation/document-sequence/config.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
formatDocumentSequencePeriod,
|
||||
normalizeDocumentSequenceProductType,
|
||||
resolveDocumentSequenceOrganizationCode,
|
||||
resolveDocumentSequenceProductTypeCode
|
||||
} from './config';
|
||||
|
||||
test('resolveDocumentSequenceProductTypeCode supports current CRM product type aliases', () => {
|
||||
assert.equal(resolveDocumentSequenceProductTypeCode('crane'), 'CR');
|
||||
assert.equal(resolveDocumentSequenceProductTypeCode('dockdoor'), 'DK');
|
||||
assert.equal(resolveDocumentSequenceProductTypeCode('dock_door'), 'DK');
|
||||
assert.equal(resolveDocumentSequenceProductTypeCode('solarcell'), 'SC');
|
||||
assert.equal(resolveDocumentSequenceProductTypeCode('service'), 'SV');
|
||||
assert.equal(resolveDocumentSequenceProductTypeCode('sparepart'), 'SP');
|
||||
});
|
||||
|
||||
test('resolveDocumentSequenceOrganizationCode uses centralized slug mapping', () => {
|
||||
assert.equal(resolveDocumentSequenceOrganizationCode('alla'), 'A');
|
||||
assert.equal(resolveDocumentSequenceOrganizationCode('alla-demo'), 'A');
|
||||
assert.equal(resolveDocumentSequenceOrganizationCode('onvalla'), 'O');
|
||||
assert.equal(resolveDocumentSequenceOrganizationCode('unknown-org'), null);
|
||||
});
|
||||
|
||||
test('formatDocumentSequencePeriod returns YYMM format', () => {
|
||||
assert.equal(formatDocumentSequencePeriod(new Date('2026-06-15T00:00:00.000Z')), '2606');
|
||||
});
|
||||
|
||||
test('normalizeDocumentSequenceProductType keeps explicit generic fallback', () => {
|
||||
assert.equal(normalizeDocumentSequenceProductType('Dock Door'), 'dock_door');
|
||||
assert.equal(normalizeDocumentSequenceProductType(''), 'all');
|
||||
});
|
||||
40
src/features/foundation/document-sequence/config.ts
Normal file
40
src/features/foundation/document-sequence/config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export const DOCUMENT_SEQUENCE_DEFAULT_FORMAT = '{prefix}{period}-{running}';
|
||||
export const DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY = 'period';
|
||||
export const DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE = 'all';
|
||||
export const DOCUMENT_SEQUENCE_LEGACY_PRODUCT_TYPE = 'legacy';
|
||||
|
||||
export const DOCUMENT_SEQUENCE_PRODUCT_TYPE_CODES: Record<string, string> = {
|
||||
crane: 'CR',
|
||||
dockdoor: 'DK',
|
||||
dock_door: 'DK',
|
||||
solarcell: 'SC',
|
||||
solar_cell: 'SC',
|
||||
solar: 'SC',
|
||||
service: 'SV',
|
||||
sparepart: 'SP',
|
||||
spare_part: 'SP'
|
||||
};
|
||||
|
||||
export const DOCUMENT_SEQUENCE_ORGANIZATION_CODES: Record<string, string> = {
|
||||
alla: 'A',
|
||||
'alla-demo': 'A',
|
||||
onvalla: 'O'
|
||||
};
|
||||
|
||||
export function normalizeDocumentSequenceProductType(value?: string | null) {
|
||||
return value?.trim().toLowerCase().replace(/[\s-]+/g, '_') || DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE;
|
||||
}
|
||||
|
||||
export function formatDocumentSequencePeriod(date: Date) {
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
return `${year}${month}`;
|
||||
}
|
||||
|
||||
export function resolveDocumentSequenceProductTypeCode(productType: string) {
|
||||
return DOCUMENT_SEQUENCE_PRODUCT_TYPE_CODES[normalizeDocumentSequenceProductType(productType)] ?? null;
|
||||
}
|
||||
|
||||
export function resolveDocumentSequenceOrganizationCode(slug?: string | null) {
|
||||
return slug ? DOCUMENT_SEQUENCE_ORGANIZATION_CODES[slug.trim().toLowerCase()] ?? null : null;
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user