task-h.1 complate

This commit is contained in:
phaichayon
2026-06-16 14:25:26 +07:00
parent edee45375e
commit 9ae41e4f2c
14 changed files with 2238 additions and 201 deletions

View File

@@ -30,9 +30,6 @@ export async function GET(_request: NextRequest, { params }: Params) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approved PDF' }, { status: 500 });
}
}
@@ -70,9 +67,6 @@ export async function POST(_request: NextRequest, { params }: Params) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to generate approved PDF' }, { status: 500 });
}
}

View File

@@ -25,9 +25,6 @@ export async function GET(_request: NextRequest, { params }: Params) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to generate quotation PDF' }, { status: 500 });
}
}

View File

@@ -25,9 +25,6 @@ export async function GET(_request: NextRequest, { params }: Params) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to generate preview PDF' }, { status: 500 });
}
}

View File

@@ -35,7 +35,8 @@ const DOCUMENT_OPTION_CATEGORIES = {
discountType: 'crm_discount_type',
unit: 'crm_unit',
customerRole: 'crm_quotation_customer_role',
productType: 'crm_product_type'
productType: 'crm_product_type',
topicType: 'crm_quotation_topic_type'
} as const;
function toRevisionLabel(revision: number) {
@@ -76,6 +77,72 @@ function findOptionCode(
return id ? options.find((option) => option.id === id)?.code ?? null : null;
}
function extractSinglePlaceholder(value: string) {
const matches = [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
const uniqueMatches = [...new Set(matches)];
return uniqueMatches.length === 1 ? uniqueMatches[0] : null;
}
function normalizePdfmeTemplateInput(
schemaJson: unknown,
templateInput: Record<string, unknown>
) {
const pages =
schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson
? (schemaJson as { schemas?: unknown }).schemas
: null;
if (!Array.isArray(pages)) {
return templateInput;
}
const normalized = { ...templateInput };
for (const page of pages) {
if (!Array.isArray(page)) {
continue;
}
for (const field of page) {
if (!field || typeof field !== 'object') {
continue;
}
const tableField = field as { type?: unknown; content?: unknown };
if (tableField.type !== 'table' || typeof tableField.content !== 'string') {
continue;
}
const placeholderKey = extractSinglePlaceholder(tableField.content);
if (!placeholderKey) {
continue;
}
const value = normalized[placeholderKey];
if (typeof value !== 'string') {
continue;
}
const lines = value
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (!lines.length) {
continue;
}
normalized[placeholderKey] = JSON.stringify(lines.map((line) => [line]));
}
}
return normalized;
}
export async function buildQuotationDocumentData(
quotationId: string,
organizationId: string
@@ -95,6 +162,7 @@ export async function buildQuotationDocumentData(
unitOptions,
customerRoleOptions,
productTypeOptions,
topicTypeOptions,
approvalRequests
] = await Promise.all([
db
@@ -114,6 +182,7 @@ export async function buildQuotationDocumentData(
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }),
listApprovalRequests(organizationId, { entityType: 'quotation', entityId: quotationId, limit: 20 })
]);
@@ -180,6 +249,7 @@ export async function buildQuotationDocumentData(
const relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item]));
const statusCode = findOptionCode(statusOptions, quotation.status);
const statusLabel = findOptionLabel(statusOptions, quotation.status);
const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code]));
const approvalApprovers: QuotationDocumentApprovalStep[] =
approvalDetail?.timeline
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
@@ -284,16 +354,19 @@ export async function buildQuotationDocumentData(
})),
topics: {
scope: topics
.filter((item) => item.topicType === 'scope')
.filter((item) => topicCodeById.get(item.topicType) === 'scope')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
exclusion: topics
.filter((item) => item.topicType === 'exclusion')
.filter((item) => topicCodeById.get(item.topicType) === 'exclusion')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
payment: topics
.filter((item) => item.topicType === 'payment')
.filter((item) => topicCodeById.get(item.topicType) === 'payment')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
other: topics
.filter((item) => !['scope', 'exclusion', 'payment'].includes(item.topicType))
.filter((item) => {
const topicCode = topicCodeById.get(item.topicType);
return !['scope', 'exclusion', 'payment'].includes(topicCode ?? '');
})
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) }))
},
approval: {
@@ -330,9 +403,14 @@ export async function getQuotationDocumentPreviewData(
fileType: 'pdfme'
});
const mappings = await resolveTemplateMappings(template.version.id, organizationId);
const templateInput = mapDocumentDataToTemplateInput(
documentData as unknown as Record<string, unknown>,
mappings
if (!mappings.length) {
throw new AuthError('Document template mappings are not configured for this template version', 409);
}
const templateInput = normalizePdfmeTemplateInput(
template.version.schemaJson,
mapDocumentDataToTemplateInput(documentData as unknown as Record<string, unknown>, mappings)
);
return {

View File

@@ -350,6 +350,7 @@ async function syncEntityStatusFromApproval(
}
if (approvalStatus === APPROVAL_REQUEST_STATUSES.approved) {
// TODO(task-h.1): add an async hook/job to auto-generate approved PDFs after final approval.
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'approved');
return;
}

View File

@@ -107,13 +107,17 @@ export async function generatePdfFromTemplate(input: GeneratePdfFromTemplateInpu
error instanceof Error &&
(error.message.includes('Font "') || error.message.includes('fontKitFont.layout'))
) {
pdf = await generate({
...generatorInput,
template: normalizeTemplateFonts(input.template),
options: undefined
});
try {
pdf = await generate({
...generatorInput,
template: normalizeTemplateFonts(input.template),
options: undefined
});
} catch {
throw new AuthError('Unable to generate PDF from the current template', 500);
}
} else {
throw error;
throw new AuthError('Unable to generate PDF from the current template', 500);
}
}
@@ -155,8 +159,15 @@ async function persistApprovedQuotationArtifact(args: {
const absolutePath = path.join(directory, safeFileName);
const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`;
await writeFile(absolutePath, args.buffer);
const fileStats = await stat(absolutePath);
try {
await writeFile(absolutePath, args.buffer);
} catch {
throw new AuthError('Unable to persist approved PDF to local storage', 500);
}
const fileStats = await stat(absolutePath).catch(() => {
throw new AuthError('Approved PDF was written but could not be verified on disk', 500);
});
await db
.update(crmQuotations)
@@ -289,7 +300,9 @@ export async function getStoredApprovedQuotationPdf(
const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
const absolutePath = path.join(process.cwd(), 'public', relativePath);
const buffer = await readFile(absolutePath);
const buffer = await readFile(absolutePath).catch(() => {
throw new AuthError('Approved PDF file is missing from local storage', 404);
});
return {
buffer,