18 KiB
洁达选品:改造商城查价工程(详细方案)
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_bandfilter_rows_by_purchase_price_bandmain()里「价格区间过滤后总行数」那段- 把全部候选行当最终输出
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 个月。解析顺序:
- 纯数字(含小数):当作月数。
- 含「月」:抽出数字当月数。
- 日期(
YYYY-MM-DD/YYYY/MM/DD/YYYY-MM):(到期日 - 今天) / 30.4375,到期日按该月最后一天。 - 都不是:
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 上用已经登录的账号:
- 打开对应商城,F12 → Network,过滤 XHR/Fetch。
- 搜索一个确定能加的自营仓商品。
- 点「加入购物车」,看新出现的请求。
- 记录:URL、方法、请求头(cookie / token 哪些必要)、body、成功响应长什么样。
- 把 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 条 操作:
- 打开商品页或搜索结果中该 sku。
- 填数量。
- 点加入购物车。
- 等成功提示或打开购物车核对。
禁止:重新用页面搜索批准文号再人工挑仓。仓已经由规则选定。
页面选择器用艺赛旗 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_loginyaobangmang_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 验收顺序(必须按这次序,不要一上来一千条真加购)
pytest tests/test_rules.py全绿。--dry-run --limit 5 --platform yaoex:结果表 5 行,结论只有 ADDED/SKIPPED/FAILED,没有 ±10% 残留。- 同样 5 条跑药帮忙。
- 人工抽 2 条 ADDED:对照网页,商家是否 11 家之一、毛利是否 ≥5%、效期、库存。
--execute-cart --limit 1一个平台,打开购物车看 sku 和数量。- 再跑同一条,确认不会把数量翻倍(幂等)。
- 再
--limit 20dry-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 20dry-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 的理由。