673 lines
30 KiB
JavaScript
673 lines
30 KiB
JavaScript
#!/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 <state.json> --out <new-directory>\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;
|
|
});
|
|
}
|