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

526 lines
23 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.

/** @jest-environment node */
import { prisma } from '@/lib/prisma'
import { getEmployeeByUserId } from '@/lib/employee-directory'
import { canManageMaterial } from '@/lib/material-authorization'
import { assertTaskDetailImageEditReady, batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
import {
cleanupTaskDetailChangeStaging,
resolveTaskDetailStagedFile,
withTaskDetailStagedUploads,
} from '@/lib/task-detail-change-staging'
import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications'
import {
decideTaskDetailChange,
expireDueTaskDetailChanges,
recoverMissingTaskDetailChangeRequestNotifications,
recoverStaleApplyingTaskDetailChanges,
submitTaskDetailChange,
} from '@/lib/task-detail-change-approval'
jest.mock('@/lib/prisma', () => ({
prisma: {
material: { findFirst: jest.fn(), findUnique: jest.fn() },
performanceSurveyEmployeeProfile: { findUnique: jest.fn(), findMany: jest.fn() },
performanceSurveyEmployeeDepartmentMembership: { findMany: jest.fn() },
organizationUnit: { findUnique: jest.fn(), findMany: jest.fn() },
taskDetailChangeRequest: {
create: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
},
},
}))
jest.mock('@/lib/employee-directory', () => ({
BINDING_STATUS: { ACTIVE: 'ACTIVE' },
getEmployeeByUserId: jest.fn(),
}))
jest.mock('@/lib/performance-survey-report-sync', () => ({
getActivePerformanceSurveyEmployeeProfileWhere: jest.fn(() => ({
OR: [
{ employmentState: 'ACTIVE' },
{ employmentState: 'UNKNOWN' },
{ employmentState: null },
],
})),
}))
jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() }))
jest.mock('@/lib/user-permission-inherit', () => ({ canUserEditTaskDetailAssets: jest.fn() }))
jest.mock('@/lib/department-permission', () => ({
isSystemAdmin: jest.fn((role: string) => role === 'ADMIN' || role === 'SYSTEM_ADMIN'),
}))
jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn(() => true) }))
jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: () => 'https://sc.example.com' }))
jest.mock('@/lib/safe-redirect', () => ({ buildDingtalkEntryPath: (path: string) => path }))
jest.mock('@/lib/task-detail-asset-edit', () => ({
assertTaskDetailImageEditReady: jest.fn(),
batchEditTaskDetailImages: jest.fn(),
TaskDetailAssetEditError: class extends Error {
constructor(message: string, public statusCode = 400) { super(message) }
},
}))
jest.mock('@/lib/task-detail-change-notifications', () => ({
queueTaskDetailChangeNotification: jest.fn(),
}))
jest.mock('@/lib/task-detail-change-staging', () => ({
cleanupTaskDetailChangeStaging: jest.fn(),
resolveTaskDetailStagedFile: jest.fn(),
withTaskDetailStagedUploads: jest.fn(),
}))
const material = {
id: 'material-1',
userId: 'owner-1',
materialType: '任务-详情',
taskStatus: 'completed',
taskNo: 'TASK-001',
productModel: 'SPU-001',
updatedAt: new Date('2026-08-28T00:00:00.000Z'),
assets: [
{ id: 'asset-1', assetType: 'main-preview' },
{ id: 'detail-1', assetType: 'detail-preview' },
],
}
function employee(levelType: 'M' | 'P', dingtalkUserId = 'employee-ding') {
return {
employeeProfileId: `employee-${levelType}`,
userId: 'user-1',
displayName: levelType === 'M' ? '管理者' : '申请人',
realName: null,
flowerName: null,
jobNumber: 'JS001',
dingtalkUserId,
departmentName: '品牌中心',
departmentPath: '公司/品牌中心',
positionTitle: '设计师',
positionLevel: `${levelType}1`,
levelType,
isLeadership: levelType === 'M',
managerName: '直属主管',
managerDingtalkUserId: 'manager-ding',
employmentState: 'ACTIVE',
employmentStatus: 'active',
}
}
describe('任务详情图片变更主管审批', () => {
beforeEach(() => {
jest.clearAllMocks()
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(material)
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([
{
id: 'manager-employee',
displayName: '直属主管',
dingtalkUserId: 'manager-ding',
employmentState: 'ACTIVE',
departmentMemberships: [{ sourceDeptId: 'department-1', isPrimary: true }],
},
])
;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([{
sourceDeptId: 'department-1',
departmentPath: '集团/品牌中心',
}])
;(prisma.organizationUnit.findUnique as jest.Mock).mockResolvedValue(null)
;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([])
;(canManageMaterial as jest.Mock).mockResolvedValue(true)
;(batchEditTaskDetailImages as jest.Mock).mockResolvedValue({ success: true })
;(assertTaskDetailImageEditReady as jest.Mock).mockResolvedValue(undefined)
;(queueTaskDetailChangeNotification as jest.Mock).mockResolvedValue('notification-1')
;(cleanupTaskDetailChangeStaging as jest.Mock).mockResolvedValue(undefined)
;(resolveTaskDetailStagedFile as jest.Mock).mockReturnValue({
trusted: true,
filePath: '/uploads/staged/new.png',
fileName: 'new.png',
fileSize: 10,
})
})
test('M 级人员有权限时直接应用,不创建审批单', async () => {
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M'))
const result = await submitTaskDetailChange('material-1', {
id: 'user-1',
role: 'USER',
}, [{ kind: 'delete', assetId: 'asset-1' }])
expect(result.mode).toBe('applied')
expect(batchEditTaskDetailImages).toHaveBeenCalledWith(expect.objectContaining({
materialId: 'material-1',
operations: [{ kind: 'delete', assetId: 'asset-1' }],
}))
expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled()
})
test('P 级人员把替换文件转为持久暂存并创建 24 小时审批单', async () => {
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P'))
;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({
id: 'manager-employee',
displayName: '直属主管',
realName: null,
employmentState: 'ACTIVE',
dingtalkUserId: 'manager-ding',
jobNumber: 'M001',
departmentPath: '公司/品牌中心',
positionLevel: 'M1',
userBindings: [{ userId: 'manager-user' }],
})
;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map([
['upload-1', { relativePath: 'task-detail-change-requests/request/new.png', fileName: 'new.png', fileSize: 10 }],
])))
;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({
...data,
material,
submittedAt: new Date(),
}))
const result = await submitTaskDetailChange('material-1', {
id: 'user-1',
role: 'USER',
}, [{ kind: 'replace', assetId: 'asset-1', uploadId: 'upload-1' }])
expect(result.mode).toBe('pending_approval')
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
expect(withTaskDetailStagedUploads).toHaveBeenCalledWith(expect.objectContaining({
ownerUserId: 'user-1',
uploadIds: ['upload-1'],
}))
expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: 'pending_manager_approval',
managerDingtalkUserId: 'manager-ding',
operations: [expect.objectContaining({
kind: 'replace',
assetId: 'asset-1',
stagedFile: expect.objectContaining({ fileName: 'new.png' }),
})],
}),
}))
})
test('P 级负责人仍需直属主管审批,不能因 isLeadership 绕过', async () => {
;(getEmployeeByUserId as jest.Mock).mockResolvedValue({
...employee('P'),
isLeadership: true,
})
;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({
id: 'manager-employee',
displayName: '直属主管',
realName: null,
employmentState: 'ACTIVE',
dingtalkUserId: 'manager-ding',
jobNumber: 'M001',
departmentPath: '公司/品牌中心',
positionLevel: 'M1',
userBindings: [{ userId: 'manager-user' }],
})
;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map()))
;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({
...data,
material,
submittedAt: new Date(),
}))
const result = await submitTaskDetailChange('material-1', {
id: 'user-1',
role: 'USER',
}, [{ kind: 'delete', assetId: 'asset-1' }])
expect(result.mode).toBe('pending_approval')
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalled()
})
test('主管唯一值缺失时按同一主部门唯一M级人员进入主管确认,不提前修改图片', async () => {
;(getEmployeeByUserId as jest.Mock).mockResolvedValue({ ...employee('P'), managerDingtalkUserId: null, managerJobNumber: null, managerName: null })
;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({
id: 'manager-employee', displayName: '部门主管', employmentState: 'ACTIVE', dingtalkUserId: 'manager-ding',
userBindings: [{ userId: 'manager-user' }],
})
;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map()))
;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({ ...data, material, submittedAt: new Date() }))
const result = await submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER' }, [{ kind: 'delete', assetId: 'asset-1' }])
expect(result.mode).toBe('pending_approval')
expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { employeeProfileId: 'employee-P', isPrimary: true },
}))
expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ managerDingtalkUserId: 'manager-ding', managerUserId: 'manager-user' }) }))
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
})
test('跨主图和详情图的变更在暂存前直接拒绝', async () => {
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P'))
await expect(submitTaskDetailChange('material-1', {
id: 'user-1',
role: 'USER',
}, [
{ kind: 'delete', assetId: 'asset-1' },
{ kind: 'delete', assetId: 'detail-1' },
])).rejects.toThrow('一次只能提交同一图片分组')
expect(withTaskDetailStagedUploads).not.toHaveBeenCalled()
expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled()
})
test('来源对应不可靠时提交前拒绝,不能创建审批或暂存文件', async () => {
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P'))
;(assertTaskDetailImageEditReady as jest.Mock).mockRejectedValue(
new TaskDetailAssetEditError('部分图片缺少可靠的来源标识,无法确认对应关系。请删除后重新上传。'),
)
await expect(submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER' }, [
{ kind: 'append', group: 'main', uploadId: 'upload-1' },
])).rejects.toThrow('请联系管理员核对原图与预览图的对应关系')
expect(assertTaskDetailImageEditReady).toHaveBeenCalledWith(expect.objectContaining({ group: 'main' }))
expect(withTaskDetailStagedUploads).not.toHaveBeenCalled()
expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled()
})
test('直属主管批准后才应用暂存操作', async () => {
const pending = {
id: 'request-1',
materialId: 'material-1',
requesterUserId: 'requester-user',
requesterName: '申请人',
managerDingtalkUserId: 'manager-ding',
status: 'pending_manager_approval',
expiresAt: new Date(Date.now() + 60_000),
baseMaterialUpdatedAt: material.updatedAt,
operations: [{
kind: 'replace',
assetId: 'asset-1',
stagedFile: { relativePath: 'task-detail-change-requests/request-1/new.png', fileName: 'new.png', fileSize: 10 },
}],
material,
requesterEmployee: { dingtalkUserId: 'requester-ding' },
}
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding'))
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock)
.mockResolvedValueOnce(pending)
.mockResolvedValueOnce({ ...pending, status: 'applied' })
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ updatedAt: material.updatedAt })
;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({})
const result = await decideTaskDetailChange('request-1', {
id: 'manager-user',
role: 'USER',
}, 'approve')
expect(result.status).toBe('applied')
expect(batchEditTaskDetailImages).toHaveBeenCalledWith(expect.objectContaining({
operatorUserId: 'requester-user',
expectedMaterialUpdatedAt: material.updatedAt,
approvalRequestId: 'request-1',
operations: [expect.objectContaining({
kind: 'replace',
stagedFile: expect.objectContaining({ trusted: true, fileName: 'new.png' }),
})],
}))
expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-1')
})
test('系统管理员可以审计查看但不能代替直属主管批准', async () => {
const pending = {
id: 'request-manager-only',
materialId: 'material-1',
requesterUserId: 'requester-user',
managerDingtalkUserId: 'manager-ding',
status: 'pending_manager_approval',
expiresAt: new Date(Date.now() + 60_000),
material,
requesterEmployee: { dingtalkUserId: 'requester-ding' },
}
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'other-manager-ding'))
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue(pending)
await expect(decideTaskDetailChange('request-manager-only', {
id: 'admin-user',
role: 'ADMIN',
}, 'approve')).rejects.toThrow('只有申请人的直属主管可以确认')
expect(prisma.taskDetailChangeRequest.updateMany).not.toHaveBeenCalled()
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
})
test('已离职的原直属主管不能继续批准待处理申请', async () => {
const pending = {
id: 'request-resigned-manager',
materialId: 'material-1',
requesterUserId: 'requester-user',
managerDingtalkUserId: 'manager-ding',
status: 'pending_manager_approval',
expiresAt: new Date(Date.now() + 60_000),
material,
requesterEmployee: { dingtalkUserId: 'requester-ding' },
}
;(getEmployeeByUserId as jest.Mock).mockResolvedValue({
...employee('M', 'manager-ding'),
employmentState: 'RESIGNED',
})
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue(pending)
await expect(decideTaskDetailChange('request-resigned-manager', {
id: 'former-manager-user',
role: 'USER',
}, 'approve')).rejects.toThrow('您无权查看这条图片变更申请')
expect(prisma.taskDetailChangeRequest.updateMany).not.toHaveBeenCalled()
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
})
test.each(['UNKNOWN', null])('在册状态为 %s 的直属主管仍可处理已有申请', async (employmentState) => {
const pending = {
id: `request-manager-${employmentState || 'null'}`,
materialId: 'material-1',
requesterUserId: 'requester-user',
requesterName: '申请人',
managerDingtalkUserId: 'manager-ding',
status: 'pending_manager_approval',
expiresAt: new Date(Date.now() + 60_000),
baseMaterialUpdatedAt: material.updatedAt,
operations: [{ kind: 'delete', assetId: 'asset-1' }],
material,
requesterEmployee: { dingtalkUserId: 'requester-ding' },
}
;(getEmployeeByUserId as jest.Mock).mockResolvedValue({
...employee('M', 'manager-ding'),
employmentState,
})
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock)
.mockResolvedValueOnce(pending)
.mockResolvedValueOnce({ ...pending, status: 'rejected' })
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({})
await expect(decideTaskDetailChange(pending.id, {
id: 'manager-user',
role: 'USER',
}, 'reject')).resolves.toMatchObject({ status: 'rejected' })
})
test('批准时正式素材已变化会停止应用、清理暂存并通知申请人', async () => {
const pending = {
id: 'request-stale',
materialId: 'material-1',
requesterUserId: 'requester-user',
requesterName: '申请人',
managerDingtalkUserId: 'manager-ding',
status: 'pending_manager_approval',
expiresAt: new Date(Date.now() + 60_000),
baseMaterialUpdatedAt: material.updatedAt,
operations: [{ kind: 'delete', assetId: 'asset-1' }],
material,
requesterEmployee: { dingtalkUserId: 'requester-ding' },
}
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding'))
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock)
.mockResolvedValueOnce(pending)
.mockResolvedValueOnce({ ...pending, status: 'apply_failed' })
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
updatedAt: new Date(material.updatedAt.getTime() + 1000),
})
;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({})
await expect(decideTaskDetailChange('request-stale', {
id: 'manager-user',
role: 'USER',
}, 'approve')).rejects.toThrow('正式素材在审批期间已更新')
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
expect(prisma.taskDetailChangeRequest.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'apply_failed' }),
}))
expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-stale')
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({
eventType: 'task_detail_change.apply_failed',
}))
})
test('应用业务错误保存失败原因并透传状态码,不伪装成成功', async () => {
const pending = {
id: 'request-failed', materialId: 'material-1', requesterUserId: 'requester-user', requesterName: '申请人',
managerDingtalkUserId: 'manager-ding', status: 'pending_manager_approval', expiresAt: new Date(Date.now() + 60_000),
baseMaterialUpdatedAt: material.updatedAt, operations: [{ kind: 'delete', assetId: 'asset-1' }], material,
requesterEmployee: { dingtalkUserId: 'requester-ding' },
}
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding'))
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValueOnce(pending).mockResolvedValueOnce({ ...pending, status: 'apply_failed' })
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ updatedAt: material.updatedAt })
;(batchEditTaskDetailImages as jest.Mock).mockRejectedValueOnce(new TaskDetailAssetEditError('插图位置已失效', 409))
await expect(decideTaskDetailChange('request-failed', { id: 'manager-user', role: 'USER' }, 'approve')).rejects.toMatchObject({ message: '插图位置已失效', statusCode: 409 })
expect(prisma.taskDetailChangeRequest.update).toHaveBeenCalledWith({ where: { id: 'request-failed' }, data: { status: 'apply_failed', applyError: '插图位置已失效' } })
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'task_detail_change.apply_failed' }))
})
test('超过 24 小时未处理的申请自动否决并清理暂存', async () => {
;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-expired' }])
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue({
id: 'request-expired',
status: 'expired',
requesterUserId: 'requester-user',
requesterName: '申请人',
requesterEmployee: { dingtalkUserId: 'requester-ding' },
material,
})
await expect(expireDueTaskDetailChanges()).resolves.toBe(1)
expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-expired')
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({
eventType: 'task_detail_change.expired',
}))
})
test('审批单存在但主管通知 outbox 缺失时会补偿排队', async () => {
;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{
id: 'request-missing-notice',
managerUserId: 'manager-user',
managerDingtalkUserId: 'manager-ding',
managerName: '直属主管',
requesterName: '申请人',
material,
operations: [{ kind: 'delete', assetId: 'asset-1' }],
expiresAt: new Date(Date.now() + 60_000),
}])
await expect(recoverMissingTaskDetailChangeRequestNotifications()).resolves.toBe(1)
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({
eventType: 'task_detail_change.requested',
businessKey: 'task-detail-change:request-missing-notice:requested',
}))
})
test('陈旧 applying 会转失败、清理暂存并通知申请人', async () => {
const now = new Date('2026-08-28T12:00:00.000Z')
;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-stale-applying' }])
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue({
id: 'request-stale-applying',
status: 'apply_failed',
requesterUserId: 'requester-user',
requesterName: '申请人',
requesterEmployee: { dingtalkUserId: 'requester-ding' },
material,
})
await expect(recoverStaleApplyingTaskDetailChanges(now)).resolves.toBe(1)
expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ id: 'request-stale-applying', status: 'applying' }),
data: expect.objectContaining({ status: 'apply_failed' }),
}))
expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-stale-applying')
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({
eventType: 'task_detail_change.apply_failed',
}))
})
})