feat(tools): 新增 eHRM 档案导入脚本 ImportEhrmProfiles

把「成员信息.xlsx」入组问卷按字段口径写入 EmployeeProfile / EmployeeContact:内置行号→账号映射与问卷→eHRM 的字段换算(年龄区间桶、政治面貌/学历/行业归一、其它志愿工作、设备与备注、联系方式类型),默认 dry-run,--write 时事务内写库;已建档员工自动跳过,可反复执行,后续问卷新增行只需补 ROW_USER 一行。
This commit is contained in:
TermiNexus committed 2026-10-01 20:27:10 +08:00
1 parent 7ca76ae53c
commit e4121b50ea
1 file changed
+374
+374
View File
@@ -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()