This commit is contained in:
phaichayon
2026-06-30 15:53:11 +07:00
parent 47eac3badc
commit a4fd166c51
26 changed files with 1670 additions and 38 deletions

View File

@@ -92,7 +92,7 @@ export async function createArtifact(input: {
entityType: string;
entityId: string;
documentType: string;
artifactType: 'preview' | 'approved_pdf' | 'export';
artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export';
templateVersionId?: string | null;
storageProvider: string;
storageKey: string;

View File

@@ -0,0 +1,89 @@
import { PDFDocument } from '@pdfme/pdf-lib';
import type {
DocumentLibraryBrand,
DocumentLibraryProductType,
} from '@/features/foundation/document-library/types';
import type { AssemblySourcePolicy, ResolvedAssemblySource } from '../types';
export function normalizeAssemblyBrand(
value: string | null | undefined,
): DocumentLibraryBrand {
const normalized = String(value ?? '')
.trim()
.toLowerCase();
if (normalized === 'alla') {
return 'alla';
}
if (normalized === 'onvalla') {
return 'onvalla';
}
return 'generic';
}
export function normalizeAssemblyProductType(
value: string | null | undefined,
): DocumentLibraryProductType {
const normalized = String(value ?? '')
.trim()
.toLowerCase();
if (normalized === 'crane') {
return 'crane';
}
if (normalized === 'dockdoor' || normalized === 'dock_door') {
return 'dock_door';
}
if (normalized === 'solarcell' || normalized === 'solar') {
return 'solar';
}
if (normalized === 'service') {
return 'service';
}
return 'all';
}
export function validateAssemblySourceOrdering(sources: AssemblySourcePolicy[]) {
const seenOrders = new Set<number>();
for (const source of sources) {
if (seenOrders.has(source.order)) {
throw new Error(`Duplicate assembly source order: ${source.order}`);
}
seenOrders.add(source.order);
}
}
export async function getPdfPageCount(buffer: Buffer, label: string) {
try {
const document = await PDFDocument.load(buffer);
return document.getPageCount();
} catch {
throw new Error(`${label} is not a readable PDF document.`);
}
}
export async function mergePdfBuffers(sources: ResolvedAssemblySource[]) {
const merged = await PDFDocument.create();
for (const source of sources) {
const document = await PDFDocument.load(source.buffer);
const pages = await merged.copyPages(
document,
document.getPageIndices(),
);
for (const page of pages) {
merged.addPage(page);
}
}
return Buffer.from(await merged.save());
}

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PDFDocument } from '@pdfme/pdf-lib';
import type { ResolvedAssemblySource } from '../types';
import {
getPdfPageCount,
mergePdfBuffers,
normalizeAssemblyBrand,
normalizeAssemblyProductType,
validateAssemblySourceOrdering,
} from './helpers';
async function createPdf(pageCount: number) {
const document = await PDFDocument.create();
for (let index = 0; index < pageCount; index += 1) {
document.addPage([595, 842]);
}
return Buffer.from(await document.save());
}
function createSource(input: {
role: string;
order: number;
pageCount: number;
}): Promise<ResolvedAssemblySource> {
return createPdf(input.pageCount).then((buffer) => ({
role: input.role,
sourceType: 'generated',
required: true,
order: input.order,
fileName: `${input.role}.pdf`,
contentType: 'application/pdf',
checksum: input.role,
pageCount: input.pageCount,
fileSize: buffer.byteLength,
metadata: {},
buffer,
}));
}
test('normalizeAssemblyProductType aligns legacy quotation codes', () => {
assert.equal(normalizeAssemblyProductType('dockdoor'), 'dock_door');
assert.equal(normalizeAssemblyProductType('solarcell'), 'solar');
assert.equal(normalizeAssemblyProductType('crane'), 'crane');
assert.equal(normalizeAssemblyProductType('unknown'), 'all');
});
test('normalizeAssemblyBrand falls back to generic', () => {
assert.equal(normalizeAssemblyBrand('alla'), 'alla');
assert.equal(normalizeAssemblyBrand('onvalla'), 'onvalla');
assert.equal(normalizeAssemblyBrand('unknown'), 'generic');
});
test('validateAssemblySourceOrdering rejects duplicate orders', () => {
assert.throws(
() =>
validateAssemblySourceOrdering([
{
sourceType: 'generated',
role: 'main',
required: true,
order: 1,
},
{
sourceType: 'document_library',
role: 'sla',
required: true,
order: 1,
documentType: 'sla',
},
]),
/Duplicate assembly source order/,
);
});
test('mergePdfBuffers preserves page order and page count', async () => {
const sources = await Promise.all([
createSource({ role: 'main', order: 1, pageCount: 2 }),
createSource({ role: 'sla', order: 2, pageCount: 1 }),
createSource({ role: 'warranty', order: 3, pageCount: 3 }),
]);
const merged = await mergePdfBuffers(sources);
const pageCount = await getPdfPageCount(merged, 'merged-test');
assert.equal(pageCount, 6);
});
test('getPdfPageCount fails for invalid pdf source', async () => {
await assert.rejects(
() => getPdfPageCount(Buffer.from('not-a-pdf'), 'invalid-source'),
/not a readable PDF document/,
);
});

View File

@@ -0,0 +1,661 @@
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
import { crmDocumentLibraries, crmDocumentLibraryVersions } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
createArtifact,
downloadArtifact,
getActiveArtifactForEntity,
voidArtifact,
} from '@/features/foundation/document-artifact/server/service';
import {
type DocumentLibraryBrand,
type DocumentLibraryProductType,
type DocumentLibraryType,
} from '@/features/foundation/document-library/types';
import {
buildOrganizationStorageKey,
getStorageProvider,
} from '@/features/foundation/storage/service';
import { sha256 } from '@/features/foundation/storage/server/checksum';
import {
buildQuotationDocumentData,
getQuotationDocumentPreviewData,
} from '@/features/crm/quotations/document/server/service';
import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service';
import type {
AssemblyPolicy,
AssemblySourcePolicy,
AssemblyWarning,
DocumentAssemblyResult,
ResolvedAssemblySource,
} from '../types';
import {
getPdfPageCount,
mergePdfBuffers,
normalizeAssemblyBrand,
normalizeAssemblyProductType,
validateAssemblySourceOrdering,
} from './helpers';
type ResolvedDocumentLibraryCandidate = {
library: typeof crmDocumentLibraries.$inferSelect;
version: typeof crmDocumentLibraryVersions.$inferSelect;
};
type AssembleDocumentInput = {
organizationId: string;
userId: string;
entityType: string;
entityId: string;
fileName: string;
artifactType: 'assembled_pdf';
policy: AssemblyPolicy;
sourceResolver: (
source: AssemblySourcePolicy,
) => Promise<ResolvedAssemblySource | null>;
auditEntityType?: string;
};
type QuotationAssemblyMetadata = {
documentType: string;
sourceDocumentType: string;
sourceDocumentId: string;
sourceDocumentCode: string;
assemblyPolicy: AssemblyPolicy;
sourceArtifacts: Array<Record<string, unknown>>;
warnings: AssemblyWarning[];
pageCount: number;
generatedAt: string;
generatedBy: string;
};
const ASSEMBLED_PDF_ARTIFACT_TYPE = 'assembled_pdf';
const DEFAULT_ASSEMBLY_LANGUAGE = 'th';
function sanitizeFileName(fileName: string) {
return fileName.replace(/[^a-zA-Z0-9._-]+/g, '-');
}
function toLibraryDocumentLabel(documentType: DocumentLibraryType) {
return documentType.replace(/_/g, ' ').toUpperCase();
}
function toRoleLabel(role: string) {
return role.replace(/_/g, ' ').toUpperCase();
}
function buildDocumentLibraryMissingMessage(input: {
role: string;
documentType: DocumentLibraryType;
brand: DocumentLibraryBrand;
language: string;
productType: DocumentLibraryProductType;
}) {
return `Missing ${toRoleLabel(input.role)} document (${toLibraryDocumentLabel(
input.documentType,
)}, brand ${input.brand}, language ${input.language}, product type ${input.productType}).`;
}
function getCandidateScore(input: {
candidate: typeof crmDocumentLibraries.$inferSelect;
brand: DocumentLibraryBrand;
productType: DocumentLibraryProductType;
}) {
let score = 0;
if (input.candidate.brand === input.brand) {
score += 2;
}
if (input.candidate.productType === input.productType) {
score += 1;
}
return score;
}
export async function resolveActiveDocumentLibraryCandidate(input: {
organizationId: string;
documentType: DocumentLibraryType;
brand: DocumentLibraryBrand;
language: string;
productType: DocumentLibraryProductType;
}): Promise<ResolvedDocumentLibraryCandidate | null> {
const brandScope =
input.brand === 'generic' ? ['generic'] : [input.brand, 'generic'];
const productTypeScope =
input.productType === 'all' ? ['all'] : [input.productType, 'all'];
const rows = await db
.select({
library: crmDocumentLibraries,
version: crmDocumentLibraryVersions,
})
.from(crmDocumentLibraryVersions)
.innerJoin(
crmDocumentLibraries,
eq(crmDocumentLibraryVersions.libraryId, crmDocumentLibraries.id),
)
.where(
and(
eq(crmDocumentLibraries.organizationId, input.organizationId),
eq(crmDocumentLibraryVersions.organizationId, input.organizationId),
eq(crmDocumentLibraries.documentType, input.documentType),
eq(crmDocumentLibraries.language, input.language),
inArray(crmDocumentLibraries.brand, brandScope),
inArray(crmDocumentLibraries.productType, productTypeScope),
eq(crmDocumentLibraries.isActive, true),
eq(crmDocumentLibraries.status, 'active'),
eq(crmDocumentLibraryVersions.isActive, true),
eq(crmDocumentLibraryVersions.status, 'active'),
isNull(crmDocumentLibraries.deletedAt),
isNull(crmDocumentLibraryVersions.deletedAt),
),
)
.orderBy(
desc(crmDocumentLibraryVersions.createdAt),
crmDocumentLibraries.code,
);
if (!rows.length) {
return null;
}
const [best] = [...rows].sort((left, right) => {
const scoreDelta =
getCandidateScore({
candidate: right.library,
brand: input.brand,
productType: input.productType,
}) -
getCandidateScore({
candidate: left.library,
brand: input.brand,
productType: input.productType,
});
if (scoreDelta !== 0) {
return scoreDelta;
}
const codeDelta = left.library.code.localeCompare(right.library.code);
if (codeDelta !== 0) {
return codeDelta;
}
return right.version.createdAt.getTime() - left.version.createdAt.getTime();
});
return best;
}
async function resolveDocumentLibrarySource(input: {
organizationId: string;
role: string;
required: boolean;
order: number;
documentType: DocumentLibraryType;
brand: DocumentLibraryBrand;
language: string;
productType: DocumentLibraryProductType;
}) {
const candidate = await resolveActiveDocumentLibraryCandidate({
organizationId: input.organizationId,
documentType: input.documentType,
brand: input.brand,
language: input.language,
productType: input.productType,
});
if (!candidate) {
return null;
}
const object = await getStorageProvider().getObject({
key: candidate.version.storageKey,
});
const buffer = Buffer.from(await new Response(object.body).arrayBuffer());
const pageCount = await getPdfPageCount(
buffer,
`${toRoleLabel(input.role)} source`,
);
return {
role: input.role,
sourceType: 'document_library' as const,
required: input.required,
order: input.order,
fileName: candidate.version.fileName,
contentType: candidate.version.mimeType,
checksum: candidate.version.checksum,
pageCount,
fileSize: candidate.version.fileSize,
metadata: {
documentType: candidate.library.documentType,
libraryId: candidate.library.id,
libraryCode: candidate.library.code,
libraryName: candidate.library.name,
brand: candidate.library.brand,
language: candidate.library.language,
productType: candidate.library.productType,
versionId: candidate.version.id,
version: candidate.version.version,
storageKey: candidate.version.storageKey,
},
buffer,
};
}
async function persistAssembledArtifact(input: {
organizationId: string;
userId: string;
entityType: string;
entityId: string;
artifactType: 'assembled_pdf';
fileName: string;
buffer: Buffer;
checksum: string;
fileSize: number;
metadata: QuotationAssemblyMetadata;
}) {
const existingArtifact = await getActiveArtifactForEntity({
organizationId: input.organizationId,
entityType: input.entityType,
entityId: input.entityId,
artifactType: input.artifactType,
});
if (existingArtifact?.status === 'active') {
await voidArtifact({
artifactId: existingArtifact.id,
organizationId: input.organizationId,
userId: input.userId,
reason: 'Superseded by newer assembled PDF artifact.',
});
}
const safeFileName = sanitizeFileName(input.fileName);
const storageKey = buildOrganizationStorageKey(input.organizationId, [
'quotations',
'assembled',
`${input.entityId}-${safeFileName}`,
]);
const stored = await getStorageProvider().putObject({
key: storageKey,
body: input.buffer,
contentType: 'application/pdf',
fileName: safeFileName,
});
const artifact = await createArtifact({
organizationId: input.organizationId,
userId: input.userId,
entityType: input.entityType,
entityId: input.entityId,
documentType: input.metadata.sourceDocumentType,
artifactType: input.artifactType,
templateVersionId: null,
storageProvider: stored.provider,
storageKey: stored.key,
fileName: safeFileName,
contentType: 'application/pdf',
fileSize: input.fileSize,
checksum: input.checksum,
metadata: input.metadata,
});
return {
artifactId: artifact.id,
storageKey: artifact.storageKey,
fileName: artifact.fileName,
checksum: artifact.checksum ?? input.checksum,
fileSize: artifact.fileSize ?? input.fileSize,
pageCount: input.metadata.pageCount,
};
}
export async function assembleDocument(
input: AssembleDocumentInput,
): Promise<DocumentAssemblyResult> {
validateAssemblySourceOrdering(input.policy.sources);
const auditEntityType = input.auditEntityType ?? input.entityType;
const warnings: AssemblyWarning[] = [];
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: auditEntityType,
entityId: input.entityId,
action: 'assembly_started',
afterData: { policy: input.policy },
});
try {
const sources: ResolvedAssemblySource[] = [];
for (const sourcePolicy of [...input.policy.sources].sort(
(left, right) => left.order - right.order,
)) {
const resolvedSource = await input.sourceResolver(sourcePolicy);
if (!resolvedSource) {
if (sourcePolicy.sourceType === 'document_library') {
const message = buildDocumentLibraryMissingMessage({
role: sourcePolicy.role,
documentType: sourcePolicy.documentType!,
brand: input.policy.brand ?? 'generic',
language: input.policy.language ?? DEFAULT_ASSEMBLY_LANGUAGE,
productType: input.policy.productType ?? 'all',
});
if (sourcePolicy.required) {
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: auditEntityType,
entityId: input.entityId,
action: 'assembly_failed',
afterData: { role: sourcePolicy.role, message },
});
throw new AuthError(message, 409);
}
warnings.push({
code: 'missing_optional_source',
role: sourcePolicy.role,
message,
});
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: auditEntityType,
entityId: input.entityId,
action: 'assembly_source_missing',
afterData: { role: sourcePolicy.role, message },
});
continue;
}
throw new AuthError(
`Required generated source missing for role ${sourcePolicy.role}.`,
409,
);
}
sources.push(resolvedSource);
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: auditEntityType,
entityId: input.entityId,
action: 'assembly_source_resolved',
afterData: {
role: resolvedSource.role,
sourceType: resolvedSource.sourceType,
fileName: resolvedSource.fileName,
checksum: resolvedSource.checksum,
pageCount: resolvedSource.pageCount,
metadata: resolvedSource.metadata,
},
});
}
if (!sources.length) {
throw new AuthError('No assembly sources available.', 409);
}
const buffer = await mergePdfBuffers(sources);
const checksum = sha256(buffer);
const fileSize = buffer.byteLength;
const pageCount = await getPdfPageCount(buffer, 'Merged assembly output');
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: auditEntityType,
entityId: input.entityId,
action: 'assembly_merge_completed',
afterData: {
fileName: input.fileName,
checksum,
fileSize,
pageCount,
sourceCount: sources.length,
},
});
return {
buffer,
fileName: input.fileName,
checksum,
fileSize,
pageCount,
policy: input.policy,
warnings,
sources,
artifact: null,
};
} catch (error) {
if (!(error instanceof AuthError)) {
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: auditEntityType,
entityId: input.entityId,
action: 'assembly_failed',
afterData: {
message:
error instanceof Error
? error.message
: 'Unknown assembly failure',
},
});
}
throw error;
}
}
export async function buildDefaultQuotationAssemblyPolicy(input: {
quotationId: string;
organizationId: string;
}): Promise<AssemblyPolicy> {
const documentData = await buildQuotationDocumentData(
input.quotationId,
input.organizationId,
);
const productTypeCode =
documentData.items.find((item) => item.productTypeCode)?.productTypeCode ??
documentData.quotation.quotationTypeCode;
return {
documentType: 'quotation_pdf',
organizationId: input.organizationId,
brand: normalizeAssemblyBrand(documentData.company.slug),
language: DEFAULT_ASSEMBLY_LANGUAGE,
productType: normalizeAssemblyProductType(productTypeCode),
sources: [
{
sourceType: 'generated',
role: 'main',
required: true,
order: 1,
},
{
sourceType: 'document_library',
role: 'sla',
required: true,
order: 2,
documentType: 'sla',
},
{
sourceType: 'document_library',
role: 'warranty',
required: false,
order: 3,
documentType: 'warranty',
},
{
sourceType: 'document_library',
role: 'datasheet',
required: false,
order: 4,
documentType: 'datasheet',
},
{
sourceType: 'document_library',
role: 'drawing',
required: false,
order: 5,
documentType: 'drawing',
},
],
};
}
export async function generateAssembledQuotationPdf(
quotationId: string,
organizationId: string,
userId: string,
) {
const [policy, preview] = await Promise.all([
buildDefaultQuotationAssemblyPolicy({ quotationId, organizationId }),
getQuotationDocumentPreviewData(quotationId, organizationId),
]);
const assembled = await assembleDocument({
organizationId,
userId,
entityType: 'crm_quotation',
entityId: quotationId,
auditEntityType: 'crm_quotation',
fileName: `${preview.documentData.quotation.code}-assembled.pdf`,
artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE,
policy,
sourceResolver: async (sourcePolicy) => {
if (sourcePolicy.sourceType === 'generated') {
const generated = await generateQuotationPdf(quotationId, organizationId);
const pageCount = await getPdfPageCount(
generated.buffer,
'Generated quotation PDF',
);
return {
role: sourcePolicy.role,
sourceType: 'generated',
required: sourcePolicy.required,
order: sourcePolicy.order,
fileName: generated.fileName,
contentType: 'application/pdf',
checksum: sha256(generated.buffer),
pageCount,
fileSize: generated.buffer.byteLength,
metadata: {
templateVersionId: generated.templateVersionId,
sourceDocumentType: 'quotation_pdf',
},
buffer: generated.buffer,
};
}
return resolveDocumentLibrarySource({
organizationId,
role: sourcePolicy.role,
required: sourcePolicy.required,
order: sourcePolicy.order,
documentType: sourcePolicy.documentType!,
brand: policy.brand ?? 'generic',
language: policy.language ?? DEFAULT_ASSEMBLY_LANGUAGE,
productType: policy.productType ?? 'all',
});
},
});
const metadata: QuotationAssemblyMetadata = {
documentType: 'assembled_pdf',
sourceDocumentType: 'quotation_pdf',
sourceDocumentId: quotationId,
sourceDocumentCode: preview.documentData.quotation.code,
assemblyPolicy: assembled.policy,
sourceArtifacts: assembled.sources.map((source) => ({
role: source.role,
sourceType: source.sourceType,
fileName: source.fileName,
checksum: source.checksum,
fileSize: source.fileSize,
pageCount: source.pageCount,
metadata: source.metadata,
})),
warnings: assembled.warnings,
pageCount: assembled.pageCount,
generatedAt: new Date().toISOString(),
generatedBy: userId,
};
const artifact = await persistAssembledArtifact({
organizationId,
userId,
entityType: 'crm_quotation',
entityId: quotationId,
artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE,
fileName: assembled.fileName,
buffer: assembled.buffer,
checksum: assembled.checksum,
fileSize: assembled.fileSize,
metadata,
});
await auditAction({
organizationId,
userId,
entityType: 'crm_quotation',
entityId: quotationId,
action: 'assembly_artifact_stored',
afterData: artifact,
});
return {
...assembled,
artifact,
};
}
export async function getStoredAssembledQuotationPdf(
quotationId: string,
organizationId: string,
options?: {
userId?: string;
auditDownload?: boolean;
},
) {
const existingArtifact = await getActiveArtifactForEntity({
organizationId,
entityType: 'crm_quotation',
entityId: quotationId,
artifactType: ASSEMBLED_PDF_ARTIFACT_TYPE,
});
if (!existingArtifact) {
throw new AuthError('Assembled quotation PDF has not been generated yet.', 404);
}
const { object, artifact } = await downloadArtifact({
artifactId: existingArtifact.id,
organizationId,
userId: options?.userId,
auditDownload: options?.auditDownload,
});
const buffer = Buffer.from(await new Response(object.body).arrayBuffer());
return {
artifact,
buffer,
fileName: object.fileName || `${quotationId}-assembled.pdf`,
};
}

View File

@@ -0,0 +1,65 @@
import type {
DocumentLibraryBrand,
DocumentLibraryProductType,
DocumentLibraryType,
} from '@/features/foundation/document-library/types';
export type AssemblySourceType = 'generated' | 'document_library';
export interface AssemblySourcePolicy {
sourceType: AssemblySourceType;
role: string;
required: boolean;
order: number;
documentType?: DocumentLibraryType;
}
export interface AssemblyPolicy {
documentType: string;
organizationId: string;
productType?: DocumentLibraryProductType | null;
brand?: DocumentLibraryBrand | null;
language?: string | null;
sources: AssemblySourcePolicy[];
}
export interface AssemblyWarning {
code: 'missing_optional_source';
role: string;
message: string;
}
export interface ResolvedAssemblySource {
role: string;
sourceType: AssemblySourceType;
required: boolean;
order: number;
fileName: string;
contentType: string;
checksum: string;
pageCount: number;
fileSize: number;
metadata: Record<string, unknown>;
buffer: Buffer;
}
export interface PersistedAssemblyArtifact {
artifactId: string;
storageKey: string;
fileName: string;
checksum: string;
fileSize: number;
pageCount: number;
}
export interface DocumentAssemblyResult {
buffer: Buffer;
fileName: string;
checksum: string;
fileSize: number;
pageCount: number;
policy: AssemblyPolicy;
warnings: AssemblyWarning[];
sources: ResolvedAssemblySource[];
artifact: PersistedAssemblyArtifact | null;
}