taks-p.4.3

This commit is contained in:
phaichayon
2026-06-29 11:18:08 +07:00
parent 2540d52670
commit 0c0450b152
20 changed files with 4900 additions and 505 deletions

View File

@@ -0,0 +1,52 @@
import type {
CompatibleTemplate,
ResolvedPage,
ResolvedPages,
RuntimeIssue,
SectionRole,
} from './runtime-contracts';
import { SINGLETON_SECTION_ROLES } from './runtime-contracts';
function createByRoleMap(): Partial<Record<SectionRole, ResolvedPage[]>> {
return {};
}
export function resolveTemplatePages(
compatibleTemplate: CompatibleTemplate,
): ResolvedPages {
const byRole = createByRoleMap();
const issues: RuntimeIssue[] = [...compatibleTemplate.issues];
const all: ResolvedPage[] = compatibleTemplate.pages.map((page) => ({ ...page }));
for (const page of all) {
for (const role of page.roles) {
const pages = byRole[role] ?? [];
pages.push(page);
byRole[role] = pages;
}
}
for (const [role, pages] of Object.entries(byRole) as Array<
[SectionRole, ResolvedPage[]]
>) {
if (!SINGLETON_SECTION_ROLES.has(role) || pages.length <= 1) {
continue;
}
issues.push({
code: 'DUPLICATE_MARKER',
severity: 'error',
message: 'PDF template resolves multiple singleton section pages.',
details: {
role,
pageIndexes: pages.map((page) => page.pageIndex),
},
});
}
return {
all,
byRole,
issues,
};
}

View File

@@ -1,6 +1,7 @@
import type { Template } from '@pdfme/common';
import { formatTopicItems } from './pdfme-transforms';
import type { PdfTopic, PdfTopicEngineOptions } from './pdf-topic.type';
import type { RuntimeIssue } from './runtime-contracts';
type TemplateSchema = {
name?: string;
@@ -13,6 +14,7 @@ type TemplateSchema = {
const DEFAULT_TOPIC_ENGINE_OPTIONS: Required<PdfTopicEngineOptions> = {
targetPageIndex: 1,
basePageIndex: 1,
pageStartY: 35,
pageBottomY: 250,
topicSpacing: 10,
@@ -24,81 +26,117 @@ const DEFAULT_TOPIC_ENGINE_OPTIONS: Required<PdfTopicEngineOptions> = {
'app2',
'app2_position',
'app3',
'app3_position'
'app3_position',
],
topicTemplateName: 'topic',
topicDataTemplateName: 'data_topic',
rowHeight: 7,
keepTogetherMinSpace: 60
keepTogetherMinSpace: 60,
};
function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function getFieldY(field: TemplateSchema) {
function getFieldY(field: TemplateSchema): number {
return typeof field.position?.y === 'number' ? field.position.y : 0;
}
function getFieldBottom(field: TemplateSchema) {
function getFieldBottom(field: TemplateSchema): number {
return getFieldY(field) + (typeof field.height === 'number' ? field.height : 0);
}
function setFieldY(field: TemplateSchema, y: number) {
field.position = {
...(field.position ?? {}),
y
};
function setFieldY(field: TemplateSchema, y: number): void {
field.position = { ...(field.position ?? {}), y };
}
function sortTopics(topics: PdfTopic[]) {
function sortTopics(topics: PdfTopic[]): PdfTopic[] {
return [...topics].sort((left, right) => {
const sortDelta = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
const sortDelta =
(left.sortOrder ?? Number.MAX_SAFE_INTEGER) -
(right.sortOrder ?? Number.MAX_SAFE_INTEGER);
if (sortDelta !== 0) {
return sortDelta;
}
return String(left.id ?? left.topicType).localeCompare(String(right.id ?? right.topicType));
return String(left.id ?? left.topicType).localeCompare(
String(right.id ?? right.topicType),
);
});
}
export function buildPdfTopicTemplate(
template: Template,
topics: PdfTopic[],
options?: PdfTopicEngineOptions
): {
template: Template;
topicInputs: Record<string, string[][]>;
} {
const resolvedOptions = { ...DEFAULT_TOPIC_ENGINE_OPTIONS, ...options };
const nextTemplate = cloneTemplate(template);
const pages = Array.isArray(nextTemplate.schemas) ? [...nextTemplate.schemas] : [];
const targetPage = pages[resolvedOptions.targetPageIndex];
function resolveOptions(
options?: PdfTopicEngineOptions,
): Required<PdfTopicEngineOptions> {
const basePageIndex =
options?.basePageIndex ?? options?.targetPageIndex ?? DEFAULT_TOPIC_ENGINE_OPTIONS.basePageIndex;
if (!Array.isArray(targetPage)) {
return { template: nextTemplate, topicInputs: {} };
return {
...DEFAULT_TOPIC_ENGINE_OPTIONS,
...options,
basePageIndex,
targetPageIndex: basePageIndex,
};
}
export function buildPdfTopicPages(
basePage: Template['schemas'][number] | undefined,
topics: PdfTopic[],
options?: PdfTopicEngineOptions,
): {
pages: Template['schemas'];
topicInputs: Record<string, string[][]>;
issues: RuntimeIssue[];
} {
const resolvedOptions = resolveOptions(options);
const issues: RuntimeIssue[] = [];
if (!Array.isArray(basePage)) {
issues.push({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'Topic engine could not find a valid base page schema.',
details: { basePageIndex: resolvedOptions.basePageIndex },
});
return { pages: [], topicInputs: {}, issues };
}
const topicTemplate = targetPage.find(
(field) => (field as TemplateSchema).name === resolvedOptions.topicTemplateName
) as TemplateSchema | undefined;
const topicDataTemplate = targetPage.find(
(field) => (field as TemplateSchema).name === resolvedOptions.topicDataTemplateName
) as TemplateSchema | undefined;
const pageFields = basePage.map((field) => cloneTemplate(field as TemplateSchema));
const topicTemplate =
pageFields.find(
(field) => field.name === resolvedOptions.topicTemplateName,
) ?? null;
const topicDataTemplate =
pageFields.find(
(field) => field.name === resolvedOptions.topicDataTemplateName,
) ?? null;
if (!topicTemplate || !topicDataTemplate) {
console.warn('[pdf-topic-engine] Missing topic or data_topic schema template');
return { template: nextTemplate, topicInputs: {} };
issues.push({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'Topic engine could not find topic/data_topic schema markers.',
details: {
basePageIndex: resolvedOptions.basePageIndex,
topicTemplateName: resolvedOptions.topicTemplateName,
topicDataTemplateName: resolvedOptions.topicDataTemplateName,
},
});
return { pages: [pageFields as Template['schemas'][number]], topicInputs: {}, issues };
}
const keepTogetherSet = new Set(resolvedOptions.keepTogetherNames);
const pageWithoutDynamicFields = targetPage.filter((field) => {
const name = (field as TemplateSchema).name;
const pageWithoutDynamicFields = pageFields.filter((field) => {
const name = field.name;
return (
name !== resolvedOptions.topicTemplateName && name !== resolvedOptions.topicDataTemplateName
name !== resolvedOptions.topicTemplateName &&
name !== resolvedOptions.topicDataTemplateName
);
}) as TemplateSchema[];
});
const keepTogetherFields = pageWithoutDynamicFields
.filter((field) => keepTogetherSet.has(field.name ?? ''))
.map((field) => cloneTemplate(field));
@@ -115,18 +153,22 @@ export function buildPdfTopicTemplate(
: resolvedOptions.pageBottomY;
const keepTogetherHeight =
keepTogetherFields.length > 0
? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) - keepTogetherTop
? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) -
keepTogetherTop
: 0;
let currentPageFields = staticFields.map((field) => cloneTemplate(field));
let currentY = Math.max(
resolvedOptions.pageStartY,
currentPageFields.length > 0
? Math.max(...currentPageFields.map((field) => getFieldBottom(field)), resolvedOptions.pageStartY)
: resolvedOptions.pageStartY
? Math.max(
...currentPageFields.map((field) => getFieldBottom(field)),
resolvedOptions.pageStartY,
)
: resolvedOptions.pageStartY,
);
const pushCurrentPage = () => {
const pushCurrentPage = (): void => {
generatedPages.push(currentPageFields as Template['schemas'][number]);
currentPageFields = [];
currentY = resolvedOptions.pageStartY;
@@ -134,17 +176,24 @@ export function buildPdfTopicTemplate(
for (const [topicIndex, topic] of sortedTopics.entries()) {
const topicRows = formatTopicItems({ items: topic.items ?? [] });
const topicKey = `topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
const itemKey = `item_topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
const topicKey = `topic_${resolvedOptions.basePageIndex}_${topicIndex}`;
const itemKey = `item_topic_${resolvedOptions.basePageIndex}_${topicIndex}`;
const labelHeight =
typeof topicTemplate.height === 'number' ? topicTemplate.height : resolvedOptions.rowHeight;
typeof topicTemplate.height === 'number'
? topicTemplate.height
: resolvedOptions.rowHeight;
const tableHeight = Math.max(
typeof topicDataTemplate.height === 'number' ? topicDataTemplate.height : resolvedOptions.rowHeight,
topicRows.length * resolvedOptions.rowHeight
typeof topicDataTemplate.height === 'number'
? topicDataTemplate.height
: resolvedOptions.rowHeight,
topicRows.length * resolvedOptions.rowHeight,
);
const blockHeight = topicToDataOffset + tableHeight;
if (currentY + blockHeight > resolvedOptions.pageBottomY && currentPageFields.length > 0) {
if (
currentY + blockHeight > resolvedOptions.pageBottomY &&
currentPageFields.length > 0
) {
pushCurrentPage();
}
@@ -160,10 +209,10 @@ export function buildPdfTopicTemplate(
topicDataField.height = tableHeight;
setFieldY(topicDataField, currentY + topicToDataOffset);
topicInputs[topicKey] = [[topic.topicType?.trim() || '-']];
topicInputs[itemKey] = topicRows;
currentPageFields.push(topicField, topicDataField);
currentY = getFieldY(topicDataField) + tableHeight + resolvedOptions.topicSpacing;
topicInputs[topicKey] = [[topic.topicType]];
topicInputs[itemKey] = topicRows;
currentY += blockHeight + resolvedOptions.topicSpacing;
}
if (keepTogetherFields.length > 0) {
@@ -189,16 +238,51 @@ export function buildPdfTopicTemplate(
generatedPages.push(currentPageFields as Template['schemas'][number]);
}
return {
pages:
generatedPages.length > 0
? generatedPages
: [pageWithoutDynamicFields as Template['schemas'][number]],
topicInputs,
issues,
};
}
export function buildPdfTopicTemplate(
template: Template,
topics: PdfTopic[],
options?: PdfTopicEngineOptions,
): {
template: Template;
topicInputs: Record<string, string[][]>;
issues: RuntimeIssue[];
} {
const resolvedOptions = resolveOptions(options);
const nextTemplate = cloneTemplate(template);
const pages = nextTemplate.schemas ?? [];
const built = buildPdfTopicPages(
pages[resolvedOptions.basePageIndex],
topics,
resolvedOptions,
);
if (built.pages.length === 0) {
return {
template: nextTemplate,
topicInputs: {},
issues: built.issues,
};
}
nextTemplate.schemas = [
...pages.slice(0, resolvedOptions.targetPageIndex),
...(generatedPages.length > 0
? generatedPages
: [pageWithoutDynamicFields as Template['schemas'][number]]),
...pages.slice(resolvedOptions.targetPageIndex + 1)
...pages.slice(0, resolvedOptions.basePageIndex),
...built.pages,
...pages.slice(resolvedOptions.basePageIndex + 1),
];
return {
template: nextTemplate,
topicInputs
topicInputs: built.topicInputs,
issues: built.issues,
};
}

View File

@@ -13,6 +13,7 @@ export type PdfTopic = {
export type PdfTopicEngineOptions = {
targetPageIndex?: number;
basePageIndex?: number;
pageStartY?: number;
pageBottomY?: number;
topicSpacing?: number;

View File

@@ -0,0 +1,240 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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 type { ResolvedTemplate } from './runtime-contracts';
import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter';
function createField(name: string, y: number, height = 7) {
return {
name,
type: 'text',
position: { x: 10, y },
width: 100,
height,
content: `{${name}}`,
};
}
function createLegacyTemplate(): Template {
return {
basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
schemas: [
[
createField('customer_name', 20),
createField('quotation_price_data', 40, 20),
createField('quotation_code_data', 70),
],
[
createField('topic', 35),
createField('data_topic', 45, 7),
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 createResolvedDocumentTemplate(schema: Template): ResolvedDocumentTemplate {
return {
template: {
id: 'template-1',
organizationId: 'org-1',
documentType: 'quotation',
productType: 'default',
fileType: 'pdfme',
templateName: 'Legacy quotation',
description: null,
isDefault: true,
isActive: true,
createdAt: '2026-06-29T00:00:00.000Z',
updatedAt: '2026-06-29T00:00:00.000Z',
deletedAt: null,
createdBy: 'system',
updatedBy: 'system',
},
version: {
id: 'version-1',
organizationId: 'org-1',
templateId: 'template-1',
version: '1.0',
filePath: null,
schemaJson: schema,
previewImageUrl: null,
isActive: true,
createdAt: '2026-06-29T00:00:00.000Z',
updatedAt: '2026-06-29T00:00:00.000Z',
deletedAt: null,
createdBy: 'system',
},
};
}
function createDocumentData(topicCount: number): QuotationDocumentData {
return {
company: {
id: 'company-1',
name: 'ALLA',
slug: 'alla',
imageUrl: null,
plan: 'pro',
},
branch: null,
customer: {
id: 'customer-1',
code: 'C-001',
name: 'Customer',
address: null,
phone: null,
email: null,
taxId: null,
},
contact: null,
quotation: {
id: 'quotation-1',
code: 'QT-TEST',
quotationDate: '2026-06-29',
validUntil: null,
projectName: null,
projectLocation: null,
attention: null,
reference: null,
revision: 0,
revisionLabel: 'Rev.0',
statusCode: 'approved',
statusLabel: 'Approved',
quotationTypeCode: null,
quotationTypeLabel: null,
currency: 'THB',
currencyCode: 'THB',
currencyLabel: 'Thai Baht',
exchangeRate: 1,
subtotal: 0,
discount: 0,
discountTypeCode: null,
discountTypeLabel: null,
taxRate: 0,
taxAmount: 0,
totalAmount: 0,
notes: null,
},
items: [],
quotationCustomers: [],
topics: {
scope: [],
exclusion: [],
payment: [],
warranty: [],
delivery: [],
other: [],
all: Array.from({ length: topicCount }, (_, index) => ({
id: `topic-${index}`,
topicType: `Topic ${index + 1}`,
sortOrder: index,
items: Array.from({ length: 6 }, (_, itemIndex) => ({
id: `${index}-${itemIndex}`,
content: `Topic ${index + 1} item ${itemIndex + 1}`,
sortOrder: itemIndex,
})),
})),
},
pdfme: {
labels: {
tel: 'Tel',
email: 'Email',
att: 'Att',
project: 'Project',
location: 'Location',
},
quotation_date: '2026-06-29',
quotation_price: '0.00',
quotation_price_data: [],
exclusion_data: [],
topic_inputs: {},
},
approval: {
requestId: null,
workflowName: null,
status: 'approved',
approvedAt: '2026-06-29T00:00:00.000Z',
currentStep: null,
currentStepRoleName: null,
approvers: [],
},
signatures: {
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', () => {
const schema = createLegacyTemplate();
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.topics?.[0]?.pageIndex, 1);
assert.equal(resolved.byRole.signature?.[0]?.pageIndex, 1);
assert.ok(
compatible.issues.some((issue) => issue.code === 'LEGACY_COMPAT_MODE'),
);
});
test('runtime keeps signature block on the last generated topic page', async () => {
const schema = createLegacyTemplate();
const template = createResolvedDocumentTemplate(schema);
const runtime = await buildQuotationPdfRuntime({
documentData: createDocumentData(12),
template,
mappings: [],
templateInput: {
customer_name: 'Customer',
},
});
assert.ok(runtime.assembled.template.schemas.length > 2);
assert.ok(
Object.keys(runtime.topicInputs).some((key) => key.startsWith('topic_1_')),
);
const topicPages = runtime.assembled.template.schemas.slice(1);
const lastPageNames = new Set(
topicPages.at(-1)?.map((field) => String((field as { name?: string }).name ?? '')) ?? [],
);
assert.ok(lastPageNames.has('app1'));
for (const page of topicPages.slice(0, -1)) {
const fieldNames = new Set(
page.map((field) => String((field as { name?: string }).name ?? '')),
);
assert.equal(fieldNames.has('app1'), false);
}
});

View File

@@ -0,0 +1,162 @@
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';
import { createRenderContext, resolveRenderPolicies } from './render-context';
import type {
BuiltSection,
QuotationPdfRuntimeResult,
RenderContext,
ResolvedTemplate,
SectionBuilder,
} from './runtime-contracts';
import { composeSections } from './section-composer';
import { adaptTemplateForSectionRuntime } from './template-compatibility-adapter';
import { assembleTemplate } from './template-assembler';
function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function createCustomerSectionBuilder(): SectionBuilder {
return {
role: 'customer',
build(context: RenderContext): BuiltSection<'customer'> {
const page = context.pages.byRole.customer?.[0] ?? null;
const policy = context.policyBySection.get('customer');
if (!page) {
return {
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.',
},
],
};
}
return {
role: 'customer',
enabled: true,
rendered: true,
anchorPageIndex: page.pageIndex,
pages: [cloneTemplate(page.schema)],
templateInputPatch: {},
issues: [],
};
},
};
}
function createTopicSectionBuilder(): SectionBuilder {
return {
role: 'topics',
build(context: RenderContext): BuiltSection<'topics'> {
const page = context.pages.byRole.topics?.[0] ?? null;
const policy = context.policyBySection.get('topics');
if (!page) {
return {
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.',
},
],
};
}
const built = buildPdfTopicPages(page.schema, context.documentData.topics.all, {
basePageIndex: page.pageIndex,
});
return {
role: 'topics',
enabled: true,
rendered: true,
anchorPageIndex: page.pageIndex,
pages: built.pages,
templateInputPatch: built.topicInputs,
issues: built.issues,
};
},
};
}
function getQuotationSectionBuilders(): SectionBuilder[] {
return [createCustomerSectionBuilder(), createTopicSectionBuilder()];
}
function createResolvedTemplate(
template: ResolvedDocumentTemplate,
): ResolvedTemplate {
return {
documentTemplate: template,
schema: cloneTemplate(template.version.schemaJson as Template),
};
}
function createRuntimeErrorMessage(): string {
return 'Quotation PDF runtime is invalid for the active document template.';
}
export async function buildQuotationPdfRuntime(args: {
documentData: QuotationDocumentData;
template: ResolvedDocumentTemplate;
mappings: DocumentTemplateMappingWithColumns[];
templateInput: Record<string, unknown>;
}): Promise<QuotationPdfRuntimeResult> {
const resolvedTemplate = createResolvedTemplate(args.template);
const compatibleTemplate = adaptTemplateForSectionRuntime(resolvedTemplate);
const pages = resolveTemplatePages(compatibleTemplate);
const policies = resolveRenderPolicies();
const context = createRenderContext({
documentData: args.documentData,
template: resolvedTemplate,
mappings: args.mappings,
pages,
policies,
issues: pages.issues,
});
const composed = await composeSections(context, getQuotationSectionBuilders());
const assembled = assembleTemplate({
template: resolvedTemplate.schema,
templateInput: args.templateInput,
sections: composed.sections,
});
const issues = [...pages.issues, ...composed.issues, ...assembled.issues];
const topicSection = composed.sections.find((section) => section.role === 'topics');
if (issues.some((issue) => issue.severity === 'error')) {
throw new Error(createRuntimeErrorMessage(), { cause: issues });
}
return {
assembled,
topicInputs: topicSection
? (topicSection.templateInputPatch as Record<string, string[][]>)
: {},
issues,
pages,
policies,
};
}

View File

@@ -0,0 +1,64 @@
import type { DocumentTemplateMappingWithColumns } from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types';
import type {
RenderContext,
RenderPolicy,
ResolvedPages,
ResolvedTemplate,
RuntimeIssue,
SectionRole,
} from './runtime-contracts';
import { SECTION_ORDER } from './runtime-contracts';
function buildDefaultPolicy(section: SectionRole): RenderPolicy {
switch (section) {
case 'customer':
return {
section,
enabled: true,
required: true,
visibleWhenEmpty: true,
};
case 'topics':
return {
section,
enabled: true,
required: true,
visibleWhenEmpty: true,
};
default:
return {
section,
enabled: false,
required: false,
visibleWhenEmpty: false,
};
}
}
export function resolveRenderPolicies(): RenderPolicy[] {
return SECTION_ORDER.map((section) => buildDefaultPolicy(section));
}
export function createRenderContext(args: {
documentData: QuotationDocumentData;
template: ResolvedTemplate;
mappings: DocumentTemplateMappingWithColumns[];
pages: ResolvedPages;
policies: RenderPolicy[];
issues?: RuntimeIssue[];
}): RenderContext {
const policyBySection = new Map<SectionRole, RenderPolicy>(
args.policies.map((policy) => [policy.section, policy]),
);
return {
documentData: args.documentData,
template: args.template,
mappings: args.mappings,
pages: args.pages,
policies: [...args.policies],
policyBySection,
issues: [...(args.issues ?? [])],
};
}

View File

@@ -0,0 +1,134 @@
import type { Template } from '@pdfme/common';
import type {
DocumentTemplateMappingWithColumns,
ResolvedDocumentTemplate,
} from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types';
export type SectionRole =
| 'customer'
| 'product_items'
| 'topics'
| 'conditions'
| 'signature'
| 'attachments'
| 'appendix'
| 'warranty'
| 'cover'
| 'unknown';
export type PageResolutionStrategy =
| 'explicit_marker'
| 'legacy_inference'
| 'fallback';
export type RuntimeIssueCode =
| 'MISSING_MARKER'
| 'DUPLICATE_MARKER'
| 'INVALID_TEMPLATE'
| 'MISSING_MAPPING'
| 'EMPTY_OPTIONAL_SECTION'
| 'EMPTY_REQUIRED_SECTION'
| 'LEGACY_COMPAT_MODE';
export interface RuntimeIssue {
code: RuntimeIssueCode;
severity: 'warning' | 'error';
message: string;
details?: Record<string, unknown>;
}
export interface ResolvedTemplate {
documentTemplate: ResolvedDocumentTemplate;
schema: Template;
}
export interface CompatibleTemplatePage {
pageIndex: number;
primaryRole: SectionRole;
roles: SectionRole[];
strategy: PageResolutionStrategy;
schema: Template['schemas'][number];
}
export interface CompatibleTemplate {
template: ResolvedTemplate;
pages: CompatibleTemplatePage[];
issues: RuntimeIssue[];
}
export interface ResolvedPage extends CompatibleTemplatePage {}
export interface ResolvedPages {
all: ResolvedPage[];
byRole: Partial<Record<SectionRole, ResolvedPage[]>>;
issues: RuntimeIssue[];
}
export interface RenderPolicy {
section: SectionRole;
enabled: boolean;
required: boolean;
visibleWhenEmpty: boolean;
}
export interface BuiltSection<TRole extends SectionRole = SectionRole> {
role: TRole;
enabled: boolean;
rendered: boolean;
anchorPageIndex: number | null;
pages: Template['schemas'];
templateInputPatch: Record<string, unknown>;
issues: RuntimeIssue[];
}
export interface RenderContext {
documentData: QuotationDocumentData;
template: ResolvedTemplate;
mappings: DocumentTemplateMappingWithColumns[];
pages: ResolvedPages;
policies: RenderPolicy[];
policyBySection: Map<SectionRole, RenderPolicy>;
issues: RuntimeIssue[];
}
export interface SectionBuilder {
role: SectionRole;
build(context: RenderContext): BuiltSection | Promise<BuiltSection>;
}
export interface AssembledTemplate {
template: Template;
templateInput: Record<string, unknown>;
issues: RuntimeIssue[];
}
export interface QuotationPdfRuntimeResult {
assembled: AssembledTemplate;
topicInputs: Record<string, string[][]>;
issues: RuntimeIssue[];
pages: ResolvedPages;
policies: RenderPolicy[];
}
export const SECTION_ORDER: SectionRole[] = [
'customer',
'product_items',
'topics',
'conditions',
'signature',
'attachments',
'appendix',
'warranty',
'cover',
];
export const SINGLETON_SECTION_ROLES = new Set<SectionRole>([
'customer',
'product_items',
'topics',
'conditions',
'signature',
'warranty',
'cover',
]);

View File

@@ -0,0 +1,45 @@
import type {
BuiltSection,
RenderContext,
RuntimeIssue,
SectionBuilder,
} from './runtime-contracts';
import { SECTION_ORDER } from './runtime-contracts';
export interface SectionComposerResult {
sections: BuiltSection[];
issues: RuntimeIssue[];
}
export async function composeSections(
context: RenderContext,
builders: SectionBuilder[],
): Promise<SectionComposerResult> {
const builderByRole = new Map(builders.map((builder) => [builder.role, builder]));
const sections: BuiltSection[] = [];
const issues: RuntimeIssue[] = [];
for (const role of SECTION_ORDER) {
const policy = context.policyBySection.get(role);
if (!policy?.enabled) {
continue;
}
const builder = builderByRole.get(role);
if (!builder) {
issues.push({
code: 'INVALID_TEMPLATE',
severity: policy.required ? 'error' : 'warning',
message: 'No section builder is registered for an enabled section.',
details: { role },
});
continue;
}
const section = await builder.build(context);
sections.push(section);
issues.push(...section.issues);
}
return { sections, issues };
}

View File

@@ -35,10 +35,9 @@ import {
formatPdfDate,
formatTopicItems,
} from "./pdfme-transforms";
import { buildPdfTopicTemplate } from "./pdf-topic-engine";
import { buildQuotationPdfRuntime } from "./quotation-pdf-runtime";
import { resolveQuotationSignatures } from "./signature-resolver";
import { resolveQuotationTopicTypeMapping } from "./topic-mapping";
import type { Template } from "@pdfme/common";
const DOCUMENT_OPTION_CATEGORIES = {
branch: "crm_branch",
@@ -260,6 +259,17 @@ function normalizePdfmeTemplateInput(
return normalized;
}
function logQuotationPdfRuntimeWarnings(issues: Array<{ message: string }>) {
if (!issues.length) {
return;
}
console.warn(
"[quotation-pdf-runtime]",
issues.map((issue) => issue.message).join(" | "),
);
}
export async function buildQuotationDocumentData(
quotationId: string,
organizationId: string,
@@ -690,16 +700,19 @@ export async function getQuotationDocumentPreviewData(
mappings,
),
);
const { template: renderTemplate, topicInputs } = buildPdfTopicTemplate(
template.version.schemaJson as Template,
documentData.topics.all,
const runtime = await buildQuotationPdfRuntime({
documentData,
template,
mappings,
templateInput,
});
const runtimeWarnings = runtime.issues.filter(
(issue) => issue.severity === "warning",
);
const mergedTemplateInput = {
...templateInput,
...topicInputs,
};
documentData.pdfme.topic_inputs = topicInputs;
logQuotationPdfRuntimeWarnings(runtimeWarnings);
documentData.pdfme.topic_inputs = runtime.topicInputs;
return {
documentData,
@@ -707,11 +720,11 @@ export async function getQuotationDocumentPreviewData(
...template,
version: {
...template.version,
schemaJson: renderTemplate,
schemaJson: runtime.assembled.template,
},
},
mappings,
templateInput: mergedTemplateInput,
templateInput: runtime.assembled.templateInput,
};
}

View File

@@ -0,0 +1,78 @@
import type { Template } from '@pdfme/common';
import type {
AssembledTemplate,
BuiltSection,
RuntimeIssue,
} from './runtime-contracts';
function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export function assembleTemplate(args: {
template: Template;
templateInput: Record<string, unknown>;
sections: BuiltSection[];
}): AssembledTemplate {
const baseTemplate = cloneTemplate(args.template);
const replacementByAnchor = new Map<number, BuiltSection>();
const issues: RuntimeIssue[] = [];
for (const section of args.sections) {
if (section.anchorPageIndex === null) {
issues.push(...section.issues);
continue;
}
if (replacementByAnchor.has(section.anchorPageIndex)) {
issues.push({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'Multiple built sections attempted to replace the same page.',
details: {
anchorPageIndex: section.anchorPageIndex,
roles: [
replacementByAnchor.get(section.anchorPageIndex)?.role,
section.role,
],
},
});
continue;
}
replacementByAnchor.set(section.anchorPageIndex, section);
issues.push(...section.issues);
}
const nextPages: Template['schemas'] = [];
for (const [pageIndex, page] of baseTemplate.schemas.entries()) {
const replacement = replacementByAnchor.get(pageIndex);
if (!replacement) {
nextPages.push(cloneTemplate(page));
continue;
}
if (!replacement.rendered || replacement.pages.length === 0) {
continue;
}
nextPages.push(...cloneTemplate(replacement.pages));
}
baseTemplate.schemas = nextPages;
const templateInput = args.sections.reduce<Record<string, unknown>>(
(accumulator, section) => ({
...accumulator,
...section.templateInputPatch,
}),
{ ...args.templateInput },
);
return {
template: baseTemplate,
templateInput,
issues,
};
}

View File

@@ -0,0 +1,209 @@
import type { Template } from '@pdfme/common';
import type {
CompatibleTemplate,
CompatibleTemplatePage,
ResolvedTemplate,
RuntimeIssue,
SectionRole,
} from './runtime-contracts';
const EXPLICIT_ROLE_PREFIX = '__page_role__';
const LEGACY_MARKERS: Record<Exclude<SectionRole, 'unknown'>, string[]> = {
customer: ['customer_name', 'quotation_price_data', 'quotation_code_data'],
product_items: ['items_table'],
topics: ['topic', 'data_topic'],
conditions: ['conditions', 'condition_data'],
signature: [
'Please_do_not',
'yours_faithfuly',
'app1',
'app1_position',
'app2',
'app2_position',
'app3',
'app3_position',
],
attachments: ['attachment'],
appendix: ['appendix'],
warranty: ['warranty'],
cover: ['cover'],
};
type TemplateField = {
name?: string;
content?: string;
};
function createIssue(issue: RuntimeIssue): RuntimeIssue {
return issue;
}
function isTemplatePageArray(
value: Template['schemas'][number] | unknown,
): value is Template['schemas'][number] {
return Array.isArray(value);
}
function getFieldNames(page: Template['schemas'][number]): string[] {
return page
.map((field) => {
if (typeof field !== 'object' || field === null || !('name' in field)) {
return '';
}
return String((field as TemplateField).name ?? '');
})
.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 (
value === 'customer' ||
value === 'product_items' ||
value === 'topics' ||
value === 'conditions' ||
value === 'signature' ||
value === 'attachments' ||
value === 'appendix' ||
value === 'warranty' ||
value === 'cover' ||
value === 'unknown'
);
}
export function adaptTemplateForSectionRuntime(
template: ResolvedTemplate,
): CompatibleTemplate {
const issues: RuntimeIssue[] = [];
const schemaPages = template.schema.schemas;
if (!Array.isArray(schemaPages)) {
return {
template,
pages: [],
issues: [
createIssue({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'PDF template schema does not expose a valid schemas array.',
}),
],
};
}
const pages: CompatibleTemplatePage[] = [];
for (const [pageIndex, rawPage] of schemaPages.entries()) {
if (!isTemplatePageArray(rawPage)) {
issues.push(
createIssue({
code: 'INVALID_TEMPLATE',
severity: 'error',
message: 'PDF template page schema is invalid.',
details: { pageIndex },
}),
);
continue;
}
const explicitRole = getExplicitRole(rawPage);
const inferredRoles = inferLegacyRoles(rawPage);
const primaryRole = explicitRole ?? inferredRoles[0] ?? 'unknown';
const roles = Array.from(
new Set([primaryRole, ...inferredRoles].filter(isSectionRole)),
);
let strategy: CompatibleTemplatePage['strategy'] = 'fallback';
if (explicitRole) {
strategy = 'explicit_marker';
} else if (inferredRoles.length > 0) {
strategy = 'legacy_inference';
issues.push(
createIssue({
code: 'LEGACY_COMPAT_MODE',
severity: 'warning',
message:
'PDF template page role was inferred from legacy field markers.',
details: { pageIndex, roles },
}),
);
} else {
issues.push(
createIssue({
code: 'MISSING_MARKER',
severity: 'warning',
message:
'PDF template page could not be classified by explicit marker or legacy inference.',
details: { pageIndex },
}),
);
}
pages.push({
pageIndex,
primaryRole,
roles: roles.length > 0 ? roles : ['unknown'],
strategy,
schema: rawPage,
});
}
return {
template,
pages,
issues,
};
}