task-p.4.4

This commit is contained in:
phaichayon
2026-06-29 14:47:24 +07:00
parent 39bcfe2817
commit aa73b9aed2
11 changed files with 1143 additions and 45 deletions

View File

@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-06-29T06:28:49.019Z", "generatedAt": "2026-06-29T07:31:40.763Z",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "QT2606-1122",
"artifacts": { "artifacts": {

View File

@@ -1,6 +1,6 @@
# PDF Integrity Summary # PDF Integrity Summary
- Generated At: 2026-06-29T06:28:49.019Z - Generated At: 2026-06-29T07:31:40.763Z
- Overall Status: PASS - Overall Status: PASS
- Quotation Code: QT2606-1122 - Quotation Code: QT2606-1122

View File

@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-06-29T06:28:46.433Z", "generatedAt": "2026-06-29T07:31:39.763Z",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "QT2606-1122",
"expectedFixtureCode": "QT-H5-AUDIT", "expectedFixtureCode": "QT-H5-AUDIT",

View File

@@ -1,6 +1,6 @@
# PDF Audit Report # PDF Audit Report
- Generated At: 2026-06-29T06:28:46.433Z - Generated At: 2026-06-29T07:31:39.763Z
- Overall Status: PASS - Overall Status: PASS
- Quotation Code: QT2606-1122 - Quotation Code: QT2606-1122
- Template: ALLA Quotation Standard v1.0 - Template: ALLA Quotation Standard v1.0

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

388
plans/task-p.4.4.md Normal file
View File

@@ -0,0 +1,388 @@
# Task P.4.4 - Product Item Engine
## Objective
Implement the Product Item Engine for the new section-based PDF runtime.
The Product Item Engine is responsible only for transforming quotation items into a PDFMe-compatible table model.
This task does **not** create or modify PDF templates.
This task does **not** insert Product Item pages.
This task prepares the runtime for Task P.4.5.
---
# Prerequisites
Completed
* P.4 Discovery
* P.4.1 Runtime Verification
* P.4.2 Runtime Architecture Design
* P.4.3 Runtime Refactoring
* P.4.3.1 Runtime Regression Stabilization
Runtime audit must already pass before implementation begins.
---
# Scope
Included
* Product Item Engine
* Product Item Builder
* Product Item Mapping
* PDFMe Table Model
* Pagination Model
* Runtime Contracts
* Unit Tests
Excluded
* PDF template JSON
* CRM Template UI
* Template Version
* Page insertion
* Product Item page rendering
* User render options
---
# Architecture
The runtime shall use the existing section-based architecture.
```text
Quotation Document
Render Context
Section Composer
Product Item Engine
BuiltSection
Template Assembler
```
The engine must be completely isolated from
* Topic Engine
* Signature Resolver
* Customer Section
---
# Responsibilities
The Product Item Engine shall
* read quotation items
* normalize data
* format values
* build PDFMe table rows
* calculate row metadata
* estimate pagination
* return a BuiltSection
The engine must not know
* page indexes
* template layouts
* template JSON
* page insertion
* section ordering
---
# Input
Primary source
```text
documentData.items
```
The engine shall not query the database.
It receives all data from RenderContext.
---
# Output
Return
```ts
interface ProductItemSection extends BuiltSection {
role: SectionRole.ProductItems;
rows: ProductItemRow[];
pagination: PaginationModel;
tableModel: PdfmeTableModel;
}
```
The output must be renderer-independent.
---
# Product Item Mapping
Support at minimum
| Column | Source |
| ----------- | ----------- |
| Item | itemNumber |
| Description | description |
| Qty | quantity |
| Unit | unitLabel |
| Unit Price | unitPrice |
| Discount | discount |
| Total | totalPrice |
If additional fields exist they should be supported when available.
Examples
* productCode
* specification
* remark
* model
* brand
Missing optional fields must not fail rendering.
---
# Value Formatting
Use centralized formatters.
Never format inside templates.
Support
* currency
* decimal quantity
* percentage
* empty value normalization
Formatting must remain locale-aware.
---
# PDFMe Table Model
Generate a canonical table model independent of template layout.
Example
```ts
interface PdfmeTableModel {
headers;
rows;
columnWidths;
alignments;
}
```
The template consumes this model later.
The engine must not know where the table is rendered.
---
# Pagination Model
Implement pagination metadata.
The engine shall estimate
* row count
* page count
* header repetition
* orphan row prevention
* available content height
Do not insert pages.
Return only metadata.
Example
```ts
interface PaginationModel {
estimatedPages;
rowsPerPage;
headerRows;
footerRows;
}
```
---
# Empty State
When quotation has zero items
Return an empty Product Item section.
Do not throw an exception.
Runtime decides whether the section is rendered.
---
# Error Handling
Generate Runtime Issues for
* invalid quantity
* invalid price
* missing mandatory description
* invalid totals
Do not stop rendering because of one invalid row.
---
# Product Item Builder
Create a dedicated builder.
Responsibilities
* invoke Product Item Engine
* produce BuiltSection
* register itself in Section Registry
The builder must not manipulate templates.
---
# Unit Tests
Cover
* 0 items
* 1 item
* 10 items
* 100 items
* long descriptions
* missing optional fields
* currency formatting
* decimal quantities
* zero discount
* non-zero discount
---
# Integration Tests
Verify
* Section Composer accepts Product Item Builder
* BuiltSection returned correctly
* Runtime diagnostics preserved
* Existing sections unaffected
No template rendering yet.
---
# Performance Requirements
Support
* 500 quotation items
without significant performance degradation.
Avoid unnecessary cloning.
Avoid repeated formatting.
---
# Code Quality
* Single Responsibility Principle
* Strong typing
* Immutable outputs
* No duplicated mapping logic
* Shared formatter usage
* Shared runtime contracts
* Builder registration through Section Registry
---
# Deliverables
* Product Item Engine
* Product Item Builder
* Product Item Mapping
* PDFMe Table Model
* Pagination Model
* Runtime diagnostics
* Unit tests
* Integration tests
---
# Acceptance Criteria
* Product Item Engine is independent from template layout.
* Engine produces canonical PDFMe table model.
* Engine does not manipulate templates.
* Engine does not insert pages.
* Engine returns pagination metadata only.
* Existing runtime regression tests remain PASS.
* Runtime audit continues to pass.
* Product Item Engine is ready for Task P.4.5.
---
# Out of Scope
## Task P.4.5
* New PDF template
* Product Item page
* PDFMe table rendering
* Page insertion
* Header repetition in rendered PDF
## Task P.4.6
* CRM Template integration
* Template version management
* Preview integration
## Task P.4.7
* Render Policy UI
* Optional section configuration
* User-selectable Product Item visibility
---
# Final Success Condition
At the end of Task P.4.4, the runtime is capable of producing a fully normalized Product Item section and PDFMe table model, ready to be rendered by a future template, while the existing PDF output remains unchanged.

View File

@@ -1,8 +1,10 @@
import { formatDate } from '@/lib/date-format';
const EMPTY_TABLE_FALLBACK = [['-']]; const EMPTY_TABLE_FALLBACK = [['-']];
const PRICE_TABLE_LABEL = 'Price (Exclude VAT)'; const PRICE_TABLE_LABEL = 'Price (Exclude VAT)';
function normalizeNumber( export function normalizePdfNumber(
amount: number | string | null | undefined amount: number | string | null | undefined,
): number | null { ): number | null {
if (typeof amount === 'number' && Number.isFinite(amount)) { if (typeof amount === 'number' && Number.isFinite(amount)) {
return amount; return amount;
@@ -18,7 +20,7 @@ function normalizeNumber(
export function formatPdfDate( export function formatPdfDate(
dateString: string | Date | null | undefined, dateString: string | Date | null | undefined,
language: 'th' | 'en' = 'en' _language: 'th' | 'en' = 'en',
): string { ): string {
if (!dateString) { if (!dateString) {
return '-'; return '-';
@@ -35,9 +37,9 @@ export function formatPdfDate(
export function formatPdfCurrency( export function formatPdfCurrency(
amount: number | string | null | undefined, amount: number | string | null | undefined,
currencyCode = 'THB' currencyCode = 'THB',
): string { ): string {
const numericAmount = normalizeNumber(amount); const numericAmount = normalizePdfNumber(amount);
if (numericAmount === null) { if (numericAmount === null) {
return '-'; return '-';
@@ -47,12 +49,12 @@ export function formatPdfCurrency(
const symbols: Record<string, string> = { const symbols: Record<string, string> = {
THB: 'THB ', THB: 'THB ',
USD: '$', USD: '$',
EUR: 'EUR ' EUR: 'EUR ',
}; };
const symbol = symbols[normalizedCode] ?? `${normalizedCode} `; const symbol = symbols[normalizedCode] ?? `${normalizedCode} `;
const formatted = new Intl.NumberFormat('en-US', { const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2 maximumFractionDigits: 2,
}).format(numericAmount); }).format(numericAmount);
return `${symbol}${formatted}`.trim(); return `${symbol}${formatted}`.trim();
@@ -60,9 +62,9 @@ export function formatPdfCurrency(
export function formatPdfCurrencyAmount( export function formatPdfCurrencyAmount(
amount: number | string | null | undefined, amount: number | string | null | undefined,
currencyCode = 'THB' currencyCode = 'THB',
): string { ): string {
const numericAmount = normalizeNumber(amount); const numericAmount = normalizePdfNumber(amount);
if (numericAmount === null) { if (numericAmount === null) {
return '-'; return '-';
@@ -72,20 +74,39 @@ export function formatPdfCurrencyAmount(
const symbols: Record<string, string> = { const symbols: Record<string, string> = {
THB: '฿', THB: '฿',
USD: '$', USD: '$',
EUR: 'EUR ' EUR: 'EUR ',
}; };
const symbol = symbols[normalizedCode] ?? `${normalizedCode} `; const symbol = symbols[normalizedCode] ?? `${normalizedCode} `;
const formatted = new Intl.NumberFormat('en-US', { const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2 maximumFractionDigits: 2,
}).format(numericAmount); }).format(numericAmount);
return `${symbol}${formatted}`.trim(); return `${symbol}${formatted}`.trim();
} }
export function formatPdfDecimal(
amount: number | string | null | undefined,
options?: {
minimumFractionDigits?: number;
maximumFractionDigits?: number;
},
): string {
const numericAmount = normalizePdfNumber(amount);
if (numericAmount === null) {
return '-';
}
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: options?.minimumFractionDigits ?? 0,
maximumFractionDigits: options?.maximumFractionDigits ?? 3,
}).format(numericAmount);
}
export function buildPdfPriceTableData( export function buildPdfPriceTableData(
amount: number | string | null | undefined, amount: number | string | null | undefined,
currencyCode?: string | null currencyCode?: string | null,
): string[][] { ): string[][] {
const normalizedCurrency = currencyCode?.trim() || 'THB'; const normalizedCurrency = currencyCode?.trim() || 'THB';
@@ -93,7 +114,7 @@ export function buildPdfPriceTableData(
PRICE_TABLE_LABEL, PRICE_TABLE_LABEL,
formatPdfCurrencyAmount(amount, normalizedCurrency), formatPdfCurrencyAmount(amount, normalizedCurrency),
'', '',
normalizedCurrency normalizedCurrency,
]]; ]];
} }
@@ -110,7 +131,8 @@ export function formatTopicItems(topic?: {
return 0; return 0;
} }
return (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER); return (left.sortOrder ?? Number.MAX_SAFE_INTEGER) -
(right.sortOrder ?? Number.MAX_SAFE_INTEGER);
}) })
.map((item) => { .map((item) => {
if (typeof item === 'string') { if (typeof item === 'string') {
@@ -127,7 +149,7 @@ export function formatTopicItems(topic?: {
export function normalizePdfmeTable( export function normalizePdfmeTable(
value: unknown, value: unknown,
columns?: Array<{ sourceField: string; formatMask?: string | null }> columns?: Array<{ sourceField: string; formatMask?: string | null }>,
): string[][] { ): string[][] {
if (!Array.isArray(value) || value.length === 0) { if (!Array.isArray(value) || value.length === 0) {
return EMPTY_TABLE_FALLBACK; return EMPTY_TABLE_FALLBACK;
@@ -138,24 +160,20 @@ export function normalizePdfmeTable(
(row as unknown[]).map((cell) => { (row as unknown[]).map((cell) => {
if (typeof cell === 'string') { if (typeof cell === 'string') {
const trimmedValue = cell.trim(); const trimmedValue = cell.trim();
return trimmedValue || (cell.length > 0 ? cell : '-');
if (trimmedValue) {
return trimmedValue;
}
return cell.length > 0 ? cell : '-';
} }
const stringValue = String(cell ?? '').trim(); const stringValue = String(cell ?? '').trim();
return stringValue || '-'; return stringValue || '-';
}) }),
); );
return normalizedRows.length ? normalizedRows : EMPTY_TABLE_FALLBACK; return normalizedRows.length ? normalizedRows : EMPTY_TABLE_FALLBACK;
} }
if (!columns?.length) { if (!columns?.length) {
return value.map((row) => [String(row ?? '').trim() || '-']) || EMPTY_TABLE_FALLBACK; const rows = value.map((row) => [String(row ?? '').trim() || '-']);
return rows.length ? rows : EMPTY_TABLE_FALLBACK;
} }
const rows = value.map((row) => { const rows = value.map((row) => {
@@ -178,4 +196,3 @@ export function normalizePdfmeTable(
return rows.length ? rows : EMPTY_TABLE_FALLBACK; return rows.length ? rows : EMPTY_TABLE_FALLBACK;
} }
import { formatDate } from '@/lib/date-format';

View File

@@ -0,0 +1,346 @@
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'));
});

View File

@@ -0,0 +1,269 @@
import type { QuotationDocumentData } from '../types';
import {
formatPdfCurrency,
formatPdfDecimal,
normalizePdfNumber,
} from './pdfme-transforms';
import type {
PaginationModel,
PdfmeTableModel,
ProductItemRow,
ProductItemSection,
RuntimeIssue,
} from './runtime-contracts';
const PRODUCT_ITEM_TABLE_HEADERS = [
'Item',
'Description',
'Qty',
'Unit',
'Unit Price',
'Discount',
'Total',
] as const;
const PRODUCT_ITEM_TABLE_WIDTHS = [10, 40, 10, 10, 12, 8, 10] as const;
const PRODUCT_ITEM_TABLE_ALIGNMENTS = [
'center',
'left',
'right',
'center',
'right',
'right',
'right',
] as const;
const DEFAULT_AVAILABLE_CONTENT_HEIGHT = 648;
const DEFAULT_BASE_ROW_HEIGHT = 24;
const DEFAULT_EXTRA_LINE_HEIGHT = 10;
const DEFAULT_DESCRIPTION_WRAP = 72;
const DEFAULT_HEADER_ROWS = 1;
const DEFAULT_FOOTER_ROWS = 0;
const DEFAULT_ORPHAN_ROW_THRESHOLD = 1;
function estimateDescriptionLineCount(description: string): number {
const normalized = description.replace(/\r\n/g, '\n').trim();
if (!normalized) {
return 1;
}
return normalized.split('\n').reduce((total, line) => {
const lineLength = line.trim().length;
const wrappedLength = Math.max(1, Math.ceil(lineLength / DEFAULT_DESCRIPTION_WRAP));
return total + wrappedLength;
}, 0);
}
function createIssue(
code: RuntimeIssue['code'],
message: string,
item: QuotationDocumentData['items'][number],
rowIndex: number,
): RuntimeIssue {
return {
code,
severity: 'warning',
message,
details: {
itemId: item.id,
rowIndex,
itemNumber: item.itemNumber,
},
};
}
function normalizeDescription(item: QuotationDocumentData['items'][number]): string {
const descriptionParts = [item.description?.trim(), item.notes?.trim()].filter(
(value): value is string => Boolean(value),
);
return descriptionParts.join('\n').trim();
}
function buildProductItemRow(
item: QuotationDocumentData['items'][number],
rowIndex: number,
currencyCode: string,
): { row: ProductItemRow; issues: RuntimeIssue[] } {
const issues: RuntimeIssue[] = [];
const description = normalizeDescription(item);
const quantity = normalizePdfNumber(item.quantity);
const unitPrice = normalizePdfNumber(item.unitPrice);
const discount = normalizePdfNumber(item.discount);
const total = normalizePdfNumber(item.totalPrice);
if (!description) {
issues.push(
createIssue(
'MISSING_PRODUCT_ITEM_DESCRIPTION',
'Product item is missing a description.',
item,
rowIndex,
),
);
}
if (quantity === null) {
issues.push(
createIssue(
'INVALID_PRODUCT_ITEM_QUANTITY',
'Product item quantity is invalid.',
item,
rowIndex,
),
);
}
if (unitPrice === null) {
issues.push(
createIssue(
'INVALID_PRODUCT_ITEM_PRICE',
'Product item unit price is invalid.',
item,
rowIndex,
),
);
}
if (total === null) {
issues.push(
createIssue(
'INVALID_PRODUCT_ITEM_TOTAL',
'Product item total is invalid.',
item,
rowIndex,
),
);
}
const estimatedLineCount = estimateDescriptionLineCount(description || '-');
const estimatedRowHeight =
DEFAULT_BASE_ROW_HEIGHT +
Math.max(0, estimatedLineCount - 1) * DEFAULT_EXTRA_LINE_HEIGHT;
return {
row: {
sourceItemId: item.id,
rowIndex,
item: String(item.itemNumber ?? rowIndex + 1),
description: description || '-',
quantity: formatPdfDecimal(quantity, { minimumFractionDigits: 0, maximumFractionDigits: 3 }),
unit: item.unitLabel?.trim() || '-',
unitPrice: formatPdfCurrency(unitPrice, currencyCode),
discount: formatPdfCurrency(discount ?? 0, currencyCode),
total: formatPdfCurrency(total, currencyCode),
estimatedLineCount,
estimatedRowHeight,
raw: {
itemNumber: item.itemNumber ?? null,
quantity,
unitPrice,
discount,
total,
},
},
issues,
};
}
function buildTableModel(rows: ProductItemRow[]): PdfmeTableModel {
return {
headers: [...PRODUCT_ITEM_TABLE_HEADERS],
rows: rows.map((row) => [
row.item,
row.description,
row.quantity,
row.unit,
row.unitPrice,
row.discount,
row.total,
]),
columnWidths: [...PRODUCT_ITEM_TABLE_WIDTHS],
alignments: [...PRODUCT_ITEM_TABLE_ALIGNMENTS],
};
}
function buildPagination(rows: ProductItemRow[]): PaginationModel {
if (rows.length === 0) {
return {
rowCount: 0,
estimatedPages: 0,
rowsPerPage: [],
headerRows: DEFAULT_HEADER_ROWS,
footerRows: DEFAULT_FOOTER_ROWS,
repeatHeader: true,
orphanRowThreshold: DEFAULT_ORPHAN_ROW_THRESHOLD,
availableContentHeight: DEFAULT_AVAILABLE_CONTENT_HEIGHT,
pageBreakIndices: [],
};
}
const rowsPerPage: number[] = [];
const pageBreakIndices: number[] = [];
let consumedHeight = 0;
let currentPageRowCount = 0;
rows.forEach((row, index) => {
const nextHeight = consumedHeight + row.estimatedRowHeight;
if (currentPageRowCount > 0 && nextHeight > DEFAULT_AVAILABLE_CONTENT_HEIGHT) {
rowsPerPage.push(currentPageRowCount);
pageBreakIndices.push(index);
consumedHeight = row.estimatedRowHeight;
currentPageRowCount = 1;
return;
}
consumedHeight = nextHeight;
currentPageRowCount += 1;
});
if (currentPageRowCount > 0) {
rowsPerPage.push(currentPageRowCount);
}
if (
rowsPerPage.length > 1 &&
rowsPerPage[rowsPerPage.length - 1] <= DEFAULT_ORPHAN_ROW_THRESHOLD
) {
rowsPerPage[rowsPerPage.length - 2] -= 1;
rowsPerPage[rowsPerPage.length - 1] += 1;
if (pageBreakIndices.length > 0) {
pageBreakIndices[pageBreakIndices.length - 1] -= 1;
}
}
return {
rowCount: rows.length,
estimatedPages: rowsPerPage.length,
rowsPerPage,
headerRows: DEFAULT_HEADER_ROWS,
footerRows: DEFAULT_FOOTER_ROWS,
repeatHeader: true,
orphanRowThreshold: DEFAULT_ORPHAN_ROW_THRESHOLD,
availableContentHeight: DEFAULT_AVAILABLE_CONTENT_HEIGHT,
pageBreakIndices,
};
}
export function buildProductItemSectionModel(args: {
items: QuotationDocumentData['items'];
currencyCode: string | null | undefined;
}): Pick<ProductItemSection, 'rows' | 'tableModel' | 'pagination' | 'issues'> {
const currencyCode = args.currencyCode?.trim() || 'THB';
const issues: RuntimeIssue[] = [];
const rows = args.items.map((item, rowIndex) => {
const built = buildProductItemRow(item, rowIndex, currencyCode);
issues.push(...built.issues);
return built.row;
});
return {
rows,
tableModel: buildTableModel(rows),
pagination: buildPagination(rows),
issues,
};
}

View File

@@ -1,15 +1,13 @@
import type { Template } from '@pdfme/common'; import type { DocumentTemplateMappingWithColumns, ResolvedDocumentTemplate } from '@/features/foundation/document-template/types';
import type {
DocumentTemplateMappingWithColumns,
ResolvedDocumentTemplate,
} from '@/features/foundation/document-template/types';
import type { QuotationDocumentData } from '../types'; import type { QuotationDocumentData } from '../types';
import { buildPdfTopicPages } from './pdf-topic-engine'; import { buildPdfTopicPages } from './pdf-topic-engine';
import { resolveTemplatePages } from './page-resolver'; import { resolveTemplatePages } from './page-resolver';
import { buildProductItemSectionModel } from './product-item-engine';
import { createRenderContext, resolveRenderPolicies } from './render-context'; import { createRenderContext, resolveRenderPolicies } from './render-context';
import { resolveQuotationStaticTemplateInputs } from './static-input-resolver'; import { resolveQuotationStaticTemplateInputs } from './static-input-resolver';
import type { import type {
BuiltSection, BuiltSection,
ProductItemSection,
QuotationPdfRuntimeResult, QuotationPdfRuntimeResult,
RenderContext, RenderContext,
ResolvedTemplate, ResolvedTemplate,
@@ -23,7 +21,7 @@ function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T; return JSON.parse(JSON.stringify(value)) as T;
} }
function createCustomerSectionBuilder(): SectionBuilder { export function createCustomerSectionBuilder(): SectionBuilder {
return { return {
role: 'customer', role: 'customer',
build(context: RenderContext): BuiltSection<'customer'> { build(context: RenderContext): BuiltSection<'customer'> {
@@ -61,7 +59,32 @@ function createCustomerSectionBuilder(): SectionBuilder {
}; };
} }
function createTopicSectionBuilder(): SectionBuilder { export function createProductItemSectionBuilder(): SectionBuilder {
return {
role: 'product_items',
build(context: RenderContext): ProductItemSection {
const built = buildProductItemSectionModel({
items: context.documentData.items,
currencyCode: context.documentData.quotation.currencyCode,
});
return {
role: 'product_items',
enabled: true,
rendered: false,
anchorPageIndex: null,
pages: [],
templateInputPatch: {},
issues: built.issues,
rows: built.rows,
tableModel: built.tableModel,
pagination: built.pagination,
};
},
};
}
export function createTopicSectionBuilder(): SectionBuilder {
return { return {
role: 'topics', role: 'topics',
build(context: RenderContext): BuiltSection<'topics'> { build(context: RenderContext): BuiltSection<'topics'> {
@@ -103,21 +126,23 @@ function createTopicSectionBuilder(): SectionBuilder {
}; };
} }
function getQuotationSectionBuilders(): SectionBuilder[] { export function getQuotationSectionBuilders(): SectionBuilder[] {
return [createCustomerSectionBuilder(), createTopicSectionBuilder()]; return [
createCustomerSectionBuilder(),
createProductItemSectionBuilder(),
createTopicSectionBuilder(),
];
} }
function createResolvedTemplate( function createResolvedTemplate(template: ResolvedDocumentTemplate): ResolvedTemplate {
template: ResolvedDocumentTemplate,
): ResolvedTemplate {
return { return {
documentTemplate: template, documentTemplate: template,
schema: cloneTemplate(template.version.schemaJson as Template), schema: cloneTemplate(template.version.schemaJson as import('@pdfme/common').Template),
}; };
} }
function createRuntimeErrorMessage(): string { function createRuntimeErrorMessage(): string {
return 'Quotation PDF runtime is invalid for the active document template.'; return 'Quotation PDF runtime invalid active document template.';
} }
export async function buildQuotationPdfRuntime(args: { export async function buildQuotationPdfRuntime(args: {

View File

@@ -29,7 +29,11 @@ export type RuntimeIssueCode =
| 'MISSING_MAPPING' | 'MISSING_MAPPING'
| 'EMPTY_OPTIONAL_SECTION' | 'EMPTY_OPTIONAL_SECTION'
| 'EMPTY_REQUIRED_SECTION' | 'EMPTY_REQUIRED_SECTION'
| 'LEGACY_COMPAT_MODE'; | 'LEGACY_COMPAT_MODE'
| 'INVALID_PRODUCT_ITEM_QUANTITY'
| 'INVALID_PRODUCT_ITEM_PRICE'
| 'MISSING_PRODUCT_ITEM_DESCRIPTION'
| 'INVALID_PRODUCT_ITEM_TOTAL';
export interface RuntimeIssue { export interface RuntimeIssue {
code: RuntimeIssueCode; code: RuntimeIssueCode;
@@ -82,6 +86,55 @@ export interface BuiltSection<TRole extends SectionRole = SectionRole> {
issues: RuntimeIssue[]; issues: RuntimeIssue[];
} }
export type PdfmeTableAlignment = 'left' | 'center' | 'right';
export interface PdfmeTableModel {
headers: string[];
rows: string[][];
columnWidths: number[];
alignments: PdfmeTableAlignment[];
}
export interface ProductItemRow {
sourceItemId: string;
rowIndex: number;
item: string;
description: string;
quantity: string;
unit: string;
unitPrice: string;
discount: string;
total: string;
estimatedLineCount: number;
estimatedRowHeight: number;
raw: {
itemNumber: number | null;
quantity: number | null;
unitPrice: number | null;
discount: number | null;
total: number | null;
};
}
export interface PaginationModel {
rowCount: number;
estimatedPages: number;
rowsPerPage: number[];
headerRows: number;
footerRows: number;
repeatHeader: boolean;
orphanRowThreshold: number;
availableContentHeight: number;
pageBreakIndices: number[];
}
export interface ProductItemSection
extends BuiltSection<'product_items'> {
rows: ProductItemRow[];
tableModel: PdfmeTableModel;
pagination: PaginationModel;
}
export interface RenderContext { export interface RenderContext {
documentData: QuotationDocumentData; documentData: QuotationDocumentData;
template: ResolvedTemplate; template: ResolvedTemplate;