feat: consolidate ticket domain baseline

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

View File

@@ -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