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

18 KiB
Raw Blame History

洁达选品:改造商城查价工程(详细方案)

Windows 按本文改同事原工程。概要口径见 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_*。

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 目标骨架:

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)

单行单平台:

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)

两平台只把候选翻译成同一结构,再进引擎。平台代码禁止自己改优先级。

@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 商家规范化

去首尾空格
去掉前缀 "[自营]"(全角空格也去)
压缩连续空格为单空格

规范化后必须 等于 下面 11 个字符串之一,顺序即优先级(下标越小越优先):

WAREHOUSE_ORDER = [
    "广东壹号药业有限公司",
    "昆山亿方药业有限公司",
    "湖北亿昊药业有限公司",
    "天津壹号药业有限公司",
    "重庆亿昊药业有限公司",
    "重庆壹号医药有限公司",
    "湖南壹号药业股份有限公司",
    "辽宁亿昊药业有限公司",
    "河南壹号医药有限公司",
    "甘肃壹号云医药有限责任公司企业店",
    "陕西壹号药业有限公司",
]

本期不做别名、不做包含匹配。对不上的 vendor_raw 记入未匹配清单。

4.2 价格

毛利 = (销售价格 - 平台价) / 销售价格
合格:毛利 >= 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 择仓

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% 加回来。

命令:

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 命令

:: 只判定,不加购(默认)
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 的理由。