task-h
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
313
src/features/foundation/pdf-generator/server/service.ts
Normal file
313
src/features/foundation/pdf-generator/server/service.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user