1536 lines
42 KiB
TypeScript
1536 lines
42 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;
|
|
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 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: 'Total',
|
|
sourceField: 'totalPrice',
|
|
columnLetter: 'F',
|
|
sortOrder: 6,
|
|
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) {
|
|
const normalized = organizationName.toUpperCase();
|
|
const fileName = normalized.includes('ONVALLA')
|
|
? 'ONVALLA_template_pdfme_fainal3.json'
|
|
: normalized.includes('ALLA')
|
|
? '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 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.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 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 normalizedTemplateInput = normalizeTemplateInputAliases(
|
|
extractTemplateFields(templateVersion.schemaJson),
|
|
templateInput
|
|
);
|
|
|
|
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;
|
|
}
|