|
|
@@ -0,0 +1,1001 @@
|
|
|
+"""Closed device-ledger and fault-maintenance quality evaluation."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import copy
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+import re
|
|
|
+import unicodedata
|
|
|
+from collections import defaultdict
|
|
|
+from collections.abc import Callable
|
|
|
+from dataclasses import dataclass
|
|
|
+from datetime import datetime, timedelta
|
|
|
+from typing import Any
|
|
|
+from uuid import UUID
|
|
|
+
|
|
|
+from app.core.common.identifiers import new_governance_uid
|
|
|
+from app.core.common.timezone_utils import now_china
|
|
|
+from app.core.data_research.errors import (
|
|
|
+ DeviceQualityConflict,
|
|
|
+ DeviceQualityInvalid,
|
|
|
+ DeviceQualityNotFound,
|
|
|
+)
|
|
|
+
|
|
|
+PROFILE_NAME = "设备台账与故障质量策略"
|
|
|
+MAX_ASSETS = 5_000
|
|
|
+MAX_SAMPLES_PER_RULE = 100
|
|
|
+SAMPLE_RETENTION_DAYS = 30
|
|
|
+SEVERITIES = frozenset({"info", "warning", "error", "critical"})
|
|
|
+FORMAT_IDENTIFIERS = frozenset({"upper_alnum_dash"})
|
|
|
+ASSET_TYPES = frozenset(
|
|
|
+ {
|
|
|
+ "device",
|
|
|
+ "component",
|
|
|
+ "measurement_point",
|
|
|
+ "alarm",
|
|
|
+ "maintenance_record",
|
|
|
+ }
|
|
|
+)
|
|
|
+RULE_CODES = (
|
|
|
+ "asset_identity_complete",
|
|
|
+ "asset_context_complete",
|
|
|
+ "source_code_unique_normalized",
|
|
|
+ "component_parent_resolved",
|
|
|
+ "fault_code_mapped",
|
|
|
+ "fault_reason_action_complete",
|
|
|
+ "maintenance_closed_loop",
|
|
|
+)
|
|
|
+RULE_PARAMETER_KEYS = {
|
|
|
+ "asset_identity_complete": frozenset(),
|
|
|
+ "asset_context_complete": frozenset({"asset_types"}),
|
|
|
+ "source_code_unique_normalized": frozenset({"format"}),
|
|
|
+ "component_parent_resolved": frozenset({"parent_field"}),
|
|
|
+ "fault_code_mapped": frozenset({"fault_field"}),
|
|
|
+ "fault_reason_action_complete": frozenset(
|
|
|
+ {"cause_field", "action_field"}
|
|
|
+ ),
|
|
|
+ "maintenance_closed_loop": frozenset(
|
|
|
+ {
|
|
|
+ "device_field",
|
|
|
+ "fault_field",
|
|
|
+ "status_field",
|
|
|
+ "completed_at_field",
|
|
|
+ "action_field",
|
|
|
+ "closed_statuses",
|
|
|
+ }
|
|
|
+ ),
|
|
|
+}
|
|
|
+FIELD_PARAMETER_KEYS = frozenset(
|
|
|
+ {
|
|
|
+ "parent_field",
|
|
|
+ "fault_field",
|
|
|
+ "cause_field",
|
|
|
+ "action_field",
|
|
|
+ "device_field",
|
|
|
+ "status_field",
|
|
|
+ "completed_at_field",
|
|
|
+ }
|
|
|
+)
|
|
|
+SECRET_KEYS = frozenset(
|
|
|
+ {
|
|
|
+ "apikey",
|
|
|
+ "authorization",
|
|
|
+ "connectionstring",
|
|
|
+ "credential",
|
|
|
+ "credentials",
|
|
|
+ "dsn",
|
|
|
+ "password",
|
|
|
+ "secret",
|
|
|
+ "token",
|
|
|
+ }
|
|
|
+)
|
|
|
+SOURCE_CODE_PATTERN = re.compile(r"^[A-Z0-9][A-Z0-9._-]{1,63}$")
|
|
|
+FIELD_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
|
|
+
|
|
|
+
|
|
|
+DEFAULT_DEVICE_QUALITY_RULES = (
|
|
|
+ {
|
|
|
+ "code": "asset_identity_complete",
|
|
|
+ "enabled": True,
|
|
|
+ "severity": "critical",
|
|
|
+ "weight": 15,
|
|
|
+ "parameters": {},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "code": "asset_context_complete",
|
|
|
+ "enabled": True,
|
|
|
+ "severity": "error",
|
|
|
+ "weight": 15,
|
|
|
+ "parameters": {"asset_types": ["device"]},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "code": "source_code_unique_normalized",
|
|
|
+ "enabled": True,
|
|
|
+ "severity": "critical",
|
|
|
+ "weight": 10,
|
|
|
+ "parameters": {"format": "upper_alnum_dash"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "code": "component_parent_resolved",
|
|
|
+ "enabled": True,
|
|
|
+ "severity": "error",
|
|
|
+ "weight": 10,
|
|
|
+ "parameters": {"parent_field": "parent_source_code"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "code": "fault_code_mapped",
|
|
|
+ "enabled": True,
|
|
|
+ "severity": "critical",
|
|
|
+ "weight": 15,
|
|
|
+ "parameters": {"fault_field": "fault_code"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "code": "fault_reason_action_complete",
|
|
|
+ "enabled": True,
|
|
|
+ "severity": "error",
|
|
|
+ "weight": 15,
|
|
|
+ "parameters": {
|
|
|
+ "cause_field": "cause_code",
|
|
|
+ "action_field": "action_code",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "code": "maintenance_closed_loop",
|
|
|
+ "enabled": True,
|
|
|
+ "severity": "critical",
|
|
|
+ "weight": 20,
|
|
|
+ "parameters": {
|
|
|
+ "device_field": "device_source_code",
|
|
|
+ "fault_field": "fault_source_code",
|
|
|
+ "status_field": "status",
|
|
|
+ "completed_at_field": "completed_at",
|
|
|
+ "action_field": "action_code",
|
|
|
+ "closed_statuses": ["closed", "completed"],
|
|
|
+ },
|
|
|
+ },
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceQualitySourceMapping:
|
|
|
+ uid: str
|
|
|
+ source_uid: str
|
|
|
+ source_entity: str
|
|
|
+ asset_type: str
|
|
|
+ source_code: str
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceQualityAssetSnapshot:
|
|
|
+ uid: str
|
|
|
+ asset_type: str
|
|
|
+ name: str
|
|
|
+ status: str
|
|
|
+ current_version: int
|
|
|
+ location: str | None
|
|
|
+ organization: str | None
|
|
|
+ responsible_person: str | None
|
|
|
+ attributes: dict[str, Any]
|
|
|
+ mappings: tuple[DeviceQualitySourceMapping, ...]
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceQualityPolicyVersionRecord:
|
|
|
+ uid: str
|
|
|
+ profile_uid: str
|
|
|
+ version: int
|
|
|
+ status: str
|
|
|
+ rules: tuple[dict[str, Any], ...]
|
|
|
+ content_hash: str
|
|
|
+ created_by: str
|
|
|
+ created_at: datetime
|
|
|
+ published_by: str | None = None
|
|
|
+ published_at: datetime | None = None
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceQualityRunRecord:
|
|
|
+ uid: str
|
|
|
+ policy_version_uid: str
|
|
|
+ policy_hash: str
|
|
|
+ source_uid: str | None
|
|
|
+ status: str
|
|
|
+ total_assets: int
|
|
|
+ total_violations: int
|
|
|
+ score: float
|
|
|
+ created_by: str
|
|
|
+ created_at: datetime
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceQualityRuleResultRecord:
|
|
|
+ uid: str
|
|
|
+ run_uid: str
|
|
|
+ rule_code: str
|
|
|
+ severity: str
|
|
|
+ weight: float
|
|
|
+ status: str
|
|
|
+ evaluated_count: int
|
|
|
+ violation_count: int
|
|
|
+ sampled_count: int
|
|
|
+ pass_rate: float
|
|
|
+ weighted_score: float
|
|
|
+ created_at: datetime
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceQualityViolationRecord:
|
|
|
+ uid: str
|
|
|
+ run_uid: str
|
|
|
+ rule_code: str
|
|
|
+ severity: str
|
|
|
+ asset_uid: str
|
|
|
+ field_name: str
|
|
|
+ source_uid: str | None
|
|
|
+ source_mapping_uid: str | None
|
|
|
+ message: str
|
|
|
+ evidence: dict[str, Any]
|
|
|
+ created_at: datetime
|
|
|
+ expires_at: datetime
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceQualityAssetScoreRecord:
|
|
|
+ uid: str
|
|
|
+ run_uid: str
|
|
|
+ asset_uid: str
|
|
|
+ asset_type: str
|
|
|
+ status: str
|
|
|
+ evaluated_rule_count: int
|
|
|
+ violation_count: int
|
|
|
+ score: float
|
|
|
+ created_at: datetime
|
|
|
+
|
|
|
+
|
|
|
+def _canonical(value: Any) -> str:
|
|
|
+ try:
|
|
|
+ return json.dumps(
|
|
|
+ value,
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ ensure_ascii=False,
|
|
|
+ )
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise DeviceQualityInvalid("quality policy must be JSON serializable") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _hash(value: Any) -> str:
|
|
|
+ return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+def _normalized_key(value: Any) -> str:
|
|
|
+ return re.sub(r"[^a-z0-9]", "", str(value).casefold())
|
|
|
+
|
|
|
+
|
|
|
+def _reject_secret_material(value: Any, path: str = "$") -> None:
|
|
|
+ if isinstance(value, dict):
|
|
|
+ for key, item in value.items():
|
|
|
+ if _normalized_key(key) in SECRET_KEYS:
|
|
|
+ raise DeviceQualityInvalid(
|
|
|
+ f"unsupported quality rule parameter at {path}.{key}"
|
|
|
+ )
|
|
|
+ _reject_secret_material(item, f"{path}.{key}")
|
|
|
+ elif isinstance(value, list):
|
|
|
+ for index, item in enumerate(value):
|
|
|
+ _reject_secret_material(item, f"{path}[{index}]")
|
|
|
+
|
|
|
+
|
|
|
+def _required_actor(value: Any) -> str:
|
|
|
+ actor = str(value or "").strip()
|
|
|
+ if not actor or len(actor) > 120:
|
|
|
+ raise DeviceQualityInvalid("actor uid is required")
|
|
|
+ return actor
|
|
|
+
|
|
|
+
|
|
|
+def _optional_source_uid(value: Any) -> str | None:
|
|
|
+ if value is None or str(value).strip() == "":
|
|
|
+ return None
|
|
|
+ text = str(value).strip()
|
|
|
+ try:
|
|
|
+ return str(UUID(text))
|
|
|
+ except (TypeError, ValueError, AttributeError) as exc:
|
|
|
+ raise DeviceQualityInvalid("source_uid must be a valid UUID") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _positive_integer(value: Any, label: str) -> int:
|
|
|
+ if isinstance(value, bool):
|
|
|
+ raise DeviceQualityInvalid(f"{label} must be a positive integer")
|
|
|
+ try:
|
|
|
+ result = int(value)
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise DeviceQualityInvalid(f"{label} must be a positive integer") from exc
|
|
|
+ if result < 1:
|
|
|
+ raise DeviceQualityInvalid(f"{label} must be a positive integer")
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _field_name(value: Any, label: str) -> str:
|
|
|
+ result = str(value or "").strip()
|
|
|
+ if not FIELD_PATTERN.fullmatch(result):
|
|
|
+ raise DeviceQualityInvalid(f"{label} must be a safe field identifier")
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _validate_parameters(code: str, value: Any) -> dict[str, Any]:
|
|
|
+ if not isinstance(value, dict):
|
|
|
+ raise DeviceQualityInvalid("quality rule parameters must be an object")
|
|
|
+ _reject_secret_material(value, f"$.{code}.parameters")
|
|
|
+ allowed = RULE_PARAMETER_KEYS[code]
|
|
|
+ unknown = sorted(set(value) - allowed)
|
|
|
+ if unknown:
|
|
|
+ raise DeviceQualityInvalid(
|
|
|
+ f"unsupported quality rule parameter: {', '.join(unknown)}"
|
|
|
+ )
|
|
|
+ missing = sorted(allowed - set(value))
|
|
|
+ if missing:
|
|
|
+ raise DeviceQualityInvalid(
|
|
|
+ f"missing quality rule parameter: {', '.join(missing)}"
|
|
|
+ )
|
|
|
+ normalized = copy.deepcopy(value)
|
|
|
+ for key in FIELD_PARAMETER_KEYS & set(normalized):
|
|
|
+ normalized[key] = _field_name(normalized[key], key)
|
|
|
+ if "asset_types" in normalized:
|
|
|
+ asset_types = normalized["asset_types"]
|
|
|
+ if (
|
|
|
+ not isinstance(asset_types, list)
|
|
|
+ or not asset_types
|
|
|
+ or len(asset_types) > len(ASSET_TYPES)
|
|
|
+ ):
|
|
|
+ raise DeviceQualityInvalid("asset_types must be a bounded list")
|
|
|
+ normalized["asset_types"] = sorted({str(item) for item in asset_types})
|
|
|
+ if not set(normalized["asset_types"]) <= ASSET_TYPES:
|
|
|
+ raise DeviceQualityInvalid("asset_types contains an unsupported type")
|
|
|
+ if "format" in normalized and normalized["format"] not in FORMAT_IDENTIFIERS:
|
|
|
+ raise DeviceQualityInvalid("unsupported source code format identifier")
|
|
|
+ if "closed_statuses" in normalized:
|
|
|
+ statuses = normalized["closed_statuses"]
|
|
|
+ if not isinstance(statuses, list) or not statuses or len(statuses) > 10:
|
|
|
+ raise DeviceQualityInvalid("closed_statuses must be a bounded list")
|
|
|
+ normalized["closed_statuses"] = sorted(
|
|
|
+ {
|
|
|
+ str(item).strip().casefold()
|
|
|
+ for item in statuses
|
|
|
+ if str(item).strip()
|
|
|
+ }
|
|
|
+ )
|
|
|
+ if not normalized["closed_statuses"]:
|
|
|
+ raise DeviceQualityInvalid("closed_statuses cannot be empty")
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+def validate_device_quality_rules(
|
|
|
+ value: Any,
|
|
|
+) -> tuple[dict[str, Any], ...]:
|
|
|
+ if not isinstance(value, (list, tuple)):
|
|
|
+ raise DeviceQualityInvalid("quality rules must be an array")
|
|
|
+ if len(value) != len(RULE_CODES):
|
|
|
+ raise DeviceQualityInvalid("quality policy has an unsupported rule code set")
|
|
|
+ normalized_by_code: dict[str, dict[str, Any]] = {}
|
|
|
+ for raw in value:
|
|
|
+ if not isinstance(raw, dict):
|
|
|
+ raise DeviceQualityInvalid("quality rule must be an object")
|
|
|
+ if set(raw) != {
|
|
|
+ "code",
|
|
|
+ "enabled",
|
|
|
+ "severity",
|
|
|
+ "weight",
|
|
|
+ "parameters",
|
|
|
+ }:
|
|
|
+ raise DeviceQualityInvalid("quality rule contains unsupported fields")
|
|
|
+ code = str(raw.get("code") or "").strip()
|
|
|
+ if code not in RULE_CODES or code in normalized_by_code:
|
|
|
+ raise DeviceQualityInvalid("quality policy has an unsupported rule code")
|
|
|
+ enabled = raw.get("enabled")
|
|
|
+ if not isinstance(enabled, bool):
|
|
|
+ raise DeviceQualityInvalid("quality rule enabled must be boolean")
|
|
|
+ severity = str(raw.get("severity") or "").strip()
|
|
|
+ if severity not in SEVERITIES:
|
|
|
+ raise DeviceQualityInvalid("quality rule severity is unsupported")
|
|
|
+ weight = raw.get("weight")
|
|
|
+ if (
|
|
|
+ isinstance(weight, bool)
|
|
|
+ or not isinstance(weight, (int, float))
|
|
|
+ or weight < 0
|
|
|
+ or weight > 100
|
|
|
+ or (enabled and weight <= 0)
|
|
|
+ ):
|
|
|
+ raise DeviceQualityInvalid("quality rule weight is invalid")
|
|
|
+ normalized_by_code[code] = {
|
|
|
+ "code": code,
|
|
|
+ "enabled": enabled,
|
|
|
+ "severity": severity,
|
|
|
+ "weight": float(weight),
|
|
|
+ "parameters": _validate_parameters(code, raw.get("parameters")),
|
|
|
+ }
|
|
|
+ if set(normalized_by_code) != set(RULE_CODES):
|
|
|
+ raise DeviceQualityInvalid("quality policy has an unsupported rule code set")
|
|
|
+ total = sum(
|
|
|
+ item["weight"]
|
|
|
+ for item in normalized_by_code.values()
|
|
|
+ if item["enabled"]
|
|
|
+ )
|
|
|
+ if abs(total - 100.0) > 0.000001:
|
|
|
+ raise DeviceQualityInvalid("enabled quality rule weights must sum to 100")
|
|
|
+ return tuple(
|
|
|
+ copy.deepcopy(normalized_by_code[code])
|
|
|
+ for code in RULE_CODES
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_code(value: Any) -> str:
|
|
|
+ normalized = unicodedata.normalize("NFKC", str(value or ""))
|
|
|
+ return "".join(normalized.split()).upper()
|
|
|
+
|
|
|
+
|
|
|
+def _safe_text(value: Any, maximum: int = 300) -> str:
|
|
|
+ text = unicodedata.normalize("NFKC", str(value or "")).strip()
|
|
|
+ return text[:maximum]
|
|
|
+
|
|
|
+
|
|
|
+def _mapping_for(asset: DeviceQualityAssetSnapshot):
|
|
|
+ return asset.mappings[0] if asset.mappings else None
|
|
|
+
|
|
|
+
|
|
|
+def _source_evidence(asset: DeviceQualityAssetSnapshot) -> dict[str, Any]:
|
|
|
+ mapping = _mapping_for(asset)
|
|
|
+ return {
|
|
|
+ "asset_uid": asset.uid,
|
|
|
+ "asset_type": asset.asset_type,
|
|
|
+ "asset_version": int(asset.current_version),
|
|
|
+ "source_uid": mapping.source_uid if mapping else None,
|
|
|
+ "source_mapping_uid": mapping.uid if mapping else None,
|
|
|
+ "source_entity": mapping.source_entity if mapping else None,
|
|
|
+ "source_code": _safe_text(mapping.source_code) if mapping else None,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _is_iso_datetime(value: Any) -> bool:
|
|
|
+ text = str(value or "").strip()
|
|
|
+ if not text:
|
|
|
+ return False
|
|
|
+ try:
|
|
|
+ datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
|
+ except ValueError:
|
|
|
+ return False
|
|
|
+ return True
|
|
|
+
|
|
|
+
|
|
|
+def _applicable(
|
|
|
+ rule_code: str,
|
|
|
+ asset: DeviceQualityAssetSnapshot,
|
|
|
+ parameters: dict[str, Any],
|
|
|
+) -> bool:
|
|
|
+ if rule_code == "asset_identity_complete":
|
|
|
+ return True
|
|
|
+ if rule_code == "asset_context_complete":
|
|
|
+ return asset.asset_type in set(parameters["asset_types"])
|
|
|
+ if rule_code == "source_code_unique_normalized":
|
|
|
+ return bool(asset.mappings)
|
|
|
+ if rule_code == "component_parent_resolved":
|
|
|
+ return asset.asset_type == "component"
|
|
|
+ if rule_code in {"fault_code_mapped", "fault_reason_action_complete"}:
|
|
|
+ return asset.asset_type == "alarm"
|
|
|
+ if rule_code == "maintenance_closed_loop":
|
|
|
+ return asset.asset_type == "maintenance_record"
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
+class DeviceQualityService:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ repository,
|
|
|
+ *,
|
|
|
+ publish_authorizer: Callable[[str], None],
|
|
|
+ uid_factory: Callable[[], str] = new_governance_uid,
|
|
|
+ now_factory: Callable[[], datetime] = now_china,
|
|
|
+ commit: Callable[[], None] = lambda: None,
|
|
|
+ rollback: Callable[[], None] = lambda: None,
|
|
|
+ ):
|
|
|
+ self.repository = repository
|
|
|
+ self.publish_authorizer = publish_authorizer
|
|
|
+ self.uid_factory = uid_factory
|
|
|
+ self.now_factory = now_factory
|
|
|
+ self.commit = commit
|
|
|
+ self.rollback = rollback
|
|
|
+
|
|
|
+ def _create_version(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ rules: Any,
|
|
|
+ actor_uid: str,
|
|
|
+ expected_version: int | None,
|
|
|
+ ) -> DeviceQualityPolicyVersionRecord:
|
|
|
+ actor = _required_actor(actor_uid)
|
|
|
+ normalized = validate_device_quality_rules(rules)
|
|
|
+ content_hash = _hash(list(normalized))
|
|
|
+ latest = self.repository.latest_version()
|
|
|
+ if expected_version is not None:
|
|
|
+ expected = _positive_integer(expected_version, "expected_version")
|
|
|
+ actual = latest.version if latest is not None else 0
|
|
|
+ if actual != expected:
|
|
|
+ raise DeviceQualityConflict("quality policy revision is stale")
|
|
|
+ existing = self.repository.find_version_by_hash(content_hash)
|
|
|
+ if existing is not None:
|
|
|
+ return existing
|
|
|
+ profile_uid = self.repository.ensure_profile(
|
|
|
+ name=PROFILE_NAME,
|
|
|
+ actor_uid=actor,
|
|
|
+ )
|
|
|
+ now = self.now_factory()
|
|
|
+ record = DeviceQualityPolicyVersionRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ profile_uid=profile_uid,
|
|
|
+ version=(latest.version + 1 if latest is not None else 1),
|
|
|
+ status="draft",
|
|
|
+ rules=normalized,
|
|
|
+ content_hash=content_hash,
|
|
|
+ created_by=actor,
|
|
|
+ created_at=now,
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ result = self.repository.create_version(record)
|
|
|
+ self.commit()
|
|
|
+ return result
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+
|
|
|
+ def bootstrap(self, *, actor_uid: str) -> DeviceQualityPolicyVersionRecord:
|
|
|
+ return self._create_version(
|
|
|
+ rules=DEFAULT_DEVICE_QUALITY_RULES,
|
|
|
+ actor_uid=actor_uid,
|
|
|
+ expected_version=None,
|
|
|
+ )
|
|
|
+
|
|
|
+ def revise(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ rules: Any,
|
|
|
+ expected_version: int,
|
|
|
+ actor_uid: str,
|
|
|
+ ) -> DeviceQualityPolicyVersionRecord:
|
|
|
+ return self._create_version(
|
|
|
+ rules=rules,
|
|
|
+ actor_uid=actor_uid,
|
|
|
+ expected_version=expected_version,
|
|
|
+ )
|
|
|
+
|
|
|
+ def publish(
|
|
|
+ self,
|
|
|
+ version_uid: str,
|
|
|
+ *,
|
|
|
+ actor_uid: str,
|
|
|
+ ) -> DeviceQualityPolicyVersionRecord:
|
|
|
+ actor = _required_actor(actor_uid)
|
|
|
+ record = self.repository.get_version(version_uid, for_update=True)
|
|
|
+ if record is None:
|
|
|
+ raise DeviceQualityNotFound("quality policy version was not found")
|
|
|
+ if record.status == "published":
|
|
|
+ return record
|
|
|
+ if record.status != "draft":
|
|
|
+ raise DeviceQualityConflict("quality policy version cannot be published")
|
|
|
+ self.publish_authorizer(actor)
|
|
|
+ try:
|
|
|
+ published = self.repository.publish_version(
|
|
|
+ record,
|
|
|
+ actor_uid=actor,
|
|
|
+ published_at=self.now_factory(),
|
|
|
+ )
|
|
|
+ self.commit()
|
|
|
+ return published
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+
|
|
|
+ def profile(self) -> dict[str, Any]:
|
|
|
+ latest = self.repository.latest_version()
|
|
|
+ active = self.repository.active_version()
|
|
|
+ return {
|
|
|
+ "profile_uid": (
|
|
|
+ latest.profile_uid
|
|
|
+ if latest is not None
|
|
|
+ else getattr(self.repository, "profile_uid", None)
|
|
|
+ ),
|
|
|
+ "name": PROFILE_NAME,
|
|
|
+ "latest_version": latest,
|
|
|
+ "active_version": active,
|
|
|
+ }
|
|
|
+
|
|
|
+ def versions(self) -> tuple[DeviceQualityPolicyVersionRecord, ...]:
|
|
|
+ return tuple(self.repository.list_versions())
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _indexes(assets):
|
|
|
+ mappings = defaultdict(list)
|
|
|
+ for item in assets:
|
|
|
+ for source_mapping in item.mappings:
|
|
|
+ key = (
|
|
|
+ source_mapping.source_uid,
|
|
|
+ source_mapping.asset_type,
|
|
|
+ _normalize_code(source_mapping.source_code),
|
|
|
+ )
|
|
|
+ mappings[key].append(item)
|
|
|
+ return mappings
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _failure(
|
|
|
+ rule_code: str,
|
|
|
+ asset: DeviceQualityAssetSnapshot,
|
|
|
+ parameters: dict[str, Any],
|
|
|
+ mapping_index,
|
|
|
+ code_sets,
|
|
|
+ ) -> tuple[str, str, dict[str, Any]] | None:
|
|
|
+ attributes = asset.attributes or {}
|
|
|
+ if rule_code == "asset_identity_complete":
|
|
|
+ if not asset.mappings or any(
|
|
|
+ not _safe_text(item.source_code)
|
|
|
+ for item in asset.mappings
|
|
|
+ ):
|
|
|
+ return (
|
|
|
+ "source_code",
|
|
|
+ "设备资产缺少可追溯的来源标识",
|
|
|
+ _source_evidence(asset),
|
|
|
+ )
|
|
|
+ return None
|
|
|
+ if rule_code == "asset_context_complete":
|
|
|
+ missing = [
|
|
|
+ field
|
|
|
+ for field in ("location", "organization", "responsible_person")
|
|
|
+ if not _safe_text(getattr(asset, field))
|
|
|
+ ]
|
|
|
+ if missing:
|
|
|
+ return (
|
|
|
+ ",".join(missing),
|
|
|
+ "设备台账位置、组织或责任人不完整",
|
|
|
+ {**_source_evidence(asset), "missing_fields": missing},
|
|
|
+ )
|
|
|
+ return None
|
|
|
+ if rule_code == "source_code_unique_normalized":
|
|
|
+ bad = []
|
|
|
+ duplicates = []
|
|
|
+ for source_mapping in asset.mappings:
|
|
|
+ raw = _safe_text(source_mapping.source_code)
|
|
|
+ normalized = _normalize_code(raw)
|
|
|
+ if (
|
|
|
+ parameters["format"] == "upper_alnum_dash"
|
|
|
+ and not SOURCE_CODE_PATTERN.fullmatch(raw.upper())
|
|
|
+ ):
|
|
|
+ bad.append(source_mapping.uid)
|
|
|
+ key = (
|
|
|
+ source_mapping.source_uid,
|
|
|
+ source_mapping.asset_type,
|
|
|
+ normalized,
|
|
|
+ )
|
|
|
+ if len(mapping_index[key]) > 1:
|
|
|
+ duplicates.append(source_mapping.uid)
|
|
|
+ if bad or duplicates:
|
|
|
+ return (
|
|
|
+ "source_code",
|
|
|
+ "来源编码格式不一致或规范化后重复",
|
|
|
+ {
|
|
|
+ **_source_evidence(asset),
|
|
|
+ "invalid_mapping_uids": sorted(bad),
|
|
|
+ "duplicate_mapping_uids": sorted(duplicates),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return None
|
|
|
+ if rule_code == "component_parent_resolved":
|
|
|
+ field = parameters["parent_field"]
|
|
|
+ parent_code = _normalize_code(attributes.get(field))
|
|
|
+ mapping = _mapping_for(asset)
|
|
|
+ resolved = bool(
|
|
|
+ mapping
|
|
|
+ and parent_code
|
|
|
+ and mapping_index[
|
|
|
+ (mapping.source_uid, "device", parent_code)
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ if not resolved:
|
|
|
+ return (
|
|
|
+ field,
|
|
|
+ "部件未关联到同一来源中的有效设备",
|
|
|
+ {
|
|
|
+ **_source_evidence(asset),
|
|
|
+ "parent_source_code": _safe_text(attributes.get(field)),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return None
|
|
|
+ if rule_code == "fault_code_mapped":
|
|
|
+ field = parameters["fault_field"]
|
|
|
+ code = _normalize_code(attributes.get(field))
|
|
|
+ if not code or code not in code_sets["fault"]:
|
|
|
+ return (
|
|
|
+ field,
|
|
|
+ "故障代码未映射到已发布的统一代码集",
|
|
|
+ {
|
|
|
+ **_source_evidence(asset),
|
|
|
+ "fault_code": _safe_text(attributes.get(field)),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return None
|
|
|
+ if rule_code == "fault_reason_action_complete":
|
|
|
+ cause_field = parameters["cause_field"]
|
|
|
+ action_field = parameters["action_field"]
|
|
|
+ cause = _normalize_code(attributes.get(cause_field))
|
|
|
+ action = _normalize_code(attributes.get(action_field))
|
|
|
+ missing = []
|
|
|
+ if not cause or cause not in code_sets["cause"]:
|
|
|
+ missing.append(cause_field)
|
|
|
+ if not action or action not in code_sets["action"]:
|
|
|
+ missing.append(action_field)
|
|
|
+ if missing:
|
|
|
+ return (
|
|
|
+ ",".join(missing),
|
|
|
+ "故障原因或措施未映射到已发布代码集",
|
|
|
+ {
|
|
|
+ **_source_evidence(asset),
|
|
|
+ "invalid_fields": missing,
|
|
|
+ "cause_code": _safe_text(attributes.get(cause_field)),
|
|
|
+ "action_code": _safe_text(attributes.get(action_field)),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return None
|
|
|
+ if rule_code == "maintenance_closed_loop":
|
|
|
+ mapping = _mapping_for(asset)
|
|
|
+ device_code = _normalize_code(
|
|
|
+ attributes.get(parameters["device_field"])
|
|
|
+ )
|
|
|
+ fault_code = _normalize_code(
|
|
|
+ attributes.get(parameters["fault_field"])
|
|
|
+ )
|
|
|
+ action_code = _normalize_code(
|
|
|
+ attributes.get(parameters["action_field"])
|
|
|
+ )
|
|
|
+ status = str(
|
|
|
+ attributes.get(parameters["status_field"]) or ""
|
|
|
+ ).strip().casefold()
|
|
|
+ completed_at = attributes.get(parameters["completed_at_field"])
|
|
|
+ missing = []
|
|
|
+ source_uid = mapping.source_uid if mapping else None
|
|
|
+ if not source_uid or not mapping_index[
|
|
|
+ (source_uid, "device", device_code)
|
|
|
+ ]:
|
|
|
+ missing.append(parameters["device_field"])
|
|
|
+ if not source_uid or not mapping_index[
|
|
|
+ (source_uid, "alarm", fault_code)
|
|
|
+ ]:
|
|
|
+ missing.append(parameters["fault_field"])
|
|
|
+ if status not in set(parameters["closed_statuses"]):
|
|
|
+ missing.append(parameters["status_field"])
|
|
|
+ if not _is_iso_datetime(completed_at):
|
|
|
+ missing.append(parameters["completed_at_field"])
|
|
|
+ if not action_code or action_code not in code_sets["action"]:
|
|
|
+ missing.append(parameters["action_field"])
|
|
|
+ if missing:
|
|
|
+ return (
|
|
|
+ ",".join(sorted(set(missing))),
|
|
|
+ "维修记录未形成设备、故障、措施和完成时间闭环",
|
|
|
+ {
|
|
|
+ **_source_evidence(asset),
|
|
|
+ "invalid_fields": sorted(set(missing)),
|
|
|
+ "status": _safe_text(status),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return None
|
|
|
+ raise DeviceQualityInvalid("unsupported quality rule code")
|
|
|
+
|
|
|
+ def run(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ actor_uid: str,
|
|
|
+ source_uid: str | None = None,
|
|
|
+ ) -> DeviceQualityRunRecord:
|
|
|
+ actor = _required_actor(actor_uid)
|
|
|
+ source = _optional_source_uid(source_uid)
|
|
|
+ active = self.repository.active_version()
|
|
|
+ if active is None:
|
|
|
+ raise DeviceQualityConflict("a published quality policy is required")
|
|
|
+ assets, total = self.repository.load_assets(
|
|
|
+ source_uid=source,
|
|
|
+ limit=MAX_ASSETS + 1,
|
|
|
+ )
|
|
|
+ if total > MAX_ASSETS or len(assets) > MAX_ASSETS:
|
|
|
+ raise DeviceQualityInvalid("quality run exceeds the 5,000 asset boundary")
|
|
|
+ code_sets = {
|
|
|
+ key: {_normalize_code(item) for item in value}
|
|
|
+ for key, value in self.repository.published_code_sets().items()
|
|
|
+ }
|
|
|
+ for code_type in ("fault", "cause", "action"):
|
|
|
+ code_sets.setdefault(code_type, set())
|
|
|
+ mapping_index = self._indexes(assets)
|
|
|
+ run_uid = self.uid_factory()
|
|
|
+ now = self.now_factory()
|
|
|
+ results = []
|
|
|
+ sampled_violations = []
|
|
|
+ violated_by_asset: dict[str, set[str]] = defaultdict(set)
|
|
|
+ applicable_by_asset: dict[str, set[str]] = defaultdict(set)
|
|
|
+ total_violations = 0
|
|
|
+ for rule in active.rules:
|
|
|
+ if not rule["enabled"]:
|
|
|
+ continue
|
|
|
+ applicable_assets = [
|
|
|
+ item
|
|
|
+ for item in assets
|
|
|
+ if _applicable(rule["code"], item, rule["parameters"])
|
|
|
+ ]
|
|
|
+ failures = []
|
|
|
+ for item in applicable_assets:
|
|
|
+ applicable_by_asset[item.uid].add(rule["code"])
|
|
|
+ failure = self._failure(
|
|
|
+ rule["code"],
|
|
|
+ item,
|
|
|
+ rule["parameters"],
|
|
|
+ mapping_index,
|
|
|
+ code_sets,
|
|
|
+ )
|
|
|
+ if failure is not None:
|
|
|
+ failures.append((item, failure))
|
|
|
+ violated_by_asset[item.uid].add(rule["code"])
|
|
|
+ evaluated_count = len(applicable_assets)
|
|
|
+ violation_count = len(failures)
|
|
|
+ pass_rate = (
|
|
|
+ 1.0
|
|
|
+ if evaluated_count == 0
|
|
|
+ else (evaluated_count - violation_count) / evaluated_count
|
|
|
+ )
|
|
|
+ sampled = failures[:MAX_SAMPLES_PER_RULE]
|
|
|
+ for item, (field_name, message, evidence) in sampled:
|
|
|
+ mapping = _mapping_for(item)
|
|
|
+ sampled_violations.append(
|
|
|
+ DeviceQualityViolationRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ run_uid=run_uid,
|
|
|
+ rule_code=rule["code"],
|
|
|
+ severity=rule["severity"],
|
|
|
+ asset_uid=item.uid,
|
|
|
+ field_name=field_name,
|
|
|
+ source_uid=mapping.source_uid if mapping else None,
|
|
|
+ source_mapping_uid=mapping.uid if mapping else None,
|
|
|
+ message=message,
|
|
|
+ evidence=evidence,
|
|
|
+ created_at=now,
|
|
|
+ expires_at=now + timedelta(days=SAMPLE_RETENTION_DAYS),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ results.append(
|
|
|
+ DeviceQualityRuleResultRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ run_uid=run_uid,
|
|
|
+ rule_code=rule["code"],
|
|
|
+ severity=rule["severity"],
|
|
|
+ weight=float(rule["weight"]),
|
|
|
+ status=(
|
|
|
+ "not_applicable"
|
|
|
+ if evaluated_count == 0
|
|
|
+ else ("passed" if violation_count == 0 else "violated")
|
|
|
+ ),
|
|
|
+ evaluated_count=evaluated_count,
|
|
|
+ violation_count=violation_count,
|
|
|
+ sampled_count=len(sampled),
|
|
|
+ pass_rate=round(pass_rate, 6),
|
|
|
+ weighted_score=round(rule["weight"] * pass_rate, 6),
|
|
|
+ created_at=now,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ total_violations += violation_count
|
|
|
+ asset_scores = []
|
|
|
+ rule_by_code = {item["code"]: item for item in active.rules}
|
|
|
+ for item in assets:
|
|
|
+ applicable_codes = applicable_by_asset.get(item.uid, set())
|
|
|
+ applicable_weight = sum(
|
|
|
+ rule_by_code[code]["weight"]
|
|
|
+ for code in applicable_codes
|
|
|
+ )
|
|
|
+ failed_weight = sum(
|
|
|
+ rule_by_code[code]["weight"]
|
|
|
+ for code in violated_by_asset.get(item.uid, set())
|
|
|
+ )
|
|
|
+ score = (
|
|
|
+ 100.0
|
|
|
+ if applicable_weight == 0
|
|
|
+ else 100.0 * (applicable_weight - failed_weight) / applicable_weight
|
|
|
+ )
|
|
|
+ asset_scores.append(
|
|
|
+ DeviceQualityAssetScoreRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ run_uid=run_uid,
|
|
|
+ asset_uid=item.uid,
|
|
|
+ asset_type=item.asset_type,
|
|
|
+ status=(
|
|
|
+ "not_applicable"
|
|
|
+ if applicable_weight == 0
|
|
|
+ else (
|
|
|
+ "passed"
|
|
|
+ if not violated_by_asset.get(item.uid)
|
|
|
+ else "violated"
|
|
|
+ )
|
|
|
+ ),
|
|
|
+ evaluated_rule_count=len(applicable_codes),
|
|
|
+ violation_count=len(violated_by_asset.get(item.uid, set())),
|
|
|
+ score=round(score, 2),
|
|
|
+ created_at=now,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ overall_score = round(
|
|
|
+ sum(item.weighted_score for item in results),
|
|
|
+ 2,
|
|
|
+ )
|
|
|
+ run = DeviceQualityRunRecord(
|
|
|
+ uid=run_uid,
|
|
|
+ policy_version_uid=active.uid,
|
|
|
+ policy_hash=active.content_hash,
|
|
|
+ source_uid=source,
|
|
|
+ status="success",
|
|
|
+ total_assets=len(assets),
|
|
|
+ total_violations=total_violations,
|
|
|
+ score=overall_score,
|
|
|
+ created_by=actor,
|
|
|
+ created_at=now,
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ created = self.repository.create_run(
|
|
|
+ run,
|
|
|
+ results,
|
|
|
+ sampled_violations,
|
|
|
+ asset_scores,
|
|
|
+ )
|
|
|
+ self.commit()
|
|
|
+ return created
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+
|
|
|
+ def runs(self, *, page: int, page_size: int):
|
|
|
+ page = _positive_integer(page, "page")
|
|
|
+ page_size = _positive_integer(page_size, "page_size")
|
|
|
+ if page_size > 100:
|
|
|
+ raise DeviceQualityInvalid("page_size exceeds 100")
|
|
|
+ return self.repository.list_runs(page=page, page_size=page_size)
|
|
|
+
|
|
|
+ def get_run(self, run_uid: str):
|
|
|
+ run = self.repository.get_run(run_uid)
|
|
|
+ if run is None:
|
|
|
+ raise DeviceQualityNotFound("quality run was not found")
|
|
|
+ return run, tuple(self.repository.list_rule_results(run_uid))
|
|
|
+
|
|
|
+ def violations(
|
|
|
+ self,
|
|
|
+ run_uid: str,
|
|
|
+ *,
|
|
|
+ rule_code: str | None,
|
|
|
+ page: int,
|
|
|
+ page_size: int,
|
|
|
+ ):
|
|
|
+ if self.repository.get_run(run_uid) is None:
|
|
|
+ raise DeviceQualityNotFound("quality run was not found")
|
|
|
+ if rule_code is not None and rule_code not in RULE_CODES:
|
|
|
+ raise DeviceQualityInvalid("unsupported quality rule code")
|
|
|
+ page = _positive_integer(page, "page")
|
|
|
+ page_size = _positive_integer(page_size, "page_size")
|
|
|
+ if page_size > 100:
|
|
|
+ raise DeviceQualityInvalid("page_size exceeds 100")
|
|
|
+ return self.repository.list_violations(
|
|
|
+ run_uid,
|
|
|
+ rule_code=rule_code,
|
|
|
+ page=page,
|
|
|
+ page_size=page_size,
|
|
|
+ )
|
|
|
+
|
|
|
+ def asset_scores(self, run_uid: str, *, page: int, page_size: int):
|
|
|
+ if self.repository.get_run(run_uid) is None:
|
|
|
+ raise DeviceQualityNotFound("quality run was not found")
|
|
|
+ page = _positive_integer(page, "page")
|
|
|
+ page_size = _positive_integer(page_size, "page_size")
|
|
|
+ if page_size > 1_000:
|
|
|
+ raise DeviceQualityInvalid("page_size exceeds 1,000")
|
|
|
+ return self.repository.list_asset_scores(
|
|
|
+ run_uid,
|
|
|
+ page=page,
|
|
|
+ page_size=page_size,
|
|
|
+ )
|