release finished product progress import and management
This commit is contained in:
parent
3c69088433
commit
9c66706b67
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "FinishedProductProgressItem"
|
||||
ADD COLUMN "followUpOwner" TEXT NOT NULL DEFAULT '';
|
||||
@ -98,6 +98,7 @@ model FinishedProductProgressItem {
|
||||
id String @id @default(cuid())
|
||||
contractDateNumber String @default("")
|
||||
contractNumber String
|
||||
followUpOwner String @default("")
|
||||
imageUrl String? @db.Text
|
||||
itemNumber String @default("")
|
||||
barcode String?
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
export {}
|
||||
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockDeleteContractGroups = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
jsonSuccess: (...args: unknown[]) => ({ success: true, data: args[0] }),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/finished-product-progress.service', () => ({
|
||||
finishedProductProgressService: {
|
||||
deleteContractGroups: (...args: unknown[]) => mockDeleteContractGroups(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { DELETE } = require('./route')
|
||||
|
||||
describe('DELETE /api/supply-chain/finished-product-progress/bulk-delete', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockHandleRequest.mockImplementation(async (_request: unknown, handler: (ctx: unknown) => unknown) => handler({
|
||||
requestId: 'req-bulk-delete',
|
||||
user: { id: 'buyer-1', name: '采购员', role: 'USER' },
|
||||
}))
|
||||
mockDeleteContractGroups.mockResolvedValue({ deletedCount: 3 })
|
||||
})
|
||||
|
||||
it('passes the selected contract groups to the service', async () => {
|
||||
const body = { contractNumbers: ['HT-001'], itemIds: ['item-9'] }
|
||||
await DELETE({ json: async () => body })
|
||||
|
||||
expect(mockDeleteContractGroups).toHaveBeenCalledWith(body, expect.objectContaining({ id: 'buyer-1' }))
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const body = await request.json().catch(() => null)
|
||||
return jsonSuccess(await finishedProductProgressService.deleteContractGroups(body, ctx.user), 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -7,6 +7,7 @@ const mockHandleRequest = jest.fn()
|
||||
const mockJsonError = jest.fn()
|
||||
const mockParseImport = jest.fn()
|
||||
const mockUploadImage = jest.fn()
|
||||
const mockDeleteImage = jest.fn()
|
||||
const mockImportRows = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
@ -20,6 +21,7 @@ jest.mock('@/lib/finished-product-progress-import', () => ({
|
||||
|
||||
jest.mock('@/lib/storage', () => ({
|
||||
uploadBufferToObjectStorageWithMetadata: (...args: unknown[]) => mockUploadImage(...args),
|
||||
deleteObjectWithResult: (...args: unknown[]) => mockDeleteImage(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/finished-product-progress.service', () => ({
|
||||
@ -71,6 +73,7 @@ describe('POST /api/supply-chain/finished-product-progress/import', () => {
|
||||
url: '/api/storage/object?key=finished-product-progress%2Fimport-images%2Fimage.png',
|
||||
storageKey: 'finished-product-progress/import-images/image.png',
|
||||
})
|
||||
mockDeleteImage.mockResolvedValue({ status: 'deleted' })
|
||||
mockImportRows.mockResolvedValue({ created: 1, updated: 0 })
|
||||
})
|
||||
|
||||
@ -93,4 +96,55 @@ describe('POST /api/supply-chain/finished-product-progress/import', () => {
|
||||
{ contractNumber: 'HT-001', imageUrl: '/api/storage/object?key=finished-product-progress%2Fimport-images%2Fimage.png', barcode: 'SKU-001' },
|
||||
], expect.objectContaining({ id: 'buyer-1' }), expect.any(Function))
|
||||
})
|
||||
|
||||
it('removes uploaded images when a later image upload fails', async () => {
|
||||
mockParseImport.mockResolvedValue({
|
||||
rows: [
|
||||
{ rowNumber: 2, values: { contractNumber: 'HT-001', imageUrl: '' } },
|
||||
{ rowNumber: 3, values: { contractNumber: 'HT-002', imageUrl: '' } },
|
||||
],
|
||||
embeddedImages: [
|
||||
{ rowNumber: 2, buffer: NodeBuffer.from('first'), extension: 'png', contentType: 'image/png' },
|
||||
{ rowNumber: 3, buffer: NodeBuffer.from('second'), extension: 'png', contentType: 'image/png' },
|
||||
],
|
||||
})
|
||||
mockUploadImage
|
||||
.mockResolvedValueOnce({ url: '/api/storage/object?key=first', storageKey: 'finished-product-progress/import-images/first.png' })
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
|
||||
await POST({ formData: async () => ({ get: () => new MockFile() }) })
|
||||
|
||||
expect(mockDeleteImage).toHaveBeenCalledWith('finished-product-progress/import-images/first.png')
|
||||
expect(mockImportRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes only images from rows not persisted when import fails', async () => {
|
||||
mockImportRows.mockImplementation(async (_rows: unknown[], _user: unknown, onProgress: (progress: unknown) => void) => {
|
||||
onProgress({ processed: 1, total: 2, created: 1, updated: 0 })
|
||||
throw new Error('database unavailable')
|
||||
})
|
||||
mockParseImport.mockResolvedValue({
|
||||
rows: [
|
||||
{ rowNumber: 2, values: { contractNumber: 'HT-001', imageUrl: '' } },
|
||||
{ rowNumber: 3, values: { contractNumber: 'HT-002', imageUrl: '' } },
|
||||
],
|
||||
embeddedImages: [
|
||||
{ rowNumber: 2, buffer: NodeBuffer.from('first'), extension: 'png', contentType: 'image/png' },
|
||||
{ rowNumber: 3, buffer: NodeBuffer.from('second'), extension: 'png', contentType: 'image/png' },
|
||||
],
|
||||
})
|
||||
mockUploadImage
|
||||
.mockResolvedValueOnce({ url: '/api/storage/object?key=first', storageKey: 'finished-product-progress/import-images/first.png' })
|
||||
.mockResolvedValueOnce({ url: '/api/storage/object?key=second', storageKey: 'finished-product-progress/import-images/second.png' })
|
||||
|
||||
const response = await POST({ formData: async () => ({ get: () => new MockFile() }) })
|
||||
const reader = response.body.getReader()
|
||||
while (!(await reader.read()).done) {
|
||||
// Drain the NDJSON stream so the import promise can complete.
|
||||
}
|
||||
|
||||
expect(mockDeleteImage).toHaveBeenCalledTimes(1)
|
||||
expect(mockDeleteImage).toHaveBeenCalledWith('finished-product-progress/import-images/second.png')
|
||||
expect(mockDeleteImage).not.toHaveBeenCalledWith('finished-product-progress/import-images/first.png')
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,11 +5,25 @@ import {
|
||||
type FinishedProductProgressImportStreamEvent,
|
||||
} from '@/lib/finished-product-progress'
|
||||
import { parseFinishedProductProgressImport } from '@/lib/finished-product-progress-import'
|
||||
import { uploadBufferToObjectStorageWithMetadata } from '@/lib/storage'
|
||||
import { deleteObjectWithResult, uploadBufferToObjectStorageWithMetadata } from '@/lib/storage'
|
||||
import { ErrorCode, isAppError } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError } from '@/server/core/response'
|
||||
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||
|
||||
type UploadedImportImage = {
|
||||
storageKey: string
|
||||
rowNumber: number
|
||||
}
|
||||
|
||||
async function cleanupUploadedImages(images: UploadedImportImage[], requestId: string) {
|
||||
const results = await Promise.all(images.map((image) => deleteObjectWithResult(image.storageKey)))
|
||||
results.forEach((result) => {
|
||||
if (result.status !== 'deleted' && result.status !== 'missing') {
|
||||
console.error(`[${requestId}] Finished product progress image cleanup failed:`, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const form = await request.formData()
|
||||
@ -24,6 +38,7 @@ export async function POST(request: NextRequest) {
|
||||
if (rows.length > 5_000) return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行', 400, ctx.requestId)
|
||||
|
||||
const embeddedImageUrls = new Map<number, string>()
|
||||
const uploadedImages: UploadedImportImage[] = []
|
||||
try {
|
||||
for (const image of parsed.embeddedImages) {
|
||||
const uploaded = await uploadBufferToObjectStorageWithMetadata(
|
||||
@ -31,9 +46,11 @@ export async function POST(request: NextRequest) {
|
||||
`finished-product-progress/import-images/${randomUUID()}-${image.rowNumber}.${image.extension}`,
|
||||
image.contentType,
|
||||
)
|
||||
uploadedImages.push({ storageKey: uploaded.storageKey, rowNumber: image.rowNumber })
|
||||
embeddedImageUrls.set(image.rowNumber, uploaded.url)
|
||||
}
|
||||
} catch (error) {
|
||||
await cleanupUploadedImages(uploadedImages, ctx.requestId)
|
||||
console.error(`[${ctx.requestId}] Finished product progress image upload failed:`, error)
|
||||
return jsonError(ErrorCode.SYSTEM_STORAGE, '导入图片上传失败,请稍后重试', 500, ctx.requestId)
|
||||
}
|
||||
@ -66,7 +83,10 @@ export async function POST(request: NextRequest) {
|
||||
progress = { processed: importRows.length, total: importRows.length, ...result }
|
||||
send({ type: 'complete', progress })
|
||||
close()
|
||||
}).catch((error: unknown) => {
|
||||
}).catch(async (error: unknown) => {
|
||||
const completedRowNumbers = new Set(rows.slice(0, progress.processed).map((row) => row.rowNumber))
|
||||
const unreferencedImages = uploadedImages.filter((image) => !completedRowNumbers.has(image.rowNumber))
|
||||
await cleanupUploadedImages(unreferencedImages, ctx.requestId)
|
||||
console.error(`[${ctx.requestId}] Finished product progress import failed:`, error)
|
||||
send({ type: 'error', progress, message: isAppError(error) ? error.message : '导入过程中发生错误' })
|
||||
close()
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { FileDown, Layers3, Plus, RotateCcw, Search, Upload, X } from 'lucide-react'
|
||||
import { FileDown, Layers3, Plus, RotateCcw, Search, Trash2, Upload, X } from 'lucide-react'
|
||||
import {
|
||||
EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS,
|
||||
type FinishedProductProgressChangeLogEntry,
|
||||
@ -87,6 +87,25 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
|
||||
await load(filters, 1, data.pageInfo.pageSize)
|
||||
} catch (error) { window.alert(error instanceof Error ? error.message : '删除失败') } finally { setLoading(false) }
|
||||
}
|
||||
const deleteSelectedGroups = async () => {
|
||||
const selectedRows = data.rows.flatMap((group) => group.details).filter((row) => selectedIds.has(row.contractNumber.trim() || row.id))
|
||||
const contractNumbers = [...new Set(selectedRows.map((row) => row.contractNumber.trim()).filter(Boolean))]
|
||||
const itemIds = selectedRows.filter((row) => !row.contractNumber.trim()).map((row) => row.id)
|
||||
if (selectedRows.length === 0) return
|
||||
const contractSummary = contractNumbers.length > 0 ? contractNumbers.slice(0, 5).join('、') : '未填写合同号的明细'
|
||||
const suffix = contractNumbers.length > 5 ? ` 等 ${contractNumbers.length} 个合同组` : ''
|
||||
if (!window.confirm(`确认批量删除已选的 ${selectedIds.size} 个合同组吗?\n共 ${selectedRows.length} 条明细,将一并删除送货记录和修改日志,且无法恢复。\n${contractSummary}${suffix}`)) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await readResponse(await fetch('/api/supply-chain/finished-product-progress/bulk-delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contractNumbers, itemIds }),
|
||||
})) as { deletedCount: number }
|
||||
window.alert(`已删除 ${result.deletedCount} 条明细`)
|
||||
await load(filters, 1, data.pageInfo.pageSize)
|
||||
} catch (error) { window.alert(error instanceof Error ? error.message : '批量删除失败') } finally { setLoading(false) }
|
||||
}
|
||||
const openContractGroupEditor = () => {
|
||||
const selectedRows = data.rows.flatMap((group) => group.details).filter((row) => selectedIds.has(row.contractNumber.trim() || row.id))
|
||||
if (selectedRows.length === 0) return
|
||||
@ -111,12 +130,13 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
|
||||
<div className="finished-product-actions">
|
||||
<FinishedProductColumnSettings visibleKeys={columns.visibleKeySet} hiddenCount={columns.hiddenCount} onToggle={columns.toggle} onShowAll={columns.showAll} />
|
||||
{canEdit && <button type="button" disabled={selectedIds.size === 0} onClick={openContractGroupEditor} className="finished-control-button" title="批量修改已勾选的同合同号明细"><Layers3 size={15} />批量修改</button>}
|
||||
{canEdit && <button type="button" disabled={selectedIds.size === 0 || loading} onClick={() => void deleteSelectedGroups()} className="finished-control-button finished-danger-button" title="删除已勾选的合同组"><Trash2 size={15} />批量删除</button>}
|
||||
{canDownload && <button type="button" className="finished-control-button" onClick={() => { window.location.href = '/api/supply-chain/finished-product-progress/template' }}><FileDown size={15} />下载模板</button>}
|
||||
{canEdit && <><button type="button" disabled={importTask.isImporting} onClick={() => fileRef.current?.click()} className="finished-control-button"><Upload size={15} />{importTask.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) void importTask.importFile(file); event.currentTarget.value = '' }} /><button type="button" onClick={() => setEditor('new')} className="finished-primary-button"><Plus size={15} />新增</button></>}
|
||||
</div>
|
||||
</header>
|
||||
<form className="finished-product-filters" onSubmit={(event) => { event.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}>
|
||||
<input value={filters.search} onChange={(event) => setFilters({ ...filters, search: event.target.value })} placeholder="搜索日期号、合同号、货号、条码、产品名称或备注" />
|
||||
<input value={filters.search} onChange={(event) => setFilters({ ...filters, search: event.target.value })} placeholder="搜索日期号、合同号、跟进人、货号、条码、产品名称或备注" />
|
||||
<FinishedProductMultiSelect label="供应商" options={data.options.suppliers} values={filters.supplier} onChange={(supplier) => setFilters({ ...filters, supplier })} />
|
||||
<FinishedProductMultiSelect label="品牌" options={data.options.brands} values={filters.brand} onChange={(brand) => setFilters({ ...filters, brand })} />
|
||||
<FinishedProductMultiSelect label="当前节点" options={data.options.currentNodes} values={filters.currentNode} onChange={(currentNode) => setFilters({ ...filters, currentNode })} />
|
||||
|
||||
@ -55,6 +55,7 @@ export function FinishedProductEditorDrawer({ item, readOnly = false, onClose, o
|
||||
<div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2">
|
||||
<ReadOnlyField label="日期号(系统生成)" value={draft.contractDateNumber ?? ''} placeholder="保存后自动生成" />
|
||||
<Field label="合同号" value={draft.contractNumber} onChange={(value) => set('contractNumber', value)} disabled={readOnly} />
|
||||
<ReadOnlyField label="跟进人(自动)" value={workingItem?.followUpOwner ?? ''} placeholder="保存后自动记录当前操作人" />
|
||||
<Field label="条码" value={draft.barcode} onChange={(value) => set('barcode', value)} disabled={readOnly} />
|
||||
<Field label="货号" value={draft.itemNumber} onChange={(value) => set('itemNumber', value)} disabled={readOnly} />
|
||||
<Field label="产品名称" value={draft.productName} onChange={(value) => set('productName', value)} disabled={readOnly} />
|
||||
|
||||
@ -32,7 +32,8 @@ export function FinishedProductTable({ rows, columns, canEdit, selectedIds, onTo
|
||||
function FinishedProductCell({ column, row, canEdit, onEdit, onView, onHistory, onPreview, onDelete }: { column: FinishedProductProgressColumnKey; row: FinishedProductProgressDetailRow; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void; onDelete: (row: FinishedProductProgressRow) => void }) {
|
||||
const base = 'whitespace-nowrap border-b border-zinc-100 bg-white px-3 py-3 text-zinc-700 group-hover:bg-zinc-50'
|
||||
if (column === 'contractNumber') return <td className={`${base} sticky ${canEdit ? 'left-[154px]' : 'left-[110px]'} z-10 font-semibold text-zinc-900`}>{row.contractNumber || '-'}</td>
|
||||
if (column === 'imageUrl') return <td className={`${base} sticky ${canEdit ? 'left-[304px]' : 'left-[260px]'} z-10`}>{row.imageUrl ? <button type="button" onClick={() => onPreview(row)} className="relative h-12 w-12 overflow-hidden rounded-[5px] border border-zinc-200 bg-zinc-50"><Image unoptimized src={row.imageUrl} alt={row.productName || row.barcode || row.contractNumber} fill sizes="48px" className="object-cover" /></button> : <span className="text-zinc-400">-</span>}</td>
|
||||
if (column === 'followUpOwner') return <td className={`${base} sticky ${canEdit ? 'left-[304px]' : 'left-[260px]'} z-10 font-medium text-zinc-900`}>{row.followUpOwner || '-'}</td>
|
||||
if (column === 'imageUrl') return <td className={`${base} sticky ${canEdit ? 'left-[424px]' : 'left-[380px]'} z-10`}>{row.imageUrl ? <button type="button" onClick={() => onPreview(row)} className="relative h-12 w-12 overflow-hidden rounded-[5px] border border-zinc-200 bg-zinc-50"><Image unoptimized src={row.imageUrl} alt={row.productName || row.barcode || row.contractNumber} fill sizes="48px" className="object-cover" /></button> : <span className="text-zinc-400">-</span>}</td>
|
||||
if (column === 'actions') return <td className={`${base} finished-product-actions-cell sticky right-0 z-10 border-l border-zinc-200`}><div className="flex flex-nowrap justify-center gap-1 whitespace-nowrap"><button type="button" title="View details" aria-label="View details" onClick={() => onView(row)} className="finished-row-button"><Eye size={14} /></button><button type="button" title="View change history" aria-label="View change history" onClick={() => onHistory(row)} className="finished-row-button"><History size={14} /></button>{canEdit && <><button type="button" title="Edit" aria-label="Edit" onClick={() => onEdit(row)} className="finished-row-button"><Pencil size={14} /></button><button type="button" title="Delete" aria-label="Delete" onClick={() => onDelete(row)} className="finished-row-button text-red-600 hover:border-red-200 hover:bg-red-50"><Trash2 size={14} /></button></>}</div></td>
|
||||
if (column === 'realtime') return <td className={`${base} tabular-nums`} title={`日期 ${row.realtimeDate || '-'} · 已送货 ${row.deliveryQuantity}`}><span>日期 {row.realtimeDate || '-'} · 已送货 {row.deliveryQuantity.toLocaleString('zh-CN')}</span></td>
|
||||
if (column === 'currentNode') return <td className={base} title={row.currentNode || '-'}>{row.currentNode || '-'}</td>
|
||||
@ -45,7 +46,7 @@ function FinishedProductCell({ column, row, canEdit, onEdit, onView, onHistory,
|
||||
|
||||
function shiftStickyClass(value: string | undefined, hasSelectionColumn: boolean) {
|
||||
if (!value || !hasSelectionColumn) return value ?? ''
|
||||
return value.replace('left-0', 'left-[44px]').replace('left-[110px]', 'left-[154px]').replace('left-[260px]', 'left-[304px]')
|
||||
return value.replace('left-0', 'left-[44px]').replace('left-[110px]', 'left-[154px]').replace('left-[260px]', 'left-[304px]').replace('left-[380px]', 'left-[424px]')
|
||||
}
|
||||
|
||||
function formatCompletedAt(value: string | null) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { FinishedProductProgressFieldKey } from '@/lib/finished-product-progress'
|
||||
|
||||
export type FinishedProductProgressColumnKey = FinishedProductProgressFieldKey | 'realtime' | 'completedAt' | 'actions'
|
||||
export type FinishedProductProgressColumnKey = FinishedProductProgressFieldKey | 'followUpOwner' | 'realtime' | 'completedAt' | 'actions'
|
||||
|
||||
export type FinishedProductProgressColumn = {
|
||||
key: FinishedProductProgressColumnKey
|
||||
@ -13,7 +13,8 @@ export type FinishedProductProgressColumn = {
|
||||
export const FINISHED_PRODUCT_PROGRESS_COLUMNS: readonly FinishedProductProgressColumn[] = [
|
||||
{ key: 'contractDateNumber', label: '日期号', minWidth: 110, locked: true, stickyClassName: 'left-0 z-40' },
|
||||
{ key: 'contractNumber', label: '合同号', minWidth: 150, locked: true, stickyClassName: 'left-[110px] z-40' },
|
||||
{ key: 'imageUrl', label: '图片', minWidth: 86, locked: true, stickyClassName: 'left-[260px] z-40' },
|
||||
{ key: 'followUpOwner', label: '跟进人', minWidth: 120, locked: true, stickyClassName: 'left-[260px] z-40' },
|
||||
{ key: 'imageUrl', label: '图片', minWidth: 86, locked: true, stickyClassName: 'left-[380px] z-40' },
|
||||
{ key: 'itemNumber', label: '货号', minWidth: 140 },
|
||||
{ key: 'barcode', label: '条码', minWidth: 160 },
|
||||
{ key: 'productName', label: '产品名称', minWidth: 220 },
|
||||
|
||||
@ -4,6 +4,7 @@ export const finishedProductProgressStyles = String.raw`
|
||||
.finished-product-toolbar h1{margin:0;font-size:18px;font-weight:750;letter-spacing:0}.finished-product-toolbar p{margin:4px 0 0;color:#71717a;font-size:12px}
|
||||
.finished-product-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px}
|
||||
.finished-control-button,.finished-primary-button,.finished-secondary-button,.finished-page-button,.finished-row-button,.finished-icon-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s}
|
||||
.finished-danger-button{border-color:#fecaca;color:#b91c1c}.finished-danger-button:hover{border-color:#fca5a5;background:#fef2f2;color:#991b1b}
|
||||
.finished-control-button,.finished-primary-button,.finished-secondary-button{height:38px;padding:0 13px}.finished-primary-button{border-color:#27272a;background:#27272a;color:#fff}.finished-primary-button:hover{background:#18181b}.finished-secondary-button:hover,.finished-control-button:hover,.finished-row-button:hover,.finished-page-button:hover{background:#f4f4f5;border-color:#a1a1aa}
|
||||
.finished-control-button:disabled,.finished-primary-button:disabled,.finished-secondary-button:disabled,.finished-row-button:disabled,.finished-page-button:disabled{cursor:not-allowed;opacity:.45}
|
||||
.finished-icon-button{width:30px;height:30px;padding:0;border:0;background:transparent}.finished-icon-button:hover{background:#f4f4f5}
|
||||
|
||||
@ -135,6 +135,7 @@ export type FinishedProductContractGroupUpdate = z.infer<typeof finishedProductC
|
||||
|
||||
export type FinishedProductProgressDetailRow = FinishedProductProgressInput & {
|
||||
id: string
|
||||
followUpOwner: string
|
||||
completedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
|
||||
@ -42,7 +42,7 @@ describe('FinishedProductProgressRepository date-number allocation', () => {
|
||||
.mockResolvedValueOnce({ id: 'occupied-item' })
|
||||
.mockResolvedValueOnce({ contractDateNumber: '260824001' })
|
||||
const tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue(undefined),
|
||||
$executeRaw: jest.fn().mockResolvedValue(0),
|
||||
finishedProductProgressItem: { findFirst, create },
|
||||
}
|
||||
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
|
||||
@ -54,7 +54,7 @@ describe('FinishedProductProgressRepository date-number allocation', () => {
|
||||
)
|
||||
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ contractDateNumber: '260824002' }),
|
||||
data: expect.objectContaining({ contractDateNumber: '260824002', followUpOwner: '测试用户' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -98,4 +98,21 @@ describe('FinishedProductProgressRepository filters and deletion', () => {
|
||||
|
||||
expect(deleteItem).toHaveBeenCalledWith({ where: { id: 'item-2' }, select: { id: true } })
|
||||
})
|
||||
|
||||
it('deletes all rows in selected contract groups and standalone rows in one transaction', async () => {
|
||||
const deleteMany = jest.fn().mockResolvedValue({ count: 3 })
|
||||
const tx = { finishedProductProgressItem: { deleteMany } }
|
||||
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
|
||||
|
||||
await expect(new FinishedProductProgressRepository().deleteContractGroups([' HT-001 ', 'HT-001'], ['item-3', 'item-3']))
|
||||
.resolves.toEqual({ count: 3 })
|
||||
expect(deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ contractNumber: { in: ['HT-001'] } },
|
||||
{ id: { in: ['item-3'] } },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -23,6 +23,7 @@ const ITEM_SELECT = {
|
||||
id: true,
|
||||
contractDateNumber: true,
|
||||
contractNumber: true,
|
||||
followUpOwner: true,
|
||||
imageUrl: true,
|
||||
itemNumber: true,
|
||||
barcode: true,
|
||||
@ -66,6 +67,7 @@ function buildWhere(filters: FinishedProductProgressFilters): Prisma.FinishedPro
|
||||
OR: [
|
||||
{ contractDateNumber: contains(token) },
|
||||
{ contractNumber: contains(token) },
|
||||
{ followUpOwner: contains(token) },
|
||||
{ itemNumber: contains(token) },
|
||||
{ barcode: contains(token) },
|
||||
{ productName: contains(token) },
|
||||
@ -119,7 +121,7 @@ async function nextContractDateNumber(
|
||||
options?: { prefix?: string; preferredSequence?: number },
|
||||
) {
|
||||
const prefix = /^\d{6}$/.test(options?.prefix ?? '') ? options!.prefix! : contractDatePrefix()
|
||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(2026082911)`
|
||||
await tx.$executeRaw`DO $$ BEGIN PERFORM pg_advisory_xact_lock(2026082911); END $$`
|
||||
const preferredSequence = Number.isInteger(options?.preferredSequence) && Number(options?.preferredSequence) > 0
|
||||
? Math.min(999, Number(options?.preferredSequence))
|
||||
: null
|
||||
@ -343,6 +345,20 @@ export class FinishedProductProgressRepository {
|
||||
return prisma.finishedProductProgressItem.delete({ where: { id }, select: { id: true } })
|
||||
}
|
||||
|
||||
async deleteContractGroups(contractNumbers: string[], itemIds: string[]) {
|
||||
const normalizedContractNumbers = [...new Set(contractNumbers.map((value) => value.trim()).filter(Boolean))]
|
||||
const normalizedItemIds = [...new Set(itemIds.map((value) => value.trim()).filter(Boolean))]
|
||||
if (normalizedContractNumbers.length === 0 && normalizedItemIds.length === 0) return { count: 0 }
|
||||
return prisma.$transaction(async (tx) => tx.finishedProductProgressItem.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
...(normalizedContractNumbers.length > 0 ? [{ contractNumber: { in: normalizedContractNumbers } }] : []),
|
||||
...(normalizedItemIds.length > 0 ? [{ id: { in: normalizedItemIds } }] : []),
|
||||
],
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async updateContractGroup(
|
||||
updates: Array<{ id: string; contractNumber: string }>,
|
||||
actor: { id: string; name: string },
|
||||
@ -364,7 +380,7 @@ export class FinishedProductProgressRepository {
|
||||
dateNumberOptions?: { prefix?: string; preferredSequence?: number },
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(2026082911)`
|
||||
await tx.$executeRaw`DO $$ BEGIN PERFORM pg_advisory_xact_lock(2026082911); END $$`
|
||||
const sameContract = input.contractNumber.trim()
|
||||
? await tx.finishedProductProgressItem.findFirst({
|
||||
where: { contractNumber: input.contractNumber },
|
||||
@ -388,6 +404,7 @@ export class FinishedProductProgressRepository {
|
||||
data: {
|
||||
...dataValues(input),
|
||||
contractDateNumber,
|
||||
followUpOwner: actor.name,
|
||||
completedAt,
|
||||
createdById: actor.id,
|
||||
createdByName: actor.name,
|
||||
@ -399,7 +416,7 @@ export class FinishedProductProgressRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async update(id: string, input: FinishedProductProgressInput, actor: { id: string; name: string }, options?: { includeContractDateNumber?: boolean }) {
|
||||
async update(id: string, input: FinishedProductProgressInput, actor: { id: string; name: string }, options?: { includeContractDateNumber?: boolean; followUpOwner?: string }) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const current = await tx.finishedProductProgressItem.findUnique({ where: { id }, select: { completedAt: true } })
|
||||
const completedAt = input.orderedQuantity > 0 && input.deliveryQuantity >= input.orderedQuantity
|
||||
@ -407,7 +424,13 @@ export class FinishedProductProgressRepository {
|
||||
: null
|
||||
return tx.finishedProductProgressItem.update({
|
||||
where: { id },
|
||||
data: { ...dataValues(input, options), completedAt, updatedById: actor.id, updatedByName: actor.name },
|
||||
data: {
|
||||
...dataValues(input, options),
|
||||
...(options?.followUpOwner !== undefined ? { followUpOwner: options.followUpOwner } : {}),
|
||||
completedAt,
|
||||
updatedById: actor.id,
|
||||
updatedByName: actor.name,
|
||||
},
|
||||
select: ITEM_SELECT,
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,6 +24,7 @@ function record(): FinishedProductProgressRecord {
|
||||
id: 'finished-1',
|
||||
contractDateNumber: '260829001',
|
||||
contractNumber: 'LK20260829001',
|
||||
followUpOwner: '导入人',
|
||||
imageUrl: signedImageUrl,
|
||||
itemNumber: 'ITEM-1',
|
||||
barcode: null,
|
||||
@ -192,4 +193,37 @@ describe('FinishedProductProgressService contract-group editing and deletion', (
|
||||
await expect(service.delete('finished-1', user)).resolves.toEqual({ id: 'finished-1' })
|
||||
expect(deleteItem).toHaveBeenCalledWith('finished-1')
|
||||
})
|
||||
|
||||
it('deletes selected contract groups after edit permission checks', async () => {
|
||||
const deleteContractGroups = jest.fn().mockResolvedValue({ count: 4 })
|
||||
const repository = { deleteContractGroups } as unknown as FinishedProductProgressRepository
|
||||
const service = new FinishedProductProgressService(repository)
|
||||
|
||||
await expect(service.deleteContractGroups({ contractNumbers: ['HT-001'], itemIds: ['item-9'] }, user))
|
||||
.resolves.toEqual({ deletedCount: 4 })
|
||||
expect(deleteContractGroups).toHaveBeenCalledWith(['HT-001'], ['item-9'])
|
||||
})
|
||||
|
||||
it('rejects batch deletion without a selected group', async () => {
|
||||
const repository = { deleteContractGroups: jest.fn() } as unknown as FinishedProductProgressRepository
|
||||
const service = new FinishedProductProgressService(repository)
|
||||
|
||||
await expect(service.deleteContractGroups({ contractNumbers: [], itemIds: [] }, user)).rejects.toThrow('请选择要删除的合同组')
|
||||
})
|
||||
|
||||
it('records the importer as follow-up owner when importing an existing row', async () => {
|
||||
const current = { ...record(), firstDueDate: null, orderDueDate: null }
|
||||
const update = jest.fn().mockResolvedValue({ ...current, followUpOwner: user.name })
|
||||
const repository = {
|
||||
findByKey: jest.fn().mockResolvedValue(null),
|
||||
findByContractNumberAndItemNumber: jest.fn().mockResolvedValue(current),
|
||||
update,
|
||||
createLog: jest.fn(),
|
||||
} as unknown as FinishedProductProgressRepository
|
||||
const service = new FinishedProductProgressService(repository)
|
||||
|
||||
await service.importRows([input()], user)
|
||||
|
||||
expect(update).toHaveBeenCalledWith(current.id, expect.objectContaining({ contractNumber: current.contractNumber }), user, { followUpOwner: user.name })
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
FINISHED_PRODUCT_PROGRESS_FIELDS,
|
||||
calculateFinishedProductInTransitQuantity,
|
||||
@ -93,6 +94,7 @@ function toDetailRow(record: FinishedProductProgressRecord): FinishedProductProg
|
||||
completedAt: dateTimeText(record.completedAt),
|
||||
contractDateNumber: record.contractDateNumber,
|
||||
contractNumber: record.contractNumber,
|
||||
followUpOwner: record.followUpOwner,
|
||||
imageUrl: record.imageUrl ? resolveObjectStorageUrlForClientAccess(record.imageUrl) : '',
|
||||
itemNumber: record.itemNumber,
|
||||
barcode: record.barcode ?? '',
|
||||
@ -362,7 +364,7 @@ export class FinishedProductProgressService {
|
||||
const nextValues = { ...finalized.values, contractDateNumber: previous?.contractDateNumber || contractDateNumber || undefined }
|
||||
const changes = changeSet(previous, nextValues)
|
||||
const record = previous
|
||||
? await this.repository.update(previous.id, nextValues, user)
|
||||
? await this.repository.update(previous.id, nextValues, user, { followUpOwner: user.name })
|
||||
: await this.repository.create(nextValues, user, dateNumberOptions(derivedDateNumber || suppliedDateNumber || null))
|
||||
assignedDateNumbers.set(values.contractNumber, record.contractDateNumber)
|
||||
if (previous) updated += 1
|
||||
@ -435,6 +437,19 @@ export class FinishedProductProgressService {
|
||||
return { id: current.id }
|
||||
}
|
||||
|
||||
async deleteContractGroups(input: unknown, user: AuthUser) {
|
||||
await this.requireAccess(user, 'edit')
|
||||
const parsed = z.object({
|
||||
contractNumbers: z.array(z.string().trim().min(1).max(160)).max(100).default([]),
|
||||
itemIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]),
|
||||
}).strict().safeParse(input)
|
||||
if (!parsed.success || (parsed.data.contractNumbers.length === 0 && parsed.data.itemIds.length === 0)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要删除的合同组')
|
||||
}
|
||||
const result = await this.repository.deleteContractGroups(parsed.data.contractNumbers, parsed.data.itemIds)
|
||||
return { deletedCount: result.count }
|
||||
}
|
||||
|
||||
async listDeliveries(itemId: string, user: AuthUser) {
|
||||
await this.requireAccess(user, 'view')
|
||||
const item = await this.repository.findById(itemId.trim())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user