diff --git a/AGENTS.md b/AGENTS.md index 8696a02..309e729 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 里保存签订 | diff --git a/README.md b/README.md index aa31dbe..1883bae 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,6 @@ | 项 | 口径 | | --- | --- | -| 返利货源细单ID | 弹层按总单联系人(现苏文辉)过滤;本期不填,见 `rpa_rebate/返利流程.md` | -| 返利计划员 | 现阶段「廖建琦」,后期改回表 3 `planner_name` | | 口 2 / 口 4 HTTP | path 空,只组包不发 | | 药帮忙支付 | 只做了 1 号药城 | | M5 | 录单停、不保存;收货通知单号人填 | diff --git a/docs/RPA订单中心接口开发口径_20260930.md b/docs/RPA订单中心接口开发口径_20260930.md index 1897cd5..3c16e28 100644 --- a/docs/RPA订单中心接口开发口径_20260930.md +++ b/docs/RPA订单中心接口开发口径_20260930.md @@ -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 协议「成功」 diff --git a/rpa_browser/adapter.py b/rpa_browser/adapter.py index 33c5380..39303a1 100644 --- a/rpa_browser/adapter.py +++ b/rpa_browser/adapter.py @@ -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) => { diff --git a/rpa_cart/cart_search.py b/rpa_cart/cart_search.py index 7510344..dcea800 100644 --- a/rpa_cart/cart_search.py +++ b/rpa_cart/cart_search.py @@ -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 = [ diff --git a/rpa_cart/cart_yaoex.py b/rpa_cart/cart_yaoex.py index 2cba6b7..507cfaf 100644 --- a/rpa_cart/cart_yaoex.py +++ b/rpa_cart/cart_yaoex.py @@ -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() diff --git a/rpa_cart/cart_ybm.py b/rpa_cart/cart_ybm.py index 2ca5a6e..63994fc 100644 --- a/rpa_cart/cart_ybm.py +++ b/rpa_cart/cart_ybm.py @@ -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="加购失败") diff --git a/rpa_cart/main.py b/rpa_cart/main.py index b4c1711..5cbad19 100644 --- a/rpa_cart/main.py +++ b/rpa_cart/main.py @@ -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: diff --git a/rpa_cart/tests/test_cart_add.py b/rpa_cart/tests/test_cart_add.py index e195883..d0433b3 100644 --- a/rpa_cart/tests/test_cart_add.py +++ b/rpa_cart/tests/test_cart_add.py @@ -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(): diff --git a/rpa_contract/README.md b/rpa_contract/README.md index 0195c61..9ee7e17 100644 --- a/rpa_contract/README.md +++ b/rpa_contract/README.md @@ -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 diff --git a/rpa_contract/contract_common.py b/rpa_contract/contract_common.py index 7d41098..3eca3f2 100644 --- a/rpa_contract/contract_common.py +++ b/rpa_contract/contract_common.py @@ -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 diff --git a/rpa_contract/contract_create_node.py b/rpa_contract/contract_create_node.py index 039f042..287058e 100644 --- a/rpa_contract/contract_create_node.py +++ b/rpa_contract/contract_create_node.py @@ -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: diff --git a/rpa_contract/contract_draft.py b/rpa_contract/contract_draft.py index dab7a9c..075bdc5 100644 --- a/rpa_contract/contract_draft.py +++ b/rpa_contract/contract_draft.py @@ -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", [])], diff --git a/rpa_contract/contract_form.py b/rpa_contract/contract_form.py index af4b21a..0a34dcd 100644 --- a/rpa_contract/contract_form.py +++ b/rpa_contract/contract_form.py @@ -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: diff --git a/rpa_contract/contract_goods.py b/rpa_contract/contract_goods.py index 13c0f48..e134af0 100644 --- a/rpa_contract/contract_goods.py +++ b/rpa_contract/contract_goods.py @@ -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) diff --git a/rpa_contract/examples/oms_purchase_contract.json b/rpa_contract/examples/oms_purchase_contract.json new file mode 100644 index 0000000..68fee6f --- /dev/null +++ b/rpa_contract/examples/oms_purchase_contract.json @@ -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" + } + ] +} diff --git a/rpa_contract/group.py b/rpa_contract/group.py index 247cad7..e8a1c9d 100644 --- a/rpa_contract/group.py +++ b/rpa_contract/group.py @@ -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)) diff --git a/rpa_contract/tests/fixtures/table3_seed.json b/rpa_contract/tests/fixtures/table3_seed.json new file mode 100644 index 0000000..94825d2 --- /dev/null +++ b/rpa_contract/tests/fixtures/table3_seed.json @@ -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" + } + ] +} diff --git a/rpa_contract/tests/test_group.py b/rpa_contract/tests/test_group.py index 045c114..2652ec3 100644 --- a/rpa_contract/tests/test_group.py +++ b/rpa_contract/tests/test_group.py @@ -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 diff --git a/rpa_contract/tests/test_job.py b/rpa_contract/tests/test_job.py index a581807..bbb8345 100644 --- a/rpa_contract/tests/test_job.py +++ b/rpa_contract/tests/test_job.py @@ -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( diff --git a/rpa_contract/tests/test_oms_input.py b/rpa_contract/tests/test_oms_input.py new file mode 100644 index 0000000..ee0dc69 --- /dev/null +++ b/rpa_contract/tests/test_oms_input.py @@ -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("") diff --git a/rpa_oms/.env.example b/rpa_oms/.env.example index ad1e271..f1e0ed2 100644 --- a/rpa_oms/.env.example +++ b/rpa_oms/.env.example @@ -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 diff --git a/rpa_oms/examples/purchase_contract_from_t1_t2.json b/rpa_oms/examples/purchase_contract_from_t1_t2.json new file mode 100644 index 0000000..2d22923 --- /dev/null +++ b/rpa_oms/examples/purchase_contract_from_t1_t2.json @@ -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" + } + ] +} diff --git a/rpa_rebate/README.md b/rpa_rebate/README.md index c5302b3..9a1a188 100644 --- a/rpa_rebate/README.md +++ b/rpa_rebate/README.md @@ -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` 仍是对外入口。 diff --git a/rpa_rebate/rebate_common.py b/rpa_rebate/rebate_common.py index 71138d6..fca9c79 100644 --- a/rpa_rebate/rebate_common.py +++ b/rpa_rebate/rebate_common.py @@ -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: diff --git a/rpa_rebate/rebate_flow_steps.py b/rpa_rebate/rebate_flow_steps.py index 8d2415e..e4bab72 100644 --- a/rpa_rebate/rebate_flow_steps.py +++ b/rpa_rebate/rebate_flow_steps.py @@ -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, diff --git a/rpa_rebate/rebate_header.py b/rpa_rebate/rebate_header.py index 1f5f354..6cdbfad 100644 --- a/rpa_rebate/rebate_header.py +++ b/rpa_rebate/rebate_header.py @@ -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) diff --git a/rpa_rebate/rebate_import.py b/rpa_rebate/rebate_import.py index a269235..47b1ed1 100644 --- a/rpa_rebate/rebate_import.py +++ b/rpa_rebate/rebate_import.py @@ -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=", diff --git a/rpa_rebate/run_batch.py b/rpa_rebate/run_batch.py index dfb49c0..91516b8 100644 --- a/rpa_rebate/run_batch.py +++ b/rpa_rebate/run_batch.py @@ -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): diff --git a/rpa_rebate/tests/test_apa_browser_adapter.py b/rpa_rebate/tests/test_apa_browser_adapter.py index 79d8f33..af04a8e 100644 --- a/rpa_rebate/tests/test_apa_browser_adapter.py +++ b/rpa_rebate/tests/test_apa_browser_adapter.py @@ -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 diff --git a/rpa_rebate/tests/test_header.py b/rpa_rebate/tests/test_header.py index fc9aa8d..ba4dd0d 100644 --- a/rpa_rebate/tests/test_header.py +++ b/rpa_rebate/tests/test_header.py @@ -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 diff --git a/rpa_rebate/tests/test_import.py b/rpa_rebate/tests/test_import.py index b379338..2755e7b 100644 --- a/rpa_rebate/tests/test_import.py +++ b/rpa_rebate/tests/test_import.py @@ -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"]) diff --git a/rpa_rebate/待确认字段.md b/rpa_rebate/待确认字段.md new file mode 100644 index 0000000..1fcac2a --- /dev/null +++ b/rpa_rebate/待确认字段.md @@ -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`)。缺列整组停。 + +--- + +确认结果记在本文件底部,再改代码。 diff --git a/rpa_rebate/现行模式.md b/rpa_rebate/现行模式.md index 98c2ec7..ea61495 100644 --- a/rpa_rebate/现行模式.md +++ b/rpa_rebate/现行模式.md @@ -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`「本期未做」。 diff --git a/rpa_rebate/返利流程.md b/rpa_rebate/返利流程.md index 4aabc5f..3a43b9c 100644 --- a/rpa_rebate/返利流程.md +++ b/rpa_rebate/返利流程.md @@ -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、药帮忙支付 | 不做 | 不在本包 | diff --git a/rpa_selection/auth_login.py b/rpa_selection/auth_login.py index f33d245..22821b5 100644 --- a/rpa_selection/auth_login.py +++ b/rpa_selection/auth_login.py @@ -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 时不关浏览器。 diff --git a/rpa_selection/http_util.py b/rpa_selection/http_util.py index 64530ca..bd5a788 100644 --- a/rpa_selection/http_util.py +++ b/rpa_selection/http_util.py @@ -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, diff --git a/rpa_selection/main.py b/rpa_selection/main.py index afd0624..2f47aee 100644 --- a/rpa_selection/main.py +++ b/rpa_selection/main.py @@ -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: diff --git a/rpa_selection/repo.py b/rpa_selection/repo.py index de2c0d5..ef74e16 100644 --- a/rpa_selection/repo.py +++ b/rpa_selection/repo.py @@ -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: diff --git a/rpa_selection/search_ybm.py b/rpa_selection/search_ybm.py index b4c893d..6d4dd73 100644 --- a/rpa_selection/search_ybm.py +++ b/rpa_selection/search_ybm.py @@ -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: diff --git a/rpa_selection/tests/test_auth_login.py b/rpa_selection/tests/test_auth_login.py index 026e593..9ea8c0b 100644 --- a/rpa_selection/tests/test_auth_login.py +++ b/rpa_selection/tests/test_auth_login.py @@ -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) diff --git a/rpa_selection/tests/test_oracle_repo.py b/rpa_selection/tests/test_oracle_repo.py index 1039fab..70463c6 100644 --- a/rpa_selection/tests/test_oracle_repo.py +++ b/rpa_selection/tests/test_oracle_repo.py @@ -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