402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
import { writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { and, isNull } from 'drizzle-orm';
|
|
import { crmQuotations } from '../src/db/schema';
|
|
import { db } from '../src/lib/db';
|
|
import { getQuotationDocumentPreviewData } from '../src/features/crm/quotations/document/server/service';
|
|
import {
|
|
resolveTemplateForDocument,
|
|
resolveTemplateMappings
|
|
} from '../src/features/foundation/document-template/server/service';
|
|
|
|
type AuditIssue =
|
|
| 'NO_MAPPING'
|
|
| 'NO_TEMPLATE_FIELD'
|
|
| 'RESOLVED_UNDEFINED'
|
|
| 'RESOLVED_NULL'
|
|
| 'TABLE_TYPE_MISMATCH'
|
|
| 'TOPIC_MAPPING_MISMATCH';
|
|
|
|
type AuditClassification =
|
|
| 'static_field'
|
|
| 'dynamic_template'
|
|
| 'legacy_content_token'
|
|
| 'runtime_dynamic_input';
|
|
|
|
interface AuditResult {
|
|
placeholderKey: string;
|
|
templateExists: boolean;
|
|
mappingExists: boolean;
|
|
sourcePath: string | null;
|
|
resolvedValue: unknown;
|
|
resolvedType: string;
|
|
classification: AuditClassification;
|
|
dynamicTemplate: boolean;
|
|
legacyContentToken: boolean;
|
|
runtimeGenerated: boolean;
|
|
issues: AuditIssue[];
|
|
notes: string[];
|
|
}
|
|
|
|
const STATIC_EXPECTED_KEYS = new Set([
|
|
'customer_name',
|
|
'customer_addr',
|
|
'customer_tel',
|
|
'customer_email',
|
|
'customer_att',
|
|
'project_name',
|
|
'site_location',
|
|
'quotation_date',
|
|
'quotation_code',
|
|
'quotation_price',
|
|
'currency',
|
|
'exclusion_data',
|
|
'app1',
|
|
'app1_position',
|
|
'app2',
|
|
'app2_position',
|
|
'app3',
|
|
'app3_position'
|
|
]);
|
|
|
|
const DYNAMIC_TEMPLATE_NAMES = new Set(['topic', 'data_topic']);
|
|
const LEGACY_CONTENT_TOKENS = new Set(['item_topic']);
|
|
|
|
function extractSchemaInventory(template: unknown) {
|
|
const fieldNames = new Set<string>();
|
|
const contentTokens = new Set<string>();
|
|
|
|
if (!template || typeof template !== 'object' || !('schemas' in template)) {
|
|
return { fieldNames, contentTokens };
|
|
}
|
|
|
|
const pages = (template as { schemas?: unknown }).schemas;
|
|
|
|
if (!Array.isArray(pages)) {
|
|
return { fieldNames, contentTokens };
|
|
}
|
|
|
|
for (const page of pages) {
|
|
if (!Array.isArray(page)) {
|
|
continue;
|
|
}
|
|
|
|
for (const field of page) {
|
|
if (!field || typeof field !== 'object') {
|
|
continue;
|
|
}
|
|
|
|
if ('name' in field && typeof field.name === 'string') {
|
|
fieldNames.add(field.name);
|
|
}
|
|
|
|
for (const token of extractTokensFromValue((field as { content?: unknown }).content)) {
|
|
contentTokens.add(token);
|
|
}
|
|
|
|
const head = (field as { head?: unknown }).head;
|
|
|
|
if (Array.isArray(head)) {
|
|
for (const value of head) {
|
|
for (const token of extractTokensFromValue(value)) {
|
|
contentTokens.add(token);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { fieldNames, contentTokens };
|
|
}
|
|
|
|
function extractTokensFromValue(value: unknown) {
|
|
const tokens = new Set<string>();
|
|
|
|
if (typeof value !== 'string') {
|
|
return tokens;
|
|
}
|
|
|
|
for (const match of value.matchAll(/\{([^}]+)\}/g)) {
|
|
tokens.add(match[1]);
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(value) as unknown;
|
|
|
|
if (Array.isArray(parsed)) {
|
|
for (const row of parsed) {
|
|
if (!Array.isArray(row)) {
|
|
continue;
|
|
}
|
|
|
|
for (const cell of row) {
|
|
if (typeof cell !== 'string') {
|
|
continue;
|
|
}
|
|
|
|
for (const match of cell.matchAll(/\{([^}]+)\}/g)) {
|
|
tokens.add(match[1]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
return tokens;
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
function classifyKey(key: string): AuditClassification {
|
|
if (DYNAMIC_TEMPLATE_NAMES.has(key)) {
|
|
return 'dynamic_template';
|
|
}
|
|
|
|
if (LEGACY_CONTENT_TOKENS.has(key)) {
|
|
return 'legacy_content_token';
|
|
}
|
|
|
|
if (key.startsWith('topic_') || key.startsWith('item_topic_')) {
|
|
return 'runtime_dynamic_input';
|
|
}
|
|
|
|
return 'static_field';
|
|
}
|
|
|
|
async function findTestQuotation() {
|
|
const quotations = await db
|
|
.select()
|
|
.from(crmQuotations)
|
|
.where(and(isNull(crmQuotations.deletedAt)))
|
|
.limit(5);
|
|
|
|
if (quotations.length === 0) {
|
|
throw new Error('No quotations found in database');
|
|
}
|
|
|
|
return quotations[0];
|
|
}
|
|
|
|
async function auditPdfmeRuntime() {
|
|
console.log('=== PDFME Runtime Audit ===\n');
|
|
|
|
console.log('Step 1: Finding test quotation...');
|
|
const quotation = await findTestQuotation();
|
|
console.log(` Found quotation: ${quotation.code} (${quotation.id})`);
|
|
|
|
console.log('\nStep 2: Getting base template and mappings...');
|
|
const template = await resolveTemplateForDocument(quotation.organizationId, {
|
|
documentType: 'quotation',
|
|
productType: 'default',
|
|
fileType: 'pdfme'
|
|
});
|
|
const mappings = await resolveTemplateMappings(template.version.id, quotation.organizationId);
|
|
console.log(` Template: ${template.template.templateName} (${template.version.id})`);
|
|
console.log(` Mappings found: ${mappings.length}`);
|
|
|
|
console.log('\nStep 3: Building preview runtime data...');
|
|
const preview = await getQuotationDocumentPreviewData(
|
|
quotation.id,
|
|
quotation.organizationId
|
|
);
|
|
const templateInput = preview.templateInput;
|
|
console.log(' Preview runtime data built successfully');
|
|
|
|
console.log('\nStep 4: Extracting schema inventory from base template...');
|
|
const inventory = extractSchemaInventory(template.version.schemaJson);
|
|
const auditedKeys = new Set<string>([
|
|
...STATIC_EXPECTED_KEYS,
|
|
...inventory.fieldNames,
|
|
...inventory.contentTokens,
|
|
...Object.keys(templateInput).filter(
|
|
(key) => key.startsWith('topic_') || key.startsWith('item_topic_')
|
|
)
|
|
]);
|
|
console.log(` Audited keys found: ${auditedKeys.size}`);
|
|
|
|
console.log('\nStep 5: Performing audit...\n');
|
|
const mappingMap = new Map<string, (typeof mappings)[number]>();
|
|
mappings.forEach((mapping) => mappingMap.set(mapping.placeholderKey, mapping));
|
|
|
|
const auditResults: AuditResult[] = [];
|
|
|
|
for (const key of auditedKeys) {
|
|
const classification = classifyKey(key);
|
|
const mappingExists = mappingMap.has(key);
|
|
const templateExists = inventory.fieldNames.has(key) || inventory.contentTokens.has(key);
|
|
const result: AuditResult = {
|
|
placeholderKey: key,
|
|
templateExists,
|
|
mappingExists,
|
|
sourcePath: mappingMap.get(key)?.sourcePath ?? null,
|
|
resolvedValue: templateInput[key],
|
|
resolvedType: typeof templateInput[key],
|
|
classification,
|
|
dynamicTemplate: classification === 'dynamic_template',
|
|
legacyContentToken: classification === 'legacy_content_token',
|
|
runtimeGenerated: classification === 'runtime_dynamic_input',
|
|
issues: [],
|
|
notes: []
|
|
};
|
|
|
|
if (classification === 'static_field' && templateExists && !mappingExists) {
|
|
result.issues.push('NO_MAPPING');
|
|
result.notes.push('Static schema field exists in template but has no DB mapping');
|
|
}
|
|
|
|
if (classification === 'static_field' && mappingExists && !templateExists) {
|
|
result.issues.push('NO_TEMPLATE_FIELD');
|
|
result.notes.push('DB mapping exists but no corresponding schema field or content token was found');
|
|
}
|
|
|
|
if (classification === 'static_field' && result.resolvedValue === undefined) {
|
|
result.issues.push('RESOLVED_UNDEFINED');
|
|
result.notes.push('Resolved runtime value is undefined');
|
|
}
|
|
|
|
if (classification === 'static_field' && result.resolvedValue === null) {
|
|
result.issues.push('RESOLVED_NULL');
|
|
result.notes.push('Resolved runtime value is null');
|
|
}
|
|
|
|
const mapping = mappingMap.get(key);
|
|
|
|
if (mapping?.dataType === 'table') {
|
|
const value = result.resolvedValue;
|
|
|
|
if (value && !Array.isArray(value)) {
|
|
result.issues.push('TABLE_TYPE_MISMATCH');
|
|
result.notes.push(`Expected string[][] for table mapping, got ${typeof value}`);
|
|
} else if (
|
|
Array.isArray(value) &&
|
|
value.length > 0 &&
|
|
!Array.isArray(value[0])
|
|
) {
|
|
result.issues.push('TABLE_TYPE_MISMATCH');
|
|
result.notes.push('Expected string[][] for table mapping, got a flat array');
|
|
}
|
|
}
|
|
|
|
if (classification === 'dynamic_template') {
|
|
if (mappingExists) {
|
|
result.issues.push('TOPIC_MAPPING_MISMATCH');
|
|
result.notes.push('Dynamic schema template must not have a static DB mapping');
|
|
} else {
|
|
result.notes.push('Handled by Dynamic Topic Engine schema cloning');
|
|
}
|
|
}
|
|
|
|
if (classification === 'legacy_content_token') {
|
|
if (mappingExists) {
|
|
result.issues.push('TOPIC_MAPPING_MISMATCH');
|
|
result.notes.push('Legacy item_topic token must not persist as a DB mapping');
|
|
} else {
|
|
result.notes.push('Injected only through dynamic topic runtime inputs');
|
|
}
|
|
}
|
|
|
|
if (classification === 'runtime_dynamic_input') {
|
|
if (mappingExists) {
|
|
result.issues.push('TOPIC_MAPPING_MISMATCH');
|
|
result.notes.push('Runtime dynamic topic keys must not be stored in DB mappings');
|
|
} else if (result.resolvedValue !== undefined) {
|
|
result.notes.push('Generated at runtime by Dynamic Topic Engine');
|
|
}
|
|
}
|
|
|
|
auditResults.push(result);
|
|
}
|
|
|
|
console.log('## Summary');
|
|
console.log(
|
|
`- Static template placeholders: ${[...inventory.fieldNames].filter((key) => STATIC_EXPECTED_KEYS.has(key)).length}`
|
|
);
|
|
console.log(`- Template mappings: ${mappings.length}`);
|
|
console.log(
|
|
`- Dynamic topic runtime keys: ${auditResults.filter((item) => item.runtimeGenerated).length}`
|
|
);
|
|
console.log(`- Issues found: ${auditResults.filter((item) => item.issues.length > 0).length}`);
|
|
console.log('');
|
|
|
|
auditResults.sort((left, right) => {
|
|
if (left.issues.length > 0 && right.issues.length === 0) {
|
|
return -1;
|
|
}
|
|
|
|
if (left.issues.length === 0 && right.issues.length > 0) {
|
|
return 1;
|
|
}
|
|
|
|
return left.placeholderKey.localeCompare(right.placeholderKey);
|
|
});
|
|
|
|
console.log('## Detailed Results\n');
|
|
|
|
for (const result of auditResults) {
|
|
console.log(`### ${result.placeholderKey}`);
|
|
console.log(`- Template: ${result.templateExists ? 'yes' : 'no'}`);
|
|
console.log(`- Mapping: ${result.mappingExists ? 'yes' : 'no'}`);
|
|
console.log(`- Classification: ${result.classification}`);
|
|
console.log(`- Source: ${result.sourcePath ?? '-'}`);
|
|
console.log(
|
|
`- Value: ${JSON.stringify(result.resolvedValue).slice(0, 120)}${JSON.stringify(result.resolvedValue).length > 120 ? '...' : ''}`
|
|
);
|
|
console.log(`- Type: ${result.resolvedType}`);
|
|
|
|
if (result.issues.length > 0) {
|
|
console.log(`- Issues: ${result.issues.join(', ')}`);
|
|
}
|
|
|
|
if (result.notes.length > 0) {
|
|
console.log(`- Notes: ${result.notes.join('; ')}`);
|
|
}
|
|
|
|
console.log('');
|
|
}
|
|
|
|
const outputPath = path.join(
|
|
process.cwd(),
|
|
'docs',
|
|
'implementation',
|
|
'pdfme-runtime-audit-data.json'
|
|
);
|
|
|
|
await writeFile(
|
|
outputPath,
|
|
JSON.stringify(
|
|
{
|
|
quotation: {
|
|
id: quotation.id,
|
|
code: quotation.code,
|
|
organizationId: quotation.organizationId
|
|
},
|
|
template: {
|
|
id: template.template.id,
|
|
templateName: template.template.templateName,
|
|
versionId: template.version.id,
|
|
version: template.version.version
|
|
},
|
|
schemaInventory: {
|
|
fieldNames: [...inventory.fieldNames].sort(),
|
|
contentTokens: [...inventory.contentTokens].sort()
|
|
},
|
|
runtimeDynamicKeys: Object.keys(templateInput)
|
|
.filter((key) => key.startsWith('topic_') || key.startsWith('item_topic_'))
|
|
.sort(),
|
|
results: auditResults,
|
|
documentData: preview.documentData,
|
|
templateInput
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
console.log(`Full audit data saved to: ${outputPath}`);
|
|
}
|
|
|
|
auditPdfmeRuntime().catch((error) => {
|
|
console.error('Audit failed:', error);
|
|
process.exit(1);
|
|
});
|