951 lines
34 KiB
Python
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,
|
|
}
|