287 lines
13 KiB
JavaScript
287 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
const TEST_MARKER = 'TEST-202609';
|
|
const FACT_OUTCOMES = new Set([
|
|
'concrete_shared_children_with_requested_facts',
|
|
'concrete_shared_children_with_fact_mismatch',
|
|
'parent_row_facts_without_concrete_child',
|
|
'requested_split_facts_not_visible_and_no_concrete_child'
|
|
]);
|
|
|
|
function object(value) {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
}
|
|
|
|
function text(value) {
|
|
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
|
}
|
|
|
|
function canonicalDate(value) {
|
|
const match = text(value).match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
|
if (!match) return '';
|
|
const candidate = `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
|
|
const parsed = new Date(`${candidate}T00:00:00.000Z`);
|
|
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === candidate ? candidate : '';
|
|
}
|
|
|
|
function unique(values) {
|
|
return [...new Set(values)];
|
|
}
|
|
|
|
function clone(value) {
|
|
return structuredClone(value);
|
|
}
|
|
|
|
function refIdentifier(ref = {}) {
|
|
return text(ref.identifier || ref.group_no || ref.child_order_no || ref.parent_group_no || ref.ddid || ref.tid);
|
|
}
|
|
|
|
function refKey(ref = {}) {
|
|
return [text(ref.kind), text(ref.tid), text(ref.ddid), refIdentifier(ref)].join('|');
|
|
}
|
|
|
|
function mergeRefs(existing = [], incoming = []) {
|
|
const result = new Map();
|
|
for (const ref of [...existing, ...incoming]) {
|
|
const key = refKey(ref);
|
|
if (!text(ref.kind) || !refIdentifier(ref) || !text(ref.tid)) throw new Error(`invalid_created_ref:${key}`);
|
|
const prior = result.get(key);
|
|
if (prior && JSON.stringify(prior) !== JSON.stringify(ref)) {
|
|
const critical = ['kind', 'identifier', 'group_no', 'child_order_no', 'parent_group_no', 'tid', 'ddid', 'departure_date', 'owner_account', 'marker'];
|
|
if (critical.some((field) => text(prior[field]) && text(ref[field]) && text(prior[field]) !== text(ref[field]))) {
|
|
throw new Error(`created_ref_conflict:${key}`);
|
|
}
|
|
}
|
|
result.set(key, { ...(prior || {}), ...clone(ref) });
|
|
}
|
|
return [...result.values()];
|
|
}
|
|
|
|
function reportFromPayload(payload) {
|
|
const root = object(payload);
|
|
const extension = object(root.extension);
|
|
if (Object.keys(extension).length) return extension;
|
|
const report = object(root.report);
|
|
return Object.keys(report).length ? report : root;
|
|
}
|
|
|
|
function assertExactSet(actual, expected, label) {
|
|
const left = unique(actual.map(text).filter(Boolean)).sort();
|
|
const right = unique(expected.map(text).filter(Boolean)).sort();
|
|
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
|
throw new Error(`${label}:expected=${right.join(',')}:actual=${left.join(',')}`);
|
|
}
|
|
}
|
|
|
|
export function recordSplitProbeResult(stateValue, allowlistValue, payloadValue, expectedRunId) {
|
|
const state = clone(stateValue);
|
|
const allowlist = clone(allowlistValue);
|
|
const report = reportFromPayload(payloadValue);
|
|
const run = object(state.run);
|
|
const shared = object(object(state.creation).shared);
|
|
const receipt = object(report.erp_receipt);
|
|
const probe = object(receipt.split_order_probe);
|
|
const verification = object(report.verification);
|
|
const verificationProbe = object(verification.split_order_probe);
|
|
const rawExpectedDates = (Array.isArray(shared.batch_dates) ? shared.batch_dates : []).map(text).filter(Boolean);
|
|
const expectedDates = rawExpectedDates.map(canonicalDate).filter(Boolean).sort();
|
|
const blockers = [];
|
|
|
|
if (!text(expectedRunId) || run.run_id !== expectedRunId) blockers.push('expected_run_id_mismatch');
|
|
if (run.marker !== TEST_MARKER || allowlist.marker !== TEST_MARKER) blockers.push('marker_mismatch');
|
|
if (!text(run.account) || allowlist.account !== run.account) blockers.push('account_mismatch');
|
|
if (allowlist.run_id !== run.run_id || allowlist.allowlist_id !== state.cleanup?.allowlist_id) blockers.push('allowlist_scope_mismatch');
|
|
if (shared.batch_split_order_probe !== true || expectedDates.length < 2) blockers.push('state_not_configured_for_split_probe');
|
|
if (expectedDates.length !== rawExpectedDates.length) blockers.push('state_batch_dates_invalid');
|
|
if (state.cleanup?.delete_authorized === true || allowlist.delete_authorized === true || allowlist.delete_enabled === true) {
|
|
blockers.push('delete_must_remain_disabled_while_recording');
|
|
}
|
|
if (report.write_attempted !== true || report.no_erp_write === true) blockers.push('probe_write_boundary_not_proven');
|
|
if (probe.status !== 'all_dates_facts_determined'
|
|
|| verificationProbe.facts_status !== 'all_dates_facts_determined') blockers.push('probe_facts_not_complete');
|
|
const perDate = Array.isArray(probe.per_date) ? probe.per_date.map(object) : [];
|
|
if (perDate.length !== expectedDates.length) blockers.push('probe_date_count_mismatch');
|
|
if (perDate.some((fact) => !canonicalDate(fact.date))) blockers.push('probe_date_invalid');
|
|
try {
|
|
assertExactSet(perDate.map((fact) => canonicalDate(fact.date)), expectedDates, 'probe_dates_mismatch');
|
|
} catch (error) {
|
|
blockers.push(error.message);
|
|
}
|
|
const groupNumbers = Array.isArray(receipt.group_numbers) ? receipt.group_numbers.map(text).filter(Boolean) : [];
|
|
try {
|
|
assertExactSet(perDate.map((fact) => fact.parent_group_no), groupNumbers, 'probe_group_numbers_mismatch');
|
|
} catch (error) {
|
|
blockers.push(error.message);
|
|
}
|
|
if (blockers.length) throw new Error(`split_probe_record_blocked:${unique(blockers).join(',')}`);
|
|
|
|
const recordedRefs = [];
|
|
const factSummary = [];
|
|
const suffix = text(state.creation?.order_suffix);
|
|
for (const fact of perDate) {
|
|
const date = canonicalDate(fact.date);
|
|
const groupNo = text(fact.parent_group_no);
|
|
const tid = text(fact.parent_tid);
|
|
const ownership = object(fact.ownership);
|
|
const childRefs = Array.isArray(fact.child_refs) ? fact.child_refs.map(object) : [];
|
|
const childCount = Number(fact.child_count);
|
|
const factBlockers = [];
|
|
if (fact.facts_determined !== true) factBlockers.push('facts_not_determined');
|
|
if (!FACT_OUTCOMES.has(text(fact.outcome))) factBlockers.push('unknown_outcome');
|
|
if (!expectedDates.includes(date)) factBlockers.push('date_out_of_scope');
|
|
if (!groupNo.includes(TEST_MARKER) || (suffix && !groupNo.includes(suffix))) factBlockers.push('group_marker_or_suffix_mismatch');
|
|
if (!/^\d+$/.test(tid)) factBlockers.push('parent_tid_invalid');
|
|
if (fact.child_reference_scan_complete !== true) factBlockers.push('child_scan_incomplete');
|
|
if (ownership.group_suffix_matched !== true || ownership.marker_matched !== true || ownership.account_matched !== true) {
|
|
factBlockers.push('ownership_evidence_mismatch');
|
|
}
|
|
if (fact.customer_requested !== true || fact.passenger_counts_requested !== true) factBlockers.push('expected_probe_facts_not_requested');
|
|
if (!Number.isInteger(childCount) || childCount < 0 || childRefs.length !== childCount) factBlockers.push('child_count_mismatch');
|
|
for (const child of childRefs) {
|
|
if (text(child.tid) !== tid || !/^\d+$/.test(text(child.ddid)) || text(child.ddid) === '0') {
|
|
factBlockers.push('child_ref_invalid');
|
|
}
|
|
}
|
|
if (factBlockers.length) throw new Error(`split_probe_fact_blocked:${date}:${unique(factBlockers).join(',')}`);
|
|
|
|
recordedRefs.push({
|
|
identifier: groupNo,
|
|
kind: 'shared_plan',
|
|
group_no: groupNo,
|
|
plan_no: groupNo,
|
|
tid,
|
|
departure_date: date,
|
|
owner_account: run.account,
|
|
marker: TEST_MARKER,
|
|
run_id: run.run_id,
|
|
creation_slot: `shared_batch:${date}`
|
|
});
|
|
for (const child of childRefs) {
|
|
const ddid = text(child.ddid);
|
|
const childOrderNo = /^D\d+$/i.test(text(child.child_order_no)) ? text(child.child_order_no).toUpperCase() : `D${ddid}`;
|
|
if (childOrderNo !== `D${ddid}`) throw new Error(`split_probe_child_number_mismatch:${date}:${childOrderNo}:${ddid}`);
|
|
recordedRefs.push({
|
|
identifier: childOrderNo,
|
|
kind: 'shared_child_order',
|
|
child_order_no: childOrderNo,
|
|
ddid,
|
|
tid,
|
|
parent_group_no: groupNo,
|
|
plan_no: groupNo,
|
|
departure_date: date,
|
|
owner_account: run.account,
|
|
marker: TEST_MARKER,
|
|
run_id: run.run_id,
|
|
creation_slot: `shared_batch_child:${date}`
|
|
});
|
|
}
|
|
factSummary.push({
|
|
date,
|
|
parent_group_no: groupNo,
|
|
parent_tid: tid,
|
|
child_count: childCount,
|
|
child_order_numbers: childRefs.map((child) => `D${text(child.ddid)}`),
|
|
outcome: text(fact.outcome),
|
|
customer_persisted_on_native_list: fact.customer_persisted_on_native_list === true,
|
|
passenger_counts_persisted_on_native_list: fact.passenger_counts_persisted_on_native_list === true
|
|
});
|
|
}
|
|
|
|
const existingStateRefs = Array.isArray(state.cleanup?.objects) ? state.cleanup.objects : [];
|
|
const legacyAllowlistRefs = Array.isArray(allowlist.created_objects) ? allowlist.created_objects : [];
|
|
const existingAllowlistRefs = Array.isArray(allowlist.created_refs) ? allowlist.created_refs : legacyAllowlistRefs;
|
|
state.cleanup.objects = mergeRefs(existingStateRefs, recordedRefs);
|
|
allowlist.created_refs = mergeRefs(existingAllowlistRefs, recordedRefs);
|
|
delete allowlist.created_objects;
|
|
allowlist.status = 'collecting_created_refs';
|
|
allowlist.order_suffix = suffix;
|
|
allowlist.native_baseline_id = text(run.native_baseline_id);
|
|
allowlist.target_dates = unique((run.target_dates || []).map(text).filter(Boolean));
|
|
allowlist.permitted_delete_kinds = ['independent_order', 'shared_plan', 'shared_child_order'];
|
|
allowlist.post_delete_requery_required = true;
|
|
allowlist.delete_enabled = false;
|
|
allowlist.delete_authorized = false;
|
|
state.creation_evidence = {
|
|
...(object(state.creation_evidence)),
|
|
shared_batch_split_order_probe: {
|
|
facts_status: probe.status,
|
|
group_numbers: groupNumbers,
|
|
fact_summary: factSummary,
|
|
manual_review_required: report.manual_review_required === true,
|
|
business_child_creation_passed: factSummary.every((fact) => (
|
|
fact.outcome === 'concrete_shared_children_with_requested_facts'
|
|
&& fact.child_count > 0
|
|
&& fact.customer_persisted_on_native_list
|
|
&& fact.passenger_counts_persisted_on_native_list
|
|
)),
|
|
source_result_sha256: createHash('sha256').update(JSON.stringify(payloadValue)).digest('hex')
|
|
}
|
|
};
|
|
return {
|
|
state,
|
|
allowlist,
|
|
recorded_refs: recordedRefs,
|
|
fact_summary: factSummary,
|
|
business_child_creation_passed: state.creation_evidence.shared_batch_split_order_probe.business_child_creation_passed
|
|
};
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = { state: '', allowlist: '', result: '', expectedRunId: '', apply: false };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const value = argv[index];
|
|
if (value === '--state') args.state = argv[++index] || '';
|
|
else if (value === '--allowlist') args.allowlist = argv[++index] || '';
|
|
else if (value === '--result') args.result = argv[++index] || '';
|
|
else if (value === '--expected-run-id') args.expectedRunId = argv[++index] || '';
|
|
else if (value === '--apply') args.apply = true;
|
|
else throw new Error(`unknown argument:${value}`);
|
|
}
|
|
for (const key of ['state', 'allowlist', 'result', 'expectedRunId']) {
|
|
if (!text(args[key])) throw new Error(`--${key === 'expectedRunId' ? 'expected-run-id' : key} is required`);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
async function writePreparedFiles(statePath, allowlistPath, result) {
|
|
const stateTemp = `${statePath}.tmp-${process.pid}`;
|
|
const allowlistTemp = `${allowlistPath}.tmp-${process.pid}`;
|
|
await writeFile(stateTemp, `${JSON.stringify(result.state, null, 2)}\n`, { flag: 'wx' });
|
|
await writeFile(allowlistTemp, `${JSON.stringify(result.allowlist, null, 2)}\n`, { flag: 'wx' });
|
|
await rename(stateTemp, statePath);
|
|
await rename(allowlistTemp, allowlistPath);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const [stateText, allowlistText, resultText] = await Promise.all([
|
|
readFile(args.state, 'utf8'),
|
|
readFile(args.allowlist, 'utf8'),
|
|
readFile(args.result, 'utf8')
|
|
]);
|
|
const result = recordSplitProbeResult(
|
|
JSON.parse(stateText),
|
|
JSON.parse(allowlistText),
|
|
JSON.parse(resultText),
|
|
args.expectedRunId
|
|
);
|
|
if (args.apply) await writePreparedFiles(resolve(args.state), resolve(args.allowlist), result);
|
|
console.log(JSON.stringify({
|
|
applied: args.apply,
|
|
recorded_ref_count: result.recorded_refs.length,
|
|
parent_count: result.recorded_refs.filter((ref) => ref.kind === 'shared_plan').length,
|
|
child_count: result.recorded_refs.filter((ref) => ref.kind === 'shared_child_order').length,
|
|
business_child_creation_passed: result.business_child_creation_passed,
|
|
fact_summary: result.fact_summary
|
|
}, null, 2));
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
await main();
|
|
}
|