diff --git a/furniture-3d-system/prisma/migrations/20260901170000_add_finished_product_follow_up_person/migration.sql b/furniture-3d-system/prisma/migrations/20260901170000_add_finished_product_follow_up_person/migration.sql new file mode 100644 index 00000000..afac09b9 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260901170000_add_finished_product_follow_up_person/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "FinishedProductProgressItem" +ADD COLUMN "followUpOwner" TEXT NOT NULL DEFAULT ''; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 13b11b7f..f5c21725 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -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? diff --git a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/bulk-delete/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/bulk-delete/route.test.ts new file mode 100644 index 00000000..cd6d5c2b --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/bulk-delete/route.test.ts @@ -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' })) + }) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/bulk-delete/route.ts b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/bulk-delete/route.ts new file mode 100644 index 00000000..23323278 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/bulk-delete/route.ts @@ -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 }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/import/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/import/route.test.ts index e5f63e56..a716121c 100644 --- a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/import/route.test.ts +++ b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/import/route.test.ts @@ -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') + }) }) diff --git a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/import/route.ts b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/import/route.ts index 462754c8..c22723cc 100644 --- a/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/import/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/finished-product-progress/import/route.ts @@ -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() + 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() diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx index 3aca0e71..369e76f7 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/FinishedProductProgressClient.tsx @@ -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
{canEdit && } + {canEdit && } {canDownload && } {canEdit && <> { const file = event.target.files?.[0]; if (file) void importTask.importFile(file); event.currentTarget.value = '' }} />}
{ event.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}> - setFilters({ ...filters, search: event.target.value })} placeholder="搜索日期号、合同号、货号、条码、产品名称或备注" /> + setFilters({ ...filters, search: event.target.value })} placeholder="搜索日期号、合同号、跟进人、货号、条码、产品名称或备注" /> setFilters({ ...filters, supplier })} /> setFilters({ ...filters, brand })} /> setFilters({ ...filters, currentNode })} /> diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductEditorDrawer.tsx b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductEditorDrawer.tsx index a09ef06f..0a329e79 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductEditorDrawer.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductEditorDrawer.tsx @@ -55,6 +55,7 @@ export function FinishedProductEditorDrawer({ item, readOnly = false, onClose, o
set('contractNumber', value)} disabled={readOnly} /> + set('barcode', value)} disabled={readOnly} /> set('itemNumber', value)} disabled={readOnly} /> set('productName', value)} disabled={readOnly} /> diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductTable.tsx b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductTable.tsx index fa31ad96..8b25c71b 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductTable.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/components/FinishedProductTable.tsx @@ -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 {row.contractNumber || '-'} - if (column === 'imageUrl') return {row.imageUrl ? : -} + if (column === 'followUpOwner') return {row.followUpOwner || '-'} + if (column === 'imageUrl') return {row.imageUrl ? : -} if (column === 'actions') return
{canEdit && <>}
if (column === 'realtime') return 日期 {row.realtimeDate || '-'} · 已送货 {row.deliveryQuantity.toLocaleString('zh-CN')} if (column === 'currentNode') return {row.currentNode || '-'} @@ -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) { diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/finishedProductProgressColumns.ts b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/finishedProductProgressColumns.ts index 61a862eb..b7cc9092 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/finishedProductProgressColumns.ts +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/finishedProductProgressColumns.ts @@ -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 }, diff --git a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/finishedProductProgressStyles.ts b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/finishedProductProgressStyles.ts index 3c73ff90..6fdb1995 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/finishedProductProgressStyles.ts +++ b/furniture-3d-system/src/app/supply-chain/purchase/finished-product-progress/finishedProductProgressStyles.ts @@ -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} diff --git a/furniture-3d-system/src/lib/finished-product-progress.ts b/furniture-3d-system/src/lib/finished-product-progress.ts index aeb8344a..b328868b 100644 --- a/furniture-3d-system/src/lib/finished-product-progress.ts +++ b/furniture-3d-system/src/lib/finished-product-progress.ts @@ -135,6 +135,7 @@ export type FinishedProductContractGroupUpdate = z.infer { .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'] } }, + ], + }, + }) + }) }) diff --git a/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts b/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts index e316e59a..6f7f9d6c 100644 --- a/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts +++ b/furniture-3d-system/src/server/repositories/finished-product-progress.repository.ts @@ -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, }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/finished-product-progress.service.test.ts b/furniture-3d-system/src/server/services/__tests__/finished-product-progress.service.test.ts index f252c49a..0b15cf44 100644 --- a/furniture-3d-system/src/server/services/__tests__/finished-product-progress.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/finished-product-progress.service.test.ts @@ -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 }) + }) }) diff --git a/furniture-3d-system/src/server/services/finished-product-progress.service.ts b/furniture-3d-system/src/server/services/finished-product-progress.service.ts index cc04fd70..880cec73 100644 --- a/furniture-3d-system/src/server/services/finished-product-progress.service.ts +++ b/furniture-3d-system/src/server/services/finished-product-progress.service.ts @@ -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())