Files
SMANAGE/tools/ImportEhrmProfiles.py
TermiNexus 8325fb0567 chore(ehrm): Chndos 改名 Chindows(账号 + 档案昵称)
- 系统账号 UserName:Chndos -> Chindows(唯一登录名,后续须用 Chindows 登录)
- 该员工档案(b2f7e978-90db-4d33-8ea4-74a897bd34b8)的网络常用昵称同步改为 Chindows
- 导入脚本 ROW_USER 的第19行映射同步改为 Chindows(否则问卷行19 解析不到账号)
2026-10-02 16:05:27 +08:00

377 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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: '羊村哲学家', # 羊村某 / 林子龙
18: 'Markstollr8651', # 裴雨辰(2026-10-02 提交)
19: 'Chindows', # 王浩然 / MikuGan(问卷昵称写 Chndos,2026-10-02 已改账号名为 Chindows)
}
# 联系方式:问卷列名 → 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()