feat(supply-chain): maintain supplier information by barcode

This commit is contained in:
WuYongJie 2026-08-28 15:43:15 +08:00
parent cc7e45a225
commit b27399d908
23 changed files with 1447 additions and 820 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import {
SUPPLY_CHAIN_SUPPLIER_INFORMATION_IMPORT_FIELDS,
type SupplyChainSupplierInformationEditableField,
type SupplyChainSupplierInformationImportProgress,
type SupplyChainSupplierInformationImportRow,
type SupplyChainSupplierInformationImportStreamEvent,
} from '@/lib/supply-chain-supplier-information'
import { ErrorCode, isAppError } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service'
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_ROWS = 5_000
function cellText(value: unknown) {
return String(value ?? '').trim()
}
function parseRows(sheet: XLSX.WorkSheet) {
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
defval: '',
raw: false,
})
const headerRow = matrix[0]?.map(cellText) ?? []
const headerIndex = new Map(headerRow.map((header, index) => [header, index]))
const barcodeIndex = headerIndex.get('条码') ?? headerIndex.get('barcode')
if (barcodeIndex === undefined) return { error: '导入文件缺少“条码”列', rows: [] }
const rows = matrix.slice(1).flatMap((values, index) => {
if (!values.some((value) => cellText(value))) return []
const updates = Object.fromEntries(
SUPPLY_CHAIN_SUPPLIER_INFORMATION_IMPORT_FIELDS.flatMap((field) => {
if (field.key === 'barcode') return []
const columnIndex = headerIndex.get(field.label) ?? headerIndex.get(field.key)
if (columnIndex === undefined) return []
const value = cellText(values[columnIndex])
return value ? [[field.key, value]] : []
}),
) as Partial<Record<SupplyChainSupplierInformationEditableField, string>>
return [{
rowNumber: index + 2,
barcode: cellText(values[barcodeIndex]),
updates,
} satisfies SupplyChainSupplierInformationImportRow]
})
return { rows }
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
await supplyChainSupplierInformationService.requireEditAccess(ctx.user)
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
}
if (file.size > MAX_FILE_SIZE) {
return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB', 400, ctx.requestId)
}
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer' })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
if (!sheet) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
}
const parsed = parseRows(sheet)
if (parsed.error) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, parsed.error, 400, ctx.requestId)
}
if (parsed.rows.length === 0) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
}
if (parsed.rows.length > MAX_ROWS) {
return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行', 400, ctx.requestId)
}
const encoder = new TextEncoder()
const initial: SupplyChainSupplierInformationImportProgress = {
processed: 0,
total: parsed.rows.length,
updated: 0,
failed: 0,
errors: [],
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false
let progress = initial
const send = (event: SupplyChainSupplierInformationImportStreamEvent) => {
if (closed) return
try {
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`))
} catch {
closed = true
}
}
const close = () => {
if (closed) return
closed = true
controller.close()
}
send({ type: 'progress', progress })
void supplyChainSupplierInformationService.importRows(parsed.rows, ctx.user, (next) => {
progress = next
send({ type: 'progress', progress })
}).then((result) => {
progress = result
send({ type: 'complete', progress })
close()
}).catch((error: unknown) => {
console.error(`[${ctx.requestId}] Supplier information import failed:`, error)
send({
type: 'error',
progress,
message: isAppError(error) ? error.message : '导入过程中发生错误',
})
close()
})
},
})
return new NextResponse(stream, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
})
}, { requireAuth: true })
}

View File

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

View File

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

View File

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { SUPPLY_CHAIN_SUPPLIER_INFORMATION_IMPORT_FIELDS } from '@/lib/supply-chain-supplier-information'
import { handleRequest } from '@/server/core/response'
import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
await supplyChainSupplierInformationService.requireViewAccess(ctx.user)
const headers = SUPPLY_CHAIN_SUPPLIER_INFORMATION_IMPORT_FIELDS.map((field) => field.label)
const sheet = XLSX.utils.aoa_to_sheet([headers])
sheet['!cols'] = headers.map((header) => ({ wch: Math.max(14, header.length * 2 + 4) }))
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, '供应商信息导入')
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
return new NextResponse(new Uint8Array(content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent('供应商信息导入模板.xlsx')}`,
'Cache-Control': 'no-store',
},
})
}, { requireAuth: true })
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,68 @@
'use client'
import { useCallback, useState } from 'react'
import type {
SupplyChainSupplierInformationImportProgress,
SupplyChainSupplierInformationImportStreamEvent,
} from '@/lib/supply-chain-supplier-information'
import { readSupplierInformationImportStream } from './supplierInformationImportStream'
export type SupplierInformationImportState = SupplyChainSupplierInformationImportProgress & {
fileName: string
status: 'preparing' | 'running' | 'complete' | 'error'
message?: string
}
const EMPTY: SupplyChainSupplierInformationImportProgress = {
processed: 0,
total: 0,
updated: 0,
failed: 0,
errors: [],
}
export function useSupplierInformationImport(onCompleted: () => void) {
const [state, setState] = useState<SupplierInformationImportState | null>(null)
const isImporting = state?.status === 'preparing' || state?.status === 'running'
const importFile = useCallback(async (file: File) => {
if (isImporting) return
setState({ ...EMPTY, fileName: file.name, status: 'preparing' })
const body = new FormData()
body.append('file', file)
try {
const response = await fetch('/api/supply-chain/supplier-information/import', {
method: 'POST',
body,
})
await readSupplierInformationImportStream(
response,
(event: SupplyChainSupplierInformationImportStreamEvent) => {
setState({
...event.progress,
fileName: file.name,
status: event.type === 'progress' ? 'running' : event.type,
...(event.type === 'error' ? { message: event.message } : {}),
})
},
)
onCompleted()
} catch (error) {
setState((current) => ({
...EMPTY,
...current,
fileName: file.name,
status: 'error',
message: error instanceof Error ? error.message : '导入失败',
}))
}
}, [isImporting, onCompleted])
return {
state,
isImporting,
importFile,
dismiss: () => setState((current) => (
current?.status === 'preparing' || current?.status === 'running' ? current : null
)),
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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 =