diff --git a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts new file mode 100644 index 00000000..a6264985 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts @@ -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 }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts new file mode 100644 index 00000000..e12eac90 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts @@ -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 }) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts index 9cc4a985..c62ee67f 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts @@ -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 }, diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx new file mode 100644 index 00000000..eff8ed8c --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx @@ -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 { + 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(null) + const [openSortKey, setOpenSortKey] = useState(null) + const [widths, setWidths] = useState>(() => readWidths(userId)) + const [liveWidths, setLiveWidths] = useState | 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, + [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 ( + <> +
+ 筛选 + {COMBO_FILTER_FIELDS.map((field) => ( + )[field.key] ?? []} + contextFilters={contextFilters} + onChange={(values) => updateFilter(field.key, values)} + /> + ))} +
+ + {activeFilterChips.length > 0 && ( +
+ 已选条件 + {activeFilterChips.map((chip) => ( + + {FIELD_LABEL_MAP.get(chip.key as ComboLimitFieldKey) ?? chip.label} + 属于:{chip.text} + + + ))} + +
+ )} + +
+ 0 ? styles.tableLoading : ''}`} + style={{ width: Math.max(totalWidth, 880) }} + > + + {COMBO_COLUMNS.map((column) => ( + + ))} + + + + {COMBO_COLUMNS.map((column) => ( + 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)[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)} + /> + ))} + + + + {loading && rows.length === 0 && ( + + )} + {!loading && error && ( + + )} + {!loading && !error && rows.length === 0 && ( + + )} + {rows.map((row) => ( + + {COMBO_COLUMNS.map((column) => { + const value = isComboLimitFieldKey(column.key) ? row[column.key] : '' + return ( + + ) + })} + + ))} + +
组合限价加载中…
{error}
没有符合条件的组合限价
+ {value || —} +
+
+ +
+ + { 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) + }} + /> + / {totalPages} + + + 共 {total} 条,每页 + + 条 + +
+ + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx index 41c31e94..a2b44178 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx @@ -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() + 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() + 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() + 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() diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx index cc8ac17c..01b32215 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx @@ -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 /** 传入时在底部显示「收起」按钮 */ 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(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 + 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 ( <> 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) + }} />
{displayOptions.length === 0 && ( @@ -117,9 +149,10 @@ type Props = { field: LimitTableFilterField selected: string[] onChange: (values: string[]) => void + contextFilters?: Record } -export function LimitFilterSelect({ field, selected, onChange }: Props) { +export function LimitFilterSelect({ field, selected, onChange, contextFilters }: Props) { const [open, setOpen] = useState(false) const wrapRef = useRef(null) @@ -152,7 +185,13 @@ export function LimitFilterSelect({ field, selected, onChange }: Props) { {open && (
- setOpen(false)} /> + setOpen(false)} + />
)}
diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx index 04fcb266..17fe0734 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx @@ -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 = { bulk: '大货商品', brand: '品牌商品', + combo: '组合限价', } /** 品牌商品 = 品牌含「顾家 / 索菲亚 / 朴美」;其余(含其他品牌)都归大货商品。 */ @@ -149,6 +153,8 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c const [productKind, setProductKind] = useState('bulk') const [filters, setFilters] = useState(EMPTY_FILTERS) + const [comboFilters, setComboFilters] = useState({}) + const [comboSort, setComboSort] = useState(null) // 列宽拖拽:拖动过程中用本地临时宽度,松开鼠标后一次性持久化到个人设置。 const [liveWidths, setLiveWidths] = useState | 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 => Boolean(field)) + const filterContext = useMemo( + () => filters as Record, + [filters], + ) return (
@@ -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()} > - + - {canExport && ( + {productKind !== 'combo' && canExport && ( <>
) } diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx index e8836ce5..46434218 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx @@ -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 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 ( - {column.label} + {column.label} @@ -180,7 +196,7 @@ export function LimitTableHeaderCell({ className={styles.sortMenuItem} onClick={() => onSort('asc')} > - 按{column.label}升序(单页) + 按{column.label}升序({sortScope === 'all' ? '全量' : '单页'}) {active && ( <> @@ -220,6 +236,7 @@ export function LimitTableHeaderCell({ field={filterField} selected={filterValues ?? []} onChange={(values) => onFilterChange?.(values)} + contextFilters={filterContext} /> , document.body, diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-filter-batch.test.ts b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-filter-batch.test.ts new file mode 100644 index 00000000..8513524c --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-filter-batch.test.ts @@ -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']) + }) +}) diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-filter-batch.ts b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-filter-batch.ts new file mode 100644 index 00000000..39c57583 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-filter-batch.ts @@ -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), + )] +} diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-price.module.css b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-price.module.css index 424e2ac6..bbb1c26b 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-price.module.css +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-price.module.css @@ -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} diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts index a01e6176..52055905 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts @@ -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 }, diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/useComboLimitData.ts b/furniture-3d-system/src/app/supply-chain/management/custom-table/useComboLimitData.ts new file mode 100644 index 00000000..7dc31894 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/useComboLimitData.ts @@ -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(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 + 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 } +} diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/useLimitTableSettings.ts b/furniture-3d-system/src/app/supply-chain/management/custom-table/useLimitTableSettings.ts index 8da63950..8e430df7 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/useLimitTableSettings.ts +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/useLimitTableSettings.ts @@ -29,6 +29,8 @@ export type LimitTableFilterField = { key: string label: string fixedOptions?: string[] + /** 非产品档案筛选项可指定自己的候选值接口 */ + optionsEndpoint?: string } export const LIMIT_TABLE_FILTER_FIELDS: LimitTableFilterField[] = [ diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts new file mode 100644 index 00000000..aa484e0e --- /dev/null +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts @@ -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']) + }) +}) diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts new file mode 100644 index 00000000..057fa35f --- /dev/null +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts @@ -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 + +export type ComboLimitFilters = Partial> + +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(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, + } +} diff --git a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-text-rules.test.ts b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-text-rules.test.ts index 2e8ec02a..a328cb8f 100644 --- a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-text-rules.test.ts +++ b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-text-rules.test.ts @@ -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"]}}') + }) }) diff --git a/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts b/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts new file mode 100644 index 00000000..1fe43806 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts @@ -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 { + 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() diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts index dd5fbff8..cbee24b8 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts @@ -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>(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 } diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts index 75c475b4..2e80d2b9 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts @@ -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>(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) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts new file mode 100644 index 00000000..290c6287 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts @@ -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 }, + ]) + }) +}) diff --git a/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts new file mode 100644 index 00000000..8767cee2 --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts @@ -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 +type PricedProductLoader = () => Promise + +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 { + const rows = await prisma.$queryRaw` + 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 | 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 { + 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()