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 # 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 靠下面这张地图定点打开,禁止用扫仓代替思考。改对应了,反工和消耗都会少。 智力用在**定落点、想清楚对错**;速度和 token 靠下面这张地图定点打开,禁止用扫仓代替思考。改对应了,反工和消耗都会少。
@ -31,7 +31,8 @@
| 节点入口 | `rpa_contract/contract_create_node.py`、`main.py` | 读到正式合同号 | | 节点入口 | `rpa_contract/contract_create_node.py`、`main.py` | 读到正式合同号 |
| 浏览器适配 | `rpa_contract/apa_browser_adapter.py` | `rpa_contract/tests/test_apa_browser_adapter.py::点名` | | 浏览器适配 | `rpa_contract/apa_browser_adapter.py` | `rpa_contract/tests/test_apa_browser_adapter.py::点名` |
| 文字验收 / 截图策略 | `rpa_contract/accept.py` | 只读 `ACCEPT.txt`,禁止读 png | | 文字验收 / 截图策略 | `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_rebate/README.md` | 占位,未实现 |
| 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — | | 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — |
| 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` | | 节点业务规则(点名一节) | `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)。 验收:`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 落点 ## CONTRACT_CREATE 落点
页面脚本在 `rpa_contract/`。`run_contract_create_flow` 串起来;修一步只打开对应函数。从表 3 合单、写表 4 尚未接。 页面脚本在 `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`。 - 接口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`。 - 接口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_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`。 - 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。 - 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。

View File

@ -4,9 +4,37 @@
| 班次 | 状态 | | 班次 | 状态 |
| --- | --- | | --- | --- |
| 加购 `operateType=1` | 未做。选品已落表 2 | | 加购 `operateType=1` | 领表 2 选品成功行,按仓加进购物车。成功盖章,失败改 `status=2` |
| 支付回传 `operateType=2` | **1 号药城已做落表**。不推接口 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 号药城支付落表 ## 1 号药城支付落表
从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。 从买家中心导出已付订单,按导出行字段写入表 2 `operate_type=2`。不对加购行。购物车和支付单没有强关联,`oms_id` 表上 NOT NULL,先填 `0`,等人后选。
@ -31,7 +59,7 @@ uv run pytest -v
## Windows 真库 ## 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` 一起带过去。 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_THICK = "1"
$env:RPA_ORACLE_CLIENT_LIB = "C:\oracle\instantclient" $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 有数 # 国庆当天可能没单,用 9-30 有数
uv run python main.py --dry-run --day 2026-09-30 --days 0 uv run python main.py --dry-run --day 2026-09-30 --days 0
uv run python main.py --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 from __future__ import annotations
import argparse import argparse
@ -6,17 +6,44 @@ import tempfile
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
import requests
from db import RepoUnavailable from db import RepoUnavailable
from job import run_yaoex_pay from job import run_yaoex_pay
from parse_xls import parse_order_xls from parse_xls import parse_order_xls
from repo import EmptyPayRepo, OraclePayRepo from repo import EmptyPayRepo, OraclePayRepo
from yaoex_client import day_range, export_orders 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 PROJECT_ROOT = Path(__file__).resolve().parent
YAOEX_PLATFORM_TYPE = 66
YBM_PLATFORM_TYPE = 48
def parse_args(argv: list[str] | None = None) -> argparse.Namespace: 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 = argparse.ArgumentParser(description="1 号药城支付落表")
parser.add_argument("--xls", type=Path, default=None, help="已导出的订单明细 xls,跳过商城导出") parser.add_argument("--xls", type=Path, default=None, help="已导出的订单明细 xls,跳过商城导出")
parser.add_argument( parser.add_argument(
@ -33,8 +60,31 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
return parser.parse_args(argv) 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: def main(argv: list[str] | None = None, repo=None) -> None:
args = parse_args(argv) 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() load_cart_dotenv()
xls_path = args.xls xls_path = args.xls
tmp_xls: Path | None = None 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__": if __name__ == "__main__":
main() main()

View File

@ -1,7 +1,7 @@
[project] [project]
name = "rpa-cart" name = "rpa-cart"
version = "0.1.0" version = "0.1.0"
description = "洁达加购与支付回传。表 2 → 接口 2。本期先做 1 号药城支付落表。" description = "洁达加购与支付回传。表 2 加购班 + 1 号药城支付落表。不推接口 2。"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"oracledb>=2.4.1", "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.days == 0
assert args.dry_run is False assert args.dry_run is False
assert args.xls is None assert args.xls is None
assert args.command == "pay"
def test_parse_args_xls_dry_run(): def test_parse_args_xls_dry_run():
args = parse_args(["--xls", "/tmp/orders.xls", "--dry-run"]) args = parse_args(["--xls", "/tmp/orders.xls", "--dry-run"])
assert args.dry_run is True assert args.dry_run is True
assert str(args.xls).endswith("orders.xls") 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 from __future__ import annotations
import sys import sys
@ -9,12 +9,16 @@ if str(SELECTION_ROOT) not in sys.path:
sys.path.append(str(SELECTION_ROOT)) sys.path.append(str(SELECTION_ROOT))
from auth_login import ( # noqa: E402 from auth_login import ( # noqa: E402
DEFAULT_YBM_COOKIE_PATH,
DEFAULT_YAOEX_AUTH_PATH, DEFAULT_YAOEX_AUTH_PATH,
BrowserRuntime, BrowserRuntime,
load_cookie_file,
load_dotenv, load_dotenv,
load_secrets, load_secrets,
load_yaoex_auth_file, load_yaoex_auth_file,
login_yaoex, login_yaoex,
login_ybm,
save_cookie_file,
save_yaoex_auth, save_yaoex_auth,
) )
from search_yaoex import YaoexAuth # noqa: E402 from search_yaoex import YaoexAuth # noqa: E402
@ -49,3 +53,29 @@ def resolve_yaoex_auth(
finally: finally:
if own: if own:
session.close() 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()