This commit is contained in:
phaichayon
2026-06-16 13:42:38 +07:00
parent 56683ee7b9
commit edee45375e
29 changed files with 4240 additions and 51 deletions

View File

@@ -314,13 +314,22 @@ async function syncQuotationStatusFromApproval(
throw new AuthError(`Quotation status ${nextStatusCode} is not configured`, 400);
}
const nextValues: Partial<typeof crmQuotations.$inferInsert> = {
status: statusId,
approvedAt: nextStatusCode === 'approved' ? new Date() : null,
updatedAt: new Date(),
updatedBy: userId
};
if (nextStatusCode !== 'approved') {
nextValues.approvedPdfUrl = null;
nextValues.approvedSnapshot = null;
nextValues.approvedTemplateVersionId = null;
}
await db
.update(crmQuotations)
.set({
status: statusId,
updatedAt: new Date(),
updatedBy: userId
})
.set(nextValues)
.where(eq(crmQuotations.id, quotationId));
}

View File

@@ -0,0 +1,313 @@
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { Font, Template } from '@pdfme/common';
import { generate } from '@pdfme/generator';
import { image, line, table, text } from '@pdfme/schemas';
import { and, eq, isNull } from 'drizzle-orm';
import {
crmQuotationAttachments,
crmQuotations,
organizations
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
getQuotationDocumentPreviewData,
prepareApprovedQuotationSnapshot
} from '@/features/crm/quotations/document/server/service';
import { getQuotationDetail } from '@/features/crm/quotations/server/service';
type GeneratePdfFromTemplateInput = {
template: Template;
inputs: Array<Record<string, unknown>>;
};
type GeneratedQuotationPdf = {
buffer: Buffer;
fileName: string;
templateVersionId: string;
approvedPdfUrl?: string | null;
};
const TEMPLATE_FONT_FALLBACKS = new Set(['cordia', 'cordiaBold']);
function getPdfPlugins() {
return {
text,
image,
line,
table
};
}
async function resolvePdfFonts(): Promise<Font> {
const regularPath = path.join(process.cwd(), 'public', 'fonts', 'cordia_new_r.ttf');
const boldPath = path.join(process.cwd(), 'public', 'fonts', 'Cordia_New_Bold.ttf');
try {
const [regularFontData, boldFontData] = await Promise.all([
readFile(regularPath),
readFile(boldPath)
]);
return {
cordia: {
data: new Uint8Array(regularFontData),
fallback: true
},
cordiaBold: {
data: new Uint8Array(boldFontData)
}
};
} catch {
return {};
}
}
function normalizeTemplateFonts<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => normalizeTemplateFonts(item)) as T;
}
if (value && typeof value === 'object') {
const nextObject = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, child]) => {
if (
key === 'fontName' &&
typeof child === 'string' &&
TEMPLATE_FONT_FALLBACKS.has(child)
) {
return [key, 'Roboto'];
}
return [key, normalizeTemplateFonts(child)];
})
);
return nextObject as T;
}
return value;
}
export async function generatePdfFromTemplate(input: GeneratePdfFromTemplateInput) {
const font = await resolvePdfFonts();
const generatorInput = {
template: input.template,
inputs: input.inputs,
plugins: getPdfPlugins(),
options: Object.keys(font).length > 0 ? { font } : undefined
};
let pdf: Uint8Array;
try {
pdf = await generate(generatorInput);
} catch (error) {
if (
error instanceof Error &&
(error.message.includes('Font "') || error.message.includes('fontKitFont.layout'))
) {
pdf = await generate({
...generatorInput,
template: normalizeTemplateFonts(input.template),
options: undefined
});
} else {
throw error;
}
}
return Buffer.from(pdf);
}
function toSafeFileName(value: string) {
return value.replace(/[^a-zA-Z0-9._-]/g, '-');
}
async function ensureGeneratedDirectory(organizationId: string) {
const directory = path.join(
process.cwd(),
'public',
'generated',
'quotations',
organizationId
);
await mkdir(directory, { recursive: true });
return directory;
}
async function persistApprovedQuotationArtifact(args: {
quotationId: string;
organizationId: string;
userId: string;
fileName: string;
buffer: Buffer;
templateVersionId: string;
}) {
const quotation = await getQuotationDetail(args.quotationId, args.organizationId);
const snapshot = await prepareApprovedQuotationSnapshot(
args.quotationId,
args.organizationId,
args.userId
);
const directory = await ensureGeneratedDirectory(args.organizationId);
const safeFileName = toSafeFileName(args.fileName);
const absolutePath = path.join(directory, safeFileName);
const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`;
await writeFile(absolutePath, args.buffer);
const fileStats = await stat(absolutePath);
await db
.update(crmQuotations)
.set({
approvedAt: quotation.approvedAt ? new Date(quotation.approvedAt) : new Date(),
approvedPdfUrl: publicPath,
approvedSnapshot: snapshot,
approvedTemplateVersionId: args.templateVersionId,
updatedAt: new Date(),
updatedBy: args.userId
})
.where(eq(crmQuotations.id, args.quotationId));
const existingAttachment = await db
.select()
.from(crmQuotationAttachments)
.where(
and(
eq(crmQuotationAttachments.quotationId, args.quotationId),
eq(crmQuotationAttachments.organizationId, args.organizationId),
eq(crmQuotationAttachments.filePath, publicPath),
isNull(crmQuotationAttachments.deletedAt)
)
)
.then((rows) => rows[0] ?? null);
if (existingAttachment) {
await db
.update(crmQuotationAttachments)
.set({
fileName: safeFileName,
originalFileName: safeFileName,
filePath: publicPath,
fileSize: fileStats.size,
fileType: 'application/pdf',
description: 'Approved quotation PDF',
uploadedAt: new Date(),
uploadedBy: args.userId,
updatedAt: new Date()
})
.where(eq(crmQuotationAttachments.id, existingAttachment.id));
} else {
await db.insert(crmQuotationAttachments).values({
id: crypto.randomUUID(),
organizationId: args.organizationId,
quotationId: args.quotationId,
fileName: safeFileName,
originalFileName: safeFileName,
filePath: publicPath,
fileSize: fileStats.size,
fileType: 'application/pdf',
description: 'Approved quotation PDF',
uploadedBy: args.userId
});
}
return {
publicPath,
fileSize: fileStats.size,
snapshot
};
}
export async function generateQuotationPdf(
quotationId: string,
organizationId: string
): Promise<GeneratedQuotationPdf> {
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
const buffer = await generatePdfFromTemplate({
template: preview.template.version.schemaJson as Template,
inputs: [preview.templateInput]
});
return {
buffer,
fileName: `${preview.documentData.quotation.code}.pdf`,
templateVersionId: preview.template.version.id
};
}
export async function generateQuotationPreviewPdf(
quotationId: string,
organizationId: string
) {
return generateQuotationPdf(quotationId, organizationId);
}
export async function generateApprovedQuotationPdf(
quotationId: string,
organizationId: string,
userId: string
): Promise<GeneratedQuotationPdf> {
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
if (preview.documentData.quotation.statusCode !== 'approved') {
throw new AuthError('Only approved quotations can generate approved PDF', 400);
}
const buffer = await generatePdfFromTemplate({
template: preview.template.version.schemaJson as Template,
inputs: [preview.templateInput]
});
const fileName = `${preview.documentData.quotation.code}-approved.pdf`;
const artifact = await persistApprovedQuotationArtifact({
quotationId,
organizationId,
userId,
fileName,
buffer,
templateVersionId: preview.template.version.id
});
return {
buffer,
fileName,
templateVersionId: preview.template.version.id,
approvedPdfUrl: artifact.publicPath
};
}
export async function getStoredApprovedQuotationPdf(
quotationId: string,
organizationId: string
): Promise<GeneratedQuotationPdf> {
const quotation = await getQuotationDetail(quotationId, organizationId);
if (!quotation.approvedPdfUrl) {
throw new AuthError('Approved PDF has not been generated yet', 404);
}
const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
const absolutePath = path.join(process.cwd(), 'public', relativePath);
const buffer = await readFile(absolutePath);
return {
buffer,
fileName: `${quotation.code}-approved.pdf`,
templateVersionId: quotation.approvedTemplateVersionId ?? ''
};
}
export async function getOrganizationGeneratedPdfDirectory(organizationId: string) {
const [organization] = await db
.select()
.from(organizations)
.where(eq(organizations.id, organizationId))
.limit(1);
if (!organization) {
throw new AuthError('Organization not found', 404);
}
return ensureGeneratedDirectory(organizationId);
}