Files
LWLT-AIBOT/tools/record_split_probe_result.mjs
2026-08-25 10:33:29 +08:00

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