task-h.5.3

This commit is contained in:
phaichayon
2026-06-19 17:15:28 +07:00
parent fd01ebf7c7
commit 51d67ef7c2
35 changed files with 4301 additions and 633 deletions

View File

@@ -1,4 +1,5 @@
import {
AUDIT_QUOTATION_CODE,
buildAuditPayload,
buildComputedSnapshotPayload,
computePayloadHash,
@@ -18,18 +19,43 @@ async function main() {
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)
let storedSnapshot = payload.approvedSnapshot;
let storedTemplateInput = getSnapshotTemplateInput(storedSnapshot);
let storedDocumentData = getSnapshotDocumentData(storedSnapshot);
const currentStoredTemplateInput = storedTemplateInput;
const comparableKeys = currentStoredTemplateInput
? Object.keys(computedSnapshot.templateInput).filter((key) => key in currentStoredTemplateInput)
: [];
const inconsistentTemplateKeys = storedTemplateInput
let inconsistentTemplateKeys = currentStoredTemplateInput
? comparableKeys.filter(
(key) =>
JSON.stringify(computedSnapshot.templateInput[key]) !==
JSON.stringify(storedTemplateInput[key])
JSON.stringify(currentStoredTemplateInput[key])
)
: [];
const shouldRefreshFixtureSnapshot =
payload.quotationCode === AUDIT_QUOTATION_CODE &&
process.env.NODE_ENV !== 'production' &&
(!storedSnapshot ||
!('quotation_price_data' in (storedTemplateInput ?? {})) ||
inconsistentTemplateKeys.length > 0 ||
payload.approvedTemplateVersionId !== computedSnapshot.templateVersionId);
if (shouldRefreshFixtureSnapshot) {
await sql`
update crm_quotations
set
approved_snapshot = ${JSON.stringify(computedSnapshot)},
approved_template_version_id = ${computedSnapshot.templateVersionId},
updated_at = now()
where id = ${payload.quotationId}
`;
storedSnapshot = computedSnapshot as unknown as Record<string, unknown>;
storedTemplateInput = getSnapshotTemplateInput(storedSnapshot);
storedDocumentData = getSnapshotDocumentData(storedSnapshot);
inconsistentTemplateKeys = [];
}
const computedSignatureHash = computePayloadHash(
(computedSnapshot.documentData.signatures as Record<string, unknown>) ?? {}
);
@@ -37,21 +63,23 @@ async function main() {
? computePayloadHash((storedDocumentData.signatures as Record<string, unknown>) ?? {})
: null;
const status = summarizeIssues([
payload.approvedSnapshot ? 'PASS' : 'WARNING',
storedSnapshot ? 'PASS' : 'WARNING',
inconsistentTemplateKeys.length ? 'FAIL' : 'PASS',
payload.approvedSnapshot && computedSignatureHash !== storedSignatureHash ? 'FAIL' : 'PASS'
storedSnapshot && computedSignatureHash !== storedSignatureHash ? 'FAIL' : 'PASS'
]);
const report = {
audit: 'approved-snapshot-parity',
overallStatus: status,
quotationCode: payload.quotationCode,
approvedSnapshotAvailable: Boolean(payload.approvedSnapshot),
approvedTemplateVersionId: payload.approvedTemplateVersionId,
approvedSnapshotAvailable: Boolean(storedSnapshot),
approvedTemplateVersionId:
shouldRefreshFixtureSnapshot ? computedSnapshot.templateVersionId : payload.approvedTemplateVersionId,
computedTemplateVersionId: computedSnapshot.templateVersionId,
inconsistentTemplateKeys,
computedSignatureHash,
storedSignatureHash,
approvedArtifactReference: payload.approvedArtifactReference
approvedArtifactReference: payload.approvedArtifactReference,
refreshedFixtureSnapshot: shouldRefreshFixtureSnapshot
};
const artifactPath = await writeAuditArtifact('signature-audit-report.json', report);

View File

@@ -51,9 +51,18 @@ async function main() {
const signatures = payload.documentData.signatures as Record<string, unknown>;
return key in signatures;
});
const priceTable = payload.templateInput.quotation_price_data;
const firstPriceRow = Array.isArray(priceTable) && Array.isArray(priceTable[0]) ? priceTable[0] : null;
const priceTableParity =
Array.isArray(priceTable) &&
Array.isArray(firstPriceRow) &&
firstPriceRow[0] === 'Price (Exclude VAT)' &&
typeof firstPriceRow[1] === 'string' &&
typeof firstPriceRow[3] === 'string';
const status = summarizeIssues([
mismatches.length ? 'FAIL' : 'PASS',
signatureParity ? 'PASS' : 'FAIL'
signatureParity ? 'PASS' : 'FAIL',
priceTableParity ? 'PASS' : 'FAIL'
]);
const report = {
audit: 'payload-parity',
@@ -65,7 +74,10 @@ async function main() {
mismatches,
comparableFieldCount: Object.keys(payload.templateInput).length,
topicInputKeyCount: Object.keys(payload.topicInputs).length,
signatureParity
signatureParity,
priceTableParity,
priceTableRowCount: Array.isArray(priceTable) ? priceTable.length : 0,
priceTableCurrency: Array.isArray(firstPriceRow) ? firstPriceRow[3] ?? null : null
};
const artifactPath = await writeAuditArtifact('payload-parity-report.json', report);

View File

@@ -1,4 +1,5 @@
import {
PDFME_STATIC_LABEL_FIELD_KEYS,
createSqlClient,
extractTemplateFields,
getActiveTemplateVersions,
@@ -40,6 +41,17 @@ async function main() {
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 tableFieldCoverage = fields
.filter((field) => field.type === 'table' && field.name)
.map((field) => {
const mapping = mappings.find((candidate) => candidate.placeholderKey === field.name) ?? null;
return {
fieldName: field.name,
hasMapping: Boolean(mapping),
sourcePath: mapping?.sourcePath ?? null,
dataType: mapping?.dataType ?? null
};
});
const coverage = schemaTokens.map((token) => {
const mapping =
mappings.find((candidate) => candidate.placeholderKey === token) ??
@@ -61,17 +73,31 @@ async function main() {
};
});
const unmappedFields = coverage.filter((item) => item.classification === 'unmapped_field');
const invalidTableMappings = tableFieldCoverage.filter(
(item) =>
item.fieldName === 'quotation_price_data' &&
(!item.hasMapping ||
item.sourcePath !== 'pdfme.quotation_price_data' ||
item.dataType !== 'table')
);
const orphanMappings = mappings
.filter(
(mapping) =>
!schemaTokens.includes(mapping.placeholderKey) &&
!fieldNames.has(mapping.placeholderKey) &&
mapping.placeholderKey !== 'items_table'
mapping.placeholderKey !== 'items_table'
)
.map((mapping) => ({
placeholderKey: mapping.placeholderKey,
sourcePath: mapping.sourcePath
}));
const missingStaticLabelMappings = [...PDFME_STATIC_LABEL_FIELD_KEYS].filter((fieldName) => {
if (!fieldNames.has(fieldName)) {
return false;
}
return !mappings.some((mapping) => mapping.placeholderKey === fieldName);
});
const tableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null;
const tableColumns = tableMapping
? columns
@@ -83,6 +109,8 @@ async function main() {
: [];
const status = summarizeIssues([
unmappedFields.length ? 'FAIL' : 'PASS',
invalidTableMappings.length ? 'FAIL' : 'PASS',
missingStaticLabelMappings.length ? 'FAIL' : 'PASS',
orphanMappings.length ? 'WARNING' : 'PASS'
]);
@@ -92,7 +120,9 @@ async function main() {
organizationId: template.organizationId,
tokenCount: schemaTokens.length,
coverage,
tableFieldCoverage,
unmappedFields,
missingStaticLabelMappings,
orphanMappings,
tableColumns,
status

View File

@@ -1,4 +1,10 @@
import { buildAuditPayload, createSqlClient, summarizeIssues } from './pdf-audit-utils.ts';
import {
PDFME_STATIC_LABEL_FIELD_KEYS,
buildAuditPayload,
createSqlClient,
summarizeIssues,
writeAuditArtifact
} from './pdf-audit-utils.ts';
type PayloadFinding = {
field: string;
@@ -126,22 +132,77 @@ async function main() {
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
)
);
const priceTable = payload.templateInput.quotation_price_data;
const priceTableRows = Array.isArray(priceTable) ? priceTable : [];
const firstPriceRow = Array.isArray(priceTableRows[0]) ? (priceTableRows[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'
});
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 });
}

View File

@@ -1,4 +1,6 @@
import {
classifyTemplateField,
computeFieldDiff,
createSqlClient,
getEffectiveMappedKeys,
extractTemplateFields,
@@ -18,11 +20,31 @@ async function main() {
for (const template of templates) {
const fields = extractTemplateFields(template.schemaJson);
const previousVersion =
(
await sql`
select schema_json as "schemaJson", version
from crm_document_template_versions
where template_id = ${template.templateId}
and deleted_at is null
and id <> ${template.versionId}
order by created_at desc
limit 1
`
)[0] ?? null;
const previousFields = previousVersion
? extractTemplateFields(previousVersion.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 fieldDiff = computeFieldDiff(fields, previousFields);
const requiredTableFields = ['quotation_price_data'];
const missingRequiredTableFields = requiredTableFields.filter(
(fieldName) => !fields.some((field) => field.name === fieldName && field.type === 'table')
);
const tableFields = fields.filter((field) => field.type === 'table').map((field) => field.name);
const dynamicTopicFields = fields
.filter((field) => field.name === 'topic' || field.name === 'data_topic')
@@ -39,8 +61,14 @@ async function main() {
mapping.placeholderKey !== 'items_table'
)
.map((mapping) => mapping.placeholderKey);
const classifications = fields.map((field) => ({
name: field.name,
type: field.type,
classification: classifyTemplateField(field, mappedKeys)
}));
const status = summarizeIssues([
duplicateFields.length ? 'FAIL' : 'PASS',
missingRequiredTableFields.length ? 'FAIL' : 'PASS',
missingMappings.length ? 'FAIL' : 'PASS',
orphanMappings.length ? 'WARNING' : 'PASS',
unknownFields.length ? 'WARNING' : 'PASS'
@@ -49,12 +77,17 @@ async function main() {
results.push({
templateName: template.templateName,
version: template.version,
previousVersion: previousVersion?.version ?? null,
organizationId: template.organizationId,
schemaFieldCount: fields.length,
tableFields,
missingRequiredTableFields,
dynamicTopicFields,
deletedFields: fieldDiff.deletedFields,
newFields: fieldDiff.newFields,
duplicateFields,
unknownFields,
classifications,
missingMappings,
orphanMappings,
mappedFieldCount: mappings.length,

View File

@@ -2,6 +2,7 @@ import {
buildAuditPayload,
createSqlClient,
getActiveTemplateVersions,
loadTemplateSourceByOrganizationName,
summarizeIssues,
writeAuditArtifact
} from './pdf-audit-utils.ts';
@@ -12,7 +13,9 @@ type IntegrityIssue = {
| 'DUPLICATE_ACTIVE_VERSION'
| 'ACTIVE_VERSION_WITHOUT_TEMPLATE'
| 'MAPPING_WITHOUT_VERSION'
| 'INVALID_APPROVED_TEMPLATE_VERSION';
| 'INVALID_APPROVED_TEMPLATE_VERSION'
| 'ACTIVE_VERSION_SCHEMA_DRIFT'
| 'MISSING_RETAINED_INACTIVE_VERSION';
message: string;
context: Record<string, unknown>;
};
@@ -21,19 +24,29 @@ async function main() {
const sql = createSqlClient();
try {
const [activeTemplateVersions, versionRows, orphanMappingRows, payload] = await Promise.all([
const [
activeTemplateVersions,
versionRows,
orphanMappingRows,
payload,
templateRows
] = await Promise.all([
getActiveTemplateVersions(sql),
sql`
select
v.id,
v.template_id as "templateId",
v.organization_id as "organizationId",
o.name as "organizationName",
v.version,
v.is_active as "isActive",
v.schema_json as "schemaJson",
t.id as "resolvedTemplateId"
from crm_document_template_versions v
left join crm_document_templates t
on t.id = v.template_id
left join organizations o
on o.id = v.organization_id
where v.deleted_at is null
`,
sql`
@@ -47,7 +60,20 @@ async function main() {
where m.deleted_at is null
and (v.id is null or v.deleted_at is not null)
`,
buildAuditPayload(sql)
buildAuditPayload(sql),
sql`
select
t.id,
t.organization_id as "organizationId",
o.name as "organizationName",
t.template_name as "templateName"
from crm_document_templates t
inner join organizations o
on o.id = t.organization_id
where t.deleted_at is null
and t.document_type = 'quotation'
and t.file_type = 'pdfme'
`
]);
const issues: IntegrityIssue[] = [];
const templateBuckets = new Map<string, typeof activeTemplateVersions>();
@@ -121,6 +147,48 @@ async function main() {
}
}
for (const templateRow of templateRows as Array<Record<string, unknown>>) {
const templateId = String(templateRow.id);
const rows = (versionRows as Array<Record<string, unknown>>).filter(
(row) => String(row.templateId) === templateId
);
const activeRows = rows.filter((row) => Boolean(row.isActive));
const inactiveRows = rows.filter((row) => !row.isActive);
const templateSource = loadTemplateSourceByOrganizationName(String(templateRow.organizationName));
if (templateSource && activeRows.length === 1) {
const activeSchema =
typeof activeRows[0].schemaJson === 'string'
? JSON.parse(String(activeRows[0].schemaJson))
: activeRows[0].schemaJson;
if (JSON.stringify(activeSchema) !== JSON.stringify(templateSource.schemaJson)) {
issues.push({
kind: 'ACTIVE_VERSION_SCHEMA_DRIFT',
message: `Active template version does not match source JSON for ${String(templateRow.templateName)}`,
context: {
templateId,
organizationName: templateRow.organizationName,
activeVersionId: activeRows[0].id,
activeVersion: activeRows[0].version,
sourceFile: templateSource.relativePath
}
});
}
}
if (rows.length > 1 && inactiveRows.length === 0) {
issues.push({
kind: 'MISSING_RETAINED_INACTIVE_VERSION',
message: `Template ${String(templateRow.templateName)} has no retained inactive historical version`,
context: {
templateId,
organizationName: templateRow.organizationName
}
});
}
}
for (const row of orphanMappingRows as Array<Record<string, unknown>>) {
issues.push({
kind: 'MAPPING_WITHOUT_VERSION',
@@ -129,13 +197,16 @@ async function main() {
});
}
if (
payload.approvedTemplateVersionId &&
!activeTemplateVersions.some((record) => record.versionId === payload.approvedTemplateVersionId)
) {
const approvedTemplateVersionExists = payload.approvedTemplateVersionId
? (versionRows as Array<Record<string, unknown>>).some(
(row) => String(row.id) === payload.approvedTemplateVersionId
)
: true;
if (payload.approvedTemplateVersionId && !approvedTemplateVersionExists) {
issues.push({
kind: 'INVALID_APPROVED_TEMPLATE_VERSION',
message: 'Audit quotation approvedTemplateVersionId does not resolve to an active template version',
message: 'Audit quotation approvedTemplateVersionId does not resolve to a retained template version',
context: {
quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId
@@ -150,6 +221,9 @@ async function main() {
activeTemplateCount: activeTemplateVersions.length,
quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId,
approvedTemplateVersionIsActive: activeTemplateVersions.some(
(record) => record.versionId === payload.approvedTemplateVersionId
),
issues
};
const artifactPath = await writeAuditArtifact('template-version-report.json', report);

View File

@@ -7,6 +7,8 @@ import {
REPORT_JSON_PATH,
REPORT_MARKDOWN_PATH,
buildAuditPayload,
classifyTemplateField,
computeFieldDiff,
createSqlClient,
extractTemplateFields,
getEffectiveMappedKeys,
@@ -22,6 +24,24 @@ async function main() {
try {
const payload = await buildAuditPayload(sql);
const fields = extractTemplateFields(payload.templateSchema);
const previousVersion =
(
await sql`
select schema_json as "schemaJson", version
from crm_document_template_versions
where template_id = (
select template_id
from crm_document_template_versions
where id = ${payload.templateVersionId}
limit 1
)
and id <> ${payload.templateVersionId}
and deleted_at is null
order by created_at desc
limit 1
`
)[0] ?? null;
const previousFields = previousVersion ? extractTemplateFields(previousVersion.schemaJson) : [];
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();
@@ -50,12 +70,19 @@ async function main() {
)
.map((mapping) => mapping.placeholderKey);
const duplicateFields = getDuplicateFieldNames(fields);
const unknownFields = getUnknownFieldNames(fields);
const unknownFields = getUnknownFieldNames(fields, mappedKeys);
const fieldDiff = computeFieldDiff(fields, previousFields);
const classifications = fields.map((field) => ({
name: field.name,
classification: classifyTemplateField(field, mappedKeys)
}));
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 priceTableMapping =
mappings.find((mapping) => mapping.placeholderKey === 'quotation_price_data') ?? null;
const itemTableColumns = itemTableMapping
? columns.filter((column) => column.mappingId === itemTableMapping.id)
: [];
@@ -98,8 +125,12 @@ async function main() {
version: payload.templateVersion,
versionId: payload.templateVersionId,
fieldCount: fields.length,
previousVersion: previousVersion?.version ?? null,
deletedFields: fieldDiff.deletedFields,
newFields: fieldDiff.newFields,
duplicateFields,
unknownFields
unknownFields,
classifications
},
metrics: {
topicCount: payload.quotationMetrics.topicCount,
@@ -113,6 +144,12 @@ async function main() {
placeholderTokenCount: placeholderTokens.length,
unmappedTokens,
orphanMappings,
priceTableMapping: priceTableMapping
? {
sourcePath: priceTableMapping.sourcePath,
dataType: priceTableMapping.dataType
}
: null,
itemTableColumns: itemTableColumns.map((column) => ({
columnName: column.columnName,
sourceField: column.sourceField
@@ -120,7 +157,10 @@ async function main() {
},
runtimePayload: {
templateInputKeys: Object.keys(payload.templateInput).length,
topicInputKeys: Object.keys(payload.topicInputs).length
topicInputKeys: Object.keys(payload.topicInputs).length,
priceTableRows: Array.isArray(payload.templateInput.quotation_price_data)
? payload.templateInput.quotation_price_data.length
: 0
},
consistency: {
status: consistencyStatus,
@@ -141,6 +181,7 @@ async function main() {
`- Overall Status: ${report.overallStatus}`,
`- Quotation Code: ${report.quotationCode}`,
`- Template: ${report.template.templateName} v${report.template.version}`,
`- Previous Version: ${report.template.previousVersion ?? 'None'}`,
'',
'## Metrics',
'',

View File

@@ -30,11 +30,13 @@ async function main() {
'placeholder-integrity-report.json'
);
const payloadReport = await readReport<Record<string, unknown>>('payload-parity-report.json');
const runtimeReport = await readReport<Record<string, unknown>>('runtime-payload-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(runtimeReport?.overallStatus ?? 'WARNING'),
String(snapshotReport?.overallStatus ?? 'WARNING')
] as Array<'PASS' | 'WARNING' | 'FAIL'>;
const overallStatus = summarizeIssues(statuses);
@@ -64,6 +66,7 @@ async function main() {
artifacts: {
templateVersionReport: 'template-version-report.json',
placeholderIntegrityReport: 'placeholder-integrity-report.json',
runtimePayloadReport: 'runtime-payload-report.json',
payloadParityReport: 'payload-parity-report.json',
topicRuntimeReport: 'topic-runtime-report.json',
signatureAuditReport: 'signature-audit-report.json'
@@ -71,9 +74,23 @@ async function main() {
statuses: {
templateVersion: templateVersionReport?.overallStatus ?? 'WARNING',
placeholderIntegrity: placeholderReport?.overallStatus ?? 'WARNING',
runtimePayload: runtimeReport?.overallStatus ?? 'WARNING',
payloadParity: payloadReport?.overallStatus ?? 'WARNING',
topicRuntime: topicRuntimeReport.overallStatus,
approvedSnapshotParity: snapshotReport?.overallStatus ?? 'WARNING'
approvedSnapshotParity: snapshotReport?.overallStatus ?? 'WARNING',
staticLabels: runtimeReport?.staticLabelsStatus ?? 'WARNING'
},
priceTable: {
present: Boolean(payload.templateInput.quotation_price_data),
rowCount: Array.isArray(payload.templateInput.quotation_price_data)
? payload.templateInput.quotation_price_data.length
: 0,
parity: payloadReport?.priceTableParity ?? false,
status:
Array.isArray(payload.templateInput.quotation_price_data) &&
(payloadReport?.priceTableParity ?? false)
? 'PASS'
: 'FAIL'
}
};
const artifactPath = await writeAuditArtifact('summary.json', summary);
@@ -88,14 +105,20 @@ async function main() {
'',
`- Template Version Integrity: ${summary.statuses.templateVersion}`,
`- Placeholder Integrity: ${summary.statuses.placeholderIntegrity}`,
`- Runtime Payload: ${summary.statuses.runtimePayload}`,
`- Payload Parity: ${summary.statuses.payloadParity}`,
`- Topic Runtime: ${summary.statuses.topicRuntime}`,
`- Approved Snapshot Parity: ${summary.statuses.approvedSnapshotParity}`,
`- Static Labels: ${summary.statuses.staticLabels}`,
`- Price Table Present: ${summary.priceTable.present ? 'Yes' : 'No'}`,
`- Price Table Status: ${summary.priceTable.status}`,
`- Price Table Parity: ${summary.priceTable.parity ? 'Yes' : 'No'}`,
'',
'## Artifact Files',
'',
`- ${summary.artifacts.templateVersionReport}`,
`- ${summary.artifacts.placeholderIntegrityReport}`,
`- ${summary.artifacts.runtimePayloadReport}`,
`- ${summary.artifacts.payloadParityReport}`,
`- ${summary.artifacts.topicRuntimeReport}`,
`- ${summary.artifacts.signatureAuditReport}`

View File

@@ -57,6 +57,29 @@ export interface MappingColumnRecord {
formatMask: string | null;
}
export type TemplateFieldClassification =
| 'static input field'
| 'dynamic topic template'
| 'readonly/static visual field'
| 'system field'
| 'unknown field';
export interface TemplateMappingSeed {
placeholderKey: string;
sourcePath: string;
dataType: 'scalar' | 'multiline' | 'table' | 'image';
defaultValue?: string | null;
formatMask?: string | null;
sortOrder: number;
columns?: Array<{
columnName: string;
sourceField: string;
columnLetter?: string;
sortOrder: number;
formatMask?: string | null;
}>;
}
export interface AuditPayload {
organizationId: string;
quotationId: string;
@@ -83,6 +106,7 @@ export interface AuditPayload {
const SYSTEM_FIELD_NAMES = new Set([
'',
'company1',
'company',
'company2',
'company_addr1',
'company_addr2',
@@ -97,6 +121,220 @@ const DYNAMIC_TOPIC_FIELD_NAMES = new Set(['topic', 'data_topic']);
const LEGACY_DYNAMIC_SEED_TOKENS = new Set(['item_topic']);
const DYNAMIC_TOPIC_TOKEN_PREFIXES = ['topic_', 'item_topic_'];
const DESIGNER_LABEL_NAME_PATTERNS = [/_label$/i, /_lable$/i];
const STATIC_VISUAL_FIELD_NAMES = new Set([
'colon',
'colon copy',
'colon_att',
'colon_email',
'colon_fax',
'colon_project',
'colon_site',
'colon_tel',
'dear_sirs',
'line',
'yours_faithfuly',
'quotation_code_lable',
'quotation_date_labe'
]);
export const PRICE_TABLE_LABEL = 'Price (Exclude VAT)';
export const PDFME_STATIC_LABELS = {
tel: 'Tel',
email: 'Email',
att: 'Att',
project: 'Project',
location: 'Location'
} as const;
export const PDFME_STATIC_LABEL_FIELD_KEYS = [
'tel_label',
'email_label',
'att_label',
'project_label',
'site_label'
] as const;
export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
{
placeholderKey: 'customer_name',
sourcePath: 'customer.name',
dataType: 'scalar',
sortOrder: 1
},
{
placeholderKey: 'customer_addr',
sourcePath: 'customer.address',
dataType: 'multiline',
sortOrder: 2
},
{
placeholderKey: 'customer_tel',
sourcePath: 'customer.phone',
dataType: 'scalar',
sortOrder: 3
},
{
placeholderKey: 'customer_email',
sourcePath: 'customer.email',
dataType: 'scalar',
sortOrder: 4
},
{
placeholderKey: 'customer_att',
sourcePath: 'quotation.attention',
dataType: 'scalar',
sortOrder: 5
},
{
placeholderKey: 'project_name',
sourcePath: 'quotation.projectName',
dataType: 'scalar',
sortOrder: 6
},
{
placeholderKey: 'site_location',
sourcePath: 'quotation.projectLocation',
dataType: 'multiline',
sortOrder: 7
},
{
placeholderKey: 'quotation_date_data',
sourcePath: 'pdfme.quotation_date',
dataType: 'scalar',
sortOrder: 8
},
{
placeholderKey: 'quotation_code_data',
sourcePath: 'quotation.code',
dataType: 'scalar',
sortOrder: 9
},
{
placeholderKey: 'quotation_price_data',
sourcePath: 'pdfme.quotation_price_data',
dataType: 'table',
defaultValue: JSON.stringify([[PRICE_TABLE_LABEL, '-', ' ', 'THB']]),
sortOrder: 10
},
{
placeholderKey: 'quotation_price',
sourcePath: 'pdfme.quotation_price',
dataType: 'scalar',
sortOrder: 11
},
{
placeholderKey: 'currency',
sourcePath: 'quotation.currency',
dataType: 'scalar',
sortOrder: 12
},
{
placeholderKey: 'exclusion_data',
sourcePath: 'pdfme.exclusion_data',
dataType: 'table',
sortOrder: 13
},
{
placeholderKey: 'tel_label',
sourcePath: 'pdfme.labels.tel',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.tel,
sortOrder: 14
},
{
placeholderKey: 'email_label',
sourcePath: 'pdfme.labels.email',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.email,
sortOrder: 15
},
{
placeholderKey: 'att_label',
sourcePath: 'pdfme.labels.att',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.att,
sortOrder: 16
},
{
placeholderKey: 'project_label',
sourcePath: 'pdfme.labels.project',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.project,
sortOrder: 17
},
{
placeholderKey: 'site_label',
sourcePath: 'pdfme.labels.location',
dataType: 'scalar',
defaultValue: PDFME_STATIC_LABELS.location,
sortOrder: 18
},
{
placeholderKey: 'app1',
sourcePath: 'signatures.preparedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 19
},
{
placeholderKey: 'app1_position',
sourcePath: 'signatures.preparedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 20
},
{
placeholderKey: 'app2',
sourcePath: 'signatures.approvedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 21
},
{
placeholderKey: 'app2_position',
sourcePath: 'signatures.approvedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 22
},
{
placeholderKey: 'app3',
sourcePath: 'signatures.authorizedBy.name',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 23
},
{
placeholderKey: 'app3_position',
sourcePath: 'signatures.authorizedBy.position',
dataType: 'scalar',
defaultValue: '-',
sortOrder: 24
},
{
placeholderKey: 'items_table',
sourcePath: 'items',
dataType: 'table',
sortOrder: 25,
columns: [
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
{ columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 },
{ columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 },
{
columnName: 'Unit Price',
sourceField: 'unitPrice',
columnLetter: 'E',
sortOrder: 5,
formatMask: 'currency'
},
{
columnName: 'Total',
sourceField: 'totalPrice',
columnLetter: 'F',
sortOrder: 6,
formatMask: 'currency'
}
]
}
] as const;
const SIGNATURE_ROLE_FALLBACK_LABELS: Record<string, string> = {
sales: 'Sales Engineer',
sales_support: 'Sales Support',
@@ -324,13 +562,7 @@ export function getUnknownFieldNames(
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'
field.name && classifyTemplateField(field, mappedKeys) === 'unknown field'
)
.map((field) => field.name)
.sort();
@@ -344,6 +576,40 @@ export function isDesignerLabelField(field: TemplateFieldRecord) {
);
}
export function classifyTemplateField(
field: TemplateFieldRecord,
mappedKeys: Set<string> = new Set()
): TemplateFieldClassification {
if (!field.name) {
return 'system field';
}
if (SYSTEM_FIELD_NAMES.has(field.name)) {
return 'system field';
}
if (DYNAMIC_TOPIC_FIELD_NAMES.has(field.name)) {
return 'dynamic topic template';
}
if (mappedKeys.has(field.name)) {
return 'static input field';
}
if (isDesignerLabelField(field) || STATIC_VISUAL_FIELD_NAMES.has(field.name)) {
return 'readonly/static visual field';
}
if (
field.tokens.length === 0 &&
['text', 'line', 'rectangle', 'ellipse', 'image'].includes(field.type)
) {
return 'readonly/static visual field';
}
return 'unknown field';
}
export function getEffectiveMappedKeys(
fields: TemplateFieldRecord[],
mappings: Array<{ placeholderKey: string }>
@@ -398,6 +664,78 @@ export function normalizeTemplateInputAliases(
return normalized;
}
export function loadTemplateSourceByOrganizationName(organizationName: string) {
const normalized = organizationName.toUpperCase();
const fileName = normalized.includes('ONVALLA')
? 'ONVALLA_template_pdfme_fainal3.json'
: normalized.includes('ALLA')
? 'ALLA_template_pdfme_fainal3.json'
: null;
if (!fileName) {
return null;
}
const filePath = path.resolve(process.cwd(), 'src', 'pdfme_template', fileName);
return {
fileName,
relativePath: `src/pdfme_template/${fileName}`,
absolutePath: filePath,
schemaJson: JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown
};
}
export function computeFieldDiff(
currentFields: TemplateFieldRecord[],
previousFields: TemplateFieldRecord[]
) {
const currentFieldNames = new Set(currentFields.map((field) => field.name).filter(Boolean));
const previousFieldNames = new Set(previousFields.map((field) => field.name).filter(Boolean));
return {
deletedFields: [...previousFieldNames].filter((name) => !currentFieldNames.has(name)).sort(),
newFields: [...currentFieldNames].filter((name) => !previousFieldNames.has(name)).sort()
};
}
export function buildSupportedMappingsForFieldNames(
fieldNames: Set<string>,
tokenNames: Set<string> = new Set()
) {
return SUPPORTED_TEMPLATE_MAPPING_SEEDS.filter(
(mapping) => fieldNames.has(mapping.placeholderKey) || tokenNames.has(mapping.placeholderKey)
);
}
export function bumpMinorTemplateVersion(version: string) {
const [majorPart, minorPart = '0'] = version.split('.');
const major = Number(majorPart);
const minor = Number(minorPart);
if (!Number.isInteger(major) || !Number.isInteger(minor)) {
return `${version}.1`;
}
return `${major}.${minor + 1}`;
}
function normalizeSnapshotPayload(snapshot: unknown) {
if (!snapshot) {
return null;
}
if (typeof snapshot === 'string') {
try {
const parsed = JSON.parse(snapshot) as unknown;
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
return snapshot && typeof snapshot === 'object' ? (snapshot as Record<string, unknown>) : null;
}
export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveTemplateVersionRecord[]> {
const rows = await sql`
select
@@ -475,7 +813,9 @@ export async function findAuditQuotation(sql: SqlClient) {
q.attention,
q.reference,
q.currency,
q.subtotal,
q.total_amount as "totalAmount",
currency_option.code as "currencyCode",
q.approved_template_version_id as "approvedTemplateVersionId",
q.salesman_id as "salesmanId",
q.created_by as "createdBy",
@@ -483,6 +823,8 @@ export async function findAuditQuotation(sql: SqlClient) {
q.approved_snapshot as "approvedSnapshot",
q.approved_pdf_url as "approvedPdfUrl"
from crm_quotations q
left join ms_options currency_option
on currency_option.id = q.currency
where q.code = ${AUDIT_QUOTATION_CODE}
and q.deleted_at is null
limit 1
@@ -506,13 +848,17 @@ export async function findAuditQuotation(sql: SqlClient) {
q.attention,
q.reference,
q.currency,
q.subtotal,
q.total_amount as "totalAmount",
currency_option.code as "currencyCode",
q.salesman_id as "salesmanId",
q.created_by as "createdBy",
q.created_at as "createdAt",
q.approved_snapshot as "approvedSnapshot",
q.approved_pdf_url as "approvedPdfUrl"
from crm_quotations q
left join ms_options currency_option
on currency_option.id = q.currency
where q.deleted_at is null
order by q.updated_at desc
limit 1
@@ -584,6 +930,40 @@ function formatPdfCurrency(amount: number | string | null | undefined, currencyC
}).format(normalized)}`.trim();
}
function formatPdfCurrencyAmount(amount: number | string | null | undefined, currencyCode = 'THB') {
if (amount === null || amount === undefined) {
return '-';
}
const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
if (!Number.isFinite(normalized)) {
return '-';
}
const symbols: Record<string, string> = {
THB: '฿',
USD: '$',
EUR: 'EUR '
};
const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `;
return `${prefix}${new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(normalized)}`.trim();
}
function buildPdfPriceTableData(amount: number | string | null | undefined, currencyCode?: string | null) {
const normalizedCurrency = currencyCode?.trim() || 'THB';
return [[
PRICE_TABLE_LABEL,
formatPdfCurrencyAmount(amount, normalizedCurrency),
' ',
normalizedCurrency
]];
}
function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: number | null }> = []) {
if (!items.length) {
return [['-']];
@@ -611,6 +991,30 @@ function applyFormatMask(value: unknown, formatMask?: string | null) {
return value;
}
function isBlankString(value: unknown) {
return typeof value === 'string' && value.trim().length === 0;
}
function resolveScalarMappingValue(
rawValue: unknown,
formattedValue: unknown,
defaultValue?: string | null
) {
if (formattedValue !== undefined && formattedValue !== null && !isBlankString(formattedValue)) {
return formattedValue;
}
if (rawValue !== undefined && rawValue !== null && !isBlankString(rawValue)) {
return rawValue;
}
if (defaultValue !== undefined && defaultValue !== null && defaultValue.trim().length > 0) {
return defaultValue;
}
return '-';
}
function normalizePdfmeTable(
value: unknown,
columns?: Array<{ sourceField: string; formatMask?: string | null }>
@@ -620,7 +1024,21 @@ function normalizePdfmeTable(
}
if (value.every((row) => Array.isArray(row))) {
return (value as unknown[][]).map((row) => row.map((cell) => String(cell ?? '-').trim() || '-'));
return (value as unknown[][]).map((row) =>
row.map((cell) => {
if (typeof cell === 'string') {
const trimmedValue = cell.trim();
if (trimmedValue) {
return trimmedValue;
}
return cell.length > 0 ? cell : '-';
}
return String(cell ?? '-').trim() || '-';
})
);
}
if (!columns?.length) {
@@ -655,7 +1073,18 @@ function mapDocumentDataToTemplateInput(
if (mapping.dataType === 'table') {
const mappingColumns = columnMap.get(mapping.id) ?? [];
result[mapping.placeholderKey] = normalizePdfmeTable(rawValue, mappingColumns);
let tableValue = rawValue;
if (!Array.isArray(tableValue) && mapping.defaultValue) {
try {
const parsedDefault = JSON.parse(mapping.defaultValue) as unknown;
tableValue = Array.isArray(parsedDefault) ? parsedDefault : tableValue;
} catch {
// ignore invalid legacy table defaults
}
}
result[mapping.placeholderKey] = normalizePdfmeTable(tableValue, mappingColumns);
continue;
}
@@ -669,12 +1098,18 @@ function mapDocumentDataToTemplateInput(
}
result[mapping.placeholderKey] =
typeof rawValue === 'string' ? rawValue : mapping.defaultValue ?? '';
typeof rawValue === 'string' && rawValue.trim().length > 0
? rawValue
: mapping.defaultValue ?? '-';
continue;
}
result[mapping.placeholderKey] =
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '-';
const formattedValue = applyFormatMask(rawValue, mapping.formatMask);
result[mapping.placeholderKey] = resolveScalarMappingValue(
rawValue,
formattedValue,
mapping.defaultValue
);
}
return result;
@@ -901,6 +1336,10 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null;
return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-';
};
const currencyCode =
typeof quotation.currencyCode === 'string' && quotation.currencyCode.trim()
? quotation.currencyCode.trim()
: 'THB';
const documentData: Record<string, unknown> = {
company: {
@@ -932,7 +1371,9 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
projectLocation: quotation.projectLocation,
attention: quotation.attention,
reference: quotation.reference,
currency: quotation.currency,
currency: currencyCode,
currencyCode,
subtotal: quotation.subtotal,
totalAmount: quotation.totalAmount
},
items: (itemRows as Array<Record<string, unknown>>).map((row) => ({
@@ -951,8 +1392,10 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
exclusion: exclusionTopic ? [exclusionTopic] : []
},
pdfme: {
labels: PDFME_STATIC_LABELS,
quotation_date: formatPdfDate(quotation.quotationDate as string),
quotation_price: formatPdfCurrency(quotation.totalAmount as number, 'THB'),
quotation_price: formatPdfCurrency(quotation.subtotal as number, currencyCode),
quotation_price_data: buildPdfPriceTableData(quotation.subtotal as number, currencyCode),
exclusion_data: formatTopicItems(exclusionTopic?.items ?? []),
topic_inputs: topicInputs
},
@@ -1014,10 +1457,7 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
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,
approvedSnapshot: normalizeSnapshotPayload(quotation.approvedSnapshot),
approvedArtifactReference:
typeof quotation.approvedPdfUrl === 'string' ? quotation.approvedPdfUrl : null
};

View File

@@ -0,0 +1,388 @@
import { createHash } from 'node:crypto';
import {
buildSupportedMappingsForFieldNames,
bumpMinorTemplateVersion,
classifyTemplateField,
computeFieldDiff,
createSqlClient,
extractTemplateFields,
getEffectiveMappedKeys,
loadTemplateSourceByOrganizationName,
summarizeIssues,
type TemplateMappingSeed
} from './pdf-audit-utils.ts';
type TemplateRow = {
id: string;
organizationId: string;
organizationName: string;
templateName: string;
createdBy: string;
updatedBy: string;
};
type TemplateVersionRow = {
id: string;
templateId: string;
version: string;
schemaJson: unknown;
filePath: string | null;
isActive: boolean;
createdAt: string;
};
function computeSchemaHash(schemaJson: unknown) {
return createHash('sha256').update(JSON.stringify(schemaJson)).digest('hex');
}
function normalizeSchemaJson(schemaJson: unknown) {
if (typeof schemaJson === 'string') {
return JSON.parse(schemaJson) as unknown;
}
return schemaJson;
}
async function upsertMapping(
sql: any,
organizationId: string,
templateVersionId: string,
mapping: TemplateMappingSeed
) {
const existing = (
(await sql`
select id
from crm_document_template_mappings
where template_version_id = ${templateVersionId}
and placeholder_key = ${mapping.placeholderKey}
limit 1
`) as Array<{ id: string }>
)[0] ?? null;
const mappingId = String(existing?.id ?? crypto.randomUUID());
if (existing) {
await sql`
update crm_document_template_mappings
set
organization_id = ${organizationId},
source_path = ${mapping.sourcePath},
data_type = ${mapping.dataType},
sheet_name = ${null},
default_value = ${mapping.defaultValue ?? null},
format_mask = ${mapping.formatMask ?? null},
sort_order = ${mapping.sortOrder},
deleted_at = ${null},
updated_at = now()
where id = ${mappingId}
`;
} else {
await sql`
insert into crm_document_template_mappings (
id,
organization_id,
template_version_id,
placeholder_key,
source_path,
data_type,
sheet_name,
default_value,
format_mask,
sort_order,
deleted_at
) values (
${mappingId},
${organizationId},
${templateVersionId},
${mapping.placeholderKey},
${mapping.sourcePath},
${mapping.dataType},
${null},
${mapping.defaultValue ?? null},
${mapping.formatMask ?? null},
${mapping.sortOrder},
${null}
)
`;
}
const desiredColumnNames = new Set((mapping.columns ?? []).map((column) => column.columnName));
for (const column of mapping.columns ?? []) {
const existingColumn =
(
(await sql`
select id
from crm_document_template_table_columns
where mapping_id = ${mappingId}
and column_name = ${column.columnName}
limit 1
`) as Array<{ id: string }>
)[0] ?? null;
if (existingColumn) {
await sql`
update crm_document_template_table_columns
set
organization_id = ${organizationId},
source_field = ${column.sourceField},
column_letter = ${column.columnLetter ?? null},
sort_order = ${column.sortOrder},
format_mask = ${column.formatMask ?? null},
deleted_at = ${null},
updated_at = now()
where id = ${existingColumn.id}
`;
continue;
}
await sql`
insert into crm_document_template_table_columns (
id,
organization_id,
mapping_id,
column_name,
source_field,
column_letter,
sort_order,
format_mask,
deleted_at
) values (
${crypto.randomUUID()},
${organizationId},
${mappingId},
${column.columnName},
${column.sourceField},
${column.columnLetter ?? null},
${column.sortOrder},
${column.formatMask ?? null},
${null}
)
`;
}
const existingColumns = (await sql`
select id, column_name as "columnName"
from crm_document_template_table_columns
where mapping_id = ${mappingId}
and deleted_at is null
`) as Array<{ id: string; columnName: string }>;
for (const column of existingColumns) {
if (desiredColumnNames.has(column.columnName)) {
continue;
}
await sql`
update crm_document_template_table_columns
set
deleted_at = now(),
updated_at = now()
where id = ${column.id}
`;
}
}
async function main() {
const sql = createSqlClient();
try {
const templates = (await sql`
select
t.id,
t.organization_id as "organizationId",
o.name as "organizationName",
t.template_name as "templateName",
t.created_by as "createdBy",
t.updated_by as "updatedBy"
from crm_document_templates t
inner join organizations o
on o.id = t.organization_id
where t.document_type = 'quotation'
and t.file_type = 'pdfme'
and t.product_type = 'default'
and t.deleted_at is null
order by o.name asc, t.created_at asc
`) as TemplateRow[];
const results = [];
for (const template of templates) {
const templateSource = loadTemplateSourceByOrganizationName(template.organizationName);
if (!templateSource) {
results.push({
organizationName: template.organizationName,
templateName: template.templateName,
status: 'WARNING',
message: 'No source template file found for organization'
});
continue;
}
const versions = (await sql`
select
id,
template_id as "templateId",
version,
schema_json as "schemaJson",
file_path as "filePath",
is_active as "isActive",
created_at as "createdAt"
from crm_document_template_versions
where template_id = ${template.id}
and deleted_at is null
order by created_at asc
`) as TemplateVersionRow[];
const activeVersion = versions.find((version) => version.isActive) ?? versions.at(-1) ?? null;
if (!activeVersion) {
throw new Error(`Template ${template.templateName} has no version rows`);
}
const targetSchema = templateSource.schemaJson;
const targetSchemaHash = computeSchemaHash(targetSchema);
const matchingVersion =
versions.find(
(version) => computeSchemaHash(normalizeSchemaJson(version.schemaJson)) === targetSchemaHash
) ?? null;
const previousFields = extractTemplateFields(normalizeSchemaJson(activeVersion.schemaJson));
const targetFields = extractTemplateFields(targetSchema);
const targetFieldNames = new Set(targetFields.map((field) => field.name).filter(Boolean));
const targetTokens = new Set(targetFields.flatMap((field) => field.tokens));
const supportedMappings = buildSupportedMappingsForFieldNames(targetFieldNames, targetTokens);
const mappedKeys = getEffectiveMappedKeys(targetFields, supportedMappings);
const fieldDiff = computeFieldDiff(targetFields, previousFields);
const createdNewVersion = !matchingVersion;
const targetVersionLabel = matchingVersion
? matchingVersion.version
: bumpMinorTemplateVersion(versions.at(-1)?.version ?? activeVersion.version);
const targetVersionId = matchingVersion?.id ?? crypto.randomUUID();
await sql.begin(async (tx) => {
if (!matchingVersion) {
await tx`
insert into crm_document_template_versions (
id,
organization_id,
template_id,
version,
file_path,
schema_json,
preview_image_url,
is_active,
deleted_at,
created_by
) values (
${targetVersionId},
${template.organizationId},
${template.id},
${targetVersionLabel},
${templateSource.relativePath},
${JSON.stringify(targetSchema)},
${null},
${true},
${null},
${template.updatedBy || template.createdBy}
)
`;
} else {
await tx`
update crm_document_template_versions
set
file_path = ${templateSource.relativePath},
is_active = ${true},
updated_at = now()
where id = ${targetVersionId}
`;
}
await tx`
update crm_document_template_versions
set
is_active = case when id = ${targetVersionId} then true else false end,
updated_at = now()
where template_id = ${template.id}
and deleted_at is null
`;
for (const mapping of supportedMappings) {
await upsertMapping(tx, template.organizationId, targetVersionId, mapping);
}
const desiredKeys = new Set(supportedMappings.map((mapping) => mapping.placeholderKey));
const existingMappings = (await tx`
select id, placeholder_key as "placeholderKey"
from crm_document_template_mappings
where template_version_id = ${targetVersionId}
and deleted_at is null
`) as Array<{ id: string; placeholderKey: string }>;
for (const mapping of existingMappings) {
if (desiredKeys.has(mapping.placeholderKey)) {
continue;
}
await tx`
update crm_document_template_mappings
set
deleted_at = now(),
updated_at = now()
where id = ${mapping.id}
`;
}
});
const classificationCounts = new Map<string, number>();
for (const field of targetFields) {
const classification = classifyTemplateField(field, mappedKeys);
classificationCounts.set(
classification,
(classificationCounts.get(classification) ?? 0) + 1
);
}
results.push({
organizationName: template.organizationName,
templateName: template.templateName,
sourceFile: templateSource.relativePath,
previousActiveVersion: activeVersion.version,
activeVersion: targetVersionLabel,
createdNewVersion,
retainedInactiveVersions: versions.filter((version) => version.id !== targetVersionId).length,
deletedFields: fieldDiff.deletedFields,
newFields: fieldDiff.newFields,
mappingKeys: supportedMappings.map((mapping) => mapping.placeholderKey),
classificationCounts: Object.fromEntries(classificationCounts.entries()),
status: summarizeIssues([
fieldDiff.deletedFields.includes('exclusion_data') ? 'PASS' : 'PASS',
supportedMappings.some((mapping) => mapping.placeholderKey === 'tel_label') ? 'PASS' : 'FAIL'
])
});
}
const overallStatus = summarizeIssues(
results.map((result) => result.status as 'PASS' | 'WARNING' | 'FAIL')
);
console.log(
JSON.stringify(
{
action: 'reseed-pdf-template-version',
overallStatus,
templateCount: results.length,
results
},
null,
2
)
);
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((error) => {
console.error(`[reseed-pdf-template-version] ${error.message}`);
process.exit(1);
});