- 系统账号 UserName:Chndos -> Chindows(唯一登录名,后续须用 Chindows 登录) - 该员工档案(b2f7e978-90db-4d33-8ea4-74a897bd34b8)的网络常用昵称同步改为 Chindows - 导入脚本 ROW_USER 的第19行映射同步改为 Chindows(否则问卷行19 解析不到账号)
377 lines
16 KiB
Python
377 lines
16 KiB
Python
"""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()
|