1688代发异常事项:当月业绩列/供应商列/整装拆装维护 + Excel式列头筛选

- 列表新增当月真实业绩列(SPU 汇总条码当月值,条码展开行显示自身),当月无业绩整行过滤
- 新增供应商列(取 SPU 内非空 supplierName),列顺序调整为 SPU|业绩|供应商|运营
- 维护抽屉新增整装/拆装二选一,标记整装的 SPU 在"是否有图纸"列显示"整装不需要图纸"
- 顶部有无筛选与 Excel 式列头筛选(含 filter-options 接口)
- 新增迁移 20260915180000_add_dropship_issue_assembly_type(SupplyChainDropshipIssue.assemblyType)
This commit is contained in:
WuYongJie 2026-09-16 08:43:27 +08:00
parent b0fd9a8800
commit 9e50804b2e
31 changed files with 1653 additions and 262 deletions

View File

@ -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 '';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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