# 合同 S09:货品明细。弹窗按操作码搜,找不到则本单失败。 # # 同品种多行时先克隆身份再填数量单价,不把两行压进同一格。 from __future__ import annotations import time from accept import evidence_shot from errors import NodeError from contract_common import ( ADD_ROW_SELECTORS, GOODS_FIELD_SUFFIXES, GOODS_PICKER, BrowserAdapter, ContractFlowContext, ContractGoodsLine, _ACCEPT, _try_read, ) # 填货品明细(S09)。弹窗按操作码搜,找不到则本单失败。 def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: if not ctx.goods_lines: evidence_shot(browser, "contract-goods-lines-filled") _ACCEPT.note("S09", "货品明细", {"lines": 0}, ok=True) return used: list[int] = [] assigned: list[int] = [] for line in ctx.goods_lines: row = _find_goods_row(browser, line, skip=used) if row < 0: same = _existing_used_match(browser, line, used) if same >= 0: row = _append_same_goods_row(browser, same) else: if used and not _empty_goods_row_indexes(browser): _add_goods_row(browser) row = _pick_goods_from_dialog(browser, line, skip=used) _fill_goods_required_qty_price(browser, line, row) used.append(row) assigned.append(row) _delete_empty_goods_rows(browser) used = [] assigned = [] for line in ctx.goods_lines: row = _find_goods_row(browser, line, skip=used) assigned.append(row) if row >= 0: used.append(row) evidence_shot(browser, "contract-goods-lines-filled") for index, (line, row) in enumerate(zip(ctx.goods_lines, assigned, strict=True)): opcode = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else "" mdm = _try_read(browser, [_goods_selector("goods_mdm", row)]) if row >= 0 else "" qty = _try_read(browser, [_goods_selector("goods_qty", row)]) if row >= 0 else "" price = _try_read(browser, [_goods_selector("goods_price", row)]) if row >= 0 else "" if line.mdm_code: code_pair = ("goodsMdm", mdm or opcode, line.mdm_code) else: code_pair = ("goodsCode", opcode, line.goods_code) _ACCEPT.check( "S09", f"货品明细#{index + 1}", [ code_pair, ("qty", qty, line.quantity), ("price", price, line.unit_price or "1.00"), ], browser=browser, screenshot_on_fail="contract-goods-pick-failed", ) # 货品弹窗搜索关键字:MDM、操作码、名称按这个顺序取第一个非空。 def _goods_search_keyword(line: ContractGoodsLine) -> str: return (line.mdm_code or line.goods_code or line.goods_name or "").strip() # 判断关键字是不是操作码形态(FQX/QX 开头且含数字)。 def _looks_like_goods_opcode(keyword: str) -> bool: compact = (keyword or "").replace(" ", "").upper() return compact.startswith(("FQX", "QX")) and any(ch.isdigit() for ch in compact) # 把商品选择弹窗当前可见行收成短文本,失败时写进报错。 def _picker_rows_dump(browser: BrowserAdapter) -> str: dump = browser.execute_script( """ return Array.from(document.querySelectorAll('tr')).map((row) => { const text = (row.innerText || '').replace(/\\s+/g, ' ').trim(); const box = row.querySelector( 'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]' ); if (!box || !text) return ''; return text.slice(0, 80); }).filter(Boolean).slice(0, 8).join('|'); """ ) return str(dump or "") # 打开选择商品弹窗搜一行货,返回明细表里新占用的行号。 def _pick_goods_from_dialog( browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = () ) -> int: keyword = _goods_search_keyword(line) if not keyword: raise NodeError("货品缺少 goodsMdmCode/goodsCode,无法在「选择商品」中搜索") _select_goods_in_picker(browser, line, keyword) row = _wait_unused_goods_row(browser, line, skip, timeout=12) if row >= 0: return row _add_goods_row(browser) _select_goods_in_picker(browser, line, keyword) row = _wait_unused_goods_row(browser, line, skip, timeout=12) if row >= 0: return row _scroll_to_goods_table(browser) evidence_shot(browser, "contract-goods-pick-failed", failed=True) dump = _goods_table_dump(browser) raise NodeError(f"选择商品后未在明细表找到新的一行「{keyword}」 dump={dump}") # 在选择商品 iframe 里搜关键字并勾中对应行,然后点确定。 def _select_goods_in_picker(browser: BrowserAdapter, line: ContractGoodsLine, keyword: str) -> None: browser.click_first(GOODS_PICKER["trigger"]) browser.wait_for_element(GOODS_PICKER["frame"], timeout_seconds=15) browser.switch_to_frame(GOODS_PICKER["frame"]) try: _search_goods_in_dialog(browser, keyword) if not _dialog_row_has_keyword(browser, keyword): _search_goods_in_dialog(browser, keyword) picked = _select_dialog_row_by_keyword(browser, keyword) if not picked and line.goods_name and not _looks_like_goods_opcode(keyword): _search_goods_in_dialog(browser, line.goods_name.strip()) picked = _select_dialog_row_by_keyword(browser, keyword) if not picked: picked = _select_dialog_row_by_keyword(browser, line.goods_name.strip()) if not picked: dump = _picker_rows_dump(browser) raise NodeError(f"选择商品失败,未找到「{keyword}」 dump={dump}") time.sleep(0.5) evidence_shot(browser, "contract-goods-picker") except Exception as exc: browser.switch_to_default_content() _scroll_to_goods_table(browser) evidence_shot(browser, "contract-goods-pick-failed", failed=True) raise NodeError(f"选择商品失败,未找到「{keyword}」:{exc}") from exc browser.switch_to_default_content() _click_dialog_ok(browser) # 在商品弹窗搜索框填关键字并点搜索,等到出现含关键字的行。 def _search_goods_in_dialog(browser: BrowserAdapter, keyword: str) -> None: browser.fill_first(GOODS_PICKER["search_input"], keyword) browser.execute_script( """ const el = document.querySelector('input.search-box-text, input[placeholder*="关键字"]'); if (!el) return false; el.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true})); el.dispatchEvent(new KeyboardEvent('keyup', {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true})); if (el.form) { el.form.dispatchEvent(new Event('submit', {bubbles:true, cancelable:true})); } return true; """ ) try: browser.click_native(GOODS_PICKER["search_button"]) except Exception: try: browser.click_first(GOODS_PICKER["search_button"]) except Exception: pass deadline = time.time() + 6 while time.time() < deadline: if _dialog_row_has_keyword(browser, keyword): return time.sleep(0.4) # 商品弹窗表格里是否已有含该关键字且带勾选框的行。 def _dialog_row_has_keyword(browser: BrowserAdapter, keyword: str) -> bool: result = browser.execute_script( """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); if (!keyword) return false; return Array.from(document.querySelectorAll('tr')).some((row) => { const text = (row.innerText || '').replace(/\\s+/g, ''); return text.indexOf(keyword) >= 0 && !!row.querySelector( 'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]' ); }); """, keyword, ) return result is True # 按关键字勾中商品弹窗里对应行。勾到返回 True。 def _select_dialog_row_by_keyword(browser: BrowserAdapter, keyword: str) -> bool: picked = browser.execute_script( """ const keyword = String(arguments[0] || '').replace(/\\s+/g, ''); if (!keyword) return {ok: false}; const rows = Array.from(document.querySelectorAll('tr')); for (const row of rows) { const text = (row.innerText || '').replace(/\\s+/g, ''); if (text.indexOf(keyword) < 0) continue; const box = row.querySelector( 'input[name="List_Selected"], input[type="checkbox"], input[type="radio"]' ); if (box) { if (!box.checked) { box.click(); } return {ok: true, text: (row.innerText || '').replace(/\\s+/g, ' ').slice(0, 80)}; } row.click(); return {ok: true, text: 'row-click'}; } return {ok: false, rows: rows.length}; """, keyword, ) return isinstance(picked, dict) and bool(picked.get("ok")) # 把「选择商品」滚进视口,方便后续点添加行。 def _scroll_to_goods_table(browser: BrowserAdapter) -> None: try: browser.execute_script( """ const hit = document.querySelector('div[title="选择商品"]') || Array.from(document.querySelectorAll('div, a, span, button')) .find((el) => (el.textContent || '').trim() === '选择商品'); if (hit) hit.scrollIntoView({block: 'center'}); """ ) time.sleep(0.3) except Exception: pass # 点商品弹窗的「确定」。原生点不到就走脚本。 def _click_dialog_ok(browser: BrowserAdapter) -> None: try: browser.click_native( [ "//*[contains(@class,'lui_dialog')]//*[normalize-space()='确定']", ".lui_dialog_main div.lui_widget_btn_txt", "text=确定", ] ) return except Exception: pass result = browser.execute_script( """ const root = document.querySelector('.lui_dialog_main') || document; const btns = Array.from(root.querySelectorAll('div, a, span, button')).filter((el) => { const text = (el.textContent || '').trim(); return text === '确定' && el.children.length === 0; }); if (!btns.length) return {ok: false}; btns[btns.length - 1].click(); return {ok: true}; """ ) if isinstance(result, dict) and result.get("ok"): return browser.click_first([".lui_dialog_main div.lui_widget_btn_txt", "text=确定"]) # 把脚本返回的行号收成 int 列表。字符串按逗号拆。 def _as_index_list(value) -> list[int]: if value is None or value is False: return [] if isinstance(value, bool): return [] if isinstance(value, (int, float)): return [int(value)] if isinstance(value, str): parts = [part.strip() for part in value.replace(";", ",").split(",") if part.strip()] out: list[int] = [] for part in parts: try: out.append(int(float(part))) except ValueError: continue return out try: seq = list(value) except TypeError: return [] out: list[int] = [] for item in seq: try: out.append(int(item)) except (TypeError, ValueError): continue return out # 从匹配行号里取第一个不在 skip 里的。没有则返回 -1。 def _next_unused_goods_row(matches: list[int], skip: list[int] | tuple[int, ...] = ()) -> int: used = {int(i) for i in skip} for row in matches: if row not in used: return row return -1 # 在明细表里按 MDM/操作码找尚未占用的行。找不到返回 -1。 def _find_goods_row( browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...] = () ) -> int: row = browser.execute_script( """ const keys = [String(arguments[0] || '').trim(), String(arguments[1] || '').trim()].filter(Boolean); const skip = {}; String(arguments[2] || '').split(',').forEach((part) => { if (part !== '') skip[Number(part)] = true; }); for (let i = 0; i < 30; i++) { if (skip[i]) continue; const fields = ['fd_opcode', 'fd_mdmCode']; const values = fields.map((suffix) => { const el = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.' + suffix + ')"]' ); return el ? String(el.value || '').trim() : ''; }).filter(Boolean); if (!values.length) continue; for (const keyword of keys) { if (values.some((v) => v === keyword)) { return i; } } } return -1; """, line.mdm_code or line.goods_code, line.goods_name, ",".join(str(int(i)) for i in skip), ) try: return int(row) except (TypeError, ValueError): return -1 # 把前 10 行明细的 MDM/数量/单价收成短文本,失败时写进报错。 def _goods_table_dump(browser: BrowserAdapter) -> str: dump = browser.execute_script( """ const out = []; for (let i = 0; i < 10; i++) { const mdm = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]' ); const qty = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsqty)"]' ); const price = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_unitprice)"]' ); if (!mdm && !qty && !price) continue; out.push( String(i) + ':' + (mdm ? String(mdm.value || '') : '') + ':' + (qty ? String(qty.value || '') : '') + ':' + (price ? String(price.value || '') : '') ); } return out.join('|'); """ ) return str(dump or "") # 等到明细表出现该货的新行。超时返回 -1。 def _wait_unused_goods_row( browser: BrowserAdapter, line: ContractGoodsLine, skip: list[int] | tuple[int, ...], timeout: float, ) -> int: deadline = time.time() + timeout while True: row = _find_goods_row(browser, line, skip=skip) if row >= 0: return row if time.time() >= deadline: return -1 _click_dialog_ok(browser) time.sleep(0.6) # 返回明细表里身份字段都空的行号。 def _empty_goods_row_indexes(browser: BrowserAdapter) -> list[int]: rows = browser.execute_script( """ const found = []; for (let i = 0; i < 30; i++) { const code = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]' ); const name = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsname)"]' ); const mdm = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]' ); if (!code && !name && !mdm) continue; const empty = !(code && String(code.value || '').trim()) && !(name && String(name.value || '').trim()) && !(mdm && String(mdm.value || '').trim()); if (empty) found.push(i); } return found.join(','); """ ) return _as_index_list(rows) # 返回明细表里已填 MDM 或操作码的行号。 def _occupied_goods_row_indexes(browser: BrowserAdapter) -> list[int]: rows = browser.execute_script( """ const found = []; for (let i = 0; i < 30; i++) { const mdm = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_mdmCode)"]' ); const code = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]' ); const filled = (mdm && String(mdm.value || '').trim()) || (code && String(code.value || '').trim()); if (filled) found.push(i); } return found.join(','); """ ) return _as_index_list(rows) # 点「添加行」或调 DocList_AddRow,给同品种再占一行。 def _add_goods_row(browser: BrowserAdapter) -> None: before = _occupied_goods_row_indexes(browser) _scroll_to_goods_table(browser) result = browser.execute_script( """ const wanted = ['添加行', '新增明细', '新增行']; const nodes = Array.from(document.querySelectorAll('a, div, span, button, input')); for (const el of nodes) { const text = (el.value || el.textContent || '').replace(/\\s+/g, '').trim(); if (!wanted.includes(text)) continue; if (el.children && el.children.length > 2) continue; const rect = el.getBoundingClientRect(); if (rect.width < 2 || rect.height < 2) continue; el.click(); return {ok: true, via: text}; } const tables = Array.from(document.querySelectorAll('table[id]')).map((t) => t.id); if (typeof DocList_AddRow === 'function') { const hit = tables.find((id) => id.toLowerCase().indexOf('detail') >= 0 || id.indexOf('DocList') >= 0 ); if (hit) { DocList_AddRow(hit); return {ok: true, via: 'DocList:' + hit}; } DocList_AddRow(); return {ok: true, via: 'DocList()'}; } return {ok: false, tables: tables.slice(0, 8).join(',')}; """ ) deadline = time.time() + 5 while time.time() < deadline: if _empty_goods_row_indexes(browser): return if len(_occupied_goods_row_indexes(browser)) > len(before): return time.sleep(0.3) if isinstance(result, dict) and not result.get("ok"): raise NodeError(f"同品种需要新的明细行,未找到「添加行」 tables={result.get('tables')}") try: browser.click_native(ADD_ROW_SELECTORS) except Exception: browser.click_first(ADD_ROW_SELECTORS) time.sleep(0.5) if _empty_goods_row_indexes(browser): return dump = _goods_table_dump(browser) raise NodeError(f"同品种需要新的明细行,点击「添加行」后没有空行 dump={dump}") # 在已经填过的行里找同一货品,供同品种追加新行时克隆身份。 def _existing_used_match( browser: BrowserAdapter, line: ContractGoodsLine, used: list[int] ) -> int: key = (line.mdm_code or line.goods_code or "").strip() name = (line.goods_name or "").strip() for row in used: blob = " ".join( value for value in ( _try_read(browser, [_goods_selector("goods_mdm", row)]), _try_read(browser, [_goods_selector("goods_code", row)]), _try_read(browser, [_goods_selector("goods_name", row)]), ) if value ) if key and key in blob: return row if name and name in blob: return row return -1 # 给已有货品克隆一行空明细,返回新行号。 def _append_same_goods_row(browser: BrowserAdapter, src_row: int) -> int: empties = _empty_goods_row_indexes(browser) if not empties: _add_goods_row(browser) empties = _empty_goods_row_indexes(browser) if not empties: dump = _goods_table_dump(browser) raise NodeError(f"同品种需要新的明细行,添加行后仍没有空行 dump={dump}") dest = empties[0] _clone_goods_identity(browser, src_row, dest) return dest # 把源行的操作码、名称、MDM、批准文号、规格、单位拷到目标行。 def _clone_goods_identity(browser: BrowserAdapter, src_row: int, dest_row: int) -> None: browser.execute_script( """ const src = Number(arguments[0]); const dest = Number(arguments[1]); const fields = [ 'fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno', 'fd_goodstype', 'fd_goodsunit' ]; for (const field of fields) { const from = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + src + '.' + field + ')"]' ); const to = document.querySelector( 'input[name="extendDataFormInfo.value(fd_detail1.' + dest + '.' + field + ')"]' ); if (!from || !to) continue; to.value = from.value; to.dispatchEvent(new Event('input', {bubbles: true})); to.dispatchEvent(new Event('change', {bubbles: true})); } """, src_row, dest_row, ) # 往指定明细行填数量和单价。单价空则写 1.00。 def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine, row: int) -> None: if row < 0: raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」") qty = str(line.quantity if line.quantity else "") if qty: browser.fill_first([_goods_selector("goods_qty", row)], qty) price = (line.unit_price or "").strip() or "1.00" browser.fill_first([_goods_selector("goods_price", row)], price) # 勾掉身份为空的明细行并点删除行。 def _delete_empty_goods_rows(browser: BrowserAdapter) -> None: marked = browser.execute_script( """ const marked = []; for (let i = 0; i < 30; i++) { const code = document.querySelector('input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_opcode)"]'); const name = document.querySelector('input[name="extendDataFormInfo.value(fd_detail1.' + i + '.fd_goodsname)"]'); if (!code && !name) continue; const empty = !(code && String(code.value || '').trim()) && !(name && String(name.value || '').trim()); if (!empty) continue; const row = (code || name).closest('tr'); const box = row ? row.querySelector('input[type="checkbox"]') : null; if (box && !box.checked) { box.click(); } marked.push(i); } return marked; """ ) if marked: try: browser.click_first(["text=删除行", "a:contains(删除行)"]) time.sleep(0.5) except Exception: pass # 拼第 row_index 行某个货品字段的 input name 选择器。 def _goods_selector(field_key: str, row_index: int) -> str: suffix = GOODS_FIELD_SUFFIXES[field_key] return f'input[name="extendDataFormInfo.value(fd_detail1.{row_index}.{suffix})"]'