1060 lines
42 KiB
Python
1060 lines
42 KiB
Python
# 返利总单节点:本段字段与 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()
|