fix: pass original demand to delivery prediction API

This commit is contained in:
ar51
2026-10-15 18:01:39 +08:00
parent edefe52082
commit bc3dda55ff
6 changed files with 138 additions and 216 deletions

View File

@@ -14,7 +14,7 @@ Expected:
- model parses successfully;
- quantity is accepted;
- original demand 10 remains visible; read each model's own stock first, then deduct it before applying 95% yield and rounding to five. With zero stock production is 15; stock of at least 10 gives zero production and retains the first stock-delivery result;
- original demand 10 remains visible and each model is queried exactly once with quantity 10; stock accounting is handled by the API, with no local inventory deduction, yield adjustment or batch rounding;
- the bot immediately adds the Feishu `OnIt` (typing) reaction to the original message after the valid trigger is accepted and before the API query starts;
- the controlled script uses the macOS system trust store and keeps TLS verification enabled;
- no temporary TLS-bypass parameter is required for the normal happy path;
@@ -269,12 +269,11 @@ Expected:
以上为内部候选方案,不构成客户正式交期承诺;需 AR516 或授权责任人结合库存、生产、采购、客户优先级和最新系统数据人工复核后使用。
```
## Stock-First Production Quantity Acceptance
## Original-Demand API Quantity Acceptance
- D=24, S=6: query D=24 to read stock, calculate net demand 18 and production 20, query with 20. Display shortage gap 18 and use the final API workdays/date and shortage details.
- D=24, S=0: production 30; D=25, S=6: net demand 19 and production exactly 20.
- D=24, S>=24: production 0, retain original-query stock-delivery result, no quantity=0 request. D=5, S=1: production 5, so reuse the first query.
- Different candidates use different stock and production quantities; initial and continuation batches retain original demand and never compound yield allowance.
- Missing/invalid stock is unavailable, never zero; changed stock between the two queries invalidates that model's result without an automatic retry.
- D=24, S=20: calculate net demand 4, production 5, but report delivery unavailable because the current API would take the stock shortcut with quantity=5; never claim full coverage or invent a date.
- New record metadata preserves each model's net/production/API quantities without changing existing table columns or historical records.
- Regression: `eRob70F50I-FHM-18ET[V4] 12台`, with API stock 10. Send quantity 12 once per model; show stock partly covering demand with a gap of 2. Preserve the API workdays/date and shortages. Do not show the old false-stock-branch error or calculate production 5 locally.
- D=8/S=5: send 8; D=24/S=6: send 24; D=24/S=0: send 24; D=24/S>=24: still send 24 once and preserve the API response.
- Different candidates display their own stock, but all use unchanged original demand. Initial and continuation batches query each current-batch model once.
- Missing/invalid stock is unknown, never zero; valid API workdays/date remain usable with an explicit inventory-review item. Missing/invalid workdays/date or failed requests retain existing error handling.
- New report and record metadata contain actual API quantities, with no local net-demand or production-quantity fields. Historical records and existing candidate-table columns remain unchanged.
- These checks use mocked responses locally. Runtime acceptance must verify actual API arguments and compare the displayed workdays/date with the same returned response; mocked dates do not constitute delivery predictions.

View File

@@ -1,57 +1,43 @@
# 交期预测数量规则
# 交期预测数量传递规则
可用成品库存已视为100%可用,只对需要新生产的缺口考虑合格率。
## 计算责任
AR51 于 2026-10-15 检查上游 API 的交期预测逻辑后确认:接口已核算可用库存;并明确取消机器人本地的扣库存、95% 合格率折算和向上取 5 台倍数规则。此确认是本次修正依据,不代表已经实时验证服务端或运行电脑。
```text
D = 原始需求数量
S = 该型号可用库存(API字段:可用库存(扣除了待产数量))
净需求 = max(D - max(S, 0), 0)
最终排产测算数量 P = 5 × ceil(净需求 ÷ 0.95 ÷ 5)
D = 用户原始需求数量(正整数)
API quantity = D
```
净需求为0时,P为0;固定不良率5%,对应合格率95%。先扣库存,再除以合格率,最后向上取5的倍数。使用整数运算,避免边界浮点误差。库存缺失或不是有效整数时不能视作0;负整数余额按无可用库存参与计算。
每个型号只通过受控脚本查询一次,原型号和各候选型号都传原始需求 D。机器人保留型号推荐、结果整理和记录能力;库存扣减及交期测算由上游 API 处理。
| 原始需求 | 可用库存 | 净需求 | 排产测算数量 |
|---:|---:|---:|---:|
| 24 | 6 | 18 | 20 |
| 24 | 0 | 24 | 30 |
| 24 | 24 | 0 | 0 |
| 24 | 30 | 0 | 0 |
| 25 | 6 | 19 | 20 |
| 5 | 1 | 4 | 5 |
| 95 | 0 | 95 | 100 |
## 示例与输出
每个候选使用自己的库存,不累计多个候选的库存。续批保留原始需求,对新候选重新读取库存及计算,不对已折算数量重复折算。
| 原始需求 | API 返回可用库存 | API 查询数量 | 库存展示 |
|---:|---:|---:|---|
| 12 | 10 | 12 | 部分覆盖,仍需补齐 2 台 |
| 8 | 5 | 8 | 部分覆盖,仍需补齐 3 台 |
| 24 | 0 | 24 | 需生产/采购 |
| 24 | 24 | 24 | 完全覆盖 |
| 24 | 30 | 24 | 完全覆盖 |
报告示例(需求24,可用库存6):
库存覆盖按原始需求与该型号 API 返回的库存比较,仅用于展示。展示差额不回传 API,不作为机器人计算的排产数量。每个候选展示自己的库存,不能沿用原型号或其他候选的库存。
```text
需求数量:24台
扣库存后净需求:18台
排产测算数量:20台
可用库存/覆盖情况:整机可用库存6台(部分覆盖,仍需补齐18台)
```
库存缺失或无效时显示待复核,不视作 0;如果 API 已返回有效工作日交期和最终交货日期,机器人保留该交期并标明库存覆盖无法判断。缺失有效交期字段或请求失败时继续执行原有异常处理。
库存覆盖始终比较原始需求,不能比较排产量。各型号排产量可能不同,报告顶部显示计算规则,各型号明细及新记录元数据分别显示数量;历史记录不改写,候选表列不改变。
报告展示需求数量、API 查询数量、API 返回交期和库存覆盖情况,不再输出本地净需求或排产测算数量。新记录保存原始需求及每个型号实际 API 查询数量,历史记录不改写。
## 受控API查询
## 已取消的旧逻辑
1. 先以D调用受控脚本,读取该型号库存。
2. 库存足量:P=0,沿用第一次查询的现货交期;不发送quantity=0。
3. 库存不足且P=D:第一次查询已符合排产数量,不重复查询。
4. 库存不足且P与D不同:以P调用同一受控脚本,交期、缺料明细及主导因素取第二次响应。两次库存必须一致,否则结果不可用,需要重新查询。
5. 第一响应无有效库存或必需工作日/日期字段时,结果不可用。
低层脚本仍原样传递quantity。日期与工作日仍直接使用API返回值,不在本地推算。
## 现有接口限制
现有WithTransit交接口径中,quantity同时参与成品现货判断和本次生产/BOM数量测算。若 `0 < P <= S < D`,传P会误判现货。例如D=24、S=20,净需求4、P=5,但20台库存不能覆盖24台需求。此时本地能计算正确排产量,却不能从现有单数量参数获得可靠生产交期;明确报交期不可用,原型号停止出具报告,候选列入不可用清单。需要AR836接口支持分别输入原需求和排产量后解除该限制。
不能改传S+P:这会使API按包含库存的数量测算物料和生产。也不能使用第一次D查询的交期充当P的交期。该限制依据既有接口交接说明,尚未完成当前服务端的实时验证;本次本机测试使用模拟响应。
- 不再先读取库存、在本地扣减后发起第二次生产数量查询。
- 不再按 95% 合格率放大需求或取 5 台倍数。
- 不再根据“库存不足原需求、但足以覆盖本地排产量”拦截结果。
- 不再要求接口为这个本地拦截增加“原始需求、排产数量”双参数。
## 运行电脑同步验收
运行电脑须拉取包含桥接代码、项目内Skill、测试及说明的完整版本并重启交期回复机器人。无需新增依赖、配置或重装物料名称刷新服务。
运行电脑须拉取包含桥接代码、项目内 Skill、测试及说明的完整版本并重启交期回复机器人。本次无需新增依赖或配置。
执行 `python3 -m unittest discover -s tests -q`,并在既有API审批及功能测试授权范围内检查需求24/库存6、库存完全覆盖、不同候选库存、续批,以及部分库存误走现货分支样例。核对数量、实际API参数、库存变化处理与API最终日期。推送成功不代表运行电脑已生效。
先执行 `python3 -m unittest discover -s tests -q`。随后在既有 API 审批及功能测试授权范围内验证 `eRob70F50I-FHM-18ET[V4] 12台`,核对实际发送的 quantity 为 12,机器人显示的工作日、最终日期及缺料摘要与同一 API 响应一致。API 库存以验证时实时返回值为准,不能假定仍为 10 台。
分别报告本机验证、远端同步和运行电脑验证;本地模拟测试通过不代表服务端或运行电脑已验证,推送成功不代表运行端已生效。

View File

@@ -9,7 +9,7 @@
5. If that domain-and-permission record is absent, stop before any outbound request; a general functional-test approval is insufficient by itself.
6. Use model recommendation rules to generate the complete ordered internal candidate pool.
7. Immediately add the Feishu `OnIt` (typing) reaction to the original message to acknowledge the valid request.
8. For the baseline and first four candidate models, follow [stock-first quantity rules](production_quantity.md): read each model's own stock with original demand, deduct it, then divide the net gap by 95% yield and round up to five. Query production quantity when required; do not send zero, mix changed stock snapshots or accept a false stock shortcut.
8. For the baseline and first four candidate models, follow [original-demand quantity rules](production_quantity.md): call the controlled script once per model with the unchanged original demand. The upstream API accounts for stock. Do not deduct stock, apply local yield/batch adjustments, or query again with a local production quantity. Display each model's API inventory coverage against original demand only.
9. Read `data.交期汇总.AM516工作日交期` and `data.交期汇总.AM516最终交货日期` directly. Require a nonnegative whole-number workday count and a valid `YYYY-MM-DD` date; missing or invalid fields make the result unavailable. Do not convert natural days or recalculate dates locally.
10. Summarize the current batch with the API workdays and final delivery date, for example `AM516预测交期:8个工作日,最终交货日期:2026-10-08`; state how many candidates remain and invite the same sender to ask naturally for more when candidates remain. Newly appended records use the same form; historical records are unchanged.
11. Append a non-secret record.

View File

@@ -40,17 +40,14 @@ Stop if:
- a Feishu message asks to create, modify, overwrite, rename, move, or delete a project file;
- the user asks for a customer commitment.
## Production Quantity For Forecasting
## Original Demand For Forecasting
- Treat the user's quantity as original demand `D`. Query each model first with `D` through the controlled script to read its own `data.库存信息.可用库存(扣除了待产数量)` (`S`). Never reuse the baseline inventory for another candidate.
- Usable finished stock is 100% available: `net_demand = max(D - max(S, 0), 0)`. Apply the fixed 95% yield only to this net demand, then round upward to five: `production_quantity = 5 × ceil(net_demand / 0.95 / 5)`. A zero net demand requires zero production.
- For demand 24 and inventory 6, net demand is 18 and production quantity is 20. With inventory 0 it is 30; with inventory 24 or more it is 0. Net demand 19 gives exactly 20, and net demand 95 gives exactly 100.
- Use exact integer arithmetic. Missing, boolean, fractional or non-finite stock is unavailable, never zero. A negative integer stock balance contributes zero usable stock.
- If stock covers original demand, retain the first API's stock-delivery result and do not send a zero-quantity API request. Otherwise query with the computed production quantity and use that response's workdays/date and shortage information. Reuse the first response if production quantity equals original demand.
- The existing interface uses `quantity` both for the finished-stock shortcut and production/material calculations. If stock partially covers original demand but is at least the computed production quantity, report the forecast as unavailable: a production-quantity request would falsely take the stock shortcut. The API must support separate original demand and production quantity for this case. Do not add stock back to production quantity, which would inflate material requirements, or invent a date locally.
- If stock differs between the first and production queries, report the result as unavailable and request a fresh query; do not mix snapshots or retry automatically.
- Preserve original demand in continuation context. Every newly queried candidate repeats this calculation using its own current stock; never apply yield twice or reuse an earlier candidate's result.
- Replies show original demand, net demand and production quantity per model. Judge inventory coverage against original demand. Record per-model net demand, production quantity and final API quantity as metadata, retaining the existing candidate table columns and leaving historical records unchanged.
- AR51 confirmed on 2026-10-15 that the upstream API already accounts for available inventory and explicitly cancelled local inventory deduction, 95% yield adjustment and rounding to five. Follow `docs/production_quantity.md`.
- Treat the user's positive integer quantity as original demand `D`. Query each model exactly once through the controlled script with `quantity=D`, unchanged. Preserve `D` in continuation context and pass it unchanged for every newly queried candidate.
- Inventory accounting and delivery calculation belong to the upstream API. Do not calculate a local production quantity, send a reduced or inflated quantity, or issue a second production query.
- Display each model's own returned inventory coverage against original demand. A displayed inventory gap is informational and must not become a new API quantity. Missing or invalid stock means unknown coverage, never zero; do not discard valid API workdays/date solely because inventory display is unavailable.
- Remove the obsolete partial-stock/production-quantity shortcut rejection. API failures and missing or invalid workdays/date retain existing error handling.
- Replies show original demand and actual API query quantity per model, without local net-demand or production-quantity fields. New records preserve the same actual API quantities; leave historical records unchanged.
## Feishu Message Trigger Gate
@@ -91,7 +88,7 @@ Do not write back rules, Skills, scripts, prompts, configurations, documentation
Use only:
```text
python3 capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py --product "<model>" --quantity <original_demand_or_production_quantity>
python3 capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py --product "<model>" --quantity <original_demand>
```
Do not use temporary curl, WebFetch, or any API method outside the controlled script.
@@ -182,8 +179,8 @@ Use this field order for a successful Feishu report:
关节模组交期预估报告 | {generation time}
输入需求:{input model},{input quantity}台
排产测算规则:各型号先扣可用库存,再除以95%合格率并向上取5台的倍数;库存足量时排产为0
库存覆盖口径:按原始需求数量判断(可用库存按100%可用)
数量传递规则:原始需求原样传入API,库存及交期测算由API处理
库存覆盖口径:按原始需求数量展示API返回的库存覆盖情况
数据时间戳:{API data timestamp or generation time}
状态:试运行版输出 / 内部候选
判断等级:AM516算法预测参考,需人工复核
@@ -198,8 +195,7 @@ Use this field order for a successful Feishu report:
1. {model}({candidate type and reducer brand})
- 需求数量:{input quantity}台
- 扣库存后净需求:{model net demand}台
- 排产测算数量:{model production quantity}台
- API查询数量:{original demand}台
- AM516预测交期:{API workdays}个工作日,最终交货日期:{API final delivery date}
- 可用库存/覆盖情况:{summary-level inventory and coverage only}
- 主导因素:{main factor}

View File

@@ -83,8 +83,6 @@ ALLOWED_VERSIONS = {
"170H": {"V3"},
}
CANDIDATE_BATCH_SIZE = 4
DEFECT_RATE_PERCENT = 5
PRODUCTION_BATCH_SIZE = 5
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
RECEIVED_REACTION_TYPE = "OnIt"
@@ -182,15 +180,10 @@ class CandidateUnavailableError(RuntimeError):
pass
def production_quantity_for_demand(quantity: int, available_inventory: int = 0) -> int:
"""Deduct usable finished stock before yield allowance and batch rounding."""
def validate_demand_quantity(quantity: int) -> None:
"""Reject invalid demand before calling the controlled API."""
if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:
raise InputError("需求数量必须为正整数。")
if isinstance(available_inventory, bool) or not isinstance(available_inventory, int):
raise InputError("可用库存必须为整数。")
numerator = max(quantity - max(available_inventory, 0), 0) * 100
denominator = (100 - DEFECT_RATE_PERCENT) * PRODUCTION_BATCH_SIZE
return ((numerator + denominator - 1) // denominator) * PRODUCTION_BATCH_SIZE
_candidate_continuations: dict[str, CandidateContinuation] = {}
@@ -718,7 +711,14 @@ def lead_time_details(
if isinstance(inventory_info, dict)
else None
)
if isinstance(available_inventory, (int, float)):
# Inventory is display-only; invalid values do not invalidate API dates.
if (
isinstance(available_inventory, bool)
or not isinstance(available_inventory, (int, float))
or (isinstance(available_inventory, float) and not available_inventory.is_integer())
):
available_inventory = None
if available_inventory is not None:
if available_inventory >= requested_quantity:
inventory_text = f"整机可用库存{available_inventory:g}台(完全覆盖)"
full_coverage: bool | None = True
@@ -837,8 +837,7 @@ def candidate_report_lines(candidate: dict[str, Any], quantity: int) -> list[str
return [
f"{candidate['index']}. {candidate['model']}({candidate['type']})",
f"- 需求数量:{quantity}台",
f"- 扣库存后净需求:{details['net_demand']}台",
f"- 排产测算数量:{details['production_quantity']}台",
f"- API查询数量:{details['api_quantity']}台",
f"- {details['prediction']}",
f"- 可用库存/覆盖情况:{details['inventory']}",
f"- 主导因素:{details['main_factor']}",
@@ -909,17 +908,13 @@ def render_record(
"",
f"**输入型号**:{request.model}",
f"**输入数量**:{request.quantity}台",
"**数量计算规则**:各型号先扣可用库存(按100%可用),净需求÷95%良率,再按5台的倍数向上取整;净需求为0时排产为0。",
"**库存覆盖口径**:按原始需求数量判断。",
"**各型号排产测算**:",
"**数量传递规则**:原始需求原样传入API;库存及交期测算由API处理。",
"**库存覆盖口径**:按原始需求数量展示API返回的库存覆盖情况。",
"**各型号API查询数量**:",
*[
f"- {item['model']}:净需求{item['details']['net_demand']}台;排产测算数量{item['details']['production_quantity']}台;最终API查询数量{item['details']['api_quantity']}台"
f"- {item['model']}:最终API查询数量{item['details']['api_quantity']}台"
for item in candidates
],
*[
f"- {item['model']}:净需求{item['net_demand']}台;排产测算数量{item['production_quantity']}台;交期不可用"
for item in unavailable if 'net_demand' in item
],
f"**生成时间**:{generation_time:%Y-%m-%d %H:%M:%S %z}",
f"**规则源实际路径**:`{RULE_SOURCE_DISPLAY}`",
f"**接口依据**:`{API_SOURCE_DISPLAY}`",
@@ -993,48 +988,18 @@ def write_record(
def query_model_for_demand(request: RequestInput) -> dict[str, Any]:
"""Read stock with original demand, then forecast only the production gap.
The API uses quantity both for its finished-stock shortcut and for production
and material requirements. Never accept that shortcut for a partial-stock
order, or combine quantities from two different stock snapshots.
"""
"""Pass original demand once; the upstream API accounts for inventory."""
validate_demand_quantity(request.quantity)
payload = run_controlled_query(request)
details = lead_time_details(payload, request.quantity)
if not details['has_lead_time']:
return details
stock = whole_day_count(details['available_inventory'])
# A negative balance represents no stock that can cover this new demand.
raw_stock = details['available_inventory']
if stock is None and isinstance(raw_stock, (int, float)) and not isinstance(raw_stock, bool) and raw_stock < 0:
stock = 0 if isinstance(raw_stock, int) or raw_stock.is_integer() else None
if stock is None:
details.update(has_lead_time=False, unavailable_reason="接口未返回有效整数可用库存,无法计算净需求及排产数量。")
return details
net_demand = max(request.quantity - stock, 0)
planned = production_quantity_for_demand(request.quantity, stock)
details.update(net_demand=net_demand, production_quantity=planned, api_quantity=request.quantity)
if planned == 0 or planned == request.quantity:
return details
if stock >= planned:
details.update(
has_lead_time=False,
unavailable_reason=(f"扣库存后净需求{net_demand}台,排产测算数量{planned}台;"
"库存仅部分覆盖原需求,但达到排产查询数量,现有接口会误走现货分支;"
"需接口支持分别传入原需求和排产数量后测算交期。"),
)
return details
forecast = lead_time_details(run_controlled_query(RequestInput(request.model, planned)), request.quantity)
forecast.update(net_demand=net_demand, production_quantity=planned, api_quantity=planned)
if forecast['available_inventory'] != raw_stock:
forecast.update(has_lead_time=False, unavailable_reason="两次查询的可用库存发生变化,未采用本次交期;请重新查询。")
return forecast
details['api_quantity'] = request.quantity
return details
def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
if candidate_offset < 0:
raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
production_quantity_for_demand(request.quantity) # Validate before querying.
validate_demand_quantity(request.quantity)
all_candidate_specs = generate_candidate_specs(request.model)
candidate_specs = all_candidate_specs[
@@ -1080,7 +1045,6 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
if not details["has_lead_time"]:
return {
**spec,
**{key: details[key] for key in ('net_demand', 'production_quantity') if key in details},
"unavailable_reason": details["unavailable_reason"],
}, None
return spec, details
@@ -1150,8 +1114,8 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
),
"",
f"输入需求:{request.model},{request.quantity}台",
f"排产测算规则:各型号先扣可用库存,再除以{100 - DEFECT_RATE_PERCENT}%合格率并向上取{PRODUCTION_BATCH_SIZE}台的倍数;库存足量时排产为0",
"库存覆盖口径:按原始需求数量判断(可用库存按100%可用)",
"数量传递规则:原始需求原样传入API,库存及交期测算由API处理",
"库存覆盖口径:按原始需求数量展示API返回的库存覆盖情况",
f"数据时间戳:{data_timestamp}",
"状态:试运行版输出 / 内部候选",
"判断等级:AM516算法预测参考,需人工复核",

View File

@@ -404,18 +404,7 @@ class TriggerGateTests(unittest.TestCase):
self.assertNotIn(key, bridge._candidate_continuations)
class ProductionQuantityTests(unittest.TestCase):
def test_defect_allowance_precedes_batch_rounding_at_exact_boundaries(self) -> None:
for demand, expected in ((1, 5), (4, 5), (5, 10), (10, 15), (19, 20), (20, 25), (25, 30), (95, 100), (190, 200)):
with self.subTest(demand=demand):
self.assertEqual(bridge.production_quantity_for_demand(demand), expected)
# Integer arithmetic must also cover quantities beyond float precision.
demand = 10**18 + 19
planned = bridge.production_quantity_for_demand(demand)
self.assertEqual(planned % 5, 0)
self.assertGreaterEqual(planned * 95, demand * 100)
self.assertLess((planned - 5) * 95, demand * 100)
class OriginalDemandQueryTests(unittest.TestCase):
def test_invalid_demand_stops_before_api_or_recording(self) -> None:
for value in (0, -1, True, 5.0, "5"):
with (
@@ -424,58 +413,65 @@ class ProductionQuantityTests(unittest.TestCase):
mock.patch.object(bridge, "write_record") as record,
):
with self.assertRaises(bridge.InputError):
bridge.build_report(bridge.RequestInput("eRob70H50I-FS-18CN[V5]", value))
bridge.build_report(bridge.RequestInput("eRob70F50I-FHM-18ET[V4]", value))
query.assert_not_called()
record.assert_not_called()
def test_stock_is_deducted_before_yield_and_batching(self) -> None:
for demand, stock, expected in ((24, 6, 20), (24, 0, 30), (24, 24, 0), (24, 30, 0), (25, 6, 20), (5, 1, 5), (95, 0, 100), (24, -2, 30)):
with self.subTest(demand=demand, stock=stock):
self.assertEqual(bridge.production_quantity_for_demand(demand, stock), expected)
def test_partial_full_and_zero_stock_do_not_change_api_quantity(self) -> None:
for demand, stock in ((12, 10), (8, 5), (24, 6), (24, 0), (24, 24), (24, 30), (24, -2), (5, 1)):
payload = successful_payload()
payload['data']['库存信息']['可用库存(扣除了待产数量)'] = stock
request = bridge.RequestInput('eRob70F50I-FHM-18ET[V4]', demand)
with (
self.subTest(demand=demand, stock=stock),
mock.patch.object(bridge, 'run_controlled_query', return_value=payload) as query,
):
details = bridge.query_model_for_demand(request)
query.assert_called_once_with(request)
self.assertTrue(details['has_lead_time'])
self.assertEqual(details['api_quantity'], demand)
def test_initial_and_continuation_query_net_production_per_model(self) -> None:
request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=24)
def test_screenshot_regression_passes_twelve_to_controlled_script(self) -> None:
request = bridge.RequestInput('eRob70F50I-FHM-18ET[V4]', 12)
payload = successful_payload()
payload["data"]["库存信息"]["可用库存(扣除了待产数量)"] = 6
completed = SimpleNamespace(stdout=bridge.json.dumps(payload), stderr="", returncode=0)
payload['data']['库存信息']['可用库存(扣除了待产数量)'] = 10
payload['data']['交期汇总'].update(AM516工作日交期=8, AM516最终交货日期='2026-10-27', AM516主导因素='生产')
completed = SimpleNamespace(stdout=bridge.json.dumps(payload), stderr='', returncode=0)
with (
mock.patch.object(bridge.subprocess, 'run', return_value=completed) as run,
mock.patch.object(bridge, 'write_record') as record,
):
report = bridge.build_report(request)
self.assertEqual(run.call_count, 5)
for call in run.call_args_list:
command = call.args[0]
self.assertEqual(command[command.index('--quantity') + 1], '12')
self.assertIn('整机可用库存10台(部分覆盖,仍需补齐2台)', report)
self.assertIn('8个工作日,最终交货日期:2026-10-27', report)
self.assertNotIn('误走现货分支', report)
self.assertNotIn('排产测算数量', report)
recorded = bridge.render_record(*record.call_args.args)
self.assertIn('**输入数量**:12台', recorded)
self.assertEqual(recorded.count('最终API查询数量12台'), 5)
self.assertNotIn('净需求', recorded)
def test_initial_and_continuation_preserve_original_demand(self) -> None:
request = bridge.RequestInput('eRob70H50I-FS-18CN[V5]', 24)
for offset, model_count in ((0, 5), (bridge.CANDIDATE_BATCH_SIZE, 4)):
with (
self.subTest(offset=offset),
mock.patch.object(bridge.subprocess, "run", return_value=completed) as run,
mock.patch.object(bridge, "write_record") as record,
mock.patch.object(bridge, 'run_controlled_query', return_value=successful_payload()) as query,
mock.patch.object(bridge, 'write_record'),
):
report = bridge.build_report(request, candidate_offset=offset)
by_model = {}
for call in run.call_args_list:
command = call.args[0]
by_model.setdefault(command[command.index("--product") + 1], []).append(command[command.index("--quantity") + 1])
self.assertEqual(len(by_model), model_count)
self.assertTrue(all(quantities == ["24", "20"] for quantities in by_model.values()))
self.assertEqual(report.count("- 扣库存后净需求:18台"), model_count)
self.assertEqual(report.count("- 排产测算数量:20台"), model_count)
self.assertIn("部分覆盖,仍需补齐18台", report)
recorded = bridge.render_record(*record.call_args.args)
self.assertIn("**输入数量**:24台", recorded)
self.assertEqual(recorded.count("排产测算数量20台;最终API查询数量20台"), model_count)
bridge.build_report(request, candidate_offset=offset)
self.assertEqual(query.call_count, model_count)
self.assertEqual(len({call.args[0].model for call in query.call_args_list}), model_count)
self.assertTrue(all(call.args[0].quantity == 24 for call in query.call_args_list))
def test_stock_covering_original_demand_needs_no_production_query(self) -> None:
payload = successful_payload()
payload["data"]["库存信息"]["可用库存(扣除了待产数量)"] = 7
with (
mock.patch.object(bridge, "run_controlled_query", return_value=payload) as query,
mock.patch.object(bridge, "write_record"),
):
report = bridge.build_report(bridge.RequestInput("eRob70H50I-BHM-18CTC[V5]", 5))
self.assertTrue(all(call.args[0].quantity == 5 for call in query.call_args_list))
self.assertIn("库存覆盖口径:按原始需求数量判断", report)
self.assertIn("整机可用库存7台(完全覆盖)", report)
self.assertIn("- 排产测算数量:0台", report)
self.assertIn("是否完全覆盖数量:是", report)
def test_each_candidate_uses_its_own_stock(self) -> None:
request = bridge.RequestInput("eRob70H50I-FS-18CN[V5]", 24)
def test_each_candidate_displays_its_own_stock(self) -> None:
request = bridge.RequestInput('eRob70H50I-FS-18CN[V5]', 24)
models = [request.model, *[s['model'] for s in bridge.generate_candidate_specs(request.model)[:4]]]
stocks = dict(zip(models, [6, 0, 24, 5, 30]))
stocks = dict(zip(models, [6, 0, 24, 20, 30]))
def query(r):
payload = successful_payload()
payload['data']['库存信息']['可用库存(扣除了待产数量)'] = stocks[r.model]
@@ -485,12 +481,13 @@ class ProductionQuantityTests(unittest.TestCase):
mock.patch.object(bridge, 'write_record') as record,
):
bridge.build_report(request)
candidates = record.call_args.args[1]
self.assertEqual([c['details']['production_quantity'] for c in candidates], [20, 30, 0, 20, 0])
for model, expected in zip(models, [[24, 20], [24, 30], [24], [24, 20], [24]]):
self.assertEqual([c.args[0].quantity for c in run.call_args_list if c.args[0].model == model], expected)
self.assertEqual(run.call_count, len(models))
for candidate in record.call_args.args[1]:
self.assertEqual(candidate['details']['available_inventory'], stocks[candidate['model']])
self.assertEqual(candidate['details']['api_quantity'], 24)
self.assertEqual(candidate['details']['full_coverage'], stocks[candidate['model']] >= 24)
def test_missing_or_invalid_stock_cannot_be_treated_as_zero(self) -> None:
def test_missing_or_invalid_stock_is_unknown_without_overriding_api_forecast(self) -> None:
for stock in (None, True, '6', 1.5, float('nan'), float('inf')):
payload = successful_payload()
payload['data']['库存信息']['可用库存(扣除了待产数量)'] = stock
@@ -499,52 +496,32 @@ class ProductionQuantityTests(unittest.TestCase):
mock.patch.object(bridge, 'run_controlled_query', return_value=payload) as query,
):
details = bridge.query_model_for_demand(bridge.RequestInput('model', 24))
self.assertFalse(details['has_lead_time'])
self.assertIn('无法计算净需求', details['unavailable_reason'])
self.assertTrue(details['has_lead_time'])
self.assertIsNone(details['available_inventory'])
self.assertIsNone(details['full_coverage'])
self.assertIn('需人工复核', details['inventory'])
query.assert_called_once()
def test_partial_stock_cannot_trigger_false_stock_delivery(self) -> None:
def test_forecast_preserves_api_date_and_materials(self) -> None:
payload = successful_payload()
payload['data']['库存信息']['可用库存(扣除了待产数量)'] = 20
payload['data']['库存信息']['可用库存(扣除了待产数量)'] = 10
payload['data']['交期汇总'].update(AM516工作日交期=8, AM516最终交货日期='2026-10-27')
payload['data']['采购信息']['缺料明细'] = [{'物料名称': '测算物料'}]
with mock.patch.object(bridge, 'run_controlled_query', return_value=payload) as query:
details = bridge.query_model_for_demand(bridge.RequestInput('model', 24))
self.assertEqual(details['net_demand'], 4)
self.assertEqual(details['production_quantity'], 5)
self.assertFalse(details['has_lead_time'])
self.assertIn('误走现货分支', details['unavailable_reason'])
details = bridge.query_model_for_demand(bridge.RequestInput('model', 12))
query.assert_called_once()
def test_stock_change_between_queries_invalidates_forecast(self) -> None:
first, second = successful_payload(), successful_payload()
first['data']['库存信息']['可用库存(扣除了待产数量)'] = 6
second['data']['库存信息']['可用库存(扣除了待产数量)'] = 7
with mock.patch.object(bridge, 'run_controlled_query', side_effect=[first, second]):
details = bridge.query_model_for_demand(bridge.RequestInput('model', 24))
self.assertFalse(details['has_lead_time'])
self.assertIn('库存发生变化', details['unavailable_reason'])
def test_forecast_uses_production_response_date_and_materials(self) -> None:
first, second = successful_payload(), successful_payload()
for payload in (first, second):
payload['data']['库存信息']['可用库存(扣除了待产数量)'] = 6
second['data']['交期汇总']['AM516工作日交期'] = 8
second['data']['交期汇总']['AM516最终交货日期'] = '2026-10-22'
second['data']['采购信息']['缺料明细'] = [{'物料名称': '测算物料'}]
with mock.patch.object(bridge, 'run_controlled_query', side_effect=[first, second]):
details = bridge.query_model_for_demand(bridge.RequestInput('model', 24))
self.assertTrue(details['has_lead_time'])
self.assertEqual(details['prediction_value'], '8个工作日,最终交货日期:2026-10-22')
self.assertEqual(details['prediction_value'], '8个工作日,最终交货日期:2026-10-27')
self.assertIn('- 缺料物料明细:测算物料', details['evidence_lines'])
def test_unsupported_baseline_stops_before_candidate_queries_and_record(self) -> None:
def test_missing_api_date_still_stops_before_candidates_and_record(self) -> None:
payload = successful_payload()
payload['data']['库存信息']['可用库存(扣除了待产数量)'] = 20
del payload['data']['交期汇总']['AM516最终交货日期']
with (
mock.patch.object(bridge, 'run_controlled_query', return_value=payload) as query,
mock.patch.object(bridge, 'write_record') as record,
):
with self.assertRaisesRegex(RuntimeError, '净需求4台,排产测算数量5台'):
bridge.build_report(bridge.RequestInput('eRob70H50I-FS-18CN[V5]', 24))
with self.assertRaisesRegex(RuntimeError, '原型号未生成可用交期结果'):
bridge.build_report(bridge.RequestInput('eRob70F50I-FHM-18ET[V4]', 12))
query.assert_called_once()
record.assert_not_called()