merge: release five accepted features
This commit is contained in:
commit
6e9d0e474f
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "InstallationDemoTool"
|
||||
ADD COLUMN "articulationConfig" JSONB;
|
||||
@ -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)])
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||
|
||||
type Context = { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, context: Context) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const { id } = await context.params
|
||||
return jsonSuccess(await finishedProductProgressService.listChangeLogs(id, ctx.user), 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||
|
||||
type Context = { params: Promise<{ id: string }> }
|
||||
|
||||
export async function PATCH(request: NextRequest, context: Context) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const { id } = await context.params
|
||||
const body = await request.json().catch(() => null)
|
||||
return jsonSuccess(await finishedProductProgressService.update(id, body, ctx.user), 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as XLSX from 'xlsx'
|
||||
import {
|
||||
FINISHED_PRODUCT_PROGRESS_IMPORT_HEADERS,
|
||||
type FinishedProductProgressImportProgress,
|
||||
type FinishedProductProgressImportStreamEvent,
|
||||
} from '@/lib/finished-product-progress'
|
||||
import { ErrorCode, isAppError } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError } from '@/server/core/response'
|
||||
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const form = await request.formData()
|
||||
const file = form.get('file')
|
||||
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
|
||||
if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB', 400, ctx.requestId)
|
||||
|
||||
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: true })
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
||||
if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
|
||||
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
|
||||
const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => {
|
||||
const key = FINISHED_PRODUCT_PROGRESS_IMPORT_HEADERS[header.trim()]
|
||||
return key ? [[key, value]] : []
|
||||
})))
|
||||
if (rows.length === 0) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
|
||||
if (rows.length > 5_000) return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行', 400, ctx.requestId)
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let closed = false
|
||||
let progress: FinishedProductProgressImportProgress = { processed: 0, total: rows.length, created: 0, updated: 0 }
|
||||
const send = (event: FinishedProductProgressImportStreamEvent) => {
|
||||
if (closed) return
|
||||
try { controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)) } catch { closed = true }
|
||||
}
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
controller.close()
|
||||
}
|
||||
|
||||
send({ type: 'progress', progress })
|
||||
void finishedProductProgressService.importRows(rows, ctx.user, (next) => {
|
||||
progress = next
|
||||
send({ type: 'progress', progress })
|
||||
}).then((result) => {
|
||||
progress = { processed: rows.length, total: rows.length, ...result }
|
||||
send({ type: 'complete', progress })
|
||||
close()
|
||||
}).catch((error: unknown) => {
|
||||
console.error(`[${ctx.requestId}] Finished product progress import failed:`, error)
|
||||
send({ type: 'error', progress, message: isAppError(error) ? error.message : '导入过程中发生错误' })
|
||||
close()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS } from '@/lib/finished-product-progress'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||
|
||||
const listSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).default(50),
|
||||
search: z.string().trim().max(200).default(''),
|
||||
supplier: z.string().trim().max(200).default(''),
|
||||
brand: z.string().trim().max(200).default(''),
|
||||
currentNode: z.string().trim().max(200).default(''),
|
||||
dueDateFrom: z.string().trim().max(20).default(''),
|
||||
dueDateTo: z.string().trim().max(20).default(''),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const parsed = listSchema.safeParse({
|
||||
...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS,
|
||||
...Object.fromEntries(request.nextUrl.searchParams.entries()),
|
||||
})
|
||||
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '成品进度筛选条件不正确', 400, ctx.requestId)
|
||||
const { page, pageSize, ...filters } = parsed.data
|
||||
return jsonSuccess(await finishedProductProgressService.listPage({ page, pageSize, filters, user: ctx.user }), 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const body = await request.json().catch(() => null)
|
||||
return jsonSuccess(await finishedProductProgressService.create(body, ctx.user), 201, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { FINISHED_PRODUCT_PROGRESS_FIELDS } from '@/lib/finished-product-progress'
|
||||
import { handleRequest } from '@/server/core/response'
|
||||
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
await finishedProductProgressService.requireTemplateAccess(ctx.user)
|
||||
const headers = FINISHED_PRODUCT_PROGRESS_FIELDS.map((field) => field.label)
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers])
|
||||
sheet['!cols'] = headers.map((header) => ({ wch: Math.max(14, header.length * 2 + 4) }))
|
||||
const workbook = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, '成品进度导入')
|
||||
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
|
||||
return new NextResponse(new Uint8Array(content), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': "attachment; filename*=UTF-8''%E6%88%90%E5%93%81%E8%BF%9B%E5%BA%A6%E5%AF%BC%E5%85%A5%E6%A8%A1%E6%9D%BF.xlsx",
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)),
|
||||
),
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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: "" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -277,6 +277,7 @@ export function useNonIntegratedDetailDomainControllers(
|
||||
skuBatchAction: workflow.skuBatchAction,
|
||||
skuState,
|
||||
spuEstimate,
|
||||
supplierCodeByName: directoryOptions.supplierCodeByName,
|
||||
supplementSelection: workflow.supplementSelection,
|
||||
transferProgress,
|
||||
workflowConfirmation: workflow.workflowConfirmation,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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': '产品变价',
|
||||
|
||||
@ -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([
|
||||
'打样进度明细',
|
||||
'成品进度明细',
|
||||
'供应商信息',
|
||||
'日常事项',
|
||||
'产品变价',
|
||||
|
||||
@ -68,6 +68,7 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
|
||||
{
|
||||
items: [
|
||||
{ label: '打样进度明细', route: '/supply-chain/new-products/sample-progress', targetKey: 'newProductSampleProgress' },
|
||||
{ label: '成品进度明细', route: '/supply-chain/purchase/finished-product-progress', targetKey: 'newProductSampleProgress' },
|
||||
{ label: '供应商信息', route: '/supply-chain/purchase/supplier-information', targetKey: 'purchaseSupplierInformation' },
|
||||
{ label: '日常事项', route: '/supply-chain/purchase/daily-affairs', targetKey: 'purchaseDailyAffairs' },
|
||||
{ label: '产品变价', route: '/supply-chain/purchase/price-change', targetKey: 'purchasePriceChange' },
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { FileDown, Plus, RotateCcw, Search, Upload, X } from 'lucide-react'
|
||||
import {
|
||||
EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS,
|
||||
type FinishedProductProgressChangeLogEntry,
|
||||
type FinishedProductProgressFilters,
|
||||
type FinishedProductProgressPage,
|
||||
type FinishedProductProgressRow,
|
||||
} from '@/lib/finished-product-progress'
|
||||
import { FinishedProductColumnSettings } from './components/FinishedProductColumnSettings'
|
||||
import { FinishedProductEditorDrawer } from './components/FinishedProductEditorDrawer'
|
||||
import { FinishedProductHistoryDialog } from './components/FinishedProductHistoryDialog'
|
||||
import { FinishedProductImportProgress } from './components/FinishedProductImportProgress'
|
||||
import { FinishedProductPagination } from './components/FinishedProductPagination'
|
||||
import { FinishedProductTable } from './components/FinishedProductTable'
|
||||
import { finishedProductProgressStyles } from './finishedProductProgressStyles'
|
||||
import { useFinishedProductProgressColumns } from './useFinishedProductProgressColumns'
|
||||
import { useFinishedProductImport } from './useFinishedProductImport'
|
||||
|
||||
async function readResponse(response: Response) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败')
|
||||
return payload.data
|
||||
}
|
||||
|
||||
export function FinishedProductProgressClient({ initialData, currentUserName, canEdit, canDownload }: { initialData: FinishedProductProgressPage; currentUserName: string; canEdit: boolean; canDownload: boolean }) {
|
||||
const [data, setData] = useState(initialData)
|
||||
const [filters, setFilters] = useState<FinishedProductProgressFilters>({ ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS })
|
||||
const [editor, setEditor] = useState<FinishedProductProgressRow | 'new' | null>(null)
|
||||
const [logs, setLogs] = useState<FinishedProductProgressChangeLogEntry[] | null>(null)
|
||||
const [preview, setPreview] = useState<FinishedProductProgressRow | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pageInput, setPageInput] = useState(String(initialData.pageInfo.page))
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const columns = useFinishedProductProgressColumns(currentUserName)
|
||||
|
||||
const load = useCallback(async (nextFilters: FinishedProductProgressFilters, page: number, pageSize: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
Object.entries(nextFilters).forEach(([key, value]) => { if (value) params.set(key, value) })
|
||||
const next = await readResponse(await fetch(`/api/supply-chain/finished-product-progress?${params}`, { cache: 'no-store' })) as FinishedProductProgressPage
|
||||
setData(next)
|
||||
setFilters(nextFilters)
|
||||
setPageInput(String(next.pageInfo.page))
|
||||
} catch (error) { window.alert(error instanceof Error ? error.message : '加载失败') } finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
const afterImport = useCallback(() => load(filters, 1, data.pageInfo.pageSize), [data.pageInfo.pageSize, filters, load])
|
||||
const importTask = useFinishedProductImport(afterImport)
|
||||
const reset = () => {
|
||||
const next = { ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS }
|
||||
setFilters(next)
|
||||
void load(next, 1, data.pageInfo.pageSize)
|
||||
}
|
||||
const openHistory = async (row: FinishedProductProgressRow) => {
|
||||
try { setLogs(await readResponse(await fetch(`/api/supply-chain/finished-product-progress/${row.id}/history`, { cache: 'no-store' }))) } catch (error) { window.alert(error instanceof Error ? error.message : '日志加载失败') }
|
||||
}
|
||||
const commitPage = () => {
|
||||
const parsed = Number(pageInput)
|
||||
if (!Number.isFinite(parsed)) return setPageInput(String(data.pageInfo.page))
|
||||
const page = Math.min(Math.max(1, Math.trunc(parsed)), data.pageInfo.totalPages)
|
||||
setPageInput(String(page))
|
||||
if (page !== data.pageInfo.page) void load(filters, page, data.pageInfo.pageSize)
|
||||
}
|
||||
|
||||
return <main className="finished-product-page">
|
||||
<header className="finished-product-toolbar">
|
||||
<div><h1>成品进度明细</h1><p>维护采购成品订单交期、在途数量、当前节点和实时进度</p></div>
|
||||
<div className="finished-product-actions">
|
||||
<FinishedProductColumnSettings visibleKeys={columns.visibleKeySet} hiddenCount={columns.hiddenCount} onToggle={columns.toggle} onShowAll={columns.showAll} />
|
||||
{canDownload && <button type="button" className="finished-control-button" onClick={() => { window.location.href = '/api/supply-chain/finished-product-progress/template' }}><FileDown size={15} />下载模板</button>}
|
||||
{canEdit && <><button type="button" disabled={importTask.isImporting} onClick={() => fileRef.current?.click()} className="finished-control-button"><Upload size={15} />{importTask.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) void importTask.importFile(file); event.currentTarget.value = '' }} /><button type="button" onClick={() => setEditor('new')} className="finished-primary-button"><Plus size={15} />新增</button></>}
|
||||
</div>
|
||||
</header>
|
||||
<form className="finished-product-filters" onSubmit={(event) => { event.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}>
|
||||
<input value={filters.search} onChange={(event) => setFilters({ ...filters, search: event.target.value })} placeholder="搜索合同号、货号、条码、产品名称或实时进度" />
|
||||
<select value={filters.supplier} onChange={(event) => setFilters({ ...filters, supplier: event.target.value })}><option value="">全部供应商</option>{data.options.suppliers.map((value) => <option key={value}>{value}</option>)}</select>
|
||||
<select value={filters.brand} onChange={(event) => setFilters({ ...filters, brand: event.target.value })}><option value="">全部品牌</option>{data.options.brands.map((value) => <option key={value}>{value}</option>)}</select>
|
||||
<select value={filters.currentNode} onChange={(event) => setFilters({ ...filters, currentNode: event.target.value })}><option value="">全部当前节点</option>{data.options.currentNodes.map((value) => <option key={value}>{value}</option>)}</select>
|
||||
<input type="date" title="订单交期开始" aria-label="订单交期开始" value={filters.dueDateFrom} onChange={(event) => setFilters({ ...filters, dueDateFrom: event.target.value })} />
|
||||
<input type="date" title="订单交期结束" aria-label="订单交期结束" value={filters.dueDateTo} onChange={(event) => setFilters({ ...filters, dueDateTo: event.target.value })} />
|
||||
<button type="submit" className="finished-primary-button"><Search size={15} />查询</button><button type="button" title="重置筛选" aria-label="重置筛选" onClick={reset} className="finished-control-button"><RotateCcw size={15} /></button>
|
||||
</form>
|
||||
<section className="finished-product-table-shell">
|
||||
<FinishedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} onEdit={setEditor} onHistory={(row) => void openHistory(row)} onPreview={setPreview} />
|
||||
<FinishedProductPagination {...data.pageInfo} loading={loading} pageInput={pageInput} onPageInput={setPageInput} onCommitPage={commitPage} onPage={(page) => void load(filters, page, data.pageInfo.pageSize)} onPageSize={(pageSize) => void load(filters, 1, pageSize)} />
|
||||
</section>
|
||||
{editor && <FinishedProductEditorDrawer item={editor === 'new' ? null : editor} onClose={() => setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />}
|
||||
{logs && <FinishedProductHistoryDialog logs={logs} onClose={() => setLogs(null)} />}
|
||||
<FinishedProductImportProgress state={importTask.state} onDismiss={importTask.dismiss} />
|
||||
{preview?.imageUrl && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) setPreview(null) }}><div className="relative h-[min(82vh,900px)] w-[min(90vw,1200px)]"><Image unoptimized src={preview.imageUrl} alt={preview.productName || preview.barcode} fill sizes="90vw" className="object-contain" /><button type="button" title="关闭" onClick={() => setPreview(null)} className="absolute right-0 top-0 flex h-10 w-10 items-center justify-center rounded-full bg-black/70 text-white"><X size={20} /></button></div></div>}
|
||||
<style jsx global>{finishedProductProgressStyles}</style>
|
||||
</main>
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, Columns3, RotateCcw, X } from 'lucide-react'
|
||||
import { FINISHED_PRODUCT_PROGRESS_COLUMNS, type FinishedProductProgressColumnKey } from '../finishedProductProgressColumns'
|
||||
|
||||
export function FinishedProductColumnSettings({
|
||||
visibleKeys,
|
||||
hiddenCount,
|
||||
onToggle,
|
||||
onShowAll,
|
||||
}: {
|
||||
visibleKeys: ReadonlySet<FinishedProductProgressColumnKey>
|
||||
hiddenCount: number
|
||||
onToggle: (key: FinishedProductProgressColumnKey) => void
|
||||
onShowAll: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) }
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [open])
|
||||
|
||||
return <div ref={ref} className="relative">
|
||||
<button type="button" onClick={() => setOpen((value) => !value)} className="finished-control-button"><Columns3 size={15} />列设置{hiddenCount > 0 ? `(隐藏${hiddenCount})` : ''}</button>
|
||||
{open && <div className="absolute right-0 top-12 z-50 w-[290px] rounded-[7px] border border-zinc-200 bg-white p-3 shadow-xl">
|
||||
<div className="mb-2 flex items-center justify-between"><strong className="text-xs text-zinc-800">显示字段</strong><button type="button" title="关闭" onClick={() => setOpen(false)} className="finished-icon-button"><X size={15} /></button></div>
|
||||
<div className="grid max-h-[320px] grid-cols-2 gap-1 overflow-y-auto">
|
||||
{FINISHED_PRODUCT_PROGRESS_COLUMNS.map((column) => <button key={column.key} type="button" disabled={column.locked} onClick={() => onToggle(column.key)} className="flex h-9 items-center gap-2 rounded-[5px] px-2 text-left text-xs text-zinc-700 hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-55">
|
||||
<span className={`flex h-4 w-4 items-center justify-center rounded-[3px] border ${visibleKeys.has(column.key) ? 'border-zinc-700 bg-zinc-800 text-white' : 'border-zinc-300'}`}>{visibleKeys.has(column.key) && <Check size={11} />}</span>{column.label}
|
||||
</button>)}
|
||||
</div>
|
||||
<button type="button" onClick={onShowAll} className="mt-2 flex h-9 w-full items-center justify-center gap-2 rounded-[5px] border border-zinc-200 text-xs text-zinc-700 hover:bg-zinc-50"><RotateCcw size={13} />全部显示</button>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Save, X } from 'lucide-react'
|
||||
import type { FinishedProductProgressInput, FinishedProductProgressRow } from '@/lib/finished-product-progress'
|
||||
|
||||
const EMPTY: FinishedProductProgressInput = {
|
||||
contractNumber: '', imageUrl: '', itemNumber: '', barcode: '', productName: '', supplier: '', brand: '', orderedQuantity: 0, inTransitQuantity: 0, orderDueDate: null, currentNode: '', realtimeProgress: '',
|
||||
}
|
||||
|
||||
function toDraft(row: FinishedProductProgressRow | null): FinishedProductProgressInput {
|
||||
if (!row) return EMPTY
|
||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, updatedByName: _updatedByName, ...input } = row
|
||||
return input
|
||||
}
|
||||
|
||||
export function FinishedProductEditorDrawer({ item, onClose, onSaved }: { item: FinishedProductProgressRow | null; onClose: () => void; onSaved: (row: FinishedProductProgressRow) => void }) {
|
||||
const [draft, setDraft] = useState(() => toDraft(item))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const set = <K extends keyof FinishedProductProgressInput>(key: K, value: FinishedProductProgressInput[K]) => setDraft((current) => ({ ...current, [key]: value }))
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(item ? `/api/supply-chain/finished-product-progress/${item.id}` : '/api/supply-chain/finished-product-progress', { method: item ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(draft) })
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '保存失败')
|
||||
onSaved(payload.data)
|
||||
} catch (error) { window.alert(error instanceof Error ? error.message : '保存失败') } finally { setSaving(false) }
|
||||
}
|
||||
return <div className="fixed inset-0 z-[70] flex justify-end bg-black/30" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}>
|
||||
<section className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{item ? '编辑成品进度' : '新增成品进度'}</h2><p className="mt-0.5 text-xs text-zinc-500">合同号与条码共同识别一条记录</p></div><button type="button" title="关闭" disabled={saving} onClick={onClose} className="finished-icon-button"><X size={18} /></button></header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2">
|
||||
<Field label="合同号" required value={draft.contractNumber} onChange={(value) => set('contractNumber', value)} />
|
||||
<Field label="条码" required value={draft.barcode} onChange={(value) => set('barcode', value)} />
|
||||
<Field label="货号" value={draft.itemNumber} onChange={(value) => set('itemNumber', value)} />
|
||||
<Field label="产品名称" value={draft.productName} onChange={(value) => set('productName', value)} />
|
||||
<Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} />
|
||||
<Field label="品牌" value={draft.brand} onChange={(value) => set('brand', value)} />
|
||||
<NumberField label="下单数量" value={draft.orderedQuantity} onChange={(value) => set('orderedQuantity', value)} />
|
||||
<NumberField label="在途数量" value={draft.inTransitQuantity} onChange={(value) => set('inTransitQuantity', value)} />
|
||||
<label className="finished-field"><span>订单交期(周更)</span><input type="date" value={draft.orderDueDate ?? ''} onChange={(event) => set('orderDueDate', event.target.value || null)} /></label>
|
||||
<Field label="当前节点" value={draft.currentNode} onChange={(value) => set('currentNode', value)} />
|
||||
<label className="finished-field sm:col-span-2"><span>图片 URL</span><input value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://..." /></label>
|
||||
<label className="finished-field sm:col-span-2"><span>实时</span><textarea rows={5} value={draft.realtimeProgress} onChange={(event) => set('realtimeProgress', event.target.value)} placeholder="填写当前实际进度" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="finished-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="finished-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange, required = false }: { label: string; value: string; onChange: (value: string) => void; required?: boolean }) {
|
||||
return <label className="finished-field"><span>{label}{required && <em>*</em>}</span><input value={value} onChange={(event) => onChange(event.target.value)} /></label>
|
||||
}
|
||||
|
||||
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) {
|
||||
return <label className="finished-field"><span>{label}</span><input type="number" min={0} step={1} value={value} onChange={(event) => onChange(Math.max(0, Math.trunc(Number(event.target.value) || 0)))} /></label>
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { X } from 'lucide-react'
|
||||
import type { FinishedProductProgressChangeLogEntry } from '@/lib/finished-product-progress'
|
||||
|
||||
const OPERATION_LABELS: Record<string, string> = { CREATE: '新增', UPDATE: '编辑', IMPORT_CREATE: '导入新增', IMPORT_UPDATE: '导入更新' }
|
||||
|
||||
function valueText(value: string | number | null) {
|
||||
if (value === null || value === '') return '空白'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function FinishedProductHistoryDialog({ logs, onClose }: { logs: FinishedProductProgressChangeLogEntry[]; onClose: () => void }) {
|
||||
return <div className="fixed inset-0 z-[75] flex items-center justify-center bg-black/35 p-5" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
|
||||
<section className="flex max-h-[82vh] w-full max-w-[820px] flex-col rounded-[7px] border border-zinc-200 bg-white shadow-2xl">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200 px-5"><div><h2 className="text-sm font-bold">修改日志</h2><p className="mt-0.5 text-xs text-zinc-500">记录新增、编辑和导入造成的字段变化</p></div><button type="button" title="关闭" onClick={onClose} className="finished-icon-button"><X size={17} /></button></header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">{logs.length === 0 ? <div className="py-14 text-center text-sm text-zinc-500">暂无修改日志</div> : <div className="space-y-3">{logs.map((log) => <article key={log.id} className="rounded-[6px] border border-zinc-200 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"><strong className="text-xs text-zinc-800">{OPERATION_LABELS[log.operation] ?? log.operation}</strong><span className="text-xs text-zinc-500">{log.actorName || '未知操作人'} · {new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(log.createdAt))}</span></div>
|
||||
<div className="space-y-2">{log.changes.map((change, index) => <div key={`${change.field}-${index}`} className="grid gap-1 rounded-[5px] bg-zinc-50 px-3 py-2 text-xs sm:grid-cols-[110px_1fr_20px_1fr]"><span className="font-semibold text-zinc-700">{change.label}</span><span className="break-all text-zinc-500">{valueText(change.before)}</span><span className="text-center text-zinc-400">→</span><span className="break-all text-zinc-800">{valueText(change.after)}</span></div>)}</div>
|
||||
</article>)}</div>}</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, CircleAlert, Upload, X } from 'lucide-react'
|
||||
import type { FinishedProductImportState } from '../useFinishedProductImport'
|
||||
|
||||
export function FinishedProductImportProgress({ state, onDismiss }: { state: FinishedProductImportState | null; onDismiss: () => void }) {
|
||||
if (!state) return null
|
||||
const active = state.status === 'preparing' || state.status === 'running'
|
||||
const percent = state.total > 0 ? Math.min(100, Math.round(state.processed / state.total * 100)) : 0
|
||||
const Icon = state.status === 'complete' ? CheckCircle2 : state.status === 'error' ? CircleAlert : Upload
|
||||
return <aside aria-live="polite" className="fixed bottom-6 right-7 z-[80] w-[320px] rounded-[7px] border border-zinc-300 bg-white p-4 text-zinc-800 shadow-xl max-sm:bottom-20 max-sm:right-3 max-sm:w-[calc(100vw-24px)]">
|
||||
<header className="flex items-center justify-between"><strong className="flex items-center gap-2 text-[13px]"><Icon size={16} />{state.status === 'complete' ? '导入完成' : state.status === 'error' ? '导入失败' : '正在导入'}</strong>{!active && <button type="button" title="关闭导入进度" onClick={onDismiss} className="finished-icon-button"><X size={15} /></button>}</header>
|
||||
<div className="mt-2 truncate text-xs text-zinc-500" title={state.fileName}>{state.fileName}</div>
|
||||
<div className="mt-3 flex justify-between text-xs"><span>{state.total > 0 ? `${state.processed} / ${state.total} 条` : '正在读取文件...'}</span>{state.total > 0 && <strong>{percent}%</strong>}</div>
|
||||
<div role="progressbar" aria-label="成品进度导入进度" aria-valuemin={0} aria-valuemax={state.total || undefined} aria-valuenow={state.total ? state.processed : undefined} className="mt-2 h-1.5 overflow-hidden rounded-full bg-zinc-200"><span className={`block h-full rounded-full ${state.status === 'error' ? 'bg-red-700' : state.status === 'complete' ? 'bg-emerald-700' : 'bg-zinc-800'}`} style={{ width: state.total ? `${percent}%` : '36%' }} /></div>
|
||||
<div className="mt-2 flex gap-5 text-xs text-zinc-500"><span>新增 {state.created}</span><span>更新 {state.updated}</span></div>
|
||||
{state.message && <p className="mt-2 text-xs text-red-700">{state.message}</p>}
|
||||
</aside>
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
|
||||
export function FinishedProductPagination({ page, pageSize, total, totalPages, loading, pageInput, onPageInput, onCommitPage, onPage, onPageSize }: { page: number; pageSize: number; total: number; totalPages: number; loading: boolean; pageInput: string; onPageInput: (value: string) => void; onCommitPage: () => void; onPage: (page: number) => void; onPageSize: (pageSize: number) => void }) {
|
||||
return <nav aria-label="成品进度明细分页" className="absolute bottom-6 left-1/2 z-30 flex max-w-[calc(100%-32px)] -translate-x-1/2 items-center gap-2.5 whitespace-nowrap rounded-full border border-zinc-200 bg-white/95 px-4 py-2 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)] backdrop-blur-xl max-sm:gap-1 max-sm:px-2 max-sm:text-xs">
|
||||
<span className="mr-1 text-zinc-600">{loading ? '正在统计...' : `共 ${total} 条`}</span>
|
||||
<button type="button" disabled={loading || page <= 1} onClick={() => onPage(page - 1)} className="finished-page-button"><ArrowLeft size={12} />上一页</button>
|
||||
<span className="inline-flex items-center gap-1 text-zinc-600"><input type="text" inputMode="numeric" pattern="[0-9]*" aria-label="跳转页码" value={pageInput} disabled={loading} onChange={(event) => onPageInput(event.target.value.replace(/\D/g, ''))} onBlur={onCommitPage} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); onCommitPage() } }} className="h-7 w-[52px] rounded-[4px] border border-zinc-200 text-center font-semibold outline-none focus:border-zinc-500" /><span className="text-zinc-400">/</span><span>{totalPages} 页</span></span>
|
||||
<button type="button" disabled={loading || page >= totalPages} onClick={() => onPage(page + 1)} className="finished-page-button">下一页<ArrowRight size={12} /></button>
|
||||
<span className="ml-1 text-zinc-500">每页<select value={pageSize} onChange={(event) => onPageSize(Number(event.target.value))} className="mx-1 h-7 rounded-[4px] border border-zinc-200 bg-white px-1"><option value={50}>50</option><option value={100}>100</option><option value={200}>200</option></select>条</span>
|
||||
</nav>
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { History, Pencil } from 'lucide-react'
|
||||
import type { FinishedProductProgressRow } from '@/lib/finished-product-progress'
|
||||
import type { FinishedProductProgressColumn, FinishedProductProgressColumnKey } from '../finishedProductProgressColumns'
|
||||
|
||||
export function FinishedProductTable({ rows, columns, canEdit, onEdit, onHistory, onPreview }: { rows: FinishedProductProgressRow[]; columns: readonly FinishedProductProgressColumn[]; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void }) {
|
||||
const minWidth = columns.reduce((total, column) => total + column.minWidth, 0)
|
||||
return <div className="min-h-0 flex-1 overflow-auto overscroll-contain">
|
||||
<table className="w-full border-separate border-spacing-0 text-center text-xs" style={{ minWidth }}>
|
||||
<thead className="text-[11px] font-bold text-zinc-600"><tr>{columns.map((column) => <th key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-3 py-3 ${column.stickyClassName ?? ''}`}>{column.label}</th>)}</tr></thead>
|
||||
<tbody>{rows.map((row) => <tr key={row.id} className="group hover:bg-zinc-50">{columns.map((column) => <FinishedProductCell key={column.key} column={column.key} row={row} canEdit={canEdit} onEdit={onEdit} onHistory={onHistory} onPreview={onPreview} />)}</tr>)}</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && <div className="py-20 text-center text-sm text-zinc-500">暂无匹配的成品进度记录</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function FinishedProductCell({ column, row, canEdit, onEdit, onHistory, onPreview }: { column: FinishedProductProgressColumnKey; row: FinishedProductProgressRow; canEdit: boolean; onEdit: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void }) {
|
||||
const base = 'border-b border-zinc-100 bg-white px-3 py-3 text-zinc-700 group-hover:bg-zinc-50'
|
||||
if (column === 'contractNumber') return <td className={`${base} sticky left-0 z-10 font-semibold text-zinc-900`}>{row.contractNumber || '-'}</td>
|
||||
if (column === 'imageUrl') return <td className={`${base} sticky left-[150px] z-10`}>{row.imageUrl ? <button type="button" onClick={() => onPreview(row)} className="relative h-12 w-12 overflow-hidden rounded-[5px] border border-zinc-200 bg-zinc-50"><Image unoptimized src={row.imageUrl} alt={row.productName || row.barcode} fill sizes="48px" className="object-cover" /></button> : <span className="text-zinc-400">-</span>}</td>
|
||||
if (column === 'actions') return <td className={`${base} sticky right-0 z-10 border-l border-zinc-200`}><div className="flex justify-center gap-1">{canEdit && <button type="button" title="编辑" onClick={() => onEdit(row)} className="finished-row-button"><Pencil size={14} />编辑</button>}<button type="button" title="修改日志" onClick={() => onHistory(row)} className="finished-row-button"><History size={14} />日志</button></div></td>
|
||||
if (column === 'orderedQuantity' || column === 'inTransitQuantity') return <td className={`${base} tabular-nums`}>{row[column].toLocaleString('zh-CN')}</td>
|
||||
if (column === 'orderDueDate') return <td className={`${base} whitespace-nowrap`}>{row.orderDueDate || '-'}</td>
|
||||
return <td className={`${base} max-w-[260px] whitespace-pre-wrap break-words`} title={String(row[column] || '')}>{String(row[column] || '-')}</td>
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { readFinishedProductImportStream } from './finishedProductImportStream'
|
||||
|
||||
function responseFromChunks(chunks: string[]) {
|
||||
const encoder = new TextEncoder()
|
||||
let index = 0
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (index >= chunks.length) return { done: true, value: undefined }
|
||||
return { done: false, value: encoder.encode(chunks[index++]) }
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('finished product import stream', () => {
|
||||
it('parses progress split across network chunks', async () => {
|
||||
const events: string[] = []
|
||||
const result = await readFinishedProductImportStream(responseFromChunks([
|
||||
'{"type":"progress","progress":{"processed":1,"total":2,',
|
||||
'"created":1,"updated":0}}\n{"type":"complete","progress":{"processed":2,"total":2,"created":1,"updated":1}}\n',
|
||||
]), (event) => events.push(event.type))
|
||||
expect(events).toEqual(['progress', 'complete'])
|
||||
expect(result).toEqual({ processed: 2, total: 2, created: 1, updated: 1 })
|
||||
})
|
||||
|
||||
it('surfaces server-side row errors with the current progress', async () => {
|
||||
await expect(readFinishedProductImportStream(responseFromChunks([
|
||||
'{"type":"error","progress":{"processed":3,"total":5,"created":2,"updated":1},"message":"第5行:条码不能为空"}\n',
|
||||
]), () => undefined)).rejects.toThrow('第5行:条码不能为空')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,43 @@
|
||||
import type { FinishedProductProgressImportProgress, FinishedProductProgressImportStreamEvent } from '@/lib/finished-product-progress'
|
||||
|
||||
function parseEvent(line: string): FinishedProductProgressImportStreamEvent {
|
||||
const value = JSON.parse(line) as Record<string, unknown>
|
||||
const progress = value.progress as Record<string, unknown> | undefined
|
||||
if (!progress || !['processed', 'total', 'created', 'updated'].every((key) => typeof progress[key] === 'number')) {
|
||||
throw new Error('导入进度响应格式不正确')
|
||||
}
|
||||
const normalized = progress as FinishedProductProgressImportProgress
|
||||
if (value.type === 'progress' || value.type === 'complete') return { type: value.type, progress: normalized }
|
||||
if (value.type === 'error' && typeof value.message === 'string') return { type: 'error', progress: normalized, message: value.message }
|
||||
throw new Error('导入进度响应格式不正确')
|
||||
}
|
||||
|
||||
export async function readFinishedProductImportStream(response: Response, onEvent: (event: FinishedProductProgressImportStreamEvent) => void) {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
throw new Error(payload?.error?.message || '导入请求失败')
|
||||
}
|
||||
if (!response.body) throw new Error('浏览器无法读取导入进度')
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let complete: FinishedProductProgressImportProgress | null = null
|
||||
const consume = (line: string) => {
|
||||
if (!line.trim()) return
|
||||
const event = parseEvent(line)
|
||||
onEvent(event)
|
||||
if (event.type === 'complete') complete = event.progress
|
||||
if (event.type === 'error') throw new Error(event.message)
|
||||
}
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
buffer += decoder.decode(value, { stream: !done })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
lines.forEach(consume)
|
||||
if (done) break
|
||||
}
|
||||
consume(buffer)
|
||||
if (!complete) throw new Error('导入连接提前结束,请查看已导入的数据')
|
||||
return complete
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import type { FinishedProductProgressFieldKey } from '@/lib/finished-product-progress'
|
||||
|
||||
export type FinishedProductProgressColumnKey = FinishedProductProgressFieldKey | 'actions'
|
||||
|
||||
export type FinishedProductProgressColumn = {
|
||||
key: FinishedProductProgressColumnKey
|
||||
label: string
|
||||
minWidth: number
|
||||
locked?: boolean
|
||||
stickyClassName?: string
|
||||
}
|
||||
|
||||
export const FINISHED_PRODUCT_PROGRESS_COLUMNS: readonly FinishedProductProgressColumn[] = [
|
||||
{ key: 'contractNumber', label: '合同号', minWidth: 150, locked: true, stickyClassName: 'left-0 z-40' },
|
||||
{ key: 'imageUrl', label: '图片', minWidth: 86, locked: true, stickyClassName: 'left-[150px] z-40' },
|
||||
{ key: 'itemNumber', label: '货号', minWidth: 140 },
|
||||
{ key: 'barcode', label: '条码', minWidth: 160 },
|
||||
{ key: 'productName', label: '产品名称', minWidth: 220 },
|
||||
{ key: 'supplier', label: '供应商', minWidth: 180 },
|
||||
{ key: 'brand', label: '品牌', minWidth: 150 },
|
||||
{ key: 'orderedQuantity', label: '下单数量', minWidth: 100 },
|
||||
{ key: 'inTransitQuantity', label: '在途数量', minWidth: 100 },
|
||||
{ key: 'orderDueDate', label: '订单交期(周更)', minWidth: 140 },
|
||||
{ key: 'currentNode', label: '当前节点', minWidth: 140 },
|
||||
{ key: 'realtimeProgress', label: '实时', minWidth: 240 },
|
||||
{ key: 'actions', label: '操作', minWidth: 132, locked: true, stickyClassName: 'right-0 z-40' },
|
||||
]
|
||||
|
||||
export const FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS = new Set(
|
||||
FINISHED_PRODUCT_PROGRESS_COLUMNS.map((column) => column.key),
|
||||
)
|
||||
|
||||
export const LOCKED_FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS = new Set(
|
||||
FINISHED_PRODUCT_PROGRESS_COLUMNS.filter((column) => column.locked).map((column) => column.key),
|
||||
)
|
||||
@ -0,0 +1,18 @@
|
||||
export const finishedProductProgressStyles = String.raw`
|
||||
.finished-product-page{height:100%;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:#f5f6f6;color:#27272a}
|
||||
.finished-product-toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px;padding:18px 24px;border-bottom:1px solid #e4e4e7;background:#fff}
|
||||
.finished-product-toolbar h1{margin:0;font-size:18px;font-weight:750;letter-spacing:0}.finished-product-toolbar p{margin:4px 0 0;color:#71717a;font-size:12px}
|
||||
.finished-product-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px}
|
||||
.finished-control-button,.finished-primary-button,.finished-secondary-button,.finished-page-button,.finished-row-button,.finished-icon-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s}
|
||||
.finished-control-button,.finished-primary-button,.finished-secondary-button{height:38px;padding:0 13px}.finished-primary-button{border-color:#27272a;background:#27272a;color:#fff}.finished-primary-button:hover{background:#18181b}.finished-secondary-button:hover,.finished-control-button:hover,.finished-row-button:hover,.finished-page-button:hover{background:#f4f4f5;border-color:#a1a1aa}
|
||||
.finished-control-button:disabled,.finished-primary-button:disabled,.finished-secondary-button:disabled,.finished-row-button:disabled,.finished-page-button:disabled{cursor:not-allowed;opacity:.45}
|
||||
.finished-icon-button{width:30px;height:30px;padding:0;border:0;background:transparent}.finished-icon-button:hover{background:#f4f4f5}
|
||||
.finished-row-button{height:29px;padding:0 8px;font-size:11px}.finished-page-button{height:30px;padding:0 12px}
|
||||
.finished-product-filters{display:grid;grid-template-columns:minmax(240px,2fr) repeat(3,minmax(130px,1fr)) 150px 150px auto auto;gap:8px;padding:12px 24px;border-bottom:1px solid #e4e4e7;background:#fff}
|
||||
.finished-product-filters input,.finished-product-filters select{min-width:0;height:38px;padding:0 11px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;outline:none}.finished-product-filters input:focus,.finished-product-filters select:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}
|
||||
.finished-product-table-shell{position:relative;display:flex;min-height:0;flex:1;flex-direction:column;margin:16px 20px 20px;overflow:hidden;border:1px solid #e4e4e7;border-radius:7px;background:#fff;box-shadow:0 8px 24px rgba(24,24,27,.05)}
|
||||
.finished-product-table-shell>div:first-child{padding-bottom:76px}
|
||||
.finished-field{display:flex;flex-direction:column;gap:6px}.finished-field>span{font-size:12px;font-weight:650;color:#52525b}.finished-field em{margin-left:3px;color:#b91c1c;font-style:normal}.finished-field input,.finished-field textarea{width:100%;box-sizing:border-box;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:9px 10px;color:#27272a;font-size:13px;outline:none}.finished-field input{height:40px}.finished-field textarea{resize:vertical;line-height:1.6}.finished-field input:focus,.finished-field textarea:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}
|
||||
@media(max-width:1280px){.finished-product-filters{grid-template-columns:repeat(4,minmax(140px,1fr))}.finished-product-filters>input:first-child{grid-column:span 2}}
|
||||
@media(max-width:760px){.finished-product-toolbar{align-items:flex-start;padding:14px}.finished-product-actions{justify-content:flex-start}.finished-product-filters{grid-template-columns:1fr 1fr;padding:10px 14px}.finished-product-filters>input:first-child{grid-column:1/-1}.finished-product-table-shell{margin:10px}.finished-product-table-shell>div:first-child{padding-bottom:72px}.finished-page-button{padding:0 7px}.finished-product-toolbar p{display:none}}
|
||||
`
|
||||
@ -0,0 +1,15 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
|
||||
import { FinishedProductProgressClient } from './FinishedProductProgressClient'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: '成品进度明细 · 供应链中心' }
|
||||
|
||||
export default async function FinishedProductProgressPage() {
|
||||
const { authUser, user } = await requireSession('/login')
|
||||
const access = await finishedProductProgressService.getAccess(authUser)
|
||||
if (!access.view) redirect('/supply-chain')
|
||||
const initialData = await finishedProductProgressService.listPage({ page: 1, pageSize: 50, user: authUser })
|
||||
return <FinishedProductProgressClient initialData={initialData} currentUserName={user.name} canEdit={access.edit} canDownload={access.download} />
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { FinishedProductProgressImportProgress, FinishedProductProgressImportStreamEvent } from '@/lib/finished-product-progress'
|
||||
import { readFinishedProductImportStream } from './finishedProductImportStream'
|
||||
|
||||
export type FinishedProductImportState = FinishedProductProgressImportProgress & {
|
||||
fileName: string
|
||||
status: 'preparing' | 'running' | 'complete' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const EMPTY = { processed: 0, total: 0, created: 0, updated: 0 }
|
||||
|
||||
export function useFinishedProductImport(onCompleted: () => Promise<void>) {
|
||||
const [state, setState] = useState<FinishedProductImportState | null>(null)
|
||||
const isImporting = state?.status === 'preparing' || state?.status === 'running'
|
||||
const importFile = useCallback(async (file: File) => {
|
||||
if (isImporting) return
|
||||
setState({ ...EMPTY, fileName: file.name, status: 'preparing' })
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
try {
|
||||
const response = await fetch('/api/supply-chain/finished-product-progress/import', { method: 'POST', body })
|
||||
await readFinishedProductImportStream(response, (event: FinishedProductProgressImportStreamEvent) => {
|
||||
setState({ ...event.progress, fileName: file.name, status: event.type === 'progress' ? 'running' : event.type, ...(event.type === 'error' ? { message: event.message } : {}) })
|
||||
})
|
||||
await onCompleted()
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...EMPTY, ...current, fileName: file.name, status: 'error', message: error instanceof Error ? error.message : '导入失败' }))
|
||||
}
|
||||
}, [isImporting, onCompleted])
|
||||
return {
|
||||
state,
|
||||
isImporting,
|
||||
importFile,
|
||||
dismiss: () => setState((current) => current?.status === 'preparing' || current?.status === 'running' ? current : null),
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
FINISHED_PRODUCT_PROGRESS_COLUMNS,
|
||||
FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS,
|
||||
LOCKED_FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS,
|
||||
type FinishedProductProgressColumnKey,
|
||||
} from './finishedProductProgressColumns'
|
||||
|
||||
function storageKey(userName: string) {
|
||||
return `finished-product-progress:hidden-columns:${userName.trim().toLocaleLowerCase() || 'anonymous'}`
|
||||
}
|
||||
|
||||
export function useFinishedProductProgressColumns(userName: string) {
|
||||
const [hiddenKeys, setHiddenKeys] = useState<Set<FinishedProductProgressColumnKey>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(storageKey(userName)) ?? '[]')
|
||||
const next = new Set<FinishedProductProgressColumnKey>()
|
||||
if (Array.isArray(value)) value.forEach((key) => {
|
||||
if (
|
||||
typeof key === 'string'
|
||||
&& FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS.has(key as FinishedProductProgressColumnKey)
|
||||
&& !LOCKED_FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS.has(key as FinishedProductProgressColumnKey)
|
||||
) next.add(key as FinishedProductProgressColumnKey)
|
||||
})
|
||||
// Hydrate the browser-only preference after the server render.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setHiddenKeys(next)
|
||||
} catch {
|
||||
setHiddenKeys(new Set())
|
||||
}
|
||||
}, [userName])
|
||||
|
||||
const save = (next: Set<FinishedProductProgressColumnKey>) => {
|
||||
setHiddenKeys(next)
|
||||
localStorage.setItem(storageKey(userName), JSON.stringify([...next]))
|
||||
}
|
||||
|
||||
const toggle = (key: FinishedProductProgressColumnKey) => {
|
||||
if (LOCKED_FINISHED_PRODUCT_PROGRESS_COLUMN_KEYS.has(key)) return
|
||||
const next = new Set(hiddenKeys)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
save(next)
|
||||
}
|
||||
|
||||
const visibleColumns = useMemo(
|
||||
() => FINISHED_PRODUCT_PROGRESS_COLUMNS.filter((column) => column.locked || !hiddenKeys.has(column.key)),
|
||||
[hiddenKeys],
|
||||
)
|
||||
|
||||
return {
|
||||
hiddenKeys,
|
||||
visibleColumns,
|
||||
hiddenCount: hiddenKeys.size,
|
||||
visibleKeySet: new Set(visibleColumns.map((column) => column.key)),
|
||||
toggle,
|
||||
showAll: () => save(new Set()),
|
||||
}
|
||||
}
|
||||
@ -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>,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
)),
|
||||
}
|
||||
}
|
||||
51
furniture-3d-system/src/lib/barcode-print-import-edit.ts
Normal file
51
furniture-3d-system/src/lib/barcode-print-import-edit.ts
Normal 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>
|
||||
>;
|
||||
@ -0,0 +1,33 @@
|
||||
import {
|
||||
FINISHED_PRODUCT_PROGRESS_FIELDS,
|
||||
finishedProductProgressInputSchema,
|
||||
} from './finished-product-progress'
|
||||
|
||||
describe('finished product progress input', () => {
|
||||
it('keeps the confirmed table header order', () => {
|
||||
expect(FINISHED_PRODUCT_PROGRESS_FIELDS.map((field) => field.label)).toEqual([
|
||||
'合同号', '图片', '货号', '条码', '产品名称', '供应商', '品牌', '下单数量', '在途数量', '订单交期(周更)', '当前节点', '实时',
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes a valid imported row', () => {
|
||||
const result = finishedProductProgressInputSchema.parse({
|
||||
contractNumber: ' HT-001 ', imageUrl: '', itemNumber: 'CP001', barcode: '690001', productName: '餐椅', supplier: '供应商A', brand: '品牌A', orderedQuantity: '1,200', inTransitQuantity: '80', orderDueDate: '2026-09-01', currentNode: '生产中', realtimeProgress: '已完成木工',
|
||||
})
|
||||
expect(result).toMatchObject({ contractNumber: 'HT-001', orderedQuantity: 1200, inTransitQuantity: 80, orderDueDate: '2026-09-01' })
|
||||
})
|
||||
|
||||
it('requires contract number and barcode and rejects negative quantities', () => {
|
||||
const base = { contractNumber: '', imageUrl: '', itemNumber: '', barcode: '', productName: '', supplier: '', brand: '', orderedQuantity: -1, inTransitQuantity: 0, orderDueDate: null, currentNode: '', realtimeProgress: '' }
|
||||
const result = finishedProductProgressInputSchema.safeParse(base)
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) expect(result.error.issues.map((issue) => issue.message)).toEqual(expect.arrayContaining(['合同号不能为空', '条码不能为空', '下单数量不能小于0']))
|
||||
})
|
||||
|
||||
it('converts an Excel serial date to an ISO date', () => {
|
||||
const result = finishedProductProgressInputSchema.parse({
|
||||
contractNumber: 'HT-002', imageUrl: '', itemNumber: '', barcode: '690002', productName: '', supplier: '', brand: '', orderedQuantity: 0, inTransitQuantity: 0, orderDueDate: 46266, currentNode: '', realtimeProgress: '',
|
||||
})
|
||||
expect(result.orderDueDate).toBe('2026-09-01')
|
||||
})
|
||||
})
|
||||
124
furniture-3d-system/src/lib/finished-product-progress.ts
Normal file
124
furniture-3d-system/src/lib/finished-product-progress.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const FINISHED_PRODUCT_PROGRESS_FIELDS = [
|
||||
{ key: 'contractNumber', label: '合同号' },
|
||||
{ key: 'imageUrl', label: '图片' },
|
||||
{ key: 'itemNumber', label: '货号' },
|
||||
{ key: 'barcode', label: '条码' },
|
||||
{ key: 'productName', label: '产品名称' },
|
||||
{ key: 'supplier', label: '供应商' },
|
||||
{ key: 'brand', label: '品牌' },
|
||||
{ key: 'orderedQuantity', label: '下单数量' },
|
||||
{ key: 'inTransitQuantity', label: '在途数量' },
|
||||
{ key: 'orderDueDate', label: '订单交期(周更)' },
|
||||
{ key: 'currentNode', label: '当前节点' },
|
||||
{ key: 'realtimeProgress', label: '实时' },
|
||||
] as const
|
||||
|
||||
export type FinishedProductProgressFieldKey = typeof FINISHED_PRODUCT_PROGRESS_FIELDS[number]['key']
|
||||
|
||||
const cleanString = (max: number) => z.preprocess(
|
||||
(value) => String(value ?? '').trim(),
|
||||
z.string().max(max),
|
||||
)
|
||||
|
||||
const requiredString = (label: string, max: number) => z.preprocess(
|
||||
(value) => String(value ?? '').trim(),
|
||||
z.string().min(1, `${label}不能为空`).max(max),
|
||||
)
|
||||
|
||||
const nonnegativeInteger = (label: string) => z.preprocess((value) => {
|
||||
if (value === '' || value === null || value === undefined) return 0
|
||||
return typeof value === 'number' ? value : Number(String(value).replace(/,/g, '').trim())
|
||||
}, z.number({ message: `${label}必须是数字` }).int(`${label}必须是整数`).min(0, `${label}不能小于0`).max(2_000_000_000, `${label}超出允许范围`))
|
||||
|
||||
function normalizeDate(value: unknown) {
|
||||
if (value === '' || value === null || value === undefined) return null
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? value : value.toISOString().slice(0, 10)
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
const excelEpoch = Date.UTC(1899, 11, 30)
|
||||
return new Date(excelEpoch + Math.trunc(value) * 86_400_000).toISOString().slice(0, 10)
|
||||
}
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
const optionalDate = z.preprocess(
|
||||
normalizeDate,
|
||||
z.string().refine((value) => !Number.isNaN(new Date(`${value}T00:00:00Z`).getTime()), '订单交期格式不正确').nullable(),
|
||||
)
|
||||
|
||||
export const finishedProductProgressInputSchema = z.object({
|
||||
contractNumber: requiredString('合同号', 160),
|
||||
imageUrl: cleanString(2_000),
|
||||
itemNumber: cleanString(160),
|
||||
barcode: requiredString('条码', 160),
|
||||
productName: cleanString(240),
|
||||
supplier: cleanString(240),
|
||||
brand: cleanString(160),
|
||||
orderedQuantity: nonnegativeInteger('下单数量'),
|
||||
inTransitQuantity: nonnegativeInteger('在途数量'),
|
||||
orderDueDate: optionalDate,
|
||||
currentNode: cleanString(160),
|
||||
realtimeProgress: cleanString(2_000),
|
||||
}).strict()
|
||||
|
||||
export type FinishedProductProgressInput = z.infer<typeof finishedProductProgressInputSchema>
|
||||
|
||||
export const EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS = {
|
||||
search: '',
|
||||
supplier: '',
|
||||
brand: '',
|
||||
currentNode: '',
|
||||
dueDateFrom: '',
|
||||
dueDateTo: '',
|
||||
} as const
|
||||
|
||||
export type FinishedProductProgressFilters = {
|
||||
[K in keyof typeof EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS]: string
|
||||
}
|
||||
|
||||
export type FinishedProductProgressRow = FinishedProductProgressInput & {
|
||||
id: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
updatedByName: string
|
||||
}
|
||||
|
||||
export type FinishedProductProgressPage = {
|
||||
rows: FinishedProductProgressRow[]
|
||||
pageInfo: { page: number; pageSize: number; total: number; totalPages: number }
|
||||
options: { suppliers: string[]; brands: string[]; currentNodes: string[] }
|
||||
}
|
||||
|
||||
export type FinishedProductProgressChange = {
|
||||
field: FinishedProductProgressFieldKey
|
||||
label: string
|
||||
before: string | number | null
|
||||
after: string | number | null
|
||||
}
|
||||
|
||||
export type FinishedProductProgressChangeLogEntry = {
|
||||
id: string
|
||||
operation: string
|
||||
actorName: string
|
||||
changes: FinishedProductProgressChange[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type FinishedProductProgressImportProgress = {
|
||||
processed: number
|
||||
total: number
|
||||
created: number
|
||||
updated: number
|
||||
}
|
||||
|
||||
export type FinishedProductProgressImportStreamEvent =
|
||||
| { type: 'progress' | 'complete'; progress: FinishedProductProgressImportProgress }
|
||||
| { type: 'error'; progress: FinishedProductProgressImportProgress; message: string }
|
||||
|
||||
export const FINISHED_PRODUCT_PROGRESS_IMPORT_HEADERS = Object.fromEntries(
|
||||
FINISHED_PRODUCT_PROGRESS_FIELDS.flatMap((field) => [
|
||||
[field.label, field.key],
|
||||
[field.key, field.key],
|
||||
]),
|
||||
) as Record<string, FinishedProductProgressFieldKey>
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
111
furniture-3d-system/src/lib/non-integrated-sku-export.test.ts
Normal file
111
furniture-3d-system/src/lib/non-integrated-sku-export.test.ts
Normal 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 个表头,缺少:材质、安装方式",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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('、')}`,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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\")')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
@ -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,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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',
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
@ -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] }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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 =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user