diff --git a/tools/ImportEhrmProfiles.py b/tools/ImportEhrmProfiles.py new file mode 100644 index 00000000..ec36f7cf --- /dev/null +++ b/tools/ImportEhrmProfiles.py @@ -0,0 +1,374 @@ +"""eHRM 员工档案导入:把「成员信息.xlsx」(入组问卷导出)写入 EmployeeProfile / EmployeeContact + +用法(在仓库根目录执行): + python tools/ImportEhrmProfiles.py # dry-run:只打印逐人字段草案,不写库 + python tools/ImportEhrmProfiles.py --write # 真正写库(事务内,任一条失败整体回滚) + python tools/ImportEhrmProfiles.py --xlsx=路径.xlsx --receipt=路径.txt + +幂等:已存在档案的员工自动跳过,可反复执行——问卷新增行后来人只需补 ROW_USER 一行再跑。 + +字段口径(与业务确认): +- 工作(问卷)→ 志愿工作(eHRM):程序员/画师/测试者 直通;运维/运营/其他 → 「其它」+ 原值进「其它志愿工作」 +- 年龄数字 → 区间桶(12~18 / 19~25 / 26~40 / 41~65 / 66以上) +- 政治面貌「中国共产主义青年团团员」→「共青团员」;「其他」的补充填空无落点,丢弃 +- 行业取「-」前缀(学校-学生 → 学校;IT/通信/电子/互联网-… → IT/通信/电子/互联网) +- 学历「高中/中专/技校」→ 高中;「初中及以下」→ 初中 +- 联系方式 9 类 → ContactType(1微信 2QQ 3邮箱 4企业微信 5X 6个人网站 7手机号 8Github 9电报) +- 设备:问卷的「备注」选项不算设备,落到「设备备注」 +- 「无 / 没有 / none now / Forget / 这是啥 / 崩了 / 114514」等填充词与带 `*` 的掩码值一律视为空 +- 生日若与提交时间同一天(明显乱填)→ 留空;入职日期、紧急联系人问卷没有 → 留空 +- 创建时间 = 问卷提交时间(北京时间 → UTC-0 存储) + +依赖:openpyxl(读 xlsx),Django 运行环境(用仓库根目录的 gvsds.settings) +""" +import os +import sys +import uuid +import datetime + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gvsds.settings') + +import django # noqa: E402 + +django.setup() + +import openpyxl # noqa: E402 +from django.db import transaction # noqa: E402 + +from gvsds.apps.core.models import User # noqa: E402 +from gvsds.apps.gerp.models import ( # noqa: E402 + Department, EmployeeContact, EmployeeProfile, UserRole, UserRoleDepartment, +) +from gvsdsdk.model_utils import UUIDObj # noqa: E402 + + +def Arg(prefix, default): + """从命令行取 `--前缀=值` 形式的参数""" + for item in sys.argv: + if item.startswith(prefix): + return item[len(prefix):] + return default + + +XLSX = Arg('--xlsx=', r'D:\Users\TermiNexus\Desktop\成员信息.xlsx') +RECEIPT = Arg('--receipt=', r'D:\Users\TermiNexus\Desktop\eHRM导入回执.txt') +WRITE = '--write' in sys.argv + +# EmployeeProfile 上的 UUID 列(写库时字符串 → BINARY(16)) +UUID_FIELDS = ('UserUUID', 'TenantUUID', 'CompanyUUID', 'DeptUUID') + +# 人员映射:问卷**行号**(含表头行1,数据自 行2 起)→ 系统账号。 +# 昵称能精确匹配的也显式登记,便于一眼核对;行13 是行6 的重复提交,置 None 跳过。 +ROW_USER = { + 2: 'Unknown', # ysj / 苏靖涵 + 3: 'felix/余凤', # 桂圆 / 周子宸 + 4: 'B-Smooth', # 屈陈月圆 + 5: '耀月', # 崔华炜 + 6: '希龙何', # 周聖然(行13 为其重复提交) + 7: '渊泽_YuanZe', # 刘先生 / 刘景珩 + 8: 'ShiZhongyan', # 时钟岩 + 9: '无风之地', # 无风之地Riel-Faimel / 张瑞健 + 10: '離快玡', # 離️快玡️ / 梁子轩 + 11: 'ShuRuMingZi', # 胡里奥 + 12: '僵君', # 韩荣祥 + 13: None, # 与行6 重复提交(希龙何 / 周聖然),跳过 + 14: '林书恒', # 123456 / 林书恒 + 15: 'CodeyZhang', # 张可迪 + 16: '悠然自得', # 李铭宇 + 17: '羊村哲学家', # 羊村某 / 林子龙 +} + +# 联系方式:问卷列名 → ContactType +CONTACT_COLUMNS = [ + ('您的联系方式:微信', 1), + ('您的联系方式:QQ', 2), + ('您的联系方式:邮箱', 3), + ('您的联系方式:企业微信账户', 4), + ('您的联系方式:X', 5), + ('您的联系方式:个人网站', 6), + ('您的联系方式:电话', 7), + ('您的联系方式:Github', 8), + ('您的联系方式:电报', 9), +] + +# 视为「没有填写」的填充词(仅用于联系方式等标识类字段,不套用到自由文本) +NOISE = {'无', '没有', 'none', 'none now', 'null', 'nan', 'no', '-', '/', '这是啥', + '不记得但有', 'forget', '忘了', '崩了', 'same wechat user', '114514'} +# 问卷「个人设备」里的非设备选项(备注是补充填空的开关) +DEVICE_NON_VALUES = {'备注'} +# 志愿工作:问卷值 → eHRM 值(不在前三项的,归「其它」并把原值写进补充) +WORK_DIRECT = {'程序员', '画师', '测试者'} +# 政治面貌映射 +POLITICAL_MAP = { + '群众': '群众', + '中国共产主义青年团团员': '共青团员', + '共青团员': '共青团员', + '中共党员': '中共党员', + '民主党派': '民主党派', + '其他': '其他', + '其它': '其他', +} +# 学历映射 +EDUCATION_MAP = { + '初中及以下': '初中', + '初中': '初中', + '高中/中专/技校': '高中', + '高中': '高中', + '中专': '中专', + '大专': '大专', + '本科': '本科', + '硕士': '硕士', + '博士': '博士', +} + + +def Text(value): + """取文本:None / 空白 → '';其余原样(自由文本字段用,保留「忘了」这类真实作答)""" + if value is None: + return '' + return str(value).strip() + + +def Clean(value): + """取标识类文本:额外剔除「无 / 没有 / Forget / 这是啥 / 114514」等填充词与带 `*` 的掩码值""" + text = Text(value) + if not text or text.lower() in NOISE or '*' in text or text.startswith('#'): + return '' + return text + + +def Bool(value): + """是/否 → True/False""" + return Text(value) == '是' + + +def AgeBucket(text): + """年龄数字 → 区间桶""" + if not text: + return '' + try: + age = int(float(text)) + except (TypeError, ValueError): + return text + if age <= 18: + return '12~18' + if age <= 25: + return '19~25' + if age <= 40: + return '26~40' + if age <= 65: + return '41~65' + return '66以上' + + +def Industry(text): + """行业取「-」前缀(问卷值是「大类-细分」)""" + return text.split('-')[0].strip() if text else '' + + +def WorkPair(row): + """问卷「工作」→ (志愿工作, 其它志愿工作)""" + job = Clean(row.get('工作')) + extra = Clean(row.get('工作:补充填空')) + if job in WORK_DIRECT: + return job, '' + if not job and not extra: + return '', '' + other = extra or job + # 原值本身就是「其他/其它」且没有补充说明时,补充栏再写一遍没有信息量 → 留空 + return '其它', ('' if not extra and other in ('其他', '其它') else other) + + +def ContactsOf(row): + """问卷联系方式列 → [(ContactType, 值)],按 CONTACT_COLUMNS 顺序(即 ContactSort)""" + result = [] + for column, ctype in CONTACT_COLUMNS: + value = Clean(row.get(column)) + if value: + result.append((ctype, value)) + return result + + +def DevicesOf(row): + """问卷「个人设备」→ (设备串, 设备备注):剔除「备注」这种非设备选项""" + raw = Clean(row.get('你所拥有的个人设备?')) + devices = [d.strip() for d in raw.split(',') if d.strip() and d.strip() not in DEVICE_NON_VALUES] + return ','.join(devices), Clean(row.get('你所拥有的个人设备?:备注-补充填空')) + + +def BirthdayOf(row, submit_time): + """生日:DateField 纯日期;与提交时间同一天视为乱填 → 留空""" + value = row.get('您的生日') + if not isinstance(value, datetime.datetime): + return None + if submit_time and value.date() == submit_time.date(): + return None + return value.date() + + +def SubmitTimeOf(row): + """问卷提交时间(北京时间)→ naive UTC""" + value = row.get('提交时间') + if not isinstance(value, datetime.datetime): + return None + return value - datetime.timedelta(hours=8) + + +def BuildProfile(row, urd, submit_time): + """按字段口径把一行问卷整理成 EmployeeProfile 的字段字典""" + work, work_other = WorkPair(row) + devices, device_remark = DevicesOf(row) + return { + 'UserUUID': urd['UserUUID'], + 'TenantUUID': urd['TenantUUID'], + 'CompanyUUID': urd['CompanyUUID'], + 'DeptUUID': urd['DeptUUID'], + 'EmployeeName': Text(row.get('姓名')), + 'Gender': Clean(row.get('性别')), + 'AgeRange': AgeBucket(Clean(row.get('请填写年龄'))), + 'Race': Clean(row.get('填写您的民族')), + 'PoliticalStatus': POLITICAL_MAP.get(Clean(row.get('政治面貌')), Clean(row.get('政治面貌'))), + 'Education': EDUCATION_MAP.get(Clean(row.get('请选择学历')), Clean(row.get('请选择学历'))), + 'Address': Text(row.get('您的地址?')).rstrip('/').strip(), + 'Birthday': BirthdayOf(row, submit_time), + 'Nickname': Text(row.get('网络常用昵称')), + 'VolunteerWish': Text(row.get('志愿')), + 'VolunteerWork': work, + 'VolunteerWorkOther': work_other, + 'Industry': Industry(Clean(row.get('您从事的行业是?'))), + 'Devices': devices, + 'DeviceRemark': device_remark, + 'AcceptDispatchDevice': Bool(row.get('你是否愿意使用工作室派发的工作设备?')), + 'AcceptRewardPunish': Bool(row.get('你是否愿意接受工作室的奖励或处罚?')), + 'ExpectedWorkHours': Text(row.get('你的工作时间是?')), + 'PhysicalIllness': Clean(row.get('你患有或疑似患有哪些躯体疾病或不可逆躯体改变?(三甲医院确认存在或显然存在)')), + 'MentalIllness': Clean(row.get('你患有或疑似患有哪些精神障碍(心理疾病)或长期精神改变?(三甲医院确认存在或显然存在)')), + 'CreateTime': submit_time or datetime.datetime.utcnow(), + } + + +def LoadWorkbook(): + """读问卷:{行号: {列名: 值}},空白行跳过""" + wb = openpyxl.load_workbook(XLSX, data_only=True) + ws = wb[wb.sheetnames[0]] + rows = list(ws.iter_rows(values_only=True)) + header = [('' if h is None else str(h).strip()) for h in rows[0]] + result = {} + for index, values in enumerate(rows[1:], start=2): + row = dict(zip(header, values)) + if any(v is not None and str(v).strip() for v in values): + result[index] = row + return result + + +def main(): + sheet = LoadWorkbook() + + # 用户与部门关联(一次查出,逐人取最早的一条「部门员工关联」) + users = {u.UserName: u for u in User.objects.all()} + ur_to_user = {str(UUIDObj(x['UserRoleUUID'])): str(UUIDObj(x['UserUUID'])) + for x in UserRole.objects.all().values('UserRoleUUID', 'UserUUID')} + dept_name = {str(UUIDObj(d['DeptUUID'])): d['DeptName'] + for d in Department.objects.all().values('DeptUUID', 'DeptName')} + first_urd = {} + for urd in UserRoleDepartment.objects.all().values( + 'UserRoleUUID', 'DeptUUID', 'CompanyUUID', 'TenantUUID', 'CreateTime'): + user_uuid = ur_to_user.get(str(UUIDObj(urd['UserRoleUUID']))) + if not user_uuid: + continue + current = first_urd.get(user_uuid) + if current is None or (urd['CreateTime'] and current['CreateTime'] and urd['CreateTime'] < current['CreateTime']): + first_urd[user_uuid] = urd + + existing = {str(UUIDObj(x['UserUUID'])) for x in EmployeeProfile.objects.all().values('UserUUID')} + + plan, skipped = [], [] + for index in sorted(sheet): + user_name = ROW_USER.get(index) + if not user_name: + skipped.append((index, '按约定跳过(重复提交行)')) + continue + user = users.get(user_name) + if user is None: + skipped.append((index, f'系统内没有账号 {user_name}')) + continue + user_uuid = str(UUIDObj(user.UserUUID)) + urd = first_urd.get(user_uuid) + if urd is None: + skipped.append((index, f'{user_name} 没有「部门员工关联」,无法建档案')) + continue + if user_uuid in existing: + skipped.append((index, f'{user_name} 已有档案,跳过')) + continue + fields = BuildProfile(sheet[index], { + 'UserUUID': user_uuid, + 'DeptUUID': str(UUIDObj(urd['DeptUUID'])), + 'CompanyUUID': str(UUIDObj(urd['CompanyUUID'])), + 'TenantUUID': str(UUIDObj(urd['TenantUUID'])) if urd['TenantUUID'] else None, + }, SubmitTimeOf(sheet[index])) + plan.append((index, user_name, dept_name.get(fields['DeptUUID'], ''), fields, ContactsOf(sheet[index]))) + + lines = ['===== eHRM 档案导入' + ('(写库)' if WRITE else '(dry-run)') + ' =====', ''] + for index, user_name, dept, fields, contacts in plan: + lines.append(f'--- 行{index} 账号={user_name} 部门={dept} ---') + for key in ('EmployeeName', 'Gender', 'AgeRange', 'Race', 'PoliticalStatus', 'Education', + 'Address', 'Birthday', 'Nickname', 'VolunteerWish', 'VolunteerWork', + 'VolunteerWorkOther', 'Industry', 'Devices', 'DeviceRemark', + 'AcceptDispatchDevice', 'AcceptRewardPunish', 'ExpectedWorkHours', + 'PhysicalIllness', 'MentalIllness', 'CreateTime'): + lines.append(f' {key} = {fields[key]}') + lines.append(f' 联系方式 = {contacts}') + lines.append('') + lines.append('===== 跳过 =====') + for index, reason in skipped: + lines.append(f' 行{index}: {reason}') + print('\n'.join(lines)) + + if not WRITE: + print('\n(dry-run 结束,未写库;确认后加 --write 重跑)') + return + + created = [] + with transaction.atomic(): + for index, user_name, dept, fields, contacts in plan: + create_data = {} + for key, value in fields.items(): + # UUID 字段:字符串 → BINARY(16)(与应用落库路径一致) + create_data[key] = uuid.UUID(value).bytes if key in UUID_FIELDS and value else value + create_data['EmployeeProfileUUID'] = uuid.uuid4().bytes + profile = EmployeeProfile.objects.create(**create_data) + for sort, (ctype, value) in enumerate(contacts): + EmployeeContact.objects.create( + EmployeeContactUUID=uuid.uuid4().bytes, + EmployeeProfileUUID=profile.EmployeeProfileUUID, + UserUUID=create_data['UserUUID'], + TenantUUID=create_data['TenantUUID'], + CompanyUUID=create_data['CompanyUUID'], + ContactType=ctype, + ContactValue=value, + ContactSort=sort, + CreateTime=create_data['CreateTime'], + ) + created.append((index, user_name, dept, len(contacts))) + + receipt = ['===== eHRM 档案导入回执 =====', + f'时间:{datetime.datetime.now():%Y-%m-%d %H:%M:%S}', + f'来源:{XLSX}', + f'新建档案:{len(created)} 份', ''] + for index, user_name, dept, count in created: + receipt.append(f' 行{index} {user_name}({dept})→ 档案 1 份 + 联系方式 {count} 条') + receipt.append('') + receipt.append('===== 跳过 =====') + for index, reason in skipped: + receipt.append(f' 行{index}: {reason}') + with open(RECEIPT, 'w', encoding='utf-8') as fp: + fp.write('\n'.join(receipt)) + + print(f'\n写库完成:新建 {len(created)} 份档案;回执已写入 {RECEIPT}') + + +if __name__ == '__main__': + main()