From 96452934a3ba12408808a9a9652a069b0facb574 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 27 Jul 2026 18:57:51 +0800 Subject: [PATCH] feat: consolidate ticket domain baseline --- backend/apps/accounts/permissions.py | 49 +- .../tests/test_permission_enforcement.py | 8 +- backend/apps/audit/api.py | 49 +- backend/apps/audit/tests/test_audit_api.py | 24 + backend/apps/solutions/api.py | 90 +-- ..._ticketresolution_agent_run_id_and_more.py | 23 + backend/apps/solutions/models.py | 6 + backend/apps/solutions/schemas.py | 12 +- .../apps/solutions/tests/test_solution_api.py | 32 +- backend/apps/tickets/api.py | 765 ++++++++++++++++-- ...llaborator_ticketfieldevidence_and_more.py | 142 ++++ backend/apps/tickets/models.py | 467 ++++++++++- backend/apps/tickets/schemas.py | 317 +++++++- backend/apps/tickets/services.py | 731 ++++++++++++++++- backend/apps/tickets/tests/test_ticket_api.py | 105 ++- .../tests/test_ticket_domain_baseline.py | 109 +++ .../tickets/TicketCollaborationPanel.test.tsx | 105 +++ .../tickets/TicketCollaborationPanel.tsx | 234 ++++++ .../features/tickets/TicketComments.test.tsx | 74 ++ .../src/features/tickets/TicketComments.tsx | 221 +++-- .../features/tickets/TicketEditSheet.test.tsx | 98 +++ .../src/features/tickets/TicketEditSheet.tsx | 400 +++++++++ .../tickets/TicketTransitionDialog.tsx | 2 - frontend/src/features/tickets/api.ts | 124 ++- frontend/src/features/tickets/types.ts | 267 +++++- 25 files changed, 4236 insertions(+), 218 deletions(-) create mode 100644 backend/apps/solutions/migrations/0002_ticketresolution_agent_run_id_and_more.py create mode 100644 backend/apps/tickets/migrations/0005_ticketcollaborator_ticketfieldevidence_and_more.py create mode 100644 backend/apps/tickets/tests/test_ticket_domain_baseline.py create mode 100644 frontend/src/features/tickets/TicketCollaborationPanel.test.tsx create mode 100644 frontend/src/features/tickets/TicketCollaborationPanel.tsx create mode 100644 frontend/src/features/tickets/TicketComments.test.tsx create mode 100644 frontend/src/features/tickets/TicketEditSheet.test.tsx create mode 100644 frontend/src/features/tickets/TicketEditSheet.tsx diff --git a/backend/apps/accounts/permissions.py b/backend/apps/accounts/permissions.py index b23326a..27bca58 100644 --- a/backend/apps/accounts/permissions.py +++ b/backend/apps/accounts/permissions.py @@ -15,17 +15,30 @@ PERMISSIONS: tuple[PermissionSpec, ...] = ( PermissionSpec("ticket.edit", "编辑工单", "工单"), PermissionSpec("ticket.export", "导出工单", "工单"), PermissionSpec("ticket.assign", "分派工单", "工单"), + PermissionSpec("ticket.resolve", "解决工单", "工单"), + PermissionSpec("ticket.reply", "确认客户回复", "工单"), PermissionSpec("ticket.close", "关闭工单", "工单"), + PermissionSpec("ticket.archive", "归档工单", "工单"), PermissionSpec("solution.view", "查看解决方案", "解决方案"), PermissionSpec("solution.manage", "管理解决方案", "解决方案"), + PermissionSpec("solution.submit", "提交解决方案审核", "解决方案"), + PermissionSpec("solution.review", "审核与发布解决方案", "解决方案"), PermissionSpec("knowledge.view", "查看知识库", "知识库"), PermissionSpec("knowledge.manage", "管理知识库", "知识库"), + PermissionSpec("knowledge.submit", "提交知识审核", "知识库"), + PermissionSpec("knowledge.review", "审核与发布知识", "知识库"), PermissionSpec("agent.use", "使用 Agent", "AI 平台"), PermissionSpec("agent.manage", "管理 Agent", "AI 平台"), PermissionSpec("agent.run.view", "查看 Agent 运行", "AI 平台"), PermissionSpec("model.manage", "管理模型配置", "AI 平台"), PermissionSpec("skill.manage", "管理 Skill", "AI 平台"), + PermissionSpec("tool.view", "查看工具目录", "AI 平台"), + PermissionSpec("tool.invoke", "调用已授权工具", "AI 平台"), + PermissionSpec("tool.confirm", "确认高风险工具调用", "AI 平台"), PermissionSpec("tool.manage", "管理 MCP 工具", "AI 平台"), + PermissionSpec("channel.view", "查看渠道消息", "渠道"), + PermissionSpec("channel.send", "发送渠道消息", "渠道"), + PermissionSpec("channel.manage", "管理渠道适配器", "渠道"), PermissionSpec("system.user.view", "查看用户", "系统治理"), PermissionSpec("system.user.manage", "管理用户", "系统治理"), PermissionSpec("system.user_group.view", "查看用户组", "系统治理"), @@ -44,9 +57,15 @@ DEFAULT_GROUPS: dict[str, tuple[str, ...]] = { "ticket.view", "knowledge.view", "knowledge.manage", + "knowledge.submit", + "knowledge.review", "solution.view", "solution.manage", + "solution.submit", + "solution.review", "agent.use", + "tool.view", + "channel.view", ), "技术支持": ( "ticket.view", @@ -54,17 +73,45 @@ DEFAULT_GROUPS: dict[str, tuple[str, ...]] = { "ticket.ai_draft", "ticket.edit", "ticket.assign", + "ticket.resolve", + "ticket.reply", "ticket.close", "knowledge.view", + "knowledge.manage", + "knowledge.submit", "solution.view", + "solution.manage", + "solution.submit", "agent.use", + "tool.view", + "tool.invoke", + "channel.view", + "channel.send", ), - "普通建单人": ( + "技术支持负责人": ( "ticket.view", "ticket.create", "ticket.ai_draft", + "ticket.edit", + "ticket.export", + "ticket.assign", + "ticket.resolve", + "ticket.reply", + "ticket.close", + "ticket.archive", "knowledge.view", + "knowledge.manage", + "knowledge.submit", "solution.view", + "solution.manage", + "solution.submit", + "agent.use", + "agent.run.view", + "tool.view", + "tool.invoke", + "tool.confirm", + "channel.view", + "channel.send", ), "只读审计员": ( "ticket.view", diff --git a/backend/apps/accounts/tests/test_permission_enforcement.py b/backend/apps/accounts/tests/test_permission_enforcement.py index 888ae9e..80c3e3a 100644 --- a/backend/apps/accounts/tests/test_permission_enforcement.py +++ b/backend/apps/accounts/tests/test_permission_enforcement.py @@ -19,9 +19,9 @@ def test_anonymous_ticket_list_is_denied(client, db): assert response.status_code == 401 -def test_user_with_ticket_view_can_list_tickets(client, db): +def test_support_user_with_ticket_view_can_list_tickets(client, db): Ticket.objects.create(ticket_no="TK202607010001", title="A", description="A") - client.force_login(user_with_role("普通建单人")) + client.force_login(user_with_role("技术支持")) response = client.get("/api/tickets") @@ -42,7 +42,7 @@ def test_user_without_ticket_create_cannot_create_ticket(client, db): def test_user_with_solution_view_can_list_solutions(client, db): - client.force_login(user_with_role("普通建单人")) + client.force_login(user_with_role("技术支持")) response = client.get("/api/solutions") @@ -57,7 +57,7 @@ def test_audit_log_requires_audit_permission(client, db): target_id="health", result="success", ) - client.force_login(user_with_role("普通建单人")) + client.force_login(user_with_role("技术支持")) denied = client.get("/api/audit/operation-logs") assert denied.status_code == 403 diff --git a/backend/apps/audit/api.py b/backend/apps/audit/api.py index 0f43602..16ec0b0 100644 --- a/backend/apps/audit/api.py +++ b/backend/apps/audit/api.py @@ -11,6 +11,8 @@ router = Router(tags=["audit"]) class OperationAuditLogOut(Schema): id: int + user_id: int | None + username: str | None module: str action: str target_type: str @@ -23,6 +25,49 @@ class OperationAuditLogOut(Schema): @router.get("/operation-logs", response=list[OperationAuditLogOut]) -def list_operation_logs(request): +def list_operation_logs( + request, + module: str | None = None, + action: str | None = None, + target_type: str | None = None, + target_id: str | None = None, + result: str | None = None, + user_id: int | None = None, + created_from: datetime | None = None, + created_to: datetime | None = None, + limit: int = 100, +): require_permission(request, "system.audit.view") - return OperationAuditLog.objects.all()[:100] + queryset = OperationAuditLog.objects.select_related("user") + filters = { + "module": module, + "action": action, + "target_type": target_type, + "target_id": target_id, + "result": result, + "user_id": user_id, + } + for field, value in filters.items(): + if value not in (None, ""): + queryset = queryset.filter(**{field: value}) + if created_from: + queryset = queryset.filter(created_at__gte=created_from) + if created_to: + queryset = queryset.filter(created_at__lte=created_to) + return [ + { + "id": item.id, + "user_id": item.user_id, + "username": item.user.username if item.user_id else None, + "module": item.module, + "action": item.action, + "target_type": item.target_type, + "target_id": item.target_id, + "request_snapshot": item.request_snapshot, + "result": item.result, + "ip_address": item.ip_address, + "user_agent": item.user_agent, + "created_at": item.created_at, + } + for item in queryset[: min(max(limit, 1), 500)] + ] diff --git a/backend/apps/audit/tests/test_audit_api.py b/backend/apps/audit/tests/test_audit_api.py index 637e2d9..2940c37 100644 --- a/backend/apps/audit/tests/test_audit_api.py +++ b/backend/apps/audit/tests/test_audit_api.py @@ -18,3 +18,27 @@ def test_list_operation_logs_returns_recent_logs(admin_client, db): assert len(payload) == 1 assert payload[0]["module"] == "system" assert payload[0]["action"] == "health_check" + + +def test_list_operation_logs_supports_business_object_filters(admin_client, db): + OperationAuditLog.objects.create( + module="tickets", + action="status_transition", + target_type="ticket", + target_id="42", + result="success", + ) + OperationAuditLog.objects.create( + module="agents", + action="run_started", + target_type="agent_run", + target_id="run-1", + result="success", + ) + + response = admin_client.get( + "/api/audit/operation-logs?module=tickets&target_type=ticket&target_id=42" + ) + + assert response.status_code == 200 + assert [item["target_id"] for item in response.json()] == ["42"] diff --git a/backend/apps/solutions/api.py b/backend/apps/solutions/api.py index 5b5b0bc..0e387ab 100644 --- a/backend/apps/solutions/api.py +++ b/backend/apps/solutions/api.py @@ -1,8 +1,9 @@ -from django.db import transaction -from django.utils import timezone from ninja import Router +from ninja.errors import HttpError from apps.accounts.api import require_permission +from apps.knowledge.governance import ensure_governed_asset +from apps.knowledge.models import GovernedAssetType from apps.solutions.models import Solution, TicketSolutionLink from apps.solutions.schemas import ( AdoptSolutionIn, @@ -12,7 +13,14 @@ from apps.solutions.schemas import ( TicketSolutionLinkOut, TicketSolutionOut, ) -from apps.tickets.models import Ticket, TicketEvent +from apps.tickets.models import Ticket +from apps.tickets.services import ( + TicketDataError, + TicketStatusError, +) +from apps.tickets.services import ( + adopt_solution_for_ticket as adopt_solution_for_ticket_service, +) router = Router(tags=["solutions"]) @@ -28,13 +36,18 @@ def create_solution(request, payload: SolutionIn): root_cause=payload.root_cause, solution=payload.solution, risk_note=payload.risk_note, - status=payload.status, + status="draft", + allow_auto_resolve=payload.allow_auto_resolve, + requires_human_action=payload.requires_human_action, + requires_external_confirmation=payload.requires_external_confirmation, + requires_production_change=payload.requires_production_change, created_by=request.user if request.user.is_authenticated else None, ) - if solution.status == "published": - from apps.knowledge.services import index_published_solution - - index_published_solution(solution) + ensure_governed_asset( + GovernedAssetType.SOLUTION, + solution, + actor=request.user if request.user.is_authenticated else None, + ) return solution @@ -75,6 +88,8 @@ def get_solution(request, solution_id: int): @router.post("/{solution_id}/link-ticket", response=TicketSolutionLinkOut) def link_solution_to_ticket(request, solution_id: int, payload: TicketSolutionLinkIn): require_permission(request, "solution.manage") + if payload.link_type == "adopted": + raise HttpError(400, "Use adopt-ticket to adopt a solution") solution = Solution.objects.get(id=solution_id) ticket = Ticket.objects.get(id=payload.ticket_id) link, _ = TicketSolutionLink.objects.get_or_create( @@ -86,9 +101,6 @@ def link_solution_to_ticket(request, solution_id: int, payload: TicketSolutionLi "created_by": request.user if request.user.is_authenticated else None, }, ) - if payload.link_type == "adopted": - solution.usage_count = solution.ticket_links.filter(link_type="adopted").count() - solution.save(update_fields=["usage_count", "updated_at"]) return { "id": link.id, "ticket_id": ticket.id, @@ -100,54 +112,22 @@ def link_solution_to_ticket(request, solution_id: int, payload: TicketSolutionLi @router.post("/{solution_id}/adopt-ticket", response=TicketSolutionLinkOut) -def adopt_solution_and_close_ticket(request, solution_id: int, payload: AdoptSolutionIn): +def adopt_solution_for_ticket(request, solution_id: int, payload: AdoptSolutionIn): require_permission(request, "solution.manage") + require_permission(request, "ticket.resolve") operator = request.user if request.user.is_authenticated else None - with transaction.atomic(): - solution = Solution.objects.select_for_update().get(id=solution_id) - ticket = Ticket.objects.select_for_update().get(id=payload.ticket_id) - link, _ = TicketSolutionLink.objects.get_or_create( - ticket=ticket, - solution=solution, - link_type="adopted", - defaults={"created_by": operator}, - ) - now = timezone.now() - from_status = ticket.status - ticket.status = "closed" - ticket.resolution_summary = solution.solution - ticket.is_resolved = True - ticket.replied_to_customer = payload.replied_to_customer - ticket.resolved_at = ticket.resolved_at or now - ticket.closed_at = now - ticket.save( - update_fields=[ - "status", - "resolution_summary", - "is_resolved", - "replied_to_customer", - "resolved_at", - "closed_at", - "updated_at", - ] - ) - solution.usage_count = solution.ticket_links.filter(link_type="adopted").count() - solution.save(update_fields=["usage_count", "updated_at"]) - TicketEvent.objects.create( - ticket=ticket, - event_type="solution_adopted", - from_value={"status": from_status}, - to_value={ - "status": ticket.status, - "solution_id": solution.id, - "solution_title": solution.title, - "resolution": ticket.resolution_summary, - "is_resolved": ticket.is_resolved, - "replied_to_customer": ticket.replied_to_customer, - }, - operator=operator, + try: + solution = Solution.objects.get(id=solution_id) + ticket, link = adopt_solution_for_ticket_service( + Ticket.objects.get(id=payload.ticket_id), + solution, + operator, remark=payload.remark, + replied_to_customer=payload.replied_to_customer, ) + solution.refresh_from_db(fields=["usage_count"]) + except (TicketDataError, TicketStatusError) as error: + raise HttpError(400, str(error)) from error return { "id": link.id, "ticket_id": ticket.id, diff --git a/backend/apps/solutions/migrations/0002_ticketresolution_agent_run_id_and_more.py b/backend/apps/solutions/migrations/0002_ticketresolution_agent_run_id_and_more.py new file mode 100644 index 0000000..ced3427 --- /dev/null +++ b/backend/apps/solutions/migrations/0002_ticketresolution_agent_run_id_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-07-25 03:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('solutions', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='ticketresolution', + name='agent_run_id', + field=models.BigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='ticketresolution', + name='source', + field=models.CharField(default='human', max_length=24), + ), + ] diff --git a/backend/apps/solutions/models.py b/backend/apps/solutions/models.py index 89012ef..dde646d 100644 --- a/backend/apps/solutions/models.py +++ b/backend/apps/solutions/models.py @@ -14,6 +14,10 @@ class Solution(models.Model): solution = models.TextField() risk_note = models.TextField(blank=True) status = models.CharField(max_length=40, default="draft") + allow_auto_resolve = models.BooleanField(default=False) + requires_human_action = models.BooleanField(default=False) + requires_external_confirmation = models.BooleanField(default=False) + requires_production_change = models.BooleanField(default=False) usage_count = models.PositiveIntegerField(default=0) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -34,9 +38,11 @@ class Solution(models.Model): class TicketResolution(models.Model): ticket = models.OneToOneField(Ticket, on_delete=models.CASCADE, related_name="resolution") resolution_type = models.CharField(max_length=80, default="resolved") + source = models.CharField(max_length=24, default="human") root_cause = models.TextField(blank=True) solution_text = models.TextField() linked_solution = models.ForeignKey(Solution, null=True, blank=True, on_delete=models.SET_NULL) + agent_run_id = models.BigIntegerField(null=True, blank=True) confirmed_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, diff --git a/backend/apps/solutions/schemas.py b/backend/apps/solutions/schemas.py index 18de205..2810458 100644 --- a/backend/apps/solutions/schemas.py +++ b/backend/apps/solutions/schemas.py @@ -12,6 +12,10 @@ class SolutionIn(Schema): solution: str risk_note: str = "" status: str = "draft" + allow_auto_resolve: bool = False + requires_human_action: bool = False + requires_external_confirmation: bool = False + requires_production_change: bool = False class SolutionOut(Schema): @@ -24,6 +28,10 @@ class SolutionOut(Schema): solution: str risk_note: str status: str + allow_auto_resolve: bool + requires_human_action: bool + requires_external_confirmation: bool + requires_production_change: bool usage_count: int created_at: datetime updated_at: datetime @@ -37,8 +45,8 @@ class TicketSolutionLinkIn(Schema): class AdoptSolutionIn(Schema): ticket_id: int - remark: str = "采纳解决方案并关闭工单" - replied_to_customer: bool = True + remark: str = "采纳解决方案并标记工单已解决" + replied_to_customer: bool = False class TicketSolutionLinkOut(Schema): diff --git a/backend/apps/solutions/tests/test_solution_api.py b/backend/apps/solutions/tests/test_solution_api.py index 12721ce..1b307d4 100644 --- a/backend/apps/solutions/tests/test_solution_api.py +++ b/backend/apps/solutions/tests/test_solution_api.py @@ -1,5 +1,5 @@ -from apps.solutions.models import Solution, TicketSolutionLink -from apps.tickets.models import Ticket +from apps.solutions.models import Solution, TicketResolution, TicketSolutionLink +from apps.tickets.models import Ticket, TicketCategory def test_create_and_list_solution(admin_client, db): @@ -23,7 +23,7 @@ def test_create_and_list_solution(admin_client, db): assert response.json()[0]["id"] == solution_id -def test_link_solution_to_ticket_increments_usage(admin_client, db): +def test_link_solution_to_ticket_rejects_adopted_link_type(admin_client, db): ticket = Ticket.objects.create(ticket_no="TK202606300020", title="A", description="A") solution = admin_client.post( "/api/solutions", @@ -37,9 +37,8 @@ def test_link_solution_to_ticket_increments_usage(admin_client, db): content_type="application/json", ) - assert response.status_code == 200 - assert response.json()["link_type"] == "adopted" - assert response.json()["solution_usage_count"] == 1 + assert response.status_code == 400 + assert not TicketSolutionLink.objects.filter(ticket=ticket, link_type="adopted").exists() def test_list_ticket_solutions_returns_linked_solution_details(admin_client, db): @@ -57,7 +56,7 @@ def test_list_ticket_solutions_returns_linked_solution_details(admin_client, db) ).json() admin_client.post( f"/api/solutions/{solution['id']}/link-ticket", - data={"ticket_id": ticket.id, "link_type": "adopted"}, + data={"ticket_id": ticket.id, "link_type": "referenced"}, content_type="application/json", ) @@ -65,16 +64,21 @@ def test_list_ticket_solutions_returns_linked_solution_details(admin_client, db) assert response.status_code == 200 payload = response.json() - assert payload[0]["link_type"] == "adopted" + assert payload[0]["link_type"] == "referenced" assert payload[0]["solution"]["title"] == "Restart callback worker" -def test_adopt_solution_closes_ticket_and_records_resolution(admin_client, db): +def test_adopt_solution_resolves_ticket_and_records_authoritative_resolution(admin_client, db): + category, _ = TicketCategory.objects.get_or_create( + code="business_issue", + defaults={"name": "业务问题"}, + ) ticket = Ticket.objects.create( ticket_no="TK202607030300", title="查询采集进度", description="客户需要确认采集进度", status="pending", + category=category, ) solution = Solution.objects.create( title="查询采集进度处理方案", @@ -100,12 +104,14 @@ def test_adopt_solution_closes_ticket_and_records_resolution(admin_client, db): assert payload["solution_usage_count"] == 1 ticket.refresh_from_db() - assert ticket.status == "closed" - assert ticket.resolution_summary == solution.solution - assert ticket.is_resolved is True + assert ticket.status == "resolved" assert ticket.replied_to_customer is True assert ticket.resolved_at is not None - assert ticket.closed_at is not None + assert ticket.closed_at is None + resolution = TicketResolution.objects.get(ticket=ticket) + assert resolution.solution_text == solution.solution + assert resolution.root_cause == solution.root_cause + assert resolution.linked_solution == solution assert TicketSolutionLink.objects.filter( ticket=ticket, solution=solution, diff --git a/backend/apps/tickets/api.py b/backend/apps/tickets/api.py index a29f3da..ec7982c 100644 --- a/backend/apps/tickets/api.py +++ b/backend/apps/tickets/api.py @@ -10,26 +10,82 @@ from ninja import Router from ninja.errors import HttpError from apps.accounts.api import require_permission +from apps.agents.services import AgentValidationError from apps.audit.models import OperationAuditLog -from apps.tickets.models import Ticket, TicketAttachment, TicketCategory, TicketComment, TicketEvent +from apps.knowledge.distillation import ( + KnowledgeDistillationError, + start_ticket_knowledge_distillation, +) +from apps.knowledge.models import TicketKnowledgeDistillation +from apps.skills.services import SkillValidationError +from apps.tickets.evaluation import build_intake_quality_metrics, record_evaluation_result +from apps.tickets.models import ( + CustomerReplyTask, + IntakeEvaluationCase, + SupportEvaluationCase, + Ticket, + TicketAttachment, + TicketCollaborator, + TicketCollaboratorRole, + TicketComment, + TicketDraft, + TicketEvent, + TicketFieldEvidence, + TicketSimilaritySuggestion, + TicketSupportAnalysis, +) +from apps.tickets.replies import ( + CustomerReplyError, + confirm_customer_reply_sent, + update_customer_reply, +) from apps.tickets.schemas import ( + CustomerReplyConfirmIn, + CustomerReplyTaskOut, + CustomerReplyUpdateIn, + IntakeEvaluationRecordIn, + IntakeEvaluationResultOut, + IntakeQualityMetricsOut, + SupportEvaluationRecordIn, + SupportEvaluationResultOut, + SupportQualityMetricsOut, TicketAttachmentIn, TicketAttachmentOut, + TicketCollaboratorIn, + TicketCollaboratorOut, TicketCommentIn, TicketCommentOut, TicketEventOut, + TicketFieldEvidenceOut, TicketIn, + TicketIntakeTraceOut, + TicketKnowledgeDistillationOut, TicketListOut, TicketOut, + TicketSimilaritySuggestionOut, TicketStatusIn, + TicketSupportAnalysisOut, TicketUpdateIn, + TicketUserOut, ) from apps.tickets.services import ( TicketDataError, TicketStatusError, create_ticket_from_fields, + set_ticket_archived, transition_requires_remark, transition_ticket, + update_ticket_fields, +) +from apps.tickets.similarity import ALGORITHM_VERSION, find_similar_tickets +from apps.tickets.support_analysis import ( + SupportAnalysisError, + start_ticket_support_analysis, +) +from apps.tickets.support_evaluation import ( + SupportEvaluationError, + build_support_quality_metrics, + record_support_evaluation_result, ) router = Router(tags=["tickets"]) @@ -63,6 +119,7 @@ def serialize_ticket_category(category): def serialize_ticket(ticket: Ticket) -> dict: + resolution = getattr(ticket, "resolution", None) return { "id": ticket.id, "ticket_no": ticket.ticket_no, @@ -76,17 +133,36 @@ def serialize_ticket(ticket: Ticket) -> dict: "problem_object": ticket.problem_object, "submitter_name": ticket.submitter_name, "submitter_org": ticket.submitter_org, - "ai_solution": ticket.ai_solution, - "resolution": ticket.resolution_summary, - "is_resolved": ticket.is_resolved, + "resolution": resolution.solution_text if resolution else "", + "resolution_id": resolution.id if resolution else None, + "resolution_type": resolution.resolution_type if resolution else None, + "resolution_source": resolution.source if resolution else None, + "resolution_root_cause": resolution.root_cause if resolution else "", + "linked_solution_id": resolution.linked_solution_id if resolution else None, + "is_resolved": resolution is not None and ticket.status in {"resolved", "closed"}, "replied_to_customer": ticket.replied_to_customer, "impact_scope": ticket.impact_scope, "environment": ticket.environment, "related_system": ticket.related_system, + "product_name": ticket.product_name, + "module_name": ticket.module_name, + "tags": ticket.tags, + "attempted_actions": ticket.attempted_actions, + "error_information": ticket.error_information, + "customer_request": ticket.customer_request, + "current_result": ticket.current_result, "requester_id": ticket.requester_id, "assignee_id": ticket.assignee_id, "requester": serialize_ticket_user(ticket.requester), "assignee": serialize_ticket_user(ticket.assignee), + "problem_started_at": ticket.problem_started_at, + "customer_last_message_at": ticket.customer_last_message_at, + "support_first_response_at": ticket.support_first_response_at, + "support_last_reply_at": ticket.support_last_reply_at, + "resolved_at": ticket.resolved_at, + "closed_at": ticket.closed_at, + "archived_at": ticket.archived_at, + "archived_by": serialize_ticket_user(ticket.archived_by), "created_at": ticket.created_at, "updated_at": ticket.updated_at, } @@ -100,10 +176,207 @@ def serialize_ticket_event(event: TicketEvent) -> dict: "to_value": event.to_value, "remark": event.remark, "operator": serialize_ticket_user(event.operator), + "source": event.source, + "agent_run_id": event.agent_run_id, + "metadata": event.metadata, "created_at": event.created_at, } +def serialize_ticket_collaborator(collaborator: TicketCollaborator) -> dict: + return { + "id": collaborator.id, + "user": serialize_ticket_user(collaborator.user), + "role": collaborator.role, + "added_by": serialize_ticket_user(collaborator.added_by), + "created_at": collaborator.created_at, + } + + +def serialize_ticket_comment(comment: TicketComment) -> dict: + return { + "id": comment.id, + "content": comment.content, + "comment_type": comment.comment_type, + "visibility": comment.visibility, + "created_by": serialize_ticket_user(comment.created_by), + "created_at": comment.created_at, + } + + +def serialize_field_evidence(item: TicketFieldEvidence) -> dict: + return { + "id": item.id, + "field_name": item.field_name, + "value": item.value, + "source": item.source, + "confidence": float(item.confidence) if item.confidence is not None else None, + "evidence": item.evidence, + "agent_run_id": item.agent_run_id, + "is_protected": item.is_protected, + "created_by": serialize_ticket_user(item.created_by), + "created_at": item.created_at, + } + + +def serialize_similarity_suggestion(item: TicketSimilaritySuggestion) -> dict: + candidate = item.candidate_ticket + return { + "ticket_id": candidate.id, + "ticket_no": candidate.ticket_no, + "title": candidate.title, + "status": candidate.status, + "category_code": candidate.category.code if candidate.category_id else None, + "score": float(item.score), + "title_similarity": float(item.title_similarity), + "keyword_score": float(item.keyword_score), + "matched_excerpt": item.matched_excerpt, + "algorithm_version": item.algorithm_version, + "created_at": item.created_at, + } + + +def serialize_dynamic_similarity(item: dict) -> dict: + candidate = item["ticket"] + return { + "ticket_id": candidate.id, + "ticket_no": candidate.ticket_no, + "title": candidate.title, + "status": candidate.status, + "category_code": candidate.category.code if candidate.category_id else None, + "score": item["score"], + "title_similarity": item["title_similarity"], + "keyword_score": item["keyword_score"], + "matched_excerpt": item["matched_excerpt"], + "algorithm_version": ALGORITHM_VERSION, + "created_at": None, + } + + +def serialize_support_analysis(item: TicketSupportAnalysis) -> dict: + run = item.run + return { + "id": item.id, + "ticket_id": item.ticket_id, + "run_public_id": str(run.public_id), + "agent_version": run.agent_version.version, + "status": run.status, + "progress_stage": run.progress_stage, + "progress_message": run.progress_message, + "query": item.query, + "retrieval_policy": item.retrieval_policy, + "references": item.references, + "final_output": run.final_output, + "automation_decision": item.automation_decision, + "applied_solution_id": item.applied_solution_id, + "processed_at": item.processed_at, + "error_code": run.error_code, + "error_message": run.error_message, + "created_at": item.created_at, + "started_at": run.started_at, + "finished_at": run.finished_at, + } + + +def serialize_knowledge_distillation(item: TicketKnowledgeDistillation) -> dict: + run = item.run + return { + "id": item.id, + "ticket_id": item.ticket_id, + "run_public_id": str(run.public_id), + "agent_version": run.agent_version.version, + "status": run.status, + "progress_stage": run.progress_stage, + "progress_message": run.progress_message, + "references": item.references, + "retrieval_policy": item.retrieval_policy, + "final_output": run.final_output, + "should_distill": item.should_distill, + "reason": item.reason, + "assessment": item.assessment, + "knowledge_item_id": item.knowledge_item_id, + "troubleshooting_plan_id": item.troubleshooting_plan_id, + "solution_id": item.solution_id, + "processed_at": item.processed_at, + "error_code": run.error_code, + "error_message": run.error_message, + "created_at": item.created_at, + "started_at": run.started_at, + "finished_at": run.finished_at, + } + + +def serialize_customer_reply_task(item: CustomerReplyTask) -> dict: + return { + "id": item.id, + "ticket_id": item.ticket_id, + "support_analysis_id": item.support_analysis_id, + "status": item.status, + "draft_text": item.draft_text, + "final_text": item.final_text, + "revision": item.revision, + "created_by_id": item.created_by_id, + "updated_by_id": item.updated_by_id, + "approved_by_id": item.approved_by_id, + "approved_at": item.approved_at, + "sent_by_id": item.sent_by_id, + "sent_at": item.sent_at, + "created_at": item.created_at, + "updated_at": item.updated_at, + "revisions": [ + { + "version": revision.version, + "content": revision.content, + "source": revision.source, + "edited_by_id": revision.edited_by_id, + "created_at": revision.created_at, + } + for revision in item.revisions.all() + ], + } + + +def serialize_evaluation_result(result) -> dict: + return { + "id": result.id, + "case_key": result.case.key, + "case_source_kind": result.case.source_kind, + "draft_public_id": str(result.draft.public_id), + "ticket_id": result.ticket_id, + "actual_auto_created": result.actual_auto_created, + "correct_field_count": result.correct_field_count, + "evaluated_field_count": result.evaluated_field_count, + "field_accuracy": ( + float(result.field_accuracy) if result.field_accuracy is not None else None + ), + "passed": result.passed, + "details": result.details, + "created_at": result.created_at, + "updated_at": result.updated_at, + } + + +def serialize_support_evaluation_result(result) -> dict: + return { + "id": result.id, + "case_key": result.case.key, + "case_source_kind": result.case.source_kind, + "analysis_id": result.analysis_id, + "ticket_id": result.analysis.ticket_id, + "actual_candidate_solution_ids": result.actual_candidate_solution_ids, + "actual_citation_ranks": result.actual_citation_ranks, + "matched_expected_solution": result.matched_expected_solution, + "actual_auto_resolved": result.actual_auto_resolved, + "actual_manual_fallback": result.actual_manual_fallback, + "incorrect_auto_reuse": result.incorrect_auto_reuse, + "citations_valid": result.citations_valid, + "passed": result.passed, + "details": result.details, + "created_at": result.created_at, + "updated_at": result.updated_at, + } + + @router.post("", response=TicketOut) def create_ticket(request, payload: TicketIn): require_permission(request, "ticket.create") @@ -128,9 +401,19 @@ def list_tickets( view: str = "all", status_group: str | None = None, resolved_within_days: int | None = None, + include_archived: bool = False, ): require_permission(request, "ticket.view") - queryset = Ticket.objects.select_related("requester", "assignee", "category").all() + queryset = Ticket.objects.select_related( + "requester", + "assignee", + "category", + "resolution", + "resolution__linked_solution", + "archived_by", + ) + if not include_archived: + queryset = queryset.filter(archived_at__isnull=True) if status: queryset = queryset.filter(status=status) if priority: @@ -146,6 +429,11 @@ def list_tickets( | Q(problem_object__icontains=q) | Q(submitter_name__icontains=q) | Q(submitter_org__icontains=q) + | Q(product_name__icontains=q) + | Q(module_name__icontains=q) + | Q(error_information__icontains=q) + | Q(customer_request__icontains=q) + | Q(current_result__icontains=q) ) if related_system: queryset = queryset.filter(related_system__icontains=related_system) @@ -192,7 +480,7 @@ def export_tickets(request): response["Content-Disposition"] = 'attachment; filename="tickets.csv"' writer = csv.writer(response) writer.writerow(["ticket_no", "title", "status", "priority", "created_at"]) - for ticket in Ticket.objects.all().order_by("ticket_no"): + for ticket in Ticket.objects.filter(archived_at__isnull=True).order_by("ticket_no"): writer.writerow( [ ticket.ticket_no, @@ -213,14 +501,267 @@ def export_tickets(request): return response +@router.get("/intake-quality-metrics", response=IntakeQualityMetricsOut) +def get_intake_quality_metrics(request, include_synthetic: bool = False): + require_permission(request, "ticket.view") + return build_intake_quality_metrics(include_synthetic=include_synthetic) + + +@router.get("/participants", response=list[TicketUserOut]) +def list_ticket_participants(request): + require_permission(request, "ticket.view") + users = get_user_model().objects.filter(is_active=True).order_by("first_name", "username", "id") + return [serialize_ticket_user(user) for user in users] + + +@router.post("/intake-evaluations", response=IntakeEvaluationResultOut) +def create_intake_evaluation(request, payload: IntakeEvaluationRecordIn): + require_permission(request, "ticket.edit") + case = IntakeEvaluationCase.objects.filter(key=payload.case_key, enabled=True).first() + if case is None: + raise HttpError(404, "评估案例不存在或已停用") + draft = TicketDraft.objects.select_related("confirmed_ticket").filter( + public_id=payload.draft_public_id + ).first() + if draft is None: + raise HttpError(404, "受理记录不存在") + result = record_evaluation_result(case, draft, reviewer=request.user) + OperationAuditLog.objects.create( + user=request.user, + module="tickets", + action="intake_quality_evaluated", + target_type="ticket_draft", + target_id=str(draft.public_id), + request_snapshot={"case_key": case.key, "source_kind": case.source_kind}, + result="success", + ) + return serialize_evaluation_result(result) + + +@router.get("/support-quality-metrics", response=SupportQualityMetricsOut) +def get_support_quality_metrics(request, include_synthetic: bool = False): + require_permission(request, "ticket.view") + require_permission(request, "knowledge.view") + require_permission(request, "solution.view") + return build_support_quality_metrics(include_synthetic=include_synthetic) + + +@router.post("/support-evaluations", response=SupportEvaluationResultOut) +def create_support_evaluation(request, payload: SupportEvaluationRecordIn): + require_permission(request, "ticket.edit") + require_permission(request, "knowledge.view") + require_permission(request, "solution.view") + case = SupportEvaluationCase.objects.filter( + key=payload.case_key, + enabled=True, + ).first() + if case is None: + raise HttpError(404, "支持分析评估案例不存在或已停用") + analysis = TicketSupportAnalysis.objects.select_related("run", "ticket").filter( + id=payload.analysis_id + ).first() + if analysis is None: + raise HttpError(404, "支持分析记录不存在") + try: + result = record_support_evaluation_result( + case, + analysis, + reviewer=request.user, + ) + except SupportEvaluationError as error: + raise HttpError(400, str(error)) from error + OperationAuditLog.objects.create( + user=request.user, + module="tickets", + action="support_quality_evaluated", + target_type="ticket_support_analysis", + target_id=str(analysis.id), + request_snapshot={"case_key": case.key, "source_kind": case.source_kind}, + result="success", + ) + result = type(result).objects.select_related("case", "analysis__ticket").get( + id=result.id + ) + return serialize_support_evaluation_result(result) + + +@router.get("/{ticket_id}/support-analyses", response=list[TicketSupportAnalysisOut]) +def list_ticket_support_analyses(request, ticket_id: int): + require_permission(request, "ticket.view") + require_permission(request, "knowledge.view") + require_permission(request, "solution.view") + queryset = TicketSupportAnalysis.objects.select_related( + "run", + "run__agent_version", + ).filter(ticket_id=ticket_id) + return [serialize_support_analysis(item) for item in queryset[:20]] + + +@router.post("/{ticket_id}/support-analyses", response=TicketSupportAnalysisOut) +def create_ticket_support_analysis(request, ticket_id: int): + require_permission(request, "ticket.view") + require_permission(request, "knowledge.view") + require_permission(request, "solution.view") + require_permission(request, "agent.use") + ticket = Ticket.objects.filter(id=ticket_id).first() + if ticket is None: + raise HttpError(404, "Ticket not found") + try: + analysis = start_ticket_support_analysis(ticket, created_by=request.user) + except (SupportAnalysisError, AgentValidationError, SkillValidationError) as error: + raise HttpError(400, str(error)) from error + analysis = TicketSupportAnalysis.objects.select_related( + "run", + "run__agent_version", + ).get(id=analysis.id) + return serialize_support_analysis(analysis) + + +@router.get( + "/{ticket_id}/knowledge-distillations", + response=list[TicketKnowledgeDistillationOut], +) +def list_ticket_knowledge_distillations(request, ticket_id: int): + require_permission(request, "ticket.view") + require_permission(request, "knowledge.view") + queryset = TicketKnowledgeDistillation.objects.select_related( + "run", + "run__agent_version", + ).filter(ticket_id=ticket_id) + return [serialize_knowledge_distillation(item) for item in queryset[:20]] + + +@router.post( + "/{ticket_id}/knowledge-distillations", + response=TicketKnowledgeDistillationOut, +) +def create_ticket_knowledge_distillation(request, ticket_id: int): + require_permission(request, "ticket.view") + require_permission(request, "knowledge.manage") + require_permission(request, "agent.use") + ticket = Ticket.objects.filter(id=ticket_id).first() + if ticket is None: + raise HttpError(404, "Ticket not found") + try: + distillation = start_ticket_knowledge_distillation( + ticket, + created_by=request.user, + ) + except ( + KnowledgeDistillationError, + AgentValidationError, + SkillValidationError, + ) as error: + raise HttpError(400, str(error)) from error + distillation = TicketKnowledgeDistillation.objects.select_related( + "run", + "run__agent_version", + ).get(id=distillation.id) + return serialize_knowledge_distillation(distillation) + + +@router.get("/{ticket_id}/reply-tasks", response=list[CustomerReplyTaskOut]) +def list_customer_reply_tasks(request, ticket_id: int): + require_permission(request, "ticket.view") + queryset = ( + CustomerReplyTask.objects.filter(ticket_id=ticket_id) + .select_related("support_analysis") + .prefetch_related("revisions") + ) + return [serialize_customer_reply_task(item) for item in queryset[:20]] + + +@router.patch( + "/{ticket_id}/reply-tasks/{reply_task_id}", + response=CustomerReplyTaskOut, +) +def edit_customer_reply_task( + request, + ticket_id: int, + reply_task_id: int, + payload: CustomerReplyUpdateIn, +): + require_permission(request, "ticket.edit") + task = CustomerReplyTask.objects.filter( + id=reply_task_id, + ticket_id=ticket_id, + ).first() + if task is None: + raise HttpError(404, "客户回复任务不存在") + try: + task = update_customer_reply( + task, + payload.final_text, + expected_revision=payload.expected_revision, + actor=request.user, + ) + except CustomerReplyError as error: + raise HttpError(409, str(error)) from error + task = CustomerReplyTask.objects.prefetch_related("revisions").get(id=task.id) + return serialize_customer_reply_task(task) + + +@router.post( + "/{ticket_id}/reply-tasks/{reply_task_id}/confirm-sent", + response=CustomerReplyTaskOut, +) +def confirm_customer_reply_task_sent( + request, + ticket_id: int, + reply_task_id: int, + payload: CustomerReplyConfirmIn, +): + require_permission(request, "ticket.reply") + task = CustomerReplyTask.objects.filter( + id=reply_task_id, + ticket_id=ticket_id, + ).first() + if task is None: + raise HttpError(404, "客户回复任务不存在") + try: + task = confirm_customer_reply_sent( + task, + expected_revision=payload.expected_revision, + actor=request.user, + ) + except CustomerReplyError as error: + raise HttpError(409, str(error)) from error + task = CustomerReplyTask.objects.prefetch_related("revisions").get(id=task.id) + return serialize_customer_reply_task(task) + + @router.get("/{ticket_id}", response=TicketOut) def get_ticket(request, ticket_id: int): require_permission(request, "ticket.view") return serialize_ticket( - Ticket.objects.select_related("requester", "assignee", "category").get(id=ticket_id) + Ticket.objects.select_related( + "requester", + "assignee", + "category", + "resolution", + "resolution__linked_solution", + "archived_by", + ).get(id=ticket_id) ) +@router.get("/{ticket_id}/similar", response=list[TicketSimilaritySuggestionOut]) +def list_similar_tickets(request, ticket_id: int): + require_permission(request, "ticket.view") + ticket = Ticket.objects.select_related("category").filter(id=ticket_id).first() + if ticket is None: + raise HttpError(404, "Ticket not found") + suggestions = list( + TicketSimilaritySuggestion.objects.filter( + source_ticket=ticket, + algorithm_version=ALGORITHM_VERSION, + ).select_related("candidate_ticket__category") + ) + if suggestions: + return [serialize_similarity_suggestion(item) for item in suggestions] + return [serialize_dynamic_similarity(item) for item in find_similar_tickets(ticket)] + + @router.get("/{ticket_id}/events", response=list[TicketEventOut]) def list_ticket_events(request, ticket_id: int): require_permission(request, "ticket.view") @@ -230,52 +771,33 @@ def list_ticket_events(request, ticket_id: int): @router.patch("/{ticket_id}", response=TicketOut) def update_ticket(request, ticket_id: int, payload: TicketUpdateIn): - require_permission(request, "ticket.edit") + changes = payload.dict(exclude_unset=True) + if "assignee_id" in changes: + require_permission(request, "ticket.assign") + if set(changes) - {"assignee_id"}: + require_permission(request, "ticket.edit") + if not changes: + require_permission(request, "ticket.edit") ticket = Ticket.objects.get(id=ticket_id) - before = { - "title": ticket.title, - "category_code": ticket.category.code if ticket.category_id else None, - "priority": ticket.priority, - "problem_performance": ticket.problem_performance, - "problem_object": ticket.problem_object, - "submitter_name": ticket.submitter_name, - "submitter_org": ticket.submitter_org, - "resolution": ticket.resolution_summary, - "is_resolved": ticket.is_resolved, - "replied_to_customer": ticket.replied_to_customer, - "impact_scope": ticket.impact_scope, - "environment": ticket.environment, - "related_system": ticket.related_system, - } - for field, value in payload.dict(exclude_unset=True).items(): - if field == "assignee_id": - user = get_user_model().objects.filter(id=value).first() if value else None - ticket.assignee = user - elif field == "category_code": - category = ( - TicketCategory.objects.filter(code=value, enabled=True).first() if value else None - ) - if value and category is None: - raise HttpError(400, "Unknown ticket category") - ticket.category = category - elif field == "resolution": - ticket.resolution_summary = value or "" - else: - setattr(ticket, field, value) - ticket.save() - TicketEvent.objects.create( - ticket=ticket, - event_type="updated", - from_value=before, - to_value=payload.dict(exclude_unset=True), - operator=request.user if request.user.is_authenticated else None, + try: + updated, _ = update_ticket_fields(ticket, changes, request.user) + except TicketDataError as error: + raise HttpError(400, str(error)) from error + return serialize_ticket( + Ticket.objects.select_related( + "requester", "assignee", "category", "resolution", "archived_by" + ).get(id=updated.id) ) - return serialize_ticket(ticket) @router.post("/{ticket_id}/status", response=TicketOut) def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn): - require_permission(request, "ticket.edit") + if payload.status == "closed": + require_permission(request, "ticket.close") + elif payload.status == "resolved": + require_permission(request, "ticket.resolve") + else: + require_permission(request, "ticket.edit") ticket = Ticket.objects.select_related("category").get(id=ticket_id) category_code = ticket.category.code if ticket.category_id else None has_resolution = bool(payload.resolution.strip()) @@ -294,21 +816,154 @@ def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn): payload.remark, request.user if request.user.is_authenticated else None, resolution=payload.resolution.strip(), - is_resolved=payload.is_resolved, replied_to_customer=payload.replied_to_customer, ) - except TicketStatusError as error: + except (TicketDataError, TicketStatusError) as error: raise HttpError(400, str(error)) from error - return serialize_ticket(updated) + return serialize_ticket( + Ticket.objects.select_related( + "requester", "assignee", "category", "resolution", "archived_by" + ).get(id=updated.id) + ) + + +@router.post("/{ticket_id}/archive", response=TicketOut) +def archive_ticket(request, ticket_id: int): + require_permission(request, "ticket.archive") + ticket = set_ticket_archived(Ticket.objects.get(id=ticket_id), True, request.user) + return serialize_ticket(ticket) + + +@router.post("/{ticket_id}/restore", response=TicketOut) +def restore_ticket(request, ticket_id: int): + require_permission(request, "ticket.archive") + ticket = set_ticket_archived(Ticket.objects.get(id=ticket_id), False, request.user) + return serialize_ticket(ticket) + + +@router.get("/{ticket_id}/collaborators", response=list[TicketCollaboratorOut]) +def list_ticket_collaborators(request, ticket_id: int): + require_permission(request, "ticket.view") + queryset = TicketCollaborator.objects.select_related("user", "added_by").filter( + ticket_id=ticket_id + ) + return [serialize_ticket_collaborator(item) for item in queryset] + + +@router.post("/{ticket_id}/collaborators", response=TicketCollaboratorOut) +def add_ticket_collaborator(request, ticket_id: int, payload: TicketCollaboratorIn): + require_permission(request, "ticket.assign") + if payload.role not in TicketCollaboratorRole.values: + raise HttpError(400, "Unknown collaborator role") + user = get_user_model().objects.filter(id=payload.user_id).first() + if user is None: + raise HttpError(400, "User not found") + ticket = Ticket.objects.get(id=ticket_id) + collaborator, created = TicketCollaborator.objects.get_or_create( + ticket=ticket, + user=user, + role=payload.role, + defaults={"added_by": request.user}, + ) + if created: + TicketEvent.objects.create( + ticket=ticket, + event_type="collaborator_added", + to_value={"user_id": user.id, "role": payload.role}, + operator=request.user, + ) + return serialize_ticket_collaborator(collaborator) + + +@router.delete("/{ticket_id}/collaborators/{collaborator_id}") +def remove_ticket_collaborator(request, ticket_id: int, collaborator_id: int): + require_permission(request, "ticket.assign") + collaborator = TicketCollaborator.objects.filter( + id=collaborator_id, + ticket_id=ticket_id, + ).first() + if collaborator is None: + raise HttpError(404, "Collaborator not found") + event_value = {"user_id": collaborator.user_id, "role": collaborator.role} + collaborator.delete() + TicketEvent.objects.create( + ticket_id=ticket_id, + event_type="collaborator_removed", + from_value=event_value, + operator=request.user, + ) + return {"success": True} + + +@router.get("/{ticket_id}/field-evidence", response=list[TicketFieldEvidenceOut]) +def list_ticket_field_evidence(request, ticket_id: int): + require_permission(request, "ticket.view") + queryset = TicketFieldEvidence.objects.select_related("created_by").filter(ticket_id=ticket_id) + return [serialize_field_evidence(item) for item in queryset] + + +@router.get("/{ticket_id}/intake-trace", response=TicketIntakeTraceOut) +def get_ticket_intake_trace(request, ticket_id: int): + require_permission(request, "ticket.view") + ticket = Ticket.objects.filter(id=ticket_id).first() + if ticket is None: + raise HttpError(404, "Ticket not found") + try: + draft = ticket.source_draft + except TicketDraft.DoesNotExist: + return {"available": False} + from apps.tickets.draft_services import serialize_timeline_item + + timeline_items = list(draft.timeline_items.all()) + return { + "available": True, + "public_id": str(draft.public_id), + "status": draft.status, + "source_text": draft.source_text, + "problem_valid": draft.problem_valid, + "invalid_reason": draft.invalid_reason, + "missing_fields": draft.missing_fields, + "message_timeline": ( + [serialize_timeline_item(item) for item in timeline_items] + if timeline_items + else draft.message_timeline + ), + "agent_run_public_id": str(draft.latest_agent_run.public_id) + if draft.latest_agent_run_id + else None, + } + + +@router.get("/{ticket_id}/comments", response=list[TicketCommentOut]) +def list_ticket_comments(request, ticket_id: int): + require_permission(request, "ticket.view") + comments = TicketComment.objects.select_related("created_by").filter(ticket_id=ticket_id) + return [serialize_ticket_comment(comment) for comment in comments] @router.post("/{ticket_id}/comments", response=TicketCommentOut) def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn): require_permission(request, "ticket.edit") + content = payload.content.strip() + if not content: + raise HttpError(400, "Comment content is required") + if len(content) > 10000: + raise HttpError(400, "Comment content is too long") + allowed_comment_types = {"internal_note", "user_reply"} + allowed_visibilities = {"internal", "public"} + if payload.comment_type not in allowed_comment_types: + raise HttpError(400, "Unknown comment type") + if payload.visibility not in allowed_visibilities: + raise HttpError(400, "Unknown comment visibility") + expected_visibility = ( + "public" if payload.comment_type == "user_reply" else "internal" + ) + if payload.visibility != expected_visibility: + raise HttpError(400, "Comment type and visibility do not match") ticket = Ticket.objects.get(id=ticket_id) comment = TicketComment.objects.create( ticket=ticket, - content=payload.content, + content=content, comment_type=payload.comment_type, visibility=payload.visibility, created_by=request.user if request.user.is_authenticated else None, @@ -319,7 +974,7 @@ def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn): to_value={"comment_id": comment.id, "comment_type": comment.comment_type}, operator=request.user if request.user.is_authenticated else None, ) - return comment + return serialize_ticket_comment(comment) @router.post("/{ticket_id}/attachments", response=TicketAttachmentOut) @@ -334,3 +989,9 @@ def add_ticket_attachment(request, ticket_id: int, payload: TicketAttachmentIn): operator=request.user if request.user.is_authenticated else None, ) return attachment + + +@router.get("/{ticket_id}/attachments", response=list[TicketAttachmentOut]) +def list_ticket_attachments(request, ticket_id: int): + require_permission(request, "ticket.view") + return TicketAttachment.objects.filter(ticket_id=ticket_id) diff --git a/backend/apps/tickets/migrations/0005_ticketcollaborator_ticketfieldevidence_and_more.py b/backend/apps/tickets/migrations/0005_ticketcollaborator_ticketfieldevidence_and_more.py new file mode 100644 index 0000000..94750ff --- /dev/null +++ b/backend/apps/tickets/migrations/0005_ticketcollaborator_ticketfieldevidence_and_more.py @@ -0,0 +1,142 @@ +# Generated by Django 5.2.15 on 2026-07-25 03:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0004_ticketdraft_ticketdraftimage_ticketdraftmessage_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TicketCollaborator', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('collaborator', 'Collaborator'), ('watcher', 'Watcher')], default='collaborator', max_length=24)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='TicketFieldEvidence', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('field_name', models.CharField(max_length=80)), + ('value', models.JSONField(blank=True, null=True)), + ('source', models.CharField(choices=[('human', 'Human'), ('agent', 'Agent'), ('channel', 'Channel'), ('system', 'System')], default='human', max_length=24)), + ('confidence', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True)), + ('evidence', models.JSONField(blank=True, default=dict)), + ('agent_run_id', models.BigIntegerField(blank=True, null=True)), + ('is_protected', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['field_name', '-created_at'], + }, + ), + migrations.RemoveField( + model_name='ticket', + name='ai_solution', + ), + migrations.RemoveField( + model_name='ticket', + name='is_resolved', + ), + migrations.RemoveField( + model_name='ticket', + name='resolution_summary', + ), + migrations.AddField( + model_name='ticket', + name='archived_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='ticket', + name='archived_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_tickets', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='ticket', + name='customer_last_message_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='ticket', + name='problem_started_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='ticket', + name='support_first_response_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='ticket', + name='support_last_reply_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='ticketevent', + name='agent_run_id', + field=models.BigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='ticketevent', + name='metadata', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='ticketevent', + name='source', + field=models.CharField(choices=[('human', 'Human'), ('agent', 'Agent'), ('channel', 'Channel'), ('system', 'System')], default='human', max_length=24), + ), + migrations.AddIndex( + model_name='ticket', + index=models.Index(fields=['archived_at'], name='tickets_tic_archive_f5fa74_idx'), + ), + migrations.AddField( + model_name='ticketcollaborator', + name='added_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_ticket_collaborators', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='ticketcollaborator', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collaborators', to='tickets.ticket'), + ), + migrations.AddField( + model_name='ticketcollaborator', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_collaborations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='ticketfieldevidence', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_field_evidence', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='ticketfieldevidence', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_evidence', to='tickets.ticket'), + ), + migrations.AddConstraint( + model_name='ticketcollaborator', + constraint=models.UniqueConstraint(fields=('ticket', 'user', 'role'), name='unique_ticket_user_collaborator_role'), + ), + migrations.AddIndex( + model_name='ticketfieldevidence', + index=models.Index(fields=['ticket', 'field_name', 'created_at'], name='tickets_tic_ticket__e9538c_idx'), + ), + migrations.AddIndex( + model_name='ticketfieldevidence', + index=models.Index(fields=['source', 'agent_run_id'], name='tickets_tic_source_c062b8_idx'), + ), + ] diff --git a/backend/apps/tickets/models.py b/backend/apps/tickets/models.py index ebfa9f1..a22a83f 100644 --- a/backend/apps/tickets/models.py +++ b/backend/apps/tickets/models.py @@ -26,6 +26,18 @@ class TicketPriority(models.TextChoices): URGENT = "urgent", "Urgent" +class TicketActorSource(models.TextChoices): + HUMAN = "human", "Human" + AGENT = "agent", "Agent" + CHANNEL = "channel", "Channel" + SYSTEM = "system", "System" + + +class TicketCollaboratorRole(models.TextChoices): + COLLABORATOR = "collaborator", "Collaborator" + WATCHER = "watcher", "Watcher" + + class TicketCategory(models.Model): parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL) name = models.CharField(max_length=120) @@ -60,13 +72,21 @@ class Ticket(models.Model): problem_object = models.CharField(max_length=160, blank=True) submitter_name = models.CharField(max_length=80, blank=True) submitter_org = models.CharField(max_length=160, blank=True) - ai_solution = models.TextField(blank=True) - resolution_summary = models.TextField(blank=True) - is_resolved = models.BooleanField(default=False) replied_to_customer = models.BooleanField(default=False) impact_scope = models.CharField(max_length=80, blank=True) environment = models.CharField(max_length=80, blank=True) related_system = models.CharField(max_length=120, blank=True) + product_name = models.CharField(max_length=120, blank=True) + module_name = models.CharField(max_length=120, blank=True) + tags = models.JSONField(default=list, blank=True) + attempted_actions = models.TextField(blank=True) + error_information = models.TextField(blank=True) + customer_request = models.TextField(blank=True) + current_result = models.TextField(blank=True) + problem_started_at = models.DateTimeField(null=True, blank=True) + customer_last_message_at = models.DateTimeField(null=True, blank=True) + support_first_response_at = models.DateTimeField(null=True, blank=True) + support_last_reply_at = models.DateTimeField(null=True, blank=True) requester = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, @@ -92,6 +112,14 @@ class Ticket(models.Model): updated_at = models.DateTimeField(auto_now=True) resolved_at = models.DateTimeField(null=True, blank=True) closed_at = models.DateTimeField(null=True, blank=True) + archived_at = models.DateTimeField(null=True, blank=True) + archived_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="archived_tickets", + ) class Meta: ordering = ["-created_at"] @@ -99,12 +127,107 @@ class Ticket(models.Model): models.Index(fields=["ticket_no"]), models.Index(fields=["status", "priority"]), models.Index(fields=["created_at"]), + models.Index(fields=["archived_at"]), ] def __str__(self) -> str: return f"{self.ticket_no} {self.title}" +class TicketCollaborator(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="collaborators") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="ticket_collaborations", + ) + role = models.CharField( + max_length=24, + choices=TicketCollaboratorRole.choices, + default=TicketCollaboratorRole.COLLABORATOR, + ) + added_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="added_ticket_collaborators", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at"] + constraints = [ + models.UniqueConstraint( + fields=["ticket", "user", "role"], + name="unique_ticket_user_collaborator_role", + ) + ] + + +class TicketFieldEvidence(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="field_evidence") + field_name = models.CharField(max_length=80) + value = models.JSONField(null=True, blank=True) + source = models.CharField( + max_length=24, + choices=TicketActorSource.choices, + default=TicketActorSource.HUMAN, + ) + confidence = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True) + evidence = models.JSONField(default=dict, blank=True) + agent_run_id = models.BigIntegerField(null=True, blank=True) + is_protected = models.BooleanField(default=False) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="ticket_field_evidence", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["field_name", "-created_at"] + indexes = [ + models.Index(fields=["ticket", "field_name", "created_at"]), + models.Index(fields=["source", "agent_run_id"]), + ] + + +class TicketSimilaritySuggestion(models.Model): + source_ticket = models.ForeignKey( + Ticket, + on_delete=models.CASCADE, + related_name="similarity_suggestions", + ) + candidate_ticket = models.ForeignKey( + Ticket, + on_delete=models.CASCADE, + related_name="suggested_for_tickets", + ) + score = models.DecimalField(max_digits=6, decimal_places=5) + title_similarity = models.DecimalField(max_digits=6, decimal_places=5, default=0) + keyword_score = models.DecimalField(max_digits=6, decimal_places=5, default=0) + matched_excerpt = models.TextField(blank=True) + algorithm_version = models.CharField(max_length=40, default="fts_trigram_v2_context") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-score", "candidate_ticket_id"] + constraints = [ + models.UniqueConstraint( + fields=["source_ticket", "candidate_ticket", "algorithm_version"], + name="unique_ticket_similarity_suggestion", + ), + models.CheckConstraint( + condition=~models.Q(source_ticket=models.F("candidate_ticket")), + name="ticket_similarity_not_self", + ), + ] + indexes = [models.Index(fields=["source_ticket", "algorithm_version", "score"])] + + class TicketComment(models.Model): ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments") content = models.TextField() @@ -129,6 +252,11 @@ class TicketAttachment(models.Model): mime_type = models.CharField(max_length=120, blank=True) file_size = models.PositiveBigIntegerField(default=0) storage_path = models.CharField(max_length=500) + sha256 = models.CharField(max_length=64, blank=True) + scan_status = models.CharField(max_length=24, default="pending") + scan_result = models.JSONField(default=dict, blank=True) + parse_status = models.CharField(max_length=24, default="pending") + parse_error = models.TextField(blank=True) ocr_text = models.TextField(blank=True) parsed_text = models.TextField(blank=True) created_by = models.ForeignKey( @@ -147,11 +275,15 @@ class TicketDraft(models.Model): on_delete=models.CASCADE, related_name="ticket_drafts", ) - status = models.CharField(max_length=24, default="editing") + status = models.CharField(max_length=24, default="collecting") revision = models.PositiveIntegerField(default=1) source_text = models.TextField(blank=True) + problem_valid = models.BooleanField(null=True, blank=True) + invalid_reason = models.TextField(blank=True) current_fields = models.JSONField(default=dict) last_ai_fields = models.JSONField(default=dict) + field_evidence = models.JSONField(default=dict) + message_timeline = models.JSONField(default=list) missing_fields = models.JSONField(default=list) questions = models.JSONField(default=list) guided_rounds = models.PositiveSmallIntegerField(default=0) @@ -191,7 +323,6 @@ class TicketDraft(models.Model): models.Index(fields=["status", "expires_at"]), ] - class TicketDraftMessage(models.Model): draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="messages") role = models.CharField(max_length=24) @@ -203,21 +334,75 @@ class TicketDraftMessage(models.Model): ordering = ["created_at"] -class TicketDraftImage(models.Model): +class TicketMessageTimelineItem(models.Model): + draft = models.ForeignKey( + TicketDraft, + on_delete=models.CASCADE, + related_name="timeline_items", + ) + ticket = models.ForeignKey( + Ticket, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="message_timeline_items", + ) + source_material = models.ForeignKey( + "TicketDraftMaterial", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="message_timeline_items", + ) + sequence = models.PositiveIntegerField() + speaker = models.CharField(max_length=120, blank=True) + role = models.CharField(max_length=24, default="unknown") + content = models.TextField() + occurred_at = models.DateTimeField(null=True, blank=True) + occurred_at_raw = models.CharField(max_length=120, blank=True) + timezone = models.CharField(max_length=80, blank=True) + source_ref = models.CharField(max_length=160, blank=True) + confidence = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True) + conflict = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["sequence"] + constraints = [ + models.UniqueConstraint( + fields=["draft", "sequence"], + name="unique_ticket_draft_timeline_sequence", + ) + ] + indexes = [ + models.Index(fields=["ticket", "sequence"]), + models.Index(fields=["draft", "sequence"]), + ] + + +class TicketDraftMaterial(models.Model): public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) - draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="images") + draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="materials") file_name = models.CharField(max_length=255) + material_type = models.CharField(max_length=24, default="image") + extension = models.CharField(max_length=16, blank=True) + claimed_mime_type = models.CharField(max_length=120, blank=True) mime_type = models.CharField(max_length=120) file_size = models.PositiveBigIntegerField() sha256 = models.CharField(max_length=64) storage_path = models.CharField(max_length=500) status = models.CharField(max_length=24, default="temporary") + scan_status = models.CharField(max_length=24, default="pending") + scan_result = models.JSONField(default=dict, blank=True) + parse_status = models.CharField(max_length=24, default="pending") + parse_error = models.TextField(blank=True) + parsed_text = models.TextField(blank=True) confirmed_attachment = models.OneToOneField( TicketAttachment, null=True, blank=True, on_delete=models.SET_NULL, - related_name="source_draft_image", + related_name="source_draft_material", ) created_at = models.DateTimeField(auto_now_add=True) @@ -225,6 +410,265 @@ class TicketDraftImage(models.Model): ordering = ["created_at"] +class IntakeEvaluationCase(models.Model): + key = models.SlugField(max_length=120, unique=True) + name = models.CharField(max_length=200) + source_kind = models.CharField(max_length=32, default="synthetic") + valid_input = models.BooleanField(default=True) + input_manifest = models.JSONField(default=dict) + expected_fields = models.JSONField(default=dict) + expected_timeline = models.JSONField(default=list) + expected_auto_created = models.BooleanField(default=True) + expected_similar_ticket = models.BooleanField(default=False) + notes = models.TextField(blank=True) + enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["key"] + indexes = [models.Index(fields=["source_kind", "enabled"])] + + +class IntakeEvaluationResult(models.Model): + case = models.ForeignKey( + IntakeEvaluationCase, + on_delete=models.PROTECT, + related_name="results", + ) + draft = models.ForeignKey( + TicketDraft, + on_delete=models.CASCADE, + related_name="evaluation_results", + ) + ticket = models.ForeignKey( + Ticket, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="intake_evaluation_results", + ) + actual_fields = models.JSONField(default=dict) + actual_auto_created = models.BooleanField(default=False) + correct_field_count = models.PositiveIntegerField(default=0) + evaluated_field_count = models.PositiveIntegerField(default=0) + field_accuracy = models.DecimalField(max_digits=6, decimal_places=5, null=True, blank=True) + passed = models.BooleanField(default=False) + details = models.JSONField(default=dict, blank=True) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="reviewed_intake_evaluations", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + constraints = [ + models.UniqueConstraint( + fields=["case", "draft"], + name="unique_intake_case_draft_result", + ) + ] + indexes = [models.Index(fields=["case", "updated_at"])] + + +class SupportEvaluationCase(models.Model): + key = models.SlugField(max_length=120, unique=True) + name = models.CharField(max_length=200) + source_kind = models.CharField(max_length=32, default="synthetic") + input_manifest = models.JSONField(default=dict) + expected_solution_ids = models.JSONField(default=list) + expected_auto_resolve = models.BooleanField(default=False) + expected_manual_fallback = models.BooleanField(default=True) + notes = models.TextField(blank=True) + enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["key"] + indexes = [models.Index(fields=["source_kind", "enabled"])] + + +class SupportEvaluationResult(models.Model): + case = models.ForeignKey( + SupportEvaluationCase, + on_delete=models.PROTECT, + related_name="results", + ) + analysis = models.ForeignKey( + "tickets.TicketSupportAnalysis", + on_delete=models.CASCADE, + related_name="evaluation_results", + ) + actual_candidate_solution_ids = models.JSONField(default=list) + actual_citation_ranks = models.JSONField(default=list) + matched_expected_solution = models.BooleanField(default=False) + actual_auto_resolved = models.BooleanField(default=False) + actual_manual_fallback = models.BooleanField(default=False) + incorrect_auto_reuse = models.BooleanField(default=False) + citations_valid = models.BooleanField(default=False) + passed = models.BooleanField(default=False) + details = models.JSONField(default=dict, blank=True) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="reviewed_support_evaluations", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + constraints = [ + models.UniqueConstraint( + fields=["case", "analysis"], + name="unique_support_case_analysis_result", + ) + ] + indexes = [models.Index(fields=["case", "updated_at"])] + + +class TicketSupportAnalysis(models.Model): + ticket = models.ForeignKey( + Ticket, + on_delete=models.CASCADE, + related_name="support_analyses", + ) + run = models.OneToOneField( + "agents.AgentRun", + on_delete=models.PROTECT, + related_name="ticket_support_analysis", + ) + query = models.TextField() + references = models.JSONField(default=list) + retrieval_policy = models.JSONField(default=dict) + automation_decision = models.JSONField(default=dict, blank=True) + applied_solution = models.ForeignKey( + "solutions.Solution", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="applied_support_analyses", + ) + processed_at = models.DateTimeField(null=True, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_ticket_support_analyses", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + indexes = [models.Index(fields=["ticket", "created_at"])] + + +class CustomerReplyTask(models.Model): + ticket = models.ForeignKey( + Ticket, + on_delete=models.CASCADE, + related_name="customer_reply_tasks", + ) + support_analysis = models.ForeignKey( + TicketSupportAnalysis, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="customer_reply_tasks", + ) + status = models.CharField(max_length=24, default="draft") + draft_text = models.TextField() + final_text = models.TextField() + revision = models.PositiveIntegerField(default=1) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_customer_reply_tasks", + ) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="updated_customer_reply_tasks", + ) + approved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="approved_customer_reply_tasks", + ) + approved_at = models.DateTimeField(null=True, blank=True) + sent_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="sent_customer_reply_tasks", + ) + sent_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["ticket"], + condition=models.Q(status__in=["draft", "approved"]), + name="unique_active_customer_reply_task", + ) + ] + indexes = [ + models.Index(fields=["ticket", "status", "created_at"]), + models.Index(fields=["status", "updated_at"]), + ] + + +class CustomerReplyRevision(models.Model): + task = models.ForeignKey( + CustomerReplyTask, + on_delete=models.CASCADE, + related_name="revisions", + ) + version = models.PositiveIntegerField() + content = models.TextField() + source = models.CharField(max_length=24, default=TicketActorSource.HUMAN) + edited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="customer_reply_revisions", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["version"] + constraints = [ + models.UniqueConstraint( + fields=["task", "version"], + name="unique_customer_reply_revision", + ) + ] + + +# Kept temporarily for imports owned by stage 2.1 integrations. +TicketDraftImage = TicketDraftMaterial + + class TicketEvent(models.Model): ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events") event_type = models.CharField(max_length=80) @@ -236,6 +680,13 @@ class TicketEvent(models.Model): blank=True, on_delete=models.SET_NULL, ) + source = models.CharField( + max_length=24, + choices=TicketActorSource.choices, + default=TicketActorSource.HUMAN, + ) + agent_run_id = models.BigIntegerField(null=True, blank=True) + metadata = models.JSONField(default=dict, blank=True) remark = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/apps/tickets/schemas.py b/backend/apps/tickets/schemas.py index faac204..188aa98 100644 --- a/backend/apps/tickets/schemas.py +++ b/backend/apps/tickets/schemas.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any from ninja import Schema @@ -15,7 +16,18 @@ class TicketIn(Schema): impact_scope: str = "" environment: str = "" related_system: str = "" + product_name: str = "" + module_name: str = "" + tags: list[str] = [] + attempted_actions: str = "" + error_information: str = "" + customer_request: str = "" + current_result: str = "" assignee_id: int | None = None + problem_started_at: datetime | None = None + customer_last_message_at: datetime | None = None + support_first_response_at: datetime | None = None + support_last_reply_at: datetime | None = None class TicketUpdateIn(Schema): @@ -27,20 +39,27 @@ class TicketUpdateIn(Schema): problem_object: str | None = None submitter_name: str | None = None submitter_org: str | None = None - resolution: str | None = None - is_resolved: bool | None = None - replied_to_customer: bool | None = None impact_scope: str | None = None environment: str | None = None related_system: str | None = None + product_name: str | None = None + module_name: str | None = None + tags: list[str] | None = None + attempted_actions: str | None = None + error_information: str | None = None + customer_request: str | None = None + current_result: str | None = None assignee_id: int | None = None + problem_started_at: datetime | None = None + customer_last_message_at: datetime | None = None + support_first_response_at: datetime | None = None + support_last_reply_at: datetime | None = None class TicketStatusIn(Schema): status: str remark: str = "" resolution: str = "" - is_resolved: bool | None = None replied_to_customer: bool | None = None @@ -70,17 +89,36 @@ class TicketOut(Schema): problem_object: str submitter_name: str submitter_org: str - ai_solution: str resolution: str + resolution_id: int | None + resolution_type: str | None + resolution_source: str | None + resolution_root_cause: str + linked_solution_id: int | None is_resolved: bool replied_to_customer: bool impact_scope: str environment: str related_system: str + product_name: str + module_name: str + tags: list[str] + attempted_actions: str + error_information: str + customer_request: str + current_result: str requester_id: int | None assignee_id: int | None requester: TicketUserOut | None = None assignee: TicketUserOut | None = None + problem_started_at: datetime | None + customer_last_message_at: datetime | None + support_first_response_at: datetime | None + support_last_reply_at: datetime | None + resolved_at: datetime | None + closed_at: datetime | None + archived_at: datetime | None + archived_by: TicketUserOut | None = None created_at: datetime updated_at: datetime @@ -100,6 +138,9 @@ class TicketEventOut(Schema): to_value: dict remark: str operator: TicketUserOut | None = None + source: str + agent_run_id: int | None + metadata: dict[str, Any] created_at: datetime @@ -114,6 +155,33 @@ class TicketCommentOut(Schema): content: str comment_type: str visibility: str + created_by: TicketUserOut | None = None + created_at: datetime + + +class TicketCollaboratorIn(Schema): + user_id: int + role: str = "collaborator" + + +class TicketCollaboratorOut(Schema): + id: int + user: TicketUserOut + role: str + added_by: TicketUserOut | None = None + created_at: datetime + + +class TicketFieldEvidenceOut(Schema): + id: int + field_name: str + value: Any + source: str + confidence: float | None + evidence: dict[str, Any] + agent_run_id: int | None + is_protected: bool + created_by: TicketUserOut | None = None created_at: datetime @@ -132,4 +200,243 @@ class TicketAttachmentOut(Schema): mime_type: str file_size: int storage_path: str + sha256: str + scan_status: str + scan_result: dict[str, Any] + parse_status: str + parse_error: str + parsed_text: str created_at: datetime + + +class TicketIntakeTraceOut(Schema): + available: bool + public_id: str | None = None + status: str = "" + source_text: str = "" + problem_valid: bool | None = None + invalid_reason: str = "" + missing_fields: list[str] = [] + message_timeline: list[dict[str, Any]] = [] + agent_run_public_id: str | None = None + + +class TicketSimilaritySuggestionOut(Schema): + ticket_id: int + ticket_no: str + title: str + status: str + category_code: str | None + score: float + title_similarity: float + keyword_score: float + matched_excerpt: str + algorithm_version: str + created_at: datetime | None = None + + +class TicketSupportAnalysisOut(Schema): + id: int + ticket_id: int + run_public_id: str + agent_version: int + status: str + progress_stage: str + progress_message: str + query: str + retrieval_policy: dict[str, Any] + references: list[dict[str, Any]] + final_output: Any | None + automation_decision: dict[str, Any] + applied_solution_id: int | None + processed_at: datetime | None + error_code: str + error_message: str + created_at: datetime + started_at: datetime | None + finished_at: datetime | None + + +class TicketKnowledgeDistillationOut(Schema): + id: int + ticket_id: int + run_public_id: str + agent_version: int + status: str + progress_stage: str + progress_message: str + references: list[dict[str, Any]] + retrieval_policy: dict[str, Any] + final_output: Any | None + should_distill: bool | None + reason: str + assessment: dict[str, Any] + knowledge_item_id: int | None + troubleshooting_plan_id: int | None + solution_id: int | None + processed_at: datetime | None + error_code: str + error_message: str + created_at: datetime + started_at: datetime | None + finished_at: datetime | None + + +class CustomerReplyRevisionOut(Schema): + version: int + content: str + source: str + edited_by_id: int | None + created_at: datetime + + +class CustomerReplyTaskOut(Schema): + id: int + ticket_id: int + support_analysis_id: int | None + status: str + draft_text: str + final_text: str + revision: int + created_by_id: int | None + updated_by_id: int | None + approved_by_id: int | None + approved_at: datetime | None + sent_by_id: int | None + sent_at: datetime | None + created_at: datetime + updated_at: datetime + revisions: list[CustomerReplyRevisionOut] + + +class CustomerReplyUpdateIn(Schema): + final_text: str + expected_revision: int + + +class CustomerReplyConfirmIn(Schema): + expected_revision: int + + +class IntakeEvaluationRecordIn(Schema): + case_key: str + draft_public_id: str + + +class IntakeEvaluationResultOut(Schema): + id: int + case_key: str + case_source_kind: str + draft_public_id: str + ticket_id: int | None + actual_auto_created: bool + correct_field_count: int + evaluated_field_count: int + field_accuracy: float | None + passed: bool + details: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class IntakeRateMetricOut(Schema): + valid_input_count: int + auto_created_count: int + auto_creation_rate: float | None + + +class IntakeEvaluationMetricOut(Schema): + enabled_case_count: int + reviewed_case_count: int + coverage_rate: float | None + valid_input_count: int + auto_created_count: int + auto_creation_rate: float | None + correct_field_count: int + evaluated_field_count: int + field_accuracy: float | None + evidence_covered_field_count: int + evidence_field_count: int + evidence_coverage_rate: float | None + correct_timeline_item_count: int + evaluated_timeline_item_count: int + timeline_accuracy: float | None + result_selection: str + + +class IntakeQualityTargetsOut(Schema): + auto_creation_rate: float + field_accuracy: float + coverage_complete: bool + auto_creation_rate_met: bool | None + field_accuracy_met: bool | None + + +class IntakeQualityMetricsOut(Schema): + include_synthetic: bool + operational: IntakeRateMetricOut + evaluation: IntakeEvaluationMetricOut + targets: IntakeQualityTargetsOut + + +class SupportEvaluationRecordIn(Schema): + case_key: str + analysis_id: int + + +class SupportEvaluationResultOut(Schema): + id: int + case_key: str + case_source_kind: str + analysis_id: int + ticket_id: int + actual_candidate_solution_ids: list[int] + actual_citation_ranks: list[int] + matched_expected_solution: bool + actual_auto_resolved: bool + actual_manual_fallback: bool + incorrect_auto_reuse: bool + citations_valid: bool + passed: bool + details: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class SupportEvaluationMetricOut(Schema): + enabled_case_count: int + reviewed_case_count: int + coverage_rate: float | None + expected_match_case_count: int + matched_solution_count: int + solution_match_rate: float | None + eligible_auto_resolve_case_count: int + auto_resolved_count: int + eligible_auto_resolve_rate: float | None + actual_auto_resolved_count: int + incorrect_auto_reuse_count: int + incorrect_auto_reuse_rate: float | None + expected_manual_fallback_count: int + manual_fallback_count: int + manual_fallback_accuracy: float | None + citation_valid_count: int + citation_validity_rate: float | None + result_selection: str + + +class SupportQualityTargetsOut(Schema): + solution_match_rate: float + eligible_auto_resolve_rate: float + incorrect_auto_reuse_rate: float + coverage_complete: bool + solution_match_rate_met: bool | None + eligible_auto_resolve_rate_met: bool | None + incorrect_auto_reuse_rate_met: bool | None + manual_fallback_accuracy_met: bool | None + citation_validity_rate_met: bool | None + + +class SupportQualityMetricsOut(Schema): + include_synthetic: bool + evaluation: SupportEvaluationMetricOut + targets: SupportQualityTargetsOut diff --git a/backend/apps/tickets/services.py b/backend/apps/tickets/services.py index be76ef2..bf02a22 100644 --- a/backend/apps/tickets/services.py +++ b/backend/apps/tickets/services.py @@ -1,8 +1,24 @@ +"""工单领域服务。 + +所有工单写入都应经过本模块,以统一执行字段校验、人工字段保护、状态机、证据记录和审计。 +""" + from django.contrib.auth import get_user_model +from django.db import transaction from django.utils import timezone +from django.utils.dateparse import parse_datetime from apps.audit.models import OperationAuditLog -from apps.tickets.models import Ticket, TicketCategory, TicketEvent, TicketPriority +from apps.tickets.models import ( + Ticket, + TicketActorSource, + TicketCategory, + TicketDraft, + TicketEvent, + TicketFieldEvidence, + TicketPriority, + TicketStatus, +) class TicketStatusError(ValueError): @@ -13,6 +29,16 @@ class TicketDataError(ValueError): pass +class TicketFieldProtectedError(TicketDataError): + pass + + +def schedule_ticket_search_index(ticket_id: int) -> None: + from apps.knowledge.tasks import dispatch_ticket_search_index + + transaction.on_commit(lambda: dispatch_ticket_search_index(ticket_id)) + + REQUIRED_TICKET_FIELDS = ( "title", "description", @@ -25,6 +51,145 @@ REQUIRED_TICKET_FIELDS = ( "environment", ) +EDITABLE_TICKET_FIELDS = { + "title", + "description", + "category_code", + "priority", + "problem_performance", + "problem_object", + "submitter_name", + "submitter_org", + "impact_scope", + "environment", + "related_system", + "product_name", + "module_name", + "tags", + "attempted_actions", + "error_information", + "customer_request", + "current_result", + "assignee_id", + "problem_started_at", + "customer_last_message_at", + "support_first_response_at", + "support_last_reply_at", +} + +BUSINESS_TIME_FIELDS = { + "problem_started_at", + "customer_last_message_at", + "support_first_response_at", + "support_last_reply_at", +} + + +def normalize_tags(value) -> list[str]: + if not isinstance(value, list): + return [] + tags = [] + for item in value: + tag = str(item).strip() + if tag and tag not in tags: + tags.append(tag[:50]) + if len(tags) >= 20: + break + return tags + + +def normalize_business_datetime(value): + if value in (None, ""): + return None + parsed = parse_datetime(value.strip()) if isinstance(value, str) else value + if parsed is None or not hasattr(parsed, "utcoffset") or timezone.is_naive(parsed): + return None + return parsed + + +def _json_value(value): + if hasattr(value, "isoformat"): + return value.isoformat() + return value + + +def _field_value(ticket: Ticket, field_name: str): + if field_name == "category_code": + return ticket.category.code if ticket.category_id else None + if field_name == "assignee_id": + return ticket.assignee_id + return getattr(ticket, field_name) + + +def get_missing_ticket_fields(ticket: Ticket) -> list[str]: + missing = [] + for field_name in REQUIRED_TICKET_FIELDS: + if field_name == "priority": + continue + value = _field_value(ticket, field_name) + if not value: + missing.append(field_name) + continue + if field_name == "title": + evidence = ticket.field_evidence.filter(field_name="title").order_by( + "-created_at" + ).first() + if evidence and evidence.evidence.get("generated_from") == "description": + missing.append(field_name) + return missing + + +def get_outstanding_intake_fields(ticket: Ticket) -> list[str]: + missing = set(get_missing_ticket_fields(ticket)) + try: + draft = ticket.source_draft + except TicketDraft.DoesNotExist: + return sorted(missing) + draft_missing = { + code + for code in draft.missing_fields + if code in EDITABLE_TICKET_FIELDS and not _field_value(ticket, code) + } + normalized = sorted(missing | draft_missing) + if normalized != sorted(draft.missing_fields): + draft.missing_fields = normalized + draft.save(update_fields=["missing_fields", "updated_at"]) + return normalized + + +def record_field_evidence( + ticket: Ticket, + field_name: str, + value, + *, + source: str, + operator=None, + confidence=None, + evidence: dict | None = None, + agent_run_id: int | None = None, + is_protected: bool = False, +) -> TicketFieldEvidence: + return TicketFieldEvidence.objects.create( + ticket=ticket, + field_name=field_name, + value=_json_value(value), + source=source, + confidence=confidence, + evidence=evidence or {}, + agent_run_id=agent_run_id, + is_protected=is_protected, + created_by=operator, + ) + + +def has_protected_field(ticket: Ticket, field_name: str) -> bool: + latest = ( + TicketFieldEvidence.objects.filter(ticket=ticket, field_name=field_name) + .order_by("-created_at", "-id") + .first() + ) + return bool(latest and latest.is_protected) + def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]: normalized = { @@ -39,7 +204,24 @@ def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]: "impact_scope": str(fields.get("impact_scope") or "").strip(), "environment": str(fields.get("environment") or "").strip(), "related_system": str(fields.get("related_system") or "").strip(), + "product_name": str(fields.get("product_name") or "").strip(), + "module_name": str(fields.get("module_name") or "").strip(), + "tags": normalize_tags(fields.get("tags")), + "attempted_actions": str(fields.get("attempted_actions") or "").strip(), + "error_information": str(fields.get("error_information") or "").strip(), + "customer_request": str(fields.get("customer_request") or "").strip(), + "current_result": str(fields.get("current_result") or "").strip(), "assignee_id": fields.get("assignee_id"), + "problem_started_at": normalize_business_datetime(fields.get("problem_started_at")), + "customer_last_message_at": normalize_business_datetime( + fields.get("customer_last_message_at") + ), + "support_first_response_at": normalize_business_datetime( + fields.get("support_first_response_at") + ), + "support_last_reply_at": normalize_business_datetime( + fields.get("support_last_reply_at") + ), } missing = [code for code in REQUIRED_TICKET_FIELDS if not normalized.get(code)] if missing: @@ -65,7 +247,16 @@ def next_ticket_no() -> str: return f"{prefix}{count:04d}" -def create_ticket_from_fields(fields: dict, user) -> Ticket: +@transaction.atomic +def create_ticket_from_fields( + fields: dict, + user, + *, + source: str = TicketActorSource.HUMAN, + agent_run_id: int | None = None, + evidence_by_field: dict | None = None, + confidence_by_field: dict | None = None, +) -> Ticket: normalized, category = validate_ticket_fields(fields) ticket = Ticket.objects.create( ticket_no=next_ticket_no(), @@ -80,7 +271,18 @@ def create_ticket_from_fields(fields: dict, user) -> Ticket: impact_scope=normalized["impact_scope"], environment=normalized["environment"], related_system=normalized["related_system"], + product_name=normalized["product_name"], + module_name=normalized["module_name"], + tags=normalized["tags"], + attempted_actions=normalized["attempted_actions"], + error_information=normalized["error_information"], + customer_request=normalized["customer_request"], + current_result=normalized["current_result"], assignee=normalized["assignee"], + problem_started_at=normalized["problem_started_at"], + customer_last_message_at=normalized["customer_last_message_at"], + support_first_response_at=normalized["support_first_response_at"], + support_last_reply_at=normalized["support_last_reply_at"], requester=user, created_by=user, ) @@ -89,10 +291,369 @@ def create_ticket_from_fields(fields: dict, user) -> Ticket: event_type="created", to_value={"status": ticket.status, "category_code": normalized["category_code"]}, operator=user, + source=source, + agent_run_id=agent_run_id, ) + evidence_by_field = evidence_by_field or {} + confidence_by_field = confidence_by_field or {} + for field_name in EDITABLE_TICKET_FIELDS: + value = _field_value(ticket, field_name) + if value not in (None, ""): + record_field_evidence( + ticket, + field_name, + value, + source=source, + operator=user, + confidence=confidence_by_field.get(field_name), + evidence=evidence_by_field.get(field_name), + agent_run_id=agent_run_id, + is_protected=source == TicketActorSource.HUMAN, + ) + from apps.tickets.similarity import schedule_ticket_similarity_refresh + + schedule_ticket_similarity_refresh(ticket) return ticket +@transaction.atomic +def create_ticket_from_intake( + fields: dict, + user, + *, + agent_run_id: int, + evidence_by_field: dict | None = None, + confidence_by_field: dict | None = None, + human_field_names: set[str] | None = None, + reported_missing_fields: list[str] | None = None, +) -> tuple[Ticket, list[str]]: + """在问题描述有效后尽早建单,不为缺失业务事实编造默认值。 + + 返回正式工单及仍待补全的字段;Agent 字段保留证据但默认不受人工字段保护。 + """ + normalized = { + "title": str(fields.get("title") or "").strip(), + "description": str(fields.get("description") or "").strip(), + "category_code": str(fields.get("category_code") or "").strip(), + "priority": str(fields.get("priority") or "medium").strip(), + "problem_performance": str(fields.get("problem_performance") or "").strip(), + "problem_object": str(fields.get("problem_object") or "").strip(), + "submitter_name": str(fields.get("submitter_name") or "").strip(), + "submitter_org": str(fields.get("submitter_org") or "").strip(), + "impact_scope": str(fields.get("impact_scope") or "").strip(), + "environment": str(fields.get("environment") or "").strip(), + "related_system": str(fields.get("related_system") or "").strip(), + "product_name": str(fields.get("product_name") or "").strip(), + "module_name": str(fields.get("module_name") or "").strip(), + "tags": normalize_tags(fields.get("tags")), + "attempted_actions": str(fields.get("attempted_actions") or "").strip(), + "error_information": str(fields.get("error_information") or "").strip(), + "customer_request": str(fields.get("customer_request") or "").strip(), + "current_result": str(fields.get("current_result") or "").strip(), + "assignee_id": fields.get("assignee_id"), + "problem_started_at": normalize_business_datetime(fields.get("problem_started_at")), + "customer_last_message_at": normalize_business_datetime( + fields.get("customer_last_message_at") + ), + "support_first_response_at": normalize_business_datetime( + fields.get("support_first_response_at") + ), + "support_last_reply_at": normalize_business_datetime( + fields.get("support_last_reply_at") + ), + } + if not normalized["description"]: + raise TicketDataError("尚未形成有效问题描述") + + category = TicketCategory.objects.filter( + code=normalized["category_code"], + enabled=True, + ).first() + if category is None: + normalized["category_code"] = "" + if normalized["priority"] not in TicketPriority.values: + normalized["priority"] = TicketPriority.MEDIUM + + missing_fields = [ + code + for code in REQUIRED_TICKET_FIELDS + if code != "priority" + and ( + not normalized.get(code) + or (code == "category_code" and category is None) + ) + ] + missing_fields = sorted( + set(missing_fields) + | { + code + for code in (reported_missing_fields or []) + if code in EDITABLE_TICKET_FIELDS and not normalized.get(code) + } + ) + if not normalized["title"]: + normalized["title"] = f"待补全问题:{normalized['description'][:170]}".strip() + + assignee = None + if normalized["assignee_id"]: + assignee = get_user_model().objects.filter(id=normalized["assignee_id"]).first() + + ticket = Ticket.objects.create( + ticket_no=next_ticket_no(), + title=normalized["title"][:200], + description=normalized["description"], + category=category, + status=TicketStatus.PENDING_INFO if missing_fields else TicketStatus.PENDING, + priority=normalized["priority"], + problem_performance=normalized["problem_performance"], + problem_object=normalized["problem_object"], + submitter_name=normalized["submitter_name"], + submitter_org=normalized["submitter_org"], + impact_scope=normalized["impact_scope"], + environment=normalized["environment"], + related_system=normalized["related_system"], + product_name=normalized["product_name"], + module_name=normalized["module_name"], + tags=normalized["tags"], + attempted_actions=normalized["attempted_actions"], + error_information=normalized["error_information"], + customer_request=normalized["customer_request"], + current_result=normalized["current_result"], + assignee=assignee, + problem_started_at=normalized["problem_started_at"], + customer_last_message_at=normalized["customer_last_message_at"], + support_first_response_at=normalized["support_first_response_at"], + support_last_reply_at=normalized["support_last_reply_at"], + requester=user, + created_by=user, + ) + TicketEvent.objects.create( + ticket=ticket, + event_type="created", + to_value={ + "status": ticket.status, + "category_code": normalized["category_code"] or None, + }, + operator=user, + source=TicketActorSource.AGENT, + agent_run_id=agent_run_id, + metadata={"missing_fields": missing_fields, "creation_mode": "auto_intake"}, + ) + + evidence_by_field = evidence_by_field or {} + confidence_by_field = confidence_by_field or {} + human_field_names = human_field_names or set() + for field_name in EDITABLE_TICKET_FIELDS: + value = _field_value(ticket, field_name) + if value in (None, ""): + continue + evidence = evidence_by_field.get(field_name) or {} + if field_name == "title" and "title" in missing_fields: + evidence = {**evidence, "generated_from": "description"} + field_source = ( + TicketActorSource.HUMAN + if field_name in human_field_names + else TicketActorSource.AGENT + ) + record_field_evidence( + ticket, + field_name, + value, + source=field_source, + operator=user, + confidence=( + None + if field_source == TicketActorSource.HUMAN + else confidence_by_field.get(field_name) + ), + evidence=evidence, + agent_run_id=agent_run_id, + is_protected=field_source == TicketActorSource.HUMAN, + ) + from apps.tickets.similarity import schedule_ticket_similarity_refresh + + schedule_ticket_similarity_refresh(ticket) + return ticket, missing_fields + + +@transaction.atomic +def update_ticket_fields( + ticket: Ticket, + changes: dict, + operator=None, + *, + source: str = TicketActorSource.HUMAN, + agent_run_id: int | None = None, + evidence_by_field: dict | None = None, + confidence_by_field: dict | None = None, +) -> tuple[Ticket, dict[str, str]]: + """通过统一入口更新工单字段,并返回被人工保护规则拦截的字段及原因。""" + + unknown_fields = set(changes) - EDITABLE_TICKET_FIELDS + if unknown_fields: + raise TicketDataError(f"不允许修改字段: {', '.join(sorted(unknown_fields))}") + + ticket = Ticket.objects.select_for_update().select_related("category").get(id=ticket.id) + evidence_by_field = evidence_by_field or {} + confidence_by_field = confidence_by_field or {} + before: dict = {} + applied: dict = {} + skipped: dict[str, str] = {} + update_fields: set[str] = set() + + for field_name, value in changes.items(): + if source == TicketActorSource.AGENT and has_protected_field(ticket, field_name): + skipped[field_name] = "protected_by_human" + continue + + before[field_name] = _json_value(_field_value(ticket, field_name)) + if field_name == "tags": + ticket.tags = normalize_tags(value) + update_fields.add("tags") + elif field_name in BUSINESS_TIME_FIELDS: + normalized_datetime = normalize_business_datetime(value) + if value not in (None, "") and normalized_datetime is None: + raise TicketDataError(f"{field_name} 必须包含明确时区") + setattr(ticket, field_name, normalized_datetime) + update_fields.add(field_name) + elif field_name == "assignee_id": + assignee = get_user_model().objects.filter(id=value).first() if value else None + if value and assignee is None: + raise TicketDataError("处理人不存在") + ticket.assignee = assignee + update_fields.add("assignee") + elif field_name == "category_code": + category = ( + TicketCategory.objects.filter(code=value, enabled=True).first() if value else None + ) + if value and category is None: + raise TicketDataError("工单分类无效或已停用") + ticket.category = category + update_fields.add("category") + else: + setattr(ticket, field_name, value) + update_fields.add(field_name) + + applied[field_name] = _json_value(_field_value(ticket, field_name)) + + if update_fields: + ticket.save(update_fields=[*sorted(update_fields), "updated_at"]) + for field_name, value in applied.items(): + record_field_evidence( + ticket, + field_name, + value, + source=source, + operator=operator, + confidence=confidence_by_field.get(field_name), + evidence=evidence_by_field.get(field_name), + agent_run_id=agent_run_id, + is_protected=source == TicketActorSource.HUMAN, + ) + TicketEvent.objects.create( + ticket=ticket, + event_type="updated", + from_value=before, + to_value=applied, + operator=operator, + source=source, + agent_run_id=agent_run_id, + metadata={"skipped_fields": skipped}, + ) + OperationAuditLog.objects.create( + user=operator, + module="tickets", + action="update", + target_type="ticket", + target_id=str(ticket.id), + result="success", + request_snapshot={ + "fields": sorted(applied), + "skipped_fields": skipped, + "source": source, + "agent_run_id": agent_run_id, + }, + ) + from apps.workitems.services import sync_ticket_missing_info_work_item + + outstanding_fields = get_outstanding_intake_fields(ticket) + if ticket.status == TicketStatus.PENDING_INFO and not outstanding_fields: + previous_status = ticket.status + ticket.status = TicketStatus.PENDING + ticket.save(update_fields=["status", "updated_at"]) + TicketEvent.objects.create( + ticket=ticket, + event_type="status_change", + from_value={"status": previous_status}, + to_value={"status": ticket.status}, + operator=operator, + source=TicketActorSource.SYSTEM, + agent_run_id=agent_run_id, + remark="待补全信息已完成,工单进入待处理", + ) + sync_ticket_missing_info_work_item( + ticket, + outstanding_fields, + actor=operator, + ) + if { + "title", + "description", + "problem_performance", + "error_information", + "product_name", + "module_name", + "related_system", + "attempted_actions", + "customer_request", + "current_result", + "tags", + } & set(applied): + from apps.tickets.similarity import schedule_ticket_similarity_refresh + + schedule_ticket_similarity_refresh(ticket) + if ticket.status in {TicketStatus.RESOLVED, TicketStatus.CLOSED}: + schedule_ticket_search_index(ticket.id) + return ticket, skipped + + +def get_ticket_resolution(ticket: Ticket): + from apps.solutions.models import TicketResolution + + return TicketResolution.objects.filter(ticket=ticket).select_related("linked_solution").first() + + +def record_ticket_resolution( + ticket: Ticket, + solution_text: str, + operator=None, + *, + resolution_type: str = "resolved", + root_cause: str = "", + linked_solution=None, + source: str = TicketActorSource.HUMAN, + agent_run_id: int | None = None, +): + from apps.solutions.models import TicketResolution + + normalized_text = solution_text.strip() + if not normalized_text: + raise TicketDataError("解决方案不能为空") + resolution, _ = TicketResolution.objects.update_or_create( + ticket=ticket, + defaults={ + "resolution_type": resolution_type, + "source": source, + "root_cause": root_cause, + "solution_text": normalized_text, + "linked_solution": linked_solution, + "agent_run_id": agent_run_id, + "confirmed_by": operator if source == TicketActorSource.HUMAN else None, + }, + ) + return resolution + + STANDARD_TRANSITIONS: dict[str, set[str]] = { "pending_info": {"pending", "closed"}, "pending": {"in_progress", "closed"}, @@ -163,6 +724,7 @@ def transition_requires_remark( return to_status in {"in_progress", "pending_confirm", "closed"} +@transaction.atomic def transition_ticket( ticket: Ticket, to_status: str, @@ -170,22 +732,38 @@ def transition_ticket( operator=None, *, resolution: str = "", - is_resolved: bool | None = None, replied_to_customer: bool | None = None, + resolution_type: str = "resolved", + root_cause: str = "", + linked_solution=None, + source: str = TicketActorSource.HUMAN, + agent_run_id: int | None = None, ) -> Ticket: + """校验状态机和必需业务结果后完成状态流转、事件记录及后续任务同步。""" + from_status = ticket.status category_code = get_ticket_category_code(ticket) validate_status_transition(from_status, to_status, category_code) + resolution_record = get_ticket_resolution(ticket) + if resolution: + resolution_record = record_ticket_resolution( + ticket, + resolution, + operator, + resolution_type=resolution_type, + root_cause=root_cause, + linked_solution=linked_solution, + source=source, + agent_run_id=agent_run_id, + ) + if to_status == "resolved" and resolution_record is None: + raise TicketDataError("标记已解决前必须提供解决方案") + ticket.status = to_status if to_status == "resolved": - ticket.resolved_at = timezone.now() - ticket.is_resolved = True if is_resolved is None else is_resolved - elif to_status != "closed" and is_resolved is not None: - ticket.is_resolved = is_resolved + ticket.resolved_at = ticket.resolved_at or timezone.now() if to_status == "closed": ticket.closed_at = timezone.now() - if resolution: - ticket.resolution_summary = resolution if replied_to_customer is not None: ticket.replied_to_customer = replied_to_customer ticket.save( @@ -193,23 +771,31 @@ def transition_ticket( "status", "resolved_at", "closed_at", - "resolution_summary", - "is_resolved", "replied_to_customer", "updated_at", ] ) + if from_status == "pending_info" and to_status != "pending_info": + from apps.workitems.services import complete_ticket_missing_info_work_item + + complete_ticket_missing_info_work_item(ticket, actor=operator) + from apps.workitems.services import sync_ticket_close_work_item + + sync_ticket_close_work_item(ticket, actor=operator) TicketEvent.objects.create( ticket=ticket, event_type="status_change", from_value={"status": from_status}, to_value={ "status": to_status, - "resolution": ticket.resolution_summary, - "is_resolved": ticket.is_resolved, + "resolution_id": resolution_record.id if resolution_record else None, + "resolution": resolution_record.solution_text if resolution_record else "", + "is_resolved": resolution_record is not None, "replied_to_customer": ticket.replied_to_customer, }, operator=operator, + source=source, + agent_run_id=agent_run_id, remark=remark, ) OperationAuditLog.objects.create( @@ -219,6 +805,123 @@ def transition_ticket( target_type="ticket", target_id=str(ticket.id), result="success", - request_snapshot={"from": from_status, "to": to_status, "remark": remark}, + request_snapshot={ + "from": from_status, + "to": to_status, + "remark": remark, + "source": source, + "agent_run_id": agent_run_id, + }, ) + if to_status == "resolved": + from apps.knowledge.tasks import dispatch_ticket_knowledge_distillation + + actor_id = operator.id if operator is not None else None + transaction.on_commit( + lambda: dispatch_ticket_knowledge_distillation(ticket.id, actor_id) + ) + schedule_ticket_search_index(ticket.id) + return ticket + + +@transaction.atomic +def adopt_solution_for_ticket( + ticket: Ticket, + solution, + operator=None, + *, + remark: str = "采纳解决方案并标记工单已解决", + replied_to_customer: bool = False, + source: str = TicketActorSource.HUMAN, + agent_run_id: int | None = None, +): + from apps.solutions.models import Solution, TicketSolutionLink + + locked_solution = Solution.objects.select_for_update().get(id=solution.id) + if locked_solution.status != "published": + raise TicketDataError("只能采纳已发布的解决方案") + locked_ticket = ( + Ticket.objects.select_for_update() + .select_related("category", "resolution") + .get(id=ticket.id) + ) + from_status = locked_ticket.status + link, _ = TicketSolutionLink.objects.get_or_create( + ticket=locked_ticket, + solution=locked_solution, + link_type="adopted", + defaults={ + "agent_run_id": agent_run_id, + "created_by": operator, + }, + ) + transition_ticket( + locked_ticket, + TicketStatus.RESOLVED, + remark, + operator, + resolution=locked_solution.solution, + replied_to_customer=replied_to_customer, + root_cause=locked_solution.root_cause, + linked_solution=locked_solution, + source=source, + agent_run_id=agent_run_id, + ) + locked_solution.usage_count = locked_solution.ticket_links.filter( + link_type="adopted" + ).count() + locked_solution.save(update_fields=["usage_count", "updated_at"]) + locked_ticket.events.create( + event_type="solution_adopted", + from_value={"status": from_status}, + to_value={ + "status": TicketStatus.RESOLVED, + "solution_id": locked_solution.id, + "solution_title": locked_solution.title, + }, + operator=operator, + source=source, + agent_run_id=agent_run_id, + remark=remark, + ) + OperationAuditLog.objects.create( + user=operator, + module="tickets", + action="solution_adopted", + target_type="ticket", + target_id=str(locked_ticket.id), + request_snapshot={ + "solution_id": locked_solution.id, + "source": source, + "agent_run_id": agent_run_id, + }, + result="success", + ) + return locked_ticket, link + + +@transaction.atomic +def set_ticket_archived(ticket: Ticket, archived: bool, operator=None) -> Ticket: + ticket = Ticket.objects.select_for_update().get(id=ticket.id) + before = ticket.archived_at + ticket.archived_at = timezone.now() if archived else None + ticket.archived_by = operator if archived else None + ticket.save(update_fields=["archived_at", "archived_by", "updated_at"]) + TicketEvent.objects.create( + ticket=ticket, + event_type="archived" if archived else "restored", + from_value={"archived_at": _json_value(before)}, + to_value={"archived_at": _json_value(ticket.archived_at)}, + operator=operator, + ) + OperationAuditLog.objects.create( + user=operator, + module="tickets", + action="archive" if archived else "restore", + target_type="ticket", + target_id=str(ticket.id), + result="success", + request_snapshot={}, + ) + schedule_ticket_search_index(ticket.id) return ticket diff --git a/backend/apps/tickets/tests/test_ticket_api.py b/backend/apps/tickets/tests/test_ticket_api.py index d8f16af..922382a 100644 --- a/backend/apps/tickets/tests/test_ticket_api.py +++ b/backend/apps/tickets/tests/test_ticket_api.py @@ -3,7 +3,14 @@ from datetime import timedelta from django.contrib.auth import get_user_model from django.utils import timezone -from apps.tickets.models import Ticket, TicketCategory, TicketEvent +from apps.solutions.models import TicketResolution +from apps.tickets.models import ( + Ticket, + TicketCategory, + TicketCollaborator, + TicketComment, + TicketEvent, +) def test_create_ticket_returns_ticket_detail(admin_client, db): @@ -205,8 +212,8 @@ def test_business_ticket_can_be_resolved_directly_with_resolution(admin_client, assert response.status_code == 200 ticket.refresh_from_db() assert ticket.status == "resolved" - assert ticket.resolution_summary == "已查询采集进度,当前已完成 80%。" - assert ticket.is_resolved is True + assert ticket.resolution.solution_text == "已查询采集进度,当前已完成 80%。" + assert response.json()["is_resolved"] is True assert ticket.replied_to_customer is True @@ -238,20 +245,19 @@ def test_closing_resolved_ticket_keeps_resolved_flag(admin_client, db): title="已解决待关闭", description="A", status="resolved", - is_resolved=True, - resolution_summary="已完成处理", ) + TicketResolution.objects.create(ticket=ticket, solution_text="已完成处理") response = admin_client.post( f"/api/tickets/{ticket.id}/status", - data={"status": "closed", "remark": "已回复", "is_resolved": False}, + data={"status": "closed", "remark": "已回复"}, content_type="application/json", ) assert response.status_code == 200 ticket.refresh_from_db() assert ticket.status == "closed" - assert ticket.is_resolved is True + assert response.json()["is_resolved"] is True def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db): @@ -287,3 +293,88 @@ def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db) assert payload[1]["to_value"] == {"status": "in_progress"} assert payload[1]["remark"] == "开始排查认证链路" assert payload[1]["operator"]["display_name"] == "admin" + + +def test_ticket_comments_return_history_author_and_validate_visibility(admin_client, db): + ticket = Ticket.objects.create( + ticket_no="TK202607270301", + title="评论历史", + description="需要协同排查", + ) + + created = admin_client.post( + f"/api/tickets/{ticket.id}/comments", + data={ + "content": " 已检查应用日志,等待数据库同事反馈。 ", + "comment_type": "internal_note", + "visibility": "internal", + }, + content_type="application/json", + ) + + assert created.status_code == 200 + assert created.json()["content"] == "已检查应用日志,等待数据库同事反馈。" + assert created.json()["created_by"]["username"] == "admin" + history = admin_client.get(f"/api/tickets/{ticket.id}/comments") + assert history.status_code == 200 + assert history.json()[0]["created_by"]["display_name"] == "admin" + assert TicketEvent.objects.filter(ticket=ticket, event_type="commented").count() == 1 + + mismatched = admin_client.post( + f"/api/tickets/{ticket.id}/comments", + data={ + "content": "客户可见内容", + "comment_type": "user_reply", + "visibility": "internal", + }, + content_type="application/json", + ) + assert mismatched.status_code == 400 + assert TicketComment.objects.filter(ticket=ticket).count() == 1 + + +def test_ticket_collaboration_lists_active_users_and_is_idempotent(admin_client, db): + user_model = get_user_model() + active_user = user_model.objects.create_user( + username="collaborator", + first_name="协作成员", + is_active=True, + ) + user_model.objects.create_user(username="disabled-collaborator", is_active=False) + ticket = Ticket.objects.create( + ticket_no="TK202607270302", + title="协作管理", + description="需要多人处理", + ) + + participants = admin_client.get("/api/tickets/participants") + assert participants.status_code == 200 + usernames = {item["username"] for item in participants.json()} + assert "collaborator" in usernames + assert "disabled-collaborator" not in usernames + + payload = {"user_id": active_user.id, "role": "collaborator"} + first = admin_client.post( + f"/api/tickets/{ticket.id}/collaborators", + data=payload, + content_type="application/json", + ) + duplicate = admin_client.post( + f"/api/tickets/{ticket.id}/collaborators", + data=payload, + content_type="application/json", + ) + assert first.status_code == duplicate.status_code == 200 + assert first.json()["id"] == duplicate.json()["id"] + assert TicketCollaborator.objects.filter(ticket=ticket).count() == 1 + assert TicketEvent.objects.filter(ticket=ticket, event_type="collaborator_added").count() == 1 + + listed = admin_client.get(f"/api/tickets/{ticket.id}/collaborators") + assert listed.status_code == 200 + assert listed.json()[0]["user"]["display_name"] == "协作成员" + + removed = admin_client.delete( + f"/api/tickets/{ticket.id}/collaborators/{first.json()['id']}" + ) + assert removed.status_code == 200 + assert TicketCollaborator.objects.filter(ticket=ticket).count() == 0 diff --git a/backend/apps/tickets/tests/test_ticket_domain_baseline.py b/backend/apps/tickets/tests/test_ticket_domain_baseline.py new file mode 100644 index 0000000..b30ed13 --- /dev/null +++ b/backend/apps/tickets/tests/test_ticket_domain_baseline.py @@ -0,0 +1,109 @@ +from django.contrib.auth import get_user_model + +from apps.tickets.models import Ticket, TicketActorSource, TicketFieldEvidence +from apps.tickets.services import update_ticket_fields + + +def test_agent_update_does_not_overwrite_human_protected_field(db): + user = get_user_model().objects.create_user(username="support-field-owner") + ticket = Ticket.objects.create( + ticket_no="TK202607250001", + title="原始标题", + description="A", + ) + ticket, _ = update_ticket_fields( + ticket, + {"title": "人工确认标题"}, + user, + source=TicketActorSource.HUMAN, + ) + + ticket, skipped = update_ticket_fields( + ticket, + {"title": "Agent 建议标题"}, + source=TicketActorSource.AGENT, + agent_run_id=42, + ) + + ticket.refresh_from_db() + assert ticket.title == "人工确认标题" + assert skipped == {"title": "protected_by_human"} + latest = TicketFieldEvidence.objects.filter(ticket=ticket, field_name="title").first() + assert latest.source == TicketActorSource.HUMAN + assert latest.is_protected is True + + +def test_agent_update_records_evidence_for_unprotected_field(db): + ticket = Ticket.objects.create( + ticket_no="TK202607250002", + title="原始标题", + description="A", + ) + + ticket, skipped = update_ticket_fields( + ticket, + {"related_system": "CRM"}, + source=TicketActorSource.AGENT, + agent_run_id=99, + confidence_by_field={"related_system": 0.93}, + evidence_by_field={"related_system": {"quote": "CRM 接口返回 500"}}, + ) + + assert skipped == {} + evidence = TicketFieldEvidence.objects.get(ticket=ticket, field_name="related_system") + assert evidence.value == "CRM" + assert evidence.source == TicketActorSource.AGENT + assert evidence.agent_run_id == 99 + assert float(evidence.confidence) == 0.93 + assert evidence.is_protected is False + + +def test_archive_hides_ticket_from_default_list_and_restore_recovers_it(admin_client, db): + ticket = Ticket.objects.create( + ticket_no="TK202607250003", + title="待归档", + description="A", + ) + + archived = admin_client.post(f"/api/tickets/{ticket.id}/archive") + default_list = admin_client.get("/api/tickets") + archived_list = admin_client.get("/api/tickets?include_archived=true") + + assert archived.status_code == 200 + assert archived.json()["archived_at"] is not None + assert default_list.json()["total"] == 0 + assert archived_list.json()["total"] == 1 + + restored = admin_client.post(f"/api/tickets/{ticket.id}/restore") + + assert restored.status_code == 200 + assert restored.json()["archived_at"] is None + assert admin_client.get("/api/tickets").json()["total"] == 1 + + +def test_ticket_collaborators_can_be_added_listed_and_removed(admin_client, db): + collaborator = get_user_model().objects.create_user(username="ticket-collaborator") + ticket = Ticket.objects.create( + ticket_no="TK202607250004", + title="需要协作", + description="A", + ) + + created = admin_client.post( + f"/api/tickets/{ticket.id}/collaborators", + data={"user_id": collaborator.id, "role": "watcher"}, + content_type="application/json", + ) + + assert created.status_code == 200 + assert created.json()["user"]["username"] == "ticket-collaborator" + assert created.json()["role"] == "watcher" + listed = admin_client.get(f"/api/tickets/{ticket.id}/collaborators") + assert [item["id"] for item in listed.json()] == [created.json()["id"]] + + removed = admin_client.delete( + f"/api/tickets/{ticket.id}/collaborators/{created.json()['id']}" + ) + + assert removed.status_code == 200 + assert admin_client.get(f"/api/tickets/{ticket.id}/collaborators").json() == [] diff --git a/frontend/src/features/tickets/TicketCollaborationPanel.test.tsx b/frontend/src/features/tickets/TicketCollaborationPanel.test.tsx new file mode 100644 index 0000000..48f0e7b --- /dev/null +++ b/frontend/src/features/tickets/TicketCollaborationPanel.test.tsx @@ -0,0 +1,105 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { TicketCollaborator, TicketUser } from './types' + +Object.defineProperties(Element.prototype, { + hasPointerCapture: { value: vi.fn(() => false), configurable: true }, + setPointerCapture: { value: vi.fn(), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + scrollIntoView: { value: vi.fn(), configurable: true } +}) + +const listTicketCollaborators = vi.fn() +const listTicketParticipants = vi.fn() +const addTicketCollaborator = vi.fn() +const removeTicketCollaborator = vi.fn() + +vi.mock('./api', () => ({ + listTicketCollaborators: (...args: unknown[]) => listTicketCollaborators(...args), + listTicketParticipants: (...args: unknown[]) => listTicketParticipants(...args), + addTicketCollaborator: (...args: unknown[]) => addTicketCollaborator(...args), + removeTicketCollaborator: (...args: unknown[]) => removeTicketCollaborator(...args) +})) + +import { TicketCollaborationPanel } from './TicketCollaborationPanel' + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const current: TicketCollaborator = { + id: 11, + user: { id: 3, username: 'support-a', display_name: '支持甲' }, + role: 'collaborator', + added_by: { id: 1, username: 'admin', display_name: '管理员' }, + created_at: '2026-07-27T08:00:00Z' +} + +const watcher: TicketUser = { + id: 4, + username: 'support-b', + display_name: '支持乙' +} + +describe('TicketCollaborationPanel', () => { + it('展示现有成员并允许移除', async () => { + const user = userEvent.setup() + listTicketCollaborators.mockResolvedValue([current]) + listTicketParticipants.mockResolvedValue([current.user, watcher]) + removeTicketCollaborator.mockResolvedValue({ success: true }) + const onChanged = vi.fn() + + render() + + expect(await screen.findByText('支持甲')).toBeInTheDocument() + expect(screen.getAllByText('协作人')).not.toHaveLength(0) + await user.click(screen.getByRole('button', { name: '移除支持甲' })) + + await waitFor(() => { + expect(removeTicketCollaborator).toHaveBeenCalledWith(7, 11) + }) + expect(screen.queryByText('支持甲')).not.toBeInTheDocument() + expect(onChanged).toHaveBeenCalledOnce() + }) + + it('从启用账号中添加关注人', async () => { + const user = userEvent.setup() + listTicketCollaborators.mockResolvedValue([]) + listTicketParticipants.mockResolvedValue([watcher]) + addTicketCollaborator.mockResolvedValue({ + id: 12, + user: watcher, + role: 'watcher', + added_by: current.added_by, + created_at: '2026-07-27T08:20:00Z' + } satisfies TicketCollaborator) + + render() + + await screen.findByText('暂无协作成员') + await user.click(screen.getByRole('combobox', { name: '选择协作成员' })) + await user.click(await screen.findByRole('option', { name: /支持乙/ })) + await user.click(screen.getByRole('combobox', { name: '选择协作角色' })) + await user.click(await screen.findByRole('option', { name: '关注人' })) + await user.click(screen.getByRole('button', { name: '添加成员' })) + + await waitFor(() => { + expect(addTicketCollaborator).toHaveBeenCalledWith(7, 4, 'watcher') + }) + expect(await screen.findByText('支持乙')).toBeInTheDocument() + }) + + it('只读账号不加载参与人目录也不显示管理按钮', async () => { + listTicketCollaborators.mockResolvedValue([current]) + + render() + + expect(await screen.findByText('支持甲')).toBeInTheDocument() + expect(listTicketParticipants).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: '移除支持甲' })).not.toBeInTheDocument() + expect(screen.getByText(/没有分派权限/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/tickets/TicketCollaborationPanel.tsx b/frontend/src/features/tickets/TicketCollaborationPanel.tsx new file mode 100644 index 0000000..9647397 --- /dev/null +++ b/frontend/src/features/tickets/TicketCollaborationPanel.tsx @@ -0,0 +1,234 @@ +import * as React from 'react' +import { Eye, Handshake, Trash2, UserPlus, Users } from 'lucide-react' +import { toast } from 'sonner' + +import { AppEmptyState } from '@/components/app/AppEmptyState' +import { AppSection } from '@/components/app/AppSection' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' + +import { + addTicketCollaborator, + listTicketCollaborators, + listTicketParticipants, + removeTicketCollaborator +} from './api' +import type { + TicketCollaborator, + TicketCollaboratorRole, + TicketUser +} from './types' + +type TicketCollaborationPanelProps = { + ticketId: number + canManage: boolean + onChanged?: () => void +} + +function displayUser(user: TicketUser) { + return user.display_name || user.username +} + +function roleLabel(role: TicketCollaboratorRole) { + return role === 'watcher' ? '关注人' : '协作人' +} + +export function TicketCollaborationPanel({ + ticketId, + canManage, + onChanged +}: TicketCollaborationPanelProps) { + const [collaborators, setCollaborators] = React.useState([]) + const [participants, setParticipants] = React.useState([]) + const [selectedUserId, setSelectedUserId] = React.useState('') + const [role, setRole] = React.useState('collaborator') + const [loading, setLoading] = React.useState(true) + const [saving, setSaving] = React.useState(false) + const [error, setError] = React.useState('') + + const load = React.useCallback(async () => { + try { + const [nextCollaborators, nextParticipants] = await Promise.all([ + listTicketCollaborators(ticketId), + canManage ? listTicketParticipants() : Promise.resolve([]) + ]) + setCollaborators(nextCollaborators) + setParticipants(nextParticipants) + setError('') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '协作成员加载失败') + } finally { + setLoading(false) + } + }, [canManage, ticketId]) + + React.useEffect(() => { + setLoading(true) + setSelectedUserId('') + void load() + }, [load]) + + const assignedUserIds = React.useMemo( + () => new Set(collaborators.map((item) => item.user.id)), + [collaborators] + ) + const availableParticipants = participants.filter((user) => !assignedUserIds.has(user.id)) + + async function handleAdd() { + if (!selectedUserId) return + setSaving(true) + setError('') + try { + const created = await addTicketCollaborator(ticketId, Number(selectedUserId), role) + setCollaborators((current) => [...current, created]) + setSelectedUserId('') + onChanged?.() + toast.success(`${roleLabel(role)}已添加`) + } catch (reason) { + const message = reason instanceof Error ? reason.message : '添加协作成员失败' + setError(message) + toast.error(message) + } finally { + setSaving(false) + } + } + + async function handleRemove(item: TicketCollaborator) { + setSaving(true) + setError('') + try { + await removeTicketCollaborator(ticketId, item.id) + setCollaborators((current) => current.filter((candidate) => candidate.id !== item.id)) + onChanged?.() + toast.success(`${roleLabel(item.role)}已移除`) + } catch (reason) { + const message = reason instanceof Error ? reason.message : '移除协作成员失败' + setError(message) + toast.error(message) + } finally { + setSaving(false) + } + } + + return ( + +
+ {error ? ( + + 协作成员暂不可用 + {error} + + ) : null} + + {loading ? ( + + ) : collaborators.length === 0 ? ( + + ) : ( +
    + {collaborators.map((item) => { + const RoleIcon = item.role === 'watcher' ? Eye : Handshake + return ( +
  • +
    +

    {displayUser(item.user)}

    +
    + + + {roleLabel(item.role)} + + + @{item.user.username} + +
    +
    + {canManage ? ( + + ) : null} +
  • + ) + })} +
+ )} + + {canManage ? ( +
+
+ + +
+ + {availableParticipants.length === 0 && participants.length > 0 ? ( +

所有启用账号均已加入此工单。

+ ) : null} +
+ ) : ( +

+ + 当前账号没有分派权限,只能查看成员。 +

+ )} +
+
+ ) +} diff --git a/frontend/src/features/tickets/TicketComments.test.tsx b/frontend/src/features/tickets/TicketComments.test.tsx new file mode 100644 index 0000000..3a98a6b --- /dev/null +++ b/frontend/src/features/tickets/TicketComments.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { TicketComment } from './types' + +const listTicketComments = vi.fn() +const addTicketComment = vi.fn() + +vi.mock('./api', () => ({ + listTicketComments: (...args: unknown[]) => listTicketComments(...args), + addTicketComment: (...args: unknown[]) => addTicketComment(...args) +})) + +import { TicketComments } from './TicketComments' + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const history: TicketComment[] = [ + { + id: 1, + content: '已检查应用日志,等待数据库反馈。', + comment_type: 'internal_note', + visibility: 'internal', + created_by: { id: 3, username: 'support', display_name: '技术支持' }, + created_at: '2026-07-27T08:00:00Z' + } +] + +describe('TicketComments', () => { + it('加载完整历史,并新增内部备注后保留在时间线', async () => { + const user = userEvent.setup() + const created: TicketComment = { + ...history[0], + id: 2, + content: '数据库连接数正常,继续检查网关。', + created_at: '2026-07-27T08:10:00Z' + } + listTicketComments.mockResolvedValue(history) + addTicketComment.mockResolvedValue(created) + const onCommentAdded = vi.fn() + + render() + + expect(await screen.findByText('已检查应用日志,等待数据库反馈。')).toBeInTheDocument() + expect(screen.getByText('技术支持')).toBeInTheDocument() + expect(screen.getByText('仅内部')).toBeInTheDocument() + + await user.type(screen.getByRole('textbox', { name: '评论内容' }), created.content) + await user.click(screen.getByRole('button', { name: '保存内部备注' })) + + await waitFor(() => { + expect(addTicketComment).toHaveBeenCalledWith(7, created.content, { + comment_type: 'internal_note', + visibility: 'internal' + }) + }) + expect(await screen.findByText(created.content)).toBeInTheDocument() + expect(onCommentAdded).toHaveBeenCalledOnce() + }) + + it('只读账号可查看历史但不能填写评论', async () => { + listTicketComments.mockResolvedValue(history) + + render() + + expect(await screen.findByText(history[0].content)).toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: '评论内容' })).not.toBeInTheDocument() + expect(screen.getByText(/只有查看权限/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/tickets/TicketComments.tsx b/frontend/src/features/tickets/TicketComments.tsx index cc5f4e0..73b9389 100644 --- a/frontend/src/features/tickets/TicketComments.tsx +++ b/frontend/src/features/tickets/TicketComments.tsx @@ -1,70 +1,193 @@ import * as React from 'react' +import { Eye, LockKeyhole, MessageSquareText, Send } from 'lucide-react' +import { toast } from 'sonner' +import { AppEmptyState } from '@/components/app/AppEmptyState' import { AppSection } from '@/components/app/AppSection' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' +import { formatDateTime } from '@/lib/format' -import { addTicketComment } from './api' +import { addTicketComment, listTicketComments } from './api' +import type { TicketComment } from './types' type TicketCommentsProps = { ticketId: number + canComment: boolean onCommentAdded?: () => void } -export function TicketComments({ ticketId, onCommentAdded }: TicketCommentsProps) { +function commentAuthor(comment: TicketComment) { + return comment.created_by?.display_name || comment.created_by?.username || '系统' +} + +export function TicketComments({ + ticketId, + canComment, + onCommentAdded +}: TicketCommentsProps) { + const [comments, setComments] = React.useState([]) const [content, setContent] = React.useState('') - const [lastComment, setLastComment] = React.useState('') - const [commentMode, setCommentMode] = React.useState('internal') + const [commentMode, setCommentMode] = React.useState<'internal' | 'public'>('internal') + const [loading, setLoading] = React.useState(true) + const [submitting, setSubmitting] = React.useState(false) + const [error, setError] = React.useState('') + + const loadComments = React.useCallback(async () => { + try { + setComments(await listTicketComments(ticketId)) + setError('') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '评论历史加载失败') + } finally { + setLoading(false) + } + }, [ticketId]) + + React.useEffect(() => { + setLoading(true) + void loadComments() + }, [loadComments]) async function handleSubmit(event: React.FormEvent) { event.preventDefault() - if (!content.trim()) return - const comment = await addTicketComment(ticketId, content, { - comment_type: commentMode === 'public' ? 'user_reply' : 'internal_note', - visibility: commentMode === 'public' ? 'public' : 'internal' - }) - setLastComment(comment.content) - setContent('') - onCommentAdded?.() + const nextContent = content.trim() + if (!nextContent || !canComment) return + setSubmitting(true) + setError('') + try { + const comment = await addTicketComment(ticketId, nextContent, { + comment_type: commentMode === 'public' ? 'user_reply' : 'internal_note', + visibility: commentMode === 'public' ? 'public' : 'internal' + }) + setComments((current) => [...current, comment]) + setContent('') + onCommentAdded?.() + toast.success(commentMode === 'public' ? '客户可见记录已保存' : '内部备注已保存') + } catch (reason) { + const message = reason instanceof Error ? reason.message : '评论保存失败' + setError(message) + toast.error(message) + } finally { + setSubmitting(false) + } } return ( -
- - {commentMode === 'public' ? '用户可以看到此回复' : '仅内部成员可见'} - - - } - > -
- -