Files
LWLT-AI/tools/dry_run_order_create.mjs
2026-07-13 19:57:46 +08:00

491 lines
17 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const DEFAULT_MAPPING = 'mappings/orders_add.mapping.json';
const DEFAULT_FORM_SCHEMA = 'schemas/orders_add_form_schema.json';
function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) {
args._.push(arg);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
args[key] = true;
} else {
args[key] = next;
i += 1;
}
}
return args;
}
function usage() {
return [
'Usage:',
' node tools/dry_run_order_create.mjs --input samples/team_order_create.dry-run.example.json',
'',
'Options:',
` --mapping <path> Default: ${DEFAULT_MAPPING}`,
` --form-schema <path> Default: ${DEFAULT_FORM_SCHEMA}`,
' --lookup-resolution <path> Require a passed lookup-resolution report and merge its resolved fields',
' --out <path> Write the full dry-run report as JSON',
' --payload-out <path> Write only the Act=DoInfoJH&... request body',
' --mapped-only Serialize mapped fields only instead of all known ListForm fields',
' --allow-blockers Exit 0 even when blockers are present',
' --help Show this help'
].join('\n');
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
function writeText(path, text) {
mkdirSync(dirname(resolve(path)), { recursive: true });
writeFileSync(path, text);
}
function fail(message, code = 1) {
console.error(message);
process.exit(code);
}
function getPath(obj, path) {
if (!path) return undefined;
const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
let current = obj;
for (const part of parts) {
if (part === '') continue;
if (current == null) return undefined;
current = current[part];
}
return current;
}
function isBlank(value) {
return value == null || String(value).trim() === '';
}
function toNumber(value, fallback = 0) {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
function integerString(value) {
return String(Math.max(0, Math.trunc(toNumber(value))));
}
function compactNumber(value) {
const num = toNumber(value);
if (Number.isInteger(num)) return String(num);
return String(Math.round((num + Number.EPSILON) * 100) / 100);
}
function dateYyyyMD(value) {
if (typeof value !== 'string') return value;
const match = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (!match) return value;
return `${match[1]}-${Number(match[2])}-${Number(match[3])}`;
}
function deriveTripLabel(trip = {}) {
if (!isBlank(trip.label)) return String(trip.label);
const days = Math.trunc(toNumber(trip.days));
const nights = trip.nights == null ? Math.max(0, days - 1) : Math.trunc(toNumber(trip.nights));
if (!days) return '';
return nights > 0 ? `${days}D${nights}N` : `${days}D`;
}
function deriveRoomTotal(roomCounts = {}) {
return ['SGL', 'TWN', 'TRP', 'DBL', 'HNM', 'TL']
.reduce((sum, key) => sum + Math.max(0, Math.trunc(toNumber(roomCounts[key]))), 0);
}
function passengerTotal(counts = {}) {
return ['adult', 'child_bed', 'child_no_bed', 'infant', 'leader']
.reduce((sum, key) => sum + Math.max(0, Math.trunc(toNumber(counts[key]))), 0);
}
function transformValue(value, transform) {
if (value == null) return value;
if (transform === 'date_yyyy_m_d') return dateYyyyMD(value);
if (transform === 'integer_string') return integerString(value);
if (transform === 'checkbox_yaobeian') return value === false ? undefined : '要备案';
return value;
}
function resolveMappedValue(operation, field) {
let value = getPath(operation, field.source);
if (isBlank(value) && field.fallback) {
if (field.fallback === 'derive_trip_label(data.trip.days, data.trip.nights)') {
value = deriveTripLabel(operation.data?.trip || {});
} else {
value = getPath(operation, field.fallback);
}
}
if (isBlank(value) && field.default != null) value = field.default;
return transformValue(value, field.transform);
}
function buildReceivableRows(data, warnings) {
const prices = data.prices || {};
const counts = data.passenger_counts || {};
const currency = prices.currency || data.system_defaults?.currency || '';
const explicitRows = Array.isArray(prices.items) ? prices.items : [];
if (explicitRows.length) {
return explicitRows.map((item) => ({
name: item.name,
unit: item.unit || '人',
quantity: toNumber(item.quantity),
unit_price: toNumber(item.unit_price),
currency: item.currency || currency,
remark: item.remark || ''
}));
}
const categories = [
['adult', '成人团费'],
['child_bed', '小童占床'],
['child_no_bed', '小童不占床'],
['infant', '婴儿'],
['leader', '领队']
];
const rows = [];
for (const [key, label] of categories) {
const quantity = toNumber(counts[key]);
const unitPrice = toNumber(prices[key]);
if (quantity > 0 && unitPrice > 0) {
rows.push({
name: label,
unit: '人',
quantity,
unit_price: unitPrice,
currency,
remark: ''
});
}
}
if (!rows.length) warnings.push('No receivable rows were generated; provide data.prices.items or category prices.');
return rows;
}
function appendPayloadFields({ formSchema, values, mappedOnly }) {
const params = new URLSearchParams();
if (mappedOnly) {
for (const [name, value] of values.entries()) params.append(name, String(value ?? ''));
return params;
}
const seen = new Set();
for (const field of formSchema.fields || []) {
const name = field.name;
if (!name || seen.has(name)) continue;
if (field.disabled || field.type === 'button' || field.type === 'submit') continue;
if (field.type === 'radio') {
if (values.has(name)) params.append(name, String(values.get(name)));
seen.add(name);
continue;
}
if (field.type === 'checkbox') {
if (values.has(name) && values.get(name)) params.append(name, String(values.get(name)));
seen.add(name);
continue;
}
params.append(name, String(values.get(name) ?? ''));
seen.add(name);
}
return params;
}
function validateMappingTargets(mapping, fieldNames, blockers) {
const check = (name, context) => {
if (!fieldNames.has(name)) blockers.push(`Mapping target ${name} (${context}) is not present in the captured LTJT form schema.`);
};
for (const field of mapping.direct_fields || []) check(field.target, 'direct_fields');
for (const name of mapping.required_target_fields || []) check(name, 'required_target_fields');
for (const name of Object.keys(mapping.room_count_fields || {})) check(name, 'room_count_fields');
}
function validateOperation(operation, mapping, blockers, warnings) {
if (operation.action !== mapping.source?.action) {
blockers.push(`Unsupported action ${operation.action || '<missing>'}; expected ${mapping.source?.action}.`);
}
if (operation.submit_mode !== 'dry_run') {
blockers.push('submit_mode must be dry_run. This tool never enables live LTJT submission.');
}
if (operation.order_nature === 'formal') {
warnings.push('order_nature is formal; keep this as dry_run until a controlled submit test is approved.');
}
const data = operation.data || {};
const requiredData = [
'customer',
'product',
'route',
'trip',
'order_number',
'departure_dates',
'passenger_counts',
'room_counts',
'prices',
'op_user',
'sales_user'
];
for (const key of requiredData) {
if (data[key] == null) blockers.push(`Missing data.${key}.`);
}
if (!Array.isArray(data.departure_dates) || data.departure_dates.length !== 1) {
blockers.push('team_order_create requires exactly one data.departure_dates value.');
}
const total = passengerTotal(data.passenger_counts || {});
if (total <= 0) blockers.push('Passenger total must be greater than zero.');
const expected = data.passenger_counts?.expected_total;
if (expected != null && Math.trunc(toNumber(expected)) !== total) {
blockers.push(`passenger_counts.expected_total (${expected}) does not equal computed total (${total}).`);
}
const orderNumber = data.order_number || {};
if (isBlank(orderNumber.prefix) || isBlank(orderNumber.suffix)) {
blockers.push('This mapping version requires data.order_number.prefix and data.order_number.suffix for tuanxuhao1/tuanxuhao2.');
}
for (const lookup of mapping.lookup_fields || []) {
const ref = getPath(operation, lookup.source);
if (!ref || isBlank(ref.name)) {
blockers.push(`Missing lookup source ${lookup.source}.name for ${lookup.name}.`);
continue;
}
if (lookup.submit_blocker_if_unresolved && ref.resolved !== true) {
blockers.push(`${lookup.source} must be resolved=true after deterministic LTJT lookup.`);
}
if (lookup.name === 'customer' && isBlank(ref.ltjt_id)) {
blockers.push('data.customer.ltjt_id is required for zutuansheid and receivable unit ids.');
}
}
const passengerListOperation = data.passenger_list?.operation || 'none';
if (passengerListOperation !== 'none') {
blockers.push(`passenger_list.operation=${passengerListOperation} is not supported by this mapping version.`);
}
const attachments = Array.isArray(data.attachments) ? data.attachments : [];
if (attachments.length > (mapping.attachments?.max || 2)) {
blockers.push(`attachments length ${attachments.length} exceeds max ${mapping.attachments?.max || 2}.`);
}
attachments.forEach((attachment, index) => {
if (isBlank(attachment.ltjt_file_ref)) {
blockers.push(`attachments[${index}].ltjt_file_ref is required; upload local files through LTJT first.`);
}
});
}
function loadLookupResolution(path, blockers) {
if (!path) return {};
const report = readJson(path);
const passed = report.status === 'lookup_resolution_passed';
if (!passed) {
blockers.push(`lookup resolution report is not passed: ${report.status || '<missing status>'}.`);
}
if (Array.isArray(report.blockers) && report.blockers.length) {
blockers.push(`lookup resolution report contains ${report.blockers.length} blocker(s).`);
}
const fields = report.resolved_fields || {};
if (!Object.keys(fields).length) {
blockers.push('lookup resolution report has no resolved_fields.');
}
for (const [key, value] of Object.entries(fields)) {
if (passed && value === '[redacted]') {
blockers.push(`lookup resolution field ${key} is redacted; use the explicit --resolved-out artifact from resolve_order_add_lookups.mjs.`);
}
}
return fields;
}
function setField({ values, fieldNames, target, value, blockers }) {
if (!fieldNames.has(target)) {
blockers.push(`Cannot set ${target}; field is absent from form schema.`);
return;
}
if (value == null) return;
values.set(target, value);
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
const inputPath = args.input || args._[0];
if (!inputPath) fail(usage());
const mappingPath = args.mapping || DEFAULT_MAPPING;
const formSchemaPath = args['form-schema'] || DEFAULT_FORM_SCHEMA;
const operation = readJson(inputPath);
const mapping = readJson(mappingPath);
const formSchema = readJson(formSchemaPath);
const fieldNames = new Set((formSchema.fields || []).map((field) => field.name).filter(Boolean));
const blockers = [];
const warnings = [];
const values = new Map();
const lookupResolvedFields = loadLookupResolution(args['lookup-resolution'], blockers);
validateMappingTargets(mapping, fieldNames, blockers);
validateOperation(operation, mapping, blockers, warnings);
for (const field of mapping.direct_fields || []) {
const value = resolveMappedValue(operation, field);
setField({
values,
fieldNames,
target: field.target,
value,
blockers,
warnings,
source: field.source
});
}
for (const [target, value] of Object.entries(lookupResolvedFields)) {
setField({ values, fieldNames, target, value, blockers, warnings, source: 'lookup-resolution' });
}
const data = operation.data || {};
if (!isBlank(data.customer?.ltjt_id)) {
setField({ values, fieldNames, target: 'zutuansheid', value: data.customer.ltjt_id, blockers, warnings });
}
for (const [target, source] of Object.entries(mapping.room_count_fields || {})) {
const value = source.startsWith('derive_room_total')
? deriveRoomTotal(data.room_counts || {})
: getPath(operation, source);
setField({ values, fieldNames, target, value: integerString(value), blockers, warnings, source });
}
const receivableRows = buildReceivableRows(data, warnings);
const maxRows = mapping.receivable_rows?.max_rows || 10;
if (receivableRows.length > maxRows) {
blockers.push(`receivable row count ${receivableRows.length} exceeds max ${maxRows}.`);
}
receivableRows.slice(0, maxRows).forEach((row, index) => {
const amount = toNumber(row.quantity) * toNumber(row.unit_price);
const customerName = data.customer?.name || '';
const customerId = data.customer?.ltjt_id || '';
const operator = operation.source?.operator || '';
const rowValues = {
[`ys_danwei${index}`]: customerName,
[`ys_danweiid${index}`]: customerId,
[`ys_xiangmu${index}`]: row.name || '',
[`ys_shuoming${index}`]: row.unit || '',
[`ys_fangshi${index}`]: '',
[`ys_bizhong${index}`]: row.currency || data.prices?.currency || data.system_defaults?.currency || '',
[`ys_shuliang${index}`]: compactNumber(row.quantity),
[`ys_danjia${index}`]: compactNumber(row.unit_price),
[`ys_jine${index}`]: compactNumber(amount),
[`ys_yishoufu${index}`]: '0',
[`ys_beizhu${index}`]: row.remark || '',
[`ys_id${index}`]: '',
[`ys_shoufulei${index}`]: '0',
[`ys_caozuoren${index}`]: operator,
[`ys_shenheren${index}`]: ''
};
for (const [target, value] of Object.entries(rowValues)) {
setField({ values, fieldNames, target, value, blockers, warnings, source: 'receivable_rows' });
}
});
const attachments = Array.isArray(data.attachments) ? data.attachments : [];
attachments.slice(0, mapping.attachments?.max || 2).forEach((attachment, index) => {
setField({
values,
fieldNames,
target: `PicFile${index}`,
value: attachment.ltjt_file_ref || '',
blockers,
warnings,
source: `data.attachments[${index}].ltjt_file_ref`
});
});
for (const requiredTarget of mapping.required_target_fields || []) {
if (isBlank(values.get(requiredTarget))) blockers.push(`Required LTJT field ${requiredTarget} is blank after mapping.`);
}
const params = appendPayloadFields({
formSchema,
values,
mappedOnly: Boolean(args['mapped-only'])
});
const serializedForm = params.toString();
const requestBody = `${mapping.target?.payload_prefix || 'Act=DoInfoJH&'}${serializedForm}`;
const sha256 = createHash('sha256').update(requestBody).digest('hex');
const status = blockers.length ? 'blocked' : 'dry_run_passed';
const report = {
generated_at: new Date().toISOString(),
status,
mapping_version: mapping.mapping_version,
input: inputPath,
mapping: mappingPath,
form_schema: formSchemaPath,
target: mapping.target,
validation: {
blockers,
warnings,
passenger_total: passengerTotal(data.passenger_counts || {}),
room_total: deriveRoomTotal(data.room_counts || {}),
receivable_rows: Math.min(receivableRows.length, maxRows),
mapped_field_count: values.size,
serialized_field_count: Array.from(params.keys()).length
},
mapped_values: Object.fromEntries(values),
payload: {
content_type: 'application/x-www-form-urlencoded; charset=UTF-8',
sha256,
serialized_form: serializedForm,
request_body: requestBody
},
submit_safety: {
live_submit_attempted: false,
live_submit_supported_by_this_tool: false,
note: 'This tool only builds and validates a dry-run payload. It does not call LTJT.'
}
};
if (args.out) writeText(args.out, `${JSON.stringify(report, null, 2)}\n`);
if (args['payload-out']) writeText(args['payload-out'], `${requestBody}\n`);
console.log(JSON.stringify({
status,
blockers: blockers.length,
warnings: warnings.length,
passenger_total: report.validation.passenger_total,
receivable_rows: report.validation.receivable_rows,
mapped_field_count: report.validation.mapped_field_count,
serialized_field_count: report.validation.serialized_field_count,
payload_sha256: sha256,
out: args.out || '',
payload_out: args['payload-out'] || ''
}, null, 2));
if (blockers.length && !args['allow-blockers']) process.exit(2);
}
main();