hotfix-add subgroup customer

This commit is contained in:
phaichayon
2026-06-18 08:58:01 +07:00
parent 04a886ea26
commit 0650cf6297
38 changed files with 8674 additions and 687 deletions

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

View File

@@ -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