diff --git a/gvsds/apps/gerp/form_service.py b/gvsds/apps/gerp/form_service.py index a6e02907..b82283c5 100644 --- a/gvsds/apps/gerp/form_service.py +++ b/gvsds/apps/gerp/form_service.py @@ -888,6 +888,10 @@ class FormService(FormServiceBase): col['editable'] = False col['hideInForm'] = True col['visible'] = False + # EmployeeProfile 直接绑「部门-员工」关系:员工/部门/部门名由虚拟列 + # UserRoleDepartmentUUID 一次选定,故三者在表单中隐藏(列表仍展示) + if table_name == 'EmployeeProfile' and pascal_name in ('UserUUID', 'DeptUUID', 'DeptName'): + col['hideInForm'] = True if table_name == 'EmployeeAssessment' and pascal_name == 'UserUUID': col['title'] = '员工' if table_name == 'EmployeeAssessment' and pascal_name == 'AssessorUUID': @@ -1054,6 +1058,29 @@ class FormService(FormServiceBase): 'multiValue': True, # 前端多选:值/回显为逗号分隔的多个职务 }) columns = virtual_cols + columns + # EmployeeProfile(员工档案)虚拟列"部门-员工":直接选一条用户-角色-部门关系 + # (UserRoleDepartment),选中即同时确定员工(UserUUID)与部门(DeptUUID/DeptName); + # 写库由 add_record / modify_record 解析该 UUID,列表仍展示真实列(员工/部门) + if table_name == 'EmployeeProfile': + columns = [{ + 'field': 'UserRoleDepartmentUUID', + 'title': '部门-员工', + 'titleEn': 'UserRoleDepartmentUUID', + 'type': 'entityPicker', + 'entityPicker': { + 'api': '/API/FormAPI/DeptEmployeeSearch', + 'labelField': 'DeptEmployeeLabel', + 'valueField': 'UserRoleDepartmentUUID', + 'placeholder': '搜索部门-员工(部门 · 姓名)', + }, + 'editable': True, + 'sortable': False, + 'filterable': False, + # 不设 required:编辑时历史档案若查不到对应关系(关系已被删)不应被前端拦住, + # 新增缺省校验由后端在 add_record 内做(提示更精确) + 'required': False, + 'visible': False, # 仅表单可见:列表已有"员工/部门"真实列 + }] + columns return columns except Exception: return [] @@ -1376,6 +1403,44 @@ class FormService(FormServiceBase): _pl = _urdp_map.get(_udk, []) r['PositionUUID'] = ','.join(_pl) r['PositionUUID_label'] = '、'.join(_pos_name_map.get(x, x) for x in _pl) + # EmployeeProfile(员工档案)"部门-员工"虚拟列回显:按 (UserUUID, DeptUUID) 反查 + # UserRoleDepartment 关系,注入 UserRoleDepartmentUUID 与展示标签,供编辑回显 + if table_name == 'EmployeeProfile': + from gvsdsdk.model_utils import UUIDObj as _UO3 + + def _ProfStr(v): + return str(_UO3(v)) if v else '' + + _prof_ur_ids = [] + for _u in {r.get('UserUUID') for r in records if r.get('UserUUID')}: + try: + _prof_ur_ids.extend(RoleManager.ListUserRoleUUIDs(_u)) + except Exception: + continue + if _prof_ur_ids: + _prof_ur_user = { + _ProfStr(_ur.UserRoleUUID): _ProfStr(_ur.UserUUID) + for _ur in RoleManager.GetUserRolesByUUIDs(_prof_ur_ids) + } + _prof_pair = {} + for _urd in RoleManager.GetUserRoleDepartmentsForUserRoles(_prof_ur_ids): + _prof_pair.setdefault( + (_prof_ur_user.get(_ProfStr(_urd.UserRoleUUID), ''), _ProfStr(_urd.DeptUUID)), + _urd, + ) + _prof_dept_names = _StrKeyMap(DepartmentManager.GetDepartmentNameMap( + [_urd.DeptUUID for _urd in _prof_pair.values() if _urd.DeptUUID] + )) + for r in records: + _urd = _prof_pair.get((_ProfStr(r.get('UserUUID')), _ProfStr(r.get('DeptUUID')))) + if _urd is None: + continue + r['UserRoleDepartmentUUID'] = _ProfStr(_urd.UserRoleDepartmentUUID) + _dn = _prof_dept_names.get(_ProfStr(_urd.DeptUUID), '') + _un = r.get('UserUUID_label') or '' + r['UserRoleDepartmentUUID_label'] = ( + f'{_dn} · {_un}' if (_dn or _un) else r['UserRoleDepartmentUUID'] + ) except Exception as e: import logging logging.error('[FormService] get_table error: table=%s, error=%s', table_name, e, exc_info=True) @@ -1383,6 +1448,42 @@ class FormService(FormServiceBase): records = [] return {'records': records, 'total': total, 'page': page, 'PageSize': page_size} + def _ResolveDeptEmployee(self, data): + """解析「部门-员工」选择器提交的 UserRoleDepartmentUUID(员工档案直接绑关系) + + 档案表不存"关系 UUID",只存员工(UserUUID)与部门(DeptUUID/DeptName); + 前端选择器提交的是 UserRoleDepartment 主键,此处解析成上述三列的值。 + + Returns: + (updates: dict | None, error: str | None) + updates = {'UserUUID': bytes, 'DeptUUID': bytes, 'DeptName': str}; + 未提交该字段时返回 (None, None),调用方保持原有赋值行为。 + """ + raw = data.get('UserRoleDepartmentUUID') or data.get('UserRoleDepartmentUUID_id') + if not raw: + return None, None + rows = RoleManager.GetUserRoleDepartmentsByUUIDs([raw]) + if not rows: + return None, '所选「部门-员工」关系不存在' + urd = rows[0] + urd_company = _to_uuid_bytes(getattr(urd, 'CompanyUUID', None)) + if self.current_company_uuid and urd_company and urd_company != self.current_company_uuid: + return None, '所选「部门-员工」关系不属于当前公司' + rels = RoleManager.GetUserRolesByUUIDs([getattr(urd, 'UserRoleUUID', None)]) + if not rels: + return None, '所选「部门-员工」关系的用户-角色关联不存在' + user_bytes = _to_uuid_bytes(getattr(rels[0], 'UserUUID', None)) + if not user_bytes: + return None, '所选「部门-员工」关系缺少员工信息' + dept_bytes = _to_uuid_bytes(getattr(urd, 'DeptUUID', None)) + dept_name = '' + if dept_bytes: + try: + dept_name = DepartmentManager.GetDepartmentNameMap([dept_bytes]).get(dept_bytes, '') + except Exception: + dept_name = '' + return {'UserUUID': user_bytes, 'DeptUUID': dept_bytes, 'DeptName': dept_name}, None + def add_record(self, data): table_name = data.get('Table', '') model = self.TABLE_MAP.get(table_name) @@ -1607,6 +1708,17 @@ class FormService(FormServiceBase): elif isinstance(_raw_pos, (list, tuple)): _raw_pos = list(_raw_pos) _urd_positions = list(_raw_pos or []) + # EmployeeProfile(员工档案)直接绑「部门-员工」关系:解析选择器提交的 + # UserRoleDepartmentUUID → UserUUID/DeptUUID/DeptName(表结构不变) + if table_name == 'EmployeeProfile': + _de_updates, _de_err = self._ResolveDeptEmployee(data) + if _de_err: + return {'error': _de_err, 'field': 'UserRoleDepartmentUUID'} + if _de_updates: + create_data.update(_de_updates) + elif not create_data.get('UserUUID'): + return {'error': '请选择「部门-员工」(员工档案须绑定一条部门-员工关系)', + 'field': 'UserRoleDepartmentUUID'} # CompanyInfo 唯一性:同一租户仅一条企业信息(epcreate 是"完善本企业"向导, # 防重复提交产生多条;修改走租户详情编辑) if table_name == 'CompanyInfo' and self.current_tenant_uuid: @@ -1743,7 +1855,14 @@ class FormService(FormServiceBase): f'新工单指派:{obj.Title}', f'您有一张新工单「{obj.Title}」待处理,请及时跟进。', ) - return {'PK': str(obj.pk) if obj.pk else None} + # 自定义 UUIDField 主键是 BINARY(16) bytes(或 UUIDObj),str() 会得到 "b'\\x..'" + # 这种非法串,统一归一化为标准 UUID 字符串供前端直接使用 + if not obj.pk: + return {'PK': None} + try: + return {'PK': UUIDObj(obj.pk).str} + except Exception: + return {'PK': str(obj.pk)} def _current_user_uuid_bytes(self): """当前操作者 UserUUID 的 BINARY(16) bytes(无法解析返回 None)""" @@ -1838,6 +1957,14 @@ class FormService(FormServiceBase): # 工单状态流转:保存修改前的 Status,用于流转判定(时间戳 + 通知) _wo_old_status = getattr(obj, 'Status', None) if table_name == 'WorkOrder' else None pascal_map = self._BuildFieldMap(model) + # EmployeeProfile(员工档案)直接绑「部门-员工」关系:解析选择器提交的 + # UserRoleDepartmentUUID → UserUUID/DeptUUID/DeptName(未提交/解析不出则保持原值) + if table_name == 'EmployeeProfile': + _de_updates, _de_err = self._ResolveDeptEmployee(data) + if _de_err: + return {'error': _de_err, 'field': 'UserRoleDepartmentUUID'} + if _de_updates: + data = {**data, **_de_updates} # UserRoleDepartment / UserRoleWorkgroup 职务多值化(身兼数职):PositionUUID 不在主表 # 字段(部门 0034 删列;工作组 0043 删列,职务复用公司员工关系子表), # 编辑时若提交了该字段则整体重写子表(未提交保持不动) @@ -1999,7 +2126,14 @@ class FormService(FormServiceBase): if table_name in self.AUDIT_TABLES: new_snapshot = self._get_record_snapshot(obj) self._record_audit(table_name, 'modify', target_obj=obj, old_data=old_snapshot, new_data=new_snapshot) - return {'PK': str(obj.pk) if obj.pk else None} + # 自定义 UUIDField 主键是 BINARY(16) bytes(或 UUIDObj),str() 会得到 "b'\\x..'" + # 这种非法串,统一归一化为标准 UUID 字符串供前端直接使用 + if not obj.pk: + return {'PK': None} + try: + return {'PK': UUIDObj(obj.pk).str} + except Exception: + return {'PK': str(obj.pk)} def modify_item(self, data): return self.modify_record(data) diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py index f4b20fae..1aecd579 100644 --- a/gvsds/apps/gerp/views/__init__.py +++ b/gvsds/apps/gerp/views/__init__.py @@ -82,7 +82,13 @@ from .asset import ( ) from .message import MessageCompatView from .menu import MenuListCompatView, MenuAdminListView, MenuSaveView, MenuDeleteView -from .form_api import FormAPICompatView, FormAPIView, CheckAdminView, WorkOrderStatsView +from .form_api import ( + FormAPICompatView, + FormAPIView, + CheckAdminView, + WorkOrderStatsView, + DeptEmployeeSearchView, +) from .doc import DocView, SerieView from .position import ( PositionListView, diff --git a/gvsds/apps/gerp/views/form_api.py b/gvsds/apps/gerp/views/form_api.py index 2799d555..bdd77430 100644 --- a/gvsds/apps/gerp/views/form_api.py +++ b/gvsds/apps/gerp/views/form_api.py @@ -1,5 +1,6 @@ import logging import uuid as _uuid +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from gvsdsdk.model_utils import UUIDObj from . import ELTAuthenticatedAPIView @@ -262,6 +263,8 @@ class FormAPICompatView(ELTAuthenticatedAPIView): result = service.modify_item(data) if result is None: return Response({'code': 1, 'msg': '修改失败,记录不存在'}, status=404) + if isinstance(result, dict) and 'error' in result: + return Response({'code': 1, 'msg': result['error'], 'field': result.get('field', '')}, status=400) return Response({'code': 0, 'msg': '修改成功', 'data': result}) elif '/Modify/' in path and table_name: access_error = _check_table_access(table_name, 'modify', request) @@ -274,6 +277,8 @@ class FormAPICompatView(ELTAuthenticatedAPIView): result = service.modify_record(data) if result is None: return Response({'code': 1, 'msg': '修改失败,记录不存在'}, status=404) + if isinstance(result, dict) and 'error' in result: + return Response({'code': 1, 'msg': result['error'], 'field': result.get('field', '')}, status=400) return Response({'code': 0, 'msg': '修改成功', 'data': result}) elif '/Delete/' in path and table_name: access_error = _check_table_access(table_name, 'delete', request) @@ -510,3 +515,139 @@ class WorkOrderStatsView(ELTAuthenticatedAPIView): 'categoryOptions': FormService.WORKORDER_CATEGORY_OPTIONS['Category'], } }) + + +def _ToUuidBytes(value): + """UUID 值归一化为 16 字节(无效/空返回 None)""" + if not value: + return None + try: + return UUIDObj(value).bytes + except Exception: + return None + + +def _ToUuidStr(value): + """UUID 值归一化为标准字符串(无效/空返回空串)""" + if not value: + return '' + try: + return UUIDObj(value).str + except Exception: + return str(value) + + +class DeptEmployeeSearchView(ELTAuthenticatedAPIView): + """「部门-员工」关系搜索——"部门-员工"选择框的数据源 + + GET /API/FormAPI/DeptEmployeeSearch?keyword=&CompanyUUID=&DeptUUID=&offset= + + 设计要点: + - 数据源是 UserRoleDepartment(部门-员工关系),而非"用户表 + 部门表"两张独立表: + 选择器里一项 = 一条部门-员工关系,选中即可同时确定员工与部门(及部门名)。 + - 返回字段:UserRoleDepartmentUUID / DeptUUID / DeptName / UserUUID / UserName / + DeptEmployeeLabel("部门名 · 用户名",选择器展示文本) + - 范围:显式传 CompanyUUID 时强制该公司(超管也不豁免);否则取当前角色公司; + 无公司上下文返回空列表(不退化成全表) + - keyword 同时匹配用户名与部门名,任一命中即保留(并集) + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + from gvsdsdk.auth.managers import AuthManager, DepartmentManager, RoleManager + + keyword = (request.query_params.get('keyword') or '').strip() + company_str = (request.query_params.get('CompanyUUID') or '').strip() + if company_str: + company_bytes = _ToUuidBytes(company_str) + else: + # 未显式指定公司:用当前角色公司(与表单服务同一来源) + _, company_bytes, _ = _get_current_context(request) + if not company_bytes: + return Response({'code': 0, 'data': {'list': [], 'has_more': False}}) + + dept_param = (request.query_params.get('DeptUUID') or '').strip() + qs = RoleManager.QueryUserRoleDepartmentsByCompany( + company_bytes, [dept_param] if dept_param else None, + ) + + if keyword: + from django.db.models import Q + # 部门名命中:收窄部门 + dept_bytes = [ + b for b in ( + _ToUuidBytes(d.DeptUUID) + for d in DepartmentManager.QueryByCompany(company_bytes).filter( + DeptName__icontains=keyword + ) + ) if b + ] + # 用户名命中:收窄到这些用户在本公司的"用户-角色对" + user_uuids, _role_uuids = RoleManager.MatchUserRoleIds(keyword) + ur_bytes = [] + if user_uuids: + ur_bytes = [ + b for b in ( + _ToUuidBytes(x) + for x in RoleManager.QueryUserRolesByCompany(company_bytes) + .filter(UserUUID__in=list(user_uuids)) + .values_list('UserRoleUUID', flat=True) + ) if b + ] + if dept_bytes and ur_bytes: + qs = qs.filter(Q(DeptUUID__in=dept_bytes) | Q(UserRoleUUID__in=ur_bytes)) + elif dept_bytes: + qs = qs.filter(DeptUUID__in=dept_bytes) + elif ur_bytes: + qs = qs.filter(UserRoleUUID__in=ur_bytes) + else: + qs = qs.none() + + offset = int(request.query_params.get('offset') or 0) + try: + total = qs.count() + except Exception: + total = 0 + rows = list(qs[offset:offset + 20]) + + # 批量解析:UserRoleUUID → UserUUID(UserRoleDepartment 只存"用户-角色对") + ur_objs = RoleManager.GetUserRolesByUUIDs([r.UserRoleUUID for r in rows if r.UserRoleUUID]) + ur_user_bytes = {} # UserRoleUUID(bytes) → UserUUID(bytes) + for ur in ur_objs: + ur_key = _ToUuidBytes(ur.UserRoleUUID) + user_key = _ToUuidBytes(ur.UserUUID) + if ur_key and user_key: + ur_user_bytes[ur_key] = user_key + user_name_map = {} + if ur_user_bytes: + try: + user_name_map = AuthManager.GetUserNames(list(set(ur_user_bytes.values()))) + except Exception: + user_name_map = {} + dept_name_map = {} + dept_keys = [_ToUuidBytes(r.DeptUUID) for r in rows if r.DeptUUID] + if dept_keys: + try: + dept_name_map = DepartmentManager.GetDepartmentNameMap(dept_keys) + except Exception: + dept_name_map = {} + + data = [] + for r in rows: + urd_str = _ToUuidStr(r.UserRoleDepartmentUUID) + dept_str = _ToUuidStr(r.DeptUUID) + user_bytes = ur_user_bytes.get(_ToUuidBytes(r.UserRoleUUID)) + user_str = _ToUuidStr(user_bytes) + user_name = user_name_map.get(user_bytes, '') if user_bytes else '' + dept_name = dept_name_map.get(_ToUuidBytes(r.DeptUUID), '') + label = f'{dept_name} · {user_name}' if (dept_name or user_name) else urd_str + data.append({ + 'UserRoleDepartmentUUID': urd_str, + 'DeptUUID': dept_str, + 'DeptName': dept_name, + 'UserUUID': user_str, + 'UserName': user_name, + 'DeptEmployeeLabel': label, + }) + return Response({'code': 0, 'data': {'list': data, 'has_more': total > offset + 20}}) diff --git a/gvsds/urls.py b/gvsds/urls.py index 89a8c46e..5b2fe03a 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -136,6 +136,7 @@ urlpatterns = [ path('API/FormAPI/TenantSearch', gerp_views.TenantViewSet.as_view({'get': 'search'}), name='form_tenant_search'), path('API/FormAPI/UserSearch', gerp_views.SiteUserManagementView.as_view(), name='form_user_search'), path('API/FormAPI/DepartmentSearch', gerp_views.DepartmentViewSet.as_view({'get': 'search'}), name='form_department_search'), + path('API/FormAPI/DeptEmployeeSearch', gerp_views.DeptEmployeeSearchView.as_view(), name='form_dept_employee_search'), path('API/FormAPI/PermissionSearch', gerp_views.PermissionViewSet.as_view({'get': 'search'}), name='form_permission_search'), path('API/FormAPI/PositionTemplateSearch', gerp_views.PositionTemplateViewSet.as_view({'get': 'search'}), name='form_position_template_search'), path('API/FormAPI/PositionSearch', gerp_views.PositionViewSet.as_view({'get': 'search'}), name='form_position_search'),