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; 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; templateInput: Record; topicInputs: Record; quotationMetrics: { topicCount: number; itemCount: number; partyCount: number; }; approvedSnapshot: Record | 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 = { 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(); 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(); 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(); 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 = 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 ) { 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 { 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(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(mappings), columns: castRows(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((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)[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 = { 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; return columns.map((column) => { const formatted = applyFormatMask(rowRecord[column.sourceField], column.formatMask); return String(formatted ?? '-').trim() || '-'; }); }); } function mapDocumentDataToTemplateInput( documentData: Record, mappings: MappingRecord[], columns: MappingColumnRecord[] ) { const result: Record = {}; const columnMap = new Map(); 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 = {}; 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 { 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>(); 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>).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>).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 = { 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>).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).length, partyCount: (partyRows as Array).length }, approvedSnapshot: quotation.approvedSnapshot && typeof quotation.approvedSnapshot === 'object' ? (quotation.approvedSnapshot as Record) : 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(rows: unknown): T[] { return rows as T[]; } export interface ComputedSnapshotPayload { quotationId: string; approvedAt: string | null; documentData: Record; templateVersionId: string; templateInput: Record; 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 | undefined)?.approvedAt === 'string' ? ((payload.documentData.approval as Record).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 | null) { if (!snapshot) { return null; } return snapshot.templateInput && typeof snapshot.templateInput === 'object' ? (snapshot.templateInput as Record) : null; } export function getSnapshotDocumentData(snapshot: Record | null) { if (!snapshot) { return null; } return snapshot.documentData && typeof snapshot.documentData === 'object' ? (snapshot.documentData as Record) : null; }