236 lines
7.1 KiB
TypeScript
236 lines
7.1 KiB
TypeScript
import {
|
|
PDFME_STATIC_LABEL_FIELD_KEYS,
|
|
buildAuditPayload,
|
|
createSqlClient,
|
|
summarizeIssues,
|
|
writeAuditArtifact
|
|
} 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 priceTable = payload.templateInput.quotation_price_data;
|
|
const priceTableRows = Array.isArray(priceTable) ? priceTable : [];
|
|
const firstPriceRow = Array.isArray(priceTableRows[0]) ? (priceTableRows[0] as unknown[]) : [];
|
|
const itemsTable = payload.templateInput.items_table;
|
|
const itemsTableRows = Array.isArray(itemsTable) ? itemsTable : [];
|
|
const firstItemsRow = Array.isArray(itemsTableRows[0]) ? (itemsTableRows[0] as unknown[]) : [];
|
|
|
|
pushFinding(findings, {
|
|
field: 'templateInput.quotation_price_data',
|
|
source: 'templateInput',
|
|
status: Array.isArray(priceTable) ? 'PASS' : 'FAIL',
|
|
value: Array.isArray(priceTable) ? `rows:${priceTable.length}` : priceTable,
|
|
message: Array.isArray(priceTable)
|
|
? 'Price table input is present'
|
|
: 'Price table input is missing or not a table'
|
|
});
|
|
pushFinding(findings, {
|
|
field: 'templateInput.quotation_price_data[0][0]',
|
|
source: 'templateInput',
|
|
status: firstPriceRow[0] === 'Price (Exclude VAT)' ? 'PASS' : 'FAIL',
|
|
value: firstPriceRow[0] ?? null,
|
|
message: 'Price table label matches canonical text'
|
|
});
|
|
pushFinding(findings, {
|
|
field: 'templateInput.quotation_price_data[0][1]',
|
|
source: 'templateInput',
|
|
status:
|
|
typeof firstPriceRow[1] === 'string' && firstPriceRow[1].trim() && firstPriceRow[1] !== '-'
|
|
? 'PASS'
|
|
: 'FAIL',
|
|
value: firstPriceRow[1] ?? null,
|
|
message: 'Price table amount cell is formatted'
|
|
});
|
|
pushFinding(findings, {
|
|
field: 'templateInput.quotation_price_data[0][3]',
|
|
source: 'templateInput',
|
|
status:
|
|
typeof firstPriceRow[3] === 'string' && firstPriceRow[3].trim()
|
|
? 'PASS'
|
|
: 'FAIL',
|
|
value: firstPriceRow[3] ?? null,
|
|
message: 'Price table currency code cell is populated'
|
|
});
|
|
if (itemsTable !== undefined) {
|
|
pushFinding(findings, {
|
|
field: 'templateInput.items_table',
|
|
source: 'templateInput',
|
|
status: Array.isArray(itemsTable) ? 'PASS' : 'FAIL',
|
|
value: Array.isArray(itemsTable) ? `rows:${itemsTable.length}` : itemsTable,
|
|
message: Array.isArray(itemsTable)
|
|
? 'Product items table input is present'
|
|
: 'Product items table input is missing or not table'
|
|
});
|
|
pushFinding(findings, {
|
|
field: 'templateInput.items_table[0]',
|
|
source: 'templateInput',
|
|
status: firstItemsRow.length === 7 ? 'PASS' : 'FAIL',
|
|
value: firstItemsRow.length,
|
|
message: 'Product items table row shape uses seven columns'
|
|
});
|
|
}
|
|
|
|
const staticLabelFindings = [...PDFME_STATIC_LABEL_FIELD_KEYS].map((fieldName) => {
|
|
const value = payload.templateInput[fieldName];
|
|
const valid = typeof value === 'string' && value.trim().length > 0 && value !== '-';
|
|
const finding: PayloadFinding = {
|
|
field: `templateInput.${fieldName}`,
|
|
source: 'templateInput',
|
|
status: valid ? 'PASS' : 'FAIL',
|
|
value: value ?? null,
|
|
message: valid ? 'Static label is populated' : 'Static label is empty'
|
|
};
|
|
pushFinding(findings, finding);
|
|
return finding;
|
|
});
|
|
|
|
const status = findings.some((finding) => finding.status === 'FAIL') ? 'FAIL' : 'PASS';
|
|
const report = {
|
|
audit: 'runtime-payload',
|
|
overallStatus: status,
|
|
staticLabelsStatus: summarizeIssues(
|
|
staticLabelFindings.map((finding) => finding.status)
|
|
),
|
|
quotationCode: payload.quotationCode,
|
|
templateName: payload.templateName,
|
|
templateVersion: payload.templateVersion,
|
|
findingCount: findings.length,
|
|
findings
|
|
};
|
|
const artifactPath = await writeAuditArtifact('runtime-payload-report.json', report);
|
|
|
|
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
|
|
} finally {
|
|
await sql.end({ timeout: 5 });
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[audit-pdf-runtime-payload] ${error.message}`);
|
|
process.exit(1);
|
|
});
|