751 lines
28 KiB
TypeScript
751 lines
28 KiB
TypeScript
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
|
||
}
|