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

@@ -135,6 +135,9 @@ async function main() {
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',
@@ -172,6 +175,24 @@ async function main() {
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];

View File

@@ -41,7 +41,10 @@ async function main() {
const duplicateFields = getDuplicateFieldNames(fields);
const unknownFields = getUnknownFieldNames(fields, mappedKeys);
const fieldDiff = computeFieldDiff(fields, previousFields);
const requiredTableFields = ['quotation_price_data'];
const hasProductItemsMarker = fieldNames.has('__section_role__product_items');
const requiredTableFields = hasProductItemsMarker
? ['items_table']
: ['quotation_price_data'];
const missingRequiredTableFields = requiredTableFields.filter(
(fieldName) => !fields.some((field) => field.name === fieldName && field.type === 'table')
);

View File

@@ -13,6 +13,10 @@ const TYPO_PATTERNS = [/lable/i, /affter/i, /__+/, /\s{2,}/];
const RESERVED_DYNAMIC_FIELD_NAMES = new Set(['topic', 'data_topic']);
function detectTypo(fieldName: string) {
if (fieldName.startsWith('__section_role__')) {
return false;
}
return TYPO_PATTERNS.some((pattern) => pattern.test(fieldName));
}

View File

@@ -3,8 +3,9 @@ import {
createSqlClient,
getActiveTemplateVersions,
loadTemplateSourceByOrganizationName,
loadTemplateSourceByRelativePath,
summarizeIssues,
writeAuditArtifact
writeAuditArtifact,
} from './pdf-audit-utils.ts';
type IntegrityIssue = {
@@ -29,7 +30,7 @@ async function main() {
versionRows,
orphanMappingRows,
payload,
templateRows
templateRows,
] = await Promise.all([
getActiveTemplateVersions(sql),
sql`
@@ -39,14 +40,13 @@ async function main() {
v.organization_id as "organizationId",
o.name as "organizationName",
v.version,
v.file_path as "filePath",
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
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`
@@ -55,8 +55,7 @@ async function main() {
m.template_version_id as "templateVersionId",
m.placeholder_key as "placeholderKey"
from crm_document_template_mappings m
left join crm_document_template_versions v
on v.id = m.template_version_id
left join crm_document_template_versions v on v.id = m.template_version_id
where m.deleted_at is null
and (v.id is null or v.deleted_at is not null)
`,
@@ -68,47 +67,40 @@ async function main() {
o.name as "organizationName",
t.template_name as "templateName"
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.deleted_at is null
and t.document_type = 'quotation'
and t.file_type = 'pdfme'
`
`,
]);
const issues: IntegrityIssue[] = [];
const templateBuckets = new Map<string, typeof activeTemplateVersions>();
for (const record of activeTemplateVersions) {
const bucketKey = [
record.organizationId,
record.documentType,
record.productType,
record.fileType
].join(':');
const items = templateBuckets.get(bucketKey) ?? [];
items.push(record);
templateBuckets.set(bucketKey, items);
const issues: IntegrityIssue[] = [];
const templateBuckets = new Map<string, Array<Record<string, unknown>>>();
for (const record of activeTemplateVersions as unknown as Array<Record<string, unknown>>) {
const bucketKey = `${String(record.organizationId)}:${String(record.templateName)}`;
const records = templateBuckets.get(bucketKey) ?? [];
records.push(record);
templateBuckets.set(bucketKey, records);
}
for (const [bucketKey, records] of templateBuckets.entries()) {
const templateIds = [...new Set(records.map((record) => record.templateId))];
if (templateIds.length > 1) {
issues.push({
kind: 'DUPLICATE_ACTIVE_TEMPLATE',
message: `More than one active template exists for ${bucketKey}`,
context: {
bucketKey,
templateIds,
templates: records.map((record) => ({
templateId: record.templateId,
templateName: record.templateName,
versionId: record.versionId,
version: record.version
}))
}
});
if (records.length <= 1) {
continue;
}
issues.push({
kind: 'DUPLICATE_ACTIVE_TEMPLATE',
message: `Multiple active templates found for ${bucketKey}.`,
context: {
bucketKey,
templateIds: records.map((record) => record.templateId),
templates: records.map((record) => ({
templateId: record.templateId,
templateName: record.templateName,
versionId: record.versionId,
version: record.version,
})),
},
});
}
const versionBuckets = new Map<string, Array<Record<string, unknown>>>();
@@ -125,36 +117,42 @@ async function main() {
if (!row.resolvedTemplateId) {
issues.push({
kind: 'ACTIVE_VERSION_WITHOUT_TEMPLATE',
message: `Active version ${String(row.id)} has no parent template`,
context: row
message: `Active version ${String(row.id)} has no parent template.`,
context: row,
});
}
}
for (const [templateId, rows] of versionBuckets.entries()) {
if (rows.length > 1) {
issues.push({
kind: 'DUPLICATE_ACTIVE_VERSION',
message: `Template ${templateId} has more than one active version`,
context: {
templateId,
versions: rows.map((row) => ({
id: row.id,
version: row.version
}))
}
});
if (rows.length <= 1) {
continue;
}
issues.push({
kind: 'DUPLICATE_ACTIVE_VERSION',
message: `Template ${templateId} has more than one active version.`,
context: {
templateId,
versions: rows.map((row) => ({
id: row.id,
version: row.version,
})),
},
});
}
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
(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));
const templateSource =
loadTemplateSourceByRelativePath(
(activeRows[0]?.filePath as string | null | undefined) ?? null,
) ??
loadTemplateSourceByOrganizationName(String(templateRow.organizationName));
if (templateSource && activeRows.length === 1) {
const activeSchema =
@@ -165,14 +163,14 @@ async function main() {
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)}`,
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
}
sourceFile: templateSource.relativePath,
},
});
}
}
@@ -180,11 +178,11 @@ async function main() {
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`,
message: `Template ${String(templateRow.templateName)} has no retained inactive historical version.`,
context: {
templateId,
organizationName: templateRow.organizationName
}
organizationName: templateRow.organizationName,
},
});
}
}
@@ -192,43 +190,67 @@ async function main() {
for (const row of orphanMappingRows as Array<Record<string, unknown>>) {
issues.push({
kind: 'MAPPING_WITHOUT_VERSION',
message: `Mapping ${String(row.id)} points to a missing template version`,
context: row
message: `Mapping ${String(row.id)} points to a missing template version.`,
context: row,
});
}
const approvedTemplateVersionExists = payload.approvedTemplateVersionId
? (versionRows as Array<Record<string, unknown>>).some(
(row) => String(row.id) === payload.approvedTemplateVersionId
(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 a retained template version',
message: 'Approved snapshot references a template version that no longer exists.',
context: {
quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId
}
approvedTemplateVersionId: payload.approvedTemplateVersionId,
},
});
}
const status = summarizeIssues(issues.length ? ['FAIL'] : ['PASS']);
const report = {
const overallStatus = summarizeIssues(
issues.length === 0 ? ['PASS'] : issues.map(() => 'FAIL'),
);
const artifactPath = await writeAuditArtifact('template-version-report.json', {
audit: 'template-version-integrity',
overallStatus: status,
overallStatus,
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);
approvedTemplateVersionIsActive: payload.approvedTemplateVersionId
? (versionRows as Array<Record<string, unknown>>).some(
(row) =>
String(row.id) === payload.approvedTemplateVersionId && Boolean(row.isActive),
)
: false,
issues,
});
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
console.log(
JSON.stringify(
{
audit: 'template-version-integrity',
overallStatus,
activeTemplateCount: activeTemplateVersions.length,
quotationCode: payload.quotationCode,
approvedTemplateVersionId: payload.approvedTemplateVersionId,
approvedTemplateVersionIsActive: payload.approvedTemplateVersionId
? (versionRows as Array<Record<string, unknown>>).some(
(row) =>
String(row.id) === payload.approvedTemplateVersionId && Boolean(row.isActive),
)
: false,
issues,
artifactPath,
},
null,
2,
),
);
} finally {
await sql.end({ timeout: 5 });
}

View File

@@ -35,6 +35,7 @@ export interface ActiveTemplateVersionRecord {
fileType: string;
versionId: string;
version: string;
filePath: string | null;
schemaJson: unknown;
}
@@ -152,6 +153,7 @@ export const PDFME_STATIC_LABEL_FIELD_KEYS = [
'project_label',
'site_label'
] as const;
export type TemplateSourceVariant = 'legacy' | 'product-v1';
export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
{
placeholderKey: 'customer_name',
@@ -327,11 +329,18 @@ export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
formatMask: 'currency'
},
{
columnName: 'Total',
sourceField: 'totalPrice',
columnName: 'Discount',
sourceField: 'discount',
columnLetter: 'F',
sortOrder: 6,
formatMask: 'currency'
},
{
columnName: 'Total',
sourceField: 'totalPrice',
columnLetter: 'G',
sortOrder: 7,
formatMask: 'currency'
}
]
}
@@ -665,12 +674,23 @@ export function normalizeTemplateInputAliases(
return normalized;
}
export function loadTemplateSourceByOrganizationName(organizationName: string) {
export function loadTemplateSourceByOrganizationName(
organizationName: string,
variant: TemplateSourceVariant = 'legacy',
) {
const normalized = organizationName.toUpperCase();
const fileName = normalized.includes('ONVALLA')
? 'ONVALLA_template_pdfme_fainal3.json'
? (
variant === 'product-v1'
? 'ONVALLA_template_pdfme_product_v1.json'
: 'ONVALLA_template_pdfme_fainal3.json'
)
: normalized.includes('ALLA')
? 'ALLA_template_pdfme_fainal3.json'
? (
variant === 'product-v1'
? 'ALLA_template_pdfme_product_v1.json'
: 'ALLA_template_pdfme_fainal3.json'
)
: null;
if (!fileName) {
@@ -686,6 +706,26 @@ export function loadTemplateSourceByOrganizationName(organizationName: string) {
};
}
export function loadTemplateSourceByRelativePath(relativePath: string | null | undefined) {
if (!relativePath) {
return null;
}
const normalizedRelativePath = relativePath.replaceAll('\\', '/');
const absolutePath = path.resolve(process.cwd(), normalizedRelativePath);
if (!fs.existsSync(absolutePath)) {
return null;
}
return {
fileName: path.basename(normalizedRelativePath),
relativePath: normalizedRelativePath,
absolutePath,
schemaJson: JSON.parse(fs.readFileSync(absolutePath, 'utf8')) as unknown
};
}
export function computeFieldDiff(
currentFields: TemplateFieldRecord[],
previousFields: TemplateFieldRecord[]
@@ -739,16 +779,17 @@ function normalizeSnapshotPayload(snapshot: unknown) {
export async function getActiveTemplateVersions(sql: SqlClient): Promise<ActiveTemplateVersionRecord[]> {
const rows = await sql`
select
t.id as "templateId",
t.template_name as "templateName",
t.organization_id as "organizationId",
t.document_type as "documentType",
t.product_type as "productType",
t.file_type as "fileType",
v.id as "versionId",
v.version,
v.schema_json as "schemaJson"
select
t.id as "templateId",
t.template_name as "templateName",
t.organization_id as "organizationId",
t.document_type as "documentType",
t.product_type as "productType",
t.file_type as "fileType",
v.id as "versionId",
v.version,
v.file_path as "filePath",
v.schema_json as "schemaJson"
from crm_document_templates t
inner join crm_document_template_versions v
on v.template_id = t.id
@@ -975,6 +1016,55 @@ function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: nu
.map((item) => [item.content?.trim() || '-']);
}
function formatPdfDecimal(amount: number | string | null | undefined) {
if (amount === null || amount === undefined) {
return '-';
}
const normalized =
typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
if (!Number.isFinite(normalized)) {
return '-';
}
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 3
}).format(normalized);
}
function buildAuditItemsTable(
items: Array<{
itemNumber?: number | null;
description?: string | null;
quantity?: number | null;
unitLabel?: string | null;
unitPrice?: number | null;
discount?: number | null;
totalPrice?: number | null;
notes?: string | null;
}>,
currencyCode?: string | null
) {
return items.map((item, index) => {
const description = [item.description?.trim(), item.notes?.trim()]
.filter((value): value is string => Boolean(value))
.join('\n')
.trim();
return [
String(item.itemNumber ?? index + 1),
description || '-',
formatPdfDecimal(item.quantity ?? null),
item.unitLabel?.trim() || '-',
formatPdfCurrency(item.unitPrice ?? null, currencyCode?.trim() || 'THB'),
formatPdfCurrency(item.discount ?? 0, currencyCode?.trim() || 'THB'),
formatPdfCurrency(item.totalPrice ?? null, currencyCode?.trim() || 'THB')
];
});
}
function applyFormatMask(value: unknown, formatMask?: string | null) {
if (value === null || value === undefined) {
return value;
@@ -1436,10 +1526,22 @@ export async function buildAuditPayload(sql: SqlClient): Promise<AuditPayload> {
),
...topicInputs
};
const normalizedTemplateInput = normalizeTemplateInputAliases(
extractTemplateFields(templateVersion.schemaJson),
templateInput
);
const templateFields = extractTemplateFields(templateVersion.schemaJson);
const hasItemsTableField = templateFields.some((field) => field.name === 'items_table');
const normalizedTemplateInput = normalizeTemplateInputAliases(
templateFields,
{
...templateInput,
...(hasItemsTableField
? {
items_table: buildAuditItemsTable(
(documentData as { items: Parameters<typeof buildAuditItemsTable>[0] }).items,
(documentData as { quotation: { currencyCode?: string | null } }).quotation.currencyCode
)
}
: {}),
}
);
return {
organizationId: quotation.organizationId,

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 });