feat: 人员架构统一到钉钉唯一值,接入上游组织架构动态同步

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
chenpei 2026-08-14 09:53:48 +08:00
parent 33f4d0b9b8
commit 7b0ee5efb4
21 changed files with 2665 additions and 108 deletions

View File

@ -86,6 +86,21 @@ export async function POST(request: NextRequest) {
);
}
// 作者名取数据库中的用户名,与其它上传入口保持一致。
// 此前直接读 session.user.username / realName,但登录态从未写入这两个字段,
// 实际一路兜底存成了邮箱,导致任务-详情素材的作者显示与其它类型不一致。
const uploader = await prisma.user.findUnique({
where: { id: (session.user as any).id },
select: { username: true },
});
if (!uploader) {
return NextResponse.json(
{ success: false, error: '当前登录账号不存在' },
{ status: 401 }
);
}
if (!taskDetailSourceType) {
return NextResponse.json(
{ success: false, error: '任务-详情必须选择出图方式(实拍、渲染或 AI)' },
@ -126,7 +141,7 @@ export async function POST(request: NextRequest) {
materialType: '任务-详情',
// taskStatus: 不写入,上传完成后再写入
previewUrl: '', // 临时占位,上传完成后更新
authorName: (session.user as any).username || (session.user as any).realName || session.user.email,
authorName: uploader.username,
userId: (session.user as any).id,
renderer: '', // 任务-详情不需要渲染器
isFullSet: false,

View File

@ -258,7 +258,9 @@ describe('performance survey position admin', () => {
})
const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0]
expect(JSON.stringify(query.where)).toContain('employmentStatus')
// 用在职状态过滤,而不是按"有没有素材库账号"过滤。
// 判定字段已从 employmentStatus(上游原样值,口径混杂)改为规范化的 employmentState。
expect(JSON.stringify(query.where)).toContain('employmentState')
expect(JSON.stringify(query.where)).not.toContain('materialUserId')
expect(JSON.stringify(query.where)).not.toContain('materialUserEmail')
})

View File

@ -459,10 +459,12 @@ describe('performance survey service policies', () => {
expect(result.permissions.canDeletePublicDashboardAssessment).toBe(false)
})
test('只有无邪账号获得公共看板单项调研删除权限', async () => {
test('公共看板调研删除权限按钉钉唯一值判定,同名账号不得越权', async () => {
// 钉钉唯一值命中本人:有权限
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
username: 'wuxie',
username: '无邪',
dingtalkUserId: '09061817188066',
})
const result = await getPerformanceSurveyDashboard(session, new URLSearchParams({
@ -472,16 +474,33 @@ describe('performance survey service policies', () => {
expect(result.permissions.canDeletePublicDashboardAssessment).toBe(true)
// 用户名同样读作「无邪」但钉钉唯一值不是本人:不得获得权限。
// 生产库里确实同时存在简体「无邪」与繁体「無邪」两个账号,
// 此前按用户名字符串比对,会把权限判给其中一个——而那未必是本人。
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
username: '无邪',
username: '無邪',
dingtalkUserId: 'ding-someone-else',
})
const productionAccountResult = await getPerformanceSurveyDashboard(session, new URLSearchParams({
const homoglyphResult = await getPerformanceSurveyDashboard(session, new URLSearchParams({
view: 'public',
month: '2026-07',
}))
expect(productionAccountResult.permissions.canDeletePublicDashboardAssessment).toBe(true)
expect(homoglyphResult.permissions.canDeletePublicDashboardAssessment).toBe(false)
// 未绑定钉钉的账号:无法验证身份,一律无权限
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
username: '无邪',
dingtalkUserId: null,
})
const unboundResult = await getPerformanceSurveyDashboard(session, new URLSearchParams({
view: 'public',
month: '2026-07',
}))
expect(unboundResult.permissions.canDeletePublicDashboardAssessment).toBe(false)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
...baseUser,
@ -506,6 +525,7 @@ describe('performance survey service policies', () => {
...baseUser,
username: '无邪',
realName: '无邪',
dingtalkUserId: '09061817188066',
})
transactionClient.performanceSurveyTarget.findMany.mockResolvedValue([{
id: 'target-public',

View File

@ -22,6 +22,7 @@ jest.mock('@/lib/prisma', () => ({
},
user: {
findFirst: jest.fn(),
findUnique: jest.fn(),
},
},
}))
@ -36,10 +37,11 @@ describe('product-package init route', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
// 贴近真实登录态:lib/auth.ts 的 session 回调只写入 id/email/role 等字段,
// 从不写入 username 或 realName。此前实现误读这两个字段,一路兜底把邮箱
// 存成了作者名,而旧测试恰好给了一个含 username 的理想化 session,把问题掩盖了。
user: {
id: 'user-1',
username: 'tester',
realName: '测试用户',
email: 'tester@example.com',
},
})
@ -51,6 +53,9 @@ describe('product-package init route', () => {
username: 'operator',
realName: '运营甲',
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
username: 'tester',
})
})
test('未填写出图方式时应拒绝初始化任务详情上传', async () => {
@ -124,8 +129,15 @@ describe('product-package init route', () => {
taskDetailSourceType: '渲染',
taskDetailRequesterUserId: 'requester-1',
taskDetailRequesterName: '运营甲',
// 作者名必须取自数据库中的用户名,而不是登录态里并不存在的字段,
// 更不能兜底成邮箱
authorName: 'tester',
}),
})
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
select: { username: true },
})
})
test('未选择需求运营人员时应返回 400', async () => {

View File

@ -5,6 +5,7 @@ import {
getDingtalkWorkNoticeAppAccessToken,
} from '@/lib/dingtalk-client'
import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync'
import { ensureBindingByDingtalkUserId } from '@/lib/employee-directory'
import {
getAuthSessionCookieCandidates,
getAuthSessionCookieName,
@ -247,9 +248,15 @@ function warmPerformanceSurveyProfile(user: {
username: user.username,
realName: user.realName,
displayName: user.realName || user.username,
}).catch((error) => {
console.warn('[performance-survey] 钉钉登录后预热人员档案失败:', error)
})
.then(() =>
// 档案就绪后按钉钉唯一值建立账号与权威人员的正式绑定,
// 取代此前"每次用到时按姓名/邮箱现猜"的软匹配。
ensureBindingByDingtalkUserId(user.id, user.dingtalkUserId),
)
.catch((error) => {
console.warn('[performance-survey] 钉钉登录后预热人员档案或建立绑定失败:', error)
})
}
export async function setDingtalkAuthenticatedSessionCookies(
@ -358,52 +365,52 @@ async function createOrUpdateUserSession(
let needCompleteProfile = false
if (!user) {
// 同名不再自动合并身份。钉钉唯一值是全项目唯一的人员判定依据,
// 仅凭姓名相同就把一个新的钉钉身份挂到已有账号上,等于让重名直接变成越权。
// 另一方面,若同名账号已绑定其他钉钉身份,用户名唯一约束也不允许再建同名账号。
// 两种情况都转人工核对,不在登录链路里自作主张。
const existingUserByName = await prisma.user.findUnique({
where: { username: displayName },
})
if (existingUserByName && !existingUserByName.dingtalkUserId) {
console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username)
user = await prisma.user.update({
where: { id: existingUserByName.id },
data: {
dingtalkUserId: userId,
loginType: 'dingtalk',
realName: displayName,
avatar: userInfo.avatar || existingUserByName.avatar,
phone: userInfo.mobile || existingUserByName.phone,
},
})
console.log('账号关联成功:', user.username)
} else {
console.log('创建新用户(使用钉钉姓名):', displayName)
let emailToUse = userInfo.email || generateRandomEmail()
const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
if (emailExists) {
emailToUse = generateRandomEmail()
}
user = await prisma.user.create({
data: {
username: displayName,
email: emailToUse,
password: await createDingtalkOnlyPasswordHash(),
realName: displayName,
avatar: userInfo.avatar || null,
phone: userInfo.mobile || null,
department: '',
role: 'USER',
isActive: false,
status: 'pending',
loginType: 'dingtalk',
dingtalkUserId: userId,
},
})
needCompleteProfile = true
console.log('用户创建成功:', user.id)
if (existingUserByName) {
console.warn(
`[dingtalk-login] 拒绝按姓名自动合并身份:username=${displayName},` +
`既有账号=${existingUserByName.id},` +
`既有钉钉ID=${existingUserByName.dingtalkUserId ?? '未绑定'}`,
)
throw new Error(
'系统中已存在同名账号,为避免身份混淆,请联系管理员确认并完成账号绑定后再登录。',
)
}
console.log('创建新用户(使用钉钉姓名):', displayName)
let emailToUse = userInfo.email || generateRandomEmail()
const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
if (emailExists) {
emailToUse = generateRandomEmail()
}
user = await prisma.user.create({
data: {
username: displayName,
email: emailToUse,
password: await createDingtalkOnlyPasswordHash(),
realName: displayName,
avatar: userInfo.avatar || null,
phone: userInfo.mobile || null,
department: '',
role: 'USER',
isActive: false,
status: 'pending',
loginType: 'dingtalk',
dingtalkUserId: userId,
},
})
needCompleteProfile = true
console.log('用户创建成功:', user.id)
} else {
console.log('更新现有用户:', user.username)
user = await prisma.user.update({
@ -526,52 +533,49 @@ async function resolveDingtalkUserForSession(
})
if (!user) {
// 与 OAuth 登录路径保持同一口径:不按姓名自动合并身份,同名一律转人工核对。
const existingUserByName = await prisma.user.findUnique({
where: { username: displayName },
})
if (existingUserByName && !existingUserByName.dingtalkUserId) {
console.log('找到同名用户,自动关联钉钉账号:', existingUserByName.username)
user = await prisma.user.update({
where: { id: existingUserByName.id },
data: {
dingtalkUserId: userId,
loginType: 'dingtalk',
realName: displayName,
avatar: userInfo.avatar || existingUserByName.avatar,
phone: userInfo.mobile || existingUserByName.phone,
},
})
console.log('账号关联成功:', user.username)
} else {
console.log('创建新用户(使用钉钉姓名):', displayName)
let emailToUse = userInfo.email || generateRandomEmail()
const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
if (emailExists) {
emailToUse = generateRandomEmail()
}
user = await prisma.user.create({
data: {
username: displayName,
email: emailToUse,
password: await createDingtalkOnlyPasswordHash(),
realName: displayName,
avatar: userInfo.avatar || null,
phone: userInfo.mobile || null,
department: '',
role: 'USER',
isActive: false,
status: 'pending',
loginType: 'dingtalk',
dingtalkUserId: userId,
},
})
warmPerformanceSurveyProfile(user)
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
if (existingUserByName) {
console.warn(
`[dingtalk-session] 拒绝按姓名自动合并身份:username=${displayName},` +
`既有账号=${existingUserByName.id},` +
`既有钉钉ID=${existingUserByName.dingtalkUserId ?? '未绑定'}`,
)
throw new Error(
'系统中已存在同名账号,为避免身份混淆,请联系管理员确认并完成账号绑定后再登录。',
)
}
console.log('创建新用户(使用钉钉姓名):', displayName)
let emailToUse = userInfo.email || generateRandomEmail()
const emailExists = await prisma.user.findUnique({ where: { email: emailToUse } })
if (emailExists) {
emailToUse = generateRandomEmail()
}
user = await prisma.user.create({
data: {
username: displayName,
email: emailToUse,
password: await createDingtalkOnlyPasswordHash(),
realName: displayName,
avatar: userInfo.avatar || null,
phone: userInfo.mobile || null,
department: '',
role: 'USER',
isActive: false,
status: 'pending',
loginType: 'dingtalk',
dingtalkUserId: userId,
},
})
warmPerformanceSurveyProfile(user)
throw new Error('您的账号尚未激活,请联系管理员激活后再登录')
} else {
console.log('更新现有用户:', user.username)
user = await prisma.user.update({

283
lib/employee-directory.ts Normal file
View File

@ -0,0 +1,283 @@
/**
* 通用人员目录服务
*
* 平台级的人员主数据入口。素材库、部门看板等功能统一通过本模块访问人员信息,
* 不再直接读取带 `PerformanceSurvey` 前缀的模型——那套表虽然是当前的物理载体,
* 但语义上已经是全平台共享的权威人员档案(数据源为 HR 报表库,非钉钉通讯录)。
*
* 身份关联原则(与《素材库店铺与多层权限改造实施计划》3.1 节一致):
* - 钉钉唯一值是唯一的自动匹配依据
* - 姓名、花名、邮箱只用于人工排错,不能自动建立权威绑定
* - 上游原始 JSON 只作审计快照,不参与运行时身份判断
*/
import { prisma } from '@/lib/prisma'
/** 绑定来源 */
export const BINDING_SOURCE = {
/** 钉钉登录时自动建立 */
DINGTALK_LOGIN: 'DINGTALK_LOGIN',
/** 管理员人工核验后建立 */
MANUAL_VERIFIED: 'MANUAL_VERIFIED',
/** 存量数据迁移建立 */
MIGRATION: 'MIGRATION',
} as const
export const BINDING_STATUS = {
ACTIVE: 'ACTIVE',
/** 历史绑定,保留可追溯性 */
RETIRED: 'RETIRED',
} as const
/** 统一的人员视图,屏蔽底层表结构 */
export interface EmployeeDirectoryEntry {
employeeProfileId: string
/** 关联的素材库账号;未开通账号的人员为 null */
userId: string | null
displayName: string
realName: string | null
flowerName: string | null
jobNumber: string | null
dingtalkUserId: string | null
departmentName: string | null
/** 部门全路径,如「品牌中心/品牌创意A部」 */
departmentPath: string | null
positionTitle: string | null
positionLevel: string | null
/** 职级类型,P(专业)/ M(管理) */
levelType: string | null
isLeadership: boolean
/** 直属上级 */
managerName: string | null
managerDingtalkUserId: string | null
employmentStatus: string | null
}
const PROFILE_SELECT = {
id: true,
displayName: true,
realName: true,
flowerName: true,
jobNumber: true,
dingtalkUserId: true,
departmentName: true,
departmentPath: true,
positionTitle: true,
positionLevel: true,
levelType: true,
isLeadership: true,
managerName: true,
managerDingtalkUserId: true,
employmentStatus: true,
} as const
type RawProfile = {
[K in keyof typeof PROFILE_SELECT]: any
}
function toEntry(profile: RawProfile, userId: string | null): EmployeeDirectoryEntry {
return {
employeeProfileId: profile.id,
userId,
displayName: profile.displayName,
realName: profile.realName,
flowerName: profile.flowerName,
jobNumber: profile.jobNumber,
dingtalkUserId: profile.dingtalkUserId,
departmentName: profile.departmentName,
departmentPath: profile.departmentPath,
positionTitle: profile.positionTitle,
positionLevel: profile.positionLevel,
levelType: profile.levelType,
isLeadership: Boolean(profile.isLeadership),
managerName: profile.managerName,
managerDingtalkUserId: profile.managerDingtalkUserId,
employmentStatus: profile.employmentStatus,
}
}
/**
* 按素材库账号查询其权威人员档案。
*
* 优先读取正式绑定关系;迁移期内若尚无绑定记录,回退到按钉钉唯一值查询
* (仅钉钉值,不使用姓名或邮箱)。绑定回填完成后可关闭回退。
*/
export async function getEmployeeByUserId(
userId: string,
options: { allowDingtalkFallback?: boolean } = {}
): Promise<EmployeeDirectoryEntry | null> {
const { allowDingtalkFallback = true } = options
const binding = await prisma.userEmployeeBinding.findFirst({
where: { userId, status: BINDING_STATUS.ACTIVE },
select: { employeeProfile: { select: PROFILE_SELECT } },
})
if (binding?.employeeProfile) {
return toEntry(binding.employeeProfile, userId)
}
if (!allowDingtalkFallback) return null
const user = await prisma.user.findUnique({
where: { id: userId },
select: { dingtalkUserId: true },
})
if (!user?.dingtalkUserId) return null
const profile = await prisma.performanceSurveyEmployeeProfile.findUnique({
where: { dingtalkUserId: user.dingtalkUserId },
select: PROFILE_SELECT,
})
return profile ? toEntry(profile, userId) : null
}
/** 按人员档案反查其素材库账号 ID,无账号返回 null */
export async function getUserIdByEmployeeId(employeeProfileId: string): Promise<string | null> {
const binding = await prisma.userEmployeeBinding.findFirst({
where: { employeeProfileId, status: BINDING_STATUS.ACTIVE },
select: { userId: true },
})
return binding?.userId ?? null
}
/**
* 建立账号与人员的绑定。
*
* 同一账号或同一人员已存在活跃绑定时,旧绑定会被置为历史状态而非删除,
* 保证可追溯。数据库层面的部分唯一索引是最终防线。
*/
export async function bindUserToEmployee(params: {
userId: string
employeeProfileId: string
source: (typeof BINDING_SOURCE)[keyof typeof BINDING_SOURCE]
note?: string
}): Promise<void> {
const { userId, employeeProfileId, source, note } = params
await prisma.$transaction(async (tx) => {
const existing = await tx.userEmployeeBinding.findFirst({
where: { userId, employeeProfileId, status: BINDING_STATUS.ACTIVE },
select: { id: true },
})
if (existing) return
// 退休掉该账号或该人员上的其它活跃绑定
await tx.userEmployeeBinding.updateMany({
where: {
status: BINDING_STATUS.ACTIVE,
OR: [{ userId }, { employeeProfileId }],
},
data: { status: BINDING_STATUS.RETIRED },
})
await tx.userEmployeeBinding.create({
data: {
userId,
employeeProfileId,
bindingSource: source,
status: BINDING_STATUS.ACTIVE,
verifiedAt: new Date(),
note: note ?? null,
},
})
})
}
/**
* 列出已开通素材库账号的人员。
*
* 这是平台的收窄口径(决策 D-A):只有具备素材库账号的人员参与权限与展示,
* 未开通账号的花名册人员不纳入。
*/
export async function listEmployeesWithAccount(options: {
departmentPathPrefix?: string
keyword?: string
take?: number
} = {}): Promise<EmployeeDirectoryEntry[]> {
const { departmentPathPrefix, keyword, take = 500 } = options
const bindings = await prisma.userEmployeeBinding.findMany({
where: {
status: BINDING_STATUS.ACTIVE,
employeeProfile: {
...(departmentPathPrefix
? { departmentPath: { startsWith: departmentPathPrefix } }
: {}),
...(keyword
? {
OR: [
{ displayName: { contains: keyword } },
{ realName: { contains: keyword } },
{ flowerName: { contains: keyword } },
{ jobNumber: { contains: keyword } },
],
}
: {}),
},
},
select: { userId: true, employeeProfile: { select: PROFILE_SELECT } },
take,
})
return bindings
.filter((b) => b.employeeProfile)
.map((b) => toEntry(b.employeeProfile, b.userId))
}
/**
* 钉钉登录后确保账号与权威人员之间存在绑定。
*
* 严格以钉钉唯一值匹配人员档案;档案尚未同步或绑定已存在时静默跳过。
* 供登录链路以 fire-and-forget 方式调用,任何情况下都不应阻塞登录本身。
*/
export async function ensureBindingByDingtalkUserId(
userId: string,
dingtalkUserId: string | null | undefined
): Promise<void> {
if (!userId || !dingtalkUserId) return
const existing = await prisma.userEmployeeBinding.findFirst({
where: { userId, status: BINDING_STATUS.ACTIVE },
select: { id: true },
})
if (existing) return
const profile = await prisma.performanceSurveyEmployeeProfile.findUnique({
where: { dingtalkUserId },
select: { id: true },
})
if (!profile) return
await bindUserToEmployee({
userId,
employeeProfileId: profile.id,
source: BINDING_SOURCE.DINGTALK_LOGIN,
})
}
/** 绑定覆盖情况统计,用于迁移对账与健康检查 */
export async function getBindingCoverage(): Promise<{
activeUsers: number
boundUsers: number
unboundUsers: number
coverageRate: number
}> {
const activeUsers = await prisma.user.count({
where: { isActive: true, archivedAt: null },
})
const boundUsers = await prisma.userEmployeeBinding.count({
where: {
status: BINDING_STATUS.ACTIVE,
user: { isActive: true, archivedAt: null },
},
})
return {
activeUsers,
boundUsers,
unboundUsers: activeUsers - boundUsers,
coverageRate: activeUsers > 0 ? Number(((boundUsers / activeUsers) * 100).toFixed(1)) : 0,
}
}

View File

@ -1,3 +1,4 @@
import { prisma } from '@/lib/prisma'
import {
isSuperAdmin,
isSystemAdmin,
@ -7,15 +8,49 @@ export interface MaterialAuthorizationUser {
id: string
role?: string | null
isSuperAdmin?: boolean | null
/** 所属部门 ID。未提供时会在需要判定托管权限的场景下按需查库 */
departmentId?: string | null
}
export interface MaterialAuthorizationTarget {
/** 素材 ID。当调用方未查询 custodyDepartmentId 时,用它补查 */
id?: string
userId: string
/**
* 素材托管部门。
* `undefined` 表示调用方没有查这个字段,`null` 表示确认未处于托管状态。
*/
custodyDepartmentId?: string | null
}
/**
* 解析用户所属部门 ID。
*
* 优先取 departmentId 外键;部分历史账号只填了部门名称字符串而没有回填
* departmentId,这里按名称兜底反查,避免这些人无法接手本部门托管的素材。
*/
async function resolveUserDepartmentId(userId: string): Promise<string | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { departmentId: true, department: true },
})
if (!user) return null
if (user.departmentId) return user.departmentId
const name = user.department?.trim()
if (!name) return null
const department = await prisma.department.findUnique({
where: { name },
select: { id: true },
})
return department?.id ?? null
}
/**
* 素材写操作的最小统一授权:
* - 素材所有者
* - 素材托管部门的成员(原归属人离职后由该部门接手维护)
* - 系统管理员(兼容 ADMIN / SYSTEM_ADMIN)
* - 超级管理员
*
@ -33,5 +68,25 @@ export async function canManageMaterial(
return true
}
// 多数调用方只 select 了 id / userId,没有查托管字段。此处按需补查,
// 避免因为某个调用点漏 select 就让托管权限静默失效——调用方若已带上
// custodyDepartmentId(包括显式的 null),这里不会产生任何额外查询。
let custodyDepartmentId = material.custodyDepartmentId
if (custodyDepartmentId === undefined && material.id) {
const row = await prisma.material.findUnique({
where: { id: material.id },
select: { custodyDepartmentId: true },
})
custodyDepartmentId = row?.custodyDepartmentId ?? null
}
if (custodyDepartmentId) {
const userDepartmentId =
user.departmentId ?? (await resolveUserDepartmentId(user.id))
if (userDepartmentId && userDepartmentId === custodyDepartmentId) {
return true
}
}
return isSuperAdmin(user.id)
}

348
lib/material-transfer.ts Normal file
View File

@ -0,0 +1,348 @@
/**
* 素材归属转移
*
* 此前系统里的"转移"是隐式发生的:在编辑素材时改掉作者,`Material.userId`
* 被直接覆盖,既不留转移流水,也查不到最初的上传者是谁。而 `MaterialTransfer`
* 表和 `Material` 上的 originalOwnerId / transferredAt / transferredBy / transferNote
* 四个字段虽然早已建好,却从未被写入过。
*
* 本模块把归属变更收敛成一件有据可查的事:
* - `MaterialTransfer` 记录每一次转移的流水(谁转给谁、谁操作的、为什么)
* - `Material` 上的四个字段保存当前状态(最初属于谁、最近一次何时被谁转移)
*
* originalOwnerId 只在首次转移时落定,之后不再变动,用于永久保留"最初是谁上传的"。
*/
import { prisma } from '@/lib/prisma'
/** 转移来源,便于区分是人工单条操作还是离职批量交接 */
export const TRANSFER_REASON = {
/** 编辑素材时更换作者 */
EDIT: '编辑素材时更换归属人',
/** 账号离职/归档后的批量交接 */
HANDOVER: '账号离职交接',
} as const
/**
* 计算随归属变更一并写入素材的转移字段。
*
* @param currentOwnerId 变更前的归属人
* @param existingOriginalOwnerId 素材上已有的最初所有者,为空表示这是第一次转移
*/
export function buildMaterialTransferFields(params: {
currentOwnerId: string
existingOriginalOwnerId?: string | null
operatorUserId: string
note?: string | null
}) {
const { currentOwnerId, existingOriginalOwnerId, operatorUserId, note } = params
return {
// 首次转移时记下最初的上传者,后续转移不再覆盖
originalOwnerId: existingOriginalOwnerId || currentOwnerId,
transferredAt: new Date(),
transferredBy: operatorUserId,
transferNote: note ?? null,
}
}
/** 写入一条转移流水。可传入事务客户端以保证与素材更新的原子性。 */
export async function recordMaterialTransfer(
client: { materialTransfer: { create: (args: any) => Promise<any> } },
params: {
materialId: string
fromUserId: string
toUserId: string
operatorUserId: string
note?: string | null
}
): Promise<void> {
await client.materialTransfer.create({
data: {
materialId: params.materialId,
fromUserId: params.fromUserId,
toUserId: params.toUserId,
transferredBy: params.operatorUserId,
transferNote: params.note ?? null,
},
})
}
export interface BulkTransferResult {
/** 转出账号名下的素材总数 */
total: number
/** 实际完成转移的条数 */
transferred: number
/** 失败条数 */
failed: number
/** 是否为预演,预演不写入任何数据 */
dryRun: boolean
failures: Array<{ materialId: string; reason: string }>
}
/**
* 把某个账号名下的素材整体转移给另一个账号。
*
* 主要面向离职交接:账号归档后无法登录,其名下素材无人可维护,而系统此前
* 只能在编辑页里一条一条改作者。
*
* 逐条使用事务,保证「素材归属 + 署名 + 转移流水」三者一致;单条失败不影响
* 其余素材,失败明细一并返回,便于重试与核对。
*/
export async function transferMaterialsBetweenUsers(params: {
fromUserId: string
toUserId: string
operatorUserId: string
note?: string | null
/** 仅转移指定素材类型,不传则转移全部 */
materialTypes?: string[]
/** 预演模式:只统计不写入 */
dryRun?: boolean
}): Promise<BulkTransferResult> {
const {
fromUserId,
toUserId,
operatorUserId,
note = TRANSFER_REASON.HANDOVER,
materialTypes,
dryRun = false,
} = params
if (fromUserId === toUserId) {
throw new Error('转出与接收账号不能是同一个人')
}
const toUser = await prisma.user.findUnique({
where: { id: toUserId },
select: { id: true, username: true, isActive: true, archivedAt: true },
})
if (!toUser) throw new Error('接收账号不存在')
if (toUser.archivedAt || !toUser.isActive) {
throw new Error('接收账号已归档或已停用,不能作为素材归属人')
}
const materials = await prisma.material.findMany({
where: {
userId: fromUserId,
deletedAt: null,
...(materialTypes?.length ? { materialType: { in: materialTypes } } : {}),
},
select: { id: true, userId: true, originalOwnerId: true },
})
const result: BulkTransferResult = {
total: materials.length,
transferred: 0,
failed: 0,
dryRun,
failures: [],
}
if (dryRun) return result
for (const material of materials) {
try {
await prisma.$transaction(async (tx) => {
await tx.material.update({
where: { id: material.id },
data: {
userId: toUserId,
// 署名与归属保持一致,避免出现"作者写着甲、归属却是乙"
authorName: toUser.username,
...buildMaterialTransferFields({
currentOwnerId: material.userId,
existingOriginalOwnerId: material.originalOwnerId,
operatorUserId,
note,
}),
},
})
await recordMaterialTransfer(tx, {
materialId: material.id,
fromUserId: material.userId,
toUserId,
operatorUserId,
note,
})
})
result.transferred += 1
} catch (error) {
result.failed += 1
result.failures.push({
materialId: material.id,
reason: error instanceof Error ? error.message : String(error),
})
}
}
return result
}
/** 统计某账号名下待交接的素材数量,用于离职处理前的预览 */
export async function countMaterialsByOwner(userId: string): Promise<number> {
return prisma.material.count({ where: { userId, deletedAt: null } })
}
/** 读取一条素材的完整转移历史,最近一次在前 */
export async function listMaterialTransferHistory(materialId: string) {
return prisma.materialTransfer.findMany({
where: { materialId },
orderBy: { transferredAt: 'desc' },
select: {
id: true,
transferredAt: true,
transferNote: true,
fromUser: { select: { id: true, username: true, realName: true } },
toUser: { select: { id: true, username: true, realName: true } },
operator: { select: { id: true, username: true, realName: true } },
},
})
}
// ============================================================
// 部门托管
// ============================================================
export const CUSTODY_REASON = {
RESIGNATION: '原归属人离职,素材由所在部门接管',
} as const
export interface CustodyResult {
/** 待托管素材数 */
total: number
/** 实际完成托管数 */
custodied: number
departmentName: string | null
dryRun: boolean
}
/**
* 把某个账号名下的素材托管给部门。
*
* 与 `transferMaterialsBetweenUsers` 的本质区别:**托管不转移归属人,也不改署名**。
* `userId` 与 `authorName` 保持原样——素材是谁做的,永远记在谁名下;变的只是
* 「现在谁负责维护它」。人离职后,其所在部门的成员即可接手。
*
* 不指定部门时自动落到该账号所属部门。已处于托管状态的素材会被跳过。
*/
export async function custodyMaterialsToDepartment(params: {
fromUserId: string
/** 不指定时自动取该账号所属部门 */
departmentId?: string
reason?: string
dryRun?: boolean
}): Promise<CustodyResult> {
const { fromUserId, reason = CUSTODY_REASON.RESIGNATION, dryRun = false } = params
const user = await prisma.user.findUnique({
where: { id: fromUserId },
select: { username: true, departmentId: true, department: true },
})
if (!user) throw new Error('账号不存在')
// 优先用显式指定的部门,其次是账号的部门外键,最后按部门名称兜底反查
let departmentId = params.departmentId ?? user.departmentId ?? null
if (!departmentId && user.department?.trim()) {
const matched = await prisma.department.findUnique({
where: { name: user.department.trim() },
select: { id: true },
})
departmentId = matched?.id ?? null
}
if (!departmentId) {
throw new Error(
`账号「${user.username}」没有可识别的所属部门,无法自动托管,请显式指定部门`,
)
}
const department = await prisma.department.findUnique({
where: { id: departmentId },
select: { id: true, name: true },
})
if (!department) throw new Error('托管部门不存在')
const where = {
userId: fromUserId,
deletedAt: null,
custodyDepartmentId: null,
}
const total = await prisma.material.count({ where })
if (dryRun) {
return { total, custodied: 0, departmentName: department.name, dryRun: true }
}
const updated = await prisma.material.updateMany({
where,
data: {
custodyDepartmentId: department.id,
custodyAt: new Date(),
custodyReason: reason,
},
})
return {
total,
custodied: updated.count,
departmentName: department.name,
dryRun: false,
}
}
/**
* 把所有已归档(离职)账号名下尚未托管的素材,批量交由各自部门接管。
*
* 逐个账号处理,某个账号失败(例如识别不出部门)不影响其余账号。
*/
export async function custodyMaterialsOfArchivedUsers(params: {
reason?: string
dryRun?: boolean
} = {}): Promise<{
accounts: number
totalMaterials: number
custodied: number
details: Array<{ username: string; department: string | null; count: number }>
skipped: Array<{ username: string; count: number; reason: string }>
}> {
const { reason = CUSTODY_REASON.RESIGNATION, dryRun = false } = params
const archived = await prisma.user.findMany({
where: { archivedAt: { not: null } },
select: { id: true, username: true },
})
const details: Array<{ username: string; department: string | null; count: number }> = []
const skipped: Array<{ username: string; count: number; reason: string }> = []
let totalMaterials = 0
let custodied = 0
for (const user of archived) {
const pending = await prisma.material.count({
where: { userId: user.id, deletedAt: null, custodyDepartmentId: null },
})
if (pending === 0) continue
totalMaterials += pending
try {
const result = await custodyMaterialsToDepartment({
fromUserId: user.id,
reason,
dryRun,
})
custodied += result.custodied
details.push({
username: user.username,
department: result.departmentName,
count: dryRun ? result.total : result.custodied,
})
} catch (error) {
skipped.push({
username: user.username,
count: pending,
reason: error instanceof Error ? error.message : String(error),
})
}
}
return { accounts: details.length, totalMaterials, custodied, details, skipped }
}

View File

@ -21,7 +21,7 @@ import {
canManuallyReplacePreview,
supportsIsFullSet,
} from '@/lib/material-type-options'
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
import { canManageMaterial } from '@/lib/material-authorization'
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
@ -30,6 +30,11 @@ import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
} from '@/lib/dingtalk-approval-no'
import {
TRANSFER_REASON,
buildMaterialTransferFields,
recordMaterialTransfer,
} from '@/lib/material-transfer'
export interface MaterialUpdateInput {
productModel: string
@ -42,6 +47,8 @@ export interface MaterialUpdateInput {
renderer?: string | null
authorName: string
userId?: string | null
/** 更换归属人时的转移理由,留空则记为默认的编辑转移 */
transferNote?: string | null
taskNo?: string | null
dingtalkApprovalNo?: string | null
taskDetailSourceType?: string | null
@ -121,10 +128,14 @@ async function ensureEditableMaterial(
return { error: '素材不存在或已被删除', status: 404 as const }
}
const hasSuperAdminPermission = await isSuperAdmin(operatorUserId)
const hasSystemAdminRole = isSystemAdmin(operatorRole)
// 统一走 canManageMaterial,与资产、SKU、白底图等写操作保持同一套判定,
// 托管给部门的素材由此自动对该部门成员开放维护权限。
const canManage = await canManageMaterial(
{ id: operatorUserId, role: operatorRole },
material,
)
if (material.userId !== operatorUserId && !hasSystemAdminRole && !hasSuperAdminPermission) {
if (!canManage) {
return { error: '您没有权限编辑此素材', status: 403 as const }
}
@ -365,8 +376,22 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
: false,
}
if (nextUserId && nextUserId !== material.userId) {
// 更换归属人按正式转移处理:在素材上落下最初上传者与本次转移信息,
// 让"改作者"这个动作不再是一次无据可查的静默覆盖。
const isOwnershipTransfer = Boolean(nextUserId && nextUserId !== material.userId)
const transferNote = normalizeOptionalString(input.transferNote) || TRANSFER_REASON.EDIT
if (isOwnershipTransfer && nextUserId) {
updateData.userId = nextUserId
Object.assign(
updateData,
buildMaterialTransferFields({
currentOwnerId: material.userId,
existingOriginalOwnerId: material.originalOwnerId,
operatorUserId,
note: transferNote,
}),
)
}
const uploadedPreviewUrl = previewImage && previewImage.size > 0 && previewUrl !== material.previewUrl
@ -411,6 +436,21 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
})
}
if (isOwnershipTransfer && nextUserId) {
// 归属变更已随素材更新一并落库。流水写入失败不回滚归属本身——素材上的
// originalOwnerId / transferredAt / transferredBy / transferNote 已完整记下了
// 这次转移的事实,缺的只是流水表里的一行,可事后补录。
await recordMaterialTransfer(prisma, {
materialId,
fromUserId: material.userId,
toUserId: nextUserId,
operatorUserId,
note: transferNote,
}).catch((error) => {
console.error('[material-update] 素材转移流水写入失败,归属变更已生效:', error)
})
}
const changes = buildMaterialChanges(material, updateData)
if (Object.keys(changes).length > 0) {
await createMaterialLog({

659
lib/organization-sync.ts Normal file
View File

@ -0,0 +1,659 @@
/**
* 组织架构与在职状态同步
*
* 直接镜像上游 HR 系统(rpt schema),取代此前两处靠猜的做法:
*
* 1. 组织架构——此前部门树是遍历已同步员工的 departmentPath 字符串现场拼出来的,
* 因此只能看到"有人的部门",同名部门会被合并成一行,父子归属取决于谁先被遍历到。
* 上游 hr_groups 实际是一棵完整单根树(单一根节点、零孤儿节点),直接同步即可。
*
* 2. 在职状态——上游口径混杂:数字码(2 试用 / 3 正式 / 5 已离职)、中文(正式/离职/试用)
* 与空值并存。此前用「离职/停用/退休」做关键词包含匹配,对数字码和空值完全失效,
* 已核实 216 条已离职档案中有 215 条被判定为在职。现改为接入离职表 + 状态码,
* 结果写入规范化的 employmentState 字段,原始值保留作审计。
*
* 本模块独立于既有的 performance-survey-report-sync,不改动那条链路。
*/
import mssql from 'mssql'
import { prisma } from '@/lib/prisma'
const GROUP_TABLE = {
schema: process.env.REPORT_DB_GROUP_SCHEMA || 'rpt',
table: process.env.REPORT_DB_GROUP_TABLE || 'hr_groups',
}
const DIMISSION_TABLE = {
schema: process.env.REPORT_DB_DIMISSION_SCHEMA || 'rpt',
table: process.env.REPORT_DB_DIMISSION_TABLE || 'hr_employee_dimission',
}
const REGISTER_TABLE = {
schema: process.env.REPORT_DB_REGISTER_SCHEMA || 'rpt',
table: process.env.REPORT_DB_REGISTER_TABLE || 'hr_employee_register',
}
/** 上游员工状态码。5 已离职,2 试用、3 正式均视为在职 */
const STATUS_CODE_RESIGNED = 5
const STATUS_CODE_ACTIVE = [2, 3]
export const EMPLOYMENT_STATE = {
ACTIVE: 'ACTIVE',
RESIGNED: 'RESIGNED',
UNKNOWN: 'UNKNOWN',
} as const
function quoteIdentifier(value: string) {
return '[' + String(value).replace(/]/g, ']]') + ']'
}
function tableRef(t: { schema: string; table: string }) {
return quoteIdentifier(t.schema) + '.' + quoteIdentifier(t.table)
}
export function isReportDbConfigured() {
return Boolean(
process.env.REPORT_DB_SERVER &&
process.env.REPORT_DB_DATABASE &&
process.env.REPORT_DB_USER &&
process.env.REPORT_DB_PASSWORD,
)
}
function buildConfig() {
return {
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: 120000,
pool: { max: 4, min: 0, idleTimeoutMillis: 30000 },
}
}
/** 上游 bigint 时间戳(秒或毫秒均可能)转 Date */
function toDate(value: unknown): Date | null {
if (value === null || value === undefined) return null
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return null
const ms = n > 1e12 ? n : n * 1000
const d = new Date(ms)
return Number.isNaN(d.getTime()) ? null : d
}
function clean(value: unknown): string | null {
if (value === null || value === undefined) return null
const s = String(value).trim()
return s === '' ? null : s
}
// ============================================================
// 一、组织架构同步
// ============================================================
export interface OrganizationSyncResult {
fetched: number
created: number
updated: number
deactivated: number
rootName: string | null
maxDepth: number
dryRun: boolean
}
interface RawGroup {
Id: number
parent_id: number | null
name: string | null
}
/**
* 在内存中把上游扁平部门列表整理成树,算出每个节点的全路径与层级。
*
* 采用逐层展开而非递归,避免异常数据造成的深递归;父节点缺失或成环的节点
* 会留在 unresolved 中,由调用方决定如何处理,不会静默丢失。
*/
function buildTree(rows: RawGroup[]) {
const byId = new Map<number, RawGroup>()
rows.forEach((r) => byId.set(r.Id, r))
const resolved = new Map<number, { path: string; level: number }>()
const roots = rows.filter((r) => r.parent_id === null || r.parent_id === 0 || r.parent_id === r.Id)
const displayName = (r: RawGroup) => clean(r.name) ?? `部门#${r.Id}`
const queue: RawGroup[] = []
roots.forEach((r) => {
resolved.set(r.Id, { path: displayName(r), level: 1 })
queue.push(r)
})
const childrenOf = new Map<number, RawGroup[]>()
rows.forEach((r) => {
if (r.parent_id === null || r.parent_id === 0 || r.parent_id === r.Id) return
const list = childrenOf.get(r.parent_id) ?? []
list.push(r)
childrenOf.set(r.parent_id, list)
})
while (queue.length > 0) {
const current = queue.shift() as RawGroup
const parentInfo = resolved.get(current.Id)
if (!parentInfo) continue
for (const child of childrenOf.get(current.Id) ?? []) {
if (resolved.has(child.Id)) continue // 防成环
resolved.set(child.Id, {
path: parentInfo.path + '/' + displayName(child),
level: parentInfo.level + 1,
})
queue.push(child)
}
}
const unresolved = rows.filter((r) => !resolved.has(r.Id))
return { resolved, unresolved, roots, byId }
}
/**
* 全量同步组织架构。
*
* 分两阶段写入:先落所有节点(此时不设 parentId),再回填父子关系——
* 因为父节点的本地主键要等它自己写完才存在。
* 上游已不存在的部门标记为 isActive=false 而非物理删除,避免历史引用断裂。
*/
export async function syncOrganizationUnits(options: { dryRun?: boolean } = {}): Promise<OrganizationSyncResult> {
const { dryRun = false } = options
if (!isReportDbConfigured()) {
throw new Error('未配置上游报表库(REPORT_DB_*),无法同步组织架构')
}
const pool = await mssql.connect(buildConfig())
let rows: RawGroup[]
try {
const result = await pool.request().query(
`SELECT Id, parent_id, name FROM ${tableRef(GROUP_TABLE)}`,
)
rows = result.recordset as RawGroup[]
} finally {
await pool.close()
}
const { resolved, unresolved, roots } = buildTree(rows)
const maxDepth = Math.max(1, ...Array.from(resolved.values()).map((v) => v.level))
const rootName = roots.length > 0 ? clean(roots[0].name) : null
if (unresolved.length > 0) {
console.warn(
`[organization-sync] ${unresolved.length} 个部门无法定位到根(父部门缺失或成环),本次跳过`,
)
}
if (dryRun) {
return {
fetched: rows.length,
created: 0,
updated: 0,
deactivated: 0,
rootName,
maxDepth,
dryRun: true,
}
}
const now = new Date()
const existing = await prisma.organizationUnit.findMany({
select: { id: true, sourceDeptId: true },
})
const existingBySource = new Map(existing.map((e) => [e.sourceDeptId, e.id]))
const payload = rows
.map((row) => {
const info = resolved.get(row.Id)
if (!info) return null
return {
sourceDeptId: row.Id,
name: clean(row.name) ?? `部门#${row.Id}`,
sourceParentId: row.parent_id ?? null,
path: info.path,
level: info.level,
}
})
.filter((v): v is NonNullable<typeof v> => v !== null)
const toCreate = payload.filter((p) => !existingBySource.has(p.sourceDeptId))
const toUpdate = payload.filter((p) => existingBySource.has(p.sourceDeptId))
// 新节点批量插入。父子关系稍后统一回填,此处不设 parentId
if (toCreate.length > 0) {
await prisma.organizationUnit.createMany({
data: toCreate.map((p) => ({ ...p, isActive: true, syncedAt: now })),
skipDuplicates: true,
})
}
// 已有节点分批更新。逐条 await 在远程库上要一次次往返,这里用 $transaction
// 把一批操作打包成一次往返,既保持类型安全,也不必手工拼接 SQL 字符串。
const BATCH = 200
for (let i = 0; i < toUpdate.length; i += BATCH) {
const batch = toUpdate.slice(i, i + BATCH)
await prisma.$transaction(
batch.map((p) =>
prisma.organizationUnit.update({
where: { sourceDeptId: p.sourceDeptId },
data: {
name: p.name,
sourceParentId: p.sourceParentId,
path: p.path,
level: p.level,
isActive: true,
syncedAt: now,
},
}),
),
)
}
const created = toCreate.length
const updated = toUpdate.length
// 父子关系整体回填:按上游的 source_parent_id 关联到父节点的本地主键,
// 一条语句取代逐节点更新。SQL 中没有任何外部字符串拼接。
await prisma.$executeRaw`
UPDATE organization_units AS child
SET parent_id = parent.id, updated_at = NOW()
FROM organization_units AS parent
WHERE child.source_parent_id = parent.source_dept_id
AND child.source_parent_id IS NOT NULL
AND child.source_parent_id <> 0
AND child.source_dept_id <> child.source_parent_id
AND child.parent_id IS DISTINCT FROM parent.id
`
// 根节点保持 parent_id 为空
await prisma.$executeRaw`
UPDATE organization_units
SET parent_id = NULL, updated_at = NOW()
WHERE (source_parent_id IS NULL OR source_parent_id = 0 OR source_parent_id = source_dept_id)
AND parent_id IS NOT NULL
`
// 上游已消失的部门下线,不物理删除
const upstreamIds = new Set(rows.map((r) => r.Id))
const stale = existing.filter((e) => !upstreamIds.has(e.sourceDeptId))
let deactivated = 0
if (stale.length > 0) {
const result = await prisma.organizationUnit.updateMany({
where: { id: { in: stale.map((s) => s.id) }, isActive: true },
data: { isActive: false, syncedAt: now },
})
deactivated = result.count
}
return { fetched: rows.length, created, updated, deactivated, rootName, maxDepth, dryRun: false }
}
// ============================================================
// 二、在职状态同步
// ============================================================
export interface EmploymentSyncResult {
upstreamResigned: number
upstreamRegistered: number
localProfiles: number
markedResigned: number
markedActive: number
markedUnknown: number
dryRun: boolean
}
/**
* 依据上游离职表与员工状态码,校正本地档案的在职状态。
*
* 判定优先级:
* 1. 出现在离职表中 → RESIGNED(同时落最后工作日与交接人)
* 2. 状态码 5 → RESIGNED
* 3. 状态码 2 / 3 → ACTIVE
* 4. 其余 → UNKNOWN(不臆断,交由人工核对)
*/
export async function syncEmploymentStates(options: { dryRun?: boolean } = {}): Promise<EmploymentSyncResult> {
const { dryRun = false } = options
if (!isReportDbConfigured()) {
throw new Error('未配置上游报表库(REPORT_DB_*),无法同步在职状态')
}
const pool = await mssql.connect(buildConfig())
let resignedRows: Array<{ userid: string; last_work_day: any; handover_userid: string | null }>
let registerRows: Array<{ userid: string; status: any }>
try {
const r1 = await pool.request().query(
`SELECT userid, last_work_day, handover_userid
FROM ${tableRef(DIMISSION_TABLE)} WHERE userid IS NOT NULL`,
)
resignedRows = r1.recordset as any[]
const r2 = await pool.request().query(
`SELECT userid, [sys01-employeeStatus] AS status
FROM ${tableRef(REGISTER_TABLE)} WHERE userid IS NOT NULL`,
)
registerRows = r2.recordset as any[]
} finally {
await pool.close()
}
const resignedMap = new Map<string, { at: Date | null; handover: string | null }>()
resignedRows.forEach((r) => {
const key = clean(r.userid)
if (!key) return
resignedMap.set(key, {
at: toDate(r.last_work_day),
handover: clean(r.handover_userid),
})
})
const statusMap = new Map<string, number | null>()
registerRows.forEach((r) => {
const key = clean(r.userid)
if (!key) return
const n = Number(clean(r.status))
statusMap.set(key, Number.isFinite(n) ? n : null)
})
const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({
select: { id: true, dingtalkUserId: true, employmentState: true },
})
let markedResigned = 0
let markedActive = 0
let markedUnknown = 0
for (const profile of profiles) {
const ding = clean(profile.dingtalkUserId)
let nextState: string = EMPLOYMENT_STATE.UNKNOWN
let resignedAt: Date | null = null
let handover: string | null = null
if (ding) {
const resigned = resignedMap.get(ding)
if (resigned) {
nextState = EMPLOYMENT_STATE.RESIGNED
resignedAt = resigned.at
handover = resigned.handover
} else {
const code = statusMap.get(ding)
if (code === STATUS_CODE_RESIGNED) nextState = EMPLOYMENT_STATE.RESIGNED
else if (code !== null && code !== undefined && STATUS_CODE_ACTIVE.includes(code)) {
nextState = EMPLOYMENT_STATE.ACTIVE
}
}
}
if (nextState === EMPLOYMENT_STATE.RESIGNED) markedResigned += 1
else if (nextState === EMPLOYMENT_STATE.ACTIVE) markedActive += 1
else markedUnknown += 1
if (!dryRun && profile.employmentState !== nextState) {
await prisma.performanceSurveyEmployeeProfile.update({
where: { id: profile.id },
data: {
employmentState: nextState,
resignedAt,
handoverDingtalkUserId: handover,
},
})
}
}
return {
upstreamResigned: resignedMap.size,
upstreamRegistered: statusMap.size,
localProfiles: profiles.length,
markedResigned,
markedActive,
markedUnknown,
dryRun,
}
}
/**
* 在职人员的查询条件,供替换此前基于关键词包含匹配的写法。
*
* UNKNOWN 视为在职:这些人上游查不到明确状态,宁可多留也不误删——
* 判定错误导致的是"看不见的人",比"多出来的人"更难被发现。
*/
export function getActiveEmployeeWhere() {
return {
OR: [
{ employmentState: EMPLOYMENT_STATE.ACTIVE },
{ employmentState: EMPLOYMENT_STATE.UNKNOWN },
{ employmentState: null },
],
}
}
// ============================================================
// 三、离职联动
// ============================================================
export interface ResignationEffectResult {
/** 权威人员已离职、素材库账号却仍活跃的总人数 */
candidates: number
/** 满足处理条件、实际纳入本次联动的人数 */
pending: number
/** 因离职时间不足或日期缺失而暂缓的人数 */
deferred: number
archivedAccounts: number
custodiedMaterials: number
details: Array<{
displayName: string
username: string
department: string | null
materials: number
resignedAt: Date | null
}>
deferredDetails: Array<{ displayName: string; reason: string }>
failures: Array<{ username: string; reason: string }>
dryRun: boolean
}
/**
* 依据权威人员的离职状态,联动处理素材库侧。
*
* 上游标记离职后,此前需要人工发现并逐个处理:账号一直可登录、素材一直挂在
* 离职者名下无人维护。这里把两件事自动化:
* 1. 归档素材库账号(软归档,历史记录全部保留)
* 2. 把其名下素材托管给所在部门,userId 与 authorName 保持不变
*
* 仅处理已通过钉钉唯一值建立正式绑定的账号,不按姓名猜测。
*
* `minDaysSinceResignation` 用于避开交接期、返聘与上游数据延迟:只处理离职已满
* 指定天数的人;离职日期缺失者一并暂缓,交人工核实,不做推断。
*/
export async function applyResignationEffects(
options: { dryRun?: boolean; minDaysSinceResignation?: number } = {},
): Promise<ResignationEffectResult> {
const { dryRun = false, minDaysSinceResignation } = options
// 通过正式绑定关系找出"人已离职、账号还活着"的情况
const bindings = await prisma.userEmployeeBinding.findMany({
where: {
status: 'ACTIVE',
employeeProfile: { employmentState: EMPLOYMENT_STATE.RESIGNED },
user: { archivedAt: null },
},
select: {
userId: true,
employeeProfile: { select: { displayName: true, resignedAt: true } },
user: { select: { username: true, department: true, isSuperAdmin: true, role: true } },
},
})
const result: ResignationEffectResult = {
candidates: bindings.length,
pending: 0,
deferred: 0,
archivedAccounts: 0,
custodiedMaterials: 0,
details: [],
deferredDetails: [],
failures: [],
dryRun,
}
const cutoff =
typeof minDaysSinceResignation === 'number'
? new Date(Date.now() - minDaysSinceResignation * 24 * 60 * 60 * 1000)
: null
for (const binding of bindings) {
const username = binding.user.username
const displayName = binding.employeeProfile.displayName
const resignedAt = binding.employeeProfile.resignedAt
if (cutoff) {
if (!resignedAt) {
result.deferred += 1
result.deferredDetails.push({ displayName, reason: '离职日期缺失,需人工核实' })
continue
}
if (resignedAt > cutoff) {
result.deferred += 1
result.deferredDetails.push({
displayName,
reason: '离职未满 ' + minDaysSinceResignation + ' 天(' + resignedAt.toLocaleDateString('zh-CN') + ')',
})
continue
}
}
const materialCount = await prisma.material.count({
where: { userId: binding.userId, deletedAt: null },
})
result.pending += 1
result.details.push({
displayName,
username,
department: binding.user.department || null,
materials: materialCount,
resignedAt,
})
if (dryRun) continue
try {
// 超级管理员不由自动流程降权归档,避免误伤关键账号,交人工确认
if (binding.user.isSuperAdmin) {
result.failures.push({ username, reason: '该账号是超级管理员,需人工确认后处理' })
continue
}
const { custodyMaterialsToDepartment } = await import('@/lib/material-transfer')
if (materialCount > 0) {
const custody = await custodyMaterialsToDepartment({ fromUserId: binding.userId })
result.custodiedMaterials += custody.custodied
}
await prisma.user.update({
where: { id: binding.userId },
data: {
isActive: false,
status: 'archived',
archivedAt: new Date(),
statusChangedAt: new Date(),
employmentNote: '上游花名册标记离职,系统自动归档',
},
})
result.archivedAccounts += 1
} catch (error) {
result.failures.push({
username,
reason: error instanceof Error ? error.message : String(error),
})
}
}
return result
}
// ============================================================
// 四、定时同步
// ============================================================
let dailySyncStarted = false
/**
* 启动组织架构与人事状态的每日同步。
*
* 沿用项目既有的进程内定时器模式:首次有人访问相关功能时懒启动,定时器全部
* unref,不会阻止进程退出。多实例部署时各实例会各自执行,同步本身是幂等的,
* 重复执行只会得到相同结果。
*
* 三件事按顺序跑,任一失败只记录日志、不影响后续周期:
* 组织架构 → 在职状态 → 离职联动
*/
export function ensureDailyOrganizationSyncStarted() {
if (dailySyncStarted) return
dailySyncStarted = true
if (!isReportDbConfigured()) {
console.info('[organization-sync] 未配置上游报表库,跳过每日同步')
return
}
const intervalHours = Math.max(1, Number(process.env.ORGANIZATION_SYNC_INTERVAL_HOURS || 24))
const initialDelaySeconds = Math.max(
30,
Number(process.env.ORGANIZATION_SYNC_INITIAL_DELAY_SECONDS || 600),
)
const minDays = Math.max(0, Number(process.env.RESIGNATION_MIN_DAYS || 30))
const runOnce = async () => {
try {
const org = await syncOrganizationUnits()
console.info(
`[organization-sync] 组织架构:上游 ${org.fetched} 个部门,新建 ${org.created},更新 ${org.updated},下线 ${org.deactivated}`,
)
} catch (error) {
console.warn('[organization-sync] 组织架构同步失败:', error)
}
try {
const emp = await syncEmploymentStates()
console.info(
`[organization-sync] 在职状态:在职 ${emp.markedActive},离职 ${emp.markedResigned},未知 ${emp.markedUnknown}`,
)
} catch (error) {
// 状态没更新就不做离职联动,避免依据陈旧数据归档账号
console.warn('[organization-sync] 在职状态同步失败,本轮跳过离职联动:', error)
return
}
try {
const effect = await applyResignationEffects({ minDaysSinceResignation: minDays })
if (effect.archivedAccounts > 0 || effect.custodiedMaterials > 0) {
console.info(
`[organization-sync] 离职联动:归档 ${effect.archivedAccounts} 个账号,托管 ${effect.custodiedMaterials} 条素材,暂缓 ${effect.deferred} 人`,
)
}
if (effect.failures.length > 0) {
console.warn(
`[organization-sync] 离职联动有 ${effect.failures.length} 人需人工处理:` +
effect.failures.map((f) => f.username + '(' + f.reason + ')').join('、'),
)
}
} catch (error) {
console.warn('[organization-sync] 离职联动失败:', error)
}
}
const starter = setTimeout(() => {
void runOnce()
const timer = setInterval(() => void runOnce(), intervalHours * 60 * 60 * 1000)
timer.unref?.()
}, initialDelaySeconds * 1000)
starter.unref?.()
}

View File

@ -111,17 +111,25 @@ const GROUP_TABLE = {
table: process.env.REPORT_DB_GROUP_TABLE || 'hr_groups',
}
const INACTIVE_EMPLOYEE_STATUS_KEYWORDS = ['离职', '停用', '退休']
/**
* 在职人员的查询条件。
*
* 改为依据规范化的 employmentState 判定。此前是拿「离职/停用/退休」对
* employmentStatus 做包含匹配,而上游状态口径混杂——既有数字码(2 试用 /
* 3 正式 / 5 已离职),也有中文,还有大量空值,关键词匹配对前两类完全失效:
* 核实时 216 条已离职档案中有 215 条被判定为在职。
*
* employmentState 由 lib/organization-sync.ts 依据上游离职表与状态码同步维护。
* UNKNOWN 一并视为在职:这些人上游查不到明确状态,宁可多留也不误删——
* 少判导致的是"人凭空消失",比多出几个人难发现得多。
*/
export function getActivePerformanceSurveyEmployeeProfileWhere() {
return {
OR: [
{ employmentStatus: null },
{
AND: INACTIVE_EMPLOYEE_STATUS_KEYWORDS.map((keyword) => ({
employmentStatus: { not: { contains: keyword } },
})),
},
{ employmentState: 'ACTIVE' },
{ employmentState: 'UNKNOWN' },
{ employmentState: null },
],
} as any
}

View File

@ -69,6 +69,7 @@ import {
previewPerformanceSurveyEmployeeSync,
syncPerformanceSurveyEmployees,
} from '@/lib/performance-survey-report-sync'
import { ensureDailyOrganizationSyncStarted } from '@/lib/organization-sync'
import {
PERFORMANCE_SURVEY_IMAGE_LIMITS,
PerformanceSurveyImageProcessingError,
@ -886,9 +887,26 @@ function ensureSystemAdministrator(ctx: UserContext) {
}
}
/**
* 公共看板调研的删除权限名单,按钉钉唯一值判定。
*
* 此前这里比对的是用户名字符串。但库中曾同时存在「无邪」(简体 U+65E0)与
* 「無邪」(繁体 U+7121)两个账号,字符串比对只会命中其中一个——而命中的
* 那个未必是本人。钉钉唯一值全项目唯一,且不随改名或繁简体写法变化。
*
* 通过环境变量覆盖,多个以逗号分隔。
*/
const PUBLIC_DASHBOARD_DELETER_DINGTALK_IDS = new Set(
(process.env.PERFORMANCE_SURVEY_PUBLIC_DASHBOARD_DELETER_DINGTALK_IDS || '09061817188066')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
)
function canDeletePerformanceSurveyPublicDashboardAssessment(ctx: UserContext) {
const username = ctx.user.username.trim().toLowerCase()
return username === 'wuxie' || username === '无邪'
const dingtalkUserId = ctx.user.dingtalkUserId?.trim()
if (!dingtalkUserId) return false
return PUBLIC_DASHBOARD_DELETER_DINGTALK_IDS.has(dingtalkUserId)
}
function ensureCanDeletePerformanceSurveyPublicDashboardAssessment(ctx: UserContext) {
@ -1068,6 +1086,8 @@ async function getManagerSurveyFilterWhere(ctx: UserContext, params?: URLSearchP
export async function getPerformanceSurveyContext(session: Session | null): Promise<UserContext> {
ensurePerformanceSurveyNotificationWorkerStarted()
ensurePerformanceSurveyDailyEmployeeSyncStarted()
// 组织架构、在职状态与离职联动的每日同步,与人员档案同步一并懒启动
ensureDailyOrganizationSyncStarted()
const sessionUserId = (session?.user as any)?.id
if (!sessionUserId) {

View File

@ -0,0 +1,35 @@
-- 素材托管部门
--
-- 归属人离职后,其名下素材此前会变成"孤儿":账号已归档无法登录,
-- 素材却仍挂在该账号下,部门里没有任何人能接手维护,且系统没有批量处理手段。
--
-- 本迁移引入"托管部门":人离职后素材交由其所在部门负责维护,
-- 但 userId 与 authorName 保持不变——素材是谁做的,永远记在谁名下。
-- 归属(谁负责维护)与署名(谁做的)由此分离。
--
-- 可重复执行。
BEGIN;
ALTER TABLE materials
ADD COLUMN IF NOT EXISTS custody_department_id TEXT,
ADD COLUMN IF NOT EXISTS custody_at TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS custody_reason TEXT;
-- 部门被删除时解除托管而不是连带删除素材
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'materials_custody_department_id_fkey'
) THEN
ALTER TABLE materials
ADD CONSTRAINT materials_custody_department_id_fkey
FOREIGN KEY (custody_department_id) REFERENCES departments(id)
ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS materials_custody_department_id_idx
ON materials(custody_department_id);
COMMIT;

View File

@ -0,0 +1,84 @@
-- 共享组织架构 + 规范化在职状态
--
-- 一、组织架构
-- 此前系统没有真正的组织表,部门树靠遍历已同步员工的 departmentPath 字符串现场拼出,
-- 只能看到"有人的部门",同名部门会被合并,父子归属取决于遍历顺序。
-- 上游 rpt.hr_groups 实际是一棵完整单根树(1075 个部门、单一根节点、零孤儿节点),
-- 直接镜像即可。
--
-- 二、在职状态
-- 上游员工状态口径混杂:既有数字码(2 试用 / 3 正式 / 5 已离职),也有「正式」「离职」
-- 「试用」等中文,还有大量空值。现有代码用「离职/停用/退休」做关键词包含匹配,
-- 对数字码和空值完全失效——已核实 216 条已离职档案中有 215 条仍被判定为在职。
-- 因此单独规范化一列供运行时判定,原始值保留作审计。
--
-- 可重复执行。
BEGIN;
-- ========== 1. 组织架构表 ==========
CREATE TABLE IF NOT EXISTS organization_units (
id TEXT NOT NULL,
source_dept_id INTEGER NOT NULL,
name TEXT NOT NULL,
parent_id TEXT,
source_parent_id INTEGER,
path TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
synced_at TIMESTAMP(3),
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL,
CONSTRAINT organization_units_pkey PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS organization_units_source_dept_id_key
ON organization_units(source_dept_id);
-- 父部门被移除时解除挂靠而不是连带删除子部门
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'organization_units_parent_id_fkey'
) THEN
ALTER TABLE organization_units
ADD CONSTRAINT organization_units_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES organization_units(id)
ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS organization_units_parent_id_idx ON organization_units(parent_id);
CREATE INDEX IF NOT EXISTS organization_units_source_parent_id_idx ON organization_units(source_parent_id);
CREATE INDEX IF NOT EXISTS organization_units_path_idx ON organization_units(path);
CREATE INDEX IF NOT EXISTS organization_units_is_active_idx ON organization_units(is_active);
-- ========== 2. 人员档案的规范化在职状态 ==========
ALTER TABLE performance_survey_employee_profiles
ADD COLUMN IF NOT EXISTS employment_state TEXT DEFAULT 'UNKNOWN',
ADD COLUMN IF NOT EXISTS resigned_at TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS handover_dingtalk_user_id TEXT;
CREATE INDEX IF NOT EXISTS ps_employee_employment_state_idx
ON performance_survey_employee_profiles(employment_state);
-- 依据现有中文状态先做一轮保守初始化:能明确判断的先落定,
-- 其余保持 UNKNOWN,等接入上游离职表后再由同步逐条校正。
UPDATE performance_survey_employee_profiles
SET employment_state = 'RESIGNED'
WHERE employment_state IS DISTINCT FROM 'RESIGNED'
AND employment_status IS NOT NULL
AND (employment_status LIKE '%离职%' OR employment_status LIKE '%停用%' OR employment_status LIKE '%退休%');
UPDATE performance_survey_employee_profiles
SET employment_state = 'ACTIVE'
WHERE employment_state IS DISTINCT FROM 'ACTIVE'
AND employment_status IS NOT NULL
AND (employment_status IN ('2', '3') OR employment_status LIKE '%正式%' OR employment_status LIKE '%试用%');
UPDATE performance_survey_employee_profiles
SET employment_state = 'RESIGNED'
WHERE employment_state IS DISTINCT FROM 'RESIGNED'
AND employment_status = '5';
COMMIT;

View File

@ -0,0 +1,121 @@
-- 素材库账号 ↔ 权威人员档案 正式绑定关系
--
-- 背景:此前账号与花名册人员之间没有任何外键,仅在运行时按
-- 钉钉ID → 邮箱 → sourcePayload JSON 逐层软匹配,数据库层面
-- 并不知道某个素材库账号和某条花名册档案是同一个人。
--
-- 本迁移建立正式绑定表,并按钉钉唯一值回填存量绑定。
-- 严格遵守:只按钉钉唯一值自动绑定,姓名与邮箱不参与自动建立绑定。
--
-- 可重复执行。
BEGIN;
-- ========== 1. 前置校验 ==========
-- 一个钉钉身份对应多个素材库账号时,回填会在同一人员档案上产生
-- 多条 ACTIVE 绑定,违反下面的部分唯一索引。此处提前拦截并给出明确原因,
-- 而不是让索引创建阶段抛出难以定位的报错。
DO $$
DECLARE
conflict_count INTEGER;
BEGIN
SELECT COUNT(*) INTO conflict_count
FROM (
SELECT "dingtalkUserId"
FROM users
WHERE "dingtalkUserId" IS NOT NULL
GROUP BY "dingtalkUserId"
HAVING COUNT(*) > 1
) AS duplicated;
IF conflict_count > 0 THEN
RAISE EXCEPTION
'存在 % 个钉钉账号绑定了多个素材库账号,必须先人工处理后再执行本迁移。禁止按姓名自动合并。',
conflict_count;
END IF;
END $$;
-- ========== 2. 钉钉唯一值:全项目唯一 ==========
-- 此前 users."dingtalkUserId" 没有任何唯一约束,数据库层面允许两个素材库账号
-- 填入同一个钉钉身份。既然钉钉唯一值是全项目唯一的人员判定依据,这条约束必须落到库上。
-- PostgreSQL 中多个 NULL 互不冲突,因此未绑定钉钉的账号不受影响。
CREATE UNIQUE INDEX IF NOT EXISTS "users_dingtalkUserId_key"
ON users("dingtalkUserId");
-- ========== 3. 建表 ==========
CREATE TABLE IF NOT EXISTS user_employee_bindings (
id TEXT NOT NULL,
user_id TEXT NOT NULL,
employee_profile_id TEXT NOT NULL,
binding_source TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE',
verified_at TIMESTAMP(3),
note TEXT,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL,
CONSTRAINT user_employee_bindings_pkey PRIMARY KEY (id)
);
-- 外键:账号或人员档案被删除时,绑定关系一并清除
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'user_employee_bindings_user_id_fkey'
) THEN
ALTER TABLE user_employee_bindings
ADD CONSTRAINT user_employee_bindings_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'user_employee_bindings_profile_id_fkey'
) THEN
ALTER TABLE user_employee_bindings
ADD CONSTRAINT user_employee_bindings_profile_id_fkey
FOREIGN KEY (employee_profile_id) REFERENCES performance_survey_employee_profiles(id)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ========== 4. 索引 ==========
CREATE INDEX IF NOT EXISTS user_employee_binding_user_idx ON user_employee_bindings(user_id);
CREATE INDEX IF NOT EXISTS user_employee_binding_profile_idx ON user_employee_bindings(employee_profile_id);
CREATE INDEX IF NOT EXISTS user_employee_binding_status_idx ON user_employee_bindings(status);
-- 活跃绑定的双向唯一:一个账号只能绑一名权威人员,一名权威人员只能绑一个活跃账号。
-- 使用部分唯一索引,从而允许保留历史(status <> 'ACTIVE')绑定记录。
CREATE UNIQUE INDEX IF NOT EXISTS user_employee_binding_active_user_key
ON user_employee_bindings(user_id) WHERE status = 'ACTIVE';
CREATE UNIQUE INDEX IF NOT EXISTS user_employee_binding_active_profile_key
ON user_employee_bindings(employee_profile_id) WHERE status = 'ACTIVE';
-- ========== 5. 回填存量绑定 ==========
-- 只按钉钉唯一值匹配。行 ID 由 user_id + profile_id 确定性生成,保证重复执行幂等。
INSERT INTO user_employee_bindings (
id, user_id, employee_profile_id, binding_source, status, verified_at, created_at, updated_at
)
SELECT
md5('user_employee_binding:' || u.id || ':' || p.id)::uuid::text,
u.id,
p.id,
'MIGRATION',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM users u
JOIN performance_survey_employee_profiles p
ON p.dingtalk_user_id = u."dingtalkUserId"
WHERE u."dingtalkUserId" IS NOT NULL
AND u."dingtalkUserId" <> ''
AND NOT EXISTS (
SELECT 1 FROM user_employee_bindings b
WHERE b.user_id = u.id AND b.status = 'ACTIVE'
)
AND NOT EXISTS (
SELECT 1 FROM user_employee_bindings b
WHERE b.employee_profile_id = p.id AND b.status = 'ACTIVE'
);
COMMIT;

View File

@ -21,7 +21,7 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
department String @default("")
dingtalkUserId String?
dingtalkUserId String? @unique
loginType String @default("normal")
realName String?
phone String?
@ -54,6 +54,7 @@ model User {
productPackageUploadJobs ProductPackageUploadJob[] @relation("ProductPackageUploadJobOperator")
departmentRef Department? @relation("UserDepartment", fields: [departmentId], references: [id])
transferToDept Department? @relation("UserTransferDepartment", fields: [transferToDeptId], references: [id])
employeeBindings UserEmployeeBinding[]
@@index([status])
@@index([archivedAt])
@ -100,6 +101,11 @@ model Material {
transferredAt DateTime?
transferredBy String?
transferNote String?
/// 原归属人离职后,素材托管给的部门。托管只改变"谁负责维护",
/// 不改变 userId 与 authorName——素材是谁做的,永远记在谁名下。
custodyDepartmentId String? @map("custody_department_id")
custodyAt DateTime? @map("custody_at")
custodyReason String? @map("custody_reason")
taskNo String? @map("task_no")
dingtalkApprovalNo String? @map("dingtalk_approval_no")
taskStatus String? @map("task_status")
@ -120,8 +126,10 @@ model Material {
productPackageUploadJobs ProductPackageUploadJob[]
user User @relation("MaterialOwner", fields: [userId], references: [id], onDelete: Cascade)
taskDetailRequester User? @relation("TaskDetailRequester", fields: [taskDetailRequesterUserId], references: [id], onDelete: SetNull)
custodyDepartment Department? @relation("MaterialCustodyDepartment", fields: [custodyDepartmentId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([custodyDepartmentId])
@@index([taskDetailRequesterUserId])
@@index([createdAt])
@@index([categoryName])
@ -333,6 +341,7 @@ model Department {
children Department[] @relation("DepartmentHierarchy")
users User[] @relation("UserDepartment")
transferredUsers User[] @relation("UserTransferDepartment")
custodyMaterials Material[] @relation("MaterialCustodyDepartment")
@@index([parentId])
@@index([status])
@ -550,6 +559,14 @@ model PerformanceSurveyEmployeeProfile {
managerJobNumber String? @map("manager_job_number")
managerName String? @map("manager_name")
employmentStatus String? @map("employment_status")
/// 规范化的在职状态:ACTIVE / RESIGNED / UNKNOWN。
/// employmentStatus 是上游原样值,口径混杂(有数字码 2/3/5,也有「正式」「离职」「试用」等中文,
/// 还有大量空值),靠关键词匹配判断在职会大面积误判,因此单独规范化一列供运行时判定使用。
employmentState String? @default("UNKNOWN") @map("employment_state")
/// 最后工作日,来自上游离职表
resignedAt DateTime? @map("resigned_at")
/// 离职交接人的钉钉唯一值,上游填写率较低,仅作参考
handoverDingtalkUserId String? @map("handover_dingtalk_user_id")
workPlace String? @map("work_place")
sourcePayload Json? @map("source_payload")
syncedAt DateTime? @map("synced_at")
@ -563,6 +580,7 @@ model PerformanceSurveyEmployeeProfile {
notifications PerformanceSurveyNotification[]
manualPositionAssignments PerformanceSurveyEmployeePositionAssignment[]
departmentMemberships PerformanceSurveyEmployeeDepartmentMembership[]
userBindings UserEmployeeBinding[]
submittedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewSubmitter")
managedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewManager")
finalWorkReviewProjects WorkReviewProject[] @relation("WorkReviewFinalReviewer")
@ -577,9 +595,66 @@ model PerformanceSurveyEmployeeProfile {
@@index([isDingtalkAdmin])
@@index([managerDingtalkUserId])
@@index([employmentStatus])
@@index([employmentState])
@@map("performance_survey_employee_profiles")
}
/// 共享组织架构,直接镜像上游 HR 系统的部门树(rpt.hr_groups)。
///
/// 此前系统没有真正的组织表:部门树是遍历已同步员工的 departmentPath 字符串
/// 现场拼出来的,因而只能看到"有人的部门",且同名部门会被合并、父子归属取决于
/// 谁先被遍历到。上游其实是一棵完整的单根树(单一根节点、零孤儿节点),
/// 直接同步即可,不必倒推。
model OrganizationUnit {
id String @id @default(uuid())
/// 上游 hr_groups.Id
sourceDeptId Int @unique @map("source_dept_id")
name String
parentId String? @map("parent_id")
/// 上游 hr_groups.parent_id,同步期间用于重建父子关系
sourceParentId Int? @map("source_parent_id")
/// 以 / 分隔的全路径,展示与前缀匹配用
path String
level Int @default(1)
/// 本次同步是否仍存在于上游;上游删除的部门置为 false 而非物理删除
isActive Boolean @default(true) @map("is_active")
syncedAt DateTime? @map("synced_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
parent OrganizationUnit? @relation("OrganizationHierarchy", fields: [parentId], references: [id])
children OrganizationUnit[] @relation("OrganizationHierarchy")
@@index([parentId])
@@index([sourceParentId])
@@index([path])
@@index([isActive])
@@map("organization_units")
}
/// 素材库账号与权威人员档案的正式绑定关系。
/// 取代此前"运行时按钉钉ID/邮箱/sourcePayload 逐层软匹配"的隐式关联做法。
/// 活跃绑定的唯一性由部分唯一索引保证(仅约束 status='ACTIVE',见对应 migration),
/// 保证一个账号只对应一名权威人员、一名权威人员只对应一个活跃账号。
/// 冲突一律进人工处理,禁止按姓名自动合并。
model UserEmployeeBinding {
id String @id @default(uuid())
userId String @map("user_id")
employeeProfileId String @map("employee_profile_id")
bindingSource String @map("binding_source")
status String @default("ACTIVE")
verifiedAt DateTime? @map("verified_at")
note String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
employeeProfile PerformanceSurveyEmployeeProfile @relation(fields: [employeeProfileId], references: [id], onDelete: Cascade)
@@index([userId], map: "user_employee_binding_user_idx")
@@index([employeeProfileId], map: "user_employee_binding_profile_idx")
@@index([status], map: "user_employee_binding_status_idx")
@@map("user_employee_bindings")
}
model PerformanceSurveyEmployeeDepartmentMembership {
id String @id @default(uuid())
employeeProfileId String @map("employee_profile_id")

View File

@ -0,0 +1,102 @@
/**
* 回填作者名:把误存成邮箱的 authorName 改回真实用户名
*
* 成因:任务-详情上传入口曾读取 session.user.username,而登录态从不写入该字段,
* 一路兜底存成了邮箱。入口已修复,本脚本清理存量。
*
* 只改 authorName(署名展示用),不动 userId(归属)。
* 取值口径与其它上传入口一致:使用 users.username。
*
* 默认预演,不写任何数据。加 --apply 才实际执行。
*
* 用法:
* npx tsx scripts/backfill-email-author-names.ts # 预演
* npx tsx scripts/backfill-email-author-names.ts --apply # 实际执行
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient({ log: [] })
async function main() {
const apply = process.argv.includes('--apply')
console.log('\n回填被误存为邮箱的作者名')
console.log('模式:' + (apply ? '⚠️ 实际执行' : '预演,不写入'))
const targets = await prisma.material.findMany({
where: { deletedAt: null, authorName: { contains: '@' } },
select: {
id: true,
authorName: true,
materialType: true,
user: { select: { username: true, realName: true, archivedAt: true } },
},
})
console.log('\n 作者名含邮箱的素材 ' + targets.length + ' 条')
const fixable = targets.filter((m) => m.user?.username && m.user.username.trim() !== '')
const unfixable = targets.filter((m) => !m.user?.username || m.user.username.trim() === '')
console.log(' 可按归属账号回填 ' + fixable.length + ' 条')
console.log(' 无法回填(账号缺失) ' + unfixable.length + ' 条')
const byType = new Map<string, number>()
fixable.forEach((m) => {
const k = m.materialType || '(无类型)'
byType.set(k, (byType.get(k) || 0) + 1)
})
console.log('\n 按素材类型分布:')
Array.from(byType.entries())
.sort((a, b) => b[1] - a[1])
.forEach(([t, c]) => console.log(' ' + t.padEnd(20, '.') + c))
const samples = fixable.slice(0, 8)
console.log('\n 回填样本(前 8 条):')
samples.forEach((m) =>
console.log(
' ' + (m.authorName ?? '').padEnd(34) + ' → ' + m.user!.username +
(m.user!.archivedAt ? '(已离职)' : ''),
),
)
if (!apply) {
console.log('\n(预演结束,未写入任何数据。加 --apply 实际执行)')
await prisma.$disconnect()
return
}
// 按目标用户名分组批量更新,避免逐条往返
const groups = new Map<string, string[]>()
fixable.forEach((m) => {
const name = m.user!.username
const list = groups.get(name) ?? []
list.push(m.id)
groups.set(name, list)
})
let updated = 0
for (const [username, ids] of groups) {
const result = await prisma.material.updateMany({
where: { id: { in: ids } },
data: { authorName: username },
})
updated += result.count
}
console.log('\n 已回填 ' + updated + ' 条')
const remaining = await prisma.material.count({
where: { deletedAt: null, authorName: { contains: '@' } },
})
console.log(' 剩余作者名含邮箱的 ' + remaining + ' 条' + (remaining === unfixable.length ? '(均为无法回填的)' : ''))
await prisma.$disconnect()
}
main().catch(async (e) => {
console.error('\n执行失败:', e)
await prisma.$disconnect()
process.exitCode = 1
})

View File

@ -0,0 +1,67 @@
/**
* 离职人员素材部门托管
*
* 把已归档(离职)账号名下的素材,交由各自所属部门接管。
*
* 托管只改变"谁负责维护":userId 与 authorName 保持不变,
* 首页素材卡片显示的作者仍然是本人——素材是谁做的,永远记在谁名下。
*
* 默认预演,不写任何数据。加 --apply 才实际执行。
*
* 用法:
* npx tsx scripts/custody-archived-user-materials.ts # 预演
* npx tsx scripts/custody-archived-user-materials.ts --apply # 实际执行
*/
import { PrismaClient } from '@prisma/client'
import { custodyMaterialsOfArchivedUsers } from '../lib/material-transfer'
const prisma = new PrismaClient()
async function main() {
const apply = process.argv.includes('--apply')
console.log('\n离职人员素材部门托管')
console.log('模式:' + (apply ? '⚠️ 实际执行' : '预演,不写入'))
const result = await custodyMaterialsOfArchivedUsers({ dryRun: !apply })
console.log('\n===== 汇总 =====')
console.log(' 涉及离职账号 ' + result.accounts + ' 个')
console.log(' 待托管素材 ' + result.totalMaterials + ' 条')
if (apply) console.log(' 已完成托管 ' + result.custodied + ' 条')
if (result.details.length > 0) {
console.log('\n===== 明细(按素材数排序)=====')
console.log(' 离职账号 接管部门 素材数')
console.log(' ' + '-'.repeat(52))
result.details
.sort((a, b) => b.count - a.count)
.forEach((d) => {
console.log(
' ' + d.username.padEnd(18) + (d.department ?? '未知').padEnd(20) + String(d.count).padStart(6)
)
})
}
if (result.skipped.length > 0) {
console.log('\n===== 无法自动托管(需人工指定部门)=====')
result.skipped
.sort((a, b) => b.count - a.count)
.forEach((s) => {
console.log(' ' + s.username.padEnd(18) + String(s.count).padStart(5) + ' 条 ← ' + s.reason)
})
}
if (!apply) {
console.log('\n(预演结束,未写入任何数据。加 --apply 实际执行)')
}
await prisma.$disconnect()
}
main().catch(async (e) => {
console.error('\n执行失败:', e)
await prisma.$disconnect()
process.exitCode = 1
})

View File

@ -0,0 +1,330 @@
/**
* 人员架构地基改造 · 第 0 步只读盘点脚本
*
* 目的:在动任何代码之前,用真实数据量化改造的影响面与风险。
*
* 安全声明:
* - 全程只执行 SELECT / COUNT / groupBy,绝不写入、更新、删除任何数据。
* - 不调用任何 S3、钉钉、报表库等外部接口。
* - 仅连接 DATABASE_URL 指向的数据库做只读聚合。
*
* 用法:
* npx tsx scripts/diagnose-people-architecture.ts
*
* 可选参数:
* --verbose 额外打印异常明细(默认只打印统计数字,不输出人名)
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const VERBOSE = process.argv.slice(2).includes('--verbose')
// 与 lib/performance-survey-report-sync.ts:114 保持一致
const INACTIVE_EMPLOYEE_STATUS_KEYWORDS = ['离职', '停用', '退休']
function norm(value: unknown): string {
if (value === null || value === undefined) return ''
return String(value).trim()
}
function section(title: string) {
console.log('\n' + '='.repeat(64))
console.log(title)
console.log('='.repeat(64))
}
function item(label: string, value: number | string, warn = false) {
const flag = warn && Number(value) > 0 ? ' ⚠️' : ''
console.log(` ${label.padEnd(44, '.')} ${String(value).padStart(6)}${flag}`)
}
async function main() {
console.log('\n人员架构地基改造 · 第 0 步只读盘点')
console.log(`执行时间:${new Date().toLocaleString('zh-CN')}`)
// ==================== 一、素材库账号 ====================
section('一、素材库账号(User)')
const totalUsers = await prisma.user.count()
const activeUsers = await prisma.user.count({
where: { isActive: true, archivedAt: null },
})
const archivedUsers = await prisma.user.count({ where: { archivedAt: { not: null } } })
const withDingtalk = await prisma.user.count({ where: { dingtalkUserId: { not: null } } })
item('账号总数', totalUsers)
item('活跃账号(isActive 且未归档)', activeUsers)
item('已归档账号', archivedUsers)
item('有钉钉 ID 的账号', withDingtalk)
item('无钉钉 ID 的账号(无法自动绑定)', totalUsers - withDingtalk, true)
// 一个钉钉身份对应多个账号 —— 绑定表唯一约束的直接阻塞项
const dupDingtalk = await prisma.user.groupBy({
by: ['dingtalkUserId'],
where: { dingtalkUserId: { not: null } },
_count: { _all: true },
having: { dingtalkUserId: { _count: { gt: 1 } } },
})
item('重复的钉钉 ID(一人多账号)', dupDingtalk.length, true)
// 姓名重复 —— 暴露"按姓名自动绑定"的历史风险面
const dupRealName = await prisma.user.groupBy({
by: ['realName'],
where: { realName: { not: null }, archivedAt: null },
_count: { _all: true },
having: { realName: { _count: { gt: 1 } } },
})
item('真实姓名重复(姓名绑定风险面)', dupRealName.length, true)
// ==================== 二、D-A 安全阀 ====================
section('二、D-A 安全阀:收窄同步对考核评估人的影响')
const activeProfileWhere = {
OR: [
{ employmentStatus: null },
{
AND: INACTIVE_EMPLOYEE_STATUS_KEYWORDS.map((keyword) => ({
employmentStatus: { not: { contains: keyword } },
})),
},
],
}
const totalProfiles = await prisma.performanceSurveyEmployeeProfile.count()
const evaluatorPool = await prisma.performanceSurveyEmployeeProfile.count({
where: activeProfileWhere as any,
})
const users = await prisma.user.findMany({
select: { id: true, email: true, dingtalkUserId: true },
})
const userIdSet = new Set(users.map((u) => u.id))
const dingSet = new Set(users.map((u) => u.dingtalkUserId).filter(Boolean) as string[])
const emailSet = new Set(users.map((u) => u.email.toLowerCase()))
const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({
where: activeProfileWhere as any,
select: {
id: true,
displayName: true,
dingtalkUserId: true,
dingEmail: true,
departmentName: true,
departmentPath: true,
sourcePayload: true,
},
})
// 与 lib/performance-survey-report-sync.ts 的账号匹配口径保持一致
function hasMaterialAccount(p: (typeof profiles)[number]): boolean {
if (p.dingtalkUserId && dingSet.has(p.dingtalkUserId)) return true
if (p.dingEmail && emailSet.has(p.dingEmail.toLowerCase())) return true
const payload = p.sourcePayload as any
const linked = payload?.materialUserId || payload?.userId
if (linked && userIdSet.has(String(linked))) return true
return false
}
const unmatched = profiles.filter((p) => !hasMaterialAccount(p))
item('花名册档案总数', totalProfiles)
item('当前可被选为评估人(全体在职)', evaluatorPool)
item('其中有素材库账号', evaluatorPool - unmatched.length)
item('★ 收窄后将选不到的人数', unmatched.length, true)
const shrinkRate = evaluatorPool > 0 ? ((unmatched.length / evaluatorPool) * 100).toFixed(1) : '0.0'
console.log(`\n → 评估人范围收缩比例:${shrinkRate}%`)
console.log(' → 判定标准:接近 0 可继续执行;数量可观则需回到 D-A 决策重新讨论')
if (unmatched.length > 0) {
const byDept = new Map<string, number>()
unmatched.forEach((p) => {
const key = norm(p.departmentName) || '(无部门)'
byDept.set(key, (byDept.get(key) || 0) + 1)
})
console.log('\n 受影响人员的部门分布(前 15):')
Array.from(byDept.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.forEach(([dept, count]) => console.log(` ${dept.padEnd(30, '.')} ${count}`))
if (VERBOSE) {
console.log('\n 明细(--verbose):')
unmatched.slice(0, 50).forEach((p) => {
console.log(` ${norm(p.displayName)} | ${norm(p.departmentPath)}`)
})
if (unmatched.length > 50) console.log(` ...另有 ${unmatched.length - 50} 人`)
}
}
// ==================== 三、部门数据一致性 ====================
section('三、部门数据一致性(双轨字段漂移)')
const departments = await prisma.department.findMany({
select: { id: true, name: true, parentId: true, status: true, managerId: true },
})
const deptNames = new Set(departments.map((d) => d.name))
const deptById = new Map(departments.map((d) => [d.id, d.name]))
const allUsers = await prisma.user.findMany({
select: {
id: true,
department: true,
departmentId: true,
managedDepartment: true,
archivedAt: true,
},
})
const liveUsers = allUsers.filter((u) => !u.archivedAt)
let noDeptId = 0
let mismatch = 0
let orphanDeptName = 0
let orphanManaged = 0
liveUsers.forEach((u) => {
const deptStr = norm(u.department)
if (deptStr && !u.departmentId) noDeptId++
if (u.departmentId) {
const refName = deptById.get(u.departmentId)
if (refName && deptStr && refName !== deptStr) mismatch++
}
if (deptStr && !deptNames.has(deptStr)) orphanDeptName++
const managed = norm(u.managedDepartment)
if (managed && !deptNames.has(managed)) orphanManaged++
})
item('部门表总数', departments.length)
item('有部门字符串但无 departmentId', noDeptId, true)
item('departmentId 与部门字符串不一致', mismatch, true)
item('部门字符串指向不存在的部门', orphanDeptName, true)
item('★ managedDepartment 指向不存在的部门', orphanManaged, true)
console.log('\n → managedDepartment 是无校验字段,却是权限判定的第一优先级输入')
// ==================== 四、部门权限表 ====================
section('四、部门权限表(改名漂移存量)')
const perms = await prisma.departmentMaterialPermission.findMany({
select: { department: true, linkedToDepartment: true, materialType: true },
})
const orphanPerms = perms.filter((p) => !deptNames.has(norm(p.department)))
const linkedPerms = perms.filter((p) => norm(p.linkedToDepartment))
const orphanLinks = linkedPerms.filter((p) => !deptNames.has(norm(p.linkedToDepartment)))
// 多跳关联链:A 关联到 B,而 B 自己也关联到 C
const linkMap = new Map<string, string>()
linkedPerms.forEach((p) => {
linkMap.set(`${p.department}||${p.materialType}`, norm(p.linkedToDepartment))
})
let multiHop = 0
linkMap.forEach((target, key) => {
const materialType = key.split('||')[1]
if (linkMap.has(`${target}||${materialType}`)) multiHop++
})
item('权限记录总数', perms.length)
item('★ 指向已不存在部门的记录', orphanPerms.length, true)
item('设置了关联部门的记录', linkedPerms.length)
item('关联到已不存在部门的记录', orphanLinks.length, true)
item('多跳关联链(现仅支持一跳)', multiHop, true)
if (orphanPerms.length > 0) {
const orphanNames = Array.from(new Set(orphanPerms.map((p) => norm(p.department))))
console.log('\n 失效的部门名(这些权限当前静默不生效):')
orphanNames.slice(0, 20).forEach((n) => console.log(` ${n}`))
if (orphanNames.length > 20) console.log(` ...另有 ${orphanNames.length - 20} 个`)
}
// ==================== 五、两套组织对照 ====================
section('五、两套组织体系对照')
const profileDeptNames = new Set(
profiles.map((p) => norm(p.departmentName)).filter(Boolean)
)
const overlap = Array.from(profileDeptNames).filter((n) => deptNames.has(n))
item('素材库自维护部门数', deptNames.size)
item('花名册出现的部门数(叶子)', profileDeptNames.size)
item('两边名称能对上的部门数', overlap.length)
item('仅存在于素材库的部门', deptNames.size - overlap.length, true)
item('仅存在于花名册的部门', profileDeptNames.size - overlap.length, true)
// 部门负责人两条路径的不同步情况
const managerDepts = departments.filter((d) => d.managerId)
const managedUsers = liveUsers.filter((u) => norm(u.managedDepartment))
item('通过部门表设了负责人的部门', managerDepts.length)
item('通过 managedDepartment 授权的账号', managedUsers.length)
console.log('\n → 这两条路径互不联动:经部门表设的负责人,managedDepartment 为空,')
console.log(' 权限会退回其本人部门,他负责的部门反而看不到')
// ==================== 六、待人工确认清单 ====================
section('六、待人工确认清单(钉钉登录已不再按姓名自动合并)')
const bindings = await prisma.userEmployeeBinding.findMany({
where: { status: 'ACTIVE' },
select: { userId: true },
})
const boundUserIds = new Set(bindings.map((b) => b.userId))
const unboundActive = await prisma.user.findMany({
where: { isActive: true, archivedAt: null },
select: {
id: true,
username: true,
realName: true,
department: true,
dingtalkUserId: true,
},
})
const pending = unboundActive.filter((u) => !boundUserIds.has(u.id))
item('未绑定权威人员的活跃账号', pending.length, true)
if (pending.length > 0) {
// 这些账号一旦有人用同名钉钉身份登录,会被挡下要求人工确认。
// 提前把花名册里的同名候选找出来,便于管理员核对后补绑。
const nameIndex = new Map<string, string[]>()
profiles.forEach((p) => {
;[norm(p.displayName), norm((p as any).realName)].forEach((n) => {
if (!n) return
const list = nameIndex.get(n) ?? []
list.push(`${norm(p.displayName)}|${norm(p.departmentPath) || '无部门'}`)
nameIndex.set(n, list)
})
})
console.log('\n 账号 / 所在部门 / 是否有钉钉ID / 花名册同名候选')
console.log(' ' + '-'.repeat(70))
pending.forEach((u) => {
const uname = norm(u.username)
const candidates = Array.from(
new Set([...(nameIndex.get(uname) ?? []), ...(nameIndex.get(norm(u.realName)) ?? [])])
)
const hint = candidates.length > 0 ? candidates.join(' / ') : '花名册无同名'
console.log(
` ${uname.padEnd(14)} ${(norm(u.department) || '无部门').padEnd(14)} ` +
`${u.dingtalkUserId ? '有' : '无'} ${hint}`
)
})
console.log(
'\n → 有同名候选的:核对无误后补绑,否则这些人首次用钉钉登录会被挡下\n' +
' → 无钉钉ID的:只能用密码登录,不受影响'
)
}
section('盘点结束')
console.log(' 本次执行未写入任何数据。\n')
}
main()
.catch((error) => {
console.error('\n盘点失败:', error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

150
scripts/probe-report-db.ts Normal file
View File

@ -0,0 +1,150 @@
/**
* 上游报表库探查 · 花名册与组织架构相关表
*
* 纯只读:只查 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
})

View File

@ -0,0 +1,127 @@
/**
* 组织架构与在职状态同步
*
* 从上游 HR 系统镜像部门树,并依据离职表校正本地档案的在职状态。
*
* 默认预演,不写任何数据。加 --apply 才实际执行。
*
* 用法:
* npx tsx --env-file=.env scripts/sync-organization.ts # 预演
* npx tsx --env-file=.env scripts/sync-organization.ts --apply # 实际执行
* npx tsx --env-file=.env scripts/sync-organization.ts --org # 只同步组织架构
* npx tsx --env-file=.env scripts/sync-organization.ts --employment # 只同步在职状态
*/
import { PrismaClient } from '@prisma/client'
import {
isReportDbConfigured,
syncOrganizationUnits,
syncEmploymentStates,
applyResignationEffects,
} from '../lib/organization-sync'
const prisma = new PrismaClient()
async function main() {
const args = process.argv.slice(2)
const apply = args.includes('--apply')
const onlyOrg = args.includes('--org')
const onlyEmployment = args.includes('--employment')
const runOrg = !onlyEmployment
const runEmployment = !onlyOrg
if (!isReportDbConfigured()) {
console.error('未配置上游报表库(REPORT_DB_SERVER / DATABASE / USER / PASSWORD)')
process.exitCode = 1
return
}
console.log('\n组织架构与在职状态同步')
console.log('模式:' + (apply ? '⚠️ 实际执行' : '预演,不写入'))
if (runOrg) {
console.log('\n===== 一、组织架构 =====')
const r = await syncOrganizationUnits({ dryRun: !apply })
console.log(' 上游部门总数 ' + r.fetched)
console.log(' 根节点 ' + (r.rootName ?? '未识别'))
console.log(' 最大层级 ' + r.maxDepth)
if (apply) {
console.log(' 新建 ' + r.created)
console.log(' 更新 ' + r.updated)
console.log(' 下线(上游已删)' + r.deactivated)
}
}
if (runEmployment) {
console.log('\n===== 二、在职状态 =====')
const r = await syncEmploymentStates({ dryRun: !apply })
console.log(' 上游离职表人数 ' + r.upstreamResigned)
console.log(' 上游在册人数 ' + r.upstreamRegistered)
console.log(' 本地档案总数 ' + r.localProfiles)
console.log(' ├ 判定为在职 ' + r.markedActive)
console.log(' ├ 判定为离职 ' + r.markedResigned)
console.log(' └ 状态未知 ' + r.markedUnknown)
console.log('\n===== 三、离职联动 =====')
// 只处理离职已满 30 天的人:避开交接期、返聘与上游数据延迟;
// 离职日期缺失者一并暂缓,交人工核实
const MIN_DAYS = Number(process.env.RESIGNATION_MIN_DAYS || 30)
const e = await applyResignationEffects({ dryRun: !apply, minDaysSinceResignation: MIN_DAYS })
console.log(' 人已离职、账号仍活跃 ' + e.candidates + ' 人')
console.log(' ├ 本次纳入处理 ' + e.pending + ' 人(离职已满 ' + MIN_DAYS + ' 天)')
console.log(' └ 暂缓 ' + e.deferred + ' 人')
if (apply) {
console.log('\n 已归档账号 ' + e.archivedAccounts)
console.log(' 已托管素材 ' + e.custodiedMaterials + ' 条')
}
if (e.details.length > 0) {
console.log('\n 纳入处理的(按素材数排序,前 20):')
e.details
.sort((a, b) => b.materials - a.materials)
.slice(0, 20)
.forEach((d) =>
console.log(
' ' + d.displayName.padEnd(12) + (d.department ?? '无部门').padEnd(14) +
String(d.materials).padStart(5) + ' 条素材 ' +
(d.resignedAt ? '最后工作日 ' + d.resignedAt.toLocaleDateString('zh-CN') : '离职日期未知'),
),
)
if (e.details.length > 20) console.log(' ...另有 ' + (e.details.length - 20) + ' 人')
}
if (e.deferredDetails.length > 0) {
console.log('\n 暂缓的(前 10):')
e.deferredDetails.slice(0, 10).forEach((d) =>
console.log(' ' + d.displayName.padEnd(12) + d.reason),
)
if (e.deferredDetails.length > 10) {
console.log(' ...另有 ' + (e.deferredDetails.length - 10) + ' 人')
}
}
if (e.failures.length > 0) {
console.log('\n 需人工处理:')
e.failures.forEach((f) => console.log(' ' + f.username.padEnd(14) + f.reason))
}
}
if (apply && runOrg) {
const total = await prisma.organizationUnit.count()
const active = await prisma.organizationUnit.count({ where: { isActive: true } })
const roots = await prisma.organizationUnit.count({ where: { parentId: null, isActive: true } })
console.log('\n===== 落库校验 =====')
console.log(' 组织节点总数 ' + total)
console.log(' 其中有效 ' + active)
console.log(' 根节点数量 ' + roots + (roots === 1 ? ' ✅ 单根树' : ' ⚠️ 预期为 1'))
}
if (!apply) {
console.log('\n(预演结束,未写入任何数据。加 --apply 实际执行)')
}
await prisma.$disconnect()
}
main().catch(async (e) => {
console.error('\n同步失败:', e instanceof Error ? e.message : e)
await prisma.$disconnect()
process.exitCode = 1
})