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

870 lines
36 KiB
JavaScript

#!/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_PORT = process.env.LWLT_CDP_PORT || '9223';
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/browser_order_add_preflight.mjs --input order.json --open-form --out reports/preflight.json',
'',
'Options:',
` --mapping <path> Default: ${DEFAULT_MAPPING}`,
` --port <number> Chrome DevTools port. Default: ${DEFAULT_PORT}`,
' --open-form Navigate the LTJT main iframe to a fresh orders_add.asp form first.',
' --out <path> Write redacted preflight report.',
' --payload-out <path> Write serialized Act=DoInfoJH payload. Requires --unsafe-include-values.',
' --unsafe-include-values Keep real serialized form values in the output artifacts.',
' --allow-blockers Exit 0 even when preflight blockers are present.',
' --help Show this help.',
'',
'This tool fills and serializes the browser form only. It never clicks SubmitButton and never calls DoInfoJH.'
].join('\n');
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
function writeJson(path, data) {
mkdirSync(dirname(resolve(path)), { recursive: true });
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
}
function writeText(path, data) {
mkdirSync(dirname(resolve(path)), { recursive: true });
writeFileSync(path, data);
}
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 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 for browser preflight. 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}).`);
}
if (isBlank(data.order_number?.prefix) || isBlank(data.order_number?.suffix)) {
blockers.push('data.order_number.prefix and data.order_number.suffix are required.');
}
const passengerListOperation = data.passenger_list?.operation || 'none';
if (passengerListOperation !== 'none') {
blockers.push(`passenger_list.operation=${passengerListOperation} is not supported by this preflight version.`);
}
const maxReceivables = mapping.receivable_rows?.max_rows || 10;
const receivableRows = Array.isArray(data.prices?.items) ? data.prices.items : [];
if (receivableRows.length > maxReceivables) {
blockers.push(`data.prices.items length ${receivableRows.length} exceeds max ${maxReceivables}.`);
}
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 buildFieldSets(operation, mapping, blockers, warnings) {
const data = operation.data || {};
const directValues = {};
for (const field of mapping.direct_fields || []) {
const value = resolveMappedValue(operation, field);
if (value != null) directValues[field.target] = value;
}
const roomValues = {};
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);
roomValues[target] = integerString(value);
}
const receivableTargets = mapping.receivable_rows?.targets || {};
const maxRows = mapping.receivable_rows?.max_rows || 10;
const receivableClearValues = {};
for (let index = 0; index < maxRows; index += 1) {
for (const pattern of Object.values(receivableTargets)) {
receivableClearValues[pattern.replace('{n}', String(index))] = '';
}
}
const receivableValues = {};
const receivableRows = buildReceivableRows(data, warnings);
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 operator = operation.source?.operator || '';
const rowValues = {
[`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}`]: ''
};
Object.assign(receivableValues, rowValues);
});
const attachmentValues = {};
const attachments = Array.isArray(data.attachments) ? data.attachments : [];
attachments.slice(0, mapping.attachments?.max || 2).forEach((attachment, index) => {
attachmentValues[`PicFile${index}`] = attachment.ltjt_file_ref || '';
});
return {
directValues,
roomValues,
receivableClearValues,
receivableValues,
attachmentValues,
requiredTargets: mapping.required_target_fields || [],
expected: {
tripLabel: deriveTripLabel(data.trip || {}),
passengerTotal: passengerTotal(data.passenger_counts || {}),
roomTotal: deriveRoomTotal(data.room_counts || {}),
receivableRowCount: Math.min(receivableRows.length, maxRows)
}
};
}
async function getJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${url}`);
return res.json();
}
async function getTarget(port) {
const targets = await getJson(`http://127.0.0.1:${port}/json`);
const target = targets.find((item) => item.type === 'page' && item.url.includes('ltjt.yunzhi.run'));
if (!target) throw new Error(`No ltjt.yunzhi.run page target on port ${port}`);
return target;
}
class CDP {
constructor(wsUrl) {
this.wsUrl = wsUrl;
this.id = 0;
this.pending = new Map();
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = resolve;
this.ws.onerror = (event) => reject(new Error(event.message || event.type || 'WebSocket error'));
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (!msg.id || !this.pending.has(msg.id)) return;
const { resolve: ok, reject: fail, timer } = this.pending.get(msg.id);
clearTimeout(timer);
this.pending.delete(msg.id);
msg.error ? fail(new Error(msg.error.message)) : ok(msg.result);
};
});
}
send(method, params = {}, timeoutMs = 30000) {
const id = ++this.id;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Timeout: ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
});
}
close() {
this.ws?.close();
}
}
async function evaluate(cdp, expression, timeoutMs = 30000) {
const result = await cdp.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true
}, timeoutMs);
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime.evaluate failed');
return result.result.value;
}
function preflightExpression({ operation, fieldSets, openForm }) {
return `(async () => {
const operation = ${JSON.stringify(operation)};
const fieldSets = ${JSON.stringify(fieldSets)};
const openForm = ${JSON.stringify(Boolean(openForm))};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const fabu = operation.data?.system_defaults?.fabudanwei || '老挝联泰';
const entryUrl = 'https://ltjt.yunzhi.run/System/Business/orders_add.asp?fabudanwei=' + encodeURIComponent(fabu) + '&copy=0&ddid=0&_=' + Date.now();
const entryStamp = new URL(entryUrl).searchParams.get('_');
function findOrderWindow() {
const queue = [window];
while (queue.length) {
const win = queue.shift();
let doc;
try { doc = win.document; } catch (err) { continue; }
if (doc.querySelector('#ListForm') && doc.location.href.includes('/orders_add.asp')) return win;
Array.from(win.frames).forEach((frame) => queue.push(frame));
}
return null;
}
function topState() {
return { url: location.href, title: document.title };
}
if (/login/i.test(location.href) || /云智办公i-5.1/.test(document.title || '')) {
return { ok: false, status: 'login_required', blockers: ['login_required'], top: topState() };
}
if (openForm) {
const frame = document.getElementById('Iframe_Home') || document.querySelector('iframe[name="MainIframe"], iframe');
if (!frame) return { ok: false, status: 'main_iframe_not_found', blockers: ['main_iframe_not_found'], top: topState() };
frame.src = entryUrl;
}
const started = Date.now();
let orderWin = findOrderWindow();
while ((!orderWin || !orderWin.document.querySelector('#ListForm') || orderWin.document.querySelector('#ListForm').elements.length < 800 || (openForm && !orderWin.document.location.href.includes('_=' + entryStamp))) && Date.now() - started < 25000) {
await sleep(500);
orderWin = findOrderWindow();
}
if (!orderWin) return { ok: false, status: 'orders_add_form_not_found', blockers: ['orders_add_form_not_found'], top: topState() };
const doc = orderWin.document;
const form = doc.querySelector('#ListForm');
const blockers = [];
const warnings = [];
const setResults = [];
const fabudanwei = form.elements.fabudanwei?.value || fabu;
function splitRows(text) {
const str = String(text || '');
if (!str) return [];
if (str.includes('◇')) return str.split('◇').map((row) => row.trim()).filter(Boolean);
if (/\\r?\\n/.test(str)) return str.split(/\\r?\\n/).map((row) => row.trim()).filter(Boolean);
return [str.trim()].filter(Boolean);
}
function splitColumns(row) {
return String(row || '').split('◆').map((part) => part.trim());
}
function summarizeText(text) {
const rows = splitRows(text);
const histogram = {};
rows.slice(0, 200).forEach((row) => {
const count = splitColumns(row).length;
histogram[count] = (histogram[count] || 0) + 1;
});
return { byte_length: new Blob([String(text || '')]).size, row_count: rows.length, column_count_histogram_first_200_rows: histogram, value_redacted: true };
}
async function fetchText(path) {
const url = new URL(path, doc.location.href).href;
const res = await orderWin.fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }
});
const text = await res.text();
return { ok: res.ok, status: res.status, url, text };
}
function findExact(text, expected, column) {
const rows = splitRows(text);
const expectedText = String(expected || '').trim();
const matches = [];
rows.forEach((row, rowIndex) => {
const columns = splitColumns(row);
if (String(columns[column] || '').trim() === expectedText) matches.push({ rowIndex, columns });
});
return matches;
}
function applySetValues(columns, setValues) {
const fields = {};
for (const [target, col] of Object.entries(setValues)) fields[target] = columns[Number(col)] ?? '';
return fields;
}
function getFormValue(name) {
const el = form.elements[name];
if (!el) return '';
if (el.length && !el.tagName) return Array.from(el).find((item) => item.checked)?.value || el[0]?.value || '';
return el.value ?? '';
}
function setFormValue(name, value, source) {
const elements = Array.from(form.elements).filter((el) => el.name === name);
if (!elements.length) {
blockers.push('Cannot set missing form field ' + name + ' from ' + source);
return;
}
const textValue = String(value ?? '');
const type = String(elements[0].type || '').toLowerCase();
if (type === 'radio') {
const matched = elements.find((el) => String(el.value) === textValue) || elements[0];
elements.forEach((el) => { el.checked = el === matched; });
} else if (type === 'checkbox') {
elements[0].checked = Boolean(value);
if (textValue) elements[0].value = textValue;
} else {
elements[0].value = textValue;
}
setResults.push({ name, source, type, value_length: textValue.length, value_redacted: true });
}
function summarizeValues(values) {
const normalized = values.map((value) => String(value ?? ''));
return {
value_count: normalized.length,
nonempty_count: normalized.filter((value) => value !== '').length,
total_value_length: normalized.reduce((sum, value) => sum + value.length, 0),
value_redacted: true
};
}
function snapshotForm() {
const snapshot = {};
Array.from(form.elements).forEach((el, index) => {
const name = el.name || el.id || '__element_' + index;
const type = String(el.type || '').toLowerCase();
const value = (type === 'checkbox' || type === 'radio') ? (el.checked ? el.value : '') : (el.value ?? '');
if (!snapshot[name]) snapshot[name] = [];
snapshot[name].push(String(value));
});
return snapshot;
}
function compareSnapshots(before, after) {
const names = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).sort();
return names
.filter((name) => JSON.stringify(before[name] || []) !== JSON.stringify(after[name] || []))
.map((name) => ({ name, before: summarizeValues(before[name] || []), after: summarizeValues(after[name] || []) }));
}
function groupChangedFields(changes) {
const groups = {};
for (const change of changes) {
let group = 'other';
if (/^(Text|shuoming|oldxingcheng|xingcheng)/.test(change.name)) group = 'itinerary_text';
else if (/^(zao|zhong|wan|zhusu|jingdian)/.test(change.name)) group = 'resources';
else if (/^(ys_|youyingshou|baojia|yingfu)/.test(change.name)) group = 'pricing_receivable';
else if (/^(TianShu|chanpinming|tuanxuhao|zhuanxianming|zutuanshe|bizhong|lianxiren)/.test(change.name)) group = 'core';
groups[group] = (groups[group] || 0) + 1;
}
return groups;
}
const endpoints = {
route: '../dat/AjaxPublicFun.asp?Act=GetInformation&fl=3&fls=1&fabudanwei=' + encodeURIComponent(fabudanwei),
product: '../dat/AjaxPublicFun.asp?Act=ProGetProductname&fls=1&fabudanwei=' + encodeURIComponent(fabudanwei),
customer: '../dat/AjaxPublicFun.asp?Act=ProTravel&fabudanwei=' + encodeURIComponent(fabudanwei),
staff: '../dat/AjaxPublicFun.asp?Act=ProDanwei_Yuangong&fabudanwei=' + encodeURIComponent(fabudanwei)
};
const fetched = {};
for (const [key, path] of Object.entries(endpoints)) fetched[key] = await fetchText(path);
const staticTrip = '1D◇2D1N◇3D2N◇4D3N◇5D4N◇6D5N◇7D6N◇8D7N◇9D8N◇10D9N';
const specs = [
{ name: 'trip_days', expected: fieldSets.expected.tripLabel, text: staticTrip, endpoint_key: '', match_column: 0, set_values: { TianShu: 0 } },
{ name: 'route', expected: operation.data?.route?.name, text: fetched.route.text, endpoint_key: 'route', match_column: 1, set_values: { zhuanxianming: 1, tuanxuhao1: 3 } },
{ name: 'product', expected: operation.data?.product?.name, text: fetched.product.text, endpoint_key: 'product', match_column: 1, set_values: { chanpinming: 1 } },
{ name: 'customer', expected: operation.data?.customer?.name, text: fetched.customer.text, endpoint_key: 'customer', match_column: 2, set_values: { bizhong: 0, zutuansheid: 1, zutuanshe: 2, lianxiren: 3, zutuanshegzr: 4 } },
{ name: 'op_user', expected: operation.data?.op_user?.name, text: fetched.staff.text || orderWin.top.QJ_Yuangong_Data, endpoint_key: 'staff', match_column: 0, set_values: { gendanren: 0 } },
{ name: 'sales_user', expected: operation.data?.sales_user?.name, text: fetched.staff.text || orderWin.top.QJ_Yuangong_Data, endpoint_key: 'staff', match_column: 0, set_values: { xiaoshouren: 0 } }
];
const resolvedFields = {};
const lookupChecks = specs.map((spec) => {
const matches = spec.expected ? findExact(spec.text, spec.expected, spec.match_column) : [];
let resolved = {};
if (!spec.expected) blockers.push(spec.name + ': missing expected standard value');
else if (matches.length !== 1) blockers.push(spec.name + ': expected exactly one existing LTJT option, found ' + matches.length);
else {
resolved = applySetValues(matches[0].columns, spec.set_values);
Object.assign(resolvedFields, resolved);
}
return {
name: spec.name,
expected_redacted: Boolean(spec.expected),
endpoint_key: spec.endpoint_key,
summary: summarizeText(spec.text),
exact_match_count: matches.length,
match_shape: matches.length === 1 ? { row_index: matches[0].rowIndex, column_count: matches[0].columns.length, set_value_targets: Object.keys(spec.set_values) } : null,
resolved_field_names: Object.keys(resolved)
};
});
if (blockers.length) {
return {
ok: true,
status: 'browser_preflight_blocked',
page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length },
lookup_checks: lookupChecks,
blockers,
warnings,
submit_safety: { live_submit_attempted: false, live_submit_supported_by_this_tool: false }
};
}
const ajaxRecords = [];
const $ = orderWin.jQuery || orderWin.$;
const originalAjax = $?.ajax;
if (!$ || typeof originalAjax !== 'function') blockers.push('jQuery.ajax is not available in the order form window');
else {
$.ajax = function ajaxWrapper(options, ...rest) {
const config = typeof options === 'string' ? { url: options } : { ...(options || {}) };
const isProductEffect = String(config.url || '').includes('Act=GetProduct');
if (!isProductEffect) return originalAjax.call(this, options, ...rest);
const record = {
url_path_redacted: '../dat/AjaxPublicFun.asp?Act=GetProduct&cpm=[redacted]',
method: config.type || config.method || 'GET',
dataType: config.dataType || '',
completed: false,
ok: false,
text_status: '',
response_byte_length: 0,
contains_login_timeout_text: false
};
ajaxRecords.push(record);
const originalSuccess = config.success;
const originalError = config.error;
const originalComplete = config.complete;
config.success = function successWrapper(data, textStatus) {
const text = String(data || '');
record.ok = true;
record.text_status = textStatus || '';
record.response_byte_length = new Blob([text]).size;
record.contains_login_timeout_text = /登陆|登录|login/i.test(text);
return originalSuccess ? originalSuccess.apply(this, arguments) : undefined;
};
config.error = function errorWrapper(jqXHR, textStatus, errorThrown) {
record.ok = false;
record.text_status = textStatus || '';
record.error_text_redacted = errorThrown ? String(errorThrown).slice(0, 80) : '';
return originalError ? originalError.apply(this, arguments) : undefined;
};
config.complete = function completeWrapper() {
record.completed = true;
return originalComplete ? originalComplete.apply(this, arguments) : undefined;
};
return originalAjax.call(this, config, ...rest);
};
}
const beforeProduct = snapshotForm();
setFormValue('chanpinming', resolvedFields.chanpinming, 'product_lookup_before_Find_product');
if (typeof orderWin.Find_product !== 'function') blockers.push('Find_product() is not available');
if (!blockers.length) {
try {
orderWin.Find_product();
} catch (err) {
blockers.push('Find_product() threw: ' + (err.message || String(err)).slice(0, 160));
}
}
const effectStarted = Date.now();
while (Date.now() - effectStarted < 15000) {
if (ajaxRecords.some((record) => record.completed)) break;
await sleep(250);
}
await sleep(1000);
if ($ && originalAjax) $.ajax = originalAjax;
const afterProduct = snapshotForm();
const productChanges = compareSnapshots(beforeProduct, afterProduct);
const productAjax = ajaxRecords.find((record) => record.url_path_redacted.includes('GetProduct')) || null;
if (!productAjax) blockers.push('GetProduct ajax request was not observed');
else if (!productAjax.ok) blockers.push('GetProduct ajax request did not complete successfully');
else if (productAjax.contains_login_timeout_text) blockers.push('GetProduct response looked like a login-timeout script');
if (!productChanges.length) blockers.push('Find_product/GetProduct completed without changing any serialized form fields');
const consistencyChecks = [
{ name: 'product_after_effect', field: 'chanpinming', expected: resolvedFields.chanpinming },
{ name: 'trip_after_effect', field: 'TianShu', expected: resolvedFields.TianShu },
{ name: 'route_after_effect', field: 'zhuanxianming', expected: resolvedFields.zhuanxianming },
{ name: 'route_prefix_after_effect', field: 'tuanxuhao1', expected: operation.data?.order_number?.prefix },
{ name: 'customer_after_effect', field: 'zutuanshe', expected: resolvedFields.zutuanshe },
{ name: 'customer_id_after_effect', field: 'zutuansheid', expected: resolvedFields.zutuansheid }
].map((check) => {
const actual = getFormValue(check.field);
const passed = String(actual || '').trim() === String(check.expected || '').trim();
if (!passed) blockers.push(check.name + ': product template value does not match the standard operation/lookup value');
return {
name: check.name,
field: check.field,
passed,
expected_redacted: true,
actual_redacted: true,
expected_length: String(check.expected || '').length,
actual_length: String(actual || '').length
};
});
if (resolvedFields.tuanxuhao1 && operation.data?.order_number?.prefix && String(resolvedFields.tuanxuhao1).trim() !== String(operation.data.order_number.prefix).trim()) {
blockers.push('route lookup prefix does not match data.order_number.prefix');
}
if (operation.data?.customer?.ltjt_id && String(operation.data.customer.ltjt_id).trim() !== String(resolvedFields.zutuansheid || '').trim()) {
blockers.push('data.customer.ltjt_id does not match the LTJT customer lookup id');
}
if (!blockers.length) {
const protectedDirectFields = new Set(['zhuanxianming', 'TianShu', 'zutuanshe', 'chanpinming', 'bizhong', 'tuanxuhao1', 'gendanren', 'xiaoshouren']);
for (const [name, value] of Object.entries(fieldSets.directValues || {})) {
if (!protectedDirectFields.has(name)) setFormValue(name, value, 'direct_standard_value');
}
for (const [name, value] of Object.entries(resolvedFields)) setFormValue(name, value, 'lookup_resolved_value');
setFormValue('tuanxuhao2', operation.data?.order_number?.suffix || '', 'direct_standard_value_after_product');
for (const [name, value] of Object.entries(fieldSets.roomValues || {})) setFormValue(name, value, 'room_counts');
for (const [name, value] of Object.entries(fieldSets.receivableClearValues || {})) setFormValue(name, value, 'clear_product_receivable_rows');
const customerName = resolvedFields.zutuanshe || operation.data?.customer?.name || '';
const customerId = resolvedFields.zutuansheid || operation.data?.customer?.ltjt_id || '';
for (let index = 0; index < 10; index += 1) {
if (Object.prototype.hasOwnProperty.call(fieldSets.receivableValues || {}, 'ys_xiangmu' + index)) {
setFormValue('ys_danwei' + index, customerName, 'receivable_rows_customer');
setFormValue('ys_danweiid' + index, customerId, 'receivable_rows_customer_id');
}
}
for (const [name, value] of Object.entries(fieldSets.receivableValues || {})) setFormValue(name, value, 'receivable_rows_standard_value');
for (const [name, value] of Object.entries(fieldSets.attachmentValues || {})) setFormValue(name, value, 'attachments');
}
const missingRequired = [];
for (const name of fieldSets.requiredTargets || []) {
if (String(getFormValue(name) || '').trim() === '') missingRequired.push(name);
}
if (missingRequired.length) blockers.push('Required LTJT fields still blank after preflight fill: ' + missingRequired.join(', '));
const serializedForm = orderWin.jQuery
? orderWin.jQuery(form).serialize()
: Array.from(new FormData(form).entries()).map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value)).join('&');
const serializedParams = new URLSearchParams(serializedForm);
return {
ok: true,
status: blockers.length ? 'browser_preflight_blocked' : 'browser_preflight_passed',
page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length },
lookup_checks: lookupChecks,
product_side_effect: {
ajax_records: ajaxRecords,
changed_field_count: productChanges.length,
changed_field_groups: groupChangedFields(productChanges),
changed_field_names: productChanges.map((change) => change.name)
},
consistency_checks: consistencyChecks,
set_summary: {
set_field_count: setResults.length,
set_field_names: Array.from(new Set(setResults.map((item) => item.name))).sort(),
set_results: setResults
},
final_form: {
serialized_field_count: Array.from(serializedParams.keys()).length,
required_missing: missingRequired,
serialized_form: serializedForm
},
blockers,
warnings,
submit_safety: {
live_submit_attempted: false,
live_submit_supported_by_this_tool: false,
note: 'This tool fills and serializes the browser form only. It never clicks SubmitButton and never calls DoInfoJH.'
}
};
})()`;
}
function redactReport(output, includeValues) {
const serialized = output.final_form?.serialized_form || '';
const sha256 = serialized ? createHash('sha256').update(`Act=DoInfoJH&${serialized}`).digest('hex') : '';
const report = {
...output,
final_form: output.final_form ? {
...output.final_form,
serialized_form_sha256: sha256,
serialized_form_redacted: !includeValues,
serialized_form: includeValues ? output.final_form.serialized_form : undefined
} : output.final_form
};
if (report.final_form && !includeValues) delete report.final_form.serialized_form;
return report;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
const inputPath = args.input || args._[0];
if (!inputPath) {
console.error(usage());
process.exit(1);
}
const operation = readJson(inputPath);
const mapping = readJson(args.mapping || DEFAULT_MAPPING);
const blockers = [];
const warnings = [];
validateOperation(operation, mapping, blockers, warnings);
const fieldSets = buildFieldSets(operation, mapping, blockers, warnings);
if (blockers.length) {
const output = {
generated_at: new Date().toISOString(),
status: 'browser_preflight_blocked',
source_input: inputPath,
blockers,
warnings,
submit_safety: {
live_submit_attempted: false,
live_submit_supported_by_this_tool: false
}
};
if (args.out) writeJson(args.out, output);
console.log(JSON.stringify({ status: output.status, blockers: blockers.length, warnings: warnings.length, out: args.out || '' }, null, 2));
if (!args['allow-blockers']) process.exit(2);
return;
}
const target = await getTarget(args.port || DEFAULT_PORT);
const cdp = new CDP(target.webSocketDebuggerUrl);
await cdp.connect();
try {
await cdp.send('Runtime.enable');
const output = await evaluate(cdp, preflightExpression({
operation,
fieldSets,
openForm: args['open-form']
}), 90000);
output.generated_at = new Date().toISOString();
output.source_input = inputPath;
output.mapping_version = mapping.mapping_version;
output.local_validation = {
passenger_total: fieldSets.expected.passengerTotal,
room_total: fieldSets.expected.roomTotal,
receivable_rows: fieldSets.expected.receivableRowCount,
warnings
};
output.warnings = [...(output.warnings || []), ...warnings];
const includeValues = Boolean(args['unsafe-include-values']);
const report = redactReport(output, includeValues);
if (args.out) writeJson(args.out, report);
if (args['payload-out']) {
if (!includeValues) {
console.error('Refusing to write payload-out without --unsafe-include-values.');
} else if (output.blockers?.length) {
console.error('Refusing to write payload-out because browser preflight is blocked.');
} else {
writeText(args['payload-out'], `Act=DoInfoJH&${output.final_form?.serialized_form || ''}`);
}
}
console.log(JSON.stringify({
status: output.status,
blockers: output.blockers?.length || 0,
lookup_checks: output.lookup_checks?.map((check) => ({ name: check.name, exact_match_count: check.exact_match_count })) || [],
product_changed_fields: output.product_side_effect?.changed_field_count || 0,
product_changed_groups: output.product_side_effect?.changed_field_groups || {},
set_field_count: output.set_summary?.set_field_count || 0,
serialized_field_count: output.final_form?.serialized_field_count || 0,
out: args.out || '',
payload_out: includeValues && !(output.blockers?.length) ? (args['payload-out'] || '') : ''
}, null, 2));
if (output.blockers?.length && !args['allow-blockers']) process.exit(2);
} finally {
cdp.close();
}
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});