Files
alla-allaos-fullstack/scripts/pdf-audit-utils.ts
phaichayon a1cac84e2b task p-4.5
2026-06-29 16:07:03 +07:00

1638 lines
45 KiB
TypeScript

import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import postgres from 'postgres';
import { resolveQuotationStaticTemplateInputs } from '../src/features/crm/quotations/document/server/static-input-resolver.ts';
export const AUDIT_QUOTATION_CODE = 'QT-H5-AUDIT';
export const AUDIT_ARTIFACTS_DIR = path.resolve(process.cwd(), 'artifacts', 'pdf-audit');
export const REPORT_JSON_PATH = path.resolve(
process.cwd(),
'docs/implementation/pdf-audit-report.json'
);
export const REPORT_MARKDOWN_PATH = path.resolve(
process.cwd(),
'docs/implementation/pdf-audit-report.md'
);
export type SqlClient = ReturnType<typeof postgres>;
export interface TemplateFieldRecord {
pageIndex: number;
fieldIndex: number;
name: string;
type: string;
tokens: string[];
}
export interface ActiveTemplateVersionRecord {
templateId: string;
templateName: string;
organizationId: string;
documentType: string;
productType: string;
fileType: string;
versionId: string;
version: string;
filePath: string | null;
schemaJson: unknown;
}
export interface MappingRecord {
id: string;
organizationId: string;
templateVersionId: string;
placeholderKey: string;
sourcePath: string;
dataType: 'scalar' | 'multiline' | 'table' | 'image';
defaultValue: string | null;
formatMask: string | null;
sortOrder: number;
}
export interface MappingColumnRecord {
mappingId: string;
columnName: string;
sourceField: string;
sortOrder: number;
formatMask: string | null;
}
export type TemplateFieldClassification =
| 'static input field'
| 'dynamic topic template'
| 'readonly/static visual field'
| 'system field'
| 'unknown field';
export interface TemplateMappingSeed {
placeholderKey: string;
sourcePath: string;
dataType: 'scalar' | 'multiline' | 'table' | 'image';
defaultValue?: string | null;
formatMask?: string | null;
sortOrder: number;
columns?: Array<{
columnName: string;
sourceField: string;
columnLetter?: string;
sortOrder: number;
formatMask?: string | null;
}>;
}
export interface AuditPayload {
organizationId: string;
quotationId: string;
quotationCode: string;
templateVersionId: string;
approvedTemplateVersionId: string | null;
templateName: string;
templateVersion: string;
templateSchema: unknown;
mappings: MappingRecord[];
mappingColumns: MappingColumnRecord[];
documentData: Record<string, unknown>;
templateInput: Record<string, unknown>;
topicInputs: Record<string, string[][]>;
quotationMetrics: {
topicCount: number;
itemCount: number;
partyCount: number;
};
approvedSnapshot: Record<string, unknown> | null;
approvedArtifactReference: string | null;
}
const SYSTEM_FIELD_NAMES = new Set([
'',
'company1',
'company',
'company2',
'company_addr1',
'company_addr2',
'company_tel',
'company_email',
'company_tax',
'Please_do_not',
'yours_faithfuly'
]);
const DYNAMIC_TOPIC_FIELD_NAMES = new Set(['topic', 'data_topic']);
const LEGACY_DYNAMIC_SEED_TOKENS = new Set(['item_topic']);
const DYNAMIC_TOPIC_TOKEN_PREFIXES = ['topic_', 'item_topic_'];
const DESIGNER_LABEL_NAME_PATTERNS = [/_label$/i, /_lable$/i];
const STATIC_VISUAL_FIELD_NAMES = new Set([
'colon',
'colon copy',
'colon_att',
'colon_email',
'colon_fax',
'colon_project',
'colon_site',
'colon_tel',
'dear_sirs',
'line',
'yours_faithfuly',
'quotation_code_lable',
'quotation_date_labe'
]);
export const PRICE_TABLE_LABEL = 'Price (Exclude VAT)';
export const PDFME_STATIC_LABELS = {
tel: 'Tel',
email: 'Email',
att: 'Att',
project: 'Project',
location: 'Location'
} as const;
export const PDFME_STATIC_LABEL_FIELD_KEYS = [
'tel_label',
'email_label',
'att_label',
'project_label',
'site_label'
] as const;
export type TemplateSourceVariant = 'legacy' | 'product-v1';
export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
{
placeholderKey: 'customer_name',
sourcePath: 'customer.name',
dataType: 'scalar',
sortOrder: 1
},
{
placeholderKey: 'customer_addr',
sourcePath: 'customer.address',
dataType: 'multiline',
sortOrder: 2
},
{
placeholderKey: 'customer_tel',
sourcePath: 'customer.phone',
dataType: 'scalar',
sortOrder: 3
},
{
placeholderKey: 'customer_email',
sourcePath: 'customer.email',
dataType: 'scalar',
sortOrder: 4
},
{
placeholderKey: 'customer_att',
sourcePath: 'quotation.attention',
dataType: 'scalar',
sortOrder: 5
},
{
placeholderKey: 'project_name',
sourcePath: 'quotation.projectName',
dataType: 'scalar',
sortOrder: 6
},
{
placeholderKey: 'site_location',
sourcePath: 'quotation.projectLocation',
dataType: 'multiline',
sortOrder: 7
},
{
placeholderKey: 'quotation_date_data',
sourcePath: 'pdfme.quotation_date',
dataType: 'scalar',
sortOrder: 8
},
{
placeholderKey: 'quotation_code_data',
sourcePath: 'quotation.code',
dataType: 'scalar',
sortOrder: 9
},
{
placeholderKey: 'quotation_price_data',
sourcePath: 'pdfme.quotation_price_data',
dataType: 'table',
defaultValue: JSON.stringify([[PRICE_TABLE_LABEL, '-', ' ', 'THB']]),
sortOrder: 10
},
{
placeholderKey: 'quotation_price',
sourcePath: 'pdfme.quotation_price',
dataType: 'scalar',
sortOrder: 11
},
{
placeholderKey: 'currency',
sourcePath: 'quotation.currency',
dataType: 'scalar',
sortOrder: 12
},
{
placeholderKey: 'exclusion_data',
sourcePath: 'pdfme.exclusion_data',
dataType: 'table',
sortOrder: 13
},
{
placeholderKey: 'tel_label',
sourcePath: 'pdfme.labels.tel',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.tel,
sortOrder: 14
},
{
placeholderKey: 'email_label',
sourcePath: 'pdfme.labels.email',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.email,
sortOrder: 15
},
{
placeholderKey: 'att_label',
sourcePath: 'pdfme.labels.att',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.att,
sortOrder: 16
},
{
placeholderKey: 'project_label',
sourcePath: 'pdfme.labels.project',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.project,
sortOrder: 17
},
{
placeholderKey: 'site_label',
sourcePath: 'pdfme.labels.location',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.location,
sortOrder: 18
},
{
placeholderKey: 'app1',
sourcePath: 'signatures.preparedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 19
},
{
placeholderKey: 'app1_position',
sourcePath: 'signatures.preparedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 20
},
{
placeholderKey: 'app2',
sourcePath: 'signatures.approvedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 21
},
{
placeholderKey: 'app2_position',
sourcePath: 'signatures.approvedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 22
},
{
placeholderKey: 'app3',
sourcePath: 'signatures.authorizedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 23
},
{
placeholderKey: 'app3_position',
sourcePath: 'signatures.authorizedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 24
},
{
placeholderKey: 'items_table',
sourcePath: 'items',
dataType: 'table',
sortOrder: 25,
columns: [
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
{ columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 },
{ columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 },
{
columnName: 'Unit Price',
sourceField: 'unitPrice',
columnLetter: 'E',
sortOrder: 5,
formatMask: 'currency'
},
{
columnName: 'Discount',
sourceField: 'discount',
columnLetter: 'F',
sortOrder: 6,
formatMask: 'currency'
},
{
columnName: 'Total',
sourceField: 'totalPrice',
columnLetter: 'G',
sortOrder: 7,
formatMask: 'currency'
}
]
}
] as const;
const SIGNATURE_ROLE_FALLBACK_LABELS: Record<string, string> = {
sales: 'Sales Engineer',
sales_support: 'Sales Support',
sales_manager: 'Sales Manager',
department_manager: 'Department Manager',
top_manager: 'General Manager'
};
export function loadEnvFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return;
}
const content = fs.readFileSync(filePath, 'utf8');
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex === -1) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
export function loadLocalEnv() {
loadEnvFile(path.resolve(process.cwd(), '.env.local'));
loadEnvFile(path.resolve(process.cwd(), '.env'));
}
export function requireEnv(name: string) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export function createSqlClient() {
loadLocalEnv();
return postgres(requireEnv('DATABASE_URL'), {
prepare: false
});
}
export async function ensureAuditArtifactsDir() {
await mkdir(AUDIT_ARTIFACTS_DIR, { recursive: true });
}
export async function writeAuditArtifact(fileName: string, payload: unknown) {
await ensureAuditArtifactsDir();
const targetPath = path.resolve(AUDIT_ARTIFACTS_DIR, fileName);
await writeFile(targetPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
return targetPath;
}
export function computePayloadHash(value: unknown) {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
export function extractTokensFromValue(value: unknown) {
const tokens = new Set<string>();
if (typeof value !== 'string') {
return tokens;
}
for (const match of value.matchAll(/\{([^}]+)\}/g)) {
tokens.add(match[1]);
}
try {
const parsed = JSON.parse(value) as unknown;
if (Array.isArray(parsed)) {
for (const row of parsed) {
if (!Array.isArray(row)) {
continue;
}
for (const cell of row) {
if (typeof cell !== 'string') {
continue;
}
for (const match of cell.matchAll(/\{([^}]+)\}/g)) {
tokens.add(match[1]);
}
}
}
}
} catch {
return tokens;
}
return tokens;
}
export function extractSinglePlaceholder(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const matches = [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
const uniqueMatches = [...new Set(matches)];
return uniqueMatches.length === 1 ? uniqueMatches[0] : null;
}
function normalizeSchemaJson(schemaJson: unknown) {
if (typeof schemaJson === 'string') {
try {
return JSON.parse(schemaJson) as unknown;
} catch {
return null;
}
}
return schemaJson;
}
export function extractTemplateFields(schemaJson: unknown) {
const fields: TemplateFieldRecord[] = [];
const normalizedSchemaJson = normalizeSchemaJson(schemaJson);
if (
!normalizedSchemaJson ||
typeof normalizedSchemaJson !== 'object' ||
!('schemas' in normalizedSchemaJson)
) {
return fields;
}
const pages = (normalizedSchemaJson as { schemas?: unknown }).schemas;
if (!Array.isArray(pages)) {
return fields;
}
for (const [pageIndex, page] of pages.entries()) {
if (!Array.isArray(page)) {
continue;
}
for (const [fieldIndex, field] of page.entries()) {
if (!field || typeof field !== 'object') {
continue;
}
const typedField = field as {
name?: unknown;
type?: unknown;
content?: unknown;
head?: unknown;
};
const tokens = new Set<string>();
for (const token of extractTokensFromValue(typedField.content)) {
tokens.add(token);
}
if (Array.isArray(typedField.head)) {
for (const value of typedField.head) {
for (const token of extractTokensFromValue(value)) {
tokens.add(token);
}
}
}
fields.push({
pageIndex,
fieldIndex,
name: typeof typedField.name === 'string' ? typedField.name : '',
type: typeof typedField.type === 'string' ? typedField.type : 'unknown',
tokens: [...tokens]
});
}
}
return fields;
}
export function getDuplicateFieldNames(fields: TemplateFieldRecord[]) {
const fieldNameCounts = new Map<string, number>();
for (const field of fields) {
if (!field.name) {
continue;
}
fieldNameCounts.set(field.name, (fieldNameCounts.get(field.name) ?? 0) + 1);
}
return [...fieldNameCounts.entries()]
.filter(([, count]) => count > 1)
.map(([name]) => name)
.sort();
}
export function getUnknownFieldNames(
fields: TemplateFieldRecord[],
mappedKeys: Set<string> = new Set()
) {
return fields
.filter(
(field) =>
field.name && classifyTemplateField(field, mappedKeys) === 'unknown field'
)
.map((field) => field.name)
.sort();
}
export function isDesignerLabelField(field: TemplateFieldRecord) {
return (
field.type === 'text' &&
field.tokens.length === 0 &&
DESIGNER_LABEL_NAME_PATTERNS.some((pattern) => pattern.test(field.name))
);
}
export function classifyTemplateField(
field: TemplateFieldRecord,
mappedKeys: Set<string> = new Set()
): TemplateFieldClassification {
if (!field.name) {
return 'system field';
}
if (SYSTEM_FIELD_NAMES.has(field.name)) {
return 'system field';
}
if (DYNAMIC_TOPIC_FIELD_NAMES.has(field.name)) {
return 'dynamic topic template';
}
if (mappedKeys.has(field.name)) {
return 'static input field';
}
if (isDesignerLabelField(field) || STATIC_VISUAL_FIELD_NAMES.has(field.name)) {
return 'readonly/static visual field';
}
if (
field.tokens.length === 0 &&
['text', 'line', 'rectangle', 'ellipse', 'image'].includes(field.type)
) {
return 'readonly/static visual field';
}
return 'unknown field';
}
export function getEffectiveMappedKeys(
fields: TemplateFieldRecord[],
mappings: Array<{ placeholderKey: string }>
) {
const mappedKeys = new Set(mappings.map((mapping) => mapping.placeholderKey));
for (const field of fields) {
if (!field.name || field.tokens.length !== 1) {
continue;
}
const [token] = field.tokens;
if (mappedKeys.has(field.name) && !mappedKeys.has(token)) {
mappedKeys.add(token);
}
if (mappedKeys.has(token) && !mappedKeys.has(field.name)) {
mappedKeys.add(field.name);
}
}
return mappedKeys;
}
export function normalizeTemplateInputAliases(
fields: TemplateFieldRecord[],
templateInput: Record<string, unknown>
) {
const normalized = { ...templateInput };
for (const field of fields) {
if (!field.name || field.tokens.length !== 1) {
continue;
}
const [token] = field.tokens;
if (!token || field.name === token) {
continue;
}
if (normalized[field.name] !== undefined && normalized[token] === undefined) {
normalized[token] = normalized[field.name];
}
if (normalized[token] !== undefined && normalized[field.name] === undefined) {
normalized[field.name] = normalized[token];
}
}
return normalized;
}
export function loadTemplateSourceByOrganizationName(
organizationName: string,
variant: TemplateSourceVariant = 'legacy',
) {
const normalized = organizationName.toUpperCase();
const fileName = normalized.includes('ONVALLA')
? (
variant === 'product-v1'
? 'ONVALLA_template_pdfme_product_v1.json'
: 'ONVALLA_template_pdfme_fainal3.json'
)
: normalized.includes('ALLA')
? (
variant === 'product-v1'
? 'ALLA_template_pdfme_product_v1.json'
: 'ALLA_template_pdfme_fainal3.json'
)
: null;
if (!fileName) {
return null;
}
const filePath = path.resolve(process.cwd(), 'src', 'pdfme_template', fileName);
return {
fileName,
relativePath: `src/pdfme_template/${fileName}`,
absolutePath: filePath,
schemaJson: JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown
};
}
export function loadTemplateSourceByRelativePath(relativePath: string | null | undefined) {
if (!relativePath) {
return null;
}
const normalizedRelativePath = relativePath.replaceAll('\\', '/');
const absolutePath = path.resolve(process.cwd(), normalizedRelativePath);
if (!fs.existsSync(absolutePath)) {
return null;
}
return {
fileName: path.basename(normalizedRelativePath),
relativePath: normalizedRelativePath,
absolutePath,
schemaJson: JSON.parse(fs.readFileSync(absolutePath, 'utf8')) as unknown
};
}
export function computeFieldDiff(
currentFields: TemplateFieldRecord[],
previousFields: TemplateFieldRecord[]
) {
const currentFieldNames = new Set(currentFields.map((field) => field.name).filter(Boolean));
const previousFieldNames = new Set(previousFields.map((field) => field.name).filter(Boolean));
return {
deletedFields: [...previousFieldNames].filter((name) => !currentFieldNames.has(name)).sort(),
newFields: [...currentFieldNames].filter((name) => !previousFieldNames.has(name)).sort()
};
}
export function buildSupportedMappingsForFieldNames(
fieldNames: Set<string>,
tokenNames: Set<string> = new Set()
) {
return SUPPORTED_TEMPLATE_MAPPING_SEEDS.filter(
(mapping) => fieldNames.has(mapping.placeholderKey) || tokenNames.has(mapping.placeholderKey)
);
}
export function bumpMinorTemplateVersion(version: string) {
const [majorPart, minorPart = '0'] = version.split('.');
const major = Number(majorPart);
const minor = Number(minorPart);
if (!Number.isInteger(major) || !Number.isInteger(minor)) {
return `${version}.1`;
}
return `${major}.${minor + 1}`;
}
function normalizeSnapshotPayload(snapshot: unknown) {
if (!snapshot) {
return null;
}
if (typeof snapshot === 'string') {
try {
const parsed = JSON.parse(snapshot) as unknown;
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
return snapshot && typeof snapshot === 'object' ? (snapshot as Record<string, unknown>) : null;
}
export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveTemplateVersionRecord[]> {
const rows = await sql`
select
t.id as "templateId",
t.template_name as "templateName",
t.organization_id as "organizationId",
t.document_type as "documentType",
t.product_type as "productType",
t.file_type as "fileType",
v.id as "versionId",
v.version,
v.file_path as "filePath",
v.schema_json as "schemaJson"
from crm_document_templates t
inner join crm_document_template_versions v
on v.template_id = t.id
where t.is_active = true
and v.is_active = true
and t.deleted_at is null
and v.deleted_at is null
order by t.organization_id asc, t.template_name asc, v.created_at asc
`;
return castRows<ActiveTemplateVersionRecord>(rows);
}
export async function getMappingsForVersion(sql: SqlClient, templateVersionId: string) {
const [mappings, columns] = await Promise.all([
sql`
select
id,
organization_id as "organizationId",
template_version_id as "templateVersionId",
placeholder_key as "placeholderKey",
source_path as "sourcePath",
data_type as "dataType",
default_value as "defaultValue",
format_mask as "formatMask",
sort_order as "sortOrder"
from crm_document_template_mappings
where template_version_id = ${templateVersionId}
and deleted_at is null
order by sort_order asc, created_at asc
`,
sql`
select
mapping_id as "mappingId",
column_name as "columnName",
source_field as "sourceField",
sort_order as "sortOrder",
format_mask as "formatMask"
from crm_document_template_table_columns
where deleted_at is null
order by sort_order asc, created_at asc
`
]);
return {
mappings: castRows<MappingRecord>(mappings),
columns: castRows<MappingColumnRecord>(columns)
};
}
export async function findAuditQuotation(sql: SqlClient) {
const [fixtureQuotation] = await sql`
select
q.id,
q.code,
q.organization_id as "organizationId",
q.customer_id as "customerId",
q.contact_id as "contactId",
q.quotation_date as "quotationDate",
q.valid_until as "validUntil",
q.project_name as "projectName",
q.project_location as "projectLocation",
q.attention,
q.reference,
q.currency,
q.subtotal,
q.total_amount as "totalAmount",
currency_option.code as "currencyCode",
q.approved_template_version_id as "approvedTemplateVersionId",
q.salesman_id as "salesmanId",
q.created_by as "createdBy",
q.created_at as "createdAt",
q.approved_snapshot as "approvedSnapshot",
q.approved_pdf_url as "approvedPdfUrl"
from crm_quotations q
left join ms_options currency_option
on currency_option.id = q.currency
where q.code = ${AUDIT_QUOTATION_CODE}
and q.deleted_at is null
limit 1
`;
if (fixtureQuotation) {
return fixtureQuotation;
}
const [fallbackQuotation] = await sql`
select
q.id,
q.code,
q.organization_id as "organizationId",
q.customer_id as "customerId",
q.contact_id as "contactId",
q.quotation_date as "quotationDate",
q.valid_until as "validUntil",
q.project_name as "projectName",
q.project_location as "projectLocation",
q.attention,
q.reference,
q.currency,
q.subtotal,
q.total_amount as "totalAmount",
currency_option.code as "currencyCode",
q.salesman_id as "salesmanId",
q.created_by as "createdBy",
q.created_at as "createdAt",
q.approved_snapshot as "approvedSnapshot",
q.approved_pdf_url as "approvedPdfUrl"
from crm_quotations q
left join ms_options currency_option
on currency_option.id = q.currency
where q.deleted_at is null
order by q.updated_at desc
limit 1
`;
if (!fallbackQuotation) {
throw new Error('No quotation found for PDF audit');
}
return fallbackQuotation;
}
function getValueByPath(source: unknown, valuePath: string): unknown {
return valuePath.split('.').reduce<unknown>((current, segment) => {
if (current === null || current === undefined) {
return undefined;
}
if (Array.isArray(current) && /^\d+$/.test(segment)) {
return current[Number(segment)];
}
if (typeof current === 'object' && current !== null && segment in current) {
return (current as Record<string, unknown>)[segment];
}
return undefined;
}, source);
}
function formatPdfDate(dateString: string | Date | null | undefined) {
if (!dateString) {
return '-';
}
const date = dateString instanceof Date ? dateString : new Date(dateString);
if (Number.isNaN(date.getTime())) {
return '-';
}
return date.toLocaleDateString('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
function formatPdfCurrency(amount: number | string | null | undefined, currencyCode = 'THB') {
if (amount === null || amount === undefined) {
return '-';
}
const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
if (!Number.isFinite(normalized)) {
return '-';
}
const symbols: Record<string, string> = {
THB: 'THB ',
USD: '$',
EUR: 'EUR '
};
const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `;
return `${prefix}${new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(normalized)}`.trim();
}
function formatPdfCurrencyAmount(amount: number | string | null | undefined, currencyCode = 'THB') {
if (amount === null || amount === undefined) {
return '-';
}
const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
if (!Number.isFinite(normalized)) {
return '-';
}
const symbols: Record<string, string> = {
THB: '฿',
USD: '$',
EUR: 'EUR '
};
const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `;
return `${prefix}${new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(normalized)}`.trim();
}
function buildPdfPriceTableData(amount: number | string | null | undefined, currencyCode?: string | null) {
const normalizedCurrency = currencyCode?.trim() || 'THB';
return [[
PRICE_TABLE_LABEL,
formatPdfCurrencyAmount(amount, normalizedCurrency),
' ',
normalizedCurrency
]];
}
function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: number | null }> = []) {
if (!items.length) {
return [['-']];
}
return [...items]
.sort((left, right) => (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER))
.map((item) => [item.content?.trim() || '-']);
}
function formatPdfDecimal(amount: number | string | null | undefined) {
if (amount === null || amount === undefined) {
return '-';
}
const normalized =
typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
if (!Number.isFinite(normalized)) {
return '-';
}
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 3
}).format(normalized);
}
function buildAuditItemsTable(
items: Array<{
itemNumber?: number | null;
description?: string | null;
quantity?: number | null;
unitLabel?: string | null;
unitPrice?: number | null;
discount?: number | null;
totalPrice?: number | null;
notes?: string | null;
}>,
currencyCode?: string | null
) {
return items.map((item, index) => {
const description = [item.description?.trim(), item.notes?.trim()]
.filter((value): value is string => Boolean(value))
.join('\n')
.trim();
return [
String(item.itemNumber ?? index + 1),
description || '-',
formatPdfDecimal(item.quantity ?? null),
item.unitLabel?.trim() || '-',
formatPdfCurrency(item.unitPrice ?? null, currencyCode?.trim() || 'THB'),
formatPdfCurrency(item.discount ?? 0, currencyCode?.trim() || 'THB'),
formatPdfCurrency(item.totalPrice ?? null, currencyCode?.trim() || 'THB')
];
});
}
function applyFormatMask(value: unknown, formatMask?: string | null) {
if (value === null || value === undefined) {
return value;
}
if (formatMask === 'currency' || formatMask?.startsWith('currency_')) {
const currencyCode = formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', '');
return formatPdfCurrency(value as number | string, currencyCode);
}
if (formatMask === 'date' || formatMask === 'date_en') {
return formatPdfDate(value as string);
}
return value;
}
function isBlankString(value: unknown) {
return typeof value === 'string' && value.trim().length === 0;
}
function resolveScalarMappingValue(
rawValue: unknown,
formattedValue: unknown,
defaultValue?: string | null
) {
if (formattedValue !== undefined && formattedValue !== null && !isBlankString(formattedValue)) {
return formattedValue;
}
if (rawValue !== undefined && rawValue !== null && !isBlankString(rawValue)) {
return rawValue;
}
if (defaultValue !== undefined && defaultValue !== null && defaultValue.trim().length > 0) {
return defaultValue;
}
return '-';
}
function normalizePdfmeTable(
value: unknown,
columns?: Array<{ sourceField: string; formatMask?: string | null }>
) {
if (!Array.isArray(value) || value.length === 0) {
return [['-']];
}
if (value.every((row) => Array.isArray(row))) {
return (value as unknown[][]).map((row) =>
row.map((cell) => {
if (typeof cell === 'string') {
const trimmedValue = cell.trim();
if (trimmedValue) {
return trimmedValue;
}
return cell.length > 0 ? cell : '-';
}
return String(cell ?? '-').trim() || '-';
})
);
}
if (!columns?.length) {
return value.map((row) => [String(row ?? '-').trim() || '-']);
}
return value.map((row) => {
const rowRecord = row as Record<string, unknown>;
return columns.map((column) => {
const formatted = applyFormatMask(rowRecord[column.sourceField], column.formatMask);
return String(formatted ?? '-').trim() || '-';
});
});
}
function mapDocumentDataToTemplateInput(
documentData: Record<string, unknown>,
mappings: MappingRecord[],
columns: MappingColumnRecord[]
) {
const result: Record<string, unknown> = {};
const columnMap = new Map<string, MappingColumnRecord[]>();
for (const column of columns) {
const items = columnMap.get(column.mappingId) ?? [];
items.push(column);
columnMap.set(column.mappingId, items);
}
for (const mapping of mappings) {
const rawValue = getValueByPath(documentData, mapping.sourcePath);
if (mapping.dataType === 'table') {
const mappingColumns = columnMap.get(mapping.id) ?? [];
let tableValue = rawValue;
if (!Array.isArray(tableValue) && mapping.defaultValue) {
try {
const parsedDefault = JSON.parse(mapping.defaultValue) as unknown;
tableValue = Array.isArray(parsedDefault) ? parsedDefault : tableValue;
} catch {
// ignore invalid legacy table defaults
}
}
result[mapping.placeholderKey] = normalizePdfmeTable(tableValue, mappingColumns);
continue;
}
if (mapping.dataType === 'multiline') {
if (Array.isArray(rawValue)) {
result[mapping.placeholderKey] = rawValue
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.join('\n');
continue;
}
result[mapping.placeholderKey] =
typeof rawValue === 'string' && rawValue.trim().length > 0
? rawValue
: mapping.defaultValue ?? '-';
continue;
}
const formattedValue = applyFormatMask(rawValue, mapping.formatMask);
result[mapping.placeholderKey] = resolveScalarMappingValue(
rawValue,
formattedValue,
mapping.defaultValue
);
}
return result;
}
function normalizeBusinessRoleLabel(businessRole: string | null | undefined) {
if (!businessRole) {
return '-';
}
return (
SIGNATURE_ROLE_FALLBACK_LABELS[businessRole] ??
businessRole
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
);
}
function buildTopicInputs(topics: Array<{ topicType: string; items: Array<{ content: string; sortOrder: number }> }>) {
const sortedTopics = [...topics].sort((left, right) => left.topicType.localeCompare(right.topicType));
const topicInputs: Record<string, string[][]> = {};
for (const [index, topic] of sortedTopics.entries()) {
topicInputs[`topic_1_${index}`] = [[topic.topicType || '-']];
topicInputs[`item_topic_1_${index}`] = formatTopicItems(topic.items);
}
return topicInputs;
}
export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
const quotation = await findAuditQuotation(sql);
const [organization, customer, contact, itemRows, topicRows, topicItemRows, partyRows, jobTitles] =
await Promise.all([
sql`
select id, name
from organizations
where id = ${quotation.organizationId}
limit 1
`.then((rows) => rows[0] ?? null),
sql`
select id, name, address, phone, email
from crm_customers
where id = ${quotation.customerId}
limit 1
`.then((rows) => rows[0] ?? null),
quotation.contactId
? sql`
select id, name, email, mobile, position
from crm_customer_contacts
where id = ${quotation.contactId}
limit 1
`.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
sql`
select
qi.id,
qi.item_number as "itemNumber",
qi.description,
qi.quantity,
qi.unit,
qi.unit_price as "unitPrice",
qi.discount,
qi.total_price as "totalPrice",
qi.sort_order as "sortOrder",
u.label as "unitLabel"
from crm_quotation_items qi
left join ms_options u
on u.id = qi.unit
where qi.quotation_id = ${quotation.id}
and qi.deleted_at is null
order by qi.sort_order asc, qi.item_number asc
`,
sql`
select
qt.id,
qt.title,
qt.sort_order as "sortOrder",
qt.topic_type as "topicTypeId",
o.code as "topicCode",
coalesce(o.label, qt.title) as "topicLabel"
from crm_quotation_topics qt
left join ms_options o
on o.id = qt.topic_type
where qt.quotation_id = ${quotation.id}
and qt.deleted_at is null
order by qt.sort_order asc, qt.created_at asc
`,
sql`
select
qti.id,
qti.topic_id as "topicId",
qti.content,
qti.sort_order as "sortOrder"
from crm_quotation_topic_items qti
where qti.deleted_at is null
and qti.topic_id in (
select id
from crm_quotation_topics
where quotation_id = ${quotation.id}
and deleted_at is null
)
order by qti.sort_order asc, qti.created_at asc
`,
sql`
select
qc.id,
qc.customer_id as "customerId",
qc.role,
qc.is_primary as "isPrimary"
from crm_quotation_customers qc
where qc.quotation_id = ${quotation.id}
and qc.deleted_at is null
`,
sql`
select code, label
from ms_options
where organization_id = ${quotation.organizationId}
and category = 'crm_job_title'
and is_active = true
and deleted_at is null
`
]);
if (!organization || !customer) {
throw new Error('Audit quotation references missing organization or customer');
}
const activeTemplateVersions = await getActiveTemplateVersions(sql);
const templateVersion = activeTemplateVersions.find(
(item) => item.organizationId === quotation.organizationId && item.documentType === 'quotation'
);
if (!templateVersion) {
throw new Error(`No active quotation template version found for organization ${quotation.organizationId}`);
}
const { mappings, columns } = await getMappingsForVersion(sql, templateVersion.versionId);
const topicItemsByTopicId = new Map<string, Array<{ content: string; sortOrder: number }>>();
for (const topicItemRow of castRows<{ topicId: string; content: string; sortOrder: number }>(
topicItemRows
)) {
const items = topicItemsByTopicId.get(topicItemRow.topicId) ?? [];
items.push({
content: topicItemRow.content,
sortOrder: topicItemRow.sortOrder
});
topicItemsByTopicId.set(topicItemRow.topicId, items);
}
const topics = (topicRows as Array<Record<string, unknown>>).map((topicRow) => ({
id: String(topicRow.id),
title: String(topicRow.title ?? topicRow.topicLabel ?? 'Topic'),
topicCode: String(topicRow.topicCode ?? ''),
topicType: String(topicRow.topicLabel ?? topicRow.title ?? 'Topic'),
items: topicItemsByTopicId.get(String(topicRow.id)) ?? []
}));
const exclusionTopic = topics.find((topic) => topic.topicCode === 'exclusion') ?? topics[0] ?? null;
const topicInputs = buildTopicInputs(
topics.map((topic) => ({
topicType: topic.topicType,
items: topic.items
}))
);
const [approvalActions, userRows, membershipRows] = await Promise.all([
sql`
select
aa.acted_by as "actedBy",
aa.acted_at as "actedAt",
aa.action,
aa.step_number as "stepNumber",
aps.role_code as "roleCode",
u.name as "actorName"
from crm_approval_actions aa
inner join crm_approval_requests ar
on ar.id = aa.approval_request_id
left join crm_approval_steps aps
on aps.workflow_id = ar.workflow_id
and aps.step_number = aa.step_number
and aps.deleted_at is null
left join users u
on u.id = aa.acted_by
where ar.entity_type = 'quotation'
and ar.entity_id = ${quotation.id}
and ar.organization_id = ${quotation.organizationId}
and aa.deleted_at is null
order by aa.acted_at asc, aa.created_at asc
`,
sql`
select id, name
from users
where id in (${quotation.salesmanId ?? quotation.createdBy}, ${quotation.createdBy})
`,
sql`
select user_id as "userId", business_role as "businessRole"
from memberships
where organization_id = ${quotation.organizationId}
`
]);
const userMap = new Map(
castRows<{ id: string; name: string }>(userRows).map((row) => [row.id, row.name])
);
const membershipMap = new Map(
castRows<{ userId: string; businessRole: string }>(membershipRows).map((row) => [
row.userId,
row.businessRole
])
);
const jobTitleMap = new Map(
castRows<{ code: string; label: string }>(jobTitles).map((row) => [row.code, row.label])
);
const approvedActions = (approvalActions as Array<Record<string, unknown>>).filter(
(row) => row.action === 'approve'
);
const finalApprovedAction = approvedActions.at(-1) ?? null;
const approvedByAction =
approvedActions.filter((row) => row.roleCode !== finalApprovedAction?.roleCode).at(-1) ?? null;
const preparedByUserId = (quotation.salesmanId as string | null) ?? (quotation.createdBy as string);
const resolvePosition = (userId: string | null | undefined, roleCode?: string | null) => {
const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null;
return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-';
};
const currencyCode =
typeof quotation.currencyCode === 'string' && quotation.currencyCode.trim()
? quotation.currencyCode.trim()
: 'THB';
const documentData: Record<string, unknown> = {
company: {
id: organization.id,
name: organization.name
},
customer: {
id: customer.id,
name: customer.name,
address: customer.address,
phone: customer.phone,
email: customer.email
},
contact: contact
? {
id: contact.id,
name: contact.name,
email: contact.email,
mobile: contact.mobile,
position: contact.position
}
: null,
quotation: {
id: quotation.id,
code: quotation.code,
quotationDate: quotation.quotationDate,
validUntil: quotation.validUntil,
projectName: quotation.projectName,
projectLocation: quotation.projectLocation,
attention: quotation.attention,
reference: quotation.reference,
currency: currencyCode,
currencyCode,
subtotal: quotation.subtotal,
totalAmount: quotation.totalAmount
},
items: (itemRows as Array<Record<string, unknown>>).map((row) => ({
id: row.id,
itemNumber: row.itemNumber,
description: row.description,
quantity: row.quantity,
unitLabel: row.unitLabel ?? row.unit ?? '-',
unitPrice: row.unitPrice,
discount: row.discount,
totalPrice: row.totalPrice
})),
quotationCustomers: partyRows,
topics: {
all: topics,
exclusion: exclusionTopic ? [exclusionTopic] : []
},
pdfme: {
labels: PDFME_STATIC_LABELS,
quotation_date: formatPdfDate(quotation.quotationDate as string),
quotation_price: formatPdfCurrency(quotation.subtotal as number, currencyCode),
quotation_price_data: buildPdfPriceTableData(quotation.subtotal as number, currencyCode),
exclusion_data: formatTopicItems(exclusionTopic?.items ?? []),
topic_inputs: topicInputs
},
signatures: {
preparedBy: {
name: userMap.get(preparedByUserId) ?? '-',
position: resolvePosition(preparedByUserId),
actedAt: new Date(String(quotation.createdAt)).toISOString()
},
approvedBy: {
name: String(approvedByAction?.actorName ?? '-'),
position: resolvePosition(
(approvedByAction?.actedBy as string | null | undefined) ?? null,
(approvedByAction?.roleCode as string | null | undefined) ?? null
),
actedAt: approvedByAction?.actedAt ? new Date(String(approvedByAction.actedAt)).toISOString() : null
},
authorizedBy: {
name: String(finalApprovedAction?.actorName ?? '-'),
position: resolvePosition(
(finalApprovedAction?.actedBy as string | null | undefined) ?? null,
(finalApprovedAction?.roleCode as string | null | undefined) ?? null
),
actedAt: finalApprovedAction?.actedAt
? new Date(String(finalApprovedAction.actedAt)).toISOString()
: null
}
}
};
const templateInput = {
...mapDocumentDataToTemplateInput(documentData, mappings, columns),
...resolveQuotationStaticTemplateInputs(
documentData as unknown as Parameters<
typeof resolveQuotationStaticTemplateInputs
>[0],
),
...topicInputs
};
const templateFields = extractTemplateFields(templateVersion.schemaJson);
const hasItemsTableField = templateFields.some((field) => field.name === 'items_table');
const normalizedTemplateInput = normalizeTemplateInputAliases(
templateFields,
{
...templateInput,
...(hasItemsTableField
? {
items_table: buildAuditItemsTable(
(documentData as { items: Parameters<typeof buildAuditItemsTable>[0] }).items,
(documentData as { quotation: { currencyCode?: string | null } }).quotation.currencyCode
)
}
: {}),
}
);
return {
organizationId: quotation.organizationId,
quotationId: quotation.id,
quotationCode: quotation.code,
templateVersionId: templateVersion.versionId,
approvedTemplateVersionId:
typeof quotation.approvedTemplateVersionId === 'string'
? quotation.approvedTemplateVersionId
: null,
templateName: templateVersion.templateName,
templateVersion: templateVersion.version,
templateSchema: templateVersion.schemaJson,
mappings,
mappingColumns: columns,
documentData,
templateInput: normalizedTemplateInput,
topicInputs,
quotationMetrics: {
topicCount: topics.length,
itemCount: (itemRows as Array<unknown>).length,
partyCount: (partyRows as Array<unknown>).length
},
approvedSnapshot: normalizeSnapshotPayload(quotation.approvedSnapshot),
approvedArtifactReference:
typeof quotation.approvedPdfUrl === 'string' ? quotation.approvedPdfUrl : null
};
}
export function summarizeIssues(statuses: Array<'PASS' | 'WARNING' | 'FAIL'>) {
if (statuses.includes('FAIL')) {
return 'FAIL';
}
if (statuses.includes('WARNING')) {
return 'WARNING';
}
return 'PASS';
}
function castRows<T>(rows: unknown): T[] {
return rows as T[];
}
export interface ComputedSnapshotPayload {
quotationId: string;
approvedAt: string | null;
documentData: Record<string, unknown>;
templateVersionId: string;
templateInput: Record<string, unknown>;
generatedAt: string;
generatedBy: string;
}
export function buildComputedSnapshotPayload(
payload: AuditPayload,
options?: { generatedAt?: string; generatedBy?: string }
): ComputedSnapshotPayload {
return {
quotationId: payload.quotationId,
approvedAt:
typeof (payload.documentData.approval as Record<string, unknown> | undefined)?.approvedAt ===
'string'
? ((payload.documentData.approval as Record<string, unknown>).approvedAt as string)
: null,
documentData: payload.documentData,
templateVersionId: payload.templateVersionId,
templateInput: payload.templateInput,
generatedAt: options?.generatedAt ?? 'audit-generated',
generatedBy: options?.generatedBy ?? 'audit-suite'
};
}
export function getSnapshotTemplateInput(snapshot: Record<string, unknown> | null) {
if (!snapshot) {
return null;
}
return snapshot.templateInput && typeof snapshot.templateInput === 'object'
? (snapshot.templateInput as Record<string, unknown>)
: null;
}
export function getSnapshotDocumentData(snapshot: Record<string, unknown> | null) {
if (!snapshot) {
return null;
}
return snapshot.documentData && typeof snapshot.documentData === 'object'
? (snapshot.documentData as Record<string, unknown>)
: null;
}