AutoRpa/rpa_rebate/rebate_submit.py

435 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 返利送审节点: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()