feat: consolidate ticket domain baseline
This commit is contained in:
@@ -10,26 +10,82 @@ from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.agents.services import AgentValidationError
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.tickets.models import Ticket, TicketAttachment, TicketCategory, TicketComment, TicketEvent
|
||||
from apps.knowledge.distillation import (
|
||||
KnowledgeDistillationError,
|
||||
start_ticket_knowledge_distillation,
|
||||
)
|
||||
from apps.knowledge.models import TicketKnowledgeDistillation
|
||||
from apps.skills.services import SkillValidationError
|
||||
from apps.tickets.evaluation import build_intake_quality_metrics, record_evaluation_result
|
||||
from apps.tickets.models import (
|
||||
CustomerReplyTask,
|
||||
IntakeEvaluationCase,
|
||||
SupportEvaluationCase,
|
||||
Ticket,
|
||||
TicketAttachment,
|
||||
TicketCollaborator,
|
||||
TicketCollaboratorRole,
|
||||
TicketComment,
|
||||
TicketDraft,
|
||||
TicketEvent,
|
||||
TicketFieldEvidence,
|
||||
TicketSimilaritySuggestion,
|
||||
TicketSupportAnalysis,
|
||||
)
|
||||
from apps.tickets.replies import (
|
||||
CustomerReplyError,
|
||||
confirm_customer_reply_sent,
|
||||
update_customer_reply,
|
||||
)
|
||||
from apps.tickets.schemas import (
|
||||
CustomerReplyConfirmIn,
|
||||
CustomerReplyTaskOut,
|
||||
CustomerReplyUpdateIn,
|
||||
IntakeEvaluationRecordIn,
|
||||
IntakeEvaluationResultOut,
|
||||
IntakeQualityMetricsOut,
|
||||
SupportEvaluationRecordIn,
|
||||
SupportEvaluationResultOut,
|
||||
SupportQualityMetricsOut,
|
||||
TicketAttachmentIn,
|
||||
TicketAttachmentOut,
|
||||
TicketCollaboratorIn,
|
||||
TicketCollaboratorOut,
|
||||
TicketCommentIn,
|
||||
TicketCommentOut,
|
||||
TicketEventOut,
|
||||
TicketFieldEvidenceOut,
|
||||
TicketIn,
|
||||
TicketIntakeTraceOut,
|
||||
TicketKnowledgeDistillationOut,
|
||||
TicketListOut,
|
||||
TicketOut,
|
||||
TicketSimilaritySuggestionOut,
|
||||
TicketStatusIn,
|
||||
TicketSupportAnalysisOut,
|
||||
TicketUpdateIn,
|
||||
TicketUserOut,
|
||||
)
|
||||
from apps.tickets.services import (
|
||||
TicketDataError,
|
||||
TicketStatusError,
|
||||
create_ticket_from_fields,
|
||||
set_ticket_archived,
|
||||
transition_requires_remark,
|
||||
transition_ticket,
|
||||
update_ticket_fields,
|
||||
)
|
||||
from apps.tickets.similarity import ALGORITHM_VERSION, find_similar_tickets
|
||||
from apps.tickets.support_analysis import (
|
||||
SupportAnalysisError,
|
||||
start_ticket_support_analysis,
|
||||
)
|
||||
from apps.tickets.support_evaluation import (
|
||||
SupportEvaluationError,
|
||||
build_support_quality_metrics,
|
||||
record_support_evaluation_result,
|
||||
)
|
||||
|
||||
router = Router(tags=["tickets"])
|
||||
@@ -63,6 +119,7 @@ def serialize_ticket_category(category):
|
||||
|
||||
|
||||
def serialize_ticket(ticket: Ticket) -> dict:
|
||||
resolution = getattr(ticket, "resolution", None)
|
||||
return {
|
||||
"id": ticket.id,
|
||||
"ticket_no": ticket.ticket_no,
|
||||
@@ -76,17 +133,36 @@ def serialize_ticket(ticket: Ticket) -> dict:
|
||||
"problem_object": ticket.problem_object,
|
||||
"submitter_name": ticket.submitter_name,
|
||||
"submitter_org": ticket.submitter_org,
|
||||
"ai_solution": ticket.ai_solution,
|
||||
"resolution": ticket.resolution_summary,
|
||||
"is_resolved": ticket.is_resolved,
|
||||
"resolution": resolution.solution_text if resolution else "",
|
||||
"resolution_id": resolution.id if resolution else None,
|
||||
"resolution_type": resolution.resolution_type if resolution else None,
|
||||
"resolution_source": resolution.source if resolution else None,
|
||||
"resolution_root_cause": resolution.root_cause if resolution else "",
|
||||
"linked_solution_id": resolution.linked_solution_id if resolution else None,
|
||||
"is_resolved": resolution is not None and ticket.status in {"resolved", "closed"},
|
||||
"replied_to_customer": ticket.replied_to_customer,
|
||||
"impact_scope": ticket.impact_scope,
|
||||
"environment": ticket.environment,
|
||||
"related_system": ticket.related_system,
|
||||
"product_name": ticket.product_name,
|
||||
"module_name": ticket.module_name,
|
||||
"tags": ticket.tags,
|
||||
"attempted_actions": ticket.attempted_actions,
|
||||
"error_information": ticket.error_information,
|
||||
"customer_request": ticket.customer_request,
|
||||
"current_result": ticket.current_result,
|
||||
"requester_id": ticket.requester_id,
|
||||
"assignee_id": ticket.assignee_id,
|
||||
"requester": serialize_ticket_user(ticket.requester),
|
||||
"assignee": serialize_ticket_user(ticket.assignee),
|
||||
"problem_started_at": ticket.problem_started_at,
|
||||
"customer_last_message_at": ticket.customer_last_message_at,
|
||||
"support_first_response_at": ticket.support_first_response_at,
|
||||
"support_last_reply_at": ticket.support_last_reply_at,
|
||||
"resolved_at": ticket.resolved_at,
|
||||
"closed_at": ticket.closed_at,
|
||||
"archived_at": ticket.archived_at,
|
||||
"archived_by": serialize_ticket_user(ticket.archived_by),
|
||||
"created_at": ticket.created_at,
|
||||
"updated_at": ticket.updated_at,
|
||||
}
|
||||
@@ -100,10 +176,207 @@ def serialize_ticket_event(event: TicketEvent) -> dict:
|
||||
"to_value": event.to_value,
|
||||
"remark": event.remark,
|
||||
"operator": serialize_ticket_user(event.operator),
|
||||
"source": event.source,
|
||||
"agent_run_id": event.agent_run_id,
|
||||
"metadata": event.metadata,
|
||||
"created_at": event.created_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_ticket_collaborator(collaborator: TicketCollaborator) -> dict:
|
||||
return {
|
||||
"id": collaborator.id,
|
||||
"user": serialize_ticket_user(collaborator.user),
|
||||
"role": collaborator.role,
|
||||
"added_by": serialize_ticket_user(collaborator.added_by),
|
||||
"created_at": collaborator.created_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_ticket_comment(comment: TicketComment) -> dict:
|
||||
return {
|
||||
"id": comment.id,
|
||||
"content": comment.content,
|
||||
"comment_type": comment.comment_type,
|
||||
"visibility": comment.visibility,
|
||||
"created_by": serialize_ticket_user(comment.created_by),
|
||||
"created_at": comment.created_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_field_evidence(item: TicketFieldEvidence) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"field_name": item.field_name,
|
||||
"value": item.value,
|
||||
"source": item.source,
|
||||
"confidence": float(item.confidence) if item.confidence is not None else None,
|
||||
"evidence": item.evidence,
|
||||
"agent_run_id": item.agent_run_id,
|
||||
"is_protected": item.is_protected,
|
||||
"created_by": serialize_ticket_user(item.created_by),
|
||||
"created_at": item.created_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_similarity_suggestion(item: TicketSimilaritySuggestion) -> dict:
|
||||
candidate = item.candidate_ticket
|
||||
return {
|
||||
"ticket_id": candidate.id,
|
||||
"ticket_no": candidate.ticket_no,
|
||||
"title": candidate.title,
|
||||
"status": candidate.status,
|
||||
"category_code": candidate.category.code if candidate.category_id else None,
|
||||
"score": float(item.score),
|
||||
"title_similarity": float(item.title_similarity),
|
||||
"keyword_score": float(item.keyword_score),
|
||||
"matched_excerpt": item.matched_excerpt,
|
||||
"algorithm_version": item.algorithm_version,
|
||||
"created_at": item.created_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_dynamic_similarity(item: dict) -> dict:
|
||||
candidate = item["ticket"]
|
||||
return {
|
||||
"ticket_id": candidate.id,
|
||||
"ticket_no": candidate.ticket_no,
|
||||
"title": candidate.title,
|
||||
"status": candidate.status,
|
||||
"category_code": candidate.category.code if candidate.category_id else None,
|
||||
"score": item["score"],
|
||||
"title_similarity": item["title_similarity"],
|
||||
"keyword_score": item["keyword_score"],
|
||||
"matched_excerpt": item["matched_excerpt"],
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
"created_at": None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_support_analysis(item: TicketSupportAnalysis) -> dict:
|
||||
run = item.run
|
||||
return {
|
||||
"id": item.id,
|
||||
"ticket_id": item.ticket_id,
|
||||
"run_public_id": str(run.public_id),
|
||||
"agent_version": run.agent_version.version,
|
||||
"status": run.status,
|
||||
"progress_stage": run.progress_stage,
|
||||
"progress_message": run.progress_message,
|
||||
"query": item.query,
|
||||
"retrieval_policy": item.retrieval_policy,
|
||||
"references": item.references,
|
||||
"final_output": run.final_output,
|
||||
"automation_decision": item.automation_decision,
|
||||
"applied_solution_id": item.applied_solution_id,
|
||||
"processed_at": item.processed_at,
|
||||
"error_code": run.error_code,
|
||||
"error_message": run.error_message,
|
||||
"created_at": item.created_at,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_knowledge_distillation(item: TicketKnowledgeDistillation) -> dict:
|
||||
run = item.run
|
||||
return {
|
||||
"id": item.id,
|
||||
"ticket_id": item.ticket_id,
|
||||
"run_public_id": str(run.public_id),
|
||||
"agent_version": run.agent_version.version,
|
||||
"status": run.status,
|
||||
"progress_stage": run.progress_stage,
|
||||
"progress_message": run.progress_message,
|
||||
"references": item.references,
|
||||
"retrieval_policy": item.retrieval_policy,
|
||||
"final_output": run.final_output,
|
||||
"should_distill": item.should_distill,
|
||||
"reason": item.reason,
|
||||
"assessment": item.assessment,
|
||||
"knowledge_item_id": item.knowledge_item_id,
|
||||
"troubleshooting_plan_id": item.troubleshooting_plan_id,
|
||||
"solution_id": item.solution_id,
|
||||
"processed_at": item.processed_at,
|
||||
"error_code": run.error_code,
|
||||
"error_message": run.error_message,
|
||||
"created_at": item.created_at,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_customer_reply_task(item: CustomerReplyTask) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"ticket_id": item.ticket_id,
|
||||
"support_analysis_id": item.support_analysis_id,
|
||||
"status": item.status,
|
||||
"draft_text": item.draft_text,
|
||||
"final_text": item.final_text,
|
||||
"revision": item.revision,
|
||||
"created_by_id": item.created_by_id,
|
||||
"updated_by_id": item.updated_by_id,
|
||||
"approved_by_id": item.approved_by_id,
|
||||
"approved_at": item.approved_at,
|
||||
"sent_by_id": item.sent_by_id,
|
||||
"sent_at": item.sent_at,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
"revisions": [
|
||||
{
|
||||
"version": revision.version,
|
||||
"content": revision.content,
|
||||
"source": revision.source,
|
||||
"edited_by_id": revision.edited_by_id,
|
||||
"created_at": revision.created_at,
|
||||
}
|
||||
for revision in item.revisions.all()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def serialize_evaluation_result(result) -> dict:
|
||||
return {
|
||||
"id": result.id,
|
||||
"case_key": result.case.key,
|
||||
"case_source_kind": result.case.source_kind,
|
||||
"draft_public_id": str(result.draft.public_id),
|
||||
"ticket_id": result.ticket_id,
|
||||
"actual_auto_created": result.actual_auto_created,
|
||||
"correct_field_count": result.correct_field_count,
|
||||
"evaluated_field_count": result.evaluated_field_count,
|
||||
"field_accuracy": (
|
||||
float(result.field_accuracy) if result.field_accuracy is not None else None
|
||||
),
|
||||
"passed": result.passed,
|
||||
"details": result.details,
|
||||
"created_at": result.created_at,
|
||||
"updated_at": result.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_support_evaluation_result(result) -> dict:
|
||||
return {
|
||||
"id": result.id,
|
||||
"case_key": result.case.key,
|
||||
"case_source_kind": result.case.source_kind,
|
||||
"analysis_id": result.analysis_id,
|
||||
"ticket_id": result.analysis.ticket_id,
|
||||
"actual_candidate_solution_ids": result.actual_candidate_solution_ids,
|
||||
"actual_citation_ranks": result.actual_citation_ranks,
|
||||
"matched_expected_solution": result.matched_expected_solution,
|
||||
"actual_auto_resolved": result.actual_auto_resolved,
|
||||
"actual_manual_fallback": result.actual_manual_fallback,
|
||||
"incorrect_auto_reuse": result.incorrect_auto_reuse,
|
||||
"citations_valid": result.citations_valid,
|
||||
"passed": result.passed,
|
||||
"details": result.details,
|
||||
"created_at": result.created_at,
|
||||
"updated_at": result.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response=TicketOut)
|
||||
def create_ticket(request, payload: TicketIn):
|
||||
require_permission(request, "ticket.create")
|
||||
@@ -128,9 +401,19 @@ def list_tickets(
|
||||
view: str = "all",
|
||||
status_group: str | None = None,
|
||||
resolved_within_days: int | None = None,
|
||||
include_archived: bool = False,
|
||||
):
|
||||
require_permission(request, "ticket.view")
|
||||
queryset = Ticket.objects.select_related("requester", "assignee", "category").all()
|
||||
queryset = Ticket.objects.select_related(
|
||||
"requester",
|
||||
"assignee",
|
||||
"category",
|
||||
"resolution",
|
||||
"resolution__linked_solution",
|
||||
"archived_by",
|
||||
)
|
||||
if not include_archived:
|
||||
queryset = queryset.filter(archived_at__isnull=True)
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
if priority:
|
||||
@@ -146,6 +429,11 @@ def list_tickets(
|
||||
| Q(problem_object__icontains=q)
|
||||
| Q(submitter_name__icontains=q)
|
||||
| Q(submitter_org__icontains=q)
|
||||
| Q(product_name__icontains=q)
|
||||
| Q(module_name__icontains=q)
|
||||
| Q(error_information__icontains=q)
|
||||
| Q(customer_request__icontains=q)
|
||||
| Q(current_result__icontains=q)
|
||||
)
|
||||
if related_system:
|
||||
queryset = queryset.filter(related_system__icontains=related_system)
|
||||
@@ -192,7 +480,7 @@ def export_tickets(request):
|
||||
response["Content-Disposition"] = 'attachment; filename="tickets.csv"'
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(["ticket_no", "title", "status", "priority", "created_at"])
|
||||
for ticket in Ticket.objects.all().order_by("ticket_no"):
|
||||
for ticket in Ticket.objects.filter(archived_at__isnull=True).order_by("ticket_no"):
|
||||
writer.writerow(
|
||||
[
|
||||
ticket.ticket_no,
|
||||
@@ -213,14 +501,267 @@ def export_tickets(request):
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/intake-quality-metrics", response=IntakeQualityMetricsOut)
|
||||
def get_intake_quality_metrics(request, include_synthetic: bool = False):
|
||||
require_permission(request, "ticket.view")
|
||||
return build_intake_quality_metrics(include_synthetic=include_synthetic)
|
||||
|
||||
|
||||
@router.get("/participants", response=list[TicketUserOut])
|
||||
def list_ticket_participants(request):
|
||||
require_permission(request, "ticket.view")
|
||||
users = get_user_model().objects.filter(is_active=True).order_by("first_name", "username", "id")
|
||||
return [serialize_ticket_user(user) for user in users]
|
||||
|
||||
|
||||
@router.post("/intake-evaluations", response=IntakeEvaluationResultOut)
|
||||
def create_intake_evaluation(request, payload: IntakeEvaluationRecordIn):
|
||||
require_permission(request, "ticket.edit")
|
||||
case = IntakeEvaluationCase.objects.filter(key=payload.case_key, enabled=True).first()
|
||||
if case is None:
|
||||
raise HttpError(404, "评估案例不存在或已停用")
|
||||
draft = TicketDraft.objects.select_related("confirmed_ticket").filter(
|
||||
public_id=payload.draft_public_id
|
||||
).first()
|
||||
if draft is None:
|
||||
raise HttpError(404, "受理记录不存在")
|
||||
result = record_evaluation_result(case, draft, reviewer=request.user)
|
||||
OperationAuditLog.objects.create(
|
||||
user=request.user,
|
||||
module="tickets",
|
||||
action="intake_quality_evaluated",
|
||||
target_type="ticket_draft",
|
||||
target_id=str(draft.public_id),
|
||||
request_snapshot={"case_key": case.key, "source_kind": case.source_kind},
|
||||
result="success",
|
||||
)
|
||||
return serialize_evaluation_result(result)
|
||||
|
||||
|
||||
@router.get("/support-quality-metrics", response=SupportQualityMetricsOut)
|
||||
def get_support_quality_metrics(request, include_synthetic: bool = False):
|
||||
require_permission(request, "ticket.view")
|
||||
require_permission(request, "knowledge.view")
|
||||
require_permission(request, "solution.view")
|
||||
return build_support_quality_metrics(include_synthetic=include_synthetic)
|
||||
|
||||
|
||||
@router.post("/support-evaluations", response=SupportEvaluationResultOut)
|
||||
def create_support_evaluation(request, payload: SupportEvaluationRecordIn):
|
||||
require_permission(request, "ticket.edit")
|
||||
require_permission(request, "knowledge.view")
|
||||
require_permission(request, "solution.view")
|
||||
case = SupportEvaluationCase.objects.filter(
|
||||
key=payload.case_key,
|
||||
enabled=True,
|
||||
).first()
|
||||
if case is None:
|
||||
raise HttpError(404, "支持分析评估案例不存在或已停用")
|
||||
analysis = TicketSupportAnalysis.objects.select_related("run", "ticket").filter(
|
||||
id=payload.analysis_id
|
||||
).first()
|
||||
if analysis is None:
|
||||
raise HttpError(404, "支持分析记录不存在")
|
||||
try:
|
||||
result = record_support_evaluation_result(
|
||||
case,
|
||||
analysis,
|
||||
reviewer=request.user,
|
||||
)
|
||||
except SupportEvaluationError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
OperationAuditLog.objects.create(
|
||||
user=request.user,
|
||||
module="tickets",
|
||||
action="support_quality_evaluated",
|
||||
target_type="ticket_support_analysis",
|
||||
target_id=str(analysis.id),
|
||||
request_snapshot={"case_key": case.key, "source_kind": case.source_kind},
|
||||
result="success",
|
||||
)
|
||||
result = type(result).objects.select_related("case", "analysis__ticket").get(
|
||||
id=result.id
|
||||
)
|
||||
return serialize_support_evaluation_result(result)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/support-analyses", response=list[TicketSupportAnalysisOut])
|
||||
def list_ticket_support_analyses(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
require_permission(request, "knowledge.view")
|
||||
require_permission(request, "solution.view")
|
||||
queryset = TicketSupportAnalysis.objects.select_related(
|
||||
"run",
|
||||
"run__agent_version",
|
||||
).filter(ticket_id=ticket_id)
|
||||
return [serialize_support_analysis(item) for item in queryset[:20]]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/support-analyses", response=TicketSupportAnalysisOut)
|
||||
def create_ticket_support_analysis(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
require_permission(request, "knowledge.view")
|
||||
require_permission(request, "solution.view")
|
||||
require_permission(request, "agent.use")
|
||||
ticket = Ticket.objects.filter(id=ticket_id).first()
|
||||
if ticket is None:
|
||||
raise HttpError(404, "Ticket not found")
|
||||
try:
|
||||
analysis = start_ticket_support_analysis(ticket, created_by=request.user)
|
||||
except (SupportAnalysisError, AgentValidationError, SkillValidationError) as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
analysis = TicketSupportAnalysis.objects.select_related(
|
||||
"run",
|
||||
"run__agent_version",
|
||||
).get(id=analysis.id)
|
||||
return serialize_support_analysis(analysis)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ticket_id}/knowledge-distillations",
|
||||
response=list[TicketKnowledgeDistillationOut],
|
||||
)
|
||||
def list_ticket_knowledge_distillations(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
require_permission(request, "knowledge.view")
|
||||
queryset = TicketKnowledgeDistillation.objects.select_related(
|
||||
"run",
|
||||
"run__agent_version",
|
||||
).filter(ticket_id=ticket_id)
|
||||
return [serialize_knowledge_distillation(item) for item in queryset[:20]]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{ticket_id}/knowledge-distillations",
|
||||
response=TicketKnowledgeDistillationOut,
|
||||
)
|
||||
def create_ticket_knowledge_distillation(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
require_permission(request, "knowledge.manage")
|
||||
require_permission(request, "agent.use")
|
||||
ticket = Ticket.objects.filter(id=ticket_id).first()
|
||||
if ticket is None:
|
||||
raise HttpError(404, "Ticket not found")
|
||||
try:
|
||||
distillation = start_ticket_knowledge_distillation(
|
||||
ticket,
|
||||
created_by=request.user,
|
||||
)
|
||||
except (
|
||||
KnowledgeDistillationError,
|
||||
AgentValidationError,
|
||||
SkillValidationError,
|
||||
) as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
distillation = TicketKnowledgeDistillation.objects.select_related(
|
||||
"run",
|
||||
"run__agent_version",
|
||||
).get(id=distillation.id)
|
||||
return serialize_knowledge_distillation(distillation)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/reply-tasks", response=list[CustomerReplyTaskOut])
|
||||
def list_customer_reply_tasks(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
queryset = (
|
||||
CustomerReplyTask.objects.filter(ticket_id=ticket_id)
|
||||
.select_related("support_analysis")
|
||||
.prefetch_related("revisions")
|
||||
)
|
||||
return [serialize_customer_reply_task(item) for item in queryset[:20]]
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{ticket_id}/reply-tasks/{reply_task_id}",
|
||||
response=CustomerReplyTaskOut,
|
||||
)
|
||||
def edit_customer_reply_task(
|
||||
request,
|
||||
ticket_id: int,
|
||||
reply_task_id: int,
|
||||
payload: CustomerReplyUpdateIn,
|
||||
):
|
||||
require_permission(request, "ticket.edit")
|
||||
task = CustomerReplyTask.objects.filter(
|
||||
id=reply_task_id,
|
||||
ticket_id=ticket_id,
|
||||
).first()
|
||||
if task is None:
|
||||
raise HttpError(404, "客户回复任务不存在")
|
||||
try:
|
||||
task = update_customer_reply(
|
||||
task,
|
||||
payload.final_text,
|
||||
expected_revision=payload.expected_revision,
|
||||
actor=request.user,
|
||||
)
|
||||
except CustomerReplyError as error:
|
||||
raise HttpError(409, str(error)) from error
|
||||
task = CustomerReplyTask.objects.prefetch_related("revisions").get(id=task.id)
|
||||
return serialize_customer_reply_task(task)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{ticket_id}/reply-tasks/{reply_task_id}/confirm-sent",
|
||||
response=CustomerReplyTaskOut,
|
||||
)
|
||||
def confirm_customer_reply_task_sent(
|
||||
request,
|
||||
ticket_id: int,
|
||||
reply_task_id: int,
|
||||
payload: CustomerReplyConfirmIn,
|
||||
):
|
||||
require_permission(request, "ticket.reply")
|
||||
task = CustomerReplyTask.objects.filter(
|
||||
id=reply_task_id,
|
||||
ticket_id=ticket_id,
|
||||
).first()
|
||||
if task is None:
|
||||
raise HttpError(404, "客户回复任务不存在")
|
||||
try:
|
||||
task = confirm_customer_reply_sent(
|
||||
task,
|
||||
expected_revision=payload.expected_revision,
|
||||
actor=request.user,
|
||||
)
|
||||
except CustomerReplyError as error:
|
||||
raise HttpError(409, str(error)) from error
|
||||
task = CustomerReplyTask.objects.prefetch_related("revisions").get(id=task.id)
|
||||
return serialize_customer_reply_task(task)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response=TicketOut)
|
||||
def get_ticket(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
return serialize_ticket(
|
||||
Ticket.objects.select_related("requester", "assignee", "category").get(id=ticket_id)
|
||||
Ticket.objects.select_related(
|
||||
"requester",
|
||||
"assignee",
|
||||
"category",
|
||||
"resolution",
|
||||
"resolution__linked_solution",
|
||||
"archived_by",
|
||||
).get(id=ticket_id)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/similar", response=list[TicketSimilaritySuggestionOut])
|
||||
def list_similar_tickets(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
ticket = Ticket.objects.select_related("category").filter(id=ticket_id).first()
|
||||
if ticket is None:
|
||||
raise HttpError(404, "Ticket not found")
|
||||
suggestions = list(
|
||||
TicketSimilaritySuggestion.objects.filter(
|
||||
source_ticket=ticket,
|
||||
algorithm_version=ALGORITHM_VERSION,
|
||||
).select_related("candidate_ticket__category")
|
||||
)
|
||||
if suggestions:
|
||||
return [serialize_similarity_suggestion(item) for item in suggestions]
|
||||
return [serialize_dynamic_similarity(item) for item in find_similar_tickets(ticket)]
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/events", response=list[TicketEventOut])
|
||||
def list_ticket_events(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
@@ -230,52 +771,33 @@ def list_ticket_events(request, ticket_id: int):
|
||||
|
||||
@router.patch("/{ticket_id}", response=TicketOut)
|
||||
def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
||||
require_permission(request, "ticket.edit")
|
||||
changes = payload.dict(exclude_unset=True)
|
||||
if "assignee_id" in changes:
|
||||
require_permission(request, "ticket.assign")
|
||||
if set(changes) - {"assignee_id"}:
|
||||
require_permission(request, "ticket.edit")
|
||||
if not changes:
|
||||
require_permission(request, "ticket.edit")
|
||||
ticket = Ticket.objects.get(id=ticket_id)
|
||||
before = {
|
||||
"title": ticket.title,
|
||||
"category_code": ticket.category.code if ticket.category_id else None,
|
||||
"priority": ticket.priority,
|
||||
"problem_performance": ticket.problem_performance,
|
||||
"problem_object": ticket.problem_object,
|
||||
"submitter_name": ticket.submitter_name,
|
||||
"submitter_org": ticket.submitter_org,
|
||||
"resolution": ticket.resolution_summary,
|
||||
"is_resolved": ticket.is_resolved,
|
||||
"replied_to_customer": ticket.replied_to_customer,
|
||||
"impact_scope": ticket.impact_scope,
|
||||
"environment": ticket.environment,
|
||||
"related_system": ticket.related_system,
|
||||
}
|
||||
for field, value in payload.dict(exclude_unset=True).items():
|
||||
if field == "assignee_id":
|
||||
user = get_user_model().objects.filter(id=value).first() if value else None
|
||||
ticket.assignee = user
|
||||
elif field == "category_code":
|
||||
category = (
|
||||
TicketCategory.objects.filter(code=value, enabled=True).first() if value else None
|
||||
)
|
||||
if value and category is None:
|
||||
raise HttpError(400, "Unknown ticket category")
|
||||
ticket.category = category
|
||||
elif field == "resolution":
|
||||
ticket.resolution_summary = value or ""
|
||||
else:
|
||||
setattr(ticket, field, value)
|
||||
ticket.save()
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="updated",
|
||||
from_value=before,
|
||||
to_value=payload.dict(exclude_unset=True),
|
||||
operator=request.user if request.user.is_authenticated else None,
|
||||
try:
|
||||
updated, _ = update_ticket_fields(ticket, changes, request.user)
|
||||
except TicketDataError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
return serialize_ticket(
|
||||
Ticket.objects.select_related(
|
||||
"requester", "assignee", "category", "resolution", "archived_by"
|
||||
).get(id=updated.id)
|
||||
)
|
||||
return serialize_ticket(ticket)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/status", response=TicketOut)
|
||||
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
|
||||
require_permission(request, "ticket.edit")
|
||||
if payload.status == "closed":
|
||||
require_permission(request, "ticket.close")
|
||||
elif payload.status == "resolved":
|
||||
require_permission(request, "ticket.resolve")
|
||||
else:
|
||||
require_permission(request, "ticket.edit")
|
||||
ticket = Ticket.objects.select_related("category").get(id=ticket_id)
|
||||
category_code = ticket.category.code if ticket.category_id else None
|
||||
has_resolution = bool(payload.resolution.strip())
|
||||
@@ -294,21 +816,154 @@ def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
|
||||
payload.remark,
|
||||
request.user if request.user.is_authenticated else None,
|
||||
resolution=payload.resolution.strip(),
|
||||
is_resolved=payload.is_resolved,
|
||||
replied_to_customer=payload.replied_to_customer,
|
||||
)
|
||||
except TicketStatusError as error:
|
||||
except (TicketDataError, TicketStatusError) as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
return serialize_ticket(updated)
|
||||
return serialize_ticket(
|
||||
Ticket.objects.select_related(
|
||||
"requester", "assignee", "category", "resolution", "archived_by"
|
||||
).get(id=updated.id)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/archive", response=TicketOut)
|
||||
def archive_ticket(request, ticket_id: int):
|
||||
require_permission(request, "ticket.archive")
|
||||
ticket = set_ticket_archived(Ticket.objects.get(id=ticket_id), True, request.user)
|
||||
return serialize_ticket(ticket)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/restore", response=TicketOut)
|
||||
def restore_ticket(request, ticket_id: int):
|
||||
require_permission(request, "ticket.archive")
|
||||
ticket = set_ticket_archived(Ticket.objects.get(id=ticket_id), False, request.user)
|
||||
return serialize_ticket(ticket)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/collaborators", response=list[TicketCollaboratorOut])
|
||||
def list_ticket_collaborators(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
queryset = TicketCollaborator.objects.select_related("user", "added_by").filter(
|
||||
ticket_id=ticket_id
|
||||
)
|
||||
return [serialize_ticket_collaborator(item) for item in queryset]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/collaborators", response=TicketCollaboratorOut)
|
||||
def add_ticket_collaborator(request, ticket_id: int, payload: TicketCollaboratorIn):
|
||||
require_permission(request, "ticket.assign")
|
||||
if payload.role not in TicketCollaboratorRole.values:
|
||||
raise HttpError(400, "Unknown collaborator role")
|
||||
user = get_user_model().objects.filter(id=payload.user_id).first()
|
||||
if user is None:
|
||||
raise HttpError(400, "User not found")
|
||||
ticket = Ticket.objects.get(id=ticket_id)
|
||||
collaborator, created = TicketCollaborator.objects.get_or_create(
|
||||
ticket=ticket,
|
||||
user=user,
|
||||
role=payload.role,
|
||||
defaults={"added_by": request.user},
|
||||
)
|
||||
if created:
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="collaborator_added",
|
||||
to_value={"user_id": user.id, "role": payload.role},
|
||||
operator=request.user,
|
||||
)
|
||||
return serialize_ticket_collaborator(collaborator)
|
||||
|
||||
|
||||
@router.delete("/{ticket_id}/collaborators/{collaborator_id}")
|
||||
def remove_ticket_collaborator(request, ticket_id: int, collaborator_id: int):
|
||||
require_permission(request, "ticket.assign")
|
||||
collaborator = TicketCollaborator.objects.filter(
|
||||
id=collaborator_id,
|
||||
ticket_id=ticket_id,
|
||||
).first()
|
||||
if collaborator is None:
|
||||
raise HttpError(404, "Collaborator not found")
|
||||
event_value = {"user_id": collaborator.user_id, "role": collaborator.role}
|
||||
collaborator.delete()
|
||||
TicketEvent.objects.create(
|
||||
ticket_id=ticket_id,
|
||||
event_type="collaborator_removed",
|
||||
from_value=event_value,
|
||||
operator=request.user,
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/field-evidence", response=list[TicketFieldEvidenceOut])
|
||||
def list_ticket_field_evidence(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
queryset = TicketFieldEvidence.objects.select_related("created_by").filter(ticket_id=ticket_id)
|
||||
return [serialize_field_evidence(item) for item in queryset]
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/intake-trace", response=TicketIntakeTraceOut)
|
||||
def get_ticket_intake_trace(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
ticket = Ticket.objects.filter(id=ticket_id).first()
|
||||
if ticket is None:
|
||||
raise HttpError(404, "Ticket not found")
|
||||
try:
|
||||
draft = ticket.source_draft
|
||||
except TicketDraft.DoesNotExist:
|
||||
return {"available": False}
|
||||
from apps.tickets.draft_services import serialize_timeline_item
|
||||
|
||||
timeline_items = list(draft.timeline_items.all())
|
||||
return {
|
||||
"available": True,
|
||||
"public_id": str(draft.public_id),
|
||||
"status": draft.status,
|
||||
"source_text": draft.source_text,
|
||||
"problem_valid": draft.problem_valid,
|
||||
"invalid_reason": draft.invalid_reason,
|
||||
"missing_fields": draft.missing_fields,
|
||||
"message_timeline": (
|
||||
[serialize_timeline_item(item) for item in timeline_items]
|
||||
if timeline_items
|
||||
else draft.message_timeline
|
||||
),
|
||||
"agent_run_public_id": str(draft.latest_agent_run.public_id)
|
||||
if draft.latest_agent_run_id
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/comments", response=list[TicketCommentOut])
|
||||
def list_ticket_comments(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
comments = TicketComment.objects.select_related("created_by").filter(ticket_id=ticket_id)
|
||||
return [serialize_ticket_comment(comment) for comment in comments]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/comments", response=TicketCommentOut)
|
||||
def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn):
|
||||
require_permission(request, "ticket.edit")
|
||||
content = payload.content.strip()
|
||||
if not content:
|
||||
raise HttpError(400, "Comment content is required")
|
||||
if len(content) > 10000:
|
||||
raise HttpError(400, "Comment content is too long")
|
||||
allowed_comment_types = {"internal_note", "user_reply"}
|
||||
allowed_visibilities = {"internal", "public"}
|
||||
if payload.comment_type not in allowed_comment_types:
|
||||
raise HttpError(400, "Unknown comment type")
|
||||
if payload.visibility not in allowed_visibilities:
|
||||
raise HttpError(400, "Unknown comment visibility")
|
||||
expected_visibility = (
|
||||
"public" if payload.comment_type == "user_reply" else "internal"
|
||||
)
|
||||
if payload.visibility != expected_visibility:
|
||||
raise HttpError(400, "Comment type and visibility do not match")
|
||||
ticket = Ticket.objects.get(id=ticket_id)
|
||||
comment = TicketComment.objects.create(
|
||||
ticket=ticket,
|
||||
content=payload.content,
|
||||
content=content,
|
||||
comment_type=payload.comment_type,
|
||||
visibility=payload.visibility,
|
||||
created_by=request.user if request.user.is_authenticated else None,
|
||||
@@ -319,7 +974,7 @@ def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn):
|
||||
to_value={"comment_id": comment.id, "comment_type": comment.comment_type},
|
||||
operator=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
return comment
|
||||
return serialize_ticket_comment(comment)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/attachments", response=TicketAttachmentOut)
|
||||
@@ -334,3 +989,9 @@ def add_ticket_attachment(request, ticket_id: int, payload: TicketAttachmentIn):
|
||||
operator=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
return attachment
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/attachments", response=list[TicketAttachmentOut])
|
||||
def list_ticket_attachments(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
return TicketAttachment.objects.filter(ticket_id=ticket_id)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
class TicketActorSource(models.TextChoices):
|
||||
HUMAN = "human", "Human"
|
||||
AGENT = "agent", "Agent"
|
||||
CHANNEL = "channel", "Channel"
|
||||
SYSTEM = "system", "System"
|
||||
|
||||
|
||||
class TicketCollaboratorRole(models.TextChoices):
|
||||
COLLABORATOR = "collaborator", "Collaborator"
|
||||
WATCHER = "watcher", "Watcher"
|
||||
|
||||
|
||||
class TicketCategory(models.Model):
|
||||
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL)
|
||||
name = models.CharField(max_length=120)
|
||||
@@ -60,13 +72,21 @@ class Ticket(models.Model):
|
||||
problem_object = models.CharField(max_length=160, blank=True)
|
||||
submitter_name = models.CharField(max_length=80, blank=True)
|
||||
submitter_org = models.CharField(max_length=160, blank=True)
|
||||
ai_solution = models.TextField(blank=True)
|
||||
resolution_summary = models.TextField(blank=True)
|
||||
is_resolved = models.BooleanField(default=False)
|
||||
replied_to_customer = models.BooleanField(default=False)
|
||||
impact_scope = models.CharField(max_length=80, blank=True)
|
||||
environment = models.CharField(max_length=80, blank=True)
|
||||
related_system = models.CharField(max_length=120, blank=True)
|
||||
product_name = models.CharField(max_length=120, blank=True)
|
||||
module_name = models.CharField(max_length=120, blank=True)
|
||||
tags = models.JSONField(default=list, blank=True)
|
||||
attempted_actions = models.TextField(blank=True)
|
||||
error_information = models.TextField(blank=True)
|
||||
customer_request = models.TextField(blank=True)
|
||||
current_result = models.TextField(blank=True)
|
||||
problem_started_at = models.DateTimeField(null=True, blank=True)
|
||||
customer_last_message_at = models.DateTimeField(null=True, blank=True)
|
||||
support_first_response_at = models.DateTimeField(null=True, blank=True)
|
||||
support_last_reply_at = models.DateTimeField(null=True, blank=True)
|
||||
requester = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
@@ -92,6 +112,14 @@ class Ticket(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
closed_at = models.DateTimeField(null=True, blank=True)
|
||||
archived_at = models.DateTimeField(null=True, blank=True)
|
||||
archived_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="archived_tickets",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
@@ -99,12 +127,107 @@ class Ticket(models.Model):
|
||||
models.Index(fields=["ticket_no"]),
|
||||
models.Index(fields=["status", "priority"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
models.Index(fields=["archived_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.ticket_no} {self.title}"
|
||||
|
||||
|
||||
class TicketCollaborator(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="collaborators")
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ticket_collaborations",
|
||||
)
|
||||
role = models.CharField(
|
||||
max_length=24,
|
||||
choices=TicketCollaboratorRole.choices,
|
||||
default=TicketCollaboratorRole.COLLABORATOR,
|
||||
)
|
||||
added_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="added_ticket_collaborators",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["ticket", "user", "role"],
|
||||
name="unique_ticket_user_collaborator_role",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class TicketFieldEvidence(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="field_evidence")
|
||||
field_name = models.CharField(max_length=80)
|
||||
value = models.JSONField(null=True, blank=True)
|
||||
source = models.CharField(
|
||||
max_length=24,
|
||||
choices=TicketActorSource.choices,
|
||||
default=TicketActorSource.HUMAN,
|
||||
)
|
||||
confidence = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True)
|
||||
evidence = models.JSONField(default=dict, blank=True)
|
||||
agent_run_id = models.BigIntegerField(null=True, blank=True)
|
||||
is_protected = models.BooleanField(default=False)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="ticket_field_evidence",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["field_name", "-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["ticket", "field_name", "created_at"]),
|
||||
models.Index(fields=["source", "agent_run_id"]),
|
||||
]
|
||||
|
||||
|
||||
class TicketSimilaritySuggestion(models.Model):
|
||||
source_ticket = models.ForeignKey(
|
||||
Ticket,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="similarity_suggestions",
|
||||
)
|
||||
candidate_ticket = models.ForeignKey(
|
||||
Ticket,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="suggested_for_tickets",
|
||||
)
|
||||
score = models.DecimalField(max_digits=6, decimal_places=5)
|
||||
title_similarity = models.DecimalField(max_digits=6, decimal_places=5, default=0)
|
||||
keyword_score = models.DecimalField(max_digits=6, decimal_places=5, default=0)
|
||||
matched_excerpt = models.TextField(blank=True)
|
||||
algorithm_version = models.CharField(max_length=40, default="fts_trigram_v2_context")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-score", "candidate_ticket_id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["source_ticket", "candidate_ticket", "algorithm_version"],
|
||||
name="unique_ticket_similarity_suggestion",
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=~models.Q(source_ticket=models.F("candidate_ticket")),
|
||||
name="ticket_similarity_not_self",
|
||||
),
|
||||
]
|
||||
indexes = [models.Index(fields=["source_ticket", "algorithm_version", "score"])]
|
||||
|
||||
|
||||
class TicketComment(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments")
|
||||
content = models.TextField()
|
||||
@@ -129,6 +252,11 @@ class TicketAttachment(models.Model):
|
||||
mime_type = models.CharField(max_length=120, blank=True)
|
||||
file_size = models.PositiveBigIntegerField(default=0)
|
||||
storage_path = models.CharField(max_length=500)
|
||||
sha256 = models.CharField(max_length=64, blank=True)
|
||||
scan_status = models.CharField(max_length=24, default="pending")
|
||||
scan_result = models.JSONField(default=dict, blank=True)
|
||||
parse_status = models.CharField(max_length=24, default="pending")
|
||||
parse_error = models.TextField(blank=True)
|
||||
ocr_text = models.TextField(blank=True)
|
||||
parsed_text = models.TextField(blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
@@ -147,11 +275,15 @@ class TicketDraft(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ticket_drafts",
|
||||
)
|
||||
status = models.CharField(max_length=24, default="editing")
|
||||
status = models.CharField(max_length=24, default="collecting")
|
||||
revision = models.PositiveIntegerField(default=1)
|
||||
source_text = models.TextField(blank=True)
|
||||
problem_valid = models.BooleanField(null=True, blank=True)
|
||||
invalid_reason = models.TextField(blank=True)
|
||||
current_fields = models.JSONField(default=dict)
|
||||
last_ai_fields = models.JSONField(default=dict)
|
||||
field_evidence = models.JSONField(default=dict)
|
||||
message_timeline = models.JSONField(default=list)
|
||||
missing_fields = models.JSONField(default=list)
|
||||
questions = models.JSONField(default=list)
|
||||
guided_rounds = models.PositiveSmallIntegerField(default=0)
|
||||
@@ -191,7 +323,6 @@ class TicketDraft(models.Model):
|
||||
models.Index(fields=["status", "expires_at"]),
|
||||
]
|
||||
|
||||
|
||||
class TicketDraftMessage(models.Model):
|
||||
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="messages")
|
||||
role = models.CharField(max_length=24)
|
||||
@@ -203,21 +334,75 @@ class TicketDraftMessage(models.Model):
|
||||
ordering = ["created_at"]
|
||||
|
||||
|
||||
class TicketDraftImage(models.Model):
|
||||
class TicketMessageTimelineItem(models.Model):
|
||||
draft = models.ForeignKey(
|
||||
TicketDraft,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="timeline_items",
|
||||
)
|
||||
ticket = models.ForeignKey(
|
||||
Ticket,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="message_timeline_items",
|
||||
)
|
||||
source_material = models.ForeignKey(
|
||||
"TicketDraftMaterial",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="message_timeline_items",
|
||||
)
|
||||
sequence = models.PositiveIntegerField()
|
||||
speaker = models.CharField(max_length=120, blank=True)
|
||||
role = models.CharField(max_length=24, default="unknown")
|
||||
content = models.TextField()
|
||||
occurred_at = models.DateTimeField(null=True, blank=True)
|
||||
occurred_at_raw = models.CharField(max_length=120, blank=True)
|
||||
timezone = models.CharField(max_length=80, blank=True)
|
||||
source_ref = models.CharField(max_length=160, blank=True)
|
||||
confidence = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True)
|
||||
conflict = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sequence"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["draft", "sequence"],
|
||||
name="unique_ticket_draft_timeline_sequence",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["ticket", "sequence"]),
|
||||
models.Index(fields=["draft", "sequence"]),
|
||||
]
|
||||
|
||||
|
||||
class TicketDraftMaterial(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="images")
|
||||
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="materials")
|
||||
file_name = models.CharField(max_length=255)
|
||||
material_type = models.CharField(max_length=24, default="image")
|
||||
extension = models.CharField(max_length=16, blank=True)
|
||||
claimed_mime_type = models.CharField(max_length=120, blank=True)
|
||||
mime_type = models.CharField(max_length=120)
|
||||
file_size = models.PositiveBigIntegerField()
|
||||
sha256 = models.CharField(max_length=64)
|
||||
storage_path = models.CharField(max_length=500)
|
||||
status = models.CharField(max_length=24, default="temporary")
|
||||
scan_status = models.CharField(max_length=24, default="pending")
|
||||
scan_result = models.JSONField(default=dict, blank=True)
|
||||
parse_status = models.CharField(max_length=24, default="pending")
|
||||
parse_error = models.TextField(blank=True)
|
||||
parsed_text = models.TextField(blank=True)
|
||||
confirmed_attachment = models.OneToOneField(
|
||||
TicketAttachment,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="source_draft_image",
|
||||
related_name="source_draft_material",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -225,6 +410,265 @@ class TicketDraftImage(models.Model):
|
||||
ordering = ["created_at"]
|
||||
|
||||
|
||||
class IntakeEvaluationCase(models.Model):
|
||||
key = models.SlugField(max_length=120, unique=True)
|
||||
name = models.CharField(max_length=200)
|
||||
source_kind = models.CharField(max_length=32, default="synthetic")
|
||||
valid_input = models.BooleanField(default=True)
|
||||
input_manifest = models.JSONField(default=dict)
|
||||
expected_fields = models.JSONField(default=dict)
|
||||
expected_timeline = models.JSONField(default=list)
|
||||
expected_auto_created = models.BooleanField(default=True)
|
||||
expected_similar_ticket = models.BooleanField(default=False)
|
||||
notes = models.TextField(blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["key"]
|
||||
indexes = [models.Index(fields=["source_kind", "enabled"])]
|
||||
|
||||
|
||||
class IntakeEvaluationResult(models.Model):
|
||||
case = models.ForeignKey(
|
||||
IntakeEvaluationCase,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="results",
|
||||
)
|
||||
draft = models.ForeignKey(
|
||||
TicketDraft,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="evaluation_results",
|
||||
)
|
||||
ticket = models.ForeignKey(
|
||||
Ticket,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="intake_evaluation_results",
|
||||
)
|
||||
actual_fields = models.JSONField(default=dict)
|
||||
actual_auto_created = models.BooleanField(default=False)
|
||||
correct_field_count = models.PositiveIntegerField(default=0)
|
||||
evaluated_field_count = models.PositiveIntegerField(default=0)
|
||||
field_accuracy = models.DecimalField(max_digits=6, decimal_places=5, null=True, blank=True)
|
||||
passed = models.BooleanField(default=False)
|
||||
details = models.JSONField(default=dict, blank=True)
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="reviewed_intake_evaluations",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["case", "draft"],
|
||||
name="unique_intake_case_draft_result",
|
||||
)
|
||||
]
|
||||
indexes = [models.Index(fields=["case", "updated_at"])]
|
||||
|
||||
|
||||
class SupportEvaluationCase(models.Model):
|
||||
key = models.SlugField(max_length=120, unique=True)
|
||||
name = models.CharField(max_length=200)
|
||||
source_kind = models.CharField(max_length=32, default="synthetic")
|
||||
input_manifest = models.JSONField(default=dict)
|
||||
expected_solution_ids = models.JSONField(default=list)
|
||||
expected_auto_resolve = models.BooleanField(default=False)
|
||||
expected_manual_fallback = models.BooleanField(default=True)
|
||||
notes = models.TextField(blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["key"]
|
||||
indexes = [models.Index(fields=["source_kind", "enabled"])]
|
||||
|
||||
|
||||
class SupportEvaluationResult(models.Model):
|
||||
case = models.ForeignKey(
|
||||
SupportEvaluationCase,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="results",
|
||||
)
|
||||
analysis = models.ForeignKey(
|
||||
"tickets.TicketSupportAnalysis",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="evaluation_results",
|
||||
)
|
||||
actual_candidate_solution_ids = models.JSONField(default=list)
|
||||
actual_citation_ranks = models.JSONField(default=list)
|
||||
matched_expected_solution = models.BooleanField(default=False)
|
||||
actual_auto_resolved = models.BooleanField(default=False)
|
||||
actual_manual_fallback = models.BooleanField(default=False)
|
||||
incorrect_auto_reuse = models.BooleanField(default=False)
|
||||
citations_valid = models.BooleanField(default=False)
|
||||
passed = models.BooleanField(default=False)
|
||||
details = models.JSONField(default=dict, blank=True)
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="reviewed_support_evaluations",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["case", "analysis"],
|
||||
name="unique_support_case_analysis_result",
|
||||
)
|
||||
]
|
||||
indexes = [models.Index(fields=["case", "updated_at"])]
|
||||
|
||||
|
||||
class TicketSupportAnalysis(models.Model):
|
||||
ticket = models.ForeignKey(
|
||||
Ticket,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="support_analyses",
|
||||
)
|
||||
run = models.OneToOneField(
|
||||
"agents.AgentRun",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="ticket_support_analysis",
|
||||
)
|
||||
query = models.TextField()
|
||||
references = models.JSONField(default=list)
|
||||
retrieval_policy = models.JSONField(default=dict)
|
||||
automation_decision = models.JSONField(default=dict, blank=True)
|
||||
applied_solution = models.ForeignKey(
|
||||
"solutions.Solution",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="applied_support_analyses",
|
||||
)
|
||||
processed_at = models.DateTimeField(null=True, blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_ticket_support_analyses",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [models.Index(fields=["ticket", "created_at"])]
|
||||
|
||||
|
||||
class CustomerReplyTask(models.Model):
|
||||
ticket = models.ForeignKey(
|
||||
Ticket,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="customer_reply_tasks",
|
||||
)
|
||||
support_analysis = models.ForeignKey(
|
||||
TicketSupportAnalysis,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="customer_reply_tasks",
|
||||
)
|
||||
status = models.CharField(max_length=24, default="draft")
|
||||
draft_text = models.TextField()
|
||||
final_text = models.TextField()
|
||||
revision = models.PositiveIntegerField(default=1)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_customer_reply_tasks",
|
||||
)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="updated_customer_reply_tasks",
|
||||
)
|
||||
approved_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="approved_customer_reply_tasks",
|
||||
)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
sent_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="sent_customer_reply_tasks",
|
||||
)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["ticket"],
|
||||
condition=models.Q(status__in=["draft", "approved"]),
|
||||
name="unique_active_customer_reply_task",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["ticket", "status", "created_at"]),
|
||||
models.Index(fields=["status", "updated_at"]),
|
||||
]
|
||||
|
||||
|
||||
class CustomerReplyRevision(models.Model):
|
||||
task = models.ForeignKey(
|
||||
CustomerReplyTask,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="revisions",
|
||||
)
|
||||
version = models.PositiveIntegerField()
|
||||
content = models.TextField()
|
||||
source = models.CharField(max_length=24, default=TicketActorSource.HUMAN)
|
||||
edited_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="customer_reply_revisions",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["version"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["task", "version"],
|
||||
name="unique_customer_reply_revision",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# Kept temporarily for imports owned by stage 2.1 integrations.
|
||||
TicketDraftImage = TicketDraftMaterial
|
||||
|
||||
|
||||
class TicketEvent(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events")
|
||||
event_type = models.CharField(max_length=80)
|
||||
@@ -236,6 +680,13 @@ class TicketEvent(models.Model):
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
source = models.CharField(
|
||||
max_length=24,
|
||||
choices=TicketActorSource.choices,
|
||||
default=TicketActorSource.HUMAN,
|
||||
)
|
||||
agent_run_id = models.BigIntegerField(null=True, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
remark = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
@@ -15,7 +16,18 @@ class TicketIn(Schema):
|
||||
impact_scope: str = ""
|
||||
environment: str = ""
|
||||
related_system: str = ""
|
||||
product_name: str = ""
|
||||
module_name: str = ""
|
||||
tags: list[str] = []
|
||||
attempted_actions: str = ""
|
||||
error_information: str = ""
|
||||
customer_request: str = ""
|
||||
current_result: str = ""
|
||||
assignee_id: int | None = None
|
||||
problem_started_at: datetime | None = None
|
||||
customer_last_message_at: datetime | None = None
|
||||
support_first_response_at: datetime | None = None
|
||||
support_last_reply_at: datetime | None = None
|
||||
|
||||
|
||||
class TicketUpdateIn(Schema):
|
||||
@@ -27,20 +39,27 @@ class TicketUpdateIn(Schema):
|
||||
problem_object: str | None = None
|
||||
submitter_name: str | None = None
|
||||
submitter_org: str | None = None
|
||||
resolution: str | None = None
|
||||
is_resolved: bool | None = None
|
||||
replied_to_customer: bool | None = None
|
||||
impact_scope: str | None = None
|
||||
environment: str | None = None
|
||||
related_system: str | None = None
|
||||
product_name: str | None = None
|
||||
module_name: str | None = None
|
||||
tags: list[str] | None = None
|
||||
attempted_actions: str | None = None
|
||||
error_information: str | None = None
|
||||
customer_request: str | None = None
|
||||
current_result: str | None = None
|
||||
assignee_id: int | None = None
|
||||
problem_started_at: datetime | None = None
|
||||
customer_last_message_at: datetime | None = None
|
||||
support_first_response_at: datetime | None = None
|
||||
support_last_reply_at: datetime | None = None
|
||||
|
||||
|
||||
class TicketStatusIn(Schema):
|
||||
status: str
|
||||
remark: str = ""
|
||||
resolution: str = ""
|
||||
is_resolved: bool | None = None
|
||||
replied_to_customer: bool | None = None
|
||||
|
||||
|
||||
@@ -70,17 +89,36 @@ class TicketOut(Schema):
|
||||
problem_object: str
|
||||
submitter_name: str
|
||||
submitter_org: str
|
||||
ai_solution: str
|
||||
resolution: str
|
||||
resolution_id: int | None
|
||||
resolution_type: str | None
|
||||
resolution_source: str | None
|
||||
resolution_root_cause: str
|
||||
linked_solution_id: int | None
|
||||
is_resolved: bool
|
||||
replied_to_customer: bool
|
||||
impact_scope: str
|
||||
environment: str
|
||||
related_system: str
|
||||
product_name: str
|
||||
module_name: str
|
||||
tags: list[str]
|
||||
attempted_actions: str
|
||||
error_information: str
|
||||
customer_request: str
|
||||
current_result: str
|
||||
requester_id: int | None
|
||||
assignee_id: int | None
|
||||
requester: TicketUserOut | None = None
|
||||
assignee: TicketUserOut | None = None
|
||||
problem_started_at: datetime | None
|
||||
customer_last_message_at: datetime | None
|
||||
support_first_response_at: datetime | None
|
||||
support_last_reply_at: datetime | None
|
||||
resolved_at: datetime | None
|
||||
closed_at: datetime | None
|
||||
archived_at: datetime | None
|
||||
archived_by: TicketUserOut | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -100,6 +138,9 @@ class TicketEventOut(Schema):
|
||||
to_value: dict
|
||||
remark: str
|
||||
operator: TicketUserOut | None = None
|
||||
source: str
|
||||
agent_run_id: int | None
|
||||
metadata: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -114,6 +155,33 @@ class TicketCommentOut(Schema):
|
||||
content: str
|
||||
comment_type: str
|
||||
visibility: str
|
||||
created_by: TicketUserOut | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TicketCollaboratorIn(Schema):
|
||||
user_id: int
|
||||
role: str = "collaborator"
|
||||
|
||||
|
||||
class TicketCollaboratorOut(Schema):
|
||||
id: int
|
||||
user: TicketUserOut
|
||||
role: str
|
||||
added_by: TicketUserOut | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TicketFieldEvidenceOut(Schema):
|
||||
id: int
|
||||
field_name: str
|
||||
value: Any
|
||||
source: str
|
||||
confidence: float | None
|
||||
evidence: dict[str, Any]
|
||||
agent_run_id: int | None
|
||||
is_protected: bool
|
||||
created_by: TicketUserOut | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -132,4 +200,243 @@ class TicketAttachmentOut(Schema):
|
||||
mime_type: str
|
||||
file_size: int
|
||||
storage_path: str
|
||||
sha256: str
|
||||
scan_status: str
|
||||
scan_result: dict[str, Any]
|
||||
parse_status: str
|
||||
parse_error: str
|
||||
parsed_text: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TicketIntakeTraceOut(Schema):
|
||||
available: bool
|
||||
public_id: str | None = None
|
||||
status: str = ""
|
||||
source_text: str = ""
|
||||
problem_valid: bool | None = None
|
||||
invalid_reason: str = ""
|
||||
missing_fields: list[str] = []
|
||||
message_timeline: list[dict[str, Any]] = []
|
||||
agent_run_public_id: str | None = None
|
||||
|
||||
|
||||
class TicketSimilaritySuggestionOut(Schema):
|
||||
ticket_id: int
|
||||
ticket_no: str
|
||||
title: str
|
||||
status: str
|
||||
category_code: str | None
|
||||
score: float
|
||||
title_similarity: float
|
||||
keyword_score: float
|
||||
matched_excerpt: str
|
||||
algorithm_version: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class TicketSupportAnalysisOut(Schema):
|
||||
id: int
|
||||
ticket_id: int
|
||||
run_public_id: str
|
||||
agent_version: int
|
||||
status: str
|
||||
progress_stage: str
|
||||
progress_message: str
|
||||
query: str
|
||||
retrieval_policy: dict[str, Any]
|
||||
references: list[dict[str, Any]]
|
||||
final_output: Any | None
|
||||
automation_decision: dict[str, Any]
|
||||
applied_solution_id: int | None
|
||||
processed_at: datetime | None
|
||||
error_code: str
|
||||
error_message: str
|
||||
created_at: datetime
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
|
||||
|
||||
class TicketKnowledgeDistillationOut(Schema):
|
||||
id: int
|
||||
ticket_id: int
|
||||
run_public_id: str
|
||||
agent_version: int
|
||||
status: str
|
||||
progress_stage: str
|
||||
progress_message: str
|
||||
references: list[dict[str, Any]]
|
||||
retrieval_policy: dict[str, Any]
|
||||
final_output: Any | None
|
||||
should_distill: bool | None
|
||||
reason: str
|
||||
assessment: dict[str, Any]
|
||||
knowledge_item_id: int | None
|
||||
troubleshooting_plan_id: int | None
|
||||
solution_id: int | None
|
||||
processed_at: datetime | None
|
||||
error_code: str
|
||||
error_message: str
|
||||
created_at: datetime
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
|
||||
|
||||
class CustomerReplyRevisionOut(Schema):
|
||||
version: int
|
||||
content: str
|
||||
source: str
|
||||
edited_by_id: int | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CustomerReplyTaskOut(Schema):
|
||||
id: int
|
||||
ticket_id: int
|
||||
support_analysis_id: int | None
|
||||
status: str
|
||||
draft_text: str
|
||||
final_text: str
|
||||
revision: int
|
||||
created_by_id: int | None
|
||||
updated_by_id: int | None
|
||||
approved_by_id: int | None
|
||||
approved_at: datetime | None
|
||||
sent_by_id: int | None
|
||||
sent_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
revisions: list[CustomerReplyRevisionOut]
|
||||
|
||||
|
||||
class CustomerReplyUpdateIn(Schema):
|
||||
final_text: str
|
||||
expected_revision: int
|
||||
|
||||
|
||||
class CustomerReplyConfirmIn(Schema):
|
||||
expected_revision: int
|
||||
|
||||
|
||||
class IntakeEvaluationRecordIn(Schema):
|
||||
case_key: str
|
||||
draft_public_id: str
|
||||
|
||||
|
||||
class IntakeEvaluationResultOut(Schema):
|
||||
id: int
|
||||
case_key: str
|
||||
case_source_kind: str
|
||||
draft_public_id: str
|
||||
ticket_id: int | None
|
||||
actual_auto_created: bool
|
||||
correct_field_count: int
|
||||
evaluated_field_count: int
|
||||
field_accuracy: float | None
|
||||
passed: bool
|
||||
details: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IntakeRateMetricOut(Schema):
|
||||
valid_input_count: int
|
||||
auto_created_count: int
|
||||
auto_creation_rate: float | None
|
||||
|
||||
|
||||
class IntakeEvaluationMetricOut(Schema):
|
||||
enabled_case_count: int
|
||||
reviewed_case_count: int
|
||||
coverage_rate: float | None
|
||||
valid_input_count: int
|
||||
auto_created_count: int
|
||||
auto_creation_rate: float | None
|
||||
correct_field_count: int
|
||||
evaluated_field_count: int
|
||||
field_accuracy: float | None
|
||||
evidence_covered_field_count: int
|
||||
evidence_field_count: int
|
||||
evidence_coverage_rate: float | None
|
||||
correct_timeline_item_count: int
|
||||
evaluated_timeline_item_count: int
|
||||
timeline_accuracy: float | None
|
||||
result_selection: str
|
||||
|
||||
|
||||
class IntakeQualityTargetsOut(Schema):
|
||||
auto_creation_rate: float
|
||||
field_accuracy: float
|
||||
coverage_complete: bool
|
||||
auto_creation_rate_met: bool | None
|
||||
field_accuracy_met: bool | None
|
||||
|
||||
|
||||
class IntakeQualityMetricsOut(Schema):
|
||||
include_synthetic: bool
|
||||
operational: IntakeRateMetricOut
|
||||
evaluation: IntakeEvaluationMetricOut
|
||||
targets: IntakeQualityTargetsOut
|
||||
|
||||
|
||||
class SupportEvaluationRecordIn(Schema):
|
||||
case_key: str
|
||||
analysis_id: int
|
||||
|
||||
|
||||
class SupportEvaluationResultOut(Schema):
|
||||
id: int
|
||||
case_key: str
|
||||
case_source_kind: str
|
||||
analysis_id: int
|
||||
ticket_id: int
|
||||
actual_candidate_solution_ids: list[int]
|
||||
actual_citation_ranks: list[int]
|
||||
matched_expected_solution: bool
|
||||
actual_auto_resolved: bool
|
||||
actual_manual_fallback: bool
|
||||
incorrect_auto_reuse: bool
|
||||
citations_valid: bool
|
||||
passed: bool
|
||||
details: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SupportEvaluationMetricOut(Schema):
|
||||
enabled_case_count: int
|
||||
reviewed_case_count: int
|
||||
coverage_rate: float | None
|
||||
expected_match_case_count: int
|
||||
matched_solution_count: int
|
||||
solution_match_rate: float | None
|
||||
eligible_auto_resolve_case_count: int
|
||||
auto_resolved_count: int
|
||||
eligible_auto_resolve_rate: float | None
|
||||
actual_auto_resolved_count: int
|
||||
incorrect_auto_reuse_count: int
|
||||
incorrect_auto_reuse_rate: float | None
|
||||
expected_manual_fallback_count: int
|
||||
manual_fallback_count: int
|
||||
manual_fallback_accuracy: float | None
|
||||
citation_valid_count: int
|
||||
citation_validity_rate: float | None
|
||||
result_selection: str
|
||||
|
||||
|
||||
class SupportQualityTargetsOut(Schema):
|
||||
solution_match_rate: float
|
||||
eligible_auto_resolve_rate: float
|
||||
incorrect_auto_reuse_rate: float
|
||||
coverage_complete: bool
|
||||
solution_match_rate_met: bool | None
|
||||
eligible_auto_resolve_rate_met: bool | None
|
||||
incorrect_auto_reuse_rate_met: bool | None
|
||||
manual_fallback_accuracy_met: bool | None
|
||||
citation_validity_rate_met: bool | None
|
||||
|
||||
|
||||
class SupportQualityMetricsOut(Schema):
|
||||
include_synthetic: bool
|
||||
evaluation: SupportEvaluationMetricOut
|
||||
targets: SupportQualityTargetsOut
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
"""工单领域服务。
|
||||
|
||||
所有工单写入都应经过本模块,以统一执行字段校验、人工字段保护、状态机、证据记录和审计。
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.tickets.models import Ticket, TicketCategory, TicketEvent, TicketPriority
|
||||
from apps.tickets.models import (
|
||||
Ticket,
|
||||
TicketActorSource,
|
||||
TicketCategory,
|
||||
TicketDraft,
|
||||
TicketEvent,
|
||||
TicketFieldEvidence,
|
||||
TicketPriority,
|
||||
TicketStatus,
|
||||
)
|
||||
|
||||
|
||||
class TicketStatusError(ValueError):
|
||||
@@ -13,6 +29,16 @@ class TicketDataError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class TicketFieldProtectedError(TicketDataError):
|
||||
pass
|
||||
|
||||
|
||||
def schedule_ticket_search_index(ticket_id: int) -> None:
|
||||
from apps.knowledge.tasks import dispatch_ticket_search_index
|
||||
|
||||
transaction.on_commit(lambda: dispatch_ticket_search_index(ticket_id))
|
||||
|
||||
|
||||
REQUIRED_TICKET_FIELDS = (
|
||||
"title",
|
||||
"description",
|
||||
@@ -25,6 +51,145 @@ REQUIRED_TICKET_FIELDS = (
|
||||
"environment",
|
||||
)
|
||||
|
||||
EDITABLE_TICKET_FIELDS = {
|
||||
"title",
|
||||
"description",
|
||||
"category_code",
|
||||
"priority",
|
||||
"problem_performance",
|
||||
"problem_object",
|
||||
"submitter_name",
|
||||
"submitter_org",
|
||||
"impact_scope",
|
||||
"environment",
|
||||
"related_system",
|
||||
"product_name",
|
||||
"module_name",
|
||||
"tags",
|
||||
"attempted_actions",
|
||||
"error_information",
|
||||
"customer_request",
|
||||
"current_result",
|
||||
"assignee_id",
|
||||
"problem_started_at",
|
||||
"customer_last_message_at",
|
||||
"support_first_response_at",
|
||||
"support_last_reply_at",
|
||||
}
|
||||
|
||||
BUSINESS_TIME_FIELDS = {
|
||||
"problem_started_at",
|
||||
"customer_last_message_at",
|
||||
"support_first_response_at",
|
||||
"support_last_reply_at",
|
||||
}
|
||||
|
||||
|
||||
def normalize_tags(value) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
tags = []
|
||||
for item in value:
|
||||
tag = str(item).strip()
|
||||
if tag and tag not in tags:
|
||||
tags.append(tag[:50])
|
||||
if len(tags) >= 20:
|
||||
break
|
||||
return tags
|
||||
|
||||
|
||||
def normalize_business_datetime(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
parsed = parse_datetime(value.strip()) if isinstance(value, str) else value
|
||||
if parsed is None or not hasattr(parsed, "utcoffset") or timezone.is_naive(parsed):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _json_value(value):
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _field_value(ticket: Ticket, field_name: str):
|
||||
if field_name == "category_code":
|
||||
return ticket.category.code if ticket.category_id else None
|
||||
if field_name == "assignee_id":
|
||||
return ticket.assignee_id
|
||||
return getattr(ticket, field_name)
|
||||
|
||||
|
||||
def get_missing_ticket_fields(ticket: Ticket) -> list[str]:
|
||||
missing = []
|
||||
for field_name in REQUIRED_TICKET_FIELDS:
|
||||
if field_name == "priority":
|
||||
continue
|
||||
value = _field_value(ticket, field_name)
|
||||
if not value:
|
||||
missing.append(field_name)
|
||||
continue
|
||||
if field_name == "title":
|
||||
evidence = ticket.field_evidence.filter(field_name="title").order_by(
|
||||
"-created_at"
|
||||
).first()
|
||||
if evidence and evidence.evidence.get("generated_from") == "description":
|
||||
missing.append(field_name)
|
||||
return missing
|
||||
|
||||
|
||||
def get_outstanding_intake_fields(ticket: Ticket) -> list[str]:
|
||||
missing = set(get_missing_ticket_fields(ticket))
|
||||
try:
|
||||
draft = ticket.source_draft
|
||||
except TicketDraft.DoesNotExist:
|
||||
return sorted(missing)
|
||||
draft_missing = {
|
||||
code
|
||||
for code in draft.missing_fields
|
||||
if code in EDITABLE_TICKET_FIELDS and not _field_value(ticket, code)
|
||||
}
|
||||
normalized = sorted(missing | draft_missing)
|
||||
if normalized != sorted(draft.missing_fields):
|
||||
draft.missing_fields = normalized
|
||||
draft.save(update_fields=["missing_fields", "updated_at"])
|
||||
return normalized
|
||||
|
||||
|
||||
def record_field_evidence(
|
||||
ticket: Ticket,
|
||||
field_name: str,
|
||||
value,
|
||||
*,
|
||||
source: str,
|
||||
operator=None,
|
||||
confidence=None,
|
||||
evidence: dict | None = None,
|
||||
agent_run_id: int | None = None,
|
||||
is_protected: bool = False,
|
||||
) -> TicketFieldEvidence:
|
||||
return TicketFieldEvidence.objects.create(
|
||||
ticket=ticket,
|
||||
field_name=field_name,
|
||||
value=_json_value(value),
|
||||
source=source,
|
||||
confidence=confidence,
|
||||
evidence=evidence or {},
|
||||
agent_run_id=agent_run_id,
|
||||
is_protected=is_protected,
|
||||
created_by=operator,
|
||||
)
|
||||
|
||||
|
||||
def has_protected_field(ticket: Ticket, field_name: str) -> bool:
|
||||
latest = (
|
||||
TicketFieldEvidence.objects.filter(ticket=ticket, field_name=field_name)
|
||||
.order_by("-created_at", "-id")
|
||||
.first()
|
||||
)
|
||||
return bool(latest and latest.is_protected)
|
||||
|
||||
|
||||
def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]:
|
||||
normalized = {
|
||||
@@ -39,7 +204,24 @@ def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]:
|
||||
"impact_scope": str(fields.get("impact_scope") or "").strip(),
|
||||
"environment": str(fields.get("environment") or "").strip(),
|
||||
"related_system": str(fields.get("related_system") or "").strip(),
|
||||
"product_name": str(fields.get("product_name") or "").strip(),
|
||||
"module_name": str(fields.get("module_name") or "").strip(),
|
||||
"tags": normalize_tags(fields.get("tags")),
|
||||
"attempted_actions": str(fields.get("attempted_actions") or "").strip(),
|
||||
"error_information": str(fields.get("error_information") or "").strip(),
|
||||
"customer_request": str(fields.get("customer_request") or "").strip(),
|
||||
"current_result": str(fields.get("current_result") or "").strip(),
|
||||
"assignee_id": fields.get("assignee_id"),
|
||||
"problem_started_at": normalize_business_datetime(fields.get("problem_started_at")),
|
||||
"customer_last_message_at": normalize_business_datetime(
|
||||
fields.get("customer_last_message_at")
|
||||
),
|
||||
"support_first_response_at": normalize_business_datetime(
|
||||
fields.get("support_first_response_at")
|
||||
),
|
||||
"support_last_reply_at": normalize_business_datetime(
|
||||
fields.get("support_last_reply_at")
|
||||
),
|
||||
}
|
||||
missing = [code for code in REQUIRED_TICKET_FIELDS if not normalized.get(code)]
|
||||
if missing:
|
||||
@@ -65,7 +247,16 @@ def next_ticket_no() -> str:
|
||||
return f"{prefix}{count:04d}"
|
||||
|
||||
|
||||
def create_ticket_from_fields(fields: dict, user) -> Ticket:
|
||||
@transaction.atomic
|
||||
def create_ticket_from_fields(
|
||||
fields: dict,
|
||||
user,
|
||||
*,
|
||||
source: str = TicketActorSource.HUMAN,
|
||||
agent_run_id: int | None = None,
|
||||
evidence_by_field: dict | None = None,
|
||||
confidence_by_field: dict | None = None,
|
||||
) -> Ticket:
|
||||
normalized, category = validate_ticket_fields(fields)
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no=next_ticket_no(),
|
||||
@@ -80,7 +271,18 @@ def create_ticket_from_fields(fields: dict, user) -> Ticket:
|
||||
impact_scope=normalized["impact_scope"],
|
||||
environment=normalized["environment"],
|
||||
related_system=normalized["related_system"],
|
||||
product_name=normalized["product_name"],
|
||||
module_name=normalized["module_name"],
|
||||
tags=normalized["tags"],
|
||||
attempted_actions=normalized["attempted_actions"],
|
||||
error_information=normalized["error_information"],
|
||||
customer_request=normalized["customer_request"],
|
||||
current_result=normalized["current_result"],
|
||||
assignee=normalized["assignee"],
|
||||
problem_started_at=normalized["problem_started_at"],
|
||||
customer_last_message_at=normalized["customer_last_message_at"],
|
||||
support_first_response_at=normalized["support_first_response_at"],
|
||||
support_last_reply_at=normalized["support_last_reply_at"],
|
||||
requester=user,
|
||||
created_by=user,
|
||||
)
|
||||
@@ -89,10 +291,369 @@ def create_ticket_from_fields(fields: dict, user) -> Ticket:
|
||||
event_type="created",
|
||||
to_value={"status": ticket.status, "category_code": normalized["category_code"]},
|
||||
operator=user,
|
||||
source=source,
|
||||
agent_run_id=agent_run_id,
|
||||
)
|
||||
evidence_by_field = evidence_by_field or {}
|
||||
confidence_by_field = confidence_by_field or {}
|
||||
for field_name in EDITABLE_TICKET_FIELDS:
|
||||
value = _field_value(ticket, field_name)
|
||||
if value not in (None, ""):
|
||||
record_field_evidence(
|
||||
ticket,
|
||||
field_name,
|
||||
value,
|
||||
source=source,
|
||||
operator=user,
|
||||
confidence=confidence_by_field.get(field_name),
|
||||
evidence=evidence_by_field.get(field_name),
|
||||
agent_run_id=agent_run_id,
|
||||
is_protected=source == TicketActorSource.HUMAN,
|
||||
)
|
||||
from apps.tickets.similarity import schedule_ticket_similarity_refresh
|
||||
|
||||
schedule_ticket_similarity_refresh(ticket)
|
||||
return ticket
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_ticket_from_intake(
|
||||
fields: dict,
|
||||
user,
|
||||
*,
|
||||
agent_run_id: int,
|
||||
evidence_by_field: dict | None = None,
|
||||
confidence_by_field: dict | None = None,
|
||||
human_field_names: set[str] | None = None,
|
||||
reported_missing_fields: list[str] | None = None,
|
||||
) -> tuple[Ticket, list[str]]:
|
||||
"""在问题描述有效后尽早建单,不为缺失业务事实编造默认值。
|
||||
|
||||
返回正式工单及仍待补全的字段;Agent 字段保留证据但默认不受人工字段保护。
|
||||
"""
|
||||
normalized = {
|
||||
"title": str(fields.get("title") or "").strip(),
|
||||
"description": str(fields.get("description") or "").strip(),
|
||||
"category_code": str(fields.get("category_code") or "").strip(),
|
||||
"priority": str(fields.get("priority") or "medium").strip(),
|
||||
"problem_performance": str(fields.get("problem_performance") or "").strip(),
|
||||
"problem_object": str(fields.get("problem_object") or "").strip(),
|
||||
"submitter_name": str(fields.get("submitter_name") or "").strip(),
|
||||
"submitter_org": str(fields.get("submitter_org") or "").strip(),
|
||||
"impact_scope": str(fields.get("impact_scope") or "").strip(),
|
||||
"environment": str(fields.get("environment") or "").strip(),
|
||||
"related_system": str(fields.get("related_system") or "").strip(),
|
||||
"product_name": str(fields.get("product_name") or "").strip(),
|
||||
"module_name": str(fields.get("module_name") or "").strip(),
|
||||
"tags": normalize_tags(fields.get("tags")),
|
||||
"attempted_actions": str(fields.get("attempted_actions") or "").strip(),
|
||||
"error_information": str(fields.get("error_information") or "").strip(),
|
||||
"customer_request": str(fields.get("customer_request") or "").strip(),
|
||||
"current_result": str(fields.get("current_result") or "").strip(),
|
||||
"assignee_id": fields.get("assignee_id"),
|
||||
"problem_started_at": normalize_business_datetime(fields.get("problem_started_at")),
|
||||
"customer_last_message_at": normalize_business_datetime(
|
||||
fields.get("customer_last_message_at")
|
||||
),
|
||||
"support_first_response_at": normalize_business_datetime(
|
||||
fields.get("support_first_response_at")
|
||||
),
|
||||
"support_last_reply_at": normalize_business_datetime(
|
||||
fields.get("support_last_reply_at")
|
||||
),
|
||||
}
|
||||
if not normalized["description"]:
|
||||
raise TicketDataError("尚未形成有效问题描述")
|
||||
|
||||
category = TicketCategory.objects.filter(
|
||||
code=normalized["category_code"],
|
||||
enabled=True,
|
||||
).first()
|
||||
if category is None:
|
||||
normalized["category_code"] = ""
|
||||
if normalized["priority"] not in TicketPriority.values:
|
||||
normalized["priority"] = TicketPriority.MEDIUM
|
||||
|
||||
missing_fields = [
|
||||
code
|
||||
for code in REQUIRED_TICKET_FIELDS
|
||||
if code != "priority"
|
||||
and (
|
||||
not normalized.get(code)
|
||||
or (code == "category_code" and category is None)
|
||||
)
|
||||
]
|
||||
missing_fields = sorted(
|
||||
set(missing_fields)
|
||||
| {
|
||||
code
|
||||
for code in (reported_missing_fields or [])
|
||||
if code in EDITABLE_TICKET_FIELDS and not normalized.get(code)
|
||||
}
|
||||
)
|
||||
if not normalized["title"]:
|
||||
normalized["title"] = f"待补全问题:{normalized['description'][:170]}".strip()
|
||||
|
||||
assignee = None
|
||||
if normalized["assignee_id"]:
|
||||
assignee = get_user_model().objects.filter(id=normalized["assignee_id"]).first()
|
||||
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no=next_ticket_no(),
|
||||
title=normalized["title"][:200],
|
||||
description=normalized["description"],
|
||||
category=category,
|
||||
status=TicketStatus.PENDING_INFO if missing_fields else TicketStatus.PENDING,
|
||||
priority=normalized["priority"],
|
||||
problem_performance=normalized["problem_performance"],
|
||||
problem_object=normalized["problem_object"],
|
||||
submitter_name=normalized["submitter_name"],
|
||||
submitter_org=normalized["submitter_org"],
|
||||
impact_scope=normalized["impact_scope"],
|
||||
environment=normalized["environment"],
|
||||
related_system=normalized["related_system"],
|
||||
product_name=normalized["product_name"],
|
||||
module_name=normalized["module_name"],
|
||||
tags=normalized["tags"],
|
||||
attempted_actions=normalized["attempted_actions"],
|
||||
error_information=normalized["error_information"],
|
||||
customer_request=normalized["customer_request"],
|
||||
current_result=normalized["current_result"],
|
||||
assignee=assignee,
|
||||
problem_started_at=normalized["problem_started_at"],
|
||||
customer_last_message_at=normalized["customer_last_message_at"],
|
||||
support_first_response_at=normalized["support_first_response_at"],
|
||||
support_last_reply_at=normalized["support_last_reply_at"],
|
||||
requester=user,
|
||||
created_by=user,
|
||||
)
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="created",
|
||||
to_value={
|
||||
"status": ticket.status,
|
||||
"category_code": normalized["category_code"] or None,
|
||||
},
|
||||
operator=user,
|
||||
source=TicketActorSource.AGENT,
|
||||
agent_run_id=agent_run_id,
|
||||
metadata={"missing_fields": missing_fields, "creation_mode": "auto_intake"},
|
||||
)
|
||||
|
||||
evidence_by_field = evidence_by_field or {}
|
||||
confidence_by_field = confidence_by_field or {}
|
||||
human_field_names = human_field_names or set()
|
||||
for field_name in EDITABLE_TICKET_FIELDS:
|
||||
value = _field_value(ticket, field_name)
|
||||
if value in (None, ""):
|
||||
continue
|
||||
evidence = evidence_by_field.get(field_name) or {}
|
||||
if field_name == "title" and "title" in missing_fields:
|
||||
evidence = {**evidence, "generated_from": "description"}
|
||||
field_source = (
|
||||
TicketActorSource.HUMAN
|
||||
if field_name in human_field_names
|
||||
else TicketActorSource.AGENT
|
||||
)
|
||||
record_field_evidence(
|
||||
ticket,
|
||||
field_name,
|
||||
value,
|
||||
source=field_source,
|
||||
operator=user,
|
||||
confidence=(
|
||||
None
|
||||
if field_source == TicketActorSource.HUMAN
|
||||
else confidence_by_field.get(field_name)
|
||||
),
|
||||
evidence=evidence,
|
||||
agent_run_id=agent_run_id,
|
||||
is_protected=field_source == TicketActorSource.HUMAN,
|
||||
)
|
||||
from apps.tickets.similarity import schedule_ticket_similarity_refresh
|
||||
|
||||
schedule_ticket_similarity_refresh(ticket)
|
||||
return ticket, missing_fields
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_ticket_fields(
|
||||
ticket: Ticket,
|
||||
changes: dict,
|
||||
operator=None,
|
||||
*,
|
||||
source: str = TicketActorSource.HUMAN,
|
||||
agent_run_id: int | None = None,
|
||||
evidence_by_field: dict | None = None,
|
||||
confidence_by_field: dict | None = None,
|
||||
) -> tuple[Ticket, dict[str, str]]:
|
||||
"""通过统一入口更新工单字段,并返回被人工保护规则拦截的字段及原因。"""
|
||||
|
||||
unknown_fields = set(changes) - EDITABLE_TICKET_FIELDS
|
||||
if unknown_fields:
|
||||
raise TicketDataError(f"不允许修改字段: {', '.join(sorted(unknown_fields))}")
|
||||
|
||||
ticket = Ticket.objects.select_for_update().select_related("category").get(id=ticket.id)
|
||||
evidence_by_field = evidence_by_field or {}
|
||||
confidence_by_field = confidence_by_field or {}
|
||||
before: dict = {}
|
||||
applied: dict = {}
|
||||
skipped: dict[str, str] = {}
|
||||
update_fields: set[str] = set()
|
||||
|
||||
for field_name, value in changes.items():
|
||||
if source == TicketActorSource.AGENT and has_protected_field(ticket, field_name):
|
||||
skipped[field_name] = "protected_by_human"
|
||||
continue
|
||||
|
||||
before[field_name] = _json_value(_field_value(ticket, field_name))
|
||||
if field_name == "tags":
|
||||
ticket.tags = normalize_tags(value)
|
||||
update_fields.add("tags")
|
||||
elif field_name in BUSINESS_TIME_FIELDS:
|
||||
normalized_datetime = normalize_business_datetime(value)
|
||||
if value not in (None, "") and normalized_datetime is None:
|
||||
raise TicketDataError(f"{field_name} 必须包含明确时区")
|
||||
setattr(ticket, field_name, normalized_datetime)
|
||||
update_fields.add(field_name)
|
||||
elif field_name == "assignee_id":
|
||||
assignee = get_user_model().objects.filter(id=value).first() if value else None
|
||||
if value and assignee is None:
|
||||
raise TicketDataError("处理人不存在")
|
||||
ticket.assignee = assignee
|
||||
update_fields.add("assignee")
|
||||
elif field_name == "category_code":
|
||||
category = (
|
||||
TicketCategory.objects.filter(code=value, enabled=True).first() if value else None
|
||||
)
|
||||
if value and category is None:
|
||||
raise TicketDataError("工单分类无效或已停用")
|
||||
ticket.category = category
|
||||
update_fields.add("category")
|
||||
else:
|
||||
setattr(ticket, field_name, value)
|
||||
update_fields.add(field_name)
|
||||
|
||||
applied[field_name] = _json_value(_field_value(ticket, field_name))
|
||||
|
||||
if update_fields:
|
||||
ticket.save(update_fields=[*sorted(update_fields), "updated_at"])
|
||||
for field_name, value in applied.items():
|
||||
record_field_evidence(
|
||||
ticket,
|
||||
field_name,
|
||||
value,
|
||||
source=source,
|
||||
operator=operator,
|
||||
confidence=confidence_by_field.get(field_name),
|
||||
evidence=evidence_by_field.get(field_name),
|
||||
agent_run_id=agent_run_id,
|
||||
is_protected=source == TicketActorSource.HUMAN,
|
||||
)
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="updated",
|
||||
from_value=before,
|
||||
to_value=applied,
|
||||
operator=operator,
|
||||
source=source,
|
||||
agent_run_id=agent_run_id,
|
||||
metadata={"skipped_fields": skipped},
|
||||
)
|
||||
OperationAuditLog.objects.create(
|
||||
user=operator,
|
||||
module="tickets",
|
||||
action="update",
|
||||
target_type="ticket",
|
||||
target_id=str(ticket.id),
|
||||
result="success",
|
||||
request_snapshot={
|
||||
"fields": sorted(applied),
|
||||
"skipped_fields": skipped,
|
||||
"source": source,
|
||||
"agent_run_id": agent_run_id,
|
||||
},
|
||||
)
|
||||
from apps.workitems.services import sync_ticket_missing_info_work_item
|
||||
|
||||
outstanding_fields = get_outstanding_intake_fields(ticket)
|
||||
if ticket.status == TicketStatus.PENDING_INFO and not outstanding_fields:
|
||||
previous_status = ticket.status
|
||||
ticket.status = TicketStatus.PENDING
|
||||
ticket.save(update_fields=["status", "updated_at"])
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="status_change",
|
||||
from_value={"status": previous_status},
|
||||
to_value={"status": ticket.status},
|
||||
operator=operator,
|
||||
source=TicketActorSource.SYSTEM,
|
||||
agent_run_id=agent_run_id,
|
||||
remark="待补全信息已完成,工单进入待处理",
|
||||
)
|
||||
sync_ticket_missing_info_work_item(
|
||||
ticket,
|
||||
outstanding_fields,
|
||||
actor=operator,
|
||||
)
|
||||
if {
|
||||
"title",
|
||||
"description",
|
||||
"problem_performance",
|
||||
"error_information",
|
||||
"product_name",
|
||||
"module_name",
|
||||
"related_system",
|
||||
"attempted_actions",
|
||||
"customer_request",
|
||||
"current_result",
|
||||
"tags",
|
||||
} & set(applied):
|
||||
from apps.tickets.similarity import schedule_ticket_similarity_refresh
|
||||
|
||||
schedule_ticket_similarity_refresh(ticket)
|
||||
if ticket.status in {TicketStatus.RESOLVED, TicketStatus.CLOSED}:
|
||||
schedule_ticket_search_index(ticket.id)
|
||||
return ticket, skipped
|
||||
|
||||
|
||||
def get_ticket_resolution(ticket: Ticket):
|
||||
from apps.solutions.models import TicketResolution
|
||||
|
||||
return TicketResolution.objects.filter(ticket=ticket).select_related("linked_solution").first()
|
||||
|
||||
|
||||
def record_ticket_resolution(
|
||||
ticket: Ticket,
|
||||
solution_text: str,
|
||||
operator=None,
|
||||
*,
|
||||
resolution_type: str = "resolved",
|
||||
root_cause: str = "",
|
||||
linked_solution=None,
|
||||
source: str = TicketActorSource.HUMAN,
|
||||
agent_run_id: int | None = None,
|
||||
):
|
||||
from apps.solutions.models import TicketResolution
|
||||
|
||||
normalized_text = solution_text.strip()
|
||||
if not normalized_text:
|
||||
raise TicketDataError("解决方案不能为空")
|
||||
resolution, _ = TicketResolution.objects.update_or_create(
|
||||
ticket=ticket,
|
||||
defaults={
|
||||
"resolution_type": resolution_type,
|
||||
"source": source,
|
||||
"root_cause": root_cause,
|
||||
"solution_text": normalized_text,
|
||||
"linked_solution": linked_solution,
|
||||
"agent_run_id": agent_run_id,
|
||||
"confirmed_by": operator if source == TicketActorSource.HUMAN else None,
|
||||
},
|
||||
)
|
||||
return resolution
|
||||
|
||||
|
||||
STANDARD_TRANSITIONS: dict[str, set[str]] = {
|
||||
"pending_info": {"pending", "closed"},
|
||||
"pending": {"in_progress", "closed"},
|
||||
@@ -163,6 +724,7 @@ def transition_requires_remark(
|
||||
return to_status in {"in_progress", "pending_confirm", "closed"}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def transition_ticket(
|
||||
ticket: Ticket,
|
||||
to_status: str,
|
||||
@@ -170,22 +732,38 @@ def transition_ticket(
|
||||
operator=None,
|
||||
*,
|
||||
resolution: str = "",
|
||||
is_resolved: bool | None = None,
|
||||
replied_to_customer: bool | None = None,
|
||||
resolution_type: str = "resolved",
|
||||
root_cause: str = "",
|
||||
linked_solution=None,
|
||||
source: str = TicketActorSource.HUMAN,
|
||||
agent_run_id: int | None = None,
|
||||
) -> Ticket:
|
||||
"""校验状态机和必需业务结果后完成状态流转、事件记录及后续任务同步。"""
|
||||
|
||||
from_status = ticket.status
|
||||
category_code = get_ticket_category_code(ticket)
|
||||
validate_status_transition(from_status, to_status, category_code)
|
||||
resolution_record = get_ticket_resolution(ticket)
|
||||
if resolution:
|
||||
resolution_record = record_ticket_resolution(
|
||||
ticket,
|
||||
resolution,
|
||||
operator,
|
||||
resolution_type=resolution_type,
|
||||
root_cause=root_cause,
|
||||
linked_solution=linked_solution,
|
||||
source=source,
|
||||
agent_run_id=agent_run_id,
|
||||
)
|
||||
if to_status == "resolved" and resolution_record is None:
|
||||
raise TicketDataError("标记已解决前必须提供解决方案")
|
||||
|
||||
ticket.status = to_status
|
||||
if to_status == "resolved":
|
||||
ticket.resolved_at = timezone.now()
|
||||
ticket.is_resolved = True if is_resolved is None else is_resolved
|
||||
elif to_status != "closed" and is_resolved is not None:
|
||||
ticket.is_resolved = is_resolved
|
||||
ticket.resolved_at = ticket.resolved_at or timezone.now()
|
||||
if to_status == "closed":
|
||||
ticket.closed_at = timezone.now()
|
||||
if resolution:
|
||||
ticket.resolution_summary = resolution
|
||||
if replied_to_customer is not None:
|
||||
ticket.replied_to_customer = replied_to_customer
|
||||
ticket.save(
|
||||
@@ -193,23 +771,31 @@ def transition_ticket(
|
||||
"status",
|
||||
"resolved_at",
|
||||
"closed_at",
|
||||
"resolution_summary",
|
||||
"is_resolved",
|
||||
"replied_to_customer",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
if from_status == "pending_info" and to_status != "pending_info":
|
||||
from apps.workitems.services import complete_ticket_missing_info_work_item
|
||||
|
||||
complete_ticket_missing_info_work_item(ticket, actor=operator)
|
||||
from apps.workitems.services import sync_ticket_close_work_item
|
||||
|
||||
sync_ticket_close_work_item(ticket, actor=operator)
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="status_change",
|
||||
from_value={"status": from_status},
|
||||
to_value={
|
||||
"status": to_status,
|
||||
"resolution": ticket.resolution_summary,
|
||||
"is_resolved": ticket.is_resolved,
|
||||
"resolution_id": resolution_record.id if resolution_record else None,
|
||||
"resolution": resolution_record.solution_text if resolution_record else "",
|
||||
"is_resolved": resolution_record is not None,
|
||||
"replied_to_customer": ticket.replied_to_customer,
|
||||
},
|
||||
operator=operator,
|
||||
source=source,
|
||||
agent_run_id=agent_run_id,
|
||||
remark=remark,
|
||||
)
|
||||
OperationAuditLog.objects.create(
|
||||
@@ -219,6 +805,123 @@ def transition_ticket(
|
||||
target_type="ticket",
|
||||
target_id=str(ticket.id),
|
||||
result="success",
|
||||
request_snapshot={"from": from_status, "to": to_status, "remark": remark},
|
||||
request_snapshot={
|
||||
"from": from_status,
|
||||
"to": to_status,
|
||||
"remark": remark,
|
||||
"source": source,
|
||||
"agent_run_id": agent_run_id,
|
||||
},
|
||||
)
|
||||
if to_status == "resolved":
|
||||
from apps.knowledge.tasks import dispatch_ticket_knowledge_distillation
|
||||
|
||||
actor_id = operator.id if operator is not None else None
|
||||
transaction.on_commit(
|
||||
lambda: dispatch_ticket_knowledge_distillation(ticket.id, actor_id)
|
||||
)
|
||||
schedule_ticket_search_index(ticket.id)
|
||||
return ticket
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def adopt_solution_for_ticket(
|
||||
ticket: Ticket,
|
||||
solution,
|
||||
operator=None,
|
||||
*,
|
||||
remark: str = "采纳解决方案并标记工单已解决",
|
||||
replied_to_customer: bool = False,
|
||||
source: str = TicketActorSource.HUMAN,
|
||||
agent_run_id: int | None = None,
|
||||
):
|
||||
from apps.solutions.models import Solution, TicketSolutionLink
|
||||
|
||||
locked_solution = Solution.objects.select_for_update().get(id=solution.id)
|
||||
if locked_solution.status != "published":
|
||||
raise TicketDataError("只能采纳已发布的解决方案")
|
||||
locked_ticket = (
|
||||
Ticket.objects.select_for_update()
|
||||
.select_related("category", "resolution")
|
||||
.get(id=ticket.id)
|
||||
)
|
||||
from_status = locked_ticket.status
|
||||
link, _ = TicketSolutionLink.objects.get_or_create(
|
||||
ticket=locked_ticket,
|
||||
solution=locked_solution,
|
||||
link_type="adopted",
|
||||
defaults={
|
||||
"agent_run_id": agent_run_id,
|
||||
"created_by": operator,
|
||||
},
|
||||
)
|
||||
transition_ticket(
|
||||
locked_ticket,
|
||||
TicketStatus.RESOLVED,
|
||||
remark,
|
||||
operator,
|
||||
resolution=locked_solution.solution,
|
||||
replied_to_customer=replied_to_customer,
|
||||
root_cause=locked_solution.root_cause,
|
||||
linked_solution=locked_solution,
|
||||
source=source,
|
||||
agent_run_id=agent_run_id,
|
||||
)
|
||||
locked_solution.usage_count = locked_solution.ticket_links.filter(
|
||||
link_type="adopted"
|
||||
).count()
|
||||
locked_solution.save(update_fields=["usage_count", "updated_at"])
|
||||
locked_ticket.events.create(
|
||||
event_type="solution_adopted",
|
||||
from_value={"status": from_status},
|
||||
to_value={
|
||||
"status": TicketStatus.RESOLVED,
|
||||
"solution_id": locked_solution.id,
|
||||
"solution_title": locked_solution.title,
|
||||
},
|
||||
operator=operator,
|
||||
source=source,
|
||||
agent_run_id=agent_run_id,
|
||||
remark=remark,
|
||||
)
|
||||
OperationAuditLog.objects.create(
|
||||
user=operator,
|
||||
module="tickets",
|
||||
action="solution_adopted",
|
||||
target_type="ticket",
|
||||
target_id=str(locked_ticket.id),
|
||||
request_snapshot={
|
||||
"solution_id": locked_solution.id,
|
||||
"source": source,
|
||||
"agent_run_id": agent_run_id,
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
return locked_ticket, link
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def set_ticket_archived(ticket: Ticket, archived: bool, operator=None) -> Ticket:
|
||||
ticket = Ticket.objects.select_for_update().get(id=ticket.id)
|
||||
before = ticket.archived_at
|
||||
ticket.archived_at = timezone.now() if archived else None
|
||||
ticket.archived_by = operator if archived else None
|
||||
ticket.save(update_fields=["archived_at", "archived_by", "updated_at"])
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="archived" if archived else "restored",
|
||||
from_value={"archived_at": _json_value(before)},
|
||||
to_value={"archived_at": _json_value(ticket.archived_at)},
|
||||
operator=operator,
|
||||
)
|
||||
OperationAuditLog.objects.create(
|
||||
user=operator,
|
||||
module="tickets",
|
||||
action="archive" if archived else "restore",
|
||||
target_type="ticket",
|
||||
target_id=str(ticket.id),
|
||||
result="success",
|
||||
request_snapshot={},
|
||||
)
|
||||
schedule_ticket_search_index(ticket.id)
|
||||
return ticket
|
||||
|
||||
@@ -3,7 +3,14 @@ from datetime import timedelta
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.tickets.models import Ticket, TicketCategory, TicketEvent
|
||||
from apps.solutions.models import TicketResolution
|
||||
from apps.tickets.models import (
|
||||
Ticket,
|
||||
TicketCategory,
|
||||
TicketCollaborator,
|
||||
TicketComment,
|
||||
TicketEvent,
|
||||
)
|
||||
|
||||
|
||||
def test_create_ticket_returns_ticket_detail(admin_client, db):
|
||||
@@ -205,8 +212,8 @@ def test_business_ticket_can_be_resolved_directly_with_resolution(admin_client,
|
||||
assert response.status_code == 200
|
||||
ticket.refresh_from_db()
|
||||
assert ticket.status == "resolved"
|
||||
assert ticket.resolution_summary == "已查询采集进度,当前已完成 80%。"
|
||||
assert ticket.is_resolved is True
|
||||
assert ticket.resolution.solution_text == "已查询采集进度,当前已完成 80%。"
|
||||
assert response.json()["is_resolved"] is True
|
||||
assert ticket.replied_to_customer is True
|
||||
|
||||
|
||||
@@ -238,20 +245,19 @@ def test_closing_resolved_ticket_keeps_resolved_flag(admin_client, db):
|
||||
title="已解决待关闭",
|
||||
description="A",
|
||||
status="resolved",
|
||||
is_resolved=True,
|
||||
resolution_summary="已完成处理",
|
||||
)
|
||||
TicketResolution.objects.create(ticket=ticket, solution_text="已完成处理")
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/tickets/{ticket.id}/status",
|
||||
data={"status": "closed", "remark": "已回复", "is_resolved": False},
|
||||
data={"status": "closed", "remark": "已回复"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
ticket.refresh_from_db()
|
||||
assert ticket.status == "closed"
|
||||
assert ticket.is_resolved is True
|
||||
assert response.json()["is_resolved"] is True
|
||||
|
||||
|
||||
def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db):
|
||||
@@ -287,3 +293,88 @@ def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db)
|
||||
assert payload[1]["to_value"] == {"status": "in_progress"}
|
||||
assert payload[1]["remark"] == "开始排查认证链路"
|
||||
assert payload[1]["operator"]["display_name"] == "admin"
|
||||
|
||||
|
||||
def test_ticket_comments_return_history_author_and_validate_visibility(admin_client, db):
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607270301",
|
||||
title="评论历史",
|
||||
description="需要协同排查",
|
||||
)
|
||||
|
||||
created = admin_client.post(
|
||||
f"/api/tickets/{ticket.id}/comments",
|
||||
data={
|
||||
"content": " 已检查应用日志,等待数据库同事反馈。 ",
|
||||
"comment_type": "internal_note",
|
||||
"visibility": "internal",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert created.status_code == 200
|
||||
assert created.json()["content"] == "已检查应用日志,等待数据库同事反馈。"
|
||||
assert created.json()["created_by"]["username"] == "admin"
|
||||
history = admin_client.get(f"/api/tickets/{ticket.id}/comments")
|
||||
assert history.status_code == 200
|
||||
assert history.json()[0]["created_by"]["display_name"] == "admin"
|
||||
assert TicketEvent.objects.filter(ticket=ticket, event_type="commented").count() == 1
|
||||
|
||||
mismatched = admin_client.post(
|
||||
f"/api/tickets/{ticket.id}/comments",
|
||||
data={
|
||||
"content": "客户可见内容",
|
||||
"comment_type": "user_reply",
|
||||
"visibility": "internal",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert mismatched.status_code == 400
|
||||
assert TicketComment.objects.filter(ticket=ticket).count() == 1
|
||||
|
||||
|
||||
def test_ticket_collaboration_lists_active_users_and_is_idempotent(admin_client, db):
|
||||
user_model = get_user_model()
|
||||
active_user = user_model.objects.create_user(
|
||||
username="collaborator",
|
||||
first_name="协作成员",
|
||||
is_active=True,
|
||||
)
|
||||
user_model.objects.create_user(username="disabled-collaborator", is_active=False)
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607270302",
|
||||
title="协作管理",
|
||||
description="需要多人处理",
|
||||
)
|
||||
|
||||
participants = admin_client.get("/api/tickets/participants")
|
||||
assert participants.status_code == 200
|
||||
usernames = {item["username"] for item in participants.json()}
|
||||
assert "collaborator" in usernames
|
||||
assert "disabled-collaborator" not in usernames
|
||||
|
||||
payload = {"user_id": active_user.id, "role": "collaborator"}
|
||||
first = admin_client.post(
|
||||
f"/api/tickets/{ticket.id}/collaborators",
|
||||
data=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
duplicate = admin_client.post(
|
||||
f"/api/tickets/{ticket.id}/collaborators",
|
||||
data=payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
assert first.status_code == duplicate.status_code == 200
|
||||
assert first.json()["id"] == duplicate.json()["id"]
|
||||
assert TicketCollaborator.objects.filter(ticket=ticket).count() == 1
|
||||
assert TicketEvent.objects.filter(ticket=ticket, event_type="collaborator_added").count() == 1
|
||||
|
||||
listed = admin_client.get(f"/api/tickets/{ticket.id}/collaborators")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["user"]["display_name"] == "协作成员"
|
||||
|
||||
removed = admin_client.delete(
|
||||
f"/api/tickets/{ticket.id}/collaborators/{first.json()['id']}"
|
||||
)
|
||||
assert removed.status_code == 200
|
||||
assert TicketCollaborator.objects.filter(ticket=ticket).count() == 0
|
||||
|
||||
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() == []
|
||||
Reference in New Issue
Block a user