AutoRpa/rpa_rebate/rebate_header.py

1060 lines
42 KiB
Python
Raw Permalink 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_header_flow。登录/查号/勾行/弹层在 rebate_common。
from __future__ import annotations
import os
import re
import time
from accept import AcceptanceReport, evidence_shot
from errors import NodeError
from rebate_common import (
HEADER_FLOW,
PAY_MODE_NEXT_MONTH_N,
REBATE_CONTRACT_DEFAULT,
SETTLE_MODE_DEFAULT,
PAY_PARAM_DEFAULT,
SETTLE_MODE_WIRE,
PLANNER_DEFAULT,
CONTACT_DEFAULT,
BrowserAdapter,
RebateFlowContext,
RebateHeaderDraft,
open_rebate_center,
login_rebate_center,
_switch_agreement_frame,
enter_purchase_agreement,
search_contract,
_header_dom_js,
_OPEN_HEADER_ROW,
today_iso,
_cell,
_click_toolbar,
_click_header_toolbar,
_native_click_header_toolbar,
_type_active,
_filter_and_pick_dropdown,
_pick_header_lookup,
_open_labeled_picker,
_read_labeled_input,
_picker_bound,
_scan_lookup_overlays,
_click_unit_clear,
_query_overlay_and_pick,
_click_keyword_in_overlays,
_ignore_source_choice,
_stop_if_source_choice,
_pick_person,
_supplier_picker_open,
_scan_rebate_order_no,
select_header_row,
select_header_row_result,
read_header_selection,
_close_header_query,
_pick_detail_option,
_read_detail_toast,
_close_detail_card,
)
_HEADER_ACCEPT = AcceptanceReport(flow=HEADER_FLOW)
HEADER = {
"rebate_contract": [
"label=返利合约",
'//td[contains(.,"返利合约")]/following-sibling::td[1]//select',
'//*[contains(text(),"返利合约")]/following::select[1]',
],
"supplier_code": [
"label=供应商编码",
'input[placeholder*="供应商编码"]',
'input[name*="supplier"]',
'//td[contains(.,"供应商编码")]/following-sibling::td[1]//input',
],
"rebate_unit_code": [
"label=返利单位编码",
'input[placeholder*="返利单位"]',
'input[name*="rebateUnit"]',
'//td[contains(.,"返利单位编码")]/following-sibling::td[1]//input',
],
"planner": [
"label=计划员",
'input[placeholder*="计划员"]',
'input[name*="planner"]',
'//td[contains(.,"计划员")]/following-sibling::td[1]//input',
'//td[contains(.,"计划员")]/following-sibling::td[1]//select',
],
"settle_mode": [
"label=采购结算方式",
'//td[contains(.,"采购结算方式")]/following-sibling::td[1]//select',
'//*[contains(text(),"采购结算方式")]/following::select[1]',
],
"pay_mode": [
"label=承付模式",
'//td[contains(.,"承付模式")]/following-sibling::td[1]//select',
'//*[contains(text(),"承付模式")]/following::select[1]',
],
"protocol_start": [
"label=协议开始日期",
'input[placeholder*="开始日期"]',
'//td[contains(.,"协议开始日期")]/following-sibling::td[1]//input',
],
"protocol_end": [
"label=协议结束日期",
'input[placeholder*="结束日期"]',
'//td[contains(.,"协议结束日期")]/following-sibling::td[1]//input',
],
}
SAVE = {
"button": [
"text=保存",
"button:contains(保存)",
"a:contains(保存)",
'input[value="保存"]',
"text=保 存",
],
}
ORDER_NO = {
"value": [
"label=总单号",
"label=采购协议号",
"label=协议编号",
"label=采购协议总单ID",
"label=协议总单号",
'input[name*="agreementId"]',
'input[name*="rebateOrder"]',
],
}
_FORM_MARKERS = ("保存", "导入导出", "下载模板")
_ORDER_NO_LABEL_RE = re.compile(
r"(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号|采购协议ID)[::\s]*([A-Za-z0-9_-]{3,})"
)
_FL_RE = re.compile(r"\bFL[A-Za-z0-9_-]{4,}\b")
_SET_LABELED = """
/* SET_LABELED */
const labelText = String(arguments[0] || '').trim();
const value = String(arguments[1] || '');
function controlFor(label) {
const nodes = Array.from(document.querySelectorAll('label, td, th, span, div'));
const node = nodes.find((el) => {
const t = (el.childNodes && el.childNodes.length === 1)
? (el.textContent || '').replace(/\\s+/g, '').trim()
: (el.innerText || '').replace(/\\s+/g, '').trim();
const compact = t.replace(/[*::]/g, '');
const wanted = label.replace(/\\s+/g, '');
return compact === wanted || compact.indexOf(wanted) === 0;
});
if (!node) { return null; }
if (node.tagName === 'LABEL' && node.htmlFor) {
return document.getElementById(node.htmlFor);
}
const next = node.nextElementSibling;
if (next) {
if (['INPUT', 'SELECT', 'TEXTAREA'].indexOf(next.tagName) >= 0) { return next; }
const inner = next.querySelector('input, select, textarea');
if (inner) { return inner; }
}
const cell = node.closest('td, th');
if (cell && cell.nextElementSibling) {
return cell.nextElementSibling.querySelector('input, select, textarea');
}
return null;
}
const el = controlFor(labelText);
if (!el) { return false; }
if (el.tagName === 'SELECT') {
const opts = Array.from(el.options || []);
const hit = opts.find((o) => {
const t = (o.textContent || o.text || '').replace(/\\s+/g, '');
const v = String(o.value || '').replace(/\\s+/g, '');
const wanted = value.replace(/\\s+/g, '');
return t === wanted || v === wanted || t.indexOf(wanted) >= 0;
});
if (!hit) { return false; }
el.value = hit.value;
el.dispatchEvent(new Event('change', {bubbles: true}));
return true;
}
const proto = el.tagName === 'TEXTAREA'
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
el.dispatchEvent(new Event('blur', {bubbles: true}));
return true;
"""
_READ_REBATE_ORDER_NO = """
/* READ_REBATE_ORDER_NO */
const labels = ['总单号', '采购协议号', '协议编号', '采购协议总单ID', '协议总单号', '采购协议ID'];
function textOf(el) {
return ((el && (el.value || el.textContent || el.innerText)) || '').replace(/\\s+/g, ' ').trim();
}
for (const label of labels) {
const nodes = Array.from(document.querySelectorAll('label, td, th, span, div'));
const node = nodes.find((el) => {
const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, '');
return t === label || t.indexOf(label) === 0;
});
if (!node) { continue; }
let value = '';
if (node.tagName === 'LABEL' && node.htmlFor) {
value = textOf(document.getElementById(node.htmlFor));
}
const next = node.nextElementSibling;
if (!value && next) {
const inner = next.querySelector('input, select, textarea, span, a') || next;
value = textOf(inner);
}
const cell = node.closest('td, th');
if (!value && cell && cell.nextElementSibling) {
const inner = cell.nextElementSibling.querySelector('input, span, a') || cell.nextElementSibling;
value = textOf(inner);
}
if (value && labels.indexOf(value.replace(/[*::]/g, '')) < 0) { return value; }
}
const body = (document.body && document.body.innerText) || '';
const m = body.match(/(?:总单号|采购协议号|协议编号|采购协议总单ID|协议总单号)[::\\s]*([A-Za-z0-9_-]{3,})/);
return m ? m[1] : '';
"""
_OPEN_DROPDOWN = """
/* OPEN_DROPDOWN */
const labelText = String(arguments[0] || '').trim();
const nodes = Array.from(document.querySelectorAll('label, td, th, span, div'));
const node = nodes.find((el) => {
const t = (el.innerText || '').replace(/\\s+/g, '').replace(/[*::]/g, '');
return t === labelText.replace(/\\s+/g, '') || t.indexOf(labelText.replace(/\\s+/g, '')) === 0;
});
if (!node) { return false; }
const cell = (node.nextElementSibling)
|| (node.closest('td, th') && node.closest('td, th').nextElementSibling)
|| node.parentElement;
if (!cell) { return false; }
const wrap = cell.querySelector('.el-select, .ant-select, .fs-wrap, .combo, select') || cell;
wrap.click();
return true;
"""
# 拼一份总单草稿。计划员空则廖建琦,协议起止都是当天,返利合约=否。
def build_header_draft(
*,
planner_name: str,
supplier_code: str,
rebate_unit_code: str,
today: str | None = None,
) -> RebateHeaderDraft:
planner = (planner_name or "").strip() or PLANNER_DEFAULT
day = today_iso(today)
return RebateHeaderDraft(
planner_name=planner,
supplier_code=(supplier_code or "").strip(),
rebate_unit_code=(rebate_unit_code or "").strip(),
rebate_contract=REBATE_CONTRACT_DEFAULT,
settle_mode=SETTLE_MODE_DEFAULT,
pay_mode=PAY_MODE_NEXT_MONTH_N,
protocol_start=day,
protocol_end=day,
contact_name=CONTACT_DEFAULT,
)
# 从环境变量读供应商编码和计划员,拼总单草稿。干跑缺表 3 时用。
def header_draft_from_env(*, today: str | None = None) -> RebateHeaderDraft:
return build_header_draft(
planner_name=os.getenv("REBATE_PLANNER_NAME") or "",
supplier_code=os.getenv("REBATE_SUPPLIER_CODE") or "",
rebate_unit_code=os.getenv("REBATE_UNIT_CODE") or "",
today=today,
)
# 总单取表 3 该合同第一行的供应商 MDM。计划员先选廖建琦,联系人苏文辉。
def header_draft_from_table3(
rows: list[dict] | tuple[dict, ...],
*,
today: str | None = None,
) -> RebateHeaderDraft:
packed = [row for row in rows if isinstance(row, dict)]
if not packed:
raise NodeError("表 3 没有该合同行,不能填总单")
first = packed[0]
supplier = _cell(first.get("supplier_mdm_code") or first.get("supplier_id"))
unit = _cell(first.get("org_code"))
draft = build_header_draft(
planner_name=PLANNER_DEFAULT,
supplier_code=supplier,
rebate_unit_code=unit,
today=today,
)
return RebateHeaderDraft(
planner_name=PLANNER_DEFAULT,
supplier_code=draft.supplier_code,
rebate_unit_code=draft.rebate_unit_code,
rebate_contract=draft.rebate_contract,
settle_mode=draft.settle_mode,
pay_mode=draft.pay_mode,
protocol_start=draft.protocol_start,
protocol_end=draft.protocol_end,
contact_name=CONTACT_DEFAULT,
supplier_name=_cell(first.get("supplier_name")),
supplier_id=_cell(first.get("supplier_id")),
)
# 优先表 3,空缺再退回环境变量。
def header_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateHeaderDraft:
number = (contract_no or "").strip()
rows: list[dict] = []
if number:
try:
from repo import OracleRebateRepo
rows = OracleRebateRepo.from_env().load_contract_goods(number)
except Exception:
rows = []
if rows:
return header_draft_from_table3(rows, today=today)
return header_draft_from_env(today=today)
# 正文是否已出现总单表单标记。
def _header_form_visible(browser: BrowserAdapter) -> bool:
body = browser.read_body_text() or ""
return any(marker in body for marker in _FORM_MARKERS)
# 勾中该合同总单后点编辑打开卡片。不要点新增总单。
def open_header_form(browser: BrowserAdapter, contract_no: str) -> None:
_switch_agreement_frame(browser)
_close_detail_card(browser)
if not select_header_row(browser, contract_no):
result = None
try:
result = browser.execute_script(_OPEN_HEADER_ROW, contract_no)
except Exception:
result = None
opened = result is True or (isinstance(result, dict) and result.get("ok"))
if opened:
time.sleep(0.5)
_click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑")
time.sleep(0.8)
if _header_form_visible(browser):
return
try:
dbl = getattr(browser, "double_click_first", None)
if callable(dbl):
dbl([".vxe-body--row", '.vxe-body--row [colid]', "tr.vxe-body--row"])
except Exception:
pass
try:
browser.click_first(
[
f"text={contract_no}",
f'//a[contains(.,"{contract_no}")]',
f'//td[contains(.,"{contract_no}")]',
]
)
except Exception:
pass
deadline = time.time() + 15
while time.time() <= deadline:
if _header_form_visible(browser):
return
for selectors in (
["text=编辑"],
["text=修改"],
["button:contains(编辑)"],
["a:contains(编辑)"],
):
try:
browser.click_first(selectors)
break
except Exception:
continue
time.sleep(0.4)
if not _header_form_visible(browser):
raise RuntimeError("未打开总单表单")
# 按下拉选择器选中给定文案,点不到再按标签脚本设值。
def _fill_select(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None:
try:
browser.select_first(selectors, value)
return
except Exception:
pass
try:
browser.execute_script(_OPEN_DROPDOWN, label)
browser.click_first(
[
f"text={value}",
f'//li[contains(.,"{value}")]',
f'//div[contains(@class,"option") and contains(.,"{value}")]',
f'//span[contains(.,"{value}")]',
]
)
return
except Exception:
pass
ok = False
try:
ok = bool(browser.execute_script(_SET_LABELED, label, value))
except Exception:
ok = False
if not ok:
raise RuntimeError(f"未选到 {label}={value}")
# 按选择器填输入框,填不到再按标签脚本设值。
def _fill_input(browser: BrowserAdapter, label: str, selectors: list[str], value: str) -> None:
try:
browser.fill_first(selectors, value)
return
except Exception:
pass
try:
browser.select_first(selectors, value)
return
except Exception:
pass
ok = False
try:
ok = bool(browser.execute_script(_SET_LABELED, label, value))
except Exception:
ok = False
if not ok:
raise RuntimeError(f"未填到 {label}={value}")
# 双击总单表已勾中行的该列,输入或打开下拉。卡片编辑时一般不用这条。
def _set_grid_field(browser: BrowserAdapter, title: str, value: str, *, as_select: bool = False) -> bool:
if not value:
return False
try:
clicked = browser.execute_script(
"""
/* SET_HEADER_CELL */
const name = String(arguments[0] || '').replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
const root = document.querySelector('.table-show') || document;
const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name);
if (!th) { return {ok: false}; }
const colid = th.getAttribute('colid');
const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row'));
let idx = 0;
for (let i = 0; i < lefts.length; i++) {
const icon = lefts[i].querySelector('.vxe-checkbox--icon');
const cls = String((icon && icon.className) || '');
if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) {
idx = i;
break;
}
}
const mains = Array.from(root.querySelectorAll(
'.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row'
));
const row = mains[idx] || mains[0];
const cell = row && row.querySelector('[colid="' + colid + '"]');
if (!cell) { return {ok: false}; }
cell.scrollIntoView({block: 'center', inline: 'center'});
cell.click();
cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window}));
return {ok: true, colid: colid, idx: idx};
""",
title,
)
except Exception:
clicked = None
if not (isinstance(clicked, dict) and clicked.get("ok")):
return False
time.sleep(0.3)
if as_select:
return _filter_and_pick_dropdown(browser, value)
try:
typed = browser.execute_script(
"""
/* TYPE_HEADER_CELL */
const name = String(arguments[0] || '').replace(/\\s+/g, '');
const value = String(arguments[1] || '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
const root = document.querySelector('.table-show') || document;
const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name);
if (!th) { return {ok: false}; }
const cell = root.querySelector(
'.vxe-table--main-wrapper .vxe-body--row [colid="' + th.getAttribute('colid') + '"], .vxe-body--row [colid="' + th.getAttribute('colid') + '"]'
);
if (!cell) { return {ok: false}; }
const el = cell.querySelector('input, textarea') || document.activeElement;
if (!el || !el.tagName) { return {ok: false}; }
el.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
return {ok: true, value: el.value};
""",
title,
value,
)
except Exception:
typed = None
if isinstance(typed, dict) and typed.get("ok"):
_type_active(browser, value)
return True
return False
# 往总单表格该列填编码并打开编码弹窗勾中。
def _fill_header_code(browser: BrowserAdapter, title: str, code: str) -> None:
if not code:
return
_set_grid_field(browser, title, code)
_pick_header_lookup(browser, code)
# 点总单表已勾中行的该列,让单元格进入编辑。
def _activate_header_cell(browser: BrowserAdapter, title: str) -> bool:
if not title:
return False
try:
clicked = browser.execute_script(
"""
/* SET_HEADER_CELL */
const name = String(arguments[0] || '').replace(/\\s+/g, '');
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, '').trim();
}
const root = document.querySelector('.table-show') || document;
const th = Array.from(root.querySelectorAll('.vxe-header--column')).find((el) => compact(el) === name);
if (!th) { return {ok: false}; }
const colid = th.getAttribute('colid');
const lefts = Array.from(root.querySelectorAll('.vxe-table--fixed-left-wrapper .vxe-body--row'));
let idx = 0;
for (let i = 0; i < lefts.length; i++) {
const icon = lefts[i].querySelector('.vxe-checkbox--icon');
const cls = String((icon && icon.className) || '');
if (cls.indexOf('checkbox-checked') >= 0 && cls.indexOf('unchecked') < 0) {
idx = i;
break;
}
}
const mains = Array.from(root.querySelectorAll(
'.vxe-table--main-wrapper .vxe-body--row, .vxe-table--body-wrapper .vxe-body--row'
));
const row = mains[idx] || mains[0];
const cell = row && row.querySelector('[colid="' + colid + '"]');
if (!cell) { return {ok: false}; }
cell.scrollIntoView({block: 'center', inline: 'center'});
cell.click();
cell.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window}));
return {ok: true, colid: colid, idx: idx};
""",
title,
)
except Exception:
clicked = None
return bool(isinstance(clicked, dict) and clicked.get("ok"))
# 总单卡片是否处于编辑(能看见供应商编码、确认 Ctrl)。
def _header_card_open(browser: BrowserAdapter) -> bool:
try:
hit = browser.execute_script(
"""
/* HEADER_CARD */
"""
+ _header_dom_js()
+ """
const root = document.querySelector('.table-show') || document.body;
const t = compact(root);
if (t.indexOf('确认 Ctrl') >= 0 || t.indexOf('请填写供应商') >= 0
|| t.indexOf('状态 编辑') >= 0 || t.indexOf('状态编辑') >= 0) {
return true;
}
return !!formItem('供应商编码');
"""
)
except Exception:
hit = False
return hit is True
# 总单卡片「确认 Ctrl + Enter」,不要点货品卡片确认。
def _confirm_header_card(browser: BrowserAdapter) -> bool:
clicked = browser.execute_script(
"""
/* CONFIRM_HEADER_CARD */
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
function vis(el) {
return !!(el && (el.offsetWidth || el.offsetHeight));
}
const card = Array.from(document.querySelectorAll('.table-card')).find((el) => {
const t = compact(el);
return vis(el) && t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0;
});
const root = card || document.querySelector('.table-show') || document;
const hit = Array.from(root.querySelectorAll('.card-header-btn, button, .elPlus-button')).find((el) => {
if (!vis(el)) return false;
const t = compact(el).replace(/\\s+/g, '');
return t.indexOf('确认Ctrl') === 0 || t === '确认' || t.indexOf('确认') === 0;
});
if (!hit) { return {ok: false, error: 'no-confirm'}; }
hit.scrollIntoView({block: 'center', inline: 'nearest'});
hit.click();
return {ok: true, via: compact(hit).slice(0, 40)};
"""
)
driver = getattr(browser, "_driver", None)
if driver is not None:
try:
from selenium.webdriver.common.action_chains import ActionChains
for el in driver.find_elements("css selector", ".table-card .card-header-btn"):
info = driver.execute_script(
"""
const el = arguments[0];
const card = el.closest('.table-card');
const t = ((card && card.innerText) || '');
return {
text: ((el.innerText || '') + '').replace(/\\s+/g, ' ').trim(),
header: t.indexOf('供应商编码') >= 0 && t.indexOf('货品编码') < 0
};
""",
el,
)
if not (isinstance(info, dict) and info.get("header")):
continue
raw = str(info.get("text") or "")
if "确认" not in raw.replace(" ", ""):
continue
driver.execute_script(
"arguments[0].scrollIntoView({block:'center', inline:'nearest'});",
el,
)
time.sleep(0.2)
ActionChains(driver).move_to_element(el).pause(0.15).click().perform()
clicked = {"ok": True, "via": "native:" + raw[:40]}
break
except Exception as exc:
print("native_header_confirm_err=", exc, flush=True)
print("confirm_header=", clicked, flush=True)
if isinstance(clicked, dict) and clicked.get("ok"):
time.sleep(0.5)
err = _read_detail_toast(browser)
if err:
raise NodeError(err)
return True
return False
# 列出总单卡片当前带星号的字段标题。
def _read_required_titles(browser: BrowserAdapter) -> list[str]:
try:
result = browser.execute_script(
"""
/* REQUIRED_TITLES */
"""
+ _header_dom_js()
+ """
const titles = [];
const items = Array.from(document.querySelectorAll('.elPlus-form-item, .el-form-item')).filter(vis);
for (const item of items) {
const star = Array.from(item.querySelectorAll('*')).some((el) => compact(el) === '*');
const required = star || String(item.className || '').indexOf('is-required') >= 0
|| !!(item.querySelector('.form-item__title.required'));
if (!required) continue;
const label = item.querySelector('.form-item__title, .elPlus-form-item__label, label, .label-wrap');
const t = compact(label || item).replace(/\\*/g, '').replace(/\\s+/g, '');
if (t && titles.indexOf(t) < 0) titles.push(t);
}
return {titles: titles};
"""
)
except Exception:
result = None
if isinstance(result, dict):
return [str(t) for t in (result.get("titles") or []) if t]
return []
# 用表 3 supplier_mdm_code 在供应商选择层搜编码并确认。
#
# 选中后名称、返利单位会带出,不要手填。选对时不应弹出货源选择。
def _pick_supplier_by_mdm(browser: BrowserAdapter, code: str, name: str = "") -> None:
value = (code or "").strip()
wanted_name = (name or "").strip()
if not value and not wanted_name:
raise NodeError("表 3 没有 supplier_mdm_code,不能选供应商")
if _picker_bound(browser, "供应商编码", value):
return
_stop_if_source_choice(browser)
opened = _supplier_picker_open(browser)
if opened:
print("lookup_overlays=", _scan_lookup_overlays(browser), "already_open=1", flush=True)
else:
_close_header_query(browser)
clicked = _open_labeled_picker(browser, "供应商编码", dots_only=True)
print("open_picker=", clicked, flush=True)
deadline = time.time() + 8
while time.time() <= deadline:
_stop_if_source_choice(browser)
overlays = _scan_lookup_overlays(browser)
print("lookup_overlays=", overlays, flush=True)
if _supplier_picker_open(browser):
opened = True
break
time.sleep(0.4)
if not opened:
raise NodeError(f"未打开返利单位选择弹窗 overlays={_scan_lookup_overlays(browser)}")
ok = False
if wanted_name:
ok = _query_overlay_and_pick(browser, "供应商名称", wanted_name)
if not ok and value:
print("unit_query_name_empty, try_code=", value, flush=True)
_click_unit_clear(browser)
time.sleep(0.3)
ok = _query_overlay_and_pick(browser, "供应商编码", value)
if not ok and value:
ok = _click_keyword_in_overlays(browser, value)
if not ok and wanted_name:
ok = _click_keyword_in_overlays(browser, wanted_name)
if not _picker_bound(browser, "供应商编码", value):
raise NodeError(
f"未选到供应商编码={value} 名称={wanted_name},当前={_read_labeled_input(browser, '供应商编码')}"
)
# 往承付参数数字框键盘填 25。不是数字的值直接跳过,不要填「以销售定结」。
def _fill_pay_param(browser: BrowserAdapter, title: str, value: str) -> None:
wanted = (value or "").strip()
if not wanted:
return
if not re.fullmatch(r"\d+(\.\d+)?", wanted):
print("skip_pay_param_not_number=", wanted, flush=True)
return
print("fill_pay_param=", wanted, flush=True)
marked = browser.execute_script(
"""
/* FOCUS_PAY_PARAM */
"""
+ _header_dom_js()
+ """
const item = formItem(arguments[0]);
const input = item && item.querySelector('input.vxe-input--inner, input');
if (!input) { return false; }
input.setAttribute('data-rpa-pay', '1');
input.click();
input.focus();
return true;
""",
title,
)
driver = getattr(browser, "_driver", None)
if marked and driver is not None:
try:
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
el = driver.find_element("css selector", '[data-rpa-pay="1"]')
driver.execute_script("arguments[0].removeAttribute('data-rpa-pay')", el)
ActionChains(driver).click(el).pause(0.1).key_down(Keys.CONTROL).send_keys("a").key_up(
Keys.CONTROL
).send_keys(Keys.BACKSPACE).send_keys(wanted).send_keys(Keys.TAB).perform()
time.sleep(0.2)
except Exception as exc:
print("pay_param_type_err=", exc, flush=True)
now = _read_labeled_input(browser, title)
print("pay_param_now=", now, flush=True)
if (now or "").strip() == wanted:
return
if not _pick_detail_option(browser, title, wanted):
try:
_fill_input(browser, title, [f"label={title}"], wanted)
except Exception:
_set_grid_field(browser, title, wanted)
print("pay_param_after=", _read_labeled_input(browser, title), flush=True)
# 按现行字段填总单卡片:供应商三个点、计划员廖建琦、联系人苏文辉、电汇、下月N号结、承付参数 25、协议起止当天。
def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None:
_ignore_source_choice(browser)
titles = _read_required_titles(browser)
print("required=", titles, flush=True)
settle = draft.settle_mode if draft.settle_mode and draft.settle_mode != SETTLE_MODE_DEFAULT else SETTLE_MODE_WIRE
planner = (draft.planner_name or "").strip() or PLANNER_DEFAULT
contact = (draft.contact_name or "").strip() or CONTACT_DEFAULT
values = {
"供应商编码": draft.supplier_code,
"计划员": planner,
"返利合约": draft.rebate_contract,
"采购结算方式": settle,
"承付模式": draft.pay_mode,
"承付参数": PAY_PARAM_DEFAULT,
"协议开始日期": draft.protocol_start,
"协议结束日期": draft.protocol_end,
"联系人": contact,
}
selects = {"返利合约", "采购结算方式", "承付模式"}
order = [
"供应商编码",
"计划员",
"联系人",
"返利合约",
"采购结算方式",
"承付模式",
"协议开始日期",
"协议结束日期",
"承付参数",
]
skip = {"供应商名称", "返利单位编码", "返利单位"}
raw = titles or list(values.keys())
needed = list(order) + [title for title in raw if title not in order and title not in skip]
for title in needed:
if title in skip:
continue
value = values.get(title, "")
if not value:
continue
if title == "供应商编码":
_pick_supplier_by_mdm(browser, value, name=draft.supplier_name)
elif title == "计划员":
_pick_person(browser, "计划员", value, supplier_name=draft.supplier_name)
elif title == "联系人":
_pick_person(browser, "联系人", value, nth=1)
elif title == "承付参数":
_fill_pay_param(browser, title, value)
elif title in selects:
if not _pick_detail_option(browser, title, value):
if title == "采购结算方式":
_pick_detail_option(browser, title, SETTLE_MODE_WIRE)
else:
try:
_fill_input(browser, title, [f"label={title}"], value)
except Exception:
_set_grid_field(browser, title, value)
print(
"card_values=",
{
"supplier": _read_labeled_input(browser, "供应商编码"),
"supplierName": _read_labeled_input(browser, "供应商名称"),
"unit": _read_labeled_input(browser, "返利单位编码"),
"planner": _read_labeled_input(browser, "计划员"),
"contact": _read_labeled_input(browser, "联系人"),
"payParam": _read_labeled_input(browser, "承付参数"),
},
flush=True,
)
if draft.protocol_start:
try:
_fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start)
except Exception:
_set_grid_field(browser, "协议开始日期", draft.protocol_start)
if draft.protocol_end:
try:
_fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end)
except Exception:
_set_grid_field(browser, "协议结束日期", draft.protocol_end)
_fill_pay_param(browser, "承付参数", PAY_PARAM_DEFAULT)
print(
"card_values_before_save=",
{
"planner": _read_labeled_input(browser, "计划员"),
"payParam": _read_labeled_input(browser, "承付参数"),
"supplier": _read_labeled_input(browser, "供应商名称"),
},
flush=True,
)
if (_read_labeled_input(browser, "承付参数") or "").strip() != PAY_PARAM_DEFAULT:
raise NodeError("总单承付参数未填成25,不能保存")
# 等到总单卡片打开。超时返回 False。
def _wait_header_card(browser: BrowserAdapter, timeout: float = 8, *, contract_no: str = "") -> bool:
if _header_card_open(browser):
return True
if getattr(browser, "_driver", None) is None:
return False
deadline = time.time() + timeout
while time.time() <= deadline:
if contract_no:
select_header_row(browser, contract_no)
_click_header_toolbar(browser, "编辑") or _click_toolbar(browser, "编辑")
time.sleep(0.4)
if _header_card_open(browser):
return True
return False
# 打开已有总单卡片并填必填。审批通过后已有一条,不要新增。
def fill_rebate_header(browser: BrowserAdapter, draft: RebateHeaderDraft, *, contract_no: str = "") -> None:
_switch_agreement_frame(browser)
_ignore_source_choice(browser)
if _wait_header_card(browser, contract_no=contract_no):
print("header_card=open", flush=True)
_fill_header_card(browser, draft)
if not _picker_bound(browser, "供应商编码", draft.supplier_code):
raise NodeError("总单供应商仍为空")
return
grid = _activate_header_cell(browser, "供应商编码") or _activate_header_cell(browser, "承付模式")
if grid:
_set_grid_field(browser, "承付模式", draft.pay_mode, as_select=True)
_set_grid_field(browser, "返利合约", draft.rebate_contract, as_select=True)
if not _set_grid_field(browser, "采购结算方式", draft.settle_mode, as_select=True):
_set_grid_field(browser, "采购结算方式", SETTLE_MODE_WIRE, as_select=True)
_set_grid_field(browser, "承付参数", PAY_PARAM_DEFAULT)
_fill_header_code(browser, "供应商编码", draft.supplier_code)
_fill_header_code(browser, "计划员", draft.planner_name or PLANNER_DEFAULT)
_fill_header_code(browser, "联系人", draft.contact_name or CONTACT_DEFAULT)
_set_grid_field(browser, "协议开始日期", draft.protocol_start)
_set_grid_field(browser, "协议结束日期", draft.protocol_end)
return
_fill_select(browser, "返利合约", HEADER["rebate_contract"], draft.rebate_contract)
if draft.supplier_code:
_fill_input(browser, "供应商编码", HEADER["supplier_code"], draft.supplier_code)
_pick_header_lookup(browser, draft.supplier_code)
_fill_input(browser, "计划员", HEADER["planner"], draft.planner_name or PLANNER_DEFAULT)
try:
_fill_select(browser, "采购结算方式", HEADER["settle_mode"], draft.settle_mode)
except Exception:
_fill_select(browser, "采购结算方式", HEADER["settle_mode"], SETTLE_MODE_WIRE)
_fill_select(browser, "承付模式", HEADER["pay_mode"], draft.pay_mode)
_fill_input(browser, "协议开始日期", HEADER["protocol_start"], draft.protocol_start)
_fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end)
# 卡片仍在编辑时点总单工具栏保存,再点卡片确认。
#
# 先确认会把卡收回表格,保存落不到库。成功返回协议总单ID。
def save_rebate_header(browser: BrowserAdapter, *, contract_no: str = "") -> str:
if _header_card_open(browser) and not _picker_bound(browser, "供应商编码", ""):
raise NodeError("总单供应商为空,不能保存")
if not _native_click_header_toolbar(browser, "保存"):
if not _click_header_toolbar(browser, "保存"):
if not _click_toolbar(browser, "保存"):
browser.click_first(SAVE["button"])
time.sleep(0.5)
err = _read_detail_toast(browser)
if err:
raise NodeError(err)
deadline = time.time() + 15
last = ""
while time.time() <= deadline:
last = _scan_rebate_order_no(browser, contract_no=contract_no)
body = browser.read_body_text() or ""
if last or "保存成功" in body:
return last
time.sleep(0.4)
return last
# 读当前总单的协议总单ID。
def read_rebate_order_no(browser: BrowserAdapter, *, contract_no: str = "") -> str:
deadline = time.time() + 15
last = ""
while time.time() <= deadline:
last = _scan_rebate_order_no(browser, contract_no=contract_no)
if last:
return last
time.sleep(0.4)
return last
# 查号 → 勾已有总单 → 补必填 → 保存拿协议总单ID。
#
# 表上已有协议总单ID则复用,不再走新建。写 ACCEPT SUMMARY REBATE_HEADER。
def run_header_flow(
browser: BrowserAdapter,
ctx: RebateFlowContext,
contract_no: str,
draft: RebateHeaderDraft | None = None,
) -> str:
_HEADER_ACCEPT.reset()
header = draft or header_draft_for_contract(contract_no)
try:
open_rebate_center(browser, ctx)
_HEADER_ACCEPT.note("S02", "打开返利中心", {"url": ctx.rebate_url}, ok=True)
try:
login_rebate_center(browser, ctx)
_HEADER_ACCEPT.note("S03", "登录", {"user": ctx.username}, ok=True)
except Exception as exc:
evidence_shot(browser, "rebate-header-login-failed", failed=True)
_HEADER_ACCEPT.note(
"S03",
"登录",
{"user": ctx.username, "error": exc},
ok=False,
raise_error=False,
)
raise
enter_purchase_agreement(browser)
_HEADER_ACCEPT.note("S04", "进入采购协议", {"menu": "采购协议管理"}, ok=True)
found = search_contract(browser, contract_no)
if not found:
href = ""
try:
href = str(browser.execute_script("return location.href") or "")
except Exception:
href = ""
if "ssoLogin" in href or "login" in href.lower() or "wel/index" in href:
login_rebate_center(browser, ctx)
enter_purchase_agreement(browser)
found = search_contract(browser, contract_no)
if not found:
print("header_selection=", read_header_selection(browser, contract_no), flush=True)
_HEADER_ACCEPT.note(
"S05",
"查号",
{"contractNo": contract_no, "found": "yes" if found else "no"},
ok=found,
)
if not found:
raise NodeError("查号未搜到合同,不能填总单")
_close_header_query(browser)
picked = select_header_row_result(browser, contract_no)
if not (isinstance(picked, dict) and picked.get("ok")):
raise NodeError("未按合同编号勾中总单行")
# 勾已有总单后走编辑补必填,禁止点新增。表上已有协议总单ID则回传该号。
existing = _scan_rebate_order_no(browser, contract_no=contract_no)
if existing == contract_no:
existing = ""
open_header_form(browser, contract_no)
_HEADER_ACCEPT.note(
"S06",
"打开总单",
{"contractNo": contract_no, "reused": "yes" if existing else "no"},
ok=True,
)
fill_rebate_header(browser, header, contract_no=contract_no)
_HEADER_ACCEPT.note(
"S07",
"填总单",
{
"planner": header.planner_name,
"payMode": header.pay_mode,
"protocolEnd": header.protocol_end,
"rebateContract": header.rebate_contract,
},
ok=True,
)
pay_before = _read_labeled_input(browser, "承付参数")
planner_before = _read_labeled_input(browser, "计划员")
print("before_save pay_param=", pay_before, "planner=", planner_before, flush=True)
saved = save_rebate_header(browser, contract_no=contract_no)
_HEADER_ACCEPT.note(
"S08",
"保存",
{"saved": "yes", "payParam": pay_before},
ok=(pay_before or "").strip() == PAY_PARAM_DEFAULT,
)
_confirm_header_card(browser)
order_no = saved or existing or read_rebate_order_no(browser, contract_no=contract_no)
_HEADER_ACCEPT.note(
"S09",
"读总单号",
{
"rebateOrderNo": order_no,
"payParam": pay_before,
"reused": "yes" if existing else "no",
},
ok=True,
raise_error=False,
)
return order_no or contract_no
finally:
print(_HEADER_ACCEPT.summary_text(), end="", flush=True)
_HEADER_ACCEPT.dump()