☰ 补单逻辑管理
系统状态:运行中 ● 👤 admin
补单逻辑管理
修改代码需要操作密码确认;查看、复制不需要密码。保存前自动备份,可回滚最近版本。
"""补单/查单逻辑窄补丁区。 这里保留“只处理已经判断可补单的订单”的原则: 1. 查询:在在线充值列表按订单号查状态,返回订单号/状态/金额/UID等。 2. 补单:点击订单左侧审核 -> 操作类型选择人工到账 -> 备注bd -> Commit/确认/确定。 本版最终逻辑:时间用 JS 写 #create_time 两个输入框并触发 Vue/Arco 事件;UID/订单号 JS 互斥;重置/查询优先真点击,失败再兜底。 如果后台DOM变化,优先在本文件补窄选择器,不要改主流程。 """ from __future__ import annotations import time from datetime import datetime def _cmd_log(msg: str): """CMD实时日志:完整打印,带毫秒时间,方便定位慢点。""" try: ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] print(f"[{ts}] {msg}", flush=True) except Exception: pass def _tick() -> float: return time.perf_counter() def _cost(label: str, start: float): try: _cmd_log(f"[耗时] {label} {int((time.perf_counter() - start) * 1000)}ms") except Exception: pass def _run_log(case_id, action: str, detail: str = "", result: str = "成功"): """CMD 全量打印;后台运行日志只写关键节点。 速度优化:页面行为每一步都写数据库会拖慢查询。 页面行为默认只打 CMD;查询条件/校验/结果/命中/异常写后台。 """ _cmd_log(f"[{action}] {result} {detail}") if not case_id: return # 后台页面日志只保留关键节点,避免每个页面动作都写库拖慢速度。 # CMD 仍然全量打印,方便现场排查。 db_actions = {"查询条件", "查询结果", "查询命中", "查询异常"} if action not in db_actions and result != "失败": return try: from ocr_engine import add_run_log add_run_log(action, detail, result, case_id) except Exception as e: _cmd_log(f"[运行日志写入失败] action={action} error={e}") async def _resolve_query_scope(page, case_id=None): """定位实际查询作用域:优先命中包含在线充值/订单号/查询的 iframe。""" scopes = [page] try: scopes.extend(list(getattr(page, "frames", []) or [])) except Exception: pass best = page best_score = -1 best_name = "page" keywords = ["在线充值", "订单号", "查询", "UID", "状态", "活动类型"] for idx, sc in enumerate(scopes): try: txt = "" try: txt = await sc.locator("body").inner_text(timeout=1200) except Exception: txt = "" low = (txt or "")[:12000] score = sum(1 for k in keywords if k in low) if score > best_score: best_score = score best = sc if idx == 0: best_name = "page" else: best_name = f"frame[{idx}]" except Exception: continue if best is not page: _run_log(case_id, "页面行为", f"查询作用域切换为 {best_name} score={best_score}") else: _run_log(case_id, "页面行为", f"查询作用域使用主页面 score={best_score}") return best async def _js_click_button_by_text(page, texts): """极速 JS 点击按钮:绕过 Playwright actionability/scroll 动画。""" if isinstance(texts, str): texts = [texts] try: return await page.evaluate( """ (texts) => { const visible = (el) => { const r = el.getBoundingClientRect(); const st = window.getComputedStyle(el); return r.width > 0 && r.height > 0 && st.display !== 'none' && st.visibility !== 'hidden'; }; const disabled = (el) => el.disabled || el.getAttribute('disabled') !== null || el.getAttribute('aria-disabled') === 'true' || (el.className || '').toString().includes('disabled'); const btns = Array.from(document.querySelectorAll('button, .arco-btn, .el-button, [role="button"]')); for (let i = btns.length - 1; i >= 0; i--) { const b = btns[i]; const txt = (b.innerText || b.textContent || '').replace(/\s+/g, '').trim(); if (!visible(b) || disabled(b)) continue; if (texts.some(t => txt.includes(String(t).replace(/\s+/g, '')))) { b.click(); return {ok: true, text: txt, index: i}; } } return {ok: false, error: 'button_not_found'}; } """, texts, ) except Exception as e: return {"ok": False, "error": str(e)} async def _click_button_by_text(page, texts, timeout: int = 800): """按钮点击:默认先用 JS click,避免 Playwright actionability/slow_mo 拖慢;失败再真点击兜底。""" if isinstance(texts, str): texts = [texts] # 快速路径:JS 直接点可见按钮。重置/查询/确定都是普通按钮,JS click 能触发 Vue/Arco onClick。 ret = await _js_click_button_by_text(page, texts) if ret.get("ok"): ret["method"] = "js_fast" return ret last_error = ret.get("error") or "" for t in texts: selectors = [ f"button:has-text('{t}')", f".arco-btn:has-text('{t}')", f"[role='button']:has-text('{t}')", ] for sel in selectors: try: loc = page.locator(sel).last if await loc.count(): await loc.click(timeout=timeout) return {"ok": True, "text": t, "method": "playwright_fallback", "selector": sel} except Exception as e: last_error = str(e) return {"ok": False, "error": last_error or "button_not_found"} async def _trigger_query_without_button(page): """查询按钮缺失时兜底触发查询:回车/submit/搜索图标。""" try: ret = await page.evaluate( """ () => { const visible = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); const st = window.getComputedStyle(el); return r.width > 0 && r.height > 0 && st.display !== 'none' && st.visibility !== 'hidden'; }; const editable = (el) => { if (!el) return false; if (el.disabled || el.readOnly) return false; const tp = String(el.type || '').toLowerCase(); if (['hidden', 'file', 'checkbox', 'radio', 'submit', 'button'].includes(tp)) return false; return true; }; const token = (el) => `${el.placeholder || ''} ${el.name || ''} ${el.id || ''} ${el.className || ''}`.toLowerCase(); const allInputs = Array.from(document.querySelectorAll('input, textarea')).filter(i => visible(i) && editable(i)); const orderLike = allInputs.find(i => { const t = token(i); return t.includes('订单') || t.includes('单号') || t.includes('order') || t.includes('trade'); }) || allInputs[0] || null; let enterTriggered = false; if (orderLike) { try { orderLike.focus(); } catch (e) {} try { orderLike.dispatchEvent(new KeyboardEvent('keydown', {bubbles:true, key:'Enter', code:'Enter', keyCode:13, which:13})); } catch (e) {} try { orderLike.dispatchEvent(new KeyboardEvent('keyup', {bubbles:true, key:'Enter', code:'Enter', keyCode:13, which:13})); } catch (e) {} try { orderLike.dispatchEvent(new Event('change', {bubbles:true})); } catch (e) {} enterTriggered = true; } let submitTriggered = false; if (orderLike) { const fm = orderLike.closest('form'); if (fm) { try { if (typeof fm.requestSubmit === 'function') fm.requestSubmit(); else fm.submit(); submitTriggered = true; } catch (e) {} } } let iconClicked = false; const iconSelectors = [ '.arco-input-search-icon', '.arco-icon-search', '.el-icon-search', '[class*="search"]', '[aria-label*="search" i]', '[title*="search" i]', '[title*="查询"]', ]; for (const sel of iconSelectors) { const nodes = Array.from(document.querySelectorAll(sel)).filter(visible); if (!nodes.length) continue; const n = nodes[nodes.length - 1]; try { n.click(); iconClicked = true; break; } catch (e) {} } return { ok: enterTriggered || submitTriggered || iconClicked, enterTriggered, submitTriggered, iconClicked, orderInputFound: !!orderLike, orderToken: orderLike ? token(orderLike) : '', }; } """ ) if ret.get("ok"): ret["method"] = "no_button_fallback" return ret return {"ok": False, "method": "no_button_fallback", **ret} except Exception as e: return {"ok": False, "method": "no_button_fallback", "error": str(e)} async def _js_fast_reset(page, case_id=None) -> bool: """查询前重置:优先真点击重置按钮,失败再 JS 清空关键条件兜底。""" ret = await _click_button_by_text(page, ["重置", "Reset"]) if ret.get("ok"): _run_log(case_id, "页面行为", f"点击重置成功 method={ret.get('method')} text={ret.get('text')}") # 智能等待:只给前端一次短暂重渲染机会,不再固定等几秒。 try: await page.wait_for_timeout(60) except Exception: pass return True # 兜底:重置按钮没点到时,清空这次会影响查询的关键项。 clear_ret = await _js_clear_identity_and_time(page) if clear_ret.get("ok"): _run_log(case_id, "页面行为", f"重置按钮失败,已JS清空关键条件 ret={clear_ret}") return True _run_log(case_id, "页面行为", f"重置失败 error={ret.get('error')} clear_ret={clear_ret}", "失败") return False async def _js_set_time_range(page, start_time: str, end_time: str): """设置【下单时间】:稳定短等待版。 必须先打开 #create_time,再分别 fill 开始/结束并 Enter。 不用纯 evaluate 一把写,避免 Arco activeRange 混乱;不用逐字 type,避免 slow_mo 放大。 """ method = "stable_placeholder_fill" try: start_loc = page.locator("input[placeholder='开始日期']").first end_loc = page.locator("input[placeholder='结束日期']").first await start_loc.click(timeout=1200) await page.keyboard.press("Control+A") await page.keyboard.press("Backspace") await page.keyboard.type(start_time, delay=10) await end_loc.click(timeout=1200) await page.keyboard.press("Control+A") await page.keyboard.press("Backspace") await page.keyboard.type(end_time, delay=10) try: await page.keyboard.press("Enter") except Exception: pass confirm_clicked = False try: confirm = page.locator("button.arco-picker-btn-confirm").last if await confirm.count(): await confirm.click(timeout=800) confirm_clicked = True except Exception: pass await page.wait_for_timeout(100) start_val = (await start_loc.input_value() or "").strip() end_val = (await end_loc.input_value() or "").strip() ok = (start_val == start_time and end_val == end_time) ret = { "ok": ok, "method": method, "confirmClicked": confirm_clicked, "start": start_val, "end": end_val, } if not ok: ret["error"] = f"time_not_applied expected={start_time}~{end_time} actual={start_val}~{end_val}" return ret except Exception as e: return {"ok": False, "error": str(e), "method": method} async def _js_read_time_range(page): """读取页面当前【下单时间】实际显示值:同样按 placeholder 区分左右。""" try: return await page.evaluate( """ () => { const visible = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); const st = window.getComputedStyle(el); return r.width > 0 && r.height > 0 && st.display !== 'none' && st.visibility !== 'hidden'; }; const root = document.querySelector('#create_time, #order_time, [data-name="create_time"], [data-name="order_time"]'); if (!root) return {ok:false, error:'order_time_root_not_found'}; const all = Array.from(root.querySelectorAll('input')).filter(visible); let startInput = root.querySelector('input[placeholder="开始日期"], input[placeholder*="开始"], input[placeholder="Start Date"], input[placeholder*="Start"]') || all[0]; let endInput = root.querySelector('input[placeholder="结束日期"], input[placeholder*="结束"], input[placeholder="End Date"], input[placeholder*="End"]') || all[1]; return { ok: !!(startInput && endInput), start: startInput ? (startInput.value || '') : '', end: endInput ? (endInput.value || '') : '', startPh: startInput ? (startInput.placeholder || '') : '', endPh: endInput ? (endInput.placeholder || '') : '', all: all.map(i => ({ph:i.placeholder || '', value:i.value || ''})) }; } """ ) except Exception as e: return {"ok": False, "error": str(e)} async def _js_clear_identity_and_time(page): """JS清空关键查询条件:UID、订单号、下单时间。只作为重置失败兜底。""" try: return await page.evaluate( """ () => { const setVal = (node, val) => { const proto = node instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; node.focus(); if (setter) setter.call(node, val); else node.value = val; node.setAttribute('value', val); node.dispatchEvent(new InputEvent('input', {bubbles:true, inputType:'deleteContentBackward', data:null})); node.dispatchEvent(new Event('change', {bubbles:true})); node.dispatchEvent(new Event('blur', {bubbles:true})); }; const uid = document.querySelector('#uid input.arco-input, #uid input[placeholder*="UID"], #uid input[type="text"]'); const order = document.querySelector('#order_id input, input[placeholder*="订单号"], input[placeholder*="单号"]'); const times = Array.from(document.querySelectorAll('#create_time input, #order_time input, [data-name="create_time"] input, [data-name="order_time"] input')); if (uid) setVal(uid, ''); if (order) setVal(order, ''); times.forEach(i => setVal(i, '')); return {ok:true, uid: !!uid, order: !!order, timeCount: times.length}; } """ ) except Exception as e: return {"ok": False, "error": str(e)} async def _js_fill_identity(page, order_no: str = "", uid: str = ""): """填写查询身份条件:订单号和 UID 强制互斥。订单号优先。""" order_no = str(order_no or "").strip() uid = str(uid or "").strip() if order_no: uid = "" elif uid: order_no = "" try: return await page.evaluate( """ ({orderNo, uid}) => { const visible = (el) => { const r = el.getBoundingClientRect(); const st = window.getComputedStyle(el); return r.width > 0 && r.height > 0 && st.display !== 'none' && st.visibility !== 'hidden'; }; const editable = (el) => { if (!el) return false; if (el.disabled || el.readOnly) return false; const tp = String(el.type || '').toLowerCase(); if (['hidden', 'file', 'checkbox', 'radio', 'submit', 'button'].includes(tp)) return false; return true; }; const setVal = (node, val) => { if (!node) return false; const proto = node instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; node.scrollIntoView({block:'center', inline:'center'}); try { node.click(); } catch(e) {} node.focus(); if (setter) setter.call(node, ''); else node.value = ''; node.setAttribute('value', ''); try { node.dispatchEvent(new InputEvent('input', {bubbles:true, inputType:'deleteContentBackward', data:null})); } catch(e) { node.dispatchEvent(new Event('input', {bubbles:true})); } node.dispatchEvent(new Event('change', {bubbles:true})); if (setter) setter.call(node, val); else node.value = val; node.setAttribute('value', val); try { node.dispatchEvent(new InputEvent('input', {bubbles:true, inputType: val ? 'insertText' : 'deleteContentBackward', data: val || null})); } catch(e) { node.dispatchEvent(new Event('input', {bubbles:true})); } node.dispatchEvent(new Event('change', {bubbles:true})); node.dispatchEvent(new KeyboardEvent('keydown', {bubbles:true, key:'Enter', code:'Enter', keyCode:13, which:13})); node.dispatchEvent(new KeyboardEvent('keyup', {bubbles:true, key:'Enter', code:'Enter', keyCode:13, which:13})); node.blur(); node.dispatchEvent(new Event('blur', {bubbles:true})); return true; }; const allInputs = Array.from(document.querySelectorAll('input, textarea')).filter(i => visible(i) && editable(i)); const token = (el) => `${el.placeholder || ''} ${el.name || ''} ${el.id || ''} ${el.className || ''}`.toLowerCase(); const isLikelyTime = (el) => { const t = token(el); return t.includes('开始') || t.includes('结束') || t.includes('日期') || t.includes('时间') || t.includes('start') || t.includes('end') || t.includes('date') || t.includes('time'); }; const bySelectors = (sels) => { for (const s of sels) { const n = document.querySelector(s); if (n && visible(n) && editable(n)) return n; } return null; }; let uidInput = bySelectors([ '#uid input.arco-input', '#uid input[placeholder*="UID"]', '#uid input[type="text"]', 'input[name*="uid" i]', 'input[id*="uid" i]', 'input[placeholder*="UID" i]', 'input[name*="member" i]', 'input[id*="member" i]', 'input[placeholder*="member" i]', 'input[name*="user" i]', 'input[id*="user" i]', 'input[placeholder*="user" i]' ]); let orderInput = bySelectors([ '#order_id input', 'input[placeholder*="订单号"]', 'input[placeholder*="单号"]', 'input[name*="order" i]', 'input[id*="order" i]', 'input[placeholder*="order" i]', 'input[name*="orderno" i]', 'input[id*="orderno" i]', 'input[placeholder*="order no" i]', 'input[name*="trade" i]', 'input[id*="trade" i]', 'input[placeholder*="trade" i]' ]); if (!orderInput) { orderInput = allInputs.find(i => { const t = token(i); return !isLikelyTime(i) && (t.includes('订单') || t.includes('单号') || t.includes('order') || t.includes('trade')); }) || null; } if (!uidInput) { uidInput = allInputs.find(i => { const t = token(i); return !isLikelyTime(i) && (t.includes('uid') || t.includes('会员') || t.includes('member') || t.includes('user')); }) || null; } // 最后兜底:仅按订单查询时,选第一个非时间输入框尝试。 if (orderNo && !orderInput) { orderInput = allInputs.find(i => !isLikelyTime(i)) || null; } if (!uidInput && !orderInput) return {ok:false, error:'uid_and_order_inputs_not_found'}; if (orderNo && !orderInput) return {ok:false, error:'order_input_not_found'}; if (uid && !uidInput) return {ok:false, error:'uid_input_not_found'}; // 先同时清空,保证 UID/订单号只能留一个。 if (uidInput) setVal(uidInput, ''); if (orderInput) setVal(orderInput, ''); if (orderNo) setVal(orderInput, orderNo); else if (uid) setVal(uidInput, uid); return { ok:true, mode: orderNo ? 'order_no' : (uid ? 'uid' : 'empty'), orderValue: orderInput ? (orderInput.value || '') : null, uidValue: uidInput ? (uidInput.value || '') : null, orderPh: orderInput ? (orderInput.placeholder || '') : null, uidPh: uidInput ? (uidInput.placeholder || '') : null, orderName: orderInput ? (orderInput.name || '') : null, uidName: uidInput ? (uidInput.name || '') : null }; } """, {"orderNo": order_no, "uid": uid}, ) except Exception as e: return {"ok": False, "error": str(e)} async def _js_fill_order_no(page, order_no: str): """兼容旧调用:按订单号查询时,JS 填订单号并清空 UID。""" try: input_box = page.locator("input[placeholder='多个订单号用逗号分隔']").first if await input_box.count(): await input_box.click(timeout=1200) await input_box.fill(str(order_no), timeout=1200) try: await input_box.press("Enter", timeout=500) except Exception: pass return { "ok": True, "mode": "order_no", "orderValue": str(order_no), "uidValue": "", "method": "stable_order_input", } except Exception: pass return await _js_fill_identity(page, order_no=order_no, uid="") async def _find_order_time_range_inputs(page, case_id=None): """精准定位【下单时间】左右两个输入框,不碰支付时间。""" try: direct = page.locator("#create_time input, #order_time input, [data-name='create_time'] input, [data-name='order_time'] input") cnt = await direct.count() if cnt >= 2: _run_log(case_id, "页面行为", f"按时间容器定位输入框成功 count={cnt}") return direct.nth(0), direct.nth(1) except Exception as e: _run_log(case_id, "页面行为", f"按时间容器定位失败 error={e}", "失败") try: form_inputs = page.locator("xpath=//*[normalize-space()='下单时间' or normalize-space()='Order Time' or normalize-space()='Create Time']/ancestor::*[contains(@class,'el-form-item')][1]//input") cnt = await form_inputs.count() if cnt >= 2: _run_log(case_id, "页面行为", f"定位下单时间输入框成功 count={cnt}") return form_inputs.nth(0), form_inputs.nth(1) except Exception as e: _run_log(case_id, "页面行为", f"按form-item定位下单时间失败 error={e}", "失败") try: start_loc = page.locator("xpath=//*[contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '下单时间') or contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'order time') or contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'create time')]/following::input[1]") end_loc = page.locator("xpath=//*[contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '下单时间') or contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'order time') or contains(translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'create time')]/following::input[2]") if await start_loc.count() and await end_loc.count(): _run_log(case_id, "页面行为", "按following定位下单时间输入框成功") return start_loc.first, end_loc.first except Exception as e: _run_log(case_id, "页面行为", f"按following定位下单时间失败 error={e}", "失败") return None, None async def _click_picker_confirm_button(page, case_id=None) -> bool: """极速点击时间弹层确定/确认按钮。""" ret = await _js_click_button_by_text(page, ["确定", "确认", "OK", "Confirm"]) if ret.get("ok"): _run_log(case_id, "页面行为", f"JS点击时间确定成功 text={ret.get('text')}") return True _run_log(case_id, "页面行为", f"JS点击时间确定失败 error={ret.get('error')}", "失败") return False async def _set_text_value(el, value: str, case_id=None, label: str = "") -> bool: """极速设置 input 值:优先 JS 原生 setter,失败再 fill,不逐字 type。""" async def _value(): try: return (await el.input_value() or "").strip() except Exception: return "" try: await el.evaluate( """ (node, val) => { node.focus(); const proto = node instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; if (setter) setter.call(node, ''); else node.value = ''; node.dispatchEvent(new Event('input', { bubbles: true })); if (setter) setter.call(node, val); else node.value = val; node.dispatchEvent(new Event('input', { bubbles: true })); node.dispatchEvent(new Event('change', { bubbles: true })); node.blur(); } """, value, ) if await _value() == value: return True except Exception as e: _run_log(case_id, "页面行为", f"JS写入{label}失败 error={e}", "失败") try: await el.click(force=True, timeout=300) await el.fill(value, timeout=500) await el.evaluate( """ (node) => { node.dispatchEvent(new Event('input', { bubbles: true })); node.dispatchEvent(new Event('change', { bubbles: true })); node.blur(); } """ ) if await _value() == value: return True except Exception as e: _run_log(case_id, "页面行为", f"fill写入{label}失败 error={e}", "失败") final = await _value() _run_log(case_id, "页面行为", f"写入{label}未生效 期望={value} 实际={final}", "失败") return False async def _read_time_range_by_label_inputs(page, case_id=None): """按“下单时间”标签读取左右输入框当前值。""" start_loc, end_loc = await _find_order_time_range_inputs(page, case_id=case_id) if not start_loc or not end_loc: return {"ok": False, "error": "order_time_inputs_not_found"} try: start_val = (await start_loc.input_value() or "").strip() end_val = (await end_loc.input_value() or "").strip() return { "ok": True, "start": start_val, "end": end_val, "method": "label_inputs_read", } except Exception as e: return {"ok": False, "error": str(e), "method": "label_inputs_read"} async def _set_time_range_by_label_inputs(page, start_time: str, end_time: str, case_id=None): """当 #create_time 不存在时,按“下单时间”标签定位并填写时间范围。""" start_loc, end_loc = await _find_order_time_range_inputs(page, case_id=case_id) if not start_loc or not end_loc: return {"ok": False, "error": "order_time_inputs_not_found", "method": "label_inputs_fill"} ok_start = await _set_text_value(start_loc, start_time, case_id=case_id, label="下单时间开始") ok_end = await _set_text_value(end_loc, end_time, case_id=case_id, label="下单时间结束") if not (ok_start and ok_end): read = await _read_time_range_by_label_inputs(page, case_id=case_id) return { "ok": False, "error": "label_inputs_set_failed", "method": "label_inputs_fill", "read": read, "start": read.get("start") or "", "end": read.get("end") or "", } # 有弹层时尝试点确定;没有也不阻断。 await _click_picker_confirm_button(page, case_id=case_id) read = await _read_time_range_by_label_inputs(page, case_id=case_id) ok = bool(read.get("ok")) and read.get("start") == start_time and read.get("end") == end_time ret = { "ok": ok, "method": "label_inputs_fill", "read": read, "start": read.get("start") or "", "end": read.get("end") or "", } if not ok: ret["error"] = f"time_not_applied expected={start_time}~{end_time} actual={ret.get('start')}~{ret.get('end')}" return ret async def _js_set_time_range_loose(page, start_time: str, end_time: str): """全页面兜底:宽松识别时间输入框并写入开始/结束时间。""" method = "global_loose_time_inputs" try: ret = await page.evaluate( """ ({start, end}) => { const visible = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); const st = window.getComputedStyle(el); return r.width > 0 && r.height > 0 && st.display !== 'none' && st.visibility !== 'hidden'; }; const setVal = (node, val) => { if (!node) return false; const proto = node instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; node.scrollIntoView({block:'center', inline:'center'}); try { node.click(); } catch(e) {} node.focus(); if (setter) setter.call(node, val); else node.value = val; node.setAttribute('value', val); try { node.dispatchEvent(new InputEvent('input', {bubbles:true, inputType:'insertText', data: val})); } catch(e) { node.dispatchEvent(new Event('input', {bubbles:true})); } node.dispatchEvent(new Event('change', {bubbles:true})); node.dispatchEvent(new KeyboardEvent('keydown', {bubbles:true, key:'Enter', code:'Enter', keyCode:13, which:13})); node.dispatchEvent(new KeyboardEvent('keyup', {bubbles:true, key:'Enter', code:'Enter', keyCode:13, which:13})); node.blur(); node.dispatchEvent(new Event('blur', {bubbles:true})); return true; }; const isTimeField = (el) => { const ph = String(el.placeholder || '').toLowerCase(); const nm = String(el.name || '').toLowerCase(); const id = String(el.id || '').toLowerCase(); const cl = String(el.className || '').toLowerCase(); const key = `${ph} ${nm} ${id} ${cl}`; return ( key.includes('开始') || key.includes('结束') || key.includes('日期') || key.includes('时间') || key.includes('start') || key.includes('end') || key.includes('date') || key.includes('time') || key.includes('create_time') || key.includes('order_time') ); }; const allVisibleInputs = Array.from(document.querySelectorAll('input')).filter(visible); const keywordInputs = allVisibleInputs.filter(isTimeField); const candidates = keywordInputs.length >= 2 ? keywordInputs : allVisibleInputs; if (candidates.length < 2) { return {ok:false, error:'loose_time_inputs_not_found', candidateCount:candidates.length}; } const pickStart = candidates.find(i => { const t = `${i.placeholder || ''} ${i.name || ''} ${i.id || ''}`.toLowerCase(); return t.includes('开始') || t.includes('start'); }); const pickEnd = candidates.find(i => { const t = `${i.placeholder || ''} ${i.name || ''} ${i.id || ''}`.toLowerCase(); return t.includes('结束') || t.includes('end'); }); let startInput = pickStart || candidates[0]; let endInput = pickEnd || candidates[1] || candidates[0]; if (startInput === endInput && candidates.length > 1) endInput = candidates[1]; setVal(startInput, start); setVal(endInput, end); const btns = Array.from(document.querySelectorAll('button, .arco-btn, .el-button, [role="button"]')).filter(visible); let confirmText = ''; for (let i = btns.length - 1; i >= 0; i--) { const txt = (btns[i].innerText || btns[i].textContent || '').replace(/\s+/g, '').trim(); if (txt.includes('确定') || txt.includes('确认') || txt.toUpperCase().includes('OK') || txt.toLowerCase().includes('confirm')) { try { btns[i].click(); confirmText = txt; } catch(e) {} break; } } const sVal = String(startInput.value || '').trim(); const eVal = String(endInput.value || '').trim(); return { ok: sVal === start && eVal === end, start: sVal, end: eVal, confirmText, candidates: candidates.slice(0, 8).map(i => ({ ph: i.placeholder || '', name: i.name || '', id: i.id || '', cls: i.className || '', value: i.value || '', })) }; } """, {"start": start_time, "end": end_time}, ) if ret.get("ok"): ret["method"] = method return ret return {"ok": False, "method": method, **ret} except Exception as e: return {"ok": False, "method": method, "error": str(e)} async def _clear_backend_query_form(page, case_id=None) -> bool: """查询前重置:JS点击,不使用 Playwright click 等待。""" return await _js_fast_reset(page, case_id=case_id) async def _fill_backend_time_range(page, start_time: str = '', end_time: str = '', case_id=None) -> bool: """极限速度填写【下单时间】:JS 直接赋值 + JS 点确定。""" _t_total = _tick() _run_log(case_id, "查询条件", f"准备设置下单时间 start={start_time} end={end_time}") if not start_time or not end_time: _run_log(case_id, "查询条件", "start_time/end_time为空,跳过时间设置", "失败") return False try: _t = _tick() await _clear_backend_query_form(page, case_id=case_id) _cost("JS查询前重置", _t) _t = _tick() ret = await _js_set_time_range(page, start_time, end_time) _cost("JS填写下单时间", _t) used_method = ret.get("method") or "" if not ret.get("ok"): _run_log(case_id, "页面行为", f"JS填写下单时间失败 ret={ret}", "失败") fb_ret = await _set_time_range_by_label_inputs(page, start_time, end_time, case_id=case_id) if not fb_ret.get("ok"): _run_log(case_id, "页面行为", f"标签定位填写下单时间失败 ret={fb_ret}", "失败") loose_ret = await _js_set_time_range_loose(page, start_time, end_time) if not loose_ret.get("ok"): _run_log(case_id, "页面行为", f"宽松定位填写下单时间失败 ret={loose_ret}", "失败") return False ret = loose_ret used_method = ret.get("method") or used_method _run_log(case_id, "页面行为", f"宽松定位填写下单时间成功 start={ret.get('start')!r} end={ret.get('end')!r}") else: ret = fb_ret used_method = ret.get("method") or used_method _run_log(case_id, "页面行为", f"标签定位填写下单时间成功 start={ret.get('start')!r} end={ret.get('end')!r}") else: _run_log(case_id, "页面行为", f"JS填写下单时间 start={ret.get('start')!r} end={ret.get('end')!r}") _run_log(case_id, "页面行为", f"下单时间填写完成 method={used_method or '-'} ret={ret}") # 智能等待:值达到目标马上继续,不固定等 350ms。 try: await page.wait_for_function( """(args) => { const root = document.querySelector('#create_time, #order_time, [data-name="create_time"], [data-name="order_time"]'); if (!root) return false; const st = root.querySelector('input[placeholder="开始日期"], input[placeholder*="开始"], input[placeholder="Start Date"], input[placeholder*="Start"]'); const et = root.querySelector('input[placeholder="结束日期"], input[placeholder*="结束"], input[placeholder="End Date"], input[placeholder*="End"]'); return st && et && st.value === args.start && et.value === args.end; }""", {"start": start_time, "end": end_time}, timeout=150, ) except Exception: pass read_ret = await _js_read_time_range(page) if not read_ret.get("ok"): if used_method.startswith("label_inputs"): read_ret = await _read_time_range_by_label_inputs(page, case_id=case_id) elif used_method.startswith("global_loose"): # 宽松模式没有固定容器时,优先使用本次写入返回值做校验基线。 read_ret = { "ok": bool(ret.get("ok")), "start": ret.get("start") or "", "end": ret.get("end") or "", "method": "global_loose_return", "source": ret, } final_start = (read_ret.get('start') or ret.get('start') or '').strip() final_end = (read_ret.get('end') or ret.get('end') or '').strip() _run_log(case_id, "页面校验", f"下单时间最终值 start={final_start!r} end={final_end!r} read={read_ret}") # 如果发现开始框等于结束时间,通常就是结束时间写到左框/右框未写入,立刻重写一次。 if final_start != start_time or final_end != end_time: _run_log(case_id, "页面校验", f"第一次时间未生效,准备重新打开弹窗重写 期望={start_time}~{end_time} 实际={final_start}~{final_end}", "失败") retry_ret = await _js_set_time_range(page, start_time, end_time) if not retry_ret.get("ok"): retry_ret = await _set_time_range_by_label_inputs(page, start_time, end_time, case_id=case_id) if not retry_ret.get("ok"): retry_ret = await _js_set_time_range_loose(page, start_time, end_time) try: await page.wait_for_function( """(args) => { const root = document.querySelector('#create_time, #order_time, [data-name="create_time"], [data-name="order_time"]'); if (!root) return false; const st = root.querySelector('input[placeholder="开始日期"], input[placeholder*="开始"], input[placeholder="Start Date"], input[placeholder*="Start"]'); const et = root.querySelector('input[placeholder="结束日期"], input[placeholder*="结束"], input[placeholder="End Date"], input[placeholder*="End"]'); return st && et && st.value === args.start && et.value === args.end; }""", {"start": start_time, "end": end_time}, timeout=150, ) except Exception: pass read_ret = await _js_read_time_range(page) if not read_ret.get("ok"): if (retry_ret.get("method") or "").startswith("label_inputs"): read_ret = await _read_time_range_by_label_inputs(page, case_id=case_id) elif (retry_ret.get("method") or "").startswith("global_loose"): read_ret = { "ok": bool(retry_ret.get("ok")), "start": retry_ret.get("start") or "", "end": retry_ret.get("end") or "", "method": "global_loose_return", "source": retry_ret, } final_start = (read_ret.get('start') or retry_ret.get('start') or '').strip() final_end = (read_ret.get('end') or retry_ret.get('end') or '').strip() _run_log(case_id, "页面校验", f"重写后下单时间 start={final_start!r} end={final_end!r} retry={retry_ret} read={read_ret}") if final_start != start_time or final_end != end_time: _run_log(case_id, "页面校验", f"时间未生效 期望={start_time}~{end_time} 实际={final_start}~{final_end}", "失败") return False _run_log(case_id, "页面校验", f"时间范围确认成功 {final_start} ~ {final_end}") _cost("设置下单时间总耗时", _t_total) return True except Exception as e: _run_log(case_id, "页面行为", f"设置下单时间异常 error={e}", "失败") return False def _parse_backend_pay_time(pay_time: str): """严格解析后台查询时间。 修复点:之前用 v[:len(fmt)],len(fmt) 是格式字符串长度,不是时间文本长度, 会导致 2026-04-22 13:09:00 解析失败,然后误用 datetime.now()。 现在解析失败直接返回 None,不再静默使用当前时间。 """ from datetime import datetime if not pay_time: return None v = str(pay_time).strip().replace('T', ' ').replace('/', '-') if len(v) >= 19: try: return datetime.strptime(v[:19], '%Y-%m-%d %H:%M:%S') except Exception: pass if len(v) >= 16: try: return datetime.strptime(v[:16], '%Y-%m-%d %H:%M') except Exception: pass return None def _calc_time_range(pay_time: str = '', range_mode: str = 'prev6') -> tuple[str, str]: """计算后台【下单时间】查询范围。 最终规则: 1. OCR 时间优先;OCR 没有时由 backend_browser 传入订单号解析时间。 2. OCR 和订单号解析时间统一用:前 6 分钟 ~ 当前分钟结束。 3. 没有可用时间时直接报错,不允许用 datetime.now() 兜底,避免查成当前时间。 """ from datetime import timedelta base = _parse_backend_pay_time(pay_time) if base is None: raise ValueError(f"没有可用后台查询时间,禁止使用当前时间兜底 pay_time={pay_time!r}") # 统一按分钟查,避免秒级边界漏单。 minute_start = base.replace(second=0, microsecond=0) minute_end = base.replace(second=59, microsecond=0) if range_mode in ('prev6', 'prev5', 'order_no_window', 'prev10', 'day', 'days7', 'days90', ''): st = minute_start - timedelta(minutes=6) et = minute_end else: # 未知模式也走统一规则,但日志里会显示传入的 mode,方便排查。 st = minute_start - timedelta(minutes=6) et = minute_end return st.strftime('%Y-%m-%d %H:%M:%S'), et.strftime('%Y-%m-%d %H:%M:%S') async def _dump_rows_preview(page, max_rows: int = 8): try: rows = await page.evaluate( """ (maxRows) => { const selectors = [ '.el-table__body-wrapper table tbody tr', '.el-table__body tbody tr', 'table tbody tr', '.ant-table-tbody > tr' ]; let trs = []; for (const sel of selectors) { trs = Array.from(document.querySelectorAll(sel)); if (trs.length) break; } return trs.slice(0, maxRows).map(tr => Array.from(tr.querySelectorAll('td')).map(td => (td.textContent || '').replace(/\s+/g,' ').trim())).filter(r => r.length); } """, max_rows, ) return rows or [] except Exception: return [] def _rows_signature(rows) -> str: if not rows: return "" try: return "###".join("|".join(map(str, r[:6])) for r in rows[:3]) except Exception: return str(rows[:3]) async def _wait_query_result_fast(page, order_no: str, before_rows=None, timeout_ms: int = 3000): """只在点击查询后等待;目标订单一出现马上返回。 不做固定等待,不等完整加载。每 50ms 看一次表格; 命中目标订单或表格内容变化就立刻返回。 """ before_sig = _rows_signature(before_rows or []) rows = await _dump_rows_preview(page, max_rows=10) if rows and order_no and order_no in str(rows): return rows step = 50 waited = 0 last_rows = rows or [] while waited <= timeout_ms: rows = await _dump_rows_preview(page, max_rows=10) if rows: last_rows = rows joined = str(rows) sig = _rows_signature(rows) if order_no and order_no in joined: return rows if sig and before_sig and sig != before_sig: return rows await page.wait_for_timeout(step) waited += step return last_rows def _extract_uid_from_text(uid_text: str) -> str: """运营后台UID常见格式:25025583(Play...),只取括号前数字。""" import re s = str(uid_text or '').strip() if '(' in s: left = s.split('(', 1)[0].strip() if left.isdigit(): return left m = re.search(r"\d{5,}", s) return m.group(0) if m else "" async def query_order_status_on_page(page, order_no: str, pay_time: str = '', range_mode: str = 'day', case_id=None) -> dict: """订单查询。所有查询必须先设置下单时间范围,再按订单号查询。 本函数同时输出两套日志: - CMD实时日志:方便现场看浏览器行为。 - 后台运行日志:方便页面里查看行为/报错/查询条件/结果。 """ _query_total_t = _tick() result = { "order_no": order_no, "status": "unknown", "matched_order_no": "", "amount": None, "uid": "", "raw_text": "", "time_range_applied": "", "rows_preview": [], } try: scope = await _resolve_query_scope(page, case_id=case_id) _run_log(case_id, "后台查询开始", f"order={order_no} pay_time={pay_time or '-'} range_mode={range_mode}") start_time, end_time = _calc_time_range(pay_time, range_mode) _run_log(case_id, "查询条件", f"计算后下单时间 start={start_time} end={end_time} mode={range_mode} 原始pay_time={pay_time or '-'}") if not start_time or not end_time: result["status"] = "error" result["error"] = f"pay_time解析失败,已阻止查询,pay_time={pay_time!r} mode={range_mode}" _run_log(case_id, "查询异常", result["error"], "失败") return result _t = _tick() applied = await _fill_backend_time_range(scope, start_time, end_time, case_id=case_id) _cost("填充下单时间返回", _t) result["time_range_applied"] = f"{start_time} ~ {end_time}" if applied else f"未确认填入:{start_time} ~ {end_time}" if not applied: result["status"] = "error" result["error"] = "下单时间未确认生效,已阻止查询" _run_log(case_id, "查询异常", result["error"], "失败") return result _t = _tick() order_ret = await _js_fill_order_no(scope, order_no) _cost("JS填写订单号", _t) if not order_ret.get("ok") or (order_ret.get("orderValue") or "").strip() != str(order_no) or (order_ret.get("uidValue") or "").strip(): result["status"] = "error" result["error"] = f"订单号/UID互斥输入失败 ret={order_ret}" _run_log(case_id, "查询异常", result["error"], "失败") return result _run_log(case_id, "页面行为", f"JS填写订单号并清空UID order={order_ret.get('orderValue')!r} uid={order_ret.get('uidValue')!r}") # 速度优化:不再查询前固定读表格签名,直接点击查询后等目标订单出现。 before_rows = [] _run_log(case_id, "页面行为", "准备点击查询按钮") _t = _tick() qret = await _click_button_by_text(scope, ["查询", "搜索", "Search"]) _cost("点击查询按钮", _t) if not qret.get("ok"): fb_qret = await _trigger_query_without_button(scope) if not fb_qret.get("ok"): result["status"] = "error" result["error"] = f"点击查询失败 ret={qret} fallback={fb_qret}" _run_log(case_id, "查询异常", result["error"], "失败") return result qret = fb_qret _run_log(case_id, "页面行为", f"已点击查询按钮 method={qret.get('method')} text={qret.get('text')}") _t = _tick() rows = await _wait_query_result_fast(scope, order_no, before_rows=before_rows, timeout_ms=3000) _cost("点击查询后等待结果", _t) result["rows_preview"] = rows _run_log(case_id, "查询结果", f"表格预览行数={len(rows)}") for idx, row in enumerate(rows[:5], 1): _run_log(case_id, "查询明细", f"row{idx}={row}") matched_row = None for row in rows: if any(str(order_no) in str(cell) for cell in row): matched_row = row break if matched_row: result["matched_order_no"] = order_no joined = " | ".join(matched_row) result["matched_row"] = matched_row # 运营后台表格固定结构: # row[1] = 订单号,row[2] = UID(括号昵称),row[9] = 状态。 # 之前扫描整行会把订单号里的数字误当 UID,这里改成只取 row[2]。 uid_text = matched_row[2] if len(matched_row) > 2 else "" result["uid"] = _extract_uid_from_text(uid_text) status_text = (matched_row[9] if len(matched_row) > 9 else "").strip() result["backend_status_text"] = status_text if status_text == "人工到账": result["status"] = "manual_success" elif status_text == "已支付": result["status"] = "success" elif status_text in {"失败", "已失败"} or "fail" in status_text.lower(): result["status"] = "fail" else: result["status"] = "not_success" _run_log(case_id, "查询命中", f"order={order_no} uid={result.get('uid') or '-'} backend_status={status_text or '-'} status={result.get('status')}") _cost("后台查询总耗时", _query_total_t) return result raw = "" try: raw = await scope.locator("body").inner_text(timeout=1500) except Exception: pass result["raw_text"] = raw[:3000] if order_no and order_no in raw: result["matched_order_no"] = order_no low = raw.lower() if "人工到账" in raw: result["status"] = "manual_success" elif "已支付" in raw or "成功" in raw or "success" in low or "paid" in low: result["status"] = "success" elif "失败" in raw or "fail" in low: result["status"] = "fail" else: result["status"] = "not_success" _run_log(case_id, "查询结果", f"未在预览行命中订单,body_contains_order={bool(order_no and order_no in raw)} status={result['status']}") _cost("后台查询总耗时", _query_total_t) return result except Exception as e: result["status"] = "error" result["error"] = str(e) _run_log(case_id, "查询异常", f"order={order_no} error={e}", "失败") _cost("后台查询总耗时", _query_total_t) return result async def perform_manual_reissue(page, order_no: str, remark: str = "bd") -> dict: """点击审核 -> 人工到账 -> 备注bd -> Commit/确认/确定。""" try: # 尝试先定位订单所在行,再点左侧审核;找不到行时兜底点页面上的审核。 row = None if order_no and await page.locator(f"tr:has-text('{order_no}')").count(): row = page.locator(f"tr:has-text('{order_no}')").first target = row if row else page clicked = False for sel in ["text=审核", "button:has-text('审核')", "a:has-text('审核')"]: loc = target.locator(sel) if await loc.count(): await loc.first.click() clicked = True break if not clicked: raise RuntimeError("未找到审核按钮") await page.wait_for_timeout(800) dialog = page.locator(".el-dialog:visible, .modal:visible, [role='dialog']:visible").first if not await dialog.count(): dialog = page # 操作类型下拉。 for sel in ["text=请选择", ".el-select", "input[placeholder*='请选择']"]: if await dialog.locator(sel).count(): await dialog.locator(sel).first.click() await page.wait_for_timeout(300) break for sel in ["text=人工到账", ".el-select-dropdown:visible text=人工到账"]: if await page.locator(sel).count(): await page.locator(sel).last.click() await page.wait_for_timeout(300) break # 备注 bd。 filled = False for sel in ["textarea[placeholder*='备注']", "input[placeholder*='备注']", "textarea", "input[name*='remark']"]: if await dialog.locator(sel).count(): await dialog.locator(sel).first.fill(remark) filled = True break # 有些表单备注不强制,没找到不阻断。 btn = dialog.locator("button:has-text('Commit'), button:has-text('确认'), button:has-text('确定'), button:has-text('提交')") if not await btn.count(): btn = page.locator("button:has-text('Commit'), button:has-text('确认'), button:has-text('确定'), button:has-text('提交')") await btn.first.click() await page.wait_for_timeout(1500) return {"ok": True, "order_no": order_no, "remark_filled": filled} except Exception as e: return {"ok": False, "order_no": order_no, "error": str(e)}
保存代码
重新加载
最近备份
文件
时间
操作
暂无备份