feat: show shortage names and explain unavailable delivery results
This commit is contained in:
@@ -463,6 +463,191 @@ class CandidateBatchTests(unittest.TestCase):
|
||||
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 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()
|
||||
|
||||
Reference in New Issue
Block a user