491 lines
17 KiB
JavaScript
Executable File
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();
|