feat: consolidate ticket domain baseline

This commit is contained in:
hailin
2026-07-27 18:57:51 +08:00
parent 5015e4407d
commit 96452934a3
25 changed files with 4236 additions and 218 deletions

View File

@@ -15,17 +15,30 @@ PERMISSIONS: tuple[PermissionSpec, ...] = (
PermissionSpec("ticket.edit", "编辑工单", "工单"), PermissionSpec("ticket.edit", "编辑工单", "工单"),
PermissionSpec("ticket.export", "导出工单", "工单"), PermissionSpec("ticket.export", "导出工单", "工单"),
PermissionSpec("ticket.assign", "分派工单", "工单"), PermissionSpec("ticket.assign", "分派工单", "工单"),
PermissionSpec("ticket.resolve", "解决工单", "工单"),
PermissionSpec("ticket.reply", "确认客户回复", "工单"),
PermissionSpec("ticket.close", "关闭工单", "工单"), PermissionSpec("ticket.close", "关闭工单", "工单"),
PermissionSpec("ticket.archive", "归档工单", "工单"),
PermissionSpec("solution.view", "查看解决方案", "解决方案"), PermissionSpec("solution.view", "查看解决方案", "解决方案"),
PermissionSpec("solution.manage", "管理解决方案", "解决方案"), PermissionSpec("solution.manage", "管理解决方案", "解决方案"),
PermissionSpec("solution.submit", "提交解决方案审核", "解决方案"),
PermissionSpec("solution.review", "审核与发布解决方案", "解决方案"),
PermissionSpec("knowledge.view", "查看知识库", "知识库"), PermissionSpec("knowledge.view", "查看知识库", "知识库"),
PermissionSpec("knowledge.manage", "管理知识库", "知识库"), PermissionSpec("knowledge.manage", "管理知识库", "知识库"),
PermissionSpec("knowledge.submit", "提交知识审核", "知识库"),
PermissionSpec("knowledge.review", "审核与发布知识", "知识库"),
PermissionSpec("agent.use", "使用 Agent", "AI 平台"), PermissionSpec("agent.use", "使用 Agent", "AI 平台"),
PermissionSpec("agent.manage", "管理 Agent", "AI 平台"), PermissionSpec("agent.manage", "管理 Agent", "AI 平台"),
PermissionSpec("agent.run.view", "查看 Agent 运行", "AI 平台"), PermissionSpec("agent.run.view", "查看 Agent 运行", "AI 平台"),
PermissionSpec("model.manage", "管理模型配置", "AI 平台"), PermissionSpec("model.manage", "管理模型配置", "AI 平台"),
PermissionSpec("skill.manage", "管理 Skill", "AI 平台"), PermissionSpec("skill.manage", "管理 Skill", "AI 平台"),
PermissionSpec("tool.view", "查看工具目录", "AI 平台"),
PermissionSpec("tool.invoke", "调用已授权工具", "AI 平台"),
PermissionSpec("tool.confirm", "确认高风险工具调用", "AI 平台"),
PermissionSpec("tool.manage", "管理 MCP 工具", "AI 平台"), PermissionSpec("tool.manage", "管理 MCP 工具", "AI 平台"),
PermissionSpec("channel.view", "查看渠道消息", "渠道"),
PermissionSpec("channel.send", "发送渠道消息", "渠道"),
PermissionSpec("channel.manage", "管理渠道适配器", "渠道"),
PermissionSpec("system.user.view", "查看用户", "系统治理"), PermissionSpec("system.user.view", "查看用户", "系统治理"),
PermissionSpec("system.user.manage", "管理用户", "系统治理"), PermissionSpec("system.user.manage", "管理用户", "系统治理"),
PermissionSpec("system.user_group.view", "查看用户组", "系统治理"), PermissionSpec("system.user_group.view", "查看用户组", "系统治理"),
@@ -44,9 +57,15 @@ DEFAULT_GROUPS: dict[str, tuple[str, ...]] = {
"ticket.view", "ticket.view",
"knowledge.view", "knowledge.view",
"knowledge.manage", "knowledge.manage",
"knowledge.submit",
"knowledge.review",
"solution.view", "solution.view",
"solution.manage", "solution.manage",
"solution.submit",
"solution.review",
"agent.use", "agent.use",
"tool.view",
"channel.view",
), ),
"技术支持": ( "技术支持": (
"ticket.view", "ticket.view",
@@ -54,17 +73,45 @@ DEFAULT_GROUPS: dict[str, tuple[str, ...]] = {
"ticket.ai_draft", "ticket.ai_draft",
"ticket.edit", "ticket.edit",
"ticket.assign", "ticket.assign",
"ticket.resolve",
"ticket.reply",
"ticket.close", "ticket.close",
"knowledge.view", "knowledge.view",
"knowledge.manage",
"knowledge.submit",
"solution.view", "solution.view",
"solution.manage",
"solution.submit",
"agent.use", "agent.use",
"tool.view",
"tool.invoke",
"channel.view",
"channel.send",
), ),
"普通建单人": ( "技术支持负责人": (
"ticket.view", "ticket.view",
"ticket.create", "ticket.create",
"ticket.ai_draft", "ticket.ai_draft",
"ticket.edit",
"ticket.export",
"ticket.assign",
"ticket.resolve",
"ticket.reply",
"ticket.close",
"ticket.archive",
"knowledge.view", "knowledge.view",
"knowledge.manage",
"knowledge.submit",
"solution.view", "solution.view",
"solution.manage",
"solution.submit",
"agent.use",
"agent.run.view",
"tool.view",
"tool.invoke",
"tool.confirm",
"channel.view",
"channel.send",
), ),
"只读审计员": ( "只读审计员": (
"ticket.view", "ticket.view",

View File

@@ -19,9 +19,9 @@ def test_anonymous_ticket_list_is_denied(client, db):
assert response.status_code == 401 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") 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") 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): 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") response = client.get("/api/solutions")
@@ -57,7 +57,7 @@ def test_audit_log_requires_audit_permission(client, db):
target_id="health", target_id="health",
result="success", result="success",
) )
client.force_login(user_with_role("普通建单人")) client.force_login(user_with_role("技术支持"))
denied = client.get("/api/audit/operation-logs") denied = client.get("/api/audit/operation-logs")
assert denied.status_code == 403 assert denied.status_code == 403

View File

@@ -11,6 +11,8 @@ router = Router(tags=["audit"])
class OperationAuditLogOut(Schema): class OperationAuditLogOut(Schema):
id: int id: int
user_id: int | None
username: str | None
module: str module: str
action: str action: str
target_type: str target_type: str
@@ -23,6 +25,49 @@ class OperationAuditLogOut(Schema):
@router.get("/operation-logs", response=list[OperationAuditLogOut]) @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") 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)]
]

View File

@@ -18,3 +18,27 @@ def test_list_operation_logs_returns_recent_logs(admin_client, db):
assert len(payload) == 1 assert len(payload) == 1
assert payload[0]["module"] == "system" assert payload[0]["module"] == "system"
assert payload[0]["action"] == "health_check" 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"]

View File

@@ -1,8 +1,9 @@
from django.db import transaction
from django.utils import timezone
from ninja import Router from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission 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.models import Solution, TicketSolutionLink
from apps.solutions.schemas import ( from apps.solutions.schemas import (
AdoptSolutionIn, AdoptSolutionIn,
@@ -12,7 +13,14 @@ from apps.solutions.schemas import (
TicketSolutionLinkOut, TicketSolutionLinkOut,
TicketSolutionOut, 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"]) router = Router(tags=["solutions"])
@@ -28,13 +36,18 @@ def create_solution(request, payload: SolutionIn):
root_cause=payload.root_cause, root_cause=payload.root_cause,
solution=payload.solution, solution=payload.solution,
risk_note=payload.risk_note, 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, created_by=request.user if request.user.is_authenticated else None,
) )
if solution.status == "published": ensure_governed_asset(
from apps.knowledge.services import index_published_solution GovernedAssetType.SOLUTION,
solution,
index_published_solution(solution) actor=request.user if request.user.is_authenticated else None,
)
return solution return solution
@@ -75,6 +88,8 @@ def get_solution(request, solution_id: int):
@router.post("/{solution_id}/link-ticket", response=TicketSolutionLinkOut) @router.post("/{solution_id}/link-ticket", response=TicketSolutionLinkOut)
def link_solution_to_ticket(request, solution_id: int, payload: TicketSolutionLinkIn): def link_solution_to_ticket(request, solution_id: int, payload: TicketSolutionLinkIn):
require_permission(request, "solution.manage") 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) solution = Solution.objects.get(id=solution_id)
ticket = Ticket.objects.get(id=payload.ticket_id) ticket = Ticket.objects.get(id=payload.ticket_id)
link, _ = TicketSolutionLink.objects.get_or_create( 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, "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 { return {
"id": link.id, "id": link.id,
"ticket_id": ticket.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) @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, "solution.manage")
require_permission(request, "ticket.resolve")
operator = request.user if request.user.is_authenticated else None operator = request.user if request.user.is_authenticated else None
with transaction.atomic(): try:
solution = Solution.objects.select_for_update().get(id=solution_id) solution = Solution.objects.get(id=solution_id)
ticket = Ticket.objects.select_for_update().get(id=payload.ticket_id) ticket, link = adopt_solution_for_ticket_service(
link, _ = TicketSolutionLink.objects.get_or_create( Ticket.objects.get(id=payload.ticket_id),
ticket=ticket, solution,
solution=solution, operator,
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, 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 { return {
"id": link.id, "id": link.id,
"ticket_id": ticket.id, "ticket_id": ticket.id,

View File

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

View File

@@ -14,6 +14,10 @@ class Solution(models.Model):
solution = models.TextField() solution = models.TextField()
risk_note = models.TextField(blank=True) risk_note = models.TextField(blank=True)
status = models.CharField(max_length=40, default="draft") 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) usage_count = models.PositiveIntegerField(default=0)
created_by = models.ForeignKey( created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@@ -34,9 +38,11 @@ class Solution(models.Model):
class TicketResolution(models.Model): class TicketResolution(models.Model):
ticket = models.OneToOneField(Ticket, on_delete=models.CASCADE, related_name="resolution") ticket = models.OneToOneField(Ticket, on_delete=models.CASCADE, related_name="resolution")
resolution_type = models.CharField(max_length=80, default="resolved") resolution_type = models.CharField(max_length=80, default="resolved")
source = models.CharField(max_length=24, default="human")
root_cause = models.TextField(blank=True) root_cause = models.TextField(blank=True)
solution_text = models.TextField() solution_text = models.TextField()
linked_solution = models.ForeignKey(Solution, null=True, blank=True, on_delete=models.SET_NULL) 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( confirmed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
null=True, null=True,

View File

@@ -12,6 +12,10 @@ class SolutionIn(Schema):
solution: str solution: str
risk_note: str = "" risk_note: str = ""
status: str = "draft" 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): class SolutionOut(Schema):
@@ -24,6 +28,10 @@ class SolutionOut(Schema):
solution: str solution: str
risk_note: str risk_note: str
status: str status: str
allow_auto_resolve: bool
requires_human_action: bool
requires_external_confirmation: bool
requires_production_change: bool
usage_count: int usage_count: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -37,8 +45,8 @@ class TicketSolutionLinkIn(Schema):
class AdoptSolutionIn(Schema): class AdoptSolutionIn(Schema):
ticket_id: int ticket_id: int
remark: str = "采纳解决方案并关闭工单" remark: str = "采纳解决方案并标记工单已解决"
replied_to_customer: bool = True replied_to_customer: bool = False
class TicketSolutionLinkOut(Schema): class TicketSolutionLinkOut(Schema):

View File

@@ -1,5 +1,5 @@
from apps.solutions.models import Solution, TicketSolutionLink from apps.solutions.models import Solution, TicketResolution, TicketSolutionLink
from apps.tickets.models import Ticket from apps.tickets.models import Ticket, TicketCategory
def test_create_and_list_solution(admin_client, db): 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 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") ticket = Ticket.objects.create(ticket_no="TK202606300020", title="A", description="A")
solution = admin_client.post( solution = admin_client.post(
"/api/solutions", "/api/solutions",
@@ -37,9 +37,8 @@ def test_link_solution_to_ticket_increments_usage(admin_client, db):
content_type="application/json", content_type="application/json",
) )
assert response.status_code == 200 assert response.status_code == 400
assert response.json()["link_type"] == "adopted" assert not TicketSolutionLink.objects.filter(ticket=ticket, link_type="adopted").exists()
assert response.json()["solution_usage_count"] == 1
def test_list_ticket_solutions_returns_linked_solution_details(admin_client, db): 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() ).json()
admin_client.post( admin_client.post(
f"/api/solutions/{solution['id']}/link-ticket", 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", 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 assert response.status_code == 200
payload = response.json() payload = response.json()
assert payload[0]["link_type"] == "adopted" assert payload[0]["link_type"] == "referenced"
assert payload[0]["solution"]["title"] == "Restart callback worker" 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 = Ticket.objects.create(
ticket_no="TK202607030300", ticket_no="TK202607030300",
title="查询采集进度", title="查询采集进度",
description="客户需要确认采集进度", description="客户需要确认采集进度",
status="pending", status="pending",
category=category,
) )
solution = Solution.objects.create( solution = Solution.objects.create(
title="查询采集进度处理方案", title="查询采集进度处理方案",
@@ -100,12 +104,14 @@ def test_adopt_solution_closes_ticket_and_records_resolution(admin_client, db):
assert payload["solution_usage_count"] == 1 assert payload["solution_usage_count"] == 1
ticket.refresh_from_db() ticket.refresh_from_db()
assert ticket.status == "closed" assert ticket.status == "resolved"
assert ticket.resolution_summary == solution.solution
assert ticket.is_resolved is True
assert ticket.replied_to_customer is True assert ticket.replied_to_customer is True
assert ticket.resolved_at is not None 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( assert TicketSolutionLink.objects.filter(
ticket=ticket, ticket=ticket,
solution=solution, solution=solution,

View File

@@ -10,26 +10,82 @@ from ninja import Router
from ninja.errors import HttpError from ninja.errors import HttpError
from apps.accounts.api import require_permission from apps.accounts.api import require_permission
from apps.agents.services import AgentValidationError
from apps.audit.models import OperationAuditLog 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 ( from apps.tickets.schemas import (
CustomerReplyConfirmIn,
CustomerReplyTaskOut,
CustomerReplyUpdateIn,
IntakeEvaluationRecordIn,
IntakeEvaluationResultOut,
IntakeQualityMetricsOut,
SupportEvaluationRecordIn,
SupportEvaluationResultOut,
SupportQualityMetricsOut,
TicketAttachmentIn, TicketAttachmentIn,
TicketAttachmentOut, TicketAttachmentOut,
TicketCollaboratorIn,
TicketCollaboratorOut,
TicketCommentIn, TicketCommentIn,
TicketCommentOut, TicketCommentOut,
TicketEventOut, TicketEventOut,
TicketFieldEvidenceOut,
TicketIn, TicketIn,
TicketIntakeTraceOut,
TicketKnowledgeDistillationOut,
TicketListOut, TicketListOut,
TicketOut, TicketOut,
TicketSimilaritySuggestionOut,
TicketStatusIn, TicketStatusIn,
TicketSupportAnalysisOut,
TicketUpdateIn, TicketUpdateIn,
TicketUserOut,
) )
from apps.tickets.services import ( from apps.tickets.services import (
TicketDataError, TicketDataError,
TicketStatusError, TicketStatusError,
create_ticket_from_fields, create_ticket_from_fields,
set_ticket_archived,
transition_requires_remark, transition_requires_remark,
transition_ticket, 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"]) router = Router(tags=["tickets"])
@@ -63,6 +119,7 @@ def serialize_ticket_category(category):
def serialize_ticket(ticket: Ticket) -> dict: def serialize_ticket(ticket: Ticket) -> dict:
resolution = getattr(ticket, "resolution", None)
return { return {
"id": ticket.id, "id": ticket.id,
"ticket_no": ticket.ticket_no, "ticket_no": ticket.ticket_no,
@@ -76,17 +133,36 @@ def serialize_ticket(ticket: Ticket) -> dict:
"problem_object": ticket.problem_object, "problem_object": ticket.problem_object,
"submitter_name": ticket.submitter_name, "submitter_name": ticket.submitter_name,
"submitter_org": ticket.submitter_org, "submitter_org": ticket.submitter_org,
"ai_solution": ticket.ai_solution, "resolution": resolution.solution_text if resolution else "",
"resolution": ticket.resolution_summary, "resolution_id": resolution.id if resolution else None,
"is_resolved": ticket.is_resolved, "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, "replied_to_customer": ticket.replied_to_customer,
"impact_scope": ticket.impact_scope, "impact_scope": ticket.impact_scope,
"environment": ticket.environment, "environment": ticket.environment,
"related_system": ticket.related_system, "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, "requester_id": ticket.requester_id,
"assignee_id": ticket.assignee_id, "assignee_id": ticket.assignee_id,
"requester": serialize_ticket_user(ticket.requester), "requester": serialize_ticket_user(ticket.requester),
"assignee": serialize_ticket_user(ticket.assignee), "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, "created_at": ticket.created_at,
"updated_at": ticket.updated_at, "updated_at": ticket.updated_at,
} }
@@ -100,10 +176,207 @@ def serialize_ticket_event(event: TicketEvent) -> dict:
"to_value": event.to_value, "to_value": event.to_value,
"remark": event.remark, "remark": event.remark,
"operator": serialize_ticket_user(event.operator), "operator": serialize_ticket_user(event.operator),
"source": event.source,
"agent_run_id": event.agent_run_id,
"metadata": event.metadata,
"created_at": event.created_at, "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) @router.post("", response=TicketOut)
def create_ticket(request, payload: TicketIn): def create_ticket(request, payload: TicketIn):
require_permission(request, "ticket.create") require_permission(request, "ticket.create")
@@ -128,9 +401,19 @@ def list_tickets(
view: str = "all", view: str = "all",
status_group: str | None = None, status_group: str | None = None,
resolved_within_days: int | None = None, resolved_within_days: int | None = None,
include_archived: bool = False,
): ):
require_permission(request, "ticket.view") 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: if status:
queryset = queryset.filter(status=status) queryset = queryset.filter(status=status)
if priority: if priority:
@@ -146,6 +429,11 @@ def list_tickets(
| Q(problem_object__icontains=q) | Q(problem_object__icontains=q)
| Q(submitter_name__icontains=q) | Q(submitter_name__icontains=q)
| Q(submitter_org__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: if related_system:
queryset = queryset.filter(related_system__icontains=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"' response["Content-Disposition"] = 'attachment; filename="tickets.csv"'
writer = csv.writer(response) writer = csv.writer(response)
writer.writerow(["ticket_no", "title", "status", "priority", "created_at"]) 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( writer.writerow(
[ [
ticket.ticket_no, ticket.ticket_no,
@@ -213,14 +501,267 @@ def export_tickets(request):
return response 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) @router.get("/{ticket_id}", response=TicketOut)
def get_ticket(request, ticket_id: int): def get_ticket(request, ticket_id: int):
require_permission(request, "ticket.view") require_permission(request, "ticket.view")
return serialize_ticket( 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]) @router.get("/{ticket_id}/events", response=list[TicketEventOut])
def list_ticket_events(request, ticket_id: int): def list_ticket_events(request, ticket_id: int):
require_permission(request, "ticket.view") require_permission(request, "ticket.view")
@@ -230,51 +771,32 @@ def list_ticket_events(request, ticket_id: int):
@router.patch("/{ticket_id}", response=TicketOut) @router.patch("/{ticket_id}", response=TicketOut)
def update_ticket(request, ticket_id: int, payload: TicketUpdateIn): def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
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") require_permission(request, "ticket.edit")
ticket = Ticket.objects.get(id=ticket_id) ticket = Ticket.objects.get(id=ticket_id)
before = { try:
"title": ticket.title, updated, _ = update_ticket_fields(ticket, changes, request.user)
"category_code": ticket.category.code if ticket.category_id else None, except TicketDataError as error:
"priority": ticket.priority, raise HttpError(400, str(error)) from error
"problem_performance": ticket.problem_performance, return serialize_ticket(
"problem_object": ticket.problem_object, Ticket.objects.select_related(
"submitter_name": ticket.submitter_name, "requester", "assignee", "category", "resolution", "archived_by"
"submitter_org": ticket.submitter_org, ).get(id=updated.id)
"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,
)
return serialize_ticket(ticket)
@router.post("/{ticket_id}/status", response=TicketOut) @router.post("/{ticket_id}/status", response=TicketOut)
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn): def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
if payload.status == "closed":
require_permission(request, "ticket.close")
elif payload.status == "resolved":
require_permission(request, "ticket.resolve")
else:
require_permission(request, "ticket.edit") require_permission(request, "ticket.edit")
ticket = Ticket.objects.select_related("category").get(id=ticket_id) ticket = Ticket.objects.select_related("category").get(id=ticket_id)
category_code = ticket.category.code if ticket.category_id else None category_code = ticket.category.code if ticket.category_id else None
@@ -294,21 +816,154 @@ def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
payload.remark, payload.remark,
request.user if request.user.is_authenticated else None, request.user if request.user.is_authenticated else None,
resolution=payload.resolution.strip(), resolution=payload.resolution.strip(),
is_resolved=payload.is_resolved,
replied_to_customer=payload.replied_to_customer, replied_to_customer=payload.replied_to_customer,
) )
except TicketStatusError as error: except (TicketDataError, TicketStatusError) as error:
raise HttpError(400, str(error)) from 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) @router.post("/{ticket_id}/comments", response=TicketCommentOut)
def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn): def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn):
require_permission(request, "ticket.edit") 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) ticket = Ticket.objects.get(id=ticket_id)
comment = TicketComment.objects.create( comment = TicketComment.objects.create(
ticket=ticket, ticket=ticket,
content=payload.content, content=content,
comment_type=payload.comment_type, comment_type=payload.comment_type,
visibility=payload.visibility, visibility=payload.visibility,
created_by=request.user if request.user.is_authenticated else None, 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}, to_value={"comment_id": comment.id, "comment_type": comment.comment_type},
operator=request.user if request.user.is_authenticated else None, operator=request.user if request.user.is_authenticated else None,
) )
return comment return serialize_ticket_comment(comment)
@router.post("/{ticket_id}/attachments", response=TicketAttachmentOut) @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, operator=request.user if request.user.is_authenticated else None,
) )
return attachment 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)

View File

@@ -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'),
),
]

View File

@@ -26,6 +26,18 @@ class TicketPriority(models.TextChoices):
URGENT = "urgent", "Urgent" 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): class TicketCategory(models.Model):
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL) parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL)
name = models.CharField(max_length=120) name = models.CharField(max_length=120)
@@ -60,13 +72,21 @@ class Ticket(models.Model):
problem_object = models.CharField(max_length=160, blank=True) problem_object = models.CharField(max_length=160, blank=True)
submitter_name = models.CharField(max_length=80, blank=True) submitter_name = models.CharField(max_length=80, blank=True)
submitter_org = models.CharField(max_length=160, 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) replied_to_customer = models.BooleanField(default=False)
impact_scope = models.CharField(max_length=80, blank=True) impact_scope = models.CharField(max_length=80, blank=True)
environment = models.CharField(max_length=80, blank=True) environment = models.CharField(max_length=80, blank=True)
related_system = models.CharField(max_length=120, 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( requester = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
null=True, null=True,
@@ -92,6 +112,14 @@ class Ticket(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
resolved_at = models.DateTimeField(null=True, blank=True) resolved_at = models.DateTimeField(null=True, blank=True)
closed_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: class Meta:
ordering = ["-created_at"] ordering = ["-created_at"]
@@ -99,12 +127,107 @@ class Ticket(models.Model):
models.Index(fields=["ticket_no"]), models.Index(fields=["ticket_no"]),
models.Index(fields=["status", "priority"]), models.Index(fields=["status", "priority"]),
models.Index(fields=["created_at"]), models.Index(fields=["created_at"]),
models.Index(fields=["archived_at"]),
] ]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.ticket_no} {self.title}" 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): class TicketComment(models.Model):
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments") ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments")
content = models.TextField() content = models.TextField()
@@ -129,6 +252,11 @@ class TicketAttachment(models.Model):
mime_type = models.CharField(max_length=120, blank=True) mime_type = models.CharField(max_length=120, blank=True)
file_size = models.PositiveBigIntegerField(default=0) file_size = models.PositiveBigIntegerField(default=0)
storage_path = models.CharField(max_length=500) 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) ocr_text = models.TextField(blank=True)
parsed_text = models.TextField(blank=True) parsed_text = models.TextField(blank=True)
created_by = models.ForeignKey( created_by = models.ForeignKey(
@@ -147,11 +275,15 @@ class TicketDraft(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="ticket_drafts", 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) revision = models.PositiveIntegerField(default=1)
source_text = models.TextField(blank=True) 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) current_fields = models.JSONField(default=dict)
last_ai_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) missing_fields = models.JSONField(default=list)
questions = models.JSONField(default=list) questions = models.JSONField(default=list)
guided_rounds = models.PositiveSmallIntegerField(default=0) guided_rounds = models.PositiveSmallIntegerField(default=0)
@@ -191,7 +323,6 @@ class TicketDraft(models.Model):
models.Index(fields=["status", "expires_at"]), models.Index(fields=["status", "expires_at"]),
] ]
class TicketDraftMessage(models.Model): class TicketDraftMessage(models.Model):
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="messages") draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="messages")
role = models.CharField(max_length=24) role = models.CharField(max_length=24)
@@ -203,21 +334,75 @@ class TicketDraftMessage(models.Model):
ordering = ["created_at"] 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) 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) 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) mime_type = models.CharField(max_length=120)
file_size = models.PositiveBigIntegerField() file_size = models.PositiveBigIntegerField()
sha256 = models.CharField(max_length=64) sha256 = models.CharField(max_length=64)
storage_path = models.CharField(max_length=500) storage_path = models.CharField(max_length=500)
status = models.CharField(max_length=24, default="temporary") 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( confirmed_attachment = models.OneToOneField(
TicketAttachment, TicketAttachment,
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="source_draft_image", related_name="source_draft_material",
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -225,6 +410,265 @@ class TicketDraftImage(models.Model):
ordering = ["created_at"] 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): class TicketEvent(models.Model):
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events") ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events")
event_type = models.CharField(max_length=80) event_type = models.CharField(max_length=80)
@@ -236,6 +680,13 @@ class TicketEvent(models.Model):
blank=True, blank=True,
on_delete=models.SET_NULL, 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) remark = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Any
from ninja import Schema from ninja import Schema
@@ -15,7 +16,18 @@ class TicketIn(Schema):
impact_scope: str = "" impact_scope: str = ""
environment: str = "" environment: str = ""
related_system: 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 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): class TicketUpdateIn(Schema):
@@ -27,20 +39,27 @@ class TicketUpdateIn(Schema):
problem_object: str | None = None problem_object: str | None = None
submitter_name: str | None = None submitter_name: str | None = None
submitter_org: 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 impact_scope: str | None = None
environment: str | None = None environment: str | None = None
related_system: 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 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): class TicketStatusIn(Schema):
status: str status: str
remark: str = "" remark: str = ""
resolution: str = "" resolution: str = ""
is_resolved: bool | None = None
replied_to_customer: bool | None = None replied_to_customer: bool | None = None
@@ -70,17 +89,36 @@ class TicketOut(Schema):
problem_object: str problem_object: str
submitter_name: str submitter_name: str
submitter_org: str submitter_org: str
ai_solution: str
resolution: 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 is_resolved: bool
replied_to_customer: bool replied_to_customer: bool
impact_scope: str impact_scope: str
environment: str environment: str
related_system: 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 requester_id: int | None
assignee_id: int | None assignee_id: int | None
requester: TicketUserOut | None = None requester: TicketUserOut | None = None
assignee: 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 created_at: datetime
updated_at: datetime updated_at: datetime
@@ -100,6 +138,9 @@ class TicketEventOut(Schema):
to_value: dict to_value: dict
remark: str remark: str
operator: TicketUserOut | None = None operator: TicketUserOut | None = None
source: str
agent_run_id: int | None
metadata: dict[str, Any]
created_at: datetime created_at: datetime
@@ -114,6 +155,33 @@ class TicketCommentOut(Schema):
content: str content: str
comment_type: str comment_type: str
visibility: 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 created_at: datetime
@@ -132,4 +200,243 @@ class TicketAttachmentOut(Schema):
mime_type: str mime_type: str
file_size: int file_size: int
storage_path: str 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 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

View File

@@ -1,8 +1,24 @@
"""工单领域服务。
所有工单写入都应经过本模块,以统一执行字段校验、人工字段保护、状态机、证据记录和审计。
"""
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone from django.utils import timezone
from django.utils.dateparse import parse_datetime
from apps.audit.models import OperationAuditLog 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): class TicketStatusError(ValueError):
@@ -13,6 +29,16 @@ class TicketDataError(ValueError):
pass 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 = ( REQUIRED_TICKET_FIELDS = (
"title", "title",
"description", "description",
@@ -25,6 +51,145 @@ REQUIRED_TICKET_FIELDS = (
"environment", "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]: def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]:
normalized = { normalized = {
@@ -39,7 +204,24 @@ def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]:
"impact_scope": str(fields.get("impact_scope") or "").strip(), "impact_scope": str(fields.get("impact_scope") or "").strip(),
"environment": str(fields.get("environment") or "").strip(), "environment": str(fields.get("environment") or "").strip(),
"related_system": str(fields.get("related_system") 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"), "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)] missing = [code for code in REQUIRED_TICKET_FIELDS if not normalized.get(code)]
if missing: if missing:
@@ -65,7 +247,16 @@ def next_ticket_no() -> str:
return f"{prefix}{count:04d}" 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) normalized, category = validate_ticket_fields(fields)
ticket = Ticket.objects.create( ticket = Ticket.objects.create(
ticket_no=next_ticket_no(), ticket_no=next_ticket_no(),
@@ -80,7 +271,18 @@ def create_ticket_from_fields(fields: dict, user) -> Ticket:
impact_scope=normalized["impact_scope"], impact_scope=normalized["impact_scope"],
environment=normalized["environment"], environment=normalized["environment"],
related_system=normalized["related_system"], 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"], 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, requester=user,
created_by=user, created_by=user,
) )
@@ -89,10 +291,369 @@ def create_ticket_from_fields(fields: dict, user) -> Ticket:
event_type="created", event_type="created",
to_value={"status": ticket.status, "category_code": normalized["category_code"]}, to_value={"status": ticket.status, "category_code": normalized["category_code"]},
operator=user, 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 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]] = { STANDARD_TRANSITIONS: dict[str, set[str]] = {
"pending_info": {"pending", "closed"}, "pending_info": {"pending", "closed"},
"pending": {"in_progress", "closed"}, "pending": {"in_progress", "closed"},
@@ -163,6 +724,7 @@ def transition_requires_remark(
return to_status in {"in_progress", "pending_confirm", "closed"} return to_status in {"in_progress", "pending_confirm", "closed"}
@transaction.atomic
def transition_ticket( def transition_ticket(
ticket: Ticket, ticket: Ticket,
to_status: str, to_status: str,
@@ -170,22 +732,38 @@ def transition_ticket(
operator=None, operator=None,
*, *,
resolution: str = "", resolution: str = "",
is_resolved: bool | None = None,
replied_to_customer: 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: ) -> Ticket:
"""校验状态机和必需业务结果后完成状态流转、事件记录及后续任务同步。"""
from_status = ticket.status from_status = ticket.status
category_code = get_ticket_category_code(ticket) category_code = get_ticket_category_code(ticket)
validate_status_transition(from_status, to_status, category_code) 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 ticket.status = to_status
if to_status == "resolved": if to_status == "resolved":
ticket.resolved_at = timezone.now() ticket.resolved_at = ticket.resolved_at or 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
if to_status == "closed": if to_status == "closed":
ticket.closed_at = timezone.now() ticket.closed_at = timezone.now()
if resolution:
ticket.resolution_summary = resolution
if replied_to_customer is not None: if replied_to_customer is not None:
ticket.replied_to_customer = replied_to_customer ticket.replied_to_customer = replied_to_customer
ticket.save( ticket.save(
@@ -193,23 +771,31 @@ def transition_ticket(
"status", "status",
"resolved_at", "resolved_at",
"closed_at", "closed_at",
"resolution_summary",
"is_resolved",
"replied_to_customer", "replied_to_customer",
"updated_at", "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( TicketEvent.objects.create(
ticket=ticket, ticket=ticket,
event_type="status_change", event_type="status_change",
from_value={"status": from_status}, from_value={"status": from_status},
to_value={ to_value={
"status": to_status, "status": to_status,
"resolution": ticket.resolution_summary, "resolution_id": resolution_record.id if resolution_record else None,
"is_resolved": ticket.is_resolved, "resolution": resolution_record.solution_text if resolution_record else "",
"is_resolved": resolution_record is not None,
"replied_to_customer": ticket.replied_to_customer, "replied_to_customer": ticket.replied_to_customer,
}, },
operator=operator, operator=operator,
source=source,
agent_run_id=agent_run_id,
remark=remark, remark=remark,
) )
OperationAuditLog.objects.create( OperationAuditLog.objects.create(
@@ -219,6 +805,123 @@ def transition_ticket(
target_type="ticket", target_type="ticket",
target_id=str(ticket.id), target_id=str(ticket.id),
result="success", 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 return ticket

View File

@@ -3,7 +3,14 @@ from datetime import timedelta
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils import timezone 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): 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 assert response.status_code == 200
ticket.refresh_from_db() ticket.refresh_from_db()
assert ticket.status == "resolved" assert ticket.status == "resolved"
assert ticket.resolution_summary == "已查询采集进度,当前已完成 80%。" assert ticket.resolution.solution_text == "已查询采集进度,当前已完成 80%。"
assert ticket.is_resolved is True assert response.json()["is_resolved"] is True
assert ticket.replied_to_customer 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="已解决待关闭", title="已解决待关闭",
description="A", description="A",
status="resolved", status="resolved",
is_resolved=True,
resolution_summary="已完成处理",
) )
TicketResolution.objects.create(ticket=ticket, solution_text="已完成处理")
response = admin_client.post( response = admin_client.post(
f"/api/tickets/{ticket.id}/status", f"/api/tickets/{ticket.id}/status",
data={"status": "closed", "remark": "已回复", "is_resolved": False}, data={"status": "closed", "remark": "已回复"},
content_type="application/json", content_type="application/json",
) )
assert response.status_code == 200 assert response.status_code == 200
ticket.refresh_from_db() ticket.refresh_from_db()
assert ticket.status == "closed" 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): 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]["to_value"] == {"status": "in_progress"}
assert payload[1]["remark"] == "开始排查认证链路" assert payload[1]["remark"] == "开始排查认证链路"
assert payload[1]["operator"]["display_name"] == "admin" 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

View File

@@ -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() == []

View File

@@ -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(<TicketCollaborationPanel ticketId={7} canManage onChanged={onChanged} />)
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(<TicketCollaborationPanel ticketId={7} canManage />)
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(<TicketCollaborationPanel ticketId={7} canManage={false} />)
expect(await screen.findByText('支持甲')).toBeInTheDocument()
expect(listTicketParticipants).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: '移除支持甲' })).not.toBeInTheDocument()
expect(screen.getByText(/没有分派权限/)).toBeInTheDocument()
})
})

View File

@@ -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<TicketCollaborator[]>([])
const [participants, setParticipants] = React.useState<TicketUser[]>([])
const [selectedUserId, setSelectedUserId] = React.useState('')
const [role, setRole] = React.useState<TicketCollaboratorRole>('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 (
<AppSection
variant="card"
title="协作成员"
description="协作人参与处理,关注人只需持续获知进展。"
>
<div className="grid gap-3">
{error ? (
<Alert variant="destructive">
<AlertTitle>协作成员暂不可用</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<AppEmptyState title="正在加载成员" />
) : collaborators.length === 0 ? (
<AppEmptyState title="暂无协作成员" description="需要跨成员处理时可在这里添加。" />
) : (
<ul className="grid gap-2" aria-label="工单协作成员">
{collaborators.map((item) => {
const RoleIcon = item.role === 'watcher' ? Eye : Handshake
return (
<li
key={item.id}
className="flex items-center justify-between gap-2 rounded-lg border bg-muted/15 p-2.5"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{displayUser(item.user)}</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Badge variant={item.role === 'watcher' ? 'outline' : 'secondary'}>
<RoleIcon />
{roleLabel(item.role)}
</Badge>
<span className="truncate text-xs text-muted-foreground">
@{item.user.username}
</span>
</div>
</div>
{canManage ? (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`移除${displayUser(item.user)}`}
disabled={saving}
onClick={() => void handleRemove(item)}
>
<Trash2 />
</Button>
) : null}
</li>
)
})}
</ul>
)}
{canManage ? (
<div className="grid gap-2 border-t pt-3">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_110px]">
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger aria-label="选择协作成员">
<SelectValue placeholder="选择启用账号" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableParticipants.map((user) => (
<SelectItem key={user.id} value={String(user.id)}>
{displayUser(user)} · {user.username}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={role}
onValueChange={(value) => setRole(value as TicketCollaboratorRole)}
>
<SelectTrigger aria-label="选择协作角色">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="collaborator">协作人</SelectItem>
<SelectItem value="watcher">关注人</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={!selectedUserId || saving}
onClick={() => void handleAdd()}
>
<UserPlus data-icon="inline-start" />
{saving ? '正在更新' : '添加成员'}
</Button>
{availableParticipants.length === 0 && participants.length > 0 ? (
<p className="text-xs text-muted-foreground">所有启用账号均已加入此工单。</p>
) : null}
</div>
) : (
<p className="flex items-center gap-2 border-t pt-3 text-xs text-muted-foreground">
<Users className="size-4" />
当前账号没有分派权限,只能查看成员。
</p>
)}
</div>
</AppSection>
)
}

View File

@@ -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(<TicketComments ticketId={7} canComment onCommentAdded={onCommentAdded} />)
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(<TicketComments ticketId={7} canComment={false} />)
expect(await screen.findByText(history[0].content)).toBeInTheDocument()
expect(screen.queryByRole('textbox', { name: '评论内容' })).not.toBeInTheDocument()
expect(screen.getByText(/只有查看权限/)).toBeInTheDocument()
})
})

View File

@@ -1,70 +1,193 @@
import * as React from 'react' 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 { 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 { 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 { 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 = { type TicketCommentsProps = {
ticketId: number ticketId: number
canComment: boolean
onCommentAdded?: () => void 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<TicketComment[]>([])
const [content, setContent] = React.useState('') const [content, setContent] = React.useState('')
const [lastComment, setLastComment] = React.useState('') const [commentMode, setCommentMode] = React.useState<'internal' | 'public'>('internal')
const [commentMode, setCommentMode] = React.useState('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) { async function handleSubmit(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
if (!content.trim()) return const nextContent = content.trim()
const comment = await addTicketComment(ticketId, content, { if (!nextContent || !canComment) return
setSubmitting(true)
setError('')
try {
const comment = await addTicketComment(ticketId, nextContent, {
comment_type: commentMode === 'public' ? 'user_reply' : 'internal_note', comment_type: commentMode === 'public' ? 'user_reply' : 'internal_note',
visibility: commentMode === 'public' ? 'public' : 'internal' visibility: commentMode === 'public' ? 'public' : 'internal'
}) })
setLastComment(comment.content) setComments((current) => [...current, comment])
setContent('') setContent('')
onCommentAdded?.() onCommentAdded?.()
toast.success(commentMode === 'public' ? '客户可见记录已保存' : '内部备注已保存')
} catch (reason) {
const message = reason instanceof Error ? reason.message : '评论保存失败'
setError(message)
toast.error(message)
} finally {
setSubmitting(false)
}
} }
return ( return (
<form onSubmit={handleSubmit}>
<AppSection <AppSection
variant="card" variant="card"
title="评论与协作" title="评论记录"
description="选择内容的可见范围后再保存。" description="按时间保留内部协作和客户可见记录;客户可见记录不代表渠道消息已经送达。"
footer={
<>
<span className="text-xs text-muted-foreground">{commentMode === 'public' ? '用户可以看到此回复' : '仅内部成员可见'}</span>
<Button type="submit" variant="secondary" disabled={!content.trim()}>
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
</Button>
</>
}
> >
<div className="grid gap-3"> <div className="grid gap-4">
<Select value={commentMode} onValueChange={setCommentMode}> {error ? (
<SelectTrigger className="w-full sm:w-48"> <Alert variant="destructive">
<SelectValue placeholder="选择回复类型" /> <AlertTitle>评论暂不可用</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<AppEmptyState title="正在加载评论" />
) : comments.length === 0 ? (
<AppEmptyState title="暂无评论" description="排查记录和沟通纪要会按时间显示在这里。" />
) : (
<ol className="grid gap-3" aria-label="评论历史">
{comments.map((comment) => {
const isPublic = comment.visibility === 'public'
const VisibilityIcon = isPublic ? Eye : LockKeyhole
return (
<li key={comment.id} className="rounded-lg border bg-muted/15 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{commentAuthor(comment)}</span>
<Badge variant={isPublic ? 'info' : 'secondary'}>
<VisibilityIcon />
{isPublic ? '客户可见' : '仅内部'}
</Badge>
</div>
<time
className="text-xs text-muted-foreground"
dateTime={comment.created_at}
>
{formatDateTime(comment.created_at)}
</time>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6">{comment.content}</p>
</li>
)
})}
</ol>
)}
{canComment ? (
<form className="grid gap-3 border-t pt-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Select
value={commentMode}
onValueChange={(value) => setCommentMode(value as 'internal' | 'public')}
>
<SelectTrigger className="w-full sm:w-48" aria-label="评论可见范围">
<SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="internal">内部备注</SelectItem> <SelectItem value="internal">内部备注</SelectItem>
<SelectItem value="public">用户可见回复</SelectItem> <SelectItem value="public">客户可见记录</SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<span className="text-xs text-muted-foreground">
{commentMode === 'public'
? '此处只记录客户可见内容,实际发送仍须通过客户回复任务。'
: '只有内部支持成员可以看到。'}
</span>
</div>
<Textarea <Textarea
placeholder={commentMode === 'public' ? '请输入用户可见的处理回复' : '请输入内部排查记录或处理说明'} aria-label="评论内容"
placeholder={
commentMode === 'public'
? '记录已向客户说明的处理进展'
: '记录排查过程、协作结论或后续动作'
}
value={content} value={content}
maxLength={10000}
onChange={(event) => setContent(event.target.value)} onChange={(event) => setContent(event.target.value)}
/> />
{lastComment ? <p className="text-sm text-muted-foreground">最新回复:{lastComment}</p> : null} <div className="flex items-center justify-between gap-3">
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null} <span className="text-xs tabular-nums text-muted-foreground">
{content.length}/10000
</span>
<Button type="submit" variant="secondary" disabled={!content.trim() || submitting}>
{submitting ? (
<MessageSquareText data-icon="inline-start" />
) : (
<Send data-icon="inline-start" />
)}
{submitting
? '正在保存'
: commentMode === 'public'
? '保存客户记录'
: '保存内部备注'}
</Button>
</div>
</form>
) : (
<p className="border-t pt-4 text-sm text-muted-foreground">
当前账号只有查看权限,不能新增评论。
</p>
)}
</div> </div>
</AppSection> </AppSection>
</form>
) )
} }

View File

@@ -0,0 +1,98 @@
import * as React from 'react'
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 { TicketEditSheet } from './TicketEditSheet'
import type { Ticket } from './types'
const mocks = vi.hoisted(() => ({
updateTicket: vi.fn()
}))
vi.mock('./api', () => ({
updateTicket: mocks.updateTicket
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
const ticket: Ticket = {
id: 7,
ticket_no: 'TK202607270007',
title: '报销单无法提交',
description: '华南公司反馈生产环境报销单无法提交。',
category: null,
category_code: null,
status: 'pending_info',
priority: 'medium',
problem_performance: '点击提交没有响应',
problem_object: '报销单',
submitter_name: '张三',
submitter_org: '',
resolution: '',
resolution_id: null,
resolution_type: null,
resolution_source: null,
resolution_root_cause: '',
linked_solution_id: null,
is_resolved: false,
replied_to_customer: false,
impact_scope: '',
environment: '生产环境',
related_system: '财务系统',
product_name: '财务系统',
module_name: '报销模块',
tags: ['报销'],
attempted_actions: '',
error_information: '',
customer_request: '恢复报销提交',
current_result: '尚未恢复',
requester_id: 1,
assignee_id: null,
requester: { id: 1, username: 'support', display_name: '技术支持' },
assignee: null,
problem_started_at: null,
customer_last_message_at: null,
support_first_response_at: null,
support_last_reply_at: null,
resolved_at: null,
closed_at: null,
archived_at: null,
archived_by: null,
created_at: '2026-07-27T08:00:00Z',
updated_at: '2026-07-27T08:00:00Z'
}
describe('TicketEditSheet', () => {
it('突出待补全字段并只提交人工实际修改的字段', async () => {
const user = userEvent.setup()
const onSaved = vi.fn()
const onOpenChange = vi.fn()
const updated = { ...ticket, submitter_org: '华南公司', status: 'pending' as const }
mocks.updateTicket.mockResolvedValue(updated)
render(
<TicketEditSheet
ticket={ticket}
missingFields={['submitter_org']}
open
onOpenChange={onOpenChange}
onSaved={onSaved}
/>
)
expect(screen.getByRole('heading', { name: '补全工单信息' })).toBeInTheDocument()
expect(screen.getByText('待补全')).toBeInTheDocument()
await user.type(screen.getByLabelText(/提出机构/), '华南公司')
await user.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => {
expect(mocks.updateTicket).toHaveBeenCalledWith(7, { submitter_org: '华南公司' })
})
expect(onSaved).toHaveBeenCalledWith(updated)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,400 @@
import * as React from 'react'
import { Save, ShieldCheck } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import { updateTicket } from './api'
import { TICKET_CATEGORIES } from './ticketCategories'
import type { Ticket, TicketCategoryCode, TicketPriority, TicketUpdateInput } from './types'
type TicketEditSheetProps = {
ticket: Ticket
missingFields: string[]
open: boolean
onOpenChange: (open: boolean) => void
onSaved: (ticket: Ticket) => void
}
type FormValues = {
title: string
description: string
category_code: TicketCategoryCode | ''
priority: TicketPriority
problem_performance: string
problem_object: string
submitter_name: string
submitter_org: string
impact_scope: string
environment: string
related_system: string
product_name: string
module_name: string
tags: string
attempted_actions: string
error_information: string
customer_request: string
current_result: string
problem_started_at: string
customer_last_message_at: string
support_first_response_at: string
support_last_reply_at: string
}
const TEXT_FIELDS = [
'title',
'description',
'problem_performance',
'problem_object',
'submitter_name',
'submitter_org',
'impact_scope',
'environment',
'related_system',
'product_name',
'module_name',
'attempted_actions',
'error_information',
'customer_request',
'current_result'
] as const
const TIME_FIELDS = [
'problem_started_at',
'customer_last_message_at',
'support_first_response_at',
'support_last_reply_at'
] as const
function toLocalDateTime(value: string | null) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const pad = (item: number) => String(item).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function toIsoDateTime(value: string) {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function normalizeTags(value: string) {
return [...new Set(value.split(/[,,]/).map((item) => item.trim()).filter(Boolean))].slice(0, 20)
}
function initialValues(ticket: Ticket): FormValues {
return {
title: ticket.title,
description: ticket.description,
category_code: ticket.category_code ?? '',
priority: ticket.priority,
problem_performance: ticket.problem_performance,
problem_object: ticket.problem_object,
submitter_name: ticket.submitter_name,
submitter_org: ticket.submitter_org,
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.join(','),
attempted_actions: ticket.attempted_actions,
error_information: ticket.error_information,
customer_request: ticket.customer_request,
current_result: ticket.current_result,
problem_started_at: toLocalDateTime(ticket.problem_started_at),
customer_last_message_at: toLocalDateTime(ticket.customer_last_message_at),
support_first_response_at: toLocalDateTime(ticket.support_first_response_at),
support_last_reply_at: toLocalDateTime(ticket.support_last_reply_at)
}
}
export function buildTicketChanges(ticket: Ticket, values: FormValues): TicketUpdateInput {
// 只提交真实变化,避免把未触碰的 Agent 字段误标记为人工保护字段。
const changes: TicketUpdateInput = {}
TEXT_FIELDS.forEach((field) => {
if (values[field] !== ticket[field]) changes[field] = values[field]
})
if ((values.category_code || null) !== ticket.category_code) {
changes.category_code = values.category_code || null
}
if (values.priority !== ticket.priority) changes.priority = values.priority
const tags = normalizeTags(values.tags)
if (JSON.stringify(tags) !== JSON.stringify(ticket.tags)) changes.tags = tags
TIME_FIELDS.forEach((field) => {
const value = toIsoDateTime(values[field])
const current = ticket[field] ? new Date(ticket[field]).toISOString() : null
if (value !== current) changes[field] = value
})
return changes
}
export function TicketEditSheet({
ticket,
missingFields,
open,
onOpenChange,
onSaved
}: TicketEditSheetProps) {
const [values, setValues] = React.useState<FormValues>(() => initialValues(ticket))
const [saving, setSaving] = React.useState(false)
const [error, setError] = React.useState('')
const missing = React.useMemo(() => new Set(missingFields), [missingFields])
React.useEffect(() => {
if (open) {
setValues(initialValues(ticket))
setError('')
}
}, [open, ticket])
function setField<K extends keyof FormValues>(field: K, value: FormValues[K]) {
setValues((current) => ({ ...current, [field]: value }))
}
function label(field: string, text: string) {
return (
<span className="flex items-center gap-2">
<span>{text}</span>
{missing.has(field) ? <Badge variant="warning">待补全</Badge> : null}
</span>
)
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const changes = buildTicketChanges(ticket, values)
if (Object.keys(changes).length === 0) {
onOpenChange(false)
return
}
setSaving(true)
setError('')
try {
const updated = await updateTicket(ticket.id, changes)
onSaved(updated)
onOpenChange(false)
toast.success('工单信息已保存')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '保存失败,请检查输入后重试')
} finally {
setSaving(false)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-2xl">
<SheetHeader>
<SheetTitle>补全工单信息</SheetTitle>
<SheetDescription>
只保存发生变化的字段。人工修改会进入保护状态,后续 Agent 不会静默覆盖。
</SheetDescription>
</SheetHeader>
<form className="flex min-h-0 flex-1 flex-col" onSubmit={handleSubmit}>
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 pb-4">
<Alert>
<ShieldCheck />
<AlertTitle>人工信息优先</AlertTitle>
<AlertDescription>
时间按当前设备时区转换并保存为带时区时间;不确定的时间请保持为空。
</AlertDescription>
</Alert>
{error ? (
<Alert variant="destructive">
<AlertTitle>信息未保存</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<FieldSet>
<FieldLegend>核心问题</FieldLegend>
<FieldGroup>
<Field>
<FieldLabel htmlFor="ticket-title">{label('title', '工单标题')}</FieldLabel>
<Input id="ticket-title" value={values.title} onChange={(event) => setField('title', event.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="ticket-description">{label('description', '问题描述')}</FieldLabel>
<Textarea id="ticket-description" rows={5} value={values.description} onChange={(event) => setField('description', event.target.value)} />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>{label('category_code', '问题分类')}</FieldLabel>
<Select value={values.category_code || 'unclassified'} onValueChange={(value) => setField('category_code', value === 'unclassified' ? '' : value as TicketCategoryCode)}>
<SelectTrigger><SelectValue placeholder="选择问题分类" /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="unclassified">暂不分类</SelectItem>
{TICKET_CATEGORIES.map((category) => (
<SelectItem key={category.code} value={category.code}>{category.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>优先级</FieldLabel>
<Select value={values.priority} onValueChange={(value) => setField('priority', value as TicketPriority)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="low">低</SelectItem>
<SelectItem value="medium">中</SelectItem>
<SelectItem value="high">高</SelectItem>
<SelectItem value="urgent">紧急</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
</FieldGroup>
</FieldSet>
<FieldSet>
<FieldLegend>提出方与业务上下文</FieldLegend>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<TextField id="ticket-submitter-name" label={label('submitter_name', '提出人')} value={values.submitter_name} onChange={(value) => setField('submitter_name', value)} />
<TextField id="ticket-submitter-org" label={label('submitter_org', '提出机构')} value={values.submitter_org} onChange={(value) => setField('submitter_org', value)} />
<TextField id="ticket-problem-object" label={label('problem_object', '问题对象')} value={values.problem_object} onChange={(value) => setField('problem_object', value)} />
<TextField id="ticket-environment" label={label('environment', '运行环境')} value={values.environment} onChange={(value) => setField('environment', value)} />
<TextField id="ticket-product-name" label={label('product_name', '产品')} value={values.product_name} onChange={(value) => setField('product_name', value)} />
<TextField id="ticket-module-name" label={label('module_name', '模块')} value={values.module_name} onChange={(value) => setField('module_name', value)} />
<TextField id="ticket-related-system" label={label('related_system', '关联系统')} value={values.related_system} onChange={(value) => setField('related_system', value)} />
<TextField id="ticket-impact-scope" label={label('impact_scope', '影响范围')} value={values.impact_scope} onChange={(value) => setField('impact_scope', value)} />
</div>
<Field>
<FieldLabel htmlFor="ticket-tags">标签</FieldLabel>
<Input id="ticket-tags" value={values.tags} onChange={(event) => setField('tags', event.target.value)} placeholder="使用逗号分隔,例如 E500,报销" />
<FieldDescription>最多保存 20 个标签,每个标签最长 50 个字符。</FieldDescription>
</Field>
</FieldGroup>
</FieldSet>
<FieldSet>
<FieldLegend>问题过程</FieldLegend>
<FieldGroup>
<LongTextField id="ticket-performance" label={label('problem_performance', '问题表现')} value={values.problem_performance} onChange={(value) => setField('problem_performance', value)} />
<LongTextField id="ticket-error" label={label('error_information', '错误信息')} value={values.error_information} onChange={(value) => setField('error_information', value)} />
<LongTextField id="ticket-actions" label={label('attempted_actions', '已尝试动作')} value={values.attempted_actions} onChange={(value) => setField('attempted_actions', value)} />
<LongTextField id="ticket-request" label={label('customer_request', '客户诉求')} value={values.customer_request} onChange={(value) => setField('customer_request', value)} />
<LongTextField id="ticket-result" label={label('current_result', '当前结果')} value={values.current_result} onChange={(value) => setField('current_result', value)} />
</FieldGroup>
</FieldSet>
<FieldSet>
<FieldLegend>业务时间</FieldLegend>
<FieldDescription>仅填写材料中能够可靠确认的时间。</FieldDescription>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<DateTimeField id="ticket-problem-started" label={label('problem_started_at', '问题开始时间')} value={values.problem_started_at} onChange={(value) => setField('problem_started_at', value)} />
<DateTimeField id="ticket-customer-last" label={label('customer_last_message_at', '客户最后消息')} value={values.customer_last_message_at} onChange={(value) => setField('customer_last_message_at', value)} />
<DateTimeField id="ticket-support-first" label={label('support_first_response_at', '支持首次响应')} value={values.support_first_response_at} onChange={(value) => setField('support_first_response_at', value)} />
<DateTimeField id="ticket-support-last" label={label('support_last_reply_at', '支持最后回复')} value={values.support_last_reply_at} onChange={(value) => setField('support_last_reply_at', value)} />
</div>
</FieldGroup>
</FieldSet>
</div>
<SheetFooter>
<SheetClose asChild>
<Button type="button" variant="outline" disabled={saving}>取消</Button>
</SheetClose>
<Button type="submit" disabled={saving}>
<Save data-icon="inline-start" />
{saving ? '正在保存' : '保存修改'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
function TextField({
id,
label,
value,
onChange
}: {
id: string
label: React.ReactNode
value: string
onChange: (value: string) => void
}) {
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input id={id} value={value} onChange={(event) => onChange(event.target.value)} />
</Field>
)
}
function LongTextField({
id,
label,
value,
onChange
}: {
id: string
label: React.ReactNode
value: string
onChange: (value: string) => void
}) {
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Textarea id={id} rows={3} value={value} onChange={(event) => onChange(event.target.value)} />
</Field>
)
}
function DateTimeField({
id,
label,
value,
onChange
}: {
id: string
label: React.ReactNode
value: string
onChange: (value: string) => void
}) {
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input id={id} type="datetime-local" value={value} onChange={(event) => onChange(event.target.value)} />
</Field>
)
}

View File

@@ -34,7 +34,6 @@ type TicketTransitionDialogProps = {
values: { values: {
remark: string remark: string
resolution: string resolution: string
is_resolved?: boolean
replied_to_customer: boolean replied_to_customer: boolean
} }
) => Promise<void> ) => Promise<void>
@@ -80,7 +79,6 @@ export function TicketTransitionDialog({
await onSubmit(status, { await onSubmit(status, {
remark: remark.trim(), remark: remark.trim(),
resolution: resolution.trim(), resolution: resolution.trim(),
is_resolved: status === 'resolved' ? true : undefined,
replied_to_customer: repliedToCustomer replied_to_customer: repliedToCustomer
}) })
onOpenChange(false) onOpenChange(false)

View File

@@ -1,13 +1,24 @@
import { apiRequest } from '@/lib/api' import { apiRequest } from '@/lib/api'
import type { import type {
CustomerReplyTask,
Ticket, Ticket,
TicketAttachment,
TicketCollaborator,
TicketCollaboratorRole,
TicketComment, TicketComment,
TicketEvent, TicketEvent,
TicketFieldEvidence,
TicketInput, TicketInput,
TicketIntakeTrace,
TicketKnowledgeDistillation,
TicketListParams, TicketListParams,
TicketListResponse, TicketListResponse,
TicketStatus TicketSimilaritySuggestion,
TicketSupportAnalysis,
TicketStatus,
TicketUpdateInput,
TicketUser
} from './types' } from './types'
export function listTickets(params: TicketListParams = {}) { export function listTickets(params: TicketListParams = {}) {
@@ -32,17 +43,95 @@ export function getTicket(ticketId: number) {
return apiRequest<Ticket>(`/tickets/${ticketId}`) return apiRequest<Ticket>(`/tickets/${ticketId}`)
} }
export function updateTicket(ticketId: number, payload: TicketUpdateInput) {
return apiRequest<Ticket>(`/tickets/${ticketId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function listTicketEvents(ticketId: number) { export function listTicketEvents(ticketId: number) {
return apiRequest<TicketEvent[]>(`/tickets/${ticketId}/events`) return apiRequest<TicketEvent[]>(`/tickets/${ticketId}/events`)
} }
export function listTicketFieldEvidence(ticketId: number) {
return apiRequest<TicketFieldEvidence[]>(`/tickets/${ticketId}/field-evidence`)
}
export function listTicketAttachments(ticketId: number) {
return apiRequest<TicketAttachment[]>(`/tickets/${ticketId}/attachments`)
}
export function getTicketIntakeTrace(ticketId: number) {
return apiRequest<TicketIntakeTrace>(`/tickets/${ticketId}/intake-trace`)
}
export function listSimilarTickets(ticketId: number) {
return apiRequest<TicketSimilaritySuggestion[]>(`/tickets/${ticketId}/similar`)
}
export function listTicketSupportAnalyses(ticketId: number) {
return apiRequest<TicketSupportAnalysis[]>(`/tickets/${ticketId}/support-analyses`)
}
export function startTicketSupportAnalysis(ticketId: number) {
return apiRequest<TicketSupportAnalysis>(`/tickets/${ticketId}/support-analyses`, {
method: 'POST'
})
}
export function listTicketKnowledgeDistillations(ticketId: number) {
return apiRequest<TicketKnowledgeDistillation[]>(
`/tickets/${ticketId}/knowledge-distillations`
)
}
export function startTicketKnowledgeDistillation(ticketId: number) {
return apiRequest<TicketKnowledgeDistillation>(
`/tickets/${ticketId}/knowledge-distillations`,
{ method: 'POST' }
)
}
export function listCustomerReplyTasks(ticketId: number) {
return apiRequest<CustomerReplyTask[]>(`/tickets/${ticketId}/reply-tasks`)
}
export function updateCustomerReplyTask(
ticketId: number,
replyTaskId: number,
finalText: string,
expectedRevision: number
) {
return apiRequest<CustomerReplyTask>(`/tickets/${ticketId}/reply-tasks/${replyTaskId}`, {
method: 'PATCH',
body: JSON.stringify({
final_text: finalText,
expected_revision: expectedRevision
})
})
}
export function confirmCustomerReplySent(
ticketId: number,
replyTaskId: number,
expectedRevision: number
) {
return apiRequest<CustomerReplyTask>(
`/tickets/${ticketId}/reply-tasks/${replyTaskId}/confirm-sent`,
{
method: 'POST',
body: JSON.stringify({ expected_revision: expectedRevision })
}
)
}
export function transitionTicket( export function transitionTicket(
ticketId: number, ticketId: number,
status: TicketStatus, status: TicketStatus,
options: { options: {
remark?: string remark?: string
resolution?: string resolution?: string
is_resolved?: boolean
replied_to_customer?: boolean replied_to_customer?: boolean
} = {} } = {}
) { ) {
@@ -52,7 +141,6 @@ export function transitionTicket(
status, status,
remark: options.remark ?? '', remark: options.remark ?? '',
resolution: options.resolution ?? '', resolution: options.resolution ?? '',
is_resolved: options.is_resolved,
replied_to_customer: options.replied_to_customer replied_to_customer: options.replied_to_customer
}) })
}) })
@@ -72,3 +160,33 @@ export function addTicketComment(
}) })
}) })
} }
export function listTicketComments(ticketId: number) {
return apiRequest<TicketComment[]>(`/tickets/${ticketId}/comments`)
}
export function listTicketParticipants() {
return apiRequest<TicketUser[]>('/tickets/participants')
}
export function listTicketCollaborators(ticketId: number) {
return apiRequest<TicketCollaborator[]>(`/tickets/${ticketId}/collaborators`)
}
export function addTicketCollaborator(
ticketId: number,
userId: number,
role: TicketCollaboratorRole
) {
return apiRequest<TicketCollaborator>(`/tickets/${ticketId}/collaborators`, {
method: 'POST',
body: JSON.stringify({ user_id: userId, role })
})
}
export function removeTicketCollaborator(ticketId: number, collaboratorId: number) {
return apiRequest<{ success: boolean }>(
`/tickets/${ticketId}/collaborators/${collaboratorId}`,
{ method: 'DELETE' }
)
}

View File

@@ -36,17 +36,36 @@ export type Ticket = {
problem_object: string problem_object: string
submitter_name: string submitter_name: string
submitter_org: string submitter_org: string
ai_solution: string
resolution: string resolution: string
resolution_id: number | null
resolution_type: string | null
resolution_source: string | null
resolution_root_cause: string
linked_solution_id: number | null
is_resolved: boolean is_resolved: boolean
replied_to_customer: boolean replied_to_customer: boolean
impact_scope: string impact_scope: string
environment: string environment: string
related_system: string related_system: string
product_name: string
module_name: string
tags: string[]
attempted_actions: string
error_information: string
customer_request: string
current_result: string
requester_id: number | null requester_id: number | null
assignee_id: number | null assignee_id: number | null
requester: TicketUser | null requester: TicketUser | null
assignee: TicketUser | null assignee: TicketUser | null
problem_started_at: string | null
customer_last_message_at: string | null
support_first_response_at: string | null
support_last_reply_at: string | null
resolved_at: string | null
closed_at: string | null
archived_at: string | null
archived_by: TicketUser | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -61,6 +80,131 @@ export type TicketListResponse = {
export type TicketListView = 'all' | 'assigned_to_me' | 'created_by_me' | 'high_priority' export type TicketListView = 'all' | 'assigned_to_me' | 'created_by_me' | 'high_priority'
export type SupportAnalysisReference = {
rank: number
source_type: 'knowledge' | 'solution' | 'historical_ticket'
source_id: number
source_version: string
title: string
excerpt: string
score: number
applicable_scope: Record<string, unknown>
auto_resolve_policy?: {
allow_auto_resolve: boolean
requires_human_action: boolean
requires_external_confirmation: boolean
requires_production_change: boolean
}
}
export type SupportAnalysisOutput = {
analysis_summary: string
troubleshooting_steps: string[]
candidate_solution_ids: number[]
customer_reply_draft: string
citations: number[]
auto_resolve_recommended: boolean
}
export type TicketSupportAnalysis = {
id: number
ticket_id: number
run_public_id: string
agent_version: number
status: string
progress_stage: string
progress_message: string
query: string
retrieval_policy: Record<string, unknown>
references: SupportAnalysisReference[]
final_output: SupportAnalysisOutput | null
automation_decision: {
policy_version?: string
status?: 'eligible' | 'manual_recommendation' | 'auto_resolved'
threshold?: number
solution_id?: number | null
score?: number
checks?: Record<string, boolean>
reasons?: string[]
applied?: boolean
evaluated_at?: string
}
applied_solution_id: number | null
processed_at: string | null
error_code: string
error_message: string
created_at: string
started_at: string | null
finished_at: string | null
}
export type KnowledgeDistillationOutput = {
should_distill: boolean
reason: string
assessment: {
novelty_score: number
reuse_value_score: number
completeness_score: number
duplicate_risk_score: number
}
citations: number[]
knowledge_draft: Record<string, unknown>
troubleshooting_plan_draft: Record<string, unknown>
solution_draft: Record<string, unknown>
}
export type TicketKnowledgeDistillation = {
id: number
ticket_id: number
run_public_id: string
agent_version: number
status: string
progress_stage: string
progress_message: string
references: SupportAnalysisReference[]
retrieval_policy: Record<string, unknown>
final_output: KnowledgeDistillationOutput | null
should_distill: boolean | null
reason: string
assessment: Record<string, number>
knowledge_item_id: number | null
troubleshooting_plan_id: number | null
solution_id: number | null
processed_at: string | null
error_code: string
error_message: string
created_at: string
started_at: string | null
finished_at: string | null
}
export type CustomerReplyRevision = {
version: number
content: string
source: 'agent' | 'human' | 'channel' | 'system'
edited_by_id: number | null
created_at: string
}
export type CustomerReplyTask = {
id: number
ticket_id: number
support_analysis_id: number | null
status: 'draft' | 'approved' | 'sent' | 'cancelled'
draft_text: string
final_text: string
revision: number
created_by_id: number | null
updated_by_id: number | null
approved_by_id: number | null
approved_at: string | null
sent_by_id: number | null
sent_at: string | null
created_at: string
updated_at: string
revisions: CustomerReplyRevision[]
}
export type TicketListParams = { export type TicketListParams = {
page?: number page?: number
page_size?: number page_size?: number
@@ -73,6 +217,7 @@ export type TicketListParams = {
view?: TicketListView view?: TicketListView
status_group?: 'active' | 'completed' | '' status_group?: 'active' | 'completed' | ''
resolved_within_days?: number resolved_within_days?: number
include_archived?: boolean
} }
export type TicketInput = { export type TicketInput = {
@@ -87,13 +232,56 @@ export type TicketInput = {
impact_scope: string impact_scope: string
environment: string environment: string
related_system: string related_system: string
product_name?: string
module_name?: string
tags?: string[]
attempted_actions?: string
error_information?: string
customer_request?: string
current_result?: string
} }
export type TicketUpdateInput = Partial<{
title: string
description: string
category_code: TicketCategoryCode | null
priority: TicketPriority
problem_performance: string
problem_object: string
submitter_name: string
submitter_org: string
impact_scope: string
environment: string
related_system: string
product_name: string
module_name: string
tags: string[]
attempted_actions: string
error_information: string
customer_request: string
current_result: string
problem_started_at: string | null
customer_last_message_at: string | null
support_first_response_at: string | null
support_last_reply_at: string | null
}>
export type TicketComment = { export type TicketComment = {
id: number id: number
content: string content: string
comment_type: string comment_type: string
visibility: string visibility: string
created_by: TicketUser | null
created_at: string
}
export type TicketCollaboratorRole = 'collaborator' | 'watcher'
export type TicketCollaborator = {
id: number
user: TicketUser
role: TicketCollaboratorRole
added_by: TicketUser | null
created_at: string created_at: string
} }
@@ -104,5 +292,82 @@ export type TicketEvent = {
to_value: Record<string, unknown> to_value: Record<string, unknown>
remark: string remark: string
operator: TicketUser | null operator: TicketUser | null
source: 'human' | 'agent' | 'channel' | 'system'
agent_run_id: number | null
metadata: Record<string, unknown>
created_at: string created_at: string
} }
export type TicketFieldEvidence = {
id: number
field_name: string
value: unknown
source: 'human' | 'agent' | 'channel' | 'system'
confidence: number | null
evidence: {
excerpt?: string
source_refs?: string[]
location?: string
raw_value?: string
timezone?: string
conflicts?: string[]
generated_from?: string
}
agent_run_id: number | null
is_protected: boolean
created_by: TicketUser | null
created_at: string
}
export type TicketAttachment = {
id: number
file_name: string
file_type: string
mime_type: string
file_size: number
storage_path: string
sha256: string
scan_status: string
scan_result: Record<string, unknown>
parse_status: string
parse_error: string
parsed_text: string
created_at: string
}
export type TicketIntakeTrace = {
available: boolean
public_id: string | null
status: string
source_text: string
problem_valid: boolean | null
invalid_reason: string
missing_fields: string[]
message_timeline: Array<{
sequence: number
speaker: string
role: 'customer' | 'support' | 'unknown'
content: string
occurred_at: string
occurred_at_raw: string
timezone: string
source_ref: string
confidence: number | null
conflict: string
}>
agent_run_public_id: string | null
}
export type TicketSimilaritySuggestion = {
ticket_id: number
ticket_no: string
title: string
status: TicketStatus
category_code: TicketCategoryCode | null
score: number
title_similarity: number
keyword_score: number
matched_excerpt: string
algorithm_version: string
created_at: string | null
}