task-p.4.4
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
2
next-env.d.ts
vendored
@@ -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
388
plans/task-p.4.4.md
Normal 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.
|
||||||
@@ -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,28 +74,47 @@ 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';
|
||||||
|
|
||||||
return [[
|
return [[
|
||||||
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';
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user