diff --git a/src/features/crm/quotations/customer-package.test.ts b/src/features/crm/quotations/customer-package.test.ts
new file mode 100644
index 0000000..9e89e4c
--- /dev/null
+++ b/src/features/crm/quotations/customer-package.test.ts
@@ -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'
+ );
+});
diff --git a/src/features/crm/quotations/customer-package.ts b/src/features/crm/quotations/customer-package.ts
new file mode 100644
index 0000000..7605e24
--- /dev/null
+++ b/src/features/crm/quotations/customer-package.ts
@@ -0,0 +1,65 @@
+import type { QuotationCustomerPackageSummary } from './api/types';
+
+const CUSTOMER_PACKAGE_ROLE_LABELS: Record = {
+ 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';
+}
diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts
index 37586a9..578845a 100644
--- a/src/features/crm/quotations/server/service.ts
+++ b/src/features/crm/quotations/server/service.ts
@@ -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;
+ 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)
+ : null
+ };
+}
+
+function toCustomerPackageWarning(
+ value: unknown
+): QuotationCustomerPackageWarning | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null;
+ }
+
+ const warning = value as Record;
+ 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)
+ : 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
});
}
diff --git a/src/features/foundation/document-sequence/product-type-resolver.test.ts b/src/features/foundation/document-sequence/product-type-resolver.test.ts
new file mode 100644
index 0000000..b0eddec
--- /dev/null
+++ b/src/features/foundation/document-sequence/product-type-resolver.test.ts
@@ -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'
+ });
+});
diff --git a/src/features/foundation/document-sequence/product-type-resolver.ts b/src/features/foundation/document-sequence/product-type-resolver.ts
new file mode 100644
index 0000000..dfbe740
--- /dev/null
+++ b/src/features/foundation/document-sequence/product-type-resolver.ts
@@ -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,
+ requestedProductType: string
+):
+ | {
+ productType: string;
+ matchedBy: Exclude;
+ }
+ | 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;
+ referenceOptions?: ReadonlyArray;
+}): 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
+ };
+}
diff --git a/src/features/foundation/document-sequence/service.ts b/src/features/foundation/document-sequence/service.ts
index 284f938..23c2370 100644
--- a/src/features/foundation/document-sequence/service.ts
+++ b/src/features/foundation/document-sequence/service.ts
@@ -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 = {
- 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 {
+async function resolveOrganizationRecord(
+ organizationId?: string,
+): Promise {
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 [
@@ -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;
+ referenceOptions: ReadonlyArray;
+}) {
+ 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;
+ referenceOptions: ReadonlyArray;
+}) {
+ const error = new AuthError(
+ "Missing product type code configuration for quotation type.",
+ 400,
+ ) as AuthError & {
+ details: ReturnType;
+ };
+
+ 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
+ rows: Array,
) {
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 {
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 {
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 {
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
+ payload: Partial,
): Promise {
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 {
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.
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
index 5cc6739..641eb1c 100644
--- a/src/lib/api-client.ts
+++ b/src/lib/api-client.ts
@@ -1,32 +1,94 @@
-const BASE_URL = '/api';
+const BASE_URL = "/api";
-export async function apiClient(endpoint: string, options?: RequestInit): Promise {
- 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 {
+ 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(
+ endpoint: string,
+ options?: RequestInit,
+): Promise {
+ 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;
+ return response.json() as Promise;
}