From e2015164eacc2f457e72e6eb718033632d72990f Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 7 Aug 2026 17:17:29 +0800 Subject: [PATCH] =?UTF-8?q?"refactor(gerp):=20=E8=8F=9C=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=20API=20=E5=A7=94=E6=89=98=20gvsdsdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit menu.py 四视图与 WorkOrderViewSet 自定义 action、WorkOrderStatsView 改经 MenuManager/WorkOrderManager,移除视图层裸 ORM 查询。" --- gvsds/apps/gerp/views/dashboard.py | 58 +-- gvsds/apps/gerp/views/form_api.py | 17 +- gvsds/apps/gerp/views/menu.py | 189 +--------- gvsds/apps/gerp/views/message.py | 214 ++++------- gvsds/apps/gerp/views/settings_api.py | 21 +- gvsds/apps/gerp/views/user.py | 62 +-- gvsds/apps/gerp/views/viewsets.py | 30 +- gvsdsdk/__init__.py | 3 + gvsdsdk/auth/__init__.py | 2 +- gvsdsdk/auth/managers.py | 518 +++++++++++++++++++++++++- gvsdsdk/models.py | 150 ++++++++ gvsdsdk/order/manager.py | 39 ++ 12 files changed, 856 insertions(+), 447 deletions(-) diff --git a/gvsds/apps/gerp/views/dashboard.py b/gvsds/apps/gerp/views/dashboard.py index 90cca77e..cc838083 100644 --- a/gvsds/apps/gerp/views/dashboard.py +++ b/gvsds/apps/gerp/views/dashboard.py @@ -6,18 +6,18 @@ GET /API/Dashboard/Stats - dept_manager: 本部门统计 - workgroup_lead: 本工作组统计 - member: 个人统计 + +统计逻辑委托 gvsdsdk:用户数经 RoleManager/AuthManager,订单数经 OrderManager。 """ import logging -import datetime from rest_framework.response import Response from . import ELTAuthenticatedAPIView -from gvsds.apps.gerp.views.form_api import _is_admin, _get_current_context +from gvsds.apps.gerp.views.form_api import _get_current_context from gvsds.apps.gerp.role_scope import GetUserScope, _get_scope_members -from gvsds.apps.gerp.models import UserRole -from gvsds.apps.core.models import User -from gvsdsdk.order.models import Order +from gvsdsdk.auth.managers import AuthManager, RoleManager +from gvsdsdk.order.manager import OrderManager logger = logging.getLogger(__name__) @@ -29,11 +29,6 @@ class DashboardStatsView(ELTAuthenticatedAPIView): _, current_company_uuid, current_tenant_uuid = _get_current_context(request) scope = GetUserScope(request) - # 时间范围 - now = datetime.datetime.utcnow() - today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - week_start = today_start - datetime.timedelta(days=today_start.weekday()) - stats = { 'Scope': scope.Type, 'TotalUsers': 0, @@ -48,17 +43,11 @@ class DashboardStatsView(ELTAuthenticatedAPIView): try: # 用户统计 if scope.Type == 'admin': - # 管理员:全公司用户 - user_qs = User.objects.all() + # 管理员:全公司用户(无公司上下文时统计全量) + company_user_uuids = None if current_company_uuid: - # 通过 UserRole 关联查找本公司用户 - company_user_uuids = set( - UserRole.objects.filter( - CompanyUUID=current_company_uuid - ).values_list('UserUUID', flat=True) - ) - user_qs = user_qs.filter(UserUUID__in=company_user_uuids) if company_user_uuids else user_qs.none() - stats['TotalUsers'] = user_qs.count() + company_user_uuids = RoleManager.GetCompanyUserUUIDs(current_company_uuid) + stats['TotalUsers'] = AuthManager.CountUsers(company_user_uuids) elif scope.Type in ('dept_manager', 'workgroup_lead'): member_uuids = _get_scope_members(scope) if member_uuids: @@ -67,28 +56,19 @@ class DashboardStatsView(ELTAuthenticatedAPIView): stats['TotalUsers'] = 1 # 订单统计 - order_qs = Order.objects.all() - if current_tenant_uuid: - order_qs = order_qs.filter(TenantUUID=current_tenant_uuid) - - if scope.Type == 'admin': - if current_company_uuid: - order_qs = order_qs.filter(CompanyUUID=current_company_uuid) - elif scope.Type == 'member': - order_qs = order_qs.filter(BuyerUserUUID=scope.UserUUID) + buyer_uuids = None + if scope.Type == 'member': + buyer_uuids = [scope.UserUUID] elif scope.Type in ('dept_manager', 'workgroup_lead'): + # 无成员上下文视为空集合(统计全 0),与 None 区分 member_uuids = _get_scope_members(scope) - if member_uuids: - order_qs = order_qs.filter(BuyerUserUUID__in=member_uuids) - else: - order_qs = order_qs.none() + buyer_uuids = member_uuids if member_uuids is not None else [] - stats['TotalOrders'] = order_qs.count() - stats['TodayOrders'] = order_qs.filter(CreateTime__gte=today_start).count() - stats['WeekOrders'] = order_qs.filter(CreateTime__gte=week_start).count() - stats['PendingOrders'] = order_qs.filter(OrderStatus=Order.ORD_PENDING).count() - stats['PaidOrders'] = order_qs.filter(OrderStatus__in=[Order.ORD_PAID, Order.ORD_PROCESSING, Order.ORD_COMPLETED]).count() - stats['CompletedOrders'] = order_qs.filter(OrderStatus=Order.ORD_COMPLETED).count() + stats.update(OrderManager.GetStats( + tenant_uuid=current_tenant_uuid, + company_uuid=current_company_uuid if scope.Type == 'admin' else None, + buyer_user_uuids=buyer_uuids, + )) except Exception: logger.warning('[DashboardStats] 统计计算失败', exc_info=True) diff --git a/gvsds/apps/gerp/views/form_api.py b/gvsds/apps/gerp/views/form_api.py index 672cd182..48040e1c 100644 --- a/gvsds/apps/gerp/views/form_api.py +++ b/gvsds/apps/gerp/views/form_api.py @@ -464,22 +464,17 @@ class WorkOrderStatsView(ELTAuthenticatedAPIView): """ def get(self, request): - from django.db.models import Count - from gvsds.apps.gerp.models import WorkOrder + from gvsdsdk.auth.managers import WorkOrderManager from gvsds.apps.gerp.form_service import FormService - qs = WorkOrder.objects.all() - - def _group(field_name): - return {row[field_name]: row['c'] for row in qs.values(field_name).annotate(c=Count('pk'))} - + stats = WorkOrderManager.GetStats() return Response({ 'code': 0, 'data': { - 'total': qs.count(), - 'byStatus': _group('Status'), - 'byPriority': _group('Priority'), - 'byCategory': _group('Category'), + 'total': stats['total'], + 'byStatus': stats['byStatus'], + 'byPriority': stats['byPriority'], + 'byCategory': stats['byCategory'], # 附状态/优先级中文标签,前端可直接渲染 'statusOptions': FormService.WORKORDER_STATUS_OPTIONS['Status'], 'priorityOptions': FormService.WORKORDER_PRIORITY_OPTIONS['Priority'], diff --git a/gvsds/apps/gerp/views/menu.py b/gvsds/apps/gerp/views/menu.py index 6fb716bb..f18ec7a8 100644 --- a/gvsds/apps/gerp/views/menu.py +++ b/gvsds/apps/gerp/views/menu.py @@ -15,9 +15,8 @@ import uuid as _uuid import logging -from django.db.models import Q from rest_framework.response import Response -from gvsds.apps.gerp.models import Menu, Company +from gvsdsdk.auth.managers import MenuManager from . import ELTAuthenticatedAPIView logger = logging.getLogger(__name__) @@ -79,43 +78,13 @@ class MenuListCompatView(ELTAuthenticatedAPIView): # 管理员特权:仅跳过权限过滤,公司过滤对所有用户生效(防止跨公司私有菜单泄漏) is_admin = _IsAdminUser(user, role_uuid_bytes) - # 公司过滤(对所有用户生效,包括管理员) - qs = Menu.objects.filter(MenuStatus=1) - if current_company: - # 公有菜单 + 当前公司私有菜单 - qs = qs.filter(Q(CompanyUUID__isnull=True) | Q(CompanyUUID=current_company)) - else: - # 无公司上下文:仅公有菜单 - qs = qs.filter(CompanyUUID__isnull=True) - - menus = qs.select_related('ParentMenuUUID', 'CompanyUUID').order_by('MenuSort', 'MenuName') - - # 构建扁平映射 + # 公司过滤(对所有用户生效,包括管理员)——经 MenuManager 委托查询 menu_map = {} - for menu in menus: - menu_uuid_str = str(menu.MenuUUID) if menu.MenuUUID else '' + for item in MenuManager.ListVisible(current_company): + menu_uuid_str = item['MenuUUID'] if not menu_uuid_str: continue - parent_uuid_str = '' - if menu.ParentMenuUUID and menu.ParentMenuUUID.MenuUUID: - parent_uuid_str = str(menu.ParentMenuUUID.MenuUUID) - company_uuid = None - if menu.CompanyUUID: - company_uuid = str(menu.CompanyUUID.CompanyUUID) - menu_map[menu_uuid_str] = { - 'Id': menu.MenuName, - 'Title': menu.MenuTitle, - 'Icon': menu.MenuIcon or '', - 'Page': menu.MenuLink or '', - 'ParentUUID': parent_uuid_str, - 'MenuUUID': menu_uuid_str, - 'CompanyUUID': company_uuid, - 'Sort': menu.MenuSort or 0, - 'Type': menu.MenuType or 0, - 'Children': [], - 'PermLevel': menu.PermissionLevel or 0, - 'PermAttr': menu.PermissionAttr or '', - } + menu_map[menu_uuid_str] = item # 祖先链校验:任何祖先不属于当前公司 → 整个分支移除 # 对所有用户生效(包括管理员),防止公有子节点挂在私有父节点下导致结构泄漏 @@ -248,31 +217,9 @@ class MenuAdminListView(ELTAuthenticatedAPIView): return Response({'code': 403, 'msg': '无权限', 'data': None}) company_uuid_str = (request.query_params.get('CompanyUUID') or '').strip() - qs = Menu.objects.all() - if company_uuid_str: - company_uuid_bytes = _ParseUUID(company_uuid_str) - if company_uuid_bytes: - qs = qs.filter(Q(CompanyUUID__isnull=True) | Q(CompanyUUID=company_uuid_bytes)) - else: - qs = qs.filter(CompanyUUID__isnull=True) - - menus = qs.select_related('ParentMenuUUID', 'CompanyUUID').order_by('MenuSort', 'MenuName') - records = [] - for menu in menus: - records.append({ - 'MenuUUID': str(menu.MenuUUID) if menu.MenuUUID else '', - 'ParentMenuUUID': str(menu.ParentMenuUUID.MenuUUID) if menu.ParentMenuUUID and menu.ParentMenuUUID.MenuUUID else '', - 'CompanyUUID': str(menu.CompanyUUID.CompanyUUID) if menu.CompanyUUID else '', - 'MenuName': menu.MenuName or '', - 'MenuTitle': menu.MenuTitle or '', - 'MenuIcon': menu.MenuIcon or '', - 'MenuLink': menu.MenuLink or '', - 'MenuSort': menu.MenuSort if menu.MenuSort is not None else 0, - 'MenuType': menu.MenuType if menu.MenuType is not None else 0, - 'MenuStatus': menu.MenuStatus if menu.MenuStatus is not None else 0, - 'PermissionLevel': menu.PermissionLevel if menu.PermissionLevel is not None else 0, - 'PermissionAttr': menu.PermissionAttr or '', - }) + company_provided = bool(company_uuid_str) + company_uuid_bytes = _ParseUUID(company_uuid_str) if company_provided else None + records = MenuManager.AdminList(company_uuid_bytes, company_provided) return Response({'code': 0, 'data': records, 'total': len(records)}) @@ -292,100 +239,11 @@ class MenuSaveView(ELTAuthenticatedAPIView): if user is None: return Response({'code': 403, 'msg': '无权限', 'data': None}) - def _get(key, default=''): - v = request.data.get(key, default) - return '' if v is None else v - - menu_uuid_str = _get('MenuUUID', '').strip() - company_uuid_str = _get('CompanyUUID', '').strip() - parent_uuid_str = _get('ParentMenuUUID', '').strip() - - # 解析公司(空则不关联公司,即公有菜单) - company = None - if company_uuid_str: - company_uuid_bytes = _ParseUUID(company_uuid_str) - if not company_uuid_bytes: - return Response({'code': 1, 'msg': 'CompanyUUID 格式无效', 'data': None}) - try: - company = Company.objects.get(CompanyUUID=company_uuid_bytes) - except Company.DoesNotExist: - return Response({'code': 1, 'msg': '公司不存在', 'data': None}) - - # 解析父菜单(空则根菜单) - parent = None - if parent_uuid_str: - parent_uuid_bytes = _ParseUUID(parent_uuid_str) - if not parent_uuid_bytes: - return Response({'code': 1, 'msg': 'ParentMenuUUID 格式无效', 'data': None}) - try: - parent = Menu.objects.get(MenuUUID=parent_uuid_bytes) - except Menu.DoesNotExist: - return Response({'code': 1, 'msg': '父菜单不存在', 'data': None}) - - # 通用字段 - menu_name = _get('MenuName', '').strip() - menu_title = _get('MenuTitle', '').strip() - menu_icon = _get('MenuIcon', '') - menu_link = _get('MenuLink', '') - try: - menu_sort = int(_get('MenuSort', '0')) - except (ValueError, TypeError): - menu_sort = 0 - try: - menu_type = int(_get('MenuType', '0')) - except (ValueError, TypeError): - menu_type = 0 - try: - menu_status = int(_get('MenuStatus', '1')) - except (ValueError, TypeError): - menu_status = 1 - try: - perm_level = float(_get('PermissionLevel', '1.0')) - except (ValueError, TypeError): - perm_level = 1.0 - perm_attr = _get('PermissionAttr', '') - - if menu_uuid_str: - # 修改 - menu_uuid_bytes = _ParseUUID(menu_uuid_str) - if not menu_uuid_bytes: - return Response({'code': 1, 'msg': 'MenuUUID 格式无效', 'data': None}) - try: - menu = Menu.objects.get(MenuUUID=menu_uuid_bytes) - except Menu.DoesNotExist: - return Response({'code': 1, 'msg': '菜单不存在', 'data': None}) - menu.CompanyUUID = company - menu.ParentMenuUUID = parent - menu.MenuName = menu_name - menu.MenuTitle = menu_title - menu.MenuIcon = menu_icon - menu.MenuLink = menu_link - menu.MenuSort = menu_sort - menu.MenuType = menu_type - menu.MenuStatus = menu_status - menu.PermissionLevel = perm_level - menu.PermissionAttr = perm_attr - menu.save() - return Response({'code': 0, 'id': menu_uuid_str, 'data': {'MenuUUID': menu_uuid_str}}) - - # 新增 - new_uuid_bytes = _uuid.uuid4().bytes - menu = Menu.objects.create( - MenuUUID=new_uuid_bytes, - CompanyUUID=company, - ParentMenuUUID=parent, - MenuName=menu_name or 'new-menu', - MenuTitle=menu_title or '新菜单', - MenuIcon=menu_icon, - MenuLink=menu_link, - MenuSort=menu_sort, - MenuType=menu_type, - MenuStatus=menu_status, - PermissionLevel=perm_level, - PermissionAttr=perm_attr, - ) - new_uuid_str = str(_uuid.UUID(bytes=new_uuid_bytes)) - return Response({'code': 0, 'id': new_uuid_str, 'data': {'MenuUUID': new_uuid_str}}) + record, err = MenuManager.Save(dict(request.data)) + if err: + return Response({'code': 1, 'msg': err, 'data': None}) + menu_uuid_str = record['MenuUUID'] + return Response({'code': 0, 'id': menu_uuid_str, 'data': {'MenuUUID': menu_uuid_str}}) class MenuDeleteView(ELTAuthenticatedAPIView): @@ -404,22 +262,7 @@ class MenuDeleteView(ELTAuthenticatedAPIView): if not menu_uuid_str: return Response({'code': 1, 'msg': '缺少 MenuUUID', 'data': None}) - menu_uuid_bytes = _ParseUUID(menu_uuid_str) - if not menu_uuid_bytes: - return Response({'code': 1, 'msg': 'MenuUUID 格式无效', 'data': None}) - - try: - menu = Menu.objects.get(MenuUUID=menu_uuid_bytes) - except Menu.DoesNotExist: - return Response({'code': 1, 'msg': '菜单不存在', 'data': None}) - - # 递归删除子菜单,避免孤儿节点 - def _DeleteDescendants(parent_uuid): - children = Menu.objects.filter(ParentMenuUUID=parent_uuid) - for child in children: - _DeleteDescendants(child.MenuUUID) - child.delete() - - _DeleteDescendants(menu.MenuUUID) - menu.delete() + ok, err = MenuManager.Delete(menu_uuid_str) + if not ok: + return Response({'code': 1, 'msg': err, 'data': None}) return Response({'code': 0, 'data': None}) diff --git a/gvsds/apps/gerp/views/message.py b/gvsds/apps/gerp/views/message.py index d6ab969e..b12a7172 100644 --- a/gvsds/apps/gerp/views/message.py +++ b/gvsds/apps/gerp/views/message.py @@ -1,127 +1,74 @@ -import uuid -import datetime +import logging + from rest_framework.response import Response -from gvsds.apps.core.models import User -from gvsds.apps.gerp.models import Message -from gvsdsdk.models import Role + from . import ELTAuthenticatedAPIView +logger = logging.getLogger(__name__) + class MessageCompatView(ELTAuthenticatedAPIView): - """消息兼容视图——站内信收发与查询""" + """消息兼容视图——站内信收发与查询(委托 gvsdsdk.MessageManager)""" def get(self, request): action = request.query_params.get('action', 'unread_count') user = request.user if hasattr(request, 'user') and request.user.IsAuthenticated else None if not user: return Response({'code': 0, 'UnreadCount': 0, 'RecentMessages': []}) - - # request.user 是 gvsdsdk.User,但 Message.ReceiverUUID/SenderUUID 是 FK 指向 core.User - # 使用 _id 后缀直接传列值避免类型不匹配 + from gvsdsdk.auth.managers import MessageManager user_uuid = user.UserUUID if action == 'unread_count': - count = Message.objects.filter(ReceiverUUID_id=user_uuid, IsRead=0).count() - recent = Message.objects.filter(ReceiverUUID_id=user_uuid).select_related('SenderUUID').order_by('-CreateTime')[:5] - recent_data = [] - for msg in recent: - sender_name = "\u7cfb\u7edf" - if msg.SenderUUID: - sender_name = msg.SenderUUID.UserName - recent_data.append({ - 'MessageUUID': msg.MessageUUID or '', - 'Title': msg.Title or '', - 'Content': (msg.Content or '')[:100], - 'MsgType': msg.MsgType or 0, - 'IsRead': msg.IsRead or 0, - 'SenderName': sender_name, - 'RelatedUUID': msg.RelatedUUID or '', - 'RelatedType': msg.RelatedType or '', - 'CreateTime': msg.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if msg.CreateTime else '', - }) - return Response({'code': 0, 'UnreadCount': count, 'RecentMessages': recent_data}) + count = MessageManager.UnreadCount(user_uuid) + recent = MessageManager.RecentMessages(user_uuid, Limit=5) + return Response({'code': 0, 'UnreadCount': count, 'RecentMessages': recent}) - elif action == 'list': + if action == 'list': # 分页参数:兼容前端协议 PageIndex/PageSize(原生 page/pageSize 兜底) - page = int(request.query_params.get('PageIndex', request.query_params.get('page', 1))) - page_size = int(request.query_params.get('PageSize', request.query_params.get('pageSize', 20))) - qs = Message.objects.filter(ReceiverUUID_id=user_uuid).select_related('SenderUUID').order_by('-CreateTime') - # 筛选:IsRead(0 未读 / 1 已读)、MsgType(0 系统 / 1 审核 / 2 工单 / 3 个人) - is_read = request.query_params.get('IsRead') - if is_read in ('0', '1'): - qs = qs.filter(IsRead=int(is_read)) - msg_type = request.query_params.get('MsgType') - if msg_type and msg_type.isdigit(): - qs = qs.filter(MsgType=int(msg_type)) - total = qs.count() - items = qs[(page - 1) * page_size:page * page_size] - data = [] - for msg in items: - sender_name = "\u7cfb\u7edf" - if msg.SenderUUID: - sender_name = msg.SenderUUID.UserName - data.append({ - 'MessageUUID': str(msg.MessageUUID or ''), - 'Title': msg.Title or '', - 'Content': msg.Content or '', - 'MsgType': msg.MsgType or 0, - 'IsRead': msg.IsRead or 0, - 'SenderName': sender_name, - 'RelatedUUID': msg.RelatedUUID or '', - 'RelatedType': msg.RelatedType or '', - 'CreateTime': msg.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if msg.CreateTime else '', - }) + try: + page = int(request.query_params.get('PageIndex', request.query_params.get('page', 1))) + except (TypeError, ValueError): + page = 1 + try: + page_size = int(request.query_params.get('PageSize', request.query_params.get('pageSize', 20))) + except (TypeError, ValueError): + page_size = 20 + is_read_raw = request.query_params.get('IsRead') + is_read = int(is_read_raw) if is_read_raw in ('0', '1') else None + msg_type_raw = request.query_params.get('MsgType') + msg_type = int(msg_type_raw) if msg_type_raw and msg_type_raw.isdigit() else None + total, data = MessageManager.ListMessages( + user_uuid, Page=page, PageSize=page_size, + IsRead=is_read, MsgType=msg_type, + ) return Response({'code': 0, 'total': total, 'data': data}) - elif action == 'detail': + if action == 'detail': # 消息UUID:兼容前端协议 MessageUUID(原生 uuid 兜底) message_uuid_str = request.query_params.get('MessageUUID', '') or request.query_params.get('uuid', '') if not message_uuid_str: - return Response({'code': 1, 'msg': '\u7f3a\u5c11MessageUUID\u53c2\u6570'}, status=400) - msg = Message.objects.filter(MessageUUID=message_uuid_str, ReceiverUUID_id=user_uuid).select_related('SenderUUID').first() - if not msg: - return Response({'code': 1, 'msg': '\u6d88\u606f\u4e0d\u5b58\u5728'}, status=404) - msg.IsRead = 1 - msg.save(update_fields=['IsRead']) - sender_name = "\u7cfb\u7edf" - if msg.SenderUUID: - sender_name = msg.SenderUUID.UserName - return Response({'code': 0, 'data': { - 'MessageUUID': str(msg.MessageUUID or ''), - 'Title': msg.Title or '', - 'Content': msg.Content or '', - 'MsgType': msg.MsgType or 0, - 'IsRead': msg.IsRead or 0, - 'SenderName': sender_name, - 'RelatedUUID': msg.RelatedUUID or '', - 'RelatedType': msg.RelatedType or '', - 'CreateTime': msg.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if msg.CreateTime else '', - }}) - - elif action == 'search_recipients': - keyword = request.query_params.get('keyword', '') - limit = int(request.query_params.get('limit', 10)) - data = [] - # 用户:按用户名搜索 - uqs = User.objects.all() - if keyword: - uqs = uqs.filter(UserName__icontains=keyword) - for u in uqs[:limit]: - data.append({'type': 'user', 'uuid': str(u.UserUUID or ''), 'name': u.UserName, 'label': u.UserName}) - # 角色:按角色名搜索(发送弹窗支持按角色群发) - rqs = Role.objects.all() - if keyword: - rqs = rqs.filter(RoleName__icontains=keyword) - for r in rqs[:limit]: - data.append({'type': 'role', 'uuid': str(r.RoleUUID or ''), 'name': r.RoleName, 'label': r.RoleName}) + return Response({'code': 1, 'msg': '缺少MessageUUID参数'}, status=400) + data = MessageManager.GetMessageDetail(user_uuid, message_uuid_str) + if data is None: + return Response({'code': 1, 'msg': '消息不存在'}, status=404) return Response({'code': 0, 'data': data}) - return Response({'code': 1, 'msg': '\u672a\u77e5\u64cd\u4f5c'}, status=400) + if action == 'search_recipients': + keyword = request.query_params.get('keyword', '') + try: + limit = int(request.query_params.get('limit', 10)) + except (TypeError, ValueError): + limit = 10 + data = MessageManager.SearchRecipients(Keyword=keyword, Limit=limit) + return Response({'code': 0, 'data': data}) + + return Response({'code': 1, 'msg': '未知操作'}, status=400) def post(self, request): user = request.user if hasattr(request, 'user') and request.user.IsAuthenticated else None if not user: - return Response({'code': 1, 'msg': '\u672a\u767b\u5f55'}, status=401) + return Response({'code': 1, 'msg': '未登录'}, status=401) + from gvsdsdk.auth.managers import MessageManager user_uuid = user.UserUUID title = request.data.get('Title', '') content = request.data.get('Content', '') @@ -131,67 +78,52 @@ class MessageCompatView(ELTAuthenticatedAPIView): related_uuid_str = request.data.get('RelatedUUID', '') related_type = request.data.get('RelatedType', '') if not title or not receiver_uuid_str: - return Response({'code': 1, 'msg': '\u7f3a\u5c11\u5fc5\u8981\u53c2\u6570'}, status=400) - - def _make_message(receiver): - return Message( - MessageUUID=uuid.uuid4().bytes, - SenderUUID_id=user_uuid, - ReceiverUUID=receiver, - Title=title, - Content=content, - MsgType=msg_type, - IsRead=0, - RelatedUUID=related_uuid_str, - RelatedType=related_type, - CreateTime=datetime.datetime.utcnow(), - ) + return Response({'code': 1, 'msg': '缺少必要参数'}, status=400) if receiver_type == 'role': - # 角色群发:给持有该角色的所有用户各发一条(复用 UserRole 关联,去重) - role_uuid_str = receiver_uuid_str - from gvsdsdk.auth.managers import RoleManager - role = RoleManager.GetRoleByUUID(role_uuid_str) - if role is None: + # 角色群发:给持有该角色的所有用户各发一条 + sent = MessageManager.SendRoleMessage( + user_uuid, receiver_uuid_str, title, content, + msg_type, related_uuid_str, related_type, + ) + if sent < 0: return Response({'code': 1, 'msg': '角色不存在'}, status=400) - receivers = RoleManager.GetUsersForRole(role.RoleUUID) - for receiver in receivers: - _make_message(receiver).save() - return Response({'code': 0, 'msg': '\u53d1\u9001\u6210\u529f'}) - try: - receiver = User.objects.get(UserUUID=receiver_uuid_str) - except User.DoesNotExist: - return Response({'code': 1, 'msg': '\u63a5\u6536\u8005\u4e0d\u5b58\u5728'}, status=400) - _make_message(receiver).save() - return Response({'code': 0, 'msg': '\u53d1\u9001\u6210\u529f'}) + return Response({'code': 0, 'msg': '发送成功'}) + + msg = MessageManager.SendMessage( + user_uuid, receiver_uuid_str, title, content, + msg_type, related_uuid_str, related_type, + ) + if msg is None: + return Response({'code': 1, 'msg': '接收者不存在'}, status=400) + return Response({'code': 0, 'msg': '发送成功'}) def patch(self, request): user = request.user if hasattr(request, 'user') and request.user.IsAuthenticated else None if not user: - return Response({'code': 1, 'msg': '\u672a\u767b\u5f55'}, status=401) + return Response({'code': 1, 'msg': '未登录'}, status=401) + from gvsdsdk.auth.managers import MessageManager # 全部已读:body 携带 action=read_all if request.data.get('action') == 'read_all': - Message.objects.filter(ReceiverUUID_id=user.UserUUID, IsRead=0).update(IsRead=1) - return Response({'code': 0, 'msg': '\u5df2\u5168\u90e8\u6807\u8bb0\u4e3a\u5df2\u8bfb'}) + MessageManager.MarkAllRead(user.UserUUID) + return Response({'code': 0, 'msg': '已全部标记为已读'}) uuid_str = request.data.get('UUID', '') if not uuid_str: - return Response({'code': 1, 'msg': '\u7f3a\u5c11uuid\u53c2\u6570'}, status=400) - msg = Message.objects.filter(MessageUUID=uuid_str, ReceiverUUID_id=user.UserUUID).first() - if not msg: - return Response({'code': 1, 'msg': '\u6d88\u606f\u4e0d\u5b58\u5728'}, status=404) - msg.IsRead = 1 - msg.save(update_fields=['IsRead']) - return Response({'code': 0, 'msg': '\u5df2\u6807\u8bb0\u4e3a\u5df2\u8bfb'}) + return Response({'code': 1, 'msg': '缺少uuid参数'}, status=400) + if not MessageManager.MarkRead(user.UserUUID, uuid_str): + return Response({'code': 1, 'msg': '消息不存在'}, status=404) + return Response({'code': 0, 'msg': '已标记为已读'}) def delete(self, request): user = request.user if hasattr(request, 'user') and request.user.IsAuthenticated else None if not user: - return Response({'code': 1, 'msg': '\u672a\u767b\u5f55'}, status=401) + return Response({'code': 1, 'msg': '未登录'}, status=401) + from gvsdsdk.auth.managers import MessageManager # 消息UUID:兼容前端协议(DELETE body MessageUUID),原生 uuid(query)/UUID(body)兜底 uuid_str = (request.data.get('MessageUUID', '') or request.query_params.get('uuid', '') or request.data.get('UUID', '')) if not uuid_str: - return Response({'code': 1, 'msg': '\u7f3a\u5c11uuid\u53c2\u6570'}, status=400) - Message.objects.filter(MessageUUID=uuid_str, ReceiverUUID_id=user.UserUUID).delete() - return Response({'code': 0, 'msg': '\u5220\u9664\u6210\u529f'}) \ No newline at end of file + return Response({'code': 1, 'msg': '缺少uuid参数'}, status=400) + MessageManager.DeleteMessage(user.UserUUID, uuid_str) + return Response({'code': 0, 'msg': '删除成功'}) diff --git a/gvsds/apps/gerp/views/settings_api.py b/gvsds/apps/gerp/views/settings_api.py index 3cea2789..46eef746 100644 --- a/gvsds/apps/gerp/views/settings_api.py +++ b/gvsds/apps/gerp/views/settings_api.py @@ -1,7 +1,6 @@ -"""用户设置 API——基于 UserSettings 模型提供存取""" +"""用户设置 API——基于 gvsdsdk UserService 提供存取""" import json from rest_framework.response import Response -from gvsds.apps.gerp.models import UserSettings from . import ELTAuthenticatedAPIView @@ -13,14 +12,10 @@ class SettingsAPIView(ELTAuthenticatedAPIView): if not user: return Response({'code': 1, 'msg': '未登录'}, status=401) + from gvsdsdk.auth.user_service import UserService try: - # request.user 是 gvsdsdk.User,UserSettings.UserUUID 是 FK 指向 core.User - # 使用 _id 后缀直接传列值避免类型不匹配 - record = UserSettings.objects.filter(UserUUID_id=user.UserUUID).first() - if record and record.SettingsData: - settings = json.loads(record.SettingsData) - else: - settings = {} + settings_text = UserService.GetUserSettings(user.UserUUID) + settings = json.loads(settings_text) if settings_text else {} except Exception: settings = {} @@ -39,12 +34,10 @@ class SettingsAPIView(ELTAuthenticatedAPIView): elif not isinstance(settings_data, str): return Response({'code': 1, 'msg': '无效的设置数据'}, status=400) + from gvsdsdk.auth.user_service import UserService try: - record, created = UserSettings.objects.update_or_create( - UserUUID_id=user.UserUUID, - defaults={'SettingsData': settings_data} - ) + UserService.SaveUserSettings(user.UserUUID, settings_data) except Exception as e: return Response({'code': 1, 'msg': f'保存失败: {str(e)}'}, status=500) - return Response({'code': 0, 'msg': '保存成功'}) \ No newline at end of file + return Response({'code': 0, 'msg': '保存成功'}) diff --git a/gvsds/apps/gerp/views/user.py b/gvsds/apps/gerp/views/user.py index 494db45c..a794aa29 100644 --- a/gvsds/apps/gerp/views/user.py +++ b/gvsds/apps/gerp/views/user.py @@ -5,7 +5,7 @@ import datetime import bcrypt from rest_framework.response import Response from gvsds.apps.core.models import User -from gvsds.apps.gerp.models import Password, UserIDCard, PersonalPage +from gvsds.apps.gerp.models import Password from gvsdsdk.model_utils import UUIDObj from . import ELTAuthenticatedAPIView from ..serializers import ( @@ -121,9 +121,9 @@ class UserIdCardView(ELTAuthenticatedAPIView): def get(self, request): user = request.user - try: - idcard = UserIDCard.objects.get(UserUUID=user.UserUUID) - except UserIDCard.DoesNotExist: + from gvsdsdk.auth.user_service import UserService + idcard = UserService.GetUserIDCard(user.UserUUID) + if idcard is None: return Response({'code': 0, 'count': 1000, 'data': {}}) born = idcard.Born return Response({ @@ -166,9 +166,9 @@ class PersonalPageConfigView(ELTAuthenticatedAPIView): def get(self, request): user = request.user - try: - page = PersonalPage.objects.get(PersonalPageUserUUID=user.UserUUID) - except PersonalPage.DoesNotExist: + from gvsdsdk.auth.user_service import UserService + page = UserService.GetPersonalPage(user.UserUUID) + if page is None: return Response({'code': 1, 'msg': '未注册个人主页'}) return Response({ 'code': 0, @@ -214,52 +214,14 @@ class PersonalPageConfigView(ELTAuthenticatedAPIView): return Response({'code': 1, 'msg': 'PersonalPageName 不能为空'}) if not re.fullmatch(r'^[a-z0-9_]+$', personal_page_name): return Response({'code': 1, 'msg': 'PersonalPageName 仅允许小写字母、数字和下划线'}) - field_map = { - 'PersonalPageName': 'PersonalPageName', - 'Subtitle': 'Subtitle', - 'BackgroundLink': 'BackgroundLink', - 'Classification': 'Classification', - 'Github': 'Github', - 'WeChat': 'Wechat', - 'QQ': 'QQ', - 'SelfIntroduction': 'SelfIntroduction', - 'ProjectExperience': 'ProjectExperience', - 'SatisfiedCustomers': 'SatisfiedCustomers', - 'ReceiveAward': 'ReceiveAward', - 'CoffeeCups': 'CoffeeCups', - 'Achievement1Icon': 'Achievement1Icon', - 'Achievement1Name': 'Achievement1Name', - 'Achievement1Time': 'Achievement1Time', - 'Achievement1Introduction': 'Achievement1Introduction', - 'Achievement2Icon': 'Achievement2Icon', - 'Achievement2Name': 'Achievement2Name', - 'Achievement2Time': 'Achievement2Time', - 'Achievement2Introduction': 'Achievement2Introduction', - 'Achievement3Icon': 'Achievement3Icon', - 'Achievement3Name': 'Achievement3Name', - 'Achievement3Time': 'Achievement3Time', - 'Achievement3Introduction': 'Achievement3Introduction', - 'Achievement4Icon': 'Achievement4Icon', - 'Achievement4Name': 'Achievement4Name', - 'Achievement4Time': 'Achievement4Time', - 'Achievement4Introduction': 'Achievement4Introduction', - 'Tags': 'Tags', - 'MarkDown': 'Markdown', - } - defaults = {} - for form_key, model_key in field_map.items(): - val = request.data.get(form_key) - if val is not None: - defaults[model_key] = val - defaults['PersonalPageName'] = personal_page_name + from gvsdsdk.auth.user_service import UserService try: - page, created = PersonalPage.objects.update_or_create( - PersonalPageUserUUID=user.UserUUID, - defaults=defaults, - ) - return Response({'code': 0, 'msg': '修改成功'}) + page, err = UserService.SavePersonalPage(user.UserUUID, request.data) + if page is None: + return Response({'code': 1, 'msg': err}) except Exception as e: return Response({'code': 1, 'msg': str(e)}, status=400) + return Response({'code': 0, 'msg': '修改成功'}) class MemberCardView(ELTAuthenticatedAPIView): diff --git a/gvsds/apps/gerp/views/viewsets.py b/gvsds/apps/gerp/views/viewsets.py index cf091f45..143f6dc4 100644 --- a/gvsds/apps/gerp/views/viewsets.py +++ b/gvsds/apps/gerp/views/viewsets.py @@ -655,9 +655,9 @@ class WorkOrderViewSet(CompanyScopedMixin, viewsets.ModelViewSet): user_uuid = request.user.UserUUID if request.user.IsAuthenticated else None if not user_uuid: return Response({'code': 401, 'msg': '未登录'}, status=401) - qs = WorkOrder.objects.filter(CreatorUUID_id=user_uuid) - serializer = self.get_serializer(qs, many=True) - return Response({'code': 0, 'data': serializer.data}) + from gvsdsdk.auth.managers import WorkOrderManager + data = WorkOrderManager.ListForUser(user_uuid) + return Response({'code': 0, 'data': data}) @action(detail=False, methods=['post']) def create_order(self, request): @@ -665,31 +665,27 @@ class WorkOrderViewSet(CompanyScopedMixin, viewsets.ModelViewSet): ser.IsValid(raise_exception=True) data = ser.ValidatedData user_uuid = request.user.UserUUID - assignee = data.get('AssigneeUUID') - order = WorkOrder.objects.create( - OrderUUID=uuid.uuid4().bytes, + from gvsdsdk.auth.managers import WorkOrderManager + record = WorkOrderManager.Create( + CreatorUUID=user_uuid, Title=data['Title'], Description=data.get('Description', ''), Category=data.get('Category', 'other'), Priority=data.get('Priority', 0), - CreatorUUID_id=user_uuid, - AssigneeUUID_id=assignee, + AssigneeUUID=data.get('AssigneeUUID'), ) - return Response({'code': 0, 'msg': '创建成功', 'data': WorkOrderSerializer(order).data}, status=201) + return Response({'code': 0, 'msg': '创建成功', 'data': record}, status=201) @action(detail=True, methods=['post']) def update_order(self, request, pk=None): - order = self.get_object() ser = WorkOrderUpdateSerializer(data=request.data) ser.IsValid(raise_exception=True) data = ser.ValidatedData - field_map = {'Status': 'Status', 'AssigneeUUID': 'AssigneeUUID', 'Resolution': 'Resolution', 'Description': 'Description', 'Priority': 'Priority'} - for key in field_map: - if key in data: - setattr(order, field_map[key], data[key]) - order.UpdateTime = datetime.datetime.utcnow() - order.save() - return Response({'code': 0, 'msg': '更新成功', 'data': WorkOrderSerializer(order).data}) + from gvsdsdk.auth.managers import WorkOrderManager + record = WorkOrderManager.Update(pk, data) + if record is None: + return Response({'code': 404, 'msg': '工单不存在'}, status=404) + return Response({'code': 0, 'msg': '更新成功', 'data': record}) class MessageViewSet(CompanyScopedMixin, viewsets.ModelViewSet): diff --git a/gvsdsdk/__init__.py b/gvsdsdk/__init__.py index 62b05c62..e05e393c 100644 --- a/gvsdsdk/__init__.py +++ b/gvsdsdk/__init__.py @@ -224,6 +224,7 @@ _LAZY_SYMBOLS = { 'TenantManager': ('auth', 'TenantManager'), 'CompanyManager': ('auth', 'CompanyManager'), 'DepartmentManager': ('auth', 'DepartmentManager'), + 'MessageManager': ('auth', 'MessageManager'), 'UserService': ('auth', 'UserService'), # ── 分佣引擎 ────────────────────────────────────────── @@ -298,6 +299,8 @@ _LAZY_SYMBOLS = { 'Position': ('models', 'Position'), 'PositionTemplate': ('models', 'PositionTemplate'), 'PositionTemplatePermission': ('models', 'PositionTemplatePermission'), + 'Menu': ('models', 'Menu'), + 'WorkOrder': ('models', 'WorkOrder'), 'FixedBinaryField': ('models', 'FixedBinaryField'), 'QModel': ('model_base', 'QModel'), 'QModelBase': ('model_base', 'QModelBase'), diff --git a/gvsdsdk/auth/__init__.py b/gvsdsdk/auth/__init__.py index d891d77d..14f1a225 100644 --- a/gvsdsdk/auth/__init__.py +++ b/gvsdsdk/auth/__init__.py @@ -6,7 +6,7 @@ __all__ = [ 'IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'TenantScopedMixin', 'UserScopedMixin', 'PermissionVerifier', 'IsAdminScopeDisabled', 'IsTenantScopeDisabled', 'IsAdmin', 'IsSuperAdmin', 'IsTenantAdmin', - 'RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'TenantManager', 'CompanyManager', 'DepartmentManager', 'UserService', + 'RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'TenantManager', 'CompanyManager', 'DepartmentManager', 'MessageManager', 'UserService', ] diff --git a/gvsdsdk/auth/managers.py b/gvsdsdk/auth/managers.py index 584851af..66bc3644 100644 --- a/gvsdsdk/auth/managers.py +++ b/gvsdsdk/auth/managers.py @@ -1,7 +1,8 @@ import uuid +from django.db.models import Q from django.utils import timezone as _timezone -from gvsdsdk.models import Role, UserRole, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password, UserPending2FA, AccountActivation, Tenant, Company, Department, UserRoleDepartment +from gvsdsdk.models import Role, UserRole, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password, UserPending2FA, AccountActivation, Tenant, Company, Department, UserRoleDepartment, Message, Menu, WorkOrder from gvsdsdk.auth.crypto import HashPassword, VerifyPassword from gvsdsdk.model_utils import _GetUserModel, UUIDObj @@ -113,6 +114,21 @@ class RoleManager: user_uuids = UserRole.objects.filter(RoleUUID=RoleUUID).values_list('UserUUID', flat=True) return User.objects.filter(UserUUID__in=user_uuids) + @staticmethod + def GetCompanyUserUUIDs(CompanyUUID): + """获取公司下所有用户 UUID(通过 UserRole 关联,去重) + + Args: + CompanyUUID: 公司 UUID(bytes/str/UUIDObj 均可) + + Returns: + set[bytes]:公司关联用户 UUID 集合 + """ + return set( + UserRole.objects.filter(CompanyUUID=UUIDObj(CompanyUUID).bytes) + .values_list('UserUUID', flat=True) + ) + @staticmethod def AssignPermission(RoleUUID, PermUUID, CompanyUUID): """为角色分配权限(指定公司)""" @@ -741,6 +757,15 @@ class AuthManager: User = _GetUserModel() return User.objects.filter(UserUUID=UUIDObj(UserUUID).bytes).first() + @staticmethod + def CountUsers(UserUUIDs=None): + """统计用户数量(可按 UUID 集合过滤;空集合返回 0)""" + User = _GetUserModel() + qs = User.objects.all() + if UserUUIDs is not None: + qs = qs.filter(UserUUID__in=UserUUIDs) if UserUUIDs else qs.none() + return qs.count() + @staticmethod def IsUserNameTaken(UserName): """用户名是否已被占用""" @@ -1255,3 +1280,494 @@ class DepartmentManager: ).values_list('DeptUUID', flat=True) return Department.objects.filter(DeptUUID__in=list(dept_uuids)).distinct() + +class MessageManager: + """站内信管理器——系统通知与站内信的收发与查询""" + + @staticmethod + def _SenderName(SenderUUID): + """解析发送者用户名(发送者为空视为系统)""" + if not SenderUUID: + return '系统' + User = _GetUserModel() + try: + sender = User.objects.filter(UserUUID=UUIDObj(SenderUUID).bytes).first() + return sender.UserName if sender else '系统' + except Exception: + return '系统' + + @staticmethod + def _ToDict(msg, SenderName, PreviewOnly=False): + return { + 'MessageUUID': str(msg.MessageUUID) if msg.MessageUUID else '', + 'Title': msg.Title or '', + 'Content': (msg.Content or '')[:100] if PreviewOnly else (msg.Content or ''), + 'MsgType': msg.MsgType or 0, + 'IsRead': msg.IsRead or 0, + 'SenderName': SenderName, + 'RelatedUUID': msg.RelatedUUID or '', + 'RelatedType': msg.RelatedType or '', + 'CreateTime': msg.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if msg.CreateTime else '', + } + + @staticmethod + def UnreadCount(UserUUID): + """获取用户未读消息数""" + return Message.objects.filter(ReceiverUUID=UUIDObj(UserUUID).bytes, IsRead=0).count() + + @staticmethod + def RecentMessages(UserUUID, Limit=5): + """获取最近消息(含发送者名,最多 Limit 条)""" + qs = Message.objects.filter( + ReceiverUUID=UUIDObj(UserUUID).bytes, + ).order_by('-CreateTime')[:Limit] + return [MessageManager._ToDict(m, MessageManager._SenderName(m.SenderUUID), PreviewOnly=True) for m in qs] + + @staticmethod + def ListMessages(UserUUID, Page=1, PageSize=20, IsRead=None, MsgType=None): + """分页列出用户消息(可按已读/类型过滤) + + Returns: + (total, list[dict]) + """ + qs = Message.objects.filter(ReceiverUUID=UUIDObj(UserUUID).bytes) + if IsRead in (0, 1): + qs = qs.filter(IsRead=IsRead) + if MsgType is not None: + qs = qs.filter(MsgType=MsgType) + total = qs.count() + items = qs.order_by('-CreateTime')[(Page - 1) * PageSize:Page * PageSize] + return total, [MessageManager._ToDict(m, MessageManager._SenderName(m.SenderUUID)) for m in items] + + @staticmethod + def GetMessageDetail(UserUUID, MessageUUID): + """获取消息详情并标记已读(不存在返回 None)""" + msg = Message.objects.filter( + MessageUUID=UUIDObj(MessageUUID).bytes, + ReceiverUUID=UUIDObj(UserUUID).bytes, + ).first() + if msg is None: + return None + if msg.IsRead != 1: + msg.IsRead = 1 + msg.save(update_fields=['IsRead']) + return MessageManager._ToDict(msg, MessageManager._SenderName(msg.SenderUUID)) + + @staticmethod + def SendMessage(SenderUserUUID, ReceiverUserUUID, Title, Content='', + MsgType=0, RelatedUUID='', RelatedType=''): + """发送单条站内信;接收者不存在返回 None""" + User = _GetUserModel() + try: + receiver_bytes = UUIDObj(ReceiverUserUUID).bytes + except Exception: + receiver_bytes = ReceiverUserUUID + if not User.objects.filter(UserUUID=receiver_bytes).exists(): + return None + sender_bytes = UUIDObj(SenderUserUUID).bytes if SenderUserUUID else None + msg = Message.objects.create( + MessageUUID=uuid.uuid4().bytes, + SenderUUID=sender_bytes, + ReceiverUUID=receiver_bytes, + Title=Title, + Content=Content, + MsgType=MsgType, + IsRead=0, + RelatedUUID=RelatedUUID, + RelatedType=RelatedType, + ) + return msg + + @staticmethod + def SendRoleMessage(SenderUserUUID, RoleUUID, Title, Content='', + MsgType=0, RelatedUUID='', RelatedType=''): + """按角色群发站内信(每个持该角色的用户一条);角色不存在返回 -1 + + Returns: + 发送条数 + """ + role = RoleManager.GetRoleByUUID(RoleUUID) + if role is None: + return -1 + receivers = RoleManager.GetUsersForRole(role.RoleUUID) + count = 0 + for receiver in receivers: + msg = MessageManager.SendMessage( + SenderUserUUID, receiver.UserUUID, Title, Content, + MsgType, RelatedUUID, RelatedType, + ) + if msg is not None: + count += 1 + return count + + @staticmethod + def MarkAllRead(UserUUID): + """全部标记已读;返回更新条数""" + return Message.objects.filter( + ReceiverUUID=UUIDObj(UserUUID).bytes, IsRead=0, + ).update(IsRead=1) + + @staticmethod + def MarkRead(UserUUID, MessageUUID): + """单条标记已读;返回是否命中""" + updated = Message.objects.filter( + MessageUUID=UUIDObj(MessageUUID).bytes, + ReceiverUUID=UUIDObj(UserUUID).bytes, + IsRead=0, + ).update(IsRead=1) + return updated > 0 + + @staticmethod + def DeleteMessage(UserUUID, MessageUUID): + """删除消息;返回是否命中""" + deleted, _ = Message.objects.filter( + MessageUUID=UUIDObj(MessageUUID).bytes, + ReceiverUUID=UUIDObj(UserUUID).bytes, + ).delete() + return deleted > 0 + + @staticmethod + def SearchRecipients(Keyword='', Limit=10): + """搜索收件人(用户 + 角色) + + Returns: + list[dict]:{type: user|role, uuid, name, label} + """ + data = [] + User = _GetUserModel() + uqs = User.objects.all() + if Keyword: + uqs = uqs.filter(UserName__icontains=Keyword) + for u in uqs[:Limit]: + uuid_str = UUIDObj(u.UserUUID).str if u.UserUUID else '' + data.append({'type': 'user', 'uuid': uuid_str, 'name': u.UserName, 'label': u.UserName}) + rqs = Role.objects.all() + if Keyword: + rqs = rqs.filter(RoleName__icontains=Keyword) + for r in rqs[:Limit]: + uuid_str = UUIDObj(r.RoleUUID).str if r.RoleUUID else '' + data.append({'type': 'role', 'uuid': uuid_str, 'name': r.RoleName, 'label': r.RoleName}) + return data + + +class MenuManager: + """菜单管理器——菜单查询、保存与递归删除""" + + @staticmethod + def _ToFlat(menu): + """Menu 实例 → 扁平 dict(键兼容 GERP 前端菜单协议)""" + return { + 'Id': menu.MenuName, + 'Title': menu.MenuTitle, + 'Icon': menu.MenuIcon or '', + 'Page': menu.MenuLink or '', + 'ParentUUID': UUIDObj(menu.ParentMenuUUID).str if menu.ParentMenuUUID else '', + 'MenuUUID': UUIDObj(menu.MenuUUID).str if menu.MenuUUID else '', + 'CompanyUUID': UUIDObj(menu.CompanyUUID).str if menu.CompanyUUID else '', + 'Sort': menu.MenuSort or 0, + 'Type': menu.MenuType or 0, + 'Children': [], + 'PermLevel': menu.PermissionLevel or 0, + 'PermAttr': menu.PermissionAttr or '', + } + + @staticmethod + def ListVisible(CompanyUUID=None): + """用户可见菜单(启用状态 + 公司过滤,按 MenuSort/MenuName 排序) + + Args: + CompanyUUID: 公司 UUID(bytes/str 均可);传 None 时仅返回公有菜单 + + Returns: + list[dict]:扁平菜单记录列表 + """ + qs = Menu.objects.filter(MenuStatus=1) + if CompanyUUID: + qs = qs.filter(Q(CompanyUUID__isnull=True) | Q(CompanyUUID=CompanyUUID)) + else: + qs = qs.filter(CompanyUUID__isnull=True) + qs = qs.order_by('MenuSort', 'MenuName') + return [MenuManager._ToFlat(m) for m in qs] + + @staticmethod + def _ToAdminFlat(menu): + """Menu 实例 → 管理列表 dict(键与 GERP 菜单管理协议一致)""" + return { + 'MenuUUID': UUIDObj(menu.MenuUUID).str if menu.MenuUUID else '', + 'ParentMenuUUID': UUIDObj(menu.ParentMenuUUID).str if menu.ParentMenuUUID else '', + 'CompanyUUID': UUIDObj(menu.CompanyUUID).str if menu.CompanyUUID else '', + 'MenuName': menu.MenuName or '', + 'MenuTitle': menu.MenuTitle or '', + 'MenuIcon': menu.MenuIcon or '', + 'MenuLink': menu.MenuLink or '', + 'MenuSort': menu.MenuSort if menu.MenuSort is not None else 0, + 'MenuType': menu.MenuType if menu.MenuType is not None else 0, + 'MenuStatus': menu.MenuStatus if menu.MenuStatus is not None else 0, + 'PermissionLevel': menu.PermissionLevel if menu.PermissionLevel is not None else 0, + 'PermissionAttr': menu.PermissionAttr or '', + } + + @staticmethod + def AdminList(CompanyUUID=None, CompanyProvided=False): + """菜单管理列表 + + Args: + CompanyUUID: 公司 UUID(bytes/str 均可) + CompanyProvided: 是否显式提供了 CompanyUUID 参数 + (True 且 CompanyUUID 为空 → 仅返回公有菜单;False → 返回全部) + + Returns: + list[dict](键为菜单管理协议) + """ + qs = Menu.objects.all() + if CompanyProvided: + if CompanyUUID: + qs = qs.filter(Q(CompanyUUID__isnull=True) | Q(CompanyUUID=CompanyUUID)) + else: + qs = qs.filter(CompanyUUID__isnull=True) + qs = qs.order_by('MenuSort', 'MenuName') + return [MenuManager._ToAdminFlat(m) for m in qs] + + @staticmethod + def Get(MenuUUID): + """按 UUID 查询菜单(不存在返回 None)""" + try: + return Menu.objects.get(MenuUUID=UUIDObj(MenuUUID).bytes) + except (Menu.DoesNotExist, ValueError, AttributeError): + return None + + @staticmethod + def Save(Data): + """新增或修改菜单(有 MenuUUID 则修改) + + Args: + Data: dict,键与前端协议一致(MenuUUID 可选) + + Returns: + (record_dict, err):err 非 None 表示失败(如格式无效/不存在) + """ + menu_uuid_str = (Data.get('MenuUUID') or '').strip() + company_uuid_str = (Data.get('CompanyUUID') or '').strip() + parent_uuid_str = (Data.get('ParentMenuUUID') or '').strip() + + # 解析公司(空则不关联公司,即公有菜单) + company_bytes = None + if company_uuid_str: + try: + company_bytes = UUIDObj(company_uuid_str).bytes + except Exception: + return None, 'CompanyUUID 格式无效' + if not Company.objects.filter(CompanyUUID=company_bytes).exists(): + return None, '公司不存在' + + # 解析父菜单(空则根菜单) + parent_bytes = None + if parent_uuid_str: + try: + parent_bytes = UUIDObj(parent_uuid_str).bytes + except Exception: + return None, 'ParentMenuUUID 格式无效' + if not Menu.objects.filter(MenuUUID=parent_bytes).exists(): + return None, '父菜单不存在' + + # 通用字段 + menu_name = (Data.get('MenuName') or '').strip() + menu_title = (Data.get('MenuTitle') or '').strip() + menu_icon = Data.get('MenuIcon') or '' + menu_link = Data.get('MenuLink') or '' + try: + menu_sort = int(Data.get('MenuSort', '0')) + except (ValueError, TypeError): + menu_sort = 0 + try: + menu_type = int(Data.get('MenuType', '0')) + except (ValueError, TypeError): + menu_type = 0 + try: + menu_status = int(Data.get('MenuStatus', '1')) + except (ValueError, TypeError): + menu_status = 1 + try: + perm_level = float(Data.get('PermissionLevel', '1.0')) + except (ValueError, TypeError): + perm_level = 1.0 + perm_attr = Data.get('PermissionAttr') or '' + + if menu_uuid_str: + # 修改 + try: + menu_uuid = UUIDObj(menu_uuid_str).bytes + except Exception: + return None, 'MenuUUID 格式无效' + menu = Menu.objects.filter(MenuUUID=menu_uuid).first() + if menu is None: + return None, '菜单不存在' + menu.CompanyUUID = company_bytes + menu.ParentMenuUUID = parent_bytes + menu.MenuName = menu_name + menu.MenuTitle = menu_title + menu.MenuIcon = menu_icon + menu.MenuLink = menu_link + menu.MenuSort = menu_sort + menu.MenuType = menu_type + menu.MenuStatus = menu_status + menu.PermissionLevel = perm_level + menu.PermissionAttr = perm_attr + menu.save() + return MenuManager._ToFlat(menu), None + + # 新增 + menu = Menu.objects.create( + MenuUUID=uuid.uuid4().bytes, + CompanyUUID=company_bytes, + ParentMenuUUID=parent_bytes, + MenuName=menu_name or 'new-menu', + MenuTitle=menu_title or '新菜单', + MenuIcon=menu_icon, + MenuLink=menu_link, + MenuSort=menu_sort, + MenuType=menu_type, + MenuStatus=menu_status, + PermissionLevel=perm_level, + PermissionAttr=perm_attr, + ) + return MenuManager._ToFlat(menu), None + + @staticmethod + def Delete(MenuUUID): + """递归删除菜单及其子孙节点 + + Args: + MenuUUID: 菜单 UUID(bytes/str 均可) + + Returns: + (成功?, 错误消息 or None) + """ + try: + menu_uuid = UUIDObj(MenuUUID).bytes + except Exception: + return False, 'MenuUUID 格式无效' + menu = Menu.objects.filter(MenuUUID=menu_uuid).first() + if menu is None: + return False, '菜单不存在' + + def _DeleteDescendants(parent_uuid): + for child in Menu.objects.filter(ParentMenuUUID=parent_uuid): + _DeleteDescendants(child.MenuUUID) + child.delete() + + _DeleteDescendants(menu.MenuUUID) + menu.delete() + return True, None + + +class WorkOrderManager: + """工单管理器——工单查询、统计、创建与更新""" + + @staticmethod + def _ToFlat(wo): + """WorkOrder 实例 → dict(字段与 WorkOrderSerializer 输出一致)""" + return { + 'OrderUUID': UUIDObj(wo.OrderUUID).str if wo.OrderUUID else '', + 'Title': wo.Title or '', + 'Description': wo.Description or '', + 'Category': wo.Category or '', + 'Priority': wo.Priority or 0, + 'Status': wo.Status or 0, + 'CreatorUUID': UUIDObj(wo.CreatorUUID).str if wo.CreatorUUID else '', + 'AssigneeUUID': UUIDObj(wo.AssigneeUUID).str if wo.AssigneeUUID else '', + 'Resolution': wo.Resolution or '', + 'CreateTime': wo.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if wo.CreateTime else '', + 'UpdateTime': wo.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if wo.UpdateTime else '', + 'CloseTime': wo.CloseTime.strftime('%Y-%m-%d %H:%M:%S') if wo.CloseTime else '', + } + + @staticmethod + def GetStats(): + """工单统计——按状态/优先级/分类计数 + + Returns: + dict: {total, byStatus, byPriority, byCategory} + """ + from django.db.models import Count + qs = WorkOrder.objects.all() + + def _group(field_name): + return {row[field_name]: row['c'] for row in qs.values(field_name).annotate(c=Count('pk'))} + + return { + 'total': qs.count(), + 'byStatus': _group('Status'), + 'byPriority': _group('Priority'), + 'byCategory': _group('Category'), + } + + @staticmethod + def ListForUser(UserUUID): + """获取用户创建的工单列表(按创建时间倒序)""" + return [ + WorkOrderManager._ToFlat(wo) + for wo in WorkOrder.objects.filter(CreatorUUID=UUIDObj(UserUUID).bytes) + ] + + @staticmethod + def Create(CreatorUUID, Title, Description='', Category='other', + Priority=0, AssigneeUUID=None): + """创建工单 + + Returns: + dict:新工单记录 + """ + assignee_bytes = None + if AssigneeUUID: + try: + assignee_bytes = UUIDObj(AssigneeUUID).bytes + except Exception: + assignee_bytes = None + wo = WorkOrder.objects.create( + OrderUUID=uuid.uuid4().bytes, + Title=Title, + Description=Description, + Category=Category, + Priority=Priority, + Status=0, + CreatorUUID=UUIDObj(CreatorUUID).bytes, + AssigneeUUID=assignee_bytes, + ) + return WorkOrderManager._ToFlat(wo) + + @staticmethod + def Get(OrderUUID): + """按 UUID 查询工单(不存在返回 None)""" + try: + return WorkOrder.objects.get(OrderUUID=UUIDObj(OrderUUID).bytes) + except (WorkOrder.DoesNotExist, ValueError, AttributeError): + return None + + @staticmethod + def Update(OrderUUID, Data): + """更新工单(Status/AssigneeUUID/Resolution/Description/Priority,自动刷新 UpdateTime) + + Args: + Data: 变更字段 dict(键同模型字段名) + + Returns: + dict:更新后的工单记录;工单不存在返回 None + """ + wo = WorkOrderManager.Get(OrderUUID) + if wo is None: + return None + field_map = { + 'Status': 'Status', + 'AssigneeUUID': 'AssigneeUUID', + 'Resolution': 'Resolution', + 'Description': 'Description', + 'Priority': 'Priority', + } + for key in field_map: + if key in Data: + setattr(wo, field_map[key], Data[key]) + wo.UpdateTime = _timezone.now() + wo.save() + return WorkOrderManager._ToFlat(wo) + diff --git a/gvsdsdk/models.py b/gvsdsdk/models.py index 728466e8..a08d34c1 100644 --- a/gvsdsdk/models.py +++ b/gvsdsdk/models.py @@ -537,6 +537,97 @@ class UserRoleDepartment(QModel): verbose_name_plural = '用户-角色部门关联' +# === 消息表 === + +class Message(QModel): + """消息表——系统通知与站内信(映射 SAAS 主库 Message 表)""" + + MessageUUID = UUIDField(primary_key=True, db_column='MessageUUID', verbose_name='消息UUID') + SenderUUID = UUIDField(null=True, blank=True, db_column='SenderUUID', verbose_name='发送者UUID') + ReceiverUUID = UUIDField(db_index=True, db_column='ReceiverUUID', verbose_name='接收者UUID') + Title = models.CharField(max_length=256, db_column='Title', verbose_name='标题') + Content = models.TextField(default='', db_column='Content', verbose_name='内容') + MsgType = models.SmallIntegerField(default=0, db_column='MsgType', verbose_name='消息类型') + IsRead = models.SmallIntegerField(default=0, db_column='IsRead', verbose_name='已读') + RelatedUUID = models.CharField(max_length=64, default='', db_column='RelatedUUID', verbose_name='关联UUID') + RelatedType = models.CharField(max_length=32, default='', db_column='RelatedType', verbose_name='关联类型') + CreateTime = models.DateTimeField(default=_UtcNow, db_column='CreateTime', verbose_name='创建时间') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'Message' + verbose_name = '消息' + verbose_name_plural = '消息' + + def __str__(self): + return self.Title + + +# === 个人中心表 === + +class UserIDCard(QModel): + """用户身份证表——实名信息(映射 SAAS 主库 UserIDCard 表)""" + + UserUUID = UUIDField(primary_key=True, db_column='UserUUID', verbose_name='用户UUID') + Name = models.CharField(max_length=128, db_column='Name', verbose_name='姓名') + OldName = models.CharField(max_length=128, default='', db_column='OldName', verbose_name='曾用名') + Born = models.DateField(db_column='Born', verbose_name='出生日期') + Sex = models.CharField(max_length=10, db_column='Sex', verbose_name='性别') + Ethnic = models.CharField(max_length=20, db_column='Ethnic', verbose_name='民族') + IdentificationNumber = models.CharField(max_length=20, db_column='IdentificationNumber', verbose_name='身份证号') + StudentRegistrationNumber = models.CharField(max_length=30, default='', db_column='StudentRegistrationNumber', verbose_name='学籍号') + ChangeStudentStatus = models.JSONField(default=list, db_column='ChangeStudentStatus', verbose_name='学籍变动') + CorrespondenceAddress = models.JSONField(default=list, db_column='CorrespondenceAddress', verbose_name='通讯地址') + HeadImage = models.CharField(max_length=4096, default='', db_column='HeadImage', verbose_name='头像') + InformationAboutFamilyMembers = models.JSONField(default=dict, db_column='InformationAboutFamilyMembers', verbose_name='家庭成员信息') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'UserIDCard' + verbose_name = '用户身份证' + verbose_name_plural = '用户身份证' + + +class PersonalPage(QModel): + """个人主页表——用户展示页面(映射 SAAS 主库 PersonalPage 表)""" + + PersonalPageUserUUID = UUIDField(primary_key=True, db_column='PersonalPageUserUUID', verbose_name='用户UUID') + PersonalPageName = models.CharField(max_length=256, unique=True, db_column='PersonalPageName', verbose_name='主页名称') + Subtitle = models.CharField(max_length=256, default='', db_column='Subtitle', verbose_name='副标题') + BackgroundLink = models.CharField(max_length=256, default='', db_column='BackgroundLink', verbose_name='背景链接') + Classification = models.CharField(max_length=256, default='', db_column='Classification', verbose_name='分类') + Github = models.CharField(max_length=256, default='', db_column='Github', verbose_name='GitHub') + Wechat = models.CharField(max_length=256, default='', db_column='Wechat', verbose_name='微信') + QQ = models.CharField(max_length=256, default='', db_column='QQ', verbose_name='QQ') + SelfIntroduction = models.TextField(default='', db_column='SelfIntroduction', verbose_name='自我介绍') + ProjectExperience = models.CharField(max_length=256, default='', db_column='ProjectExperience', verbose_name='项目经验') + SatisfiedCustomers = models.CharField(max_length=256, default='', db_column='SatisfiedCustomers', verbose_name='满意客户') + ReceiveAward = models.CharField(max_length=256, default='', db_column='ReceiveAward', verbose_name='获奖') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'PersonalPage' + verbose_name = '个人主页' + verbose_name_plural = '个人主页' + + +class UserSettings(QModel): + """用户设置表——存储每位用户的个性化系统设置(映射 SAAS 主库 UserSettings 表)""" + + UserUUID = UUIDField(primary_key=True, db_column='UserUUID', verbose_name='用户UUID') + SettingsData = models.TextField(default='', db_column='SettingsData', verbose_name='设置数据') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'UserSettings' + verbose_name = '用户设置' + verbose_name_plural = '用户设置' + + # === 角色表 === class Role(QModel): @@ -900,3 +991,62 @@ class Position(QModel): return self.PositionName # (UserDept 表已废弃——员工部门归属已迁移到 UserRoleDepartment 关联表,不再用独立表) + + +# === 菜单表 === + +class Menu(QModel): + """菜单表——系统导航与功能入口(映射 SAAS 主库 Menu 表)""" + + MenuUUID = UUIDField(primary_key=True, db_column='MenuUUID', verbose_name='菜单UUID') + CompanyUUID = UUIDField(null=True, blank=True, db_column='CompanyUUID', verbose_name='公司UUID') + ParentMenuUUID = UUIDField(null=True, blank=True, db_column='ParentMenuUUID', verbose_name='上级菜单UUID') + MenuName = models.CharField(max_length=128, db_column='MenuName', verbose_name='菜单名称') + MenuTitle = models.CharField(max_length=128, db_column='MenuTitle', verbose_name='菜单标题') + MenuIcon = models.CharField(max_length=128, default='', db_column='MenuIcon', verbose_name='菜单图标') + MenuLink = models.CharField(max_length=256, default='', db_column='MenuLink', verbose_name='菜单链接') + MenuSort = models.SmallIntegerField(default=0, db_column='MenuSort', verbose_name='排序') + PermissionLevel = models.FloatField(default=1.0, db_column='PermissionLevel', verbose_name='权限级别') + PermissionAttr = models.CharField(max_length=256, default='', db_column='PermissionAttr', verbose_name='权限属性') + MenuStatus = models.SmallIntegerField(default=1, db_column='MenuStatus', verbose_name='菜单状态') + MenuType = models.SmallIntegerField(default=0, db_column='MenuType', verbose_name='菜单类型') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'Menu' + verbose_name = '菜单' + verbose_name_plural = '菜单' + + def __str__(self): + return self.MenuTitle or self.MenuName + + +# === 工单表 === + +class WorkOrder(QModel): + """工单表——任务与问题跟踪(映射 SAAS 主库 WorkOrder 表)""" + + OrderUUID = UUIDField(primary_key=True, db_column='OrderUUID', verbose_name='工单UUID') + Title = models.CharField(max_length=256, db_column='Title', verbose_name='标题') + Description = models.CharField(max_length=4096, default='', db_column='Description', verbose_name='描述') + Category = models.CharField(max_length=64, default='other', db_column='Category', verbose_name='分类') + Priority = models.SmallIntegerField(default=0, db_column='Priority', verbose_name='优先级') + Status = models.SmallIntegerField(default=0, db_column='Status', verbose_name='状态') + CreatorUUID = UUIDField(db_column='CreatorUUID', db_index=True, verbose_name='创建人UUID') + AssigneeUUID = UUIDField(null=True, blank=True, db_column='AssigneeUUID', verbose_name='指派人UUID') + Resolution = models.CharField(max_length=4096, default='', db_column='Resolution', verbose_name='解决方案') + CreateTime = models.DateTimeField(default=_UtcNow, db_column='CreateTime', verbose_name='创建时间') + UpdateTime = models.DateTimeField(null=True, blank=True, db_column='UpdateTime', verbose_name='更新时间') + CloseTime = models.DateTimeField(null=True, blank=True, db_column='CloseTime', verbose_name='关闭时间') + + class Meta: + managed = False + app_label = 'gvsdsdk' + db_table = 'WorkOrder' + ordering = ['-CreateTime'] + verbose_name = '工单' + verbose_name_plural = '工单' + + def __str__(self): + return self.Title diff --git a/gvsdsdk/order/manager.py b/gvsdsdk/order/manager.py index 7b9e5274..3fa36e35 100644 --- a/gvsdsdk/order/manager.py +++ b/gvsdsdk/order/manager.py @@ -28,6 +28,7 @@ SAAS 级订单编排器——子服务通过此管理器操作订单生命周期 """ import uuid as _uuid import logging +import datetime from decimal import Decimal from typing import Optional, List @@ -119,6 +120,44 @@ class OrderManager: records = list(qs[start:start + page_size]) return {'records': records, 'total': total, 'page': page, 'page_size': page_size} + @staticmethod + def GetStats(tenant_uuid=None, company_uuid=None, buyer_user_uuids=None): + """仪表盘订单统计——按租户/公司/买家多维度计数 + + Args: + tenant_uuid: 租户 UUID(None 不限制) + company_uuid: 公司 UUID(None 不限制) + buyer_user_uuids: 买家 UUID 集合(None 不限制;空集合返回全 0) + + Returns: + dict: TotalOrders/TodayOrders/WeekOrders/PendingOrders/ + PaidOrders/CompletedOrders + """ + now = _timezone.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = today_start - datetime.timedelta(days=today_start.weekday()) + + qs = Order.objects.all() + if tenant_uuid: + qs = qs.filter(TenantUUID=tenant_uuid) + if company_uuid: + qs = qs.filter(CompanyUUID=company_uuid) + if buyer_user_uuids is not None: + buyer_bytes = [OrderManager._to_bytes(u) for u in buyer_user_uuids] + buyer_bytes = [b for b in buyer_bytes if b] + qs = qs.filter(BuyerUserUUID__in=buyer_bytes) if buyer_bytes else qs.none() + + return { + 'TotalOrders': qs.count(), + 'TodayOrders': qs.filter(CreateTime__gte=today_start).count(), + 'WeekOrders': qs.filter(CreateTime__gte=week_start).count(), + 'PendingOrders': qs.filter(OrderStatus=Order.ORD_PENDING).count(), + 'PaidOrders': qs.filter( + OrderStatus__in=[Order.ORD_PAID, Order.ORD_PROCESSING, Order.ORD_COMPLETED] + ).count(), + 'CompletedOrders': qs.filter(OrderStatus=Order.ORD_COMPLETED).count(), + } + def GetOrderItems(self, order_uuid): """获取订单行项目列表""" order_uuid_bytes = self._to_bytes(order_uuid)