pdf task-h
This commit is contained in:
67
scripts/audit-approved-snapshot-parity.ts
Normal file
67
scripts/audit-approved-snapshot-parity.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
buildAuditPayload,
|
||||
buildComputedSnapshotPayload,
|
||||
computePayloadHash,
|
||||
createSqlClient,
|
||||
getSnapshotDocumentData,
|
||||
getSnapshotTemplateInput,
|
||||
summarizeIssues,
|
||||
writeAuditArtifact
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
async function main() {
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const payload = await buildAuditPayload(sql);
|
||||
const computedSnapshot = buildComputedSnapshotPayload(payload, {
|
||||
generatedAt: 'audit-generated',
|
||||
generatedBy: 'audit-suite'
|
||||
});
|
||||
const storedTemplateInput = getSnapshotTemplateInput(payload.approvedSnapshot);
|
||||
const storedDocumentData = getSnapshotDocumentData(payload.approvedSnapshot);
|
||||
const comparableKeys = storedTemplateInput
|
||||
? Object.keys(computedSnapshot.templateInput).filter((key) => key in storedTemplateInput)
|
||||
: [];
|
||||
const inconsistentTemplateKeys = storedTemplateInput
|
||||
? comparableKeys.filter(
|
||||
(key) =>
|
||||
JSON.stringify(computedSnapshot.templateInput[key]) !==
|
||||
JSON.stringify(storedTemplateInput[key])
|
||||
)
|
||||
: [];
|
||||
const computedSignatureHash = computePayloadHash(
|
||||
(computedSnapshot.documentData.signatures as Record<string, unknown>) ?? {}
|
||||
);
|
||||
const storedSignatureHash = storedDocumentData
|
||||
? computePayloadHash((storedDocumentData.signatures as Record<string, unknown>) ?? {})
|
||||
: null;
|
||||
const status = summarizeIssues([
|
||||
payload.approvedSnapshot ? 'PASS' : 'WARNING',
|
||||
inconsistentTemplateKeys.length ? 'FAIL' : 'PASS',
|
||||
payload.approvedSnapshot && computedSignatureHash !== storedSignatureHash ? 'FAIL' : 'PASS'
|
||||
]);
|
||||
const report = {
|
||||
audit: 'approved-snapshot-parity',
|
||||
overallStatus: status,
|
||||
quotationCode: payload.quotationCode,
|
||||
approvedSnapshotAvailable: Boolean(payload.approvedSnapshot),
|
||||
approvedTemplateVersionId: payload.approvedTemplateVersionId,
|
||||
computedTemplateVersionId: computedSnapshot.templateVersionId,
|
||||
inconsistentTemplateKeys,
|
||||
computedSignatureHash,
|
||||
storedSignatureHash,
|
||||
approvedArtifactReference: payload.approvedArtifactReference
|
||||
};
|
||||
const artifactPath = await writeAuditArtifact('signature-audit-report.json', report);
|
||||
|
||||
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[audit-approved-snapshot-parity] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
81
scripts/audit-payload-parity.ts
Normal file
81
scripts/audit-payload-parity.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
buildAuditPayload,
|
||||
buildComputedSnapshotPayload,
|
||||
computePayloadHash,
|
||||
createSqlClient,
|
||||
summarizeIssues,
|
||||
writeAuditArtifact
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
function pickComparablePayload(payload: Record<string, unknown>) {
|
||||
return {
|
||||
templateInput: payload.templateInput,
|
||||
topicInputKeys:
|
||||
payload.topicInputs && typeof payload.topicInputs === 'object'
|
||||
? Object.keys(payload.topicInputs as Record<string, unknown>).sort()
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const payload = await buildAuditPayload(sql);
|
||||
const previewPayload = pickComparablePayload({
|
||||
templateInput: payload.templateInput,
|
||||
topicInputs: payload.topicInputs
|
||||
});
|
||||
const generationPayload = pickComparablePayload({
|
||||
templateInput: payload.templateInput,
|
||||
topicInputs: payload.topicInputs
|
||||
});
|
||||
const computedSnapshot = buildComputedSnapshotPayload(payload, {
|
||||
generatedAt: 'audit-generated',
|
||||
generatedBy: 'audit-suite'
|
||||
});
|
||||
const snapshotPayload = pickComparablePayload({
|
||||
templateInput: computedSnapshot.templateInput,
|
||||
topicInputs: payload.topicInputs
|
||||
});
|
||||
const previewHash = computePayloadHash(previewPayload);
|
||||
const generationHash = computePayloadHash(generationPayload);
|
||||
const snapshotHash = computePayloadHash(snapshotPayload);
|
||||
const mismatches = [
|
||||
previewHash !== generationHash ? 'preview:generation' : null,
|
||||
generationHash !== snapshotHash ? 'generation:snapshot' : null,
|
||||
previewHash !== snapshotHash ? 'preview:snapshot' : null
|
||||
].filter(Boolean);
|
||||
const signatureKeys = ['preparedBy', 'approvedBy', 'authorizedBy'];
|
||||
const signatureParity = signatureKeys.every((key) => {
|
||||
const signatures = payload.documentData.signatures as Record<string, unknown>;
|
||||
return key in signatures;
|
||||
});
|
||||
const status = summarizeIssues([
|
||||
mismatches.length ? 'FAIL' : 'PASS',
|
||||
signatureParity ? 'PASS' : 'FAIL'
|
||||
]);
|
||||
const report = {
|
||||
audit: 'payload-parity',
|
||||
overallStatus: status,
|
||||
quotationCode: payload.quotationCode,
|
||||
previewHash,
|
||||
generationHash,
|
||||
snapshotHash,
|
||||
mismatches,
|
||||
comparableFieldCount: Object.keys(payload.templateInput).length,
|
||||
topicInputKeyCount: Object.keys(payload.topicInputs).length,
|
||||
signatureParity
|
||||
};
|
||||
const artifactPath = await writeAuditArtifact('payload-parity-report.json', report);
|
||||
|
||||
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[audit-payload-parity] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
125
scripts/audit-pdf-mapping-coverage.ts
Normal file
125
scripts/audit-pdf-mapping-coverage.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
createSqlClient,
|
||||
extractTemplateFields,
|
||||
getActiveTemplateVersions,
|
||||
getEffectiveMappedKeys,
|
||||
getMappingsForVersion,
|
||||
summarizeIssues
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
function classifyToken(token: string, mappedKeys: Set<string>) {
|
||||
if (token === 'topic' || token === 'data_topic') {
|
||||
return 'dynamic_topic_field';
|
||||
}
|
||||
|
||||
if (token === 'item_topic') {
|
||||
return 'dynamic_topic_field';
|
||||
}
|
||||
|
||||
if (token.startsWith('topic_') || token.startsWith('item_topic_')) {
|
||||
return 'dynamic_runtime_field';
|
||||
}
|
||||
|
||||
if (mappedKeys.has(token)) {
|
||||
return 'mapped_field';
|
||||
}
|
||||
|
||||
return 'unmapped_field';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const templates = await getActiveTemplateVersions(sql);
|
||||
const results = [];
|
||||
|
||||
for (const template of templates) {
|
||||
const fields = extractTemplateFields(template.schemaJson);
|
||||
const { mappings, columns } = await getMappingsForVersion(sql, template.versionId);
|
||||
const mappedKeys = getEffectiveMappedKeys(fields, mappings);
|
||||
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
|
||||
const schemaTokens = [...new Set(fields.flatMap((field) => field.tokens))].sort();
|
||||
const coverage = schemaTokens.map((token) => {
|
||||
const mapping =
|
||||
mappings.find((candidate) => candidate.placeholderKey === token) ??
|
||||
mappings.find(
|
||||
(candidate) =>
|
||||
fields.some(
|
||||
(field) =>
|
||||
field.name === candidate.placeholderKey &&
|
||||
field.tokens.length === 1 &&
|
||||
field.tokens[0] === token
|
||||
)
|
||||
) ??
|
||||
null;
|
||||
return {
|
||||
token,
|
||||
classification: classifyToken(token, mappedKeys),
|
||||
sourcePath: mapping?.sourcePath ?? null,
|
||||
dataType: mapping?.dataType ?? null
|
||||
};
|
||||
});
|
||||
const unmappedFields = coverage.filter((item) => item.classification === 'unmapped_field');
|
||||
const orphanMappings = mappings
|
||||
.filter(
|
||||
(mapping) =>
|
||||
!schemaTokens.includes(mapping.placeholderKey) &&
|
||||
!fieldNames.has(mapping.placeholderKey) &&
|
||||
mapping.placeholderKey !== 'items_table'
|
||||
)
|
||||
.map((mapping) => ({
|
||||
placeholderKey: mapping.placeholderKey,
|
||||
sourcePath: mapping.sourcePath
|
||||
}));
|
||||
const tableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null;
|
||||
const tableColumns = tableMapping
|
||||
? columns
|
||||
.filter((column) => column.mappingId === tableMapping.id)
|
||||
.map((column) => ({
|
||||
columnName: column.columnName,
|
||||
sourceField: column.sourceField
|
||||
}))
|
||||
: [];
|
||||
const status = summarizeIssues([
|
||||
unmappedFields.length ? 'FAIL' : 'PASS',
|
||||
orphanMappings.length ? 'WARNING' : 'PASS'
|
||||
]);
|
||||
|
||||
results.push({
|
||||
templateName: template.templateName,
|
||||
version: template.version,
|
||||
organizationId: template.organizationId,
|
||||
tokenCount: schemaTokens.length,
|
||||
coverage,
|
||||
unmappedFields,
|
||||
orphanMappings,
|
||||
tableColumns,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
const overallStatus = summarizeIssues(
|
||||
results.map((result) => result.status) as Array<'PASS' | 'WARNING' | 'FAIL'>
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
audit: 'mapping-coverage',
|
||||
overallStatus,
|
||||
results
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[audit-pdf-mapping-coverage] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
153
scripts/audit-pdf-runtime-payload.ts
Normal file
153
scripts/audit-pdf-runtime-payload.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { buildAuditPayload, createSqlClient, summarizeIssues } from './pdf-audit-utils.ts';
|
||||
|
||||
type PayloadFinding = {
|
||||
field: string;
|
||||
source: 'documentData' | 'templateInput' | 'topicInputs';
|
||||
status: 'PASS' | 'WARNING' | 'FAIL';
|
||||
value: unknown;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function pushFinding(findings: PayloadFinding[], finding: PayloadFinding) {
|
||||
findings.push(finding);
|
||||
}
|
||||
|
||||
function walkValue(
|
||||
findings: PayloadFinding[],
|
||||
source: PayloadFinding['source'],
|
||||
prefix: string,
|
||||
value: unknown
|
||||
) {
|
||||
if (value === undefined) {
|
||||
pushFinding(findings, {
|
||||
field: prefix,
|
||||
source,
|
||||
status: 'FAIL',
|
||||
value,
|
||||
message: 'Value is undefined'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
pushFinding(findings, {
|
||||
field: prefix,
|
||||
source,
|
||||
status: 'WARNING',
|
||||
value,
|
||||
message: 'Value is null'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
pushFinding(findings, {
|
||||
field: prefix,
|
||||
source,
|
||||
status: value.trim() ? 'PASS' : 'WARNING',
|
||||
value,
|
||||
message: value.trim() ? 'String is populated' : 'String is empty'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (source === 'documentData' && value.some((row) => !Array.isArray(row))) {
|
||||
pushFinding(findings, {
|
||||
field: prefix,
|
||||
source,
|
||||
status: 'PASS',
|
||||
value: `rows:${value.length}`,
|
||||
message: 'Collection payload is populated'
|
||||
});
|
||||
|
||||
for (const [index, item] of value.entries()) {
|
||||
walkValue(findings, source, `${prefix}[${index}]`, item);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const validTable = value.every(
|
||||
(row) => Array.isArray(row) && row.every((cell) => typeof cell === 'string')
|
||||
);
|
||||
|
||||
pushFinding(findings, {
|
||||
field: prefix,
|
||||
source,
|
||||
status: validTable ? 'PASS' : 'FAIL',
|
||||
value: Array.isArray(value) ? `rows:${value.length}` : value,
|
||||
message: validTable ? 'Array shape is valid' : 'Array shape is invalid for PDF payload'
|
||||
});
|
||||
|
||||
for (const [index, item] of value.entries()) {
|
||||
if (Array.isArray(item)) {
|
||||
for (const [cellIndex, cell] of item.entries()) {
|
||||
if (typeof cell !== 'string') {
|
||||
pushFinding(findings, {
|
||||
field: `${prefix}[${index}][${cellIndex}]`,
|
||||
source,
|
||||
status: 'FAIL',
|
||||
value: cell,
|
||||
message: 'Table cell is not a string'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
for (const [key, childValue] of Object.entries(value)) {
|
||||
walkValue(findings, source, prefix ? `${prefix}.${key}` : key, childValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pushFinding(findings, {
|
||||
field: prefix,
|
||||
source,
|
||||
status: 'PASS',
|
||||
value,
|
||||
message: 'Scalar value is populated'
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const payload = await buildAuditPayload(sql);
|
||||
const findings: PayloadFinding[] = [];
|
||||
|
||||
walkValue(findings, 'documentData', 'documentData', payload.documentData);
|
||||
walkValue(findings, 'templateInput', 'templateInput', payload.templateInput);
|
||||
walkValue(findings, 'topicInputs', 'topicInputs', payload.topicInputs);
|
||||
|
||||
const status = summarizeIssues(findings.map((finding) => finding.status));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
audit: 'runtime-payload',
|
||||
overallStatus: status,
|
||||
quotationCode: payload.quotationCode,
|
||||
templateName: payload.templateName,
|
||||
templateVersion: payload.templateVersion,
|
||||
findingCount: findings.length,
|
||||
findings
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[audit-pdf-runtime-payload] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
89
scripts/audit-pdf-template-inventory.ts
Normal file
89
scripts/audit-pdf-template-inventory.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
createSqlClient,
|
||||
getEffectiveMappedKeys,
|
||||
extractTemplateFields,
|
||||
getActiveTemplateVersions,
|
||||
getDuplicateFieldNames,
|
||||
getMappingsForVersion,
|
||||
getUnknownFieldNames,
|
||||
summarizeIssues
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
async function main() {
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const templates = await getActiveTemplateVersions(sql);
|
||||
const results = [];
|
||||
|
||||
for (const template of templates) {
|
||||
const fields = extractTemplateFields(template.schemaJson);
|
||||
const { mappings } = await getMappingsForVersion(sql, template.versionId);
|
||||
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
|
||||
const mappedKeys = getEffectiveMappedKeys(fields, mappings);
|
||||
const duplicateFields = getDuplicateFieldNames(fields);
|
||||
const unknownFields = getUnknownFieldNames(fields, mappedKeys);
|
||||
const tableFields = fields.filter((field) => field.type === 'table').map((field) => field.name);
|
||||
const dynamicTopicFields = fields
|
||||
.filter((field) => field.name === 'topic' || field.name === 'data_topic')
|
||||
.map((field) => field.name);
|
||||
const placeholderTokens = new Set(fields.flatMap((field) => field.tokens));
|
||||
const missingMappings = [...placeholderTokens].filter(
|
||||
(token) => token !== 'item_topic' && !mappedKeys.has(token)
|
||||
);
|
||||
const orphanMappings = mappings
|
||||
.filter(
|
||||
(mapping) =>
|
||||
!placeholderTokens.has(mapping.placeholderKey) &&
|
||||
!fieldNames.has(mapping.placeholderKey) &&
|
||||
mapping.placeholderKey !== 'items_table'
|
||||
)
|
||||
.map((mapping) => mapping.placeholderKey);
|
||||
const status = summarizeIssues([
|
||||
duplicateFields.length ? 'FAIL' : 'PASS',
|
||||
missingMappings.length ? 'FAIL' : 'PASS',
|
||||
orphanMappings.length ? 'WARNING' : 'PASS',
|
||||
unknownFields.length ? 'WARNING' : 'PASS'
|
||||
]);
|
||||
|
||||
results.push({
|
||||
templateName: template.templateName,
|
||||
version: template.version,
|
||||
organizationId: template.organizationId,
|
||||
schemaFieldCount: fields.length,
|
||||
tableFields,
|
||||
dynamicTopicFields,
|
||||
duplicateFields,
|
||||
unknownFields,
|
||||
missingMappings,
|
||||
orphanMappings,
|
||||
mappedFieldCount: mappings.length,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
const overallStatus = summarizeIssues(
|
||||
results.map((result) => result.status) as Array<'PASS' | 'WARNING' | 'FAIL'>
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
audit: 'template-inventory',
|
||||
overallStatus,
|
||||
templateCount: results.length,
|
||||
results
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[audit-pdf-template-inventory] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
105
scripts/audit-placeholder-integrity.ts
Normal file
105
scripts/audit-placeholder-integrity.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
createSqlClient,
|
||||
extractTemplateFields,
|
||||
getActiveTemplateVersions,
|
||||
getEffectiveMappedKeys,
|
||||
getMappingsForVersion,
|
||||
isDesignerLabelField,
|
||||
summarizeIssues,
|
||||
writeAuditArtifact
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
const TYPO_PATTERNS = [/lable/i, /affter/i, /__+/, /\s{2,}/];
|
||||
const RESERVED_DYNAMIC_FIELD_NAMES = new Set(['topic', 'data_topic']);
|
||||
|
||||
function detectTypo(fieldName: string) {
|
||||
return TYPO_PATTERNS.some((pattern) => pattern.test(fieldName));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const templates = await getActiveTemplateVersions(sql);
|
||||
const results = [];
|
||||
|
||||
for (const template of templates) {
|
||||
const fields = extractTemplateFields(template.schemaJson);
|
||||
const { mappings } = await getMappingsForVersion(sql, template.versionId);
|
||||
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
|
||||
const mappedKeys = getEffectiveMappedKeys(fields, mappings);
|
||||
const tokens = [...new Set(fields.flatMap((field) => field.tokens))].sort();
|
||||
const duplicateFieldNames = [...fieldNames].filter(
|
||||
(fieldName) => fields.filter((field) => field.name === fieldName).length > 1
|
||||
);
|
||||
const typoFields = fields
|
||||
.filter((field) => field.name && detectTypo(field.name) && !isDesignerLabelField(field))
|
||||
.map((field) => field.name);
|
||||
const unmappedPlaceholders = tokens.filter(
|
||||
(token) =>
|
||||
token !== 'item_topic' &&
|
||||
!token.startsWith('topic_') &&
|
||||
!token.startsWith('item_topic_') &&
|
||||
!mappedKeys.has(token)
|
||||
);
|
||||
const deadMappings = mappings
|
||||
.filter(
|
||||
(mapping) =>
|
||||
!fieldNames.has(mapping.placeholderKey) &&
|
||||
!tokens.includes(mapping.placeholderKey) &&
|
||||
mapping.placeholderKey !== 'items_table'
|
||||
)
|
||||
.map((mapping) => mapping.placeholderKey);
|
||||
const invalidReservedMappings = mappings
|
||||
.filter(
|
||||
(mapping) =>
|
||||
mapping.placeholderKey.startsWith('topic_') ||
|
||||
mapping.placeholderKey.startsWith('item_topic_')
|
||||
)
|
||||
.map((mapping) => mapping.placeholderKey);
|
||||
const missingReservedFields = [...RESERVED_DYNAMIC_FIELD_NAMES].filter(
|
||||
(name) => !fieldNames.has(name)
|
||||
);
|
||||
const status = summarizeIssues([
|
||||
duplicateFieldNames.length ? 'FAIL' : 'PASS',
|
||||
typoFields.length ? 'FAIL' : 'PASS',
|
||||
unmappedPlaceholders.length ? 'FAIL' : 'PASS',
|
||||
deadMappings.length ? 'FAIL' : 'PASS',
|
||||
invalidReservedMappings.length ? 'FAIL' : 'PASS',
|
||||
missingReservedFields.length ? 'FAIL' : 'PASS'
|
||||
]);
|
||||
|
||||
results.push({
|
||||
templateName: template.templateName,
|
||||
organizationId: template.organizationId,
|
||||
version: template.version,
|
||||
duplicateFieldNames,
|
||||
typoFields,
|
||||
unmappedPlaceholders,
|
||||
deadMappings,
|
||||
invalidReservedMappings,
|
||||
missingReservedFields,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
const overallStatus = summarizeIssues(
|
||||
results.map((result) => result.status) as Array<'PASS' | 'WARNING' | 'FAIL'>
|
||||
);
|
||||
const report = {
|
||||
audit: 'placeholder-integrity',
|
||||
overallStatus,
|
||||
results
|
||||
};
|
||||
const artifactPath = await writeAuditArtifact('placeholder-integrity-report.json', report);
|
||||
|
||||
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[audit-placeholder-integrity] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
166
scripts/audit-template-version-integrity.ts
Normal file
166
scripts/audit-template-version-integrity.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
buildAuditPayload,
|
||||
createSqlClient,
|
||||
getActiveTemplateVersions,
|
||||
summarizeIssues,
|
||||
writeAuditArtifact
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
type IntegrityIssue = {
|
||||
kind:
|
||||
| 'DUPLICATE_ACTIVE_TEMPLATE'
|
||||
| 'DUPLICATE_ACTIVE_VERSION'
|
||||
| 'ACTIVE_VERSION_WITHOUT_TEMPLATE'
|
||||
| 'MAPPING_WITHOUT_VERSION'
|
||||
| 'INVALID_APPROVED_TEMPLATE_VERSION';
|
||||
message: string;
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const [activeTemplateVersions, versionRows, orphanMappingRows, payload] = await Promise.all([
|
||||
getActiveTemplateVersions(sql),
|
||||
sql`
|
||||
select
|
||||
v.id,
|
||||
v.template_id as "templateId",
|
||||
v.organization_id as "organizationId",
|
||||
v.version,
|
||||
v.is_active as "isActive",
|
||||
t.id as "resolvedTemplateId"
|
||||
from crm_document_template_versions v
|
||||
left join crm_document_templates t
|
||||
on t.id = v.template_id
|
||||
where v.deleted_at is null
|
||||
`,
|
||||
sql`
|
||||
select
|
||||
m.id,
|
||||
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
|
||||
where m.deleted_at is null
|
||||
and (v.id is null or v.deleted_at is not null)
|
||||
`,
|
||||
buildAuditPayload(sql)
|
||||
]);
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const versionBuckets = new Map<string, Array<Record<string, unknown>>>();
|
||||
|
||||
for (const row of versionRows as Array<Record<string, unknown>>) {
|
||||
if (!row.isActive) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const items = versionBuckets.get(String(row.templateId)) ?? [];
|
||||
items.push(row);
|
||||
versionBuckets.set(String(row.templateId), items);
|
||||
|
||||
if (!row.resolvedTemplateId) {
|
||||
issues.push({
|
||||
kind: 'ACTIVE_VERSION_WITHOUT_TEMPLATE',
|
||||
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
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
payload.approvedTemplateVersionId &&
|
||||
!activeTemplateVersions.some((record) => record.versionId === payload.approvedTemplateVersionId)
|
||||
) {
|
||||
issues.push({
|
||||
kind: 'INVALID_APPROVED_TEMPLATE_VERSION',
|
||||
message: 'Audit quotation approvedTemplateVersionId does not resolve to an active template version',
|
||||
context: {
|
||||
quotationCode: payload.quotationCode,
|
||||
approvedTemplateVersionId: payload.approvedTemplateVersionId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const status = summarizeIssues(issues.length ? ['FAIL'] : ['PASS']);
|
||||
const report = {
|
||||
audit: 'template-version-integrity',
|
||||
overallStatus: status,
|
||||
activeTemplateCount: activeTemplateVersions.length,
|
||||
quotationCode: payload.quotationCode,
|
||||
approvedTemplateVersionId: payload.approvedTemplateVersionId,
|
||||
issues
|
||||
};
|
||||
const artifactPath = await writeAuditArtifact('template-version-report.json', report);
|
||||
|
||||
console.log(JSON.stringify({ ...report, artifactPath }, null, 2));
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[audit-template-version-integrity] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
194
scripts/generate-pdf-audit-report.ts
Normal file
194
scripts/generate-pdf-audit-report.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AUDIT_ARTIFACTS_DIR,
|
||||
AUDIT_QUOTATION_CODE,
|
||||
REPORT_JSON_PATH,
|
||||
REPORT_MARKDOWN_PATH,
|
||||
buildAuditPayload,
|
||||
createSqlClient,
|
||||
extractTemplateFields,
|
||||
getEffectiveMappedKeys,
|
||||
getDuplicateFieldNames,
|
||||
getMappingsForVersion,
|
||||
getUnknownFieldNames,
|
||||
summarizeIssues
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
async function main() {
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const payload = await buildAuditPayload(sql);
|
||||
const fields = extractTemplateFields(payload.templateSchema);
|
||||
const { mappings, columns } = await getMappingsForVersion(sql, payload.templateVersionId);
|
||||
const fieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
|
||||
const placeholderTokens = [...new Set(fields.flatMap((field) => field.tokens))].sort();
|
||||
const mappedKeys = getEffectiveMappedKeys(fields, mappings);
|
||||
const unmappedTokens = placeholderTokens.filter((token) => {
|
||||
if (token === 'item_topic') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (token === 'topic' || token === 'data_topic') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (token.startsWith('topic_') || token.startsWith('item_topic_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !mappedKeys.has(token);
|
||||
});
|
||||
const orphanMappings = mappings
|
||||
.filter(
|
||||
(mapping) =>
|
||||
!placeholderTokens.includes(mapping.placeholderKey) &&
|
||||
!fieldNames.has(mapping.placeholderKey) &&
|
||||
mapping.placeholderKey !== 'items_table'
|
||||
)
|
||||
.map((mapping) => mapping.placeholderKey);
|
||||
const duplicateFields = getDuplicateFieldNames(fields);
|
||||
const unknownFields = getUnknownFieldNames(fields);
|
||||
const topicRows = Object.keys(payload.topicInputs).filter((key) => key.startsWith('topic_')).length;
|
||||
const topicItemRows = Object.keys(payload.topicInputs).filter((key) =>
|
||||
key.startsWith('item_topic_')
|
||||
).length;
|
||||
const itemTableMapping = mappings.find((mapping) => mapping.placeholderKey === 'items_table') ?? null;
|
||||
const itemTableColumns = itemTableMapping
|
||||
? columns.filter((column) => column.mappingId === itemTableMapping.id)
|
||||
: [];
|
||||
const snapshotTemplateInput =
|
||||
payload.approvedSnapshot &&
|
||||
typeof payload.approvedSnapshot === 'object' &&
|
||||
'templateInput' in payload.approvedSnapshot
|
||||
? (payload.approvedSnapshot.templateInput as Record<string, unknown> | null)
|
||||
: null;
|
||||
const sharedKeys = snapshotTemplateInput
|
||||
? Object.keys(payload.templateInput).filter(
|
||||
(key) =>
|
||||
!(key.startsWith('topic_') || key.startsWith('item_topic_')) &&
|
||||
key in snapshotTemplateInput
|
||||
)
|
||||
: [];
|
||||
const inconsistentKeys = sharedKeys.filter(
|
||||
(key) => JSON.stringify(payload.templateInput[key]) !== JSON.stringify(snapshotTemplateInput?.[key])
|
||||
);
|
||||
const consistencyStatus = snapshotTemplateInput
|
||||
? inconsistentKeys.length
|
||||
? 'FAIL'
|
||||
: 'PASS'
|
||||
: 'WARNING';
|
||||
const overallStatus = summarizeIssues([
|
||||
unmappedTokens.length ? 'FAIL' : 'PASS',
|
||||
orphanMappings.length ? 'WARNING' : 'PASS',
|
||||
duplicateFields.length ? 'FAIL' : 'PASS',
|
||||
unknownFields.length ? 'WARNING' : 'PASS',
|
||||
consistencyStatus
|
||||
]);
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
overallStatus,
|
||||
quotationCode: payload.quotationCode,
|
||||
expectedFixtureCode: AUDIT_QUOTATION_CODE,
|
||||
template: {
|
||||
templateName: payload.templateName,
|
||||
version: payload.templateVersion,
|
||||
versionId: payload.templateVersionId,
|
||||
fieldCount: fields.length,
|
||||
duplicateFields,
|
||||
unknownFields
|
||||
},
|
||||
metrics: {
|
||||
topicCount: payload.quotationMetrics.topicCount,
|
||||
itemCount: payload.quotationMetrics.itemCount,
|
||||
partyCount: payload.quotationMetrics.partyCount,
|
||||
dynamicTopicFieldCount: topicRows,
|
||||
dynamicTopicItemFieldCount: topicItemRows,
|
||||
mappingCount: mappings.length
|
||||
},
|
||||
mappingCoverage: {
|
||||
placeholderTokenCount: placeholderTokens.length,
|
||||
unmappedTokens,
|
||||
orphanMappings,
|
||||
itemTableColumns: itemTableColumns.map((column) => ({
|
||||
columnName: column.columnName,
|
||||
sourceField: column.sourceField
|
||||
}))
|
||||
},
|
||||
runtimePayload: {
|
||||
templateInputKeys: Object.keys(payload.templateInput).length,
|
||||
topicInputKeys: Object.keys(payload.topicInputs).length
|
||||
},
|
||||
consistency: {
|
||||
status: consistencyStatus,
|
||||
approvedSnapshotAvailable: Boolean(snapshotTemplateInput),
|
||||
approvedArtifactReference: payload.approvedArtifactReference,
|
||||
inconsistentKeys
|
||||
},
|
||||
integritySummary: {
|
||||
available: fs.existsSync(path.resolve(AUDIT_ARTIFACTS_DIR, 'summary.json')),
|
||||
artifactDir: AUDIT_ARTIFACTS_DIR
|
||||
}
|
||||
};
|
||||
|
||||
const markdown = [
|
||||
'# PDF Audit Report',
|
||||
'',
|
||||
`- Generated At: ${report.generatedAt}`,
|
||||
`- Overall Status: ${report.overallStatus}`,
|
||||
`- Quotation Code: ${report.quotationCode}`,
|
||||
`- Template: ${report.template.templateName} v${report.template.version}`,
|
||||
'',
|
||||
'## Metrics',
|
||||
'',
|
||||
`- Topic Count: ${report.metrics.topicCount}`,
|
||||
`- Item Count: ${report.metrics.itemCount}`,
|
||||
`- Project Party Count: ${report.metrics.partyCount}`,
|
||||
`- Mapping Count: ${report.metrics.mappingCount}`,
|
||||
'',
|
||||
'## Mapping Coverage',
|
||||
'',
|
||||
`- Placeholder Tokens: ${report.mappingCoverage.placeholderTokenCount}`,
|
||||
`- Unmapped Tokens: ${report.mappingCoverage.unmappedTokens.length ? report.mappingCoverage.unmappedTokens.join(', ') : 'None'}`,
|
||||
`- Orphan Mappings: ${report.mappingCoverage.orphanMappings.length ? report.mappingCoverage.orphanMappings.join(', ') : 'None'}`,
|
||||
'',
|
||||
'## Visual / Consistency',
|
||||
'',
|
||||
`- Approved Snapshot Available: ${report.consistency.approvedSnapshotAvailable ? 'Yes' : 'No'}`,
|
||||
`- Consistency Status: ${report.consistency.status}`,
|
||||
`- Inconsistent Keys: ${report.consistency.inconsistentKeys.length ? report.consistency.inconsistentKeys.join(', ') : 'None'}`,
|
||||
'',
|
||||
'## Integrity Layer',
|
||||
'',
|
||||
`- Integrity Summary Available: ${report.integritySummary.available ? 'Yes' : 'No'}`,
|
||||
`- Artifact Directory: ${report.integritySummary.artifactDir}`
|
||||
].join('\n');
|
||||
|
||||
await writeFile(REPORT_JSON_PATH, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
await writeFile(REPORT_MARKDOWN_PATH, `${markdown}\n`, 'utf8');
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
audit: 'pdf-report',
|
||||
overallStatus,
|
||||
jsonPath: REPORT_JSON_PATH,
|
||||
markdownPath: REPORT_MARKDOWN_PATH,
|
||||
report
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[generate-pdf-audit-report] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
115
scripts/generate-pdf-integrity-report.ts
Normal file
115
scripts/generate-pdf-integrity-report.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AUDIT_ARTIFACTS_DIR,
|
||||
buildAuditPayload,
|
||||
createSqlClient,
|
||||
summarizeIssues,
|
||||
writeAuditArtifact
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
async function readReport<T>(fileName: string): Promise<T | null> {
|
||||
const filePath = path.resolve(AUDIT_ARTIFACTS_DIR, fileName);
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sql = createSqlClient();
|
||||
|
||||
try {
|
||||
const payload = await buildAuditPayload(sql);
|
||||
const templateVersionReport = await readReport<Record<string, unknown>>(
|
||||
'template-version-report.json'
|
||||
);
|
||||
const placeholderReport = await readReport<Record<string, unknown>>(
|
||||
'placeholder-integrity-report.json'
|
||||
);
|
||||
const payloadReport = await readReport<Record<string, unknown>>('payload-parity-report.json');
|
||||
const snapshotReport = await readReport<Record<string, unknown>>('signature-audit-report.json');
|
||||
const statuses = [
|
||||
String(templateVersionReport?.overallStatus ?? 'WARNING'),
|
||||
String(placeholderReport?.overallStatus ?? 'WARNING'),
|
||||
String(payloadReport?.overallStatus ?? 'WARNING'),
|
||||
String(snapshotReport?.overallStatus ?? 'WARNING')
|
||||
] as Array<'PASS' | 'WARNING' | 'FAIL'>;
|
||||
const overallStatus = summarizeIssues(statuses);
|
||||
const topicRuntimeReport = {
|
||||
audit: 'topic-runtime',
|
||||
overallStatus: Object.keys(payload.topicInputs).length > 0 ? 'PASS' : 'FAIL',
|
||||
quotationCode: payload.quotationCode,
|
||||
topicCount: payload.quotationMetrics.topicCount,
|
||||
topicInputKeys: Object.keys(payload.topicInputs)
|
||||
.filter((key) => key.startsWith('topic_'))
|
||||
.sort(),
|
||||
itemTopicInputKeys: Object.keys(payload.topicInputs)
|
||||
.filter((key) => key.startsWith('item_topic_'))
|
||||
.sort(),
|
||||
duplicateKeys: Object.keys(payload.topicInputs).filter(
|
||||
(key, index, array) => array.indexOf(key) !== index
|
||||
),
|
||||
emptySections: Object.entries(payload.topicInputs)
|
||||
.filter(([, value]) => !Array.isArray(value) || value.length === 0)
|
||||
.map(([key]) => key)
|
||||
};
|
||||
await writeAuditArtifact('topic-runtime-report.json', topicRuntimeReport);
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
overallStatus,
|
||||
quotationCode: payload.quotationCode,
|
||||
artifacts: {
|
||||
templateVersionReport: 'template-version-report.json',
|
||||
placeholderIntegrityReport: 'placeholder-integrity-report.json',
|
||||
payloadParityReport: 'payload-parity-report.json',
|
||||
topicRuntimeReport: 'topic-runtime-report.json',
|
||||
signatureAuditReport: 'signature-audit-report.json'
|
||||
},
|
||||
statuses: {
|
||||
templateVersion: templateVersionReport?.overallStatus ?? 'WARNING',
|
||||
placeholderIntegrity: placeholderReport?.overallStatus ?? 'WARNING',
|
||||
payloadParity: payloadReport?.overallStatus ?? 'WARNING',
|
||||
topicRuntime: topicRuntimeReport.overallStatus,
|
||||
approvedSnapshotParity: snapshotReport?.overallStatus ?? 'WARNING'
|
||||
}
|
||||
};
|
||||
const artifactPath = await writeAuditArtifact('summary.json', summary);
|
||||
const markdown = [
|
||||
'# PDF Integrity Summary',
|
||||
'',
|
||||
`- Generated At: ${summary.generatedAt}`,
|
||||
`- Overall Status: ${summary.overallStatus}`,
|
||||
`- Quotation Code: ${summary.quotationCode}`,
|
||||
'',
|
||||
'## Statuses',
|
||||
'',
|
||||
`- Template Version Integrity: ${summary.statuses.templateVersion}`,
|
||||
`- Placeholder Integrity: ${summary.statuses.placeholderIntegrity}`,
|
||||
`- Payload Parity: ${summary.statuses.payloadParity}`,
|
||||
`- Topic Runtime: ${summary.statuses.topicRuntime}`,
|
||||
`- Approved Snapshot Parity: ${summary.statuses.approvedSnapshotParity}`,
|
||||
'',
|
||||
'## Artifact Files',
|
||||
'',
|
||||
`- ${summary.artifacts.templateVersionReport}`,
|
||||
`- ${summary.artifacts.placeholderIntegrityReport}`,
|
||||
`- ${summary.artifacts.payloadParityReport}`,
|
||||
`- ${summary.artifacts.topicRuntimeReport}`,
|
||||
`- ${summary.artifacts.signatureAuditReport}`
|
||||
].join('\n');
|
||||
const markdownPath = path.resolve(AUDIT_ARTIFACTS_DIR, 'summary.md');
|
||||
await writeFile(markdownPath, `${markdown}\n`, 'utf8');
|
||||
|
||||
console.log(JSON.stringify({ ...summary, artifactPath, markdownPath }, null, 2));
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[generate-pdf-integrity-report] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
1089
scripts/pdf-audit-utils.ts
Normal file
1089
scripts/pdf-audit-utils.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,10 @@ const TASK_H1_PASSWORD = 'TaskH1Pass!2026';
|
||||
const FIXTURE_CODES = {
|
||||
customer: 'CUS-TASK-H1',
|
||||
approvedQuotation: 'QT-TASK-H1-APPROVED',
|
||||
draftQuotation: 'QT-TASK-H1-DRAFT'
|
||||
draftQuotation: 'QT-TASK-H1-DRAFT',
|
||||
auditQuotation: 'QT-H5-AUDIT',
|
||||
billingCustomer: 'CUS-TASK-H5-BILL',
|
||||
consultantCustomer: 'CUS-TASK-H5-CONSULT'
|
||||
};
|
||||
|
||||
const FIXTURE_USERS = {
|
||||
@@ -373,6 +376,88 @@ async function ensureCustomerAndContact(sql, organization, creatorId, optionIds)
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureRelatedCustomer(sql, organization, creatorId, optionIds, definition) {
|
||||
const existing =
|
||||
(
|
||||
await sql`
|
||||
select id
|
||||
from crm_customers
|
||||
where organization_id = ${organization.id}
|
||||
and code = ${definition.code}
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
const customerId = existing?.id ?? crypto.randomUUID();
|
||||
|
||||
if (existing) {
|
||||
await sql`
|
||||
update crm_customers
|
||||
set
|
||||
branch_id = ${optionIds.branchId},
|
||||
name = ${definition.name},
|
||||
abbr = ${definition.abbr},
|
||||
tax_id = ${definition.taxId},
|
||||
customer_type = ${optionIds.customerTypeCompanyId},
|
||||
customer_status = ${optionIds.customerStatusActiveId},
|
||||
address = ${definition.address},
|
||||
phone = ${definition.phone},
|
||||
email = ${definition.email},
|
||||
lead_channel = ${optionIds.leadChannelSalesId},
|
||||
customer_group = ${optionIds.customerGroupStandardId},
|
||||
notes = ${definition.notes},
|
||||
is_active = true,
|
||||
deleted_at = null,
|
||||
updated_at = now(),
|
||||
updated_by = ${creatorId}
|
||||
where id = ${customerId}
|
||||
`;
|
||||
} else {
|
||||
await sql`
|
||||
insert into crm_customers (
|
||||
id,
|
||||
organization_id,
|
||||
branch_id,
|
||||
code,
|
||||
name,
|
||||
abbr,
|
||||
tax_id,
|
||||
customer_type,
|
||||
customer_status,
|
||||
address,
|
||||
phone,
|
||||
email,
|
||||
lead_channel,
|
||||
customer_group,
|
||||
notes,
|
||||
is_active,
|
||||
created_by,
|
||||
updated_by
|
||||
) values (
|
||||
${customerId},
|
||||
${organization.id},
|
||||
${optionIds.branchId},
|
||||
${definition.code},
|
||||
${definition.name},
|
||||
${definition.abbr},
|
||||
${definition.taxId},
|
||||
${optionIds.customerTypeCompanyId},
|
||||
${optionIds.customerStatusActiveId},
|
||||
${definition.address},
|
||||
${definition.phone},
|
||||
${definition.email},
|
||||
${optionIds.leadChannelSalesId},
|
||||
${optionIds.customerGroupStandardId},
|
||||
${definition.notes},
|
||||
true,
|
||||
${creatorId},
|
||||
${creatorId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
return customerId;
|
||||
}
|
||||
|
||||
async function upsertQuotation(sql, args) {
|
||||
const {
|
||||
organization,
|
||||
@@ -382,7 +467,9 @@ async function upsertQuotation(sql, args) {
|
||||
customerId,
|
||||
contactId,
|
||||
code,
|
||||
approved
|
||||
approved,
|
||||
fixtureProfile = 'h1',
|
||||
projectParties = []
|
||||
} = args;
|
||||
const existing =
|
||||
(
|
||||
@@ -397,9 +484,24 @@ async function upsertQuotation(sql, args) {
|
||||
const quotationId = existing?.id ?? crypto.randomUUID();
|
||||
const statusId = approved ? optionIds.quotationStatusApprovedId : optionIds.quotationStatusDraftId;
|
||||
const approvalNow = approved ? new Date().toISOString() : null;
|
||||
const notes = approved
|
||||
? 'Approved quotation fixture for Task H.1 PDF stabilization.'
|
||||
: 'Draft quotation fixture for Task H.1 negative API validation.';
|
||||
const notes =
|
||||
fixtureProfile === 'h5'
|
||||
? 'Approved quotation fixture for Task H.5 PDF mapping and parity audit.'
|
||||
: approved
|
||||
? 'Approved quotation fixture for Task H.1 PDF stabilization.'
|
||||
: 'Draft quotation fixture for Task H.1 negative API validation.';
|
||||
const projectName =
|
||||
fixtureProfile === 'h5'
|
||||
? 'Task H.5 Audit Fixture'
|
||||
: approved
|
||||
? 'Task H.1 Approved PDF Fixture'
|
||||
: 'Task H.1 Draft PDF Fixture';
|
||||
const reference =
|
||||
fixtureProfile === 'h5'
|
||||
? 'TASK-H5-AUDIT'
|
||||
: approved
|
||||
? 'TASK-H1-APPROVED'
|
||||
: 'TASK-H1-DRAFT';
|
||||
|
||||
if (existing) {
|
||||
await sql`
|
||||
@@ -412,10 +514,10 @@ async function upsertQuotation(sql, args) {
|
||||
quotation_date = ${'2026-06-16T00:00:00.000Z'},
|
||||
valid_until = ${'2026-07-16T00:00:00.000Z'},
|
||||
quotation_type = ${optionIds.quotationTypeServiceId},
|
||||
project_name = ${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'},
|
||||
project_name = ${projectName},
|
||||
project_location = 'Bangkok HQ',
|
||||
attention = 'Task H.1 Contact',
|
||||
reference = ${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
|
||||
reference = ${reference},
|
||||
notes = ${notes},
|
||||
status = ${statusId},
|
||||
revision = 0,
|
||||
@@ -507,10 +609,10 @@ async function upsertQuotation(sql, args) {
|
||||
${'2026-06-16T00:00:00.000Z'},
|
||||
${'2026-07-16T00:00:00.000Z'},
|
||||
${optionIds.quotationTypeServiceId},
|
||||
${approved ? 'Task H.1 Approved PDF Fixture' : 'Task H.1 Draft PDF Fixture'},
|
||||
${projectName},
|
||||
'Bangkok HQ',
|
||||
'Task H.1 Contact',
|
||||
${approved ? 'TASK-H1-APPROVED' : 'TASK-H1-DRAFT'},
|
||||
${reference},
|
||||
${notes},
|
||||
${statusId},
|
||||
0,
|
||||
@@ -572,77 +674,170 @@ async function upsertQuotation(sql, args) {
|
||||
await sql`delete from crm_quotation_topics where quotation_id = ${quotationId}`;
|
||||
await sql`delete from crm_quotation_items where quotation_id = ${quotationId}`;
|
||||
|
||||
await sql`
|
||||
insert into crm_quotation_items (
|
||||
id,
|
||||
organization_id,
|
||||
quotation_id,
|
||||
item_number,
|
||||
product_type,
|
||||
description,
|
||||
quantity,
|
||||
unit,
|
||||
unit_price,
|
||||
discount,
|
||||
discount_type,
|
||||
tax_rate,
|
||||
total_price,
|
||||
notes,
|
||||
sort_order
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${quotationId},
|
||||
1,
|
||||
${optionIds.productTypeCraneId},
|
||||
'1 set overhead crane with installation and commissioning',
|
||||
1,
|
||||
${optionIds.unitSetId},
|
||||
125000,
|
||||
5000,
|
||||
${optionIds.discountPercentageId},
|
||||
7,
|
||||
120000,
|
||||
'Smoke-test commercial line',
|
||||
1
|
||||
)
|
||||
`;
|
||||
const items =
|
||||
fixtureProfile === 'h5'
|
||||
? [
|
||||
{
|
||||
itemNumber: 1,
|
||||
description: '1 set overhead crane with installation and commissioning',
|
||||
quantity: 1,
|
||||
unitPrice: 125000,
|
||||
discount: 5000,
|
||||
totalPrice: 120000,
|
||||
notes: 'Primary lifting package'
|
||||
},
|
||||
{
|
||||
itemNumber: 2,
|
||||
description: 'Load testing and safety certification',
|
||||
quantity: 1,
|
||||
unitPrice: 25000,
|
||||
discount: 0,
|
||||
totalPrice: 25000,
|
||||
notes: 'Audit fixture support line'
|
||||
},
|
||||
{
|
||||
itemNumber: 3,
|
||||
description: 'Operator training and turnover documentation',
|
||||
quantity: 2,
|
||||
unitPrice: 8000,
|
||||
discount: 0,
|
||||
totalPrice: 16000,
|
||||
notes: 'Audit fixture service line'
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
itemNumber: 1,
|
||||
description: '1 set overhead crane with installation and commissioning',
|
||||
quantity: 1,
|
||||
unitPrice: 125000,
|
||||
discount: 5000,
|
||||
totalPrice: 120000,
|
||||
notes: 'Smoke-test commercial line'
|
||||
}
|
||||
];
|
||||
|
||||
await sql`
|
||||
insert into crm_quotation_customers (
|
||||
id,
|
||||
organization_id,
|
||||
quotation_id,
|
||||
customer_id,
|
||||
role,
|
||||
is_primary
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${quotationId},
|
||||
${customerId},
|
||||
${optionIds.customerRoleOwnerId},
|
||||
true
|
||||
)
|
||||
`;
|
||||
for (const [index, item] of items.entries()) {
|
||||
await sql`
|
||||
insert into crm_quotation_items (
|
||||
id,
|
||||
organization_id,
|
||||
quotation_id,
|
||||
item_number,
|
||||
product_type,
|
||||
description,
|
||||
quantity,
|
||||
unit,
|
||||
unit_price,
|
||||
discount,
|
||||
discount_type,
|
||||
tax_rate,
|
||||
total_price,
|
||||
notes,
|
||||
sort_order
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${quotationId},
|
||||
${item.itemNumber},
|
||||
${optionIds.productTypeCraneId},
|
||||
${item.description},
|
||||
${item.quantity},
|
||||
${optionIds.unitSetId},
|
||||
${item.unitPrice},
|
||||
${item.discount},
|
||||
${optionIds.discountPercentageId},
|
||||
7,
|
||||
${item.totalPrice},
|
||||
${item.notes},
|
||||
${index + 1}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const topics = [
|
||||
{
|
||||
typeId: optionIds.topicTypeScopeId,
|
||||
title: 'Scope of work',
|
||||
items: ['Supply crane equipment', 'Install and test the system']
|
||||
},
|
||||
{
|
||||
typeId: optionIds.topicTypeExclusionId,
|
||||
title: 'Exclusions',
|
||||
items: ['Civil work by customer', 'Power feeder by customer']
|
||||
},
|
||||
{
|
||||
typeId: optionIds.topicTypePaymentId,
|
||||
title: 'Payment terms',
|
||||
items: ['50% upon PO', '40% before delivery', '10% after commissioning']
|
||||
}
|
||||
];
|
||||
const resolvedProjectParties =
|
||||
projectParties.length > 0
|
||||
? projectParties
|
||||
: [
|
||||
{
|
||||
customerId,
|
||||
role: optionIds.customerRoleCustomerId,
|
||||
isPrimary: true
|
||||
}
|
||||
];
|
||||
|
||||
for (const [index, party] of resolvedProjectParties.entries()) {
|
||||
await sql`
|
||||
insert into crm_quotation_customers (
|
||||
id,
|
||||
organization_id,
|
||||
quotation_id,
|
||||
customer_id,
|
||||
role,
|
||||
is_primary,
|
||||
remark
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${quotationId},
|
||||
${party.customerId},
|
||||
${party.role},
|
||||
${party.isPrimary},
|
||||
${party.remark ?? `Fixture party ${index + 1}`}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const topics =
|
||||
fixtureProfile === 'h5'
|
||||
? [
|
||||
{
|
||||
typeId: optionIds.topicTypeScopeId,
|
||||
title: 'Scope',
|
||||
items: [
|
||||
'Supply crane equipment',
|
||||
'Install and test the system',
|
||||
'Perform SAT with project stakeholders'
|
||||
]
|
||||
},
|
||||
{
|
||||
typeId: optionIds.topicTypeExclusionId,
|
||||
title: 'Exclusion',
|
||||
items: ['Civil work by customer', 'Power feeder by customer']
|
||||
},
|
||||
{
|
||||
typeId: optionIds.topicTypePaymentId,
|
||||
title: 'Payment',
|
||||
items: ['50% upon PO', '40% before delivery', '10% after commissioning']
|
||||
},
|
||||
{
|
||||
typeId: 'warranty',
|
||||
title: 'Warranty',
|
||||
items: ['12 months after commissioning', 'Consumables excluded']
|
||||
},
|
||||
{
|
||||
typeId: 'delivery',
|
||||
title: 'Delivery',
|
||||
items: ['Within 60 days after approved drawing', 'Subject to final site readiness']
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
typeId: optionIds.topicTypeScopeId,
|
||||
title: 'Scope of work',
|
||||
items: ['Supply crane equipment', 'Install and test the system']
|
||||
},
|
||||
{
|
||||
typeId: optionIds.topicTypeExclusionId,
|
||||
title: 'Exclusions',
|
||||
items: ['Civil work by customer', 'Power feeder by customer']
|
||||
},
|
||||
{
|
||||
typeId: optionIds.topicTypePaymentId,
|
||||
title: 'Payment terms',
|
||||
items: ['50% upon PO', '40% before delivery', '10% after commissioning']
|
||||
}
|
||||
];
|
||||
|
||||
for (const [topicIndex, topic] of topics.entries()) {
|
||||
const topicId = crypto.randomUUID();
|
||||
@@ -851,11 +1046,23 @@ async function main() {
|
||||
'crm_product_type',
|
||||
'crane'
|
||||
),
|
||||
customerRoleOwnerId: await resolveOptionId(
|
||||
customerRoleCustomerId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_quotation_customer_role',
|
||||
'owner'
|
||||
'crm_project_party_role',
|
||||
'customer'
|
||||
),
|
||||
customerRoleBillingCustomerId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_project_party_role',
|
||||
'billing_customer'
|
||||
),
|
||||
customerRoleConsultantId: await resolveOptionId(
|
||||
sql,
|
||||
organization.id,
|
||||
'crm_project_party_role',
|
||||
'consultant'
|
||||
),
|
||||
topicTypeScopeId: await resolveOptionId(
|
||||
sql,
|
||||
@@ -884,6 +1091,32 @@ async function main() {
|
||||
creatorId,
|
||||
optionIds
|
||||
);
|
||||
const billingCustomerId = await ensureRelatedCustomer(sql, organization, creatorId, optionIds, {
|
||||
code: FIXTURE_CODES.billingCustomer,
|
||||
name: 'Task H.5 Billing Customer',
|
||||
abbr: 'TASKH5BILL',
|
||||
taxId: '0105559002233',
|
||||
address: '88 Audit Park Road, Bangkok 10240',
|
||||
phone: '02-000-2000',
|
||||
email: 'billing-customer@local.test',
|
||||
notes: 'Billing customer for Task H.5 parity fixture'
|
||||
});
|
||||
const consultantCustomerId = await ensureRelatedCustomer(
|
||||
sql,
|
||||
organization,
|
||||
creatorId,
|
||||
optionIds,
|
||||
{
|
||||
code: FIXTURE_CODES.consultantCustomer,
|
||||
name: 'Task H.5 Consultant',
|
||||
abbr: 'TASKH5CONS',
|
||||
taxId: '0105559003344',
|
||||
address: '77 Review Lane, Bangkok 10110',
|
||||
phone: '02-000-3000',
|
||||
email: 'consultant-customer@local.test',
|
||||
notes: 'Consultant customer for Task H.5 parity fixture'
|
||||
}
|
||||
);
|
||||
|
||||
const users = {
|
||||
admin: await ensureUser(sql, organization, creatorId, FIXTURE_USERS.admin, 'Task H.1 Admin', {
|
||||
@@ -930,6 +1163,37 @@ async function main() {
|
||||
code: FIXTURE_CODES.draftQuotation,
|
||||
approved: false
|
||||
});
|
||||
const auditQuotationId = await upsertQuotation(sql, {
|
||||
organization,
|
||||
creatorId: users.admin.id,
|
||||
workflow,
|
||||
optionIds,
|
||||
customerId,
|
||||
contactId,
|
||||
code: FIXTURE_CODES.auditQuotation,
|
||||
approved: true,
|
||||
fixtureProfile: 'h5',
|
||||
projectParties: [
|
||||
{
|
||||
customerId,
|
||||
role: optionIds.customerRoleCustomerId,
|
||||
isPrimary: true,
|
||||
remark: 'End customer'
|
||||
},
|
||||
{
|
||||
customerId: billingCustomerId,
|
||||
role: optionIds.customerRoleBillingCustomerId,
|
||||
isPrimary: false,
|
||||
remark: 'Billing customer'
|
||||
},
|
||||
{
|
||||
customerId: consultantCustomerId,
|
||||
role: optionIds.customerRoleConsultantId,
|
||||
isPrimary: false,
|
||||
remark: 'Consultant'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
@@ -939,6 +1203,8 @@ async function main() {
|
||||
approvedQuotationCode: FIXTURE_CODES.approvedQuotation,
|
||||
draftQuotationId,
|
||||
draftQuotationCode: FIXTURE_CODES.draftQuotation,
|
||||
auditQuotationId,
|
||||
auditQuotationCode: FIXTURE_CODES.auditQuotation,
|
||||
users,
|
||||
password: TASK_H1_PASSWORD
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user