feat: 加购班领表2按仓加入购物车,失败改status=2

选品成功行领取后按条形码对仓加车。成功盖章不插行;登录挂、库存、中包装只改该行失败状态。不推接口2。
This commit is contained in:
Yuuuu 2026-10-05 08:39:16 +08:00
parent abf26b4259
commit 0fa29ec9bd
17 changed files with 1534 additions and 10 deletions

View File

@ -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`。
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。

View File

@ -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 推送。

28
rpa_cart/cart_add.py Normal file
View File

@ -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)

98
rpa_cart/cart_job.py Normal file
View File

@ -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}")

79
rpa_cart/cart_match.py Normal file
View File

@ -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

245
rpa_cart/cart_repo.py Normal file
View File

@ -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

91
rpa_cart/cart_search.py Normal file
View File

@ -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,
)

100
rpa_cart/cart_yaoex.py Normal file
View File

@ -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="加购失败")

78
rpa_cart/cart_ybm.py Normal file
View File

@ -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="加购失败")

View File

@ -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()

View File

@ -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",

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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()