662 lines
18 KiB
TypeScript
662 lines
18 KiB
TypeScript
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`,
|
|
};
|
|
}
|