feat(supply-chain): add finished product progress detail

This commit is contained in:
WuYongJie 2026-08-28 15:43:13 +08:00
parent 571b840911
commit 4492c0eb8f
30 changed files with 1342 additions and 0 deletions

View File

@ -0,0 +1,49 @@
CREATE TABLE "FinishedProductProgressItem" (
"id" TEXT NOT NULL,
"contractNumber" TEXT NOT NULL,
"imageUrl" TEXT,
"itemNumber" TEXT NOT NULL DEFAULT '',
"barcode" TEXT NOT NULL,
"productName" TEXT NOT NULL DEFAULT '',
"supplier" TEXT NOT NULL DEFAULT '',
"brand" TEXT NOT NULL DEFAULT '',
"orderedQuantity" INTEGER NOT NULL DEFAULT 0,
"inTransitQuantity" INTEGER NOT NULL DEFAULT 0,
"orderDueDate" DATE,
"currentNode" TEXT NOT NULL DEFAULT '',
"realtimeProgress" TEXT NOT NULL DEFAULT '',
"createdById" TEXT,
"createdByName" TEXT NOT NULL DEFAULT '',
"updatedById" TEXT,
"updatedByName" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinishedProductProgressItem_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinishedProductProgressChangeLog" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"actorId" TEXT,
"actorName" TEXT NOT NULL,
"changes" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinishedProductProgressChangeLog_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "FinishedProductProgressItem_contractNumber_barcode_key" ON "FinishedProductProgressItem"("contractNumber", "barcode");
CREATE INDEX "FinishedProductProgressItem_itemNumber_idx" ON "FinishedProductProgressItem"("itemNumber");
CREATE INDEX "FinishedProductProgressItem_supplier_idx" ON "FinishedProductProgressItem"("supplier");
CREATE INDEX "FinishedProductProgressItem_brand_idx" ON "FinishedProductProgressItem"("brand");
CREATE INDEX "FinishedProductProgressItem_currentNode_idx" ON "FinishedProductProgressItem"("currentNode");
CREATE INDEX "FinishedProductProgressItem_orderDueDate_idx" ON "FinishedProductProgressItem"("orderDueDate");
CREATE INDEX "FinishedProductProgressItem_updatedAt_idx" ON "FinishedProductProgressItem"("updatedAt");
CREATE INDEX "FinishedProductProgressChangeLog_itemId_createdAt_idx" ON "FinishedProductProgressChangeLog"("itemId", "createdAt");
CREATE INDEX "FinishedProductProgressChangeLog_createdAt_idx" ON "FinishedProductProgressChangeLog"("createdAt");
ALTER TABLE "FinishedProductProgressChangeLog"
ADD CONSTRAINT "FinishedProductProgressChangeLog_itemId_fkey"
FOREIGN KEY ("itemId") REFERENCES "FinishedProductProgressItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -94,6 +94,51 @@ model SampleProgressChangeLog {
@@index([itemId, createdAt])
}
model FinishedProductProgressItem {
id String @id @default(cuid())
contractNumber String
imageUrl String? @db.Text
itemNumber String @default("")
barcode String
productName String @default("")
supplier String @default("")
brand String @default("")
orderedQuantity Int @default(0)
inTransitQuantity Int @default(0)
orderDueDate DateTime? @db.Date
currentNode String @default("")
realtimeProgress String @default("") @db.Text
createdById String?
createdByName String @default("")
updatedById String?
updatedByName String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
changeLogs FinishedProductProgressChangeLog[]
@@unique([contractNumber, barcode])
@@index([itemNumber])
@@index([supplier])
@@index([brand])
@@index([currentNode])
@@index([orderDueDate])
@@index([updatedAt])
}
model FinishedProductProgressChangeLog {
id String @id @default(cuid())
itemId String
item FinishedProductProgressItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
operation String
actorId String?
actorName String
changes Json
createdAt DateTime @default(now())
@@index([itemId, createdAt])
@@index([createdAt])
}
// ============================================
// 产品分类
// ============================================
@ -3884,3 +3929,4 @@ model InstallationDemoTool {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -0,0 +1,12 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
type Context = { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, context: Context) {
return handleRequest(request, async (ctx) => {
const { id } = await context.params
return jsonSuccess(await finishedProductProgressService.listChangeLogs(id, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,13 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
type Context = { params: Promise<{ id: string }> }
export async function PATCH(request: NextRequest, context: Context) {
return handleRequest(request, async (ctx) => {
const { id } = await context.params
const body = await request.json().catch(() => null)
return jsonSuccess(await finishedProductProgressService.update(id, body, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import {
FINISHED_PRODUCT_PROGRESS_IMPORT_HEADERS,
type FinishedProductProgressImportProgress,
type FinishedProductProgressImportStreamEvent,
} from '@/lib/finished-product-progress'
import { ErrorCode, isAppError } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB', 400, ctx.requestId)
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: true })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => {
const key = FINISHED_PRODUCT_PROGRESS_IMPORT_HEADERS[header.trim()]
return key ? [[key, value]] : []
})))
if (rows.length === 0) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
if (rows.length > 5_000) return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行', 400, ctx.requestId)
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false
let progress: FinishedProductProgressImportProgress = { processed: 0, total: rows.length, created: 0, updated: 0 }
const send = (event: FinishedProductProgressImportStreamEvent) => {
if (closed) return
try { controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)) } catch { closed = true }
}
const close = () => {
if (closed) return
closed = true
controller.close()
}
send({ type: 'progress', progress })
void finishedProductProgressService.importRows(rows, ctx.user, (next) => {
progress = next
send({ type: 'progress', progress })
}).then((result) => {
progress = { processed: rows.length, total: rows.length, ...result }
send({ type: 'complete', progress })
close()
}).catch((error: unknown) => {
console.error(`[${ctx.requestId}] Finished product progress import failed:`, error)
send({ type: 'error', progress, message: isAppError(error) ? error.message : '导入过程中发生错误' })
close()
})
},
})
return new NextResponse(stream, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
})
}, { requireAuth: true })
}

View File

@ -0,0 +1,36 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS } from '@/lib/finished-product-progress'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
search: z.string().trim().max(200).default(''),
supplier: z.string().trim().max(200).default(''),
brand: z.string().trim().max(200).default(''),
currentNode: z.string().trim().max(200).default(''),
dueDateFrom: z.string().trim().max(20).default(''),
dueDateTo: z.string().trim().max(20).default(''),
})
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = listSchema.safeParse({
...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS,
...Object.fromEntries(request.nextUrl.searchParams.entries()),
})
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '成品进度筛选条件不正确', 400, ctx.requestId)
const { page, pageSize, ...filters } = parsed.data
return jsonSuccess(await finishedProductProgressService.listPage({ page, pageSize, filters, user: ctx.user }), 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
return jsonSuccess(await finishedProductProgressService.create(body, ctx.user), 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { FINISHED_PRODUCT_PROGRESS_FIELDS } from '@/lib/finished-product-progress'
import { handleRequest } from '@/server/core/response'
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
await finishedProductProgressService.requireTemplateAccess(ctx.user)
const headers = FINISHED_PRODUCT_PROGRESS_FIELDS.map((field) => field.label)
const sheet = XLSX.utils.aoa_to_sheet([headers])
sheet['!cols'] = headers.map((header) => ({ wch: Math.max(14, header.length * 2 + 4) }))
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, '成品进度导入')
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
return new NextResponse(new Uint8Array(content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': "attachment; filename*=UTF-8''%E6%88%90%E5%93%81%E8%BF%9B%E5%BA%A6%E5%AF%BC%E5%85%A5%E6%A8%A1%E6%9D%BF.xlsx",
'Cache-Control': 'no-store',
},
})
}, { requireAuth: true })
}

View File

@ -100,6 +100,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
'/supply-chain/database/store-account-management': '店铺账号管理',
'/supply-chain/new-products/unified-table': '新品开发进度总表',
'/supply-chain/new-products/sample-progress': '打样进度明细',
'/supply-chain/purchase/finished-product-progress': '成品进度明细',
'/supply-chain/purchase/supplier-information': '供应商信息',
'/supply-chain/purchase/daily-affairs': '日常事项',
'/supply-chain/purchase/price-change': '产品变价',

View File

@ -27,6 +27,11 @@ describe('supply chain menu', () => {
module: '采购管理',
route: '/supply-chain/new-products/sample-progress',
}),
expect.objectContaining({
label: '成品进度明细',
module: '采购管理',
route: '/supply-chain/purchase/finished-product-progress',
}),
expect.objectContaining({
label: '供应商信息',
module: '采购管理',
@ -37,6 +42,7 @@ describe('supply chain menu', () => {
expect(items.filter((item) => item.module === '采购管理').map((item) => item.label)).toEqual([
'打样进度明细',
'成品进度明细',
'供应商信息',
'日常事项',
'产品变价',

View File

@ -68,6 +68,7 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
{
items: [
{ label: '打样进度明细', route: '/supply-chain/new-products/sample-progress', targetKey: 'newProductSampleProgress' },
{ label: '成品进度明细', route: '/supply-chain/purchase/finished-product-progress', targetKey: 'newProductSampleProgress' },
{ label: '供应商信息', route: '/supply-chain/purchase/supplier-information', targetKey: 'purchaseSupplierInformation' },
{ label: '日常事项', route: '/supply-chain/purchase/daily-affairs', targetKey: 'purchaseDailyAffairs' },
{ label: '产品变价', route: '/supply-chain/purchase/price-change', targetKey: 'purchasePriceChange' },

View File

@ -0,0 +1,98 @@
'use client'
import Image from 'next/image'
import { useCallback, useRef, useState } from 'react'
import { FileDown, Plus, RotateCcw, Search, Upload, X } from 'lucide-react'
import {
EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS,
type FinishedProductProgressChangeLogEntry,
type FinishedProductProgressFilters,
type FinishedProductProgressPage,
type FinishedProductProgressRow,
} from '@/lib/finished-product-progress'
import { FinishedProductColumnSettings } from './components/FinishedProductColumnSettings'
import { FinishedProductEditorDrawer } from './components/FinishedProductEditorDrawer'
import { FinishedProductHistoryDialog } from './components/FinishedProductHistoryDialog'
import { FinishedProductImportProgress } from './components/FinishedProductImportProgress'
import { FinishedProductPagination } from './components/FinishedProductPagination'
import { FinishedProductTable } from './components/FinishedProductTable'
import { finishedProductProgressStyles } from './finishedProductProgressStyles'
import { useFinishedProductProgressColumns } from './useFinishedProductProgressColumns'
import { useFinishedProductImport } from './useFinishedProductImport'
async function readResponse(response: Response) {
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败')
return payload.data
}
export function FinishedProductProgressClient({ initialData, currentUserName, canEdit, canDownload }: { initialData: FinishedProductProgressPage; currentUserName: string; canEdit: boolean; canDownload: boolean }) {
const [data, setData] = useState(initialData)
const [filters, setFilters] = useState<FinishedProductProgressFilters>({ ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS })
const [editor, setEditor] = useState<FinishedProductProgressRow | 'new' | null>(null)
const [logs, setLogs] = useState<FinishedProductProgressChangeLogEntry[] | null>(null)
const [preview, setPreview] = useState<FinishedProductProgressRow | null>(null)
const [loading, setLoading] = useState(false)
const [pageInput, setPageInput] = useState(String(initialData.pageInfo.page))
const fileRef = useRef<HTMLInputElement>(null)
const columns = useFinishedProductProgressColumns(currentUserName)
const load = useCallback(async (nextFilters: FinishedProductProgressFilters, page: number, pageSize: number) => {
setLoading(true)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
Object.entries(nextFilters).forEach(([key, value]) => { if (value) params.set(key, value) })
const next = await readResponse(await fetch(`/api/supply-chain/finished-product-progress?${params}`, { cache: 'no-store' })) as FinishedProductProgressPage
setData(next)
setFilters(nextFilters)
setPageInput(String(next.pageInfo.page))
} catch (error) { window.alert(error instanceof Error ? error.message : '加载失败') } finally { setLoading(false) }
}, [])
const afterImport = useCallback(() => load(filters, 1, data.pageInfo.pageSize), [data.pageInfo.pageSize, filters, load])
const importTask = useFinishedProductImport(afterImport)
const reset = () => {
const next = { ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS }
setFilters(next)
void load(next, 1, data.pageInfo.pageSize)
}
const openHistory = async (row: FinishedProductProgressRow) => {
try { setLogs(await readResponse(await fetch(`/api/supply-chain/finished-product-progress/${row.id}/history`, { cache: 'no-store' }))) } catch (error) { window.alert(error instanceof Error ? error.message : '日志加载失败') }
}
const commitPage = () => {
const parsed = Number(pageInput)
if (!Number.isFinite(parsed)) return setPageInput(String(data.pageInfo.page))
const page = Math.min(Math.max(1, Math.trunc(parsed)), data.pageInfo.totalPages)
setPageInput(String(page))
if (page !== data.pageInfo.page) void load(filters, page, data.pageInfo.pageSize)
}
return <main className="finished-product-page">
<header className="finished-product-toolbar">
<div><h1>成品进度明细</h1><p>维护采购成品订单交期、在途数量、当前节点和实时进度</p></div>
<div className="finished-product-actions">
<FinishedProductColumnSettings visibleKeys={columns.visibleKeySet} hiddenCount={columns.hiddenCount} onToggle={columns.toggle} onShowAll={columns.showAll} />
{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="搜索合同号、货号、条码、产品名称或实时进度" />
<select value={filters.supplier} onChange={(event) => setFilters({ ...filters, supplier: event.target.value })}><option value="">全部供应商</option>{data.options.suppliers.map((value) => <option key={value}>{value}</option>)}</select>
<select value={filters.brand} onChange={(event) => setFilters({ ...filters, brand: event.target.value })}><option value="">全部品牌</option>{data.options.brands.map((value) => <option key={value}>{value}</option>)}</select>
<select value={filters.currentNode} onChange={(event) => setFilters({ ...filters, currentNode: event.target.value })}><option value="">全部当前节点</option>{data.options.currentNodes.map((value) => <option key={value}>{value}</option>)}</select>
<input type="date" title="订单交期开始" aria-label="订单交期开始" value={filters.dueDateFrom} onChange={(event) => setFilters({ ...filters, dueDateFrom: event.target.value })} />
<input type="date" title="订单交期结束" aria-label="订单交期结束" value={filters.dueDateTo} onChange={(event) => setFilters({ ...filters, dueDateTo: event.target.value })} />
<button type="submit" className="finished-primary-button"><Search size={15} />查询</button><button type="button" title="重置筛选" aria-label="重置筛选" onClick={reset} className="finished-control-button"><RotateCcw size={15} /></button>
</form>
<section className="finished-product-table-shell">
<FinishedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} onEdit={setEditor} onHistory={(row) => void openHistory(row)} onPreview={setPreview} />
<FinishedProductPagination {...data.pageInfo} loading={loading} pageInput={pageInput} onPageInput={setPageInput} onCommitPage={commitPage} onPage={(page) => void load(filters, page, data.pageInfo.pageSize)} onPageSize={(pageSize) => void load(filters, 1, pageSize)} />
</section>
{editor && <FinishedProductEditorDrawer item={editor === 'new' ? null : editor} onClose={() => setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />}
{logs && <FinishedProductHistoryDialog logs={logs} onClose={() => setLogs(null)} />}
<FinishedProductImportProgress state={importTask.state} onDismiss={importTask.dismiss} />
{preview?.imageUrl && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) setPreview(null) }}><div className="relative h-[min(82vh,900px)] w-[min(90vw,1200px)]"><Image unoptimized src={preview.imageUrl} alt={preview.productName || preview.barcode} fill sizes="90vw" className="object-contain" /><button type="button" title="关闭" onClick={() => setPreview(null)} className="absolute right-0 top-0 flex h-10 w-10 items-center justify-center rounded-full bg-black/70 text-white"><X size={20} /></button></div></div>}
<style jsx global>{finishedProductProgressStyles}</style>
</main>
}

View File

@ -0,0 +1,39 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Check, Columns3, RotateCcw, X } from 'lucide-react'
import { FINISHED_PRODUCT_PROGRESS_COLUMNS, type FinishedProductProgressColumnKey } from '../finishedProductProgressColumns'
export function FinishedProductColumnSettings({
visibleKeys,
hiddenCount,
onToggle,
onShowAll,
}: {
visibleKeys: ReadonlySet<FinishedProductProgressColumnKey>
hiddenCount: number
onToggle: (key: FinishedProductProgressColumnKey) => void
onShowAll: () => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) }
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [open])
return <div ref={ref} className="relative">
<button type="button" onClick={() => setOpen((value) => !value)} className="finished-control-button"><Columns3 size={15} />列设置{hiddenCount > 0 ? `(隐藏${hiddenCount})` : ''}</button>
{open && <div className="absolute right-0 top-12 z-50 w-[290px] rounded-[7px] border border-zinc-200 bg-white p-3 shadow-xl">
<div className="mb-2 flex items-center justify-between"><strong className="text-xs text-zinc-800">显示字段</strong><button type="button" title="关闭" onClick={() => setOpen(false)} className="finished-icon-button"><X size={15} /></button></div>
<div className="grid max-h-[320px] grid-cols-2 gap-1 overflow-y-auto">
{FINISHED_PRODUCT_PROGRESS_COLUMNS.map((column) => <button key={column.key} type="button" disabled={column.locked} onClick={() => onToggle(column.key)} className="flex h-9 items-center gap-2 rounded-[5px] px-2 text-left text-xs text-zinc-700 hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-55">
<span className={`flex h-4 w-4 items-center justify-center rounded-[3px] border ${visibleKeys.has(column.key) ? 'border-zinc-700 bg-zinc-800 text-white' : 'border-zinc-300'}`}>{visibleKeys.has(column.key) && <Check size={11} />}</span>{column.label}
</button>)}
</div>
<button type="button" onClick={onShowAll} className="mt-2 flex h-9 w-full items-center justify-center gap-2 rounded-[5px] border border-zinc-200 text-xs text-zinc-700 hover:bg-zinc-50"><RotateCcw size={13} />全部显示</button>
</div>}
</div>
}

View File

@ -0,0 +1,60 @@
'use client'
import { useState } from 'react'
import { Save, X } from 'lucide-react'
import type { FinishedProductProgressInput, FinishedProductProgressRow } from '@/lib/finished-product-progress'
const EMPTY: FinishedProductProgressInput = {
contractNumber: '', imageUrl: '', itemNumber: '', barcode: '', productName: '', supplier: '', brand: '', orderedQuantity: 0, inTransitQuantity: 0, orderDueDate: null, currentNode: '', realtimeProgress: '',
}
function toDraft(row: FinishedProductProgressRow | null): FinishedProductProgressInput {
if (!row) return EMPTY
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, updatedByName: _updatedByName, ...input } = row
return input
}
export function FinishedProductEditorDrawer({ item, onClose, onSaved }: { item: FinishedProductProgressRow | null; onClose: () => void; onSaved: (row: FinishedProductProgressRow) => void }) {
const [draft, setDraft] = useState(() => toDraft(item))
const [saving, setSaving] = useState(false)
const set = <K extends keyof FinishedProductProgressInput>(key: K, value: FinishedProductProgressInput[K]) => setDraft((current) => ({ ...current, [key]: value }))
const save = async () => {
setSaving(true)
try {
const response = await fetch(item ? `/api/supply-chain/finished-product-progress/${item.id}` : '/api/supply-chain/finished-product-progress', { method: item ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '保存失败')
onSaved(payload.data)
} catch (error) { window.alert(error instanceof Error ? error.message : '保存失败') } finally { setSaving(false) }
}
return <div className="fixed inset-0 z-[70] flex justify-end bg-black/30" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}>
<section className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl">
<header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{item ? '编辑成品进度' : '新增成品进度'}</h2><p className="mt-0.5 text-xs text-zinc-500">合同号与条码共同识别一条记录</p></div><button type="button" title="关闭" disabled={saving} onClick={onClose} className="finished-icon-button"><X size={18} /></button></header>
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2">
<Field label="合同号" required value={draft.contractNumber} onChange={(value) => set('contractNumber', value)} />
<Field label="条码" required value={draft.barcode} onChange={(value) => set('barcode', value)} />
<Field label="货号" value={draft.itemNumber} onChange={(value) => set('itemNumber', value)} />
<Field label="产品名称" value={draft.productName} onChange={(value) => set('productName', value)} />
<Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} />
<Field label="品牌" value={draft.brand} onChange={(value) => set('brand', value)} />
<NumberField label="下单数量" value={draft.orderedQuantity} onChange={(value) => set('orderedQuantity', value)} />
<NumberField label="在途数量" value={draft.inTransitQuantity} onChange={(value) => set('inTransitQuantity', value)} />
<label className="finished-field"><span>订单交期(周更)</span><input type="date" value={draft.orderDueDate ?? ''} onChange={(event) => set('orderDueDate', event.target.value || null)} /></label>
<Field label="当前节点" value={draft.currentNode} onChange={(value) => set('currentNode', value)} />
<label className="finished-field sm:col-span-2"><span>图片 URL</span><input value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://..." /></label>
<label className="finished-field sm:col-span-2"><span>实时</span><textarea rows={5} value={draft.realtimeProgress} onChange={(event) => set('realtimeProgress', event.target.value)} placeholder="填写当前实际进度" /></label>
</div>
</div>
<footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="finished-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="finished-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer>
</section>
</div>
}
function Field({ label, value, onChange, required = false }: { label: string; value: string; onChange: (value: string) => void; required?: boolean }) {
return <label className="finished-field"><span>{label}{required && <em>*</em>}</span><input value={value} onChange={(event) => onChange(event.target.value)} /></label>
}
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) {
return <label className="finished-field"><span>{label}</span><input type="number" min={0} step={1} value={value} onChange={(event) => onChange(Math.max(0, Math.trunc(Number(event.target.value) || 0)))} /></label>
}

View File

@ -0,0 +1,23 @@
'use client'
import { X } from 'lucide-react'
import type { FinishedProductProgressChangeLogEntry } from '@/lib/finished-product-progress'
const OPERATION_LABELS: Record<string, string> = { CREATE: '新增', UPDATE: '编辑', IMPORT_CREATE: '导入新增', IMPORT_UPDATE: '导入更新' }
function valueText(value: string | number | null) {
if (value === null || value === '') return '空白'
return String(value)
}
export function FinishedProductHistoryDialog({ logs, onClose }: { logs: FinishedProductProgressChangeLogEntry[]; onClose: () => void }) {
return <div className="fixed inset-0 z-[75] flex items-center justify-center bg-black/35 p-5" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
<section className="flex max-h-[82vh] w-full max-w-[820px] flex-col rounded-[7px] border border-zinc-200 bg-white shadow-2xl">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200 px-5"><div><h2 className="text-sm font-bold">修改日志</h2><p className="mt-0.5 text-xs text-zinc-500">记录新增、编辑和导入造成的字段变化</p></div><button type="button" title="关闭" onClick={onClose} className="finished-icon-button"><X size={17} /></button></header>
<div className="min-h-0 flex-1 overflow-y-auto p-5">{logs.length === 0 ? <div className="py-14 text-center text-sm text-zinc-500">暂无修改日志</div> : <div className="space-y-3">{logs.map((log) => <article key={log.id} className="rounded-[6px] border border-zinc-200 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"><strong className="text-xs text-zinc-800">{OPERATION_LABELS[log.operation] ?? log.operation}</strong><span className="text-xs text-zinc-500">{log.actorName || '未知操作人'} · {new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(log.createdAt))}</span></div>
<div className="space-y-2">{log.changes.map((change, index) => <div key={`${change.field}-${index}`} className="grid gap-1 rounded-[5px] bg-zinc-50 px-3 py-2 text-xs sm:grid-cols-[110px_1fr_20px_1fr]"><span className="font-semibold text-zinc-700">{change.label}</span><span className="break-all text-zinc-500">{valueText(change.before)}</span><span className="text-center text-zinc-400">→</span><span className="break-all text-zinc-800">{valueText(change.after)}</span></div>)}</div>
</article>)}</div>}</div>
</section>
</div>
}

View File

@ -0,0 +1,19 @@
'use client'
import { CheckCircle2, CircleAlert, Upload, X } from 'lucide-react'
import type { FinishedProductImportState } from '../useFinishedProductImport'
export function FinishedProductImportProgress({ state, onDismiss }: { state: FinishedProductImportState | null; onDismiss: () => void }) {
if (!state) return null
const active = state.status === 'preparing' || state.status === 'running'
const percent = state.total > 0 ? Math.min(100, Math.round(state.processed / state.total * 100)) : 0
const Icon = state.status === 'complete' ? CheckCircle2 : state.status === 'error' ? CircleAlert : Upload
return <aside aria-live="polite" className="fixed bottom-6 right-7 z-[80] w-[320px] rounded-[7px] border border-zinc-300 bg-white p-4 text-zinc-800 shadow-xl max-sm:bottom-20 max-sm:right-3 max-sm:w-[calc(100vw-24px)]">
<header className="flex items-center justify-between"><strong className="flex items-center gap-2 text-[13px]"><Icon size={16} />{state.status === 'complete' ? '导入完成' : state.status === 'error' ? '导入失败' : '正在导入'}</strong>{!active && <button type="button" title="关闭导入进度" onClick={onDismiss} className="finished-icon-button"><X size={15} /></button>}</header>
<div className="mt-2 truncate text-xs text-zinc-500" title={state.fileName}>{state.fileName}</div>
<div className="mt-3 flex justify-between text-xs"><span>{state.total > 0 ? `${state.processed} / ${state.total} 条` : '正在读取文件...'}</span>{state.total > 0 && <strong>{percent}%</strong>}</div>
<div role="progressbar" aria-label="成品进度导入进度" aria-valuemin={0} aria-valuemax={state.total || undefined} aria-valuenow={state.total ? state.processed : undefined} className="mt-2 h-1.5 overflow-hidden rounded-full bg-zinc-200"><span className={`block h-full rounded-full ${state.status === 'error' ? 'bg-red-700' : state.status === 'complete' ? 'bg-emerald-700' : 'bg-zinc-800'}`} style={{ width: state.total ? `${percent}%` : '36%' }} /></div>
<div className="mt-2 flex gap-5 text-xs text-zinc-500"><span>新增 {state.created}</span><span>更新 {state.updated}</span></div>
{state.message && <p className="mt-2 text-xs text-red-700">{state.message}</p>}
</aside>
}

View File

@ -0,0 +1,13 @@
'use client'
import { ArrowLeft, ArrowRight } from 'lucide-react'
export function FinishedProductPagination({ page, pageSize, total, totalPages, loading, pageInput, onPageInput, onCommitPage, onPage, onPageSize }: { page: number; pageSize: number; total: number; totalPages: number; loading: boolean; pageInput: string; onPageInput: (value: string) => void; onCommitPage: () => void; onPage: (page: number) => void; onPageSize: (pageSize: number) => void }) {
return <nav aria-label="成品进度明细分页" className="absolute bottom-6 left-1/2 z-30 flex max-w-[calc(100%-32px)] -translate-x-1/2 items-center gap-2.5 whitespace-nowrap rounded-full border border-zinc-200 bg-white/95 px-4 py-2 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)] backdrop-blur-xl max-sm:gap-1 max-sm:px-2 max-sm:text-xs">
<span className="mr-1 text-zinc-600">{loading ? '正在统计...' : `共 ${total} 条`}</span>
<button type="button" disabled={loading || page <= 1} onClick={() => onPage(page - 1)} className="finished-page-button"><ArrowLeft size={12} />上一页</button>
<span className="inline-flex items-center gap-1 text-zinc-600"><input type="text" inputMode="numeric" pattern="[0-9]*" aria-label="跳转页码" value={pageInput} disabled={loading} onChange={(event) => onPageInput(event.target.value.replace(/\D/g, ''))} onBlur={onCommitPage} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); onCommitPage() } }} className="h-7 w-[52px] rounded-[4px] border border-zinc-200 text-center font-semibold outline-none focus:border-zinc-500" /><span className="text-zinc-400">/</span><span>{totalPages} 页</span></span>
<button type="button" disabled={loading || page >= totalPages} onClick={() => onPage(page + 1)} className="finished-page-button">下一页<ArrowRight size={12} /></button>
<span className="ml-1 text-zinc-500">每页<select value={pageSize} onChange={(event) => onPageSize(Number(event.target.value))} className="mx-1 h-7 rounded-[4px] border border-zinc-200 bg-white px-1"><option value={50}>50</option><option value={100}>100</option><option value={200}>200</option></select>条</span>
</nav>
}

View File

@ -0,0 +1,27 @@
'use client'
import Image from 'next/image'
import { History, Pencil } from 'lucide-react'
import type { FinishedProductProgressRow } from '@/lib/finished-product-progress'
import type { FinishedProductProgressColumn, FinishedProductProgressColumnKey } from '../finishedProductProgressColumns'
export function FinishedProductTable({ rows, columns, canEdit, onEdit, onHistory, onPreview }: { rows: FinishedProductProgressRow[]; columns: readonly FinishedProductProgressColumn[]; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void }) {
const minWidth = columns.reduce((total, column) => total + column.minWidth, 0)
return <div className="min-h-0 flex-1 overflow-auto overscroll-contain">
<table className="w-full border-separate border-spacing-0 text-center text-xs" style={{ minWidth }}>
<thead className="text-[11px] font-bold text-zinc-600"><tr>{columns.map((column) => <th key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-3 py-3 ${column.stickyClassName ?? ''}`}>{column.label}</th>)}</tr></thead>
<tbody>{rows.map((row) => <tr key={row.id} className="group hover:bg-zinc-50">{columns.map((column) => <FinishedProductCell key={column.key} column={column.key} row={row} canEdit={canEdit} onEdit={onEdit} onHistory={onHistory} onPreview={onPreview} />)}</tr>)}</tbody>
</table>
{rows.length === 0 && <div className="py-20 text-center text-sm text-zinc-500">暂无匹配的成品进度记录</div>}
</div>
}
function FinishedProductCell({ column, row, canEdit, onEdit, onHistory, onPreview }: { column: FinishedProductProgressColumnKey; row: FinishedProductProgressRow; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void }) {
const base = '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 left-0 z-10 font-semibold text-zinc-900`}>{row.contractNumber || '-'}</td>
if (column === 'imageUrl') return <td className={`${base} sticky left-[150px] 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} fill sizes="48px" className="object-cover" /></button> : <span className="text-zinc-400">-</span>}</td>
if (column === 'actions') return <td className={`${base} sticky right-0 z-10 border-l border-zinc-200`}><div className="flex justify-center gap-1">{canEdit && <button type="button" title="编辑" onClick={() => onEdit(row)} className="finished-row-button"><Pencil size={14} />编辑</button>}<button type="button" title="修改日志" onClick={() => onHistory(row)} className="finished-row-button"><History size={14} />日志</button></div></td>
if (column === 'orderedQuantity' || column === 'inTransitQuantity') return <td className={`${base} tabular-nums`}>{row[column].toLocaleString('zh-CN')}</td>
if (column === 'orderDueDate') return <td className={`${base} whitespace-nowrap`}>{row.orderDueDate || '-'}</td>
return <td className={`${base} max-w-[260px] whitespace-pre-wrap break-words`} title={String(row[column] || '')}>{String(row[column] || '-')}</td>
}

View File

@ -0,0 +1,37 @@
import { readFinishedProductImportStream } from './finishedProductImportStream'
function responseFromChunks(chunks: string[]) {
const encoder = new TextEncoder()
let index = 0
return {
ok: true,
body: {
getReader() {
return {
async read() {
if (index >= chunks.length) return { done: true, value: undefined }
return { done: false, value: encoder.encode(chunks[index++]) }
},
}
},
},
} as unknown as Response
}
describe('finished product import stream', () => {
it('parses progress split across network chunks', async () => {
const events: string[] = []
const result = await readFinishedProductImportStream(responseFromChunks([
'{"type":"progress","progress":{"processed":1,"total":2,',
'"created":1,"updated":0}}\n{"type":"complete","progress":{"processed":2,"total":2,"created":1,"updated":1}}\n',
]), (event) => events.push(event.type))
expect(events).toEqual(['progress', 'complete'])
expect(result).toEqual({ processed: 2, total: 2, created: 1, updated: 1 })
})
it('surfaces server-side row errors with the current progress', async () => {
await expect(readFinishedProductImportStream(responseFromChunks([
'{"type":"error","progress":{"processed":3,"total":5,"created":2,"updated":1},"message":"第5行:条码不能为空"}\n',
]), () => undefined)).rejects.toThrow('第5行:条码不能为空')
})
})

View File

@ -0,0 +1,43 @@
import type { FinishedProductProgressImportProgress, FinishedProductProgressImportStreamEvent } from '@/lib/finished-product-progress'
function parseEvent(line: string): FinishedProductProgressImportStreamEvent {
const value = JSON.parse(line) as Record<string, unknown>
const progress = value.progress as Record<string, unknown> | undefined
if (!progress || !['processed', 'total', 'created', 'updated'].every((key) => typeof progress[key] === 'number')) {
throw new Error('导入进度响应格式不正确')
}
const normalized = progress as FinishedProductProgressImportProgress
if (value.type === 'progress' || value.type === 'complete') return { type: value.type, progress: normalized }
if (value.type === 'error' && typeof value.message === 'string') return { type: 'error', progress: normalized, message: value.message }
throw new Error('导入进度响应格式不正确')
}
export async function readFinishedProductImportStream(response: Response, onEvent: (event: FinishedProductProgressImportStreamEvent) => void) {
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error?.message || '导入请求失败')
}
if (!response.body) throw new Error('浏览器无法读取导入进度')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let complete: FinishedProductProgressImportProgress | null = null
const consume = (line: string) => {
if (!line.trim()) return
const event = parseEvent(line)
onEvent(event)
if (event.type === 'complete') complete = event.progress
if (event.type === 'error') throw new Error(event.message)
}
while (true) {
const { done, value } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
lines.forEach(consume)
if (done) break
}
consume(buffer)
if (!complete) throw new Error('导入连接提前结束,请查看已导入的数据')
return complete
}

View File

@ -0,0 +1,35 @@
import type { FinishedProductProgressFieldKey } from '@/lib/finished-product-progress'
export type FinishedProductProgressColumnKey = FinishedProductProgressFieldKey | 'actions'
export type FinishedProductProgressColumn = {
key: FinishedProductProgressColumnKey
label: string
minWidth: number
locked?: boolean
stickyClassName?: string
}
export const FINISHED_PRODUCT_PROGRESS_COLUMNS: readonly FinishedProductProgressColumn[] = [
{ key: 'contractNumber', label: '合同号', minWidth: 150, locked: true, stickyClassName: 'left-0 z-40' },
{ key: 'imageUrl', label: '图片', minWidth: 86, locked: true, stickyClassName: 'left-[150px] z-40' },
{ key: 'itemNumber', label: '货号', minWidth: 140 },
{ key: 'barcode', label: '条码', minWidth: 160 },
{ key: 'productName', label: '产品名称', minWidth: 220 },
{ key: 'supplier', label: '供应商', minWidth: 180 },
{ key: 'brand', label: '品牌', minWidth: 150 },
{ key: 'orderedQuantity', label: '下单数量', minWidth: 100 },
{ key: 'inTransitQuantity', label: '在途数量', minWidth: 100 },
{ key: 'orderDueDate', label: '订单交期(周更)', minWidth: 140 },
{ key: 'currentNode', label: '当前节点', minWidth: 140 },
{ key: 'realtimeProgress', label: '实时', minWidth: 240 },
{ key: 'actions', label: '操作', minWidth: 132, locked: true, stickyClassName: 'right-0 z-40' },
]
export const FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS = new Set(
FINISHED_PRODUCT_PROGRESS_COLUMNS.map((column) => column.key),
)
export const LOCKED_FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS = new Set(
FINISHED_PRODUCT_PROGRESS_COLUMNS.filter((column) => column.locked).map((column) => column.key),
)

View File

@ -0,0 +1,18 @@
export const finishedProductProgressStyles = String.raw`
.finished-product-page{height:100%;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:#f5f6f6;color:#27272a}
.finished-product-toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px;padding:18px 24px;border-bottom:1px solid #e4e4e7;background:#fff}
.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-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}
.finished-row-button{height:29px;padding:0 8px;font-size:11px}.finished-page-button{height:30px;padding:0 12px}
.finished-product-filters{display:grid;grid-template-columns:minmax(240px,2fr) repeat(3,minmax(130px,1fr)) 150px 150px auto auto;gap:8px;padding:12px 24px;border-bottom:1px solid #e4e4e7;background:#fff}
.finished-product-filters input,.finished-product-filters select{min-width:0;height:38px;padding:0 11px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;outline:none}.finished-product-filters input:focus,.finished-product-filters select:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}
.finished-product-table-shell{position:relative;display:flex;min-height:0;flex:1;flex-direction:column;margin:16px 20px 20px;overflow:hidden;border:1px solid #e4e4e7;border-radius:7px;background:#fff;box-shadow:0 8px 24px rgba(24,24,27,.05)}
.finished-product-table-shell>div:first-child{padding-bottom:76px}
.finished-field{display:flex;flex-direction:column;gap:6px}.finished-field>span{font-size:12px;font-weight:650;color:#52525b}.finished-field em{margin-left:3px;color:#b91c1c;font-style:normal}.finished-field input,.finished-field textarea{width:100%;box-sizing:border-box;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:9px 10px;color:#27272a;font-size:13px;outline:none}.finished-field input{height:40px}.finished-field textarea{resize:vertical;line-height:1.6}.finished-field input:focus,.finished-field textarea:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}
@media(max-width:1280px){.finished-product-filters{grid-template-columns:repeat(4,minmax(140px,1fr))}.finished-product-filters>input:first-child{grid-column:span 2}}
@media(max-width:760px){.finished-product-toolbar{align-items:flex-start;padding:14px}.finished-product-actions{justify-content:flex-start}.finished-product-filters{grid-template-columns:1fr 1fr;padding:10px 14px}.finished-product-filters>input:first-child{grid-column:1/-1}.finished-product-table-shell{margin:10px}.finished-product-table-shell>div:first-child{padding-bottom:72px}.finished-page-button{padding:0 7px}.finished-product-toolbar p{display:none}}
`

View File

@ -0,0 +1,15 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
import { FinishedProductProgressClient } from './FinishedProductProgressClient'
export const dynamic = 'force-dynamic'
export const metadata = { title: '成品进度明细 · 供应链中心' }
export default async function FinishedProductProgressPage() {
const { authUser, user } = await requireSession('/login')
const access = await finishedProductProgressService.getAccess(authUser)
if (!access.view) redirect('/supply-chain')
const initialData = await finishedProductProgressService.listPage({ page: 1, pageSize: 50, user: authUser })
return <FinishedProductProgressClient initialData={initialData} currentUserName={user.name} canEdit={access.edit} canDownload={access.download} />
}

View File

@ -0,0 +1,39 @@
'use client'
import { useCallback, useState } from 'react'
import type { FinishedProductProgressImportProgress, FinishedProductProgressImportStreamEvent } from '@/lib/finished-product-progress'
import { readFinishedProductImportStream } from './finishedProductImportStream'
export type FinishedProductImportState = FinishedProductProgressImportProgress & {
fileName: string
status: 'preparing' | 'running' | 'complete' | 'error'
message?: string
}
const EMPTY = { processed: 0, total: 0, created: 0, updated: 0 }
export function useFinishedProductImport(onCompleted: () => Promise<void>) {
const [state, setState] = useState<FinishedProductImportState | null>(null)
const isImporting = state?.status === 'preparing' || state?.status === 'running'
const importFile = useCallback(async (file: File) => {
if (isImporting) return
setState({ ...EMPTY, fileName: file.name, status: 'preparing' })
const body = new FormData()
body.append('file', file)
try {
const response = await fetch('/api/supply-chain/finished-product-progress/import', { method: 'POST', body })
await readFinishedProductImportStream(response, (event: FinishedProductProgressImportStreamEvent) => {
setState({ ...event.progress, fileName: file.name, status: event.type === 'progress' ? 'running' : event.type, ...(event.type === 'error' ? { message: event.message } : {}) })
})
await onCompleted()
} catch (error) {
setState((current) => ({ ...EMPTY, ...current, fileName: file.name, status: 'error', message: error instanceof Error ? error.message : '导入失败' }))
}
}, [isImporting, onCompleted])
return {
state,
isImporting,
importFile,
dismiss: () => setState((current) => current?.status === 'preparing' || current?.status === 'running' ? current : null),
}
}

View File

@ -0,0 +1,63 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import {
FINISHED_PRODUCT_PROGRESS_COLUMNS,
FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS,
LOCKED_FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS,
type FinishedProductProgressColumnKey,
} from './finishedProductProgressColumns'
function storageKey(userName: string) {
return `finished-product-progress:hidden-columns:${userName.trim().toLocaleLowerCase() || 'anonymous'}`
}
export function useFinishedProductProgressColumns(userName: string) {
const [hiddenKeys, setHiddenKeys] = useState<Set<FinishedProductProgressColumnKey>>(new Set())
useEffect(() => {
try {
const value = JSON.parse(localStorage.getItem(storageKey(userName)) ?? '[]')
const next = new Set<FinishedProductProgressColumnKey>()
if (Array.isArray(value)) value.forEach((key) => {
if (
typeof key === 'string'
&& FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS.has(key as FinishedProductProgressColumnKey)
&& !LOCKED_FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS.has(key as FinishedProductProgressColumnKey)
) next.add(key as FinishedProductProgressColumnKey)
})
// Hydrate the browser-only preference after the server render.
// eslint-disable-next-line react-hooks/set-state-in-effect
setHiddenKeys(next)
} catch {
setHiddenKeys(new Set())
}
}, [userName])
const save = (next: Set<FinishedProductProgressColumnKey>) => {
setHiddenKeys(next)
localStorage.setItem(storageKey(userName), JSON.stringify([...next]))
}
const toggle = (key: FinishedProductProgressColumnKey) => {
if (LOCKED_FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS.has(key)) return
const next = new Set(hiddenKeys)
if (next.has(key)) next.delete(key)
else next.add(key)
save(next)
}
const visibleColumns = useMemo(
() => FINISHED_PRODUCT_PROGRESS_COLUMNS.filter((column) => column.locked || !hiddenKeys.has(column.key)),
[hiddenKeys],
)
return {
hiddenKeys,
visibleColumns,
hiddenCount: hiddenKeys.size,
visibleKeySet: new Set(visibleColumns.map((column) => column.key)),
toggle,
showAll: () => save(new Set()),
}
}

View File

@ -0,0 +1,33 @@
import {
FINISHED_PRODUCT_PROGRESS_FIELDS,
finishedProductProgressInputSchema,
} from './finished-product-progress'
describe('finished product progress input', () => {
it('keeps the confirmed table header order', () => {
expect(FINISHED_PRODUCT_PROGRESS_FIELDS.map((field) => field.label)).toEqual([
'合同号', '图片', '货号', '条码', '产品名称', '供应商', '品牌', '下单数量', '在途数量', '订单交期(周更)', '当前节点', '实时',
])
})
it('normalizes a valid imported row', () => {
const result = finishedProductProgressInputSchema.parse({
contractNumber: ' HT-001 ', imageUrl: '', itemNumber: 'CP001', barcode: '690001', productName: '餐椅', supplier: '供应商A', brand: '品牌A', orderedQuantity: '1,200', inTransitQuantity: '80', orderDueDate: '2026-09-01', currentNode: '生产中', realtimeProgress: '已完成木工',
})
expect(result).toMatchObject({ contractNumber: 'HT-001', orderedQuantity: 1200, inTransitQuantity: 80, orderDueDate: '2026-09-01' })
})
it('requires contract number and barcode and rejects negative quantities', () => {
const base = { contractNumber: '', imageUrl: '', itemNumber: '', barcode: '', productName: '', supplier: '', brand: '', orderedQuantity: -1, inTransitQuantity: 0, orderDueDate: null, currentNode: '', realtimeProgress: '' }
const result = finishedProductProgressInputSchema.safeParse(base)
expect(result.success).toBe(false)
if (!result.success) expect(result.error.issues.map((issue) => issue.message)).toEqual(expect.arrayContaining(['合同号不能为空', '条码不能为空', '下单数量不能小于0']))
})
it('converts an Excel serial date to an ISO date', () => {
const result = finishedProductProgressInputSchema.parse({
contractNumber: 'HT-002', imageUrl: '', itemNumber: '', barcode: '690002', productName: '', supplier: '', brand: '', orderedQuantity: 0, inTransitQuantity: 0, orderDueDate: 46266, currentNode: '', realtimeProgress: '',
})
expect(result.orderDueDate).toBe('2026-09-01')
})
})

View File

@ -0,0 +1,124 @@
import { z } from 'zod'
export const FINISHED_PRODUCT_PROGRESS_FIELDS = [
{ key: 'contractNumber', label: '合同号' },
{ key: 'imageUrl', label: '图片' },
{ key: 'itemNumber', label: '货号' },
{ key: 'barcode', label: '条码' },
{ key: 'productName', label: '产品名称' },
{ key: 'supplier', label: '供应商' },
{ key: 'brand', label: '品牌' },
{ key: 'orderedQuantity', label: '下单数量' },
{ key: 'inTransitQuantity', label: '在途数量' },
{ key: 'orderDueDate', label: '订单交期(周更)' },
{ key: 'currentNode', label: '当前节点' },
{ key: 'realtimeProgress', label: '实时' },
] as const
export type FinishedProductProgressFieldKey = typeof FINISHED_PRODUCT_PROGRESS_FIELDS[number]['key']
const cleanString = (max: number) => z.preprocess(
(value) => String(value ?? '').trim(),
z.string().max(max),
)
const requiredString = (label: string, max: number) => z.preprocess(
(value) => String(value ?? '').trim(),
z.string().min(1, `${label}不能为空`).max(max),
)
const nonnegativeInteger = (label: string) => z.preprocess((value) => {
if (value === '' || value === null || value === undefined) return 0
return typeof value === 'number' ? value : Number(String(value).replace(/,/g, '').trim())
}, z.number({ message: `${label}必须是数字` }).int(`${label}必须是整数`).min(0, `${label}不能小于0`).max(2_000_000_000, `${label}超出允许范围`))
function normalizeDate(value: unknown) {
if (value === '' || value === null || value === undefined) return null
if (value instanceof Date) return Number.isNaN(value.getTime()) ? value : value.toISOString().slice(0, 10)
if (typeof value === 'number' && Number.isFinite(value)) {
const excelEpoch = Date.UTC(1899, 11, 30)
return new Date(excelEpoch + Math.trunc(value) * 86_400_000).toISOString().slice(0, 10)
}
return String(value).trim()
}
const optionalDate = z.preprocess(
normalizeDate,
z.string().refine((value) => !Number.isNaN(new Date(`${value}T00:00:00Z`).getTime()), '订单交期格式不正确').nullable(),
)
export const finishedProductProgressInputSchema = z.object({
contractNumber: requiredString('合同号', 160),
imageUrl: cleanString(2_000),
itemNumber: cleanString(160),
barcode: requiredString('条码', 160),
productName: cleanString(240),
supplier: cleanString(240),
brand: cleanString(160),
orderedQuantity: nonnegativeInteger('下单数量'),
inTransitQuantity: nonnegativeInteger('在途数量'),
orderDueDate: optionalDate,
currentNode: cleanString(160),
realtimeProgress: cleanString(2_000),
}).strict()
export type FinishedProductProgressInput = z.infer<typeof finishedProductProgressInputSchema>
export const EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS = {
search: '',
supplier: '',
brand: '',
currentNode: '',
dueDateFrom: '',
dueDateTo: '',
} as const
export type FinishedProductProgressFilters = {
[K in keyof typeof EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS]: string
}
export type FinishedProductProgressRow = FinishedProductProgressInput & {
id: string
createdAt: string
updatedAt: string
updatedByName: string
}
export type FinishedProductProgressPage = {
rows: FinishedProductProgressRow[]
pageInfo: { page: number; pageSize: number; total: number; totalPages: number }
options: { suppliers: string[]; brands: string[]; currentNodes: string[] }
}
export type FinishedProductProgressChange = {
field: FinishedProductProgressFieldKey
label: string
before: string | number | null
after: string | number | null
}
export type FinishedProductProgressChangeLogEntry = {
id: string
operation: string
actorName: string
changes: FinishedProductProgressChange[]
createdAt: string
}
export type FinishedProductProgressImportProgress = {
processed: number
total: number
created: number
updated: number
}
export type FinishedProductProgressImportStreamEvent =
| { type: 'progress' | 'complete'; progress: FinishedProductProgressImportProgress }
| { type: 'error'; progress: FinishedProductProgressImportProgress; message: string }
export const FINISHED_PRODUCT_PROGRESS_IMPORT_HEADERS = Object.fromEntries(
FINISHED_PRODUCT_PROGRESS_FIELDS.flatMap((field) => [
[field.label, field.key],
[field.key, field.key],
]),
) as Record<string, FinishedProductProgressFieldKey>

View File

@ -318,6 +318,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) {
if (pathname === '/supply-chain/new-products/termination-feedback') return 'newProductTerminationFeedback' as const
if (pathname === '/supply-chain/new-products/unified-table') return 'newProductUnifiedTable' as const
if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/finished-product-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/supplier-information') return 'purchaseSupplierInformation' as const
if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const
if (pathname === '/supply-chain/purchase/price-change') return 'purchasePriceChange' as const
@ -347,6 +348,12 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
if (pathname.startsWith('/api/supply-chain/store-account-management')) {
return { targetKey: 'storeAccountManagement' as const, action }
}
if (pathname.startsWith('/api/supply-chain/finished-product-progress')) {
return {
targetKey: 'newProductSampleProgress' as const,
action: pathname.endsWith('/template') ? 'download' as const : action,
}
}
if (pathname.startsWith('/api/supply-chain/new-product-termination-feedback')) {
return { targetKey: 'newProductTerminationFeedback' as const, action }
}

View File

@ -0,0 +1,17 @@
import fs from 'node:fs'
import path from 'node:path'
describe('finished product progress migration', () => {
const sql = fs.readFileSync(path.join(process.cwd(), 'prisma/migrations/20260827161500_add_finished_product_progress/migration.sql'), 'utf8')
it('creates an independent item and change-log table', () => {
expect(sql).toContain('CREATE TABLE "FinishedProductProgressItem"')
expect(sql).toContain('CREATE TABLE "FinishedProductProgressChangeLog"')
expect(sql).toContain('FinishedProductProgressChangeLog_itemId_fkey')
})
it('enforces the contract-number and barcode import key', () => {
expect(sql).toContain('FinishedProductProgressItem_contractNumber_barcode_key')
expect(sql).toContain('(\"contractNumber\", \"barcode\")')
})
})

View File

@ -0,0 +1,167 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import type { FinishedProductProgressFilters, FinishedProductProgressInput } from '@/lib/finished-product-progress'
const ITEM_SELECT = {
id: true,
contractNumber: true,
imageUrl: true,
itemNumber: true,
barcode: true,
productName: true,
supplier: true,
brand: true,
orderedQuantity: true,
inTransitQuantity: true,
orderDueDate: true,
currentNode: true,
realtimeProgress: true,
createdAt: true,
updatedAt: true,
updatedByName: true,
} as const
export type FinishedProductProgressRecord = Prisma.FinishedProductProgressItemGetPayload<{
select: typeof ITEM_SELECT
}>
function contains(value: string) {
return { contains: value, mode: 'insensitive' as const }
}
function buildWhere(filters: FinishedProductProgressFilters): Prisma.FinishedProductProgressItemWhereInput | undefined {
const and: Prisma.FinishedProductProgressItemWhereInput[] = []
const tokens = [...new Set(filters.search.split(/[\s,,;;]+/).map((value) => value.trim()).filter(Boolean))].slice(0, 100)
tokens.forEach((token) => and.push({
OR: [
{ contractNumber: contains(token) },
{ itemNumber: contains(token) },
{ barcode: contains(token) },
{ productName: contains(token) },
{ supplier: contains(token) },
{ brand: contains(token) },
{ currentNode: contains(token) },
{ realtimeProgress: contains(token) },
],
}))
if (filters.supplier) and.push({ supplier: contains(filters.supplier) })
if (filters.brand) and.push({ brand: contains(filters.brand) })
if (filters.currentNode) and.push({ currentNode: contains(filters.currentNode) })
if (filters.dueDateFrom || filters.dueDateTo) {
and.push({
orderDueDate: {
...(filters.dueDateFrom ? { gte: new Date(`${filters.dueDateFrom}T00:00:00Z`) } : {}),
...(filters.dueDateTo ? { lte: new Date(`${filters.dueDateTo}T00:00:00Z`) } : {}),
},
})
}
return and.length > 0 ? { AND: and } : undefined
}
function dataValues(input: FinishedProductProgressInput) {
return {
...input,
imageUrl: input.imageUrl || null,
orderDueDate: input.orderDueDate ? new Date(`${input.orderDueDate}T00:00:00Z`) : null,
}
}
export class FinishedProductProgressRepository {
async listPage(params: { skip: number; take: number; filters: FinishedProductProgressFilters }) {
const where = buildWhere(params.filters)
const [rows, total] = await Promise.all([
prisma.finishedProductProgressItem.findMany({
where,
select: ITEM_SELECT,
orderBy: [{ orderDueDate: 'asc' }, { updatedAt: 'desc' }],
skip: params.skip,
take: params.take,
}),
prisma.finishedProductProgressItem.count({ where }),
])
return { rows, total }
}
async listOptions() {
const [suppliers, brands, currentNodes] = await Promise.all([
prisma.finishedProductProgressItem.findMany({ select: { supplier: true }, distinct: ['supplier'], orderBy: { supplier: 'asc' } }),
prisma.finishedProductProgressItem.findMany({ select: { brand: true }, distinct: ['brand'], orderBy: { brand: 'asc' } }),
prisma.finishedProductProgressItem.findMany({ select: { currentNode: true }, distinct: ['currentNode'], orderBy: { currentNode: 'asc' } }),
])
return {
suppliers: suppliers.map((row) => row.supplier.trim()).filter(Boolean),
brands: brands.map((row) => row.brand.trim()).filter(Boolean),
currentNodes: currentNodes.map((row) => row.currentNode.trim()).filter(Boolean),
}
}
async findById(id: string) {
return prisma.finishedProductProgressItem.findUnique({ where: { id }, select: ITEM_SELECT })
}
async findByKey(contractNumber: string, barcode: string) {
return prisma.finishedProductProgressItem.findUnique({
where: { contractNumber_barcode: { contractNumber, barcode } },
select: ITEM_SELECT,
})
}
async create(input: FinishedProductProgressInput, actor: { id: string; name: string }) {
return prisma.finishedProductProgressItem.create({
data: {
...dataValues(input),
createdById: actor.id,
createdByName: actor.name,
updatedById: actor.id,
updatedByName: actor.name,
},
select: ITEM_SELECT,
})
}
async update(id: string, input: FinishedProductProgressInput, actor: { id: string; name: string }) {
return prisma.finishedProductProgressItem.update({
where: { id },
data: { ...dataValues(input), updatedById: actor.id, updatedByName: actor.name },
select: ITEM_SELECT,
})
}
async upsert(input: FinishedProductProgressInput, actor: { id: string; name: string }) {
return prisma.finishedProductProgressItem.upsert({
where: { contractNumber_barcode: { contractNumber: input.contractNumber, barcode: input.barcode } },
create: {
...dataValues(input),
createdById: actor.id,
createdByName: actor.name,
updatedById: actor.id,
updatedByName: actor.name,
},
update: { ...dataValues(input), updatedById: actor.id, updatedByName: actor.name },
select: ITEM_SELECT,
})
}
async createLog(data: {
itemId: string
operation: string
actorId: string
actorName: string
changes: unknown
}) {
await prisma.finishedProductProgressChangeLog.create({
data: { ...data, changes: data.changes as Prisma.InputJsonValue },
})
}
async listLogs(itemId: string, take = 200) {
return prisma.finishedProductProgressChangeLog.findMany({
where: { itemId },
select: { id: true, operation: true, actorName: true, changes: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take,
})
}
}
export const finishedProductProgressRepository = new FinishedProductProgressRepository()

View File

@ -0,0 +1,206 @@
import {
EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS,
FINISHED_PRODUCT_PROGRESS_FIELDS,
finishedProductProgressInputSchema,
type FinishedProductProgressChange,
type FinishedProductProgressFilters,
type FinishedProductProgressImportProgress,
type FinishedProductProgressInput,
type FinishedProductProgressRow,
} from '@/lib/finished-product-progress'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
finishedProductProgressRepository,
type FinishedProductProgressRecord,
type FinishedProductProgressRepository,
} from '@/server/repositories/finished-product-progress.repository'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
const MAX_PAGE_SIZE = 200
function dateText(value: Date | string | null | undefined) {
if (!value) return null
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10)
}
function cleanFilters(input?: Partial<FinishedProductProgressFilters>): FinishedProductProgressFilters {
return Object.fromEntries(Object.keys(EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS).map((key) => [
key,
String(input?.[key as keyof FinishedProductProgressFilters] ?? '').trim().slice(0, 200),
])) as FinishedProductProgressFilters
}
function comparable(input: FinishedProductProgressInput | FinishedProductProgressRecord) {
return {
contractNumber: input.contractNumber,
imageUrl: input.imageUrl ?? '',
itemNumber: input.itemNumber,
barcode: input.barcode,
productName: input.productName,
supplier: input.supplier,
brand: input.brand,
orderedQuantity: input.orderedQuantity,
inTransitQuantity: input.inTransitQuantity,
orderDueDate: dateText(input.orderDueDate),
currentNode: input.currentNode,
realtimeProgress: input.realtimeProgress,
} satisfies Record<string, string | number | null>
}
function changeSet(
before: FinishedProductProgressInput | FinishedProductProgressRecord | null,
after: FinishedProductProgressInput,
): FinishedProductProgressChange[] {
const previous = before ? comparable(before) : {}
const next = comparable(after)
return FINISHED_PRODUCT_PROGRESS_FIELDS.flatMap((field) => {
const beforeValue = previous[field.key as keyof typeof previous] ?? null
const afterValue = next[field.key]
return beforeValue === afterValue ? [] : [{ field: field.key, label: field.label, before: beforeValue, after: afterValue }]
})
}
function toRow(record: FinishedProductProgressRecord): FinishedProductProgressRow {
return {
id: record.id,
contractNumber: record.contractNumber,
imageUrl: record.imageUrl ?? '',
itemNumber: record.itemNumber,
barcode: record.barcode,
productName: record.productName,
supplier: record.supplier,
brand: record.brand,
orderedQuantity: record.orderedQuantity,
inTransitQuantity: record.inTransitQuantity,
orderDueDate: dateText(record.orderDueDate),
currentNode: record.currentNode,
realtimeProgress: record.realtimeProgress,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
updatedByName: record.updatedByName,
}
}
function parseInput(input: unknown, rowNumber?: number) {
const parsed = finishedProductProgressInputSchema.safeParse(input)
if (parsed.success) return parsed.data
const message = parsed.error.issues[0]?.message ?? '数据格式不正确'
throw new AppError(
ErrorCode.VALIDATION_FAILED,
rowNumber ? `第${rowNumber}行:${message}` : message,
)
}
export class FinishedProductProgressService {
constructor(private readonly repository: FinishedProductProgressRepository = finishedProductProgressRepository) {}
async getAccess(user: AuthUser) {
return supplyChainPermissionSettingsService.getUserAccess(user, 'newProductSampleProgress')
}
private async requireAccess(user: AuthUser, action: 'view' | 'download' | 'edit') {
const access = await this.getAccess(user)
if (!access[action]) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有成品进度明细权限')
return access
}
async listPage(params: {
page?: number
pageSize?: number
filters?: Partial<FinishedProductProgressFilters>
user: AuthUser
}) {
await this.requireAccess(params.user, 'view')
const page = Math.max(1, Math.trunc(params.page || 1))
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(params.pageSize || 50)))
const filters = cleanFilters(params.filters)
const [result, options] = await Promise.all([
this.repository.listPage({ skip: (page - 1) * pageSize, take: pageSize, filters }),
this.repository.listOptions(),
])
return {
rows: result.rows.map(toRow),
pageInfo: { page, pageSize, total: result.total, totalPages: Math.max(1, Math.ceil(result.total / pageSize)) },
options,
}
}
async create(input: unknown, user: AuthUser) {
await this.requireAccess(user, 'edit')
const values = parseInput(input)
if (await this.repository.findByKey(values.contractNumber, values.barcode)) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '相同合同号和条码的记录已存在')
}
const record = await this.repository.create(values, user)
await this.repository.createLog({ itemId: record.id, operation: 'CREATE', actorId: user.id, actorName: user.name, changes: changeSet(null, values) })
return toRow(record)
}
async update(id: string, input: unknown, user: AuthUser) {
await this.requireAccess(user, 'edit')
const current = await this.repository.findById(id.trim())
if (!current) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '成品进度记录不存在')
const values = parseInput(input)
const sameKey = await this.repository.findByKey(values.contractNumber, values.barcode)
if (sameKey && sameKey.id !== current.id) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '相同合同号和条码的记录已存在')
}
const changes = changeSet(current, values)
if (changes.length === 0) return toRow(current)
const record = await this.repository.update(current.id, values, user)
await this.repository.createLog({ itemId: record.id, operation: 'UPDATE', actorId: user.id, actorName: user.name, changes })
return toRow(record)
}
async importRows(
rows: Array<Record<string, unknown>>,
user: AuthUser,
onProgress?: (progress: FinishedProductProgressImportProgress) => void,
) {
await this.requireAccess(user, 'edit')
if (rows.length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据')
if (rows.length > 5_000) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行')
let created = 0
let updated = 0
for (let index = 0; index < rows.length; index += 1) {
const values = parseInput(rows[index], index + 2)
const previous = await this.repository.findByKey(values.contractNumber, values.barcode)
const changes = changeSet(previous, values)
const record = await this.repository.upsert(values, user)
if (previous) updated += 1
else created += 1
if (changes.length > 0) {
await this.repository.createLog({
itemId: record.id,
operation: previous ? 'IMPORT_UPDATE' : 'IMPORT_CREATE',
actorId: user.id,
actorName: user.name,
changes,
})
}
onProgress?.({ processed: index + 1, total: rows.length, created, updated })
}
return { created, updated }
}
async listChangeLogs(itemId: string, user: AuthUser) {
await this.requireAccess(user, 'view')
if (!await this.repository.findById(itemId.trim())) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '成品进度记录不存在')
const logs = await this.repository.listLogs(itemId.trim())
return logs.map((log) => ({
id: log.id,
operation: log.operation,
actorName: log.actorName,
changes: log.changes as unknown as FinishedProductProgressChange[],
createdAt: log.createdAt.toISOString(),
}))
}
async requireTemplateAccess(user: AuthUser) {
await this.requireAccess(user, 'download')
}
}
export const finishedProductProgressService = new FinishedProductProgressService()