# 返利细单新增:按货品ID搜、已在表则跳过、ACCEPT 成功失败条数。 import pytest from accept import accept_log_path from apa_browser_adapter import chrome_debug_port, chrome_session_flow, resolve_chrome_profile_dir from errors import NodeError from rebate_flow_steps import ( COMPENSATION_DEFAULT, LOGIN, PAY_MODE_CODE, PAY_PARAM_DEFAULT, SETTLE_CODE, TAX_RATE_DEFAULT, RebateFlowContext, RebateImportLine, add_detail_lines_by_goods_id, build_import_draft, build_import_rows, fill_import_excel, run_import_flow, write_canonical_template, ) # 细单流程假浏览器,按脚本关键字回货品表行和导入结果。 class FakeBrowser: # 记下页面脚本回传的勾选、保存和协议号。 def __init__( self, *, table_text="", body_text="", login_body="", import_result=None, goods_rows=None, ): self.table_text = table_text self.body_text = body_text self.login_body = login_body or body_text self.import_result = import_result or {"success": 1, "fail": 0, "reasons": []} self.goods_rows = list(goods_rows) if goods_rows is not None else [] self.last_goods_id = "" self.opened: list[str] = [] self.filled: list[tuple[list[str], str]] = [] self.selected: list[tuple[list[str], str]] = [] self.clicked: list[list[str]] = [] self.uploaded: list[str] = [] self.download_dir = None self.script_count = 0 # 记下打开过的 URL。 def open_url(self, url: str) -> None: self.opened.append(url) # 记下填过的选择器和值。 def fill_first(self, selectors: list[str], value: str) -> None: self.filled.append((list(selectors), value)) # 记下点过的选择器。 def click_first(self, selectors: list[str]) -> None: self.clicked.append(list(selectors)) # 记下原生点击,行为同 click_first。 def click_native(self, selectors: list[str]) -> None: self.click_first(selectors) # 假正文里没有这段字就抛 TimeoutError。 def wait_for_text(self, text: str, timeout_seconds: int = 15) -> None: haystack = f"{self.body_text}\n{self.table_text}\n{self.login_body}" if text not in haystack: raise TimeoutError(text) # 假页面一律当作元素已在。 def wait_for_element(self, selectors: list[str], timeout_seconds: int = 15) -> None: return None # 假页面一律当作元素已在。 def element_exists(self, selectors: list[str]) -> bool: return True # 返回构造时塞进的正文。 def read_body_text(self) -> str: return self.body_text # 按脚本标记回勾选、保存、协议号。 def execute_script(self, script: str, *args): self.script_count += 1 text = script or "" if "SELECT_HEADER_ROW" in text: return {"ok": True, "text": self.table_text} if "GOODS_ADD_VISIBLE" in text: return True if "CLICK_GOODS_ADD" in text: self.clicked.append(["+新增"]) return {"ok": True, "text": "+新增"} if "CLOSE_HEADER_QUERY" in text: return {"ok": True} if "OPEN_GOODS_PICKER" in text: return {"ok": True} if "WAIT_GOODS_PICKER" in text: return True if "GOODS_ID_FIELD" in text: return True if "OPEN_GOODS_ID_QUERY" in text: return {"ok": True, "already": True} if "SAVE_DETAIL" in text: self.clicked.append(["保存"]) return {"ok": True} if "CONFIRM_SAVE_POPUP" in text: return {"ok": True, "asked": False} if "OPEN_DETAIL_SELECT" in text: return {"ok": True} if "FILTER_DETAIL_SELECT" in text: return {"ok": True} if "CLOSE_DETAIL_CARD" in text: return {"ok": True} if "CLOSE_HEADER_CARD" in text: return {"ok": True} if "GOODS_TABLE_ROWS" in text: return list(self.goods_rows) if "FILL_GOODS_ID" in text: self.last_goods_id = str(args[0] if args else "") return {"ok": True, "label": "货品ID", "value": self.last_goods_id} if "CONFIRM_DETAIL_CARD" in text: gid = self.last_goods_id if gid and not any(gid in row for row in self.goods_rows): self.goods_rows.append(gid) return {"ok": True, "via": "确认"} if "PICK_DETAIL_OPTION" in text: return {"ok": True, "text": str(args[0] if args else "")} if "DETAIL_TOAST" in text: return {"texts": []} if "DETAIL_FORM_ERRORS" in text: return [] if "PICK_GOODS_ROW" in text: return {"ok": True} if "SOURCE_CHOICE" in text: return False if "PICK_NTH_OVERLAY" in text: return {"ok": True, "text": str(args[1] if len(args) > 1 else "")} if "TYPE_DETAIL_FIELD" in text: return True if "FILL_DETAIL_CELLS" in text: return {"qty": True, "price": True, "remark": True, "compensation": True, "tax": True, "payParam": True, "inbound": True} if "DETAIL_CARD_PAGER" in text: return "第 1 / 1 条记录" if "OPEN_HEADER_ROW" in text: return {"ok": True, "via": "row"} if "READ_IMPORT_RESULT" in text: return dict(self.import_result) if "table-toolbar-btn__text" in text: title = str(args[0] if args else "") self.clicked.append([title]) if title in {"下载模板", "下载导入模板", "导出模板", "导入模板", "模板下载"}: self._write_template() return {"ok": True} return self.table_text # 假截图,返回空路径。 def screenshot(self, name: str) -> str: return name # 假读取,返回空字符串。 def read_first(self, selectors: list[str], value_name: str = "") -> str: return "" # 假切入 iframe,什么都不做。 def switch_to_frame(self, selectors: list[str]) -> None: return None # 假切回主文档,什么都不做。 def switch_to_default_content(self) -> None: return None # 记下选过的选择器和值。 def select_first(self, selectors: list[str], value: str) -> None: self.selected.append((list(selectors), value)) # 记下 xform 选择。 def select_xform(self, name: str, value: str) -> None: return None # 记下上传路径。 def upload_file_first(self, selectors: list[str], file_path: str) -> None: self.uploaded.append(file_path) # 记下下载目录。 def set_download_dir(self, path: str) -> None: from pathlib import Path self.download_dir = Path(path) self.download_dir.mkdir(parents=True, exist_ok=True) # 在下载目录写一份空 xlsx 当模板。 def _write_template(self) -> None: if self.download_dir is None: return write_canonical_template(self.download_dir / "detail_template.xlsx") # 构造一段假返利会话入参。 def _ctx() -> RebateFlowContext: return RebateFlowContext(rebate_url="http://example", username="u", password="p") # 构造一条带货品ID和订单号的细单。 def _line() -> RebateImportLine: return RebateImportLine( goods_code="G001", qty="10", tax_price="6.70", order_no="ZQD1", source_line_id="SRC1", ) # 构造一份含一行细单的导入草稿。 def _draft(): return build_import_draft(lines=[_line()], today="2026-10-10") # Excel 行字典走现行列:备注=订单号、补偿 0、承付参数 25。 def test_import_rows_follow_mapping(): row = build_import_rows([_line()], today="2026-10-10")[0] assert row["货品编码"] == "G001" assert row["数量"] == "10" assert row["含税协议单价"] == "6.70" assert row["税率"] == TAX_RATE_DEFAULT assert row["关税税率"] == TAX_RATE_DEFAULT assert row["承付模式"] == PAY_MODE_CODE assert row["承付参数"] == PAY_PARAM_DEFAULT assert row["采购结算方式"] == SETTLE_CODE assert row["细单开始日期"] == "2026-10-10" assert row["细单结束日期"] == "2026-12-31" assert row["特定补偿预估值"] == COMPENSATION_DEFAULT assert row["进货补偿预估值"] == COMPENSATION_DEFAULT assert row["细单备注"] == "ZQD1" assert row["货源细单ID"] == "SRC1" # 货品编码列用 goodsId,不用操作码。 def test_import_rows_search_by_goods_id(): line = RebateImportLine( goods_code="FFTMMHTTP", qty="10", tax_price="6.70", order_no="ZQD1", goods_id="4000018689", ) row = build_import_rows([line], today="2026-10-10")[0] assert row["货品编码"] == "4000018689" # 同时有货品编码和 goodsId 时搜 goodsId。 def test_import_line_from_mapping_prefers_goods_id(): from rebate_flow_steps import _line_from_mapping line = _line_from_mapping( { "goodsId": "4000018689", "goodsCode": "FFTMMHTTP", "qty": "10", "taxPrice": "6.70", "orderNo": "ZQD1", } ) row = build_import_rows([line], today="2026-10-10")[0] assert line.goods_id == "4000018689" assert row["货品编码"] == "4000018689" # 细单缺订单号时拒绝组草稿。 def test_import_draft_requires_oms_order_no(): with pytest.raises(NodeError): build_import_draft( lines=[RebateImportLine(goods_code="G001", qty="10", tax_price="6.70", order_no="")] ) # 填模板时货品编码列写入 goodsId。 def test_fill_excel_writes_goods_id_into_goods_id_column(tmp_path): from openpyxl import Workbook, load_workbook template = tmp_path / "t.xlsx" wb = Workbook() ws = wb.active ws["A1"] = "货品ID" ws["B1"] = "数量" wb.save(template) line = RebateImportLine( goods_code="FFTMMHTTP", qty="10", tax_price="6.70", order_no="ZQD1", goods_id="4000018689", ) out = fill_import_excel( template, build_import_rows([line], today="2026-10-10"), tmp_path / "out.xlsx", ) ws = load_workbook(out).active assert str(ws["A2"].value) == "4000018689" # 填模板时备注是订单号,补偿是 0。 def test_fill_excel_writes_remark_and_zero_compensation(tmp_path): from openpyxl import load_workbook template = tmp_path / "t.xlsx" write_canonical_template(template) out = fill_import_excel( template, build_import_rows([_line()], today="2026-10-10"), tmp_path / "out.xlsx", ) ws = load_workbook(out).active data = {cell.value: value.value for cell, value in zip(ws[1], ws[2])} assert str(data["细单备注"]) == "ZQD1" assert str(data["特定补偿预估值"]) == "0" assert str(data["进货补偿预估值"]) == "0" assert str(data["承付模式"]) == "11" # 细单流程写 ACCEPT,带成功失败条数和货品ID。 def test_import_flow_writes_accept_with_success_fail(tmp_path, monkeypatch): monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path) browser = FakeBrowser( table_text="HT1 重庆亿昊", body_text="采购协议管理 合同编号 查询 返利合约 承付模式 保存 导入导出 成功 1 条 失败 0 条", login_body="采购协议管理", import_result={"success": 1, "fail": 0, "reasons": []}, ) result = run_import_flow(browser, _ctx(), "HT1", _draft(), download_dir=tmp_path / "dl") assert result.success_count == 1 assert result.fail_count == 0 assert result.parsed is True assert browser.uploaded == [] text = accept_log_path("REBATE_IMPORT").read_text(encoding="utf-8") assert "ACCEPT SUMMARY REBATE_IMPORT 8/8 ok" in text assert "ACCEPT S06 ok 选中总单" in text assert "ACCEPT S07 ok 新增细单" in text assert "goodsId=G001" in text assert "ACCEPT S08 ok 保存细单" in text assert "1条货品已保存" in text assert "ACCEPT S10 ok 读导入结果" in text assert "remark=ZQD1" in text assert "compensation=0" in text assert "success=1" in text assert "fail=0" in text # 已登录会话不再填账密。 def test_import_reuses_logged_in_session(tmp_path, monkeypatch): monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False) monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path) browser = FakeBrowser( table_text="HT1 重庆亿昊", body_text="采购协议管理 待审批 合同编号 查询 返利合约 承付模式 保存 导入导出 成功 1 条 失败 0 条", login_body="采购协议管理", import_result={"success": 1, "fail": 0, "reasons": []}, ) run_import_flow(browser, _ctx(), "HT1", _draft(), download_dir=tmp_path / "dl") login_fills = [value for selectors, value in browser.filled if selectors == LOGIN["user"]] assert login_fills == [] assert "u" not in [value for _, value in browser.filled] # 细单和查号共用同一 Chrome 会话和端口。 def test_import_chrome_session_reuses_find(): assert chrome_session_flow("REBATE_IMPORT") == "REBATE_FIND" assert resolve_chrome_profile_dir("REBATE_IMPORT") == resolve_chrome_profile_dir("REBATE_FIND") assert chrome_debug_port("REBATE_IMPORT") == chrome_debug_port("REBATE_FIND") # 按货品ID构造一条细单,给查重用例用。 def _goods_line(goods_id: str, *, order_no: str = "ZQD1") -> RebateImportLine: return RebateImportLine( goods_code=goods_id, qty="10", tax_price="6.70", order_no=order_no, goods_id=goods_id, ) # 货品表已有该 ID 则跳过新增。 def test_add_detail_skips_goods_already_in_table(): browser = FakeBrowser(goods_rows=["G001 10 ZQD1"]) added = add_detail_lines_by_goods_id(browser, [_goods_line("G001")]) assert added == 1 assert ["+新增"] not in browser.clicked assert ["保存"] not in browser.clicked # 同批已有的跳过、没有的仍新增。 def test_add_detail_skips_existing_and_adds_missing(): browser = FakeBrowser(goods_rows=["G001 10 ZQD1"]) added = add_detail_lines_by_goods_id( browser, [_goods_line("G001"), _goods_line("G002", order_no="ZQD2")], ) assert added == 2 assert browser.clicked.count(["+新增"]) == 1 assert any("G002" in row for row in browser.goods_rows) assert ["保存"] in browser.clicked