Files
alla-allaos-fullstack/scripts/reseed-pdf-template-version.ts
phaichayon df1821982d commit
2026-07-15 05:46:59 +07:00

463 lines
13 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import {
buildSupportedMappingsForFieldNames,
bumpMinorTemplateVersion,
classifyTemplateField,
computeFieldDiff,
createSqlClient,
extractTemplateFields,
getEffectiveMappedKeys,
loadTemplateSourceByOrganizationName,
summarizeIssues,
type TemplateMappingSeed,
type TemplateSourceVariant,
} 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;
};
type ScriptOptions = {
variant: TemplateSourceVariant;
activate: boolean;
};
function computeSchemaHash(schemaJson: unknown): string {
return createHash('sha256').update(JSON.stringify(schemaJson)).digest('hex');
}
function normalizeSchemaJson(schemaJson: unknown): unknown {
if (typeof schemaJson === 'string') {
return JSON.parse(schemaJson) as unknown;
}
return schemaJson;
}
function parseArgs(argv: string[]): ScriptOptions {
let variant: TemplateSourceVariant = 'legacy';
let activate = false;
for (const arg of argv) {
if (arg === '--activate') {
activate = true;
continue;
}
if (arg.startsWith('--template-variant=')) {
const value = arg.slice('--template-variant='.length);
if (value === 'legacy' || value === 'product-v1') {
variant = value;
}
}
}
if (variant === 'legacy') {
activate = true;
}
return { variant, activate };
}
function resolveVersionLabel(args: {
variant: TemplateSourceVariant;
versions: TemplateVersionRow[];
activeVersion: TemplateVersionRow;
matchingVersion: TemplateVersionRow | null;
}): string {
if (args.matchingVersion) {
return args.matchingVersion.version;
}
if (args.variant === 'product-v1') {
return '2.0';
}
return bumpMinorTemplateVersion(args.versions.at(-1)?.version ?? args.activeVersion.version);
}
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
`
)[0] as { id: string } | undefined;
const mappingId = existing?.id ?? 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
) values (
${mappingId},
${organizationId},
${templateVersionId},
${mapping.placeholderKey},
${mapping.sourcePath},
${mapping.dataType},
${null},
${mapping.defaultValue ?? null},
${mapping.formatMask ?? null},
${mapping.sortOrder}
)
`;
}
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}
and deleted_at is null
limit 1
`
)[0] as { id: string } | undefined;
const columnId = existingColumn?.id ?? randomUUID();
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 ?? 0},
format_mask = ${column.formatMask ?? null},
deleted_at = ${null},
updated_at = now()
where id = ${columnId}
`;
} else {
await sql`
insert into crm_document_template_table_columns (
id,
organization_id,
mapping_id,
column_name,
source_field,
column_letter,
sort_order,
format_mask
) values (
${columnId},
${organizationId},
${mappingId},
${column.columnName},
${column.sourceField},
${column.columnLetter ?? null},
${column.sortOrder ?? 0},
${column.formatMask ?? 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 options = parseArgs(process.argv.slice(2));
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: Array<Record<string, unknown>> = [];
for (const template of templates) {
const templateSource = loadTemplateSourceByOrganizationName(
template.organizationName,
options.variant,
);
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 = resolveVersionLabel({
variant: options.variant,
versions,
activeVersion,
matchingVersion,
});
const targetVersionId = matchingVersion?.id ?? randomUUID();
const isProductVariant = options.variant === 'product-v1';
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,
metadata_json,
preview_image_url,
is_active,
deleted_at,
created_by
) values (
${targetVersionId},
${template.organizationId},
${template.id},
${targetVersionLabel},
${templateSource.relativePath},
${JSON.stringify(targetSchema)},
case
when ${isProductVariant} then jsonb_build_object('templateVariant', 'product-v1')
else null
end,
${null},
${options.activate},
${null},
${template.updatedBy || template.createdBy}
)
`;
} else {
await tx`
update crm_document_template_versions
set
file_path = ${templateSource.relativePath},
metadata_json = case
when ${isProductVariant}
then coalesce(metadata_json, '{}'::jsonb) || jsonb_build_object('templateVariant', 'product-v1')
else metadata_json
end,
is_active = ${options.activate ? true : matchingVersion.isActive},
updated_at = now()
where id = ${targetVersionId}
`;
}
if (options.activate) {
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,
variant: options.variant,
activated: options.activate,
sourceFile: templateSource.relativePath,
previousActiveVersion: activeVersion.version,
targetVersion: 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([
supportedMappings.some((mapping) => mapping.placeholderKey === 'tel_label')
? 'PASS'
: 'FAIL',
options.variant === 'product-v1' &&
supportedMappings.some((mapping) => mapping.placeholderKey === 'items_table')
? 'PASS'
: 'PASS',
]),
});
}
const overallStatus = summarizeIssues(
results.map((result) => String(result.status) as 'PASS' | 'WARNING' | 'FAIL'),
);
console.log(
JSON.stringify(
{
action: 'reseed-pdf-template-version',
overallStatus,
variant: options.variant,
activate: options.activate,
resultCount: 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);
});