task-p.7.1.1

This commit is contained in:
phaichayon
2026-06-30 20:42:28 +07:00
parent e5766ea311
commit 8feaee61cb
29 changed files with 2673 additions and 875 deletions

View File

@@ -8,6 +8,8 @@ import {
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export { POST } from './generate/route';
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {

View File

@@ -96,11 +96,14 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfPreview)));
const canPreviewCustomerPackage = canPreviewPdf;
const canDownloadPdf =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfDownload)));
const canGenerateCustomerPackage = canDownloadPdf;
const canDownloadCustomerPackage = canDownloadPdf;
const canGenerateApprovedPdf =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
@@ -165,7 +168,10 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
currentUserId={session?.user?.id ?? ''}
canPreviewDocument={canPreviewDocument}
canPreviewPdf={canPreviewPdf}
canPreviewCustomerPackage={canPreviewCustomerPackage}
canDownloadPdf={canDownloadPdf}
canGenerateCustomerPackage={canGenerateCustomerPackage}
canDownloadCustomerPackage={canDownloadCustomerPackage}
canGenerateApprovedPdf={canGenerateApprovedPdf}
/>
</HydrationBoundary>

View File

@@ -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);
}
}
});

View File

@@ -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 })
});
}

View File

@@ -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;
}

View File

@@ -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>

View 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'
);
});

View 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';
}

View File

@@ -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
});
}

View File

@@ -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'
});
});

View File

@@ -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
};
}

View File

@@ -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.

View File

@@ -1,32 +1,94 @@
const BASE_URL = '/api';
const BASE_URL = "/api";
export async function apiClient<T>(endpoint: string, options?: RequestInit): Promise<T> {
const isServer = typeof window === 'undefined';
const requestHeaders = isServer ? await (await import('next/headers')).headers() : null;
const host = requestHeaders?.get('x-forwarded-host') ?? requestHeaders?.get('host');
const protocol = requestHeaders?.get('x-forwarded-proto') ?? 'http';
const origin = isServer && host ? `${protocol}://${host}` : '';
interface ApiErrorPayload {
message?: string;
error?: string;
}
export class ApiClientError extends Error {
status: number;
statusText: string;
payload: unknown;
constructor(
message: string,
input: { status: number; statusText: string; payload: unknown },
) {
super(message);
this.name = "ApiClientError";
this.status = input.status;
this.statusText = input.statusText;
this.payload = input.payload;
}
}
async function readErrorPayload(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return response.json().catch(() => null);
}
return response.text().catch(() => null);
}
function extractApiErrorMessage(payload: unknown, response: Response): string {
if (payload && typeof payload === "object") {
const candidate = payload as ApiErrorPayload;
if (typeof candidate.message === "string" && candidate.message.trim()) {
return candidate.message.trim();
}
if (typeof candidate.error === "string" && candidate.error.trim()) {
return candidate.error.trim();
}
}
if (typeof payload === "string" && payload.trim()) {
return payload.trim();
}
return `API error: ${response.status} ${response.statusText}`;
}
export async function apiClient<T>(
endpoint: string,
options?: RequestInit,
): Promise<T> {
const isServer = typeof window === "undefined";
const requestHeaders = isServer
? await (await import("next/headers")).headers()
: null;
const host =
requestHeaders?.get("x-forwarded-host") ?? requestHeaders?.get("host");
const protocol = requestHeaders?.get("x-forwarded-proto") ?? "http";
const origin = isServer && host ? `${protocol}://${host}` : "";
const optionHeaders = options?.headers;
const restOptions = options ? { ...options } : null;
if (restOptions && 'headers' in restOptions) {
if (restOptions && "headers" in restOptions) {
delete restOptions.headers;
}
const res = await fetch(`${origin}${BASE_URL}${endpoint}`, {
const response = await fetch(`${origin}${BASE_URL}${endpoint}`, {
...restOptions,
headers: {
'Content-Type': 'application/json',
...(isServer && requestHeaders?.get('cookie')
? { cookie: requestHeaders.get('cookie') as string }
"Content-Type": "application/json",
...(isServer && requestHeaders?.get("cookie")
? { cookie: requestHeaders.get("cookie") as string }
: {}),
...optionHeaders
}
...optionHeaders,
},
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
if (!response.ok) {
const payload = await readErrorPayload(response);
const message = extractApiErrorMessage(payload, response);
throw new ApiClientError(message, {
status: response.status,
statusText: response.statusText,
payload,
});
}
return res.json() as Promise<T>;
return response.json() as Promise<T>;
}