task-p.7
This commit is contained in:
@@ -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;
|
||||
|
||||
89
src/features/foundation/document-assembly/server/helpers.ts
Normal file
89
src/features/foundation/document-assembly/server/helpers.ts
Normal 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());
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
661
src/features/foundation/document-assembly/server/service.ts
Normal file
661
src/features/foundation/document-assembly/server/service.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
65
src/features/foundation/document-assembly/types.ts
Normal file
65
src/features/foundation/document-assembly/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user