feat(supply-chain): enforce import required fields and move sample request column for supplier recommended products

- import rejects rows missing required fields and reports the row number with
  missing labels; drop-shipping fee is required only when shipping mode includes
  代发; date is system-filled on import and not required
- move 申请寄样 after 代发运费 across export, import template, table columns
  and the editor drawer
- gate the download-template button behind edit permission (the template belongs
  to the purchaser import workflow)
- add selection endpoint tests
This commit is contained in:
WuYongJie 2026-09-03 15:07:09 +08:00
parent 455025f546
commit 205db8234c
7 changed files with 133 additions and 15 deletions

View File

@ -0,0 +1,50 @@
const mockHandleRequest = jest.fn()
const mockJsonSuccess = jest.fn()
const mockUpdateSelection = jest.fn()
const mockListSelectionOperators = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
}))
jest.mock('@/server/services/supplier-recommended-products.service', () => ({
supplierRecommendedProductsService: {
updateSelection: (...args: unknown[]) => mockUpdateSelection(...args),
listSelectionOperators: (...args: unknown[]) => mockListSelectionOperators(...args),
},
}))
const { GET, POST } = require('./route')
describe('POST /api/supply-chain/supplier-recommended-products/selection', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: (ctx: unknown) => unknown) => handler({
requestId: 'req-selection',
user: { id: 'operator-1', name: '运营', role: 'USER', businessRoles: ['OPERATIONS'] },
}))
mockJsonSuccess.mockImplementation((data, status, requestId) => ({ data, status, requestId }))
mockUpdateSelection.mockResolvedValue({ action: 'select', requestedCount: 2, updatedCount: 2 })
mockListSelectionOperators.mockResolvedValue([{ id: 'operator-2', name: '运营乙', departmentName: '运营中心' }])
})
it('passes the batch selection payload and authenticated user to the service', async () => {
const response = await POST({
json: async () => ({ ids: ['recommended-1', 'recommended-2'], action: 'select' }),
} as never)
expect(mockUpdateSelection).toHaveBeenCalledWith(
{ ids: ['recommended-1', 'recommended-2'], action: 'select' },
expect.objectContaining({ id: 'operator-1', businessRoles: ['OPERATIONS'] }),
)
expect(response).toEqual(expect.objectContaining({ status: 200 }))
})
it('returns searchable active operator candidates', async () => {
const response = await GET({ nextUrl: new URL('http://localhost/api/supply-chain/supplier-recommended-products/selection?search=%E8%BF%90%E8%90%A5') })
expect(mockListSelectionOperators).toHaveBeenCalledWith('运营', expect.objectContaining({ id: 'operator-1' }))
expect(response).toEqual(expect.objectContaining({ status: 200 }))
})
})

View File

@ -221,7 +221,7 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow
{(['all', 'unselected', 'selected'] as const).map((selection) => <button key={selection} type="button" aria-pressed={filters.selection === selection} onClick={() => changeSelectionFilter(selection)} className={filters.selection === selection ? 'recommended-selection-switch-active' : ''}>{selection === 'all' ? '全部' : selection === 'unselected' ? '未选' : '已选'}</button>)}
</div>
<SupplierRecommendedProductColumnSettings columns={columns.orderedColumns} visibleKeys={columns.visibleKeySet} hiddenCount={columns.hiddenCount} onToggle={columns.toggle} onMove={columns.move} onShowAll={columns.showAll} onHideAll={columns.hideAll} onReset={columns.reset} />
{canDownload && <button type="button" className="recommended-control-button" onClick={() => { window.location.href = '/api/supply-chain/supplier-recommended-products/template' }}><FileDown size={15} />下载模板</button>}
{canEdit && <button type="button" className="recommended-control-button" onClick={() => { window.location.href = '/api/supply-chain/supplier-recommended-products/template' }}><FileDown size={15} />下载模板</button>}
{canEdit && <>
<button type="button" onClick={batchRemove} disabled={selectedIds.size === 0 || batchDeleting || deletingId !== null} className="recommended-danger-button recommended-control-button"><Trash2 size={15} />{batchDeleting ? '删除中' : `批量删除${selectedIds.size ? `(${selectedIds.size})` : ''}`}</button>
<button type="button" disabled={importTask.isImporting || batchDeleting} onClick={() => fileRef.current?.click()} className="recommended-control-button"><Upload size={15} />{importTask.isImporting ? '导入中' : '导入'}</button>

View File

@ -39,6 +39,6 @@ export function SupplierRecommendedProductEditor({ item, onClose, onSaved }: { i
event.preventDefault()
void uploadImageFiles(files)
}
return <div className="fixed inset-0 z-[70] flex justify-end bg-black/30" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}><section role="dialog" aria-modal="true" aria-label={item ? '编辑供应商新品推荐' : '新增供应商新品推荐'} onPaste={handleDrawerPaste} className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl"><header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}</h2><p className="mt-0.5 text-xs text-zinc-500">供应商推荐信息人工维护,空白字段可保存</p></div><button type="button" title="关闭" disabled={saving} onClick={onClose} className="recommended-icon-button"><X size={18} /></button></header><div className="min-h-0 flex-1 overflow-y-auto p-6"><div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2"><label className="recommended-field"><span>日期</span><input type="date" value={draft.recommendationDate ?? ''} onChange={(event) => set('recommendationDate', event.target.value || null)} /></label><Field label="类目" value={draft.category} onChange={(value) => set('category', value)} /><Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} /><Field label="采购" value={draft.purchaser} onChange={(value) => set('purchaser', value)} /><Field label="产品代码(供应商自己的)" value={draft.productCode} onChange={(value) => set('productCode', value)} /><Field label="申请寄样" value={draft.sampleRequest} onChange={(value) => set('sampleRequest', value)} /><Field label="尺寸" value={draft.dimensions} onChange={(value) => set('dimensions', value)} /><Field label="价格(含税价+专票)" value={draft.price} onChange={(value) => set('price', value)} /><Field label="材质" value={draft.material} onChange={(value) => set('material', value)} /><Field label="板材等级" value={draft.boardGrade} onChange={(value) => set('boardGrade', value)} /><Field label="重量" value={draft.weight} onChange={(value) => set('weight', value)} /><label className="recommended-field"><span>下单量</span><input type="number" min={0} step={1} value={draft.orderQuantity} onChange={(event) => set('orderQuantity', Math.max(0, Math.trunc(Number(event.target.value) || 0)))} /></label><Field label="生产周期" value={draft.productionCycle} onChange={(value) => set('productionCycle', value)} /><Field label="发货模式" value={draft.shippingMode} onChange={(value) => set('shippingMode', value)} /><Field label="代发运费(代发情况下)" value={draft.dropShippingFee} onChange={(value) => set('dropShippingFee', value)} /><div className="recommended-field sm:col-span-2"><label className="recommended-field"><span>图片 URL</span><input value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://... 或在抽屉内 Ctrl+V 粘贴截图" /></label><div className="mt-2 flex items-center gap-3"><label className={`recommended-control-button shrink-0 cursor-pointer ${uploadingImageName ? 'pointer-events-none opacity-60' : ''}`}><input type="file" accept="image/*" className="hidden" disabled={Boolean(uploadingImageName)} onChange={(event) => { void uploadImageFiles(Array.from(event.target.files ?? [])); event.currentTarget.value = '' }} />上传图片</label>{uploadingImageName && <span className="text-xs text-zinc-500">上传中:{uploadingImageName}</span>}{imageError && <span className="text-xs text-red-600" role="alert">{imageError}</span>}</div>{!uploadingImageName && draft.imageUrl && <Image unoptimized src={draft.imageUrl} alt="图片预览" width={96} height={96} className="mt-2 h-24 w-24 rounded-[5px] border border-zinc-200 object-cover" />}</div></div></div><footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="recommended-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="recommended-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer></section></div>
return <div className="fixed inset-0 z-[70] flex justify-end bg-black/30" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}><section role="dialog" aria-modal="true" aria-label={item ? '编辑供应商新品推荐' : '新增供应商新品推荐'} onPaste={handleDrawerPaste} className="flex h-full w-full max-w-[780px] flex-col bg-[#f7f8f8] shadow-2xl"><header className="flex h-16 shrink-0 items-center justify-between border-b border-black/10 bg-white px-6"><div><h2 className="text-base font-bold text-zinc-900">{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}</h2><p className="mt-0.5 text-xs text-zinc-500">供应商推荐信息人工维护,空白字段可保存</p></div><button type="button" title="关闭" disabled={saving} onClick={onClose} className="recommended-icon-button"><X size={18} /></button></header><div className="min-h-0 flex-1 overflow-y-auto p-6"><div className="grid gap-4 rounded-[7px] border border-black/10 bg-white p-5 sm:grid-cols-2"><label className="recommended-field"><span>日期</span><input type="date" value={draft.recommendationDate ?? ''} onChange={(event) => set('recommendationDate', event.target.value || null)} /></label><Field label="类目" value={draft.category} onChange={(value) => set('category', value)} /><Field label="供应商" value={draft.supplier} onChange={(value) => set('supplier', value)} /><Field label="采购" value={draft.purchaser} onChange={(value) => set('purchaser', value)} /><Field label="产品代码(供应商自己的)" value={draft.productCode} onChange={(value) => set('productCode', value)} /><Field label="尺寸" value={draft.dimensions} onChange={(value) => set('dimensions', value)} /><Field label="价格(含税价+专票)" value={draft.price} onChange={(value) => set('price', value)} /><Field label="材质" value={draft.material} onChange={(value) => set('material', value)} /><Field label="板材等级" value={draft.boardGrade} onChange={(value) => set('boardGrade', value)} /><Field label="重量" value={draft.weight} onChange={(value) => set('weight', value)} /><label className="recommended-field"><span>下单量</span><input type="number" min={0} step={1} value={draft.orderQuantity} onChange={(event) => set('orderQuantity', Math.max(0, Math.trunc(Number(event.target.value) || 0)))} /></label><Field label="生产周期" value={draft.productionCycle} onChange={(value) => set('productionCycle', value)} /><Field label="发货模式" value={draft.shippingMode} onChange={(value) => set('shippingMode', value)} /><Field label="代发运费(代发情况下)" value={draft.dropShippingFee} onChange={(value) => set('dropShippingFee', value)} /><Field label="申请寄样" value={draft.sampleRequest} onChange={(value) => set('sampleRequest', value)} /><div className="recommended-field sm:col-span-2"><label className="recommended-field"><span>图片 URL</span><input value={draft.imageUrl} onChange={(event) => set('imageUrl', event.target.value)} placeholder="https://... 或在抽屉内 Ctrl+V 粘贴截图" /></label><div className="mt-2 flex items-center gap-3"><label className={`recommended-control-button shrink-0 cursor-pointer ${uploadingImageName ? 'pointer-events-none opacity-60' : ''}`}><input type="file" accept="image/*" className="hidden" disabled={Boolean(uploadingImageName)} onChange={(event) => { void uploadImageFiles(Array.from(event.target.files ?? [])); event.currentTarget.value = '' }} />上传图片</label>{uploadingImageName && <span className="text-xs text-zinc-500">上传中:{uploadingImageName}</span>}{imageError && <span className="text-xs text-red-600" role="alert">{imageError}</span>}</div>{!uploadingImageName && draft.imageUrl && <Image unoptimized src={draft.imageUrl} alt="图片预览" width={96} height={96} className="mt-2 h-24 w-24 rounded-[5px] border border-zinc-200 object-cover" />}</div></div></div><footer className="flex shrink-0 justify-end gap-3 border-t border-black/10 bg-white px-6 py-4"><button type="button" disabled={saving} onClick={onClose} className="recommended-secondary-button">取消</button><button type="button" disabled={saving} onClick={() => void save()} className="recommended-primary-button"><Save size={15} />{saving ? '保存中...' : '保存'}</button></footer></section></div>
}
function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { return <label className="recommended-field"><span>{label}</span><input value={value} onChange={(event) => onChange(event.target.value)} /></label> }

View File

@ -10,7 +10,6 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS: readonly SupplierRecommendedP
{ key: 'purchaser', label: '采购', minWidth: 110 },
{ key: 'productCode', label: '产品代码(供应商自己的)', minWidth: 190 },
{ key: 'imageUrl', label: '图片', minWidth: 86 },
{ key: 'sampleRequest', label: '申请寄样', minWidth: 120 },
{ key: 'dimensions', label: '尺寸', minWidth: 150 },
{ key: 'price', label: '价格(含税价+专票)', minWidth: 170 },
{ key: 'material', label: '材质', minWidth: 140 },
@ -20,6 +19,7 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS: readonly SupplierRecommendedP
{ key: 'productionCycle', label: '生产周期', minWidth: 120 },
{ key: 'shippingMode', label: '发货模式', minWidth: 120 },
{ key: 'dropShippingFee', label: '代发运费(代发情况下)', minWidth: 190 },
{ key: 'sampleRequest', label: '申请寄样', minWidth: 120 },
{ key: 'actions', label: '操作', minWidth: 220, locked: true },
]

View File

@ -7,7 +7,6 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_FIELDS = [
{ key: 'purchaser', label: '采购' },
{ key: 'productCode', label: '产品代码(供应商自己的)' },
{ key: 'imageUrl', label: '图片' },
{ key: 'sampleRequest', label: '申请寄样' },
{ key: 'dimensions', label: '尺寸' },
{ key: 'price', label: '价格(含税价+专票)' },
{ key: 'material', label: '材质' },
@ -17,8 +16,14 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_FIELDS = [
{ key: 'productionCycle', label: '生产周期' },
{ key: 'shippingMode', label: '发货模式' },
{ key: 'dropShippingFee', label: '代发运费(代发情况下)' },
{ key: 'sampleRequest', label: '申请寄样' },
] as const
// 导入必填:除申请寄样(可空)外全部必填;日期由系统按导入日期填写,不在文件里要求。
export const SUPPLIER_RECOMMENDED_PRODUCT_REQUIRED_KEYS: readonly (typeof SUPPLIER_RECOMMENDED_PRODUCT_FIELDS)[number]['key'][] = SUPPLIER_RECOMMENDED_PRODUCT_FIELDS
.filter((field) => field.key !== 'sampleRequest' && field.key !== 'recommendationDate')
.map((field) => field.key)
export type SupplierRecommendedProductFieldKey = typeof SUPPLIER_RECOMMENDED_PRODUCT_FIELDS[number]['key']
export const SUPPLIER_RECOMMENDED_PRODUCT_SELECTION_STATUSES = ['UNSELECTED', 'SELECTED'] as const

View File

@ -93,20 +93,21 @@ describe('SupplierRecommendedProductsService', () => {
await expect(service.importRows([{
recommendationDate: '1999-01-01',
category: '床头柜',
supplier: '供应商A',
purchaser: '',
productCode: '',
purchaser: '采购员',
productCode: 'A-001',
imageUrl: '/api/storage/object?key=import-image',
sampleRequest: '',
dimensions: '',
price: '',
material: '',
boardGrade: '',
weight: '',
dimensions: '40/40/60',
price: '99',
material: '橡胶木',
boardGrade: 'enf',
weight: '10',
orderQuantity: 10,
productionCycle: '',
shippingMode: '',
dropShippingFee: '',
productionCycle: '15',
shippingMode: '代发',
dropShippingFee: '15',
}], USER, progress)).resolves.toEqual({ created: 1, updated: 0 })
expect(importCreate).toHaveBeenCalledWith(
@ -117,6 +118,58 @@ describe('SupplierRecommendedProductsService', () => {
expect(progress).toHaveBeenCalledWith({ processed: 1, total: 1, created: 1, updated: 0 })
})
it('rejects imported rows missing required fields and reports the row number and labels', async () => {
const importCreate = jest.fn()
const repository = { importCreate } as unknown as SupplierRecommendedProductsRepository
const service = new SupplierRecommendedProductsService(repository)
await expect(service.importRows([{
category: '',
supplier: '',
purchaser: '',
productCode: '',
imageUrl: '',
dimensions: '',
price: '',
material: '',
boardGrade: '',
weight: '',
orderQuantity: '',
productionCycle: '',
shippingMode: '代发',
dropShippingFee: '',
}], USER)).rejects.toThrow(/第2行:缺少必填字段:.+类目/)
expect(importCreate).not.toHaveBeenCalled()
})
it('requires drop-shipping fee only when shipping mode is 代发', async () => {
const importCreate = jest.fn().mockResolvedValue({ id: 'recommended-import-1' })
const repository = { importCreate } as unknown as SupplierRecommendedProductsRepository
const service = new SupplierRecommendedProductsService(repository)
const base = {
category: '床头柜',
supplier: '供应商A',
purchaser: '采购员',
productCode: 'A-001',
imageUrl: '/api/storage/object?key=import-image',
dimensions: '40/40/60',
price: '99',
material: '橡胶木',
boardGrade: 'enf',
weight: '10',
orderQuantity: 10,
productionCycle: '15',
}
await expect(service.importRows([{ ...base, shippingMode: '自提', dropShippingFee: '', sampleRequest: '' }], USER))
.resolves.toEqual({ created: 1, updated: 0 })
await expect(service.importRows([{ ...base, shippingMode: '代发', dropShippingFee: '', sampleRequest: '' }], USER))
.rejects.toThrow('缺少必填字段:代发运费(代发情况下)')
expect(importCreate).toHaveBeenCalledTimes(1)
})
it('requires and persists the selected operator for batch selection', async () => {
const updateSelection = jest.fn().mockResolvedValue({ foundCount: 2, updatedCount: 2 })
const repository = { updateSelection } as unknown as SupplierRecommendedProductsRepository

View File

@ -1,5 +1,6 @@
import {
SUPPLIER_RECOMMENDED_PRODUCT_FIELDS,
SUPPLIER_RECOMMENDED_PRODUCT_REQUIRED_KEYS,
supplierRecommendedProductInputSchema,
type SupplierRecommendedProductChange,
type SupplierRecommendedProductFilters,
@ -226,9 +227,18 @@ export class SupplierRecommendedProductsService {
if (!rows.length) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据')
if (rows.length > 5_000) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行')
const importDate = currentImportDate()
const requiredFields = SUPPLIER_RECOMMENDED_PRODUCT_FIELDS.filter((field) => SUPPLIER_RECOMMENDED_PRODUCT_REQUIRED_KEYS.includes(field.key))
let created = 0
for (let index = 0; index < rows.length; index += 1) {
const values = parseInput({ ...rows[index], recommendationDate: importDate }, index + 2)
const rowNumber = index + 2
const rowValues = rows[index]
const shippingMode = String(rowValues.shippingMode ?? '').trim()
const missing = requiredFields
.filter((field) => field.key !== 'dropShippingFee' || shippingMode.includes('代发'))
.filter((field) => String(rowValues[field.key] ?? '').trim() === '')
.map((field) => field.label)
if (missing.length) throw new AppError(ErrorCode.VALIDATION_FAILED, `第${rowNumber}行:缺少必填字段:${missing.join('、')}`)
const values = parseInput({ ...rowValues, recommendationDate: importDate }, rowNumber)
await this.repository.importCreate(values, user, changeSet(null, values))
created += 1
onProgress?.({ processed: index + 1, total: rows.length, created, updated: 0 })