fix: preserve insertion seams through approved image deletions
This commit is contained in:
parent
87919a3943
commit
b7592fa687
@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import TaskDetailChangeRequestPage from './page'
|
||||
@ -11,11 +11,20 @@ const createJsonResponse = <T,>(body: T, status = 200) => ({
|
||||
json: async () => body,
|
||||
})
|
||||
|
||||
const requestRecord = (status = 'pending_manager_approval', applyError: string | null = null) => ({
|
||||
success: true,
|
||||
data: { id: 'request-1', status, applyError, canDecide: status === 'pending_manager_approval',
|
||||
managerName: '测试主管', requesterName: '测试提交人', material: { productModel: '测试素材', materialType: '任务-详情' },
|
||||
operations: [{ kind: 'delete', assetId: 'asset-1' }],
|
||||
},
|
||||
})
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'request-1' }),
|
||||
}))
|
||||
|
||||
describe('TaskDetailChangeRequestPage', () => {
|
||||
afterEach(() => { jest.useRealTimers() })
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
@ -82,4 +91,84 @@ describe('TaskDetailChangeRequestPage', () => {
|
||||
expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '否决' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('applying is shown as approved and processing, with read-only refresh instead of decision buttons', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = jest.fn(async () => createJsonResponse(requestRecord('applying')))
|
||||
global.fetch = fetchMock as jest.Mock
|
||||
render(<TaskDetailChangeRequestPage />)
|
||||
expect(await screen.findByText(/主管已批准,正在应用图片变更/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: '刷新状态' }))
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls.every((call: unknown[]) => !(call[1] as RequestInit)?.method)).toBe(true)
|
||||
})
|
||||
|
||||
test('failed decision refreshes persisted state and retains the specific apply error', async () => {
|
||||
const user = userEvent.setup()
|
||||
let submitted = false
|
||||
const reason = '插图位置不存在或已被本次批量操作改动,请刷新后重试'
|
||||
global.fetch = jest.fn(async (_input, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') {
|
||||
submitted = true
|
||||
return createJsonResponse({ error: reason }, 409)
|
||||
}
|
||||
return createJsonResponse(requestRecord(submitted ? 'apply_failed' : 'pending_manager_approval', submitted ? reason : null))
|
||||
}) as jest.Mock
|
||||
render(<TaskDetailChangeRequestPage />)
|
||||
await user.click(await screen.findByRole('button', { name: '批准' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(reason)
|
||||
expect(screen.getAllByText('应用失败')).toHaveLength(2)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('本单不能重复批准')
|
||||
await user.click(screen.getByRole('button', { name: '刷新状态' }))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(reason)
|
||||
expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('a slow POST can be reconciled by read-only refresh without sending another approval', async () => {
|
||||
const user = userEvent.setup()
|
||||
let status = 'pending_manager_approval'
|
||||
let finish!: (response: ReturnType<typeof createJsonResponse>) => void
|
||||
const fetchMock = jest.fn((_input, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') {
|
||||
status = 'applying'
|
||||
return new Promise(resolve => { finish = resolve })
|
||||
}
|
||||
return Promise.resolve(createJsonResponse(requestRecord(status)))
|
||||
})
|
||||
global.fetch = fetchMock as jest.Mock
|
||||
render(<TaskDetailChangeRequestPage />)
|
||||
await user.click(await screen.findByRole('button', { name: '批准' }))
|
||||
expect(screen.getByText(/正在提交审批并处理图片变更/)).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: '刷新状态' }))
|
||||
expect(await screen.findByText(/主管已批准,正在应用图片变更/)).toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.filter(([,init]) => init?.method === 'POST')).toHaveLength(1)
|
||||
status = 'applied'
|
||||
await act(async () => { finish(createJsonResponse({ success: true })); await Promise.resolve() })
|
||||
expect(await screen.findAllByText('已通过')).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('a timed-out POST is not treated as failure of the application and later success clears the timeout hint', async () => {
|
||||
jest.useFakeTimers()
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
|
||||
let status = 'pending_manager_approval'
|
||||
const fetchMock = jest.fn((_input, init?: RequestInit) => {
|
||||
if (init?.method === 'POST') {
|
||||
status = 'applying'
|
||||
return new Promise((_resolve, reject) => init.signal?.addEventListener('abort', () => reject(new DOMException('timeout', 'AbortError'))))
|
||||
}
|
||||
return Promise.resolve(createJsonResponse(requestRecord(status)))
|
||||
})
|
||||
global.fetch = fetchMock as jest.Mock
|
||||
render(<TaskDetailChangeRequestPage />)
|
||||
await user.click(await screen.findByRole('button', { name: '批准' }))
|
||||
await act(async () => { await jest.advanceTimersByTimeAsync(30_100) })
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('请求未返回结果')
|
||||
expect(screen.getByText(/主管已批准,正在应用图片变更/)).toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
|
||||
status = 'applied'
|
||||
await act(async () => { await jest.advanceTimersByTimeAsync(3100) })
|
||||
expect(screen.getAllByText('已通过')).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
type TaskDetailChangeRequestDecision = 'approve' | 'reject'
|
||||
|
||||
type TaskDetailChangeRequestStatus = 'pending_approval' | 'approved' | 'rejected' | 'expired' | 'apply_failed'
|
||||
type TaskDetailChangeRequestStatus = 'pending_approval' | 'applying' | 'approved' | 'rejected' | 'expired' | 'apply_failed'
|
||||
|
||||
type TaskDetailChangeRequestOperation = {
|
||||
kind: string
|
||||
@ -30,6 +30,7 @@ type TaskDetailChangeRequestViewModel = {
|
||||
taskNo: string | null
|
||||
expiresAt: string | null
|
||||
decidedAt: string | null
|
||||
applyError: string | null
|
||||
operations: TaskDetailChangeRequestOperation[]
|
||||
}
|
||||
|
||||
@ -42,6 +43,10 @@ const STATUS_META: Record<TaskDetailChangeRequestStatus, { label: string; classN
|
||||
label: '已通过',
|
||||
className: 'border-[#b9ead0] bg-[#ecfdf3] text-[#087a3c]',
|
||||
},
|
||||
applying: {
|
||||
label: '正在应用',
|
||||
className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
|
||||
},
|
||||
rejected: {
|
||||
label: '已否决',
|
||||
className: 'border-[#f3d29b] bg-[#fff8e8] text-[#8a5200]',
|
||||
@ -82,6 +87,7 @@ function normalizeStatus(value: unknown): TaskDetailChangeRequestStatus {
|
||||
if (raw === 'rejected' || raw === 'reject') return 'rejected'
|
||||
if (raw === 'expired' || raw === 'timeout' || raw === 'timed_out') return 'expired'
|
||||
if (raw === 'apply_failed') return 'apply_failed'
|
||||
if (raw === 'applying') return 'applying'
|
||||
return 'pending_approval'
|
||||
}
|
||||
|
||||
@ -152,6 +158,7 @@ function normalizeChangeRequestPayload(payload: unknown): TaskDetailChangeReques
|
||||
taskNo: readText(material.taskNo) || readText(record.taskNo),
|
||||
expiresAt: readText(record.expiresAt) || readText(record.deadlineAt) || readText(record.expireAt),
|
||||
decidedAt: readText(record.decidedAt) || readText(record.reviewedAt),
|
||||
applyError: readText(record.applyError),
|
||||
operations,
|
||||
}
|
||||
}
|
||||
@ -186,54 +193,104 @@ export default function TaskDetailChangeRequestPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyDecision, setBusyDecision] = useState<TaskDetailChangeRequestDecision | null>(null)
|
||||
const mounted = useRef(true)
|
||||
const controllers = useRef(new Set<AbortController>())
|
||||
const readSequence = useRef(0)
|
||||
|
||||
const loadRequest = useCallback(async () => {
|
||||
const requestJson = useCallback(async (url: string, init?: RequestInit, timeoutMs = 15_000) => {
|
||||
const controller = new AbortController()
|
||||
controllers.current.add(controller)
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch(`/api/task-detail-change-requests/${requestId}`, {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal })
|
||||
const payload = await response.json().catch(() => null)
|
||||
return { response, payload }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
controllers.current.delete(controller)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadRequest = useCallback(async (options: { initial?: boolean; keepError?: boolean } = {}) => {
|
||||
const sequence = ++readSequence.current
|
||||
try {
|
||||
if (options.initial) setLoading(true)
|
||||
if (!options.keepError) setError(null)
|
||||
const { response, payload } = await requestJson(`/api/task-detail-change-requests/${requestId}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error((payload as { error?: string } | null)?.error || '加载审批详情失败')
|
||||
}
|
||||
const next = normalizeChangeRequestPayload(payload)
|
||||
if (!next) throw new Error('审批详情格式不正确')
|
||||
if (mounted.current && sequence === readSequence.current) {
|
||||
setRecord(next)
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : '加载审批详情失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (['approved', 'rejected', 'expired'].includes(next.status)) setError(null)
|
||||
}
|
||||
}, [requestId])
|
||||
return next
|
||||
} catch (loadError) {
|
||||
if (mounted.current && sequence === readSequence.current && !options.keepError) {
|
||||
setError(loadError instanceof Error && loadError.name === 'AbortError'
|
||||
? '读取状态超时,请刷新状态重试'
|
||||
: loadError instanceof Error ? loadError.message : '加载审批详情失败')
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
if (mounted.current && options.initial) setLoading(false)
|
||||
}
|
||||
}, [requestId, requestJson])
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
const activeControllers = controllers.current
|
||||
if (requestId) {
|
||||
void loadRequest()
|
||||
void loadRequest({ initial: true })
|
||||
}
|
||||
return () => {
|
||||
mounted.current = false
|
||||
activeControllers.forEach(controller => controller.abort())
|
||||
}
|
||||
}, [requestId, loadRequest])
|
||||
|
||||
useEffect(() => {
|
||||
if (!busyDecision && record?.status !== 'applying') return
|
||||
let cancelled = false
|
||||
const deadline = Date.now() + 2 * 60_000
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const poll = async () => {
|
||||
const next = await loadRequest({ keepError: true })
|
||||
if (next && !['pending_approval', 'applying'].includes(next.status)) return
|
||||
if (!cancelled && Date.now() < deadline) timer = setTimeout(poll, 3000)
|
||||
}
|
||||
timer = setTimeout(poll, 3000)
|
||||
return () => { cancelled = true; clearTimeout(timer) }
|
||||
}, [busyDecision, record?.status, loadRequest])
|
||||
|
||||
const submitDecision = async (decision: TaskDetailChangeRequestDecision) => {
|
||||
if (!record || busyDecision) return
|
||||
|
||||
try {
|
||||
setBusyDecision(decision)
|
||||
setError(null)
|
||||
const response = await fetch(`/api/task-detail-change-requests/${requestId}/decision`, {
|
||||
const { response, payload } = await requestJson(`/api/task-detail-change-requests/${requestId}/decision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decision }),
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
}, 30_000)
|
||||
if (!response.ok) {
|
||||
throw new Error((payload as { error?: string } | null)?.error || '提交审批结果失败')
|
||||
}
|
||||
await loadRequest()
|
||||
if (mounted.current) await loadRequest()
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : '提交审批结果失败')
|
||||
if (mounted.current) {
|
||||
setError(submitError instanceof Error && submitError.name === 'AbortError'
|
||||
? '请求未返回结果,请刷新状态确认处理进度,不要重复批准。'
|
||||
: submitError instanceof Error ? submitError.message : '提交审批结果失败')
|
||||
await loadRequest({ keepError: true })
|
||||
}
|
||||
} finally {
|
||||
setBusyDecision(null)
|
||||
if (mounted.current) setBusyDecision(null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -247,7 +304,7 @@ export default function TaskDetailChangeRequestPage() {
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-[28px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">审批详情</h1>
|
||||
{!loading && record && (
|
||||
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${statusMeta.className}`}>
|
||||
<span role="status" aria-live="polite" className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${statusMeta.className}`}>
|
||||
{statusMeta.label}
|
||||
</span>
|
||||
)}
|
||||
@ -257,8 +314,8 @@ export default function TaskDetailChangeRequestPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-[13px] text-[#b42318]">
|
||||
{error && !record?.applyError && (
|
||||
<div role="alert" className="mb-4 break-words rounded-xl border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-[13px] text-[#b42318]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@ -301,7 +358,7 @@ export default function TaskDetailChangeRequestPage() {
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<aside className="rounded-xl border border-[#e7e8ea] bg-white p-5">
|
||||
<aside className="min-w-0 rounded-xl border border-[#e7e8ea] bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">处理状态</h2>
|
||||
<dl className="mt-4 space-y-4">
|
||||
<div>
|
||||
@ -324,6 +381,23 @@ export default function TaskDetailChangeRequestPage() {
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{(record.status === 'applying' || busyDecision && record.status === 'pending_approval') && (
|
||||
<p role="status" className="mt-4 text-[13px] leading-6 text-[#315f91]">
|
||||
{record.status === 'applying'
|
||||
? '主管已批准,正在应用图片变更。可刷新状态查看处理结果。'
|
||||
: '正在提交审批并处理图片变更,请勿重复操作。'}
|
||||
</p>
|
||||
)}
|
||||
{record.status === 'apply_failed' && (
|
||||
<div role="alert" className="mt-4 break-words rounded-lg border border-[#f5c2c0] bg-[#fff5f4] p-3 text-[13px] leading-6 text-[#b42318]">
|
||||
<p>{record.applyError || '图片变更未能完成应用。'}</p>
|
||||
<p className="mt-1">请提交人重新选择图片并提交新的申请,本单不能重复批准。</p>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" onClick={() => void loadRequest()} className="mt-4 min-h-11 rounded-lg border border-[#e7e8ea] px-3 text-[13px] font-medium hover:bg-[#fafafa] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2">
|
||||
刷新状态
|
||||
</button>
|
||||
|
||||
{record.canDecide && record.status === 'pending_approval' && (
|
||||
<div className="mt-5 flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
@ -363,7 +437,7 @@ export default function TaskDetailChangeRequestPage() {
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-[#1a1c1d]">{describeOperation(op, index)}</p>
|
||||
<p className="break-all text-[13px] font-medium text-[#1a1c1d]">{describeOperation(op, index)}</p>
|
||||
<p className="mt-1 text-[12px] text-[#86898e]">
|
||||
{op.kind === 'append'
|
||||
? '追加图片会在主管通过后追加到分组末尾'
|
||||
|
||||
@ -389,6 +389,112 @@ describe('task-detail asset edit batch data-plane', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('主管批准删除和原接缝插图的六项组合时整体应用一次,允许复用已删除源文件名', async () => {
|
||||
const originals = await Promise.all(Array.from({ length: 6 }, (_, i) => createSolidImage(`original-${i}.png`, 80, 120)))
|
||||
const uploads = await Promise.all(Array.from({ length: 3 }, (_, i) => createSolidImage(`incoming-${i}.png`, 70, 90)))
|
||||
const names = originals.map((_, i) => `1_0${i + 1}.png`)
|
||||
configureAssetQueries({
|
||||
previewRows: originals.map((source, i) => ({ id: `row-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `1_0${i + 1}-preview.webp`, sortOrder: i })),
|
||||
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue(names.map(name => ({ name })))
|
||||
mockExtractZipEntries.mockResolvedValue(originals.map((source, i) => ({ entryName: names[i], filePath: source.filePath, size: source.size })))
|
||||
|
||||
const result = await batchEditTaskDetailImages({
|
||||
materialId: 'material-1', operatorUserId: 'operator-1', approvalRequestId: 'request-combined',
|
||||
operations: uploads.flatMap((source, i) => [
|
||||
{ kind: 'delete' as const, assetId: `row-${i * 2 + 1}` },
|
||||
{ kind: 'insert' as const, afterAssetId: `row-${i * 2}`, beforeAssetId: `row-${i * 2 + 1}`,
|
||||
stagedFile: { trusted: true as const, filePath: source.filePath, fileSize: source.size, fileName: names[i * 2 + 1] } },
|
||||
]),
|
||||
})
|
||||
expect(result).toMatchObject({ success: true, appliedCount: 6, entryCount: 6 })
|
||||
expect(mockCreateZipFromFiles).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreateZipFromZips).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)).toEqual([
|
||||
originals[0].filePath, uploads[0].filePath, originals[2].filePath, uploads[1].filePath, originals[4].filePath, uploads[2].filePath,
|
||||
])
|
||||
expect(prisma.materialAsset.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['row-1', 'row-3', 'row-5'] } } })
|
||||
expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'request-combined', materialId: 'material-1', status: 'applying' },
|
||||
data: expect.objectContaining({ status: 'applied' }),
|
||||
}))
|
||||
})
|
||||
|
||||
test.each([{ deleteFirst: true }, { deleteFirst: false }])('长图删除与两个接缝插图保留相同顺序,删除在前=$deleteFirst', async ({ deleteFirst }) => {
|
||||
const long = await createSolidImage('deleted-long.png', 1000, 4000)
|
||||
const previews = await Promise.all([1500, 1500, 1000].map((height, i) => createSolidImage(`long-00${i + 1}.webp`, 1000, height, 'webp')))
|
||||
const tail = await createSolidImage('tail.png', 80, 120)
|
||||
const inserted = await Promise.all([0, 1].map(i => createSolidImage(`new-${i}.png`, 80, 90)))
|
||||
configureAssetQueries({
|
||||
previewRows: [
|
||||
...previews.map((source, i) => ({ id: `slice-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `long-00${i + 1}.webp`, sortOrder: i })),
|
||||
{ id: 'tail', assetType: 'detail-preview', fileUrl: tail.filePath, fileName: 'tail-preview.webp', sortOrder: 3 },
|
||||
],
|
||||
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue([{ name: 'long.png' }, { name: 'tail.png' }])
|
||||
mockExtractZipEntries.mockResolvedValue([
|
||||
{ entryName: 'long.png', filePath: long.filePath, size: long.size },
|
||||
{ entryName: 'tail.png', filePath: tail.filePath, size: tail.size },
|
||||
])
|
||||
const remove = { kind: 'delete' as const, assetId: 'slice-1' }
|
||||
const inserts = inserted.map((source, i) => ({
|
||||
kind: 'insert' as const, afterAssetId: `slice-${i}`, beforeAssetId: `slice-${i + 1}`,
|
||||
stagedFile: { trusted: true as const, filePath: source.filePath, fileName: `new-${i}.png`, fileSize: source.size },
|
||||
}))
|
||||
const result = await batchEditTaskDetailImages({
|
||||
materialId: 'material-1', operatorUserId: 'operator-1',
|
||||
operations: deleteFirst ? [remove, ...inserts] : [...inserts, remove],
|
||||
})
|
||||
expect(result.entryCount).toBe(3)
|
||||
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)).toEqual([inserted[0].filePath, inserted[1].filePath, tail.filePath])
|
||||
expect(prisma.materialAsset.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['slice-0', 'slice-1', 'slice-2'] } } })
|
||||
expect(mockUploadPreview).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('接缝两侧原图都删除时,插入仍保留在原位置且按选择顺序排列', async () => {
|
||||
const sources = await Promise.all([0, 1, 2].map(i => createSolidImage(`source-${i}.png`, 80, 90)))
|
||||
const inserts = await Promise.all([0, 1].map(i => createSolidImage(`insert-${i}.png`, 70, 80)))
|
||||
configureAssetQueries({
|
||||
previewRows: sources.map((source, i) => ({ id: `row-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `1_0${i + 1}-preview.webp`, sortOrder: i })),
|
||||
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue(sources.map((_, i) => ({ name: `1_0${i + 1}.png` })))
|
||||
mockExtractZipEntries.mockResolvedValue(sources.map((source, i) => ({ entryName: `1_0${i + 1}.png`, filePath: source.filePath, size: source.size })))
|
||||
await batchEditTaskDetailImages({
|
||||
materialId: 'material-1', operatorUserId: 'operator-1',
|
||||
operations: [
|
||||
{ kind: 'delete', assetId: 'row-0' }, { kind: 'delete', assetId: 'row-1' },
|
||||
...inserts.map((source, i) => ({ kind: 'insert' as const, afterAssetId: 'row-0', beforeAssetId: 'row-1',
|
||||
stagedFile: { trusted: true as const, filePath: source.filePath, fileName: `insert-${i}.png`, fileSize: source.size } })),
|
||||
],
|
||||
})
|
||||
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)).toEqual([inserts[0].filePath, inserts[1].filePath, sources[2].filePath])
|
||||
})
|
||||
|
||||
test.each(['replace', 'rename'] as const)('同一源长图的不同切片不能同时删除和%s,必须在生成图片前拒绝', async (kind) => {
|
||||
const source = await createSolidImage('conflicting-long.png', 80, 3000)
|
||||
const replacement = await createSolidImage('replacement.png', 80, 90)
|
||||
configureAssetQueries({
|
||||
previewRows: [0, 1].map(i => ({ id: `slice-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `long-00${i + 1}.webp`, sortOrder: i })),
|
||||
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue([{ name: 'long.png' }])
|
||||
mockExtractZipEntries.mockResolvedValue([{ entryName: 'long.png', filePath: source.filePath, size: source.size }])
|
||||
await expect(batchEditTaskDetailImages({
|
||||
materialId: 'material-1', operatorUserId: 'operator-1',
|
||||
operations: [
|
||||
{ kind: 'delete', assetId: 'slice-1' },
|
||||
kind === 'rename' ? { kind, assetId: 'slice-0', newFileName: 'renamed.png' }
|
||||
: { kind, assetId: 'slice-0', stagedFile: { trusted: true, filePath: replacement.filePath, fileName: 'replacement.png', fileSize: replacement.size } },
|
||||
],
|
||||
})).rejects.toThrow('同一张源图不能同时删除并替换或改名')
|
||||
expect(mockUploadPreview).not.toHaveBeenCalled()
|
||||
expect(mockCreateZipFromFiles).not.toHaveBeenCalled()
|
||||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('详情同 run 静态长图插图会拆成上半/插入/下半并重建 preview', async () => {
|
||||
const longSource = await createSolidImage('long-source.png', 1000, 4000)
|
||||
const preview1 = await createSolidImage('long-001.webp', 1000, 1500, 'webp')
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getEmployeeByUserId } from '@/lib/employee-directory'
|
||||
import { canManageMaterial } from '@/lib/material-authorization'
|
||||
import { batchEditTaskDetailImages } from '@/lib/task-detail-asset-edit'
|
||||
import { batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
|
||||
import {
|
||||
cleanupTaskDetailChangeStaging,
|
||||
resolveTaskDetailStagedFile,
|
||||
@ -46,7 +46,12 @@ jest.mock('@/lib/department-permission', () => ({
|
||||
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', () => ({ batchEditTaskDetailImages: jest.fn() }))
|
||||
jest.mock('@/lib/task-detail-asset-edit', () => ({
|
||||
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(),
|
||||
}))
|
||||
@ -344,6 +349,23 @@ describe('任务详情图片变更主管审批', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
@ -1663,6 +1663,8 @@ type BatchRowState = ExistingBatchRowState | NewBatchRowState
|
||||
interface BatchRunState {
|
||||
entry: { path: string; name: string; size: number }
|
||||
rows: BatchRowState[]
|
||||
/** 保留原始源图身份;接缝拆分出的两半仍属于同一张待删除源图。 */
|
||||
sourceRunIndex?: number
|
||||
}
|
||||
|
||||
type PreparedBatchMutation =
|
||||
@ -1930,7 +1932,8 @@ async function executeBatchRebuild(
|
||||
)
|
||||
: []
|
||||
|
||||
const finalRuns: BatchRunState[] = context.runs.map((run, index) => ({
|
||||
let finalRuns: BatchRunState[] = context.runs.map((run, index) => ({
|
||||
sourceRunIndex: index,
|
||||
entry: {
|
||||
path: extracted[index]?.filePath || '',
|
||||
name: entryNames[index],
|
||||
@ -1941,6 +1944,29 @@ async function executeBatchRebuild(
|
||||
|
||||
const handledAssetIds = new Set<string>()
|
||||
const repeatedInsertBoundaries = new Map<string, BatchRunState>()
|
||||
const deletedAssetIds = new Set(operations
|
||||
.filter((operation) => operation.mutation.kind === 'delete')
|
||||
.map((operation) => operation.assetId))
|
||||
const deletedSourceRuns = new Set(context.runs.flatMap((run, index) => (
|
||||
run.rows.some((row) => deletedAssetIds.has(row.id)) ? [index] : []
|
||||
)))
|
||||
const isDeletedSourceRun = (run: BatchRunState) => (
|
||||
run.sourceRunIndex !== undefined && deletedSourceRuns.has(run.sourceRunIndex)
|
||||
)
|
||||
const survivingEntryNames = () => finalRuns.filter((run) => !isDeletedSourceRun(run)).map((run) => run.entry.name)
|
||||
|
||||
// 所有接缝都来自用户提交时看到的原始图片序列。先验证,删除仅在最终输出时生效,
|
||||
// 保留定位节点,使“删后图 + 在其前方插图”等组合不依赖操作排列顺序。
|
||||
for (const operation of operations) {
|
||||
if (operation.mutation.kind === 'insert') {
|
||||
resolveInsertSeam(finalRuns, operation.mutation.afterAssetId, operation.mutation.beforeAssetId)
|
||||
} else if (operation.assetId && operation.mutation.kind !== 'delete') {
|
||||
const sourceIndex = context.runs.findIndex((run) => run.rows.some((row) => row.id === operation.assetId))
|
||||
if (deletedSourceRuns.has(sourceIndex)) {
|
||||
throw new TaskDetailAssetEditError('同一张源图不能同时删除并替换或改名,请只保留一种操作', 409)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const operation of operations) {
|
||||
if (operation.assetId) {
|
||||
@ -1950,13 +1976,15 @@ async function executeBatchRebuild(
|
||||
handledAssetIds.add(operation.assetId)
|
||||
}
|
||||
|
||||
if (operation.mutation.kind === 'delete') continue
|
||||
|
||||
if (operation.mutation.kind === 'append') {
|
||||
if (group !== operation.mutation.group) {
|
||||
throw new TaskDetailAssetEditError('追加图片只支持当前分组,请刷新后重试')
|
||||
}
|
||||
const entryName = ensureUniqueEntryName(
|
||||
operation.mutation.source.fileName,
|
||||
finalRuns.map((run) => run.entry.name),
|
||||
survivingEntryNames(),
|
||||
'追加图片的文件名与包内其他文件重名,请先改名后再追加',
|
||||
)
|
||||
finalRuns.push(await buildNewBatchRunState({
|
||||
@ -1980,7 +2008,7 @@ async function executeBatchRebuild(
|
||||
}
|
||||
|
||||
const seamKey = `${operation.mutation.afterAssetId}\u0000${operation.mutation.beforeAssetId}`
|
||||
const existingEntryNames = finalRuns.map((run) => run.entry.name)
|
||||
const existingEntryNames = survivingEntryNames()
|
||||
const insertedEntryName = ensureUniqueEntryName(
|
||||
operation.mutation.source.fileName,
|
||||
existingEntryNames,
|
||||
@ -2031,6 +2059,24 @@ async function executeBatchRebuild(
|
||||
}
|
||||
|
||||
const targetRun = finalRuns[seam.afterRunIndex]
|
||||
if (isDeletedSourceRun(targetRun)) {
|
||||
// 整张原图将被删除:只拆分内存中的定位节点,不生成或上传会被丢弃的两半预览。
|
||||
const topRun = { ...targetRun, rows: targetRun.rows.slice(0, seam.afterRowIndex + 1) }
|
||||
const bottomRun = { ...targetRun, rows: targetRun.rows.slice(seam.beforeRowIndex) }
|
||||
const insertedRun = await buildNewBatchRunState({
|
||||
source: operation.mutation.source,
|
||||
entryName: insertedEntryName,
|
||||
group,
|
||||
materialId: context.materialId,
|
||||
isPrivate: context.taskDetailPrivate,
|
||||
entryIndex: seam.afterRunIndex + 1,
|
||||
workspaceDir: workspace.dir,
|
||||
generatedPreviewLocations,
|
||||
})
|
||||
finalRuns.splice(seam.afterRunIndex, 1, topRun, insertedRun, bottomRun)
|
||||
repeatedInsertBoundaries.set(seamKey, bottomRun)
|
||||
continue
|
||||
}
|
||||
const sourceAssetIds = targetRun.rows.map((row) => (
|
||||
row.kind === 'existing' ? row.id : row.sourceAssetId || null
|
||||
))
|
||||
@ -2087,6 +2133,8 @@ async function executeBatchRebuild(
|
||||
generatedPreviewLocations,
|
||||
sourceAssetIds: sourceAssetIds.slice(seam.beforeRowIndex) as string[],
|
||||
})
|
||||
topRun.sourceRunIndex = targetRun.sourceRunIndex
|
||||
bottomRun.sourceRunIndex = targetRun.sourceRunIndex
|
||||
finalRuns.splice(seam.afterRunIndex, 1, topRun, insertedRun, bottomRun)
|
||||
repeatedInsertBoundaries.set(seamKey, bottomRun)
|
||||
continue
|
||||
@ -2102,16 +2150,11 @@ async function executeBatchRebuild(
|
||||
}
|
||||
const run = finalRuns[located.runIndex]
|
||||
|
||||
if (operation.mutation.kind === 'delete') {
|
||||
finalRuns.splice(located.runIndex, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
if (operation.mutation.kind === 'rename') {
|
||||
const originalExtension = getExtension(run.entry.name)
|
||||
const newBaseName = operation.mutation.newBaseName
|
||||
const finalEntryName = `${newBaseName}${originalExtension}`
|
||||
const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && candidate.entry.name === finalEntryName)
|
||||
const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && !isDeletedSourceRun(candidate) && candidate.entry.name === finalEntryName)
|
||||
if (conflict) {
|
||||
throw new TaskDetailAssetEditError(`改名后的「${finalEntryName}」与包内其他文件重名,请换一个名字`, 409)
|
||||
}
|
||||
@ -2125,7 +2168,7 @@ async function executeBatchRebuild(
|
||||
}
|
||||
|
||||
const finalEntryName = buildFinalEntryName(run.entry.name, operation.mutation.source.fileName)
|
||||
const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && candidate.entry.name === finalEntryName)
|
||||
const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && !isDeletedSourceRun(candidate) && candidate.entry.name === finalEntryName)
|
||||
if (conflict) {
|
||||
throw new TaskDetailAssetEditError(`替换后的「${finalEntryName}」与包内其他文件重名,请先改名再替换`, 409)
|
||||
}
|
||||
@ -2148,8 +2191,10 @@ async function executeBatchRebuild(
|
||||
skuCode: run.rows[0]?.skuCode || null,
|
||||
barcodes,
|
||||
})
|
||||
finalRuns[located.runIndex].sourceRunIndex = run.sourceRunIndex
|
||||
}
|
||||
|
||||
finalRuns = finalRuns.filter((run) => !isDeletedSourceRun(run))
|
||||
assertFinalBatchLimits(context, group, finalRuns)
|
||||
const { db, finalRowUrls } = buildBatchDbChanges(context, group, finalRuns)
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
type TaskDetailStagedFile,
|
||||
} from '@/lib/task-detail-change-staging'
|
||||
import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications'
|
||||
import { batchEditTaskDetailImages } from '@/lib/task-detail-asset-edit'
|
||||
import { batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit'
|
||||
|
||||
const REQUEST_STATUS = {
|
||||
PENDING: 'pending_manager_approval',
|
||||
@ -636,6 +636,9 @@ export async function decideTaskDetailChange(
|
||||
if (final) {
|
||||
await queueRequesterResultNotification(final, '在批准后应用失败,请重新提交').catch(() => undefined)
|
||||
}
|
||||
if (error instanceof TaskDetailAssetEditError) {
|
||||
throw new TaskDetailChangeApprovalError(message, error.statusCode)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user