From 6132d383aee28b4d56e18d8dde5ba197b5266e1e Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 3 Jul 2026 16:36:46 +0800 Subject: [PATCH] feat: adopt solution and close ticket --- backend/apps/solutions/api.py | 64 ++++++++- backend/apps/solutions/schemas.py | 6 + .../apps/solutions/tests/test_solution_api.py | 46 +++++++ .../2026-07-03-adopt-solution-close-ticket.md | 128 ++++++++++++++++++ .../features/solutions/SolutionLinkPanel.tsx | 64 ++++++++- frontend/src/features/solutions/api.ts | 11 ++ .../src/features/tickets/TicketDetailPage.tsx | 16 ++- 7 files changed, 325 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-03-adopt-solution-close-ticket.md diff --git a/backend/apps/solutions/api.py b/backend/apps/solutions/api.py index dad248d..bd98b46 100644 --- a/backend/apps/solutions/api.py +++ b/backend/apps/solutions/api.py @@ -1,15 +1,18 @@ +from django.db import transaction +from django.utils import timezone from ninja import Router from apps.accounts.api import require_permission from apps.solutions.models import Solution, TicketSolutionLink from apps.solutions.schemas import ( + AdoptSolutionIn, SolutionIn, SolutionOut, TicketSolutionLinkIn, TicketSolutionLinkOut, TicketSolutionOut, ) -from apps.tickets.models import Ticket +from apps.tickets.models import Ticket, TicketEvent router = Router(tags=["solutions"]) @@ -89,3 +92,62 @@ def link_solution_to_ticket(request, solution_id: int, payload: TicketSolutionLi "solution_usage_count": solution.usage_count, "created_at": link.created_at, } + + +@router.post("/{solution_id}/adopt-ticket", response=TicketSolutionLinkOut) +def adopt_solution_and_close_ticket(request, solution_id: int, payload: AdoptSolutionIn): + require_permission(request, "solution.manage") + 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, + remark=payload.remark, + ) + return { + "id": link.id, + "ticket_id": ticket.id, + "solution_id": solution.id, + "link_type": link.link_type, + "solution_usage_count": solution.usage_count, + "created_at": link.created_at, + } diff --git a/backend/apps/solutions/schemas.py b/backend/apps/solutions/schemas.py index 1840d18..18de205 100644 --- a/backend/apps/solutions/schemas.py +++ b/backend/apps/solutions/schemas.py @@ -35,6 +35,12 @@ class TicketSolutionLinkIn(Schema): agent_run_id: int | None = None +class AdoptSolutionIn(Schema): + ticket_id: int + remark: str = "采纳解决方案并关闭工单" + replied_to_customer: bool = True + + class TicketSolutionLinkOut(Schema): id: int ticket_id: int diff --git a/backend/apps/solutions/tests/test_solution_api.py b/backend/apps/solutions/tests/test_solution_api.py index 8ef55f7..12721ce 100644 --- a/backend/apps/solutions/tests/test_solution_api.py +++ b/backend/apps/solutions/tests/test_solution_api.py @@ -1,3 +1,4 @@ +from apps.solutions.models import Solution, TicketSolutionLink from apps.tickets.models import Ticket @@ -66,3 +67,48 @@ def test_list_ticket_solutions_returns_linked_solution_details(admin_client, db) payload = response.json() assert payload[0]["link_type"] == "adopted" assert payload[0]["solution"]["title"] == "Restart callback worker" + + +def test_adopt_solution_closes_ticket_and_records_resolution(admin_client, db): + ticket = Ticket.objects.create( + ticket_no="TK202607030300", + title="查询采集进度", + description="客户需要确认采集进度", + status="pending", + ) + solution = Solution.objects.create( + title="查询采集进度处理方案", + problem_signature="采集进度查询", + root_cause="税局采集链路不稳定", + solution="查询 Python 日志,确认税局不稳定,仍在采集中。", + status="published", + ) + + response = admin_client.post( + f"/api/solutions/{solution.id}/adopt-ticket", + data={ + "ticket_id": ticket.id, + "replied_to_customer": True, + "remark": "采纳方案并回复客户", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["link_type"] == "adopted" + 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.replied_to_customer is True + assert ticket.resolved_at is not None + assert ticket.closed_at is not None + assert TicketSolutionLink.objects.filter( + ticket=ticket, + solution=solution, + link_type="adopted", + ).exists() + assert ticket.events.filter(event_type="solution_adopted").exists() diff --git a/docs/superpowers/plans/2026-07-03-adopt-solution-close-ticket.md b/docs/superpowers/plans/2026-07-03-adopt-solution-close-ticket.md new file mode 100644 index 0000000..aafdd6d --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-adopt-solution-close-ticket.md @@ -0,0 +1,128 @@ +# Adopt Solution Close Ticket Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow support staff to search published solutions, adopt one, write it into the ticket resolution, and close the ticket in one action. + +**Architecture:** Keep the adoption workflow server-side so ticket status, solution link, solution usage count, resolution fields, and timeline events stay consistent. The frontend remains a shadcn/App-component panel that calls a single action endpoint and refreshes ticket context. + +**Tech Stack:** Django, Django Ninja, PostgreSQL migrations already present, React, TypeScript, Tailwind, shadcn/ui project components. + +--- + +### Task 1: Backend Adopt-And-Close Endpoint + +**Files:** +- Modify: `backend/apps/solutions/schemas.py` +- Modify: `backend/apps/solutions/api.py` +- Test: `backend/apps/solutions/tests/test_solution_api.py` + +- [ ] **Step 1: Write the failing API test** + +Add a test that creates a published solution and pending ticket, posts to `/api/solutions/{solution_id}/adopt-ticket`, and asserts: + +```python +assert response.status_code == 200 +ticket.refresh_from_db() +assert ticket.status == "closed" +assert ticket.is_resolved is True +assert ticket.replied_to_customer is True +assert ticket.resolution_summary == solution.solution +assert ticket.events.filter(event_type="solution_adopted").exists() +``` + +- [ ] **Step 2: Implement the schema and endpoint** + +Add a payload with `ticket_id`, `remark`, and `replied_to_customer`. The endpoint should: + +```python +link, _ = TicketSolutionLink.objects.get_or_create( + ticket=ticket, + solution=solution, + link_type="adopted", + defaults={"created_by": request.user if request.user.is_authenticated else None}, +) +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 timezone.now() +ticket.closed_at = timezone.now() +ticket.save(update_fields=[...]) +TicketEvent.objects.create(event_type="solution_adopted", ...) +``` + +- [ ] **Step 3: Verify backend** + +Run: + +```powershell +python -m pytest backend\apps\solutions\tests backend\apps\tickets\tests +python -m ruff check backend\apps\solutions backend\apps\tickets +``` + +Expected: all tests pass and ruff reports no issues. + +### Task 2: Frontend Search And Adopt UI + +**Files:** +- Modify: `frontend/src/features/solutions/api.ts` +- Modify: `frontend/src/features/solutions/SolutionLinkPanel.tsx` +- Modify: `frontend/src/features/tickets/TicketDetailPage.tsx` + +- [ ] **Step 1: Add API client** + +Add: + +```ts +export function adoptSolutionAndCloseTicket(solutionId: number, ticketId: number) { + return apiRequest(`/solutions/${solutionId}/adopt-ticket`, { + method: 'POST', + body: JSON.stringify({ + ticket_id: ticketId, + replied_to_customer: true, + remark: '采纳解决方案并关闭工单' + }) + }) +} +``` + +- [ ] **Step 2: Add search and action button** + +In `SolutionLinkPanel`, add a search input filtering by `title`, `problem_signature`, and `solution`. Rename the adoption action to `采纳并关闭`, call the new endpoint, refresh linked solutions, and notify the parent ticket detail page to reload. + +- [ ] **Step 3: Verify frontend** + +Run: + +```powershell +npm run typecheck +npm run build +``` + +Expected: TypeScript and Vite build both succeed. + +### Task 3: Commit + +**Files:** +- Stage only the backend/frontend files touched by this feature. + +- [ ] **Step 1: Inspect git status** + +Run: + +```powershell +git status --short +git diff --stat +``` + +Expected: existing local `.gitignore` and local lock/compose files remain unstaged. + +- [ ] **Step 2: Commit** + +Run: + +```powershell +git add backend/apps/solutions frontend/src/features/solutions frontend/src/features/tickets/TicketDetailPage.tsx docs/superpowers/plans/2026-07-03-adopt-solution-close-ticket.md +git commit -m "feat: adopt solution and close ticket" +``` diff --git a/frontend/src/features/solutions/SolutionLinkPanel.tsx b/frontend/src/features/solutions/SolutionLinkPanel.tsx index 8f593a1..91712f0 100644 --- a/frontend/src/features/solutions/SolutionLinkPanel.tsx +++ b/frontend/src/features/solutions/SolutionLinkPanel.tsx @@ -1,10 +1,12 @@ import * as React from 'react' +import { Search, Sparkles } from 'lucide-react' import { AppSection } from '@/components/app/AppSection' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' -import { linkSolutionToTicket, listSolutions, listTicketSolutions } from './api' +import { adoptSolutionAndCloseTicket, linkSolutionToTicket, listSolutions, listTicketSolutions } from './api' import type { Solution, TicketSolution } from './types' const LINK_TYPE_LABELS: Record = { @@ -12,10 +14,18 @@ const LINK_TYPE_LABELS: Record = { adopted: '已采纳' } -export function SolutionLinkPanel({ ticketId }: { ticketId: number }) { +export function SolutionLinkPanel({ + ticketId, + onTicketChanged +}: { + ticketId: number + onTicketChanged?: () => void +}) { const [solutions, setSolutions] = React.useState([]) const [linkedSolutions, setLinkedSolutions] = React.useState([]) + const [query, setQuery] = React.useState('') const [message, setMessage] = React.useState('') + const [submittingSolutionId, setSubmittingSolutionId] = React.useState(null) function refresh() { listSolutions({ status: 'published' }).then(setSolutions).catch(() => setSolutions([])) @@ -32,8 +42,28 @@ export function SolutionLinkPanel({ ticketId }: { ticketId: number }) { refresh() } + async function handleAdoptAndClose(solution: Solution) { + setSubmittingSolutionId(solution.id) + try { + await adoptSolutionAndCloseTicket(solution.id, ticketId) + setMessage(`已采纳「${solution.title}」并关闭工单`) + refresh() + onTicketChanged?.() + } finally { + setSubmittingSolutionId(null) + } + } + const linkedSolutionIds = new Set(linkedSolutions.map((link) => link.solution_id)) - const availableSolutions = solutions.filter((solution) => !linkedSolutionIds.has(solution.id)) + const normalizedQuery = query.trim().toLowerCase() + const availableSolutions = solutions + .filter((solution) => !linkedSolutionIds.has(solution.id)) + .filter((solution) => { + if (!normalizedQuery) return true + return `${solution.title} ${solution.problem_signature} ${solution.solution} ${solution.root_cause}` + .toLowerCase() + .includes(normalizedQuery) + }) return ( +
+
+ + setQuery(event.target.value)} + /> +
+
+ + 自动匹配将在 AI 接入后基于问题描述、对象和历史方案推荐。 +
+
+ {availableSolutions.length > 0 ? ( -
-

可关联方案

+
+

可用方案

{availableSolutions.map((solution) => (
@@ -82,7 +128,13 @@ export function SolutionLinkPanel({ ticketId }: { ticketId: number }) { - +
))} diff --git a/frontend/src/features/solutions/api.ts b/frontend/src/features/solutions/api.ts index 353bb66..c806783 100644 --- a/frontend/src/features/solutions/api.ts +++ b/frontend/src/features/solutions/api.ts @@ -26,3 +26,14 @@ export function linkSolutionToTicket(solutionId: number, ticketId: number, linkT body: JSON.stringify({ ticket_id: ticketId, link_type: linkType }) }) } + +export function adoptSolutionAndCloseTicket(solutionId: number, ticketId: number) { + return apiRequest(`/solutions/${solutionId}/adopt-ticket`, { + method: 'POST', + body: JSON.stringify({ + ticket_id: ticketId, + replied_to_customer: true, + remark: '采纳解决方案并关闭工单' + }) + }) +} diff --git a/frontend/src/features/tickets/TicketDetailPage.tsx b/frontend/src/features/tickets/TicketDetailPage.tsx index bc80417..ab5793f 100644 --- a/frontend/src/features/tickets/TicketDetailPage.tsx +++ b/frontend/src/features/tickets/TicketDetailPage.tsx @@ -66,11 +66,15 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) { const [transitionError, setTransitionError] = React.useState('') React.useEffect(() => { + refreshTicket() + refreshEvents() + }, [ticketId]) + + function refreshTicket() { getTicket(ticketId) .then(setTicket) .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败')) - refreshEvents() - }, [ticketId]) + } function refreshEvents() { listTicketEvents(ticketId).then(setEvents).catch(() => setEvents([])) @@ -189,7 +193,13 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) { side={ <> - + { + refreshTicket() + refreshEvents() + }} + /> 后续接入}>