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