feat(supply-chain): add finished product progress detail
This commit is contained in:
parent
571b840911
commit
4492c0eb8f
@ -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;
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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': '产品变价',
|
||||
|
||||
@ -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([
|
||||
'打样进度明细',
|
||||
'成品进度明细',
|
||||
'供应商信息',
|
||||
'日常事项',
|
||||
'产品变价',
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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行:条码不能为空')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
@ -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}}
|
||||
`
|
||||
@ -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} />
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
124
furniture-3d-system/src/lib/finished-product-progress.ts
Normal file
124
furniture-3d-system/src/lib/finished-product-progress.ts
Normal 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>
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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\")')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user