task p-4.5

This commit is contained in:
phaichayon
2026-06-29 16:07:03 +07:00
parent aa73b9aed2
commit a1cac84e2b
19 changed files with 4812 additions and 413 deletions

View File

@@ -1,22 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Template } from '@pdfme/common';
import type { ResolvedDocumentTemplate } from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types';
import { buildQuotationPdfRuntime } from './quotation-pdf-runtime';
import { resolveTemplatePages } from './page-resolver';
import { assembleTemplate } from './template-assembler';
import type { ResolvedTemplate } from './runtime-contracts';
import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter';
function createField(name: string, y: number, height = 7) {
function createField(name: string, y: number, height = 7, type = 'text', content?: string) {
return {
name,
type: 'text',
type,
position: { x: 10, y },
width: 100,
height,
content: `{${name}}`,
content: content ?? `{${name}}`,
};
}
function createMarker(role: string) {
return {
name: `__section_role__${role}`,
type: 'text',
position: { x: 1, y: 1 },
width: 8,
height: 2,
content: role,
readOnly: true,
opacity: 0,
};
}
@@ -26,12 +38,54 @@ function createLegacyTemplate(): Template {
schemas: [
[
createField('customer_name', 20),
createField('quotation_price_data', 40, 20),
createField('quotation_price_data', 40, 20, 'table', '[["Price (Exclude VAT)","-","","THB"]]'),
createField('quotation_code_data', 70),
],
[
createField('topic', 35),
createField('data_topic', 45, 7),
createField('topic', 35, 10, 'table', '[["Topic:"]]'),
createField('data_topic', 45, 10, 'table', '[["{item_topic}"]]'),
createField('Please_do_not', 225),
createField('yours_faithfuly', 232),
createField('app1', 240),
createField('app1_position', 247),
createField('app2', 240),
createField('app2_position', 247),
createField('app3', 240),
createField('app3_position', 247),
],
],
} as unknown as Template;
}
function createProductTemplate(): Template {
return {
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [
[
createMarker('customer'),
createField('customer_name', 20),
createField('quotation_code_data', 30),
],
[
createMarker('product_items'),
{
name: 'items_table',
type: 'table',
position: { x: 10, y: 40 },
width: 180,
height: 120,
content: '[["1","Sample","1","EA","THB 1.00","THB 0.00","THB 1.00"]]',
showHead: true,
repeatHead: true,
head: ['Item', 'Description', 'Qty', 'Unit', 'Unit Price', 'Discount', 'Total'],
headWidthPercentages: [8, 34, 8, 10, 14, 12, 14],
},
],
[
createMarker('topics'),
createMarker('signature'),
createField('topic', 35, 10, 'table', '[["Topic:"]]'),
createField('data_topic', 45, 10, 'table', '[["{item_topic}"]]'),
createField('Please_do_not', 225),
createField('yours_faithfuly', 232),
createField('app1', 240),
@@ -53,7 +107,7 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
documentType: 'quotation',
productType: 'default',
fileType: 'pdfme',
templateName: 'Legacy quotation',
templateName: 'Quotation template',
description: null,
isDefault: true,
isActive: true,
@@ -64,7 +118,7 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
updatedBy: 'system',
},
version: {
id: 'version-1',
id: 'template-version-1',
organizationId: 'org-1',
templateId: 'template-1',
version: '1.0',
@@ -80,7 +134,26 @@ function createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTempl
};
}
function createDocumentData(topicCount: number): QuotationDocumentData {
function createItem(index: number): QuotationDocumentData['items'][number] {
return {
id: `item-${index}`,
itemNumber: index + 1,
productTypeCode: null,
productTypeLabel: null,
description: `Product item ${index + 1}`,
quantity: 1,
unitCode: 'EA',
unitLabel: 'EA',
unitPrice: 1200 + index,
discount: index * 10,
discountTypeCode: null,
taxRate: 0,
totalPrice: 1200 + index - index * 10,
notes: null,
};
}
function createDocumentData(topicCount: number, itemCount = 0): QuotationDocumentData {
return {
company: {
id: 'company-1',
@@ -128,7 +201,7 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
totalAmount: 0,
notes: null,
},
items: [],
items: Array.from({ length: itemCount }, (_, index) => createItem(index)),
quotationCustomers: [],
topics: {
scope: [],
@@ -157,7 +230,7 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
location: 'Location',
},
quotation_date: '2026-06-29',
quotation_price: '0.00',
quotation_price: 'THB 0.00',
quotation_price_data: [],
exclusion_data: [],
topic_inputs: {},
@@ -172,24 +245,15 @@ function createDocumentData(topicCount: number): QuotationDocumentData {
approvers: [],
},
signatures: {
preparedBy: {
name: 'Prepared',
position: 'Sales',
},
approvedBy: {
name: 'Approved',
position: 'Manager',
},
authorizedBy: {
name: 'Authorized',
position: 'Director',
},
preparedBy: { name: 'Prepared', position: 'Sales' },
approvedBy: { name: 'Approved', position: 'Manager' },
authorizedBy: { name: 'Authorized', position: 'Director' },
},
watermarkStatus: null,
} as QuotationDocumentData;
};
}
test('legacy template page roles are inferred without page-index assumptions', () => {
test('legacy template page roles are still inferred without page-index assumptions', () => {
const schema = createLegacyTemplate();
const template = createResolvedDocumentTemplate(schema);
const runtimeTemplate: ResolvedTemplate = {
@@ -203,12 +267,10 @@ test('legacy template page roles are inferred without page-index assumptions', (
assert.equal(resolved.byRole.customer?.[0]?.pageIndex, 0);
assert.equal(resolved.byRole.topics?.[0]?.pageIndex, 1);
assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 1);
assert.ok(
compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'),
);
assert.ok(compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'));
});
test('runtime keeps signature block on the last generated topic page', async () => {
test('runtime keeps signature block on last generated topic page for legacy templates', async () => {
const schema = createLegacyTemplate();
const template = createResolvedDocumentTemplate(schema);
const runtime = await buildQuotationPdfRuntime({
@@ -221,9 +283,7 @@ test('runtime keeps signature block on the last generated topic page', async ()
});
assert.ok(runtime.assembled.template.schemas.length > 2);
assert.ok(
Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')),
);
assert.ok(Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')));
const topicPages = runtime.assembled.template.schemas.slice(1);
const lastPageNames = new Set(
@@ -257,38 +317,50 @@ test('runtime restores legacy static labels into final template input', async ()
assert.equal(runtime.assembled.templateInput.att_label, 'Att');
assert.equal(runtime.assembled.templateInput.project_label, 'Project');
assert.equal(runtime.assembled.templateInput.site_label, 'Location');
assert.equal(runtime.assembled.templateInput.quotation_date, '2026-06-29');
assert.equal(runtime.assembled.templateInput.quotation_date_data, '2026-06-29');
});
test('template assembler does not overwrite populated values with nullish or blank patches', () => {
const assembled = assembleTemplate({
template: createLegacyTemplate(),
test('explicit section markers resolve product item page and runtime injects items_table', async () => {
const schema = createProductTemplate();
const template = createResolvedDocumentTemplate(schema);
const runtimeTemplate: ResolvedTemplate = {
documentTemplate: template,
schema,
};
const compatible = adaptTemplateForSectionRuntime(runtimeTemplate);
const resolved = resolveTemplatePages(compatible);
assert.equal(resolved.byRole.customer?.[0]?.pageIndex, 0);
assert.equal(resolved.byRole.product_items?.[0]?.pageIndex, 1);
assert.equal(resolved.byRole.topics?.[0]?.pageIndex, 2);
assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 2);
assert.equal(compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'), false);
const runtime = await buildQuotationPdfRuntime({
documentData: createDocumentData(4, 4),
template,
mappings: [],
templateInput: {
tel_label: 'Tel',
customer_name: 'Customer',
},
staticInputPatch: {
tel_label: '',
email_label: 'Email',
},
sections: [
{
role: 'topics',
enabled: true,
rendered: false,
anchorPageIndex: 1,
pages: [],
templateInputPatch: {
tel_label: undefined,
project_label: 'Project',
},
issues: [],
},
],
});
assert.equal(assembled.templateInput.tel_label, 'Tel');
assert.equal(assembled.templateInput.email_label, 'Email');
assert.equal(assembled.templateInput.project_label, 'Project');
assert.ok(runtime.assembled.template.schemas.length >= 3);
const productPageNames = new Set(
runtime.assembled.template.schemas[1]?.map((field) =>
String((field as { name?: string }).name ?? ''),
) ?? [],
);
assert.ok(productPageNames.has('items_table'));
assert.ok(Array.isArray(runtime.assembled.templateInput.items_table));
assert.equal((runtime.assembled.templateInput.items_table as string[][]).length, 4);
assert.deepEqual((runtime.assembled.templateInput.items_table as string[][])[0], [
'1',
'Product item 1',
'1',
'EA',
'THB 1,200.00',
'THB 0.00',
'THB 1,200.00',
]);
});

View File

@@ -1,4 +1,8 @@
import type { DocumentTemplateMappingWithColumns, ResolvedDocumentTemplate } from '@/features/foundation/document-template/types';
import type { Template } from '@pdfme/common';
import type {
DocumentTemplateMappingWithColumns,
ResolvedDocumentTemplate,
} from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types';
import { buildPdfTopicPages } from './pdf-topic-engine';
import { resolveTemplatePages } from './page-resolver';
@@ -21,6 +25,29 @@ function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function buildMissingPageSection<TRole extends BuiltSection['role']>(args: {
role: TRole;
enabled: boolean;
required: boolean;
message: string;
}): BuiltSection<TRole> {
return {
role: args.role,
enabled: args.enabled,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
issues: [
{
code: 'MISSING_MARKER',
severity: args.required ? 'error' : 'warning',
message: args.message,
},
],
};
}
export function createCustomerSectionBuilder(): SectionBuilder {
return {
role: 'customer',
@@ -29,21 +56,12 @@ export function createCustomerSectionBuilder(): SectionBuilder {
const policy = context.policyBySection.get('customer');
if (!page) {
return {
return buildMissingPageSection({
role: 'customer',
enabled: policy?.enabled ?? false,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
issues: [
{
code: 'MISSING_MARKER',
severity: policy?.required ? 'error' : 'warning',
message: 'Customer section page could not be resolved.',
},
],
};
required: policy?.required ?? true,
message: 'Customer section page could not be resolved.',
});
}
return {
@@ -63,18 +81,45 @@ export function createProductItemSectionBuilder(): SectionBuilder {
return {
role: 'product_items',
build(context: RenderContext): ProductItemSection {
const page = context.pages.byRole.product_items?.[0] ?? null;
const policy = context.policyBySection.get('product_items');
const built = buildProductItemSectionModel({
items: context.documentData.items,
currencyCode: context.documentData.quotation.currencyCode,
});
if (!page) {
return {
...buildMissingPageSection({
role: 'product_items',
enabled: policy?.enabled ?? false,
required: policy?.required ?? false,
message: 'Product item section page could not be resolved.',
}),
rows: built.rows,
tableModel: built.tableModel,
pagination: built.pagination,
issues: [
...built.issues,
...buildMissingPageSection({
role: 'product_items',
enabled: policy?.enabled ?? false,
required: policy?.required ?? false,
message: 'Product item section page could not be resolved.',
}).issues,
],
};
}
return {
role: 'product_items',
enabled: true,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
rendered: true,
anchorPageIndex: page.pageIndex,
pages: [cloneTemplate(page.schema)],
templateInputPatch: {
items_table: built.tableModel.rows,
},
issues: built.issues,
rows: built.rows,
tableModel: built.tableModel,
@@ -92,21 +137,12 @@ export function createTopicSectionBuilder(): SectionBuilder {
const policy = context.policyBySection.get('topics');
if (!page) {
return {
return buildMissingPageSection({
role: 'topics',
enabled: policy?.enabled ?? false,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
issues: [
{
code: 'MISSING_MARKER',
severity: policy?.required ? 'error' : 'warning',
message: 'Topic section page could not be resolved.',
},
],
};
required: policy?.required ?? true,
message: 'Topic section page could not be resolved.',
});
}
const built = buildPdfTopicPages(page.schema, context.documentData.topics.all, {
@@ -137,7 +173,7 @@ export function getQuotationSectionBuilders(): SectionBuilder[] {
function createResolvedTemplate(template: ResolvedDocumentTemplate): ResolvedTemplate {
return {
documentTemplate: template,
schema: cloneTemplate(template.version.schemaJson as import('@pdfme/common').Template),
schema: cloneTemplate(template.version.schemaJson as Template),
};
}
@@ -154,7 +190,10 @@ export async function buildQuotationPdfRuntime(args: {
const resolvedTemplate = createResolvedTemplate(args.template);
const compatibleTemplate = adaptTemplateForSectionRuntime(resolvedTemplate);
const pages = resolveTemplatePages(compatibleTemplate);
const policies = resolveRenderPolicies();
const policies = resolveRenderPolicies({
pages,
documentData: args.documentData,
});
const staticInputPatch = resolveQuotationStaticTemplateInputs(args.documentData);
const context = createRenderContext({
documentData: args.documentData,
@@ -172,7 +211,9 @@ export async function buildQuotationPdfRuntime(args: {
sections: composed.sections,
});
const issues = [...pages.issues, ...composed.issues, ...assembled.issues];
const topicSection = composed.sections.find((section) => section.role === 'topics');
const topicSection = composed.sections.find(
(section): section is BuiltSection<'topics'> => section.role === 'topics',
);
if (issues.some((issue) => issue.severity === 'error')) {
throw new Error(createRuntimeErrorMessage(), { cause: issues });

View File

@@ -10,7 +10,10 @@ import type {
} from './runtime-contracts';
import { SECTION_ORDER } from './runtime-contracts';
function buildDefaultPolicy(section: SectionRole): RenderPolicy {
function buildDefaultPolicy(
section: SectionRole,
args?: { pages?: ResolvedPages; documentData?: QuotationDocumentData },
): RenderPolicy {
switch (section) {
case 'customer':
return {
@@ -19,6 +22,13 @@ function buildDefaultPolicy(section: SectionRole): RenderPolicy {
required: true,
visibleWhenEmpty: true,
};
case 'product_items':
return {
section,
enabled: Boolean(args?.pages?.byRole.product_items?.length),
required: false,
visibleWhenEmpty: true,
};
case 'topics':
return {
section,
@@ -36,8 +46,11 @@ function buildDefaultPolicy(section: SectionRole): RenderPolicy {
}
}
export function resolveRenderPolicies(): RenderPolicy[] {
return SECTION_ORDER.map((section) => buildDefaultPolicy(section));
export function resolveRenderPolicies(args?: {
pages?: ResolvedPages;
documentData?: QuotationDocumentData;
}): RenderPolicy[] {
return SECTION_ORDER.map((section) => buildDefaultPolicy(section, args));
}
export function createRenderContext(args: {

View File

@@ -7,7 +7,7 @@ import type {
SectionRole,
} from './runtime-contracts';
const EXPLICIT_ROLE_PREFIX = '__page_role__';
const EXPLICIT_ROLE_PREFIXES = ['__section_role__', '__page_role__'] as const;
const LEGACY_MARKERS: Record<Exclude<SectionRole, 'unknown'>, string[]> = {
customer: ['customer_name', 'quotation_price_data', 'quotation_code_data'],
@@ -48,7 +48,7 @@ function isTemplatePageArray(
function getFieldNames(page: Template['schemas'][number]): string[] {
return page
.map((field) => {
if (typeof field !== 'object' || field === null || !('name' in field)) {
if (!field || typeof field !== 'object' || !('name' in field)) {
return '';
}
@@ -57,60 +57,8 @@ function getFieldNames(page: Template['schemas'][number]): string[] {
.filter(Boolean);
}
function getExplicitRole(page: Template['schemas'][number]): SectionRole | null {
for (const field of page) {
if (!field || typeof field !== 'object') {
continue;
}
const name =
'name' in field && typeof field.name === 'string' ? field.name : null;
const content =
'content' in field && typeof field.content === 'string'
? field.content.trim()
: null;
const byName = name?.startsWith(EXPLICIT_ROLE_PREFIX)
? name.slice(EXPLICIT_ROLE_PREFIX.length)
: null;
const candidate = byName ?? content;
if (candidate && isSectionRole(candidate)) {
return candidate;
}
}
return null;
}
function inferLegacyRoles(page: Template['schemas'][number]): SectionRole[] {
const names = new Set(getFieldNames(page));
const inferred: SectionRole[] = [];
for (const [role, markers] of Object.entries(LEGACY_MARKERS) as Array<
[Exclude<SectionRole, 'unknown'>, string[]]
>) {
if (markers.every((marker) => names.has(marker))) {
inferred.push(role);
}
}
if (!inferred.length && names.has('topic')) {
inferred.push('topics');
}
if (
!inferred.includes('signature') &&
['app1', 'app2', 'app3'].some((name) => names.has(name))
) {
inferred.push('signature');
}
return inferred;
}
function isSectionRole(value: string): value is SectionRole {
return (
function isSectionRole(value: string | null | undefined): value is SectionRole {
return value !== undefined && value !== null && (
value === 'customer' ||
value === 'product_items' ||
value === 'topics' ||
@@ -124,11 +72,56 @@ function isSectionRole(value: string): value is SectionRole {
);
}
function getExplicitRoles(page: Template['schemas'][number]): SectionRole[] {
const roles = new Set<SectionRole>();
for (const field of page) {
if (!field || typeof field !== 'object') {
continue;
}
const name = 'name' in field && typeof field.name === 'string' ? field.name : null;
const content =
'content' in field && typeof field.content === 'string'
? field.content.trim()
: null;
for (const prefix of EXPLICIT_ROLE_PREFIXES) {
const byName = name?.startsWith(prefix) ? name.slice(prefix.length) : null;
if (isSectionRole(byName)) {
roles.add(byName);
}
}
if (isSectionRole(content)) {
roles.add(content);
}
}
return [...roles];
}
function inferLegacyRoles(page: Template['schemas'][number]): SectionRole[] {
const fieldNames = new Set(getFieldNames(page));
const roles: SectionRole[] = [];
for (const [role, markers] of Object.entries(LEGACY_MARKERS) as Array<
[Exclude<SectionRole, 'unknown'>, string[]]
>) {
if (markers.every((marker) => fieldNames.has(marker))) {
roles.push(role);
}
}
return roles;
}
export function adaptTemplateForSectionRuntime(
template: ResolvedTemplate,
): CompatibleTemplate {
const issues: RuntimeIssue[] = [];
const schemaPages = template.schema.schemas;
const issues: RuntimeIssue[] = [];
if (!Array.isArray(schemaPages)) {
return {
@@ -159,15 +152,18 @@ export function adaptTemplateForSectionRuntime(
continue;
}
const explicitRole = getExplicitRole(rawPage);
const explicitRoles = getExplicitRoles(rawPage);
const inferredRoles = inferLegacyRoles(rawPage);
const primaryRole = explicitRole ?? inferredRoles[0] ?? 'unknown';
const primaryRole = explicitRoles[0] ?? inferredRoles[0] ?? 'unknown';
const roles = Array.from(
new Set([primaryRole, ...inferredRoles].filter(isSectionRole)),
new Set(
[primaryRole, ...explicitRoles, ...inferredRoles].filter(isSectionRole),
),
);
let strategy: CompatibleTemplatePage['strategy'] = 'fallback';
if (explicitRole) {
if (explicitRoles.length > 0) {
strategy = 'explicit_marker';
} else if (inferredRoles.length > 0) {
strategy = 'legacy_inference';
@@ -175,8 +171,7 @@ export function adaptTemplateForSectionRuntime(
createIssue({
code: 'LEGACY_COMPAT_MODE',
severity: 'warning',
message:
'PDF template page role was inferred from legacy field markers.',
message: 'PDF template page role inferred from legacy field markers.',
details: { pageIndex, roles },
}),
);