feat: 联调后收口选品加购合同返利

选品 cookie 只留 HTTP 可发项;11.2 按 fid IN 锁基表,避免 ORA-02014。
加购走现行口:1 号药城 /api/cart/add,药帮忙 change-cart。
合同吃接口 3 信封合单,页面缺省不猜。
返利计划员、账期、货源细单ID 读表 3,空则整组停。
This commit is contained in:
34BFF76918A9449ABAB323149DDCEA73 2026-10-26 01:15:05 +08:00
parent bcf5e03d07
commit 7d258ce62f
42 changed files with 1647 additions and 232 deletions

View File

@ -148,7 +148,7 @@
验收:`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`。 验收:`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`,不碰选品 / 加购 / 合同页面。货源细单ID 本期不填(弹层按联系人苏文辉过滤)。计划员现阶段廖建琦。 不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。货源细单ID 按表 3 `goods_source_id` 搜(弹层按总单联系人过滤,空则整组停)。计划员读表 3 `planner_name`。
## 返利中心及之后 ## 返利中心及之后
@ -158,7 +158,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `REBATE_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 | | `REBATE_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 |
| `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:勾已有总单,按 `返利流程.md` 填现行字段 → 保存拿协议总单ID | | `REBATE_HEADER` | RPA | `rebateOrderNo` | 已拆:勾已有总单,按 `返利流程.md` 填现行字段 → 保存拿协议总单ID |
| `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=25。不做 Excel | | `REBATE_IMPORT` | RPA | 货品表行数 | 已拆:勾总单 → 货品「新增」→ 货品编码三个点按 `goodsId` 搜 → 货源细单ID 按 `goods_source_id` 搜 → 卡片确认落到表 → 货品工具栏保存。细单备注=订单号,特定补偿=0,承付参数=表 3 账期。不做 Excel |
| `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 | | `REBATE_SUBMIT` | RPA | `submitted` + 总单/细单号 | 已拆:点送审;马上能见号则带回 |
| `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) | | `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) |
| `M5_MANUAL_CONFIRM` | 采购 | — | 人在 M5 里保存签订 | | `M5_MANUAL_CONFIRM` | 采购 | — | 人在 M5 里保存签订 |

View File

@ -40,8 +40,6 @@
| 项 | 口径 | | 项 | 口径 |
| --- | --- | | --- | --- |
| 返利货源细单ID | 弹层按总单联系人(现苏文辉)过滤;本期不填,见 `rpa_rebate/返利流程.md` |
| 返利计划员 | 现阶段「廖建琦」,后期改回表 3 `planner_name` |
| 口 2 / 口 4 HTTP | path 空,只组包不发 | | 口 2 / 口 4 HTTP | path 空,只组包不发 |
| 药帮忙支付 | 只做了 1 号药城 | | 药帮忙支付 | 只做了 1 号药城 |
| M5 | 录单停、不保存;收货通知单号人填 | | M5 | 录单停、不保存;收货通知单号人填 |

View File

@ -136,7 +136,7 @@
- 搜到:先回合同成功,再录协议 - 搜到:先回合同成功,再录协议
- 搜不到:本班不录协议、不重开合同,下一班再搜 - 搜不到:本班不录协议、不重开合同,下一班再搜
总单/细单现行字段以 `rpa_rebate/返利流程.md` 为准:计划员现阶段「廖建琦」(后期改回订单中心),联系人「苏文辉」,结算「电汇」,承付「下月 N 号结」参数 `25`,协议开始/结束当天。细单走页面新增(按 `goods_id` 搜),不做 Excel。货源细单ID 本期不填。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。映射表作对照,冲突听返利流程。 总单/细单现行字段以 `rpa_rebate/返利流程.md` 为准:计划员表 3 `planner_name`,联系人写死「苏文辉」(表 3 无列),结算「电汇」,承付「下月 N 号结」参数用表 3 `payment_term`,协议起止用表 3 日期。细单走页面新增(按 `goods_id` 搜),货源细单ID 搜表 3 `goods_source_id`,空则整组停。班次缺表 3 整组失败。不要用方案 5.5 的「登录人 / 账期 / 当年最后一天」。映射表作对照,冲突听返利流程。
### 5.3 协议「成功」 ### 5.3 协议「成功」

View File

@ -28,7 +28,7 @@ function compact(el) {
const hits = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => { const hits = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).filter((el) => {
if (!vis(el)) return false; if (!vis(el)) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return title === '货源选择'; return title === '货源选择' || title.indexOf('货源细单') >= 0;
}); });
for (const modal of hits) { for (const modal of hits) {
const btn = Array.from(modal.querySelectorAll('button')).find((el) => { const btn = Array.from(modal.querySelectorAll('button')).find((el) => {

View File

@ -10,6 +10,7 @@ import requests
import selection_path # noqa: F401 先把 rpa_selection 放进导入路径 import selection_path # noqa: F401 先把 rpa_selection 放进导入路径
from cart_match import CartOffer from cart_match import CartOffer
from http_util import set_session_cookies
from search_yaoex import map_yaoex_products, query_yaoex_products from search_yaoex import map_yaoex_products, query_yaoex_products
from search_ybm import map_ybm_products, query_ybm_products from search_ybm import map_ybm_products, query_ybm_products
@ -54,8 +55,7 @@ def search_ybm_offers(
client = session or requests.Session() client = session or requests.Session()
try: try:
if own: if own:
for name, value in cookies.items(): set_session_cookies(client, cookies)
client.cookies.set(name, value)
rows = query_ybm_products(client, query_key) rows = query_ybm_products(client, query_key)
candidates = map_ybm_products(rows) candidates = map_ybm_products(rows)
offers = [ offers = [

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import time import time
from typing import Any from typing import Any
@ -14,28 +15,25 @@ from cart_match import CartOffer
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
GATEWAY = "https://gateway-b2b.fangkuaiyi.com" GATEWAY = "https://gateway-b2b.fangkuaiyi.com"
YAOEX_ADD_URL = f"{GATEWAY}/small/shopCart/addShopCart" YAOEX_ADD_URL = f"{GATEWAY}/api/cart/add"
# PC 搜索页加车写死的来源码,所有登录账号同一套。
YAOEX_ADD_TYPE = "1"
YAOEX_SOURCE_TYPE = 17052
YAOEX_FROMWHERE_CART = 0
# 按再搜到的 sku 把数量加入 1 号药城购物车。没有 sku 当系统失败,不改业务原因。 # 按搜索结果的 spu 和仓,把数量加入 1 号药城购物车。缺 spu 或仓当系统失败。
def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Session | None = None) -> AddOutcome: def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Session | None = None) -> AddOutcome:
ids = _ids(offer) ids = _ids(offer)
if not ids["skuId"] and not ids["spuCode"]: if not ids["spuCode"] or ids["supplyId"] in ("", None):
return AddOutcome(kind=KIND_SYSTEM, reason="缺少 sku") return AddOutcome(kind=KIND_SYSTEM, reason="缺少 sku")
payload = _form( common, payload = _form(auth, ids, qty)
auth,
{
"skuId": ids["skuId"],
"spuCode": ids["spuCode"],
"sellerCode": ids["sellerCode"],
"amount": int(qty),
},
)
own = session is None own = session is None
client = session or requests.Session() client = session or requests.Session()
try: try:
response = client.post( response = client.post(
YAOEX_ADD_URL, YAOEX_ADD_URL,
params=common,
data=payload, data=payload,
headers=_headers(), headers=_headers(),
timeout=30, timeout=30,
@ -50,20 +48,36 @@ def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Sessio
client.close() client.close()
# 从报价里取出加车要的 sku、spu 和商家编码。 # 从报价里取出加车要的 spu 和仓。仓优先 storeId / frontSellerCode,各账号字段名一样。
def _ids(offer: CartOffer) -> dict[str, str]: def _ids(offer: CartOffer) -> dict[str, Any]:
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
if not isinstance(raw, dict): if not isinstance(raw, dict):
raw = {} raw = {}
sku = str(raw.get("skuId") or raw.get("id") or offer.sku_id or "") spu = str(raw.get("spuCode") or offer.sku_id or "").strip()
spu = str(raw.get("spuCode") or offer.sku_id or "") supply = _supply_id(raw)
seller = str(raw.get("sellerCode") or raw.get("vendorCode") or raw.get("shopCode") or "") return {"spuCode": spu, "supplyId": supply}
return {"skuId": sku, "spuCode": spu, "sellerCode": seller}
# 拼 1 号药城表单:带上登录 token,再叠上加车字段。 # 仓号收成网关要的值。纯数字就当整数,避免各账号字符串/数字不一致。
def _form(auth, extra: dict[str, Any]) -> dict[str, Any]: def _supply_id(raw: dict[str, Any]):
payload = { value = raw.get("storeId")
if value in (None, ""):
value = raw.get("frontSellerCode")
if value in (None, ""):
value = raw.get("supplyId")
if value in (None, ""):
value = raw.get("sellerCode")
if value in (None, ""):
return ""
text = str(value).strip()
if text.isdigit():
return int(text)
return text
# 拼 1 号药城加车表单:query 和 body 都带登录 token,itemList 是页面同款 JSON。
def _form(auth, ids: dict[str, Any], qty: int) -> tuple[dict[str, Any], dict[str, Any]]:
common = {
"traderName": "yaoex_pc", "traderName": "yaoex_pc",
"trader": "pc", "trader": "pc",
"closesignature": "yes", "closesignature": "yes",
@ -75,11 +89,22 @@ def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
"userId": getattr(auth, "user_id", ""), "userId": getattr(auth, "user_id", ""),
"buyerCode": getattr(auth, "buyer_code", ""), "buyerCode": getattr(auth, "buyer_code", ""),
} }
payload.update(extra) item: dict[str, Any] = {
return payload "productNum": int(qty),
"spuCode": ids["spuCode"],
"supplyId": ids["supplyId"],
}
payload = {
**common,
"addType": YAOEX_ADD_TYPE,
"sourceType": YAOEX_SOURCE_TYPE,
"fromwhere": YAOEX_FROMWHERE_CART,
"itemList": json.dumps([item], ensure_ascii=False),
}
return common, payload
# 1 号药城加车请求头。来源页是购物车。 # 1 号药城加车请求头。来源页是搜索加车页,所有账号同一套。
def _headers() -> dict[str, str]: def _headers() -> dict[str, str]:
return { return {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
@ -88,7 +113,12 @@ def _headers() -> dict[str, str]:
"traderName": "yaoex_pc", "traderName": "yaoex_pc",
"trader": "pc", "trader": "pc",
"Origin": "https://mall.yaoex.com", "Origin": "https://mall.yaoex.com",
"Referer": "https://mall.yaoex.com/v2/cart/", "Referer": "https://mall.yaoex.com/v2/pages/",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
} }
@ -96,11 +126,38 @@ def _headers() -> dict[str, str]:
def _outcome_from_json(payload: object) -> AddOutcome: def _outcome_from_json(payload: object) -> AddOutcome:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常") return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
msg = str(payload.get("msg") or payload.get("message") or payload.get("errorMsg") or "") if payload.get("success") is True or payload.get("code") in (0, "0", 200, "200"):
code = payload.get("code")
success = payload.get("success")
if success is True or code in (0, "0", 200, "200"):
return AddOutcome(kind=KIND_OK) return AddOutcome(kind=KIND_OK)
if msg: rtn = payload.get("rtn_code")
if rtn not in (None, 0, "0"):
msg = str(payload.get("rtn_msg") or payload.get("rtn_tip") or "加购失败")
if "登录" in msg:
return AddOutcome(kind=KIND_SYSTEM, reason=msg)
return classify_mall_message(msg) return classify_mall_message(msg)
data = payload.get("data")
if not isinstance(data, dict):
data = payload
blob = _result_text(payload, data)
status = data.get("statusCode")
if status in (0, "0") and not blob:
return AddOutcome(kind=KIND_OK)
if blob:
return classify_mall_message(blob)
if status in (0, "0"):
return AddOutcome(kind=KIND_OK)
if status in (2, "2"):
return AddOutcome(kind=KIND_BUSINESS, reason="限购")
return AddOutcome(kind=KIND_SYSTEM, reason="加购失败") return AddOutcome(kind=KIND_SYSTEM, reason="加购失败")
# 从 data.message 和 cartResponseList[].message 拼出商城回文。
def _result_text(payload: dict, data: dict) -> str:
parts: list[str] = []
for row in data.get("cartResponseList") or data.get("addCartItemList") or []:
if isinstance(row, dict) and row.get("message"):
parts.append(str(row["message"]))
for key in ("message", "msg", "errorMsg"):
value = data.get(key) or payload.get(key)
if value:
parts.append(str(value))
return " ".join(parts).strip()

View File

@ -5,12 +5,15 @@ from __future__ import annotations
import requests import requests
import urllib3 import urllib3
import selection_path # noqa: F401
from cart_add import KIND_OK, KIND_SYSTEM, AddOutcome, classify_mall_message from cart_add import KIND_OK, KIND_SYSTEM, AddOutcome, classify_mall_message
from cart_match import CartOffer from cart_match import CartOffer
from http_util import set_session_cookies
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/add-product" # 搜索页加车现行地址。旧口 /new-front/cart/add-product 已 404。
YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/change-cart"
# 按再搜到的 sku 把数量加入药帮忙购物车。没有 sku 当系统失败。 # 按再搜到的 sku 把数量加入药帮忙购物车。没有 sku 当系统失败。
@ -28,11 +31,10 @@ def add_ybm_cart(
client = session or requests.Session() client = session or requests.Session()
try: try:
if own: if own:
for name, value in cookies.items(): set_session_cookies(client, cookies)
client.cookies.set(name, value)
response = client.post( response = client.post(
YBM_ADD_URL, YBM_ADD_URL,
json={"skuId": sku, "skuNum": int(qty)}, json=_body(offer, sku, qty),
headers=_headers(), headers=_headers(),
timeout=30, timeout=30,
verify=False, verify=False,
@ -48,10 +50,31 @@ def add_ybm_cart(
# 从报价里取药帮忙加车用的 skuId。 # 从报价里取药帮忙加车用的 skuId。
def _sku_id(offer: CartOffer) -> str: def _sku_id(offer: CartOffer) -> str:
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra raw = _raw(offer)
if not isinstance(raw, dict): return str(raw.get("skuId") or raw.get("id") or offer.sku_id or "")
raw = {}
return str(raw.get("skuId") or offer.sku_id or "")
# 搜索页加车体:amount 是数量,direct=1 是列表加车。merchantId 有就带,没有留空。
def _body(offer: CartOffer, sku: str, qty: int) -> dict:
raw = _raw(offer)
merchant = raw.get("merchantId")
if merchant in (None, ""):
merchant = ""
return {
"skuId": int(sku) if str(sku).isdigit() else sku,
"amount": int(qty),
"merchantId": merchant,
"mddata": "",
"direct": 1,
"qtdata": "{}",
}
# 报价里的原始商品字段。没有 raw 就用 extra 本身。
def _raw(offer: CartOffer) -> dict:
extra = offer.extra if isinstance(offer.extra, dict) else {}
raw = extra.get("raw") if isinstance(extra.get("raw"), dict) else extra
return raw if isinstance(raw, dict) else {}
# 药帮忙加车请求头。 # 药帮忙加车请求头。
@ -70,14 +93,18 @@ def _headers() -> dict[str, str]:
# 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。 # 把加车回包收成成功、业务失败或系统失败。看不懂的回包当系统失败。
#
# 现行口成功时顶层是 code=200 / msg=操作成功。data.message 可能带中包装提示,
# 页面仍当加车成功;选品也不再按中包装拆量,这里不把提示打成失败。
def _outcome_from_json(payload: object) -> AddOutcome: def _outcome_from_json(payload: object) -> AddOutcome:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常") return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
msg = str(payload.get("msg") or payload.get("message") or "")
success = payload.get("success") success = payload.get("success")
code = payload.get("code") code = payload.get("code")
if success is True or code in (0, "0", 200, "200"): if success is True or code in (0, "0", 200, "200"):
return AddOutcome(kind=KIND_OK) return AddOutcome(kind=KIND_OK)
if msg: data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
return classify_mall_message(msg) blob = str(data.get("message") or payload.get("msg") or payload.get("message") or "").strip()
if blob:
return classify_mall_message(blob)
return AddOutcome(kind=KIND_SYSTEM, reason="加购失败") return AddOutcome(kind=KIND_SYSTEM, reason="加购失败")

View File

@ -19,6 +19,7 @@ from yaoex_login import (
resolve_yaoex_auth, resolve_yaoex_auth,
resolve_ybm_cookies, resolve_ybm_cookies,
) )
from http_util import set_session_cookies
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
EXPORT_DIR = PROJECT_ROOT / "exports" / "yaoex" EXPORT_DIR = PROJECT_ROOT / "exports" / "yaoex"
@ -204,8 +205,7 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
print(f"药帮忙登录失败,本平台中止:{exc}") print(f"药帮忙登录失败,本平台中止:{exc}")
if ybm_cookies: if ybm_cookies:
for name, value in ybm_cookies.items(): set_session_cookies(ybm_session, ybm_cookies)
ybm_session.cookies.set(name, value)
if yaoex_auth is not None: if yaoex_auth is not None:

View File

@ -1,3 +1,5 @@
import json
from cart_add import KIND_BUSINESS, KIND_OK, KIND_SYSTEM, classify_mall_message from cart_add import KIND_BUSINESS, KIND_OK, KIND_SYSTEM, classify_mall_message
from cart_match import CartOffer from cart_match import CartOffer
from cart_yaoex import YAOEX_ADD_URL, add_yaoex_cart from cart_yaoex import YAOEX_ADD_URL, add_yaoex_cart
@ -53,8 +55,8 @@ def _offer(**overrides) -> CartOffer:
"extra", "extra",
{ {
"raw": { "raw": {
"skuId": "SKU66",
"spuCode": "SPU66", "spuCode": "SPU66",
"frontSellerCode": 8353,
"sellerCode": "GD01", "sellerCode": "GD01",
} }
}, },
@ -87,13 +89,34 @@ def test_yaoex_add_posts_token_and_qty():
outcome = add_yaoex_cart(FakeAuth(), _offer(), 7, session=session) outcome = add_yaoex_cart(FakeAuth(), _offer(), 7, session=session)
assert outcome.kind == KIND_OK assert outcome.kind == KIND_OK
assert session.calls[0]["url"] == YAOEX_ADD_URL assert session.calls[0]["url"] == YAOEX_ADD_URL
assert session.calls[0]["params"]["token"] == "tok"
payload = session.calls[0]["data"] payload = session.calls[0]["data"]
assert payload["token"] == "tok" assert payload["token"] == "tok"
assert payload["userToken"] == "tok" assert payload["userToken"] == "tok"
assert str(payload["amount"]) == "7" assert payload["addType"] == "1"
assert payload["skuId"] == "SKU66" assert payload["sourceType"] == 17052
assert payload["spuCode"] == "SPU66" items = json.loads(payload["itemList"])
assert payload["sellerCode"] == "GD01" assert items[0]["productNum"] == 7
assert items[0]["spuCode"] == "SPU66"
assert items[0]["supplyId"] == 8353
# 网关按 cartResponseList 回库存不足时记业务失败。
def test_yaoex_add_stock_message_from_cart_list():
session = FakeSession(
FakeResponse(
{
"rtn_code": "0",
"data": {
"statusCode": 1,
"cartResponseList": [{"message": "库存不足", "spuCode": "SPU66"}],
},
}
)
)
outcome = add_yaoex_cart(FakeAuth(), _offer(), 1, session=session)
assert outcome.kind == KIND_BUSINESS
assert outcome.reason == "库存不足"
def test_yaoex_add_classifies_pack_error(): def test_yaoex_add_classifies_pack_error():
@ -104,14 +127,32 @@ def test_yaoex_add_classifies_pack_error():
def test_ybm_add_posts_sku_and_qty(): def test_ybm_add_posts_sku_and_qty():
session = FakeSession(FakeResponse({"success": True, "msg": "成功"})) session = FakeSession(FakeResponse({"success": True, "code": 200, "msg": "操作成功"}))
offer = _offer(sku_id="SKU48", extra={"raw": {"skuId": "SKU48"}}) offer = _offer(sku_id="SKU48", extra={"raw": {"skuId": "SKU48"}})
outcome = add_ybm_cart({"SESSION": "c1"}, offer, 3, session=session) outcome = add_ybm_cart({"SESSION": "c1"}, offer, 3, session=session)
assert outcome.kind == KIND_OK assert outcome.kind == KIND_OK
assert session.calls[0]["url"] == YBM_ADD_URL assert session.calls[0]["url"] == YBM_ADD_URL
assert YBM_ADD_URL.endswith("/new-front/cart/change-cart")
body = session.calls[0]["json"] body = session.calls[0]["json"]
assert body["skuId"] == "SKU48" assert body["skuId"] == "SKU48"
assert body["skuNum"] == 3 assert body["amount"] == 3
assert body["direct"] == 1
# 现行口成功时 data.message 可能带中包装提示,仍按加车成功。
def test_ybm_success_keeps_pack_hint_as_ok():
session = FakeSession(
FakeResponse(
{
"success": True,
"code": 200,
"msg": "操作成功",
"data": {"message": "中包装验证:只能按照500的倍数购买", "qty": 500},
}
)
)
outcome = add_ybm_cart({}, _offer(extra={"raw": {"skuId": "X"}}), 5, session=session)
assert outcome.kind == KIND_OK
def test_ybm_http_error_is_system_fail(): def test_ybm_http_error_is_system_fail():

View File

@ -46,6 +46,12 @@ uv run pytest tests/test_job.py::test_job_merges_same_supplier_and_writes_table4
uv run python main.py --input-json examples/contract_create_input_dry_run.json uv run python main.py --input-json examples/contract_create_input_dry_run.json
``` ```
接口 3 形态 JSON(不领表,合单规则与班次相同;客商/货品/日期/金额都从报文来):
```bash
uv run python main.py --input-json examples/oms_purchase_contract.json
```
班次(领表 3): 班次(领表 3):
```bash ```bash

View File

@ -119,23 +119,23 @@ class ContractFlowContext:
customer_code: str = "" customer_code: str = ""
customer_name: str = "" customer_name: str = ""
contract_category: str = "" contract_category: str = ""
settlement_type: str = "付款" settlement_type: str = ""
contract_background: str = "" contract_background: str = ""
contract_nature: str = "主合同" contract_nature: str = ""
amount_currency: str = "人民币" amount_currency: str = ""
amount_type: str = "合同标的金额" amount_type: str = ""
amount: str = "" amount: str = ""
contract_title: str = "" contract_title: str = ""
contract_end_date: str = "" contract_end_date: str = ""
contract_sign_date: str = "" contract_sign_date: str = ""
contract_start_date: str = "" contract_start_date: str = ""
is_major_contract: str = "否" is_major_contract: str = ""
is_prepayment: str = "否" is_prepayment: str = ""
belong_year: str = "" belong_year: str = ""
payment_method: str = "账期支付" payment_method: str = ""
payment_days: str = "30" payment_days: str = ""
contract_status: str = "正式合同" contract_status: str = ""
contains_blood_products: str = "否" contains_blood_products: str = ""
submit_mode: str = "submit" submit_mode: str = "submit"
goods_lines: list[ContractGoodsLine] = field(default_factory=list) goods_lines: list[ContractGoodsLine] = field(default_factory=list)
merged_pdf_path: str | None = None merged_pdf_path: str | None = None

View File

@ -20,7 +20,9 @@ from contract_draft import (
) )
from contract_flow_steps import ContractFlowContext, run_contract_create_flow from contract_flow_steps import ContractFlowContext, run_contract_create_flow
from errors import NodeError, PdfMergeUnsupported, UnknownRpaError from errors import NodeError, PdfMergeUnsupported, UnknownRpaError
from group import drafts_from_oms_payload, is_oms_envelope
from pdf_utils import ensure_merged_pdf from pdf_utils import ensure_merged_pdf
from rpa_oracle import load_job_env
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/" CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
@ -30,13 +32,6 @@ class ContractCreateError(NodeError):
pass pass
# 返回本包 examples 下默认干跑 JSON 的路径。
def default_contract_input_path() -> str:
return str(
Path(__file__).resolve().parent / "examples" / "contract_create_input_dry_run.json"
)
# 一次合同起草的入参。账密为空时由 resolve_contract_credentials 补环境变量。 # 一次合同起草的入参。账密为空时由 resolve_contract_credentials 补环境变量。
@dataclass(frozen=True) @dataclass(frozen=True)
class ContractCreateInput: class ContractCreateInput:
@ -120,15 +115,49 @@ def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
# 把 JSON 字典收成 ContractCreateInput,并挂上页面上下文。 # 把 JSON 字典收成 ContractCreateInput,并挂上页面上下文。
#
# 顶层有 data 数组时按接口 3 信封合单;否则仍按页面草稿字段收。
def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput: def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
if is_oms_envelope(data):
return _build_spec_from_oms(data)
draft = contract_draft_from_dict(data) draft = contract_draft_from_dict(data)
_validate_data(data, draft) _validate_data(data, draft)
return _spec_from_draft(data, draft)
# 把接口 3 信封收成一次起草入参。命令行一次只录合出的第一组。
def _build_spec_from_oms(data: dict[str, Any]) -> ContractCreateInput:
drafts = drafts_from_oms_payload(data)
if not drafts:
raise ContractCreateError("接口 3 报文没有可录的合同行")
if len(drafts) > 1:
raise ContractCreateError(
f"接口 3 报文合出 {len(drafts)} 组,命令行一次只录一组"
)
draft = drafts[0]
_validate_oms_draft(draft)
if not _str(data.get("jobId")):
data = {
**data,
"jobId": f"OMS-{draft.customer_code}-{draft.order_date}",
}
if not _str(data.get("supplierName")):
data = {**data, "supplierName": draft.supplier_name}
if not _str(data.get("orderDate")):
data = {**data, "orderDate": draft.order_date}
if "sourceType" not in data:
data = {**data, "sourceType": draft.source_type}
return _spec_from_draft(data, draft)
# 按草稿和信封顶层字段组装入参,并挂上页面上下文。
def _spec_from_draft(data: dict[str, Any], draft: ContractDraft) -> ContractCreateInput:
spec = ContractCreateInput( spec = ContractCreateInput(
job_id=_require(data, "jobId"), job_id=_require(data, "jobId"),
supplier_name=draft.supplier_name or _require(data, "supplierName"), supplier_name=draft.supplier_name or _require(data, "supplierName"),
order_date=draft.order_date or _require(data, "orderDate"), order_date=draft.order_date or _require(data, "orderDate"),
source_type=draft.source_type or _require(data, "sourceType"), source_type=draft.source_type or _str(data.get("sourceType")),
contract_system_no=_opt(data.get("contractSystemNo")), contract_system_no=_opt(data.get("contractSystemNo")),
attempt_no=int(data.get("attemptNo", 1) or 1), attempt_no=int(data.get("attemptNo", 1) or 1),
contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL), contract_url=_clean(data.get("contractUrl")) or os.getenv("CONTRACT_URL", CONTRACT_URL),
@ -139,7 +168,6 @@ def build_spec_from_data(data: dict[str, Any]) -> ContractCreateInput:
template_name=draft.template_name, template_name=draft.template_name,
merged_pdf_output=_opt(data.get("mergedPdfOutput")) or os.getenv("MERGED_PDF_OUTPUT"), merged_pdf_output=_opt(data.get("mergedPdfOutput")) or os.getenv("MERGED_PDF_OUTPUT"),
) )
ctx = build_flow_context(spec, draft) ctx = build_flow_context(spec, draft)
return replace(spec, flow_context=ctx) return replace(spec, flow_context=ctx)
@ -256,6 +284,36 @@ def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
raise ContractCreateError("缺少必填字段:" + ", ".join(missing)) raise ContractCreateError("缺少必填字段:" + ", ".join(missing))
# 检查接口 3 合单后的草稿:客商 MDM、三日期、金额、货品行。
def _validate_oms_draft(draft: ContractDraft) -> None:
if draft.submit_mode not in ("dry_run", "submit"):
raise ContractCreateError(f"submitMode 非法:{draft.submit_mode}(仅支持 dry_run / submit)")
missing = []
if not _str(draft.supplier_name):
missing.append("supplierName")
if not _str(draft.customer_code):
missing.append("supplierMdmCode")
if not _str(draft.order_date):
missing.append("contractSignDate")
if not _str(draft.amount):
missing.append("amount")
else:
try:
float(draft.amount)
except (TypeError, ValueError):
raise ContractCreateError(f"amount 不是合法数字:{draft.amount}")
if missing:
raise ContractCreateError("缺少必填字段:" + ", ".join(missing))
if not draft.goods_lines:
raise ContractCreateError("接口 3 报文没有可录的货品行")
for index, line in enumerate(draft.goods_lines, start=1):
code = _str(line.mdm_code) or _str(line.goods_code)
if not code or not _str(line.goods_name) or line.quantity <= 0:
raise ContractCreateError(
f"data[{index}] 缺少 goodsMdmCode/goodsName,或 qty<=0"
)
# 取出必填字符串。缺或空白则失败。 # 取出必填字符串。缺或空白则失败。
def _require(data: dict[str, Any], key: str) -> str: def _require(data: dict[str, Any], key: str) -> str:
value = _str(data.get(key)) value = _str(data.get(key))
@ -285,16 +343,11 @@ def _clean(value: Any) -> str:
return value return value
# 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。 # 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。空入参不回退 examples。
def load_input_data(raw: str) -> dict[str, Any]: def load_input_data(raw: str) -> dict[str, Any]:
raw = (raw or "").strip() raw = (raw or "").strip()
if not raw: if not raw:
fallback = default_contract_input_path() raise ContractCreateError("缺少入参 JSON")
if Path(fallback).is_file():
print("未传入 -i/--input,使用默认联调入参:", fallback, flush=True)
raw = fallback
else:
return {}
try: try:
obj = json.loads(raw) obj = json.loads(raw)
if isinstance(obj, dict): if isinstance(obj, dict):
@ -333,6 +386,7 @@ def parse_args() -> argparse.Namespace:
# 命令行入口:JSON 或参数起草一份合同,打印 JSON 结果。 # 命令行入口:JSON 或参数起草一份合同,打印 JSON 结果。
def main() -> int: def main() -> int:
load_job_env(Path(__file__).resolve().parent)
args = parse_args() args = parse_args()
if args.input_json: if args.input_json:

View File

@ -21,23 +21,23 @@ class ContractDraft:
customer_code: str = "" customer_code: str = ""
customer_name: str = "" customer_name: str = ""
contract_category: str = "" contract_category: str = ""
settlement_type: str = "付款" settlement_type: str = ""
contract_background: str = "" contract_background: str = ""
contract_nature: str = "主合同" contract_nature: str = ""
amount_currency: str = "人民币" amount_currency: str = ""
amount_type: str = "合同标的金额" amount_type: str = ""
amount: str = "" amount: str = ""
contract_title: str = "" contract_title: str = ""
contract_end_date: str = "" contract_end_date: str = ""
contract_sign_date: str = "" contract_sign_date: str = ""
contract_start_date: str = "" contract_start_date: str = ""
is_major_contract: str = "否" is_major_contract: str = ""
is_prepayment: str = "否" is_prepayment: str = ""
belong_year: str = "" belong_year: str = ""
payment_method: str = "账期支付" payment_method: str = ""
payment_days: str = "30" payment_days: str = ""
contract_status: str = "正式合同" contract_status: str = ""
contains_blood_products: str = "否" contains_blood_products: str = ""
submit_mode: str = "submit" submit_mode: str = "submit"
goods_lines: list[ContractGoodsLine] = field(default_factory=list) goods_lines: list[ContractGoodsLine] = field(default_factory=list)
platform_pdf_paths: list[str] = field(default_factory=list) platform_pdf_paths: list[str] = field(default_factory=list)
@ -53,7 +53,7 @@ def load_contract_draft(path: str | Path) -> ContractDraft:
return contract_draft_from_dict(data) return contract_draft_from_dict(data)
# 把 JSON 字典收成起草草稿。缺的字段用页面上的默认值。 # 把 JSON 字典收成起草草稿。缺的字段留空,页面步骤有值才填。
def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft: def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
goods_lines = [ goods_lines = [
ContractGoodsLine( ContractGoodsLine(
@ -78,23 +78,23 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
customer_code=str(data.get("customerCode", "")), customer_code=str(data.get("customerCode", "")),
customer_name=str(data.get("customerName", "")), customer_name=str(data.get("customerName", "")),
contract_category=str(data.get("contractCategory", "")), contract_category=str(data.get("contractCategory", "")),
settlement_type=str(data.get("settlementType", "付款")), settlement_type=str(data.get("settlementType", "")),
contract_background=str(data.get("contractBackground", "")), contract_background=str(data.get("contractBackground", "")),
contract_nature=str(data.get("contractNature", "主合同")), contract_nature=str(data.get("contractNature", "")),
amount_currency=str(data.get("amountCurrency", "人民币")), amount_currency=str(data.get("amountCurrency", "")),
amount_type=str(data.get("amountType", "合同标的金额")), amount_type=str(data.get("amountType", "")),
amount=str(data.get("amount", "")), amount=str(data.get("amount", "")),
contract_title=str(data.get("contractTitle") or data.get("docSubject") or ""), contract_title=str(data.get("contractTitle") or data.get("docSubject") or ""),
contract_end_date=str(data.get("contractEndDate", "")), contract_end_date=str(data.get("contractEndDate", "")),
contract_sign_date=str(data.get("contractSignDate", "")), contract_sign_date=str(data.get("contractSignDate", "")),
contract_start_date=str(data.get("contractStartDate", "")), contract_start_date=str(data.get("contractStartDate", "")),
is_major_contract=str(data.get("isMajorContract", "否")), is_major_contract=str(data.get("isMajorContract", "")),
is_prepayment=str(data.get("isPrepayment", "否")), is_prepayment=str(data.get("isPrepayment", "")),
belong_year=str(data.get("belongYear", "")), belong_year=str(data.get("belongYear", "")),
payment_method=str(data.get("paymentMethod", "账期支付")), payment_method=str(data.get("paymentMethod", "")),
payment_days=str(data.get("paymentDays", "30")), payment_days=str(data.get("paymentDays", "")),
contract_status=str(data.get("contractStatus", "正式合同")), contract_status=str(data.get("contractStatus", "")),
contains_blood_products=str(data.get("containsBloodProducts", "否")), contains_blood_products=str(data.get("containsBloodProducts", "")),
submit_mode=str(data.get("submitMode", "submit")), submit_mode=str(data.get("submitMode", "submit")),
goods_lines=goods_lines, goods_lines=goods_lines,
platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])], platform_pdf_paths=[str(path) for path in data.get("platformPdfPaths", [])],

View File

@ -117,7 +117,7 @@ def fill_contract_basic_info(browser: BrowserAdapter, ctx: ContractFlowContext)
_select_settlement_type(browser, ctx.settlement_type) _select_settlement_type(browser, ctx.settlement_type)
if ctx.contract_background: if ctx.contract_background:
browser.fill_first([F["contract_background"]], ctx.contract_background) browser.fill_first([F["contract_background"]], ctx.contract_background)
# 合同性质默认主合同(页面已勾选),仅在需要从合同时切换 # 合同性质:入参是从合同才改点;空或主合同则不动页面已勾选项。
if ctx.contract_nature and ctx.contract_nature != "主合同": if ctx.contract_nature and ctx.contract_nature != "主合同":
browser.click_first(['input[name="fdType"][value="01"]']) browser.click_first(['input[name="fdType"][value="01"]'])
_fill_contract_title(browser, ctx) _fill_contract_title(browser, ctx)
@ -212,7 +212,7 @@ def check_customer_diligence(browser: BrowserAdapter, ctx: ContractFlowContext)
# 填金额与签订日、开始日、结束日(S08)。 # 填金额与签订日、开始日、结束日(S08)。
def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
amount_type = (ctx.amount_type or "合同标的金额").strip() amount_type = (ctx.amount_type or "").strip()
if amount_type: if amount_type:
browser.select_xform(X["amount_type"], amount_type) browser.select_xform(X["amount_type"], amount_type)
if ctx.amount: if ctx.amount:
@ -238,9 +238,11 @@ def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowCon
) )
# 填付款与标志(S10)。这期一律账期,不勾预付款。 # 填付款与标志(S10)。有值才填,不猜账期/预付。
def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None: def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
if ctx.is_major_contract:
browser.select_xform(X["is_major_contract"], _yes_no(ctx.is_major_contract)) browser.select_xform(X["is_major_contract"], _yes_no(ctx.is_major_contract))
if ctx.is_prepayment:
browser.select_xform(X["is_prepayment"], _yes_no(ctx.is_prepayment)) browser.select_xform(X["is_prepayment"], _yes_no(ctx.is_prepayment))
if ctx.belong_year: if ctx.belong_year:
browser.fill_first([F["belong_year"]], ctx.belong_year) browser.fill_first([F["belong_year"]], ctx.belong_year)

View File

@ -66,7 +66,7 @@ def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext)
[ [
code_pair, code_pair,
("qty", qty, line.quantity), ("qty", qty, line.quantity),
("price", price, line.unit_price or "1.00"), ("price", price, line.unit_price),
], ],
browser=browser, browser=browser,
screenshot_on_fail="contract-goods-pick-failed", screenshot_on_fail="contract-goods-pick-failed",
@ -561,14 +561,16 @@ def _clone_goods_identity(browser: BrowserAdapter, src_row: int, dest_row: int)
) )
# 往指定明细行填数量和单价。单价空则写 1.00。 # 往指定明细行填数量和单价。单价空则失败,不猜 1.00。
def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine, row: int) -> None: def _fill_goods_required_qty_price(browser: BrowserAdapter, line: ContractGoodsLine, row: int) -> None:
if row < 0: if row < 0:
raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」") raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」")
qty = str(line.quantity if line.quantity else "") qty = str(line.quantity if line.quantity else "")
if qty: if qty:
browser.fill_first([_goods_selector("goods_qty", row)], qty) browser.fill_first([_goods_selector("goods_qty", row)], qty)
price = (line.unit_price or "").strip() or "1.00" price = (line.unit_price or "").strip()
if not price:
raise NodeError(f"货品「{line.goods_code or line.goods_name}」缺少单价")
browser.fill_first([_goods_selector("goods_price", row)], price) browser.fill_first([_goods_selector("goods_price", row)], price)

View File

@ -0,0 +1,121 @@
{
"submitMode": "submit",
"data": [
{
"fid": 300001,
"omsId": 20261025000001,
"supplierId": "SUP001",
"supplierName": "重庆亿昊药业有限公司",
"contractSignDate": "2026-10-25",
"contractStartDate": "2026-10-25",
"contractEndDate": "2027-10-24",
"paymentTerm": 30,
"goodsId": "G000001",
"goodsName": "模拟货品一",
"regNo": "国药准字H20261001",
"spec": "10mg*10袋",
"unit": "盒",
"qty": "10.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "10.0000",
"goodsSourceId": "GS001",
"purchaserId": "P001",
"purchaserName": "张三",
"plannerId": "PL001",
"plannerName": "李四",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "901",
"customerType": 1,
"goodsMdmCode": "FQX0100020661",
"supplierMdmCode": "DW0100046361"
},
{
"fid": 300002,
"omsId": 20261025000002,
"supplierId": "SUP001",
"supplierName": "重庆亿昊药业有限公司",
"contractSignDate": "2026-10-25",
"contractStartDate": "2026-10-25",
"contractEndDate": "2027-10-24",
"paymentTerm": 30,
"goodsId": "G000002",
"goodsName": "模拟货品二",
"regNo": "国药准字H20261002",
"spec": "20mg*10袋",
"unit": "盒",
"qty": "10.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "10.0000",
"goodsSourceId": "GS002",
"purchaserId": "P001",
"purchaserName": "张三",
"plannerId": "PL001",
"plannerName": "李四",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "901",
"customerType": 1,
"goodsMdmCode": "FQX0100031787",
"supplierMdmCode": "DW0100046361"
},
{
"fid": 300003,
"omsId": 20261025000003,
"supplierId": "SUP001",
"supplierName": "重庆亿昊药业有限公司",
"contractSignDate": "2026-10-25",
"contractStartDate": "2026-10-25",
"contractEndDate": "2027-10-24",
"paymentTerm": 30,
"goodsId": "G000003",
"goodsName": "模拟货品三",
"regNo": "国药准字H20261003",
"spec": "30mg*10袋",
"unit": "盒",
"qty": "10.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "10.0000",
"goodsSourceId": "GS003",
"purchaserId": "P001",
"purchaserName": "张三",
"plannerId": "PL001",
"plannerName": "李四",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "901",
"customerType": 1,
"goodsMdmCode": "FQX0100044037",
"supplierMdmCode": "DW0100046361"
},
{
"fid": 300004,
"omsId": 20261025000004,
"supplierId": "SUP001",
"supplierName": "重庆亿昊药业有限公司",
"contractSignDate": "2026-10-25",
"contractStartDate": "2026-10-25",
"contractEndDate": "2027-10-24",
"paymentTerm": 30,
"goodsId": "G000004",
"goodsName": "只录协议货品",
"regNo": "国药准字H20261004",
"spec": "1mg",
"unit": "盒",
"qty": "1.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "1.0000",
"goodsSourceId": "GS004",
"purchaserId": "P001",
"purchaserName": "张三",
"plannerId": "PL001",
"plannerName": "李四",
"contractEntryStatus": 0,
"contractNo": "HT20261025004",
"orgCode": "901",
"customerType": 1,
"goodsMdmCode": "FQX0100118047",
"supplierMdmCode": "DW0100046361"
}
]
}

View File

@ -3,8 +3,10 @@
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
from dataclasses import replace
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from typing import Any
from contract_draft import ContractDraft from contract_draft import ContractDraft
from contract_flow_steps import ContractGoodsLine from contract_flow_steps import ContractGoodsLine
@ -109,15 +111,121 @@ def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
source_type="", source_type="",
customer_code=(first.supplier_mdm_code or "").strip(), customer_code=(first.supplier_mdm_code or "").strip(),
customer_name=first.supplier_name, customer_name=first.supplier_name,
contract_category="采购协议",
amount=money4(total), amount=money4(total),
contract_sign_date=sign, contract_sign_date=sign,
contract_start_date=iso_date(first.contract_start_date), contract_start_date=iso_date(first.contract_start_date),
contract_end_date=iso_date(first.contract_end_date), contract_end_date=iso_date(first.contract_end_date),
is_prepayment="否", belong_year=sign[:4] if sign else "",
belong_year=sign[:4],
payment_method="账期支付",
payment_days=str(first.payment_term), payment_days=str(first.payment_term),
goods_lines=goods, goods_lines=goods,
lookup_only=any(LOOKUP_ONLY_RESULT in (row.process_result or "") for row in rows), lookup_only=any(LOOKUP_ONLY_RESULT in (row.process_result or "") for row in rows),
) )
# 判断是不是订单中心接口 3 信封:顶层有 data 数组。
def is_oms_envelope(data: object) -> bool:
if not isinstance(data, dict) or "data" not in data:
return False
items = data.get("data")
if not isinstance(items, list):
return False
if not items:
return True
first = items[0]
return isinstance(first, dict) and (
"omsId" in first or "supplierMdmCode" in first or "goodsMdmCode" in first
)
# 把接口 3 一行收成表 3 内存行。不落库,给合单和页面草稿用。
def row_from_oms_item(item: dict[str, Any]) -> PurchaseContractRow:
entry = item.get("contractEntryStatus")
if entry in (None, ""):
entry_status = None
else:
entry_status = int(entry)
contract_no = item.get("contractNo")
if contract_no is None or str(contract_no).strip() == "":
contract_no = None
else:
contract_no = str(contract_no).strip()
return PurchaseContractRow(
fid=_oms_int(item.get("fid")),
oms_id=_oms_int(item.get("omsId")),
supplier_id=_oms_str(item.get("supplierId")),
supplier_name=_oms_str(item.get("supplierName")),
contract_sign_date=_oms_date(item.get("contractSignDate")),
contract_start_date=_oms_date(item.get("contractStartDate")),
contract_end_date=_oms_date(item.get("contractEndDate")),
payment_term=_oms_int(item.get("paymentTerm")),
goods_id=_oms_str(item.get("goodsId")),
goods_name=_oms_str(item.get("goodsName")),
reg_no=_oms_str(item.get("regNo")),
spec=_oms_str(item.get("spec")),
unit=_oms_str(item.get("unit")),
qty=_oms_decimal(item.get("qty")),
unit_price=_oms_decimal(item.get("unitPrice")),
purchaser_id=_oms_str(item.get("purchaserId")),
purchaser_name=_oms_str(item.get("purchaserName")),
planner_id=_oms_str(item.get("plannerId")),
planner_name=_oms_str(item.get("plannerName")),
contract_entry_status=entry_status,
contract_no=contract_no,
process_status=0,
org_code=_oms_str(item.get("orgCode")) or None,
goods_mdm_code=_oms_str(item.get("goodsMdmCode")) or None,
supplier_mdm_code=_oms_str(item.get("supplierMdmCode")) or None,
)
# 把接口 3 信封收成页面草稿。协议重录行跳过,合单规则与班次相同。
#
# 顶层 submitMode 会写进每份草稿;缺则仍是起草默认的 submit。
def drafts_from_oms_payload(payload: dict[str, Any]) -> list[ContractDraft]:
items = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(items, list):
return []
rows: list[PurchaseContractRow] = []
for item in items:
if not isinstance(item, dict):
continue
row = row_from_oms_item(item)
if is_claimable(row):
rows.append(row)
drafts = [draft_from_group(group) for group in group_by_supplier(rows)]
mode = _oms_str(payload.get("submitMode")) if isinstance(payload, dict) else ""
if mode:
drafts = [replace(draft, submit_mode=mode) for draft in drafts]
return drafts
# 把接口 3 字段收成整数。空当 0。
def _oms_int(value: Any) -> int:
if value in (None, ""):
return 0
return int(value)
# 把接口 3 字段收成去两端空白的字符串。None 当空。
def _oms_str(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
# 把接口 3 日期收成 date。空当 None。
def _oms_date(value: Any) -> date | None:
if value in (None, ""):
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return date.fromisoformat(str(value)[:10])
# 把接口 3 金额/数量收成 Decimal。空当 0。
def _oms_decimal(value: Any) -> Decimal:
if value in (None, ""):
return Decimal("0")
return Decimal(str(value))

View File

@ -0,0 +1,111 @@
{
"data": [
{
"fid": 410001,
"omsId": 202603150001,
"supplierId": "SUP-A",
"supplierName": "江苏康源药业有限公司",
"contractSignDate": "2026-03-15",
"contractStartDate": "2026-03-16",
"contractEndDate": "2027-03-15",
"paymentTerm": 45,
"goodsId": "G410001",
"goodsName": "头孢克肟胶囊",
"regNo": "国药准字H20260315",
"spec": "0.1g*12粒",
"unit": "盒",
"qty": "6.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "18.5000",
"purchaserId": "P4101",
"purchaserName": "王采购",
"plannerId": "PL4101",
"plannerName": "赵计划",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "801",
"goodsMdmCode": "QX0100888001",
"supplierMdmCode": "DW0100888001"
},
{
"fid": 410002,
"omsId": 202603150002,
"supplierId": "SUP-A",
"supplierName": "江苏康源药业有限公司",
"contractSignDate": "2026-03-15",
"contractStartDate": "2026-03-16",
"contractEndDate": "2027-03-15",
"paymentTerm": 45,
"goodsId": "G410002",
"goodsName": "阿奇霉素片",
"regNo": "国药准字H20260316",
"spec": "0.25g*6片",
"unit": "盒",
"qty": "4.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "22.0000",
"purchaserId": "P4101",
"purchaserName": "王采购",
"plannerId": "PL4101",
"plannerName": "赵计划",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "801",
"goodsMdmCode": "QX0100888002",
"supplierMdmCode": "DW0100888001"
},
{
"fid": 410003,
"omsId": 202604010001,
"supplierId": "SUP-B",
"supplierName": "杭州益民医药有限公司",
"contractSignDate": "2026-04-01",
"contractStartDate": "2026-04-02",
"contractEndDate": "2027-04-01",
"paymentTerm": 60,
"goodsId": "G410003",
"goodsName": "布洛芬缓释胶囊",
"regNo": "国药准字H20260401",
"spec": "0.3g*20粒",
"unit": "盒",
"qty": "10.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "9.8000",
"purchaserId": "P4102",
"purchaserName": "钱采购",
"plannerId": "PL4102",
"plannerName": "孙计划",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "802",
"goodsMdmCode": "QX0100777003",
"supplierMdmCode": "DW0100777002"
},
{
"fid": 410004,
"omsId": 202603150099,
"supplierId": "SUP-A",
"supplierName": "江苏康源药业有限公司",
"contractSignDate": "2026-03-15",
"contractStartDate": "2026-03-16",
"contractEndDate": "2027-03-15",
"paymentTerm": 45,
"goodsId": "G410099",
"goodsName": "只录协议跳过行",
"regNo": "国药准字H20260399",
"spec": "1g*1袋",
"unit": "袋",
"qty": "1.0000",
"unitPrice": "1.0000",
"purchaserId": "P4101",
"purchaserName": "王采购",
"plannerId": "PL4101",
"plannerName": "赵计划",
"contractEntryStatus": 0,
"contractNo": "HT-OLD-410004",
"orgCode": "801",
"goodsMdmCode": "QX0100888099",
"supplierMdmCode": "DW0100888001"
}
]
}

View File

@ -1,15 +1,24 @@
import json
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from pathlib import Path
from group import ( from group import (
draft_from_group, draft_from_group,
drafts_from_oms_payload,
group_by_supplier, group_by_supplier,
is_claimable, is_claimable,
is_oms_envelope,
missing_mdm_reason, missing_mdm_reason,
money4, money4,
row_from_oms_item,
) )
from repo import PurchaseContractRow from repo import PurchaseContractRow
_OMS_EXAMPLE = (
Path(__file__).resolve().parent.parent / "examples" / "oms_purchase_contract.json"
)
def _row(**overrides) -> PurchaseContractRow: def _row(**overrides) -> PurchaseContractRow:
data = dict( data = dict(
@ -77,7 +86,10 @@ def test_group_by_supplier_keeps_fid_order_and_first_header():
assert draft.contract_end_date == "2027-09-30" assert draft.contract_end_date == "2027-09-30"
assert draft.amount == "2000.0000" assert draft.amount == "2000.0000"
assert draft.source_type == "" assert draft.source_type == ""
assert draft.is_prepayment == "否" assert draft.contract_category == ""
assert draft.settlement_type == ""
assert draft.is_prepayment == ""
assert draft.payment_method == ""
assert len(draft.goods_lines) == 2 assert len(draft.goods_lines) == 2
assert draft.goods_lines[0].goods_code == "QX0100167583" assert draft.goods_lines[0].goods_code == "QX0100167583"
assert draft.goods_lines[0].operation_code == "QX0100167583" assert draft.goods_lines[0].operation_code == "QX0100167583"
@ -118,3 +130,68 @@ def test_missing_mdm_reason():
def test_money4(): def test_money4():
assert money4(Decimal("10.5")) == "10.5000" assert money4(Decimal("10.5")) == "10.5000"
def test_oms_envelope_skips_protocol_and_merges_same_supplier():
payload = json.loads(_OMS_EXAMPLE.read_text(encoding="utf-8"))
assert is_oms_envelope(payload) is True
drafts = drafts_from_oms_payload(payload)
assert len(drafts) == 1
draft = drafts[0]
assert draft.customer_code == "DW0100046361"
assert draft.customer_name == "重庆亿昊药业有限公司"
assert draft.order_date == "2026-10-25"
assert draft.contract_sign_date == "2026-10-25"
assert draft.contract_start_date == "2026-10-25"
assert draft.contract_end_date == "2027-10-24"
assert draft.amount == "300.0000"
assert draft.payment_days == "30"
assert draft.belong_year == "2026"
assert draft.submit_mode == "submit"
assert [line.mdm_code for line in draft.goods_lines] == [
"FQX0100020661",
"FQX0100031787",
"FQX0100044037",
]
assert [line.quantity for line in draft.goods_lines] == [10.0, 10.0, 10.0]
def test_oms_item_maps_mdm_and_dates():
row = row_from_oms_item(
{
"fid": 9,
"omsId": 2026102509,
"supplierId": "SUP001",
"supplierName": "重庆亿昊药业有限公司",
"contractSignDate": "2026-10-25",
"contractStartDate": "2026-10-26",
"contractEndDate": "2027-10-24",
"paymentTerm": 90,
"goodsId": "G1",
"goodsName": "阿莫西林胶囊",
"regNo": "H1",
"spec": "0.25g",
"unit": "盒",
"qty": "2.0000",
"unitPrice": "12.5000",
"purchaserId": "P1",
"purchaserName": "张三",
"plannerId": "PL1",
"plannerName": "李四",
"orgCode": "901",
"goodsMdmCode": "FQX0100020661",
"supplierMdmCode": "DW0100046361",
}
)
assert row.fid == 9
assert row.oms_id == 2026102509
assert row.goods_mdm_code == "FQX0100020661"
assert row.supplier_mdm_code == "DW0100046361"
assert row.contract_start_date == date(2026, 10, 26)
assert row.qty == Decimal("2.0000")
assert is_claimable(row) is True
def test_page_draft_json_is_not_oms_envelope():
assert is_oms_envelope({"jobId": "x", "goodsLines": []}) is False
assert is_oms_envelope({"data": []}) is True

View File

@ -1,10 +1,13 @@
# 合同班次:领取戳、合单、尽调失败、系统故障短码。 # 合同班次:领取戳、合单、尽调失败、系统故障短码。
import json
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from pathlib import Path
from errors import DiligenceError, NodeError from errors import DiligenceError, NodeError
from group import row_from_oms_item
from job import PAGE_FAILED, outcome_from_run, run_batch from job import PAGE_FAILED, outcome_from_run, run_batch
from persist import ( from persist import (
KIND_DILIGENCE, KIND_DILIGENCE,
@ -22,6 +25,8 @@ from run_batch import (
production_runner, production_runner,
) )
_TABLE3_SEED = Path(__file__).resolve().parent / "fixtures" / "table3_seed.json"
# 构造一条待录合同的表 3 行。 # 构造一条待录合同的表 3 行。
def _row(**overrides) -> PurchaseContractRow: def _row(**overrides) -> PurchaseContractRow:
@ -135,6 +140,65 @@ def test_save_group_inserts_one_table4_row_per_oms_id():
assert repo.table3[3].process_status == 1 assert repo.table3[3].process_status == 1
# 从表 3 种子造数:两家供应商合两单,协议重录行不领。
def _rows_from_table3_seed() -> list[PurchaseContractRow]:
payload = json.loads(_TABLE3_SEED.read_text(encoding="utf-8"))
return [row_from_oms_item(item) for item in payload["data"]]
# 班次草稿客商、货品、账期、金额都来自表 3 种子,不猜付款方式和类别。
def test_job_fills_draft_from_table3_seed():
repo = MemoryContractRepo(_rows_from_table3_seed())
calls: list = []
def runner(draft):
calls.append(draft)
who = draft.customer_code
return ContractOutcome(KIND_OK, f"HT-{who}", None)
run_batch(repo, runner=runner)
assert [draft.customer_code for draft in calls] == [
"DW0100888001",
"DW0100777002",
]
first, second = calls
assert first.customer_name == "江苏康源药业有限公司"
assert first.payment_days == "45"
assert first.contract_sign_date == "2026-03-15"
assert first.contract_start_date == "2026-03-16"
assert first.contract_end_date == "2027-03-15"
assert first.amount == "199.0000"
assert [line.mdm_code for line in first.goods_lines] == [
"QX0100888001",
"QX0100888002",
]
assert [line.unit_price for line in first.goods_lines] == ["18.5000", "22.0000"]
assert [line.quantity for line in first.goods_lines] == [6.0, 4.0]
assert first.contract_category == ""
assert first.settlement_type == ""
assert first.payment_method == ""
assert first.is_prepayment == ""
assert second.customer_name == "杭州益民医药有限公司"
assert second.payment_days == "60"
assert second.amount == "98.0000"
assert [line.mdm_code for line in second.goods_lines] == ["QX0100777003"]
assert [line.unit_price for line in second.goods_lines] == ["9.8000"]
assert [row["oms_id"] for row in repo.table4] == [
202603150001,
202603150002,
202604010001,
]
assert {row["contract_no"] for row in repo.table4} == {
"HT-DW0100888001",
"HT-DW0100777002",
}
assert repo.table3[410001].process_status == 1
assert repo.table3[410002].process_status == 1
assert repo.table3[410003].process_status == 1
assert repo.table3[410004].process_status == 0
assert repo.table3[410004].contract_no == "HT-OLD-410004"
# 同一供应商合单一次录入,协议重录行不进本班。 # 同一供应商合单一次录入,协议重录行不进本班。
def test_job_merges_same_supplier_and_writes_table4(): def test_job_merges_same_supplier_and_writes_table4():
repo = MemoryContractRepo( repo = MemoryContractRepo(

View File

@ -0,0 +1,90 @@
# 命令行吃接口 3 信封:合单后的客商、货品、金额都来自报文。
from __future__ import annotations
import json
from pathlib import Path
import pytest
from contract_create_node import ContractCreateError, build_spec_from_data, load_input_data
from contract_draft import contract_draft_from_dict
_OMS_EXAMPLE = (
Path(__file__).resolve().parent.parent / "examples" / "oms_purchase_contract.json"
)
_PAGE_DRAFT = (
Path(__file__).resolve().parent.parent
/ "examples"
/ "contract_create_input_dry_run.json"
)
def test_build_spec_from_oms_envelope_uses_json_fields(monkeypatch):
monkeypatch.setenv("CONTRACT_USERNAME", "u")
monkeypatch.setenv("CONTRACT_PASSWORD", "p")
data = json.loads(_OMS_EXAMPLE.read_text(encoding="utf-8"))
spec = build_spec_from_data(data)
ctx = spec.flow_context
assert ctx is not None
assert spec.job_id == "OMS-DW0100046361-2026-10-25"
assert spec.supplier_name == "重庆亿昊药业有限公司"
assert spec.order_date == "2026-10-25"
assert spec.submit_mode == "submit"
assert ctx.customer_code == "DW0100046361"
assert ctx.customer_name == "重庆亿昊药业有限公司"
assert ctx.amount == "300.0000"
assert ctx.contract_sign_date == "2026-10-25"
assert ctx.contract_start_date == "2026-10-25"
assert ctx.contract_end_date == "2027-10-24"
assert ctx.payment_days == "30"
assert ctx.contract_category == ""
assert ctx.settlement_type == ""
assert ctx.payment_method == ""
assert ctx.is_prepayment == ""
assert [line.mdm_code for line in ctx.goods_lines] == [
"FQX0100020661",
"FQX0100031787",
"FQX0100044037",
]
assert [line.unit_price for line in ctx.goods_lines] == [
"10.0000",
"10.0000",
"10.0000",
]
def test_page_draft_json_still_builds(monkeypatch):
monkeypatch.setenv("CONTRACT_USERNAME", "u")
monkeypatch.setenv("CONTRACT_PASSWORD", "p")
data = json.loads(_PAGE_DRAFT.read_text(encoding="utf-8"))
spec = build_spec_from_data(data)
assert spec.submit_mode == "dry_run"
assert spec.flow_context is not None
assert spec.flow_context.customer_code == "DW0100046361"
assert spec.flow_context.goods_lines[0].goods_code == "FQX0100118047"
# JSON 没写的页面字段保持空,不回填账期/采购协议等默认值。
def test_draft_json_omitted_page_fields_stay_empty():
draft = contract_draft_from_dict(
{
"supplierName": "示例供应商",
"orderDate": "2026-10-01",
"sourceType": "YYC",
}
)
assert draft.contract_category == ""
assert draft.settlement_type == ""
assert draft.contract_nature == ""
assert draft.amount_type == ""
assert draft.is_prepayment == ""
assert draft.payment_method == ""
assert draft.payment_days == ""
assert draft.contract_status == ""
# 空入参直接失败,不回退 examples 里的测试客商。
def test_load_input_data_rejects_empty():
with pytest.raises(ContractCreateError, match="缺少入参 JSON"):
load_input_data("")

View File

@ -7,3 +7,8 @@ RPA_ORACLE_USER=rpainit
RPA_ORACLE_PASSWORD= RPA_ORACLE_PASSWORD=
RPA_ORACLE_THICK=1 RPA_ORACLE_THICK=1
RPA_ORACLE_CLIENT_LIB=C:\oracle\instantclient RPA_ORACLE_CLIENT_LIB=C:\oracle\instantclient
OMS_AUTH_TOKEN_URL=https://gzmpc-paas-restcloud-test.gzmpc.com/gw/rest/core/auth/token
OMS_AUTH_USER=rpa
OMS_AUTH_PASSWORD=
OMS_CART_SETTLE_URL=https://gzmpc-paas-restcloud-test.gzmpc.com/gw/api-order-center/v1/yxdc/cartSettleResult
OMS_CONTRACT_AGREEMENT_URL=https://gzmpc-paas-restcloud-test.gzmpc.com/gw/api-order-center/v1/yxdc/contractAgreementResult

View File

@ -0,0 +1,120 @@
{
"data": [
{
"fid": 210001,
"omsId": 2026100201,
"supplierId": "DW0100046361",
"supplierName": "重庆亿昊药业有限公司",
"contractSignDate": "2026-10-02",
"contractStartDate": "2026-10-02",
"contractEndDate": "2027-10-01",
"paymentTerm": 90,
"goodsId": "G2026100201",
"goodsName": "珍棒 阿莫西林胶囊",
"regNo": "联调按条形码检索",
"spec": "0.25g*10s*2板",
"unit": "盒",
"qty": "10.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "1.7300",
"goodsSourceId": "T2-4",
"purchaserId": "P001",
"purchaserName": "张三",
"plannerId": "PL001",
"plannerName": "李四",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "901",
"customerType": 1,
"goodsMdmCode": "QX0100167583",
"supplierMdmCode": "DW0100046361"
},
{
"fid": 210002,
"omsId": 2026100204,
"supplierId": "DW0100046361",
"supplierName": "重庆亿昊药业有限公司",
"contractSignDate": "2026-10-02",
"contractStartDate": "2026-10-02",
"contractEndDate": "2027-10-01",
"paymentTerm": 90,
"goodsId": "G2026100204",
"goodsName": "石药 阿莫西林胶囊",
"regNo": "联调按条形码检索",
"spec": "0.5g*12s*2板",
"unit": "盒",
"qty": "46.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "4.8600",
"goodsSourceId": "T2-7",
"purchaserId": "P001",
"purchaserName": "张三",
"plannerId": "PL001",
"plannerName": "李四",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "901",
"customerType": 1,
"goodsMdmCode": "QX0100167583",
"supplierMdmCode": "DW0100046361"
},
{
"fid": 210003,
"omsId": 2026100204,
"supplierId": "DW0100046361",
"supplierName": "重庆亿昊药业有限公司",
"contractSignDate": "2026-10-02",
"contractStartDate": "2026-10-02",
"contractEndDate": "2027-10-01",
"paymentTerm": 90,
"goodsId": "G2026100204",
"goodsName": "石药 阿莫西林胶囊",
"regNo": "联调按条形码检索",
"spec": "0.5g*12s*2板",
"unit": "盒",
"qty": "154.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "4.9500",
"goodsSourceId": "T2-8",
"purchaserId": "P001",
"purchaserName": "张三",
"plannerId": "PL001",
"plannerName": "李四",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "901",
"customerType": 1,
"goodsMdmCode": "QX0100167583",
"supplierMdmCode": "DW0100046361"
},
{
"fid": 210004,
"omsId": 2026100205,
"supplierId": "DW0100046361",
"supplierName": "重庆亿昊药业有限公司",
"contractSignDate": "2026-10-02",
"contractStartDate": "2026-10-02",
"contractEndDate": "2027-10-01",
"paymentTerm": 90,
"goodsId": "G2026100205",
"goodsName": "舍尔 头孢克肟分散片",
"regNo": "联调按条形码检索",
"spec": "0.1g*6s*2板",
"unit": "盒",
"qty": "5.0000",
"purchaseTaxRate": "0.13",
"unitPrice": "6.1800",
"goodsSourceId": "T2-9",
"purchaserId": "P001",
"purchaserName": "张三",
"plannerId": "PL001",
"plannerName": "李四",
"contractEntryStatus": null,
"contractNo": null,
"orgCode": "901",
"customerType": 1,
"goodsMdmCode": "QX0100167583",
"supplierMdmCode": "DW0100046361"
}
]
}

View File

@ -1,6 +1,6 @@
# rpa_rebate # rpa_rebate
返利班。领表 4 起草行、表 3 只录协议行,按合同号查返利中心。搜到后同一班走总单 → 细单新增 → 送审;送审成功才写表 4。接口 4 组包在 `rpa_oms`,本班不推、不改 `push_status`。细单不做 Excel 导入。现行字段见 `现行模式.md`、`返利流程.md`。 返利班。领表 4 起草行、表 3 只录协议行,按合同号查返利中心。搜到后同一班走总单 → 细单新增 → 送审;送审成功才写表 4。接口 4 组包在 `rpa_oms`,本班不推、不改 `push_status`。细单不做 Excel 导入。现行字段见 `现行模式.md`、`返利流程.md`。写死项待确认见 `待确认字段.md`。
| | | | | |
| --- | --- | | --- | --- |
@ -42,7 +42,7 @@ cd rpa_rebate
uv run python run_header.py uv run python run_header.py
``` ```
验收看控制台 `ACCEPT SUMMARY REBATE_HEADER` 或 `screenshots/REBATE_HEADER/ACCEPT.txt`。需要 `REBATE_ACCEPT_CONTRACT_NO`。优先表 3:供应商 MDM、货品行。计划员「廖建琦」,联系人「苏文辉」,承付「下月N号结」,承付参数 `25`,结算「电汇」,协议开始/结束当天。卡片仍在「编辑」时点总单工具栏保存。 验收看控制台 `ACCEPT SUMMARY REBATE_HEADER` 或 `screenshots/REBATE_HEADER/ACCEPT.txt`。需要 `REBATE_ACCEPT_CONTRACT_NO`。优先表 3:供应商 MDM、计划员、`payment_term`、协议起止、货品行。联系人表 3 无列,可用 `REBATE_CONTACT_NAME`。承付「下月N号结」,结算「电汇」。卡片仍在「编辑」时点总单工具栏保存。
页面细单新增(复用查号已登录 Chrome,不送审、不写 `actionCode=2`、不连 Oracle,不做 Excel): 页面细单新增(复用查号已登录 Chrome,不送审、不写 `actionCode=2`、不连 Oracle,不做 Excel):
@ -51,7 +51,7 @@ cd rpa_rebate
uv run python run_import.py 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 码去搜。 验收看控制台 `ACCEPT SUMMARY REBATE_IMPORT` 或 `screenshots/REBATE_IMPORT/ACCEPT.txt`,须货品表有行。需要 `REBATE_ACCEPT_CONTRACT_NO`。表 3 有货品则按行新增;空缺再用 `REBATE_IMPORT_LINES` JSON(字段 `goodsId`、`goodsSourceId`、`qty`、`taxPrice`、`orderNo`)或 `REBATE_GOODS_ID` / `REBATE_SOURCE_LINE_ID` 等。货品编码三个点按货品ID搜,货源细单ID 按 `goods_source_id` 搜,空则整组停。细单备注=订单号,特定补偿=0,承付参数用表 3 账期。不要用操作码或 MDM 码去搜。
页面送审(复用查号已登录 Chrome,不写 `actionCode=2`、不连 Oracle、不推口 4): 页面送审(复用查号已登录 Chrome,不写 `actionCode=2`、不连 Oracle、不推口 4):
@ -125,4 +125,4 @@ uv run pytest tests/test_oracle_repo.py::test_from_env_without_user_raises -q
## 本期不做 ## 本期不做
货源细单ID、计划员改回订单中心、向订单中心发 HTTP、改 `push_status`。货源弹层按联系人「苏文辉」过滤,见 `返利流程.md`。页面步骤按节点拆在 `rebate_find.py` / `rebate_header.py` / `rebate_import.py` / `rebate_submit.py`,共用登录/查号/勾行/弹层在 `rebate_common.py`;`rebate_flow_steps.py` 仍是对外入口。 向订单中心发 HTTP、改 `push_status`。货源弹层按总单联系人过滤,见 `返利流程.md`。页面步骤按节点拆在 `rebate_find.py` / `rebate_header.py` / `rebate_import.py` / `rebate_submit.py`,共用登录/查号/勾行/弹层在 `rebate_common.py`;`rebate_flow_steps.py` 仍是对外入口。

View File

@ -166,7 +166,7 @@ class RebateFlowContext:
username: str username: str
password: str password: str
# 总单卡片要填的字段。计划员现阶段写死廖建琦,联系人苏文辉。 # 总单卡片要填的字段。计划员、日期、承付参数优先表 3,空再环境变量。
@dataclass(frozen=True) @dataclass(frozen=True)
class RebateHeaderDraft: class RebateHeaderDraft:
planner_name: str planner_name: str
@ -180,8 +180,9 @@ class RebateHeaderDraft:
contact_name: str = "" contact_name: str = ""
supplier_name: str = "" supplier_name: str = ""
supplier_id: str = "" supplier_id: str = ""
pay_param: str = ""
# 一条细单:货品ID、数量、含税单价、订单号。货源细单ID本期不填。 # 一条细单:货品ID、数量、含税单价、订单号、货源细单ID 来自表 3。
@dataclass(frozen=True) @dataclass(frozen=True)
class RebateImportLine: class RebateImportLine:
goods_code: str goods_code: str
@ -192,6 +193,9 @@ class RebateImportLine:
goods_id: str = "" goods_id: str = ""
tax_rate: str = "" tax_rate: str = ""
net_price: str = "" net_price: str = ""
pay_param: str = ""
protocol_start: str = ""
protocol_end: str = ""
# 一组细单加上当天日期,供新增节点一次加完。 # 一组细单加上当天日期,供新增节点一次加完。
@dataclass(frozen=True) @dataclass(frozen=True)
@ -867,6 +871,35 @@ def _cell(value: object) -> str:
return "" return ""
return str(value).strip() return str(value).strip()
# 日期收成 YYYY-MM-DD。表 3 可能是 datetime。
def _iso_date(value: object) -> str:
if value is None or value == "":
return ""
if hasattr(value, "strftime"):
try:
return str(value.strftime("%Y-%m-%d"))
except Exception:
return ""
text = _cell(value).replace("/", "-")
if len(text) >= 10 and text[4:5] == "-" and text[7:8] == "-":
return text[:10]
return text
# 承付参数 / 账期收成整数文本。90.0 写成 90。
def _digit_text(value: object) -> str:
text = _cell(value)
if not text:
return ""
try:
num = Decimal(text)
if num == num.to_integral_value():
return str(int(num))
return format(num.normalize(), "f").rstrip("0").rstrip(".")
except Exception:
return text
# 税率文本;空则用默认 0.13。 # 税率文本;空则用默认 0.13。
def _tax_rate_of(raw: object) -> str: def _tax_rate_of(raw: object) -> str:
text = _cell(raw) text = _cell(raw)
@ -1309,7 +1342,7 @@ def _close_overlay_named(browser: BrowserAdapter, title: str) -> None:
pass pass
time.sleep(0.3) time.sleep(0.3)
# 列出当前可见的选择弹层标题,排除货源选择 bug 层。 # 列出当前可见的选择弹层标题,排除货源选择 / 货源细单 bug 层。
def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]: def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]:
try: try:
result = browser.execute_script( result = browser.execute_script(
@ -1325,7 +1358,8 @@ def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]:
const cls = String(el.className || ''); const cls = String(el.className || '');
if (!vis(el) || cls.indexOf('is--visible') < 0) return false; if (!vis(el) || cls.indexOf('is--visible') < 0) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return title !== '货源选择'; if (title === '货源选择' || title.indexOf('货源细单') >= 0) return false;
return true;
}).map((el) => { }).map((el) => {
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title')); const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return (title || compact(el).slice(0, 40)); return (title || compact(el).slice(0, 40));
@ -1568,7 +1602,8 @@ def _click_keyword_in_overlays(browser: BrowserAdapter, keyword: str) -> bool:
for (const root of roots) { for (const root of roots) {
const title = compact(root.querySelector('.vxe-modal--header-title, .vxe-modal--title')); const title = compact(root.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
const t = compact(root); const t = compact(root);
if (title === '货源选择' || t.indexOf('货源选择') === 0) continue; if (title === '货源选择' || title.indexOf('货源细单') >= 0
|| t.indexOf('货源选择') === 0 || t.indexOf('货源细单') === 0) continue;
if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) continue; if (t.indexOf('请填写供应商') >= 0 && t.indexOf('采购协议号') >= 0) continue;
const row = Array.from(root.querySelectorAll( const row = Array.from(root.querySelectorAll(
'.vxe-body--row, tr, .elPlus-select-dropdown__item, li' '.vxe-body--row, tr, .elPlus-select-dropdown__item, li'
@ -1620,7 +1655,7 @@ def _pick_labeled_code(browser: BrowserAdapter, title: str, code: str) -> None:
if not _picker_bound(browser, title, code): if not _picker_bound(browser, title, code):
raise NodeError(f"未选到{title}={code},当前={_read_labeled_input(browser, title)}") raise NodeError(f"未选到{title}={code},当前={_read_labeled_input(browser, title)}")
# 是否出现误弹的货源选择层(不是计划员选人层)。 # 是否出现误弹的货源选择 / 货源细单ID 层(不是计划员选人层)。
def _source_choice_visible(browser: BrowserAdapter) -> bool: def _source_choice_visible(browser: BrowserAdapter) -> bool:
try: try:
hit = browser.execute_script( hit = browser.execute_script(
@ -1640,7 +1675,9 @@ def _source_choice_visible(browser: BrowserAdapter) -> bool:
'.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title'
)); ));
const t = compact(el).slice(0, 40); const t = compact(el).slice(0, 40);
if (title.indexOf('货源选择') < 0 && t.indexOf('货源选择') !== 0) return false; const bug = title.indexOf('货源选择') >= 0 || title.indexOf('货源细单') >= 0
|| t.indexOf('货源选择') === 0 || t.indexOf('货源细单') === 0;
if (!bug) return false;
const planner = Array.from(el.querySelectorAll('input')).some((inp) => { const planner = Array.from(el.querySelectorAll('input')).some((inp) => {
const p = String(inp.placeholder || ''); const p = String(inp.placeholder || '');
return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0; return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0;
@ -1654,7 +1691,7 @@ def _source_choice_visible(browser: BrowserAdapter) -> bool:
hit = False hit = False
return hit is True return hit is True
# 看得见的货源选择是 bug 弹层:点关闭,不要确定、不要 Escape。 # 看得见的货源选择 / 货源细单ID 是 bug 弹层:点关闭,不要确定、不要 Escape。
def _close_source_choice(browser: BrowserAdapter) -> bool: def _close_source_choice(browser: BrowserAdapter) -> bool:
try: try:
result = browser.execute_script( result = browser.execute_script(
@ -1674,7 +1711,8 @@ def _close_source_choice(browser: BrowserAdapter) -> bool:
'.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title' '.vxe-modal--header-title, .vxe-modal--header, .vxe-modal--title, .elPlus-dialog__title, .el-dialog__title'
)); ));
const t = compact(el).slice(0, 40); const t = compact(el).slice(0, 40);
return title.indexOf('货源选择') >= 0 || t.indexOf('货源选择') === 0; return title.indexOf('货源选择') >= 0 || title.indexOf('货源细单') >= 0
|| t.indexOf('货源选择') === 0 || t.indexOf('货源细单') === 0;
}); });
if (!hits.length) { return {ok: false, via: 'none-visible'}; } if (!hits.length) { return {ok: false, via: 'none-visible'}; }
let via = ''; let via = '';
@ -1699,14 +1737,14 @@ def _close_source_choice(browser: BrowserAdapter) -> bool:
return True return True
return False return False
# 全局忽略货源选择 bug 弹层。 # 全局忽略货源选择 / 货源细单ID bug 弹层。
def _ignore_source_choice(browser: BrowserAdapter) -> None: def _ignore_source_choice(browser: BrowserAdapter) -> None:
for _ in range(3): for _ in range(3):
if not _source_choice_visible(browser): if not _source_choice_visible(browser):
return return
_close_source_choice(browser) _close_source_choice(browser)
# 看见货源选择 bug 弹层就关掉,不把流程停掉。 # 看见货源选择 / 货源细单ID bug 弹层就关掉,不把流程停掉。
def _stop_if_source_choice(browser: BrowserAdapter) -> None: def _stop_if_source_choice(browser: BrowserAdapter) -> None:
_ignore_source_choice(browser) _ignore_source_choice(browser)
@ -1729,7 +1767,7 @@ def _pick_nth_overlay_row(browser: BrowserAdapter, nth: int, name: str = "") ->
)).filter((el) => { )).filter((el) => {
if (!vis(el)) return false; if (!vis(el)) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title') || {innerText:''}); const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title') || {innerText:''});
return title !== '货源选择'; return title !== '货源选择' && title.indexOf('货源细单') < 0;
}); });
const roots = overlays.length ? overlays : [document]; const roots = overlays.length ? overlays : [document];
for (const root of roots) { for (const root of roots) {
@ -1956,7 +1994,7 @@ def _query_person_and_pick(
# 点三个点打开选人层,按姓名搜并对上供应商名称。 # 点三个点打开选人层,按姓名搜并对上供应商名称。
# #
# 计划员选廖建琦,联系人选苏文辉。适配器默认关货源层时这里必须先 hold。选不上抛 NodeError。 # 点三个点打开选人层,按姓名搜并对上供应商名称。适配器默认关货源层时这里必须先 hold。选不上抛 NodeError。
def _pick_person( def _pick_person(
browser: BrowserAdapter, title: str, name: str, *, nth: int | None = None, supplier_name: str = "" browser: BrowserAdapter, title: str, name: str, *, nth: int | None = None, supplier_name: str = ""
) -> None: ) -> None:

View File

@ -184,6 +184,9 @@ from rebate_import import (
_open_goods_picker, _open_goods_picker,
_wait_goods_picker, _wait_goods_picker,
_pick_goods_id_in_dialog, _pick_goods_id_in_dialog,
_open_source_picker,
_wait_source_picker,
_pick_source_line_id,
_detail_card_pager, _detail_card_pager,
_goods_table_rows, _goods_table_rows,
_wait_goods_row, _wait_goods_row,

View File

@ -30,6 +30,8 @@ from rebate_common import (
_OPEN_HEADER_ROW, _OPEN_HEADER_ROW,
today_iso, today_iso,
_cell, _cell,
_iso_date,
_digit_text,
_click_toolbar, _click_toolbar,
_click_header_toolbar, _click_header_toolbar,
_native_click_header_toolbar, _native_click_header_toolbar,
@ -244,16 +246,22 @@ wrap.click();
return true; return true;
""" """
# 拼一份总单草稿。计划员空则廖建琦,协议起止都是当天,返利合约=否。 # 拼一份总单草稿。计划员、承付参数、日期优先入参,空再回退默认。
def build_header_draft( def build_header_draft(
*, *,
planner_name: str, planner_name: str,
supplier_code: str, supplier_code: str,
rebate_unit_code: str, rebate_unit_code: str,
today: str | None = None, today: str | None = None,
contact_name: str = "",
pay_param: str = "",
protocol_start: str | None = None,
protocol_end: str | None = None,
) -> RebateHeaderDraft: ) -> RebateHeaderDraft:
planner = (planner_name or "").strip() or PLANNER_DEFAULT planner = (planner_name or "").strip() or PLANNER_DEFAULT
day = today_iso(today) day = today_iso(today)
start = _iso_date(protocol_start) or day
end = _iso_date(protocol_end) or day
return RebateHeaderDraft( return RebateHeaderDraft(
planner_name=planner, planner_name=planner,
supplier_code=(supplier_code or "").strip(), supplier_code=(supplier_code or "").strip(),
@ -261,21 +269,26 @@ def build_header_draft(
rebate_contract=REBATE_CONTRACT_DEFAULT, rebate_contract=REBATE_CONTRACT_DEFAULT,
settle_mode=SETTLE_MODE_DEFAULT, settle_mode=SETTLE_MODE_DEFAULT,
pay_mode=PAY_MODE_NEXT_MONTH_N, pay_mode=PAY_MODE_NEXT_MONTH_N,
protocol_start=day, protocol_start=start,
protocol_end=day, protocol_end=end,
contact_name=CONTACT_DEFAULT, contact_name=(contact_name or "").strip() or CONTACT_DEFAULT,
pay_param=_digit_text(pay_param) or PAY_PARAM_DEFAULT,
) )
# 从环境变量读供应商编码和计划员,拼总单草稿。干跑缺表 3 时用。 # 从环境变量读供应商、计划员、承付参数和日期。干跑缺表 3 时用。
def header_draft_from_env(*, today: str | None = None) -> RebateHeaderDraft: def header_draft_from_env(*, today: str | None = None) -> RebateHeaderDraft:
return build_header_draft( return build_header_draft(
planner_name=os.getenv("REBATE_PLANNER_NAME") or "", planner_name=os.getenv("REBATE_PLANNER_NAME") or "",
supplier_code=os.getenv("REBATE_SUPPLIER_CODE") or "", supplier_code=os.getenv("REBATE_SUPPLIER_CODE") or "",
rebate_unit_code=os.getenv("REBATE_UNIT_CODE") or "", rebate_unit_code=os.getenv("REBATE_UNIT_CODE") or "",
contact_name=os.getenv("REBATE_CONTACT_NAME") or "",
pay_param=os.getenv("REBATE_PAY_PARAM") or "",
protocol_start=os.getenv("REBATE_PROTOCOL_START") or "",
protocol_end=os.getenv("REBATE_PROTOCOL_END") or "",
today=today, today=today,
) )
# 总单取表 3 该合同第一行的供应商 MDM。计划员先选廖建琦,联系人苏文辉。 # 总单只从表 3 该合同第一行取:供应商 MDM、计划员、账期、协议起止。缺列整组停。
def header_draft_from_table3( def header_draft_from_table3(
rows: list[dict] | tuple[dict, ...], rows: list[dict] | tuple[dict, ...],
*, *,
@ -287,14 +300,29 @@ def header_draft_from_table3(
first = packed[0] first = packed[0]
supplier = _cell(first.get("supplier_mdm_code") or first.get("supplier_id")) supplier = _cell(first.get("supplier_mdm_code") or first.get("supplier_id"))
unit = _cell(first.get("org_code")) unit = _cell(first.get("org_code"))
planner = _cell(first.get("planner_name"))
pay_param = _digit_text(first.get("payment_term"))
start = _iso_date(first.get("contract_start_date"))
end = _iso_date(first.get("contract_end_date"))
if not supplier:
raise NodeError("表 3 没有供应商 MDM,不能填总单")
if not planner:
raise NodeError("表 3 没有计划员,不能填总单")
if not pay_param:
raise NodeError("表 3 没有账期,不能填总单")
if not start or not end:
raise NodeError("表 3 没有协议日期,不能填总单")
draft = build_header_draft( draft = build_header_draft(
planner_name=PLANNER_DEFAULT, planner_name=planner,
supplier_code=supplier, supplier_code=supplier,
rebate_unit_code=unit, rebate_unit_code=unit,
pay_param=pay_param,
protocol_start=start,
protocol_end=end,
today=today, today=today,
) )
return RebateHeaderDraft( return RebateHeaderDraft(
planner_name=PLANNER_DEFAULT, planner_name=draft.planner_name,
supplier_code=draft.supplier_code, supplier_code=draft.supplier_code,
rebate_unit_code=draft.rebate_unit_code, rebate_unit_code=draft.rebate_unit_code,
rebate_contract=draft.rebate_contract, rebate_contract=draft.rebate_contract,
@ -302,24 +330,33 @@ def header_draft_from_table3(
pay_mode=draft.pay_mode, pay_mode=draft.pay_mode,
protocol_start=draft.protocol_start, protocol_start=draft.protocol_start,
protocol_end=draft.protocol_end, protocol_end=draft.protocol_end,
contact_name=CONTACT_DEFAULT, contact_name=draft.contact_name,
supplier_name=_cell(first.get("supplier_name")), supplier_name=_cell(first.get("supplier_name")),
supplier_id=_cell(first.get("supplier_id")), supplier_id=_cell(first.get("supplier_id")),
pay_param=draft.pay_param,
) )
# 优先表 3,空缺再退回环境变量。 # 班次必须读表 3。页面干跑 require_table=False 才允许环境变量。
def header_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateHeaderDraft: def header_draft_for_contract(
contract_no: str, *, today: str | None = None, require_table: bool = False
) -> RebateHeaderDraft:
number = (contract_no or "").strip() number = (contract_no or "").strip()
rows: list[dict] = [] rows: list[dict] = []
load_error = None
if number: if number:
try: try:
from repo import OracleRebateRepo from repo import OracleRebateRepo
rows = OracleRebateRepo.from_env().load_contract_goods(number) rows = OracleRebateRepo.from_env().load_contract_goods(number)
except Exception: except Exception as exc:
load_error = exc
rows = [] rows = []
if rows: if rows:
return header_draft_from_table3(rows, today=today) return header_draft_from_table3(rows, today=today)
if require_table:
if load_error is not None:
raise NodeError("表 3 不可用,不能填总单") from load_error
raise NodeError("表 3 没有该合同行,不能填总单")
return header_draft_from_env(today=today) return header_draft_from_env(today=today)
# 正文是否已出现总单表单标记。 # 正文是否已出现总单表单标记。
@ -774,7 +811,7 @@ def _fill_pay_param(browser: BrowserAdapter, title: str, value: str) -> None:
_set_grid_field(browser, title, wanted) _set_grid_field(browser, title, wanted)
print("pay_param_after=", _read_labeled_input(browser, title), flush=True) print("pay_param_after=", _read_labeled_input(browser, title), flush=True)
# 按现行字段填总单卡片:供应商三个点、计划员廖建琦、联系人苏文辉、电汇、下月N号结、承付参数 25、协议起止当天。 # 按草稿填总单卡片:供应商、计划员、联系人、电汇、下月N号结、承付参数、协议起止。
def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None: def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None:
_ignore_source_choice(browser) _ignore_source_choice(browser)
titles = _read_required_titles(browser) titles = _read_required_titles(browser)
@ -782,13 +819,14 @@ def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None
settle = draft.settle_mode if draft.settle_mode and draft.settle_mode != SETTLE_MODE_DEFAULT else SETTLE_MODE_WIRE settle = draft.settle_mode if draft.settle_mode and draft.settle_mode != SETTLE_MODE_DEFAULT else SETTLE_MODE_WIRE
planner = (draft.planner_name or "").strip() or PLANNER_DEFAULT planner = (draft.planner_name or "").strip() or PLANNER_DEFAULT
contact = (draft.contact_name or "").strip() or CONTACT_DEFAULT contact = (draft.contact_name or "").strip() or CONTACT_DEFAULT
pay_param = (draft.pay_param or "").strip() or PAY_PARAM_DEFAULT
values = { values = {
"供应商编码": draft.supplier_code, "供应商编码": draft.supplier_code,
"计划员": planner, "计划员": planner,
"返利合约": draft.rebate_contract, "返利合约": draft.rebate_contract,
"采购结算方式": settle, "采购结算方式": settle,
"承付模式": draft.pay_mode, "承付模式": draft.pay_mode,
"承付参数": PAY_PARAM_DEFAULT, "承付参数": pay_param,
"协议开始日期": draft.protocol_start, "协议开始日期": draft.protocol_start,
"协议结束日期": draft.protocol_end, "协议结束日期": draft.protocol_end,
"联系人": contact, "联系人": contact,
@ -853,7 +891,7 @@ def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None
_fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end) _fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end)
except Exception: except Exception:
_set_grid_field(browser, "协议结束日期", draft.protocol_end) _set_grid_field(browser, "协议结束日期", draft.protocol_end)
_fill_pay_param(browser, "承付参数", PAY_PARAM_DEFAULT) _fill_pay_param(browser, "承付参数", pay_param)
print( print(
"card_values_before_save=", "card_values_before_save=",
{ {
@ -863,8 +901,8 @@ def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None
}, },
flush=True, flush=True,
) )
if (_read_labeled_input(browser, "承付参数") or "").strip() != PAY_PARAM_DEFAULT: if (_read_labeled_input(browser, "承付参数") or "").strip() != pay_param:
raise NodeError("总单承付参数未填成25,不能保存") raise NodeError(f"总单承付参数未填成{pay_param},不能保存")
# 等到总单卡片打开。超时返回 False。 # 等到总单卡片打开。超时返回 False。
def _wait_header_card(browser: BrowserAdapter, timeout: float = 8, *, contract_no: str = "") -> bool: def _wait_header_card(browser: BrowserAdapter, timeout: float = 8, *, contract_no: str = "") -> bool:
@ -898,7 +936,7 @@ def fill_rebate_header(browser: BrowserAdapter, draft: RebateHeaderDraft, *, con
_set_grid_field(browser, "返利合约", draft.rebate_contract, as_select=True) _set_grid_field(browser, "返利合约", draft.rebate_contract, as_select=True)
if not _set_grid_field(browser, "采购结算方式", draft.settle_mode, as_select=True): if not _set_grid_field(browser, "采购结算方式", draft.settle_mode, as_select=True):
_set_grid_field(browser, "采购结算方式", SETTLE_MODE_WIRE, as_select=True) _set_grid_field(browser, "采购结算方式", SETTLE_MODE_WIRE, as_select=True)
_set_grid_field(browser, "承付参数", PAY_PARAM_DEFAULT) _set_grid_field(browser, "承付参数", draft.pay_param or PAY_PARAM_DEFAULT)
_fill_header_code(browser, "供应商编码", draft.supplier_code) _fill_header_code(browser, "供应商编码", draft.supplier_code)
_fill_header_code(browser, "计划员", draft.planner_name or PLANNER_DEFAULT) _fill_header_code(browser, "计划员", draft.planner_name or PLANNER_DEFAULT)
_fill_header_code(browser, "联系人", draft.contact_name or CONTACT_DEFAULT) _fill_header_code(browser, "联系人", draft.contact_name or CONTACT_DEFAULT)
@ -1032,13 +1070,14 @@ def run_header_flow(
) )
pay_before = _read_labeled_input(browser, "承付参数") pay_before = _read_labeled_input(browser, "承付参数")
planner_before = _read_labeled_input(browser, "计划员") planner_before = _read_labeled_input(browser, "计划员")
wanted_pay = (header.pay_param or "").strip() or PAY_PARAM_DEFAULT
print("before_save pay_param=", pay_before, "planner=", planner_before, flush=True) print("before_save pay_param=", pay_before, "planner=", planner_before, flush=True)
saved = save_rebate_header(browser, contract_no=contract_no) saved = save_rebate_header(browser, contract_no=contract_no)
_HEADER_ACCEPT.note( _HEADER_ACCEPT.note(
"S08", "S08",
"保存", "保存",
{"saved": "yes", "payParam": pay_before}, {"saved": "yes", "payParam": pay_before},
ok=(pay_before or "").strip() == PAY_PARAM_DEFAULT, ok=(pay_before or "").strip() == wanted_pay,
) )
_confirm_header_card(browser) _confirm_header_card(browser)
order_no = saved or existing or read_rebate_order_no(browser, contract_no=contract_no) order_no = saved or existing or read_rebate_order_no(browser, contract_no=contract_no)

View File

@ -35,6 +35,8 @@ from rebate_common import (
today_iso, today_iso,
year_end_iso, year_end_iso,
_cell, _cell,
_iso_date,
_digit_text,
_tax_rate_of, _tax_rate_of,
_untaxed_price, _untaxed_price,
_click_text, _click_text,
@ -44,11 +46,13 @@ from rebate_common import (
_read_detail_toast, _read_detail_toast,
_close_header_card, _close_header_card,
_close_detail_card, _close_detail_card,
_ignore_source_choice,
_hold_source_choice,
) )
_IMPORT_ACCEPT = AcceptanceReport(flow=IMPORT_FLOW) _IMPORT_ACCEPT = AcceptanceReport(flow=IMPORT_FLOW)
# 细单按表 3 货品行:货品ID、数量、含税单价、无税=含税/(1+税率)、备注=omsId。 # 细单按表 3 货品行:货品ID、数量、含税单价、货源细单ID、账期、协议日期。
def import_lines_from_table3(rows: list[dict] | tuple[dict, ...]) -> tuple[RebateImportLine, ...]: def import_lines_from_table3(rows: list[dict] | tuple[dict, ...]) -> tuple[RebateImportLine, ...]:
lines: list[RebateImportLine] = [] lines: list[RebateImportLine] = []
for row in rows: for row in rows:
@ -69,25 +73,36 @@ def import_lines_from_table3(rows: list[dict] | tuple[dict, ...]) -> tuple[Rebat
goods_id=goods_id, goods_id=goods_id,
tax_rate=tax_rate, tax_rate=tax_rate,
net_price=_untaxed_price(tax_price, tax_rate), net_price=_untaxed_price(tax_price, tax_rate),
pay_param=_digit_text(row.get("payment_term")),
protocol_start=_iso_date(row.get("contract_start_date")),
protocol_end=_iso_date(row.get("contract_end_date")),
) )
) )
if not lines: if not lines:
raise NodeError("表 3 没有货品行,不能加细单") raise NodeError("表 3 没有货品行,不能加细单")
return tuple(lines) return tuple(lines)
# 优先表 3 货品行,空缺再退回环境变量。 # 班次必须读表 3。页面干跑 require_table=False 才允许环境变量 / JSON。
def import_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateImportDraft: def import_draft_for_contract(
contract_no: str, *, today: str | None = None, require_table: bool = False
) -> RebateImportDraft:
number = (contract_no or "").strip() number = (contract_no or "").strip()
rows: list[dict] = [] rows: list[dict] = []
load_error = None
if number: if number:
try: try:
from repo import OracleRebateRepo from repo import OracleRebateRepo
rows = OracleRebateRepo.from_env().load_contract_goods(number) rows = OracleRebateRepo.from_env().load_contract_goods(number)
except Exception: except Exception as exc:
load_error = exc
rows = [] rows = []
if rows: if rows:
return build_import_draft(lines=import_lines_from_table3(rows), today=today) return build_import_draft(lines=import_lines_from_table3(rows), today=today)
if require_table:
if load_error is not None:
raise NodeError("表 3 不可用,不能加细单") from load_error
raise NodeError("表 3 没有货品行,不能加细单")
return import_draft_from_env(today=today) return import_draft_from_env(today=today)
IMPORT = { IMPORT = {
@ -208,7 +223,7 @@ def build_import_rows(
"税率": TAX_RATE_DEFAULT, "税率": TAX_RATE_DEFAULT,
"关税税率": TAX_RATE_DEFAULT, "关税税率": TAX_RATE_DEFAULT,
"承付模式": PAY_MODE_CODE, "承付模式": PAY_MODE_CODE,
"承付参数": PAY_PARAM_DEFAULT, "承付参数": _cell(line.pay_param) or PAY_PARAM_DEFAULT,
"细单开始日期": day, "细单开始日期": day,
"细单结束日期": end, "细单结束日期": end,
"采购结算方式": SETTLE_CODE, "采购结算方式": SETTLE_CODE,
@ -234,6 +249,8 @@ def build_import_draft(
raise NodeError("细单货品ID和订单号必须用订单中心的") raise NodeError("细单货品ID和订单号必须用订单中心的")
if not _cell(line.qty) or not _cell(line.tax_price): if not _cell(line.qty) or not _cell(line.tax_price):
raise NodeError("细单数量和含税协议单价必须用订单中心的") raise NodeError("细单数量和含税协议单价必须用订单中心的")
if not _cell(line.source_line_id):
raise NodeError("细单货源细单ID必须用订单中心的 goodsSourceId")
return RebateImportDraft(lines=packed, today=today_iso(today)) return RebateImportDraft(lines=packed, today=today_iso(today))
# 把 camelCase / snake_case 字典收成 RebateImportLine。无税单价按含税和税率算。 # 把 camelCase / snake_case 字典收成 RebateImportLine。无税单价按含税和税率算。
@ -257,6 +274,9 @@ def _line_from_mapping(item: dict) -> RebateImportLine:
goods_id=goods_id, goods_id=goods_id,
tax_rate=tax_rate, tax_rate=tax_rate,
net_price=_untaxed_price(tax_price, tax_rate), net_price=_untaxed_price(tax_price, tax_rate),
pay_param=_digit_text(item.get("paymentTerm") or item.get("payment_term") or item.get("pay_param")),
protocol_start=_iso_date(item.get("contractStartDate") or item.get("contract_start_date")),
protocol_end=_iso_date(item.get("contractEndDate") or item.get("contract_end_date")),
) )
# 从 REBATE_IMPORT_LINES 或单条环境变量拼细单草稿。表 3 空缺时干跑用。 # 从 REBATE_IMPORT_LINES 或单条环境变量拼细单草稿。表 3 空缺时干跑用。
@ -706,6 +726,7 @@ def _wait_goods_id_field(browser: BrowserAdapter, timeout: float = 8) -> bool:
# 打开货品选择弹窗。 # 打开货品选择弹窗。
def _open_goods_picker(browser: BrowserAdapter) -> bool: def _open_goods_picker(browser: BrowserAdapter) -> bool:
_ignore_source_choice(browser)
opened = browser.execute_script( opened = browser.execute_script(
""" """
/* OPEN_GOODS_PICKER */ /* OPEN_GOODS_PICKER */
@ -835,6 +856,144 @@ def _pick_goods_id_in_dialog(browser: BrowserAdapter, goods_id: str) -> bool:
time.sleep(0.4) time.sleep(0.4)
return False return False
# 点货源细单ID三个点。弹层按总单联系人过滤,要按住别让适配器当 bug 层关掉。
def _open_source_picker(browser: BrowserAdapter) -> bool:
opened = browser.execute_script(
"""
/* OPEN_SOURCE_PICKER */
"""
+ _header_dom_js()
+ """
const item = detailItem('货源细单ID') || formItem('货源细单ID');
if (!item) { return {ok: false, error: 'no-source'}; }
item.scrollIntoView({block: 'center', inline: 'nearest'});
const via = clickDots(item);
if (via) { return {ok: true, via: via}; }
const btn = item.querySelector('button.elPlus-button, button');
if (!btn) { return {ok: false}; }
btn.click();
return {ok: true, via: 'button'};
"""
)
return bool(isinstance(opened, dict) and opened.get("ok"))
# 等到货源细单 / 货源选择弹层出现。计划员选人层不算。
def _wait_source_picker(browser: BrowserAdapter, timeout: float = 12) -> bool:
deadline = time.time() + timeout
while time.time() <= deadline:
hit = browser.execute_script(
"""
/* WAIT_SOURCE_PICKER */
function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); }
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
const el = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((n) => {
if (!vis(n) && String(n.className || '').indexOf('is--visible') < 0) return false;
const title = compact(n.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
const t = compact(n);
const hit = title.indexOf('货源细单') >= 0 || title === '货源选择'
|| t.indexOf('货源细单') === 0 || t.indexOf('货源选择') === 0;
if (!hit) return false;
const planner = Array.from(n.querySelectorAll('input')).some((inp) => {
const p = String(inp.placeholder || '');
return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0;
});
return !planner;
});
return !!(el && (el.offsetWidth || el.offsetHeight));
"""
)
if hit is True:
return True
time.sleep(0.4)
return False
# 弹窗里按表 3 goods_source_id 搜货源细单。搜不到整组停。
def _pick_source_line_id(browser: BrowserAdapter, source_id: str) -> bool:
keyword = (source_id or "").strip()
if not keyword:
return False
_hold_source_choice(browser, True)
try:
if not _open_source_picker(browser):
return False
if not _wait_source_picker(browser):
return False
filled = browser.execute_script(
"""
/* FILL_SOURCE_ID */
const no = String(arguments[0] || '');
function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); }
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
const modal = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => {
if (!vis(el)) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return title.indexOf('货源细单') >= 0 || title === '货源选择';
});
if (!modal) { return {ok: false, error: 'no-modal'}; }
const input = modal.querySelector('input.elPlus-input__inner, input');
if (!input) { return {ok: false, error: 'no-input'}; }
input.click();
input.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, no);
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
const query = Array.from(modal.querySelectorAll('button')).find((el) => {
const t = compact(el);
return t === '查询' || t.indexOf('查询') === 0;
});
if (query) { query.click(); }
return {ok: true, value: input.value};
""",
keyword,
)
if not (isinstance(filled, dict) and filled.get("ok") and filled.get("value") == keyword):
return False
deadline = time.time() + 8
while time.time() <= deadline:
picked = browser.execute_script(
"""
/* PICK_SOURCE_ROW */
const keyword = String(arguments[0] || '').replace(/\\s+/g, '');
function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ''); }
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
const modal = Array.from(document.querySelectorAll('.vxe-modal--wrapper')).find((el) => {
if (!vis(el)) return false;
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
return title.indexOf('货源细单') >= 0 || title === '货源选择';
});
const root = modal || document;
const rows = Array.from(root.querySelectorAll('.vxe-body--row, tr, [role="row"]'));
for (const row of rows) {
const text = compact(row);
if (text.indexOf(keyword) < 0) continue;
const radio = row.querySelector(
'.vxe-cell--radio, .vxe-radio--icon, .vxe-radio--label, input[type="radio"]'
);
if (radio) { radio.click(); } else { row.click(); }
const ok = Array.from(root.querySelectorAll('button')).find((el) => {
if (!vis(el)) return false;
const t = compact(el);
return t === '确定' || t === '确 定';
});
if (ok) { ok.click(); }
return {ok: true, via: radio ? 'radio' : 'row'};
}
return {ok: false};
""",
keyword,
)
if isinstance(picked, dict) and picked.get("ok"):
time.sleep(0.5)
return True
time.sleep(0.4)
return False
finally:
_hold_source_choice(browser, False)
# 读细单卡片分页文案,用来判断确认有没有把行落到表。 # 读细单卡片分页文案,用来判断确认有没有把行落到表。
def _detail_card_pager(browser: BrowserAdapter) -> str: def _detail_card_pager(browser: BrowserAdapter) -> str:
try: try:
@ -857,9 +1016,10 @@ def _goods_table_rows(browser: BrowserAdapter) -> list[str]:
/* GOODS_TABLE_ROWS */ /* GOODS_TABLE_ROWS */
function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); } function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); }
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); } function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
const table = Array.from(document.querySelectorAll('.single-table')).find((el) => { const header = document.querySelector('.table-show .single-table');
return vis(el) && compact(el).indexOf('协议细单ID') >= 0; const table = Array.from(document.querySelectorAll('.single-table')).filter((el) => {
}); return el !== header && vis(el);
}).find((el) => compact(el).indexOf('协议细单ID') >= 0 || compact(el).indexOf('货品') >= 0);
if (!table) { return []; } if (!table) { return []; }
return Array.from(table.querySelectorAll('.vxe-body--row')).filter(vis).map((el) => compact(el).slice(0, 240)); return Array.from(table.querySelectorAll('.vxe-body--row')).filter(vis).map((el) => compact(el).slice(0, 240));
""" """
@ -886,6 +1046,7 @@ def _wait_goods_row(browser: BrowserAdapter, gid: str, timeout: float = 8) -> bo
# 点货品卡片标题栏「确认 Ctrl + Enter」,不要点总单卡片确认。 # 点货品卡片标题栏「确认 Ctrl + Enter」,不要点总单卡片确认。
def _confirm_detail_card(browser: BrowserAdapter) -> bool: def _confirm_detail_card(browser: BrowserAdapter) -> bool:
_ignore_source_choice(browser)
before = _detail_card_pager(browser) before = _detail_card_pager(browser)
clicked = browser.execute_script( clicked = browser.execute_script(
""" """
@ -1008,24 +1169,27 @@ def _type_detail_field(browser: BrowserAdapter, title: str, value: str) -> bool:
return True return True
try: try:
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
el = driver.find_element("css selector", '.table-card [data-rpa-fill="1"]') el = driver.find_element("css selector", '.table-card [data-rpa-fill="1"]')
driver.execute_script("arguments[0].removeAttribute('data-rpa-fill')", el) driver.execute_script("arguments[0].removeAttribute('data-rpa-fill')", el)
ActionChains(driver).move_to_element(el).click().pause(0.05).key_down(Keys.CONTROL).send_keys("a").key_up( ActionChains(driver).move_to_element(el).click().pause(0.05).key_down(Keys.CONTROL).send_keys("a").key_up(
Keys.CONTROL Keys.CONTROL
).send_keys(Keys.BACKSPACE).send_keys(wanted).send_keys(Keys.TAB).perform() ).send_keys(Keys.BACKSPACE).send_keys(wanted).perform()
driver.execute_script("arguments[0].blur()", el)
return True return True
except Exception: except Exception:
return False return False
# 填数量、含税单价、无税单价、备注=订单号、特定/进货补偿=0、承付模式和参数 25、结算电汇。 # 填数量、含税单价、无税单价、备注=订单号、特定/进货补偿=0、承付模式和表 3 账期、结算电汇。
def _fill_last_detail_qty_price(browser: BrowserAdapter, line: RebateImportLine) -> None: def _fill_last_detail_qty_price(browser: BrowserAdapter, line: RebateImportLine) -> None:
qty = _cell(line.qty) qty = _cell(line.qty)
price = _cell(line.tax_price) price = _cell(line.tax_price)
remark = _cell(line.order_no) remark = _cell(line.order_no)
tax = _tax_rate_of(line.tax_rate) tax = _tax_rate_of(line.tax_rate)
net = _cell(line.net_price) or _untaxed_price(price, tax) net = _cell(line.net_price) or _untaxed_price(price, tax)
pay_param = _digit_text(line.pay_param) or PAY_PARAM_DEFAULT
start = _iso_date(line.protocol_start)
end = _iso_date(line.protocol_end)
filled = browser.execute_script( filled = browser.execute_script(
""" """
/* FILL_DETAIL_CELLS */ /* FILL_DETAIL_CELLS */
@ -1072,7 +1236,7 @@ def _fill_last_detail_qty_price(browser: BrowserAdapter, line: RebateImportLine)
remark, remark,
COMPENSATION_DEFAULT, COMPENSATION_DEFAULT,
tax, tax,
PAY_PARAM_DEFAULT, pay_param,
net, net,
) )
print("fill_detail=", filled, flush=True) print("fill_detail=", filled, flush=True)
@ -1089,15 +1253,20 @@ def _fill_last_detail_qty_price(browser: BrowserAdapter, line: RebateImportLine)
_type_detail_field(browser, "数量", qty) _type_detail_field(browser, "数量", qty)
_type_detail_field(browser, "含税协议单价", price) _type_detail_field(browser, "含税协议单价", price)
_type_detail_field(browser, "无税协议价", net) _type_detail_field(browser, "无税协议价", net)
_type_detail_field(browser, "承付参数", PAY_PARAM_DEFAULT) _type_detail_field(browser, "承付参数", pay_param)
_type_detail_field(browser, "细单备注", remark) _type_detail_field(browser, "细单备注", remark)
_type_detail_field(browser, "特定补偿预估值", COMPENSATION_DEFAULT) _type_detail_field(browser, "特定补偿预估值", COMPENSATION_DEFAULT)
_type_detail_field(browser, "进货补偿预估值", COMPENSATION_DEFAULT) _type_detail_field(browser, "进货补偿预估值", COMPENSATION_DEFAULT)
if start:
_type_detail_field(browser, "细单开始日期", start)
if end:
_type_detail_field(browser, "细单结束日期", end)
if not _pick_detail_option(browser, "承付模式", PAY_MODE_NEXT_MONTH_N, in_detail=True): if not _pick_detail_option(browser, "承付模式", PAY_MODE_NEXT_MONTH_N, in_detail=True):
raise NodeError("细单承付未点到下月N号结") raise NodeError("细单承付未点到下月N号结")
_pick_detail_option(browser, "承付参数", PAY_PARAM_DEFAULT, in_detail=True) _pick_detail_option(browser, "承付参数", pay_param, in_detail=True)
if not _pick_detail_option(browser, "采购结算方式", SETTLE_MODE_WIRE, in_detail=True): if not _pick_detail_option(browser, "采购结算方式", SETTLE_MODE_WIRE, in_detail=True):
raise NodeError("细单结算未精确点到电汇") raise NodeError("细单结算未精确点到电汇")
_ignore_source_choice(browser)
# 货品表是否已有该货品ID。重跑时跳过已存在的行。 # 货品表是否已有该货品ID。重跑时跳过已存在的行。
def _goods_id_already_in_table(browser: BrowserAdapter, gid: str) -> bool: def _goods_id_already_in_table(browser: BrowserAdapter, gid: str) -> bool:
@ -1120,6 +1289,16 @@ def add_detail_lines_by_goods_id(
created = 0 created = 0
_close_header_card(browser) _close_header_card(browser)
_close_detail_card(browser) _close_detail_card(browser)
_ignore_source_choice(browser)
missing_source = [
goods_search_id(line)
for line in lines
if goods_search_id(line)
and not _cell(line.source_line_id)
and not _goods_id_already_in_table(browser, goods_search_id(line))
]
if missing_source:
raise NodeError("细单货源细单ID必须用订单中心的 goodsSourceId")
for line in lines: for line in lines:
gid = goods_search_id(line) gid = goods_search_id(line)
if not gid: if not gid:
@ -1130,9 +1309,13 @@ def add_detail_lines_by_goods_id(
continue continue
if not _click_add_detail(browser): if not _click_add_detail(browser):
raise NodeError("选中总单后未出现货品新增") raise NodeError("选中总单后未出现货品新增")
_ignore_source_choice(browser)
if not _pick_goods_id_in_dialog(browser, gid): if not _pick_goods_id_in_dialog(browser, gid):
raise NodeError(f"新增后未按货品ID搜到 {gid},停下确认搜框是不是货品ID") raise NodeError(f"新增后未按货品ID搜到 {gid},停下确认搜框是不是货品ID")
_fill_last_detail_qty_price(browser, line) _fill_last_detail_qty_price(browser, line)
source_id = _cell(line.source_line_id)
if not _pick_source_line_id(browser, source_id):
raise NodeError(f"细单 {gid} 未按货源细单ID搜到 {source_id},整组停")
try: try:
print( print(
"card_btns=", "card_btns=",

View File

@ -11,6 +11,7 @@ from job import recorder, run_batch
from persist import KIND_HOLD, KIND_NOT_FOUND, AgreementOutcome, RebateGroup from persist import KIND_HOLD, KIND_NOT_FOUND, AgreementOutcome, RebateGroup
from rebate_finder import RebateFinder, context_from_env from rebate_finder import RebateFinder, context_from_env
from rebate_flow_steps import ( from rebate_flow_steps import (
header_draft_for_contract,
import_draft_for_contract, import_draft_for_contract,
run_header_flow, run_header_flow,
run_import_flow, run_import_flow,
@ -62,12 +63,16 @@ def production_runner(browser, ctx, finder: RebateFinder, group: RebateGroup) ->
# 填总单并返回协议总单号。没拿到号后面就不送审。 # 填总单并返回协议总单号。没拿到号后面就不送审。
def header(number: str) -> str: def header(number: str) -> str:
print(f"header contract_no={number}", flush=True) print(f"header contract_no={number}", flush=True)
return run_header_flow(browser, ctx, number) return run_header_flow(
browser, ctx, number, header_draft_for_contract(number, require_table=True)
)
# 按表 3 货品新增细单。有失败行时这一组不算录入成功。 # 按表 3 货品新增细单。有失败行时这一组不算录入成功。
def importer(number: str): def importer(number: str):
print(f"import contract_no={number}", flush=True) print(f"import contract_no={number}", flush=True)
return run_import_flow(browser, ctx, number, import_draft_for_contract(number)) return run_import_flow(
browser, ctx, number, import_draft_for_contract(number, require_table=True)
)
# 送审。成功才由落表逻辑插 actionCode=2。 # 送审。成功才由落表逻辑插 actionCode=2。
def submitter(number: str): def submitter(number: str):

View File

@ -12,6 +12,14 @@ from apa_browser_adapter import (
) )
# 适配器关层脚本同时认货源选择和货源细单ID。
def test_adapter_ignore_source_js_matches_source_line_id():
from rpa_browser.adapter import _IGNORE_SOURCE_JS
assert "货源选择" in _IGNORE_SOURCE_JS
assert "货源细单" in _IGNORE_SOURCE_JS
# 返利 ApaBrowserAdapter 是 rpa_browser 共享类或其子类。 # 返利 ApaBrowserAdapter 是 rpa_browser 共享类或其子类。
def test_adapter_is_shared_or_subclass(): def test_adapter_is_shared_or_subclass():
from rpa_browser.adapter import ApaBrowserAdapter as Shared from rpa_browser.adapter import ApaBrowserAdapter as Shared

View File

@ -1,7 +1,9 @@
# 返利总单:表 3 MDM、计划员廖建琦、复用已有总单号。 # 返利总单:表 3 MDM、计划员、账期、协议日期,复用已有总单号。
import pytest
from accept import accept_log_path from accept import accept_log_path
from apa_browser_adapter import chrome_debug_port, chrome_session_flow, resolve_chrome_profile_dir from apa_browser_adapter import chrome_debug_port, chrome_session_flow, resolve_chrome_profile_dir
from errors import NodeError
from rebate_flow_steps import ( from rebate_flow_steps import (
CONTACT_DEFAULT, CONTACT_DEFAULT,
LOGIN, LOGIN,
@ -167,7 +169,7 @@ def _draft():
) )
# 总单供应商用表 3 MDM,计划员仍是廖建琦,细单货品ID和备注来自表 3。 # 总单供应商、计划员、账期、协议日期用表 3,细单货品ID和货源细单ID也来自表 3。
def test_header_draft_from_table3_uses_mdm_and_planner(): def test_header_draft_from_table3_uses_mdm_and_planner():
draft = header_draft_from_table3( draft = header_draft_from_table3(
[ [
@ -181,14 +183,20 @@ def test_header_draft_from_table3_uses_mdm_and_planner():
"qty": "10", "qty": "10",
"unit_price": "10", "unit_price": "10",
"oms_id": "20260928000003", "oms_id": "20260928000003",
"payment_term": 90,
"contract_start_date": "2026-10-25",
"contract_end_date": "2027-10-24",
} }
], ],
today="2026-10-12", today="2026-10-12",
) )
assert draft.supplier_code == "DW0100046361" assert draft.supplier_code == "DW0100046361"
assert draft.planner_name == PLANNER_DEFAULT assert draft.planner_name == "李四"
assert draft.rebate_unit_code == "901" assert draft.rebate_unit_code == "901"
assert draft.contact_name == CONTACT_DEFAULT assert draft.contact_name == CONTACT_DEFAULT
assert draft.pay_param == "90"
assert draft.protocol_start == "2026-10-25"
assert draft.protocol_end == "2027-10-24"
lines = import_lines_from_table3( lines = import_lines_from_table3(
[ [
{ {
@ -197,6 +205,9 @@ def test_header_draft_from_table3_uses_mdm_and_planner():
"unit_price": "10", "unit_price": "10",
"oms_id": "20260928000003", "oms_id": "20260928000003",
"goods_source_id": "SRC1", "goods_source_id": "SRC1",
"payment_term": 90,
"contract_start_date": "2026-10-25",
"contract_end_date": "2027-10-24",
} }
] ]
) )
@ -204,6 +215,26 @@ def test_header_draft_from_table3_uses_mdm_and_planner():
assert lines[0].order_no == "20260928000003" assert lines[0].order_no == "20260928000003"
assert lines[0].tax_rate == "0.13" assert lines[0].tax_rate == "0.13"
assert lines[0].net_price == "8.8496" assert lines[0].net_price == "8.8496"
assert lines[0].source_line_id == "SRC1"
assert lines[0].pay_param == "90"
assert lines[0].protocol_start == "2026-10-25"
# 表 3 缺计划员时整组停,不读环境变量。
def test_header_draft_from_table3_requires_planner(monkeypatch):
monkeypatch.setenv("REBATE_PLANNER_NAME", "环境人")
with pytest.raises(NodeError, match="计划员"):
header_draft_from_table3(
[
{
"supplier_mdm_code": "DW0100046361",
"org_code": "901",
"payment_term": 25,
"contract_start_date": "2026-10-25",
"contract_end_date": "2027-10-24",
}
]
)
# 总单默认走现行字段:下月N号结、协议起止当天、返利合约否。 # 总单默认走现行字段:下月N号结、协议起止当天、返利合约否。
@ -217,7 +248,7 @@ def test_header_defaults_follow_mapping_not_scheme_55():
assert draft.settle_mode == "否" assert draft.settle_mode == "否"
# 计划员名为空时回退廖建琦,联系人苏文辉。 # 计划员名为空时回退默认,联系人表 3 无列仍用苏文辉。
def test_header_draft_defaults_planner(): def test_header_draft_defaults_planner():
draft = build_header_draft(planner_name="", supplier_code="S1", rebate_unit_code="901") draft = build_header_draft(planner_name="", supplier_code="S1", rebate_unit_code="901")
assert draft.planner_name == PLANNER_DEFAULT assert draft.planner_name == PLANNER_DEFAULT

View File

@ -15,6 +15,7 @@ from rebate_flow_steps import (
RebateImportLine, RebateImportLine,
add_detail_lines_by_goods_id, add_detail_lines_by_goods_id,
build_import_draft, build_import_draft,
import_draft_for_contract,
build_import_rows, build_import_rows,
fill_import_excel, fill_import_excel,
run_import_flow, run_import_flow,
@ -134,6 +135,16 @@ class FakeBrowser:
return [] return []
if "PICK_GOODS_ROW" in text: if "PICK_GOODS_ROW" in text:
return {"ok": True} return {"ok": True}
if "OPEN_SOURCE_PICKER" in text:
return {"ok": True, "via": "dots"}
if "WAIT_SOURCE_PICKER" in text:
return True
if "FILL_SOURCE_ID" in text:
return {"ok": True, "value": str(args[0] if args else "")}
if "PICK_SOURCE_ROW" in text:
return {"ok": True, "via": "row"}
if "CLOSE_SOURCE_CHOICE" in text:
return {"ok": True, "via": "close", "count": 0}
if "SOURCE_CHOICE" in text: if "SOURCE_CHOICE" in text:
return False return False
if "PICK_NTH_OVERLAY" in text: if "PICK_NTH_OVERLAY" in text:
@ -277,6 +288,38 @@ def test_import_draft_requires_oms_order_no():
) )
# 班次缺表 3 时整组停,不读 JSON。
def test_import_draft_require_table_without_rows(monkeypatch):
class FakeRepo:
@classmethod
def from_env(cls):
return cls()
def load_contract_goods(self, contract_no):
return []
monkeypatch.setenv("REBATE_IMPORT_LINES", '[{"goodsId":"G1","goodsSourceId":"S1","qty":"1","taxPrice":"1","orderNo":"O1"}]')
monkeypatch.setattr("repo.OracleRebateRepo", FakeRepo)
with pytest.raises(NodeError, match="表 3 没有货品行"):
import_draft_for_contract("HT1", require_table=True)
# 细单缺货源细单ID时整组拒绝。
def test_import_draft_requires_source_line_id():
with pytest.raises(NodeError):
build_import_draft(
lines=[
RebateImportLine(
goods_code="G001",
qty="10",
tax_price="6.70",
order_no="ZQD1",
goods_id="G001",
)
]
)
# 填模板时货品编码列写入 goodsId。 # 填模板时货品编码列写入 goodsId。
def test_fill_excel_writes_goods_id_into_goods_id_column(tmp_path): def test_fill_excel_writes_goods_id_into_goods_id_column(tmp_path):
from openpyxl import Workbook, load_workbook from openpyxl import Workbook, load_workbook
@ -382,9 +425,25 @@ def _goods_line(goods_id: str, *, order_no: str = "ZQD1") -> RebateImportLine:
tax_price="6.70", tax_price="6.70",
order_no=order_no, order_no=order_no,
goods_id=goods_id, goods_id=goods_id,
source_line_id="SRC1",
) )
# 待新增行没有货源细单ID 时整组停,不点新增。
def test_add_detail_stops_when_source_line_missing():
browser = FakeBrowser()
line = RebateImportLine(
goods_code="G009",
qty="10",
tax_price="6.70",
order_no="ZQD9",
goods_id="G009",
)
with pytest.raises(NodeError, match="货源细单ID"):
add_detail_lines_by_goods_id(browser, [line])
assert ["+新增"] not in browser.clicked
# 货品表已有该 ID 则跳过新增。 # 货品表已有该 ID 则跳过新增。
def test_add_detail_skips_goods_already_in_table(): def test_add_detail_skips_goods_already_in_table():
browser = FakeBrowser(goods_rows=["G001 10 ZQD1"]) browser = FakeBrowser(goods_rows=["G001 10 ZQD1"])
@ -394,6 +453,43 @@ def test_add_detail_skips_goods_already_in_table():
assert ["保存"] not in browser.clicked assert ["保存"] not in browser.clicked
# 细单填完后关掉货源细单ID 弹层,避免 TAB 后再弹挡住确认。
def test_add_detail_closes_source_line_popup():
class SourceLineBrowser(FakeBrowser):
def __init__(self):
super().__init__()
self.source_open = True
self.closed = 0
def execute_script(self, script: str, *args):
text = script or ""
if "CLOSE_SOURCE_CHOICE" in text:
self.source_open = False
self.closed += 1
return {"ok": True, "via": "close", "count": 1}
if "SOURCE_CHOICE" in text:
return self.source_open
return super().execute_script(script, *args)
browser = SourceLineBrowser()
added = add_detail_lines_by_goods_id(browser, [_goods_line("G002")])
assert added == 1
assert browser.closed >= 1
assert browser.source_open is False
# 关层脚本要认货源细单ID,不能只认货源选择。
def test_source_choice_scripts_match_source_line_id():
import inspect
from rebate_common import _close_source_choice, _source_choice_visible
visible_src = inspect.getsource(_source_choice_visible)
close_src = inspect.getsource(_close_source_choice)
assert "货源细单" in visible_src
assert "货源细单" in close_src
# 同批已有的跳过、没有的仍新增。 # 同批已有的跳过、没有的仍新增。
def test_add_detail_skips_existing_and_adds_missing(): def test_add_detail_skips_existing_and_adds_missing():
browser = FakeBrowser(goods_rows=["G001 10 ZQD1"]) browser = FakeBrowser(goods_rows=["G001 10 ZQD1"])

View File

@ -0,0 +1,42 @@
# 返利录入待确认字段
给业务确认。接口 3 / 表 3 没有这些列,RPA 现在按页面口径写死。确认前代码不改。
确认方式:每项勾「统一写死」或「按单变化」。按单变化要说数据从哪来(订单中心补字段 / 别的表 / 人工)。
---
## 要拍板的项
| 页面字段 | 现在怎么填 | 问什么 | 统一写死则 | 按单变化则 |
| --- | --- | --- | --- | --- |
| 联系人 | 苏文辉 | 是否永远这个人 | 保持苏文辉 | 补列或给对照表。货源弹层按联系人过滤,换人则 `goods_source_id` 可能搜空 |
| 返利合约 | 否 | 是否永远「否」 | 保持否 | 订单中心补字段 |
| 采购结算方式 | 电汇(不要点「电汇—非直联」) | 是否永远电汇 | 保持电汇 | 订单中心补字段 |
| 承付模式 | 下月 N 号结 | 是否永远这个模式 | 保持下月 N 号结 | 说明还有哪些模式、从哪取值 |
| 特定补偿预估值 | 0 | 页面确认时报必填,是否永远 0 | 保持 0 | 金额从哪来 |
| 进货补偿预估值 | 0 | 同上 | 保持 0 | 金额从哪来 |
---
## 承付参数(单独问)
总单、细单的「承付参数」现在读表 3 `payment_term`(接口 3 `paymentTerm`,常见 90,单位像是账期天数)。
页面承付模式是「下月 N 号结」时,N 一般是 1–31 号。用 90 去填 N 会对不上。
请业务拍:
1. `payment_term` 就是「下月 N 号」的 N(那业务上不该出现 >31)
2. `payment_term` 是账期天数,N 另有规则(例如固定 25)——请写出规则
3. 承付模式其实不是「下月 N 号结」,请指出页面该选哪一项、参数怎么填
---
## 已经读表 3、不用确认
供应商 MDM、计划员、协议开始/结束、货品 ID、数量、含税单价、税率(空则 0.13)、货源细单 ID、细单备注(`oms_id`)。缺列整组停。
---
确认结果记在本文件底部,再改代码。

View File

@ -19,22 +19,22 @@
| 位置 | 字段 | 值 | | 位置 | 字段 | 值 |
| --- | --- | --- | | --- | --- | --- |
| 总单 | 供应商编码 | 表 3 `supplier_mdm_code`,三个点搜 | | 总单 | 供应商编码 | 表 3 `supplier_mdm_code`,三个点搜 |
| 总单 | 计划员 | 廖建琦(选择框,不要选 ID) | | 总单 | 计划员 | 表 3 `planner_name`(选择框,不要选 ID) |
| 总单 | 联系人 | 苏文辉 | | 总单 | 联系人 | 写死苏文辉(表 3 无此列) |
| 总单 | 返利合约 | 否 | | 总单 | 返利合约 | 否 |
| 总单 | 采购结算方式 | 电汇(不要点电汇—非直联) | | 总单 | 采购结算方式 | 电汇(不要点电汇—非直联) |
| 总单 | 承付模式 | 下月N号结 | | 总单 | 承付模式 | 下月N号结 |
| 总单 | 承付参数 | **25**(数字框键盘输入) | | 总单 | 承付参数 | 表 3 `payment_term` |
| 总单 | 协议开始/结束 | 当天 | | 总单 | 协议开始/结束 | 表 3 `contract_start_date` / `contract_end_date` |
| 细单 | 货品 | 表 3 `goods_id` | | 细单 | 货品 | 表 3 `goods_id` |
| 细单 | 数量 / 含税单价 | 表 3 `qty` / `unit_price` | | 细单 | 数量 / 含税单价 | 表 3 `qty` / `unit_price` |
| 细单 | 无税单价 | `含税 / (1+税率)` | | 细单 | 无税单价 | `含税 / (1+税率)` |
| 细单 | 承付模式 | 下月N号结 | | 细单 | 承付模式 | 下月N号结 |
| 细单 | 承付参数 | **25** | | 细单 | 承付参数 | 表 3 `payment_term` |
| 细单 | 采购结算方式 | 电汇 | | 细单 | 采购结算方式 | 电汇 |
| 细单 | 备注 | `oms_id` | | 细单 | 备注 | `oms_id` |
| 细单 | 特定补偿 / 进货补偿 | 0 | | 细单 | 特定补偿 / 进货补偿 | 0 |
| 细单 | 货源细单ID | 本期不填。弹层按联系人(苏文辉)过滤 | | 细单 | 货源细单ID | 表 3 `goods_source_id`。空 → 整组停。弹层按联系人过滤 |
落表:起草行 `contract_status=2`、`process_status=1`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带协议总单ID / 协议细单ID;`push_status=0`。人名固定「系统管理员」。搜不到表 4 `process_status` 改回 0。 落表:起草行 `contract_status=2`、`process_status=1`;送审成功每 `oms_id` 插 `action_code=2`、`agreement_status=1`,带协议总单ID / 协议细单ID;`push_status=0`。人名固定「系统管理员」。搜不到表 4 `process_status` 改回 0。
@ -44,16 +44,16 @@
| --- | --- | --- | | --- | --- | --- |
| 细单路径 | Agents / README 写「下模板 → 填 Excel → 上传」 | 页面「新增 + 货品ID搜」。`run_import_flow` 调 `add_detail_lines_by_goods_id` | | 细单路径 | Agents / README 写「下模板 → 填 Excel → 上传」 | 页面「新增 + 货品ID搜」。`run_import_flow` 调 `add_detail_lines_by_goods_id` |
| `返利流程(1).md` | 已知/未知草稿,Excel 与页面双轨 | 废稿,指向 `返利流程.md` | | `返利流程(1).md` | 已知/未知草稿,Excel 与页面双轨 | 废稿,指向 `返利流程.md` |
| 计划员 | 映射表 / 口径:订单中心 `planner_name` | 选择框「廖建琦」,后期改回 | | 计划员 | 映射表 / 口径:订单中心 `planner_name` | 表 3 `planner_name` |
| 联系人 | 映射表无 | 「苏文辉」。货源弹层按此人过滤 | | 联系人 | 映射表无 | 写死「苏文辉」。货源弹层按此人过滤 |
| 采购结算方式 | 映射表总单默认「否」、细单代码 `3` | 总单、细单都精确点「电汇」 | | 采购结算方式 | 映射表总单默认「否」、细单代码 `3` | 总单、细单都精确点「电汇」 |
| 承付参数 | 「不要填 25 / 以销售定结」 | 总单、细单都填 **25** | | 承付参数 | 「不要填 25 / 以销售定结」 | 表 3 `payment_term` |
| 关税税率 | 映射表 `0.13` | 不填 | | 关税税率 | 映射表 `0.13` | 不填 |
| 税率 | 映射表固定 `0.13` | 表 3 `purchase_tax_rate`,空则 `0.13` | | 税率 | 映射表固定 `0.13` | 表 3 `purchase_tax_rate`,空则 `0.13` |
| 细单日期 | 映射表开始当天、结束年底 | 先用页面默认,报错再看 | | 细单日期 | 映射表开始当天、结束年底 | 先用页面默认,报错再看 |
| 总单保存 | 先卡片确认再保存 | 先总单工具栏保存(卡仍在编辑),再确认 | | 总单保存 | 先卡片确认再保存 | 先总单工具栏保存(卡仍在编辑),再确认 |
| 细单确认 | 容易点到总单「确认」 | 只点货品卡片「确认 Ctrl + Enter」,确认后核对货品表有该 `goods_id` | | 细单确认 | 容易点到总单「确认」 | 只点货品卡片「确认 Ctrl + Enter」,确认后核对货品表有该 `goods_id` |
| 计划员弹层 | 适配器看见「货源选择」就关 | 计划员三点打开的货源层要按住再选廖建琦 | | 计划员弹层 | 适配器看见「货源选择」就关 | 计划员三点打开的货源层要按住再按表 3 姓名搜 |
| 货品来源 | 只按表 3 `contract_no` | `contract_no` 空时用表 4 `oms_id` 对表 3 | | 货品来源 | 只按表 3 `contract_no` | `contract_no` 空时用表 4 `oms_id` 对表 3 |
| 落表 | (曾按行拆成功/失败,已撤回) | 同一合同一组一个结果 | | 落表 | (曾按行拆成功/失败,已撤回) | 同一合同一组一个结果 |
@ -62,8 +62,8 @@
| 项 | 落点 | | 项 | 落点 |
| --- | --- | | --- | --- |
| 班次入口 | `run_batch.py`:查号 → 总单 → 细单新增 → 送审 → `save_group` | | 班次入口 | `run_batch.py`:查号 → 总单 → 细单新增 → 送审 → `save_group` |
| 总单数字框键盘填 25 | `_fill_pay_param` | | 总单数字框键盘填账期 | `_fill_pay_param` |
| 细单键盘填 25 | `_fill_last_detail_qty_price` / `_type_detail_field` | | 细单键盘填账期 | `_fill_last_detail_qty_price` / `_type_detail_field` |
| 货品表核对 | `_goods_table_rows`、`_wait_goods_row` | | 货品表核对 | `_goods_table_rows`、`_wait_goods_row` |
| 原生点击 | 计划员附加按钮、总单/货品工具栏保存、卡片确认 | | 原生点击 | 计划员附加按钮、总单/货品工具栏保存、卡片确认 |
| 现行登记 | 本文件 | | 现行登记 | 本文件 |
@ -72,9 +72,8 @@
`rebate_import.py` 仍有 Excel 下模板 / 填表 / 上传函数(`write_canonical_template`、`download_detail_template` 等)。`run_import_flow` 不调用它们。不要按这些函数写新逻辑。对外仍从 `rebate_flow_steps` import。 `rebate_import.py` 仍有 Excel 下模板 / 填表 / 上传函数(`write_canonical_template`、`download_detail_template` 等)。`run_import_flow` 不调用它们。不要按这些函数写新逻辑。对外仍从 `rebate_flow_steps` import。
## 下一会话(未做) 正式班次只读表 3,缺列整组停,不用环境变量顶。`run_header.py` / `run_import.py` 页面干跑才允许环境变量 / `REBATE_IMPORT_LINES` JSON。
1. **货源细单ID**:三个点搜表 3 `goods_source_id`。弹层只出总单联系人(现苏文辉)名下货源,不是全局搜号。空 → 整组停、`KIND_FAIL`、不送审。不要按行一条成功两条失败。 联系人、返利合约、电汇、承付模式、补偿 0 待业务确认,清单 `待确认字段.md`。
2. **计划员**:现阶段「廖建琦」;改回表 3 `planner_name`。
口 2 / 口 4 HTTP、M5、药帮忙支付不在本包,见根 `README.md`「本期未做」。 口 2 / 口 4 HTTP、M5、药帮忙支付不在本包,见根 `README.md`「本期未做」。

View File

@ -11,11 +11,11 @@
- 登录账号走本机 `rpa_rebate/.env` - 登录账号走本机 `rpa_rebate/.env`
- 合同号用合同系统号,例:`035012026101035440` - 合同号用合同系统号,例:`035012026101035440`
- 细单货品:点「货品编码」三个点,弹窗里用表 3 / 接口 3 的 `goodsId` 搜 - 细单货品:点「货品编码」三个点,弹窗里用表 3 / 接口 3 的 `goodsId` 搜
- 计划员:先点进选择框,默认选「廖建琦」,不要选计划员 ID。后期再改回订单中心计划员 - 计划员:表 3 `planner_name`,选择框按姓名搜,不要选计划员 ID
- 联系人:选择框,选第二条「苏文辉」 - 联系人:表 3 无此列,写死「苏文辉」
- 承付模式:方案「下月 N 号结」 - 承付模式:方案「下月 N 号结」
- 承付参数:总单、细单都填 `25`(vxe 数字框,键盘输入) - 承付参数:表 3 `payment_term`(vxe 数字框,键盘输入)
- 协议开始、协议结束:当天 - 协议开始、协议结束:表 3 `contract_start_date` / `contract_end_date`
- 细单备注 = 订单号(`oms_id`) - 细单备注 = 订单号(`oms_id`)
- 特定补偿预估值 = 0 - 特定补偿预估值 = 0
- 进货补偿预估值 = 0 - 进货补偿预估值 = 0
@ -60,8 +60,8 @@
3. 带星号都要填 3. 带星号都要填
4. 供应商:点「供应商编码」三个点,打开选择框,用表 3 的 `supplier_mdm_code` 搜,对上编码那一列,确认选中。选中后会带出供应商名称、返利单位等,不要手填这些带出项。名称和编码不要填反 4. 供应商:点「供应商编码」三个点,打开选择框,用表 3 的 `supplier_mdm_code` 搜,对上编码那一列,确认选中。选中后会带出供应商名称、返利单位等,不要手填这些带出项。名称和编码不要填反
5. 选对供应商时不应弹出「货源选择」;若弹出,停下问 5. 选对供应商时不应弹出「货源选择」;若弹出,停下问
6. 计划员:进选择框,先默认选「廖建琦」,不要选计划员 ID。后期再改 6. 计划员:进选择框,按表 3 `planner_name` 搜,不要选计划员 ID
7. 联系人:进选择框,选第二条「苏文辉」 7. 联系人:进选择框,选「苏文辉」
8. 采购结算方式:可筛选下拉,先输入「电汇」,再精确点「电汇」,不要点「电汇—非直联」 8. 采购结算方式:可筛选下拉,先输入「电汇」,再精确点「电汇」,不要点「电汇—非直联」
9. 承付模式:「下月N号结」。承付参数填 `25`(数字框,键盘输入;不要填「以销售定结」) 9. 承付模式:「下月N号结」。承付参数填 `25`(数字框,键盘输入;不要填「以销售定结」)
10. 卡片还在「编辑」时点总单工具栏「保存」,再点卡片「确认 Ctrl + Enter」。先确认会把卡收回表格,保存落不到库 10. 卡片还在「编辑」时点总单工具栏「保存」,再点卡片「确认 Ctrl + Enter」。先确认会把卡收回表格,保存落不到库
@ -74,14 +74,14 @@
| 供应商编码 | 三个点打开选择框,用 `supplier_mdm_code` 搜,确认选中那条 | 表 3 | | 供应商编码 | 三个点打开选择框,用 `supplier_mdm_code` 搜,确认选中那条 | 表 3 |
| 供应商名称 | 不填,选供应商后带出 | 带出 | | 供应商名称 | 不填,选供应商后带出 | 带出 |
| 返利单位编码 | 不填,选供应商后带出 | 带出 | | 返利单位编码 | 不填,选供应商后带出 | 带出 |
| 计划员 | 选择框先选「廖建琦」,不要选计划员 ID;后期再改 | 本账号,后期改 | | 计划员 | 选择框按姓名搜,不要选计划员 ID | 表 3 `planner_name` |
| 联系人 | 选择框选第二条「苏文辉」 | 页面选项 | | 联系人 | 选择框搜姓名 | 写死「苏文辉」(表 3 无此列) |
| 返利合约 | 「否」 | 写死 | | 返利合约 | 「否」 | 写死 |
| 采购结算方式 | 「电汇」,精确点中 | 写死 | | 采购结算方式 | 「电汇」,精确点中 | 写死 |
| 承付模式 | 「下月N号结」 | 方案 | | 承付模式 | 「下月N号结」 | 方案 |
| 承付参数 | `25` | 写死 | | 承付参数 | 数字框键盘输入 | 表 3 `payment_term` |
| 协议开始日期 | 当天 | 写死 | | 协议开始日期 | 填表 3 开始日 | 表 3 `contract_start_date` |
| 协议结束日期 | 当天 | 写死 | | 协议结束日期 | 填表 3 结束日 | 表 3 `contract_end_date` |
--- ---
@ -94,10 +94,10 @@
5. 先点行里的单选 `.vxe-cell--radio`,再点弹窗底「确 定」 5. 先点行里的单选 `.vxe-cell--radio`,再点弹窗底「确 定」
6. 数量、含税协议单价、无税单价按表 3 填 6. 数量、含税协议单价、无税单价按表 3 填
7. 细单备注 = 订单号;特定补偿 = 0 7. 细单备注 = 订单号;特定补偿 = 0
8. 承付模式:下月N号结;承付参数 `25` 8. 承付模式:下月N号结;承付参数用表 3 `payment_term`
9. 采购结算方式:先输入「电汇」再精确点「电汇」 9. 采购结算方式:先输入「电汇」再精确点「电汇」
10. 关税税率不填。货源细单ID 本期不填。弹层按**总单联系人**过滤(现「苏文辉」),不是全局按号搜;表 3 `goods_source_id` 若不是该联系人名下货源,三个点是空的。下一会话再填;搜不到整组报错返回,不按行拆。进货补偿预估值确认时报必填,填 0 10. 关税税率不填。货源细单ID 三个点搜表 3 `goods_source_id`。弹层按**总单联系人**过滤,不是全局按号搜。空或搜不到 → 整组停、不送审,不按行拆。进货补偿预估值确认时报必填,填 0
11. 细单开始 / 结束日期先用系统默认;保存报错再看 11. 细单开始 / 结束日期用表 3 `contract_start_date` / `contract_end_date`
12. 每条卡片填完点标题栏「确认 Ctrl + Enter」,行才落到货品表。不要点「返回表格」代替确认 12. 每条卡片填完点标题栏「确认 Ctrl + Enter」,行才落到货品表。不要点「返回表格」代替确认
13. **三条都确认完,再点货品工具栏「保存」**(新增旁边那颗,不要点总单工具栏保存)。保存时若弹出确认,再点确认。不要每条都点工具栏保存 13. **三条都确认完,再点货品工具栏「保存」**(新增旁边那颗,不要点总单工具栏保存)。保存时若弹出确认,再点确认。不要每条都点工具栏保存
14. 提示「总单供应商为空」时,先回总单把供应商补上,再继续细单 14. 提示「总单供应商为空」时,先回总单把供应商补上,再继续细单
@ -115,14 +115,14 @@
| 税率 | 表 3 `purchase_tax_rate`,空则 `0.13` | 表 3 | | 税率 | 表 3 `purchase_tax_rate`,空则 `0.13` | 表 3 |
| 关税税率 | 不填 | — | | 关税税率 | 不填 | — |
| 承付模式 | 「下月N号结」 | 方案 | | 承付模式 | 「下月N号结」 | 方案 |
| 承付参数 | `25` | 写死 | | 承付参数 | 表 3 `payment_term` | 表 3 |
| 采购结算方式 | 「电汇」,精确点中 | 写死 | | 采购结算方式 | 「电汇」,精确点中 | 写死 |
| 细单开始日期 | 系统默认,报错再看 | 页面 | | 细单开始日期 | 表 3 开始日 | 表 3 `contract_start_date` |
| 细单结束日期 | 系统默认,报错再看 | 页面 | | 细单结束日期 | 表 3 结束日 | 表 3 `contract_end_date` |
| 特定补偿预估值 | `0` | 写死 | | 特定补偿预估值 | `0` | 写死 |
| 进货补偿预估值 | 页面确认时报必填,填 `0` | 写死 | | 进货补偿预估值 | 页面确认时报必填,填 `0` | 写死 |
| 细单备注 | 订单号 | 表 3 `oms_id` | | 细单备注 | 订单号 | 表 3 `oms_id` |
| 货源细单ID | 本期不填。弹层按总单联系人(现苏文辉)过滤;下一会话三个点搜,空则整组报错返回 | — | | 货源细单ID | 三个点搜;空或搜不到整组停 | 表 3 `goods_source_id` |
--- ---
@ -153,20 +153,20 @@
- 不要点查询弹层的「取消」 - 不要点查询弹层的「取消」
- 不要拿第一行或 `CG-` 号当总单 - 不要拿第一行或 `CG-` 号当总单
- 不要新增总单(审批通过后已有一条) - 不要新增总单(审批通过后已有一条)
- 不要选计划员 ID;现阶段选「廖建琦」 - 不要选计划员 ID;按表 3 `planner_name` 搜姓名
- 供应商选择框里不要把名称和编码填反;用 `supplier_mdm_code` 搜编码 - 供应商选择框里不要把名称和编码填反;用 `supplier_mdm_code` 搜编码
- 不要用 opcode / 货品编码在返利中心搜品种;用货品ID - 不要用 opcode / 货品编码在返利中心搜品种;用货品ID
- 结算不要点到「电汇—非直联」 - 结算不要点到「电汇—非直联」
- 细单不要点总单工具栏的保存 - 细单不要点总单工具栏的保存
- 不要每条细单都点工具栏保存;卡片「确定」落到表后再统一保存 - 不要每条细单都点工具栏保存;卡片「确定」落到表后再统一保存
- 不要把承付参数填成「以销售定结」;总单、细单都填 `25` - 不要把承付参数填成「以销售定结」;总单、细单都填表 3 `payment_term`
- 不要做 Excel 导入 - 不要做 Excel 导入
- 不要按行拆同一合同的成功/失败;一组一个结果 - 不要按行拆同一合同的成功/失败;一组一个结果
- 不要点总单卡片的「确认」去当细单确认;细单只点货品卡片「确认 Ctrl + Enter」 - 不要点总单卡片的「确认」去当细单确认;细单只点货品卡片「确认 Ctrl + Enter」
- 搜不到不要重录合同 - 搜不到不要重录合同
- 浏览器卡住时不要关,停下问 - 浏览器卡住时不要关,停下问
- 计划员三个点打开的「货源选择」是选人弹层,要按住再搜「廖建琦」;适配器默认关货源层时计划员选不了 - 计划员三个点打开的「货源选择」是选人弹层,要按住再按表 3 姓名搜;适配器默认关货源层时计划员选不了
- 货源跟联系人,不跟计划员。细单货源弹层只出苏文辉名下货源 - 货源跟联系人,不跟计划员。细单货源弹层只出总单联系人名下货源
--- ---
@ -174,6 +174,5 @@
| 项 | 现在 | 以后 | | 项 | 现在 | 以后 |
| --- | --- | --- | | --- | --- | --- |
| 货源细单ID | 不填 | 三个点按联系人过滤搜表 3 号;空则整组失败 | | 联系人 | 写死「苏文辉」(表 3 无列) | 若订单中心补列再改 |
| 计划员 | 廖建琦 | 改回表 3 `planner_name` |
| 口 2 / 口 4 HTTP、M5、药帮忙支付 | 不做 | 不在本包 | | 口 2 / 口 4 HTTP、M5、药帮忙支付 | 不做 | 不在本包 |

View File

@ -12,7 +12,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from http_util import parse_cookie_value from http_util import cookies_for_http, parse_cookie_value
from mall_config import ( from mall_config import (
YAOEX_HOME_URL, YAOEX_HOME_URL,
YAOEX_LOGIN_URL, YAOEX_LOGIN_URL,
@ -67,12 +67,13 @@ def load_yaoex_auth_file(path: Path) -> YaoexAuth:
) )
# 从缓存 JSON 读出药帮忙 cookie。文件必须是对象。 # 从缓存 JSON 读出药帮忙 cookie。文件必须是对象。只留下能进 HTTP 头的项。
def load_cookie_file(path: Path) -> dict[str, str]: def load_cookie_file(path: Path) -> dict[str, str]:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError(f"cookie 文件不是对象:{path}") raise ValueError(f"cookie 文件不是对象:{path}")
return {str(key): parse_cookie_value(str(value)) for key, value in data.items()} loaded = {str(key): parse_cookie_value(str(value)) for key, value in data.items()}
return cookies_for_http(loaded)
# 把缓存文件权限收成 0o600。Windows 上 chmod 可能无效,忽略 OSError。 # 把缓存文件权限收成 0o600。Windows 上 chmod 可能无效,忽略 OSError。
@ -97,10 +98,13 @@ def save_yaoex_auth(auth: YaoexAuth, path: Path = DEFAULT_YAOEX_AUTH_PATH) -> No
_restrict_file_mode(path) _restrict_file_mode(path)
# 写出药帮忙 cookie 缓存,权限 0o600。 # 写出药帮忙 cookie 缓存,权限 0o600。中文展示字段不写进文件。
def save_cookie_file(cookies: dict[str, str], path: Path = DEFAULT_YBM_COOKIE_PATH) -> None: def save_cookie_file(cookies: dict[str, str], path: Path = DEFAULT_YBM_COOKIE_PATH) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8") path.write_text(
json.dumps(cookies_for_http(cookies), ensure_ascii=False, indent=2),
encoding="utf-8",
)
_restrict_file_mode(path) _restrict_file_mode(path)
@ -321,7 +325,7 @@ def _playwright_login_yaoex(username: str, password: str, runtime: BrowserRuntim
return YaoexAuth(token=token, user_id=user_id, buyer_code=buyer_code) return YaoexAuth(token=token, user_id=user_id, buyer_code=buyer_code)
# 打开药帮忙,登录后带回全部 cookie。 # 打开药帮忙,登录后带回能进 HTTP 头的 cookie。
def _playwright_login_ybm(username: str, password: str, runtime: BrowserRuntime) -> dict[str, str]: def _playwright_login_ybm(username: str, password: str, runtime: BrowserRuntime) -> dict[str, str]:
context, page = runtime.new_page() context, page = runtime.new_page()
_ensure_ybm_logged_in(page, username, password) _ensure_ybm_logged_in(page, username, password)
@ -329,7 +333,8 @@ def _playwright_login_ybm(username: str, password: str, runtime: BrowserRuntime)
page.wait_for_timeout(3000) page.wait_for_timeout(3000)
print("药帮忙窗口应已打开首页。") print("药帮忙窗口应已打开首页。")
cookies = context.cookies() cookies = context.cookies()
return {item["name"]: parse_cookie_value(item["value"]) for item in cookies} loaded = {item["name"]: parse_cookie_value(item["value"]) for item in cookies}
return cookies_for_http(loaded)
# 登录 1 号药城。缺账号则退出;传入 runtime 时不关浏览器。 # 登录 1 号药城。缺账号则退出;传入 runtime 时不关浏览器。

View File

@ -22,6 +22,34 @@ def parse_cookie_value(raw_value: str | None) -> str:
return unquote(raw_value.strip().strip('"')) return unquote(raw_value.strip().strip('"'))
# 只留下能放进 HTTP Cookie 头的项。名字必须 ASCII,值必须 latin-1。
#
# 浏览器登录会带上企业名、站点名等中文字段,requests 发不出去。
# 按编码过滤,不按 cookie 名写死,换账号打开浏览器同样适用。
def cookies_for_http(cookies: dict[str, str] | None) -> dict[str, str]:
safe: dict[str, str] = {}
if not cookies:
return safe
for name, value in cookies.items():
key = str(name)
val = "" if value is None else str(value)
try:
key.encode("ascii")
val.encode("latin-1")
except UnicodeEncodeError:
continue
if not key:
continue
safe[key] = val
return safe
# 把能进 HTTP 头的 cookie 写进会话。中文展示字段丢掉。
def set_session_cookies(session: requests.Session, cookies: dict[str, str] | None) -> None:
for name, value in cookies_for_http(cookies).items():
session.cookies.set(name, value)
# 把商城响应收成 JSON 对象。剥掉 BOM 和防劫持前缀。不是对象就报平台和查询键。 # 把商城响应收成 JSON 对象。剥掉 BOM 和防劫持前缀。不是对象就报平台和查询键。
def parse_response_json_or_raise( def parse_response_json_or_raise(
response: requests.Response, response: requests.Response,

View File

@ -30,6 +30,7 @@ from mall_config import (
) )
from repo import OracleSelectRepo from repo import OracleSelectRepo
from rules import Candidate from rules import Candidate
from http_util import set_session_cookies
from search_yaoex import YaoexAuth from search_yaoex import YaoexAuth
from search_yaoex import search_candidates as search_yaoex_candidates from search_yaoex import search_candidates as search_yaoex_candidates
from search_ybm import search_candidates as search_ybm_candidates from search_ybm import search_candidates as search_ybm_candidates
@ -160,8 +161,7 @@ def main(argv: list[str] | None = None, repo=None) -> None:
yaoex_cache: dict[str, list[Candidate]] = {} yaoex_cache: dict[str, list[Candidate]] = {}
ybm_cache: dict[str, list[Candidate]] = {} ybm_cache: dict[str, list[Candidate]] = {}
if ybm_cookies: if ybm_cookies:
for name, value in ybm_cookies.items(): set_session_cookies(ybm_session, ybm_cookies)
ybm_session.cookies.set(name, value)
searchers = {} searchers = {}
if yaoex_auth is not None: if yaoex_auth is not None:

View File

@ -35,10 +35,25 @@ ORDER BY fid
_SELECT_PENDING = _SELECT_BODY + "\nFOR UPDATE SKIP LOCKED" _SELECT_PENDING = _SELECT_BODY + "\nFOR UPDATE SKIP LOCKED"
_SELECT_PENDING_LIMIT = f""" # 11.2 不能对内联视图 FOR UPDATE(ORA-02014)。先按 fid 截条,再锁基表。
SELECT * FROM ( _SELECT_PENDING_LIMIT = """
{_SELECT_BODY} SELECT fid, oms_id, bar_code, sale_price, plan_purchase_qty,
) WHERE ROWNUM <= :limit NVL(approval_no, '') AS approval_no,
NVL(goods_name, '') AS goods_name,
NVL(goods_spec, '') AS goods_spec,
NVL(manufacturer, '') AS manufacturer,
NVL(org_code, '') AS org_code,
deal_flag
FROM rpa_sales_goods_mapping_data
WHERE fid IN (
SELECT fid FROM (
SELECT fid
FROM rpa_sales_goods_mapping_data
WHERE deal_flag = 0
OR (deal_flag = 3 AND deal_time < SYSDATE - NUMTODSINTERVAL(:lease_minutes, 'MINUTE'))
ORDER BY fid
) WHERE ROWNUM <= :limit
)
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
""" """
@ -313,7 +328,9 @@ class OracleSelectRepo:
使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。 使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。
更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。 更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。
进程退出后行停在 3,超过租约(deal_time)才能再领。 进程退出后行停在 3,超过租约(deal_time)才能再领。
limit 仍只截这一班领多少;为空表示不限制。没有可领的行时返回空列表。 limit 仍只截这一班领多少;为空表示不限制。有上限时按 fid IN (ROWNUM)
锁基表,避免 11.2 对内联视图 FOR UPDATE 报 ORA-02014。
没有可领的行时返回空列表。
""" """
binds: dict = {"lease_minutes": LEASE_MINUTES} binds: dict = {"lease_minutes": LEASE_MINUTES}
try: try:

View File

@ -6,7 +6,7 @@ from typing import Any
import requests import requests
from http_util import post_json_with_retry from http_util import post_json_with_retry, set_session_cookies
from mall_config import YBM_MALL_NAME, YBM_SEARCH_API_URL, YBM_SORT_STRATEGY from mall_config import YBM_MALL_NAME, YBM_SEARCH_API_URL, YBM_SORT_STRATEGY
from rules import Candidate, normalize_vendor, parse_expiry_months from rules import Candidate, normalize_vendor, parse_expiry_months
from util import normalize_text, parse_float_or_none, parse_int_or_none from util import normalize_text, parse_float_or_none, parse_int_or_none
@ -122,8 +122,7 @@ def search_candidates(
own_session = session or requests.Session() own_session = session or requests.Session()
try: try:
if session is None: if session is None:
for name, value in cookies.items(): set_session_cookies(own_session, cookies)
own_session.cookies.set(name, value)
rows = query_ybm_products(own_session, query_key) rows = query_ybm_products(own_session, query_key)
mapped = map_ybm_products(rows) mapped = map_ybm_products(rows)
finally: finally:

View File

@ -6,7 +6,10 @@ import json
import os import os
import stat import stat
from auth_login import load_secrets, save_cookie_file, save_yaoex_auth import requests
from auth_login import load_cookie_file, load_secrets, save_cookie_file, save_yaoex_auth
from http_util import cookies_for_http, set_session_cookies
from search_yaoex import YaoexAuth from search_yaoex import YaoexAuth
@ -46,6 +49,40 @@ def test_load_secrets_ignores_secrets_json(tmp_path, monkeypatch):
assert secrets["yaoex"]["password"] == "" assert secrets["yaoex"]["password"] == ""
# 写进 requests 会话时中文 cookie 不触发 latin-1 编码错误。
def test_set_session_cookies_skips_unicode_values():
session = requests.Session()
set_session_cookies(
session,
{"SESSION": "abc", "ycenterpriseName": "洁达药业"},
)
assert session.cookies.get("SESSION") == "abc"
# 中文企业名等展示字段丢掉,会话 id 留下。换账号打开浏览器同样适用。
def test_cookies_for_http_keeps_ascii_and_drops_unicode():
got = cookies_for_http(
{
"SESSION": "abc",
"ycenterpriseName": "洁达药业",
"siteName": "总部",
"ycstationName": "广州",
}
)
assert got == {"SESSION": "abc"}
# 写出缓存时不把中文 cookie 写进文件,读回来也能进 HTTP 头。
def test_save_cookie_file_drops_unicode_values(tmp_path):
path = tmp_path / "ybm.json"
save_cookie_file(
{"sid": "1", "ycenterpriseName": "洁达药业"},
path,
)
loaded = load_cookie_file(path)
assert loaded == {"sid": "1"}
# 账号只认环境变量。旁边的 json 即使写了别的用户名也不采用。 # 账号只认环境变量。旁边的 json 即使写了别的用户名也不采用。
def test_load_secrets_reads_env_not_json(tmp_path, monkeypatch): def test_load_secrets_reads_env_not_json(tmp_path, monkeypatch):
monkeypatch.setattr("auth_login.PROJECT_ROOT", tmp_path) monkeypatch.setattr("auth_login.PROJECT_ROOT", tmp_path)

View File

@ -231,7 +231,10 @@ def test_claim_sql_uses_skip_locked_and_lease():
assert "deal_flag = 3" in sql assert "deal_flag = 3" in sql
assert "deal_time" in sql assert "deal_time" in sql
assert "numtodsinterval(:lease_minutes" in sql assert "numtodsinterval(:lease_minutes" in sql
assert "skip locked" in " ".join(_SELECT_PENDING_LIMIT.split()).lower() limit_sql = " ".join(_SELECT_PENDING_LIMIT.split()).lower()
assert "for update skip locked" in limit_sql
assert "rownum <=" in limit_sql
assert "fid in" in limit_sql
assert LEASE_MINUTES == 30 assert LEASE_MINUTES == 30