diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json
index 06e41a4..85fb21b 100644
--- a/artifacts/pdf-audit/summary.json
+++ b/artifacts/pdf-audit/summary.json
@@ -1,5 +1,5 @@
{
- "generatedAt": "2026-06-29T06:28:49.019Z",
+ "generatedAt": "2026-06-29T07:31:40.763Z",
"overallStatus": "PASS",
"quotationCode": "QT2606-1122",
"artifacts": {
diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md
index e96382d..ad98530 100644
--- a/artifacts/pdf-audit/summary.md
+++ b/artifacts/pdf-audit/summary.md
@@ -1,6 +1,6 @@
# PDF Integrity Summary
-- Generated At: 2026-06-29T06:28:49.019Z
+- Generated At: 2026-06-29T07:31:40.763Z
- Overall Status: PASS
- Quotation Code: QT2606-1122
diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json
index b77390d..481a989 100644
--- a/docs/implementation/pdf-audit-report.json
+++ b/docs/implementation/pdf-audit-report.json
@@ -1,5 +1,5 @@
{
- "generatedAt": "2026-06-29T06:28:46.433Z",
+ "generatedAt": "2026-06-29T07:31:39.763Z",
"overallStatus": "PASS",
"quotationCode": "QT2606-1122",
"expectedFixtureCode": "QT-H5-AUDIT",
diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md
index e652cd9..15c3880 100644
--- a/docs/implementation/pdf-audit-report.md
+++ b/docs/implementation/pdf-audit-report.md
@@ -1,6 +1,6 @@
# PDF Audit Report
-- Generated At: 2026-06-29T06:28:46.433Z
+- Generated At: 2026-06-29T07:31:39.763Z
- Overall Status: PASS
- Quotation Code: QT2606-1122
- Template: ALLA Quotation Standard v1.0
diff --git a/next-env.d.ts b/next-env.d.ts
index 9edff1c..c4b7818 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/plans/task-p.4.4.md b/plans/task-p.4.4.md
new file mode 100644
index 0000000..7ed0927
--- /dev/null
+++ b/plans/task-p.4.4.md
@@ -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.
diff --git a/src/features/crm/quotations/document/server/pdfme-transforms.ts b/src/features/crm/quotations/document/server/pdfme-transforms.ts
index cb136d7..8f5b14d 100644
--- a/src/features/crm/quotations/document/server/pdfme-transforms.ts
+++ b/src/features/crm/quotations/document/server/pdfme-transforms.ts
@@ -1,8 +1,10 @@
+import { formatDate } from '@/lib/date-format';
+
const EMPTY_TABLE_FALLBACK = [['-']];
const PRICE_TABLE_LABEL = 'Price (Exclude VAT)';
-function normalizeNumber(
- amount: number | string | null | undefined
+export function normalizePdfNumber(
+ amount: number | string | null | undefined,
): number | null {
if (typeof amount === 'number' && Number.isFinite(amount)) {
return amount;
@@ -18,7 +20,7 @@ function normalizeNumber(
export function formatPdfDate(
dateString: string | Date | null | undefined,
- language: 'th' | 'en' = 'en'
+ _language: 'th' | 'en' = 'en',
): string {
if (!dateString) {
return '-';
@@ -35,9 +37,9 @@ export function formatPdfDate(
export function formatPdfCurrency(
amount: number | string | null | undefined,
- currencyCode = 'THB'
+ currencyCode = 'THB',
): string {
- const numericAmount = normalizeNumber(amount);
+ const numericAmount = normalizePdfNumber(amount);
if (numericAmount === null) {
return '-';
@@ -47,12 +49,12 @@ export function formatPdfCurrency(
const symbols: Record = {
THB: 'THB ',
USD: '$',
- EUR: 'EUR '
+ EUR: 'EUR ',
};
const symbol = symbols[normalizedCode] ?? `${normalizedCode} `;
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
- maximumFractionDigits: 2
+ maximumFractionDigits: 2,
}).format(numericAmount);
return `${symbol}${formatted}`.trim();
@@ -60,9 +62,9 @@ export function formatPdfCurrency(
export function formatPdfCurrencyAmount(
amount: number | string | null | undefined,
- currencyCode = 'THB'
+ currencyCode = 'THB',
): string {
- const numericAmount = normalizeNumber(amount);
+ const numericAmount = normalizePdfNumber(amount);
if (numericAmount === null) {
return '-';
@@ -72,28 +74,47 @@ export function formatPdfCurrencyAmount(
const symbols: Record = {
THB: '฿',
USD: '$',
- EUR: 'EUR '
+ EUR: 'EUR ',
};
const symbol = symbols[normalizedCode] ?? `${normalizedCode} `;
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
- maximumFractionDigits: 2
+ maximumFractionDigits: 2,
}).format(numericAmount);
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(
amount: number | string | null | undefined,
- currencyCode?: string | null
+ currencyCode?: string | null,
): string[][] {
const normalizedCurrency = currencyCode?.trim() || 'THB';
return [[
PRICE_TABLE_LABEL,
formatPdfCurrencyAmount(amount, normalizedCurrency),
- ' ',
- normalizedCurrency
+ '',
+ normalizedCurrency,
]];
}
@@ -110,7 +131,8 @@ export function formatTopicItems(topic?: {
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) => {
if (typeof item === 'string') {
@@ -127,7 +149,7 @@ export function formatTopicItems(topic?: {
export function normalizePdfmeTable(
value: unknown,
- columns?: Array<{ sourceField: string; formatMask?: string | null }>
+ columns?: Array<{ sourceField: string; formatMask?: string | null }>,
): string[][] {
if (!Array.isArray(value) || value.length === 0) {
return EMPTY_TABLE_FALLBACK;
@@ -138,24 +160,20 @@ export function normalizePdfmeTable(
(row as unknown[]).map((cell) => {
if (typeof cell === 'string') {
const trimmedValue = cell.trim();
-
- if (trimmedValue) {
- return trimmedValue;
- }
-
- return cell.length > 0 ? cell : '-';
+ return trimmedValue || (cell.length > 0 ? cell : '-');
}
const stringValue = String(cell ?? '').trim();
return stringValue || '-';
- })
+ }),
);
return normalizedRows.length ? normalizedRows : EMPTY_TABLE_FALLBACK;
}
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) => {
@@ -178,4 +196,3 @@ export function normalizePdfmeTable(
return rows.length ? rows : EMPTY_TABLE_FALLBACK;
}
-import { formatDate } from '@/lib/date-format';
diff --git a/src/features/crm/quotations/document/server/product-item-engine.test.ts b/src/features/crm/quotations/document/server/product-item-engine.test.ts
new file mode 100644
index 0000000..763a6d5
--- /dev/null
+++ b/src/features/crm/quotations/document/server/product-item-engine.test.ts
@@ -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] {
+ 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'));
+});
diff --git a/src/features/crm/quotations/document/server/product-item-engine.ts b/src/features/crm/quotations/document/server/product-item-engine.ts
new file mode 100644
index 0000000..a5fc66c
--- /dev/null
+++ b/src/features/crm/quotations/document/server/product-item-engine.ts
@@ -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 {
+ 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,
+ };
+}
diff --git a/src/features/crm/quotations/document/server/quotation-pdf-runtime.ts b/src/features/crm/quotations/document/server/quotation-pdf-runtime.ts
index c2e3014..78e2f25 100644
--- a/src/features/crm/quotations/document/server/quotation-pdf-runtime.ts
+++ b/src/features/crm/quotations/document/server/quotation-pdf-runtime.ts
@@ -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 { buildPdfTopicPages } from './pdf-topic-engine';
import { resolveTemplatePages } from './page-resolver';
+import { buildProductItemSectionModel } from './product-item-engine';
import { createRenderContext, resolveRenderPolicies } from './render-context';
import { resolveQuotationStaticTemplateInputs } from './static-input-resolver';
import type {
BuiltSection,
+ ProductItemSection,
QuotationPdfRuntimeResult,
RenderContext,
ResolvedTemplate,
@@ -23,7 +21,7 @@ function cloneTemplate(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
-function createCustomerSectionBuilder(): SectionBuilder {
+export function createCustomerSectionBuilder(): SectionBuilder {
return {
role: '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 {
role: 'topics',
build(context: RenderContext): BuiltSection<'topics'> {
@@ -103,21 +126,23 @@ function createTopicSectionBuilder(): SectionBuilder {
};
}
-function getQuotationSectionBuilders(): SectionBuilder[] {
- return [createCustomerSectionBuilder(), createTopicSectionBuilder()];
+export function getQuotationSectionBuilders(): SectionBuilder[] {
+ return [
+ createCustomerSectionBuilder(),
+ createProductItemSectionBuilder(),
+ createTopicSectionBuilder(),
+ ];
}
-function createResolvedTemplate(
- template: ResolvedDocumentTemplate,
-): ResolvedTemplate {
+function createResolvedTemplate(template: ResolvedDocumentTemplate): ResolvedTemplate {
return {
documentTemplate: template,
- schema: cloneTemplate(template.version.schemaJson as Template),
+ schema: cloneTemplate(template.version.schemaJson as import('@pdfme/common').Template),
};
}
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: {
diff --git a/src/features/crm/quotations/document/server/runtime-contracts.ts b/src/features/crm/quotations/document/server/runtime-contracts.ts
index 962761d..821a295 100644
--- a/src/features/crm/quotations/document/server/runtime-contracts.ts
+++ b/src/features/crm/quotations/document/server/runtime-contracts.ts
@@ -29,7 +29,11 @@ export type RuntimeIssueCode =
| 'MISSING_MAPPING'
| 'EMPTY_OPTIONAL_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 {
code: RuntimeIssueCode;
@@ -82,6 +86,55 @@ export interface BuiltSection {
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 {
documentData: QuotationDocumentData;
template: ResolvedTemplate;