280 lines
9.1 KiB
Python
280 lines
9.1 KiB
Python
"""客户回复草稿的修订、渠道批准与送达确认领域服务。"""
|
|
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from apps.audit.models import OperationAuditLog
|
|
from apps.tickets.models import (
|
|
CustomerReplyRevision,
|
|
CustomerReplyTask,
|
|
Ticket,
|
|
TicketActorSource,
|
|
TicketSupportAnalysis,
|
|
)
|
|
from apps.workitems.services import sync_customer_reply_work_item
|
|
|
|
|
|
class CustomerReplyError(ValueError):
|
|
pass
|
|
|
|
|
|
def _normalized_reply(value: str) -> str:
|
|
normalized = value.strip()
|
|
if not normalized:
|
|
raise CustomerReplyError("客户回复内容不能为空")
|
|
if len(normalized) > 10000:
|
|
raise CustomerReplyError("客户回复内容不能超过 10000 个字符")
|
|
return normalized
|
|
|
|
|
|
@transaction.atomic
|
|
def ensure_customer_reply_task(
|
|
analysis: TicketSupportAnalysis,
|
|
draft_text: str,
|
|
) -> CustomerReplyTask | None:
|
|
"""为可信分析创建或复用客户回复任务,并冻结初始建议版本。"""
|
|
|
|
normalized = draft_text.strip()
|
|
if not normalized:
|
|
return None
|
|
analysis = (
|
|
TicketSupportAnalysis.objects.select_for_update()
|
|
.select_related("ticket", "run", "created_by")
|
|
.get(id=analysis.id)
|
|
)
|
|
task = (
|
|
CustomerReplyTask.objects.select_for_update()
|
|
.filter(ticket=analysis.ticket, status__in=["draft", "approved"])
|
|
.first()
|
|
)
|
|
if task is None:
|
|
task = CustomerReplyTask.objects.create(
|
|
ticket=analysis.ticket,
|
|
support_analysis=analysis,
|
|
draft_text=normalized,
|
|
final_text=normalized,
|
|
created_by=analysis.created_by,
|
|
)
|
|
CustomerReplyRevision.objects.create(
|
|
task=task,
|
|
version=1,
|
|
content=normalized,
|
|
source=TicketActorSource.AGENT,
|
|
)
|
|
elif task.status == "draft":
|
|
has_human_edit = task.revisions.filter(source=TicketActorSource.HUMAN).exists()
|
|
task.support_analysis = analysis
|
|
task.draft_text = normalized
|
|
update_fields = ["support_analysis", "draft_text", "updated_at"]
|
|
if not has_human_edit and task.final_text != normalized:
|
|
task.revision += 1
|
|
task.final_text = normalized
|
|
update_fields.extend(["revision", "final_text"])
|
|
CustomerReplyRevision.objects.create(
|
|
task=task,
|
|
version=task.revision,
|
|
content=normalized,
|
|
source=TicketActorSource.AGENT,
|
|
)
|
|
task.save(update_fields=update_fields)
|
|
else:
|
|
sync_customer_reply_work_item(task, actor=analysis.created_by)
|
|
return task
|
|
sync_customer_reply_work_item(task, actor=analysis.created_by)
|
|
analysis.ticket.events.create(
|
|
event_type="customer_reply_draft_created",
|
|
to_value={
|
|
"reply_task_id": task.id,
|
|
"revision": task.revision,
|
|
"support_analysis_id": analysis.id,
|
|
},
|
|
operator=analysis.created_by,
|
|
source=TicketActorSource.AGENT,
|
|
agent_run_id=analysis.run_id,
|
|
)
|
|
return task
|
|
|
|
|
|
@transaction.atomic
|
|
def update_customer_reply(
|
|
task: CustomerReplyTask,
|
|
final_text: str,
|
|
*,
|
|
expected_revision: int,
|
|
actor,
|
|
) -> CustomerReplyTask:
|
|
"""使用乐观版本号修订回复,已批准或已发送版本不可被静默覆盖。"""
|
|
|
|
task = (
|
|
CustomerReplyTask.objects.select_for_update()
|
|
.select_related("ticket", "support_analysis")
|
|
.get(id=task.id)
|
|
)
|
|
if task.status != "draft":
|
|
raise CustomerReplyError("已确认渠道发送、已发送或已取消的回复不能再编辑")
|
|
if task.revision != expected_revision:
|
|
raise CustomerReplyError("回复草稿已被更新,请刷新后重试")
|
|
normalized = _normalized_reply(final_text)
|
|
if normalized == task.final_text:
|
|
return task
|
|
task.revision += 1
|
|
task.final_text = normalized
|
|
task.updated_by = actor
|
|
task.save(update_fields=["revision", "final_text", "updated_by", "updated_at"])
|
|
CustomerReplyRevision.objects.create(
|
|
task=task,
|
|
version=task.revision,
|
|
content=normalized,
|
|
source=TicketActorSource.HUMAN,
|
|
edited_by=actor,
|
|
)
|
|
sync_customer_reply_work_item(task, actor=actor)
|
|
task.ticket.events.create(
|
|
event_type="customer_reply_edited",
|
|
to_value={"reply_task_id": task.id, "revision": task.revision},
|
|
operator=actor,
|
|
source=TicketActorSource.HUMAN,
|
|
agent_run_id=(
|
|
task.support_analysis.run_id if task.support_analysis_id else None
|
|
),
|
|
)
|
|
OperationAuditLog.objects.create(
|
|
user=actor,
|
|
module="tickets",
|
|
action="customer_reply_edited",
|
|
target_type="customer_reply_task",
|
|
target_id=str(task.id),
|
|
request_snapshot={"ticket_id": task.ticket_id, "revision": task.revision},
|
|
result="success",
|
|
)
|
|
return task
|
|
|
|
|
|
@transaction.atomic
|
|
def approve_customer_reply_for_channel(
|
|
task: CustomerReplyTask,
|
|
*,
|
|
expected_revision: int,
|
|
actor,
|
|
channel_endpoint_id: int,
|
|
external_conversation_id: str,
|
|
) -> CustomerReplyTask:
|
|
"""批准指定回复版本进入唯一渠道会话,不在此函数直接发送消息。"""
|
|
|
|
task = (
|
|
CustomerReplyTask.objects.select_for_update()
|
|
.select_related("ticket", "support_analysis")
|
|
.get(id=task.id)
|
|
)
|
|
if task.status == "approved":
|
|
if task.revision != expected_revision:
|
|
raise CustomerReplyError("回复版本与已确认版本不一致")
|
|
return task
|
|
if task.status != "draft":
|
|
raise CustomerReplyError("只有待确认回复可以进入渠道发送")
|
|
if task.revision != expected_revision:
|
|
raise CustomerReplyError("回复草稿已被更新,请刷新后重试")
|
|
_normalized_reply(task.final_text)
|
|
now = timezone.now()
|
|
task.status = "approved"
|
|
task.approved_by = actor
|
|
task.approved_at = now
|
|
task.save(
|
|
update_fields=["status", "approved_by", "approved_at", "updated_at"]
|
|
)
|
|
sync_customer_reply_work_item(task, actor=actor)
|
|
task.ticket.events.create(
|
|
event_type="customer_reply_channel_approved",
|
|
to_value={
|
|
"reply_task_id": task.id,
|
|
"revision": task.revision,
|
|
"channel_endpoint_id": channel_endpoint_id,
|
|
"external_conversation_id": external_conversation_id,
|
|
"approved_at": now.isoformat(),
|
|
},
|
|
operator=actor,
|
|
source=TicketActorSource.HUMAN,
|
|
agent_run_id=(
|
|
task.support_analysis.run_id if task.support_analysis_id else None
|
|
),
|
|
)
|
|
OperationAuditLog.objects.create(
|
|
user=actor,
|
|
module="tickets",
|
|
action="customer_reply_channel_approved",
|
|
target_type="customer_reply_task",
|
|
target_id=str(task.id),
|
|
request_snapshot={
|
|
"ticket_id": task.ticket_id,
|
|
"revision": task.revision,
|
|
"channel_endpoint_id": channel_endpoint_id,
|
|
"external_conversation_id": external_conversation_id,
|
|
},
|
|
result="success",
|
|
)
|
|
return task
|
|
|
|
|
|
@transaction.atomic
|
|
def confirm_customer_reply_sent(
|
|
task: CustomerReplyTask,
|
|
*,
|
|
expected_revision: int,
|
|
actor,
|
|
source: str = TicketActorSource.HUMAN,
|
|
channel_message_public_id: str = "",
|
|
) -> CustomerReplyTask:
|
|
"""仅在人工确认或渠道明确送达后标记回复完成并记录审计。"""
|
|
|
|
task = (
|
|
CustomerReplyTask.objects.select_for_update()
|
|
.select_related("ticket", "support_analysis")
|
|
.get(id=task.id)
|
|
)
|
|
if task.status == "sent":
|
|
return task
|
|
if task.status not in {"draft", "approved"}:
|
|
raise CustomerReplyError("已取消的回复不能确认发送")
|
|
if task.revision != expected_revision:
|
|
raise CustomerReplyError("回复草稿已被更新,请刷新后重试")
|
|
_normalized_reply(task.final_text)
|
|
now = timezone.now()
|
|
task.status = "sent"
|
|
task.sent_by = actor
|
|
task.sent_at = now
|
|
task.save(update_fields=["status", "sent_by", "sent_at", "updated_at"])
|
|
Ticket.objects.filter(id=task.ticket_id).update(
|
|
replied_to_customer=True,
|
|
support_last_reply_at=now,
|
|
updated_at=now,
|
|
)
|
|
sync_customer_reply_work_item(task, actor=actor)
|
|
task.ticket.events.create(
|
|
event_type="customer_reply_sent",
|
|
to_value={
|
|
"reply_task_id": task.id,
|
|
"revision": task.revision,
|
|
"sent_at": now.isoformat(),
|
|
"channel_message_public_id": channel_message_public_id,
|
|
},
|
|
operator=actor,
|
|
source=source,
|
|
agent_run_id=(
|
|
task.support_analysis.run_id if task.support_analysis_id else None
|
|
),
|
|
)
|
|
OperationAuditLog.objects.create(
|
|
user=actor,
|
|
module="tickets",
|
|
action="customer_reply_sent",
|
|
target_type="customer_reply_task",
|
|
target_id=str(task.id),
|
|
request_snapshot={
|
|
"ticket_id": task.ticket_id,
|
|
"revision": task.revision,
|
|
"channel_message_public_id": channel_message_public_id,
|
|
},
|
|
result="success",
|
|
)
|
|
return task
|