# 返利送审节点:run_submit_flow。登录/查号/勾行在 rebate_common。 from __future__ import annotations import re import time from accept import AcceptanceReport, evidence_shot from rebate_common import ( SUBMIT_FLOW, BrowserAdapter, RebateFlowContext, RebateSubmitResult, open_rebate_center, login_rebate_center, _switch_agreement_frame, enter_purchase_agreement, search_contract, _OPEN_HEADER_ROW, _cell, _click_toolbar, _click_header_toolbar, _native_click_header_toolbar, _scan_rebate_order_no, _clean_order_no, _click_text, select_header_row, _close_header_query, _SELECT_HEADER_ROW, ) _SUBMIT_ACCEPT = AcceptanceReport(flow=SUBMIT_FLOW) _SUBMIT_TITLES = ("送审", "提交送审", "提交审批") _SUBMIT_FAIL_MARKS = ("送审失败", "提交失败", "请先导入") _ALREADY_SUBMIT_MARKS = ("已送审", "已经送审", "不允许送审", "不能送审", "不可重复") _DETAIL_TAB_TITLES = ("协议细单", "细单", "明细") _SUBMIT_ID_LABELS = { "总单号", "采购协议号", "协议编号", "采购协议总单ID", "协议总单号", "采购协议ID", "协议总单ID", "协议细单ID", "采购协议细单ID", "细单号", "细单ID", "货源细单ID", } _BUSY_MARKS = ("加载中", "查询加载", "设置查询条件", "自定义查询") _ID_RE = re.compile(r"^[A-Za-z0-9_-]{3,40}$") _DTL_LABEL_RE = re.compile( r"(?:采购协议细单ID|协议细单ID)[::\s]*([A-Za-z0-9_-]{3,40})" ) _CONFIRM_SUBMIT = """ /* CONFIRM_SUBMIT */ const marks = ['确定送审', '确认送审', '是否送审', '确定提交', '确认提交', '是否提交']; const body = (document.body && document.body.innerText) || ''; const asked = marks.some((m) => body.indexOf(m) >= 0) || !!document.querySelector('.el-message-box, .el-dialog__wrapper, .vxe-modal--wrapper, .ant-modal'); if (!asked) { return {ok: false, asked: false}; } const btn = Array.from(document.querySelectorAll('button, .el-button, span')).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); return (t === '确定' || t === '确认' || t === '是') && (el.offsetWidth || el.offsetHeight); }); if (!btn) { return {ok: false, asked: true}; } btn.click(); return {ok: true, asked: true}; """ _READ_AGREEMENT_NOS = """ /* READ_AGREEMENT_NOS */ const no = String(arguments[0] || '').trim(); const headerNames = ['协议总单ID', '采购协议总单ID', '采购协议号', '总单号', '协议编号', '协议总单号', '采购协议ID']; const detailNames = ['协议细单ID', '采购协议细单ID', '协议明细ID', '细单号', '细单ID', '明细ID']; function compact(el) { return ((el && (el.innerText || el.value || el.textContent)) || '').replace(/\\s+/g, ' ').trim(); } function isId(t) { return /^[A-Za-z0-9_-]{3,40}$/.test(t || ''); } function fromVxe(names, requireNo) { const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ title: (th.innerText || '').replace(/\\s+/g, '').trim(), colid: th.getAttribute('colid'), })); const rows = Array.from(document.querySelectorAll('.vxe-body--row')); for (const name of names) { const h = heads.find((x) => x.title === name); if (!h || !h.colid) { continue; } for (const row of rows) { const text = (row.innerText || ''); if (requireNo && no && text.indexOf(no) < 0) { continue; } const c = row.querySelector('[colid="' + h.colid + '"]'); const t = compact(c); if (isId(t)) { return t; } } if (!requireNo) { const cells = document.querySelectorAll('.vxe-body--row [colid="' + h.colid + '"]'); for (const c of cells) { const t = compact(c); if (isId(t)) { return t; } } } } return ''; } function fromElTable(names, requireNo) { const tables = Array.from(document.querySelectorAll('table, .el-table')); for (const table of tables) { const ths = Array.from(table.querySelectorAll('th')); const titles = ths.map((th) => (th.innerText || '').replace(/\\s+/g, '').trim()); const rows = Array.from(table.querySelectorAll('tbody tr, .el-table__body tr')); for (const name of names) { const idx = titles.indexOf(name); if (idx < 0) { continue; } for (const row of rows) { const text = (row.innerText || ''); if (requireNo && no && text.indexOf(no) < 0) { continue; } const cells = row.querySelectorAll('td'); const t = compact(cells[idx]); if (isId(t)) { return t; } } } } return ''; } function fromTitleContains(needles, skip) { const heads = Array.from(document.querySelectorAll('.vxe-header--column')).map((th) => ({ title: (th.innerText || '').replace(/\\s+/g, '').trim(), colid: th.getAttribute('colid'), })); for (const h of heads) { if (!h.colid) { continue; } if (skip.some((s) => h.title.indexOf(s) >= 0)) { continue; } if (!needles.some((n) => h.title.indexOf(n) >= 0)) { continue; } const cells = document.querySelectorAll('.vxe-body--row [colid="' + h.colid + '"]'); for (const c of cells) { const t = compact(c); if (isId(t)) { return t; } } } return ''; } function fromLabel(names) { for (const label of names) { const nodes = Array.from(document.querySelectorAll('label, td, th, span')); const node = nodes.find((el) => { const raw = (el.childNodes && el.childNodes.length === 1) ? (el.textContent || '') : (el.innerText || ''); const t = raw.replace(/\\s+/g, '').replace(/[*::]/g, ''); return t === label && t.length <= 20; }); if (!node) { continue; } let value = ''; if (node.tagName === 'LABEL' && node.htmlFor) { value = compact(document.getElementById(node.htmlFor)); } const next = node.nextElementSibling; if (!value && next) { value = compact(next.querySelector('input, span, a') || next); } const cell = node.closest('td, th'); if (!value && cell && cell.nextElementSibling) { value = compact(cell.nextElementSibling.querySelector('input, span, a') || cell.nextElementSibling); } if (isId(value)) { return value; } } return ''; } const titles = Array.from(document.querySelectorAll('.vxe-header--column, th')).map((th) => { return (th.innerText || '').replace(/\\s+/g, '').trim(); }).filter(Boolean); const header = fromVxe(headerNames, true) || fromElTable(headerNames, true) || fromLabel(headerNames) || fromVxe(headerNames, false) || fromElTable(headerNames, false); const detail = fromVxe(detailNames, false) || fromElTable(detailNames, false) || fromLabel(detailNames) || fromTitleContains(['细单', '明细'], ['货源', '备注', '日期']); return {header: header, detail: detail, titles: titles}; """ # 丢掉列名、合同号本身、忙碌文案,只留像协议总单ID/细单ID的串。 def _clean_submit_id(raw: object, *, contract_no: str = "") -> str: text = _clean_order_no(raw, contract_no=contract_no) if not text: return "" if text in _SUBMIT_ID_LABELS: return "" if contract_no and text == contract_no.strip(): return "" if any(mark in text for mark in _BUSY_MARKS): return "" if not _ID_RE.fullmatch(text): return "" return text # 先按合同编号勾中总单行;勾不到再双击打开。 def _select_or_open_row(browser: BrowserAdapter, contract_no: str) -> None: _switch_agreement_frame(browser) if select_header_row(browser, contract_no): return selected = None try: selected = browser.execute_script(_SELECT_HEADER_ROW, contract_no) except Exception: selected = None if isinstance(selected, dict) and selected.get("ok"): return try: browser.execute_script(_OPEN_HEADER_ROW, contract_no) except Exception: pass # 送审弹出确认框时点确定。 def _confirm_if_asked(browser: BrowserAdapter) -> None: result = None try: result = browser.execute_script(_CONFIRM_SUBMIT) except Exception: result = None if isinstance(result, dict) and result.get("ok"): return if isinstance(result, dict) and result.get("asked"): for title in ("确定", "确认", "是"): if _click_text(browser, title): return # 点总单工具栏「送审」。点到则返回按钮文案,点不到返回空。 def click_rebate_submit(browser: BrowserAdapter) -> str: _switch_agreement_frame(browser) for title in _SUBMIT_TITLES: if _click_header_toolbar(browser, title): _native_click_header_toolbar(browser, title) _confirm_if_asked(browser) return title for title in _SUBMIT_TITLES: if _click_toolbar(browser, title): _confirm_if_asked(browser) return title for title in _SUBMIT_TITLES: if _click_text(browser, title): _confirm_if_asked(browser) return title return "" # 正文是否含给定标记之一。 def _body_has(browser: BrowserAdapter, marks: tuple[str, ...]) -> bool: body = browser.read_body_text() or "" return any(mark in body for mark in marks) # 正文里是否出现送审失败标记,有则返回那句。 def _submit_error(browser: BrowserAdapter) -> str: body = browser.read_body_text() or "" for mark in _SUBMIT_FAIL_MARKS: if mark in body: return mark return "" # 双击总单行打开查看,方便读协议号。 def _open_header_view(browser: BrowserAdapter, contract_no: str) -> None: _switch_agreement_frame(browser) try: browser.execute_script(_OPEN_HEADER_ROW, contract_no) except Exception: pass # 切到细单/货品页签,好读协议细单ID。 def _reveal_detail_tab(browser: BrowserAdapter) -> None: _switch_agreement_frame(browser) for title in _DETAIL_TAB_TITLES: if _click_toolbar(browser, title): return try: clicked = browser.execute_script( """ const title = String(arguments[0] || '').replace(/\\s+/g, ''); const hit = Array.from(document.querySelectorAll( '[role="tab"], .el-tabs__item, .el-radio-button, li, span, a, div' )).find((el) => { const t = ((el.innerText || '') + '').replace(/\\s+/g, '').trim(); return t === title && (el.offsetWidth || el.offsetHeight); }); if (!hit) { return {ok: false}; } hit.click(); return {ok: true}; """, title, ) except Exception: clicked = None if isinstance(clicked, dict) and clicked.get("ok"): return # 从表格或标签扫协议总单ID和协议细单ID。 def _scan_agreement_nos(browser: BrowserAdapter, *, contract_no: str = "") -> tuple[str, str, str]: header = "" detail = "" titles = "" try: raw = browser.execute_script(_READ_AGREEMENT_NOS, contract_no) except Exception: raw = None if isinstance(raw, dict): header = _clean_submit_id(raw.get("header"), contract_no=contract_no) detail = _clean_submit_id(raw.get("detail"), contract_no=contract_no) titles = ",".join(_cell(x) for x in (raw.get("titles") or []) if _cell(x)) if not header: header = _clean_submit_id( _scan_rebate_order_no(browser, contract_no=contract_no), contract_no=contract_no, ) if not detail: body = browser.read_body_text() or "" match = _DTL_LABEL_RE.search(body) if match: detail = _clean_submit_id(match.group(1), contract_no=contract_no) return header, detail, titles # 把表格滚到最右,避免协议号列被挡住。 def _scroll_grid_end(browser: BrowserAdapter) -> None: try: browser.execute_script( """ const wraps = Array.from(document.querySelectorAll( '.vxe-table--body-wrapper, .vxe-table--header-wrapper, .vxe-table--body-inner-wrapper' )); wraps.forEach((w) => { w.scrollLeft = w.scrollWidth; }); return wraps.length; """ ) except Exception: return # 等到能读到协议总单ID和细单ID。列表上一时看不见就再打开行或切页签。 def read_agreement_nos(browser: BrowserAdapter, *, contract_no: str = "") -> tuple[str, str, str]: deadline = time.time() + 15 last = ("", "", "") opened = False revealed = False scrolled = False while time.time() <= deadline: last = _scan_agreement_nos(browser, contract_no=contract_no) if last[0] and last[1]: return last if not scrolled: _scroll_grid_end(browser) scrolled = True elif not opened: _open_header_view(browser, contract_no) opened = True elif last[0] and not last[1] and not revealed: _reveal_detail_tab(browser) revealed = True time.sleep(0.4) return last # 查号 → 勾总单 → 点送审。马上能见号则带回。 # # 细单必须已保存。写 ACCEPT SUMMARY REBATE_SUBMIT。 def run_submit_flow( browser: BrowserAdapter, ctx: RebateFlowContext, contract_no: str, ) -> RebateSubmitResult: _SUBMIT_ACCEPT.reset() result = RebateSubmitResult(False) try: open_rebate_center(browser, ctx) _SUBMIT_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True) try: login_rebate_center(browser, ctx) _SUBMIT_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True) except Exception as exc: evidence_shot(browser, "rebate-submit-login-failed", failed=True) _SUBMIT_ACCEPT.note( "S03", "登录", {"user": ctx.username, "error": exc}, ok=False, raise_error=False, ) raise enter_purchase_agreement(browser) _SUBMIT_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True) found = search_contract(browser, contract_no) _SUBMIT_ACCEPT.note( "S05", "查号", {"contractNo": contract_no, "found": "yes" if found else "no"}, ok=found, ) _close_header_query(browser) _select_or_open_row(browser, contract_no) _SUBMIT_ACCEPT.note("S06", "选总单", {"contractNo": contract_no}, ok=True) before_header, before_detail, _ = _scan_agreement_nos(browser, contract_no=contract_no) clicked = click_rebate_submit(browser) error = _submit_error(browser) if clicked else "未点到送审" already = _body_has(browser, _ALREADY_SUBMIT_MARKS) submitted = bool(clicked and not error) or already facts: dict[str, object] = { "submitted": "yes" if submitted else "no", "via": clicked or ("已送审" if already else ""), } if error and not already: facts["error"] = error _SUBMIT_ACCEPT.note("S07", "点送审", facts, ok=submitted) header, detail, titles = read_agreement_nos(browser, contract_no=contract_no) header = header or before_header detail = detail or before_detail read_facts: dict[str, object] = {"rebateOrderNo": header, "agreementDtlId": detail} if not detail: cols = [t for t in titles.split(",") if "细单" in t or "明细" in t] if cols: read_facts["detailCols"] = ",".join(cols[:8]) _SUBMIT_ACCEPT.note( "S08", "读号", read_facts, ok=bool(header), ) result = RebateSubmitResult(submitted, header, detail) return result finally: print(_SUBMIT_ACCEPT.summary_text(), end="", flush=True) _SUBMIT_ACCEPT.dump()