pdf task-h

This commit is contained in:
phaichayon
2026-06-19 15:09:58 +07:00
parent a85d24235c
commit fd01ebf7c7
35 changed files with 3905 additions and 133 deletions

View File

@@ -0,0 +1,67 @@
import {
buildAuditPayload,
buildComputedSnapshotPayload,
computePayloadHash,
createSqlClient,
getSnapshotDocumentData,
getSnapshotTemplateInput,
summarizeIssues,
writeAuditArtifact
} from './pdf-audit-utils.ts';
async function main() {
const sql = createSqlClient();
try {
const payload = await buildAuditPayload(sql);
const computedSnapshot = buildComputedSnapshotPayload(payload, {
generatedAt: 'audit-generated',
generatedBy: 'audit-suite'
});
const storedTemplateInput = getSnapshotTemplateInput(payload.approvedSnapshot);
const storedDocumentData = getSnapshotDocumentData(payload.approvedSnapshot);
const comparableKeys = storedTemplateInput
? Object.keys(computedSnapshot.templateInput).filter((key) => key in storedTemplateInput)
: [];
const inconsistentTemplateKeys = storedTemplateInput
? comparableKeys.filter(
(key) =>
JSON.stringify(computedSnapshot.templateInput[key]) !==
JSON.stringify(storedTemplateInput[key])
)
: [];
const computedSignatureHash = computePayloadHash(
(computedSnapshot.documentData.signatures as Record<string, unknown>) ?? {}
);
const storedSignatureHash = storedDocumentData
? computePayloadHash((storedDocumentData.signatures as Record<string, unknown>) ?? {})
: null;
const status = summarizeIssues([
payload.approvedSnapshot ? 'PASS' : 'WARNING',
inconsistentTemplateKeys.length ? 'FAIL' : 'PASS',
payload.approvedSnapshot && computedSignatureHash !== storedSignatureHash ? 'FAIL' : 'PASS'
]);
const report = {
audit: 'approved-snapshot-parity',
overallStatus: status,
quotationCode: payload.quotationCode,
approvedSnapshotAvailable: Boolean(payload.approvedSnapshot),
approvedTemplateVersionId: payload.approvedTemplateVersionId,
computedTemplateVersionId: computedSnapshot.templateVersionId,
inconsistentTemplateKeys,
computedSignatureHash,
storedSignatureHash,
approvedArtifactReference: payload.approvedArtifactReference
};
const artifactPath = await writeAuditArtifact('signature-audit-report.json', report);
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[audit-approved-snapshot-parity] ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,81 @@
import {
buildAuditPayload,
buildComputedSnapshotPayload,
computePayloadHash,
createSqlClient,
summarizeIssues,
writeAuditArtifact
} from './pdf-audit-utils.ts';
function pickComparablePayload(payload: Record<string, unknown>) {
return {
templateInput: payload.templateInput,
topicInputKeys:
payload.topicInputs && typeof payload.topicInputs === 'object'
? Object.keys(payload.topicInputs as Record<string, unknown>).sort()
: []
};
}
async function main() {
const sql = createSqlClient();
try {
const payload = await buildAuditPayload(sql);
const previewPayload = pickComparablePayload({
templateInput: payload.templateInput,
topicInputs: payload.topicInputs
});
const generationPayload = pickComparablePayload({
templateInput: payload.templateInput,
topicInputs: payload.topicInputs
});
const computedSnapshot = buildComputedSnapshotPayload(payload, {
generatedAt: 'audit-generated',
generatedBy: 'audit-suite'
});
const snapshotPayload = pickComparablePayload({
templateInput: computedSnapshot.templateInput,
topicInputs: payload.topicInputs
});
const previewHash = computePayloadHash(previewPayload);
const generationHash = computePayloadHash(generationPayload);
const snapshotHash = computePayloadHash(snapshotPayload);
const mismatches = [
previewHash !== generationHash ? 'preview:generation' : null,
generationHash !== snapshotHash ? 'generation:snapshot' : null,
previewHash !== snapshotHash ? 'preview:snapshot' : null
].filter(Boolean);
const signatureKeys = ['preparedBy', 'approvedBy', 'authorizedBy'];
const signatureParity = signatureKeys.every((key) => {
const signatures = payload.documentData.signatures as Record<string, unknown>;
return key in signatures;
});
const status = summarizeIssues([
mismatches.length ? 'FAIL' : 'PASS',
signatureParity ? 'PASS' : 'FAIL'
]);
const report = {
audit: 'payload-parity',
overallStatus: status,
quotationCode: payload.quotationCode,
previewHash,
generationHash,
snapshotHash,
mismatches,
comparableFieldCount: Object.keys(payload.templateInput).length,
topicInputKeyCount: Object.keys(payload.topicInputs).length,
signatureParity
};
const artifactPath = await writeAuditArtifact('payload-parity-report.json', report);
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[audit-payload-parity] ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,125 @@
import {
createSqlClient,
extractTemplateFields,
getActiveTemplateVersions,
getEffectiveMappedKeys,
getMappingsForVersion,
summarizeIssues
} from './pdf-audit-utils.ts';
function classifyToken(token: string, mappedKeys: Set<string>) {
if (token === 'topic' || token === 'data_topic') {
return 'dynamic_topic_field';
}
if (token === 'item_topic') {
return 'dynamic_topic_field';
}
if (token.startsWith('topic_') || token.startsWith('item_topic_')) {
return 'dynamic_runtime_field';
}
if (mappedKeys.has(token)) {
return 'mapped_field';
}
return 'unmapped_field';
}
async function main() {
const sql = createSqlClient();
try {
const templates = await getActiveTemplateVersions(sql);
const results = [];
for (const template of templates) {
const fields = extractTemplateFields(template.schemaJson);
const { mappings, columns } = await getMappingsForVersion(sql, template.versionId);
const mappedKeys = getEffectiveMappedKeys(fields, mappings);
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
const schemaTokens = [...new Set(fields.flatMap((field) => field.tokens))].sort();
const coverage = schemaTokens.map((token) => {
const mapping =
mappings.find((candidate) => candidate.placeholderKey === token) ??
mappings.find(
(candidate) =>
fields.some(
(field) =>
field.name === candidate.placeholderKey &&
field.tokens.length === 1 &&
field.tokens[0] === token
)
) ??
null;
return {
token,
classification: classifyToken(token, mappedKeys),
sourcePath: mapping?.sourcePath ?? null,
dataType: mapping?.dataType ?? null
};
});
const unmappedFields = coverage.filter((item) => item.classification === 'unmapped_field');
const orphanMappings = mappings
.filter(
(mapping) =>
!schemaTokens.includes(mapping.placeholderKey) &&
!fieldNames.has(mapping.placeholderKey) &&
mapping.placeholderKey !== 'items_table'
)
.map((mapping) => ({
placeholderKey: mapping.placeholderKey,
sourcePath: mapping.sourcePath
}));
const tableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null;
const tableColumns = tableMapping
? columns
.filter((column) => column.mappingId === tableMapping.id)
.map((column) => ({
columnName: column.columnName,
sourceField: column.sourceField
}))
: [];
const status = summarizeIssues([
unmappedFields.length ? 'FAIL' : 'PASS',
orphanMappings.length ? 'WARNING' : 'PASS'
]);
results.push({
templateName: template.templateName,
version: template.version,
organizationId: template.organizationId,
tokenCount: schemaTokens.length,
coverage,
unmappedFields,
orphanMappings,
tableColumns,
status
});
}
const overallStatus = summarizeIssues(
results.map((result) => result.status) as Array<'PASS' | 'WARNING' | 'FAIL'>
);
console.log(
JSON.stringify(
{
audit: 'mapping-coverage',
overallStatus,
results
},
null,
2
)
);
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[audit-pdf-mapping-coverage] ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,153 @@
import { buildAuditPayload, createSqlClient, summarizeIssues } from './pdf-audit-utils.ts';
type PayloadFinding = {
field: string;
source: 'documentData' | 'templateInput' | 'topicInputs';
status: 'PASS' | 'WARNING' | 'FAIL';
value: unknown;
message: string;
};
function pushFinding(findings: PayloadFinding[], finding: PayloadFinding) {
findings.push(finding);
}
function walkValue(
findings: PayloadFinding[],
source: PayloadFinding['source'],
prefix: string,
value: unknown
) {
if (value === undefined) {
pushFinding(findings, {
field: prefix,
source,
status: 'FAIL',
value,
message: 'Value is undefined'
});
return;
}
if (value === null) {
pushFinding(findings, {
field: prefix,
source,
status: 'WARNING',
value,
message: 'Value is null'
});
return;
}
if (typeof value === 'string') {
pushFinding(findings, {
field: prefix,
source,
status: value.trim() ? 'PASS' : 'WARNING',
value,
message: value.trim() ? 'String is populated' : 'String is empty'
});
return;
}
if (Array.isArray(value)) {
if (source === 'documentData' && value.some((row) => !Array.isArray(row))) {
pushFinding(findings, {
field: prefix,
source,
status: 'PASS',
value: `rows:${value.length}`,
message: 'Collection payload is populated'
});
for (const [index, item] of value.entries()) {
walkValue(findings, source, `${prefix}[${index}]`, item);
}
return;
}
const validTable = value.every(
(row) => Array.isArray(row) && row.every((cell) => typeof cell === 'string')
);
pushFinding(findings, {
field: prefix,
source,
status: validTable ? 'PASS' : 'FAIL',
value: Array.isArray(value) ? `rows:${value.length}` : value,
message: validTable ? 'Array shape is valid' : 'Array shape is invalid for PDF payload'
});
for (const [index, item] of value.entries()) {
if (Array.isArray(item)) {
for (const [cellIndex, cell] of item.entries()) {
if (typeof cell !== 'string') {
pushFinding(findings, {
field: `${prefix}[${index}][${cellIndex}]`,
source,
status: 'FAIL',
value: cell,
message: 'Table cell is not a string'
});
}
}
}
}
return;
}
if (typeof value === 'object') {
for (const [key, childValue] of Object.entries(value)) {
walkValue(findings, source, prefix ? `${prefix}.${key}` : key, childValue);
}
return;
}
pushFinding(findings, {
field: prefix,
source,
status: 'PASS',
value,
message: 'Scalar value is populated'
});
}
async function main() {
const sql = createSqlClient();
try {
const payload = await buildAuditPayload(sql);
const findings: PayloadFinding[] = [];
walkValue(findings, 'documentData', 'documentData', payload.documentData);
walkValue(findings, 'templateInput', 'templateInput', payload.templateInput);
walkValue(findings, 'topicInputs', 'topicInputs', payload.topicInputs);
const status = summarizeIssues(findings.map((finding) => finding.status));
console.log(
JSON.stringify(
{
audit: 'runtime-payload',
overallStatus: status,
quotationCode: payload.quotationCode,
templateName: payload.templateName,
templateVersion: payload.templateVersion,
findingCount: findings.length,
findings
},
null,
2
)
);
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[audit-pdf-runtime-payload] ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,89 @@
import {
createSqlClient,
getEffectiveMappedKeys,
extractTemplateFields,
getActiveTemplateVersions,
getDuplicateFieldNames,
getMappingsForVersion,
getUnknownFieldNames,
summarizeIssues
} from './pdf-audit-utils.ts';
async function main() {
const sql = createSqlClient();
try {
const templates = await getActiveTemplateVersions(sql);
const results = [];
for (const template of templates) {
const fields = extractTemplateFields(template.schemaJson);
const { mappings } = await getMappingsForVersion(sql, template.versionId);
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
const mappedKeys = getEffectiveMappedKeys(fields, mappings);
const duplicateFields = getDuplicateFieldNames(fields);
const unknownFields = getUnknownFieldNames(fields, mappedKeys);
const tableFields = fields.filter((field) => field.type === 'table').map((field) => field.name);
const dynamicTopicFields = fields
.filter((field) => field.name === 'topic' || field.name === 'data_topic')
.map((field) => field.name);
const placeholderTokens = new Set(fields.flatMap((field) => field.tokens));
const missingMappings = [...placeholderTokens].filter(
(token) => token !== 'item_topic' && !mappedKeys.has(token)
);
const orphanMappings = mappings
.filter(
(mapping) =>
!placeholderTokens.has(mapping.placeholderKey) &&
!fieldNames.has(mapping.placeholderKey) &&
mapping.placeholderKey !== 'items_table'
)
.map((mapping) => mapping.placeholderKey);
const status = summarizeIssues([
duplicateFields.length ? 'FAIL' : 'PASS',
missingMappings.length ? 'FAIL' : 'PASS',
orphanMappings.length ? 'WARNING' : 'PASS',
unknownFields.length ? 'WARNING' : 'PASS'
]);
results.push({
templateName: template.templateName,
version: template.version,
organizationId: template.organizationId,
schemaFieldCount: fields.length,
tableFields,
dynamicTopicFields,
duplicateFields,
unknownFields,
missingMappings,
orphanMappings,
mappedFieldCount: mappings.length,
status
});
}
const overallStatus = summarizeIssues(
results.map((result) => result.status) as Array<'PASS' | 'WARNING' | 'FAIL'>
);
console.log(
JSON.stringify(
{
audit: 'template-inventory',
overallStatus,
templateCount: results.length,
results
},
null,
2
)
);
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[audit-pdf-template-inventory] ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,105 @@
import {
createSqlClient,
extractTemplateFields,
getActiveTemplateVersions,
getEffectiveMappedKeys,
getMappingsForVersion,
isDesignerLabelField,
summarizeIssues,
writeAuditArtifact
} from './pdf-audit-utils.ts';
const TYPO_PATTERNS = [/lable/i, /affter/i, /__+/, /\s{2,}/];
const RESERVED_DYNAMIC_FIELD_NAMES = new Set(['topic', 'data_topic']);
function detectTypo(fieldName: string) {
return TYPO_PATTERNS.some((pattern) => pattern.test(fieldName));
}
async function main() {
const sql = createSqlClient();
try {
const templates = await getActiveTemplateVersions(sql);
const results = [];
for (const template of templates) {
const fields = extractTemplateFields(template.schemaJson);
const { mappings } = await getMappingsForVersion(sql, template.versionId);
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
const mappedKeys = getEffectiveMappedKeys(fields, mappings);
const tokens = [...new Set(fields.flatMap((field) => field.tokens))].sort();
const duplicateFieldNames = [...fieldNames].filter(
(fieldName) => fields.filter((field) => field.name === fieldName).length > 1
);
const typoFields = fields
.filter((field) => field.name && detectTypo(field.name) && !isDesignerLabelField(field))
.map((field) => field.name);
const unmappedPlaceholders = tokens.filter(
(token) =>
token !== 'item_topic' &&
!token.startsWith('topic_') &&
!token.startsWith('item_topic_') &&
!mappedKeys.has(token)
);
const deadMappings = mappings
.filter(
(mapping) =>
!fieldNames.has(mapping.placeholderKey) &&
!tokens.includes(mapping.placeholderKey) &&
mapping.placeholderKey !== 'items_table'
)
.map((mapping) => mapping.placeholderKey);
const invalidReservedMappings = mappings
.filter(
(mapping) =>
mapping.placeholderKey.startsWith('topic_') ||
mapping.placeholderKey.startsWith('item_topic_')
)
.map((mapping) => mapping.placeholderKey);
const missingReservedFields = [...RESERVED_DYNAMIC_FIELD_NAMES].filter(
(name) => !fieldNames.has(name)
);
const status = summarizeIssues([
duplicateFieldNames.length ? 'FAIL' : 'PASS',
typoFields.length ? 'FAIL' : 'PASS',
unmappedPlaceholders.length ? 'FAIL' : 'PASS',
deadMappings.length ? 'FAIL' : 'PASS',
invalidReservedMappings.length ? 'FAIL' : 'PASS',
missingReservedFields.length ? 'FAIL' : 'PASS'
]);
results.push({
templateName: template.templateName,
organizationId: template.organizationId,
version: template.version,
duplicateFieldNames,
typoFields,
unmappedPlaceholders,
deadMappings,
invalidReservedMappings,
missingReservedFields,
status
});
}
const overallStatus = summarizeIssues(
results.map((result) => result.status) as Array<'PASS' | 'WARNING' | 'FAIL'>
);
const report = {
audit: 'placeholder-integrity',
overallStatus,
results
};
const artifactPath = await writeAuditArtifact('placeholder-integrity-report.json', report);
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[audit-placeholder-integrity] ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,166 @@
import {
buildAuditPayload,
createSqlClient,
getActiveTemplateVersions,
summarizeIssues,
writeAuditArtifact
} from './pdf-audit-utils.ts';
type IntegrityIssue = {
kind:
| 'DUPLICATE_ACTIVE_TEMPLATE'
| 'DUPLICATE_ACTIVE_VERSION'
| 'ACTIVE_VERSION_WITHOUT_TEMPLATE'
| 'MAPPING_WITHOUT_VERSION'
| 'INVALID_APPROVED_TEMPLATE_VERSION';
message: string;
context: Record<string, unknown>;
};
async function main() {
const sql = createSqlClient();
try {
const [activeTemplateVersions, versionRows, orphanMappingRows, payload] = await Promise.all([
getActiveTemplateVersions(sql),
sql`
select
v.id,
v.template_id as "templateId",
v.organization_id as "organizationId",
v.version,
v.is_active as "isActive",
t.id as "resolvedTemplateId"
from crm_document_template_versions v
left join crm_document_templates t
on t.id = v.template_id
where v.deleted_at is null
`,
sql`
select
m.id,
m.template_version_id as "templateVersionId",
m.placeholder_key as "placeholderKey"
from crm_document_template_mappings m
left join crm_document_template_versions v
on v.id = m.template_version_id
where m.deleted_at is null
and (v.id is null or v.deleted_at is not null)
`,
buildAuditPayload(sql)
]);
const issues: IntegrityIssue[] = [];
const templateBuckets = new Map<string, typeof activeTemplateVersions>();
for (const record of activeTemplateVersions) {
const bucketKey = [
record.organizationId,
record.documentType,
record.productType,
record.fileType
].join(':');
const items = templateBuckets.get(bucketKey) ?? [];
items.push(record);
templateBuckets.set(bucketKey, items);
}
for (const [bucketKey, records] of templateBuckets.entries()) {
const templateIds = [...new Set(records.map((record) => record.templateId))];
if (templateIds.length > 1) {
issues.push({
kind: 'DUPLICATE_ACTIVE_TEMPLATE',
message: `More than one active template exists for ${bucketKey}`,
context: {
bucketKey,
templateIds,
templates: records.map((record) => ({
templateId: record.templateId,
templateName: record.templateName,
versionId: record.versionId,
version: record.version
}))
}
});
}
}
const versionBuckets = new Map<string, Array<Record<string, unknown>>>();
for (const row of versionRows as Array<Record<string, unknown>>) {
if (!row.isActive) {
continue;
}
const items = versionBuckets.get(String(row.templateId)) ?? [];
items.push(row);
versionBuckets.set(String(row.templateId), items);
if (!row.resolvedTemplateId) {
issues.push({
kind: 'ACTIVE_VERSION_WITHOUT_TEMPLATE',
message: `Active version ${String(row.id)} has no parent template`,
context: row
});
}
}
for (const [templateId, rows] of versionBuckets.entries()) {
if (rows.length > 1) {
issues.push({
kind: 'DUPLICATE_ACTIVE_VERSION',
message: `Template ${templateId} has more than one active version`,
context: {
templateId,
versions: rows.map((row) => ({
id: row.id,
version: row.version
}))
}
});
}
}
for (const row of orphanMappingRows as Array<Record<string, unknown>>) {
issues.push({
kind: 'MAPPING_WITHOUT_VERSION',
message: `Mapping ${String(row.id)} points to a missing template version`,
context: row
});
}
if (
payload.approvedTemplateVersionId &&
!activeTemplateVersions.some((record) => record.versionId === payload.approvedTemplateVersionId)
) {
issues.push({
kind: 'INVALID_APPROVED_TEMPLATE_VERSION',
message: 'Audit quotation approvedTemplateVersionId does not resolve to an active template version',
context: {
quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId
}
});
}
const status = summarizeIssues(issues.length ? ['FAIL'] : ['PASS']);
const report = {
audit: 'template-version-integrity',
overallStatus: status,
activeTemplateCount: activeTemplateVersions.length,
quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId,
issues
};
const artifactPath = await writeAuditArtifact('template-version-report.json', report);
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[audit-template-version-integrity] ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,194 @@
import { writeFile } from 'node:fs/promises';
import fs from 'node:fs';
import path from 'node:path';
import {
AUDIT_ARTIFACTS_DIR,
AUDIT_QUOTATION_CODE,
REPORT_JSON_PATH,
REPORT_MARKDOWN_PATH,
buildAuditPayload,
createSqlClient,
extractTemplateFields,
getEffectiveMappedKeys,
getDuplicateFieldNames,
getMappingsForVersion,
getUnknownFieldNames,
summarizeIssues
} from './pdf-audit-utils.ts';
async function main() {
const sql = createSqlClient();
try {
const payload = await buildAuditPayload(sql);
const fields = extractTemplateFields(payload.templateSchema);
const { mappings, columns } = await getMappingsForVersion(sql, payload.templateVersionId);
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
const placeholderTokens = [...new Set(fields.flatMap((field) => field.tokens))].sort();
const mappedKeys = getEffectiveMappedKeys(fields, mappings);
const unmappedTokens = placeholderTokens.filter((token) => {
if (token === 'item_topic') {
return false;
}
if (token === 'topic' || token === 'data_topic') {
return false;
}
if (token.startsWith('topic_') || token.startsWith('item_topic_')) {
return false;
}
return !mappedKeys.has(token);
});
const orphanMappings = mappings
.filter(
(mapping) =>
!placeholderTokens.includes(mapping.placeholderKey) &&
!fieldNames.has(mapping.placeholderKey) &&
mapping.placeholderKey !== 'items_table'
)
.map((mapping) => mapping.placeholderKey);
const duplicateFields = getDuplicateFieldNames(fields);
const unknownFields = getUnknownFieldNames(fields);
const topicRows = Object.keys(payload.topicInputs).filter((key) => key.startsWith('topic_')).length;
const topicItemRows = Object.keys(payload.topicInputs).filter((key) =>
key.startsWith('item_topic_')
).length;
const itemTableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null;
const itemTableColumns = itemTableMapping
? columns.filter((column) => column.mappingId === itemTableMapping.id)
: [];
const snapshotTemplateInput =
payload.approvedSnapshot &&
typeof payload.approvedSnapshot === 'object' &&
'templateInput' in payload.approvedSnapshot
? (payload.approvedSnapshot.templateInput as Record<string, unknown> | null)
: null;
const sharedKeys = snapshotTemplateInput
? Object.keys(payload.templateInput).filter(
(key) =>
!(key.startsWith('topic_') || key.startsWith('item_topic_')) &&
key in snapshotTemplateInput
)
: [];
const inconsistentKeys = sharedKeys.filter(
(key) => JSON.stringify(payload.templateInput[key]) !== JSON.stringify(snapshotTemplateInput?.[key])
);
const consistencyStatus = snapshotTemplateInput
? inconsistentKeys.length
? 'FAIL'
: 'PASS'
: 'WARNING';
const overallStatus = summarizeIssues([
unmappedTokens.length ? 'FAIL' : 'PASS',
orphanMappings.length ? 'WARNING' : 'PASS',
duplicateFields.length ? 'FAIL' : 'PASS',
unknownFields.length ? 'WARNING' : 'PASS',
consistencyStatus
]);
const report = {
generatedAt: new Date().toISOString(),
overallStatus,
quotationCode: payload.quotationCode,
expectedFixtureCode: AUDIT_QUOTATION_CODE,
template: {
templateName: payload.templateName,
version: payload.templateVersion,
versionId: payload.templateVersionId,
fieldCount: fields.length,
duplicateFields,
unknownFields
},
metrics: {
topicCount: payload.quotationMetrics.topicCount,
itemCount: payload.quotationMetrics.itemCount,
partyCount: payload.quotationMetrics.partyCount,
dynamicTopicFieldCount: topicRows,
dynamicTopicItemFieldCount: topicItemRows,
mappingCount: mappings.length
},
mappingCoverage: {
placeholderTokenCount: placeholderTokens.length,
unmappedTokens,
orphanMappings,
itemTableColumns: itemTableColumns.map((column) => ({
columnName: column.columnName,
sourceField: column.sourceField
}))
},
runtimePayload: {
templateInputKeys: Object.keys(payload.templateInput).length,
topicInputKeys: Object.keys(payload.topicInputs).length
},
consistency: {
status: consistencyStatus,
approvedSnapshotAvailable: Boolean(snapshotTemplateInput),
approvedArtifactReference: payload.approvedArtifactReference,
inconsistentKeys
},
integritySummary: {
available: fs.existsSync(path.resolve(AUDIT_ARTIFACTS_DIR, 'summary.json')),
artifactDir: AUDIT_ARTIFACTS_DIR
}
};
const markdown = [
'# PDF Audit Report',
'',
`- Generated At: ${report.generatedAt}`,
`- Overall Status: ${report.overallStatus}`,
`- Quotation Code: ${report.quotationCode}`,
`- Template: ${report.template.templateName} v${report.template.version}`,
'',
'## Metrics',
'',
`- Topic Count: ${report.metrics.topicCount}`,
`- Item Count: ${report.metrics.itemCount}`,
`- Project Party Count: ${report.metrics.partyCount}`,
`- Mapping Count: ${report.metrics.mappingCount}`,
'',
'## Mapping Coverage',
'',
`- Placeholder Tokens: ${report.mappingCoverage.placeholderTokenCount}`,
`- Unmapped Tokens: ${report.mappingCoverage.unmappedTokens.length ? report.mappingCoverage.unmappedTokens.join(', ') : 'None'}`,
`- Orphan Mappings: ${report.mappingCoverage.orphanMappings.length ? report.mappingCoverage.orphanMappings.join(', ') : 'None'}`,
'',
'## Visual / Consistency',
'',
`- Approved Snapshot Available: ${report.consistency.approvedSnapshotAvailable ? 'Yes' : 'No'}`,
`- Consistency Status: ${report.consistency.status}`,
`- Inconsistent Keys: ${report.consistency.inconsistentKeys.length ? report.consistency.inconsistentKeys.join(', ') : 'None'}`,
'',
'## Integrity Layer',
'',
`- Integrity Summary Available: ${report.integritySummary.available ? 'Yes' : 'No'}`,
`- Artifact Directory: ${report.integritySummary.artifactDir}`
].join('\n');
await writeFile(REPORT_JSON_PATH, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
await writeFile(REPORT_MARKDOWN_PATH, `${markdown}\n`, 'utf8');
console.log(
JSON.stringify(
{
audit: 'pdf-report',
overallStatus,
jsonPath: REPORT_JSON_PATH,
markdownPath: REPORT_MARKDOWN_PATH,
report
},
null,
2
)
);
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[generate-pdf-audit-report] ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,115 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
AUDIT_ARTIFACTS_DIR,
buildAuditPayload,
createSqlClient,
summarizeIssues,
writeAuditArtifact
} from './pdf-audit-utils.ts';
async function readReport<T>(fileName: string): Promise<T | null> {
const filePath = path.resolve(AUDIT_ARTIFACTS_DIR, fileName);
try {
const raw = await readFile(filePath, 'utf8');
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function main() {
const sql = createSqlClient();
try {
const payload = await buildAuditPayload(sql);
const templateVersionReport = await readReport<Record<string, unknown>>(
'template-version-report.json'
);
const placeholderReport = await readReport<Record<string, unknown>>(
'placeholder-integrity-report.json'
);
const payloadReport = await readReport<Record<string, unknown>>('payload-parity-report.json');
const snapshotReport = await readReport<Record<string, unknown>>('signature-audit-report.json');
const statuses = [
String(templateVersionReport?.overallStatus ?? 'WARNING'),
String(placeholderReport?.overallStatus ?? 'WARNING'),
String(payloadReport?.overallStatus ?? 'WARNING'),
String(snapshotReport?.overallStatus ?? 'WARNING')
] as Array<'PASS' | 'WARNING' | 'FAIL'>;
const overallStatus = summarizeIssues(statuses);
const topicRuntimeReport = {
audit: 'topic-runtime',
overallStatus: Object.keys(payload.topicInputs).length > 0 ? 'PASS' : 'FAIL',
quotationCode: payload.quotationCode,
topicCount: payload.quotationMetrics.topicCount,
topicInputKeys: Object.keys(payload.topicInputs)
.filter((key) => key.startsWith('topic_'))
.sort(),
itemTopicInputKeys: Object.keys(payload.topicInputs)
.filter((key) => key.startsWith('item_topic_'))
.sort(),
duplicateKeys: Object.keys(payload.topicInputs).filter(
(key, index, array) => array.indexOf(key) !== index
),
emptySections: Object.entries(payload.topicInputs)
.filter(([, value]) => !Array.isArray(value) || value.length === 0)
.map(([key]) => key)
};
await writeAuditArtifact('topic-runtime-report.json', topicRuntimeReport);
const summary = {
generatedAt: new Date().toISOString(),
overallStatus,
quotationCode: payload.quotationCode,
artifacts: {
templateVersionReport: 'template-version-report.json',
placeholderIntegrityReport: 'placeholder-integrity-report.json',
payloadParityReport: 'payload-parity-report.json',
topicRuntimeReport: 'topic-runtime-report.json',
signatureAuditReport: 'signature-audit-report.json'
},
statuses: {
templateVersion: templateVersionReport?.overallStatus ?? 'WARNING',
placeholderIntegrity: placeholderReport?.overallStatus ?? 'WARNING',
payloadParity: payloadReport?.overallStatus ?? 'WARNING',
topicRuntime: topicRuntimeReport.overallStatus,
approvedSnapshotParity: snapshotReport?.overallStatus ?? 'WARNING'
}
};
const artifactPath = await writeAuditArtifact('summary.json', summary);
const markdown = [
'# PDF Integrity Summary',
'',
`- Generated At: ${summary.generatedAt}`,
`- Overall Status: ${summary.overallStatus}`,
`- Quotation Code: ${summary.quotationCode}`,
'',
'## Statuses',
'',
`- Template Version Integrity: ${summary.statuses.templateVersion}`,
`- Placeholder Integrity: ${summary.statuses.placeholderIntegrity}`,
`- Payload Parity: ${summary.statuses.payloadParity}`,
`- Topic Runtime: ${summary.statuses.topicRuntime}`,
`- Approved Snapshot Parity: ${summary.statuses.approvedSnapshotParity}`,
'',
'## Artifact Files',
'',
`- ${summary.artifacts.templateVersionReport}`,
`- ${summary.artifacts.placeholderIntegrityReport}`,
`- ${summary.artifacts.payloadParityReport}`,
`- ${summary.artifacts.topicRuntimeReport}`,
`- ${summary.artifacts.signatureAuditReport}`
].join('\n');
const markdownPath = path.resolve(AUDIT_ARTIFACTS_DIR, 'summary.md');
await writeFile(markdownPath, `${markdown}\n`, 'utf8');
console.log(JSON.stringify({ ...summary, artifactPath, markdownPath }, null, 2));
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[generate-pdf-integrity-report] ${error.message}`);
process.exit(1);
});

1089
scripts/pdf-audit-utils.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,10 @@ const TASK_H1_PASSWORD = 'TaskH1Pass!2026';
const FIXTURE_CODES = {
customer: 'CUS-TASK-H1',
approvedQuotation: 'QT-TASK-H1-APPROVED',
draftQuotation: 'QT-TASK-H1-DRAFT'
draftQuotation: 'QT-TASK-H1-DRAFT',
auditQuotation: 'QT-H5-AUDIT',
billingCustomer: 'CUS-TASK-H5-BILL',
consultantCustomer: 'CUS-TASK-H5-CONSULT'
};
const FIXTURE_USERS = {
@@ -373,6 +376,88 @@ async function ensureCustomerAndContact(sql, organization, creatorId, optionIds)
};
}
async function ensureRelatedCustomer(sql, organization, creatorId, optionIds, definition) {
const existing =
(
await sql`
select id
from crm_customers
where organization_id = ${organization.id}
and code = ${definition.code}
limit 1
`
)[0] ?? null;
const customerId = existing?.id ?? crypto.randomUUID();
if (existing) {
await sql`
update crm_customers
set
branch_id = ${optionIds.branchId},
name = ${definition.name},
abbr = ${definition.abbr},
tax_id = ${definition.taxId},
customer_type = ${optionIds.customerTypeCompanyId},
customer_status = ${optionIds.customerStatusActiveId},
address = ${definition.address},
phone = ${definition.phone},
email = ${definition.email},
lead_channel = ${optionIds.leadChannelSalesId},
customer_group = ${optionIds.customerGroupStandardId},
notes = ${definition.notes},
is_active = true,
deleted_at = null,
updated_at = now(),
updated_by = ${creatorId}
where id = ${customerId}
`;
} else {
await sql`
insert into crm_customers (
id,
organization_id,
branch_id,
code,
name,
abbr,
tax_id,
customer_type,
customer_status,
address,
phone,
email,
lead_channel,
customer_group,
notes,
is_active,
created_by,
updated_by
) values (
${customerId},
${organization.id},
${optionIds.branchId},
${definition.code},
${definition.name},
${definition.abbr},
${definition.taxId},
${optionIds.customerTypeCompanyId},
${optionIds.customerStatusActiveId},
${definition.address},
${definition.phone},
${definition.email},
${optionIds.leadChannelSalesId},
${optionIds.customerGroupStandardId},
${definition.notes},
true,
${creatorId},
${creatorId}
)
`;
}
return customerId;
}
async function upsertQuotation(sql, args) {
const {
organization,
@@ -382,7 +467,9 @@ async function upsertQuotation(sql, args) {
customerId,
contactId,
code,
approved
approved,
fixtureProfile = 'h1',
projectParties = []
} = args;
const existing =
(
@@ -397,9 +484,24 @@ async function upsertQuotation(sql, args) {
const quotationId = existing?.id ?? crypto.randomUUID();
const statusId = approved ? optionIds.quotationStatusApprovedId : optionIds.quotationStatusDraftId;
const approvalNow = approved ? new Date().toISOString() : null;
const notes = approved
? 'Approved quotation fixture for Task H.1 PDF stabilization.'
: 'Draft quotation fixture for Task H.1 negative API validation.';
const notes =
fixtureProfile === 'h5'
? 'Approved quotation fixture for Task H.5 PDF mapping and parity audit.'
: approved
? 'Approved quotation fixture for Task H.1 PDF stabilization.'
: 'Draft quotation fixture for Task H.1 negative API validation.';
const projectName =
fixtureProfile === 'h5'
? 'Task H.5 Audit Fixture'
: approved
? 'Task H.1 Approved PDF Fixture'
: 'Task H.1 Draft PDF Fixture';
const reference =
fixtureProfile === 'h5'
? 'TASK-H5-AUDIT'
: approved
? 'TASK-H1-APPROVED'
: 'TASK-H1-DRAFT';
if (existing) {
await sql`
@@ -412,10 +514,10 @@ async function upsertQuotation(sql, args) {
quotation_date = ${'2026-06-16T00:00:00.000Z'},
valid_until = ${'2026-07-16T00:00:00.000Z'},
quotation_type = ${optionIds.quotationTypeServiceId},
project_name = ${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'},
project_name = ${projectName},
project_location = 'Bangkok HQ',
attention = 'Task H.1 Contact',
reference = ${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
reference = ${reference},
notes = ${notes},
status = ${statusId},
revision = 0,
@@ -507,10 +609,10 @@ async function upsertQuotation(sql, args) {
${'2026-06-16T00:00:00.000Z'},
${'2026-07-16T00:00:00.000Z'},
${optionIds.quotationTypeServiceId},
${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'},
${projectName},
'Bangkok HQ',
'Task H.1 Contact',
${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
${reference},
${notes},
${statusId},
0,
@@ -572,77 +674,170 @@ async function upsertQuotation(sql, args) {
await sql`delete from crm_quotation_topics where quotation_id = ${quotationId}`;
await sql`delete from crm_quotation_items where quotation_id = ${quotationId}`;
await sql`
insert into crm_quotation_items (
id,
organization_id,
quotation_id,
item_number,
product_type,
description,
quantity,
unit,
unit_price,
discount,
discount_type,
tax_rate,
total_price,
notes,
sort_order
) values (
${crypto.randomUUID()},
${organization.id},
${quotationId},
1,
${optionIds.productTypeCraneId},
'1 set overhead crane with installation and commissioning',
1,
${optionIds.unitSetId},
125000,
5000,
${optionIds.discountPercentageId},
7,
120000,
'Smoke-test commercial line',
1
)
`;
const items =
fixtureProfile === 'h5'
? [
{
itemNumber: 1,
description: '1 set overhead crane with installation and commissioning',
quantity: 1,
unitPrice: 125000,
discount: 5000,
totalPrice: 120000,
notes: 'Primary lifting package'
},
{
itemNumber: 2,
description: 'Load testing and safety certification',
quantity: 1,
unitPrice: 25000,
discount: 0,
totalPrice: 25000,
notes: 'Audit fixture support line'
},
{
itemNumber: 3,
description: 'Operator training and turnover documentation',
quantity: 2,
unitPrice: 8000,
discount: 0,
totalPrice: 16000,
notes: 'Audit fixture service line'
}
]
: [
{
itemNumber: 1,
description: '1 set overhead crane with installation and commissioning',
quantity: 1,
unitPrice: 125000,
discount: 5000,
totalPrice: 120000,
notes: 'Smoke-test commercial line'
}
];
await sql`
insert into crm_quotation_customers (
id,
organization_id,
quotation_id,
customer_id,
role,
is_primary
) values (
${crypto.randomUUID()},
${organization.id},
${quotationId},
${customerId},
${optionIds.customerRoleOwnerId},
true
)
`;
for (const [index, item] of items.entries()) {
await sql`
insert into crm_quotation_items (
id,
organization_id,
quotation_id,
item_number,
product_type,
description,
quantity,
unit,
unit_price,
discount,
discount_type,
tax_rate,
total_price,
notes,
sort_order
) values (
${crypto.randomUUID()},
${organization.id},
${quotationId},
${item.itemNumber},
${optionIds.productTypeCraneId},
${item.description},
${item.quantity},
${optionIds.unitSetId},
${item.unitPrice},
${item.discount},
${optionIds.discountPercentageId},
7,
${item.totalPrice},
${item.notes},
${index + 1}
)
`;
}
const topics = [
{
typeId: optionIds.topicTypeScopeId,
title: 'Scope of work',
items: ['Supply crane equipment', 'Install and test the system']
},
{
typeId: optionIds.topicTypeExclusionId,
title: 'Exclusions',
items: ['Civil work by customer', 'Power feeder by customer']
},
{
typeId: optionIds.topicTypePaymentId,
title: 'Payment terms',
items: ['50% upon PO', '40% before delivery', '10% after commissioning']
}
];
const resolvedProjectParties =
projectParties.length > 0
? projectParties
: [
{
customerId,
role: optionIds.customerRoleCustomerId,
isPrimary: true
}
];
for (const [index, party] of resolvedProjectParties.entries()) {
await sql`
insert into crm_quotation_customers (
id,
organization_id,
quotation_id,
customer_id,
role,
is_primary,
remark
) values (
${crypto.randomUUID()},
${organization.id},
${quotationId},
${party.customerId},
${party.role},
${party.isPrimary},
${party.remark ?? `Fixture party ${index + 1}`}
)
`;
}
const topics =
fixtureProfile === 'h5'
? [
{
typeId: optionIds.topicTypeScopeId,
title: 'Scope',
items: [
'Supply crane equipment',
'Install and test the system',
'Perform SAT with project stakeholders'
]
},
{
typeId: optionIds.topicTypeExclusionId,
title: 'Exclusion',
items: ['Civil work by customer', 'Power feeder by customer']
},
{
typeId: optionIds.topicTypePaymentId,
title: 'Payment',
items: ['50% upon PO', '40% before delivery', '10% after commissioning']
},
{
typeId: 'warranty',
title: 'Warranty',
items: ['12 months after commissioning', 'Consumables excluded']
},
{
typeId: 'delivery',
title: 'Delivery',
items: ['Within 60 days after approved drawing', 'Subject to final site readiness']
}
]
: [
{
typeId: optionIds.topicTypeScopeId,
title: 'Scope of work',
items: ['Supply crane equipment', 'Install and test the system']
},
{
typeId: optionIds.topicTypeExclusionId,
title: 'Exclusions',
items: ['Civil work by customer', 'Power feeder by customer']
},
{
typeId: optionIds.topicTypePaymentId,
title: 'Payment terms',
items: ['50% upon PO', '40% before delivery', '10% after commissioning']
}
];
for (const [topicIndex, topic] of topics.entries()) {
const topicId = crypto.randomUUID();
@@ -851,11 +1046,23 @@ async function main() {
'crm_product_type',
'crane'
),
customerRoleOwnerId: await resolveOptionId(
customerRoleCustomerId: await resolveOptionId(
sql,
organization.id,
'crm_quotation_customer_role',
'owner'
'crm_project_party_role',
'customer'
),
customerRoleBillingCustomerId: await resolveOptionId(
sql,
organization.id,
'crm_project_party_role',
'billing_customer'
),
customerRoleConsultantId: await resolveOptionId(
sql,
organization.id,
'crm_project_party_role',
'consultant'
),
topicTypeScopeId: await resolveOptionId(
sql,
@@ -884,6 +1091,32 @@ async function main() {
creatorId,
optionIds
);
const billingCustomerId = await ensureRelatedCustomer(sql, organization, creatorId, optionIds, {
code: FIXTURE_CODES.billingCustomer,
name: 'Task H.5 Billing Customer',
abbr: 'TASKH5BILL',
taxId: '0105559002233',
address: '88 Audit Park Road, Bangkok 10240',
phone: '02-000-2000',
email: 'billing-customer@local.test',
notes: 'Billing customer for Task H.5 parity fixture'
});
const consultantCustomerId = await ensureRelatedCustomer(
sql,
organization,
creatorId,
optionIds,
{
code: FIXTURE_CODES.consultantCustomer,
name: 'Task H.5 Consultant',
abbr: 'TASKH5CONS',
taxId: '0105559003344',
address: '77 Review Lane, Bangkok 10110',
phone: '02-000-3000',
email: 'consultant-customer@local.test',
notes: 'Consultant customer for Task H.5 parity fixture'
}
);
const users = {
admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', {
@@ -930,6 +1163,37 @@ async function main() {
code: FIXTURE_CODES.draftQuotation,
approved: false
});
const auditQuotationId = await upsertQuotation(sql, {
organization,
creatorId: users.admin.id,
workflow,
optionIds,
customerId,
contactId,
code: FIXTURE_CODES.auditQuotation,
approved: true,
fixtureProfile: 'h5',
projectParties: [
{
customerId,
role: optionIds.customerRoleCustomerId,
isPrimary: true,
remark: 'End customer'
},
{
customerId: billingCustomerId,
role: optionIds.customerRoleBillingCustomerId,
isPrimary: false,
remark: 'Billing customer'
},
{
customerId: consultantCustomerId,
role: optionIds.customerRoleConsultantId,
isPrimary: false,
remark: 'Consultant'
}
]
});
console.log(
JSON.stringify(
@@ -939,6 +1203,8 @@ async function main() {
approvedQuotationCode: FIXTURE_CODES.approvedQuotation,
draftQuotationId,
draftQuotationCode: FIXTURE_CODES.draftQuotation,
auditQuotationId,
auditQuotationCode: FIXTURE_CODES.auditQuotation,
users,
password: TASK_H1_PASSWORD
},