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`。
不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。货源细单ID 本期不填(弹层按联系人苏文辉过滤)。计划员现阶段廖建琦。
不推接口 4,不改 `push_status`,不碰选品 / 加购 / 合同页面。货源细单ID 按表 3 `goods_source_id` 搜(弹层按总单联系人过滤,空则整组停)。计划员读表 3 `planner_name`。
## 返利中心及之后
@ -158,7 +158,7 @@
| --- | --- | --- | --- |
| `REBATE_FIND` | RPA | 搜到 / 搜不到 | 已拆:登录 → 采购协议 → 按合同号搜 |
| `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` + 总单/细单号 | 已拆:点送审;马上能见号则带回 |
| `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) |
| `M5_MANUAL_CONFIRM` | 采购 | — | 人在 M5 里保存签订 |

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import json
import time
from typing import Any
@ -14,28 +15,25 @@ from cart_match import CartOffer
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
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:
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")
payload = _form(
auth,
{
"skuId": ids["skuId"],
"spuCode": ids["spuCode"],
"sellerCode": ids["sellerCode"],
"amount": int(qty),
},
)
common, payload = _form(auth, ids, qty)
own = session is None
client = session or requests.Session()
try:
response = client.post(
YAOEX_ADD_URL,
params=common,
data=payload,
headers=_headers(),
timeout=30,
@ -50,20 +48,36 @@ def add_yaoex_cart(auth, offer: CartOffer, qty: int, *, session: requests.Sessio
client.close()
# 从报价里取出加车要的 sku、spu 和商家编码。
def _ids(offer: CartOffer) -> dict[str, str]:
# 从报价里取出加车要的 spu 和仓。仓优先 storeId / frontSellerCode,各账号字段名一样。
def _ids(offer: CartOffer) -> dict[str, Any]:
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
if not isinstance(raw, dict):
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 "")
seller = str(raw.get("sellerCode") or raw.get("vendorCode") or raw.get("shopCode") or "")
return {"skuId": sku, "spuCode": spu, "sellerCode": seller}
spu = str(raw.get("spuCode") or offer.sku_id or "").strip()
supply = _supply_id(raw)
return {"spuCode": spu, "supplyId": supply}
# 拼 1 号药城表单:带上登录 token,再叠上加车字段。
def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
payload = {
# 仓号收成网关要的值。纯数字就当整数,避免各账号字符串/数字不一致。
def _supply_id(raw: dict[str, Any]):
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",
"trader": "pc",
"closesignature": "yes",
@ -75,11 +89,22 @@ def _form(auth, extra: dict[str, Any]) -> dict[str, Any]:
"userId": getattr(auth, "user_id", ""),
"buyerCode": getattr(auth, "buyer_code", ""),
}
payload.update(extra)
return payload
item: dict[str, Any] = {
"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]:
return {
"Content-Type": "application/x-www-form-urlencoded",
@ -88,7 +113,12 @@ def _headers() -> dict[str, str]:
"traderName": "yaoex_pc",
"trader": "pc",
"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:
if not isinstance(payload, dict):
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
msg = str(payload.get("msg") or payload.get("message") or payload.get("errorMsg") or "")
code = payload.get("code")
success = payload.get("success")
if success is True or code in (0, "0", 200, "200"):
if payload.get("success") is True or payload.get("code") in (0, "0", 200, "200"):
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)
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="加购失败")
# 从 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 urllib3
import selection_path # noqa: F401
from cart_add import KIND_OK, KIND_SYSTEM, AddOutcome, classify_mall_message
from cart_match import CartOffer
from http_util import set_session_cookies
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 当系统失败。
@ -28,11 +31,10 @@ def add_ybm_cart(
client = session or requests.Session()
try:
if own:
for name, value in cookies.items():
client.cookies.set(name, value)
set_session_cookies(client, cookies)
response = client.post(
YBM_ADD_URL,
json={"skuId": sku, "skuNum": int(qty)},
json=_body(offer, sku, qty),
headers=_headers(),
timeout=30,
verify=False,
@ -48,10 +50,31 @@ def add_ybm_cart(
# 从报价里取药帮忙加车用的 skuId。
def _sku_id(offer: CartOffer) -> str:
raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra
if not isinstance(raw, dict):
raw = {}
return str(raw.get("skuId") or offer.sku_id or "")
raw = _raw(offer)
return str(raw.get("skuId") or raw.get("id") 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:
if not isinstance(payload, dict):
return AddOutcome(kind=KIND_SYSTEM, reason="加购返回异常")
msg = str(payload.get("msg") or payload.get("message") or "")
success = payload.get("success")
code = payload.get("code")
if success is True or code in (0, "0", 200, "200"):
return AddOutcome(kind=KIND_OK)
if msg:
return classify_mall_message(msg)
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
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="加购失败")

View File

@ -19,6 +19,7 @@ from yaoex_login import (
resolve_yaoex_auth,
resolve_ybm_cookies,
)
from http_util import set_session_cookies
PROJECT_ROOT = Path(__file__).resolve().parent
EXPORT_DIR = PROJECT_ROOT / "exports" / "yaoex"
@ -204,8 +205,7 @@ def main_cart(args: argparse.Namespace, repo=None) -> None:
print(f"药帮忙登录失败,本平台中止:{exc}")
if ybm_cookies:
for name, value in ybm_cookies.items():
ybm_session.cookies.set(name, value)
set_session_cookies(ybm_session, ybm_cookies)
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_match import CartOffer
from cart_yaoex import YAOEX_ADD_URL, add_yaoex_cart
@ -53,8 +55,8 @@ def _offer(**overrides) -> CartOffer:
"extra",
{
"raw": {
"skuId": "SKU66",
"spuCode": "SPU66",
"frontSellerCode": 8353,
"sellerCode": "GD01",
}
},
@ -87,13 +89,34 @@ def test_yaoex_add_posts_token_and_qty():
outcome = add_yaoex_cart(FakeAuth(), _offer(), 7, session=session)
assert outcome.kind == KIND_OK
assert session.calls[0]["url"] == YAOEX_ADD_URL
assert session.calls[0]["params"]["token"] == "tok"
payload = session.calls[0]["data"]
assert payload["token"] == "tok"
assert payload["userToken"] == "tok"
assert str(payload["amount"]) == "7"
assert payload["skuId"] == "SKU66"
assert payload["spuCode"] == "SPU66"
assert payload["sellerCode"] == "GD01"
assert payload["addType"] == "1"
assert payload["sourceType"] == 17052
items = json.loads(payload["itemList"])
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():
@ -104,14 +127,32 @@ def test_yaoex_add_classifies_pack_error():
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"}})
outcome = add_ybm_cart({"SESSION": "c1"}, offer, 3, session=session)
assert outcome.kind == KIND_OK
assert session.calls[0]["url"] == YBM_ADD_URL
assert YBM_ADD_URL.endswith("/new-front/cart/change-cart")
body = session.calls[0]["json"]
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():

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
```
接口 3 形态 JSON(不领表,合单规则与班次相同;客商/货品/日期/金额都从报文来):
```bash
uv run python main.py --input-json examples/oms_purchase_contract.json
```
班次(领表 3):
```bash

View File

@ -119,23 +119,23 @@ class ContractFlowContext:
customer_code: str = ""
customer_name: str = ""
contract_category: str = ""
settlement_type: str = "付款"
settlement_type: str = ""
contract_background: str = ""
contract_nature: str = "主合同"
amount_currency: str = "人民币"
amount_type: str = "合同标的金额"
contract_nature: str = ""
amount_currency: str = ""
amount_type: str = ""
amount: str = ""
contract_title: str = ""
contract_end_date: str = ""
contract_sign_date: str = ""
contract_start_date: str = ""
is_major_contract: str = "否"
is_prepayment: str = "否"
is_major_contract: str = ""
is_prepayment: str = ""
belong_year: str = ""
payment_method: str = "账期支付"
payment_days: str = "30"
contract_status: str = "正式合同"
contains_blood_products: str = "否"
payment_method: str = ""
payment_days: str = ""
contract_status: str = ""
contains_blood_products: str = ""
submit_mode: str = "submit"
goods_lines: list[ContractGoodsLine] = field(default_factory=list)
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 errors import NodeError, PdfMergeUnsupported, UnknownRpaError
from group import drafts_from_oms_payload, is_oms_envelope
from pdf_utils import ensure_merged_pdf
from rpa_oracle import load_job_env
CONTRACT_URL = "http://contract-test.gzmpc.com:8080/"
@ -30,13 +32,6 @@ class ContractCreateError(NodeError):
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 补环境变量。
@dataclass(frozen=True)
class ContractCreateInput:
@ -120,15 +115,49 @@ def create_contract_in_contract_system(spec: ContractCreateInput) -> str:
# 把 JSON 字典收成 ContractCreateInput,并挂上页面上下文。
#
# 顶层有 data 数组时按接口 3 信封合单;否则仍按页面草稿字段收。
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)
_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(
job_id=_require(data, "jobId"),
supplier_name=draft.supplier_name or _require(data, "supplierName"),
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")),
attempt_no=int(data.get("attemptNo", 1) or 1),
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,
merged_pdf_output=_opt(data.get("mergedPdfOutput")) or os.getenv("MERGED_PDF_OUTPUT"),
)
ctx = build_flow_context(spec, draft)
return replace(spec, flow_context=ctx)
@ -256,6 +284,36 @@ def _validate_data(data: dict[str, Any], draft: ContractDraft) -> None:
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:
value = _str(data.get(key))
@ -285,16 +343,11 @@ def _clean(value: Any) -> str:
return value
# 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。
# 解析入参:优先按 JSON 字符串,其次按 JSON 文件路径。空入参不回退 examples。
def load_input_data(raw: str) -> dict[str, Any]:
raw = (raw or "").strip()
if not raw:
fallback = default_contract_input_path()
if Path(fallback).is_file():
print("未传入 -i/--input,使用默认联调入参:", fallback, flush=True)
raw = fallback
else:
return {}
raise ContractCreateError("缺少入参 JSON")
try:
obj = json.loads(raw)
if isinstance(obj, dict):
@ -333,6 +386,7 @@ def parse_args() -> argparse.Namespace:
# 命令行入口:JSON 或参数起草一份合同,打印 JSON 结果。
def main() -> int:
load_job_env(Path(__file__).resolve().parent)
args = parse_args()
if args.input_json:

View File

@ -21,23 +21,23 @@ class ContractDraft:
customer_code: str = ""
customer_name: str = ""
contract_category: str = ""
settlement_type: str = "付款"
settlement_type: str = ""
contract_background: str = ""
contract_nature: str = "主合同"
amount_currency: str = "人民币"
amount_type: str = "合同标的金额"
contract_nature: str = ""
amount_currency: str = ""
amount_type: str = ""
amount: str = ""
contract_title: str = ""
contract_end_date: str = ""
contract_sign_date: str = ""
contract_start_date: str = ""
is_major_contract: str = "否"
is_prepayment: str = "否"
is_major_contract: str = ""
is_prepayment: str = ""
belong_year: str = ""
payment_method: str = "账期支付"
payment_days: str = "30"
contract_status: str = "正式合同"
contains_blood_products: str = "否"
payment_method: str = ""
payment_days: str = ""
contract_status: str = ""
contains_blood_products: str = ""
submit_mode: str = "submit"
goods_lines: list[ContractGoodsLine] = 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)
# 把 JSON 字典收成起草草稿。缺的字段用页面上的默认值。
# 把 JSON 字典收成起草草稿。缺的字段留空,页面步骤有值才填。
def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
goods_lines = [
ContractGoodsLine(
@ -78,23 +78,23 @@ def contract_draft_from_dict(data: dict[str, Any]) -> ContractDraft:
customer_code=str(data.get("customerCode", "")),
customer_name=str(data.get("customerName", "")),
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_nature=str(data.get("contractNature", "主合同")),
amount_currency=str(data.get("amountCurrency", "人民币")),
amount_type=str(data.get("amountType", "合同标的金额")),
contract_nature=str(data.get("contractNature", "")),
amount_currency=str(data.get("amountCurrency", "")),
amount_type=str(data.get("amountType", "")),
amount=str(data.get("amount", "")),
contract_title=str(data.get("contractTitle") or data.get("docSubject") or ""),
contract_end_date=str(data.get("contractEndDate", "")),
contract_sign_date=str(data.get("contractSignDate", "")),
contract_start_date=str(data.get("contractStartDate", "")),
is_major_contract=str(data.get("isMajorContract", "否")),
is_prepayment=str(data.get("isPrepayment", "否")),
is_major_contract=str(data.get("isMajorContract", "")),
is_prepayment=str(data.get("isPrepayment", "")),
belong_year=str(data.get("belongYear", "")),
payment_method=str(data.get("paymentMethod", "账期支付")),
payment_days=str(data.get("paymentDays", "30")),
contract_status=str(data.get("contractStatus", "正式合同")),
contains_blood_products=str(data.get("containsBloodProducts", "否")),
payment_method=str(data.get("paymentMethod", "")),
payment_days=str(data.get("paymentDays", "")),
contract_status=str(data.get("contractStatus", "")),
contains_blood_products=str(data.get("containsBloodProducts", "")),
submit_mode=str(data.get("submitMode", "submit")),
goods_lines=goods_lines,
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)
if ctx.contract_background:
browser.fill_first([F["contract_background"]], ctx.contract_background)
# 合同性质默认主合同(页面已勾选),仅在需要从合同时切换
# 合同性质:入参是从合同才改点;空或主合同则不动页面已勾选项。
if ctx.contract_nature and ctx.contract_nature != "主合同":
browser.click_first(['input[name="fdType"][value="01"]'])
_fill_contract_title(browser, ctx)
@ -212,7 +212,7 @@ def check_customer_diligence(browser: BrowserAdapter, ctx: ContractFlowContext)
# 填金额与签订日、开始日、结束日(S08)。
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:
browser.select_xform(X["amount_type"], amount_type)
if ctx.amount:
@ -238,10 +238,12 @@ def fill_contract_amount_and_dates(browser: BrowserAdapter, ctx: ContractFlowCon
)
# 填付款与标志(S10)。这期一律账期,不勾预付款。
# 填付款与标志(S10)。有值才填,不猜账期/预付。
def fill_contract_payment_info(browser: BrowserAdapter, ctx: ContractFlowContext) -> None:
browser.select_xform(X["is_major_contract"], _yes_no(ctx.is_major_contract))
browser.select_xform(X["is_prepayment"], _yes_no(ctx.is_prepayment))
if 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))
if ctx.belong_year:
browser.fill_first([F["belong_year"]], ctx.belong_year)
if ctx.payment_method:

View File

@ -66,7 +66,7 @@ def fill_contract_goods_lines(browser: BrowserAdapter, ctx: ContractFlowContext)
[
code_pair,
("qty", qty, line.quantity),
("price", price, line.unit_price or "1.00"),
("price", price, line.unit_price),
],
browser=browser,
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:
if row < 0:
raise NodeError(f"选择商品后未在明细表找到「{line.goods_code or line.goods_name}」")
qty = str(line.quantity if line.quantity else "")
if 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)

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 collections import OrderedDict
from dataclasses import replace
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
from contract_draft import ContractDraft
from contract_flow_steps import ContractGoodsLine
@ -109,15 +111,121 @@ def draft_from_group(rows: list[PurchaseContractRow]) -> ContractDraft:
source_type="",
customer_code=(first.supplier_mdm_code or "").strip(),
customer_name=first.supplier_name,
contract_category="采购协议",
amount=money4(total),
contract_sign_date=sign,
contract_start_date=iso_date(first.contract_start_date),
contract_end_date=iso_date(first.contract_end_date),
is_prepayment="否",
belong_year=sign[:4],
payment_method="账期支付",
belong_year=sign[:4] if sign else "",
payment_days=str(first.payment_term),
goods_lines=goods,
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 decimal import Decimal
from pathlib import Path
from group import (
draft_from_group,
drafts_from_oms_payload,
group_by_supplier,
is_claimable,
is_oms_envelope,
missing_mdm_reason,
money4,
row_from_oms_item,
)
from repo import PurchaseContractRow
_OMS_EXAMPLE = (
Path(__file__).resolve().parent.parent / "examples" / "oms_purchase_contract.json"
)
def _row(**overrides) -> PurchaseContractRow:
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.amount == "2000.0000"
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 draft.goods_lines[0].goods_code == "QX0100167583"
assert draft.goods_lines[0].operation_code == "QX0100167583"
@ -118,3 +130,68 @@ def test_missing_mdm_reason():
def test_money4():
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 datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from errors import DiligenceError, NodeError
from group import row_from_oms_item
from job import PAGE_FAILED, outcome_from_run, run_batch
from persist import (
KIND_DILIGENCE,
@ -22,6 +25,8 @@ from run_batch import (
production_runner,
)
_TABLE3_SEED = Path(__file__).resolve().parent / "fixtures" / "table3_seed.json"
# 构造一条待录合同的表 3 行。
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
# 从表 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():
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_THICK=1
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
返利班。领表 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
```
验收看控制台 `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):
@ -51,7 +51,7 @@ cd rpa_rebate
uv run python run_import.py
```
验收看控制台 `ACCEPT SUMMARY REBATE_IMPORT` 或 `screenshots/REBATE_IMPORT/ACCEPT.txt`,须货品表有行。需要 `REBATE_ACCEPT_CONTRACT_NO`。表 3 有货品则按行新增;空缺再用 `REBATE_GOODS_ID` / `REBATE_QTY` / `REBATE_TAX_PRICE` / `REBATE_ORDER_NO`(或 `REBATE_IMPORT_LINES` JSON,字段 `goodsId`)。货品编码三个点按货品ID搜。细单备注=订单号,特定补偿=0,承付参数=25。不要用操作码或 MDM 码去搜。
验收看控制台 `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):
@ -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
password: str
# 总单卡片要填的字段。计划员现阶段写死廖建琦,联系人苏文辉。
# 总单卡片要填的字段。计划员、日期、承付参数优先表 3,空再环境变量。
@dataclass(frozen=True)
class RebateHeaderDraft:
planner_name: str
@ -180,8 +180,9 @@ class RebateHeaderDraft:
contact_name: str = ""
supplier_name: str = ""
supplier_id: str = ""
pay_param: str = ""
# 一条细单:货品ID、数量、含税单价、订单号。货源细单ID本期不填。
# 一条细单:货品ID、数量、含税单价、订单号、货源细单ID 来自表 3。
@dataclass(frozen=True)
class RebateImportLine:
goods_code: str
@ -192,6 +193,9 @@ class RebateImportLine:
goods_id: str = ""
tax_rate: str = ""
net_price: str = ""
pay_param: str = ""
protocol_start: str = ""
protocol_end: str = ""
# 一组细单加上当天日期,供新增节点一次加完。
@dataclass(frozen=True)
@ -867,6 +871,35 @@ def _cell(value: object) -> str:
return ""
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。
def _tax_rate_of(raw: object) -> str:
text = _cell(raw)
@ -1309,7 +1342,7 @@ def _close_overlay_named(browser: BrowserAdapter, title: str) -> None:
pass
time.sleep(0.3)
# 列出当前可见的选择弹层标题,排除货源选择 bug 层。
# 列出当前可见的选择弹层标题,排除货源选择 / 货源细单 bug 层。
def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]:
try:
result = browser.execute_script(
@ -1325,7 +1358,8 @@ def _scan_lookup_overlays(browser: BrowserAdapter) -> list[str]:
const cls = String(el.className || '');
if (!vis(el) || cls.indexOf('is--visible') < 0) return false;
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) => {
const title = compact(el.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
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) {
const title = compact(root.querySelector('.vxe-modal--header-title, .vxe-modal--title'));
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;
const row = Array.from(root.querySelectorAll(
'.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):
raise NodeError(f"未选到{title}={code},当前={_read_labeled_input(browser, title)}")
# 是否出现误弹的货源选择层(不是计划员选人层)。
# 是否出现误弹的货源选择 / 货源细单ID 层(不是计划员选人层)。
def _source_choice_visible(browser: BrowserAdapter) -> bool:
try:
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'
));
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 p = String(inp.placeholder || '');
return p.indexOf('计划员') >= 0 || p.indexOf('联系人') >= 0;
@ -1654,7 +1691,7 @@ def _source_choice_visible(browser: BrowserAdapter) -> bool:
hit = False
return hit is True
# 看得见的货源选择是 bug 弹层:点关闭,不要确定、不要 Escape。
# 看得见的货源选择 / 货源细单ID 是 bug 弹层:点关闭,不要确定、不要 Escape。
def _close_source_choice(browser: BrowserAdapter) -> bool:
try:
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'
));
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'}; }
let via = '';
@ -1699,14 +1737,14 @@ def _close_source_choice(browser: BrowserAdapter) -> bool:
return True
return False
# 全局忽略货源选择 bug 弹层。
# 全局忽略货源选择 / 货源细单ID bug 弹层。
def _ignore_source_choice(browser: BrowserAdapter) -> None:
for _ in range(3):
if not _source_choice_visible(browser):
return
_close_source_choice(browser)
# 看见货源选择 bug 弹层就关掉,不把流程停掉。
# 看见货源选择 / 货源细单ID bug 弹层就关掉,不把流程停掉。
def _stop_if_source_choice(browser: BrowserAdapter) -> None:
_ignore_source_choice(browser)
@ -1729,7 +1767,7 @@ def _pick_nth_overlay_row(browser: BrowserAdapter, nth: int, name: str = "") ->
)).filter((el) => {
if (!vis(el)) return false;
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];
for (const root of roots) {
@ -1956,7 +1994,7 @@ def _query_person_and_pick(
# 点三个点打开选人层,按姓名搜并对上供应商名称。
#
# 计划员选廖建琦,联系人选苏文辉。适配器默认关货源层时这里必须先 hold。选不上抛 NodeError。
# 点三个点打开选人层,按姓名搜并对上供应商名称。适配器默认关货源层时这里必须先 hold。选不上抛 NodeError。
def _pick_person(
browser: BrowserAdapter, title: str, name: str, *, nth: int | None = None, supplier_name: str = ""
) -> None:

View File

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

View File

@ -30,6 +30,8 @@ from rebate_common import (
_OPEN_HEADER_ROW,
today_iso,
_cell,
_iso_date,
_digit_text,
_click_toolbar,
_click_header_toolbar,
_native_click_header_toolbar,
@ -244,16 +246,22 @@ wrap.click();
return true;
"""
# 拼一份总单草稿。计划员空则廖建琦,协议起止都是当天,返利合约=否。
# 拼一份总单草稿。计划员、承付参数、日期优先入参,空再回退默认。
def build_header_draft(
*,
planner_name: str,
supplier_code: str,
rebate_unit_code: str,
today: str | None = None,
contact_name: str = "",
pay_param: str = "",
protocol_start: str | None = None,
protocol_end: str | None = None,
) -> RebateHeaderDraft:
planner = (planner_name or "").strip() or PLANNER_DEFAULT
day = today_iso(today)
start = _iso_date(protocol_start) or day
end = _iso_date(protocol_end) or day
return RebateHeaderDraft(
planner_name=planner,
supplier_code=(supplier_code or "").strip(),
@ -261,21 +269,26 @@ def build_header_draft(
rebate_contract=REBATE_CONTRACT_DEFAULT,
settle_mode=SETTLE_MODE_DEFAULT,
pay_mode=PAY_MODE_NEXT_MONTH_N,
protocol_start=day,
protocol_end=day,
contact_name=CONTACT_DEFAULT,
protocol_start=start,
protocol_end=end,
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:
return build_header_draft(
planner_name=os.getenv("REBATE_PLANNER_NAME") or "",
supplier_code=os.getenv("REBATE_SUPPLIER_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,
)
# 总单取表 3 该合同第一行的供应商 MDM。计划员先选廖建琦,联系人苏文辉。
# 总单只从表 3 该合同第一行取:供应商 MDM、计划员、账期、协议起止。缺列整组停。
def header_draft_from_table3(
rows: list[dict] | tuple[dict, ...],
*,
@ -287,14 +300,29 @@ def header_draft_from_table3(
first = packed[0]
supplier = _cell(first.get("supplier_mdm_code") or first.get("supplier_id"))
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(
planner_name=PLANNER_DEFAULT,
planner_name=planner,
supplier_code=supplier,
rebate_unit_code=unit,
pay_param=pay_param,
protocol_start=start,
protocol_end=end,
today=today,
)
return RebateHeaderDraft(
planner_name=PLANNER_DEFAULT,
planner_name=draft.planner_name,
supplier_code=draft.supplier_code,
rebate_unit_code=draft.rebate_unit_code,
rebate_contract=draft.rebate_contract,
@ -302,24 +330,33 @@ def header_draft_from_table3(
pay_mode=draft.pay_mode,
protocol_start=draft.protocol_start,
protocol_end=draft.protocol_end,
contact_name=CONTACT_DEFAULT,
contact_name=draft.contact_name,
supplier_name=_cell(first.get("supplier_name")),
supplier_id=_cell(first.get("supplier_id")),
pay_param=draft.pay_param,
)
# 优先表 3,空缺再退回环境变量。
def header_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateHeaderDraft:
# 班次必须读表 3。页面干跑 require_table=False 才允许环境变量。
def header_draft_for_contract(
contract_no: str, *, today: str | None = None, require_table: bool = False
) -> RebateHeaderDraft:
number = (contract_no or "").strip()
rows: list[dict] = []
load_error = None
if number:
try:
from repo import OracleRebateRepo
rows = OracleRebateRepo.from_env().load_contract_goods(number)
except Exception:
except Exception as exc:
load_error = exc
rows = []
if rows:
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)
# 正文是否已出现总单表单标记。
@ -774,7 +811,7 @@ def _fill_pay_param(browser: BrowserAdapter, title: str, value: str) -> None:
_set_grid_field(browser, title, wanted)
print("pay_param_after=", _read_labeled_input(browser, title), flush=True)
# 按现行字段填总单卡片:供应商三个点、计划员廖建琦、联系人苏文辉、电汇、下月N号结、承付参数 25、协议起止当天。
# 按草稿填总单卡片:供应商、计划员、联系人、电汇、下月N号结、承付参数、协议起止。
def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None:
_ignore_source_choice(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
planner = (draft.planner_name or "").strip() or PLANNER_DEFAULT
contact = (draft.contact_name or "").strip() or CONTACT_DEFAULT
pay_param = (draft.pay_param or "").strip() or PAY_PARAM_DEFAULT
values = {
"供应商编码": draft.supplier_code,
"计划员": planner,
"返利合约": draft.rebate_contract,
"采购结算方式": settle,
"承付模式": draft.pay_mode,
"承付参数": PAY_PARAM_DEFAULT,
"承付参数": pay_param,
"协议开始日期": draft.protocol_start,
"协议结束日期": draft.protocol_end,
"联系人": contact,
@ -853,7 +891,7 @@ def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None
_fill_input(browser, "协议结束日期", HEADER["protocol_end"], draft.protocol_end)
except Exception:
_set_grid_field(browser, "协议结束日期", draft.protocol_end)
_fill_pay_param(browser, "承付参数", PAY_PARAM_DEFAULT)
_fill_pay_param(browser, "承付参数", pay_param)
print(
"card_values_before_save=",
{
@ -863,8 +901,8 @@ def _fill_header_card(browser: BrowserAdapter, draft: RebateHeaderDraft) -> None
},
flush=True,
)
if (_read_labeled_input(browser, "承付参数") or "").strip() != PAY_PARAM_DEFAULT:
raise NodeError("总单承付参数未填成25,不能保存")
if (_read_labeled_input(browser, "承付参数") or "").strip() != pay_param:
raise NodeError(f"总单承付参数未填成{pay_param},不能保存")
# 等到总单卡片打开。超时返回 False。
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)
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, "承付参数", 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.planner_name or PLANNER_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, "承付参数")
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)
saved = save_rebate_header(browser, contract_no=contract_no)
_HEADER_ACCEPT.note(
"S08",
"保存",
{"saved": "yes", "payParam": pay_before},
ok=(pay_before or "").strip() == PAY_PARAM_DEFAULT,
ok=(pay_before or "").strip() == wanted_pay,
)
_confirm_header_card(browser)
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,
year_end_iso,
_cell,
_iso_date,
_digit_text,
_tax_rate_of,
_untaxed_price,
_click_text,
@ -44,11 +46,13 @@ from rebate_common import (
_read_detail_toast,
_close_header_card,
_close_detail_card,
_ignore_source_choice,
_hold_source_choice,
)
_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, ...]:
lines: list[RebateImportLine] = []
for row in rows:
@ -69,25 +73,36 @@ def import_lines_from_table3(rows: list[dict] | tuple[dict, ...]) -> tuple[Rebat
goods_id=goods_id,
tax_rate=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:
raise NodeError("表 3 没有货品行,不能加细单")
return tuple(lines)
# 优先表 3 货品行,空缺再退回环境变量。
def import_draft_for_contract(contract_no: str, *, today: str | None = None) -> RebateImportDraft:
# 班次必须读表 3。页面干跑 require_table=False 才允许环境变量 / JSON。
def import_draft_for_contract(
contract_no: str, *, today: str | None = None, require_table: bool = False
) -> RebateImportDraft:
number = (contract_no or "").strip()
rows: list[dict] = []
load_error = None
if number:
try:
from repo import OracleRebateRepo
rows = OracleRebateRepo.from_env().load_contract_goods(number)
except Exception:
except Exception as exc:
load_error = exc
rows = []
if rows:
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)
IMPORT = {
@ -208,7 +223,7 @@ def build_import_rows(
"税率": TAX_RATE_DEFAULT,
"关税税率": TAX_RATE_DEFAULT,
"承付模式": PAY_MODE_CODE,
"承付参数": PAY_PARAM_DEFAULT,
"承付参数": _cell(line.pay_param) or PAY_PARAM_DEFAULT,
"细单开始日期": day,
"细单结束日期": end,
"采购结算方式": SETTLE_CODE,
@ -234,6 +249,8 @@ def build_import_draft(
raise NodeError("细单货品ID和订单号必须用订单中心的")
if not _cell(line.qty) or not _cell(line.tax_price):
raise NodeError("细单数量和含税协议单价必须用订单中心的")
if not _cell(line.source_line_id):
raise NodeError("细单货源细单ID必须用订单中心的 goodsSourceId")
return RebateImportDraft(lines=packed, today=today_iso(today))
# 把 camelCase / snake_case 字典收成 RebateImportLine。无税单价按含税和税率算。
@ -257,6 +274,9 @@ def _line_from_mapping(item: dict) -> RebateImportLine:
goods_id=goods_id,
tax_rate=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 空缺时干跑用。
@ -706,6 +726,7 @@ def _wait_goods_id_field(browser: BrowserAdapter, timeout: float = 8) -> bool:
# 打开货品选择弹窗。
def _open_goods_picker(browser: BrowserAdapter) -> bool:
_ignore_source_choice(browser)
opened = browser.execute_script(
"""
/* OPEN_GOODS_PICKER */
@ -835,6 +856,144 @@ def _pick_goods_id_in_dialog(browser: BrowserAdapter, goods_id: str) -> bool:
time.sleep(0.4)
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:
try:
@ -857,9 +1016,10 @@ def _goods_table_rows(browser: BrowserAdapter) -> list[str]:
/* GOODS_TABLE_ROWS */
function compact(el) { return ((el && (el.innerText || '')) || '').replace(/\\s+/g, ' ').trim(); }
function vis(el) { return !!(el && (el.offsetWidth || el.offsetHeight)); }
const table = Array.from(document.querySelectorAll('.single-table')).find((el) => {
return vis(el) && compact(el).indexOf('协议细单ID') >= 0;
});
const header = document.querySelector('.table-show .single-table');
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 []; }
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」,不要点总单卡片确认。
def _confirm_detail_card(browser: BrowserAdapter) -> bool:
_ignore_source_choice(browser)
before = _detail_card_pager(browser)
clicked = browser.execute_script(
"""
@ -1008,24 +1169,27 @@ def _type_detail_field(browser: BrowserAdapter, title: str, value: str) -> bool:
return True
try:
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"]')
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(
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
except Exception:
return False
# 填数量、含税单价、无税单价、备注=订单号、特定/进货补偿=0、承付模式和参数 25、结算电汇。
# 填数量、含税单价、无税单价、备注=订单号、特定/进货补偿=0、承付模式和表 3 账期、结算电汇。
def _fill_last_detail_qty_price(browser: BrowserAdapter, line: RebateImportLine) -> None:
qty = _cell(line.qty)
price = _cell(line.tax_price)
remark = _cell(line.order_no)
tax = _tax_rate_of(line.tax_rate)
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(
"""
/* FILL_DETAIL_CELLS */
@ -1072,7 +1236,7 @@ def _fill_last_detail_qty_price(browser: BrowserAdapter, line: RebateImportLine)
remark,
COMPENSATION_DEFAULT,
tax,
PAY_PARAM_DEFAULT,
pay_param,
net,
)
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, "含税协议单价", price)
_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, "特定补偿预估值", 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):
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):
raise NodeError("细单结算未精确点到电汇")
_ignore_source_choice(browser)
# 货品表是否已有该货品ID。重跑时跳过已存在的行。
def _goods_id_already_in_table(browser: BrowserAdapter, gid: str) -> bool:
@ -1120,6 +1289,16 @@ def add_detail_lines_by_goods_id(
created = 0
_close_header_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:
gid = goods_search_id(line)
if not gid:
@ -1130,9 +1309,13 @@ def add_detail_lines_by_goods_id(
continue
if not _click_add_detail(browser):
raise NodeError("选中总单后未出现货品新增")
_ignore_source_choice(browser)
if not _pick_goods_id_in_dialog(browser, gid):
raise NodeError(f"新增后未按货品ID搜到 {gid},停下确认搜框是不是货品ID")
_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:
print(
"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 rebate_finder import RebateFinder, context_from_env
from rebate_flow_steps import (
header_draft_for_contract,
import_draft_for_contract,
run_header_flow,
run_import_flow,
@ -62,12 +63,16 @@ def production_runner(browser, ctx, finder: RebateFinder, group: RebateGroup) ->
# 填总单并返回协议总单号。没拿到号后面就不送审。
def header(number: str) -> str:
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 货品新增细单。有失败行时这一组不算录入成功。
def importer(number: str):
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。
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 共享类或其子类。
def test_adapter_is_shared_or_subclass():
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 apa_browser_adapter import chrome_debug_port, chrome_session_flow, resolve_chrome_profile_dir
from errors import NodeError
from rebate_flow_steps import (
CONTACT_DEFAULT,
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():
draft = header_draft_from_table3(
[
@ -181,14 +183,20 @@ def test_header_draft_from_table3_uses_mdm_and_planner():
"qty": "10",
"unit_price": "10",
"oms_id": "20260928000003",
"payment_term": 90,
"contract_start_date": "2026-10-25",
"contract_end_date": "2027-10-24",
}
],
today="2026-10-12",
)
assert draft.supplier_code == "DW0100046361"
assert draft.planner_name == PLANNER_DEFAULT
assert draft.planner_name == "李四"
assert draft.rebate_unit_code == "901"
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(
[
{
@ -197,6 +205,9 @@ def test_header_draft_from_table3_uses_mdm_and_planner():
"unit_price": "10",
"oms_id": "20260928000003",
"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].tax_rate == "0.13"
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号结、协议起止当天、返利合约否。
@ -217,7 +248,7 @@ def test_header_defaults_follow_mapping_not_scheme_55():
assert draft.settle_mode == "否"
# 计划员名为空时回退廖建琦,联系人苏文辉。
# 计划员名为空时回退默认,联系人表 3 无列仍用苏文辉。
def test_header_draft_defaults_planner():
draft = build_header_draft(planner_name="", supplier_code="S1", rebate_unit_code="901")
assert draft.planner_name == PLANNER_DEFAULT

View File

@ -15,6 +15,7 @@ from rebate_flow_steps import (
RebateImportLine,
add_detail_lines_by_goods_id,
build_import_draft,
import_draft_for_contract,
build_import_rows,
fill_import_excel,
run_import_flow,
@ -134,6 +135,16 @@ class FakeBrowser:
return []
if "PICK_GOODS_ROW" in text:
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:
return False
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。
def test_fill_excel_writes_goods_id_into_goods_id_column(tmp_path):
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",
order_no=order_no,
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 则跳过新增。
def test_add_detail_skips_goods_already_in_table():
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
# 细单填完后关掉货源细单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():
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`,三个点搜 |
| 总单 | 计划员 | 廖建琦(选择框,不要选 ID) |
| 总单 | 联系人 | 苏文辉 |
| 总单 | 计划员 | 表 3 `planner_name`(选择框,不要选 ID) |
| 总单 | 联系人 | 写死苏文辉(表 3 无此列) |
| 总单 | 返利合约 | 否 |
| 总单 | 采购结算方式 | 电汇(不要点电汇—非直联) |
| 总单 | 承付模式 | 下月N号结 |
| 总单 | 承付参数 | **25**(数字框键盘输入) |
| 总单 | 协议开始/结束 | 当天 |
| 总单 | 承付参数 | 表 3 `payment_term` |
| 总单 | 协议开始/结束 | 表 3 `contract_start_date` / `contract_end_date` |
| 细单 | 货品 | 表 3 `goods_id` |
| 细单 | 数量 / 含税单价 | 表 3 `qty` / `unit_price` |
| 细单 | 无税单价 | `含税 / (1+税率)` |
| 细单 | 承付模式 | 下月N号结 |
| 细单 | 承付参数 | **25** |
| 细单 | 承付参数 | 表 3 `payment_term` |
| 细单 | 采购结算方式 | 电汇 |
| 细单 | 备注 | `oms_id` |
| 细单 | 特定补偿 / 进货补偿 | 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。
@ -44,16 +44,16 @@
| --- | --- | --- |
| 细单路径 | Agents / README 写「下模板 → 填 Excel → 上传」 | 页面「新增 + 货品ID搜」。`run_import_flow` 调 `add_detail_lines_by_goods_id` |
| `返利流程(1).md` | 已知/未知草稿,Excel 与页面双轨 | 废稿,指向 `返利流程.md` |
| 计划员 | 映射表 / 口径:订单中心 `planner_name` | 选择框「廖建琦」,后期改回 |
| 联系人 | 映射表无 | 「苏文辉」。货源弹层按此人过滤 |
| 计划员 | 映射表 / 口径:订单中心 `planner_name` | 表 3 `planner_name` |
| 联系人 | 映射表无 | 写死「苏文辉」。货源弹层按此人过滤 |
| 采购结算方式 | 映射表总单默认「否」、细单代码 `3` | 总单、细单都精确点「电汇」 |
| 承付参数 | 「不要填 25 / 以销售定结」 | 总单、细单都填 **25** |
| 承付参数 | 「不要填 25 / 以销售定结」 | 表 3 `payment_term` |
| 关税税率 | 映射表 `0.13` | 不填 |
| 税率 | 映射表固定 `0.13` | 表 3 `purchase_tax_rate`,空则 `0.13` |
| 细单日期 | 映射表开始当天、结束年底 | 先用页面默认,报错再看 |
| 总单保存 | 先卡片确认再保存 | 先总单工具栏保存(卡仍在编辑),再确认 |
| 细单确认 | 容易点到总单「确认」 | 只点货品卡片「确认 Ctrl + Enter」,确认后核对货品表有该 `goods_id` |
| 计划员弹层 | 适配器看见「货源选择」就关 | 计划员三点打开的货源层要按住再选廖建琦 |
| 计划员弹层 | 适配器看见「货源选择」就关 | 计划员三点打开的货源层要按住再按表 3 姓名搜 |
| 货品来源 | 只按表 3 `contract_no` | `contract_no` 空时用表 4 `oms_id` 对表 3 |
| 落表 | (曾按行拆成功/失败,已撤回) | 同一合同一组一个结果 |
@ -62,8 +62,8 @@
| 项 | 落点 |
| --- | --- |
| 班次入口 | `run_batch.py`:查号 → 总单 → 细单新增 → 送审 → `save_group` |
| 总单数字框键盘填 25 | `_fill_pay_param` |
| 细单键盘填 25 | `_fill_last_detail_qty_price` / `_type_detail_field` |
| 总单数字框键盘填账期 | `_fill_pay_param` |
| 细单键盘填账期 | `_fill_last_detail_qty_price` / `_type_detail_field` |
| 货品表核对 | `_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。
## 下一会话(未做)
正式班次只读表 3,缺列整组停,不用环境变量顶。`run_header.py` / `run_import.py` 页面干跑才允许环境变量 / `REBATE_IMPORT_LINES` JSON。
1. **货源细单ID**:三个点搜表 3 `goods_source_id`。弹层只出总单联系人(现苏文辉)名下货源,不是全局搜号。空 → 整组停、`KIND_FAIL`、不送审。不要按行一条成功两条失败。
2. **计划员**:现阶段「廖建琦」;改回表 3 `planner_name`。
联系人、返利合约、电汇、承付模式、补偿 0 待业务确认,清单 `待确认字段.md`。
口 2 / 口 4 HTTP、M5、药帮忙支付不在本包,见根 `README.md`「本期未做」。

View File

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

View File

@ -12,7 +12,7 @@ import time
from pathlib import Path
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 (
YAOEX_HOME_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]:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
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。
@ -97,10 +98,13 @@ def save_yaoex_auth(auth: YaoexAuth, path: Path = DEFAULT_YAOEX_AUTH_PATH) -> No
_restrict_file_mode(path)
# 写出药帮忙 cookie 缓存,权限 0o600。
# 写出药帮忙 cookie 缓存,权限 0o600。中文展示字段不写进文件。
def save_cookie_file(cookies: dict[str, str], path: Path = DEFAULT_YBM_COOKIE_PATH) -> None:
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)
@ -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)
# 打开药帮忙,登录后带回全部 cookie。
# 打开药帮忙,登录后带回能进 HTTP 头的 cookie。
def _playwright_login_ybm(username: str, password: str, runtime: BrowserRuntime) -> dict[str, str]:
context, page = runtime.new_page()
_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)
print("药帮忙窗口应已打开首页。")
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 时不关浏览器。

View File

@ -22,6 +22,34 @@ def parse_cookie_value(raw_value: str | None) -> str:
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 和防劫持前缀。不是对象就报平台和查询键。
def parse_response_json_or_raise(
response: requests.Response,

View File

@ -30,6 +30,7 @@ from mall_config import (
)
from repo import OracleSelectRepo
from rules import Candidate
from http_util import set_session_cookies
from search_yaoex import YaoexAuth
from search_yaoex import search_candidates as search_yaoex_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]] = {}
ybm_cache: dict[str, list[Candidate]] = {}
if ybm_cookies:
for name, value in ybm_cookies.items():
ybm_session.cookies.set(name, value)
set_session_cookies(ybm_session, ybm_cookies)
searchers = {}
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_LIMIT = f"""
SELECT * FROM (
{_SELECT_BODY}
) WHERE ROWNUM <= :limit
# 11.2 不能对内联视图 FOR UPDATE(ORA-02014)。先按 fid 截条,再锁基表。
_SELECT_PENDING_LIMIT = """
SELECT fid, oms_id, bar_code, sale_price, plan_purchase_qty,
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
"""
@ -313,7 +328,9 @@ class OracleSelectRepo:
使用 FOR UPDATE SKIP LOCKED,两个班次不会领到同一行。
更新按每段 500 个 fid 执行,同一事务,任一段失败整笔回滚。
进程退出后行停在 3,超过租约(deal_time)才能再领。
limit 仍只截这一班领多少;为空表示不限制。没有可领的行时返回空列表。
limit 仍只截这一班领多少;为空表示不限制。有上限时按 fid IN (ROWNUM)
锁基表,避免 11.2 对内联视图 FOR UPDATE 报 ORA-02014。
没有可领的行时返回空列表。
"""
binds: dict = {"lease_minutes": LEASE_MINUTES}
try:

View File

@ -6,7 +6,7 @@ from typing import Any
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 rules import Candidate, normalize_vendor, parse_expiry_months
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()
try:
if session is None:
for name, value in cookies.items():
own_session.cookies.set(name, value)
set_session_cookies(own_session, cookies)
rows = query_ybm_products(own_session, query_key)
mapped = map_ybm_products(rows)
finally:

View File

@ -6,7 +6,10 @@ import json
import os
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
@ -46,6 +49,40 @@ def test_load_secrets_ignores_secrets_json(tmp_path, monkeypatch):
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 即使写了别的用户名也不采用。
def test_load_secrets_reads_env_not_json(tmp_path, monkeypatch):
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_time" 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