#!/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 Default: ${DEFAULT_MAPPING}`, ` --form-schema Default: ${DEFAULT_FORM_SCHEMA}`, ' --lookup-resolution Require a passed lookup-resolution report and merge its resolved fields', ' --out Write the full dry-run report as JSON', ' --payload-out 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 || ''}; 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 || ''}.`); } 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();