merge: release five accepted features

This commit is contained in:
WuYongJie 2026-08-28 16:09:04 +08:00
commit 6e9d0e474f
88 changed files with 5025 additions and 914 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

@ -0,0 +1,2 @@
ALTER TABLE "InstallationDemoTool"
ADD COLUMN "articulationConfig" JSONB;

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])
}
// ============================================
// 产品分类
// ============================================
@ -3881,6 +3926,7 @@ model InstallationDemoTool {
motionAxis Json?
spinTurns Float @default(3)
strikeDistanceRatio Float @default(0.2)
articulationConfig Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@ -3996,3 +4042,4 @@ model ScriptMonitorRun {
@@index([scriptId, startedAt(sort: Desc)])
@@index([agentId, startedAt(sort: Desc)])
}

View File

@ -123,3 +123,27 @@ export async function DELETE(request: NextRequest) {
{ requireAuth: true },
)
}
export async function PATCH(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = readObjectBody(await request.json().catch(() => null))
try {
const result = await barcodePrintService.updateImportRecords(
ctx.user,
body.ids,
body.fields,
)
return jsonSuccess(result, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

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

@ -0,0 +1,23 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service'
const barcodeSchema = z.string().trim().min(1).max(160)
export async function GET(
request: NextRequest,
context: { params: Promise<{ barcode: string }> },
) {
return handleRequest(request, async (ctx) => {
await supplyChainSupplierInformationService.requireViewAccess(ctx.user)
const params = await context.params
const parsed = barcodeSchema.safeParse(decodeURIComponent(params.barcode))
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '条码格式不正确', 400, ctx.requestId)
}
const history = await supplyChainSupplierInformationService.listHistory(parsed.data)
return jsonSuccess(history, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,19 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service'
const barcodeSchema = z.string().trim().min(1).max(160)
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
await supplyChainSupplierInformationService.requireViewAccess(ctx.user)
const parsed = barcodeSchema.safeParse(request.nextUrl.searchParams.get('barcode') ?? '')
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '条码格式不正确', 400, ctx.requestId)
}
const history = await supplyChainSupplierInformationService.listHistory(parsed.data)
return jsonSuccess(history, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,106 @@
import { ReadableStream as NodeReadableStream } from 'node:stream/web'
export {}
const mockHandleRequest = jest.fn()
const mockJsonError = jest.fn()
const mockRequireEditAccess = jest.fn()
const mockImportRows = jest.fn()
const mockSheetToJson = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonError: (...args: unknown[]) => mockJsonError(...args),
}))
jest.mock('@/server/services/supply-chain-supplier-information.service', () => ({
supplyChainSupplierInformationService: {
requireEditAccess: (...args: unknown[]) => mockRequireEditAccess(...args),
importRows: (...args: unknown[]) => mockImportRows(...args),
},
}))
jest.mock('xlsx', () => ({
read: () => ({ SheetNames: ['供应商信息导入'], Sheets: { 供应商信息导入: {} } }),
utils: { sheet_to_json: (...args: unknown[]) => mockSheetToJson(...args) },
}))
class MockFile {
size = 32
async arrayBuffer() { return new ArrayBuffer(32) }
}
const { POST } = require('./route')
describe('POST /api/supply-chain/supplier-information/import', () => {
const originalFile = global.File
const originalReadableStream = global.ReadableStream
beforeAll(() => {
Object.defineProperty(global, 'File', { configurable: true, value: MockFile })
Object.defineProperty(global, 'ReadableStream', { configurable: true, value: NodeReadableStream })
})
afterAll(() => {
Object.defineProperty(global, 'File', { configurable: true, value: originalFile })
Object.defineProperty(global, 'ReadableStream', { configurable: true, value: originalReadableStream })
})
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: (ctx: unknown) => unknown) => handler({
requestId: 'req-supplier-import',
user: { id: 'buyer-1', name: '采购员', role: 'USER' },
}))
mockSheetToJson.mockReturnValue([
['条码', '采购', '供应商', '稳定生产周期'],
['BC-001', '采购A', '供应商A', '30天'],
['UNKNOWN', '', '供应商B', ''],
])
mockImportRows.mockImplementation(async (_rows, _user, onProgress) => {
const first = { processed: 1, total: 2, updated: 1, failed: 0, errors: [] }
const complete = {
processed: 2,
total: 2,
updated: 1,
failed: 1,
errors: [{ rowNumber: 3, barcode: 'UNKNOWN', message: '条码不存在于产品档案' }],
}
onProgress(first)
onProgress(complete)
return complete
})
})
it('maps template columns and streams success, failure and row errors', async () => {
const response = await POST({
formData: async () => ({ get: () => new MockFile() }),
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
let body = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
body += decoder.decode(value, { stream: true })
}
body += decoder.decode()
const events = body.trim().split('\n').map((line: string) => JSON.parse(line))
expect(mockRequireEditAccess).toHaveBeenCalledWith(expect.objectContaining({ id: 'buyer-1' }))
expect(mockImportRows).toHaveBeenCalledWith([
{
rowNumber: 2,
barcode: 'BC-001',
updates: { purchaseOwner: '采购A', supplierName: '供应商A', stableLeadTime: '30天' },
},
{ rowNumber: 3, barcode: 'UNKNOWN', updates: { supplierName: '供应商B' } },
], expect.objectContaining({ id: 'buyer-1' }), expect.any(Function))
expect(events.at(-1)).toEqual({
type: 'complete',
progress: expect.objectContaining({ updated: 1, failed: 1 }),
})
expect(events.at(-1).progress.errors[0]).toEqual(expect.objectContaining({ rowNumber: 3 }))
expect(response.headers.get('Content-Type')).toContain('application/x-ndjson')
})
})

View File

@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import {
SUPPLY_CHAIN_SUPPLIER_INFORMATION_IMPORT_FIELDS,
type SupplyChainSupplierInformationEditableField,
type SupplyChainSupplierInformationImportProgress,
type SupplyChainSupplierInformationImportRow,
type SupplyChainSupplierInformationImportStreamEvent,
} from '@/lib/supply-chain-supplier-information'
import { ErrorCode, isAppError } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service'
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_ROWS = 5_000
function cellText(value: unknown) {
return String(value ?? '').trim()
}
function parseRows(sheet: XLSX.WorkSheet) {
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
defval: '',
raw: false,
})
const headerRow = matrix[0]?.map(cellText) ?? []
const headerIndex = new Map(headerRow.map((header, index) => [header, index]))
const barcodeIndex = headerIndex.get('条码') ?? headerIndex.get('barcode')
if (barcodeIndex === undefined) return { error: '导入文件缺少“条码”列', rows: [] }
const rows = matrix.slice(1).flatMap((values, index) => {
if (!values.some((value) => cellText(value))) return []
const updates = Object.fromEntries(
SUPPLY_CHAIN_SUPPLIER_INFORMATION_IMPORT_FIELDS.flatMap((field) => {
if (field.key === 'barcode') return []
const columnIndex = headerIndex.get(field.label) ?? headerIndex.get(field.key)
if (columnIndex === undefined) return []
const value = cellText(values[columnIndex])
return value ? [[field.key, value]] : []
}),
) as Partial<Record<SupplyChainSupplierInformationEditableField, string>>
return [{
rowNumber: index + 2,
barcode: cellText(values[barcodeIndex]),
updates,
} satisfies SupplyChainSupplierInformationImportRow]
})
return { rows }
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
await supplyChainSupplierInformationService.requireEditAccess(ctx.user)
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 > MAX_FILE_SIZE) {
return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB', 400, ctx.requestId)
}
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer' })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
if (!sheet) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
}
const parsed = parseRows(sheet)
if (parsed.error) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, parsed.error, 400, ctx.requestId)
}
if (parsed.rows.length === 0) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
}
if (parsed.rows.length > MAX_ROWS) {
return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行', 400, ctx.requestId)
}
const encoder = new TextEncoder()
const initial: SupplyChainSupplierInformationImportProgress = {
processed: 0,
total: parsed.rows.length,
updated: 0,
failed: 0,
errors: [],
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false
let progress = initial
const send = (event: SupplyChainSupplierInformationImportStreamEvent) => {
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 supplyChainSupplierInformationService.importRows(parsed.rows, ctx.user, (next) => {
progress = next
send({ type: 'progress', progress })
}).then((result) => {
progress = result
send({ type: 'complete', progress })
close()
}).catch((error: unknown) => {
console.error(`[${ctx.requestId}] Supplier information 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

@ -3,7 +3,8 @@ export {}
const mockHandleRequest = jest.fn()
const mockJsonSuccess = jest.fn()
const mockJsonError = jest.fn()
const mockUpdateSupplier = jest.fn()
const mockRequireEditAccess = jest.fn()
const mockUpdateBarcode = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
@ -13,7 +14,8 @@ jest.mock('@/server/core/response', () => ({
jest.mock('@/server/services/supply-chain-supplier-information.service', () => ({
supplyChainSupplierInformationService: {
updateSupplier: (...args: unknown[]) => mockUpdateSupplier(...args),
requireEditAccess: (...args: unknown[]) => mockRequireEditAccess(...args),
updateBarcode: (...args: unknown[]) => mockUpdateBarcode(...args),
},
}))
@ -30,10 +32,10 @@ describe('PUT /api/supply-chain/supplier-information', () => {
})
it('passes validated supplier updates and the authenticated actor to the service', async () => {
mockUpdateSupplier.mockResolvedValue({ affectedBarcodes: 2, changedValues: 2 })
mockUpdateBarcode.mockResolvedValue({ barcode: 'BC-001', changedValues: 2 })
const request = {
json: jest.fn().mockResolvedValue({
supplierKey: '浩远',
barcode: 'BC-001',
updates: {
purchaseOwner: '采购B',
stableLeadTime: '30天',
@ -45,8 +47,9 @@ describe('PUT /api/supply-chain/supplier-information', () => {
const response = await PUT(request)
expect(mockHandleRequest.mock.calls[0][2]).toEqual({ requireAuth: true })
expect(mockUpdateSupplier).toHaveBeenCalledWith({
supplierKey: '浩远',
expect(mockRequireEditAccess).toHaveBeenCalledWith(expect.objectContaining({ id: 'buyer-1' }))
expect(mockUpdateBarcode).toHaveBeenCalledWith({
barcode: 'BC-001',
updates: {
purchaseOwner: '采购B',
stableLeadTime: '30天',
@ -59,12 +62,12 @@ describe('PUT /api/supply-chain/supplier-information', () => {
it('rejects an edit that has no concrete field value', async () => {
const request = {
json: jest.fn().mockResolvedValue({ supplierKey: '浩远', updates: {} }),
json: jest.fn().mockResolvedValue({ barcode: 'BC-001', updates: {} }),
}
const response = await PUT(request)
expect(mockUpdateSupplier).not.toHaveBeenCalled()
expect(mockUpdateBarcode).not.toHaveBeenCalled()
expect(response).toEqual(expect.objectContaining({
message: '供应商信息修改内容不正确',
status: 400,

View File

@ -5,16 +5,16 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service'
const updateSchema = z.object({
supplierKey: z.string().trim().min(1).max(180),
barcode: z.string().trim().min(1).max(160),
updates: z.object({
purchaseOwner: z.string().trim().min(1).max(160).optional(),
supplierName: z.string().trim().min(1).max(180).optional(),
minimumOrderQuantity: z.string().trim().min(1).max(120).optional(),
firstLeadTime: z.string().trim().min(1).max(120).optional(),
stableLeadTime: z.string().trim().min(1).max(120).optional(),
palletSpec: z.string().trim().min(1).max(240).optional(),
backupSupplier: z.string().trim().min(1).max(180).optional(),
supplierType: z.string().trim().min(1).max(160).optional(),
purchaseOwner: z.string().trim().max(160).optional(),
supplierName: z.string().trim().max(180).optional(),
minimumOrderQuantity: z.string().trim().max(120).optional(),
firstLeadTime: z.string().trim().max(120).optional(),
stableLeadTime: z.string().trim().max(120).optional(),
palletSpec: z.string().trim().max(240).optional(),
backupSupplier: z.string().trim().max(180).optional(),
supplierType: z.string().trim().max(160).optional(),
}).strict(),
}).strict().refine((input) => Object.keys(input.updates).length > 0, {
message: '请至少填写一项要修改的内容',
@ -34,7 +34,8 @@ export async function PUT(request: NextRequest) {
)
}
const result = await supplyChainSupplierInformationService.updateSupplier({
await supplyChainSupplierInformationService.requireEditAccess(ctx.user)
const result = await supplyChainSupplierInformationService.updateBarcode({
...parsed.data,
user: ctx.user,
})

View File

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { SUPPLY_CHAIN_SUPPLIER_INFORMATION_IMPORT_FIELDS } from '@/lib/supply-chain-supplier-information'
import { handleRequest } from '@/server/core/response'
import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
await supplyChainSupplierInformationService.requireViewAccess(ctx.user)
const headers = SUPPLY_CHAIN_SUPPLIER_INFORMATION_IMPORT_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''${encodeURIComponent('供应商信息导入模板.xlsx')}`,
'Cache-Control': 'no-store',
},
})
}, { requireAuth: true })
}

View File

@ -4,6 +4,7 @@ import type { InstallationToolItem } from '../types'
import type {
InstallationToolMotionType,
InstallationToolMotionVector3,
InstallationHingeMotionConfig,
} from '@/lib/installation-demo-tool-motion'
interface ToolMutationInput {
@ -18,6 +19,7 @@ interface ToolMutationInput {
motionAxis?: InstallationToolMotionVector3 | null
spinTurns?: number
strikeDistanceRatio?: number
articulationConfig?: InstallationHingeMotionConfig | null
}
async function readResponse<T>(response: Response): Promise<T> {

View File

@ -4,7 +4,9 @@ import Link from 'next/link'
import { ArrowLeft, Plus, Wrench } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import {
createDefaultInstallationHingeMotionConfig,
normalizeInstallationToolMotionConfig,
type InstallationHingeJointKey,
type InstallationToolMotionConfig,
} from '@/lib/installation-demo-tool-motion'
import { useInstallationToolbox } from '../hooks/useInstallationToolbox'
@ -28,6 +30,7 @@ function buildToolMotionConfig(tool: ReturnType<typeof useInstallationToolbox>['
motionAxis: tool.motionAxis,
spinTurns: tool.spinTurns,
strikeDistanceRatio: tool.strikeDistanceRatio,
articulationConfig: tool.articulationConfig,
})
}
@ -41,6 +44,9 @@ export default function InstallationToolboxClient({ canEdit }: InstallationToolb
const [submitting, setSubmitting] = useState(false)
const [saving, setSaving] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [modelNodeNames, setModelNodeNames] = useState<string[]>([])
const [activeHingeJoint, setActiveHingeJoint] = useState<InstallationHingeJointKey>('primary')
const [hingeProgress, setHingeProgress] = useState(0)
const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate'>('translate')
const [message, setMessage] = useState<string | null>(null)
const [motionConfig, setMotionConfig] = useState<InstallationToolMotionConfig>(() =>
@ -62,8 +68,36 @@ export default function InstallationToolboxClient({ canEdit }: InstallationToolb
if (!selectedTool) return
setMotionConfig(buildToolMotionConfig(selectedTool))
setPreviewing(false)
setModelNodeNames([])
setActiveHingeJoint('primary')
setHingeProgress(0)
setMessage(null)
}, [selectedTool?.id])
}, [selectedTool])
useEffect(() => {
if (
motionConfig.motionType !== 'hinge' ||
motionConfig.articulationConfig ||
modelNodeNames.length < 3
) return
setMotionConfig((current) => ({
...current,
articulationConfig: createDefaultInstallationHingeMotionConfig(modelNodeNames),
}))
}, [modelNodeNames, motionConfig.articulationConfig, motionConfig.motionType])
useEffect(() => {
if (!previewing || motionConfig.motionType !== 'hinge') return
const startedAt = performance.now()
let animationFrame = 0
const animate = () => {
const phase = ((performance.now() - startedAt) % 2400) / 2400
setHingeProgress(0.5 - 0.5 * Math.cos(Math.PI * 2 * phase))
animationFrame = window.requestAnimationFrame(animate)
}
animate()
return () => window.cancelAnimationFrame(animationFrame)
}, [motionConfig.motionType, previewing])
const submit = async () => {
if (!canEdit || !file || !toolCode.trim() || !name.trim()) return
@ -193,8 +227,11 @@ export default function InstallationToolboxClient({ canEdit }: InstallationToolb
canEdit={canEdit}
previewing={previewing}
gizmoMode={gizmoMode}
activeHingeJoint={activeHingeJoint}
hingeProgress={hingeProgress}
onGizmoModeChange={setGizmoMode}
onConfigChange={setMotionConfig}
onNodeNamesChange={setModelNodeNames}
/>
</section>
@ -221,14 +258,24 @@ export default function InstallationToolboxClient({ canEdit }: InstallationToolb
saving={saving}
previewing={previewing}
message={effectiveMessage}
nodeNames={modelNodeNames}
activeHingeJoint={activeHingeJoint}
hingeProgress={hingeProgress}
onConfigChange={(nextConfig) => {
setPreviewing(false)
setMotionConfig(nextConfig)
setMessage(null)
}}
onSave={() => void saveMotion()}
onTogglePreview={() => setPreviewing((current) => !current)}
onTogglePreview={() => {
setPreviewing((current) => {
if (current) setHingeProgress(0)
return !current
})
}}
onDelete={() => void remove()}
onActiveHingeJointChange={setActiveHingeJoint}
onHingeProgressChange={setHingeProgress}
/>
) : (
<aside className="flex items-center justify-center rounded-[8px] border border-black/[0.07] bg-white p-8 text-center text-[12px] font-semibold text-[#8b9092]">

View File

@ -0,0 +1,151 @@
'use client'
import type {
InstallationHingeJointKey,
InstallationHingeMotionConfig,
} from '@/lib/installation-demo-tool-motion'
interface InstallationHingeMotionInspectorProps {
config: InstallationHingeMotionConfig
nodeNames: string[]
activeJoint: InstallationHingeJointKey
progress: number
canEdit: boolean
saving: boolean
previewing: boolean
onConfigChange: (config: InstallationHingeMotionConfig) => void
onActiveJointChange: (joint: InstallationHingeJointKey) => void
onProgressChange: (progress: number) => void
}
const ROLE_FIELDS = [
['baseNodeName', '固定座'],
['linkNodeName', '主连杆'],
['leafNodeName', '门侧件'],
] as const
export function InstallationHingeMotionInspector({
config,
nodeNames,
activeJoint,
progress,
canEdit,
saving,
previewing,
onConfigChange,
onActiveJointChange,
onProgressChange,
}: InstallationHingeMotionInspectorProps) {
const jointKey = activeJoint === 'primary' ? 'primaryJoint' : 'secondaryJoint'
const joint = config[jointKey]
const updateJoint = (patch: Partial<typeof joint>) =>
onConfigChange({ ...config, [jointKey]: { ...joint, ...patch } })
return (
<>
<div className="grid gap-2 border-t border-black/[0.06] pt-4">
<span className="text-[11px] font-bold text-[#5a6062]">节点角色</span>
<div className="grid gap-2">
{ROLE_FIELDS.map(([field, label]) => (
<label key={field} className="grid grid-cols-[64px_minmax(0,1fr)] items-center gap-2">
<span className="text-[10px] font-bold text-[#777d7f]">{label}</span>
<select
value={config[field] ?? ''}
disabled={!canEdit || saving || previewing}
onChange={(event) => onConfigChange({ ...config, [field]: event.target.value || null })}
className="h-8 min-w-0 rounded-[6px] border border-black/[0.08] bg-white px-2 text-[11px] font-semibold outline-none disabled:bg-black/[0.03]"
>
<option value="">未指定</option>
{nodeNames.map((nodeName) => <option key={nodeName} value={nodeName}>{nodeName}</option>)}
</select>
</label>
))}
</div>
</div>
<div className="grid gap-2 border-t border-black/[0.06] pt-4">
<span className="text-[11px] font-bold text-[#5a6062]">当前校准轴</span>
<div className="grid grid-cols-2 gap-1.5">
{([['primary', '主轴'], ['secondary', '门轴']] as const).map(([key, label]) => (
<button
key={key}
type="button"
disabled={previewing}
onClick={() => onActiveJointChange(key)}
className={[
'h-8 rounded-[6px] border text-[11px] font-black transition disabled:opacity-40',
activeJoint === key
? 'border-[#2d3335] bg-[#2d3335] text-white'
: 'border-black/[0.08] text-[#5a6062] hover:bg-[#f5f7f8]',
].join(' ')}
>
{label}
</button>
))}
</div>
</div>
<div className="grid gap-3">
<label className="grid gap-2">
<span className="flex items-center justify-between text-[11px] font-bold text-[#5a6062]">
<span>中间态角度</span><span>{joint.middleAngleDegrees.toFixed(0)}°</span>
</span>
<input
type="range"
min="-180"
max="180"
step="1"
value={joint.middleAngleDegrees}
disabled={!canEdit || saving || previewing}
onChange={(event) => updateJoint({ middleAngleDegrees: Number(event.target.value) })}
className="accent-[#2d3335]"
/>
</label>
<label className="grid gap-2">
<span className="flex items-center justify-between text-[11px] font-bold text-[#5a6062]">
<span>闭合态角度</span><span>{joint.closedAngleDegrees.toFixed(0)}°</span>
</span>
<input
type="range"
min="-180"
max="180"
step="1"
value={joint.closedAngleDegrees}
disabled={!canEdit || saving || previewing}
onChange={(event) => updateJoint({ closedAngleDegrees: Number(event.target.value) })}
className="accent-[#2d3335]"
/>
</label>
</div>
<div className="grid gap-2 border-t border-black/[0.06] pt-4">
<span className="flex items-center justify-between text-[11px] font-bold text-[#5a6062]">
<span>开合位置</span><span>{Math.round(progress * 100)}%</span>
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={progress}
disabled={previewing}
onChange={(event) => onProgressChange(Number(event.target.value))}
className="accent-[#2d3335]"
/>
<div className="grid grid-cols-3 gap-1.5">
{([['打开', 0], ['中间', 0.5], ['闭合', 1]] as const).map(([label, value]) => (
<button
key={label}
type="button"
disabled={previewing}
onClick={() => onProgressChange(value)}
className="h-7 rounded-[6px] border border-black/[0.08] text-[10px] font-black text-[#5a6062] hover:bg-[#f5f7f8] disabled:opacity-40"
>
{label}
</button>
))}
</div>
</div>
</>
)
}

View File

@ -1,8 +1,13 @@
'use client'
import { FlipVertical2, Hammer, Play, RotateCw, Save, Square, Trash2 } from 'lucide-react'
import type { InstallationToolMotionConfig, InstallationToolMotionType } from '@/lib/installation-demo-tool-motion'
import { FlipVertical2, GitFork, Hammer, Play, RotateCw, Save, Square, Trash2 } from 'lucide-react'
import type {
InstallationHingeJointKey,
InstallationToolMotionConfig,
InstallationToolMotionType,
} from '@/lib/installation-demo-tool-motion'
import type { InstallationToolItem } from '../../types'
import { InstallationHingeMotionInspector } from './InstallationHingeMotionInspector'
const MOTION_OPTIONS: Array<{
type: InstallationToolMotionType
@ -12,6 +17,7 @@ const MOTION_OPTIONS: Array<{
{ type: 'spin', label: '旋拧', icon: RotateCw },
{ type: 'strike', label: '敲击', icon: Hammer },
{ type: 'follow', label: '仅跟随', icon: Square },
{ type: 'hinge', label: '铰链开合', icon: GitFork },
]
interface InstallationToolMotionInspectorProps {
@ -22,10 +28,15 @@ interface InstallationToolMotionInspectorProps {
saving: boolean
previewing: boolean
message: string | null
nodeNames: string[]
activeHingeJoint: InstallationHingeJointKey
hingeProgress: number
onConfigChange: (config: InstallationToolMotionConfig) => void
onSave: () => void
onTogglePreview: () => void
onDelete: () => void
onActiveHingeJointChange: (joint: InstallationHingeJointKey) => void
onHingeProgressChange: (progress: number) => void
}
export function InstallationToolMotionInspector({
@ -36,10 +47,15 @@ export function InstallationToolMotionInspector({
saving,
previewing,
message,
nodeNames,
activeHingeJoint,
hingeProgress,
onConfigChange,
onSave,
onTogglePreview,
onDelete,
onActiveHingeJointChange,
onHingeProgressChange,
}: InstallationToolMotionInspectorProps) {
const update = <Key extends keyof InstallationToolMotionConfig>(
key: Key,
@ -55,7 +71,7 @@ export function InstallationToolMotionInspector({
<div className="grid gap-4 p-4">
<div className="grid gap-2">
<span className="text-[11px] font-bold text-[#5a6062]">动作类型</span>
<div className="grid grid-cols-3 gap-1.5">
<div className="grid grid-cols-2 gap-1.5">
{MOTION_OPTIONS.map(({ type, label, icon: Icon }) => (
<button
key={type}
@ -78,10 +94,27 @@ export function InstallationToolMotionInspector({
{config.motionType !== 'follow' ? (
<div className="rounded-[6px] bg-[#f5f7f8] px-3 py-2 text-[11px] font-semibold leading-5 text-[#5a6062]">
左侧工具栏选择“移动”拖动蓝色支点,选择“旋转”调整蓝色箭头方向。
{config.motionType === 'hinge'
? '先选择主轴或门轴,再用左侧“移动”拖动蓝色轴点,用“旋转”调整蓝色轴向。'
: '左侧工具栏选择“移动”拖动蓝色支点,选择“旋转”调整蓝色箭头方向。'}
</div>
) : null}
{config.motionType === 'hinge' && config.articulationConfig ? (
<InstallationHingeMotionInspector
config={config.articulationConfig}
nodeNames={nodeNames}
activeJoint={activeHingeJoint}
progress={hingeProgress}
canEdit={canEdit}
saving={saving}
previewing={previewing}
onConfigChange={(articulationConfig) => update('articulationConfig', articulationConfig)}
onActiveJointChange={onActiveHingeJointChange}
onProgressChange={onHingeProgressChange}
/>
) : null}
{config.motionType === 'spin' ? (
<label className="grid gap-2">
<span className="flex items-center justify-between text-[11px] font-bold text-[#5a6062]">
@ -118,7 +151,7 @@ export function InstallationToolMotionInspector({
</label>
) : null}
{config.motionType !== 'follow' ? (
{config.motionType !== 'follow' && config.motionType !== 'hinge' ? (
<button
type="button"
disabled={!canEdit || saving}
@ -135,7 +168,7 @@ export function InstallationToolMotionInspector({
<div className="grid grid-cols-2 gap-2">
<button
type="button"
disabled={config.motionType === 'follow'}
disabled={config.motionType === 'follow' || (config.motionType === 'hinge' && !config.articulationConfig)}
onClick={onTogglePreview}
className="flex h-9 items-center justify-center gap-2 rounded-[6px] border border-black/[0.08] text-[11px] font-black text-[#5a6062] hover:bg-[#f5f7f8] disabled:opacity-35"
>

View File

@ -1,12 +1,20 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Crosshair, Wrench } from 'lucide-react'
import * as THREE from 'three'
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
import type { ThreeDEngine } from '@/lib/3d/engine'
import type { InstallationToolMotionConfig } from '@/lib/installation-demo-tool-motion'
import type {
InstallationHingeJointKey,
InstallationToolMotionConfig,
} from '@/lib/installation-demo-tool-motion'
import type { InstallationToolItem } from '../../types'
import {
buildInstallationHingeNodeTransforms,
resolveModelLocalBounds,
type InstallationHingeOriginalTransform,
} from '../lib/installationToolHingeRuntime'
const CONTROL_NODE_NAME = '__installation_tool_motion_control__'
const UNIT_Y = new THREE.Vector3(0, 1, 0)
@ -97,8 +105,11 @@ interface InstallationToolMotionPreviewProps {
canEdit: boolean
previewing: boolean
gizmoMode: 'translate' | 'rotate'
activeHingeJoint: InstallationHingeJointKey
hingeProgress: number
onGizmoModeChange: (mode: 'translate' | 'rotate') => void
onConfigChange: (config: InstallationToolMotionConfig) => void
onNodeNamesChange: (nodeNames: string[]) => void
}
export function InstallationToolMotionPreview({
@ -107,14 +118,19 @@ export function InstallationToolMotionPreview({
canEdit,
previewing,
gizmoMode,
activeHingeJoint,
hingeProgress,
onGizmoModeChange,
onConfigChange,
onNodeNamesChange,
}: InstallationToolMotionPreviewProps) {
const [engineState, setEngineState] = useState<{
engine: ThreeDEngine
modelRoot: THREE.Object3D
bounds: THREE.Box3
localBounds: THREE.Box3
maxDimension: number
originals: Record<string, InstallationHingeOriginalTransform>
} | null>(null)
const controlRef = useRef<THREE.Group | null>(null)
const directionIndicatorRef = useRef<THREE.Group | null>(null)
@ -131,14 +147,25 @@ export function InstallationToolMotionPreview({
const bounds = new THREE.Box3().setFromObject(modelRoot)
if (bounds.isEmpty()) return
const size = bounds.getSize(new THREE.Vector3())
const localBounds = resolveModelLocalBounds(modelRoot)
const nodeNames = Array.from(new Set(engine.getAllMeshes().map(({ name }) => name).filter(Boolean)))
const originals = Object.fromEntries(
nodeNames.flatMap((nodeName) => {
const transform = engine.getNodeOriginalLocalTransform(nodeName)
return transform ? [[nodeName, transform]] : []
}),
)
onNodeNamesChange(nodeNames)
setEngineState({
engine,
modelRoot,
bounds,
localBounds,
maxDimension: Math.max(size.x, size.y, size.z),
originals,
})
},
[],
[onNodeNamesChange],
)
useEffect(() => {
@ -147,7 +174,8 @@ export function InstallationToolMotionPreview({
const { control, directionIndicator } = createMotionControl(state.maxDimension)
controlRef.current = control
directionIndicatorRef.current = directionIndicator
state.engine.getScene().add(control, directionIndicator)
const parent = config.motionType === 'hinge' ? state.modelRoot : state.engine.getScene()
parent.add(control, directionIndicator)
return () => {
disposeObjectTree(control)
@ -157,23 +185,33 @@ export function InstallationToolMotionPreview({
directionIndicatorRef.current = null
}
}
}, [engineState, tool])
}, [config.motionType, engineState, tool])
useEffect(() => {
const state = engineState
const control = controlRef.current
const directionIndicator = directionIndicatorRef.current
if (!state || !control || !directionIndicator) return
const pivot = config.motionPivot ?? [0.5, 0.5, 0.5]
control.position.copy(resolveBoundsPoint(state.bounds, pivot))
control.quaternion.setFromUnitVectors(UNIT_Y, resolveAxis(config, state.bounds))
const hingeJoint = config.motionType === 'hinge' && config.articulationConfig
? config.articulationConfig[activeHingeJoint === 'primary' ? 'primaryJoint' : 'secondaryJoint']
: null
const pivot = hingeJoint?.pivot ?? config.motionPivot ?? [0.5, 0.5, 0.5]
const bounds = hingeJoint ? state.localBounds : state.bounds
const axisConfig = hingeJoint
? { ...config, motionAxis: hingeJoint.axis }
: config
control.position.copy(resolveBoundsPoint(bounds, pivot))
control.quaternion.setFromUnitVectors(UNIT_Y, resolveAxis(axisConfig, bounds))
directionIndicator.position.copy(control.position)
directionIndicator.quaternion.copy(control.quaternion)
control.visible = config.motionType !== 'follow'
directionIndicator.visible = config.motionType !== 'follow'
const showControl = config.motionType !== 'follow' && !(
config.motionType === 'hinge' && previewing
)
control.visible = showControl
directionIndicator.visible = showControl
control.updateMatrixWorld(true)
directionIndicator.updateMatrixWorld(true)
}, [config, engineState])
}, [activeHingeJoint, config, engineState, previewing])
useEffect(() => {
const state = engineState
@ -181,7 +219,7 @@ export function InstallationToolMotionPreview({
const root = state.modelRoot
const basePosition = root.position.clone()
const baseRotation = root.quaternion.clone()
if (!previewing || config.motionType === 'follow') return
if (!previewing || config.motionType === 'follow' || config.motionType === 'hinge') return
const pivot = resolveBoundsPoint(state.bounds, config.motionPivot ?? [0.5, 0.5, 0.5])
const axis = resolveAxis(config, state.bounds)
@ -198,7 +236,7 @@ export function InstallationToolMotionPreview({
)
root.position.copy(pivot.clone().add(basePosition.clone().sub(pivot).applyQuaternion(spin)))
root.quaternion.copy(spin.multiply(baseRotation))
} else {
} else if (config.motionType === 'strike') {
const pulse = 0.5 - 0.5 * Math.cos(Math.PI * 4 * phase)
root.position.addScaledVector(
axis,
@ -218,6 +256,20 @@ export function InstallationToolMotionPreview({
}
}, [config, engineState, previewing])
const hingeNodeTransforms = useMemo(() => {
if (
!engineState ||
config.motionType !== 'hinge' ||
!config.articulationConfig
) return []
return buildInstallationHingeNodeTransforms({
config: config.articulationConfig,
progress: hingeProgress,
bounds: engineState.localBounds,
originals: engineState.originals,
})
}, [config.articulationConfig, config.motionType, engineState, hingeProgress])
const handleControlTransform = useCallback(
({ nodeName, position, rotation }: {
nodeName: string
@ -229,6 +281,29 @@ export function InstallationToolMotionPreview({
directionIndicator?.position.fromArray(position)
directionIndicator?.quaternion.fromArray(rotation)
directionIndicator?.updateMatrixWorld(true)
if (configRef.current.motionType === 'hinge' && configRef.current.articulationConfig) {
const current = configRef.current
const articulationConfig = current.articulationConfig!
const jointKey = activeHingeJoint === 'primary' ? 'primaryJoint' : 'secondaryJoint'
const joint = articulationConfig[jointKey]
const nextJoint = gizmoMode === 'translate'
? {
...joint,
pivot: normalizeBoundsPoint(engineState.localBounds, new THREE.Vector3(...position)),
}
: {
...joint,
axis: (() => {
const axis = UNIT_Y.clone().applyQuaternion(new THREE.Quaternion(...rotation)).normalize()
return [axis.x, axis.y, axis.z] as [number, number, number]
})(),
}
onConfigChange({
...current,
articulationConfig: { ...articulationConfig, [jointKey]: nextJoint },
})
return
}
if (gizmoMode === 'translate') {
onConfigChange({
...configRef.current,
@ -242,9 +317,16 @@ export function InstallationToolMotionPreview({
motionAxis: [axis.x, axis.y, axis.z],
})
},
[engineState, gizmoMode, onConfigChange, previewing],
[activeHingeJoint, engineState, gizmoMode, onConfigChange, previewing],
)
const highlightedNodeNames = config.motionType === 'hinge' && config.articulationConfig
? [activeHingeJoint === 'primary'
? config.articulationConfig.linkNodeName
: config.articulationConfig.leafNodeName]
.filter((nodeName): nodeName is string => Boolean(nodeName))
: []
return (
<ModelPreviewPanel
title={tool ? `${tool.name} · ${tool.toolCode}` : '工具动作预览'}
@ -255,6 +337,10 @@ export function InstallationToolMotionPreview({
emptyDescription="从左侧工具目录选择一个模型"
emptyIcon={<Wrench size={30} strokeWidth={1.6} />}
showSceneGrid={false}
nodeLocalTransformStates={hingeNodeTransforms}
preferLocalTransformStates={config.motionType === 'hinge'}
retainLocalTransformsOnEmpty={config.motionType === 'hinge'}
highlightedNodeNames={highlightedNodeNames}
showCameraActionButtons
enableTranslateGizmo={canEdit && !previewing && config.motionType !== 'follow'}
gizmoMode={gizmoMode}
@ -266,7 +352,13 @@ export function InstallationToolMotionPreview({
overlayTopRight={tool ? (
<div className="flex items-center gap-2 rounded-[6px] bg-white/82 px-3 py-2 text-[10px] font-bold text-[#5a6062] shadow-sm ring-1 ring-black/[0.06] backdrop-blur-xl">
<Crosshair size={13} />
{config.motionType === 'spin' ? '蓝点为旋转支点' : config.motionType === 'strike' ? '蓝点为锤头作用点' : '仅跟随'}
{config.motionType === 'spin'
? '蓝点为旋转支点'
: config.motionType === 'strike'
? '蓝点为锤头作用点'
: config.motionType === 'hinge'
? `蓝点为${activeHingeJoint === 'primary' ? '主轴' : '门轴'}轴心`
: '仅跟随'}
</div>
) : null}
/>

View File

@ -0,0 +1,78 @@
import * as THREE from 'three'
import { createDefaultInstallationHingeMotionConfig } from '@/lib/installation-demo-tool-motion'
import { buildInstallationHingeNodeTransforms } from './installationToolHingeRuntime'
describe('installation tool hinge runtime', () => {
const config = createDefaultInstallationHingeMotionConfig([
'asset_000',
'asset_001',
'asset_002',
])
const bounds = new THREE.Box3(
new THREE.Vector3(-1, -1, -1),
new THREE.Vector3(1, 1, 1),
)
const originals = {
asset_001: { position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0, 1] as [number, number, number, number] },
asset_002: { position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0, 1] as [number, number, number, number] },
}
it('keeps both moving nodes at their imported pose while fully open', () => {
const transforms = buildInstallationHingeNodeTransforms({ config, progress: 0, bounds, originals })
expect(transforms).toHaveLength(2)
transforms.forEach((transform) => {
expect(transform.position.every((value) => Math.abs(value) < 1e-8)).toBe(true)
expect(transform.rotation).toEqual([0, 0, 0, 1])
})
})
it('applies the primary joint to both descendants and the secondary joint only to the leaf', () => {
const transforms = buildInstallationHingeNodeTransforms({ config, progress: 1, bounds, originals })
const link = transforms.find((transform) => transform.nodeName === 'asset_002')!
const leaf = transforms.find((transform) => transform.nodeName === 'asset_001')!
expect(link.rotation).not.toEqual([0, 0, 0, 1])
expect(leaf.rotation).not.toEqual(link.rotation)
const leafUp = new THREE.Vector3(0, 1, 0).applyQuaternion(
new THREE.Quaternion(...leaf.rotation),
)
expect(leafUp.y).toBeCloseTo(0)
expect(leafUp.z).toBeCloseTo(-1)
})
it('moves the leaf joint with the link before applying the leaf rotation', () => {
const articulatedConfig = {
...config,
primaryJoint: {
...config.primaryJoint,
pivot: [0.5, 0.5, 0.5] as [number, number, number],
middleAngleDegrees: 45,
closedAngleDegrees: 90,
},
secondaryJoint: {
...config.secondaryJoint,
pivot: [0.5, 1, 0.5] as [number, number, number],
middleAngleDegrees: -45,
closedAngleDegrees: -90,
},
}
const transforms = buildInstallationHingeNodeTransforms({
config: articulatedConfig,
progress: 1,
bounds,
originals: {
...originals,
asset_001: {
position: [0, 1, 0],
rotation: [0, 0, 0, 1],
},
},
})
const leaf = transforms.find((transform) => transform.nodeName === 'asset_001')!
expect(leaf.position[0]).toBeCloseTo(0)
expect(leaf.position[1]).toBeCloseTo(0)
expect(leaf.position[2]).toBeCloseTo(1)
})
})

View File

@ -0,0 +1,144 @@
import * as THREE from 'three'
import type { NodeLocalTransformState } from '@/lib/3d/engine'
import {
resolveInstallationHingeJointAngles,
type InstallationHingeMotionConfig,
type InstallationToolMotionVector3,
} from '@/lib/installation-demo-tool-motion'
export interface InstallationHingeOriginalTransform {
position: InstallationToolMotionVector3
rotation: [number, number, number, number]
}
export function resolveBoundsPoint(
bounds: THREE.Box3,
normalizedPoint: InstallationToolMotionVector3,
) {
const size = bounds.getSize(new THREE.Vector3())
return new THREE.Vector3(
bounds.min.x + size.x * normalizedPoint[0],
bounds.min.y + size.y * normalizedPoint[1],
bounds.min.z + size.z * normalizedPoint[2],
)
}
export function normalizeBoundsPoint(bounds: THREE.Box3, point: THREE.Vector3) {
const size = bounds.getSize(new THREE.Vector3())
return [
size.x > 1e-8 ? THREE.MathUtils.clamp((point.x - bounds.min.x) / size.x, 0, 1) : 0.5,
size.y > 1e-8 ? THREE.MathUtils.clamp((point.y - bounds.min.y) / size.y, 0, 1) : 0.5,
size.z > 1e-8 ? THREE.MathUtils.clamp((point.z - bounds.min.z) / size.z, 0, 1) : 0.5,
] as InstallationToolMotionVector3
}
export function resolveModelLocalBounds(modelRoot: THREE.Object3D) {
modelRoot.updateMatrixWorld(true)
const inverseRootWorld = modelRoot.matrixWorld.clone().invert()
const bounds = new THREE.Box3().makeEmpty()
modelRoot.traverse((child) => {
const mesh = child as THREE.Mesh
if (!mesh.isMesh || !mesh.geometry) return
mesh.geometry.computeBoundingBox()
const geometryBounds = mesh.geometry.boundingBox
if (!geometryBounds) return
const meshToRoot = inverseRootWorld.clone().multiply(mesh.matrixWorld)
bounds.union(geometryBounds.clone().applyMatrix4(meshToRoot))
})
return bounds
}
function resolveAxis(axis: InstallationToolMotionVector3 | null) {
const result = axis ? new THREE.Vector3(...axis) : new THREE.Vector3(1, 0, 0)
return result.lengthSq() > 1e-8 ? result.normalize() : new THREE.Vector3(1, 0, 0)
}
function buildRotationAroundPivot(
pivot: THREE.Vector3,
axis: InstallationToolMotionVector3 | null,
angleDegrees: number,
) {
const rotation = new THREE.Matrix4().makeRotationAxis(
resolveAxis(axis),
THREE.MathUtils.degToRad(angleDegrees),
)
return new THREE.Matrix4()
.makeTranslation(pivot.x, pivot.y, pivot.z)
.multiply(rotation)
.multiply(new THREE.Matrix4().makeTranslation(-pivot.x, -pivot.y, -pivot.z))
}
function buildRotationAroundMovingPivot(
pivot: THREE.Vector3,
parentMotion: THREE.Matrix4,
axis: InstallationToolMotionVector3 | null,
angleDegrees: number,
) {
const movingPivot = pivot.clone().applyMatrix4(parentMotion)
const movingAxis = resolveAxis(axis).transformDirection(parentMotion)
return buildRotationAroundPivot(
movingPivot,
[movingAxis.x, movingAxis.y, movingAxis.z],
angleDegrees,
)
}
function buildOriginalMatrix(transform: InstallationHingeOriginalTransform) {
return new THREE.Matrix4().compose(
new THREE.Vector3(...transform.position),
new THREE.Quaternion(...transform.rotation),
new THREE.Vector3(1, 1, 1),
)
}
function decomposeNodeTransform(nodeName: string, matrix: THREE.Matrix4): NodeLocalTransformState {
const position = new THREE.Vector3()
const rotation = new THREE.Quaternion()
matrix.decompose(position, rotation, new THREE.Vector3())
return {
nodeName,
position: [position.x, position.y, position.z],
rotation: [rotation.x, rotation.y, rotation.z, rotation.w],
}
}
export function buildInstallationHingeNodeTransforms(params: {
config: InstallationHingeMotionConfig
progress: number
bounds: THREE.Box3
originals: Record<string, InstallationHingeOriginalTransform>
}): NodeLocalTransformState[] {
const { config, progress, bounds, originals } = params
const linkNodeName = config.linkNodeName
const leafNodeName = config.leafNodeName
if (!linkNodeName || !leafNodeName) return []
const linkOriginal = originals[linkNodeName]
const leafOriginal = originals[leafNodeName]
if (!linkOriginal || !leafOriginal) return []
const angles = resolveInstallationHingeJointAngles(config, progress)
const primaryMotion = buildRotationAroundPivot(
resolveBoundsPoint(bounds, config.primaryJoint.pivot ?? [0.5, 0.5, 0.5]),
config.primaryJoint.axis,
angles.primaryAngleDegrees,
)
const leafMotion = buildRotationAroundMovingPivot(
resolveBoundsPoint(bounds, config.secondaryJoint.pivot ?? [0.5, 0.5, 0.5]),
primaryMotion,
config.secondaryJoint.axis,
angles.secondaryAngleDegrees,
)
return [
decomposeNodeTransform(
linkNodeName,
primaryMotion.clone().multiply(buildOriginalMatrix(linkOriginal)),
),
decomposeNodeTransform(
leafNodeName,
leafMotion.clone().multiply(primaryMotion).multiply(buildOriginalMatrix(leafOriginal)),
),
]
}

View File

@ -6,6 +6,7 @@ import type {
import type {
InstallationToolMotionType,
InstallationToolMotionVector3,
InstallationHingeMotionConfig,
} from '@/lib/installation-demo-tool-motion'
export type InstallationSpuNode = InstallationDemoSpuDTO
@ -103,6 +104,7 @@ export interface InstallationToolItem {
motionAxis: InstallationToolMotionVector3 | null
spinTurns: number
strikeDistanceRatio: number
articulationConfig: InstallationHingeMotionConfig | null
createdAt: string
updatedAt: string
}

View File

@ -0,0 +1,107 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { BarcodePrintRow } from "./barcodePrintRows";
import { BarcodePrintBulkEditDialog } from "./BarcodePrintBulkEditDialog";
function buildRow(overrides: Partial<BarcodePrintRow> = {}): BarcodePrintRow {
return {
id: "barcode-print-import:record-1",
importRecordIds: ["record-1"],
spu: "SPU-001",
model: "MODEL-001",
specCode: "",
setCount: "",
skuCode: "SKU-001",
brandSkuCode: "",
productName: "",
materialNo: "",
specification: "",
certificateMaterial: "",
barcode: "BARCODE-001",
factoryModel: "",
attributeCode: "",
drawingId: "",
barcodeSpecName: "",
productCategory: "",
salesCategory: "",
materialType: "",
size: "",
color: "",
material: "",
executeStandard: "",
tagMaterial: "",
supplierCode: "",
supplierName: "供应商A",
warehouse: "",
installationService: "",
lengthCm: "",
widthCm: "",
heightCm: "",
volumeCm3: "",
weightKg: "",
costPrice: "",
productPlanning: "",
visualPlanning: "",
spuOwner: "",
operationsOwner: "",
purchaseOwner: "",
productionType: "",
shippingMode: "",
supplierCategory: "",
purchaseOrderMethod: "",
assemblyDetail: "",
createdAt: "2026-08-28",
dataSource: "导入",
...overrides,
};
}
describe("BarcodePrintBulkEditDialog", () => {
it("requires a selected field and submits the selected value", () => {
const onSubmit = jest.fn();
render(
<BarcodePrintBulkEditDialog
isOpen
rows={[buildRow()]}
isSaving={false}
onClose={jest.fn()}
onSubmit={onSubmit}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(screen.getByText("请选择要修改的字段。")).toBeInTheDocument();
fireEvent.click(screen.getByRole("checkbox", { name: "修改供应商名称" }));
fireEvent.change(screen.getByRole("textbox", { name: "供应商名称值" }), {
target: { value: "供应商B" },
});
fireEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(onSubmit).toHaveBeenCalledWith({
fields: { supplierName: "供应商B" },
});
});
it("allows a blank value to clear a selected field", () => {
const onSubmit = jest.fn();
render(
<BarcodePrintBulkEditDialog
isOpen
rows={[buildRow()]}
isSaving={false}
onClose={jest.fn()}
onSubmit={onSubmit}
/>,
);
fireEvent.click(screen.getByRole("checkbox", { name: "修改供应商名称" }));
fireEvent.change(screen.getByRole("textbox", { name: "供应商名称值" }), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(onSubmit).toHaveBeenCalledWith({
fields: { supplierName: "" },
});
});
});

View File

@ -0,0 +1,177 @@
"use client";
import { Save, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
BARCODE_PRINT_IMPORT_EDIT_FIELDS,
type BarcodePrintImportEditableField,
type BarcodePrintImportEditFields,
} from "@/lib/barcode-print-import-edit";
import type { BarcodePrintRow } from "./barcodePrintRows";
export type BarcodePrintBulkEditSubmit = {
fields: BarcodePrintImportEditFields;
};
function buildInitialValues(rows: BarcodePrintRow[]) {
return Object.fromEntries(
BARCODE_PRINT_IMPORT_EDIT_FIELDS.map((field) => {
const values = new Set(rows.map((row) => String(row[field.key] ?? "")));
return [field.key, values.size === 1 ? (values.values().next().value ?? "") : ""];
}),
) as Record<BarcodePrintImportEditableField, string>;
}
export function BarcodePrintBulkEditDialog({
isOpen,
rows,
isSaving,
onClose,
onSubmit,
}: {
isOpen: boolean;
rows: BarcodePrintRow[];
isSaving: boolean;
onClose: () => void;
onSubmit: (value: BarcodePrintBulkEditSubmit) => void;
}) {
const initialValues = useMemo(() => buildInitialValues(rows), [rows]);
const [selectedFields, setSelectedFields] = useState<
Set<BarcodePrintImportEditableField>
>(() => new Set());
const [values, setValues] = useState(initialValues);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !isSaving) onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, isSaving, onClose]);
if (!isOpen) return null;
function toggleField(field: BarcodePrintImportEditableField) {
setSelectedFields((current) => {
const next = new Set(current);
if (next.has(field)) next.delete(field);
else next.add(field);
return next;
});
setErrorMessage("");
}
function updateField(field: BarcodePrintImportEditableField, value: string) {
setValues((current) => ({ ...current, [field]: value }));
setSelectedFields((current) => new Set(current).add(field));
setErrorMessage("");
}
function submit() {
if (selectedFields.size === 0) {
setErrorMessage("请选择要修改的字段。");
return;
}
if (selectedFields.has("skuCode") && !values.skuCode.trim()) {
setErrorMessage("货号不能为空。");
return;
}
const fields = Object.fromEntries(
Array.from(selectedFields, (field) => [field, values[field]]),
) as BarcodePrintImportEditFields;
onSubmit({ fields });
}
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="barcode-print-bulk-edit-title"
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => {
if (!isSaving) onClose();
}}
>
<div
className="flex h-full w-[min(640px,100vw)] flex-col border-l border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)] shadow-[-24px_0_48px_rgba(17,19,21,0.18)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--obs-shell-border)] px-5">
<h2 id="barcode-print-bulk-edit-title" className="text-[15px] font-apple-semibold">
修改选中导入数据({rows.length} 行)
</h2>
<button
type="button"
aria-label="关闭"
disabled={isSaving}
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white text-[var(--obs-shell-muted)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)] disabled:opacity-55"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
{BARCODE_PRINT_IMPORT_EDIT_FIELDS.map((field) => {
const selected = selectedFields.has(field.key);
return (
<div key={field.key} className="min-w-0">
<label className="mb-1.5 flex items-center gap-2 text-[12px] font-apple-semibold">
<input
type="checkbox"
aria-label={`修改${field.label}`}
checked={selected}
onChange={() => toggleField(field.key)}
className="h-3.5 w-3.5"
/>
<span>{field.label}</span>
</label>
<input
value={values[field.key]}
aria-label={`${field.label}值`}
onChange={(event) => updateField(field.key, event.target.value)}
className={`h-9 w-full rounded-[6px] border bg-white px-2.5 text-[12px] font-apple-medium outline-none transition ${
selected
? "border-[var(--obs-shell-title)] text-[var(--obs-shell-title)]"
: "border-[var(--obs-shell-border)] text-[var(--obs-shell-muted)]"
}`}
/>
</div>
);
})}
</div>
{errorMessage ? (
<div className="mt-4 rounded-[6px] border border-[var(--obs-shell-danger-border)] bg-[var(--obs-shell-danger-bg)] px-3 py-2 text-[12px] font-apple-medium text-[var(--obs-shell-danger)]">
{errorMessage}
</div>
) : null}
</div>
<div className="flex shrink-0 justify-end gap-2 border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-5 py-4">
<button
type="button"
disabled={isSaving}
onClick={submit}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[var(--obs-shell-action-border)] bg-[var(--obs-shell-action-bg)] px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-action-text)] transition hover:bg-[var(--obs-shell-action-hover-bg)] disabled:opacity-55"
>
<Save className="h-3.5 w-3.5" aria-hidden="true" />
{isSaving ? "正在保存" : "保存修改"}
</button>
<button
type="button"
disabled={isSaving}
onClick={onClose}
className="inline-flex h-8 items-center rounded-[6px] border border-[var(--obs-shell-border)] bg-white px-4 text-[12px] font-apple-semibold text-[var(--obs-shell-control-text)] transition hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)] disabled:opacity-55"
>
取消
</button>
</div>
</div>
</div>
);
}

View File

@ -23,6 +23,8 @@ function renderHeader({
onBrandSkuImport={jest.fn()}
onDownloadTemplate={jest.fn()}
onExport={onExport}
onDeleteSelected={jest.fn()}
onEditSelected={jest.fn()}
onFilterChange={jest.fn()}
onSearch={jest.fn()}
onReset={jest.fn()}
@ -43,6 +45,12 @@ describe("BarcodePrintHeader", () => {
expect(
screen.queryByRole("button", { name: "品牌货号导入模板下载" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "删除选中" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "修改选中" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "查询" })).toBeInTheDocument();
});
@ -57,9 +65,30 @@ describe("BarcodePrintHeader", () => {
expect(
screen.getByRole("button", { name: "品牌货号导入模板下载" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "删除选中" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "修改选中" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "导出数据" })).not.toBeInTheDocument();
});
it("places bulk delete and edit actions after the import template actions", () => {
renderHeader({ canManageImports: true });
const brandSkuTemplate = screen.getByRole("button", {
name: "品牌货号导入模板下载",
});
const deleteSelected = screen.getByRole("button", { name: "删除选中" });
const editSelected = screen.getByRole("button", { name: "修改选中" });
expect(
brandSkuTemplate.compareDocumentPosition(deleteSelected) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
deleteSelected.compareDocumentPosition(editSelected) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("shows the export action for administrators", () => {
const onExport = jest.fn();
renderHeader({ canManageImports: true, canExport: true, onExport });

View File

@ -1,6 +1,14 @@
"use client";
import { Download, FileDown, RotateCcw, Search, Upload } from "lucide-react";
import {
Download,
FileDown,
Pencil,
RotateCcw,
Search,
Trash2,
Upload,
} from "lucide-react";
import type { BarcodePrintImportKind } from "./barcodePrintImport";
import {
BarcodePrintActionButton,
@ -23,12 +31,15 @@ export function BarcodePrintHeader({
statusMessage,
canManageImports,
canExport,
selectedRowCount = 0,
exporting = false,
onBarcodeImport,
actionDisabled = false,
onBrandSkuImport,
onDownloadTemplate,
onExport,
onDeleteSelected,
onEditSelected,
onFilterChange,
onSearch,
onReset,
@ -39,12 +50,15 @@ export function BarcodePrintHeader({
statusMessage: string;
canManageImports: boolean;
canExport: boolean;
selectedRowCount?: number;
exporting?: boolean;
actionDisabled?: boolean;
onBarcodeImport: () => void;
onBrandSkuImport: () => void;
onDownloadTemplate: (kind: BarcodePrintImportKind) => void;
onExport: () => void;
onDeleteSelected: () => void;
onEditSelected: () => void;
onFilterChange: <K extends keyof BarcodePrintFilterState>(
key: K,
value: BarcodePrintFilterState[K],
@ -84,6 +98,21 @@ export function BarcodePrintHeader({
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>品牌货号导入模板下载</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton
disabled={actionDisabled || selectedRowCount === 0}
intent="danger"
onClick={onDeleteSelected}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
<span>删除选中{selectedRowCount ? `(${selectedRowCount} 行)` : ""}</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton
disabled={actionDisabled || selectedRowCount === 0}
onClick={onEditSelected}
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
<span>修改选中{selectedRowCount ? `(${selectedRowCount} 行)` : ""}</span>
</BarcodePrintActionButton>
</>
) : null}
</div>

View File

@ -67,6 +67,9 @@ export function BarcodePrintTable({
const columns = isSupplier
? getVisibleBarcodePrintColumns(true)
: BARCODE_PRINT_COLUMNS;
const selectableRows = pagedRows.filter(
(row) => (row.importRecordIds?.length ?? 0) > 0,
);
return (
<div className="min-h-0 flex-1 overflow-auto bg-white">
<table
@ -88,6 +91,7 @@ export function BarcodePrintTable({
aria-label="选择当前筛选结果"
checked={allVisibleSelected}
onChange={onToggleVisibleRows}
disabled={selectableRows.length === 0}
className="h-3.5 w-3.5"
/>
</th>
@ -169,6 +173,7 @@ export function BarcodePrintTable({
}行`}
checked={selectedRowIds.has(row.id)}
onChange={() => onToggleRow(row.id)}
disabled={!row.importRecordIds?.length}
className="h-3.5 w-3.5"
/>
</td>

View File

@ -21,6 +21,7 @@ import {
deleteBarcodePrintImportRecords,
fetchBarcodePrintRowsPaged,
saveBarcodePrintImportRecords,
updateBarcodePrintImportRecords,
type BarcodePrintPagedResponse,
} from "./barcodePrintApi";
import { BarcodePrintHeader } from "./BarcodePrintHeader";
@ -71,6 +72,10 @@ import type {
BarcodePrintFilterState,
} from "./barcodePrintTypes";
import { useBarcodePrintExport } from "./useBarcodePrintExport";
import {
BarcodePrintBulkEditDialog,
type BarcodePrintBulkEditSubmit,
} from "./BarcodePrintBulkEditDialog";
type BarcodePrintWorkspaceUser = {
id: string;
@ -141,9 +146,9 @@ export function BarcodePrintWorkspace({
useState<BarcodePrintFilterState>(EMPTY_FILTERS);
const [appliedFilters, setAppliedFilters] =
useState<BarcodePrintFilterState>(EMPTY_FILTERS);
const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(
() => new Set(),
);
const [selectedRowsById, setSelectedRowsById] = useState<
Map<string, BarcodePrintRow>
>(() => new Map());
const [currentPage, setCurrentPage] = useState(1);
const [requestedPage, setRequestedPage] = useState(1);
const [reloadNonce, setReloadNonce] = useState(0);
@ -164,6 +169,8 @@ export function BarcodePrintWorkspace({
useState<PendingPrintJob | null>(null);
const [pendingCertificateEdit, setPendingCertificateEdit] =
useState<PendingCertificateEdit | null>(null);
const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [bulkEditSaving, setBulkEditSaving] = useState(false);
const [certificatePreview, setCertificatePreview] =
useState<CertificatePrintPreview | null>(null);
const exportFilters = useMemo(
@ -206,8 +213,28 @@ export function BarcodePrintWorkspace({
);
// 后端已分页:当前页数据就是 rows,不再前端切片
const pagedRows = displayedRows;
const selectedRowIds = useMemo(
() => new Set(selectedRowsById.keys()),
[selectedRowsById],
);
const selectableRows = useMemo(
() => pagedRows.filter((row) => (row.importRecordIds?.length ?? 0) > 0),
[pagedRows],
);
const allVisibleSelected =
pagedRows.length > 0 && pagedRows.every((row) => selectedRowIds.has(row.id));
selectableRows.length > 0 &&
selectableRows.every((row) => selectedRowIds.has(row.id));
const selectedRows = useMemo(
() => Array.from(selectedRowsById.values()),
[selectedRowsById],
);
const selectedImportRecordIds = useMemo(
() =>
Array.from(
new Set(selectedRows.flatMap((row) => row.importRecordIds ?? [])),
),
[selectedRows],
);
const pendingPrintRow = pendingPrintJob
? pagedRows.find((row) => row.id === pendingPrintJob.rowId) ?? null
: null;
@ -321,29 +348,35 @@ export function BarcodePrintWorkspace({
}
function toggleVisibleRows() {
setSelectedRowIds((current) => {
const next = new Set(current);
setSelectedRowsById((current) => {
const next = new Map(current);
if (allVisibleSelected) {
pagedRows.forEach((row) => next.delete(row.id));
selectableRows.forEach((row) => next.delete(row.id));
} else {
pagedRows.forEach((row) => next.add(row.id));
selectableRows.forEach((row) => next.set(row.id, row));
}
return next;
});
}
function toggleRow(rowId: string) {
setSelectedRowIds((current) => {
const next = new Set(current);
const row = pagedRows.find((item) => item.id === rowId);
if (!row?.importRecordIds?.length) return;
setSelectedRowsById((current) => {
const next = new Map(current);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
next.set(rowId, row);
}
return next;
});
}
function clearSelectedRows() {
setSelectedRowsById(new Map());
}
async function handleImportFile(kind: BarcodePrintImportKind, file: File | null) {
if (!file) return;
@ -392,7 +425,7 @@ export function BarcodePrintWorkspace({
await waitForProgressFrame();
await saveBarcodePrintImportRecords(result.records);
setSelectedRowIds(new Set());
clearSelectedRows();
pageCacheRef.current.clear();
pageRequestRef.current.clear();
setRows([]);
@ -470,12 +503,12 @@ export function BarcodePrintWorkspace({
}
try {
await deleteBarcodePrintImportRecords(importRecordIds);
setSelectedRowIds(new Set());
const result = await deleteBarcodePrintImportRecords(importRecordIds);
clearSelectedRows();
setImportProgress(EMPTY_IMPORT_PROGRESS);
pageCacheRef.current.clear();
pageRequestRef.current.clear();
setStatusMessage("已删除该行导入数据。");
setStatusMessage(`已删除 ${result.deleted} 条导入数据。`);
setReloadNonce((n) => n + 1);
} catch (error) {
setStatusMessage(
@ -484,6 +517,65 @@ export function BarcodePrintWorkspace({
}
}
async function deleteSelectedData() {
if (selectedImportRecordIds.length === 0) return;
if (
!window.confirm(
`确认删除选中的 ${selectedImportRecordIds.length} 条导入数据吗?`,
)
) {
return;
}
try {
const result = await deleteBarcodePrintImportRecords(
selectedImportRecordIds,
);
clearSelectedRows();
pageCacheRef.current.clear();
pageRequestRef.current.clear();
setStatusMessage(`已删除 ${result.deleted} 条导入数据。`);
setReloadNonce((n) => n + 1);
} catch (error) {
setStatusMessage(
error instanceof Error ? error.message : "删除导入数据失败。",
);
}
}
function openBulkEdit() {
if (selectedImportRecordIds.length === 0) return;
setBulkEditOpen(true);
}
async function submitBulkEdit({ fields }: BarcodePrintBulkEditSubmit) {
if (selectedImportRecordIds.length === 0) return;
setBulkEditSaving(true);
try {
const result = await updateBarcodePrintImportRecords(
selectedImportRecordIds,
fields,
);
setBulkEditOpen(false);
clearSelectedRows();
pageCacheRef.current.clear();
pageRequestRef.current.clear();
setStatusMessage(`已修改 ${result.updated} 条导入数据。`);
setReloadNonce((n) => n + 1);
} catch (error) {
setStatusMessage(
error instanceof Error ? error.message : "修改导入数据失败。",
);
} finally {
setBulkEditSaving(false);
}
}
const closeBulkEdit = useCallback(() => {
if (!bulkEditSaving) setBulkEditOpen(false);
}, [bulkEditSaving]);
function startRowNumberColumnResize(
event: ReactMouseEvent<HTMLButtonElement>,
) {
@ -662,6 +754,7 @@ export function BarcodePrintWorkspace({
setHasNextPage(false);
setCurrentPage(1);
setRequestedPage(1);
clearSelectedRows();
}
return (
@ -698,7 +791,8 @@ export function BarcodePrintWorkspace({
salesCategoryOptions={salesCategoryOptions}
statusMessage={isUnboundSupplier ? UNBOUND_SUPPLIER_MESSAGE : statusMessage}
canManageImports={canManageImports}
canExport={canExport}
canExport={canExport}
selectedRowCount={selectedRows.length}
exporting={exporting}
actionDisabled={isUnboundSupplier || exporting}
onBarcodeImport={() => {
@ -710,7 +804,9 @@ export function BarcodePrintWorkspace({
else brandSkuImportInputRef.current?.click();
}}
onDownloadTemplate={(kind) => void handleDownloadTemplate(kind)}
onExport={() => void exportRows()}
onExport={() => void exportRows()}
onDeleteSelected={() => void deleteSelectedData()}
onEditSelected={openBulkEdit}
onFilterChange={updateDraftFilter}
onSearch={() => {
setAppliedFilters(draftFilters);
@ -810,6 +906,15 @@ export function BarcodePrintWorkspace({
onPrint={() => void printCertificatePreview(certificatePreview)}
/>
) : null}
{bulkEditOpen ? (
<BarcodePrintBulkEditDialog
isOpen
rows={selectedRows}
isSaving={bulkEditSaving}
onClose={closeBulkEdit}
onSubmit={(value) => void submitBulkEdit(value)}
/>
) : null}
<BarcodePrintProgressToast progress={importProgress} />
</section>
);

View File

@ -1,5 +1,6 @@
import type { BarcodePrintImportRecord } from "./barcodePrintImport";
import type { BarcodePrintRow } from "./barcodePrintRows";
import type { BarcodePrintImportEditFields } from "@/lib/barcode-print-import-edit";
type ApiEnvelope<T> = {
success?: boolean;
@ -98,7 +99,7 @@ export async function saveBarcodePrintImportRecords(
export async function deleteBarcodePrintImportRecords(
ids: string[],
): Promise<void> {
): Promise<{ deleted: number }> {
const response = await fetch("/api/intake/barcode-print", {
method: "DELETE",
headers: {
@ -107,5 +108,20 @@ export async function deleteBarcodePrintImportRecords(
body: JSON.stringify({ ids }),
});
await readApiData<{ deleted: number }>(response);
return readApiData<{ deleted: number }>(response);
}
export async function updateBarcodePrintImportRecords(
ids: string[],
fields: BarcodePrintImportEditFields,
): Promise<{ updated: number }> {
const response = await fetch("/api/intake/barcode-print", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ids, fields }),
});
return readApiData<{ updated: number }>(response);
}

View File

@ -277,6 +277,7 @@ export function useNonIntegratedDetailDomainControllers(
skuBatchAction: workflow.skuBatchAction,
skuState,
spuEstimate,
supplierCodeByName: directoryOptions.supplierCodeByName,
supplementSelection: workflow.supplementSelection,
transferProgress,
workflowConfirmation: workflow.workflowConfirmation,

View File

@ -87,6 +87,7 @@ type SkuReviewActionControllerProps = Pick<
selectedBom: WorkflowDerivedState["selectedBom"];
seriesDerived: ProductSetupState["seriesDerived"];
seriesDraft: ProductSetupState["seriesDraft"];
supplierCodeByName: ReadonlyMap<string, string>;
skuBatchAction: WorkflowDerivedState["skuBatchAction"];
skuState: ReturnType<typeof useNonIntegratedSkuState>;
spuEstimate: ReturnType<typeof useSpuEstimateRefreshToken>;
@ -133,6 +134,7 @@ export function useNonIntegratedSkuReviewActionControllers({
selectedBom,
seriesDerived,
seriesDraft,
supplierCodeByName,
skuBatchAction,
skuState,
spuEstimate,
@ -252,6 +254,7 @@ export function useNonIntegratedSkuReviewActionControllers({
setSkuBatchFlowNos: skuState.setSkuBatchFlowNos,
setSkuItems: skuState.setSkuItems,
setUploadProgress: transferProgress.setUploadProgress,
supplierCodeByName,
skuBatchChangeTypes: skuState.skuBatchChangeTypes,
skuBatchCount: skuState.skuBatchCount,
skuBatchFlowNos: skuState.skuBatchFlowNos,

View File

@ -4,6 +4,7 @@ import type { NonIntegratedSkuItem } from "../../nonIntegratedStorage";
import { createFormValuesFromProduct } from "./productFormValueHelpers";
import { createEmptySkuItem } from "./skuBrandHelpers";
import {
buildSkuItemsFromFuzzyImportedWorkbookRows,
buildSkuItemsFromImportedWorkbookRows,
getSkuWorkbookImportBatchMetadata,
} from "./skuDerivationHelpers";
@ -149,3 +150,122 @@ describe("SKU workbook batch isolation", () => {
});
});
});
describe("SKU workbook fuzzy import", () => {
it("writes only the five fuzzy fields and preserves unrelated SKU content", () => {
const historicalItem = createSkuItem("batch-1-sku-1", 1, 1, {
skuCode: "HISTORICAL-SKU",
});
const currentItem = createSkuItem("batch-2-sku-1", 2, 2, {
skuCode: "EXISTING-SKU",
barcode: "EXISTING-BARCODE",
model: "EXISTING-MODEL",
manufacturer: "旧供应商",
manufacturerCode: "OLD",
});
const result = buildSkuItemsFromFuzzyImportedWorkbookRows({
currentItems: [historicalItem, currentItem],
rows: [
{
size: "120×60×75cm",
color: "胡桃色",
material: "实木",
installMethod: "整装",
manufacturer: "供应商甲",
rowNumber: 2,
},
],
supplierCodeByName: new Map([["供应商甲", "SUP-001"]]),
targetBatchNo: 2,
});
expect(result[0]).toBe(historicalItem);
expect(result[1]).toMatchObject({
id: "batch-2-sku-1",
skuCode: "EXISTING-SKU",
barcode: "EXISTING-BARCODE",
model: "EXISTING-MODEL",
size: "120×60×75cm",
color: "胡桃色",
material: "实木",
installMethod: "整装",
manufacturer: "供应商甲",
manufacturerCode: "SUP-001",
});
});
it("rejects a supplier that is not in the system directory", () => {
expect(() =>
buildSkuItemsFromFuzzyImportedWorkbookRows({
currentItems: [createSkuItem("sku-1", 1, 1)],
rows: [
{
size: "120×60×75cm",
color: "胡桃色",
material: "实木",
installMethod: "整装",
manufacturer: "未知供应商",
rowNumber: 5,
},
],
supplierCodeByName: new Map([["供应商甲", "SUP-001"]]),
targetBatchNo: 1,
}),
).toThrow("第5行供应商“未知供应商”未在系统供应商目录中找到");
});
it("keeps imported rows in spreadsheet order and creates rows beyond the current batch", () => {
const result = buildSkuItemsFromFuzzyImportedWorkbookRows({
currentItems: [createSkuItem("sku-1", 1, 1)],
rows: [
{
size: "A",
color: "红",
material: "实木",
installMethod: "整装",
manufacturer: "供应商甲",
rowNumber: 2,
},
{
size: "B",
color: "蓝",
material: "板材",
installMethod: "拆装",
manufacturer: "供应商甲",
rowNumber: 3,
},
],
supplierCodeByName: new Map([["供应商甲", "SUP-001"]]),
targetBatchNo: 1,
});
expect(result).toHaveLength(2);
expect(result.map((item) => item.size)).toEqual(["A", "B"]);
expect(result.every((item) => item.batchNo === 1)).toBe(true);
});
it("does not remove existing rows when the fuzzy workbook has fewer rows", () => {
const firstItem = createSkuItem("sku-1", 1, 1, { skuCode: "FIRST" });
const secondItem = createSkuItem("sku-2", 1, 2, { skuCode: "SECOND" });
const result = buildSkuItemsFromFuzzyImportedWorkbookRows({
currentItems: [firstItem, secondItem],
rows: [
{
size: "A",
color: "红",
material: "实木",
installMethod: "整装",
manufacturer: "供应商甲",
rowNumber: 2,
},
],
supplierCodeByName: new Map([["供应商甲", "SUP-001"]]),
targetBatchNo: 1,
});
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ size: "A", skuCode: "FIRST" });
expect(result[1]).toBe(secondItem);
});
});

View File

@ -26,7 +26,13 @@ import {
SKU_WORKBOOK_SOURCE,
} from "./skuSourceHelpers";
import { isSeriesComponentAssetSkuItem } from "./seriesComponentAssetSkuHelpers";
import { resolveExecutionStandardOption } from "./detailValueHelpers";
import {
resolveExecutionStandardOption,
} from "./detailValueHelpers";
export {
buildSkuItemsFromFuzzyImportedWorkbookRows,
getFuzzyImportSupplierErrors,
} from "./skuFuzzyImportHelpers";
import {
DEFAULT_ASSET_SKU_ATTRIBUTE_CODE,
normalizeSkuSalesStatus,

View File

@ -0,0 +1,128 @@
import type { NonIntegratedSkuFuzzyImportRow } from "@/lib/non-integrated-sku-export";
import {
buildNonIntegratedSkuId,
type NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import { createEmptySkuItem } from "./skuBrandHelpers";
import { resolveSupplierEntryByName } from "./detailValueHelpers";
export function getFuzzyImportSupplierErrors(
rows: NonIntegratedSkuFuzzyImportRow[],
supplierCodeByName: ReadonlyMap<string, string>,
) {
const unmatchedSuppliers = rows.reduce((result, row) => {
const supplierName = row.manufacturer.trim();
if (
supplierName &&
!resolveSupplierEntryByName(supplierName, supplierCodeByName) &&
!result.has(supplierName)
) {
result.set(supplierName, row.rowNumber);
}
return result;
}, new Map<string, number>());
return Array.from(unmatchedSuppliers.entries()).map(
([supplierName, rowNumber]) =>
`第${rowNumber}行供应商“${supplierName}”未在系统供应商目录中找到,请使用系统中的完整名称。`,
);
}
export function buildSkuItemsFromFuzzyImportedWorkbookRows({
currentItems,
rows,
supplierCodeByName,
targetBatchNo,
}: {
currentItems: NonIntegratedSkuItem[];
rows: NonIntegratedSkuFuzzyImportRow[];
supplierCodeByName: ReadonlyMap<string, string>;
targetBatchNo: number;
}) {
if (rows.length === 0) return currentItems;
const supplierErrors = getFuzzyImportSupplierErrors(rows, supplierCodeByName);
if (supplierErrors.length > 0) {
throw new Error(supplierErrors.join(" "));
}
const normalizedTargetBatchNo = Math.max(1, Math.floor(targetBatchNo));
const isTargetBatchItem = (item: NonIntegratedSkuItem) =>
(Number.isFinite(item.batchNo) && item.batchNo > 0
? Math.floor(item.batchNo)
: 1) === normalizedTargetBatchNo;
const targetBatchItems = currentItems.filter(isTargetBatchItem);
const firstTargetBatchIndex = currentItems.findIndex(isTargetBatchItem);
const preservedItems = currentItems.filter((item) => !isTargetBatchItem(item));
const insertionIndex =
firstTargetBatchIndex < 0
? preservedItems.length
: currentItems
.slice(0, firstTargetBatchIndex)
.filter((item) => !isTargetBatchItem(item)).length;
let nextSequence =
currentItems.reduce(
(maxSequence, item, index) =>
Math.max(
maxSequence,
Number.isFinite(item.skuSequence) && item.skuSequence > 0
? item.skuSequence
: index + 1,
),
0,
) + 1;
const importedRows = rows.map((row, index) => {
const existingItem = targetBatchItems[index];
const hasExistingSequence =
existingItem !== undefined &&
Number.isFinite(existingItem.skuSequence) &&
existingItem.skuSequence > 0;
const supplier = row.manufacturer.trim()
? resolveSupplierEntryByName(row.manufacturer, supplierCodeByName)
: null;
const baseItem = existingItem ?? createEmptySkuItem();
const item = {
...baseItem,
id: existingItem?.id ?? buildNonIntegratedSkuId(),
skuSequence: hasExistingSequence
? existingItem.skuSequence
: nextSequence,
batchNo: normalizedTargetBatchNo,
size: row.size.trim(),
color: row.color.trim(),
material: row.material.trim(),
installMethod: row.installMethod.trim(),
manufacturer: supplier?.name ?? "",
manufacturerCode: supplier?.code ?? "",
};
if (!hasExistingSequence) nextSequence += 1;
return item;
});
const nextTargetBatchItems = targetBatchItems.map(
(item, index) => importedRows[index] ?? item,
);
if (importedRows.length > targetBatchItems.length) {
nextTargetBatchItems.push(
...importedRows.slice(targetBatchItems.length),
);
}
if (nextTargetBatchItems.length === targetBatchItems.length) {
let targetIndex = 0;
return currentItems.map((item) =>
isTargetBatchItem(item)
? nextTargetBatchItems[targetIndex++] ?? item
: item,
);
}
return [
...preservedItems.slice(0, insertionIndex),
...nextTargetBatchItems,
...preservedItems.slice(insertionIndex),
];
}

View File

@ -14,9 +14,11 @@ import type {
import type { DownstreamSkuBatch } from "../shared/InheritedSkuBatchTabs";
import type { FormValues } from "./productFormValueHelpers";
import {
buildSkuItemsFromFuzzyImportedWorkbookRows,
buildSkuItemsFromImportedWorkbookRows,
buildSkuWorkbookFileName,
downloadSkuWorkbook,
getFuzzyImportSupplierErrors,
getSkuWorkbookImportBatchMetadata,
} from "./skuDerivationHelpers";
@ -45,6 +47,7 @@ type UseSkuWorkbookControllerParams = {
setSkuBatchFlowNos: Dispatch<SetStateAction<Record<string, string>>>;
setSkuItems: Dispatch<SetStateAction<NonIntegratedSkuItem[]>>;
setUploadProgress: Dispatch<SetStateAction<UploadProgressState | null>>;
supplierCodeByName: ReadonlyMap<string, string>;
skuBatchChangeTypes: Record<string, string>;
skuBatchCount: number;
skuBatchFlowNos: Record<string, string>;
@ -81,6 +84,7 @@ export function useSkuWorkbookController({
setSkuBatchFlowNos,
setSkuItems,
setUploadProgress,
supplierCodeByName,
skuBatchChangeTypes,
skuBatchCount,
skuBatchFlowNos,
@ -144,22 +148,44 @@ export function useSkuWorkbookController({
setIsImportingSku(true);
try {
startImportProgress(file);
const { rows } = await parseNonIntegratedSkuWorkbook(file);
const workbookImport = await parseNonIntegratedSkuWorkbook(file);
updateImportProgress(45, file.name);
const { mode, rows } = workbookImport;
if (rows.length === 0) {
throw new Error("上传资料中没有可导入的 SKU 行");
}
if (mode === "fuzzy") {
const supplierErrors = getFuzzyImportSupplierErrors(
rows,
supplierCodeByName,
);
if (supplierErrors.length > 0) {
throw new Error(supplierErrors.join(" "));
}
}
const importBatchMetadata = getSkuWorkbookImportBatchMetadata({
currentBatchCount: skuBatchCount,
rows,
targetBatchNo,
});
const importBatchMetadata =
mode === "standard"
? getSkuWorkbookImportBatchMetadata({
currentBatchCount: skuBatchCount,
rows,
targetBatchNo,
})
: null;
if (!(await ensureFormalSpuReadyForSkuEdit())) {
throw new Error("请先生成正式SPU后再导入SKU资料");
}
setSkuItems((current) => {
if (mode === "fuzzy") {
return buildSkuItemsFromFuzzyImportedWorkbookRows({
currentItems: current,
rows,
supplierCodeByName,
targetBatchNo,
});
}
return buildSkuItemsFromImportedWorkbookRows({
currentItems: current,
formValues,
@ -167,26 +193,38 @@ export function useSkuWorkbookController({
targetBatchNo,
});
});
setSkuBatchCount(importBatchMetadata.nextBatchCount);
if (importBatchMetadata) {
setSkuBatchCount(importBatchMetadata.nextBatchCount);
}
setActiveSkuBatch((current) =>
current === "all" || current <= importBatchMetadata.nextBatchCount
current === "all" ||
!importBatchMetadata ||
current <= importBatchMetadata.nextBatchCount
? current
: importBatchMetadata.nextBatchCount,
);
setActiveDownstreamSkuBatch((current) =>
current === "all" || current <= importBatchMetadata.nextBatchCount
current === "all" ||
!importBatchMetadata ||
current <= importBatchMetadata.nextBatchCount
? current
: importBatchMetadata.nextBatchCount,
);
setSkuBatchFlowNos((current) => ({
...current,
...importBatchMetadata.batchFlowNos,
}));
setSkuBatchChangeTypes((current) => ({
...current,
...importBatchMetadata.batchChangeTypes,
}));
setSaveMessage(`已导入 ${rows.length} 行 SKU 资料。`);
if (importBatchMetadata) {
setSkuBatchFlowNos((current) => ({
...current,
...importBatchMetadata.batchFlowNos,
}));
setSkuBatchChangeTypes((current) => ({
...current,
...importBatchMetadata.batchChangeTypes,
}));
}
setSaveMessage(
mode === "fuzzy"
? `已按供应商、尺寸、颜色、材质、安装方式导入 ${rows.length} 行 SKU 资料。`
: `已导入 ${rows.length} 行 SKU 资料。`,
);
setSaveMessageType("success");
finishImportProgress(file.name);
} catch (error) {

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

@ -28,6 +28,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: '采购管理',
@ -38,6 +43,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

@ -9,29 +9,28 @@ import type {
SupplyChainSupplierInformationUpdates,
} from '@/lib/supply-chain-supplier-information'
const FIELDS: Array<{
key: SupplyChainSupplierInformationEditableField
label: string
placeholder: string
}> = [
{ key: 'purchaseOwner', label: '采购', placeholder: '填写采购姓名' },
{ key: 'supplierName', label: '供应商', placeholder: '填写供应商名称' },
{ key: 'minimumOrderQuantity', label: '产品起订量', placeholder: '填写产品起订量' },
{ key: 'firstLeadTime', label: '首单生产周期', placeholder: '填写首单生产周期' },
{ key: 'stableLeadTime', label: '稳定生产周期', placeholder: '填写稳定生产周期' },
{ key: 'palletSpec', label: '托盘参数', placeholder: '填写托盘参数' },
{ key: 'backupSupplier', label: '替换供应商', placeholder: '填写替换供应商' },
{ key: 'supplierType', label: '供应商类型', placeholder: '填写供应商类型' },
]
type EditValues = Record<SupplyChainSupplierInformationEditableField, string>
function initialValue(values: readonly string[]) {
return values.length === 1 ? values[0] : ''
}
function CurrentValues({ values }: { values: readonly string[] }) {
if (values.length === 0) return <span className="supplier-edit-current empty">当前无值</span>
if (values.length === 1) return <span className="supplier-edit-current">当前:{values[0]}</span>
return (
<details className="supplier-edit-current multiple">
<summary>当前多个值({values.length} 项)</summary>
<div>{values.map((value) => <span key={value}>{value}</span>)}</div>
</details>
)
}
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 SupplierInformationEditDialog({
@ -43,16 +42,9 @@ export function SupplierInformationEditDialog({
onClose: () => void
onSaved: () => void
}) {
const [values, setValues] = useState<EditValues>({
purchaseOwner: initialValue(row.purchaserValues),
supplierName: row.supplierName,
minimumOrderQuantity: initialValue(row.minimumOrderQuantityValues),
firstLeadTime: initialValue(row.firstLeadTimeValues),
stableLeadTime: initialValue(row.stableLeadTimeValues),
palletSpec: initialValue(row.palletSpecValues),
backupSupplier: initialValue(row.backupSupplierValues),
supplierType: initialValue(row.supplierTypeValues),
})
const [values, setValues] = useState<EditValues>(() => Object.fromEntries(
FIELDS.map((field) => [field.key, row[field.key]]),
) as EditValues)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -64,36 +56,15 @@ export function SupplierInformationEditDialog({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, saving])
const setField = (field: SupplyChainSupplierInformationEditableField, value: string) => {
setValues((current) => ({ ...current, [field]: value }))
}
const save = async (event: FormEvent) => {
event.preventDefault()
if (saving) return
const updates: SupplyChainSupplierInformationUpdates = {}
const addValue = (
field: SupplyChainSupplierInformationEditableField,
currentValues: readonly string[],
) => {
const value = values[field].trim()
if (!value) return
if (currentValues.length === 1 && currentValues[0] === value) return
updates[field] = value
}
addValue('purchaseOwner', row.purchaserValues)
addValue('supplierName', [row.supplierName])
addValue('minimumOrderQuantity', row.minimumOrderQuantityValues)
addValue('firstLeadTime', row.firstLeadTimeValues)
addValue('stableLeadTime', row.stableLeadTimeValues)
addValue('palletSpec', row.palletSpecValues)
addValue('backupSupplier', row.backupSupplierValues)
addValue('supplierType', row.supplierTypeValues)
const updates = Object.fromEntries(FIELDS.flatMap((field) => {
const value = values[field.key].trim()
return value === row[field.key] ? [] : [[field.key, value]]
})) as SupplyChainSupplierInformationUpdates
if (Object.keys(updates).length === 0) {
setError('没有需要保存的修改;多个值字段可填写新值后统一覆盖')
setError('没有需要保存的修改')
return
}
@ -103,7 +74,7 @@ export function SupplierInformationEditDialog({
await readResponse(await fetch('/api/supply-chain/supplier-information', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplierKey: row.supplierKey, updates }),
body: JSON.stringify({ barcode: row.barcode, updates }),
}))
onSaved()
} catch (cause) {
@ -113,165 +84,56 @@ export function SupplierInformationEditDialog({
}
}
const editableFields: Array<{
field: SupplyChainSupplierInformationEditableField
label: string
currentValues: readonly string[]
placeholder: string
}> = [
{ field: 'purchaseOwner', label: '采购', currentValues: row.purchaserValues, placeholder: '填写采购姓名' },
{ field: 'supplierName', label: '供应商', currentValues: [row.supplierName], placeholder: '填写供应商名称' },
{ field: 'minimumOrderQuantity', label: '产品起订量', currentValues: row.minimumOrderQuantityValues, placeholder: '填写产品起订量' },
{ field: 'firstLeadTime', label: '首单生产周期', currentValues: row.firstLeadTimeValues, placeholder: '填写首单生产周期' },
{ field: 'stableLeadTime', label: '稳定生产周期', currentValues: row.stableLeadTimeValues, placeholder: '填写稳定生产周期' },
{ field: 'palletSpec', label: '托盘参数', currentValues: row.palletSpecValues, placeholder: '填写托盘参数' },
{ field: 'backupSupplier', label: '替换供应商', currentValues: row.backupSupplierValues, placeholder: '填写替换供应商' },
{ field: 'supplierType', label: '供应商类型', currentValues: row.supplierTypeValues, placeholder: '填写供应商类型' },
]
return createPortal(
<div
className="supplier-edit-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !saving) onClose()
}}
>
<form
className="supplier-edit-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="supplier-edit-title"
onSubmit={save}
>
<div className="supplier-edit-backdrop" role="presentation" onMouseDown={(event) => {
if (event.target === event.currentTarget && !saving) onClose()
}}>
<form className="supplier-edit-dialog" role="dialog" aria-modal="true" aria-labelledby="supplier-edit-title" onSubmit={save}>
<header>
<div>
<h2 id="supplier-edit-title">编辑供应商信息</h2>
<p>{row.supplierName} · 关联 {row.barcodeCount} 个条码</p>
<p>条码:{row.barcode}</p>
</div>
<button type="button" title="关闭" aria-label="关闭" onClick={onClose} disabled={saving}>
<X size={17} />
</button>
<button type="button" title="关闭" aria-label="关闭" onClick={onClose} disabled={saving}><X size={17} /></button>
</header>
<div className="supplier-edit-notice">
保存后会统一更新该供应商关联的全部条码;留空表示不修改。
</div>
<div className="supplier-edit-notice">本次保存只修改当前条码;清空输入框后保存会清空该字段。</div>
<div className="supplier-edit-grid">
{editableFields.map((item, index) => (
<label key={item.field}>
<span>{item.label}</span>
<CurrentValues values={item.currentValues} />
{FIELDS.map((field, index) => (
<label key={field.key}>
<span>{field.label}</span>
<input
autoFocus={index === 0}
value={values[item.field]}
onChange={(event) => setField(item.field, event.target.value)}
placeholder={item.currentValues.length > 1 ? '留空不修改;填写后统一覆盖' : item.placeholder}
maxLength={item.field === 'palletSpec' ? 240 : 180}
value={values[field.key]}
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
placeholder={field.placeholder}
maxLength={field.key === 'palletSpec' ? 240 : 180}
/>
</label>
))}
</div>
{error ? <p className="supplier-edit-error" role="alert">{error}</p> : null}
<footer>
<button type="button" onClick={onClose} disabled={saving}>取消</button>
<button type="submit" className="primary" disabled={saving}>
{saving ? '保存中...' : '确认保存'}
</button>
<button type="submit" className="primary" disabled={saving}>{saving ? '保存中...' : '确认保存'}</button>
</footer>
<style jsx global>{`
.supplier-edit-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(24, 24, 27, 0.28);
}
.supplier-edit-dialog {
display: flex;
width: min(760px, calc(100vw - 40px));
max-height: calc(100vh - 40px);
flex-direction: column;
overflow: auto;
border: 1px solid #e4e4e7;
border-radius: 8px;
padding: 20px;
background: #fff;
box-shadow: 0 18px 50px rgba(24, 24, 27, 0.18);
color: #27272a;
}
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
h2 { margin: 0; font-size: 17px; font-weight: 680; }
header p { margin: 5px 0 0; color: #71717a; font-size: 12px; }
header button {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 0;
background: transparent;
color: #71717a;
cursor: pointer;
}
.supplier-edit-notice {
margin-top: 16px;
border-left: 3px solid #64748b;
padding: 8px 10px;
background: #f8fafc;
color: #52525b;
font-size: 12px;
}
.supplier-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 15px 18px;
margin-top: 16px;
}
.supplier-edit-grid label { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
.supplier-edit-grid label > span:first-child { color: #52525b; font-size: 12px; font-weight: 650; }
.supplier-edit-grid input {
width: 100%;
height: 36px;
box-sizing: border-box;
border: 1px solid #d4d4d8;
border-radius: 5px;
padding: 0 10px;
outline: none;
background: #fff;
color: #27272a;
font-size: 13px;
}
.supplier-edit-backdrop { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(24,24,27,.28); }
.supplier-edit-dialog { display: flex; width: min(760px,calc(100vw - 40px)); max-height: calc(100vh - 40px); flex-direction: column; overflow: auto; border: 1px solid #e4e4e7; border-radius: 8px; padding: 20px; background: #fff; box-shadow: 0 18px 50px rgba(24,24,27,.18); color: #27272a; }
.supplier-edit-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.supplier-edit-dialog h2 { margin: 0; font-size: 17px; font-weight: 680; }
.supplier-edit-dialog header p { margin: 5px 0 0; color: #71717a; font-size: 12px; }
.supplier-edit-dialog header button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; background: transparent; color: #71717a; cursor: pointer; }
.supplier-edit-notice { margin-top: 16px; border-left: 3px solid #64748b; padding: 8px 10px; background: #f8fafc; color: #52525b; font-size: 12px; }
.supplier-edit-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 15px 18px; margin-top: 16px; }
.supplier-edit-grid label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #52525b; font-size: 12px; font-weight: 650; }
.supplier-edit-grid input { width: 100%; height: 36px; box-sizing: border-box; border: 1px solid #d4d4d8; border-radius: 5px; padding: 0 10px; outline: none; background: #fff; color: #27272a; font-size: 13px; font-weight: 400; }
.supplier-edit-grid input:focus { border-color: #52525b; box-shadow: 0 0 0 2px rgba(82,82,91,.09); }
:global(.supplier-edit-current) { min-height: 18px; color: #71717a; font-size: 11px; }
:global(.supplier-edit-current.empty) { color: #a1a1aa; }
:global(.supplier-edit-current.multiple summary) { cursor: pointer; color: #315f75; font-weight: 650; }
:global(.supplier-edit-current.multiple div) { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
:global(.supplier-edit-current.multiple div span) { border: 1px solid #e4e4e7; border-radius: 3px; padding: 2px 5px; background: #fafafa; color: #52525b; }
.supplier-edit-error { margin: 14px 0 0; color: #b91c1c; font-size: 12px; }
footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
footer button {
height: 34px;
border: 1px solid #d4d4d8;
border-radius: 5px;
padding: 0 14px;
background: #fff;
color: #52525b;
cursor: pointer;
}
footer button.primary { border-color: #27272a; background: #27272a; color: #fff; }
footer button:disabled { cursor: wait; opacity: 0.55; }
@media (max-width: 680px) {
.supplier-edit-backdrop { padding: 12px; }
.supplier-edit-dialog { width: 100%; max-height: calc(100vh - 24px); padding: 16px; }
.supplier-edit-grid { grid-template-columns: 1fr; }
}
.supplier-edit-dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.supplier-edit-dialog footer button { height: 34px; border: 1px solid #d4d4d8; border-radius: 5px; padding: 0 14px; background: #fff; color: #52525b; cursor: pointer; }
.supplier-edit-dialog footer button.primary { border-color: #27272a; background: #27272a; color: #fff; }
.supplier-edit-dialog footer button:disabled { cursor: wait; opacity: .55; }
@media(max-width:680px){.supplier-edit-backdrop{padding:12px}.supplier-edit-dialog{width:100%;max-height:calc(100vh - 24px);padding:16px}.supplier-edit-grid{grid-template-columns:1fr}}
`}</style>
</form>
</div>,

View File

@ -1,16 +1,17 @@
'use client'
import Link from 'next/link'
import { Filter, Search, X } from 'lucide-react'
import { Search } from 'lucide-react'
import type { SupplyChainSupplierInformationFilters } from '@/lib/supply-chain-supplier-information'
import { SupplierMultiSelect } from './SupplierMultiSelect'
const FIELDS: Array<{
key: keyof SupplyChainSupplierInformationFilters
const TEXT_FIELDS: Array<{
key: Exclude<keyof SupplyChainSupplierInformationFilters, 'suppliers'>
label: string
placeholder: string
}> = [
{ key: 'barcode', label: '条码', placeholder: '搜索条码' },
{ key: 'purchaser', label: '采购', placeholder: '搜索采购' },
{ key: 'supplier', label: '供应商', placeholder: '搜索供应商' },
{ key: 'minimumOrderQuantity', label: '产品起订量', placeholder: '搜索起订量' },
{ key: 'firstLeadTime', label: '首单生产周期', placeholder: '搜索首单周期' },
{ key: 'stableLeadTime', label: '稳定生产周期', placeholder: '搜索稳定周期' },
@ -21,26 +22,27 @@ const FIELDS: Array<{
export function SupplierInformationFilters({
filters,
suppliers,
pageSize,
}: {
filters: SupplyChainSupplierInformationFilters
suppliers: string[]
pageSize: number
}) {
const activeCount = Object.values(filters).filter(Boolean).length
const clearHref = pageSize === 50
const resetHref = pageSize === 50
? '/supply-chain/purchase/supplier-information'
: `/supply-chain/purchase/supplier-information?pageSize=${pageSize}`
return (
<form className="supplier-filters" method="get">
{pageSize !== 50 ? <input type="hidden" name="pageSize" value={pageSize} /> : null}
<div className="supplier-filter-heading">
<Filter size={14} aria-hidden="true" />
<span>筛选</span>
{activeCount > 0 ? <b>{activeCount}</b> : null}
</div>
<div className="supplier-filter-grid">
{FIELDS.map((field) => (
<SupplierMultiSelect
key={filters.suppliers.join('\u0000')}
options={suppliers}
value={filters.suppliers}
/>
{TEXT_FIELDS.map((field) => (
<label key={field.key}>
<span>{field.label}</span>
<div>
@ -58,109 +60,21 @@ export function SupplierInformationFilters({
))}
</div>
<div className="supplier-filter-actions">
{activeCount > 0 ? (
<Link href={clearHref} className="supplier-clear-filters">
<X size={14} />
清空
</Link>
) : null}
<button type="submit">查询</button>
<Link href={resetHref}>重置</Link>
</div>
<style jsx>{`
.supplier-filters {
display: flex;
align-items: flex-end;
gap: 12px;
padding: 12px 18px;
overflow-x: auto;
border-bottom: 1px solid var(--sc-border);
background: var(--sc-surface);
}
.supplier-filter-heading {
display: flex;
height: 34px;
align-items: center;
gap: 6px;
color: var(--sc-muted);
font-size: 12px;
font-weight: 650;
white-space: nowrap;
}
.supplier-filter-heading b {
display: grid;
min-width: 19px;
height: 19px;
place-items: center;
border-radius: 3px;
background: var(--sc-surface-strong);
color: var(--sc-ink);
font-size: 11px;
}
.supplier-filter-grid {
display: grid;
min-width: 1120px;
flex: 1;
grid-template-columns: repeat(8, minmax(132px, 1fr));
gap: 8px;
}
.supplier-filter-grid label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
color: var(--sc-muted);
font-size: 11px;
}
.supplier-filter-grid label > div {
display: flex;
height: 32px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid var(--sc-border);
border-radius: 4px;
color: var(--sc-subtle);
}
.supplier-filter-grid input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--sc-ink);
font-size: 12px;
}
.supplier-filter-actions {
display: flex;
flex: 0 0 auto;
height: 34px;
align-items: center;
gap: 7px;
}
.supplier-filter-actions button,
:global(.supplier-clear-filters) {
display: inline-flex;
height: 34px;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid var(--sc-border);
border-radius: 4px;
padding: 0 12px;
background: var(--sc-surface);
color: var(--sc-muted);
font-size: 12px;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.supplier-filter-actions button {
border-color: #3f3f46;
background: #3f3f46;
color: #fff;
font-weight: 650;
}
.supplier-filters { position: relative; z-index: 40; display: flex; align-items: flex-end; gap: 12px; padding: 12px 18px; overflow: visible; border-bottom: 1px solid var(--sc-border); background: var(--sc-surface); }
.supplier-filter-grid { display: grid; min-width: 0; flex: 1; grid-template-columns: repeat(9, minmax(112px, 1fr)); gap: 8px; }
.supplier-filter-grid label { display: flex; min-width: 0; flex-direction: column; gap: 4px; color: var(--sc-muted); font-size: 11px; }
.supplier-filter-grid label > div { display: flex; height: 32px; align-items: center; gap: 6px; padding: 0 8px; border: 1px solid var(--sc-border); border-radius: 4px; color: var(--sc-subtle); }
.supplier-filter-grid input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--sc-ink); font-size: 12px; }
.supplier-filter-actions { display: flex; flex: 0 0 auto; height: 34px; align-items: center; gap: 7px; }
.supplier-filter-actions button, .supplier-filter-actions :global(a) { display: inline-flex; height: 34px; align-items: center; justify-content: center; border: 1px solid var(--sc-border); border-radius: 4px; padding: 0 12px; background: var(--sc-surface); color: var(--sc-muted); font-size: 12px; text-decoration: none; white-space: nowrap; cursor: pointer; }
.supplier-filter-actions button { border-color: #3f3f46; background: #3f3f46; color: #fff; font-weight: 650; }
@media(max-width:1200px){.supplier-filters{align-items:flex-end;flex-wrap:wrap}.supplier-filter-grid{width:100%;flex-basis:100%;grid-template-columns:repeat(3,minmax(160px,1fr))}.supplier-filter-actions{margin-left:auto}}
@media(max-width:680px){.supplier-filter-grid{grid-template-columns:1fr}.supplier-filter-actions{width:100%;justify-content:flex-end}}
`}</style>
</form>
)

View File

@ -0,0 +1,116 @@
'use client'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { LoaderCircle, X } from 'lucide-react'
import type {
SupplyChainSupplierInformationHistoryItem,
SupplyChainSupplierInformationRow,
} from '@/lib/supply-chain-supplier-information'
function formatTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(new Date(value))
}
export function SupplierInformationHistoryDialog({
row,
onClose,
}: {
row: SupplyChainSupplierInformationRow
onClose: () => void
}) {
const [items, setItems] = useState<SupplyChainSupplierInformationHistoryItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
const search = new URLSearchParams({ barcode: row.barcode })
void fetch(`/api/supply-chain/supplier-information/history?${search.toString()}`, {
signal: controller.signal,
}).then(async (response) => {
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '日志读取失败')
setItems(payload.data)
}).catch((cause) => {
if (cause instanceof DOMException && cause.name === 'AbortError') return
setError(cause instanceof Error ? cause.message : '日志读取失败')
}).finally(() => setLoading(false))
return () => controller.abort()
}, [row.barcode])
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [onClose])
return createPortal(
<div className="supplier-history-backdrop" role="presentation" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}>
<section className="supplier-history-dialog" role="dialog" aria-modal="true" aria-labelledby="supplier-history-title">
<header>
<div>
<h2 id="supplier-history-title">修改日志</h2>
<p>条码:{row.barcode}</p>
</div>
<button type="button" aria-label="关闭" title="关闭" onClick={onClose}><X size={17} /></button>
</header>
<div className="supplier-history-table-wrap">
<table>
<thead><tr><th>修改时间</th><th>操作人</th><th>字段</th><th>修改前</th><th>修改后</th></tr></thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>{formatTime(item.createdAt)}</td>
<td>{item.actorName || '-'}</td>
<td>{item.fieldLabel}</td>
<td>{item.previousValue || '-'}</td>
<td>{item.nextValue || '-'}</td>
</tr>
))}
</tbody>
</table>
{loading ? <div className="supplier-history-state"><LoaderCircle size={17} />正在读取...</div> : null}
{!loading && error ? <div className="supplier-history-state error">{error}</div> : null}
{!loading && !error && items.length === 0 ? <div className="supplier-history-state">暂无修改日志</div> : null}
</div>
<footer><button type="button" onClick={onClose}>关闭</button></footer>
<style jsx global>{`
.supplier-history-backdrop { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(24,24,27,.28); }
.supplier-history-dialog { display: flex; width: min(980px,calc(100vw - 40px)); max-height: calc(100vh - 40px); flex-direction: column; border: 1px solid #e4e4e7; border-radius: 8px; padding: 20px; background: #fff; box-shadow: 0 18px 50px rgba(24,24,27,.18); color: #27272a; }
.supplier-history-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.supplier-history-dialog h2 { margin: 0; font-size: 17px; }
.supplier-history-dialog header p { margin: 5px 0 0; color: #71717a; font-size: 12px; }
.supplier-history-dialog header button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; background: transparent; color: #71717a; cursor: pointer; }
.supplier-history-table-wrap { position: relative; min-height: 220px; margin-top: 16px; overflow: auto; border: 1px solid #e4e4e7; }
.supplier-history-dialog table { width: 100%; min-width: 760px; border-collapse: collapse; table-layout: fixed; font-size: 12px; }
.supplier-history-dialog th, .supplier-history-dialog td { height: 42px; border-bottom: 1px solid #e4e4e7; padding: 7px 10px; text-align: left; vertical-align: middle; }
.supplier-history-dialog th { position: sticky; top: 0; background: #f4f4f5; color: #52525b; font-weight: 650; }
.supplier-history-dialog td { color: #3f3f46; word-break: break-word; }
.supplier-history-dialog th:first-child { width: 164px; }
.supplier-history-dialog th:nth-child(2) { width: 110px; }
.supplier-history-dialog th:nth-child(3) { width: 140px; }
.supplier-history-state { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 7px; color: #a1a1aa; font-size: 12px; }
.supplier-history-state :global(svg) { animation: supplier-history-spin .8s linear infinite; }
.supplier-history-state.error { color: #b91c1c; }
.supplier-history-dialog footer { display: flex; justify-content: flex-end; margin-top: 16px; }
.supplier-history-dialog footer button { height: 34px; border: 1px solid #d4d4d8; border-radius: 5px; padding: 0 14px; background: #fff; color: #52525b; cursor: pointer; }
@keyframes supplier-history-spin { to { transform: rotate(360deg); } }
`}</style>
</section>
</div>,
document.body,
)
}

View File

@ -0,0 +1,68 @@
'use client'
import { AlertTriangle, CheckCircle2, LoaderCircle, X } from 'lucide-react'
import type { SupplierInformationImportState } from './useSupplierInformationImport'
export function SupplierInformationImportProgress({
state,
onDismiss,
}: {
state: SupplierInformationImportState | null
onDismiss: () => void
}) {
if (!state) return null
const running = state.status === 'preparing' || state.status === 'running'
const percent = state.total > 0 ? Math.round((state.processed / state.total) * 100) : 0
const Icon = running ? LoaderCircle : state.status === 'complete' ? CheckCircle2 : AlertTriangle
return (
<aside className="supplier-import-progress" aria-live="polite">
<header>
<Icon size={17} className={running ? 'is-spinning' : ''} />
<div>
<strong>{running ? '正在导入供应商信息' : state.status === 'complete' ? '导入完成' : '导入失败'}</strong>
<span title={state.fileName}>{state.fileName}</span>
</div>
{!running ? <button type="button" aria-label="关闭导入结果" onClick={onDismiss}><X size={15} /></button> : null}
</header>
<div className="supplier-import-track"><span style={{ width: `${percent}%` }} /></div>
<div className="supplier-import-stats">
<span>{state.processed}/{state.total || '-'}</span>
<span>成功 {state.updated}</span>
<span className={state.failed > 0 ? 'has-error' : ''}>失败 {state.failed}</span>
</div>
{state.message ? <p className="supplier-import-message">{state.message}</p> : null}
{state.errors.length > 0 ? (
<div className="supplier-import-errors">
{state.errors.map((error) => (
<div key={`${error.rowNumber}-${error.barcode}-${error.message}`}>
<b>第 {error.rowNumber} 行</b>
<span>{error.barcode || '未填写条码'}</span>
<p>{error.message}</p>
</div>
))}
</div>
) : null}
<style jsx>{`
.supplier-import-progress { position: fixed; right: 22px; bottom: 22px; z-index: 120; width: min(440px,calc(100vw - 32px)); max-height: min(520px,calc(100vh - 44px)); overflow: auto; border: 1px solid #d4d4d8; border-radius: 8px; padding: 15px; background: #fff; box-shadow: 0 18px 50px rgba(24,24,27,.18); color: #27272a; }
header { display: flex; align-items: flex-start; gap: 9px; }
header > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
header strong { font-size: 13px; }
header span { overflow: hidden; color: #71717a; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
header button { display: grid; width: 26px; height: 26px; place-items: center; border: 0; background: transparent; color: #71717a; cursor: pointer; }
:global(.is-spinning) { animation: supplier-spin .8s linear infinite; }
.supplier-import-track { height: 5px; margin-top: 13px; overflow: hidden; border-radius: 5px; background: #e4e4e7; }
.supplier-import-track span { display: block; height: 100%; background: #3f3f46; transition: width .15s ease; }
.supplier-import-stats { display: flex; gap: 14px; margin-top: 9px; color: #52525b; font-size: 11px; }
.supplier-import-stats .has-error, .supplier-import-message { color: #b91c1c; }
.supplier-import-message { margin: 10px 0 0; font-size: 12px; }
.supplier-import-errors { display: flex; max-height: 260px; flex-direction: column; gap: 7px; margin-top: 11px; overflow-y: auto; }
.supplier-import-errors > div { display: grid; grid-template-columns: 72px minmax(80px,130px) 1fr; gap: 7px; border-top: 1px solid #f0f0f1; padding-top: 7px; font-size: 11px; }
.supplier-import-errors b { color: #52525b; }
.supplier-import-errors span { overflow: hidden; color: #71717a; text-overflow: ellipsis; white-space: nowrap; }
.supplier-import-errors p { margin: 0; color: #b91c1c; }
@keyframes supplier-spin { to { transform: rotate(360deg); } }
`}</style>
</aside>
)
}

View File

@ -1,89 +1,67 @@
'use client'
import { Pencil } from 'lucide-react'
import { History, Pencil } from 'lucide-react'
import type { SupplyChainSupplierInformationRow } from '@/lib/supply-chain-supplier-information'
function ValueCell({ values }: { values: readonly string[] }) {
if (values.length === 0) return <span className="supplier-empty-value">-</span>
if (values.length === 1) return <span className="supplier-single-value">{values[0]}</span>
return (
<details className="supplier-multi-value">
<summary>{values.length} 项</summary>
<div className="supplier-multi-list">
{values.map((value) => <span key={value}>{value}</span>)}
</div>
</details>
)
}
const COLUMNS: Array<{ key: keyof SupplyChainSupplierInformationRow; label: string }> = [
{ key: 'barcode', label: '条码' },
{ key: 'purchaseOwner', label: '采购' },
{ key: 'supplierName', label: '供应商' },
{ key: 'minimumOrderQuantity', label: '产品起订量' },
{ key: 'firstLeadTime', label: '首单生产周期' },
{ key: 'stableLeadTime', label: '稳定生产周期' },
{ key: 'palletSpec', label: '托盘参数' },
{ key: 'backupSupplier', label: '替换供应商' },
{ key: 'supplierType', label: '供应商类型' },
]
export function SupplierInformationTable({
rows,
startIndex,
canEdit,
onEdit,
onHistory,
}: {
rows: SupplyChainSupplierInformationRow[]
startIndex: number
canEdit: boolean
onEdit: (row: SupplyChainSupplierInformationRow) => void
onHistory: (row: SupplyChainSupplierInformationRow) => void
}) {
return (
<div className="supplier-table-wrap">
<table className="supplier-table">
<colgroup>
<col style={{ width: 58 }} />
<col style={{ width: 120 }} />
<col style={{ width: 220 }} />
<col style={{ width: 140 }} />
<col style={{ width: 150 }} />
<col style={{ width: 150 }} />
<col style={{ width: 140 }} />
<col style={{ width: 170 }} />
<col style={{ width: 140 }} />
{canEdit ? <col style={{ width: 84 }} /> : null}
{COLUMNS.map((column) => <col key={column.key} style={{ width: column.key === 'barcode' ? 170 : column.key === 'supplierName' ? 220 : 150 }} />)}
<col style={{ width: canEdit ? 150 : 80 }} />
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>采购</th>
<th>供应商</th>
<th>产品起订量</th>
<th>首单生产周期</th>
<th>稳定生产周期</th>
<th>托盘参数</th>
<th>替换供应商</th>
<th>供应商类型</th>
{canEdit ? <th>操作</th> : null}
{COLUMNS.map((column) => <th key={column.key}>{column.label}</th>)}
<th>操作</th>
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={row.supplierKey}>
<td className="supplier-index-cell">{startIndex + rowIndex + 1}</td>
<td><ValueCell values={row.purchaserValues} /></td>
<td className="supplier-name-cell">
<strong>{row.supplierName}</strong>
<span>{row.barcodeCount} 个条码</span>
</td>
<td><ValueCell values={row.minimumOrderQuantityValues} /></td>
<td><ValueCell values={row.firstLeadTimeValues} /></td>
<td><ValueCell values={row.stableLeadTimeValues} /></td>
<td><ValueCell values={row.palletSpecValues} /></td>
<td><ValueCell values={row.backupSupplierValues} /></td>
<td><ValueCell values={row.supplierTypeValues} /></td>
{canEdit ? (
<td className="supplier-action-cell">
<button
type="button"
title={`编辑 ${row.supplierName}`}
onClick={() => onEdit(row)}
>
<Pencil size={13} />
编辑
</button>
{rows.map((row, index) => (
<tr key={row.id}>
<td className="supplier-index-cell">{startIndex + index + 1}</td>
{COLUMNS.map((column) => (
<td key={column.key} className={column.key === 'barcode' ? 'barcode-cell' : ''}>
{row[column.key] ? <span title={String(row[column.key])}>{String(row[column.key])}</span> : <span className="supplier-empty-value">-</span>}
</td>
) : null}
))}
<td className="supplier-action-cell">
<button type="button" title={`查看 ${row.barcode} 的修改日志`} onClick={() => onHistory(row)}>
<History size={13} /> 日志
</button>
{canEdit ? (
<button type="button" title={`编辑 ${row.barcode}`} onClick={() => onEdit(row)}>
<Pencil size={13} /> 编辑
</button>
) : null}
</td>
</tr>
))}
</tbody>
@ -91,101 +69,19 @@ export function SupplierInformationTable({
{rows.length === 0 ? <div className="supplier-empty">暂无供应商数据</div> : null}
<style jsx>{`
.supplier-table-wrap {
min-height: 0;
flex: 1;
overflow: auto;
padding-bottom: 68px;
background: var(--sc-surface);
}
.supplier-table {
width: 100%;
min-width: 1288px;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
}
.supplier-table th {
position: sticky;
z-index: 2;
top: 0;
height: 42px;
padding: 0 12px;
border-right: 1px solid var(--sc-border);
border-bottom: 1px solid var(--sc-border);
background: var(--sc-surface-strong);
color: var(--sc-muted);
text-align: left;
font-weight: 650;
white-space: nowrap;
}
.supplier-table td {
height: 52px;
padding: 8px 12px;
border-right: 1px solid var(--sc-border);
border-bottom: 1px solid var(--sc-border);
background: var(--sc-surface);
vertical-align: middle;
}
.supplier-table-wrap { min-height: 0; flex: 1; overflow: auto; padding-bottom: 68px; background: var(--sc-surface); }
.supplier-table { width: 100%; min-width: 1550px; table-layout: fixed; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.supplier-table th { position: sticky; z-index: 2; top: 0; height: 42px; padding: 0 12px; border-right: 1px solid var(--sc-border); border-bottom: 1px solid var(--sc-border); background: var(--sc-surface-strong); color: var(--sc-muted); text-align: left; font-weight: 650; white-space: nowrap; }
.supplier-table td { height: 48px; padding: 8px 12px; border-right: 1px solid var(--sc-border); border-bottom: 1px solid var(--sc-border); background: var(--sc-surface); color: var(--sc-ink); vertical-align: middle; }
.supplier-table td > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.supplier-table tbody tr:hover td { background: var(--sc-hover); }
.supplier-index-cell { color: var(--sc-subtle); text-align: center; }
.supplier-name-cell strong,
.supplier-name-cell span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.supplier-name-cell strong { color: var(--sc-ink); font-size: 13px; font-weight: 650; }
.supplier-name-cell span { margin-top: 3px; color: var(--sc-subtle); font-size: 11px; }
.barcode-cell { color: #315f75; font-weight: 600; }
.supplier-empty-value { color: var(--sc-subtle); }
.supplier-single-value {
display: block;
overflow: hidden;
color: var(--sc-ink);
text-overflow: ellipsis;
white-space: nowrap;
}
.supplier-multi-value { position: relative; }
.supplier-multi-value summary {
width: fit-content;
cursor: pointer;
color: #315f75;
font-weight: 650;
list-style: none;
}
.supplier-multi-value summary::-webkit-details-marker { display: none; }
.supplier-multi-list { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.supplier-multi-list span {
display: inline-flex;
min-height: 22px;
align-items: center;
border: 1px solid var(--sc-border);
border-radius: 4px;
padding: 2px 6px;
background: var(--sc-surface-strong);
color: var(--sc-ink);
white-space: nowrap;
}
.supplier-action-cell button {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: #315f75;
font-size: 12px;
cursor: pointer;
}
.supplier-action-cell { white-space: nowrap; }
.supplier-action-cell button { display: inline-flex; align-items: center; gap: 4px; margin-right: 9px; border: 0; background: transparent; color: #315f75; font-size: 12px; cursor: pointer; }
.supplier-action-cell button:hover { color: #183b4b; }
.supplier-empty {
display: grid;
min-height: 240px;
place-items: center;
color: var(--sc-subtle);
font-size: 13px;
}
.supplier-empty { display: grid; min-height: 240px; place-items: center; color: var(--sc-subtle); font-size: 13px; }
`}</style>
</div>
)

View File

@ -1,8 +1,8 @@
'use client'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { ArrowLeft, ArrowRight, Download, Upload } from 'lucide-react'
import type {
SupplyChainSupplierInformationFilters,
SupplyChainSupplierInformationPage,
@ -10,7 +10,10 @@ import type {
} from '@/lib/supply-chain-supplier-information'
import { SupplierInformationEditDialog } from './SupplierInformationEditDialog'
import { SupplierInformationFilters } from './SupplierInformationFilters'
import { SupplierInformationHistoryDialog } from './SupplierInformationHistoryDialog'
import { SupplierInformationImportProgress } from './SupplierInformationImportProgress'
import { SupplierInformationTable } from './SupplierInformationTable'
import { useSupplierInformationImport } from './useSupplierInformationImport'
type SupplierInformationViewProps = {
data: SupplyChainSupplierInformationPage
@ -25,7 +28,11 @@ function pageHref(
) {
const params = new URLSearchParams()
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, value)
if (Array.isArray(value)) {
value.forEach((item) => params.append(key, item))
} else if (value) {
params.set(key, value)
}
})
if (page > 1) params.set('page', String(page))
if (pageSize !== 50) params.set('pageSize', String(pageSize))
@ -44,6 +51,10 @@ export function SupplierInformationView({
const { rows, pageInfo } = data
const [pageInput, setPageInput] = useState(String(pageInfo.page))
const [editingRow, setEditingRow] = useState<SupplyChainSupplierInformationRow | null>(null)
const [historyRow, setHistoryRow] = useState<SupplyChainSupplierInformationRow | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const refreshAfterImport = useCallback(() => router.refresh(), [router])
const supplierImport = useSupplierInformationImport(refreshAfterImport)
const hasPrevious = pageInfo.page > 1
const hasNext = pageInfo.page < pageInfo.totalPages
@ -71,20 +82,52 @@ export function SupplierInformationView({
<header className="supplier-toolbar">
<div className="supplier-title-wrap">
<h1>供应商信息</h1>
<span>共 {pageInfo.total} 家</span>
<span>共 {pageInfo.total} 条</span>
</div>
<div className="supplier-toolbar-actions">
<a href="/api/supply-chain/supplier-information/template">
<Download size={14} /> 下载模板
</a>
{canEdit ? (
<>
<input
ref={fileInputRef}
type="file"
hidden
accept=".xlsx,.xls"
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) void supplierImport.importFile(file)
}}
/>
<button
type="button"
disabled={supplierImport.isImporting}
onClick={() => fileInputRef.current?.click()}
>
<Upload size={14} /> {supplierImport.isImporting ? '导入中...' : '导入'}
</button>
</>
) : null}
</div>
</header>
<SupplierInformationFilters filters={filters} pageSize={pageInfo.pageSize} />
<SupplierInformationFilters
filters={filters}
suppliers={data.options.suppliers}
pageSize={pageInfo.pageSize}
/>
<SupplierInformationTable
rows={rows}
startIndex={(pageInfo.page - 1) * pageInfo.pageSize}
canEdit={canEdit}
onEdit={setEditingRow}
onHistory={setHistoryRow}
/>
<footer className="supplier-pagination">
<span className="supplier-page-total">共 {pageInfo.total} 家</span>
<span className="supplier-page-total">共 {pageInfo.total} 条</span>
<button
type="button"
className="supplier-page-btn"
@ -134,13 +177,13 @@ export function SupplierInformationView({
<option value={100}>100</option>
<option value={200}>200</option>
</select>
家
条
</span>
</footer>
{editingRow ? (
<SupplierInformationEditDialog
key={editingRow.supplierKey}
key={editingRow.barcode}
row={editingRow}
onClose={() => setEditingRow(null)}
onSaved={() => {
@ -149,6 +192,10 @@ export function SupplierInformationView({
}}
/>
) : null}
{historyRow ? (
<SupplierInformationHistoryDialog row={historyRow} onClose={() => setHistoryRow(null)} />
) : null}
<SupplierInformationImportProgress state={supplierImport.state} onDismiss={supplierImport.dismiss} />
<style jsx>{`
.supplier-information-page {
@ -176,6 +223,11 @@ export function SupplierInformationView({
}
.supplier-title-wrap h1 { margin: 0; font-size: 17px; font-weight: 700; }
.supplier-title-wrap span { color: var(--sc-subtle); font-size: 12px; }
.supplier-toolbar-actions { display: flex; align-items: center; gap: 8px; }
.supplier-toolbar-actions a,
.supplier-toolbar-actions button { display: inline-flex; height: 34px; align-items: center; gap: 5px; border: 1px solid var(--sc-border); border-radius: 5px; padding: 0 11px; background: var(--sc-surface); color: var(--sc-muted); font-size: 12px; text-decoration: none; cursor: pointer; }
.supplier-toolbar-actions button { border-color: #3f3f46; background: #3f3f46; color: #fff; }
.supplier-toolbar-actions button:disabled { cursor: wait; opacity: .55; }
.supplier-pagination {
position: absolute;
bottom: 24px;

View File

@ -0,0 +1,139 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Search, X } from 'lucide-react'
export function SupplierMultiSelect({
options,
value,
}: {
options: string[]
value: string[]
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [selected, setSelected] = useState(value)
const rootRef = useRef<HTMLDivElement>(null)
const selectedSet = useMemo(() => new Set(selected), [selected])
const filtered = useMemo(() => {
const keyword = search.trim().toLocaleLowerCase('zh-CN')
return keyword
? options.filter((option) => option.toLocaleLowerCase('zh-CN').includes(keyword))
: options
}, [options, search])
useEffect(() => {
if (!open) return
const closeOnOutside = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', closeOnOutside)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('mousedown', closeOnOutside)
document.removeEventListener('keydown', closeOnEscape)
}
}, [open])
const toggle = (option: string) => {
setSelected((current) => current.includes(option)
? current.filter((item) => item !== option)
: [...current, option])
}
return (
<label className="supplier-picker-label">
<span>供应商</span>
<div ref={rootRef} className="supplier-picker">
{selected.map((supplier) => (
<input key={supplier} type="hidden" name="suppliers" value={supplier} />
))}
<button
type="button"
className={`supplier-picker-trigger${open ? ' is-open' : ''}`}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span>{selected.length > 0 ? `已选 ${selected.length} 家供应商` : '选择供应商'}</span>
<ChevronDown size={14} />
</button>
{open ? (
<div className="supplier-picker-menu">
<div className="supplier-picker-search">
<Search size={14} />
<input
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="输入供应商名称搜索"
aria-label="搜索供应商"
/>
{search ? (
<button type="button" aria-label="清空供应商搜索" onClick={() => setSearch('')}>
<X size={13} />
</button>
) : null}
</div>
<div className="supplier-picker-summary">
<span>已选 {selected.length} 项</span>
{selected.length > 0 ? (
<button type="button" onClick={() => setSelected([])}>清空选择</button>
) : null}
</div>
<div className="supplier-picker-options" role="listbox" aria-multiselectable="true">
{filtered.length === 0 ? (
<div className="supplier-picker-empty">暂无匹配供应商</div>
) : filtered.map((option) => (
<button
type="button"
role="option"
aria-selected={selectedSet.has(option)}
className={selectedSet.has(option) ? 'is-selected' : ''}
key={option}
onClick={() => toggle(option)}
>
<span className="supplier-picker-check">
{selectedSet.has(option) ? <Check size={12} /> : null}
</span>
<span>{option}</span>
</button>
))}
</div>
<div className="supplier-picker-footer">
<button type="button" onClick={() => setOpen(false)}>完成</button>
</div>
</div>
) : null}
</div>
<style jsx>{`
.supplier-picker-label { position: relative; display: flex; min-width: 0; flex-direction: column; gap: 4px; color: var(--sc-muted); font-size: 11px; }
.supplier-picker { position: relative; }
.supplier-picker-trigger { display: flex; width: 100%; height: 32px; align-items: center; justify-content: space-between; gap: 6px; border: 1px solid var(--sc-border); border-radius: 4px; padding: 0 8px; background: var(--sc-surface); color: var(--sc-muted); font-size: 12px; cursor: pointer; }
.supplier-picker-trigger > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.supplier-picker-trigger.is-open { border-color: var(--sc-muted); color: var(--sc-ink); }
.supplier-picker-trigger :global(svg) { flex: 0 0 auto; transition: transform .15s ease; }
.supplier-picker-trigger.is-open :global(svg) { transform: rotate(180deg); }
.supplier-picker-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 80; width: 310px; max-width: calc(100vw - 32px); padding: 10px; border: 1px solid var(--sc-border); border-radius: 6px; background: var(--sc-surface); box-shadow: 0 12px 28px rgba(24,24,27,.14); }
.supplier-picker-search { display: flex; height: 32px; align-items: center; gap: 6px; border: 1px solid var(--sc-border); border-radius: 4px; padding: 0 8px; color: var(--sc-subtle); }
.supplier-picker-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--sc-ink); font-size: 12px; }
.supplier-picker-search button { display: grid; width: 20px; height: 20px; place-items: center; border: 0; padding: 0; background: transparent; color: var(--sc-subtle); cursor: pointer; }
.supplier-picker-summary { display: flex; min-height: 30px; align-items: center; justify-content: space-between; color: var(--sc-muted); font-size: 11px; }
.supplier-picker-summary button { border: 0; background: transparent; color: #315f75; font-size: 11px; cursor: pointer; }
.supplier-picker-options { display: flex; max-height: 250px; flex-direction: column; gap: 2px; overflow-y: auto; }
.supplier-picker-options > button { display: flex; min-height: 31px; align-items: center; gap: 7px; border: 0; border-radius: 4px; padding: 4px 6px; background: transparent; color: var(--sc-ink); font-size: 12px; text-align: left; cursor: pointer; }
.supplier-picker-options > button:hover, .supplier-picker-options > button.is-selected { background: var(--sc-hover); }
.supplier-picker-options > button > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.supplier-picker-check { display: grid; width: 15px; height: 15px; flex: 0 0 auto; place-items: center; border: 1px solid var(--sc-border); border-radius: 3px; }
.is-selected .supplier-picker-check { border-color: #3f3f46; background: #3f3f46; color: #fff; }
.supplier-picker-empty { padding: 28px 8px; color: var(--sc-subtle); text-align: center; }
.supplier-picker-footer { display: flex; justify-content: flex-end; padding-top: 9px; }
.supplier-picker-footer button { height: 28px; border: 1px solid #3f3f46; border-radius: 4px; padding: 0 11px; background: #3f3f46; color: #fff; font-size: 11px; cursor: pointer; }
`}</style>
</label>
)
}

View File

@ -38,7 +38,16 @@ export default async function SupplierInformationPage({
key,
firstValue(params[key]).trim().slice(0, 120),
]),
) as SupplyChainSupplierInformationFilters
) as Omit<SupplyChainSupplierInformationFilters, 'suppliers'>
const supplierValues = Array.isArray(params.suppliers)
? params.suppliers
: params.suppliers ? [params.suppliers] : []
const normalizedFilters: SupplyChainSupplierInformationFilters = {
...filters,
suppliers: supplierValues
.map((value) => value.trim().slice(0, 180))
.filter(Boolean),
}
const parsedPage = Number(firstValue(params.page))
const page = Number.isFinite(parsedPage) ? Math.max(1, Math.trunc(parsedPage)) : 1
const parsedPageSize = Number(firstValue(params.pageSize))
@ -46,8 +55,8 @@ export default async function SupplierInformationPage({
const data = await supplyChainSupplierInformationService.listPage({
page,
pageSize,
filters,
filters: normalizedFilters,
})
return <SupplierInformationView data={data} filters={filters} canEdit={access.edit} />
return <SupplierInformationView data={data} filters={normalizedFilters} canEdit={access.edit} />
}

View File

@ -0,0 +1,33 @@
import { readSupplierInformationImportStream } from './supplierInformationImportStream'
function streamResponse(lines: string[]) {
const encoder = new TextEncoder()
let index = 0
return {
ok: true,
body: {
getReader() {
return {
async read() {
if (index >= lines.length) return { done: true, value: undefined }
return { done: false, value: encoder.encode(lines[index++]) }
},
}
},
},
} as unknown as Response
}
describe('readSupplierInformationImportStream', () => {
it('reads chunked progress and retains failed row details', async () => {
const events: unknown[] = []
const result = await readSupplierInformationImportStream(streamResponse([
'{"type":"progress","progress":{"processed":1,"total":2,"updated":1,"failed":0,"errors":[]}}\n',
'{"type":"complete","progress":{"processed":2,"total":2,"updated":1,"failed":1,"errors":[{"rowNumber":3,"barcode":"UNKNOWN","message":"条码不存在"}]}}\n',
]), (event) => events.push(event))
expect(result.failed).toBe(1)
expect(result.errors[0]).toEqual(expect.objectContaining({ rowNumber: 3, barcode: 'UNKNOWN' }))
expect(events).toHaveLength(2)
})
})

View File

@ -0,0 +1,58 @@
import type {
SupplyChainSupplierInformationImportProgress,
SupplyChainSupplierInformationImportStreamEvent,
} from '@/lib/supply-chain-supplier-information'
function parseProgress(value: unknown): SupplyChainSupplierInformationImportProgress {
if (!value || typeof value !== 'object') throw new Error('导入进度响应格式不正确')
const progress = value as Record<string, unknown>
if (!['processed', 'total', 'updated', 'failed'].every((key) => typeof progress[key] === 'number')) {
throw new Error('导入进度响应格式不正确')
}
if (!Array.isArray(progress.errors)) throw new Error('导入进度响应格式不正确')
return progress as SupplyChainSupplierInformationImportProgress
}
function parseEvent(line: string): SupplyChainSupplierInformationImportStreamEvent {
const value = JSON.parse(line) as Record<string, unknown>
const progress = parseProgress(value.progress)
if (value.type === 'progress' || value.type === 'complete') return { type: value.type, progress }
if (value.type === 'error' && typeof value.message === 'string') {
return { type: 'error', progress, message: value.message }
}
throw new Error('导入进度响应格式不正确')
}
export async function readSupplierInformationImportStream(
response: Response,
onEvent: (event: SupplyChainSupplierInformationImportStreamEvent) => 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: SupplyChainSupplierInformationImportProgress | 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,68 @@
'use client'
import { useCallback, useState } from 'react'
import type {
SupplyChainSupplierInformationImportProgress,
SupplyChainSupplierInformationImportStreamEvent,
} from '@/lib/supply-chain-supplier-information'
import { readSupplierInformationImportStream } from './supplierInformationImportStream'
export type SupplierInformationImportState = SupplyChainSupplierInformationImportProgress & {
fileName: string
status: 'preparing' | 'running' | 'complete' | 'error'
message?: string
}
const EMPTY: SupplyChainSupplierInformationImportProgress = {
processed: 0,
total: 0,
updated: 0,
failed: 0,
errors: [],
}
export function useSupplierInformationImport(onCompleted: () => void) {
const [state, setState] = useState<SupplierInformationImportState | 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/supplier-information/import', {
method: 'POST',
body,
})
await readSupplierInformationImportStream(
response,
(event: SupplyChainSupplierInformationImportStreamEvent) => {
setState({
...event.progress,
fileName: file.name,
status: event.type === 'progress' ? 'running' : event.type,
...(event.type === 'error' ? { message: event.message } : {}),
})
},
)
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,51 @@
export type BarcodePrintImportEditableField =
| "spu"
| "model"
| "skuCode"
| "barcode"
| "factoryModel"
| "barcodeSpecName"
| "productCategory"
| "salesCategory"
| "size"
| "color"
| "executeStandard"
| "material"
| "tagMaterial"
| "supplierName"
| "createdAt"
| "brandSkuCode"
| "productName"
| "materialNo"
| "specification"
| "certificateMaterial";
export const BARCODE_PRINT_IMPORT_EDIT_FIELDS: Array<{
key: BarcodePrintImportEditableField;
label: string;
}> = [
{ key: "spu", label: "SPU" },
{ key: "model", label: "型号" },
{ key: "skuCode", label: "货号" },
{ key: "barcode", label: "条码" },
{ key: "factoryModel", label: "工厂型号" },
{ key: "barcodeSpecName", label: "规格名称" },
{ key: "productCategory", label: "商品类目" },
{ key: "salesCategory", label: "销售类目" },
{ key: "size", label: "尺寸" },
{ key: "color", label: "颜色" },
{ key: "executeStandard", label: "执行标准" },
{ key: "material", label: "主材" },
{ key: "tagMaterial", label: "吊牌材质" },
{ key: "supplierName", label: "供应商名称" },
{ key: "createdAt", label: "创建时间" },
{ key: "brandSkuCode", label: "品牌货号" },
{ key: "productName", label: "品名" },
{ key: "materialNo", label: "料号" },
{ key: "specification", label: "规格(长*宽*高)" },
{ key: "certificateMaterial", label: "合格证的主材*基材" },
];
export type BarcodePrintImportEditFields = Partial<
Record<BarcodePrintImportEditableField, string>
>;

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

@ -0,0 +1,89 @@
import {
createDefaultInstallationHingeMotionConfig,
normalizeInstallationToolMotionConfig,
resolveInstallationHingeJointAngles,
} from './installation-demo-tool-motion'
describe('installation demo tool hinge motion', () => {
it('assigns the three imported hinge nodes to their expected roles', () => {
const config = createDefaultInstallationHingeMotionConfig([
'asset_000',
'asset_001',
'asset_002',
])
expect(config.baseNodeName).toBe('asset_000')
expect(config.linkNodeName).toBe('asset_002')
expect(config.leafNodeName).toBe('asset_001')
expect(config.primaryJoint.middleAngleDegrees).toBe(16)
expect(config.primaryJoint.closedAngleDegrees).toBe(32)
expect(config.secondaryJoint.middleAngleDegrees).toBe(-61)
expect(config.secondaryJoint.closedAngleDegrees).toBe(-122)
})
it('normalizes hinge pivots, axes and angles', () => {
const config = normalizeInstallationToolMotionConfig({
motionType: 'hinge',
articulationConfig: {
version: 1,
baseNodeName: ' base ',
linkNodeName: 'link',
leafNodeName: 'leaf',
primaryJoint: {
pivot: [-1, 0.5, 2],
axis: [0, 0, 0],
middleAngleDegrees: 240,
closedAngleDegrees: -240,
},
secondaryJoint: {
pivot: [0.2, 0.3, 0.4],
axis: [2, 0, 0],
middleAngleDegrees: 20,
closedAngleDegrees: 80,
},
},
})
expect(config.articulationConfig).toMatchObject({
baseNodeName: 'base',
primaryJoint: {
pivot: [0, 0.5, 1],
axis: [1, 0, 0],
middleAngleDegrees: 180,
closedAngleDegrees: -180,
},
})
})
it('uses the authored middle pose during piecewise interpolation', () => {
const config = createDefaultInstallationHingeMotionConfig([
'asset_000',
'asset_001',
'asset_002',
])
config.primaryJoint.middleAngleDegrees = 20
config.primaryJoint.closedAngleDegrees = 80
expect(resolveInstallationHingeJointAngles(config, 0.25).primaryAngleDegrees).toBe(10)
expect(resolveInstallationHingeJointAngles(config, 0.5).primaryAngleDegrees).toBe(20)
expect(resolveInstallationHingeJointAngles(config, 0.75).primaryAngleDegrees).toBe(50)
})
it('moves both joints together while preserving the final 90-degree leaf pose', () => {
const config = createDefaultInstallationHingeMotionConfig([
'asset_000',
'asset_001',
'asset_002',
])
const quarter = resolveInstallationHingeJointAngles(config, 0.25)
const middle = resolveInstallationHingeJointAngles(config, 0.5)
const threeQuarter = resolveInstallationHingeJointAngles(config, 0.75)
const closed = resolveInstallationHingeJointAngles(config, 1)
expect(quarter).toEqual({ primaryAngleDegrees: 8, secondaryAngleDegrees: -30.5 })
expect(middle).toEqual({ primaryAngleDegrees: 16, secondaryAngleDegrees: -61 })
expect(threeQuarter).toEqual({ primaryAngleDegrees: 24, secondaryAngleDegrees: -91.5 })
expect(closed.primaryAngleDegrees + closed.secondaryAngleDegrees).toBe(-90)
})
})

View File

@ -1,6 +1,23 @@
export type InstallationToolMotionType = 'spin' | 'strike' | 'follow'
export type InstallationToolMotionType = 'spin' | 'strike' | 'follow' | 'hinge'
export type InstallationToolMotionVector3 = [number, number, number]
export type InstallationHingeJointKey = 'primary' | 'secondary'
export interface InstallationHingeJointConfig {
pivot: InstallationToolMotionVector3 | null
axis: InstallationToolMotionVector3 | null
middleAngleDegrees: number
closedAngleDegrees: number
}
export interface InstallationHingeMotionConfig {
version: 1
baseNodeName: string | null
linkNodeName: string | null
leafNodeName: string | null
primaryJoint: InstallationHingeJointConfig
secondaryJoint: InstallationHingeJointConfig
}
export interface InstallationToolMotionConfig {
motionType: InstallationToolMotionType
@ -8,6 +25,21 @@ export interface InstallationToolMotionConfig {
motionAxis: InstallationToolMotionVector3 | null
spinTurns: number
strikeDistanceRatio: number
articulationConfig: InstallationHingeMotionConfig | null
}
const DEFAULT_PRIMARY_JOINT: InstallationHingeJointConfig = {
pivot: [0.5, 0.38, 0.68],
axis: [1, 0, 0],
middleAngleDegrees: 16,
closedAngleDegrees: 32,
}
const DEFAULT_SECONDARY_JOINT: InstallationHingeJointConfig = {
pivot: [0.5, 0.29, 0.58],
axis: [1, 0, 0],
middleAngleDegrees: -61,
closedAngleDegrees: -122,
}
export const DEFAULT_INSTALLATION_TOOL_MOTION: InstallationToolMotionConfig = {
@ -16,10 +48,11 @@ export const DEFAULT_INSTALLATION_TOOL_MOTION: InstallationToolMotionConfig = {
motionAxis: null,
spinTurns: 3,
strikeDistanceRatio: 0.2,
articulationConfig: null,
}
export function isInstallationToolMotionType(value: unknown): value is InstallationToolMotionType {
return value === 'spin' || value === 'strike' || value === 'follow'
return value === 'spin' || value === 'strike' || value === 'follow' || value === 'hinge'
}
export function normalizeInstallationToolMotionVector(
@ -39,6 +72,77 @@ export function normalizeInstallationToolMotionVector(
return vector.map((item) => Math.min(Math.max(item, 0), 1)) as InstallationToolMotionVector3
}
function normalizeNodeName(value: unknown) {
const normalized = typeof value === 'string' ? value.trim() : ''
return normalized || null
}
function normalizeAngle(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value)
? Math.min(Math.max(value, -180), 180)
: fallback
}
function normalizeHingeJoint(
value: unknown,
fallback: InstallationHingeJointConfig,
): InstallationHingeJointConfig {
const source = value && typeof value === 'object'
? value as Record<string, unknown>
: {}
const axis = normalizeInstallationToolMotionVector(source.axis)
return {
pivot: normalizeInstallationToolMotionVector(source.pivot, { clampUnitRange: true })
?? [...fallback.pivot!] as InstallationToolMotionVector3,
axis: axis && axis.some((component) => Math.abs(component) > 1e-8)
? axis
: [...fallback.axis!] as InstallationToolMotionVector3,
middleAngleDegrees: normalizeAngle(
source.middleAngleDegrees,
fallback.middleAngleDegrees,
),
closedAngleDegrees: normalizeAngle(
source.closedAngleDegrees,
fallback.closedAngleDegrees,
),
}
}
export function createDefaultInstallationHingeMotionConfig(
nodeNames: string[],
): InstallationHingeMotionConfig {
const uniqueNodeNames = Array.from(new Set(nodeNames.map((name) => name.trim()).filter(Boolean)))
const byName = new Map(uniqueNodeNames.map((name) => [name.toLowerCase(), name]))
const resolve = (preferred: string, fallbackIndex: number) =>
byName.get(preferred) ?? uniqueNodeNames[fallbackIndex] ?? null
return {
version: 1,
baseNodeName: resolve('asset_000', 0),
linkNodeName: resolve('asset_002', 1),
leafNodeName: resolve('asset_001', 2),
primaryJoint: { ...DEFAULT_PRIMARY_JOINT, pivot: [...DEFAULT_PRIMARY_JOINT.pivot!] },
secondaryJoint: { ...DEFAULT_SECONDARY_JOINT, pivot: [...DEFAULT_SECONDARY_JOINT.pivot!] },
}
}
export function normalizeInstallationHingeMotionConfig(
value: unknown,
): InstallationHingeMotionConfig | null {
if (!value || typeof value !== 'object') return null
const source = value as Record<string, unknown>
return {
version: 1,
baseNodeName: normalizeNodeName(source.baseNodeName),
linkNodeName: normalizeNodeName(source.linkNodeName),
leafNodeName: normalizeNodeName(source.leafNodeName),
primaryJoint: normalizeHingeJoint(source.primaryJoint, DEFAULT_PRIMARY_JOINT),
secondaryJoint: normalizeHingeJoint(source.secondaryJoint, DEFAULT_SECONDARY_JOINT),
}
}
export function normalizeInstallationToolMotionConfig(
value: Partial<InstallationToolMotionConfig> | null | undefined,
): InstallationToolMotionConfig {
@ -62,5 +166,25 @@ export function normalizeInstallationToolMotionConfig(
motionAxis: normalizeInstallationToolMotionVector(value?.motionAxis),
spinTurns,
strikeDistanceRatio,
articulationConfig: normalizeInstallationHingeMotionConfig(value?.articulationConfig),
}
}
export function resolveInstallationHingeJointAngles(
config: InstallationHingeMotionConfig,
progress: number,
) {
const normalizedProgress = Math.min(Math.max(progress, 0), 1)
const interpolate = (joint: InstallationHingeJointConfig) => {
if (normalizedProgress <= 0.5) {
return joint.middleAngleDegrees * normalizedProgress * 2
}
return joint.middleAngleDegrees +
(joint.closedAngleDegrees - joint.middleAngleDegrees) * (normalizedProgress - 0.5) * 2
}
return {
primaryAngleDegrees: interpolate(config.primaryJoint),
secondaryAngleDegrees: interpolate(config.secondaryJoint),
}
}

View File

@ -0,0 +1,111 @@
import * as XLSX from "xlsx";
import {
NON_INTEGRATED_SKU_TEMPLATE_COLUMNS,
parseNonIntegratedSkuWorkbook,
} from "@/lib/non-integrated-sku-export";
function createWorkbookFile(rows: unknown[][], name = "sku-import.xlsx") {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.aoa_to_sheet(rows),
"供应商资料",
);
const bytes = XLSX.write(workbook, { bookType: "xlsx", type: "array" }) as ArrayBuffer;
const file = new File([bytes], name, {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
Object.defineProperty(file, "arrayBuffer", {
value: async () => bytes,
});
return file;
}
describe("parseNonIntegratedSkuWorkbook", () => {
it("recognizes the five-field supplier workbook regardless of column order", async () => {
const file = createWorkbookFile([
[
"产品图片",
"安装方式",
"供应商(名称必须跟系统里面的一样)",
"材质",
"产品名称",
"颜色",
"尺寸",
],
[
"image-a.png",
"整装",
"供应商甲",
"实木",
"床头柜",
"胡桃色",
"120×60×75cm",
],
["", "拆装", "供应商乙", "板材", "", "白色", "80×40×45cm"],
]);
await expect(parseNonIntegratedSkuWorkbook(file)).resolves.toEqual({
mode: "fuzzy",
sheetName: "供应商资料",
rows: [
{
installMethod: "整装",
manufacturer: "供应商甲",
material: "实木",
color: "胡桃色",
size: "120×60×75cm",
rowNumber: 2,
},
{
installMethod: "拆装",
manufacturer: "供应商乙",
material: "板材",
color: "白色",
size: "80×40×45cm",
rowNumber: 3,
},
],
});
});
it("keeps the strict standard template path", async () => {
const headers = NON_INTEGRATED_SKU_TEMPLATE_COLUMNS.map(
(column) => column.label,
);
const values = headers.map((header) =>
header === "SPU"
? "SZ0230"
: header === "货号"
? "SZ023001"
: header === "条码"
? "SZ023001N1280V01"
: "",
);
await expect(
parseNonIntegratedSkuWorkbook(
createWorkbookFile([headers, values], "standard.xlsx"),
),
).resolves.toEqual({
mode: "standard",
sheetName: "供应商资料",
rows: [
expect.objectContaining({
spu: "SZ0230",
skuCode: "SZ023001",
barcode: "SZ023001N1280V01",
}),
],
});
});
it("reports missing fuzzy headers clearly", async () => {
const file = createWorkbookFile([["供应商", "尺寸", "颜色"]]);
await expect(parseNonIntegratedSkuWorkbook(file)).rejects.toThrow(
"模糊导入至少需要供应商、尺寸、颜色、材质、安装方式 5 个表头,缺少:材质、安装方式",
);
});
});

View File

@ -26,9 +26,38 @@ export type NonIntegratedSkuExportRow = {
dingTalkFlowNo: string
}
export type NonIntegratedSkuFuzzyImportRow = {
size: string
color: string
material: string
installMethod: string
manufacturer: string
rowNumber: number
}
export type NonIntegratedSkuWorkbookImport =
| {
mode: "standard"
rows: NonIntegratedSkuExportRow[]
sheetName: string
}
| {
mode: "fuzzy"
rows: NonIntegratedSkuFuzzyImportRow[]
sheetName: string
}
const EXCEL_MIME_TYPE =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
const FUZZY_IMPORT_COLUMNS = [
{ key: "manufacturer", label: "供应商" },
{ key: "size", label: "尺寸" },
{ key: "color", label: "颜色" },
{ key: "material", label: "材质" },
{ key: "installMethod", label: "安装方式" },
] as const
export const NON_INTEGRATED_SKU_WORKBOOK_ACCEPT =
'.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@ -229,13 +258,38 @@ function normalizeWorkbookCellValue(value: unknown) {
return String(value).trim()
}
function normalizeWorkbookHeaderValue(value: string) {
return value.replace(/\s+/g, "").trim()
}
function getFuzzyImportColumnKey(value: string) {
const normalizedValue = normalizeWorkbookHeaderValue(value)
if (!normalizedValue) return null
if (
normalizedValue === "供应商" ||
normalizedValue === "供应商名称" ||
/^供应商[((].*[))]$/.test(normalizedValue)
) {
return "manufacturer"
}
return (
FUZZY_IMPORT_COLUMNS.find(
(column) => column.key !== "manufacturer" && column.label === normalizedValue,
)?.key ?? null
)
}
function createEmptyImportRow(): NonIntegratedSkuExportRow {
return Object.fromEntries(
NON_INTEGRATED_SKU_TEMPLATE_COLUMNS.map((column) => [column.key, '']),
) as NonIntegratedSkuExportRow
}
export async function parseNonIntegratedSkuWorkbook(file: File) {
export async function parseNonIntegratedSkuWorkbook(
file: File,
): Promise<NonIntegratedSkuWorkbookImport> {
const XLSX = await import('xlsx')
const bytes = await file.arrayBuffer()
const workbook = XLSX.read(bytes, { type: 'array', cellDates: false })
@ -272,31 +326,91 @@ export async function parseNonIntegratedSkuWorkbook(file: File) {
(column) =>
!optionalLabels.has(column.label) && !headerIndexByLabel.has(column.label),
).map((column) => column.label)
if (missingLabels.length > 0) {
const fuzzyHeaderIndexByKey = new Map<string, number>()
headerCells.forEach((label, index) => {
const key = getFuzzyImportColumnKey(label)
if (key && !fuzzyHeaderIndexByKey.has(key)) {
fuzzyHeaderIndexByKey.set(key, index)
}
})
const fuzzyMissingColumns = FUZZY_IMPORT_COLUMNS.filter(
(column) => !fuzzyHeaderIndexByKey.has(column.key),
)
if (missingLabels.length === 0) {
const rows = sheetRows
.slice(headerRowIndex + 1)
.map((row) => {
const importRow = createEmptyImportRow()
for (const column of NON_INTEGRATED_SKU_TEMPLATE_COLUMNS) {
const cellIndex = headerIndexByLabel.get(column.label)
importRow[column.key] =
cellIndex === undefined
? ''
: normalizeWorkbookCellValue(row[cellIndex])
}
return importRow
})
.filter((row) =>
NON_INTEGRATED_SKU_TEMPLATE_COLUMNS.some(
(column) => row[column.key].trim().length > 0,
),
)
return {
mode: "standard" as const,
rows,
sheetName,
}
}
if (fuzzyMissingColumns.length === 0) {
const rows = sheetRows
.slice(headerRowIndex + 1)
.map((row, index) => ({
size: normalizeWorkbookCellValue(
row[fuzzyHeaderIndexByKey.get("size") ?? -1],
),
color: normalizeWorkbookCellValue(
row[fuzzyHeaderIndexByKey.get("color") ?? -1],
),
material: normalizeWorkbookCellValue(
row[fuzzyHeaderIndexByKey.get("material") ?? -1],
),
installMethod: normalizeWorkbookCellValue(
row[fuzzyHeaderIndexByKey.get("installMethod") ?? -1],
),
manufacturer: normalizeWorkbookCellValue(
row[fuzzyHeaderIndexByKey.get("manufacturer") ?? -1],
),
rowNumber: headerRowIndex + index + 2,
}))
.filter((row) =>
[
row.size,
row.color,
row.material,
row.installMethod,
row.manufacturer,
].some((value) => value.length > 0),
)
return {
mode: "fuzzy" as const,
rows,
sheetName,
}
}
if (fuzzyHeaderIndexByKey.size > 0) {
throw new Error(
`上传资料表头和下载资料模板不一致,缺少:${missingLabels.join('、')}`,
`模糊导入至少需要供应商、尺寸、颜色、材质、安装方式 5 个表头,缺少:${fuzzyMissingColumns
.map((column) => column.label)
.join("、")}`,
)
}
const rows = sheetRows
.slice(headerRowIndex + 1)
.map((row) => {
const importRow = createEmptyImportRow()
for (const column of NON_INTEGRATED_SKU_TEMPLATE_COLUMNS) {
const cellIndex = headerIndexByLabel.get(column.label)
importRow[column.key] =
cellIndex === undefined ? '' : normalizeWorkbookCellValue(row[cellIndex])
}
return importRow
})
.filter((row) =>
NON_INTEGRATED_SKU_TEMPLATE_COLUMNS.some(
(column) => row[column.key].trim().length > 0,
),
)
return {
rows,
sheetName,
}
throw new Error(
`上传资料表头和下载资料模板不一致,缺少:${missingLabels.join('、')}`,
)
}

View File

@ -321,6 +321,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
@ -351,6 +352,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

@ -1,19 +1,19 @@
export type SupplyChainSupplierInformationRow = {
supplierKey: string
id: string
barcode: string
purchaseOwner: string
supplierName: string
barcodeCount: number
purchaserValues: string[]
minimumOrderQuantityValues: string[]
firstLeadTimeValues: string[]
stableLeadTimeValues: string[]
palletSpecValues: string[]
backupSupplierValues: string[]
supplierTypeValues: string[]
minimumOrderQuantity: string
firstLeadTime: string
stableLeadTime: string
palletSpec: string
backupSupplier: string
supplierType: string
}
export const SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS = [
'barcode',
'purchaser',
'supplier',
'minimumOrderQuantity',
'firstLeadTime',
'stableLeadTime',
@ -25,15 +25,16 @@ export const SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS = [
export type SupplyChainSupplierInformationFilterKey =
(typeof SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS)[number]
export type SupplyChainSupplierInformationFilters = Record<
SupplyChainSupplierInformationFilterKey,
string
>
export type SupplyChainSupplierInformationFilters =
Record<SupplyChainSupplierInformationFilterKey, string> & {
suppliers: string[]
}
export const EMPTY_SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTERS:
SupplyChainSupplierInformationFilters = {
barcode: '',
purchaser: '',
supplier: '',
suppliers: [],
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
@ -61,9 +62,8 @@ export type SupplyChainSupplierInformationUpdates = Partial<
>
export type SupplyChainSupplierInformationUpdateResult = {
affectedBarcodes: number
barcode: string
changedValues: number
supplierKey: string
}
export type SupplyChainSupplierInformationPage = {
@ -74,4 +74,57 @@ export type SupplyChainSupplierInformationPage = {
total: number
totalPages: number
}
options: {
suppliers: string[]
}
}
export const SUPPLY_CHAIN_SUPPLIER_INFORMATION_IMPORT_FIELDS = [
{ key: 'barcode', label: '条码', required: true },
{ key: 'purchaseOwner', label: '采购' },
{ key: 'supplierName', label: '供应商' },
{ key: 'minimumOrderQuantity', label: '产品起订量' },
{ key: 'firstLeadTime', label: '首单生产周期' },
{ key: 'stableLeadTime', label: '稳定生产周期' },
{ key: 'palletSpec', label: '托盘参数' },
{ key: 'backupSupplier', label: '替换供应商' },
{ key: 'supplierType', label: '供应商类型' },
] as const
export type SupplyChainSupplierInformationImportRow = {
rowNumber: number
barcode: string
updates: SupplyChainSupplierInformationUpdates
}
export type SupplyChainSupplierInformationImportError = {
rowNumber: number
barcode: string
message: string
}
export type SupplyChainSupplierInformationImportProgress = {
processed: number
total: number
updated: number
failed: number
errors: SupplyChainSupplierInformationImportError[]
}
export type SupplyChainSupplierInformationImportStreamEvent =
| { type: 'progress' | 'complete'; progress: SupplyChainSupplierInformationImportProgress }
| {
type: 'error'
progress: SupplyChainSupplierInformationImportProgress
message: string
}
export type SupplyChainSupplierInformationHistoryItem = {
id: string
actorName: string
field: string
fieldLabel: string
previousValue: string
nextValue: string
createdAt: string
}

View File

@ -147,6 +147,7 @@ describe('supplier request boundary', () => {
['GET', '/api/intake/barcode-print', true],
['POST', '/api/intake/barcode-print', true],
['DELETE', '/api/intake/barcode-print', true],
['PATCH', '/api/intake/barcode-print', true],
['GET', '/api/intake/node-permission-settings', false],
['GET', '/api/intake/non-integrated', false],
['GET', '/api/intake/non-integrated/product-1', false],

View File

@ -213,7 +213,7 @@ export function isSupplierAllowedApiRequest(request: NextRequest) {
if (pathname.startsWith('/api/auth/')) return true
if (pathname === '/api/intake/barcode-print') {
return method === 'GET' || method === 'POST' || method === 'DELETE'
return method === 'GET' || method === 'POST' || method === 'DELETE' || method === 'PATCH'
}
return false
}

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

@ -1,5 +1,9 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
supplyChainProductArchive: {
findMany: jest.fn(),
count: jest.fn(),
},
$queryRaw: jest.fn(),
$transaction: jest.fn(),
},
@ -9,13 +13,15 @@ import { prisma } from '@/lib/prisma'
import { SupplyChainSupplierInformationRepositoryClass } from '../supply-chain-supplier-information.repository'
const mockPrisma = prisma as unknown as {
supplyChainProductArchive: { findMany: jest.Mock; count: jest.Mock }
$queryRaw: jest.Mock
$transaction: jest.Mock
}
const EMPTY_FILTERS = {
barcode: '',
purchaser: '',
supplier: '',
suppliers: [],
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
@ -31,44 +37,38 @@ function queryText(query: { sql?: string; text?: string }) {
describe('SupplyChainSupplierInformationRepository', () => {
beforeEach(() => jest.clearAllMocks())
it('combines filters against any barcode value in the supplier group', async () => {
mockPrisma.$queryRaw
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ total: 0 }])
it('queries one row per barcode with searchable fields and selected suppliers', async () => {
mockPrisma.supplyChainProductArchive.findMany.mockResolvedValue([])
mockPrisma.supplyChainProductArchive.count.mockResolvedValue(0)
mockPrisma.$queryRaw.mockResolvedValue([{ supplierName: '供应商A' }])
const repository = new SupplyChainSupplierInformationRepositoryClass()
await repository.listPage({
skip: 0,
await expect(repository.listPage({
skip: 50,
take: 50,
filters: {
...EMPTY_FILTERS,
barcode: 'BC-01',
purchaser: '采购A',
minimumOrderQuantity: '20',
stableLeadTime: '30天',
palletSpec: '托盘B',
backupSupplier: '备选厂',
suppliers: ['供应商A', '供应商B'],
},
})
})).resolves.toEqual({ records: [], total: 0, supplierOptions: ['供应商A'] })
const query = mockPrisma.$queryRaw.mock.calls[0][0] as {
sql?: string
text?: string
values?: unknown[]
}
const sql = queryText(query)
expect(sql).toContain('HAVING')
expect(sql).toContain('BOOL_OR("purchaseOwner" ILIKE')
expect(sql).toContain('BOOL_OR("minimumOrderQuantity" ILIKE')
expect(sql).toContain('BOOL_OR("stableLeadTime" ILIKE')
expect(sql).toContain('BOOL_OR("palletSpec" ILIKE')
expect(sql).toContain('BOOL_OR("backupSupplier" ILIKE')
expect(query.values).toEqual(expect.arrayContaining([
'%采购A%', '%20%', '%30天%', '%托盘B%', '%备选厂%',
]))
const query = mockPrisma.supplyChainProductArchive.findMany.mock.calls[0][0]
expect(query.where).toEqual(expect.objectContaining({
supplierStatus: '合作',
barcode: { contains: 'BC-01', mode: 'insensitive' },
purchaseOwner: { contains: '采购A', mode: 'insensitive' },
supplierName: { in: ['供应商A', '供应商B'] },
}))
expect(query).toEqual(expect.objectContaining({ skip: 50, take: 50 }))
const supplierOptionsQuery = mockPrisma.$queryRaw.mock.calls[0][0]
expect(queryText(supplierOptionsQuery)).toContain('"supplierStatus"')
expect(queryText(supplierOptionsQuery)).toContain("= '合作'")
})
it('locks matching archive rows before one bulk update', async () => {
const records = [{
it('locks and updates only the requested barcode', async () => {
const record = {
id: 'archive-1',
barcode: 'BC-001',
supplierName: '浩远',
@ -79,23 +79,36 @@ describe('SupplyChainSupplierInformationRepository', () => {
palletSpec: '标准盘',
backupSupplier: '',
supplierType: '成品',
}]
}
const tx = {
$queryRaw: jest.fn().mockResolvedValue(records),
supplyChainProductArchive: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
$queryRaw: jest.fn().mockResolvedValue([record]),
supplyChainProductArchive: { update: jest.fn().mockResolvedValue(record) },
}
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
const repository = new SupplyChainSupplierInformationRepositoryClass()
await expect(repository.updateSupplierFields({
supplierKey: '浩远',
updates: { purchaseOwner: '采购B', stableLeadTime: '30天', backupSupplier: '备选厂' },
})).resolves.toEqual(records)
await expect(repository.updateBarcodeFields({
barcode: 'BC-001',
updates: { purchaseOwner: '采购B', stableLeadTime: '30天' },
})).resolves.toEqual(record)
expect(queryText(tx.$queryRaw.mock.calls[0][0])).toContain('FOR UPDATE')
expect(tx.supplyChainProductArchive.updateMany).toHaveBeenCalledWith({
where: { id: { in: ['archive-1'] } },
data: { purchaseOwner: '采购B', stableLeadTime: '30天', backupSupplier: '备选厂' },
const lockQuery = tx.$queryRaw.mock.calls[0][0]
expect(queryText(lockQuery)).toContain('WHERE "barcode" =')
expect(queryText(lockQuery)).toContain('FOR UPDATE')
expect(lockQuery.values).toContain('BC-001')
expect(tx.supplyChainProductArchive.update).toHaveBeenCalledWith({
where: { id: 'archive-1' },
data: { purchaseOwner: '采购B', stableLeadTime: '30天' },
})
})
it('limits history to supplier maintenance fields for the barcode', async () => {
mockPrisma.$queryRaw.mockResolvedValue([])
const repository = new SupplyChainSupplierInformationRepositoryClass()
await repository.listHistory('BC-001')
const query = mockPrisma.$queryRaw.mock.calls[0][0]
expect(queryText(query)).toContain('"details"->>\'field\' IN')
expect(queryText(query)).toContain('"skuCode" =')
expect(query.values).toContain('BC-001')
})
})

View File

@ -326,7 +326,7 @@ class BarcodePrintImportRepositoryClass {
}
async deleteMany(scope: BarcodePrintImportScope, ids: string[]) {
await prisma.barcodePrintImportRecord.deleteMany({
return prisma.barcodePrintImportRecord.deleteMany({
where: {
id: { in: ids },
sourceSkuItemId: null,
@ -334,6 +334,58 @@ class BarcodePrintImportRepositoryClass {
},
})
}
async findEditableByIds(scope: BarcodePrintImportScope, ids: string[]) {
return prisma.barcodePrintImportRecord.findMany({
where: {
id: { in: ids },
sourceSkuItemId: null,
...normalizedSupplierScope(scope),
},
})
}
async updateMany(
scope: BarcodePrintImportScope,
records: Array<{
id: string
updates: Prisma.InputJsonValue
skuCode?: string
normalizedSku?: string
supplierName?: string
normalizedSupplierName?: string
}>,
) {
return prisma.$transaction(async (transaction) => {
let count = 0
for (const record of records) {
const result = await transaction.barcodePrintImportRecord.updateMany({
where: {
id: record.id,
sourceSkuItemId: null,
...normalizedSupplierScope(scope),
},
data: {
updates: record.updates,
...(record.skuCode === undefined
? {}
: {
skuCode: record.skuCode,
normalizedSku: record.normalizedSku,
}),
...(record.supplierName === undefined
? {}
: {
supplierName: record.supplierName,
normalizedSupplierName: record.normalizedSupplierName,
}),
},
})
count += result.count
}
return { count }
})
}
}
export const barcodePrintImportRepository = new BarcodePrintImportRepositoryClass()

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

@ -2,7 +2,10 @@ import 'server-only'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import type { InstallationToolMotionType } from '@/lib/installation-demo-tool-motion'
import type {
InstallationHingeMotionConfig,
InstallationToolMotionType,
} from '@/lib/installation-demo-tool-motion'
interface InstallationDemoToolMutationData {
toolCode: string
@ -16,6 +19,7 @@ interface InstallationDemoToolMutationData {
motionAxis: [number, number, number] | null
spinTurns: number
strikeDistanceRatio: number
articulationConfig: InstallationHingeMotionConfig | null
}
function toCreateData(data: InstallationDemoToolMutationData): Prisma.InstallationDemoToolCreateInput {
@ -23,13 +27,16 @@ function toCreateData(data: InstallationDemoToolMutationData): Prisma.Installati
...data,
motionPivot: data.motionPivot ?? Prisma.DbNull,
motionAxis: data.motionAxis ?? Prisma.DbNull,
articulationConfig: data.articulationConfig
? data.articulationConfig as unknown as Prisma.InputJsonValue
: Prisma.DbNull,
}
}
function toUpdateData(
data: Partial<InstallationDemoToolMutationData>,
): Prisma.InstallationDemoToolUpdateInput {
const { motionPivot, motionAxis, ...rest } = data
const { motionPivot, motionAxis, articulationConfig, ...rest } = data
return {
...rest,
...(motionPivot !== undefined
@ -38,6 +45,13 @@ function toUpdateData(
...(motionAxis !== undefined
? { motionAxis: motionAxis ?? Prisma.DbNull }
: {}),
...(articulationConfig !== undefined
? {
articulationConfig: articulationConfig
? articulationConfig as unknown as Prisma.InputJsonValue
: Prisma.DbNull,
}
: {}),
}
}

View File

@ -5,19 +5,6 @@ import type {
SupplyChainSupplierInformationUpdates,
} from '@/lib/supply-chain-supplier-information'
export type SupplyChainSupplierAggregateRecord = {
supplierKey: string
supplierName: string
barcodeCount: number
purchaserValues: string[]
minimumOrderQuantityValues: string[]
firstLeadTimeValues: string[]
stableLeadTimeValues: string[]
palletSpecValues: string[]
backupSupplierValues: string[]
supplierTypeValues: string[]
}
export type SupplyChainSupplierArchiveRecord = {
id: string
barcode: string
@ -31,54 +18,60 @@ export type SupplyChainSupplierArchiveRecord = {
supplierType: string
}
export type SupplyChainSupplierHistoryRecord = {
id: string
actorName: string
details: Prisma.JsonValue | null
createdAt: Date
}
export interface SupplyChainSupplierInformationRepository {
listPage(params: {
skip: number
take: number
filters: SupplyChainSupplierInformationFilters
}): Promise<{
records: SupplyChainSupplierAggregateRecord[]
records: SupplyChainSupplierArchiveRecord[]
total: number
supplierOptions: string[]
}>
updateSupplierFields(params: {
supplierKey: string
updateBarcodeFields(params: {
barcode: string
updates: SupplyChainSupplierInformationUpdates
}): Promise<SupplyChainSupplierArchiveRecord[]>
}): Promise<SupplyChainSupplierArchiveRecord | null>
listHistory(barcode: string): Promise<SupplyChainSupplierHistoryRecord[]>
}
function likePattern(value: string) {
return `%${value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
const ARCHIVE_SELECT = {
id: true,
barcode: true,
supplierName: true,
purchaseOwner: true,
minimumOrderQuantity: true,
firstLeadTime: true,
stableLeadTime: true,
palletSpec: true,
backupSupplier: true,
supplierType: true,
} satisfies Prisma.SupplyChainProductArchiveSelect
function contains(value: string) {
return value ? { contains: value, mode: Prisma.QueryMode.insensitive } : undefined
}
function buildAggregateFilterClause(filters: SupplyChainSupplierInformationFilters) {
const clauses: Prisma.Sql[] = []
if (filters.purchaser) {
clauses.push(Prisma.sql`BOOL_OR("purchaseOwner" ILIKE ${likePattern(filters.purchaser)} ESCAPE '\\')`)
}
if (filters.supplier) {
clauses.push(Prisma.sql`BOOL_OR("supplierName" ILIKE ${likePattern(filters.supplier)} ESCAPE '\\')`)
}
if (filters.minimumOrderQuantity) {
clauses.push(Prisma.sql`BOOL_OR("minimumOrderQuantity" ILIKE ${likePattern(filters.minimumOrderQuantity)} ESCAPE '\\')`)
}
if (filters.firstLeadTime) {
clauses.push(Prisma.sql`BOOL_OR("firstLeadTime" ILIKE ${likePattern(filters.firstLeadTime)} ESCAPE '\\')`)
}
if (filters.stableLeadTime) {
clauses.push(Prisma.sql`BOOL_OR("stableLeadTime" ILIKE ${likePattern(filters.stableLeadTime)} ESCAPE '\\')`)
}
if (filters.palletSpec) {
clauses.push(Prisma.sql`BOOL_OR("palletSpec" ILIKE ${likePattern(filters.palletSpec)} ESCAPE '\\')`)
}
if (filters.backupSupplier) {
clauses.push(Prisma.sql`BOOL_OR("backupSupplier" ILIKE ${likePattern(filters.backupSupplier)} ESCAPE '\\')`)
}
if (filters.supplierType) {
clauses.push(Prisma.sql`BOOL_OR("supplierType" ILIKE ${likePattern(filters.supplierType)} ESCAPE '\\')`)
}
return clauses.length > 0
? Prisma.sql`HAVING ${Prisma.join(clauses, ' AND ')}`
: Prisma.empty
function buildWhere(filters: SupplyChainSupplierInformationFilters) {
return {
supplierStatus: '合作',
barcode: contains(filters.barcode),
purchaseOwner: contains(filters.purchaser),
supplierName: filters.suppliers.length > 0 ? { in: filters.suppliers } : undefined,
minimumOrderQuantity: contains(filters.minimumOrderQuantity),
firstLeadTime: contains(filters.firstLeadTime),
stableLeadTime: contains(filters.stableLeadTime),
palletSpec: contains(filters.palletSpec),
backupSupplier: contains(filters.backupSupplier),
supplierType: contains(filters.supplierType),
} satisfies Prisma.SupplyChainProductArchiveWhereInput
}
export class SupplyChainSupplierInformationRepositoryClass
@ -89,74 +82,34 @@ export class SupplyChainSupplierInformationRepositoryClass
take: number
filters: SupplyChainSupplierInformationFilters
}) {
const filterClause = buildAggregateFilterClause(params.filters)
const groupedSuppliers = Prisma.sql`
SELECT
LOWER(REGEXP_REPLACE(BTRIM("supplierName"), E'\\s+', '', 'g')) AS "supplierKey",
MIN(BTRIM("supplierName")) AS "supplierName",
COUNT(DISTINCT "barcode")::int AS "barcodeCount",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("purchaseOwner"))
FILTER (WHERE NULLIF(BTRIM("purchaseOwner"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "purchaserValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("minimumOrderQuantity"))
FILTER (WHERE NULLIF(BTRIM("minimumOrderQuantity"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "minimumOrderQuantityValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("firstLeadTime"))
FILTER (WHERE NULLIF(BTRIM("firstLeadTime"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "firstLeadTimeValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("stableLeadTime"))
FILTER (WHERE NULLIF(BTRIM("stableLeadTime"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "stableLeadTimeValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("palletSpec"))
FILTER (WHERE NULLIF(BTRIM("palletSpec"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "palletSpecValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("backupSupplier"))
FILTER (WHERE NULLIF(BTRIM("backupSupplier"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "backupSupplierValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("supplierType"))
FILTER (WHERE NULLIF(BTRIM("supplierType"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "supplierTypeValues"
FROM "SupplyChainProductArchive"
WHERE NULLIF(BTRIM("supplierName"), '') IS NOT NULL
GROUP BY LOWER(REGEXP_REPLACE(BTRIM("supplierName"), E'\\s+', '', 'g'))
${filterClause}
`
const [records, totals] = await Promise.all([
prisma.$queryRaw<SupplyChainSupplierAggregateRecord[]>(Prisma.sql`
WITH "groupedSuppliers" AS (${groupedSuppliers})
SELECT *
FROM "groupedSuppliers"
const where = buildWhere(params.filters)
const [records, total, supplierRows] = await Promise.all([
prisma.supplyChainProductArchive.findMany({
where,
select: ARCHIVE_SELECT,
orderBy: [{ supplierName: 'asc' }, { barcode: 'asc' }],
skip: params.skip,
take: params.take,
}),
prisma.supplyChainProductArchive.count({ where }),
prisma.$queryRaw<Array<{ supplierName: string }>>(Prisma.sql`
SELECT DISTINCT BTRIM("supplierName") AS "supplierName"
FROM "SupplyChainProductArchive"
WHERE NULLIF(BTRIM("supplierName"), '') IS NOT NULL
AND BTRIM("supplierStatus") = '合作'
ORDER BY "supplierName" ASC
OFFSET ${params.skip}
LIMIT ${params.take}
`),
prisma.$queryRaw<Array<{ total: number }>>(Prisma.sql`
WITH "groupedSuppliers" AS (${groupedSuppliers})
SELECT COUNT(*)::int AS "total"
FROM "groupedSuppliers"
`),
])
return { records, total: totals[0]?.total ?? 0 }
return {
records,
total,
supplierOptions: supplierRows.map((row) => row.supplierName),
}
}
async updateSupplierFields(params: {
supplierKey: string
async updateBarcodeFields(params: {
barcode: string
updates: SupplyChainSupplierInformationUpdates
}) {
return prisma.$transaction(async (tx) => {
@ -173,19 +126,40 @@ export class SupplyChainSupplierInformationRepositoryClass
"backupSupplier",
"supplierType"
FROM "SupplyChainProductArchive"
WHERE LOWER(REGEXP_REPLACE(BTRIM("supplierName"), E'\\s+', '', 'g')) = ${params.supplierKey}
ORDER BY "barcode" ASC
WHERE "barcode" = ${params.barcode}
FOR UPDATE
`)
if (records.length === 0) return []
const record = records[0]
if (!record) return null
await tx.supplyChainProductArchive.updateMany({
where: { id: { in: records.map((record) => record.id) } },
await tx.supplyChainProductArchive.update({
where: { id: record.id },
data: params.updates,
})
return records
return record
})
}
async listHistory(barcode: string) {
return prisma.$queryRaw<SupplyChainSupplierHistoryRecord[]>(Prisma.sql`
SELECT "id", "actorName", "details", "createdAt"
FROM "OperationLog"
WHERE "skuCode" = ${barcode}
AND "workspaceCategory" = 'SUPPLY_CHAIN_PRODUCT_ARCHIVE'
AND "details"->>'field' IN (
'purchaseOwner',
'supplierName',
'minimumOrderQuantity',
'firstLeadTime',
'stableLeadTime',
'palletSpec',
'backupSupplier',
'supplierType'
)
ORDER BY "createdAt" DESC
LIMIT 200
`)
}
}
export const supplyChainSupplierInformationRepository =

View File

@ -1,33 +1,42 @@
import { SupplyChainSupplierInformationService } from '../supply-chain-supplier-information.service'
import type { SupplyChainSupplierInformationRepository } from '@/server/repositories/supply-chain-supplier-information.repository'
const USER = { id: 'buyer-1', name: '采购员', role: 'USER' } as never
function createRepository(
overrides: Partial<SupplyChainSupplierInformationRepository> = {},
): SupplyChainSupplierInformationRepository {
return {
listPage: jest.fn().mockResolvedValue({ total: 0, records: [] }),
updateSupplierFields: jest.fn().mockResolvedValue([]),
listPage: jest.fn().mockResolvedValue({ total: 0, records: [], supplierOptions: [] }),
updateBarcodeFields: jest.fn().mockResolvedValue(null),
listHistory: jest.fn().mockResolvedValue([]),
...overrides,
}
}
function archiveRecord(barcode = 'BC-001') {
return {
id: `archive-${barcode}`,
barcode,
supplierName: '浩远',
purchaseOwner: '采购A',
minimumOrderQuantity: '10',
firstLeadTime: '7',
stableLeadTime: '',
palletSpec: '标准盘',
backupSupplier: '',
supplierType: '成品',
}
}
describe('SupplyChainSupplierInformationService', () => {
it('returns one normalized supplier row including maintained supplier fields', async () => {
it('returns barcode rows, normalized filters and supplier options', async () => {
const record = archiveRecord()
const repository = createRepository({
listPage: jest.fn().mockResolvedValue({
total: 1,
records: [{
supplierKey: '浩远',
supplierName: '浩远',
barcodeCount: 12,
purchaserValues: ['采购B', '采购A', '采购A'],
minimumOrderQuantityValues: ['20'],
firstLeadTimeValues: ['15', '7'],
stableLeadTimeValues: ['30天'],
palletSpecValues: ['60', '30'],
backupSupplierValues: ['备选厂B', '备选厂A'],
supplierTypeValues: ['成品供应商'],
}],
records: [record],
supplierOptions: ['浩远', '林氏'],
}),
})
const service = new SupplyChainSupplierInformationService(repository)
@ -36,8 +45,9 @@ describe('SupplyChainSupplierInformationService', () => {
page: 1,
pageSize: 50,
filters: {
barcode: ' BC ',
purchaser: ' 采购A ',
supplier: ' 浩远 ',
suppliers: [' 浩远 ', '浩远'],
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
@ -51,8 +61,9 @@ describe('SupplyChainSupplierInformationService', () => {
skip: 0,
take: 50,
filters: {
barcode: 'BC',
purchaser: '采购A',
supplier: '浩远',
suppliers: ['浩远'],
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
@ -61,135 +72,108 @@ describe('SupplyChainSupplierInformationService', () => {
supplierType: '',
},
})
expect(result.rows[0]).toEqual(expect.objectContaining({
purchaserValues: ['采购A', '采购B'],
firstLeadTimeValues: ['7', '15'],
palletSpecValues: ['30', '60'],
stableLeadTimeValues: ['30天'],
backupSupplierValues: ['备选厂A', '备选厂B'],
}))
expect(result.pageInfo).toEqual({
page: 1,
pageSize: 50,
total: 1,
totalPages: 1,
})
expect(result.rows).toEqual([record])
expect(result.options.suppliers).toEqual(['浩远', '林氏'])
expect(result.pageInfo).toEqual({ page: 1, pageSize: 50, total: 1, totalPages: 1 })
})
it('bounds pagination before querying the repository', async () => {
const repository = createRepository({
listPage: jest.fn().mockResolvedValue({ total: 250, records: [] }),
listPage: jest.fn().mockResolvedValue({ total: 250, records: [], supplierOptions: [] }),
})
const service = new SupplyChainSupplierInformationService(repository)
const result = await service.listPage({ page: -2, pageSize: 500 })
expect(repository.listPage).toHaveBeenCalledWith({
skip: 0,
take: 200,
filters: {
purchaser: '',
supplier: '',
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
palletSpec: '',
backupSupplier: '',
supplierType: '',
},
})
expect(repository.listPage).toHaveBeenCalledWith(expect.objectContaining({ skip: 0, take: 200 }))
expect(result.pageInfo.totalPages).toBe(2)
})
it('updates only supplied values and records each changed barcode field', async () => {
it('updates only one barcode, supports clearing a field and records actual changes', async () => {
const repository = createRepository({
updateSupplierFields: jest.fn().mockResolvedValue([
{
id: 'archive-1',
barcode: 'BC-001',
supplierName: '浩远',
purchaseOwner: '采购A',
minimumOrderQuantity: '10',
firstLeadTime: '7',
stableLeadTime: '',
palletSpec: '标准盘',
backupSupplier: '',
supplierType: '成品',
},
{
id: 'archive-2',
barcode: 'BC-002',
supplierName: '浩远',
purchaseOwner: '采购B',
minimumOrderQuantity: '10',
firstLeadTime: '7',
stableLeadTime: '',
palletSpec: '标准盘',
backupSupplier: '',
supplierType: '成品',
},
]),
updateBarcodeFields: jest.fn().mockResolvedValue(archiveRecord()),
})
const recordChanges = jest.fn().mockResolvedValue(undefined)
const service = new SupplyChainSupplierInformationService(repository, recordChanges)
const result = await service.updateSupplier({
supplierKey: ' 浩 远 ',
const result = await service.updateBarcode({
barcode: ' BC-001 ',
updates: {
purchaseOwner: ' 采购C ',
minimumOrderQuantity: ' ',
purchaseOwner: ' 采购B ',
stableLeadTime: ' 30天 ',
backupSupplier: ' 备选厂 ',
palletSpec: '',
supplierType: '成品',
},
user: { id: 'buyer-1', name: '采购员', role: 'USER' } as never,
user: USER,
})
expect(repository.updateSupplierFields).toHaveBeenCalledWith({
supplierKey: '浩远',
expect(repository.updateBarcodeFields).toHaveBeenCalledWith({
barcode: 'BC-001',
updates: {
purchaseOwner: '采购C',
purchaseOwner: '采购B',
stableLeadTime: '30天',
backupSupplier: '备选厂',
palletSpec: '',
supplierType: '成品',
},
})
expect(recordChanges).toHaveBeenCalledTimes(2)
expect(recordChanges).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(recordChanges).toHaveBeenCalledWith(expect.objectContaining({
barcode: 'BC-001',
changes: [
{
field: 'purchaseOwner',
fieldLabel: '采购',
previousValue: '采购A',
nextValue: '采购C',
},
{
field: 'stableLeadTime',
fieldLabel: '稳定生产周期',
previousValue: '',
nextValue: '30天',
},
{
field: 'backupSupplier',
fieldLabel: '替换供应商',
previousValue: '',
nextValue: '备选厂',
},
{ field: 'purchaseOwner', fieldLabel: '采购', previousValue: '采购A', nextValue: '采购B' },
{ field: 'stableLeadTime', fieldLabel: '稳定生产周期', previousValue: '', nextValue: '30天' },
{ field: 'palletSpec', fieldLabel: '托盘参数', previousValue: '标准盘', nextValue: '' },
],
}))
expect(result).toEqual({ affectedBarcodes: 2, changedValues: 6, supplierKey: '浩远' })
expect(result).toEqual({ barcode: 'BC-001', changedValues: 3 })
})
it('does not write when every edit field is blank', async () => {
const repository = createRepository()
it('imports valid rows while reporting unknown barcodes and empty updates by line', async () => {
const repository = createRepository({
updateBarcodeFields: jest.fn()
.mockResolvedValueOnce(archiveRecord('BC-001'))
.mockResolvedValueOnce(null),
})
const service = new SupplyChainSupplierInformationService(repository, jest.fn())
const progress = jest.fn()
await expect(service.updateSupplier({
supplierKey: '浩远',
updates: { purchaseOwner: ' ', palletSpec: '' },
user: { id: 'buyer-1', name: '采购员', role: 'USER' } as never,
})).rejects.toThrow('请至少填写一项要修改的内容')
expect(repository.updateSupplierFields).not.toHaveBeenCalled()
const result = await service.importRows([
{ rowNumber: 2, barcode: 'BC-001', updates: { purchaseOwner: '采购B' } },
{ rowNumber: 3, barcode: 'UNKNOWN', updates: { supplierName: '未知厂' } },
{ rowNumber: 4, barcode: 'BC-004', updates: {} },
], USER, progress)
expect(result).toEqual(expect.objectContaining({ processed: 3, total: 3, updated: 1, failed: 2 }))
expect(result.errors).toEqual([
expect.objectContaining({ rowNumber: 3, barcode: 'UNKNOWN' }),
expect.objectContaining({ rowNumber: 4, barcode: 'BC-004', message: '请至少填写一项要修改的内容' }),
])
expect(progress).toHaveBeenCalledTimes(3)
})
it('maps supplier field logs to the dialog contract', async () => {
const repository = createRepository({
listHistory: jest.fn().mockResolvedValue([{
id: 'log-1',
actorName: '采购员',
details: {
field: 'supplierName',
fieldLabel: '供应商',
previousValue: 'A厂',
nextValue: 'B厂',
},
createdAt: new Date('2026-08-28T02:00:00.000Z'),
}]),
})
const service = new SupplyChainSupplierInformationService(repository)
await expect(service.listHistory('BC-001')).resolves.toEqual([{
id: 'log-1',
actorName: '采购员',
field: 'supplierName',
fieldLabel: '供应商',
previousValue: 'A厂',
nextValue: 'B厂',
createdAt: '2026-08-28T02:00:00.000Z',
}])
})
})

View File

@ -1,5 +1,9 @@
import type { Prisma } from '@prisma/client'
import type { BarcodePrintImportRecord } from '@/app/intake/barcode-print/barcodePrintImport'
import type {
BarcodePrintImportEditableField,
BarcodePrintImportEditFields,
} from '@/lib/barcode-print-import-edit'
import {
buildBarcodePrintBrandExceptionRows,
mergeBarcodePrintImportRows,
@ -18,6 +22,11 @@ import {
} from '@/server/repositories/barcode-print.repository'
import { intakeNonIntegratedProductService } from './intake-non-integrated-product.service'
import { intakeNonIntegratedScopeService } from './intake-non-integrated-scope.service'
import {
OperationLogAction,
OperationLogStatus,
operationLogService,
} from './operation-log.service'
type BarcodePrintImportKind = 'barcode' | 'brandSku'
@ -50,6 +59,33 @@ type SaveBarcodePrintImportRecordInput = {
updates?: unknown
}
const BARCODE_EDITABLE_FIELDS = new Set<BarcodePrintImportEditableField>([
'spu',
'model',
'skuCode',
'barcode',
'factoryModel',
'barcodeSpecName',
'productCategory',
'salesCategory',
'size',
'color',
'executeStandard',
'material',
'tagMaterial',
'supplierName',
'createdAt',
])
const BRAND_SKU_EDITABLE_FIELDS = new Set<BarcodePrintImportEditableField>([
'skuCode',
'brandSkuCode',
'productName',
'materialNo',
'specification',
'certificateMaterial',
])
function readText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
@ -111,6 +147,48 @@ function normalizeUpdates(value: unknown, skuCode: string): Record<string, unkno
}
}
function normalizeEditFields(value: unknown): BarcodePrintImportEditFields {
if (!isRecord(value)) {
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'缺少要修改的字段。',
)
}
const fields: BarcodePrintImportEditFields = {}
for (const [key, rawValue] of Object.entries(value)) {
if (
!BARCODE_EDITABLE_FIELDS.has(key as BarcodePrintImportEditableField) &&
!BRAND_SKU_EDITABLE_FIELDS.has(key as BarcodePrintImportEditableField)
) {
continue
}
if (typeof rawValue !== 'string') {
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'修改字段必须是文本。',
)
}
fields[key as BarcodePrintImportEditableField] = rawValue.trim()
}
if (Object.keys(fields).length === 0) {
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'没有选择要修改的字段。',
)
}
if (fields.skuCode === '') {
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'货号不能为空。',
)
}
return fields
}
function toImportRecord(record: {
id: string
kind: string
@ -447,8 +525,127 @@ class BarcodePrintService {
)
}
await barcodePrintImportRepository.deleteMany(buildImportScope(user), normalizedIds)
return { deleted: normalizedIds.length }
const result = await barcodePrintImportRepository.deleteMany(
buildImportScope(user),
normalizedIds,
)
if (result.count > 0) {
await operationLogService.record({
action: OperationLogAction.INTAKE_PRODUCT_DELETE,
status: OperationLogStatus.SUCCESS,
actorId: user.id,
actorName: user.name,
workspaceCategory: '条码打印',
message: `删除 ${result.count} 条手动导入条码数据`,
details: { importRecordIds: normalizedIds, deleted: result.count },
})
}
return { deleted: result.count }
}
async updateImportRecords(
user: AuthUser,
ids: unknown,
rawFields: unknown,
) {
if (!canManageBarcodePrintImports(user)) {
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
'只有设计师和管理员可以修改导入数据。',
)
}
const normalizedIds = Array.isArray(ids)
? Array.from(
new Set(
ids
.map((id) => readText(id))
.filter((id) => id.length > 0),
),
)
: []
if (normalizedIds.length === 0) {
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'缺少要修改的导入记录。',
)
}
const fields = normalizeEditFields(rawFields)
const records = await barcodePrintImportRepository.findEditableByIds(
buildImportScope(user),
normalizedIds,
)
if (records.length === 0) {
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'没有可修改的手动导入记录。',
)
}
const updates = records.flatMap((record) => {
const current = isRecord(record.updates) ? record.updates : {}
const allowed = record.kind === 'brandSku'
? BRAND_SKU_EDITABLE_FIELDS
: BARCODE_EDITABLE_FIELDS
const relevantFields = Object.entries(fields).filter(([key]) =>
allowed.has(key as BarcodePrintImportEditableField),
)
if (relevantFields.length === 0) return []
const next = { ...current } as Record<string, unknown>
for (const [key, value] of relevantFields) {
next[key] = value
}
next.skuCode = readText(next.skuCode) || record.skuCode
next.dataSource = readText(next.dataSource) || '导入'
const skuCode = fields.skuCode === undefined
? undefined
: readText(fields.skuCode)
const supplierName =
record.kind === 'barcode' && fields.supplierName !== undefined
? readText(fields.supplierName)
: undefined
return [{
id: record.id,
updates: next as Prisma.InputJsonValue,
skuCode,
normalizedSku: skuCode ? normalizeSkuCode(skuCode) : undefined,
supplierName,
normalizedSupplierName:
supplierName === undefined ? undefined : normalizeSupplierName(supplierName),
}]
})
if (updates.length === 0) {
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'所选导入记录不包含这些字段。',
)
}
const result = await barcodePrintImportRepository.updateMany(
buildImportScope(user),
updates,
)
if (result.count === 0) {
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
'没有可修改的手动导入记录。',
)
}
await operationLogService.record({
action: OperationLogAction.INTAKE_PRODUCT_WRITE,
status: OperationLogStatus.SUCCESS,
actorId: user.id,
actorName: user.name,
workspaceCategory: '条码打印',
message: `修改 ${result.count} 条手动导入条码数据`,
details: {
importRecordIds: normalizedIds,
fields: Object.keys(fields),
updated: result.count,
},
})
return { updated: result.count }
}
}

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()

View File

@ -51,6 +51,7 @@ const legacyTool = {
motionAxis: null,
spinTurns: 3,
strikeDistanceRatio: 0.2,
articulationConfig: null,
createdAt: new Date('2026-07-27T00:00:00.000Z'),
updatedAt: new Date('2026-07-27T00:00:00.000Z'),
}
@ -119,4 +120,47 @@ describe('InstallationDemoToolService', () => {
}),
)
})
it('normalizes and persists a three-node hinge articulation', async () => {
mockedRepository.findByToolCode.mockResolvedValue(null)
mockedRepository.create.mockResolvedValue(legacyTool as never)
await new InstallationDemoToolService().create(user, {
toolCode: 'TOOL-HINGE',
name: 'Hinge',
modelUrl: legacyTool.modelUrl,
fileName: legacyTool.fileName,
motionType: 'hinge',
articulationConfig: {
version: 1,
baseNodeName: 'asset_000',
linkNodeName: 'asset_002',
leafNodeName: 'asset_001',
primaryJoint: {
pivot: [-1, 0.4, 2],
axis: [1, 0, 0],
middleAngleDegrees: 30,
closedAngleDegrees: 65,
},
secondaryJoint: {
pivot: [0.5, 0.3, 0.6],
axis: [1, 0, 0],
middleAngleDegrees: -50,
closedAngleDegrees: -105,
},
},
})
expect(mockedRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
motionType: 'hinge',
articulationConfig: expect.objectContaining({
baseNodeName: 'asset_000',
linkNodeName: 'asset_002',
leafNodeName: 'asset_001',
primaryJoint: expect.objectContaining({ pivot: [0, 0.4, 1] }),
}),
}),
)
})
})

View File

@ -4,6 +4,7 @@ import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
import {
DEFAULT_INSTALLATION_TOOL_MOTION,
isInstallationToolMotionType,
normalizeInstallationHingeMotionConfig,
normalizeInstallationToolMotionVector,
} from '@/lib/installation-demo-tool-motion'
import { AppError, ErrorCode } from '@/server/core/errors'
@ -75,6 +76,7 @@ function normalizeMotionFields(input: Record<string, unknown>) {
0.02,
1,
),
articulationConfig: normalizeInstallationHingeMotionConfig(input.articulationConfig),
}
}
@ -158,6 +160,9 @@ export class InstallationDemoToolService {
strikeDistanceRatio: input.strikeDistanceRatio === undefined
? existingMotion.strikeDistanceRatio
: normalizeMotionNumber(input.strikeDistanceRatio, existingMotion.strikeDistanceRatio, 0.02, 1),
articulationConfig: input.articulationConfig === undefined
? existingMotion.articulationConfig
: normalizeInstallationHingeMotionConfig(input.articulationConfig),
})
}

View File

@ -1,7 +1,9 @@
import {
EMPTY_SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTERS,
SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS,
type SupplyChainSupplierInformationFilters,
type SupplyChainSupplierInformationHistoryItem,
type SupplyChainSupplierInformationImportProgress,
type SupplyChainSupplierInformationImportRow,
type SupplyChainSupplierInformationPage,
type SupplyChainSupplierInformationUpdates,
} from '@/lib/supply-chain-supplier-information'
@ -19,14 +21,45 @@ import {
archiveChangeActorFromUser,
recordProductArchiveFieldChanges,
} from '@/server/services/supply-chain-product-archive-change-log.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
const EDITABLE_FIELD_LABELS = Object.fromEntries(
PRODUCT_ARCHIVE_CHANGE_FIELDS.map((field) => [field.key, field.label]),
) as Record<keyof SupplyChainSupplierInformationUpdates, string>
function normalizeValues(values: readonly string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
.sort((left, right) => left.localeCompare(right, 'zh-CN', { numeric: true }))
function isHistoryDetails(value: unknown): value is {
field: string
fieldLabel?: string
previousValue?: string
nextValue?: string
} {
return Boolean(value && typeof value === 'object' && 'field' in value)
}
function normalizeFilters(filters?: Partial<SupplyChainSupplierInformationFilters>) {
return {
barcode: filters?.barcode?.trim().slice(0, 120) ?? '',
purchaser: filters?.purchaser?.trim().slice(0, 120) ?? '',
suppliers: [...new Set((filters?.suppliers ?? [])
.map((value) => value.trim().slice(0, 180))
.filter(Boolean))],
minimumOrderQuantity: filters?.minimumOrderQuantity?.trim().slice(0, 120) ?? '',
firstLeadTime: filters?.firstLeadTime?.trim().slice(0, 120) ?? '',
stableLeadTime: filters?.stableLeadTime?.trim().slice(0, 120) ?? '',
palletSpec: filters?.palletSpec?.trim().slice(0, 120) ?? '',
backupSupplier: filters?.backupSupplier?.trim().slice(0, 120) ?? '',
supplierType: filters?.supplierType?.trim().slice(0, 120) ?? '',
} satisfies SupplyChainSupplierInformationFilters
}
function normalizeUpdates(updates: SupplyChainSupplierInformationUpdates) {
return Object.fromEntries(
SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS.flatMap((field) => (
Object.prototype.hasOwnProperty.call(updates, field)
? [[field, String(updates[field] ?? '').trim().slice(0, field === 'palletSpec' ? 240 : 180)]]
: []
)),
) as SupplyChainSupplierInformationUpdates
}
export class SupplyChainSupplierInformationService {
@ -44,75 +77,73 @@ export class SupplyChainSupplierInformationService {
}) {
const page = Math.max(1, Math.trunc(params.page) || 1)
const pageSize = Math.min(200, Math.max(1, Math.trunc(params.pageSize) || 50))
const filters = Object.fromEntries(
Object.keys(EMPTY_SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTERS).map((key) => [
key,
params.filters?.[key as keyof SupplyChainSupplierInformationFilters]
?.trim()
.slice(0, 120) ?? '',
]),
) as SupplyChainSupplierInformationFilters
const { records, total } = await this.repository.listPage({
const filters = normalizeFilters(params.filters)
const { records, total, supplierOptions } = await this.repository.listPage({
skip: (page - 1) * pageSize,
take: pageSize,
filters,
})
return {
rows: records.map((record) => ({
...record,
purchaserValues: normalizeValues(record.purchaserValues),
minimumOrderQuantityValues: normalizeValues(record.minimumOrderQuantityValues),
firstLeadTimeValues: normalizeValues(record.firstLeadTimeValues),
stableLeadTimeValues: normalizeValues(record.stableLeadTimeValues),
palletSpecValues: normalizeValues(record.palletSpecValues),
backupSupplierValues: normalizeValues(record.backupSupplierValues),
supplierTypeValues: normalizeValues(record.supplierTypeValues),
})),
rows: records,
pageInfo: {
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
options: { suppliers: supplierOptions },
} satisfies SupplyChainSupplierInformationPage
}
async updateSupplier(input: {
supplierKey: string
async requireViewAccess(user: AuthUser) {
const access = await supplyChainPermissionSettingsService.getUserAccess(
user,
'purchaseSupplierInformation',
)
if (!access.view) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有查看供应商信息的权限')
}
return access
}
async requireEditAccess(user: AuthUser) {
const access = await this.requireViewAccess(user)
if (!access.edit) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有维护供应商信息的权限')
}
}
async updateBarcode(input: {
barcode: string
updates: SupplyChainSupplierInformationUpdates
user: AuthUser
}) {
const supplierKey = normalizeSupplierKey(input.supplierKey)
const updates = Object.fromEntries(
SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS.flatMap((field) => {
const value = input.updates[field]?.trim()
return value ? [[field, value]] : []
}),
) as SupplyChainSupplierInformationUpdates
if (!supplierKey || Object.keys(updates).length === 0) {
const barcode = input.barcode.trim().slice(0, 160)
const updates = normalizeUpdates(input.updates)
if (!barcode) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '条码不能为空')
}
if (Object.keys(updates).length === 0) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少填写一项要修改的内容')
}
const records = await this.repository.updateSupplierFields({ supplierKey, updates })
if (records.length === 0) {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '供应商关联的产品档案不存在')
const record = await this.repository.updateBarcodeFields({ barcode, updates })
if (!record) {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, `条码 ${barcode} 不存在于产品档案`)
}
let changedValues = 0
for (const record of records) {
const changes = Object.entries(updates).flatMap(([field, nextValue]) => {
const previousValue = record[field as keyof SupplyChainSupplierInformationUpdates]
if (previousValue === nextValue) return []
return [{
field: field as keyof SupplyChainSupplierInformationUpdates,
fieldLabel: EDITABLE_FIELD_LABELS[field as keyof SupplyChainSupplierInformationUpdates],
previousValue,
nextValue,
} satisfies ProductArchiveFieldChange]
})
if (changes.length === 0) continue
changedValues += changes.length
const changes = Object.entries(updates).flatMap(([field, nextValue]) => {
const previousValue = record[field as keyof SupplyChainSupplierInformationUpdates]
if (previousValue === nextValue) return []
return [{
field: field as keyof SupplyChainSupplierInformationUpdates,
fieldLabel: EDITABLE_FIELD_LABELS[field as keyof SupplyChainSupplierInformationUpdates],
previousValue,
nextValue,
} satisfies ProductArchiveFieldChange]
})
if (changes.length > 0) {
await this.recordChanges({
barcode: record.barcode,
actor: archiveChangeActorFromUser(input.user),
@ -121,15 +152,60 @@ export class SupplyChainSupplierInformationService {
}
return {
affectedBarcodes: records.length,
changedValues,
supplierKey: normalizeSupplierKey(updates.supplierName ?? records[0].supplierName),
barcode,
changedValues: changes.length,
}
}
}
export function normalizeSupplierKey(value: string) {
return value.trim().replace(/\s+/g, '').toLocaleLowerCase('zh-CN')
async importRows(
rows: SupplyChainSupplierInformationImportRow[],
user: AuthUser,
onProgress?: (progress: SupplyChainSupplierInformationImportProgress) => void,
) {
const progress: SupplyChainSupplierInformationImportProgress = {
processed: 0,
total: rows.length,
updated: 0,
failed: 0,
errors: [],
}
for (const row of rows) {
try {
await this.updateBarcode({ barcode: row.barcode, updates: row.updates, user })
progress.updated += 1
} catch (error) {
progress.failed += 1
progress.errors.push({
rowNumber: row.rowNumber,
barcode: row.barcode,
message: error instanceof Error ? error.message : '导入失败',
})
}
progress.processed += 1
onProgress?.({ ...progress, errors: [...progress.errors] })
}
return progress
}
async listHistory(barcodeInput: string): Promise<SupplyChainSupplierInformationHistoryItem[]> {
const barcode = barcodeInput.trim().slice(0, 160)
if (!barcode) return []
const records = await this.repository.listHistory(barcode)
return records.flatMap((record) => {
if (!isHistoryDetails(record.details)) return []
return [{
id: record.id,
actorName: record.actorName,
field: record.details.field,
fieldLabel: record.details.fieldLabel ?? record.details.field,
previousValue: record.details.previousValue ?? '',
nextValue: record.details.nextValue ?? '',
createdAt: record.createdAt.toISOString(),
}]
})
}
}
export const supplyChainSupplierInformationService =