diff --git a/AGENTS.md b/AGENTS.md index 8a02d29..ffb4ff5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,7 +110,7 @@ | 步 | 函数 | 要点 | | --- | --- | --- | | S02 打开 | `open_contract_system` | `http://contract-test.gzmpc.com:8080/` | -| S03 登录 | `login_contract_system` | `test002` / `1` | +| S03 登录 | `login_contract_system` | `10007228` / `1` | | S04–S05 起草 | `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 | | S06 签约方 | `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` | | 尽调 | `check_customer_diligence` | 正文含「未尽调」则失败,不提交 | diff --git a/rpa_contract/README.md b/rpa_contract/README.md index 62710f9..f9fffba 100644 --- a/rpa_contract/README.md +++ b/rpa_contract/README.md @@ -15,7 +15,7 @@ ## 测试账号 地址:`http://contract-test.gzmpc.com:8080/` -账号:`test002 / 1` +账号:`10007228 / 1` 客商必须是合同系统里真实存在的操作码,占位符搜不到。例如 `DW0100046361` / `重庆亿昊药业有限公司`。 ## 页面步骤 diff --git a/rpa_contract/apa_browser_adapter.py b/rpa_contract/apa_browser_adapter.py index 5a42c94..2c091ee 100644 --- a/rpa_contract/apa_browser_adapter.py +++ b/rpa_contract/apa_browser_adapter.py @@ -54,8 +54,8 @@ class ApaBrowserAdapter: profile_base = resolve_chrome_profile_base() if _is_inside_repo(profile_base, REPO_ROOT): raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_base}") - # 每次进程单独目录:上次 detach 留下的 Chrome 会锁住同一 profile - profile_dir = profile_base / str(os.getpid()) + # 每次适配器单独目录:同进程第二组、上次 detach 留下的 Chrome 都会锁住同一 profile + profile_dir = profile_base / f"{os.getpid()}-{id(self)}" os.makedirs(profile_dir, exist_ok=True) profile_dir = str(profile_dir) diff --git a/rpa_contract/contract_create_node.py b/rpa_contract/contract_create_node.py index 8d2df60..6da0697 100644 --- a/rpa_contract/contract_create_node.py +++ b/rpa_contract/contract_create_node.py @@ -41,7 +41,7 @@ class ContractCreateInput: contract_system_no: str | None = None attempt_no: int = 1 contract_url: str = CONTRACT_URL - username: str = "test002" + username: str = "10007228" password: str = "1" submit_mode: str = "submit" order_no_list: tuple[str, ...] = () @@ -107,7 +107,7 @@ def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput: contract_system_no=_opt(data.get("contractSystemNo")), attempt_no=int(data.get("attemptNo", 1) or 1), contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL), - username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "test002"), + username=_clean(data.get("username")) or os.getenv("CONTRACT_USERNAME", "10007228"), password=str(data.get("password") or os.getenv("CONTRACT_PASSWORD", "1")), submit_mode=draft.submit_mode, order_no_list=tuple(draft.order_no_list), @@ -295,7 +295,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--contract-system-no", default=os.getenv("CONTRACT_SYSTEM_NO")) parser.add_argument("--attempt-no", type=int, default=1) parser.add_argument("--contract-url", default=os.getenv("CONTRACT_URL", CONTRACT_URL)) - parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "test002")) + parser.add_argument("--username", default=os.getenv("CONTRACT_USERNAME", "10007228")) parser.add_argument("--password", default=os.getenv("CONTRACT_PASSWORD", "1")) parser.add_argument("--draft-path", default=os.getenv("CONTRACT_DRAFT_PATH")) parser.add_argument("--merged-pdf-output", default=os.getenv("MERGED_PDF_OUTPUT")) diff --git a/rpa_contract/contract_flow_steps.py b/rpa_contract/contract_flow_steps.py index 9e0eab6..b908d95 100644 --- a/rpa_contract/contract_flow_steps.py +++ b/rpa_contract/contract_flow_steps.py @@ -113,17 +113,23 @@ def run_contract_create_flow(browser: BrowserAdapter, ctx: ContractFlowContext) fill_contract_goods_lines(browser, ctx) fill_contract_payment_info(browser, ctx) upload_contract_document_if_needed(browser, ctx) + fd_id = _read_document_id(browser) if not submit_contract_for_approval(browser, ctx): if ctx.submit_mode == "dry_run": _ACCEPT.note("S13", "读合同号", {"contractNo": "(dry_run)", "mode": "dry_run"}, ok=True) return "" _ACCEPT.note("S13", "读合同号", {"contractNo": "(none)", "mode": ctx.submit_mode}, ok=False) return "" - contract_no = read_contract_number(browser, ctx) + fd_id = fd_id or _read_document_id(browser) + contract_no = read_contract_number(browser, ctx, fd_id=fd_id) _ACCEPT.note( "S13", "读合同号", - {"contractNo": contract_no or "(none)", "via": "我的合同/申请时间/第一行"}, + { + "contractNo": contract_no or "(none)", + "via": "我的合同查询", + "fdId": fd_id or "(none)", + }, ok=_usable_contract_no(contract_no), ) return contract_no @@ -242,7 +248,6 @@ ADD_ROW_SELECTORS = [ "text=添加行", "text=新增明细", "text=新增行", - "text=新增", "button:contains(添加行)", ] @@ -309,6 +314,10 @@ def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) -> evidence_shot(browser, "contract-already-logged-in") _ACCEPT.note("S03", "登录", {"user": ctx.username, "via": "already"}, ok=True) return + browser.wait_for_element( + ['input[name="j_username"]', 'input[placeholder="用户名"]'], + timeout_seconds=15, + ) browser.fill_first(['input[name="j_username"]', 'input[placeholder="用户名"]'], ctx.username) browser.fill_first(['input[name="j_password"]', 'input[placeholder="密码"]'], ctx.password) browser.click_first(['a[class*="login_button"]', 'a:contains("登录")', 'input[name="btn_submit"]']) @@ -581,21 +590,37 @@ def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) 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: - _pick_goods_from_dialog(browser, line) - _fill_goods_required_qty_price(browser, line) + 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) evidence_shot(browser, "contract-goods-lines-filled") - for index, line in enumerate(ctx.goods_lines): - row = _find_goods_row(browser, line) - code = _try_read(browser, [_goods_selector("goods_code", row)]) if row >= 0 else "" + 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}", [ - ("goodsCode", code, line.goods_code), + code_pair, ("qty", qty, line.quantity), ("price", price, line.unit_price or "1.00"), ], @@ -604,11 +629,33 @@ def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext) ) -def _pick_goods_from_dialog(browser: BrowserAdapter, line: ContractGoodsLine) -> None: - keyword = (line.goods_code or line.goods_name or "").strip() - if not keyword: - raise NodeError("货品缺少 goodsCode/goodsName,无法在「选择商品」中搜索") +def _goods_search_keyword(line: ContractGoodsLine) -> str: + return (line.mdm_code or line.goods_code or line.goods_name or "").strip() + +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}") + + +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"]) @@ -631,15 +678,6 @@ def _pick_goods_from_dialog(browser: BrowserAdapter, line: ContractGoodsLine) -> raise NodeError(f"选择商品失败,未找到「{keyword}」:{exc}") from exc browser.switch_to_default_content() _click_dialog_ok(browser) - deadline = time.time() + 12 - while time.time() < deadline: - if _find_goods_row(browser, line) >= 0: - return - _click_dialog_ok(browser) - time.sleep(0.6) - _scroll_to_goods_table(browser) - evidence_shot(browser, "contract-goods-pick-failed", failed=True) - raise NodeError(f"选择商品后未在明细表找到「{keyword}」") def _search_goods_in_dialog(browser: BrowserAdapter, keyword: str) -> None: @@ -755,11 +793,55 @@ def _click_dialog_ok(browser: BrowserAdapter) -> None: browser.click_first([".lui_dialog_main div.lui_widget_btn_txt", "text=确定"]) -def _find_goods_row(browser: BrowserAdapter, line: ContractGoodsLine) -> 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 + + +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 + + +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_goodsname', 'fd_mdmCode', 'fd_registdocno']; const values = fields.map((suffix) => { const el = document.querySelector( @@ -777,8 +859,9 @@ def _find_goods_row(browser: BrowserAdapter, line: ContractGoodsLine) -> int: } return -1; """, - line.goods_code, + line.mdm_code or line.goods_code, line.goods_name, + ",".join(str(int(i)) for i in skip), ) try: return int(row) @@ -786,8 +869,211 @@ def _find_goods_row(browser: BrowserAdapter, line: ContractGoodsLine) -> int: return -1 -def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine) -> None: - row = _find_goods_row(browser, line) +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 "") + + +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) + + +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) + + +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 + + +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, + ) + + +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 "") @@ -882,18 +1168,24 @@ def _fill_concession_defaults(browser: BrowserAdapter) -> None: ) -def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: +def _resolve_contract_body_path(ctx: ContractFlowContext) -> str: path = (ctx.contract_document_path or "").strip() if not path and ctx.source_type == "YYC": path = (ctx.merged_pdf_path or "").strip() + if path: + return path + if ctx.submit_mode == "dry_run": + return "" + from pdf_utils import ensure_placeholder_contract_pdf + + return ensure_placeholder_contract_pdf() + + +def upload_contract_document_if_needed(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: + path = _resolve_contract_body_path(ctx) if not path: - if ctx.submit_mode == "dry_run": - evidence_shot(browser, "contract-pdf-upload-skipped") - _ACCEPT.note("S11", "上传正文", {"skipped": "dry_run_no_file"}, ok=True) - return - if ctx.source_type == "YYC": - raise PdfMergeUnsupported("缺少合同正文/平台合同文件,无法上传") - _ACCEPT.note("S11", "上传正文", {"skipped": ctx.source_type or "no_file"}, ok=True) + evidence_shot(browser, "contract-pdf-upload-skipped") + _ACCEPT.note("S11", "上传正文", {"skipped": "dry_run_no_file"}, ok=True) return try: _upload_contract_body(browser, path) @@ -1070,36 +1362,62 @@ def submit_contract_for_approval(browser: BrowserAdapter, ctx: ContractFlowConte _ACCEPT.note("S12", "提交", {"mode": "dry_run", "submitted": "no"}, ok=True) return False _close_blocking_dialogs(browser) - fd_id = _read_document_id(browser) + # 测试环境直接提交会落到「该文档不存在」:先暂存落库,再提交。合同号仍从我的合同查。 try: - browser.click_native( - [ - "//*[normalize-space()='暂存']/following::*[normalize-space()='提交'][1]", - "text=提交", - ] - ) - except Exception: - _click_top_submit(browser) + _click_toolbar_button(browser, "暂存") + except Exception as exc: + print(f"暂存未点到:{exc}", flush=True) + saved = _wait_after_save_or_submit(browser, timeout_seconds=15) + blocked = _submit_validation_error(browser) + if blocked: + evidence_shot(browser, "contract-submitted", failed=True) + _ACCEPT.note("S12", "提交", {"submitted": "no", "error": blocked, "at": "暂存"}, ok=False, raise_error=False) + return False + if not saved: + print("暂存后仍在表单页,继续点提交。", flush=True) + try: + _click_toolbar_button(browser, "提交") + except Exception as exc: + evidence_shot(browser, "contract-submitted", failed=True) + _ACCEPT.note("S12", "提交", {"submitted": "no", "error": str(exc)[:120]}, ok=False, raise_error=False) + return False evidence_shot(browser, "contract-after-submit-click") - _confirm_submit_dialogs(browser) - deadline = time.time() + 5 + submitted = _wait_after_save_or_submit(browser, timeout_seconds=20) + blocked = _submit_validation_error(browser) + if blocked: + evidence_shot(browser, "contract-submitted", failed=True) + _ACCEPT.note("S12", "提交", {"submitted": "no", "error": blocked}, ok=False, raise_error=False) + return False + print("提交已点,回我的合同查询合同号。", flush=True) + _ACCEPT.note("S12", "提交", {"submitted": "yes", "via": "暂存后提交", "leftForm": "yes" if submitted else "no"}, ok=True) + return True + + +def _submit_error_from_body(body: str) -> str: + text = body or "" + if "该文档不存在" in text: + return "该文档不存在" + if "操作失败" in text and "您的请求已提交" in text: + return "操作失败" + return "" + + +def _wait_after_save_or_submit(browser: BrowserAdapter, timeout_seconds: float) -> bool: + deadline = time.time() + timeout_seconds while time.time() < deadline: - if _submit_succeeded(browser): - evidence_shot(browser, "contract-submitted") - print("提交成功,回我的合同取编号。", flush=True) - fd_id = fd_id or _read_document_id(browser) - _ACCEPT.note("S12", "提交", {"submitted": "yes"}, ok=True) - return True + if _submit_validation_error(browser): + return False _confirm_submit_dialogs(browser) + if _submit_succeeded(browser): + return True time.sleep(0.5) - evidence_shot(browser, "contract-submitted", failed=True) - print("提交后仍在起草页,浏览器保持打开,请手工检查。", flush=True) - _ACCEPT.note("S12", "提交", {"submitted": "no", "page": "draft"}, ok=False, raise_error=False) - return False + return not bool(_submit_validation_error(browser)) and _submit_succeeded(browser) def _submit_succeeded(browser: BrowserAdapter) -> bool: body = browser.read_body_text() or "" + if _submit_error_from_body(body): + return False if any(mark in body for mark in ("您的操作已成功", "操作已成功", "提交成功", "流程已提交", "成功提交")): return True url = "" @@ -1110,6 +1428,28 @@ def _submit_succeeded(browser: BrowserAdapter) -> bool: return "method=add" not in url and "kmAgreementApply" in url +def _submit_validation_error(browser: BrowserAdapter) -> str: + body = browser.read_body_text() or "" + hit = _submit_error_from_body(body) + if hit: + return hit + text = browser.execute_script( + """ + const roots = Array.from(document.querySelectorAll( + '.lui_dialog, .lui_message, .lui_alert, .lui_dialog_content, .prompt_frame' + )); + return roots.map((el) => (el.innerText || '').trim()).filter(Boolean).join('\\n').slice(0, 400); + """ + ) + blob = str(text or "").strip() + if not blob: + return "" + for mark in ("不能为空", "请填写", "校验失败", "提交失败", "必填项未填", "该文档不存在", "操作失败"): + if mark in blob: + return blob[:120] + return "" + + def _read_document_id(browser: BrowserAdapter) -> str: value = browser.execute_script( """ @@ -1138,36 +1478,52 @@ def _open_submitted_view(browser: BrowserAdapter, fd_id: str) -> None: time.sleep(1.5) -def _click_top_submit(browser: BrowserAdapter) -> None: +def _click_toolbar_button(browser: BrowserAdapter, label: str) -> None: clicked = browser.execute_script( """ - const toolbar = document.querySelector('.lui_toolbar_frame, .lui_toolbar, [class*="lui_toolbar"]'); - const root = toolbar || document; - const hits = Array.from(root.querySelectorAll('div, a, button, span')).filter((el) => { - return (el.textContent || '').trim() === '提交' && el.children.length === 0; - }); - if (!hits.length) return {ok: false}; - const save = Array.from(document.querySelectorAll('div, a, span')).find((el) => { - return (el.textContent || '').trim() === '暂存' && el.children.length === 0; - }); - const target = (save && hits.find((el) => { - const a = save.getBoundingClientRect(); - const b = el.getBoundingClientRect(); - return Math.abs(a.top - b.top) < 40; - })) || hits[0]; - target.click(); - return {ok: true}; - """ + const label = String(arguments[0] || ''); + function visible(el) { + const box = el.getBoundingClientRect(); + return box.width > 2 && box.height > 2; + } + function clickIn(win) { + const toolbar = win.document.querySelector( + '.lui_toolbar_frame, .lui_toolbar, [class*="lui_toolbar"]' + ); + const roots = [toolbar, win.document].filter(Boolean); + for (const root of roots) { + const hits = Array.from(root.querySelectorAll('div, a, button, span, li')).filter((el) => { + return (el.textContent || '').replace(/\\s+/g, '').trim() === label && visible(el); + }); + hits.sort((a, b) => a.children.length - b.children.length); + if (hits.length) { + hits[0].click(); + return true; + } + } + for (let i = 0; i < win.frames.length; i++) { + try { if (clickIn(win.frames[i])) return true; } catch (e) {} + } + return false; + } + return {ok: clickIn(window), label: label}; + """, + label, ) + print(f"toolbar {label} js={clicked}", flush=True) if isinstance(clicked, dict) and clicked.get("ok"): return - browser.click_first(["text=提交", "text=提交送审", "text=送审"]) + browser.click_first([f"text={label}", f"button:contains({label})"]) + + +def _click_top_submit(browser: BrowserAdapter) -> None: + _click_toolbar_button(browser, "提交") def _confirm_submit_dialogs(browser: BrowserAdapter) -> None: browser.execute_script( """ - const labels = ['确定', '确认', '是', '提交', 'OK']; + const labels = ['确定', '确认', '是', 'OK']; const nodes = Array.from(document.querySelectorAll('div, a, button, span, input')); for (const label of labels) { const hit = nodes.find((el) => { @@ -1182,7 +1538,9 @@ def _confirm_submit_dialogs(browser: BrowserAdapter) -> None: time.sleep(0.8) -def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | None = None) -> str: +def read_contract_number( + browser: BrowserAdapter, ctx: ContractFlowContext | None = None, *, fd_id: str = "" +) -> str: if ctx is None: ctx = ContractFlowContext( contract_url="http://contract-test.gzmpc.com:8080/", @@ -1192,7 +1550,7 @@ def read_contract_number(browser: BrowserAdapter, ctx: ContractFlowContext | Non order_date="", source_type="", ) - return _read_contract_number_from_my_list(browser, ctx) + return _read_contract_number_from_my_list(browser, ctx, fd_id=fd_id) def _read_contract_number_on_page(browser: BrowserAdapter) -> str: @@ -1200,43 +1558,51 @@ def _read_contract_number_on_page(browser: BrowserAdapter) -> str: browser.switch_to_default_content() except Exception: pass - try: - value = (browser.read_first( - [ - 'input[name="fdNumber"]', - 'input[name="contractNo"]', - 'input[name="contractSystemNo"]', - "label=合同编号", - ], - "contract_number", - ) or "").strip() - if _usable_contract_no(value): - return value - except Exception: - pass scanned = browser.execute_script( """ - const skip = ['提交时自动生成', '自动生成', '==请选择==']; - const inputs = document.querySelectorAll( - 'input[name="fdNumber"], input[name="contractNo"], input[name*="fdNumber"]' - ); - for (const el of inputs) { - const v = (el.value || '').trim(); - if (v && skip.indexOf(v) < 0) return v; + const skip = ['提交时自动生成', '自动生成', '==请选择==', '合同编号', '合同标题']; + function fromDoc(doc) { + const inputs = doc.querySelectorAll( + 'input[name="fdNumber"], input[name="contractNo"], input[name="contractSystemNo"], input[name*="fdNumber"]' + ); + for (const el of inputs) { + const v = (el.value || '').trim(); + if (v && skip.indexOf(v) < 0) return v; + } + const nodes = Array.from(doc.querySelectorAll('td, th, span, div, label, em, b')); + for (let i = 0; i < nodes.length; i++) { + const label = (nodes[i].innerText || '').replace(/\\s+/g, '').trim(); + if (label !== '合同编号' && label !== '合同编号:' && label !== '合同编号:') continue; + const box = nodes[i].parentElement || nodes[i]; + const typed = box.querySelector('input, textarea'); + const n = typed + ? String(typed.value || '').trim() + : ((nodes[i + 1] && (nodes[i + 1].innerText || nodes[i + 1].value || '')) || '') + .replace(/\\s+/g, ' ').trim(); + if (n && skip.indexOf(n) < 0) return n; + } + return ''; } - const nodes = Array.from(document.querySelectorAll('td, th, span, div, label, em, b')); - for (let i = 0; i < nodes.length; i++) { - const label = (nodes[i].innerText || '').replace(/\\s+/g, '').trim(); - if (label !== '合同编号' && label !== '合同编号:') continue; - const next = nodes[i + 1]; - const n = next ? ((next.innerText || next.value || '').replace(/\\s+/g, ' ').trim()) : ''; - if (n && skip.indexOf(n) < 0) return n; + function walk(win) { + try { + const no = fromDoc(win.document); + if (no) return no; + } catch (e) {} + for (let i = 0; i < win.frames.length; i++) { + try { + const no = walk(win.frames[i]); + if (no) return no; + } catch (e) {} + } + return ''; } - return ''; + return walk(window); """ ) if isinstance(scanned, str) and _usable_contract_no(scanned.strip()): return scanned.strip() + if scanned: + print(f"S13 详情页扫到不可用值={scanned!r}", flush=True) body = browser.read_body_text() or "" for line in body.splitlines(): text = line.strip() @@ -1423,24 +1789,258 @@ def _scrape_latest_my_contract_number(browser: BrowserAdapter) -> str: return "" +def _pick_contract_row( + rows: list[dict], + *, + fd_id: str = "", + title: str = "", + customer_code: str = "", +) -> str: + fd_id = (fd_id or "").strip() + title = (title or "").strip() + customer_code = (customer_code or "").strip() + if fd_id: + for row in rows: + href = str(row.get("href") or "") + no = str(row.get("no") or "").strip() + if fd_id in href and _usable_contract_no(no): + return no + if title and customer_code: + for row in rows: + row_title = str(row.get("title") or "") + row_cust = str(row.get("customer") or "") + no = str(row.get("no") or "").strip() + if title in row_title and customer_code in row_cust and _usable_contract_no(no): + return no + if title: + for row in rows: + no = str(row.get("no") or "").strip() + if title in str(row.get("title") or "") and _usable_contract_no(no): + return no + return "" + + +def _search_my_contracts(browser: BrowserAdapter, title: str, customer_code: str) -> None: + title = (title or "").strip() + customer_code = (customer_code or "").strip() + if not title and not customer_code: + return + browser.execute_script( + """ + const title = String(arguments[0] || ''); + const customer = String(arguments[1] || ''); + function setValue(el, value) { + if (!el) return false; + el.focus(); + el.value = value; + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new Event('change', {bubbles: true})); + return true; + } + function fillNearLabel(doc, label, value) { + if (!value) return false; + const nodes = Array.from(doc.querySelectorAll('td, th, span, label, div, em')); + for (const node of nodes) { + const text = (node.innerText || '').replace(/\\s+/g, '').trim(); + if (text !== label && text !== label + ':' && text !== label + ':') continue; + const box = node.parentElement || node; + const input = box.querySelector('input[type="text"], input:not([type]), input[type="search"]'); + if (setValue(input, value)) return true; + const next = node.nextElementSibling; + if (next && setValue(next.querySelector('input'), value)) return true; + } + return false; + } + function clickSearch(doc) { + const labels = ['搜索', '查询']; + const nodes = Array.from(doc.querySelectorAll('a, div, span, button, input')); + for (const label of labels) { + const hit = nodes.find((el) => { + const text = (el.textContent || el.value || '').trim(); + return text === label && (!el.children || el.children.length === 0); + }); + if (hit) { hit.click(); return true; } + } + return false; + } + function walk(win) { + try { + fillNearLabel(win.document, '合同标题', title) + || fillNearLabel(win.document, '合同(协议)全称', title); + fillNearLabel(win.document, '客户编码', customer); + clickSearch(win.document); + } catch (e) {} + for (let i = 0; i < win.frames.length; i++) { + try { walk(win.frames[i]); } catch (e) {} + } + } + walk(window); + return true; + """, + title, + customer_code, + ) + _wait_text_in_frames(browser, "合同编号", timeout_seconds=10) + + +def _scrape_my_contract_rows(browser: BrowserAdapter) -> list[dict]: + raw = browser.execute_script( + """ + function scrapeTable(doc) { + const rows = Array.from(doc.querySelectorAll('tr')); + let header = null; + const found = []; + for (const tr of rows) { + const cells = Array.from(tr.querySelectorAll('th, td')).map((c) => + (c.innerText || '').replace(/\\s+/g, ' ').trim() + ); + if (!header && cells.indexOf('合同编号') >= 0) { + header = {}; + cells.forEach((h, i) => { if (h && header[h] === undefined) header[h] = i; }); + continue; + } + if (!header) continue; + const no = (cells[header['合同编号']] || '').trim(); + const title = ( + cells[header['合同标题']] + || cells[header['合同(协议)全称']] + || cells[header['合同名称']] + || cells[header['主题']] + || cells[header['标题']] + || '' + ).trim(); + const customer = ( + cells[header['客户编码']] || cells[header['客户名称']] || cells[header['客商编码']] || '' + ).trim(); + const link = tr.querySelector('a[href*="fdId"], a[href*="fdid"]'); + const box = tr.querySelector('input[name="List_Selected"]'); + const href = link + ? String(link.getAttribute('href') || '') + : (box ? String(box.value || '') : (tr.innerHTML || '')); + if (!no && !href) continue; + found.push({no: no, title: title, customer: customer, href: href}); + } + return found; + } + function scrapeLinks(doc) { + const found = []; + const links = Array.from(doc.querySelectorAll('a[href*="fdId"], a[href*="fdid"]')); + for (const a of links) { + const href = String(a.getAttribute('href') || ''); + const tr = a.closest('tr') || a.parentElement; + const blob = ((tr && tr.innerText) || a.innerText || '').replace(/\\s+/g, ' ').trim(); + const hit = blob.match(/HT[0-9A-Za-z-]+/) || blob.match(/\\b\\d{10,}\\b/); + found.push({ + no: hit ? hit[0] : '', + title: blob, + customer: blob, + href: href + }); + } + return found; + } + function walk(win) { + let all = []; + try { all = all.concat(scrapeTable(win.document)); } catch (e) {} + try { all = all.concat(scrapeLinks(win.document)); } catch (e) {} + for (let i = 0; i < win.frames.length; i++) { + try { all = all.concat(walk(win.frames[i])); } catch (e) {} + } + return all; + } + return walk(window); + """ + ) + if not isinstance(raw, list): + return [] + rows: list[dict] = [] + for item in raw: + if not isinstance(item, dict): + continue + rows.append( + { + "no": str(item.get("no") or "").strip(), + "title": str(item.get("title") or "").strip(), + "customer": str(item.get("customer") or "").strip(), + "href": str(item.get("href") or "").strip(), + } + ) + return rows + + +def _row_has_fd_id(rows: list[dict], fd_id: str) -> bool: + token = (fd_id or "").strip() + if not token: + return False + return any(token in str(row.get("href") or "") for row in rows) + + +def _read_number_by_fd_id(browser: BrowserAdapter, fd_id: str) -> str: + doc_id = (fd_id or "").strip() + if not doc_id: + return "" + _open_submitted_view(browser, doc_id) + deadline = time.time() + 10 + while time.time() < deadline: + body = browser.read_body_text() or "" + if "该文档不存在" in body or "操作失败" in body: + print(f"S13 详情不存在 fdId={doc_id}", flush=True) + return "" + no = _read_contract_number_on_page(browser) + if no: + print("从合同详情读到合同编号:", no, flush=True) + return no + time.sleep(0.8) + return "" + + def _read_contract_number_from_my_list( browser: BrowserAdapter, ctx: ContractFlowContext, - timeout_seconds: int = 15, + timeout_seconds: int = 25, + fd_id: str = "", ) -> str: - print("S13:刷新主界面 → 我的合同 → 申请时间 → 第一行最新合同号", flush=True) + title = _expected_title(ctx) + customer = (ctx.customer_code or "").strip() + print( + f"S13:回我的合同查询 fdId={fd_id or '-'} title={title} customer={customer}", + flush=True, + ) _open_my_contracts(browser, ctx) - _sort_my_contracts_by_apply_time(browser) deadline = time.time() + timeout_seconds - last = "" + searched = False while time.time() < deadline: - last = _scrape_latest_my_contract_number(browser) - if last: - print("从我的合同读到最新合同编号:", last, flush=True) - return last - time.sleep(0.3) - print("我的合同列表未读到合同编号", flush=True) - return last + rows = _scrape_my_contract_rows(browser) + print(f"S13 列表行数={len(rows)}", flush=True) + for row in rows[:6]: + print( + "S13 row" + f" no={row.get('no')!r}" + f" title={str(row.get('title') or '')[:60]!r}" + f" customer={str(row.get('customer') or '')[:30]!r}" + f" href={str(row.get('href') or '')[:90]!r}", + flush=True, + ) + no = _pick_contract_row( + rows, + fd_id=fd_id, + title=title, + customer_code=customer, + ) + if no: + print("从我的合同读到合同编号:", no, flush=True) + return no + if fd_id and _row_has_fd_id(rows, fd_id): + no = _read_number_by_fd_id(browser, fd_id) + if no: + return no + _open_my_contracts(browser, ctx) + if not searched: + _search_my_contracts(browser, title, customer) + searched = True + time.sleep(1) + print("我的合同列表未匹配到合同编号", flush=True) + return "" def _usable_contract_no(value: str) -> bool: diff --git a/rpa_contract/examples/placeholder_contract.pdf b/rpa_contract/examples/placeholder_contract.pdf new file mode 100644 index 0000000..aa8bb68 --- /dev/null +++ b/rpa_contract/examples/placeholder_contract.pdf @@ -0,0 +1,45 @@ +%PDF-1.3 +%âãÏÓ +1 0 obj +<< +/Producer (pypdf) +>> +endobj +2 0 obj +<< +/Type /Pages +/Count 1 +/Kids [ 4 0 R ] +>> +endobj +3 0 obj +<< +/Type /Catalog +/Pages 2 0 R +>> +endobj +4 0 obj +<< +/Type /Page +/Resources << +>> +/MediaBox [ 0.0 0.0 612 792 ] +/Parent 2 0 R +>> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000054 00000 n +0000000113 00000 n +0000000162 00000 n +trailer +<< +/Size 5 +/Root 3 0 R +/Info 1 0 R +>> +startxref +256 +%%EOF diff --git a/rpa_contract/group.py b/rpa_contract/group.py index 6f311ad..79c19e6 100644 --- a/rpa_contract/group.py +++ b/rpa_contract/group.py @@ -31,6 +31,9 @@ def missing_mdm_reason(rows: list[PurchaseContractRow]) -> str | None: return "缺少供应商MDM码" if not (rows[0].supplier_mdm_code or "").strip(): return "缺少供应商MDM码" + for row in rows: + if not (row.goods_mdm_code or "").strip(): + return "缺少货品MDM码" return None @@ -58,14 +61,14 @@ def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft: ) goods = [ ContractGoodsLine( - operation_code=row.goods_id, - goods_code=row.goods_id, + operation_code=(row.goods_mdm_code or "").strip(), + goods_code=(row.goods_mdm_code or "").strip(), goods_name=row.goods_name, approval_no=row.reg_no, spec=row.spec, unit=row.unit, quantity=float(row.qty), - mdm_code=row.goods_mdm_code or "", + mdm_code=(row.goods_mdm_code or "").strip(), unit_price=money4(row.unit_price), ) for row in rows diff --git a/rpa_contract/pdf_utils.py b/rpa_contract/pdf_utils.py index 78b40e8..65caba4 100644 --- a/rpa_contract/pdf_utils.py +++ b/rpa_contract/pdf_utils.py @@ -1,8 +1,25 @@ -"""合同 PDF 合并。""" +"""合同 PDF 合并、提交占位正文。""" from __future__ import annotations from pathlib import Path +_PLACEHOLDER_PDF = Path(__file__).resolve().parent / "examples" / "placeholder_contract.pdf" + + +def ensure_placeholder_contract_pdf() -> str: + """提交必须挂合同正文;没有平台文件时用这一份占位 PDF。""" + path = _PLACEHOLDER_PDF + if path.is_file() and path.stat().st_size > 64: + return str(path) + path.parent.mkdir(parents=True, exist_ok=True) + from pypdf import PdfWriter + + writer = PdfWriter() + writer.add_blank_page(width=612, height=792) + with path.open("wb") as file: + writer.write(file) + return str(path) + def ensure_merged_pdf(source_paths: list[str], output_path: str) -> str: """Return one PDF path, merging multiple source PDFs when needed. diff --git a/rpa_contract/tests/test_flow_gates.py b/rpa_contract/tests/test_flow_gates.py index 1c99c4b..8c281cc 100644 --- a/rpa_contract/tests/test_flow_gates.py +++ b/rpa_contract/tests/test_flow_gates.py @@ -1,8 +1,15 @@ +from pathlib import Path + from contract_flow_steps import ( ContractFlowContext, begin_date_for, check_customer_diligence, sign_date_for, + _as_index_list, + _next_unused_goods_row, + _pick_contract_row, + _resolve_contract_body_path, + _submit_error_from_body, ) from errors import DiligenceError import pytest @@ -48,3 +55,82 @@ def test_diligence_raises_when_page_says_not_done(): def test_diligence_passes_when_text_absent(): check_customer_diligence(_FakeBrowser("客户已尽调"), _ctx()) + + +def test_same_product_keeps_own_row(): + assert _next_unused_goods_row([0, 1, 2], []) == 0 + assert _next_unused_goods_row([0, 1, 2], [0]) == 1 + assert _next_unused_goods_row([0, 1, 2], [0, 1]) == 2 + assert _next_unused_goods_row([0], [0]) == -1 + assert _next_unused_goods_row([], [0]) == -1 + + +def test_goods_row_indexes_from_script_string(): + assert _as_index_list("0,1,2") == [0, 1, 2] + assert _as_index_list("") == [] + assert _as_index_list(None) == [] + assert _as_index_list([0, 1]) == [0, 1] + + +def test_pick_contract_row_fd_id_beats_latest(): + rows = [ + {"no": "HT-NEW", "title": "别人的合同", "customer": "X", "href": "fdId=aaa"}, + { + "no": "HT-OURS", + "title": "重庆亿昊药业有限公司采购协议20261002", + "customer": "DW0100046361", + "href": "kmAgreementApply.do?method=view&fdId=ours", + }, + ] + assert ( + _pick_contract_row( + rows, + fd_id="ours", + title="重庆亿昊药业有限公司采购协议20261002", + customer_code="DW0100046361", + ) + == "HT-OURS" + ) + + +def test_pick_contract_row_title_and_customer(): + rows = [ + {"no": "HT-NEW", "title": "别人的合同", "customer": "X", "href": "fdId=aaa"}, + { + "no": "HT-OURS", + "title": "重庆亿昊药业有限公司采购协议20261002", + "customer": "DW0100046361", + "href": "fdId=ours", + }, + ] + assert ( + _pick_contract_row( + rows, + title="重庆亿昊药业有限公司采购协议20261002", + customer_code="DW0100046361", + ) + == "HT-OURS" + ) + + +def test_pick_contract_row_does_not_take_first_unmatched(): + rows = [{"no": "HT-NEW", "title": "别人的合同", "customer": "X", "href": "fdId=aaa"}] + assert _pick_contract_row(rows, title="重庆亿昊", customer_code="DW0100046361") == "" + + +def test_submit_error_from_doc_missing_page(): + body = "您的请求已提交,系统返回如下信息:\n操作失败!\n该文档不存在" + assert _submit_error_from_body(body) == "该文档不存在" + assert _submit_error_from_body("客户编码 提交") == "" + + +def test_submit_without_file_uses_placeholder_body(): + ctx = _ctx(submit_mode="submit", source_type="") + path = _resolve_contract_body_path(ctx) + assert Path(path).is_file() + assert Path(path).stat().st_size > 64 + + +def test_dry_run_without_file_skips_body(): + ctx = _ctx(submit_mode="dry_run", source_type="") + assert _resolve_contract_body_path(ctx) == "" diff --git a/rpa_contract/tests/test_group.py b/rpa_contract/tests/test_group.py index da802b9..31b96c6 100644 --- a/rpa_contract/tests/test_group.py +++ b/rpa_contract/tests/test_group.py @@ -79,8 +79,9 @@ def test_group_by_supplier_keeps_fid_order_and_first_header(): assert draft.source_type == "" assert draft.is_prepayment == "否" assert len(draft.goods_lines) == 2 - assert draft.goods_lines[0].goods_code == "G0001" - assert draft.goods_lines[0].operation_code == "G0001" + assert draft.goods_lines[0].goods_code == "QX0100167583" + assert draft.goods_lines[0].operation_code == "QX0100167583" + assert draft.goods_lines[0].mdm_code == "QX0100167583" assert draft.goods_lines[0].unit_price == "100.0000" assert draft.belong_year == "2026" @@ -88,6 +89,7 @@ def test_group_by_supplier_keeps_fid_order_and_first_header(): def test_missing_mdm_reason(): rows = [_row(supplier_mdm_code="")] assert missing_mdm_reason(rows) == "缺少供应商MDM码" + assert missing_mdm_reason([_row(goods_mdm_code="")]) == "缺少货品MDM码" assert missing_mdm_reason([_row()]) is None diff --git a/rpa_contract/tests/test_job.py b/rpa_contract/tests/test_job.py index 2fc991c..c4d7be3 100644 --- a/rpa_contract/tests/test_job.py +++ b/rpa_contract/tests/test_job.py @@ -90,7 +90,14 @@ def test_job_merges_same_supplier_and_writes_table4(): run_batch(repo, runner=runner) assert len(calls) == 1 - assert [line.goods_code for line in calls[0].goods_lines] == ["G0001", "G0002"] + assert [line.goods_code for line in calls[0].goods_lines] == [ + "QX0100167583", + "QX0100167583", + ] + assert [line.mdm_code for line in calls[0].goods_lines] == [ + "QX0100167583", + "QX0100167583", + ] assert calls[0].customer_code == "DW0100046361" assert [row["oms_id"] for row in repo.table4] == [2026100501, 2026100502] assert {row["contract_no"] for row in repo.table4} == {"HT20261005001"}