task p-4.5

This commit is contained in:
phaichayon
2026-06-29 16:07:03 +07:00
parent aa73b9aed2
commit a1cac84e2b
19 changed files with 4812 additions and 413 deletions

View File

@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import {
buildSupportedMappingsForFieldNames,
bumpMinorTemplateVersion,
@@ -9,7 +9,8 @@ import {
getEffectiveMappedKeys,
loadTemplateSourceByOrganizationName,
summarizeIssues,
type TemplateMappingSeed
type TemplateMappingSeed,
type TemplateSourceVariant,
} from './pdf-audit-utils.ts';
type TemplateRow = {
@@ -31,11 +32,16 @@ type TemplateVersionRow = {
createdAt: string;
};
function computeSchemaHash(schemaJson: unknown) {
type ScriptOptions = {
variant: TemplateSourceVariant;
activate: boolean;
};
function computeSchemaHash(schemaJson: unknown): string {
return createHash('sha256').update(JSON.stringify(schemaJson)).digest('hex');
}
function normalizeSchemaJson(schemaJson: unknown) {
function normalizeSchemaJson(schemaJson: unknown): unknown {
if (typeof schemaJson === 'string') {
return JSON.parse(schemaJson) as unknown;
}
@@ -43,22 +49,66 @@ function normalizeSchemaJson(schemaJson: 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
mapping: TemplateMappingSeed,
) {
const existing = (
(await sql`
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());
`
)[0] as { id: string } | undefined;
const mappingId = existing?.id ?? randomUUID();
if (existing) {
await sql`
@@ -87,8 +137,7 @@ async function upsertMapping(
sheet_name,
default_value,
format_mask,
sort_order,
deleted_at
sort_order
) values (
${mappingId},
${organizationId},
@@ -99,8 +148,7 @@ async function upsertMapping(
${null},
${mapping.defaultValue ?? null},
${mapping.formatMask ?? null},
${mapping.sortOrder},
${null}
${mapping.sortOrder}
)
`;
}
@@ -108,16 +156,18 @@ async function upsertMapping(
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;
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`
@@ -126,46 +176,43 @@ async function upsertMapping(
organization_id = ${organizationId},
source_field = ${column.sourceField},
column_letter = ${column.columnLetter ?? null},
sort_order = ${column.sortOrder},
sort_order = ${column.sortOrder ?? 0},
format_mask = ${column.formatMask ?? null},
deleted_at = ${null},
updated_at = now()
where id = ${existingColumn.id}
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}
)
`;
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`
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 }>;
` as Array<{ id: string; columnName: string }>;
for (const column of existingColumns) {
if (desiredColumnNames.has(column.columnName)) {
@@ -174,19 +221,18 @@ async function upsertMapping(
await sql`
update crm_document_template_table_columns
set
deleted_at = now(),
updated_at = now()
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`
const templates = await sql`
select
t.id,
t.organization_id as "organizationId",
@@ -195,30 +241,33 @@ async function main() {
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
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 = [];
` as TemplateRow[];
const results: Array<Record<string, unknown>> = [];
for (const template of templates) {
const templateSource = loadTemplateSourceByOrganizationName(template.organizationName);
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'
message: 'No source template file found for organization.',
});
continue;
}
const versions = (await sql`
const versions = await sql`
select
id,
template_id as "templateId",
@@ -231,32 +280,37 @@ async function main() {
where template_id = ${template.id}
and deleted_at is null
order by created_at asc
`) as TemplateVersionRow[];
` 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`);
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 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 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();
const targetVersionLabel = resolveVersionLabel({
variant: options.variant,
versions,
activeVersion,
matchingVersion,
});
const targetVersionId = matchingVersion?.id ?? randomUUID();
await sql.begin(async (tx) => {
if (!matchingVersion) {
@@ -280,7 +334,7 @@ async function main() {
${templateSource.relativePath},
${JSON.stringify(targetSchema)},
${null},
${true},
${options.activate},
${null},
${template.updatedBy || template.createdBy}
)
@@ -290,32 +344,34 @@ async function main() {
update crm_document_template_versions
set
file_path = ${templateSource.relativePath},
is_active = ${true},
is_active = ${options.activate ? true : matchingVersion.isActive},
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
`;
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`
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 }>;
` as Array<{ id: string; placeholderKey: string }>;
for (const mapping of existingMappings) {
if (desiredKeys.has(mapping.placeholderKey)) {
@@ -324,9 +380,7 @@ async function main() {
await tx`
update crm_document_template_mappings
set
deleted_at = now(),
updated_at = now()
set deleted_at = now(), updated_at = now()
where id = ${mapping.id}
`;
}
@@ -338,16 +392,18 @@ async function main() {
const classification = classifyTemplateField(field, mappedKeys);
classificationCounts.set(
classification,
(classificationCounts.get(classification) ?? 0) + 1
(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,
activeVersion: targetVersionLabel,
targetVersion: targetVersionLabel,
createdNewVersion,
retainedInactiveVersions: versions.filter((version) => version.id !== targetVersionId).length,
deletedFields: fieldDiff.deletedFields,
@@ -355,14 +411,19 @@ async function main() {
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'
])
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) => result.status as 'PASS' | 'WARNING' | 'FAIL')
results.map((result) => String(result.status) as 'PASS' | 'WARNING' | 'FAIL'),
);
console.log(
@@ -370,12 +431,14 @@ async function main() {
{
action: 'reseed-pdf-template-version',
overallStatus,
templateCount: results.length,
results
variant: options.variant,
activate: options.activate,
resultCount: results.length,
results,
},
null,
2
)
2,
),
);
} finally {
await sql.end({ timeout: 5 });