diff --git a/backend/apps/tickets/api.py b/backend/apps/tickets/api.py index 9fb594a..40ab327 100644 --- a/backend/apps/tickets/api.py +++ b/backend/apps/tickets/api.py @@ -10,7 +10,7 @@ from ninja.errors import HttpError from apps.accounts.api import require_permission from apps.audit.models import OperationAuditLog -from apps.tickets.models import Ticket, TicketAttachment, TicketComment, TicketEvent +from apps.tickets.models import Ticket, TicketAttachment, TicketCategory, TicketComment, TicketEvent from apps.tickets.schemas import ( TicketAttachmentIn, TicketAttachmentOut, @@ -23,7 +23,7 @@ from apps.tickets.schemas import ( TicketStatusIn, TicketUpdateIn, ) -from apps.tickets.services import transition_ticket +from apps.tickets.services import TicketStatusError, transition_requires_remark, transition_ticket router = Router(tags=["tickets"]) @@ -44,14 +44,35 @@ def serialize_ticket_user(user): } +def serialize_ticket_category(category): + if category is None: + return None + return { + "id": category.id, + "code": category.code, + "name": category.name, + "description": category.description, + } + + def serialize_ticket(ticket: Ticket) -> dict: return { "id": ticket.id, "ticket_no": ticket.ticket_no, "title": ticket.title, "description": ticket.description, + "category": serialize_ticket_category(ticket.category), + "category_code": ticket.category.code if ticket.category_id else None, "status": ticket.status, "priority": ticket.priority, + "problem_performance": ticket.problem_performance, + "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, + "replied_to_customer": ticket.replied_to_customer, "impact_scope": ticket.impact_scope, "environment": ticket.environment, "related_system": ticket.related_system, @@ -82,11 +103,21 @@ def create_ticket(request, payload: TicketIn): assignee = None if payload.assignee_id: assignee = get_user_model().objects.filter(id=payload.assignee_id).first() + category = None + if payload.category_code: + category = TicketCategory.objects.filter(code=payload.category_code, enabled=True).first() + if category is None: + raise HttpError(400, "Unknown ticket category") ticket = Ticket.objects.create( ticket_no=next_ticket_no(), title=payload.title, description=payload.description, + category=category, priority=payload.priority, + problem_performance=payload.problem_performance, + problem_object=payload.problem_object, + submitter_name=payload.submitter_name, + submitter_org=payload.submitter_org, impact_scope=payload.impact_scope, environment=payload.environment, related_system=payload.related_system, @@ -97,7 +128,7 @@ def create_ticket(request, payload: TicketIn): TicketEvent.objects.create( ticket=ticket, event_type="created", - to_value={"status": ticket.status}, + to_value={"status": ticket.status, "category_code": payload.category_code}, operator=request.user if request.user.is_authenticated else None, ) return serialize_ticket(ticket) @@ -110,22 +141,29 @@ def list_tickets( page_size: int = 20, status: str | None = None, priority: str | None = None, + category_code: str | None = None, q: str | None = None, related_system: str | None = None, assignee_id: int | None = None, view: str = "all", ): require_permission(request, "ticket.view") - queryset = Ticket.objects.select_related("requester", "assignee").all() + queryset = Ticket.objects.select_related("requester", "assignee", "category").all() if status: queryset = queryset.filter(status=status) if priority: queryset = queryset.filter(priority=priority) + if category_code: + queryset = queryset.filter(category__code=category_code) if q: queryset = queryset.filter( Q(ticket_no__icontains=q) | Q(title__icontains=q) | Q(description__icontains=q) + | Q(problem_performance__icontains=q) + | Q(problem_object__icontains=q) + | Q(submitter_name__icontains=q) + | Q(submitter_org__icontains=q) ) if related_system: queryset = queryset.filter(related_system__icontains=related_system) @@ -185,7 +223,7 @@ def export_tickets(request): def get_ticket(request, ticket_id: int): require_permission(request, "ticket.view") return serialize_ticket( - Ticket.objects.select_related("requester", "assignee").get(id=ticket_id) + Ticket.objects.select_related("requester", "assignee", "category").get(id=ticket_id) ) @@ -202,7 +240,15 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn): 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, @@ -211,6 +257,15 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn): 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() @@ -227,15 +282,29 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn): @router.post("/{ticket_id}/status", response=TicketOut) def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn): require_permission(request, "ticket.edit") - if not payload.remark.strip(): - raise HttpError(400, "Transition remark is required") - ticket = Ticket.objects.get(id=ticket_id) - updated = transition_ticket( - ticket, + 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()) + if transition_requires_remark( + ticket.status, payload.status, - payload.remark, - request.user if request.user.is_authenticated else None, - ) + category_code, + ) and not payload.remark.strip(): + raise HttpError(400, "Transition remark is required") + if payload.status == "resolved" and not (payload.remark.strip() or has_resolution): + raise HttpError(400, "Resolution or transition remark is required") + try: + updated = transition_ticket( + ticket, + payload.status, + 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: + raise HttpError(400, str(error)) from error return serialize_ticket(updated) diff --git a/backend/apps/tickets/migrations/0002_ticket_context_and_standard_categories.py b/backend/apps/tickets/migrations/0002_ticket_context_and_standard_categories.py new file mode 100644 index 0000000..3ceb799 --- /dev/null +++ b/backend/apps/tickets/migrations/0002_ticket_context_and_standard_categories.py @@ -0,0 +1,78 @@ +from django.db import migrations, models + +STANDARD_CATEGORIES = [ + ("business_issue", "业务问题", "业务进度、业务规则、业务状态确认等支持请求。", 10), + ("technical_issue", "技术问题", "系统异常、接口故障、服务不可用等技术排查请求。", 20), + ("data_issue", "数据问题", "数据缺失、数据不一致、采集或同步结果异常。", 30), + ("operation_issue", "操作问题", "用户操作指导、配置入口、权限使用等支持请求。", 40), +] + + +def seed_standard_categories(apps, schema_editor): + TicketCategory = apps.get_model("tickets", "TicketCategory") + for code, name, description, sort_order in STANDARD_CATEGORIES: + TicketCategory.objects.update_or_create( + code=code, + defaults={ + "name": name, + "description": description, + "enabled": True, + "sort_order": sort_order, + }, + ) + + +def remove_standard_categories(apps, schema_editor): + TicketCategory = apps.get_model("tickets", "TicketCategory") + category_codes = [category[0] for category in STANDARD_CATEGORIES] + TicketCategory.objects.filter(code__in=category_codes).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("tickets", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="ticket", + name="ai_solution", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="ticket", + name="is_resolved", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="ticket", + name="problem_object", + field=models.CharField(blank=True, max_length=160), + ), + migrations.AddField( + model_name="ticket", + name="problem_performance", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="ticket", + name="replied_to_customer", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="ticket", + name="resolution_summary", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="ticket", + name="submitter_name", + field=models.CharField(blank=True, max_length=80), + ), + migrations.AddField( + model_name="ticket", + name="submitter_org", + field=models.CharField(blank=True, max_length=160), + ), + migrations.RunPython(seed_standard_categories, remove_standard_categories), + ] diff --git a/backend/apps/tickets/models.py b/backend/apps/tickets/models.py index 01ab19a..604c281 100644 --- a/backend/apps/tickets/models.py +++ b/backend/apps/tickets/models.py @@ -48,6 +48,14 @@ class Ticket(models.Model): choices=TicketPriority.choices, default=TicketPriority.MEDIUM, ) + problem_performance = models.TextField(blank=True) + 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) diff --git a/backend/apps/tickets/schemas.py b/backend/apps/tickets/schemas.py index 9c54ee2..faac204 100644 --- a/backend/apps/tickets/schemas.py +++ b/backend/apps/tickets/schemas.py @@ -6,7 +6,12 @@ from ninja import Schema class TicketIn(Schema): title: str description: str + category_code: str | None = None priority: str = "medium" + problem_performance: str = "" + problem_object: str = "" + submitter_name: str = "" + submitter_org: str = "" impact_scope: str = "" environment: str = "" related_system: str = "" @@ -16,7 +21,15 @@ class TicketIn(Schema): class TicketUpdateIn(Schema): title: str | None = None description: str | None = None + category_code: str | None = None priority: str | None = None + problem_performance: str | None = None + 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 @@ -26,6 +39,9 @@ class TicketUpdateIn(Schema): class TicketStatusIn(Schema): status: str remark: str = "" + resolution: str = "" + is_resolved: bool | None = None + replied_to_customer: bool | None = None class TicketUserOut(Schema): @@ -34,13 +50,30 @@ class TicketUserOut(Schema): display_name: str +class TicketCategoryOut(Schema): + id: int + code: str + name: str + description: str + + class TicketOut(Schema): id: int ticket_no: str title: str description: str + category: TicketCategoryOut | None = None + category_code: str | None = None status: str priority: str + problem_performance: str + problem_object: str + submitter_name: str + submitter_org: str + ai_solution: str + resolution: str + is_resolved: bool + replied_to_customer: bool impact_scope: str environment: str related_system: str diff --git a/backend/apps/tickets/services.py b/backend/apps/tickets/services.py index 1d987cb..9cbaff0 100644 --- a/backend/apps/tickets/services.py +++ b/backend/apps/tickets/services.py @@ -8,7 +8,7 @@ class TicketStatusError(ValueError): pass -ALLOWED_TRANSITIONS: dict[str, set[str]] = { +STANDARD_TRANSITIONS: dict[str, set[str]] = { "pending_info": {"pending", "closed"}, "pending": {"in_progress", "closed"}, "in_progress": {"pending_confirm"}, @@ -17,26 +17,113 @@ ALLOWED_TRANSITIONS: dict[str, set[str]] = { "closed": set(), } +SHORT_TRANSITIONS: dict[str, set[str]] = { + "pending_info": {"pending", "resolved", "closed"}, + "pending": {"resolved", "closed"}, + "in_progress": {"resolved", "closed"}, + "pending_confirm": {"resolved", "closed"}, + "resolved": {"closed"}, + "closed": set(), +} -def validate_status_transition(from_status: str, to_status: str) -> None: - if to_status not in ALLOWED_TRANSITIONS.get(from_status, set()): +MEDIUM_TRANSITIONS: dict[str, set[str]] = { + "pending_info": {"pending", "closed"}, + "pending": {"in_progress", "resolved", "closed"}, + "in_progress": {"resolved", "pending_confirm", "closed"}, + "pending_confirm": {"resolved", "in_progress", "closed"}, + "resolved": {"closed"}, + "closed": set(), +} + +CATEGORY_TRANSITION_POLICIES = { + "business_issue": SHORT_TRANSITIONS, + "operation_issue": SHORT_TRANSITIONS, + "data_issue": MEDIUM_TRANSITIONS, + "technical_issue": STANDARD_TRANSITIONS, +} + +# Backward compatible export used by older tests and internal imports. +ALLOWED_TRANSITIONS = STANDARD_TRANSITIONS + + +def get_ticket_category_code(ticket: Ticket) -> str | None: + if ticket.category_id and ticket.category: + return ticket.category.code + return None + + +def get_allowed_transitions(from_status: str, category_code: str | None = None) -> set[str]: + transitions = CATEGORY_TRANSITION_POLICIES.get(category_code or "", STANDARD_TRANSITIONS) + return transitions.get(from_status, set()) + + +def validate_status_transition( + from_status: str, + to_status: str, + category_code: str | None = None, +) -> None: + if to_status not in get_allowed_transitions(from_status, category_code): raise TicketStatusError(f"Invalid ticket status transition: {from_status} -> {to_status}") -def transition_ticket(ticket: Ticket, to_status: str, remark: str = "", operator=None) -> Ticket: +def transition_requires_remark( + from_status: str, + to_status: str, + category_code: str | None = None, +) -> bool: + if to_status == "resolved": + return False + if category_code in {"business_issue", "operation_issue"} and to_status == "closed": + return False + return to_status in {"in_progress", "pending_confirm", "closed"} + + +def transition_ticket( + ticket: Ticket, + to_status: str, + remark: str = "", + operator=None, + *, + resolution: str = "", + is_resolved: bool | None = None, + replied_to_customer: bool | None = None, +) -> Ticket: from_status = ticket.status - validate_status_transition(from_status, to_status) + category_code = get_ticket_category_code(ticket) + validate_status_transition(from_status, to_status, category_code) 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 is_resolved is not None: + ticket.is_resolved = is_resolved if to_status == "closed": ticket.closed_at = timezone.now() - ticket.save(update_fields=["status", "resolved_at", "closed_at", "updated_at"]) + if resolution: + ticket.resolution_summary = resolution + if replied_to_customer is not None: + ticket.replied_to_customer = replied_to_customer + ticket.save( + update_fields=[ + "status", + "resolved_at", + "closed_at", + "resolution_summary", + "is_resolved", + "replied_to_customer", + "updated_at", + ] + ) TicketEvent.objects.create( ticket=ticket, event_type="status_change", from_value={"status": from_status}, - to_value={"status": to_status}, + to_value={ + "status": to_status, + "resolution": ticket.resolution_summary, + "is_resolved": ticket.is_resolved, + "replied_to_customer": ticket.replied_to_customer, + }, operator=operator, remark=remark, ) diff --git a/backend/apps/tickets/tests/test_ticket_api.py b/backend/apps/tickets/tests/test_ticket_api.py index 565da02..dfa320c 100644 --- a/backend/apps/tickets/tests/test_ticket_api.py +++ b/backend/apps/tickets/tests/test_ticket_api.py @@ -1,15 +1,21 @@ from django.contrib.auth import get_user_model -from apps.tickets.models import Ticket, TicketEvent +from apps.tickets.models import Ticket, TicketCategory, TicketEvent def test_create_ticket_returns_ticket_detail(admin_client, db): + TicketCategory.objects.get_or_create(code="business_issue", defaults={"name": "业务问题"}) response = admin_client.post( "/api/tickets", data={ "title": "Payment callback fails", "description": "Callback returns HTTP 500 in production", + "category_code": "business_issue", "priority": "high", + "problem_performance": "采集进度未更新", + "problem_object": "客户 A", + "submitter_name": "张三", + "submitter_org": "华东分公司", "impact_scope": "payment users", "environment": "production", "related_system": "payment", @@ -21,6 +27,10 @@ def test_create_ticket_returns_ticket_detail(admin_client, db): payload = response.json() assert payload["ticket_no"].startswith("TK") assert payload["title"] == "Payment callback fails" + assert payload["category"]["name"] == "业务问题" + assert payload["category_code"] == "business_issue" + assert payload["problem_object"] == "客户 A" + assert payload["submitter_org"] == "华东分公司" assert payload["status"] == "pending" assert TicketEvent.objects.filter(ticket_id=payload["id"], event_type="created").exists() @@ -129,6 +139,59 @@ def test_transition_ticket_requires_non_empty_remark(admin_client, db): assert response.status_code == 400 +def test_business_ticket_can_be_resolved_directly_with_resolution(admin_client, db): + category, _ = TicketCategory.objects.get_or_create( + code="business_issue", + defaults={"name": "业务问题"}, + ) + ticket = Ticket.objects.create( + ticket_no="TK202607010104", + title="查询采集进度", + description="客户需要确认采集进度", + status="pending", + category=category, + ) + + response = admin_client.post( + f"/api/tickets/{ticket.id}/status", + data={ + "status": "resolved", + "resolution": "已查询采集进度,当前已完成 80%。", + "replied_to_customer": True, + }, + content_type="application/json", + ) + + 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.replied_to_customer is True + + +def test_technical_ticket_cannot_be_resolved_directly(admin_client, db): + category, _ = TicketCategory.objects.get_or_create( + code="technical_issue", + defaults={"name": "技术问题"}, + ) + ticket = Ticket.objects.create( + ticket_no="TK202607010105", + title="生产接口异常", + description="接口 500", + status="pending", + category=category, + ) + + response = admin_client.post( + f"/api/tickets/{ticket.id}/status", + data={"status": "resolved", "resolution": "已恢复"}, + content_type="application/json", + ) + + assert response.status_code == 400 + + def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db): user_model = get_user_model() operator = user_model.objects.get(username="admin") diff --git a/backend/apps/tickets/tests/test_ticket_status.py b/backend/apps/tickets/tests/test_ticket_status.py index f2cc4d1..c57aa0e 100644 --- a/backend/apps/tickets/tests/test_ticket_status.py +++ b/backend/apps/tickets/tests/test_ticket_status.py @@ -20,3 +20,16 @@ def test_in_progress_ticket_must_not_resolve_directly(): def test_resolved_ticket_cannot_move_back_to_in_progress(): with pytest.raises(TicketStatusError, match="resolved -> in_progress"): validate_status_transition("resolved", "in_progress") + + +def test_business_ticket_can_resolve_directly_from_pending(): + validate_status_transition("pending", "resolved", "business_issue") + + +def test_technical_ticket_cannot_resolve_directly_from_pending(): + with pytest.raises(TicketStatusError, match="pending -> resolved"): + validate_status_transition("pending", "resolved", "technical_issue") + + +def test_data_ticket_can_resolve_from_in_progress(): + validate_status_transition("in_progress", "resolved", "data_issue") diff --git a/frontend/src/features/tickets/TicketCreatePage.tsx b/frontend/src/features/tickets/TicketCreatePage.tsx index b0bd32a..3c62ffe 100644 --- a/frontend/src/features/tickets/TicketCreatePage.tsx +++ b/frontend/src/features/tickets/TicketCreatePage.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { AlertCircle, Bot, CheckCircle2, Lightbulb, Send } from 'lucide-react' +import { AlertCircle, CheckCircle2, Lightbulb, Send } from 'lucide-react' import { AppField } from '@/components/app/AppField' import { AppPage } from '@/components/app/AppPage' @@ -13,17 +13,25 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa import { Textarea } from '@/components/ui/textarea' import { createTicket } from './api' +import { TICKET_CATEGORIES } from './ticketCategories' import type { TicketInput } from './types' const initialForm: TicketInput = { title: '', description: '', + category_code: 'business_issue', priority: 'medium', + problem_performance: '', + problem_object: '', + submitter_name: '', + submitter_org: '', impact_scope: '', environment: '', related_system: '' } +const ENVIRONMENT_OPTIONS = ['生产环境', '测试环境', '预发布环境', '本地环境'] + export function TicketCreatePage() { const [form, setForm] = React.useState(initialForm) const [error, setError] = React.useState('') @@ -36,6 +44,10 @@ export function TicketCreatePage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault() setError('') + if (!form.environment || !form.problem_performance || !form.problem_object || !form.submitter_name || !form.submitter_org) { + setError('请完整填写问题表现、问题对象、提出人、提出机构和环境。') + return + } setSubmitting(true) try { const ticket = await createTicket(form) @@ -61,18 +73,94 @@ export function TicketCreatePage() { ) : null} - - + + + + + updateField('title', event.target.value)} + /> + + + + + +
+ + updateField('problem_performance', event.target.value)} + /> + + + updateField('problem_object', event.target.value)} + /> + +
+ +