"""工单从自动建单到一次确认、送达解决和定时关闭的领域编排。""" 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, }