素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时 逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随 上游 HR 系统自动更新。 人员身份 - 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一 - users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号 - 移除钉钉登录的同名自动合并,同名冲突一律转人工核对 - 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权 组织架构 - 新增 OrganizationUnit,直接镜像上游 HR 部门树 - 不再从员工部门路径倒推,同名部门不会再被合并 - 上游已删除的部门下线而非物理删除 人员状态 - 新增规范化的 employmentState,取代对混杂原始值做关键词匹配 - 接入上游离职表,离职判定不再依赖中文关键词 - 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者 素材归属 - 归属变更纳入正式转移,记录最初上传者、转移流水与理由 - 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名 - 编辑素材的权限判定统一到 canManageMaterial - 修复任务-详情上传把邮箱误存为作者名的问题 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
/**
|
|
* 素材归属转移
|
|
*
|
|
* 此前系统里的"转移"是隐式发生的:在编辑素材时改掉作者,`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 }
|
|
}
|