Files
AM516/tests/test_feishu_bot_bridge.py

881 lines
39 KiB
Python
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.
from __future__ import annotations
import sys
import types
import unittest
from types import SimpleNamespace
from unittest import mock
if "truststore" not in sys.modules:
try:
import truststore # noqa: F401
except ModuleNotFoundError:
truststore_stub = types.ModuleType("truststore")
truststore_stub.inject_into_ssl = lambda: None
sys.modules["truststore"] = truststore_stub
if "lark_oapi" not in sys.modules:
try:
import lark_oapi # noqa: F401
except ModuleNotFoundError:
lark_stub = types.ModuleType("lark_oapi")
lark_stub.im = SimpleNamespace(v1=SimpleNamespace(P2ImMessageReceiveV1=object))
lark_stub.Client = object
sys.modules["lark_oapi"] = lark_stub
from shared.runtime import feishu_bot_bridge as bridge
def successful_payload() -> dict[str, object]:
return {
"data": {
"时间戳": "2026-08-31 12:00:00",
"交期汇总": {
"AM516总交期": 1,
"AM516工作日交期": 1,
"AM516最终交货日期": "2026-09-01",
"AM516主导因素": "现货",
},
"库存信息": {"可用库存(扣除了待产数量)": 100},
"BOM信息": {"是否有BOM(False则需提醒无BOM)": True},
"采购信息": {"缺料明细": []},
}
}
def message_event(message_id: str, chat_id: str, open_id: str) -> SimpleNamespace:
return SimpleNamespace(
event=SimpleNamespace(
message=SimpleNamespace(message_id=message_id, chat_id=chat_id),
sender=SimpleNamespace(sender_id=SimpleNamespace(open_id=open_id)),
)
)
class TriggerGateTests(unittest.TestCase):
def setUp(self) -> None:
with bridge._candidate_continuation_lock:
bridge._candidate_continuations.clear()
def test_complete_model_and_quantity_trigger(self) -> None:
request = bridge.parse_trigger_request("eRob70H50I-BHM-18CTC[V5] 10台")
self.assertEqual(
request,
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10),
)
def test_natural_language_model_and_quantity_trigger(self) -> None:
request = bridge.parse_trigger_request(
"请查询 eRob142H100I-BHM-18ET[V4],这个数量20台的交期"
)
self.assertEqual(
request,
bridge.RequestInput(model="eRob142H100I-BHM-18ET[V4]", quantity=20),
)
def test_unrelated_group_message_is_silent(self) -> None:
self.assertIsNone(bridge.parse_trigger_request("这个机器人头像有点丑"))
def test_quantity_without_complete_model_is_silent(self) -> None:
self.assertIsNone(bridge.parse_trigger_request("关节模组要10台"))
def test_model_without_quantity_is_silent(self) -> None:
self.assertIsNone(bridge.parse_trigger_request("eRob70H50I-BHM-18CTC[V5]"))
def test_ambiguous_quantity_is_silent(self) -> None:
self.assertIsNone(
bridge.parse_trigger_request(
"eRob70H50I-BHM-18CTC[V5] 可能要10台,也可能要20台"
)
)
def test_invalid_series_version_is_silent(self) -> None:
self.assertIsNone(bridge.parse_trigger_request("eRob70H50I-BHM-18CTC[V6] 10台"))
def test_ignored_message_does_not_build_or_reply(self) -> None:
data = SimpleNamespace(
event=SimpleNamespace(message=SimpleNamespace(message_id="om_ignored"))
)
with (
mock.patch.object(bridge, "message_text", return_value="他怎么什么消息都回复啊"),
mock.patch.object(bridge, "build_report") as build_report,
mock.patch.object(
bridge,
"add_received_reaction",
) as react,
mock.patch.object(bridge, "reply") as reply,
):
bridge.handle_message(object(), data)
build_report.assert_not_called()
react.assert_not_called()
reply.assert_not_called()
def test_invalid_inputs_get_guidance_without_query_or_reaction(self) -> None:
for chat_type, text in [
("p2p", "你好"), ("p2p", None),
("p2p", "还有吗"), ("group", "交期怎么查"),
("group", "eRob110H160I-FHM-18ET[V6]"),
("group", "eRob110H160I-FHM-18ET[V6] 10台或20台"),
("group", "eRob110H160I-FHM-18ET[V6] 10台 eRob142H100I-BHM-18ET[V4] 20台"),
]:
with self.subTest(chat_type=chat_type, text=text):
data = message_event("om_help", "oc_help", "ou_help")
data.event.message.chat_type = chat_type
with (
mock.patch.object(bridge, "message_text", return_value=text),
mock.patch.object(bridge, "build_report") as build,
mock.patch.object(bridge, "add_received_reaction") as react,
mock.patch.object(bridge, "reply") as reply,
):
bridge.handle_message("client", data)
build.assert_not_called()
react.assert_not_called()
reply.assert_called_once()
self.assertIn("提问示例", reply.call_args.args[2])
def test_valid_message_builds_and_replies(self) -> None:
data = SimpleNamespace(
event=SimpleNamespace(message=SimpleNamespace(message_id="om_valid"))
)
with (
mock.patch.object(
bridge,
"message_text",
return_value="eRob70H50I-BHM-18CTC[V5] 10台",
),
mock.patch.object(
bridge,
"add_received_reaction",
return_value=False,
) as react,
mock.patch.object(bridge, "build_report", return_value="report") as build_report,
mock.patch.object(bridge, "reply") as reply,
):
bridge.handle_message("client", data)
react.assert_called_once_with("client", "om_valid")
build_report.assert_called_once_with(
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
)
reply.assert_called_once_with("client", "om_valid", "report")
def test_received_reaction_targets_original_message_with_onit(self) -> None:
class ReactionBodyBuilder:
def __init__(self) -> None:
self.value: dict[str, str] | None = None
def reaction_type(self, value: dict[str, str]) -> "ReactionBodyBuilder":
self.value = value
return self
def build(self) -> SimpleNamespace:
return SimpleNamespace(reaction_type=self.value)
class ReactionRequestBuilder:
def __init__(self) -> None:
self.message_id_value: str | None = None
self.body: SimpleNamespace | None = None
def message_id(self, value: str) -> "ReactionRequestBuilder":
self.message_id_value = value
return self
def request_body(self, value: SimpleNamespace) -> "ReactionRequestBuilder":
self.body = value
return self
def build(self) -> SimpleNamespace:
return SimpleNamespace(
message_id=self.message_id_value,
request_body=self.body,
)
body_type = SimpleNamespace(builder=ReactionBodyBuilder)
request_type = SimpleNamespace(builder=ReactionRequestBuilder)
create_reaction = mock.Mock(
return_value=SimpleNamespace(
success=lambda: True,
code=0,
msg="ok",
)
)
client = SimpleNamespace(
im=SimpleNamespace(
v1=SimpleNamespace(
message_reaction=SimpleNamespace(create=create_reaction),
)
)
)
with (
mock.patch.object(
bridge.lark.im.v1,
"CreateMessageReactionRequestBody",
body_type,
create=True,
),
mock.patch.object(
bridge.lark.im.v1,
"CreateMessageReactionRequest",
request_type,
create=True,
),
):
result = bridge.add_received_reaction(client, "om_original")
self.assertTrue(result)
sent_request = create_reaction.call_args.args[0]
self.assertEqual(sent_request.message_id, "om_original")
self.assertEqual(
sent_request.request_body.reaction_type,
{"emoji_type": "OnIt"},
)
def test_received_reaction_exception_does_not_escape(self) -> None:
broken_body_type = SimpleNamespace(
builder=mock.Mock(side_effect=RuntimeError("reaction unavailable"))
)
with (
mock.patch.object(
bridge.lark.im.v1,
"CreateMessageReactionRequestBody",
broken_body_type,
create=True,
),
mock.patch.object(bridge.logging, "exception") as log_exception,
):
result = bridge.add_received_reaction(object(), "om_original")
self.assertFalse(result)
log_exception.assert_called_once()
def test_candidate_continuation_phrase_is_recognized(self) -> None:
valid_phrases = (
"还有没有其他候选型号?",
"还有吗?",
"还有别的吗",
"其他呢",
"还有类似的吗",
"还有什么推荐",
"有没有其他选择",
"还有其他方案吗",
"再推荐几个",
"可以再给我几个吗",
"继续吧",
"接着来",
"下一批",
"换一批",
"多看几个候选",
"more candidates",
"any other options?",
)
for phrase in valid_phrases:
with self.subTest(phrase=phrase):
self.assertTrue(bridge.is_candidate_continuation_request(phrase))
invalid_phrases = (
"这个机器人头像有点丑",
"more interesting",
"继续查询英语例句",
"下一课",
"再来一首歌",
"有其他事情吗",
)
for phrase in invalid_phrases:
with self.subTest(phrase=phrase):
self.assertFalse(bridge.is_candidate_continuation_request(phrase))
def test_continuation_without_same_conversation_context_gets_guidance(self) -> None:
data = message_event("om_more", "oc_other", "ou_other")
with (
mock.patch.object(
bridge,
"message_text",
return_value="还有没有其他候选型号?",
),
mock.patch.object(bridge, "build_report") as build_report,
mock.patch.object(
bridge,
"add_received_reaction",
) as react,
mock.patch.object(bridge, "reply") as reply,
):
bridge.handle_message("client", data)
build_report.assert_not_called()
react.assert_not_called()
reply.assert_called_once()
self.assertIn("当前没有可继续的查询", reply.call_args.args[2])
def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None:
request_text = "eRob70H50I-FS-18CN[V5] 10台"
request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
initial_data = message_event("om_initial", "oc_same", "ou_same")
follow_up_data = message_event("om_follow_up", "oc_same", "ou_same")
with (
mock.patch.object(
bridge,
"message_text",
side_effect=[request_text, "还有吗?"],
),
mock.patch.object(
bridge,
"build_report",
side_effect=["initial report", "continued report"],
) as build_report,
mock.patch.object(
bridge,
"add_received_reaction",
) as react,
mock.patch.object(bridge, "reply") as reply,
):
bridge.handle_message("client", initial_data)
bridge.handle_message("client", follow_up_data)
self.assertEqual(
build_report.call_args_list,
[mock.call(request), mock.call(request, candidate_offset=4)],
)
self.assertEqual(
react.call_args_list,
[
mock.call("client", "om_initial"),
mock.call("client", "om_follow_up"),
],
)
self.assertEqual(
reply.call_args_list,
[
mock.call("client", "om_initial", "initial report"),
mock.call("client", "om_follow_up", "continued report"),
],
)
def test_context_is_cleared_when_all_candidates_fit_in_first_batch(self) -> None:
request_text = "eRob70H50I-BHM-18CTC[V5] 10台"
initial_data = message_event("om_small", "oc_small", "ou_small")
follow_up_data = message_event("om_small_more", "oc_small", "ou_small")
with (
mock.patch.object(
bridge,
"message_text",
side_effect=[request_text, "还有没有其他候选型号?"],
),
mock.patch.object(
bridge,
"add_received_reaction",
) as react,
mock.patch.object(bridge, "build_report", return_value="report") as build_report,
mock.patch.object(bridge, "reply") as reply,
):
bridge.handle_message("client", initial_data)
bridge.handle_message("client", follow_up_data)
build_report.assert_called_once_with(
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
)
react.assert_called_once_with("client", "om_small")
self.assertEqual(reply.call_count, 2)
self.assertEqual(reply.call_args_list[0], mock.call("client", "om_small", "report"))
self.assertIn("当前没有可继续的查询", reply.call_args.args[2])
def test_expired_candidate_context_is_removed(self) -> None:
key = "oc_expired:ou_expired"
request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
bridge._candidate_continuations[key] = bridge.CandidateContinuation(
request=request,
next_offset=4,
total_candidates=10,
expires_at=0,
)
self.assertIsNone(bridge.get_candidate_continuation(key))
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):
def setUp(self) -> None:
self.request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
self.specs = bridge.generate_candidate_specs(self.request.model)
self.assertGreater(len(self.specs), bridge.CANDIDATE_BATCH_SIZE * 2)
def test_initial_report_queries_baseline_and_only_four_candidates(self) -> None:
with (
mock.patch.object(
bridge,
"run_controlled_query",
return_value=successful_payload(),
) as run_query,
mock.patch.object(bridge, "write_record"),
):
report = bridge.build_report(self.request)
queried_models = [call.args[0].model for call in run_query.call_args_list]
self.assertEqual(len(queried_models), 1 + bridge.CANDIDATE_BATCH_SIZE)
self.assertEqual(queried_models[0], self.request.model)
self.assertCountEqual(
queried_models[1:],
[spec["model"] for spec in self.specs[: bridge.CANDIDATE_BATCH_SIZE]],
)
self.assertIn("本次先推荐4个候选型号(原型号另作基准)", report)
self.assertIn("可说“还有吗”“再推荐几个”“继续”等类似表达", report)
self.assertNotIn(self.specs[bridge.CANDIDATE_BATCH_SIZE]["model"], report)
def test_follow_up_report_queries_next_four_without_requerying_baseline(self) -> None:
with (
mock.patch.object(
bridge,
"run_controlled_query",
return_value=successful_payload(),
) as run_query,
mock.patch.object(bridge, "write_record"),
):
report = bridge.build_report(
self.request,
candidate_offset=bridge.CANDIDATE_BATCH_SIZE,
)
queried_models = [call.args[0].model for call in run_query.call_args_list]
expected_models = [
spec["model"]
for spec in self.specs[
bridge.CANDIDATE_BATCH_SIZE : bridge.CANDIDATE_BATCH_SIZE * 2
]
]
self.assertEqual(len(queried_models), bridge.CANDIDATE_BATCH_SIZE)
self.assertNotIn(self.request.model, queried_models)
self.assertCountEqual(queried_models, expected_models)
self.assertIn("本次继续推荐第5-8个候选型号", report)
for model in expected_models:
self.assertIn(model, report)
for model in [spec["model"] for spec in self.specs[:4]]:
self.assertNotIn(model, report)
class UnavailableReasonTests(unittest.TestCase):
def test_missing_empty_and_invalid_required_fields_have_specific_reasons(self) -> None:
cases = [
("AM516工作日交期", "missing", "缺少“AM516工作日交期”字段"),
("AM516工作日交期", None, "“AM516工作日交期”返回空值"),
("AM516工作日交期", -1, "应为非负整数工作日"),
("AM516工作日交期", True, "应为非负整数工作日"),
("AM516最终交货日期", "missing", "缺少“AM516最终交货日期”字段"),
("AM516最终交货日期", "", "“AM516最终交货日期”返回空值"),
("AM516最终交货日期", "2026-02-30", "应为有效的 YYYY-MM-DD 日期"),
]
for field, value, expected in cases:
with self.subTest(field=field, value=value):
payload = successful_payload()
if value == "missing":
del payload["data"]["交期汇总"][field]
else:
payload["data"]["交期汇总"][field] = value
reason = bridge.lead_time_details(payload, 10)["unavailable_reason"]
self.assertIn(expected, reason)
self.assertNotIn("型号不存在", reason)
self.assertIn("无法替代上述必需字段", reason)
def test_missing_data_summary_and_bom_are_reported_from_response_evidence(self) -> None:
for payload, expected in (
({}, "缺少 data 字段"),
({"data": None}, "data 为空"),
({"data": []}, "data 格式错误"),
({"data": {}}, "缺少“交期汇总”字段"),
({"data": {"交期汇总": None}}, "“交期汇总”为空"),
({"data": {"交期汇总": []}}, "“交期汇总”格式错误"),
):
with self.subTest(payload=payload):
self.assertIn(expected, bridge.lead_time_details(payload, 10)["unavailable_reason"])
payload = successful_payload()
del payload["data"]["交期汇总"]["AM516工作日交期"]
payload["data"]["BOM信息"]["是否有BOM(False则需提醒无BOM)"] = False
self.assertIn("接口同时标记无有效 BOM", bridge.lead_time_details(payload, 10)["unavailable_reason"])
def test_controlled_query_failure_reasons_do_not_echo_raw_error_content(self) -> None:
cases = [
({"ok": False, "error": "missing API key: SECRET_MARKER"}, "未配置交期预测 API Key"),
({"ok": False, "status_code": 401}, "身份认证失败"),
({"ok": False, "status_code": 403}, "拒绝访问"),
({"ok": False, "status_code": 404}, "接口未说明是否为型号不存在"),
({"ok": False, "status_code": 429}, "请求频率受限"),
({"ok": False, "status_code": 503}, "服务异常"),
({"ok": False, "error": "TLS certificate verification failed"}, "TLS 证书验证失败"),
({"ok": False, "error": "request failed: nodename nor servname provided SECRET_MARKER"}, "域名解析失败"),
({"ok": False, "error": "request failed: timed out SECRET_MARKER"}, "请求超时"),
({"success": False, "message": "SECRET_MARKER"}, "未提供可安全展示的具体原因"),
]
request = bridge.RequestInput(model="eRob70H120I-BHM-18CT[V5]", quantity=25)
for payload, expected in cases:
with self.subTest(payload=payload):
payload["body_preview"] = "SECRET_MARKER sensitive raw data"
completed = SimpleNamespace(
stdout=bridge.json.dumps(payload), stderr="SECRET_MARKER", returncode=1 if payload.get("ok") is False else 0,
)
with mock.patch.object(bridge.subprocess, "run", return_value=completed):
with self.assertRaises(RuntimeError) as caught:
bridge.run_controlled_query(request)
self.assertIn(expected, str(caught.exception))
self.assertNotIn("SECRET_MARKER", str(caught.exception))
def test_stopped_feishu_reply_includes_actual_missing_field_and_no_record(self) -> None:
payload = successful_payload()
del payload["data"]["交期汇总"]["AM516工作日交期"]
data = message_event("om_reason", "oc_reason", "ou_reason")
with (
mock.patch.object(bridge, "message_text", return_value="eRob70H120I-BHM-18CT[V5] 25台"),
mock.patch.object(bridge, "add_received_reaction"),
mock.patch.object(bridge, "run_controlled_query", return_value=payload),
mock.patch.object(bridge, "write_record") as write_record,
mock.patch.object(bridge, "reply") as reply,
):
bridge.handle_message("client", data)
report = reply.call_args.args[2]
self.assertIn("原因:缺少“AM516工作日交期”字段", report)
self.assertIn("处理建议", report)
self.assertEqual(report.count(bridge.DISCLAIMER), 1)
write_record.assert_not_called()
def test_each_unavailable_candidate_keeps_its_own_reason(self) -> None:
request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
specs = bridge.generate_candidate_specs(request.model)
def query(query_request: bridge.RequestInput) -> dict[str, object]:
payload = successful_payload()
if query_request.model == specs[0]["model"]:
raise bridge.CandidateUnavailableError("HTTP 422:请求参数未通过接口校验。")
if query_request.model == specs[1]["model"]:
del payload["data"]["交期汇总"]["AM516最终交货日期"]
return payload
with (
mock.patch.object(bridge, "run_controlled_query", side_effect=query),
mock.patch.object(bridge, "write_record"),
):
report = bridge.build_report(request)
self.assertIn(f"- {specs[0]['model']}:原因:HTTP 422", report)
self.assertIn(f"- {specs[1]['model']}:原因:缺少“AM516最终交货日期”字段", report)
self.assertNotIn("不存在或接口未返回", report)
class ShortageMaterialNamesTests(unittest.TestCase):
def setUp(self) -> None:
patcher = mock.patch.object(bridge, "load_material_names", return_value={})
self.load_names = patcher.start()
self.addCleanup(patcher.stop)
def test_code_only_shortages_use_local_names_and_preserve_api_name_priority(self) -> None:
self.load_names.return_value = {"20.40.00001": "编码器", "20.50.00002": "轴承"}
lines = bridge.shortage_material_names_line([
{"缺料物料": "20.40.00001"},
{"缺料物料": " 20.50.00002 "},
{"缺料物料": "20.40.00001"},
{"缺料物料": "20.50.00002", "物料名称": "API更新后的轴承名称"},
])
self.assertEqual(lines, "- 缺料物料明细:编码器;轴承;API更新后的轴承名称")
self.load_names.assert_called_once_with()
self.assertNotIn("20.40", lines)
self.assertNotIn("20.50", lines)
def test_missing_or_invalid_cache_keeps_available_names_and_explains_failure(self) -> None:
for reason in ("本地物料名称缓存文件缺失", "本地物料名称缓存计数或完整性校验失败"):
with self.subTest(reason=reason):
self.load_names.side_effect = bridge.MaterialNameCacheError(reason)
line = bridge.shortage_material_names_line([
{"物料名称": "编码器"},
{"缺料物料": "20.50.00002"},
{"缺料物料": "20.50.00003"},
])
self.assertIn("编码器", line)
self.assertIn("2项缺料记录未找到物料名称", line)
self.assertIn(reason, line)
self.assertNotIn("20.50.", line)
def test_initial_and_continuation_code_only_shortages_resolve_offline(self) -> None:
request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
self.load_names.return_value = {"20.40.00001": "编码器"}
def query(query_request: bridge.RequestInput) -> dict[str, object]:
payload = successful_payload()
payload["data"]["采购信息"]["缺料明细"] = [{"缺料物料": "20.40.00001"}]
return payload
for offset in (0, bridge.CANDIDATE_BATCH_SIZE):
with (
self.subTest(offset=offset),
mock.patch.object(bridge, "run_controlled_query", side_effect=query) as run_query,
mock.patch.object(bridge, "write_record"),
):
report = bridge.build_report(request, candidate_offset=offset)
self.assertEqual(report.count("- 缺料物料明细:编码器"), run_query.call_count)
self.assertNotIn("未找到物料名称", report)
self.assertNotIn("20.40.00001", report)
def test_all_shortage_names_are_shown_without_non_reducer_raw_fields(self) -> None:
payload = successful_payload()
payload["data"]["采购信息"]["缺料明细"] = [
{"缺料物料": "20.30.00157", "物料名称": "减速器", "库存数量": 11},
{"缺料物料": "20.40.00999", "物料名称": " 编码器 ", "库存数量": 9876},
{"缺料物料": "20.50.00888", "缺料物料名称": "轴承", "在途数量": 8765},
{"缺料物料": "20.40.00777", "物料名称": "编码器"},
]
lines = bridge.lead_time_details(payload, requested_quantity=10)["evidence_lines"]
self.assertEqual(lines[0], "- 缺料物料明细:减速器;编码器;轴承")
self.assertIn("减速器缺料展开:物料编码20.30.00157;库存数量11;", lines[1])
for raw_value in ("20.40.00999", "20.50.00888", "20.40.00777", "9876", "8765"):
self.assertNotIn(raw_value, "\n".join(lines))
def test_missing_names_never_fall_back_to_codes_or_claim_no_shortage(self) -> None:
for valid_name in (False, True):
with self.subTest(valid_name=valid_name):
payload = successful_payload()
shortages = [
{"缺料物料": "20.40.00999"},
{"物料名称": " "},
{"物料名称": 123},
None,
]
if valid_name:
shortages.append({"物料名称": "轴承"})
payload["data"]["采购信息"]["缺料明细"] = shortages
lines = bridge.lead_time_details(payload, requested_quantity=10)["evidence_lines"]
expected = "4项缺料记录未找到物料名称,请核对物料编码并更新本地名称缓存后复核"
if valid_name:
expected = f"轴承(另有{expected})"
self.assertEqual(lines, [f"- 缺料物料明细:{expected}"])
def test_empty_missing_and_invalid_bom_remain_distinct(self) -> None:
payload = successful_payload()
lines = bridge.lead_time_details(payload, requested_quantity=10)["evidence_lines"]
self.assertEqual(lines, ["- 缺料物料明细:API未返回缺料记录。"])
del payload["data"]["采购信息"]["缺料明细"]
lines = bridge.lead_time_details(payload, requested_quantity=10)["evidence_lines"]
self.assertIn("接口未返回可识别缺料汇总", lines[0])
payload["data"]["BOM信息"]["是否有BOM(False则需提醒无BOM)"] = False
payload["data"]["采购信息"]["缺料明细"] = [{"物料名称": "轴承"}]
lines = bridge.lead_time_details(payload, requested_quantity=10)["evidence_lines"]
self.assertIn("API未返回有效BOM", lines[0])
self.assertNotIn("轴承", "\n".join(lines))
def test_initial_and_continuation_reports_keep_names_under_their_model(self) -> None:
request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
def query(query_request: bridge.RequestInput) -> dict[str, object]:
payload = successful_payload()
payload["data"]["采购信息"]["缺料明细"] = [
{"物料名称": f"样例物料-{query_request.model}"}
]
return payload
for offset in (0, bridge.CANDIDATE_BATCH_SIZE):
with (
self.subTest(offset=offset),
mock.patch.object(bridge, "run_controlled_query", side_effect=query) as run_query,
mock.patch.object(bridge, "write_record"),
):
report = bridge.build_report(request, candidate_offset=offset)
self.assertEqual(report.count("- 缺料物料明细:"), run_query.call_count)
for call in run_query.call_args_list:
model = call.args[0].model
heading = report.index(f"{model}(")
detail = report.index(f"- 缺料物料明细:样例物料-{model}", heading)
end = report.index("- 需客户确认的关键差异:", heading)
self.assertLess(detail, end)
class ApiWorkdayOutputTests(unittest.TestCase):
def test_api_workdays_and_date_override_legacy_fields(self) -> None:
payload = successful_payload()
summary = payload["data"]["交期汇总"]
summary["AM516预测交期"] = "2026-10-03"
summary["AM516总交期"] = 31
summary["AM516工作日交期"] = 8
summary["AM516最终交货日期"] = "2026-10-08"
details = bridge.lead_time_details(payload, requested_quantity=10)
self.assertEqual(details["total_days"], 31)
self.assertEqual(details["workdays"], 8)
self.assertEqual(details["delivery_date"], "2026-10-08")
self.assertEqual(
details["prediction"],
"AM516预测交期:8个工作日,最终交货日期:2026-10-08",
)
def test_new_fields_work_without_natural_days_or_local_calendar(self) -> None:
payload = successful_payload()
summary = payload["data"]["交期汇总"]
del summary["AM516总交期"]
summary["AM516工作日交期"] = 8
summary["AM516最终交货日期"] = "2027-01-15"
details = bridge.lead_time_details(payload, requested_quantity=10)
self.assertTrue(details["has_lead_time"])
self.assertEqual(details["workdays"], 8)
self.assertEqual(details["delivery_date"], "2027-01-15")
def test_zero_workdays_are_preserved(self) -> None:
payload = successful_payload()
payload["data"]["交期汇总"]["AM516工作日交期"] = 0
details = bridge.lead_time_details(payload, requested_quantity=10)
self.assertTrue(details["has_lead_time"])
self.assertEqual(
details["prediction_value"], "0个工作日,最终交货日期:2026-09-01"
)
def test_missing_new_fields_do_not_fall_back_to_old_fields(self) -> None:
for field in ("AM516工作日交期", "AM516最终交货日期"):
with self.subTest(field=field):
payload = successful_payload()
summary = payload["data"]["交期汇总"]
summary["AM516预测交期"] = "2026-09-01"
summary["总交期"] = 15
del summary[field]
details = bridge.lead_time_details(payload, requested_quantity=10)
self.assertFalse(details["has_lead_time"])
self.assertIsNone(details["prediction_value"])
self.assertIn("未返回有效工作日交期和最终交货日期", details["prediction"])
def test_invalid_workdays_and_dates_are_not_displayed_as_valid(self) -> None:
invalid_values = {
"AM516工作日交期": [None, True, -1, 1.5, "8", float("nan"), float("inf")],
"AM516最终交货日期": [None, True, 20261008, "", "2026-02-30", "20261008"],
}
for field, values in invalid_values.items():
for value in values:
with self.subTest(field=field, value=value):
payload = successful_payload()
payload["data"]["交期汇总"][field] = value
details = bridge.lead_time_details(payload, requested_quantity=10)
self.assertFalse(details["has_lead_time"])
self.assertIsNone(details["prediction_value"])
def test_report_and_record_use_api_workdays_for_fastest_candidate(self) -> None:
request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
fastest_model = bridge.generate_candidate_specs(request.model)[0]["model"]
def query(query_request: bridge.RequestInput) -> dict[str, object]:
payload = successful_payload()
summary = payload["data"]["交期汇总"]
fastest = query_request.model == fastest_model
# Deliberately disagree with natural-day ranking to detect old conversion.
summary["AM516总交期"] = 31 if fastest else 1
summary["AM516工作日交期"] = 8 if fastest else 18
summary["AM516最终交货日期"] = "2026-10-08" if fastest else "2026-10-21"
return payload
with (
mock.patch.object(bridge, "run_controlled_query", side_effect=query),
mock.patch.object(bridge, "write_record") as write_record,
):
report = bridge.build_report(request)
record = bridge.render_record(*write_record.call_args.args)
self.assertIn(f"最快候选型号:{fastest_model}", report)
self.assertIn("最快候选交期:8个工作日,最终交货日期:2026-10-08", report)
for output in (report, record):
self.assertIn("8个工作日,最终交货日期:2026-10-08", output)
self.assertIn("18个工作日,最终交货日期:2026-10-21", output)
self.assertNotIn("自然日", output)
self.assertNotIn("折算", output)
def test_baseline_missing_new_fields_stops_before_recording(self) -> None:
payload = successful_payload()
del payload["data"]["交期汇总"]["AM516工作日交期"]
request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10)
with (
mock.patch.object(bridge, "run_controlled_query", return_value=payload),
mock.patch.object(bridge, "write_record") as write_record,
):
with self.assertRaisesRegex(RuntimeError, "工作日交期和最终交货日期"):
bridge.build_report(request)
write_record.assert_not_called()
if __name__ == "__main__":
unittest.main()