From aa73b9aed2f56f7543fe20e448aed977310a5a25 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 29 Jun 2026 14:47:24 +0700 Subject: [PATCH] task-p.4.4 --- artifacts/pdf-audit/summary.json | 2 +- artifacts/pdf-audit/summary.md | 2 +- docs/implementation/pdf-audit-report.json | 2 +- docs/implementation/pdf-audit-report.md | 2 +- next-env.d.ts | 2 +- plans/task-p.4.4.md | 388 ++++++++++++++++++ .../document/server/pdfme-transforms.ts | 67 +-- .../server/product-item-engine.test.ts | 346 ++++++++++++++++ .../document/server/product-item-engine.ts | 269 ++++++++++++ .../document/server/quotation-pdf-runtime.ts | 53 ++- .../document/server/runtime-contracts.ts | 55 ++- 11 files changed, 1143 insertions(+), 45 deletions(-) create mode 100644 plans/task-p.4.4.md create mode 100644 src/features/crm/quotations/document/server/product-item-engine.test.ts create mode 100644 src/features/crm/quotations/document/server/product-item-engine.ts 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;