feat: consolidate ticket domain baseline
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,52 +771,33 @@ 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):
|
||||||
require_permission(request, "ticket.edit")
|
changes = payload.dict(exclude_unset=True)
|
||||||
|
if "assignee_id" in changes:
|
||||||
|
require_permission(request, "ticket.assign")
|
||||||
|
if set(changes) - {"assignee_id"}:
|
||||||
|
require_permission(request, "ticket.edit")
|
||||||
|
if not changes:
|
||||||
|
require_permission(request, "ticket.edit")
|
||||||
ticket = Ticket.objects.get(id=ticket_id)
|
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):
|
||||||
require_permission(request, "ticket.edit")
|
if payload.status == "closed":
|
||||||
|
require_permission(request, "ticket.close")
|
||||||
|
elif payload.status == "resolved":
|
||||||
|
require_permission(request, "ticket.resolve")
|
||||||
|
else:
|
||||||
|
require_permission(request, "ticket.edit")
|
||||||
ticket = Ticket.objects.select_related("category").get(id=ticket_id)
|
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
|
||||||
has_resolution = bool(payload.resolution.strip())
|
has_resolution = bool(payload.resolution.strip())
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
109
backend/apps/tickets/tests/test_ticket_domain_baseline.py
Normal file
109
backend/apps/tickets/tests/test_ticket_domain_baseline.py
Normal 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() == []
|
||||||
105
frontend/src/features/tickets/TicketCollaborationPanel.test.tsx
Normal file
105
frontend/src/features/tickets/TicketCollaborationPanel.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
234
frontend/src/features/tickets/TicketCollaborationPanel.tsx
Normal file
234
frontend/src/features/tickets/TicketCollaborationPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
frontend/src/features/tickets/TicketComments.test.tsx
Normal file
74
frontend/src/features/tickets/TicketComments.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
comment_type: commentMode === 'public' ? 'user_reply' : 'internal_note',
|
setSubmitting(true)
|
||||||
visibility: commentMode === 'public' ? 'public' : 'internal'
|
setError('')
|
||||||
})
|
try {
|
||||||
setLastComment(comment.content)
|
const comment = await addTicketComment(ticketId, nextContent, {
|
||||||
setContent('')
|
comment_type: commentMode === 'public' ? 'user_reply' : 'internal_note',
|
||||||
onCommentAdded?.()
|
visibility: commentMode === 'public' ? 'public' : 'internal'
|
||||||
|
})
|
||||||
|
setComments((current) => [...current, comment])
|
||||||
|
setContent('')
|
||||||
|
onCommentAdded?.()
|
||||||
|
toast.success(commentMode === 'public' ? '客户可见记录已保存' : '内部备注已保存')
|
||||||
|
} catch (reason) {
|
||||||
|
const message = reason instanceof Error ? reason.message : '评论保存失败'
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<AppSection
|
||||||
<AppSection
|
variant="card"
|
||||||
variant="card"
|
title="评论记录"
|
||||||
title="评论与协作"
|
description="按时间保留内部协作和客户可见记录;客户可见记录不代表渠道消息已经送达。"
|
||||||
description="选择内容的可见范围后再保存。"
|
>
|
||||||
footer={
|
<div className="grid gap-4">
|
||||||
<>
|
{error ? (
|
||||||
<span className="text-xs text-muted-foreground">{commentMode === 'public' ? '用户可以看到此回复' : '仅内部成员可见'}</span>
|
<Alert variant="destructive">
|
||||||
<Button type="submit" variant="secondary" disabled={!content.trim()}>
|
<AlertTitle>评论暂不可用</AlertTitle>
|
||||||
{commentMode === 'public' ? '发表用户回复' : '保存内部备注'}
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Button>
|
</Alert>
|
||||||
</>
|
) : null}
|
||||||
}
|
|
||||||
>
|
{loading ? (
|
||||||
<div className="grid gap-3">
|
<AppEmptyState title="正在加载评论" />
|
||||||
<Select value={commentMode} onValueChange={setCommentMode}>
|
) : comments.length === 0 ? (
|
||||||
<SelectTrigger className="w-full sm:w-48">
|
<AppEmptyState title="暂无评论" description="排查记录和沟通纪要会按时间显示在这里。" />
|
||||||
<SelectValue placeholder="选择回复类型" />
|
) : (
|
||||||
</SelectTrigger>
|
<ol className="grid gap-3" aria-label="评论历史">
|
||||||
<SelectContent>
|
{comments.map((comment) => {
|
||||||
<SelectGroup>
|
const isPublic = comment.visibility === 'public'
|
||||||
<SelectItem value="internal">内部备注</SelectItem>
|
const VisibilityIcon = isPublic ? Eye : LockKeyhole
|
||||||
<SelectItem value="public">用户可见回复</SelectItem>
|
return (
|
||||||
</SelectGroup>
|
<li key={comment.id} className="rounded-lg border bg-muted/15 p-3">
|
||||||
</SelectContent>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
</Select>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Textarea
|
<span className="font-medium">{commentAuthor(comment)}</span>
|
||||||
placeholder={commentMode === 'public' ? '请输入用户可见的处理回复' : '请输入内部排查记录或处理说明'}
|
<Badge variant={isPublic ? 'info' : 'secondary'}>
|
||||||
value={content}
|
<VisibilityIcon />
|
||||||
onChange={(event) => setContent(event.target.value)}
|
{isPublic ? '客户可见' : '仅内部'}
|
||||||
/>
|
</Badge>
|
||||||
{lastComment ? <p className="text-sm text-muted-foreground">最新回复:{lastComment}</p> : null}
|
</div>
|
||||||
{!lastComment ? <p className="text-sm text-muted-foreground">暂无评论</p> : null}
|
<time
|
||||||
</div>
|
className="text-xs text-muted-foreground"
|
||||||
</AppSection>
|
dateTime={comment.created_at}
|
||||||
</form>
|
>
|
||||||
|
{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>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="internal">内部备注</SelectItem>
|
||||||
|
<SelectItem value="public">客户可见记录</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{commentMode === 'public'
|
||||||
|
? '此处只记录客户可见内容,实际发送仍须通过客户回复任务。'
|
||||||
|
: '只有内部支持成员可以看到。'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
aria-label="评论内容"
|
||||||
|
placeholder={
|
||||||
|
commentMode === 'public'
|
||||||
|
? '记录已向客户说明的处理进展'
|
||||||
|
: '记录排查过程、协作结论或后续动作'
|
||||||
|
}
|
||||||
|
value={content}
|
||||||
|
maxLength={10000}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<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>
|
||||||
|
</AppSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
98
frontend/src/features/tickets/TicketEditSheet.test.tsx
Normal file
98
frontend/src/features/tickets/TicketEditSheet.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
400
frontend/src/features/tickets/TicketEditSheet.tsx
Normal file
400
frontend/src/features/tickets/TicketEditSheet.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user