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

951 lines
34 KiB
Python

"""工单从自动建单到一次确认、送达解决和定时关闭的领域编排。"""
from datetime import timedelta
from django.conf import settings
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
from apps.audit.models import OperationAuditLog
from apps.tickets.models import (
CustomerReplyTask,
Ticket,
TicketActorSource,
TicketAutomationEvent,
TicketAutomationRun,
TicketAutomationRunStatus,
TicketStatus,
TicketSupportAnalysis,
)
from apps.tickets.replies import (
confirm_customer_reply_sent,
update_customer_reply,
)
from apps.tickets.services import (
TicketDataError,
TicketStatusError,
adopt_solution_for_ticket,
create_follow_up_ticket,
reopen_ticket,
transition_ticket,
update_ticket_fields,
)
from apps.tickets.support_analysis import (
SupportAnalysisError,
start_ticket_support_analysis,
)
from apps.workitems.models import WorkItem, WorkItemStatus, WorkItemType
ACTIVE_AUTOMATION_STATUSES = {
TicketAutomationRunStatus.QUEUED,
TicketAutomationRunStatus.RUNNING,
TicketAutomationRunStatus.WAITING_INPUT,
TicketAutomationRunStatus.WAITING_APPROVAL,
TicketAutomationRunStatus.WAITING_DELIVERY,
TicketAutomationRunStatus.WAITING_CUSTOMER,
}
class TicketAutomationError(ValueError):
pass
def ticket_automation_readiness() -> dict:
"""检查自动闭环的静态依赖;失败时新工单安全回退现有人工流程。"""
from apps.agents.models import AgentDefinition
from apps.channels.models import ChannelEndpoint
definitions = {
item.code: item
for item in AgentDefinition.objects.select_related(
"active_version__model_deployment__provider"
).filter(code__in={"ticket-intake", "support-analysis"})
}
def agent_ready(code: str) -> bool:
definition = definitions.get(code)
if definition is None or definition.active_version_id is None:
return False
deployment = definition.active_version.model_deployment
return bool(
deployment
and deployment.enabled
and deployment.archived_at is None
and deployment.provider.enabled
and deployment.provider.archived_at is None
)
checks = {
"ticket_intake_agent": agent_ready("ticket-intake"),
"support_analysis_agent": agent_ready("support-analysis"),
"celery_broker_configured": bool(settings.CELERY_BROKER_URL),
"object_storage_configured": all(
[
settings.MINIO_ENDPOINT,
settings.MINIO_ACCESS_KEY,
settings.MINIO_SECRET_KEY,
settings.MINIO_BUCKET,
]
),
}
channel_configured = ChannelEndpoint.objects.filter(enabled=True).exists()
return {
"enabled": bool(settings.TICKET_AUTOMATION_ENABLED),
"ready": all(checks.values()),
"checks": checks,
"delivery_mode": "managed_channel" if channel_configured else "manual_confirmation",
}
def _dispatch_advance(run_id: int) -> None:
from apps.tickets.tasks import advance_ticket_automation_task
advance_ticket_automation_task.delay(run_id)
def _dispatch_close(run_id: int, eta) -> None:
from apps.tickets.tasks import close_ticket_automation_task
close_ticket_automation_task.apply_async(args=[run_id], eta=eta)
def resume_automation_for_agent_run(agent_run_id: int) -> None:
analysis = TicketSupportAnalysis.objects.filter(run_id=agent_run_id).first()
if analysis is None:
return
automation_run = analysis.automation_runs.filter(
status__in=ACTIVE_AUTOMATION_STATUSES
).first()
if automation_run is not None:
_dispatch_advance(automation_run.id)
def append_automation_event(
run: TicketAutomationRun,
event_type: str,
payload: dict | None = None,
) -> TicketAutomationEvent:
"""在锁持有事务中分配单调序号,供页面续传和审计。"""
latest = (
TicketAutomationEvent.objects.filter(run=run).aggregate(value=Max("sequence"))[
"value"
]
or 0
)
return TicketAutomationEvent.objects.create(
run=run,
sequence=latest + 1,
event_type=event_type,
payload=payload or {},
)
def automation_policy_snapshot() -> dict:
return {
"version": "ticket_once_confirm_v1",
"max_retries": int(settings.TICKET_AUTOMATION_MAX_RETRIES),
"auto_close_hours": int(settings.TICKET_AUTO_CLOSE_HOURS),
"support_reference_min_score": float(settings.SUPPORT_REFERENCE_MIN_SCORE),
}
@transaction.atomic
def start_ticket_automation(
ticket: Ticket,
*,
initiated_by=None,
blocking_missing_fields: list[str] | None = None,
blocking_questions: list[str] | None = None,
force: bool = False,
) -> TicketAutomationRun | None:
"""创建唯一活动编排;默认受功能开关控制,显式重试可强制启动。"""
if not force and not settings.TICKET_AUTOMATION_ENABLED:
return None
if not force and not ticket_automation_readiness()["ready"]:
return None
ticket = Ticket.objects.select_for_update(of=("self",)).get(id=ticket.id)
if ticket.status == TicketStatus.CLOSED or ticket.archived_at is not None:
raise TicketAutomationError("已关闭或已归档工单不能启动自动处理")
active = (
TicketAutomationRun.objects.select_for_update()
.filter(ticket=ticket, status__in=ACTIVE_AUTOMATION_STATUSES)
.first()
)
if active is not None:
return active
run = TicketAutomationRun.objects.create(
ticket=ticket,
initiated_by=initiated_by,
policy_snapshot=automation_policy_snapshot(),
blocking_missing_fields=blocking_missing_fields or [],
blocking_questions=blocking_questions or [],
)
append_automation_event(
run,
"queued",
{
"ticket_id": ticket.id,
"blocking_missing_fields": run.blocking_missing_fields,
},
)
ticket.events.create(
event_type="automation_started",
to_value={"automation_run_public_id": str(run.public_id)},
operator=initiated_by,
source=TicketActorSource.SYSTEM,
metadata={"policy": run.policy_snapshot},
)
transaction.on_commit(lambda: _dispatch_advance(run.id))
return run
def _mark_failed(
run: TicketAutomationRun,
code: str,
message: str,
) -> TicketAutomationRun:
run.status = TicketAutomationRunStatus.FAILED
run.current_step = "human_takeover"
run.last_error_code = code
run.last_error_message = message
run.finished_at = timezone.now()
run.save()
append_automation_event(run, "failed", {"error_code": code, "message": message})
WorkItem.objects.get_or_create(
task_type=WorkItemType.SUPPORT_ANALYSIS_FAILED,
object_type="ticket_automation_run",
object_id=str(run.public_id),
status=WorkItemStatus.ACTIVE,
defaults={
"ticket": run.ticket,
"title": f"工单 {run.ticket.ticket_no} 自动分析待接管",
"description": message,
"payload": {
"automation_run_public_id": str(run.public_id),
"error_code": code,
},
"assignee": run.ticket.assignee,
"created_by": run.initiated_by,
},
)
return run
@transaction.atomic
def advance_ticket_automation(run_id: int) -> TicketAutomationRun:
"""幂等推进自动流程;异步边界只保存等待状态,由完成回调继续。"""
run = (
TicketAutomationRun.objects.select_for_update(of=("self",))
.select_related("ticket__category", "initiated_by", "support_analysis__run")
.get(id=run_id)
)
if run.status not in ACTIVE_AUTOMATION_STATUSES:
return run
ticket = Ticket.objects.select_for_update(of=("self",)).select_related("category").get(
id=run.ticket_id
)
if ticket.status == TicketStatus.CLOSED:
run.status = TicketAutomationRunStatus.COMPLETED
run.current_step = "closed"
run.finished_at = run.finished_at or timezone.now()
run.save()
append_automation_event(run, "completed", {"ticket_status": ticket.status})
return run
if run.blocking_missing_fields:
run.status = TicketAutomationRunStatus.WAITING_INPUT
run.current_step = "collect_input"
run.save()
append_automation_event(
run,
"waiting_input",
{
"missing_fields": run.blocking_missing_fields,
"questions": run.blocking_questions,
},
)
return run
run.status = TicketAutomationRunStatus.RUNNING
run.started_at = run.started_at or timezone.now()
run.last_error_code = ""
run.last_error_message = ""
try:
if ticket.status == TicketStatus.PENDING_INFO:
run.current_step = "accept_intake"
transition_ticket(
ticket,
TicketStatus.PENDING,
"非阻塞字段允许后续补全,自动进入待处理",
run.initiated_by,
source=TicketActorSource.SYSTEM,
)
ticket.refresh_from_db()
append_automation_event(run, "intake_accepted")
if ticket.status == TicketStatus.PENDING:
run.current_step = "start_processing"
transition_ticket(
ticket,
TicketStatus.IN_PROGRESS,
"自动编排开始支持分析",
run.initiated_by,
source=TicketActorSource.SYSTEM,
)
ticket.refresh_from_db()
append_automation_event(run, "processing_started")
if ticket.status == TicketStatus.IN_PROGRESS:
run.current_step = "support_analysis"
analysis = run.support_analysis
if analysis is None:
analysis = start_ticket_support_analysis(
ticket,
created_by=run.initiated_by,
source=TicketActorSource.AGENT,
)
run.support_analysis = analysis
run.save()
append_automation_event(
run,
"analysis_started",
{"agent_run_public_id": str(analysis.run.public_id)},
)
return run
analysis.run.refresh_from_db()
if analysis.run.status in {"queued", "running"}:
run.save()
return run
if analysis.run.status != "succeeded" or analysis.processed_at is None:
max_retries = int(run.policy_snapshot.get("max_retries", 2))
if run.retry_count < max_retries:
run.retry_count += 1
run.support_analysis = None
run.save()
append_automation_event(
run,
"analysis_retrying",
{
"retry_count": run.retry_count,
"error_code": analysis.run.error_code,
},
)
transaction.on_commit(lambda: _dispatch_advance(run.id))
return run
return _mark_failed(
run,
analysis.run.error_code or "support_analysis_failed",
analysis.run.error_message or "支持分析未形成可信结果,请人工接管",
)
reply_task = (
CustomerReplyTask.objects.filter(
ticket=ticket,
support_analysis=analysis,
status__in=["draft", "approved"],
)
.order_by("-created_at")
.first()
)
if reply_task is None:
return _mark_failed(
run,
"reply_task_missing",
"支持分析已完成,但未生成正式客户回复任务",
)
transition_ticket(
ticket,
TicketStatus.PENDING_CONFIRM,
"支持分析和正式回复已准备完成,等待一次批准发送",
run.initiated_by,
source=TicketActorSource.AGENT,
agent_run_id=analysis.run_id,
)
run.reply_task = reply_task
run.status = TicketAutomationRunStatus.WAITING_APPROVAL
run.current_step = "approve_and_send"
run.save()
WorkItem.objects.filter(
task_type=WorkItemType.SUPPORT_ANALYSIS_FAILED,
object_type="ticket_automation_run",
object_id=str(run.public_id),
status=WorkItemStatus.ACTIVE,
).update(
status=WorkItemStatus.COMPLETED,
completed_by=run.initiated_by,
completed_at=timezone.now(),
)
append_automation_event(
run,
"waiting_approval",
{
"support_analysis_id": analysis.id,
"reply_task_id": reply_task.id,
"result_mode": analysis.result_mode,
},
)
return run
if ticket.status == TicketStatus.PENDING_CONFIRM:
run.status = TicketAutomationRunStatus.WAITING_APPROVAL
run.current_step = "approve_and_send"
run.save()
return run
if ticket.status == TicketStatus.RESOLVED:
_schedule_customer_wait(run)
return run
except (SupportAnalysisError, TicketStatusError, TicketDataError) as error:
return _mark_failed(run, "automation_step_failed", str(error))
run.save()
return run
def _approval_resolution(run: TicketAutomationRun) -> tuple[str, str, int | None]:
approval = run.policy_snapshot.get("approval", {})
return (
str(approval.get("resolution_text") or "").strip(),
str(approval.get("root_cause") or "").strip(),
approval.get("solution_id"),
)
@transaction.atomic
def approve_and_send(
run: TicketAutomationRun,
*,
actor,
expected_reply_revision: int,
final_text: str,
resolution_text: str,
root_cause: str = "",
solution_id: int | None = None,
channel_endpoint_id: int | None = None,
external_conversation_id: str = "",
manual_delivery_confirmed: bool = False,
) -> TicketAutomationRun:
"""一次冻结方案和回复;有渠道则投递,无渠道则确认外部已发送。"""
run = (
TicketAutomationRun.objects.select_for_update(of=("self",))
.select_related("ticket", "reply_task")
.get(id=run.id)
)
if run.status in {
TicketAutomationRunStatus.WAITING_CUSTOMER,
TicketAutomationRunStatus.COMPLETED,
} and run.approved_at:
return run
if run.status not in {
TicketAutomationRunStatus.WAITING_APPROVAL,
TicketAutomationRunStatus.WAITING_DELIVERY,
}:
raise TicketAutomationError("当前工单不等待批准发送")
if run.reply_task_id is None:
raise TicketAutomationError("正式客户回复任务不存在")
if not resolution_text.strip() and not solution_id:
raise TicketAutomationError("批准前必须提供解决结论或已发布方案")
task = run.reply_task
if task.status == "draft" and final_text.strip() != task.final_text:
task = update_customer_reply(
task,
final_text,
expected_revision=expected_reply_revision,
actor=actor,
)
expected_reply_revision = task.revision
elif task.revision != expected_reply_revision:
raise TicketAutomationError("客户回复版本已变化,请刷新后重试")
snapshot = dict(run.policy_snapshot)
snapshot["approval"] = {
"resolution_text": resolution_text.strip(),
"root_cause": root_cause.strip(),
"solution_id": solution_id,
"reply_task_id": task.id,
"reply_revision": task.revision,
"approved_at": timezone.now().isoformat(),
}
run.policy_snapshot = snapshot
run.approved_by = actor
run.approved_at = timezone.now()
run.reply_task = task
if channel_endpoint_id:
from apps.channels.models import ChannelEndpoint
from apps.channels.services import ChannelError, queue_outbound_message
endpoint = ChannelEndpoint.objects.filter(id=channel_endpoint_id, enabled=True).first()
if endpoint is None:
raise TicketAutomationError("渠道不存在或已停用")
try:
message = queue_outbound_message(
endpoint,
{
"external_conversation_id": external_conversation_id,
"idempotency_key": f"ticket-automation:{run.public_id}:reply:{task.revision}",
"customer_reply_task_id": task.id,
"expected_revision": task.revision,
"payload": {"automation_run_public_id": str(run.public_id)},
},
actor=actor,
)
except ChannelError as error:
raise TicketAutomationError(str(error)) from error
run.status = TicketAutomationRunStatus.WAITING_DELIVERY
run.current_step = "wait_delivery"
run.save()
append_automation_event(
run,
"reply_queued",
{"channel_message_public_id": str(message.public_id)},
)
elif manual_delivery_confirmed:
confirm_customer_reply_sent(
task,
expected_revision=task.revision,
actor=actor,
)
run.status = TicketAutomationRunStatus.WAITING_DELIVERY
run.current_step = "wait_delivery_callback"
run.save()
append_automation_event(run, "manual_delivery_confirmed")
else:
raise TicketAutomationError("请选择发送渠道,或确认已在外部完成发送")
OperationAuditLog.objects.create(
user=actor,
module="tickets",
action="automation_approved_and_sent",
target_type="ticket_automation_run",
target_id=str(run.public_id),
request_snapshot={
"ticket_id": run.ticket_id,
"reply_task_id": task.id,
"reply_revision": task.revision,
"solution_id": solution_id,
"delivery_mode": "channel" if channel_endpoint_id else "manual_confirmed",
},
result="success",
)
return run
@transaction.atomic
def handle_customer_reply_delivered(reply_task_id: int) -> TicketAutomationRun | None:
"""渠道或人工确认送达后记录解决结果并启动无异议关闭计时。"""
run = (
TicketAutomationRun.objects.select_for_update(of=("self",))
.select_related("ticket__category", "approved_by", "support_analysis__run")
.filter(
reply_task_id=reply_task_id,
status__in={
TicketAutomationRunStatus.WAITING_DELIVERY,
TicketAutomationRunStatus.WAITING_APPROVAL,
},
)
.first()
)
if run is None:
return None
ticket = Ticket.objects.select_for_update(of=("self",)).select_related("category").get(
id=run.ticket_id
)
if ticket.status == TicketStatus.PENDING_CONFIRM:
resolution_text, root_cause, solution_id = _approval_resolution(run)
if solution_id:
from apps.solutions.models import Solution
solution = Solution.objects.filter(id=solution_id, status="published").first()
if solution is None:
return _mark_failed(run, "solution_changed", "批准的解决方案已失效")
adopt_solution_for_ticket(
ticket,
solution,
run.approved_by,
remark="客户回复送达后按一次批准结果标记解决",
replied_to_customer=True,
source=TicketActorSource.SYSTEM,
agent_run_id=run.support_analysis.run_id
if run.support_analysis_id
else None,
)
else:
transition_ticket(
ticket,
TicketStatus.RESOLVED,
"客户回复送达后按一次批准结果标记解决",
run.approved_by,
resolution=resolution_text,
replied_to_customer=True,
root_cause=root_cause,
source=TicketActorSource.SYSTEM,
agent_run_id=run.support_analysis.run_id
if run.support_analysis_id
else None,
)
_schedule_customer_wait(run)
return run
@transaction.atomic
def handle_customer_reply_delivery_failed(
reply_task_id: int,
error_message: str,
) -> TicketAutomationRun | None:
"""记录渠道失败但保留批准快照,使同一外发消息可安全重试。"""
run = (
TicketAutomationRun.objects.select_for_update(of=("self",))
.filter(
reply_task_id=reply_task_id,
status=TicketAutomationRunStatus.WAITING_DELIVERY,
)
.first()
)
if run is None:
return None
run.current_step = "delivery_failed"
run.last_error_code = "channel_delivery_failed"
run.last_error_message = error_message[:2000] or "渠道发送失败"
run.save()
append_automation_event(
run,
"delivery_failed",
{"error": run.last_error_message},
)
return run
def _schedule_customer_wait(run: TicketAutomationRun) -> None:
hours = int(run.policy_snapshot.get("auto_close_hours", 24))
eta = timezone.now() + timedelta(hours=hours)
# 自动关闭已经接管结束动作,避免工作台继续制造一个人工“关闭工单”待办。
WorkItem.objects.filter(
ticket_id=run.ticket_id,
task_type=WorkItemType.TICKET_CLOSE,
status=WorkItemStatus.ACTIVE,
).update(
status=WorkItemStatus.COMPLETED,
completed_by=run.approved_by,
completed_at=timezone.now(),
)
run.status = TicketAutomationRunStatus.WAITING_CUSTOMER
run.current_step = "wait_customer"
run.next_action_at = eta
run.save()
append_automation_event(
run,
"waiting_customer",
{"auto_close_at": eta.isoformat(), "hours": hours},
)
transaction.on_commit(lambda: _dispatch_close(run.id, eta))
@transaction.atomic
def close_due_ticket_automation(run_id: int, *, force: bool = False) -> bool:
"""客户无异议窗口结束后,以系统身份幂等关闭已解决工单。"""
run = (
TicketAutomationRun.objects.select_for_update(of=("self",))
.select_related("ticket")
.get(id=run_id)
)
if run.status != TicketAutomationRunStatus.WAITING_CUSTOMER:
return False
if not force and (run.next_action_at is None or run.next_action_at > timezone.now()):
return False
ticket = Ticket.objects.select_for_update(of=("self",)).get(id=run.ticket_id)
if ticket.status != TicketStatus.RESOLVED:
return False
transition_ticket(
ticket,
TicketStatus.CLOSED,
"客户回复送达后 24 小时无异议,系统按策略自动关闭",
None,
source=TicketActorSource.SYSTEM,
)
run.status = TicketAutomationRunStatus.COMPLETED
run.current_step = "closed"
run.next_action_at = None
run.finished_at = timezone.now()
run.save()
append_automation_event(run, "completed", {"reason": "no_objection_timeout"})
return True
POSITIVE_CUSTOMER_PHRASES = (
"已解决",
"恢复了",
"可以了",
"好了",
"works now",
"resolved",
)
NEGATIVE_CUSTOMER_PHRASES = (
"未解决",
"还是不行",
"仍然不行",
"没有恢复",
"继续报错",
"not resolved",
"still broken",
)
@transaction.atomic
def handle_customer_feedback(
ticket_id: int,
text: str,
*,
channel_message_id: int | None = None,
) -> str:
"""只对明确反馈自动推进;含糊消息暂停关闭,避免误判客户意图。"""
normalized = " ".join(text.casefold().split())
run = (
TicketAutomationRun.objects.select_for_update(of=("self",))
.select_related("ticket")
.filter(
ticket_id=ticket_id,
status__in={
TicketAutomationRunStatus.WAITING_CUSTOMER,
TicketAutomationRunStatus.WAITING_INPUT,
},
)
.first()
)
if run is None:
ticket = Ticket.objects.select_for_update(of=("self",)).filter(id=ticket_id).first()
if (
ticket is None
or ticket.status != TicketStatus.CLOSED
or not any(phrase in normalized for phrase in NEGATIVE_CUSTOMER_PHRASES)
):
return "ignored"
follow_up = create_follow_up_ticket(ticket, text)
completed_run = (
TicketAutomationRun.objects.filter(
ticket=ticket,
status=TicketAutomationRunStatus.COMPLETED,
)
.order_by("-created_at")
.first()
)
if completed_run is not None and not completed_run.events.filter(
event_type="follow_up_created"
).exists():
append_automation_event(
completed_run,
"follow_up_created",
{"follow_up_ticket_id": follow_up.id, "text": text[:500]},
)
if channel_message_id:
from apps.channels.models import ChannelMessage
message = ChannelMessage.objects.select_related("conversation").filter(
id=channel_message_id
).first()
if message is not None:
message.ticket = follow_up
message.save(update_fields=["ticket", "updated_at"])
if message.conversation_id:
message.conversation.ticket = follow_up
message.conversation.save(update_fields=["ticket", "updated_at"])
return "follow_up_created"
if (
run.status == TicketAutomationRunStatus.WAITING_INPUT
and run.current_step == "collect_input"
and normalized
):
previous_context = run.ticket.current_result.strip()
customer_context = f"{previous_context}\n客户补充:{text.strip()}".strip()
update_ticket_fields(
run.ticket,
{
"current_result": customer_context,
"customer_last_message_at": timezone.now(),
},
source=TicketActorSource.CHANNEL,
)
run.blocking_missing_fields = []
run.blocking_questions = []
run.status = TicketAutomationRunStatus.QUEUED
run.current_step = "resume_after_customer_input"
run.save()
append_automation_event(run, "customer_input_received", {"text": text[:500]})
transaction.on_commit(lambda: _dispatch_advance(run.id))
return "resumed"
if any(phrase in normalized for phrase in NEGATIVE_CUSTOMER_PHRASES):
reopen_ticket(run.ticket, source=TicketActorSource.CHANNEL)
run.status = TicketAutomationRunStatus.RUNNING
run.current_step = "support_analysis"
run.support_analysis = None
run.reply_task = None
run.next_action_at = None
run.save()
append_automation_event(run, "customer_rejected", {"text": text[:500]})
transaction.on_commit(lambda: _dispatch_advance(run.id))
return "reopened"
if any(phrase in normalized for phrase in POSITIVE_CUSTOMER_PHRASES):
append_automation_event(run, "customer_confirmed", {"text": text[:500]})
close_due_ticket_automation(run.id, force=True)
return "closed"
run.status = TicketAutomationRunStatus.WAITING_INPUT
run.current_step = "confirm_customer_intent"
run.blocking_questions = ["请确认客户是否认可当前解决结果"]
run.next_action_at = None
run.save()
append_automation_event(run, "customer_intent_ambiguous", {"text": text[:500]})
from apps.workitems.models import WorkItem
WorkItem.objects.get_or_create(
task_type=WorkItemType.TICKET_CUSTOMER_CONFIRMATION,
object_type="ticket",
object_id=str(ticket_id),
status="active",
defaults={
"ticket": run.ticket,
"title": f"确认工单 {run.ticket.ticket_no} 客户反馈",
"description": "客户在自动关闭窗口内发送了新消息,需确认是否已经解决。",
"payload": {
"automation_run_public_id": str(run.public_id),
"message": text[:1000],
},
"assignee": run.ticket.assignee,
"created_by": run.approved_by,
},
)
return "needs_confirmation"
@transaction.atomic
def apply_automation_action(
run: TicketAutomationRun,
action: str,
*,
actor,
) -> TicketAutomationRun:
run = TicketAutomationRun.objects.select_for_update(of=("self",)).get(id=run.id)
if (
action == "retry"
and run.status == TicketAutomationRunStatus.WAITING_DELIVERY
and run.current_step == "delivery_failed"
):
from apps.channels.models import ChannelMessage
from apps.channels.services import ChannelError, retry_outbound_message
message = (
ChannelMessage.objects.filter(
customer_reply_task_id=run.reply_task_id,
direction="outbound",
status="failed",
)
.order_by("-created_at")
.first()
)
if message is None:
raise TicketAutomationError("没有可重试的失败渠道消息")
try:
retry_outbound_message(message, actor=actor)
except ChannelError as error:
raise TicketAutomationError(str(error)) from error
run.current_step = "wait_delivery"
run.last_error_code = ""
run.last_error_message = ""
run.save()
append_automation_event(
run,
"delivery_retry_queued",
{"channel_message_public_id": str(message.public_id)},
)
return run
if action in {"pause", "take_over", "cancel"}:
run.status = TicketAutomationRunStatus.CANCELLED
run.current_step = "human_takeover" if action == "take_over" else "paused"
run.finished_at = timezone.now()
run.save()
append_automation_event(run, action, {"actor_id": actor.id})
return run
if action in {"retry", "resume"}:
if run.status not in {
TicketAutomationRunStatus.FAILED,
TicketAutomationRunStatus.CANCELLED,
TicketAutomationRunStatus.WAITING_INPUT,
}:
raise TicketAutomationError("当前自动化运行不允许重试或恢复")
if action == "resume" and run.status == TicketAutomationRunStatus.WAITING_INPUT:
run.blocking_missing_fields = []
run.blocking_questions = []
run.status = TicketAutomationRunStatus.QUEUED
run.current_step = "resume"
run.last_error_code = ""
run.last_error_message = ""
run.finished_at = None
run.save()
WorkItem.objects.filter(
task_type=WorkItemType.SUPPORT_ANALYSIS_FAILED,
object_type="ticket_automation_run",
object_id=str(run.public_id),
status=WorkItemStatus.ACTIVE,
).update(
status=WorkItemStatus.COMPLETED,
completed_by=actor,
completed_at=timezone.now(),
)
append_automation_event(run, action, {"actor_id": actor.id})
transaction.on_commit(lambda: _dispatch_advance(run.id))
return run
raise TicketAutomationError("未知自动化操作")
def serialize_automation_run(run: TicketAutomationRun) -> dict:
ticket = run.ticket
if run.status in {
TicketAutomationRunStatus.QUEUED,
TicketAutomationRunStatus.RUNNING,
}:
display_phase = "processing"
next_human_action = ""
elif run.status == TicketAutomationRunStatus.WAITING_APPROVAL:
display_phase = "confirmation"
next_human_action = "approve_and_send"
elif run.status in {
TicketAutomationRunStatus.WAITING_DELIVERY,
TicketAutomationRunStatus.WAITING_CUSTOMER,
}:
delivery_failed = run.current_step == "delivery_failed"
display_phase = "attention" if delivery_failed else "confirmation"
next_human_action = "retry" if delivery_failed else ""
elif run.status == TicketAutomationRunStatus.COMPLETED:
display_phase = "completed"
next_human_action = ""
else:
display_phase = "attention"
next_human_action = "take_over"
return {
"public_id": str(run.public_id),
"ticket_id": run.ticket_id,
"ticket_status": ticket.status,
"status": run.status,
"current_step": run.current_step,
"display_phase": display_phase,
"next_human_action": next_human_action,
"blocking_missing_fields": run.blocking_missing_fields,
"blocking_questions": run.blocking_questions,
"retry_count": run.retry_count,
"last_error_code": run.last_error_code,
"last_error_message": run.last_error_message,
"support_analysis_id": run.support_analysis_id,
"reply_task_id": run.reply_task_id,
"next_action_at": run.next_action_at,
"approved_at": run.approved_at,
"started_at": run.started_at,
"finished_at": run.finished_at,
"created_at": run.created_at,
"updated_at": run.updated_at,
}