feat: 投诉工单正规化(介入/牵连提现拦截,仅俱乐部开关)
支持被投诉对象、平台介入、协商中状态与组长投诉;提现硬拦含管事向上牵连组长,默认仅 lxs 开启。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
"""工单业务:创建 / 升级 / 客服处理(幂等与校验)。"""
|
||||
"""工单业务:创建 / 升级 / 客服处理 / 提现拦截 / 列表范围。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from gongdan.constants import (
|
||||
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD,
|
||||
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD, TYPE_ZUZHANG,
|
||||
TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED,
|
||||
STATUS_LABELS, MAX_IMAGES, MAX_SHUOMING_LEN, MAX_SHENGJI_TIMES, OSS_PREFIX,
|
||||
STATUS_NEGOTIATING, STATUS_LABELS, OPEN_STATUSES, MAX_IMAGES, MAX_SHUOMING_LEN,
|
||||
MAX_SHENGJI_TIMES, TARGET_ROLE_GUANSHI, TARGET_ROLE_ZUZHANG, TARGET_ROLE_LABELS,
|
||||
TARGET_ROLE_DASHOU, TARGET_ROLE_PLATFORM,
|
||||
)
|
||||
from gongdan.models import Gongdan, GongdanImage, GongdanShengji
|
||||
from gongdan.models import Gongdan, GongdanImage, GongdanShengji, ClubGongdanConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,8 +38,84 @@ def _trim(text, max_len=MAX_SHUOMING_LEN):
|
||||
return (text or '').strip()[:max_len]
|
||||
|
||||
|
||||
def is_complaint_withdraw_block_enabled(club_id: str) -> bool:
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return False
|
||||
row = ClubGongdanConfig.query.filter(club_id=cid).first()
|
||||
return bool(row and row.complaint_withdraw_block)
|
||||
|
||||
|
||||
def ensure_club_gongdan_config(club_id: str, *, withdraw_block: bool = False) -> ClubGongdanConfig:
|
||||
cid = (club_id or '').strip()
|
||||
row = ClubGongdanConfig.query.filter(club_id=cid).first()
|
||||
if not row:
|
||||
row = ClubGongdanConfig(club_id=cid, complaint_withdraw_block=bool(withdraw_block))
|
||||
row.save()
|
||||
return row
|
||||
|
||||
|
||||
def _user_nicheng(uid: str) -> str:
|
||||
"""展示昵称:打手 nicheng → 老板 nickname → 用户{UID}。不把 UID 当昵称展示。"""
|
||||
uid = (uid or '').strip()
|
||||
if not uid:
|
||||
return ''
|
||||
try:
|
||||
from users.models import UserDashou, UserBoss
|
||||
from users.business_models import User
|
||||
|
||||
ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first()
|
||||
if ds and (ds.nicheng or '').strip():
|
||||
return (ds.nicheng or '').strip()[:32]
|
||||
boss = UserBoss.query.filter(user__UserUID=uid).select_related('user').first()
|
||||
if boss and (boss.nickname or '').strip():
|
||||
return (boss.nickname or '').strip()[:32]
|
||||
u = User.query.filter(UserUID=uid).first() or User.objects.filter(UserUID=uid).first()
|
||||
if u and (getattr(u, 'Phone', None) or '').strip():
|
||||
phone = str(u.Phone).strip()
|
||||
if len(phone) >= 4:
|
||||
return f'用户*{phone[-4:]}'
|
||||
return '用户'
|
||||
except Exception:
|
||||
return '用户'
|
||||
|
||||
|
||||
def _resolve_target(leixing, guanshi_id=None, target_role=None, target_user_id=None):
|
||||
"""归一化被投诉对象;兼容旧 guanshi_id。管事/组长投诉必须选人。"""
|
||||
role = (target_role or '').strip().lower()
|
||||
tid = (target_user_id or '').strip()
|
||||
gid = (guanshi_id or '').strip() or None
|
||||
|
||||
if leixing == TYPE_GUANSHI:
|
||||
role = TARGET_ROLE_GUANSHI
|
||||
tid = tid or (gid or '')
|
||||
if not tid:
|
||||
raise GongdanError('请选择被投诉的管事', 'need_guanshi')
|
||||
from users.models import UserGuanshi
|
||||
if not UserGuanshi.query.filter(user__UserUID=tid).exists():
|
||||
if not UserGuanshi.objects.filter(user__UserUID=tid).exists():
|
||||
raise GongdanError('管事不存在,请重新选择', 'guanshi_not_found')
|
||||
return role, tid, tid
|
||||
|
||||
if leixing == TYPE_ZUZHANG:
|
||||
role = TARGET_ROLE_ZUZHANG
|
||||
if not tid:
|
||||
raise GongdanError('请选择被投诉的组长', 'need_zuzhang')
|
||||
from users.models import UserZuzhang
|
||||
if not UserZuzhang.query.filter(user__UserUID=tid).exists():
|
||||
if not UserZuzhang.objects.filter(user__UserUID=tid).exists():
|
||||
raise GongdanError('组长不存在,请重新选择', 'zuzhang_not_found')
|
||||
return role, tid, None
|
||||
|
||||
if role and tid:
|
||||
if role not in TARGET_ROLE_LABELS and role not in ('order', 'recharge', 'penalty'):
|
||||
raise GongdanError('无效的被投诉角色', 'bad_target_role')
|
||||
return role, tid, gid if role == TARGET_ROLE_GUANSHI else None
|
||||
|
||||
return role or '', tid or '', gid
|
||||
|
||||
|
||||
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
|
||||
@@ -72,20 +150,46 @@ def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None,
|
||||
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:
|
||||
if leixing in (TYPE_FRAUD, TYPE_ZUZHANG):
|
||||
return None, None, None, None
|
||||
|
||||
raise GongdanError('不支持的投诉类型', 'bad_type')
|
||||
|
||||
|
||||
def serialize_gongdan(gd, include_images=True, include_shengji=False):
|
||||
def _effective_target_uid(gd) -> str:
|
||||
return (gd.target_user_id or gd.guanshi_id or '').strip()
|
||||
|
||||
|
||||
def _effective_target_role(gd) -> str:
|
||||
role = (gd.target_role or '').strip()
|
||||
if role:
|
||||
return role
|
||||
if gd.leixing == TYPE_GUANSHI or gd.guanshi_id:
|
||||
return TARGET_ROLE_GUANSHI
|
||||
if gd.leixing == TYPE_ZUZHANG:
|
||||
return TARGET_ROLE_ZUZHANG
|
||||
return ''
|
||||
|
||||
|
||||
def serialize_gongdan(gd, include_images=True, include_shengji=False, *, viewer_uid=None, for_admin=False):
|
||||
"""
|
||||
序列化。默认脱敏:不把申请人/被投诉人/客服 UID 作为展示字段下发。
|
||||
for_admin=True 时仍不主动展示 UID 列,只多给内部审计需要的标记。
|
||||
contact_peer_uid 仅当 viewer 是当事方时返回,供 IM 打开(前端勿渲染)。
|
||||
"""
|
||||
t_role = _effective_target_role(gd)
|
||||
t_uid = _effective_target_uid(gd)
|
||||
relation = ''
|
||||
if viewer_uid:
|
||||
if gd.shenqingren_id == viewer_uid:
|
||||
relation = 'created'
|
||||
elif t_uid and t_uid == viewer_uid:
|
||||
relation = 'received'
|
||||
else:
|
||||
relation = 'linked' if _viewer_is_linked_zuzhang(viewer_uid, gd) else ''
|
||||
|
||||
data = {
|
||||
'gongdan_id': gd.gongdan_id,
|
||||
'leixing': gd.leixing,
|
||||
@@ -93,20 +197,58 @@ def serialize_gongdan(gd, include_images=True, include_shengji=False):
|
||||
'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 '',
|
||||
# 对外:只显示「平台已介入」,不返回客服 UID
|
||||
'platform_intervened': bool(gd.platform_intervened),
|
||||
'intervene_at': gd.intervene_at.isoformat() if gd.intervene_at else '',
|
||||
'intervene_reason': gd.intervene_reason 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 '',
|
||||
'target_role': t_role,
|
||||
'target_role_label': TARGET_ROLE_LABELS.get(t_role, t_role or ''),
|
||||
'target_nicheng': _user_nicheng(t_uid),
|
||||
'complainant_nicheng': _user_nicheng(gd.shenqingren_id),
|
||||
'relation': relation,
|
||||
'is_open': gd.zhuangtai in OPEN_STATUSES,
|
||||
'can_contact': False,
|
||||
'contact_peer_uid': '',
|
||||
'contact_peer_nicheng': '',
|
||||
'can_contact_complainant': False,
|
||||
'contact_complainant_uid': '',
|
||||
'contact_complainant_nicheng': '',
|
||||
}
|
||||
if viewer_uid and relation in ('created', 'received'):
|
||||
peer = t_uid if relation == 'created' else gd.shenqingren_id
|
||||
if peer and peer != viewer_uid:
|
||||
data['can_contact'] = True
|
||||
data['contact_peer_uid'] = peer
|
||||
data['contact_peer_nicheng'] = _user_nicheng(peer)
|
||||
elif viewer_uid and relation == 'linked':
|
||||
# 牵连组长:默认可联系主被投诉管事;另可联系投诉人
|
||||
if t_uid and t_uid != viewer_uid:
|
||||
data['can_contact'] = True
|
||||
data['contact_peer_uid'] = t_uid
|
||||
data['contact_peer_nicheng'] = _user_nicheng(t_uid)
|
||||
if gd.shenqingren_id and gd.shenqingren_id != viewer_uid:
|
||||
data['can_contact_complainant'] = True
|
||||
data['contact_complainant_uid'] = gd.shenqingren_id
|
||||
data['contact_complainant_nicheng'] = _user_nicheng(gd.shenqingren_id)
|
||||
if for_admin:
|
||||
# 客服需要 UID 做内部处理;用户端绝不下发
|
||||
data['shenqingren_id'] = gd.shenqingren_id or ''
|
||||
data['target_user_id'] = t_uid or ''
|
||||
data['guanshi_id'] = gd.guanshi_id or ''
|
||||
data['chuliren_id'] = gd.chuliren_id or ''
|
||||
data['chuliren_name'] = gd.chuliren_name or ('平台客服' if gd.platform_intervened or gd.chuliren_id else '')
|
||||
else:
|
||||
data['chuliren_name'] = '平台客服' if (gd.platform_intervened or gd.chuliren_name) else ''
|
||||
|
||||
if include_images:
|
||||
data['images'] = [
|
||||
{'url': img.url, 'paixu': img.paixu}
|
||||
@@ -124,6 +266,209 @@ def serialize_gongdan(gd, include_images=True, include_shengji=False):
|
||||
return data
|
||||
|
||||
|
||||
def _viewer_is_linked_zuzhang(viewer_uid: str, gd) -> bool:
|
||||
"""下级管事被投诉时,其上级组长视为牵连方。"""
|
||||
t_uid = _effective_target_uid(gd)
|
||||
t_role = _effective_target_role(gd)
|
||||
if not t_uid or t_role != TARGET_ROLE_GUANSHI:
|
||||
return False
|
||||
try:
|
||||
from users.models import UserGuanshi
|
||||
gs = UserGuanshi.objects.filter(user__UserUID=t_uid).first()
|
||||
if not gs:
|
||||
return False
|
||||
return (gs.yaoqingren or '').strip() == str(viewer_uid)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _sub_guanshi_uids(zuzhang_uid: str):
|
||||
from users.models import UserGuanshi
|
||||
rows = UserGuanshi.objects.filter(yaoqingren=str(zuzhang_uid)).select_related('user')
|
||||
out = []
|
||||
for r in rows:
|
||||
uid = getattr(getattr(r, 'user', None), 'UserUID', None)
|
||||
if uid:
|
||||
out.append(str(uid))
|
||||
return out
|
||||
|
||||
|
||||
def open_complaint_q_for_target_uids(uids):
|
||||
"""未结清且指向这些 UID 的工单条件。"""
|
||||
uids = [str(u) for u in (uids or []) if u]
|
||||
if not uids:
|
||||
return Q(pk__in=[])
|
||||
return (
|
||||
Q(zhuangtai__in=OPEN_STATUSES)
|
||||
& (Q(target_user_id__in=uids) | Q(guanshi_id__in=uids))
|
||||
)
|
||||
|
||||
|
||||
def check_complaint_blocks_withdraw(user_main, club_id=None):
|
||||
"""
|
||||
提现拦截。
|
||||
返回 (ok, msg, extra)
|
||||
extra: reason=received|linked, tab, count
|
||||
"""
|
||||
try:
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
cid = (club_id or get_user_club_id(user_main) or '').strip()
|
||||
except Exception:
|
||||
cid = (club_id or '').strip()
|
||||
|
||||
if not is_complaint_withdraw_block_enabled(cid):
|
||||
return True, '', {}
|
||||
|
||||
uid = str(user_main.UserUID)
|
||||
base = Gongdan.query.filter(club_id=cid) if cid else Gongdan.query.all()
|
||||
|
||||
# 1) 自己是主被投诉人
|
||||
qs_recv = base.filter(open_complaint_q_for_target_uids([uid]))
|
||||
cnt = qs_recv.count()
|
||||
if cnt:
|
||||
return False, '你有未处理完的投诉,请处理后再提现', {
|
||||
'reason': 'received',
|
||||
'tab': 'received',
|
||||
'count': cnt,
|
||||
'code': 'COMPLAINT_BLOCK_WITHDRAW',
|
||||
}
|
||||
|
||||
# 2) 自己是组长,下级管事被投诉 → 牵连
|
||||
try:
|
||||
from users.models import UserZuzhang
|
||||
is_zz = UserZuzhang.query.filter(user=user_main).exists()
|
||||
except Exception:
|
||||
is_zz = False
|
||||
if is_zz:
|
||||
subs = _sub_guanshi_uids(uid)
|
||||
if subs:
|
||||
qs_link = base.filter(open_complaint_q_for_target_uids(subs)).filter(
|
||||
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
|
||||
)
|
||||
cnt2 = qs_link.count()
|
||||
if cnt2:
|
||||
return False, '下级管事有未处理投诉,暂不可提现', {
|
||||
'reason': 'linked',
|
||||
'tab': 'linked',
|
||||
'count': cnt2,
|
||||
'code': 'COMPLAINT_BLOCK_WITHDRAW',
|
||||
}
|
||||
|
||||
return True, '', {}
|
||||
|
||||
|
||||
def search_complaint_targets(*, role, keyword='', club_id=None, page=1, page_size=20):
|
||||
"""按昵称/UID 搜管事或组长,供投诉选人(返回 uid 仅供提交,前端展示昵称)。"""
|
||||
role = (role or '').strip().lower()
|
||||
keyword = (keyword or '').strip()
|
||||
page = max(int(page or 1), 1)
|
||||
page_size = min(max(int(page_size or 20), 1), 50)
|
||||
cid = (club_id or '').strip()
|
||||
|
||||
from users.models import UserGuanshi, UserZuzhang, UserBoss
|
||||
|
||||
rows = []
|
||||
if role == TARGET_ROLE_GUANSHI:
|
||||
qs = UserGuanshi.query.filter(zhuangtai=1).select_related('user')
|
||||
if cid:
|
||||
qs = qs.filter(user__ClubID=cid)
|
||||
if keyword:
|
||||
boss_uids = list(
|
||||
UserBoss.query.filter(nickname__icontains=keyword).values_list('user__UserUID', flat=True)[:80]
|
||||
)
|
||||
qs = qs.filter(Q(user__UserUID__icontains=keyword) | Q(user__UserUID__in=boss_uids))
|
||||
qs = qs.order_by('-id')
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
for g in qs[start:start + page_size]:
|
||||
uid = getattr(getattr(g, 'user', None), 'UserUID', None)
|
||||
if not uid:
|
||||
continue
|
||||
rows.append({
|
||||
'user_id': str(uid),
|
||||
'nicheng': _user_nicheng(str(uid)),
|
||||
'role': TARGET_ROLE_GUANSHI,
|
||||
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_GUANSHI],
|
||||
})
|
||||
elif role == TARGET_ROLE_ZUZHANG:
|
||||
qs = UserZuzhang.query.filter(zhuangtai=1).select_related('user')
|
||||
if cid:
|
||||
qs = qs.filter(user__ClubID=cid)
|
||||
if keyword:
|
||||
boss_uids = list(
|
||||
UserBoss.query.filter(nickname__icontains=keyword).values_list('user__UserUID', flat=True)[:80]
|
||||
)
|
||||
qs = qs.filter(Q(user__UserUID__icontains=keyword) | Q(user__UserUID__in=boss_uids))
|
||||
qs = qs.order_by('-id')
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
for z in qs[start:start + page_size]:
|
||||
uid = getattr(getattr(z, 'user', None), 'UserUID', None)
|
||||
if not uid:
|
||||
continue
|
||||
rows.append({
|
||||
'user_id': str(uid),
|
||||
'nicheng': _user_nicheng(str(uid)),
|
||||
'role': TARGET_ROLE_ZUZHANG,
|
||||
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_ZUZHANG],
|
||||
})
|
||||
else:
|
||||
raise GongdanError('role 须为 guanshi 或 zuzhang', 'bad_role')
|
||||
|
||||
return {'list': rows, 'total': total, 'page': page, 'page_size': page_size, 'role': role}
|
||||
|
||||
|
||||
def list_gongdan_for_user(yonghuid, *, scope='created', club_id=None, page=1, page_size=20):
|
||||
"""
|
||||
scope:
|
||||
created — 我发起的
|
||||
received — 我被投诉的
|
||||
linked — 下级牵连(组长)
|
||||
"""
|
||||
scope = (scope or 'created').strip().lower()
|
||||
page = max(int(page or 1), 1)
|
||||
page_size = min(max(int(page_size or 20), 1), 50)
|
||||
uid = str(yonghuid)
|
||||
qs = Gongdan.query.all()
|
||||
if club_id:
|
||||
qs = qs.filter(club_id=club_id)
|
||||
|
||||
if scope == 'received':
|
||||
qs = qs.filter(Q(target_user_id=uid) | Q(guanshi_id=uid)).order_by('-CreateTime')
|
||||
elif scope == 'linked':
|
||||
subs = _sub_guanshi_uids(uid)
|
||||
if not subs:
|
||||
qs = qs.none()
|
||||
else:
|
||||
qs = qs.filter(Q(target_user_id__in=subs) | Q(guanshi_id__in=subs)).filter(
|
||||
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
|
||||
).order_by('-CreateTime')
|
||||
else:
|
||||
qs = qs.filter(shenqingren_id=uid).order_by('-CreateTime')
|
||||
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
rows = list(qs[start:start + page_size])
|
||||
return {
|
||||
'list': [serialize_gongdan(g, include_images=False, viewer_uid=uid) for g in rows],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'scope': scope,
|
||||
}
|
||||
|
||||
|
||||
def user_can_view_gongdan(gd, yonghuid) -> bool:
|
||||
uid = str(yonghuid)
|
||||
if gd.shenqingren_id == uid:
|
||||
return True
|
||||
if _effective_target_uid(gd) == uid:
|
||||
return True
|
||||
if _viewer_is_linked_zuzhang(uid, gd):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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:
|
||||
@@ -149,6 +494,8 @@ def create_gongdan(
|
||||
chongzhi_id=None,
|
||||
fadan_id=None,
|
||||
guanshi_id=None,
|
||||
target_role=None,
|
||||
target_user_id=None,
|
||||
image_urls=None,
|
||||
):
|
||||
try:
|
||||
@@ -163,7 +510,10 @@ def create_gongdan(
|
||||
raise GongdanError('请填写投诉说明', 'need_shuoming')
|
||||
|
||||
order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs(
|
||||
leixing, order_id, chongzhi_id, fadan_id, guanshi_id,
|
||||
leixing, order_id, chongzhi_id, fadan_id, guanshi_id or target_user_id,
|
||||
)
|
||||
t_role, t_uid, guanshi_id = _resolve_target(
|
||||
leixing, guanshi_id=guanshi_id, target_role=target_role, target_user_id=target_user_id or guanshi_id,
|
||||
)
|
||||
|
||||
gid = _gen_gongdan_id()
|
||||
@@ -179,6 +529,8 @@ def create_gongdan(
|
||||
chongzhi_id=chongzhi_id,
|
||||
fadan_id=fadan_id,
|
||||
guanshi_id=guanshi_id,
|
||||
target_role=t_role or '',
|
||||
target_user_id=t_uid or '',
|
||||
shuoming=text,
|
||||
shenqingren_id=yonghuid,
|
||||
club_id=club_id or None,
|
||||
@@ -186,13 +538,12 @@ def create_gongdan(
|
||||
shengji_cishu=0,
|
||||
)
|
||||
_replace_images(gd, image_urls)
|
||||
logger.info('创建工单 %s type=%s user=%s', gid, leixing, yonghuid)
|
||||
logger.info('创建工单 %s type=%s user=%s target=%s/%s', gid, leixing, yonghuid, t_role, t_uid)
|
||||
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')
|
||||
@@ -219,13 +570,11 @@ def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None):
|
||||
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
|
||||
|
||||
|
||||
@@ -237,13 +586,16 @@ def handle_gongdan(
|
||||
handler_name,
|
||||
action,
|
||||
chuli_jieguo='',
|
||||
intervene_reason='',
|
||||
):
|
||||
"""
|
||||
客服处理:
|
||||
action=done → 已处理
|
||||
action=dissatisfied → 标记不满意(一般由用户侧触发,客服 sparingly)
|
||||
action=close → 关闭
|
||||
action=processing → 重新标处理中
|
||||
action:
|
||||
done/finish/resolve → 已处理
|
||||
close → 关闭
|
||||
processing/reopen → 处理中
|
||||
dissatisfied → 不满意
|
||||
intervene → 标记平台介入(不改结案状态)
|
||||
negotiate → 协商中(客服 sparingly)
|
||||
"""
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
@@ -252,22 +604,40 @@ def handle_gongdan(
|
||||
action = (action or '').strip().lower()
|
||||
jieguo = _trim(chuli_jieguo)
|
||||
|
||||
if action in ('intervene', 'platform_intervene'):
|
||||
gd.platform_intervened = True
|
||||
gd.intervene_at = timezone.now()
|
||||
gd.intervene_by = (handler_uid or '')[:32]
|
||||
gd.intervene_reason = (intervene_reason or 'kefu')[:32]
|
||||
if gd.zhuangtai not in OPEN_STATUSES:
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
gd.save()
|
||||
logger.info('工单平台介入 %s by=%s', gongdan_id, handler_uid)
|
||||
return gd
|
||||
|
||||
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.chuliren_name = (handler_name or '平台客服')[:64]
|
||||
gd.chuli_time = timezone.now()
|
||||
if not gd.platform_intervened:
|
||||
gd.platform_intervened = True
|
||||
gd.intervene_at = gd.intervene_at or timezone.now()
|
||||
gd.intervene_by = (handler_uid or '')[:32]
|
||||
gd.intervene_reason = gd.intervene_reason or 'kefu_done'
|
||||
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.chuliren_name = (handler_name or '平台客服')[:64]
|
||||
gd.chuli_time = timezone.now()
|
||||
elif action in ('processing', 'reopen'):
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
elif action in ('negotiate',):
|
||||
gd.zhuangtai = STATUS_NEGOTIATING
|
||||
elif action in ('dissatisfied',):
|
||||
gd.zhuangtai = STATUS_DISSATISFIED
|
||||
else:
|
||||
@@ -290,3 +660,55 @@ def user_mark_dissatisfied(*, gongdan_id, yonghuid):
|
||||
gd.zhuangtai = STATUS_DISSATISFIED
|
||||
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_resolve_gongdan(*, 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 not in OPEN_STATUSES:
|
||||
raise GongdanError('当前状态无需确认解决', 'bad_status')
|
||||
gd.zhuangtai = STATUS_DONE
|
||||
gd.chuli_jieguo = gd.chuli_jieguo or '双方协商已解决'
|
||||
gd.chuli_time = timezone.now()
|
||||
gd.chuliren_name = gd.chuliren_name or '双方协商'
|
||||
gd.save()
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_start_negotiate(*, gongdan_id, yonghuid):
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
if not user_can_view_gongdan(gd, yonghuid):
|
||||
raise GongdanError('无权操作此工单', 'forbidden')
|
||||
if gd.zhuangtai not in (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING):
|
||||
raise GongdanError('当前状态不可转协商', 'bad_status')
|
||||
gd.zhuangtai = STATUS_NEGOTIATING
|
||||
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_apply_intervene(*, 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 and _effective_target_uid(gd) != yonghuid:
|
||||
raise GongdanError('无权申请介入', 'forbidden')
|
||||
if gd.zhuangtai not in OPEN_STATUSES:
|
||||
raise GongdanError('当前状态不可申请介入', 'bad_status')
|
||||
gd.platform_intervened = True
|
||||
if not gd.intervene_at:
|
||||
gd.intervene_at = timezone.now()
|
||||
gd.intervene_reason = gd.intervene_reason or 'user_apply'
|
||||
if gd.zhuangtai == STATUS_NEGOTIATING:
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
gd.save()
|
||||
return gd
|
||||
|
||||
Reference in New Issue
Block a user