feat(supplier-recommendations): add guided editor options

This commit is contained in:
WuYongJie 2026-09-15 14:44:48 +08:00
parent f6dd04eeee
commit 170d776aa6
14 changed files with 956 additions and 11 deletions

View File

@ -0,0 +1,96 @@
export {}
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
const mockHandleRequest = jest.fn()
const mockList = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (data: unknown, status = 200, requestId?: string) =>
new Response(JSON.stringify({ success: true, data, meta: { requestId } }), {
status,
headers: { 'content-type': 'application/json' },
}),
jsonError: (code: string, message?: string, status = 400) =>
new Response(JSON.stringify({ success: false, error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' },
}),
}))
jest.mock('@/server/services/supply-chain-product-archive-category-config.service', () => ({
supplyChainProductArchiveCategoryConfigService: {
list: (...args: unknown[]) => mockList(...args),
},
}))
const { GET } = require('./route')
const { CATEGORY_CONFIG_ACTIVE } = require('@/lib/supply-chain-product-archive-category-config')
function buildPage(items: Array<{ categoryL2: string }>, page: number, total: number) {
return {
items: items.map((item, index) => ({
id: `${page}-${index}`,
categoryL2: item.categoryL2,
categoryL1: '一级类目',
status: 'ACTIVE',
createdAt: '2026-09-15T00:00:00.000Z',
updatedAt: '2026-09-15T00:00:00.000Z',
})),
pageInfo: { page, pageSize: 200, total, totalPages: Math.max(1, Math.ceil(total / 200)) },
}
}
describe('GET /api/supply-chain/supplier-recommended-products/category-options', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
handler({ requestId: 'req-category-options', user: { id: 'user-1', role: 'USER', status: 'ACTIVE' } }),
)
})
it('返回启用二级类目(去重、按中文排序)且只查启用状态', async () => {
mockList.mockResolvedValue(
buildPage(
[{ categoryL2: '床头柜' }, { categoryL2: '书架' }, { categoryL2: '床头柜' }, { categoryL2: '沙发' }],
1,
3,
),
)
const response = await GET({ nextUrl: { searchParams: new URLSearchParams() } } as any)
const payload = await response.json()
expect(payload).toEqual(
expect.objectContaining({
success: true,
data: { categories: ['床头柜', '沙发', '书架'] },
}),
)
expect(mockList).toHaveBeenCalledTimes(1)
expect(mockList).toHaveBeenCalledWith({ page: 1, pageSize: 200, status: CATEGORY_CONFIG_ACTIVE })
})
it('类目超过一页时继续翻页直到取完', async () => {
const firstPageItems = Array.from({ length: 200 }, (_, index) => ({ categoryL2: `类目${String(index).padStart(3, '0')}` }))
mockList
.mockResolvedValueOnce(buildPage(firstPageItems, 1, 201))
.mockResolvedValueOnce(buildPage([{ categoryL2: '补充类目' }], 2, 201))
const response = await GET({ nextUrl: { searchParams: new URLSearchParams() } } as any)
const payload = await response.json()
expect(mockList).toHaveBeenCalledTimes(2)
expect(mockList).toHaveBeenNthCalledWith(2, { page: 2, pageSize: 200, status: CATEGORY_CONFIG_ACTIVE })
expect(payload.data.categories).toHaveLength(201)
expect(payload.data.categories).toContain('补充类目')
})
it('要求登录后访问', async () => {
const request = { nextUrl: { searchParams: new URLSearchParams() } } as any
await GET(request)
expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true })
})
})

View File

@ -0,0 +1,33 @@
import { NextRequest } from 'next/server'
import { CATEGORY_CONFIG_ACTIVE } from '@/lib/supply-chain-product-archive-category-config'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveCategoryConfigService } from '@/server/services/supply-chain-product-archive-category-config.service'
const CATEGORY_OPTIONS_PAGE_SIZE = 200
const CATEGORY_OPTIONS_MAX_PAGES = 10
// GET: 供应商新品推荐编辑器用的启用二级类目选项(登录即可,权限走页面自身映射 view)。
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const categoryL2Set = new Set<string>()
for (let page = 1; page <= CATEGORY_OPTIONS_MAX_PAGES; page++) {
const result = await supplyChainProductArchiveCategoryConfigService.list({
page,
pageSize: CATEGORY_OPTIONS_PAGE_SIZE,
status: CATEGORY_CONFIG_ACTIVE,
})
for (const item of result.items) {
const categoryL2 = item.categoryL2.trim()
if (categoryL2) categoryL2Set.add(categoryL2)
}
if (page * CATEGORY_OPTIONS_PAGE_SIZE >= result.pageInfo.total) break
}
const categories = [...categoryL2Set].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
return jsonSuccess({ categories }, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,67 @@
export {}
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
const mockHandleRequest = jest.fn()
const mockListPurchaserOptions = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (data: unknown, status = 200, requestId?: string) =>
new Response(JSON.stringify({ success: true, data, meta: { requestId } }), {
status,
headers: { 'content-type': 'application/json' },
}),
jsonError: (code: string, message?: string, status = 400) =>
new Response(JSON.stringify({ success: false, error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' },
}),
}))
jest.mock('@/server/services/supplier-recommended-products.service', () => ({
supplierRecommendedProductsService: {
listPurchaserOptions: (...args: unknown[]) => mockListPurchaserOptions(...args),
},
}))
const { GET } = require('./route')
describe('GET /api/supply-chain/supplier-recommended-products/purchaser-options', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
handler({ requestId: 'req-purchaser-options', user: { id: 'user-1', role: 'USER', status: 'ACTIVE' } }),
)
})
it('返回 PURCHASING 业务角色的在职采购用户列表', async () => {
mockListPurchaserOptions.mockResolvedValueOnce([
{ id: 'p1', name: '采购甲', departmentName: '采购部' },
{ id: 'p2', name: '采购乙', departmentName: '' },
])
const response = await GET({ nextUrl: { searchParams: new URLSearchParams() } } as any)
const payload = await response.json()
expect(mockListPurchaserOptions).toHaveBeenCalledTimes(1)
expect(payload).toEqual(
expect.objectContaining({
success: true,
data: {
purchasers: [
{ id: 'p1', name: '采购甲', departmentName: '采购部' },
{ id: 'p2', name: '采购乙', departmentName: '' },
],
},
}),
)
})
it('要求登录后访问(权限走页面自身 view 映射)', async () => {
const request = { nextUrl: { searchParams: new URLSearchParams() } } as any
await GET(request)
expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true })
})
})

View File

@ -0,0 +1,16 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { supplierRecommendedProductsService } from '@/server/services/supplier-recommended-products.service'
// GET: 供应商新品推荐编辑器的采购点选项(系统内 PURCHASING 业务角色的在职用户)。
// 权限走页面自身映射(GET -> view@purchaseSupplierRecommendedProducts)。
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const purchasers = await supplierRecommendedProductsService.listPurchaserOptions()
return jsonSuccess({ purchasers }, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,77 @@
export {}
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
const mockHandleRequest = jest.fn()
const mockSearchEnabledSuppliers = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (data: unknown, status = 200, requestId?: string) =>
new Response(JSON.stringify({ success: true, data, meta: { requestId } }), {
status,
headers: { 'content-type': 'application/json' },
}),
jsonError: (code: string, message?: string, status = 400) =>
new Response(JSON.stringify({ success: false, error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' },
}),
}))
jest.mock('@/server/services/external-supplier-directory.service', () => ({
externalSupplierDirectoryService: {
searchEnabledSuppliers: (...args: unknown[]) => mockSearchEnabledSuppliers(...args),
},
}))
const { GET } = require('./route')
describe('GET /api/supply-chain/supplier-recommended-products/supplier-options', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
handler({ requestId: 'req-supplier-options', user: { id: 'user-1', role: 'USER', status: 'ACTIVE' } }),
)
})
it('按关键词搜索系统供应商目录', async () => {
mockSearchEnabledSuppliers.mockResolvedValueOnce([
{ supplierCode: 'S001', supplierName: '系统供应商甲' },
{ supplierCode: 'S002', supplierName: '系统供应商乙' },
])
const response = await GET({
nextUrl: { searchParams: new URLSearchParams([['query', ' 供应商 ']]) },
} as any)
const payload = await response.json()
expect(mockSearchEnabledSuppliers).toHaveBeenCalledWith('供应商', 20)
expect(payload).toEqual(
expect.objectContaining({
success: true,
data: {
suppliers: [
{ supplierCode: 'S001', supplierName: '系统供应商甲' },
{ supplierCode: 'S002', supplierName: '系统供应商乙' },
],
},
}),
)
})
it('关键词为空时不查询目录、直接返回空列表', async () => {
const response = await GET({ nextUrl: { searchParams: new URLSearchParams() } } as any)
const payload = await response.json()
expect(mockSearchEnabledSuppliers).not.toHaveBeenCalled()
expect(payload).toEqual(expect.objectContaining({ success: true, data: { suppliers: [] } }))
})
it('要求登录后访问(权限走页面自身 view 映射)', async () => {
const request = { nextUrl: { searchParams: new URLSearchParams() } } as any
await GET(request)
expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true })
})
})

View File

@ -0,0 +1,21 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { externalSupplierDirectoryService } from '@/server/services/external-supplier-directory.service'
// GET: 供应商新品推荐编辑器的供应商点选建议(系统供应商目录搜索)。
// 系统里还没有的供应商允许手动填写,因此这里只提供搜索建议,不做强制校验。
// 权限走页面自身映射(GET -> view@purchaseSupplierRecommendedProducts),采购员可查。
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const query = request.nextUrl.searchParams.get('query')?.trim().slice(0, 100) ?? ''
const suppliers = query
? await externalSupplierDirectoryService.searchEnabledSuppliers(query, 20)
: []
return jsonSuccess({ suppliers }, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,269 @@
export {}
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
import { AppError, ErrorCode } from '@/server/core/errors'
const mockHandleRequest = jest.fn()
const mockUploadBufferToObjectStorage = jest.fn()
const uploadedObjectBodies = new Map<string, Buffer>()
jest.mock('@/server/core/response', () => {
return {
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (data: unknown, status = 200, requestId?: string) =>
new Response(
JSON.stringify({
success: true,
data,
meta: {
requestId,
timestamp: '2026-09-14T00:00:00.000Z',
},
}),
{
status,
headers: { 'content-type': 'application/json' },
},
),
jsonError: (code: string, message?: string, status = 400, requestId?: string) =>
new Response(
JSON.stringify({
success: false,
error: { code, message },
meta: {
requestId,
timestamp: '2026-09-14T00:00:00.000Z',
},
}),
{
status,
headers: { 'content-type': 'application/json' },
},
),
}
})
jest.mock('@/lib/storage', () => ({
uploadBufferToObjectStorage: async (...args: unknown[]) => {
const url = await mockUploadBufferToObjectStorage(...args)
const key = String(args[1] ?? '')
uploadedObjectBodies.set(key, Buffer.from(args[0] as Buffer))
return url
},
}))
const { POST } = require('./route')
function createUploadFile(
bytes: Uint8Array,
fileName: string,
type: string,
sizeOverride?: number,
) {
const file = new File([bytes], fileName, { type })
if (typeof sizeOverride === 'number') {
Object.defineProperty(file, 'size', { value: sizeOverride })
}
Object.defineProperty(file, 'arrayBuffer', {
value: async () =>
bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
),
})
return file
}
describe('POST /api/supply-chain/supplier-recommended-products/upload', () => {
beforeEach(() => {
jest.clearAllMocks()
uploadedObjectBodies.clear()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
handler({
requestId: 'req-supplier-upload',
user: {
id: 'user-1',
email: 'buyer@example.com',
name: 'Buyer',
role: 'USER',
status: 'ACTIVE',
},
}),
)
mockUploadBufferToObjectStorage.mockResolvedValue(
'/api/storage/object?key=supplier-recommended-images%2Fmock',
)
})
afterEach(() => {
uploadedObjectBodies.clear()
})
it('stores an uploaded image and returns the object storage url', async () => {
const imageBytes = new TextEncoder().encode('supplier-image-body')
const response = await POST({
formData: async () => {
const formData = new FormData()
formData.append('file', createUploadFile(imageBytes, 'photo.PNG', 'image/png'))
return formData
},
} as any)
const payload = await response.json()
expect(response.status).toBe(201)
expect(payload).toEqual(
expect.objectContaining({
success: true,
data: expect.objectContaining({
fileName: 'photo.PNG',
mimeType: 'image/png',
size: imageBytes.length,
storageKey: expect.stringMatching(/^supplier-recommended-images\/.+\.(png|PNG)$/),
url: expect.stringContaining('/api/storage/object?key='),
}),
}),
)
expect(mockUploadBufferToObjectStorage).toHaveBeenCalledWith(
Buffer.from(imageBytes),
expect.stringMatching(/^supplier-recommended-images\//),
'image/png',
)
expect(
uploadedObjectBodies.get(payload.data.storageKey)?.toString('utf8'),
).toBe('supplier-image-body')
})
it('rejects the request when no file is provided', async () => {
const response = await POST({
formData: async () => new FormData(),
} as any)
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: ErrorCode.VALIDATION_REQUIRED_FIELD,
message: '请选择要上传的图片',
}),
}),
)
expect(mockUploadBufferToObjectStorage).not.toHaveBeenCalled()
})
it('rejects empty files', async () => {
const response = await POST({
formData: async () => {
const formData = new FormData()
formData.append('file', createUploadFile(new Uint8Array(0), 'empty.png', 'image/png'))
return formData
},
} as any)
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: ErrorCode.VALIDATION_FAILED,
message: '文件不能为空',
}),
}),
)
})
it('rejects non-image files', async () => {
const response = await POST({
formData: async () => {
const formData = new FormData()
formData.append('file', createUploadFile(new TextEncoder().encode('pdf'), 'doc.pdf', 'application/pdf'))
return formData
},
} as any)
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: ErrorCode.VALIDATION_FAILED,
message: '仅支持上传图片文件',
}),
}),
)
expect(mockUploadBufferToObjectStorage).not.toHaveBeenCalled()
})
it('rejects images above the 25MB limit', async () => {
const response = await POST({
formData: async () => {
const formData = new FormData()
formData.append(
'file',
createUploadFile(
new TextEncoder().encode('too-large'),
'too-large.png',
'image/png',
26 * 1024 * 1024,
),
)
return formData
},
} as any)
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: ErrorCode.FILE_TOO_LARGE,
message: '图片过大,最大允许 25MB。',
}),
}),
)
expect(mockUploadBufferToObjectStorage).not.toHaveBeenCalled()
})
it('wraps object storage failures with a readable message', async () => {
mockUploadBufferToObjectStorage.mockRejectedValueOnce(new Error('bucket unreachable'))
await expect(
POST({
formData: async () => {
const formData = new FormData()
formData.append(
'file',
createUploadFile(new TextEncoder().encode('image'), 'photo.png', 'image/png'),
)
return formData
},
} as any),
).rejects.toMatchObject({
message: '图片上传到对象存储失败:bucket unreachable',
})
})
it('requires an authenticated user', async () => {
const request = { formData: async () => new FormData() } as any
await POST(request)
expect(mockHandleRequest).toHaveBeenCalledWith(
request,
expect.any(Function),
{ requireAuth: true },
)
})
it('propagates auth errors raised by handleRequest', async () => {
mockHandleRequest.mockImplementationOnce(async () => {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限')
})
await expect(
POST({
formData: async () => new FormData(),
} as any),
).rejects.toMatchObject({ message: '没有权限' })
})
})

View File

@ -0,0 +1,73 @@
import { randomUUID } from 'crypto'
import { NextRequest } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { uploadBufferToObjectStorage } from '@/lib/storage'
export const SUPPLIER_RECOMMENDED_IMAGE_STORAGE_PREFIX = 'supplier-recommended-images'
const SUPPLIER_RECOMMENDED_IMAGE_MAX_BYTES = 25 * 1024 * 1024
function sanitizeImageExtension(fileName: string) {
const extension = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')).trim().toLowerCase() : ''
if (!/^\.[a-z0-9.]+$/.test(extension)) return ''
return extension
}
export async function POST(request: NextRequest) {
return handleRequest(
request,
async ({ requestId }) => {
const formData = await request.formData()
const file = formData.get('file')
if (!(file instanceof File)) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要上传的图片', 400, requestId)
}
if (file.size <= 0) {
return jsonError(ErrorCode.VALIDATION_FAILED, '文件不能为空', 400, requestId)
}
if (!file.type.startsWith('image/')) {
return jsonError(ErrorCode.VALIDATION_FAILED, '仅支持上传图片文件', 400, requestId)
}
if (file.size > SUPPLIER_RECOMMENDED_IMAGE_MAX_BYTES) {
return jsonError(ErrorCode.FILE_TOO_LARGE, '图片过大,最大允许 25MB。', 400, requestId)
}
const extension = sanitizeImageExtension(file.name) || '.png'
const storageKey = `${SUPPLIER_RECOMMENDED_IMAGE_STORAGE_PREFIX}/${Date.now()}-${randomUUID()}${extension}`
const bytes = await file.arrayBuffer()
try {
const url = await uploadBufferToObjectStorage(
Buffer.from(bytes),
storageKey,
file.type || 'application/octet-stream',
)
return jsonSuccess(
{
url,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
storageKey,
},
201,
requestId,
)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
if (error instanceof Error) {
error.message = `图片上传到对象存储失败:${detail}`
throw error
}
throw new Error(`图片上传到对象存储失败:${detail}`)
}
},
{ requireAuth: true },
)
}

View File

@ -283,7 +283,7 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow
<SupplierRecommendedProductPagination {...data.pageInfo} loading={loading} pageInput={pageInput} onPageInput={setPageInput} onCommitPage={commitPage} onPage={(page) => void load(filters, page, data.pageInfo.pageSize)} onPageSize={(pageSize) => void load(filters, 1, pageSize)} />
</section>
</>}
{editor && <SupplierRecommendedProductEditor item={editor === 'new' ? null : editor} onClose={() => setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />}
{editor && <SupplierRecommendedProductEditor item={editor === 'new' ? null : editor} supplierHistory={data.options.suppliers} currentUser={currentUser} onClose={() => setEditor(null)} onSaved={() => { setEditor(null); void load(filters, data.pageInfo.page, data.pageInfo.pageSize) }} />}
{detail && <SupplierRecommendedProductDetailDialog row={detail} canEdit={canEdit} onClose={() => setDetail(null)} onEdit={(row) => { setDetail(null); setEditor(row) }} onDelete={(row) => { setDetail(null); remove(row) }} onHistory={(row) => { setDetail(null); void openHistory(row) }} onPreview={(row) => setPreview(row)} />}
{logs && <SupplierRecommendedProductHistoryDialog logs={logs} onClose={() => setLogs(null)} />}
{importTask.state.status !== 'idle' && <SupplierRecommendedProductImportProgress state={importTask.state} onDismiss={importTask.dismiss} />}

View File

@ -1,14 +1,31 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { SupplierRecommendedProductEditor } from './SupplierRecommendedProductEditor'
jest.mock('@/app/intake/non-integrated/workspaces/documents/uploadRecordHelpers', () => ({
uploadFileToServer: jest.fn(async () => ({ url: '/api/storage/object?key=supplier-recommended-images%2Fpaste.png', fileName: 'paste.png', mimeType: 'image/png' })),
jest.mock('../uploadImageToServer', () => ({
uploadSupplierRecommendedImageToServer: jest.fn(async () => ({ url: '/api/storage/object?key=supplier-recommended-images%2Fpaste.png', fileName: 'paste.png', mimeType: 'image/png' })),
}))
const ITEM = {
id: 'recommended-1', recommendationDate: '2026-09-02', category: '床头柜', supplier: '供应商A', purchaser: '采购员', productCode: 'A-001', imageUrl: '', sampleRequest: '', dimensions: '', price: '', material: '', boardGrade: '', weight: '', orderQuantity: 0, productionCycle: '', shippingMode: '', dropShippingFee: '', createdAt: '2026-09-02T01:00:00.000Z', updatedAt: '2026-09-02T01:00:00.000Z', updatedByName: '采购员',
}
function jsonResponse(data: unknown, ok = true) {
return { ok, status: ok ? 200 : 500, json: async () => ({ success: ok, data }) } as unknown as Response
}
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/category-options')) return jsonResponse({ categories: ['床头柜', '书架', '意式沙发'] })
if (url.includes('/supplier-options')) return jsonResponse({ suppliers: [{ supplierCode: 'S001', supplierName: '系统供应商甲' }] })
if (url.includes('/purchaser-options')) return jsonResponse({ purchasers: [{ id: 'p1', name: '采购甲', departmentName: '采购部' }, { id: 'p2', name: '采购乙', departmentName: '' }] })
return jsonResponse({})
})
beforeEach(() => {
jest.clearAllMocks()
globalThis.fetch = fetchMock as unknown as typeof fetch
})
describe('SupplierRecommendedProductEditor 图片粘贴上传', () => {
it('在抽屉内粘贴截图后自动上传并回填图片 URL', async () => {
render(<SupplierRecommendedProductEditor item={ITEM} onClose={jest.fn()} onSaved={jest.fn()} />)
@ -32,8 +49,8 @@ describe('SupplierRecommendedProductEditor 图片粘贴上传', () => {
})
it('上传失败时展示错误提示', async () => {
const { uploadFileToServer } = jest.requireMock('@/app/intake/non-integrated/workspaces/documents/uploadRecordHelpers') as { uploadFileToServer: jest.Mock }
uploadFileToServer.mockRejectedValueOnce(new Error('存储服务不可用'))
const { uploadSupplierRecommendedImageToServer } = jest.requireMock('../uploadImageToServer') as { uploadSupplierRecommendedImageToServer: jest.Mock }
uploadSupplierRecommendedImageToServer.mockRejectedValueOnce(new Error('存储服务不可用'))
render(<SupplierRecommendedProductEditor item={ITEM} onClose={jest.fn()} onSaved={jest.fn()} />)
const urlInput = screen.getByPlaceholderText(/Ctrl\+V 粘贴截图/)
@ -43,3 +60,92 @@ describe('SupplierRecommendedProductEditor 图片粘贴上传', () => {
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('存储服务不可用'))
})
})
describe('SupplierRecommendedProductEditor 点选字段', () => {
it('新增时日期自动填充当天、当前登录人是采购时默认选中', async () => {
render(<SupplierRecommendedProductEditor item={null} currentUser={{ id: 'p1', name: '采购甲' }} onClose={jest.fn()} onSaved={jest.fn()} />)
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date())
expect(screen.getByLabelText('日期(当天)')).toHaveValue(today)
await waitFor(() => expect(screen.getByLabelText('采购')).toHaveValue('采购甲'))
})
it('采购下拉展示系统 PURCHASING 用户的采购人列表', async () => {
render(<SupplierRecommendedProductEditor item={null} onClose={jest.fn()} onSaved={jest.fn()} />)
const select = screen.getByLabelText('采购') as HTMLSelectElement
await waitFor(() => expect(Array.from(select.options).map((option) => option.value)).toEqual(expect.arrayContaining(['采购甲', '采购乙'])))
expect(select).toHaveValue('')
})
it('申请寄样不在表单中体现', () => {
render(<SupplierRecommendedProductEditor item={ITEM} onClose={jest.fn()} onSaved={jest.fn()} />)
expect(screen.queryByText('申请寄样')).not.toBeInTheDocument()
})
it('类目下拉展示系统类目配置的二级类目', async () => {
render(<SupplierRecommendedProductEditor item={null} onClose={jest.fn()} onSaved={jest.fn()} />)
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/category-options')))
const select = screen.getByLabelText('类目') as HTMLSelectElement
await waitFor(() => expect(Array.from(select.options).map((option) => option.value)).toEqual(expect.arrayContaining(['床头柜', '书架', '意式沙发'])))
})
it('编辑历史记录时类目下拉保留旧值', async () => {
render(<SupplierRecommendedProductEditor item={ITEM} onClose={jest.fn()} onSaved={jest.fn()} />)
const select = screen.getByLabelText('类目') as HTMLSelectElement
expect(select).toHaveValue('床头柜')
})
it('供应商输入为空时聚焦不弹出建议,避免历史脏值污染目录', () => {
render(<SupplierRecommendedProductEditor item={null} supplierHistory={['112', '亿家达']} onClose={jest.fn()} onSaved={jest.fn()} />)
const input = screen.getByLabelText('供应商')
fireEvent.focus(input)
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('/supplier-options'))
})
it('供应商输入时展示系统供应商建议并支持点选回填', async () => {
render(<SupplierRecommendedProductEditor item={null} onClose={jest.fn()} onSaved={jest.fn()} />)
const input = screen.getByLabelText('供应商')
fireEvent.change(input, { target: { value: '系统' } })
await waitFor(() => expect(screen.getByRole('option', { name: '系统供应商甲' })).toBeInTheDocument())
fireEvent.mouseDown(screen.getByRole('option', { name: '系统供应商甲' }))
expect(input).toHaveValue('系统供应商甲')
})
it('供应商允许手动填写系统里没有的名称', () => {
render(<SupplierRecommendedProductEditor item={null} onClose={jest.fn()} onSaved={jest.fn()} />)
const input = screen.getByLabelText('供应商')
fireEvent.change(input, { target: { value: '系统里没有的供应商' } })
expect(input).toHaveValue('系统里没有的供应商')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
it('发货模式下拉提供代发、入库、代发/入库', () => {
render(<SupplierRecommendedProductEditor item={null} onClose={jest.fn()} onSaved={jest.fn()} />)
const select = screen.getByLabelText('发货模式') as HTMLSelectElement
const values = Array.from(select.options).map((option) => option.value)
expect(values).toEqual(expect.arrayContaining(['代发', '入库', '代发/入库']))
})
it('板材等级支持点选并保留手写值', () => {
render(<SupplierRecommendedProductEditor item={{ ...ITEM, boardGrade: '少见等级' }} onClose={jest.fn()} onSaved={jest.fn()} />)
const input = screen.getByLabelText('板材等级')
expect(input).toHaveValue('少见等级')
const datalist = document.getElementById('recommended-board-grade-options') as HTMLDataListElement | null
expect(datalist).not.toBeNull()
const optionValues = Array.from(datalist?.options ?? []).map((option) => option.value)
expect(optionValues).toEqual(expect.arrayContaining(['ENF级', '非ENF级', 'E0级', '少见等级']))
})
})

View File

@ -51,3 +51,11 @@
.recommended-pk-config-add:disabled{opacity:.45;cursor:not-allowed}
.recommended-pk-config-error{border:1px solid #fecaca;border-radius:6px;background:#fef2f2;color:#b91c1c;font-size:12px;padding:8px 12px}
/* ===== 编辑器点选字段(类目/采购/发货模式下拉、供应商建议面板) ===== */
.recommended-field select{width:100%;box-sizing:border-box;height:40px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;padding:0 10px;color:#27272a;font-size:13px;outline:none;cursor:pointer}
.recommended-field select:focus{border-color:#71717a;box-shadow:0 0 0 2px rgba(82,82,91,.08)}
.recommended-supplier-field{position:relative}
.recommended-supplier-suggestions{position:absolute;left:0;right:0;top:100%;z-index:85;display:flex;max-height:220px;flex-direction:column;overflow-y:auto;border:1px solid #d4d4d8;border-radius:5px;background:#fff;box-shadow:0 12px 30px rgba(24,24,27,.14)}
.recommended-supplier-suggestions button{display:flex;align-items:center;border:0;background:none;padding:9px 12px;color:#27272a;font-size:12px;text-align:left;cursor:pointer}
.recommended-supplier-suggestions button:hover{background:#f4f4f5}

View File

@ -0,0 +1,51 @@
import { compressImageFileForUpload } from '@/lib/client-image-compression'
import { shouldUseRemoteAssetStorage } from '@/app/intake/non-integrated/workspaces/documents/uploadStorageHelpers'
import { readFileAsDataUrl } from '@/app/intake/non-integrated/workspaces/documents/uploadFilePreview'
export type SupplierRecommendedImageUploadResult = {
url: string
fileName: string
mimeType: string
}
const SUPPLIER_RECOMMENDED_IMAGE_TARGET_BYTES = 2 * 1024 * 1024
const SUPPLIER_RECOMMENDED_IMAGE_HARD_LIMIT_BYTES = 20 * 1024 * 1024
const SUPPLIER_RECOMMENDED_IMAGE_MAX_DIMENSION = 2400
export async function prepareSupplierRecommendedImageForUpload(file: File) {
return compressImageFileForUpload(file, {
targetBytes: SUPPLIER_RECOMMENDED_IMAGE_TARGET_BYTES,
hardLimitBytes: SUPPLIER_RECOMMENDED_IMAGE_HARD_LIMIT_BYTES,
maxDimension: SUPPLIER_RECOMMENDED_IMAGE_MAX_DIMENSION,
errorMessage: '图片压缩后仍超过当前上传入口限制,请换用更小的图片。',
})
}
export async function uploadSupplierRecommendedImageToServer(file: File): Promise<SupplierRecommendedImageUploadResult> {
if (!shouldUseRemoteAssetStorage()) {
return {
url: await readFileAsDataUrl(file),
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
}
}
const uploadFile = await prepareSupplierRecommendedImageForUpload(file)
const formData = new FormData()
formData.append('file', uploadFile)
const response = await fetch('/api/supply-chain/supplier-recommended-products/upload', {
method: 'POST',
body: formData,
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || '图片上传失败')
}
return {
url: payload.data.url,
fileName: payload.data.fileName || uploadFile.name,
mimeType: payload.data.mimeType || uploadFile.type || 'application/octet-stream',
}
}

View File

@ -290,6 +290,16 @@ export class SupplierRecommendedProductsService {
}))
}
// 采购字段点选项:系统内 PURCHASING 业务角色的在职用户(与选款人用 OPERATIONS 角色同一个口径)。
async listPurchaserOptions() {
const users = await userService.listAll({ businessRole: 'PURCHASING', status: 'ACTIVE' })
return users.map((purchaser) => ({
id: purchaser.id,
name: purchaser.name,
departmentName: purchaser.department?.name ?? '',
}))
}
private async requireAccess(user: AuthUser, action: 'view' | 'download' | 'edit') {
const access = await this.getAccess(user)
if (!access[action]) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有供应商新品推荐权限')