feat(supplier-recommendations): add guided editor options
This commit is contained in:
parent
f6dd04eeee
commit
170d776aa6
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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: '没有权限' })
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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} />}
|
||||
|
||||
@ -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级', '少见等级']))
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -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}
|
||||
|
||||
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
@ -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, '当前账号没有供应商新品推荐权限')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user