#!/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 Default: ${DEFAULT_MAPPING}`, ` --port Chrome DevTools port. Default: ${DEFAULT_PORT}`, ' --open-form Navigate the LTJT main iframe to a fresh orders_add.asp form first.', ' --out Write redacted preflight report.', ' --payload-out 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 || ''}; 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) + '©=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); });