AutoRpa/docs/superpowers/specs/2026-09-14-jieda-procure-cr...

499 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 洁达选品:改造商城查价工程(详细方案)
> Windows 按本文改同事原工程。概要口径见 [2026-09-14-jieda-procure-crawler-overview.md](./2026-09-14-jieda-procure-crawler-overview.md)。
> 不要改仓库一期状态机,不要把 ±10% 比价逻辑留在主流程里。
| 文档属性 | 内容 |
| --- | --- |
| 版本 | V1.0 |
| 日期 | 2026-09-14 |
| 执行环境 | Windows + 艺赛旗 Python 应用(`isa-rpa` + `uv`) |
| 基线代码 | 同事工程 `main.py`(约 907 行) |
| 建议工作副本 | 拷一份到本仓库 `rpa_yisaiqi/procure_mall/`,或 Windows 本地独立目录;不要在原 zip 上直接当唯一真源 |
---
## 1. 原工程:留什么、删什么、补什么
### 1.1 原样保留(不要重写)
| 函数 / 能力 | 作用 |
| --- | --- |
| `SecretClient` + `YAOEX_SECRET_NAME` / `YBM_SECRET_NAME` | 密码箱登录 |
| `get_yaoex_auth` / `ensure_yaoex_logged_in` | 1 号药城登录,抽 `token / userId / buyerCode` |
| `get_ybm_cookies` / `ensure_ybm_logged_in` | 药帮忙登录,抽 cookie |
| `build_yaoex_search_request` / `query_yaoex_products` | `homeSearchList`,`keyword=批准文号`,`per=20`,`deadLineStart=12` |
| `decrypt_yaoex_price` / `build_yaoex_price_decrypt_key` | 价格 AES 解密 |
| `build_ybm_search_payload` / `query_ybm_products` | `list-products`,`queryWord=批准文号`,`isThirdCompany=0`,`isEffectPeriodOverOneYear=1` |
| `post_json_with_retry` | 3 次重试 |
| 按批准文号缓存 | 同一文号不打两次 |
搜索接口:
- 1 号药城:`POST https://gateway-b2b.fangkuaiyi.com/home/search/homeSearchList`
- 药帮忙:`POST https://www.ybm100.com/new-front/search/list-products`
字段映射继续用原代码:
| 结果字段 | 1 号药城 | 药帮忙 |
| --- | --- | --- |
| 平台索引ID | `spuCode` | `productInfo.skuId` |
| 平台名称 | `shortName` | `productInfo.showName` |
| 平台规格 | `spec` | `productInfo.spec` |
| 平台价格 | `decrypt(price)` | `price`,空则 `fob`;库存 0 时价当 0 |
| 效期 | `deadLine` | `productInfo.nearEffect` |
| 商家 | `vendorName` | `productInfo.shopName` |
| 库存 | `stockCount` | `productInfo.availableQty` |
### 1.2 必须删除或停用
- `is_in_purchase_price_band`
- `filter_rows_by_purchase_price_band`
- `main()` 里「价格区间过滤后总行数」那段
- 把全部候选行当最终输出
### 1.3 必须新增
- 选品规则引擎(两平台共用)
- 按行决策:每行每平台 0 或 1 个仓
- 加购适配器(接口优先,页面兜底)
- `--dry-run` / `--limit` / `--platform`
- 新结果表(结论 + 原因,不再是比价明细大表)
---
## 2. 建议拆文件
原 `main.py` 已 900 行,不要再往里堆加购和规则。Windows 按下列拆;若艺赛旗工程限制只能单文件,也必须把规则和加购写成独立函数,禁止写进 `query_*`。
```text
procure_mall/
pyproject.toml # 沿用原工程
main.py # 只编排:读入 → 登录 → 循环行 → 写结果
search_yaoex.py # 从 main.py 搬登录+查询+解密
search_ybm.py # 从 main.py 搬登录+查询
http_util.py # post_json_with_retry 等
rules.py # 合格判定 + 仓库排序
cart_yaoex.py # 1 号药城加购
cart_ybm.py # 药帮忙加购
io_excel.py # 读输入、写结果
tests/test_rules.py # 规则单测,不连商城
```
`main.py` 目标骨架:
```python
def main() -> None:
args = parse_args() # --dry-run 默认 True;--limit N;--platform all|yaoex|ybm
rows = load_input_rows(args.input_path)
if args.limit:
rows = rows[: args.limit]
yaoex_auth = asyncio.run(get_yaoex_auth()) if args.platform in ("all", "yaoex") else None
ybm_cookies = asyncio.run(get_ybm_cookies()) if args.platform in ("all", "ybm") else None
decisions = []
for purchase in rows:
if yaoex_auth is not None:
decisions.append(run_one_platform(purchase, "1号药城", yaoex_auth, dry_run=args.dry_run))
if ybm_cookies is not None:
decisions.append(run_one_platform(purchase, "药帮忙", ybm_cookies, dry_run=args.dry_run))
write_decision_file(decisions)
```
单行单平台:
```python
def run_one_platform(purchase, mall_name, auth, dry_run=True) -> dict:
try:
candidates = search_candidates(mall_name, auth, purchase["批准文号"])
except Exception as exc:
return decision(purchase, mall_name, status="FAILED", reason=f"QUERY_FAILED:{exc}")
picked, rejected = select_warehouse(candidates, purchase)
if picked is None:
return decision(purchase, mall_name, status="SKIPPED", reason=rejected_summary(rejected), raw=candidates)
if dry_run:
return decision(purchase, mall_name, status="ADDED", reason="DRY_RUN", picked=picked)
try:
cart_ref = add_to_cart(mall_name, auth, picked, qty=purchase["采购数量"])
except Exception as exc:
return decision(purchase, mall_name, status="FAILED", reason=f"CART_FAILED:{exc}", picked=picked)
return decision(purchase, mall_name, status="ADDED", reason="CART_OK", picked=picked, cart_ref=cart_ref)
```
查询失败不得改成 SKIPPED。
---
## 3. 输入 / 输出
### 3.1 输入 Excel(改模板,不要沿用只有采购价格的比价模板)
路径建议:`桌面/福建商城选品测试/选品输入.xlsx`
第一行表头,从第 2 行起数据。
| 列名 | 必填 | 说明 |
| --- | --- | --- |
| 行号 | 否 | 没有则按 Excel 行号 |
| 批准文号 | 是 | 查询关键字;空行跳过 |
| 销售价格 | 是 | 毛利基准。缺或无法转数字 → 该行两平台都 SKIPPED,原因 `NO_SALE_PRICE` |
| 采购数量 | 是 | 库存门槛和加购数量。缺或 ≤0 → SKIPPED,原因 `NO_QTY` |
| 通用名 | 否 | 回填 |
| 规格 | 否 | 回填,不参与匹配 |
| 生产厂家 | 否 | 回填,不参与匹配 |
本期**不要用采购价格做合格判断**。模板里可以留着采购价格列,规则引擎忽略它。
`--limit 5` 只跑前 5 条有效行。
### 3.2 输出 Excel
路径:`桌面/福建商城选品测试/结果/选品结果_YYYYMMDD_HHMMSS.xlsx`
一行 = 一个输入行 × 一个平台。
| 列名 | 说明 |
| --- | --- |
| 行号 | 输入行 |
| 批准文号 | |
| 通用名 | |
| 销售价格 | |
| 采购数量 | |
| 商城 | `1号药城` / `药帮忙` |
| 结论 | `ADDED` / `SKIPPED` / `FAILED` |
| 原因 | 见 3.3 |
| 选中商家 | ADDED 时必填 |
| 平台索引ID | sku/spu |
| 平台名称 | |
| 平台规格 | |
| 平台价格 | |
| 效期原文 | |
| 库存 | |
| 毛利率 | `(销售价-平台价)/销售价`,保留 4 位小数 |
| dry_run | `Y/N` |
| 加购凭证 | 接口回执或页面摘要;dry-run 为空 |
| 候选商家清单 | 可选,分号拼接,便于核对为什么没选上 |
另写一份 `未匹配商家_时间戳.txt`:所有出现过、但不在 11 家名单里的商家名(去重)。用来决定要不要加别名。
### 3.3 原因码
| 原因码 | 结论 | 何时 |
| --- | --- | --- |
| `NO_SALE_PRICE` | SKIPPED | 销售价缺失 |
| `NO_QTY` | SKIPPED | 采购数量缺失或 ≤0 |
| `NO_HIT` | SKIPPED | 接口无货或空列表 |
| `NO_SELF_WAREHOUSE` | SKIPPED | 有货但不在 11 家 |
| `MARGIN_LT_5PCT` | SKIPPED | 自营仓有货但毛利不够(可与其它原因并列,取「没有任何候选过齐 1~4」) |
| `EXPIRY_LT_12M` | SKIPPED | 效期不够 |
| `STOCK_LT_QTY` | SKIPPED | 库存不够 |
| `NO_CANDIDATE_AFTER_RULES` | SKIPPED | 1~4 筛完为空(推荐作为总原因,明细放候选清单) |
| `DRY_RUN` | ADDED | 规则通过,未真正加购 |
| `CART_OK` | ADDED | 加购成功 |
| `QUERY_FAILED:...` | FAILED | 搜索接口失败(已重试) |
| `CART_FAILED:...` | FAILED | 加购失败 |
| `CART_UNKNOWN:...` | FAILED | 加购后无法确认是否进车 |
SKIPPED 的总原因用 `NO_CANDIDATE_AFTER_RULES`,并在「候选商家清单」里带每条被淘汰原因。不要只写一个模糊的「不合格」。
---
## 4. 规则引擎(`rules.py`)
两平台只把候选翻译成同一结构,再进引擎。平台代码禁止自己改优先级。
```python
@dataclass
class Candidate:
mall_name: str
sku_id: str
name: str
spec: str
price: float
expiry_raw: str
expiry_months: float | None
vendor_raw: str
vendor_norm: str
stock: float
extra: dict
@dataclass
class PurchaseLine:
approval_no: str
sale_price: float
qty: float
```
### 4.1 商家规范化
```text
去首尾空格
去掉前缀 "[自营]"(全角空格也去)
压缩连续空格为单空格
```
规范化后必须 **等于** 下面 11 个字符串之一,顺序即优先级(下标越小越优先):
```python
WAREHOUSE_ORDER = [
"广东壹号药业有限公司",
"昆山亿方药业有限公司",
"湖北亿昊药业有限公司",
"天津壹号药业有限公司",
"重庆亿昊药业有限公司",
"重庆壹号医药有限公司",
"湖南壹号药业股份有限公司",
"辽宁亿昊药业有限公司",
"河南壹号医药有限公司",
"甘肃壹号云医药有限责任公司企业店",
"陕西壹号药业有限公司",
]
```
本期不做别名、不做包含匹配。对不上的 `vendor_raw` 记入未匹配清单。
### 4.2 价格
```text
毛利 = (销售价格 - 平台价) / 销售价格
合格:毛利 >= 0.05
即:平台价 <= 销售价格 * 0.95
```
平台价无法转数字 → 该候选淘汰。销售价 ≤ 0 → 整行 SKIPPED。
**禁止**用采购价、禁止 ±10% 区间。
### 4.3 效期
目标:剩余 ≥ 12 个月。解析顺序:
1. 纯数字(含小数):当作月数。
2. 含「月」:抽出数字当月数。
3. 日期(`YYYY-MM-DD` / `YYYY/MM/DD` / `YYYY-MM`):`(到期日 - 今天) / 30.4375`,到期日按该月最后一天。
4. 都不是:`expiry_months = None`,该候选淘汰(原因 `EXPIRY_UNPARSEABLE`)。
接口虽已带 12 个月过滤,仍以本函数为准。
### 4.4 库存
`stock >= qty`。库存无法转数字或为 0 → 淘汰。药帮忙无货把价格改成 0 的旧逻辑可保留在映射层,引擎侧会因库存/价格再淘汰一次。
### 4.5 择仓
```text
passed = [c for c in candidates if 自营仓 and 毛利 and 效期 and 库存]
if not passed: return None
return min(passed, key=lambda c: WAREHOUSE_ORDER.index(c.vendor_norm))
```
同一仓库多条 SKU:取平台价更低的;再相同取库存更大的。不要两条都加购。
### 4.6 必须先写的单测(不连网)
文件:`tests/test_rules.py`。Windows 在改 `main.py` 前先把这些测过。
| 用例 | 期望 |
| --- | --- |
| 11 家名单内、毛利 6%、效期 13 月、库存够 | 选出该仓 |
| 毛利刚好 5% | 选出(`>= 0.05`) |
| 毛利 4.9% | 淘汰 |
| 商家不在名单 | 淘汰 |
| 商家带 `[自营] 广东壹号药业有限公司` | 视为广东壹号 |
| 广东和昆山都合格 | 选广东 |
| 只有昆山合格 | 选昆山 |
| 库存 9、采购 10 | 淘汰 |
| 效期 11 个月 | 淘汰 |
| 效期 `2027-09-14` 且今天起 ≥12 个月 | 过 |
| 空候选 | SKIPPED 语义(函数返回 None) |
| 销售价用 100、平台价 96 | 淘汰(毛利 4%) |
| 销售价 100、平台价 95 | 过 |
用采购价 ±10% 的旧例子(平台价 96、采购价 90)在新规则下若销售价是 100 应淘汰。加一条单测钉死,防止有人把 ±10% 加回来。
命令:
```bat
cd /d 工程目录
uv run pytest tests/test_rules.py -v
```
期望:全绿。未装 pytest 则 `uv add --dev pytest`。
---
## 5. 加购(原工程没有,这是新工作)
### 5.1 抓接口(每个平台做一次,记录进 `docs/cart-api.md` 或工程内 `CART_API.md`)
在 Windows 上用已经登录的账号:
1. 打开对应商城,F12 → Network,过滤 XHR/Fetch。
2. 搜索一个确定能加的自营仓商品。
3. 点「加入购物车」,看新出现的请求。
4. 记录:URL、方法、请求头(cookie / token 哪些必要)、body、成功响应长什么样。
5. 把 body 里的 sku/spu、数量、商家 ID 标出来。
1 号药城登录态用 `ycgltoken` 等;药帮忙用整份 cookie。加购请求必须带上搜品时同一套认证,不要重新登录。
若加购接口有 CSRF / 签名 / 人机验证:不要硬解,改页面点击(5.3)。
### 5.2 接口加购(优先)
`cart_yaoex.add(auth, candidate, qty) -> cart_ref`
`cart_ybm.add(cookies, candidate, qty) -> cart_ref`
- 数量 = 采购数量。
- 成功:响应明确成功,或随后查购物车能看到该 sku + 数量。
- 响应含糊:结论 `FAILED`,原因 `CART_UNKNOWN`,不要报 ADDED。
- 已在车里:视为成功(幂等),不要再加一次导致数量翻倍。先查车再加。
### 5.3 页面加购(接口抓不到才用)
只对规则已经选出的那 **1 条** 操作:
1. 打开商品页或搜索结果中该 sku。
2. 填数量。
3. 点加入购物车。
4. 等成功提示或打开购物车核对。
禁止:重新用页面搜索批准文号再人工挑仓。仓已经由规则选定。
页面选择器用艺赛旗 `BrowserSession`,登录态复用。不要为加购再登一次(除非会话丢了)。
### 5.4 dry-run
默认 `--dry-run`。`ADDED + DRY_RUN` 表示规则通过。关 dry-run 必须显式 `--execute-cart`。
第一次真正加购只用 `--limit 1 --platform yaoex --execute-cart`,人工打开购物车核对后再加量。
---
## 6. Windows 环境与运行
### 6.1 环境
- 艺赛旗 Python 应用,能 `uv run main.py`
- 密码箱已有:
- `yaoex_GYJDLHT_login`
- `yaobangmang_18146141930_login`
- 账号必须是业务允许加购的测试/正式采购账号。真正加购前跟业务确认,避免测出一车真货。
- 桌面目录:`福建商城选品测试\`
### 6.2 命令
```bat
:: 只判定,不加购(默认)
uv run main.py --dry-run --limit 5
:: 只跑一个平台
uv run main.py --dry-run --limit 5 --platform yaoex
:: 确认规则后,真加购 1 条
uv run main.py --execute-cart --limit 1 --platform yaoex
```
把命令写进 `README.md`,替换原「查价结果」说明。
### 6.3 验收顺序(必须按这次序,不要一上来一千条真加购)
1. `pytest tests/test_rules.py` 全绿。
2. `--dry-run --limit 5 --platform yaoex`:结果表 5 行,结论只有 ADDED/SKIPPED/FAILED,没有 ±10% 残留。
3. 同样 5 条跑药帮忙。
4. 人工抽 2 条 ADDED:对照网页,商家是否 11 家之一、毛利是否 ≥5%、效期、库存。
5. `--execute-cart --limit 1` 一个平台,打开购物车看 sku 和数量。
6. 再跑同一条,确认不会把数量翻倍(幂等)。
7. 再 `--limit 20` dry-run,看耗时。预估一千条:约 `20 条耗时 × 50`,写入结果 README。登录应只发生两次(每平台一次)。
失败则停在当前步,不要跳到一千条。
---
## 7. 异常
| 情况 | 处理 |
| --- | --- |
| 密码箱空 | 立即失败退出,不写半份结果 |
| 登录失败 | 该平台本批中止;另一平台仍可跑(已登录的话) |
| 单文号查询 3 次仍失败 | 该行该平台 FAILED,继续下一行 |
| 加购超时 | FAILED `CART_UNKNOWN`,不要当 SKIPPED |
| 极验登录点一次不够 | 沿用原等待逻辑;仍失败则整平台中止,记登录错误 |
| 关闭 dry-run 但加购模块未实现 | 启动时报错退出,禁止假装 ADDED |
脚本不点支付、不打开结算页。
---
## 8. Windows 任务清单
按序做。每步可单独验证。
### 任务 1:工作副本和输入模板
- [ ] 拷贝同事工程到工作目录(仓库 `rpa_yisaiqi/procure_mall/` 或 Windows 本地)
- [ ] 桌面建 `福建商城选品测试\选品输入.xlsx`,表头按 3.1
- [ ] 放 5 条真实批准文号、销售价格、采购数量(问业务要,不要用假文号)
- [ ] `uv run python -c "import isa_rpa, requests, openpyxl; print('ok')"` 能过
### 任务 2:规则引擎 + 单测
- [ ] 新建 `rules.py`、`tests/test_rules.py`
- [ ] 实现规范化、毛利、效期、库存、择仓
- [ ] `uv run pytest tests/test_rules.py -v` 全绿
- [ ] 确认没有 import 原 ±10% 函数
### 任务 3:搜品接到按行决策(仍不加购)
- [ ] 拆出或保留 `query_yaoex_products` / `query_ybm_products`
- [ ] 候选映射成 `Candidate`
- [ ] `main.py` 改为按行循环,默认 dry-run
- [ ] 删除 `filter_rows_by_purchase_price_band` 调用
- [ ] 写新结果表(3.2)
- [ ] 跑 `uv run main.py --dry-run --limit 5`
- [ ] 人工核对 2 条 ADDED、2 条 SKIPPED
### 任务 4:抓加购接口
- [ ] 1 号药城 F12 抓「加入购物车」,写入 `CART_API.md`
- [ ] 药帮忙同样写一份
- [ ] 判定:接口可用 / 必须页面点击
### 任务 5:实现加购(默认仍 dry-run)
- [ ] `cart_yaoex.py` / `cart_ybm.py`
- [ ] 先查车再加,避免数量翻倍
- [ ] `--execute-cart --limit 1` 单平台
- [ ] 人工打开购物车核对
- [ ] 再跑同一条,数量不翻倍
### 任务 6:收尾
- [ ] README:命令、dry-run 默认、输入输出路径、密码箱名称
- [ ] `--limit 20` dry-run 耗时写进 README
- [ ] 把 `未匹配商家` 列表交给业务看要不要补别名(本期不改代码硬匹配)
- [ ] 交付:工程、5 条结果 xlsx、加购成功截图、CART_API.md
---
## 9. 与后续 Oracle / 状态中心的边界
本期 `decision` 字典就是以后 `PROCURE_ITEM_RUN` 的 `output_json`。字段先对齐:`result=ADDED|SKIPPED|FAILED`、原因、商家、sku、价格、数量。
不要在本期接 FastAPI、不要写 MySQL、不要为选品新建 Oracle 表。下一期只换输入输出通道,不换规则和搜品。
---
## 10. 未决(不影响 Windows 开工)
| 项 | 本期默认 | 谁拍板 |
| --- | --- | --- |
| 两平台都合格是否都加购 | 都加 | 业务;要互斥再改 `main` 循环 |
| 毛利 刚好 5% | 算合格 `>=` | 已按设计说明「不低于 5%」 |
| 商家名不完全等于 11 家 | 淘汰并记未匹配清单 | 业务补别名后再改 `WAREHOUSE_ORDER` 映射 |
| 真正加购用测试账号还是正式采购账号 | 必须业务书面确认后再 `--execute-cart` | 业务 / 运维 |
加购接口在改代码前未知,这是正常的。任务 4 的产出就是答案,不是阻塞任务 1~3 的理由。