293 lines
10 KiB
Python
293 lines
10 KiB
Python
"""工单业务:创建 / 升级 / 客服处理(幂等与校验)。"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import random
|
||
import time
|
||
from decimal import Decimal
|
||
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
|
||
from gongdan.constants import (
|
||
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD,
|
||
TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED,
|
||
STATUS_LABELS, MAX_IMAGES, MAX_SHUOMING_LEN, MAX_SHENGJI_TIMES, OSS_PREFIX,
|
||
)
|
||
from gongdan.models import Gongdan, GongdanImage, GongdanShengji
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class GongdanError(Exception):
|
||
def __init__(self, message, code='error'):
|
||
super().__init__(message)
|
||
self.code = code
|
||
|
||
|
||
def _gen_gongdan_id():
|
||
ts = int(time.time() * 1000)
|
||
ns = int(time.time_ns() // 1000) % 1000000
|
||
rnd = random.randint(0, 99)
|
||
return f'GD{ts:011d}{ns:06d}{rnd:02d}'
|
||
|
||
|
||
def _trim(text, max_len=MAX_SHUOMING_LEN):
|
||
return (text or '').strip()[:max_len]
|
||
|
||
|
||
def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None):
|
||
"""按类型校验必填关联 ID,并尽量核对库内是否存在。"""
|
||
order_id = (order_id or '').strip() or None
|
||
chongzhi_id = (chongzhi_id or '').strip() or None
|
||
fadan_id = (fadan_id or '').strip() or None
|
||
guanshi_id = (guanshi_id or '').strip() or None
|
||
|
||
if leixing == TYPE_ORDER:
|
||
if not order_id:
|
||
raise GongdanError('订单投诉请填写订单ID', 'need_order_id')
|
||
from orders.models import Order
|
||
if not Order.query.filter(OrderID=order_id).exists():
|
||
raise GongdanError('订单不存在,请核对订单ID', 'order_not_found')
|
||
return order_id, None, None, None
|
||
|
||
if leixing == TYPE_RECHARGE:
|
||
if not chongzhi_id:
|
||
raise GongdanError('充值投诉请填写充值记录ID', 'need_chongzhi_id')
|
||
from products.models import Czjilu
|
||
if not Czjilu.query.filter(dingdan_id=chongzhi_id).exists():
|
||
raise GongdanError('充值记录不存在,请核对充值ID', 'chongzhi_not_found')
|
||
return None, chongzhi_id, None, None
|
||
|
||
if leixing == TYPE_PENALTY:
|
||
if not fadan_id:
|
||
raise GongdanError('罚款投诉请填写罚单ID', 'need_fadan_id')
|
||
from orders.models import Penalty
|
||
try:
|
||
fid = int(fadan_id)
|
||
except (TypeError, ValueError):
|
||
raise GongdanError('罚单ID格式不正确', 'bad_fadan_id')
|
||
if not Penalty.query.filter(id=fid).exists():
|
||
raise GongdanError('罚单不存在,请核对罚单ID', 'fadan_not_found')
|
||
return None, None, str(fid), None
|
||
|
||
if leixing == TYPE_GUANSHI:
|
||
# 管事UID 选填,有则校验存在
|
||
if guanshi_id:
|
||
from users.models import UserGuanshi
|
||
if not UserGuanshi.objects.filter(user__UserUID=guanshi_id).exists():
|
||
raise GongdanError('管事不存在,请核对管事ID', 'guanshi_not_found')
|
||
return None, None, None, guanshi_id
|
||
|
||
if leixing == TYPE_FRAUD:
|
||
return None, None, None, None
|
||
|
||
raise GongdanError('不支持的投诉类型', 'bad_type')
|
||
|
||
|
||
def serialize_gongdan(gd, include_images=True, include_shengji=False):
|
||
data = {
|
||
'gongdan_id': gd.gongdan_id,
|
||
'leixing': gd.leixing,
|
||
'leixing_label': TYPE_LABELS.get(gd.leixing, str(gd.leixing)),
|
||
'order_id': gd.order_id or '',
|
||
'chongzhi_id': gd.chongzhi_id or '',
|
||
'fadan_id': gd.fadan_id or '',
|
||
'guanshi_id': gd.guanshi_id or '',
|
||
'shuoming': gd.shuoming or '',
|
||
'shenqingren_id': gd.shenqingren_id,
|
||
'club_id': gd.club_id or '',
|
||
'zhuangtai': gd.zhuangtai,
|
||
'zhuangtai_label': STATUS_LABELS.get(gd.zhuangtai, str(gd.zhuangtai)),
|
||
'chuli_jieguo': gd.chuli_jieguo or '',
|
||
'chuliren_id': gd.chuliren_id or '',
|
||
'chuliren_name': gd.chuliren_name or '',
|
||
'chuli_time': gd.chuli_time.isoformat() if gd.chuli_time else '',
|
||
'shengji_cishu': gd.shengji_cishu or 0,
|
||
'CreateTime': gd.CreateTime.isoformat() if gd.CreateTime else '',
|
||
'UpdateTime': gd.UpdateTime.isoformat() if gd.UpdateTime else '',
|
||
}
|
||
if include_images:
|
||
data['images'] = [
|
||
{'url': img.url, 'paixu': img.paixu}
|
||
for img in gd.images.all().order_by('paixu', 'id')
|
||
]
|
||
if include_shengji:
|
||
data['shengji_list'] = [
|
||
{
|
||
'cishu': s.cishu,
|
||
'shuoming': s.shuoming,
|
||
'CreateTime': s.CreateTime.isoformat() if s.CreateTime else '',
|
||
}
|
||
for s in gd.shengji_list.all().order_by('cishu', 'id')
|
||
]
|
||
return data
|
||
|
||
|
||
def _replace_images(gd, image_urls):
|
||
urls = [u.strip() for u in (image_urls or []) if u and str(u).strip()]
|
||
if len(urls) > MAX_IMAGES:
|
||
raise GongdanError(f'最多上传{MAX_IMAGES}张图片', 'too_many_images')
|
||
GongdanImage.objects.filter(gongdan=gd).delete()
|
||
for i, url in enumerate(urls):
|
||
GongdanImage.objects.create(
|
||
gongdan=gd,
|
||
gongdan_hao=gd.gongdan_id,
|
||
url=url[:512],
|
||
paixu=i,
|
||
)
|
||
|
||
|
||
@transaction.atomic
|
||
def create_gongdan(
|
||
*,
|
||
yonghuid,
|
||
leixing,
|
||
shuoming,
|
||
club_id=None,
|
||
order_id=None,
|
||
chongzhi_id=None,
|
||
fadan_id=None,
|
||
guanshi_id=None,
|
||
image_urls=None,
|
||
):
|
||
try:
|
||
leixing = int(leixing)
|
||
except (TypeError, ValueError):
|
||
raise GongdanError('投诉类型无效', 'bad_type')
|
||
if leixing not in TYPE_LABELS:
|
||
raise GongdanError('投诉类型无效', 'bad_type')
|
||
|
||
text = _trim(shuoming)
|
||
if not text:
|
||
raise GongdanError('请填写投诉说明', 'need_shuoming')
|
||
|
||
order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs(
|
||
leixing, order_id, chongzhi_id, fadan_id, guanshi_id,
|
||
)
|
||
|
||
gid = _gen_gongdan_id()
|
||
for _ in range(5):
|
||
if not Gongdan.query.filter(gongdan_id=gid).exists():
|
||
break
|
||
gid = _gen_gongdan_id()
|
||
|
||
gd = Gongdan.objects.create(
|
||
gongdan_id=gid,
|
||
leixing=leixing,
|
||
order_id=order_id,
|
||
chongzhi_id=chongzhi_id,
|
||
fadan_id=fadan_id,
|
||
guanshi_id=guanshi_id,
|
||
shuoming=text,
|
||
shenqingren_id=yonghuid,
|
||
club_id=club_id or None,
|
||
zhuangtai=STATUS_PROCESSING,
|
||
shengji_cishu=0,
|
||
)
|
||
_replace_images(gd, image_urls)
|
||
logger.info('创建工单 %s type=%s user=%s', gid, leixing, yonghuid)
|
||
return gd
|
||
|
||
|
||
@transaction.atomic
|
||
def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None):
|
||
"""不满意后继续投诉:写升级记录;可选整体替换证据图。"""
|
||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||
if not gd:
|
||
raise GongdanError('工单不存在', 'not_found')
|
||
if gd.shenqingren_id != yonghuid:
|
||
raise GongdanError('无权操作此工单', 'forbidden')
|
||
if gd.zhuangtai not in (STATUS_DONE, STATUS_DISSATISFIED):
|
||
raise GongdanError('当前状态不可继续投诉,请等待客服处理', 'bad_status')
|
||
if (gd.shengji_cishu or 0) >= MAX_SHENGJI_TIMES:
|
||
raise GongdanError(f'最多升级{MAX_SHENGJI_TIMES}次,请联系客服', 'too_many_shengji')
|
||
|
||
text = _trim(shuoming)
|
||
if not text:
|
||
raise GongdanError('请填写补充说明', 'need_shuoming')
|
||
|
||
next_cishu = (gd.shengji_cishu or 0) + 1
|
||
GongdanShengji.objects.create(
|
||
gongdan=gd,
|
||
gongdan_hao=gd.gongdan_id,
|
||
cishu=next_cishu,
|
||
shuoming=text,
|
||
shenqingren_id=yonghuid,
|
||
)
|
||
gd.shengji_cishu = next_cishu
|
||
gd.zhuangtai = STATUS_PROCESSING
|
||
gd.chuli_jieguo = ''
|
||
gd.chuli_time = None
|
||
# 保留原处理人记录便于审计,但状态回到处理中
|
||
gd.save(update_fields=[
|
||
'shengji_cishu', 'zhuangtai', 'chuli_jieguo', 'chuli_time', 'UpdateTime',
|
||
])
|
||
if image_urls is not None:
|
||
_replace_images(gd, image_urls)
|
||
logger.info('工单升级 %s cishu=%s', gongdan_id, next_cishu)
|
||
return gd
|
||
|
||
|
||
@transaction.atomic
|
||
def handle_gongdan(
|
||
*,
|
||
gongdan_id,
|
||
handler_uid,
|
||
handler_name,
|
||
action,
|
||
chuli_jieguo='',
|
||
):
|
||
"""
|
||
客服处理:
|
||
action=done → 已处理
|
||
action=dissatisfied → 标记不满意(一般由用户侧触发,客服 sparingly)
|
||
action=close → 关闭
|
||
action=processing → 重新标处理中
|
||
"""
|
||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||
if not gd:
|
||
raise GongdanError('工单不存在', 'not_found')
|
||
|
||
action = (action or '').strip().lower()
|
||
jieguo = _trim(chuli_jieguo)
|
||
|
||
if action in ('done', 'finish', 'resolve'):
|
||
if not jieguo:
|
||
raise GongdanError('请填写处理结果', 'need_jieguo')
|
||
gd.zhuangtai = STATUS_DONE
|
||
gd.chuli_jieguo = jieguo
|
||
gd.chuliren_id = handler_uid
|
||
gd.chuliren_name = (handler_name or '')[:64]
|
||
gd.chuli_time = timezone.now()
|
||
elif action in ('close',):
|
||
gd.zhuangtai = STATUS_CLOSED
|
||
gd.chuli_jieguo = jieguo or gd.chuli_jieguo or '已关闭'
|
||
gd.chuliren_id = handler_uid
|
||
gd.chuliren_name = (handler_name or '')[:64]
|
||
gd.chuli_time = timezone.now()
|
||
elif action in ('processing', 'reopen'):
|
||
gd.zhuangtai = STATUS_PROCESSING
|
||
elif action in ('dissatisfied',):
|
||
gd.zhuangtai = STATUS_DISSATISFIED
|
||
else:
|
||
raise GongdanError('未知处理动作', 'bad_action')
|
||
|
||
gd.save()
|
||
logger.info('工单处理 %s action=%s by=%s', gongdan_id, action, handler_uid)
|
||
return gd
|
||
|
||
|
||
@transaction.atomic
|
||
def user_mark_dissatisfied(*, gongdan_id, yonghuid):
|
||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||
if not gd:
|
||
raise GongdanError('工单不存在', 'not_found')
|
||
if gd.shenqingren_id != yonghuid:
|
||
raise GongdanError('无权操作此工单', 'forbidden')
|
||
if gd.zhuangtai != STATUS_DONE:
|
||
raise GongdanError('仅已处理的工单可标记不满意', 'bad_status')
|
||
gd.zhuangtai = STATUS_DISSATISFIED
|
||
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||
return gd
|