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

feat: add governed quality remediation lifecycle

马小龙 3 недель назад
Родитель
Сommit
75f420bc84
28 измененных файлов с 5768 добавлено и 10 удалено
  1. 326 0
      app/api/data_development/routes.py
  2. 20 0
      app/core/data_research/errors.py
  3. 432 0
      app/core/data_research/quality_issue_repository.py
  4. 595 0
      app/core/data_research/quality_issues.py
  5. 11 0
      app/core/system/permissions.py
  6. 149 0
      app/models/data_research.py
  7. 326 0
      deployment/app/api/data_development/routes.py
  8. 20 0
      deployment/app/core/data_research/errors.py
  9. 432 0
      deployment/app/core/data_research/quality_issue_repository.py
  10. 595 0
      deployment/app/core/data_research/quality_issues.py
  11. 11 0
      deployment/app/core/system/permissions.py
  12. 149 0
      deployment/app/models/data_research.py
  13. 128 0
      deployment/migrations/versions/20260729_340_quality_issue_remediation.py
  14. 1 0
      docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md
  15. 5 3
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  16. 2 1
      docs/architecture/DATA_MODEL.md
  17. 294 1
      docs/architecture/OPENAPI.yaml
  18. 96 0
      docs/superpowers/plans/2026-07-29-wp08-quality-remediation.md
  19. 52 1
      frontend/src/api/dataDevelopment.js
  20. 728 3
      frontend/src/views/dataGovernance/development/deviceQuality.vue
  21. 57 0
      frontend/src/views/dataGovernance/development/deviceQualityModel.js
  22. 31 1
      frontend/tests/device-quality-model.test.mjs
  23. 128 0
      migrations/versions/20260729_340_quality_issue_remediation.py
  24. 292 0
      tests/data_research/test_quality_issue_api.py
  25. 48 0
      tests/data_research/test_quality_issue_frontend_contract.py
  26. 531 0
      tests/data_research/test_quality_issues.py
  27. 296 0
      tests/integration/test_quality_issue_postgres.py
  28. 13 0
      tests/test_permission_matrix.py

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

@@ -9,6 +9,7 @@ from flask import current_app, g, jsonify, request, send_file
 
 from app import db
 from app.api.data_development import bp
+from app.core.common.timezone_utils import now_china
 from app.core.data_research.errors import DataResearchError
 from app.models.result import failed, success
 
@@ -253,6 +254,52 @@ def get_device_quality_service():
     )
 
 
+def get_quality_issue_service():
+    from app.core.data_research.errors import QualityIssueForbidden
+    from app.core.data_research.quality_issue_repository import (
+        SqlAlchemyQualityIssueRepository,
+    )
+    from app.core.data_research.quality_issues import QualityIssueService
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    repository = SqlAlchemyQualityIssueRepository(db.session)
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+
+    def assert_accountable(actor_uid):
+        matrix = responsibilities.get(
+            "quality_issue",
+            "DEVICE_QUALITY_ISSUES",
+        )
+        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 QualityIssueForbidden(
+                "only the accountable quality issue asset manager may review"
+            )
+
+    return QualityIssueService(
+        repository,
+        review_authorizer=assert_accountable,
+        is_admin=lambda actor_uid: repository.user_has_role(
+            actor_uid,
+            "admin",
+        ),
+        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
@@ -794,6 +841,81 @@ def _device_quality_asset_score(record):
     }
 
 
+def _quality_issue(record):
+    return {
+        "uid": str(record.uid),
+        "issue_code": record.issue_code,
+        "source_violation_uid": str(record.source_violation_uid),
+        "source_run_uid": str(record.source_run_uid),
+        "rule_code": record.rule_code,
+        "severity": record.severity,
+        "priority": record.priority,
+        "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 {}),
+        "recurrence_key": record.recurrence_key,
+        "occurrence_number": int(record.occurrence_number),
+        "status": record.status,
+        "assignee_uid": (
+            str(record.assignee_uid) if record.assignee_uid else None
+        ),
+        "due_at": _iso(record.due_at),
+        "is_overdue": bool(record.is_overdue(now_china())),
+        "current_version": int(record.current_version),
+        "created_by": str(record.created_by),
+        "updated_by": str(record.updated_by),
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+        "closed_at": _iso(record.closed_at),
+    }
+
+
+def _quality_issue_remediation(record):
+    if record is None:
+        return None
+    return {
+        "uid": str(record.uid),
+        "issue_uid": str(record.issue_uid),
+        "round_number": int(record.round_number),
+        "summary": record.summary,
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+        "submitted_by": str(record.submitted_by),
+        "submitted_at": _iso(record.submitted_at),
+        "review_status": record.review_status,
+        "reviewed_by": (
+            str(record.reviewed_by) if record.reviewed_by else None
+        ),
+        "reviewed_at": _iso(record.reviewed_at),
+        "review_note": record.review_note,
+        "verification_run_uid": (
+            str(record.verification_run_uid)
+            if record.verification_run_uid else None
+        ),
+    }
+
+
+def _quality_issue_timeline(record):
+    return {
+        "uid": str(record.uid),
+        "issue_uid": str(record.issue_uid),
+        "action": record.action,
+        "from_status": record.from_status,
+        "to_status": record.to_status,
+        "actor_uid": str(record.actor_uid),
+        "note": record.note,
+        "payload": dict(record.payload or {}),
+        "created_at": _iso(record.created_at),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -1622,6 +1744,210 @@ def list_device_quality_asset_scores(run_uid):
         return _error(error)
 
 
+@bp.route("/device-quality/issues", methods=["GET"])
+def list_quality_issues():
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        overdue_only = str(
+            request.args.get("overdue_only", "")
+        ).strip().lower() in {"1", "true", "yes"}
+        records, total = get_quality_issue_service().issues(
+            status=request.args.get("status"),
+            assignee_uid=request.args.get("assignee_uid"),
+            overdue_only=overdue_only,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [_quality_issue(item) for item 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/issues/statistics", methods=["GET"])
+def get_quality_issue_statistics():
+    try:
+        return jsonify(
+            success(get_quality_issue_service().statistics())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/assignees", methods=["GET"])
+def list_quality_issue_assignees():
+    try:
+        records = get_quality_issue_service().assignees()
+        return jsonify(
+            success({"records": list(records), "total": len(records)})
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/import", methods=["POST"])
+def import_quality_issues():
+    try:
+        body = request.get_json(silent=True) or {}
+        result = get_quality_issue_service().import_violations(
+            violation_uids=body.get("violation_uids"),
+            priority=body.get("priority"),
+            due_at=body.get("due_at"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        payload = {
+            "records": [_quality_issue(item) for item in result.records],
+            "created_count": int(result.created_count),
+            "existing_count": int(result.existing_count),
+        }
+        status = 201 if result.created_count else 200
+        return jsonify(success(payload)), status
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/<issue_uid>", methods=["GET"])
+def get_quality_issue(issue_uid):
+    try:
+        issue, remediation = get_quality_issue_service().get(issue_uid)
+        return jsonify(
+            success(
+                {
+                    **_quality_issue(issue),
+                    "latest_remediation": _quality_issue_remediation(
+                        remediation
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/timeline",
+    methods=["GET"],
+)
+def get_quality_issue_timeline(issue_uid):
+    try:
+        records = get_quality_issue_service().timeline(issue_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _quality_issue_timeline(item) for item in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/assign",
+    methods=["POST"],
+)
+def assign_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().assign(
+            issue_uid,
+            assignee_uid=body.get("assignee_uid"),
+            due_at=body.get("due_at"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/start",
+    methods=["POST"],
+)
+def start_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().start(
+            issue_uid,
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/submit",
+    methods=["POST"],
+)
+def submit_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().submit(
+            issue_uid,
+            summary=body.get("summary"),
+            evidence_refs=body.get("evidence_refs", []),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/review",
+    methods=["POST"],
+)
+def review_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().review(
+            issue_uid,
+            verification_result=body.get("verification_result"),
+            note=body.get("note"),
+            verification_run_uid=body.get("verification_run_uid"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/reopen",
+    methods=["POST"],
+)
+def reopen_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().reopen(
+            issue_uid,
+            reason=body.get("reason"),
+            due_at=body.get("due_at"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

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

@@ -118,3 +118,23 @@ class DeviceQualityNotFound(DataResearchError):
 class DeviceQualityConflict(DataResearchError):
     code = "DEVICE_QUALITY_CONFLICT"
     http_status = 409
+
+
+class QualityIssueInvalid(DataResearchError):
+    code = "QUALITY_ISSUE_INVALID"
+    http_status = 422
+
+
+class QualityIssueForbidden(DataResearchError):
+    code = "QUALITY_ISSUE_FORBIDDEN"
+    http_status = 403
+
+
+class QualityIssueNotFound(DataResearchError):
+    code = "QUALITY_ISSUE_NOT_FOUND"
+    http_status = 404
+
+
+class QualityIssueConflict(DataResearchError):
+    code = "QUALITY_ISSUE_CONFLICT"
+    http_status = 409

+ 432 - 0
app/core/data_research/quality_issue_repository.py

@@ -0,0 +1,432 @@
+"""SQLAlchemy persistence for governed device-quality remediation issues."""
+
+from __future__ import annotations
+
+from sqlalchemy import case, func, text
+
+from app.core.data_research.device_quality_repository import (
+    SqlAlchemyDeviceQualityRepository,
+)
+from app.core.data_research.errors import QualityIssueConflict
+from app.core.data_research.quality_issues import (
+    QualityIssueRecord,
+    QualityIssueRemediationRecord,
+    QualityIssueTimelineRecord,
+)
+from app.models.data_research import (
+    DeviceQualityIssue,
+    DeviceQualityIssueRemediation,
+    DeviceQualityIssueTimeline,
+    DeviceQualityRun,
+    DeviceQualityViolationSample,
+)
+
+
+class SqlAlchemyQualityIssueRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _issue(model):
+        return QualityIssueRecord(
+            uid=str(model.uid),
+            issue_code=model.issue_code,
+            source_violation_uid=str(model.source_violation_uid),
+            source_run_uid=str(model.source_run_uid),
+            rule_code=model.rule_code,
+            severity=model.severity,
+            priority=model.priority,
+            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 {}),
+            recurrence_key=model.recurrence_key,
+            occurrence_number=int(model.occurrence_number),
+            status=model.status,
+            assignee_uid=(
+                str(model.assignee_uid) if model.assignee_uid else None
+            ),
+            due_at=model.due_at,
+            current_version=int(model.current_version),
+            created_by=str(model.created_by),
+            updated_by=str(model.updated_by),
+            created_at=model.created_at,
+            updated_at=model.updated_at,
+            closed_at=model.closed_at,
+        )
+
+    @staticmethod
+    def _remediation(model):
+        if model is None:
+            return None
+        return QualityIssueRemediationRecord(
+            uid=str(model.uid),
+            issue_uid=str(model.issue_uid),
+            round_number=int(model.round_number),
+            summary=model.summary,
+            evidence_refs=tuple(
+                dict(item) for item in (model.evidence_refs or [])
+            ),
+            submitted_by=str(model.submitted_by),
+            submitted_at=model.submitted_at,
+            review_status=model.review_status,
+            reviewed_by=(
+                str(model.reviewed_by) if model.reviewed_by else None
+            ),
+            reviewed_at=model.reviewed_at,
+            review_note=model.review_note,
+            verification_run_uid=(
+                str(model.verification_run_uid)
+                if model.verification_run_uid else None
+            ),
+        )
+
+    @staticmethod
+    def _timeline(model):
+        return QualityIssueTimelineRecord(
+            uid=str(model.uid),
+            issue_uid=str(model.issue_uid),
+            action=model.action,
+            from_status=model.from_status,
+            to_status=model.to_status,
+            actor_uid=str(model.actor_uid),
+            note=model.note,
+            payload=dict(model.payload or {}),
+            created_at=model.created_at,
+        )
+
+    def get_violations(self, violation_uids):
+        models = (
+            self.session.query(DeviceQualityViolationSample)
+            .filter(DeviceQualityViolationSample.uid.in_(violation_uids))
+            .all()
+        )
+        by_uid = {
+            str(model.uid): SqlAlchemyDeviceQualityRepository._violation(model)
+            for model in models
+        }
+        return [by_uid[uid] for uid in violation_uids if uid in by_uid]
+
+    def find_open_by_recurrence_key(self, recurrence_key):
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"),
+            {"lock_key": f"quality-issue:{recurrence_key}"},
+        )
+        model = (
+            self.session.query(DeviceQualityIssue)
+            .filter(
+                DeviceQualityIssue.recurrence_key == recurrence_key,
+                DeviceQualityIssue.status != "closed",
+            )
+            .first()
+        )
+        return self._issue(model) if model is not None else None
+
+    def recurrence_count(self, recurrence_key):
+        return int(
+            self.session.query(func.count(DeviceQualityIssue.uid))
+            .filter(DeviceQualityIssue.recurrence_key == recurrence_key)
+            .scalar()
+            or 0
+        )
+
+    @staticmethod
+    def _issue_model(record):
+        return DeviceQualityIssue(
+            uid=record.uid,
+            issue_code=record.issue_code,
+            source_violation_uid=record.source_violation_uid,
+            source_run_uid=record.source_run_uid,
+            rule_code=record.rule_code,
+            severity=record.severity,
+            priority=record.priority,
+            asset_uid=record.asset_uid,
+            field_name=record.field_name,
+            source_uid=record.source_uid,
+            source_mapping_uid=record.source_mapping_uid,
+            message=record.message,
+            evidence=dict(record.evidence),
+            recurrence_key=record.recurrence_key,
+            occurrence_number=record.occurrence_number,
+            status=record.status,
+            assignee_uid=record.assignee_uid,
+            due_at=record.due_at,
+            current_version=record.current_version,
+            created_by=record.created_by,
+            updated_by=record.updated_by,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+            closed_at=record.closed_at,
+        )
+
+    @staticmethod
+    def _event_model(record):
+        return DeviceQualityIssueTimeline(
+            uid=record.uid,
+            issue_uid=record.issue_uid,
+            action=record.action,
+            from_status=record.from_status,
+            to_status=record.to_status,
+            actor_uid=record.actor_uid,
+            note=record.note,
+            payload=dict(record.payload),
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _remediation_model(record):
+        return DeviceQualityIssueRemediation(
+            uid=record.uid,
+            issue_uid=record.issue_uid,
+            round_number=record.round_number,
+            summary=record.summary,
+            evidence_refs=[dict(item) for item in record.evidence_refs],
+            submitted_by=record.submitted_by,
+            submitted_at=record.submitted_at,
+            review_status=record.review_status,
+            reviewed_by=record.reviewed_by,
+            reviewed_at=record.reviewed_at,
+            review_note=record.review_note,
+            verification_run_uid=record.verification_run_uid,
+        )
+
+    def create_issue(self, issue, event):
+        model = self._issue_model(issue)
+        self.session.add(model)
+        self.session.add(self._event_model(event))
+        self.session.flush()
+        return self._issue(model)
+
+    def get_issue(self, issue_uid, *, for_update=False):
+        query = self.session.query(DeviceQualityIssue).filter_by(uid=issue_uid)
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._issue(model) if model is not None else None
+
+    def active_user_exists(self, user_uid):
+        return bool(
+            self.session.execute(
+                text(
+                    "SELECT 1 FROM public.users "
+                    "WHERE id = CAST(:uid AS uuid) AND status = 'active'"
+                ),
+                {"uid": user_uid},
+            ).scalar()
+        )
+
+    def user_has_role(self, user_uid, role):
+        return bool(
+            self.session.execute(
+                text(
+                    """
+                    SELECT 1
+                    FROM public.users u
+                    JOIN public.user_roles ur ON ur.user_id = u.id
+                    JOIN public.roles r ON r.id = ur.role_id
+                    WHERE u.id = CAST(:uid AS uuid)
+                      AND u.status = 'active'
+                      AND r.name = :role
+                    """
+                ),
+                {"uid": user_uid, "role": role},
+            ).scalar()
+        )
+
+    def list_active_users(self):
+        rows = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT id::text AS uid, username, display_name
+                    FROM public.users
+                    WHERE status = 'active'
+                    ORDER BY COALESCE(display_name, username), username
+                    LIMIT 500
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        )
+        return [
+            {
+                "uid": row["uid"],
+                "username": row["username"],
+                "display_name": row["display_name"] or row["username"],
+            }
+            for row in rows
+        ]
+
+    def run_exists(self, run_uid):
+        return bool(
+            self.session.query(DeviceQualityRun.uid)
+            .filter_by(uid=run_uid)
+            .first()
+        )
+
+    def transition(
+        self,
+        issue,
+        *,
+        expected_version,
+        event,
+        remediation=None,
+        remediation_review=None,
+    ):
+        updates = {
+            "status": issue.status,
+            "assignee_uid": issue.assignee_uid,
+            "due_at": issue.due_at,
+            "current_version": issue.current_version,
+            "updated_by": issue.updated_by,
+            "updated_at": issue.updated_at,
+            "closed_at": issue.closed_at,
+        }
+        changed = (
+            self.session.query(DeviceQualityIssue)
+            .filter(
+                DeviceQualityIssue.uid == issue.uid,
+                DeviceQualityIssue.current_version == expected_version,
+            )
+            .update(updates, synchronize_session=False)
+        )
+        if changed != 1:
+            raise QualityIssueConflict("quality issue version is stale")
+        if remediation is not None:
+            self.session.add(self._remediation_model(remediation))
+        if remediation_review is not None:
+            model = (
+                self.session.query(DeviceQualityIssueRemediation)
+                .filter_by(issue_uid=issue.uid)
+                .order_by(
+                    DeviceQualityIssueRemediation.round_number.desc()
+                )
+                .with_for_update()
+                .first()
+            )
+            if model is None:
+                raise QualityIssueConflict("remediation evidence is missing")
+            for field, value in remediation_review.items():
+                setattr(model, field, value)
+        self.session.add(self._event_model(event))
+        self.session.flush()
+        return issue
+
+    def latest_remediation(self, issue_uid):
+        model = (
+            self.session.query(DeviceQualityIssueRemediation)
+            .filter_by(issue_uid=issue_uid)
+            .order_by(DeviceQualityIssueRemediation.round_number.desc())
+            .first()
+        )
+        return self._remediation(model)
+
+    def list_issues(
+        self,
+        *,
+        status,
+        assignee_uid,
+        overdue_only,
+        now,
+        page,
+        page_size,
+    ):
+        query = self.session.query(DeviceQualityIssue)
+        if status:
+            query = query.filter(DeviceQualityIssue.status == status)
+        if assignee_uid:
+            query = query.filter(
+                DeviceQualityIssue.assignee_uid == assignee_uid
+            )
+        if overdue_only:
+            query = query.filter(
+                DeviceQualityIssue.status != "closed",
+                DeviceQualityIssue.due_at.is_not(None),
+                DeviceQualityIssue.due_at < now,
+            )
+        total = int(
+            query.with_entities(
+                func.count(DeviceQualityIssue.uid)
+            ).scalar()
+            or 0
+        )
+        models = (
+            query.order_by(
+                case(
+                    (
+                        DeviceQualityIssue.status != "closed",
+                        0,
+                    ),
+                    else_=1,
+                ),
+                DeviceQualityIssue.due_at.asc().nullslast(),
+                DeviceQualityIssue.created_at.desc(),
+                DeviceQualityIssue.uid,
+            )
+            .offset((page - 1) * page_size)
+            .limit(page_size)
+            .all()
+        )
+        return [self._issue(model) for model in models], total
+
+    def list_timeline(self, issue_uid):
+        models = (
+            self.session.query(DeviceQualityIssueTimeline)
+            .filter_by(issue_uid=issue_uid)
+            .order_by(
+                DeviceQualityIssueTimeline.created_at,
+                DeviceQualityIssueTimeline.uid,
+            )
+            .all()
+        )
+        return [self._timeline(model) for model in models]
+
+    def statistics(self, *, now):
+        total, open_count, closed_count, overdue_count, recurrent_issues = (
+            self.session.query(
+                func.count(DeviceQualityIssue.uid),
+                func.count(DeviceQualityIssue.uid).filter(
+                    DeviceQualityIssue.status != "closed"
+                ),
+                func.count(DeviceQualityIssue.uid).filter(
+                    DeviceQualityIssue.status == "closed"
+                ),
+                func.count(DeviceQualityIssue.uid).filter(
+                    DeviceQualityIssue.status != "closed",
+                    DeviceQualityIssue.due_at.is_not(None),
+                    DeviceQualityIssue.due_at < now,
+                ),
+                func.count(DeviceQualityIssue.uid).filter(
+                    DeviceQualityIssue.occurrence_number > 1
+                ),
+            ).one()
+        )
+        recurrent = (
+            self.session.query(func.count())
+            .select_from(
+                self.session.query(DeviceQualityIssue.recurrence_key)
+                .group_by(DeviceQualityIssue.recurrence_key)
+                .having(func.count(DeviceQualityIssue.uid) > 1)
+                .subquery()
+            )
+            .scalar()
+            or 0
+        )
+        return {
+            "total": int(total),
+            "open": int(open_count),
+            "closed": int(closed_count),
+            "overdue": int(overdue_count),
+            "recurrent": int(recurrent),
+            "recurrent_issues": int(recurrent_issues),
+            "recurrence_rate": (
+                round(int(recurrent_issues) / int(total), 6)
+                if total else 0.0
+            ),
+        }

+ 595 - 0
app/core/data_research/quality_issues.py

@@ -0,0 +1,595 @@
+"""Governed remediation lifecycle for immutable device-quality violations."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import unicodedata
+import uuid
+from collections.abc import Callable
+from dataclasses import dataclass, replace
+from datetime import datetime
+from typing import Any
+
+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 (
+    QualityIssueConflict,
+    QualityIssueForbidden,
+    QualityIssueInvalid,
+    QualityIssueNotFound,
+)
+
+ISSUE_STATUSES = frozenset(
+    {"open", "assigned", "in_progress", "pending_review", "closed"}
+)
+PRIORITIES = frozenset({"low", "medium", "high", "critical"})
+VERIFICATION_RESULTS = frozenset({"passed", "failed"})
+MAX_IMPORT_SIZE = 100
+MAX_EVIDENCE_REFS = 20
+MAX_TEXT = 2_000
+
+
+@dataclass(frozen=True)
+class QualityIssueRecord:
+    uid: str
+    issue_code: str
+    source_violation_uid: str
+    source_run_uid: str
+    rule_code: str
+    severity: str
+    priority: str
+    asset_uid: str
+    field_name: str
+    source_uid: str | None
+    source_mapping_uid: str | None
+    message: str
+    evidence: dict[str, Any]
+    recurrence_key: str
+    occurrence_number: int
+    status: str
+    assignee_uid: str | None
+    due_at: datetime | None
+    current_version: int
+    created_by: str
+    updated_by: str
+    created_at: datetime
+    updated_at: datetime
+    closed_at: datetime | None = None
+
+    def is_overdue(self, now: datetime) -> bool:
+        return (
+            self.status != "closed"
+            and self.due_at is not None
+            and self.due_at < now
+        )
+
+
+@dataclass(frozen=True)
+class QualityIssueRemediationRecord:
+    uid: str
+    issue_uid: str
+    round_number: int
+    summary: str
+    evidence_refs: tuple[dict[str, Any], ...]
+    submitted_by: str
+    submitted_at: datetime
+    review_status: str = "pending"
+    reviewed_by: str | None = None
+    reviewed_at: datetime | None = None
+    review_note: str | None = None
+    verification_run_uid: str | None = None
+
+
+@dataclass(frozen=True)
+class QualityIssueTimelineRecord:
+    uid: str
+    issue_uid: str
+    action: str
+    from_status: str | None
+    to_status: str
+    actor_uid: str
+    note: str | None
+    payload: dict[str, Any]
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class QualityIssueImportResult:
+    records: tuple[QualityIssueRecord, ...]
+    created_count: int
+    existing_count: int
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (ValueError, TypeError, AttributeError) as error:
+        raise QualityIssueInvalid(f"{field} must be a UUID") from error
+
+
+def _actor(value: Any) -> str:
+    return _uuid(value, "actor uid")
+
+
+def _expected_version(value: Any) -> int:
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError) as error:
+        raise QualityIssueInvalid("expected_version must be a positive integer") from error
+    if parsed < 1:
+        raise QualityIssueInvalid("expected_version must be a positive integer")
+    return parsed
+
+
+def _text(value: Any, field: str, *, maximum: int = MAX_TEXT) -> str:
+    normalized = unicodedata.normalize("NFKC", str(value or "")).strip()
+    if not normalized:
+        raise QualityIssueInvalid(f"{field} is required")
+    if len(normalized) > maximum:
+        raise QualityIssueInvalid(f"{field} exceeds {maximum} characters")
+    return normalized
+
+
+def _optional_due_at(value: Any) -> datetime | None:
+    if value in (None, ""):
+        return None
+    if isinstance(value, datetime):
+        parsed = value
+    else:
+        try:
+            parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+        except ValueError as error:
+            raise QualityIssueInvalid("due_at must be an ISO-8601 datetime") from error
+    if parsed.tzinfo is None:
+        raise QualityIssueInvalid("due_at must include a timezone")
+    return parsed
+
+
+def _recurrence_key(violation) -> str:
+    identity = {
+        "asset_uid": str(violation.asset_uid),
+        "field_name": str(violation.field_name),
+        "rule_code": str(violation.rule_code),
+    }
+    encoded = json.dumps(
+        identity,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    ).encode("utf-8")
+    return hashlib.sha256(encoded).hexdigest()
+
+
+def _evidence_refs(value: Any) -> tuple[dict[str, Any], ...]:
+    if not isinstance(value, list) or len(value) > MAX_EVIDENCE_REFS:
+        raise QualityIssueInvalid(
+            f"evidence_refs must be a list with at most {MAX_EVIDENCE_REFS} items"
+        )
+    result = []
+    for raw in value:
+        if not isinstance(raw, dict):
+            raise QualityIssueInvalid("each evidence reference must be an object")
+        encoded = json.dumps(raw, ensure_ascii=False, sort_keys=True)
+        if len(encoded) > 2_000:
+            raise QualityIssueInvalid("an evidence reference is too large")
+        result.append(copy.deepcopy(raw))
+    return tuple(result)
+
+
+class QualityIssueService:
+    def __init__(
+        self,
+        repository,
+        *,
+        review_authorizer: Callable[[str], None],
+        is_admin: Callable[[str], bool] = lambda _uid: False,
+        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.review_authorizer = review_authorizer
+        self.is_admin = is_admin
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def _event(
+        self,
+        issue_uid: str,
+        *,
+        action: str,
+        from_status: str | None,
+        to_status: str,
+        actor_uid: str,
+        note: str | None = None,
+        payload: dict[str, Any] | None = None,
+        at: datetime,
+    ) -> QualityIssueTimelineRecord:
+        return QualityIssueTimelineRecord(
+            uid=self.uid_factory(),
+            issue_uid=issue_uid,
+            action=action,
+            from_status=from_status,
+            to_status=to_status,
+            actor_uid=actor_uid,
+            note=note,
+            payload=copy.deepcopy(payload or {}),
+            created_at=at,
+        )
+
+    def import_violations(
+        self,
+        *,
+        violation_uids: Any,
+        priority: str,
+        due_at: datetime | str | None,
+        actor_uid: str,
+    ) -> QualityIssueImportResult:
+        actor = _actor(actor_uid)
+        if (
+            not isinstance(violation_uids, list)
+            or not 1 <= len(violation_uids) <= MAX_IMPORT_SIZE
+        ):
+            raise QualityIssueInvalid(
+                "violation_uids must contain between 1 and 100 items"
+            )
+        normalized_uids = tuple(
+            dict.fromkeys(_uuid(item, "violation uid") for item in violation_uids)
+        )
+        normalized_priority = str(priority or "").strip().lower()
+        if normalized_priority not in PRIORITIES:
+            raise QualityIssueInvalid("priority is invalid")
+        deadline = _optional_due_at(due_at)
+        violations = self.repository.get_violations(normalized_uids)
+        if len(violations) != len(normalized_uids):
+            raise QualityIssueInvalid("one or more violation samples were not found")
+
+        records = []
+        created_count = 0
+        existing_count = 0
+        try:
+            for violation in violations:
+                key = _recurrence_key(violation)
+                existing = self.repository.find_open_by_recurrence_key(key)
+                if existing is not None:
+                    records.append(existing)
+                    existing_count += 1
+                    continue
+                now = self.now_factory()
+                issue_uid = self.uid_factory()
+                occurrence = self.repository.recurrence_count(key) + 1
+                issue = QualityIssueRecord(
+                    uid=issue_uid,
+                    issue_code=f"QI-{issue_uid.replace('-', '')[-12:].upper()}",
+                    source_violation_uid=str(violation.uid),
+                    source_run_uid=str(violation.run_uid),
+                    rule_code=violation.rule_code,
+                    severity=violation.severity,
+                    priority=normalized_priority,
+                    asset_uid=str(violation.asset_uid),
+                    field_name=violation.field_name,
+                    source_uid=(
+                        str(violation.source_uid)
+                        if violation.source_uid else None
+                    ),
+                    source_mapping_uid=(
+                        str(violation.source_mapping_uid)
+                        if violation.source_mapping_uid else None
+                    ),
+                    message=violation.message,
+                    evidence=copy.deepcopy(violation.evidence),
+                    recurrence_key=key,
+                    occurrence_number=occurrence,
+                    status="open",
+                    assignee_uid=None,
+                    due_at=deadline,
+                    current_version=1,
+                    created_by=actor,
+                    updated_by=actor,
+                    created_at=now,
+                    updated_at=now,
+                )
+                event = self._event(
+                    issue.uid,
+                    action="created",
+                    from_status=None,
+                    to_status="open",
+                    actor_uid=actor,
+                    payload={
+                        "source_run_uid": issue.source_run_uid,
+                        "source_violation_uid": issue.source_violation_uid,
+                        "occurrence_number": occurrence,
+                    },
+                    at=now,
+                )
+                records.append(self.repository.create_issue(issue, event))
+                created_count += 1
+            self.commit()
+        except Exception:
+            self.rollback()
+            raise
+        return QualityIssueImportResult(
+            records=tuple(records),
+            created_count=created_count,
+            existing_count=existing_count,
+        )
+
+    def _load(self, issue_uid: str):
+        uid = _uuid(issue_uid, "issue uid")
+        issue = self.repository.get_issue(uid, for_update=True)
+        if issue is None:
+            raise QualityIssueNotFound("quality issue was not found")
+        return issue
+
+    def _transition(
+        self,
+        issue: QualityIssueRecord,
+        *,
+        to_status: str,
+        action: str,
+        actor_uid: str,
+        expected_version: int,
+        note: str | None = None,
+        payload: dict[str, Any] | None = None,
+        remediation=None,
+        remediation_review=None,
+        **changes,
+    ):
+        expected = _expected_version(expected_version)
+        if issue.current_version != expected:
+            raise QualityIssueConflict("quality issue version is stale")
+        now = self.now_factory()
+        updated = replace(
+            issue,
+            status=to_status,
+            current_version=expected + 1,
+            updated_by=actor_uid,
+            updated_at=now,
+            **changes,
+        )
+        event = self._event(
+            issue.uid,
+            action=action,
+            from_status=issue.status,
+            to_status=to_status,
+            actor_uid=actor_uid,
+            note=note,
+            payload=payload,
+            at=now,
+        )
+        try:
+            result = self.repository.transition(
+                updated,
+                expected_version=expected,
+                event=event,
+                remediation=remediation,
+                remediation_review=remediation_review,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def assign(
+        self,
+        issue_uid: str,
+        *,
+        assignee_uid: str,
+        due_at: datetime | str | None,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        actor = _actor(actor_uid)
+        assignee = _uuid(assignee_uid, "assignee uid")
+        issue = self._load(issue_uid)
+        if issue.status == "closed":
+            raise QualityIssueConflict("a closed issue must be reopened first")
+        if not self.repository.active_user_exists(assignee):
+            raise QualityIssueInvalid("assignee must be an active user")
+        return self._transition(
+            issue,
+            to_status="assigned",
+            action="assigned",
+            actor_uid=actor,
+            expected_version=expected_version,
+            payload={"assignee_uid": assignee},
+            assignee_uid=assignee,
+            due_at=_optional_due_at(due_at),
+        )
+
+    def _assert_worker(self, issue, actor_uid):
+        if actor_uid != issue.assignee_uid and not self.is_admin(actor_uid):
+            raise QualityIssueForbidden(
+                "only the current assignee or an administrator may remediate"
+            )
+
+    def start(self, issue_uid: str, *, expected_version: int, actor_uid: str):
+        actor = _actor(actor_uid)
+        issue = self._load(issue_uid)
+        if issue.status != "assigned":
+            raise QualityIssueConflict("only an assigned issue can be started")
+        self._assert_worker(issue, actor)
+        return self._transition(
+            issue,
+            to_status="in_progress",
+            action="started",
+            actor_uid=actor,
+            expected_version=expected_version,
+        )
+
+    def submit(
+        self,
+        issue_uid: str,
+        *,
+        summary: str,
+        evidence_refs: Any,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        actor = _actor(actor_uid)
+        issue = self._load(issue_uid)
+        if issue.status not in {"assigned", "in_progress"}:
+            raise QualityIssueConflict(
+                "only an assigned or in-progress issue can be submitted"
+            )
+        self._assert_worker(issue, actor)
+        now = self.now_factory()
+        latest = self.repository.latest_remediation(issue.uid)
+        remediation = QualityIssueRemediationRecord(
+            uid=self.uid_factory(),
+            issue_uid=issue.uid,
+            round_number=(latest.round_number + 1 if latest else 1),
+            summary=_text(summary, "summary"),
+            evidence_refs=_evidence_refs(evidence_refs),
+            submitted_by=actor,
+            submitted_at=now,
+        )
+        return self._transition(
+            issue,
+            to_status="pending_review",
+            action="submitted",
+            actor_uid=actor,
+            expected_version=expected_version,
+            payload={
+                "remediation_uid": remediation.uid,
+                "round_number": remediation.round_number,
+            },
+            remediation=remediation,
+        )
+
+    def review(
+        self,
+        issue_uid: str,
+        *,
+        verification_result: str,
+        note: str,
+        verification_run_uid: str | None,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        actor = _actor(actor_uid)
+        issue = self._load(issue_uid)
+        if issue.status != "pending_review":
+            raise QualityIssueConflict("only a pending issue can be reviewed")
+        remediation = self.repository.latest_remediation(issue.uid)
+        if remediation is None:
+            raise QualityIssueConflict("remediation evidence is missing")
+        if actor == remediation.submitted_by:
+            raise QualityIssueForbidden(
+                "a submitter cannot review their own remediation"
+            )
+        self.review_authorizer(actor)
+        result = str(verification_result or "").strip().lower()
+        if result not in VERIFICATION_RESULTS:
+            raise QualityIssueInvalid("verification_result is invalid")
+        review_note = _text(note, "review note")
+        run_uid = (
+            _uuid(verification_run_uid, "verification run uid")
+            if verification_run_uid
+            else None
+        )
+        if run_uid and not self.repository.run_exists(run_uid):
+            raise QualityIssueInvalid("verification run was not found")
+        now = self.now_factory()
+        passed = result == "passed"
+        return self._transition(
+            issue,
+            to_status=("closed" if passed else "in_progress"),
+            action=("closed" if passed else "rejected"),
+            actor_uid=actor,
+            expected_version=expected_version,
+            note=review_note,
+            payload={
+                "remediation_uid": remediation.uid,
+                "verification_result": result,
+                "verification_run_uid": run_uid,
+            },
+            remediation_review={
+                "review_status": result,
+                "reviewed_by": actor,
+                "reviewed_at": now,
+                "review_note": review_note,
+                "verification_run_uid": run_uid,
+            },
+            closed_at=(now if passed else None),
+        )
+
+    def reopen(
+        self,
+        issue_uid: str,
+        *,
+        reason: str,
+        due_at: datetime | str | None,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        actor = _actor(actor_uid)
+        issue = self._load(issue_uid)
+        if issue.status != "closed":
+            raise QualityIssueConflict("only a closed issue can be reopened")
+        return self._transition(
+            issue,
+            to_status=("assigned" if issue.assignee_uid else "open"),
+            action="reopened",
+            actor_uid=actor,
+            expected_version=expected_version,
+            note=_text(reason, "reason"),
+            due_at=_optional_due_at(due_at),
+            closed_at=None,
+        )
+
+    def get(self, issue_uid: str):
+        issue = self.repository.get_issue(
+            _uuid(issue_uid, "issue uid"),
+            for_update=False,
+        )
+        if issue is None:
+            raise QualityIssueNotFound("quality issue was not found")
+        return issue, self.repository.latest_remediation(issue.uid)
+
+    def issues(
+        self,
+        *,
+        status: str | None,
+        assignee_uid: str | None,
+        overdue_only: bool,
+        page: int,
+        page_size: int,
+    ):
+        try:
+            page_value = int(page)
+            size_value = int(page_size)
+        except (TypeError, ValueError) as error:
+            raise QualityIssueInvalid("page values must be integers") from error
+        if page_value < 1 or not 1 <= size_value <= 100:
+            raise QualityIssueInvalid("page values are outside the allowed range")
+        normalized_status = str(status or "").strip().lower() or None
+        if normalized_status and normalized_status not in ISSUE_STATUSES:
+            raise QualityIssueInvalid("status is invalid")
+        assignee = (
+            _uuid(assignee_uid, "assignee uid") if assignee_uid else None
+        )
+        return self.repository.list_issues(
+            status=normalized_status,
+            assignee_uid=assignee,
+            overdue_only=bool(overdue_only),
+            now=self.now_factory(),
+            page=page_value,
+            page_size=size_value,
+        )
+
+    def timeline(self, issue_uid: str):
+        issue, _remediation = self.get(issue_uid)
+        return tuple(self.repository.list_timeline(issue.uid))
+
+    def statistics(self):
+        return dict(self.repository.statistics(now=self.now_factory()))
+
+    def assignees(self):
+        return tuple(self.repository.list_active_users())

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

@@ -41,6 +41,8 @@ DEVICE_ENTITIES_REVIEW = "device-entities:review"
 DEVICE_QUALITY_EDIT = "device-quality:edit"
 DEVICE_QUALITY_EXECUTE = "device-quality:execute"
 DEVICE_QUALITY_PUBLISH = "device-quality:publish"
+QUALITY_ISSUES_EDIT = "quality-issues:edit"
+QUALITY_ISSUES_REVIEW = "quality-issues:review"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -64,6 +66,7 @@ ROLE_PERMISSIONS = {
             DEVICE_ENTITIES_EDIT,
             DEVICE_QUALITY_EDIT,
             DEVICE_QUALITY_EXECUTE,
+            QUALITY_ISSUES_EDIT,
         }
     ),
     "admin": frozenset(
@@ -102,6 +105,8 @@ ROLE_PERMISSIONS = {
             DEVICE_QUALITY_EDIT,
             DEVICE_QUALITY_EXECUTE,
             DEVICE_QUALITY_PUBLISH,
+            QUALITY_ISSUES_EDIT,
+            QUALITY_ISSUES_REVIEW,
         }
     ),
 }
@@ -181,6 +186,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
     if path.startswith("/api/development/v1/device-quality"):
         if method == "GET":
             return (READ_GOVERNANCE,)
+        if path.startswith(
+            "/api/development/v1/device-quality/issues"
+        ):
+            if path.endswith("/review"):
+                return (QUALITY_ISSUES_REVIEW,)
+            return (QUALITY_ISSUES_EDIT,)
         if path.endswith("/publish"):
             return (DEVICE_QUALITY_PUBLISH,)
         if path == "/api/development/v1/device-quality/runs":

+ 149 - 0
app/models/data_research.py

@@ -1013,6 +1013,155 @@ class DeviceQualityAssetScore(db.Model):
     )
 
 
+class DeviceQualityIssue(db.Model):
+    __tablename__ = "device_quality_issues"
+    __table_args__ = (
+        db.CheckConstraint(
+            "severity IN ('info','warning','error','critical')",
+            name="ck_device_quality_issue_severity",
+        ),
+        db.CheckConstraint(
+            "priority IN ('low','medium','high','critical')",
+            name="ck_device_quality_issue_priority",
+        ),
+        db.CheckConstraint(
+            "status IN "
+            "('open','assigned','in_progress','pending_review','closed')",
+            name="ck_device_quality_issue_status",
+        ),
+        db.CheckConstraint(
+            "occurrence_number > 0 AND current_version > 0",
+            name="ck_device_quality_issue_versions",
+        ),
+        db.Index(
+            "uq_device_quality_open_recurrence",
+            "recurrence_key",
+            unique=True,
+            postgresql_where=db.text("status <> 'closed'"),
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    issue_code = db.Column(db.String(32), nullable=False, unique=True)
+    source_violation_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    source_run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    rule_code = db.Column(db.String(120), nullable=False)
+    severity = db.Column(db.String(20), nullable=False)
+    priority = 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))
+    source_mapping_uid = db.Column(UUID(as_uuid=False))
+    message = db.Column(db.String(1000), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+    recurrence_key = db.Column(db.String(64), nullable=False)
+    occurrence_number = db.Column(db.Integer, nullable=False)
+    status = db.Column(db.String(30), nullable=False, default="open")
+    assignee_uid = db.Column(UUID(as_uuid=False))
+    due_at = db.Column(db.DateTime(timezone=True))
+    current_version = db.Column(db.Integer, nullable=False, default=1)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+    updated_by = db.Column(UUID(as_uuid=False), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    updated_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    closed_at = db.Column(db.DateTime(timezone=True))
+
+
+class DeviceQualityIssueRemediation(db.Model):
+    __tablename__ = "device_quality_issue_remediations"
+    __table_args__ = (
+        db.CheckConstraint(
+            "round_number > 0",
+            name="ck_device_quality_issue_remediation_round",
+        ),
+        db.CheckConstraint(
+            "review_status IN ('pending','passed','failed')",
+            name="ck_device_quality_issue_remediation_review",
+        ),
+        db.UniqueConstraint(
+            "issue_uid",
+            "round_number",
+            name="uq_device_quality_issue_remediation_round",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    issue_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_issues.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    round_number = db.Column(db.Integer, nullable=False)
+    summary = db.Column(db.String(2000), nullable=False)
+    evidence_refs = db.Column(JSONB, nullable=False)
+    submitted_by = db.Column(UUID(as_uuid=False), nullable=False)
+    submitted_at = db.Column(db.DateTime(timezone=True), nullable=False)
+    review_status = db.Column(db.String(20), nullable=False, default="pending")
+    reviewed_by = db.Column(UUID(as_uuid=False))
+    reviewed_at = db.Column(db.DateTime(timezone=True))
+    review_note = db.Column(db.String(2000))
+    verification_run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="RESTRICT"),
+    )
+
+
+class DeviceQualityIssueTimeline(db.Model):
+    __tablename__ = "device_quality_issue_timeline"
+    __table_args__ = (
+        db.CheckConstraint(
+            "to_status IN "
+            "('open','assigned','in_progress','pending_review','closed')",
+            name="ck_device_quality_issue_timeline_status",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    issue_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_issues.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    action = db.Column(db.String(40), nullable=False)
+    from_status = db.Column(db.String(30))
+    to_status = db.Column(db.String(30), nullable=False)
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    note = db.Column(db.String(2000))
+    payload = db.Column(JSONB, nullable=False)
+    created_at = db.Column(db.DateTime(timezone=True), nullable=False)
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

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

@@ -9,6 +9,7 @@ from flask import current_app, g, jsonify, request, send_file
 
 from app import db
 from app.api.data_development import bp
+from app.core.common.timezone_utils import now_china
 from app.core.data_research.errors import DataResearchError
 from app.models.result import failed, success
 
@@ -253,6 +254,52 @@ def get_device_quality_service():
     )
 
 
+def get_quality_issue_service():
+    from app.core.data_research.errors import QualityIssueForbidden
+    from app.core.data_research.quality_issue_repository import (
+        SqlAlchemyQualityIssueRepository,
+    )
+    from app.core.data_research.quality_issues import QualityIssueService
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    repository = SqlAlchemyQualityIssueRepository(db.session)
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+
+    def assert_accountable(actor_uid):
+        matrix = responsibilities.get(
+            "quality_issue",
+            "DEVICE_QUALITY_ISSUES",
+        )
+        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 QualityIssueForbidden(
+                "only the accountable quality issue asset manager may review"
+            )
+
+    return QualityIssueService(
+        repository,
+        review_authorizer=assert_accountable,
+        is_admin=lambda actor_uid: repository.user_has_role(
+            actor_uid,
+            "admin",
+        ),
+        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
@@ -794,6 +841,81 @@ def _device_quality_asset_score(record):
     }
 
 
+def _quality_issue(record):
+    return {
+        "uid": str(record.uid),
+        "issue_code": record.issue_code,
+        "source_violation_uid": str(record.source_violation_uid),
+        "source_run_uid": str(record.source_run_uid),
+        "rule_code": record.rule_code,
+        "severity": record.severity,
+        "priority": record.priority,
+        "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 {}),
+        "recurrence_key": record.recurrence_key,
+        "occurrence_number": int(record.occurrence_number),
+        "status": record.status,
+        "assignee_uid": (
+            str(record.assignee_uid) if record.assignee_uid else None
+        ),
+        "due_at": _iso(record.due_at),
+        "is_overdue": bool(record.is_overdue(now_china())),
+        "current_version": int(record.current_version),
+        "created_by": str(record.created_by),
+        "updated_by": str(record.updated_by),
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+        "closed_at": _iso(record.closed_at),
+    }
+
+
+def _quality_issue_remediation(record):
+    if record is None:
+        return None
+    return {
+        "uid": str(record.uid),
+        "issue_uid": str(record.issue_uid),
+        "round_number": int(record.round_number),
+        "summary": record.summary,
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+        "submitted_by": str(record.submitted_by),
+        "submitted_at": _iso(record.submitted_at),
+        "review_status": record.review_status,
+        "reviewed_by": (
+            str(record.reviewed_by) if record.reviewed_by else None
+        ),
+        "reviewed_at": _iso(record.reviewed_at),
+        "review_note": record.review_note,
+        "verification_run_uid": (
+            str(record.verification_run_uid)
+            if record.verification_run_uid else None
+        ),
+    }
+
+
+def _quality_issue_timeline(record):
+    return {
+        "uid": str(record.uid),
+        "issue_uid": str(record.issue_uid),
+        "action": record.action,
+        "from_status": record.from_status,
+        "to_status": record.to_status,
+        "actor_uid": str(record.actor_uid),
+        "note": record.note,
+        "payload": dict(record.payload or {}),
+        "created_at": _iso(record.created_at),
+    }
+
+
 def _device_asset_page(name, *, default, maximum):
     from app.core.data_research.errors import DeviceAssetInvalid
 
@@ -1622,6 +1744,210 @@ def list_device_quality_asset_scores(run_uid):
         return _error(error)
 
 
+@bp.route("/device-quality/issues", methods=["GET"])
+def list_quality_issues():
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        overdue_only = str(
+            request.args.get("overdue_only", "")
+        ).strip().lower() in {"1", "true", "yes"}
+        records, total = get_quality_issue_service().issues(
+            status=request.args.get("status"),
+            assignee_uid=request.args.get("assignee_uid"),
+            overdue_only=overdue_only,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [_quality_issue(item) for item 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/issues/statistics", methods=["GET"])
+def get_quality_issue_statistics():
+    try:
+        return jsonify(
+            success(get_quality_issue_service().statistics())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/assignees", methods=["GET"])
+def list_quality_issue_assignees():
+    try:
+        records = get_quality_issue_service().assignees()
+        return jsonify(
+            success({"records": list(records), "total": len(records)})
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/import", methods=["POST"])
+def import_quality_issues():
+    try:
+        body = request.get_json(silent=True) or {}
+        result = get_quality_issue_service().import_violations(
+            violation_uids=body.get("violation_uids"),
+            priority=body.get("priority"),
+            due_at=body.get("due_at"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        payload = {
+            "records": [_quality_issue(item) for item in result.records],
+            "created_count": int(result.created_count),
+            "existing_count": int(result.existing_count),
+        }
+        status = 201 if result.created_count else 200
+        return jsonify(success(payload)), status
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/<issue_uid>", methods=["GET"])
+def get_quality_issue(issue_uid):
+    try:
+        issue, remediation = get_quality_issue_service().get(issue_uid)
+        return jsonify(
+            success(
+                {
+                    **_quality_issue(issue),
+                    "latest_remediation": _quality_issue_remediation(
+                        remediation
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/timeline",
+    methods=["GET"],
+)
+def get_quality_issue_timeline(issue_uid):
+    try:
+        records = get_quality_issue_service().timeline(issue_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _quality_issue_timeline(item) for item in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/assign",
+    methods=["POST"],
+)
+def assign_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().assign(
+            issue_uid,
+            assignee_uid=body.get("assignee_uid"),
+            due_at=body.get("due_at"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/start",
+    methods=["POST"],
+)
+def start_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().start(
+            issue_uid,
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/submit",
+    methods=["POST"],
+)
+def submit_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().submit(
+            issue_uid,
+            summary=body.get("summary"),
+            evidence_refs=body.get("evidence_refs", []),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/review",
+    methods=["POST"],
+)
+def review_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().review(
+            issue_uid,
+            verification_result=body.get("verification_result"),
+            note=body.get("note"),
+            verification_run_uid=body.get("verification_run_uid"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/reopen",
+    methods=["POST"],
+)
+def reopen_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().reopen(
+            issue_uid,
+            reason=body.get("reason"),
+            due_at=body.get("due_at"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:

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

@@ -118,3 +118,23 @@ class DeviceQualityNotFound(DataResearchError):
 class DeviceQualityConflict(DataResearchError):
     code = "DEVICE_QUALITY_CONFLICT"
     http_status = 409
+
+
+class QualityIssueInvalid(DataResearchError):
+    code = "QUALITY_ISSUE_INVALID"
+    http_status = 422
+
+
+class QualityIssueForbidden(DataResearchError):
+    code = "QUALITY_ISSUE_FORBIDDEN"
+    http_status = 403
+
+
+class QualityIssueNotFound(DataResearchError):
+    code = "QUALITY_ISSUE_NOT_FOUND"
+    http_status = 404
+
+
+class QualityIssueConflict(DataResearchError):
+    code = "QUALITY_ISSUE_CONFLICT"
+    http_status = 409

+ 432 - 0
deployment/app/core/data_research/quality_issue_repository.py

@@ -0,0 +1,432 @@
+"""SQLAlchemy persistence for governed device-quality remediation issues."""
+
+from __future__ import annotations
+
+from sqlalchemy import case, func, text
+
+from app.core.data_research.device_quality_repository import (
+    SqlAlchemyDeviceQualityRepository,
+)
+from app.core.data_research.errors import QualityIssueConflict
+from app.core.data_research.quality_issues import (
+    QualityIssueRecord,
+    QualityIssueRemediationRecord,
+    QualityIssueTimelineRecord,
+)
+from app.models.data_research import (
+    DeviceQualityIssue,
+    DeviceQualityIssueRemediation,
+    DeviceQualityIssueTimeline,
+    DeviceQualityRun,
+    DeviceQualityViolationSample,
+)
+
+
+class SqlAlchemyQualityIssueRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _issue(model):
+        return QualityIssueRecord(
+            uid=str(model.uid),
+            issue_code=model.issue_code,
+            source_violation_uid=str(model.source_violation_uid),
+            source_run_uid=str(model.source_run_uid),
+            rule_code=model.rule_code,
+            severity=model.severity,
+            priority=model.priority,
+            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 {}),
+            recurrence_key=model.recurrence_key,
+            occurrence_number=int(model.occurrence_number),
+            status=model.status,
+            assignee_uid=(
+                str(model.assignee_uid) if model.assignee_uid else None
+            ),
+            due_at=model.due_at,
+            current_version=int(model.current_version),
+            created_by=str(model.created_by),
+            updated_by=str(model.updated_by),
+            created_at=model.created_at,
+            updated_at=model.updated_at,
+            closed_at=model.closed_at,
+        )
+
+    @staticmethod
+    def _remediation(model):
+        if model is None:
+            return None
+        return QualityIssueRemediationRecord(
+            uid=str(model.uid),
+            issue_uid=str(model.issue_uid),
+            round_number=int(model.round_number),
+            summary=model.summary,
+            evidence_refs=tuple(
+                dict(item) for item in (model.evidence_refs or [])
+            ),
+            submitted_by=str(model.submitted_by),
+            submitted_at=model.submitted_at,
+            review_status=model.review_status,
+            reviewed_by=(
+                str(model.reviewed_by) if model.reviewed_by else None
+            ),
+            reviewed_at=model.reviewed_at,
+            review_note=model.review_note,
+            verification_run_uid=(
+                str(model.verification_run_uid)
+                if model.verification_run_uid else None
+            ),
+        )
+
+    @staticmethod
+    def _timeline(model):
+        return QualityIssueTimelineRecord(
+            uid=str(model.uid),
+            issue_uid=str(model.issue_uid),
+            action=model.action,
+            from_status=model.from_status,
+            to_status=model.to_status,
+            actor_uid=str(model.actor_uid),
+            note=model.note,
+            payload=dict(model.payload or {}),
+            created_at=model.created_at,
+        )
+
+    def get_violations(self, violation_uids):
+        models = (
+            self.session.query(DeviceQualityViolationSample)
+            .filter(DeviceQualityViolationSample.uid.in_(violation_uids))
+            .all()
+        )
+        by_uid = {
+            str(model.uid): SqlAlchemyDeviceQualityRepository._violation(model)
+            for model in models
+        }
+        return [by_uid[uid] for uid in violation_uids if uid in by_uid]
+
+    def find_open_by_recurrence_key(self, recurrence_key):
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"),
+            {"lock_key": f"quality-issue:{recurrence_key}"},
+        )
+        model = (
+            self.session.query(DeviceQualityIssue)
+            .filter(
+                DeviceQualityIssue.recurrence_key == recurrence_key,
+                DeviceQualityIssue.status != "closed",
+            )
+            .first()
+        )
+        return self._issue(model) if model is not None else None
+
+    def recurrence_count(self, recurrence_key):
+        return int(
+            self.session.query(func.count(DeviceQualityIssue.uid))
+            .filter(DeviceQualityIssue.recurrence_key == recurrence_key)
+            .scalar()
+            or 0
+        )
+
+    @staticmethod
+    def _issue_model(record):
+        return DeviceQualityIssue(
+            uid=record.uid,
+            issue_code=record.issue_code,
+            source_violation_uid=record.source_violation_uid,
+            source_run_uid=record.source_run_uid,
+            rule_code=record.rule_code,
+            severity=record.severity,
+            priority=record.priority,
+            asset_uid=record.asset_uid,
+            field_name=record.field_name,
+            source_uid=record.source_uid,
+            source_mapping_uid=record.source_mapping_uid,
+            message=record.message,
+            evidence=dict(record.evidence),
+            recurrence_key=record.recurrence_key,
+            occurrence_number=record.occurrence_number,
+            status=record.status,
+            assignee_uid=record.assignee_uid,
+            due_at=record.due_at,
+            current_version=record.current_version,
+            created_by=record.created_by,
+            updated_by=record.updated_by,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+            closed_at=record.closed_at,
+        )
+
+    @staticmethod
+    def _event_model(record):
+        return DeviceQualityIssueTimeline(
+            uid=record.uid,
+            issue_uid=record.issue_uid,
+            action=record.action,
+            from_status=record.from_status,
+            to_status=record.to_status,
+            actor_uid=record.actor_uid,
+            note=record.note,
+            payload=dict(record.payload),
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _remediation_model(record):
+        return DeviceQualityIssueRemediation(
+            uid=record.uid,
+            issue_uid=record.issue_uid,
+            round_number=record.round_number,
+            summary=record.summary,
+            evidence_refs=[dict(item) for item in record.evidence_refs],
+            submitted_by=record.submitted_by,
+            submitted_at=record.submitted_at,
+            review_status=record.review_status,
+            reviewed_by=record.reviewed_by,
+            reviewed_at=record.reviewed_at,
+            review_note=record.review_note,
+            verification_run_uid=record.verification_run_uid,
+        )
+
+    def create_issue(self, issue, event):
+        model = self._issue_model(issue)
+        self.session.add(model)
+        self.session.add(self._event_model(event))
+        self.session.flush()
+        return self._issue(model)
+
+    def get_issue(self, issue_uid, *, for_update=False):
+        query = self.session.query(DeviceQualityIssue).filter_by(uid=issue_uid)
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._issue(model) if model is not None else None
+
+    def active_user_exists(self, user_uid):
+        return bool(
+            self.session.execute(
+                text(
+                    "SELECT 1 FROM public.users "
+                    "WHERE id = CAST(:uid AS uuid) AND status = 'active'"
+                ),
+                {"uid": user_uid},
+            ).scalar()
+        )
+
+    def user_has_role(self, user_uid, role):
+        return bool(
+            self.session.execute(
+                text(
+                    """
+                    SELECT 1
+                    FROM public.users u
+                    JOIN public.user_roles ur ON ur.user_id = u.id
+                    JOIN public.roles r ON r.id = ur.role_id
+                    WHERE u.id = CAST(:uid AS uuid)
+                      AND u.status = 'active'
+                      AND r.name = :role
+                    """
+                ),
+                {"uid": user_uid, "role": role},
+            ).scalar()
+        )
+
+    def list_active_users(self):
+        rows = (
+            self.session.execute(
+                text(
+                    """
+                    SELECT id::text AS uid, username, display_name
+                    FROM public.users
+                    WHERE status = 'active'
+                    ORDER BY COALESCE(display_name, username), username
+                    LIMIT 500
+                    """
+                )
+            )
+            .mappings()
+            .all()
+        )
+        return [
+            {
+                "uid": row["uid"],
+                "username": row["username"],
+                "display_name": row["display_name"] or row["username"],
+            }
+            for row in rows
+        ]
+
+    def run_exists(self, run_uid):
+        return bool(
+            self.session.query(DeviceQualityRun.uid)
+            .filter_by(uid=run_uid)
+            .first()
+        )
+
+    def transition(
+        self,
+        issue,
+        *,
+        expected_version,
+        event,
+        remediation=None,
+        remediation_review=None,
+    ):
+        updates = {
+            "status": issue.status,
+            "assignee_uid": issue.assignee_uid,
+            "due_at": issue.due_at,
+            "current_version": issue.current_version,
+            "updated_by": issue.updated_by,
+            "updated_at": issue.updated_at,
+            "closed_at": issue.closed_at,
+        }
+        changed = (
+            self.session.query(DeviceQualityIssue)
+            .filter(
+                DeviceQualityIssue.uid == issue.uid,
+                DeviceQualityIssue.current_version == expected_version,
+            )
+            .update(updates, synchronize_session=False)
+        )
+        if changed != 1:
+            raise QualityIssueConflict("quality issue version is stale")
+        if remediation is not None:
+            self.session.add(self._remediation_model(remediation))
+        if remediation_review is not None:
+            model = (
+                self.session.query(DeviceQualityIssueRemediation)
+                .filter_by(issue_uid=issue.uid)
+                .order_by(
+                    DeviceQualityIssueRemediation.round_number.desc()
+                )
+                .with_for_update()
+                .first()
+            )
+            if model is None:
+                raise QualityIssueConflict("remediation evidence is missing")
+            for field, value in remediation_review.items():
+                setattr(model, field, value)
+        self.session.add(self._event_model(event))
+        self.session.flush()
+        return issue
+
+    def latest_remediation(self, issue_uid):
+        model = (
+            self.session.query(DeviceQualityIssueRemediation)
+            .filter_by(issue_uid=issue_uid)
+            .order_by(DeviceQualityIssueRemediation.round_number.desc())
+            .first()
+        )
+        return self._remediation(model)
+
+    def list_issues(
+        self,
+        *,
+        status,
+        assignee_uid,
+        overdue_only,
+        now,
+        page,
+        page_size,
+    ):
+        query = self.session.query(DeviceQualityIssue)
+        if status:
+            query = query.filter(DeviceQualityIssue.status == status)
+        if assignee_uid:
+            query = query.filter(
+                DeviceQualityIssue.assignee_uid == assignee_uid
+            )
+        if overdue_only:
+            query = query.filter(
+                DeviceQualityIssue.status != "closed",
+                DeviceQualityIssue.due_at.is_not(None),
+                DeviceQualityIssue.due_at < now,
+            )
+        total = int(
+            query.with_entities(
+                func.count(DeviceQualityIssue.uid)
+            ).scalar()
+            or 0
+        )
+        models = (
+            query.order_by(
+                case(
+                    (
+                        DeviceQualityIssue.status != "closed",
+                        0,
+                    ),
+                    else_=1,
+                ),
+                DeviceQualityIssue.due_at.asc().nullslast(),
+                DeviceQualityIssue.created_at.desc(),
+                DeviceQualityIssue.uid,
+            )
+            .offset((page - 1) * page_size)
+            .limit(page_size)
+            .all()
+        )
+        return [self._issue(model) for model in models], total
+
+    def list_timeline(self, issue_uid):
+        models = (
+            self.session.query(DeviceQualityIssueTimeline)
+            .filter_by(issue_uid=issue_uid)
+            .order_by(
+                DeviceQualityIssueTimeline.created_at,
+                DeviceQualityIssueTimeline.uid,
+            )
+            .all()
+        )
+        return [self._timeline(model) for model in models]
+
+    def statistics(self, *, now):
+        total, open_count, closed_count, overdue_count, recurrent_issues = (
+            self.session.query(
+                func.count(DeviceQualityIssue.uid),
+                func.count(DeviceQualityIssue.uid).filter(
+                    DeviceQualityIssue.status != "closed"
+                ),
+                func.count(DeviceQualityIssue.uid).filter(
+                    DeviceQualityIssue.status == "closed"
+                ),
+                func.count(DeviceQualityIssue.uid).filter(
+                    DeviceQualityIssue.status != "closed",
+                    DeviceQualityIssue.due_at.is_not(None),
+                    DeviceQualityIssue.due_at < now,
+                ),
+                func.count(DeviceQualityIssue.uid).filter(
+                    DeviceQualityIssue.occurrence_number > 1
+                ),
+            ).one()
+        )
+        recurrent = (
+            self.session.query(func.count())
+            .select_from(
+                self.session.query(DeviceQualityIssue.recurrence_key)
+                .group_by(DeviceQualityIssue.recurrence_key)
+                .having(func.count(DeviceQualityIssue.uid) > 1)
+                .subquery()
+            )
+            .scalar()
+            or 0
+        )
+        return {
+            "total": int(total),
+            "open": int(open_count),
+            "closed": int(closed_count),
+            "overdue": int(overdue_count),
+            "recurrent": int(recurrent),
+            "recurrent_issues": int(recurrent_issues),
+            "recurrence_rate": (
+                round(int(recurrent_issues) / int(total), 6)
+                if total else 0.0
+            ),
+        }

+ 595 - 0
deployment/app/core/data_research/quality_issues.py

@@ -0,0 +1,595 @@
+"""Governed remediation lifecycle for immutable device-quality violations."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import unicodedata
+import uuid
+from collections.abc import Callable
+from dataclasses import dataclass, replace
+from datetime import datetime
+from typing import Any
+
+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 (
+    QualityIssueConflict,
+    QualityIssueForbidden,
+    QualityIssueInvalid,
+    QualityIssueNotFound,
+)
+
+ISSUE_STATUSES = frozenset(
+    {"open", "assigned", "in_progress", "pending_review", "closed"}
+)
+PRIORITIES = frozenset({"low", "medium", "high", "critical"})
+VERIFICATION_RESULTS = frozenset({"passed", "failed"})
+MAX_IMPORT_SIZE = 100
+MAX_EVIDENCE_REFS = 20
+MAX_TEXT = 2_000
+
+
+@dataclass(frozen=True)
+class QualityIssueRecord:
+    uid: str
+    issue_code: str
+    source_violation_uid: str
+    source_run_uid: str
+    rule_code: str
+    severity: str
+    priority: str
+    asset_uid: str
+    field_name: str
+    source_uid: str | None
+    source_mapping_uid: str | None
+    message: str
+    evidence: dict[str, Any]
+    recurrence_key: str
+    occurrence_number: int
+    status: str
+    assignee_uid: str | None
+    due_at: datetime | None
+    current_version: int
+    created_by: str
+    updated_by: str
+    created_at: datetime
+    updated_at: datetime
+    closed_at: datetime | None = None
+
+    def is_overdue(self, now: datetime) -> bool:
+        return (
+            self.status != "closed"
+            and self.due_at is not None
+            and self.due_at < now
+        )
+
+
+@dataclass(frozen=True)
+class QualityIssueRemediationRecord:
+    uid: str
+    issue_uid: str
+    round_number: int
+    summary: str
+    evidence_refs: tuple[dict[str, Any], ...]
+    submitted_by: str
+    submitted_at: datetime
+    review_status: str = "pending"
+    reviewed_by: str | None = None
+    reviewed_at: datetime | None = None
+    review_note: str | None = None
+    verification_run_uid: str | None = None
+
+
+@dataclass(frozen=True)
+class QualityIssueTimelineRecord:
+    uid: str
+    issue_uid: str
+    action: str
+    from_status: str | None
+    to_status: str
+    actor_uid: str
+    note: str | None
+    payload: dict[str, Any]
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class QualityIssueImportResult:
+    records: tuple[QualityIssueRecord, ...]
+    created_count: int
+    existing_count: int
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (ValueError, TypeError, AttributeError) as error:
+        raise QualityIssueInvalid(f"{field} must be a UUID") from error
+
+
+def _actor(value: Any) -> str:
+    return _uuid(value, "actor uid")
+
+
+def _expected_version(value: Any) -> int:
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError) as error:
+        raise QualityIssueInvalid("expected_version must be a positive integer") from error
+    if parsed < 1:
+        raise QualityIssueInvalid("expected_version must be a positive integer")
+    return parsed
+
+
+def _text(value: Any, field: str, *, maximum: int = MAX_TEXT) -> str:
+    normalized = unicodedata.normalize("NFKC", str(value or "")).strip()
+    if not normalized:
+        raise QualityIssueInvalid(f"{field} is required")
+    if len(normalized) > maximum:
+        raise QualityIssueInvalid(f"{field} exceeds {maximum} characters")
+    return normalized
+
+
+def _optional_due_at(value: Any) -> datetime | None:
+    if value in (None, ""):
+        return None
+    if isinstance(value, datetime):
+        parsed = value
+    else:
+        try:
+            parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+        except ValueError as error:
+            raise QualityIssueInvalid("due_at must be an ISO-8601 datetime") from error
+    if parsed.tzinfo is None:
+        raise QualityIssueInvalid("due_at must include a timezone")
+    return parsed
+
+
+def _recurrence_key(violation) -> str:
+    identity = {
+        "asset_uid": str(violation.asset_uid),
+        "field_name": str(violation.field_name),
+        "rule_code": str(violation.rule_code),
+    }
+    encoded = json.dumps(
+        identity,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    ).encode("utf-8")
+    return hashlib.sha256(encoded).hexdigest()
+
+
+def _evidence_refs(value: Any) -> tuple[dict[str, Any], ...]:
+    if not isinstance(value, list) or len(value) > MAX_EVIDENCE_REFS:
+        raise QualityIssueInvalid(
+            f"evidence_refs must be a list with at most {MAX_EVIDENCE_REFS} items"
+        )
+    result = []
+    for raw in value:
+        if not isinstance(raw, dict):
+            raise QualityIssueInvalid("each evidence reference must be an object")
+        encoded = json.dumps(raw, ensure_ascii=False, sort_keys=True)
+        if len(encoded) > 2_000:
+            raise QualityIssueInvalid("an evidence reference is too large")
+        result.append(copy.deepcopy(raw))
+    return tuple(result)
+
+
+class QualityIssueService:
+    def __init__(
+        self,
+        repository,
+        *,
+        review_authorizer: Callable[[str], None],
+        is_admin: Callable[[str], bool] = lambda _uid: False,
+        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.review_authorizer = review_authorizer
+        self.is_admin = is_admin
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def _event(
+        self,
+        issue_uid: str,
+        *,
+        action: str,
+        from_status: str | None,
+        to_status: str,
+        actor_uid: str,
+        note: str | None = None,
+        payload: dict[str, Any] | None = None,
+        at: datetime,
+    ) -> QualityIssueTimelineRecord:
+        return QualityIssueTimelineRecord(
+            uid=self.uid_factory(),
+            issue_uid=issue_uid,
+            action=action,
+            from_status=from_status,
+            to_status=to_status,
+            actor_uid=actor_uid,
+            note=note,
+            payload=copy.deepcopy(payload or {}),
+            created_at=at,
+        )
+
+    def import_violations(
+        self,
+        *,
+        violation_uids: Any,
+        priority: str,
+        due_at: datetime | str | None,
+        actor_uid: str,
+    ) -> QualityIssueImportResult:
+        actor = _actor(actor_uid)
+        if (
+            not isinstance(violation_uids, list)
+            or not 1 <= len(violation_uids) <= MAX_IMPORT_SIZE
+        ):
+            raise QualityIssueInvalid(
+                "violation_uids must contain between 1 and 100 items"
+            )
+        normalized_uids = tuple(
+            dict.fromkeys(_uuid(item, "violation uid") for item in violation_uids)
+        )
+        normalized_priority = str(priority or "").strip().lower()
+        if normalized_priority not in PRIORITIES:
+            raise QualityIssueInvalid("priority is invalid")
+        deadline = _optional_due_at(due_at)
+        violations = self.repository.get_violations(normalized_uids)
+        if len(violations) != len(normalized_uids):
+            raise QualityIssueInvalid("one or more violation samples were not found")
+
+        records = []
+        created_count = 0
+        existing_count = 0
+        try:
+            for violation in violations:
+                key = _recurrence_key(violation)
+                existing = self.repository.find_open_by_recurrence_key(key)
+                if existing is not None:
+                    records.append(existing)
+                    existing_count += 1
+                    continue
+                now = self.now_factory()
+                issue_uid = self.uid_factory()
+                occurrence = self.repository.recurrence_count(key) + 1
+                issue = QualityIssueRecord(
+                    uid=issue_uid,
+                    issue_code=f"QI-{issue_uid.replace('-', '')[-12:].upper()}",
+                    source_violation_uid=str(violation.uid),
+                    source_run_uid=str(violation.run_uid),
+                    rule_code=violation.rule_code,
+                    severity=violation.severity,
+                    priority=normalized_priority,
+                    asset_uid=str(violation.asset_uid),
+                    field_name=violation.field_name,
+                    source_uid=(
+                        str(violation.source_uid)
+                        if violation.source_uid else None
+                    ),
+                    source_mapping_uid=(
+                        str(violation.source_mapping_uid)
+                        if violation.source_mapping_uid else None
+                    ),
+                    message=violation.message,
+                    evidence=copy.deepcopy(violation.evidence),
+                    recurrence_key=key,
+                    occurrence_number=occurrence,
+                    status="open",
+                    assignee_uid=None,
+                    due_at=deadline,
+                    current_version=1,
+                    created_by=actor,
+                    updated_by=actor,
+                    created_at=now,
+                    updated_at=now,
+                )
+                event = self._event(
+                    issue.uid,
+                    action="created",
+                    from_status=None,
+                    to_status="open",
+                    actor_uid=actor,
+                    payload={
+                        "source_run_uid": issue.source_run_uid,
+                        "source_violation_uid": issue.source_violation_uid,
+                        "occurrence_number": occurrence,
+                    },
+                    at=now,
+                )
+                records.append(self.repository.create_issue(issue, event))
+                created_count += 1
+            self.commit()
+        except Exception:
+            self.rollback()
+            raise
+        return QualityIssueImportResult(
+            records=tuple(records),
+            created_count=created_count,
+            existing_count=existing_count,
+        )
+
+    def _load(self, issue_uid: str):
+        uid = _uuid(issue_uid, "issue uid")
+        issue = self.repository.get_issue(uid, for_update=True)
+        if issue is None:
+            raise QualityIssueNotFound("quality issue was not found")
+        return issue
+
+    def _transition(
+        self,
+        issue: QualityIssueRecord,
+        *,
+        to_status: str,
+        action: str,
+        actor_uid: str,
+        expected_version: int,
+        note: str | None = None,
+        payload: dict[str, Any] | None = None,
+        remediation=None,
+        remediation_review=None,
+        **changes,
+    ):
+        expected = _expected_version(expected_version)
+        if issue.current_version != expected:
+            raise QualityIssueConflict("quality issue version is stale")
+        now = self.now_factory()
+        updated = replace(
+            issue,
+            status=to_status,
+            current_version=expected + 1,
+            updated_by=actor_uid,
+            updated_at=now,
+            **changes,
+        )
+        event = self._event(
+            issue.uid,
+            action=action,
+            from_status=issue.status,
+            to_status=to_status,
+            actor_uid=actor_uid,
+            note=note,
+            payload=payload,
+            at=now,
+        )
+        try:
+            result = self.repository.transition(
+                updated,
+                expected_version=expected,
+                event=event,
+                remediation=remediation,
+                remediation_review=remediation_review,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def assign(
+        self,
+        issue_uid: str,
+        *,
+        assignee_uid: str,
+        due_at: datetime | str | None,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        actor = _actor(actor_uid)
+        assignee = _uuid(assignee_uid, "assignee uid")
+        issue = self._load(issue_uid)
+        if issue.status == "closed":
+            raise QualityIssueConflict("a closed issue must be reopened first")
+        if not self.repository.active_user_exists(assignee):
+            raise QualityIssueInvalid("assignee must be an active user")
+        return self._transition(
+            issue,
+            to_status="assigned",
+            action="assigned",
+            actor_uid=actor,
+            expected_version=expected_version,
+            payload={"assignee_uid": assignee},
+            assignee_uid=assignee,
+            due_at=_optional_due_at(due_at),
+        )
+
+    def _assert_worker(self, issue, actor_uid):
+        if actor_uid != issue.assignee_uid and not self.is_admin(actor_uid):
+            raise QualityIssueForbidden(
+                "only the current assignee or an administrator may remediate"
+            )
+
+    def start(self, issue_uid: str, *, expected_version: int, actor_uid: str):
+        actor = _actor(actor_uid)
+        issue = self._load(issue_uid)
+        if issue.status != "assigned":
+            raise QualityIssueConflict("only an assigned issue can be started")
+        self._assert_worker(issue, actor)
+        return self._transition(
+            issue,
+            to_status="in_progress",
+            action="started",
+            actor_uid=actor,
+            expected_version=expected_version,
+        )
+
+    def submit(
+        self,
+        issue_uid: str,
+        *,
+        summary: str,
+        evidence_refs: Any,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        actor = _actor(actor_uid)
+        issue = self._load(issue_uid)
+        if issue.status not in {"assigned", "in_progress"}:
+            raise QualityIssueConflict(
+                "only an assigned or in-progress issue can be submitted"
+            )
+        self._assert_worker(issue, actor)
+        now = self.now_factory()
+        latest = self.repository.latest_remediation(issue.uid)
+        remediation = QualityIssueRemediationRecord(
+            uid=self.uid_factory(),
+            issue_uid=issue.uid,
+            round_number=(latest.round_number + 1 if latest else 1),
+            summary=_text(summary, "summary"),
+            evidence_refs=_evidence_refs(evidence_refs),
+            submitted_by=actor,
+            submitted_at=now,
+        )
+        return self._transition(
+            issue,
+            to_status="pending_review",
+            action="submitted",
+            actor_uid=actor,
+            expected_version=expected_version,
+            payload={
+                "remediation_uid": remediation.uid,
+                "round_number": remediation.round_number,
+            },
+            remediation=remediation,
+        )
+
+    def review(
+        self,
+        issue_uid: str,
+        *,
+        verification_result: str,
+        note: str,
+        verification_run_uid: str | None,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        actor = _actor(actor_uid)
+        issue = self._load(issue_uid)
+        if issue.status != "pending_review":
+            raise QualityIssueConflict("only a pending issue can be reviewed")
+        remediation = self.repository.latest_remediation(issue.uid)
+        if remediation is None:
+            raise QualityIssueConflict("remediation evidence is missing")
+        if actor == remediation.submitted_by:
+            raise QualityIssueForbidden(
+                "a submitter cannot review their own remediation"
+            )
+        self.review_authorizer(actor)
+        result = str(verification_result or "").strip().lower()
+        if result not in VERIFICATION_RESULTS:
+            raise QualityIssueInvalid("verification_result is invalid")
+        review_note = _text(note, "review note")
+        run_uid = (
+            _uuid(verification_run_uid, "verification run uid")
+            if verification_run_uid
+            else None
+        )
+        if run_uid and not self.repository.run_exists(run_uid):
+            raise QualityIssueInvalid("verification run was not found")
+        now = self.now_factory()
+        passed = result == "passed"
+        return self._transition(
+            issue,
+            to_status=("closed" if passed else "in_progress"),
+            action=("closed" if passed else "rejected"),
+            actor_uid=actor,
+            expected_version=expected_version,
+            note=review_note,
+            payload={
+                "remediation_uid": remediation.uid,
+                "verification_result": result,
+                "verification_run_uid": run_uid,
+            },
+            remediation_review={
+                "review_status": result,
+                "reviewed_by": actor,
+                "reviewed_at": now,
+                "review_note": review_note,
+                "verification_run_uid": run_uid,
+            },
+            closed_at=(now if passed else None),
+        )
+
+    def reopen(
+        self,
+        issue_uid: str,
+        *,
+        reason: str,
+        due_at: datetime | str | None,
+        expected_version: int,
+        actor_uid: str,
+    ):
+        actor = _actor(actor_uid)
+        issue = self._load(issue_uid)
+        if issue.status != "closed":
+            raise QualityIssueConflict("only a closed issue can be reopened")
+        return self._transition(
+            issue,
+            to_status=("assigned" if issue.assignee_uid else "open"),
+            action="reopened",
+            actor_uid=actor,
+            expected_version=expected_version,
+            note=_text(reason, "reason"),
+            due_at=_optional_due_at(due_at),
+            closed_at=None,
+        )
+
+    def get(self, issue_uid: str):
+        issue = self.repository.get_issue(
+            _uuid(issue_uid, "issue uid"),
+            for_update=False,
+        )
+        if issue is None:
+            raise QualityIssueNotFound("quality issue was not found")
+        return issue, self.repository.latest_remediation(issue.uid)
+
+    def issues(
+        self,
+        *,
+        status: str | None,
+        assignee_uid: str | None,
+        overdue_only: bool,
+        page: int,
+        page_size: int,
+    ):
+        try:
+            page_value = int(page)
+            size_value = int(page_size)
+        except (TypeError, ValueError) as error:
+            raise QualityIssueInvalid("page values must be integers") from error
+        if page_value < 1 or not 1 <= size_value <= 100:
+            raise QualityIssueInvalid("page values are outside the allowed range")
+        normalized_status = str(status or "").strip().lower() or None
+        if normalized_status and normalized_status not in ISSUE_STATUSES:
+            raise QualityIssueInvalid("status is invalid")
+        assignee = (
+            _uuid(assignee_uid, "assignee uid") if assignee_uid else None
+        )
+        return self.repository.list_issues(
+            status=normalized_status,
+            assignee_uid=assignee,
+            overdue_only=bool(overdue_only),
+            now=self.now_factory(),
+            page=page_value,
+            page_size=size_value,
+        )
+
+    def timeline(self, issue_uid: str):
+        issue, _remediation = self.get(issue_uid)
+        return tuple(self.repository.list_timeline(issue.uid))
+
+    def statistics(self):
+        return dict(self.repository.statistics(now=self.now_factory()))
+
+    def assignees(self):
+        return tuple(self.repository.list_active_users())

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

@@ -41,6 +41,8 @@ DEVICE_ENTITIES_REVIEW = "device-entities:review"
 DEVICE_QUALITY_EDIT = "device-quality:edit"
 DEVICE_QUALITY_EXECUTE = "device-quality:execute"
 DEVICE_QUALITY_PUBLISH = "device-quality:publish"
+QUALITY_ISSUES_EDIT = "quality-issues:edit"
+QUALITY_ISSUES_REVIEW = "quality-issues:review"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -64,6 +66,7 @@ ROLE_PERMISSIONS = {
             DEVICE_ENTITIES_EDIT,
             DEVICE_QUALITY_EDIT,
             DEVICE_QUALITY_EXECUTE,
+            QUALITY_ISSUES_EDIT,
         }
     ),
     "admin": frozenset(
@@ -102,6 +105,8 @@ ROLE_PERMISSIONS = {
             DEVICE_QUALITY_EDIT,
             DEVICE_QUALITY_EXECUTE,
             DEVICE_QUALITY_PUBLISH,
+            QUALITY_ISSUES_EDIT,
+            QUALITY_ISSUES_REVIEW,
         }
     ),
 }
@@ -181,6 +186,12 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
     if path.startswith("/api/development/v1/device-quality"):
         if method == "GET":
             return (READ_GOVERNANCE,)
+        if path.startswith(
+            "/api/development/v1/device-quality/issues"
+        ):
+            if path.endswith("/review"):
+                return (QUALITY_ISSUES_REVIEW,)
+            return (QUALITY_ISSUES_EDIT,)
         if path.endswith("/publish"):
             return (DEVICE_QUALITY_PUBLISH,)
         if path == "/api/development/v1/device-quality/runs":

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

@@ -1013,6 +1013,155 @@ class DeviceQualityAssetScore(db.Model):
     )
 
 
+class DeviceQualityIssue(db.Model):
+    __tablename__ = "device_quality_issues"
+    __table_args__ = (
+        db.CheckConstraint(
+            "severity IN ('info','warning','error','critical')",
+            name="ck_device_quality_issue_severity",
+        ),
+        db.CheckConstraint(
+            "priority IN ('low','medium','high','critical')",
+            name="ck_device_quality_issue_priority",
+        ),
+        db.CheckConstraint(
+            "status IN "
+            "('open','assigned','in_progress','pending_review','closed')",
+            name="ck_device_quality_issue_status",
+        ),
+        db.CheckConstraint(
+            "occurrence_number > 0 AND current_version > 0",
+            name="ck_device_quality_issue_versions",
+        ),
+        db.Index(
+            "uq_device_quality_open_recurrence",
+            "recurrence_key",
+            unique=True,
+            postgresql_where=db.text("status <> 'closed'"),
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    issue_code = db.Column(db.String(32), nullable=False, unique=True)
+    source_violation_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    source_run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    rule_code = db.Column(db.String(120), nullable=False)
+    severity = db.Column(db.String(20), nullable=False)
+    priority = 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))
+    source_mapping_uid = db.Column(UUID(as_uuid=False))
+    message = db.Column(db.String(1000), nullable=False)
+    evidence = db.Column(JSONB, nullable=False)
+    recurrence_key = db.Column(db.String(64), nullable=False)
+    occurrence_number = db.Column(db.Integer, nullable=False)
+    status = db.Column(db.String(30), nullable=False, default="open")
+    assignee_uid = db.Column(UUID(as_uuid=False))
+    due_at = db.Column(db.DateTime(timezone=True))
+    current_version = db.Column(db.Integer, nullable=False, default=1)
+    created_by = db.Column(UUID(as_uuid=False), nullable=False)
+    updated_by = db.Column(UUID(as_uuid=False), nullable=False)
+    created_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    updated_at = db.Column(
+        db.DateTime(timezone=True),
+        nullable=False,
+        default=now_china,
+    )
+    closed_at = db.Column(db.DateTime(timezone=True))
+
+
+class DeviceQualityIssueRemediation(db.Model):
+    __tablename__ = "device_quality_issue_remediations"
+    __table_args__ = (
+        db.CheckConstraint(
+            "round_number > 0",
+            name="ck_device_quality_issue_remediation_round",
+        ),
+        db.CheckConstraint(
+            "review_status IN ('pending','passed','failed')",
+            name="ck_device_quality_issue_remediation_review",
+        ),
+        db.UniqueConstraint(
+            "issue_uid",
+            "round_number",
+            name="uq_device_quality_issue_remediation_round",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    issue_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_issues.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    round_number = db.Column(db.Integer, nullable=False)
+    summary = db.Column(db.String(2000), nullable=False)
+    evidence_refs = db.Column(JSONB, nullable=False)
+    submitted_by = db.Column(UUID(as_uuid=False), nullable=False)
+    submitted_at = db.Column(db.DateTime(timezone=True), nullable=False)
+    review_status = db.Column(db.String(20), nullable=False, default="pending")
+    reviewed_by = db.Column(UUID(as_uuid=False))
+    reviewed_at = db.Column(db.DateTime(timezone=True))
+    review_note = db.Column(db.String(2000))
+    verification_run_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_runs.uid", ondelete="RESTRICT"),
+    )
+
+
+class DeviceQualityIssueTimeline(db.Model):
+    __tablename__ = "device_quality_issue_timeline"
+    __table_args__ = (
+        db.CheckConstraint(
+            "to_status IN "
+            "('open','assigned','in_progress','pending_review','closed')",
+            name="ck_device_quality_issue_timeline_status",
+        ),
+        {"schema": "public"},
+    )
+
+    uid = db.Column(
+        UUID(as_uuid=False),
+        primary_key=True,
+        default=new_governance_uid,
+    )
+    issue_uid = db.Column(
+        UUID(as_uuid=False),
+        db.ForeignKey("public.device_quality_issues.uid", ondelete="CASCADE"),
+        nullable=False,
+    )
+    action = db.Column(db.String(40), nullable=False)
+    from_status = db.Column(db.String(30))
+    to_status = db.Column(db.String(30), nullable=False)
+    actor_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    note = db.Column(db.String(2000))
+    payload = db.Column(JSONB, nullable=False)
+    created_at = db.Column(db.DateTime(timezone=True), nullable=False)
+
+
 class EvidenceFragment(db.Model):
     __tablename__ = "evidence_fragments"
     __table_args__ = (

+ 128 - 0
deployment/migrations/versions/20260729_340_quality_issue_remediation.py

@@ -0,0 +1,128 @@
+"""Add governed device-quality issue remediation lifecycle."""
+
+from alembic import op
+
+revision = "20260729_340"
+down_revision = "20260729_330"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_quality_issues (
+            uid UUID PRIMARY KEY,
+            issue_code VARCHAR(32) NOT NULL UNIQUE,
+            source_violation_uid UUID NOT NULL,
+            source_run_uid UUID NOT NULL
+                REFERENCES public.device_quality_runs(uid) ON DELETE RESTRICT,
+            rule_code VARCHAR(120) NOT NULL,
+            severity VARCHAR(20) NOT NULL
+                CHECK (severity IN ('info','warning','error','critical')),
+            priority VARCHAR(20) NOT NULL
+                CHECK (priority IN ('low','medium','high','critical')),
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            field_name VARCHAR(300) NOT NULL,
+            source_uid UUID,
+            source_mapping_uid UUID,
+            message VARCHAR(1000) NOT NULL,
+            evidence JSONB NOT NULL,
+            recurrence_key CHAR(64) NOT NULL,
+            occurrence_number INTEGER NOT NULL CHECK (occurrence_number > 0),
+            status VARCHAR(30) NOT NULL DEFAULT 'open'
+                CHECK (
+                    status IN (
+                        'open','assigned','in_progress',
+                        'pending_review','closed'
+                    )
+                ),
+            assignee_uid UUID
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            due_at TIMESTAMPTZ,
+            current_version INTEGER NOT NULL DEFAULT 1
+                CHECK (current_version > 0),
+            created_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            updated_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            closed_at TIMESTAMPTZ
+        );
+        CREATE UNIQUE INDEX uq_device_quality_open_recurrence
+            ON public.device_quality_issues(recurrence_key)
+            WHERE status <> 'closed';
+        CREATE INDEX idx_device_quality_issues_worklist
+            ON public.device_quality_issues(
+                status, priority, due_at, created_at DESC
+            );
+        CREATE INDEX idx_device_quality_issues_assignee
+            ON public.device_quality_issues(
+                assignee_uid, status, due_at
+            );
+        CREATE INDEX idx_device_quality_issues_recurrence
+            ON public.device_quality_issues(
+                recurrence_key, occurrence_number DESC
+            );
+        CREATE INDEX idx_device_quality_issues_source
+            ON public.device_quality_issues(
+                source_run_uid, source_violation_uid
+            );
+
+        CREATE TABLE public.device_quality_issue_remediations (
+            uid UUID PRIMARY KEY,
+            issue_uid UUID NOT NULL
+                REFERENCES public.device_quality_issues(uid) ON DELETE CASCADE,
+            round_number INTEGER NOT NULL CHECK (round_number > 0),
+            summary VARCHAR(2000) NOT NULL,
+            evidence_refs JSONB NOT NULL,
+            submitted_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            submitted_at TIMESTAMPTZ NOT NULL,
+            review_status VARCHAR(20) NOT NULL DEFAULT 'pending'
+                CHECK (review_status IN ('pending','passed','failed')),
+            reviewed_by UUID
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            reviewed_at TIMESTAMPTZ,
+            review_note VARCHAR(2000),
+            verification_run_uid UUID
+                REFERENCES public.device_quality_runs(uid) ON DELETE RESTRICT,
+            UNIQUE (issue_uid, round_number)
+        );
+        CREATE INDEX idx_device_quality_issue_remediations
+            ON public.device_quality_issue_remediations(
+                issue_uid, round_number DESC
+            );
+
+        CREATE TABLE public.device_quality_issue_timeline (
+            uid UUID PRIMARY KEY,
+            issue_uid UUID NOT NULL
+                REFERENCES public.device_quality_issues(uid) ON DELETE CASCADE,
+            action VARCHAR(40) NOT NULL,
+            from_status VARCHAR(30),
+            to_status VARCHAR(30) NOT NULL
+                CHECK (
+                    to_status IN (
+                        'open','assigned','in_progress',
+                        'pending_review','closed'
+                    )
+                ),
+            actor_uid UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            note VARCHAR(2000),
+            payload JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL
+        );
+        CREATE INDEX idx_device_quality_issue_timeline
+            ON public.device_quality_issue_timeline(
+                issue_uid, created_at, uid
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Remediation and review evidence is retained during application rollback.
+    pass

+ 1 - 0
docs/DATAOPS_PHASE1_3_MONTH_WORK_PLAN_20260729.md

@@ -160,6 +160,7 @@ P2 不阻塞第一阶段验收。没有完成的 P2 功能必须保留接口和
 | 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 承接整改与复验 |
+| WP-08 | 工程完成,待企业整改流程验收 | WP-07 违规证据快照转问题;同类未关闭问题幂等去重;有效用户分派和期限;整改提交、独立责任人复核、关闭、退回与重开;乐观锁和追加式处理时间线;逾期、发生次数、复发问题组及复发率;编辑者与复核者权限分离;OpenAPI 191 项;真实 PostgreSQL 定向验证 | 需要企业提供实际质量问题、整改负责人、唯一质量问题复核负责人、整改时限和通过标准并完成端到端运营验收;复验检查可关联但不把违规抽样未命中当成自动通过;通知升级、通用工单集成、自动修复、跨资产趋势和根因分析不在 WP-08 |
 
 ## 7. 12 周执行计划
 

+ 5 - 3
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -547,6 +547,8 @@ WP-06 已补齐跨来源实体治理工程链:确定性规则按名称、位
 
 WP-07 已补齐设备质量工程链:七类封闭规则覆盖台账身份和上下文完整性、来源编码规范与唯一性、部件父级解析、故障代码映射、原因/措施完整性和维修闭环;规则以不可变版本发布,执行绑定精确版本和内容哈希,保存规则级精确计数、最多 100 条脱敏违规样本及资产级评分。质量检查不改写设备台账,企业真实设备、告警和维修数据仍待现场验收,整改工单与复验闭环归 WP-08。
 
+WP-08 已补齐设备质量问题的工程闭环:可从 WP-07 违规样本批量创建带安全证据快照的问题,同一未关闭规则/资产/字段问题幂等复用;问题支持有效用户分派、期限、整改提交、独立责任人复核、关闭、退回和重开,所有动作形成追加式时间线并以版本号防止并发覆盖。工作台展示逾期、发生次数、复发问题组和复发率。企业真实问题、人员分工、整改时限和复核标准仍待现场运营验收;通知升级、自动修复、通用工单集成和根因分析不在 WP-08。
+
 ### 12.5 数据标准、语义与本体
 
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
@@ -587,9 +589,9 @@ WP-07 已补齐设备质量工程链:七类封闭规则覆盖台账身份和
 | DQA-07 | 数据质量 / Schema 漂移 | 字段、类型、约束和枚举变化检测 | 部分建设 |
 | DQA-08 | 数据质量 / 新鲜度 | 数据更新时间、延迟和过期检测 | 规划中 |
 | DQA-09 | 数据质量 / 质量 SLA | 质量目标、阈值、窗口、违约和升级 | 规划中 |
-| DQA-10 | 质量运营 / 质量问题 | 问题创建、分类、影响、责任人和优先级 | 规划中 |
-| DQA-11 | 质量运营 / 整改工单 | 分派、整改、复核、关闭、重开和逾期 | 规划中 |
-| DQA-12 | 质量运营 / 复发分析 | 重复问题识别、复发率和治理效果 | 规划中 |
+| DQA-10 | 质量运营 / 质量问题 | 问题创建、分类、影响、责任人和优先级 | 工程完成,待企业质量问题验收 |
+| DQA-11 | 质量运营 / 整改工单 | 分派、整改、复核、关闭、重开和逾期 | 工程完成,待企业整改流程验收 |
+| DQA-12 | 质量运营 / 复发分析 | 重复问题识别、复发率和治理效果 | 部分建设;确定性复发身份、次数、问题组和复发率已形成,跨资产趋势及治理效果分析待建设 |
 | DQA-13 | 质量智能 / 根因分析 | 结合血缘、变更和运行证据分析根因 | 规划中 |
 | DQA-14 | 质量智能 / 影响分析 | 识别受影响资产、产品、报表、Agent 和业务域 | 部分建设 |
 | DQA-15 | 质量智能 / 修复建议 | AI 生成受证据约束的修复建议 | 规划中 |

+ 2 - 1
docs/architecture/DATA_MODEL.md

@@ -192,7 +192,8 @@ flowchart LR
 - 设备资产、源编码映射和不可变版本以 PostgreSQL 为源真相;跨来源匹配、合并与回滚在 WP-06 经审核后实施。
 - 实体匹配候选、审核、主资产关联和回滚证据以 PostgreSQL 为源真相;合并是可撤销关系,不删除、不搬迁设备资产、来源映射或历史版本。
 - 规则自动合并默认关闭,只允许显式开闸后的确定性规则高置信度候选;AI 候选始终进入人工审核。
-- 设备质量策略版本、执行结果、规则计数、违规样本和资产评分以 PostgreSQL 为源真相;检查只读设备资产和已发布语义代码,不修改来源台账,整改闭环由 WP-08 承接。
+- 设备质量策略版本、执行结果、规则计数、违规样本和资产评分以 PostgreSQL 为源真相;检查只读设备资产和已发布语义代码,不修改来源台账。
+- 质量问题、整改轮次和处理时间线以 PostgreSQL 为源真相;问题保存 WP-07 违规的安全证据快照,状态变更采用乐观锁,关闭必须由 `quality_issue/DEVICE_QUALITY_ISSUES` 唯一负责的设备资产管理员独立复核。逾期由期限和未关闭状态实时计算,复发由规则、资产和字段的确定性身份统计,不等同于自动根因结论。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 294 - 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: 180
+x-route-count: 191
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -2099,6 +2099,299 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/device-quality/issues":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_quality_issues_get
+      summary: "list quality issues"
+      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/issues/assignees":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_quality_issue_assignees_get
+      summary: "list quality issue assignees"
+      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/issues/import":
+    post:
+      tags: [data_development]
+      operationId: data_development_import_quality_issues_post
+      summary: "import quality issues"
+      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/issues/statistics":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_quality_issue_statistics_get
+      summary: "get quality issue statistics"
+      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/issues/{issue_uid}":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_quality_issue_get
+      summary: "get quality issue"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: issue_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/issues/{issue_uid}/assign":
+    post:
+      tags: [data_development]
+      operationId: data_development_assign_quality_issue_post
+      summary: "assign quality issue"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: issue_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/issues/{issue_uid}/reopen":
+    post:
+      tags: [data_development]
+      operationId: data_development_reopen_quality_issue_post
+      summary: "reopen quality issue"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: issue_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/issues/{issue_uid}/review":
+    post:
+      tags: [data_development]
+      operationId: data_development_review_quality_issue_post
+      summary: "review quality issue"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: issue_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/issues/{issue_uid}/start":
+    post:
+      tags: [data_development]
+      operationId: data_development_start_quality_issue_post
+      summary: "start quality issue"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: issue_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/issues/{issue_uid}/submit":
+    post:
+      tags: [data_development]
+      operationId: data_development_submit_quality_issue_post
+      summary: "submit quality issue"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: issue_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/issues/{issue_uid}/timeline":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_quality_issue_timeline_get
+      summary: "get quality issue timeline"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: issue_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/profile":
     get:
       tags: [data_development]

+ 96 - 0
docs/superpowers/plans/2026-07-29-wp08-quality-remediation.md

@@ -0,0 +1,96 @@
+# WP08 Quality Issue Remediation Implementation Plan
+
+> **For Codex:** Execute this plan with targeted tests only. Do not run the full regression suite.
+
+**Goal:** Turn immutable WP07 device-quality violations into governed quality issues that can be assigned, remediated, independently reviewed, closed, reopened, and measured for overdue and recurrence.
+
+**Architecture:** WP07 quality runs and violation samples remain immutable source evidence. WP08 adds a PostgreSQL-backed issue register, append-only remediation rounds, and append-only timeline events. An issue snapshots the bounded violation evidence so the work record remains auditable after the 30-day violation-sample retention window. State transitions are explicit and optimistic-lock protected; closure requires an accountable quality-issue reviewer who is not the remediation submitter. Statistics are derived from persisted issue history and never presented as an automated root-cause conclusion.
+
+**Technology:** Flask, SQLAlchemy/PostgreSQL, Alembic, Vue 2/Vuetify, Node test runner, pytest.
+
+---
+
+## Scope and constraints
+
+- Implement DQA-10 issue creation/classification/impact/priority, DQA-11 assignment/remediation/review/close/reopen/overdue, and the bounded DQA-12 recurrence count/rate.
+- Create issues only from persisted WP07 violation samples. Preserve source run, violation, rule, asset, field, mapping, message, and safe evidence snapshot.
+- Import at most 100 violation UIDs per request. Re-importing the same unresolved recurrence identity returns the existing issue; a later violation may become a new recurrence only after the prior issue is closed.
+- Use states `open`, `assigned`, `in_progress`, `pending_review`, and `closed`. Every mutation requires `expected_version`.
+- Only an active platform user may be assigned. Only the current assignee or an administrator may start work and submit remediation.
+- Closure requires a `passed` verification result from the unique accountable asset manager configured at `quality_issue/DEVICE_QUALITY_ISSUES`. A reviewer cannot approve their own remediation submission.
+- Rejection returns the issue to `in_progress`; reopening a closed issue returns it to `assigned` when it still has an assignee.
+- Store remediation summary and up to 20 bounded evidence references. Optional `verification_run_uid` links a real WP07 run, but no absence-in-a-sample claim is treated as automatic proof.
+- Overdue is derived from `due_at < now` while not closed. Recurrence is derived from the deterministic identity of rule, canonical asset, and field; it is not AI root-cause analysis.
+- Do not add generic ticketing, notifications, external workflow integration, automatic source repair, automatic quality re-execution, SLA escalation, or predictive maintenance.
+
+## Task 1: Domain lifecycle
+
+**Files:**
+
+- Create: `app/core/data_research/quality_issues.py`
+- Modify: `app/core/data_research/errors.py`
+- Test: `tests/data_research/test_quality_issues.py`
+
+1. Write failing tests for violation import, open-issue deduplication, assignment, assignee-only work, submission, independent accountable review, rejection, close, reopen, stale versions, overdue and recurrence.
+2. Add immutable record types, bounded input validation, deterministic recurrence identity, and the transition service.
+3. Run only `tests/data_research/test_quality_issues.py`.
+
+## Task 2: PostgreSQL persistence
+
+**Files:**
+
+- Create: `app/core/data_research/quality_issue_repository.py`
+- Modify: `app/models/data_research.py`
+- Create: `migrations/versions/20260729_340_quality_issue_remediation.py`
+- Test: `tests/integration/test_quality_issue_postgres.py`
+
+1. Write failing integration tests for schema constraints, open-issue deduplication, append-only rounds/timeline, filtering, overdue, and recurrence aggregation.
+2. Add issue, remediation-round, and timeline models with indexes and checks.
+3. Implement repository locking, active-user validation, source evidence lookup, pagination, and statistics queries.
+4. Apply migration 340 to the local PostgreSQL test database and run only the WP08 integration test.
+
+## Task 3: Governed API
+
+**Files:**
+
+- Modify: `app/api/data_development/routes.py`
+- Modify: `app/core/system/permissions.py`
+- Modify: `tests/test_permission_matrix.py`
+- Create: `tests/data_research/test_quality_issue_api.py`
+
+1. Write failing permission and route tests for read, import, assign, start, submit, review, reopen, timeline, and statistics.
+2. Add `quality-issues:edit` and `quality-issues:review`. Editors receive edit; administrators receive edit/review; reads remain governance-read.
+3. Bind review authorization to the unique accountable `quality_issue/DEVICE_QUALITY_ISSUES` asset manager.
+4. Expose bounded list/detail/statistics/timeline reads and explicit transition writes below `/api/development/v1/device-quality/issues`.
+5. Run only WP08 API and permission tests.
+
+## Task 4: Quality issue workbench
+
+**Files:**
+
+- Modify: `frontend/src/api/dataDevelopment.js`
+- Modify: `frontend/src/views/dataGovernance/development/deviceQuality.vue`
+- Modify: `frontend/src/views/dataGovernance/development/deviceQualityModel.js`
+- Modify: `frontend/tests/device-quality-model.test.mjs`
+- Create: `tests/data_research/test_quality_issue_frontend_contract.py`
+
+1. Write failing model and source-contract tests for permissions, labels, overdue/recurrence presentation, API bindings, issue table, timeline, assignment, submission, review, and reopen actions.
+2. Add an issue-register section to the existing device-quality workbench so violations and remediation remain in one governed context.
+3. Allow eligible users to select WP07 violation samples and create issues, then operate the explicit lifecycle through constrained dialogs.
+4. Run only the device-quality Node model test, WP08 frontend contract, ESLint on touched frontend files, and a production build.
+
+## Task 5: Contracts, ledger and targeted acceptance
+
+**Files:**
+
+- 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: matching files under `deployment/app/`
+
+1. Generate OpenAPI from current routes and record the new operation count.
+2. Copy changed runtime source into `deployment/app/` and verify byte parity.
+3. Update DQA-10 through DQA-12 and WP-08 maturity honestly: engineering complete but enterprise quality data and operating-role acceptance still pending.
+4. Run only touched Python lint, WP08 unit/integration/API/permission/contract/architecture tests, frontend model/lint/build, migration head check, and one local browser lifecycle using real PostgreSQL.
+5. Review the diff, commit WP08 on the current branch, and leave the worktree clean.

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

@@ -180,6 +180,46 @@ export const getDeviceQualityAssetScores = (uid, params) => http.get(
   `${DEVICE_QUALITY_BASE}/runs/${uid}/asset-scores`,
   params
 )
+export const getQualityIssues = params => http.get(
+  `${DEVICE_QUALITY_BASE}/issues`,
+  params
+)
+export const getQualityIssue = uid => http.get(
+  `${DEVICE_QUALITY_BASE}/issues/${uid}`
+)
+export const getQualityIssueStatistics = () => http.get(
+  `${DEVICE_QUALITY_BASE}/issues/statistics`
+)
+export const getQualityIssueAssignees = () => http.get(
+  `${DEVICE_QUALITY_BASE}/issues/assignees`
+)
+export const getQualityIssueTimeline = uid => http.get(
+  `${DEVICE_QUALITY_BASE}/issues/${uid}/timeline`
+)
+export const importQualityIssues = params => http.post(
+  `${DEVICE_QUALITY_BASE}/issues/import`,
+  params
+)
+export const assignQualityIssue = (uid, params) => http.post(
+  `${DEVICE_QUALITY_BASE}/issues/${uid}/assign`,
+  params
+)
+export const startQualityIssue = (uid, expectedVersion) => http.post(
+  `${DEVICE_QUALITY_BASE}/issues/${uid}/start`,
+  { expected_version: expectedVersion }
+)
+export const submitQualityIssue = (uid, params) => http.post(
+  `${DEVICE_QUALITY_BASE}/issues/${uid}/submit`,
+  params
+)
+export const reviewQualityIssue = (uid, params) => http.post(
+  `${DEVICE_QUALITY_BASE}/issues/${uid}/review`,
+  params
+)
+export const reopenQualityIssue = (uid, params) => http.post(
+  `${DEVICE_QUALITY_BASE}/issues/${uid}/reopen`,
+  params
+)
 
 export default {
   createIngestionJob,
@@ -241,5 +281,16 @@ export default {
   getDeviceQualityRuns,
   getDeviceQualityRun,
   getDeviceQualityViolations,
-  getDeviceQualityAssetScores
+  getDeviceQualityAssetScores,
+  getQualityIssues,
+  getQualityIssue,
+  getQualityIssueStatistics,
+  getQualityIssueAssignees,
+  getQualityIssueTimeline,
+  importQualityIssues,
+  assignQualityIssue,
+  startQualityIssue,
+  submitQualityIssue,
+  reviewQualityIssue,
+  reopenQualityIssue
 }

+ 728 - 3
frontend/src/views/dataGovernance/development/deviceQuality.vue

@@ -48,7 +48,7 @@
     </div>
 
     <v-alert type="info" outlined class="mb-5">
-      质量检查读取设备台账和已发布语义代码,不改写来源数据。违规修复、责任派发和复验闭环属于 WP08
+      质量检查不改写来源数据;整改问题保留违规证据快照,并通过分派、整改、独立复核、关闭和重开形成审计闭环
     </v-alert>
 
     <v-row class="mb-1">
@@ -135,6 +135,146 @@
       </v-data-table>
     </v-card>
 
+    <v-card outlined class="mb-5">
+      <v-card-title class="flex-wrap">
+        质量问题整改闭环
+        <v-spacer />
+        <v-chip small outlined class="mr-2">
+          未关闭 {{ issueStatistics.open || 0 }}
+        </v-chip>
+        <v-chip small outlined color="error" class="mr-2">
+          逾期 {{ issueStatistics.overdue || 0 }}
+        </v-chip>
+        <v-chip small outlined color="warning">
+          复发组 {{ issueStatistics.recurrent || 0 }} ·
+          复发率 {{ formatIssueRate(issueStatistics.recurrence_rate) }}
+        </v-chip>
+      </v-card-title>
+      <v-divider />
+      <div class="d-flex flex-wrap align-center pa-4">
+        <v-select
+          v-model="issueStatusFilter"
+          :items="issueStatusOptions"
+          item-text="text"
+          item-value="value"
+          label="问题状态"
+          clearable
+          dense
+          outlined
+          hide-details
+          class="issue-filter mr-3"
+          @change="reloadIssues"
+        />
+        <v-checkbox
+          v-model="overdueOnly"
+          label="只看逾期"
+          dense
+          hide-details
+          class="mt-0"
+          @change="reloadIssues"
+        />
+        <v-spacer />
+        <v-btn text color="primary" :loading="loadingIssues" @click="loadIssues">
+          刷新
+        </v-btn>
+      </div>
+      <v-data-table
+        :headers="issueHeaders"
+        :items="issues"
+        :loading="loadingIssues"
+        :items-per-page="issuePageSize"
+        hide-default-footer
+      >
+        <template v-slot:[`item.issue_code`]="{ item }">
+          <strong>{{ item.issue_code }}</strong>
+          <div class="text-caption text--secondary">
+            {{ ruleLabel(item.rule_code) }}
+          </div>
+        </template>
+        <template v-slot:[`item.status`]="{ item }">
+          <v-chip small outlined :color="issueStatusColor(item.status)">
+            {{ issueStatusLabel(item.status) }}
+          </v-chip>
+        </template>
+        <template v-slot:[`item.priority`]="{ item }">
+          {{ priorityLabel(item.priority) }}
+        </template>
+        <template v-slot:[`item.recurrence`]="{ item }">
+          <span :class="{ 'warning--text': item.occurrence_number > 1 }">
+            {{ recurrenceLabel(item.occurrence_number) }}
+          </span>
+        </template>
+        <template v-slot:[`item.due_at`]="{ item }">
+          <span :class="{ 'error--text font-weight-bold': dueState(item) === 'overdue' }">
+            {{ displayTime(item.due_at) }}
+          </span>
+        </template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn text small color="primary" @click="openIssue(item)">详情</v-btn>
+          <v-btn
+            v-if="canEditIssueRegister && item.status !== 'closed'"
+            text
+            small
+            @click="openAssign(item)"
+          >
+            分派
+          </v-btn>
+          <v-btn
+            v-if="canEditIssueRegister && item.status === 'assigned'"
+            text
+            small
+            @click="startIssue(item)"
+          >
+            开始
+          </v-btn>
+          <v-btn
+            v-if="canEditIssueRegister && ['assigned', 'in_progress'].includes(item.status)"
+            text
+            small
+            @click="openSubmit(item)"
+          >
+            提交
+          </v-btn>
+          <v-btn
+            v-if="canReviewIssueRegister && item.status === 'pending_review'"
+            text
+            small
+            color="success"
+            @click="openReview(item)"
+          >
+            复核
+          </v-btn>
+          <v-btn
+            v-if="canEditIssueRegister && item.status === 'closed'"
+            text
+            small
+            color="warning"
+            @click="openReopen(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">
+          共 {{ issueTotal }} 个问题,已关闭 {{ issueStatistics.closed || 0 }}
+        </span>
+        <v-spacer />
+        <v-pagination
+          v-model="issuePage"
+          :length="issuePageCount"
+          :total-visible="7"
+          @input="loadIssues"
+        />
+      </div>
+    </v-card>
+
     <v-card outlined class="mb-5">
       <v-card-title>质量检查记录</v-card-title>
       <v-divider />
@@ -233,8 +373,11 @@
             <v-tabs-items v-model="detailTab">
               <v-tab-item>
                 <v-data-table
+                  v-model="selectedViolations"
                   :headers="violationHeaders"
                   :items="violations"
+                  item-key="uid"
+                  show-select
                   :items-per-page="20"
                   hide-default-footer
                 >
@@ -256,6 +399,16 @@
                 <div class="text-caption text--secondary mt-2">
                   当前展示 {{ violations.length }} / {{ violationTotal }} 条;每条规则最多保留 100 条样本,30 天后清理。
                 </div>
+                <v-btn
+                  v-if="canEditIssueRegister"
+                  class="mt-3"
+                  color="primary"
+                  outlined
+                  :disabled="selectedViolations.length === 0"
+                  @click="issueImportDialog = true"
+                >
+                  创建整改问题({{ selectedViolations.length }})
+                </v-btn>
               </v-tab-item>
               <v-tab-item>
                 <v-data-table
@@ -284,6 +437,261 @@
       </v-card>
     </v-dialog>
 
+    <v-dialog v-model="issueImportDialog" max-width="620">
+      <v-card>
+        <v-card-title>创建质量整改问题</v-card-title>
+        <v-card-text>
+          <v-alert dense outlined type="info">
+            将为 {{ selectedViolations.length }} 条违规保存证据快照;未关闭的同类问题不会重复创建。
+          </v-alert>
+          <v-select
+            v-model="issueForm.priority"
+            :items="priorityOptions"
+            item-text="text"
+            item-value="value"
+            label="优先级"
+            outlined
+            dense
+          />
+          <v-text-field
+            v-model="issueForm.dueAt"
+            type="datetime-local"
+            label="整改期限(可选)"
+            outlined
+            dense
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="issueImportDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="savingIssue" @click="createIssues">
+            创建问题
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="issueDetailDialog" max-width="900" scrollable>
+      <v-card>
+        <v-card-title>
+          整改问题详情
+          <v-spacer />
+          <v-btn icon @click="issueDetailDialog = 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="issueDetailLoading" indeterminate />
+          <template v-else-if="selectedIssue">
+            <v-row>
+              <v-col cols="12" sm="4">
+                <div class="field-label">问题编号 / 状态</div>
+                <div>
+                  <strong>{{ selectedIssue.issue_code }}</strong>
+                  · {{ issueStatusLabel(selectedIssue.status) }}
+                </div>
+              </v-col>
+              <v-col cols="12" sm="4">
+                <div class="field-label">规则 / 字段</div>
+                <div>{{ ruleLabel(selectedIssue.rule_code) }}</div>
+                <div class="text-caption">{{ selectedIssue.field_name }}</div>
+              </v-col>
+              <v-col cols="12" sm="4">
+                <div class="field-label">优先级 / 发生次数</div>
+                <div>
+                  {{ priorityLabel(selectedIssue.priority) }} ·
+                  {{ recurrenceLabel(selectedIssue.occurrence_number) }}
+                </div>
+              </v-col>
+            </v-row>
+            <v-alert dense outlined type="warning" class="mt-2">
+              {{ selectedIssue.message }}
+            </v-alert>
+            <div class="field-label">来源证据</div>
+            <div class="mono mb-4">
+              检查 {{ selectedIssue.source_run_uid }} /
+              违规 {{ selectedIssue.source_violation_uid }} /
+              资产 {{ selectedIssue.asset_uid }}
+            </div>
+            <template v-if="selectedIssue.latest_remediation">
+              <div class="field-label">最近整改与复核</div>
+              <div>{{ selectedIssue.latest_remediation.summary }}</div>
+              <div class="text-caption mb-4">
+                第 {{ selectedIssue.latest_remediation.round_number }} 轮 ·
+                {{ selectedIssue.latest_remediation.review_status }}
+                <span v-if="selectedIssue.latest_remediation.review_note">
+                  · {{ selectedIssue.latest_remediation.review_note }}
+                </span>
+              </div>
+            </template>
+            <div class="field-label mb-2">处理时间线</div>
+            <v-timeline dense>
+              <v-timeline-item
+                v-for="event in issueTimeline"
+                :key="event.uid"
+                small
+                color="primary"
+              >
+                <strong>{{ timelineActionLabel(event.action) }}</strong>
+                <div class="text-caption">
+                  {{ displayTime(event.created_at) }} · {{ event.actor_uid }}
+                </div>
+                <div v-if="event.note" class="mt-1">{{ event.note }}</div>
+              </v-timeline-item>
+            </v-timeline>
+          </template>
+        </v-card-text>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="assignDialog" max-width="620">
+      <v-card>
+        <v-card-title>分派整改负责人</v-card-title>
+        <v-card-text>
+          <v-select
+            v-model="issueForm.assigneeUid"
+            :items="assignees"
+            item-text="display_name"
+            item-value="uid"
+            label="负责人"
+            outlined
+            dense
+          >
+            <template v-slot:item="{ item }">
+              <div>
+                {{ item.display_name }}
+                <span class="text-caption text--secondary">
+                  ({{ item.username }})
+                </span>
+              </div>
+            </template>
+          </v-select>
+          <v-text-field
+            v-model="issueForm.dueAt"
+            type="datetime-local"
+            label="整改期限(可选)"
+            outlined
+            dense
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="assignDialog = false">取消</v-btn>
+          <v-btn
+            color="primary"
+            :disabled="!issueForm.assigneeUid"
+            :loading="savingIssue"
+            @click="assignIssue"
+          >
+            确认分派
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="submitDialog" max-width="620">
+      <v-card>
+        <v-card-title>提交整改复核</v-card-title>
+        <v-card-text>
+          <v-textarea
+            v-model.trim="issueForm.summary"
+            label="整改说明"
+            hint="说明修复了什么、如何核对;证据引用可在后续轮次持续补充"
+            persistent-hint
+            outlined
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="submitDialog = false">取消</v-btn>
+          <v-btn
+            color="primary"
+            :disabled="!issueForm.summary"
+            :loading="savingIssue"
+            @click="submitIssue"
+          >
+            提交复核
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="reviewDialog" max-width="620">
+      <v-card>
+        <v-card-title>复核整改结果</v-card-title>
+        <v-card-text>
+          <v-select
+            v-model="issueForm.verificationResult"
+            :items="verificationOptions"
+            item-text="text"
+            item-value="value"
+            label="复验结果"
+            outlined
+            dense
+          />
+          <v-text-field
+            v-model.trim="issueForm.verificationRunUid"
+            label="复验质量检查 UID(可选)"
+            outlined
+            dense
+          />
+          <v-textarea
+            v-model.trim="issueForm.note"
+            label="复核说明"
+            outlined
+          />
+          <v-alert dense outlined type="info">
+            通过将关闭问题,未通过将退回整改中;复核人不能是本轮整改提交人。
+          </v-alert>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="reviewDialog = false">取消</v-btn>
+          <v-btn
+            color="success"
+            :disabled="!issueForm.verificationResult || !issueForm.note"
+            :loading="savingIssue"
+            @click="reviewIssue"
+          >
+            提交复核
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="reopenDialog" max-width="620">
+      <v-card>
+        <v-card-title>重新打开质量问题</v-card-title>
+        <v-card-text>
+          <v-textarea
+            v-model.trim="issueForm.reason"
+            label="重开原因"
+            outlined
+          />
+          <v-text-field
+            v-model="issueForm.dueAt"
+            type="datetime-local"
+            label="新的整改期限(可选)"
+            outlined
+            dense
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="reopenDialog = false">取消</v-btn>
+          <v-btn
+            color="warning"
+            :disabled="!issueForm.reason"
+            :loading="savingIssue"
+            @click="reopenIssue"
+          >
+            确认重开
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
     <v-dialog v-model="revisionDialog" max-width="980" scrollable>
       <v-card>
         <v-card-title>新建质量规则版本</v-card-title>
@@ -372,6 +780,7 @@
 
 <script>
 import {
+  assignQualityIssue,
   bootstrapDeviceQuality,
   createDeviceQualityVersion,
   getDeviceQualityAssetScores,
@@ -379,20 +788,37 @@ import {
   getDeviceQualityRun,
   getDeviceQualityRuns,
   getDeviceQualityViolations,
+  getQualityIssue,
+  getQualityIssueAssignees,
+  getQualityIssues,
+  getQualityIssueStatistics,
+  getQualityIssueTimeline,
+  importQualityIssues,
   publishDeviceQualityVersion,
+  reopenQualityIssue,
+  reviewQualityIssue,
+  startQualityIssue,
+  submitQualityIssue,
   runDeviceQuality
 } from '@/api/dataDevelopment'
 import {
   canBootstrapOrRevise,
+  canEditIssues,
   canPublish,
+  canReviewIssues,
   canRun,
+  dueState,
   evidenceSummary,
   formatPassRate,
   formatScore,
+  issueStatusLabel,
   policyStatusLabel,
+  priorityLabel,
+  recurrenceLabel,
   resultStatusLabel,
   ruleLabel,
-  severityLabel
+  severityLabel,
+  timelineActionLabel
 } from './deviceQualityModel'
 
 export default {
@@ -418,6 +844,53 @@ export default {
     detailTab: 0,
     revisionRules: [],
     runSourceUid: '',
+    loadingIssues: false,
+    savingIssue: false,
+    issueDetailLoading: false,
+    issues: [],
+    issueTotal: 0,
+    issuePage: 1,
+    issuePageSize: 20,
+    issueStatusFilter: null,
+    overdueOnly: false,
+    issueStatistics: {},
+    assignees: [],
+    selectedViolations: [],
+    selectedIssue: null,
+    issueTimeline: [],
+    issueImportDialog: false,
+    issueDetailDialog: false,
+    assignDialog: false,
+    submitDialog: false,
+    reviewDialog: false,
+    reopenDialog: false,
+    issueForm: {
+      priority: 'high',
+      dueAt: '',
+      assigneeUid: '',
+      summary: '',
+      verificationResult: 'passed',
+      verificationRunUid: '',
+      note: '',
+      reason: ''
+    },
+    issueStatusOptions: [
+      { text: '待分派', value: 'open' },
+      { text: '已分派', value: 'assigned' },
+      { text: '整改中', value: 'in_progress' },
+      { text: '待复核', value: 'pending_review' },
+      { text: '已关闭', value: 'closed' }
+    ],
+    priorityOptions: [
+      { text: '低', value: 'low' },
+      { text: '中', value: 'medium' },
+      { text: '高', value: 'high' },
+      { text: '紧急', value: 'critical' }
+    ],
+    verificationOptions: [
+      { text: '通过并关闭', value: 'passed' },
+      { text: '不通过并退回', value: 'failed' }
+    ],
     severityOptions: [
       { text: '提示', value: 'info' },
       { text: '警告', value: 'warning' },
@@ -452,6 +925,16 @@ export default {
       { text: '适用规则', value: 'evaluated_rule_count' },
       { text: '违规数', value: 'violation_count' },
       { text: '得分', value: 'score' }
+    ],
+    issueHeaders: [
+      { text: '问题', value: 'issue_code', sortable: false },
+      { text: '状态', value: 'status', sortable: false },
+      { text: '优先级', value: 'priority', sortable: false },
+      { text: '资产 UID', value: 'asset_uid', sortable: false },
+      { text: '复发', value: 'recurrence', sortable: false },
+      { text: '负责人', value: 'assignee_uid', sortable: false },
+      { text: '期限', value: 'due_at', sortable: false },
+      { text: '操作', value: 'actions', sortable: false, width: 300 }
     ]
   }),
   computed: {
@@ -467,6 +950,12 @@ export default {
     canExecute () {
       return canRun(this.permissions)
     },
+    canEditIssueRegister () {
+      return canEditIssues(this.permissions)
+    },
+    canReviewIssueRegister () {
+      return canReviewIssues(this.permissions)
+    },
     latestVersion () {
       return this.profile.latest_version || null
     },
@@ -482,6 +971,9 @@ export default {
     runPageCount () {
       return Math.max(1, Math.ceil(this.runTotal / this.runPageSize))
     },
+    issuePageCount () {
+      return Math.max(1, Math.ceil(this.issueTotal / this.issuePageSize))
+    },
     enabledWeight () {
       return Number(this.revisionRules
         .filter(item => item.enabled)
@@ -496,12 +988,23 @@ export default {
     evidenceSummary,
     formatPassRate,
     formatScore,
+    issueStatusLabel,
     policyStatusLabel,
+    priorityLabel,
+    recurrenceLabel,
     resultStatusLabel,
     ruleLabel,
     severityLabel,
+    timelineActionLabel,
+    dueState,
     async load () {
-      await Promise.all([this.loadProfile(), this.loadRuns()])
+      await Promise.all([
+        this.loadProfile(),
+        this.loadRuns(),
+        this.loadIssues(),
+        this.loadIssueStatistics(),
+        this.loadAssignees()
+      ])
     },
     async loadProfile () {
       this.loadingProfile = true
@@ -530,6 +1033,44 @@ export default {
         this.loadingRuns = false
       }
     },
+    async loadIssues () {
+      this.loadingIssues = true
+      try {
+        const response = await getQualityIssues({
+          status: this.issueStatusFilter || undefined,
+          overdue_only: this.overdueOnly,
+          page: this.issuePage,
+          page_size: this.issuePageSize
+        })
+        const data = response.data || {}
+        this.issues = data.records || []
+        this.issueTotal = data.total || 0
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loadingIssues = false
+      }
+    },
+    async loadIssueStatistics () {
+      try {
+        const response = await getQualityIssueStatistics()
+        this.issueStatistics = response.data || {}
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    async loadAssignees () {
+      try {
+        const response = await getQualityIssueAssignees()
+        this.assignees = (response.data && response.data.records) || []
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    async reloadIssues () {
+      this.issuePage = 1
+      await this.loadIssues()
+    },
     async bootstrap () {
       this.saving = true
       try {
@@ -600,6 +1141,7 @@ export default {
       this.detailTab = 0
       this.selectedRun = null
       this.violations = []
+      this.selectedViolations = []
       this.assetScores = []
       try {
         const [detail, violations, scores] = await Promise.all([
@@ -625,6 +1167,189 @@ export default {
         this.detailLoading = false
       }
     },
+    async createIssues () {
+      this.savingIssue = true
+      try {
+        const response = await importQualityIssues({
+          violation_uids: this.selectedViolations.map(item => item.uid),
+          priority: this.issueForm.priority,
+          due_at: this.toIso(this.issueForm.dueAt)
+        })
+        const data = response.data || {}
+        this.$snackbar.success(
+          `已创建 ${data.created_count || 0} 个问题,复用 ${data.existing_count || 0} 个未关闭问题`
+        )
+        this.issueImportDialog = false
+        this.selectedViolations = []
+        await Promise.all([this.loadIssues(), this.loadIssueStatistics()])
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.savingIssue = false
+      }
+    },
+    async openIssue (item) {
+      this.issueDetailDialog = true
+      this.issueDetailLoading = true
+      this.selectedIssue = null
+      this.issueTimeline = []
+      try {
+        const [detail, timeline] = await Promise.all([
+          getQualityIssue(item.uid),
+          getQualityIssueTimeline(item.uid)
+        ])
+        this.selectedIssue = detail.data
+        this.issueTimeline = (timeline.data && timeline.data.records) || []
+      } catch (error) {
+        this.$snackbar.error(error)
+        this.issueDetailDialog = false
+      } finally {
+        this.issueDetailLoading = false
+      }
+    },
+    resetIssueForm () {
+      this.issueForm = {
+        priority: 'high',
+        dueAt: '',
+        assigneeUid: '',
+        summary: '',
+        verificationResult: 'passed',
+        verificationRunUid: '',
+        note: '',
+        reason: ''
+      }
+    },
+    openAssign (item) {
+      this.resetIssueForm()
+      this.selectedIssue = item
+      this.issueForm.assigneeUid = item.assignee_uid || ''
+      this.issueForm.dueAt = this.toInputTime(item.due_at)
+      this.assignDialog = true
+    },
+    async assignIssue () {
+      this.savingIssue = true
+      try {
+        await assignQualityIssue(this.selectedIssue.uid, {
+          assignee_uid: this.issueForm.assigneeUid,
+          due_at: this.toIso(this.issueForm.dueAt),
+          expected_version: this.selectedIssue.current_version
+        })
+        this.$snackbar.success('整改负责人和期限已更新')
+        this.assignDialog = false
+        await this.afterIssueMutation()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.savingIssue = false
+      }
+    },
+    async startIssue (item) {
+      this.savingIssue = true
+      try {
+        await startQualityIssue(item.uid, item.current_version)
+        this.$snackbar.success('问题已进入整改中')
+        await this.afterIssueMutation()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.savingIssue = false
+      }
+    },
+    openSubmit (item) {
+      this.resetIssueForm()
+      this.selectedIssue = item
+      this.submitDialog = true
+    },
+    async submitIssue () {
+      this.savingIssue = true
+      try {
+        await submitQualityIssue(this.selectedIssue.uid, {
+          summary: this.issueForm.summary,
+          evidence_refs: [],
+          expected_version: this.selectedIssue.current_version
+        })
+        this.$snackbar.success('整改结果已提交独立复核')
+        this.submitDialog = false
+        await this.afterIssueMutation()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.savingIssue = false
+      }
+    },
+    openReview (item) {
+      this.resetIssueForm()
+      this.selectedIssue = item
+      this.reviewDialog = true
+    },
+    async reviewIssue () {
+      this.savingIssue = true
+      try {
+        await reviewQualityIssue(this.selectedIssue.uid, {
+          verification_result: this.issueForm.verificationResult,
+          verification_run_uid: this.issueForm.verificationRunUid || null,
+          note: this.issueForm.note,
+          expected_version: this.selectedIssue.current_version
+        })
+        this.$snackbar.success(
+          this.issueForm.verificationResult === 'passed'
+            ? '复核通过,问题已关闭'
+            : '复核未通过,问题已退回整改'
+        )
+        this.reviewDialog = false
+        await this.afterIssueMutation()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.savingIssue = false
+      }
+    },
+    openReopen (item) {
+      this.resetIssueForm()
+      this.selectedIssue = item
+      this.reopenDialog = true
+    },
+    async reopenIssue () {
+      this.savingIssue = true
+      try {
+        await reopenQualityIssue(this.selectedIssue.uid, {
+          reason: this.issueForm.reason,
+          due_at: this.toIso(this.issueForm.dueAt),
+          expected_version: this.selectedIssue.current_version
+        })
+        this.$snackbar.success('质量问题已重新打开')
+        this.reopenDialog = false
+        await this.afterIssueMutation()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.savingIssue = false
+      }
+    },
+    async afterIssueMutation () {
+      await Promise.all([this.loadIssues(), this.loadIssueStatistics()])
+    },
+    issueStatusColor (status) {
+      return {
+        open: 'grey',
+        assigned: 'info',
+        in_progress: 'primary',
+        pending_review: 'warning',
+        closed: 'success'
+      }[status] || 'grey'
+    },
+    formatIssueRate (value) {
+      return `${(Number(value || 0) * 100).toFixed(1)}%`
+    },
+    toIso (value) {
+      return value ? new Date(value).toISOString() : null
+    },
+    toInputTime (value) {
+      if (!value) return ''
+      const date = new Date(value)
+      const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
+      return local.toISOString().slice(0, 16)
+    },
     displayTime (value) {
       if (!value) return '—'
       return new Date(value).toLocaleString('zh-CN', { hour12: false })

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

@@ -27,6 +27,31 @@ const RESULT_STATUS_LABELS = {
   not_applicable: '不适用'
 }
 
+const ISSUE_STATUS_LABELS = {
+  open: '待分派',
+  assigned: '已分派',
+  in_progress: '整改中',
+  pending_review: '待复核',
+  closed: '已关闭'
+}
+
+const PRIORITY_LABELS = {
+  low: '低',
+  medium: '中',
+  high: '高',
+  critical: '紧急'
+}
+
+const TIMELINE_ACTION_LABELS = {
+  created: '创建问题',
+  assigned: '分派整改',
+  started: '开始整改',
+  submitted: '提交复核',
+  rejected: '复核退回',
+  closed: '复核关闭',
+  reopened: '重新打开'
+}
+
 export function canBootstrapOrRevise (permissions) {
   return (permissions || []).includes('device-quality:edit')
 }
@@ -39,6 +64,14 @@ export function canRun (permissions) {
   return (permissions || []).includes('device-quality:execute')
 }
 
+export function canEditIssues (permissions) {
+  return (permissions || []).includes('quality-issues:edit')
+}
+
+export function canReviewIssues (permissions) {
+  return (permissions || []).includes('quality-issues:review')
+}
+
 export function policyStatusLabel (value) {
   return POLICY_STATUS_LABELS[value] || value || '—'
 }
@@ -55,6 +88,30 @@ export function resultStatusLabel (value) {
   return RESULT_STATUS_LABELS[value] || value || '—'
 }
 
+export function issueStatusLabel (value) {
+  return ISSUE_STATUS_LABELS[value] || value || '—'
+}
+
+export function priorityLabel (value) {
+  return PRIORITY_LABELS[value] || value || '—'
+}
+
+export function timelineActionLabel (value) {
+  return TIMELINE_ACTION_LABELS[value] || value || '—'
+}
+
+export function recurrenceLabel (occurrenceNumber) {
+  const count = Number(occurrenceNumber || 0)
+  return count > 1 ? `第 ${count} 次发生` : '首次发生'
+}
+
+export function dueState (issue, now = new Date()) {
+  if (!issue || issue.status === 'closed' || !issue.due_at) return 'normal'
+  return new Date(issue.due_at).getTime() < now.getTime()
+    ? 'overdue'
+    : 'pending'
+}
+
 export function formatScore (value) {
   if (value === null || value === undefined || Number.isNaN(Number(value))) {
     return '—'

+ 31 - 1
frontend/tests/device-quality-model.test.mjs

@@ -3,15 +3,22 @@ import assert from 'node:assert/strict'
 
 import {
   canBootstrapOrRevise,
+  canEditIssues,
   canPublish,
+  canReviewIssues,
   canRun,
+  dueState,
   evidenceSummary,
   formatPassRate,
   formatScore,
+  issueStatusLabel,
   policyStatusLabel,
+  priorityLabel,
+  recurrenceLabel,
   resultStatusLabel,
   ruleLabel,
-  severityLabel
+  severityLabel,
+  timelineActionLabel
 } from '../src/views/dataGovernance/development/deviceQualityModel.js'
 
 test('separates policy editing, publishing and execution permissions', () => {
@@ -23,6 +30,29 @@ test('separates policy editing, publishing and execution permissions', () => {
   assert.equal(canRun(['governance:read']), false)
 })
 
+test('separates quality issue editing and independent review permissions', () => {
+  assert.equal(canEditIssues(['quality-issues:edit']), true)
+  assert.equal(canEditIssues(['governance:read']), false)
+  assert.equal(canReviewIssues(['quality-issues:review']), true)
+  assert.equal(canReviewIssues(['quality-issues:edit']), false)
+})
+
+test('presents issue lifecycle, recurrence and overdue state', () => {
+  assert.equal(issueStatusLabel('pending_review'), '待复核')
+  assert.equal(priorityLabel('critical'), '紧急')
+  assert.equal(timelineActionLabel('reopened'), '重新打开')
+  assert.equal(recurrenceLabel(1), '首次发生')
+  assert.equal(recurrenceLabel(3), '第 3 次发生')
+  assert.equal(dueState({
+    status: 'in_progress',
+    due_at: '2026-07-29T09:00:00Z'
+  }, new Date('2026-07-29T10:00:00Z')), 'overdue')
+  assert.equal(dueState({
+    status: 'closed',
+    due_at: '2026-07-29T09:00:00Z'
+  }, new Date('2026-07-29T10:00:00Z')), 'normal')
+})
+
 test('presents governed policy and result labels', () => {
   assert.equal(policyStatusLabel('draft'), '草稿')
   assert.equal(policyStatusLabel('published'), '已发布')

+ 128 - 0
migrations/versions/20260729_340_quality_issue_remediation.py

@@ -0,0 +1,128 @@
+"""Add governed device-quality issue remediation lifecycle."""
+
+from alembic import op
+
+revision = "20260729_340"
+down_revision = "20260729_330"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.device_quality_issues (
+            uid UUID PRIMARY KEY,
+            issue_code VARCHAR(32) NOT NULL UNIQUE,
+            source_violation_uid UUID NOT NULL,
+            source_run_uid UUID NOT NULL
+                REFERENCES public.device_quality_runs(uid) ON DELETE RESTRICT,
+            rule_code VARCHAR(120) NOT NULL,
+            severity VARCHAR(20) NOT NULL
+                CHECK (severity IN ('info','warning','error','critical')),
+            priority VARCHAR(20) NOT NULL
+                CHECK (priority IN ('low','medium','high','critical')),
+            asset_uid UUID NOT NULL
+                REFERENCES public.device_assets(uid) ON DELETE RESTRICT,
+            field_name VARCHAR(300) NOT NULL,
+            source_uid UUID,
+            source_mapping_uid UUID,
+            message VARCHAR(1000) NOT NULL,
+            evidence JSONB NOT NULL,
+            recurrence_key CHAR(64) NOT NULL,
+            occurrence_number INTEGER NOT NULL CHECK (occurrence_number > 0),
+            status VARCHAR(30) NOT NULL DEFAULT 'open'
+                CHECK (
+                    status IN (
+                        'open','assigned','in_progress',
+                        'pending_review','closed'
+                    )
+                ),
+            assignee_uid UUID
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            due_at TIMESTAMPTZ,
+            current_version INTEGER NOT NULL DEFAULT 1
+                CHECK (current_version > 0),
+            created_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            updated_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            closed_at TIMESTAMPTZ
+        );
+        CREATE UNIQUE INDEX uq_device_quality_open_recurrence
+            ON public.device_quality_issues(recurrence_key)
+            WHERE status <> 'closed';
+        CREATE INDEX idx_device_quality_issues_worklist
+            ON public.device_quality_issues(
+                status, priority, due_at, created_at DESC
+            );
+        CREATE INDEX idx_device_quality_issues_assignee
+            ON public.device_quality_issues(
+                assignee_uid, status, due_at
+            );
+        CREATE INDEX idx_device_quality_issues_recurrence
+            ON public.device_quality_issues(
+                recurrence_key, occurrence_number DESC
+            );
+        CREATE INDEX idx_device_quality_issues_source
+            ON public.device_quality_issues(
+                source_run_uid, source_violation_uid
+            );
+
+        CREATE TABLE public.device_quality_issue_remediations (
+            uid UUID PRIMARY KEY,
+            issue_uid UUID NOT NULL
+                REFERENCES public.device_quality_issues(uid) ON DELETE CASCADE,
+            round_number INTEGER NOT NULL CHECK (round_number > 0),
+            summary VARCHAR(2000) NOT NULL,
+            evidence_refs JSONB NOT NULL,
+            submitted_by UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            submitted_at TIMESTAMPTZ NOT NULL,
+            review_status VARCHAR(20) NOT NULL DEFAULT 'pending'
+                CHECK (review_status IN ('pending','passed','failed')),
+            reviewed_by UUID
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            reviewed_at TIMESTAMPTZ,
+            review_note VARCHAR(2000),
+            verification_run_uid UUID
+                REFERENCES public.device_quality_runs(uid) ON DELETE RESTRICT,
+            UNIQUE (issue_uid, round_number)
+        );
+        CREATE INDEX idx_device_quality_issue_remediations
+            ON public.device_quality_issue_remediations(
+                issue_uid, round_number DESC
+            );
+
+        CREATE TABLE public.device_quality_issue_timeline (
+            uid UUID PRIMARY KEY,
+            issue_uid UUID NOT NULL
+                REFERENCES public.device_quality_issues(uid) ON DELETE CASCADE,
+            action VARCHAR(40) NOT NULL,
+            from_status VARCHAR(30),
+            to_status VARCHAR(30) NOT NULL
+                CHECK (
+                    to_status IN (
+                        'open','assigned','in_progress',
+                        'pending_review','closed'
+                    )
+                ),
+            actor_uid UUID NOT NULL
+                REFERENCES public.users(id) ON DELETE RESTRICT,
+            note VARCHAR(2000),
+            payload JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL
+        );
+        CREATE INDEX idx_device_quality_issue_timeline
+            ON public.device_quality_issue_timeline(
+                issue_uid, created_at, uid
+            );
+        """
+    )
+
+
+def downgrade() -> None:
+    # Remediation and review evidence is retained during application rollback.
+    pass

+ 292 - 0
tests/data_research/test_quality_issue_api.py

@@ -0,0 +1,292 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+ISSUE_UID = "01900000-0000-7000-8000-000000000901"
+VIOLATION_UID = "01900000-0000-7000-8000-000000000902"
+RUN_UID = "01900000-0000-7000-8000-000000000903"
+ASSET_UID = "01900000-0000-7000-8000-000000000904"
+ASSIGNEE_UID = "01900000-0000-7000-8000-000000000905"
+
+
+class FakeQualityIssueService:
+    def __init__(self):
+        from app.core.data_research.quality_issues import (
+            QualityIssueImportResult,
+            QualityIssueRecord,
+            QualityIssueRemediationRecord,
+            QualityIssueTimelineRecord,
+        )
+
+        now = datetime(2026, 7, 29, 10, tzinfo=UTC)
+        self.issue = QualityIssueRecord(
+            uid=ISSUE_UID,
+            issue_code="QI-000000000901",
+            source_violation_uid=VIOLATION_UID,
+            source_run_uid=RUN_UID,
+            rule_code="asset_context_complete",
+            severity="error",
+            priority="high",
+            asset_uid=ASSET_UID,
+            field_name="location",
+            source_uid=None,
+            source_mapping_uid=None,
+            message="设备位置缺失",
+            evidence={
+                "asset_uid": ASSET_UID,
+                "missing_fields": ["location"],
+            },
+            recurrence_key="a" * 64,
+            occurrence_number=2,
+            status="assigned",
+            assignee_uid=ASSIGNEE_UID,
+            due_at=now + timedelta(days=2),
+            current_version=2,
+            created_by="editor-1",
+            updated_by="editor-1",
+            created_at=now,
+            updated_at=now,
+        )
+        self.remediation = QualityIssueRemediationRecord(
+            uid="01900000-0000-7000-8000-000000000906",
+            issue_uid=ISSUE_UID,
+            round_number=1,
+            summary="已完成补录",
+            evidence_refs=(),
+            submitted_by="editor-1",
+            submitted_at=now,
+        )
+        self.event = QualityIssueTimelineRecord(
+            uid="01900000-0000-7000-8000-000000000907",
+            issue_uid=ISSUE_UID,
+            action="assigned",
+            from_status="open",
+            to_status="assigned",
+            actor_uid="editor-1",
+            note=None,
+            payload={"assignee_uid": ASSIGNEE_UID},
+            created_at=now,
+        )
+        self.import_result = QualityIssueImportResult(
+            records=(self.issue,),
+            created_count=1,
+            existing_count=0,
+        )
+        self.actions = []
+
+    def issues(self, **filters):
+        self.actions.append(("issues", filters))
+        return [self.issue], 1
+
+    def statistics(self):
+        self.actions.append(("statistics",))
+        return {
+            "total": 2,
+            "open": 1,
+            "closed": 1,
+            "overdue": 0,
+            "recurrent": 1,
+        }
+
+    def assignees(self):
+        self.actions.append(("assignees",))
+        return [
+            {
+                "uid": ASSIGNEE_UID,
+                "username": "assignee",
+                "display_name": "整改负责人",
+            }
+        ]
+
+    def import_violations(self, **payload):
+        self.actions.append(("import", payload))
+        return self.import_result
+
+    def get(self, issue_uid):
+        self.actions.append(("get", issue_uid))
+        return self.issue, self.remediation
+
+    def timeline(self, issue_uid):
+        self.actions.append(("timeline", issue_uid))
+        return [self.event]
+
+    def assign(self, issue_uid, **payload):
+        self.actions.append(("assign", issue_uid, payload))
+        return self.issue
+
+    def start(self, issue_uid, **payload):
+        self.actions.append(("start", issue_uid, payload))
+        return self.issue
+
+    def submit(self, issue_uid, **payload):
+        self.actions.append(("submit", issue_uid, payload))
+        return self.issue
+
+    def review(self, issue_uid, **payload):
+        self.actions.append(("review", issue_uid, payload))
+        return self.issue
+
+    def reopen(self, issue_uid, **payload):
+        self.actions.append(("reopen", issue_uid, payload))
+        return self.issue
+
+
+@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 = FakeQualityIssueService()
+
+    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_quality_issue_service",
+        lambda: service,
+        raising=False,
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def test_viewer_reads_issue_register_detail_timeline_and_statistics(client):
+    http, service = client
+    headers = {"Authorization": "Bearer viewer"}
+
+    listing = http.get(
+        "/api/development/v1/device-quality/issues"
+        "?status=assigned&overdue_only=true&page=1&page_size=20",
+        headers=headers,
+    )
+    detail = http.get(
+        f"/api/development/v1/device-quality/issues/{ISSUE_UID}",
+        headers=headers,
+    )
+    timeline = http.get(
+        f"/api/development/v1/device-quality/issues/{ISSUE_UID}/timeline",
+        headers=headers,
+    )
+    statistics = http.get(
+        "/api/development/v1/device-quality/issues/statistics",
+        headers=headers,
+    )
+    assignees = http.get(
+        "/api/development/v1/device-quality/issues/assignees",
+        headers=headers,
+    )
+    denied = http.post(
+        "/api/development/v1/device-quality/issues/import",
+        headers=headers,
+        json={"violation_uids": [VIOLATION_UID], "priority": "high"},
+    )
+
+    assert listing.status_code == 200
+    assert listing.get_json()["data"]["records"][0]["occurrence_number"] == 2
+    assert detail.get_json()["data"]["latest_remediation"]["round_number"] == 1
+    assert timeline.get_json()["data"]["records"][0]["action"] == "assigned"
+    assert statistics.get_json()["data"]["recurrent"] == 1
+    assert assignees.get_json()["data"]["records"][0]["display_name"] == (
+        "整改负责人"
+    )
+    assert denied.status_code == 403
+    assert service.actions[0][0] == "issues"
+
+
+def test_editor_imports_assigns_starts_submits_and_reopens_but_not_reviews(
+    client,
+):
+    http, service = client
+    headers = {"Authorization": "Bearer editor"}
+    base = f"/api/development/v1/device-quality/issues/{ISSUE_UID}"
+
+    imported = http.post(
+        "/api/development/v1/device-quality/issues/import",
+        headers=headers,
+        json={
+            "violation_uids": [VIOLATION_UID],
+            "priority": "high",
+            "due_at": "2026-08-01T12:00:00+08:00",
+        },
+    )
+    assigned = http.post(
+        f"{base}/assign",
+        headers=headers,
+        json={
+            "assignee_uid": ASSIGNEE_UID,
+            "due_at": None,
+            "expected_version": 1,
+        },
+    )
+    started = http.post(
+        f"{base}/start",
+        headers=headers,
+        json={"expected_version": 2},
+    )
+    submitted = http.post(
+        f"{base}/submit",
+        headers=headers,
+        json={
+            "summary": "已补录",
+            "evidence_refs": [],
+            "expected_version": 3,
+        },
+    )
+    reopened = http.post(
+        f"{base}/reopen",
+        headers=headers,
+        json={
+            "reason": "现场复发",
+            "due_at": None,
+            "expected_version": 5,
+        },
+    )
+    denied = http.post(
+        f"{base}/review",
+        headers=headers,
+        json={
+            "verification_result": "passed",
+            "note": "复验通过",
+            "expected_version": 4,
+        },
+    )
+
+    assert imported.status_code == 201
+    assert assigned.status_code == 200
+    assert started.status_code == 200
+    assert submitted.status_code == 200
+    assert reopened.status_code == 200
+    assert denied.status_code == 403
+    assert (service.actions[0][0],) == ("import",)
+
+
+def test_admin_reviews_with_verification_evidence(client):
+    http, service = client
+    response = http.post(
+        f"/api/development/v1/device-quality/issues/{ISSUE_UID}/review",
+        headers={"Authorization": "Bearer admin"},
+        json={
+            "verification_result": "passed",
+            "note": "人工核对与复验通过",
+            "verification_run_uid": RUN_UID,
+            "expected_version": 4,
+        },
+    )
+
+    assert response.status_code == 200
+    assert service.actions[-1][0] == "review"
+    assert service.actions[-1][2]["verification_run_uid"] == RUN_UID
+    assert service.actions[-1][2]["actor_uid"] == "admin-1"

+ 48 - 0
tests/data_research/test_quality_issue_frontend_contract.py

@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_quality_issue_workbench_exposes_full_governed_lifecycle():
+    api = (
+        ROOT / "frontend/src/api/dataDevelopment.js"
+    ).read_text(encoding="utf-8")
+    view = (
+        ROOT
+        / "frontend/src/views/dataGovernance/development/deviceQuality.vue"
+    ).read_text(encoding="utf-8")
+    model = (
+        ROOT
+        / "frontend/src/views/dataGovernance/development/deviceQualityModel.js"
+    ).read_text(encoding="utf-8")
+
+    for operation in (
+        "getQualityIssues",
+        "getQualityIssueStatistics",
+        "getQualityIssueAssignees",
+        "getQualityIssueTimeline",
+        "importQualityIssues",
+        "assignQualityIssue",
+        "startQualityIssue",
+        "submitQualityIssue",
+        "reviewQualityIssue",
+        "reopenQualityIssue",
+    ):
+        assert operation in api
+        assert operation in view
+
+    assert "质量问题整改闭环" in view
+    assert "创建整改问题" in view
+    assert "分派整改负责人" in view
+    assert "提交整改复核" in view
+    assert "复核整改结果" in view
+    assert "重新打开质量问题" in view
+    assert "处理时间线" in view
+    assert "逾期" in view
+    assert "复发组" in view
+    assert "quality-issues:edit" in model
+    assert "quality-issues:review" in model
+    assert "pending_review" in model
+    assert "reopened" in model

+ 531 - 0
tests/data_research/test_quality_issues.py

@@ -0,0 +1,531 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+ACTOR_UID = "00000000-0000-7000-8000-000000000901"
+ASSIGNEE_UID = "00000000-0000-7000-8000-000000000902"
+REVIEWER_UID = "00000000-0000-7000-8000-000000000903"
+ADMIN_UID = "00000000-0000-7000-8000-000000000904"
+RUN_UID = "00000000-0000-7000-8000-000000000905"
+VERIFY_RUN_UID = "00000000-0000-7000-8000-000000000906"
+VIOLATION_UID = "00000000-0000-7000-8000-000000000907"
+ASSET_UID = "00000000-0000-7000-8000-000000000908"
+
+
+class MemoryQualityIssueRepository:
+    def __init__(self, violation):
+        self.violations = {violation.uid: violation}
+        self.runs = {violation.run_uid, VERIFY_RUN_UID}
+        self.active_users = {ACTOR_UID, ASSIGNEE_UID, REVIEWER_UID, ADMIN_UID}
+        self.issues = {}
+        self.remediations = {}
+        self.timeline = {}
+
+    def get_violations(self, violation_uids):
+        return [
+            self.violations[uid]
+            for uid in violation_uids
+            if uid in self.violations
+        ]
+
+    def find_open_by_recurrence_key(self, recurrence_key):
+        return next(
+            (
+                item
+                for item in self.issues.values()
+                if item.recurrence_key == recurrence_key
+                and item.status != "closed"
+            ),
+            None,
+        )
+
+    def recurrence_count(self, recurrence_key):
+        return sum(
+            item.recurrence_key == recurrence_key
+            for item in self.issues.values()
+        )
+
+    def create_issue(self, issue, event):
+        self.issues[issue.uid] = issue
+        self.timeline[issue.uid] = [event]
+        return issue
+
+    def get_issue(self, issue_uid, *, for_update=False):
+        del for_update
+        return self.issues.get(issue_uid)
+
+    def active_user_exists(self, user_uid):
+        return user_uid in self.active_users
+
+    def run_exists(self, run_uid):
+        return run_uid in self.runs
+
+    def transition(
+        self,
+        issue,
+        *,
+        expected_version,
+        event,
+        remediation=None,
+        remediation_review=None,
+    ):
+        current = self.issues[issue.uid]
+        if current.current_version != expected_version:
+            from app.core.data_research.errors import QualityIssueConflict
+
+            raise QualityIssueConflict("quality issue version is stale")
+        self.issues[issue.uid] = issue
+        self.timeline[issue.uid].append(event)
+        if remediation is not None:
+            self.remediations[issue.uid] = remediation
+        if remediation_review is not None:
+            current_round = self.remediations[issue.uid]
+            self.remediations[issue.uid] = replace(
+                current_round,
+                **remediation_review,
+            )
+        return issue
+
+    def latest_remediation(self, issue_uid):
+        return self.remediations.get(issue_uid)
+
+    def list_issues(self, **filters):
+        records = list(reversed(tuple(self.issues.values())))
+        status = filters.get("status")
+        assignee_uid = filters.get("assignee_uid")
+        overdue_only = filters.get("overdue_only")
+        now = filters.get("now")
+        if status:
+            records = [item for item in records if item.status == status]
+        if assignee_uid:
+            records = [
+                item for item in records
+                if item.assignee_uid == assignee_uid
+            ]
+        if overdue_only:
+            records = [
+                item for item in records
+                if item.status != "closed"
+                and item.due_at is not None
+                and item.due_at < now
+            ]
+        page = filters["page"]
+        page_size = filters["page_size"]
+        start = (page - 1) * page_size
+        return records[start : start + page_size], len(records)
+
+    def list_timeline(self, issue_uid):
+        return tuple(self.timeline.get(issue_uid, ()))
+
+    def statistics(self, *, now):
+        records = list(self.issues.values())
+        recurrence_groups = {}
+        for item in records:
+            recurrence_groups[item.recurrence_key] = (
+                recurrence_groups.get(item.recurrence_key, 0) + 1
+            )
+        return {
+            "total": len(records),
+            "open": sum(item.status != "closed" for item in records),
+            "closed": sum(item.status == "closed" for item in records),
+            "overdue": sum(
+                item.status != "closed"
+                and item.due_at is not None
+                and item.due_at < now
+                for item in records
+            ),
+            "recurrent": sum(value > 1 for value in recurrence_groups.values()),
+            "recurrent_issues": sum(
+                item.occurrence_number > 1 for item in records
+            ),
+            "recurrence_rate": (
+                round(
+                    sum(item.occurrence_number > 1 for item in records)
+                    / len(records),
+                    6,
+                )
+                if records else 0.0
+            ),
+        }
+
+
+@pytest.fixture()
+def violation():
+    from app.core.data_research.device_quality import (
+        DeviceQualityViolationRecord,
+    )
+
+    now = datetime(2026, 7, 29, 9, tzinfo=UTC)
+    return DeviceQualityViolationRecord(
+        uid=VIOLATION_UID,
+        run_uid=RUN_UID,
+        rule_code="asset_context_complete",
+        severity="error",
+        asset_uid=ASSET_UID,
+        field_name="location,organization",
+        source_uid=None,
+        source_mapping_uid=None,
+        message="设备台账位置、组织或责任人不完整",
+        evidence={
+            "asset_uid": ASSET_UID,
+            "missing_fields": ["location", "organization"],
+        },
+        created_at=now,
+        expires_at=now + timedelta(days=30),
+    )
+
+
+@pytest.fixture()
+def clock():
+    return datetime(2026, 7, 29, 10, tzinfo=UTC)
+
+
+def make_service(repository, clock):
+    from app.core.data_research.quality_issues import QualityIssueService
+
+    identifiers = iter(
+        f"00000000-0000-7000-8000-000000000{value}"
+        for value in range(920, 999)
+    )
+
+    def authorize_review(actor_uid):
+        from app.core.data_research.errors import QualityIssueForbidden
+
+        if actor_uid != REVIEWER_UID:
+            raise QualityIssueForbidden("accountable reviewer required")
+
+    return QualityIssueService(
+        repository,
+        review_authorizer=authorize_review,
+        is_admin=lambda uid: uid == ADMIN_UID,
+        uid_factory=identifiers.__next__,
+        now_factory=lambda: clock,
+    )
+
+
+def create_issue(service, *, due_at=None):
+    result = service.import_violations(
+        violation_uids=[VIOLATION_UID],
+        priority="high",
+        due_at=due_at,
+        actor_uid=ACTOR_UID,
+    )
+    return result.records[0]
+
+
+def assign_start_submit(service, issue):
+    assigned = service.assign(
+        issue.uid,
+        assignee_uid=ASSIGNEE_UID,
+        due_at=issue.due_at,
+        expected_version=issue.current_version,
+        actor_uid=ACTOR_UID,
+    )
+    started = service.start(
+        issue.uid,
+        expected_version=assigned.current_version,
+        actor_uid=ASSIGNEE_UID,
+    )
+    submitted = service.submit(
+        issue.uid,
+        summary="已补充设备位置和所属组织并完成来源核对",
+        evidence_refs=[
+            {
+                "type": "device_asset_version",
+                "uid": ASSET_UID,
+                "version": 2,
+            }
+        ],
+        expected_version=started.current_version,
+        actor_uid=ASSIGNEE_UID,
+    )
+    return submitted
+
+
+def test_import_snapshots_violation_and_deduplicates_unresolved_identity(
+    violation,
+    clock,
+):
+    repository = MemoryQualityIssueRepository(violation)
+    service = make_service(repository, clock)
+
+    first = service.import_violations(
+        violation_uids=[VIOLATION_UID],
+        priority="high",
+        due_at=clock + timedelta(days=3),
+        actor_uid=ACTOR_UID,
+    )
+    second = service.import_violations(
+        violation_uids=[VIOLATION_UID],
+        priority="critical",
+        due_at=clock + timedelta(days=1),
+        actor_uid=ACTOR_UID,
+    )
+
+    assert first.created_count == 1
+    assert second.created_count == 0
+    assert second.existing_count == 1
+    issue = first.records[0]
+    assert issue.source_violation_uid == VIOLATION_UID
+    assert issue.status == "open"
+    assert issue.priority == "high"
+    assert issue.occurrence_number == 1
+    assert issue.evidence["missing_fields"] == ["location", "organization"]
+    assert repository.timeline[issue.uid][0].action == "created"
+
+
+def test_import_is_bounded_and_requires_every_violation(violation, clock):
+    from app.core.data_research.errors import QualityIssueInvalid
+
+    service = make_service(MemoryQualityIssueRepository(violation), clock)
+
+    with pytest.raises(QualityIssueInvalid, match="between 1 and 100"):
+        service.import_violations(
+            violation_uids=[],
+            priority="high",
+            due_at=None,
+            actor_uid=ACTOR_UID,
+        )
+    with pytest.raises(QualityIssueInvalid, match="not found"):
+        service.import_violations(
+            violation_uids=[
+                "00000000-0000-7000-8000-000000000999"
+            ],
+            priority="high",
+            due_at=None,
+            actor_uid=ACTOR_UID,
+        )
+
+
+def test_assignment_rejects_disabled_user_and_stale_version(violation, clock):
+    from app.core.data_research.errors import (
+        QualityIssueConflict,
+        QualityIssueInvalid,
+    )
+
+    repository = MemoryQualityIssueRepository(violation)
+    service = make_service(repository, clock)
+    issue = create_issue(service)
+
+    with pytest.raises(QualityIssueInvalid, match="active user"):
+        service.assign(
+            issue.uid,
+            assignee_uid="00000000-0000-7000-8000-000000000999",
+            due_at=None,
+            expected_version=1,
+            actor_uid=ACTOR_UID,
+        )
+    assigned = service.assign(
+        issue.uid,
+        assignee_uid=ASSIGNEE_UID,
+        due_at=clock + timedelta(days=2),
+        expected_version=1,
+        actor_uid=ACTOR_UID,
+    )
+    assert assigned.status == "assigned"
+    assert assigned.assignee_uid == ASSIGNEE_UID
+    assert assigned.current_version == 2
+    with pytest.raises(QualityIssueConflict, match="stale"):
+        service.start(
+            issue.uid,
+            expected_version=1,
+            actor_uid=ASSIGNEE_UID,
+        )
+
+
+def test_only_assignee_or_admin_can_work_and_submission_is_bounded(
+    violation,
+    clock,
+):
+    from app.core.data_research.errors import (
+        QualityIssueForbidden,
+        QualityIssueInvalid,
+    )
+
+    repository = MemoryQualityIssueRepository(violation)
+    service = make_service(repository, clock)
+    issue = create_issue(service)
+    service.assign(
+        issue.uid,
+        assignee_uid=ASSIGNEE_UID,
+        due_at=None,
+        expected_version=1,
+        actor_uid=ACTOR_UID,
+    )
+
+    with pytest.raises(QualityIssueForbidden, match="assignee"):
+        service.start(
+            issue.uid,
+            expected_version=2,
+            actor_uid=ACTOR_UID,
+        )
+    started = service.start(
+        issue.uid,
+        expected_version=2,
+        actor_uid=ADMIN_UID,
+    )
+    with pytest.raises(QualityIssueInvalid, match="summary"):
+        service.submit(
+            issue.uid,
+            summary="",
+            evidence_refs=[],
+            expected_version=started.current_version,
+            actor_uid=ASSIGNEE_UID,
+        )
+    submitted = service.submit(
+        issue.uid,
+        summary="已完成字段补录",
+        evidence_refs=[{"type": "note", "uid": "evidence-1"}],
+        expected_version=started.current_version,
+        actor_uid=ASSIGNEE_UID,
+    )
+    assert submitted.status == "pending_review"
+    assert repository.remediations[issue.uid].review_status == "pending"
+
+
+def test_independent_accountable_review_closes_or_rejects(violation, clock):
+    from app.core.data_research.errors import QualityIssueForbidden
+
+    repository = MemoryQualityIssueRepository(violation)
+    service = make_service(repository, clock)
+    issue = create_issue(service)
+    submitted = assign_start_submit(service, issue)
+
+    with pytest.raises(QualityIssueForbidden, match="own remediation"):
+        service.review(
+            issue.uid,
+            verification_result="passed",
+            note="复验通过",
+            verification_run_uid=VERIFY_RUN_UID,
+            expected_version=submitted.current_version,
+            actor_uid=ASSIGNEE_UID,
+        )
+    with pytest.raises(QualityIssueForbidden, match="accountable"):
+        service.review(
+            issue.uid,
+            verification_result="passed",
+            note="复验通过",
+            verification_run_uid=VERIFY_RUN_UID,
+            expected_version=submitted.current_version,
+            actor_uid=ACTOR_UID,
+        )
+
+    rejected = service.review(
+        issue.uid,
+        verification_result="failed",
+        note="复验仍缺少组织字段",
+        verification_run_uid=VERIFY_RUN_UID,
+        expected_version=submitted.current_version,
+        actor_uid=REVIEWER_UID,
+    )
+    assert rejected.status == "in_progress"
+    resubmitted = service.submit(
+        issue.uid,
+        summary="已再次补充所属组织",
+        evidence_refs=[],
+        expected_version=rejected.current_version,
+        actor_uid=ASSIGNEE_UID,
+    )
+    closed = service.review(
+        issue.uid,
+        verification_result="passed",
+        note="人工核对和复验均通过",
+        verification_run_uid=VERIFY_RUN_UID,
+        expected_version=resubmitted.current_version,
+        actor_uid=REVIEWER_UID,
+    )
+    assert closed.status == "closed"
+    assert closed.closed_at == clock
+    assert repository.remediations[issue.uid].review_status == "passed"
+
+
+def test_close_reopen_and_later_import_create_a_recurrence(violation, clock):
+    repository = MemoryQualityIssueRepository(violation)
+    service = make_service(repository, clock)
+    issue = create_issue(service, due_at=clock - timedelta(hours=1))
+    submitted = assign_start_submit(service, issue)
+    closed = service.review(
+        issue.uid,
+        verification_result="passed",
+        note="复验通过",
+        verification_run_uid=None,
+        expected_version=submitted.current_version,
+        actor_uid=REVIEWER_UID,
+    )
+    reopened = service.reopen(
+        issue.uid,
+        reason="现场抽查发现字段再次缺失",
+        due_at=clock + timedelta(days=1),
+        expected_version=closed.current_version,
+        actor_uid=ACTOR_UID,
+    )
+    assert reopened.status == "assigned"
+    assert reopened.closed_at is None
+    assert repository.timeline[issue.uid][-1].action == "reopened"
+
+    submitted_again = service.submit(
+        issue.uid,
+        summary="重新补录",
+        evidence_refs=[],
+        expected_version=reopened.current_version,
+        actor_uid=ASSIGNEE_UID,
+    )
+    closed_again = service.review(
+        issue.uid,
+        verification_result="passed",
+        note="复验通过",
+        verification_run_uid=None,
+        expected_version=submitted_again.current_version,
+        actor_uid=REVIEWER_UID,
+    )
+    assert closed_again.status == "closed"
+
+    new_violation = replace(
+        violation,
+        uid="00000000-0000-7000-8000-000000000909",
+        run_uid=VERIFY_RUN_UID,
+    )
+    repository.violations[new_violation.uid] = new_violation
+    recurrence = service.import_violations(
+        violation_uids=[new_violation.uid],
+        priority="high",
+        due_at=None,
+        actor_uid=ACTOR_UID,
+    ).records[0]
+    assert recurrence.uid != issue.uid
+    assert recurrence.occurrence_number == 2
+
+
+def test_list_statistics_and_timeline_expose_overdue_and_recurrence(
+    violation,
+    clock,
+):
+    repository = MemoryQualityIssueRepository(violation)
+    service = make_service(repository, clock)
+    issue = create_issue(service, due_at=clock - timedelta(minutes=1))
+
+    records, total = service.issues(
+        status=None,
+        assignee_uid=None,
+        overdue_only=True,
+        page=1,
+        page_size=20,
+    )
+    statistics = service.statistics()
+    timeline = service.timeline(issue.uid)
+
+    assert total == 1
+    assert records[0].is_overdue(clock)
+    assert statistics == {
+        "total": 1,
+        "open": 1,
+        "closed": 0,
+        "overdue": 1,
+        "recurrent": 0,
+        "recurrent_issues": 0,
+        "recurrence_rate": 0.0,
+    }
+    assert [item.action for item in timeline] == ["created"]

+ 296 - 0
tests/integration/test_quality_issue_postgres.py

@@ -0,0 +1,296 @@
+from __future__ import annotations
+
+import os
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy import text
+
+pytestmark = pytest.mark.integration
+
+
+def test_quality_issue_postgres_persists_lifecycle_and_recurrence(
+    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.quality_issue_repository import (
+        SqlAlchemyQualityIssueRepository,
+    )
+    from app.core.data_research.quality_issues import QualityIssueService
+    from app.models.data_research import (
+        DeviceAsset,
+        DeviceQualityIssue,
+        DeviceQualityProfile,
+        DeviceQualityProfileVersion,
+        DeviceQualityRun,
+        DeviceQualityViolationSample,
+        IngestionSource,
+    )
+
+    app = create_app()
+    app.config.update(TESTING=True)
+    suffix = uuid.uuid4().hex[:10]
+    actor_uid = str(uuid.uuid4())
+    assignee_uid = str(uuid.uuid4())
+    reviewer_uid = str(uuid.uuid4())
+    source_uid = str(uuid.uuid4())
+    asset_uid = str(uuid.uuid4())
+    profile_uid = str(uuid.uuid4())
+    version_uid = str(uuid.uuid4())
+    run_uid = str(uuid.uuid4())
+    verify_run_uid = str(uuid.uuid4())
+    violation_uid = str(uuid.uuid4())
+    recurrence_violation_uid = str(uuid.uuid4())
+    issue_uids = []
+    now = datetime.now(UTC)
+
+    try:
+        with app.app_context():
+            for user_uid, username in (
+                (actor_uid, f"wp08-actor-{suffix}"),
+                (assignee_uid, f"wp08-assignee-{suffix}"),
+                (reviewer_uid, f"wp08-reviewer-{suffix}"),
+            ):
+                db.session.execute(
+                    text(
+                        """
+                        INSERT INTO public.users (
+                            id, username, display_name, password_hash, status
+                        ) VALUES (
+                            CAST(:id AS uuid), :username, :username,
+                            'integration-test', 'active'
+                        )
+                        """
+                    ),
+                    {"id": user_uid, "username": username},
+                )
+            db.session.add(
+                IngestionSource(
+                    uid=source_uid,
+                    source_type="database",
+                    name=f"WP08 source {suffix}",
+                    config={"database_type": "postgresql"},
+                    permission_scope={},
+                    status="active",
+                    created_by=actor_uid,
+                )
+            )
+            db.session.add(
+                DeviceAsset(
+                    uid=asset_uid,
+                    asset_type="device",
+                    name=f"WP08 device {suffix}",
+                    status="active",
+                    current_version=1,
+                    content_hash="b" * 64,
+                    attributes={},
+                    created_by=actor_uid,
+                    updated_by=actor_uid,
+                )
+            )
+            db.session.add(
+                DeviceQualityProfile(
+                    uid=profile_uid,
+                    code=f"WP08_{suffix}",
+                    name="WP08 integration profile",
+                    created_by=actor_uid,
+                )
+            )
+            db.session.flush()
+            db.session.add(
+                DeviceQualityProfileVersion(
+                    uid=version_uid,
+                    profile_uid=profile_uid,
+                    version=1,
+                    status="draft",
+                    rules=[],
+                    content_hash="c" * 64,
+                    created_by=actor_uid,
+                )
+            )
+            db.session.flush()
+            for current_run_uid in (run_uid, verify_run_uid):
+                db.session.add(
+                    DeviceQualityRun(
+                        uid=current_run_uid,
+                        policy_version_uid=version_uid,
+                        policy_hash="c" * 64,
+                        source_uid=source_uid,
+                        status="success",
+                        total_assets=1,
+                        total_violations=1,
+                        score=80,
+                        created_by=actor_uid,
+                    )
+                )
+            db.session.flush()
+            for current_uid, current_run_uid in (
+                (violation_uid, run_uid),
+                (recurrence_violation_uid, verify_run_uid),
+            ):
+                db.session.add(
+                    DeviceQualityViolationSample(
+                        uid=current_uid,
+                        run_uid=current_run_uid,
+                        rule_code="asset_context_complete",
+                        severity="error",
+                        asset_uid=asset_uid,
+                        field_name="location",
+                        source_uid=source_uid,
+                        message="设备位置缺失",
+                        evidence={
+                            "asset_uid": asset_uid,
+                            "missing_fields": ["location"],
+                        },
+                        created_at=now,
+                        expires_at=now + timedelta(days=30),
+                    )
+                )
+            db.session.commit()
+
+            repository = SqlAlchemyQualityIssueRepository(db.session)
+            service = QualityIssueService(
+                repository,
+                review_authorizer=lambda actor: (
+                    None
+                    if actor == reviewer_uid
+                    else pytest.fail("unexpected reviewer")
+                ),
+                commit=db.session.commit,
+                rollback=db.session.rollback,
+            )
+            baseline_statistics = service.statistics()
+            empty_records, empty_total = service.issues(
+                status="closed",
+                assignee_uid=assignee_uid,
+                overdue_only=False,
+                page=1,
+                page_size=20,
+            )
+            assert empty_records == []
+            assert empty_total == 0
+            imported = service.import_violations(
+                violation_uids=[violation_uid],
+                priority="high",
+                due_at=now - timedelta(minutes=1),
+                actor_uid=actor_uid,
+            )
+            issue = imported.records[0]
+            issue_uids.append(issue.uid)
+            deduplicated = service.import_violations(
+                violation_uids=[violation_uid],
+                priority="critical",
+                due_at=None,
+                actor_uid=actor_uid,
+            )
+            assert deduplicated.existing_count == 1
+
+            assigned = service.assign(
+                issue.uid,
+                assignee_uid=assignee_uid,
+                due_at=issue.due_at,
+                expected_version=1,
+                actor_uid=actor_uid,
+            )
+            started = service.start(
+                issue.uid,
+                expected_version=assigned.current_version,
+                actor_uid=assignee_uid,
+            )
+            submitted = service.submit(
+                issue.uid,
+                summary="设备位置已补录并复核来源",
+                evidence_refs=[
+                    {
+                        "type": "device_asset",
+                        "uid": asset_uid,
+                        "version": 2,
+                    }
+                ],
+                expected_version=started.current_version,
+                actor_uid=assignee_uid,
+            )
+            closed = service.review(
+                issue.uid,
+                verification_result="passed",
+                note="复核通过",
+                verification_run_uid=verify_run_uid,
+                expected_version=submitted.current_version,
+                actor_uid=reviewer_uid,
+            )
+            assert closed.status == "closed"
+
+            recurrence = service.import_violations(
+                violation_uids=[recurrence_violation_uid],
+                priority="high",
+                due_at=None,
+                actor_uid=actor_uid,
+            ).records[0]
+            issue_uids.append(recurrence.uid)
+            assert recurrence.occurrence_number == 2
+            statistics = service.statistics()
+            assert statistics["total"] == baseline_statistics["total"] + 2
+            assert statistics["recurrent"] == baseline_statistics["recurrent"] + 1
+            assert (
+                statistics["recurrent_issues"]
+                == baseline_statistics["recurrent_issues"] + 1
+            )
+            assert statistics["recurrence_rate"] == round(
+                statistics["recurrent_issues"] / statistics["total"],
+                6,
+            )
+            assert [item.action for item in service.timeline(issue.uid)] == [
+                "created",
+                "assigned",
+                "started",
+                "submitted",
+                "closed",
+            ]
+            loaded, remediation = service.get(issue.uid)
+            assert loaded.evidence["missing_fields"] == ["location"]
+            assert remediation.review_status == "passed"
+            assert remediation.verification_run_uid == verify_run_uid
+
+            persisted = db.session.get(DeviceQualityIssue, issue.uid)
+            assert persisted.current_version == 5
+            assert persisted.source_violation_uid == violation_uid
+    finally:
+        with app.app_context():
+            if issue_uids:
+                db.session.query(DeviceQualityIssue).filter(
+                    DeviceQualityIssue.uid.in_(issue_uids)
+                ).delete(synchronize_session=False)
+            db.session.query(DeviceQualityViolationSample).filter(
+                DeviceQualityViolationSample.uid.in_(
+                    [violation_uid, recurrence_violation_uid]
+                )
+            ).delete(synchronize_session=False)
+            db.session.query(DeviceQualityRun).filter(
+                DeviceQualityRun.uid.in_([run_uid, verify_run_uid])
+            ).delete(synchronize_session=False)
+            db.session.query(DeviceQualityProfileVersion).filter_by(
+                uid=version_uid
+            ).delete(synchronize_session=False)
+            db.session.query(DeviceQualityProfile).filter_by(
+                uid=profile_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.execute(
+                text(
+                    "DELETE FROM public.users "
+                    "WHERE id = ANY(CAST(:ids AS uuid[]))"
+                ),
+                {"ids": [actor_uid, assignee_uid, reviewer_uid]},
+            )
+            db.session.commit()

+ 13 - 0
tests/test_permission_matrix.py

@@ -30,6 +30,9 @@ def test_fixed_role_permission_matrix_is_monotonic():
     assert "device-quality:execute" in editor
     assert "device-quality:publish" not in editor
     assert "device-quality:publish" in admin
+    assert "quality-issues:edit" in editor
+    assert "quality-issues:review" not in editor
+    assert "quality-issues:review" in admin
 
 
 def test_data_development_paths_have_specific_write_policies():
@@ -112,6 +115,16 @@ def test_data_development_paths_have_specific_write_policies():
     assert permission_for_request(
         "/api/development/v1/device-quality/runs", "POST"
     ) == ("device-quality:execute",)
+    assert permission_for_request(
+        "/api/development/v1/device-quality/issues", "GET"
+    ) == ("governance:read",)
+    assert permission_for_request(
+        "/api/development/v1/device-quality/issues/import", "POST"
+    ) == ("quality-issues:edit",)
+    assert permission_for_request(
+        "/api/development/v1/device-quality/issues/issue-1/review",
+        "POST",
+    ) == ("quality-issues:review",)
 
 
 def test_business_domain_read_endpoints_are_available_to_viewers():