From 94215f7a7fd267f8d61561678ebe65cbbef4684e Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Fri, 2 Oct 2026 16:42:11 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20lock=20=E9=80=89=E5=93=81=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=BB=93=E5=BA=8F=E4=B8=8E=E6=8B=86=E9=87=8F?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the 2026-10-02 selection rules: 4% margin, four-tier mixed warehouses, greedy split allocation, all-or-nothing persist. No cart, no interface 2. --- ...10-02-selection-split-allocation-design.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md diff --git a/docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md b/docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md new file mode 100644 index 0000000..f1750cb --- /dev/null +++ b/docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md @@ -0,0 +1,279 @@ +# 选品:跨平台仓序 + 拆量落表 + +| 文档属性 | 内容 | +| --- | --- | +| 日期 | 2026-10-02 | +| 状态 | 已锁定,本轮实施依据 | +| 范围 | `选品流程/` 规则、班次判定、表 2 落盘。不加购,不推接口 2 | +| 替代 | `选品流程/README.md` 规则节;口径文档 3.1 / 3.3 中「单仓满量、两边都写满量」的作业语义 | +| 不变 | 接口 1 收件、表结构、查询键(条形码)、两平台登录/搜索、接口 2 报文形态 | + +一张 `omsId` 一个品种。`status` 仍是该品种整单采购状态。 + +--- + +## 1. 要做成什么 + +从表 1 领一行,两个商城都搜,**合并候选后统一判定**: + +1. 硬过滤:自营仓、毛利 ≥ 4%、效期 ≥ 12 个月。库存不再是过滤项。 +2. 按四级仓序(同仓低价、再高库存)排序。 +3. 同一平台同一仓只留 1 条候选。 +4. 按序贪心拆量,直到计划采购量凑齐。 +5. 凑齐:每个仓一行写入表 2,`status=1`,数量是该仓拆到的件数,合计等于计划量。 +6. 凑不齐:不写成功行,表 2 `status=2`,表 1 `deal_flag=2`。 + +完成标准:`选品流程` 单测全绿;用内存仓库能演示「杭州 3 + 广东 7 = 计划 10」落两行,以及「合计 9 < 10」整单失败且成功行为 0。 + +--- + +## 2. 不做什么 + +- 真加购、清空购物车、页面点击 +- 接口 2 推送(`push_status` 仍为 0) +- 批准文号搜索、商家名模糊匹配 +- 中包装 / 起订量 +- 同一仓多 SKU 拆成两行 +- 改表结构、改接口 1 + +--- + +## 3. 已锁定决策 + +| 项 | 决定 | +| --- | --- | +| 毛利 | `(salePrice - 平台价) / salePrice >= 0.04`,恰好 4% 过 | +| 拆量 | 跨仓跨平台贪心拆。凑不齐一件都不落成功行 | +| 仓序 | 见第 4 节全称表。级内顺序即距离顺序 | +| 湖南壹号、甘肃壹号云 | 移出自营集合 | +| 同一平台同一仓 | 只留 1 条(低价,再高库存)。余量给下一仓 | +| `multi_platform_flag` | 实际拆到两个 `platform_type` 时为 1;同一平台拆两仓为 0 | +| 一边故障、另一边单独凑齐 | 按成功落,不写故障平台失败行 | +| 一边故障、剩下的凑不齐 | **系统故障**(可把 `deal_flag` 改回 0 重跑),禁止写成库存不足 | +| 两边都搜到、合计不够 | 业务失败「库存不足」,该 `omsId` 不重跑选品 | +| 查询键 | 仍是条形码 | + +--- + +## 4. 自营仓全称与顺序 + +商家名去掉 `[自营]`、压缩空白后必须**等于**下表某一行。对不上 = 非自营,淘汰。 + +| 序 | 级 | 平台 | 全称 | +| ---: | ---: | --- | --- | +| 0 | 1 | 药帮忙 | 杭州小药药自营旗舰店 | +| 1 | 1 | 药帮忙 | 长沙小药药自营旗舰店 | +| 2 | 1 | 药帮忙 | 湖北小药药自营旗舰店 | +| 3 | 2 | 1号药城 | 广东壹号药业有限公司 | +| 4 | 2 | 1号药城 | 昆山亿方药业有限公司 | +| 5 | 2 | 1号药城 | 湖北亿昊药业有限公司 | +| 6 | 3 | 药帮忙 | 济南小药药自营旗舰店 | +| 7 | 3 | 1号药城 | 天津壹号药业有限公司 | +| 8 | 3 | 1号药城 | 重庆亿昊药业有限公司 | +| 9 | 4 | 1号药城 | 辽宁亿昊药业有限公司 | +| 10 | 4 | 1号药城 | 陕西壹号药业有限公司 | +| 11 | 4 | 1号药城 | 河南壹号医药有限公司 | +| 12 | 4 | 1号药城 | 重庆壹号医药有限公司 | +| 13 | 4 | 药帮忙 | 重庆小药药自营旗舰店 | + +删除 `WAREHOUSE_ALIASES` 里甘肃那条。平台字段与名单不一致(例如 1 号药城搜出杭州小药药)仍按**商家全称**定序,不按搜索来源改序。 + +--- + +## 5. 算法 + +计划量 `qty = int(plan_purchase_qty)`。`qty <= 0` 仍走现有 `NO_QTY`。库存按件向下取整后再拆。 + +### 5.1 过滤(单条候选) + +`evaluate_candidate` 不再看库存。淘汰原因仅: + +- `NOT_SELF_WAREHOUSE` +- `PRICE_UNPARSEABLE` +- `MARGIN_LT_4PCT`(原 `MARGIN_LT_5PCT`) +- `EXPIRY_UNPARSEABLE` / `EXPIRY_LT_12M` +- `NO_SALE_PRICE`(采购行级,先于搜品) + +库存为 `None` 或向下取整后 `<= 0`:可以过过滤,但折叠和拆量时当作 0,拆不到量。 + +### 5.2 折叠 + +`key = (mall_name, vendor_norm)`。同一 key 多条时保留 `(price, -stock)` 最优的一条。其余记入 rejected,原因 `SAME_WAREHOUSE_NOT_BEST`。 + +### 5.3 排序 + +` (WAREHOUSE_RANK[vendor_norm], price, -stock) `。`price` 缺失视为 `+inf`(过滤阶段已淘汰,排序不应再见到)。 + +### 5.4 拆量 + +``` +remaining = qty +lines = [] +for candidate in sorted_passed: + take = min(int(stock), remaining) + if take <= 0: + continue + lines.append(Allocation(candidate, take)) + remaining -= take + if remaining == 0: + break +``` + +库存 `None` 时 `take` 当 0,不要对 `None` 做 `int()`。 + +`remaining == 0` → 成功,`lines` 即为表 2 成功行。 +`remaining > 0` → 见第 6 节,**丢弃 `lines`**,不落成功行。 + +禁止:先落部分成功行再标失败。 + +--- + +## 6. 班次:先搜齐再判定 + +`job.run_batch` 对一行先跑完本次启用的全部平台搜索,再调用一次统一判定。禁止再「每平台独立选仓、独立满量写入」。 + +每个平台的搜索结果是三种之一: + +| 结果 | 含义 | +| --- | --- | +| `candidates` | 搜到 0..n 条(空列表 = 无命中,不是故障) | +| `LOGIN_FAILED:...` | 本平台本班不可用 | +| `QUERY_FAILED:...` | 本行本平台查询异常 | + +无条形码:两个平台都不搜,业务失败 `NO_BAR_CODE`(与现在相同)。 + +统一判定: + +1. 采购行缺销售价 / 数量:`SKIPPED`,不搜也行(保持现有,在搜之前拦)。 +2. 把所有成功搜到的 `candidates` 合并,走第 5 节。 +3. 凑齐:成功。故障平台的错误忽略,不写该平台失败行。 +4. 凑不齐,且存在任一平台 `LOGIN_FAILED` / `QUERY_FAILED`:整单 **系统故障**。 +5. 凑不齐,且无系统故障:业务失败。 + +只跑 `--platform yaoex` 时,可用候选只有 1 号药城。凑不齐且该平台无故障 → 业务失败;该平台故障 → 系统故障。 + +--- + +## 7. 落表 + +成功行(每条 `Allocation` 一行): + +| 列 | 值 | +| --- | --- | +| `operate_type` | 1 | +| `oms_id` | 本行 | +| `platform_type` | 48 药帮忙 / 66 1号药城 | +| `supplier_name` | `vendor_raw` | +| `bar_code` / `org_code` | 原样 | +| `purchase_price` | 该候选平台价,四位小数 | +| `purchase_qty` | 该仓 `take`,**不是**计划全量 | +| `purchase_total` | 单价 × 该仓数量,四位小数 | +| `status` | 1 | +| `fail_reason` | null | +| `push_status` | 0 | +| `multi_platform_flag` | 本单成功行里出现 ≥2 个不同 `platform_type` 则为 1,否则 0 | + +表 1:`deal_flag=1`,`deal_result` 形如 `选品成功:药帮忙、1号药城`(按成功行出现的商城去重,顺序:药帮忙、1号药城)。 + +业务失败:每个**已启用且已判定**的平台落一行 `status=2`,`purchase_qty` 仍用计划量(造数校验:数量 > 0),`purchase_price` 仍用销售价兜底。`supplier_name` 仍为「无符合规则供应商」。`multi_platform_flag` 按失败行平台数(≥2 个平台则为 1)。 + +业务失败原因(`fail_reason` / `deal_result`)。先看过滤后是否留下可拆候选(含库存为 0、以及被 `SAME_WAREHOUSE_NOT_BEST` 折叠掉但仍算「该仓有过过滤候选」——以折叠**之前**过过滤的集合为准): + +1. `NO_BAR_CODE` / `NO_SALE_PRICE` / `NO_QTY`:沿用现有中文 +2. **过过滤集合非空,但拆不满**:一律 `无符合规则商品,库存不足`。rejected 里即使同时有非自营、效期、毛利淘汰,也不改这条。 +3. **过过滤集合为空**时,才按候选清单(含淘汰原因)往下看,取第一条能成立的: + - 含 `MARGIN_LT_4PCT`:`无符合规则商品,毛利不足 4%` + - 含 `EXPIRY_LT_12M` / `EXPIRY_UNPARSEABLE`:`无符合规则商品,效期不足 12 个月` + - 含 `NOT_SELF_WAREHOUSE`:`无符合规则商品,非自营仓` + - 全部无命中:`无符合规则商品,平台无命中` + - 其它:`无符合规则商品` + +第 9 节必测补一条:杭州自营库存 4 + 若干非自营/效期短淘汰、计划 10 → 原因必须是库存不足,不能是非自营或效期。 + +系统故障:每个启用平台一行 `status=2`,原因带 `系统故障:`。有故障的平台写该平台错误;无故障但被连带失败的平台写 `系统故障:对端平台不可用,合计库存不足`。表 1 `deal_result` 以 `系统故障:` 开头。`SelectionResult.mall_errors` 按平台给出原始错误串,persist 据此写各行,禁止所有失败行共用一句。 + +`JobSummary` 按表 2 行计:`added` = `status=1` 行数,`skipped` = 业务失败行数,`failed` = 系统故障行数。 + +--- + +## 8. 模块怎么改 + +| 文件 | 改什么 | +| --- | --- | +| `rules.py` | 新名单;`MIN_MARGIN=0.04`;`evaluate_candidate` 去掉库存;新增 `allocate()`(过滤+折叠+排序+拆量);`select_warehouse` 删除或改为调用 `allocate` 后取第一仓——**禁止**再作为班次入口 | +| `pipeline.py` | 新增 `run_selection(purchase, mall_outcomes)`,一次产出整单结果。`run_one_platform` 只留给单测过渡,班次不再调用 | +| `job.py` | 先收集各平台搜索结果,再 `run_selection` | +| `persist.py` | 吃整单结果:成功按拆量多行;`multi_platform_flag` 按平台种类数;失败原因 5% → 4%,并增加库存不足 | +| `README.md` | 规则节换成本文摘要 | +| `资料/RPA订单中心接口开发口径_20260930.md` | 3.1 改为毛利 4%、库存改为拆量;3.2 示例「毛利不足 5%」改为 4%;3.3 「只凑到一部分」改为整单失败、不落部分成功 | +| `资料/采购平台选品条件优先项_20260902.txt` | 文首加「已被 2026-10-02 spec 替代」;不改历史原文 | + +搜索、登录、`repo.py`、表 DDL 不动。 + +建议的数据结构(名称可改,语义不要改): + +```python +@dataclass +class MallOutcome: + mall_name: str + candidates: list[Candidate] | None = None + failed_reason: str | None = None # LOGIN_FAILED: / QUERY_FAILED: + +@dataclass +class Allocation: + candidate: Candidate + qty: int + +@dataclass +class SelectionResult: + kind: str # SUCCESS / BUSINESS_FAIL / SYSTEM_FAIL + reason: str # SELECTED / STOCK_INSUFFICIENT / NO_HIT / LOGIN_FAILED:... 等 + allocations: list[Allocation] + rejected: list[Rejected] + malls: list[str] # 本行启用过的平台,落失败行用 + mall_errors: dict[str, str] # 平台 → LOGIN_FAILED:... / QUERY_FAILED:... +``` + +`reason` 在 `run_selection` / `allocate` 里用折叠**前**过过滤集合算完。persist 只做中文映射,禁止从 `rejected` 反推库存不足(库存为 0 的过过滤候选不在 rejected 里)。 + +`kind` 决定落表,不要用「有没有 FAILED 决策」去猜: + +- `SUCCESS`:`allocations` 合计数量必须等于 `int(qty)`;表 1 `deal_flag=1`,表 2 只写成功行 +- `BUSINESS_FAIL`:`allocations` 必须为空;表 1 `deal_flag=2`,原因无「系统故障」前缀,该 `omsId` 不重跑 +- `SYSTEM_FAIL`:`allocations` 必须为空;表 1 `deal_flag=2`,`deal_result` 以 `系统故障:` 开头 + +`pipeline` 的展示字段若仍用 `结论=ADDED/SKIPPED/FAILED`,映射为 SUCCESS→ADDED、BUSINESS_FAIL→SKIPPED、SYSTEM_FAIL→FAILED。`persist` 只认 `kind`。 + +成功行 `platform_type` 按该候选的 `mall_name`(搜索来源)映射 48/66,仓序只按商家全称。第 4 节「不按搜索来源改序」只约束排序,不改 `platform_type`。 + +--- + +## 9. 必须覆盖的行为(单测) + +规则: + +- 毛利恰好 4% 过;3.9% 淘汰(销售价 100、平台价 96 过,96.1 淘汰) +- 杭州小药药优先于广东壹号 +- 湖南壹号、甘肃壹号云视为非自营 +- 近仓 3、次仓 20、计划 10 → 近仓 3 + 次仓 7 +- 合计 9、计划 10 → 无 allocation +- 同一仓两 SKU(价 90 库存 5、价 92 库存 100)、计划 10 → 只用价 90 那条的 5,余量 5 给下一仓,不吃同仓 92 的库存 +- 库存 3.9 按 3 件拆 + +班次 / 落表: + +- 两平台拆开:药帮忙 3 + 1 号药城 7,两行 `status=1`,数量分别为 3 和 7,`multi_platform_flag=1`,表 1 `deal_flag=1` +- 只在药帮忙拆两仓:两行、同一 `platform_type=48`、`multi_platform_flag=0` +- 药帮忙登录失败、广东壹号库存 20、计划 10 → 只落 1 号药城成功一行 +- 药帮忙登录失败、1 号药城库存 4、计划 10 → 系统故障,无成功行 +- 两边都搜到、合计库存 4、计划 10 → 业务失败「库存不足」,无成功行 +- 杭州自营库存 4 + 路边店/效期短淘汰、计划 10 → 业务失败「库存不足」(不是非自营、不是效期) +- 无条形码仍不搜 + +--- + +## 10. 风险 + +- 搜到落表之间库存会变。这期只落判定,真加购时再处理。 +- 中包装可能导致拆出的件数加不进车。真加购时再对。 +- 订单中心若假定「一个 `omsId` + 一个 `platform_type` 只有一行」,同一平台拆两仓会撞上。表结构允许多行;联调时若对方拒,再改成同平台合一行(本期不合)。