435 lines
16 KiB
Python
435 lines
16 KiB
Python
# 返利送审节点: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()
|