feat(supply-chain): complete 1688 dropship sourcing workflow
This commit is contained in:
parent
499cbf66d1
commit
a22657e28e
@ -0,0 +1,42 @@
|
||||
CREATE TABLE "Dropship1688SourcingTask" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sourceArchiveId" TEXT NOT NULL,
|
||||
"sourceSpu" TEXT NOT NULL DEFAULT '',
|
||||
"sourceBarcode" VARCHAR(100) NOT NULL,
|
||||
"sourceCostPrice" DECIMAL(24,4),
|
||||
"sourceSkuOperator" TEXT NOT NULL DEFAULT '',
|
||||
"sourceDateFrom" DATE NOT NULL,
|
||||
"sourceDateTo" DATE NOT NULL,
|
||||
"purchaserUserId" TEXT NOT NULL,
|
||||
"purchaserName" TEXT NOT NULL,
|
||||
"supplierName" TEXT NOT NULL DEFAULT '',
|
||||
"quoted_price_tax_included" DECIMAL(24,4),
|
||||
"invoiceTaxPoint" DECIMAL(8,4),
|
||||
"sourcingMode" TEXT NOT NULL DEFAULT '',
|
||||
"jianshangBarcode" TEXT NOT NULL DEFAULT '',
|
||||
"acceptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Dropship1688SourcingTask_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Dropship1688SourcingTask_sourceBarcode_purchaserUserId_key"
|
||||
ON "Dropship1688SourcingTask"("sourceBarcode", "purchaserUserId");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_sourceArchiveId_idx"
|
||||
ON "Dropship1688SourcingTask"("sourceArchiveId");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_purchaserName_idx"
|
||||
ON "Dropship1688SourcingTask"("purchaserName");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_sourceSpu_idx"
|
||||
ON "Dropship1688SourcingTask"("sourceSpu");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_sourceSkuOperator_idx"
|
||||
ON "Dropship1688SourcingTask"("sourceSkuOperator");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_purchaserUserId_acceptedAt_idx"
|
||||
ON "Dropship1688SourcingTask"("purchaserUserId", "acceptedAt");
|
||||
|
||||
CREATE INDEX "Dropship1688SourcingTask_acceptedAt_idx"
|
||||
ON "Dropship1688SourcingTask"("acceptedAt");
|
||||
@ -807,6 +807,36 @@ model SupplyChainProductArchiveSalesPerformanceDaily {
|
||||
@@index([syncedAt])
|
||||
}
|
||||
|
||||
// 1688 代发商品的采购寻源承接快照。
|
||||
// 不建立业务外键:商品档案或账号变化后,仍需保留承接时的条码、人员和成本依据。
|
||||
model Dropship1688SourcingTask {
|
||||
id String @id @default(cuid())
|
||||
sourceArchiveId String
|
||||
sourceSpu String @default("")
|
||||
sourceBarcode String @db.VarChar(100)
|
||||
sourceCostPrice Decimal? @db.Decimal(24, 4)
|
||||
sourceSkuOperator String @default("")
|
||||
sourceDateFrom DateTime @db.Date
|
||||
sourceDateTo DateTime @db.Date
|
||||
purchaserUserId String
|
||||
purchaserName String
|
||||
supplierName String @default("")
|
||||
quotedPriceTaxIncluded Decimal? @map("quoted_price_tax_included") @db.Decimal(24, 4)
|
||||
invoiceTaxPoint Decimal? @db.Decimal(8, 4)
|
||||
sourcingMode String @default("")
|
||||
jianshangBarcode String @default("")
|
||||
acceptedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([sourceBarcode, purchaserUserId])
|
||||
@@index([sourceArchiveId])
|
||||
@@index([purchaserName])
|
||||
@@index([sourceSpu])
|
||||
@@index([sourceSkuOperator])
|
||||
@@index([purchaserUserId, acceptedAt])
|
||||
@@index([acceptedAt])
|
||||
}
|
||||
|
||||
model SupplyChainProductArchiveSalesSyncRun {
|
||||
id String @id @default(cuid())
|
||||
startedAt DateTime @default(now())
|
||||
|
||||
@ -28,11 +28,12 @@ describe('1688 dropship filter options route', () => {
|
||||
it('uses the authenticated supply-chain permission wrapper and preserves exact filter values', async () => {
|
||||
const response = await GET(request({
|
||||
field: 'sku', dateFrom: '2026-09-01', dateTo: '2026-09-07',
|
||||
keyword: '卧室', optionQuery: '白色',
|
||||
columnFilters: JSON.stringify({ spu: { mode: 'include', values: ['', ' DF-1 ', ' DF-1 '] } }),
|
||||
}))
|
||||
expect(handleRequest).toHaveBeenCalledWith(expect.anything(), expect.any(Function), { requireAuth: true })
|
||||
expect(dropship1688DataService.listFilterOptions).toHaveBeenCalledWith({
|
||||
field: 'sku', keyword: '', dateFrom: '2026-09-01', dateTo: '2026-09-07',
|
||||
field: 'sku', keyword: '卧室', optionQuery: '白色', dateFrom: '2026-09-01', dateTo: '2026-09-07',
|
||||
filters: { spu: { mode: 'include', values: ['', ' DF-1 '] } },
|
||||
})
|
||||
expect(response).toMatchObject({ success: true, data: { values: ['', 'SKU-1'] }, status: 200 })
|
||||
@ -42,6 +43,7 @@ describe('1688 dropship filter options route', () => {
|
||||
{ field: 'color' },
|
||||
{ field: 'sku', dateFrom: '2026-02-31' },
|
||||
{ field: 'sku', columnFilters: '{' },
|
||||
{ field: 'sku', optionQuery: 'x'.repeat(501) },
|
||||
{ field: 'sku', columnFilters: JSON.stringify({ sku: { mode: 'include', values: [3] } }) },
|
||||
{ field: 'sku', columnFilters: JSON.stringify({ other: { mode: 'include', values: [] } }) },
|
||||
])('rejects invalid filter input: %j', async (params) => {
|
||||
@ -53,17 +55,22 @@ describe('1688 dropship filter options route', () => {
|
||||
const values = Array.from({ length: 5000 }, (_, index) => `DF-SKU-${index}`)
|
||||
const response = await POST({
|
||||
headers: new Headers(),
|
||||
json: async () => ({ field: 'spu', filters: { sku: { mode: 'include', values } } }),
|
||||
json: async () => ({
|
||||
field: 'spu', keyword: '卧室', optionQuery: '白色',
|
||||
filters: { sku: { mode: 'include', values } },
|
||||
}),
|
||||
} as NextRequest)
|
||||
expect(response).toMatchObject({ success: true, status: 200 })
|
||||
expect(handleRequest).toHaveBeenCalledWith(expect.anything(), expect.any(Function), { requireAuth: true })
|
||||
const input = jest.mocked(dropship1688DataService.listFilterOptions).mock.calls[0][0]
|
||||
expect(new Set(input.filters?.sku?.values)).toEqual(new Set(values))
|
||||
expect(input).toMatchObject({ field: 'spu', keyword: '卧室', optionQuery: '白色' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ field: 'color' },
|
||||
{ field: 'sku', dateTo: '2026-02-31' },
|
||||
{ field: 'sku', optionQuery: 'x'.repeat(501) },
|
||||
{ field: 'sku', filters: { sku: { mode: 'include', values: [3] } } },
|
||||
])('rejects invalid POST input: %j', async (body) => {
|
||||
expect(await POST({ headers: new Headers(), json: async () => body } as NextRequest)).toMatchObject({ success: false, status: 400 })
|
||||
|
||||
@ -13,6 +13,7 @@ const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) => {
|
||||
const querySchema = z.object({
|
||||
field: z.enum(FILTER_FIELDS),
|
||||
keyword: z.string().max(500).optional().default(''),
|
||||
optionQuery: z.string().max(500).optional().default(''),
|
||||
dateFrom: dateSchema.optional(),
|
||||
dateTo: dateSchema.optional(),
|
||||
columnFilters: z.string().max(500_000).optional(),
|
||||
@ -27,6 +28,7 @@ export async function GET(request: NextRequest) {
|
||||
const parsed = querySchema.safeParse({
|
||||
field: request.nextUrl.searchParams.get('field') || undefined,
|
||||
keyword: request.nextUrl.searchParams.get('keyword') || undefined,
|
||||
optionQuery: request.nextUrl.searchParams.get('optionQuery') || undefined,
|
||||
dateFrom: request.nextUrl.searchParams.get('dateFrom') || undefined,
|
||||
dateTo: request.nextUrl.searchParams.get('dateTo') || undefined,
|
||||
columnFilters: request.nextUrl.searchParams.get('columnFilters') || undefined,
|
||||
@ -47,6 +49,7 @@ export async function GET(request: NextRequest) {
|
||||
const data = await dropship1688DataService.listFilterOptions({
|
||||
field: parsed.data.field,
|
||||
keyword: parsed.data.keyword,
|
||||
optionQuery: parsed.data.optionQuery,
|
||||
dateFrom: parsed.data.dateFrom,
|
||||
dateTo: parsed.data.dateTo,
|
||||
filters: normalizeDropship1688Filters(validation.data),
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockListTasksForExport = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
}))
|
||||
jest.mock('@/server/services/dropship-1688-sourcing.service', () => ({
|
||||
dropship1688SourcingService: {
|
||||
listTasksForExport: (...args: unknown[]) => mockListTasksForExport(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import ExcelJS from 'exceljs'
|
||||
import { POST } from './route'
|
||||
|
||||
describe('POST sourcing export', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockHandleRequest.mockImplementation(async (_request, handler) => handler({ requestId: 'request-1' }))
|
||||
mockListTasksForExport.mockResolvedValue([{
|
||||
id: 'task-1', sourceArchiveId: 'archive-1', sourceSpu: 'SPU-ONE', sourceBarcode: 'DF-ONE',
|
||||
sourceCostPrice: '34.2900', sourceSkuOperator: '运营甲', sourceDateFrom: '2026-09-01',
|
||||
sourceDateTo: '2026-09-10', purchaserUserId: 'buyer-1', purchaserName: '采购甲',
|
||||
supplierName: '', quotedPriceTaxIncluded: '50.0000', invoiceTaxPoint: '13.0000',
|
||||
sourcingMode: '代发', jianshangBarcode: 'JS-001', acceptedAt: '', updatedAt: '',
|
||||
sku: 'SKU-ONE', hasImage: false,
|
||||
}])
|
||||
})
|
||||
|
||||
it('exports every row matching the server-side filters as a round-trip workbook', async () => {
|
||||
const filters = {
|
||||
purchaserNames: ['采购甲'],
|
||||
sourceSpus: ['SPU-ONE'],
|
||||
sourceSkuOperators: ['运营甲'],
|
||||
}
|
||||
const response = await POST({ json: async () => ({ filters }) } as never)
|
||||
expect(mockListTasksForExport).toHaveBeenCalledWith({ filters })
|
||||
expect(response.headers.get('content-type')).toContain('spreadsheetml.sheet')
|
||||
expect(response.headers.get('content-disposition')).toContain('.xlsx')
|
||||
expect(response.headers.get('x-exported-rows')).toBe('1')
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.load(Buffer.from(await response.arrayBuffer()) as ExcelJS.Buffer)
|
||||
expect(workbook.getWorksheet('寻源明细')?.getCell('B2').value).toBe('DF-ONE')
|
||||
expect(workbook.getWorksheet('寻源明细')?.getCell('H2').value).toBe('采购甲')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createDropship1688SourcingWorkbook,
|
||||
DROPSHIP_1688_SOURCING_WORKBOOK_MIME,
|
||||
} from '@/lib/dropship-1688-sourcing-workbook'
|
||||
import { handleRequest } from '@/server/core/response'
|
||||
import { dropship1688SourcingService } from '@/server/services/dropship-1688-sourcing.service'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const tasks = await dropship1688SourcingService.listTasksForExport(
|
||||
await request.json().catch(() => null),
|
||||
)
|
||||
const workbook = await createDropship1688SourcingWorkbook(tasks)
|
||||
const body = new ArrayBuffer(workbook.byteLength)
|
||||
new Uint8Array(body).set(workbook)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
const fileName = encodeURIComponent(`1688代发寻源明细-${date}.xlsx`)
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
'Content-Type': DROPSHIP_1688_SOURCING_WORKBOOK_MIME,
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${fileName}`,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Exported-Rows': String(tasks.length),
|
||||
'X-Request-Id': ctx.requestId,
|
||||
},
|
||||
})
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockJsonSuccess = jest.fn()
|
||||
const mockImportRows = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
|
||||
}))
|
||||
jest.mock('@/server/services/dropship-1688-sourcing.service', () => ({
|
||||
dropship1688SourcingService: {
|
||||
importRows: (...args: unknown[]) => mockImportRows(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { createDropship1688SourcingWorkbook } from '@/lib/dropship-1688-sourcing-workbook'
|
||||
|
||||
class MockFile {
|
||||
name = '寻源明细.xlsx'
|
||||
type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
size: number
|
||||
|
||||
constructor(private readonly bytes: Uint8Array) {
|
||||
this.size = bytes.byteLength
|
||||
}
|
||||
|
||||
async arrayBuffer() {
|
||||
const output = new ArrayBuffer(this.bytes.byteLength)
|
||||
new Uint8Array(output).set(this.bytes)
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
describe('POST sourcing import', () => {
|
||||
const originalFile = global.File
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(global, 'File', { configurable: true, value: MockFile })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(global, 'File', { configurable: true, value: originalFile })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockHandleRequest.mockImplementation(async (_request, handler) => handler({
|
||||
requestId: 'request-1',
|
||||
user: { id: 'buyer-1', name: '采购甲', role: 'USER', businessRoles: ['PURCHASING'] },
|
||||
}))
|
||||
mockImportRows.mockResolvedValue({ updated: 1 })
|
||||
mockJsonSuccess.mockImplementation((data) => Response.json({ data }))
|
||||
})
|
||||
|
||||
it('parses the exported workbook and sends editable rows to the service', async () => {
|
||||
const bytes = await createDropship1688SourcingWorkbook([{
|
||||
sourceSpu: 'SPU-ONE', sourceBarcode: 'DF-ONE', sourceCostPrice: '34.2900',
|
||||
quotedPriceTaxIncluded: '50.0000', invoiceTaxPoint: '13.0000',
|
||||
sourceSkuOperator: '运营甲', purchaserName: '采购甲', supplierName: '供应商甲',
|
||||
sourcingMode: '代发', jianshangBarcode: 'JS-001',
|
||||
}])
|
||||
const file = new MockFile(bytes)
|
||||
const { POST } = await import('./route')
|
||||
const response = await POST({
|
||||
headers: new Headers(),
|
||||
formData: async () => ({ get: () => file }),
|
||||
} as never)
|
||||
|
||||
expect(mockImportRows).toHaveBeenCalledWith([{
|
||||
rowNumber: 2, sourceBarcode: 'DF-ONE', purchaserName: '采购甲',
|
||||
supplierName: '供应商甲', quotedPriceTaxIncluded: '50', invoiceTaxPoint: '13',
|
||||
sourcingMode: '代发', jianshangBarcode: 'JS-001',
|
||||
}], expect.objectContaining({ id: 'buyer-1' }))
|
||||
await expect(response.json()).resolves.toEqual({ data: { updated: 1 } })
|
||||
})
|
||||
|
||||
it('rejects an oversized declared multipart body before parsing form data', async () => {
|
||||
const formData = jest.fn()
|
||||
const { POST } = await import('./route')
|
||||
|
||||
await expect(POST({
|
||||
headers: new Headers({ 'content-length': String(11 * 1024 * 1024 + 1) }),
|
||||
formData,
|
||||
} as never)).rejects.toMatchObject({ message: 'Excel 文件不能超过10MB' })
|
||||
|
||||
expect(formData).not.toHaveBeenCalled()
|
||||
expect(mockImportRows).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,50 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import {
|
||||
DROPSHIP_1688_SOURCING_WORKBOOK_MIME,
|
||||
parseDropship1688SourcingWorkbook,
|
||||
} from '@/lib/dropship-1688-sourcing-workbook'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonSuccess } from '@/server/core/response'
|
||||
import { dropship1688SourcingService } from '@/server/services/dropship-1688-sourcing.service'
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + 1024 * 1024
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const contentLengthHeader = request.headers.get('content-length')
|
||||
if (contentLengthHeader !== null) {
|
||||
const contentLength = Number(contentLengthHeader)
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_MULTIPART_BODY_SIZE) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB')
|
||||
}
|
||||
}
|
||||
|
||||
const form = await request.formData()
|
||||
const file = form.get('file')
|
||||
if (!(file instanceof File)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择需要导入的 Excel 文件')
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB')
|
||||
}
|
||||
if (!file.name.toLowerCase().endsWith('.xlsx') && file.type !== DROPSHIP_1688_SOURCING_WORKBOOK_MIME) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '仅支持 .xlsx 文件')
|
||||
}
|
||||
|
||||
let rows
|
||||
try {
|
||||
rows = await parseDropship1688SourcingWorkbook(Buffer.from(await file.arrayBuffer()))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message.startsWith('导入文件')
|
||||
? error.message
|
||||
: 'Excel 文件无法解析,请使用“导出模板”生成的 .xlsx 文件'
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
message,
|
||||
)
|
||||
}
|
||||
const result = await dropship1688SourcingService.importRows(rows, ctx.user)
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockJsonError = jest.fn()
|
||||
const mockJsonSuccess = jest.fn()
|
||||
const mockListPage = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
jsonError: (...args: unknown[]) => mockJsonError(...args),
|
||||
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
|
||||
}))
|
||||
jest.mock('@/server/services/dropship-1688-sourcing.service', () => ({
|
||||
dropship1688SourcingService: { listPage: (...args: unknown[]) => mockListPage(...args) },
|
||||
}))
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { GET } from './route'
|
||||
|
||||
function request(query = '') {
|
||||
return new NextRequest(`http://localhost/api/supply-chain/1688-dropship-data/sourcing-list${query}`)
|
||||
}
|
||||
|
||||
describe('GET sourcing list', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockHandleRequest.mockImplementation(async (_request, handler) => handler({ requestId: 'request-1' }))
|
||||
mockJsonSuccess.mockImplementation((data, status) => ({ success: true, data, status }))
|
||||
mockJsonError.mockImplementation((_code, message, status) => ({ success: false, message, status }))
|
||||
mockListPage.mockResolvedValue({ rows: [], total: 0, page: 1, pageSize: 50, totalPages: 1 })
|
||||
})
|
||||
|
||||
it('loads a fixed 50-row page with repeated server-side filters', async () => {
|
||||
const response = await GET(request(
|
||||
'?page=3&purchaser=%E9%87%87%E8%B4%AD%E7%94%B2&purchaser=%E9%87%87%E8%B4%AD%E4%B9%99'
|
||||
+ '&spu=SPU-ONE&operator=%E8%BF%90%E8%90%A5%E7%94%B2',
|
||||
))
|
||||
|
||||
expect(response).toMatchObject({ success: true, status: 200 })
|
||||
expect(mockHandleRequest).toHaveBeenCalledWith(expect.any(NextRequest), expect.any(Function), { requireAuth: true })
|
||||
expect(mockListPage).toHaveBeenCalledWith({
|
||||
page: 3,
|
||||
pageSize: 50,
|
||||
filters: {
|
||||
purchaserNames: ['采购甲', '采购乙'],
|
||||
sourceSpus: ['SPU-ONE'],
|
||||
sourceSkuOperators: ['运营甲'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to the first unfiltered page', async () => {
|
||||
await GET(request())
|
||||
expect(mockListPage).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
filters: { purchaserNames: [], sourceSpus: [], sourceSkuOperators: [] },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'?page=0',
|
||||
'?page=1.5',
|
||||
`?purchaser=${'x'.repeat(201)}`,
|
||||
'?spu=',
|
||||
])('rejects invalid query input: %s', async (query) => {
|
||||
expect(await GET(request(query))).toMatchObject({
|
||||
success: false,
|
||||
status: 400,
|
||||
message: '寻源明细筛选参数不正确',
|
||||
})
|
||||
expect(mockListPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,48 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { DROPSHIP_1688_SOURCING_PAGE_SIZE } from '@/lib/dropship-1688-sourcing'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { dropship1688SourcingService } from '@/server/services/dropship-1688-sourcing.service'
|
||||
|
||||
const filterValuesSchema = z.array(z.string().trim().min(1).max(200)).max(100)
|
||||
|
||||
const querySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(1_000_000).default(1),
|
||||
purchaserNames: filterValuesSchema.default([]),
|
||||
sourceSpus: filterValuesSchema.default([]),
|
||||
sourceSkuOperators: filterValuesSchema.default([]),
|
||||
}).strict().transform((input) => ({
|
||||
page: input.page,
|
||||
filters: {
|
||||
purchaserNames: [...new Set(input.purchaserNames)],
|
||||
sourceSpus: [...new Set(input.sourceSpus)],
|
||||
sourceSkuOperators: [...new Set(input.sourceSkuOperators)],
|
||||
},
|
||||
}))
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const parsed = querySchema.safeParse({
|
||||
page: request.nextUrl.searchParams.get('page') || undefined,
|
||||
purchaserNames: request.nextUrl.searchParams.getAll('purchaser'),
|
||||
sourceSpus: request.nextUrl.searchParams.getAll('spu'),
|
||||
sourceSkuOperators: request.nextUrl.searchParams.getAll('operator'),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'寻源明细筛选参数不正确',
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const data = await dropship1688SourcingService.listPage({
|
||||
page: parsed.data.page,
|
||||
pageSize: DROPSHIP_1688_SOURCING_PAGE_SIZE,
|
||||
filters: parsed.data.filters,
|
||||
})
|
||||
return jsonSuccess(data, 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { DropshipAcceptanceButton } from './DropshipAcceptanceButton'
|
||||
import { claimDropship1688Sourcing } from './sourcing.actions'
|
||||
|
||||
const refresh = jest.fn()
|
||||
|
||||
jest.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) }))
|
||||
jest.mock('./sourcing.actions', () => ({ claimDropship1688Sourcing: jest.fn() }))
|
||||
|
||||
const props = {
|
||||
acceptanceRevision: '',
|
||||
barcode: 'DF-A1AD000205',
|
||||
dateFrom: '2026-09-01',
|
||||
dateTo: '2026-09-10',
|
||||
alreadyAccepted: false,
|
||||
canAccept: true,
|
||||
purchaserCount: 0,
|
||||
}
|
||||
|
||||
describe('DropshipAcceptanceButton', () => {
|
||||
let alertSpy: jest.SpyInstance
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
alertSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows the available count and disables acceptance without permission', () => {
|
||||
const { rerender } = render(<DropshipAcceptanceButton {...props} canAccept={false} />)
|
||||
const deniedButton = screen.getByRole('button', { name: '承接 0/3' })
|
||||
expect(deniedButton).toBeDisabled()
|
||||
expect(deniedButton).toHaveAttribute('title', '当前账号没有承接权限')
|
||||
expect(deniedButton).toHaveAttribute('data-state', 'permission')
|
||||
|
||||
rerender(<DropshipAcceptanceButton {...props} purchaserCount={2} />)
|
||||
const availableButton = screen.getByRole('button', { name: '承接 2/3' })
|
||||
expect(availableButton).toBeEnabled()
|
||||
expect(availableButton).toHaveAttribute('data-state', 'available')
|
||||
expect(claimDropship1688Sourcing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an accepted button non-full until three purchasers have accepted', () => {
|
||||
const { rerender } = render(
|
||||
<DropshipAcceptanceButton {...props} alreadyAccepted purchaserCount={1} />,
|
||||
)
|
||||
const acceptedButton = screen.getByRole('button', { name: '已承接 1/3' })
|
||||
expect(acceptedButton).toBeDisabled()
|
||||
expect(acceptedButton).toHaveAttribute('data-state', 'accepted')
|
||||
|
||||
rerender(<DropshipAcceptanceButton {...props} alreadyAccepted purchaserCount={3} />)
|
||||
const fullButton = screen.getByRole('button', { name: '已满 3/3' })
|
||||
expect(fullButton).toBeDisabled()
|
||||
expect(fullButton).toHaveAttribute('data-state', 'full')
|
||||
expect(fullButton).toHaveAttribute('title', '已满3名采购')
|
||||
})
|
||||
|
||||
it('locks while submitting, then marks success locally and refreshes server data', async () => {
|
||||
let resolveClaim: ((value: { ok: true }) => void) | undefined
|
||||
jest.mocked(claimDropship1688Sourcing).mockImplementation(() => new Promise((resolve) => {
|
||||
resolveClaim = resolve
|
||||
}))
|
||||
render(<DropshipAcceptanceButton {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '承接 0/3' }))
|
||||
const pendingButton = screen.getByRole('button', { name: '承接中' })
|
||||
expect(pendingButton).toBeDisabled()
|
||||
fireEvent.click(pendingButton)
|
||||
expect(claimDropship1688Sourcing).toHaveBeenCalledTimes(1)
|
||||
expect(claimDropship1688Sourcing).toHaveBeenCalledWith({
|
||||
barcode: 'DF-A1AD000205',
|
||||
dateFrom: '2026-09-01',
|
||||
dateTo: '2026-09-10',
|
||||
})
|
||||
|
||||
resolveClaim?.({ ok: true })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '已承接 1/3' })).toBeDisabled())
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(alertSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears optimistic acceptance after the server confirms and later revokes it', async () => {
|
||||
jest.mocked(claimDropship1688Sourcing).mockResolvedValue({ ok: true })
|
||||
const { rerender } = render(<DropshipAcceptanceButton {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '承接 0/3' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '已承接 1/3' })).toBeDisabled())
|
||||
|
||||
rerender(
|
||||
<DropshipAcceptanceButton
|
||||
{...props}
|
||||
acceptanceRevision="task-1"
|
||||
alreadyAccepted
|
||||
purchaserCount={1}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '已承接 1/3' })).toBeDisabled()
|
||||
|
||||
rerender(<DropshipAcceptanceButton {...props} />)
|
||||
const available = screen.getByRole('button', { name: '承接 0/3' })
|
||||
expect(available).toBeEnabled()
|
||||
expect(available).toHaveAttribute('data-state', 'available')
|
||||
})
|
||||
|
||||
it('shows an action error and allows retrying', async () => {
|
||||
jest.mocked(claimDropship1688Sourcing)
|
||||
.mockResolvedValueOnce({ ok: false, message: '当前商品暂时无法承接' })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
render(<DropshipAcceptanceButton {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '承接 0/3' }))
|
||||
await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('当前商品暂时无法承接'))
|
||||
expect(screen.getByRole('button', { name: '承接 0/3' })).toBeEnabled()
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '承接 0/3' }))
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1))
|
||||
expect(screen.getByRole('button', { name: '已承接 1/3' })).toBeDisabled()
|
||||
expect(claimDropship1688Sourcing).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows thrown errors and falls back to a clear message for unknown failures', async () => {
|
||||
jest.mocked(claimDropship1688Sourcing)
|
||||
.mockRejectedValueOnce(new Error('服务暂不可用'))
|
||||
.mockRejectedValueOnce(null)
|
||||
render(<DropshipAcceptanceButton {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '承接 0/3' }))
|
||||
await waitFor(() => expect(alertSpy).toHaveBeenLastCalledWith('服务暂不可用'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '承接 0/3' }))
|
||||
await waitFor(() => expect(alertSpy).toHaveBeenLastCalledWith('承接失败,请稍后重试'))
|
||||
expect(screen.getByRole('button', { name: '承接 0/3' })).toBeEnabled()
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { Handshake } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { DROPSHIP_1688_MAX_PURCHASERS } from '@/lib/dropship-1688-sourcing'
|
||||
import { claimDropship1688Sourcing } from './sourcing.actions'
|
||||
|
||||
type DropshipAcceptanceButtonProps = {
|
||||
acceptanceRevision: string
|
||||
barcode: string
|
||||
dateFrom: string
|
||||
dateTo: string
|
||||
alreadyAccepted: boolean
|
||||
canAccept: boolean
|
||||
purchaserCount: number
|
||||
}
|
||||
|
||||
const DEFAULT_ERROR_MESSAGE = '承接失败,请稍后重试'
|
||||
|
||||
export function DropshipAcceptanceButton({
|
||||
acceptanceRevision,
|
||||
...props
|
||||
}: DropshipAcceptanceButtonProps) {
|
||||
return <DropshipAcceptanceButtonState key={acceptanceRevision} {...props} />
|
||||
}
|
||||
|
||||
function DropshipAcceptanceButtonState({
|
||||
barcode,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
alreadyAccepted,
|
||||
canAccept,
|
||||
purchaserCount,
|
||||
}: Omit<DropshipAcceptanceButtonProps, 'acceptanceRevision'>) {
|
||||
const router = useRouter()
|
||||
const submitting = useRef(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [acceptedLocally, setAcceptedLocally] = useState(false)
|
||||
const accepted = alreadyAccepted || acceptedLocally
|
||||
const displayedCount = Math.min(
|
||||
DROPSHIP_1688_MAX_PURCHASERS,
|
||||
Math.max(0, purchaserCount) + (acceptedLocally && !alreadyAccepted ? 1 : 0),
|
||||
)
|
||||
const full = displayedCount >= DROPSHIP_1688_MAX_PURCHASERS
|
||||
const disabled = !canAccept || accepted || full || pending
|
||||
const state = !canAccept
|
||||
? 'permission'
|
||||
: full
|
||||
? 'full'
|
||||
: accepted
|
||||
? 'accepted'
|
||||
: pending
|
||||
? 'pending'
|
||||
: 'available'
|
||||
const label = pending
|
||||
? '承接中'
|
||||
: full
|
||||
? `已满 ${displayedCount}/${DROPSHIP_1688_MAX_PURCHASERS}`
|
||||
: accepted
|
||||
? `已承接 ${displayedCount}/${DROPSHIP_1688_MAX_PURCHASERS}`
|
||||
: `承接 ${displayedCount}/${DROPSHIP_1688_MAX_PURCHASERS}`
|
||||
const title = !canAccept
|
||||
? '当前账号没有承接权限'
|
||||
: full
|
||||
? `已满${DROPSHIP_1688_MAX_PURCHASERS}名采购`
|
||||
: accepted
|
||||
? `你已承接,当前${displayedCount}/${DROPSHIP_1688_MAX_PURCHASERS}人`
|
||||
: pending
|
||||
? '正在承接'
|
||||
: `当前${displayedCount}/${DROPSHIP_1688_MAX_PURCHASERS}人承接`
|
||||
|
||||
async function accept() {
|
||||
if (disabled || submitting.current) return
|
||||
submitting.current = true
|
||||
setPending(true)
|
||||
|
||||
try {
|
||||
const result = await claimDropship1688Sourcing({ barcode, dateFrom, dateTo })
|
||||
if (!result.ok) {
|
||||
window.alert(result.message?.trim() || DEFAULT_ERROR_MESSAGE)
|
||||
return
|
||||
}
|
||||
setAcceptedLocally(true)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
window.alert(
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: DEFAULT_ERROR_MESSAGE,
|
||||
)
|
||||
} finally {
|
||||
submitting.current = false
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
data-state={state}
|
||||
onClick={() => void accept()}
|
||||
>
|
||||
<Handshake size={13} aria-hidden="true" />{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,399 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import type { Dropship1688Filters } from '@/lib/dropship-1688-filters'
|
||||
import {
|
||||
DropshipAutoSubmitDateField,
|
||||
DropshipFieldFilter,
|
||||
DropshipFieldFilterGroup,
|
||||
} from './DropshipFieldFilter'
|
||||
import { saveDropship1688Filters } from './filters.actions'
|
||||
|
||||
const push = jest.fn()
|
||||
|
||||
jest.mock('next/navigation', () => ({ useRouter: () => ({ push }) }))
|
||||
jest.mock('./filters.actions', () => ({ saveDropship1688Filters: jest.fn() }))
|
||||
|
||||
function response(body: unknown, ok = true) {
|
||||
return { ok, json: async () => body } as Response
|
||||
}
|
||||
|
||||
let fetchMock: jest.Mock
|
||||
|
||||
function values(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `VALUE-${index}`)
|
||||
}
|
||||
|
||||
function renderFilter(
|
||||
filters: Dropship1688Filters = {},
|
||||
overrides: { keyword?: string } = {},
|
||||
) {
|
||||
return render(filterUi(filters, overrides))
|
||||
}
|
||||
|
||||
function filterUi(
|
||||
filters: Dropship1688Filters = {},
|
||||
overrides: { keyword?: string } = {},
|
||||
) {
|
||||
return (
|
||||
<DropshipFieldFilterGroup
|
||||
dateFrom="2026-09-01"
|
||||
dateTo="2026-09-07"
|
||||
keyword={overrides.keyword ?? ''}
|
||||
filters={filters}
|
||||
>
|
||||
<DropshipFieldFilter
|
||||
field="skuOperator"
|
||||
noun="运营"
|
||||
allLabel="全部运营"
|
||||
/>
|
||||
</DropshipFieldFilterGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function openSelect(filters: Dropship1688Filters = {}) {
|
||||
renderFilter(filters)
|
||||
const trigger = screen.getByRole('button', { name: '运营筛选' })
|
||||
fireEvent.click(trigger)
|
||||
}
|
||||
|
||||
describe('DropshipFieldFilter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fetchMock = jest.fn(async () => response({ success: true, data: { values: values(3) } }))
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
jest.mocked(saveDropship1688Filters).mockResolvedValue('r'.repeat(64))
|
||||
})
|
||||
|
||||
it('submits the form whenever either date changes', () => {
|
||||
const submitted = jest.fn()
|
||||
render(
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitted()
|
||||
}}>
|
||||
<DropshipAutoSubmitDateField
|
||||
name="dateFrom"
|
||||
label="开始日期"
|
||||
defaultValue="2026-09-01"
|
||||
/>
|
||||
<DropshipAutoSubmitDateField
|
||||
name="dateTo"
|
||||
label="结束日期"
|
||||
defaultValue="2026-09-07"
|
||||
/>
|
||||
</form>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('开始日期'), {
|
||||
target: { value: '2026-08-01' },
|
||||
})
|
||||
expect(submitted).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('结束日期'), {
|
||||
target: { value: '2026-08-31' },
|
||||
})
|
||||
expect(submitted).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('summarizes an include selection and an exclude selection differently', () => {
|
||||
const { rerender } = render(filterUi({
|
||||
skuOperator: { mode: 'include', values: ['运营甲', '运营乙'] },
|
||||
}))
|
||||
expect(screen.getByRole('button', { name: '运营筛选' })).toHaveTextContent('已选 2 项')
|
||||
|
||||
rerender(filterUi({ skuOperator: { mode: 'exclude', values: ['运营甲'] } }))
|
||||
expect(screen.getByRole('button', { name: '运营筛选' })).toHaveTextContent('排除 1 项')
|
||||
|
||||
rerender(filterUi({ skuOperator: { mode: 'include', values: [] } }))
|
||||
expect(screen.getByRole('button', { name: '运营筛选' })).toHaveTextContent('已选 0 项')
|
||||
})
|
||||
|
||||
it('loads candidates through the shared filter-options endpoint with the current filters', async () => {
|
||||
openSelect({ sku: { mode: 'include', values: ['SKU-1'] } })
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body))
|
||||
expect(body).toEqual({
|
||||
field: 'skuOperator',
|
||||
keyword: '',
|
||||
optionQuery: '',
|
||||
dateFrom: '2026-09-01',
|
||||
dateTo: '2026-09-07',
|
||||
filters: { sku: { mode: 'include', values: ['SKU-1'] } },
|
||||
})
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'VALUE-0' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('keeps the page keyword separate when the option query changes', async () => {
|
||||
render(
|
||||
<DropshipFieldFilterGroup
|
||||
dateFrom="2026-09-01"
|
||||
dateTo="2026-09-07"
|
||||
keyword="卧室"
|
||||
filters={{}}
|
||||
>
|
||||
<DropshipFieldFilter field="sku" noun="SKU" allLabel="全部SKU" />
|
||||
</DropshipFieldFilterGroup>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'SKU筛选' }))
|
||||
expect(screen.getByText('请输入关键词搜索')).toBeVisible()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: '搜索SKU' }), {
|
||||
target: { value: '甲' },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body))
|
||||
expect(body.keyword).toBe('卧室')
|
||||
expect(body.optionQuery).toBe('甲')
|
||||
})
|
||||
|
||||
it('filters purchaser candidates locally without repeating the server request', async () => {
|
||||
render(
|
||||
<DropshipFieldFilterGroup
|
||||
dateFrom="2026-09-01"
|
||||
dateTo="2026-09-07"
|
||||
keyword=""
|
||||
filters={{}}
|
||||
>
|
||||
<DropshipFieldFilter field="purchaser" noun="采购" allLabel="全部采购" />
|
||||
</DropshipFieldFilterGroup>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '采购筛选' }))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: '搜索采购' }), {
|
||||
target: { value: 'VALUE-1' },
|
||||
})
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'VALUE-1' })).toBeVisible())
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies the checked values as an include filter without an apply button', async () => {
|
||||
openSelect()
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'VALUE-1' }))
|
||||
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
const target = decodeURIComponent(String(push.mock.calls[0][0]))
|
||||
expect(target).toContain('"skuOperator":{"mode":"include","values":["VALUE-1"]}')
|
||||
expect(saveDropship1688Filters).not.toHaveBeenCalled()
|
||||
// 弹层保持打开,与寻源明细的筛选一致。
|
||||
expect(screen.getByRole('dialog', { name: '运营选项' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('coalesces rapid checking into a single navigation', async () => {
|
||||
openSelect()
|
||||
await screen.findByRole('checkbox', { name: 'VALUE-0' })
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'VALUE-0' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'VALUE-1' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'VALUE-2' }))
|
||||
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
const target = decodeURIComponent(String(push.mock.calls[0][0]))
|
||||
expect(target).toContain('"values":["VALUE-0","VALUE-1","VALUE-2"]')
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 800 })
|
||||
})
|
||||
|
||||
it('removes the field filter through 清空', async () => {
|
||||
openSelect({ skuOperator: { mode: 'include', values: ['运营甲'] } })
|
||||
await screen.findByRole('checkbox', { name: 'VALUE-0' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空' }))
|
||||
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
const target = decodeURIComponent(String(push.mock.calls[0][0]))
|
||||
expect(target).not.toContain('skuOperator')
|
||||
expect(target).not.toContain('columnFilters')
|
||||
})
|
||||
|
||||
it('warns before replacing an exclude filter and can clear it directly', async () => {
|
||||
openSelect({ skuOperator: { mode: 'exclude', values: ['运营甲'] } })
|
||||
expect(screen.getByText(/当前筛选是排除模式/)).toBeVisible()
|
||||
const clear = screen.getByRole('button', { name: '清空' })
|
||||
expect(clear).toBeEnabled()
|
||||
fireEvent.click(clear)
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
expect(decodeURIComponent(String(push.mock.calls[0][0]))).not.toContain('skuOperator')
|
||||
})
|
||||
|
||||
it('can clear an explicit empty include filter', async () => {
|
||||
openSelect({ skuOperator: { mode: 'include', values: [] } })
|
||||
const clear = screen.getByRole('button', { name: '清空' })
|
||||
expect(clear).toBeEnabled()
|
||||
fireEvent.click(clear)
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
expect(decodeURIComponent(String(push.mock.calls[0][0]))).not.toContain('skuOperator')
|
||||
})
|
||||
|
||||
it('merges rapid selections from different fields into one navigation', async () => {
|
||||
render(
|
||||
<DropshipFieldFilterGroup
|
||||
dateFrom="2026-09-01"
|
||||
dateTo="2026-09-07"
|
||||
keyword=""
|
||||
filters={{}}
|
||||
>
|
||||
<DropshipFieldFilter field="purchaser" noun="采购" allLabel="全部采购" />
|
||||
<DropshipFieldFilter field="spu" noun="SPU" allLabel="全部SPU" />
|
||||
</DropshipFieldFilterGroup>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '采购筛选' }))
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'VALUE-0' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'SPU筛选' }))
|
||||
const spuDialog = screen.getByRole('dialog', { name: 'SPU选项' })
|
||||
expect(within(spuDialog).getByText('请输入关键词搜索')).toBeVisible()
|
||||
fireEvent.change(within(spuDialog).getByRole('searchbox', { name: '搜索SPU' }), {
|
||||
target: { value: 'VALUE' },
|
||||
})
|
||||
fireEvent.click(await within(spuDialog).findByRole('checkbox', { name: 'VALUE-1' }))
|
||||
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
const target = decodeURIComponent(String(push.mock.calls[0][0]))
|
||||
expect(target).toContain('"purchaser":{"mode":"include","values":["VALUE-0"]}')
|
||||
expect(target).toContain('"spu":{"mode":"include","values":["VALUE-1"]}')
|
||||
})
|
||||
|
||||
it('uses the current date and keyword inputs when a filter auto-applies', async () => {
|
||||
render(
|
||||
<form aria-label="数据筛选">
|
||||
<input aria-label="开始日期" name="dateFrom" defaultValue="2026-09-01" />
|
||||
<input aria-label="结束日期" name="dateTo" defaultValue="2026-09-07" />
|
||||
<input aria-label="商品检索" name="keyword" defaultValue="" />
|
||||
<DropshipFieldFilterGroup
|
||||
dateFrom="2026-09-01"
|
||||
dateTo="2026-09-07"
|
||||
keyword=""
|
||||
filters={{}}
|
||||
>
|
||||
<DropshipFieldFilter field="skuOperator" noun="运营" allLabel="全部运营" />
|
||||
</DropshipFieldFilterGroup>
|
||||
<button type="submit">查询</button>
|
||||
</form>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '开始日期' }), {
|
||||
target: { value: '2026-08-01' },
|
||||
})
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '商品检索' }), {
|
||||
target: { value: '卧室' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '运营筛选' }))
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'VALUE-0' }))
|
||||
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
const target = decodeURIComponent(String(push.mock.calls[0][0]))
|
||||
expect(target).toContain('dateFrom=2026-08-01')
|
||||
expect(target).toContain('keyword=卧室')
|
||||
expect(target).toContain('"skuOperator":{"mode":"include","values":["VALUE-0"]}')
|
||||
})
|
||||
|
||||
it('flushes a pending filter before an immediate date change', async () => {
|
||||
render(
|
||||
<form aria-label="数据筛选">
|
||||
<DropshipAutoSubmitDateField
|
||||
name="dateFrom"
|
||||
label="开始日期"
|
||||
defaultValue="2026-09-01"
|
||||
/>
|
||||
<input name="dateTo" defaultValue="2026-09-07" />
|
||||
<DropshipFieldFilterGroup
|
||||
dateFrom="2026-09-01"
|
||||
dateTo="2026-09-07"
|
||||
keyword=""
|
||||
filters={{}}
|
||||
>
|
||||
<DropshipFieldFilter field="skuOperator" noun="运营" allLabel="全部运营" />
|
||||
</DropshipFieldFilterGroup>
|
||||
</form>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '运营筛选' }))
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'VALUE-1' }))
|
||||
fireEvent.change(screen.getByLabelText('开始日期'), {
|
||||
target: { value: '2026-08-01' },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
const target = decodeURIComponent(String(push.mock.calls[0][0]))
|
||||
expect(target).toContain('dateFrom=2026-08-01')
|
||||
expect(target).toContain('"skuOperator":{"mode":"include","values":["VALUE-1"]}')
|
||||
})
|
||||
|
||||
it('replays a query submitted while a large filter save is in flight', async () => {
|
||||
const manyValues = Array.from({ length: 150 }, (_, index) => `VALUE-${index}-${'x'.repeat(20)}`)
|
||||
fetchMock = jest.fn(async () => response({ success: true, data: { values: manyValues } }))
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
let resolveSave!: (value: string) => void
|
||||
const save = new Promise<string>((resolve) => { resolveSave = resolve })
|
||||
jest.mocked(saveDropship1688Filters).mockReturnValue(save)
|
||||
|
||||
render(
|
||||
<form aria-label="数据筛选">
|
||||
<input aria-label="商品检索" name="keyword" defaultValue="" />
|
||||
<DropshipFieldFilterGroup
|
||||
dateFrom="2026-09-01"
|
||||
dateTo="2026-09-07"
|
||||
keyword=""
|
||||
filters={{}}
|
||||
>
|
||||
<DropshipFieldFilter field="skuOperator" noun="运营" allLabel="全部运营" />
|
||||
</DropshipFieldFilterGroup>
|
||||
<button type="submit">查询</button>
|
||||
</form>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '运营筛选' }))
|
||||
await screen.findByRole('checkbox', { name: manyValues[0] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '全选全部' }))
|
||||
await waitFor(() => expect(saveDropship1688Filters).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '商品检索' }), {
|
||||
target: { value: 'DF-LATEST' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }))
|
||||
await act(async () => resolveSave('r'.repeat(64)))
|
||||
|
||||
await waitFor(() => expect(saveDropship1688Filters).toHaveBeenCalledTimes(2), { timeout: 2000 })
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1), { timeout: 2000 })
|
||||
expect(decodeURIComponent(String(push.mock.calls[0][0]))).toContain('keyword=DF-LATEST')
|
||||
})
|
||||
|
||||
it('stops retrying automatically after saving a large selection fails', async () => {
|
||||
const manyValues = Array.from({ length: 150 }, (_, index) => `VALUE-${index}-${'x'.repeat(20)}`)
|
||||
fetchMock = jest.fn(async () => response({ success: true, data: { values: manyValues } }))
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
jest.mocked(saveDropship1688Filters).mockRejectedValue(new Error('offline'))
|
||||
|
||||
openSelect()
|
||||
await screen.findByRole('checkbox', { name: manyValues[0] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '全选全部' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '运营筛选' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('筛选保存失败,请重试')
|
||||
expect(screen.queryByRole('dialog', { name: '运营选项' })).not.toBeInTheDocument()
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
expect(saveDropship1688Filters).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: '运营筛选' })).toHaveTextContent('全部运营')
|
||||
})
|
||||
|
||||
it('renders a windowed slice instead of every candidate', async () => {
|
||||
fetchMock = jest.fn(async () => response({ success: true, data: { values: values(60) } }))
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
openSelect()
|
||||
await waitFor(() => expect(screen.getAllByRole('checkbox')).toHaveLength(16))
|
||||
expect(screen.queryByRole('checkbox', { name: 'VALUE-40' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers a retry after a failed candidate load', async () => {
|
||||
fetchMock = jest.fn(async () => response({ success: false, error: { message: '候选值加载失败' } }, false))
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
openSelect()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('候选值加载失败')
|
||||
|
||||
fetchMock = jest.fn(async () => response({ success: true, data: { values: values(3) } }))
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'VALUE-0' })).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,497 @@
|
||||
'use client'
|
||||
|
||||
import { Check, ChevronDown, LoaderCircle, Search, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
DROPSHIP1688_FILTER_MAX_BYTES,
|
||||
serializeDropship1688Filters,
|
||||
type Dropship1688FilterField,
|
||||
type Dropship1688Filters,
|
||||
} from '@/lib/dropship-1688-filters'
|
||||
import styles from './dropship-1688-data.module.css'
|
||||
import { saveDropship1688Filters } from './filters.actions'
|
||||
|
||||
const ROW_HEIGHT = 30
|
||||
const WINDOW_SIZE = 16
|
||||
const LIST_MAX_HEIGHT = 240
|
||||
const SEARCH_DEBOUNCE_MS = 250
|
||||
const POPOVER_WIDTH = 296
|
||||
const APPLY_DEBOUNCE_MS = 300
|
||||
|
||||
type FilterGroupContextValue = {
|
||||
filters: Dropship1688Filters
|
||||
pending: boolean
|
||||
saving: boolean
|
||||
readPeriod: () => { dateFrom: string; dateTo: string; keyword: string }
|
||||
scheduleSelection: (field: Dropship1688FilterField, values: string[]) => void
|
||||
}
|
||||
|
||||
const FilterGroupContext = createContext<FilterGroupContextValue | null>(null)
|
||||
|
||||
export function DropshipAutoSubmitDateField({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
}: {
|
||||
name: 'dateFrom' | 'dateTo'
|
||||
label: string
|
||||
defaultValue: string
|
||||
}) {
|
||||
return (
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type="date"
|
||||
defaultValue={defaultValue}
|
||||
onChange={(event) => event.currentTarget.form?.requestSubmit()}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropshipFieldFilterGroup({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
keyword,
|
||||
filters,
|
||||
children,
|
||||
}: {
|
||||
dateFrom: string
|
||||
dateTo: string
|
||||
keyword: string
|
||||
filters: Dropship1688Filters
|
||||
children: ReactNode
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [workingFilters, setWorkingFilters] = useState(filters)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const [pending, startTransition] = useTransition()
|
||||
const filtersRef = useRef(filters)
|
||||
const committedFiltersRef = useRef(filters)
|
||||
const periodRef = useRef({ dateFrom, dateTo, keyword })
|
||||
const applyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const desiredFilters = useRef<Dropship1688Filters | null>(null)
|
||||
const applyingRef = useRef(false)
|
||||
const groupRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
periodRef.current = { dateFrom, dateTo, keyword }
|
||||
|
||||
const readPeriod = useCallback(() => {
|
||||
const fallback = periodRef.current
|
||||
const form = groupRef.current?.closest('form')
|
||||
const value = (name: string, defaultValue: string) => {
|
||||
const control = form?.elements.namedItem(name)
|
||||
return control instanceof HTMLInputElement ? control.value : defaultValue
|
||||
}
|
||||
return {
|
||||
dateFrom: value('dateFrom', fallback.dateFrom),
|
||||
dateTo: value('dateTo', fallback.dateTo),
|
||||
keyword: value('keyword', fallback.keyword),
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
committedFiltersRef.current = filters
|
||||
if (applyingRef.current || desiredFilters.current) return
|
||||
filtersRef.current = filters
|
||||
setWorkingFilters(filters)
|
||||
}, [filters])
|
||||
|
||||
const flushApply = useCallback(async () => {
|
||||
const target = desiredFilters.current
|
||||
if (!target) return
|
||||
if (applyingRef.current) {
|
||||
applyTimer.current = setTimeout(() => void flushApply(), 200)
|
||||
return
|
||||
}
|
||||
applyingRef.current = true
|
||||
setSaving(true)
|
||||
setSaveError('')
|
||||
try {
|
||||
const { dateFrom, dateTo, keyword } = readPeriod()
|
||||
const serialized = serializeDropship1688Filters(target)
|
||||
if (new Blob([serialized]).size > DROPSHIP1688_FILTER_MAX_BYTES) {
|
||||
throw new Error('筛选条件过多,请缩小选择范围')
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
params.set('dateFrom', dateFrom)
|
||||
params.set('dateTo', dateTo)
|
||||
params.set('keyword', keyword)
|
||||
params.delete('page')
|
||||
params.delete('filterRef')
|
||||
params.delete('columnFilters')
|
||||
if (encodeURIComponent(serialized).length > 2000) {
|
||||
params.set('filterRef', await saveDropship1688Filters(target))
|
||||
} else if (serialized) {
|
||||
params.set('columnFilters', serialized)
|
||||
}
|
||||
// A newer selection or form submission arrived while a large filter was saving.
|
||||
// Skip the stale navigation; the finally block will flush the latest request.
|
||||
if (desiredFilters.current !== target) return
|
||||
desiredFilters.current = null
|
||||
startTransition(() => router.push(`${window.location.pathname}?${params}`, { scroll: false }))
|
||||
} catch (reason) {
|
||||
if (desiredFilters.current === target) {
|
||||
desiredFilters.current = null
|
||||
filtersRef.current = committedFiltersRef.current
|
||||
setWorkingFilters(committedFiltersRef.current)
|
||||
setSaveError(reason instanceof Error && reason.message.startsWith('筛选条件过多')
|
||||
? reason.message
|
||||
: '筛选保存失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
applyingRef.current = false
|
||||
if (desiredFilters.current) {
|
||||
applyTimer.current = setTimeout(() => void flushApply(), 120)
|
||||
}
|
||||
}
|
||||
}, [readPeriod, router])
|
||||
|
||||
const scheduleSelection = useCallback((field: Dropship1688FilterField, values: string[]) => {
|
||||
const next: Dropship1688Filters = { ...filtersRef.current }
|
||||
if (values.length === 0) delete next[field]
|
||||
else next[field] = { mode: 'include', values }
|
||||
filtersRef.current = next
|
||||
desiredFilters.current = next
|
||||
setWorkingFilters(next)
|
||||
setSaveError('')
|
||||
if (applyTimer.current) clearTimeout(applyTimer.current)
|
||||
applyTimer.current = setTimeout(() => void flushApply(), APPLY_DEBOUNCE_MS)
|
||||
}, [flushApply])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (applyTimer.current) clearTimeout(applyTimer.current)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const form = groupRef.current?.closest('form')
|
||||
if (!form) return
|
||||
const handleSubmit = (event: SubmitEvent) => {
|
||||
if (!desiredFilters.current && !applyingRef.current && !pending) return
|
||||
event.preventDefault()
|
||||
// Preserve a query made while another save/navigation is in flight. A fresh
|
||||
// object keeps the queued request distinct from the target currently saving.
|
||||
if (applyingRef.current || pending) {
|
||||
desiredFilters.current = { ...filtersRef.current }
|
||||
}
|
||||
if (applyTimer.current) clearTimeout(applyTimer.current)
|
||||
if (!applyingRef.current) void flushApply()
|
||||
}
|
||||
form.addEventListener('submit', handleSubmit)
|
||||
return () => form.removeEventListener('submit', handleSubmit)
|
||||
}, [flushApply, pending])
|
||||
|
||||
return (
|
||||
<FilterGroupContext.Provider value={{
|
||||
filters: workingFilters,
|
||||
pending,
|
||||
saving,
|
||||
readPeriod,
|
||||
scheduleSelection,
|
||||
}}>
|
||||
<div ref={groupRef} className={styles.dropshipFilterGroup}>
|
||||
{children}
|
||||
{saveError ? (
|
||||
<p className={styles.dropshipFilterGroupError} role="alert">{saveError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</FilterGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据明细筛选行的单个筛选项。
|
||||
* 与寻源明细的筛选控件保持同样的「搜索 + 勾选」交互,但候选项与筛选状态都走服务端:
|
||||
* 候选来自 filter-options 接口,勾选结果沿用列筛选的 filters / filterRef 持久化口径。
|
||||
* 选项可能有几万个,所以列表做窗口化渲染,并由服务端候选搜索缩小范围。
|
||||
*/
|
||||
export function DropshipFieldFilter({
|
||||
field,
|
||||
noun,
|
||||
allLabel,
|
||||
}: {
|
||||
field: Dropship1688FilterField
|
||||
/** 字段名,例如「运营」,用于拼出「运营筛选」「搜索运营」。 */
|
||||
noun: string
|
||||
allLabel: string
|
||||
}) {
|
||||
const group = useContext(FilterGroupContext)
|
||||
if (!group) throw new Error('DropshipFieldFilter must be used inside DropshipFieldFilterGroup')
|
||||
const {
|
||||
filters,
|
||||
pending,
|
||||
saving,
|
||||
readPeriod,
|
||||
scheduleSelection,
|
||||
} = group
|
||||
const fieldLabel = `${noun}筛选`
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [values, setValues] = useState<string[]>([])
|
||||
const [selection, setSelection] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [retry, setRetry] = useState(0)
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
// 触发器靠屏幕右缘时,弹层改为向左展开,避免溢出视口。
|
||||
const [alignRight, setAlignRight] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const serializedFilters = useMemo(
|
||||
() => {
|
||||
const candidateFilters = { ...filters }
|
||||
delete candidateFilters[field]
|
||||
return serializeDropship1688Filters(candidateFilters)
|
||||
},
|
||||
[field, filters],
|
||||
)
|
||||
const current = filters[field]
|
||||
const excludeMode = current?.mode === 'exclude'
|
||||
const selected = useMemo(
|
||||
() => (current?.mode === 'include' ? current.values : []),
|
||||
[current],
|
||||
)
|
||||
const selectedSet = useMemo(() => new Set(selection), [selection])
|
||||
const remoteSearch = field === 'spu' || field === 'sku' || field === 'barcode'
|
||||
const remoteOptionQuery = remoteSearch
|
||||
? debouncedSearch
|
||||
: ''
|
||||
const waitingForQuery = remoteSearch && !remoteOptionQuery.trim()
|
||||
const matches = useMemo(() => {
|
||||
const query = debouncedSearch.trim().toLocaleLowerCase()
|
||||
if (!query) return values
|
||||
return values.filter((value) => value.toLocaleLowerCase().includes(query))
|
||||
}, [debouncedSearch, values])
|
||||
const windowStart = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - 3)
|
||||
const summary = excludeMode && current && current.values.length > 0
|
||||
? `排除 ${current.values.length} 项`
|
||||
: current?.mode === 'include' && current.values.length === 0
|
||||
? '已选 0 项'
|
||||
: selected.length === 0
|
||||
? allLabel
|
||||
: selected.length === 1
|
||||
? selected[0]
|
||||
: `已选 ${selected.length} 项`
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), SEARCH_DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !waitingForQuery) return
|
||||
setValues(selected)
|
||||
setLoading(false)
|
||||
setError('')
|
||||
}, [open, selected, waitingForQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || waitingForQuery) return
|
||||
const controller = new AbortController()
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { dateFrom, dateTo, keyword } = readPeriod()
|
||||
const response = await fetch('/api/supply-chain/1688-dropship-data/filter-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
field,
|
||||
keyword,
|
||||
optionQuery: remoteOptionQuery,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
filters: JSON.parse(serializedFilters || '{}'),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok || !payload.success || !Array.isArray(payload.data?.values)) {
|
||||
throw new Error(payload.error?.message || '候选值加载失败')
|
||||
}
|
||||
if (!controller.signal.aborted) setValues(payload.data.values)
|
||||
} catch (reason) {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : '候选值加载失败')
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [open, field, remoteOptionQuery, readPeriod, serializedFilters, retry, waitingForQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
searchRef.current?.focus()
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
// jsdom 没有实现 Element.scrollTo,测试环境里用可选调用兜住。
|
||||
listRef.current?.scrollTo?.({ top: 0 })
|
||||
setScrollTop(0)
|
||||
}, [debouncedSearch, open])
|
||||
|
||||
function scheduleApply(nextSelection: string[]) {
|
||||
setSelection(nextSelection)
|
||||
scheduleSelection(field, nextSelection)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelection(selected)
|
||||
}, [open, selected])
|
||||
|
||||
function close() {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dropshipFilterField} ref={containerRef}>
|
||||
<span className={styles.dropshipFilterLabel}>{fieldLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dropshipFilterTrigger}
|
||||
aria-label={fieldLabel}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
disabled={saving || pending}
|
||||
data-active={Boolean(current)}
|
||||
title={current ? current.values.join('、') : `搜索并勾选${noun}`}
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
const bounds = containerRef.current?.getBoundingClientRect()
|
||||
setAlignRight(Boolean(bounds && bounds.left + POPOVER_WIDTH + 8 > window.innerWidth))
|
||||
setSelection(selected)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<span className={styles.dropshipFilterTriggerText}>{summary}</span>
|
||||
{pending || saving
|
||||
? <LoaderCircle className={styles.sourcingSpinner} size={14} />
|
||||
: <ChevronDown size={14} aria-hidden="true" />}
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className={styles.sourcingFilterPopover}
|
||||
role="dialog"
|
||||
aria-label={`${noun}选项`}
|
||||
style={alignRight ? { left: 'auto', right: 0 } : undefined}
|
||||
>
|
||||
<div className={styles.sourcingFilterSearch}>
|
||||
<Search size={14} aria-hidden="true" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={search}
|
||||
aria-label={`搜索${noun}`}
|
||||
placeholder={`搜索${noun}`}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
{search ? (
|
||||
<button type="button" aria-label="清空搜索" title="清空搜索" onClick={() => setSearch('')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.sourcingFilterActions}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || values.length === 0}
|
||||
onClick={() => scheduleApply([...new Set([...selection, ...matches])])}
|
||||
>
|
||||
全选{debouncedSearch.trim() ? '当前结果' : '全部'}
|
||||
</button>
|
||||
<button type="button" disabled={!current && selection.length === 0} onClick={() => scheduleApply([])}>
|
||||
清空
|
||||
</button>
|
||||
<span>{selection.length > 0 ? `已选 ${selection.length} 项` : '未筛选,显示全部'}</span>
|
||||
</div>
|
||||
{excludeMode ? (
|
||||
<p className={styles.dropshipFilterNotice} role="status">
|
||||
当前筛选是排除模式,勾选后会改为包含模式。
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className={styles.sourcingFilterList}
|
||||
ref={listRef}
|
||||
style={{ maxHeight: LIST_MAX_HEIGHT }}
|
||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||
>
|
||||
{loading ? (
|
||||
<p className={styles.sourcingFilterEmpty}>加载中…</p>
|
||||
) : error ? (
|
||||
<div className={styles.dropshipFilterError} role="alert">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setRetry((value) => value + 1)}>重试</button>
|
||||
</div>
|
||||
) : matches.length === 0 ? (
|
||||
<p className={styles.sourcingFilterEmpty}>
|
||||
{waitingForQuery
|
||||
? '请输入关键词搜索'
|
||||
: debouncedSearch.trim()
|
||||
? `没有匹配「${debouncedSearch.trim()}」的选项`
|
||||
: '没有可选项'}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ height: matches.length * ROW_HEIGHT, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: windowStart * ROW_HEIGHT, left: 0, right: 0 }}>
|
||||
{matches.slice(windowStart, windowStart + WINDOW_SIZE).map((value) => (
|
||||
<label key={value} className={styles.sourcingFilterOption}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(value)}
|
||||
onChange={() => scheduleApply(selectedSet.has(value)
|
||||
? selection.filter((item) => item !== value)
|
||||
: [...selection, value])}
|
||||
/>
|
||||
<span className={styles.sourcingFilterOptionBox} aria-hidden="true">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
<span className={styles.sourcingFilterOptionText} title={value}>
|
||||
{value || '(空值)'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { Dropship1688SourcingTaskDto } from '@/server/services/dropship-1688-sourcing.service'
|
||||
import { DropshipSourcingRow } from './DropshipSourcingRow'
|
||||
import { revokeDropship1688SourcingTask } from './sourcing.actions'
|
||||
|
||||
const refresh = jest.fn()
|
||||
|
||||
jest.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) }))
|
||||
jest.mock('./sourcing.actions', () => ({
|
||||
updateDropship1688SourcingTask: jest.fn(),
|
||||
revokeDropship1688SourcingTask: jest.fn(),
|
||||
}))
|
||||
|
||||
function task(overrides: Partial<Dropship1688SourcingTaskDto> = {}): Dropship1688SourcingTaskDto {
|
||||
return {
|
||||
id: 'task-1',
|
||||
sourceArchiveId: 'archive-1',
|
||||
sourceSpu: 'SPU-ONE',
|
||||
sourceBarcode: 'DF-ONE',
|
||||
sourceCostPrice: '70.0000',
|
||||
sourceSkuOperator: '运营甲',
|
||||
sourceDateFrom: '2026-09-01',
|
||||
sourceDateTo: '2026-09-10',
|
||||
purchaserUserId: 'buyer-1',
|
||||
purchaserName: '采购甲',
|
||||
supplierName: '',
|
||||
quotedPriceTaxIncluded: null,
|
||||
invoiceTaxPoint: null,
|
||||
sourcingMode: '',
|
||||
jianshangBarcode: '',
|
||||
acceptedAt: '2026-09-16T01:00:00.000Z',
|
||||
updatedAt: '2026-09-16T01:00:00.000Z',
|
||||
sku: 'SKU-ONE',
|
||||
hasImage: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(options: {
|
||||
task?: Dropship1688SourcingTaskDto
|
||||
editable?: boolean
|
||||
revocable?: boolean
|
||||
} = {}) {
|
||||
return render(
|
||||
<table>
|
||||
<tbody>
|
||||
<DropshipSourcingRow
|
||||
task={options.task ?? task()}
|
||||
editable={options.editable ?? true}
|
||||
revocable={options.revocable ?? true}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
)
|
||||
}
|
||||
|
||||
const revokeName = '撤销 DF-ONE 采购甲 的承接'
|
||||
|
||||
describe('DropshipSourcingRow revoke', () => {
|
||||
let alertSpy: jest.SpyInstance
|
||||
let confirmSpy: jest.SpyInstance
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => undefined)
|
||||
confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
alertSpy.mockRestore()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('offers revoke next to edit for an own acceptance that has no jianshang barcode', () => {
|
||||
renderRow()
|
||||
const revoke = screen.getByRole('button', { name: revokeName })
|
||||
expect(revoke).toBeEnabled()
|
||||
expect(revoke).toHaveAttribute('title', '撤销承接')
|
||||
expect(screen.getByRole('button', { name: '编辑 DF-ONE 采购甲 的寻源明细' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('disables revoke for another purchaser acceptance', () => {
|
||||
renderRow({ editable: false, revocable: false })
|
||||
const revoke = screen.getByRole('button', { name: revokeName })
|
||||
expect(revoke).toBeDisabled()
|
||||
expect(revoke).toHaveAttribute('title', '只能撤销本人承接的寻源任务')
|
||||
})
|
||||
|
||||
it('keeps an administrator edit-capable but unable to revoke another purchaser acceptance', () => {
|
||||
renderRow({ editable: true, revocable: false })
|
||||
expect(screen.getByRole('button', { name: '编辑 DF-ONE 采购甲 的寻源明细' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: revokeName })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables revoke once a jianshang barcode is filled in', () => {
|
||||
renderRow({ task: task({ jianshangBarcode: 'JS-001' }) })
|
||||
expect(screen.getByText('已启用')).toBeVisible()
|
||||
const revoke = screen.getByRole('button', { name: revokeName })
|
||||
expect(revoke).toBeDisabled()
|
||||
expect(revoke).toHaveAttribute('title', '已填写简尚条码,不能撤销')
|
||||
})
|
||||
|
||||
it('revokes the acceptance after confirmation and refreshes both views', async () => {
|
||||
jest.mocked(revokeDropship1688SourcingTask).mockResolvedValue({ ok: true })
|
||||
renderRow()
|
||||
fireEvent.click(screen.getByRole('button', { name: revokeName }))
|
||||
await waitFor(() => expect(revokeDropship1688SourcingTask).toHaveBeenCalledWith({ taskId: 'task-1' }))
|
||||
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('DF-ONE'))
|
||||
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('采购甲'))
|
||||
expect(refresh).toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: revokeName })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: revokeName })).toHaveTextContent('已撤销')
|
||||
})
|
||||
|
||||
it('does not revoke anything when the confirmation is dismissed', () => {
|
||||
confirmSpy.mockReturnValue(false)
|
||||
renderRow()
|
||||
fireEvent.click(screen.getByRole('button', { name: revokeName }))
|
||||
expect(revokeDropship1688SourcingTask).not.toHaveBeenCalled()
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a domain failure without refreshing', async () => {
|
||||
jest.mocked(revokeDropship1688SourcingTask).mockResolvedValue({
|
||||
ok: false,
|
||||
message: '该寻源任务已填写简尚条码,不能撤销',
|
||||
})
|
||||
renderRow()
|
||||
fireEvent.click(screen.getByRole('button', { name: revokeName }))
|
||||
await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('该寻源任务已填写简尚条码,不能撤销'))
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides unexpected transport failures behind a safe message', async () => {
|
||||
jest.mocked(revokeDropship1688SourcingTask).mockRejectedValue(new Error('database-secret'))
|
||||
renderRow()
|
||||
fireEvent.click(screen.getByRole('button', { name: revokeName }))
|
||||
await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('撤销失败,请稍后重试'))
|
||||
expect(alertSpy).not.toHaveBeenCalledWith('database-secret')
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,301 @@
|
||||
'use client'
|
||||
|
||||
import { LoaderCircle, Pencil, Save, Undo2, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { Dropship1688SourcingTaskDto } from '@/server/services/dropship-1688-sourcing.service'
|
||||
import { DropshipImageCell } from './DropshipImageCell'
|
||||
import { revokeDropship1688SourcingTask, updateDropship1688SourcingTask } from './sourcing.actions'
|
||||
import styles from './dropship-1688-data.module.css'
|
||||
|
||||
type SourcingDraft = {
|
||||
supplierName: string
|
||||
quotedPriceTaxIncluded: string
|
||||
invoiceTaxPoint: string
|
||||
sourcingMode: string
|
||||
jianshangBarcode: string
|
||||
}
|
||||
|
||||
const SOURCING_MODES = ['入库', '代发'] as const
|
||||
const DECIMAL_SCALE = BigInt(10_000)
|
||||
const ZERO = BigInt(0)
|
||||
const ACCEPTED_AT_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
|
||||
|
||||
function acceptedAtText(value: string) {
|
||||
const match = ACCEPTED_AT_PATTERN.exec(value)
|
||||
if (!match || Number.isNaN(Date.parse(value))) return '--'
|
||||
// acceptedAt is a PostgreSQL TIMESTAMP (without time zone). Prisma's pg adapter
|
||||
// tags that stored Shanghai wall-clock value with Z, so converting it again would add 8 hours.
|
||||
return `${match[1]}-${match[2]}-${match[3]} ${match[4]}:${match[5]}:${match[6]}`
|
||||
}
|
||||
|
||||
function decimalInputText(value: string | null) {
|
||||
if (!value) return ''
|
||||
const [whole, fraction = ''] = value.split('.')
|
||||
const compactFraction = fraction.replace(/0+$/, '')
|
||||
return compactFraction ? `${whole}.${compactFraction}` : whole
|
||||
}
|
||||
|
||||
function taskDraft(task: Dropship1688SourcingTaskDto): SourcingDraft {
|
||||
return {
|
||||
supplierName: task.supplierName,
|
||||
quotedPriceTaxIncluded: decimalInputText(task.quotedPriceTaxIncluded),
|
||||
invoiceTaxPoint: decimalInputText(task.invoiceTaxPoint),
|
||||
sourcingMode: SOURCING_MODES.includes(task.sourcingMode as typeof SOURCING_MODES[number])
|
||||
? task.sourcingMode
|
||||
: '',
|
||||
jianshangBarcode: task.jianshangBarcode,
|
||||
}
|
||||
}
|
||||
|
||||
function sameDraft(left: SourcingDraft, right: SourcingDraft) {
|
||||
return Object.keys(left).every((key) => left[key as keyof SourcingDraft] === right[key as keyof SourcingDraft])
|
||||
}
|
||||
|
||||
function decimalDifference(quotedPrice: string, sourceCostPrice: string | null) {
|
||||
if (!sourceCostPrice || !/^\d+(?:\.\d{1,4})?$/.test(quotedPrice)) return null
|
||||
const toScaledInteger = (value: string) => {
|
||||
const [whole, fraction = ''] = value.split('.')
|
||||
return BigInt(whole) * DECIMAL_SCALE + BigInt(fraction.padEnd(4, '0'))
|
||||
}
|
||||
return toScaledInteger(quotedPrice) - toScaledInteger(sourceCostPrice)
|
||||
}
|
||||
|
||||
function differenceText(value: bigint | null) {
|
||||
if (value === null) return '--'
|
||||
const sign = value > ZERO ? '+' : value < ZERO ? '-' : ''
|
||||
const absolute = value < ZERO ? -value : value
|
||||
const whole = absolute / DECIMAL_SCALE
|
||||
const fraction = (absolute % DECIMAL_SCALE).toString().padStart(4, '0').replace(/0+$/, '')
|
||||
return `${sign}¥${whole}.${fraction.padEnd(2, '0')}`
|
||||
}
|
||||
|
||||
export function DropshipSourcingRow({ task, editable, revocable, onRevoked }: {
|
||||
task: Dropship1688SourcingTaskDto
|
||||
editable: boolean
|
||||
revocable: boolean
|
||||
onRevoked?: () => void
|
||||
}) {
|
||||
const initialDraft = taskDraft(task)
|
||||
const [draft, setDraft] = useState<SourcingDraft>(initialDraft)
|
||||
const [savedDraft, setSavedDraft] = useState<SourcingDraft>(initialDraft)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [revoking, setRevoking] = useState(false)
|
||||
const [revoked, setRevoked] = useState(false)
|
||||
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||
const router = useRouter()
|
||||
const revokeSubmitting = useRef(false)
|
||||
const dirty = !sameDraft(draft, savedDraft)
|
||||
const fieldPrefix = `${task.sourceBarcode} ${task.purchaserName}`
|
||||
const draftComparison = decimalDifference(draft.quotedPriceTaxIncluded, task.sourceCostPrice)
|
||||
const savedComparison = decimalDifference(savedDraft.quotedPriceTaxIncluded, task.sourceCostPrice)
|
||||
const draftComparisonClass = draftComparison === null || draftComparison === ZERO
|
||||
? styles.sourcingComparisonNeutral
|
||||
: draftComparison > ZERO
|
||||
? styles.sourcingComparisonPositive
|
||||
: styles.sourcingComparisonNegative
|
||||
const savedComparisonClass = savedComparison === null || savedComparison === ZERO
|
||||
? styles.sourcingComparisonNeutral
|
||||
: savedComparison > ZERO
|
||||
? styles.sourcingComparisonPositive
|
||||
: styles.sourcingComparisonNegative
|
||||
const enabled = savedDraft.jianshangBarcode.trim().length > 0
|
||||
const acceptedAt = acceptedAtText(task.acceptedAt)
|
||||
const canRevoke = revocable && !enabled && !revoked
|
||||
const revokeTitle = revoked
|
||||
? '已撤销,正在刷新'
|
||||
: !revocable
|
||||
? '只能撤销本人承接的寻源任务'
|
||||
: enabled
|
||||
? '已填写简尚条码,不能撤销'
|
||||
: '撤销承接'
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !saving) closeEditor()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
function setField(field: keyof SourcingDraft, value: string) {
|
||||
setDraft((current) => ({ ...current, [field]: value }))
|
||||
setFeedback(null)
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
if (!editable) return
|
||||
setDraft(savedDraft)
|
||||
setFeedback(null)
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (saving) return
|
||||
setDraft(savedDraft)
|
||||
setFeedback(null)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editable || saving || !dirty) return
|
||||
setSaving(true)
|
||||
setFeedback(null)
|
||||
try {
|
||||
const result = await updateDropship1688SourcingTask({ taskId: task.id, ...draft })
|
||||
if (!result.ok) {
|
||||
setFeedback({ type: 'error', message: result.message })
|
||||
return
|
||||
}
|
||||
const normalized = taskDraft(result.task)
|
||||
setDraft(normalized)
|
||||
setSavedDraft(normalized)
|
||||
setFeedback({ type: 'success', message: '已保存' })
|
||||
setEditing(false)
|
||||
} catch {
|
||||
setFeedback({ type: 'error', message: '保存失败,请稍后重试' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!canRevoke || revokeSubmitting.current) return
|
||||
const confirmed = window.confirm(
|
||||
`确认撤销这次承接吗?\n代发条码:${task.sourceBarcode}\n采购:${task.purchaserName}\n`
|
||||
+ '撤销后该采购在此条码上的承接记录会被删除,已填写的供应商和报价也会一并丢失,且无法恢复。',
|
||||
)
|
||||
if (!confirmed) return
|
||||
revokeSubmitting.current = true
|
||||
setRevoking(true)
|
||||
setFeedback(null)
|
||||
try {
|
||||
const result = await revokeDropship1688SourcingTask({ taskId: task.id })
|
||||
if (!result.ok) {
|
||||
window.alert(result.message?.trim() || '撤销失败,请稍后重试')
|
||||
return
|
||||
}
|
||||
setRevoked(true)
|
||||
onRevoked?.()
|
||||
router.refresh()
|
||||
} catch {
|
||||
// The action already maps domain failures to safe messages; anything thrown here is
|
||||
// infrastructure-level, so never surface the raw error text to the user.
|
||||
window.alert('撤销失败,请稍后重试')
|
||||
} finally {
|
||||
revokeSubmitting.current = false
|
||||
setRevoking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<tr>
|
||||
<td className={styles.sourcingActionCell}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`编辑 ${fieldPrefix} 的寻源明细`}
|
||||
title={editable ? '编辑寻源明细' : '只能编辑本人承接的寻源任务'}
|
||||
disabled={!editable}
|
||||
onClick={openEditor}
|
||||
>
|
||||
<Pencil size={14} />编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sourcingRevokeButton}
|
||||
aria-label={`撤销 ${fieldPrefix} 的承接`}
|
||||
title={revokeTitle}
|
||||
disabled={!canRevoke || revoking}
|
||||
onClick={() => void revoke()}
|
||||
>
|
||||
{revoking ? <LoaderCircle className={styles.sourcingSpinner} size={14} /> : <Undo2 size={14} />}
|
||||
{revoking ? '撤销中' : revoked ? '已撤销' : '撤销'}
|
||||
</button>
|
||||
<span className={styles.sourcingSaveStatus} aria-live="polite">{feedback?.type === 'success' ? feedback.message : ''}</span>
|
||||
</td>
|
||||
<td className={styles.sourcingAcceptedAtCell}>
|
||||
<time dateTime={acceptedAt === '--' ? undefined : acceptedAt.replace(' ', 'T')}>{acceptedAt}</time>
|
||||
</td>
|
||||
<td className={styles.sourcingEnabledCell}>
|
||||
<span className={enabled ? styles.sourcingEnabled : styles.sourcingDisabled}>
|
||||
{enabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</td>
|
||||
<td className={styles.imageCell}>
|
||||
<DropshipImageCell barcode={task.sourceBarcode} sku={task.sku} hasImage={task.hasImage} />
|
||||
</td>
|
||||
<td className={styles.codeCell} title={task.sourceSpu || undefined}>{task.sourceSpu || '--'}</td>
|
||||
<td className={styles.codeCell} title={task.sourceBarcode}>{task.sourceBarcode}</td>
|
||||
<td className={styles.numberCell}>{task.sourceCostPrice ? `¥${Number(task.sourceCostPrice).toFixed(2)}` : '--'}</td>
|
||||
<td>{savedDraft.quotedPriceTaxIncluded ? `¥${savedDraft.quotedPriceTaxIncluded}` : '--'}</td>
|
||||
<td className={styles.sourcingComparisonCell}>
|
||||
<output aria-label={`${fieldPrefix} 当前对比`} className={savedComparisonClass}>{differenceText(savedComparison)}</output>
|
||||
</td>
|
||||
<td>{savedDraft.invoiceTaxPoint ? `${savedDraft.invoiceTaxPoint}%` : '--'}</td>
|
||||
<td title={task.sourceSkuOperator || undefined}>{task.sourceSkuOperator || '--'}</td>
|
||||
<td title={task.purchaserName}>{task.purchaserName}</td>
|
||||
<td title={savedDraft.supplierName || undefined}>{savedDraft.supplierName || '--'}</td>
|
||||
<td>{savedDraft.sourcingMode || '--'}</td>
|
||||
<td className={styles.codeCell} title={savedDraft.jianshangBarcode || undefined}>{savedDraft.jianshangBarcode || '--'}</td>
|
||||
</tr>
|
||||
{editing && typeof document !== 'undefined' ? createPortal(
|
||||
<div className={styles.sourcingEditorBackdrop} role="presentation" onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) closeEditor()
|
||||
}}>
|
||||
<form className={styles.sourcingEditor} role="dialog" aria-modal="true" aria-labelledby={`sourcing-editor-${task.id}`} onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void save()
|
||||
}}>
|
||||
<header className={styles.sourcingEditorHeader}>
|
||||
<div>
|
||||
<h2 id={`sourcing-editor-${task.id}`}>编辑寻源明细</h2>
|
||||
<p>{task.sourceBarcode} · 采购:{task.purchaserName} · 代发成本价:{task.sourceCostPrice ? `¥${Number(task.sourceCostPrice).toFixed(2)}` : '--'}</p>
|
||||
</div>
|
||||
<button type="button" aria-label="关闭编辑" title="关闭" disabled={saving} onClick={closeEditor}><X size={18} /></button>
|
||||
</header>
|
||||
<div className={styles.sourcingEditorGrid}>
|
||||
<label>
|
||||
<span>供应商</span>
|
||||
<input autoFocus aria-label={`${fieldPrefix} 供应商`} disabled={saving} maxLength={200} value={draft.supplierName} onChange={(event) => setField('supplierName', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>报价(含税)</span>
|
||||
<input aria-label={`${fieldPrefix} 报价(含税)`} disabled={saving} inputMode="decimal" min="0" step="0.0001" type="number" value={draft.quotedPriceTaxIncluded} onChange={(event) => setField('quotedPriceTaxIncluded', event.target.value)} />
|
||||
</label>
|
||||
<div className={styles.sourcingEditorCalculated}>
|
||||
<span>对比</span>
|
||||
<output aria-label={`${fieldPrefix} 对比`} className={draftComparisonClass}>{differenceText(draftComparison)}</output>
|
||||
</div>
|
||||
<label>
|
||||
<span>发票税点</span>
|
||||
<input aria-label={`${fieldPrefix} 发票税点`} disabled={saving} inputMode="decimal" max="100" min="0" step="0.0001" type="number" value={draft.invoiceTaxPoint} onChange={(event) => setField('invoiceTaxPoint', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>模式</span>
|
||||
<select aria-label={`${fieldPrefix} 模式`} disabled={saving} value={draft.sourcingMode} onChange={(event) => setField('sourcingMode', event.target.value)}>
|
||||
<option value="" disabled hidden>请选择</option>
|
||||
{SOURCING_MODES.map((mode) => <option key={mode} value={mode}>{mode}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>简尚条码</span>
|
||||
<input aria-label={`${fieldPrefix} 简尚条码`} disabled={saving} maxLength={100} value={draft.jianshangBarcode} onChange={(event) => setField('jianshangBarcode', event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
{feedback?.type === 'error' ? <p className={styles.sourcingEditorError} role="alert">{feedback.message}</p> : null}
|
||||
<footer className={styles.sourcingEditorFooter}>
|
||||
<button type="button" disabled={saving} onClick={closeEditor}>取消</button>
|
||||
<button type="submit" className={styles.sourcingEditorSave} disabled={saving || !dirty}>
|
||||
{saving ? <LoaderCircle className={styles.sourcingSpinner} size={15} /> : <Save size={15} />}
|
||||
{saving ? '保存中' : '保存'}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { DropshipWorkspace } from './DropshipWorkspace'
|
||||
|
||||
const routerRefresh = jest.fn()
|
||||
|
||||
jest.mock('./sourcing.actions', () => ({
|
||||
updateDropship1688SourcingTask: jest.fn(),
|
||||
revokeDropship1688SourcingTask: jest.fn(),
|
||||
}))
|
||||
jest.mock('next/navigation', () => ({ useRouter: () => ({ refresh: routerRefresh }) }))
|
||||
|
||||
function sourcingTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'task-1', sourceArchiveId: 'archive-1', sourceBarcode: 'DF-ONE',
|
||||
sourceCostPrice: '70.0000', sourceSkuOperator: '运营甲', sourceDateFrom: '2026-09-01',
|
||||
sourceDateTo: '2026-09-10', purchaserUserId: 'buyer-1', purchaserName: '采购甲',
|
||||
supplierName: '', quotedPriceTaxIncluded: null, invoiceTaxPoint: null,
|
||||
sourcingMode: '', jianshangBarcode: '',
|
||||
acceptedAt: '2026-09-16T17:23:45.000Z', updatedAt: '2026-09-16T17:23:45.000Z',
|
||||
sourceSpu: 'SPU-ONE', sku: 'SKU-ONE', hasImage: false, ...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function sourcingPage(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
rows: [sourcingTask()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
totalPages: 1,
|
||||
filterOptions: {
|
||||
purchaserNames: ['采购甲', '采购乙'],
|
||||
sourceSpus: ['SPU-ONE', 'SPU-TWO'],
|
||||
sourceSkuOperators: ['运营甲', '运营乙'],
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers(headers),
|
||||
json: async () => ({ data }),
|
||||
blob: async () => new Blob(['workbook']),
|
||||
} as Response
|
||||
}
|
||||
|
||||
function renderWorkspace(props: Partial<Parameters<typeof DropshipWorkspace>[0]> = {}) {
|
||||
return render(
|
||||
<DropshipWorkspace
|
||||
summary={<dl><dt>真实业绩</dt><dd>¥818,312.29</dd></dl>}
|
||||
actorUserId="buyer-1"
|
||||
{...props}
|
||||
>
|
||||
<label>商品检索<input defaultValue="DF-123" /></label>
|
||||
<a href="?dateFrom=2026-09-01&page=2&filterRef=saved">下一页</a>
|
||||
<div data-testid="table-scroll">商品明细</div>
|
||||
</DropshipWorkspace>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DropshipWorkspace', () => {
|
||||
const fetchMock = jest.fn()
|
||||
const originalFetch = global.fetch
|
||||
const originalCreateObjectUrl = URL.createObjectURL
|
||||
const originalRevokeObjectUrl = URL.revokeObjectURL
|
||||
|
||||
beforeAll(() => {
|
||||
global.fetch = fetchMock
|
||||
URL.createObjectURL = jest.fn(() => 'blob:workbook')
|
||||
URL.revokeObjectURL = jest.fn()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch
|
||||
URL.createObjectURL = originalCreateObjectUrl
|
||||
URL.revokeObjectURL = originalRevokeObjectUrl
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fetchMock.mockResolvedValue(jsonResponse(sourcingPage()))
|
||||
})
|
||||
|
||||
it('keeps sourcing lazy and preserves the mounted detail panel across tab switches', async () => {
|
||||
renderWorkspace()
|
||||
const details = screen.getByRole('tab', { name: '数据明细' })
|
||||
const sourcing = screen.getByRole('tab', { name: '寻源明细' })
|
||||
const input = screen.getByRole('textbox', { name: '商品检索' })
|
||||
const table = screen.getByTestId('table-scroll')
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
fireEvent.change(input, { target: { value: 'DF-456' } })
|
||||
table.scrollLeft = 300
|
||||
table.scrollTop = 200
|
||||
fireEvent.click(sourcing)
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'/api/supply-chain/1688-dropship-data/sourcing-list?page=1',
|
||||
)
|
||||
expect(await screen.findByRole('cell', { name: 'DF-ONE' })).toBeVisible()
|
||||
expect(screen.getByText('¥818,312.29')).not.toBeVisible()
|
||||
|
||||
fireEvent.click(details)
|
||||
expect(screen.getByRole('textbox', { name: '商品检索' })).toBe(input)
|
||||
expect(input).toHaveValue('DF-456')
|
||||
expect(table.scrollLeft).toBe(300)
|
||||
expect(table.scrollTop).toBe(200)
|
||||
expect(screen.getByRole('link', { name: '下一页' })).toHaveAttribute(
|
||||
'href', '?dateFrom=2026-09-01&page=2&filterRef=saved',
|
||||
)
|
||||
})
|
||||
|
||||
it('supports keyboard tab navigation and exposes the sourcing table contract', async () => {
|
||||
renderWorkspace()
|
||||
const details = screen.getByRole('tab', { name: '数据明细' })
|
||||
const sourcing = screen.getByRole('tab', { name: '寻源明细' })
|
||||
fireEvent.keyDown(details, { key: 'ArrowRight' })
|
||||
expect(sourcing).toHaveFocus()
|
||||
await screen.findByRole('cell', { name: 'DF-ONE' })
|
||||
|
||||
const table = screen.getByRole('table', { name: '寻源明细表' })
|
||||
expect(within(table).getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
|
||||
'操作', '承接日期', '是否启用', '产品图片', '代发SPU', '代发条码', '代发成本价', '报价(含税)',
|
||||
'对比', '发票税点', 'SKU运营', '采购', '供应商', '模式', '简尚条码',
|
||||
])
|
||||
expect(within(table).getByRole('cell', { name: '2026-09-16 17:23:45' })).toBeVisible()
|
||||
expect(screen.getByRole('navigation', { name: '寻源明细分页' })).toHaveTextContent('每页 50 条')
|
||||
|
||||
fireEvent.keyDown(sourcing, { key: 'Home' })
|
||||
expect(details).toHaveFocus()
|
||||
})
|
||||
|
||||
it('runs purchaser, SPU and operator filters on the server and resets to page one', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse(sourcingPage({
|
||||
rows: Array.from({ length: 50 }, (_, index) => sourcingTask({ id: `task-${index}`, sourceBarcode: `DF-${index}` })),
|
||||
total: 51,
|
||||
totalPages: 2,
|
||||
})))
|
||||
.mockResolvedValueOnce(jsonResponse(sourcingPage({
|
||||
rows: [sourcingTask({ id: 'task-2', sourceBarcode: 'DF-TWO', purchaserName: '采购乙' })],
|
||||
total: 51,
|
||||
page: 2,
|
||||
totalPages: 2,
|
||||
})))
|
||||
|
||||
renderWorkspace()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '寻源明细' }))
|
||||
await screen.findByText('51 条')
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('page=2')
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(sourcingPage({
|
||||
rows: [sourcingTask({ id: 'task-2', sourceBarcode: 'DF-TWO', purchaserName: '采购乙' })],
|
||||
total: 1,
|
||||
})))
|
||||
fireEvent.click(screen.getByRole('button', { name: '采购筛选' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '采购乙' }))
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
|
||||
const requestUrl = String(fetchMock.mock.calls[2][0])
|
||||
expect(requestUrl).toContain('page=1')
|
||||
expect(requestUrl).toContain(`purchaser=${encodeURIComponent('采购乙')}`)
|
||||
expect(await screen.findByRole('cell', { name: 'DF-TWO' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('exports all matching rows by filters instead of sending current-page task IDs', async () => {
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/sourcing-export')) {
|
||||
return jsonResponse(null, { 'X-Exported-Rows': '75' })
|
||||
}
|
||||
return jsonResponse(sourcingPage({ total: 75, totalPages: 2 }))
|
||||
})
|
||||
const anchorClick = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
renderWorkspace({ canExportSourcing: true })
|
||||
fireEvent.click(screen.getByRole('tab', { name: '寻源明细' }))
|
||||
await screen.findByText('75 条')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '采购筛选' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '采购乙' }))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||
const exportButton = screen.getByRole('button', { name: '导出模板' })
|
||||
await waitFor(() => expect(exportButton).toBeEnabled())
|
||||
fireEvent.click(exportButton)
|
||||
|
||||
await screen.findByText('已导出 75 条寻源数据')
|
||||
const exportCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/sourcing-export'))
|
||||
expect(exportCall?.[1]).toMatchObject({ method: 'POST' })
|
||||
expect(JSON.parse(String((exportCall?.[1] as RequestInit).body))).toEqual({
|
||||
filters: {
|
||||
purchaserNames: ['采购乙'],
|
||||
sourceSpus: [],
|
||||
sourceSkuOperators: [],
|
||||
},
|
||||
})
|
||||
expect(anchorClick).toHaveBeenCalled()
|
||||
anchorClick.mockRestore()
|
||||
})
|
||||
|
||||
it('reloads the active server page after importing a workbook', async () => {
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/sourcing-import')) return jsonResponse({ updated: 2 })
|
||||
return jsonResponse(sourcingPage())
|
||||
})
|
||||
const { container } = renderWorkspace({ canImportSourcing: true })
|
||||
fireEvent.click(screen.getByRole('tab', { name: '寻源明细' }))
|
||||
await screen.findByRole('cell', { name: 'DF-ONE' })
|
||||
|
||||
const file = new File(['xlsx'], '寻源明细.xlsx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
})
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
await screen.findByText('导入完成,已更新 2 条寻源数据')
|
||||
await waitFor(() => {
|
||||
const listCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes('/sourcing-list?'))
|
||||
expect(listCalls).toHaveLength(2)
|
||||
})
|
||||
expect(routerRefresh).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('derives row edit and revoke capabilities from the actor instead of an RSC task-id list', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(sourcingPage({
|
||||
rows: [
|
||||
sourcingTask(),
|
||||
sourcingTask({ id: 'task-2', purchaserUserId: 'buyer-2', purchaserName: '采购乙' }),
|
||||
],
|
||||
total: 2,
|
||||
})))
|
||||
renderWorkspace({ canEditSourcing: true })
|
||||
fireEvent.click(screen.getByRole('tab', { name: '寻源明细' }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: '编辑 DF-ONE 采购甲 的寻源明细' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '撤销 DF-ONE 采购甲 的承接' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '编辑 DF-ONE 采购乙 的寻源明细' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '撤销 DF-ONE 采购乙 的承接' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,408 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowLeft, ArrowRight, Download, LoaderCircle, RefreshCw, Upload } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import type { Dropship1688SourcingPageDto } from '@/server/services/dropship-1688-sourcing.service'
|
||||
import { DropshipSourcingRow } from './DropshipSourcingRow'
|
||||
import { SourcingFilterSelect } from './SourcingFilterSelect'
|
||||
import styles from './dropship-1688-data.module.css'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'details', label: '数据明细' },
|
||||
{ key: 'sourcing', label: '寻源明细' },
|
||||
] as const
|
||||
|
||||
const SOURCING_COLUMNS = [
|
||||
{ key: 'actions', label: '操作', width: 176 },
|
||||
{ key: 'acceptedAt', label: '承接日期', width: 168 },
|
||||
{ key: 'enabled', label: '是否启用', width: 110 },
|
||||
{ key: 'image', label: '产品图片', width: 150 },
|
||||
{ key: 'sourceSpu', label: '代发SPU', width: 160 },
|
||||
{ key: 'sourceBarcode', label: '代发条码', width: 170 },
|
||||
{ key: 'sourceCostPrice', label: '代发成本价', width: 150 },
|
||||
{ key: 'quotedPriceTaxIncluded', label: '报价(含税)', width: 150 },
|
||||
{ key: 'comparison', label: '对比', width: 160 },
|
||||
{ key: 'invoiceTaxPoint', label: '发票税点', width: 130 },
|
||||
{ key: 'sourceSkuOperator', label: 'SKU运营', width: 160 },
|
||||
{ key: 'purchaserName', label: '采购', width: 150 },
|
||||
{ key: 'supplierName', label: '供应商', width: 180 },
|
||||
{ key: 'sourcingMode', label: '模式', width: 130 },
|
||||
{ key: 'jianshangBarcode', label: '简尚条码', width: 170 },
|
||||
] as const
|
||||
|
||||
type View = typeof TABS[number]['key']
|
||||
|
||||
async function responseMessage(response: Response, fallback: string) {
|
||||
try {
|
||||
const payload = await response.json() as { error?: { message?: string } }
|
||||
return payload.error?.message || fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function appendValues(params: URLSearchParams, key: string, values: readonly string[]) {
|
||||
values.forEach((value) => params.append(key, value))
|
||||
}
|
||||
|
||||
export function DropshipWorkspace({
|
||||
summary,
|
||||
actorUserId,
|
||||
canEditAllSourcing = false,
|
||||
canEditSourcing = false,
|
||||
canExportSourcing = false,
|
||||
canImportSourcing = false,
|
||||
children,
|
||||
}: {
|
||||
summary: ReactNode
|
||||
actorUserId: string
|
||||
canEditAllSourcing?: boolean
|
||||
canEditSourcing?: boolean
|
||||
canExportSourcing?: boolean
|
||||
canImportSourcing?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [view, setView] = useState<View>('details')
|
||||
const [purchaserFilter, setPurchaserFilter] = useState<string[]>([])
|
||||
const [spuFilter, setSpuFilter] = useState<string[]>([])
|
||||
const [operatorFilter, setOperatorFilter] = useState<string[]>([])
|
||||
const [sourcingPage, setSourcingPage] = useState<Dropship1688SourcingPageDto | null>(null)
|
||||
const [sourcingPageNumber, setSourcingPageNumber] = useState(1)
|
||||
const [sourcingLoading, setSourcingLoading] = useState(false)
|
||||
const [sourcingError, setSourcingError] = useState('')
|
||||
const [sourcingRevision, setSourcingRevision] = useState(0)
|
||||
const [transferring, setTransferring] = useState<'export' | 'import' | null>(null)
|
||||
const [transferFeedback, setTransferFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||
const id = useId()
|
||||
const buttons = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const router = useRouter()
|
||||
const rows = sourcingPage?.rows ?? []
|
||||
const total = sourcingPage?.total ?? 0
|
||||
const currentPage = sourcingPage?.page ?? sourcingPageNumber
|
||||
const totalPages = sourcingPage?.totalPages ?? 1
|
||||
const hasSourcingFilters = Boolean(
|
||||
purchaserFilter.length || spuFilter.length || operatorFilter.length,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== 'sourcing') return
|
||||
|
||||
const controller = new AbortController()
|
||||
const params = new URLSearchParams({ page: String(sourcingPageNumber) })
|
||||
appendValues(params, 'purchaser', purchaserFilter)
|
||||
appendValues(params, 'spu', spuFilter)
|
||||
appendValues(params, 'operator', operatorFilter)
|
||||
|
||||
setSourcingLoading(true)
|
||||
setSourcingError('')
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/supply-chain/1688-dropship-data/sourcing-list?${params.toString()}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseMessage(response, '寻源明细加载失败,请稍后重试'))
|
||||
}
|
||||
const payload = await response.json() as { data?: Dropship1688SourcingPageDto }
|
||||
if (!payload.data || !Array.isArray(payload.data.rows)) {
|
||||
throw new Error('寻源明细返回格式不正确')
|
||||
}
|
||||
if (controller.signal.aborted) return
|
||||
setSourcingPage(payload.data)
|
||||
if (payload.data.page !== sourcingPageNumber) {
|
||||
setSourcingPageNumber(payload.data.page)
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
setSourcingError(error instanceof Error ? error.message : '寻源明细加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setSourcingLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => controller.abort()
|
||||
}, [operatorFilter, purchaserFilter, sourcingPageNumber, sourcingRevision, spuFilter, view])
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
let next: number
|
||||
switch (event.key) {
|
||||
case 'ArrowRight': next = (index + 1) % TABS.length; break
|
||||
case 'ArrowLeft': next = (index + TABS.length - 1) % TABS.length; break
|
||||
case 'Home': next = 0; break
|
||||
case 'End': next = TABS.length - 1; break
|
||||
default: return
|
||||
}
|
||||
event.preventDefault()
|
||||
setView(TABS[next].key)
|
||||
buttons.current[next]?.focus()
|
||||
}
|
||||
|
||||
function resetSourcingFilters() {
|
||||
setPurchaserFilter([])
|
||||
setSpuFilter([])
|
||||
setOperatorFilter([])
|
||||
setSourcingPageNumber(1)
|
||||
setTransferFeedback(null)
|
||||
}
|
||||
|
||||
function changeSourcingFilter(setter: (values: string[]) => void, values: string[]) {
|
||||
setter(values)
|
||||
setSourcingPageNumber(1)
|
||||
setTransferFeedback(null)
|
||||
}
|
||||
|
||||
function reloadSourcing() {
|
||||
setSourcingRevision((revision) => revision + 1)
|
||||
}
|
||||
|
||||
async function exportSourcingTasks() {
|
||||
if (total === 0 || transferring) return
|
||||
setTransferring('export')
|
||||
setTransferFeedback(null)
|
||||
try {
|
||||
const response = await fetch('/api/supply-chain/1688-dropship-data/sourcing-export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filters: {
|
||||
purchaserNames: purchaserFilter,
|
||||
sourceSpus: spuFilter,
|
||||
sourceSkuOperators: operatorFilter,
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(await responseMessage(response, '导出失败,请稍后重试'))
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `1688代发寻源明细-${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
const exportedRows = Number.parseInt(response.headers.get('X-Exported-Rows') ?? '', 10)
|
||||
setTransferFeedback({
|
||||
type: 'success',
|
||||
message: `已导出 ${Number.isSafeInteger(exportedRows) ? exportedRows : total} 条寻源数据`,
|
||||
})
|
||||
} catch (error) {
|
||||
setTransferFeedback({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : '导出失败,请稍后重试',
|
||||
})
|
||||
} finally {
|
||||
setTransferring(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function importSourcingTasks(file: File) {
|
||||
if (transferring) return
|
||||
setTransferring('import')
|
||||
setTransferFeedback(null)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.set('file', file)
|
||||
const response = await fetch('/api/supply-chain/1688-dropship-data/sourcing-import', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (!response.ok) throw new Error(await responseMessage(response, '导入失败,请检查文件后重试'))
|
||||
const payload = await response.json() as { data?: { updated?: number } }
|
||||
const updated = payload.data?.updated ?? 0
|
||||
setTransferFeedback({ type: 'success', message: `导入完成,已更新 ${updated} 条寻源数据` })
|
||||
reloadSourcing()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
setTransferFeedback({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : '导入失败,请检查文件后重试',
|
||||
})
|
||||
} finally {
|
||||
setTransferring(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.workspaceNavigation}>
|
||||
<div className={styles.workspaceTabs} role="tablist" aria-label="1688代发功能">
|
||||
{TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
ref={(element) => { buttons.current[index] = element }}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${id}-${tab.key}-tab`}
|
||||
aria-controls={`${id}-${tab.key}-panel`}
|
||||
aria-selected={view === tab.key}
|
||||
tabIndex={view === tab.key ? 0 : -1}
|
||||
className={view === tab.key ? styles.detailTabActive : styles.detailTab}
|
||||
onClick={() => setView(tab.key)}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
id={`${id}-details-panel`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${id}-details-tab`}
|
||||
className={styles.workspacePanel}
|
||||
hidden={view !== 'details'}
|
||||
>
|
||||
<header className={styles.toolbar}>
|
||||
{summary}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
<section
|
||||
id={`${id}-sourcing-panel`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${id}-sourcing-tab`}
|
||||
className={styles.workspacePanel}
|
||||
hidden={view !== 'sourcing'}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={styles.sourcingToolbar}>
|
||||
<div className={styles.sourcingFilters} aria-label="寻源明细筛选">
|
||||
<SourcingFilterSelect
|
||||
noun="采购"
|
||||
allLabel="全部采购"
|
||||
options={sourcingPage?.filterOptions.purchaserNames ?? []}
|
||||
selected={purchaserFilter}
|
||||
onChange={(values) => changeSourcingFilter(setPurchaserFilter, values)}
|
||||
/>
|
||||
<SourcingFilterSelect
|
||||
noun="SPU"
|
||||
allLabel="全部SPU"
|
||||
options={sourcingPage?.filterOptions.sourceSpus ?? []}
|
||||
selected={spuFilter}
|
||||
onChange={(values) => changeSourcingFilter(setSpuFilter, values)}
|
||||
/>
|
||||
<SourcingFilterSelect
|
||||
noun="运营"
|
||||
allLabel="全部运营"
|
||||
options={sourcingPage?.filterOptions.sourceSkuOperators ?? []}
|
||||
selected={operatorFilter}
|
||||
onChange={(values) => changeSourcingFilter(setOperatorFilter, values)}
|
||||
/>
|
||||
<button type="button" className={styles.sourcingResetButton} disabled={!hasSourcingFilters} onClick={resetSourcingFilters}>
|
||||
<RefreshCw size={14} />重置
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.sourcingTransferActions}>
|
||||
<span>{total} 条</span>
|
||||
{canExportSourcing ? (
|
||||
<button type="button" disabled={Boolean(transferring) || sourcingLoading || Boolean(sourcingError) || total === 0} onClick={() => void exportSourcingTasks()}>
|
||||
{transferring === 'export' ? <LoaderCircle className={styles.sourcingSpinner} size={15} /> : <Download size={15} />}
|
||||
{transferring === 'export' ? '导出中' : '导出模板'}
|
||||
</button>
|
||||
) : null}
|
||||
{canImportSourcing ? (
|
||||
<>
|
||||
<button type="button" disabled={Boolean(transferring)} onClick={() => fileInput.current?.click()}>
|
||||
{transferring === 'import' ? <LoaderCircle className={styles.sourcingSpinner} size={15} /> : <Upload size={15} />}
|
||||
{transferring === 'import' ? '导入中' : '导入'}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
hidden
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) void importSourcingTasks(file)
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{transferFeedback ? (
|
||||
<p className={transferFeedback.type === 'error' ? styles.sourcingTransferError : styles.sourcingTransferSuccess} role={transferFeedback.type === 'error' ? 'alert' : 'status'}>
|
||||
{transferFeedback.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<section className={styles.sourcingTableRegion} aria-label="寻源明细表区域" aria-busy={sourcingLoading}>
|
||||
<div className={styles.sourcingTableScroll} tabIndex={0} aria-label="寻源明细横向滚动区域">
|
||||
<table className={`${styles.table} ${styles.sourcingTable}`} aria-label="寻源明细表">
|
||||
<colgroup>
|
||||
{SOURCING_COLUMNS.map((column) => (
|
||||
<col key={column.key} style={{ width: column.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{SOURCING_COLUMNS.map((column) => (
|
||||
<th key={column.key} scope="col" className={column.key === 'actions' ? styles.sourcingActionHeader : undefined}>{column.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sourcingLoading ? (
|
||||
<tr>
|
||||
<td className={styles.sourcingEmpty} colSpan={SOURCING_COLUMNS.length}>
|
||||
<span className={styles.sourcingLoading}><LoaderCircle className={styles.sourcingSpinner} size={16} />正在加载寻源数据</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : sourcingError ? (
|
||||
<tr>
|
||||
<td className={styles.sourcingEmpty} colSpan={SOURCING_COLUMNS.length}>
|
||||
<div className={styles.sourcingLoadError} role="alert">
|
||||
<span>{sourcingError}</span>
|
||||
<button type="button" onClick={reloadSourcing}>重新加载</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td className={styles.sourcingEmpty} colSpan={SOURCING_COLUMNS.length}>
|
||||
{hasSourcingFilters ? '当前筛选条件下没有寻源数据' : '暂无寻源数据'}
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.map((task) => (
|
||||
<DropshipSourcingRow
|
||||
key={`${task.id}:${task.updatedAt}`}
|
||||
task={task}
|
||||
editable={canEditAllSourcing || (canEditSourcing && task.purchaserUserId === actorUserId)}
|
||||
revocable={canEditSourcing && task.purchaserUserId === actorUserId}
|
||||
onRevoked={reloadSourcing}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{!sourcingLoading && !sourcingError ? (
|
||||
<nav aria-label="寻源明细分页" className={styles.pagination}>
|
||||
<span>共 {total} 条</span>
|
||||
<button
|
||||
type="button"
|
||||
className={currentPage <= 1 ? styles.pageButtonDisabled : styles.pageButton}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setSourcingPageNumber(Math.max(1, currentPage - 1))}
|
||||
>
|
||||
<ArrowLeft size={13} />上一页
|
||||
</button>
|
||||
<strong>{currentPage}</strong>
|
||||
<span>/ {totalPages} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
className={currentPage >= totalPages ? styles.pageButtonDisabled : styles.pageButton}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setSourcingPageNumber(Math.min(totalPages, currentPage + 1))}
|
||||
>
|
||||
下一页<ArrowRight size={13} />
|
||||
</button>
|
||||
<span>每页 {sourcingPage?.pageSize ?? 50} 条</span>
|
||||
</nav>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { SourcingFilterSelect } from './SourcingFilterSelect'
|
||||
|
||||
function Harness({ options }: { options: readonly string[] }) {
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
return (
|
||||
<SourcingFilterSelect
|
||||
noun="采购"
|
||||
allLabel="全部采购"
|
||||
options={options}
|
||||
selected={selected}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function openList(options: readonly string[] = ['采购甲', '采购乙', '采购丙']) {
|
||||
render(<Harness options={options} />)
|
||||
const trigger = screen.getByRole('button', { name: '采购筛选' })
|
||||
fireEvent.click(trigger)
|
||||
return trigger
|
||||
}
|
||||
|
||||
describe('SourcingFilterSelect', () => {
|
||||
it('summarizes the current selection on the trigger', () => {
|
||||
const trigger = openList()
|
||||
expect(trigger).toHaveTextContent('全部采购')
|
||||
expect(trigger).toHaveAttribute('data-active', 'false')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '采购甲' }))
|
||||
expect(trigger).toHaveTextContent('采购甲')
|
||||
expect(trigger).toHaveAttribute('data-active', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '采购乙' }))
|
||||
expect(trigger).toHaveTextContent('已选 2 项')
|
||||
})
|
||||
|
||||
it('lists every option and toggles a value off again', () => {
|
||||
openList(['采购甲', '采购乙'])
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(2)
|
||||
|
||||
const option = screen.getByRole('checkbox', { name: '采购甲' })
|
||||
fireEvent.click(option)
|
||||
expect(option).toBeChecked()
|
||||
fireEvent.click(option)
|
||||
expect(option).not.toBeChecked()
|
||||
expect(screen.getByRole('button', { name: '采购筛选' })).toHaveTextContent('全部采购')
|
||||
})
|
||||
|
||||
it('searches the option list and reports when nothing matches', () => {
|
||||
openList(['采购甲', '采购乙', '运营丙'])
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: '搜索采购' }), {
|
||||
target: { value: '乙' },
|
||||
})
|
||||
expect(screen.getByRole('checkbox', { name: '采购乙' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('checkbox', { name: '采购甲' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: '搜索采购' }), {
|
||||
target: { value: '不存在' },
|
||||
})
|
||||
expect(screen.getByText('没有匹配「不存在」的选项')).toBeVisible()
|
||||
})
|
||||
|
||||
it('checks only the currently matching options when using bulk select', () => {
|
||||
const trigger = openList(['采购甲', '采购乙', '采购丙'])
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: '搜索采购' }), {
|
||||
target: { value: '采购' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '全选当前结果' }))
|
||||
expect(trigger).toHaveTextContent('已选 3 项')
|
||||
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: '搜索采购' }), {
|
||||
target: { value: '丙' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空' }))
|
||||
expect(trigger).toHaveTextContent('全部采购')
|
||||
})
|
||||
|
||||
it('disables bulk select when every matching option is already checked', () => {
|
||||
openList(['采购甲'])
|
||||
const selectAll = screen.getByRole('button', { name: '全选全部' })
|
||||
expect(selectAll).toBeEnabled()
|
||||
fireEvent.click(selectAll)
|
||||
expect(selectAll).toBeDisabled()
|
||||
})
|
||||
|
||||
it('closes on Escape and on an outside click', () => {
|
||||
openList()
|
||||
expect(screen.getByRole('dialog', { name: '采购选项' })).toBeVisible()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '采购筛选' }))
|
||||
expect(screen.getByRole('dialog')).toBeVisible()
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tells the user when there is nothing to choose from', () => {
|
||||
openList([])
|
||||
expect(screen.getByText('暂无可选项')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { Check, ChevronDown, Search, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styles from './dropship-1688-data.module.css'
|
||||
|
||||
const LIST_MAX_HEIGHT = 232
|
||||
const POPOVER_WIDTH = 296
|
||||
|
||||
/**
|
||||
* 寻源明细的工具条筛选项。
|
||||
* 选项数量会随承接记录增长,因此这里不使用原生 select,而是带搜索的可多选勾选列表。
|
||||
* 约定:selected 为空数组表示「全部」,与工具条原有的「全部采购 / 全部SPU / 全部运营」语义一致。
|
||||
*/
|
||||
export function SourcingFilterSelect({
|
||||
noun,
|
||||
allLabel,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
/** 字段名,例如「采购」,用于拼出「采购筛选」「搜索采购」「采购选项」。 */
|
||||
noun: string
|
||||
/** 未筛选时触发器显示的文案,例如「全部采购」。 */
|
||||
allLabel: string
|
||||
options: readonly string[]
|
||||
selected: readonly string[]
|
||||
onChange: (next: string[]) => void
|
||||
}) {
|
||||
const fieldLabel = `${noun}筛选`
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
// 触发器靠屏幕右缘时,弹层改为向左展开,避免溢出视口。
|
||||
const [alignRight, setAlignRight] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected])
|
||||
const matching = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase()
|
||||
if (!query) return options
|
||||
return options.filter((option) => option.toLocaleLowerCase().includes(query))
|
||||
}, [options, search])
|
||||
const matchingChecked = matching.reduce((count, option) => count + Number(selectedSet.has(option)), 0)
|
||||
const allMatchingChecked = matching.length > 0 && matchingChecked === matching.length
|
||||
|
||||
const summary = selected.length === 0
|
||||
? allLabel
|
||||
: selected.length === 1
|
||||
? selected[0]
|
||||
: `已选 ${selected.length} 项`
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
searchRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function close() {
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
function toggle(option: string) {
|
||||
onChange(selectedSet.has(option)
|
||||
? selected.filter((value) => value !== option)
|
||||
: [...selected, option])
|
||||
}
|
||||
|
||||
function checkAllMatching() {
|
||||
onChange([...new Set([...selected, ...matching])])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.sourcingFilterField} ref={containerRef}>
|
||||
<span className={styles.sourcingFilterLabel}>{fieldLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sourcingFilterTrigger}
|
||||
aria-label={fieldLabel}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
data-active={selected.length > 0}
|
||||
title={selected.length > 0 ? selected.join('、') : allLabel}
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
const bounds = containerRef.current?.getBoundingClientRect()
|
||||
setAlignRight(Boolean(bounds && bounds.left + POPOVER_WIDTH + 8 > window.innerWidth))
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<span className={styles.sourcingFilterTriggerText}>{summary}</span>
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className={styles.sourcingFilterPopover}
|
||||
role="dialog"
|
||||
aria-label={`${noun}选项`}
|
||||
style={alignRight ? { left: 'auto', right: 0 } : undefined}
|
||||
>
|
||||
<div className={styles.sourcingFilterSearch}>
|
||||
<Search size={14} aria-hidden="true" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={search}
|
||||
aria-label={`搜索${noun}`}
|
||||
placeholder={`搜索${noun}`}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
{search ? (
|
||||
<button type="button" aria-label="清空搜索" title="清空搜索" onClick={() => setSearch('')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.sourcingFilterActions}>
|
||||
<button type="button" disabled={matching.length === 0 || allMatchingChecked} onClick={checkAllMatching}>
|
||||
全选{search.trim() ? '当前结果' : '全部'}
|
||||
</button>
|
||||
<button type="button" disabled={selected.length === 0} onClick={() => onChange([])}>清空</button>
|
||||
<span>{selected.length > 0 ? `已选 ${selected.length} 项` : '未筛选,显示全部'}</span>
|
||||
</div>
|
||||
<div className={styles.sourcingFilterList} style={{ maxHeight: LIST_MAX_HEIGHT }}>
|
||||
{options.length === 0 ? (
|
||||
<p className={styles.sourcingFilterEmpty}>暂无可选项</p>
|
||||
) : matching.length === 0 ? (
|
||||
<p className={styles.sourcingFilterEmpty}>没有匹配「{search.trim()}」的选项</p>
|
||||
) : matching.map((option) => {
|
||||
const checked = selectedSet.has(option)
|
||||
return (
|
||||
<label key={option} className={styles.sourcingFilterOption}>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(option)} />
|
||||
<span className={styles.sourcingFilterOptionBox} aria-hidden="true">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
<span className={styles.sourcingFilterOptionText} title={option}>{option}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,27 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import Dropship1688DataPage from './page'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { dropship1688DataService } from '@/server/services/dropship-1688-data.service'
|
||||
import { dropship1688FilterStateService } from '@/server/services/dropship-1688-filter-state.service'
|
||||
import { dropship1688SourcingService } from '@/server/services/dropship-1688-sourcing.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
|
||||
jest.mock('@/lib/server-session', () => ({ requireSession: jest.fn(async () => ({ authUser: { id: 'local-user' } })) }))
|
||||
jest.mock('next/navigation', () => ({
|
||||
redirect: jest.fn(),
|
||||
useRouter: () => ({ refresh: jest.fn() }),
|
||||
}))
|
||||
jest.mock('@/lib/server-session', () => ({ requireSession: jest.fn() }))
|
||||
jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
|
||||
supplyChainPermissionSettingsService: { getUserAccess: jest.fn(async () => ({ view: true })) },
|
||||
supplyChainPermissionSettingsService: { getUserAccess: jest.fn() },
|
||||
}))
|
||||
jest.mock('@/server/services/dropship-1688-data.service', () => ({ dropship1688DataService: { listPage: jest.fn() } }))
|
||||
jest.mock('@/server/services/dropship-1688-filter-state.service', () => ({
|
||||
dropship1688FilterStateService: { load: jest.fn() },
|
||||
}))
|
||||
jest.mock('@/server/services/dropship-1688-sourcing.service', () => ({
|
||||
dropship1688SourcingService: { listPurchasersByBarcodes: jest.fn() },
|
||||
}))
|
||||
jest.mock('./SalesTrendPopover', () => ({ SalesTrendPopover: () => null }))
|
||||
jest.mock('./DropshipHeaderFilter', () => ({ DropshipHeaderFilter: () => null }))
|
||||
|
||||
const filters = { sku: { mode: 'include' as const, values: ['SKU-1', 'SKU-2'] } }
|
||||
const filterRef = 'c'.repeat(64)
|
||||
@ -20,15 +29,94 @@ const filterRef = 'c'.repeat(64)
|
||||
describe('1688 dropship page filter state', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.mocked(requireSession).mockResolvedValue({
|
||||
authUser: {
|
||||
id: 'local-user', email: 'viewer@example.com', name: '查看人', role: 'USER',
|
||||
status: 'ACTIVE', businessRoles: [],
|
||||
},
|
||||
} as never)
|
||||
jest.mocked(supplyChainPermissionSettingsService.getUserAccess).mockResolvedValue({
|
||||
view: true, edit: true, download: true,
|
||||
})
|
||||
jest.mocked(dropship1688FilterStateService.load).mockResolvedValue(filters)
|
||||
jest.mocked(dropship1688SourcingService.listPurchasersByBarcodes).mockResolvedValue({})
|
||||
jest.mocked(dropship1688DataService.listPage).mockResolvedValue({
|
||||
rows: [], total: 100, page: 1, pageSize: 50, totalPages: 2,
|
||||
dateFrom: '2026-09-01', dateTo: '2026-09-07', keyword: '', filters,
|
||||
dataUpdatedThrough: '2026-09-07',
|
||||
summary: { shipQty: '0', salesAmount: '0', realPerformance: '0' },
|
||||
summary: {
|
||||
shipQty: '0', salesAmount: '0', realPerformance: '0', grossProfit: '0', afterSaleCostRate: '',
|
||||
totalProducts: '100', productsWithSalesAmount: '70',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('shows seven summary metrics in order without the redundant update metadata', async () => {
|
||||
const listPage = jest.mocked(dropship1688DataService.listPage)
|
||||
const emptyPage = await listPage({})
|
||||
listPage.mockResolvedValue({
|
||||
...emptyPage,
|
||||
summary: {
|
||||
salesAmount: '1234.5', realPerformance: '1000', shipQty: '12345',
|
||||
grossProfit: '125.5', afterSaleCostRate: '0.025',
|
||||
totalProducts: '100', productsWithSalesAmount: '70',
|
||||
},
|
||||
})
|
||||
render(await Dropship1688DataPage({}))
|
||||
const panel = screen.getByRole('tabpanel', { name: '数据明细' })
|
||||
const summary = panel.querySelector('dl')!
|
||||
expect(within(summary).getAllByRole('term').map((term) => term.textContent)).toEqual([
|
||||
'销售金额', '真实业绩', '发货件数', '毛利额', '售后成本率', '总商品数', '有销售金额商品数',
|
||||
])
|
||||
expect(within(summary).getAllByRole('definition').map((value) => value.textContent)).toEqual([
|
||||
'¥1,234.50', '¥1,000.00', '12,345', '¥125.50', '2.50%', '100', '70',
|
||||
])
|
||||
expect(within(panel).queryByText('数据更新至 2026-09-07')).not.toBeInTheDocument()
|
||||
expect(within(panel).queryByText('当前共 100 个商品')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a dash for an undefined summary cost rate', async () => {
|
||||
render(await Dropship1688DataPage({}))
|
||||
expect(screen.getByText('售后成本率', { selector: 'dt' }).nextElementSibling).toHaveTextContent('--')
|
||||
})
|
||||
|
||||
it('includes the accepting purchaser column in the empty table span', async () => {
|
||||
render(await Dropship1688DataPage({}))
|
||||
const table = screen.getByRole('table')
|
||||
const headers = within(table).getAllByRole('columnheader')
|
||||
expect(headers.slice(2, 5).map((header) => header.textContent)).toEqual(['销售趋势', '承接采购', '成本价'])
|
||||
expect(within(table).getByRole('cell', { name: '当前筛选条件下没有商品' })).toHaveAttribute('colspan', String(headers.length))
|
||||
})
|
||||
|
||||
it('uses sourcing-style toolbar filters in the requested order without table-header filters', async () => {
|
||||
const legacyFilters = {
|
||||
...filters,
|
||||
barcode: { mode: 'include' as const, values: ['DF-A'] },
|
||||
}
|
||||
render(await Dropship1688DataPage({ searchParams: Promise.resolve({
|
||||
keyword: 'DF-A',
|
||||
columnFilters: JSON.stringify(legacyFilters),
|
||||
}) }))
|
||||
|
||||
const form = screen.getByRole('form', { name: '数据明细筛选' })
|
||||
const filterTriggers = within(form).getAllByRole('button')
|
||||
.filter((button) => button.getAttribute('aria-haspopup') === 'dialog')
|
||||
expect(filterTriggers.map((button) => button.getAttribute('aria-label'))).toEqual([
|
||||
'采购筛选', '运营筛选', 'SPU筛选', 'SKU筛选',
|
||||
])
|
||||
expect(within(form).queryByRole('button', { name: '条码筛选' })).not.toBeInTheDocument()
|
||||
expect(within(form).queryByRole('searchbox', { name: '商品检索' })).not.toBeInTheDocument()
|
||||
expect(within(form).queryByRole('button', { name: '查询' })).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('table')).queryByRole('button', { name: /筛选/ })).not.toBeInTheDocument()
|
||||
expect(dropship1688DataService.listPage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
keyword: '',
|
||||
filters,
|
||||
}))
|
||||
const next = new URL(screen.getByRole('link', { name: '下一页' }).getAttribute('href')!, 'http://localhost')
|
||||
expect(next.searchParams.has('keyword')).toBe(false)
|
||||
expect(next.searchParams.get('columnFilters')).toBe(JSON.stringify(filters))
|
||||
})
|
||||
|
||||
it('loads user-scoped saved filters and preserves the short reference across form and pagination', async () => {
|
||||
render(await Dropship1688DataPage({ searchParams: Promise.resolve({ filterRef }) }))
|
||||
expect(dropship1688FilterStateService.load).toHaveBeenCalledWith('local-user', filterRef)
|
||||
@ -67,7 +155,7 @@ describe('1688 dropship page filter state', () => {
|
||||
rows: [{
|
||||
id: 'df-example', barcode: 'DF-EXAMPLE/01', spu: 'DF-SPU', sku: 'DF-SKU',
|
||||
image: 'https://images.example.com/product.png', color: '', skuOperator: '',
|
||||
costPrice: '', shipQty: '', salesAmount: '', afterSaleAmount: '', realPerformance: '',
|
||||
costPrice: '70', shipQty: '', salesAmount: '', afterSaleAmount: '', realPerformance: '',
|
||||
afterSaleCostRate: '', grossProfit: '', grossMargin: '', trend: [], comparisonTrend: [],
|
||||
}],
|
||||
})
|
||||
@ -75,5 +163,100 @@ describe('1688 dropship page filter state', () => {
|
||||
expect(screen.getByRole('img', { name: 'DF-SKU' })).toHaveAttribute(
|
||||
'src', '/api/supply-chain/1688-dropship-data/source-image?barcode=DF-EXAMPLE%2F01',
|
||||
)
|
||||
const table = screen.getByRole('table')
|
||||
const headers = within(table).getAllByRole('columnheader')
|
||||
const cells = within(table).getAllByRole('cell')
|
||||
const purchasingIndex = headers.findIndex((header) => header.textContent === '承接采购')
|
||||
expect(cells).toHaveLength(headers.length)
|
||||
const actions = within(cells[purchasingIndex - 1]).getByRole('group', { name: 'DF-SKU 承接采购操作' })
|
||||
expect(within(actions).getAllByRole('button').map((button) => button.textContent)).toEqual(['承接 0/3'])
|
||||
expect(within(actions).getByRole('button', { name: '承接 0/3' })).toBeDisabled()
|
||||
expect(cells[purchasingIndex]).toHaveTextContent('--')
|
||||
expect(cells[purchasingIndex + 1]).toHaveTextContent('¥70.00')
|
||||
})
|
||||
|
||||
it('shows every purchaser and marks only the current purchaser as accepted', async () => {
|
||||
jest.mocked(requireSession).mockResolvedValue({
|
||||
authUser: {
|
||||
id: 'buyer-1', email: 'buyer@example.com', name: '采购甲', role: 'USER',
|
||||
status: 'ACTIVE', businessRoles: ['PURCHASING'],
|
||||
},
|
||||
} as never)
|
||||
const emptyPage = await jest.mocked(dropship1688DataService.listPage)({})
|
||||
jest.mocked(dropship1688DataService.listPage).mockResolvedValue({
|
||||
...emptyPage,
|
||||
rows: [{
|
||||
id: 'df-example', barcode: 'DF-EXAMPLE', spu: 'DF-SPU', sku: 'DF-SKU',
|
||||
image: '', color: '', skuOperator: '运营甲', costPrice: '70', shipQty: '',
|
||||
salesAmount: '', afterSaleAmount: '', realPerformance: '', afterSaleCostRate: '',
|
||||
grossProfit: '', grossMargin: '', trend: [], comparisonTrend: [],
|
||||
}],
|
||||
})
|
||||
jest.mocked(dropship1688SourcingService.listPurchasersByBarcodes).mockResolvedValue({
|
||||
'DF-EXAMPLE': [
|
||||
{ userId: 'buyer-1', name: '采购甲', acceptedAt: '2026-09-16T01:00:00.000Z' },
|
||||
{ userId: 'buyer-2', name: '采购乙', acceptedAt: '2026-09-16T02:00:00.000Z' },
|
||||
],
|
||||
})
|
||||
|
||||
render(await Dropship1688DataPage({ searchParams: Promise.resolve({}) }))
|
||||
|
||||
const table = screen.getByRole('table', { name: '' })
|
||||
const headers = within(table).getAllByRole('columnheader')
|
||||
const cells = within(table).getAllByRole('cell')
|
||||
const purchasingIndex = headers.findIndex((header) => header.textContent === '承接采购')
|
||||
expect(cells[purchasingIndex]).toHaveTextContent('采购甲')
|
||||
expect(cells[purchasingIndex]).toHaveTextContent('采购乙')
|
||||
const acceptedButton = screen.getByRole('button', { name: '已承接 2/3' })
|
||||
expect(acceptedButton).toBeDisabled()
|
||||
expect(acceptedButton).toHaveAttribute('data-state', 'accepted')
|
||||
|
||||
expect(dropship1688SourcingService.listPurchasersByBarcodes).toHaveBeenCalledWith(['DF-EXAMPLE'])
|
||||
})
|
||||
|
||||
it('keeps every sourcing write control disabled without operation permission', async () => {
|
||||
jest.mocked(requireSession).mockResolvedValue({
|
||||
authUser: {
|
||||
id: 'buyer-1', email: 'buyer@example.com', name: '采购甲', role: 'USER',
|
||||
status: 'ACTIVE', businessRoles: ['PURCHASING'],
|
||||
},
|
||||
} as never)
|
||||
jest.mocked(supplyChainPermissionSettingsService.getUserAccess).mockResolvedValue({
|
||||
view: true, edit: false, download: true,
|
||||
})
|
||||
const emptyPage = await jest.mocked(dropship1688DataService.listPage)({})
|
||||
jest.mocked(dropship1688DataService.listPage).mockResolvedValue({
|
||||
...emptyPage,
|
||||
rows: [{
|
||||
id: 'df-example', barcode: 'DF-EXAMPLE', spu: 'DF-SPU', sku: 'DF-SKU', image: '',
|
||||
color: '', skuOperator: '运营甲', costPrice: '70', shipQty: '', salesAmount: '',
|
||||
afterSaleAmount: '', realPerformance: '', afterSaleCostRate: '', grossProfit: '',
|
||||
grossMargin: '', trend: [], comparisonTrend: [],
|
||||
}],
|
||||
})
|
||||
render(await Dropship1688DataPage({ searchParams: Promise.resolve({}) }))
|
||||
expect(screen.getByRole('button', { name: '承接 0/3' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('lets an administrator accept a task without the purchasing business role', async () => {
|
||||
jest.mocked(requireSession).mockResolvedValue({
|
||||
authUser: {
|
||||
id: 'admin-1', email: 'admin@example.com', name: '管理员', role: 'ADMIN',
|
||||
status: 'ACTIVE', businessRoles: [],
|
||||
},
|
||||
} as never)
|
||||
const emptyPage = await jest.mocked(dropship1688DataService.listPage)({})
|
||||
jest.mocked(dropship1688DataService.listPage).mockResolvedValue({
|
||||
...emptyPage,
|
||||
rows: [{
|
||||
id: 'df-example', barcode: 'DF-EXAMPLE', spu: 'DF-SPU', sku: 'DF-SKU', image: '',
|
||||
color: '', skuOperator: '运营甲', costPrice: '70', shipQty: '', salesAmount: '',
|
||||
afterSaleAmount: '', realPerformance: '', afterSaleCostRate: '', grossProfit: '',
|
||||
grossMargin: '', trend: [], comparisonTrend: [],
|
||||
}],
|
||||
})
|
||||
|
||||
render(await Dropship1688DataPage({ searchParams: Promise.resolve({}) }))
|
||||
expect(screen.getByRole('button', { name: '承接 0/3' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import { ArrowLeft, ArrowRight, Filter, RefreshCw, Search } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowRight, Filter, RefreshCw } from 'lucide-react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { hasBusinessRole, isAdmin } from '@/lib/role-scope'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { dropship1688DataService } from '@/server/services/dropship-1688-data.service'
|
||||
import { dropship1688FilterStateService } from '@/server/services/dropship-1688-filter-state.service'
|
||||
import { dropship1688SourcingService } from '@/server/services/dropship-1688-sourcing.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
import styles from './dropship-1688-data.module.css'
|
||||
import { DropshipAcceptanceButton } from './DropshipAcceptanceButton'
|
||||
import { SalesTrendPopover } from './SalesTrendPopover'
|
||||
import { DropshipHeaderFilter } from './DropshipHeaderFilter'
|
||||
import {
|
||||
DropshipAutoSubmitDateField,
|
||||
DropshipFieldFilter,
|
||||
DropshipFieldFilterGroup,
|
||||
} from './DropshipFieldFilter'
|
||||
import { DropshipImageCell } from './DropshipImageCell'
|
||||
import { DropshipWorkspace } from './DropshipWorkspace'
|
||||
import { parseDropship1688Filters, serializeDropship1688Filters, type Dropship1688Filters } from '@/lib/dropship-1688-filters'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@ -67,15 +75,16 @@ function pageHref(input: {
|
||||
const COLUMNS = [
|
||||
{ label: '产品图片', width: 108 },
|
||||
{ label: '真实业绩', width: 132 },
|
||||
{ label: '销售趋势', width: 188 },
|
||||
{ label: '销售趋势', width: 286 },
|
||||
{ label: '承接采购', width: 132 },
|
||||
{ label: '成本价', width: 132 },
|
||||
{ label: '发货件数', width: 132 },
|
||||
{ label: '售后金额', width: 132 },
|
||||
{ label: '售后成本率', width: 132 },
|
||||
{ label: 'SKU运营', width: 132, filterField: 'skuOperator' },
|
||||
{ label: 'SPU', width: 132, filterField: 'spu' },
|
||||
{ label: 'SKU', width: 132, filterField: 'sku' },
|
||||
{ label: '条码', width: 148, filterField: 'barcode' },
|
||||
{ label: 'SKU运营', width: 132 },
|
||||
{ label: 'SPU', width: 132 },
|
||||
{ label: 'SKU', width: 132 },
|
||||
{ label: '条码', width: 148 },
|
||||
{ label: '颜色', width: 132 },
|
||||
{ label: '销售金额', width: 132 },
|
||||
{ label: '毛利额', width: 132 },
|
||||
@ -97,12 +106,11 @@ export default async function Dropship1688DataPage({
|
||||
const params = await searchParams
|
||||
const dateFrom = firstValue(params?.dateFrom)
|
||||
const dateTo = firstValue(params?.dateTo)
|
||||
const keyword = firstValue(params?.keyword)
|
||||
const filterRef = firstValue(params?.filterRef)
|
||||
const filters = filterRef
|
||||
const loadedFilters = filterRef
|
||||
? await dropship1688FilterStateService.load(authUser.id, filterRef)
|
||||
: parseDropship1688Filters(firstValue(params?.columnFilters))
|
||||
if (filters === null) {
|
||||
if (loadedFilters === null) {
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.toolbar}><div className={styles.titleLine}><h1>1688代发数据明细</h1></div></header>
|
||||
@ -113,52 +121,63 @@ export default async function Dropship1688DataPage({
|
||||
</main>
|
||||
)
|
||||
}
|
||||
// 条码筛选和商品检索已从工具栏移除,旧链接中的对应条件也不能继续隐式生效。
|
||||
const filters = { ...loadedFilters }
|
||||
delete filters.barcode
|
||||
const requestedPage = Number.parseInt(firstValue(params?.page), 10)
|
||||
const data = await dropship1688DataService.listPage({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
keyword,
|
||||
keyword: '',
|
||||
filters,
|
||||
page: Number.isSafeInteger(requestedPage) ? requestedPage : 1,
|
||||
})
|
||||
const purchasersByBarcode = await dropship1688SourcingService.listPurchasersByBarcodes(
|
||||
data.rows.map((row) => row.barcode),
|
||||
)
|
||||
const administrator = isAdmin(authUser)
|
||||
const canAccept = access.edit && (administrator || hasBusinessRole(authUser, 'PURCHASING'))
|
||||
const previousHref = pageHref({ ...data, filterRef, page: Math.max(1, data.page - 1) })
|
||||
const nextHref = pageHref({ ...data, filterRef, page: Math.min(data.totalPages, data.page + 1) })
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.toolbar}>
|
||||
<div>
|
||||
<div className={styles.titleLine}>
|
||||
<h1>1688代发数据明细</h1>
|
||||
<span>本地 DF 数据</span>
|
||||
</div>
|
||||
<p>数据更新至 {data.dataUpdatedThrough || '--'},当前共 {integerText(String(data.total))} 个商品</p>
|
||||
</div>
|
||||
<DropshipWorkspace
|
||||
summary={
|
||||
<dl className={styles.summary}>
|
||||
<div><dt>发货件数</dt><dd>{integerText(data.summary.shipQty)}</dd></div>
|
||||
<div><dt>销售金额</dt><dd>{moneyText(data.summary.salesAmount)}</dd></div>
|
||||
<div><dt>真实业绩</dt><dd>{moneyText(data.summary.realPerformance)}</dd></div>
|
||||
<div><dt>发货件数</dt><dd>{integerText(data.summary.shipQty)}</dd></div>
|
||||
<div><dt>毛利额</dt><dd>{moneyText(data.summary.grossProfit)}</dd></div>
|
||||
<div><dt>售后成本率</dt><dd>{percentText(data.summary.afterSaleCostRate)}</dd></div>
|
||||
<div><dt>总商品数</dt><dd>{integerText(data.summary.totalProducts)}</dd></div>
|
||||
<div><dt>有销售金额商品数</dt><dd>{integerText(data.summary.productsWithSalesAmount)}</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
}
|
||||
actorUserId={authUser.id}
|
||||
canEditAllSourcing={access.edit && administrator}
|
||||
canEditSourcing={canAccept}
|
||||
canExportSourcing={access.download}
|
||||
canImportSourcing={canAccept}
|
||||
>
|
||||
|
||||
<form className={styles.filters} method="get">
|
||||
<form aria-label="数据明细筛选" className={styles.filters} method="get">
|
||||
{filterRef
|
||||
? <input type="hidden" name="filterRef" value={filterRef} />
|
||||
: <input type="hidden" name="columnFilters" value={serializeDropship1688Filters(data.filters)} />}
|
||||
<div className={styles.filterTitle}><Filter size={15} /><span>筛选</span></div>
|
||||
<label>
|
||||
<span>开始日期</span>
|
||||
<input name="dateFrom" type="date" defaultValue={data.dateFrom} />
|
||||
</label>
|
||||
<label>
|
||||
<span>结束日期</span>
|
||||
<input name="dateTo" type="date" defaultValue={data.dateTo} />
|
||||
</label>
|
||||
<label className={styles.keywordField}>
|
||||
<span>商品检索</span>
|
||||
<input name="keyword" type="search" defaultValue={data.keyword} placeholder="SPU / SKU / 条码 / SKU运营" />
|
||||
</label>
|
||||
<button className={styles.searchButton} type="submit"><Search size={15} />查询</button>
|
||||
<DropshipAutoSubmitDateField name="dateFrom" label="开始日期" defaultValue={data.dateFrom} />
|
||||
<DropshipAutoSubmitDateField name="dateTo" label="结束日期" defaultValue={data.dateTo} />
|
||||
<DropshipFieldFilterGroup
|
||||
dateFrom={data.dateFrom}
|
||||
dateTo={data.dateTo}
|
||||
keyword={data.keyword}
|
||||
filters={data.filters}
|
||||
>
|
||||
<DropshipFieldFilter field="purchaser" noun="采购" allLabel="全部采购" />
|
||||
<DropshipFieldFilter field="skuOperator" noun="运营" allLabel="全部运营" />
|
||||
<DropshipFieldFilter field="spu" noun="SPU" allLabel="全部SPU" />
|
||||
<DropshipFieldFilter field="sku" noun="SKU" allLabel="全部SKU" />
|
||||
</DropshipFieldFilterGroup>
|
||||
<a className={styles.resetButton} href="/supply-chain/new-products/1688-dropship-data">
|
||||
<RefreshCw size={14} />重置
|
||||
</a>
|
||||
@ -176,19 +195,7 @@ export default async function Dropship1688DataPage({
|
||||
<tr>
|
||||
{COLUMNS.map((column) => (
|
||||
<th key={column.label}>
|
||||
<span className={styles.columnHeading}>
|
||||
{column.label}
|
||||
{'filterField' in column && (
|
||||
<DropshipHeaderFilter
|
||||
field={column.filterField}
|
||||
label={column.label}
|
||||
filters={data.filters}
|
||||
dateFrom={data.dateFrom}
|
||||
dateTo={data.dateTo}
|
||||
keyword={data.keyword}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.columnHeading}>{column.label}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@ -198,39 +205,67 @@ export default async function Dropship1688DataPage({
|
||||
<tr>
|
||||
<td className={styles.empty} colSpan={COLUMNS.length}>当前筛选条件下没有商品</td>
|
||||
</tr>
|
||||
) : data.rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className={styles.imageCell}>
|
||||
<DropshipImageCell
|
||||
barcode={row.barcode}
|
||||
sku={row.sku}
|
||||
hasImage={Boolean(row.image)}
|
||||
/>
|
||||
</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.realPerformance)}</td>
|
||||
<td className={styles.trendCell}>
|
||||
<SalesTrendPopover
|
||||
barcode={row.barcode}
|
||||
dateFrom={data.dateFrom}
|
||||
dateTo={data.dateTo}
|
||||
trend={row.trend}
|
||||
comparisonTrend={row.comparisonTrend}
|
||||
/>
|
||||
</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.costPrice)}</td>
|
||||
<td className={styles.numberCell}>{row.shipQty ? integerText(row.shipQty) : '--'}</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.afterSaleAmount)}</td>
|
||||
<td className={styles.numberCell}>{percentText(row.afterSaleCostRate)}</td>
|
||||
<td>{row.skuOperator || '--'}</td>
|
||||
<td className={styles.codeCell} title={row.spu || undefined}>{row.spu || '--'}</td>
|
||||
<td className={styles.codeCell} title={row.sku || undefined}>{row.sku || '--'}</td>
|
||||
<td className={styles.codeCell} title={row.barcode}>{row.barcode}</td>
|
||||
<td title={row.color || undefined}>{row.color || '--'}</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.salesAmount)}</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.grossProfit)}</td>
|
||||
<td className={styles.numberCell}>{percentText(row.grossMargin)}</td>
|
||||
</tr>
|
||||
))}
|
||||
) : data.rows.map((row) => {
|
||||
const purchasers = purchasersByBarcode[row.barcode.toUpperCase()] ?? []
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td className={styles.imageCell}>
|
||||
<DropshipImageCell
|
||||
barcode={row.barcode}
|
||||
sku={row.sku}
|
||||
hasImage={Boolean(row.image)}
|
||||
/>
|
||||
</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.realPerformance)}</td>
|
||||
<td className={styles.trendCell}>
|
||||
<div className={styles.trendWithActions}>
|
||||
<SalesTrendPopover
|
||||
barcode={row.barcode}
|
||||
dateFrom={data.dateFrom}
|
||||
dateTo={data.dateTo}
|
||||
trend={row.trend}
|
||||
comparisonTrend={row.comparisonTrend}
|
||||
/>
|
||||
<div className={styles.rowActions} role="group" aria-label={`${row.sku || row.barcode} 承接采购操作`}>
|
||||
<DropshipAcceptanceButton
|
||||
acceptanceRevision={purchasers
|
||||
.map((purchaser) => `${purchaser.userId}:${purchaser.acceptedAt}`)
|
||||
.sort()
|
||||
.join(',')}
|
||||
barcode={row.barcode}
|
||||
dateFrom={data.dateFrom}
|
||||
dateTo={data.dateTo}
|
||||
alreadyAccepted={purchasers.some((purchaser) => purchaser.userId === authUser.id)}
|
||||
canAccept={canAccept}
|
||||
purchaserCount={purchasers.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.purchaserCell}>
|
||||
{purchasers.length > 0 ? (
|
||||
<div className={styles.purchaserNames} title={purchasers.map((purchaser) => purchaser.name).join('、')}>
|
||||
{purchasers.map((purchaser) => (
|
||||
<span key={purchaser.userId}>{purchaser.name}</span>
|
||||
))}
|
||||
</div>
|
||||
) : '--'}
|
||||
</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.costPrice)}</td>
|
||||
<td className={styles.numberCell}>{row.shipQty ? integerText(row.shipQty) : '--'}</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.afterSaleAmount)}</td>
|
||||
<td className={styles.numberCell}>{percentText(row.afterSaleCostRate)}</td>
|
||||
<td>{row.skuOperator || '--'}</td>
|
||||
<td className={styles.codeCell} title={row.spu || undefined}>{row.spu || '--'}</td>
|
||||
<td className={styles.codeCell} title={row.sku || undefined}>{row.sku || '--'}</td>
|
||||
<td className={styles.codeCell} title={row.barcode}>{row.barcode}</td>
|
||||
<td title={row.color || undefined}>{row.color || '--'}</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.salesAmount)}</td>
|
||||
<td className={styles.numberCell}>{moneyText(row.grossProfit)}</td>
|
||||
<td className={styles.numberCell}>{percentText(row.grossMargin)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -252,6 +287,6 @@ export default async function Dropship1688DataPage({
|
||||
)}
|
||||
<span>每页 {data.pageSize} 条</span>
|
||||
</nav>
|
||||
</main>
|
||||
</DropshipWorkspace>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,158 @@
|
||||
jest.mock('next/cache', () => ({ revalidatePath: jest.fn() }))
|
||||
jest.mock('@/lib/server-session', () => ({ requireSession: jest.fn() }))
|
||||
jest.mock('@/server/services/dropship-1688-sourcing.service', () => ({
|
||||
dropship1688SourcingService: { claim: jest.fn(), update: jest.fn(), revoke: jest.fn() },
|
||||
}))
|
||||
jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
|
||||
supplyChainPermissionSettingsService: { getUserAccess: jest.fn() },
|
||||
}))
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { dropship1688SourcingService } from '@/server/services/dropship-1688-sourcing.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
import {
|
||||
claimDropship1688Sourcing,
|
||||
revokeDropship1688SourcingTask,
|
||||
updateDropship1688SourcingTask,
|
||||
} from './sourcing.actions'
|
||||
|
||||
const input = { barcode: 'DF-ONE', dateFrom: '2026-09-01', dateTo: '2026-09-10' }
|
||||
|
||||
describe('claimDropship1688Sourcing', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.mocked(requireSession).mockResolvedValue({
|
||||
authUser: {
|
||||
id: 'buyer-1', email: 'buyer@example.com', name: '采购甲', role: 'USER',
|
||||
status: 'ACTIVE', businessRoles: ['PURCHASING'],
|
||||
},
|
||||
} as never)
|
||||
jest.mocked(supplyChainPermissionSettingsService.getUserAccess).mockResolvedValue({
|
||||
view: true, edit: true, download: true,
|
||||
})
|
||||
jest.mocked(dropship1688SourcingService.claim).mockResolvedValue({} as never)
|
||||
jest.mocked(dropship1688SourcingService.update).mockResolvedValue({ id: 'task-1' } as never)
|
||||
jest.mocked(dropship1688SourcingService.revoke).mockResolvedValue({ id: 'task-1' } as never)
|
||||
})
|
||||
|
||||
it('claims as the authenticated user and refreshes the workspace', async () => {
|
||||
await expect(claimDropship1688Sourcing(input)).resolves.toEqual({ ok: true })
|
||||
expect(dropship1688SourcingService.claim).toHaveBeenCalledWith(
|
||||
input,
|
||||
expect.objectContaining({ id: 'buyer-1', name: '采购甲' }),
|
||||
)
|
||||
expect(revalidatePath).toHaveBeenCalledWith('/supply-chain/new-products/1688-dropship-data')
|
||||
})
|
||||
|
||||
it.each(['workspace', 'newProduct1688DropshipData'])('denies missing %s view access', async (deniedTarget) => {
|
||||
jest.mocked(supplyChainPermissionSettingsService.getUserAccess).mockImplementation(async (_user, target) => ({
|
||||
view: target !== deniedTarget, edit: false, download: false,
|
||||
}))
|
||||
await expect(claimDropship1688Sourcing(input)).resolves.toEqual({
|
||||
ok: false, message: '当前账号没有1688代发数据查看权限',
|
||||
})
|
||||
expect(dropship1688SourcingService.claim).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['claim', () => claimDropship1688Sourcing(input), dropship1688SourcingService.claim],
|
||||
['update', () => updateDropship1688SourcingTask({ taskId: 'task-1' }), dropship1688SourcingService.update],
|
||||
['revoke', () => revokeDropship1688SourcingTask({ taskId: 'task-1' }), dropship1688SourcingService.revoke],
|
||||
])('denies %s without the page operation permission', async (_name, action, serviceMethod) => {
|
||||
jest.mocked(supplyChainPermissionSettingsService.getUserAccess).mockImplementation(async (_user, target) => ({
|
||||
view: true,
|
||||
edit: target === 'workspace',
|
||||
download: target === 'workspace',
|
||||
}))
|
||||
|
||||
await expect(action()).resolves.toEqual({
|
||||
ok: false, message: '当前账号没有1688代发数据操作权限',
|
||||
})
|
||||
expect(serviceMethod).not.toHaveBeenCalled()
|
||||
expect(revalidatePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a safe domain error without refreshing', async () => {
|
||||
jest.mocked(dropship1688SourcingService.claim).mockRejectedValue(
|
||||
new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有采购或管理员可以承接寻源任务'),
|
||||
)
|
||||
await expect(claimDropship1688Sourcing(input)).resolves.toEqual({
|
||||
ok: false, message: '只有采购或管理员可以承接寻源任务',
|
||||
})
|
||||
expect(revalidatePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not leak unexpected infrastructure errors', async () => {
|
||||
jest.mocked(dropship1688SourcingService.claim).mockRejectedValue(new Error('database-secret'))
|
||||
await expect(claimDropship1688Sourcing(input)).resolves.toEqual({
|
||||
ok: false, message: '承接失败,请稍后重试',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates as the authenticated user and refreshes the workspace', async () => {
|
||||
const update = { taskId: 'task-1', supplierName: '供应商甲' }
|
||||
await expect(updateDropship1688SourcingTask(update)).resolves.toEqual({
|
||||
ok: true, task: { id: 'task-1' },
|
||||
})
|
||||
expect(dropship1688SourcingService.update).toHaveBeenCalledWith(
|
||||
update,
|
||||
expect.objectContaining({ id: 'buyer-1' }),
|
||||
)
|
||||
expect(revalidatePath).toHaveBeenCalledWith('/supply-chain/new-products/1688-dropship-data')
|
||||
})
|
||||
|
||||
it('returns update permission errors and hides unexpected failures', async () => {
|
||||
jest.mocked(dropship1688SourcingService.update).mockRejectedValueOnce(
|
||||
new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只能修改本人承接的寻源任务'),
|
||||
)
|
||||
await expect(updateDropship1688SourcingTask({ taskId: 'task-2' })).resolves.toEqual({
|
||||
ok: false, message: '只能修改本人承接的寻源任务',
|
||||
})
|
||||
jest.mocked(dropship1688SourcingService.update).mockRejectedValueOnce(new Error('database-secret'))
|
||||
await expect(updateDropship1688SourcingTask({ taskId: 'task-1' })).resolves.toEqual({
|
||||
ok: false, message: '保存失败,请稍后重试',
|
||||
})
|
||||
})
|
||||
|
||||
it('revokes as the authenticated user and refreshes the workspace', async () => {
|
||||
await expect(revokeDropship1688SourcingTask({ taskId: 'task-1' })).resolves.toEqual({ ok: true })
|
||||
expect(dropship1688SourcingService.revoke).toHaveBeenCalledWith(
|
||||
{ taskId: 'task-1' },
|
||||
expect.objectContaining({ id: 'buyer-1' }),
|
||||
)
|
||||
expect(revalidatePath).toHaveBeenCalledWith('/supply-chain/new-products/1688-dropship-data')
|
||||
})
|
||||
|
||||
it.each(['workspace', 'newProduct1688DropshipData'])('denies revoke without %s view access', async (deniedTarget) => {
|
||||
jest.mocked(supplyChainPermissionSettingsService.getUserAccess).mockImplementation(async (_user, target) => ({
|
||||
view: target !== deniedTarget, edit: false, download: false,
|
||||
}))
|
||||
await expect(revokeDropship1688SourcingTask({ taskId: 'task-1' })).resolves.toEqual({
|
||||
ok: false, message: '当前账号没有1688代发数据查看权限',
|
||||
})
|
||||
expect(dropship1688SourcingService.revoke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns revoke domain errors without refreshing and hides infrastructure failures', async () => {
|
||||
jest.mocked(dropship1688SourcingService.revoke).mockRejectedValueOnce(
|
||||
new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只能撤销本人承接的寻源任务'),
|
||||
)
|
||||
await expect(revokeDropship1688SourcingTask({ taskId: 'task-1' })).resolves.toEqual({
|
||||
ok: false, message: '只能撤销本人承接的寻源任务',
|
||||
})
|
||||
jest.mocked(dropship1688SourcingService.revoke).mockRejectedValueOnce(
|
||||
new AppError(ErrorCode.VALIDATION_FAILED, '该寻源任务已填写简尚条码,不能撤销'),
|
||||
)
|
||||
await expect(revokeDropship1688SourcingTask({ taskId: 'task-1' })).resolves.toEqual({
|
||||
ok: false, message: '该寻源任务已填写简尚条码,不能撤销',
|
||||
})
|
||||
expect(revalidatePath).not.toHaveBeenCalled()
|
||||
|
||||
jest.mocked(dropship1688SourcingService.revoke).mockRejectedValueOnce(new Error('database-secret'))
|
||||
await expect(revokeDropship1688SourcingTask({ taskId: 'task-1' })).resolves.toEqual({
|
||||
ok: false, message: '撤销失败,请稍后重试',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,81 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { AppError, ErrorCode, isAppError } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { dropship1688SourcingService } from '@/server/services/dropship-1688-sourcing.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
|
||||
const PAGE_PATH = '/supply-chain/new-products/1688-dropship-data'
|
||||
|
||||
async function assertDropshipWorkspaceAccess(authUser: AuthUser): Promise<void> {
|
||||
const [workspaceAccess, pageAccess] = await Promise.all([
|
||||
supplyChainPermissionSettingsService.getUserAccess(authUser, 'workspace'),
|
||||
supplyChainPermissionSettingsService.getUserAccess(authUser, 'newProduct1688DropshipData'),
|
||||
])
|
||||
if (!workspaceAccess.view || !pageAccess.view) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有1688代发数据查看权限')
|
||||
}
|
||||
if (!pageAccess.edit) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有1688代发数据操作权限')
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimDropship1688Sourcing(input: unknown): Promise<
|
||||
{ ok: true } | { ok: false; message: string }
|
||||
> {
|
||||
const { authUser } = await requireSession('/login')
|
||||
|
||||
try {
|
||||
await assertDropshipWorkspaceAccess(authUser)
|
||||
|
||||
await dropship1688SourcingService.claim(input, authUser)
|
||||
revalidatePath(PAGE_PATH)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: isAppError(error) ? error.message : '承接失败,请稍后重试',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDropship1688SourcingTask(input: unknown): Promise<
|
||||
| { ok: true; task: Awaited<ReturnType<typeof dropship1688SourcingService.update>> }
|
||||
| { ok: false; message: string }
|
||||
> {
|
||||
const { authUser } = await requireSession('/login')
|
||||
|
||||
try {
|
||||
await assertDropshipWorkspaceAccess(authUser)
|
||||
|
||||
const task = await dropship1688SourcingService.update(input, authUser)
|
||||
revalidatePath(PAGE_PATH)
|
||||
return { ok: true, task }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: isAppError(error) ? error.message : '保存失败,请稍后重试',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeDropship1688SourcingTask(
|
||||
input: unknown,
|
||||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
const { authUser } = await requireSession('/login')
|
||||
|
||||
try {
|
||||
await assertDropshipWorkspaceAccess(authUser)
|
||||
|
||||
await dropship1688SourcingService.revoke(input, authUser)
|
||||
revalidatePath(PAGE_PATH)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: isAppError(error) ? error.message : '撤销失败,请稍后重试',
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const FILTER_FIELDS = ['skuOperator', 'spu', 'sku', 'barcode'] as const
|
||||
// purchaser 不是商品档案上的列,而是承接记录里的采购;仓储层会单独解析它。
|
||||
export const FILTER_FIELDS = ['skuOperator', 'purchaser', 'spu', 'sku', 'barcode'] as const
|
||||
export type Dropship1688FilterField = typeof FILTER_FIELDS[number]
|
||||
export const DROPSHIP1688_FILTER_MAX_BYTES = 900_000
|
||||
|
||||
@ -11,6 +12,7 @@ const columnFilterSchema = z.object({
|
||||
|
||||
export const dropship1688FiltersSchema = z.object({
|
||||
skuOperator: columnFilterSchema.optional(),
|
||||
purchaser: columnFilterSchema.optional(),
|
||||
spu: columnFilterSchema.optional(),
|
||||
sku: columnFilterSchema.optional(),
|
||||
barcode: columnFilterSchema.optional(),
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
import ExcelJS from 'exceljs'
|
||||
import JSZip from 'jszip'
|
||||
import {
|
||||
createDropship1688SourcingWorkbook,
|
||||
parseDropship1688SourcingWorkbook,
|
||||
validateDropship1688SourcingWorkbookZip,
|
||||
} from './dropship-1688-sourcing-workbook'
|
||||
|
||||
describe('dropship 1688 sourcing workbook', () => {
|
||||
it('exports a round-trip template with the requested order and calculated comparison', async () => {
|
||||
const bytes = await createDropship1688SourcingWorkbook([{
|
||||
sourceSpu: 'SPU-ONE',
|
||||
sourceBarcode: 'DF-ONE',
|
||||
sourceCostPrice: '34.2900',
|
||||
quotedPriceTaxIncluded: '50.0000',
|
||||
invoiceTaxPoint: '13.0000',
|
||||
sourceSkuOperator: '运营甲',
|
||||
purchaserName: '采购甲',
|
||||
supplierName: '供应商甲',
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
}])
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.load(bytes as ExcelJS.Buffer)
|
||||
const sheet = workbook.getWorksheet('寻源明细')!
|
||||
|
||||
expect(sheet.getRow(1).values).toEqual([
|
||||
undefined, '代发SPU', '代发条码', '代发成本价', '报价(含税)', '对比',
|
||||
'发票税点', 'SKU运营', '采购', '供应商', '模式', '简尚条码',
|
||||
])
|
||||
expect(sheet.getCell('A2').value).toBe('SPU-ONE')
|
||||
expect(sheet.getCell('B2').value).toBe('DF-ONE')
|
||||
expect(sheet.getCell('E2').value).toEqual({ formula: 'IF(OR(D2="",C2=""),"",D2-C2)' })
|
||||
expect(sheet.getCell('J2').dataValidation).toMatchObject({
|
||||
type: 'list', formulae: ['"入库,代发"'],
|
||||
})
|
||||
expect(sheet.views[0]).toMatchObject({ state: 'frozen', ySplit: 1 })
|
||||
})
|
||||
|
||||
it('parses editable values and keeps the barcode plus purchaser key', async () => {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const sheet = workbook.addWorksheet('寻源明细')
|
||||
sheet.addRow([
|
||||
'代发SPU', '代发条码', '代发成本价', '报价(含税)', '对比',
|
||||
'发票税点', 'SKU运营', '采购', '供应商', '模式', '简尚条码',
|
||||
])
|
||||
sheet.addRow([
|
||||
'SPU-ONE', 'DF-ONE', 34.29, 50, 15.71,
|
||||
0.13, '运营甲', '采购甲', '供应商甲', '代发', 'JS-001',
|
||||
])
|
||||
sheet.getCell('F2').numFmt = '0%'
|
||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer())
|
||||
|
||||
await expect(parseDropship1688SourcingWorkbook(buffer)).resolves.toEqual([{
|
||||
rowNumber: 2,
|
||||
sourceBarcode: 'DF-ONE',
|
||||
purchaserName: '采购甲',
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: '50',
|
||||
invoiceTaxPoint: '13',
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects workbooks without the complete round-trip headers', async () => {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
workbook.addWorksheet('寻源明细').addRow(['代发条码', '采购', '报价(含税)'])
|
||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer())
|
||||
|
||||
await expect(parseDropship1688SourcingWorkbook(buffer)).rejects.toThrow(
|
||||
'导入文件缺少必要表头',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects ZIP archives with too many entries before workbook parsing', async () => {
|
||||
const zip = new JSZip()
|
||||
zip.file('one.xml', '<one/>')
|
||||
zip.file('two.xml', '<two/>')
|
||||
const buffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||
|
||||
await expect(validateDropship1688SourcingWorkbookZip(buffer, {
|
||||
maxEntries: 1,
|
||||
})).rejects.toThrow('导入文件包含过多 ZIP 条目')
|
||||
})
|
||||
|
||||
it('rejects a ZIP entry whose declared uncompressed size is too large', async () => {
|
||||
const zip = new JSZip()
|
||||
zip.file('large.xml', 'abc')
|
||||
const buffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||
|
||||
await expect(validateDropship1688SourcingWorkbookZip(buffer, {
|
||||
maxEntryUncompressedSize: 2,
|
||||
})).rejects.toThrow('导入文件包含解压后过大的 ZIP 条目')
|
||||
})
|
||||
|
||||
it('rejects ZIP archives whose aggregate uncompressed size is too large', async () => {
|
||||
const zip = new JSZip()
|
||||
zip.file('one.xml', 'abc')
|
||||
zip.file('two.xml', 'def')
|
||||
const buffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||
|
||||
await expect(validateDropship1688SourcingWorkbookZip(buffer, {
|
||||
maxTotalUncompressedSize: 5,
|
||||
})).rejects.toThrow('导入文件解压后的总大小过大')
|
||||
})
|
||||
|
||||
it('rejects more than 50,000 data rows before iterating workbook contents', async () => {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const sheet = workbook.addWorksheet('寻源明细')
|
||||
sheet.addRow([
|
||||
'代发SPU', '代发条码', '代发成本价', '报价(含税)', '对比',
|
||||
'发票税点', 'SKU运营', '采购', '供应商', '模式', '简尚条码',
|
||||
])
|
||||
for (let rowNumber = 2; rowNumber <= 50_002; rowNumber += 1) {
|
||||
sheet.getCell(rowNumber, 1).value = 'overflow'
|
||||
}
|
||||
const buffer = Buffer.from(await workbook.xlsx.writeBuffer())
|
||||
|
||||
await expect(parseDropship1688SourcingWorkbook(buffer)).rejects.toThrow(
|
||||
'导入文件最多允许50000行数据',
|
||||
)
|
||||
})
|
||||
})
|
||||
282
furniture-3d-system/src/lib/dropship-1688-sourcing-workbook.ts
Normal file
282
furniture-3d-system/src/lib/dropship-1688-sourcing-workbook.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import ExcelJS from 'exceljs'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
export const DROPSHIP_1688_SOURCING_WORKBOOK_MIME =
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
|
||||
export type Dropship1688SourcingWorkbookRow = {
|
||||
sourceSpu: string
|
||||
sourceBarcode: string
|
||||
sourceCostPrice: string | null
|
||||
quotedPriceTaxIncluded: string | null
|
||||
invoiceTaxPoint: string | null
|
||||
sourceSkuOperator: string
|
||||
purchaserName: string
|
||||
supplierName: string
|
||||
sourcingMode: string
|
||||
jianshangBarcode: string
|
||||
}
|
||||
|
||||
export type Dropship1688SourcingImportRow = {
|
||||
rowNumber: number
|
||||
sourceBarcode: string
|
||||
purchaserName: string
|
||||
supplierName: string
|
||||
quotedPriceTaxIncluded: string
|
||||
invoiceTaxPoint: string
|
||||
sourcingMode: string
|
||||
jianshangBarcode: string
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'sourceSpu', label: '代发SPU', width: 22, editable: false },
|
||||
{ key: 'sourceBarcode', label: '代发条码', width: 22, editable: false },
|
||||
{ key: 'sourceCostPrice', label: '代发成本价', width: 15, editable: false },
|
||||
{ key: 'quotedPriceTaxIncluded', label: '报价(含税)', width: 16, editable: true },
|
||||
{ key: 'comparison', label: '对比', width: 15, editable: false },
|
||||
{ key: 'invoiceTaxPoint', label: '发票税点', width: 14, editable: true },
|
||||
{ key: 'sourceSkuOperator', label: 'SKU运营', width: 18, editable: false },
|
||||
{ key: 'purchaserName', label: '采购', width: 16, editable: false },
|
||||
{ key: 'supplierName', label: '供应商', width: 24, editable: true },
|
||||
{ key: 'sourcingMode', label: '模式', width: 12, editable: true },
|
||||
{ key: 'jianshangBarcode', label: '简尚条码', width: 22, editable: true },
|
||||
] as const
|
||||
|
||||
const REQUIRED_HEADERS = [
|
||||
'代发条码',
|
||||
'采购',
|
||||
'报价(含税)',
|
||||
'发票税点',
|
||||
'供应商',
|
||||
'模式',
|
||||
'简尚条码',
|
||||
] as const
|
||||
|
||||
type WorkbookZipLimits = {
|
||||
maxEntries: number
|
||||
maxEntryUncompressedSize: number
|
||||
maxTotalUncompressedSize: number
|
||||
}
|
||||
|
||||
const DEFAULT_WORKBOOK_ZIP_LIMITS: WorkbookZipLimits = {
|
||||
maxEntries: 2_048,
|
||||
maxEntryUncompressedSize: 96 * 1024 * 1024,
|
||||
maxTotalUncompressedSize: 128 * 1024 * 1024,
|
||||
}
|
||||
|
||||
const MAX_WORKBOOK_ROW_COUNT = 50_001
|
||||
|
||||
type ZipEntryWithDeclaredSize = {
|
||||
dir: boolean
|
||||
_data?: { uncompressedSize?: unknown }
|
||||
}
|
||||
|
||||
export async function validateDropship1688SourcingWorkbookZip(
|
||||
buffer: Buffer | Uint8Array | ArrayBuffer,
|
||||
limitOverrides: Partial<WorkbookZipLimits> = {},
|
||||
): Promise<void> {
|
||||
const limits = { ...DEFAULT_WORKBOOK_ZIP_LIMITS, ...limitOverrides }
|
||||
const zip = await JSZip.loadAsync(buffer)
|
||||
const entries = Object.values(zip.files) as ZipEntryWithDeclaredSize[]
|
||||
if (entries.length > limits.maxEntries) {
|
||||
throw new Error(`导入文件包含过多 ZIP 条目(最多${limits.maxEntries}个)`)
|
||||
}
|
||||
|
||||
let totalUncompressedSize = 0
|
||||
for (const entry of entries) {
|
||||
if (entry.dir) continue
|
||||
const declaredSize = entry._data?.uncompressedSize
|
||||
if (typeof declaredSize !== 'number'
|
||||
|| !Number.isSafeInteger(declaredSize)
|
||||
|| declaredSize < 0) {
|
||||
throw new Error('导入文件包含无效的 ZIP 条目大小')
|
||||
}
|
||||
if (declaredSize > limits.maxEntryUncompressedSize) {
|
||||
throw new Error('导入文件包含解压后过大的 ZIP 条目')
|
||||
}
|
||||
totalUncompressedSize += declaredSize
|
||||
if (!Number.isSafeInteger(totalUncompressedSize)
|
||||
|| totalUncompressedSize > limits.maxTotalUncompressedSize) {
|
||||
throw new Error('导入文件解压后的总大小过大')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decimalValue(value: string | null) {
|
||||
if (!value) return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function applyHeaderStyle(cell: ExcelJS.Cell, editable: boolean) {
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
|
||||
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: 'FF27272A' } }
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: editable ? 'FFEFF6FF' : 'FFF4F4F5' },
|
||||
}
|
||||
cell.border = {
|
||||
top: { style: 'thin', color: { argb: 'FFD4D4D8' } },
|
||||
left: { style: 'thin', color: { argb: 'FFD4D4D8' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FFD4D4D8' } },
|
||||
right: { style: 'thin', color: { argb: 'FFD4D4D8' } },
|
||||
}
|
||||
}
|
||||
|
||||
function applyBodyStyle(cell: ExcelJS.Cell, editable: boolean) {
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'left' }
|
||||
cell.font = { name: 'Microsoft YaHei', size: 10, color: { argb: 'FF27272A' } }
|
||||
cell.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: editable ? 'FFFFFBEB' : 'FFFFFFFF' },
|
||||
}
|
||||
cell.border = {
|
||||
top: { style: 'thin', color: { argb: 'FFE4E4E7' } },
|
||||
left: { style: 'thin', color: { argb: 'FFE4E4E7' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FFE4E4E7' } },
|
||||
right: { style: 'thin', color: { argb: 'FFE4E4E7' } },
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDropship1688SourcingWorkbook(
|
||||
rows: readonly Dropship1688SourcingWorkbookRow[],
|
||||
): Promise<Uint8Array> {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
workbook.creator = '供应链中心'
|
||||
workbook.created = new Date()
|
||||
workbook.modified = new Date()
|
||||
workbook.calcProperties.fullCalcOnLoad = true
|
||||
|
||||
const sheet = workbook.addWorksheet('寻源明细')
|
||||
sheet.properties.defaultRowHeight = 22
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }]
|
||||
sheet.columns = COLUMNS.map((column) => ({
|
||||
key: column.key,
|
||||
header: column.label,
|
||||
width: column.width,
|
||||
}))
|
||||
sheet.getRow(1).height = 28
|
||||
COLUMNS.forEach((column, index) => {
|
||||
const cell = sheet.getCell(1, index + 1)
|
||||
applyHeaderStyle(cell, column.editable)
|
||||
cell.note = column.editable
|
||||
? '可填写;导入后覆盖对应“代发条码 + 采购”的寻源记录。'
|
||||
: '识别或计算字段,请勿修改。'
|
||||
})
|
||||
|
||||
rows.forEach((source, index) => {
|
||||
const rowNumber = index + 2
|
||||
const row = sheet.addRow({
|
||||
sourceSpu: source.sourceSpu,
|
||||
sourceBarcode: source.sourceBarcode,
|
||||
sourceCostPrice: decimalValue(source.sourceCostPrice),
|
||||
quotedPriceTaxIncluded: decimalValue(source.quotedPriceTaxIncluded),
|
||||
invoiceTaxPoint: decimalValue(source.invoiceTaxPoint),
|
||||
sourceSkuOperator: source.sourceSkuOperator,
|
||||
purchaserName: source.purchaserName,
|
||||
supplierName: source.supplierName,
|
||||
sourcingMode: source.sourcingMode,
|
||||
jianshangBarcode: source.jianshangBarcode,
|
||||
})
|
||||
row.height = 24
|
||||
COLUMNS.forEach((column, columnIndex) => {
|
||||
applyBodyStyle(row.getCell(columnIndex + 1), column.editable)
|
||||
})
|
||||
row.getCell(5).value = {
|
||||
formula: `IF(OR(D${rowNumber}="",C${rowNumber}=""),"",D${rowNumber}-C${rowNumber})`,
|
||||
}
|
||||
row.getCell(3).numFmt = '¥#,##0.00'
|
||||
row.getCell(4).numFmt = '¥#,##0.00'
|
||||
row.getCell(5).numFmt = '[Green]+¥#,##0.00;[Red]-¥#,##0.00;¥0.00'
|
||||
row.getCell(6).numFmt = '0.####"%"'
|
||||
row.getCell(10).dataValidation = {
|
||||
type: 'list',
|
||||
allowBlank: true,
|
||||
formulae: ['"入库,代发"'],
|
||||
showErrorMessage: true,
|
||||
errorTitle: '模式不正确',
|
||||
error: '模式只能选择“入库”或“代发”。',
|
||||
}
|
||||
})
|
||||
|
||||
sheet.autoFilter = {
|
||||
from: { row: 1, column: 1 },
|
||||
to: { row: Math.max(1, rows.length + 1), column: COLUMNS.length },
|
||||
}
|
||||
const output = await workbook.xlsx.writeBuffer()
|
||||
return new Uint8Array(output)
|
||||
}
|
||||
|
||||
function cellText(cell: ExcelJS.Cell) {
|
||||
return cell.text.trim()
|
||||
}
|
||||
|
||||
function decimalCellText(cell: ExcelJS.Cell, percent: boolean) {
|
||||
if (cell.value === null || cell.value === undefined || cell.value === '') return ''
|
||||
if (typeof cell.value === 'number') {
|
||||
const calculatedPercent = (cell.numFmt ?? '').replace(/"[^"]*"/g, '').includes('%')
|
||||
const value = percent && calculatedPercent ? cell.value * 100 : cell.value
|
||||
return String(value)
|
||||
}
|
||||
const text = cellText(cell).replace(/,/g, '')
|
||||
if (!text) return ''
|
||||
return percent && text.endsWith('%') ? text.slice(0, -1).trim() : text
|
||||
}
|
||||
|
||||
export async function parseDropship1688SourcingWorkbook(
|
||||
buffer: Buffer | Uint8Array | ArrayBuffer,
|
||||
): Promise<Dropship1688SourcingImportRow[]> {
|
||||
await validateDropship1688SourcingWorkbookZip(buffer)
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.load(buffer as ExcelJS.Buffer)
|
||||
const sheet = workbook.getWorksheet('寻源明细') ?? workbook.worksheets[0]
|
||||
if (!sheet) throw new Error('导入文件没有工作表')
|
||||
if (sheet.actualRowCount > MAX_WORKBOOK_ROW_COUNT) {
|
||||
throw new Error('导入文件最多允许50000行数据')
|
||||
}
|
||||
|
||||
const headerByLabel = new Map<string, number>()
|
||||
sheet.getRow(1).eachCell({ includeEmpty: false }, (cell, columnNumber) => {
|
||||
headerByLabel.set(cellText(cell), columnNumber)
|
||||
})
|
||||
const missing = REQUIRED_HEADERS.filter((header) => !headerByLabel.has(header))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`导入文件缺少必要表头:${missing.join('、')}`)
|
||||
}
|
||||
|
||||
const column = (label: typeof REQUIRED_HEADERS[number]) => headerByLabel.get(label)!
|
||||
const rows: Dropship1688SourcingImportRow[] = []
|
||||
for (let rowNumber = 2; rowNumber <= sheet.actualRowCount; rowNumber += 1) {
|
||||
const row = sheet.getRow(rowNumber)
|
||||
const sourceBarcode = cellText(row.getCell(column('代发条码')))
|
||||
const purchaserName = cellText(row.getCell(column('采购')))
|
||||
const supplierName = cellText(row.getCell(column('供应商')))
|
||||
const quotedPriceTaxIncluded = decimalCellText(row.getCell(column('报价(含税)')), false)
|
||||
const invoiceTaxPoint = decimalCellText(row.getCell(column('发票税点')), true)
|
||||
const sourcingMode = cellText(row.getCell(column('模式')))
|
||||
const jianshangBarcode = cellText(row.getCell(column('简尚条码')))
|
||||
const hasAnyValue = [
|
||||
sourceBarcode,
|
||||
purchaserName,
|
||||
supplierName,
|
||||
quotedPriceTaxIncluded,
|
||||
invoiceTaxPoint,
|
||||
sourcingMode,
|
||||
jianshangBarcode,
|
||||
].some(Boolean)
|
||||
if (!hasAnyValue) continue
|
||||
rows.push({
|
||||
rowNumber,
|
||||
sourceBarcode,
|
||||
purchaserName,
|
||||
supplierName,
|
||||
quotedPriceTaxIncluded,
|
||||
invoiceTaxPoint,
|
||||
sourcingMode,
|
||||
jianshangBarcode,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
15
furniture-3d-system/src/lib/dropship-1688-sourcing.ts
Normal file
15
furniture-3d-system/src/lib/dropship-1688-sourcing.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export const DROPSHIP_1688_MAX_PURCHASERS = 3
|
||||
export const DROPSHIP_1688_SOURCING_PAGE_SIZE = 50
|
||||
export const DROPSHIP_1688_SOURCING_EXPORT_LIMIT = 50_000
|
||||
|
||||
export type Dropship1688SourcingFilters = {
|
||||
purchaserNames: string[]
|
||||
sourceSpus: string[]
|
||||
sourceSkuOperators: string[]
|
||||
}
|
||||
|
||||
export const EMPTY_DROPSHIP_1688_SOURCING_FILTERS: Dropship1688SourcingFilters = {
|
||||
purchaserNames: [],
|
||||
sourceSpus: [],
|
||||
sourceSkuOperators: [],
|
||||
}
|
||||
@ -138,13 +138,19 @@ describe('supply chain permission settings', () => {
|
||||
).toEqual({ targetKey: 'scriptMonitor', action: 'view' })
|
||||
})
|
||||
|
||||
it('assigns only the dedicated DF image endpoint to the 1688 module', () => {
|
||||
it('assigns the dedicated image and sourcing workbook endpoints to the 1688 module', () => {
|
||||
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/source-image', 'GET'))
|
||||
.toEqual({ targetKey: 'newProduct1688DropshipData', action: 'view' })
|
||||
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/source-image', 'POST'))
|
||||
.toEqual({ targetKey: 'newProduct1688DropshipData', action: 'edit' })
|
||||
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/source-image/other', 'GET'))
|
||||
.toBeUndefined()
|
||||
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/sourcing-list', 'GET'))
|
||||
.toEqual({ targetKey: 'newProduct1688DropshipData', action: 'view' })
|
||||
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/sourcing-export', 'POST'))
|
||||
.toEqual({ targetKey: 'newProduct1688DropshipData', action: 'download' })
|
||||
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/sourcing-import', 'POST'))
|
||||
.toEqual({ targetKey: 'newProduct1688DropshipData', action: 'edit' })
|
||||
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/product-archive/source-image', 'GET'))
|
||||
.toEqual({ targetKey: 'productArchive', action: 'view' })
|
||||
})
|
||||
|
||||
@ -352,6 +352,15 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
|
||||
if (pathname === '/api/supply-chain/1688-dropship-data/source-image') {
|
||||
return { targetKey: 'newProduct1688DropshipData' as const, action }
|
||||
}
|
||||
if (pathname === '/api/supply-chain/1688-dropship-data/sourcing-list') {
|
||||
return { targetKey: 'newProduct1688DropshipData' as const, action: 'view' as const }
|
||||
}
|
||||
if (pathname === '/api/supply-chain/1688-dropship-data/sourcing-export') {
|
||||
return { targetKey: 'newProduct1688DropshipData' as const, action: 'download' as const }
|
||||
}
|
||||
if (pathname === '/api/supply-chain/1688-dropship-data/sourcing-import') {
|
||||
return { targetKey: 'newProduct1688DropshipData' as const, action: 'edit' as const }
|
||||
}
|
||||
if (pathname.startsWith('/api/supply-chain/product-archive/dashboard')) {
|
||||
return { targetKey: 'dashboard' as const, action }
|
||||
}
|
||||
|
||||
@ -8,6 +8,9 @@ jest.mock('@/lib/prisma', () => ({
|
||||
groupBy: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
dropship1688SourcingTask: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -20,6 +23,7 @@ const archiveFindMany = prisma.supplyChainProductArchive.findMany as jest.Mock
|
||||
const dailyAggregate = prisma.supplyChainProductArchiveSalesPerformanceDaily.aggregate as jest.Mock
|
||||
const dailyGroupBy = prisma.supplyChainProductArchiveSalesPerformanceDaily.groupBy as jest.Mock
|
||||
const dailyFindMany = prisma.supplyChainProductArchiveSalesPerformanceDaily.findMany as jest.Mock
|
||||
const taskFindMany = prisma.dropship1688SourcingTask.findMany as jest.Mock
|
||||
|
||||
function archive(id: string, barcode: string) {
|
||||
return {
|
||||
@ -32,12 +36,12 @@ function archive(id: string, barcode: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function performance(barcode: string, realPerformance: string) {
|
||||
function performance(barcode: string, realPerformance: string, salesAmount = '0') {
|
||||
return {
|
||||
barcode,
|
||||
_sum: {
|
||||
shipQty: 0n,
|
||||
salesAmount: new Prisma.Decimal(0),
|
||||
salesAmount: new Prisma.Decimal(salesAmount),
|
||||
realPerformance: new Prisma.Decimal(realPerformance),
|
||||
clearSalesCost: new Prisma.Decimal(0),
|
||||
sendFreight: new Prisma.Decimal(0),
|
||||
@ -54,6 +58,7 @@ describe('dropship1688DataRepository', () => {
|
||||
_sum: { shipQty: 0n, shipAmount: new Prisma.Decimal(0), realPerformance: new Prisma.Decimal(0) },
|
||||
})
|
||||
dailyFindMany.mockResolvedValue([])
|
||||
taskFindMany.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('sorts the complete result by filtered real performance before pagination', async () => {
|
||||
@ -154,6 +159,33 @@ describe('dropship1688DataRepository', () => {
|
||||
expect(result.summary._sum.realPerformance.toString()).toBe('90')
|
||||
})
|
||||
|
||||
it('counts only products whose fully backfilled displayed sales amount is positive', async () => {
|
||||
const candidates = [
|
||||
archive('positive', 'DF-POSITIVE'),
|
||||
archive('zero', 'DF-ZERO'),
|
||||
archive('negative', 'DF-NEGATIVE'),
|
||||
archive('legacy', 'DF-LEGACY'),
|
||||
]
|
||||
archiveFindMany.mockImplementation(({ where }) => Promise.resolve(
|
||||
where.id ? candidates.filter((candidate) => where.id.in.includes(candidate.id)) : candidates,
|
||||
))
|
||||
const legacy = performance('DF-LEGACY', '40', '999')
|
||||
legacy._count = { _all: 2, salesAmount: 1 }
|
||||
dailyGroupBy.mockResolvedValue([
|
||||
performance('DF-POSITIVE', '100', '120'),
|
||||
performance('DF-ZERO', '80', '0'),
|
||||
performance('DF-NEGATIVE', '60', '-1'),
|
||||
legacy,
|
||||
])
|
||||
|
||||
const result = await dropship1688DataRepository.listPage({
|
||||
keyword: '', dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-07'), skip: 0, take: 50,
|
||||
})
|
||||
|
||||
expect(result.summary.productsWithSalesAmount).toBe(1)
|
||||
expect(result.summary._sum.shipAmount).toBeNull()
|
||||
})
|
||||
|
||||
it('matches ider-backed operators within the selected date range', async () => {
|
||||
archiveFindMany.mockImplementation(({ where }) => Promise.resolve(where.id
|
||||
? [archive('a', 'DF-A')]
|
||||
@ -257,6 +289,64 @@ describe('dropship1688DataRepository', () => {
|
||||
expect(result.summary._sum.realPerformance.toString()).toBe(total)
|
||||
})
|
||||
|
||||
it('keeps filtered sales and calculated-profit summaries stable across pages', async () => {
|
||||
const candidates = [
|
||||
archive('a', 'DF-A'),
|
||||
archive('b', 'DF-B'),
|
||||
archive('c', 'DF-C'),
|
||||
]
|
||||
archiveFindMany.mockImplementation(({ where }) => Promise.resolve(
|
||||
where.id ? candidates.filter((candidate) => where.id.in.includes(candidate.id)) : candidates,
|
||||
))
|
||||
const first = performance('DF-A', '100', '20')
|
||||
first._sum.clearSalesCost = new Prisma.Decimal('20')
|
||||
first._sum.sendFreight = new Prisma.Decimal('5')
|
||||
first._sum.afterSaleAmount = new Prisma.Decimal('0.0001')
|
||||
const second = performance('DF-B', '50', '10')
|
||||
second._sum.clearSalesCost = new Prisma.Decimal('10')
|
||||
second._sum.sendFreight = new Prisma.Decimal('2')
|
||||
second._sum.afterSaleAmount = new Prisma.Decimal('0.0002')
|
||||
const excluded = performance('DF-C', '10000', '9000')
|
||||
excluded._sum.clearSalesCost = new Prisma.Decimal('500')
|
||||
excluded._sum.sendFreight = new Prisma.Decimal('100')
|
||||
excluded._sum.afterSaleAmount = new Prisma.Decimal('1000')
|
||||
dailyGroupBy.mockResolvedValue([first, second, excluded])
|
||||
const input = {
|
||||
keyword: '',
|
||||
dateFrom: new Date('2026-09-01'),
|
||||
dateTo: new Date('2026-09-07'),
|
||||
filters: { sku: { mode: 'include' as const, values: ['SKU-a', 'SKU-b'] } },
|
||||
take: 1,
|
||||
}
|
||||
|
||||
for (const [skip, barcode] of [[0, 'DF-A'], [1, 'DF-B']] as const) {
|
||||
const result = await dropship1688DataRepository.listPage({ ...input, skip })
|
||||
expect(result.archives.map((record) => record.barcode)).toEqual([barcode])
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.summary.productsWithSalesAmount).toBe(2)
|
||||
expect(result.summary._sum.shipAmount?.toString()).toBe('30')
|
||||
expect(result.summary._sum.realPerformance.toString()).toBe('150')
|
||||
expect(result.summary._sum.grossProfit.toString()).toBe('113')
|
||||
expect(result.summary._sum.afterSaleAmount.toFixed(4)).toBe('0.0003')
|
||||
}
|
||||
expect(dailyGroupBy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: {
|
||||
barcode: { startsWith: 'DF-' },
|
||||
statDate: { gte: input.dateFrom, lte: input.dateTo },
|
||||
},
|
||||
_sum: expect.objectContaining({
|
||||
salesAmount: true,
|
||||
realPerformance: true,
|
||||
clearSalesCost: true,
|
||||
sendFreight: true,
|
||||
afterSaleAmount: true,
|
||||
}),
|
||||
}))
|
||||
const aggregateSelection = dailyGroupBy.mock.calls[0][0]._sum
|
||||
expect(aggregateSelection).not.toHaveProperty('grossProfit')
|
||||
expect(dailyGroupBy.mock.calls[0][0].where).not.toHaveProperty('archive')
|
||||
})
|
||||
|
||||
it('filters by the latest trimmed non-empty operator in the selected period, not historical matches', async () => {
|
||||
const candidates = [archive('a', 'DF-A'), archive('b', 'DF-B'), archive('empty', 'DF-EMPTY')]
|
||||
archiveFindMany.mockImplementation(({ where }) => Promise.resolve(
|
||||
@ -289,6 +379,122 @@ describe('dropship1688DataRepository', () => {
|
||||
expect(historical.archives.map((record) => record.barcode)).toEqual(['DF-B'])
|
||||
})
|
||||
|
||||
it('matches a product when any accepting purchaser is selected', async () => {
|
||||
const candidates = [archive('a', 'DF-A'), archive('b', 'DF-B')]
|
||||
archiveFindMany.mockImplementation(({ where }) => Promise.resolve(
|
||||
where.id ? candidates.filter((candidate) => where.id.in.includes(candidate.id)) : candidates,
|
||||
))
|
||||
dailyGroupBy.mockResolvedValue([])
|
||||
taskFindMany.mockResolvedValue([{ sourceBarcode: 'DF-A' }])
|
||||
const input = {
|
||||
keyword: '', dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-07'), skip: 0, take: 50,
|
||||
}
|
||||
|
||||
const included = await dropship1688DataRepository.listPage({
|
||||
...input, filters: { purchaser: { mode: 'include', values: ['采购乙'] } },
|
||||
})
|
||||
expect(included.archives.map((record) => record.barcode)).toEqual(['DF-A'])
|
||||
|
||||
const excluded = await dropship1688DataRepository.listPage({
|
||||
...input, filters: { purchaser: { mode: 'exclude', values: ['采购甲'] } },
|
||||
})
|
||||
expect(excluded.archives.map((record) => record.barcode)).toEqual(['DF-B'])
|
||||
expect(taskFindMany).toHaveBeenNthCalledWith(1, {
|
||||
where: { purchaserName: { in: ['采购乙'] } },
|
||||
distinct: ['sourceBarcode'],
|
||||
select: { sourceBarcode: true },
|
||||
})
|
||||
expect(taskFindMany).toHaveBeenNthCalledWith(2, {
|
||||
where: { purchaserName: { in: ['采购甲'] } },
|
||||
distinct: ['sourceBarcode'],
|
||||
select: { sourceBarcode: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps purchaser-filtered totals and summaries stable across pages', async () => {
|
||||
const candidates = [archive('a', 'DF-A'), archive('b', 'DF-B'), archive('c', 'DF-C')]
|
||||
archiveFindMany.mockImplementation(({ where }) => Promise.resolve(
|
||||
where.id ? candidates.filter((candidate) => where.id.in.includes(candidate.id)) : candidates,
|
||||
))
|
||||
taskFindMany.mockResolvedValue([{ sourceBarcode: 'DF-A' }, { sourceBarcode: 'DF-B' }])
|
||||
dailyGroupBy.mockResolvedValue([
|
||||
performance('DF-A', '10'), performance('DF-B', '20'), performance('DF-C', '999'),
|
||||
])
|
||||
const filters = { purchaser: { mode: 'include' as const, values: ['采购甲'] } }
|
||||
|
||||
for (const [skip, barcode] of [[0, 'DF-B'], [1, 'DF-A']] as const) {
|
||||
const result = await dropship1688DataRepository.listPage({
|
||||
keyword: '', dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-07'),
|
||||
skip, take: 1, filters,
|
||||
})
|
||||
expect(result.archives.map((record) => record.barcode)).toEqual([barcode])
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.summary._sum.realPerformance.toString()).toBe('30')
|
||||
}
|
||||
expect(taskFindMany).toHaveBeenCalledTimes(2)
|
||||
expect(taskFindMany).toHaveBeenLastCalledWith({
|
||||
where: { purchaserName: { in: ['采购甲'] } },
|
||||
distinct: ['sourceBarcode'],
|
||||
select: { sourceBarcode: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('offers searchable purchaser options from the remaining candidate products', async () => {
|
||||
archiveFindMany.mockResolvedValue([
|
||||
archive('a', 'DF-A'),
|
||||
archive('b', 'DF-B'),
|
||||
archive('c', 'DF-C'),
|
||||
])
|
||||
taskFindMany.mockImplementation(({ where }) => {
|
||||
if (where.purchaserName) return Promise.resolve([{ sourceBarcode: 'DF-A' }])
|
||||
const requested = new Set(where.sourceBarcode.in)
|
||||
return Promise.resolve([
|
||||
...(requested.has('DF-A') ? [{ purchaserName: '采购乙' }, { purchaserName: '采购甲' }] : []),
|
||||
...(requested.has('DF-B') ? [{ purchaserName: ' 采购甲 ' }] : []),
|
||||
...(requested.has('DF-C') ? [{ purchaserName: '采购丁' }] : []),
|
||||
])
|
||||
})
|
||||
const input = {
|
||||
field: 'purchaser' as const,
|
||||
keyword: '',
|
||||
dateFrom: new Date('2026-09-01'),
|
||||
dateTo: new Date('2026-09-07'),
|
||||
}
|
||||
|
||||
const all = await dropship1688DataRepository.listFilterOptions({ ...input, filters: {} })
|
||||
expect(all.values).toEqual(['采购丁', '采购甲', '采购乙'])
|
||||
|
||||
const searched = await dropship1688DataRepository.listFilterOptions({
|
||||
...input,
|
||||
optionQuery: '乙',
|
||||
filters: { spu: { mode: 'include', values: ['SPU-a', 'SPU-b'] } },
|
||||
})
|
||||
expect(searched.values).toEqual(['采购乙'])
|
||||
|
||||
const purchaserConstrainedSpus = await dropship1688DataRepository.listFilterOptions({
|
||||
...input,
|
||||
field: 'spu',
|
||||
optionQuery: 'a',
|
||||
filters: { purchaser: { mode: 'include', values: ['采购乙'] } },
|
||||
})
|
||||
expect(purchaserConstrainedSpus.values).toEqual(['SPU-a'])
|
||||
expect(taskFindMany).toHaveBeenNthCalledWith(1, {
|
||||
where: { sourceBarcode: { in: ['DF-A', 'DF-B', 'DF-C'] } },
|
||||
distinct: ['purchaserName'],
|
||||
select: { purchaserName: true },
|
||||
})
|
||||
expect(taskFindMany).toHaveBeenNthCalledWith(2, {
|
||||
where: { sourceBarcode: { in: ['DF-A', 'DF-B'] } },
|
||||
distinct: ['purchaserName'],
|
||||
select: { purchaserName: true },
|
||||
})
|
||||
expect(taskFindMany).toHaveBeenNthCalledWith(3, {
|
||||
where: { purchaserName: { in: ['采购乙'] } },
|
||||
distinct: ['sourceBarcode'],
|
||||
select: { sourceBarcode: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('lists all unique options with other column filters applied and its own filter omitted', async () => {
|
||||
archiveFindMany.mockResolvedValue([
|
||||
{ ...archive('a', 'DF-A'), spu: 'SHARED' },
|
||||
|
||||
@ -0,0 +1,446 @@
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
supplyChainProductArchive: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
supplyChainProductArchiveSalesPerformanceDaily: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
dropship1688SourcingTask: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
update: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { dropship1688SourcingRepository } from '../dropship-1688-sourcing.repository'
|
||||
|
||||
const archiveFindUnique = prisma.supplyChainProductArchive.findUnique as jest.Mock
|
||||
const archiveFindMany = prisma.supplyChainProductArchive.findMany as jest.Mock
|
||||
const dailyFindMany = prisma.supplyChainProductArchiveSalesPerformanceDaily.findMany as jest.Mock
|
||||
const taskFindMany = prisma.dropship1688SourcingTask.findMany as jest.Mock
|
||||
const taskCount = prisma.dropship1688SourcingTask.count as jest.Mock
|
||||
const taskFindUnique = prisma.dropship1688SourcingTask.findUnique as jest.Mock
|
||||
const taskUpdate = prisma.dropship1688SourcingTask.update as jest.Mock
|
||||
const taskDeleteMany = prisma.dropship1688SourcingTask.deleteMany as jest.Mock
|
||||
const transactionQueryRaw = jest.fn()
|
||||
const transactionFindUnique = jest.fn()
|
||||
const transactionCount = jest.fn()
|
||||
const transactionCreate = jest.fn()
|
||||
const transaction = {
|
||||
$queryRaw: transactionQueryRaw,
|
||||
dropship1688SourcingTask: {
|
||||
findUnique: transactionFindUnique,
|
||||
count: transactionCount,
|
||||
create: transactionCreate,
|
||||
},
|
||||
}
|
||||
|
||||
const dateFrom = new Date('2026-09-01T00:00:00.000Z')
|
||||
const dateTo = new Date('2026-09-10T00:00:00.000Z')
|
||||
|
||||
function task(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'task-1',
|
||||
sourceArchiveId: 'archive-1',
|
||||
sourceSpu: 'SPU-ONE',
|
||||
sourceBarcode: 'DF-ONE',
|
||||
sourceCostPrice: new Prisma.Decimal('70'),
|
||||
sourceSkuOperator: '子美',
|
||||
sourceDateFrom: dateFrom,
|
||||
sourceDateTo: dateTo,
|
||||
purchaserUserId: 'buyer-1',
|
||||
purchaserName: '采购甲',
|
||||
supplierName: '',
|
||||
quotedPriceTaxIncluded: null,
|
||||
invoiceTaxPoint: null,
|
||||
sourcingMode: '',
|
||||
jianshangBarcode: '',
|
||||
acceptedAt: new Date('2026-09-16T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-16T01:00:00.000Z'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dropship1688SourcingRepository', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
archiveFindUnique.mockResolvedValue(null)
|
||||
archiveFindMany.mockResolvedValue([])
|
||||
dailyFindMany.mockResolvedValue([])
|
||||
taskFindMany.mockResolvedValue([])
|
||||
taskCount.mockResolvedValue(0)
|
||||
taskFindUnique.mockResolvedValue(null)
|
||||
taskUpdate.mockResolvedValue(task())
|
||||
taskDeleteMany.mockResolvedValue({ count: 1 })
|
||||
transactionQueryRaw.mockResolvedValue([{
|
||||
expected_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: 1,
|
||||
}])
|
||||
transactionFindUnique.mockResolvedValue(null)
|
||||
transactionCount.mockResolvedValue(0)
|
||||
transactionCreate.mockResolvedValue(task())
|
||||
jest.mocked(prisma.$transaction).mockImplementation(async (input) => (
|
||||
Array.isArray(input)
|
||||
? Promise.all(input)
|
||||
: input(transaction as never)
|
||||
) as never)
|
||||
})
|
||||
|
||||
it('loads and updates the editable sourcing fields with the shared task projection', async () => {
|
||||
taskFindUnique.mockResolvedValue(task())
|
||||
taskUpdate.mockResolvedValue(task({
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: new Prisma.Decimal('68.5'),
|
||||
invoiceTaxPoint: new Prisma.Decimal('13'),
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
}))
|
||||
|
||||
await expect(dropship1688SourcingRepository.findTaskById('task-1')).resolves.toEqual(task())
|
||||
expect(taskFindUnique).toHaveBeenCalledWith({ where: { id: 'task-1' }, select: expect.any(Object) })
|
||||
|
||||
const update = {
|
||||
id: 'task-1',
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: new Prisma.Decimal('68.5'),
|
||||
invoiceTaxPoint: new Prisma.Decimal('13'),
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
}
|
||||
await expect(dropship1688SourcingRepository.updateTask(update)).resolves.toMatchObject({
|
||||
supplierName: '供应商甲', sourcingMode: '代发',
|
||||
})
|
||||
expect(taskUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'task-1' },
|
||||
data: {
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: update.quotedPriceTaxIncluded,
|
||||
invoiceTaxPoint: update.invoiceTaxPoint,
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
},
|
||||
select: expect.any(Object),
|
||||
})
|
||||
})
|
||||
|
||||
it('atomically deletes only an own sourcing task whose jianshang barcode is empty', async () => {
|
||||
await expect(dropship1688SourcingRepository.deleteTaskIfRevocable({
|
||||
id: 'task-1',
|
||||
purchaserUserId: 'buyer-1',
|
||||
})).resolves.toBe(true)
|
||||
expect(taskDeleteMany).toHaveBeenCalledTimes(1)
|
||||
expect(taskDeleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'task-1',
|
||||
purchaserUserId: 'buyer-1',
|
||||
jianshangBarcode: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('reports when the atomic revoke predicate no longer matches', async () => {
|
||||
taskDeleteMany.mockResolvedValue({ count: 0 })
|
||||
await expect(dropship1688SourcingRepository.deleteTaskIfRevocable({
|
||||
id: 'task-1',
|
||||
purchaserUserId: 'buyer-1',
|
||||
})).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('recomputes the latest non-empty cost and operator independently inside the selected range', async () => {
|
||||
archiveFindUnique.mockResolvedValue({ id: 'archive-1', barcode: 'DF-ONE', spu: ' SPU-ONE ' })
|
||||
dailyFindMany.mockResolvedValue([
|
||||
{ unitCost: new Prisma.Decimal('72.5'), skuOperator: ' ' },
|
||||
{ unitCost: null, skuOperator: ' 子美 ' },
|
||||
{ unitCost: new Prisma.Decimal('65'), skuOperator: '旧运营' },
|
||||
])
|
||||
|
||||
await expect(dropship1688SourcingRepository.findClaimSource({
|
||||
barcode: 'DF-ONE', dateFrom, dateTo,
|
||||
})).resolves.toEqual({
|
||||
archiveId: 'archive-1',
|
||||
spu: 'SPU-ONE',
|
||||
barcode: 'DF-ONE',
|
||||
costPrice: new Prisma.Decimal('72.5'),
|
||||
skuOperator: '子美',
|
||||
})
|
||||
expect(dailyFindMany).toHaveBeenCalledWith({
|
||||
where: { barcode: 'DF-ONE', statDate: { gte: dateFrom, lte: dateTo } },
|
||||
select: { skuOperator: true, unitCost: true },
|
||||
orderBy: { statDate: 'desc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not read daily facts when the archive barcode does not exist', async () => {
|
||||
await expect(dropship1688SourcingRepository.findClaimSource({
|
||||
barcode: 'DF-MISSING', dateFrom, dateTo,
|
||||
})).resolves.toBeNull()
|
||||
expect(dailyFindMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the existing task for a repeated purchaser without consuming another slot', async () => {
|
||||
transactionFindUnique.mockResolvedValue(task())
|
||||
const source = {
|
||||
archiveId: 'archive-1',
|
||||
spu: 'SPU-ONE',
|
||||
barcode: 'DF-ONE',
|
||||
costPrice: new Prisma.Decimal('70'),
|
||||
skuOperator: '子美',
|
||||
}
|
||||
|
||||
await expect(dropship1688SourcingRepository.claim({
|
||||
source,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
purchaser: { id: 'buyer-1', name: '采购甲' },
|
||||
})).resolves.toEqual({ status: 'already-accepted', task: task() })
|
||||
|
||||
expect(transactionFindUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
sourceBarcode_purchaserUserId: {
|
||||
sourceBarcode: 'DF-ONE',
|
||||
purchaserUserId: 'buyer-1',
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
})
|
||||
expect(transactionCount).not.toHaveBeenCalled()
|
||||
expect(transactionCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes claims by barcode and allows the third purchaser', async () => {
|
||||
transactionCount.mockResolvedValue(2)
|
||||
transactionCreate.mockResolvedValue(task({
|
||||
id: 'task-3', purchaserUserId: 'buyer-3', purchaserName: '采购丙',
|
||||
}))
|
||||
const source = {
|
||||
archiveId: 'archive-1',
|
||||
spu: 'SPU-ONE',
|
||||
barcode: 'DF-ONE',
|
||||
costPrice: new Prisma.Decimal('70'),
|
||||
skuOperator: '子美',
|
||||
}
|
||||
|
||||
await expect(dropship1688SourcingRepository.claim({
|
||||
source,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
purchaser: { id: 'buyer-3', name: '采购丙' },
|
||||
})).resolves.toEqual({
|
||||
status: 'claimed',
|
||||
task: task({ id: 'task-3', purchaserUserId: 'buyer-3', purchaserName: '采购丙' }),
|
||||
})
|
||||
|
||||
expect(transactionQueryRaw).toHaveBeenCalledTimes(1)
|
||||
expect(transactionQueryRaw.mock.calls[0][0].join(' ')).toContain(
|
||||
'pg_advisory_xact_lock(hashtextextended(',
|
||||
)
|
||||
expect(transactionQueryRaw.mock.calls[0][0].join(' ')).toContain('::text AS lock_result')
|
||||
expect(transactionQueryRaw.mock.calls[0][1]).toBe('DF-ONE')
|
||||
expect(transactionCount).toHaveBeenCalledWith({ where: { sourceBarcode: 'DF-ONE' } })
|
||||
expect(transactionCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
sourceArchiveId: 'archive-1',
|
||||
sourceSpu: 'SPU-ONE',
|
||||
sourceBarcode: 'DF-ONE',
|
||||
sourceCostPrice: source.costPrice,
|
||||
sourceSkuOperator: '子美',
|
||||
sourceDateFrom: dateFrom,
|
||||
sourceDateTo: dateTo,
|
||||
purchaserUserId: 'buyer-3',
|
||||
purchaserName: '采购丙',
|
||||
},
|
||||
select: expect.any(Object),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a fourth purchaser without creating a task', async () => {
|
||||
transactionCount.mockResolvedValue(3)
|
||||
|
||||
await expect(dropship1688SourcingRepository.claim({
|
||||
source: {
|
||||
archiveId: 'archive-1',
|
||||
spu: 'SPU-ONE',
|
||||
barcode: 'DF-ONE',
|
||||
costPrice: new Prisma.Decimal('70'),
|
||||
skuOperator: '子美',
|
||||
},
|
||||
dateFrom,
|
||||
dateTo,
|
||||
purchaser: { id: 'buyer-4', name: '采购丁' },
|
||||
})).resolves.toEqual({ status: 'full' })
|
||||
|
||||
expect(transactionQueryRaw).toHaveBeenCalledTimes(1)
|
||||
expect(transactionCount).toHaveBeenCalledWith({ where: { sourceBarcode: 'DF-ONE' } })
|
||||
expect(transactionCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the accepted SPU snapshot while enriching current SKU and image data', async () => {
|
||||
taskFindMany.mockResolvedValue([
|
||||
task(),
|
||||
task({ id: 'task-2', sourceSpu: 'SPU-TWO', sourceBarcode: 'DF-TWO', purchaserUserId: 'buyer-2' }),
|
||||
])
|
||||
archiveFindMany.mockResolvedValue([
|
||||
{ barcode: 'DF-ONE', sku: ' SKU-ONE ', image: ' https://example.com/one.jpg ' },
|
||||
])
|
||||
|
||||
const result = await dropship1688SourcingRepository.listTasks()
|
||||
|
||||
expect(taskFindMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
orderBy: [{ acceptedAt: 'desc' }, { id: 'asc' }],
|
||||
}))
|
||||
expect(archiveFindMany).toHaveBeenCalledWith({
|
||||
where: { barcode: { in: ['DF-ONE', 'DF-TWO'] } },
|
||||
select: { barcode: true, sku: true, image: true },
|
||||
})
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ sourceBarcode: 'DF-ONE', sourceSpu: 'SPU-ONE', sku: 'SKU-ONE', hasImage: true }),
|
||||
expect.objectContaining({ sourceBarcode: 'DF-TWO', sourceSpu: 'SPU-TWO', sku: '', hasImage: false }),
|
||||
])
|
||||
})
|
||||
|
||||
it('counts filtered tasks before loading and enriching only the requested page', async () => {
|
||||
const filters = {
|
||||
purchaserNames: ['采购甲'],
|
||||
sourceSpus: ['SPU-ONE'],
|
||||
sourceSkuOperators: ['子美'],
|
||||
}
|
||||
const where = {
|
||||
purchaserName: { in: ['采购甲'] },
|
||||
sourceSpu: { in: ['SPU-ONE'] },
|
||||
sourceSkuOperator: { in: ['子美'] },
|
||||
}
|
||||
taskCount.mockResolvedValue(61)
|
||||
taskFindMany.mockResolvedValue([task()])
|
||||
archiveFindMany.mockResolvedValue([
|
||||
{ barcode: 'DF-ONE', sku: ' SKU-ONE ', image: ' https://example.com/one.jpg ' },
|
||||
])
|
||||
|
||||
await expect(dropship1688SourcingRepository.countTasks(filters)).resolves.toBe(61)
|
||||
await expect(dropship1688SourcingRepository.listTasksPage({
|
||||
filters, skip: 50, take: 50,
|
||||
})).resolves.toEqual([
|
||||
expect.objectContaining({ id: 'task-1', sku: 'SKU-ONE', hasImage: true }),
|
||||
])
|
||||
|
||||
expect(taskCount).toHaveBeenCalledWith({ where })
|
||||
expect(taskFindMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where,
|
||||
skip: 50,
|
||||
take: 50,
|
||||
orderBy: [{ acceptedAt: 'desc' }, { id: 'asc' }],
|
||||
}))
|
||||
expect(archiveFindMany).toHaveBeenCalledWith({
|
||||
where: { barcode: { in: ['DF-ONE'] } },
|
||||
select: { barcode: true, sku: true, image: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('loads all accepting purchasers for the requested barcodes', async () => {
|
||||
const records = [
|
||||
{ sourceBarcode: 'DF-ONE', purchaserUserId: 'buyer-1', purchaserName: '采购甲', acceptedAt: new Date() },
|
||||
{ sourceBarcode: 'DF-ONE', purchaserUserId: 'buyer-2', purchaserName: '采购乙', acceptedAt: new Date() },
|
||||
]
|
||||
taskFindMany.mockResolvedValue(records)
|
||||
|
||||
await expect(
|
||||
dropship1688SourcingRepository.listPurchasersByBarcodes(['DF-ONE', 'DF-TWO']),
|
||||
).resolves.toBe(records)
|
||||
expect(taskFindMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { sourceBarcode: { in: ['DF-ONE', 'DF-TWO'] } },
|
||||
orderBy: [{ sourceBarcode: 'asc' }, { acceptedAt: 'asc' }, { id: 'asc' }],
|
||||
}))
|
||||
})
|
||||
|
||||
it('loads import candidates by barcode and updates them with one set-based statement', async () => {
|
||||
taskFindMany.mockResolvedValue([task()])
|
||||
await expect(
|
||||
dropship1688SourcingRepository.listTasksByBarcodes(['DF-ONE']),
|
||||
).resolves.toEqual([task()])
|
||||
expect(taskFindMany).toHaveBeenCalledWith({
|
||||
where: { sourceBarcode: { in: ['DF-ONE'] } },
|
||||
select: expect.any(Object),
|
||||
orderBy: [{ sourceBarcode: 'asc' }, { purchaserName: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
|
||||
const update = {
|
||||
id: 'task-1', supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: new Prisma.Decimal('50'),
|
||||
invoiceTaxPoint: new Prisma.Decimal('13'), sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
}
|
||||
await dropship1688SourcingRepository.updateTasks([update])
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(transactionQueryRaw).toHaveBeenCalledTimes(1)
|
||||
expect(transactionQueryRaw.mock.calls[0][0].join(' ')).toContain('jsonb_to_recordset')
|
||||
expect(JSON.parse(transactionQueryRaw.mock.calls[0][1])).toEqual([{
|
||||
id: 'task-1',
|
||||
supplier_name: '供应商甲',
|
||||
quoted_price_tax_included: '50',
|
||||
invoice_tax_point: '13',
|
||||
sourcing_mode: '代发',
|
||||
jianshang_barcode: 'JS-001',
|
||||
}])
|
||||
expect(taskUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses one set-based write for a 50,000-row import', async () => {
|
||||
const updates = Array.from({ length: 50_000 }, (_, index) => ({
|
||||
id: `task-${index + 1}`,
|
||||
supplierName: `供应商${index + 1}`,
|
||||
quotedPriceTaxIncluded: new Prisma.Decimal('50'),
|
||||
invoiceTaxPoint: new Prisma.Decimal('13'),
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: `JS-${index + 1}`,
|
||||
}))
|
||||
transactionQueryRaw.mockResolvedValueOnce([{
|
||||
expected_count: updates.length,
|
||||
matched_count: updates.length,
|
||||
updated_count: updates.length,
|
||||
}])
|
||||
|
||||
await dropship1688SourcingRepository.updateTasks(updates)
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(jest.mocked(prisma.$transaction).mock.calls[0][1]).toEqual({
|
||||
maxWait: 10_000,
|
||||
timeout: 120_000,
|
||||
})
|
||||
expect(transactionQueryRaw).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(transactionQueryRaw.mock.calls[0][1])).toHaveLength(50_000)
|
||||
expect(taskUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts the transaction when the full payload does not match update targets', async () => {
|
||||
transactionQueryRaw.mockResolvedValueOnce([{
|
||||
expected_count: 2,
|
||||
matched_count: 1,
|
||||
updated_count: 0,
|
||||
}])
|
||||
const updates = ['task-1', 'task-missing'].map((id) => ({
|
||||
id,
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: null,
|
||||
invoiceTaxPoint: null,
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: '',
|
||||
}))
|
||||
|
||||
await expect(dropship1688SourcingRepository.updateTasks(updates)).rejects.toMatchObject({
|
||||
code: ErrorCode.VALIDATION_FAILED,
|
||||
message: '寻源任务状态已变化,导入未执行任何更新',
|
||||
})
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(transactionQueryRaw).toHaveBeenCalledTimes(1)
|
||||
expect(taskUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -23,6 +23,7 @@ const ARCHIVE_RANK_SELECT = {
|
||||
} satisfies Prisma.SupplyChainProductArchiveSelect
|
||||
|
||||
const ZERO_DECIMAL = new Prisma.Decimal(0)
|
||||
const PURCHASER_QUERY_CHUNK_SIZE = 5_000
|
||||
// Preserve the database's Decimal(24, 4) amounts when summing filtered groups.
|
||||
const SummaryDecimal = Prisma.Decimal.clone({ precision: 40 })
|
||||
|
||||
@ -41,18 +42,36 @@ type FilterInput = {
|
||||
filters?: Dropship1688Filters
|
||||
}
|
||||
|
||||
type FilterContext = {
|
||||
operators: Map<string, string>
|
||||
purchaserBarcodes: Set<string>
|
||||
}
|
||||
|
||||
function normalizedBarcode(value: string) {
|
||||
return value.trim().toUpperCase()
|
||||
}
|
||||
|
||||
function uniqueTextOptions(values: readonly string[]) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
}
|
||||
|
||||
function filterCandidates(
|
||||
candidates: ArchiveCandidate[],
|
||||
filters: Dropship1688Filters,
|
||||
operators: Map<string, string>,
|
||||
context: FilterContext,
|
||||
) {
|
||||
const predicates = FILTER_FIELDS.flatMap((field) => {
|
||||
const filter = filters[field]
|
||||
if (!filter) return []
|
||||
const values = new Set(filter.values)
|
||||
return [(candidate: ArchiveCandidate) => {
|
||||
if (field === 'purchaser') {
|
||||
const matched = context.purchaserBarcodes.has(normalizedBarcode(candidate.barcode))
|
||||
return matched === (filter.mode === 'include')
|
||||
}
|
||||
const value = field === 'skuOperator'
|
||||
? operators.get(candidate.barcode) ?? ''
|
||||
? context.operators.get(candidate.barcode) ?? ''
|
||||
: candidate[field]
|
||||
return values.has(value) === (filter.mode === 'include')
|
||||
}]
|
||||
@ -110,18 +129,66 @@ class Dropship1688DataRepository {
|
||||
return new Map([...latest].map(([barcode, record]) => [barcode, record.value]))
|
||||
}
|
||||
|
||||
async listFilterOptions(input: FilterInput & { field: Dropship1688FilterField }) {
|
||||
private async purchaserBarcodesForNames(names: readonly string[]) {
|
||||
const barcodes = new Set<string>()
|
||||
for (let offset = 0; offset < names.length; offset += PURCHASER_QUERY_CHUNK_SIZE) {
|
||||
const chunk = names.slice(offset, offset + PURCHASER_QUERY_CHUNK_SIZE)
|
||||
const tasks = await prisma.dropship1688SourcingTask.findMany({
|
||||
where: { purchaserName: { in: [...chunk] } },
|
||||
distinct: ['sourceBarcode'],
|
||||
select: { sourceBarcode: true },
|
||||
})
|
||||
tasks.forEach((task) => {
|
||||
const barcode = normalizedBarcode(task.sourceBarcode)
|
||||
if (barcode) barcodes.add(barcode)
|
||||
})
|
||||
}
|
||||
return barcodes
|
||||
}
|
||||
|
||||
private async purchaserNamesForBarcodes(barcodes: readonly string[]) {
|
||||
const names: string[] = []
|
||||
for (let offset = 0; offset < barcodes.length; offset += PURCHASER_QUERY_CHUNK_SIZE) {
|
||||
const chunk = barcodes.slice(offset, offset + PURCHASER_QUERY_CHUNK_SIZE)
|
||||
const tasks = await prisma.dropship1688SourcingTask.findMany({
|
||||
where: { sourceBarcode: { in: [...chunk] } },
|
||||
distinct: ['purchaserName'],
|
||||
select: { purchaserName: true },
|
||||
})
|
||||
names.push(...tasks.map((task) => task.purchaserName))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
async listFilterOptions(input: FilterInput & {
|
||||
field: Dropship1688FilterField
|
||||
optionQuery?: string
|
||||
}) {
|
||||
const filters = { ...input.filters }
|
||||
delete filters[input.field]
|
||||
const [candidates, operators] = await Promise.all([
|
||||
const [candidates, operators, purchaserBarcodes] = await Promise.all([
|
||||
this.candidates(input),
|
||||
input.field === 'skuOperator' || filters.skuOperator
|
||||
? this.latestOperators(input)
|
||||
: Promise.resolve(new Map<string, string>()),
|
||||
filters.purchaser
|
||||
? this.purchaserBarcodesForNames(filters.purchaser.values)
|
||||
: Promise.resolve(new Set<string>()),
|
||||
])
|
||||
const values = filterCandidates(candidates, filters, operators).map((candidate) =>
|
||||
input.field === 'skuOperator' ? operators.get(candidate.barcode) ?? '' : candidate[input.field],
|
||||
)
|
||||
const remainingCandidates = filterCandidates(candidates, filters, { operators, purchaserBarcodes })
|
||||
const optionQuery = input.optionQuery?.trim().toLocaleLowerCase('zh-CN') ?? ''
|
||||
const matchesOptionQuery = (value: string) => !optionQuery
|
||||
|| (value || '(空值)').toLocaleLowerCase('zh-CN').includes(optionQuery)
|
||||
if (input.field === 'purchaser') {
|
||||
const names = await this.purchaserNamesForBarcodes(
|
||||
remainingCandidates.map((candidate) => normalizedBarcode(candidate.barcode)),
|
||||
)
|
||||
return { values: uniqueTextOptions(names.filter(matchesOptionQuery)) }
|
||||
}
|
||||
const values = remainingCandidates
|
||||
.map((candidate) => input.field === 'skuOperator'
|
||||
? operators.get(candidate.barcode) ?? '' : candidate[input.field])
|
||||
.filter(matchesOptionQuery)
|
||||
return { values: [...new Set(values)].sort((left, right) => left.localeCompare(right, 'zh-CN')) }
|
||||
}
|
||||
|
||||
@ -142,7 +209,7 @@ class Dropship1688DataRepository {
|
||||
barcode: { startsWith: 'DF-' },
|
||||
} satisfies Prisma.SupplyChainProductArchiveSalesPerformanceDailyWhereInput
|
||||
|
||||
const [candidates, dailyPerformance, operators] = await Promise.all([
|
||||
const [candidates, dailyPerformance, operators, purchaserBarcodes] = await Promise.all([
|
||||
this.candidates(input),
|
||||
prisma.supplyChainProductArchiveSalesPerformanceDaily.groupBy({
|
||||
by: ['barcode'],
|
||||
@ -160,6 +227,9 @@ class Dropship1688DataRepository {
|
||||
input.filters?.skuOperator
|
||||
? this.latestOperators(input)
|
||||
: Promise.resolve(new Map<string, string>()),
|
||||
input.filters?.purchaser
|
||||
? this.purchaserBarcodesForNames(input.filters.purchaser.values)
|
||||
: Promise.resolve(new Set<string>()),
|
||||
])
|
||||
|
||||
const allPerformance = dailyPerformance.map((record) => ({
|
||||
@ -177,8 +247,16 @@ class Dropship1688DataRepository {
|
||||
const performanceByBarcode = new Map(
|
||||
allPerformance.map((record) => [record.barcode, record] as const),
|
||||
)
|
||||
const rankCandidates = filterCandidates(candidates, input.filters ?? {}, operators)
|
||||
const rankCandidates = filterCandidates(
|
||||
candidates,
|
||||
input.filters ?? {},
|
||||
{ operators, purchaserBarcodes },
|
||||
)
|
||||
const summary = {
|
||||
productsWithSalesAmount: rankCandidates.reduce((count, candidate) => {
|
||||
const displayedSalesAmount = performanceByBarcode.get(candidate.barcode)?._sum.shipAmount
|
||||
return count + Number(displayedSalesAmount?.greaterThan(0) ?? false)
|
||||
}, 0),
|
||||
_sum: rankCandidates.reduce((sum, candidate) => {
|
||||
const values = performanceByBarcode.get(candidate.barcode)?._sum
|
||||
return {
|
||||
@ -186,11 +264,15 @@ class Dropship1688DataRepository {
|
||||
shipAmount: sum.shipAmount === null || (values && values.shipAmount === null)
|
||||
? null : sum.shipAmount.plus(values?.shipAmount ?? ZERO_DECIMAL),
|
||||
realPerformance: sum.realPerformance.plus(values?.realPerformance ?? ZERO_DECIMAL),
|
||||
grossProfit: sum.grossProfit.plus(values?.grossProfit ?? ZERO_DECIMAL),
|
||||
afterSaleAmount: sum.afterSaleAmount.plus(values?.afterSaleAmount ?? ZERO_DECIMAL),
|
||||
}
|
||||
}, {
|
||||
shipQty: BigInt(0),
|
||||
shipAmount: new SummaryDecimal(0) as Prisma.Decimal | null,
|
||||
realPerformance: new SummaryDecimal(0),
|
||||
grossProfit: new SummaryDecimal(0),
|
||||
afterSaleAmount: new SummaryDecimal(0),
|
||||
}),
|
||||
}
|
||||
rankCandidates.sort((left, right) => {
|
||||
|
||||
@ -0,0 +1,414 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
DROPSHIP_1688_MAX_PURCHASERS,
|
||||
type Dropship1688SourcingFilters,
|
||||
} from '@/lib/dropship-1688-sourcing'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
|
||||
const TASK_SELECT = {
|
||||
id: true,
|
||||
sourceArchiveId: true,
|
||||
sourceSpu: true,
|
||||
sourceBarcode: true,
|
||||
sourceCostPrice: true,
|
||||
sourceSkuOperator: true,
|
||||
sourceDateFrom: true,
|
||||
sourceDateTo: true,
|
||||
purchaserUserId: true,
|
||||
purchaserName: true,
|
||||
supplierName: true,
|
||||
quotedPriceTaxIncluded: true,
|
||||
invoiceTaxPoint: true,
|
||||
sourcingMode: true,
|
||||
jianshangBarcode: true,
|
||||
acceptedAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.Dropship1688SourcingTaskSelect
|
||||
|
||||
const PURCHASER_SELECT = {
|
||||
sourceBarcode: true,
|
||||
purchaserUserId: true,
|
||||
purchaserName: true,
|
||||
acceptedAt: true,
|
||||
} satisfies Prisma.Dropship1688SourcingTaskSelect
|
||||
|
||||
export type Dropship1688SourcingTaskRecord = Prisma.Dropship1688SourcingTaskGetPayload<{
|
||||
select: typeof TASK_SELECT
|
||||
}>
|
||||
|
||||
export type Dropship1688SourcingPurchaserRecord = Prisma.Dropship1688SourcingTaskGetPayload<{
|
||||
select: typeof PURCHASER_SELECT
|
||||
}>
|
||||
|
||||
export type Dropship1688ClaimSource = {
|
||||
archiveId: string
|
||||
spu: string
|
||||
barcode: string
|
||||
costPrice: Prisma.Decimal | null
|
||||
skuOperator: string
|
||||
}
|
||||
|
||||
export type Dropship1688SourcingTaskWithProduct = Dropship1688SourcingTaskRecord & {
|
||||
sku: string
|
||||
hasImage: boolean
|
||||
}
|
||||
|
||||
export type Dropship1688ClaimResult =
|
||||
| { status: 'claimed' | 'already-accepted'; task: Dropship1688SourcingTaskRecord }
|
||||
| { status: 'full' }
|
||||
|
||||
export type Dropship1688SourcingFilterOptions = {
|
||||
purchaserNames: string[]
|
||||
sourceSpus: string[]
|
||||
sourceSkuOperators: string[]
|
||||
}
|
||||
|
||||
function taskWhere(filters: Dropship1688SourcingFilters): Prisma.Dropship1688SourcingTaskWhereInput {
|
||||
return {
|
||||
...(filters.purchaserNames.length > 0
|
||||
? { purchaserName: { in: filters.purchaserNames } }
|
||||
: {}),
|
||||
...(filters.sourceSpus.length > 0
|
||||
? { sourceSpu: { in: filters.sourceSpus } }
|
||||
: {}),
|
||||
...(filters.sourceSkuOperators.length > 0
|
||||
? { sourceSkuOperator: { in: filters.sourceSkuOperators } }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueOptions(values: readonly string[]) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
|
||||
}
|
||||
|
||||
class Dropship1688SourcingRepository {
|
||||
private async enrichTasks(
|
||||
tasks: Dropship1688SourcingTaskRecord[],
|
||||
): Promise<Dropship1688SourcingTaskWithProduct[]> {
|
||||
const barcodes = [...new Set(tasks.map((task) => task.sourceBarcode))]
|
||||
const archives = barcodes.length === 0
|
||||
? []
|
||||
: await prisma.supplyChainProductArchive.findMany({
|
||||
where: { barcode: { in: barcodes } },
|
||||
select: { barcode: true, sku: true, image: true },
|
||||
})
|
||||
const archiveByBarcode = new Map(archives.map((archive) => [archive.barcode, archive] as const))
|
||||
|
||||
return tasks.map((task) => {
|
||||
const archive = archiveByBarcode.get(task.sourceBarcode)
|
||||
return {
|
||||
...task,
|
||||
sku: archive?.sku.trim() ?? '',
|
||||
hasImage: Boolean(archive?.image?.trim()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async findTaskById(id: string): Promise<Dropship1688SourcingTaskRecord | null> {
|
||||
return prisma.dropship1688SourcingTask.findUnique({
|
||||
where: { id },
|
||||
select: TASK_SELECT,
|
||||
})
|
||||
}
|
||||
|
||||
async updateTask(input: {
|
||||
id: string
|
||||
supplierName: string
|
||||
quotedPriceTaxIncluded: Prisma.Decimal | null
|
||||
invoiceTaxPoint: Prisma.Decimal | null
|
||||
sourcingMode: string
|
||||
jianshangBarcode: string
|
||||
}): Promise<Dropship1688SourcingTaskRecord> {
|
||||
return prisma.dropship1688SourcingTask.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
supplierName: input.supplierName,
|
||||
quotedPriceTaxIncluded: input.quotedPriceTaxIncluded,
|
||||
invoiceTaxPoint: input.invoiceTaxPoint,
|
||||
sourcingMode: input.sourcingMode,
|
||||
jianshangBarcode: input.jianshangBarcode,
|
||||
},
|
||||
select: TASK_SELECT,
|
||||
})
|
||||
}
|
||||
|
||||
async deleteTaskIfRevocable(input: {
|
||||
id: string
|
||||
purchaserUserId: string
|
||||
}): Promise<boolean> {
|
||||
const deleted = await prisma.dropship1688SourcingTask.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
purchaserUserId: input.purchaserUserId,
|
||||
jianshangBarcode: '',
|
||||
},
|
||||
})
|
||||
return deleted.count === 1
|
||||
}
|
||||
|
||||
async listTasksByBarcodes(
|
||||
barcodes: readonly string[],
|
||||
): Promise<Dropship1688SourcingTaskRecord[]> {
|
||||
if (barcodes.length === 0) return []
|
||||
return prisma.dropship1688SourcingTask.findMany({
|
||||
where: { sourceBarcode: { in: [...barcodes] } },
|
||||
select: TASK_SELECT,
|
||||
orderBy: [{ sourceBarcode: 'asc' }, { purchaserName: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
async updateTasks(input: Array<{
|
||||
id: string
|
||||
supplierName: string
|
||||
quotedPriceTaxIncluded: Prisma.Decimal | null
|
||||
invoiceTaxPoint: Prisma.Decimal | null
|
||||
sourcingMode: string
|
||||
jianshangBarcode: string
|
||||
}>): Promise<void> {
|
||||
if (input.length === 0) return
|
||||
const payload = input.map((task) => ({
|
||||
id: task.id,
|
||||
supplier_name: task.supplierName,
|
||||
quoted_price_tax_included: task.quotedPriceTaxIncluded?.toString() ?? null,
|
||||
invoice_tax_point: task.invoiceTaxPoint?.toString() ?? null,
|
||||
sourcing_mode: task.sourcingMode,
|
||||
jianshang_barcode: task.jianshangBarcode,
|
||||
}))
|
||||
|
||||
await prisma.$transaction(async (transaction) => {
|
||||
const [result] = await transaction.$queryRaw<Array<{
|
||||
expected_count: number
|
||||
matched_count: number
|
||||
updated_count: number
|
||||
}>>`
|
||||
WITH payload AS (
|
||||
SELECT *
|
||||
FROM jsonb_to_recordset(${JSON.stringify(payload)}::jsonb) AS input(
|
||||
id text,
|
||||
supplier_name text,
|
||||
quoted_price_tax_included text,
|
||||
invoice_tax_point text,
|
||||
sourcing_mode text,
|
||||
jianshang_barcode text
|
||||
)
|
||||
),
|
||||
payload_stats AS (
|
||||
SELECT COUNT(*)::int AS total, COUNT(DISTINCT id)::int AS distinct_ids
|
||||
FROM payload
|
||||
),
|
||||
matched_stats AS (
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM payload
|
||||
INNER JOIN "Dropship1688SourcingTask" AS task ON task.id = payload.id
|
||||
),
|
||||
updated AS (
|
||||
UPDATE "Dropship1688SourcingTask" AS task
|
||||
SET
|
||||
"supplierName" = payload.supplier_name,
|
||||
"quoted_price_tax_included" = payload.quoted_price_tax_included::numeric(24, 4),
|
||||
"invoiceTaxPoint" = payload.invoice_tax_point::numeric(8, 4),
|
||||
"sourcingMode" = payload.sourcing_mode,
|
||||
"jianshangBarcode" = payload.jianshang_barcode,
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
FROM payload
|
||||
WHERE task.id = payload.id
|
||||
AND (
|
||||
SELECT payload_stats.total = payload_stats.distinct_ids
|
||||
AND payload_stats.total = matched_stats.total
|
||||
FROM payload_stats, matched_stats
|
||||
)
|
||||
RETURNING task.id
|
||||
)
|
||||
SELECT
|
||||
payload_stats.total AS expected_count,
|
||||
matched_stats.total AS matched_count,
|
||||
(SELECT COUNT(*)::int FROM updated) AS updated_count
|
||||
FROM payload_stats, matched_stats
|
||||
`
|
||||
if (!result
|
||||
|| result.expected_count !== input.length
|
||||
|| result.matched_count !== input.length
|
||||
|| result.updated_count !== input.length) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
'寻源任务状态已变化,导入未执行任何更新',
|
||||
)
|
||||
}
|
||||
}, { maxWait: 10_000, timeout: 120_000 })
|
||||
}
|
||||
|
||||
async findClaimSource(input: {
|
||||
barcode: string
|
||||
dateFrom: Date
|
||||
dateTo: Date
|
||||
}): Promise<Dropship1688ClaimSource | null> {
|
||||
const archive = await prisma.supplyChainProductArchive.findUnique({
|
||||
where: { barcode: input.barcode },
|
||||
select: { id: true, barcode: true, spu: true },
|
||||
})
|
||||
if (!archive) return null
|
||||
|
||||
const dailyRecords = await prisma.supplyChainProductArchiveSalesPerformanceDaily.findMany({
|
||||
where: {
|
||||
barcode: archive.barcode,
|
||||
statDate: { gte: input.dateFrom, lte: input.dateTo },
|
||||
},
|
||||
select: { skuOperator: true, unitCost: true },
|
||||
orderBy: { statDate: 'desc' },
|
||||
})
|
||||
|
||||
let costPrice: Prisma.Decimal | null = null
|
||||
let skuOperator = ''
|
||||
for (const record of dailyRecords) {
|
||||
if (costPrice === null && record.unitCost !== null) {
|
||||
costPrice = record.unitCost
|
||||
}
|
||||
const operator = record.skuOperator.trim()
|
||||
if (!skuOperator && operator) {
|
||||
skuOperator = operator
|
||||
}
|
||||
if (costPrice !== null && skuOperator) break
|
||||
}
|
||||
|
||||
return {
|
||||
archiveId: archive.id,
|
||||
spu: archive.spu.trim(),
|
||||
barcode: archive.barcode,
|
||||
costPrice,
|
||||
skuOperator,
|
||||
}
|
||||
}
|
||||
|
||||
async claim(input: {
|
||||
source: Dropship1688ClaimSource
|
||||
dateFrom: Date
|
||||
dateTo: Date
|
||||
purchaser: { id: string; name: string }
|
||||
}): Promise<Dropship1688ClaimResult> {
|
||||
return prisma.$transaction(async (transaction) => {
|
||||
// Serialize claims per barcode so two simultaneous clicks cannot create a fourth purchaser.
|
||||
await transaction.$queryRaw`
|
||||
SELECT pg_advisory_xact_lock(hashtextextended(${input.source.barcode}, 0))::text AS lock_result
|
||||
`
|
||||
|
||||
const existing = await transaction.dropship1688SourcingTask.findUnique({
|
||||
where: {
|
||||
sourceBarcode_purchaserUserId: {
|
||||
sourceBarcode: input.source.barcode,
|
||||
purchaserUserId: input.purchaser.id,
|
||||
},
|
||||
},
|
||||
select: TASK_SELECT,
|
||||
})
|
||||
if (existing) return { status: 'already-accepted', task: existing }
|
||||
|
||||
const purchaserCount = await transaction.dropship1688SourcingTask.count({
|
||||
where: { sourceBarcode: input.source.barcode },
|
||||
})
|
||||
if (purchaserCount >= DROPSHIP_1688_MAX_PURCHASERS) {
|
||||
return { status: 'full' }
|
||||
}
|
||||
|
||||
const task = await transaction.dropship1688SourcingTask.create({
|
||||
data: {
|
||||
sourceArchiveId: input.source.archiveId,
|
||||
sourceSpu: input.source.spu,
|
||||
sourceBarcode: input.source.barcode,
|
||||
sourceCostPrice: input.source.costPrice,
|
||||
sourceSkuOperator: input.source.skuOperator,
|
||||
sourceDateFrom: input.dateFrom,
|
||||
sourceDateTo: input.dateTo,
|
||||
purchaserUserId: input.purchaser.id,
|
||||
purchaserName: input.purchaser.name,
|
||||
},
|
||||
select: TASK_SELECT,
|
||||
})
|
||||
return { status: 'claimed', task }
|
||||
})
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Dropship1688SourcingTaskWithProduct[]> {
|
||||
const tasks = await prisma.dropship1688SourcingTask.findMany({
|
||||
select: TASK_SELECT,
|
||||
orderBy: [{ acceptedAt: 'desc' }, { id: 'asc' }],
|
||||
})
|
||||
return this.enrichTasks(tasks)
|
||||
}
|
||||
|
||||
async countTasks(filters: Dropship1688SourcingFilters): Promise<number> {
|
||||
return prisma.dropship1688SourcingTask.count({ where: taskWhere(filters) })
|
||||
}
|
||||
|
||||
async listTasksPage(input: {
|
||||
filters: Dropship1688SourcingFilters
|
||||
skip: number
|
||||
take: number
|
||||
}): Promise<Dropship1688SourcingTaskWithProduct[]> {
|
||||
const tasks = await prisma.dropship1688SourcingTask.findMany({
|
||||
where: taskWhere(input.filters),
|
||||
select: TASK_SELECT,
|
||||
orderBy: [{ acceptedAt: 'desc' }, { id: 'asc' }],
|
||||
skip: input.skip,
|
||||
take: input.take,
|
||||
})
|
||||
return this.enrichTasks(tasks)
|
||||
}
|
||||
|
||||
async listFilterOptions(
|
||||
filters: Dropship1688SourcingFilters,
|
||||
): Promise<Dropship1688SourcingFilterOptions> {
|
||||
const [purchasers, spus, operators] = await Promise.all([
|
||||
prisma.dropship1688SourcingTask.findMany({
|
||||
where: taskWhere({ ...filters, purchaserNames: [] }),
|
||||
distinct: ['purchaserName'],
|
||||
select: { purchaserName: true },
|
||||
orderBy: { purchaserName: 'asc' },
|
||||
}),
|
||||
prisma.dropship1688SourcingTask.findMany({
|
||||
where: taskWhere({ ...filters, sourceSpus: [] }),
|
||||
distinct: ['sourceSpu'],
|
||||
select: { sourceSpu: true },
|
||||
orderBy: { sourceSpu: 'asc' },
|
||||
}),
|
||||
prisma.dropship1688SourcingTask.findMany({
|
||||
where: taskWhere({ ...filters, sourceSkuOperators: [] }),
|
||||
distinct: ['sourceSkuOperator'],
|
||||
select: { sourceSkuOperator: true },
|
||||
orderBy: { sourceSkuOperator: 'asc' },
|
||||
}),
|
||||
])
|
||||
return {
|
||||
purchaserNames: uniqueOptions(purchasers.map((row) => row.purchaserName)),
|
||||
sourceSpus: uniqueOptions(spus.map((row) => row.sourceSpu)),
|
||||
sourceSkuOperators: uniqueOptions(operators.map((row) => row.sourceSkuOperator)),
|
||||
}
|
||||
}
|
||||
|
||||
async listTasksForExport(input: {
|
||||
filters: Dropship1688SourcingFilters
|
||||
take: number
|
||||
}): Promise<Dropship1688SourcingTaskRecord[]> {
|
||||
return prisma.dropship1688SourcingTask.findMany({
|
||||
where: taskWhere(input.filters),
|
||||
select: TASK_SELECT,
|
||||
orderBy: [{ acceptedAt: 'desc' }, { id: 'asc' }],
|
||||
take: input.take,
|
||||
})
|
||||
}
|
||||
|
||||
async listPurchasersByBarcodes(
|
||||
barcodes: readonly string[],
|
||||
): Promise<Dropship1688SourcingPurchaserRecord[]> {
|
||||
if (barcodes.length === 0) return []
|
||||
return prisma.dropship1688SourcingTask.findMany({
|
||||
where: { sourceBarcode: { in: [...barcodes] } },
|
||||
select: PURCHASER_SELECT,
|
||||
orderBy: [{ sourceBarcode: 'asc' }, { acceptedAt: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const dropship1688SourcingRepository = new Dropship1688SourcingRepository()
|
||||
export { Dropship1688SourcingRepository }
|
||||
@ -24,10 +24,13 @@ function pageResult(total = 1) {
|
||||
}],
|
||||
total,
|
||||
summary: {
|
||||
productsWithSalesAmount: 1,
|
||||
_sum: {
|
||||
shipQty: 1n,
|
||||
shipAmount: new Prisma.Decimal('29'),
|
||||
realPerformance: new Prisma.Decimal('67.23'),
|
||||
grossProfit: new Prisma.Decimal('38.23'),
|
||||
afterSaleAmount: new Prisma.Decimal('6.70'),
|
||||
},
|
||||
},
|
||||
performance: [{
|
||||
@ -82,6 +85,10 @@ describe('dropship1688DataService', () => {
|
||||
shipQty: '1',
|
||||
salesAmount: '29.0000',
|
||||
realPerformance: '67.2300',
|
||||
grossProfit: '38.2300',
|
||||
afterSaleCostRate: new Prisma.Decimal('6.70').dividedBy('67.23').toFixed(8),
|
||||
totalProducts: '1',
|
||||
productsWithSalesAmount: '1',
|
||||
},
|
||||
})
|
||||
expect(result.rows[0]).toMatchObject({
|
||||
@ -145,6 +152,44 @@ describe('dropship1688DataService', () => {
|
||||
expect(result.rows[0].realPerformance).toBe('67.2300')
|
||||
})
|
||||
|
||||
it('uses totals for all filtered products for gross profit and after-sales cost rate on every page', async () => {
|
||||
const source = pageResult(51)
|
||||
source.summary._sum.realPerformance = new Prisma.Decimal('200')
|
||||
source.summary._sum.grossProfit = new Prisma.Decimal('70')
|
||||
source.summary._sum.afterSaleAmount = new Prisma.Decimal('20')
|
||||
repository.listPage.mockResolvedValue(source as never)
|
||||
const filters = { skuOperator: { mode: 'include' as const, values: ['九千七'] } }
|
||||
|
||||
for (const page of [1, 2]) {
|
||||
const result = await dropship1688DataService.listPage({
|
||||
dateFrom: '2026-09-01', dateTo: '2026-09-07', keyword: ' DF ', filters, page,
|
||||
})
|
||||
expect(result.page).toBe(page)
|
||||
expect(result.summary).toMatchObject({
|
||||
realPerformance: '200.0000', grossProfit: '70.0000', afterSaleCostRate: '0.10000000',
|
||||
})
|
||||
expect(result.rows[0].grossProfit).toBe('38.2300')
|
||||
expect(result.rows[0].afterSaleCostRate).not.toBe(result.summary.afterSaleCostRate)
|
||||
expect(repository.listPage).toHaveBeenLastCalledWith({
|
||||
keyword: 'DF', dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-07'),
|
||||
filters, skip: (page - 1) * 50, take: 50,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the summary after-sales cost rate unknown when total real performance is zero', async () => {
|
||||
const source = pageResult()
|
||||
source.summary._sum.realPerformance = new Prisma.Decimal(0)
|
||||
source.summary._sum.grossProfit = new Prisma.Decimal('-3.12')
|
||||
repository.listPage.mockResolvedValue(source as never)
|
||||
|
||||
const result = await dropship1688DataService.listPage({})
|
||||
|
||||
expect(result.summary).toMatchObject({
|
||||
realPerformance: '0.0000', grossProfit: '-3.1200', afterSaleCostRate: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the last non-empty ider value in the selected period', async () => {
|
||||
const resultWithOperatorHistory = pageResult()
|
||||
resultWithOperatorHistory.trends = [
|
||||
@ -181,11 +226,23 @@ describe('dropship1688DataService', () => {
|
||||
const options = await dropship1688DataService.listFilterOptions({ field: 'sku', filters })
|
||||
expect(options.values).toEqual(['SKU-1'])
|
||||
expect(repository.listFilterOptions).toHaveBeenCalledWith({
|
||||
field: 'sku', keyword: '', filters,
|
||||
field: 'sku', keyword: '', optionQuery: '', filters,
|
||||
dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-09'),
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the product keyword and candidate query separate', async () => {
|
||||
repository.listFilterOptions.mockResolvedValue({ values: ['采购甲'] })
|
||||
|
||||
await dropship1688DataService.listFilterOptions({
|
||||
field: 'purchaser', keyword: ' 卧室 ', optionQuery: ' 采购甲 ',
|
||||
})
|
||||
|
||||
expect(repository.listFilterOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
field: 'purchaser', keyword: '卧室', optionQuery: '采购甲',
|
||||
}))
|
||||
})
|
||||
|
||||
it('retains filters when no daily data is available', async () => {
|
||||
repository.findAvailableDateRange.mockResolvedValue({
|
||||
_min: { statDate: null }, _max: { statDate: null },
|
||||
@ -194,6 +251,10 @@ describe('dropship1688DataService', () => {
|
||||
const result = await dropship1688DataService.listPage({ filters })
|
||||
expect(result.filters).toEqual(filters)
|
||||
expect(result.rows).toEqual([])
|
||||
expect(result.summary).toEqual({
|
||||
shipQty: '0', salesAmount: '0', realPerformance: '0', grossProfit: '0', afterSaleCostRate: '',
|
||||
totalProducts: '0', productsWithSalesAmount: '0',
|
||||
})
|
||||
expect(await dropship1688DataService.listFilterOptions({ field: 'skuOperator', filters })).toEqual({ values: [] })
|
||||
})
|
||||
|
||||
|
||||
@ -0,0 +1,595 @@
|
||||
jest.mock('@/server/repositories/dropship-1688-sourcing.repository', () => ({
|
||||
dropship1688SourcingRepository: {
|
||||
findClaimSource: jest.fn(),
|
||||
findTaskById: jest.fn(),
|
||||
updateTask: jest.fn(),
|
||||
deleteTaskIfRevocable: jest.fn(),
|
||||
listTasksByBarcodes: jest.fn(),
|
||||
updateTasks: jest.fn(),
|
||||
claim: jest.fn(),
|
||||
listTasks: jest.fn(),
|
||||
countTasks: jest.fn(),
|
||||
listTasksPage: jest.fn(),
|
||||
listFilterOptions: jest.fn(),
|
||||
listTasksForExport: jest.fn(),
|
||||
listPurchasersByBarcodes: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { Prisma } from '@prisma/client'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { dropship1688SourcingRepository } from '@/server/repositories/dropship-1688-sourcing.repository'
|
||||
import { dropship1688SourcingService } from '../dropship-1688-sourcing.service'
|
||||
|
||||
const repository = dropship1688SourcingRepository as unknown as {
|
||||
findClaimSource: jest.Mock
|
||||
findTaskById: jest.Mock
|
||||
updateTask: jest.Mock
|
||||
deleteTaskIfRevocable: jest.Mock
|
||||
listTasksByBarcodes: jest.Mock
|
||||
updateTasks: jest.Mock
|
||||
claim: jest.Mock
|
||||
listTasks: jest.Mock
|
||||
countTasks: jest.Mock
|
||||
listTasksPage: jest.Mock
|
||||
listFilterOptions: jest.Mock
|
||||
listTasksForExport: jest.Mock
|
||||
listPurchasersByBarcodes: jest.Mock
|
||||
}
|
||||
|
||||
const dateFrom = new Date('2026-09-01T00:00:00.000Z')
|
||||
const dateTo = new Date('2026-09-10T00:00:00.000Z')
|
||||
|
||||
function user(overrides: Partial<AuthUser> = {}): AuthUser {
|
||||
return {
|
||||
id: 'buyer-1',
|
||||
email: 'buyer@example.com',
|
||||
name: '采购甲',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['PURCHASING'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function task(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'task-1',
|
||||
sourceArchiveId: 'archive-1',
|
||||
sourceSpu: 'SPU-ONE',
|
||||
sourceBarcode: 'DF-ONE',
|
||||
sourceCostPrice: new Prisma.Decimal('70'),
|
||||
sourceSkuOperator: '子美',
|
||||
sourceDateFrom: dateFrom,
|
||||
sourceDateTo: dateTo,
|
||||
purchaserUserId: 'buyer-1',
|
||||
purchaserName: '采购甲',
|
||||
supplierName: '',
|
||||
quotedPriceTaxIncluded: null,
|
||||
invoiceTaxPoint: null,
|
||||
sourcingMode: '',
|
||||
jianshangBarcode: '',
|
||||
acceptedAt: new Date('2026-09-16T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-16T01:00:00.000Z'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dropship1688SourcingService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
repository.findClaimSource.mockResolvedValue({
|
||||
archiveId: 'archive-1',
|
||||
spu: 'SPU-ONE',
|
||||
barcode: 'DF-ONE',
|
||||
costPrice: new Prisma.Decimal('70'),
|
||||
skuOperator: '子美',
|
||||
})
|
||||
repository.claim.mockResolvedValue({ status: 'claimed', task: task() })
|
||||
repository.findTaskById.mockResolvedValue(task())
|
||||
repository.updateTask.mockResolvedValue(task())
|
||||
repository.deleteTaskIfRevocable.mockResolvedValue(true)
|
||||
repository.listTasksByBarcodes.mockResolvedValue([])
|
||||
repository.updateTasks.mockResolvedValue(undefined)
|
||||
repository.listTasks.mockResolvedValue([])
|
||||
repository.countTasks.mockResolvedValue(0)
|
||||
repository.listTasksPage.mockResolvedValue([])
|
||||
repository.listFilterOptions.mockResolvedValue({
|
||||
purchaserNames: [], sourceSpus: [], sourceSkuOperators: [],
|
||||
})
|
||||
repository.listTasksForExport.mockResolvedValue([])
|
||||
repository.listPurchasersByBarcodes.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('allows purchasing users and derives the purchaser identity only from the server actor', async () => {
|
||||
const result = await dropship1688SourcingService.claim({
|
||||
barcode: ' df-one ',
|
||||
dateFrom: '2026-09-01',
|
||||
dateTo: '2026-09-10',
|
||||
}, user({ name: ' 采购甲 ' }))
|
||||
|
||||
expect(repository.findClaimSource).toHaveBeenCalledWith({
|
||||
barcode: 'DF-ONE', dateFrom, dateTo,
|
||||
})
|
||||
expect(repository.claim).toHaveBeenCalledWith(expect.objectContaining({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
purchaser: { id: 'buyer-1', name: '采购甲' },
|
||||
}))
|
||||
expect(result).toMatchObject({
|
||||
sourceBarcode: 'DF-ONE',
|
||||
sourceSpu: 'SPU-ONE',
|
||||
sourceCostPrice: '70.0000',
|
||||
purchaserUserId: 'buyer-1',
|
||||
purchaserName: '采购甲',
|
||||
sourceDateFrom: '2026-09-01',
|
||||
sourceDateTo: '2026-09-10',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows an administrator without the purchasing business role', async () => {
|
||||
await expect(dropship1688SourcingService.claim({
|
||||
barcode: 'DF-ONE', dateFrom: '2026-09-01', dateTo: '2026-09-10',
|
||||
}, user({ role: 'SUPER_ADMIN', businessRoles: [] }))).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('updates all editable fields for the purchaser who accepted the task', async () => {
|
||||
repository.updateTask.mockResolvedValue(task({
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: new Prisma.Decimal('68.5'),
|
||||
invoiceTaxPoint: new Prisma.Decimal('13'),
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
}))
|
||||
|
||||
const result = await dropship1688SourcingService.update({
|
||||
taskId: 'task-1',
|
||||
supplierName: ' 供应商甲 ',
|
||||
quotedPriceTaxIncluded: '68.5',
|
||||
invoiceTaxPoint: '13',
|
||||
sourcingMode: ' 代发 ',
|
||||
jianshangBarcode: ' JS-001 ',
|
||||
}, user())
|
||||
|
||||
expect(repository.findTaskById).toHaveBeenCalledWith('task-1')
|
||||
expect(repository.updateTask).toHaveBeenCalledWith({
|
||||
id: 'task-1',
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: new Prisma.Decimal('68.5'),
|
||||
invoiceTaxPoint: new Prisma.Decimal('13'),
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: '68.5000',
|
||||
invoiceTaxPoint: '13.0000',
|
||||
sourcingMode: '代发',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows administrators to update another purchaser task', async () => {
|
||||
repository.findTaskById.mockResolvedValue(task({ purchaserUserId: 'buyer-2' }))
|
||||
await expect(dropship1688SourcingService.update({
|
||||
taskId: 'task-1', supplierName: '', quotedPriceTaxIncluded: '', invoiceTaxPoint: '',
|
||||
sourcingMode: '', jianshangBarcode: '',
|
||||
}, user({ role: 'ADMIN', businessRoles: [] }))).resolves.toBeDefined()
|
||||
expect(repository.updateTask).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects another purchaser and non-purchasing users before writing', async () => {
|
||||
repository.findTaskById.mockResolvedValue(task({ purchaserUserId: 'buyer-2' }))
|
||||
const input = {
|
||||
taskId: 'task-1', supplierName: '', quotedPriceTaxIncluded: '', invoiceTaxPoint: '',
|
||||
sourcingMode: '', jianshangBarcode: '',
|
||||
}
|
||||
await expect(dropship1688SourcingService.update(input, user())).rejects.toMatchObject({
|
||||
code: ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
message: '只能修改本人承接的寻源任务',
|
||||
})
|
||||
await expect(dropship1688SourcingService.update(
|
||||
input,
|
||||
user({ businessRoles: ['OPERATIONS'] }),
|
||||
)).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
|
||||
expect(repository.updateTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ quotedPriceTaxIncluded: '-1', invoiceTaxPoint: '' },
|
||||
{ quotedPriceTaxIncluded: '1.12345', invoiceTaxPoint: '' },
|
||||
{ quotedPriceTaxIncluded: '', invoiceTaxPoint: '100.0001' },
|
||||
{ quotedPriceTaxIncluded: '', invoiceTaxPoint: '101' },
|
||||
])('rejects invalid sourcing decimals: %j', async (values) => {
|
||||
await expect(dropship1688SourcingService.update({
|
||||
taskId: 'task-1', supplierName: '', sourcingMode: '',
|
||||
jianshangBarcode: '', ...values,
|
||||
}, user())).rejects.toMatchObject({ code: ErrorCode.VALIDATION_INVALID_FORMAT })
|
||||
expect(repository.findTaskById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects sourcing modes outside the two supported choices', async () => {
|
||||
await expect(dropship1688SourcingService.update({
|
||||
taskId: 'task-1', supplierName: '', quotedPriceTaxIncluded: '', invoiceTaxPoint: '',
|
||||
sourcingMode: '一件代发', jianshangBarcode: '',
|
||||
}, user())).rejects.toMatchObject({
|
||||
code: ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
message: '模式只能选择入库或代发',
|
||||
})
|
||||
expect(repository.findTaskById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing sourcing task without writing', async () => {
|
||||
repository.findTaskById.mockResolvedValue(null)
|
||||
await expect(dropship1688SourcingService.update({
|
||||
taskId: 'missing', supplierName: '', quotedPriceTaxIncluded: '', invoiceTaxPoint: '',
|
||||
sourcingMode: '', jianshangBarcode: '',
|
||||
}, user())).rejects.toMatchObject({ code: ErrorCode.PRODUCT_NOT_FOUND })
|
||||
expect(repository.updateTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes the purchaser own acceptance and deletes exactly that task', async () => {
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user(),
|
||||
)).resolves.toEqual({ id: 'task-1' })
|
||||
expect(repository.findTaskById).toHaveBeenCalledWith('task-1')
|
||||
expect(repository.deleteTaskIfRevocable).toHaveBeenCalledWith({
|
||||
id: 'task-1', purchaserUserId: 'buyer-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let an administrator revoke another purchaser acceptance', async () => {
|
||||
repository.findTaskById.mockResolvedValue(
|
||||
task({ purchaserUserId: 'buyer-2', purchaserName: '采购乙' }),
|
||||
)
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user({ role: 'ADMIN', businessRoles: [] }),
|
||||
)).rejects.toMatchObject({
|
||||
code: ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
message: '只能撤销本人承接的寻源任务',
|
||||
})
|
||||
expect(repository.deleteTaskIfRevocable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to revoke an acceptance owned by another purchaser', async () => {
|
||||
repository.findTaskById.mockResolvedValue(
|
||||
task({ purchaserUserId: 'buyer-2', purchaserName: '采购乙' }),
|
||||
)
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user(),
|
||||
)).rejects.toMatchObject({
|
||||
code: ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
message: '只能撤销本人承接的寻源任务',
|
||||
})
|
||||
expect(repository.deleteTaskIfRevocable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to revoke an acceptance that already has a jianshang barcode', async () => {
|
||||
repository.findTaskById.mockResolvedValue(task({ jianshangBarcode: ' JS-001 ' }))
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user(),
|
||||
)).rejects.toMatchObject({
|
||||
code: ErrorCode.VALIDATION_FAILED,
|
||||
message: '该寻源任务已填写简尚条码,不能撤销',
|
||||
})
|
||||
expect(repository.deleteTaskIfRevocable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies the ownership rule before the jianshang barcode rule', async () => {
|
||||
repository.findTaskById.mockResolvedValue(task({
|
||||
purchaserUserId: 'buyer-2', purchaserName: '采购乙', jianshangBarcode: 'JS-001',
|
||||
}))
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user(),
|
||||
)).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
|
||||
expect(repository.deleteTaskIfRevocable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to revoke without the purchasing role before reading the task', async () => {
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user({ businessRoles: ['OPERATIONS'] }),
|
||||
)).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
|
||||
expect(repository.findTaskById).not.toHaveBeenCalled()
|
||||
expect(repository.deleteTaskIfRevocable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to revoke a missing task', async () => {
|
||||
repository.findTaskById.mockResolvedValue(null)
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'missing' },
|
||||
user(),
|
||||
)).rejects.toMatchObject({ code: ErrorCode.PRODUCT_NOT_FOUND })
|
||||
expect(repository.deleteTaskIfRevocable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps an atomic revoke miss to a newly filled jianshang barcode', async () => {
|
||||
repository.findTaskById
|
||||
.mockResolvedValueOnce(task())
|
||||
.mockResolvedValueOnce(task({ jianshangBarcode: 'JS-001' }))
|
||||
repository.deleteTaskIfRevocable.mockResolvedValue(false)
|
||||
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user(),
|
||||
)).rejects.toMatchObject({
|
||||
code: ErrorCode.VALIDATION_FAILED,
|
||||
message: '该寻源任务已填写简尚条码,不能撤销',
|
||||
})
|
||||
expect(repository.findTaskById).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('maps an atomic revoke miss to a concurrently removed task', async () => {
|
||||
repository.findTaskById
|
||||
.mockResolvedValueOnce(task())
|
||||
.mockResolvedValueOnce(null)
|
||||
repository.deleteTaskIfRevocable.mockResolvedValue(false)
|
||||
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user(),
|
||||
)).rejects.toMatchObject({
|
||||
code: ErrorCode.PRODUCT_NOT_FOUND,
|
||||
message: '寻源任务不存在',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an atomic revoke miss to an ownership mismatch', async () => {
|
||||
repository.findTaskById
|
||||
.mockResolvedValueOnce(task())
|
||||
.mockResolvedValueOnce(task({ purchaserUserId: 'buyer-2', purchaserName: '采购乙' }))
|
||||
repository.deleteTaskIfRevocable.mockResolvedValue(false)
|
||||
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user(),
|
||||
)).rejects.toMatchObject({
|
||||
code: ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
message: '只能撤销本人承接的寻源任务',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an unexplained atomic revoke miss to a refreshable state conflict', async () => {
|
||||
repository.deleteTaskIfRevocable.mockResolvedValue(false)
|
||||
|
||||
await expect(dropship1688SourcingService.revoke(
|
||||
{ taskId: 'task-1' },
|
||||
user(),
|
||||
)).rejects.toMatchObject({
|
||||
code: ErrorCode.VALIDATION_FAILED,
|
||||
message: '寻源任务状态已变化,请刷新页面后重试',
|
||||
})
|
||||
expect(repository.findTaskById).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ taskId: '' },
|
||||
{ taskId: ' ' },
|
||||
{ taskId: 'task-1', purchaserName: '伪造采购' },
|
||||
])('rejects invalid revoke input: %j', async (input) => {
|
||||
await expect(dropship1688SourcingService.revoke(input, user()))
|
||||
.rejects.toMatchObject({ code: ErrorCode.VALIDATION_INVALID_FORMAT })
|
||||
expect(repository.findTaskById).not.toHaveBeenCalled()
|
||||
expect(repository.deleteTaskIfRevocable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a fourth purchaser when all three slots are already filled', async () => {
|
||||
repository.claim.mockResolvedValue({ status: 'full' })
|
||||
|
||||
await expect(dropship1688SourcingService.claim({
|
||||
barcode: 'DF-ONE', dateFrom: '2026-09-01', dateTo: '2026-09-10',
|
||||
}, user())).rejects.toMatchObject({
|
||||
code: ErrorCode.VALIDATION_OUT_OF_RANGE,
|
||||
message: '该商品最多由3名采购承接',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-purchasing users before reading product data', async () => {
|
||||
await expect(dropship1688SourcingService.claim({
|
||||
barcode: 'DF-ONE', dateFrom: '2026-09-01', dateTo: '2026-09-10',
|
||||
}, user({ businessRoles: ['OPERATIONS'] }))).rejects.toMatchObject({
|
||||
code: ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
})
|
||||
expect(repository.findClaimSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ barcode: 'SKU-ONE', dateFrom: '2026-09-01', dateTo: '2026-09-10' },
|
||||
{ barcode: 'DF-ONE', dateFrom: '2026-02-31', dateTo: '2026-09-10' },
|
||||
{ barcode: 'DF-ONE', dateFrom: '2026-09-11', dateTo: '2026-09-10' },
|
||||
{ barcode: 'DF-ONE', dateFrom: '2026-09-01', dateTo: '2026-09-10', purchaserName: '伪造采购' },
|
||||
])('rejects invalid claim input: %j', async (input) => {
|
||||
await expect(dropship1688SourcingService.claim(input, user())).rejects.toMatchObject({
|
||||
code: ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
})
|
||||
expect(repository.findClaimSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing or non-DF archive after the authoritative lookup', async () => {
|
||||
repository.findClaimSource.mockResolvedValueOnce(null)
|
||||
await expect(dropship1688SourcingService.claim({
|
||||
barcode: 'DF-MISSING', dateFrom: '2026-09-01', dateTo: '2026-09-10',
|
||||
}, user())).rejects.toMatchObject({ code: ErrorCode.PRODUCT_NOT_FOUND })
|
||||
|
||||
repository.findClaimSource.mockResolvedValueOnce({
|
||||
archiveId: 'archive-legacy', spu: 'SPU-LEGACY', barcode: 'SKU-LEGACY', costPrice: null, skuOperator: '',
|
||||
})
|
||||
await expect(dropship1688SourcingService.claim({
|
||||
barcode: 'DF-ONE', dateFrom: '2026-09-01', dateTo: '2026-09-10',
|
||||
}, user())).rejects.toMatchObject({ code: ErrorCode.PRODUCT_NOT_FOUND })
|
||||
expect(repository.claim).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('formats the accepted SPU snapshot while preserving current SKU and image enrichment', async () => {
|
||||
repository.listTasks.mockResolvedValue([
|
||||
task({ quotedPriceTaxIncluded: new Prisma.Decimal('62.3456'), invoiceTaxPoint: new Prisma.Decimal('13'), sku: 'SKU-ONE', hasImage: true }),
|
||||
task({
|
||||
id: 'task-2', sourceSpu: '', sourceBarcode: 'DF-TWO', sourceCostPrice: null,
|
||||
purchaserUserId: 'buyer-2', quotedPriceTaxIncluded: null, sku: '', hasImage: false,
|
||||
}),
|
||||
])
|
||||
|
||||
await expect(dropship1688SourcingService.listTasks()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
sourceBarcode: 'DF-ONE', sourceCostPrice: '70.0000', quotedPriceTaxIncluded: '62.3456',
|
||||
invoiceTaxPoint: '13.0000',
|
||||
sourceSpu: 'SPU-ONE', sku: 'SKU-ONE', hasImage: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sourceBarcode: 'DF-TWO', sourceCostPrice: null, quotedPriceTaxIncluded: null,
|
||||
sourceSpu: '', sku: '', hasImage: false,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps an out-of-range sourcing page to the last available page', async () => {
|
||||
const filters = {
|
||||
purchaserNames: ['采购甲'],
|
||||
sourceSpus: [],
|
||||
sourceSkuOperators: [],
|
||||
}
|
||||
repository.countTasks.mockResolvedValue(61)
|
||||
repository.listTasksPage.mockResolvedValue([task({ id: 'task-last' })])
|
||||
repository.listFilterOptions.mockResolvedValue({
|
||||
purchaserNames: ['采购甲'], sourceSpus: ['SPU-ONE'], sourceSkuOperators: ['子美'],
|
||||
})
|
||||
|
||||
const result = await dropship1688SourcingService.listPage({
|
||||
page: 9, pageSize: 50, filters,
|
||||
})
|
||||
|
||||
expect(repository.countTasks).toHaveBeenCalledWith(filters)
|
||||
expect(repository.listTasksPage).toHaveBeenCalledTimes(1)
|
||||
expect(repository.listTasksPage).toHaveBeenCalledWith({
|
||||
filters, skip: 50, take: 50,
|
||||
})
|
||||
expect(repository.listFilterOptions).toHaveBeenCalledTimes(1)
|
||||
expect(result).toMatchObject({
|
||||
total: 61,
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
totalPages: 2,
|
||||
rows: [expect.objectContaining({ id: 'task-last' })],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not query a task page when the filtered count is zero', async () => {
|
||||
const filters = { purchaserNames: [], sourceSpus: [], sourceSkuOperators: [] }
|
||||
|
||||
const result = await dropship1688SourcingService.listPage({
|
||||
page: 1, pageSize: 50, filters,
|
||||
})
|
||||
|
||||
expect(repository.countTasks).toHaveBeenCalledWith(filters)
|
||||
expect(repository.listTasksPage).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({ total: 0, page: 1, totalPages: 1, rows: [] })
|
||||
})
|
||||
|
||||
it('imports editable fields by the exact barcode and purchaser pair in one write', async () => {
|
||||
repository.listTasksByBarcodes.mockResolvedValue([
|
||||
task(),
|
||||
task({ id: 'task-2', purchaserUserId: 'buyer-2', purchaserName: '采购乙' }),
|
||||
])
|
||||
|
||||
await expect(dropship1688SourcingService.importRows([
|
||||
{
|
||||
rowNumber: 2,
|
||||
sourceBarcode: 'df-one',
|
||||
purchaserName: '采购甲',
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: '88.5',
|
||||
invoiceTaxPoint: '13',
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
},
|
||||
], user())).resolves.toEqual({ updated: 1 })
|
||||
|
||||
expect(repository.listTasksByBarcodes).toHaveBeenCalledWith(['DF-ONE'])
|
||||
expect(repository.updateTasks).toHaveBeenCalledWith([{
|
||||
id: 'task-1',
|
||||
supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: new Prisma.Decimal('88.5'),
|
||||
invoiceTaxPoint: new Prisma.Decimal('13'),
|
||||
sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects duplicate workbook keys before reading or writing tasks', async () => {
|
||||
const row = {
|
||||
sourceBarcode: 'DF-ONE', purchaserName: '采购甲', supplierName: '',
|
||||
quotedPriceTaxIncluded: '', invoiceTaxPoint: '', sourcingMode: '', jianshangBarcode: '',
|
||||
}
|
||||
await expect(dropship1688SourcingService.importRows([
|
||||
{ rowNumber: 2, ...row },
|
||||
{ rowNumber: 4, ...row },
|
||||
], user())).rejects.toMatchObject({
|
||||
code: ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
message: '第4行与第2行的代发条码和采购重复',
|
||||
})
|
||||
expect(repository.listTasksByBarcodes).not.toHaveBeenCalled()
|
||||
expect(repository.updateTasks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a purchaser import another purchaser row', async () => {
|
||||
repository.listTasksByBarcodes.mockResolvedValue([
|
||||
task({ id: 'task-2', purchaserUserId: 'buyer-2', purchaserName: '采购乙' }),
|
||||
])
|
||||
await expect(dropship1688SourcingService.importRows([{
|
||||
rowNumber: 2, sourceBarcode: 'DF-ONE', purchaserName: '采购乙', supplierName: '',
|
||||
quotedPriceTaxIncluded: '', invoiceTaxPoint: '', sourcingMode: '', jianshangBarcode: '',
|
||||
}], user())).rejects.toMatchObject({
|
||||
code: ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
message: '第2行:只能导入本人承接的寻源任务',
|
||||
})
|
||||
expect(repository.updateTasks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates every import row before writing any update', async () => {
|
||||
repository.listTasksByBarcodes.mockResolvedValue([task()])
|
||||
|
||||
await expect(dropship1688SourcingService.importRows([
|
||||
{
|
||||
rowNumber: 2, sourceBarcode: 'DF-ONE', purchaserName: '采购甲', supplierName: '供应商甲',
|
||||
quotedPriceTaxIncluded: '88.5', invoiceTaxPoint: '13', sourcingMode: '代发',
|
||||
jianshangBarcode: 'JS-001',
|
||||
},
|
||||
{
|
||||
rowNumber: 3, sourceBarcode: 'DF-TWO', purchaserName: '采购甲', supplierName: '供应商乙',
|
||||
quotedPriceTaxIncluded: '66', invoiceTaxPoint: '13', sourcingMode: '入库',
|
||||
jianshangBarcode: 'JS-002',
|
||||
},
|
||||
], user())).rejects.toMatchObject({
|
||||
code: ErrorCode.PRODUCT_NOT_FOUND,
|
||||
message: '第3行:未找到代发条码 DF-TWO 与采购 采购甲 的承接记录',
|
||||
})
|
||||
|
||||
expect(repository.updateTasks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes requested barcodes and groups multiple accepting purchasers', async () => {
|
||||
repository.listPurchasersByBarcodes.mockResolvedValue([
|
||||
{ sourceBarcode: 'DF-ONE', purchaserUserId: 'buyer-1', purchaserName: '采购甲', acceptedAt: new Date('2026-09-16T01:00:00.000Z') },
|
||||
{ sourceBarcode: 'DF-ONE', purchaserUserId: 'buyer-2', purchaserName: '采购乙', acceptedAt: new Date('2026-09-16T02:00:00.000Z') },
|
||||
])
|
||||
|
||||
const result = await dropship1688SourcingService.listPurchasersByBarcodes([
|
||||
' df-one ', 'DF-ONE', 'DF-TWO', 'SKU-IGNORED',
|
||||
])
|
||||
|
||||
expect(repository.listPurchasersByBarcodes).toHaveBeenCalledWith(['DF-ONE', 'DF-TWO'])
|
||||
expect(result).toEqual({
|
||||
'DF-ONE': [
|
||||
{ userId: 'buyer-1', name: '采购甲', acceptedAt: '2026-09-16T01:00:00.000Z' },
|
||||
{ userId: 'buyer-2', name: '采购乙', acceptedAt: '2026-09-16T02:00:00.000Z' },
|
||||
],
|
||||
'DF-TWO': [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -95,7 +95,15 @@ class Dropship1688DataService {
|
||||
page: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
totalPages: 1,
|
||||
summary: { shipQty: '0', salesAmount: '0', realPerformance: '0' },
|
||||
summary: {
|
||||
shipQty: '0',
|
||||
salesAmount: '0',
|
||||
realPerformance: '0',
|
||||
grossProfit: '0',
|
||||
afterSaleCostRate: '',
|
||||
totalProducts: '0',
|
||||
productsWithSalesAmount: '0',
|
||||
},
|
||||
dataUpdatedThrough: '',
|
||||
filters: input.filters ?? {},
|
||||
}
|
||||
@ -186,6 +194,13 @@ class Dropship1688DataService {
|
||||
shipQty: result.summary._sum.shipQty?.toString() ?? '0',
|
||||
salesAmount: decimalText(result.summary._sum.shipAmount),
|
||||
realPerformance: decimalText(result.summary._sum.realPerformance) || '0',
|
||||
grossProfit: decimalText(result.summary._sum.grossProfit) || '0',
|
||||
afterSaleCostRate: percentageText(
|
||||
result.summary._sum.afterSaleAmount,
|
||||
result.summary._sum.realPerformance,
|
||||
),
|
||||
totalProducts: String(result.total),
|
||||
productsWithSalesAmount: String(result.summary.productsWithSalesAmount),
|
||||
},
|
||||
dataUpdatedThrough: dateText(availableTo),
|
||||
filters: input.filters ?? {},
|
||||
@ -195,6 +210,7 @@ class Dropship1688DataService {
|
||||
async listFilterOptions(input: {
|
||||
field: Dropship1688FilterField
|
||||
keyword?: string
|
||||
optionQuery?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
filters?: Dropship1688Filters
|
||||
@ -209,6 +225,7 @@ class Dropship1688DataService {
|
||||
return dropship1688DataRepository.listFilterOptions({
|
||||
field: input.field,
|
||||
keyword: input.keyword?.trim() ?? '',
|
||||
optionQuery: input.optionQuery?.trim() ?? '',
|
||||
dateFrom,
|
||||
dateTo,
|
||||
filters: input.filters,
|
||||
|
||||
@ -0,0 +1,446 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
DROPSHIP_1688_MAX_PURCHASERS,
|
||||
DROPSHIP_1688_SOURCING_EXPORT_LIMIT,
|
||||
type Dropship1688SourcingFilters,
|
||||
} from '@/lib/dropship-1688-sourcing'
|
||||
import { hasBusinessRole, isAdmin } from '@/lib/role-scope'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import {
|
||||
dropship1688SourcingRepository,
|
||||
type Dropship1688SourcingPurchaserRecord,
|
||||
type Dropship1688SourcingRepository,
|
||||
type Dropship1688SourcingTaskRecord,
|
||||
type Dropship1688SourcingTaskWithProduct,
|
||||
} from '@/server/repositories/dropship-1688-sourcing.repository'
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
const dateSchema = z.string().regex(DATE_PATTERN).refine((value) => {
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
})
|
||||
|
||||
const claimSchema = z.object({
|
||||
barcode: z.string().trim().min(4).max(100).regex(/^DF-.+/i).transform((value) => value.toUpperCase()),
|
||||
dateFrom: dateSchema,
|
||||
dateTo: dateSchema,
|
||||
}).strict().refine((value) => value.dateFrom <= value.dateTo, {
|
||||
message: '开始日期不能晚于结束日期',
|
||||
path: ['dateFrom'],
|
||||
})
|
||||
|
||||
const priceSchema = z.string().trim().refine(
|
||||
(value) => value === '' || /^(?:0|[1-9]\d{0,19})(?:\.\d{1,4})?$/.test(value),
|
||||
'报价必须是非负数,且最多保留4位小数',
|
||||
)
|
||||
|
||||
const taxPointSchema = z.string().trim().refine((value) => {
|
||||
if (value === '' || !/^(?:0|[1-9]\d{0,2})(?:\.\d{1,4})?$/.test(value)) return value === ''
|
||||
return new Prisma.Decimal(value).lte(100)
|
||||
}, '发票税点必须是0到100之间的数字,且最多保留4位小数')
|
||||
|
||||
const updateSchema = z.object({
|
||||
taskId: z.string().trim().min(1).max(100),
|
||||
supplierName: z.string().trim().max(200, '供应商不能超过200个字符'),
|
||||
quotedPriceTaxIncluded: priceSchema,
|
||||
invoiceTaxPoint: taxPointSchema,
|
||||
sourcingMode: z.string().trim().pipe(
|
||||
z.enum(['', '入库', '代发'], { message: '模式只能选择入库或代发' }),
|
||||
),
|
||||
jianshangBarcode: z.string().trim().max(100, '简尚条码不能超过100个字符'),
|
||||
}).strict()
|
||||
|
||||
const revokeSchema = z.object({
|
||||
taskId: z.string().trim().min(1).max(100),
|
||||
}).strict()
|
||||
|
||||
const importRowSchema = z.object({
|
||||
rowNumber: z.number().int().min(2),
|
||||
sourceBarcode: z.string().trim().min(1, '代发条码不能为空').max(100)
|
||||
.regex(/^DF-.+/i, '代发条码格式不正确')
|
||||
.transform((value) => value.toUpperCase()),
|
||||
purchaserName: z.string().trim().min(1, '采购不能为空').max(200),
|
||||
supplierName: z.string().trim().max(200, '供应商不能超过200个字符'),
|
||||
quotedPriceTaxIncluded: priceSchema,
|
||||
invoiceTaxPoint: taxPointSchema,
|
||||
sourcingMode: z.string().trim().pipe(
|
||||
z.enum(['', '入库', '代发'], { message: '模式只能选择入库或代发' }),
|
||||
),
|
||||
jianshangBarcode: z.string().trim().max(100, '简尚条码不能超过100个字符'),
|
||||
}).strict()
|
||||
|
||||
const sourcingFilterSchema = z.object({
|
||||
purchaserNames: z.array(z.string().trim().min(1).max(200)).max(100).default([]),
|
||||
sourceSpus: z.array(z.string().trim().min(1).max(200)).max(100).default([]),
|
||||
sourceSkuOperators: z.array(z.string().trim().min(1).max(200)).max(100).default([]),
|
||||
}).strict().transform((filters) => ({
|
||||
purchaserNames: [...new Set(filters.purchaserNames)],
|
||||
sourceSpus: [...new Set(filters.sourceSpus)],
|
||||
sourceSkuOperators: [...new Set(filters.sourceSkuOperators)],
|
||||
}))
|
||||
|
||||
function utcDate(value: string) {
|
||||
return new Date(`${value}T00:00:00.000Z`)
|
||||
}
|
||||
|
||||
function dateText(value: Date) {
|
||||
return value.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function toTaskDto(
|
||||
record: Dropship1688SourcingTaskRecord & Partial<Pick<Dropship1688SourcingTaskWithProduct, 'sku' | 'hasImage'>>,
|
||||
) {
|
||||
return {
|
||||
id: record.id,
|
||||
sourceArchiveId: record.sourceArchiveId,
|
||||
sourceSpu: record.sourceSpu,
|
||||
sourceBarcode: record.sourceBarcode,
|
||||
sourceCostPrice: record.sourceCostPrice?.toFixed(4) ?? null,
|
||||
sourceSkuOperator: record.sourceSkuOperator,
|
||||
sourceDateFrom: dateText(record.sourceDateFrom),
|
||||
sourceDateTo: dateText(record.sourceDateTo),
|
||||
purchaserUserId: record.purchaserUserId,
|
||||
purchaserName: record.purchaserName,
|
||||
supplierName: record.supplierName,
|
||||
quotedPriceTaxIncluded: record.quotedPriceTaxIncluded?.toFixed(4) ?? null,
|
||||
invoiceTaxPoint: record.invoiceTaxPoint?.toFixed(4) ?? null,
|
||||
sourcingMode: record.sourcingMode,
|
||||
jianshangBarcode: record.jianshangBarcode,
|
||||
acceptedAt: record.acceptedAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
sku: record.sku ?? '',
|
||||
hasImage: record.hasImage ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export type Dropship1688SourcingTaskDto = ReturnType<typeof toTaskDto>
|
||||
export type Dropship1688SourcingPageDto = {
|
||||
rows: Dropship1688SourcingTaskDto[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
filterOptions: {
|
||||
purchaserNames: string[]
|
||||
sourceSpus: string[]
|
||||
sourceSkuOperators: string[]
|
||||
}
|
||||
}
|
||||
export type Dropship1688SourcingPurchaserDto = {
|
||||
userId: string
|
||||
name: string
|
||||
acceptedAt: string
|
||||
}
|
||||
|
||||
function appendPurchaser(
|
||||
grouped: Record<string, Dropship1688SourcingPurchaserDto[]>,
|
||||
record: Dropship1688SourcingPurchaserRecord,
|
||||
) {
|
||||
const purchasers = grouped[record.sourceBarcode]
|
||||
if (!purchasers) return
|
||||
purchasers.push({
|
||||
userId: record.purchaserUserId,
|
||||
name: record.purchaserName,
|
||||
acceptedAt: record.acceptedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
class Dropship1688SourcingService {
|
||||
constructor(
|
||||
private readonly repository: Dropship1688SourcingRepository = dropship1688SourcingRepository,
|
||||
) {}
|
||||
|
||||
async claim(input: unknown, actor: AuthUser): Promise<Dropship1688SourcingTaskDto> {
|
||||
if (!isAdmin(actor) && !hasBusinessRole(actor, 'PURCHASING')) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有采购或管理员可以承接寻源任务')
|
||||
}
|
||||
|
||||
const parsed = claimSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
parsed.error.issues[0]?.message ?? '承接参数不正确',
|
||||
)
|
||||
}
|
||||
|
||||
const dateFrom = utcDate(parsed.data.dateFrom)
|
||||
const dateTo = utcDate(parsed.data.dateTo)
|
||||
const source = await this.repository.findClaimSource({
|
||||
barcode: parsed.data.barcode,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
})
|
||||
if (!source || !source.barcode.toUpperCase().startsWith('DF-')) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '1688代发商品不存在')
|
||||
}
|
||||
|
||||
const result = await this.repository.claim({
|
||||
source,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
purchaser: { id: actor.id, name: actor.name.trim() },
|
||||
})
|
||||
if (result.status === 'full') {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_OUT_OF_RANGE,
|
||||
`该商品最多由${DROPSHIP_1688_MAX_PURCHASERS}名采购承接`,
|
||||
)
|
||||
}
|
||||
return toTaskDto({ ...result.task, sku: '', hasImage: false })
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Dropship1688SourcingTaskDto[]> {
|
||||
const tasks = await this.repository.listTasks()
|
||||
return tasks.map(toTaskDto)
|
||||
}
|
||||
|
||||
async listPage(input: {
|
||||
page: number
|
||||
pageSize: number
|
||||
filters: Dropship1688SourcingFilters
|
||||
}): Promise<Dropship1688SourcingPageDto> {
|
||||
const [total, filterOptions] = await Promise.all([
|
||||
this.repository.countTasks(input.filters),
|
||||
this.repository.listFilterOptions(input.filters),
|
||||
])
|
||||
const totalPages = Math.max(1, Math.ceil(total / input.pageSize))
|
||||
const page = Math.min(input.page, totalPages)
|
||||
const tasks = total === 0
|
||||
? []
|
||||
: await this.repository.listTasksPage({
|
||||
filters: input.filters,
|
||||
skip: (page - 1) * input.pageSize,
|
||||
take: input.pageSize,
|
||||
})
|
||||
return {
|
||||
rows: tasks.map(toTaskDto),
|
||||
total,
|
||||
page,
|
||||
pageSize: input.pageSize,
|
||||
totalPages,
|
||||
filterOptions,
|
||||
}
|
||||
}
|
||||
|
||||
async listTasksForExport(input: unknown): Promise<Dropship1688SourcingTaskDto[]> {
|
||||
const parsed = z.object({ filters: sourcingFilterSchema }).strict().safeParse(input)
|
||||
if (!parsed.success) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '寻源导出筛选条件不正确')
|
||||
}
|
||||
const tasks = await this.repository.listTasksForExport({
|
||||
filters: parsed.data.filters,
|
||||
take: DROPSHIP_1688_SOURCING_EXPORT_LIMIT + 1,
|
||||
})
|
||||
if (tasks.length > DROPSHIP_1688_SOURCING_EXPORT_LIMIT) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_OUT_OF_RANGE,
|
||||
`单次最多导出${DROPSHIP_1688_SOURCING_EXPORT_LIMIT}条寻源数据,请缩小筛选范围`,
|
||||
)
|
||||
}
|
||||
if (tasks.length === 0) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '当前筛选条件下没有寻源数据')
|
||||
}
|
||||
return tasks.map(toTaskDto)
|
||||
}
|
||||
|
||||
async importRows(input: unknown, actor: AuthUser): Promise<{ updated: number }> {
|
||||
if (!isAdmin(actor) && !hasBusinessRole(actor, 'PURCHASING')) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有采购或管理员可以导入寻源明细')
|
||||
}
|
||||
|
||||
const parsed = z.array(importRowSchema)
|
||||
.min(1, '导入文件没有可用数据')
|
||||
.max(50_000, '单次最多导入50000行')
|
||||
.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0]
|
||||
const rowIndex = typeof issue?.path[0] === 'number' ? issue.path[0] : null
|
||||
const originalRow = rowIndex === null || !Array.isArray(input)
|
||||
? null
|
||||
: input[rowIndex]
|
||||
const rowNumber = originalRow && typeof originalRow === 'object' && 'rowNumber' in originalRow
|
||||
? (originalRow as { rowNumber?: unknown }).rowNumber
|
||||
: null
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
`${typeof rowNumber === 'number' ? `第${rowNumber}行:` : ''}${issue?.message ?? '导入数据格式不正确'}`,
|
||||
)
|
||||
}
|
||||
|
||||
const rows = parsed.data
|
||||
const duplicateKeys = new Map<string, number>()
|
||||
rows.forEach((row) => {
|
||||
const key = `${row.sourceBarcode}\u0000${row.purchaserName}`
|
||||
const firstRowNumber = duplicateKeys.get(key)
|
||||
if (firstRowNumber !== undefined) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
`第${row.rowNumber}行与第${firstRowNumber}行的代发条码和采购重复`,
|
||||
)
|
||||
}
|
||||
duplicateKeys.set(key, row.rowNumber)
|
||||
})
|
||||
|
||||
const records = await this.repository.listTasksByBarcodes([
|
||||
...new Set(rows.map((row) => row.sourceBarcode)),
|
||||
])
|
||||
const recordsByKey = new Map<string, Dropship1688SourcingTaskWithProduct[]>()
|
||||
records.forEach((record) => {
|
||||
const key = `${record.sourceBarcode.toUpperCase()}\u0000${record.purchaserName.trim()}`
|
||||
recordsByKey.set(key, [
|
||||
...(recordsByKey.get(key) ?? []),
|
||||
{ ...record, sku: '', hasImage: false },
|
||||
])
|
||||
})
|
||||
|
||||
const updates = rows.map((row) => {
|
||||
const matches = recordsByKey.get(`${row.sourceBarcode}\u0000${row.purchaserName}`) ?? []
|
||||
if (matches.length === 0) {
|
||||
throw new AppError(
|
||||
ErrorCode.PRODUCT_NOT_FOUND,
|
||||
`第${row.rowNumber}行:未找到代发条码 ${row.sourceBarcode} 与采购 ${row.purchaserName} 的承接记录`,
|
||||
)
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
`第${row.rowNumber}行:代发条码 ${row.sourceBarcode} 与采购 ${row.purchaserName} 对应多条记录,无法确定覆盖目标`,
|
||||
)
|
||||
}
|
||||
const task = matches[0]
|
||||
if (!isAdmin(actor) && task.purchaserUserId !== actor.id) {
|
||||
throw new AppError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
`第${row.rowNumber}行:只能导入本人承接的寻源任务`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
id: task.id,
|
||||
supplierName: row.supplierName,
|
||||
quotedPriceTaxIncluded: row.quotedPriceTaxIncluded
|
||||
? new Prisma.Decimal(row.quotedPriceTaxIncluded)
|
||||
: null,
|
||||
invoiceTaxPoint: row.invoiceTaxPoint
|
||||
? new Prisma.Decimal(row.invoiceTaxPoint)
|
||||
: null,
|
||||
sourcingMode: row.sourcingMode,
|
||||
jianshangBarcode: row.jianshangBarcode,
|
||||
}
|
||||
})
|
||||
|
||||
await this.repository.updateTasks(updates)
|
||||
return { updated: updates.length }
|
||||
}
|
||||
|
||||
async update(input: unknown, actor: AuthUser): Promise<Dropship1688SourcingTaskDto> {
|
||||
if (!isAdmin(actor) && !hasBusinessRole(actor, 'PURCHASING')) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有采购或管理员可以填写寻源明细')
|
||||
}
|
||||
|
||||
const parsed = updateSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
parsed.error.issues[0]?.message ?? '寻源明细参数不正确',
|
||||
)
|
||||
}
|
||||
|
||||
const existing = await this.repository.findTaskById(parsed.data.taskId)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '寻源任务不存在')
|
||||
}
|
||||
if (!isAdmin(actor) && existing.purchaserUserId !== actor.id) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只能修改本人承接的寻源任务')
|
||||
}
|
||||
|
||||
const updated = await this.repository.updateTask({
|
||||
id: parsed.data.taskId,
|
||||
supplierName: parsed.data.supplierName,
|
||||
quotedPriceTaxIncluded: parsed.data.quotedPriceTaxIncluded
|
||||
? new Prisma.Decimal(parsed.data.quotedPriceTaxIncluded)
|
||||
: null,
|
||||
invoiceTaxPoint: parsed.data.invoiceTaxPoint
|
||||
? new Prisma.Decimal(parsed.data.invoiceTaxPoint)
|
||||
: null,
|
||||
sourcingMode: parsed.data.sourcingMode,
|
||||
jianshangBarcode: parsed.data.jianshangBarcode,
|
||||
})
|
||||
return toTaskDto({ ...updated, sku: '', hasImage: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销承接。
|
||||
* 限制一:简尚条码已填写的寻源任务不能撤销(说明寻源已完成,不能回退)。
|
||||
* 限制二:只能撤销本人承接的寻源任务,管理员也不能代替其他采购撤销。
|
||||
*/
|
||||
async revoke(input: unknown, actor: AuthUser): Promise<{ id: string }> {
|
||||
if (!isAdmin(actor) && !hasBusinessRole(actor, 'PURCHASING')) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有采购或管理员可以撤销寻源任务')
|
||||
}
|
||||
|
||||
const parsed = revokeSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '撤销参数不正确')
|
||||
}
|
||||
|
||||
const existing = await this.repository.findTaskById(parsed.data.taskId)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '寻源任务不存在')
|
||||
}
|
||||
if (existing.purchaserUserId !== actor.id) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只能撤销本人承接的寻源任务')
|
||||
}
|
||||
if (existing.jianshangBarcode.trim().length > 0) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
'该寻源任务已填写简尚条码,不能撤销',
|
||||
)
|
||||
}
|
||||
|
||||
const deleted = await this.repository.deleteTaskIfRevocable({
|
||||
id: existing.id,
|
||||
purchaserUserId: actor.id,
|
||||
})
|
||||
if (!deleted) {
|
||||
const latest = await this.repository.findTaskById(existing.id)
|
||||
if (!latest) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '寻源任务不存在')
|
||||
}
|
||||
if (latest.purchaserUserId !== actor.id) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只能撤销本人承接的寻源任务')
|
||||
}
|
||||
if (latest.jianshangBarcode.trim().length > 0) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
'该寻源任务已填写简尚条码,不能撤销',
|
||||
)
|
||||
}
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '寻源任务状态已变化,请刷新页面后重试')
|
||||
}
|
||||
return { id: existing.id }
|
||||
}
|
||||
|
||||
async listPurchasersByBarcodes(
|
||||
barcodes: readonly string[],
|
||||
): Promise<Record<string, Dropship1688SourcingPurchaserDto[]>> {
|
||||
const normalizedBarcodes = [...new Set(
|
||||
barcodes
|
||||
.map((barcode) => barcode.trim().toUpperCase())
|
||||
.filter((barcode) => barcode.length <= 100 && /^DF-.+/.test(barcode)),
|
||||
)]
|
||||
const grouped = Object.fromEntries(
|
||||
normalizedBarcodes.map((barcode) => [barcode, [] as Dropship1688SourcingPurchaserDto[]]),
|
||||
)
|
||||
if (normalizedBarcodes.length === 0) return grouped
|
||||
|
||||
const records = await this.repository.listPurchasersByBarcodes(normalizedBarcodes)
|
||||
records.forEach((record) => appendPurchaser(grouped, record))
|
||||
return grouped
|
||||
}
|
||||
}
|
||||
|
||||
export const dropship1688SourcingService = new Dropship1688SourcingService()
|
||||
export { Dropship1688SourcingService }
|
||||
Loading…
x
Reference in New Issue
Block a user