496 lines
15 KiB
JavaScript
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,
|
|
};
|