Merge latest main into accepted homepage release

This commit is contained in:
WuYongJie 2026-10-19 15:17:01 +08:00
commit 5cf84a19c8
22 changed files with 1724 additions and 43 deletions

View File

@ -0,0 +1,108 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import {
COMBO_LIMIT_FIELD_KEYS,
type ComboLimitFieldKey,
type ComboLimitFilters,
} from '@/lib/supply-chain-combo-limit-price'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainComboLimitService } from '@/server/services/supply-chain-combo-limit.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 300
const querySchema = z.object({
field: z.enum(COMBO_LIMIT_FIELD_KEYS as [ComboLimitFieldKey, ...ComboLimitFieldKey[]]),
q: z.string().max(200).optional(),
offset: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(200),
})
const filtersSchema = z.object({
comboCode: z.array(z.string().max(200)).max(20000).optional(),
opsHead: z.array(z.string().max(60)).max(20000).optional(),
ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(),
nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(),
jxLimitPrice: z.array(z.string().max(40)).max(200).optional(),
alibabaPageLimitPrice: z.array(z.string().max(40)).max(200).optional(),
distributorPrice89: z.array(z.string().max(40)).max(200).optional(),
distributorPrice85: z.array(z.string().max(40)).max(200).optional(),
distributorPrice80: z.array(z.string().max(40)).max(200).optional(),
}).strict()
function parseJson(value: string | null) {
if (!value) return { success: true as const, data: null }
try {
return { success: true as const, data: JSON.parse(value) as unknown }
} catch {
return { success: false as const, data: null }
}
}
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId)
}
const query = querySchema.safeParse({
field: request.nextUrl.searchParams.get('field') ?? undefined,
q: request.nextUrl.searchParams.get('q') ?? undefined,
offset: request.nextUrl.searchParams.get('offset') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
})
if (!query.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段或分页参数不正确', 400, ctx.requestId)
}
const filtersPayload = parseJson(request.nextUrl.searchParams.get('filters'))
if (!filtersPayload.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const filters = filtersPayload.data === null
? { success: true as const, data: {} as ComboLimitFilters }
: filtersSchema.safeParse(filtersPayload.data)
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const data = await supplyChainComboLimitService.listOptions({
...query.data,
filters: filters.data,
})
return jsonSuccess(data, 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId)
}
const query = querySchema.safeParse({
field: request.nextUrl.searchParams.get('field') ?? undefined,
q: request.nextUrl.searchParams.get('q') ?? undefined,
offset: request.nextUrl.searchParams.get('offset') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
})
if (!query.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段或分页参数不正确', 400, ctx.requestId)
}
const filters = filtersSchema.safeParse(await request.json().catch(() => null))
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const data = await supplyChainComboLimitService.listOptions({
...query.data,
filters: filters.data,
})
return jsonSuccess(data, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,130 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import {
COMBO_LIMIT_FIELD_KEYS,
type ComboLimitFieldKey,
type ComboLimitFilters,
type ComboLimitSort,
} from '@/lib/supply-chain-combo-limit-price'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainComboLimitService } from '@/server/services/supply-chain-combo-limit.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 300
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
})
const filtersSchema = z.object({
comboCode: z.array(z.string().max(200)).max(20000).optional(),
opsHead: z.array(z.string().max(60)).max(20000).optional(),
ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(),
nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(),
jxLimitPrice: z.array(z.string().max(40)).max(200).optional(),
alibabaPageLimitPrice: z.array(z.string().max(40)).max(200).optional(),
distributorPrice89: z.array(z.string().max(40)).max(200).optional(),
distributorPrice85: z.array(z.string().max(40)).max(200).optional(),
distributorPrice80: z.array(z.string().max(40)).max(200).optional(),
}).strict()
const sortSchema = z.object({
field: z.enum(COMBO_LIMIT_FIELD_KEYS as [ComboLimitFieldKey, ...ComboLimitFieldKey[]]),
direction: z.enum(['asc', 'desc']),
}).strict()
function parseJson(value: string | null) {
if (!value) return { success: true as const, data: null }
try {
return { success: true as const, data: JSON.parse(value) as unknown }
} catch {
return { success: false as const, data: null }
}
}
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId)
}
const page = listQuerySchema.safeParse({
page: request.nextUrl.searchParams.get('page') ?? undefined,
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
})
if (!page.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
}
const filtersPayload = parseJson(request.nextUrl.searchParams.get('filters'))
if (!filtersPayload.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const filters = filtersPayload.data === null
? { success: true as const, data: {} as ComboLimitFilters }
: filtersSchema.safeParse(filtersPayload.data)
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const sortPayload = parseJson(request.nextUrl.searchParams.get('sort'))
if (!sortPayload.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId)
}
const sort = sortPayload.data === null
? { success: true as const, data: null as ComboLimitSort }
: sortSchema.safeParse(sortPayload.data)
if (!sort.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId)
}
const data = await supplyChainComboLimitService.listPage({
...page.data,
filters: filters.data,
sort: sort.data,
forceRefresh: request.nextUrl.searchParams.get('refresh') === '1',
})
return jsonSuccess(data, 200, ctx.requestId)
}, { requireAuth: true })
}
// 批量筛选项较多时 URL 会超长,前端改用 POST 传完整 filters / sort。
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId)
}
const page = listQuerySchema.safeParse({
page: request.nextUrl.searchParams.get('page') ?? undefined,
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
})
if (!page.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
}
const body = await request.json().catch(() => null) as { filters?: unknown; sort?: unknown } | null
const filters = filtersSchema.safeParse(body?.filters ?? {})
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const sort = sortSchema.safeParse(body?.sort ?? null)
if (body?.sort !== null && body?.sort !== undefined && !sort.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId)
}
const data = await supplyChainComboLimitService.listPage({
...page.data,
filters: filters.data,
sort: sort.success ? sort.data : null,
forceRefresh: request.nextUrl.searchParams.get('refresh') === '1',
})
return jsonSuccess(data, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -139,7 +139,7 @@ export const ARCHIVE_COLUMNS: ArchiveColumnGroup[] = [
group: '限价',
columns: [
// 负责人限价在「运营信息」分组里已有(运营负责人限价),这里不再重复一列
{ prop: 'nonOwnerLimitPriceNew', label: '非负责人限价(新)', width: 130 },
{ prop: 'nonOwnerLimitPriceNew', label: '非负责人限价', width: 130 },
{ prop: 'jxLimitPrice', label: '京喜限价', width: 100 },
{ prop: 'alibabaPageLimitPrice', label: '阿里页面限价', width: 110 },
{ prop: 'distributorPrice89', label: '分销商89折', width: 100 },

View File

@ -0,0 +1,360 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
COMBO_LIMIT_FIELDS,
isComboLimitFieldKey,
type ComboLimitFieldKey,
type ComboLimitFilters,
type ComboLimitRow,
type ComboLimitSort,
} from '@/lib/supply-chain-combo-limit-price'
import type { LimitTableColumn } from './limit-table-columns'
import { LimitFilterSelect } from './LimitFilterSelect'
import { LimitTableHeaderCell } from './LimitTableHeaderCell'
import type { LimitTableFilterField } from './useLimitTableSettings'
import styles from './limit-price.module.css'
const OPTIONS_ENDPOINT = '/api/supply-chain/limit-management/combo-limits/options'
const WIDTH_STORAGE_PREFIX = 'supply-chain.limit-management.combo-widths.v1'
const COMBO_COLUMNS: LimitTableColumn[] = COMBO_LIMIT_FIELDS.map((field) => ({
key: field.key,
label: field.label,
group: 'limit',
width: field.key === 'comboCode' ? 280 : 122,
kind: 'archive',
numeric: field.numeric,
}))
const COMBO_FILTER_FIELDS: LimitTableFilterField[] = COMBO_LIMIT_FIELDS
.filter((field) => field.key === 'comboCode' || field.key === 'opsHead')
.map((field) => ({
key: field.key,
label: field.label,
optionsEndpoint: OPTIONS_ENDPOINT,
}))
const COLUMN_MAP = new Map(COMBO_COLUMNS.map((column) => [column.key, column]))
const FIELD_LABEL_MAP = new Map(COMBO_LIMIT_FIELDS.map((field) => [field.key, field.label]))
function storageKey(userId: string) {
return `${WIDTH_STORAGE_PREFIX}:${userId || 'anonymous'}`
}
function readWidths(userId: string): Record<string, number> {
if (typeof window === 'undefined') return {}
try {
const raw = window.localStorage.getItem(storageKey(userId))
if (!raw) return {}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') return {}
return Object.fromEntries(
Object.entries(parsed).flatMap(([key, value]) => {
const width = Number(value)
return COLUMN_MAP.has(key) && Number.isFinite(width) && width >= 48 && width <= 600
? [[key, Math.round(width)]]
: []
}),
)
} catch {
return {}
}
}
export function ComboLimitSection({
userId,
rows,
page,
pageSize,
total,
totalPages,
loading,
error,
filters,
sort,
onPageChange,
onPageSizeChange,
onFiltersChange,
onSortChange,
}: {
userId: string
rows: ComboLimitRow[]
page: number
pageSize: number
total: number
totalPages: number
loading: boolean
error: string
filters: ComboLimitFilters
sort: ComboLimitSort
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: 50 | 100) => void
onFiltersChange: (filters: ComboLimitFilters) => void
onSortChange: (sort: ComboLimitSort) => void
}) {
const [openFilterKey, setOpenFilterKey] = useState<string | null>(null)
const [openSortKey, setOpenSortKey] = useState<string | null>(null)
const [widths, setWidths] = useState<Record<string, number>>(() => readWidths(userId))
const [liveWidths, setLiveWidths] = useState<Record<string, number> | null>(null)
useEffect(() => {
if (!openSortKey) return
const onDocumentMouseDown = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
if (target?.closest('[data-limit-sort-cell]') || target?.closest('[data-limit-sort-menu]')) return
setOpenSortKey(null)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpenSortKey(null)
}
document.addEventListener('mousedown', onDocumentMouseDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onDocumentMouseDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [openSortKey])
const contextFilters = useMemo(
() => filters as Record<string, string[]>,
[filters],
)
const activeFilterChips = useMemo(() => COMBO_LIMIT_FIELDS.flatMap((field) => {
const values = filters[field.key] ?? []
if (!values.length) return []
return [{
key: field.key,
label: field.label,
text: values.length > 3 ? `${values.slice(0, 3).join(',')}…(共 ${values.length} 个)` : values.join(','),
}]
}), [filters])
const updateFilter = (key: string, values: string[]) => {
if (!isComboLimitFieldKey(key)) return
const next = { ...filters }
if (values.length) next[key] = values
else delete next[key]
onFiltersChange(next)
onPageChange(1)
}
const clearAllFilters = () => {
onFiltersChange({})
onPageChange(1)
}
const columnWidth = (column: LimitTableColumn) =>
liveWidths?.[column.key] ?? widths[column.key] ?? column.width
const totalWidth = COMBO_COLUMNS.reduce((sum, column) => sum + columnWidth(column), 0)
const startHeaderResize = (
column: LimitTableColumn,
event: { clientX: number; preventDefault: () => void; stopPropagation: () => void },
) => {
event.preventDefault()
event.stopPropagation()
const startX = event.clientX
const startWidth = columnWidth(column)
const base = { ...widths }
let latest = startWidth
setLiveWidths(base)
const onMove = (moveEvent: MouseEvent) => {
latest = Math.max(48, Math.min(600, Math.round(startWidth + moveEvent.clientX - startX)))
setLiveWidths({ ...base, [column.key]: latest })
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
const next = { ...base, [column.key]: latest }
setWidths(next)
setLiveWidths(null)
try {
window.localStorage.setItem(storageKey(userId), JSON.stringify(next))
} catch {}
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const commitPageInput = (value: string) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return null
}
const next = Math.min(Math.max(Math.trunc(parsed), 1), Math.max(1, totalPages))
if (next !== page) onPageChange(next)
return next
}
return (
<>
<div className={styles.filterBar}>
<span className={styles.filterHint}>筛选</span>
{COMBO_FILTER_FIELDS.map((field) => (
<LimitFilterSelect
key={field.key}
field={field}
selected={(filters as Record<string, string[] | undefined>)[field.key] ?? []}
contextFilters={contextFilters}
onChange={(values) => updateFilter(field.key, values)}
/>
))}
</div>
{activeFilterChips.length > 0 && (
<div className={styles.filterChips} aria-label="已选筛选条件">
<span className={styles.filterChipsLabel}>已选条件</span>
{activeFilterChips.map((chip) => (
<span key={chip.key} className={styles.filterChip}>
<b>{FIELD_LABEL_MAP.get(chip.key as ComboLimitFieldKey) ?? chip.label}</b>
<span className={styles.filterChipValue}>属于:{chip.text}</span>
<button
type="button"
className={styles.filterChipClear}
onClick={() => updateFilter(chip.key, [])}
title={`清除「${chip.label}」筛选`}
aria-label={`清除「${chip.label}」筛选`}
>
×
</button>
</span>
))}
<button type="button" className={styles.filterChipsReset} onClick={clearAllFilters}>
清空全部
</button>
</div>
)}
<div className={styles.tableScroll}>
<table
className={`${styles.table} ${loading && rows.length > 0 ? styles.tableLoading : ''}`}
style={{ width: Math.max(totalWidth, 880) }}
>
<colgroup>
{COMBO_COLUMNS.map((column) => (
<col key={column.key} style={{ width: columnWidth(column) }} />
))}
</colgroup>
<thead>
<tr>
{COMBO_COLUMNS.map((column) => (
<LimitTableHeaderCell
key={column.key}
column={column}
numeric={column.numeric}
hint="组合限价 = 各子商品限价 × 组合明细数量后相加"
sortScope="all"
stickyTop={0}
sort={sort ? { key: sort.field, direction: sort.direction } : null}
menuOpen={openSortKey === column.key}
onToggleMenu={() => setOpenSortKey((current) => (current === column.key ? null : column.key))}
onCloseMenu={() => setOpenSortKey(null)}
onSort={(direction) => {
onSortChange({
field: column.key as ComboLimitFieldKey,
direction,
})
setOpenSortKey(null)
onPageChange(1)
}}
onClearSort={() => {
onSortChange(null)
setOpenSortKey(null)
}}
filterField={COMBO_FILTER_FIELDS.find((field) => field.key === column.key)}
filterValues={(filters as Record<string, string[] | undefined>)[column.key] ?? []}
filterContext={contextFilters}
filterOpen={openFilterKey === column.key}
onToggleFilter={() => setOpenFilterKey((current) => (current === column.key ? null : column.key))}
onCloseFilter={() => setOpenFilterKey(null)}
onFilterChange={(values) => updateFilter(column.key, values)}
onResizeStart={(event) => startHeaderResize(column, event)}
/>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0 && (
<tr className={styles.stateRow}><td colSpan={COMBO_COLUMNS.length}>组合限价加载中…</td></tr>
)}
{!loading && error && (
<tr className={styles.stateRow}><td colSpan={COMBO_COLUMNS.length}><span className={styles.errorState}>{error}</span></td></tr>
)}
{!loading && !error && rows.length === 0 && (
<tr className={styles.stateRow}><td colSpan={COMBO_COLUMNS.length}>没有符合条件的组合限价</td></tr>
)}
{rows.map((row) => (
<tr key={row.comboCode}>
{COMBO_COLUMNS.map((column) => {
const value = isComboLimitFieldKey(column.key) ? row[column.key] : ''
return (
<td
key={column.key}
className={`${styles.bodyTd} ${column.numeric ? styles.numericCell : ''}`}
>
{value || <span className={styles.placeholderCell}>—</span>}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.pagination}>
<button
type="button"
className={styles.pagerButton}
aria-label="上一页"
title="上一页"
disabled={page <= 1 || loading}
onClick={() => onPageChange(Math.max(1, page - 1))}
>
<ChevronLeft size={15} />
</button>
<input
key={page}
className={styles.pageInput}
defaultValue={page}
inputMode="numeric"
aria-label="页码"
onInput={(event) => { event.currentTarget.value = event.currentTarget.value.replace(/[^\d]/g, '') }}
onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }}
onBlur={(event) => {
const next = commitPageInput(event.currentTarget.value)
event.currentTarget.value = String(next ?? page)
}}
/>
<span className={styles.pageTotal}>/ {totalPages}</span>
<button
type="button"
className={styles.pagerButton}
aria-label="下一页"
title="下一页"
disabled={page >= totalPages || loading}
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
>
<ChevronRight size={15} />
</button>
<span className={styles.paginationSummary}>
共 {total} 条,每页
<select
className={styles.pageSizeSelect}
aria-label="每页条数"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value) as 50 | 100)}
>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
条
</span>
</div>
</>
)
}

View File

@ -161,6 +161,38 @@ function mockLimitManagementFetch(rows: SupplyChainProductArchiveRow[]) {
},
})
}
if (url.includes('/api/supply-chain/limit-management/combo-limits/options')) {
return jsonResponse({
success: true,
data: {
field: 'comboCode',
values: ['COMBO-AB'],
offset: 0,
limit: 200,
hasMore: false,
total: 1,
},
})
}
if (url.includes('/api/supply-chain/limit-management/combo-limits')) {
return jsonResponse({
success: true,
data: {
rows: [{
comboCode: 'COMBO-AB',
opsHead: '运营甲',
ownerLimitPrice: '69.00',
nonOwnerLimitPriceNew: '79.00',
jxLimitPrice: '50.00',
alibabaPageLimitPrice: '73.47',
distributorPrice89: '65.39',
distributorPrice85: '62.45',
distributorPrice80: '58.78',
}],
pageInfo: { page: 1, pageSize: 50, total: 1, totalPages: 1 },
},
})
}
if (url.includes('/api/supply-chain/limit-management/products')) {
return jsonResponse({
success: true,
@ -225,6 +257,67 @@ describe('限价管理', () => {
expect(screen.getByRole('button', { name: '导出明细(含图片)' })).toBeInTheDocument()
})
it('品牌商品旁展示组合限价页,并只展示组合编码与限价价格字段', async () => {
render(<CustomTableView canEdit canExport userId="user-a" />)
await screen.findByText('测试岩板餐桌')
fireEvent.click(screen.getByRole('button', { name: '组合限价' }))
expect(await screen.findByText('COMBO-AB')).toBeInTheDocument()
for (const label of ['组合编码', '运营负责人', '负责人限价', '非负责人限价', '京喜限价', '阿里页面限价', '分销商89折', '分销商85折', '分销商8折']) {
expect(screen.getByRole('columnheader', { name: new RegExp(`^${label}`) })).toBeInTheDocument()
}
expect(screen.getByText('69.00')).toBeInTheDocument()
expect(screen.getByText('运营甲')).toBeInTheDocument()
expect(document.querySelectorAll('[data-limit-filter]')).toHaveLength(2)
expect(screen.getByRole('button', { name: '筛选运营负责人' })).toBeInTheDocument()
expect((screen.getByRole('columnheader', { name: /^组合编码/ }) as HTMLElement).style.top).toBe('0px')
fireEvent.click(screen.getByRole('button', { name: '筛选组合编码', exact: true }))
expect(await screen.findByRole('textbox', { name: '搜索组合编码' })).toBeInTheDocument()
const columnLabels = screen.getAllByRole('columnheader').map((cell) => (cell.textContent ?? '').replace(/[↕↑↓▾]/g, '').trim())
expect(columnLabels.indexOf('运营负责人')).toBe(columnLabels.indexOf('组合编码') + 1)
expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument()
})
it('条码候选值跟随已选商品状态,避免选出与明细条件冲突的条码', async () => {
render(<CustomTableView canEdit userId="user-a" />)
await screen.findByText('测试岩板餐桌')
fireEvent.click(screen.getByRole('button', { name: '筛选条码', exact: true }))
await screen.findByRole('dialog', { name: '筛选条码' })
let optionRequest = ''
await waitFor(() => {
optionRequest = (global.fetch as jest.Mock).mock.calls
.map((call) => String(call[0]))
.filter((url) => url.includes('/limit-management/options') && url.includes('field=barcode'))
.at(-1) ?? ''
expect(optionRequest).not.toBe('')
})
const filters = new URL(optionRequest, 'http://localhost').searchParams.get('filters') ?? '{}'
expect(JSON.parse(filters)).toEqual({ productStatus: ['销售'] })
})
it('support batch paste queries and apply all pasted values together', async () => {
render(<CustomTableView canEdit userId="user-a" />)
await screen.findByText('测试岩板餐桌')
fireEvent.click(screen.getByRole('button', { name: '筛选条码', exact: true }))
const dialog = await screen.findByRole('dialog', { name: '筛选条码' })
const search = within(dialog).getByRole('textbox', { name: '搜索条码' })
fireEvent.change(search, { target: { value: 'BAR-001 BAR-002 BAR-003' } })
fireEvent.keyDown(search, { key: 'Enter' })
await waitFor(() => {
const latestProductRequest = (global.fetch as jest.Mock).mock.calls
.map((call) => String(call[0]))
.filter((url) => url.includes('/limit-management/products'))
.at(-1) ?? ''
const filters = new URL(latestProductRequest, 'http://localhost').searchParams.get('filters') ?? '{}'
expect((JSON.parse(filters) as { barcode?: string[] }).barcode).toEqual(['BAR-001', 'BAR-002', 'BAR-003'])
})
})
it('限价页按《限价抬头及逻辑》显示分组表头,并展示产品全量档案数据', async () => {
render(<CustomTableView canEdit userId="user-a" />)

View File

@ -3,12 +3,13 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { ApiResponse } from '@/server/core/types'
import type { ProductArchiveFilterOptions } from '@/lib/supply-chain-product-archive'
import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE } from '@/lib/supply-chain-product-archive'
import type { LimitTableFilterField } from './useLimitTableSettings'
import { parseBatchFilterValues } from './limit-filter-batch'
import styles from './limit-price.module.css'
const OPTION_LIMIT = 200
const POST_FILTERS_THRESHOLD = 4000
/** 「空值」在接口里是哨兵值,界面上统一显示成「空值」。 */
function optionLabel(value: string) {
@ -19,12 +20,14 @@ type PanelProps = {
field: LimitTableFilterField
selected: string[]
onChange: (values: string[]) => void
/** 其他已选筛选条件,用于让候选值随筛选范围联动 */
contextFilters?: Record<string, string[]>
/** 传入时在底部显示「收起」按钮 */
onCollapse?: () => void
}
/** 选项加载 + 搜索 + 多选列表:顶部筛选栏与表头筛选浮层共用。 */
export function FilterOptionPanel({ field, selected, onChange, onCollapse }: PanelProps) {
export function FilterOptionPanel({ field, selected, onChange, contextFilters, onCollapse }: PanelProps) {
const [search, setSearch] = useState('')
const [options, setOptions] = useState<string[]>(field.fixedOptions ?? [])
const [loading, setLoading] = useState(false)
@ -41,11 +44,21 @@ export function FilterOptionPanel({ field, selected, onChange, onCollapse }: Pan
})
const keyword = search.trim()
if (keyword) params.set('q', keyword)
const response = await fetch(
`/api/supply-chain/limit-management/options?${params.toString()}`,
{ signal: controller.signal },
)
const payload = (await response.json()) as ApiResponse<ProductArchiveFilterOptions>
const filtersText = contextFilters && Object.keys(contextFilters).length
? JSON.stringify(contextFilters)
: ''
const endpoint = field.optionsEndpoint ?? '/api/supply-chain/limit-management/options'
const getParams = new URLSearchParams(params)
if (filtersText) getParams.set('filters', filtersText)
const response = filtersText.length > POST_FILTERS_THRESHOLD
? await fetch(`${endpoint}?${params.toString()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: filtersText,
signal: controller.signal,
})
: await fetch(`${endpoint}?${getParams.toString()}`, { signal: controller.signal })
const payload = (await response.json()) as ApiResponse<{ values: string[] }>
if (!response.ok || !payload.success || !payload.data) throw new Error('加载失败')
setOptions(payload.data.values)
} catch {
@ -58,7 +71,7 @@ export function FilterOptionPanel({ field, selected, onChange, onCollapse }: Pan
controller.abort()
window.clearTimeout(timer)
}
}, [field.key, field.fixedOptions, search])
}, [contextFilters, field.fixedOptions, field.key, field.optionsEndpoint, search])
const displayOptions = useMemo(() => {
const keyword = search.trim().toLowerCase()
@ -77,14 +90,33 @@ export function FilterOptionPanel({ field, selected, onChange, onCollapse }: Pan
)
}
const applyBatch = (raw: string) => {
const values = parseBatchFilterValues(raw)
.map((value) => value === '空值' ? PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE : value)
if (!values.length) return
onChange([...new Set([...selected, ...values])].slice(0, 20000))
setSearch('')
}
return (
<>
<input
className={styles.filterSearch}
value={search}
placeholder={`搜索${field.label}`}
placeholder={`搜索${field.label},多个值用空格分隔`}
aria-label={`搜索${field.label}`}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
applyBatch(search)
}}
onPaste={(event) => {
const text = event.clipboardData.getData('text')
if (parseBatchFilterValues(text).length <= 1) return
event.preventDefault()
applyBatch(text)
}}
/>
<div className={styles.filterOptions} role="listbox" aria-label={field.label}>
{displayOptions.length === 0 && (
@ -117,9 +149,10 @@ type Props = {
field: LimitTableFilterField
selected: string[]
onChange: (values: string[]) => void
contextFilters?: Record<string, string[]>
}
export function LimitFilterSelect({ field, selected, onChange }: Props) {
export function LimitFilterSelect({ field, selected, onChange, contextFilters }: Props) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
@ -152,7 +185,13 @@ export function LimitFilterSelect({ field, selected, onChange }: Props) {
{open && (
<div className={styles.filterDropdown}>
<FilterOptionPanel field={field} selected={selected} onChange={onChange} onCollapse={() => setOpen(false)} />
<FilterOptionPanel
field={field}
selected={selected}
onChange={onChange}
contextFilters={contextFilters}
onCollapse={() => setOpen(false)}
/>
</div>
)}
</div>

View File

@ -28,13 +28,17 @@ import {
} from './useLimitTableSettings'
import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE } from '@/lib/supply-chain-product-archive'
import { useLimitPriceData } from './useLimitPriceData'
import { useComboLimitData } from './useComboLimitData'
import { ComboLimitSection } from './ComboLimitSection'
import type { ComboLimitFilters, ComboLimitSort } from '@/lib/supply-chain-combo-limit-price'
import styles from './limit-price.module.css'
type ProductKind = 'bulk' | 'brand'
type ProductKind = 'bulk' | 'brand' | 'combo'
const PRODUCT_KIND_LABELS: Record<ProductKind, string> = {
bulk: '大货商品',
brand: '品牌商品',
combo: '组合限价',
}
/** 品牌商品 = 品牌含「顾家 / 索菲亚 / 朴美」;其余(含其他品牌)都归大货商品。 */
@ -149,6 +153,8 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
const [productKind, setProductKind] = useState<ProductKind>('bulk')
const [filters, setFilters] = useState<ProductArchiveFilters>(EMPTY_FILTERS)
const [comboFilters, setComboFilters] = useState<ComboLimitFilters>({})
const [comboSort, setComboSort] = useState<ComboLimitSort>(null)
// 列宽拖拽:拖动过程中用本地临时宽度,松开鼠标后一次性持久化到个人设置。
const [liveWidths, setLiveWidths] = useState<Record<string, number> | null>(null)
const [page, setPage] = useState(1)
@ -193,7 +199,7 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
const effectiveFilters = useMemo(() => {
const next: ProductArchiveFilters = { ...filters }
const tabBrands = brandPartition?.[productKind] ?? []
const tabBrands = productKind === 'combo' ? [] : (brandPartition?.[productKind] ?? [])
if (tabBrands.length) {
const selected = filters.brand ?? []
const intersection = selected.length
@ -208,8 +214,18 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
page,
settings.pageSize,
effectiveFilters,
hydrated && brandPartitionSettled,
hydrated && brandPartitionSettled && productKind !== 'combo',
)
const comboPageSize: 50 | 100 = settings.pageSize === 50 ? 50 : 100
const comboData = useComboLimitData(
page,
comboPageSize,
comboFilters,
comboSort,
hydrated && productKind === 'combo',
)
const activeLoading = productKind === 'combo' ? comboData.loading : loading
const activeTotalPages = productKind === 'combo' ? comboData.pageInfo.totalPages : pageInfo.totalPages
// 限价明细导出:两种(不含图片 / 含图片),走全局下载中心后台生成,
// 进度显示在右上角下载中心里,页面不再挂浮动进度卡。
@ -341,8 +357,8 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
}
useEffect(() => {
if (page > pageInfo.totalPages) setPage(Math.max(1, pageInfo.totalPages))
}, [page, pageInfo.totalPages])
if (page > activeTotalPages) setPage(Math.max(1, activeTotalPages))
}, [activeTotalPages, page])
useEffect(() => {
setPageInput(String(page))
@ -414,6 +430,10 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
const visibleFilterFields = settings.filterFields
.map((key) => LIMIT_TABLE_FILTER_FIELD_MAP.get(key))
.filter((field): field is NonNullable<typeof field> => Boolean(field))
const filterContext = useMemo(
() => filters as Record<string, string[]>,
[filters],
)
return (
<section className={styles.tabPanel} aria-label="限价商品列表">
@ -435,12 +455,12 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
type="button"
className={styles.iconButton}
title="刷新"
aria-label="刷新限价商品"
onClick={() => reload()}
aria-label={productKind === 'combo' ? '刷新组合限价' : '刷新限价商品'}
onClick={() => productKind === 'combo' ? comboData.reload() : reload()}
>
<RefreshCw size={15} className={loading ? 'animate-spin' : undefined} />
<RefreshCw size={15} className={activeLoading ? 'animate-spin' : undefined} />
</button>
{canExport && (
{productKind !== 'combo' && canExport && (
<>
<button
type="button"
@ -460,22 +480,46 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
</button>
</>
)}
{exportMessage ? <span className={styles.exportHint}>{exportMessage}</span> : null}
{productKind !== 'combo' && exportMessage ? <span className={styles.exportHint}>{exportMessage}</span> : null}
<span className={styles.toolbarSpacer} />
<LimitTableSettingsMenu
compact={settings.compact}
onCompactChange={setCompact}
columns={settings.columns}
filterFields={settings.filterFields}
onToggleColumn={toggleColumn}
onSetColumnsVisible={setColumnVisible}
onMoveColumn={moveColumn}
onResetColumns={resetColumns}
onToggleFilterField={toggleFilterField}
onResetFilterFields={resetFilterFields}
/>
{productKind !== 'combo' && (
<LimitTableSettingsMenu
compact={settings.compact}
onCompactChange={setCompact}
columns={settings.columns}
filterFields={settings.filterFields}
onToggleColumn={toggleColumn}
onSetColumnsVisible={setColumnVisible}
onMoveColumn={moveColumn}
onResetColumns={resetColumns}
onToggleFilterField={toggleFilterField}
onResetFilterFields={resetFilterFields}
/>
)}
</div>
{productKind === 'combo' ? (
<ComboLimitSection
userId={userId}
rows={comboData.rows}
page={page}
pageSize={comboPageSize}
total={comboData.pageInfo.total}
totalPages={comboData.pageInfo.totalPages}
loading={comboData.loading}
error={comboData.error}
filters={comboFilters}
sort={comboSort}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size)
setPage(1)
}}
onFiltersChange={setComboFilters}
onSortChange={setComboSort}
/>
) : (
<>
<div className={styles.filterBar}>
<span className={styles.filterHint}>筛选</span>
{visibleFilterFields.length === 0 && (
@ -486,6 +530,7 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
key={field.key}
field={field}
selected={(filters as Record<string, string[] | undefined>)[field.key] ?? []}
contextFilters={filterContext}
onChange={(values) => updateFilterValue(field.key, values)}
/>
))}
@ -582,6 +627,7 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
filterValues={column.filterKey
? ((filters as Record<string, string[] | undefined>)[column.filterKey] ?? [])
: undefined}
filterContext={filterContext}
filterOpen={openFilterKey === column.key}
onToggleFilter={() => setOpenFilterKey((current) => (current === column.key ? null : column.key))}
onCloseFilter={() => setOpenFilterKey(null)}
@ -672,6 +718,8 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
title={imagePreview?.title ?? ''}
onClose={() => setImagePreview(null)}
/>
</>
)}
</section>
)
}

View File

@ -10,7 +10,8 @@ import styles from './limit-price.module.css'
export type LimitTableSort = { key: string; direction: 'asc' | 'desc' } | null
function limitTableColumnHint(column: LimitTableColumn) {
function limitTableColumnHint(column: LimitTableColumn, hint?: string) {
if (hint) return hint
if (column.kind === 'placeholder') return '该字段数据来源待接入'
if (column.format === 'packSize') return '由产品全量档案的长、宽、高组合'
if (column.format === 'assemblyType') return '由商品标签(整装/组装)推导'
@ -26,6 +27,10 @@ type Props = {
sort: LimitTableSort
menuOpen: boolean
numeric?: boolean
hint?: string
sortScope?: 'page' | 'all'
/** 单行表头传 0;双行表头(含分组行)默认沿用 CSS 的 30px */
stickyTop?: number
onToggleMenu: () => void
onCloseMenu: () => void
onSort: (direction: 'asc' | 'desc') => void
@ -34,6 +39,7 @@ type Props = {
filterField?: LimitTableFilterField
filterValues?: string[]
filterOpen?: boolean
filterContext?: Record<string, string[]>
onToggleFilter?: () => void
onCloseFilter?: () => void
onFilterChange?: (values: string[]) => void
@ -46,6 +52,9 @@ export function LimitTableHeaderCell({
sort,
menuOpen,
numeric,
hint,
sortScope = 'page',
stickyTop,
onToggleMenu,
onCloseMenu,
onSort,
@ -53,6 +62,7 @@ export function LimitTableHeaderCell({
filterField,
filterValues,
filterOpen,
filterContext,
onToggleFilter,
onCloseFilter,
onFilterChange,
@ -107,13 +117,18 @@ export function LimitTableHeaderCell({
if (filterButtonRef.current?.contains(target)) return
onCloseFilter?.()
}
const onScroll = (event: Event) => {
const target = event.target
if (target instanceof Node && filterPanelRef.current?.contains(target)) return
onCloseFilter?.()
}
const close = () => onCloseFilter?.()
document.addEventListener('mousedown', onMouseDown)
window.addEventListener('scroll', close, true)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', close)
return () => {
document.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('scroll', close, true)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', close)
}
}, [filterOpen, onCloseFilter])
@ -123,6 +138,7 @@ export function LimitTableHeaderCell({
return (
<th
scope="col"
style={stickyTop === undefined ? undefined : { top: stickyTop }}
className={[
styles.headTh,
numeric ? styles.headThNumeric : '',
@ -138,7 +154,7 @@ export function LimitTableHeaderCell({
aria-expanded={menuOpen}
onClick={onToggleMenu}
>
<span className={styles.headLabel} title={limitTableColumnHint(column)}>{column.label}</span>
<span className={styles.headLabel} title={limitTableColumnHint(column, hint)}>{column.label}</span>
<span className={active ? styles.sortActive : styles.sortIdle} aria-hidden="true">
{active ? (sort?.direction === 'asc' ? '↑' : '↓') : '↕'}
</span>
@ -180,7 +196,7 @@ export function LimitTableHeaderCell({
className={styles.sortMenuItem}
onClick={() => onSort('asc')}
>
<ArrowUp size={13} />按{column.label}升序(单页)
<ArrowUp size={13} />按{column.label}升序({sortScope === 'all' ? '全量' : '单页'})
</button>
<button
type="button"
@ -188,7 +204,7 @@ export function LimitTableHeaderCell({
className={styles.sortMenuItem}
onClick={() => onSort('desc')}
>
<ArrowDown size={13} />按{column.label}降序(单页)
<ArrowDown size={13} />按{column.label}降序({sortScope === 'all' ? '全量' : '单页'})
</button>
{active && (
<>
@ -220,6 +236,7 @@ export function LimitTableHeaderCell({
field={filterField}
selected={filterValues ?? []}
onChange={(values) => onFilterChange?.(values)}
contextFilters={filterContext}
/>
</div>,
document.body,

View File

@ -0,0 +1,17 @@
import { parseBatchFilterValues } from './limit-filter-batch'
describe('parseBatchFilterValues', () => {
it('parses pasted values separated by spaces, newlines, commas and semicolons', () => {
expect(parseBatchFilterValues('CODE-A CODE-B\nCODE-C,CODE-D; CODE-E、CODE-A')).toEqual([
'CODE-A',
'CODE-B',
'CODE-C',
'CODE-D',
'CODE-E',
])
})
it('collapses repeated spaces and ignores empty entries', () => {
expect(parseBatchFilterValues(' 供应商A ,, \n供应商B ')).toEqual(['供应商A', '供应商B'])
})
})

View File

@ -0,0 +1,11 @@
const BATCH_SEPARATOR = /[\s,,;;、]+/
/** 批量筛选支持换行、逗号、分号、顿号和制表符分隔,结果去重并保持输入顺序。 */
export function parseBatchFilterValues(raw: string) {
return [...new Set(
raw
.split(BATCH_SEPARATOR)
.map((value) => value.trim())
.filter(Boolean),
)]
}

View File

@ -79,7 +79,7 @@
.sortMenuDivider{height:1px;margin:4px 6px;background:#f0f0f1}
/* 表头筛选浮层与列宽拖拽手柄 */
.headResizeHandle{position:absolute;top:0;right:-2px;bottom:0;z-index:8;width:5px;cursor:col-resize}
.headResizeHandle{position:absolute;top:0;right:-13px;bottom:0;z-index:8;width:5px;cursor:col-resize}
.headResizeHandle:hover{background:rgba(24,24,27,.16)}
.headerFilterPanel{position:fixed;z-index:130;display:flex;width:240px;max-height:340px;flex-direction:column;border:1px solid #e4e4e7;border-radius:6px;background:#fff;box-shadow:0 14px 36px rgba(24,24,27,.16);padding:6px}
.headerFilterTitle{flex:0 0 auto;padding:2px 6px 6px;color:#71717a;font-size:11px;font-weight:600}

View File

@ -68,7 +68,7 @@ export const LIMIT_TABLE_COLUMNS: LimitTableColumn[] = [
// 限价
{ key: 'ownerLimitPrice', label: '负责人限价', group: 'limit', width: 110, kind: 'archive', prop: 'opsPriceLimit', numeric: true, filterKey: 'opsPriceLimit' },
{ key: 'nonOwnerLimitPriceNew', label: '非负责人限价(新)', group: 'limit', width: 140, kind: 'archive', prop: 'nonOwnerLimitPriceNew', numeric: true },
{ key: 'nonOwnerLimitPriceNew', label: '非负责人限价', group: 'limit', width: 140, kind: 'archive', prop: 'nonOwnerLimitPriceNew', numeric: true },
{ key: 'jxLimitPrice', label: '京喜限价', group: 'limit', width: 106, kind: 'archive', prop: 'jxLimitPrice', numeric: true },
{ key: 'alibabaPageLimitPrice', label: '阿里页面限价', group: 'limit', width: 122, kind: 'archive', prop: 'alibabaPageLimitPrice', numeric: true },
{ key: 'distributorPrice89', label: '分销商89折', group: 'limit', width: 116, kind: 'archive', prop: 'distributorPrice89', numeric: true },

View File

@ -0,0 +1,82 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ApiResponse } from '@/server/core/types'
import type {
ComboLimitFilters,
ComboLimitPage,
ComboLimitSort,
} from '@/lib/supply-chain-combo-limit-price'
const POST_FILTERS_THRESHOLD = 4000
const EMPTY_PAGE: ComboLimitPage = {
rows: [],
pageInfo: { page: 1, pageSize: 50, total: 0, totalPages: 1 },
}
export function useComboLimitData(
page: number,
pageSize: number,
filters: ComboLimitFilters,
sort: ComboLimitSort,
enabled = true,
) {
const [result, setResult] = useState<ComboLimitPage>(EMPTY_PAGE)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [reloadKey, setReloadKey] = useState(0)
const forceRefreshRef = useRef(false)
useEffect(() => {
if (!enabled) return
const controller = new AbortController()
let cancelled = false
async function load() {
setLoading(true)
setError('')
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (forceRefreshRef.current) params.set('refresh', '1')
forceRefreshRef.current = false
const filtersText = Object.keys(filters).length ? JSON.stringify(filters) : ''
const sortText = sort ? JSON.stringify(sort) : ''
const endpoint = `/api/supply-chain/limit-management/combo-limits?${params.toString()}`
const response = filtersText.length > POST_FILTERS_THRESHOLD
? await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filters, sort }),
signal: controller.signal,
})
: await fetch(`${endpoint}${filtersText ? `&filters=${encodeURIComponent(filtersText)}` : ''}${sortText ? `&sort=${encodeURIComponent(sortText)}` : ''}`, {
signal: controller.signal,
})
const payload = (await response.json()) as ApiResponse<ComboLimitPage>
if (!response.ok || !payload.success || !payload.data) {
throw new Error(payload.error?.message || '组合限价加载失败')
}
if (!cancelled) setResult(payload.data)
} catch (loadError) {
if (cancelled || controller.signal.aborted) return
setError(loadError instanceof Error ? loadError.message : '组合限价加载失败')
} finally {
if (!cancelled && !controller.signal.aborted) setLoading(false)
}
}
void load()
return () => {
cancelled = true
controller.abort()
}
}, [enabled, filters, page, pageSize, reloadKey, sort])
const reload = useCallback(() => {
forceRefreshRef.current = true
setReloadKey((key) => key + 1)
}, [])
return { ...result, loading, error, reload }
}

View File

@ -29,6 +29,8 @@ export type LimitTableFilterField = {
key: string
label: string
fixedOptions?: string[]
/** 非产品档案筛选项可指定自己的候选值接口 */
optionsEndpoint?: string
}
export const LIMIT_TABLE_FILTER_FIELDS: LimitTableFilterField[] = [

View File

@ -0,0 +1,134 @@
import {
buildComboLimitRow,
comboLimitOptions,
filterComboLimitRows,
isComboComponentEligible,
paginateComboLimitRows,
sortComboLimitRows,
} from './supply-chain-combo-limit-price'
describe('supply-chain combo limit price', () => {
it('excludes products that are stopped or discontinued from combinations', () => {
expect(isComboComponentEligible('销售')).toBe(true)
expect(isComboComponentEligible(' 新品 ')).toBe(true)
expect(isComboComponentEligible('停产')).toBe(false)
expect(isComboComponentEligible(' 退市 ')).toBe(false)
})
it('formats the A(50) + B(19) combination as 69.00 and derives the other price fields', () => {
const row = buildComboLimitRow({
combineCode: 'COMBO-AB',
opsHead: '运营甲、运营乙',
itemCount: 2,
costCount: 2,
ownerTotal: 69,
nonOwnerTotal: 79,
costTotal: 37,
})
expect(row).toEqual({
comboCode: 'COMBO-AB',
opsHead: '运营甲、运营乙',
ownerLimitPrice: '69.00',
nonOwnerLimitPriceNew: '79.00',
jxLimitPrice: '50.00',
alibabaPageLimitPrice: '73.47',
distributorPrice89: '65.39',
distributorPrice85: '62.45',
distributorPrice80: '58.78',
})
})
it('leaves Jingxi blank when any child product has no cost price', () => {
const row = buildComboLimitRow({
combineCode: 'COMBO-MISSING-COST',
opsHead: '运营甲',
itemCount: 3,
costCount: 2,
ownerTotal: 120,
nonOwnerTotal: 136,
costTotal: 80,
})
expect(row.jxLimitPrice).toBe('')
expect(row.ownerLimitPrice).toBe('120.00')
})
it('filters by selected values, sorts the full result and paginates', () => {
const rows = [
buildComboLimitRow({
combineCode: 'COMBO-2',
opsHead: '运营乙',
itemCount: 1,
costCount: 1,
ownerTotal: 90,
nonOwnerTotal: 100,
costTotal: 50,
}),
buildComboLimitRow({
combineCode: 'COMBO-1',
opsHead: '运营甲',
itemCount: 1,
costCount: 1,
ownerTotal: 50,
nonOwnerTotal: 60,
costTotal: 30,
}),
buildComboLimitRow({
combineCode: 'OTHER',
opsHead: '运营丙',
itemCount: 1,
costCount: 1,
ownerTotal: 10,
nonOwnerTotal: 20,
costTotal: 10,
}),
]
const filtered = filterComboLimitRows(rows, {
comboCode: ['COMBO-2', 'COMBO-1'],
})
const sorted = sortComboLimitRows(filtered, {
field: 'ownerLimitPrice',
direction: 'desc',
})
const page = paginateComboLimitRows(sorted, 1, 1)
expect(page.rows.map((row) => row.comboCode)).toEqual(['COMBO-2'])
expect(page.pageInfo).toEqual({ page: 1, pageSize: 1, total: 2, totalPages: 2 })
expect(filterComboLimitRows(rows, { opsHead: ['运营甲'] }).map((row) => row.comboCode)).toEqual(['COMBO-1'])
})
it('creates searchable filter options and splits every owner in a multi-owner combo', () => {
const rows = [
buildComboLimitRow({
combineCode: 'COMBO-AB',
opsHead: '运营甲、运营乙',
itemCount: 2,
costCount: 2,
ownerTotal: 69,
nonOwnerTotal: 79,
costTotal: 37,
}),
buildComboLimitRow({
combineCode: 'COMBO-AC',
opsHead: '运营丙',
itemCount: 2,
costCount: 2,
ownerTotal: 80,
nonOwnerTotal: 90,
costTotal: 40,
}),
]
const result = comboLimitOptions(rows, 'comboCode', { q: 'ab', limit: 20 })
expect(result.values).toEqual(['COMBO-AB'])
expect(result.total).toBe(1)
expect(result.hasMore).toBe(false)
const ownerOptions = comboLimitOptions(rows, 'opsHead', {}).values
expect(ownerOptions).toHaveLength(3)
expect(ownerOptions).toEqual(expect.arrayContaining(['运营甲', '运营乙', '运营丙']))
expect(filterComboLimitRows(rows, { opsHead: ['运营乙'] }).map((row) => row.comboCode)).toEqual(['COMBO-AB'])
})
})

View File

@ -0,0 +1,179 @@
export const COMBO_LIMIT_FIELDS = [
{ key: 'comboCode', label: '组合编码', numeric: false },
{ key: 'opsHead', label: '运营负责人', numeric: false },
{ key: 'ownerLimitPrice', label: '负责人限价', numeric: true },
{ key: 'nonOwnerLimitPriceNew', label: '非负责人限价', numeric: true },
{ key: 'jxLimitPrice', label: '京喜限价', numeric: true },
{ key: 'alibabaPageLimitPrice', label: '阿里页面限价', numeric: true },
{ key: 'distributorPrice89', label: '分销商89折', numeric: true },
{ key: 'distributorPrice85', label: '分销商85折', numeric: true },
{ key: 'distributorPrice80', label: '分销商8折', numeric: true },
] as const
const COMBO_EXCLUDED_PRODUCT_STATUSES = new Set(['停产', '退市'])
/** 组合中任一子商品停产或退市时,整个组合不再展示限价。 */
export function isComboComponentEligible(productStatus: string) {
return !COMBO_EXCLUDED_PRODUCT_STATUSES.has(productStatus.trim())
}
export type ComboLimitFieldKey = (typeof COMBO_LIMIT_FIELDS)[number]['key']
export type ComboLimitRow = Record<ComboLimitFieldKey, string>
export type ComboLimitFilters = Partial<Record<ComboLimitFieldKey, string[]>>
export type ComboLimitSort = {
field: ComboLimitFieldKey
direction: 'asc' | 'desc'
} | null
export type ComboLimitAggregate = {
combineCode: string
/** 同一组合的多个子项可能由不同运营负责,去重后用「、」连接 */
opsHead: string
itemCount: number
costCount: number
ownerTotal: number
nonOwnerTotal: number
costTotal: number | null
}
export type ComboLimitPage = {
rows: ComboLimitRow[]
pageInfo: {
page: number
pageSize: number
total: number
totalPages: number
}
}
export type ComboLimitOptionResult = {
field: ComboLimitFieldKey
values: string[]
offset: number
limit: number
hasMore: boolean
total: number
}
export const COMBO_LIMIT_FIELD_KEYS = COMBO_LIMIT_FIELDS.map((field) => field.key)
const COMBO_LIMIT_FIELD_KEY_SET = new Set<string>(COMBO_LIMIT_FIELD_KEYS)
export function isComboLimitFieldKey(value: string): value is ComboLimitFieldKey {
return COMBO_LIMIT_FIELD_KEY_SET.has(value)
}
function roundPrice(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100
}
export function formatComboLimitPrice(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) ? roundPrice(value).toFixed(2) : ''
}
/**
* 组合价按组合明细中的子项限价汇总,子项数量由 qty 体现。
* A(50)×1 + B(19)×1 = 69;若 B 的数量为 2,则为 50 + 19×2 = 88。
*/
export function buildComboLimitRow(input: ComboLimitAggregate): ComboLimitRow {
const nonOwnerTotal = input.nonOwnerTotal
const alibabaTotal = nonOwnerTotal * 0.93
// 任一子项缺少成本价时,京喜组合价无法完整汇总,留空而不是按部分子项少算。
const jxTotal = input.costCount === input.itemCount && input.costTotal !== null
? input.costTotal / 0.74
: null
return {
comboCode: input.combineCode,
opsHead: input.opsHead,
ownerLimitPrice: formatComboLimitPrice(input.ownerTotal),
nonOwnerLimitPriceNew: formatComboLimitPrice(nonOwnerTotal),
jxLimitPrice: formatComboLimitPrice(jxTotal),
alibabaPageLimitPrice: formatComboLimitPrice(alibabaTotal),
distributorPrice89: formatComboLimitPrice(alibabaTotal * 0.89),
distributorPrice85: formatComboLimitPrice(alibabaTotal * 0.85),
distributorPrice80: formatComboLimitPrice(alibabaTotal * 0.8),
}
}
export function filterComboLimitRows(rows: ComboLimitRow[], filters: ComboLimitFilters) {
const active = Object.entries(filters).flatMap(([field, values]) => {
if (!isComboLimitFieldKey(field)) return []
const normalized = [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))]
return normalized.length ? [{ field, values: new Set(normalized) }] : []
})
if (!active.length) return rows
return rows.filter((row) => active.every(({ field, values }) => {
if (field === 'opsHead') {
return row.opsHead
.split('、')
.map((value) => value.trim())
.some((value) => values.has(value))
}
return values.has(row[field])
}))
}
export function sortComboLimitRows(rows: ComboLimitRow[], sort: ComboLimitSort) {
if (!sort) return rows
const factor = sort.direction === 'asc' ? 1 : -1
return rows
.map((row, index) => ({ row, index }))
.sort((left, right) => {
const a = left.row[sort.field]
const b = right.row[sort.field]
if (!a && !b) return left.index - right.index
if (!a) return 1
if (!b) return -1
if (sort.field !== 'comboCode') {
const aNumber = Number(a)
const bNumber = Number(b)
if (Number.isFinite(aNumber) && Number.isFinite(bNumber) && aNumber !== bNumber) {
return (aNumber - bNumber) * factor
}
}
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true }) * factor
})
.map((item) => item.row)
}
export function paginateComboLimitRows(rows: ComboLimitRow[], pageValue: number, pageSizeValue: number): ComboLimitPage {
const pageSize = Math.min(200, Math.max(1, Math.floor(pageSizeValue)))
const total = rows.length
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const page = Math.min(Math.max(1, Math.floor(pageValue)), totalPages)
const offset = (page - 1) * pageSize
return {
rows: rows.slice(offset, offset + pageSize),
pageInfo: { page, pageSize, total, totalPages },
}
}
export function comboLimitOptions(
rows: ComboLimitRow[],
field: ComboLimitFieldKey,
input: { q?: string; offset?: number; limit?: number },
): ComboLimitOptionResult {
const q = input.q?.trim().toLocaleLowerCase('zh-Hans-CN') ?? ''
const fieldValues = field === 'opsHead'
? rows.flatMap((row) => row.opsHead.split('、').map((value) => value.trim()).filter(Boolean))
: rows.map((row) => row[field]).filter(Boolean)
const allValues = [...new Set(fieldValues)]
.sort((left, right) => left.localeCompare(right, 'zh-Hans-CN', { numeric: true }))
const matched = q
? allValues.filter((value) => value.toLocaleLowerCase('zh-Hans-CN').includes(q))
: allValues
const offset = Math.max(0, Math.floor(input.offset ?? 0))
const limit = Math.min(1000, Math.max(1, Math.floor(input.limit ?? 200)))
const values = matched.slice(offset, offset + limit)
return {
field,
values,
offset,
limit,
hasMore: offset + values.length < matched.length,
total: matched.length,
}
}

View File

@ -11,17 +11,24 @@ import {
} from '../supply-chain-product-archive-filters'
jest.mock('@/lib/prisma', () => ({
prisma: { $queryRaw: jest.fn() },
prisma: {
$queryRaw: jest.fn(),
supplyChainStyleLimitPrice: { findMany: jest.fn() },
},
}))
const mockedQueryRaw = (jest.requireMock('@/lib/prisma') as {
prisma: { $queryRaw: jest.Mock }
}).prisma.$queryRaw
const mockedStyleLimitFindMany = (jest.requireMock('@/lib/prisma') as {
prisma: { supplyChainStyleLimitPrice: { findMany: jest.Mock } }
}).prisma.supplyChainStyleLimitPrice.findMany
beforeEach(() => {
clearNormalCodeValueCache()
mockedQueryRaw.mockReset()
mockedQueryRaw.mockResolvedValue([{ value: 'DNZ0411' }, { value: 'XG0022' }])
mockedStyleLimitFindMany.mockReset()
})
describe('normal archive code pattern', () => {
@ -241,4 +248,30 @@ describe('buildProductArchiveWhereWithTextRules normal code', () => {
expect(serialized).toContain('整装')
expect(serialized).toContain('组装')
})
// 「负责人限价」是 SupplyChainStyleLimitPrice 的派生列:按价格换回货号再过滤。
it('filters 负责人限价 by mapping the selected price back to 货号', async () => {
mockedQueryRaw.mockResolvedValueOnce([{ styleCode: 'SJ004833' }, { styleCode: 'SJ004834' }])
const where = await buildProductArchiveWhereWithTextRules({ opsPriceLimit: ['213.80'] }) as { AND?: unknown[] }
const serialized = JSON.stringify(where.AND)
expect(serialized).toContain('{"sku":{"in":["SJ004833","SJ004834"]}}')
})
it('returns no rows when the selected 负责人限价 has no matching 货号', async () => {
mockedQueryRaw.mockResolvedValueOnce([])
const where = await buildProductArchiveWhereWithTextRules({ opsPriceLimit: ['123.45'] }) as { AND?: unknown[] }
expect(JSON.stringify(where.AND)).toContain('__NO_MATCH__')
})
it('treats 负责人限价空值 as 货号不在限价表里', async () => {
mockedStyleLimitFindMany.mockResolvedValueOnce([{ styleCode: 'SJ004833' }, { styleCode: 'SJ004834' }])
const where = await buildProductArchiveWhereWithTextRules({
opsPriceLimit: [PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE],
}) as { AND?: unknown[] }
const serialized = JSON.stringify(where.AND)
expect(serialized).toContain('"NOT":{"sku":{"in":["SJ004833","SJ004834"]}}')
})
})

View File

@ -0,0 +1,143 @@
import sql from 'mssql'
import type { ComboLimitAggregate } from '@/lib/supply-chain-combo-limit-price'
export type ComboLimitPricedProduct = {
barcode: string
productStatus: string
opsHead: string
ownerLimitPrice: number
costPrice: number | null
}
function requiredEnv(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`Missing required environment variable: ${name}`)
return value
}
function safeIdentifier(value: string, label: string) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new Error(`${label} is not a valid SQL Server identifier`)
return value
}
class JstComboLimitSourceRepository {
private pool: sql.ConnectionPool | null = null
private async getPool() {
if (this.pool?.connected) return this.pool
this.pool = await new sql.ConnectionPool({
server: requiredEnv('JST_REPORT_DB_HOST'),
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
database: requiredEnv('JST_REPORT_DB_NAME'),
user: requiredEnv('JST_REPORT_DB_USER'),
password: requiredEnv('JST_REPORT_DB_PASSWORD'),
options: {
encrypt: (process.env.JST_REPORT_DB_ENCRYPT?.trim() || 'true') !== 'false',
trustServerCertificate: (process.env.JST_REPORT_DB_TRUST_SERVER_CERTIFICATE?.trim() || 'true') !== 'false',
},
pool: { min: 0, max: 4, idleTimeoutMillis: 30_000 },
requestTimeout: 300_000,
connectionTimeout: 20_000,
}).connect()
return this.pool
}
/**
* 只返回所有子项都能匹配到负责人限价的组合。
* 数量和负责人/非负责人限价在 SQL Server 内聚合,避免把数万条组合明细逐行传到应用层。
*/
async listCompleteComboLimits(products: ComboLimitPricedProduct[]): Promise<ComboLimitAggregate[]> {
if (!products.length) return []
const schema = safeIdentifier(process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', 'JST_REPORT_DB_SCHEMA')
const pool = await this.getPool()
const result = await pool.request()
.input('products', sql.NVarChar(sql.MAX), JSON.stringify(products))
.query<{
combineCode: string
opsHeadList: string | null
itemCount: number
costCount: number
ownerTotal: number
nonOwnerTotal: number
costTotal: number | null
}>(`
WITH prices AS (
SELECT
barcode,
opsHead,
ownerPrice,
costPrice
FROM OPENJSON(@products)
WITH (
barcode nvarchar(120) '$.barcode',
opsHead nvarchar(60) '$.opsHead',
ownerPrice decimal(18,4) '$.ownerLimitPrice',
costPrice decimal(18,4) '$.costPrice'
)
),
comboItemTotals AS (
SELECT combine_sku_id, COUNT(*) AS itemCount
FROM [${schema}].[jst_comboProductItem]
WHERE combine_sku_id IS NOT NULL
AND LTRIM(RTRIM(CAST(combine_sku_id AS nvarchar(200)))) <> ''
AND qty > 0
GROUP BY combine_sku_id
),
comboPrices AS (
SELECT
i.combine_sku_id,
COUNT(*) AS matchedCount,
COUNT(p.costPrice) AS costCount,
STRING_AGG(
CASE WHEN LTRIM(RTRIM(p.opsHead)) <> '' THEN p.opsHead END,
N'、'
) AS opsHeadList,
SUM(p.ownerPrice * i.qty) AS ownerTotal,
SUM((
p.ownerPrice
+ CASE WHEN p.ownerPrice * 0.05 > 10 THEN p.ownerPrice * 0.05 ELSE 10 END
) * i.qty) AS nonOwnerTotal,
SUM(p.costPrice * i.qty) AS costTotal
FROM [${schema}].[jst_comboProductItem] i
JOIN prices p ON p.barcode = CAST(i.src_sku_id AS nvarchar(120))
WHERE i.combine_sku_id IS NOT NULL
AND LTRIM(RTRIM(CAST(i.combine_sku_id AS nvarchar(200)))) <> ''
AND i.qty > 0
GROUP BY i.combine_sku_id
)
SELECT
CAST(cp.combine_sku_id AS nvarchar(200)) AS combineCode,
CAST(cp.opsHeadList AS nvarchar(1000)) AS opsHeadList,
CAST(t.itemCount AS int) AS itemCount,
CAST(cp.costCount AS int) AS costCount,
CAST(cp.ownerTotal AS decimal(28,4)) AS ownerTotal,
CAST(cp.nonOwnerTotal AS decimal(28,4)) AS nonOwnerTotal,
CAST(cp.costTotal AS decimal(28,4)) AS costTotal
FROM comboPrices cp
JOIN comboItemTotals t ON t.combine_sku_id = cp.combine_sku_id
WHERE cp.matchedCount = t.itemCount
`)
return result.recordset.map((row) => ({
combineCode: String(row.combineCode ?? '').trim(),
opsHead: [...new Set(
String(row.opsHeadList ?? '')
.split('、')
.map((value) => value.trim())
.filter(Boolean),
)].join('、'),
itemCount: Number(row.itemCount),
costCount: Number(row.costCount),
ownerTotal: Number(row.ownerTotal),
nonOwnerTotal: Number(row.nonOwnerTotal),
costTotal: row.costTotal === null ? null : Number(row.costTotal),
})).filter((row) => row.combineCode)
}
async close() {
if (!this.pool) return
await this.pool.close()
this.pool = null
}
}
export const jstComboLimitSourceRepository = new JstComboLimitSourceRepository()

View File

@ -605,6 +605,38 @@ export async function buildProductArchiveWhereWithTextRules(
if (clause) extra.push(clause)
}
}
// 「负责人限价」:派生自 SupplyChainStyleLimitPrice.ownerLimitPrice(按货号),
// 先把选中的价格换回货号,再按 sku 过滤;「空值」= 不在限价表里的货号。
if (omitFilter !== 'opsPriceLimit') {
const rawValues = (filters.opsPriceLimit ?? []).filter(Boolean)
if (rawValues.length > 0) {
const includesEmpty = rawValues.includes(PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE)
const prices = rawValues
.filter((value) => value !== PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE)
.map((value) => Number(String(value).replace(/[^\d.-]/g, '')))
.filter((value) => Number.isFinite(value))
const priceClauses: Prisma.SupplyChainProductArchiveWhereInput[] = []
if (prices.length > 0) {
const rows = await prisma.$queryRaw<Array<{ styleCode: string }>>(Prisma.sql`
SELECT DISTINCT "styleCode"
FROM "SupplyChainStyleLimitPrice"
WHERE "ownerLimitPrice" IS NOT NULL
AND ROUND("ownerLimitPrice"::numeric, 2) IN (${Prisma.join(prices)})
`)
const styleCodes = rows.map((row) => row.styleCode).filter(Boolean)
// 选中的价格在限价表里没有对应货号时返回空结果(而不是忽略条件)
priceClauses.push({ sku: { in: styleCodes.length > 0 ? styleCodes : ['__NO_MATCH__'] } })
}
if (includesEmpty) {
const limitedStyles = await prisma.supplyChainStyleLimitPrice.findMany({ select: { styleCode: true } })
priceClauses.push({
NOT: { sku: { in: limitedStyles.map((row) => row.styleCode).filter(Boolean) } },
})
}
if (priceClauses.length === 1) extra.push(priceClauses[0])
else if (priceClauses.length > 1) extra.push({ OR: priceClauses })
}
}
const asyncClauses = [...extra, ...ownerClauses]
return asyncClauses.length > 0 ? { AND: [base, ...asyncClauses] } : base
}

View File

@ -140,6 +140,34 @@ class SupplyChainProductArchiveRepositoryClass implements SupplyChainProductArch
const values = index.spuSet.size > 0 ? ['有', '无'] : ['无']
return pageOptions(values, params)
}
// 「负责人限价」是派生列(SupplyChainStyleLimitPrice.ownerLimitPrice,按货号),
// 候选值 = 当前筛选范围内实际出现的限价(两位小数,与界面展示一致)。
if (params.field === 'opsPriceLimit') {
const query = await preparePeriodArchiveQuery(params.filters, params.field)
const unfiltered = query.ids === null && Object.keys(query.where).length === 0
if (unfiltered) {
const rows = await prisma.$queryRaw<Array<{ price: string }>>(Prisma.sql`
SELECT DISTINCT ROUND("ownerLimitPrice"::numeric, 2)::text AS price
FROM "SupplyChainStyleLimitPrice"
WHERE "ownerLimitPrice" IS NOT NULL
ORDER BY price
`)
// 没有流程限价的商品占多数,「空值」始终是一个有效候选
return pageOptions([...rows.map((row) => row.price), PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE], params)
}
const records = await readSelectedArchives(query, { sku: true })
const styleCodes = [...new Set(records.map((record) => String(record.sku ?? '').trim()).filter(Boolean))]
const limitRows = styleCodes.length
? await prisma.supplyChainStyleLimitPrice.findMany({
where: { styleCode: { in: styleCodes }, ownerLimitPrice: { not: null } },
select: { styleCode: true, ownerLimitPrice: true },
})
: []
const values = [...new Set(limitRows.map((row) => Number(row.ownerLimitPrice).toFixed(2)))]
const coveredStyles = new Set(limitRows.map((row) => row.styleCode))
const hasMissingLimit = styleCodes.some((styleCode) => !coveredStyles.has(styleCode))
return pageOptions(hasMissingLimit ? [...values, PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE] : values, params)
}
const field = FILTER_OPTION_DATABASE_FIELDS[params.field as keyof typeof FILTER_OPTION_DATABASE_FIELDS]
if (!field) return { values: [], hasMore: false, total: 0 }
const query = await preparePeriodArchiveQuery(params.filters, params.field)

View File

@ -0,0 +1,91 @@
jest.mock('@/server/repositories/jst-combo-limit-source.repository', () => ({
jstComboLimitSourceRepository: {
listCompleteComboLimits: jest.fn().mockResolvedValue([]),
},
}))
import { SupplyChainComboLimitService } from '../supply-chain-combo-limit.service'
function aggregate(combineCode: string, ownerTotal: number) {
return {
combineCode,
opsHead: '运营甲',
itemCount: 2,
costCount: 2,
ownerTotal,
nonOwnerTotal: ownerTotal + 20,
costTotal: ownerTotal * 0.5,
}
}
describe('SupplyChainComboLimitService', () => {
it('caches the aggregated combo rows and supports an explicit refresh', async () => {
const repository = {
listCompleteComboLimits: jest.fn()
.mockResolvedValueOnce([aggregate('COMBO-B', 80), aggregate('COMBO-A', 69)])
.mockResolvedValueOnce([aggregate('COMBO-C', 100)]),
}
const loader = jest.fn().mockResolvedValue([
{ barcode: 'A', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 },
{ barcode: 'B', productStatus: '销售', opsHead: '运营乙', ownerLimitPrice: 19, costPrice: 10 },
])
let now = 1_000
const service = new SupplyChainComboLimitService(repository, loader, () => now)
const first = await service.listPage({ page: 1, pageSize: 50 })
now += 1_000
const cached = await service.listPage({ page: 1, pageSize: 50 })
expect(first.rows.map((row) => row.comboCode)).toEqual(['COMBO-A', 'COMBO-B'])
expect(cached.rows).toEqual(first.rows)
expect(loader).toHaveBeenCalledTimes(1)
expect(repository.listCompleteComboLimits).toHaveBeenCalledTimes(1)
const refreshed = await service.listPage({ page: 1, pageSize: 50, forceRefresh: true })
expect(refreshed.rows.map((row) => row.comboCode)).toEqual(['COMBO-C'])
expect(loader).toHaveBeenCalledTimes(2)
})
it('builds options using the other active filters but not the current field', async () => {
const repository = {
listCompleteComboLimits: jest.fn().mockResolvedValue([
aggregate('COMBO-A', 69),
aggregate('COMBO-B', 80),
aggregate('COMBO-C', 90),
]),
}
const service = new SupplyChainComboLimitService(
repository,
jest.fn().mockResolvedValue([{ barcode: 'A', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }]),
() => 1_000,
)
const result = await service.listOptions({
field: 'comboCode',
filters: {
comboCode: ['COMBO-A'],
ownerLimitPrice: ['80.00'],
},
})
expect(result.values).toEqual(['COMBO-B'])
})
it('removes stopped and discontinued child products before building combinations', async () => {
const repository = {
listCompleteComboLimits: jest.fn().mockResolvedValue([]),
}
const loader = jest.fn().mockResolvedValue([
{ barcode: 'ACTIVE', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 },
{ barcode: 'STOPPED', productStatus: '停产', opsHead: '运营乙', ownerLimitPrice: 19, costPrice: 10 },
{ barcode: 'DISCONTINUED', productStatus: '退市', opsHead: '运营丙', ownerLimitPrice: 20, costPrice: 10 },
])
const service = new SupplyChainComboLimitService(repository, loader, () => 1_000)
await service.listPage({ page: 1, pageSize: 50 })
expect(repository.listCompleteComboLimits).toHaveBeenCalledWith([
{ barcode: 'ACTIVE', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 },
])
})
})

View File

@ -0,0 +1,134 @@
import { prisma } from '@/lib/prisma'
import {
buildComboLimitRow,
comboLimitOptions,
filterComboLimitRows,
isComboComponentEligible,
paginateComboLimitRows,
sortComboLimitRows,
type ComboLimitAggregate,
type ComboLimitFieldKey,
type ComboLimitFilters,
type ComboLimitPage,
type ComboLimitRow,
type ComboLimitSort,
} from '@/lib/supply-chain-combo-limit-price'
import {
jstComboLimitSourceRepository,
type ComboLimitPricedProduct,
} from '@/server/repositories/jst-combo-limit-source.repository'
type ComboSourceRepository = Pick<typeof jstComboLimitSourceRepository, 'listCompleteComboLimits'>
type PricedProductLoader = () => Promise<ComboLimitPricedProduct[]>
const CACHE_TTL_MS = 5 * 60_000
type CacheEntry = {
expiresAt: number
rows: ComboLimitRow[]
}
type PriceSourceRow = {
barcode: string
productStatus: string
opsHead: string
ownerLimitPrice: string | number
costPrice: string | null
}
function parseCostPrice(value: string | null | undefined) {
const parsed = Number(String(value ?? '').replace(/[^\d.-]/g, ''))
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
async function loadPricedArchiveProducts(): Promise<ComboLimitPricedProduct[]> {
const rows = await prisma.$queryRaw<PriceSourceRow[]>`
SELECT
a."barcode" AS "barcode",
a."productStatus" AS "productStatus",
a."opsHead" AS "opsHead",
l."ownerLimitPrice"::text AS "ownerLimitPrice",
a."costPrice" AS "costPrice"
FROM "SupplyChainProductArchive" a
INNER JOIN "SupplyChainStyleLimitPrice" l ON l."styleCode" = a."sku"
WHERE l."ownerLimitPrice" > 0
`
return rows.flatMap((row) => {
const barcode = String(row.barcode ?? '').trim()
const ownerLimitPrice = Number(row.ownerLimitPrice)
if (!barcode || !Number.isFinite(ownerLimitPrice) || ownerLimitPrice <= 0) return []
return [{
barcode,
productStatus: String(row.productStatus ?? '').trim(),
opsHead: String(row.opsHead ?? '').trim(),
ownerLimitPrice,
costPrice: parseCostPrice(row.costPrice),
}]
})
}
export class SupplyChainComboLimitService {
private cache: CacheEntry | null = null
private inFlight: Promise<ComboLimitRow[]> | null = null
constructor(
private readonly repository: ComboSourceRepository = jstComboLimitSourceRepository,
private readonly loadPricedProducts: PricedProductLoader = loadPricedArchiveProducts,
private readonly now: () => number = Date.now,
) {}
private async computeRows() {
const products = (await this.loadPricedProducts())
.filter((product) => isComboComponentEligible(product.productStatus))
const aggregates: ComboLimitAggregate[] = await this.repository.listCompleteComboLimits(products)
return aggregates
.map(buildComboLimitRow)
.sort((left, right) => left.comboCode.localeCompare(right.comboCode, 'zh-Hans-CN', { numeric: true }))
}
private async loadRows(forceRefresh = false) {
const now = this.now()
if (!forceRefresh && this.cache && this.cache.expiresAt > now) return this.cache.rows
if (this.inFlight) return this.inFlight
this.inFlight = this.computeRows()
.then((rows) => {
this.cache = { rows, expiresAt: this.now() + CACHE_TTL_MS }
return rows
})
.finally(() => {
this.inFlight = null
})
return this.inFlight
}
async listPage(input: {
page: number
pageSize: number
filters?: ComboLimitFilters
sort?: ComboLimitSort
forceRefresh?: boolean
}): Promise<ComboLimitPage> {
const rows = await this.loadRows(input.forceRefresh)
const filtered = filterComboLimitRows(rows, input.filters ?? {})
return paginateComboLimitRows(
sortComboLimitRows(filtered, input.sort ?? null),
input.page,
input.pageSize,
)
}
async listOptions(input: {
field: ComboLimitFieldKey
q?: string
offset?: number
limit?: number
filters?: ComboLimitFilters
}) {
const rows = await this.loadRows()
const filters = { ...(input.filters ?? {}) }
delete filters[input.field]
return comboLimitOptions(filterComboLimitRows(rows, filters), input.field, input)
}
}
export const supplyChainComboLimitService = new SupplyChainComboLimitService()