hotfix-add subgroup customer
This commit is contained in:
401
scripts/audit-pdfme-runtime.ts
Normal file
401
scripts/audit-pdfme-runtime.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
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);
|
||||
});
|
||||
@@ -335,10 +335,16 @@ async function main() {
|
||||
`/api/crm/quotations/${draftQuotation.id}/approved-pdf`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
const previewData = await fetchWithAuth(
|
||||
baseUrl,
|
||||
superAdminCookies,
|
||||
`/api/crm/quotations/${approvedQuotation.id}/document-preview`
|
||||
);
|
||||
assert(
|
||||
nonApprovedGenerate.status === 400,
|
||||
`Draft quotation POST expected 400, got ${nonApprovedGenerate.status}`
|
||||
);
|
||||
assert(previewData.status === 200, `Document preview expected 200, got ${previewData.status}`);
|
||||
|
||||
const adminPreview = await fetchWithAuth(
|
||||
baseUrl,
|
||||
@@ -395,6 +401,7 @@ async function main() {
|
||||
|
||||
const pdfShape = estimatePageCount(approvedGet.bytes);
|
||||
const approvedSnapshot = persistence.quotation.approved_snapshot;
|
||||
const previewPayload = previewData.json?.preview;
|
||||
const templatePageCount = await queryTemplatePageCount(
|
||||
sql,
|
||||
persistence.quotation.approved_template_version_id
|
||||
@@ -432,6 +439,41 @@ async function main() {
|
||||
approvedSnapshot.documentData?.topics?.payment?.length >= 1,
|
||||
'approvedSnapshot is missing payment topic data'
|
||||
);
|
||||
assert(
|
||||
typeof previewPayload?.templateInput?.quotation_price === 'string',
|
||||
'templateInput quotation_price is not formatted string'
|
||||
);
|
||||
assert(
|
||||
typeof previewPayload?.templateInput?.quotation_date === 'string',
|
||||
'templateInput quotation_date is not formatted string'
|
||||
);
|
||||
assert(
|
||||
Array.isArray(previewPayload?.templateInput?.exclusion_data) &&
|
||||
previewPayload.templateInput.exclusion_data.length >= 1 &&
|
||||
Array.isArray(previewPayload.templateInput.exclusion_data[0]),
|
||||
'templateInput exclusion_data is not a non-empty string[][] table'
|
||||
);
|
||||
const dynamicTopicKeys = Object.keys(previewPayload?.templateInput ?? {}).filter(
|
||||
(key) => key.startsWith('topic_') || key.startsWith('item_topic_')
|
||||
);
|
||||
assert(dynamicTopicKeys.length >= 2, 'templateInput is missing dynamic topic keys');
|
||||
assert(
|
||||
Array.isArray(previewPayload?.documentData?.pdfme?.topic_inputs?.[dynamicTopicKeys[0]]),
|
||||
'documentData pdfme.topic_inputs is missing generated topic inputs'
|
||||
);
|
||||
const approvedPdfText = approvedGet.bytes.toString('latin1');
|
||||
assert(
|
||||
!approvedPdfText.includes('{exclusion_data}'),
|
||||
'Approved PDF still contains unresolved {exclusion_data}'
|
||||
);
|
||||
assert(
|
||||
!approvedPdfText.includes('{quotation_price}'),
|
||||
'Approved PDF still contains unresolved {quotation_price}'
|
||||
);
|
||||
assert(
|
||||
!approvedPdfText.includes('{item_topic}'),
|
||||
'Approved PDF still contains unresolved {item_topic}'
|
||||
);
|
||||
assert(
|
||||
approvedSnapshot.documentData?.approval?.approvers?.length >= 1,
|
||||
'approvedSnapshot is missing approval block data'
|
||||
@@ -471,6 +513,15 @@ async function main() {
|
||||
disposition: approvedGet.disposition,
|
||||
bytes: approvedGet.bytes.length
|
||||
},
|
||||
documentPreview: {
|
||||
status: previewData.status,
|
||||
quotationPrice: previewPayload?.templateInput?.quotation_price ?? null,
|
||||
quotationDate: previewPayload?.templateInput?.quotation_date ?? null,
|
||||
exclusionRows: Array.isArray(previewPayload?.templateInput?.exclusion_data)
|
||||
? previewPayload.templateInput.exclusion_data.length
|
||||
: 0,
|
||||
dynamicTopicKeys
|
||||
},
|
||||
nonApprovedPost: {
|
||||
status: nonApprovedGenerate.status,
|
||||
message: nonApprovedGenerate.json?.message ?? null
|
||||
|
||||
Reference in New Issue
Block a user