#!/usr/bin/env node import { readFile, mkdir, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { validateStandardOperation } from '../LianSyn-platform/external-agent-client.mjs'; const require = createRequire(import.meta.url); const plans = require('../chrome-extension/ltjt-order-assistant/operation-plans.js'); const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '..'); const TEST_MARKER = 'TEST-202609'; const EXPORT_TYPES = [ 'xingyou-confirm', 'liantai-confirm', 'job-order', 'visitor-list', 'guide-confirm', 'hotel-preorder', 'transport-preorder', 'filing-current', 'filing-history', 'pickup-sign' ]; const ARRANGEMENT_ACTIONS = [ 'arrangement_guide', 'arrangement_vehicle', 'arrangement_hotel', 'arrangement_transport', 'arrangement_other' ]; const CREATED_REF_KEYS = [ 'identifier', 'kind', 'order_no', 'group_no', 'plan_no', 'parent_group_no', 'parent_plan_no', 'child_order_no', 'ddid', 'tid', 'child_did', 'ltjt_ddid', 'ltjt_tdid', 'ltjt_child_id', 'marker' ]; const OPERATION_REF_KEYS = [ 'kind', 'identifier', 'order_no', 'group_no', 'plan_no', 'parent_group_no', 'parent_plan_no', 'child_order_no', 'ltjt_ddid', 'ltjt_tdid', 'ltjt_child_id', 'ddid', 'tid', 'parent_tid', 'child_did', 'visitor_name', 'departure_date', 'expected_passenger_count', 'arrangement_history', 'owner_account', 'marker' ]; export async function createFreshOutputDirectory(outPath) { await mkdir(dirname(outPath), { recursive: true }); await mkdir(outPath, { recursive: false }); } function object(value) { return value && typeof value === 'object' && !Array.isArray(value); } function text(value) { return typeof value === 'string' ? value.trim() : ''; } function clone(value) { return structuredClone(value); } function unique(values) { return [...new Set(values)]; } function dateInSeptember(value) { if (!/^2026-09-\d{2}$/.test(text(value))) return false; const parsed = new Date(`${value}T00:00:00Z`); return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; } function identifier(refs = {}) { return text(refs.identifier || refs.order_no || refs.group_no || refs.child_order_no || refs.parent_group_no || refs.parent_plan_no || refs.plan_no || refs.ddid || refs.tid); } function createdRef(refs = {}) { return Object.fromEntries(CREATED_REF_KEYS .filter((key) => refs[key] !== undefined && refs[key] !== null && String(refs[key]).trim() !== '') .map((key) => [key, String(refs[key]).trim()])); } function operationRef(refs = {}, additions = {}) { const source = { ...refs, ...additions }; return Object.fromEntries(OPERATION_REF_KEYS .filter((key) => source[key] !== undefined && source[key] !== null && source[key] !== '') .map((key) => [key, typeof source[key] === 'string' ? source[key].trim() : clone(source[key])])); } function refKey(refs = {}) { return [text(refs.kind), text(refs.tid || refs.ltjt_tdid), text(refs.ddid || refs.child_did || refs.ltjt_ddid || refs.ltjt_child_id), identifier(refs)].join('|'); } function allKnownObjects(state) { const core = Object.values(state.objects || {}).filter(object); const cleanup = Array.isArray(state.cleanup?.objects) ? state.cleanup.objects.filter(object) : []; const byKey = new Map(); for (const refs of [...core, ...cleanup]) byKey.set(refKey(refs), clone(refs)); return [...byKey.values()]; } function lifecycleContext(state, { live = state.run?.allow_live_write === true, allowlist = false } = {}) { const run = state.run || {}; const result = { run_id: text(run.run_id), marker: TEST_MARKER, account: text(run.account), native_baseline_id: text(run.native_baseline_id), allow_live_write: live, live_window: '2026-09', target_dates: unique((run.target_dates || []).map(text).filter(Boolean)), created_refs: allKnownObjects(state).map(createdRef), phase: text(run.phase || 'plugin_replay'), operator: text(run.operator || run.account) }; if (allowlist) result.allowlist_id = text(state.cleanup?.allowlist_id); return result; } function sourceFor(state, { live, allowlist = false } = {}) { return { test_context: lifecycleContext(state, { live, allowlist }) }; } function operation(state, action, data, options = {}) { return { action, order_nature: 'formal', submit_mode: 'dry_run', source: sourceFor(state, options), data }; } function creationSource(state, id, options = {}) { const context = { run_id: text(state.run?.run_id), marker: TEST_MARKER, account: text(state.run?.account), native_baseline_id: text(state.run?.native_baseline_id), live_window: '2026-09', target_dates: unique((options.targetDates || state.run?.target_dates || []).map(text).filter(Boolean)), phase: text(options.phase || `creation_replay:${id}`), operator: text(state.run?.operator || state.run?.account) }; if (options.phase === 'shared_batch_split_order_probe') { context.phase = 'shared_batch_split_order_probe'; } return { test_context: context }; } function creationOperation(state, action, id, data, options = {}) { return { action, order_nature: 'formal', submit_mode: 'dry_run', source: creationSource(state, id, options), data }; } function commonCreateData(route, dates, suffix, { batch = false } = {}) { const data = { customer: clone(route.customer), product: clone(route.product), departure_dates: [...dates], passenger_counts: { adult: 15, leader: 1, expected_total: 16 }, room_counts: { TWN: 8 }, order_number: { suffix }, special_requests: `${TEST_MARKER} ${text(route.remark || '')}`.trim() }; if (batch) { data.recurrence = { start_date: dates[0], end_date: dates[dates.length - 1], pattern: 'specified_dates' }; } return data; } function buildCreationOperations(state) { const config = state.creation || {}; const independent = config.independent || {}; const shared = config.shared || {}; const suffix = text(config.order_suffix); const results = []; if (object(independent.product) && object(independent.customer) && dateInSeptember(independent.single_date)) { results.push({ id: 'C01-independent-single', phase: 'create', operation: creationOperation(state, 'team_order_create', 'C01', commonCreateData(independent, [independent.single_date], suffix)) }); } const independentBatchDates = (independent.batch_dates || []).map(text).filter(Boolean).sort(); if (object(independent.product) && object(independent.customer) && independentBatchDates.length >= 2 && independentBatchDates.every(dateInSeptember)) { results.push({ id: 'C02-independent-batch', phase: 'create', operation: creationOperation(state, 'team_order_batch_create', 'C02', commonCreateData(independent, independentBatchDates, suffix, { batch: true })) }); } if (object(shared.product) && dateInSeptember(shared.single_date)) { results.push({ id: 'C03-shared-single-plan', phase: 'create', operation: creationOperation(state, 'shared_plan_create', 'C03', { product: clone(shared.product), departure_dates: [shared.single_date], planned_capacity: 16, room_counts: { TWN: 8 }, groups_per_date: 1, order_number: { suffix } }) }); } const sharedBatchDates = (shared.batch_dates || []).map(text).filter(Boolean).sort(); if (object(shared.product) && sharedBatchDates.length >= 2 && sharedBatchDates.every(dateInSeptember)) { const splitOrderProbe = shared.batch_split_order_probe === true; results.push({ id: 'C04-shared-batch-plan', phase: 'create', operation: creationOperation(state, 'shared_plan_create', 'C04', { product: clone(shared.product), departure_dates: sharedBatchDates, recurrence: { start_date: sharedBatchDates[0], end_date: sharedBatchDates[sharedBatchDates.length - 1], pattern: 'specified_dates' }, planned_capacity: 16, room_counts: { TWN: 8 }, groups_per_date: 1, order_number: { suffix }, ...(splitOrderProbe ? { split_order: { customer: clone(shared.customer), passenger_counts: { adult: 15, leader: 1, expected_total: 16 } } } : {}) }, splitOrderProbe ? { phase: 'shared_batch_split_order_probe', targetDates: sharedBatchDates } : {}) }); } const parent = state.objects?.shared_plan; if (object(parent) && object(shared.customer) && dateInSeptember(parent.departure_date)) { results.push({ id: 'C05-shared-child', phase: 'create_after_parent_requery', operation: creationOperation(state, 'shared_child_order_create', 'C05', { existing_refs: { parent_group_no: identifier(parent), tid: text(parent.tid) }, departure_dates: [parent.departure_date], customer: clone(shared.customer), passenger_counts: { adult: 15, leader: 1, expected_total: 16 }, special_requests: `${TEST_MARKER} ${text(state.run?.run_id)} shared child` }) }); } return results; } function parsePassengerTsv(value) { const lines = String(value || '').trim().split(/\r?\n/).filter(Boolean); const headers = lines.shift()?.split('\t') || []; if (headers.join('\t') !== plans.PASSENGER_HEADERS.join('\t')) { throw new Error(`passenger TSV headers must be exactly: ${plans.PASSENGER_HEADERS.join('\t')}`); } return lines.map((line, rowIndex) => { const values = line.split('\t'); if (values.length !== headers.length) throw new Error(`passenger TSV row ${rowIndex + 1} has ${values.length} columns; expected ${headers.length}`); return Object.fromEntries(headers.map((header, index) => [header, header === '序号' ? Number(values[index]) : values[index]])); }); } function namedRefReady(value) { return object(value) && Boolean(text(value.name)) && value.resolved === true && Boolean(text(value.id || value.ltjt_id)); } function lifecycleRefsReady(refs, kind) { if (!object(refs) || refs.kind !== kind || refs.marker !== TEST_MARKER || !text(refs.owner_account) || !dateInSeptember(refs.departure_date) || !text(refs.tid) || !identifier(refs)) return false; if (kind !== 'shared_plan' && !text(refs.ddid || refs.child_did || refs.ltjt_ddid || refs.ltjt_child_id)) return false; return true; } function resourcesReady(resources = {}) { return namedRefReady(resources.guide) && object(resources.vehicle) && namedRefReady(resources.vehicle.supplier) && text(resources.vehicle.item) && object(resources.hotel) && namedRefReady(resources.hotel.resource) && text(resources.hotel.room_type) && object(resources.transport) && namedRefReady(resources.transport.supplier) && text(resources.transport.item) && object(resources.other) && namedRefReady(resources.other.supplier) && text(resources.other.item); } function arrangementValue(state, targetKey, action, mode = 'create') { const refs = state.objects[targetKey]; const resource = state.resources || {}; const parameters = state.arrangement_parameters?.[targetKey] || {}; const remark = `${TEST_MARKER} ${text(state.run?.run_id)} ${targetKey} ${action}`; const common = { mode, status: '未确认', remark, side_effect_policy: 'no_external' }; if (action === 'arrangement_guide') Object.assign(common, { resource: clone(resource.guide) }); if (action === 'arrangement_vehicle') Object.assign(common, { supplier: clone(resource.vehicle.supplier), item: resource.vehicle.item, start_date: refs.departure_date, end_date: refs.departure_date, quantity: Number(parameters.vehicle_quantity || 1) }); if (action === 'arrangement_hotel') Object.assign(common, { resource: clone(resource.hotel.resource), room_type: resource.hotel.room_type, start_date: refs.departure_date, end_date: parameters.hotel_end_date, room_count: Number(parameters.hotel_room_count || 8) }); if (action === 'arrangement_transport') Object.assign(common, { supplier: clone(resource.transport.supplier), item: resource.transport.item, date: refs.departure_date, quantity: Number(parameters.transport_quantity || 16) }); if (action === 'arrangement_other') Object.assign(common, { supplier: clone(resource.other.supplier), item: resource.other.item, date: refs.departure_date, quantity: Number(parameters.other_quantity || 1), filing: clone(parameters.filing) }); if (mode === 'clear') { const rowIds = state.arrangement_row_ids?.[targetKey] || {}; common.target = { slot_index: 0 }; if (action !== 'arrangement_guide') common.target.row_id = text(rowIds[action] || rowIds[action.replace(/^arrangement_/, '')]); if (action === 'arrangement_other') common.preserve_filing = true; } return common; } function allClearTargetsReady(state) { return ['independent_order', 'shared_plan'].every((targetKey) => { const rows = state.arrangement_row_ids?.[targetKey] || {}; return ['arrangement_vehicle', 'arrangement_hotel', 'arrangement_transport', 'arrangement_other'] .every((action) => Boolean(text(rows[action] || rows[action.replace(/^arrangement_/, '')]))); }); } function exportOperation(state, refs, id) { const exportRefs = operationRef(refs); return operation(state, 'confirmation_export', { existing_refs: exportRefs, export_types: EXPORT_TYPES, visitor_name: text(state.export?.visitor_name || '测试领队'), recovery: { export_only: true, never_resave: true } }, { id, live: false }); } function deletedKeys(state) { return new Set((state.cleanup?.deleted_refs || []).filter(object).map(refKey)); } function buildDeleteStage(state) { const cleanup = state.cleanup || {}; if (cleanup.export_evidence_frozen !== true || cleanup.delete_authorized !== true || state.run?.allow_live_write !== true) return []; const pending = allKnownObjects(state).filter((refs) => !deletedKeys(state).has(refKey(refs))); const children = pending.filter((refs) => refs.kind === 'shared_child_order'); const plansPending = pending.filter((refs) => refs.kind === 'shared_plan'); const independent = pending.filter((refs) => refs.kind === 'independent_order'); let selected = children; if (!selected.length && plansPending.length) { const proven = new Set((cleanup.shared_plans_with_no_children || []).map(String)); selected = plansPending.filter((refs) => proven.has(text(refs.tid))); if (selected.length !== plansPending.length) return []; } if (!selected.length && !plansPending.length) selected = independent; const allCreated = allKnownObjects(state).map(createdRef); return selected.map((refs, index) => { const guard = { marker: TEST_MARKER, allowlist_id: text(cleanup.allowlist_id), account: text(state.run?.account), run_id: text(state.run?.run_id), created_refs: allCreated, post_delete_requery: true, export_evidence_frozen: true, delete_authorized: true }; if (refs.kind === 'shared_plan') Object.assign(guard, { child_refs_deleted: true, delete_sequence: 'child_before_parent' }); return { id: `D${String(index + 1).padStart(2, '0')}-${refs.kind}-${identifier(refs)}`, phase: 'delete_cleanup', operation: operation(state, 'order_delete', { existing_refs: operationRef(refs), delete_guard: guard }, { id: `delete-${identifier(refs)}`, live: true, allowlist: true }) }; }); } export function validateReplayState(state) { const blockers = []; if (!object(state)) return ['replay state must be an object']; const run = state.run || {}; if (state.contract_version !== 'ltjt-lifecycle-replay-v1') blockers.push('contract_version must be ltjt-lifecycle-replay-v1'); if (!text(run.run_id)) blockers.push('run.run_id is required'); if (run.marker !== TEST_MARKER) blockers.push(`run.marker must be ${TEST_MARKER}`); if (!text(run.account)) blockers.push('run.account is required'); if (!text(run.native_baseline_id)) blockers.push('run.native_baseline_id is required'); if (run.live_window !== '2026-09') blockers.push('run.live_window must be 2026-09'); const targetDates = run.target_dates || []; if (!Array.isArray(targetDates) || !targetDates.length || targetDates.some((date) => !dateInSeptember(date))) blockers.push('run.target_dates must contain valid September 2026 dates'); if (run.allow_live_write !== true && run.allow_live_write !== false) blockers.push('run.allow_live_write must be boolean'); const suffix = text(state.creation?.order_suffix); if (!suffix.includes(TEST_MARKER)) blockers.push(`creation.order_suffix must contain ${TEST_MARKER}`); const shared = state.creation?.shared || {}; if (shared.batch_split_order_probe === true) { const dates = (shared.batch_dates || []).map(text).filter(Boolean); if (!object(shared.customer)) blockers.push('creation.shared.customer is required for batch_split_order_probe'); if (dates.length < 2 || dates.some((date) => !targetDates.includes(date))) { blockers.push('creation.shared.batch_dates must contain at least two run.target_dates for batch_split_order_probe'); } } for (const refs of allKnownObjects(state)) { if (!lifecycleRefsReady(refs, refs.kind)) blockers.push(`incomplete lifecycle refs: ${refKey(refs)}`); if (refs.owner_account !== run.account) blockers.push(`owner mismatch: ${refKey(refs)}`); if (!targetDates.includes(refs.departure_date)) blockers.push(`target date missing for ${refKey(refs)}`); } if (state.cleanup?.delete_authorized === true) { if (!text(state.cleanup.allowlist_id)) blockers.push('cleanup.allowlist_id is required when delete_authorized=true'); if (state.cleanup.export_evidence_frozen !== true) blockers.push('cleanup.export_evidence_frozen must be true before delete authorization'); } return unique(blockers); } export function buildReplay(state, passengerTsv) { const blockers = validateReplayState(state); const rows = parsePassengerTsv(passengerTsv); if (rows.length !== 16) blockers.push(`passenger TSV must contain 16 rows; found ${rows.length}`); const entries = buildCreationOperations(state); const independent = state.objects?.independent_order; const parent = state.objects?.shared_plan; const child = state.objects?.shared_child_order; const coreReady = lifecycleRefsReady(independent, 'independent_order') && lifecycleRefsReady(parent, 'shared_plan') && lifecycleRefsReady(child, 'shared_child_order'); if (coreReady) { entries.push({ id: 'P01-passengers-independent', phase: 'passenger_import', operation: operation(state, 'passenger_list_import', { existing_refs: operationRef(independent, { expected_passenger_count: 16 }), passenger_list: { operation: 'first_import', row_count: 16, rows: clone(rows), marker: TEST_MARKER } }, { id: 'P01' }) }); entries.push({ id: 'P02-passengers-shared-child', phase: 'passenger_import', operation: operation(state, 'passenger_list_import', { existing_refs: operationRef(child, { expected_passenger_count: 16 }), passenger_list: { operation: 'first_import', row_count: 16, rows: clone(rows), marker: TEST_MARKER } }, { id: 'P02' }) }); if (resourcesReady(state.resources)) { for (const targetKey of ['independent_order', 'shared_plan']) { for (const action of ARRANGEMENT_ACTIONS) { entries.push({ id: `A-create-${targetKey}-${action}`, phase: 'arrangement_create', operation: operation(state, action, { existing_refs: operationRef(state.objects[targetKey]), arrangement: arrangementValue(state, targetKey, action, 'create') }, { id: `create-${targetKey}-${action}` }) }); } } } entries.push({ id: 'U02-update-shared-plan-after-arrangement', phase: 'business_updates', operation: operation(state, 'order_update_shared_plan', { existing_refs: operationRef(parent), updates: { actions: [{ target: 'planned_capacity', operation: 'set', value: Number(state.updates?.shared_planned_capacity || 17) }] } }, { id: 'U02' }) }); entries.push({ id: 'U03-update-shared-child-after-arrangement', phase: 'business_updates', operation: operation(state, 'order_update_shared_child', { existing_refs: operationRef(child), updates: { actions: [{ target: 'lodging_note', operation: 'append', value: TEST_MARKER }] } }, { id: 'U03' }) }); entries.push({ id: 'E01-export-arranged-independent', phase: 'source_export_arranged', operation: exportOperation(state, independent, 'E01') }); entries.push({ id: 'E02-export-arranged-shared-child', phase: 'source_export_arranged', operation: exportOperation(state, child, 'E02') }); const receivables = state.receivables || {}; if (receivables.native_baseline_verified === true && receivables.plugin_add_verified !== true) { entries.push({ id: 'R03-receivable-plugin-add', phase: 'receivable_plugin_replay', operation: operation(state, 'order_update_shared_child', { existing_refs: operationRef(child), receivable_fixture: { operation: 'add', name: '其他费用', quantity: 1, unit_price: 0.01, currency: 'CNY', remark: `${TEST_MARKER} R03`, paid: false, settled: false, marker: TEST_MARKER } }, { id: 'R03' }) }); } else if (receivables.plugin_add_verified === true && receivables.plugin_clear_verified !== true) { const rowId = text(receivables.plugin_row_id); if (!/^[1-9]\d*$/.test(rowId)) blockers.push('receivables.plugin_row_id is required after plugin add before clear'); else entries.push({ id: 'R04-receivable-plugin-clear', phase: 'receivable_plugin_replay', operation: operation(state, 'order_update_shared_child', { existing_refs: operationRef(child), receivable_fixture: { operation: 'clear', row_id: rowId, name: '其他费用', quantity: 1, unit_price: 0.01, currency: 'CNY', remark: `${TEST_MARKER} R03`, paid: false, settled: false, marker: TEST_MARKER } }, { id: 'R04' }) }); } const generatedZeroRows = Array.isArray(receivables.independent_generated_zero_rows) ? receivables.independent_generated_zero_rows : []; if (receivables.plugin_clear_verified === true && generatedZeroRows.length && receivables.independent_generated_zero_cleared !== true) { const rowIds = generatedZeroRows.map((row) => text(row?.row_id)); const rowNames = generatedZeroRows.map((row) => text(row?.name)).sort(); if (generatedZeroRows.length !== 2 || rowIds.some((rowId) => !/^[1-9]\d*$/.test(rowId)) || new Set(rowIds).size !== rowIds.length || rowNames.join('|') !== ['单人房差', '成人团费'].sort().join('|')) { blockers.push('receivables.independent_generated_zero_rows must contain the exact adult-fee and single-room-difference rows'); } else entries.push({ id: 'R05-independent-generated-zero-receivable-clear', phase: 'receivable_prerequisite_cleanup', operation: operation(state, 'order_update_independent', { existing_refs: operationRef(independent), receivable_fixture: { operation: 'clear_generated_zero', rows: clone(generatedZeroRows), marker: TEST_MARKER } }, { id: 'R05' }) }); } if (resourcesReady(state.resources) && allClearTargetsReady(state) && state.receivables?.plugin_clear_verified === true) { for (const targetKey of ['independent_order', 'shared_plan']) { for (const action of [...ARRANGEMENT_ACTIONS].reverse()) { entries.push({ id: `A-clear-${targetKey}-${action}`, phase: 'arrangement_clear', operation: operation(state, action, { existing_refs: operationRef(state.objects[targetKey]), arrangement: arrangementValue(state, targetKey, action, 'clear') }, { id: `clear-${targetKey}-${action}` }) }); } } } if (state.gates?.arrangements_cleared === true && state.gates?.receivables_cleared === true) { const sharedChildActiveStatus = text(state.transitions?.shared_child_active_status || '预订'); if (!['预订', '已确认'].includes(sharedChildActiveStatus)) { blockers.push('transitions.shared_child_active_status must be 预订 or 已确认'); } const transitions = [ ['T01-cancel-independent', 'order_cancel', independent, { mode: 'list', from_status: '预订', to_status: '已取消' }], ['T02-restore-independent', 'order_restore', independent, { mode: 'edit', from_status: '已取消', to_status: '预订' }], ['T03-cancel-shared-child', 'order_cancel', child, { mode: 'edit', from_status: sharedChildActiveStatus, to_status: '已取消' }], ['T04-restore-shared-child', 'order_restore', child, { mode: 'edit', from_status: '已取消', to_status: sharedChildActiveStatus }], ['T05-cancel-shared-plan', 'order_cancel', parent, { mode: 'list', from_status: '收客中', to_status: '已取消' }], ['T06-restore-shared-plan', 'order_restore', parent, { mode: 'edit', from_status: '已取消', to_status: '收客中' }], ['T07-restore-child-after-parent', 'order_restore', child, { mode: 'edit', from_status: '已取消', to_status: sharedChildActiveStatus }] ]; for (const [id, action, refs, values] of transitions) { entries.push({ id, phase: 'cancel_restore', operation: operation(state, action, { existing_refs: operationRef(refs), transition: { ...values, target_kind: refs.kind, marker: TEST_MARKER, ...(action === 'order_cancel' ? { receivables_cleared: true, arrangements_cleared: true } : {}) } }, { id }) }); } } if (state.gates?.transitions_verified === true) { entries.push({ id: 'E03-export-final-independent', phase: 'source_export_final', operation: exportOperation(state, independent, 'E03') }); entries.push({ id: 'E04-export-final-shared-child', phase: 'source_export_final', operation: exportOperation(state, child, 'E04') }); } } entries.push(...buildDeleteStage(state)); return { entries, blockers: unique(blockers) }; } async function schemaValidator() { const schema = JSON.parse(await readFile(resolve(ROOT, 'schemas/standard_system_operation.schema.json'), 'utf8')); const ajv = new Ajv2020({ allErrors: true, strict: false }); addFormats(ajv); return ajv.compile(schema); } export async function validateReplayOperations(entries) { const validateSchema = await schemaValidator(); return entries.map((entry) => { const parserErrors = validateStandardOperation(entry.operation); const plan = plans.validateOperation(clone(entry.operation)); const schemaOk = validateSchema(entry.operation); const schemaErrors = schemaOk ? [] : (validateSchema.errors || []).map((item) => `${item.instancePath || '/'} ${item.message}`); return { id: entry.id, action: entry.operation.action, ok: parserErrors.length === 0 && plan.ok === true && schemaOk, parser_errors: parserErrors, planner_blockers: plan.blockers || [], schema_errors: schemaErrors, execution: plan.execution, no_erp_write: plan.noErpWrite }; }); } function parseArgs(argv) { const args = { state: '', out: '' }; for (let index = 0; index < argv.length; index += 1) { if (argv[index] === '--state') args.state = argv[++index] || ''; else if (argv[index] === '--out') args.out = argv[++index] || ''; else if (argv[index] === '--help' || argv[index] === '-h') args.help = true; else throw new Error(`unknown argument: ${argv[index]}`); } return args; } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { process.stdout.write('Usage: node tools/build_lifecycle_replay.mjs --state --out \n'); return; } if (!args.state || !args.out) throw new Error('--state and --out are required'); const statePath = resolve(args.state); const outPath = resolve(args.out); const state = JSON.parse(await readFile(statePath, 'utf8')); const passengerPath = resolve(dirname(statePath), text(state.passenger_tsv || 'synthetic-passengers.tsv')); const passengerTsv = await readFile(passengerPath, 'utf8'); const built = buildReplay(state, passengerTsv); const validation = await validateReplayOperations(built.entries); const invalid = validation.filter((item) => !item.ok); const report = { contract_version: state.contract_version, run_id: state.run?.run_id, generated_at: new Date().toISOString(), operation_count: built.entries.length, blockers: built.blockers, validation, phase_counts: Object.fromEntries([...new Set(built.entries.map((entry) => entry.phase))] .map((phase) => [phase, built.entries.filter((entry) => entry.phase === phase).length])) }; if (built.blockers.length || invalid.length) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); process.exitCode = 1; return; } await createFreshOutputDirectory(outPath); for (const [index, entry] of built.entries.entries()) { const name = `${String(index + 1).padStart(3, '0')}-${entry.id.replace(/[^A-Za-z0-9_-]+/g, '-')}.json`; await writeFile(resolve(outPath, name), `${JSON.stringify(entry.operation, null, 2)}\n`, { flag: 'wx' }); } await writeFile(resolve(outPath, 'replay-manifest.json'), `${JSON.stringify(report, null, 2)}\n`, { flag: 'wx' }); process.stdout.write(`${JSON.stringify({ status: 'replay_operations_built', out: outPath, ...report }, null, 2)}\n`); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().catch((error) => { process.stderr.write(`${error.stack || error.message || String(error)}\n`); process.exitCode = 1; }); }