task-p.7.1.1
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
createQuotation,
|
||||
createQuotationAttachment,
|
||||
createQuotationCustomer,
|
||||
generateQuotationCustomerPackage,
|
||||
createQuotationFollowup,
|
||||
createQuotationItem,
|
||||
createQuotationRevision,
|
||||
@@ -374,3 +375,13 @@ export const createQuotationRevisionMutation = mutationOptions({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const generateQuotationCustomerPackageMutation = mutationOptions({
|
||||
mutationFn: ({ quotationId }: { quotationId: string }) =>
|
||||
generateQuotationCustomerPackage(quotationId),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateQuotationHeaderQueries(variables.quotationId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MutationSuccessResponse,
|
||||
QuotationAttachmentMutationPayload,
|
||||
QuotationAttachmentsResponse,
|
||||
QuotationCustomerPackageGenerateResponse,
|
||||
QuotationCustomerMutationPayload,
|
||||
QuotationCustomersResponse,
|
||||
QuotationDetailResponse,
|
||||
@@ -41,6 +42,37 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
|
||||
return apiClient<QuotationDetailResponse>(`/crm/quotations/${id}`);
|
||||
}
|
||||
|
||||
export async function generateQuotationCustomerPackage(
|
||||
quotationId: string
|
||||
): Promise<QuotationCustomerPackageGenerateResponse> {
|
||||
return apiClient<QuotationCustomerPackageGenerateResponse>(
|
||||
`/crm/quotations/${quotationId}/assembled-pdf`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadQuotationCustomerPackage(quotationId: string): Promise<{
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
}> {
|
||||
const response = await fetch(`/api/crm/quotations/${quotationId}/assembled-pdf/download`);
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(payload?.message ?? 'Failed to download customer package');
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get('content-disposition') ?? '';
|
||||
const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/i);
|
||||
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
fileName: fileNameMatch?.[1] ?? `${quotationId}-customer-package.pdf`
|
||||
};
|
||||
}
|
||||
|
||||
export async function createQuotation(data: QuotationMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>('/crm/quotations', {
|
||||
method: 'POST',
|
||||
@@ -212,4 +244,3 @@ export async function createQuotationRevision(id: string, revisionRemark?: strin
|
||||
body: JSON.stringify({ revisionRemark })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,37 @@ export interface QuotationApprovedArtifactSummary {
|
||||
lockedByName: string | null;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageSource {
|
||||
role: string;
|
||||
sourceType: 'generated' | 'document_library';
|
||||
fileName: string;
|
||||
checksum: string | null;
|
||||
fileSize: number | null;
|
||||
pageCount: number | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageWarning {
|
||||
code: string;
|
||||
role: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageSummary {
|
||||
artifactId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
fileSize: number | null;
|
||||
checksum: string | null;
|
||||
status: string;
|
||||
pageCount: number | null;
|
||||
generatedAt: string;
|
||||
generatedBy: string;
|
||||
generatedByName: string | null;
|
||||
includedDocuments: QuotationCustomerPackageSource[];
|
||||
warnings: QuotationCustomerPackageWarning[];
|
||||
}
|
||||
|
||||
export interface QuotationRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -103,6 +134,7 @@ export interface QuotationRecord {
|
||||
approvedSnapshot: unknown | null;
|
||||
approvedTemplateVersionId: string | null;
|
||||
approvedArtifact: QuotationApprovedArtifactSummary | null;
|
||||
customerPackage: QuotationCustomerPackageSummary | null;
|
||||
hasLegacyApprovedPdf: boolean;
|
||||
acceptedAt: string | null;
|
||||
rejectedAt: string | null;
|
||||
@@ -432,3 +464,20 @@ export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface QuotationCustomerPackageGenerateResponse {
|
||||
success: boolean;
|
||||
fileName: string;
|
||||
checksum: string;
|
||||
fileSize: number;
|
||||
pageCount: number;
|
||||
warnings: QuotationCustomerPackageWarning[];
|
||||
artifact: {
|
||||
artifactId: string;
|
||||
storageKey: string;
|
||||
fileName: string;
|
||||
checksum: string;
|
||||
fileSize: number;
|
||||
pageCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
createQuotationAttachmentMutation,
|
||||
createQuotationCustomerMutation,
|
||||
createQuotationFollowupMutation,
|
||||
generateQuotationCustomerPackageMutation,
|
||||
createQuotationItemMutation,
|
||||
createQuotationRevisionMutation,
|
||||
createQuotationTopicMutation,
|
||||
@@ -64,10 +65,12 @@ import {
|
||||
quotationRevisionsOptions,
|
||||
quotationTopicsOptions
|
||||
} from '../api/queries';
|
||||
import { downloadQuotationCustomerPackage } from '../api/service';
|
||||
import type {
|
||||
QuotationAttachmentMutationPayload,
|
||||
QuotationAttachmentRecord,
|
||||
QuotationCustomerListItem,
|
||||
QuotationCustomerPackageSource,
|
||||
QuotationCustomerMutationPayload,
|
||||
QuotationFollowupMutationPayload,
|
||||
QuotationFollowupRecord,
|
||||
@@ -79,6 +82,11 @@ import type {
|
||||
} from '../api/types';
|
||||
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
||||
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
||||
import {
|
||||
getCustomerPackageRoleLabel,
|
||||
getCustomerPackageUiStatus,
|
||||
mapCustomerPackageErrorMessage
|
||||
} from '../customer-package';
|
||||
import { QuotationDocumentPreview } from './quotation-document-preview';
|
||||
import { QuotationFormSheet } from './quotation-form-sheet';
|
||||
import { QuotationApprovalTab } from './quotation-approval-tab';
|
||||
@@ -102,6 +110,27 @@ function EmptyState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerPackageSourceItem({
|
||||
item
|
||||
}: {
|
||||
item: QuotationCustomerPackageSource;
|
||||
}) {
|
||||
return (
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>{getCustomerPackageRoleLabel(item.role)}</span>
|
||||
<Badge variant='outline'>{item.sourceType === 'generated' ? 'Generated' : 'Library'}</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 text-sm'>{item.fileName}</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{item.pageCount ? `${formatNumber(item.pageCount)} page(s)` : 'Page count unavailable'}
|
||||
{item.fileSize ? ` • ${formatNumber(item.fileSize)} bytes` : ''}
|
||||
{item.checksum ? ` • ${item.checksum.slice(0, 16)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -673,7 +702,10 @@ export function QuotationDetail({
|
||||
currentUserId,
|
||||
canPreviewDocument,
|
||||
canPreviewPdf,
|
||||
canPreviewCustomerPackage,
|
||||
canDownloadPdf,
|
||||
canGenerateCustomerPackage,
|
||||
canDownloadCustomerPackage,
|
||||
canGenerateApprovedPdf
|
||||
}: {
|
||||
quotationId: string;
|
||||
@@ -694,7 +726,10 @@ export function QuotationDetail({
|
||||
currentUserId: string;
|
||||
canPreviewDocument: boolean;
|
||||
canPreviewPdf: boolean;
|
||||
canPreviewCustomerPackage: boolean;
|
||||
canDownloadPdf: boolean;
|
||||
canGenerateCustomerPackage: boolean;
|
||||
canDownloadCustomerPackage: boolean;
|
||||
canGenerateApprovedPdf: boolean;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||
@@ -705,6 +740,12 @@ export function QuotationDetail({
|
||||
const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId));
|
||||
const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId));
|
||||
const quotation = data.quotation;
|
||||
const [customerPackageError, setCustomerPackageError] = useState<string | null>(null);
|
||||
const [confirmRegenerateOpen, setConfirmRegenerateOpen] = useState(false);
|
||||
const customerPackageStatus = getCustomerPackageUiStatus({
|
||||
customerPackage: quotation.customerPackage,
|
||||
lastErrorMessage: customerPackageError
|
||||
});
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const [itemOpen, setItemOpen] = useState(false);
|
||||
@@ -844,6 +885,44 @@ export function QuotationDetail({
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
|
||||
});
|
||||
const generateCustomerPackage = useMutation({
|
||||
...generateQuotationCustomerPackageMutation,
|
||||
onSuccess: () => {
|
||||
setCustomerPackageError(null);
|
||||
setConfirmRegenerateOpen(false);
|
||||
toast.success('Customer package generated successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = mapCustomerPackageErrorMessage(
|
||||
error instanceof Error ? error.message : null
|
||||
);
|
||||
setCustomerPackageError(message);
|
||||
setConfirmRegenerateOpen(false);
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
const downloadCustomerPackage = useMutation({
|
||||
mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
|
||||
onSuccess: async ({ blob, fileName }) => {
|
||||
setCustomerPackageError(null);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Customer package downloaded successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = mapCustomerPackageErrorMessage(
|
||||
error instanceof Error ? error.message : null
|
||||
);
|
||||
setCustomerPackageError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
const generateApprovedPdf = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
|
||||
@@ -918,16 +997,30 @@ export function QuotationDetail({
|
||||
() => new Map(referenceData.topicTypes.map((item) => [item.id, item])),
|
||||
[referenceData.topicTypes]
|
||||
);
|
||||
const followupTypeMap = useMemo(
|
||||
() => new Map(referenceData.followupTypes.map((item) => [item.id, item])),
|
||||
[referenceData.followupTypes]
|
||||
);
|
||||
const customer = customerMap.get(quotation.customerId);
|
||||
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
||||
const status = statusMap.get(quotation.status);
|
||||
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||
status?.code ?? ''
|
||||
);
|
||||
const followupTypeMap = useMemo(
|
||||
() => new Map(referenceData.followupTypes.map((item) => [item.id, item])),
|
||||
[referenceData.followupTypes]
|
||||
);
|
||||
const customer = customerMap.get(quotation.customerId);
|
||||
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
||||
const status = statusMap.get(quotation.status);
|
||||
const customerPackage = quotation.customerPackage;
|
||||
const customerPackageActionLabel = customerPackage ? 'Regenerate Package' : 'Generate Customer Package';
|
||||
const customerPackageStatusLabel =
|
||||
customerPackageStatus === 'generated'
|
||||
? 'Generated'
|
||||
: customerPackageStatus === 'failed'
|
||||
? 'Failed'
|
||||
: 'Not Generated';
|
||||
const customerPackageStatusDescription =
|
||||
customerPackageStatus === 'generated'
|
||||
? 'Customer-facing assembled PDF is ready.'
|
||||
: customerPackageStatus === 'failed'
|
||||
? customerPackageError
|
||||
: 'Generate the package before downloading or previewing it.';
|
||||
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||
status?.code ?? ''
|
||||
);
|
||||
const canSubmitCurrentQuotation =
|
||||
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
||||
const canGenerateApprovedNow = canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
|
||||
@@ -1075,6 +1168,31 @@ export function QuotationDetail({
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
<Dialog open={confirmRegenerateOpen} onOpenChange={setConfirmRegenerateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regenerate Customer Package?</DialogTitle>
|
||||
<DialogDescription>
|
||||
A new assembled package artifact will replace the current active package for this quotation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setConfirmRegenerateOpen(false)}
|
||||
disabled={generateCustomerPackage.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={generateCustomerPackage.isPending}
|
||||
onClick={() => generateCustomerPackage.mutate({ quotationId })}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-3'>
|
||||
@@ -1162,6 +1280,131 @@ export function QuotationDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<CardTitle>Customer Package</CardTitle>
|
||||
<CardDescription>
|
||||
Generate and download the assembled customer-facing quotation package.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{canGenerateCustomerPackage ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={generateCustomerPackage.isPending}
|
||||
onClick={() =>
|
||||
customerPackage
|
||||
? setConfirmRegenerateOpen(true)
|
||||
: generateCustomerPackage.mutate({ quotationId })
|
||||
}
|
||||
>
|
||||
<Icons.badgeCheck className='mr-2 h-4 w-4' />
|
||||
{customerPackageActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{canPreviewCustomerPackage ? (
|
||||
<Button asChild variant='outline' disabled={!customerPackage}>
|
||||
<Link
|
||||
href={`/api/crm/quotations/${quotationId}/assembled-pdf`}
|
||||
target='_blank'
|
||||
aria-disabled={!customerPackage}
|
||||
onClick={(event) => {
|
||||
if (!customerPackage) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icons.eyeOff className='mr-2 h-4 w-4' /> View Customer Package
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canDownloadCustomerPackage ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={!customerPackage}
|
||||
isLoading={downloadCustomerPackage.isPending}
|
||||
onClick={() => downloadCustomerPackage.mutate()}
|
||||
>
|
||||
<Icons.download className='mr-2 h-4 w-4' /> Download Customer Package
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{!canGenerateCustomerPackage && !canDownloadCustomerPackage && !canPreviewCustomerPackage ? (
|
||||
<EmptyState message='You do not have permission to generate or download customer package.' />
|
||||
) : (
|
||||
<>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem label='Status' value={customerPackageStatusLabel} />
|
||||
<FieldItem
|
||||
label='Generated At'
|
||||
value={customerPackage?.generatedAt ? formatDateTime(customerPackage.generatedAt) : null}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Generated By'
|
||||
value={customerPackage?.generatedByName ?? customerPackage?.generatedBy ?? null}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Page Count'
|
||||
value={
|
||||
customerPackage?.pageCount !== null && customerPackage?.pageCount !== undefined
|
||||
? String(customerPackage.pageCount)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='File Size'
|
||||
value={customerPackage?.fileSize ? `${formatNumber(customerPackage.fileSize)} bytes` : null}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Checksum'
|
||||
value={customerPackage?.checksum ? customerPackage.checksum.slice(0, 16) : null}
|
||||
/>
|
||||
<FieldItem label='File Name' value={customerPackage?.fileName ?? null} />
|
||||
<FieldItem
|
||||
label='Included Documents'
|
||||
value={
|
||||
customerPackage?.includedDocuments.length
|
||||
? String(customerPackage.includedDocuments.length)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='rounded-lg border border-dashed p-4 text-sm'>
|
||||
{customerPackageStatusDescription}
|
||||
</div>
|
||||
{customerPackage?.warnings.length ? (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>Warnings</div>
|
||||
{customerPackage.warnings.map((warning) => (
|
||||
<div key={`${warning.code}-${warning.role}`} className='rounded-lg border p-3 text-sm'>
|
||||
{warning.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className='space-y-3'>
|
||||
<div className='text-sm font-medium'>Included Documents</div>
|
||||
{!customerPackage?.includedDocuments.length ? (
|
||||
<EmptyState message='No assembled package has been generated yet.' />
|
||||
) : (
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
{customerPackage.includedDocuments.map((item) => (
|
||||
<CustomerPackageSourceItem
|
||||
key={`${item.role}-${item.fileName}-${item.checksum ?? 'no-checksum'}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
<div className='space-y-6 lg:col-span-2'>
|
||||
<Card>
|
||||
|
||||
72
src/features/crm/quotations/customer-package.test.ts
Normal file
72
src/features/crm/quotations/customer-package.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getCustomerPackageRoleLabel,
|
||||
getCustomerPackageUiStatus,
|
||||
mapCustomerPackageErrorMessage
|
||||
} from './customer-package';
|
||||
|
||||
test('mapCustomerPackageErrorMessage rewrites not-generated error', () => {
|
||||
assert.equal(
|
||||
mapCustomerPackageErrorMessage('Assembled quotation PDF has not been generated yet.'),
|
||||
'Customer package has not been generated yet. Please generate it first.'
|
||||
);
|
||||
});
|
||||
|
||||
test('mapCustomerPackageErrorMessage rewrites missing document error', () => {
|
||||
assert.equal(
|
||||
mapCustomerPackageErrorMessage(
|
||||
'Missing SLA document (SLA, brand generic, language th, product type crane).'
|
||||
),
|
||||
'Cannot generate package because required SLA document is missing.'
|
||||
);
|
||||
});
|
||||
|
||||
test('mapCustomerPackageErrorMessage rewrites forbidden error', () => {
|
||||
assert.equal(
|
||||
mapCustomerPackageErrorMessage('Forbidden'),
|
||||
'You do not have permission to generate or download customer package.'
|
||||
);
|
||||
});
|
||||
|
||||
test('getCustomerPackageRoleLabel falls back to humanized labels', () => {
|
||||
assert.equal(getCustomerPackageRoleLabel('main'), 'Quotation');
|
||||
assert.equal(getCustomerPackageRoleLabel('custom_appendix'), 'Custom Appendix');
|
||||
});
|
||||
|
||||
test('getCustomerPackageUiStatus reflects generated, failed, and not-generated states', () => {
|
||||
assert.equal(
|
||||
getCustomerPackageUiStatus({
|
||||
customerPackage: {
|
||||
artifactId: 'artifact-1',
|
||||
fileName: 'pkg.pdf',
|
||||
contentType: 'application/pdf',
|
||||
fileSize: 100,
|
||||
checksum: 'abc',
|
||||
status: 'active',
|
||||
pageCount: 2,
|
||||
generatedAt: '2026-06-30T00:00:00.000Z',
|
||||
generatedBy: 'user-1',
|
||||
generatedByName: 'User',
|
||||
includedDocuments: [],
|
||||
warnings: []
|
||||
},
|
||||
lastErrorMessage: 'Something failed before'
|
||||
}),
|
||||
'generated'
|
||||
);
|
||||
assert.equal(
|
||||
getCustomerPackageUiStatus({
|
||||
customerPackage: null,
|
||||
lastErrorMessage: 'Customer package generation failed.'
|
||||
}),
|
||||
'failed'
|
||||
);
|
||||
assert.equal(
|
||||
getCustomerPackageUiStatus({
|
||||
customerPackage: null,
|
||||
lastErrorMessage: null
|
||||
}),
|
||||
'not_generated'
|
||||
);
|
||||
});
|
||||
65
src/features/crm/quotations/customer-package.ts
Normal file
65
src/features/crm/quotations/customer-package.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { QuotationCustomerPackageSummary } from './api/types';
|
||||
|
||||
const CUSTOMER_PACKAGE_ROLE_LABELS: Record<string, string> = {
|
||||
main: 'Quotation',
|
||||
sla: 'SLA',
|
||||
warranty: 'Warranty',
|
||||
datasheet: 'Datasheet',
|
||||
drawing: 'Drawing'
|
||||
};
|
||||
|
||||
export type CustomerPackageUiStatus = 'not_generated' | 'generated' | 'failed';
|
||||
|
||||
export function getCustomerPackageRoleLabel(role: string): string {
|
||||
return (
|
||||
CUSTOMER_PACKAGE_ROLE_LABELS[role] ??
|
||||
role
|
||||
.split(/[_\s-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
export function mapCustomerPackageErrorMessage(message?: string | null): string {
|
||||
const normalized = message?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return 'Customer package generation failed. Please check document library configuration.';
|
||||
}
|
||||
|
||||
if (
|
||||
normalized === 'Forbidden' ||
|
||||
normalized.toLowerCase().includes('permission') ||
|
||||
normalized.toLowerCase().includes('forbidden')
|
||||
) {
|
||||
return 'You do not have permission to generate or download customer package.';
|
||||
}
|
||||
|
||||
if (normalized === 'Assembled quotation PDF has not been generated yet.') {
|
||||
return 'Customer package has not been generated yet. Please generate it first.';
|
||||
}
|
||||
|
||||
const missingRequiredDocument = normalized.match(/^Missing ([A-Z_ ]+) document/i);
|
||||
if (missingRequiredDocument) {
|
||||
const role = getCustomerPackageRoleLabel(missingRequiredDocument[1].toLowerCase());
|
||||
return `Cannot generate package because required ${role} document is missing.`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getCustomerPackageUiStatus(input: {
|
||||
customerPackage: QuotationCustomerPackageSummary | null;
|
||||
lastErrorMessage?: string | null;
|
||||
}): CustomerPackageUiStatus {
|
||||
if (input.customerPackage) {
|
||||
return 'generated';
|
||||
}
|
||||
|
||||
if (input.lastErrorMessage) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
return 'not_generated';
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import { AuthError } from '@/lib/auth/session';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import {
|
||||
getActiveArtifactForEntity,
|
||||
type DocumentArtifactRecord
|
||||
} from '@/features/foundation/document-artifact/server/service';
|
||||
import {
|
||||
type CrmSecurityContext,
|
||||
canAccessScopedCrmRecord
|
||||
@@ -47,6 +51,9 @@ import type {
|
||||
QuotationApprovedArtifactSummary,
|
||||
QuotationBranchOption,
|
||||
QuotationContactLookup,
|
||||
QuotationCustomerPackageSource,
|
||||
QuotationCustomerPackageSummary,
|
||||
QuotationCustomerPackageWarning,
|
||||
QuotationCustomerListItem,
|
||||
QuotationCustomerLookup,
|
||||
QuotationCustomerMutationPayload,
|
||||
@@ -133,10 +140,99 @@ function mapApprovedArtifactSummary(
|
||||
};
|
||||
}
|
||||
|
||||
function toCustomerPackageSource(
|
||||
value: unknown
|
||||
): QuotationCustomerPackageSource | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>;
|
||||
if (typeof source.role !== 'string' || typeof source.fileName !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (source.sourceType !== 'generated' && source.sourceType !== 'document_library') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
role: source.role,
|
||||
sourceType: source.sourceType,
|
||||
fileName: source.fileName,
|
||||
checksum: typeof source.checksum === 'string' ? source.checksum : null,
|
||||
fileSize: typeof source.fileSize === 'number' ? source.fileSize : null,
|
||||
pageCount: typeof source.pageCount === 'number' ? source.pageCount : null,
|
||||
metadata:
|
||||
source.metadata && typeof source.metadata === 'object' && !Array.isArray(source.metadata)
|
||||
? (source.metadata as Record<string, unknown>)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function toCustomerPackageWarning(
|
||||
value: unknown
|
||||
): QuotationCustomerPackageWarning | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const warning = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof warning.code !== 'string' ||
|
||||
typeof warning.role !== 'string' ||
|
||||
typeof warning.message !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: warning.code,
|
||||
role: warning.role,
|
||||
message: warning.message
|
||||
};
|
||||
}
|
||||
|
||||
function mapCustomerPackageSummary(
|
||||
artifact: DocumentArtifactRecord
|
||||
): QuotationCustomerPackageSummary {
|
||||
const metadata =
|
||||
artifact.metadata && typeof artifact.metadata === 'object' && !Array.isArray(artifact.metadata)
|
||||
? (artifact.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const includedDocuments = Array.isArray(metadata?.sourceArtifacts)
|
||||
? metadata.sourceArtifacts
|
||||
.map((item) => toCustomerPackageSource(item))
|
||||
.filter((item): item is QuotationCustomerPackageSource => item !== null)
|
||||
: [];
|
||||
const warnings = Array.isArray(metadata?.warnings)
|
||||
? metadata.warnings
|
||||
.map((item) => toCustomerPackageWarning(item))
|
||||
.filter((item): item is QuotationCustomerPackageWarning => item !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
artifactId: artifact.id,
|
||||
fileName: artifact.fileName,
|
||||
contentType: artifact.contentType,
|
||||
fileSize: artifact.fileSize,
|
||||
checksum: artifact.checksum,
|
||||
status: artifact.status,
|
||||
pageCount: typeof metadata?.pageCount === 'number' ? metadata.pageCount : null,
|
||||
generatedAt: artifact.generatedAt,
|
||||
generatedBy: artifact.generatedBy,
|
||||
generatedByName: artifact.generatedByName,
|
||||
includedDocuments,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
function mapQuotationRecord(
|
||||
row: typeof crmQuotations.$inferSelect,
|
||||
options?: {
|
||||
approvedArtifact?: QuotationApprovedArtifactSummary | null;
|
||||
customerPackage?: QuotationCustomerPackageSummary | null;
|
||||
branchName?: string | null;
|
||||
branchCode?: string | null;
|
||||
salesmanName?: string | null;
|
||||
@@ -188,6 +284,7 @@ function mapQuotationRecord(
|
||||
approvedSnapshot: row.approvedSnapshot ?? null,
|
||||
approvedTemplateVersionId: row.approvedTemplateVersionId,
|
||||
approvedArtifact: options?.approvedArtifact ?? null,
|
||||
customerPackage: options?.customerPackage ?? null,
|
||||
hasLegacyApprovedPdf:
|
||||
!row.approvedArtifactId &&
|
||||
typeof row.approvedPdfUrl === 'string' &&
|
||||
@@ -1240,6 +1337,12 @@ export async function getQuotationDetail(
|
||||
})
|
||||
]);
|
||||
const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null;
|
||||
const customerPackageArtifactPromise = getActiveArtifactForEntity({
|
||||
organizationId,
|
||||
entityType: 'crm_quotation',
|
||||
entityId: id,
|
||||
artifactType: 'assembled_pdf'
|
||||
});
|
||||
const baseDisplayOptions = {
|
||||
branchName: branchDisplay?.name ?? null,
|
||||
branchCode: branchDisplay?.code ?? null,
|
||||
@@ -1256,7 +1359,13 @@ export async function getQuotationDetail(
|
||||
: null);
|
||||
|
||||
if (!approvedArtifactId) {
|
||||
return mapQuotationRecord(quotation, baseDisplayOptions);
|
||||
const customerPackageArtifact = await customerPackageArtifactPromise;
|
||||
return mapQuotationRecord(quotation, {
|
||||
...baseDisplayOptions,
|
||||
customerPackage: customerPackageArtifact
|
||||
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
const [artifact] = await db
|
||||
@@ -1272,10 +1381,19 @@ export async function getQuotationDetail(
|
||||
.limit(1);
|
||||
|
||||
if (!artifact) {
|
||||
return mapQuotationRecord(quotation);
|
||||
const customerPackageArtifact = await customerPackageArtifactPromise;
|
||||
return mapQuotationRecord(quotation, {
|
||||
...baseDisplayOptions,
|
||||
customerPackage: customerPackageArtifact
|
||||
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
const artifactActorMap = await resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]);
|
||||
const [artifactActorMap, customerPackageArtifact] = await Promise.all([
|
||||
resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]),
|
||||
customerPackageArtifactPromise
|
||||
]);
|
||||
|
||||
return mapQuotationRecord(quotation, {
|
||||
...baseDisplayOptions,
|
||||
@@ -1284,7 +1402,10 @@ export async function getQuotationDetail(
|
||||
lockedByName: artifact.lockedBy
|
||||
? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null)
|
||||
: null
|
||||
})
|
||||
}),
|
||||
customerPackage: customerPackageArtifact
|
||||
? mapCustomerPackageSummary(customerPackageArtifact)
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveDocumentSequenceProductTypeInput,
|
||||
type DocumentSequenceProductTypeLookupOption
|
||||
} from './product-type-resolver';
|
||||
|
||||
const productTypeOptions: DocumentSequenceProductTypeLookupOption[] = [
|
||||
{ id: 'product-crane', code: 'crane', label: 'Crane' },
|
||||
{ id: 'product-dockdoor', code: 'dockdoor', label: 'Dock Door' },
|
||||
{ id: 'product-solarcell', code: 'solarcell', label: 'Solar Cell' },
|
||||
{ id: 'product-service', code: 'service', label: 'Service' },
|
||||
{ id: 'product-sparepart', code: 'sparepart', label: 'Spare Part' }
|
||||
];
|
||||
|
||||
const quotationTypeOptions: DocumentSequenceProductTypeLookupOption[] = [
|
||||
{ id: 'quotation-crane', code: 'crane', label: 'Crane' },
|
||||
{ id: 'quotation-dockdoor', code: 'dockdoor', label: 'Dock Door' },
|
||||
{ id: 'quotation-solarcell', code: 'solarcell', label: 'Solar Cell' },
|
||||
{ id: 'quotation-service', code: 'service', label: 'Service' }
|
||||
];
|
||||
|
||||
test('resolveDocumentSequenceProductTypeInput resolves quotation type option ids', () => {
|
||||
const resolution = resolveDocumentSequenceProductTypeInput({
|
||||
documentType: 'quotation',
|
||||
productType: 'quotation-crane',
|
||||
options: productTypeOptions,
|
||||
referenceOptions: quotationTypeOptions
|
||||
});
|
||||
|
||||
assert.deepEqual(resolution, {
|
||||
productType: 'crane',
|
||||
matchedBy: 'option-id',
|
||||
normalizedRequestedProductType: 'quotation_crane'
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDocumentSequenceProductTypeInput resolves quotation type codes directly', () => {
|
||||
const resolution = resolveDocumentSequenceProductTypeInput({
|
||||
documentType: 'quotation',
|
||||
productType: 'service',
|
||||
options: productTypeOptions,
|
||||
referenceOptions: quotationTypeOptions
|
||||
});
|
||||
|
||||
assert.deepEqual(resolution, {
|
||||
productType: 'service',
|
||||
matchedBy: 'option-code',
|
||||
normalizedRequestedProductType: 'service'
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDocumentSequenceProductTypeInput resolves document-sequence aliases', () => {
|
||||
const dockDoorResolution = resolveDocumentSequenceProductTypeInput({
|
||||
documentType: 'quotation',
|
||||
productType: 'dock_door',
|
||||
options: productTypeOptions,
|
||||
referenceOptions: quotationTypeOptions
|
||||
});
|
||||
const solarResolution = resolveDocumentSequenceProductTypeInput({
|
||||
documentType: 'quotation',
|
||||
productType: 'solar',
|
||||
options: productTypeOptions,
|
||||
referenceOptions: quotationTypeOptions
|
||||
});
|
||||
|
||||
assert.deepEqual(dockDoorResolution, {
|
||||
productType: 'dockdoor',
|
||||
matchedBy: 'sequence-alias',
|
||||
normalizedRequestedProductType: 'dock_door'
|
||||
});
|
||||
assert.deepEqual(solarResolution, {
|
||||
productType: 'solarcell',
|
||||
matchedBy: 'sequence-alias',
|
||||
normalizedRequestedProductType: 'solar'
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDocumentSequenceProductTypeInput returns generic sequence fallback for non-quotation documents', () => {
|
||||
const resolution = resolveDocumentSequenceProductTypeInput({
|
||||
documentType: 'customer',
|
||||
productType: undefined,
|
||||
options: productTypeOptions
|
||||
});
|
||||
|
||||
assert.deepEqual(resolution, {
|
||||
productType: 'all',
|
||||
matchedBy: 'generic',
|
||||
normalizedRequestedProductType: null
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDocumentSequenceProductTypeInput reports unresolved quotation inputs', () => {
|
||||
const resolution = resolveDocumentSequenceProductTypeInput({
|
||||
documentType: 'quotation',
|
||||
productType: 'other',
|
||||
options: productTypeOptions,
|
||||
referenceOptions: quotationTypeOptions
|
||||
});
|
||||
|
||||
assert.deepEqual(resolution, {
|
||||
productType: null,
|
||||
matchedBy: 'generic',
|
||||
normalizedRequestedProductType: 'other'
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
normalizeDocumentSequenceProductType,
|
||||
resolveDocumentSequenceProductTypeCode
|
||||
} from './config';
|
||||
|
||||
export interface DocumentSequenceProductTypeLookupOption {
|
||||
id: string;
|
||||
code: string;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export type DocumentSequenceProductTypeMatch =
|
||||
| 'generic'
|
||||
| 'option-id'
|
||||
| 'option-code'
|
||||
| 'sequence-alias';
|
||||
|
||||
export interface DocumentSequenceProductTypeResolution {
|
||||
productType: string | null;
|
||||
matchedBy: DocumentSequenceProductTypeMatch;
|
||||
normalizedRequestedProductType: string | null;
|
||||
}
|
||||
|
||||
function resolveProductTypeOptionByCode(
|
||||
options: ReadonlyArray<DocumentSequenceProductTypeLookupOption>,
|
||||
requestedProductType: string
|
||||
):
|
||||
| {
|
||||
productType: string;
|
||||
matchedBy: Exclude<DocumentSequenceProductTypeMatch, 'generic' | 'option-id'>;
|
||||
}
|
||||
| null {
|
||||
const normalizedRequestedProductType =
|
||||
normalizeDocumentSequenceProductType(requestedProductType);
|
||||
|
||||
const byCode = options.find(
|
||||
(option) =>
|
||||
normalizeDocumentSequenceProductType(option.code) === normalizedRequestedProductType
|
||||
);
|
||||
if (byCode) {
|
||||
return {
|
||||
productType: normalizeDocumentSequenceProductType(byCode.code),
|
||||
matchedBy: 'option-code'
|
||||
};
|
||||
}
|
||||
|
||||
const requestedSequenceCode =
|
||||
resolveDocumentSequenceProductTypeCode(normalizedRequestedProductType);
|
||||
if (!requestedSequenceCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bySequenceAlias = options.find(
|
||||
(option) =>
|
||||
resolveDocumentSequenceProductTypeCode(option.code) === requestedSequenceCode
|
||||
);
|
||||
if (!bySequenceAlias) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
productType: normalizeDocumentSequenceProductType(bySequenceAlias.code),
|
||||
matchedBy: 'sequence-alias'
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDocumentSequenceProductTypeInput(input: {
|
||||
documentType: string;
|
||||
productType?: string | null;
|
||||
options: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
|
||||
referenceOptions?: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
|
||||
}): DocumentSequenceProductTypeResolution {
|
||||
const normalizedDocumentType = input.documentType.trim();
|
||||
const requestedProductType = input.productType?.trim() ?? null;
|
||||
|
||||
if (normalizedDocumentType !== 'quotation') {
|
||||
return {
|
||||
productType: requestedProductType
|
||||
? normalizeDocumentSequenceProductType(requestedProductType)
|
||||
: 'all',
|
||||
matchedBy: 'generic',
|
||||
normalizedRequestedProductType: requestedProductType
|
||||
? normalizeDocumentSequenceProductType(requestedProductType)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!requestedProductType) {
|
||||
return {
|
||||
productType: null,
|
||||
matchedBy: 'generic',
|
||||
normalizedRequestedProductType: null
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedRequestedProductType =
|
||||
normalizeDocumentSequenceProductType(requestedProductType);
|
||||
|
||||
const byId = input.options.find((option) => option.id === requestedProductType);
|
||||
if (byId) {
|
||||
return {
|
||||
productType: normalizeDocumentSequenceProductType(byId.code),
|
||||
matchedBy: 'option-id',
|
||||
normalizedRequestedProductType
|
||||
};
|
||||
}
|
||||
|
||||
const directCodeMatch = resolveProductTypeOptionByCode(
|
||||
input.options,
|
||||
requestedProductType
|
||||
);
|
||||
if (directCodeMatch) {
|
||||
return {
|
||||
productType: directCodeMatch.productType,
|
||||
matchedBy: directCodeMatch.matchedBy,
|
||||
normalizedRequestedProductType
|
||||
};
|
||||
}
|
||||
|
||||
const referenceOptionById = input.referenceOptions?.find(
|
||||
(option) => option.id === requestedProductType
|
||||
);
|
||||
if (referenceOptionById) {
|
||||
const translatedMatch = resolveProductTypeOptionByCode(
|
||||
input.options,
|
||||
referenceOptionById.code
|
||||
);
|
||||
|
||||
return {
|
||||
productType: translatedMatch?.productType ?? null,
|
||||
matchedBy: 'option-id',
|
||||
normalizedRequestedProductType
|
||||
};
|
||||
}
|
||||
|
||||
const translatedCode = input.referenceOptions?.find(
|
||||
(option) =>
|
||||
normalizeDocumentSequenceProductType(option.code) === normalizedRequestedProductType
|
||||
)?.code;
|
||||
if (!translatedCode) {
|
||||
return {
|
||||
productType: null,
|
||||
matchedBy: 'generic',
|
||||
normalizedRequestedProductType
|
||||
};
|
||||
}
|
||||
|
||||
const translatedMatch = resolveProductTypeOptionByCode(input.options, translatedCode);
|
||||
return {
|
||||
productType: translatedMatch?.productType ?? null,
|
||||
matchedBy: translatedMatch?.matchedBy ?? 'generic',
|
||||
normalizedRequestedProductType
|
||||
};
|
||||
}
|
||||
@@ -1,38 +1,43 @@
|
||||
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 { 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';
|
||||
resolveDocumentSequenceProductTypeCode,
|
||||
} from "./config";
|
||||
import {
|
||||
resolveDocumentSequenceProductTypeInput,
|
||||
type DocumentSequenceProductTypeLookupOption,
|
||||
} from "./product-type-resolver";
|
||||
import type {
|
||||
DocumentSequenceInput,
|
||||
DocumentSequenceListItem,
|
||||
DocumentSequenceMutationPayload,
|
||||
DocumentSequenceRecord,
|
||||
DocumentSequenceResetPayload,
|
||||
DocumentSequenceResult
|
||||
} from './types';
|
||||
DocumentSequenceResult,
|
||||
} from "./types";
|
||||
|
||||
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
|
||||
const PRODUCT_TYPE_CATEGORY = "crm_product_type";
|
||||
const QUOTATION_TYPE_CATEGORY = "crm_quotation_type";
|
||||
|
||||
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
|
||||
customer: 'CUS',
|
||||
contact: 'CON',
|
||||
opportunity: 'ENQ',
|
||||
crm_lead: 'LD',
|
||||
crm_opportunity: 'EN',
|
||||
quotation: 'QT',
|
||||
approval: 'APV'
|
||||
customer: "CUS",
|
||||
contact: "CON",
|
||||
opportunity: "ENQ",
|
||||
crm_lead: "LD",
|
||||
crm_opportunity: "EN",
|
||||
quotation: "QT",
|
||||
approval: "APV",
|
||||
};
|
||||
|
||||
type OrganizationRecord = {
|
||||
@@ -43,19 +48,19 @@ type OrganizationRecord = {
|
||||
|
||||
function getCurrentPeriod(date = new Date()) {
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
}
|
||||
|
||||
function normalizeBranchId(branchId?: string | null) {
|
||||
return branchId?.trim() || '';
|
||||
return branchId?.trim() || "";
|
||||
}
|
||||
|
||||
function normalizeDocumentType(documentType: string) {
|
||||
const normalized = documentType.trim();
|
||||
|
||||
if (!normalized) {
|
||||
throw new AuthError('Document type is required.', 400);
|
||||
throw new AuthError("Document type is required.", 400);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -65,11 +70,11 @@ function normalizePeriod(period?: string | null) {
|
||||
const normalized = period?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
throw new AuthError('Period is required.', 400);
|
||||
throw new AuthError("Period is required.", 400);
|
||||
}
|
||||
|
||||
if (!/^\d{4}$/.test(normalized)) {
|
||||
throw new AuthError('Period must use YYMM format.', 400);
|
||||
throw new AuthError("Period must use YYMM format.", 400);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -89,7 +94,7 @@ function formatBranchDisplayName(input: {
|
||||
branchName?: string | null;
|
||||
}) {
|
||||
if (!input.branchId) {
|
||||
return 'All Branches';
|
||||
return "All Branches";
|
||||
}
|
||||
|
||||
if (input.branchCode && input.branchName) {
|
||||
@@ -108,28 +113,30 @@ function buildDocumentCode(
|
||||
prefix: string,
|
||||
period: string,
|
||||
nextNumber: number,
|
||||
paddingLength: number
|
||||
paddingLength: number,
|
||||
) {
|
||||
const running = String(nextNumber).padStart(paddingLength, '0');
|
||||
const running = String(nextNumber).padStart(paddingLength, "0");
|
||||
|
||||
return format
|
||||
.replaceAll('{prefix}', prefix)
|
||||
.replaceAll('{period}', period)
|
||||
.replaceAll('{running}', running);
|
||||
.replaceAll("{prefix}", prefix)
|
||||
.replaceAll("{period}", period)
|
||||
.replaceAll("{running}", running);
|
||||
}
|
||||
|
||||
async function resolveOrganizationRecord(organizationId?: string): Promise<OrganizationRecord> {
|
||||
async function resolveOrganizationRecord(
|
||||
organizationId?: string,
|
||||
): Promise<OrganizationRecord> {
|
||||
if (!organizationId) {
|
||||
const organization = await getCurrentOrganization();
|
||||
|
||||
if (!organization) {
|
||||
throw new AuthError('Active organization required.', 400);
|
||||
throw new AuthError("Active organization required.", 400);
|
||||
}
|
||||
|
||||
return {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug
|
||||
slug: organization.slug,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,21 +144,21 @@ async function resolveOrganizationRecord(organizationId?: string): Promise<Organ
|
||||
.select({
|
||||
id: organizations.id,
|
||||
name: organizations.name,
|
||||
slug: organizations.slug
|
||||
slug: organizations.slug,
|
||||
})
|
||||
.from(organizations)
|
||||
.where(eq(organizations.id, organizationId))
|
||||
.limit(1);
|
||||
|
||||
if (!organization) {
|
||||
throw new AuthError('Organization not found.', 404);
|
||||
throw new AuthError("Organization not found.", 404);
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
async function resolveProductTypeMap(organizationId: string) {
|
||||
const options = await getActiveOptionsByCategory(PRODUCT_TYPE_CATEGORY, { organizationId });
|
||||
async function resolveOptionMap(category: string, organizationId: string) {
|
||||
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||
|
||||
return new Map(
|
||||
options.map((option) => [
|
||||
@@ -159,45 +166,105 @@ async function resolveProductTypeMap(organizationId: string) {
|
||||
{
|
||||
id: option.id,
|
||||
code: normalizeDocumentSequenceProductType(option.code),
|
||||
label: option.label
|
||||
}
|
||||
])
|
||||
label: option.label,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveProductTypeMap(organizationId: string) {
|
||||
return resolveOptionMap(PRODUCT_TYPE_CATEGORY, organizationId);
|
||||
}
|
||||
|
||||
async function resolveQuotationTypeMap(organizationId: string) {
|
||||
return resolveOptionMap(QUOTATION_TYPE_CATEGORY, organizationId);
|
||||
}
|
||||
|
||||
function buildProductTypeResolutionDetails(input: {
|
||||
organizationId: string;
|
||||
documentType: string;
|
||||
requestedProductType: string | null;
|
||||
normalizedRequestedProductType: string | null;
|
||||
productTypeOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
|
||||
referenceOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
|
||||
}) {
|
||||
return {
|
||||
organizationId: input.organizationId,
|
||||
documentType: input.documentType,
|
||||
requestedProductType: input.requestedProductType,
|
||||
normalizedRequestedProductType: input.normalizedRequestedProductType,
|
||||
productTypeOptionIds: input.productTypeOptions.map((option) => option.id),
|
||||
productTypeOptionCodes: input.productTypeOptions.map((option) => option.code),
|
||||
referenceOptionIds: input.referenceOptions.map((option) => option.id),
|
||||
referenceOptionCodes: input.referenceOptions.map((option) => option.code),
|
||||
};
|
||||
}
|
||||
|
||||
function buildProductTypeResolutionError(input: {
|
||||
organizationId: string;
|
||||
documentType: string;
|
||||
requestedProductType: string | null;
|
||||
normalizedRequestedProductType: string | null;
|
||||
productTypeOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
|
||||
referenceOptions: ReadonlyArray<DocumentSequenceProductTypeLookupOption>;
|
||||
}) {
|
||||
const error = new AuthError(
|
||||
"Missing product type code configuration for quotation type.",
|
||||
400,
|
||||
) as AuthError & {
|
||||
details: ReturnType<typeof buildProductTypeResolutionDetails>;
|
||||
};
|
||||
|
||||
error.details = buildProductTypeResolutionDetails(input);
|
||||
return error;
|
||||
}
|
||||
|
||||
async function resolveScopedProductType(input: {
|
||||
organizationId: string;
|
||||
documentType: string;
|
||||
productType?: string | null;
|
||||
}) {
|
||||
const normalizedDocumentType = normalizeDocumentType(input.documentType);
|
||||
const requestedProductType = input.productType?.trim();
|
||||
const requestedProductType = input.productType?.trim() ?? null;
|
||||
|
||||
if (normalizedDocumentType !== 'quotation') {
|
||||
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);
|
||||
throw new AuthError(
|
||||
"Product type required for quotation document sequences.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const productTypeMap = await resolveProductTypeMap(input.organizationId);
|
||||
const normalizedRequestedProductType =
|
||||
normalizeDocumentSequenceProductType(requestedProductType);
|
||||
const productTypeOptions = [
|
||||
...(await resolveProductTypeMap(input.organizationId)).values(),
|
||||
];
|
||||
const referenceOptions = [
|
||||
...(await resolveQuotationTypeMap(input.organizationId)).values(),
|
||||
];
|
||||
const resolution = resolveDocumentSequenceProductTypeInput({
|
||||
documentType: normalizedDocumentType,
|
||||
productType: requestedProductType,
|
||||
options: productTypeOptions,
|
||||
referenceOptions,
|
||||
});
|
||||
|
||||
const byCode = productTypeMap.get(normalizedRequestedProductType);
|
||||
if (byCode) {
|
||||
return byCode.code;
|
||||
if (resolution.productType) {
|
||||
return resolution.productType;
|
||||
}
|
||||
|
||||
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);
|
||||
throw buildProductTypeResolutionError({
|
||||
organizationId: input.organizationId,
|
||||
documentType: normalizedDocumentType,
|
||||
requestedProductType,
|
||||
normalizedRequestedProductType: resolution.normalizedRequestedProductType,
|
||||
productTypeOptions,
|
||||
referenceOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePrefix(input: {
|
||||
@@ -211,21 +278,31 @@ async function resolvePrefix(input: {
|
||||
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
|
||||
if (input.documentType !== "quotation") {
|
||||
return (
|
||||
DEFAULT_DOCUMENT_PREFIXES[input.documentType] ??
|
||||
input.documentType.slice(0, 3).toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
const productTypeCode = resolveDocumentSequenceProductTypeCode(input.productType);
|
||||
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);
|
||||
throw new AuthError(
|
||||
`Missing product type code configuration for ${input.productType}.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return `${productTypeCode}${organizationCode}`;
|
||||
@@ -233,11 +310,14 @@ async function resolvePrefix(input: {
|
||||
|
||||
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;
|
||||
@@ -258,36 +338,42 @@ async function assertUniqueScope(input: {
|
||||
eq(documentSequences.productType, input.productType),
|
||||
eq(documentSequences.documentType, input.documentType),
|
||||
eq(documentSequences.period, input.period),
|
||||
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : [])
|
||||
)
|
||||
...(input.currentId ? [ne(documentSequences.id, input.currentId)] : []),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new AuthError(
|
||||
'Duplicate document sequence scope for organization, branch, product type, document type, and period.',
|
||||
409
|
||||
"Duplicate document sequence scope for organization, branch, product type, document type, and period.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function mapSequenceRecords(
|
||||
organization: OrganizationRecord,
|
||||
rows: Array<typeof documentSequences.$inferSelect>
|
||||
rows: Array<typeof documentSequences.$inferSelect>,
|
||||
) {
|
||||
const [branchDisplayMap, productTypeMap] = await Promise.all([
|
||||
resolveBranchDisplays({
|
||||
organizationId: organization.id,
|
||||
branchIds: rows.map((row) => row.branchId || null)
|
||||
branchIds: rows.map((row) => row.branchId || null),
|
||||
}),
|
||||
resolveProductTypeMap(organization.id)
|
||||
resolveProductTypeMap(organization.id),
|
||||
]);
|
||||
|
||||
const organizationCode = resolveDocumentSequenceOrganizationCode(organization.slug);
|
||||
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 branchDisplay = branchId
|
||||
? (branchDisplayMap.get(branchId) ?? null)
|
||||
: null;
|
||||
const normalizedProductType = normalizeDocumentSequenceProductType(
|
||||
row.productType,
|
||||
);
|
||||
const productType = productTypeMap.get(normalizedProductType) ?? null;
|
||||
|
||||
return {
|
||||
@@ -301,17 +387,17 @@ async function mapSequenceRecords(
|
||||
branchDisplayName: formatBranchDisplayName({
|
||||
branchId,
|
||||
branchCode: branchDisplay?.code ?? null,
|
||||
branchName: branchDisplay?.name ?? null
|
||||
branchName: branchDisplay?.name ?? null,
|
||||
}),
|
||||
productType: normalizedProductType,
|
||||
productTypeLabel:
|
||||
normalizedProductType === DOCUMENT_SEQUENCE_GENERIC_PRODUCT_TYPE
|
||||
? 'All Product Types'
|
||||
: normalizedProductType === 'legacy'
|
||||
? 'Legacy'
|
||||
: productType?.label ?? null,
|
||||
? "All Product Types"
|
||||
: normalizedProductType === "legacy"
|
||||
? "Legacy"
|
||||
: (productType?.label ?? null),
|
||||
productTypeSequenceCode:
|
||||
normalizedProductType === 'legacy'
|
||||
normalizedProductType === "legacy"
|
||||
? null
|
||||
: resolveDocumentSequenceProductTypeCode(normalizedProductType),
|
||||
documentType: row.documentType,
|
||||
@@ -323,19 +409,21 @@ async function mapSequenceRecords(
|
||||
resetPolicy: row.resetPolicy,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
} satisfies DocumentSequenceRecord;
|
||||
});
|
||||
}
|
||||
|
||||
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
|
||||
function toSequenceResult(
|
||||
row: typeof documentSequences.$inferSelect,
|
||||
): DocumentSequenceResult {
|
||||
return {
|
||||
code: buildDocumentCode(
|
||||
row.format,
|
||||
row.prefix,
|
||||
row.period,
|
||||
row.currentNumber + 1,
|
||||
row.paddingLength
|
||||
row.paddingLength,
|
||||
),
|
||||
documentType: row.documentType,
|
||||
productType: row.productType,
|
||||
@@ -343,12 +431,12 @@ function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentS
|
||||
currentNumber: row.currentNumber,
|
||||
nextNumber: row.currentNumber + 1,
|
||||
period: row.period,
|
||||
prefix: row.prefix
|
||||
prefix: row.prefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDocumentSequences(
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<DocumentSequenceListItem[]> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const rows = await db
|
||||
@@ -359,7 +447,7 @@ export async function listDocumentSequences(
|
||||
asc(documentSequences.documentType),
|
||||
asc(documentSequences.productType),
|
||||
asc(documentSequences.period),
|
||||
asc(documentSequences.branchId)
|
||||
asc(documentSequences.branchId),
|
||||
);
|
||||
|
||||
const records = await mapSequenceRecords(organization, rows);
|
||||
@@ -371,14 +459,14 @@ export async function listDocumentSequences(
|
||||
record.prefix,
|
||||
record.period,
|
||||
record.currentNumber + 1,
|
||||
record.paddingLength
|
||||
)
|
||||
record.paddingLength,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const row = await assertSequence(id, organization.id);
|
||||
@@ -389,7 +477,7 @@ export async function getDocumentSequence(
|
||||
|
||||
export async function createDocumentSequence(
|
||||
organizationId: string,
|
||||
payload: DocumentSequenceMutationPayload
|
||||
payload: DocumentSequenceMutationPayload,
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const documentType = normalizeDocumentType(payload.documentType);
|
||||
@@ -397,7 +485,7 @@ export async function createDocumentSequence(
|
||||
const productType = await resolveScopedProductType({
|
||||
organizationId: organization.id,
|
||||
documentType,
|
||||
productType: payload.productType
|
||||
productType: payload.productType,
|
||||
});
|
||||
const period = normalizePeriod(payload.period);
|
||||
|
||||
@@ -406,7 +494,7 @@ export async function createDocumentSequence(
|
||||
branchId,
|
||||
productType,
|
||||
documentType,
|
||||
period
|
||||
period,
|
||||
});
|
||||
|
||||
const [created] = await db
|
||||
@@ -421,7 +509,7 @@ export async function createDocumentSequence(
|
||||
organization,
|
||||
documentType,
|
||||
productType,
|
||||
prefix: payload.prefix
|
||||
prefix: payload.prefix,
|
||||
}),
|
||||
period,
|
||||
currentNumber: payload.currentNumber ?? 0,
|
||||
@@ -429,7 +517,7 @@ export async function createDocumentSequence(
|
||||
format: normalizeFormat(payload.format),
|
||||
resetPolicy: normalizeResetPolicy(payload.resetPolicy),
|
||||
isActive: payload.isActive ?? true,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -440,17 +528,21 @@ export async function createDocumentSequence(
|
||||
export async function updateDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
payload: Partial<DocumentSequenceMutationPayload>
|
||||
payload: Partial<DocumentSequenceMutationPayload>,
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const current = await assertSequence(id, organization.id);
|
||||
const documentType = normalizeDocumentType(payload.documentType ?? current.documentType);
|
||||
const documentType = normalizeDocumentType(
|
||||
payload.documentType ?? current.documentType,
|
||||
);
|
||||
const branchId =
|
||||
payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId);
|
||||
payload.branchId === undefined
|
||||
? current.branchId
|
||||
: normalizeBranchId(payload.branchId);
|
||||
const productType = await resolveScopedProductType({
|
||||
organizationId: organization.id,
|
||||
documentType,
|
||||
productType: payload.productType ?? current.productType
|
||||
productType: payload.productType ?? current.productType,
|
||||
});
|
||||
const period = normalizePeriod(payload.period ?? current.period);
|
||||
|
||||
@@ -460,7 +552,7 @@ export async function updateDocumentSequence(
|
||||
productType,
|
||||
documentType,
|
||||
period,
|
||||
currentId: id
|
||||
currentId: id,
|
||||
});
|
||||
|
||||
const [updated] = await db
|
||||
@@ -473,15 +565,17 @@ export async function updateDocumentSequence(
|
||||
organization,
|
||||
documentType,
|
||||
productType,
|
||||
prefix: payload.prefix ?? current.prefix
|
||||
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),
|
||||
resetPolicy: normalizeResetPolicy(
|
||||
payload.resetPolicy ?? current.resetPolicy,
|
||||
),
|
||||
isActive: payload.isActive ?? current.isActive,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(documentSequences.id, id))
|
||||
.returning();
|
||||
@@ -493,7 +587,7 @@ export async function updateDocumentSequence(
|
||||
export async function resetDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
payload: DocumentSequenceResetPayload
|
||||
payload: DocumentSequenceResetPayload,
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
await assertSequence(id, organization.id);
|
||||
@@ -502,7 +596,7 @@ export async function resetDocumentSequence(
|
||||
.update(documentSequences)
|
||||
.set({
|
||||
currentNumber: payload.currentNumber,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(documentSequences.id, id))
|
||||
.returning();
|
||||
@@ -511,7 +605,10 @@ export async function resetDocumentSequence(
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function deleteDocumentSequence(id: string, organizationId: string) {
|
||||
export async function deleteDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const organization = await resolveOrganizationRecord(organizationId);
|
||||
const sequence = await assertSequence(id, organization.id);
|
||||
await db.delete(documentSequences).where(eq(documentSequences.id, id));
|
||||
@@ -520,7 +617,10 @@ export async function deleteDocumentSequence(id: string, organizationId: string)
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function previewDocumentSequenceById(id: string, organizationId: string) {
|
||||
export async function previewDocumentSequenceById(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const row = await assertSequence(id, organizationId);
|
||||
return toSequenceResult(row);
|
||||
}
|
||||
@@ -532,7 +632,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
const productType = await resolveScopedProductType({
|
||||
organizationId: organization.id,
|
||||
documentType,
|
||||
productType: input.productType
|
||||
productType: input.productType,
|
||||
});
|
||||
const period = normalizePeriod(input.period ?? getCurrentPeriod());
|
||||
|
||||
@@ -544,8 +644,8 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
eq(documentSequences.branchId, branchId),
|
||||
eq(documentSequences.productType, productType),
|
||||
eq(documentSequences.documentType, documentType),
|
||||
eq(documentSequences.period, period)
|
||||
)
|
||||
eq(documentSequences.period, period),
|
||||
),
|
||||
});
|
||||
|
||||
if (!sequence) {
|
||||
@@ -560,14 +660,14 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
prefix: await resolvePrefix({
|
||||
organization,
|
||||
documentType,
|
||||
productType
|
||||
productType,
|
||||
}),
|
||||
period,
|
||||
currentNumber: 0,
|
||||
paddingLength: 3,
|
||||
format: DOCUMENT_SEQUENCE_DEFAULT_FORMAT,
|
||||
resetPolicy: DOCUMENT_SEQUENCE_DEFAULT_RESET_POLICY,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
@@ -577,24 +677,24 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
eq(documentSequences.branchId, branchId),
|
||||
eq(documentSequences.productType, productType),
|
||||
eq(documentSequences.documentType, documentType),
|
||||
eq(documentSequences.period, period)
|
||||
)
|
||||
eq(documentSequences.period, period),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!sequence) {
|
||||
throw new AuthError('Unable to initialize document sequence.', 500);
|
||||
throw new AuthError("Unable to initialize document sequence.", 500);
|
||||
}
|
||||
|
||||
if (!sequence.isActive) {
|
||||
throw new AuthError('Document sequence is inactive.', 400);
|
||||
throw new AuthError("Document sequence is inactive.", 400);
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(documentSequences)
|
||||
.set({
|
||||
currentNumber: sql`${documentSequences.currentNumber} + 1`,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(documentSequences.id, sequence.id))
|
||||
.returning();
|
||||
@@ -607,7 +707,7 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
updated.prefix,
|
||||
updated.period,
|
||||
updated.currentNumber,
|
||||
updated.paddingLength
|
||||
updated.paddingLength,
|
||||
),
|
||||
documentType: updated.documentType,
|
||||
productType: updated.productType,
|
||||
@@ -615,35 +715,42 @@ export async function generateNextDocumentCode(input: DocumentSequenceInput) {
|
||||
currentNumber: updated.currentNumber,
|
||||
nextNumber: updated.currentNumber + 1,
|
||||
period: updated.period,
|
||||
prefix: updated.prefix
|
||||
} satisfies DocumentSequenceResult
|
||||
prefix: updated.prefix,
|
||||
} satisfies DocumentSequenceResult,
|
||||
};
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
entityType: 'crm_document_sequence',
|
||||
entityType: "crm_document_sequence",
|
||||
entityId: result.sequenceId,
|
||||
action: 'generate',
|
||||
action: "generate",
|
||||
branchId: branchId || null,
|
||||
afterData: result.result
|
||||
afterData: result.result,
|
||||
});
|
||||
|
||||
return result.result;
|
||||
} catch (error) {
|
||||
try {
|
||||
const errorDetails =
|
||||
error && typeof error === "object" && "details" in error
|
||||
? ((error as { details?: unknown }).details ?? null)
|
||||
: null;
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
entityType: 'crm_document_sequence',
|
||||
entityType: "crm_document_sequence",
|
||||
entityId: `${documentType}:${branchId}:${productType}:${period}`,
|
||||
action: 'generate_failed',
|
||||
action: "generate_failed",
|
||||
branchId: branchId || null,
|
||||
afterData: {
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
documentType,
|
||||
productType,
|
||||
period
|
||||
}
|
||||
requestedProductType: input.productType?.trim() ?? null,
|
||||
period,
|
||||
details: errorDetails,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Keep original error as the primary failure signal.
|
||||
|
||||
Reference in New Issue
Block a user