368 lines
14 KiB
Python
368 lines
14 KiB
Python
# 返利总单:表 3 MDM、计划员廖建琦、复用已有总单号。
|
||
|
||
from accept import accept_log_path
|
||
from apa_browser_adapter import chrome_debug_port, chrome_session_flow, resolve_chrome_profile_dir
|
||
from rebate_flow_steps import (
|
||
CONTACT_DEFAULT,
|
||
LOGIN,
|
||
PAY_MODE_NEXT_MONTH_N,
|
||
PAY_PARAM_DEFAULT,
|
||
PLANNER_DEFAULT,
|
||
RebateFlowContext,
|
||
_pick_person,
|
||
build_header_draft,
|
||
fill_rebate_header,
|
||
header_draft_from_table3,
|
||
import_lines_from_table3,
|
||
run_header_flow,
|
||
)
|
||
|
||
|
||
# 总单流程假浏览器,按脚本关键字回勾选和协议总单ID。
|
||
class FakeBrowser:
|
||
# 记下页面脚本回传的勾选、保存和协议号。
|
||
def __init__(
|
||
self,
|
||
*,
|
||
table_text="",
|
||
body_text="",
|
||
login_body="",
|
||
order_no="",
|
||
existing_order_no="",
|
||
):
|
||
self.table_text = table_text
|
||
self.body_text = body_text
|
||
self.login_body = login_body or body_text
|
||
self.order_no = order_no
|
||
self.existing_order_no = existing_order_no
|
||
self.saved = False
|
||
self.opened: list[str] = []
|
||
self.filled: list[tuple[list[str], str]] = []
|
||
self.selected: list[tuple[list[str], str]] = []
|
||
self.clicked: list[list[str]] = []
|
||
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 "OPEN_HEADER_ROW" in text:
|
||
return {"ok": True, "via": "row"}
|
||
if "SELECT_HEADER_ROW" in text:
|
||
return {"ok": True, "text": str(args[0] if args else "")}
|
||
if "HEADER_SELECTION" in text:
|
||
no = str(args[0] if args else "")
|
||
return {"ok": True, "cells": [no], "contract": no, "checkedIdx": 0, "pager": "共1条"}
|
||
if "CLOSE_HEADER_QUERY" in text or "CLOSE_DETAIL_CARD" in text:
|
||
return {"ok": True}
|
||
if "CLICK_HEADER_TOOLBAR" in text:
|
||
title = str(args[0] if args else "")
|
||
self.clicked.append([title])
|
||
if title == "保存":
|
||
self.saved = True
|
||
return {"ok": True}
|
||
if "DETAIL_TOAST" in text:
|
||
return {"texts": []}
|
||
if "HEADER_CARD" in text:
|
||
return False
|
||
if "OPEN_LABELED_PICKER" in text:
|
||
return {"ok": True}
|
||
if "FILL_HEADER_LOOKUP" in text or "PICK_HEADER_LOOKUP" in text:
|
||
return {"ok": True}
|
||
if "CONFIRM_HEADER_CARD" in text:
|
||
return {"ok": True, "via": "确认"}
|
||
if "SET_HEADER_CELL" in text:
|
||
return {"ok": False}
|
||
if "SET_LABELED" in text:
|
||
label = args[0] if args else ""
|
||
value = args[1] if len(args) > 1 else ""
|
||
self.filled.append(([str(label)], str(value)))
|
||
return True
|
||
if "READ_LABELED_INPUT" in text:
|
||
title = str(args[0] if args else "")
|
||
if title == "承付参数":
|
||
return {"value": PAY_PARAM_DEFAULT}
|
||
if title == "计划员":
|
||
return {"value": "李四"}
|
||
return {"value": ""}
|
||
if "READ_REBATE_ORDER_NO" in text:
|
||
return self.existing_order_no or (self.order_no if self.saved else "")
|
||
return self.table_text
|
||
|
||
# 假截图,返回空路径。
|
||
def screenshot(self, name: str) -> str:
|
||
return name
|
||
|
||
# 假读取,返回空字符串。
|
||
def read_first(self, selectors: list[str], value_name: str = "") -> str:
|
||
return self.order_no
|
||
|
||
# 假切入 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:
|
||
return None
|
||
|
||
|
||
# 构造一段假返利会话入参。
|
||
def _ctx() -> RebateFlowContext:
|
||
return RebateFlowContext(rebate_url="http://example", username="u", password="p")
|
||
|
||
|
||
# 构造一份带测试客商编码的总单草稿。
|
||
def _draft():
|
||
return build_header_draft(
|
||
planner_name="李四",
|
||
supplier_code="DW0100046361",
|
||
rebate_unit_code="901",
|
||
today="2026-10-09",
|
||
)
|
||
|
||
|
||
# 总单供应商用表 3 MDM,计划员仍是廖建琦,细单货品ID和备注来自表 3。
|
||
def test_header_draft_from_table3_uses_mdm_and_planner():
|
||
draft = header_draft_from_table3(
|
||
[
|
||
{
|
||
"supplier_id": "SUP001",
|
||
"supplier_mdm_code": "DW0100046361",
|
||
"planner_name": "李四",
|
||
"purchaser_name": "张三",
|
||
"org_code": "901",
|
||
"goods_id": "4000018689",
|
||
"qty": "10",
|
||
"unit_price": "10",
|
||
"oms_id": "20260928000003",
|
||
}
|
||
],
|
||
today="2026-10-12",
|
||
)
|
||
assert draft.supplier_code == "DW0100046361"
|
||
assert draft.planner_name == PLANNER_DEFAULT
|
||
assert draft.rebate_unit_code == "901"
|
||
assert draft.contact_name == CONTACT_DEFAULT
|
||
lines = import_lines_from_table3(
|
||
[
|
||
{
|
||
"goods_id": "4000018689",
|
||
"qty": "10",
|
||
"unit_price": "10",
|
||
"oms_id": "20260928000003",
|
||
"goods_source_id": "SRC1",
|
||
}
|
||
]
|
||
)
|
||
assert lines[0].goods_id == "4000018689"
|
||
assert lines[0].order_no == "20260928000003"
|
||
assert lines[0].tax_rate == "0.13"
|
||
assert lines[0].net_price == "8.8496"
|
||
|
||
|
||
# 总单默认走现行字段:下月N号结、协议起止当天、返利合约否。
|
||
def test_header_defaults_follow_mapping_not_scheme_55():
|
||
draft = _draft()
|
||
assert draft.planner_name == "李四"
|
||
assert draft.pay_mode == PAY_MODE_NEXT_MONTH_N
|
||
assert draft.protocol_start == "2026-10-09"
|
||
assert draft.protocol_end == "2026-10-09"
|
||
assert draft.rebate_contract == "否"
|
||
assert draft.settle_mode == "否"
|
||
|
||
|
||
# 计划员名为空时回退廖建琦,联系人苏文辉。
|
||
def test_header_draft_defaults_planner():
|
||
draft = build_header_draft(planner_name="", supplier_code="S1", rebate_unit_code="901")
|
||
assert draft.planner_name == PLANNER_DEFAULT
|
||
assert draft.contact_name == CONTACT_DEFAULT
|
||
|
||
|
||
# 填总单时用草稿里的计划员、结算和承付模式。
|
||
def test_fill_header_uses_mapping_values():
|
||
browser = FakeBrowser(
|
||
table_text="HT1 重庆亿昊",
|
||
body_text="返利合约 计划员 承付模式 保存",
|
||
)
|
||
fill_rebate_header(browser, _draft())
|
||
filled_values = [value for _, value in browser.filled]
|
||
selected_values = [value for _, value in browser.selected]
|
||
assert "李四" in filled_values
|
||
assert "2026-10-09" in filled_values
|
||
assert PAY_MODE_NEXT_MONTH_N in selected_values
|
||
assert "否" in selected_values
|
||
assert "廖建峰" not in filled_values
|
||
assert "账期" not in selected_values
|
||
assert "2026-12-31" not in filled_values
|
||
|
||
|
||
# 总单流程写 ACCEPT,带上协议总单ID。
|
||
def test_header_flow_writes_accept_with_order_no(tmp_path, monkeypatch):
|
||
monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False)
|
||
monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path)
|
||
browser = FakeBrowser(
|
||
table_text="HT1 重庆亿昊",
|
||
body_text="采购协议管理 合同编号 查询 返利合约 承付模式 计划员 保存 总单号 FL20261009001",
|
||
login_body="采购协议管理",
|
||
order_no="FL20261009001",
|
||
)
|
||
order_no = run_header_flow(browser, _ctx(), "HT1", _draft())
|
||
assert order_no == "FL20261009001"
|
||
text = accept_log_path("REBATE_HEADER").read_text(encoding="utf-8")
|
||
assert "ACCEPT SUMMARY REBATE_HEADER 8/8 ok" in text
|
||
assert "ACCEPT S09 ok 读总单号" in text
|
||
assert "rebateOrderNo=FL20261009001" in text
|
||
assert "planner=李四" in text
|
||
assert f"payMode={PAY_MODE_NEXT_MONTH_N}" in text
|
||
assert "protocolEnd=2026-10-09" in text
|
||
|
||
|
||
# 表上已有协议总单ID则复用,不再新建。
|
||
def test_header_reuses_existing_order_no_without_create(tmp_path, monkeypatch):
|
||
monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False)
|
||
monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path)
|
||
browser = FakeBrowser(
|
||
table_text="HT1 重庆亿昊 FL_EXISTING",
|
||
body_text="采购协议管理 合同编号 查询 返利合约 承付模式 计划员 保存 总单号 FL_EXISTING",
|
||
login_body="采购协议管理",
|
||
existing_order_no="FL_EXISTING",
|
||
)
|
||
order_no = run_header_flow(browser, _ctx(), "HT1", _draft())
|
||
assert order_no == "FL_EXISTING"
|
||
assert ["新增"] not in browser.clicked
|
||
assert ["保存"] in browser.clicked
|
||
text = accept_log_path("REBATE_HEADER").read_text(encoding="utf-8")
|
||
assert "rebateOrderNo=FL_EXISTING" in text
|
||
assert "reused=yes" in text
|
||
assert "ACCEPT S07 ok 填总单" in text
|
||
|
||
|
||
# 已登录会话不再填账密。
|
||
def test_header_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="采购协议管理 待审批 合同编号 查询 返利合约 承付模式 计划员 保存",
|
||
login_body="采购协议管理",
|
||
order_no="FL1",
|
||
)
|
||
run_header_flow(browser, _ctx(), "HT1", _draft())
|
||
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_header_chrome_session_reuses_find():
|
||
assert chrome_session_flow("REBATE_HEADER") == "REBATE_FIND"
|
||
assert resolve_chrome_profile_dir("REBATE_HEADER") == resolve_chrome_profile_dir("REBATE_FIND")
|
||
assert chrome_debug_port("REBATE_HEADER") == chrome_debug_port("REBATE_FIND")
|
||
|
||
|
||
# 计划员三个点打开自己的选择层,不拿货源选择当层。
|
||
class PlannerPickBrowser:
|
||
# 记下计划员选择层是否打开、选中的姓名和 ID。
|
||
def __init__(self):
|
||
self.planner_name = ""
|
||
self.planner_id = ""
|
||
self.layer_open = False
|
||
self.scripts: list[str] = []
|
||
|
||
# 按脚本标记模拟计划员三个点弹层。
|
||
def execute_script(self, script: str, *args):
|
||
text = script or ""
|
||
self.scripts.append(text)
|
||
if "READ_LABELED_INPUT" in text:
|
||
title = str(args[0] if args else "")
|
||
if title == "计划员ID":
|
||
return {"value": self.planner_id}
|
||
if title == "计划员":
|
||
return {"value": self.planner_name}
|
||
return {"value": ""}
|
||
if "CLOSE_HEADER_QUERY" in text:
|
||
return {"ok": True}
|
||
if "OPEN_LABELED_PICKER" in text:
|
||
self.layer_open = True
|
||
return {"ok": True, "via": "more-outline"}
|
||
if "PERSON_PICKER_VISIBLE" in text:
|
||
return self.layer_open
|
||
if "FOCUS_PERSON_FIELD" in text:
|
||
return {"ok": True, "ph": "请输入计划员", "title": "计划员选择"}
|
||
if "CLICK_PERSON_QUERY" in text:
|
||
return {"ok": True, "via": "query"}
|
||
if "PERSON_PICKER_ROWS" in text:
|
||
return {"ok": True, "match": True, "count": 1, "row": "廖建琦"}
|
||
if "PICK_PERSON_ROW" in text:
|
||
self.planner_name = "廖建琦"
|
||
self.planner_id = "U1001"
|
||
self.layer_open = False
|
||
return {"ok": True, "text": "廖建琦"}
|
||
if "CLOSE_PERSON_PICKER" in text:
|
||
self.layer_open = False
|
||
return {"ok": True, "via": "close"}
|
||
if "SCAN_LOOKUP_OVERLAYS" in text:
|
||
return ["计划员选择"] if self.layer_open else []
|
||
if "SOURCE_CHOICE" in text or "CLOSE_SOURCE_CHOICE" in text:
|
||
return False
|
||
return None
|
||
|
||
|
||
# 计划员三个点按姓名选廖建琦并绑上 ID,不走货源细单。
|
||
def test_pick_person_selects_liao_and_binds_id():
|
||
browser = PlannerPickBrowser()
|
||
_pick_person(browser, "计划员", PLANNER_DEFAULT)
|
||
assert browser.planner_name == "廖建琦"
|
||
assert browser.planner_id == "U1001"
|
||
joined = "\n".join(browser.scripts)
|
||
assert "FOCUS_PERSON_FIELD" in joined
|
||
assert "PICK_PERSON_ROW" in joined
|
||
assert "FOCUS_SOURCE_FIELD" not in joined
|
||
assert "PICK_SOURCE_ROW" not in joined
|