certflow.utils.status_inference 源代码

"""状态推断模块

从自由文本 execution_status 中推断标准化的 production_status,
并支持提取日期信息。

规则设计原则:
- 优先级从高到低:发货 > 完成 > 生产中 > 待生产
- 日期格式自动识别:2024/03/15已发、2024-03-20发完 等
- 默认返回"待生产"

配置驱动:
- 状态枚举、推断规则、查询关键词均从 config.yaml 的 production_status 节点加载
- 调用 init_from_config() 后,所有硬编码回退值被配置值覆盖
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import Any, Literal

# ============================================================
# 状态常量与类型定义(默认值,会被配置覆盖)
# ============================================================
ProductionStatus = Literal["待生产", "生产中", "已完成", "已发货", "部分发货"]

STATUS_VALUES: list[str] = [
    "待生产",
    "生产中",
    "已完成",
    "已发货",
    "部分发货",
]

STATUS_DEFAULT: str = "待生产"


[文档] class StatusInference: """从自由文本执行情况中推断生产状态 execution_status 是历史自由文本字段,内容格式不统一: - "2024/03/15已发" - "已发货" - "2024-03-20发完" - "生产中" - "完成" - ""(空值) 通过正则匹配将自由文本映射到标准化的 production_status。 配置驱动:调用 init_from_config() 从 config.yaml 加载推断规则和查询关键词。 Examples: >>> StatusInference.infer("2024/03/15已发") '已发货' >>> StatusInference.infer("生产中") '生产中' >>> StatusInference.infer(None) '待生产' >>> StatusInference.extract_date("2024/03/15已发") '2024-03-15' """ _initialized: bool = False # 状态映射规则(按优先级排序,先匹配的先生效)—— 默认值,会被配置覆盖 STATUS_RULES: list[tuple[str, str]] = [ # 部分发货(必须在发货之前,因为"部分发货"包含"发货") (r"(部分发|分批发|已发部分)", "部分发货"), # 发货相关 (r"(已发|发完|发货|发运|已交付|已发出)", "已发货"), (r"\d{4}[/-]\d{1,2}[/-]\d{1,2}\s*(已发|发货|发运)", "已发货"), # 完成相关 (r"(完成|完工|已完|制作完成)", "已完成"), # 生产中相关 (r"(生产中|加工中|制作中)", "生产中"), # 待生产/未开始 (r"(待生产|未开始|未生产|未加工)", "待生产"), ] # 查询关键词映射 —— 默认值,会被配置覆盖 QUERY_KEYWORDS: dict[str, str] = { "已发货": "已发", "部分发货": "部分", "已完成": "完成", "生产中": "生产", } @classmethod def _load_config_from_yaml(cls, config_path: str | None = None) -> dict[str, Any]: """尝试通过项目配置系统或直接解析 YAML 加载配置""" # 尝试通过项目配置系统加载(支持 !include 等自定义标签) try: from certflow.config.settings import cfg as settings_cfg return settings_cfg("production_status", {}) except Exception: pass # 回退:直接解析 YAML import yaml if config_path is None: config_path = str( Path(__file__).resolve().parent.parent.parent.parent / "config" / "config.yaml" ) try: with open(config_path, encoding="utf-8") as f: full_config = yaml.safe_load(f) return full_config.get("production_status", {}) if full_config else {} except (FileNotFoundError, Exception): return {}
[文档] @classmethod def init_from_config(cls, config_path: str | None = None) -> None: """从配置文件初始化状态推断模块 在应用启动时调用一次,从 config.yaml 的 production_status 节点加载: - 状态枚举值 (values) - 默认状态 (default) - 推断规则 (inference_rules) - 查询关键词 (query_keywords) 优先使用项目 ConfigLoader(支持 !include 语法), 回退到标准 yaml.safe_load(仅当配置不含自定义标签时)。 Args: config_path: 配置文件路径,为 None 时自动查找 """ if cls._initialized: return config = cls._load_config_from_yaml(config_path) if not config: cls._initialized = True return # 加载状态枚举值 values: list[str] = config.get("values", []) if values: global STATUS_VALUES STATUS_VALUES = values # 加载默认状态 default: str | None = config.get("default") if default: global STATUS_DEFAULT STATUS_DEFAULT = default # 加载推断规则 rules: list[dict] = config.get("inference_rules", []) if rules: cls.STATUS_RULES = [("|".join(r["patterns"]), r["set_to"]) for r in rules] # 加载查询关键词 keywords: dict = config.get("query_keywords", {}) if keywords: cls.QUERY_KEYWORDS = keywords cls._initialized = True
[文档] @classmethod def infer(cls, execution_status: str | None) -> ProductionStatus: """从执行情况推断生产状态 Args: execution_status: 执行情况的原始文本,如 "2024/03/15已发" Returns: 推断的状态,如 "已发货",无匹配时返回默认状态 """ if not execution_status or not isinstance(execution_status, str): return STATUS_DEFAULT # type: ignore[return-value] text = str(execution_status).strip() if not text: return STATUS_DEFAULT # type: ignore[return-value] for pattern, status in cls.STATUS_RULES: if re.search(pattern, text): return status # type: ignore[return-value] return STATUS_DEFAULT # type: ignore[return-value]
[文档] @classmethod def get_query_keyword(cls, status: str) -> str: """获取状态对应的查询关键词(用于模糊匹配 execution_status) Args: status: 标准化状态值,如 "已发货" Returns: 查询关键词,如 "已发",未配置时返回原状态值 """ if not cls._initialized: cls.init_from_config() return cls.QUERY_KEYWORDS.get(status, status)
[文档] @classmethod def load_rules_from_config(cls, config: dict[str, Any]) -> None: """从配置字典加载规则(向后兼容旧接口) Args: config: 配置字典,支持两种格式: - 旧格式:{"status_rules": [(pattern, status), ...]} - 新格式:{"inference_rules": [{"patterns": [...], "set_to": "..."}, ...]} Example: >>> config = { ... "status_rules": [ ... ("已发|发完", "已发货"), ... ("完成", "已完成"), ... ] ... } >>> StatusInference.load_rules_from_config(config) """ if "inference_rules" in config: cls.STATUS_RULES = [ ("|".join(r["patterns"]), r["set_to"]) for r in config["inference_rules"] ] elif "status_rules" in config: cls.STATUS_RULES = [(pattern, status) for pattern, status in config["status_rules"]]
[文档] @classmethod def extract_date(cls, execution_status: str | None) -> str | None: """从执行情况中提取日期(如有) 支持格式: - 2024/03/15 - 2024-03-15 - 2024年03月15日 - 24/03/15(两位数年份) Args: execution_status: 原始文本 Returns: 提取的日期(YYYY-MM-DD格式)或 None """ if not execution_status: return None text = str(execution_status).strip() # 格式1: 2024/03/15 或 2024-03-15 或 2024-3-15 match = re.search(r"(\d{4})[/-](\d{1,2})[/-](\d{1,2})", text) if match: year, month, day = match.groups() return f"{year}-{int(month):02d}-{int(day):02d}" # 格式2: 2024年03月15日 或 2024年3月15日 match = re.search(r"(\d{4})年(\d{1,2})月(\d{1,2})日", text) if match: year, month, day = match.groups() return f"{year}-{int(month):02d}-{int(day):02d}" # 格式3: 24/03/15(两位数年份,假设2000+) match = re.search(r"(\d{2})[/-](\d{1,2})[/-](\d{1,2})", text) if match: year, month, day = match.groups() full_year = 2000 + int(year) return f"{full_year}-{int(month):02d}-{int(day):02d}" return None
# ============================================================ # 自测 # ============================================================ if __name__ == "__main__": test_cases: list[tuple[str | None, str, str | None]] = [ ("2024/03/15已发", "已发货", "2024-03-15"), ("已发货", "已发货", None), ("2024-3-20发完", "已发货", "2024-03-20"), ("2026/1/9已发", "已发货", "2026-01-09"), ("生产中", "生产中", None), ("已完成", "已完成", None), ("部分发货", "部分发货", None), ("待生产", "待生产", None), ("2024年03月15日已发货", "已发货", "2024-03-15"), ("2024-03-15发完", "已发货", "2024-03-15"), ("加工中", "生产中", None), ("已完工", "已完成", None), ("2024/05/20已交付", "已发货", "2024-05-20"), ("分批发", "部分发货", None), ("未开始", "待生产", None), ("", "待生产", None), (None, "待生产", None), ("无意义文本", "待生产", None), ] all_passed = True for text, expected_status, expected_date in test_cases: status = StatusInference.infer(text) date = StatusInference.extract_date(text) status_ok = status == expected_status date_ok = date == expected_date if not status_ok or not date_ok: all_passed = False print( f"FAIL: text={text!r} | " f"status: got={status!r}, expected={expected_status!r} | " f"date: got={date!r}, expected={expected_date!r}" ) if all_passed: print("所有测试通过!") else: print("存在测试失败,请检查。")