Merge latest main into accepted homepage release
This commit is contained in:
commit
5cf84a19c8
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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" />)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,8 +480,9 @@ 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} />
|
||||
{productKind !== 'combo' && (
|
||||
<LimitTableSettingsMenu
|
||||
compact={settings.compact}
|
||||
onCompactChange={setCompact}
|
||||
@ -474,8 +495,31 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
)]
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -29,6 +29,8 @@ export type LimitTableFilterField = {
|
||||
key: string
|
||||
label: string
|
||||
fixedOptions?: string[]
|
||||
/** 非产品档案筛选项可指定自己的候选值接口 */
|
||||
optionsEndpoint?: string
|
||||
}
|
||||
|
||||
export const LIMIT_TABLE_FILTER_FIELDS: LimitTableFilterField[] = [
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
179
furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts
Normal file
179
furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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"]}}')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user