feat: show shortage names and explain unavailable delivery results
This commit is contained in:
@@ -26,6 +26,8 @@ Expected:
|
|||||||
- Feishu output uses rich-text `post`; the report title, overall-judgment heading, numbered candidate headings, and unavailable-candidate heading are bold;
|
- Feishu output uses rich-text `post`; the report title, overall-judgment heading, numbered candidate headings, and unavailable-candidate heading are bold;
|
||||||
- the judgment level and fixed disclaimer provide the global human-review boundary;
|
- the judgment level and fixed disclaimer provide the global human-review boundary;
|
||||||
- inventory output is limited to summary-level available quantity and coverage; non-reducer shortage output never exposes material codes or raw ERP details;
|
- inventory output is limited to summary-level available quantity and coverage; non-reducer shortage output never exposes material codes or raw ERP details;
|
||||||
|
- each usable model includes `缺料物料明细` listing only names from the API shortage records (`物料名称`, with `缺料物料名称` as the fallback), preserving order and deduplicating names; the same behavior applies to continuation replies;
|
||||||
|
- missing names are reported explicitly without substituting codes or inferring names; an empty shortage list says `API未返回缺料记录`, and missing shortage data or invalid BOM keeps the unavailable-data reminder;
|
||||||
- reducer shortages are the sole exception: when `缺料物料` starts with `20.30.`, output only the reducer code, inventory, inspection, in-transit, existing-demand quantities, and in-transit coverage conclusion in the Skill-defined order;
|
- reducer shortages are the sole exception: when `缺料物料` starts with `20.30.`, output only the reducer code, inventory, inspection, in-transit, existing-demand quantities, and in-transit coverage conclusion in the Skill-defined order;
|
||||||
- shortage items outside the `20.30.` reducer prefix never expose their material codes or quantity details;
|
- shortage items outside the `20.30.` reducer prefix never expose their material codes or quantity details;
|
||||||
- result is marked as internal candidate;
|
- result is marked as internal candidate;
|
||||||
@@ -38,7 +40,7 @@ Expected:
|
|||||||
- the appended record contains query metadata, model parsing, and a complete Markdown table with one row per model queried in the current batch;
|
- the appended record contains query metadata, model parsing, and a complete Markdown table with one row per model queried in the current batch;
|
||||||
- each future appended candidate row records the same query-completion time in `YYYY-MM-DD HH:mm:ss +0800` form (with the actual local UTC offset), without rewriting or backfilling historical records;
|
- each future appended candidate row records the same query-completion time in `YYYY-MM-DD HH:mm:ss +0800` form (with the actual local UTC offset), without rewriting or backfilling historical records;
|
||||||
- record-table columns follow this order: 序号、记录时间、型号、推荐类型、差异项、API状态、AM516预测交期、可用库存、主导因素;
|
- record-table columns follow this order: 序号、记录时间、型号、推荐类型、差异项、API状态、AM516预测交期、可用库存、主导因素;
|
||||||
- unavailable candidates remain in their original rule order and are recorded as `❌ 不存在` with unavailable result fields shown as `—`;
|
- unavailable candidates remain in their original rule order and are recorded as `❌ 不可用` with unavailable result fields shown as `—`; lack of a result does not itself establish that the model does not exist;
|
||||||
|
|
||||||
## Sample 1B | API Workdays And Final Delivery Date
|
## Sample 1B | API Workdays And Final Delivery Date
|
||||||
|
|
||||||
@@ -51,6 +53,9 @@ Rules and expected results:
|
|||||||
- a valid API date in another calendar year remains usable without a local holiday calendar;
|
- a valid API date in another calendar year remains usable without a local holiday calendar;
|
||||||
- accept zero workdays; reject negative, fractional, boolean, or non-numeric workday values and invalid dates;
|
- accept zero workdays; reject negative, fractional, boolean, or non-numeric workday values and invalid dates;
|
||||||
- if either new field is missing or invalid, report the lead-time result as unavailable instead of falling back to old fields or estimating;
|
- if either new field is missing or invalid, report the lead-time result as unavailable instead of falling back to old fields or estimating;
|
||||||
|
- a stopped original-model reply includes `原因:` identifying each missing, empty, or invalid field and a suggestion to check the API data; no record is appended;
|
||||||
|
- each unavailable candidate is listed with its own reason; HTTP 404 or missing fields must not be summarized as a confirmed nonexistent model;
|
||||||
|
- controlled-query failures show their safe category (configuration, authentication/permissions, HTTP, TLS, DNS/network, timeout, or JSON format), without displaying raw response bodies or secret-bearing error strings;
|
||||||
- choose the fastest candidate using the API workday count, even when the natural-day totals would rank candidates differently.
|
- choose the fastest candidate using the API workday count, even when the natural-day totals would rank candidates differently.
|
||||||
|
|
||||||
## Sample 1A | Continue With Other Candidates
|
## Sample 1A | Continue With Other Candidates
|
||||||
|
|||||||
@@ -118,6 +118,22 @@ Do not use temporary curl, WebFetch, or any API method outside the controlled sc
|
|||||||
- Missing or unsupported models count as queried positions and remain summarized
|
- Missing or unsupported models count as queried positions and remain summarized
|
||||||
under `不可用候选`; do not silently skip them to pull later candidates forward.
|
under `不可用候选`; do not silently skip them to pull later candidates forward.
|
||||||
|
|
||||||
|
## Unavailable Result Reasons
|
||||||
|
|
||||||
|
- When the original model has no usable result, stop before recording and include a separate `原因:` line. Identify missing or empty `data` / `交期汇总` objects, or each missing, empty, or invalid required workday/date field. If old lead-time fields were returned, explain that they cannot substitute for the required fields.
|
||||||
|
- Mention missing BOM only when the response explicitly marks it as absent. Field omissions alone do not establish that a model does not exist or that materials are short.
|
||||||
|
- Preserve a separate reason for every unavailable candidate in initial and continuation replies; do not merge them into `不存在或接口未返回交期汇总`.
|
||||||
|
- For controlled-query failures, explain the known category: missing API Key, HTTP status and its meaning, TLS validation, DNS/connection failure, timeout, or response-format failure. Do not copy raw error messages, response bodies, secrets, or ERP details into replies.
|
||||||
|
- If no deeper service-side cause can be confirmed, explicitly say it was not provided and needs runtime/service-log verification. Do not invent a business cause. Keep the fixed disclaimer once at the end.
|
||||||
|
|
||||||
|
## Shortage Material Names Rule
|
||||||
|
|
||||||
|
- For every usable model, add `缺料物料明细` using only names from `data.采购信息.缺料明细[]`. Read `物料名称`, or `缺料物料名称` when the former is absent or blank.
|
||||||
|
- Preserve the API order, omit duplicate names, and display names separated by `;`. Include both reducer and non-reducer shortage names.
|
||||||
|
- Do not substitute material codes, infer names from code prefixes, or list materials from the complete BOM. This added detail contains no codes, quantities, or specifications.
|
||||||
|
- If some shortage records lack a nonblank string name, retain the available names and state how many records did not return a name. If all names are missing, state `N项缺料记录未返回物料名称,需人工复核`.
|
||||||
|
- If the shortage list is empty, state `API未返回缺料记录`; missing shortage data or an invalid BOM retains the existing unavailable-data reminder. Do not infer that missing data means no shortage.
|
||||||
|
|
||||||
## Reducer Shortage Detail Rule
|
## Reducer Shortage Detail Rule
|
||||||
|
|
||||||
- Treat a shortage item as a reducer only when `data.采购信息.缺料明细[].缺料物料` starts with `20.30.`.
|
- Treat a shortage item as a reducer only when `data.采购信息.缺料明细[].缺料物料` starts with `20.30.`.
|
||||||
@@ -172,6 +188,7 @@ Use this field order for a successful Feishu report:
|
|||||||
- 主导因素:{main factor}
|
- 主导因素:{main factor}
|
||||||
- 推荐理由:{rule-based recommendation reason}
|
- 推荐理由:{rule-based recommendation reason}
|
||||||
- 差异项:{field changes formatted as `字段 原值→新值`; join multiple changes with `;`, for example `编码器 HS→HM;品牌 DZ→LF`}
|
- 差异项:{field changes formatted as `字段 原值→新值`; join multiple changes with `;`, for example `编码器 HS→HM;品牌 DZ→LF`}
|
||||||
|
- 缺料物料明细:{shortage material names only, separated by `;`; report missing names explicitly}
|
||||||
- 减速器缺料展开:{only for shortage material codes starting with `20.30.`; otherwise do not expand material details}
|
- 减速器缺料展开:{only for shortage material codes starting with `20.30.`; otherwise do not expand material details}
|
||||||
- 需客户确认的关键差异:{customer confirmation item}
|
- 需客户确认的关键差异:{customer confirmation item}
|
||||||
|
|
||||||
|
|||||||
@@ -458,6 +458,47 @@ def model_difference_text(source_model: str, candidate_model: str) -> str:
|
|||||||
return ";".join(differences) if differences else "无"
|
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]:
|
def run_controlled_query(request: RequestInput) -> dict[str, Any]:
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
[
|
[
|
||||||
@@ -477,18 +518,25 @@ def run_controlled_query(request: RequestInput) -> dict[str, Any]:
|
|||||||
output = completed.stdout.strip()
|
output = completed.stdout.strip()
|
||||||
json_start = output.find("{")
|
json_start = output.find("{")
|
||||||
if json_start < 0:
|
if json_start < 0:
|
||||||
raise RuntimeError("交期预测接口请求失败,未生成候选结果。")
|
reason = (
|
||||||
|
"受控查询脚本缺少运行依赖,请检查 Python 环境。"
|
||||||
|
if "ModuleNotFoundError" in completed.stderr
|
||||||
|
else "受控查询脚本未输出 JSON 结果,请检查运行环境和脚本日志。"
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"未生成候选结果。\n原因:{reason}")
|
||||||
try:
|
try:
|
||||||
payload = json.loads(output[json_start:])
|
payload = json.loads(output[json_start:])
|
||||||
except json.JSONDecodeError as exc:
|
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):
|
if completed.returncode != 0 or not payload.get("ok", True):
|
||||||
status_code = payload.get("status_code")
|
status_code = payload.get("status_code")
|
||||||
if status_code in {400, 404, 422}:
|
if isinstance(status_code, int) and status_code in {400, 404, 422}:
|
||||||
raise CandidateUnavailableError("不存在或接口未返回交期汇总")
|
raise CandidateUnavailableError(query_failure_reason(payload))
|
||||||
raise RuntimeError("交期预测接口请求失败,未生成候选结果。")
|
raise RuntimeError(f"未生成候选结果。\n原因:{query_failure_reason(payload)}")
|
||||||
if payload.get("success") is False:
|
if payload.get("success") is False:
|
||||||
raise CandidateUnavailableError("不存在或接口未返回交期汇总")
|
raise CandidateUnavailableError(query_failure_reason(payload))
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -501,10 +549,81 @@ def whole_day_count(value: Any) -> int | None:
|
|||||||
return result if result >= 0 else 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(
|
def lead_time_details(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
requested_quantity: int,
|
requested_quantity: int,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
unavailable_reason = lead_time_unavailable_reason(payload)
|
||||||
data = payload.get("data")
|
data = payload.get("data")
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return {
|
return {
|
||||||
@@ -520,6 +639,7 @@ def lead_time_details(
|
|||||||
"evidence_lines": ["- 缺料/BOM摘要:接口未返回,需人工复核。"],
|
"evidence_lines": ["- 缺料/BOM摘要:接口未返回,需人工复核。"],
|
||||||
"timestamp": None,
|
"timestamp": None,
|
||||||
"has_lead_time": False,
|
"has_lead_time": False,
|
||||||
|
"unavailable_reason": unavailable_reason,
|
||||||
}
|
}
|
||||||
summary = data.get("交期汇总")
|
summary = data.get("交期汇总")
|
||||||
if not isinstance(summary, dict):
|
if not isinstance(summary, dict):
|
||||||
@@ -536,6 +656,7 @@ def lead_time_details(
|
|||||||
"evidence_lines": ["- 缺料/BOM摘要:接口未返回,需人工复核。"],
|
"evidence_lines": ["- 缺料/BOM摘要:接口未返回,需人工复核。"],
|
||||||
"timestamp": data.get("时间戳") if isinstance(data.get("时间戳"), str) else None,
|
"timestamp": data.get("时间戳") if isinstance(data.get("时间戳"), str) else None,
|
||||||
"has_lead_time": False,
|
"has_lead_time": False,
|
||||||
|
"unavailable_reason": unavailable_reason,
|
||||||
}
|
}
|
||||||
|
|
||||||
main_factor = summary.get("AM516主导因素")
|
main_factor = summary.get("AM516主导因素")
|
||||||
@@ -548,19 +669,12 @@ def lead_time_details(
|
|||||||
if total_days is None:
|
if total_days is None:
|
||||||
total_days = whole_day_count(summary.get("总交期"))
|
total_days = whole_day_count(summary.get("总交期"))
|
||||||
workdays = whole_day_count(summary.get("AM516工作日交期"))
|
workdays = whole_day_count(summary.get("AM516工作日交期"))
|
||||||
delivery_date = summary.get("AM516最终交货日期")
|
delivery_date = valid_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
|
|
||||||
if workdays is not None and delivery_date is not None:
|
if workdays is not None and delivery_date is not None:
|
||||||
prediction_value = f"{workdays}个工作日,最终交货日期:{delivery_date}"
|
prediction_value = f"{workdays}个工作日,最终交货日期:{delivery_date}"
|
||||||
prediction = f"AM516预测交期:{prediction_value}"
|
prediction = f"AM516预测交期:{prediction_value}"
|
||||||
else:
|
else:
|
||||||
prediction = "接口未返回有效工作日交期和最终交货日期,需人工复核。"
|
prediction = f"接口未返回有效工作日交期和最终交货日期,需人工复核。原因:{unavailable_reason}"
|
||||||
prediction_value = None
|
prediction_value = None
|
||||||
|
|
||||||
inventory_info = data.get("库存信息")
|
inventory_info = data.get("库存信息")
|
||||||
@@ -602,7 +716,7 @@ def lead_time_details(
|
|||||||
and isinstance(item.get("缺料物料"), str)
|
and isinstance(item.get("缺料物料"), str)
|
||||||
and item["缺料物料"].startswith("20.30.")
|
and item["缺料物料"].startswith("20.30.")
|
||||||
]
|
]
|
||||||
evidence_lines = []
|
evidence_lines = [shortage_material_names_line(shortages)]
|
||||||
real_shortage_key = (
|
real_shortage_key = (
|
||||||
"真实缺料数量(每套需求×订单数量 + 存量需求数量 − 库存数量 − 待检数量 − 在途数量)"
|
"真实缺料数量(每套需求×订单数量 + 存量需求数量 − 库存数量 − 待检数量 − 在途数量)"
|
||||||
)
|
)
|
||||||
@@ -624,12 +738,8 @@ def lead_time_details(
|
|||||||
f"存量需求数量{item.get('存量需求数量', '未返回')};"
|
f"存量需求数量{item.get('存量需求数量', '未返回')};"
|
||||||
f"含在途覆盖:{coverage};"
|
f"含在途覆盖:{coverage};"
|
||||||
)
|
)
|
||||||
if not evidence_lines:
|
|
||||||
evidence_lines = [
|
|
||||||
f"- 缺料/BOM摘要:存在{len(shortages)}项缺料记录;无减速器缺料,不展开其他物料。"
|
|
||||||
]
|
|
||||||
elif isinstance(shortages, list):
|
elif isinstance(shortages, list):
|
||||||
evidence_lines = ["- 缺料/BOM摘要:API未返回缺料记录。"]
|
evidence_lines = ["- 缺料物料明细:API未返回缺料记录。"]
|
||||||
else:
|
else:
|
||||||
evidence_lines = ["- 缺料/BOM摘要:接口未返回可识别缺料汇总,需人工复核。"]
|
evidence_lines = ["- 缺料/BOM摘要:接口未返回可识别缺料汇总,需人工复核。"]
|
||||||
|
|
||||||
@@ -649,6 +759,7 @@ def lead_time_details(
|
|||||||
"evidence_lines": evidence_lines,
|
"evidence_lines": evidence_lines,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
"has_lead_time": prediction_value is not None,
|
"has_lead_time": prediction_value is not None,
|
||||||
|
"unavailable_reason": unavailable_reason,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -804,7 +915,7 @@ def render_record(
|
|||||||
candidate["model"],
|
candidate["model"],
|
||||||
candidate["type"],
|
candidate["type"],
|
||||||
candidate["difference"],
|
candidate["difference"],
|
||||||
"❌ 不存在",
|
"❌ 不可用",
|
||||||
"—",
|
"—",
|
||||||
"—",
|
"—",
|
||||||
"—",
|
"—",
|
||||||
@@ -848,13 +959,20 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
candidates: list[dict[str, Any]] = []
|
candidates: list[dict[str, Any]] = []
|
||||||
baseline: dict[str, Any] | None = None
|
baseline: dict[str, Any] | None = None
|
||||||
if is_initial_batch:
|
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 = lead_time_details(
|
||||||
baseline_payload,
|
baseline_payload,
|
||||||
request.quantity,
|
request.quantity,
|
||||||
)
|
)
|
||||||
if not baseline["has_lead_time"]:
|
if not baseline["has_lead_time"]:
|
||||||
raise RuntimeError("原型号接口未返回有效工作日交期和最终交货日期,未生成候选结果。")
|
raise RuntimeError(
|
||||||
|
"原型号接口未返回有效工作日交期和最终交货日期,未生成候选结果。\n"
|
||||||
|
f"原因:{baseline['unavailable_reason']}\n"
|
||||||
|
"处理建议:请接口维护人员核对上述字段及服务端计算结果后重试。"
|
||||||
|
)
|
||||||
candidates.append(
|
candidates.append(
|
||||||
{
|
{
|
||||||
"index": 1,
|
"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]:
|
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"], request.quantity))
|
||||||
except CandidateUnavailableError:
|
except CandidateUnavailableError as exc:
|
||||||
return spec, None
|
return {**spec, "unavailable_reason": str(exc)}, None
|
||||||
details = lead_time_details(
|
details = lead_time_details(
|
||||||
payload,
|
payload,
|
||||||
request.quantity,
|
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:
|
if candidate_specs:
|
||||||
with concurrent.futures.ThreadPoolExecutor(
|
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.extend(candidate_report_lines(candidate_item, request.quantity))
|
||||||
report_lines.append("不可用候选:")
|
report_lines.append("不可用候选:")
|
||||||
if unavailable:
|
if unavailable:
|
||||||
preview = " / ".join(item["model"] for item in unavailable[:5])
|
for item in unavailable:
|
||||||
suffix = f" 等{len(unavailable)}项" if len(unavailable) > 5 else ""
|
report_lines.append(f"- {item['model']}:原因:{item['unavailable_reason']}")
|
||||||
report_lines.append(
|
|
||||||
f"- {preview}{suffix}:不存在或接口未返回交期汇总。"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
report_lines.append("- 无(本次已查询候选均返回交期汇总)")
|
report_lines.append("- 无(本次已查询候选均返回交期汇总)")
|
||||||
if remaining_count:
|
if remaining_count:
|
||||||
@@ -1178,7 +1295,10 @@ def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -
|
|||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}"
|
report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}"
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
report = f"AM516 内部候选查询已停止:交期预测接口超时,未生成候选结果。\n\n{DISCLAIMER}"
|
report = (
|
||||||
|
"AM516 内部候选查询已停止,未生成候选结果。\n"
|
||||||
|
f"原因:受控交期查询超过45秒未完成,请稍后重试。\n\n{DISCLAIMER}"
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Unexpected AM516 bridge error")
|
logging.exception("Unexpected AM516 bridge error")
|
||||||
report = f"AM516 内部候选查询已停止:运行异常,未生成候选结果。\n\n{DISCLAIMER}"
|
report = f"AM516 内部候选查询已停止:运行异常,未生成候选结果。\n\n{DISCLAIMER}"
|
||||||
|
|||||||
@@ -463,6 +463,191 @@ class CandidateBatchTests(unittest.TestCase):
|
|||||||
self.assertNotIn(model, report)
|
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):
|
class ApiWorkdayOutputTests(unittest.TestCase):
|
||||||
def test_api_workdays_and_date_override_legacy_fields(self) -> None:
|
def test_api_workdays_and_date_override_legacy_fields(self) -> None:
|
||||||
payload = successful_payload()
|
payload = successful_payload()
|
||||||
|
|||||||
Reference in New Issue
Block a user