feat(supply-chain): complete 1688 dropship sourcing workflow

This commit is contained in:
伯阳 2026-09-18 11:17:47 +08:00
parent 499cbf66d1
commit a22657e28e
39 changed files with 7186 additions and 147 deletions

View File

@ -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");

View File

@ -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())

View File

@ -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 })

View File

@ -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),

View File

@ -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('采购甲')
})
})

View File

@ -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 })
}

View File

@ -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()
})
})

View File

@ -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 })
}

View File

@ -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()
})
})

View File

@ -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 })
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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())
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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}
</>
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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: '撤销失败,请稍后重试',
})
})
})

View File

@ -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 : '撤销失败,请稍后重试',
}
}
}

View File

@ -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(),

View File

@ -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行数据',
)
})
})

View 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
}

View 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: [],
}

View File

@ -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' })
})

View File

@ -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 }
}

View File

@ -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' },

View File

@ -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()
})
})

View File

@ -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) => {

View File

@ -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 }

View File

@ -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: [] })
})

View File

@ -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': [],
})
})
})

View File

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

View File

@ -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 }