taks-p.4.3
This commit is contained in:
52
src/features/crm/quotations/document/server/page-resolver.ts
Normal file
52
src/features/crm/quotations/document/server/page-resolver.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type PdfTopic = {
|
||||
|
||||
export type PdfTopicEngineOptions = {
|
||||
targetPageIndex?: number;
|
||||
basePageIndex?: number;
|
||||
pageStartY?: number;
|
||||
pageBottomY?: number;
|
||||
topicSpacing?: number;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 ?? [])],
|
||||
};
|
||||
}
|
||||
134
src/features/crm/quotations/document/server/runtime-contracts.ts
Normal file
134
src/features/crm/quotations/document/server/runtime-contracts.ts
Normal 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',
|
||||
]);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user