feat: account for defects and five-unit batches in delivery forecasts
This commit is contained in:
@@ -14,6 +14,7 @@ Expected:
|
|||||||
|
|
||||||
- model parses successfully;
|
- model parses successfully;
|
||||||
- quantity is accepted;
|
- 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;
|
||||||
- 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 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;
|
- 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;
|
- no temporary TLS-bypass parameter is required for the normal happy path;
|
||||||
@@ -83,6 +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 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 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;
|
||||||
- the bot queries candidate positions 5-8 in the unchanged rule order;
|
- 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;
|
- 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 `不可用候选`;
|
- unavailable candidates still consume their original positions and are summarized under `不可用候选`;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 交期预测数量口径
|
||||||
|
|
||||||
|
用户输入为原始需求数量。交期回复机器人在查询原型号、候选型号及后续批次前,统一按以下规则计算 API 输入数量:
|
||||||
|
|
||||||
|
```text
|
||||||
|
排产测算数量 = 5 × ceil(原始需求数量 ÷ 0.95 ÷ 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
固定不良率为 5%,对应良率 95%。先除以良率,再向上取到 5 的倍数。代码使用整数运算,避免恰好落在倍数边界时因浮点误差多加一个批次。
|
||||||
|
|
||||||
|
| 原始需求 | 除以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 |
|
||||||
|
|
||||||
|
回复示例中的数量部分:
|
||||||
|
|
||||||
|
```text
|
||||||
|
输入需求:eRob70H120I-BHM-18CT[V5],5台
|
||||||
|
排产测算数量:10台(不良率5%,按5台的倍数向上取整)
|
||||||
|
库存覆盖口径:按排产测算数量判断
|
||||||
|
|
||||||
|
1. eRob70H120I-BHM-18CT[V5](原型号,DZ)
|
||||||
|
- 需求数量:5台
|
||||||
|
- 排产测算数量:10台
|
||||||
|
```
|
||||||
|
|
||||||
|
实际交期继续读取 API 返回的工作日交期和最终交货日期。库存覆盖也以本次 API 使用的排产测算数量判断,例如原始需求 5、API 数量 10、库存 7 时,显示部分覆盖,仍需补齐 3 台。
|
||||||
|
|
||||||
|
对话上下文和本地记录保留原始需求;记录元数据另列排产测算数量和计算规则。继续推荐仍从原始需求重新计算一次,不把 10 台再次放大为 15 台。低层受控脚本的 `--quantity` 参数就是最终发送给 API 的数量,不在脚本内重复换算。
|
||||||
|
|
||||||
|
本机通过相应测试后,运行电脑需要同步这次代码和规则变更,并重启交期回复机器人;另一个后台物料名称刷新服务不因数量规则变化而需要重新安装。
|
||||||
@@ -40,6 +40,15 @@ Stop if:
|
|||||||
- a Feishu message asks to create, modify, overwrite, rename, move, or delete a project file;
|
- a Feishu message asks to create, modify, overwrite, rename, move, or delete a project file;
|
||||||
- the user asks for a customer commitment.
|
- the user asks for a customer commitment.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
## Feishu Message Trigger Gate
|
## Feishu Message Trigger Gate
|
||||||
|
|
||||||
- Enter the query workflow only when one message contains exactly one complete,
|
- Enter the query workflow only when one message contains exactly one complete,
|
||||||
@@ -79,7 +88,7 @@ Do not write back rules, Skills, scripts, prompts, configurations, documentation
|
|||||||
Use only:
|
Use only:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
python3 capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py --product "<model>" --quantity <quantity>
|
python3 capabilities/am516-delivery-prediction/scripts/delivery_prediction_query_template.py --product "<model>" --quantity <production_quantity>
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not use temporary curl, WebFetch, or any API method outside the controlled script.
|
Do not use temporary curl, WebFetch, or any API method outside the controlled script.
|
||||||
@@ -170,6 +179,8 @@ Use this field order for a successful Feishu report:
|
|||||||
关节模组交期预估报告 | {generation time}
|
关节模组交期预估报告 | {generation time}
|
||||||
|
|
||||||
输入需求:{input model},{input quantity}台
|
输入需求:{input model},{input quantity}台
|
||||||
|
排产测算数量:{production quantity}台(不良率5%,按5台的倍数向上取整)
|
||||||
|
库存覆盖口径:按排产测算数量判断
|
||||||
数据时间戳:{API data timestamp or generation time}
|
数据时间戳:{API data timestamp or generation time}
|
||||||
状态:试运行版输出 / 内部候选
|
状态:试运行版输出 / 内部候选
|
||||||
判断等级:AM516算法预测参考,需人工复核
|
判断等级:AM516算法预测参考,需人工复核
|
||||||
@@ -183,7 +194,8 @@ Use this field order for a successful Feishu report:
|
|||||||
- 主导因素:{main factor}
|
- 主导因素:{main factor}
|
||||||
|
|
||||||
1. {model}({candidate type and reducer brand})
|
1. {model}({candidate type and reducer brand})
|
||||||
- 数量:{quantity}台
|
- 需求数量:{input quantity}台
|
||||||
|
- 排产测算数量:{production quantity}台
|
||||||
- AM516预测交期:{API workdays}个工作日,最终交货日期:{API final delivery date}
|
- AM516预测交期:{API workdays}个工作日,最终交货日期:{API final delivery date}
|
||||||
- 可用库存/覆盖情况:{summary-level inventory and coverage only}
|
- 可用库存/覆盖情况:{summary-level inventory and coverage only}
|
||||||
- 主导因素:{main factor}
|
- 主导因素:{main factor}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ ALLOWED_VERSIONS = {
|
|||||||
"170H": {"V3"},
|
"170H": {"V3"},
|
||||||
}
|
}
|
||||||
CANDIDATE_BATCH_SIZE = 4
|
CANDIDATE_BATCH_SIZE = 4
|
||||||
|
DEFECT_RATE_PERCENT = 5
|
||||||
|
PRODUCTION_BATCH_SIZE = 5
|
||||||
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
|
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
|
||||||
RECEIVED_REACTION_TYPE = "OnIt"
|
RECEIVED_REACTION_TYPE = "OnIt"
|
||||||
|
|
||||||
@@ -180,6 +182,15 @@ class CandidateUnavailableError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def production_quantity_for_demand(quantity: int) -> int:
|
||||||
|
"""Cover 5% defects, then round up to a batch of five without float error."""
|
||||||
|
if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:
|
||||||
|
raise InputError("需求数量必须为正整数。")
|
||||||
|
numerator = quantity * 100
|
||||||
|
denominator = (100 - DEFECT_RATE_PERCENT) * PRODUCTION_BATCH_SIZE
|
||||||
|
return ((numerator + denominator - 1) // denominator) * PRODUCTION_BATCH_SIZE
|
||||||
|
|
||||||
|
|
||||||
_candidate_continuations: dict[str, CandidateContinuation] = {}
|
_candidate_continuations: dict[str, CandidateContinuation] = {}
|
||||||
_candidate_continuation_lock = threading.Lock()
|
_candidate_continuation_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -819,11 +830,12 @@ def overall_judgment_lines(candidates: list[dict[str, Any]]) -> list[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def candidate_report_lines(candidate: dict[str, Any], quantity: int) -> list[str]:
|
def candidate_report_lines(candidate: dict[str, Any], quantity: int, production_quantity: int) -> list[str]:
|
||||||
details = candidate["details"]
|
details = candidate["details"]
|
||||||
return [
|
return [
|
||||||
f"{candidate['index']}. {candidate['model']}({candidate['type']})",
|
f"{candidate['index']}. {candidate['model']}({candidate['type']})",
|
||||||
f"- 数量:{quantity}台",
|
f"- 需求数量:{quantity}台",
|
||||||
|
f"- 排产测算数量:{production_quantity}台",
|
||||||
f"- {details['prediction']}",
|
f"- {details['prediction']}",
|
||||||
f"- 可用库存/覆盖情况:{details['inventory']}",
|
f"- 可用库存/覆盖情况:{details['inventory']}",
|
||||||
f"- 主导因素:{details['main_factor']}",
|
f"- 主导因素:{details['main_factor']}",
|
||||||
@@ -894,6 +906,9 @@ def render_record(
|
|||||||
"",
|
"",
|
||||||
f"**输入型号**:{request.model}",
|
f"**输入型号**:{request.model}",
|
||||||
f"**输入数量**:{request.quantity}台",
|
f"**输入数量**:{request.quantity}台",
|
||||||
|
f"**排产测算数量(API查询数量)**:{production_quantity_for_demand(request.quantity)}台",
|
||||||
|
"**数量计算规则**:原始需求÷95%良率,再按5台的倍数向上取整。",
|
||||||
|
"**库存覆盖口径**:按排产测算数量判断。",
|
||||||
f"**生成时间**:{generation_time:%Y-%m-%d %H:%M:%S %z}",
|
f"**生成时间**:{generation_time:%Y-%m-%d %H:%M:%S %z}",
|
||||||
f"**规则源实际路径**:`{RULE_SOURCE_DISPLAY}`",
|
f"**规则源实际路径**:`{RULE_SOURCE_DISPLAY}`",
|
||||||
f"**接口依据**:`{API_SOURCE_DISPLAY}`",
|
f"**接口依据**:`{API_SOURCE_DISPLAY}`",
|
||||||
@@ -969,6 +984,7 @@ def write_record(
|
|||||||
def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
||||||
if candidate_offset < 0:
|
if candidate_offset < 0:
|
||||||
raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
|
raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
|
||||||
|
production_quantity = production_quantity_for_demand(request.quantity)
|
||||||
|
|
||||||
all_candidate_specs = generate_candidate_specs(request.model)
|
all_candidate_specs = generate_candidate_specs(request.model)
|
||||||
candidate_specs = all_candidate_specs[
|
candidate_specs = all_candidate_specs[
|
||||||
@@ -982,12 +998,12 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
baseline: dict[str, Any] | None = None
|
baseline: dict[str, Any] | None = None
|
||||||
if is_initial_batch:
|
if is_initial_batch:
|
||||||
try:
|
try:
|
||||||
baseline_payload = run_controlled_query(request)
|
baseline_payload = run_controlled_query(RequestInput(request.model, production_quantity))
|
||||||
except CandidateUnavailableError as exc:
|
except CandidateUnavailableError as exc:
|
||||||
raise RuntimeError(f"原型号未生成候选结果。\n原因:{exc}") from exc
|
raise RuntimeError(f"原型号未生成候选结果。\n原因:{exc}") from exc
|
||||||
baseline = lead_time_details(
|
baseline = lead_time_details(
|
||||||
baseline_payload,
|
baseline_payload,
|
||||||
request.quantity,
|
production_quantity,
|
||||||
)
|
)
|
||||||
if not baseline["has_lead_time"]:
|
if not baseline["has_lead_time"]:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -1012,12 +1028,12 @@ 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]:
|
def query_candidate(spec: dict[str, str]) -> tuple[dict[str, str], dict[str, Any] | None]:
|
||||||
try:
|
try:
|
||||||
payload = run_controlled_query(RequestInput(spec["model"], request.quantity))
|
payload = run_controlled_query(RequestInput(spec["model"], production_quantity))
|
||||||
except CandidateUnavailableError as exc:
|
except CandidateUnavailableError as exc:
|
||||||
return {**spec, "unavailable_reason": str(exc)}, None
|
return {**spec, "unavailable_reason": str(exc)}, None
|
||||||
details = lead_time_details(
|
details = lead_time_details(
|
||||||
payload,
|
payload,
|
||||||
request.quantity,
|
production_quantity,
|
||||||
)
|
)
|
||||||
if not details["has_lead_time"]:
|
if not details["has_lead_time"]:
|
||||||
return {**spec, "unavailable_reason": details["unavailable_reason"]}, None
|
return {**spec, "unavailable_reason": details["unavailable_reason"]}, None
|
||||||
@@ -1088,6 +1104,8 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
),
|
),
|
||||||
"",
|
"",
|
||||||
f"输入需求:{request.model},{request.quantity}台",
|
f"输入需求:{request.model},{request.quantity}台",
|
||||||
|
f"排产测算数量:{production_quantity}台(不良率{DEFECT_RATE_PERCENT}%,按{PRODUCTION_BATCH_SIZE}台的倍数向上取整)",
|
||||||
|
"库存覆盖口径:按排产测算数量判断",
|
||||||
f"数据时间戳:{data_timestamp}",
|
f"数据时间戳:{data_timestamp}",
|
||||||
"状态:试运行版输出 / 内部候选",
|
"状态:试运行版输出 / 内部候选",
|
||||||
"判断等级:AM516算法预测参考,需人工复核",
|
"判断等级:AM516算法预测参考,需人工复核",
|
||||||
@@ -1098,7 +1116,7 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for candidate_item in candidates:
|
for candidate_item in candidates:
|
||||||
report_lines.extend(candidate_report_lines(candidate_item, request.quantity))
|
report_lines.extend(candidate_report_lines(candidate_item, request.quantity, production_quantity))
|
||||||
report_lines.append("不可用候选:")
|
report_lines.append("不可用候选:")
|
||||||
if unavailable:
|
if unavailable:
|
||||||
for item in unavailable:
|
for item in unavailable:
|
||||||
|
|||||||
@@ -404,6 +404,66 @@ class TriggerGateTests(unittest.TestCase):
|
|||||||
self.assertNotIn(key, bridge._candidate_continuations)
|
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)
|
||||||
|
|
||||||
|
def test_invalid_demand_stops_before_api_or_recording(self) -> None:
|
||||||
|
for value in (0, -1, True, 5.0, "5"):
|
||||||
|
with (
|
||||||
|
self.subTest(value=value),
|
||||||
|
mock.patch.object(bridge, "run_controlled_query") as query,
|
||||||
|
mock.patch.object(bridge, "write_record") as record,
|
||||||
|
):
|
||||||
|
with self.assertRaises(bridge.InputError):
|
||||||
|
bridge.build_report(bridge.RequestInput("eRob70H50I-FS-18CN[V5]", value))
|
||||||
|
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)):
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
recorded = bridge.render_record(*record.call_args.args)
|
||||||
|
self.assertIn("**输入数量**:5台", recorded)
|
||||||
|
self.assertIn("**排产测算数量(API查询数量)**:10台", recorded)
|
||||||
|
|
||||||
|
def test_stock_coverage_uses_same_quantity_as_api_forecast(self) -> None:
|
||||||
|
payload = successful_payload()
|
||||||
|
payload["data"]["库存信息"]["可用库存(扣除了待产数量)"] = 7
|
||||||
|
with (
|
||||||
|
mock.patch.object(bridge, "run_controlled_query", return_value=payload),
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class CandidateBatchTests(unittest.TestCase):
|
class CandidateBatchTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
|
self.request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
|
||||||
|
|||||||
Reference in New Issue
Block a user