Files
LWLT-AI/archive/handoff/2026-07-12/legacy-erp-handoff/tools/erp_traveler_import.js
2026-07-13 19:57:46 +08:00

496 lines
15 KiB
JavaScript

const { normalizeTravelerDate } = require('./erp_traveler_list');
function travelerListFromPayload(payload = {}) {
return payload.travelerList
|| (payload.supplemental && payload.supplemental.travelerList)
|| null;
}
function normalizeImportMode(value) {
return String(value === undefined || value === null ? '' : value).trim().toLowerCase();
}
function travelerImportModeFromPayload(payload = {}, options = {}) {
return [
options.importMode,
options.travelerImportMode,
payload.importMode,
payload.travelerImportMode,
payload.travelerOperation,
payload.operation,
payload.supplemental && payload.supplemental.importMode,
payload.supplemental && payload.supplemental.travelerImportMode,
payload.supplemental && payload.supplemental.travelerOperation,
].map(normalizeImportMode).find(Boolean) || '';
}
function isSupplementalTravelerImport(payload = {}, options = {}) {
const mode = travelerImportModeFromPayload(payload, options);
if (/(append|supplement|supplemental|add|merge|追加|补充|新增)/i.test(mode)) return true;
if (/(replace|overwrite|initial|create|first|替换|覆盖|首次|新建)/i.test(mode)) return false;
const writeTargets = Array.isArray(payload.writeTargets) ? payload.writeTargets.map((target) => String(target)) : [];
return writeTargets.some((target) => /^supplemental\.(travelerList|travelers)$/i.test(target));
}
const ERP_PASTE_HEADERS = [
'序号',
'中文名',
'英文名',
'性别',
'出生日期',
'出生地',
'护照号码',
'签发日期',
'有效日期',
'签发地',
'备注',
];
const ERP_PASTE_FIELDS = [
'sequence',
'chineseName',
'englishName',
'gender',
'birthDate',
'birthPlace',
'passportNo',
'issueDate',
'expiryDate',
'issuePlace',
'remark',
];
function cleanCell(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/\r?\n/g, ' ')
.trim();
}
function pasteCell(row, field) {
const value = cleanCell(row[field]);
return ['birthDate', 'issueDate', 'expiryDate'].includes(field)
? normalizeTravelerDate(value, field)
: value;
}
function formatTravelerListForErpPaste(list) {
const rows = Array.isArray(list && list.rows) ? list.rows : [];
const lines = [
ERP_PASTE_HEADERS.join('\t'),
...rows.map((row) => ERP_PASTE_FIELDS.map((field) => pasteCell(row, field)).join('\t')),
];
return {
headers: [...ERP_PASTE_HEADERS],
fields: [...ERP_PASTE_FIELDS],
rowCount: rows.length,
text: lines.join('\n'),
};
}
async function safeCount(locator) {
if (!locator || typeof locator.count !== 'function') return 0;
try {
return await locator.count();
} catch (error) {
return 0;
}
}
async function clickFirstMatching(frame, selectors) {
if (!frame || typeof frame.locator !== 'function') {
return { ok: false, selector: '', error: 'frame_locator_unavailable' };
}
for (const selector of selectors) {
const locator = frame.locator(selector);
if (await safeCount(locator) < 1) continue;
try {
await locator.first().click();
return { ok: true, selector };
} catch (error) {
return { ok: false, selector, error: error.message };
}
}
return { ok: false, selector: '', error: 'control_not_found' };
}
async function fillFirstMatching(frame, selectors, value) {
if (!frame || typeof frame.locator !== 'function') {
return { ok: false, selector: '', error: 'frame_locator_unavailable' };
}
for (const selector of selectors) {
const locator = frame.locator(selector);
if (await safeCount(locator) < 1) continue;
try {
await locator.first().fill(value);
return { ok: true, selector };
} catch (error) {
return { ok: false, selector, error: error.message };
}
}
return { ok: false, selector: '', error: 'control_not_found' };
}
async function tryClickImport(frame) {
const result = await clickFirstMatching(frame, [
'a:has-text("导入")',
'button:has-text("导入")',
'input[type="button"][value*="导入"]',
'input[type="submit"][value*="导入"]',
'text=导入',
'a:has-text("瀵煎叆")',
'button:has-text("瀵煎叆")',
'input[type="button"][value*="瀵煎叆"]',
'input[type="submit"][value*="瀵煎叆"]',
'text=瀵煎叆',
]);
return result.ok ? result : { ...result, error: result.error === 'control_not_found' ? 'import_button_not_found' : result.error };
}
function getFrameUrl(frame) {
if (!frame || typeof frame.url !== 'function') return '';
try {
return String(frame.url() || '');
} catch (error) {
return '';
}
}
function getFrameName(frame) {
if (!frame || typeof frame.name !== 'function') return '';
try {
return String(frame.name() || '');
} catch (error) {
return '';
}
}
function getFramePage(frame) {
if (!frame || typeof frame.page !== 'function') return null;
try {
return frame.page();
} catch (error) {
return null;
}
}
async function isImportDialogFrame(frame) {
const url = getFrameUrl(frame).toLowerCase();
if (url.includes('/daoru.asp')) return true;
if (!frame || typeof frame.locator !== 'function') return false;
const textarea = frame.locator('textarea#DaoruText, textarea[name="DaoruText"]');
return (await safeCount(textarea)) > 0;
}
async function findImportDialogFrame(frame, timeoutMs = 3000) {
const page = getFramePage(frame);
if (!page || typeof page.frames !== 'function') {
return {
ok: true,
frame,
selector: 'current_frame',
url: getFrameUrl(frame),
name: getFrameName(frame),
};
}
const deadline = Date.now() + timeoutMs;
do {
for (const candidate of page.frames()) {
if (await isImportDialogFrame(candidate)) {
return {
ok: true,
frame: candidate,
selector: 'frame[url*="daoru.asp"]',
url: getFrameUrl(candidate),
name: getFrameName(candidate),
};
}
}
await waitAfterImport(frame, 100);
} while (Date.now() < deadline);
return {
ok: false,
frame: null,
selector: '',
url: '',
name: '',
error: 'import_dialog_frame_not_found',
};
}
async function tryFillImportTextarea(frame, text) {
const result = await fillFirstMatching(frame, [
'textarea#DaoruText',
'textarea[name="DaoruText"]',
'textarea',
], text);
return result.ok ? result : { ...result, error: result.error === 'control_not_found' ? 'import_textarea_not_found' : result.error };
}
async function tryClickConfirmImport(frame) {
const result = await clickFirstMatching(frame, [
'input[name="DaoruB"]',
'input[onclick*="DaoRuDones"]',
'input[type="button"][value*="确定导入"]',
'input[type="submit"][value*="确定导入"]',
'button:has-text("确定导入")',
'a:has-text("确定导入")',
'text=确定导入',
'input[type="button"][value*="纭畾瀵煎叆"]',
'input[type="submit"][value*="纭畾瀵煎叆"]',
'button:has-text("纭畾瀵煎叆")',
'a:has-text("纭畾瀵煎叆")',
'text=纭畾瀵煎叆',
]);
return result.ok ? result : { ...result, error: result.error === 'control_not_found' ? 'confirm_import_button_not_found' : result.error };
}
async function waitAfterImport(frame, timeoutMs = 1200) {
if (frame && typeof frame.waitForTimeout === 'function') {
await frame.waitForTimeout(timeoutMs);
return;
}
const page = frame && typeof frame.page === 'function' ? frame.page() : null;
if (page && typeof page.waitForTimeout === 'function') {
await page.waitForTimeout(timeoutMs);
}
}
function normalizeTravelerTableState(value) {
if (value && typeof value === 'object') {
return {
rowCount: Number(value.rowCount || 0),
filledRows: Number(value.filledRows || 0),
};
}
const count = Number(value || 0);
return { rowCount: count, filledRows: count };
}
async function readTravelerTableState(frame) {
if (!frame || typeof frame.evaluate !== 'function') return { rowCount: 0, filledRows: 0 };
try {
const state = await frame.evaluate(() => {
const writable = (root) => {
if (!root || typeof root.querySelectorAll !== 'function') return [];
const skipTypes = new Set(['hidden', 'button', 'submit', 'reset', 'image', 'file']);
return Array.from(root.querySelectorAll('input, textarea, select'))
.filter((el) => !el.disabled && !skipTypes.has(String(el.type || '').toLowerCase()));
};
const textValue = (el) => String(el && (el.value || el.textContent || '') || '').trim();
const isTravelerContentField = (el) => {
const key = `${el.id || ''} ${el.name || ''}`.toLowerCase();
return /(xingming|pinyinxm|xingbie|shengri|nianling|chushengdi|haoma|qianfadi|fazhengri|youxiaori|dianhua|kbeizhu)/.test(key);
};
const rows = Array.from(document.querySelectorAll('tr'));
const headerIndex = rows.findIndex((row) => {
const text = String(row.innerText || row.textContent || '');
return text.includes('游客信息')
|| text.includes('游客名单')
|| text.includes('名单格式')
|| text.includes('娓稿淇℃伅')
|| text.includes('娓稿鍚嶅崟')
|| text.includes('鍚嶅崟鏍煎紡');
});
if (headerIndex < 0) return { rowCount: 0, filledRows: 0 };
const cellTexts = (row) => Array.from(row.querySelectorAll('td, th'))
.map((cell) => textValue(cell))
.filter((value) => value);
const isReadonlyTravelerRow = (row) => {
const cells = cellTexts(row);
if (cells.length < 8) return false;
if (!/^\d+$/.test(cells[0])) return false;
return cells.slice(1).filter(Boolean).length >= 5;
};
const rowsAfterHeader = rows.slice(headerIndex + 1);
const writableRows = rowsAfterHeader.filter((row) => writable(row).length >= 4);
if (writableRows.length) {
const filledRows = writableRows.filter((row) => writable(row)
.some((el) => isTravelerContentField(el) && textValue(el))).length;
return { rowCount: writableRows.length, filledRows };
}
const readonlyRows = rowsAfterHeader.filter(isReadonlyTravelerRow);
return { rowCount: readonlyRows.length, filledRows: readonlyRows.length };
});
return normalizeTravelerTableState(state);
} catch (error) {
return { rowCount: 0, filledRows: 0 };
}
}
async function readTravelerRowCount(frame) {
const state = await readTravelerTableState(frame);
return state.rowCount;
}
async function waitForTravelerImportState(frame, expectedRows, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
let state = await readTravelerTableState(frame);
while (Date.now() < deadline) {
if (expectedRows > 0 && state.rowCount >= expectedRows && state.filledRows >= expectedRows) {
return state;
}
await waitAfterImport(frame, 200);
state = await readTravelerTableState(frame);
}
return state;
}
function travelerImportVerificationTarget(payload, options, beforeState, expectedRows) {
const appendMode = isSupplementalTravelerImport(payload, options);
const beforeFilledRows = Number(beforeState && beforeState.filledRows || 0);
const expectedAfterFilledRows = appendMode ? beforeFilledRows + expectedRows : expectedRows;
return {
appendMode,
expectedAfterFilledRows,
};
}
async function importTravelerListToFrame(frame, payload = {}, options = {}) {
const list = travelerListFromPayload(payload);
const expectedRows = Number(list && list.totalCount || 0);
if (!list) {
return { attempted: false, imported: false, reason: 'traveler_list_missing' };
}
const formatted = formatTravelerListForErpPaste(list);
const beforeState = await readTravelerTableState(frame);
const beforeRows = beforeState.rowCount;
const beforeFilledRows = beforeState.filledRows;
const verificationTarget = travelerImportVerificationTarget(payload, options, beforeState, expectedRows);
const { appendMode, expectedAfterFilledRows } = verificationTarget;
const clickImport = await tryClickImport(frame);
if (!clickImport.ok) {
return {
attempted: true,
imported: false,
method: 'erp_textarea_paste',
expectedRows,
appendMode,
expectedAfterFilledRows,
beforeRows,
beforeFilledRows,
clickImport,
reason: clickImport.error,
};
}
await waitAfterImport(frame, options.waitForModalMs || 300);
const dialogFrame = await findImportDialogFrame(frame, options.waitForModalFrameMs || 3000);
if (!dialogFrame.ok) {
return {
attempted: true,
imported: false,
method: 'erp_textarea_paste',
expectedRows,
appendMode,
expectedAfterFilledRows,
beforeRows,
beforeFilledRows,
clickImport,
dialogFrame,
reason: dialogFrame.error,
};
}
const fill = await tryFillImportTextarea(dialogFrame.frame, formatted.text);
if (!fill.ok) {
return {
attempted: true,
imported: false,
method: 'erp_textarea_paste',
expectedRows,
appendMode,
expectedAfterFilledRows,
beforeRows,
beforeFilledRows,
clickImport,
dialogFrame,
fill,
reason: fill.error,
};
}
const clickConfirm = await tryClickConfirmImport(dialogFrame.frame);
if (!clickConfirm.ok) {
return {
attempted: true,
imported: false,
method: 'erp_textarea_paste',
expectedRows,
appendMode,
expectedAfterFilledRows,
beforeRows,
beforeFilledRows,
clickImport,
dialogFrame,
fill,
clickConfirm,
reason: clickConfirm.error,
};
}
await waitAfterImport(frame, options.waitAfterImportMs);
const afterState = await waitForTravelerImportState(frame, expectedAfterFilledRows, options.waitForVerificationMs || 5000);
const afterRows = afterState.rowCount;
const afterFilledRows = afterState.filledRows;
const imported = expectedRows > 0 && afterRows >= expectedAfterFilledRows && afterFilledRows >= expectedAfterFilledRows;
return {
attempted: true,
imported,
method: 'erp_textarea_paste',
expectedRows,
appendMode,
expectedAfterFilledRows,
beforeRows,
beforeFilledRows,
afterRows,
afterFilledRows,
pasteRows: formatted.rowCount,
pasteHeaders: formatted.headers,
clickImport,
dialogFrame,
fill,
clickConfirm,
reason: imported ? '' : 'import_not_verified',
};
}
function payloadAfterTravelerImport(payload = {}, importResult = {}) {
if (!importResult.imported) return payload;
const supplemental = { ...(payload.supplemental || {}) };
return {
...payload,
supplemental: {
...supplemental,
travelers: [],
},
};
}
function requireTravelerImportSuccess(payload = {}, importResult = {}) {
const list = travelerListFromPayload(payload);
if (!list) return;
if (importResult && importResult.imported) return;
const reason = String(importResult && (importResult.reason || importResult.error) || 'traveler_import_failed');
const error = new Error(`Traveler list import failed: ${reason}`);
error.code = reason;
error.reason = reason;
error.details = importResult;
error.customerMessage = `游客名单导入失败(${reason}),订单未保存,请处理名单后再提交。`;
throw error;
}
module.exports = {
travelerListFromPayload,
formatTravelerListForErpPaste,
readTravelerTableState,
readTravelerRowCount,
importTravelerListToFrame,
payloadAfterTravelerImport,
requireTravelerImportSuccess,
};