diff --git a/AGENTS.md b/AGENTS.md index ee1ff3a..48c0947 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AutoRpa -洁达 RPA。当前主线是**订单中心入站**(`rpa_oms`)和**选品班次**(`rpa_selection/`):收件落 Oracle,请求里不跑选品;选品作业另起进程捞 `deal_flag=0`。合同页面在 `rpa_contract/`;加购 `rpa_cart/`、返利 `rpa_rebate/` 仍是占位。根上不再有 `选品流程/`、`state_center/`、`rpa_yisaiqi/`。 +洁达 RPA。当前主线是**订单中心入站**(`rpa_oms`)、**选品班次**(`rpa_selection/`)和**加购班次**(`rpa_cart/`):收件落 Oracle,请求里不跑选品;选品作业另起进程捞 `deal_flag=0`;加购另起进程领表 2 成功行。合同页面在 `rpa_contract/`;返利 `rpa_rebate/` 仍是占位。根上不再有 `选品流程/`、`state_center/`、`rpa_yisaiqi/`。 智力用在**定落点、想清楚对错**;速度和 token 靠下面这张地图定点打开,禁止用扫仓代替思考。改对应了,反工和消耗都会少。 @@ -31,7 +31,8 @@ | 节点入口 | `rpa_contract/contract_create_node.py`、`main.py` | 读到正式合同号 | | 浏览器适配 | `rpa_contract/apa_browser_adapter.py` | `rpa_contract/tests/test_apa_browser_adapter.py::点名` | | 文字验收 / 截图策略 | `rpa_contract/accept.py` | 只读 `ACCEPT.txt`,禁止读 png | -| 加购 / 支付回传 | `rpa_cart/README.md` | 加购未做;1 号药城支付按 Excel 落表,不对加购行 | +| 加购班次 | `rpa_cart/cart_job.py`、`cart_repo.py`、`cart_match.py` | `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` | +| 支付回传 | `rpa_cart/job.py`、`settle.py` | 1 号药城支付按 Excel 落表,不对加购行 | | 返利班 | `rpa_rebate/README.md` | 占位,未实现 | | 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — | | 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` | @@ -83,6 +84,22 @@ 验收:`cd rpa_selection` → `uv run pytest tests/test_pipeline.py::test_split_across_malls_is_success -q`(杭州 3 + 广东 7 = 计划 10)。 +## 加购落点 + +班次作业在 `rpa_cart/`,不在选品请求里跑。领表 2 `operate_type=1 AND status=1 AND push_status=0 AND update_user_name=系统管理员`。登录 1 号药城 / 药帮忙,按条形码再搜,对上 `supplier_name`,按行加车。 + +| 步 | 函数 | 要点 | +| --- | --- | --- | +| 领取 | `claim_pending` | 盖 `加购中`;崩溃停在这戳,改回 `系统管理员` 再跑 | +| 对仓 | `match_offer` | 条形码结果对商家全称;同仓留低价 | +| 加车 | `run_cart_batch` + 平台 `add_*_cart` | 改数量加入购物车。无 `sku_id`,必须再搜 | +| 成功 | `mark_success` | 不插行,`update_user_name=加购班`,`status` 仍 1 | +| 失败 | `mark_fail` | 该行 `status=2` + 原因。登录挂写系统故障;库存/中包装/对不上仓写业务原因 | + +同一 `omsId` 拆两仓要加齐;一行失败只改那一行。不推接口 2,不清空购物车,不碰合同、表 1。接口 2 仍捞全部 `push_status=0`,加购班跑完之前不要组装。 + +验收:`cd rpa_cart` → `uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q`(杭州 3 + 广东 7,不插新行)。 + ## CONTRACT_CREATE 落点 页面脚本在 `rpa_contract/`。`run_contract_create_flow` 串起来;修一步只打开对应函数。从表 3 合单、写表 4 尚未接。 @@ -124,6 +141,7 @@ - 接口2:点名 `rpa_oms/tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。 - 接口3:点名 `rpa_oms/tests/test_purchase_contract_receive.py` 或 `test_oracle_contract_repo.py`。 - 选品:点名 `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` 或 `test_rules.py::test_margin_exactly_4_percent_passes`。 +- 加购:点名 `rpa_cart/tests/test_cart_job.py::test_job_adds_split_rows_without_insert` 或 `test_job_marks_out_of_stock`。 - 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。 - 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。 diff --git a/rpa_cart/README.md b/rpa_cart/README.md index 5032d92..c4b0365 100644 --- a/rpa_cart/README.md +++ b/rpa_cart/README.md @@ -4,9 +4,37 @@ | 班次 | 状态 | | --- | --- | -| 加购 `operateType=1` | 未做。选品已落表 2 | +| 加购 `operateType=1` | 领表 2 选品成功行,按仓加进购物车。成功盖章,失败改 `status=2` | | 支付回传 `operateType=2` | **1 号药城已做落表**。不推接口 2 | +## 加购 + +选品已经把「哪个仓、加几件」写成表 2:`operate_type=1`、`status=1`、`push_status=0`、`update_user_name=系统管理员`。本班读这些行,登录 1 号药城 / 药帮忙,按条形码再搜一次,对上 `supplier_name`,改数量,加入购物车。同一 `omsId` 拆两仓、两平台要加齐。 + +成功不插新行,只把 `update_user_name` 改成 `加购班`。加不进去(登录挂、库存没了、中包装加不进、对不上仓)改该行 `status=2`,写失败原因。领取时先盖 `加购中`;中途崩溃停在这一戳,改回 `系统管理员` 再跑。 + +不推订单中心,不清空购物车,不碰合同。表 2 没有 `sku_id`,加购必须再搜条形码。选完到加购之间库存可能变,已知。 + +```bash +cd rpa_cart +uv sync --python 3.12 +uv run python main.py cart +uv run python main.py cart --limit 20 +uv run python main.py cart --platform yaoex +``` + +账号用 `rpa_selection/.env` 的 `YAOEX_*` / `YBM_*`,库用 `RPA_ORACLE_*`。 + +验收(内存、不连库): + +```bash +uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q +``` + +杭州 3 + 广东 7 两行都加车,表 2 仍两行。失败点名 `test_job_marks_out_of_stock`、`test_job_marks_login_fail_as_system_error`。 + +接口 2 仍捞全部 `push_status=0`。加购班跑完之前不要组装推送。 + ## 1 号药城支付落表 从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。 @@ -31,7 +59,7 @@ uv run pytest -v ## Windows 真库 -Mac 打不到 `192.168.12.244`,**插入表 2 在 Windows 上测**。导出/解析在 Mac 已经跑过,Win 上要测的是登录、导出、落表。 +Mac 打不到 `192.168.12.244`,**改表 2 在 Windows 上测**。支付导出/解析在 Mac 已经跑过;Win 上要测登录、导出、落表,以及加购登录和对仓加车。 Python 3.12 + Instant Client(位数一致)+ Chrome。密码不要写进仓库。把本包和 `rpa_selection/.env` 一起带过去。 @@ -49,7 +77,8 @@ $env:RPA_ORACLE_PASSWORD = "这里填密码" $env:RPA_ORACLE_THICK = "1" $env:RPA_ORACLE_CLIENT_LIB = "C:\oracle\instantclient" -uv run pytest -v +uv run pytest tests/test_cart_job.py::test_job_adds_split_rows_without_insert -q +uv run python main.py cart # 国庆当天可能没单,用 9-30 有数 uv run python main.py --dry-run --day 2026-09-30 --days 0 uv run python main.py --day 2026-09-30 --days 0 @@ -74,4 +103,4 @@ ORDER BY fid DESC; ## 本期不做 -真加购、清空购物车、药帮忙支付、接口 2 推送。 +清空购物车、药帮忙支付、接口 2 推送。 diff --git a/rpa_cart/cart_add.py b/rpa_cart/cart_add.py new file mode 100644 index 0000000..52abf17 --- /dev/null +++ b/rpa_cart/cart_add.py @@ -0,0 +1,28 @@ +"""加车结果。HTTP 客户端把商城回包收成这三种。""" +from __future__ import annotations + +from dataclasses import dataclass + +KIND_OK = "ok" +KIND_BUSINESS = "business" +KIND_SYSTEM = "system" + + +@dataclass +class AddOutcome: + kind: str + reason: str = "" + + +def classify_mall_message(text: str) -> AddOutcome: + msg = (text or "").strip() + if not msg: + return AddOutcome(kind=KIND_SYSTEM, reason="加购失败") + if any(token in msg for token in ("成功", "已加入")): + return AddOutcome(kind=KIND_OK) + if any(token in msg for token in ("中包装", "倍数", "起订")): + reason = msg if "中包装" in msg else f"中包装加不进:{msg}" + return AddOutcome(kind=KIND_BUSINESS, reason=reason) + if any(token in msg for token in ("库存", "无货", "缺货")): + return AddOutcome(kind=KIND_BUSINESS, reason="库存不足") + return AddOutcome(kind=KIND_BUSINESS, reason=msg) diff --git a/rpa_cart/cart_job.py b/rpa_cart/cart_job.py new file mode 100644 index 0000000..15193c9 --- /dev/null +++ b/rpa_cart/cart_job.py @@ -0,0 +1,98 @@ +"""加购班次:领表 2 选品成功行 → 再搜条形码对仓 → 加车。成功盖章,失败改 status=2。""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from cart_add import ( # noqa: F401 + KIND_BUSINESS, + KIND_OK, + KIND_SYSTEM, + AddOutcome, +) +from cart_match import CartOffer, match_offer, middle_pack, qty_fits_pack +from cart_repo import CartRow, clip_reason + +Searcher = Callable[[str], list[CartOffer]] +Adder = Callable[[CartOffer, int], AddOutcome] + + +@dataclass +class CartSummary: + claimed: int = 0 + added: int = 0 + failed: int = 0 + + +def run_cart_batch( + repo, + *, + searchers: dict[int, Searcher], + adders: dict[int, Adder], + login_errors: dict[int, str], + limit: int | None = None, + platform_types: list[int] | None = None, +) -> CartSummary: + rows = repo.claim_pending(limit=limit, platform_types=platform_types) + summary = CartSummary(claimed=len(rows)) + for row in rows: + reason = _add_one(row, searchers, adders, login_errors) + if reason is None: + repo.mark_success(row.fid) + summary.added += 1 + else: + repo.mark_fail(row.fid, reason) + summary.failed += 1 + return summary + + +def _add_one( + row: CartRow, + searchers: dict[int, Searcher], + adders: dict[int, Adder], + login_errors: dict[int, str], +) -> str | None: + platform = row.platform_type + if platform in login_errors: + return _system(login_errors[platform]) + searcher = searchers.get(platform) + if searcher is None: + return _system("未登录该平台") + adder = adders.get(platform) + if adder is None: + return _system("未配置加车") + bar_code = (row.bar_code or "").strip() + if not bar_code: + return "缺少条形码" + qty = int(row.purchase_qty) + if qty <= 0: + return "采购数量无效" + try: + offers = searcher(bar_code) + except Exception as exc: + return _system(f"查询失败:{exc}") + offer = match_offer(offers, row.supplier_name) + if offer is None: + return "未找到对应仓" + if offer.stock is not None and offer.stock < qty: + return "库存不足" + pack = middle_pack(offer) + if not qty_fits_pack(qty, pack): + return f"中包装加不进:数量须为{pack}的倍数" + try: + outcome = adder(offer, qty) + except Exception as exc: + return _system(f"加购失败:{exc}") + if outcome.kind == KIND_OK: + return None + reason = (outcome.reason or "加购失败").strip() + if outcome.kind == KIND_SYSTEM: + return _system(reason) + return clip_reason(reason) + + +def _system(detail: str) -> str: + text = (detail or "未知错误").strip() + if text.startswith("系统故障"): + return clip_reason(text) + return clip_reason(f"系统故障:{text}") diff --git a/rpa_cart/cart_match.py b/rpa_cart/cart_match.py new file mode 100644 index 0000000..55b50b6 --- /dev/null +++ b/rpa_cart/cart_match.py @@ -0,0 +1,79 @@ +"""按供应商名对上搜到的仓。无 sku_id 时靠条形码结果 + 商家全称。""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +SELF_OPERATED_PREFIX = "[自营]" +PACK_KEYS = ("middlePackNum", "midPackQty", "packNum", "packQty") + + +@dataclass +class CartOffer: + sku_id: str + vendor_raw: str + vendor_norm: str + stock: float | None + price: float | None = None + extra: dict[str, Any] = field(default_factory=dict) + + +def normalize_supplier(raw: str) -> str: + text = (raw or "").replace("\u3000", " ").strip() + if text.startswith(SELF_OPERATED_PREFIX): + text = text[len(SELF_OPERATED_PREFIX) :] + return " ".join(text.split()) + + +def match_offer(offers: list[CartOffer], supplier_name: str) -> CartOffer | None: + target = normalize_supplier(supplier_name) + if not target: + return None + matched = [ + offer + for offer in offers + if normalize_supplier(offer.vendor_raw) == target + or normalize_supplier(offer.vendor_norm) == target + ] + if not matched: + return None + return min(matched, key=_offer_rank) + + +def middle_pack(offer: CartOffer) -> int | None: + raw = offer.extra.get("raw") if isinstance(offer.extra.get("raw"), dict) else offer.extra + if not isinstance(raw, dict): + return None + for key in PACK_KEYS: + value = _positive_int(raw.get(key)) + if value is not None: + return value + return None + + +def qty_fits_pack(qty: int, pack: int | None) -> bool: + if pack is None or pack <= 1: + return True + return qty % pack == 0 + + +def _offer_rank(offer: CartOffer) -> tuple[int, float, float]: + price = offer.price + stock = offer.stock + return ( + 0 if price is not None else 1, + float(price) if price is not None else 0.0, + -float(stock) if stock is not None else 0.0, + ) + + +def _positive_int(value: object) -> int | None: + if value is None or value == "": + return None + try: + number = int(float(str(value))) + except (TypeError, ValueError): + return None + if number <= 0: + return None + return number diff --git a/rpa_cart/cart_repo.py b/rpa_cart/cart_repo.py new file mode 100644 index 0000000..60a1b35 --- /dev/null +++ b/rpa_cart/cart_repo.py @@ -0,0 +1,245 @@ +"""表 2 加购行:领取、成功盖章、失败改状态。成功不插新行。""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from db import RepoUnavailable, connect_from_env, oracle_dsn + +OPERATE_TYPE_ADD_CART = 1 +STATUS_OK = 1 +STATUS_FAIL = 2 +PUSH_NOT_SENT = 0 +STAMP_SELECTION = "系统管理员" +STAMP_CLAIMING = "加购中" +STAMP_DONE = "加购班" +FAIL_REASON_MAX = 500 + +_CLAIM_SQL = """ +UPDATE rpa_cart_settlement_goods_data +SET update_user_name = :stamp, + update_time = SYSDATE +WHERE fid = :fid + AND operate_type = 1 + AND status = 1 + AND push_status = 0 + AND NVL(update_user_name, :selection_stamp) = :selection_stamp +""" + +_SUCCESS_SQL = """ +UPDATE rpa_cart_settlement_goods_data +SET update_user_name = :stamp, + fail_reason = NULL, + update_time = SYSDATE +WHERE fid = :fid + AND update_user_name = :claim_stamp +""" + +_FAIL_SQL = """ +UPDATE rpa_cart_settlement_goods_data +SET status = :status, + fail_reason = :fail_reason, + update_user_name = :stamp, + update_time = SYSDATE +WHERE fid = :fid + AND update_user_name = :claim_stamp +""" + + +@dataclass +class CartRow: + fid: int + oms_id: int + platform_type: int + supplier_name: str + bar_code: str + purchase_qty: int + status: int + fail_reason: str | None + push_status: int + update_user_name: str + operate_type: int = OPERATE_TYPE_ADD_CART + + +def clip_reason(text: str) -> str: + text = (text or "").strip() + if len(text) <= FAIL_REASON_MAX: + return text + return text[: FAIL_REASON_MAX - 1] + "…" + + +def _is_pending(row: dict[str, Any]) -> bool: + stamp = row.get("update_user_name") or STAMP_SELECTION + return ( + int(row.get("operate_type") or 0) == OPERATE_TYPE_ADD_CART + and int(row.get("status") or 0) == STATUS_OK + and int(row.get("push_status") or 0) == PUSH_NOT_SENT + and stamp == STAMP_SELECTION + ) + + +def _row_from_dict(data: dict[str, Any]) -> CartRow: + qty = data.get("purchase_qty") + return CartRow( + fid=int(data["fid"]), + oms_id=int(data["oms_id"]), + platform_type=int(data["platform_type"]), + supplier_name=str(data.get("supplier_name") or ""), + bar_code=str(data.get("bar_code") or ""), + purchase_qty=int(qty) if qty is not None else 0, + status=int(data.get("status") or 0), + fail_reason=data.get("fail_reason"), + push_status=int(data.get("push_status") or 0), + update_user_name=str(data.get("update_user_name") or ""), + operate_type=int(data.get("operate_type") or OPERATE_TYPE_ADD_CART), + ) + + +class MemoryCartRepo: + def __init__(self, rows: list[dict[str, Any]]): + self.rows: dict[int, dict[str, Any]] = {int(row["fid"]): dict(row) for row in rows} + + def claim_pending( + self, limit: int | None = None, platform_types: list[int] | None = None + ) -> list[CartRow]: + pending = sorted( + (row for row in self.rows.values() if _is_pending(row)), + key=lambda row: (int(row["oms_id"]), int(row["fid"])), + ) + if platform_types is not None: + allowed = set(platform_types) + pending = [row for row in pending if int(row["platform_type"]) in allowed] + if limit is not None: + pending = pending[:limit] + claimed: list[CartRow] = [] + for row in pending: + row["update_user_name"] = STAMP_CLAIMING + claimed.append(_row_from_dict(row)) + return claimed + + def mark_success(self, fid: int) -> None: + row = self.rows[fid] + row["update_user_name"] = STAMP_DONE + row["fail_reason"] = None + + def mark_fail(self, fid: int, reason: str) -> None: + row = self.rows[fid] + row["status"] = STATUS_FAIL + row["fail_reason"] = clip_reason(reason) + row["update_user_name"] = STAMP_DONE + + +class OracleCartRepo: + def __init__(self, connect: Callable): + self._connect = connect + + @classmethod + def from_env(cls) -> "OracleCartRepo": + oracle_dsn() + return cls(connect=connect_from_env) + + def claim_pending( + self, limit: int | None = None, platform_types: list[int] | None = None + ) -> list[CartRow]: + conn = self._connect() + try: + with conn.cursor() as cur: + sql, params = _pending_sql(limit=limit, platform_types=platform_types) + cur.execute(sql, params) + fetched = [_row_from_cursor(cur, rec) for rec in cur.fetchall()] + claimed: list[CartRow] = [] + for row in fetched: + cur.execute( + _CLAIM_SQL, + { + "fid": row.fid, + "stamp": STAMP_CLAIMING, + "selection_stamp": STAMP_SELECTION, + }, + ) + if cur.rowcount == 1: + row.update_user_name = STAMP_CLAIMING + claimed.append(row) + conn.commit() + return claimed + except Exception as exc: + try: + conn.rollback() + except Exception: + pass + raise RepoUnavailable(str(exc)) from exc + finally: + conn.close() + + def mark_success(self, fid: int) -> None: + self._execute( + _SUCCESS_SQL, + {"fid": int(fid), "stamp": STAMP_DONE, "claim_stamp": STAMP_CLAIMING}, + ) + + def mark_fail(self, fid: int, reason: str) -> None: + self._execute( + _FAIL_SQL, + { + "fid": int(fid), + "status": STATUS_FAIL, + "fail_reason": clip_reason(reason), + "stamp": STAMP_DONE, + "claim_stamp": STAMP_CLAIMING, + }, + ) + + def _execute(self, sql: str, params: dict[str, Any]) -> None: + conn = self._connect() + try: + with conn.cursor() as cur: + cur.execute(sql, params) + conn.commit() + except Exception as exc: + try: + conn.rollback() + except Exception: + pass + raise RepoUnavailable(str(exc)) from exc + finally: + conn.close() + + +def _row_from_cursor(cur, rec) -> CartRow: + cols = [item[0].lower() for item in cur.description] + return _row_from_dict(dict(zip(cols, rec))) + + +def _pending_sql( + *, limit: int | None, platform_types: list[int] | None +) -> tuple[str, dict[str, Any]]: + params: dict[str, Any] = {"selection_stamp": STAMP_SELECTION} + filters = [ + "operate_type = 1", + "status = 1", + "push_status = 0", + "NVL(update_user_name, :selection_stamp) = :selection_stamp", + ] + if platform_types is not None: + if not platform_types: + filters.append("1 = 0") + else: + keys: list[str] = [] + for index, platform in enumerate(platform_types): + key = f"p{index}" + keys.append(f":{key}") + params[key] = int(platform) + filters.append(f"platform_type IN ({', '.join(keys)})") + sql = f""" +SELECT fid, oms_id, platform_type, supplier_name, bar_code, + purchase_qty, status, fail_reason, push_status, update_user_name, + operate_type +FROM rpa_cart_settlement_goods_data +WHERE {' AND '.join(filters)} +ORDER BY oms_id, fid +""" + if limit is not None: + params["limit"] = int(limit) + sql = f"SELECT * FROM (\n{sql}\n) WHERE ROWNUM <= :limit" + return sql, params diff --git a/rpa_cart/cart_search.py b/rpa_cart/cart_search.py new file mode 100644 index 0000000..542b7c9 --- /dev/null +++ b/rpa_cart/cart_search.py @@ -0,0 +1,91 @@ +"""再搜条形码,保留原始商品字段给加车用。""" +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import requests + +from cart_match import CartOffer +from yaoex_login import SELECTION_ROOT + +if str(SELECTION_ROOT) not in sys.path: + sys.path.append(str(SELECTION_ROOT)) + +from search_yaoex import map_yaoex_products, query_yaoex_products # noqa: E402 +from search_ybm import map_ybm_products, query_ybm_products # noqa: E402 + + +def search_yaoex_offers( + auth, + query_key: str, + session: requests.Session | None = None, + cache: dict[str, list[CartOffer]] | None = None, +) -> list[CartOffer]: + store = cache if cache is not None else {} + if query_key in store: + return store[query_key] + own = session is None + client = session or requests.Session() + try: + products, user_id = query_yaoex_products(client, auth, query_key) + candidates = map_yaoex_products(products, user_id) + offers = [ + _from_candidate(candidate, raw) + for candidate, raw in zip(candidates, products) + ] + finally: + if own: + client.close() + store[query_key] = offers + return offers + + +def search_ybm_offers( + cookies: dict[str, str], + query_key: str, + session: requests.Session | None = None, + cache: dict[str, list[CartOffer]] | None = None, +) -> list[CartOffer]: + store = cache if cache is not None else {} + if query_key in store: + return store[query_key] + own = session is None + client = session or requests.Session() + try: + if own: + for name, value in cookies.items(): + client.cookies.set(name, value) + rows = query_ybm_products(client, query_key) + candidates = map_ybm_products(rows) + offers = [ + _from_candidate(candidate, _ybm_raw(row)) + for candidate, row in zip(candidates, rows) + ] + finally: + if own: + client.close() + store[query_key] = offers + return offers + + +def _ybm_raw(row: dict[str, Any]) -> dict[str, Any]: + product = row.get("productInfo") + raw = dict(product) if isinstance(product, dict) else dict(row) + if not raw.get("skuId"): + raw["skuId"] = row.get("skuId") + return raw + + +def _from_candidate(candidate, raw: dict[str, Any]) -> CartOffer: + extra = dict(candidate.extra) + extra["raw"] = raw + return CartOffer( + sku_id=candidate.sku_id, + vendor_raw=candidate.vendor_raw, + vendor_norm=candidate.vendor_norm, + stock=candidate.stock, + price=candidate.price, + extra=extra, + ) diff --git a/rpa_cart/cart_yaoex.py b/rpa_cart/cart_yaoex.py new file mode 100644 index 0000000..558c00a --- /dev/null +++ b/rpa_cart/cart_yaoex.py @@ -0,0 +1,100 @@ +"""1 号药城加入购物车。""" +from __future__ import annotations + +import time +from typing import Any + +import requests +import urllib3 + +from cart_add import KIND_OK, KIND_SYSTEM, AddOutcome, classify_mall_message +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" + + +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"]: + return AddOutcome(kind=KIND_SYSTEM, reason="缺少 sku") + payload = _form( + auth, + { + "skuId": ids["skuId"], + "spuCode": ids["spuCode"], + "sellerCode": ids["sellerCode"], + "amount": int(qty), + }, + ) + own = session is None + client = session or requests.Session() + try: + response = client.post( + YAOEX_ADD_URL, + data=payload, + headers=_headers(), + timeout=30, + verify=False, + ) + response.raise_for_status() + return _outcome_from_json(response.json()) + except Exception as exc: + return AddOutcome(kind=KIND_SYSTEM, reason=str(exc)) + finally: + if own: + client.close() + + +def _ids(offer: CartOffer) -> dict[str, str]: + 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} + + +def _form(auth, extra: dict[str, Any]) -> dict[str, Any]: + payload = { + "traderName": "yaoex_pc", + "trader": "pc", + "closesignature": "yes", + "signature_method": "md5", + "signature": "****", + "timestamp": str(int(time.time() * 1000)), + "token": auth.token, + "userToken": auth.token, + "userId": getattr(auth, "user_id", ""), + "buyerCode": getattr(auth, "buyer_code", ""), + } + payload.update(extra) + return payload + + +def _headers() -> dict[str, str]: + return { + "Content-Type": "application/x-www-form-urlencoded", + "X-Requested-With": "XMLHttpRequest", + "X-Request-Agent": "Axios", + "traderName": "yaoex_pc", + "trader": "pc", + "Origin": "https://mall.yaoex.com", + "Referer": "https://mall.yaoex.com/v2/cart/", + } + + +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"): + return AddOutcome(kind=KIND_OK) + if msg: + return classify_mall_message(msg) + return AddOutcome(kind=KIND_SYSTEM, reason="加购失败") diff --git a/rpa_cart/cart_ybm.py b/rpa_cart/cart_ybm.py new file mode 100644 index 0000000..391d6e6 --- /dev/null +++ b/rpa_cart/cart_ybm.py @@ -0,0 +1,78 @@ +"""药帮忙加入购物车。""" +from __future__ import annotations + +import requests +import urllib3 + +from cart_add import KIND_OK, KIND_SYSTEM, AddOutcome, classify_mall_message +from cart_match import CartOffer + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +YBM_ADD_URL = "https://www.ybm100.com/new-front/cart/add-product" + + +def add_ybm_cart( + cookies: dict[str, str], + offer: CartOffer, + qty: int, + *, + session: requests.Session | None = None, +) -> AddOutcome: + sku = _sku_id(offer) + if not sku: + return AddOutcome(kind=KIND_SYSTEM, reason="缺少 sku") + own = session is None + client = session or requests.Session() + try: + if own: + for name, value in cookies.items(): + client.cookies.set(name, value) + response = client.post( + YBM_ADD_URL, + json={"skuId": sku, "skuNum": int(qty)}, + headers=_headers(), + timeout=30, + verify=False, + ) + response.raise_for_status() + return _outcome_from_json(response.json()) + except Exception as exc: + return AddOutcome(kind=KIND_SYSTEM, reason=str(exc)) + finally: + if own: + client.close() + + +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 "") + + +def _headers() -> dict[str, str]: + return { + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "Origin": "https://www.ybm100.com", + "Referer": "https://www.ybm100.com/new/", + "X-Requested-With": "XMLHttpRequest", + "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" + ), + } + + +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) + return AddOutcome(kind=KIND_SYSTEM, reason="加购失败") diff --git a/rpa_cart/main.py b/rpa_cart/main.py index 67ad35e..9b3860c 100644 --- a/rpa_cart/main.py +++ b/rpa_cart/main.py @@ -1,4 +1,4 @@ -"""1 号药城支付落表:导出当天已付订单 → 写入表 2 operate_type=2。不对加购行。""" +"""加购班与 1 号药城支付落表。默认支付;`cart` 子命令加购。""" from __future__ import annotations import argparse @@ -6,17 +6,44 @@ import tempfile from datetime import date from pathlib import Path +import requests + from db import RepoUnavailable from job import run_yaoex_pay from parse_xls import parse_order_xls from repo import EmptyPayRepo, OraclePayRepo from yaoex_client import day_range, export_orders -from yaoex_login import load_cart_dotenv, resolve_yaoex_auth +from yaoex_login import ( + BrowserRuntime, + load_cart_dotenv, + resolve_yaoex_auth, + resolve_ybm_cookies, +) PROJECT_ROOT = Path(__file__).resolve().parent +YAOEX_PLATFORM_TYPE = 66 +YBM_PLATFORM_TYPE = 48 def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + argv = list(argv) if argv is not None else None + if argv is None: + import sys + + argv = sys.argv[1:] + command = "pay" + if argv and argv[0] in {"pay", "cart"}: + command = argv.pop(0) + if command == "cart": + args = _parse_cart_args(argv) + args.command = "cart" + return args + args = _parse_pay_args(argv) + args.command = "pay" + return args + + +def _parse_pay_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="1 号药城支付落表") parser.add_argument("--xls", type=Path, default=None, help="已导出的订单明细 xls,跳过商城导出") parser.add_argument( @@ -33,8 +60,31 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: return parser.parse_args(argv) +def _parse_cart_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="加购班:领表 2 选品成功行,按仓加进购物车") + parser.add_argument("--limit", type=int, default=None, help="本班最多领取 N 行") + parser.add_argument( + "--platform", + choices=["all", "yaoex", "ybm"], + default="all", + help="all=两平台都跑", + ) + parser.add_argument("--fresh-login", action="store_true", help="忽略登录缓存") + parser.add_argument("--secrets", type=Path, default=None) + parser.add_argument("--yaoex-auth", type=Path, default=None) + parser.add_argument("--ybm-cookies", type=Path, default=None) + return parser.parse_args(argv) + + def main(argv: list[str] | None = None, repo=None) -> None: args = parse_args(argv) + if args.command == "cart": + main_cart(args, repo=repo) + return + main_pay(args, repo=repo) + + +def main_pay(args: argparse.Namespace, repo=None) -> None: load_cart_dotenv() xls_path = args.xls tmp_xls: Path | None = None @@ -87,5 +137,115 @@ def main(argv: list[str] | None = None, repo=None) -> None: ) +def main_cart(args: argparse.Namespace, repo=None) -> None: + from cart_job import run_cart_batch + from cart_repo import OracleCartRepo + from cart_search import search_yaoex_offers, search_ybm_offers + from cart_yaoex import add_yaoex_cart + from cart_ybm import add_ybm_cart + + load_cart_dotenv() + if repo is None: + try: + repo = OracleCartRepo.from_env() + except RepoUnavailable as exc: + print(f"数据库不可用:{exc}") + raise SystemExit(2) from exc + + want_yaoex = args.platform in {"all", "yaoex"} + want_ybm = args.platform in {"all", "ybm"} + login_errors: dict[int, str] = {} + searchers = {} + adders = {} + yaoex_auth = None + ybm_cookies = None + runtime = BrowserRuntime(keep=False) + yaoex_session = requests.Session() + ybm_session = requests.Session() + yaoex_cache: dict = {} + ybm_cache: dict = {} + try: + if want_yaoex: + try: + yaoex_auth = resolve_yaoex_auth( + secrets_path=args.secrets, + auth_path=args.yaoex_auth, + fresh=args.fresh_login, + runtime=runtime, + ) + print("1号药城认证就绪。") + except SystemExit: + raise + except Exception as exc: + login_errors[YAOEX_PLATFORM_TYPE] = str(exc) + print(f"1号药城登录失败,本平台中止:{exc}") + if want_ybm: + try: + ybm_cookies = resolve_ybm_cookies( + secrets_path=args.secrets, + cookie_path=args.ybm_cookies, + fresh=args.fresh_login, + runtime=runtime, + ) + print("药帮忙认证就绪。") + except SystemExit: + raise + except Exception as exc: + login_errors[YBM_PLATFORM_TYPE] = str(exc) + print(f"药帮忙登录失败,本平台中止:{exc}") + + if ybm_cookies: + for name, value in ybm_cookies.items(): + ybm_session.cookies.set(name, value) + + if yaoex_auth is not None: + + def search_yaoex(query_key: str): + return search_yaoex_offers( + yaoex_auth, query_key, session=yaoex_session, cache=yaoex_cache + ) + + def add_yaoex(offer, qty: int): + return add_yaoex_cart(yaoex_auth, offer, qty, session=yaoex_session) + + searchers[YAOEX_PLATFORM_TYPE] = search_yaoex + adders[YAOEX_PLATFORM_TYPE] = add_yaoex + + if ybm_cookies is not None: + + def search_ybm(query_key: str): + return search_ybm_offers( + ybm_cookies, query_key, session=ybm_session, cache=ybm_cache + ) + + def add_ybm(offer, qty: int): + return add_ybm_cart(ybm_cookies, offer, qty, session=ybm_session) + + searchers[YBM_PLATFORM_TYPE] = search_ybm + adders[YBM_PLATFORM_TYPE] = add_ybm + + platform_types: list[int] = [] + if want_yaoex: + platform_types.append(YAOEX_PLATFORM_TYPE) + if want_ybm: + platform_types.append(YBM_PLATFORM_TYPE) + summary = run_cart_batch( + repo, + searchers=searchers, + adders=adders, + login_errors=login_errors, + limit=args.limit, + platform_types=platform_types, + ) + print( + f"领取={summary.claimed} 加购成功={summary.added} 失败={summary.failed}" + ) + print("未推订单中心,未清空购物车。") + finally: + yaoex_session.close() + ybm_session.close() + runtime.close() + + if __name__ == "__main__": main() diff --git a/rpa_cart/pyproject.toml b/rpa_cart/pyproject.toml index cb0a154..ae16e0c 100644 --- a/rpa_cart/pyproject.toml +++ b/rpa_cart/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "rpa-cart" version = "0.1.0" -description = "洁达加购与支付回传。表 2 → 接口 2。本期先做 1 号药城支付落表。" +description = "洁达加购与支付回传。表 2 加购班 + 1 号药城支付落表。不推接口 2。" requires-python = ">=3.12" dependencies = [ "oracledb>=2.4.1", diff --git a/rpa_cart/tests/test_cart_add.py b/rpa_cart/tests/test_cart_add.py new file mode 100644 index 0000000..e195883 --- /dev/null +++ b/rpa_cart/tests/test_cart_add.py @@ -0,0 +1,120 @@ +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 +from cart_ybm import YBM_ADD_URL, add_ybm_cart + + +class FakeAuth: + token = "tok" + user_id = "u1" + buyer_code = "b1" + + +class FakeResponse: + def __init__(self, json_data=None, status_code=200, text=""): + self._json = json_data + self.status_code = status_code + self.text = text or str(json_data) + self.headers = {"content-type": "application/json"} + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + def json(self): + if self._json is None: + raise ValueError("not json") + return self._json + + +class FakeSession: + def __init__(self, response: FakeResponse): + self.response = response + self.calls: list[dict] = [] + + def post(self, url, data=None, json=None, headers=None, timeout=None, verify=None, params=None): + self.calls.append( + { + "url": url, + "data": data, + "json": json, + "headers": headers, + "params": params, + } + ) + return self.response + + def close(self): + return None + + +def _offer(**overrides) -> CartOffer: + extra = overrides.pop( + "extra", + { + "raw": { + "skuId": "SKU66", + "spuCode": "SPU66", + "sellerCode": "GD01", + } + }, + ) + data = dict( + sku_id="SPU66", + vendor_raw="广东壹号药业有限公司", + vendor_norm="广东壹号药业有限公司", + stock=20.0, + price=90.0, + extra=extra, + ) + data.update(overrides) + return CartOffer(**data) + + +def test_classify_pack_and_stock_and_ok(): + pack = classify_mall_message("加入失败,数量须为中包装倍数") + assert pack.kind == KIND_BUSINESS + assert "中包装" in pack.reason + stock = classify_mall_message("库存不足") + assert stock.kind == KIND_BUSINESS + assert stock.reason == "库存不足" + ok = classify_mall_message("加入购物车成功") + assert ok.kind == KIND_OK + + +def test_yaoex_add_posts_token_and_qty(): + session = FakeSession(FakeResponse({"code": 0, "msg": "成功"})) + outcome = add_yaoex_cart(FakeAuth(), _offer(), 7, session=session) + assert outcome.kind == KIND_OK + assert session.calls[0]["url"] == YAOEX_ADD_URL + 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" + + +def test_yaoex_add_classifies_pack_error(): + session = FakeSession(FakeResponse({"code": 1, "msg": "数量必须是中包装的倍数"})) + outcome = add_yaoex_cart(FakeAuth(), _offer(), 7, session=session) + assert outcome.kind == KIND_BUSINESS + assert "中包装" in outcome.reason + + +def test_ybm_add_posts_sku_and_qty(): + session = FakeSession(FakeResponse({"success": True, "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 + body = session.calls[0]["json"] + assert body["skuId"] == "SKU48" + assert body["skuNum"] == 3 + + +def test_ybm_http_error_is_system_fail(): + session = FakeSession(FakeResponse(json_data=None, status_code=500, text="bad")) + outcome = add_ybm_cart({}, _offer(extra={"raw": {"skuId": "X"}}), 1, session=session) + assert outcome.kind == KIND_SYSTEM diff --git a/rpa_cart/tests/test_cart_job.py b/rpa_cart/tests/test_cart_job.py new file mode 100644 index 0000000..6eba926 --- /dev/null +++ b/rpa_cart/tests/test_cart_job.py @@ -0,0 +1,247 @@ +from cart_job import KIND_BUSINESS, KIND_OK, KIND_SYSTEM, AddOutcome, run_cart_batch +from cart_match import CartOffer +from cart_repo import STAMP_DONE, STAMP_SELECTION, MemoryCartRepo + + +HANGZHOU = "杭州小药药自营旗舰店" +GUANGDONG = "广东壹号药业有限公司" +YBM = 48 +YAOEX = 66 + + +def _row(**overrides) -> dict: + data = { + "fid": 1, + "oms_id": 2026100201, + "platform_type": YAOEX, + "supplier_name": GUANGDONG, + "bar_code": "06901234567890", + "purchase_qty": 7, + "status": 1, + "fail_reason": None, + "push_status": 0, + "update_user_name": STAMP_SELECTION, + "operate_type": 1, + } + data.update(overrides) + return data + + +def _offer(vendor: str, **overrides) -> CartOffer: + data = dict( + sku_id="S-" + vendor[:2], + vendor_raw=vendor, + vendor_norm=vendor, + stock=20.0, + price=90.0, + ) + data.update(overrides) + return CartOffer(**data) + + +def test_job_adds_split_rows_without_insert(): + repo = MemoryCartRepo( + [ + _row(fid=1, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3), + _row(fid=2, platform_type=YAOEX, supplier_name=GUANGDONG, purchase_qty=7), + ] + ) + added: list[tuple[int, str, int]] = [] + + def search_ybm(bar_code: str): + assert bar_code == "06901234567890" + return [_offer(HANGZHOU, sku_id="HZ", stock=5.0)] + + def search_yaoex(bar_code: str): + assert bar_code == "06901234567890" + return [_offer(GUANGDONG, sku_id="GD", stock=20.0)] + + def add_ybm(offer: CartOffer, qty: int): + added.append((YBM, offer.sku_id, qty)) + return AddOutcome(kind=KIND_OK) + + def add_yaoex(offer: CartOffer, qty: int): + added.append((YAOEX, offer.sku_id, qty)) + return AddOutcome(kind=KIND_OK) + + summary = run_cart_batch( + repo, + searchers={YBM: search_ybm, YAOEX: search_yaoex}, + adders={YBM: add_ybm, YAOEX: add_yaoex}, + login_errors={}, + ) + assert summary.claimed == 2 + assert summary.added == 2 + assert summary.failed == 0 + assert added == [(YBM, "HZ", 3), (YAOEX, "GD", 7)] + assert list(repo.rows) == [1, 2] + assert repo.rows[1]["status"] == 1 + assert repo.rows[2]["status"] == 1 + assert repo.rows[1]["update_user_name"] == STAMP_DONE + assert repo.rows[2]["update_user_name"] == STAMP_DONE + assert repo.rows[1]["push_status"] == 0 + + +def test_job_marks_out_of_stock(): + repo = MemoryCartRepo([_row(purchase_qty=10)]) + + def search_yaoex(bar_code: str): + return [_offer(GUANGDONG, stock=4.0)] + + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, + login_errors={}, + ) + assert summary.added == 0 + assert summary.failed == 1 + assert repo.rows[1]["status"] == 2 + assert repo.rows[1]["fail_reason"] == "库存不足" + assert repo.rows[1]["update_user_name"] == STAMP_DONE + + +def test_job_marks_login_fail_as_system_error(): + repo = MemoryCartRepo( + [ + _row(fid=1, platform_type=YAOEX), + _row(fid=2, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3), + ] + ) + ybm_added = [] + + def search_ybm(bar_code: str): + return [_offer(HANGZHOU)] + + def add_ybm(offer: CartOffer, qty: int): + ybm_added.append(qty) + return AddOutcome(kind=KIND_OK) + + summary = run_cart_batch( + repo, + searchers={YBM: search_ybm}, + adders={YBM: add_ybm}, + login_errors={YAOEX: "账号密码错误"}, + ) + assert summary.added == 1 + assert summary.failed == 1 + assert repo.rows[1]["status"] == 2 + assert repo.rows[1]["fail_reason"].startswith("系统故障:") + assert "账号密码错误" in repo.rows[1]["fail_reason"] + assert repo.rows[2]["status"] == 1 + assert repo.rows[2]["update_user_name"] == STAMP_DONE + assert ybm_added == [3] + + +def test_job_skips_already_stamped_rows(): + repo = MemoryCartRepo([_row(update_user_name=STAMP_DONE)]) + called = [] + + def search_yaoex(bar_code: str): + called.append(bar_code) + return [_offer(GUANGDONG)] + + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, + login_errors={}, + ) + assert summary.claimed == 0 + assert called == [] + assert repo.rows[1]["update_user_name"] == STAMP_DONE + + +def test_job_marks_missing_warehouse(): + repo = MemoryCartRepo([_row()]) + + def search_yaoex(bar_code: str): + return [_offer(HANGZHOU)] + + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, + login_errors={}, + ) + assert summary.failed == 1 + assert repo.rows[1]["status"] == 2 + assert repo.rows[1]["fail_reason"] == "未找到对应仓" + + +def test_job_marks_pack_reject(): + repo = MemoryCartRepo([_row(purchase_qty=7)]) + + def search_yaoex(bar_code: str): + return [_offer(GUANGDONG, extra={"raw": {"middlePackNum": 10}})] + + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, + login_errors={}, + ) + assert summary.failed == 1 + assert repo.rows[1]["status"] == 2 + assert repo.rows[1]["fail_reason"].startswith("中包装加不进") + + +def test_job_records_mall_add_failure(): + repo = MemoryCartRepo([_row()]) + + def search_yaoex(bar_code: str): + return [_offer(GUANGDONG)] + + def add_yaoex(offer: CartOffer, qty: int): + return AddOutcome(kind=KIND_BUSINESS, reason="中包装加不进:数量须为10的倍数") + + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: add_yaoex}, + login_errors={}, + ) + assert summary.failed == 1 + assert repo.rows[1]["fail_reason"] == "中包装加不进:数量须为10的倍数" + + +def test_job_platform_filter_leaves_other_mall_pending(): + repo = MemoryCartRepo( + [ + _row(fid=1, platform_type=YAOEX), + _row(fid=2, platform_type=YBM, supplier_name=HANGZHOU, purchase_qty=3), + ] + ) + + def search_yaoex(bar_code: str): + return [_offer(GUANGDONG)] + + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, + login_errors={}, + platform_types=[YAOEX], + ) + assert summary.claimed == 1 + assert summary.added == 1 + assert repo.rows[1]["update_user_name"] == STAMP_DONE + assert repo.rows[2]["update_user_name"] == STAMP_SELECTION + assert repo.rows[2]["status"] == 1 + + +def test_job_search_error_is_system_fail(): + repo = MemoryCartRepo([_row()]) + + def search_yaoex(bar_code: str): + raise RuntimeError("网关 502") + + summary = run_cart_batch( + repo, + searchers={YAOEX: search_yaoex}, + adders={YAOEX: lambda offer, qty: AddOutcome(kind=KIND_OK)}, + login_errors={}, + ) + assert summary.failed == 1 + assert repo.rows[1]["fail_reason"].startswith("系统故障:") + assert "网关 502" in repo.rows[1]["fail_reason"] diff --git a/rpa_cart/tests/test_cart_match.py b/rpa_cart/tests/test_cart_match.py new file mode 100644 index 0000000..5eb24a1 --- /dev/null +++ b/rpa_cart/tests/test_cart_match.py @@ -0,0 +1,56 @@ +from cart_match import CartOffer, match_offer, middle_pack, qty_fits_pack + + +def _offer(**overrides) -> CartOffer: + data = dict( + sku_id="S1", + vendor_raw="广东壹号药业有限公司", + vendor_norm="广东壹号药业有限公司", + stock=20.0, + price=90.0, + ) + data.update(overrides) + return CartOffer(**data) + + +def test_matches_supplier_name_exact(): + picked = match_offer( + [_offer(vendor_raw="杭州小药药自营旗舰店", vendor_norm="杭州小药药自营旗舰店"), _offer()], + "广东壹号药业有限公司", + ) + assert picked is not None + assert picked.sku_id == "S1" + assert picked.vendor_raw == "广东壹号药业有限公司" + + +def test_matches_supplier_name_stripping_self_operated_prefix(): + picked = match_offer( + [_offer(vendor_raw="[自营] 广东壹号药业有限公司", vendor_norm="广东壹号药业有限公司")], + "广东壹号药业有限公司", + ) + assert picked is not None + assert picked.vendor_norm == "广东壹号药业有限公司" + + +def test_same_supplier_keeps_cheaper(): + picked = match_offer( + [ + _offer(sku_id="DEAR", price=94.0, stock=100.0), + _offer(sku_id="CHEAP", price=90.0, stock=5.0), + ], + "广东壹号药业有限公司", + ) + assert picked is not None + assert picked.sku_id == "CHEAP" + + +def test_missing_supplier_returns_none(): + assert match_offer([_offer()], "杭州小药药自营旗舰店") is None + + +def test_qty_not_multiple_of_middle_pack(): + offer = _offer(extra={"raw": {"middlePackNum": 10}}) + assert middle_pack(offer) == 10 + assert qty_fits_pack(7, 10) is False + assert qty_fits_pack(10, 10) is True + assert qty_fits_pack(7, None) is True diff --git a/rpa_cart/tests/test_cart_repo.py b/rpa_cart/tests/test_cart_repo.py new file mode 100644 index 0000000..4623327 --- /dev/null +++ b/rpa_cart/tests/test_cart_repo.py @@ -0,0 +1,136 @@ +from cart_repo import ( + STAMP_CLAIMING, + STAMP_DONE, + STAMP_SELECTION, + MemoryCartRepo, + OracleCartRepo, +) + + +def _row(**overrides) -> dict: + data = { + "fid": 1, + "oms_id": 2026100201, + "platform_type": 66, + "supplier_name": "广东壹号药业有限公司", + "bar_code": "06901234567890", + "purchase_qty": 7, + "status": 1, + "fail_reason": None, + "push_status": 0, + "update_user_name": STAMP_SELECTION, + "operate_type": 1, + } + data.update(overrides) + return data + + +def test_claim_pending_stamps_processing_and_skips_done(): + repo = MemoryCartRepo( + [ + _row(), + _row(fid=2, oms_id=2026100202, update_user_name=STAMP_DONE), + _row(fid=3, oms_id=2026100203, status=2, update_user_name=STAMP_SELECTION), + _row(fid=4, oms_id=2026100204, operate_type=2), + ] + ) + claimed = repo.claim_pending() + assert [row.fid for row in claimed] == [1] + assert claimed[0].update_user_name == STAMP_CLAIMING + assert repo.rows[1]["update_user_name"] == STAMP_CLAIMING + assert repo.rows[2]["update_user_name"] == STAMP_DONE + assert repo.rows[3]["status"] == 2 + + +def test_mark_success_does_not_insert_or_change_status(): + repo = MemoryCartRepo([_row()]) + repo.claim_pending() + repo.mark_success(1) + assert list(repo.rows) == [1] + assert repo.rows[1]["status"] == 1 + assert repo.rows[1]["fail_reason"] is None + assert repo.rows[1]["update_user_name"] == STAMP_DONE + assert repo.rows[1]["push_status"] == 0 + + +def test_mark_fail_sets_status_and_reason(): + repo = MemoryCartRepo([_row()]) + repo.claim_pending() + repo.mark_fail(1, "库存不足") + assert list(repo.rows) == [1] + assert repo.rows[1]["status"] == 2 + assert repo.rows[1]["fail_reason"] == "库存不足" + assert repo.rows[1]["update_user_name"] == STAMP_DONE + + +def test_oracle_claim_updates_stamp(): + fetched = [ + ( + 11, + 2026100201, + 66, + "广东壹号药业有限公司", + "0690", + 7, + 1, + None, + 0, + STAMP_SELECTION, + 1, + ) + ] + executed: list[tuple[str, dict]] = [] + + class FakeCursor: + description = [ + ("FID",), + ("OMS_ID",), + ("PLATFORM_TYPE",), + ("SUPPLIER_NAME",), + ("BAR_CODE",), + ("PURCHASE_QTY",), + ("STATUS",), + ("FAIL_REASON",), + ("PUSH_STATUS",), + ("UPDATE_USER_NAME",), + ("OPERATE_TYPE",), + ] + rowcount = 1 + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def execute(self, sql, params=None): + executed.append((sql, params or {})) + + def fetchall(self): + return fetched + + class FakeConn: + committed = False + + def cursor(self): + return FakeCursor() + + def commit(self): + self.committed = True + + def rollback(self): + raise AssertionError("不应 rollback") + + def close(self): + pass + + conn = FakeConn() + repo = OracleCartRepo(connect=lambda: conn) + claimed = repo.claim_pending() + assert conn.committed is True + assert len(claimed) == 1 + assert claimed[0].fid == 11 + assert claimed[0].update_user_name == STAMP_CLAIMING + assert "update_user_name" in executed[1][0].lower() + assert executed[1][1]["fid"] == 11 + assert executed[1][1]["stamp"] == STAMP_CLAIMING diff --git a/rpa_cart/tests/test_main.py b/rpa_cart/tests/test_main.py index 9df319a..3b6388e 100644 --- a/rpa_cart/tests/test_main.py +++ b/rpa_cart/tests/test_main.py @@ -6,9 +6,18 @@ def test_parse_args_defaults(): assert args.days == 0 assert args.dry_run is False assert args.xls is None + assert args.command == "pay" def test_parse_args_xls_dry_run(): args = parse_args(["--xls", "/tmp/orders.xls", "--dry-run"]) assert args.dry_run is True assert str(args.xls).endswith("orders.xls") + assert args.command == "pay" + + +def test_parse_args_cart_subcommand(): + args = parse_args(["cart", "--limit", "5", "--platform", "yaoex"]) + assert args.command == "cart" + assert args.limit == 5 + assert args.platform == "yaoex" diff --git a/rpa_cart/yaoex_login.py b/rpa_cart/yaoex_login.py index 78b68a5..5edea0b 100644 --- a/rpa_cart/yaoex_login.py +++ b/rpa_cart/yaoex_login.py @@ -1,4 +1,4 @@ -"""复用 rpa_selection 的 1 号药城登录。""" +"""复用 rpa_selection 的 1 号药城 / 药帮忙登录。""" from __future__ import annotations import sys @@ -9,12 +9,16 @@ if str(SELECTION_ROOT) not in sys.path: sys.path.append(str(SELECTION_ROOT)) from auth_login import ( # noqa: E402 + DEFAULT_YBM_COOKIE_PATH, DEFAULT_YAOEX_AUTH_PATH, BrowserRuntime, + load_cookie_file, load_dotenv, load_secrets, load_yaoex_auth_file, login_yaoex, + login_ybm, + save_cookie_file, save_yaoex_auth, ) from search_yaoex import YaoexAuth # noqa: E402 @@ -49,3 +53,29 @@ def resolve_yaoex_auth( finally: if own: session.close() + + +def resolve_ybm_cookies( + *, + secrets_path: Path | None = None, + cookie_path: Path | None = None, + fresh: bool = False, + runtime: BrowserRuntime | None = None, +) -> dict[str, str]: + cached = cookie_path or DEFAULT_YBM_COOKIE_PATH + if not fresh and cached.exists(): + cookies = load_cookie_file(cached) + if cookies: + return cookies + secrets = load_secrets(secrets_path) + if not secrets["ybm"].get("username"): + raise SystemExit("缺少药帮忙账号。配置 rpa_selection/.env 或环境变量 YBM_USERNAME / YBM_PASSWORD。") + own = runtime is None + session = runtime or BrowserRuntime(keep=False) + try: + cookies = login_ybm(secrets_path, runtime=session) + save_cookie_file(cookies, cached) + return cookies + finally: + if own: + session.close()