1090 lines
31 KiB
TypeScript
1090 lines
31 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';
|
|
|
|
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 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',
|
|
'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 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 &&
|
|
!SYSTEM_FIELD_NAMES.has(field.name) &&
|
|
!DYNAMIC_TOPIC_FIELD_NAMES.has(field.name) &&
|
|
!mappedKeys.has(field.name) &&
|
|
field.tokens.length === 0 &&
|
|
field.type !== 'table' &&
|
|
field.type !== 'text'
|
|
)
|
|
.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 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 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.total_amount as "totalAmount",
|
|
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
|
|
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.total_amount as "totalAmount",
|
|
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
|
|
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 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 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) => 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) ?? [];
|
|
result[mapping.placeholderKey] = normalizePdfmeTable(rawValue, 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 : mapping.defaultValue ?? '';
|
|
continue;
|
|
}
|
|
|
|
result[mapping.placeholderKey] =
|
|
applyFormatMask(rawValue, mapping.formatMask) ?? 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 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: quotation.currency,
|
|
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: {
|
|
quotation_date: formatPdfDate(quotation.quotationDate as string),
|
|
quotation_price: formatPdfCurrency(quotation.totalAmount as number, 'THB'),
|
|
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),
|
|
...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:
|
|
quotation.approvedSnapshot && typeof quotation.approvedSnapshot === 'object'
|
|
? (quotation.approvedSnapshot as Record<string, unknown>)
|
|
: null,
|
|
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;
|
|
}
|