1688代发异常事项:当月业绩列/供应商列/整装拆装维护 + Excel式列头筛选
- 列表新增当月真实业绩列(SPU 汇总条码当月值,条码展开行显示自身),当月无业绩整行过滤 - 新增供应商列(取 SPU 内非空 supplierName),列顺序调整为 SPU|业绩|供应商|运营 - 维护抽屉新增整装/拆装二选一,标记整装的 SPU 在"是否有图纸"列显示"整装不需要图纸" - 顶部有无筛选与 Excel 式列头筛选(含 filter-options 接口) - 新增迁移 20260915180000_add_dropship_issue_assembly_type(SupplyChainDropshipIssue.assemblyType)
This commit is contained in:
parent
b0fd9a8800
commit
9e50804b2e
@ -0,0 +1,4 @@
|
||||
-- 1688 dropship issues page: operations now maintain whether the SPU ships
|
||||
-- assembled (整装, drawing not required) or knocked down (拆装, drawing required).
|
||||
ALTER TABLE "SupplyChainDropshipIssue"
|
||||
ADD COLUMN "assemblyType" VARCHAR(16) NOT NULL DEFAULT '';
|
||||
@ -824,15 +824,17 @@ model SupplyChainProductArchiveSalesSyncRun {
|
||||
@@index([status, startedAt])
|
||||
}
|
||||
|
||||
// Operations-maintained checklist per dropship (DF) SPU: invoice type and
|
||||
// return address are filled in on the 1688 dropship issues page. Read-only
|
||||
// columns (image, ops owner, drawing/video flags) always come live from the
|
||||
// product archive, so only these two fields live here.
|
||||
// Operations-maintained checklist per dropship (DF) SPU: invoice type,
|
||||
// return address and assembly type (整装/拆装) are filled in on the 1688
|
||||
// dropship issues page. Read-only columns (image, ops owner, drawing/video
|
||||
// flags) always come live from the product archive, so only these fields
|
||||
// live here.
|
||||
model SupplyChainDropshipIssue {
|
||||
id String @id @default(cuid())
|
||||
spu String @unique @db.VarChar(120)
|
||||
invoiceType String @default("") @db.VarChar(16)
|
||||
returnAddress String @default("") @db.Text
|
||||
assemblyType String @default("") @db.VarChar(16)
|
||||
updatedByName String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
export {}
|
||||
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
||||
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockListColumnFilterOptions = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
jsonSuccess: (data: unknown, status = 200, requestId?: string) =>
|
||||
new Response(JSON.stringify({ success: true, data, meta: { requestId } }), { status }),
|
||||
jsonError: (code: string, message?: string, status = 400) =>
|
||||
new Response(JSON.stringify({ success: false, error: { code, message } }), { status }),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/supply-chain-dropship-issue.service', () => ({
|
||||
supplyChainDropshipIssueService: {
|
||||
listColumnFilterOptions: (...args: unknown[]) => mockListColumnFilterOptions(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { POST } = require('./route')
|
||||
|
||||
function post(body: unknown) {
|
||||
return POST({ json: async () => body } as any)
|
||||
}
|
||||
|
||||
describe('POST dropship issues filter-options', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
|
||||
handler({ requestId: 'filter-options-request', user: { id: 'user-1' } }),
|
||||
)
|
||||
mockListColumnFilterOptions.mockResolvedValue({ values: ['DF-A', 'DF-B'] })
|
||||
})
|
||||
|
||||
it('forwards the requested field and all active filters', async () => {
|
||||
const response = await post({
|
||||
field: 'spu',
|
||||
keyword: '桌',
|
||||
hasInvoice: false,
|
||||
hasReturnAddress: true,
|
||||
opsHeads: ['运营甲'],
|
||||
hasDrawing: true,
|
||||
columnFilters: { opsHead: { mode: 'include', values: ['运营甲'] } },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockListColumnFilterOptions).toHaveBeenCalledWith({
|
||||
field: 'spu',
|
||||
filters: {
|
||||
keyword: '桌',
|
||||
hasInvoice: false,
|
||||
hasReturnAddress: true,
|
||||
invoiceType: undefined,
|
||||
opsHeads: ['运营甲'],
|
||||
hasDrawing: true,
|
||||
hasVideo: undefined,
|
||||
columnFilters: { opsHead: { mode: 'include', values: ['运营甲'] } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ field: 'unknown' },
|
||||
{ field: 'spu', hasInvoice: 'false' },
|
||||
{ field: 'spu', columnFilters: { unknown: { mode: 'include', values: [] } } },
|
||||
])('rejects invalid filter input: %j', async (body) => {
|
||||
expect((await post(body)).status).toBe(400)
|
||||
expect(mockListColumnFilterOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires authentication', async () => {
|
||||
const request = { json: async () => ({ field: 'spu' }) } as any
|
||||
await POST(request)
|
||||
expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,45 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
DROPSHIP_ISSUE_COLUMN_FILTER_FIELDS,
|
||||
DROPSHIP_ISSUE_FILTER_MAX_BYTES,
|
||||
dropshipIssueColumnFiltersSchema,
|
||||
normalizeDropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { supplyChainDropshipIssueService } from '@/server/services/supply-chain-dropship-issue.service'
|
||||
|
||||
const bodySchema = z.object({
|
||||
field: z.enum(DROPSHIP_ISSUE_COLUMN_FILTER_FIELDS),
|
||||
keyword: z.string().trim().max(120).optional().default(''),
|
||||
hasInvoice: z.boolean().optional(),
|
||||
hasReturnAddress: z.boolean().optional(),
|
||||
invoiceType: z.enum(['普票', '专票']).optional(),
|
||||
opsHeads: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
|
||||
hasDrawing: z.boolean().optional(),
|
||||
hasVideo: z.boolean().optional(),
|
||||
columnFilters: dropshipIssueColumnFiltersSchema.optional().default({}),
|
||||
}).strict()
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const body = await request.json().catch(() => null)
|
||||
if (!body || Buffer.byteLength(JSON.stringify(body), 'utf8') > DROPSHIP_ISSUE_FILTER_MAX_BYTES) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件过多,请缩小选择范围', 400, ctx.requestId)
|
||||
}
|
||||
const parsed = bodySchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选参数不正确', 400, ctx.requestId)
|
||||
}
|
||||
const { field, ...filters } = parsed.data
|
||||
const data = await supplyChainDropshipIssueService.listColumnFilterOptions({
|
||||
field,
|
||||
filters: {
|
||||
...filters,
|
||||
columnFilters: normalizeDropshipIssueColumnFilters(filters.columnFilters),
|
||||
},
|
||||
})
|
||||
return jsonSuccess(data, 200, ctx.requestId)
|
||||
}, { requireAuth: true })
|
||||
}
|
||||
@ -33,7 +33,17 @@ describe('dropship issues SPU list route', () => {
|
||||
|
||||
it('requires only authentication and forwards normalized filters', async () => {
|
||||
const response = await GET(
|
||||
request({ page: '2', pageSize: '30', keyword: 'DF-A', pendingInvoice: 'true' }),
|
||||
request({
|
||||
page: '2',
|
||||
pageSize: '30',
|
||||
keyword: 'DF-A',
|
||||
hasInvoice: 'false',
|
||||
hasReturnAddress: 'true',
|
||||
hasDrawing: 'true',
|
||||
hasVideo: 'false',
|
||||
ops: '运营甲,运营乙',
|
||||
columnFilters: JSON.stringify({ spu: { mode: 'include', values: ['DF-A'] } }),
|
||||
}),
|
||||
)
|
||||
expect(handleRequest).toHaveBeenCalledWith(expect.anything(), expect.any(Function), {
|
||||
requireAuth: true,
|
||||
@ -42,9 +52,13 @@ describe('dropship issues SPU list route', () => {
|
||||
page: 2,
|
||||
pageSize: 30,
|
||||
keyword: 'DF-A',
|
||||
pendingInvoice: true,
|
||||
pendingReturnAddress: false,
|
||||
hasInvoice: false,
|
||||
hasReturnAddress: true,
|
||||
invoiceType: undefined,
|
||||
opsHeads: ['运营甲', '运营乙'],
|
||||
hasDrawing: true,
|
||||
hasVideo: false,
|
||||
columnFilters: { spu: { mode: 'include', values: ['DF-A'] } },
|
||||
})
|
||||
expect(response).toMatchObject({ success: true, status: 200 })
|
||||
})
|
||||
@ -53,8 +67,9 @@ describe('dropship issues SPU list route', () => {
|
||||
{ page: '0' },
|
||||
{ pageSize: '999' },
|
||||
{ keyword: 'x'.repeat(121) },
|
||||
{ pendingInvoice: 'yes' },
|
||||
{ hasInvoice: 'yes' },
|
||||
{ invoiceType: '增值税' },
|
||||
{ columnFilters: '{' },
|
||||
])('rejects invalid query input: %j', async (params) => {
|
||||
expect(await GET(request(params))).toMatchObject({ success: false, status: 400 })
|
||||
expect(supplyChainDropshipIssueService.listSpuPage).not.toHaveBeenCalled()
|
||||
|
||||
@ -6,6 +6,10 @@ import {
|
||||
supplyChainDropshipIssueService,
|
||||
DROPSHIP_INVOICE_TYPES,
|
||||
} from '@/server/services/supply-chain-dropship-issue.service'
|
||||
import {
|
||||
dropshipIssueColumnFiltersSchema,
|
||||
normalizeDropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
|
||||
const triStateSchema = z.preprocess(
|
||||
(value) => (value === '' || value === null || value === undefined ? undefined : value),
|
||||
@ -16,14 +20,29 @@ const listQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).default(50),
|
||||
keyword: z.string().trim().max(120).optional(),
|
||||
pendingInvoice: z.enum(['true', 'false']).optional(),
|
||||
pendingReturnAddress: z.enum(['true', 'false']).optional(),
|
||||
hasInvoice: triStateSchema,
|
||||
hasReturnAddress: triStateSchema,
|
||||
invoiceType: z.enum(DROPSHIP_INVOICE_TYPES).optional(),
|
||||
ops: z.string().trim().max(500).optional(),
|
||||
hasDrawing: triStateSchema,
|
||||
hasVideo: triStateSchema,
|
||||
columnFilters: z.string().max(500_000).optional(),
|
||||
})
|
||||
|
||||
function booleanValue(value: 'true' | 'false' | undefined) {
|
||||
return value === 'true' ? true : value === 'false' ? false : undefined
|
||||
}
|
||||
|
||||
function parseColumnFilters(value: string | undefined) {
|
||||
if (!value) return {}
|
||||
try {
|
||||
const parsed = dropshipIssueColumnFiltersSchema.safeParse(JSON.parse(value))
|
||||
return parsed.success ? normalizeDropshipIssueColumnFilters(parsed.data) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
@ -32,40 +51,35 @@ export async function GET(request: NextRequest) {
|
||||
page: request.nextUrl.searchParams.get('page') ?? undefined,
|
||||
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
|
||||
keyword: request.nextUrl.searchParams.get('keyword') ?? undefined,
|
||||
pendingInvoice: request.nextUrl.searchParams.get('pendingInvoice') ?? undefined,
|
||||
pendingReturnAddress:
|
||||
request.nextUrl.searchParams.get('pendingReturnAddress') ?? undefined,
|
||||
hasInvoice: request.nextUrl.searchParams.get('hasInvoice') ?? undefined,
|
||||
hasReturnAddress: request.nextUrl.searchParams.get('hasReturnAddress') ?? undefined,
|
||||
invoiceType: request.nextUrl.searchParams.get('invoiceType') ?? undefined,
|
||||
ops: request.nextUrl.searchParams.get('ops') ?? undefined,
|
||||
hasDrawing: request.nextUrl.searchParams.get('hasDrawing') ?? undefined,
|
||||
hasVideo: request.nextUrl.searchParams.get('hasVideo') ?? undefined,
|
||||
columnFilters: request.nextUrl.searchParams.get('columnFilters') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '查询参数不正确', 400, ctx.requestId)
|
||||
}
|
||||
const columnFilters = parseColumnFilters(parsed.data.columnFilters)
|
||||
if (columnFilters === null) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '列头筛选参数不正确', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
const result = await supplyChainDropshipIssueService.listSpuPage({
|
||||
page: parsed.data.page,
|
||||
pageSize: parsed.data.pageSize,
|
||||
keyword: parsed.data.keyword,
|
||||
pendingInvoice: parsed.data.pendingInvoice === 'true',
|
||||
pendingReturnAddress: parsed.data.pendingReturnAddress === 'true',
|
||||
hasInvoice: booleanValue(parsed.data.hasInvoice),
|
||||
hasReturnAddress: booleanValue(parsed.data.hasReturnAddress),
|
||||
invoiceType: parsed.data.invoiceType,
|
||||
opsHeads: parsed.data.ops
|
||||
? parsed.data.ops.split(',').map((name) => name.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
hasDrawing:
|
||||
parsed.data.hasDrawing === 'true'
|
||||
? true
|
||||
: parsed.data.hasDrawing === 'false'
|
||||
? false
|
||||
: undefined,
|
||||
hasVideo:
|
||||
parsed.data.hasVideo === 'true'
|
||||
? true
|
||||
: parsed.data.hasVideo === 'false'
|
||||
? false
|
||||
: undefined,
|
||||
hasDrawing: booleanValue(parsed.data.hasDrawing),
|
||||
hasVideo: booleanValue(parsed.data.hasVideo),
|
||||
columnFilters,
|
||||
})
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
},
|
||||
|
||||
@ -13,6 +13,7 @@ jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
|
||||
jest.mock('@/server/services/supply-chain-dropship-issue.service', () => ({
|
||||
supplyChainDropshipIssueService: { updateSpu: jest.fn() },
|
||||
DROPSHIP_INVOICE_TYPES: ['普票', '专票'],
|
||||
DROPSHIP_ASSEMBLY_TYPES: ['整装', '拆装'],
|
||||
}))
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
@ -38,6 +39,7 @@ describe('dropship issues update route', () => {
|
||||
spu: 'DF-A1',
|
||||
invoiceType: '普票',
|
||||
returnAddress: '地址',
|
||||
assemblyType: '拆装',
|
||||
updatedByName: '测试运营',
|
||||
changed: true,
|
||||
} as never)
|
||||
@ -45,7 +47,12 @@ describe('dropship issues update route', () => {
|
||||
|
||||
it('requires authentication, checks edit permission and forwards the actor', async () => {
|
||||
const response = await PATCH(
|
||||
request({ spu: 'DF-A1', invoiceType: '普票', returnAddress: '地址' }),
|
||||
request({
|
||||
spu: 'DF-A1',
|
||||
invoiceType: '普票',
|
||||
returnAddress: '地址',
|
||||
assemblyType: '拆装',
|
||||
}),
|
||||
)
|
||||
expect(handleRequest).toHaveBeenCalledWith(expect.anything(), expect.any(Function), {
|
||||
requireAuth: true,
|
||||
@ -55,7 +62,7 @@ describe('dropship issues update route', () => {
|
||||
'newProductDropshipIssues',
|
||||
)
|
||||
expect(supplyChainDropshipIssueService.updateSpu).toHaveBeenCalledWith(
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: '地址' },
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: '地址', assemblyType: '拆装' },
|
||||
{ id: 'user-1', name: '测试运营' },
|
||||
)
|
||||
expect(response).toMatchObject({ success: true, status: 200 })
|
||||
@ -66,15 +73,21 @@ describe('dropship issues update route', () => {
|
||||
.mocked(supplyChainPermissionSettingsService.getUserAccess)
|
||||
.mockResolvedValue({ view: true, edit: false } as never)
|
||||
expect(
|
||||
await PATCH(request({ spu: 'DF-A1', invoiceType: '普票', returnAddress: '' })),
|
||||
await PATCH(request({
|
||||
spu: 'DF-A1',
|
||||
invoiceType: '普票',
|
||||
returnAddress: '',
|
||||
assemblyType: '',
|
||||
})),
|
||||
).toMatchObject({ success: false, status: 403 })
|
||||
expect(supplyChainDropshipIssueService.updateSpu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ spu: '', invoiceType: '普票', returnAddress: '' },
|
||||
{ spu: 'DF-A1', invoiceType: '收据', returnAddress: '' },
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: 'x'.repeat(501) },
|
||||
{ spu: '', invoiceType: '普票', returnAddress: '', assemblyType: '' },
|
||||
{ spu: 'DF-A1', invoiceType: '收据', returnAddress: '', assemblyType: '' },
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: 'x'.repeat(501), assemblyType: '' },
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: '', assemblyType: '组装' },
|
||||
])('rejects invalid body: %j', async (body) => {
|
||||
expect(await PATCH(request(body))).toMatchObject({ success: false, status: 400 })
|
||||
expect(supplyChainDropshipIssueService.updateSpu).not.toHaveBeenCalled()
|
||||
|
||||
@ -6,12 +6,14 @@ import { supplyChainPermissionSettingsService } from '@/server/services/supply-c
|
||||
import {
|
||||
supplyChainDropshipIssueService,
|
||||
DROPSHIP_INVOICE_TYPES,
|
||||
DROPSHIP_ASSEMBLY_TYPES,
|
||||
} from '@/server/services/supply-chain-dropship-issue.service'
|
||||
|
||||
const updateBodySchema = z.object({
|
||||
spu: z.string().trim().min(1).max(120),
|
||||
invoiceType: z.enum(['', ...DROPSHIP_INVOICE_TYPES]),
|
||||
returnAddress: z.string().max(500),
|
||||
assemblyType: z.enum(['', ...DROPSHIP_ASSEMBLY_TYPES]),
|
||||
})
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
@ -37,6 +39,7 @@ export async function PATCH(request: NextRequest) {
|
||||
spu: parsed.data.spu,
|
||||
invoiceType: parsed.data.invoiceType,
|
||||
returnAddress: parsed.data.returnAddress ?? '',
|
||||
assemblyType: parsed.data.assemblyType ?? '',
|
||||
},
|
||||
{ id: ctx.user.id, name: ctx.user.name },
|
||||
)
|
||||
|
||||
@ -0,0 +1,112 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { DropshipIssueColumnFilter } from './DropshipIssueColumnFilter'
|
||||
import { saveDropshipIssueColumnFilters } from './filters.actions'
|
||||
|
||||
const push = jest.fn()
|
||||
jest.mock('next/navigation', () => ({ useRouter: () => ({ push }) }))
|
||||
jest.mock('./filters.actions', () => ({ saveDropshipIssueColumnFilters: jest.fn() }))
|
||||
|
||||
const context = {
|
||||
keyword: '桌',
|
||||
hasInvoice: false,
|
||||
hasReturnAddress: true,
|
||||
opsHeads: ['运营甲'],
|
||||
hasDrawing: true,
|
||||
hasVideo: false,
|
||||
}
|
||||
|
||||
describe('DropshipIssueColumnFilter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
window.history.replaceState({}, '', '/supply-chain/new-products/dropship-issues?page=3&pageSize=100&keyword=%E6%A1%8C&hasInvoice=false&ops=%E8%BF%90%E8%90%A5%E7%94%B2')
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { values: ['SPU-A', 'SPU-B', 'SPU-C'] } }),
|
||||
})
|
||||
})
|
||||
|
||||
async function open(firstValue = 'SPU-A') {
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选SPU' }))
|
||||
await screen.findByRole('checkbox', { name: firstValue })
|
||||
return screen.getByRole('dialog', { name: '筛选SPU' })
|
||||
}
|
||||
|
||||
it('loads candidates with all active filters and applies an Excel-style multi-selection', async () => {
|
||||
render(
|
||||
<DropshipIssueColumnFilter
|
||||
field="spu"
|
||||
label="SPU"
|
||||
filters={{ opsHead: { mode: 'include', values: ['运营甲'] } }}
|
||||
context={context}
|
||||
/>,
|
||||
)
|
||||
const dialog = await open()
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/supply-chain/new-products/dropship-issues/filter-options',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
field: 'spu',
|
||||
...context,
|
||||
columnFilters: { opsHead: { mode: 'include', values: ['运营甲'] } },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '全不选' }))
|
||||
fireEvent.click(within(dialog).getByRole('checkbox', { name: 'SPU-A' }))
|
||||
fireEvent.click(within(dialog).getByRole('checkbox', { name: 'SPU-C' }))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '应用' }))
|
||||
|
||||
const url = new URL(push.mock.calls[0][0], 'http://localhost')
|
||||
expect(JSON.parse(url.searchParams.get('columnFilters')!)).toEqual({
|
||||
spu: { mode: 'include', values: ['SPU-A', 'SPU-C'] },
|
||||
opsHead: { mode: 'include', values: ['运营甲'] },
|
||||
})
|
||||
expect(url.searchParams.has('page')).toBe(false)
|
||||
expect(url.searchParams.get('pageSize')).toBe('100')
|
||||
expect(url.searchParams.get('hasInvoice')).toBe('false')
|
||||
})
|
||||
|
||||
it('uses a short reference for large selections and reports a save failure', async () => {
|
||||
const values = Array.from({ length: 500 }, (_, index) => `SPU-${index}-${'X'.repeat(8)}`)
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { values } }),
|
||||
} as Response)
|
||||
jest.mocked(saveDropshipIssueColumnFilters).mockRejectedValue(new Error('筛选缓存暂不可用'))
|
||||
render(
|
||||
<DropshipIssueColumnFilter
|
||||
field="spu"
|
||||
label="SPU"
|
||||
filters={{ spu: { mode: 'include', values } }}
|
||||
context={context}
|
||||
/>,
|
||||
)
|
||||
await open(values[0])
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('筛选缓存暂不可用')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears both a saved reference and the current page when clearing a column filter', async () => {
|
||||
window.history.replaceState({}, '', '/supply-chain/new-products/dropship-issues?filterRef=old&page=3&hasVideo=false')
|
||||
render(
|
||||
<DropshipIssueColumnFilter
|
||||
field="spu"
|
||||
label="SPU"
|
||||
filters={{ spu: { mode: 'include', values: ['SPU-A'] } }}
|
||||
context={context}
|
||||
/>,
|
||||
)
|
||||
const dialog = await open()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '清除筛选' }))
|
||||
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1))
|
||||
const params = new URL(push.mock.calls[0][0], 'http://localhost').searchParams
|
||||
expect(params.has('filterRef')).toBe(false)
|
||||
expect(params.has('columnFilters')).toBe(false)
|
||||
expect(params.has('page')).toBe(false)
|
||||
expect(params.get('hasVideo')).toBe('false')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CircleAlert, X } from 'lucide-react'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ColumnHeaderFilter } from '@/components/common/ColumnHeaderFilter'
|
||||
import {
|
||||
DROPSHIP_ISSUE_FILTER_MAX_BYTES,
|
||||
serializeDropshipIssueColumnFilters,
|
||||
type DropshipIssueColumnFilterField,
|
||||
type DropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
import type { ColumnHeaderFilterSelection } from '@/lib/column-header-filters'
|
||||
import { saveDropshipIssueColumnFilters } from './filters.actions'
|
||||
import styles from './dropship-issues.module.css'
|
||||
import type { DropshipIssueFilterContext } from './types'
|
||||
|
||||
export function DropshipIssueColumnFilter({
|
||||
field,
|
||||
label,
|
||||
filters,
|
||||
context,
|
||||
}: {
|
||||
field: DropshipIssueColumnFilterField
|
||||
label: string
|
||||
filters: DropshipIssueColumnFilters
|
||||
context: DropshipIssueFilterContext
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [pending, startTransition] = useTransition()
|
||||
const [applyError, setApplyError] = useState('')
|
||||
|
||||
async function loadValues() {
|
||||
const response = await fetch('/api/supply-chain/new-products/dropship-issues/filter-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ field, ...context, columnFilters: filters }),
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok || !payload.success || !Array.isArray(payload.data?.values)) {
|
||||
throw new Error(payload.error?.message || '候选值加载失败')
|
||||
}
|
||||
return payload.data.values as string[]
|
||||
}
|
||||
|
||||
async function apply(selection: ColumnHeaderFilterSelection | null) {
|
||||
setApplyError('')
|
||||
const next = { ...filters }
|
||||
if (selection) next[field] = selection
|
||||
else delete next[field]
|
||||
const serialized = serializeDropshipIssueColumnFilters(next)
|
||||
if (new Blob([serialized]).size > DROPSHIP_ISSUE_FILTER_MAX_BYTES) {
|
||||
throw new Error('筛选条件过多,请缩小选择范围')
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
params.delete('page')
|
||||
params.delete('filterRef')
|
||||
params.delete('columnFilters')
|
||||
if (encodeURIComponent(serialized).length > 2_000) {
|
||||
params.set('filterRef', await saveDropshipIssueColumnFilters(next))
|
||||
} else if (serialized) {
|
||||
params.set('columnFilters', serialized)
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`${window.location.pathname}?${params.toString()}`, { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColumnHeaderFilter
|
||||
label={label}
|
||||
active={Boolean(filters[field])}
|
||||
disabled={pending}
|
||||
loadValues={loadValues}
|
||||
selection={filters[field] ?? null}
|
||||
onApply={(selection) => {
|
||||
void apply(selection).catch((reason) => {
|
||||
setApplyError(reason instanceof Error ? reason.message : '筛选保存失败,请重试')
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{applyError && createPortal(
|
||||
<div className={styles.filterApplyError} role="alert">
|
||||
<CircleAlert size={16} />
|
||||
<span>{applyError}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭筛选错误提示"
|
||||
title="关闭"
|
||||
onClick={() => setApplyError('')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,14 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, CircleDollarSign, FileBadge, LoaderCircle, X } from 'lucide-react'
|
||||
import type { DropshipIssueSpuRow } from './types'
|
||||
import { Check, CircleDollarSign, FileBadge, Hammer, LoaderCircle, Package, X } from 'lucide-react'
|
||||
import type { DropshipAssemblyType, DropshipIssueSpuRow } from './types'
|
||||
|
||||
const INVOICE_OPTIONS = [
|
||||
{ value: '普票', label: '普票', hint: '普通发票', Icon: CircleDollarSign },
|
||||
{ value: '专票', label: '专票', hint: '增值税专用发票', Icon: FileBadge },
|
||||
] as const
|
||||
|
||||
const ASSEMBLY_OPTIONS = [
|
||||
{ value: '整装', label: '整装', hint: '整件发货,不需要图纸', Icon: Package },
|
||||
{ value: '拆装', label: '拆装', hint: '散件发货,需要上传图纸', Icon: Hammer },
|
||||
] as const
|
||||
|
||||
export function DropshipIssueEditDialog({
|
||||
row,
|
||||
onClose,
|
||||
@ -16,16 +21,23 @@ export function DropshipIssueEditDialog({
|
||||
}: {
|
||||
row: DropshipIssueSpuRow
|
||||
onClose: () => void
|
||||
onSaved: (updated: { spu: string; invoiceType: string; returnAddress: string }) => void
|
||||
onSaved: (updated: {
|
||||
spu: string
|
||||
invoiceType: string
|
||||
returnAddress: string
|
||||
assemblyType: DropshipAssemblyType
|
||||
}) => void
|
||||
}) {
|
||||
const [invoiceType, setInvoiceType] = useState(row.invoiceType)
|
||||
const [returnAddress, setReturnAddress] = useState(row.returnAddress)
|
||||
const [assemblyType, setAssemblyType] = useState<DropshipAssemblyType>(row.assemblyType)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setInvoiceType(row.invoiceType)
|
||||
setReturnAddress(row.returnAddress)
|
||||
setAssemblyType(row.assemblyType)
|
||||
setError('')
|
||||
}, [row])
|
||||
|
||||
@ -41,13 +53,14 @@ export function DropshipIssueEditDialog({
|
||||
spu: row.spu,
|
||||
invoiceType,
|
||||
returnAddress,
|
||||
assemblyType,
|
||||
}),
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.error?.message || '保存失败')
|
||||
}
|
||||
onSaved({ spu: row.spu, invoiceType, returnAddress })
|
||||
onSaved({ spu: row.spu, invoiceType, returnAddress, assemblyType })
|
||||
onClose()
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : '保存失败')
|
||||
@ -106,6 +119,42 @@ export function DropshipIssueEditDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="di-field">
|
||||
<div className="di-field-head">
|
||||
<label>整装 / 拆装(二选一)</label>
|
||||
<button
|
||||
type="button"
|
||||
className="di-invoice-clear"
|
||||
onClick={() => setAssemblyType('')}
|
||||
disabled={!assemblyType}
|
||||
>
|
||||
清除已选
|
||||
</button>
|
||||
</div>
|
||||
<div className="di-invoice-options" role="radiogroup" aria-label="安装方式">
|
||||
{ASSEMBLY_OPTIONS.map(({ value, label, hint, Icon }) => {
|
||||
const isActive = assemblyType === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
className={`di-invoice-card ${isActive ? 'is-active' : ''}`}
|
||||
onClick={() => setAssemblyType(value)}
|
||||
>
|
||||
<Icon size={17} strokeWidth={1.8} />
|
||||
<span className="di-invoice-card-text">
|
||||
<span className="di-invoice-card-label">{label}</span>
|
||||
<span className="di-invoice-card-hint">{hint}</span>
|
||||
</span>
|
||||
{isActive && <Check size={15} strokeWidth={2.4} className="di-invoice-check" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="di-field">
|
||||
<label>退货地址</label>
|
||||
<textarea
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DropshipIssuesPagination } from './DropshipIssuesPagination'
|
||||
import { DropshipOpsFilter } from './DropshipOpsFilter'
|
||||
|
||||
const push = jest.fn()
|
||||
jest.mock('next/navigation', () => ({ useRouter: () => ({ push }) }))
|
||||
|
||||
describe('dropship issues filter navigation', () => {
|
||||
beforeEach(() => {
|
||||
push.mockClear()
|
||||
})
|
||||
|
||||
it('preserves top and column filters when paging', () => {
|
||||
render(
|
||||
<DropshipIssuesPagination
|
||||
page={2}
|
||||
pageSize={100}
|
||||
total={2740}
|
||||
totalPages={28}
|
||||
keyword="桌"
|
||||
hasInvoice={false}
|
||||
hasReturnAddress
|
||||
ops={['运营甲']}
|
||||
hasDrawing
|
||||
hasVideo={false}
|
||||
filterRef=""
|
||||
columnFilters={{ invoiceType: { mode: 'include', values: ['专票', '未填写'] } }}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /下一页/ }))
|
||||
|
||||
const params = new URL(push.mock.calls[0][0], 'http://localhost').searchParams
|
||||
expect(params.get('page')).toBe('3')
|
||||
expect(params.get('pageSize')).toBe('100')
|
||||
expect(params.get('keyword')).toBe('桌')
|
||||
expect(params.get('hasInvoice')).toBe('false')
|
||||
expect(params.get('hasReturnAddress')).toBe('true')
|
||||
expect(params.get('ops')).toBe('运营甲')
|
||||
expect(params.get('hasDrawing')).toBe('true')
|
||||
expect(params.get('hasVideo')).toBe('false')
|
||||
expect(JSON.parse(params.get('columnFilters')!)).toEqual({
|
||||
invoiceType: { mode: 'include', values: ['未填写', '专票'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the entire URL when selecting an operator and resets the page', () => {
|
||||
window.history.replaceState({}, '', '/supply-chain/new-products/dropship-issues?page=4&pageSize=200&hasDrawing=false&columnFilters=kept')
|
||||
render(
|
||||
<DropshipOpsFilter
|
||||
options={[{ name: '运营甲', spuCount: 12 }, { name: '运营乙', spuCount: 8 }]}
|
||||
selected={[]}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /全部运营/ }))
|
||||
fireEvent.click(screen.getByRole('option', { name: /运营甲/ }))
|
||||
|
||||
const params = new URL(push.mock.calls[0][0], 'http://localhost').searchParams
|
||||
expect(params.get('ops')).toBe('运营甲')
|
||||
expect(params.get('page')).toBe('1')
|
||||
expect(params.get('pageSize')).toBe('200')
|
||||
expect(params.get('hasDrawing')).toBe('false')
|
||||
expect(params.get('columnFilters')).toBe('kept')
|
||||
})
|
||||
})
|
||||
@ -3,6 +3,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import {
|
||||
serializeDropshipIssueColumnFilters,
|
||||
type DropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [50, 100, 200] as const
|
||||
|
||||
@ -12,22 +16,26 @@ export function DropshipIssuesPagination({
|
||||
total,
|
||||
totalPages,
|
||||
keyword,
|
||||
pendingInvoice,
|
||||
pendingReturnAddress,
|
||||
hasInvoice,
|
||||
hasReturnAddress,
|
||||
ops,
|
||||
hasDrawing,
|
||||
hasVideo,
|
||||
filterRef,
|
||||
columnFilters,
|
||||
}: {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
keyword: string
|
||||
pendingInvoice: boolean
|
||||
pendingReturnAddress: boolean
|
||||
hasInvoice?: boolean
|
||||
hasReturnAddress?: boolean
|
||||
ops: string[]
|
||||
hasDrawing?: boolean
|
||||
hasVideo?: boolean
|
||||
filterRef: string
|
||||
columnFilters: DropshipIssueColumnFilters
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [pageInput, setPageInput] = useState(String(page))
|
||||
@ -39,11 +47,14 @@ export function DropshipIssuesPagination({
|
||||
function navigate(nextPage: number, nextPageSize = pageSize) {
|
||||
const params = new URLSearchParams()
|
||||
if (keyword) params.set('keyword', keyword)
|
||||
if (pendingInvoice) params.set('pendingInvoice', 'true')
|
||||
if (pendingReturnAddress) params.set('pendingReturnAddress', 'true')
|
||||
if (hasInvoice !== undefined) params.set('hasInvoice', String(hasInvoice))
|
||||
if (hasReturnAddress !== undefined) params.set('hasReturnAddress', String(hasReturnAddress))
|
||||
if (ops.length > 0) params.set('ops', ops.join(','))
|
||||
if (hasDrawing !== undefined) params.set('hasDrawing', String(hasDrawing))
|
||||
if (hasVideo !== undefined) params.set('hasVideo', String(hasVideo))
|
||||
const serializedFilters = serializeDropshipIssueColumnFilters(columnFilters)
|
||||
if (filterRef) params.set('filterRef', filterRef)
|
||||
else if (serializedFilters) params.set('columnFilters', serializedFilters)
|
||||
if (nextPageSize !== 50) params.set('pageSize', String(nextPageSize))
|
||||
params.set('page', String(Math.min(Math.max(1, nextPage), Math.max(1, totalPages))))
|
||||
router.push(`/supply-chain/new-products/dropship-issues?${params.toString()}`)
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { DropshipIssuesTable } from './DropshipIssuesTable'
|
||||
|
||||
jest.mock('./filters.actions', () => ({ saveDropshipIssueColumnFilters: jest.fn() }))
|
||||
jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) }))
|
||||
|
||||
describe('DropshipIssuesTable column filters', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn()
|
||||
})
|
||||
|
||||
it('renders Excel-style filter triggers for the six requested columns', () => {
|
||||
render(
|
||||
<DropshipIssuesTable
|
||||
initialRows={[]}
|
||||
canEdit={false}
|
||||
columnFilters={{}}
|
||||
filterContext={{ keyword: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '筛选SPU' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '筛选运营' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '筛选是否有图纸' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '筛选是否有安装视频' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '筛选专票/普票' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '筛选退货地址' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /^筛选/ })).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('orders the columns as SPU, 业绩, 供应商, 运营', () => {
|
||||
render(
|
||||
<DropshipIssuesTable
|
||||
initialRows={[]}
|
||||
canEdit={false}
|
||||
columnFilters={{}}
|
||||
filterContext={{ keyword: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const headers = screen
|
||||
.getAllByRole('columnheader')
|
||||
.map((cell) => cell.textContent ?? '')
|
||||
expect(headers.join('|')).toMatch(/SPU[\s\S]*业绩[\s\S]*供应商[\s\S]*运营/)
|
||||
})
|
||||
|
||||
it('shows the month performance, supplier and the 整装 drawing exemption', () => {
|
||||
render(
|
||||
<DropshipIssuesTable
|
||||
initialRows={[
|
||||
{
|
||||
spu: 'DF-A1',
|
||||
barcode: 'DF-A101',
|
||||
image: '',
|
||||
productName: '测试产品',
|
||||
opsHead: '运营A',
|
||||
supplierName: '供应商甲',
|
||||
monthPerformance: '12345.6000',
|
||||
assemblyType: '整装',
|
||||
skuCount: 2,
|
||||
hasDrawingCount: 0,
|
||||
hasVideoCount: 0,
|
||||
invoiceType: '',
|
||||
returnAddress: '',
|
||||
updatedByName: '',
|
||||
updatedAt: null,
|
||||
},
|
||||
]}
|
||||
canEdit={false}
|
||||
columnFilters={{}}
|
||||
filterContext={{ keyword: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('¥12,345.60')).toBeInTheDocument()
|
||||
expect(screen.getByText('供应商甲')).toBeInTheDocument()
|
||||
expect(screen.getByText('整装不需要图纸')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -10,9 +10,15 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { DropshipIssueImageCell } from './DropshipIssueImageCell'
|
||||
import { DropshipSkuPanel } from './DropshipSkuPanel'
|
||||
import { moneyText } from './format'
|
||||
import { DropshipIssueEditDialog } from './DropshipIssueEditDialog'
|
||||
import { DropshipIssueAuditDrawer } from './DropshipIssueAuditDrawer'
|
||||
import type { DropshipIssueSpuRow } from './types'
|
||||
import { DropshipIssueColumnFilter } from './DropshipIssueColumnFilter'
|
||||
import type { DropshipIssueFilterContext, DropshipIssueSpuRow } from './types'
|
||||
import type {
|
||||
DropshipIssueColumnFilterField,
|
||||
DropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
|
||||
// 跳主页面而非登录页:图纸系统登录态有效时直接进入,未登录由它自己跳登录。
|
||||
// 之前写死 #/login 导致每次点击都强制重新输账号。
|
||||
@ -24,21 +30,27 @@ function hasNo(value: number) {
|
||||
|
||||
const COLUMNS = [
|
||||
{ label: '图片', width: 84 },
|
||||
{ label: 'SPU', width: 180 },
|
||||
{ label: '运营', width: 96 },
|
||||
{ label: '是否有图纸', width: 96 },
|
||||
{ label: '是否有安装视频', width: 104 },
|
||||
{ label: 'SPU', width: 180, filterField: 'spu' },
|
||||
{ label: '业绩', width: 124 },
|
||||
{ label: '供应商', width: 184 },
|
||||
{ label: '运营', width: 96, filterField: 'opsHead' },
|
||||
{ label: '是否有图纸', width: 128, filterField: 'hasDrawing' },
|
||||
{ label: '是否有安装视频', width: 126, filterField: 'hasVideo' },
|
||||
{ label: '上传链接', width: 108 },
|
||||
{ label: '专票/普票', width: 96 },
|
||||
{ label: '退货地址', width: 260 },
|
||||
{ label: '专票/普票', width: 112, filterField: 'invoiceType' },
|
||||
{ label: '退货地址', width: 260, filterField: 'returnAddress' },
|
||||
] as const
|
||||
|
||||
export function DropshipIssuesTable({
|
||||
initialRows,
|
||||
canEdit,
|
||||
columnFilters,
|
||||
filterContext,
|
||||
}: {
|
||||
initialRows: DropshipIssueSpuRow[]
|
||||
canEdit: boolean
|
||||
columnFilters: DropshipIssueColumnFilters
|
||||
filterContext: DropshipIssueFilterContext
|
||||
}) {
|
||||
const [rows, setRows] = useState(initialRows)
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set())
|
||||
@ -72,9 +84,26 @@ export function DropshipIssuesTable({
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{COLUMNS.map((column) => (
|
||||
<th key={column.label}>{column.label}</th>
|
||||
))}
|
||||
{COLUMNS.map((column) => {
|
||||
const filterField = 'filterField' in column
|
||||
? column.filterField as DropshipIssueColumnFilterField
|
||||
: null
|
||||
return (
|
||||
<th key={column.label}>
|
||||
<span className="di-column-heading">
|
||||
<span>{column.label}</span>
|
||||
{filterField && (
|
||||
<DropshipIssueColumnFilter
|
||||
field={filterField}
|
||||
label={column.label}
|
||||
filters={columnFilters}
|
||||
context={filterContext}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -112,11 +141,19 @@ export function DropshipIssuesTable({
|
||||
{row.spu || '--'}
|
||||
<span className="di-sku-count">{row.skuCount} 个条码</span>
|
||||
</td>
|
||||
<td className="di-money">{moneyText(row.monthPerformance)}</td>
|
||||
<td className="di-supplier" title={row.supplierName || undefined}>
|
||||
{row.supplierName || '--'}
|
||||
</td>
|
||||
<td>{row.opsHead || '--'}</td>
|
||||
<td>
|
||||
<span className={hasNo(row.hasDrawingCount) ? 'di-yes' : 'di-no'}>
|
||||
{hasNo(row.hasDrawingCount) ? '有' : '无'}
|
||||
</span>
|
||||
{row.assemblyType === '整装' ? (
|
||||
<span className="di-exempt">整装不需要图纸</span>
|
||||
) : (
|
||||
<span className={hasNo(row.hasDrawingCount) ? 'di-yes' : 'di-no'}>
|
||||
{hasNo(row.hasDrawingCount) ? '有' : '无'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={hasNo(row.hasVideoCount) ? 'di-yes' : 'di-no'}>
|
||||
@ -176,7 +213,13 @@ export function DropshipIssuesTable({
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && <DropshipSkuPanel spu={row.spu} columnCount={columnCount} />}
|
||||
{isOpen && (
|
||||
<DropshipSkuPanel
|
||||
spu={row.spu}
|
||||
assemblyType={row.assemblyType}
|
||||
columnCount={columnCount}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
@ -195,6 +238,7 @@ export function DropshipIssuesTable({
|
||||
...item,
|
||||
invoiceType: updated.invoiceType,
|
||||
returnAddress: updated.returnAddress,
|
||||
assemblyType: updated.assemblyType,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
@ -231,6 +275,11 @@ export function DropshipIssuesTable({
|
||||
border-bottom: 1px solid #e4e8ea;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.di-column-heading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.di-table td {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
@ -284,6 +333,16 @@ export function DropshipIssuesTable({
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
.di-money {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #27272a;
|
||||
}
|
||||
.di-supplier {
|
||||
color: #3f3f46;
|
||||
}
|
||||
.di-exempt {
|
||||
color: #7b8385;
|
||||
}
|
||||
.di-address {
|
||||
white-space: normal;
|
||||
line-height: 1.45;
|
||||
@ -366,6 +425,12 @@ export function DropshipIssuesTable({
|
||||
.di-sku-row .di-no {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.di-sku-row .di-money {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.di-sku-row .di-exempt {
|
||||
color: #7b8385;
|
||||
}
|
||||
.di-sku-row .di-upload-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@ -12,15 +12,9 @@ export type DropshipOpsOption = {
|
||||
export function DropshipOpsFilter({
|
||||
options,
|
||||
selected,
|
||||
keyword,
|
||||
pendingInvoice,
|
||||
pendingReturnAddress,
|
||||
}: {
|
||||
options: DropshipOpsOption[]
|
||||
selected: string[]
|
||||
keyword: string
|
||||
pendingInvoice: boolean
|
||||
pendingReturnAddress: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -41,11 +35,9 @@ export function DropshipOpsFilter({
|
||||
}, [open])
|
||||
|
||||
function navigate(nextSelected: string[]) {
|
||||
const params = new URLSearchParams()
|
||||
if (keyword) params.set('keyword', keyword)
|
||||
if (pendingInvoice) params.set('pendingInvoice', 'true')
|
||||
if (pendingReturnAddress) params.set('pendingReturnAddress', 'true')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (nextSelected.length > 0) params.set('ops', nextSelected.join(','))
|
||||
else params.delete('ops')
|
||||
params.set('page', '1')
|
||||
router.push(`/supply-chain/new-products/dropship-issues?${params.toString()}`)
|
||||
}
|
||||
|
||||
@ -4,17 +4,20 @@ import { useEffect, useState } from 'react'
|
||||
import { ExternalLink, LoaderCircle } from 'lucide-react'
|
||||
import type { ApiResponse } from '@/server/core/types'
|
||||
import { DropshipIssueImageCell } from './DropshipIssueImageCell'
|
||||
import type { DropshipIssueSkuRow } from './types'
|
||||
import { moneyText } from './format'
|
||||
import type { DropshipAssemblyType, DropshipIssueSkuRow } from './types'
|
||||
|
||||
// 跳主页面而非登录页:图纸系统登录态有效时直接进入,未登录由它自己跳登录。
|
||||
const DRAWING_SYSTEM_URL = 'https://ms.jiansuntuzhi.cn:6040/#/main'
|
||||
|
||||
type DropshipSkuPanelProps = {
|
||||
spu: string
|
||||
/** 继承 SPU 的安装方式:整装条码同样显示"整装不需要图纸"。 */
|
||||
assemblyType: DropshipAssemblyType
|
||||
columnCount: number
|
||||
}
|
||||
|
||||
export function DropshipSkuPanel({ spu, columnCount }: DropshipSkuPanelProps) {
|
||||
export function DropshipSkuPanel({ spu, assemblyType, columnCount }: DropshipSkuPanelProps) {
|
||||
const [rows, setRows] = useState<DropshipIssueSkuRow[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@ -90,11 +93,19 @@ export function DropshipSkuPanel({ spu, columnCount }: DropshipSkuPanelProps) {
|
||||
<td className="di-mono" title={row.barcode}>
|
||||
{row.barcode || '--'}
|
||||
</td>
|
||||
<td className="di-money">{moneyText(row.monthPerformance)}</td>
|
||||
<td className="di-supplier" title={row.supplierName || undefined}>
|
||||
{row.supplierName || '--'}
|
||||
</td>
|
||||
<td>{row.opsHead || '--'}</td>
|
||||
<td>
|
||||
<span className={row.hasDrawing === '有' ? 'di-yes' : 'di-no'}>
|
||||
{row.hasDrawing === '有' ? '有' : '无'}
|
||||
</span>
|
||||
{assemblyType === '整装' ? (
|
||||
<span className="di-exempt">整装不需要图纸</span>
|
||||
) : (
|
||||
<span className={row.hasDrawing === '有' ? 'di-yes' : 'di-no'}>
|
||||
{row.hasDrawing === '有' ? '有' : '无'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={row.hasVideo === '有' ? 'di-yes' : 'di-no'}>
|
||||
|
||||
@ -68,26 +68,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggleField {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 7px !important;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.toggleField input[type='checkbox'] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: #3f3f46;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggleField > span {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectField {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
@ -146,10 +126,49 @@
|
||||
color: var(--sc-muted);
|
||||
}
|
||||
|
||||
.filterApplyError {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 24px;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: min(420px, calc(100vw - 32px));
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filterApplyError span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filterApplyError button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filterApplyError button:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.tableRegion {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 14px 18px 88px;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
jest.mock('@/lib/server-session', () => ({ requireSession: jest.fn() }))
|
||||
jest.mock('@/server/services/supply-chain-dropship-issue-filter-state.service', () => ({
|
||||
supplyChainDropshipIssueFilterStateService: { save: jest.fn() },
|
||||
}))
|
||||
jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
|
||||
supplyChainPermissionSettingsService: { getUserAccess: jest.fn() },
|
||||
}))
|
||||
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { supplyChainDropshipIssueFilterStateService } from '@/server/services/supply-chain-dropship-issue-filter-state.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
import { saveDropshipIssueColumnFilters } from './filters.actions'
|
||||
|
||||
describe('saveDropshipIssueColumnFilters', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.mocked(requireSession).mockResolvedValue({ authUser: { id: 'viewer-a', role: 'USER' } } as never)
|
||||
jest.mocked(supplyChainPermissionSettingsService.getUserAccess).mockResolvedValue({
|
||||
view: true,
|
||||
edit: false,
|
||||
download: false,
|
||||
})
|
||||
jest.mocked(supplyChainDropshipIssueFilterStateService.save).mockResolvedValue('a'.repeat(64))
|
||||
})
|
||||
|
||||
it('saves valid filters in the authenticated viewer scope', async () => {
|
||||
const filters = { spu: { mode: 'include' as const, values: ['SPU-A'] } }
|
||||
expect(await saveDropshipIssueColumnFilters(filters)).toBe('a'.repeat(64))
|
||||
expect(supplyChainDropshipIssueFilterStateService.save).toHaveBeenCalledWith('viewer-a', filters)
|
||||
expect(supplyChainPermissionSettingsService.getUserAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'viewer-a' }),
|
||||
'newProductDropshipIssues',
|
||||
)
|
||||
})
|
||||
|
||||
it('denies viewers without page access before saving', async () => {
|
||||
jest.mocked(supplyChainPermissionSettingsService.getUserAccess).mockResolvedValue({
|
||||
view: false,
|
||||
edit: false,
|
||||
download: false,
|
||||
})
|
||||
await expect(saveDropshipIssueColumnFilters({})).rejects.toThrow('没有1688代发异常事项查看权限')
|
||||
expect(supplyChainDropshipIssueFilterStateService.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid values before saving', async () => {
|
||||
await expect(saveDropshipIssueColumnFilters({ spu: { mode: 'include', values: [4] } } as never))
|
||||
.rejects.toThrow('筛选条件不正确')
|
||||
expect(supplyChainDropshipIssueFilterStateService.save).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,28 @@
|
||||
'use server'
|
||||
|
||||
import {
|
||||
dropshipIssueColumnFiltersSchema,
|
||||
type DropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { supplyChainDropshipIssueFilterStateService } from '@/server/services/supply-chain-dropship-issue-filter-state.service'
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
|
||||
export async function saveDropshipIssueColumnFilters(
|
||||
filters: DropshipIssueColumnFilters,
|
||||
) {
|
||||
const { authUser } = await requireSession('/login')
|
||||
const access = await supplyChainPermissionSettingsService.getUserAccess(
|
||||
authUser,
|
||||
'newProductDropshipIssues',
|
||||
)
|
||||
if (!access.view) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有1688代发异常事项查看权限')
|
||||
}
|
||||
const parsed = dropshipIssueColumnFiltersSchema.safeParse(filters)
|
||||
if (!parsed.success) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件不正确')
|
||||
}
|
||||
return supplyChainDropshipIssueFilterStateService.save(authUser.id, parsed.data)
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
const moneyFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
|
||||
/** 当月真实业绩:以人民币两位小数展示,无值显示 --。 */
|
||||
export function moneyText(value: string) {
|
||||
const number = Number(value)
|
||||
return value && Number.isFinite(number) ? `¥${moneyFormatter.format(number)}` : '--'
|
||||
}
|
||||
@ -8,6 +8,12 @@ import { DropshipIssuesTable } from './DropshipIssuesTable'
|
||||
import { DropshipIssuesPagination } from './DropshipIssuesPagination'
|
||||
import { DropshipOpsFilter } from './DropshipOpsFilter'
|
||||
import type { DropshipIssueSpuRow } from './types'
|
||||
import {
|
||||
parseBooleanFilter,
|
||||
parseDropshipIssueColumnFilters,
|
||||
serializeDropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
import { supplyChainDropshipIssueFilterStateService } from '@/server/services/supply-chain-dropship-issue-filter-state.service'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const metadata = { title: '1688代发异常事项 · 供应链中心' }
|
||||
@ -21,18 +27,6 @@ function firstValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] ?? '' : value ?? ''
|
||||
}
|
||||
|
||||
function flagValue(value: string | string[] | undefined) {
|
||||
return firstValue(value) === 'true'
|
||||
}
|
||||
|
||||
// 三态:'true' → true(只看有),'false' → false(只看无),其他 → undefined(全部)
|
||||
function triStateValue(value: string | string[] | undefined) {
|
||||
const raw = firstValue(value)
|
||||
if (raw === 'true') return true
|
||||
if (raw === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pageSizeValue(value: string | string[] | undefined) {
|
||||
const parsed = Number.parseInt(firstValue(value), 10)
|
||||
return (PAGE_SIZE_OPTIONS as readonly number[]).includes(parsed) ? parsed : DEFAULT_PAGE_SIZE
|
||||
@ -52,10 +46,26 @@ export default async function DropshipIssuesPage({
|
||||
|
||||
const params = await searchParams
|
||||
const keyword = firstValue(params?.keyword).slice(0, 120)
|
||||
const pendingInvoice = flagValue(params?.pendingInvoice)
|
||||
const pendingReturnAddress = flagValue(params?.pendingReturnAddress)
|
||||
const hasDrawing = triStateValue(params?.hasDrawing)
|
||||
const hasVideo = triStateValue(params?.hasVideo)
|
||||
const hasInvoice = parseBooleanFilter(params?.hasInvoice)
|
||||
const hasReturnAddress = parseBooleanFilter(params?.hasReturnAddress)
|
||||
const hasDrawing = parseBooleanFilter(params?.hasDrawing)
|
||||
const hasVideo = parseBooleanFilter(params?.hasVideo)
|
||||
const filterRef = firstValue(params?.filterRef)
|
||||
const columnFilters = filterRef
|
||||
? await supplyChainDropshipIssueFilterStateService.load(authUser.id, filterRef)
|
||||
: parseDropshipIssueColumnFilters(firstValue(params?.columnFilters))
|
||||
if (columnFilters === null) {
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.filters} role="alert">
|
||||
<span>表头筛选条件已失效,请重新选择。</span>
|
||||
<a className={styles.resetButton} href="/supply-chain/new-products/dropship-issues">
|
||||
<RefreshCw size={14} />重置筛选
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
const selectedOps = firstValue(params?.ops)
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
@ -69,11 +79,12 @@ export default async function DropshipIssuesPage({
|
||||
page: Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1,
|
||||
pageSize,
|
||||
keyword,
|
||||
pendingInvoice,
|
||||
pendingReturnAddress,
|
||||
hasInvoice,
|
||||
hasReturnAddress,
|
||||
opsHeads: selectedOps.length > 0 ? selectedOps : undefined,
|
||||
hasDrawing,
|
||||
hasVideo,
|
||||
columnFilters,
|
||||
}),
|
||||
supplyChainDropshipIssueService.listOpsHeads(),
|
||||
])
|
||||
@ -84,6 +95,9 @@ export default async function DropshipIssuesPage({
|
||||
image: row.image,
|
||||
productName: row.productName,
|
||||
opsHead: row.opsHead,
|
||||
supplierName: row.supplierName,
|
||||
monthPerformance: row.monthPerformance,
|
||||
assemblyType: row.assemblyType === '整装' || row.assemblyType === '拆装' ? row.assemblyType : '',
|
||||
skuCount: row.skuCount,
|
||||
hasDrawingCount: row.hasDrawingCount,
|
||||
hasVideoCount: row.hasVideoCount,
|
||||
@ -96,6 +110,9 @@ export default async function DropshipIssuesPage({
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<form className={styles.filters} method="get">
|
||||
{filterRef
|
||||
? <input type="hidden" name="filterRef" value={filterRef} />
|
||||
: <input type="hidden" name="columnFilters" value={serializeDropshipIssueColumnFilters(columnFilters)} />}
|
||||
{selectedOps.length > 0 && (
|
||||
<input type="hidden" name="ops" value={selectedOps.join(',')} />
|
||||
)}
|
||||
@ -112,23 +129,21 @@ export default async function DropshipIssuesPage({
|
||||
placeholder="SPU / 条码 / 产品名称 / 运营"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.toggleField}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="pendingInvoice"
|
||||
value="true"
|
||||
defaultChecked={pendingInvoice}
|
||||
/>
|
||||
<span>只看待填发票</span>
|
||||
<label className={styles.selectField}>
|
||||
<span>是否有发票</span>
|
||||
<select name="hasInvoice" defaultValue={hasInvoice === undefined ? '' : String(hasInvoice)}>
|
||||
<option value="">全部</option>
|
||||
<option value="true">有</option>
|
||||
<option value="false">无</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.toggleField}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="pendingReturnAddress"
|
||||
value="true"
|
||||
defaultChecked={pendingReturnAddress}
|
||||
/>
|
||||
<span>只看待填退货地址</span>
|
||||
<label className={styles.selectField}>
|
||||
<span>是否有退货地址</span>
|
||||
<select name="hasReturnAddress" defaultValue={hasReturnAddress === undefined ? '' : String(hasReturnAddress)}>
|
||||
<option value="">全部</option>
|
||||
<option value="true">有</option>
|
||||
<option value="false">无</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.selectField}>
|
||||
<span>是否有图纸</span>
|
||||
@ -155,9 +170,6 @@ export default async function DropshipIssuesPage({
|
||||
<DropshipOpsFilter
|
||||
options={opsOptions}
|
||||
selected={selectedOps}
|
||||
keyword={keyword}
|
||||
pendingInvoice={pendingInvoice}
|
||||
pendingReturnAddress={pendingReturnAddress}
|
||||
/>
|
||||
<button className={styles.searchButton} type="submit">
|
||||
<Search size={15} />
|
||||
@ -170,7 +182,19 @@ export default async function DropshipIssuesPage({
|
||||
</form>
|
||||
|
||||
<section className={styles.tableRegion} aria-label="1688代发异常事项表">
|
||||
<DropshipIssuesTable initialRows={rows} canEdit={Boolean(access.edit)} />
|
||||
<DropshipIssuesTable
|
||||
initialRows={rows}
|
||||
canEdit={Boolean(access.edit)}
|
||||
columnFilters={columnFilters}
|
||||
filterContext={{
|
||||
keyword,
|
||||
hasInvoice,
|
||||
hasReturnAddress,
|
||||
opsHeads: selectedOps.length ? selectedOps : undefined,
|
||||
hasDrawing,
|
||||
hasVideo,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<DropshipIssuesPagination
|
||||
@ -179,11 +203,13 @@ export default async function DropshipIssuesPage({
|
||||
total={data.pageInfo.total}
|
||||
totalPages={data.pageInfo.totalPages}
|
||||
keyword={keyword}
|
||||
pendingInvoice={pendingInvoice}
|
||||
pendingReturnAddress={pendingReturnAddress}
|
||||
hasInvoice={hasInvoice}
|
||||
hasReturnAddress={hasReturnAddress}
|
||||
ops={selectedOps}
|
||||
hasDrawing={hasDrawing}
|
||||
hasVideo={hasVideo}
|
||||
filterRef={filterRef}
|
||||
columnFilters={columnFilters}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
/** 安装方式:由运营在本页维护,空表示还没维护。 */
|
||||
export type DropshipAssemblyType = '' | '整装' | '拆装'
|
||||
|
||||
export type DropshipIssueSpuRow = {
|
||||
spu: string
|
||||
barcode: string
|
||||
image: string
|
||||
productName: string
|
||||
opsHead: string
|
||||
supplierName: string
|
||||
/** 当月真实业绩:该 SPU 下全部条码的合计。 */
|
||||
monthPerformance: string
|
||||
assemblyType: DropshipAssemblyType
|
||||
skuCount: number
|
||||
hasDrawingCount: number
|
||||
hasVideoCount: number
|
||||
@ -19,6 +26,9 @@ export type DropshipIssueSkuRow = {
|
||||
productName: string
|
||||
image: string | null
|
||||
opsHead: string
|
||||
supplierName: string
|
||||
/** 当月真实业绩:该条码自身。 */
|
||||
monthPerformance: string
|
||||
hasDrawing: string
|
||||
hasVideo: string
|
||||
installDocId: string
|
||||
@ -33,6 +43,16 @@ export type DropshipIssuePageInfo = {
|
||||
|
||||
export type DropshipIssueListFilters = {
|
||||
keyword: string
|
||||
pendingInvoice: boolean
|
||||
pendingReturnAddress: boolean
|
||||
hasInvoice?: boolean
|
||||
hasReturnAddress?: boolean
|
||||
}
|
||||
|
||||
export type DropshipIssueFilterContext = {
|
||||
keyword: string
|
||||
hasInvoice?: boolean
|
||||
hasReturnAddress?: boolean
|
||||
invoiceType?: '普票' | '专票'
|
||||
opsHeads?: string[]
|
||||
hasDrawing?: boolean
|
||||
hasVideo?: boolean
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
import {
|
||||
normalizeDropshipIssueColumnFilters,
|
||||
parseBooleanFilter,
|
||||
parseDropshipIssueColumnFilters,
|
||||
serializeDropshipIssueColumnFilters,
|
||||
} from './supply-chain-dropship-issue-filters'
|
||||
|
||||
describe('supply-chain-dropship-issue-filters', () => {
|
||||
it('解析并规范化允许的列头筛选', () => {
|
||||
expect(parseDropshipIssueColumnFilters(JSON.stringify({
|
||||
spu: { mode: 'include', values: ['DF-B', 'DF-A', 'DF-A'] },
|
||||
hasDrawing: { mode: 'exclude', values: [] },
|
||||
}))).toEqual({
|
||||
spu: { mode: 'include', values: ['DF-A', 'DF-B'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('非法 JSON 或非法字段回退为空筛选', () => {
|
||||
expect(parseDropshipIssueColumnFilters('{')).toEqual({})
|
||||
expect(parseDropshipIssueColumnFilters(JSON.stringify({ unknown: { mode: 'include', values: [] } }))).toEqual({})
|
||||
})
|
||||
|
||||
it('序列化时移除等同于未筛选的选择', () => {
|
||||
expect(serializeDropshipIssueColumnFilters({
|
||||
opsHead: { mode: 'exclude', values: [] },
|
||||
invoiceType: { mode: 'include', values: ['专票'] },
|
||||
})).toBe(JSON.stringify({ invoiceType: { mode: 'include', values: ['专票'] } }))
|
||||
})
|
||||
|
||||
it('解析顶部全部/有/无三态值', () => {
|
||||
expect(parseBooleanFilter('true')).toBe(true)
|
||||
expect(parseBooleanFilter(['false'])).toBe(false)
|
||||
expect(parseBooleanFilter('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('拒绝单列超过候选上限的筛选', () => {
|
||||
const values = Array.from({ length: 5_001 }, (_, index) => String(index))
|
||||
expect(parseDropshipIssueColumnFilters(JSON.stringify({
|
||||
spu: { mode: 'include', values },
|
||||
}))).toEqual({})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,74 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
normalizeColumnHeaderFilterSelection,
|
||||
type ColumnHeaderFilterSelection,
|
||||
} from '@/lib/column-header-filters'
|
||||
|
||||
export const DROPSHIP_ISSUE_COLUMN_FILTER_FIELDS = [
|
||||
'spu',
|
||||
'opsHead',
|
||||
'hasDrawing',
|
||||
'hasVideo',
|
||||
'invoiceType',
|
||||
'returnAddress',
|
||||
] as const
|
||||
|
||||
export type DropshipIssueColumnFilterField =
|
||||
(typeof DROPSHIP_ISSUE_COLUMN_FILTER_FIELDS)[number]
|
||||
|
||||
export const DROPSHIP_ISSUE_FILTER_MAX_BYTES = 500_000
|
||||
|
||||
const dropshipIssueColumnFilterSelectionSchema = z.object({
|
||||
mode: z.enum(['include', 'exclude']),
|
||||
values: z.array(z.string().max(2_048)).max(5_000),
|
||||
}).strict()
|
||||
|
||||
export const dropshipIssueColumnFiltersSchema = z.object({
|
||||
spu: dropshipIssueColumnFilterSelectionSchema.optional(),
|
||||
opsHead: dropshipIssueColumnFilterSelectionSchema.optional(),
|
||||
hasDrawing: dropshipIssueColumnFilterSelectionSchema.optional(),
|
||||
hasVideo: dropshipIssueColumnFilterSelectionSchema.optional(),
|
||||
invoiceType: dropshipIssueColumnFilterSelectionSchema.optional(),
|
||||
returnAddress: dropshipIssueColumnFilterSelectionSchema.optional(),
|
||||
}).strict()
|
||||
|
||||
export type DropshipIssueColumnFilters = Partial<
|
||||
Record<DropshipIssueColumnFilterField, ColumnHeaderFilterSelection>
|
||||
>
|
||||
|
||||
export function normalizeDropshipIssueColumnFilters(
|
||||
filters: DropshipIssueColumnFilters,
|
||||
): DropshipIssueColumnFilters {
|
||||
const normalized: DropshipIssueColumnFilters = {}
|
||||
for (const field of DROPSHIP_ISSUE_COLUMN_FILTER_FIELDS) {
|
||||
const selection = normalizeColumnHeaderFilterSelection(filters[field])
|
||||
if (selection) normalized[field] = selection
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function parseDropshipIssueColumnFilters(
|
||||
value: string | null | undefined,
|
||||
): DropshipIssueColumnFilters {
|
||||
if (!value) return {}
|
||||
try {
|
||||
const parsed = dropshipIssueColumnFiltersSchema.safeParse(JSON.parse(value))
|
||||
return parsed.success ? normalizeDropshipIssueColumnFilters(parsed.data) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeDropshipIssueColumnFilters(
|
||||
filters: DropshipIssueColumnFilters,
|
||||
): string {
|
||||
const normalized = normalizeDropshipIssueColumnFilters(filters)
|
||||
return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : ''
|
||||
}
|
||||
|
||||
export function parseBooleanFilter(value: string | string[] | undefined) {
|
||||
const raw = Array.isArray(value) ? value[0] : value
|
||||
if (raw === 'true') return true
|
||||
if (raw === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
$queryRaw: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SupplyChainDropshipIssueRepositoryClass } from '../supply-chain-dropship-issue.repository'
|
||||
|
||||
const queryRaw = prisma.$queryRaw as jest.Mock
|
||||
|
||||
function queryText(query: { sql?: string; text?: string }) {
|
||||
return query.sql ?? query.text ?? String(query)
|
||||
}
|
||||
|
||||
describe('SupplyChainDropshipIssueRepository', () => {
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it('applies top presence filters and Excel include/exclude selections to the full SPU query', async () => {
|
||||
queryRaw
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ total: 0 }])
|
||||
const repository = new SupplyChainDropshipIssueRepositoryClass()
|
||||
|
||||
await repository.listSpuPage({
|
||||
keyword: '桌',
|
||||
hasInvoice: false,
|
||||
hasReturnAddress: true,
|
||||
hasDrawing: true,
|
||||
hasVideo: false,
|
||||
opsHeads: ['运营甲'],
|
||||
columnFilters: {
|
||||
spu: { mode: 'include', values: ['DF-A', 'DF-B'] },
|
||||
invoiceType: { mode: 'exclude', values: ['未填写'] },
|
||||
},
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
})
|
||||
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2)
|
||||
const pageQuery = queryRaw.mock.calls[0][0]
|
||||
const text = queryText(pageQuery)
|
||||
// 当月无业绩的 SPU 整行不显示,并且默认按当月真实业绩降序。
|
||||
expect(text).toContain('spp."monthPerformance" > 0')
|
||||
expect(text).toContain('ORDER BY spp."monthPerformance" DESC')
|
||||
expect(text).toContain('COALESCE(sup."supplierName", \'\') AS "supplierName"')
|
||||
expect(text).toContain('agg."hasDrawingCount" > 0')
|
||||
expect(text).toContain('agg."hasVideoCount" = 0')
|
||||
expect(text).toContain('COALESCE(m."invoiceType", \'\') = \'\'')
|
||||
expect(text).toContain('COALESCE(m."returnAddress", \'\') <> \'\'')
|
||||
expect(text).toContain('ops."opsHead" IN')
|
||||
expect(text).toContain('rep.spu IN')
|
||||
expect(text).toContain('NOT (COALESCE(NULLIF(m."invoiceType", \'\'), \'未填写\') IN')
|
||||
expect(pageQuery.values).toEqual(expect.arrayContaining([
|
||||
'%桌%', '运营甲', 'DF-A', 'DF-B', '未填写', 50, 100,
|
||||
]))
|
||||
})
|
||||
|
||||
it('candidate query ignores its own selection while keeping all other filters', async () => {
|
||||
queryRaw.mockResolvedValueOnce([{ value: 'DF-A' }, { value: 'DF-B' }])
|
||||
const repository = new SupplyChainDropshipIssueRepositoryClass()
|
||||
|
||||
await expect(repository.listColumnFilterValues({
|
||||
field: 'spu',
|
||||
filters: {
|
||||
columnFilters: {
|
||||
spu: { mode: 'include', values: ['DF-OLD'] },
|
||||
opsHead: { mode: 'include', values: ['运营甲'] },
|
||||
},
|
||||
},
|
||||
})).resolves.toEqual(['DF-A', 'DF-B'])
|
||||
|
||||
const query = queryRaw.mock.calls[0][0]
|
||||
const text = queryText(query)
|
||||
expect(text).toContain('SELECT DISTINCT rep.spu AS value')
|
||||
expect(text).toContain('COALESCE(ops."opsHead", \'\') IN')
|
||||
expect(query.values).toContain('运营甲')
|
||||
expect(query.values).not.toContain('DF-OLD')
|
||||
})
|
||||
|
||||
it('include with no selected value yields an empty result condition', async () => {
|
||||
queryRaw
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ total: 0 }])
|
||||
const repository = new SupplyChainDropshipIssueRepositoryClass()
|
||||
await repository.listSpuPage({
|
||||
columnFilters: { spu: { mode: 'include', values: [] } },
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
expect(queryText(queryRaw.mock.calls[0][0]))
|
||||
.toContain('spp."monthPerformance" > 0 AND FALSE')
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,10 @@
|
||||
import { OperationLogAction, Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { shanghaiToday } from '@/lib/product-archive-sales-period'
|
||||
import type {
|
||||
DropshipIssueColumnFilterField,
|
||||
DropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
|
||||
export type DropshipIssueSpuRow = {
|
||||
spu: string
|
||||
@ -7,19 +12,28 @@ export type DropshipIssueSpuRow = {
|
||||
image: string
|
||||
productName: string
|
||||
opsHead: string
|
||||
supplierName: string
|
||||
/** 当月真实业绩(条码级汇总后按 SPU 合计),保留数据库的小数位文本。 */
|
||||
monthPerformance: string
|
||||
/** 整装/拆装/空,由运营在本页维护。 */
|
||||
assemblyType: string
|
||||
skuCount: number
|
||||
hasDrawingCount: number
|
||||
hasVideoCount: number
|
||||
}
|
||||
|
||||
export type DropshipIssueSpuListParams = {
|
||||
export type DropshipIssueSpuFilters = {
|
||||
keyword?: string
|
||||
pendingInvoice?: boolean
|
||||
pendingReturnAddress?: boolean
|
||||
hasInvoice?: boolean
|
||||
hasReturnAddress?: boolean
|
||||
invoiceType?: string
|
||||
opsHeads?: string[]
|
||||
hasDrawing?: boolean
|
||||
hasVideo?: boolean
|
||||
columnFilters?: DropshipIssueColumnFilters
|
||||
}
|
||||
|
||||
export type DropshipIssueSpuListParams = DropshipIssueSpuFilters & {
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
@ -40,16 +54,24 @@ export interface SupplyChainDropshipIssueRepository {
|
||||
productName: string
|
||||
image: string | null
|
||||
opsHead: string
|
||||
supplierName: string
|
||||
/** 该条码的当月真实业绩。 */
|
||||
monthPerformance: string
|
||||
hasDrawing: string
|
||||
hasVideo: string
|
||||
installDocId: string
|
||||
}[]
|
||||
>
|
||||
listOpsHeads(): Promise<{ name: string; spuCount: number }[]>
|
||||
listColumnFilterValues(input: {
|
||||
field: DropshipIssueColumnFilterField
|
||||
filters: DropshipIssueSpuFilters
|
||||
}): Promise<string[]>
|
||||
findBySpu(spu: string): Promise<{
|
||||
spu: string
|
||||
invoiceType: string
|
||||
returnAddress: string
|
||||
assemblyType: string
|
||||
updatedByName: string
|
||||
updatedAt: Date
|
||||
} | null>
|
||||
@ -57,10 +79,15 @@ export interface SupplyChainDropshipIssueRepository {
|
||||
spu: string
|
||||
invoiceType: string
|
||||
returnAddress: string
|
||||
assemblyType: string
|
||||
updatedByName: string
|
||||
}): Promise<{
|
||||
previous: { invoiceType: string; returnAddress: string } | null
|
||||
next: { invoiceType: string; returnAddress: string }
|
||||
previous: {
|
||||
invoiceType: string
|
||||
returnAddress: string
|
||||
assemblyType: string
|
||||
} | null
|
||||
next: { invoiceType: string; returnAddress: string; assemblyType: string }
|
||||
}>
|
||||
listAuditLogs(input: {
|
||||
spu: string
|
||||
@ -81,9 +108,16 @@ export interface SupplyChainDropshipIssueRepository {
|
||||
// that actually have an image; counts come from a grouped join.
|
||||
// The CTE prefix is shared by both the page query and the count query because
|
||||
// a WITH clause only applies to the single statement it belongs to.
|
||||
const SPU_CTE = Prisma.sql`
|
||||
// 当月窗口与 1688 代发数据明细页同口径:本自然月 1 号至当天(上海时区)。
|
||||
function monthSalesWindow() {
|
||||
const today = shanghaiToday()
|
||||
return { monthEnd: today, monthStart: `${today.slice(0, 7)}-01` }
|
||||
}
|
||||
|
||||
function spuCte(monthStart: string, monthEnd: string) {
|
||||
return Prisma.sql`
|
||||
WITH df AS (
|
||||
SELECT spu, barcode, image, "productName", "opsHead", "hasDrawing", "hasVideo", "sales7d"
|
||||
SELECT spu, barcode, image, "productName", "opsHead", "supplierName", "hasDrawing", "hasVideo"
|
||||
FROM "SupplyChainProductArchive"
|
||||
WHERE barcode LIKE 'DF%'
|
||||
),
|
||||
@ -99,27 +133,49 @@ const SPU_CTE = Prisma.sql`
|
||||
WHERE "opsHead" <> ''
|
||||
GROUP BY spu
|
||||
),
|
||||
sup AS (
|
||||
SELECT spu, min(nullif("supplierName", '')) AS "supplierName"
|
||||
FROM df
|
||||
WHERE "supplierName" <> ''
|
||||
GROUP BY spu
|
||||
),
|
||||
agg AS (
|
||||
SELECT spu,
|
||||
count(*)::int AS "skuCount",
|
||||
count(*) FILTER (WHERE "hasDrawing" = '有')::int AS "hasDrawingCount",
|
||||
count(*) FILTER (WHERE "hasVideo" = '有')::int AS "hasVideoCount",
|
||||
COALESCE(sum(
|
||||
CASE WHEN "sales7d" ~ '^[0-9]+(\.[0-9]+)?$' THEN "sales7d"::numeric ELSE 0 END
|
||||
), 0) AS "sales7dSum"
|
||||
count(*) FILTER (WHERE "hasVideo" = '有')::int AS "hasVideoCount"
|
||||
FROM df
|
||||
GROUP BY spu
|
||||
),
|
||||
perf AS (
|
||||
SELECT d."barcode", sum(d."realPerformance") AS "monthPerformance"
|
||||
FROM "SupplyChainProductArchiveSalesPerformanceDaily" d
|
||||
WHERE d."statDate" >= ${monthStart}::date AND d."statDate" <= ${monthEnd}::date
|
||||
GROUP BY d."barcode"
|
||||
),
|
||||
spp AS (
|
||||
SELECT df.spu, sum(perf."monthPerformance") AS "monthPerformance"
|
||||
FROM df
|
||||
JOIN perf ON perf."barcode" = df.barcode
|
||||
GROUP BY df.spu
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
const SPU_PAGE_QUERY = Prisma.sql`
|
||||
${SPU_CTE}
|
||||
// 当月无业绩的 SPU 整行不显示:命中条件合并进基础候选集,
|
||||
// 列表、总数和列头候选值口径保持一致。
|
||||
const SPU_MONTH_PERFORMANCE_CONDITION = Prisma.sql`spp."monthPerformance" > 0`
|
||||
|
||||
const SPU_PAGE_SELECT = Prisma.sql`
|
||||
SELECT
|
||||
rep.spu,
|
||||
rep.barcode,
|
||||
COALESCE(rep.image, '') AS image,
|
||||
COALESCE(rep."productName", '') AS "productName",
|
||||
COALESCE(ops."opsHead", '') AS "opsHead",
|
||||
COALESCE(sup."supplierName", '') AS "supplierName",
|
||||
spp."monthPerformance"::text AS "monthPerformance",
|
||||
COALESCE(m."assemblyType", '') AS "assemblyType",
|
||||
agg."skuCount",
|
||||
agg."hasDrawingCount",
|
||||
agg."hasVideoCount",
|
||||
@ -129,59 +185,96 @@ const SPU_PAGE_QUERY = Prisma.sql`
|
||||
m."updatedAt"
|
||||
FROM rep
|
||||
JOIN agg ON agg.spu = rep.spu
|
||||
JOIN spp ON spp.spu = rep.spu
|
||||
LEFT JOIN ops ON ops.spu = rep.spu
|
||||
LEFT JOIN sup ON sup.spu = rep.spu
|
||||
LEFT JOIN "SupplyChainDropshipIssue" m ON m.spu = rep.spu
|
||||
`
|
||||
|
||||
class SupplyChainDropshipIssueRepositoryClass
|
||||
const SPU_FROM_JOINS = Prisma.sql`
|
||||
FROM rep
|
||||
JOIN agg ON agg.spu = rep.spu
|
||||
JOIN spp ON spp.spu = rep.spu
|
||||
LEFT JOIN ops ON ops.spu = rep.spu
|
||||
LEFT JOIN sup ON sup.spu = rep.spu
|
||||
LEFT JOIN "SupplyChainDropshipIssue" m ON m.spu = rep.spu
|
||||
`
|
||||
|
||||
const FILTER_EXPRESSIONS: Record<DropshipIssueColumnFilterField, Prisma.Sql> = {
|
||||
spu: Prisma.sql`rep.spu`,
|
||||
opsHead: Prisma.sql`COALESCE(ops."opsHead", '')`,
|
||||
hasDrawing: Prisma.sql`CASE WHEN agg."hasDrawingCount" > 0 THEN '有' ELSE '无' END`,
|
||||
hasVideo: Prisma.sql`CASE WHEN agg."hasVideoCount" > 0 THEN '有' ELSE '无' END`,
|
||||
invoiceType: Prisma.sql`COALESCE(NULLIF(m."invoiceType", ''), '未填写')`,
|
||||
returnAddress: Prisma.sql`CASE WHEN COALESCE(m."returnAddress", '') <> '' THEN '有' ELSE '无' END`,
|
||||
}
|
||||
|
||||
function columnFilterCondition(
|
||||
expression: Prisma.Sql,
|
||||
selection: DropshipIssueColumnFilters[DropshipIssueColumnFilterField],
|
||||
) {
|
||||
if (!selection) return null
|
||||
if (selection.values.length === 0) return selection.mode === 'include' ? Prisma.sql`FALSE` : null
|
||||
const values = selection.values.map((value) => Prisma.sql`${value}`)
|
||||
const condition = Prisma.sql`${expression} IN (${Prisma.join(values, ', ')})`
|
||||
return selection.mode === 'include' ? condition : Prisma.sql`NOT (${condition})`
|
||||
}
|
||||
|
||||
function buildConditions(
|
||||
params: DropshipIssueSpuFilters,
|
||||
omittedColumn?: DropshipIssueColumnFilterField,
|
||||
) {
|
||||
const conditions: Prisma.Sql[] = [SPU_MONTH_PERFORMANCE_CONDITION]
|
||||
const keyword = params.keyword?.trim()
|
||||
if (keyword) {
|
||||
const like = `%${keyword.replace(/[%_]/g, (character) => `\\${character}`)}%`
|
||||
conditions.push(
|
||||
Prisma.sql`(rep.spu ILIKE ${like} OR rep."productName" ILIKE ${like} OR ops."opsHead" ILIKE ${like}
|
||||
OR EXISTS (SELECT 1 FROM df b WHERE b.spu = rep.spu AND b.barcode ILIKE ${like}))`,
|
||||
)
|
||||
}
|
||||
if (params.hasDrawing === true) conditions.push(Prisma.sql`agg."hasDrawingCount" > 0`)
|
||||
else if (params.hasDrawing === false) conditions.push(Prisma.sql`agg."hasDrawingCount" = 0`)
|
||||
if (params.hasVideo === true) conditions.push(Prisma.sql`agg."hasVideoCount" > 0`)
|
||||
else if (params.hasVideo === false) conditions.push(Prisma.sql`agg."hasVideoCount" = 0`)
|
||||
if (params.hasInvoice === true) conditions.push(Prisma.sql`COALESCE(m."invoiceType", '') <> ''`)
|
||||
else if (params.hasInvoice === false) conditions.push(Prisma.sql`COALESCE(m."invoiceType", '') = ''`)
|
||||
if (params.hasReturnAddress === true) conditions.push(Prisma.sql`COALESCE(m."returnAddress", '') <> ''`)
|
||||
else if (params.hasReturnAddress === false) conditions.push(Prisma.sql`COALESCE(m."returnAddress", '') = ''`)
|
||||
if (params.opsHeads?.length) {
|
||||
const names = params.opsHeads.map((name) => name.trim()).filter(Boolean).slice(0, 50)
|
||||
if (names.length) {
|
||||
conditions.push(Prisma.sql`ops."opsHead" IN (${Prisma.join(names.map((name) => Prisma.sql`${name}`), ', ')})`)
|
||||
}
|
||||
}
|
||||
const invoiceType = params.invoiceType?.trim()
|
||||
if (invoiceType) conditions.push(Prisma.sql`m."invoiceType" = ${invoiceType}`)
|
||||
for (const [field, expression] of Object.entries(FILTER_EXPRESSIONS) as [DropshipIssueColumnFilterField, Prisma.Sql][]) {
|
||||
if (field === omittedColumn) continue
|
||||
const condition = columnFilterCondition(expression, params.columnFilters?.[field])
|
||||
if (condition) conditions.push(condition)
|
||||
}
|
||||
return conditions
|
||||
}
|
||||
|
||||
function whereSql(conditions: Prisma.Sql[]) {
|
||||
return conditions.length
|
||||
? Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`
|
||||
: Prisma.empty
|
||||
}
|
||||
|
||||
export class SupplyChainDropshipIssueRepositoryClass
|
||||
implements SupplyChainDropshipIssueRepository
|
||||
{
|
||||
async listSpuPage(params: DropshipIssueSpuListParams) {
|
||||
const conditions: Prisma.Sql[] = []
|
||||
const keyword = params.keyword?.trim()
|
||||
if (keyword) {
|
||||
const like = `%${keyword.replace(/[%_]/g, (ch) => '\\' + ch)}%`
|
||||
conditions.push(
|
||||
Prisma.sql`(rep.spu ILIKE ${like} OR rep."productName" ILIKE ${like} OR ops."opsHead" ILIKE ${like}
|
||||
OR EXISTS (SELECT 1 FROM df b WHERE b.spu = rep.spu AND b.barcode ILIKE ${like}))`,
|
||||
)
|
||||
}
|
||||
if (params.hasDrawing === true) {
|
||||
conditions.push(Prisma.sql`agg."hasDrawingCount" > 0`)
|
||||
} else if (params.hasDrawing === false) {
|
||||
conditions.push(Prisma.sql`agg."hasDrawingCount" = 0`)
|
||||
}
|
||||
if (params.hasVideo === true) {
|
||||
conditions.push(Prisma.sql`agg."hasVideoCount" > 0`)
|
||||
} else if (params.hasVideo === false) {
|
||||
conditions.push(Prisma.sql`agg."hasVideoCount" = 0`)
|
||||
}
|
||||
if (params.pendingInvoice) {
|
||||
conditions.push(Prisma.sql`COALESCE(m."invoiceType", '') = ''`)
|
||||
}
|
||||
if (params.pendingReturnAddress) {
|
||||
conditions.push(Prisma.sql`COALESCE(m."returnAddress", '') = ''`)
|
||||
}
|
||||
if (params.opsHeads && params.opsHeads.length > 0) {
|
||||
const names = params.opsHeads.map((name) => name.trim()).filter(Boolean).slice(0, 50)
|
||||
if (names.length > 0) {
|
||||
conditions.push(
|
||||
Prisma.sql`ops."opsHead" IN (${Prisma.join(names.map((name) => Prisma.sql`${name}`), ', ')})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const invoiceType = params.invoiceType?.trim()
|
||||
if (invoiceType) {
|
||||
conditions.push(Prisma.sql`m."invoiceType" = ${invoiceType}`)
|
||||
}
|
||||
const where = conditions.length
|
||||
? Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`
|
||||
: Prisma.empty
|
||||
const where = whereSql(buildConditions(params))
|
||||
const { monthStart, monthEnd } = monthSalesWindow()
|
||||
const cte = spuCte(monthStart, monthEnd)
|
||||
|
||||
const limit = Math.min(200, Math.max(1, params.limit))
|
||||
const offset = Math.max(0, params.offset)
|
||||
|
||||
// 默认按销售业绩(SPU 近 7 天销量合计)降序,无销量的按 SPU 号稳定排列。
|
||||
// 默认按当月真实业绩降序,同业绩的按 SPU 号稳定排列。
|
||||
const rows = await prisma.$queryRaw<
|
||||
(DropshipIssueSpuRow & {
|
||||
invoiceType: string
|
||||
@ -190,24 +283,33 @@ class SupplyChainDropshipIssueRepositoryClass
|
||||
updatedAt: Date | null
|
||||
})[]
|
||||
>(
|
||||
Prisma.sql`${SPU_PAGE_QUERY} ${where} ORDER BY agg."sales7dSum" DESC, rep.spu ASC LIMIT ${limit} OFFSET ${offset}`,
|
||||
Prisma.sql`${cte} ${SPU_PAGE_SELECT} ${where} ORDER BY spp."monthPerformance" DESC, rep.spu ASC LIMIT ${limit} OFFSET ${offset}`,
|
||||
)
|
||||
|
||||
const totalRows = await prisma.$queryRaw<{ total: number }[]>(
|
||||
Prisma.sql`
|
||||
${SPU_CTE}
|
||||
SELECT count(*)::int AS total
|
||||
FROM rep
|
||||
JOIN agg ON agg.spu = rep.spu
|
||||
LEFT JOIN ops ON ops.spu = rep.spu
|
||||
LEFT JOIN "SupplyChainDropshipIssue" m ON m.spu = rep.spu
|
||||
${where}
|
||||
`,
|
||||
Prisma.sql`${cte} SELECT count(*)::int AS total ${SPU_FROM_JOINS} ${where}`,
|
||||
)
|
||||
|
||||
return { rows, total: totalRows[0]?.total ?? 0 }
|
||||
}
|
||||
|
||||
async listColumnFilterValues(input: {
|
||||
field: DropshipIssueColumnFilterField
|
||||
filters: DropshipIssueSpuFilters
|
||||
}) {
|
||||
const expression = FILTER_EXPRESSIONS[input.field]
|
||||
const where = whereSql(buildConditions(input.filters, input.field))
|
||||
const { monthStart, monthEnd } = monthSalesWindow()
|
||||
const rows = await prisma.$queryRaw<{ value: string }[]>(Prisma.sql`
|
||||
${spuCte(monthStart, monthEnd)}
|
||||
SELECT DISTINCT ${expression} AS value
|
||||
${SPU_FROM_JOINS}
|
||||
${where}
|
||||
ORDER BY value ASC
|
||||
`)
|
||||
return rows.map((row) => row.value ?? '')
|
||||
}
|
||||
|
||||
async listSkusBySpu(spu: string) {
|
||||
const rows = await prisma.supplyChainProductArchive.findMany({
|
||||
where: { spu, barcode: { startsWith: 'DF' } },
|
||||
@ -217,33 +319,71 @@ class SupplyChainDropshipIssueRepositoryClass
|
||||
productName: true,
|
||||
image: true,
|
||||
opsHead: true,
|
||||
supplierName: true,
|
||||
hasDrawing: true,
|
||||
hasVideo: true,
|
||||
installDocId: true,
|
||||
},
|
||||
orderBy: { barcode: 'asc' },
|
||||
})
|
||||
// 上游运营字段是条码级稀疏填充(仅约 16% 条码有值),
|
||||
// 同一 SPU 的运营视为同一人:条码行自身为空时回填该 SPU 下任意非空值。
|
||||
const fallback = rows.find((row) => row.opsHead)?.opsHead ?? ''
|
||||
if (!fallback) return rows
|
||||
return rows.map((row) => (row.opsHead ? row : { ...row, opsHead: fallback }))
|
||||
if (rows.length === 0) return []
|
||||
|
||||
const { monthStart, monthEnd } = monthSalesWindow()
|
||||
const barcodes = rows.map((row) => row.barcode)
|
||||
const performances = await prisma.$queryRaw<{ barcode: string; monthPerformance: string }[]>(
|
||||
Prisma.sql`
|
||||
SELECT d."barcode", sum(d."realPerformance")::text AS "monthPerformance"
|
||||
FROM "SupplyChainProductArchiveSalesPerformanceDaily" d
|
||||
WHERE d."barcode" IN (${Prisma.join(barcodes.map((barcode) => Prisma.sql`${barcode}`), ', ')})
|
||||
AND d."statDate" >= ${monthStart}::date AND d."statDate" <= ${monthEnd}::date
|
||||
GROUP BY d."barcode"
|
||||
`,
|
||||
)
|
||||
const performanceByBarcode = new Map(
|
||||
performances.map((record) => [record.barcode, record.monthPerformance] as const),
|
||||
)
|
||||
|
||||
// 上游运营与供应商字段都是条码级稀疏填充,同一 SPU 视为同一归属:
|
||||
// 条码行自身为空时回填该 SPU 下任意非空值。
|
||||
const opsFallback = rows.find((row) => row.opsHead)?.opsHead ?? ''
|
||||
const supplierFallback = rows.find((row) => row.supplierName)?.supplierName ?? ''
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
opsHead: row.opsHead || opsFallback,
|
||||
supplierName: row.supplierName || supplierFallback,
|
||||
monthPerformance: performanceByBarcode.get(row.barcode) ?? '0',
|
||||
}))
|
||||
}
|
||||
|
||||
// 运营筛选下拉的选项:DF 数据里出现过的运营名单 + 各自名下 SPU 数。
|
||||
// 运营筛选下拉的选项:当月有业绩的 DF SPU 里出现过的运营名单 + 各自名下 SPU 数。
|
||||
async listOpsHeads() {
|
||||
return prisma.$queryRaw<{ name: string; spuCount: number }[]>`
|
||||
const { monthStart, monthEnd } = monthSalesWindow()
|
||||
return prisma.$queryRaw<{ name: string; spuCount: number }[]>(Prisma.sql`
|
||||
WITH df AS (
|
||||
SELECT spu, "opsHead"
|
||||
SELECT spu, barcode, "opsHead"
|
||||
FROM "SupplyChainProductArchive"
|
||||
WHERE barcode LIKE 'DF%'
|
||||
),
|
||||
perf AS (
|
||||
SELECT d."barcode", sum(d."realPerformance") AS "monthPerformance"
|
||||
FROM "SupplyChainProductArchiveSalesPerformanceDaily" d
|
||||
WHERE d."statDate" >= ${monthStart}::date AND d."statDate" <= ${monthEnd}::date
|
||||
GROUP BY d."barcode"
|
||||
),
|
||||
spp AS (
|
||||
SELECT df.spu
|
||||
FROM df
|
||||
JOIN perf ON perf."barcode" = df.barcode
|
||||
GROUP BY df.spu
|
||||
HAVING sum(perf."monthPerformance") > 0
|
||||
)
|
||||
SELECT "opsHead" AS name, count(DISTINCT spu)::int AS "spuCount"
|
||||
SELECT df."opsHead" AS name, count(DISTINCT df.spu)::int AS "spuCount"
|
||||
FROM df
|
||||
WHERE "opsHead" <> ''
|
||||
GROUP BY "opsHead"
|
||||
ORDER BY count(DISTINCT spu) DESC, "opsHead" ASC
|
||||
`
|
||||
JOIN spp ON spp.spu = df.spu
|
||||
WHERE df."opsHead" <> ''
|
||||
GROUP BY df."opsHead"
|
||||
ORDER BY count(DISTINCT df.spu) DESC, df."opsHead" ASC
|
||||
`)
|
||||
}
|
||||
|
||||
async findBySpu(spu: string) {
|
||||
@ -253,6 +393,7 @@ class SupplyChainDropshipIssueRepositoryClass
|
||||
spu: true,
|
||||
invoiceType: true,
|
||||
returnAddress: true,
|
||||
assemblyType: true,
|
||||
updatedByName: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
@ -263,11 +404,12 @@ class SupplyChainDropshipIssueRepositoryClass
|
||||
spu: string
|
||||
invoiceType: string
|
||||
returnAddress: string
|
||||
assemblyType: string
|
||||
updatedByName: string
|
||||
}) {
|
||||
const previous = await prisma.supplyChainDropshipIssue.findUnique({
|
||||
where: { spu: input.spu },
|
||||
select: { invoiceType: true, returnAddress: true },
|
||||
select: { invoiceType: true, returnAddress: true, assemblyType: true },
|
||||
})
|
||||
await prisma.supplyChainDropshipIssue.upsert({
|
||||
where: { spu: input.spu },
|
||||
@ -275,18 +417,24 @@ class SupplyChainDropshipIssueRepositoryClass
|
||||
spu: input.spu,
|
||||
invoiceType: input.invoiceType,
|
||||
returnAddress: input.returnAddress,
|
||||
assemblyType: input.assemblyType,
|
||||
updatedByName: input.updatedByName,
|
||||
},
|
||||
update: {
|
||||
invoiceType: input.invoiceType,
|
||||
returnAddress: input.returnAddress,
|
||||
assemblyType: input.assemblyType,
|
||||
updatedByName: input.updatedByName,
|
||||
},
|
||||
select: { spu: true },
|
||||
})
|
||||
return {
|
||||
previous,
|
||||
next: { invoiceType: input.invoiceType, returnAddress: input.returnAddress },
|
||||
next: {
|
||||
invoiceType: input.invoiceType,
|
||||
returnAddress: input.returnAddress,
|
||||
assemblyType: input.assemblyType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
jest.mock('@/server/cache', () => ({ cacheService: { get: jest.fn(), set: jest.fn() } }))
|
||||
|
||||
import { cacheService } from '@/server/cache'
|
||||
import { DROPSHIP_ISSUE_FILTER_MAX_BYTES } from '@/lib/supply-chain-dropship-issue-filters'
|
||||
import { supplyChainDropshipIssueFilterStateService } from '../supply-chain-dropship-issue-filter-state.service'
|
||||
|
||||
const cache = new Map<string, unknown>()
|
||||
|
||||
describe('supplyChainDropshipIssueFilterStateService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
cache.clear()
|
||||
jest.mocked(cacheService.get).mockImplementation(async (key) => (cache.get(key) ?? null) as never)
|
||||
jest.mocked(cacheService.set).mockImplementation(async (key, value) => { cache.set(key, value) })
|
||||
})
|
||||
|
||||
it('stores normalized filters under a user-scoped digest for 24 hours', async () => {
|
||||
const filters = {
|
||||
spu: { mode: 'include' as const, values: ['SPU-B', 'SPU-A', 'SPU-B'] },
|
||||
invoiceType: { mode: 'exclude' as const, values: ['未填写'] },
|
||||
}
|
||||
const filterRef = await supplyChainDropshipIssueFilterStateService.save('user-a', filters)
|
||||
|
||||
expect(filterRef).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(cacheService.set).toHaveBeenCalledWith(
|
||||
`dropship-issues:filter-state:user-a:${filterRef}`,
|
||||
{
|
||||
spu: { mode: 'include', values: ['SPU-A', 'SPU-B'] },
|
||||
invoiceType: { mode: 'exclude', values: ['未填写'] },
|
||||
},
|
||||
86400,
|
||||
)
|
||||
expect(await supplyChainDropshipIssueFilterStateService.load('user-a', filterRef)).toEqual({
|
||||
spu: { mode: 'include', values: ['SPU-A', 'SPU-B'] },
|
||||
invoiceType: { mode: 'exclude', values: ['未填写'] },
|
||||
})
|
||||
expect(await supplyChainDropshipIssueFilterStateService.load('user-b', filterRef)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects malformed, expired and corrupted references', async () => {
|
||||
expect(await supplyChainDropshipIssueFilterStateService.load('user-a', '../bad-ref')).toBeNull()
|
||||
expect(cacheService.get).not.toHaveBeenCalled()
|
||||
|
||||
const filterRef = await supplyChainDropshipIssueFilterStateService.save('user-a', {
|
||||
spu: { mode: 'include', values: [] },
|
||||
})
|
||||
cache.clear()
|
||||
expect(await supplyChainDropshipIssueFilterStateService.load('user-a', filterRef)).toBeNull()
|
||||
cache.set(`dropship-issues:filter-state:user-a:${filterRef}`, { unknown: true })
|
||||
expect(await supplyChainDropshipIssueFilterStateService.load('user-a', filterRef)).toBeNull()
|
||||
cache.set(`dropship-issues:filter-state:user-a:${filterRef}`, {})
|
||||
expect(await supplyChainDropshipIssueFilterStateService.load('user-a', filterRef)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects oversized selections before writing cache', async () => {
|
||||
const filters = {
|
||||
spu: {
|
||||
mode: 'include' as const,
|
||||
values: Array.from({ length: 400 }, (_, index) => `${index}-${'中'.repeat(700)}`),
|
||||
},
|
||||
}
|
||||
expect(Buffer.byteLength(JSON.stringify(filters), 'utf8')).toBeGreaterThan(DROPSHIP_ISSUE_FILTER_MAX_BYTES)
|
||||
await expect(supplyChainDropshipIssueFilterStateService.save('user-a', filters)).rejects.toThrow('筛选条件过多')
|
||||
expect(cacheService.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -12,6 +12,8 @@ import type { SupplyChainDropshipIssueRepository } from '@/server/repositories/s
|
||||
function createRepositoryMock(): jest.Mocked<SupplyChainDropshipIssueRepository> {
|
||||
return {
|
||||
listSpuPage: jest.fn(),
|
||||
listColumnFilterValues: jest.fn(),
|
||||
listOpsHeads: jest.fn(),
|
||||
listSkusBySpu: jest.fn(),
|
||||
listAuditLogs: jest.fn(),
|
||||
findBySpu: jest.fn(),
|
||||
@ -21,6 +23,19 @@ function createRepositoryMock(): jest.Mocked<SupplyChainDropshipIssueRepository>
|
||||
|
||||
const actor: DropshipIssueActor = { id: 'user-1', name: '测试运营' }
|
||||
|
||||
const archiveSkuRow = {
|
||||
barcode: 'DF-A1-01',
|
||||
sku: 'SKU-1',
|
||||
productName: '测试产品',
|
||||
image: null,
|
||||
opsHead: '运营A',
|
||||
supplierName: '测试供应商',
|
||||
monthPerformance: '1234.5600',
|
||||
hasDrawing: '有',
|
||||
hasVideo: '无',
|
||||
installDocId: '',
|
||||
}
|
||||
|
||||
describe('supplyChainDropshipIssueService', () => {
|
||||
let repository: jest.Mocked<SupplyChainDropshipIssueRepository>
|
||||
let service: SupplyChainDropshipIssueService
|
||||
@ -41,14 +56,20 @@ describe('supplyChainDropshipIssueService', () => {
|
||||
page: 0,
|
||||
pageSize: 999,
|
||||
keyword: ' DF-1 ',
|
||||
pendingInvoice: true,
|
||||
pendingReturnAddress: false,
|
||||
hasInvoice: false,
|
||||
hasReturnAddress: true,
|
||||
opsHeads: [' 运营甲 ', '运营甲', '运营乙'],
|
||||
columnFilters: { spu: { mode: 'include', values: ['DF-A', 'DF-A'] } },
|
||||
})
|
||||
expect(repository.listSpuPage).toHaveBeenCalledWith({
|
||||
keyword: ' DF-1 ',
|
||||
pendingInvoice: true,
|
||||
pendingReturnAddress: false,
|
||||
hasInvoice: false,
|
||||
hasReturnAddress: true,
|
||||
invoiceType: undefined,
|
||||
opsHeads: ['运营甲', '运营乙'],
|
||||
hasDrawing: undefined,
|
||||
hasVideo: undefined,
|
||||
columnFilters: { spu: { mode: 'include', values: ['DF-A'] } },
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
})
|
||||
@ -66,6 +87,26 @@ describe('supplyChainDropshipIssueService', () => {
|
||||
).rejects.toThrow('发票类型不正确')
|
||||
expect(repository.listSpuPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads column candidates with normalized filters', async () => {
|
||||
repository.listColumnFilterValues.mockResolvedValue(['DF-A', 'DF-B'])
|
||||
await expect(service.listColumnFilterOptions({
|
||||
field: 'spu',
|
||||
filters: {
|
||||
keyword: '桌',
|
||||
opsHeads: [' 运营甲 ', '运营甲'],
|
||||
columnFilters: { opsHead: { mode: 'include', values: ['运营甲', '运营甲'] } },
|
||||
},
|
||||
})).resolves.toEqual({ values: ['DF-A', 'DF-B'] })
|
||||
expect(repository.listColumnFilterValues).toHaveBeenCalledWith({
|
||||
field: 'spu',
|
||||
filters: {
|
||||
keyword: '桌',
|
||||
opsHeads: ['运营甲'],
|
||||
columnFilters: { opsHead: { mode: 'include', values: ['运营甲'] } },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('listSkusBySpu / listAuditLogs', () => {
|
||||
@ -87,16 +128,28 @@ describe('supplyChainDropshipIssueService', () => {
|
||||
})
|
||||
|
||||
describe('updateSpu', () => {
|
||||
it('rejects invalid SPU, invoice type and overlong address before touching data', async () => {
|
||||
it('rejects invalid SPU, invoice type, assembly type and overlong address before touching data', async () => {
|
||||
await expect(
|
||||
service.updateSpu({ spu: '不合法', invoiceType: '普票', returnAddress: '' }, actor),
|
||||
service.updateSpu(
|
||||
{ spu: '不合法', invoiceType: '普票', returnAddress: '', assemblyType: '' },
|
||||
actor,
|
||||
),
|
||||
).rejects.toThrow('SPU 不正确')
|
||||
await expect(
|
||||
service.updateSpu({ spu: 'DF-A1', invoiceType: '收据', returnAddress: '' }, actor),
|
||||
service.updateSpu(
|
||||
{ spu: 'DF-A1', invoiceType: '收据', returnAddress: '', assemblyType: '' },
|
||||
actor,
|
||||
),
|
||||
).rejects.toThrow('发票类型只能是普票或专票')
|
||||
await expect(
|
||||
service.updateSpu(
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: 'x'.repeat(501) },
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: '', assemblyType: '组装' },
|
||||
actor,
|
||||
),
|
||||
).rejects.toThrow('安装方式只能是整装或拆装')
|
||||
await expect(
|
||||
service.updateSpu(
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: 'x'.repeat(501), assemblyType: '' },
|
||||
actor,
|
||||
),
|
||||
).rejects.toThrow('退货地址过长')
|
||||
@ -106,31 +159,23 @@ describe('supplyChainDropshipIssueService', () => {
|
||||
it('rejects SPUs without any dropship SKU in the archive', async () => {
|
||||
repository.listSkusBySpu.mockResolvedValue([])
|
||||
await expect(
|
||||
service.updateSpu({ spu: 'DF-UNKNOWN', invoiceType: '普票', returnAddress: '地址' }, actor),
|
||||
service.updateSpu(
|
||||
{ spu: 'DF-UNKNOWN', invoiceType: '普票', returnAddress: '地址', assemblyType: '' },
|
||||
actor,
|
||||
),
|
||||
).rejects.toThrow('该 SPU 下没有代发 SKU')
|
||||
expect(repository.upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('upserts and records an operation log with a field-level diff', async () => {
|
||||
repository.listSkusBySpu.mockResolvedValue([
|
||||
{
|
||||
barcode: 'DF-A1-01',
|
||||
sku: 'SKU-1',
|
||||
productName: '测试产品',
|
||||
image: null,
|
||||
opsHead: '运营A',
|
||||
hasDrawing: '有',
|
||||
hasVideo: '无',
|
||||
installDocId: '',
|
||||
},
|
||||
])
|
||||
repository.listSkusBySpu.mockResolvedValue([archiveSkuRow])
|
||||
repository.upsert.mockResolvedValue({
|
||||
previous: { invoiceType: '专票', returnAddress: '' },
|
||||
next: { invoiceType: '普票', returnAddress: '新地址' },
|
||||
previous: { invoiceType: '专票', returnAddress: '', assemblyType: '' },
|
||||
next: { invoiceType: '普票', returnAddress: '新地址', assemblyType: '整装' },
|
||||
})
|
||||
|
||||
const result = await service.updateSpu(
|
||||
{ spu: ' df-a1 ', invoiceType: '普票', returnAddress: ' 新地址 ' },
|
||||
{ spu: ' df-a1 ', invoiceType: '普票', returnAddress: ' 新地址 ', assemblyType: '整装' },
|
||||
actor,
|
||||
)
|
||||
|
||||
@ -138,18 +183,21 @@ describe('supplyChainDropshipIssueService', () => {
|
||||
spu: 'DF-A1',
|
||||
invoiceType: '普票',
|
||||
returnAddress: '新地址',
|
||||
assemblyType: '整装',
|
||||
updatedByName: '测试运营',
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
spu: 'DF-A1',
|
||||
invoiceType: '普票',
|
||||
returnAddress: '新地址',
|
||||
assemblyType: '整装',
|
||||
updatedByName: '测试运营',
|
||||
changed: true,
|
||||
})
|
||||
const logCall = jest.mocked(operationLogService.record).mock.calls[0][0]
|
||||
expect(logCall.message).toContain('普票/专票')
|
||||
expect(logCall.message).toContain('退货地址')
|
||||
expect(logCall.message).toContain('安装方式')
|
||||
expect(operationLogService.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
productSpu: 'DF-A1',
|
||||
@ -158,28 +206,35 @@ describe('supplyChainDropshipIssueService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('skips the operation log when nothing actually changed', async () => {
|
||||
repository.listSkusBySpu.mockResolvedValue([])
|
||||
repository.listSkusBySpu.mockClear()
|
||||
repository.listSkusBySpu.mockResolvedValue([
|
||||
{
|
||||
barcode: 'DF-A1-01',
|
||||
sku: 'SKU-1',
|
||||
productName: '测试产品',
|
||||
image: null,
|
||||
opsHead: '运营A',
|
||||
hasDrawing: '无',
|
||||
hasVideo: '无',
|
||||
installDocId: '',
|
||||
},
|
||||
])
|
||||
it('records the assembly type change on its own', async () => {
|
||||
repository.listSkusBySpu.mockResolvedValue([archiveSkuRow])
|
||||
repository.upsert.mockResolvedValue({
|
||||
previous: { invoiceType: '普票', returnAddress: '地址' },
|
||||
next: { invoiceType: '普票', returnAddress: '地址' },
|
||||
previous: { invoiceType: '普票', returnAddress: '地址', assemblyType: '' },
|
||||
next: { invoiceType: '普票', returnAddress: '地址', assemblyType: '整装' },
|
||||
})
|
||||
|
||||
const result = await service.updateSpu(
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: '地址' },
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: '地址', assemblyType: '整装' },
|
||||
actor,
|
||||
)
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(jest.mocked(operationLogService.record).mock.calls[0][0].message).toContain(
|
||||
'安装方式: (未填) → 整装',
|
||||
)
|
||||
})
|
||||
|
||||
it('skips the operation log when nothing actually changed', async () => {
|
||||
repository.listSkusBySpu.mockResolvedValue([
|
||||
{ ...archiveSkuRow, hasDrawing: '无' },
|
||||
])
|
||||
repository.upsert.mockResolvedValue({
|
||||
previous: { invoiceType: '普票', returnAddress: '地址', assemblyType: '拆装' },
|
||||
next: { invoiceType: '普票', returnAddress: '地址', assemblyType: '拆装' },
|
||||
})
|
||||
|
||||
const result = await service.updateSpu(
|
||||
{ spu: 'DF-A1', invoiceType: '普票', returnAddress: '地址', assemblyType: '拆装' },
|
||||
actor,
|
||||
)
|
||||
expect(result.changed).toBe(false)
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
DROPSHIP_ISSUE_FILTER_MAX_BYTES,
|
||||
dropshipIssueColumnFiltersSchema,
|
||||
normalizeDropshipIssueColumnFilters,
|
||||
serializeDropshipIssueColumnFilters,
|
||||
type DropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
import { cacheService } from '@/server/cache'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
|
||||
const FILTER_STATE_TTL_SECONDS = 24 * 60 * 60
|
||||
const FILTER_REF_PATTERN = /^[a-f0-9]{64}$/
|
||||
|
||||
function cacheKey(userId: string, filterRef: string) {
|
||||
return `dropship-issues:filter-state:${userId}:${filterRef}`
|
||||
}
|
||||
|
||||
function digest(serialized: string) {
|
||||
return createHash('sha256').update(serialized).digest('hex')
|
||||
}
|
||||
|
||||
class SupplyChainDropshipIssueFilterStateService {
|
||||
async save(userId: string, filters: DropshipIssueColumnFilters) {
|
||||
const parsed = dropshipIssueColumnFiltersSchema.safeParse(filters)
|
||||
if (!parsed.success) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件不正确')
|
||||
}
|
||||
const normalized = normalizeDropshipIssueColumnFilters(parsed.data)
|
||||
const serialized = serializeDropshipIssueColumnFilters(normalized)
|
||||
if (Buffer.byteLength(serialized, 'utf8') > DROPSHIP_ISSUE_FILTER_MAX_BYTES) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件过多,请缩小选择范围')
|
||||
}
|
||||
const filterRef = digest(serialized)
|
||||
await cacheService.set(cacheKey(userId, filterRef), normalized, FILTER_STATE_TTL_SECONDS)
|
||||
return filterRef
|
||||
}
|
||||
|
||||
async load(userId: string, filterRef: string) {
|
||||
if (!FILTER_REF_PATTERN.test(filterRef)) return null
|
||||
const cached = await cacheService.get<unknown>(cacheKey(userId, filterRef))
|
||||
const parsed = dropshipIssueColumnFiltersSchema.safeParse(cached)
|
||||
if (!parsed.success) return null
|
||||
const normalized = normalizeDropshipIssueColumnFilters(parsed.data)
|
||||
if (digest(serializeDropshipIssueColumnFilters(normalized)) !== filterRef) return null
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
export const supplyChainDropshipIssueFilterStateService =
|
||||
new SupplyChainDropshipIssueFilterStateService()
|
||||
@ -4,14 +4,24 @@ import {
|
||||
} from '@prisma/client'
|
||||
import {
|
||||
supplyChainDropshipIssueRepository,
|
||||
type DropshipIssueSpuFilters,
|
||||
type SupplyChainDropshipIssueRepository,
|
||||
} from '@/server/repositories/supply-chain-dropship-issue.repository'
|
||||
import {
|
||||
normalizeDropshipIssueColumnFilters,
|
||||
type DropshipIssueColumnFilterField,
|
||||
type DropshipIssueColumnFilters,
|
||||
} from '@/lib/supply-chain-dropship-issue-filters'
|
||||
import { operationLogService } from '@/server/services/operation-log.service'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
|
||||
export const DROPSHIP_INVOICE_TYPES = ['普票', '专票'] as const
|
||||
export type DropshipInvoiceType = (typeof DROPSHIP_INVOICE_TYPES)[number]
|
||||
|
||||
/** 安装方式口径与资产中心/草稿组一致:整装不需要图纸,拆装需要上传图纸。 */
|
||||
export const DROPSHIP_ASSEMBLY_TYPES = ['整装', '拆装'] as const
|
||||
export type DropshipAssemblyType = (typeof DROPSHIP_ASSEMBLY_TYPES)[number]
|
||||
|
||||
export type DropshipIssueActor = {
|
||||
id: string
|
||||
name: string
|
||||
@ -32,12 +42,13 @@ export class SupplyChainDropshipIssueService {
|
||||
page: number
|
||||
pageSize: number
|
||||
keyword?: string
|
||||
pendingInvoice?: boolean
|
||||
pendingReturnAddress?: boolean
|
||||
hasInvoice?: boolean
|
||||
hasReturnAddress?: boolean
|
||||
invoiceType?: string
|
||||
opsHeads?: string[]
|
||||
hasDrawing?: boolean
|
||||
hasVideo?: boolean
|
||||
columnFilters?: DropshipIssueColumnFilters
|
||||
}) {
|
||||
const page = Math.max(1, Math.trunc(params.page))
|
||||
const pageSize = Math.min(200, Math.max(1, Math.trunc(params.pageSize)))
|
||||
@ -51,12 +62,13 @@ export class SupplyChainDropshipIssueService {
|
||||
: undefined
|
||||
const { rows, total } = await this.dependencies.repository.listSpuPage({
|
||||
keyword: params.keyword,
|
||||
pendingInvoice: params.pendingInvoice,
|
||||
pendingReturnAddress: params.pendingReturnAddress,
|
||||
hasInvoice: params.hasInvoice,
|
||||
hasReturnAddress: params.hasReturnAddress,
|
||||
invoiceType: params.invoiceType,
|
||||
opsHeads: opsHeads && opsHeads.length > 0 ? opsHeads : undefined,
|
||||
hasDrawing: params.hasDrawing,
|
||||
hasVideo: params.hasVideo,
|
||||
columnFilters: normalizeDropshipIssueColumnFilters(params.columnFilters ?? {}),
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
})
|
||||
@ -81,6 +93,27 @@ export class SupplyChainDropshipIssueService {
|
||||
return this.dependencies.repository.listOpsHeads()
|
||||
}
|
||||
|
||||
async listColumnFilterOptions(input: {
|
||||
field: DropshipIssueColumnFilterField
|
||||
filters: DropshipIssueSpuFilters
|
||||
}) {
|
||||
if (input.filters.invoiceType && !this.isValidInvoiceType(input.filters.invoiceType)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '发票类型不正确')
|
||||
}
|
||||
const opsHeads = input.filters.opsHeads
|
||||
? Array.from(new Set(input.filters.opsHeads.map((name) => name.trim()).filter(Boolean))).slice(0, 50)
|
||||
: undefined
|
||||
const values = await this.dependencies.repository.listColumnFilterValues({
|
||||
field: input.field,
|
||||
filters: {
|
||||
...input.filters,
|
||||
opsHeads: opsHeads?.length ? opsHeads : undefined,
|
||||
columnFilters: normalizeDropshipIssueColumnFilters(input.filters.columnFilters ?? {}),
|
||||
},
|
||||
})
|
||||
return { values }
|
||||
}
|
||||
|
||||
async listAuditLogs(spu: string) {
|
||||
const normalized = spu.trim().toUpperCase()
|
||||
if (!normalized) return []
|
||||
@ -95,6 +128,7 @@ export class SupplyChainDropshipIssueService {
|
||||
spu: string
|
||||
invoiceType: string
|
||||
returnAddress: string
|
||||
assemblyType: string
|
||||
},
|
||||
actor: DropshipIssueActor,
|
||||
) {
|
||||
@ -108,6 +142,12 @@ export class SupplyChainDropshipIssueService {
|
||||
'发票类型只能是普票或专票',
|
||||
)
|
||||
}
|
||||
if (!this.isValidAssemblyType(input.assemblyType)) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'安装方式只能是整装或拆装',
|
||||
)
|
||||
}
|
||||
const returnAddress = input.returnAddress.trim()
|
||||
if (returnAddress.length > 500) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '退货地址过长')
|
||||
@ -123,6 +163,7 @@ export class SupplyChainDropshipIssueService {
|
||||
spu,
|
||||
invoiceType: input.invoiceType,
|
||||
returnAddress,
|
||||
assemblyType: input.assemblyType,
|
||||
updatedByName: actor.name,
|
||||
})
|
||||
|
||||
@ -137,6 +178,11 @@ export class SupplyChainDropshipIssueService {
|
||||
`退货地址: ${previous ? previous.returnAddress || '(未填)' : '(未填)'} → ${next.returnAddress || '(未填)'}`,
|
||||
)
|
||||
}
|
||||
if (!previous || previous.assemblyType !== next.assemblyType) {
|
||||
changes.push(
|
||||
`安装方式: ${previous ? previous.assemblyType || '(未填)' : '(未填)'} → ${next.assemblyType || '(未填)'}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
await operationLogService.record({
|
||||
@ -150,7 +196,7 @@ export class SupplyChainDropshipIssueService {
|
||||
message: changes.join(';'),
|
||||
details: {
|
||||
spu,
|
||||
previous: previous ?? { invoiceType: '', returnAddress: '' },
|
||||
previous: previous ?? { invoiceType: '', returnAddress: '', assemblyType: '' },
|
||||
next,
|
||||
},
|
||||
})
|
||||
@ -162,6 +208,10 @@ export class SupplyChainDropshipIssueService {
|
||||
private isValidInvoiceType(value: string) {
|
||||
return value === '' || (DROPSHIP_INVOICE_TYPES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
private isValidAssemblyType(value: string) {
|
||||
return value === '' || (DROPSHIP_ASSEMBLY_TYPES as readonly string[]).includes(value)
|
||||
}
|
||||
}
|
||||
|
||||
export const supplyChainDropshipIssueService =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user