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

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

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

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

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

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

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 }
}