347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
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 { resolveTemplatePages } from './page-resolver';
|
|
import { buildProductItemSectionModel } from './product-item-engine';
|
|
import { createRenderContext } from './render-context';
|
|
import { createProductItemSectionBuilder, getQuotationSectionBuilders } from './quotation-pdf-runtime';
|
|
import type { ProductItemSection, RenderPolicy, ResolvedTemplate } from './runtime-contracts';
|
|
import { composeSections } from './section-composer';
|
|
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('topic', 35),
|
|
createField('data_topic', 45, 7),
|
|
createField('Please_do_not', 225),
|
|
createField('yours_faithfuly', 232),
|
|
],
|
|
],
|
|
} 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',
|
|
},
|
|
version: {
|
|
id: 'template-version-1',
|
|
templateId: 'template-1',
|
|
versionNumber: 1,
|
|
schemaJson: schema,
|
|
createdAt: '2026-06-29T00:00:00.000Z',
|
|
updatedAt: '2026-06-29T00:00:00.000Z',
|
|
},
|
|
} as unknown as ResolvedDocumentTemplate;
|
|
}
|
|
|
|
function createItem(
|
|
index: number,
|
|
overrides: Partial<QuotationDocumentData['items'][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: 1000 + index,
|
|
discount: 0,
|
|
discountTypeCode: null,
|
|
taxRate: 0,
|
|
totalPrice: 1000 + index,
|
|
notes: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createDocumentData(items: QuotationDocumentData['items']): 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: [],
|
|
},
|
|
pdfme: {
|
|
labels: {
|
|
tel: 'Tel',
|
|
email: 'Email',
|
|
att: 'Att',
|
|
project: 'Project',
|
|
location: 'Location',
|
|
},
|
|
quotation_date: '2026-06-29',
|
|
quotation_price: 'THB 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,
|
|
};
|
|
}
|
|
|
|
function createRuntimeContext(items: QuotationDocumentData['items']) {
|
|
const schema = createLegacyTemplate();
|
|
const template = createResolvedDocumentTemplate(schema);
|
|
const runtimeTemplate: ResolvedTemplate = {
|
|
documentTemplate: template,
|
|
schema,
|
|
};
|
|
const pages = resolveTemplatePages(adaptTemplateForSectionRuntime(runtimeTemplate));
|
|
const policies: RenderPolicy[] = [
|
|
{ section: 'customer', enabled: true, required: true, visibleWhenEmpty: true },
|
|
{ section: 'product_items', enabled: true, required: false, visibleWhenEmpty: false },
|
|
{ section: 'topics', enabled: true, required: true, visibleWhenEmpty: true },
|
|
{ section: 'conditions', enabled: false, required: false, visibleWhenEmpty: false },
|
|
{ section: 'signature', enabled: false, required: false, visibleWhenEmpty: false },
|
|
{ section: 'attachments', enabled: false, required: false, visibleWhenEmpty: false },
|
|
{ section: 'appendix', enabled: false, required: false, visibleWhenEmpty: false },
|
|
{ section: 'warranty', enabled: false, required: false, visibleWhenEmpty: false },
|
|
{ section: 'cover', enabled: false, required: false, visibleWhenEmpty: false },
|
|
];
|
|
|
|
return createRenderContext({
|
|
documentData: createDocumentData(items),
|
|
template: runtimeTemplate,
|
|
mappings: [],
|
|
pages,
|
|
policies,
|
|
issues: pages.issues,
|
|
});
|
|
}
|
|
|
|
test('product item engine returns empty section model when quotation has no items', () => {
|
|
const built = buildProductItemSectionModel({ items: [], currencyCode: 'THB' });
|
|
|
|
assert.equal(built.rows.length, 0);
|
|
assert.deepEqual(built.tableModel.headers, [
|
|
'Item',
|
|
'Description',
|
|
'Qty',
|
|
'Unit',
|
|
'Unit Price',
|
|
'Discount',
|
|
'Total',
|
|
]);
|
|
assert.equal(built.pagination.estimatedPages, 0);
|
|
assert.deepEqual(built.issues, []);
|
|
});
|
|
|
|
test('product item engine formats one row with decimal quantity and discount', () => {
|
|
const built = buildProductItemSectionModel({
|
|
items: [
|
|
createItem(0, {
|
|
quantity: 2.5,
|
|
unitPrice: 1234.5,
|
|
discount: 50,
|
|
totalPrice: 3036.25,
|
|
}),
|
|
],
|
|
currencyCode: 'THB',
|
|
});
|
|
|
|
assert.equal(built.rows.length, 1);
|
|
assert.equal(built.rows[0]?.quantity, '2.5');
|
|
assert.equal(built.rows[0]?.unitPrice, 'THB 1,234.50');
|
|
assert.equal(built.rows[0]?.discount, 'THB 50.00');
|
|
assert.equal(built.rows[0]?.total, 'THB 3,036.25');
|
|
assert.deepEqual(built.tableModel.rows[0], [
|
|
'1',
|
|
'Product item 1',
|
|
'2.5',
|
|
'EA',
|
|
'THB 1,234.50',
|
|
'THB 50.00',
|
|
'THB 3,036.25',
|
|
]);
|
|
});
|
|
|
|
test('product item engine supports 10 and 100 items with pagination metadata', () => {
|
|
const tenItems = buildProductItemSectionModel({
|
|
items: Array.from({ length: 10 }, (_, index) => createItem(index)),
|
|
currencyCode: 'THB',
|
|
});
|
|
const hundredItems = buildProductItemSectionModel({
|
|
items: Array.from({ length: 100 }, (_, index) => createItem(index)),
|
|
currencyCode: 'THB',
|
|
});
|
|
|
|
assert.equal(tenItems.rows.length, 10);
|
|
assert.equal(tenItems.pagination.rowCount, 10);
|
|
assert.equal(tenItems.pagination.estimatedPages, 1);
|
|
|
|
assert.equal(hundredItems.rows.length, 100);
|
|
assert.ok(hundredItems.pagination.estimatedPages > 1);
|
|
assert.equal(
|
|
hundredItems.pagination.rowsPerPage.reduce((sum, value) => sum + value, 0),
|
|
100,
|
|
);
|
|
});
|
|
|
|
test('product item engine increases estimated row height for long descriptions and allows missing optionals', () => {
|
|
const longDescription = Array.from({ length: 40 }, () => 'long-description').join(' ');
|
|
const built = buildProductItemSectionModel({
|
|
items: [
|
|
createItem(0, {
|
|
description: longDescription,
|
|
unitLabel: null,
|
|
notes: 'extra note',
|
|
}),
|
|
],
|
|
currencyCode: 'THB',
|
|
});
|
|
|
|
assert.ok((built.rows[0]?.estimatedLineCount ?? 0) > 1);
|
|
assert.ok((built.rows[0]?.estimatedRowHeight ?? 0) > 24);
|
|
assert.equal(built.rows[0]?.unit, '-');
|
|
assert.match(built.rows[0]?.description ?? '', /extra note/);
|
|
});
|
|
|
|
test('product item engine reports invalid quantity, price, description, and total as runtime issues', () => {
|
|
const built = buildProductItemSectionModel({
|
|
items: [
|
|
createItem(0, {
|
|
description: '',
|
|
quantity: Number.NaN,
|
|
unitPrice: Number.POSITIVE_INFINITY,
|
|
totalPrice: Number.NaN,
|
|
}),
|
|
],
|
|
currencyCode: 'THB',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
built.issues.map((issue) => issue.code).sort(),
|
|
[
|
|
'INVALID_PRODUCT_ITEM_PRICE',
|
|
'INVALID_PRODUCT_ITEM_QUANTITY',
|
|
'INVALID_PRODUCT_ITEM_TOTAL',
|
|
'MISSING_PRODUCT_ITEM_DESCRIPTION',
|
|
],
|
|
);
|
|
});
|
|
|
|
test('section composer accepts product item builder and existing sections remain available', async () => {
|
|
const context = createRuntimeContext([
|
|
createItem(0),
|
|
createItem(1, { discount: 25, totalPrice: 1976 }),
|
|
]);
|
|
|
|
const section = (await createProductItemSectionBuilder().build(context)) as ProductItemSection;
|
|
assert.equal(section.rendered, false);
|
|
assert.equal(section.anchorPageIndex, null);
|
|
assert.equal(section.rows.length, 2);
|
|
|
|
const composed = await composeSections(context, getQuotationSectionBuilders());
|
|
const sectionRoles = composed.sections.map((item) => item.role);
|
|
|
|
assert.deepEqual(sectionRoles, ['customer', 'product_items', 'topics']);
|
|
const productItemsSection = composed.sections.find(
|
|
(item): item is ProductItemSection => item.role === 'product_items',
|
|
);
|
|
assert.ok(productItemsSection);
|
|
assert.equal(productItemsSection.tableModel.rows.length, 2);
|
|
assert.ok(composed.sections.some((item) => item.role === 'customer'));
|
|
assert.ok(composed.sections.some((item) => item.role === 'topics'));
|
|
});
|