feat: show shortage names and explain unavailable delivery results
This commit is contained in:
@@ -458,6 +458,47 @@ def model_difference_text(source_model: str, candidate_model: str) -> str:
|
||||
return ";".join(differences) if differences else "无"
|
||||
|
||||
|
||||
def query_failure_reason(payload: dict[str, Any]) -> str:
|
||||
"""Translate known failure categories without echoing raw API bodies or secrets."""
|
||||
error = payload.get("error")
|
||||
error = error.lower() if isinstance(error, str) else ""
|
||||
if error.startswith("missing api key:"):
|
||||
return "运行环境未配置交期预测 API Key,请由运行维护人员完成配置后重试。"
|
||||
if error == "tls certificate verification failed":
|
||||
return "TLS 证书验证失败,请检查运行环境的证书信任配置。"
|
||||
if error == "tls verification bypass is not permitted for this target host":
|
||||
return "请求目标不满足已批准的域名和 TLS 校验要求,请检查接口配置。"
|
||||
status = payload.get("status_code")
|
||||
http_reasons = {
|
||||
400: "接口拒绝请求参数",
|
||||
401: "接口身份认证失败,请检查 API Key 是否有效",
|
||||
403: "接口拒绝访问,请检查账号或 API Key 权限",
|
||||
404: "请求的接口或资源未找到;接口未说明是否为型号不存在",
|
||||
422: "请求参数未通过接口校验",
|
||||
429: "接口请求频率受限,请稍后重试",
|
||||
}
|
||||
if isinstance(status, int) and not isinstance(status, bool):
|
||||
if status in http_reasons:
|
||||
return f"HTTP {status}:{http_reasons[status]}。"
|
||||
if 500 <= status <= 599:
|
||||
return f"HTTP {status}:交期预测服务异常,请稍后重试。"
|
||||
return f"接口返回 HTTP {status},未提供可安全展示的具体原因。"
|
||||
if error.startswith("request failed:"):
|
||||
if "timed out" in error or "timeout" in error:
|
||||
return "交期预测接口请求超时,请稍后重试。"
|
||||
if any(
|
||||
token in error
|
||||
for token in ("name or service not known", "nodename nor servname", "name resolution", "getaddrinfo")
|
||||
):
|
||||
return "接口域名解析失败,请检查运行环境的网络和 DNS 配置。"
|
||||
return "网络连接失败,请检查运行环境的网络、代理和接口可达性。"
|
||||
if error.startswith("response is not valid json:"):
|
||||
return "接口响应不是有效 JSON,无法读取交期结果。"
|
||||
if payload.get("success") is False:
|
||||
return "接口标记本次查询失败(success=false),未提供可安全展示的具体原因,需核对服务端日志。"
|
||||
return "受控查询脚本执行失败,未提供可安全展示的具体原因,需核对运行日志。"
|
||||
|
||||
|
||||
def run_controlled_query(request: RequestInput) -> dict[str, Any]:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
@@ -477,18 +518,25 @@ def run_controlled_query(request: RequestInput) -> dict[str, Any]:
|
||||
output = completed.stdout.strip()
|
||||
json_start = output.find("{")
|
||||
if json_start < 0:
|
||||
raise RuntimeError("交期预测接口请求失败,未生成候选结果。")
|
||||
reason = (
|
||||
"受控查询脚本缺少运行依赖,请检查 Python 环境。"
|
||||
if "ModuleNotFoundError" in completed.stderr
|
||||
else "受控查询脚本未输出 JSON 结果,请检查运行环境和脚本日志。"
|
||||
)
|
||||
raise RuntimeError(f"未生成候选结果。\n原因:{reason}")
|
||||
try:
|
||||
payload = json.loads(output[json_start:])
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("交期预测接口返回格式异常,未生成候选结果。") from exc
|
||||
raise RuntimeError("未生成候选结果。\n原因:受控查询脚本输出的 JSON 格式异常。") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("未生成候选结果。\n原因:接口响应的顶层数据不是对象。")
|
||||
if completed.returncode != 0 or not payload.get("ok", True):
|
||||
status_code = payload.get("status_code")
|
||||
if status_code in {400, 404, 422}:
|
||||
raise CandidateUnavailableError("不存在或接口未返回交期汇总")
|
||||
raise RuntimeError("交期预测接口请求失败,未生成候选结果。")
|
||||
if isinstance(status_code, int) and status_code in {400, 404, 422}:
|
||||
raise CandidateUnavailableError(query_failure_reason(payload))
|
||||
raise RuntimeError(f"未生成候选结果。\n原因:{query_failure_reason(payload)}")
|
||||
if payload.get("success") is False:
|
||||
raise CandidateUnavailableError("不存在或接口未返回交期汇总")
|
||||
raise CandidateUnavailableError(query_failure_reason(payload))
|
||||
return payload
|
||||
|
||||
|
||||
@@ -501,10 +549,81 @@ def whole_day_count(value: Any) -> int | None:
|
||||
return result if result >= 0 else None
|
||||
|
||||
|
||||
def valid_delivery_date(value: Any) -> str | None:
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
|
||||
return None
|
||||
try:
|
||||
date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def lead_time_unavailable_reason(payload: dict[str, Any]) -> str | None:
|
||||
if "data" not in payload:
|
||||
return "接口响应缺少 data 字段,无法读取交期汇总。"
|
||||
data = payload.get("data")
|
||||
if data is None:
|
||||
return "接口响应的 data 为空,无法读取交期汇总。"
|
||||
if not isinstance(data, dict):
|
||||
return "接口响应的 data 格式错误(应为数据对象),无法读取交期汇总。"
|
||||
if "交期汇总" not in data:
|
||||
return "接口数据缺少“交期汇总”字段。"
|
||||
summary = data.get("交期汇总")
|
||||
if summary is None:
|
||||
return "接口返回的“交期汇总”为空。"
|
||||
if not isinstance(summary, dict):
|
||||
return "接口返回的“交期汇总”格式错误(应为数据对象)。"
|
||||
reasons: list[str] = []
|
||||
for field, validator, requirement in (
|
||||
("AM516工作日交期", whole_day_count, "应为非负整数工作日"),
|
||||
("AM516最终交货日期", valid_delivery_date, "应为有效的 YYYY-MM-DD 日期"),
|
||||
):
|
||||
if field not in summary:
|
||||
reasons.append(f"缺少“{field}”字段")
|
||||
elif summary[field] is None or summary[field] == "":
|
||||
reasons.append(f"“{field}”返回空值")
|
||||
elif validator(summary[field]) is None:
|
||||
reasons.append(f"“{field}”值无效({requirement})")
|
||||
if not reasons:
|
||||
return None
|
||||
if any(field in summary for field in ("AM516预测交期", "AM516总交期", "总交期")):
|
||||
reasons.append("已返回旧交期或自然日字段,无法替代上述必需字段")
|
||||
bom = data.get("BOM信息")
|
||||
if isinstance(bom, dict) and bom.get("是否有BOM(False则需提醒无BOM)") is False:
|
||||
reasons.append("接口同时标记无有效 BOM,需核对 BOM 数据")
|
||||
return ";".join(reasons) + "。"
|
||||
|
||||
|
||||
def shortage_material_names_line(shortages: list[Any]) -> str:
|
||||
"""Show only names from shortage records, never codes or other raw fields."""
|
||||
names: list[str] = []
|
||||
missing_names = 0
|
||||
for item in shortages:
|
||||
name = None
|
||||
if isinstance(item, dict):
|
||||
for key in ("物料名称", "缺料物料名称"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
name = " ".join(value.split())
|
||||
break
|
||||
if name is None:
|
||||
missing_names += 1
|
||||
elif name not in names:
|
||||
names.append(name)
|
||||
|
||||
text = ";".join(names)
|
||||
if missing_names:
|
||||
missing_text = f"{missing_names}项缺料记录未返回物料名称,需人工复核"
|
||||
text = f"{text}(另有{missing_text})" if text else missing_text
|
||||
return f"- 缺料物料明细:{text}"
|
||||
|
||||
|
||||
def lead_time_details(
|
||||
payload: dict[str, Any],
|
||||
requested_quantity: int,
|
||||
) -> dict[str, Any]:
|
||||
unavailable_reason = lead_time_unavailable_reason(payload)
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
@@ -520,6 +639,7 @@ def lead_time_details(
|
||||
"evidence_lines": ["- 缺料/BOM摘要:接口未返回,需人工复核。"],
|
||||
"timestamp": None,
|
||||
"has_lead_time": False,
|
||||
"unavailable_reason": unavailable_reason,
|
||||
}
|
||||
summary = data.get("交期汇总")
|
||||
if not isinstance(summary, dict):
|
||||
@@ -536,6 +656,7 @@ def lead_time_details(
|
||||
"evidence_lines": ["- 缺料/BOM摘要:接口未返回,需人工复核。"],
|
||||
"timestamp": data.get("时间戳") if isinstance(data.get("时间戳"), str) else None,
|
||||
"has_lead_time": False,
|
||||
"unavailable_reason": unavailable_reason,
|
||||
}
|
||||
|
||||
main_factor = summary.get("AM516主导因素")
|
||||
@@ -548,19 +669,12 @@ def lead_time_details(
|
||||
if total_days is None:
|
||||
total_days = whole_day_count(summary.get("总交期"))
|
||||
workdays = whole_day_count(summary.get("AM516工作日交期"))
|
||||
delivery_date = summary.get("AM516最终交货日期")
|
||||
if isinstance(delivery_date, str) and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", delivery_date):
|
||||
try:
|
||||
date.fromisoformat(delivery_date)
|
||||
except ValueError:
|
||||
delivery_date = None
|
||||
else:
|
||||
delivery_date = None
|
||||
delivery_date = valid_delivery_date(summary.get("AM516最终交货日期"))
|
||||
if workdays is not None and delivery_date is not None:
|
||||
prediction_value = f"{workdays}个工作日,最终交货日期:{delivery_date}"
|
||||
prediction = f"AM516预测交期:{prediction_value}"
|
||||
else:
|
||||
prediction = "接口未返回有效工作日交期和最终交货日期,需人工复核。"
|
||||
prediction = f"接口未返回有效工作日交期和最终交货日期,需人工复核。原因:{unavailable_reason}"
|
||||
prediction_value = None
|
||||
|
||||
inventory_info = data.get("库存信息")
|
||||
@@ -602,7 +716,7 @@ def lead_time_details(
|
||||
and isinstance(item.get("缺料物料"), str)
|
||||
and item["缺料物料"].startswith("20.30.")
|
||||
]
|
||||
evidence_lines = []
|
||||
evidence_lines = [shortage_material_names_line(shortages)]
|
||||
real_shortage_key = (
|
||||
"真实缺料数量(每套需求×订单数量 + 存量需求数量 − 库存数量 − 待检数量 − 在途数量)"
|
||||
)
|
||||
@@ -624,12 +738,8 @@ def lead_time_details(
|
||||
f"存量需求数量{item.get('存量需求数量', '未返回')};"
|
||||
f"含在途覆盖:{coverage};"
|
||||
)
|
||||
if not evidence_lines:
|
||||
evidence_lines = [
|
||||
f"- 缺料/BOM摘要:存在{len(shortages)}项缺料记录;无减速器缺料,不展开其他物料。"
|
||||
]
|
||||
elif isinstance(shortages, list):
|
||||
evidence_lines = ["- 缺料/BOM摘要:API未返回缺料记录。"]
|
||||
evidence_lines = ["- 缺料物料明细:API未返回缺料记录。"]
|
||||
else:
|
||||
evidence_lines = ["- 缺料/BOM摘要:接口未返回可识别缺料汇总,需人工复核。"]
|
||||
|
||||
@@ -649,6 +759,7 @@ def lead_time_details(
|
||||
"evidence_lines": evidence_lines,
|
||||
"timestamp": timestamp,
|
||||
"has_lead_time": prediction_value is not None,
|
||||
"unavailable_reason": unavailable_reason,
|
||||
}
|
||||
|
||||
|
||||
@@ -804,7 +915,7 @@ def render_record(
|
||||
candidate["model"],
|
||||
candidate["type"],
|
||||
candidate["difference"],
|
||||
"❌ 不存在",
|
||||
"❌ 不可用",
|
||||
"—",
|
||||
"—",
|
||||
"—",
|
||||
@@ -848,13 +959,20 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
||||
candidates: list[dict[str, Any]] = []
|
||||
baseline: dict[str, Any] | None = None
|
||||
if is_initial_batch:
|
||||
baseline_payload = run_controlled_query(request)
|
||||
try:
|
||||
baseline_payload = run_controlled_query(request)
|
||||
except CandidateUnavailableError as exc:
|
||||
raise RuntimeError(f"原型号未生成候选结果。\n原因:{exc}") from exc
|
||||
baseline = lead_time_details(
|
||||
baseline_payload,
|
||||
request.quantity,
|
||||
)
|
||||
if not baseline["has_lead_time"]:
|
||||
raise RuntimeError("原型号接口未返回有效工作日交期和最终交货日期,未生成候选结果。")
|
||||
raise RuntimeError(
|
||||
"原型号接口未返回有效工作日交期和最终交货日期,未生成候选结果。\n"
|
||||
f"原因:{baseline['unavailable_reason']}\n"
|
||||
"处理建议:请接口维护人员核对上述字段及服务端计算结果后重试。"
|
||||
)
|
||||
candidates.append(
|
||||
{
|
||||
"index": 1,
|
||||
@@ -873,13 +991,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"], request.quantity))
|
||||
except CandidateUnavailableError:
|
||||
return spec, None
|
||||
except CandidateUnavailableError as exc:
|
||||
return {**spec, "unavailable_reason": str(exc)}, None
|
||||
details = lead_time_details(
|
||||
payload,
|
||||
request.quantity,
|
||||
)
|
||||
return spec, details if details["has_lead_time"] else None
|
||||
if not details["has_lead_time"]:
|
||||
return {**spec, "unavailable_reason": details["unavailable_reason"]}, None
|
||||
return spec, details
|
||||
|
||||
if candidate_specs:
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
@@ -959,11 +1079,8 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
||||
report_lines.extend(candidate_report_lines(candidate_item, request.quantity))
|
||||
report_lines.append("不可用候选:")
|
||||
if unavailable:
|
||||
preview = " / ".join(item["model"] for item in unavailable[:5])
|
||||
suffix = f" 等{len(unavailable)}项" if len(unavailable) > 5 else ""
|
||||
report_lines.append(
|
||||
f"- {preview}{suffix}:不存在或接口未返回交期汇总。"
|
||||
)
|
||||
for item in unavailable:
|
||||
report_lines.append(f"- {item['model']}:原因:{item['unavailable_reason']}")
|
||||
else:
|
||||
report_lines.append("- 无(本次已查询候选均返回交期汇总)")
|
||||
if remaining_count:
|
||||
@@ -1178,7 +1295,10 @@ def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -
|
||||
except RuntimeError as exc:
|
||||
report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}"
|
||||
except subprocess.TimeoutExpired:
|
||||
report = f"AM516 内部候选查询已停止:交期预测接口超时,未生成候选结果。\n\n{DISCLAIMER}"
|
||||
report = (
|
||||
"AM516 内部候选查询已停止,未生成候选结果。\n"
|
||||
f"原因:受控交期查询超过45秒未完成,请稍后重试。\n\n{DISCLAIMER}"
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Unexpected AM516 bridge error")
|
||||
report = f"AM516 内部候选查询已停止:运行异常,未生成候选结果。\n\n{DISCLAIMER}"
|
||||
|
||||
Reference in New Issue
Block a user