feat: 返利班通用录入流程(总单、细单新增、送审)

查号后同一班补总单、按货品 ID 新增细单、送审写表 4。细单不做 Excel 导入。
承付参数总单和细单都填 25。同一合同一组一个结果。
合同货品弹窗按操作码搜,找不到则失败。
This commit is contained in:
34BFF76918A9449ABAB323149DDCEA73 2026-10-13 17:29:43 +08:00
parent 39754bef79
commit ac19e60eb5
24 changed files with 6620 additions and 150 deletions

View File

@ -37,6 +37,9 @@
| 支付回传 | `rpa_cart/job.py`、`settle.py` | 1 号药城支付按 Excel 落表,不对加购行 |
| 返利班查号落表 | `rpa_rebate/job.py`、`persist.py`、`repo.py` | `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` |
| 返利班查号页面 | `rpa_rebate/rebate_flow_steps.py`、`rebate_finder.py` | `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/tests/test_finder.py::test_find_flow_writes_accept_for_missing_number` |
| 返利总单页面 | `rpa_rebate/rebate_flow_steps.py`、`run_header.py` | `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no` |
| 返利细单新增 | `rpa_rebate/rebate_flow_steps.py`、`run_import.py` | `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail` |
| 返利送审 | `rpa_rebate/rebate_flow_steps.py`、`run_submit.py` | `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos` |
| 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — |
| 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` |
@ -126,29 +129,30 @@
## 返利班落点
班次作业在 `rpa_rebate/`,不在接口 3 请求里跑。领表 4 `action_code=1 AND contract_status=1`(合同班起草、尚未回成功),以及表 3 `contract_entry_status=0` 且已有 `contractNo` 的只录协议行。按合同号查返利中心;搜到再回 `contractStatus=2`、录协议写 `actionCode=2`。搜不到不重录合同,留给下一班。查号走页面;录协议仍用替身。接口 4 组包在 oms,path 空,不发 HTTP。
班次作业在 `rpa_rebate/`,不在接口 3 请求里跑。领表 4 `action_code=1 AND contract_status=1`(合同班起草、尚未回成功),以及表 3 `contract_entry_status=0` 且已有 `contractNo` 的只录协议行。按合同号查返利中心;搜到后同一班走总单 → 细单新增 → 送审,送审成功才插 `actionCode=2`、`agreementStatus=1` 并带上号。搜不到不重录合同,留给下一班。接口 4 组包在 oms,path 空,不发 HTTP。同一合同一组一个结果。
| 步 | 函数 | 要点 |
| --- | --- | --- |
| 领取 | `claim_pending` | 表 4 盖 `查号中`;表 3 `process_status` 0→3。崩溃停戳,改回 `合同班` / `0` 再跑 |
| 查号 | `RebateFinder` / `search_contract` | 打开 `REBATE_URL` → 登录 → 采购协议 → 按合同号搜;表里出现该号才 `True`,输入框回显不算 |
| 落表 | `build_found` + `save_group` | 起草行 `contract_status=2`;每 `oms_id` 插 `action_code=2`、`agreement_status=1`;`push_status=0` |
| 录协议 | `recorder` | 搜到后总单 → 细单新增(按货品ID,不做 Excel)→ 送审;送审成功才插 `actionCode=2` |
| 落表 | `build_found` + `save_group` | 起草行 `contract_status=2`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带总单/细单号;`push_status=0` |
| 搜不到 | `build_not_found` | 戳改回 `合同班`;表 3 改回 `0`;不插协议行 |
验收:`cd rpa_rebate` → `uv run pytest tests/test_job.py::test_job_finds_contract_and_writes_agreement -q`。页面:`uv run python run_find.py`,只读 `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/screenshots/REBATE_FIND/ACCEPT.txt`。接口 4:`cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`。
验收:`cd rpa_rebate` → `uv run pytest tests/test_job.py::test_job_finds_contract_and_writes_agreement -q`。页面:`uv run python run_find.py`,只读 `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/screenshots/REBATE_FIND/ACCEPT.txt`。总单:`uv run python run_header.py`,只读 `ACCEPT SUMMARY REBATE_HEADER`。细单新增:`uv run python run_import.py`,只读 `ACCEPT SUMMARY REBATE_IMPORT`。送审:`uv run python run_submit.py`,只读 `ACCEPT SUMMARY REBATE_SUBMIT`。接口 4:`cd rpa_oms` → `uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope -q`。现行字段与步骤见 `rpa_rebate/现行模式.md`、`rpa_rebate/返利流程.md`。
录协议、映射表默认值、送审下一刀。不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。
不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。
## 返利中心及之后
查号页面已接 `REBATE_FIND`。录协议班次骨架仍是替身。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开页面时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。
查号页面已接 `REBATE_FIND`。总单页面已接 `REBATE_HEADER`。细单新增已接 `REBATE_IMPORT`(节点名仍叫 IMPORT,页面走新增,不做 Excel)。送审已接 `REBATE_SUBMIT`。班次串查号 → 总单 → 细单新增 → 送审,送审成功才写 `actionCode=2`。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开页面时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。
| 节点 | 谁做 | 要回传 | 页面要做什么 |
| --- | --- | --- | --- |
| `REBATE_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 |
| `REBATE_HEADER` | RPA | `rebateOrderNo` | 尚未拆:填必填 → 保存拿总单号 |
| `REBATE_IMPORT` | RPA | 成功/失败行 | 下模板 → 按字段填 Excel → 上传 → 读导入结果 |
| `REBATE_SUBMIT` | RPA | `submitted` | 点提交送审 |
| `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:搜到后填映射表默认值 → 保存拿总单号 |
| `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel |
| `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 |
| `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) |
| `M5_MANUAL_CONFIRM` | 采购 | — | 人在 M5 里保存签订 |
| `M5_RECEIPT_NOTICE` | 采购 | `receiptNoticeNo` | 人填收货通知单号,全链终点钥匙 |
@ -165,6 +169,9 @@
- 合同班:点名 `rpa_contract/tests/test_job.py::test_job_merges_same_supplier_and_writes_table4` 或 `test_job_marks_diligence_fail`。
- 返利班:点名 `rpa_rebate/tests/test_job.py::test_job_finds_contract_and_writes_agreement` 或 `test_job_leaves_not_found_for_next_shift`。
- 返利查号页:只读控制台 `ACCEPT SUMMARY REBATE_FIND` 或 `rpa_rebate/screenshots/REBATE_FIND/ACCEPT.txt`。
- 返利总单页:只读控制台 `ACCEPT SUMMARY REBATE_HEADER` 或 `rpa_rebate/screenshots/REBATE_HEADER/ACCEPT.txt`;点名 `rpa_rebate/tests/test_header.py::test_header_flow_writes_accept_with_order_no`。
- 返利细单新增:只读控制台 `ACCEPT SUMMARY REBATE_IMPORT` 或 `rpa_rebate/screenshots/REBATE_IMPORT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_import.py::test_import_flow_writes_accept_with_success_fail`。
- 返利送审:只读控制台 `ACCEPT SUMMARY REBATE_SUBMIT` 或 `rpa_rebate/screenshots/REBATE_SUBMIT/ACCEPT.txt`;点名 `rpa_rebate/tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos`。
- 接口4:点名 `rpa_oms/tests/test_contract_agreement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。
- 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。

View File

@ -23,7 +23,7 @@
| `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 |
| `rpa_cart/` | 加购(未做)+ 1 号药城支付落表。表 2 → 口 2 仍未推 |
| `rpa_contract/` | 合同班。表 3 合单录入 → 表 4;接口 4 组包在 oms,path 空 |
| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号走页面,录协议仍用替身 |
| `rpa_rebate/` | 返利班。表 4 查号 / 表 3 只录协议 → 表 4;查号 → 总单 → 细单新增 → 送审同一班 |
| 包 | 读 | 写 | 口 |
| --- | --- | --- | --- |

View File

@ -343,6 +343,10 @@ def login_contract_system(browser: BrowserAdapter, ctx: ContractFlowContext) ->
def enter_contract_create(browser: BrowserAdapter) -> None:
# 登录后直接导航到起草表单页,跳过模板选择(前端委托事件 Selenium 点击无效)
reopen = getattr(browser, "reopen_url", None)
if callable(reopen):
reopen(FORM_PAGE_URL)
else:
browser.open_url(FORM_PAGE_URL)
browser.wait_for_text("客户编码", timeout_seconds=20)
on_form = "客户编码" in (browser.read_body_text() or "")
@ -612,6 +616,13 @@ def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext)
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 ""
@ -639,6 +650,27 @@ def _goods_search_keyword(line: ContractGoodsLine) -> str:
return (line.mdm_code or line.goods_code or line.goods_name or "").strip()
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:
@ -667,14 +699,17 @@ def _select_goods_in_picker(browser: BrowserAdapter, line: ContractGoodsLine, ke
browser.switch_to_frame(GOODS_PICKER["frame"])
try:
_search_goods_in_dialog(browser, keyword)
if not _dialog_row_has_keyword(browser, keyword) and line.goods_name:
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 and line.goods_name:
if not picked:
picked = _select_dialog_row_by_keyword(browser, line.goods_name.strip())
if not picked:
browser.wait_for_element(GOODS_PICKER["result_checkbox"], timeout_seconds=10)
browser.click_first(GOODS_PICKER["result_checkbox"])
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:
@ -848,7 +883,7 @@ def _find_goods_row(
});
for (let i = 0; i < 30; i++) {
if (skip[i]) continue;
const fields = ['fd_opcode', 'fd_goodsname', 'fd_mdmCode', 'fd_registdocno'];
const fields = ['fd_opcode', 'fd_mdmCode'];
const values = fields.map((suffix) => {
const el = document.querySelector(
'input[name="extendDataFormInfo.value(fd_detail1.' + i + '.' + suffix + ')"]'
@ -856,9 +891,8 @@ def _find_goods_row(
return el ? String(el.value || '').trim() : '';
}).filter(Boolean);
if (!values.length) continue;
const joined = values.join(' ');
for (const keyword of keys) {
if (values.some((v) => v === keyword || v.indexOf(keyword) >= 0) || joined.indexOf(keyword) >= 0) {
if (values.some((v) => v === keyword)) {
return i;
}
}

View File

@ -3,3 +3,7 @@ REBATE_USERNAME=
REBATE_PASSWORD=
# 页面 ACCEPT 要搜的合同号;空则用哨兵号验收「搜不到」
REBATE_ACCEPT_CONTRACT_NO=
# 总单必填,计划员用订单中心的,不要填登录人
REBATE_PLANNER_NAME=
REBATE_SUPPLIER_CODE=
REBATE_UNIT_CODE=

View File

@ -1,11 +1,11 @@
# rpa_rebate
返利班。领表 4 起草行、表 3 只录协议行,按合同号查返利中心。搜到再录协议、写表 4。查号走页面;录协议仍用替身。接口 4 组包在 `rpa_oms`,本班不推、不改 `push_status`。
返利班。领表 4 起草行、表 3 只录协议行,按合同号查返利中心。搜到后同一班走总单 → 细单新增 → 送审;送审成功才写表 4。接口 4 组包在 `rpa_oms`,本班不推、不改 `push_status`。细单不做 Excel 导入。现行字段见 `现行模式.md`、`返利流程.md`。
| | |
| --- | --- |
| 读 | 表 4 `action_code=1 AND contract_status=1`(已有合同号、尚未回成功);表 3 `contract_entry_status=0` 且已有 `contractNo` |
| 写 | 表 4:搜到则起草行 `contractStatus=2`;录协议后插 `actionCode=2`、`agreementStatus=1` |
| 写 | 表 4:搜到则起草行 `contractStatus=2`;送审成功插 `actionCode=2`、`agreementStatus=1`,带总单/细单号 |
| 口 | 口 4(组包在 oms,path 空) |
与合同同一天两班,建议合同班跑完再跑。查号挂在本班,不单开查状态任务。搜不到不重录合同,戳改回 `合同班`,留给下一班再搜。
@ -21,6 +21,9 @@ cd rpa_rebate
uv sync --python 3.12
uv run pytest tests/test_job.py::test_job_finds_contract_and_writes_agreement -q
uv run pytest tests/test_finder.py::test_find_flow_writes_accept_for_missing_number -q
uv run pytest tests/test_header.py::test_header_flow_writes_accept_with_order_no -q
uv run pytest tests/test_import.py::test_import_flow_writes_accept_with_success_fail -q
uv run pytest tests/test_submit.py::test_submit_flow_writes_accept_with_order_nos -q
```
页面查号(不开录协议、不连 Oracle)。账号放本机 `.env`:
@ -32,6 +35,33 @@ uv run python run_find.py
验收看控制台 `ACCEPT SUMMARY REBATE_FIND` 或 `screenshots/REBATE_FIND/ACCEPT.txt`。默认搜哨兵号,期望 `found=no`。要搜真号:`REBATE_ACCEPT_CONTRACT_NO=合同号`。
页面总单(复用查号已登录 Chrome,不送审、不连 Oracle):
```bash
cd rpa_rebate
uv run python run_header.py
```
验收看控制台 `ACCEPT SUMMARY REBATE_HEADER` 或 `screenshots/REBATE_HEADER/ACCEPT.txt`。需要 `REBATE_ACCEPT_CONTRACT_NO`。优先表 3:供应商 MDM、货品行。计划员「廖建琦」,联系人「苏文辉」,承付「下月N号结」,承付参数 `25`,结算「电汇」,协议开始/结束当天。卡片仍在「编辑」时点总单工具栏保存。
页面细单新增(复用查号已登录 Chrome,不送审、不写 `actionCode=2`、不连 Oracle,不做 Excel):
```bash
cd rpa_rebate
uv run python run_import.py
```
验收看控制台 `ACCEPT SUMMARY REBATE_IMPORT` 或 `screenshots/REBATE_IMPORT/ACCEPT.txt`,须货品表有行。需要 `REBATE_ACCEPT_CONTRACT_NO`。表 3 有货品则按行新增;空缺再用 `REBATE_GOODS_ID` / `REBATE_QTY` / `REBATE_TAX_PRICE` / `REBATE_ORDER_NO`(或 `REBATE_IMPORT_LINES` JSON,字段 `goodsId`)。货品编码三个点按货品ID搜。细单备注=订单号,特定补偿=0,承付参数=25。不要用操作码或 MDM 码去搜。
页面送审(复用查号已登录 Chrome,不写 `actionCode=2`、不连 Oracle、不推口 4):
```bash
cd rpa_rebate
uv run python run_submit.py
```
验收看控制台 `ACCEPT SUMMARY REBATE_SUBMIT` 或 `screenshots/REBATE_SUBMIT/ACCEPT.txt`,须带总单号/细单号。需要 `REBATE_ACCEPT_CONTRACT_NO`。点完马上能看见号才回成功。
接口 4 组包(`rpa_oms`,不发 HTTP):
```bash
@ -41,4 +71,4 @@ uv run pytest tests/test_contract_agreement_outbound.py::test_assembles_unpushed
## 本期不做
录协议(总单 / 导入 / 送审)、映射表默认值、向订单中心发 HTTP、改 `push_status`。
向订单中心发 HTTP、改 `push_status`。

View File

@ -5,11 +5,44 @@ Selenium Chrome 浏览器适配。通过 WebDriver 操控 Chrome。
from __future__ import annotations
import os
import socket
import subprocess
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
_FLOW_DEBUG_PORTS = {
"REBATE_FIND": 9335,
"default": 9335,
}
_USER_CHROME_PORT = 9222
def use_user_chrome() -> bool:
raw = (os.getenv("RPA_USE_USER_CHROME") or "").strip().lower()
return raw in {"1", "true", "yes", "on"}
def user_chrome_real_profile_dir() -> Path:
return (Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "User Data").resolve()
def user_chrome_profile_dir() -> Path:
"""Chrome 禁止在默认 User Data 上开调试口,用仓库外 junction 指向同一目录。不要 resolve,否则又变回默认路径。"""
link = REPO_ROOT.parent / "rpa-chrome-profile" / "daily-chrome"
real = user_chrome_real_profile_dir()
if not link.exists():
link.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["cmd.exe", "/c", "mklink", "/J", str(link), str(real)],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return link
def _is_inside_repo(path: Path, repo_root: Path) -> bool:
try:
@ -19,6 +52,14 @@ def _is_inside_repo(path: Path, repo_root: Path) -> bool:
return True
def chrome_session_flow(flow: str) -> str:
"""REBATE_HEADER 复用查号已登录的 Chrome。"""
name = (flow or "default").strip() or "default"
if name.startswith("REBATE"):
return "REBATE_FIND"
return name
def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path | None = None) -> Path:
"""Chrome user-data dir must stay outside the repo so list_dir never walks 3GB."""
root = (repo_root or REPO_ROOT).resolve()
@ -33,6 +74,199 @@ def resolve_chrome_profile_base(requested: str | None = None, *, repo_root: Path
return outside
def resolve_chrome_profile_dir(
flow: str = "default",
requested: str | None = None,
*,
repo_root: Path | None = None,
) -> Path:
"""Stable profile per Chrome session so later rebate nodes reuse login."""
if use_user_chrome():
return user_chrome_profile_dir()
base = resolve_chrome_profile_base(requested, repo_root=repo_root)
session = chrome_session_flow(flow)
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in session)
return (base / (safe or "default")).resolve()
def chrome_debug_port(flow: str = "default") -> int:
raw = (os.getenv("RPA_CHROME_DEBUG_PORT") or "").strip()
if raw:
try:
return int(raw)
except ValueError:
pass
if use_user_chrome():
return _USER_CHROME_PORT
session = chrome_session_flow(flow)
return _FLOW_DEBUG_PORTS.get(session, 9335)
def _port_open(port: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.4):
return True
except OSError:
return False
def _read_devtools_port(profile_dir: Path) -> int | None:
marker = profile_dir / "DevToolsActivePort"
if not marker.is_file():
return None
try:
first = marker.read_text(encoding="utf-8").splitlines()[0].strip()
port = int(first)
except (OSError, ValueError, IndexError):
return None
return port if port > 0 else None
def _chrome_service():
from selenium.webdriver.chrome.service import Service
chromedriver = os.getenv("CHROMEDRIVER_PATH")
if chromedriver and os.path.exists(chromedriver):
return Service(chromedriver)
from webdriver_manager.chrome import ChromeDriverManager
return Service(ChromeDriverManager().install())
def _chrome_binary() -> str:
raw = (os.getenv("CHROME_PATH") or "").strip()
candidates = [
raw,
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
str(Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "Application" / "chrome.exe"),
]
for path in candidates:
if path and os.path.isfile(path):
return path
raise RuntimeError("未找到 chrome.exe")
def _attach_chrome(service, port: int):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.add_experimental_option("debuggerAddress", f"127.0.0.1:{port}")
driver = webdriver.Chrome(service=service, options=options)
driver.execute_script("return 1")
return driver
def _wait_port(port: int, timeout: float = 20) -> bool:
deadline = time.time() + timeout
while time.time() <= deadline:
if _port_open(port):
return True
time.sleep(0.3)
return False
def _spawn_chrome(profile_dir: Path, port: int, *, restore_session: bool = False) -> None:
"""独立拉起 Chrome,不跟 Python / chromedriver 一起退出。"""
chrome = _chrome_binary()
flags = 0x00000008 | 0x00000200 | 0x01000000
cmd = [
chrome,
f"--remote-debugging-port={str(port)}",
"--user-data-dir=" + str(profile_dir),
"--remote-allow-origins=*",
"--start-maximized",
"--no-first-run",
"--no-default-browser-check",
"--disable-popup-blocking",
]
if restore_session:
cmd.append("--restore-last-session")
subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=False,
creationflags=flags,
)
def _kill_chrome() -> None:
creation = getattr(subprocess, "CREATE_NO_WINDOW", 0)
subprocess.run(
["taskkill", "/IM", "chrome.exe", "/F"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
creationflags=creation,
)
def _wait_profile_unlocked(profile_dir: Path, timeout: float = 20) -> None:
deadline = time.time() + timeout
while time.time() <= deadline:
locked = (profile_dir / "SingletonLock").exists() or (profile_dir / "lockfile").exists()
if not locked:
return
time.sleep(0.3)
def _launch_chrome(service, profile_dir: Path, port: int, *, restore_session: bool = False):
if not _port_open(port):
if restore_session:
print(f"Chrome 日常窗口无调试口,按同一用户目录重开 port={port} profile={profile_dir}", flush=True)
_kill_chrome()
_wait_profile_unlocked(profile_dir)
time.sleep(0.8)
_spawn_chrome(profile_dir, port, restore_session=restore_session)
if not _wait_port(port):
raise RuntimeError(f"Chrome 未在端口 {port} 起来")
return _attach_chrome(service, port)
def _focus_agreement_tab(driver) -> bool:
try:
handles = list(driver.window_handles)
except Exception:
return False
for handle in handles:
try:
driver.switch_to.window(handle)
href = str(driver.current_url or "")
title = str(driver.title or "")
except Exception:
continue
if "procurementAgree" in href or "采购协议" in title:
return True
return False
_IGNORE_SOURCE_JS = """
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
function compact(el) {
return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim();
}
const hits = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => {
if (!vis(el)) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return title === '货源选择';
});
for (const modal of hits) {
const btn = Array.from(modal.querySelectorAll('button')).find((el) => {
const t = compact(el).replace(/ /g, '');
return vis(el) && (t === '关闭' || t === '取消');
});
if (btn) { btn.click(); continue; }
const x = modal.querySelector('.vxe-modal--close-btn, .vxe-icon-close');
if (x) { x.click(); }
}
return hits.length;
"""
class ApaBrowserAdapter:
"""Selenium Chrome 浏览器适配器,实现 BrowserAdapter 协议"""
@ -41,56 +275,70 @@ class ApaBrowserAdapter:
self._flow = flow
self._driver = None
self._is_open = False
self._download_dir = ""
self._download_before: set[str] = set()
self._ignoring_source = False
self._hold_source_choice = False
def _get_driver(self):
"""创建或获取 Chrome WebDriver"""
"""复用查号已打开的 Chrome;没有就按稳定 profile 新开。"""
if self._driver is not None:
return self._driver
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
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())
profile_dir = resolve_chrome_profile_dir(self._flow)
if _is_inside_repo(profile_dir, REPO_ROOT):
raise RuntimeError(f"拒绝在仓库内创建 Chrome 用户目录: {profile_dir}")
os.makedirs(profile_dir, exist_ok=True)
profile_dir = str(profile_dir)
port = chrome_debug_port(self._flow)
if not use_user_chrome():
port = _read_devtools_port(profile_dir) or port
options = Options()
options.page_load_strategy = "eager"
# 不自动关闭浏览器,方便调试
options.add_experimental_option("detach", True)
# 避免被检测为自动化
options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument("--no-first-run")
options.add_argument("--no-default-browser-check")
options.add_argument("--disable-gpu")
options.add_argument("--disable-extensions")
options.add_argument("--disable-popup-blocking")
options.add_argument("--start-maximized")
# 独立目录,避免和日常 Chrome 抢用户数据目录导致卡住
options.add_argument("--user-data-dir=" + profile_dir)
chromedriver = os.getenv("CHROMEDRIVER_PATH")
if chromedriver and os.path.exists(chromedriver):
service = Service(chromedriver)
driver = None
if _port_open(port):
try:
driver = _attach_chrome(_chrome_service(), port)
print(f"Chrome 复用已有实例 flow={self._flow} port={port}", flush=True)
except Exception as exc:
print(f"Chrome 复用失败 port={port}:{exc}", flush=True)
driver = None
if driver is None:
launch_port = chrome_debug_port(self._flow)
try:
driver = _launch_chrome(
_chrome_service(),
profile_dir,
launch_port,
restore_session=use_user_chrome(),
)
print(
f"Chrome 新开实例 flow={self._flow} port={launch_port} profile={profile_dir}",
flush=True,
)
except Exception as exc:
retry_port = _read_devtools_port(profile_dir)
if retry_port and _port_open(retry_port):
driver = _attach_chrome(_chrome_service(), retry_port)
print(f"Chrome 锁定后改为复用 port={retry_port}", flush=True)
else:
from webdriver_manager.chrome import ChromeDriverManager
service = Service(ChromeDriverManager().install())
self._driver = webdriver.Chrome(service=service, options=options)
self._driver.set_page_load_timeout(45)
self._driver.set_script_timeout(30)
raise RuntimeError(f"打开 Chrome 失败:{exc}") from exc
driver.set_page_load_timeout(45)
driver.set_script_timeout(30)
self._driver = driver
return self._driver
def _ensure_open(self):
if self._driver is None:
driver = self._get_driver()
if self._url:
href = ""
try:
href = str(driver.execute_script("return location.href") or "")
except Exception:
href = ""
blank = (not href) or href.startswith("chrome://") or href.startswith("about:")
if use_user_chrome() and _focus_agreement_tab(driver):
print("Chrome 切到已打开的采购协议页", flush=True)
elif self._url and blank:
driver.get(self._url)
self._is_open = True
@ -205,6 +453,7 @@ class ApaBrowserAdapter:
if el.get_attribute("value") != str(value):
self._set_value_via_js(el, str(value))
time.sleep(0.3)
self._ignore_source_bug()
return
except Exception as e:
last_error = e
@ -226,6 +475,7 @@ class ApaBrowserAdapter:
time.sleep(0.2)
ActionChains(self._driver).move_to_element(el).pause(0.15).click().perform()
time.sleep(0.4)
self._ignore_source_bug()
return
except Exception as e:
last_error = e
@ -334,9 +584,25 @@ class ApaBrowserAdapter:
self._ensure_open()
self._driver.switch_to.default_content()
def _ignore_source_bug(self) -> None:
"""货源选择是 bug 弹层,看见就关,不参与任何选择。"""
if self._hold_source_choice or self._ignoring_source or self._driver is None:
return
self._ignoring_source = True
try:
n = self._driver.execute_script(_IGNORE_SOURCE_JS)
if n:
time.sleep(0.2)
except Exception:
pass
finally:
self._ignoring_source = False
def execute_script(self, script: str, *args):
self._ensure_open()
return self._driver.execute_script(script, *args)
result = self._driver.execute_script(script, *args)
self._ignore_source_bug()
return result
def select_xform(self, name: str, value: str) -> None:
"""选中 Landray xform fancy-select(.fs-wrap / .fs-option)。"""
@ -472,6 +738,47 @@ class ApaBrowserAdapter:
continue
raise RuntimeError(f"upload_file_first failed: {selectors}, last: {last_error}")
def set_download_dir(self, path: str) -> None:
self._ensure_open()
dest = str(Path(path).expanduser().resolve())
Path(dest).mkdir(parents=True, exist_ok=True)
self._download_dir = dest
self._download_before = {p.name for p in Path(dest).iterdir()}
try:
self._driver.execute_cdp_cmd(
"Page.setDownloadBehavior",
{"behavior": "allow", "downloadPath": dest, "eventsEnabled": True},
)
except Exception:
try:
self._driver.execute_cdp_cmd(
"Browser.setDownloadBehavior",
{"behavior": "allow", "downloadPath": dest, "eventsEnabled": True},
)
except Exception as exc:
print(f"set_download_dir skipped: {exc}", flush=True)
def wait_for_download(self, suffix: str = ".xlsx", timeout_seconds: int = 20) -> str:
dest = Path(self._download_dir or "")
if not dest.is_dir():
raise RuntimeError("未设置下载目录")
before = set(self._download_before)
deadline = time.time() + timeout_seconds
wanted = (suffix or ".xlsx").lower()
while time.time() <= deadline:
hits = [
p
for p in dest.iterdir()
if p.name not in before
and p.suffix.lower() == wanted
and not str(p).endswith(".crdownload")
and not Path(str(p) + ".crdownload").exists()
]
if hits:
return str(max(hits, key=lambda p: p.stat().st_mtime))
time.sleep(0.3)
raise RuntimeError(f"未下到 {wanted} 文件")
def read_first(self, selectors: list, value_name: str = "") -> str:
self._ensure_open()
last_error = None

View File

@ -1,4 +1,4 @@
"""返利班次:领表 4 起草行 / 表 3 只录协议行 → 查号 → 录协议替身 → 写表 4。"""
"""返利班次:领表 4 起草行 / 表 3 只录协议行 → runner 查号+总单+细单+送审 → 写表 4。"""
from __future__ import annotations
from collections import OrderedDict
@ -7,6 +7,8 @@ from typing import Callable
from persist import (
KIND_FAIL,
KIND_NOT_FOUND,
KIND_OK,
AgreementOutcome,
RebateGroup,
agreement_ok,
@ -15,8 +17,10 @@ from persist import (
)
from repo import Claimed
Finder = Callable[[str], bool]
Recorder = Callable[[str], AgreementOutcome]
Runner = Callable[[RebateGroup], AgreementOutcome]
Header = Callable[[str], str]
Importer = Callable[[str], object]
Submitter = Callable[[str], object]
@dataclass
@ -28,11 +32,41 @@ class JobSummary:
failed: int = 0
def recorder(
contract_no: str,
header: Header,
importer: Importer,
submitter: Submitter,
) -> AgreementOutcome:
try:
order_no = (header(contract_no) or "").strip()
except Exception as exc:
return AgreementOutcome(KIND_FAIL, None, None, str(exc) or "总单失败")
if not order_no:
return AgreementOutcome(KIND_FAIL, None, None, "总单未拿到号")
try:
imported = importer(contract_no)
except Exception as exc:
return AgreementOutcome(KIND_FAIL, None, None, str(exc) or "细单导入失败")
if not _import_ok(imported):
return AgreementOutcome(KIND_FAIL, None, None, _import_reason(imported))
try:
submitted = submitter(contract_no)
except Exception as exc:
return AgreementOutcome(KIND_FAIL, None, None, str(exc) or "送审失败")
if submitted is None or not bool(getattr(submitted, "submitted", False)):
return AgreementOutcome(KIND_FAIL, None, None, "送审失败")
agreement_id = (getattr(submitted, "agreement_id", None) or "").strip()
if not agreement_id:
return AgreementOutcome(KIND_FAIL, None, None, "送审未读到总单号")
agreement_dtl_id = (getattr(submitted, "agreement_dtl_id", None) or "").strip() or None
return AgreementOutcome(KIND_OK, agreement_id, agreement_dtl_id, None)
def run_batch(
repo,
*,
finder: Finder,
recorder: Recorder,
runner: Runner,
limit: int | None = None,
) -> JobSummary:
claimed = repo.claim_pending(limit=limit)
@ -43,21 +77,15 @@ def run_batch(
)
for group in groups:
try:
found = bool(finder(group.contract_no))
except Exception:
repo.save_group(build_not_found(group))
summary.not_found += 1
continue
if not found:
repo.save_group(build_not_found(group))
summary.not_found += 1
continue
try:
outcome = recorder(group.contract_no)
outcome = runner(group)
except Exception as exc:
outcome = AgreementOutcome(KIND_FAIL, None, None, str(exc))
if outcome is None:
outcome = AgreementOutcome(KIND_FAIL, None, None, "协议录入失败")
if outcome.kind == KIND_NOT_FOUND:
repo.save_group(build_not_found(group))
summary.not_found += 1
continue
repo.save_group(build_found(group, outcome))
if agreement_ok(outcome):
summary.ok += 1
@ -80,3 +108,21 @@ def group_claimed(claimed: Claimed) -> list[RebateGroup]:
for row in claimed.protocols:
bucket(row["contract_no"]).protocols.append(row)
return list(groups.values())
def _import_ok(result: object) -> bool:
if result is None:
return False
success = int(getattr(result, "success_count", 0) or 0)
fail = int(getattr(result, "fail_count", 0) or 0)
parsed = getattr(result, "parsed", True)
return bool(parsed) and success > 0 and fail == 0
def _import_reason(result: object) -> str:
if result is None:
return "细单导入失败"
reasons = getattr(result, "fail_reasons", ()) or ()
if reasons:
return str(reasons[0]).strip() or "细单导入失败"
return "细单导入失败"

View File

@ -1,15 +1,15 @@
"""返利班结果:查号 / 录协议 → 表 4 更新与插入、表 3 状态。"""
"""返利班结果:查号 / 送审 → 表 4 更新与插入、表 3 状态。"""
from __future__ import annotations
from dataclasses import dataclass, field
KIND_OK = "ok"
KIND_FAIL = "fail"
KIND_NOT_FOUND = "not_found"
STAMP_CONTRACT = "合同班"
STAMP_CLAIMING = "查号中"
STAMP_DONE = "返利班"
PROCESS_RESULT_MAX = 1000
FAIL_REASON_MAX = 500
@dataclass(frozen=True)
@ -75,12 +75,12 @@ def build_found(group: RebateGroup, outcome: AgreementOutcome) -> GroupSave:
seen_protocol.setdefault(int(row["oms_id"]), row)
for row in seen_protocol.values():
inserts.append(_action1_success(row, number))
if ok:
seen_oms: dict[int, dict] = {}
for row in group.drafts + group.protocols:
seen_oms.setdefault(int(row["oms_id"]), row)
for row in seen_oms.values():
inserts.append(_action2(row, number, outcome, ok))
if ok:
inserts.append(_action2(row, number, outcome))
process_status = 1
process_result = "协议已录入"
else:
@ -122,27 +122,17 @@ def _action1_success(row: dict, contract_no: str) -> dict:
}
def _action2(row: dict, contract_no: str, outcome: AgreementOutcome, ok: bool) -> dict:
if ok:
status = 1
fail_reason = None
agreement_id = (outcome.agreement_id or "").strip() or None
agreement_dtl_id = (outcome.agreement_dtl_id or "").strip() or None
else:
status = 2
fail_reason = clip(outcome.reason or "协议录入失败", FAIL_REASON_MAX)
agreement_id = None
agreement_dtl_id = None
def _action2(row: dict, contract_no: str, outcome: AgreementOutcome) -> dict:
return {
"oms_id": int(row["oms_id"]),
"action_code": 2,
"contract_status": None,
"contract_fail_reason": None,
"contract_no": contract_no,
"agreement_status": status,
"agreement_fail_reason": fail_reason,
"agreement_id": agreement_id,
"agreement_dtl_id": agreement_dtl_id,
"agreement_status": 1,
"agreement_fail_reason": None,
"agreement_id": (outcome.agreement_id or "").strip() or None,
"agreement_dtl_id": (outcome.agreement_dtl_id or "").strip() or None,
"push_status": 0,
"org_code": row.get("org_code"),
"create_user_id": 0,

View File

@ -5,6 +5,7 @@ description = "洁达返利班。表 4 查号 / 表 3 只录协议 → 表 4。
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"openpyxl>=3.1.5",
"oracledb>=2.4.1",
"selenium>=4.15.0",
"webdriver-manager>=4.0.0",

File diff suppressed because it is too large Load Diff

View File

@ -95,6 +95,20 @@ class MemoryRebateRepo:
target["process_status"] = item["process_status"]
target["process_result"] = item["process_result"]
def load_contract_goods(self, contract_no: str) -> list[dict]:
number = (contract_no or "").strip()
oms_ids = {
int(row["oms_id"])
for row in self.table4.values()
if (row.get("contract_no") or "").strip() == number and row.get("oms_id") is not None
}
return [
dict(row)
for row in self.table3.values()
if (row.get("contract_no") or "").strip() == number
or int(row.get("oms_id") or 0) in oms_ids
]
_SELECT_DRAFTS = """
SELECT
@ -206,6 +220,22 @@ _TABLE4_BIND_KEYS = (
)
_SELECT_CONTRACT_GOODS = """
SELECT
fid, oms_id, supplier_id, supplier_name, supplier_mdm_code,
planner_id, planner_name, purchaser_name, org_code,
goods_id, qty, unit_price, goods_source_id, purchase_tax_rate,
contract_start_date, contract_end_date, payment_term
FROM rpa_purchase_contract_data
WHERE TRIM(contract_no) = :contract_no
OR oms_id IN (
SELECT oms_id FROM rpa_contract_agreement_result
WHERE TRIM(contract_no) = :contract_no
)
ORDER BY fid
"""
def _records(cur) -> list[dict]:
cols = [item[0].lower() for item in cur.description]
return [dict(zip(cols, rec)) for rec in cur.fetchall()]
@ -240,6 +270,20 @@ class OracleRebateRepo:
finally:
conn.close()
def load_contract_goods(self, contract_no: str) -> list[dict]:
number = (contract_no or "").strip()
if not number:
return []
conn = self._connect()
try:
with conn.cursor() as cur:
cur.execute(_SELECT_CONTRACT_GOODS, {"contract_no": number})
return _records(cur)
except Exception as exc:
raise RepoUnavailable(str(exc)) from exc
finally:
conn.close()
def save_group(self, payload: GroupSave) -> None:
conn = self._connect()
try:

73
rpa_rebate/run_batch.py Normal file
View File

@ -0,0 +1,73 @@
"""返利班次入口:领表 4 起草行 / 表 3 只录协议行,runner 查号 → 总单 → 细单 → 送审,写表 4。"""
from __future__ import annotations
import argparse
from apa_browser_adapter import ApaBrowserAdapter
from db import load_runtime_env
from job import recorder, run_batch
from persist import KIND_NOT_FOUND, AgreementOutcome, RebateGroup
from rebate_finder import RebateFinder, context_from_env
from rebate_flow_steps import (
import_draft_for_contract,
run_header_flow,
run_import_flow,
run_submit_flow,
)
from repo import OracleRebateRepo
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="返利班次:查号 + 总单 + 细单 + 送审")
parser.add_argument("--limit", type=int, default=None, help="本班最多领取 N 条")
return parser.parse_args(argv)
def production_runner(browser, ctx, finder: RebateFinder, group: RebateGroup) -> AgreementOutcome:
contract_no = group.contract_no
print(f"find contract_no={contract_no}", flush=True)
try:
found = bool(finder(contract_no))
except Exception:
return AgreementOutcome(KIND_NOT_FOUND, None, None, None)
print(f"found={'yes' if found else 'no'} contract_no={contract_no}", flush=True)
if not found:
return AgreementOutcome(KIND_NOT_FOUND, None, None, None)
def header(number: str) -> str:
print(f"header contract_no={number}", flush=True)
return run_header_flow(browser, ctx, number)
def importer(number: str):
print(f"import contract_no={number}", flush=True)
return run_import_flow(browser, ctx, number, import_draft_for_contract(number))
def submitter(number: str):
print(f"submit contract_no={number}", flush=True)
return run_submit_flow(browser, ctx, number)
return recorder(contract_no, header, importer, submitter)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
load_runtime_env()
repo = OracleRebateRepo.from_env()
ctx = context_from_env()
browser = ApaBrowserAdapter(url=ctx.rebate_url, flow="REBATE_FIND")
finder = RebateFinder(browser, ctx)
summary = run_batch(
repo,
runner=lambda group: production_runner(browser, ctx, finder, group),
limit=args.limit,
)
print(
f"claimed={summary.claimed} groups={summary.groups} "
f"ok={summary.ok} not_found={summary.not_found} failed={summary.failed}",
flush=True,
)
return 0 if summary.failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

26
rpa_rebate/run_header.py Normal file
View File

@ -0,0 +1,26 @@
"""页面总单入口。不连 Oracle、不导细单、不送审。验收只看 ACCEPT.txt。"""
from __future__ import annotations
import os
from apa_browser_adapter import ApaBrowserAdapter
from db import load_runtime_env
from rebate_finder import context_from_env
from rebate_flow_steps import header_draft_for_contract, run_header_flow
def main() -> int:
load_runtime_env()
contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or "").strip()
if not contract_no:
print("缺少 REBATE_ACCEPT_CONTRACT_NO,总单验收需要已搜到的合同号", flush=True)
return 1
ctx = context_from_env()
draft = header_draft_for_contract(contract_no)
browser = ApaBrowserAdapter(url=ctx.rebate_url, flow="REBATE_HEADER")
order_no = run_header_flow(browser, ctx, contract_no, draft)
return 0 if order_no else 1
if __name__ == "__main__":
raise SystemExit(main())

28
rpa_rebate/run_import.py Normal file
View File

@ -0,0 +1,28 @@
"""页面细单新增入口(按货品ID搜,不做 Excel)。不连 Oracle、不送审、不写 actionCode=2。验收只看 ACCEPT.txt。"""
from __future__ import annotations
import os
from apa_browser_adapter import ApaBrowserAdapter
from db import load_runtime_env
from rebate_finder import context_from_env
from rebate_flow_steps import import_draft_for_contract, run_import_flow
def main() -> int:
load_runtime_env()
contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or "").strip()
if not contract_no:
print("缺少 REBATE_ACCEPT_CONTRACT_NO,细单新增需要已搜到的合同号", flush=True)
return 1
ctx = context_from_env()
draft = import_draft_for_contract(contract_no)
browser = ApaBrowserAdapter(url=ctx.rebate_url, flow="REBATE_IMPORT")
result = run_import_flow(browser, ctx, contract_no, draft)
if result.parsed and result.fail_count == 0 and result.success_count > 0:
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())

27
rpa_rebate/run_submit.py Normal file
View File

@ -0,0 +1,27 @@
"""页面送审入口。不连 Oracle、不写 actionCode=2、不推口 4。验收只看 ACCEPT.txt。"""
from __future__ import annotations
import os
from apa_browser_adapter import ApaBrowserAdapter
from db import load_runtime_env
from rebate_finder import context_from_env
from rebate_flow_steps import run_submit_flow
def main() -> int:
load_runtime_env()
contract_no = (os.getenv("REBATE_ACCEPT_CONTRACT_NO") or "").strip()
if not contract_no:
print("缺少 REBATE_ACCEPT_CONTRACT_NO,送审需要已搜到的合同号", flush=True)
return 1
ctx = context_from_env()
browser = ApaBrowserAdapter(url=ctx.rebate_url, flow="REBATE_SUBMIT")
result = run_submit_flow(browser, ctx, contract_no)
if result.submitted and result.agreement_id:
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,6 +1,7 @@
from accept import accept_log_path
from job import run_batch
from persist import KIND_OK, STAMP_CONTRACT, STAMP_DONE, AgreementOutcome
from rebate_finder import RebateFinder
from rebate_flow_steps import (
SENTINEL_CONTRACT_NO,
RebateFlowContext,
@ -8,7 +9,6 @@ from rebate_flow_steps import (
run_find_flow,
search_contract,
)
from rebate_finder import RebateFinder
from repo import MemoryRebateRepo
@ -97,6 +97,14 @@ def test_search_table_hit_is_found():
assert search_contract(browser, "HT1") is True
def test_search_one_row_without_visible_contract_no_is_found():
browser = FakeBrowser(
table_text="协议总单ID 采购协议号\nCG-743799-20261010-00002",
body_text="采购协议管理 共 1 条",
)
assert search_contract(browser, "035012026101035440") is True
def test_page_finder_found_writes_agreement_like_stub():
repo = MemoryRebateRepo(
table4=[
@ -130,10 +138,11 @@ def test_page_finder_found_writes_agreement_like_stub():
password="secret",
)
def recorder(contract_no: str) -> AgreementOutcome:
def runner(group):
assert finder(group.contract_no) is True
return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", None)
summary = run_batch(repo, finder=finder, recorder=recorder)
summary = run_batch(repo, runner=runner)
assert summary.ok == 1
assert repo.table4[1]["contract_status"] == 2
assert repo.table4[1]["update_user_name"] == STAMP_DONE

View File

@ -0,0 +1,295 @@
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,
PLANNER_DEFAULT,
RebateFlowContext,
_pick_person,
build_header_draft,
fill_rebate_header,
header_draft_from_table3,
import_lines_from_table3,
run_header_flow,
)
class FakeBrowser:
def __init__(self, *, table_text="", body_text="", login_body="", 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.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
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))
def click_native(self, selectors: list[str]) -> None:
self.click_first(selectors)
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:
self.clicked.append([str(args[0] if args else "")])
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_REBATE_ORDER_NO" in text:
return self.order_no
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
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))
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",
)
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"
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
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
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]
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:
"""计划员三个点打开自己的选择层,不拿货源选择当层。"""
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
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

View File

@ -0,0 +1,325 @@
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,
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):
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.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
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))
def click_native(self, selectors: list[str]) -> None:
self.click_first(selectors)
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 ["G001 10 ZQD1"]
if "PICK_DETAIL_OPTION" in text:
return {"ok": True, "text": str(args[0] if args else "")}
if "CONFIRM_DETAIL_CARD" in text:
return {"ok": True, "via": "确认"}
if "DETAIL_TOAST" in text:
return {"texts": []}
if "DETAIL_FORM_ERRORS" in text:
return []
if "FILL_GOODS_ID" in text:
return {"ok": True, "label": "货品ID", "value": str(args[0] if args else "")}
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 ""
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))
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)
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")
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")
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"
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"
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="")]
)
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"
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"
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]
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")

View File

@ -1,5 +1,6 @@
from job import run_batch
from persist import KIND_OK, STAMP_CONTRACT, STAMP_DONE, AgreementOutcome
from job import recorder, run_batch
from persist import KIND_NOT_FOUND, KIND_OK, STAMP_CONTRACT, STAMP_DONE, AgreementOutcome
from rebate_flow_steps import RebateImportResult, RebateSubmitResult
from repo import MemoryRebateRepo
@ -46,22 +47,16 @@ def test_job_finds_contract_and_writes_agreement():
_t4(fid=3, contract_status=2, contract_no="HT-DONE", update_user_name=STAMP_DONE),
]
)
looked: list[str] = []
recorded: list[str] = []
steps: list[str] = []
def finder(contract_no: str) -> bool:
looked.append(contract_no)
return True
def recorder(contract_no: str) -> AgreementOutcome:
recorded.append(contract_no)
def runner(group):
steps.append(group.contract_no)
return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", None)
summary = run_batch(repo, finder=finder, recorder=recorder)
summary = run_batch(repo, runner=runner)
assert summary.claimed == 2
assert summary.ok == 1
assert looked == ["HT1"]
assert recorded == ["HT1"]
assert steps == ["HT1"]
drafts = [row for row in repo.table4.values() if row["fid"] in (1, 2)]
assert {row["contract_status"] for row in drafts} == {2}
assert {row["action_code"] for row in drafts} == {1}
@ -80,14 +75,14 @@ def test_job_finds_contract_and_writes_agreement():
def test_job_leaves_not_found_for_next_shift():
repo = MemoryRebateRepo(table4=[_t4()])
recorded = {"n": 0}
later = {"n": 0}
def recorder(contract_no: str) -> AgreementOutcome:
recorded["n"] += 1
return AgreementOutcome(KIND_OK, "AGR1", "AGRD1", None)
def runner(group):
later["n"] += 1
return AgreementOutcome(KIND_NOT_FOUND, None, None, None)
run_batch(repo, finder=lambda _no: False, recorder=recorder)
assert recorded["n"] == 0
run_batch(repo, runner=runner)
assert later["n"] == 1
row = repo.table4[1]
assert row["contract_status"] == 1
assert row["action_code"] == 1
@ -97,14 +92,14 @@ def test_job_leaves_not_found_for_next_shift():
def test_job_claims_protocol_only_and_writes_table4():
repo = MemoryRebateRepo(table3=[_t3(), _t3(fid=11, process_status=1, contract_no="HT-SKIP")])
recorded: list[str] = []
steps: list[str] = []
def recorder(contract_no: str) -> AgreementOutcome:
recorded.append(contract_no)
def runner(group):
steps.append(group.contract_no)
return AgreementOutcome(KIND_OK, "AGR-OLD", "AGRD-OLD", None)
run_batch(repo, finder=lambda _no: True, recorder=recorder)
assert recorded == ["HT-OLD"]
run_batch(repo, runner=runner)
assert steps == ["HT-OLD"]
assert repo.table3[10]["process_status"] == 1
assert repo.table3[11]["process_status"] == 1
success = [row for row in repo.table4.values() if row["action_code"] == 1]
@ -118,3 +113,25 @@ def test_job_claims_protocol_only_and_writes_table4():
assert agreements[0]["agreement_status"] == 1
assert agreements[0]["agreement_id"] == "AGR-OLD"
assert agreements[0]["agreement_dtl_id"] == "AGRD-OLD"
def test_recorder_passes_contract_no_to_header_import_submit():
steps: list[str] = []
def header(contract_no: str) -> str:
steps.append(f"header:{contract_no}")
return "RB1"
def importer(contract_no: str) -> RebateImportResult:
steps.append(f"import:{contract_no}")
return RebateImportResult(1, 0, (), True)
def submitter(contract_no: str) -> RebateSubmitResult:
steps.append(f"submit:{contract_no}")
return RebateSubmitResult(True, "AGR1", "AGRD1")
outcome = recorder("HT1", header, importer, submitter)
assert steps == ["header:HT1", "import:HT1", "submit:HT1"]
assert outcome.kind == KIND_OK
assert outcome.agreement_id == "AGR1"
assert outcome.agreement_dtl_id == "AGRD1"

View File

@ -0,0 +1,138 @@
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 LOGIN, RebateFlowContext, _clean_submit_id, run_submit_flow
class FakeBrowser:
def __init__(self, *, table_text="", body_text="", login_body="", agreement_id="", agreement_dtl_id=""):
self.table_text = table_text
self.body_text = body_text
self.login_body = login_body or body_text
self.agreement_id = agreement_id
self.agreement_dtl_id = agreement_dtl_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.script_count = 0
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))
def click_native(self, selectors: list[str]) -> None:
self.click_first(selectors)
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 or "OPEN_HEADER_ROW" in text:
return {"ok": True, "via": "row"}
if "READ_AGREEMENT_NOS" in text:
return {"header": self.agreement_id, "detail": self.agreement_dtl_id}
if "READ_REBATE_ORDER_NO" in text:
return self.agreement_id
if "CONFIRM_SUBMIT" in text:
return {"ok": False, "asked": False}
if "table-toolbar-btn__text" in text:
title = str(args[0] if args else "")
self.clicked.append([title])
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 self.agreement_id
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))
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 test_submit_flow_writes_accept_with_order_nos(tmp_path, monkeypatch):
monkeypatch.delenv("RPA_ACCEPT_LOG", raising=False)
monkeypatch.setattr("accept.SCREENSHOT_ROOT", tmp_path)
browser = FakeBrowser(
table_text="HT1 重庆亿昊",
body_text="采购协议管理 合同编号 查询 送审 协议总单ID 71342020260528 协议细单ID 71342020260529",
login_body="采购协议管理",
agreement_id="71342020260528",
agreement_dtl_id="71342020260529",
)
result = run_submit_flow(browser, _ctx(), "HT1")
assert result.submitted is True
assert result.agreement_id == "71342020260528"
assert result.agreement_dtl_id == "71342020260529"
assert ["送审"] in browser.clicked
text = accept_log_path("REBATE_SUBMIT").read_text(encoding="utf-8")
assert "ACCEPT SUMMARY REBATE_SUBMIT 7/7 ok" in text
assert "ACCEPT S07 ok 点送审" in text
assert "submitted=yes" in text
assert "ACCEPT S08 ok 读号" in text
assert "rebateOrderNo=71342020260528" in text
assert "agreementDtlId=71342020260529" in text
def test_submit_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="采购协议管理",
agreement_id="AGR1",
agreement_dtl_id="AGRD1",
)
run_submit_flow(browser, _ctx(), "HT1")
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]
def test_submit_chrome_session_reuses_find():
assert chrome_session_flow("REBATE_SUBMIT") == "REBATE_FIND"
assert resolve_chrome_profile_dir("REBATE_SUBMIT") == resolve_chrome_profile_dir("REBATE_FIND")
assert chrome_debug_port("REBATE_SUBMIT") == chrome_debug_port("REBATE_FIND")
def test_submit_scan_rejects_loading_placeholder():
assert _clean_submit_id("查询加载中...设置查询条件") == ""
assert _clean_submit_id("协议细单ID") == ""
assert _clean_submit_id("71342020260528") == "71342020260528"
assert _clean_submit_id("71342020260528", contract_no="71342020260528") == ""

View File

@ -308,6 +308,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@ -335,6 +344,18 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "oracledb"
version = "26.0.1"
@ -481,6 +502,7 @@ name = "rpa-rebate"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "openpyxl" },
{ name = "oracledb" },
{ name = "selenium" },
{ name = "webdriver-manager" },
@ -493,6 +515,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "oracledb", specifier = ">=2.4.1" },
{ name = "selenium", specifier = ">=4.15.0" },
{ name = "webdriver-manager", specifier = ">=4.0.0" },

View File

@ -0,0 +1,71 @@
# 返利班现行模式(给审计)
写代码以 `返利流程.md` 为准。班次入口 `run_batch.py`。节点名 `REBATE_IMPORT` 仍叫 IMPORT,页面走细单新增,**不做 Excel**。
## 班次
1. 领表 4 `action_code=1 AND contract_status=1`(盖「查号中」);领表 3 只录协议行 `process_status` 0→3
2. 按合同号查返利中心,表里出现该号才算搜到
3. 勾已有总单(审批通过后已有一条,不要新增总单)
4. 补总单必填 → **卡片仍在编辑时**点总单工具栏保存 → 卡片确认
5. 细单:货品「新增」→ 货品编码三个点按表 3 `goods_id` 搜 → 货品卡片「确认 Ctrl + Enter」落到表 → 全部确认完再点货品工具栏保存
6. 总单工具栏送审;成功才写表 4
7. 同一合同一组一个结果
货品行:`load_contract_goods` 先按 `contract_no`,空则用表 4 该合同的 `oms_id` 对表 3。
## 字段(现行)
| 位置 | 字段 | 值 |
| --- | --- | --- |
| 总单 | 供应商编码 | 表 3 `supplier_mdm_code`,三个点搜 |
| 总单 | 计划员 | 廖建琦(选择框,不要选 ID) |
| 总单 | 联系人 | 苏文辉 |
| 总单 | 返利合约 | 否 |
| 总单 | 采购结算方式 | 电汇(不要点电汇—非直联) |
| 总单 | 承付模式 | 下月N号结 |
| 总单 | 承付参数 | **25**(数字框键盘输入) |
| 总单 | 协议开始/结束 | 当天 |
| 细单 | 货品 | 表 3 `goods_id` |
| 细单 | 数量 / 含税单价 | 表 3 `qty` / `unit_price` |
| 细单 | 无税单价 | `含税 / (1+税率)` |
| 细单 | 承付模式 | 下月N号结 |
| 细单 | 承付参数 | **25** |
| 细单 | 采购结算方式 | 电汇 |
| 细单 | 备注 | `oms_id` |
| 细单 | 特定补偿 / 进货补偿 | 0 |
| 细单 | 货源细单ID | 本期不填(下一会话:按总单联系人对货源,搜不到整组报错) |
落表:起草行 `contract_status=2`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带协议总单ID / 协议细单ID;`push_status=0`。搜不到戳改回合同班。
## 修改(相对旧稿 / Excel 方案)
| 项 | 旧 | 现 |
| --- | --- | --- |
| 细单路径 | Agents / README 写「下模板 → 填 Excel → 上传」 | 页面「新增 + 货品ID搜」。`run_import_flow` 调 `add_detail_lines_by_goods_id` |
| `返利流程(1).md` | 已知/未知草稿,Excel 与页面双轨 | 废稿,指向 `返利流程.md` |
| 承付参数 | 「不要填 25 / 以销售定结」 | 总单、细单都填 **25** |
| 总单保存 | 先卡片确认再保存 | 先总单工具栏保存(卡仍在编辑),再确认 |
| 细单确认 | 容易点到总单「确认」 | 只点货品卡片「确认 Ctrl + Enter」,确认后核对货品表有该 `goods_id` |
| 计划员弹层 | 适配器看见「货源选择」就关 | 计划员三点打开的货源层要按住再选廖建琦 |
| 货品来源 | 只按表 3 `contract_no` | `contract_no` 空时用表 4 `oms_id` 对表 3 |
| 落表 | (曾按行拆成功/失败,已撤回) | 同一合同一组一个结果 |
## 增加
| 项 | 落点 |
| --- | --- |
| 班次入口 | `run_batch.py`:查号 → 总单 → 细单新增 → 送审 → `save_group` |
| 总单数字框键盘填 25 | `_fill_pay_param` |
| 细单键盘填 25 | `_fill_last_detail_qty_price` / `_type_detail_field` |
| 货品表核对 | `_goods_table_rows`、`_wait_goods_row` |
| 原生点击 | 计划员附加按钮、总单/货品工具栏保存、卡片确认 |
| 现行登记 | 本文件 |
## 代码里还留着、班次不走
`rebate_flow_steps.py` 仍有 Excel 下模板 / 填表 / 上传函数(`write_canonical_template`、`download_detail_template` 等)。`run_import_flow` 不调用它们。不要按这些函数写新逻辑。
## 下一会话(未做)
细单「货源细单ID」三个点:用总单联系人对货源。弹层搜不到 → 整组停、写报错返回。不要按行一条成功两条失败。

View File

@ -0,0 +1,6 @@
# 废稿
这份是早期「已知/未知」草稿,还夹着 Excel 下模板上传,**不要按本文写代码**。
现行口径:`返利流程.md`
班次对照:`现行模式.md`

168
rpa_rebate/返利流程.md Normal file
View File

@ -0,0 +1,168 @@
# 返利中心页面流程
写代码以本文为准。
---
## 环境
- 地址:`http://192.168.170.144:8001`
- 菜单:采购协议管理
- 登录账号走本机 `rpa_rebate/.env`
- 合同号用合同系统号,例:`035012026101035440`
- 细单货品:点「货品编码」三个点,弹窗里用表 3 / 接口 3 的 `goodsId` 搜
- 计划员:先点进选择框,默认选「廖建琦」,不要选计划员 ID。后期再改回订单中心计划员
- 联系人:选择框,选第二条「苏文辉」
- 承付模式:方案「下月 N 号结」
- 承付参数:总单、细单都填 `25`(vxe 数字框,键盘输入)
- 协议开始、协议结束:当天
- 细单备注 = 订单号(`oms_id`)
- 特定补偿预估值 = 0
- 进货补偿预估值 = 0
- 搜不到合同:不重录合同,留给下一班
- 送审成功才写表 4:`actionCode=2`、`agreementStatus=1`,带上 **协议总单ID** / **协议细单ID**
---
## 整班顺序
1. 打开返利中心,登录,进采购协议管理
2. 按合同号查号,表里出现该号才算搜到
3. 勾中该合同已有总单(审批通过后返利中心已有一条,不要点新增总单)
4. 补总单必填(卡片仍在「编辑」)→ 总单工具栏保存 → 再点卡片「确认 Ctrl + Enter」→ 读「协议总单ID」
5. 该总单下多次点细单「新增」,按货品 ID 搜品种;每条卡片点货品卡「确认 Ctrl + Enter」落到货品表;**所有细单都确认完再点货品工具栏保存**
6. 总单工具栏点送审(细单必须已保存)
7. 送审成功才落表 4。同一合同一组一个结果。
细单只走页面「新增 + 货品编码三个点 + 按货品ID搜」。Excel 下模板上传不做。班次入口 `run_batch.py`。
---
## 1 查号
1. 打开查询
2. 合同编号填合同系统号
3. 点查询
4. 关查询弹层用 **X / Esc**,不要点「取消」(会清掉合同编号筛选)
5. 总单表 `.table-show` 里「合同编号」列等于该号,点左侧固定列勾选框
6. 禁止拿第一行,禁止拿 `CG-` 号凑合
7. 勾中总单后,货品区才出「新增」
8. 表里出现该合同号才算搜到;输入框回显不算
合同审批通过后,返利中心已经有一条总单,直接在那条上操作,不要新增总单。打开后仍要补表头必填;供应商要点三个点进选择框。
---
## 2 总单
1. 先按合同编号勾中总单行
2. 打开总单:点「编辑」或双击行,哪个方便用哪个。不要点「新增」总单
3. 带星号都要填
4. 供应商:点「供应商编码」三个点,打开选择框,用表 3 的 `supplier_mdm_code` 搜,对上编码那一列,确认选中。选中后会带出供应商名称、返利单位等,不要手填这些带出项。名称和编码不要填反
5. 选对供应商时不应弹出「货源选择」;若弹出,停下问
6. 计划员:进选择框,先默认选「廖建琦」,不要选计划员 ID。后期再改
7. 联系人:进选择框,选第二条「苏文辉」
8. 采购结算方式:可筛选下拉,先输入「电汇」,再精确点「电汇」,不要点「电汇—非直联」
9. 承付模式:「下月N号结」。承付参数填 `25`(数字框,键盘输入;不要填「以销售定结」)
10. 卡片还在「编辑」时点总单工具栏「保存」,再点卡片「确认 Ctrl + Enter」。先确认会把卡收回表格,保存落不到库
11. 保存后总单号认 **协议总单ID**
### 2.1 总单字段
| 页面字段 | 怎么填 | 从哪来 |
| --- | --- | --- |
| 供应商编码 | 三个点打开选择框,用 `supplier_mdm_code` 搜,确认选中那条 | 表 3 |
| 供应商名称 | 不填,选供应商后带出 | 带出 |
| 返利单位编码 | 不填,选供应商后带出 | 带出 |
| 计划员 | 选择框先选「廖建琦」,不要选计划员 ID;后期再改 | 本账号,后期改 |
| 联系人 | 选择框选第二条「苏文辉」 | 页面选项 |
| 返利合约 | 「否」 | 写死 |
| 采购结算方式 | 「电汇」,精确点中 | 写死 |
| 承付模式 | 「下月N号结」 | 方案 |
| 承付参数 | `25` | 写死 |
| 协议开始日期 | 当天 | 写死 |
| 协议结束日期 | 当天 | 写死 |
---
## 3 细单
1. 必须先勾中总单,货品区才出「新增」
2. 点货品「新增」(同一合同多行:每行点一次新增)
3. 细单「货品编码」字段点三个点,打开货品选择
4. 弹窗里用 **货品ID** 搜表 3 的 `goods_id`(不要用 opcode / 货品编码搜)
5. 先点行里的单选 `.vxe-cell--radio`,再点弹窗底「确 定」
6. 数量、含税协议单价、无税单价按表 3 填
7. 细单备注 = 订单号;特定补偿 = 0
8. 承付模式:下月N号结;承付参数 `25`
9. 采购结算方式:先输入「电汇」再精确点「电汇」
10. 关税税率不填。货源细单ID 本期不填(下一会话:按总单联系人对货源,搜不到整组报错返回)。进货补偿预估值确认时报必填,填 0
11. 细单开始 / 结束日期先用系统默认;保存报错再看
12. 每条卡片填完点标题栏「确认 Ctrl + Enter」,行才落到货品表。不要点「返回表格」代替确认
13. **三条都确认完,再点货品工具栏「保存」**(新增旁边那颗,不要点总单工具栏保存)。保存时若弹出确认,再点确认。不要每条都点工具栏保存
14. 提示「总单供应商为空」时,先回总单把供应商补上,再继续细单
表 3 没有「无税单价」列,只有 `unit_price`(含税)和 `purchase_tax_rate`。无税单价按 `含税 ÷ (1+税率)` 算;税率用该行 `purchase_tax_rate`,空则 `0.13`。
### 3.1 细单字段
| 页面字段 | 怎么填 | 从哪来 |
| --- | --- | --- |
| 货品编码 | 三个点打开选择框,用货品ID搜 | 表 3 `goods_id` |
| 数量 | 手填 | 表 3 `qty` |
| 含税协议单价 | 手填 | 表 3 `unit_price` |
| 无税单价 | 手填 | 表 3:`unit_price / (1 + purchase_tax_rate)` |
| 税率 | 表 3 `purchase_tax_rate`,空则 `0.13` | 表 3 |
| 关税税率 | 不填 | — |
| 承付模式 | 「下月N号结」 | 方案 |
| 承付参数 | `25` | 写死 |
| 采购结算方式 | 「电汇」,精确点中 | 写死 |
| 细单开始日期 | 系统默认,报错再看 | 页面 |
| 细单结束日期 | 系统默认,报错再看 | 页面 |
| 特定补偿预估值 | `0` | 写死 |
| 进货补偿预估值 | 页面确认时报必填,填 `0` | 写死 |
| 细单备注 | 订单号 | 表 3 `oms_id` |
| 货源细单ID | 本期不填。下一会话:总单联系人同一货源;三个点搜不到则整组报错返回 | — |
---
## 4 送审
1. 总单、细单都保存完再送审;未保存细单不能送审
2. 送审按钮在 **总单工具栏**
3. 点「送审」;有确认框就确定
4. 送审成功才插表 4 协议行
5. 总单号认 **协议总单ID**,细单号认 **协议细单ID**。列表上一时看不见,再点一次查询
---
## 5 落表(班次,不改页面)
- 领表 4:`action_code=1 AND contract_status=1`(合同班起草、尚未回成功)
- 领表 3:`contract_entry_status=0` 且已有 `contractNo` 的只录协议行
- 领取时表 4 盖「查号中」,表 3 `process_status` 0→3
- 崩溃停戳:改回「合同班」/ `0` 再跑
- 搜到并送审成功:起草行 `contract_status=2`;同一合同每个 `oms_id` 插同一套 `action_code=2`、`agreement_status=1`,带协议总单ID / 协议细单ID;`push_status=0`。一组一个结果,不按行拆成功/失败
- 搜不到:戳改回「合同班」,表 3 改回 `0`,不插协议行
- 不推接口 4,不改 `push_status`
---
## 6 不要做
- 不要点查询弹层的「取消」
- 不要拿第一行或 `CG-` 号当总单
- 不要新增总单(审批通过后已有一条)
- 不要选计划员 ID;现阶段选「廖建琦」
- 供应商选择框里不要把名称和编码填反;用 `supplier_mdm_code` 搜编码
- 不要用 opcode / 货品编码在返利中心搜品种;用货品ID
- 结算不要点到「电汇—非直联」
- 细单不要点总单工具栏的保存
- 不要每条细单都点工具栏保存;卡片「确定」落到表后再统一保存
- 不要把承付参数填成「以销售定结」;总单、细单都填 `25`
- 不要做 Excel 导入
- 不要按行拆同一合同的成功/失败;一组一个结果
- 不要点总单卡片的「确认」去当细单确认;细单只点货品卡片「确认 Ctrl + Enter」
- 搜不到不要重录合同
- 浏览器卡住时不要关,停下问
- 计划员三个点打开的「货源选择」是选人弹层,要按住再搜「廖建琦」;适配器默认关货源层时计划员选不了