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, };