389 lines
11 KiB
TypeScript
389 lines
11 KiB
TypeScript
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);
|
|
});
|