"""工单业务:创建 / 升级 / 客服处理(幂等与校验)。""" 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