Просмотр исходного кода

feat: add governed device quality workbench

马小龙 3 недель назад
Родитель
Сommit
73991026d1
41 измененных файлов с 7960 добавлено и 14 удалено
  1. 329 0
      app/api/data_development/routes.py
  2. 1001 0
      app/core/data_research/device_quality.py
  3. 478 0
      app/core/data_research/device_quality_repository.py
  4. 20 0
      app/core/data_research/errors.py
  5. 2 1
      app/core/governance/responsibilities.py
  6. 16 0
      app/core/system/permissions.py
  7. 295 0
      app/models/data_research.py
  8. 329 0
      deployment/app/api/data_development/routes.py
  9. 1001 0
      deployment/app/core/data_research/device_quality.py
  10. 478 0
      deployment/app/core/data_research/device_quality_repository.py
  11. 20 0
      deployment/app/core/data_research/errors.py
  12. 17 0
      deployment/app/core/governance/__init__.py
  13. 387 0
      deployment/app/core/governance/responsibilities.py
  14. 16 0
      deployment/app/core/system/permissions.py
  15. 295 0
      deployment/app/models/data_research.py
  16. 80 0
      deployment/migrations/versions/20260729_270_governance_responsibilities.py
  17. 45 0
      deployment/migrations/versions/20260729_280_catalog_ingestion_execution.py
  18. 91 0
      deployment/migrations/versions/20260729_290_device_asset_catalog.py
  19. 91 0
      deployment/migrations/versions/20260729_300_device_semantics.py
  20. 119 0
      deployment/migrations/versions/20260729_310_device_entity_resolution.py
  21. 167 0
      deployment/migrations/versions/20260729_320_device_quality.py
  22. 39 0
      deployment/migrations/versions/20260729_330_device_quality_responsibility_type.py
  23. 1 0
      docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md
  24. 12 10
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  25. 7 0
      docs/architecture/DATA_MODEL.md
  26. 241 1
      docs/architecture/OPENAPI.yaml
  27. 212 0
      docs/superpowers/plans/2026-07-29-wp07-device-quality.md
  28. 49 1
      frontend/src/api/dataDevelopment.js
  29. 12 0
      frontend/src/router/routes.js
  30. 657 0
      frontend/src/views/dataGovernance/development/deviceQuality.vue
  31. 80 0
      frontend/src/views/dataGovernance/development/deviceQualityModel.js
  32. 1 0
      frontend/src/views/dataGovernance/development/index.vue
  33. 53 0
      frontend/tests/device-quality-model.test.mjs
  34. 167 0
      migrations/versions/20260729_320_device_quality.py
  35. 39 0
      migrations/versions/20260729_330_device_quality_responsibility_type.py
  36. 12 1
      tests/core/governance/test_responsibilities.py
  37. 542 0
      tests/data_research/test_device_quality.py
  38. 283 0
      tests/data_research/test_device_quality_api.py
  39. 215 0
      tests/integration/test_device_quality_postgres.py
  40. 44 0
      tests/test_database_migrations.py
  41. 17 0
      tests/test_permission_matrix.py

+ 329 - 0
app/api/data_development/routes.py

@@ -212,6 +212,47 @@ def get_device_entity_resolution_service():
     )
 
 
+def get_device_quality_service():
+    from app.core.data_research.device_quality import DeviceQualityService
+    from app.core.data_research.device_quality_repository import (
+        SqlAlchemyDeviceQualityRepository,
+    )
+    from app.core.data_research.errors import DeviceQualityForbidden
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+
+    def assert_accountable(actor_uid):
+        matrix = responsibilities.get(
+            "device_quality",
+            "DEVICE_QUALITY",
+        )
+        accountable = [
+            item
+            for item in matrix.get("assignments", [])
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        ]
+        if len(accountable) != 1 or str(accountable[0].get("user_id")) != str(
+            actor_uid
+        ):
+            raise DeviceQualityForbidden(
+                "only the accountable device quality asset manager may publish"
+            )
+
+    return DeviceQualityService(
+        SqlAlchemyDeviceQualityRepository(db.session),
+        publish_authorizer=assert_accountable,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def get_candidate_decision_service():
     from app.core.data_research.candidate_decisions import CandidateDecisionService
     from app.core.data_research.data_elements import DataElementService
@@ -665,6 +706,94 @@ def _device_entity_generation(result):
     }
 
 
+def _device_quality_version(record):
+    if record is None:
+        return None
+    return {
+        "uid": str(record.uid),
+        "profile_uid": str(record.profile_uid),
+        "version": int(record.version),
+        "status": record.status,
+        "rules": [dict(item) for item in record.rules],
+        "content_hash": record.content_hash,
+        "created_by": record.created_by,
+        "created_at": _iso(record.created_at),
+        "published_by": record.published_by,
+        "published_at": _iso(record.published_at),
+    }
+
+
+def _device_quality_run(record):
+    return {
+        "uid": str(record.uid),
+        "policy_version_uid": str(record.policy_version_uid),
+        "policy_hash": record.policy_hash,
+        "source_uid": (
+            str(record.source_uid) if record.source_uid else None
+        ),
+        "status": record.status,
+        "total_assets": int(record.total_assets),
+        "total_violations": int(record.total_violations),
+        "score": float(record.score),
+        "created_by": record.created_by,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_quality_rule_result(record):
+    return {
+        "uid": str(record.uid),
+        "run_uid": str(record.run_uid),
+        "rule_code": record.rule_code,
+        "severity": record.severity,
+        "weight": float(record.weight),
+        "status": record.status,
+        "evaluated_count": int(record.evaluated_count),
+        "violation_count": int(record.violation_count),
+        "sampled_count": int(record.sampled_count),
+        "pass_rate": float(record.pass_rate),
+        "weighted_score": float(record.weighted_score),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_quality_violation(record):
+    return {
+        "uid": str(record.uid),
+        "run_uid": str(record.run_uid),
+        "rule_code": record.rule_code,
+        "severity": record.severity,
+        "asset_uid": str(record.asset_uid),
+        "field_name": record.field_name,
+        "source_uid": (
+            str(record.source_uid) if record.source_uid else None
+        ),
+        "source_mapping_uid": (
+            str(record.source_mapping_uid)
+            if record.source_mapping_uid
+            else None
+        ),
+        "message": record.message,
+        "evidence": dict(record.evidence or {}),
+        "created_at": _iso(record.created_at),
+        "expires_at": _iso(record.expires_at),
+    }
+
+
+def _device_quality_asset_score(record):
+    return {
+        "uid": str(record.uid),
+        "run_uid": str(record.run_uid),
+        "asset_uid": str(record.asset_uid),
+        "asset_type": record.asset_type,
+        "status": record.status,
+        "evaluated_rule_count": int(record.evaluated_rule_count),
+        "violation_count": int(record.violation_count),
+        "score": float(record.score),
+        "created_at": _iso(record.created_at),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -1293,6 +1422,206 @@ def list_device_entity_rollbacks(merge_uid):
         return _error(error)
 
 
+@bp.route("/device-quality/profile", methods=["GET"])
+def get_device_quality_profile():
+    try:
+        result = get_device_quality_service().profile()
+        return jsonify(
+            success(
+                {
+                    "profile_uid": result["profile_uid"],
+                    "name": result["name"],
+                    "latest_version": _device_quality_version(
+                        result["latest_version"]
+                    ),
+                    "active_version": _device_quality_version(
+                        result["active_version"]
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/bootstrap", methods=["POST"])
+def bootstrap_device_quality():
+    try:
+        record = get_device_quality_service().bootstrap(
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_quality_version(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/profile/versions", methods=["POST"])
+def revise_device_quality_profile():
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_device_quality_service().revise(
+            rules=body.get("rules"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_quality_version(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/profile/versions", methods=["GET"])
+def list_device_quality_versions():
+    try:
+        records = get_device_quality_service().versions()
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_version(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/profile/versions/<version_uid>/publish",
+    methods=["POST"],
+)
+def publish_device_quality_version(version_uid):
+    try:
+        record = get_device_quality_service().publish(
+            version_uid,
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_quality_version(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/runs", methods=["POST"])
+def run_device_quality():
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_device_quality_service().run(
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+            source_uid=body.get("source_uid"),
+        )
+        return jsonify(success(_device_quality_run(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/runs", methods=["GET"])
+def list_device_quality_runs():
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_quality_service().runs(
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_run(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/runs/<run_uid>", methods=["GET"])
+def get_device_quality_run(run_uid):
+    try:
+        record, results = get_device_quality_service().get_run(run_uid)
+        return jsonify(
+            success(
+                {
+                    **_device_quality_run(record),
+                    "rule_results": [
+                        _device_quality_rule_result(item)
+                        for item in results
+                    ],
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/runs/<run_uid>/violations",
+    methods=["GET"],
+)
+def list_device_quality_violations(run_uid):
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_quality_service().violations(
+            run_uid,
+            rule_code=request.args.get("rule_code"),
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_violation(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/runs/<run_uid>/asset-scores",
+    methods=["GET"],
+)
+def list_device_quality_asset_scores(run_uid):
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_quality_service().asset_scores(
+            run_uid,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_asset_score(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 1001 - 0
app/core/data_research/device_quality.py

@@ -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,
+        )

+ 478 - 0
app/core/data_research/device_quality_repository.py

@@ -0,0 +1,478 @@
+"""SQLAlchemy persistence for device-domain quality policy and evidence."""
+
+from __future__ import annotations
+
+from sqlalchemy import func, text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_research.device_quality import (
+    DeviceQualityAssetScoreRecord,
+    DeviceQualityAssetSnapshot,
+    DeviceQualityPolicyVersionRecord,
+    DeviceQualityRuleResultRecord,
+    DeviceQualityRunRecord,
+    DeviceQualitySourceMapping,
+    DeviceQualityViolationRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceAssetSourceMapping,
+    DeviceQualityAssetScore,
+    DeviceQualityProfile,
+    DeviceQualityProfileVersion,
+    DeviceQualityRuleResult,
+    DeviceQualityRun,
+    DeviceQualityViolationSample,
+    DeviceSemanticCode,
+)
+
+PROFILE_CODE = "DEVICE_QUALITY"
+
+
+class SqlAlchemyDeviceQualityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @property
+    def profile_uid(self):
+        model = (
+            self.session.query(DeviceQualityProfile)
+            .filter_by(code=PROFILE_CODE)
+            .first()
+        )
+        return str(model.uid) if model is not None else None
+
+    @staticmethod
+    def _version(model):
+        return DeviceQualityPolicyVersionRecord(
+            uid=str(model.uid),
+            profile_uid=str(model.profile_uid),
+            version=int(model.version),
+            status=model.status,
+            rules=tuple(dict(item) for item in (model.rules or [])),
+            content_hash=model.content_hash,
+            created_by=model.created_by,
+            created_at=model.created_at,
+            published_by=model.published_by,
+            published_at=model.published_at,
+        )
+
+    @staticmethod
+    def _run(model):
+        return DeviceQualityRunRecord(
+            uid=str(model.uid),
+            policy_version_uid=str(model.policy_version_uid),
+            policy_hash=model.policy_hash,
+            source_uid=str(model.source_uid) if model.source_uid else None,
+            status=model.status,
+            total_assets=int(model.total_assets),
+            total_violations=int(model.total_violations),
+            score=float(model.score),
+            created_by=model.created_by,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _result(model):
+        return DeviceQualityRuleResultRecord(
+            uid=str(model.uid),
+            run_uid=str(model.run_uid),
+            rule_code=model.rule_code,
+            severity=model.severity,
+            weight=float(model.weight),
+            status=model.status,
+            evaluated_count=int(model.evaluated_count),
+            violation_count=int(model.violation_count),
+            sampled_count=int(model.sampled_count),
+            pass_rate=float(model.pass_rate),
+            weighted_score=float(model.weighted_score),
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _violation(model):
+        return DeviceQualityViolationRecord(
+            uid=str(model.uid),
+            run_uid=str(model.run_uid),
+            rule_code=model.rule_code,
+            severity=model.severity,
+            asset_uid=str(model.asset_uid),
+            field_name=model.field_name,
+            source_uid=str(model.source_uid) if model.source_uid else None,
+            source_mapping_uid=(
+                str(model.source_mapping_uid)
+                if model.source_mapping_uid
+                else None
+            ),
+            message=model.message,
+            evidence=dict(model.evidence or {}),
+            created_at=model.created_at,
+            expires_at=model.expires_at,
+        )
+
+    @staticmethod
+    def _asset_score(model):
+        return DeviceQualityAssetScoreRecord(
+            uid=str(model.uid),
+            run_uid=str(model.run_uid),
+            asset_uid=str(model.asset_uid),
+            asset_type=model.asset_type,
+            status=model.status,
+            evaluated_rule_count=int(model.evaluated_rule_count),
+            violation_count=int(model.violation_count),
+            score=float(model.score),
+            created_at=model.created_at,
+        )
+
+    def ensure_profile(self, *, name, actor_uid):
+        self.session.execute(
+            text(
+                "SELECT pg_advisory_xact_lock("
+                "hashtext('device-quality:profile'))"
+            )
+        )
+        model = (
+            self.session.query(DeviceQualityProfile)
+            .filter_by(code=PROFILE_CODE)
+            .first()
+        )
+        if model is None:
+            model = DeviceQualityProfile(
+                uid=new_governance_uid(),
+                code=PROFILE_CODE,
+                name=str(name),
+                created_by=str(actor_uid),
+            )
+            self.session.add(model)
+            self.session.flush()
+        return str(model.uid)
+
+    def latest_version(self):
+        model = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(DeviceQualityProfile.code == PROFILE_CODE)
+            .order_by(DeviceQualityProfileVersion.version.desc())
+            .first()
+        )
+        return self._version(model) if model is not None else None
+
+    def find_version_by_hash(self, content_hash):
+        model = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(
+                DeviceQualityProfile.code == PROFILE_CODE,
+                DeviceQualityProfileVersion.content_hash
+                == str(content_hash),
+            )
+            .first()
+        )
+        return self._version(model) if model is not None else None
+
+    def create_version(self, record):
+        model = DeviceQualityProfileVersion(
+            uid=record.uid,
+            profile_uid=record.profile_uid,
+            version=record.version,
+            status=record.status,
+            rules=list(record.rules),
+            content_hash=record.content_hash,
+            created_by=record.created_by,
+            created_at=record.created_at,
+            published_by=record.published_by,
+            published_at=record.published_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._version(model)
+
+    def get_version(self, version_uid, *, for_update=False):
+        query = self.session.query(DeviceQualityProfileVersion).filter_by(
+            uid=str(version_uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._version(model) if model is not None else None
+
+    def list_versions(self):
+        models = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(DeviceQualityProfile.code == PROFILE_CODE)
+            .order_by(DeviceQualityProfileVersion.version.desc())
+            .all()
+        )
+        return [self._version(model) for model in models]
+
+    def active_version(self):
+        model = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(
+                DeviceQualityProfile.code == PROFILE_CODE,
+                DeviceQualityProfileVersion.status == "published",
+            )
+            .order_by(DeviceQualityProfileVersion.version.desc())
+            .first()
+        )
+        return self._version(model) if model is not None else None
+
+    def publish_version(self, record, *, actor_uid, published_at):
+        self.session.query(DeviceQualityProfileVersion).filter(
+            DeviceQualityProfileVersion.profile_uid == record.profile_uid,
+            DeviceQualityProfileVersion.status == "published",
+        ).update(
+            {"status": "superseded"},
+            synchronize_session=False,
+        )
+        model = self.session.get(
+            DeviceQualityProfileVersion,
+            str(record.uid),
+        )
+        model.status = "published"
+        model.published_by = str(actor_uid)
+        model.published_at = published_at
+        self.session.flush()
+        return self._version(model)
+
+    @staticmethod
+    def _asset(model, mappings):
+        return DeviceQualityAssetSnapshot(
+            uid=str(model.uid),
+            asset_type=model.asset_type,
+            name=model.name,
+            status=model.status,
+            current_version=int(model.current_version),
+            location=model.location,
+            organization=model.organization,
+            responsible_person=model.responsible_person,
+            attributes=dict(model.attributes or {}),
+            mappings=tuple(
+                DeviceQualitySourceMapping(
+                    uid=str(item.uid),
+                    source_uid=str(item.source_uid),
+                    source_entity=item.source_entity,
+                    asset_type=item.asset_type,
+                    source_code=item.source_code,
+                )
+                for item in mappings
+            ),
+        )
+
+    def load_assets(self, *, source_uid, limit):
+        query = self.session.query(DeviceAsset).filter_by(status="active")
+        if source_uid is not None:
+            query = query.join(
+                DeviceAssetSourceMapping,
+                DeviceAssetSourceMapping.asset_uid == DeviceAsset.uid,
+            ).filter(
+                DeviceAssetSourceMapping.source_uid == str(source_uid)
+            )
+        total = (
+            query.with_entities(func.count(func.distinct(DeviceAsset.uid)))
+            .scalar()
+            or 0
+        )
+        models = (
+            query.distinct()
+            .order_by(DeviceAsset.uid.asc())
+            .limit(int(limit))
+            .all()
+        )
+        asset_uids = [str(model.uid) for model in models]
+        mapping_models = (
+            self.session.query(DeviceAssetSourceMapping)
+            .filter(DeviceAssetSourceMapping.asset_uid.in_(asset_uids))
+            .order_by(
+                DeviceAssetSourceMapping.asset_uid.asc(),
+                DeviceAssetSourceMapping.uid.asc(),
+            )
+            .all()
+            if asset_uids
+            else []
+        )
+        by_asset = {}
+        for item in mapping_models:
+            by_asset.setdefault(str(item.asset_uid), []).append(item)
+        return [
+            self._asset(model, by_asset.get(str(model.uid), []))
+            for model in models
+        ], int(total)
+
+    def published_code_sets(self):
+        rows = (
+            self.session.query(
+                DeviceSemanticCode.code_type,
+                DeviceSemanticCode.canonical_code,
+            )
+            .filter(DeviceSemanticCode.status == "published")
+            .all()
+        )
+        result = {"fault": set(), "cause": set(), "action": set()}
+        for code_type, canonical_code in rows:
+            result[str(code_type)].add(str(canonical_code))
+        return result
+
+    def create_run(self, run, results, violations, asset_scores):
+        model = DeviceQualityRun(
+            uid=run.uid,
+            policy_version_uid=run.policy_version_uid,
+            policy_hash=run.policy_hash,
+            source_uid=run.source_uid,
+            status=run.status,
+            total_assets=run.total_assets,
+            total_violations=run.total_violations,
+            score=run.score,
+            created_by=run.created_by,
+            created_at=run.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        self.session.add_all(
+            [
+                DeviceQualityRuleResult(
+                    uid=item.uid,
+                    run_uid=item.run_uid,
+                    rule_code=item.rule_code,
+                    severity=item.severity,
+                    weight=item.weight,
+                    status=item.status,
+                    evaluated_count=item.evaluated_count,
+                    violation_count=item.violation_count,
+                    sampled_count=item.sampled_count,
+                    pass_rate=item.pass_rate,
+                    weighted_score=item.weighted_score,
+                    created_at=item.created_at,
+                )
+                for item in results
+            ]
+        )
+        self.session.add_all(
+            [
+                DeviceQualityViolationSample(
+                    uid=item.uid,
+                    run_uid=item.run_uid,
+                    rule_code=item.rule_code,
+                    severity=item.severity,
+                    asset_uid=item.asset_uid,
+                    field_name=item.field_name,
+                    source_uid=item.source_uid,
+                    source_mapping_uid=item.source_mapping_uid,
+                    message=item.message,
+                    evidence=item.evidence,
+                    created_at=item.created_at,
+                    expires_at=item.expires_at,
+                )
+                for item in violations
+            ]
+        )
+        self.session.add_all(
+            [
+                DeviceQualityAssetScore(
+                    uid=item.uid,
+                    run_uid=item.run_uid,
+                    asset_uid=item.asset_uid,
+                    asset_type=item.asset_type,
+                    status=item.status,
+                    evaluated_rule_count=item.evaluated_rule_count,
+                    violation_count=item.violation_count,
+                    score=item.score,
+                    created_at=item.created_at,
+                )
+                for item in asset_scores
+            ]
+        )
+        self.session.flush()
+        return self._run(model)
+
+    def list_runs(self, *, page, page_size):
+        query = self.session.query(DeviceQualityRun)
+        total = query.with_entities(
+            func.count(DeviceQualityRun.uid)
+        ).scalar() or 0
+        models = (
+            query.order_by(
+                DeviceQualityRun.created_at.desc(),
+                DeviceQualityRun.uid.desc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._run(model) for model in models], int(total)
+
+    def get_run(self, run_uid):
+        model = self.session.get(DeviceQualityRun, str(run_uid))
+        return self._run(model) if model is not None else None
+
+    def list_rule_results(self, run_uid):
+        models = (
+            self.session.query(DeviceQualityRuleResult)
+            .filter_by(run_uid=str(run_uid))
+            .order_by(DeviceQualityRuleResult.rule_code.asc())
+            .all()
+        )
+        return [self._result(model) for model in models]
+
+    def list_violations(
+        self,
+        run_uid,
+        *,
+        rule_code,
+        page,
+        page_size,
+    ):
+        query = self.session.query(DeviceQualityViolationSample).filter_by(
+            run_uid=str(run_uid)
+        )
+        if rule_code is not None:
+            query = query.filter_by(rule_code=str(rule_code))
+        total = query.with_entities(
+            func.count(DeviceQualityViolationSample.uid)
+        ).scalar() or 0
+        models = (
+            query.order_by(
+                DeviceQualityViolationSample.rule_code.asc(),
+                DeviceQualityViolationSample.asset_uid.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._violation(model) for model in models], int(total)
+
+    def list_asset_scores(self, run_uid, *, page, page_size):
+        query = self.session.query(DeviceQualityAssetScore).filter_by(
+            run_uid=str(run_uid)
+        )
+        total = query.with_entities(
+            func.count(DeviceQualityAssetScore.uid)
+        ).scalar() or 0
+        models = (
+            query.order_by(
+                DeviceQualityAssetScore.score.asc(),
+                DeviceQualityAssetScore.asset_uid.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._asset_score(model) for model in models], int(total)

+ 20 - 0
app/core/data_research/errors.py

@@ -98,3 +98,23 @@ class DeviceEntityNotFound(DataResearchError):
 class DeviceEntityConflict(DataResearchError):
     code = "DEVICE_ENTITY_CONFLICT"
     http_status = 409
+
+
+class DeviceQualityInvalid(DataResearchError):
+    code = "DEVICE_QUALITY_INVALID"
+    http_status = 422
+
+
+class DeviceQualityForbidden(DataResearchError):
+    code = "DEVICE_QUALITY_FORBIDDEN"
+    http_status = 403
+
+
+class DeviceQualityNotFound(DataResearchError):
+    code = "DEVICE_QUALITY_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceQualityConflict(DataResearchError):
+    code = "DEVICE_QUALITY_CONFLICT"
+    http_status = 409

+ 2 - 1
app/core/governance/responsibilities.py

@@ -11,13 +11,13 @@ from sqlalchemy import text
 
 from app.core.common.identifiers import new_governance_uid
 
-
 RESOURCE_TYPES = frozenset(
     {
         "business_domain",
         "device_asset",
         "device_ontology",
         "device_mapping",
+        "device_quality",
         "fault_classification",
         "quality_issue",
     }
@@ -27,6 +27,7 @@ DEVICE_RESOURCE_TYPES = frozenset(
         "device_asset",
         "device_ontology",
         "device_mapping",
+        "device_quality",
         "fault_classification",
     }
 )

+ 16 - 0
app/core/system/permissions.py

@@ -38,6 +38,9 @@ DEVICE_SEMANTICS_EDIT = "device-semantics:edit"
 DEVICE_SEMANTICS_REVIEW = "device-semantics:review"
 DEVICE_ENTITIES_EDIT = "device-entities:edit"
 DEVICE_ENTITIES_REVIEW = "device-entities:review"
+DEVICE_QUALITY_EDIT = "device-quality:edit"
+DEVICE_QUALITY_EXECUTE = "device-quality:execute"
+DEVICE_QUALITY_PUBLISH = "device-quality:publish"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -59,6 +62,8 @@ ROLE_PERMISSIONS = {
             DEVICE_ASSETS_EDIT,
             DEVICE_SEMANTICS_EDIT,
             DEVICE_ENTITIES_EDIT,
+            DEVICE_QUALITY_EDIT,
+            DEVICE_QUALITY_EXECUTE,
         }
     ),
     "admin": frozenset(
@@ -94,6 +99,9 @@ ROLE_PERMISSIONS = {
             DEVICE_SEMANTICS_REVIEW,
             DEVICE_ENTITIES_EDIT,
             DEVICE_ENTITIES_REVIEW,
+            DEVICE_QUALITY_EDIT,
+            DEVICE_QUALITY_EXECUTE,
+            DEVICE_QUALITY_PUBLISH,
         }
     ),
 }
@@ -170,6 +178,14 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path.endswith("/review") or path.endswith("/rollback"):
             return (DEVICE_ENTITIES_REVIEW,)
         return (DEVICE_ENTITIES_EDIT,)
+    if path.startswith("/api/development/v1/device-quality"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        if path.endswith("/publish"):
+            return (DEVICE_QUALITY_PUBLISH,)
+        if path == "/api/development/v1/device-quality/runs":
+            return (DEVICE_QUALITY_EXECUTE,)
+        return (DEVICE_QUALITY_EDIT,)
     if path.startswith("/api/development/v1/ingestion-jobs"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 295 - 0
app/models/data_research.py

@@ -718,6 +718,301 @@ class DeviceEntityMergeRollback(db.Model):
     )
 
 
+class DeviceQualityProfile(db.Model):
+    __tablename__ = "device_quality_profiles"
+    __table_args__ = ({"schema": "public"},)
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    code = db.Column(db.String(120), nullable=False, unique=True)
+    name = db.Column(db.String(300), nullable=False)
+    created_by = db.Column(db.String(120), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceQualityProfileVersion(db.Model):
+    __tablename__ = "device_quality_profile_versions"
+    __table_args__ = (
+        db.CheckConstraint(
+            "version > 0",
+            name="ck_device_quality_profile_version_number",
+        ),
+        db.CheckConstraint(
+            "status IN ('draft','published','superseded')",
+            name="ck_device_quality_profile_version_status",
+        ),
+        db.UniqueConstraint(
+            "profile_uid",
+            "version",
+            name="uq_device_quality_profile_version",
+        ),
+        db.UniqueConstraint(
+            "profile_uid",
+            "content_hash",
+            name="uq_device_quality_profile_hash",
+        ),
+        db.Index(
+            "uq_device_quality_active_profile",
+            "profile_uid",
+            unique=True,
+            postgresql_where=db.text("status = 'published'"),
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    profile_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_quality_profiles.uid",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    version = db.Column(db.Integer, nullable=False)
+    status = db.Column(db.String(20), nullable=False, default="draft")
+    rules = db.Column(JSONB, nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    created_by = db.Column(db.String(120), nullable=False)
+    published_by = db.Column(db.String(120))
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    published_at = db.Column(db.DateTime(timezone=True))
+
+
+class DeviceQualityRun(db.Model):
+    __tablename__ = "device_quality_runs"
+    __table_args__ = (
+        db.CheckConstraint(
+            "status IN ('success','failed')",
+            name="ck_device_quality_run_status",
+        ),
+        db.CheckConstraint(
+            "total_assets >= 0 AND total_violations >= 0",
+            name="ck_device_quality_run_counts",
+        ),
+        db.CheckConstraint(
+            "score >= 0 AND score <= 100",
+            name="ck_device_quality_run_score",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    policy_version_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_quality_profile_versions.uid",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    policy_hash = db.Column(db.String(64), nullable=False)
+    source_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.ingestion_sources.uid",
+            ondelete="RESTRICT",
+        ),
+    )
+    status = db.Column(db.String(20), nullable=False)
+    total_assets = db.Column(db.Integer, nullable=False)
+    total_violations = db.Column(db.Integer, nullable=False)
+    score = db.Column(db.Float, nullable=False)
+    created_by = db.Column(db.String(120), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceQualityRuleResult(db.Model):
+    __tablename__ = "device_quality_rule_results"
+    __table_args__ = (
+        db.CheckConstraint(
+            "severity IN ('info','warning','error','critical')",
+            name="ck_device_quality_rule_result_severity",
+        ),
+        db.CheckConstraint(
+            "status IN ('passed','violated','not_applicable')",
+            name="ck_device_quality_rule_result_status",
+        ),
+        db.CheckConstraint(
+            "evaluated_count >= 0 AND violation_count >= 0 "
+            "AND sampled_count >= 0 "
+            "AND violation_count <= evaluated_count "
+            "AND sampled_count <= violation_count",
+            name="ck_device_quality_rule_result_counts",
+        ),
+        db.CheckConstraint(
+            "pass_rate >= 0 AND pass_rate <= 1 "
+            "AND weighted_score >= 0 AND weighted_score <= weight "
+            "AND weight >= 0 AND weight <= 100",
+            name="ck_device_quality_rule_result_scores",
+        ),
+        db.UniqueConstraint(
+            "run_uid",
+            "rule_code",
+            name="uq_device_quality_run_rule",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    rule_code = db.Column(db.String(120), nullable=False)
+    severity = db.Column(db.String(20), nullable=False)
+    weight = db.Column(db.Float, nullable=False)
+    status = db.Column(db.String(30), nullable=False)
+    evaluated_count = db.Column(db.Integer, nullable=False)
+    violation_count = db.Column(db.Integer, nullable=False)
+    sampled_count = db.Column(db.Integer, nullable=False)
+    pass_rate = db.Column(db.Float, nullable=False)
+    weighted_score = db.Column(db.Float, nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceQualityViolationSample(db.Model):
+    __tablename__ = "device_quality_violation_samples"
+    __table_args__ = (
+        db.CheckConstraint(
+            "severity IN ('info','warning','error','critical')",
+            name="ck_device_quality_violation_severity",
+        ),
+        db.UniqueConstraint(
+            "run_uid",
+            "rule_code",
+            "asset_uid",
+            "field_name",
+            name="uq_device_quality_violation_identity",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    rule_code = db.Column(db.String(120), nullable=False)
+    severity = db.Column(db.String(20), nullable=False)
+    asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    field_name = db.Column(db.String(300), nullable=False)
+    source_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.ingestion_sources.uid",
+            ondelete="RESTRICT",
+        ),
+    )
+    source_mapping_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_asset_source_mappings.uid",
+            ondelete="RESTRICT",
+        ),
+    )
+    message = db.Column(db.String(1000), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    expires_at = db.Column(db.DateTime(timezone=True), nullable=False)
+
+
+class DeviceQualityAssetScore(db.Model):
+    __tablename__ = "device_quality_asset_scores"
+    __table_args__ = (
+        db.CheckConstraint(
+            "status IN ('passed','violated','not_applicable')",
+            name="ck_device_quality_asset_score_status",
+        ),
+        db.CheckConstraint(
+            "evaluated_rule_count >= 0 AND violation_count >= 0 "
+            "AND violation_count <= evaluated_rule_count",
+            name="ck_device_quality_asset_score_counts",
+        ),
+        db.CheckConstraint(
+            "score >= 0 AND score <= 100",
+            name="ck_device_quality_asset_score_value",
+        ),
+        db.UniqueConstraint(
+            "run_uid",
+            "asset_uid",
+            name="uq_device_quality_run_asset",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    asset_type = db.Column(db.String(40), nullable=False)
+    status = db.Column(db.String(30), nullable=False)
+    evaluated_rule_count = db.Column(db.Integer, nullable=False)
+    violation_count = db.Column(db.Integer, nullable=False)
+    score = db.Column(db.Float, nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

+ 329 - 0
deployment/app/api/data_development/routes.py

@@ -212,6 +212,47 @@ def get_device_entity_resolution_service():
     )
 
 
+def get_device_quality_service():
+    from app.core.data_research.device_quality import DeviceQualityService
+    from app.core.data_research.device_quality_repository import (
+        SqlAlchemyDeviceQualityRepository,
+    )
+    from app.core.data_research.errors import DeviceQualityForbidden
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+
+    def assert_accountable(actor_uid):
+        matrix = responsibilities.get(
+            "device_quality",
+            "DEVICE_QUALITY",
+        )
+        accountable = [
+            item
+            for item in matrix.get("assignments", [])
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        ]
+        if len(accountable) != 1 or str(accountable[0].get("user_id")) != str(
+            actor_uid
+        ):
+            raise DeviceQualityForbidden(
+                "only the accountable device quality asset manager may publish"
+            )
+
+    return DeviceQualityService(
+        SqlAlchemyDeviceQualityRepository(db.session),
+        publish_authorizer=assert_accountable,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
 def get_candidate_decision_service():
     from app.core.data_research.candidate_decisions import CandidateDecisionService
     from app.core.data_research.data_elements import DataElementService
@@ -665,6 +706,94 @@ def _device_entity_generation(result):
     }
 
 
+def _device_quality_version(record):
+    if record is None:
+        return None
+    return {
+        "uid": str(record.uid),
+        "profile_uid": str(record.profile_uid),
+        "version": int(record.version),
+        "status": record.status,
+        "rules": [dict(item) for item in record.rules],
+        "content_hash": record.content_hash,
+        "created_by": record.created_by,
+        "created_at": _iso(record.created_at),
+        "published_by": record.published_by,
+        "published_at": _iso(record.published_at),
+    }
+
+
+def _device_quality_run(record):
+    return {
+        "uid": str(record.uid),
+        "policy_version_uid": str(record.policy_version_uid),
+        "policy_hash": record.policy_hash,
+        "source_uid": (
+            str(record.source_uid) if record.source_uid else None
+        ),
+        "status": record.status,
+        "total_assets": int(record.total_assets),
+        "total_violations": int(record.total_violations),
+        "score": float(record.score),
+        "created_by": record.created_by,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_quality_rule_result(record):
+    return {
+        "uid": str(record.uid),
+        "run_uid": str(record.run_uid),
+        "rule_code": record.rule_code,
+        "severity": record.severity,
+        "weight": float(record.weight),
+        "status": record.status,
+        "evaluated_count": int(record.evaluated_count),
+        "violation_count": int(record.violation_count),
+        "sampled_count": int(record.sampled_count),
+        "pass_rate": float(record.pass_rate),
+        "weighted_score": float(record.weighted_score),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_quality_violation(record):
+    return {
+        "uid": str(record.uid),
+        "run_uid": str(record.run_uid),
+        "rule_code": record.rule_code,
+        "severity": record.severity,
+        "asset_uid": str(record.asset_uid),
+        "field_name": record.field_name,
+        "source_uid": (
+            str(record.source_uid) if record.source_uid else None
+        ),
+        "source_mapping_uid": (
+            str(record.source_mapping_uid)
+            if record.source_mapping_uid
+            else None
+        ),
+        "message": record.message,
+        "evidence": dict(record.evidence or {}),
+        "created_at": _iso(record.created_at),
+        "expires_at": _iso(record.expires_at),
+    }
+
+
+def _device_quality_asset_score(record):
+    return {
+        "uid": str(record.uid),
+        "run_uid": str(record.run_uid),
+        "asset_uid": str(record.asset_uid),
+        "asset_type": record.asset_type,
+        "status": record.status,
+        "evaluated_rule_count": int(record.evaluated_rule_count),
+        "violation_count": int(record.violation_count),
+        "score": float(record.score),
+        "created_at": _iso(record.created_at),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -1293,6 +1422,206 @@ def list_device_entity_rollbacks(merge_uid):
         return _error(error)
 
 
+@bp.route("/device-quality/profile", methods=["GET"])
+def get_device_quality_profile():
+    try:
+        result = get_device_quality_service().profile()
+        return jsonify(
+            success(
+                {
+                    "profile_uid": result["profile_uid"],
+                    "name": result["name"],
+                    "latest_version": _device_quality_version(
+                        result["latest_version"]
+                    ),
+                    "active_version": _device_quality_version(
+                        result["active_version"]
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/bootstrap", methods=["POST"])
+def bootstrap_device_quality():
+    try:
+        record = get_device_quality_service().bootstrap(
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_quality_version(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/profile/versions", methods=["POST"])
+def revise_device_quality_profile():
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_device_quality_service().revise(
+            rules=body.get("rules"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_quality_version(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/profile/versions", methods=["GET"])
+def list_device_quality_versions():
+    try:
+        records = get_device_quality_service().versions()
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_version(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/profile/versions/<version_uid>/publish",
+    methods=["POST"],
+)
+def publish_device_quality_version(version_uid):
+    try:
+        record = get_device_quality_service().publish(
+            version_uid,
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_quality_version(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/runs", methods=["POST"])
+def run_device_quality():
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_device_quality_service().run(
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+            source_uid=body.get("source_uid"),
+        )
+        return jsonify(success(_device_quality_run(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/runs", methods=["GET"])
+def list_device_quality_runs():
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_quality_service().runs(
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_run(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/runs/<run_uid>", methods=["GET"])
+def get_device_quality_run(run_uid):
+    try:
+        record, results = get_device_quality_service().get_run(run_uid)
+        return jsonify(
+            success(
+                {
+                    **_device_quality_run(record),
+                    "rule_results": [
+                        _device_quality_rule_result(item)
+                        for item in results
+                    ],
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/runs/<run_uid>/violations",
+    methods=["GET"],
+)
+def list_device_quality_violations(run_uid):
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_quality_service().violations(
+            run_uid,
+            rule_code=request.args.get("rule_code"),
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_violation(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/runs/<run_uid>/asset-scores",
+    methods=["GET"],
+)
+def list_device_quality_asset_scores(run_uid):
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_quality_service().asset_scores(
+            run_uid,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_asset_score(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

+ 1001 - 0
deployment/app/core/data_research/device_quality.py

@@ -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,
+        )

+ 478 - 0
deployment/app/core/data_research/device_quality_repository.py

@@ -0,0 +1,478 @@
+"""SQLAlchemy persistence for device-domain quality policy and evidence."""
+
+from __future__ import annotations
+
+from sqlalchemy import func, text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_research.device_quality import (
+    DeviceQualityAssetScoreRecord,
+    DeviceQualityAssetSnapshot,
+    DeviceQualityPolicyVersionRecord,
+    DeviceQualityRuleResultRecord,
+    DeviceQualityRunRecord,
+    DeviceQualitySourceMapping,
+    DeviceQualityViolationRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceAssetSourceMapping,
+    DeviceQualityAssetScore,
+    DeviceQualityProfile,
+    DeviceQualityProfileVersion,
+    DeviceQualityRuleResult,
+    DeviceQualityRun,
+    DeviceQualityViolationSample,
+    DeviceSemanticCode,
+)
+
+PROFILE_CODE = "DEVICE_QUALITY"
+
+
+class SqlAlchemyDeviceQualityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @property
+    def profile_uid(self):
+        model = (
+            self.session.query(DeviceQualityProfile)
+            .filter_by(code=PROFILE_CODE)
+            .first()
+        )
+        return str(model.uid) if model is not None else None
+
+    @staticmethod
+    def _version(model):
+        return DeviceQualityPolicyVersionRecord(
+            uid=str(model.uid),
+            profile_uid=str(model.profile_uid),
+            version=int(model.version),
+            status=model.status,
+            rules=tuple(dict(item) for item in (model.rules or [])),
+            content_hash=model.content_hash,
+            created_by=model.created_by,
+            created_at=model.created_at,
+            published_by=model.published_by,
+            published_at=model.published_at,
+        )
+
+    @staticmethod
+    def _run(model):
+        return DeviceQualityRunRecord(
+            uid=str(model.uid),
+            policy_version_uid=str(model.policy_version_uid),
+            policy_hash=model.policy_hash,
+            source_uid=str(model.source_uid) if model.source_uid else None,
+            status=model.status,
+            total_assets=int(model.total_assets),
+            total_violations=int(model.total_violations),
+            score=float(model.score),
+            created_by=model.created_by,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _result(model):
+        return DeviceQualityRuleResultRecord(
+            uid=str(model.uid),
+            run_uid=str(model.run_uid),
+            rule_code=model.rule_code,
+            severity=model.severity,
+            weight=float(model.weight),
+            status=model.status,
+            evaluated_count=int(model.evaluated_count),
+            violation_count=int(model.violation_count),
+            sampled_count=int(model.sampled_count),
+            pass_rate=float(model.pass_rate),
+            weighted_score=float(model.weighted_score),
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _violation(model):
+        return DeviceQualityViolationRecord(
+            uid=str(model.uid),
+            run_uid=str(model.run_uid),
+            rule_code=model.rule_code,
+            severity=model.severity,
+            asset_uid=str(model.asset_uid),
+            field_name=model.field_name,
+            source_uid=str(model.source_uid) if model.source_uid else None,
+            source_mapping_uid=(
+                str(model.source_mapping_uid)
+                if model.source_mapping_uid
+                else None
+            ),
+            message=model.message,
+            evidence=dict(model.evidence or {}),
+            created_at=model.created_at,
+            expires_at=model.expires_at,
+        )
+
+    @staticmethod
+    def _asset_score(model):
+        return DeviceQualityAssetScoreRecord(
+            uid=str(model.uid),
+            run_uid=str(model.run_uid),
+            asset_uid=str(model.asset_uid),
+            asset_type=model.asset_type,
+            status=model.status,
+            evaluated_rule_count=int(model.evaluated_rule_count),
+            violation_count=int(model.violation_count),
+            score=float(model.score),
+            created_at=model.created_at,
+        )
+
+    def ensure_profile(self, *, name, actor_uid):
+        self.session.execute(
+            text(
+                "SELECT pg_advisory_xact_lock("
+                "hashtext('device-quality:profile'))"
+            )
+        )
+        model = (
+            self.session.query(DeviceQualityProfile)
+            .filter_by(code=PROFILE_CODE)
+            .first()
+        )
+        if model is None:
+            model = DeviceQualityProfile(
+                uid=new_governance_uid(),
+                code=PROFILE_CODE,
+                name=str(name),
+                created_by=str(actor_uid),
+            )
+            self.session.add(model)
+            self.session.flush()
+        return str(model.uid)
+
+    def latest_version(self):
+        model = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(DeviceQualityProfile.code == PROFILE_CODE)
+            .order_by(DeviceQualityProfileVersion.version.desc())
+            .first()
+        )
+        return self._version(model) if model is not None else None
+
+    def find_version_by_hash(self, content_hash):
+        model = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(
+                DeviceQualityProfile.code == PROFILE_CODE,
+                DeviceQualityProfileVersion.content_hash
+                == str(content_hash),
+            )
+            .first()
+        )
+        return self._version(model) if model is not None else None
+
+    def create_version(self, record):
+        model = DeviceQualityProfileVersion(
+            uid=record.uid,
+            profile_uid=record.profile_uid,
+            version=record.version,
+            status=record.status,
+            rules=list(record.rules),
+            content_hash=record.content_hash,
+            created_by=record.created_by,
+            created_at=record.created_at,
+            published_by=record.published_by,
+            published_at=record.published_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._version(model)
+
+    def get_version(self, version_uid, *, for_update=False):
+        query = self.session.query(DeviceQualityProfileVersion).filter_by(
+            uid=str(version_uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._version(model) if model is not None else None
+
+    def list_versions(self):
+        models = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(DeviceQualityProfile.code == PROFILE_CODE)
+            .order_by(DeviceQualityProfileVersion.version.desc())
+            .all()
+        )
+        return [self._version(model) for model in models]
+
+    def active_version(self):
+        model = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(
+                DeviceQualityProfile.code == PROFILE_CODE,
+                DeviceQualityProfileVersion.status == "published",
+            )
+            .order_by(DeviceQualityProfileVersion.version.desc())
+            .first()
+        )
+        return self._version(model) if model is not None else None
+
+    def publish_version(self, record, *, actor_uid, published_at):
+        self.session.query(DeviceQualityProfileVersion).filter(
+            DeviceQualityProfileVersion.profile_uid == record.profile_uid,
+            DeviceQualityProfileVersion.status == "published",
+        ).update(
+            {"status": "superseded"},
+            synchronize_session=False,
+        )
+        model = self.session.get(
+            DeviceQualityProfileVersion,
+            str(record.uid),
+        )
+        model.status = "published"
+        model.published_by = str(actor_uid)
+        model.published_at = published_at
+        self.session.flush()
+        return self._version(model)
+
+    @staticmethod
+    def _asset(model, mappings):
+        return DeviceQualityAssetSnapshot(
+            uid=str(model.uid),
+            asset_type=model.asset_type,
+            name=model.name,
+            status=model.status,
+            current_version=int(model.current_version),
+            location=model.location,
+            organization=model.organization,
+            responsible_person=model.responsible_person,
+            attributes=dict(model.attributes or {}),
+            mappings=tuple(
+                DeviceQualitySourceMapping(
+                    uid=str(item.uid),
+                    source_uid=str(item.source_uid),
+                    source_entity=item.source_entity,
+                    asset_type=item.asset_type,
+                    source_code=item.source_code,
+                )
+                for item in mappings
+            ),
+        )
+
+    def load_assets(self, *, source_uid, limit):
+        query = self.session.query(DeviceAsset).filter_by(status="active")
+        if source_uid is not None:
+            query = query.join(
+                DeviceAssetSourceMapping,
+                DeviceAssetSourceMapping.asset_uid == DeviceAsset.uid,
+            ).filter(
+                DeviceAssetSourceMapping.source_uid == str(source_uid)
+            )
+        total = (
+            query.with_entities(func.count(func.distinct(DeviceAsset.uid)))
+            .scalar()
+            or 0
+        )
+        models = (
+            query.distinct()
+            .order_by(DeviceAsset.uid.asc())
+            .limit(int(limit))
+            .all()
+        )
+        asset_uids = [str(model.uid) for model in models]
+        mapping_models = (
+            self.session.query(DeviceAssetSourceMapping)
+            .filter(DeviceAssetSourceMapping.asset_uid.in_(asset_uids))
+            .order_by(
+                DeviceAssetSourceMapping.asset_uid.asc(),
+                DeviceAssetSourceMapping.uid.asc(),
+            )
+            .all()
+            if asset_uids
+            else []
+        )
+        by_asset = {}
+        for item in mapping_models:
+            by_asset.setdefault(str(item.asset_uid), []).append(item)
+        return [
+            self._asset(model, by_asset.get(str(model.uid), []))
+            for model in models
+        ], int(total)
+
+    def published_code_sets(self):
+        rows = (
+            self.session.query(
+                DeviceSemanticCode.code_type,
+                DeviceSemanticCode.canonical_code,
+            )
+            .filter(DeviceSemanticCode.status == "published")
+            .all()
+        )
+        result = {"fault": set(), "cause": set(), "action": set()}
+        for code_type, canonical_code in rows:
+            result[str(code_type)].add(str(canonical_code))
+        return result
+
+    def create_run(self, run, results, violations, asset_scores):
+        model = DeviceQualityRun(
+            uid=run.uid,
+            policy_version_uid=run.policy_version_uid,
+            policy_hash=run.policy_hash,
+            source_uid=run.source_uid,
+            status=run.status,
+            total_assets=run.total_assets,
+            total_violations=run.total_violations,
+            score=run.score,
+            created_by=run.created_by,
+            created_at=run.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        self.session.add_all(
+            [
+                DeviceQualityRuleResult(
+                    uid=item.uid,
+                    run_uid=item.run_uid,
+                    rule_code=item.rule_code,
+                    severity=item.severity,
+                    weight=item.weight,
+                    status=item.status,
+                    evaluated_count=item.evaluated_count,
+                    violation_count=item.violation_count,
+                    sampled_count=item.sampled_count,
+                    pass_rate=item.pass_rate,
+                    weighted_score=item.weighted_score,
+                    created_at=item.created_at,
+                )
+                for item in results
+            ]
+        )
+        self.session.add_all(
+            [
+                DeviceQualityViolationSample(
+                    uid=item.uid,
+                    run_uid=item.run_uid,
+                    rule_code=item.rule_code,
+                    severity=item.severity,
+                    asset_uid=item.asset_uid,
+                    field_name=item.field_name,
+                    source_uid=item.source_uid,
+                    source_mapping_uid=item.source_mapping_uid,
+                    message=item.message,
+                    evidence=item.evidence,
+                    created_at=item.created_at,
+                    expires_at=item.expires_at,
+                )
+                for item in violations
+            ]
+        )
+        self.session.add_all(
+            [
+                DeviceQualityAssetScore(
+                    uid=item.uid,
+                    run_uid=item.run_uid,
+                    asset_uid=item.asset_uid,
+                    asset_type=item.asset_type,
+                    status=item.status,
+                    evaluated_rule_count=item.evaluated_rule_count,
+                    violation_count=item.violation_count,
+                    score=item.score,
+                    created_at=item.created_at,
+                )
+                for item in asset_scores
+            ]
+        )
+        self.session.flush()
+        return self._run(model)
+
+    def list_runs(self, *, page, page_size):
+        query = self.session.query(DeviceQualityRun)
+        total = query.with_entities(
+            func.count(DeviceQualityRun.uid)
+        ).scalar() or 0
+        models = (
+            query.order_by(
+                DeviceQualityRun.created_at.desc(),
+                DeviceQualityRun.uid.desc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._run(model) for model in models], int(total)
+
+    def get_run(self, run_uid):
+        model = self.session.get(DeviceQualityRun, str(run_uid))
+        return self._run(model) if model is not None else None
+
+    def list_rule_results(self, run_uid):
+        models = (
+            self.session.query(DeviceQualityRuleResult)
+            .filter_by(run_uid=str(run_uid))
+            .order_by(DeviceQualityRuleResult.rule_code.asc())
+            .all()
+        )
+        return [self._result(model) for model in models]
+
+    def list_violations(
+        self,
+        run_uid,
+        *,
+        rule_code,
+        page,
+        page_size,
+    ):
+        query = self.session.query(DeviceQualityViolationSample).filter_by(
+            run_uid=str(run_uid)
+        )
+        if rule_code is not None:
+            query = query.filter_by(rule_code=str(rule_code))
+        total = query.with_entities(
+            func.count(DeviceQualityViolationSample.uid)
+        ).scalar() or 0
+        models = (
+            query.order_by(
+                DeviceQualityViolationSample.rule_code.asc(),
+                DeviceQualityViolationSample.asset_uid.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._violation(model) for model in models], int(total)
+
+    def list_asset_scores(self, run_uid, *, page, page_size):
+        query = self.session.query(DeviceQualityAssetScore).filter_by(
+            run_uid=str(run_uid)
+        )
+        total = query.with_entities(
+            func.count(DeviceQualityAssetScore.uid)
+        ).scalar() or 0
+        models = (
+            query.order_by(
+                DeviceQualityAssetScore.score.asc(),
+                DeviceQualityAssetScore.asset_uid.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._asset_score(model) for model in models], int(total)

+ 20 - 0
deployment/app/core/data_research/errors.py

@@ -98,3 +98,23 @@ class DeviceEntityNotFound(DataResearchError):
 class DeviceEntityConflict(DataResearchError):
     code = "DEVICE_ENTITY_CONFLICT"
     http_status = 409
+
+
+class DeviceQualityInvalid(DataResearchError):
+    code = "DEVICE_QUALITY_INVALID"
+    http_status = 422
+
+
+class DeviceQualityForbidden(DataResearchError):
+    code = "DEVICE_QUALITY_FORBIDDEN"
+    http_status = 403
+
+
+class DeviceQualityNotFound(DataResearchError):
+    code = "DEVICE_QUALITY_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceQualityConflict(DataResearchError):
+    code = "DEVICE_QUALITY_CONFLICT"
+    http_status = 409

+ 17 - 0
deployment/app/core/governance/__init__.py

@@ -0,0 +1,17 @@
+"""Governance responsibility and operating-model services."""
+
+from app.core.governance.responsibilities import (
+    ResponsibilityConflict,
+    ResponsibilityService,
+    ResponsibilityUserUnavailable,
+    ResponsibilityValidationError,
+    SqlAlchemyResponsibilityRepository,
+)
+
+__all__ = [
+    "ResponsibilityConflict",
+    "ResponsibilityService",
+    "ResponsibilityUserUnavailable",
+    "ResponsibilityValidationError",
+    "SqlAlchemyResponsibilityRepository",
+]

+ 387 - 0
deployment/app/core/governance/responsibilities.py

@@ -0,0 +1,387 @@
+"""Versioned RACI bindings for governed resources and device accountability."""
+
+from __future__ import annotations
+
+import json
+import uuid
+from dataclasses import asdict, dataclass
+from typing import Any
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+
+RESOURCE_TYPES = frozenset(
+    {
+        "business_domain",
+        "device_asset",
+        "device_ontology",
+        "device_mapping",
+        "device_quality",
+        "fault_classification",
+        "quality_issue",
+    }
+)
+DEVICE_RESOURCE_TYPES = frozenset(
+    {
+        "device_asset",
+        "device_ontology",
+        "device_mapping",
+        "device_quality",
+        "fault_classification",
+    }
+)
+RESPONSIBILITY_ROLES = frozenset(
+    {"domain_owner", "data_steward", "data_architect", "asset_manager"}
+)
+RACI_ROLES = frozenset({"responsible", "accountable", "consulted", "informed"})
+
+
+class ResponsibilityError(RuntimeError):
+    """Base error for responsibility-matrix operations."""
+
+
+class ResponsibilityValidationError(ResponsibilityError):
+    """The requested matrix violates its governed contract."""
+
+
+class ResponsibilityConflict(ResponsibilityError):
+    """The matrix changed after the caller loaded it."""
+
+
+class ResponsibilityUserUnavailable(ResponsibilityError):
+    """An assignment targets an unknown or disabled user."""
+
+
+@dataclass(frozen=True)
+class ResponsibilityAssignment:
+    user_id: str
+    responsibility_role: str
+    raci_role: str
+
+
+def _valid_uuid(value: Any, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (ValueError, TypeError, AttributeError) as exc:
+        raise ResponsibilityValidationError(f"{field} must be a UUID") from exc
+
+
+def validate_resource(resource_type: str, resource_uid: str | None = None) -> None:
+    if resource_type not in RESOURCE_TYPES:
+        raise ResponsibilityValidationError("unsupported resource type")
+    if resource_uid is None:
+        return
+    value = str(resource_uid).strip()
+    if not value or len(value) > 120:
+        raise ResponsibilityValidationError("resource uid is invalid")
+
+
+def validate_matrix(
+    resource_type: str,
+    assignments: list[dict[str, Any]],
+) -> tuple[ResponsibilityAssignment, ...]:
+    validate_resource(resource_type)
+    if not isinstance(assignments, list) or not assignments:
+        raise ResponsibilityValidationError("responsibility matrix cannot be empty")
+
+    validated: list[ResponsibilityAssignment] = []
+    identities: set[tuple[str, str, str]] = set()
+    for raw in assignments:
+        if not isinstance(raw, dict):
+            raise ResponsibilityValidationError("assignment must be an object")
+        user_id = _valid_uuid(raw.get("user_id"), "user id")
+        responsibility_role = str(raw.get("responsibility_role") or "").strip()
+        raci_role = str(raw.get("raci_role") or "").strip()
+        if responsibility_role not in RESPONSIBILITY_ROLES:
+            raise ResponsibilityValidationError("unsupported responsibility role")
+        if raci_role not in RACI_ROLES:
+            raise ResponsibilityValidationError("unsupported RACI role")
+        identity = (user_id, responsibility_role, raci_role)
+        if identity in identities:
+            raise ResponsibilityValidationError("duplicate responsibility assignment")
+        identities.add(identity)
+        validated.append(
+            ResponsibilityAssignment(
+                user_id=user_id,
+                responsibility_role=responsibility_role,
+                raci_role=raci_role,
+            )
+        )
+
+    if resource_type in DEVICE_RESOURCE_TYPES:
+        accountable_asset_managers = [
+            item
+            for item in validated
+            if item.responsibility_role == "asset_manager"
+            and item.raci_role == "accountable"
+        ]
+        if len(accountable_asset_managers) != 1:
+            raise ResponsibilityValidationError(
+                "device scope requires exactly one accountable asset manager"
+            )
+    return tuple(validated)
+
+
+class ResponsibilityService:
+    def __init__(self, repository):
+        self.repository = repository
+
+    def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
+        validate_resource(resource_type, resource_uid)
+        return self.repository.get(resource_type, resource_uid)
+
+    def replace(
+        self,
+        *,
+        resource_type: str,
+        resource_uid: str,
+        assignments: list[dict[str, Any]],
+        expected_revision: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        validate_resource(resource_type, resource_uid)
+        actor_uid = _valid_uuid(actor_uid, "actor uid")
+        try:
+            revision = int(expected_revision)
+        except (TypeError, ValueError) as exc:
+            raise ResponsibilityValidationError("revision is invalid") from exc
+        if revision < 0:
+            raise ResponsibilityValidationError("revision is invalid")
+        return self.repository.replace(
+            resource_type=resource_type,
+            resource_uid=str(resource_uid).strip(),
+            assignments=validate_matrix(resource_type, assignments),
+            expected_revision=revision,
+            actor_uid=actor_uid,
+        )
+
+
+class SqlAlchemyResponsibilityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _assignment_dict(row) -> dict[str, Any]:
+        return {
+            "user_id": str(row["user_id"]),
+            "username": row["username"],
+            "display_name": row["display_name"],
+            "responsibility_role": row["responsibility_role"],
+            "raci_role": row["raci_role"],
+        }
+
+    def _assignments(self, scope_id: str) -> list[dict[str, Any]]:
+        rows = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT a.user_id::text AS user_id, u.username,
+                           u.display_name, a.responsibility_role, a.raci_role
+                    FROM public.governance_responsibility_assignments a
+                    JOIN public.users u ON u.id = a.user_id
+                    WHERE a.scope_id = CAST(:scope_id AS uuid)
+                    ORDER BY a.raci_role, a.responsibility_role, u.username
+                    """
+                ),
+                {"scope_id": scope_id},
+            )
+            .mappings()
+            .all()
+        )
+        return [self._assignment_dict(row) for row in rows]
+
+    def get(self, resource_type: str, resource_uid: str) -> dict[str, Any]:
+        scope = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT id::text AS id, revision
+                    FROM public.governance_responsibility_scopes
+                    WHERE resource_type = :resource_type
+                      AND resource_uid = :resource_uid
+                    """
+                ),
+                {
+                    "resource_type": resource_type,
+                    "resource_uid": resource_uid,
+                },
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if scope is None:
+            return {
+                "resource_type": resource_type,
+                "resource_uid": resource_uid,
+                "revision": 0,
+                "assignments": [],
+            }
+        return {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "revision": int(scope["revision"]),
+            "assignments": self._assignments(scope["id"]),
+        }
+
+    def replace(
+        self,
+        *,
+        resource_type: str,
+        resource_uid: str,
+        assignments: tuple[ResponsibilityAssignment, ...],
+        expected_revision: int,
+        actor_uid: str,
+    ) -> dict[str, Any]:
+        lock_key = f"responsibility:{resource_type}:{resource_uid}"
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": lock_key},
+        )
+        scope = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT id::text AS id, revision
+                    FROM public.governance_responsibility_scopes
+                    WHERE resource_type = :resource_type
+                      AND resource_uid = :resource_uid
+                    FOR UPDATE
+                    """
+                ),
+                {
+                    "resource_type": resource_type,
+                    "resource_uid": resource_uid,
+                },
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if scope is None:
+            if expected_revision != 0:
+                raise ResponsibilityConflict("responsibility revision conflict")
+            scope_id = new_governance_uid()
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.governance_responsibility_scopes (
+                        id, resource_type, resource_uid, revision,
+                        updated_by
+                    ) VALUES (
+                        CAST(:id AS uuid), :resource_type, :resource_uid, 0,
+                        CAST(:actor_uid AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "id": scope_id,
+                    "resource_type": resource_type,
+                    "resource_uid": resource_uid,
+                    "actor_uid": actor_uid,
+                },
+            )
+            current_revision = 0
+            before: list[dict[str, Any]] = []
+        else:
+            scope_id = str(scope["id"])
+            current_revision = int(scope["revision"])
+            if current_revision != expected_revision:
+                raise ResponsibilityConflict("responsibility revision conflict")
+            before = self._assignments(scope_id)
+
+        user_ids = sorted({item.user_id for item in assignments})
+        active_user_ids = {
+            str(row[0])
+            for row in self.session.execute(
+                text(
+                    """
+                    SELECT id::text FROM public.users
+                    WHERE status = 'active' AND id::text = ANY(:user_ids)
+                    """
+                ),
+                {"user_ids": user_ids},
+            )
+        }
+        if active_user_ids != set(user_ids):
+            raise ResponsibilityUserUnavailable(
+                "responsibility user is unknown or disabled"
+            )
+
+        self.session.execute(
+            text(
+                """
+                DELETE FROM public.governance_responsibility_assignments
+                WHERE scope_id = CAST(:scope_id AS uuid)
+                """
+            ),
+            {"scope_id": scope_id},
+        )
+        for assignment in assignments:
+            self.session.execute(
+                text(
+                    """
+                    INSERT INTO public.governance_responsibility_assignments (
+                        id, scope_id, user_id, responsibility_role,
+                        raci_role, assigned_by
+                    ) VALUES (
+                        CAST(:id AS uuid), CAST(:scope_id AS uuid),
+                        CAST(:user_id AS uuid), :responsibility_role,
+                        :raci_role, CAST(:actor_uid AS uuid)
+                    )
+                    """
+                ),
+                {
+                    "id": new_governance_uid(),
+                    "scope_id": scope_id,
+                    "user_id": assignment.user_id,
+                    "responsibility_role": assignment.responsibility_role,
+                    "raci_role": assignment.raci_role,
+                    "actor_uid": actor_uid,
+                },
+            )
+
+        new_revision = current_revision + 1
+        self.session.execute(
+            text(
+                """
+                UPDATE public.governance_responsibility_scopes
+                SET revision = :revision, updated_by = CAST(:actor_uid AS uuid),
+                    updated_at = CURRENT_TIMESTAMP
+                WHERE id = CAST(:scope_id AS uuid)
+                """
+            ),
+            {
+                "scope_id": scope_id,
+                "revision": new_revision,
+                "actor_uid": actor_uid,
+            },
+        )
+        requested = [asdict(item) for item in assignments]
+        self.session.execute(
+            text(
+                """
+                INSERT INTO public.governance_responsibility_audit_events (
+                    scope_id, resource_type, resource_uid, actor_uid,
+                    action, before_state, after_state
+                ) VALUES (
+                    CAST(:scope_id AS uuid), :resource_type, :resource_uid,
+                    CAST(:actor_uid AS uuid), 'matrix_replaced',
+                    CAST(:before_state AS jsonb), CAST(:after_state AS jsonb)
+                )
+                """
+            ),
+            {
+                "scope_id": scope_id,
+                "resource_type": resource_type,
+                "resource_uid": resource_uid,
+                "actor_uid": actor_uid,
+                "before_state": json.dumps(before, ensure_ascii=False),
+                "after_state": json.dumps(requested, ensure_ascii=False),
+            },
+        )
+        return {
+            "resource_type": resource_type,
+            "resource_uid": resource_uid,
+            "revision": new_revision,
+            "assignments": self._assignments(scope_id),
+        }

+ 16 - 0
deployment/app/core/system/permissions.py

@@ -38,6 +38,9 @@ DEVICE_SEMANTICS_EDIT = "device-semantics:edit"
 DEVICE_SEMANTICS_REVIEW = "device-semantics:review"
 DEVICE_ENTITIES_EDIT = "device-entities:edit"
 DEVICE_ENTITIES_REVIEW = "device-entities:review"
+DEVICE_QUALITY_EDIT = "device-quality:edit"
+DEVICE_QUALITY_EXECUTE = "device-quality:execute"
+DEVICE_QUALITY_PUBLISH = "device-quality:publish"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -59,6 +62,8 @@ ROLE_PERMISSIONS = {
             DEVICE_ASSETS_EDIT,
             DEVICE_SEMANTICS_EDIT,
             DEVICE_ENTITIES_EDIT,
+            DEVICE_QUALITY_EDIT,
+            DEVICE_QUALITY_EXECUTE,
         }
     ),
     "admin": frozenset(
@@ -94,6 +99,9 @@ ROLE_PERMISSIONS = {
             DEVICE_SEMANTICS_REVIEW,
             DEVICE_ENTITIES_EDIT,
             DEVICE_ENTITIES_REVIEW,
+            DEVICE_QUALITY_EDIT,
+            DEVICE_QUALITY_EXECUTE,
+            DEVICE_QUALITY_PUBLISH,
         }
     ),
 }
@@ -170,6 +178,14 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path.endswith("/review") or path.endswith("/rollback"):
             return (DEVICE_ENTITIES_REVIEW,)
         return (DEVICE_ENTITIES_EDIT,)
+    if path.startswith("/api/development/v1/device-quality"):
+        if method == "GET":
+            return (READ_GOVERNANCE,)
+        if path.endswith("/publish"):
+            return (DEVICE_QUALITY_PUBLISH,)
+        if path == "/api/development/v1/device-quality/runs":
+            return (DEVICE_QUALITY_EXECUTE,)
+        return (DEVICE_QUALITY_EDIT,)
     if path.startswith("/api/development/v1/ingestion-jobs"):
         if method == "GET":
             return (READ_GOVERNANCE,)

+ 295 - 0
deployment/app/models/data_research.py

@@ -718,6 +718,301 @@ class DeviceEntityMergeRollback(db.Model):
     )
 
 
+class DeviceQualityProfile(db.Model):
+    __tablename__ = "device_quality_profiles"
+    __table_args__ = ({"schema": "public"},)
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    code = db.Column(db.String(120), nullable=False, unique=True)
+    name = db.Column(db.String(300), nullable=False)
+    created_by = db.Column(db.String(120), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceQualityProfileVersion(db.Model):
+    __tablename__ = "device_quality_profile_versions"
+    __table_args__ = (
+        db.CheckConstraint(
+            "version > 0",
+            name="ck_device_quality_profile_version_number",
+        ),
+        db.CheckConstraint(
+            "status IN ('draft','published','superseded')",
+            name="ck_device_quality_profile_version_status",
+        ),
+        db.UniqueConstraint(
+            "profile_uid",
+            "version",
+            name="uq_device_quality_profile_version",
+        ),
+        db.UniqueConstraint(
+            "profile_uid",
+            "content_hash",
+            name="uq_device_quality_profile_hash",
+        ),
+        db.Index(
+            "uq_device_quality_active_profile",
+            "profile_uid",
+            unique=True,
+            postgresql_where=db.text("status = 'published'"),
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    profile_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_quality_profiles.uid",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    version = db.Column(db.Integer, nullable=False)
+    status = db.Column(db.String(20), nullable=False, default="draft")
+    rules = db.Column(JSONB, nullable=False)
+    content_hash = db.Column(db.String(64), nullable=False)
+    created_by = db.Column(db.String(120), nullable=False)
+    published_by = db.Column(db.String(120))
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    published_at = db.Column(db.DateTime(timezone=True))
+
+
+class DeviceQualityRun(db.Model):
+    __tablename__ = "device_quality_runs"
+    __table_args__ = (
+        db.CheckConstraint(
+            "status IN ('success','failed')",
+            name="ck_device_quality_run_status",
+        ),
+        db.CheckConstraint(
+            "total_assets >= 0 AND total_violations >= 0",
+            name="ck_device_quality_run_counts",
+        ),
+        db.CheckConstraint(
+            "score >= 0 AND score <= 100",
+            name="ck_device_quality_run_score",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    policy_version_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_quality_profile_versions.uid",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    policy_hash = db.Column(db.String(64), nullable=False)
+    source_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.ingestion_sources.uid",
+            ondelete="RESTRICT",
+        ),
+    )
+    status = db.Column(db.String(20), nullable=False)
+    total_assets = db.Column(db.Integer, nullable=False)
+    total_violations = db.Column(db.Integer, nullable=False)
+    score = db.Column(db.Float, nullable=False)
+    created_by = db.Column(db.String(120), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceQualityRuleResult(db.Model):
+    __tablename__ = "device_quality_rule_results"
+    __table_args__ = (
+        db.CheckConstraint(
+            "severity IN ('info','warning','error','critical')",
+            name="ck_device_quality_rule_result_severity",
+        ),
+        db.CheckConstraint(
+            "status IN ('passed','violated','not_applicable')",
+            name="ck_device_quality_rule_result_status",
+        ),
+        db.CheckConstraint(
+            "evaluated_count >= 0 AND violation_count >= 0 "
+            "AND sampled_count >= 0 "
+            "AND violation_count <= evaluated_count "
+            "AND sampled_count <= violation_count",
+            name="ck_device_quality_rule_result_counts",
+        ),
+        db.CheckConstraint(
+            "pass_rate >= 0 AND pass_rate <= 1 "
+            "AND weighted_score >= 0 AND weighted_score <= weight "
+            "AND weight >= 0 AND weight <= 100",
+            name="ck_device_quality_rule_result_scores",
+        ),
+        db.UniqueConstraint(
+            "run_uid",
+            "rule_code",
+            name="uq_device_quality_run_rule",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    rule_code = db.Column(db.String(120), nullable=False)
+    severity = db.Column(db.String(20), nullable=False)
+    weight = db.Column(db.Float, nullable=False)
+    status = db.Column(db.String(30), nullable=False)
+    evaluated_count = db.Column(db.Integer, nullable=False)
+    violation_count = db.Column(db.Integer, nullable=False)
+    sampled_count = db.Column(db.Integer, nullable=False)
+    pass_rate = db.Column(db.Float, nullable=False)
+    weighted_score = db.Column(db.Float, nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
+class DeviceQualityViolationSample(db.Model):
+    __tablename__ = "device_quality_violation_samples"
+    __table_args__ = (
+        db.CheckConstraint(
+            "severity IN ('info','warning','error','critical')",
+            name="ck_device_quality_violation_severity",
+        ),
+        db.UniqueConstraint(
+            "run_uid",
+            "rule_code",
+            "asset_uid",
+            "field_name",
+            name="uq_device_quality_violation_identity",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    rule_code = db.Column(db.String(120), nullable=False)
+    severity = db.Column(db.String(20), nullable=False)
+    asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    field_name = db.Column(db.String(300), nullable=False)
+    source_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.ingestion_sources.uid",
+            ondelete="RESTRICT",
+        ),
+    )
+    source_mapping_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey(
+            "public.device_asset_source_mappings.uid",
+            ondelete="RESTRICT",
+        ),
+    )
+    message = db.Column(db.String(1000), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    expires_at = db.Column(db.DateTime(timezone=True), nullable=False)
+
+
+class DeviceQualityAssetScore(db.Model):
+    __tablename__ = "device_quality_asset_scores"
+    __table_args__ = (
+        db.CheckConstraint(
+            "status IN ('passed','violated','not_applicable')",
+            name="ck_device_quality_asset_score_status",
+        ),
+        db.CheckConstraint(
+            "evaluated_rule_count >= 0 AND violation_count >= 0 "
+            "AND violation_count <= evaluated_rule_count",
+            name="ck_device_quality_asset_score_counts",
+        ),
+        db.CheckConstraint(
+            "score >= 0 AND score <= 100",
+            name="ck_device_quality_asset_score_value",
+        ),
+        db.UniqueConstraint(
+            "run_uid",
+            "asset_uid",
+            name="uq_device_quality_run_asset",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    asset_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_assets.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    asset_type = db.Column(db.String(40), nullable=False)
+    status = db.Column(db.String(30), nullable=False)
+    evaluated_rule_count = db.Column(db.Integer, nullable=False)
+    violation_count = db.Column(db.Integer, nullable=False)
+    score = db.Column(db.Float, nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

+ 80 - 0
deployment/migrations/versions/20260729_270_governance_responsibilities.py

@@ -0,0 +1,80 @@
+"""Add versioned and audited governance responsibility matrices."""
+
+from alembic import op
+
+
+revision = "20260729_270"
+down_revision = "20260724_260"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE IF NOT EXISTS public.governance_responsibility_scopes (
+            id UUID PRIMARY KEY,
+            resource_type VARCHAR(40) NOT NULL
+                CHECK (
+                    resource_type IN (
+                        'business_domain','device_asset','device_ontology',
+                        'device_mapping','fault_classification','quality_issue'
+                    )
+                ),
+            resource_uid VARCHAR(120) NOT NULL,
+            revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0),
+            updated_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (resource_type, resource_uid)
+        );
+
+        CREATE TABLE IF NOT EXISTS public.governance_responsibility_assignments (
+            id UUID PRIMARY KEY,
+            scope_id UUID NOT NULL
+                REFERENCES public.governance_responsibility_scopes(id)
+                ON DELETE CASCADE,
+            user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
+            responsibility_role VARCHAR(32) NOT NULL
+                CHECK (
+                    responsibility_role IN (
+                        'domain_owner','data_steward',
+                        'data_architect','asset_manager'
+                    )
+                ),
+            raci_role VARCHAR(16) NOT NULL
+                CHECK (
+                    raci_role IN (
+                        'responsible','accountable','consulted','informed'
+                    )
+                ),
+            assigned_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            assigned_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (scope_id, user_id, responsibility_role, raci_role)
+        );
+        CREATE INDEX IF NOT EXISTS idx_governance_responsibility_user
+            ON public.governance_responsibility_assignments(user_id);
+
+        CREATE TABLE IF NOT EXISTS public.governance_responsibility_audit_events (
+            id BIGSERIAL PRIMARY KEY,
+            scope_id UUID REFERENCES public.governance_responsibility_scopes(id)
+                ON DELETE SET NULL,
+            resource_type VARCHAR(40) NOT NULL,
+            resource_uid VARCHAR(120) NOT NULL,
+            actor_uid UUID REFERENCES public.users(id) ON DELETE SET NULL,
+            action VARCHAR(40) NOT NULL,
+            before_state JSONB NOT NULL,
+            after_state JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX IF NOT EXISTS idx_governance_responsibility_audit_scope
+            ON public.governance_responsibility_audit_events(
+                resource_type, resource_uid, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Responsibility history is retained during application rollback.
+    pass

+ 45 - 0
deployment/migrations/versions/20260729_280_catalog_ingestion_execution.py

@@ -0,0 +1,45 @@
+"""Add attempt-aware database catalog ingestion evidence."""
+
+from alembic import op
+
+
+revision = "20260729_280"
+down_revision = "20260729_270"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        ALTER TABLE public.ingestion_jobs
+            ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 0,
+            ADD COLUMN failure_stage VARCHAR(30);
+
+        ALTER TABLE public.ingestion_jobs
+            ADD CONSTRAINT ck_ingestion_job_attempt_count
+            CHECK (attempt_count >= 0);
+
+        CREATE TABLE public.catalog_snapshots (
+            uid UUID PRIMARY KEY,
+            job_uid UUID NOT NULL
+                REFERENCES public.ingestion_jobs(uid) ON DELETE CASCADE,
+            source_uid UUID NOT NULL
+                REFERENCES public.ingestion_sources(uid),
+            attempt INTEGER NOT NULL CHECK (attempt > 0),
+            database_type VARCHAR(20) NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            snapshot JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (job_uid, attempt)
+        );
+
+        CREATE INDEX idx_catalog_snapshots_source_created
+            ON public.catalog_snapshots(source_uid, created_at DESC);
+        """
+    )
+
+
+def downgrade() -> None:
+    # Catalog execution evidence is retained across application rollback.
+    pass

+ 91 - 0
deployment/migrations/versions/20260729_290_device_asset_catalog.py

@@ -0,0 +1,91 @@
+"""Add canonical device assets, source mappings, and immutable versions."""
+
+from alembic import op
+
+
+revision = "20260729_290"
+down_revision = "20260729_280"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_assets (
+            uid UUID PRIMARY KEY,
+            asset_type VARCHAR(40) NOT NULL
+                CHECK (
+                    asset_type IN (
+                        'device','component','measurement_point',
+                        'alarm','maintenance_record'
+                    )
+                ),
+            name VARCHAR(300) NOT NULL,
+            status VARCHAR(20) NOT NULL DEFAULT 'active'
+                CHECK (status IN ('active','retired')),
+            current_version INTEGER NOT NULL DEFAULT 1
+                CHECK (current_version > 0),
+            content_hash CHAR(64) NOT NULL,
+            location VARCHAR(300),
+            organization VARCHAR(300),
+            responsible_person VARCHAR(300),
+            attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_by VARCHAR(100),
+            updated_by VARCHAR(100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_assets_type_status
+            ON public.device_assets(asset_type, status);
+        CREATE INDEX idx_device_assets_updated
+            ON public.device_assets(updated_at DESC);
+
+        CREATE TABLE public.device_asset_source_mappings (
+            uid UUID PRIMARY KEY,
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE CASCADE,
+            source_uid UUID NOT NULL
+                REFERENCES public.ingestion_sources(uid),
+            source_entity VARCHAR(300) NOT NULL,
+            asset_type VARCHAR(40) NOT NULL
+                CHECK (
+                    asset_type IN (
+                        'device','component','measurement_point',
+                        'alarm','maintenance_record'
+                    )
+                ),
+            source_code VARCHAR(300) NOT NULL,
+            source_updated_at TIMESTAMPTZ,
+            first_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            last_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (source_uid, source_entity, asset_type, source_code)
+        );
+        CREATE INDEX idx_device_asset_mapping_asset
+            ON public.device_asset_source_mappings(asset_uid);
+        CREATE INDEX idx_device_asset_mapping_source_code
+            ON public.device_asset_source_mappings(source_code);
+
+        CREATE TABLE public.device_asset_versions (
+            uid UUID PRIMARY KEY,
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE CASCADE,
+            version INTEGER NOT NULL CHECK (version > 0),
+            content_hash CHAR(64) NOT NULL,
+            snapshot JSONB NOT NULL,
+            source_mapping_uid UUID NOT NULL
+                REFERENCES public.device_asset_source_mappings(uid)
+                ON DELETE RESTRICT,
+            actor_uid VARCHAR(100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (asset_uid, version)
+        );
+        CREATE INDEX idx_device_asset_versions_created
+            ON public.device_asset_versions(asset_uid, created_at DESC);
+        """
+    )
+
+
+def downgrade() -> None:
+    # Device identity and immutable history are retained on application rollback.
+    pass

+ 91 - 0
deployment/migrations/versions/20260729_300_device_semantics.py

@@ -0,0 +1,91 @@
+"""Add governed device semantic codes, immutable versions, and reviews."""
+
+from alembic import op
+
+revision = "20260729_300"
+down_revision = "20260729_290"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_semantic_codes (
+            uid UUID PRIMARY KEY,
+            ontology_uid UUID NOT NULL
+                REFERENCES public.ontologies(uid) ON DELETE RESTRICT,
+            code_type VARCHAR(20) NOT NULL
+                CHECK (code_type IN ('fault','cause','action')),
+            canonical_code VARCHAR(120) NOT NULL,
+            canonical_name VARCHAR(300) NOT NULL,
+            definition TEXT,
+            status VARCHAR(20) NOT NULL DEFAULT 'draft'
+                CHECK (
+                    status IN (
+                        'draft','in_review','published','rejected','retired'
+                    )
+                ),
+            current_version INTEGER NOT NULL DEFAULT 1
+                CHECK (current_version > 0),
+            source_mappings JSONB NOT NULL DEFAULT '[]'::jsonb,
+            evidence_uids JSONB NOT NULL DEFAULT '[]'::jsonb,
+            suggestion_source VARCHAR(20) NOT NULL DEFAULT 'manual'
+                CHECK (suggestion_source IN ('manual','rule','ai')),
+            confidence DOUBLE PRECISION
+                CHECK (
+                    confidence IS NULL
+                    OR (confidence >= 0 AND confidence <= 1)
+                ),
+            created_by VARCHAR(100),
+            updated_by VARCHAR(100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (ontology_uid, code_type, canonical_code)
+        );
+        CREATE INDEX idx_device_semantic_codes_lookup
+            ON public.device_semantic_codes(
+                ontology_uid, code_type, status, canonical_code
+            );
+        CREATE INDEX idx_device_semantic_codes_updated
+            ON public.device_semantic_codes(updated_at DESC);
+
+        CREATE TABLE public.device_semantic_code_versions (
+            uid UUID PRIMARY KEY,
+            code_uid UUID NOT NULL
+                REFERENCES public.device_semantic_codes(uid)
+                ON DELETE CASCADE,
+            version INTEGER NOT NULL CHECK (version > 0),
+            snapshot JSONB NOT NULL,
+            created_by VARCHAR(100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (code_uid, version)
+        );
+        CREATE INDEX idx_device_semantic_code_versions_created
+            ON public.device_semantic_code_versions(
+                code_uid, created_at DESC
+            );
+
+        CREATE TABLE public.device_semantic_code_reviews (
+            uid UUID PRIMARY KEY,
+            code_uid UUID NOT NULL
+                REFERENCES public.device_semantic_codes(uid)
+                ON DELETE CASCADE,
+            version INTEGER NOT NULL CHECK (version > 0),
+            decision VARCHAR(20) NOT NULL
+                CHECK (decision IN ('approve','reject')),
+            reason VARCHAR(1000) NOT NULL DEFAULT '',
+            actor_uid VARCHAR(100) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_semantic_code_reviews_created
+            ON public.device_semantic_code_reviews(
+                code_uid, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Governed code dictionaries and their approval evidence are retained.
+    pass

+ 119 - 0
deployment/migrations/versions/20260729_310_device_entity_resolution.py

@@ -0,0 +1,119 @@
+"""Add governed device entity candidates, merges, and rollbacks."""
+
+from alembic import op
+
+revision = "20260729_310"
+down_revision = "20260729_300"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_entity_match_candidates (
+            uid UUID PRIMARY KEY,
+            left_asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            right_asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            canonical_asset_uid UUID
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            status VARCHAR(20) NOT NULL DEFAULT 'pending'
+                CHECK (
+                    status IN (
+                        'pending','merged','rejected','rolled_back'
+                    )
+                ),
+            suggestion_source VARCHAR(20) NOT NULL
+                CHECK (suggestion_source IN ('rule','ai','manual')),
+            confidence DOUBLE PRECISION NOT NULL
+                CHECK (confidence >= 0 AND confidence <= 1),
+            explanation JSONB NOT NULL DEFAULT '[]'::jsonb,
+            evidence_uids JSONB NOT NULL DEFAULT '[]'::jsonb,
+            model_provider VARCHAR(200),
+            model_name VARCHAR(200),
+            current_version INTEGER NOT NULL DEFAULT 1
+                CHECK (current_version > 0),
+            created_by VARCHAR(100),
+            reviewed_by VARCHAR(100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (left_asset_uid <> right_asset_uid)
+        );
+        CREATE UNIQUE INDEX uq_device_entity_open_pair
+            ON public.device_entity_match_candidates(
+                left_asset_uid, right_asset_uid
+            )
+            WHERE status IN ('pending','merged');
+        CREATE INDEX idx_device_entity_candidates_status
+            ON public.device_entity_match_candidates(
+                status, suggestion_source, updated_at DESC
+            );
+
+        CREATE TABLE public.device_entity_match_reviews (
+            uid UUID PRIMARY KEY,
+            candidate_uid UUID NOT NULL
+                REFERENCES public.device_entity_match_candidates(uid)
+                ON DELETE CASCADE,
+            version INTEGER NOT NULL CHECK (version > 0),
+            decision VARCHAR(20) NOT NULL
+                CHECK (decision IN ('approve','reject','auto_approve')),
+            reason VARCHAR(1000) NOT NULL,
+            actor_uid VARCHAR(100) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_entity_reviews_candidate
+            ON public.device_entity_match_reviews(
+                candidate_uid, created_at DESC
+            );
+
+        CREATE TABLE public.device_entity_merge_events (
+            uid UUID PRIMARY KEY,
+            candidate_uid UUID NOT NULL UNIQUE
+                REFERENCES public.device_entity_match_candidates(uid)
+                ON DELETE CASCADE,
+            canonical_asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            member_asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            review_uid UUID NOT NULL
+                REFERENCES public.device_entity_match_reviews(uid)
+                ON DELETE RESTRICT,
+            snapshot JSONB NOT NULL,
+            actor_uid VARCHAR(100) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_entity_merge_canonical
+            ON public.device_entity_merge_events(
+                canonical_asset_uid, created_at DESC
+            );
+        CREATE INDEX idx_device_entity_merge_member
+            ON public.device_entity_merge_events(
+                member_asset_uid, created_at DESC
+            );
+
+        CREATE TABLE public.device_entity_merge_rollbacks (
+            uid UUID PRIMARY KEY,
+            merge_uid UUID NOT NULL UNIQUE
+                REFERENCES public.device_entity_merge_events(uid)
+                ON DELETE CASCADE,
+            candidate_uid UUID NOT NULL
+                REFERENCES public.device_entity_match_candidates(uid)
+                ON DELETE CASCADE,
+            reason VARCHAR(1000) NOT NULL,
+            snapshot JSONB NOT NULL,
+            actor_uid VARCHAR(100) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_entity_rollbacks_candidate
+            ON public.device_entity_merge_rollbacks(
+                candidate_uid, created_at DESC
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Candidate, review, merge, and rollback evidence is retained.
+    pass

+ 167 - 0
deployment/migrations/versions/20260729_320_device_quality.py

@@ -0,0 +1,167 @@
+"""Add immutable device quality policies, runs, results, and evidence."""
+
+from alembic import op
+
+revision = "20260729_320"
+down_revision = "20260729_310"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_quality_profiles (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            created_by VARCHAR(120) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.device_quality_profile_versions (
+            uid UUID PRIMARY KEY,
+            profile_uid UUID NOT NULL
+                REFERENCES public.device_quality_profiles(uid)
+                ON DELETE RESTRICT,
+            version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL DEFAULT 'draft'
+                CHECK (status IN ('draft','published','superseded')),
+            rules JSONB NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            created_by VARCHAR(120) NOT NULL,
+            published_by VARCHAR(120),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            published_at TIMESTAMPTZ,
+            UNIQUE (profile_uid, version),
+            UNIQUE (profile_uid, content_hash)
+        );
+        CREATE UNIQUE INDEX uq_device_quality_active_profile
+            ON public.device_quality_profile_versions(profile_uid)
+            WHERE status = 'published';
+        CREATE INDEX idx_device_quality_profile_versions
+            ON public.device_quality_profile_versions(
+                profile_uid, version DESC
+            );
+
+        CREATE TABLE public.device_quality_runs (
+            uid UUID PRIMARY KEY,
+            policy_version_uid UUID NOT NULL
+                REFERENCES public.device_quality_profile_versions(uid)
+                ON DELETE RESTRICT,
+            policy_hash CHAR(64) NOT NULL,
+            source_uid UUID
+                REFERENCES public.ingestion_sources(uid)
+                ON DELETE RESTRICT,
+            status VARCHAR(20) NOT NULL
+                CHECK (status IN ('success','failed')),
+            total_assets INTEGER NOT NULL CHECK (total_assets >= 0),
+            total_violations INTEGER NOT NULL CHECK (total_violations >= 0),
+            score DOUBLE PRECISION NOT NULL
+                CHECK (score >= 0 AND score <= 100),
+            created_by VARCHAR(120) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_quality_runs_created
+            ON public.device_quality_runs(created_at DESC, uid);
+        CREATE INDEX idx_device_quality_runs_policy
+            ON public.device_quality_runs(policy_version_uid, created_at DESC);
+
+        CREATE TABLE public.device_quality_rule_results (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.device_quality_runs(uid)
+                ON DELETE CASCADE,
+            rule_code VARCHAR(120) NOT NULL,
+            severity VARCHAR(20) NOT NULL
+                CHECK (severity IN ('info','warning','error','critical')),
+            weight DOUBLE PRECISION NOT NULL
+                CHECK (weight >= 0 AND weight <= 100),
+            status VARCHAR(30) NOT NULL
+                CHECK (status IN ('passed','violated','not_applicable')),
+            evaluated_count INTEGER NOT NULL CHECK (evaluated_count >= 0),
+            violation_count INTEGER NOT NULL CHECK (
+                violation_count >= 0
+                AND violation_count <= evaluated_count
+            ),
+            sampled_count INTEGER NOT NULL CHECK (
+                sampled_count >= 0
+                AND sampled_count <= violation_count
+            ),
+            pass_rate DOUBLE PRECISION NOT NULL
+                CHECK (pass_rate >= 0 AND pass_rate <= 1),
+            weighted_score DOUBLE PRECISION NOT NULL CHECK (
+                weighted_score >= 0 AND weighted_score <= weight
+            ),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (run_uid, rule_code)
+        );
+        CREATE INDEX idx_device_quality_rule_results
+            ON public.device_quality_rule_results(
+                run_uid, severity, rule_code
+            );
+
+        CREATE TABLE public.device_quality_violation_samples (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.device_quality_runs(uid)
+                ON DELETE CASCADE,
+            rule_code VARCHAR(120) NOT NULL,
+            severity VARCHAR(20) NOT NULL
+                CHECK (severity IN ('info','warning','error','critical')),
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid)
+                ON DELETE RESTRICT,
+            field_name VARCHAR(300) NOT NULL,
+            source_uid UUID
+                REFERENCES public.ingestion_sources(uid)
+                ON DELETE RESTRICT,
+            source_mapping_uid UUID
+                REFERENCES public.device_asset_source_mappings(uid)
+                ON DELETE RESTRICT,
+            message VARCHAR(1000) NOT NULL,
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            expires_at TIMESTAMPTZ NOT NULL,
+            UNIQUE (run_uid, rule_code, asset_uid, field_name)
+        );
+        CREATE INDEX idx_device_quality_violation_run
+            ON public.device_quality_violation_samples(
+                run_uid, rule_code, asset_uid
+            );
+        CREATE INDEX idx_device_quality_violation_retention
+            ON public.device_quality_violation_samples(expires_at);
+
+        CREATE TABLE public.device_quality_asset_scores (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.device_quality_runs(uid)
+                ON DELETE CASCADE,
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid)
+                ON DELETE RESTRICT,
+            asset_type VARCHAR(40) NOT NULL,
+            status VARCHAR(30) NOT NULL
+                CHECK (status IN ('passed','violated','not_applicable')),
+            evaluated_rule_count INTEGER NOT NULL
+                CHECK (evaluated_rule_count >= 0),
+            violation_count INTEGER NOT NULL CHECK (
+                violation_count >= 0
+                AND violation_count <= evaluated_rule_count
+            ),
+            score DOUBLE PRECISION NOT NULL
+                CHECK (score >= 0 AND score <= 100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (run_uid, asset_uid)
+        );
+        CREATE INDEX idx_device_quality_asset_scores
+            ON public.device_quality_asset_scores(
+                run_uid, score, asset_uid
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Quality policies, runs, scores, and violation evidence are retained.
+    pass

+ 39 - 0
deployment/migrations/versions/20260729_330_device_quality_responsibility_type.py

@@ -0,0 +1,39 @@
+"""Allow device-quality responsibility scopes."""
+
+from alembic import op
+
+revision = "20260729_330"
+down_revision = "20260729_320"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        ALTER TABLE public.governance_responsibility_scopes
+            DROP CONSTRAINT IF EXISTS
+                governance_responsibility_scopes_resource_type_check;
+
+        ALTER TABLE public.governance_responsibility_scopes
+            ADD CONSTRAINT
+                governance_responsibility_scopes_resource_type_check
+            CHECK (
+                resource_type IN (
+                    'business_domain','device_asset','device_ontology',
+                    'device_mapping','fault_classification','quality_issue',
+                    'device_quality'
+                )
+            ) NOT VALID;
+
+        ALTER TABLE public.governance_responsibility_scopes
+            VALIDATE CONSTRAINT
+                governance_responsibility_scopes_resource_type_check;
+        """
+    )
+
+
+def downgrade() -> None:
+    # Keep the additive responsibility type so existing accountable-manager
+    # assignments remain readable after an application rollback.
+    pass

+ 1 - 0
docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md

@@ -159,6 +159,7 @@ P2 不阻塞第一阶段验收。没有完成的 P2 功能必须保留接口和
 | WP-04 | 工程完成,待企业数据验收 | 五类设备对象规范化导入;稳定平台 UID 与来源身份唯一映射;无变化不增版、变化生成不可变版本;关键词和类型/状态/来源筛选;来源与责任信息详情;查看者只读、编辑者受控导入;OpenAPI 151 项;本地 PostgreSQL 与页面链路定向验证 | 需要企业提供设备台账与维修样本、字段映射和业务确认;跨来源自动匹配、合并与回滚归 WP-06,质量与血缘汇聚不在 WP-04 |
 | WP-05 | 工程完成,待企业语义与代码集验收 | 通用本体能力已纳入当前分支;11 类设备核心概念、10 条标准关系和平台设备身份映射;设备覆盖度及负责人发布门禁;故障/原因/措施代码不可变版本、证据、提交、审批与审计;查看者、编辑者、审批者权限分离;OpenAPI 161 项;本地 PostgreSQL 和页面链路定向验证 | 需要企业确认设备语义、故障/原因/措施代码和唯一设备资产负责人;规则/AI 建议不会自动发布;受治理 AI 提供方与企业证据集未验收,SEM-21 保持部分建设;跨来源实体匹配归 WP-06 |
 | WP-06 | 工程完成,待企业匹配阈值验收 | 跨来源同类型资产候选;名称、位置、组织、责任人、型号和来源编码逐项评分解释;规则与受治理 AI 候选;权限与唯一设备映射负责人双门禁;非破坏性主资产关联;不可变审核、合并快照和追加式回滚证据;OpenAPI 170 项;真实 PostgreSQL 定向验证 | 需要企业提供跨系统同一/不同设备标注集、确认候选阈值和唯一设备映射负责人;自动合并默认关闭且只允许确定性规则严格高置信度候选,AI 自动合并不在首期;属性幸存规则、物理删除、质量评分和下游图谱投影不在 WP-06 |
+| WP-07 | 工程完成,待企业质量数据验收 | 七类封闭设备质量规则;不可变策略版本和唯一生效发布;设备资产负责人发布门禁;绑定策略哈希的质量执行;规则级精确计数、违规样本和资产评分;查看者、编辑者、发布者权限分离;OpenAPI 180 项;真实 PostgreSQL 和页面链路定向验证 | 需要企业提供设备、部件、告警、维修样本并确认字段映射、规则权重和质量阈值;每条规则只保留最多 100 条脱敏样本且 30 天后清理;通用画像、Schema 漂移、趋势和质量问题整改闭环仍分别保留为后续能力,WP-08 承接整改与复验 |
 
 ## 7. 12 周执行计划
 

+ 12 - 10
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -27,7 +27,7 @@ DataOps Platform 已从早期的“元数据管理 + n8n 工作流 + 数据订
 当前工程能力已经明显超过旧版架构总览:
 
 - Flask 当前注册 **11 个 Blueprint**。
-- 路由源码中共有 **170 个路由声明**。
+- 路由源码中共有 **180 个路由声明**。
 - 前端可见一级模块包括工作台、数据治理知识库、数据研发、数据地图、数据工厂、数据服务、数据审核和系统管理。
 - Alembic 迁移已经覆盖 RBAC、工作台、知识库、数据源凭据、工作流引擎、Runner/MCP、n8n→Kestra 迁移、数据研发采集/本体、AI 数据规则、规则运行证据和数据工厂投产。
 - 当前分支相对 `master` 领先 37 个提交;不含本报告,普查启动时还有 32 个已修改文件和 11 个未跟踪项。因此数据规则和生产线投产能力不能直接视为主分支或生产环境现状。
@@ -214,7 +214,7 @@ flowchart TB
 | 全本地 Docker 栈 | PostgreSQL/pgvector、源 PostgreSQL/MySQL、Neo4j、MinIO、n8n、Kestra、Runner、Backend、Frontend | 已建设 | [本地栈说明](../deploy/docker/README.md) |
 | 可选 MCP/知识侧车 | Kestra MCP、DataOps Context/Scheduling MCP、LightRAG 投影服务 | 已建设,按 profile 启用 | `deploy/docker/docker-compose.yml` |
 | 生产发布包 | `deployment/` 可独立部署,包含脚本、配置和发布副本 | 已建设但必须保持单向生成 | [部署源真相](architecture/DEPLOYMENT_SOURCE_OF_TRUTH.md) |
-| API 机器清单 | 自动生成 OpenAPI | 工程完成,持续门禁 | 当前源码和 `OPENAPI.yaml` 均记录 170 个路由操作,由生成器和契约测试保持一致 |
+| API 机器清单 | 自动生成 OpenAPI | 工程完成,持续门禁 | 当前源码和 `OPENAPI.yaml` 均记录 180 个路由操作,由生成器和契约测试保持一致 |
 | HOPMS 数据导入 | 资源导入脚本、dry-run、导入结果和验收样例 | 已建设的离线工具 | `scripts/import_hopms_dataset.py`、`docs/generated/` |
 | n8n 资产盘点/迁移命令 | 清单、单流程迁移、双轨对账和退役审计 | 已建设 | `scripts/inventory_n8n_workflows.py`、`app/commands/migrate_n8n_workflow.py` |
 | 规则运行运维 | 健康检查、证据、canary、激活、回滚、故障处置 | 已建设 | [规则运行手册](operations/DATA_RULE_RUNTIME_RUNBOOK.md) |
@@ -285,7 +285,7 @@ flowchart TB
 - Vue 2 → Vue 3 迁移;
 - 生产旧表的只读核查、备份、依赖确认和独立下线;
 - 自动生成并持续校验 `app/` → `deployment/app/` 发布副本;
-- OpenAPI 与当前 161 个路由重新生成并纳入门禁
+- 持续自动生成 OpenAPI,并保持路由、权限矩阵和发布副本一致
 - 清理仍引用旧任务/SSH 自动部署语义的文档和兼容代码。
 
 ## 6. 已退役、明确不再建设或仅兼容保留的能力
@@ -322,9 +322,9 @@ flowchart TB
 
 ### 7.3 OpenAPI 漂移已收口
 
-- 当前路由源码:170 个路由声明。
-- 当前 `docs/architecture/OPENAPI.yaml`:`x-route-count: 170`。
-- 自动生成与契约测试已覆盖数据规则、设备台账、设备语义和实体匹配新增路由。
+- 当前路由源码:180 个路由声明。
+- 当前 `docs/architecture/OPENAPI.yaml`:`x-route-count: 180`。
+- 自动生成与契约测试已覆盖数据规则、设备台账、设备语义、实体匹配和设备质量新增路由。
 
 后续新增路由仍必须同步再生成,并通过路由清单一致性门禁。
 
@@ -545,6 +545,8 @@ WP-04 已补齐设备垂直切片的工程拼图:设备、部件、测点、
 
 WP-06 已补齐跨来源实体治理工程链:确定性规则按名称、位置、组织、责任人、型号和来源编码生成候选及逐项解释;受治理 AI 可提交带模型、置信度和证据的候选,但不能自动合并;设备资产负责人审批后只建立非破坏性主资产关联,并保留审核、合并快照和追加式回滚证据。真实企业台账的阈值、误匹配率和责任矩阵仍待现场验收。
 
+WP-07 已补齐设备质量工程链:七类封闭规则覆盖台账身份和上下文完整性、来源编码规范与唯一性、部件父级解析、故障代码映射、原因/措施完整性和维修闭环;规则以不可变版本发布,执行绑定精确版本和内容哈希,保存规则级精确计数、最多 100 条脱敏违规样本及资产级评分。质量检查不改写设备台账,企业真实设备、告警和维修数据仍待现场验收,整改工单与复验闭环归 WP-08。
+
 ### 12.5 数据标准、语义与本体
 
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
@@ -577,9 +579,9 @@ WP-06 已补齐跨来源实体治理工程链:确定性规则按名称、位
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
 |---|---|---|---|
 | DQA-01 | 数据质量 / 规则定义 | 自然语言规则、封闭 RuleSpec 和版本 | 工程完成,受门禁 |
-| DQA-02 | 数据质量 / 数据画像 | 完整率、唯一性、分布、空值、模式和样例画像 | 部分建设 |
+| DQA-02 | 数据质量 / 数据画像 | 完整率、唯一性、分布、空值、模式和样例画像 | 部分建设;已形成设备域完整性、唯一性和规则通过率画像,通用分布/模式画像待建设 |
 | DQA-03 | 数据质量 / 规则执行 | SQL 下推、Polars 批处理和 `quality.check` | 工程完成,受门禁 |
-| DQA-04 | 数据质量 / 质量评分 | 资产、数据产品和业务域的质量评分 | 规划中 |
+| DQA-04 | 数据质量 / 质量评分 | 资产、数据产品和业务域的质量评分 | 部分建设;已形成设备资产和设备质量检查整体评分,数据产品及通用业务域评分待建设 |
 | DQA-05 | 数据质量 / 质量趋势 | 质量指标时间序列、同比、环比和退化趋势 | 规划中 |
 | DQA-06 | 数据质量 / 异常检测 | 数据量、分布、模式、重复和异常值检测 | 规划中 |
 | DQA-07 | 数据质量 / Schema 漂移 | 字段、类型、约束和枚举变化检测 | 部分建设 |
@@ -598,8 +600,8 @@ WP-06 已补齐跨来源实体治理工程链:确定性规则按名称、位
 | OBS-04 | 数据可观测 / 事故管理 | 告警聚合、事故、责任人、时间线和复盘 | 规划中 |
 | OBS-05 | 数据可观测 / 告警治理 | 告警抑制、去重、升级、值班和送达回执 | 规划中 |
 | OBS-06 | 数据可观测 / 业务影响 | 将技术异常映射到数据产品、业务域和用户影响 | 规划中 |
-| OBS-07 | 设备质量 / 台账完整性 | 设备关系、位置、责任人和标识完整性检查 | 规划中 |
-| OBS-08 | 设备质量 / 故障数据 | 故障代码映射、原因完整性和维修闭环检查 | 规划中 |
+| OBS-07 | 设备质量 / 台账完整性 | 设备关系、位置、责任人和标识完整性检查 | 工程完成,待企业设备台账数据验收 |
+| OBS-08 | 设备质量 / 故障数据 | 故障代码映射、原因完整性和维修闭环检查 | 工程完成,待企业告警与维修数据验收 |
 | OBS-09 | 设备可观测 / 运行明细 | 近期运行明细、告警、停机和维修事件关联 | 规划中 |
 | OBS-10 | 设备可观测 / 根因图谱 | 设备—部件—告警—故障—维修—影响链路分析 | 规划中 |
 

+ 7 - 0
docs/architecture/DATA_MODEL.md

@@ -156,6 +156,12 @@ flowchart LR
 | `device_entity_match_reviews` | `candidate_uid`, `version`, `decision`, `reason`, `actor_uid` | 人工批准、拒绝或严格门禁自动批准的不可变审核证据 |
 | `device_entity_merge_events` | `candidate_uid`, `canonical_asset_uid`, `member_asset_uid`, `review_uid`, `snapshot` | 非破坏性主资产关联及合并前证据快照,不改写设备资产和来源映射 |
 | `device_entity_merge_rollbacks` | `merge_uid`, `candidate_uid`, `reason`, `snapshot`, `actor_uid` | 每次合并最多一个追加式回滚事件;原合并事件保留 |
+| `device_quality_profiles` | `uid`, `name`, `created_by` | 设备台账与故障质量策略的稳定身份 |
+| `device_quality_profile_versions` | `profile_uid`, `version`, `status`, `rules`, `content_hash`, `published_by` | 七类封闭规则的不可变版本;只允许一个生效发布版本 |
+| `device_quality_runs` | `policy_version_uid`, `policy_hash`, `source_uid`, `total_assets`, `total_violations`, `score` | 绑定精确策略版本和检查范围的不可变质量执行 |
+| `device_quality_rule_results` | `run_uid`, `rule_code`, `evaluated_count`, `violation_count`, `pass_rate`, `weighted_score` | 每条启用规则的精确计数和得分贡献 |
+| `device_quality_violation_samples` | `run_uid`, `rule_code`, `asset_uid`, `field_name`, `source_mapping_uid`, `evidence`, `expires_at` | 每条规则最多 100 条脱敏违规样本,保留 30 天 |
+| `device_quality_asset_scores` | `run_uid`, `asset_uid`, `evaluated_rule_count`, `violation_count`, `score` | 按适用规则计算的资产级质量评分 |
 | `ontologies` | `code`, `owner_uid`, `draft_revision`, `active_version_uid` | 本体稳定身份和生效版本 |
 | `ontology_versions` | `ontology_uid`, `version`, `parent_version_uid`, `graph_document`, `content_hash` | 不可变本体版本 |
 | `ontology_domain_links` | `ontology_uid`, `domain_uid`, `role` | 多业务域 owner/contributor/consumer 关系 |
@@ -186,6 +192,7 @@ flowchart LR
 - 设备资产、源编码映射和不可变版本以 PostgreSQL 为源真相;跨来源匹配、合并与回滚在 WP-06 经审核后实施。
 - 实体匹配候选、审核、主资产关联和回滚证据以 PostgreSQL 为源真相;合并是可撤销关系,不删除、不搬迁设备资产、来源映射或历史版本。
 - 规则自动合并默认关闭,只允许显式开闸后的确定性规则高置信度候选;AI 候选始终进入人工审核。
+- 设备质量策略版本、执行结果、规则计数、违规样本和资产评分以 PostgreSQL 为源真相;检查只读设备资产和已发布语义代码,不修改来源台账,整改闭环由 WP-08 承接。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 241 - 1
docs/architecture/OPENAPI.yaml

@@ -3,7 +3,7 @@ info:
   title: "DataOps Platform API(当前代码基线)"
   version: "2026-07-16"
   description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"
-x-route-count: 170
+x-route-count: 180
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -2073,6 +2073,246 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-quality/bootstrap":
+    post:
+      tags: [data_development]
+      operationId: data_development_bootstrap_device_quality_post
+      summary: "bootstrap device quality"
+      x-source: "app/api/data_development/routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-quality/profile":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_device_quality_profile_get
+      summary: "get device quality profile"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-quality/profile/versions":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_quality_versions_get
+      summary: "list device quality versions"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [data_development]
+      operationId: data_development_revise_device_quality_profile_post
+      summary: "revise device quality profile"
+      x-source: "app/api/data_development/routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-quality/profile/versions/{version_uid}/publish":
+    post:
+      tags: [data_development]
+      operationId: data_development_publish_device_quality_version_post
+      summary: "publish device quality version"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: version_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-quality/runs":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_quality_runs_get
+      summary: "list device quality runs"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [data_development]
+      operationId: data_development_run_device_quality_post
+      summary: "run device quality"
+      x-source: "app/api/data_development/routes.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-quality/runs/{run_uid}":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_device_quality_run_get
+      summary: "get device quality run"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: run_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-quality/runs/{run_uid}/asset-scores":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_quality_asset_scores_get
+      summary: "list device quality asset scores"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: run_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-quality/runs/{run_uid}/violations":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_device_quality_violations_get
+      summary: "list device quality violations"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: run_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/development/v1/device-semantics/bootstrap":
     post:
       tags: [data_development]

+ 212 - 0
docs/superpowers/plans/2026-07-29-wp07-device-quality.md

@@ -0,0 +1,212 @@
+# WP-07 Device Ledger and Fault Quality Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add versioned, explainable device-ledger and fault-maintenance quality policies that produce immutable execution results, bounded violation samples, and drill-down quality scores without changing source assets.
+
+**Architecture:** Existing closed RuleSpec and `quality.check` remain the platform-level authoring and execution foundation. WP-07 adds a deterministic device-domain policy overlay for the canonical WP-04 asset catalog and WP-05 published fault/cause/action codes: one immutable policy version selects allowlisted device checks and weights, each run evaluates a bounded canonical snapshot, and append-only result tables preserve rule, asset, field, source, and mapping evidence. WP-08 will consume violations later; WP-07 does not create or mutate remediation tickets.
+
+**Tech Stack:** Flask, SQLAlchemy, PostgreSQL JSONB, Alembic, Vue 2, Vuetify, pytest.
+
+---
+
+## Global Constraints
+
+- Reuse `device_assets`, `device_asset_source_mappings`, `device_asset_versions`, and published `device_semantic_codes`; never rewrite or delete them during quality evaluation.
+- Reuse the platform's deny-by-default RBAC. Viewer may read. Editor/admin may create drafts and execute published policies. Publication requires admin permission plus the unique accountable `asset_manager` for `device_quality/DEVICE_QUALITY`.
+- Add `device_quality` to the governed responsibility resource types and require exactly one accountable device asset manager.
+- A device-quality policy is a closed allowlist, not arbitrary Python, SQL, CEL, regular-expression source, or an AI-generated executable.
+- The first policy contains exactly these checks: asset identity completeness, asset context completeness, normalized source-code uniqueness, component parent resolution, fault-code mapping, fault cause/action completeness, and maintenance closure.
+- Policy rule codes are stable. Draft revisions may change enabled state, severity, weight, and bounded allowlisted parameters only. Enabled weights must be positive and sum to 100.
+- Only one published version is active. Publishing a new version appends a new immutable version and supersedes the prior active version without deleting its evidence.
+- Every run binds the exact policy-version UID and content hash. A run captures evaluated asset/version/mapping evidence so later catalog changes cannot alter historical results.
+- Quality execution is bounded to 5,000 assets per run, 100 violation samples per rule, and 1,000 asset scores per response page. Counts remain exact even when samples are truncated.
+- Violation samples contain platform UIDs, field names, safe messages, and bounded redacted evidence only. Credentials, connection details, personal contact data, and raw operating measurements are rejected.
+- Overall score is the sum of each enabled rule's `weight × pass_rate`; rules with zero applicable records score their full weight and report `not_applicable`.
+- Per-asset score uses only rules applicable to that asset. An asset with no applicable enabled rule receives 100 and `not_applicable`.
+- Fault rules operate on `alarm.attributes.fault_code`, `cause_code`, and `action_code`; maintenance closure operates on `maintenance_record.attributes.device_source_code`, `fault_source_code`, `status`, `completed_at`, and `action_code`. Field names are explicit in the versioned policy.
+- `source_code` format consistency uses a server-owned allowlisted format identifier, not caller-supplied regular expressions.
+- Schema drift remains a partial platform capability; WP-07 validates the canonical device contract and code fields but does not build a general source-schema diff engine.
+- Do not create WP-08 remediation issues, AI root-cause conclusions, automatic repairs, predictive maintenance, or time-series storage.
+- Validation follows the user-approved rule: run only WP-07 domain, persistence, API, permission, frontend, migration, contract, release-copy, and local browser checks; do not run the full repository regression.
+- Continue on `codex/dataops-phase1-equipment-governance`; do not push or deploy remotely.
+
+### Task 1: Closed Device-Quality Policy and Deterministic Evaluation
+
+**Files:**
+- Create: `app/core/data_research/device_quality.py`
+- Modify: `app/core/data_research/errors.py`
+- Create: `tests/data_research/test_device_quality.py`
+
+**Interfaces:**
+- Produce `DeviceQualityPolicyVersionRecord`, `DeviceQualityRunRecord`, `DeviceQualityRuleResultRecord`, `DeviceQualityViolationRecord`, and `DeviceQualityAssetScoreRecord`.
+- Produce `validate_device_quality_rules`, `DeviceQualityService.bootstrap`, `revise`, `publish`, `profile`, `versions`, `run`, `runs`, `get_run`, `violations`, and `asset_scores`.
+- Repository boundary supplies policy versions, active assets with mappings, published semantic codes, immutable run/result/sample persistence, and bounded searches.
+
+- [x] **Step 1: Write failing policy and evaluation tests**
+
+Cover the seven stable rule codes, exact enabled-weight total, unsupported keys and parameters, secret rejection, draft version immutability, accountable-manager publication, one active published version, exact score arithmetic, zero-applicable behavior, every ledger/fault/maintenance rule, source and mapping evidence, 5,000-asset boundary, 100-sample truncation, and no source mutation.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/data_research/test_device_quality.py
+```
+
+Expected: collection fails because `device_quality` does not exist.
+
+- [x] **Step 3: Implement the minimal closed policy and evaluator**
+
+Normalize only server-allowlisted rule fields and format identifiers. Evaluate immutable asset snapshots and published semantic-code sets. Persist exact counts separately from bounded samples. Reject stale profile revisions and invalid publication/run transitions with `409`.
+
+- [x] **Step 4: Verify GREEN**
+
+Run the Task 1 command and expect all tests to pass.
+
+### Task 2: PostgreSQL Quality Policy, Run, Result, Sample, and Score Ledger
+
+**Files:**
+- Modify: `app/models/data_research.py`
+- Create: `app/core/data_research/device_quality_repository.py`
+- Create: `migrations/versions/20260729_320_device_quality.py`
+- Create: `migrations/versions/20260729_330_device_quality_responsibility_type.py`
+- Modify: `tests/test_database_migrations.py`
+- Create: `tests/integration/test_device_quality_postgres.py`
+
+**Interfaces:**
+- Add `device_quality_profiles`, `device_quality_profile_versions`, `device_quality_runs`, `device_quality_rule_results`, `device_quality_violation_samples`, and `device_quality_asset_scores`.
+- Use UUIDv7 identities, JSONB policy/evidence snapshots, profile-version and run-result uniqueness, `FOR UPDATE` publication locks, and data-preserving downgrade.
+- Active policy is the newest published version not superseded by another published version.
+- Extend the existing responsibility-scope database allowlist with `device_quality`; preserve assignments during application rollback.
+
+- [x] **Step 1: Write failing migration and real-PostgreSQL tests**
+
+Cover schema constraints, idempotent bootstrap, immutable versions, publication serialization, exact run/profile binding, exact counts with truncated samples, asset/source/mapping evidence, per-asset scores, bounded filters, and cleanup limited to test-owned rows.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+TEST_DATABASE_URL=postgresql+psycopg2://dataops:dataops-test-password@127.0.0.1:15432/dataops \
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/test_database_migrations.py::test_device_quality_migration_is_non_destructive_and_reversible \
+  tests/integration/test_device_quality_postgres.py
+```
+
+Expected: failure because migration `20260729_320` and the repository do not exist.
+
+- [x] **Step 3: Implement migration, models, and repository**
+
+Keep historical policy/run evidence on downgrade. Use database constraints for allowed statuses, severity, score ranges, nonnegative counts, one rule result per run/rule, one asset score per run/asset, and one violation sample per run/rule/asset/field identity.
+
+- [x] **Step 4: Upgrade local PostgreSQL and verify GREEN**
+
+Upgrade the isolated local database to `20260729_320`, then rerun the Task 2 command with `TEST_DATABASE_URL`.
+
+### Task 3: Permission-Controlled Device-Quality API
+
+**Files:**
+- Modify: `app/api/data_development/routes.py`
+- Modify: `app/core/system/permissions.py`
+- Modify: `app/core/governance/responsibilities.py`
+- Create: `tests/data_research/test_device_quality_api.py`
+- Modify: `tests/test_permission_matrix.py`
+- Modify: `tests/core/governance/test_responsibilities.py`
+
+**Interfaces:**
+- Produce:
+  - `GET /api/development/v1/device-quality/profile`
+  - `POST /api/development/v1/device-quality/bootstrap`
+  - `POST /api/development/v1/device-quality/profile/versions`
+  - `GET /api/development/v1/device-quality/profile/versions`
+  - `POST /api/development/v1/device-quality/profile/versions/{version_uid}/publish`
+  - `POST /api/development/v1/device-quality/runs`
+  - `GET /api/development/v1/device-quality/runs`
+  - `GET /api/development/v1/device-quality/runs/{run_uid}`
+  - `GET /api/development/v1/device-quality/runs/{run_uid}/violations`
+  - `GET /api/development/v1/device-quality/runs/{run_uid}/asset-scores`
+
+- [x] **Step 1: Write failing API, permission, and responsibility tests**
+
+Cover viewer reads and write denial, editor bootstrap/revise/execute, editor publication denial, admin platform permission plus accountable-manager runtime denial, safe pagination/filters, closed error envelopes, and `device_quality` responsibility validation.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+PYTHONPATH=. .venv/bin/pytest -q \
+  tests/data_research/test_device_quality_api.py \
+  tests/test_permission_matrix.py \
+  tests/core/governance/test_responsibilities.py
+```
+
+Expected: failure because the routes, permissions, and responsibility type do not exist.
+
+- [x] **Step 3: Implement API and authorization**
+
+Add `device-quality:edit` and `device-quality:execute` to editor/admin and `device-quality:publish` to admin. Reads remain on `governance:read`; the service repeats the unique accountable-manager check for publication.
+
+- [x] **Step 4: Verify GREEN**
+
+Run the Task 3 command and expect all tests to pass.
+
+### Task 4: Device-Quality Workbench and Delivery Evidence
+
+**Files:**
+- Modify: `frontend/src/api/dataDevelopment.js`
+- Modify: `frontend/src/router/routes.js`
+- Modify: `frontend/src/views/dataGovernance/development/index.vue`
+- Create: `frontend/src/views/dataGovernance/development/deviceQuality.vue`
+- Create: `frontend/src/views/dataGovernance/development/deviceQualityModel.js`
+- Create: `frontend/tests/device-quality-model.test.mjs`
+- Modify: `docs/architecture/OPENAPI.yaml`
+- Modify: `docs/architecture/DATA_MODEL.md`
+- Modify: `docs/FUNCTION_MODULE_CENSUS_20260726.md`
+- Modify: `docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md`
+- Modify: `deployment/app/` for the WP-07 backend subset.
+
+**Interfaces:**
+- Produce `/data-governance/development/device-quality` with policy/version status, seven rule definitions, last-run score, rule pass rates, violation samples, asset-score drill-down, source evidence, permission-aware bootstrap/revise/publish/run controls, and a clear boundary that remediation belongs to WP-08.
+
+- [x] **Step 1: Write failing frontend model tests**
+
+Cover policy/rule/status labels, score and pass-rate formatting, not-applicable handling, severity labels, permission-aware mutations, and evidence summaries.
+
+- [x] **Step 2: Verify RED**
+
+Run:
+
+```bash
+cd frontend
+node --test tests/device-quality-model.test.mjs
+```
+
+Expected: failure because the frontend model module does not exist.
+
+- [x] **Step 3: Implement the workbench**
+
+Add the development-center entry and responsive workbench. Keep historical policy/run evidence readable to viewers; hide unavailable mutations; show exact/truncated sample counts and the WP-08 boundary.
+
+- [x] **Step 4: Regenerate contracts and update ledgers**
+
+Run:
+
+```bash
+.venv/bin/python scripts/generate_openapi.py \
+  --output docs/architecture/OPENAPI.yaml
+```
+
+Record OBS-07 and OBS-08 as engineering complete pending enterprise data acceptance. Record DQA-02 and DQA-04 as partially built because WP-07 supplies device-domain completeness/uniqueness profiles and asset/domain scores but not a general profiling platform. Keep DQA-07 partial because general schema drift remains outside WP-07.
+
+- [x] **Step 5: Run targeted verification**
+
+Run only the WP-07 domain, PostgreSQL, API, permission, responsibility, migration, OpenAPI, frontend model, targeted lint, production build, release-copy parity, and local browser workflow. In the browser bootstrap a draft, publish it as the accountable asset manager, run it against local device/alarm/maintenance data, inspect score and violation evidence, and confirm zero console errors. Run `git diff --check`.
+
+- [x] **Step 6: Commit**
+
+Create one independently reversible WP-07 engineering commit. Do not push.

+ 49 - 1
frontend/src/api/dataDevelopment.js

@@ -5,6 +5,7 @@ const ONTOLOGY_BASE = '/development/v1/ontologies'
 const DEVICE_ASSET_BASE = '/development/v1/device-assets'
 const DEVICE_SEMANTIC_BASE = '/development/v1/device-semantics'
 const DEVICE_ENTITY_BASE = '/development/v1/device-entities'
+const DEVICE_QUALITY_BASE = '/development/v1/device-quality'
 
 export const createIngestionJob = params => http.post(BASE, params)
 export const getIngestionJobs = params => http.get(BASE, params)
@@ -142,6 +143,43 @@ export const rollbackDeviceEntityMerge = (uid, params) => http.post(
 export const getDeviceEntityRollbacks = uid => http.get(
   `${DEVICE_ENTITY_BASE}/merges/${uid}/rollbacks`
 )
+export const getDeviceQualityProfile = () => http.get(
+  `${DEVICE_QUALITY_BASE}/profile`
+)
+export const bootstrapDeviceQuality = () => http.post(
+  `${DEVICE_QUALITY_BASE}/bootstrap`,
+  {}
+)
+export const createDeviceQualityVersion = params => http.post(
+  `${DEVICE_QUALITY_BASE}/profile/versions`,
+  params
+)
+export const getDeviceQualityVersions = () => http.get(
+  `${DEVICE_QUALITY_BASE}/profile/versions`
+)
+export const publishDeviceQualityVersion = uid => http.post(
+  `${DEVICE_QUALITY_BASE}/profile/versions/${uid}/publish`,
+  {}
+)
+export const runDeviceQuality = params => http.post(
+  `${DEVICE_QUALITY_BASE}/runs`,
+  params
+)
+export const getDeviceQualityRuns = params => http.get(
+  `${DEVICE_QUALITY_BASE}/runs`,
+  params
+)
+export const getDeviceQualityRun = uid => http.get(
+  `${DEVICE_QUALITY_BASE}/runs/${uid}`
+)
+export const getDeviceQualityViolations = (uid, params) => http.get(
+  `${DEVICE_QUALITY_BASE}/runs/${uid}/violations`,
+  params
+)
+export const getDeviceQualityAssetScores = (uid, params) => http.get(
+  `${DEVICE_QUALITY_BASE}/runs/${uid}/asset-scores`,
+  params
+)
 
 export default {
   createIngestionJob,
@@ -193,5 +231,15 @@ export default {
   getDeviceEntityReviews,
   getDeviceEntityMerges,
   rollbackDeviceEntityMerge,
-  getDeviceEntityRollbacks
+  getDeviceEntityRollbacks,
+  getDeviceQualityProfile,
+  bootstrapDeviceQuality,
+  createDeviceQualityVersion,
+  getDeviceQualityVersions,
+  publishDeviceQualityVersion,
+  runDeviceQuality,
+  getDeviceQualityRuns,
+  getDeviceQualityRun,
+  getDeviceQualityViolations,
+  getDeviceQualityAssetScores
 }

+ 12 - 0
frontend/src/router/routes.js

@@ -240,6 +240,18 @@ export default {
           name: 'deviceEntityResolution',
           alwaysShow: 0
         },
+        {
+          hidden: 1,
+          type: 1,
+          title: '设备质量',
+          path: '/data-governance/development/device-quality',
+          children: [],
+          label: '设备质量',
+          component: 'dataGovernance/development/deviceQuality',
+          meta: { roles: ['viewer', 'editor', 'admin'], title: '设备台账与故障质量', readOnly: 'viewer' },
+          name: 'deviceQuality',
+          alwaysShow: 0
+        },
         {
           hidden: 1,
           type: 1,

+ 657 - 0
frontend/src/views/dataGovernance/development/deviceQuality.vue

@@ -0,0 +1,657 @@
+<template>
+  <div class="pa-6 device-quality">
+    <div class="d-flex flex-wrap align-center mb-5">
+      <div>
+        <h1 class="text-h4 mb-1">设备台账与故障质量</h1>
+        <div class="text--secondary">
+          以版本化规则检查设备身份、来源编码、故障语义和维修闭环。
+        </div>
+      </div>
+      <v-spacer />
+      <v-btn
+        v-if="canEdit && !latestVersion"
+        outlined
+        color="primary"
+        class="mr-2"
+        :loading="saving"
+        @click="bootstrap"
+      >
+        初始化规则
+      </v-btn>
+      <v-btn
+        v-if="canEdit && latestVersion"
+        outlined
+        color="primary"
+        class="mr-2"
+        @click="openRevision"
+      >
+        新建规则版本
+      </v-btn>
+      <v-btn
+        v-if="canPublishPolicy && latestVersion && latestVersion.status === 'draft'"
+        outlined
+        color="success"
+        class="mr-2"
+        :loading="saving"
+        @click="publishLatest"
+      >
+        发布当前版本
+      </v-btn>
+      <v-btn
+        v-if="canExecute"
+        color="primary"
+        :disabled="!activeVersion"
+        @click="runDialog = true"
+      >
+        执行质量检查
+      </v-btn>
+    </div>
+
+    <v-alert type="info" outlined class="mb-5">
+      质量检查读取设备台账和已发布语义代码,不改写来源数据。违规修复、责任派发和复验闭环属于 WP08。
+    </v-alert>
+
+    <v-row class="mb-1">
+      <v-col cols="12" sm="6" lg="3">
+        <v-card outlined class="pa-4 summary-card">
+          <div class="text-caption text--secondary">策略状态</div>
+          <div class="text-h6 mt-2">
+            {{ activeVersion ? '已生效' : (latestVersion ? '待发布' : '未初始化') }}
+          </div>
+          <div class="text-caption mt-1">
+            {{ latestVersion ? `最新 v${latestVersion.version}` : '暂无规则版本' }}
+          </div>
+        </v-card>
+      </v-col>
+      <v-col cols="12" sm="6" lg="3">
+        <v-card outlined class="pa-4 summary-card">
+          <div class="text-caption text--secondary">生效版本</div>
+          <div class="text-h6 mt-2">
+            {{ activeVersion ? `v${activeVersion.version}` : '—' }}
+          </div>
+          <div class="text-caption mono mt-1">
+            {{ activeVersion ? activeVersion.content_hash.slice(0, 12) : '等待发布' }}
+          </div>
+        </v-card>
+      </v-col>
+      <v-col cols="12" sm="6" lg="3">
+        <v-card outlined class="pa-4 summary-card">
+          <div class="text-caption text--secondary">最近质量得分</div>
+          <div class="text-h5 mt-2">
+            {{ latestRun ? formatScore(latestRun.score) : '—' }}
+          </div>
+          <div class="text-caption mt-1">
+            {{ latestRun ? `${latestRun.total_violations} 项违规` : '尚未执行' }}
+          </div>
+        </v-card>
+      </v-col>
+      <v-col cols="12" sm="6" lg="3">
+        <v-card outlined class="pa-4 summary-card">
+          <div class="text-caption text--secondary">最近检查资产</div>
+          <div class="text-h5 mt-2">
+            {{ latestRun ? latestRun.total_assets : '—' }}
+          </div>
+          <div class="text-caption mt-1">
+            {{ latestRun ? displayTime(latestRun.created_at) : '尚未执行' }}
+          </div>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <v-card outlined class="mb-5">
+      <v-card-title>
+        质量策略
+        <v-spacer />
+        <v-chip v-if="latestVersion" small outlined>
+          {{ policyStatusLabel(latestVersion.status) }}
+        </v-chip>
+      </v-card-title>
+      <v-divider />
+      <v-data-table
+        :headers="ruleHeaders"
+        :items="displayRules"
+        :loading="loadingProfile"
+        disable-pagination
+        hide-default-footer
+      >
+        <template v-slot:[`item.code`]="{ item }">
+          <strong>{{ ruleLabel(item.code) }}</strong>
+          <div class="text-caption text--secondary mono">{{ item.code }}</div>
+        </template>
+        <template v-slot:[`item.enabled`]="{ item }">
+          <v-chip small :color="item.enabled ? 'success' : 'grey'" text-color="white">
+            {{ item.enabled ? '启用' : '停用' }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.severity`]="{ item }">
+          {{ severityLabel(item.severity) }}
+        </template>
+        <template v-slot:[`item.weight`]="{ item }">
+          {{ formatScore(item.weight) }}
+        </template>
+        <template v-slot:no-data>
+          <div class="py-8 text--secondary">尚未初始化设备质量策略</div>
+        </template>
+      </v-data-table>
+    </v-card>
+
+    <v-card outlined class="mb-5">
+      <v-card-title>质量检查记录</v-card-title>
+      <v-divider />
+      <v-data-table
+        :headers="runHeaders"
+        :items="runs"
+        :loading="loadingRuns"
+        :items-per-page="runPageSize"
+        hide-default-footer
+      >
+        <template v-slot:[`item.score`]="{ item }">
+          <strong>{{ formatScore(item.score) }}</strong>
+        </template>
+        <template v-slot:[`item.scope`]="{ item }">
+          <span class="mono">{{ item.source_uid || '全部来源' }}</span>
+        </template>
+        <template v-slot:[`item.created_at`]="{ item }">
+          {{ displayTime(item.created_at) }}
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn text small color="primary" @click="openRun(item)">查看结果</v-btn>
+        </template>
+        <template v-slot:no-data>
+          <div class="py-8 text--secondary">暂无质量检查记录</div>
+        </template>
+      </v-data-table>
+      <v-divider />
+      <div class="d-flex align-center pa-4">
+        <span class="text-caption text--secondary">
+          共 {{ runTotal }} 次检查
+        </span>
+        <v-spacer />
+        <v-pagination
+          v-model="runPage"
+          :length="runPageCount"
+          :total-visible="7"
+          @input="loadRuns"
+        />
+      </div>
+    </v-card>
+
+    <v-dialog v-model="detailDialog" max-width="1200" scrollable>
+      <v-card>
+        <v-card-title>
+          质量检查结果
+          <v-spacer />
+          <v-btn icon @click="detailDialog = false"><v-icon>mdi-close</v-icon></v-btn>
+        </v-card-title>
+        <v-divider />
+        <v-card-text class="pt-5">
+          <v-progress-linear v-if="detailLoading" indeterminate />
+          <template v-else-if="selectedRun">
+            <v-row class="mb-2">
+              <v-col cols="12" sm="4">
+                <div class="field-label">质量得分</div>
+                <div class="text-h5">{{ formatScore(selectedRun.score) }}</div>
+              </v-col>
+              <v-col cols="12" sm="4">
+                <div class="field-label">检查资产 / 违规</div>
+                <div>{{ selectedRun.total_assets }} / {{ selectedRun.total_violations }}</div>
+              </v-col>
+              <v-col cols="12" sm="4">
+                <div class="field-label">策略证据</div>
+                <div class="mono">{{ selectedRun.policy_hash }}</div>
+              </v-col>
+            </v-row>
+
+            <h3 class="text-subtitle-1 font-weight-bold mb-2">规则结果</h3>
+            <v-simple-table dense class="mb-6">
+              <thead>
+                <tr>
+                  <th>规则</th>
+                  <th>状态</th>
+                  <th>检查数</th>
+                  <th>违规数</th>
+                  <th>通过率</th>
+                  <th>得分贡献</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="item in selectedRun.rule_results || []" :key="item.uid">
+                  <td>{{ ruleLabel(item.rule_code) }}</td>
+                  <td>{{ resultStatusLabel(item.status) }}</td>
+                  <td>{{ item.evaluated_count }}</td>
+                  <td>{{ item.violation_count }}</td>
+                  <td>{{ formatPassRate(item.pass_rate) }}</td>
+                  <td>{{ formatScore(item.weighted_score) }} / {{ formatScore(item.weight) }}</td>
+                </tr>
+              </tbody>
+            </v-simple-table>
+
+            <v-tabs v-model="detailTab" class="mb-3">
+              <v-tab>违规样本</v-tab>
+              <v-tab>资产评分</v-tab>
+            </v-tabs>
+            <v-tabs-items v-model="detailTab">
+              <v-tab-item>
+                <v-data-table
+                  :headers="violationHeaders"
+                  :items="violations"
+                  :items-per-page="20"
+                  hide-default-footer
+                >
+                  <template v-slot:[`item.rule_code`]="{ item }">
+                    {{ ruleLabel(item.rule_code) }}
+                  </template>
+                  <template v-slot:[`item.evidence`]="{ item }">
+                    <div class="text-caption">
+                      来源映射:{{ evidenceSummary(item.evidence).sourceMappingUid || '—' }}
+                    </div>
+                    <div class="text-caption">
+                      异常字段数:{{ evidenceSummary(item.evidence).issueCount }}
+                    </div>
+                  </template>
+                  <template v-slot:no-data>
+                    <div class="py-6 text--secondary">没有违规样本</div>
+                  </template>
+                </v-data-table>
+                <div class="text-caption text--secondary mt-2">
+                  当前展示 {{ violations.length }} / {{ violationTotal }} 条;每条规则最多保留 100 条样本,30 天后清理。
+                </div>
+              </v-tab-item>
+              <v-tab-item>
+                <v-data-table
+                  :headers="assetScoreHeaders"
+                  :items="assetScores"
+                  :items-per-page="20"
+                  hide-default-footer
+                >
+                  <template v-slot:[`item.score`]="{ item }">
+                    <strong>{{ formatScore(item.score) }}</strong>
+                  </template>
+                  <template v-slot:[`item.status`]="{ item }">
+                    {{ resultStatusLabel(item.status) }}
+                  </template>
+                  <template v-slot:no-data>
+                    <div class="py-6 text--secondary">没有资产评分</div>
+                  </template>
+                </v-data-table>
+                <div class="text-caption text--secondary mt-2">
+                  当前展示 {{ assetScores.length }} / {{ assetScoreTotal }} 条资产评分。
+                </div>
+              </v-tab-item>
+            </v-tabs-items>
+          </template>
+        </v-card-text>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="revisionDialog" max-width="980" scrollable>
+      <v-card>
+        <v-card-title>新建质量规则版本</v-card-title>
+        <v-divider />
+        <v-card-text class="pt-5">
+          <v-alert dense outlined type="info">
+            规则代码和参数边界固定;可调整启用状态、严重度和权重。启用规则权重合计必须为 100。
+          </v-alert>
+          <v-simple-table dense>
+            <thead>
+              <tr>
+                <th>规则</th>
+                <th>启用</th>
+                <th>严重度</th>
+                <th>权重</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="rule in revisionRules" :key="rule.code">
+                <td>{{ ruleLabel(rule.code) }}</td>
+                <td><v-switch v-model="rule.enabled" dense hide-details /></td>
+                <td>
+                  <v-select
+                    v-model="rule.severity"
+                    :items="severityOptions"
+                    item-text="text"
+                    item-value="value"
+                    dense
+                    hide-details
+                  />
+                </td>
+                <td>
+                  <v-text-field
+                    v-model.number="rule.weight"
+                    type="number"
+                    min="0"
+                    max="100"
+                    dense
+                    hide-details
+                  />
+                </td>
+              </tr>
+            </tbody>
+          </v-simple-table>
+          <div class="text-right mt-3">
+            启用权重合计:<strong>{{ enabledWeight }}</strong>
+          </div>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="revisionDialog = false">取消</v-btn>
+          <v-btn
+            color="primary"
+            :disabled="enabledWeight !== 100"
+            :loading="saving"
+            @click="createRevision"
+          >
+            保存草稿
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="runDialog" max-width="580">
+      <v-card>
+        <v-card-title>执行设备质量检查</v-card-title>
+        <v-card-text>
+          <v-text-field
+            v-model.trim="runSourceUid"
+            label="来源 UID(可选)"
+            hint="留空检查全部来源;单次最多检查 5,000 个资产"
+            persistent-hint
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="runDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="saving" @click="executeRun">
+            开始检查
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  bootstrapDeviceQuality,
+  createDeviceQualityVersion,
+  getDeviceQualityAssetScores,
+  getDeviceQualityProfile,
+  getDeviceQualityRun,
+  getDeviceQualityRuns,
+  getDeviceQualityViolations,
+  publishDeviceQualityVersion,
+  runDeviceQuality
+} from '@/api/dataDevelopment'
+import {
+  canBootstrapOrRevise,
+  canPublish,
+  canRun,
+  evidenceSummary,
+  formatPassRate,
+  formatScore,
+  policyStatusLabel,
+  resultStatusLabel,
+  ruleLabel,
+  severityLabel
+} from './deviceQualityModel'
+
+export default {
+  name: 'DeviceQuality',
+  data: () => ({
+    loadingProfile: false,
+    loadingRuns: false,
+    saving: false,
+    detailLoading: false,
+    profile: {},
+    runs: [],
+    runTotal: 0,
+    runPage: 1,
+    runPageSize: 20,
+    selectedRun: null,
+    violations: [],
+    violationTotal: 0,
+    assetScores: [],
+    assetScoreTotal: 0,
+    detailDialog: false,
+    revisionDialog: false,
+    runDialog: false,
+    detailTab: 0,
+    revisionRules: [],
+    runSourceUid: '',
+    severityOptions: [
+      { text: '提示', value: 'info' },
+      { text: '警告', value: 'warning' },
+      { text: '错误', value: 'error' },
+      { text: '严重', value: 'critical' }
+    ],
+    ruleHeaders: [
+      { text: '规则', value: 'code', sortable: false },
+      { text: '状态', value: 'enabled', sortable: false, width: 100 },
+      { text: '严重度', value: 'severity', sortable: false, width: 110 },
+      { text: '权重', value: 'weight', sortable: false, width: 100 }
+    ],
+    runHeaders: [
+      { text: '执行时间', value: 'created_at' },
+      { text: '检查范围', value: 'scope', sortable: false },
+      { text: '资产数', value: 'total_assets' },
+      { text: '违规数', value: 'total_violations' },
+      { text: '得分', value: 'score' },
+      { text: '操作', value: 'actions', sortable: false, width: 120 }
+    ],
+    violationHeaders: [
+      { text: '规则', value: 'rule_code', sortable: false },
+      { text: '资产 UID', value: 'asset_uid', sortable: false },
+      { text: '字段', value: 'field_name', sortable: false },
+      { text: '说明', value: 'message', sortable: false },
+      { text: '证据摘要', value: 'evidence', sortable: false }
+    ],
+    assetScoreHeaders: [
+      { text: '资产 UID', value: 'asset_uid', sortable: false },
+      { text: '类型', value: 'asset_type' },
+      { text: '状态', value: 'status' },
+      { text: '适用规则', value: 'evaluated_rule_count' },
+      { text: '违规数', value: 'violation_count' },
+      { text: '得分', value: 'score' }
+    ]
+  }),
+  computed: {
+    permissions () {
+      return (this.$store.state.user.userInfo || {}).permissions || []
+    },
+    canEdit () {
+      return canBootstrapOrRevise(this.permissions)
+    },
+    canPublishPolicy () {
+      return canPublish(this.permissions)
+    },
+    canExecute () {
+      return canRun(this.permissions)
+    },
+    latestVersion () {
+      return this.profile.latest_version || null
+    },
+    activeVersion () {
+      return this.profile.active_version || null
+    },
+    displayRules () {
+      return (this.latestVersion && this.latestVersion.rules) || []
+    },
+    latestRun () {
+      return this.runs[0] || null
+    },
+    runPageCount () {
+      return Math.max(1, Math.ceil(this.runTotal / this.runPageSize))
+    },
+    enabledWeight () {
+      return Number(this.revisionRules
+        .filter(item => item.enabled)
+        .reduce((total, item) => total + Number(item.weight || 0), 0)
+        .toFixed(6))
+    }
+  },
+  created () {
+    this.load()
+  },
+  methods: {
+    evidenceSummary,
+    formatPassRate,
+    formatScore,
+    policyStatusLabel,
+    resultStatusLabel,
+    ruleLabel,
+    severityLabel,
+    async load () {
+      await Promise.all([this.loadProfile(), this.loadRuns()])
+    },
+    async loadProfile () {
+      this.loadingProfile = true
+      try {
+        const response = await getDeviceQualityProfile()
+        this.profile = response.data || {}
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loadingProfile = false
+      }
+    },
+    async loadRuns () {
+      this.loadingRuns = true
+      try {
+        const response = await getDeviceQualityRuns({
+          page: this.runPage,
+          page_size: this.runPageSize
+        })
+        const data = response.data || {}
+        this.runs = data.records || []
+        this.runTotal = data.total || 0
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loadingRuns = false
+      }
+    },
+    async bootstrap () {
+      this.saving = true
+      try {
+        await bootstrapDeviceQuality()
+        this.$snackbar.success('默认质量规则已初始化为草稿')
+        await this.loadProfile()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    openRevision () {
+      this.revisionRules = JSON.parse(JSON.stringify(this.displayRules))
+      this.revisionDialog = true
+    },
+    async createRevision () {
+      this.saving = true
+      try {
+        await createDeviceQualityVersion({
+          expected_version: this.latestVersion.version,
+          rules: this.revisionRules
+        })
+        this.$snackbar.success('新的质量规则草稿已创建')
+        this.revisionDialog = false
+        await this.loadProfile()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async publishLatest () {
+      this.saving = true
+      try {
+        await publishDeviceQualityVersion(this.latestVersion.uid)
+        this.$snackbar.success('质量规则版本已发布')
+        await this.loadProfile()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async executeRun () {
+      this.saving = true
+      try {
+        const response = await runDeviceQuality({
+          source_uid: this.runSourceUid || null
+        })
+        this.$snackbar.success(
+          `质量检查完成,得分 ${formatScore(response.data.score)}`
+        )
+        this.runDialog = false
+        this.runSourceUid = ''
+        this.runPage = 1
+        await this.loadRuns()
+        await this.openRun(response.data)
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.saving = false
+      }
+    },
+    async openRun (item) {
+      this.detailDialog = true
+      this.detailLoading = true
+      this.detailTab = 0
+      this.selectedRun = null
+      this.violations = []
+      this.assetScores = []
+      try {
+        const [detail, violations, scores] = await Promise.all([
+          getDeviceQualityRun(item.uid),
+          getDeviceQualityViolations(item.uid, {
+            page: 1,
+            page_size: 100
+          }),
+          getDeviceQualityAssetScores(item.uid, {
+            page: 1,
+            page_size: 100
+          })
+        ])
+        this.selectedRun = detail.data
+        this.violations = (violations.data && violations.data.records) || []
+        this.violationTotal = (violations.data && violations.data.total) || 0
+        this.assetScores = (scores.data && scores.data.records) || []
+        this.assetScoreTotal = (scores.data && scores.data.total) || 0
+      } catch (error) {
+        this.$snackbar.error(error)
+        this.detailDialog = false
+      } finally {
+        this.detailLoading = false
+      }
+    },
+    displayTime (value) {
+      if (!value) return '—'
+      return new Date(value).toLocaleString('zh-CN', { hour12: false })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.device-quality {
+  max-width: 1500px;
+  margin: 0 auto;
+}
+
+.summary-card {
+  min-height: 128px;
+}
+
+.field-label {
+  color: rgba(0, 0, 0, 0.6);
+  font-size: 12px;
+  margin-bottom: 4px;
+}
+
+.mono {
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 12px;
+  word-break: break-all;
+}
+</style>

+ 80 - 0
frontend/src/views/dataGovernance/development/deviceQualityModel.js

@@ -0,0 +1,80 @@
+const POLICY_STATUS_LABELS = {
+  draft: '草稿',
+  published: '已发布',
+  superseded: '已停用'
+}
+
+const RULE_LABELS = {
+  asset_identity_complete: '资产身份完整性',
+  asset_context_complete: '设备上下文完整性',
+  source_code_unique_normalized: '来源编码规范与唯一性',
+  component_parent_resolved: '部件父级可解析性',
+  fault_code_mapped: '故障代码映射',
+  fault_reason_action_complete: '故障原因与措施完整性',
+  maintenance_closed_loop: '维修闭环完整性'
+}
+
+const SEVERITY_LABELS = {
+  info: '提示',
+  warning: '警告',
+  error: '错误',
+  critical: '严重'
+}
+
+const RESULT_STATUS_LABELS = {
+  passed: '通过',
+  violated: '存在违规',
+  not_applicable: '不适用'
+}
+
+export function canBootstrapOrRevise (permissions) {
+  return (permissions || []).includes('device-quality:edit')
+}
+
+export function canPublish (permissions) {
+  return (permissions || []).includes('device-quality:publish')
+}
+
+export function canRun (permissions) {
+  return (permissions || []).includes('device-quality:execute')
+}
+
+export function policyStatusLabel (value) {
+  return POLICY_STATUS_LABELS[value] || value || '—'
+}
+
+export function ruleLabel (value) {
+  return RULE_LABELS[value] || value || '—'
+}
+
+export function severityLabel (value) {
+  return SEVERITY_LABELS[value] || value || '—'
+}
+
+export function resultStatusLabel (value) {
+  return RESULT_STATUS_LABELS[value] || value || '—'
+}
+
+export function formatScore (value) {
+  if (value === null || value === undefined || Number.isNaN(Number(value))) {
+    return '—'
+  }
+  return Number(value).toFixed(1)
+}
+
+export function formatPassRate (value) {
+  if (value === null || value === undefined || Number.isNaN(Number(value))) {
+    return '—'
+  }
+  return `${(Number(value) * 100).toFixed(1)}%`
+}
+
+export function evidenceSummary (evidence) {
+  const value = evidence || {}
+  const issues = value.missing_fields || value.invalid_fields || []
+  return {
+    assetUid: value.asset_uid || null,
+    sourceMappingUid: value.source_mapping_uid || null,
+    issueCount: Array.isArray(issues) ? issues.length : 0
+  }
+}

+ 1 - 0
frontend/src/views/dataGovernance/development/index.vue

@@ -26,6 +26,7 @@ export default {
       { title: '治理评审', description: '证据预览、批量决策与数据元素生命周期', icon: 'mdi-clipboard-check-outline', path: '/data-governance/development/review' },
       { title: '设备台账', description: '设备、部件、测点、告警与维护记录统一追溯', icon: 'mdi-factory', path: '/data-governance/development/device-assets' },
       { title: '实体匹配', description: '跨来源匹配候选、审核、非破坏性合并与回滚', icon: 'mdi-vector-link', path: '/data-governance/development/entity-resolution' },
+      { title: '设备质量', description: '规则版本、质量检查、违规样本与资产评分', icon: 'mdi-shield-check-outline', path: '/data-governance/development/device-quality' },
       { title: '本体中心', description: '跨业务域本体定义、校验、发布与回滚', icon: 'mdi-graph-outline', path: '/data-governance/ontology' }
     ]
   })

+ 53 - 0
frontend/tests/device-quality-model.test.mjs

@@ -0,0 +1,53 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+  canBootstrapOrRevise,
+  canPublish,
+  canRun,
+  evidenceSummary,
+  formatPassRate,
+  formatScore,
+  policyStatusLabel,
+  resultStatusLabel,
+  ruleLabel,
+  severityLabel
+} from '../src/views/dataGovernance/development/deviceQualityModel.js'
+
+test('separates policy editing, publishing and execution permissions', () => {
+  assert.equal(canBootstrapOrRevise(['device-quality:edit']), true)
+  assert.equal(canBootstrapOrRevise(['device-quality:execute']), false)
+  assert.equal(canPublish(['device-quality:publish']), true)
+  assert.equal(canPublish(['device-quality:edit']), false)
+  assert.equal(canRun(['device-quality:execute']), true)
+  assert.equal(canRun(['governance:read']), false)
+})
+
+test('presents governed policy and result labels', () => {
+  assert.equal(policyStatusLabel('draft'), '草稿')
+  assert.equal(policyStatusLabel('published'), '已发布')
+  assert.equal(ruleLabel('maintenance_closed_loop'), '维修闭环完整性')
+  assert.equal(severityLabel('critical'), '严重')
+  assert.equal(resultStatusLabel('violated'), '存在违规')
+  assert.equal(resultStatusLabel('not_applicable'), '不适用')
+})
+
+test('formats quality score and pass rate consistently', () => {
+  assert.equal(formatScore(85), '85.0')
+  assert.equal(formatScore(null), '—')
+  assert.equal(formatPassRate(0.856), '85.6%')
+  assert.equal(formatPassRate(undefined), '—')
+})
+
+test('summarizes bounded violation evidence without exposing raw attributes', () => {
+  assert.deepEqual(evidenceSummary({
+    asset_uid: 'asset-1',
+    source_mapping_uid: 'mapping-1',
+    missing_fields: ['location', 'organization'],
+    password: 'must-not-be-shown'
+  }), {
+    assetUid: 'asset-1',
+    sourceMappingUid: 'mapping-1',
+    issueCount: 2
+  })
+})

+ 167 - 0
migrations/versions/20260729_320_device_quality.py

@@ -0,0 +1,167 @@
+"""Add immutable device quality policies, runs, results, and evidence."""
+
+from alembic import op
+
+revision = "20260729_320"
+down_revision = "20260729_310"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_quality_profiles (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            name VARCHAR(300) NOT NULL,
+            created_by VARCHAR(120) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+
+        CREATE TABLE public.device_quality_profile_versions (
+            uid UUID PRIMARY KEY,
+            profile_uid UUID NOT NULL
+                REFERENCES public.device_quality_profiles(uid)
+                ON DELETE RESTRICT,
+            version INTEGER NOT NULL CHECK (version > 0),
+            status VARCHAR(20) NOT NULL DEFAULT 'draft'
+                CHECK (status IN ('draft','published','superseded')),
+            rules JSONB NOT NULL,
+            content_hash CHAR(64) NOT NULL,
+            created_by VARCHAR(120) NOT NULL,
+            published_by VARCHAR(120),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            published_at TIMESTAMPTZ,
+            UNIQUE (profile_uid, version),
+            UNIQUE (profile_uid, content_hash)
+        );
+        CREATE UNIQUE INDEX uq_device_quality_active_profile
+            ON public.device_quality_profile_versions(profile_uid)
+            WHERE status = 'published';
+        CREATE INDEX idx_device_quality_profile_versions
+            ON public.device_quality_profile_versions(
+                profile_uid, version DESC
+            );
+
+        CREATE TABLE public.device_quality_runs (
+            uid UUID PRIMARY KEY,
+            policy_version_uid UUID NOT NULL
+                REFERENCES public.device_quality_profile_versions(uid)
+                ON DELETE RESTRICT,
+            policy_hash CHAR(64) NOT NULL,
+            source_uid UUID
+                REFERENCES public.ingestion_sources(uid)
+                ON DELETE RESTRICT,
+            status VARCHAR(20) NOT NULL
+                CHECK (status IN ('success','failed')),
+            total_assets INTEGER NOT NULL CHECK (total_assets >= 0),
+            total_violations INTEGER NOT NULL CHECK (total_violations >= 0),
+            score DOUBLE PRECISION NOT NULL
+                CHECK (score >= 0 AND score <= 100),
+            created_by VARCHAR(120) NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE INDEX idx_device_quality_runs_created
+            ON public.device_quality_runs(created_at DESC, uid);
+        CREATE INDEX idx_device_quality_runs_policy
+            ON public.device_quality_runs(policy_version_uid, created_at DESC);
+
+        CREATE TABLE public.device_quality_rule_results (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.device_quality_runs(uid)
+                ON DELETE CASCADE,
+            rule_code VARCHAR(120) NOT NULL,
+            severity VARCHAR(20) NOT NULL
+                CHECK (severity IN ('info','warning','error','critical')),
+            weight DOUBLE PRECISION NOT NULL
+                CHECK (weight >= 0 AND weight <= 100),
+            status VARCHAR(30) NOT NULL
+                CHECK (status IN ('passed','violated','not_applicable')),
+            evaluated_count INTEGER NOT NULL CHECK (evaluated_count >= 0),
+            violation_count INTEGER NOT NULL CHECK (
+                violation_count >= 0
+                AND violation_count <= evaluated_count
+            ),
+            sampled_count INTEGER NOT NULL CHECK (
+                sampled_count >= 0
+                AND sampled_count <= violation_count
+            ),
+            pass_rate DOUBLE PRECISION NOT NULL
+                CHECK (pass_rate >= 0 AND pass_rate <= 1),
+            weighted_score DOUBLE PRECISION NOT NULL CHECK (
+                weighted_score >= 0 AND weighted_score <= weight
+            ),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (run_uid, rule_code)
+        );
+        CREATE INDEX idx_device_quality_rule_results
+            ON public.device_quality_rule_results(
+                run_uid, severity, rule_code
+            );
+
+        CREATE TABLE public.device_quality_violation_samples (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.device_quality_runs(uid)
+                ON DELETE CASCADE,
+            rule_code VARCHAR(120) NOT NULL,
+            severity VARCHAR(20) NOT NULL
+                CHECK (severity IN ('info','warning','error','critical')),
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid)
+                ON DELETE RESTRICT,
+            field_name VARCHAR(300) NOT NULL,
+            source_uid UUID
+                REFERENCES public.ingestion_sources(uid)
+                ON DELETE RESTRICT,
+            source_mapping_uid UUID
+                REFERENCES public.device_asset_source_mappings(uid)
+                ON DELETE RESTRICT,
+            message VARCHAR(1000) NOT NULL,
+            evidence JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            expires_at TIMESTAMPTZ NOT NULL,
+            UNIQUE (run_uid, rule_code, asset_uid, field_name)
+        );
+        CREATE INDEX idx_device_quality_violation_run
+            ON public.device_quality_violation_samples(
+                run_uid, rule_code, asset_uid
+            );
+        CREATE INDEX idx_device_quality_violation_retention
+            ON public.device_quality_violation_samples(expires_at);
+
+        CREATE TABLE public.device_quality_asset_scores (
+            uid UUID PRIMARY KEY,
+            run_uid UUID NOT NULL
+                REFERENCES public.device_quality_runs(uid)
+                ON DELETE CASCADE,
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid)
+                ON DELETE RESTRICT,
+            asset_type VARCHAR(40) NOT NULL,
+            status VARCHAR(30) NOT NULL
+                CHECK (status IN ('passed','violated','not_applicable')),
+            evaluated_rule_count INTEGER NOT NULL
+                CHECK (evaluated_rule_count >= 0),
+            violation_count INTEGER NOT NULL CHECK (
+                violation_count >= 0
+                AND violation_count <= evaluated_rule_count
+            ),
+            score DOUBLE PRECISION NOT NULL
+                CHECK (score >= 0 AND score <= 100),
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (run_uid, asset_uid)
+        );
+        CREATE INDEX idx_device_quality_asset_scores
+            ON public.device_quality_asset_scores(
+                run_uid, score, asset_uid
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Quality policies, runs, scores, and violation evidence are retained.
+    pass

+ 39 - 0
migrations/versions/20260729_330_device_quality_responsibility_type.py

@@ -0,0 +1,39 @@
+"""Allow device-quality responsibility scopes."""
+
+from alembic import op
+
+revision = "20260729_330"
+down_revision = "20260729_320"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        ALTER TABLE public.governance_responsibility_scopes
+            DROP CONSTRAINT IF EXISTS
+                governance_responsibility_scopes_resource_type_check;
+
+        ALTER TABLE public.governance_responsibility_scopes
+            ADD CONSTRAINT
+                governance_responsibility_scopes_resource_type_check
+            CHECK (
+                resource_type IN (
+                    'business_domain','device_asset','device_ontology',
+                    'device_mapping','fault_classification','quality_issue',
+                    'device_quality'
+                )
+            ) NOT VALID;
+
+        ALTER TABLE public.governance_responsibility_scopes
+            VALIDATE CONSTRAINT
+                governance_responsibility_scopes_resource_type_check;
+        """
+    )
+
+
+def downgrade() -> None:
+    # Keep the additive responsibility type so existing accountable-manager
+    # assignments remain readable after an application rollback.
+    pass

+ 12 - 1
tests/core/governance/test_responsibilities.py

@@ -2,7 +2,6 @@ from __future__ import annotations
 
 import pytest
 
-
 USER_A = "01900000-0000-7000-8000-000000000101"
 USER_B = "01900000-0000-7000-8000-000000000102"
 
@@ -46,6 +45,18 @@ def test_device_scope_requires_one_accountable_asset_manager():
 
     assert [item.user_id for item in validated] == [USER_A, USER_B]
 
+    quality = validate_matrix(
+        "device_quality",
+        [
+            {
+                "user_id": USER_A,
+                "responsibility_role": "asset_manager",
+                "raci_role": "accountable",
+            }
+        ],
+    )
+    assert quality[0].user_id == USER_A
+
 
 def test_responsibility_matrix_rejects_duplicates_and_unknown_values():
     from app.core.governance.responsibilities import (

+ 542 - 0
tests/data_research/test_device_quality.py

@@ -0,0 +1,542 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import datetime
+
+import pytest
+
+ACTOR_UID = "00000000-0000-7000-8000-000000000701"
+MANAGER_UID = "00000000-0000-7000-8000-000000000702"
+SOURCE_UID = "00000000-0000-7000-8000-000000000703"
+
+
+class MemoryDeviceQualityRepository:
+    def __init__(self, assets=(), code_sets=None):
+        self.profile_uid = "00000000-0000-7000-8000-000000000710"
+        self.versions_by_uid = {}
+        self.version_order = []
+        self.assets = list(assets)
+        self.code_sets = code_sets or {
+            "fault": {"F-001"},
+            "cause": {"C-001"},
+            "action": {"A-001"},
+        }
+        self.run_records = {}
+        self.run_results = {}
+        self.run_violations = {}
+        self.run_scores = {}
+
+    def ensure_profile(self, *, name, actor_uid):
+        del name, actor_uid
+        return self.profile_uid
+
+    def latest_version(self):
+        if not self.version_order:
+            return None
+        return self.versions_by_uid[self.version_order[-1]]
+
+    def find_version_by_hash(self, content_hash):
+        return next(
+            (
+                item
+                for item in self.versions_by_uid.values()
+                if item.content_hash == content_hash
+            ),
+            None,
+        )
+
+    def create_version(self, record):
+        self.versions_by_uid[record.uid] = record
+        self.version_order.append(record.uid)
+        return record
+
+    def get_version(self, version_uid, *, for_update=False):
+        del for_update
+        return self.versions_by_uid.get(version_uid)
+
+    def list_versions(self):
+        return [
+            self.versions_by_uid[uid]
+            for uid in reversed(self.version_order)
+        ]
+
+    def active_version(self):
+        published = [
+            item
+            for item in self.versions_by_uid.values()
+            if item.status == "published"
+        ]
+        return max(published, key=lambda item: item.version, default=None)
+
+    def publish_version(self, record, *, actor_uid, published_at):
+        for uid, item in list(self.versions_by_uid.items()):
+            if item.status == "published":
+                self.versions_by_uid[uid] = replace(
+                    item,
+                    status="superseded",
+                )
+        published = replace(
+            record,
+            status="published",
+            published_by=actor_uid,
+            published_at=published_at,
+        )
+        self.versions_by_uid[record.uid] = published
+        return published
+
+    def load_assets(self, *, source_uid, limit):
+        records = [
+            item
+            for item in self.assets
+            if source_uid is None
+            or any(
+                mapping.source_uid == source_uid
+                for mapping in item.mappings
+            )
+        ]
+        return records[:limit], len(records)
+
+    def published_code_sets(self):
+        return {key: set(value) for key, value in self.code_sets.items()}
+
+    def create_run(self, run, results, violations, asset_scores):
+        self.run_records[run.uid] = run
+        self.run_results[run.uid] = tuple(results)
+        self.run_violations[run.uid] = tuple(violations)
+        self.run_scores[run.uid] = tuple(asset_scores)
+        return run
+
+    def list_runs(self, *, page, page_size):
+        records = list(reversed(tuple(self.run_records.values())))
+        start = (page - 1) * page_size
+        return records[start : start + page_size], len(records)
+
+    def get_run(self, run_uid):
+        return self.run_records.get(run_uid)
+
+    def list_rule_results(self, run_uid):
+        return self.run_results.get(run_uid, ())
+
+    def list_violations(
+        self,
+        run_uid,
+        *,
+        rule_code,
+        page,
+        page_size,
+    ):
+        records = [
+            item
+            for item in self.run_violations.get(run_uid, ())
+            if rule_code is None or item.rule_code == rule_code
+        ]
+        start = (page - 1) * page_size
+        return records[start : start + page_size], len(records)
+
+    def list_asset_scores(self, run_uid, *, page, page_size):
+        records = list(self.run_scores.get(run_uid, ()))
+        start = (page - 1) * page_size
+        return records[start : start + page_size], len(records)
+
+
+def mapping(
+    uid,
+    source_code,
+    *,
+    source_uid=SOURCE_UID,
+    asset_type="device",
+):
+    from app.core.data_research.device_quality import (
+        DeviceQualitySourceMapping,
+    )
+
+    return DeviceQualitySourceMapping(
+        uid=uid,
+        source_uid=source_uid,
+        source_entity="asset.equipment",
+        asset_type=asset_type,
+        source_code=source_code,
+    )
+
+
+def asset(
+    uid,
+    asset_type,
+    source_code,
+    *,
+    location="动力车间",
+    organization="设备动力部",
+    responsible_person="张工",
+    attributes=None,
+):
+    from app.core.data_research.device_quality import (
+        DeviceQualityAssetSnapshot,
+    )
+
+    return DeviceQualityAssetSnapshot(
+        uid=uid,
+        asset_type=asset_type,
+        name=f"{asset_type}-{source_code}",
+        status="active",
+        current_version=1,
+        location=location,
+        organization=organization,
+        responsible_person=responsible_person,
+        attributes=attributes or {},
+        mappings=(
+            mapping(
+                f"{uid}-mapping",
+                source_code,
+                asset_type=asset_type,
+            ),
+        ),
+    )
+
+
+def service(repository, *, manager_uid=MANAGER_UID):
+    from app.core.data_research.device_quality import DeviceQualityService
+
+    identifiers = iter(
+        f"00000000-0000-7000-8000-000000000{value}"
+        for value in range(720, 999)
+    )
+    ticks = iter(
+        datetime(2026, 7, 29, 9, minute)
+        for minute in range(60)
+    )
+
+    def authorize(actor_uid):
+        from app.core.data_research.errors import DeviceQualityForbidden
+
+        if actor_uid != manager_uid:
+            raise DeviceQualityForbidden("accountable manager required")
+
+    return DeviceQualityService(
+        repository,
+        publish_authorizer=authorize,
+        uid_factory=identifiers.__next__,
+        now_factory=ticks.__next__,
+    )
+
+
+def _rules_by_code(rules):
+    return {item["code"]: item for item in rules}
+
+
+def test_default_policy_is_closed_weighted_and_idempotently_versioned():
+    repository = MemoryDeviceQualityRepository()
+    quality = service(repository)
+
+    first = quality.bootstrap(actor_uid=ACTOR_UID)
+    second = quality.bootstrap(actor_uid=ACTOR_UID)
+
+    assert first.uid == second.uid
+    assert first.version == 1
+    assert first.status == "draft"
+    assert {item["code"] for item in first.rules} == {
+        "asset_identity_complete",
+        "asset_context_complete",
+        "source_code_unique_normalized",
+        "component_parent_resolved",
+        "fault_code_mapped",
+        "fault_reason_action_complete",
+        "maintenance_closed_loop",
+    }
+    assert sum(item["weight"] for item in first.rules if item["enabled"]) == 100
+    assert len(repository.versions_by_uid) == 1
+
+
+@pytest.mark.parametrize(
+    ("mutate", "message"),
+    (
+        (
+            lambda rules: rules
+            + [
+                {
+                    "code": "run_python",
+                    "enabled": True,
+                    "severity": "error",
+                    "weight": 1,
+                    "parameters": {},
+                }
+            ],
+            "rule code",
+        ),
+        (
+            lambda rules: [
+                {**rules[0], "weight": 1},
+                *rules[1:],
+            ],
+            "100",
+        ),
+        (
+            lambda rules: [
+                {
+                    **rules[0],
+                    "parameters": {"password": "secret"},
+                },
+                *rules[1:],
+            ],
+            "parameter",
+        ),
+        (
+            lambda rules: [
+                {
+                    **rules[0],
+                    "severity": "blocker",
+                },
+                *rules[1:],
+            ],
+            "severity",
+        ),
+    ),
+)
+def test_policy_revision_rejects_open_or_unsafe_rule_shapes(mutate, message):
+    from app.core.data_research.errors import DeviceQualityInvalid
+
+    repository = MemoryDeviceQualityRepository()
+    quality = service(repository)
+    current = quality.bootstrap(actor_uid=ACTOR_UID)
+
+    with pytest.raises(DeviceQualityInvalid, match=message):
+        quality.revise(
+            rules=mutate([dict(item) for item in current.rules]),
+            expected_version=1,
+            actor_uid=ACTOR_UID,
+        )
+
+
+def test_revision_is_immutable_and_rejects_a_stale_expected_version():
+    from app.core.data_research.errors import DeviceQualityConflict
+
+    repository = MemoryDeviceQualityRepository()
+    quality = service(repository)
+    first = quality.bootstrap(actor_uid=ACTOR_UID)
+    changed = [dict(item) for item in first.rules]
+    changed[0] = {**changed[0], "severity": "warning"}
+
+    second = quality.revise(
+        rules=changed,
+        expected_version=1,
+        actor_uid=ACTOR_UID,
+    )
+
+    assert second.version == 2
+    assert second.uid != first.uid
+    assert _rules_by_code(first.rules)["asset_identity_complete"]["severity"] == (
+        "critical"
+    )
+    assert _rules_by_code(second.rules)["asset_identity_complete"]["severity"] == (
+        "warning"
+    )
+    with pytest.raises(DeviceQualityConflict, match="stale"):
+        quality.revise(
+            rules=changed,
+            expected_version=1,
+            actor_uid=ACTOR_UID,
+        )
+
+
+def test_only_accountable_manager_can_publish_and_one_version_remains_active():
+    from app.core.data_research.errors import DeviceQualityForbidden
+
+    repository = MemoryDeviceQualityRepository()
+    quality = service(repository)
+    first = quality.bootstrap(actor_uid=ACTOR_UID)
+
+    with pytest.raises(DeviceQualityForbidden):
+        quality.publish(first.uid, actor_uid=ACTOR_UID)
+
+    published = quality.publish(first.uid, actor_uid=MANAGER_UID)
+    changed = [dict(item) for item in first.rules]
+    changed[0] = {**changed[0], "severity": "warning"}
+    second = quality.revise(
+        rules=changed,
+        expected_version=1,
+        actor_uid=ACTOR_UID,
+    )
+    quality.publish(second.uid, actor_uid=MANAGER_UID)
+
+    assert published.status == "published"
+    assert repository.get_version(first.uid).status == "superseded"
+    assert repository.active_version().uid == second.uid
+
+
+def test_quality_run_evaluates_ledger_fault_and_maintenance_rules_with_evidence():
+    records = [
+        asset(
+            "asset-device",
+            "device",
+            "EQ-001",
+            location="",
+        ),
+        asset(
+            "asset-component",
+            "component",
+            "PART-001",
+            attributes={"parent_source_code": "MISSING"},
+        ),
+        asset(
+            "asset-alarm",
+            "alarm",
+            "ALARM-001",
+            attributes={
+                "fault_code": "F-UNKNOWN",
+                "cause_code": "",
+                "action_code": "A-UNKNOWN",
+            },
+        ),
+        asset(
+            "asset-maintenance",
+            "maintenance_record",
+            "WO-001",
+            attributes={
+                "device_source_code": "EQ-001",
+                "fault_source_code": "ALARM-001",
+                "status": "open",
+                "completed_at": "",
+                "action_code": "A-UNKNOWN",
+            },
+        ),
+    ]
+    repository = MemoryDeviceQualityRepository(records)
+    quality = service(repository)
+    version = quality.bootstrap(actor_uid=ACTOR_UID)
+    quality.publish(version.uid, actor_uid=MANAGER_UID)
+
+    run = quality.run(actor_uid=ACTOR_UID)
+    result_by_code = {
+        item.rule_code: item
+        for item in repository.run_results[run.uid]
+    }
+    violations = repository.run_violations[run.uid]
+
+    assert run.status == "success"
+    assert run.total_assets == 4
+    assert run.total_violations == 5
+    assert run.score == pytest.approx(25.0)
+    assert result_by_code["asset_identity_complete"].violation_count == 0
+    assert result_by_code["asset_context_complete"].violation_count == 1
+    assert result_by_code["component_parent_resolved"].violation_count == 1
+    assert result_by_code["fault_code_mapped"].violation_count == 1
+    assert (
+        result_by_code["fault_reason_action_complete"].violation_count == 1
+    )
+    assert result_by_code["maintenance_closed_loop"].violation_count == 1
+    assert {
+        item.asset_uid
+        for item in violations
+    } == {
+        "asset-device",
+        "asset-component",
+        "asset-alarm",
+        "asset-maintenance",
+    }
+    assert all(item.source_uid == SOURCE_UID for item in violations)
+    assert all(item.source_mapping_uid.endswith("-mapping") for item in violations)
+    assert all("password" not in str(item.evidence).lower() for item in violations)
+
+
+def test_normalized_source_code_uniqueness_detects_case_and_spacing_collisions():
+    left = asset("asset-left", "device", "EQ-001")
+    right = asset("asset-right", "device", " eq-001 ")
+    repository = MemoryDeviceQualityRepository([left, right])
+    quality = service(repository)
+    version = quality.bootstrap(actor_uid=ACTOR_UID)
+    quality.publish(version.uid, actor_uid=MANAGER_UID)
+
+    run = quality.run(actor_uid=ACTOR_UID)
+    unique = next(
+        item
+        for item in repository.run_results[run.uid]
+        if item.rule_code == "source_code_unique_normalized"
+    )
+
+    assert unique.evaluated_count == 2
+    assert unique.violation_count == 2
+    assert unique.pass_rate == 0
+
+
+def test_zero_applicable_rules_receive_full_weight_and_not_applicable_status():
+    repository = MemoryDeviceQualityRepository(
+        [asset("asset-device", "device", "EQ-001")]
+    )
+    quality = service(repository)
+    version = quality.bootstrap(actor_uid=ACTOR_UID)
+    quality.publish(version.uid, actor_uid=MANAGER_UID)
+
+    run = quality.run(actor_uid=ACTOR_UID)
+    results = repository.run_results[run.uid]
+
+    assert run.score == 100
+    assert next(
+        item
+        for item in results
+        if item.rule_code == "maintenance_closed_loop"
+    ).status == "not_applicable"
+    assert repository.run_scores[run.uid][0].score == 100
+
+
+def test_run_binds_exact_policy_and_keeps_samples_bounded_without_losing_counts():
+    records = [
+        asset(
+            f"asset-{index}",
+            "device",
+            f"EQ-{index:04d}",
+            location="",
+        )
+        for index in range(101)
+    ]
+    repository = MemoryDeviceQualityRepository(records)
+    quality = service(repository)
+    version = quality.bootstrap(actor_uid=ACTOR_UID)
+    quality.publish(version.uid, actor_uid=MANAGER_UID)
+
+    run = quality.run(actor_uid=ACTOR_UID, source_uid=SOURCE_UID)
+    context_result = next(
+        item
+        for item in repository.run_results[run.uid]
+        if item.rule_code == "asset_context_complete"
+    )
+
+    assert run.policy_version_uid == version.uid
+    assert run.policy_hash == version.content_hash
+    assert context_result.violation_count == 101
+    assert context_result.sampled_count == 100
+    assert len(
+        [
+            item
+            for item in repository.run_violations[run.uid]
+            if item.rule_code == "asset_context_complete"
+        ]
+    ) == 100
+    assert repository.assets[0].location == ""
+
+
+def test_run_refuses_more_than_five_thousand_assets():
+    from app.core.data_research.errors import DeviceQualityInvalid
+
+    repository = MemoryDeviceQualityRepository(
+        [
+            asset(f"asset-{index}", "device", f"EQ-{index:05d}")
+            for index in range(5001)
+        ]
+    )
+    quality = service(repository)
+    version = quality.bootstrap(actor_uid=ACTOR_UID)
+    quality.publish(version.uid, actor_uid=MANAGER_UID)
+
+    with pytest.raises(DeviceQualityInvalid, match="5,000"):
+        quality.run(actor_uid=ACTOR_UID)
+
+
+def test_run_rejects_an_invalid_source_uid_before_repository_access():
+    from app.core.data_research.errors import DeviceQualityInvalid
+
+    repository = MemoryDeviceQualityRepository()
+    quality = service(repository)
+    version = quality.bootstrap(actor_uid=ACTOR_UID)
+    quality.publish(version.uid, actor_uid=MANAGER_UID)
+
+    with pytest.raises(DeviceQualityInvalid, match="source_uid"):
+        quality.run(actor_uid=ACTOR_UID, source_uid="not-a-uuid")

+ 283 - 0
tests/data_research/test_device_quality_api.py

@@ -0,0 +1,283 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+VERSION_UID = "01900000-0000-7000-8000-000000000801"
+RUN_UID = "01900000-0000-7000-8000-000000000802"
+ASSET_UID = "01900000-0000-7000-8000-000000000803"
+SOURCE_UID = "01900000-0000-7000-8000-000000000804"
+MAPPING_UID = "01900000-0000-7000-8000-000000000805"
+
+
+class FakeDeviceQualityService:
+    def __init__(self):
+        from app.core.data_research.device_quality import (
+            DEFAULT_DEVICE_QUALITY_RULES,
+            DeviceQualityAssetScoreRecord,
+            DeviceQualityPolicyVersionRecord,
+            DeviceQualityRuleResultRecord,
+            DeviceQualityRunRecord,
+            DeviceQualityViolationRecord,
+            validate_device_quality_rules,
+        )
+
+        now = datetime(2026, 7, 29, 17, 0, tzinfo=UTC)
+        self.version = DeviceQualityPolicyVersionRecord(
+            uid=VERSION_UID,
+            profile_uid="01900000-0000-7000-8000-000000000806",
+            version=1,
+            status="published",
+            rules=validate_device_quality_rules(
+                DEFAULT_DEVICE_QUALITY_RULES
+            ),
+            content_hash="a" * 64,
+            created_by="editor-1",
+            created_at=now,
+            published_by="admin-1",
+            published_at=now,
+        )
+        self.run_record = DeviceQualityRunRecord(
+            uid=RUN_UID,
+            policy_version_uid=VERSION_UID,
+            policy_hash="a" * 64,
+            source_uid=SOURCE_UID,
+            status="success",
+            total_assets=1,
+            total_violations=1,
+            score=85.0,
+            created_by="editor-1",
+            created_at=now,
+        )
+        self.rule_result = DeviceQualityRuleResultRecord(
+            uid="01900000-0000-7000-8000-000000000807",
+            run_uid=RUN_UID,
+            rule_code="asset_context_complete",
+            severity="error",
+            weight=15.0,
+            status="violated",
+            evaluated_count=1,
+            violation_count=1,
+            sampled_count=1,
+            pass_rate=0.0,
+            weighted_score=0.0,
+            created_at=now,
+        )
+        self.violation = DeviceQualityViolationRecord(
+            uid="01900000-0000-7000-8000-000000000808",
+            run_uid=RUN_UID,
+            rule_code="asset_context_complete",
+            severity="error",
+            asset_uid=ASSET_UID,
+            field_name="location",
+            source_uid=SOURCE_UID,
+            source_mapping_uid=MAPPING_UID,
+            message="设备台账位置、组织或责任人不完整",
+            evidence={
+                "asset_uid": ASSET_UID,
+                "source_mapping_uid": MAPPING_UID,
+                "missing_fields": ["location"],
+            },
+            created_at=now,
+            expires_at=now + timedelta(days=30),
+        )
+        self.asset_score = DeviceQualityAssetScoreRecord(
+            uid="01900000-0000-7000-8000-000000000809",
+            run_uid=RUN_UID,
+            asset_uid=ASSET_UID,
+            asset_type="device",
+            status="violated",
+            evaluated_rule_count=3,
+            violation_count=1,
+            score=62.5,
+            created_at=now,
+        )
+        self.actions = []
+
+    def profile(self):
+        self.actions.append(("profile",))
+        return {
+            "profile_uid": self.version.profile_uid,
+            "name": "设备台账与故障质量策略",
+            "latest_version": self.version,
+            "active_version": self.version,
+        }
+
+    def bootstrap(self, *, actor_uid):
+        self.actions.append(("bootstrap", actor_uid))
+        return self.version
+
+    def revise(self, *, rules, expected_version, actor_uid):
+        self.actions.append(
+            ("revise", rules, expected_version, actor_uid)
+        )
+        return self.version
+
+    def versions(self):
+        self.actions.append(("versions",))
+        return [self.version]
+
+    def publish(self, version_uid, *, actor_uid):
+        self.actions.append(("publish", version_uid, actor_uid))
+        return self.version
+
+    def run(self, *, actor_uid, source_uid=None):
+        self.actions.append(("run", actor_uid, source_uid))
+        return self.run_record
+
+    def runs(self, *, page, page_size):
+        self.actions.append(("runs", page, page_size))
+        return [self.run_record], 1
+
+    def get_run(self, run_uid):
+        self.actions.append(("get_run", run_uid))
+        return self.run_record, [self.rule_result]
+
+    def violations(self, run_uid, *, rule_code, page, page_size):
+        self.actions.append(
+            ("violations", run_uid, rule_code, page, page_size)
+        )
+        return [self.violation], 1
+
+    def asset_scores(self, run_uid, *, page, page_size):
+        self.actions.append(("asset_scores", run_uid, page, page_size))
+        return [self.asset_score], 1
+
+
+@pytest.fixture()
+def client(monkeypatch):
+    from flask import request
+
+    from app import create_app
+    from app.api.data_development import routes
+    from app.core.system import permissions
+
+    service = FakeDeviceQualityService()
+
+    def identity():
+        role = request.headers.get(
+            "Authorization",
+            "",
+        ).removeprefix("Bearer ")
+        if role not in {"viewer", "editor", "admin"}:
+            return None
+        return {"id": f"{role}-1", "roles": [role]}
+
+    monkeypatch.setattr(permissions, "authenticate_request", identity)
+    monkeypatch.setattr(
+        routes,
+        "get_device_quality_service",
+        lambda: service,
+        raising=False,
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def test_viewer_reads_profile_runs_and_evidence_but_cannot_bootstrap(client):
+    http, service = client
+    headers = {"Authorization": "Bearer viewer"}
+
+    profile = http.get(
+        "/api/development/v1/device-quality/profile",
+        headers=headers,
+    )
+    versions = http.get(
+        "/api/development/v1/device-quality/profile/versions",
+        headers=headers,
+    )
+    runs = http.get(
+        "/api/development/v1/device-quality/runs?page=1&page_size=20",
+        headers=headers,
+    )
+    detail = http.get(
+        f"/api/development/v1/device-quality/runs/{RUN_UID}",
+        headers=headers,
+    )
+    violations = http.get(
+        f"/api/development/v1/device-quality/runs/{RUN_UID}/violations"
+        "?rule_code=asset_context_complete&page=1&page_size=20",
+        headers=headers,
+    )
+    scores = http.get(
+        f"/api/development/v1/device-quality/runs/{RUN_UID}/asset-scores"
+        "?page=1&page_size=20",
+        headers=headers,
+    )
+    denied = http.post(
+        "/api/development/v1/device-quality/bootstrap",
+        headers=headers,
+        json={},
+    )
+
+    assert profile.status_code == 200
+    assert profile.get_json()["data"]["active_version"]["version"] == 1
+    assert len(profile.get_json()["data"]["active_version"]["rules"]) == 7
+    assert versions.get_json()["data"]["total"] == 1
+    assert runs.get_json()["data"]["records"][0]["score"] == 85.0
+    assert detail.get_json()["data"]["rule_results"][0]["pass_rate"] == 0.0
+    assert violations.get_json()["data"]["records"][0]["asset_uid"] == (
+        ASSET_UID
+    )
+    assert violations.get_json()["data"]["records"][0]["evidence"] == {
+        "asset_uid": ASSET_UID,
+        "missing_fields": ["location"],
+        "source_mapping_uid": MAPPING_UID,
+    }
+    assert scores.get_json()["data"]["records"][0]["score"] == 62.5
+    assert denied.status_code == 403
+    assert service.actions[0] == ("profile",)
+
+
+def test_editor_can_bootstrap_revise_and_execute_but_cannot_publish(client):
+    http, service = client
+    headers = {"Authorization": "Bearer editor"}
+
+    bootstrapped = http.post(
+        "/api/development/v1/device-quality/bootstrap",
+        headers=headers,
+        json={},
+    )
+    revised = http.post(
+        "/api/development/v1/device-quality/profile/versions",
+        headers=headers,
+        json={
+            "expected_version": 1,
+            "rules": list(service.version.rules),
+        },
+    )
+    executed = http.post(
+        "/api/development/v1/device-quality/runs",
+        headers=headers,
+        json={"source_uid": SOURCE_UID},
+    )
+    denied = http.post(
+        f"/api/development/v1/device-quality/profile/versions/"
+        f"{VERSION_UID}/publish",
+        headers=headers,
+        json={},
+    )
+
+    assert bootstrapped.status_code == 201
+    assert revised.status_code == 201
+    assert executed.status_code == 201
+    assert executed.get_json()["data"]["policy_version_uid"] == VERSION_UID
+    assert denied.status_code == 403
+    assert ("bootstrap", "editor-1") in service.actions
+    assert ("run", "editor-1", SOURCE_UID) in service.actions
+
+
+def test_admin_publishes_exact_version_through_runtime_gate(client):
+    http, service = client
+    response = http.post(
+        f"/api/development/v1/device-quality/profile/versions/"
+        f"{VERSION_UID}/publish",
+        headers={"Authorization": "Bearer admin"},
+        json={},
+    )
+
+    assert response.status_code == 200
+    assert response.get_json()["data"]["status"] == "published"
+    assert service.actions[-1] == ("publish", VERSION_UID, "admin-1")

+ 215 - 0
tests/integration/test_device_quality_postgres.py

@@ -0,0 +1,215 @@
+from __future__ import annotations
+
+import os
+import uuid
+
+import pytest
+from sqlalchemy import create_engine, text
+
+pytestmark = pytest.mark.integration
+
+
+def test_device_quality_responsibility_type_is_allowed_by_postgres():
+    platform_url = os.environ.get("TEST_DATABASE_URL")
+    if not platform_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+
+    engine = create_engine(platform_url)
+    try:
+        with engine.connect() as connection:
+            definition = connection.execute(
+                text(
+                    """
+                    SELECT pg_get_constraintdef(oid)
+                    FROM pg_constraint
+                    WHERE conname =
+                        'governance_responsibility_scopes_resource_type_check'
+                    """
+                )
+            ).scalar_one()
+        assert "device_quality" in definition
+    finally:
+        engine.dispose()
+
+
+def test_device_quality_postgres_keeps_version_run_and_source_evidence(
+    monkeypatch,
+):
+    platform_url = os.environ.get("TEST_DATABASE_URL")
+    if not platform_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+
+    monkeypatch.setenv("DATABASE_URL", platform_url)
+    from app import create_app, db
+    from app.core.data_research.device_quality import DeviceQualityService
+    from app.core.data_research.device_quality_repository import (
+        SqlAlchemyDeviceQualityRepository,
+    )
+    from app.models.data_research import (
+        DeviceAsset,
+        DeviceAssetSourceMapping,
+        DeviceQualityAssetScore,
+        DeviceQualityProfile,
+        DeviceQualityProfileVersion,
+        DeviceQualityRuleResult,
+        DeviceQualityRun,
+        DeviceQualityViolationSample,
+        IngestionSource,
+    )
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    source_uid = str(uuid.uuid4())
+    asset_uid = str(uuid.uuid4())
+    mapping_uid = str(uuid.uuid4())
+    profile_uids = []
+    run_uids = []
+    try:
+        with app.app_context():
+            db.session.add(
+                IngestionSource(
+                    uid=source_uid,
+                    source_type="database",
+                    name="WP07 PostgreSQL 质量验收源",
+                    config={
+                        "database_type": "postgresql",
+                        "database": "acceptance",
+                        "schema": "asset",
+                    },
+                    permission_scope={},
+                    status="active",
+                    created_by="integration-test",
+                )
+            )
+            db.session.add(
+                DeviceAsset(
+                    uid=asset_uid,
+                    asset_type="device",
+                    name="WP07 验收设备",
+                    status="active",
+                    current_version=1,
+                    content_hash="a" * 64,
+                    location="动力车间",
+                    organization="设备动力部",
+                    responsible_person="张工",
+                    attributes={"model": "P-100"},
+                    created_by="integration-test",
+                    updated_by="integration-test",
+                )
+            )
+            db.session.flush()
+            db.session.add(
+                DeviceAssetSourceMapping(
+                    uid=mapping_uid,
+                    asset_uid=asset_uid,
+                    source_uid=source_uid,
+                    source_entity="asset.equipment",
+                    asset_type="device",
+                    source_code="EQ-WP07-001",
+                )
+            )
+            db.session.commit()
+
+            repository = SqlAlchemyDeviceQualityRepository(db.session)
+            quality = DeviceQualityService(
+                repository,
+                publish_authorizer=lambda _actor: None,
+                commit=db.session.commit,
+                rollback=db.session.rollback,
+            )
+            had_profile = repository.latest_version() is not None
+            draft = quality.bootstrap(actor_uid="integration-test")
+            if not had_profile:
+                profile_uids.append(draft.profile_uid)
+            published = (
+                quality.publish(
+                    draft.uid,
+                    actor_uid="integration-test",
+                )
+                if draft.status == "draft"
+                else draft
+            )
+            run = quality.run(
+                actor_uid="integration-test",
+                source_uid=source_uid,
+            )
+            run_uids.append(run.uid)
+            second_run = quality.run(
+                actor_uid="integration-test",
+                source_uid=source_uid,
+            )
+            run_uids.append(second_run.uid)
+
+            loaded, results = quality.get_run(run.uid)
+            violations, violation_total = quality.violations(
+                run.uid,
+                rule_code=None,
+                page=1,
+                page_size=100,
+            )
+            scores, score_total = quality.asset_scores(
+                run.uid,
+                page=1,
+                page_size=100,
+            )
+            recent_runs, run_total = quality.runs(
+                page=1,
+                page_size=100,
+            )
+
+            assert published.status == "published"
+            assert loaded.policy_version_uid == published.uid
+            assert loaded.policy_hash == published.content_hash
+            assert loaded.total_assets == 1
+            assert loaded.score == 100
+            assert len(results) == 7
+            assert violation_total == 0
+            assert violations == []
+            assert score_total == 1
+            assert scores[0].asset_uid == asset_uid
+            assert scores[0].score == 100
+            assert run_total >= 2
+            assert {run.uid, second_run.uid} <= {
+                item.uid for item in recent_runs
+            }
+
+            snapshots, total = repository.load_assets(
+                source_uid=source_uid,
+                limit=5_001,
+            )
+            assert total == 1
+            assert snapshots[0].uid == asset_uid
+            assert snapshots[0].current_version == 1
+            assert snapshots[0].mappings[0].uid == mapping_uid
+    finally:
+        with app.app_context():
+            if run_uids:
+                db.session.query(DeviceQualityViolationSample).filter(
+                    DeviceQualityViolationSample.run_uid.in_(run_uids)
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceQualityAssetScore).filter(
+                    DeviceQualityAssetScore.run_uid.in_(run_uids)
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceQualityRuleResult).filter(
+                    DeviceQualityRuleResult.run_uid.in_(run_uids)
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceQualityRun).filter(
+                    DeviceQualityRun.uid.in_(run_uids)
+                ).delete(synchronize_session=False)
+            if profile_uids:
+                db.session.query(DeviceQualityProfileVersion).filter(
+                    DeviceQualityProfileVersion.profile_uid.in_(profile_uids)
+                ).delete(synchronize_session=False)
+                db.session.query(DeviceQualityProfile).filter(
+                    DeviceQualityProfile.uid.in_(profile_uids)
+                ).delete(synchronize_session=False)
+            db.session.query(DeviceAssetSourceMapping).filter_by(
+                uid=mapping_uid
+            ).delete(synchronize_session=False)
+            db.session.query(DeviceAsset).filter_by(
+                uid=asset_uid
+            ).delete(synchronize_session=False)
+            db.session.query(IngestionSource).filter_by(
+                uid=source_uid
+            ).delete(synchronize_session=False)
+            db.session.commit()

+ 44 - 0
tests/test_database_migrations.py

@@ -180,6 +180,50 @@ def test_device_entity_resolution_migration_is_non_destructive_and_reversible():
     assert "UPDATE public.device_asset_source_mappings" not in migration
 
 
+def test_device_quality_migration_is_non_destructive_and_reversible():
+    migration = (
+        ROOT
+        / "migrations"
+        / "versions"
+        / "20260729_320_device_quality.py"
+    ).read_text(encoding="utf-8")
+
+    assert 'revision = "20260729_320"' in migration
+    assert 'down_revision = "20260729_310"' in migration
+    for table in (
+        "device_quality_profiles",
+        "device_quality_profile_versions",
+        "device_quality_runs",
+        "device_quality_rule_results",
+        "device_quality_violation_samples",
+        "device_quality_asset_scores",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    assert "rules JSONB NOT NULL" in migration
+    assert "policy_hash CHAR(64) NOT NULL" in migration
+    assert "evidence JSONB NOT NULL" in migration
+    assert "UNIQUE (run_uid, rule_code)" in migration
+    assert "UNIQUE (run_uid, asset_uid)" in migration
+    assert "DROP TABLE" not in migration.upper()
+    assert "DELETE FROM public.device_assets" not in migration
+    assert "UPDATE public.device_asset_source_mappings" not in migration
+
+
+def test_device_quality_responsibility_type_migration_is_additive():
+    migration = (
+        ROOT
+        / "migrations"
+        / "versions"
+        / "20260729_330_device_quality_responsibility_type.py"
+    ).read_text(encoding="utf-8")
+
+    assert 'revision = "20260729_330"' in migration
+    assert 'down_revision = "20260729_320"' in migration
+    assert "device_quality" in migration
+    assert "DROP TABLE" not in migration.upper()
+    assert "DELETE FROM" not in migration.upper()
+
+
 def test_data_element_migration_adds_versioned_governance_tables():
     migration = (
         ROOT

+ 17 - 0
tests/test_permission_matrix.py

@@ -26,6 +26,10 @@ def test_fixed_role_permission_matrix_is_monotonic():
     assert "device-entities:edit" in editor
     assert "device-entities:review" not in editor
     assert "device-entities:review" in admin
+    assert "device-quality:edit" in editor
+    assert "device-quality:execute" in editor
+    assert "device-quality:publish" not in editor
+    assert "device-quality:publish" in admin
 
 
 def test_data_development_paths_have_specific_write_policies():
@@ -95,6 +99,19 @@ def test_data_development_paths_have_specific_write_policies():
         "/api/development/v1/device-entities/merges/merge-1/rollback",
         "POST",
     ) == ("device-entities:review",)
+    assert permission_for_request(
+        "/api/development/v1/device-quality/profile", "GET"
+    ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/development/v1/device-quality/bootstrap", "POST"
+    ) == ("device-quality:edit",)
+    assert permission_for_request(
+        "/api/development/v1/device-quality/profile/versions/version-1/publish",
+        "POST",
+    ) == ("device-quality:publish",)
+    assert permission_for_request(
+        "/api/development/v1/device-quality/runs", "POST"
+    ) == ("device-quality:execute",)
 
 
 def test_business_domain_read_endpoints_are_available_to_viewers():