gongxiangp2/scripts/probe-report-db.ts
chenpei 7b0ee5efb4 feat: 人员架构统一到钉钉唯一值,接入上游组织架构动态同步
素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时
逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随
上游 HR 系统自动更新。

人员身份
- 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一
- users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号
- 移除钉钉登录的同名自动合并,同名冲突一律转人工核对
- 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权

组织架构
- 新增 OrganizationUnit,直接镜像上游 HR 部门树
- 不再从员工部门路径倒推,同名部门不会再被合并
- 上游已删除的部门下线而非物理删除

人员状态
- 新增规范化的 employmentState,取代对混杂原始值做关键词匹配
- 接入上游离职表,离职判定不再依赖中文关键词
- 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者

素材归属
- 归属变更纳入正式转移,记录最初上传者、转移流水与理由
- 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名
- 编辑素材的权限判定统一到 canManageMaterial
- 修复任务-详情上传把邮箱误存为作者名的问题

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:53:48 +08:00

151 lines
5.9 KiB
TypeScript
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.

/**
* 上游报表库探查 · 花名册与组织架构相关表
*
* 纯只读:只查 INFORMATION_SCHEMA 与少量样本行,不写入、不建表、不改结构。
*
* 凭据一律从环境变量读取,脚本内不含任何账号密码。
*
* 用法(凭据在命令行传入,不落盘):
* REPORT_DB_SERVER=... REPORT_DB_DATABASE=... REPORT_DB_USER=... REPORT_DB_PASSWORD=... \
* npx tsx scripts/probe-report-db.ts
*/
import mssql from 'mssql'
const required = ['REPORT_DB_SERVER', 'REPORT_DB_DATABASE', 'REPORT_DB_USER', 'REPORT_DB_PASSWORD']
const missing = required.filter((k) => !process.env[k])
if (missing.length > 0) {
console.error('缺少环境变量:' + missing.join(', '))
process.exit(1)
}
const config: mssql.config = {
server: process.env.REPORT_DB_SERVER as string,
database: process.env.REPORT_DB_DATABASE as string,
user: process.env.REPORT_DB_USER as string,
password: process.env.REPORT_DB_PASSWORD as string,
port: Number(process.env.REPORT_DB_PORT || 1433),
options: {
encrypt: process.env.REPORT_DB_ENCRYPT === 'true',
trustServerCertificate: process.env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false',
},
connectionTimeout: 20000,
requestTimeout: 60000,
pool: { max: 2, min: 0, idleTimeoutMillis: 10000 },
}
async function main() {
const pool = await mssql.connect(config)
try {
console.log('已连接:' + config.server + ' / ' + config.database)
// 一、rpt schema 下与人事、组织相关的表
const tables = await pool.request().query(`
SELECT TABLE_SCHEMA, TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'rpt'
AND (
TABLE_NAME LIKE '%hr[_]%' OR TABLE_NAME LIKE '%employee%' OR
TABLE_NAME LIKE '%flower%' OR TABLE_NAME LIKE '%group%' OR
TABLE_NAME LIKE '%dept%' OR TABLE_NAME LIKE '%staff%' OR
TABLE_NAME LIKE '%org%' OR TABLE_NAME LIKE '%user%'
)
ORDER BY TABLE_NAME
`)
console.log('\n===== 一、人事/组织相关表(rpt schema)=====')
tables.recordset.forEach((r: any) => console.log(' ' + r.TABLE_SCHEMA + '.' + r.TABLE_NAME))
console.log(' 共 ' + tables.recordset.length + ' 张')
// 二、rpt schema 表总数,用于判断整体规模
const total = await pool.request().query(`
SELECT COUNT(*) AS c FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'rpt'
`)
console.log('\n (rpt schema 共有 ' + total.recordset[0].c + ' 张表)')
// 三、重点表的列结构
const focus = [
'hr_groups',
'hr_employee_dimission',
'hr_flowerName_book',
'op_OperationGroup_Reflect',
]
for (const t of focus) {
const cols = await pool.request().input('t', t).query(`
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH AS LEN, IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'rpt' AND TABLE_NAME = @t
ORDER BY ORDINAL_POSITION
`)
console.log('\n===== rpt.' + t + ' =====')
if (cols.recordset.length === 0) {
console.log(' ⚠️ 表不存在')
continue
}
const cnt = await pool.request().query('SELECT COUNT(*) AS c FROM rpt.' + t)
console.log(' 行数:' + cnt.recordset[0].c)
cols.recordset.forEach((c: any) => {
const len = c.LEN ? '(' + (c.LEN === -1 ? 'max' : c.LEN) + ')' : ''
console.log(' ' + String(c.COLUMN_NAME).padEnd(28) + (c.DATA_TYPE + len).padEnd(18) + (c.IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL'))
})
}
// 四、组织树的根节点与层级结构
console.log('\n===== 组织树结构分析(hr_groups)=====')
const rootStats = await pool.request().query(`
SELECT
SUM(CASE WHEN parent_id IS NULL THEN 1 ELSE 0 END) AS null_parent,
SUM(CASE WHEN parent_id = 0 THEN 1 ELSE 0 END) AS zero_parent,
SUM(CASE WHEN parent_id = Id THEN 1 ELSE 0 END) AS self_parent,
COUNT(*) AS total
FROM rpt.hr_groups
`)
const rs = rootStats.recordset[0]
console.log(' 部门总数 ' + rs.total)
console.log(' parent_id 为 NULL ' + rs.null_parent)
console.log(' parent_id 为 0 ' + rs.zero_parent)
console.log(' parent_id 指向自己 ' + rs.self_parent)
const roots = await pool.request().query(`
SELECT TOP 10 Id, name, parent_id FROM rpt.hr_groups
WHERE parent_id IS NULL OR parent_id = 0 OR parent_id = Id
ORDER BY Id
`)
console.log('\n 根节点样本:')
roots.recordset.forEach((r: any) =>
console.log(' Id=' + String(r.Id).padEnd(12) + 'parent=' + String(r.parent_id ?? 'NULL').padEnd(10) + r.name)
)
// 孤儿节点:父部门在表中不存在
const orphans = await pool.request().query(`
SELECT COUNT(*) AS c FROM rpt.hr_groups g
WHERE g.parent_id IS NOT NULL AND g.parent_id <> 0 AND g.parent_id <> g.Id
AND NOT EXISTS (SELECT 1 FROM rpt.hr_groups p WHERE p.Id = g.parent_id)
`)
console.log('\n 孤儿节点(父部门不存在) ' + orphans.recordset[0].c)
// 五、在职状态口径
console.log('\n===== 在职状态口径 =====')
const active = await pool.request().query(`
SELECT active, COUNT(*) AS c FROM rpt.hr_employee GROUP BY active
`)
active.recordset.forEach((r: any) =>
console.log(' hr_employee.active = ' + String(r.active) + ' → ' + r.c + ' 人')
)
const status = await pool.request().query(`
SELECT [sys01-employeeStatus] AS s, COUNT(*) AS c
FROM rpt.hr_employee_register GROUP BY [sys01-employeeStatus] ORDER BY c DESC
`)
console.log('')
status.recordset.forEach((r: any) =>
console.log(' employeeStatus = ' + String(r.s ?? '(空)').padEnd(12) + '→ ' + r.c + ' 人')
)
} finally {
await pool.close()
}
}
main().catch((e) => {
console.error('探查失败:', e instanceof Error ? e.message : e)
process.exitCode = 1
})