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

330 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const 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 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`);
}
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 lookupExpression(operation, openForm) {
return `(async () => {
const operation = ${JSON.stringify(operation)};
const openForm = ${JSON.stringify(Boolean(openForm))};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const entryUrl = 'https://ltjt.yunzhi.run/System/Business/orders_add.asp?fabudanwei=' + encodeURIComponent(operation.data?.system_defaults?.fabudanwei || '老挝联泰') + '&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;
}
if (openForm) {
const frame = document.getElementById('Iframe_Home') || document.querySelector('iframe[name="MainIframe"], iframe');
if (!frame) return { ok: false, status: 'main_iframe_not_found' };
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' };
const doc = orderWin.document;
const form = doc.querySelector('#ListForm');
const fabudanwei = form.elements.fabudanwei?.value || operation.data?.system_defaults?.fabudanwei || '老挝联泰';
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 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) {
const seps = ['◆', '^', '|', String.fromCharCode(9), ','];
let best = [row];
for (const sep of seps) {
const parts = String(row || '').split(sep).map((part) => part.trim());
if (parts.length > best.length) best = parts;
}
return best;
}
function summarize(text) {
const rows = splitRows(text);
const columnHistogram = {};
for (const row of rows.slice(0, 200)) {
const count = splitColumns(row).length;
columnHistogram[count] = (columnHistogram[count] || 0) + 1;
}
return {
byte_length: new Blob([String(text || '')]).size,
row_count: rows.length,
column_count_histogram_first_200_rows: columnHistogram,
value_redacted: true
};
}
function findMatches(text, expected, options = {}) {
const rows = splitRows(text);
const expectedText = String(expected || '').trim();
const exactRows = [];
for (let i = 0; i < rows.length; i += 1) {
const columns = splitColumns(rows[i]);
const candidateColumns = options.columns || columns.map((_, idx) => idx);
const matchedColumns = candidateColumns.filter((idx) => String(columns[idx] || '').trim() === expectedText);
if (matchedColumns.length) exactRows.push({ row_index: i, matched_columns: matchedColumns, column_count: columns.length });
}
return exactRows;
}
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 [name, path] of Object.entries(endpoints)) {
fetched[name] = await fetchText(path);
}
const checks = [
{
name: 'trip_days',
expected: operation.data?.trip?.label || (operation.data?.trip?.days ? operation.data.trip.days + 'D' + Math.max(0, (operation.data.trip.nights ?? operation.data.trip.days - 1)) + 'N' : ''),
source: 'static TianShu SelectBox options',
summary: summarize('1D◇2D1N◇3D2N◇4D3N◇5D4N◇6D5N◇7D6N◇8D7N◇9D8N◇10D9N'),
matches: findMatches('1D◇2D1N◇3D2N◇4D3N◇5D4N◇6D5N◇7D6N◇8D7N◇9D8N◇10D9N', operation.data?.trip?.label, { columns: [0] }),
required_linked_targets: ['TianShu']
},
{
name: 'route',
expected: operation.data?.route?.name,
endpoint_key: 'route',
endpoint_path: endpoints.route,
summary: summarize(fetched.route.text),
matches: findMatches(fetched.route.text, operation.data?.route?.name, { columns: [1] }),
required_linked_targets: ['zhuanxianming', 'tuanxuhao1']
},
{
name: 'product',
expected: operation.data?.product?.name,
endpoint_key: 'product',
endpoint_path: endpoints.product,
summary: summarize(fetched.product.text),
matches: findMatches(fetched.product.text, operation.data?.product?.name, { columns: [1] }),
required_linked_targets: ['chanpinming', 'GetProduct(cpm) side effects']
},
{
name: 'customer',
expected: operation.data?.customer?.name,
endpoint_key: 'customer',
endpoint_path: endpoints.customer,
summary: summarize(fetched.customer.text),
matches: findMatches(fetched.customer.text, operation.data?.customer?.name, { columns: [2] }),
required_linked_targets: ['zutuansheid', 'zutuanshe', 'lianxiren', 'zutuanshegzr', 'bizhong']
},
{
name: 'op_user',
expected: operation.data?.op_user?.name,
endpoint_key: 'staff',
endpoint_path: endpoints.staff,
summary: summarize(fetched.staff.text || orderWin.top.QJ_Yuangong_Data),
matches: findMatches(fetched.staff.text || orderWin.top.QJ_Yuangong_Data, operation.data?.op_user?.name),
required_linked_targets: ['gendanren']
},
{
name: 'sales_user',
expected: operation.data?.sales_user?.name,
endpoint_key: 'staff',
endpoint_path: endpoints.staff,
summary: summarize(fetched.staff.text || orderWin.top.QJ_Yuangong_Data),
matches: findMatches(fetched.staff.text || orderWin.top.QJ_Yuangong_Data, operation.data?.sales_user?.name),
required_linked_targets: ['xiaoshouren']
}
];
const blockers = [];
for (const check of checks) {
if (!check.expected) blockers.push(check.name + ': missing expected standard value');
else if (check.matches.length !== 1) blockers.push(check.name + ': expected exactly one existing LTJT option, found ' + check.matches.length);
}
return {
ok: true,
status: blockers.length ? 'lookup_validation_blocked' : 'lookup_validation_passed',
page: {
url: doc.location.href,
title: doc.title,
form_element_count: form.elements.length
},
checks: checks.map((check) => ({
name: check.name,
expected_redacted: Boolean(check.expected),
endpoint_key: check.endpoint_key || '',
endpoint_path: check.endpoint_path || check.source || '',
summary: check.summary,
exact_match_count: check.matches.length,
match_shapes: check.matches,
required_linked_targets: check.required_linked_targets
})),
blockers,
fetched_statuses: Object.fromEntries(Object.entries(fetched).map(([key, value]) => [key, { ok: value.ok, status: value.status, byte_length: new Blob([value.text]).size }]))
};
})()`;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const inputPath = args.input || args._[0];
if (!inputPath) {
console.error('Usage: node tools/validate_order_add_lookups.mjs --input samples/team_order_create.dry-run.example.json --open-form --out reports/order.lookup-validation.json');
process.exit(1);
}
const operation = readJson(inputPath);
const target = await getTarget(args.port || PORT);
const cdp = new CDP(target.webSocketDebuggerUrl);
await cdp.connect();
try {
await cdp.send('Runtime.enable');
const output = await evaluate(cdp, lookupExpression(operation, args['open-form']), 60000);
if (args.out) writeJson(args.out, output);
console.log(JSON.stringify({
status: output.status,
page: output.page,
blockers: output.blockers?.length || 0,
checks: output.checks?.map((check) => ({
name: check.name,
exact_match_count: check.exact_match_count,
row_count: check.summary?.row_count,
linked_targets: check.required_linked_targets
})) || [],
out: args.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);
});