gongxiangp2/lib/task-detail-change-approval.ts

751 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from 'crypto'
import { getEmployeeByUserId, BINDING_STATUS } from '@/lib/employee-directory'
import { canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit'
import { canManageMaterial } from '@/lib/material-authorization'
import { EmployeeManagerResolutionError, resolveEmployeeDirectManagerProfile } from '@/lib/employee-manager'
import { canAccessMaterialPrivacy, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy'
import { isProductPackageType } from '@/lib/material-type-mapper'
import { prisma } from '@/lib/prisma'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { buildDingtalkEntryPath } from '@/lib/safe-redirect'
import { isSystemAdmin } from '@/lib/department-permission'
import {
cleanupTaskDetailChangeStaging,
resolveTaskDetailStagedFile,
withTaskDetailStagedUploads,
type TaskDetailStagedFile,
} from '@/lib/task-detail-change-staging'
import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications'
import { batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
const REQUEST_STATUS = {
PENDING: 'pending_manager_approval',
APPLYING: 'applying',
APPLIED: 'applied',
REJECTED: 'rejected',
EXPIRED: 'expired',
APPLY_FAILED: 'apply_failed',
} as const
const APPROVAL_WINDOW_MS = 24 * 60 * 60 * 1000
const STALE_APPLYING_MS = 30 * 60 * 1000
const MAX_OPERATIONS = 100
export class TaskDetailChangeApprovalError extends Error {
constructor(message: string, public statusCode = 400) {
super(message)
this.name = 'TaskDetailChangeApprovalError'
}
}
export type TaskDetailChangeOperationInput =
| { kind: 'delete'; assetId: string }
| { kind: 'rename'; assetId: string; newFileName: string }
| { kind: 'replace'; assetId: string; uploadId: string }
| { kind: 'append'; group: 'main' | 'sku'; uploadId: string }
| {
kind: 'insert'
group: 'detail'
uploadId: string
afterAssetId: string
beforeAssetId: string
}
type StoredOperation = Omit<TaskDetailChangeOperationInput, 'uploadId'> & {
stagedFile?: TaskDetailStagedFile
}
type Actor = {
id: string
role?: string | null
isSuperAdmin?: boolean
}
function clean(value: unknown) {
const text = String(value ?? '').trim()
return text || null
}
function normalizeOperations(value: unknown): TaskDetailChangeOperationInput[] {
const rows = Array.isArray(value) ? value : []
if (rows.length === 0) throw new TaskDetailChangeApprovalError('图片变更不能为空')
if (rows.length > MAX_OPERATIONS) {
throw new TaskDetailChangeApprovalError(`一次最多提交 ${MAX_OPERATIONS} 项图片变更`)
}
const operations: TaskDetailChangeOperationInput[] = []
const targetAssetIds = new Set<string>()
for (const raw of rows) {
const row = raw as Record<string, unknown>
const kind = clean(row.kind)
if (kind === 'delete') {
const assetId = clean(row.assetId)
if (!assetId) throw new TaskDetailChangeApprovalError('删除操作缺少图片标识')
if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改')
targetAssetIds.add(assetId)
operations.push({ kind, assetId })
continue
}
if (kind === 'rename') {
const assetId = clean(row.assetId)
const newFileName = clean(row.newFileName)
if (!assetId || !newFileName) throw new TaskDetailChangeApprovalError('改名操作参数不完整')
if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改')
targetAssetIds.add(assetId)
operations.push({ kind, assetId, newFileName })
continue
}
if (kind === 'replace') {
const assetId = clean(row.assetId)
const uploadId = clean(row.uploadId)
if (!assetId || !uploadId) throw new TaskDetailChangeApprovalError('替换操作参数不完整')
if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改')
targetAssetIds.add(assetId)
operations.push({ kind, assetId, uploadId })
continue
}
if (kind === 'append') {
const group = clean(row.group)
const uploadId = clean(row.uploadId)
if ((group !== 'main' && group !== 'sku') || !uploadId) {
throw new TaskDetailChangeApprovalError('追加图片操作参数不完整')
}
operations.push({ kind, group, uploadId })
continue
}
if (kind === 'insert') {
const group = clean(row.group)
const uploadId = clean(row.uploadId)
const afterAssetId = clean(row.afterAssetId)
const beforeAssetId = clean(row.beforeAssetId)
if (group !== 'detail' || !uploadId || !afterAssetId || !beforeAssetId) {
throw new TaskDetailChangeApprovalError('详情图插入位置或图片参数不完整')
}
operations.push({ kind, group, uploadId, afterAssetId, beforeAssetId })
continue
}
throw new TaskDetailChangeApprovalError(`不支持的图片变更类型:${kind || '空'}`)
}
return operations
}
function assertSingleOperationGroup(
operations: TaskDetailChangeOperationInput[],
assets: Array<{ id: string; assetType: string }>,
) {
const assetGroups = new Map<string, 'main' | 'sku' | 'detail'>()
for (const asset of assets) {
if (asset.assetType === 'main-preview') assetGroups.set(asset.id, 'main')
if (asset.assetType === 'sku-preview') assetGroups.set(asset.id, 'sku')
if (asset.assetType === 'detail-preview') assetGroups.set(asset.id, 'detail')
}
const groups = new Set<'main' | 'sku' | 'detail'>()
for (const operation of operations) {
if (operation.kind === 'append') {
groups.add(operation.group)
continue
}
if (operation.kind === 'insert') {
if (
assetGroups.get(operation.afterAssetId) !== 'detail'
|| assetGroups.get(operation.beforeAssetId) !== 'detail'
) {
throw new TaskDetailChangeApprovalError('详情图插入位置不存在或不属于该素材', 404)
}
groups.add('detail')
continue
}
const group = assetGroups.get(operation.assetId)
if (!group) {
throw new TaskDetailChangeApprovalError('要修改的图片不存在或不属于该素材', 404)
}
groups.add(group)
}
if (groups.size !== 1) {
throw new TaskDetailChangeApprovalError('一次只能提交同一图片分组(主图、SKU图或详情图)的变更,请分组提交')
}
}
async function loadAuthorizedContext(materialId: string, actor: Actor) {
const [material, employee] = await Promise.all([
prisma.material.findFirst({
where: { id: materialId, deletedAt: null },
select: {
id: true,
userId: true,
materialType: true,
...TASK_DETAIL_PRIVACY_SELECT,
taskStatus: true,
taskNo: true,
productModel: true,
updatedAt: true,
assets: {
where: { assetType: { in: ['main-preview', 'sku-preview', 'detail-preview'] } },
select: { id: true, assetType: true },
},
},
}),
getEmployeeByUserId(actor.id),
])
if (!material) throw new TaskDetailChangeApprovalError('素材不存在或已删除', 404)
if (!(await canAccessMaterialPrivacy(material, actor.id))) {
throw new TaskDetailChangeApprovalError('无权限访问该私密素材', 403)
}
if (!isProductPackageType(material.materialType)) {
throw new TaskDetailChangeApprovalError('只有任务-详情素材支持图片变更')
}
if (material.taskStatus !== 'completed') {
throw new TaskDetailChangeApprovalError('素材尚未完成上传,暂不能修改图片')
}
let allowed = await canManageMaterial(
{ id: actor.id, role: actor.role, isSuperAdmin: actor.isSuperAdmin },
{ id: material.id, userId: material.userId },
)
if (!allowed) allowed = await canUserEditTaskDetailAssets(actor.id)
if (!allowed) throw new TaskDetailChangeApprovalError('您没有权限修改此素材', 403)
return { material, employee }
}
function employeeSnapshot(employee: NonNullable<Awaited<ReturnType<typeof getEmployeeByUserId>>>) {
return {
employeeProfileId: employee.employeeProfileId,
userId: employee.userId,
displayName: employee.displayName,
jobNumber: employee.jobNumber,
dingtalkUserId: employee.dingtalkUserId,
departmentName: employee.departmentName,
departmentPath: employee.departmentPath,
positionTitle: employee.positionTitle,
positionLevel: employee.positionLevel,
levelType: employee.levelType,
isLeadership: employee.isLeadership,
managerName: employee.managerName,
managerDingtalkUserId: employee.managerDingtalkUserId,
}
}
async function resolveManager(employee: NonNullable<Awaited<ReturnType<typeof getEmployeeByUserId>>>) {
let resolvedManager
try {
resolvedManager = await resolveEmployeeDirectManagerProfile(employee)
} catch (error) {
if (error instanceof EmployeeManagerResolutionError) {
throw new TaskDetailChangeApprovalError(error.message, 409)
}
throw error
}
const managerDingtalkUserId = clean(resolvedManager.dingtalkUserId)
if (!managerDingtalkUserId) {
throw new TaskDetailChangeApprovalError('直属主管缺少钉钉唯一值,暂不能提交图片变更申请', 409)
}
const manager = await prisma.performanceSurveyEmployeeProfile.findUnique({
where: { dingtalkUserId: managerDingtalkUserId },
include: {
userBindings: {
where: { status: BINDING_STATUS.ACTIVE },
select: { userId: true },
take: 1,
},
},
})
if (!manager || manager.employmentState !== 'ACTIVE') {
throw new TaskDetailChangeApprovalError('直属主管账号无有效在职档案,暂不能提交图片变更申请', 409)
}
return {
employee: manager,
userId: manager.userBindings[0]?.userId || null,
dingtalkUserId: managerDingtalkUserId,
name: manager.displayName || manager.realName || employee.managerName || '直属主管',
}
}
function isDirectApplyActor(actor: Actor, employee: Awaited<ReturnType<typeof getEmployeeByUserId>>) {
const positionLevel = employee?.positionLevel?.trim().toUpperCase()
return Boolean(
actor.isSuperAdmin
|| isSystemAdmin(String(actor.role || ''))
|| employee?.levelType?.toUpperCase() === 'M'
|| positionLevel?.startsWith('M')
)
}
function actionUrl(requestId: string) {
return `${getPublicAppOrigin()}${buildDingtalkEntryPath(`/task-detail-change-requests/${requestId}`)}`
}
async function queueManagerNotification(request: {
id: string
managerUserId: string | null
managerDingtalkUserId: string
managerName: string
requesterName: string
material: { taskNo: string | null; productModel: string }
operations: unknown[]
expiresAt: Date
}) {
await queueTaskDetailChangeNotification({
requestId: request.id,
recipientUserId: request.managerUserId,
dingtalkUserId: request.managerDingtalkUserId,
recipientName: request.managerName,
eventType: 'task_detail_change.requested',
businessKey: `task-detail-change:${request.id}:requested`,
title: '任务详情图片变更待确认',
content: [
'### ◆ 图片变更待确认',
'',
`**素材:** ${request.material.taskNo || request.material.productModel}`,
`**提交人:** ${request.requesterName}`,
`**变更数量:** ${request.operations.length} 项`,
`**截止时间:** ${request.expiresAt.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
'',
'> 未确认前正式素材不会改变;超过截止时间将自动否决。',
].join('\n'),
actionUrl: actionUrl(request.id),
actionTitle: '立即确认',
})
}
async function queueRequesterResultNotification(request: any, label: string) {
const dingtalkUserId = clean(request.requesterEmployee?.dingtalkUserId)
if (!dingtalkUserId) return
await queueTaskDetailChangeNotification({
requestId: request.id,
recipientUserId: request.requesterUserId,
dingtalkUserId,
recipientName: request.requesterName,
eventType: `task_detail_change.${request.status}`,
businessKey: `task-detail-change:${request.id}:${request.status}`,
title: '任务详情图片变更结果',
content: `您的图片变更申请已${label}。\n\n> 素材:${request.material.taskNo || request.material.productModel}`,
actionUrl: actionUrl(request.id),
actionTitle: '查看结果',
})
}
function storeOperations(
operations: TaskDetailChangeOperationInput[],
staged: Map<string, TaskDetailStagedFile>,
): StoredOperation[] {
return operations.map((operation) => {
if (!('uploadId' in operation)) return operation as StoredOperation
const stagedFile = staged.get(operation.uploadId)
if (!stagedFile) throw new TaskDetailChangeApprovalError('图片持久暂存结果缺失', 500)
const { uploadId: _uploadId, ...rest } = operation
return { ...rest, stagedFile } as StoredOperation
})
}
function applyOperations(requestId: string, operations: StoredOperation[]) {
return operations.map((operation) => {
if (!operation.stagedFile) return operation
return {
...operation,
stagedFile: resolveTaskDetailStagedFile(requestId, operation.stagedFile),
}
})
}
export async function submitTaskDetailChange(
materialId: string,
actor: Actor,
rawOperations: unknown,
) {
const operations = normalizeOperations(rawOperations)
const { material, employee } = await loadAuthorizedContext(materialId, actor)
assertSingleOperationGroup(operations, material.assets)
if (isDirectApplyActor(actor, employee)) {
const data = await batchEditTaskDetailImages({
materialId,
operatorUserId: actor.id,
operatorRole: actor.role,
operations: operations as any,
})
return { mode: 'applied' as const, data }
}
if (!employee) {
throw new TaskDetailChangeApprovalError('当前账号没有匹配花名册,暂不能提交主管审批', 403)
}
const manager = await resolveManager(employee)
const requestId = randomUUID()
const expiresAt = new Date(Date.now() + APPROVAL_WINDOW_MS)
const uploadIds = operations.filter((item): item is Extract<TaskDetailChangeOperationInput, { uploadId: string }> => (
'uploadId' in item
)).map((item) => item.uploadId)
const request = await withTaskDetailStagedUploads({
requestId,
ownerUserId: actor.id,
uploadIds,
commit: async (staged) => {
const storedOperations = storeOperations(operations, staged)
return prisma.taskDetailChangeRequest.create({
data: {
id: requestId,
materialId,
requesterUserId: actor.id,
requesterEmployeeId: employee.employeeProfileId,
requesterName: employee.displayName,
requesterSnapshot: employeeSnapshot(employee) as any,
requesterPositionLevel: employee.positionLevel,
requesterLevelType: employee.levelType,
requesterIsLeadership: employee.isLeadership,
managerEmployeeId: manager.employee.id,
managerUserId: manager.userId,
managerDingtalkUserId: manager.dingtalkUserId,
managerName: manager.name,
managerSnapshot: {
id: manager.employee.id,
dingtalkUserId: manager.employee.dingtalkUserId,
jobNumber: manager.employee.jobNumber,
displayName: manager.name,
departmentPath: manager.employee.departmentPath,
positionLevel: manager.employee.positionLevel,
} as any,
status: REQUEST_STATUS.PENDING,
operations: storedOperations as any,
baseMaterialUpdatedAt: material.updatedAt,
expiresAt,
},
include: { material: { select: { taskNo: true, productModel: true } } },
})
},
})
await queueManagerNotification({
...request,
operations,
expiresAt,
}).catch((error) => {
console.error('[task-detail-change] 审批单已创建,但主管通知排队失败:', error)
})
return {
mode: 'pending_approval' as const,
request: {
id: request.id,
status: request.status,
managerName: request.managerName,
expiresAt: request.expiresAt,
},
}
}
async function loadRequestForActor(id: string, actor: Actor) {
const [request, employee] = await Promise.all([
prisma.taskDetailChangeRequest.findUnique({
where: { id },
include: {
material: { select: { id: true, taskNo: true, productModel: true, materialType: true, updatedAt: true, ...TASK_DETAIL_PRIVACY_SELECT } },
requesterEmployee: { select: { dingtalkUserId: true } },
},
}),
getEmployeeByUserId(actor.id),
])
if (!request) throw new TaskDetailChangeApprovalError('图片变更申请不存在', 404)
if (!(await canAccessMaterialPrivacy(request.material, actor.id))) {
throw new TaskDetailChangeApprovalError('无权限访问该私密素材', 403)
}
const canDecide = Boolean(
employee?.dingtalkUserId && employee.dingtalkUserId === request.managerDingtalkUserId,
)
const canView = canDecide
|| request.requesterUserId === actor.id
|| actor.isSuperAdmin
|| isSystemAdmin(String(actor.role || ''))
if (!canView) throw new TaskDetailChangeApprovalError('您无权查看这条图片变更申请', 403)
return { request, employee, canDecide }
}
export async function getTaskDetailChangeRequest(id: string, actor: Actor) {
const { request, canDecide } = await loadRequestForActor(id, actor)
const operations = (Array.isArray(request.operations) ? request.operations : []) as StoredOperation[]
return {
id: request.id,
status: request.status,
requesterName: request.requesterName,
managerName: request.managerName,
material: request.material,
operations: operations.map(({ stagedFile, ...operation }) => ({
...operation,
fileName: stagedFile?.fileName || null,
})),
submittedAt: request.submittedAt,
expiresAt: request.expiresAt,
decidedAt: request.decidedAt,
appliedAt: request.appliedAt,
applyError: request.applyError,
canDecide: canDecide && request.status === REQUEST_STATUS.PENDING && request.expiresAt > new Date(),
}
}
export async function listTaskDetailChanges(materialId: string, actor: Actor) {
const material = await prisma.material.findFirst({
where: { id: materialId, deletedAt: null },
select: TASK_DETAIL_PRIVACY_SELECT,
})
if (!material || !(await canAccessMaterialPrivacy(material, actor.id))) {
throw new TaskDetailChangeApprovalError('无权限访问该素材', 403)
}
const employee = await getEmployeeByUserId(actor.id)
const privileged = Boolean(actor.isSuperAdmin || isSystemAdmin(String(actor.role || '')))
const rows = await prisma.taskDetailChangeRequest.findMany({
where: {
materialId,
...(privileged
? {}
: {
OR: [
{ requesterUserId: actor.id },
...(employee?.dingtalkUserId
? [{ managerDingtalkUserId: employee.dingtalkUserId }]
: []),
],
}),
},
orderBy: { createdAt: 'desc' },
take: 10,
select: {
id: true,
status: true,
requesterName: true,
managerName: true,
operations: true,
submittedAt: true,
expiresAt: true,
decidedAt: true,
appliedAt: true,
applyError: true,
},
})
return rows.map((row) => ({
...row,
operationCount: Array.isArray(row.operations) ? row.operations.length : 0,
operations: undefined,
}))
}
export async function decideTaskDetailChange(
id: string,
actor: Actor,
decision: 'approve' | 'reject',
) {
const { request, canDecide } = await loadRequestForActor(id, actor)
if (!canDecide) throw new TaskDetailChangeApprovalError('只有申请人的直属主管可以确认', 403)
const now = new Date()
if (request.status !== REQUEST_STATUS.PENDING) {
throw new TaskDetailChangeApprovalError('该申请已经处理,不能重复确认', 409)
}
if (request.expiresAt <= now) {
const expired = await prisma.taskDetailChangeRequest.updateMany({
where: { id, status: REQUEST_STATUS.PENDING },
data: { status: REQUEST_STATUS.EXPIRED, decidedAt: now },
})
if (expired.count === 1) {
await cleanupTaskDetailChangeStaging(id).catch(() => undefined)
const final = await prisma.taskDetailChangeRequest.findUnique({
where: { id },
include: {
material: { select: { taskNo: true, productModel: true } },
requesterEmployee: { select: { dingtalkUserId: true } },
},
})
if (final) {
await queueRequesterResultNotification(final, '因超过 24 小时未确认而自动否决').catch(() => undefined)
}
}
throw new TaskDetailChangeApprovalError('该申请已超时并自动否决', 409)
}
if (decision === 'reject') {
const updated = await prisma.taskDetailChangeRequest.updateMany({
where: { id, status: REQUEST_STATUS.PENDING, expiresAt: { gt: now } },
data: {
status: REQUEST_STATUS.REJECTED,
decidedAt: now,
decidedByUserId: actor.id,
},
})
if (updated.count !== 1) throw new TaskDetailChangeApprovalError('申请状态已变化,请刷新', 409)
await cleanupTaskDetailChangeStaging(id).catch(() => undefined)
const final = await prisma.taskDetailChangeRequest.findUnique({
where: { id },
include: {
material: { select: { taskNo: true, productModel: true } },
requesterEmployee: { select: { dingtalkUserId: true } },
},
})
if (final) await queueRequesterResultNotification(final, '被主管否决').catch(() => undefined)
return { status: REQUEST_STATUS.REJECTED }
}
const claimed = await prisma.taskDetailChangeRequest.updateMany({
where: { id, status: REQUEST_STATUS.PENDING, expiresAt: { gt: now } },
data: {
status: REQUEST_STATUS.APPLYING,
decidedAt: now,
decidedByUserId: actor.id,
},
})
if (claimed.count !== 1) throw new TaskDetailChangeApprovalError('申请状态已变化,请刷新', 409)
try {
const material = await prisma.material.findUnique({
where: { id: request.materialId },
select: { updatedAt: true },
})
if (!material || material.updatedAt.getTime() !== request.baseMaterialUpdatedAt.getTime()) {
throw new TaskDetailChangeApprovalError('正式素材在审批期间已更新,本申请已停止应用', 409)
}
const operations = applyOperations(
id,
(Array.isArray(request.operations) ? request.operations : []) as StoredOperation[],
)
await batchEditTaskDetailImages({
materialId: request.materialId,
operatorUserId: request.requesterUserId,
expectedMaterialUpdatedAt: request.baseMaterialUpdatedAt,
approvalRequestId: id,
operations: operations as any,
})
await cleanupTaskDetailChangeStaging(id).catch(() => undefined)
const final = await prisma.taskDetailChangeRequest.findUnique({
where: { id },
include: {
material: { select: { taskNo: true, productModel: true } },
requesterEmployee: { select: { dingtalkUserId: true } },
},
})
if (final) await queueRequesterResultNotification(final, '由直属主管批准并生效').catch(() => undefined)
return { status: REQUEST_STATUS.APPLIED }
} catch (error) {
const message = error instanceof Error ? error.message : '批准后应用图片变更失败'
await prisma.taskDetailChangeRequest.update({
where: { id },
data: { status: REQUEST_STATUS.APPLY_FAILED, applyError: message },
})
await cleanupTaskDetailChangeStaging(id).catch(() => undefined)
const final = await prisma.taskDetailChangeRequest.findUnique({
where: { id },
include: {
material: { select: { taskNo: true, productModel: true } },
requesterEmployee: { select: { dingtalkUserId: true } },
},
})
if (final) {
await queueRequesterResultNotification(final, '在批准后应用失败,请重新提交').catch(() => undefined)
}
if (error instanceof TaskDetailAssetEditError) {
throw new TaskDetailChangeApprovalError(message, error.statusCode)
}
throw error
}
}
export async function expireDueTaskDetailChanges(now = new Date()) {
const due = await prisma.taskDetailChangeRequest.findMany({
where: { status: REQUEST_STATUS.PENDING, expiresAt: { lte: now } },
select: { id: true },
orderBy: { expiresAt: 'asc' },
take: 50,
})
let expired = 0
for (const row of due) {
const changed = await prisma.taskDetailChangeRequest.updateMany({
where: { id: row.id, status: REQUEST_STATUS.PENDING, expiresAt: { lte: now } },
data: { status: REQUEST_STATUS.EXPIRED, decidedAt: now },
})
if (changed.count !== 1) continue
expired += 1
await cleanupTaskDetailChangeStaging(row.id).catch(() => undefined)
const final = await prisma.taskDetailChangeRequest.findUnique({
where: { id: row.id },
include: {
material: { select: { taskNo: true, productModel: true } },
requesterEmployee: { select: { dingtalkUserId: true } },
},
})
if (final) await queueRequesterResultNotification(final, '因超过 24 小时未确认而自动否决').catch(() => undefined)
}
return expired
}
/**
* 补偿“审批单已持久化、通知 outbox 落库前进程退出/数据库瞬时失败”的窄窗口。
* 唯一键保证多实例并发扫描也只会生成一条待发送通知。
*/
export async function recoverMissingTaskDetailChangeRequestNotifications() {
const rows = await prisma.taskDetailChangeRequest.findMany({
where: {
status: REQUEST_STATUS.PENDING,
expiresAt: { gt: new Date() },
notifications: {
none: { eventType: 'task_detail_change.requested' },
},
},
orderBy: { createdAt: 'asc' },
take: 20,
include: {
material: { select: { taskNo: true, productModel: true } },
},
})
let queued = 0
for (const request of rows) {
await queueManagerNotification({
id: request.id,
managerUserId: request.managerUserId,
managerDingtalkUserId: request.managerDingtalkUserId,
managerName: request.managerName,
requesterName: request.requesterName,
material: request.material,
operations: Array.isArray(request.operations) ? request.operations : [],
expiresAt: request.expiresAt,
})
queued += 1
}
return queued
}
/**
* 正式素材更新与 request=applied 在同一数据库事务提交;因此长时间停留在
* applying 只可能是进程中断或处理超时。先转失败会让迟到事务因状态不匹配而回滚。
*/
export async function recoverStaleApplyingTaskDetailChanges(now = new Date()) {
const staleBefore = new Date(now.getTime() - STALE_APPLYING_MS)
const rows = await prisma.taskDetailChangeRequest.findMany({
where: { status: REQUEST_STATUS.APPLYING, updatedAt: { lte: staleBefore } },
select: { id: true },
orderBy: { updatedAt: 'asc' },
take: 50,
})
let failed = 0
for (const row of rows) {
const changed = await prisma.taskDetailChangeRequest.updateMany({
where: {
id: row.id,
status: REQUEST_STATUS.APPLYING,
updatedAt: { lte: staleBefore },
},
data: {
status: REQUEST_STATUS.APPLY_FAILED,
applyError: '批准后的图片应用进程中断或超时,请重新提交变更',
},
})
if (changed.count !== 1) continue
failed += 1
await cleanupTaskDetailChangeStaging(row.id).catch(() => undefined)
const final = await prisma.taskDetailChangeRequest.findUnique({
where: { id: row.id },
include: {
material: { select: { taskNo: true, productModel: true } },
requesterEmployee: { select: { dingtalkUserId: true } },
},
})
if (final) {
await queueRequesterResultNotification(final, '因应用进程中断或超时而失败,请重新提交').catch(() => undefined)
}
}
return failed
}