agent-foundation/scripts/generate_employee_directory_sql.py
2026-06-12 09:56:34 +08:00

417 lines
22 KiB
Python
Raw 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.

import pathlib
import re
import pandas as pd
ROOT = pathlib.Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "src" / "main" / "resources" / "db" / "employee_directory.sql"
def clean(value):
if value is None:
return ""
return str(value).strip()
def normalize_phone(value):
return re.sub(r"\D", "", clean(value))
def sql(value):
value = clean(value)
if not value:
return "NULL"
return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'"
def split_paths(value):
value = clean(value)
if not value:
return []
return [part.strip() for part in re.split(r"[;;]", value) if part.strip()]
def infer_role(xuebang_position):
text = clean(xuebang_position)
if any(keyword in text for keyword in ["集团管理员", "总裁"]):
return {
"code": "GROUP_ADMIN",
"name": "集团管理员",
"level": 0,
"scope_type": "ALL_ORGANIZATION",
"source": "XUEBANG_POSITION_RULE",
}
if any(keyword in text for keyword in ["城市总监", "城市总", "运动馆负责人"]):
return {
"code": "VENUE_OWNER",
"name": "运动馆负责人",
"level": 1,
"scope_type": "MULTI_VENUE",
"source": "XUEBANG_POSITION_RULE",
}
if any(keyword in text for keyword in ["馆长", "教学主管"]):
return {
"code": "VENUE_MANAGER",
"name": "馆长/教学主管",
"level": 2,
"scope_type": "VENUE",
"source": "XUEBANG_POSITION_RULE",
}
if any(keyword in text for keyword in ["教练", "课程顾问", "学管师", "班主任"]):
return {
"code": "COACH",
"name": "教练/一线员工",
"level": 3,
"scope_type": "SELF_OR_ASSIGNED",
"source": "XUEBANG_POSITION_RULE",
}
return {
"code": "STAFF",
"name": "普通员工",
"level": 9,
"scope_type": "SELF",
"source": "DEFAULT_RULE",
}
def main():
xb = pd.read_excel(ROOT / "学邦通讯录.xlsx", dtype=str).fillna("")
wx = pd.read_excel(ROOT / "企业微信通讯录.xlsx", sheet_name="成员列表", header=9, dtype=str).fillna("")
xb_by_phone = {}
for _, row in xb.iterrows():
phone = normalize_phone(row.get("联系电话"))
if phone and phone not in xb_by_phone:
xb_by_phone[phone] = row
employees = []
matched_xb_accounts = set()
for _, row in wx.iterrows():
wecom_user_id = clean(row.get("账号"))
if not wecom_user_id:
continue
mobile = normalize_phone(row.get("手机"))
xb_match = xb_by_phone.get(mobile) if mobile else None
xuebang_account = clean(xb_match.get("登录帐号")) if xb_match is not None else ""
if xuebang_account:
matched_xb_accounts.add(xuebang_account)
position = clean(row.get("职务"))
xuebang_position = clean(xb_match.get("职位")) if xb_match is not None else ""
xuebang_manage_scope = clean(xb_match.get("管理权限")) if xb_match is not None else ""
wecom_department_path = clean(row.get("部门"))
role = infer_role(xuebang_position)
employees.append({
"name": clean(row.get("姓名")),
"mobile": mobile,
"gender": clean(row.get("性别")),
"position": xuebang_position or position,
"wecom_user_id": wecom_user_id,
"wecom_alias": clean(row.get("别名")),
"wecom_department_path": wecom_department_path,
"wecom_email": clean(row.get("邮箱")) or clean(row.get("企业邮箱")),
"wecom_active_status": clean(row.get("激活状态")),
"wecom_disabled_status": clean(row.get("禁用状态")),
"xuebang_account": xuebang_account,
"xuebang_status": clean(xb_match.get("状态")) if xb_match is not None else "",
"xuebang_employment_status": clean(xb_match.get("在职状态")) if xb_match is not None else "",
"xuebang_user_type": clean(xb_match.get("用户类型")) if xb_match is not None else "",
"xuebang_primary_org": clean(xb_match.get("归属架构")) if xb_match is not None else "",
"xuebang_manage_scope": xuebang_manage_scope,
"xuebang_important_user": clean(xb_match.get("重要用户")) if xb_match is not None else "",
"xuebang_position": xuebang_position,
"primary_role_code": role["code"],
"primary_role_name": role["name"],
"role_level": role["level"],
"data_scope_type": role["scope_type"],
"role_source": role["source"],
"match_method": "PHONE" if xb_match is not None else "UNMATCHED",
"source_flags": "WECOM,XUEBANG" if xb_match is not None else "WECOM",
})
for _, row in xb.iterrows():
xuebang_account = clean(row.get("登录帐号"))
if not xuebang_account or xuebang_account in matched_xb_accounts:
continue
xuebang_position = clean(row.get("职位"))
xuebang_manage_scope = clean(row.get("管理权限"))
role = infer_role(xuebang_position)
employees.append({
"name": clean(row.get("姓名")),
"mobile": normalize_phone(row.get("联系电话")),
"gender": clean(row.get("性别")),
"position": xuebang_position,
"wecom_user_id": "",
"wecom_alias": "",
"wecom_department_path": "",
"wecom_email": "",
"wecom_active_status": "",
"wecom_disabled_status": "",
"xuebang_account": xuebang_account,
"xuebang_status": clean(row.get("状态")),
"xuebang_employment_status": clean(row.get("在职状态")),
"xuebang_user_type": clean(row.get("用户类型")),
"xuebang_primary_org": clean(row.get("归属架构")),
"xuebang_manage_scope": xuebang_manage_scope,
"xuebang_important_user": clean(row.get("重要用户")),
"xuebang_position": xuebang_position,
"primary_role_code": role["code"],
"primary_role_name": role["name"],
"role_level": role["level"],
"data_scope_type": role["scope_type"],
"role_source": role["source"],
"match_method": "XUEBANG_ONLY",
"source_flags": "XUEBANG",
})
orgs = {}
def add_org(source, org_path):
org_path = clean(org_path)
if not org_path:
return None
parts = [part.strip() for part in org_path.split("/") if part.strip()] if "/" in org_path else [org_path]
current = []
parent_path = ""
for part in parts:
current.append(part)
full_path = "/".join(current)
key = (source, full_path)
if key not in orgs:
orgs[key] = {
"id": len(orgs) + 1,
"source": source,
"path": full_path,
"name": part,
"parent_path": parent_path,
"level": len(current),
}
parent_path = full_path
return orgs[(source, "/".join(parts))]["id"]
for employee in employees:
for path in split_paths(employee["wecom_department_path"]):
add_org("WECOM", path)
if employee["xuebang_primary_org"]:
add_org("XUEBANG", employee["xuebang_primary_org"])
lines = [
"CREATE DATABASE IF NOT EXISTS agent_foundation DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;",
"USE agent_foundation;",
"",
"DROP TABLE IF EXISTS employee_data_permission;",
"DROP TABLE IF EXISTS employee_org_relation;",
"DROP TABLE IF EXISTS employee_directory;",
"DROP TABLE IF EXISTS role_definition;",
"DROP TABLE IF EXISTS org_unit;",
"",
"""CREATE TABLE org_unit (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '组织节点主键',
source_system VARCHAR(32) NOT NULL COMMENT '来源系统:WECOM 企业微信,XUEBANG 学邦',
org_path VARCHAR(512) NOT NULL COMMENT '组织完整路径,企业微信使用 / 分隔,学邦使用原始架构名称',
org_name VARCHAR(128) NOT NULL COMMENT '当前层级组织名称',
parent_path VARCHAR(512) NULL COMMENT '父级组织完整路径,根节点为空',
level_no INT NOT NULL COMMENT '组织层级,从 1 开始',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_org_source_path (source_system, org_path),
KEY idx_org_parent (source_system, parent_path)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织架构表,统一保存企业微信部门和学邦架构';""",
"",
"""CREATE TABLE employee_directory (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '员工主键',
employee_name VARCHAR(128) NOT NULL COMMENT '员工姓名,优先取企业微信姓名,学邦独有员工取学邦姓名',
mobile VARCHAR(32) NULL COMMENT '手机号,已去除非数字字符,用于两套通讯录自动匹配',
gender VARCHAR(16) NULL COMMENT '性别',
position_name VARCHAR(255) NULL COMMENT '职位名称,企业微信员工优先取企业微信职务,学邦独有员工取学邦职位',
wecom_user_id VARCHAR(128) NULL COMMENT '企业微信账号/UserID,用于从回调 FromUserName 反查员工',
wecom_alias VARCHAR(128) NULL COMMENT '企业微信别名',
wecom_department_path VARCHAR(512) NULL COMMENT '企业微信部门路径,多个部门时保留原始分隔文本',
wecom_email VARCHAR(255) NULL COMMENT '企业微信邮箱或企业邮箱',
wecom_active_status VARCHAR(64) NULL COMMENT '企业微信激活状态',
wecom_disabled_status VARCHAR(64) NULL COMMENT '企业微信禁用状态',
xuebang_account VARCHAR(128) NULL COMMENT '学邦登录账号,用于访问学邦业务数据',
xuebang_status VARCHAR(64) NULL COMMENT '学邦账号状态',
xuebang_employment_status VARCHAR(64) NULL COMMENT '学邦在职状态',
xuebang_user_type VARCHAR(64) NULL COMMENT '学邦用户类型',
xuebang_primary_org VARCHAR(512) NULL COMMENT '学邦归属架构',
xuebang_manage_scope TEXT NULL COMMENT '学邦管理权限原始文本,作为后续数据权限范围依据',
xuebang_important_user VARCHAR(32) NULL COMMENT '学邦重要用户标记',
xuebang_position VARCHAR(255) NULL COMMENT '学邦职位',
primary_role_code VARCHAR(64) NOT NULL COMMENT '组织角色编码:GROUP_ADMIN、VENUE_OWNER、VENUE_MANAGER、COACH、STAFF',
primary_role_name VARCHAR(128) NOT NULL COMMENT '组织角色名称,例如运动馆负责人、馆长/教学主管、教练/一线员工',
role_level INT NOT NULL COMMENT '组织角色层级,数字越小权限越高:1 运动馆负责人,2 馆长,3 教练',
data_scope_type VARCHAR(64) NOT NULL COMMENT '默认数据范围类型:ALL_ORGANIZATION、MULTI_VENUE、VENUE、SELF_OR_ASSIGNED、SELF',
role_source VARCHAR(64) NOT NULL COMMENT '角色识别来源:XUEBANG_POSITION_RULE 学邦职位规则,DEFAULT_RULE 默认规则',
match_method VARCHAR(32) NOT NULL COMMENT '账号匹配方式:PHONE 手机号匹配,UNMATCHED 未匹配,XUEBANG_ONLY 仅学邦存在',
source_flags VARCHAR(64) NOT NULL COMMENT '员工来源标记:WECOM、XUEBANG 或 WECOM,XUEBANG',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_employee_wecom_user_id (wecom_user_id),
UNIQUE KEY uk_employee_xuebang_account (xuebang_account),
KEY idx_employee_mobile (mobile),
KEY idx_employee_name (employee_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工账号映射主表,保存企业微信账号和学邦账号对应关系';""",
"",
"""CREATE TABLE role_definition (
role_code VARCHAR(64) PRIMARY KEY COMMENT '角色编码',
role_name VARCHAR(128) NOT NULL COMMENT '角色名称',
role_level INT NOT NULL COMMENT '角色层级,数字越小权限越高',
data_scope_type VARCHAR(64) NOT NULL COMMENT '该角色默认数据范围类型',
description VARCHAR(512) NOT NULL COMMENT '角色权限说明'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织角色定义表,用于区分运动馆负责人、馆长、教练等权限层级';""",
"",
"""CREATE TABLE employee_org_relation (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '员工组织关系主键',
employee_id BIGINT NOT NULL COMMENT '员工 ID,关联 employee_directory.id',
org_unit_id BIGINT NOT NULL COMMENT '组织节点 ID,关联 org_unit.id',
source_system VARCHAR(32) NOT NULL COMMENT '来源系统:WECOM 企业微信,XUEBANG 学邦',
relation_type VARCHAR(64) NOT NULL COMMENT '关系类型:WECOM_DEPARTMENT 企业微信部门,XUEBANG_PRIMARY_ORG 学邦归属架构',
is_primary TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否主组织,1 是,0 否',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_employee_org_relation (employee_id, org_unit_id, relation_type),
KEY idx_employee_org_employee (employee_id),
KEY idx_employee_org_unit (org_unit_id),
CONSTRAINT fk_employee_org_employee FOREIGN KEY (employee_id) REFERENCES employee_directory(id),
CONSTRAINT fk_employee_org_org FOREIGN KEY (org_unit_id) REFERENCES org_unit(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工与组织架构关系表,支持后续按组织做数据权限';""",
"",
"""CREATE TABLE employee_data_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '数据权限主键',
employee_id BIGINT NOT NULL COMMENT '员工 ID,关联 employee_directory.id',
source_system VARCHAR(32) NOT NULL COMMENT '权限来源系统:WECOM 企业微信,XUEBANG 学邦',
permission_type VARCHAR(64) NOT NULL COMMENT '权限类型:ALL_ORGANIZATION_SCOPE 全部组织,WECOM_DEPARTMENT_SCOPE 企业微信部门范围,XUEBANG_MANAGE_SCOPE 学邦管理权限范围',
org_unit_id BIGINT NULL COMMENT '关联组织节点 ID;学邦管理权限原始文本无法拆成节点时为空',
scope_path VARCHAR(512) NOT NULL COMMENT '权限范围完整路径或原始管理权限文本',
scope_name VARCHAR(128) NOT NULL COMMENT '权限范围显示名称',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_employee_permission_scope (employee_id, source_system, permission_type, scope_path),
KEY idx_permission_employee (employee_id),
KEY idx_permission_org (org_unit_id),
CONSTRAINT fk_permission_employee FOREIGN KEY (employee_id) REFERENCES employee_directory(id),
CONSTRAINT fk_permission_org FOREIGN KEY (org_unit_id) REFERENCES org_unit(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工数据权限范围表,用于从企业微信账号定位学邦账号和可见组织范围';""",
"",
]
for org in sorted(orgs.values(), key=lambda item: item["id"]):
lines.append(
"INSERT INTO org_unit (id, source_system, org_path, org_name, parent_path, level_no) "
f"VALUES ({org['id']}, {sql(org['source'])}, {sql(org['path'])}, {sql(org['name'])}, {sql(org['parent_path'])}, {org['level']});"
)
role_defs = [
("GROUP_ADMIN", "集团管理员", 0, "ALL_ORGANIZATION", "集团级权限,可按学邦管理权限覆盖全部或大范围组织数据"),
("VENUE_OWNER", "运动馆负责人", 1, "MULTI_VENUE", "负责人层级,可查看其管理权限覆盖的多个运动馆或区域数据"),
("VENUE_MANAGER", "馆长/教学主管", 2, "VENUE", "场馆管理层级,默认查看所在场馆及下属教练相关数据"),
("COACH", "教练/一线员工", 3, "SELF_OR_ASSIGNED", "一线执行层级,默认查看本人或被分配学员/客户数据"),
("STAFF", "普通员工", 9, "SELF", "未命中明确岗位规则时使用,默认仅本人数据"),
]
lines.append("")
for role in role_defs:
lines.append(
"INSERT INTO role_definition (role_code, role_name, role_level, data_scope_type, description) "
f"VALUES ({sql(role[0])}, {sql(role[1])}, {role[2]}, {sql(role[3])}, {sql(role[4])});"
)
employee_cols = [
"employee_name", "mobile", "gender", "position_name", "wecom_user_id", "wecom_alias",
"wecom_department_path", "wecom_email", "wecom_active_status", "wecom_disabled_status",
"xuebang_account", "xuebang_status", "xuebang_employment_status", "xuebang_user_type",
"xuebang_primary_org", "xuebang_manage_scope", "xuebang_important_user", "xuebang_position",
"primary_role_code", "primary_role_name", "role_level", "data_scope_type", "role_source",
"match_method", "source_flags",
]
lines.append("")
for employee_id, employee in enumerate(employees, start=1):
values = [
employee["name"], employee["mobile"], employee["gender"], employee["position"],
employee["wecom_user_id"], employee["wecom_alias"], employee["wecom_department_path"],
employee["wecom_email"], employee["wecom_active_status"], employee["wecom_disabled_status"],
employee["xuebang_account"], employee["xuebang_status"], employee["xuebang_employment_status"],
employee["xuebang_user_type"], employee["xuebang_primary_org"], employee["xuebang_manage_scope"],
employee["xuebang_important_user"], employee["xuebang_position"], employee["primary_role_code"],
employee["primary_role_name"], employee["role_level"], employee["data_scope_type"], employee["role_source"], employee["match_method"],
employee["source_flags"],
]
lines.append(
f"INSERT INTO employee_directory (id, {', '.join(employee_cols)}) "
f"VALUES ({employee_id}, {', '.join(sql(value) for value in values)});"
)
relation_id = 1
permission_id = 1
lines.append("")
for employee_id, employee in enumerate(employees, start=1):
if employee["primary_role_code"] == "GROUP_ADMIN":
lines.append(
"INSERT INTO employee_data_permission (id, employee_id, source_system, permission_type, org_unit_id, scope_path, scope_name) "
f"VALUES ({permission_id}, {employee_id}, 'SYSTEM', 'ALL_ORGANIZATION_SCOPE', NULL, '*', '全部组织');"
)
permission_id += 1
for index, path in enumerate(split_paths(employee["wecom_department_path"])):
org_id = add_org("WECOM", path)
if org_id:
lines.append(
"INSERT INTO employee_org_relation (id, employee_id, org_unit_id, source_system, relation_type, is_primary) "
f"VALUES ({relation_id}, {employee_id}, {org_id}, 'WECOM', 'WECOM_DEPARTMENT', {1 if index == 0 else 0});"
)
relation_id += 1
lines.append(
"INSERT INTO employee_data_permission (id, employee_id, source_system, permission_type, org_unit_id, scope_path, scope_name) "
f"VALUES ({permission_id}, {employee_id}, 'WECOM', 'WECOM_DEPARTMENT_SCOPE', {org_id}, {sql(path)}, {sql(path.split('/')[-1])});"
)
permission_id += 1
if employee["xuebang_primary_org"]:
org_id = add_org("XUEBANG", employee["xuebang_primary_org"])
lines.append(
"INSERT INTO employee_org_relation (id, employee_id, org_unit_id, source_system, relation_type, is_primary) "
f"VALUES ({relation_id}, {employee_id}, {org_id}, 'XUEBANG', 'XUEBANG_PRIMARY_ORG', 1);"
)
relation_id += 1
if employee["xuebang_manage_scope"]:
scope = employee["xuebang_manage_scope"]
lines.append(
"INSERT INTO employee_data_permission (id, employee_id, source_system, permission_type, org_unit_id, scope_path, scope_name) "
f"VALUES ({permission_id}, {employee_id}, 'XUEBANG', 'XUEBANG_MANAGE_SCOPE', NULL, {sql(scope)}, {sql(scope)});"
)
permission_id += 1
lines.extend([
"",
"""CREATE OR REPLACE VIEW v_employee_account_mapping AS
SELECT id, employee_name, mobile, wecom_user_id, xuebang_account, xuebang_primary_org, xuebang_manage_scope,
primary_role_code, primary_role_name, role_level, data_scope_type, match_method, source_flags
FROM employee_directory;""",
"",
"""CREATE OR REPLACE VIEW v_wecom_data_permission AS
SELECT e.wecom_user_id, e.xuebang_account, e.employee_name, e.primary_role_code, e.primary_role_name,
e.role_level, e.data_scope_type, p.source_system, p.permission_type, p.scope_path, p.scope_name
FROM employee_directory e
JOIN employee_data_permission p ON p.employee_id = e.id
WHERE e.wecom_user_id IS NOT NULL;""",
"",
f"-- Summary: employees={len(employees)}, org_units={len(orgs)}, org_relations={relation_id - 1}, permissions={permission_id - 1}",
"",
])
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text("\n".join(lines), encoding="utf-8")
print(f"wrote {OUTPUT}")
print(f"employees={len(employees)} org_units={len(orgs)} org_relations={relation_id - 1} permissions={permission_id - 1}")
if __name__ == "__main__":
main()