408 lines
14 KiB
Python
408 lines
14 KiB
Python
# 返利细单新增:按货品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
|