from __future__ import annotations import concurrent.futures import itertools import json import logging import os import re import subprocess import sys import threading import time import unicodedata from dataclasses import dataclass from datetime import date, datetime from pathlib import Path from typing import Any import truststore truststore.inject_into_ssl() import lark_oapi as lark if __package__: from .material_name_cache import MaterialNameCacheError, load_material_names else: from material_name_cache import MaterialNameCacheError, load_material_names PROJECT_ROOT = Path(__file__).resolve().parents[2] QUERY_SCRIPT = ( PROJECT_ROOT / "capabilities" / "am516-delivery-prediction" / "scripts" / "delivery_prediction_query_template.py" ) RECORD_FILE = ( PROJECT_ROOT / "capabilities" / "am516-delivery-prediction" / "records" / "delivery_prediction_records.md" ) RULE_SOURCE_DISPLAY = "capabilities/am516-delivery-prediction/rules/eRob关节模组相似型号推荐规则_v3.1.md" API_SOURCE_DISPLAY = "/api/SaleAgent/DeliveryPrediction/WithTransit" DISCLAIMER = ( "以上为内部候选方案,不构成客户正式交期承诺;需 AR516 或授权责任人结合库存、生产、采购、" "客户优先级和最新系统数据人工复核后使用。" ) MODEL_SEARCH_PATTERN = re.compile( r"eRob(?:70|80|90|110|142|170)[FH](?:50|80|100|120|160)[IT]" r"-[BF](?:HS|HM|S|M)-18[CE][NT]C?[\[(]V[3-6][\])]" ) MODEL_PATTERN = re.compile( r"^eRob(?P70|80|90|110|142|170)(?P[FH])" r"(?P50|80|100|120|160)(?P[IT])-(?P[BF])" r"(?PHS|HM|S|M)-18(?P[CE])(?P[NT])" r"(?PC?)(?P[\[(])(?PV[3-6])[\])]$" ) QUANTITY_LABEL_PATTERN = re.compile( r"(?:需求数量|订单数量|采购数量|查询数量|数量|需求量|qty|quantity)" r"\s*(?:为|是|[::=])?\s*(?P[1-9]\d*)\s*(?:台|套|件)?", re.IGNORECASE, ) QUANTITY_UNIT_PATTERN = re.compile( r"(?[1-9]\d*)\s*(?:台|套|件)" ) STANDALONE_QUANTITY_PATTERN = re.compile( r"(?[1-9]\d*)(?![A-Za-z0-9])" ) ALLOWED_VERSIONS = { "70F": {"V3", "V4"}, "70H": {"V4", "V5"}, "80F": {"V4"}, "80H": {"V5", "V6"}, "90H": {"V3", "V6"}, "110H": {"V4", "V6"}, "142F": {"V4"}, "142H": {"V4"}, "170F": {"V3"}, "170H": {"V3"}, } CANDIDATE_BATCH_SIZE = 4 DEFECT_RATE_PERCENT = 5 PRODUCTION_BATCH_SIZE = 5 CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60 RECEIVED_REACTION_TYPE = "OnIt" CANDIDATE_CONTINUATION_SHORT_PHRASES = { "还有吗", "还有呢", "还有没有", "有没有", "有吗", "其他呢", "其他的呢", "别的呢", "剩下的呢", "余下的呢", "后面的呢", "继续", "继续吧", "继续看", "继续看看", "继续推荐", "接着来", "接着看", "再来几个", "再给几个", "再给我几个", "再推荐几个", "再查几个", "再看几个", "再看看", "多来几个", "多推荐几个", "推荐更多", "更多", "下一批", "下一组", "换一批", "再来一批", "下一个", "往下看", } CANDIDATE_CONTINUATION_PATTERNS = ( re.compile( r"(?:还有没有|还有|是否还有|有没有|有无)" r"(?:其他|别的|更多|剩余|余下|后续|类似)?(?:的)?" r"(?:候选型号|候选|型号|方案|选择|选项|可选项|替代型号|替代方案|推荐)" ), re.compile( r"(?:继续|接着|再|多)(?:推荐|查询|查看|展示|看看|看|给|来).{0,6}" r"(?:其他|别的|更多|剩余|余下|后续)?(?:的)?" r"(?:候选型号|候选|型号|方案|选择|选项|可选项|替代型号|替代方案)" ), re.compile( r"(?:候选型号|候选|型号|方案|选择|选项|可选项|替代型号|替代方案)" r".{0,6}(?:还有|有吗|更多|其他|别的|剩余|余下|继续|再来)" ), re.compile( r"(?:还有|有没有)(?:别的|其他|更多|新的|剩余|余下|类似)" r"(?:的)?(?:吗|呢|有吗)?$" ), re.compile(r"(?:还有|有没有)(?:哪些|什么)(?:其他)?(?:候选|型号|方案|选择|选项|推荐)?$"), re.compile( r"(?:可以|请|麻烦|帮我)?(?:再|多)(?:推荐|查询|查看|看|给|来)" r"(?:我)?(?:几个|一些)(?:候选|型号|方案|选择|选项)?(?:吗|吧)?$" ), re.compile(r"(?:下一批|下一组)(?:候选|型号|方案|选择|选项)?$"), re.compile(r"(?:换|再来|再给我)(?:一批|一组|几个|一些)(?:候选|型号|方案|选择|选项)?(?:吗|吧)?$"), re.compile(r"(?:more|other|remaining|next)(?:candidates?|models?|options?|alternatives?|batch)"), re.compile( r"(?:continue|showme|giveme|recommend)(?:more|other|remaining)?" r"(?:candidates?|models?|options?|alternatives?)" ), ) @dataclass(frozen=True) class RequestInput: model: str quantity: int @dataclass(frozen=True) class CandidateContinuation: request: RequestInput next_offset: int total_candidates: int expires_at: float class InputError(ValueError): pass class CandidateUnavailableError(RuntimeError): pass def production_quantity_for_demand(quantity: int, available_inventory: int = 0) -> int: """Deduct usable finished stock before yield allowance and batch rounding.""" if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0: raise InputError("需求数量必须为正整数。") if isinstance(available_inventory, bool) or not isinstance(available_inventory, int): raise InputError("可用库存必须为整数。") numerator = max(quantity - max(available_inventory, 0), 0) * 100 denominator = (100 - DEFECT_RATE_PERCENT) * PRODUCTION_BATCH_SIZE return ((numerator + denominator - 1) // denominator) * PRODUCTION_BATCH_SIZE _candidate_continuations: dict[str, CandidateContinuation] = {} _candidate_continuation_lock = threading.Lock() def load_dotenv() -> None: """Load simple KEY=VALUE pairs from the ignored local .env file.""" env_file = PROJECT_ROOT / ".env" if not env_file.exists(): return for line in env_file.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() if key and key not in os.environ: os.environ[key] = value.strip().strip('"').strip("'") def extract_quantity(text_without_models: str) -> int: strong_candidates = { int(match.group("quantity")) for pattern in (QUANTITY_LABEL_PATTERN, QUANTITY_UNIT_PATTERN) for match in pattern.finditer(text_without_models) } if len(strong_candidates) == 1: return strong_candidates.pop() if len(strong_candidates) > 1: raise InputError("识别到多个可能的数量,请明确一个正整数数量后重试。") fallback_candidates = { int(match.group("quantity")) for match in STANDALONE_QUANTITY_PATTERN.finditer(text_without_models) } if len(fallback_candidates) == 1: return fallback_candidates.pop() if len(fallback_candidates) > 1: raise InputError("识别到多个可能的数量,请使用“数量20台”等明确表达后重试。") raise InputError("已识别型号,但未识别到正整数数量,请补充“数量20台”等信息后重试。") def parse_request(text: str) -> RequestInput: normalized_text = unicodedata.normalize("NFKC", text) model_matches = [match.group(0) for match in MODEL_SEARCH_PATTERN.finditer(normalized_text)] unique_models = list(dict.fromkeys(model_matches)) if not unique_models: raise InputError("未识别到完整 eRob 型号,请核对型号后重试。") if len(unique_models) > 1: raise InputError("识别到多个 eRob 型号,请一次只查询一个型号。") model = unique_models[0] model_match = MODEL_PATTERN.fullmatch(model) if not model_match: raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。") fields = model_match.groupdict() series = f"{fields['diameter']}{fields['structure']}" if fields["version"] not in ALLOWED_VERSIONS.get(series, set()): raise InputError("型号版本与外径/结构组合不在当前规则包中,请 AR51 或 AR516 人工确认。") text_without_models = MODEL_SEARCH_PATTERN.sub(" ", normalized_text) quantity = extract_quantity(text_without_models) return RequestInput(model=model, quantity=quantity) def parse_trigger_request(text: str) -> RequestInput | None: """Return a query only when one complete model and one quantity are present.""" try: return parse_request(text) except InputError: return None def is_candidate_continuation_request(text: str) -> bool: normalized_text = unicodedata.normalize("NFKC", text).lower() compact_text = re.sub(r"[\s,。!?、;:,.!?;:]+", "", normalized_text) compact_text = re.sub(r"^(?:@?_user_\d+)+", "", compact_text) if compact_text in CANDIDATE_CONTINUATION_SHORT_PHRASES: return True return any(pattern.search(compact_text) for pattern in CANDIDATE_CONTINUATION_PATTERNS) def conversation_key(data: lark.im.v1.P2ImMessageReceiveV1) -> str | None: event = getattr(data, "event", None) message = getattr(event, "message", None) sender = getattr(event, "sender", None) sender_id = getattr(sender, "sender_id", None) chat_id = getattr(message, "chat_id", None) open_id = getattr(sender_id, "open_id", None) if not isinstance(chat_id, str) or not chat_id: return None if not isinstance(open_id, str) or not open_id: return None return f"{chat_id}:{open_id}" def clear_candidate_continuation(key: str | None) -> None: if not key: return with _candidate_continuation_lock: _candidate_continuations.pop(key, None) def remember_candidate_continuation( key: str | None, request: RequestInput, next_offset: int, total_candidates: int, ) -> None: if not key: return with _candidate_continuation_lock: if next_offset >= total_candidates: _candidate_continuations.pop(key, None) return _candidate_continuations[key] = CandidateContinuation( request=request, next_offset=next_offset, total_candidates=total_candidates, expires_at=time.monotonic() + CANDIDATE_CONTINUATION_TTL_SECONDS, ) def get_candidate_continuation(key: str | None) -> CandidateContinuation | None: if not key: return None with _candidate_continuation_lock: state = _candidate_continuations.get(key) if state is None: return None if state.expires_at <= time.monotonic(): _candidate_continuations.pop(key, None) return None return state def build_model(fields: dict[str, str]) -> str: closing_bracket = "]" if fields["bracket"] == "[" else ")" return ( f"eRob{fields['diameter']}{fields['structure']}{fields['ratio']}{fields['mount']}" f"-{fields['brake']}{fields['encoder']}-18{fields['communication']}" f"{fields['sensor']}{fields['grease']}{fields['bracket']}" f"{fields['version']}{closing_bracket}" ) def encoder_upgrade_options(encoder: str) -> list[str]: options = { "S": ["S", "M", "HS", "HM"], "M": ["M", "HS", "HM"], "HS": ["HS", "HM"], "HM": ["HM"], } return options[encoder] def candidate_type(source_model: str, candidate_model: str) -> str: source = MODEL_PATTERN.fullmatch(source_model) candidate = MODEL_PATTERN.fullmatch(candidate_model) if not source or not candidate: raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。") source_fields = source.groupdict() candidate_fields = candidate.groupdict() upgrades: list[str] = [] if source_fields["brake"] != candidate_fields["brake"]: upgrades.append("制动") if source_fields["encoder"] != candidate_fields["encoder"]: upgrades.append(f"{candidate_fields['encoder']}编码器") if source_fields["sensor"] != candidate_fields["sensor"]: upgrades.append("传感器") if source_fields["grease"] != candidate_fields["grease"]: upgrades.append("低温油脂") base_type = "+".join(upgrades) + "升级候选" if upgrades else "品牌替代候选" brand = "DZ" if candidate_fields["bracket"] == "[" else "LF" return f"{base_type},{brand}" def candidate_reason(source_model: str, candidate_model: str) -> str: difference = model_difference_text(source_model, candidate_model) return f"强约束保持一致;{difference}。" def candidate_confirmation(source_model: str, candidate_model: str) -> str: source = MODEL_PATTERN.fullmatch(source_model) candidate = MODEL_PATTERN.fullmatch(candidate_model) if not source or not candidate: raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。") source_fields = source.groupdict() candidate_fields = candidate.groupdict() items: list[str] = [] if source_fields["brake"] != candidate_fields["brake"]: items.append("是否接受带制动配置") if source_fields["encoder"] != candidate_fields["encoder"]: items.append(f"是否接受{candidate_fields['encoder']}编码器") if source_fields["sensor"] != candidate_fields["sensor"]: items.append("是否接受力矩传感器") if source_fields["grease"] != candidate_fields["grease"]: items.append("是否接受低温油脂") if source_fields["bracket"] != candidate_fields["bracket"]: items.append("客户、项目或认证是否有减速器品牌限制") items.extend(["价格差异", "正式承诺边界"]) return ";".join(items) + "。" def generate_candidate_specs(model: str) -> list[dict[str, str]]: match = MODEL_PATTERN.fullmatch(model) if not match: raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。") original = match.groupdict() brake_options = [original["brake"]] + (["B"] if original["brake"] == "F" else []) encoder_options = encoder_upgrade_options(original["encoder"]) sensor_options = [original["sensor"]] + (["T"] if original["sensor"] == "N" else []) grease_options = [original["grease"]] + (["C"] if original["grease"] == "" else []) bracket_options = [original["bracket"], "(" if original["bracket"] == "[" else "["] candidates: list[dict[str, str]] = [] seen = {model} for brake, encoder, sensor, grease, bracket in itertools.product( brake_options, encoder_options, sensor_options, grease_options, bracket_options, ): fields = dict(original) fields.update( brake=brake, encoder=encoder, sensor=sensor, grease=grease, bracket=bracket, ) candidate_model = build_model(fields) if candidate_model in seen: continue seen.add(candidate_model) candidates.append( { "model": candidate_model, "type": candidate_type(model, candidate_model), "reason": candidate_reason(model, candidate_model), "difference": model_difference_text(model, candidate_model), "confirmation": candidate_confirmation(model, candidate_model), } ) return candidates def model_difference_text(source_model: str, candidate_model: str) -> str: source = MODEL_PATTERN.fullmatch(source_model) candidate = MODEL_PATTERN.fullmatch(candidate_model) if not source or not candidate: raise InputError("型号无法按 eRob 规则解析,请核对型号后重试。") source_fields = source.groupdict() candidate_fields = candidate.groupdict() field_specs = ( ("diameter", "外径", None), ("structure", "结构", None), ("ratio", "减速比", None), ("mount", "安装", None), ("brake", "制动", None), ("encoder", "编码器", None), ("communication", "通讯", None), ("sensor", "传感器", None), ("grease", "油脂", {"": "标准", "C": "低温"}), ("version", "版本", None), ("bracket", "品牌", {"[": "DZ", "(": "LF"}), ) differences: list[str] = [] for field, label, value_labels in field_specs: source_value = source_fields[field] candidate_value = candidate_fields[field] if source_value == candidate_value: continue if value_labels: source_value = value_labels.get(source_value, source_value) candidate_value = value_labels.get(candidate_value, candidate_value) differences.append(f"{label} {source_value}→{candidate_value}") 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( [ sys.executable, str(QUERY_SCRIPT), "--product", request.model, "--quantity", str(request.quantity), ], cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=45, check=False, ) output = completed.stdout.strip() json_start = output.find("{") if json_start < 0: 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("未生成候选结果。\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 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(query_failure_reason(payload)) return payload def whole_day_count(value: Any) -> int | None: if isinstance(value, bool) or not isinstance(value, (int, float)): return None if isinstance(value, float) and not value.is_integer(): return None result = int(value) 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 cached_names: dict[str, str] | None = None cache_error: str | None = None 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: code = item.get("缺料物料") if isinstance(code, str) and code.strip(): if cached_names is None and cache_error is None: try: cached_names = load_material_names() except MaterialNameCacheError as exc: cache_error = str(exc) if cached_names is not None: name = cached_names.get(code.strip()) 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}项缺料记录未找到物料名称" missing_text += ( f"({cache_error}),需补齐缓存后复核" if cache_error is not None else ",请核对物料编码并更新本地名称缓存后复核" ) 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 { "prediction": "接口未返回可用交期汇总,需人工复核。", "prediction_value": None, "total_days": None, "workdays": None, "delivery_date": None, "main_factor": "接口未返回,需人工复核", "inventory": "接口未返回可用库存汇总,需人工复核。", "available_inventory": None, "full_coverage": None, "evidence_lines": ["- 缺料/BOM摘要:接口未返回,需人工复核。"], "timestamp": None, "has_lead_time": False, "unavailable_reason": unavailable_reason, } summary = data.get("交期汇总") if not isinstance(summary, dict): return { "prediction": "接口未返回可用交期汇总,需人工复核。", "prediction_value": None, "total_days": None, "workdays": None, "delivery_date": None, "main_factor": "接口未返回,需人工复核", "inventory": "接口未返回可用库存汇总,需人工复核。", "available_inventory": None, "full_coverage": None, "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主导因素") main_factor_text = ( str(main_factor) if isinstance(main_factor, (str, int, float)) else "接口未返回,需人工复核" ) total_days = whole_day_count(summary.get("AM516总交期")) if total_days is None: total_days = whole_day_count(summary.get("总交期")) workdays = whole_day_count(summary.get("AM516工作日交期")) 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 = f"接口未返回有效工作日交期和最终交货日期,需人工复核。原因:{unavailable_reason}" prediction_value = None inventory_info = data.get("库存信息") available_inventory = ( inventory_info.get("可用库存(扣除了待产数量)") if isinstance(inventory_info, dict) else None ) if isinstance(available_inventory, (int, float)): if available_inventory >= requested_quantity: inventory_text = f"整机可用库存{available_inventory:g}台(完全覆盖)" full_coverage: bool | None = True elif available_inventory > 0: gap = requested_quantity - available_inventory inventory_text = f"整机可用库存{available_inventory:g}台(部分覆盖,仍需补齐{gap:g}台)" full_coverage = False else: inventory_text = f"整机可用库存{available_inventory:g}台(需生产/采购)" full_coverage = False else: inventory_text = "接口未返回可用库存汇总,需人工复核。" full_coverage = None bom_info = data.get("BOM信息") has_bom = ( bom_info.get("是否有BOM(False则需提醒无BOM)") if isinstance(bom_info, dict) else None ) purchase_info = data.get("采购信息") shortages = purchase_info.get("缺料明细") if isinstance(purchase_info, dict) else None if has_bom is False: evidence_lines = ["- 缺料/BOM摘要:API未返回有效BOM,需补充数据源。"] elif isinstance(shortages, list) and shortages: reducer_shortages = [ item for item in shortages if isinstance(item, dict) and isinstance(item.get("缺料物料"), str) and item["缺料物料"].startswith("20.30.") ] evidence_lines = [shortage_material_names_line(shortages)] real_shortage_key = ( "真实缺料数量(每套需求×订单数量 + 存量需求数量 − 库存数量 − 待检数量 − 在途数量)" ) for item in reducer_shortages: real_shortage = item.get(real_shortage_key) coverage = ( "不足" if isinstance(real_shortage, (int, float)) and real_shortage > 0 else "满足" if isinstance(real_shortage, (int, float)) else "待确认" ) evidence_lines.append( "- 减速器缺料展开:" f"物料编码{item['缺料物料']};" f"库存数量{item.get('库存数量', '未返回')};" f"待检数量{item.get('待检数量', '未返回')};" f"在途数量{item.get('在途数量', '未返回')};" f"存量需求数量{item.get('存量需求数量', '未返回')};" f"含在途覆盖:{coverage};" ) elif isinstance(shortages, list): evidence_lines = ["- 缺料物料明细:API未返回缺料记录。"] else: evidence_lines = ["- 缺料/BOM摘要:接口未返回可识别缺料汇总,需人工复核。"] timestamp = data.get("时间戳") if isinstance(data.get("时间戳"), str) else None return { "prediction": prediction, "prediction_value": prediction_value, "total_days": total_days, "workdays": workdays, "delivery_date": delivery_date, "main_factor": main_factor_text, "inventory": inventory_text, "available_inventory": ( available_inventory if isinstance(available_inventory, (int, float)) else None ), "full_coverage": full_coverage, "evidence_lines": evidence_lines, "timestamp": timestamp, "has_lead_time": prediction_value is not None, "unavailable_reason": unavailable_reason, } def overall_judgment_lines(candidates: list[dict[str, Any]]) -> list[str]: available = [candidate for candidate in candidates if candidate["details"]["has_lead_time"]] if not available: return [ "- 现货候选:无法判断", "- 最快候选型号:无可用交期汇总", "- 最快候选交期:无法判断", "- 是否完全覆盖数量:无法判断", "- 主导因素:接口未返回,需人工复核", ] fastest = min( available, key=lambda candidate: ( candidate["details"]["workdays"] if candidate["details"]["workdays"] is not None else float("inf") ), ) stock_models = [ candidate["model"] for candidate in available if candidate["details"]["full_coverage"] is True ] coverage = fastest["details"]["full_coverage"] coverage_text = "是" if coverage is True else "否" if coverage is False else "无法判断" return [ f"- 现货候选:{'; '.join(stock_models) if stock_models else '无'}", f"- 最快候选型号:{fastest['model']}", f"- 最快候选交期:{fastest['details']['prediction'].split(':', 1)[-1]}", f"- 是否完全覆盖数量:{coverage_text}", f"- 主导因素:{fastest['details']['main_factor']}", ] def candidate_report_lines(candidate: dict[str, Any], quantity: int) -> list[str]: details = candidate["details"] return [ f"{candidate['index']}. {candidate['model']}({candidate['type']})", f"- 需求数量:{quantity}台", f"- 扣库存后净需求:{details['net_demand']}台", f"- 排产测算数量:{details['production_quantity']}台", f"- {details['prediction']}", f"- 可用库存/覆盖情况:{details['inventory']}", f"- 主导因素:{details['main_factor']}", f"- 推荐理由:{candidate['reason']}", f"- 差异项:{candidate['difference']}", *details["evidence_lines"], f"- 需客户确认的关键差异:{candidate['confirmation']}", "", ] def markdown_cell(value: Any) -> str: """Keep candidate values inside one Markdown table cell.""" return str(value).replace("|", "\\|").replace("\n", "
") def model_analysis_lines(model: str) -> list[str]: match = MODEL_PATTERN.fullmatch(model) if not match: return ["- 型号解析失败,需人工复核。"] fields = match.groupdict() labels = { "structure": {"F": "扁平", "H": "长筒"}, "mount": {"I": "直筒", "T": "转角"}, "brake": {"B": "带制动", "F": "无制动"}, "encoder": {"S": "单圈", "M": "多圈", "HS": "高精度单圈", "HM": "高精度多圈"}, "communication": {"C": "CANopen", "E": "EtherCAT"}, "sensor": {"N": "无", "T": "有"}, } return [ f"- 外径:{fields['diameter']} | 结构:{fields['structure']} {labels['structure'][fields['structure']]} " f"| 减速比:{fields['ratio']} | 安装:{fields['mount']} {labels['mount'][fields['mount']]}", f"- 制动:{fields['brake']} {labels['brake'][fields['brake']]} " f"| 编码器:{fields['encoder']} {labels['encoder'][fields['encoder']]} | 轴径:18", f"- 通讯:{fields['communication']} {labels['communication'][fields['communication']]} " f"| 传感器:{fields['sensor']} {labels['sensor'][fields['sensor']]} " f"| 油脂:{'低温' if fields['grease'] else '标准'}", f"- 版本/品牌:{fields['version']} " f"{'DZ' if fields['bracket'] == '[' else 'LF'}", ] def record_data_timestamp(candidates: list[dict[str, Any]], fallback: datetime) -> str: timestamps = sorted( { candidate["details"]["timestamp"] for candidate in candidates if candidate["details"].get("timestamp") } ) if not timestamps: return fallback.strftime("%Y-%m-%d %H:%M:%S %z") if len(timestamps) == 1: return timestamps[0] return f"{timestamps[0]} ~ {timestamps[-1]}" def render_record( request: RequestInput, candidates: list[dict[str, Any]], unavailable: list[dict[str, str]], generation_time: datetime, ) -> str: all_count = len(candidates) + len(unavailable) record_time = generation_time.strftime("%Y-%m-%d %H:%M:%S %z") lines = [ f"\n## {generation_time:%Y-%m-%d %H:%M:%S %z} | {request.model} | {request.quantity}台", "", f"**输入型号**:{request.model}", f"**输入数量**:{request.quantity}台", "**数量计算规则**:各型号先扣可用库存(按100%可用),净需求÷95%良率,再按5台的倍数向上取整;净需求为0时排产为0。", "**库存覆盖口径**:按原始需求数量判断。", "**各型号排产测算**:", *[ f"- {item['model']}:净需求{item['details']['net_demand']}台;排产测算数量{item['details']['production_quantity']}台;最终API查询数量{item['details']['api_quantity']}台" for item in candidates ], *[ f"- {item['model']}:净需求{item['net_demand']}台;排产测算数量{item['production_quantity']}台;交期不可用" for item in unavailable if 'net_demand' in item ], f"**生成时间**:{generation_time:%Y-%m-%d %H:%M:%S %z}", f"**规则源实际路径**:`{RULE_SOURCE_DISPLAY}`", f"**接口依据**:`{API_SOURCE_DISPLAY}`", f"**数据时间戳**:{record_data_timestamp(candidates, generation_time)}", "", "### 型号解析", "", *model_analysis_lines(request.model), "", f"### 候选型号清单及 API 结果(共{all_count}项)", "", "| 序号 | 记录时间 | 型号 | 推荐类型 | 差异项 | API状态 | AM516预测交期 | 可用库存 | 主导因素 |", "|---:|---|---|---|---|---|---|---:|---|", ] record_rows = [ (candidate["record_order"], True, candidate) for candidate in candidates ] + [ (candidate["record_order"], False, candidate) for candidate in unavailable ] for record_order, is_available, candidate in sorted(record_rows): if is_available: details = candidate["details"] inventory = details.get("available_inventory") inventory_text = f"{inventory:g}台" if isinstance(inventory, (int, float)) else "—" api_status = "✅ 现货" if details.get("full_coverage") is True else "✅ 返回" row = [ record_order, record_time, candidate["model"], candidate["type"], candidate["difference"], api_status, details.get("prediction_value") or "—", inventory_text, details.get("main_factor") or "—", ] else: row = [ record_order, record_time, candidate["model"], candidate["type"], candidate["difference"], "❌ 不可用", "—", "—", "—", ] lines.append("| " + " | ".join(markdown_cell(value) for value in row) + " |") lines.extend( [ "", "### 复核边界", "", DISCLAIMER, "", ] ) return "\n".join(lines) def write_record( request: RequestInput, candidates: list[dict[str, Any]], unavailable: list[dict[str, str]], generation_time: datetime, ) -> None: record = render_record(request, candidates, unavailable, generation_time) with RECORD_FILE.open("a", encoding="utf-8") as file: file.write(record) def query_model_for_demand(request: RequestInput) -> dict[str, Any]: """Read stock with original demand, then forecast only the production gap. The API uses quantity both for its finished-stock shortcut and for production and material requirements. Never accept that shortcut for a partial-stock order, or combine quantities from two different stock snapshots. """ payload = run_controlled_query(request) details = lead_time_details(payload, request.quantity) if not details['has_lead_time']: return details stock = whole_day_count(details['available_inventory']) # A negative balance represents no stock that can cover this new demand. raw_stock = details['available_inventory'] if stock is None and isinstance(raw_stock, (int, float)) and not isinstance(raw_stock, bool) and raw_stock < 0: stock = 0 if isinstance(raw_stock, int) or raw_stock.is_integer() else None if stock is None: details.update(has_lead_time=False, unavailable_reason="接口未返回有效整数可用库存,无法计算净需求及排产数量。") return details net_demand = max(request.quantity - stock, 0) planned = production_quantity_for_demand(request.quantity, stock) details.update(net_demand=net_demand, production_quantity=planned, api_quantity=request.quantity) if planned == 0 or planned == request.quantity: return details if stock >= planned: details.update( has_lead_time=False, unavailable_reason=(f"扣库存后净需求{net_demand}台,排产测算数量{planned}台;" "库存仅部分覆盖原需求,但达到排产查询数量,现有接口会误走现货分支;" "需接口支持分别传入原需求和排产数量后测算交期。"), ) return details forecast = lead_time_details(run_controlled_query(RequestInput(request.model, planned)), request.quantity) forecast.update(net_demand=net_demand, production_quantity=planned, api_quantity=planned) if forecast['available_inventory'] != raw_stock: forecast.update(has_lead_time=False, unavailable_reason="两次查询的可用库存发生变化,未采用本次交期;请重新查询。") return forecast def build_report(request: RequestInput, candidate_offset: int = 0) -> str: if candidate_offset < 0: raise InputError("候选批次位置无效,请重新发起型号和数量查询。") production_quantity_for_demand(request.quantity) # Validate before querying. all_candidate_specs = generate_candidate_specs(request.model) candidate_specs = all_candidate_specs[ candidate_offset : candidate_offset + CANDIDATE_BATCH_SIZE ] is_initial_batch = candidate_offset == 0 if not is_initial_batch and not candidate_specs: return f"AM516 已无其他规则候选型号。\n\n{DISCLAIMER}" candidates: list[dict[str, Any]] = [] baseline: dict[str, Any] | None = None if is_initial_batch: try: baseline = query_model_for_demand(request) except CandidateUnavailableError as exc: raise RuntimeError(f"原型号未生成候选结果。\n原因:{exc}") from exc if not baseline["has_lead_time"]: raise RuntimeError( "原型号未生成可用交期结果,未生成候选结果。\n" f"原因:{baseline['unavailable_reason']}\n" "处理建议:请接口维护人员核对上述原因、库存口径及服务端计算结果后重试。" ) candidates.append( { "index": 1, "record_order": 1, "model": request.model, "type": "原型号,DZ" if "[" in request.model else "原型号,LF", "details": baseline, "reason": "原始需求基准,用于交期测算与候选比较。", "difference": "无", "confirmation": "无型号差异;需确认正式交付节点和承诺边界。", } ) unavailable: list[dict[str, str]] = [] def query_candidate(spec: dict[str, str]) -> tuple[dict[str, str], dict[str, Any] | None]: try: details = query_model_for_demand(RequestInput(spec["model"], request.quantity)) except CandidateUnavailableError as exc: return {**spec, "unavailable_reason": str(exc)}, None if not details["has_lead_time"]: return { **spec, **{key: details[key] for key in ('net_demand', 'production_quantity') if key in details}, "unavailable_reason": details["unavailable_reason"], }, None return spec, details if candidate_specs: with concurrent.futures.ThreadPoolExecutor( max_workers=min(CANDIDATE_BATCH_SIZE, len(candidate_specs)) ) as executor: future_map = { executor.submit(query_candidate, spec): spec for spec in candidate_specs } for future in concurrent.futures.as_completed(future_map): spec, details = future.result() if details is None: unavailable.append(spec) continue candidates.append({**spec, "details": details}) candidate_order = { spec["model"]: index for index, spec in enumerate(all_candidate_specs) } candidate_start = 1 if is_initial_batch else 0 candidates[candidate_start:] = sorted( candidates[candidate_start:], key=lambda item: candidate_order[item["model"]] ) unavailable.sort(key=lambda item: candidate_order[item["model"]]) for candidate in candidates[candidate_start:]: candidate["record_order"] = candidate_order[candidate["model"]] + 2 candidate["index"] = candidate["record_order"] for candidate in unavailable: candidate["record_order"] = candidate_order[candidate["model"]] + 2 generation_time = datetime.now().astimezone() write_record(request, candidates, unavailable, generation_time) data_timestamp = ( baseline["timestamp"] if baseline and baseline["timestamp"] else next( ( candidate["details"]["timestamp"] for candidate in candidates if candidate["details"].get("timestamp") ), f"{generation_time:%Y-%m-%d %H:%M}", ) ) queried_end = candidate_offset + len(candidate_specs) remaining_count = len(all_candidate_specs) - queried_end if is_initial_batch: batch_scope = ( f"本次先推荐{len(candidate_specs)}个候选型号(原型号另作基准)" if remaining_count else f"本次已推荐全部{len(candidate_specs)}个候选型号(原型号另作基准)" ) else: batch_scope = f"本次继续推荐第{candidate_offset + 1}-{queried_end}个候选型号" if remaining_count: batch_scope += f";尚有{remaining_count}个规则候选未查询" else: batch_scope += ";规则候选已全部查询" report_lines = [ ( f"关节模组交期预估报告 | {generation_time:%Y-%m-%d %H:%M}" if is_initial_batch else f"关节模组交期预估报告 | {generation_time:%Y-%m-%d %H:%M}(其他候选)" ), "", f"输入需求:{request.model},{request.quantity}台", f"排产测算规则:各型号先扣可用库存,再除以{100 - DEFECT_RATE_PERCENT}%合格率并向上取{PRODUCTION_BATCH_SIZE}台的倍数;库存足量时排产为0", "库存覆盖口径:按原始需求数量判断(可用库存按100%可用)", f"数据时间戳:{data_timestamp}", "状态:试运行版输出 / 内部候选", "判断等级:AM516算法预测参考,需人工复核", f"候选范围:{batch_scope}", "", "总体判断:" if is_initial_batch else "本批次判断:", *overall_judgment_lines(candidates), "", ] for candidate_item in candidates: report_lines.extend(candidate_report_lines(candidate_item, request.quantity)) report_lines.append("不可用候选:") if unavailable: for item in unavailable: report_lines.append(f"- {item['model']}:原因:{item['unavailable_reason']}") else: report_lines.append("- 无(本次已查询候选均返回交期汇总)") if remaining_count: report_lines.append( f"其他候选:尚有{remaining_count}个未查询;" "如需继续,可说“还有吗”“再推荐几个”“继续”等类似表达。" ) else: report_lines.append("其他候选:规则候选已全部查询。") report_lines.extend(["", DISCLAIMER]) return "\n".join(report_lines) def message_text(data: lark.im.v1.P2ImMessageReceiveV1) -> str | None: message = data.event.message if message.message_type != "text" or not message.content: return None try: content = json.loads(message.content) except json.JSONDecodeError: return None text = content.get("text") return text if isinstance(text, str) else None def post_paragraph(line: str) -> list[dict[str, Any]]: element: dict[str, Any] = {"tag": "text", "text": line or " "} if ( line in {"总体判断:", "本批次判断:", "不可用候选:"} or re.match(r"^\d+\.\s+eRob", line) ): element["style"] = ["bold"] return [element] def build_post_contents(text: str, max_bytes: int = 28000) -> list[dict[str, Any]]: lines = text.splitlines() title = lines.pop(0) if lines and lines[0].startswith("关节模组交期预估报告 |") else "" pages: list[dict[str, Any]] = [] content: list[list[dict[str, Any]]] = [] for line in lines: paragraph = post_paragraph(line) page_title = title if not pages else f"{title}(续)" if title else "" tentative = {"zh_cn": {"title": page_title, "content": [*content, paragraph]}} if content and len(json.dumps(tentative, ensure_ascii=False).encode("utf-8")) > max_bytes: pages.append({"zh_cn": {"title": page_title, "content": content}}) content = [paragraph] else: content.append(paragraph) page_title = title if not pages else f"{title}(续)" if title else "" pages.append({"zh_cn": {"title": page_title, "content": content or [post_paragraph(" ")]}}) return pages def build_post_content(text: str) -> dict[str, Any]: return build_post_contents(text)[0] def reply(client: lark.Client, message_id: str, text: str) -> None: post_contents = build_post_contents(text) for index, post_content in enumerate(post_contents): reply_uuid = message_id if index == 0 else f"{message_id[:44]}-{index + 1}" request = ( lark.im.v1.ReplyMessageRequest.builder() .message_id(message_id) .request_body( lark.im.v1.ReplyMessageRequestBody.builder() .msg_type("post") .content(json.dumps(post_content, ensure_ascii=False)) .uuid(reply_uuid) .build() ) .build() ) response = client.im.v1.message.reply(request) if not response.success(): logging.error( "Feishu reply failed: code=%s part=%s/%s", response.code, index + 1, len(post_contents), ) return logging.info("Feishu reply sent: message_id=%s parts=%s", message_id, len(post_contents)) def add_received_reaction(client: lark.Client, message_id: str) -> bool: try: request_body = ( lark.im.v1.CreateMessageReactionRequestBody.builder() .reaction_type({"emoji_type": RECEIVED_REACTION_TYPE}) .build() ) request = ( lark.im.v1.CreateMessageReactionRequest.builder() .message_id(message_id) .request_body(request_body) .build() ) response = client.im.v1.message_reaction.create(request) except Exception: logging.exception( "Feishu received reaction raised; continuing query: message_id=%s", message_id, ) return False if not response.success(): logging.error( "Feishu received reaction failed: code=%s msg=%s message_id=%s", response.code, response.msg, message_id, ) return False logging.info( "Feishu received reaction added: type=%s message_id=%s", RECEIVED_REACTION_TYPE, message_id, ) return True _seen_message_ids: set[str] = set() _seen_message_lock = threading.Lock() def input_guidance(data: lark.im.v1.P2ImMessageReceiveV1, text: str | None) -> str | None: """Help direct-message users and relevant group queries without calling the API.""" message = data.event.message normalized = unicodedata.normalize("NFKC", text or "").lower() if getattr(message, "chat_type", None) != "p2p" and not any( word in normalized for word in ("erob", "am516", "交期", "候选型号") ): return None if text and is_candidate_continuation_request(text): reason = "当前没有可继续的查询,请重新发送完整型号和数量。" elif text: try: parse_request(text) except InputError as exc: reason = str(exc) else: return None else: reason = "请使用文字发送型号和数量,暂不支持从图片、文件或语音中识别。" return ( f"AM516 交期查询使用说明\n\n{reason}\n\n" "请一次发送一个完整 eRob 型号(含版本)和一个正整数数量。\n" "提问示例:\neRob110H160I-FHM-18ET[V6] 10台\n\n" "也可以说:请查询 eRob110H160I-FHM-18ET[V6],数量10台的交期。\n" "请将示例替换为实际型号和数量;多个型号请分条发送。\n" "收到结果后,可在同一聊天中发送“还有没有其他候选型号”。\n\n" f"{DISCLAIMER}" ) def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -> None: text = message_text(data) message_id = data.event.message.message_id if not message_id: return if not text: guidance = input_guidance(data, text) if guidance: reply(client, message_id, guidance) return key = conversation_key(data) request = parse_trigger_request(text) candidate_offset = 0 continuation_state: CandidateContinuation | None = None if request is None and is_candidate_continuation_request(text): continuation_state = get_candidate_continuation(key) if continuation_state is not None: request = continuation_state.request candidate_offset = continuation_state.next_offset if request is None: guidance = input_guidance(data, text) if guidance: reply(client, message_id, guidance) return logging.info( "Feishu message ignored: no complete AM516 request or active candidate continuation; message_id=%s", message_id, ) return add_received_reaction(client, message_id) if continuation_state is None: clear_candidate_continuation(key) try: report = ( build_report(request) if candidate_offset == 0 else build_report(request, candidate_offset=candidate_offset) ) total_candidates = ( continuation_state.total_candidates if continuation_state is not None else len(generate_candidate_specs(request.model)) ) remember_candidate_continuation( key, request, min(candidate_offset + CANDIDATE_BATCH_SIZE, total_candidates), total_candidates, ) except InputError as exc: report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}" except RuntimeError as exc: report = f"AM516 内部候选查询已停止:{exc}\n\n{DISCLAIMER}" except subprocess.TimeoutExpired: report = ( "AM516 内部候选查询已停止,未生成候选结果。\n" f"原因:受控交期查询超过45秒未完成,请稍后重试。\n\n{DISCLAIMER}" ) except Exception: logging.exception("Unexpected AM516 bridge error") report = f"AM516 内部候选查询已停止:运行异常,未生成候选结果。\n\n{DISCLAIMER}" reply(client, message_id, report) def main() -> None: load_dotenv() app_id = os.getenv("FEISHU_APP_ID", "").strip() app_secret = os.getenv("FEISHU_APP_SECRET", "").strip() if not app_id or not app_secret: raise SystemExit("FEISHU_APP_ID and FEISHU_APP_SECRET must be configured in the local environment.") if not QUERY_SCRIPT.is_file() or not RECORD_FILE.is_file(): raise SystemExit("Required AM516 capability files are missing; bridge did not start.") client = lark.Client.builder().app_id(app_id).app_secret(app_secret).build() def on_message(data: lark.im.v1.P2ImMessageReceiveV1) -> None: message_id = data.event.message.message_id sender_type = data.event.sender.sender_type logging.info("Feishu event received: message_id=%s sender_type=%s", message_id, sender_type) if data.event.sender.sender_type != "user": return if not message_id: return with _seen_message_lock: if message_id in _seen_message_ids: return _seen_message_ids.add(message_id) threading.Thread(target=handle_message, args=(client, data), daemon=True).start() event_handler = ( lark.EventDispatcherHandler.builder("", "") .register_p2_im_message_receive_v1(on_message) .build() ) logging.info("Starting AM516 Feishu long-connection bridge.") lark.ws.Client( app_id, app_secret, log_level=lark.LogLevel.WARNING, event_handler=event_handler, ).start() if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") main()