feat(gerp): 员工档案直接绑定「部门-员工」关系

档案表单原来要分别选「员工」(UserSearch) 与「部门」(DepartmentSearch),易出现员工与部门不匹配;现改为一个「部门-员工」选择框,直接绑定一条 UserRoleDepartment 关系,选中即同时确定员工与部门,表结构不变(仍写 UserUUID/DeptUUID/DeptName)。

新增 GET /API/FormAPI/DeptEmployeeSearch:数据源为 UserRoleDepartment,返回 UserRoleDepartmentUUID/DeptUUID/DeptName/UserUUID/UserName 与标签「部门 · 员工」;keyword 同时匹配用户名与部门名;显式 CompanyUUID 优先,否则用当前角色公司,无公司上下文返回空。EmployeeProfile 注入虚拟列 UserRoleDepartmentUUID(仅表单可见),表单内隐藏 UserUUID/DeptUUID/DeptName(列表仍展示);get_table 按 (UserUUID, DeptUUID) 反查关系回填 UUID 与标签保证编辑回显;add_record/modify_record 解析该 UUID 写三列(新增未选时提示「请选择部门-员工」,跨公司关系拒绝)。

顺带修复:/Modify/ 与 /ModifyItem/ 补错误字典透出(原来错误被当成功返回);add_record/modify_record 返回主键由 bytes repr 归一化为标准 UUID 字符串。
This commit is contained in:
TermiNexus committed 2026-09-30 10:39:30 +08:00
1 parent c5dd5ed43c
commit 6da812bb20
4 files changed
+285 -3

No files matched your search

+136 -2
View File
@@ -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)
+7 -1
View File
@@ -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,
+141
View File
@@ -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}})
+1
View File
@@ -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'),