Files
ai-ticket-platform/backend/apps/tickets/services.py
2026-09-18 15:26:19 +08:00

1091 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""工单领域服务。
所有工单写入都应经过本模块,以统一执行字段校验、人工字段保护、状态机、证据记录和审计。
"""
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,
TicketActorSource,
TicketCategory,
TicketDraft,
TicketEvent,
TicketFieldEvidence,
TicketPriority,
TicketStatus,
)
class TicketStatusError(ValueError):
pass
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",
"category_code",
"priority",
"problem_performance",
"problem_object",
"submitter_name",
"submitter_org",
"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 = {
"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")
),
}
missing = [code for code in REQUIRED_TICKET_FIELDS if not normalized.get(code)]
if missing:
raise TicketDataError(f"缺少必填字段: {', '.join(missing)}")
if normalized["priority"] not in TicketPriority.values:
raise TicketDataError("工单优先级无效")
category = TicketCategory.objects.filter(code=normalized["category_code"], enabled=True).first()
if category is None:
raise TicketDataError("工单分类无效或已停用")
if normalized["assignee_id"]:
assignee = get_user_model().objects.filter(id=normalized["assignee_id"]).first()
if assignee is None:
raise TicketDataError("处理人不存在")
else:
assignee = None
normalized["assignee"] = assignee
return normalized, category
def next_ticket_no() -> str:
prefix = timezone.localdate().strftime("TK%Y%m%d")
count = Ticket.objects.filter(ticket_no__startswith=prefix).count() + 1
return f"{prefix}{count:04d}"
@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(),
title=normalized["title"],
description=normalized["description"],
category=category,
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=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,
)
TicketEvent.objects.create(
ticket=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_follow_up_ticket(
source_ticket: Ticket,
customer_feedback: str,
*,
operator=None,
) -> Ticket:
"""为已关闭工单的新负向反馈创建唯一活动后续工单。
已关闭记录保持不可变;后续问题继承必要上下文但不继承处理人,自动化未就绪时
仍可按普通工单人工接管。
"""
source_ticket = (
Ticket.objects.select_for_update(of=("self",))
.select_related("category", "requester", "created_by")
.get(id=source_ticket.id)
)
if source_ticket.status != TicketStatus.CLOSED:
raise TicketStatusError("只有已关闭工单才能创建后续工单")
existing = (
source_ticket.follow_up_tickets.exclude(status=TicketStatus.CLOSED)
.order_by("-created_at")
.first()
)
if existing is not None:
return existing
feedback = customer_feedback.strip()
if not feedback:
raise TicketDataError("客户反馈不能为空")
actor = operator or source_ticket.requester or source_ticket.created_by
ticket = Ticket.objects.create(
ticket_no=next_ticket_no(),
title=f"后续问题:{feedback[:170]}"[:200],
description=feedback,
category=source_ticket.category,
status=TicketStatus.PENDING,
priority=source_ticket.priority,
problem_performance=feedback,
problem_object=source_ticket.problem_object,
submitter_name=source_ticket.submitter_name,
submitter_org=source_ticket.submitter_org,
impact_scope=source_ticket.impact_scope,
environment=source_ticket.environment,
related_system=source_ticket.related_system,
product_name=source_ticket.product_name,
module_name=source_ticket.module_name,
tags=source_ticket.tags,
attempted_actions=source_ticket.attempted_actions,
error_information=source_ticket.error_information,
customer_request=source_ticket.customer_request,
current_result=f"客户在原工单关闭后反馈:{feedback}",
requester=source_ticket.requester,
created_by=actor,
follow_up_of=source_ticket,
customer_last_message_at=timezone.now(),
)
TicketEvent.objects.create(
ticket=ticket,
event_type="created",
to_value={
"status": ticket.status,
"category_code": ticket.category.code if ticket.category_id else None,
},
operator=operator,
source=TicketActorSource.CHANNEL,
metadata={
"creation_mode": "closed_ticket_follow_up",
"source_ticket_id": source_ticket.id,
},
)
TicketEvent.objects.create(
ticket=source_ticket,
event_type="follow_up_created",
to_value={"follow_up_ticket_id": ticket.id, "ticket_no": ticket.ticket_no},
operator=operator,
source=TicketActorSource.SYSTEM,
metadata={"customer_feedback": feedback[:1000]},
)
for field_name, value in {
"description": feedback,
"problem_performance": feedback,
"current_result": ticket.current_result,
}.items():
record_field_evidence(
ticket,
field_name,
value,
source=TicketActorSource.CHANNEL,
operator=operator,
evidence={"source_ticket_id": source_ticket.id},
)
from apps.tickets.similarity import schedule_ticket_similarity_refresh
schedule_ticket_similarity_refresh(ticket)
ticket_id = ticket.id
def start_follow_up_automation() -> None:
from apps.tickets.automation import start_ticket_automation
follow_up = Ticket.objects.get(id=ticket_id)
start_ticket_automation(follow_up, initiated_by=actor)
transaction.on_commit(start_follow_up_automation)
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(of=("self",))
.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"},
"in_progress": {"pending_confirm"},
"pending_confirm": {"resolved", "in_progress", "closed"},
"resolved": {"closed"},
"closed": set(),
}
SHORT_TRANSITIONS: dict[str, set[str]] = {
"pending_info": {"pending", "resolved", "closed"},
"pending": {"resolved", "closed"},
"in_progress": {"resolved", "closed"},
"pending_confirm": {"resolved", "closed"},
"resolved": {"closed"},
"closed": set(),
}
MEDIUM_TRANSITIONS: dict[str, set[str]] = {
"pending_info": {"pending", "closed"},
"pending": {"in_progress", "resolved", "closed"},
"in_progress": {"resolved", "pending_confirm", "closed"},
"pending_confirm": {"resolved", "in_progress", "closed"},
"resolved": {"closed"},
"closed": set(),
}
CATEGORY_TRANSITION_POLICIES = {
"business_issue": SHORT_TRANSITIONS,
"operation_issue": SHORT_TRANSITIONS,
"data_issue": MEDIUM_TRANSITIONS,
"technical_issue": STANDARD_TRANSITIONS,
}
# Backward compatible export used by older tests and internal imports.
ALLOWED_TRANSITIONS = STANDARD_TRANSITIONS
def get_ticket_category_code(ticket: Ticket) -> str | None:
if ticket.category_id and ticket.category:
return ticket.category.code
return None
def get_allowed_transitions(from_status: str, category_code: str | None = None) -> set[str]:
transitions = CATEGORY_TRANSITION_POLICIES.get(category_code or "", STANDARD_TRANSITIONS)
return transitions.get(from_status, set())
def validate_status_transition(
from_status: str,
to_status: str,
category_code: str | None = None,
) -> None:
if to_status not in get_allowed_transitions(from_status, category_code):
raise TicketStatusError(f"Invalid ticket status transition: {from_status} -> {to_status}")
def transition_requires_remark(
from_status: str,
to_status: str,
category_code: str | None = None,
) -> bool:
if to_status == "resolved":
return False
if category_code in {"business_issue", "operation_issue"} and to_status == "closed":
return False
return to_status in {"in_progress", "pending_confirm", "closed"}
@transaction.atomic
def transition_ticket(
ticket: Ticket,
to_status: str,
remark: str = "",
operator=None,
*,
resolution: str = "",
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 = ticket.resolved_at or timezone.now()
if to_status == "closed":
ticket.closed_at = timezone.now()
if replied_to_customer is not None:
ticket.replied_to_customer = replied_to_customer
ticket.save(
update_fields=[
"status",
"resolved_at",
"closed_at",
"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_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(
user=operator,
module="tickets",
action="status_transition",
target_type="ticket",
target_id=str(ticket.id),
result="success",
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 reopen_ticket(
ticket: Ticket,
*,
operator=None,
remark: str = "客户反馈问题仍未解决,恢复处理",
source: str = TicketActorSource.SYSTEM,
) -> Ticket:
"""在保留既有事件和解决记录的前提下开启新一轮处理。"""
ticket = Ticket.objects.select_for_update(of=("self",)).get(id=ticket.id)
if ticket.status != TicketStatus.RESOLVED:
raise TicketStatusError("只有已解决且尚未关闭的工单可以恢复处理")
from_status = ticket.status
ticket.status = TicketStatus.IN_PROGRESS
ticket.resolved_at = None
ticket.closed_at = None
ticket.replied_to_customer = False
ticket.save(
update_fields=[
"status",
"resolved_at",
"closed_at",
"replied_to_customer",
"updated_at",
]
)
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="ticket_reopened",
from_value={"status": from_status},
to_value={"status": TicketStatus.IN_PROGRESS},
operator=operator,
source=source,
remark=remark,
)
OperationAuditLog.objects.create(
user=operator,
module="tickets",
action="ticket_reopened",
target_type="ticket",
target_id=str(ticket.id),
request_snapshot={"source": source, "remark": remark},
result="success",
)
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(of=("self",))
.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