AutoRpa/docs/superpowers/specs/2026-10-02-selection-split-...

279 lines
13 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.

# 选品:跨平台仓序 + 拆量落表
| 文档属性 | 内容 |
| --- | --- |
| 日期 | 2026-10-02 |
| 状态 | 已锁定,本轮实施依据 |
| 范围 | `选品流程/` 规则、班次判定、表 2 落盘。不加购,不推接口 2 |
| 替代 | `选品流程/README.md` 规则节;口径文档 3.1 / 3.3 中「单仓满量、两边都写满量」的作业语义 |
| 不变 | 接口 1 收件、表结构、查询键(条形码)、两平台登录/搜索、接口 2 报文形态 |
一张 `omsId` 一个品种。`status` 仍是该品种整单采购状态。
---
## 1. 要做成什么
从表 1 领一行,两个商城都搜,**合并候选后统一判定**:
1. 硬过滤:自营仓、毛利 ≥ 4%、效期 ≥ 12 个月。库存不再是过滤项。
2. 按四级仓序(同仓低价、再高库存)排序。
3. 同一平台同一仓的多个 sku 都参与拆量,不再只留低价那条。
4. 按序贪心拆量,直到计划采购量凑齐。
5. 凑齐:每条拆量一行写入表 2,`status=1`,数量是该条拆到的件数,合计等于计划量。
6. 凑不齐:不写成功行,表 2 `status=2`,表 1 `deal_flag=2`。
完成标准:`选品流程` 单测全绿;用内存仓库能演示「杭州 3 + 广东 7 = 计划 10」落两行,以及「合计 9 < 10」整单失败且成功行为 0。
---
## 2. 不做什么
- 真加购、清空购物车、页面点击
- 接口 2 推送(`push_status` 仍为 0)
- 批准文号搜索、商家名模糊匹配
- 中包装 / 起订量
- 改表结构、改接口 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 同仓
不再按 `(mall_name, vendor_norm)` 折成一条。过过滤的 sku 全部进入拆量。同仓低价先吃,不够再吃同仓下一条;仓序仍优先于跨仓低价。不再产生 `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。以过过滤的集合为准):
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,价 92 拿 5,不把余量给下一仓
- 库存 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` 只有一行」,同一平台拆两仓会撞上。表结构允许多行;联调时若对方拒,再改成同平台合一行(本期不合)。