# 洁达选品:改造商城查价工程(详细方案) > 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 的理由。