From edefe52082b909f6ac5e524a0fe6303812fedac7 Mon Sep 17 00:00:00 2001 From: ar51 Date: Mon, 12 Oct 2026 11:57:24 +0800 Subject: [PATCH] fix: deduct available stock before production yield adjustment --- .../docs/acceptance_samples.md | 14 +- .../docs/production_quantity.md | 67 ++++++---- .../am516-delivery-prediction/docs/runbook.md | 2 +- .../joint-module-leadtime-report/SKILL.md | 24 ++-- shared/runtime/feishu_bot_bridge.py | 96 ++++++++++---- tests/test_feishu_bot_bridge.py | 123 +++++++++++++++--- 6 files changed, 246 insertions(+), 80 deletions(-) diff --git a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md index 9d38df0..69fb850 100644 --- a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md +++ b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md @@ -14,7 +14,7 @@ Expected: - model parses successfully; - quantity is accepted; -- the original demand of 10 remains visible in the reply and record, while the baseline and every recommended candidate call the API with 15 after dividing by 95% yield and rounding to a batch of five; +- 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; - 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; @@ -84,7 +84,7 @@ Expected: - the bot immediately adds the Feishu `OnIt` (typing) reaction to the original message after the continuation and active-context gates pass; - the bot reuses the active model and quantity only for that same sender and chat; -- the continuation retains original demand 10 and again uses forecast quantity 15; it never compounds the adjustment to 20; +- continuation retains original demand 10 and computes each new candidate from its own current stock; with zero stock production is 15. It never compounds an earlier adjustment or reuses baseline stock; - the bot queries candidate positions 5-8 in the unchanged rule order; - the original model and candidate positions 1-4 are not queried or displayed again; - unavailable candidates still consume their original positions and are summarized under `不可用候选`; @@ -268,3 +268,13 @@ Expected: ```text 以上为内部候选方案,不构成客户正式交期承诺;需 AR516 或授权责任人结合库存、生产、采购、客户优先级和最新系统数据人工复核后使用。 ``` + +## Stock-First Production 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. diff --git a/capabilities/am516-delivery-prediction/docs/production_quantity.md b/capabilities/am516-delivery-prediction/docs/production_quantity.md index 7466e8d..ace16df 100644 --- a/capabilities/am516-delivery-prediction/docs/production_quantity.md +++ b/capabilities/am516-delivery-prediction/docs/production_quantity.md @@ -1,36 +1,57 @@ -# 交期预测数量口径 +# 交期预测数量规则 -用户输入为原始需求数量。交期回复机器人在查询原型号、候选型号及后续批次前,统一按以下规则计算 API 输入数量: +可用成品库存已视为100%可用,只对需要新生产的缺口考虑合格率。 ```text -排产测算数量 = 5 × ceil(原始需求数量 ÷ 0.95 ÷ 5) +D = 原始需求数量 +S = 该型号可用库存(API字段:可用库存(扣除了待产数量)) +净需求 = max(D - max(S, 0), 0) +最终排产测算数量 P = 5 × ceil(净需求 ÷ 0.95 ÷ 5) ``` -固定不良率为 5%,对应良率 95%。先除以良率,再向上取到 5 的倍数。代码使用整数运算,避免恰好落在倍数边界时因浮点误差多加一个批次。 +净需求为0时,P为0;固定不良率5%,对应合格率95%。先扣库存,再除以合格率,最后向上取5的倍数。使用整数运算,避免边界浮点误差。库存缺失或不是有效整数时不能视作0;负整数余额按无可用库存参与计算。 -| 原始需求 | 除以95%良率 | 排产测算数量 / API quantity | -|---:|---:|---:| -| 5 | 5.263… | 10 | -| 10 | 10.526… | 15 | -| 19 | 20 | 20 | -| 20 | 21.052… | 25 | -| 25 | 26.315… | 30 | -| 95 | 100 | 100 | +| 原始需求 | 可用库存 | 净需求 | 排产测算数量 | +|---:|---:|---:|---:| +| 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 | -回复示例中的数量部分: +每个候选使用自己的库存,不累计多个候选的库存。续批保留原始需求,对新候选重新读取库存及计算,不对已折算数量重复折算。 + +报告示例(需求24,可用库存6): ```text -输入需求:eRob70H120I-BHM-18CT[V5],5台 -排产测算数量:10台(不良率5%,按5台的倍数向上取整) -库存覆盖口径:按排产测算数量判断 - -1. eRob70H120I-BHM-18CT[V5](原型号,DZ) -- 需求数量:5台 -- 排产测算数量:10台 +需求数量:24台 +扣库存后净需求:18台 +排产测算数量:20台 +可用库存/覆盖情况:整机可用库存6台(部分覆盖,仍需补齐18台) ``` -实际交期继续读取 API 返回的工作日交期和最终交货日期。库存覆盖也以本次 API 使用的排产测算数量判断,例如原始需求 5、API 数量 10、库存 7 时,显示部分覆盖,仍需补齐 3 台。 +库存覆盖始终比较原始需求,不能比较排产量。各型号排产量可能不同,报告顶部显示计算规则,各型号明细及新记录元数据分别显示数量;历史记录不改写,候选表列不改变。 -对话上下文和本地记录保留原始需求;记录元数据另列排产测算数量和计算规则。继续推荐仍从原始需求重新计算一次,不把 10 台再次放大为 15 台。低层受控脚本的 `--quantity` 参数就是最终发送给 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的交期。该限制依据既有接口交接说明,尚未完成当前服务端的实时验证;本次本机测试使用模拟响应。 + +## 运行电脑同步验收 + +运行电脑须拉取包含桥接代码、项目内Skill、测试及说明的完整版本并重启交期回复机器人。无需新增依赖、配置或重装物料名称刷新服务。 + +执行 `python3 -m unittest discover -s tests -q`,并在既有API审批及功能测试授权范围内检查需求24/库存6、库存完全覆盖、不同候选库存、续批,以及部分库存误走现货分支样例。核对数量、实际API参数、库存变化处理与API最终日期。推送成功不代表运行电脑已生效。 diff --git a/capabilities/am516-delivery-prediction/docs/runbook.md b/capabilities/am516-delivery-prediction/docs/runbook.md index 9d8cfea..cb4909a 100644 --- a/capabilities/am516-delivery-prediction/docs/runbook.md +++ b/capabilities/am516-delivery-prediction/docs/runbook.md @@ -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. Run the controlled script for the original model baseline and only the first four candidate models. +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. 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. diff --git a/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md b/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md index 03eb90a..c7bc632 100644 --- a/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md +++ b/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md @@ -42,12 +42,15 @@ Stop if: ## Production Quantity For Forecasting -- Treat the user's quantity as original demand. Before querying any model, divide by the fixed 95% yield (5% defect rate), then round upward to a multiple of five: `production_quantity = 5 × ceil(original_demand / 0.95 / 5)`. -- For example, demand 5 gives `5 / 0.95 = 5.263...`, then a forecast quantity of 10. Demand 10 gives 15; demand 19 gives exactly 20; demand 25 gives 30; demand 95 gives exactly 100. -- Use exact integer arithmetic to avoid rounding an exact batch boundary upward due to floating-point error. Do not replace division by yield with multiplication by 1.05, and do not round to five before applying the yield. -- Send this computed quantity as the controlled script's `--quantity` / API `quantity` for the baseline, all candidates and continuation batches. Calculate from the original demand each time; never adjust an already adjusted quantity again. The low-level query script passes the supplied API quantity unchanged. -- Preserve original demand in the conversation context, reply and record. Display both `需求数量` and `排产测算数量`; state the fixed defect rate and batching rule. Newly appended records include the forecast quantity as metadata without rewriting historical records or changing the candidate table columns. -- Judge inventory coverage against the same production quantity used by the API and label this quantity scope in the reply. +- 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. ## Feishu Message Trigger Gate @@ -88,7 +91,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 "" --quantity +python3 capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py --product "" --quantity ``` Do not use temporary curl, WebFetch, or any API method outside the controlled script. @@ -179,8 +182,8 @@ Use this field order for a successful Feishu report: 关节模组交期预估报告 | {generation time} 输入需求:{input model},{input quantity}台 -排产测算数量:{production quantity}台(不良率5%,按5台的倍数向上取整) -库存覆盖口径:按排产测算数量判断 +排产测算规则:各型号先扣可用库存,再除以95%合格率并向上取5台的倍数;库存足量时排产为0 +库存覆盖口径:按原始需求数量判断(可用库存按100%可用) 数据时间戳:{API data timestamp or generation time} 状态:试运行版输出 / 内部候选 判断等级:AM516算法预测参考,需人工复核 @@ -195,7 +198,8 @@ Use this field order for a successful Feishu report: 1. {model}({candidate type and reducer brand}) - 需求数量:{input quantity}台 -- 排产测算数量:{production quantity}台 +- 扣库存后净需求:{model net demand}台 +- 排产测算数量:{model production quantity}台 - AM516预测交期:{API workdays}个工作日,最终交货日期:{API final delivery date} - 可用库存/覆盖情况:{summary-level inventory and coverage only} - 主导因素:{main factor} diff --git a/shared/runtime/feishu_bot_bridge.py b/shared/runtime/feishu_bot_bridge.py index 2fb0399..6237952 100644 --- a/shared/runtime/feishu_bot_bridge.py +++ b/shared/runtime/feishu_bot_bridge.py @@ -182,11 +182,13 @@ class CandidateUnavailableError(RuntimeError): pass -def production_quantity_for_demand(quantity: int) -> int: - """Cover 5% defects, then round up to a batch of five without float error.""" +def production_quantity_for_demand(quantity: int, available_inventory: int = 0) -> int: + """Deduct usable finished stock before yield allowance and batch rounding.""" if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0: raise InputError("需求数量必须为正整数。") - numerator = quantity * 100 + 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 @@ -830,12 +832,13 @@ def overall_judgment_lines(candidates: list[dict[str, Any]]) -> list[str]: ] -def candidate_report_lines(candidate: dict[str, Any], quantity: int, production_quantity: int) -> list[str]: +def candidate_report_lines(candidate: dict[str, Any], quantity: int) -> list[str]: details = candidate["details"] return [ f"{candidate['index']}. {candidate['model']}({candidate['type']})", f"- 需求数量:{quantity}台", - f"- 排产测算数量:{production_quantity}台", + f"- 扣库存后净需求:{details['net_demand']}台", + f"- 排产测算数量:{details['production_quantity']}台", f"- {details['prediction']}", f"- 可用库存/覆盖情况:{details['inventory']}", f"- 主导因素:{details['main_factor']}", @@ -906,9 +909,17 @@ def render_record( "", f"**输入型号**:{request.model}", f"**输入数量**:{request.quantity}台", - f"**排产测算数量(API查询数量)**:{production_quantity_for_demand(request.quantity)}台", - "**数量计算规则**:原始需求÷95%良率,再按5台的倍数向上取整。", - "**库存覆盖口径**:按排产测算数量判断。", + "**数量计算规则**:各型号先扣可用库存(按100%可用),净需求÷95%良率,再按5台的倍数向上取整;净需求为0时排产为0。", + "**库存覆盖口径**:按原始需求数量判断。", + "**各型号排产测算**:", + *[ + f"- {item['model']}:净需求{item['details']['net_demand']}台;排产测算数量{item['details']['production_quantity']}台;最终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}`", @@ -981,10 +992,49 @@ def write_record( file.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. + """ + 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 + + def build_report(request: RequestInput, candidate_offset: int = 0) -> str: if candidate_offset < 0: raise InputError("候选批次位置无效,请重新发起型号和数量查询。") - production_quantity = production_quantity_for_demand(request.quantity) + production_quantity_for_demand(request.quantity) # Validate before querying. all_candidate_specs = generate_candidate_specs(request.model) candidate_specs = all_candidate_specs[ @@ -998,18 +1048,14 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str: baseline: dict[str, Any] | None = None if is_initial_batch: try: - baseline_payload = run_controlled_query(RequestInput(request.model, production_quantity)) + baseline = query_model_for_demand(request) except CandidateUnavailableError as exc: raise RuntimeError(f"原型号未生成候选结果。\n原因:{exc}") from exc - baseline = lead_time_details( - baseline_payload, - production_quantity, - ) if not baseline["has_lead_time"]: raise RuntimeError( - "原型号接口未返回有效工作日交期和最终交货日期,未生成候选结果。\n" + "原型号未生成可用交期结果,未生成候选结果。\n" f"原因:{baseline['unavailable_reason']}\n" - "处理建议:请接口维护人员核对上述字段及服务端计算结果后重试。" + "处理建议:请接口维护人员核对上述原因、库存口径及服务端计算结果后重试。" ) candidates.append( { @@ -1028,15 +1074,15 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str: def query_candidate(spec: dict[str, str]) -> tuple[dict[str, str], dict[str, Any] | None]: try: - payload = run_controlled_query(RequestInput(spec["model"], production_quantity)) + details = query_model_for_demand(RequestInput(spec["model"], request.quantity)) except CandidateUnavailableError as exc: return {**spec, "unavailable_reason": str(exc)}, None - details = lead_time_details( - payload, - production_quantity, - ) if not details["has_lead_time"]: - return {**spec, "unavailable_reason": details["unavailable_reason"]}, None + 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 if candidate_specs: @@ -1104,8 +1150,8 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str: ), "", f"输入需求:{request.model},{request.quantity}台", - f"排产测算数量:{production_quantity}台(不良率{DEFECT_RATE_PERCENT}%,按{PRODUCTION_BATCH_SIZE}台的倍数向上取整)", - "库存覆盖口径:按排产测算数量判断", + f"排产测算规则:各型号先扣可用库存,再除以{100 - DEFECT_RATE_PERCENT}%合格率并向上取{PRODUCTION_BATCH_SIZE}台的倍数;库存足量时排产为0", + "库存覆盖口径:按原始需求数量判断(可用库存按100%可用)", f"数据时间戳:{data_timestamp}", "状态:试运行版输出 / 内部候选", "判断等级:AM516算法预测参考,需人工复核", @@ -1116,7 +1162,7 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str: "", ] for candidate_item in candidates: - report_lines.extend(candidate_report_lines(candidate_item, request.quantity, production_quantity)) + report_lines.extend(candidate_report_lines(candidate_item, request.quantity)) report_lines.append("不可用候选:") if unavailable: for item in unavailable: diff --git a/tests/test_feishu_bot_bridge.py b/tests/test_feishu_bot_bridge.py index 31cddf5..183e0b7 100644 --- a/tests/test_feishu_bot_bridge.py +++ b/tests/test_feishu_bot_bridge.py @@ -428,40 +428,125 @@ class ProductionQuantityTests(unittest.TestCase): query.assert_not_called() record.assert_not_called() - def test_initial_and_follow_up_send_adjusted_quantity_once_to_controlled_script(self) -> None: - request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=5) - completed = SimpleNamespace(stdout=bridge.json.dumps(successful_payload()), stderr="", returncode=0) - for offset, expected_calls in ((0, 5), (bridge.CANDIDATE_BATCH_SIZE, 4)): + 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_initial_and_continuation_query_net_production_per_model(self) -> None: + request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=24) + payload = successful_payload() + payload["data"]["库存信息"]["可用库存(扣除了待产数量)"] = 6 + completed = SimpleNamespace(stdout=bridge.json.dumps(payload), stderr="", returncode=0) + 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, ): report = bridge.build_report(request, candidate_offset=offset) - self.assertEqual(run.call_count, expected_calls) + by_model = {} for call in run.call_args_list: command = call.args[0] - self.assertEqual(command[command.index("--quantity") + 1], "10") - self.assertIn(f"输入需求:{request.model},5台", report) - self.assertEqual(report.count("- 需求数量:5台"), expected_calls) - self.assertEqual(report.count("- 排产测算数量:10台"), expected_calls) - self.assertEqual(record.call_args.args[0].quantity, 5) + 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("**输入数量**:5台", recorded) - self.assertIn("**排产测算数量(API查询数量)**:10台", recorded) + self.assertIn("**输入数量**:24台", recorded) + self.assertEqual(recorded.count("排产测算数量20台;最终API查询数量20台"), model_count) - def test_stock_coverage_uses_same_quantity_as_api_forecast(self) -> None: + 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), + 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.assertIn("库存覆盖口径:按排产测算数量判断", report) - self.assertIn("部分覆盖,仍需补齐3台", report) - self.assertIn("是否完全覆盖数量:否", report) - self.assertIn("现货候选:无", report) + 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) + models = [request.model, *[s['model'] for s in bridge.generate_candidate_specs(request.model)[:4]]] + stocks = dict(zip(models, [6, 0, 24, 5, 30])) + def query(r): + payload = successful_payload() + payload['data']['库存信息']['可用库存(扣除了待产数量)'] = stocks[r.model] + return payload + with ( + mock.patch.object(bridge, 'run_controlled_query', side_effect=query) as run, + 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) + + def test_missing_or_invalid_stock_cannot_be_treated_as_zero(self) -> None: + for stock in (None, True, '6', 1.5, float('nan'), float('inf')): + payload = successful_payload() + payload['data']['库存信息']['可用库存(扣除了待产数量)'] = stock + with ( + self.subTest(stock=stock), + 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']) + query.assert_called_once() + + def test_partial_stock_cannot_trigger_false_stock_delivery(self) -> None: + payload = successful_payload() + payload['data']['库存信息']['可用库存(扣除了待产数量)'] = 20 + 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']) + 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.assertIn('- 缺料物料明细:测算物料', details['evidence_lines']) + + def test_unsupported_baseline_stops_before_candidate_queries_and_record(self) -> None: + payload = successful_payload() + payload['data']['库存信息']['可用库存(扣除了待产数量)'] = 20 + 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)) + query.assert_called_once() + record.assert_not_called() class CandidateBatchTests(unittest.TestCase): @@ -871,7 +956,7 @@ class ApiWorkdayOutputTests(unittest.TestCase): mock.patch.object(bridge, "run_controlled_query", return_value=payload), mock.patch.object(bridge, "write_record") as write_record, ): - with self.assertRaisesRegex(RuntimeError, "工作日交期和最终交货日期"): + with self.assertRaisesRegex(RuntimeError, "缺少“AM516工作日交期”字段"): bridge.build_report(request) write_record.assert_not_called()