diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveHeaderFilter.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveHeaderFilter.tsx index 4e16220a..8b400e7e 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveHeaderFilter.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveHeaderFilter.tsx @@ -1,10 +1,13 @@ 'use client' -import { useEffect, useRef, useState } from 'react' -import { Filter } from 'lucide-react' -import type { - ProductArchiveFilterOptionKey, - ProductArchiveFilters, +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { Filter, X } from 'lucide-react' +import { + PRODUCT_ARCHIVE_NORMAL_CODE_FIELDS, + PRODUCT_ARCHIVE_TEXT_FILTER_KEYS, + type ProductArchiveFilters, + type ProductArchiveFilterOptionKey, + type ProductArchiveTextFilterKey, } from '@/lib/supply-chain-product-archive' import { ProductArchiveFilterOptionList } from './ProductArchiveFilterOptionList' @@ -34,6 +37,74 @@ const FILTER_CONTROLS: Record = { updateTime: { kind: 'dateRange', filterKey: 'updateTime' }, } +// 文本列带「形态 + 排除规则」,用双排;其余列保持原来的单列窄弹层。 +const POPOVER_WIDTH = 300 +const POPOVER_TEXT_WIDTH = 520 +const POPOVER_MARGIN = 12 +// 低于这个高度就没法同时放下规则和候选值了,宁可让它溢出去靠页面滚动。 +const POPOVER_MIN_HEIGHT = 200 + +const TEXT_FILTER_KEY_SET: ReadonlySet = new Set(PRODUCT_ARCHIVE_TEXT_FILTER_KEYS) +const NORMAL_CODE_FIELD_SET: ReadonlySet = new Set(PRODUCT_ARCHIVE_NORMAL_CODE_FIELDS) + +function isTextFilterKey(key: string): key is ProductArchiveTextFilterKey { + return TEXT_FILTER_KEY_SET.has(key) +} + +/** 本列是否已勾「只看正常编码」。 */ +function normalCodeEnabled(filters: ProductArchiveFilters, key: string) { + return NORMAL_CODE_FIELD_SET.has(key) + && Boolean(filters.normalCodeOnly?.includes(key as ProductArchiveTextFilterKey)) +} + +/** 本列当前的「排除以…开头」关键词。 */ +function excludeKeywords(filters: ProductArchiveFilters, key: string) { + if (!isTextFilterKey(key)) return [] + return (filters.excludeStartsWith ?? []) + .filter((rule) => rule.field === key) + .map((rule) => rule.startsWith) +} + +/** 去空、按大写去重,保持输入顺序。 */ +function normalizeExcludeInputs(values: string[]) { + const seen = new Set() + const normalized: string[] = [] + for (const value of values) { + const keyword = value.trim() + if (!keyword) continue + const dedupeKey = keyword.toUpperCase() + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + normalized.push(keyword) + } + return normalized +} + +/** + * 弹层往右下展开,靠右的列会顶出屏幕、靠近屏幕底部的表头会把弹层截断。 + * 打开前先按已知宽度和视口算好横向偏移与最大高度,避免「先渲染再挪」的闪动。 + */ +function resolvePopoverLayout(trigger: HTMLElement | null, width: number) { + const fallback = { shift: 0, maxHeight: 520, above: false } + if (!trigger || typeof window === 'undefined') return fallback + const rect = trigger.getBoundingClientRect() + + let shift = 0 + if (rect.left + width > window.innerWidth - POPOVER_MARGIN) { + shift = window.innerWidth - POPOVER_MARGIN - (rect.left + width) + } + if (rect.left + shift < POPOVER_MARGIN) shift = POPOVER_MARGIN - rect.left + + const below = window.innerHeight - (rect.bottom + 7) - POPOVER_MARGIN + const above = rect.top - 7 - POPOVER_MARGIN + // 下方放不下时向上翻;两边都不够就用较大的一边并允许内部滚动。 + const useAbove = below < POPOVER_MIN_HEIGHT && above > below + const available = useAbove ? above : below + const maxHeight = Math.max(POPOVER_MIN_HEIGHT, Math.min(520, available)) + + return { shift: Math.round(shift), maxHeight: Math.round(maxHeight), above: useAbove } +} + function getSelectedValues(control: FilterControl, filters: ProductArchiveFilters) { if (control.kind === 'dateRange') return [] return filters[control.filterKey] ?? [] @@ -70,6 +141,9 @@ export function ProductArchiveHeaderFilter({ const [selectedValues, setSelectedValues] = useState([]) const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') + const [normalCodeOn, setNormalCodeOn] = useState(false) + const [excludeInputs, setExcludeInputs] = useState([]) + const [popoverLayout, setPopoverLayout] = useState({ shift: 0, maxHeight: 520, above: false }) useEffect(() => { if (!open) return @@ -82,18 +156,47 @@ export function ProductArchiveHeaderFilter({ if (!control) return null -const clear = () => { - const next = { ...filters } as ProductArchiveFilters - if (control.kind === 'dateRange') { - delete next[`${control.filterKey}From` as 'createTimeFrom' | 'updateTimeFrom'] - delete next[`${control.filterKey}To` as 'createTimeFrom' | 'updateTimeTo'] - } else { - delete next[control.filterKey] + const textFilterKey = control.kind === 'options' && isTextFilterKey(control.filterKey) + ? control.filterKey + : null + const supportsNormalCode = Boolean(textFilterKey && NORMAL_CODE_FIELD_SET.has(textFilterKey)) + + /** + * 把本列的两类文本规则写回 filters。 + * 只动本列那几条,其他列的规则原样保留(规则是跨列共存的)。 + */ + const writeTextRules = (next: ProductArchiveFilters, keywordList: string[], normalCode: boolean) => { + if (!textFilterKey) return + + const otherRules = (filters.excludeStartsWith ?? []).filter((rule) => rule.field !== textFilterKey) + const mergedRules = [ + ...otherRules, + ...keywordList.map((startsWith) => ({ field: textFilterKey, startsWith })), + ] + if (mergedRules.length) next.excludeStartsWith = mergedRules + else delete next.excludeStartsWith + + const otherFields = (filters.normalCodeOnly ?? []).filter((key) => key !== textFilterKey) + const mergedFields = normalCode && supportsNormalCode ? [...otherFields, textFilterKey] : otherFields + if (mergedFields.length) next.normalCodeOnly = mergedFields + else delete next.normalCodeOnly + } + + const clear = () => { + const next = { ...filters } as ProductArchiveFilters + if (control.kind === 'dateRange') { + delete next[`${control.filterKey}From` as 'createTimeFrom' | 'updateTimeFrom'] + delete next[`${control.filterKey}To` as 'createTimeFrom' | 'updateTimeTo'] + } else { + delete next[control.filterKey] + writeTextRules(next, [], false) + } + onChange(next) + setSelectedValues([]) + setExcludeInputs([]) + setNormalCodeOn(false) + setOpen(false) } - onChange(next) - setSelectedValues([]) - setOpen(false) -} const apply = () => { const next = { ...filters } as ProductArchiveFilters @@ -106,6 +209,7 @@ const clear = () => { } } else delete next[control.filterKey] + writeTextRules(next, normalizeExcludeInputs(excludeInputs), normalCodeOn) } else { const fromKey = `${control.filterKey}From` as 'createTimeFrom' | 'updateTimeFrom' const toKey = `${control.filterKey}To` as 'createTimeTo' | 'updateTimeTo' @@ -126,7 +230,24 @@ const clear = () => { ) } - const active = controlHasFilter(control, filters) + const activeRuleCount = control.kind === 'options' + ? excludeKeywords(filters, control.filterKey).length + + (normalCodeEnabled(filters, control.filterKey) ? 1 : 0) + : 0 + const active = controlHasFilter(control, filters) || activeRuleCount > 0 + + const optionList = control.kind === 'options' ? ( + { + setSelectedValues((current) => [...new Set([...current, ...values])]) + }} + /> + ) : null return ( @@ -138,11 +259,19 @@ const clear = () => { aria-expanded={open} onClick={(event) => { event.stopPropagation() - if (control.kind === 'options') setSelectedValues(getSelectedValues(control, filters)) + if (control.kind === 'options') { + setSelectedValues(getSelectedValues(control, filters)) + setExcludeInputs(excludeKeywords(filters, control.filterKey)) + setNormalCodeOn(normalCodeEnabled(filters, control.filterKey)) + } if (control.kind === 'dateRange') { setDateFrom(filters[`${control.filterKey}From` as 'createTimeFrom' | 'updateTimeFrom'] ?? '') setDateTo(filters[`${control.filterKey}To` as 'createTimeTo' | 'updateTimeTo'] ?? '') } + if (!open) { + const width = textFilterKey ? POPOVER_TEXT_WIDTH : POPOVER_WIDTH + setPopoverLayout(resolvePopoverLayout(event.currentTarget, width)) + } setOpen((current) => !current) }} > @@ -151,41 +280,92 @@ const clear = () => { {open && (
{ event.preventDefault() apply() }} > - {control.kind === 'options' && ( - { - setSelectedValues((current) => [...new Set([...current, ...values])]) - }} - /> - )} + {textFilterKey ? ( +
+
+ {supportsNormalCode && ( + + )} - {control.kind === 'dateRange' && ( -
- setDateFrom(event.target.value)} - /> - 至 - setDateTo(event.target.value)} - /> +
排除「以…开头」
+ {excludeInputs.length > 0 && ( +
+ {excludeInputs.map((keyword, index) => ( +
+ setExcludeInputs((current) => current.map( + (item, itemIndex) => (itemIndex === index ? event.target.value : item), + ))} + /> + +
+ ))} +
+ )} + +
+ +
{optionList}
+ ) : ( + <> + {optionList} + {control.kind === 'dateRange' && ( +
+ setDateFrom(event.target.value)} + /> + 至 + setDateTo(event.target.value)} + /> +
+ )} + )}
@@ -212,9 +392,51 @@ const clear = () => { .archive-header-filter-trigger.active { color: #2563eb; } .archive-header-filter-popover { position: absolute; top: calc(100% + 7px); left: 0; z-index: 20; - width: 286px; padding: 10px; border: 1px solid #d4d4d8; border-radius: 6px; + display: flex; flex-direction: column; + width: 300px; max-height: var(--popover-max-height, 520px); padding: 10px; + border: 1px solid #d4d4d8; border-radius: 6px; background: #fff; box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); text-align: left; + transform: translateX(var(--popover-shift, 0px)); + } + .archive-header-filter-popover.is-above { top: auto; bottom: calc(100% + 7px); } + .archive-header-filter-popover.is-text { width: 520px; } + .archive-header-filter-body { display: flex; flex: 1 1 auto; min-height: 0; gap: 12px; } + .archive-header-filter-rules { + display: flex; flex: 0 0 190px; flex-direction: column; min-height: 0; + padding-right: 12px; border-right: 1px solid #e4e4e7; + } + .archive-header-filter-normalcode { + display: flex; flex: 0 0 auto; align-items: center; gap: 7px; + padding: 8px; margin-bottom: 12px; border-radius: 5px; background: #fafafa; + color: #27272a; font-size: 12px; line-height: 1.4; cursor: pointer; + } + .archive-header-filter-normalcode:hover { background: #f4f4f5; } + .archive-header-filter-normalcode input { width: 14px; height: 14px; margin: 0; flex: 0 0 auto; accent-color: #27272a; } + .archive-header-filter-rules-label { flex: 0 0 auto; margin: 0 0 6px 1px; color: #71717a; font-size: 12px; } + .archive-header-filter-rule-list { + display: flex; flex: 0 1 auto; min-height: 0; flex-direction: column; gap: 5px; + padding: 7px; border-radius: 5px; background: #fafafa; overflow-y: auto; + } + .archive-header-filter-rule { display: flex; align-items: center; gap: 5px; } + .archive-header-filter-rule input { + min-width: 0; flex: 1; height: 28px; padding: 0 8px; border: 1px solid #d4d4d8; + border-radius: 4px; background: #fff; color: #27272a; font-size: 12px; outline: none; + } + .archive-header-filter-rule input:focus { border-color: #52525b; } + .archive-header-filter-rule-remove { + display: inline-flex; align-items: center; justify-content: center; + width: 24px; height: 24px; flex: 0 0 auto; padding: 0; border: none; border-radius: 4px; + background: transparent; color: #a1a1aa; cursor: pointer; + } + .archive-header-filter-rule-remove:hover { background: #e4e4e7; color: #b91c1c; } + .archive-header-filter-add-rule { + flex: 0 0 auto; margin-top: 8px; padding: 0; border: none; background: transparent; + color: #2563eb; font-size: 12px; font-weight: 600; cursor: pointer; + } + .archive-header-filter-add-rule:hover { text-decoration: underline; } + .archive-header-filter-values { + display: flex; flex: 1 1 auto; min-width: 0; min-height: 0; flex-direction: column; } .archive-header-filter-dates { display: flex; align-items: center; gap: 5px; } .archive-header-filter-dates input { @@ -222,8 +444,11 @@ const clear = () => { border-radius: 4px; color: #3f3f46; font-size: 11px; outline: none; } .archive-header-filter-dates span { color: #71717a; font-size: 12px; } - .archive-header-filter-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 10px; } - .archive-header-filter-actions button { height: 28px; padding: 0 10px; border-radius: 4px; font-size: 12px; cursor: pointer; } + .archive-header-filter-actions { + display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 7px; + margin-top: 10px; padding-top: 10px; border-top: 1px solid #e4e4e7; + } + .archive-header-filter-actions button { height: 28px; padding: 0 12px; border-radius: 4px; font-size: 12px; cursor: pointer; } .archive-header-filter-clear { border: 1px solid #d4d4d8; background: #fff; color: #52525b; } .archive-header-filter-apply { border: 1px solid #27272a; background: #27272a; color: #fff; } `} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchiveViewPreferences.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchiveViewPreferences.ts index 7539ad0a..a66bdd16 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchiveViewPreferences.ts +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/useProductArchiveViewPreferences.ts @@ -5,8 +5,11 @@ import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE, PRODUCT_ARCHIVE_FILTER_OPTION_KEYS, PRODUCT_ARCHIVE_SALES_STATUSES, + PRODUCT_ARCHIVE_TEXT_FILTER_KEYS, + type ProductArchiveExcludeRule, type ProductArchiveFilters, type ProductArchiveSalesStatus, + type ProductArchiveTextFilterKey, } from '@/lib/supply-chain-product-archive' const STORAGE_KEY = 'supply_chain_product_archive_view_v1' @@ -42,6 +45,36 @@ function cleanStringArray(value: unknown): string[] | undefined { return normalized.length ? normalized : undefined } +const TEXT_FILTER_KEY_SET: ReadonlySet = new Set(PRODUCT_ARCHIVE_TEXT_FILTER_KEYS) + +/** 「只看正常编码」的列白名单归一(去重、去掉非文本列)。 */ +function cleanNormalCodeOnly(value: unknown): ProductArchiveTextFilterKey[] | undefined { + const values = Array.isArray(value) ? value : [] + const normalized = [...new Set( + values.filter((item): item is ProductArchiveTextFilterKey => + typeof item === 'string' && TEXT_FILTER_KEY_SET.has(item)), + )] + return normalized.length ? normalized : undefined +} + +/** 「排除以…开头」规则归一(去空、去非文本列、按列+关键词去重)。 */ +function cleanExcludeRules(value: unknown): ProductArchiveExcludeRule[] | undefined { + const values = Array.isArray(value) ? value : [] + const rules: ProductArchiveExcludeRule[] = [] + const seen = new Set() + for (const item of values) { + if (!isRecord(item)) continue + const field = item.field + const startsWith = cleanString(item.startsWith) + if (typeof field !== 'string' || !TEXT_FILTER_KEY_SET.has(field) || !startsWith) continue + const dedupeKey = `${field}\u0000${startsWith.toUpperCase()}` + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + rules.push({ field: field as ProductArchiveTextFilterKey, startsWith }) + } + return rules.length ? rules : undefined +} + function sanitizeFilters(value: unknown): ProductArchiveFilters { if (!isRecord(value)) return {} @@ -69,6 +102,11 @@ function sanitizeFilters(value: unknown): ProductArchiveFilters { if (normalized) filters[key] = normalized } + const normalCodeOnly = cleanNormalCodeOnly(value.normalCodeOnly) + if (normalCodeOnly) filters.normalCodeOnly = normalCodeOnly + const excludeStartsWith = cleanExcludeRules(value.excludeStartsWith) + if (excludeStartsWith) filters.excludeStartsWith = excludeStartsWith + return filters } diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive.ts b/furniture-3d-system/src/lib/supply-chain-product-archive.ts index eee42631..39f0552c 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive.ts @@ -101,6 +101,39 @@ export type ProductArchiveSalesStatus = (typeof PRODUCT_ARCHIVE_SALES_STATUSES)[number] export type ProductArchiveStatusFilter = ProductArchiveSalesStatus | typeof PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE +/** + * 「只看正常编码」与「排除以…开头」只对这几列开放。 + * 都是 NOT NULL 的文本列;nullable 列(image/weight/…)不开入口, + * 避免 NOT 条件在 SQL 里把 NULL 行一起判掉。 + */ +export const PRODUCT_ARCHIVE_TEXT_FILTER_KEYS = ['spu', 'sku', 'barcode', 'productName'] as const +export type ProductArchiveTextFilterKey = (typeof PRODUCT_ARCHIVE_TEXT_FILTER_KEYS)[number] + +/** + * 「正常编码」形态:字母前缀(2~6位) + 可选「-字母段(2~6位)」 + 数字编号(3~8位)。 + * 命中:XG0022 / DNZ0411 / GBSFY-SJ0380 / GBPM-YMJ0147 / CFZW0161 / CM20062362 + * 不命中:1036.00 / 110886Z15 / 1643A / 2247B / 含中文的商品名 / 32 位哈希 + * 服务端用同一个串拼 SQL 正则,避免两边口径漂移。 + */ +export const PRODUCT_ARCHIVE_NORMAL_CODE_PATTERN = '^[A-Z]{2,6}(-[A-Z]{2,6})?[0-9]{3,8}$' + +export function isNormalArchiveCode(value: string) { + return new RegExp(PRODUCT_ARCHIVE_NORMAL_CODE_PATTERN).test(String(value ?? '').trim().toUpperCase()) +} + +/** + * 「只看正常编码」当前只对 SPU 开放。 + * 其余文本列(条码是纯数字、商品名称是中文)不存在这个形态概念。 + * 要放开哪一列,改这里即可(服务端已支持任意文本列)。 + */ +export const PRODUCT_ARCHIVE_NORMAL_CODE_FIELDS = ['spu'] as const satisfies readonly ProductArchiveTextFilterKey[] + +/** 一条「排除以…开头」规则。可累加,多条之间取「且」。 */ +export type ProductArchiveExcludeRule = { + field: ProductArchiveTextFilterKey + startsWith: string +} + export type ProductArchiveFilters = { salesGranularity?: 'day' | 'month' | 'year' salesPeriod?: string @@ -190,6 +223,10 @@ export type ProductArchiveFilters = { createTimeTo?: string updateTimeFrom?: string updateTimeTo?: string + /** 只看「正常编码」(字母前缀+数字编号)。目前仅对 spu 开放。 */ + normalCodeOnly?: ProductArchiveTextFilterKey[] + /** 「排除以…开头」规则,可累加。与上面的勾选值「包含」是「且」的关系。 */ + excludeStartsWith?: ProductArchiveExcludeRule[] } export const PRODUCT_ARCHIVE_FILTER_OPTION_KEYS = [ 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 new file mode 100644 index 00000000..749eea3f --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-text-rules.test.ts @@ -0,0 +1,155 @@ +import { parseProductArchiveFilters } from '@/server/services/supply-chain-product-archive-query' +import { + PRODUCT_ARCHIVE_NORMAL_CODE_PATTERN, + isNormalArchiveCode, +} from '@/lib/supply-chain-product-archive' +import { + buildProductArchiveWhere, + buildProductArchiveWhereWithTextRules, + clearNormalCodeValueCache, +} from '../supply-chain-product-archive-filters' + +jest.mock('@/lib/prisma', () => ({ + prisma: { $queryRaw: jest.fn() }, +})) + +const mockedQueryRaw = (jest.requireMock('@/lib/prisma') as { + prisma: { $queryRaw: jest.Mock } +}).prisma.$queryRaw + +beforeEach(() => { + clearNormalCodeValueCache() + mockedQueryRaw.mockReset() + mockedQueryRaw.mockResolvedValue([{ value: 'DNZ0411' }, { value: 'XG0022' }]) +}) + +describe('normal archive code pattern', () => { + it('matches the codes the user wants to keep', () => { + for (const code of ['XG0022', 'DNZ0411', 'GBSFY-SJ0380', 'GBPM-YMJ0147', 'CFZW0161', 'CM20062362']) { + expect(isNormalArchiveCode(code)).toBe(true) + } + }) + + it('rejects the junk codes the user reported', () => { + for (const code of ['1036.00', '110886Z15', '1643A', '2247B', '0', '723E32B019A9337FA83B59A5AE754975', 'A31-344软包折叠椅45中古黑色黑皮【8188】']) { + expect(isNormalArchiveCode(code)).toBe(false) + } + }) + + it('rejects a long digit run (the MKHDNZ style codes are filtered by the rule, not by prefix)', () => { + expect(isNormalArchiveCode('MKHDNZ1234567890123')).toBe(false) + }) + + it('keeps the SQL and JS rules on the same pattern string', () => { + expect(PRODUCT_ARCHIVE_NORMAL_CODE_PATTERN).toBe('^[A-Z]{2,6}(-[A-Z]{2,6})?[0-9]{3,8}$') + }) +}) + +describe('product archive text rule parsing', () => { + it('accepts the new text rule fields', () => { + expect(parseProductArchiveFilters(JSON.stringify({ + normalCodeOnly: ['spu'], + excludeStartsWith: [{ field: 'spu', startsWith: 'WM-' }], + }))).toEqual({ + success: true, + data: { + normalCodeOnly: ['spu'], + excludeStartsWith: [{ field: 'spu', startsWith: 'WM-' }], + }, + }) + }) + + it('rejects non-text columns and empty keywords', () => { + expect(parseProductArchiveFilters(JSON.stringify({ normalCodeOnly: ['categoryL1'] }))).toEqual({ success: false }) + expect(parseProductArchiveFilters(JSON.stringify({ excludeStartsWith: [{ field: 'weight', startsWith: 'W' }] }))) + .toEqual({ success: false }) + expect(parseProductArchiveFilters(JSON.stringify({ excludeStartsWith: [{ field: 'spu', startsWith: ' ' }] }))) + .toEqual({ success: false }) + }) + + it('caps how many exclude rules can be sent', () => { + const rules = Array.from({ length: 21 }, (_, index) => ({ field: 'spu', startsWith: `R${index}` })) + expect(parseProductArchiveFilters(JSON.stringify({ excludeStartsWith: rules }))).toEqual({ success: false }) + }) +}) + +describe('buildProductArchiveWhere exclude rules', () => { + it('emits a NOT startsWith clause per rule', () => { + const where = buildProductArchiveWhere({ + excludeStartsWith: [ + { field: 'spu', startsWith: 'WM-' }, + { field: 'spu', startsWith: 'DF-' }, + ], + }) as { AND?: Array> } + + expect(where.AND).toEqual([ + { NOT: { spu: { startsWith: 'WM-', mode: 'insensitive' } } }, + { NOT: { spu: { startsWith: 'DF-', mode: 'insensitive' } } }, + ]) + }) + + it('drops empty keywords and de-duplicates case-insensitively', () => { + const where = buildProductArchiveWhere({ + excludeStartsWith: [ + { field: 'spu', startsWith: 'DF-' }, + { field: 'spu', startsWith: 'df-' }, + { field: 'spu', startsWith: ' DF- ' }, + { field: 'spu', startsWith: ' ' }, + ], + }) as { AND?: Array> } + + expect(where.AND).toHaveLength(1) + expect(where.AND?.[0]).toEqual({ NOT: { spu: { startsWith: 'DF-', mode: 'insensitive' } } }) + }) + + it('ignores rules aimed at a column that is not a text filter target', () => { + const where = buildProductArchiveWhere({ + excludeStartsWith: [{ field: 'weight' as never, startsWith: 'W' }], + }) + expect(where).toEqual({}) + }) + + it('leaves the legacy shape untouched when no text rules are set', () => { + expect(buildProductArchiveWhere({ categoryL1: ['桌类'] })).toEqual({ + AND: [{ categoryL1: { in: ['桌类'] } }], + }) + }) +}) + +describe('buildProductArchiveWhereWithTextRules normal code', () => { + it('resolves the normal-code values into an IN clause', async () => { + const where = await buildProductArchiveWhereWithTextRules({ normalCodeOnly: ['spu'] }) as { + AND?: unknown[] + } + + expect(mockedQueryRaw).toHaveBeenCalledTimes(1) + expect(where.AND).toEqual([ + {}, + { spu: { in: ['DNZ0411', 'XG0022'] } }, + ]) + }) + + it('merges exclude rules into the same AND group', async () => { + const where = await buildProductArchiveWhereWithTextRules({ + normalCodeOnly: ['spu'], + excludeStartsWith: [{ field: 'spu', startsWith: 'WM-' }], + }) as { AND?: unknown[] } + + expect(where.AND).toEqual([ + { AND: [{ NOT: { spu: { startsWith: 'WM-', mode: 'insensitive' } } }] }, + { spu: { in: ['DNZ0411', 'XG0022'] } }, + ]) + }) + + it('caches the resolved values across calls', async () => { + await buildProductArchiveWhereWithTextRules({ normalCodeOnly: ['spu'] }) + await buildProductArchiveWhereWithTextRules({ normalCodeOnly: ['spu'] }) + expect(mockedQueryRaw).toHaveBeenCalledTimes(1) + }) + + it('returns the plain where when there are no text rules', async () => { + const where = await buildProductArchiveWhereWithTextRules({ categoryL1: ['桌类'] }) + expect(where).toEqual({ AND: [{ categoryL1: { in: ['桌类'] } }] }) + expect(mockedQueryRaw).not.toHaveBeenCalled() + }) +}) 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 b9d8d037..c8cb138e 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 @@ -1,5 +1,13 @@ import { Prisma } from '@prisma/client' -import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE, type ProductArchiveFilters, type ProductArchiveFilterOptionKey } from '@/lib/supply-chain-product-archive' +import { prisma } from '@/lib/prisma' +import { + PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE, + PRODUCT_ARCHIVE_NORMAL_CODE_PATTERN, + type ProductArchiveExcludeRule, + type ProductArchiveFilters, + type ProductArchiveFilterOptionKey, + type ProductArchiveTextFilterKey, +} from '@/lib/supply-chain-product-archive' function parseDateBoundary(value: string, endOfDay = false) { return new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}`) @@ -24,6 +32,25 @@ function buildEmptyValueClause(databaseField: string): Prisma.SupplyChainProduct return { [databaseField]: '' } as Prisma.SupplyChainProductArchiveWhereInput } +/** + * 去掉空值与重复的「排除以…开头」规则,保证同一列同一条只出一遍。 + * 关键词按大写去重:SQL 侧是不区分大小写的 startsWith。 + */ +function normalizeExcludeRules(filters: ProductArchiveFilters): ProductArchiveExcludeRule[] { + const seen = new Set() + const rules: ProductArchiveExcludeRule[] = [] + for (const rule of filters.excludeStartsWith ?? []) { + const field = rule?.field + const startsWith = String(rule?.startsWith ?? '').trim() + if (!field || !startsWith || !(field in TEXT_FILTER_DATABASE_FIELDS)) continue + const dedupeKey = `${field}\u0000${startsWith.toUpperCase()}` + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + rules.push({ field, startsWith }) + } + return rules +} + export function buildProductArchiveWhere( filters: ProductArchiveFilters = {}, omitFilter?: ProductArchiveFilterOptionKey, @@ -83,6 +110,19 @@ export function buildProductArchiveWhere( clauses.push({ salesPerformance: { is: condition } } as Prisma.SupplyChainProductArchiveWhereInput) } } + // 「排除以…开头」:纯 Prisma 条件,不需要查库。 + // 只作用在 NOT NULL 的文本列上,NOT 不会误伤 NULL 行。 + // + // 注意:这里**故意不看 omitFilter**。omit 的语义是「取候选值时忽略本列已勾选的值」, + // 而文本规则是「这列接受什么形态」,必须作用到候选值列表上——否则列表里会冒出 + // 已经被排除的取值,用户勾了却得到 0 行。 + for (const rule of normalizeExcludeRules(filters)) { + const databaseField = TEXT_FILTER_DATABASE_FIELDS[rule.field] + if (!databaseField) continue + clauses.push({ + NOT: { [databaseField]: { startsWith: rule.startsWith, mode: 'insensitive' } }, + } as Prisma.SupplyChainProductArchiveWhereInput) + } if (filters.createTimeFrom || filters.createTimeTo) { clauses.push({ businessCreatedAt: { @@ -153,6 +193,8 @@ export const FILTER_OPTION_DATABASE_FIELDS = { opsDirector: 'opsDirector', opsDeptHead: 'opsDeptHead', opsHead: 'opsHead', + hasDrawing: 'hasDrawing', + hasVideo: 'hasVideo', warehouseHead: 'warehouseHead', warehouseType: 'warehouseType', purchasePrice: 'purchasePrice', @@ -179,3 +221,69 @@ export const FILTER_OPTION_PERFORMANCE_FIELDS = { feeRatio: 'feeRatio', operatingProfit: 'operatingProfit', } as const + +/** 「只看正常编码」「排除以…开头」允许作用的列 —— 全部是 NOT NULL 文本列。 */ +export const TEXT_FILTER_DATABASE_FIELDS = { + spu: 'spu', + sku: 'sku', + barcode: 'barcode', + productName: 'productName', +} as const satisfies Record + +/** + * 「正常编码」取值清单的缓存。 + * + * Prisma 7 的字符串过滤不支持正则(生成类型里没有 regex),所以只能先用原生 SQL + * 把符合形态的取值查出来,再拼成 `in` 条件。这些取值只在档案同步时才会变, + * 因此按列缓存几分钟,避免每次翻页都做一次全表正则扫描。 + */ +const NORMAL_CODE_CACHE_TTL_MS = 5 * 60 * 1000 +const normalCodeValueCache = new Map() + +async function listNormalCodeValues(databaseField: string): Promise { + const cached = normalCodeValueCache.get(databaseField) + if (cached && cached.expiresAt > Date.now()) return cached.values + + const column = Prisma.raw(`"${databaseField}"`) + const rows = await prisma.$queryRaw>(Prisma.sql` + SELECT DISTINCT ${column} AS value + FROM "SupplyChainProductArchive" + WHERE ${column} ~ ${PRODUCT_ARCHIVE_NORMAL_CODE_PATTERN} + `) + const values = rows.map((row) => row.value).filter((value): value is string => Boolean(value)) + normalCodeValueCache.set(databaseField, { values, expiresAt: Date.now() + NORMAL_CODE_CACHE_TTL_MS }) + return values +} + +/** 供测试与排查清缓存用。 */ +export function clearNormalCodeValueCache() { + normalCodeValueCache.clear() +} + +async function resolveNormalCodeClauses( + filters: ProductArchiveFilters, +): Promise { + const clauses: Prisma.SupplyChainProductArchiveWhereInput[] = [] + for (const key of new Set(filters.normalCodeOnly ?? [])) { + const databaseField = TEXT_FILTER_DATABASE_FIELDS[key] + if (!databaseField) continue + clauses.push({ [databaseField]: { in: await listNormalCodeValues(databaseField) } }) + } + return clauses +} + +/** + * 带「文本规则」的取数条件。 + * + * `buildProductArchiveWhere` 保持同步(只有纯 Prisma 条件,老调用点与老测试不受影响); + * 需要查库的「只看正常编码」在这里补齐,再与基础条件合并。 + * 档案的列表、候选项、导出、图片导出都经由 preparePeriodArchiveQuery 走这一个入口。 + */ +export async function buildProductArchiveWhereWithTextRules( + filters: ProductArchiveFilters = {}, + omitFilter?: ProductArchiveFilterOptionKey, +): Promise { + const base = buildProductArchiveWhere(filters, omitFilter) + const extra = await resolveNormalCodeClauses(filters) + return extra.length > 0 ? { AND: [base, ...extra] } : base +} diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-period.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-period.repository.ts index 8b015675..ac0f00ff 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-period.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-period.repository.ts @@ -4,7 +4,7 @@ import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE, type ProductArchiveFilterOptionKey, import { resolveSalesPeriod, type SalesCoverage } from '@/lib/product-archive-sales-period' import { calculateProductArchiveSalesPerformance } from '@/server/services/product-archive-sales-performance' import type { ProductArchiveSalesPerformanceAggregate } from './product-archive-sales-performance-source.repository' -import { buildProductArchiveWhere } from './supply-chain-product-archive-filters' +import { buildProductArchiveWhereWithTextRules } from './supply-chain-product-archive-filters' import type { ProductArchiveRecord } from './supply-chain-product-archive-select' export const PERFORMANCE_KEYS = [ @@ -90,7 +90,7 @@ export function overlayPerformance(records: ProductArchiveRecord[], dataset: Per return records.map((record) => ({ ...record, salesPerformance: findPerformance(dataset, record.barcode) })) } export async function preparePeriodArchiveQuery(filters: ProductArchiveFilters = {}, omit?: ProductArchiveFilterOptionKey, dataset?: PeriodDataset) { - const where = buildProductArchiveWhere(getArchiveMasterFilters(filters), omit) + const where = await buildProductArchiveWhereWithTextRules(getArchiveMasterFilters(filters), omit) if (!PERFORMANCE_KEYS.some((key) => key !== omit && filters[key]?.length)) return { where, ids: null } const resolved = dataset ?? await listPeriodPerformance(filters) const products = await prisma.supplyChainProductArchive.findMany({ diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts index 4c41ece9..9c3c82a5 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-query.ts @@ -3,6 +3,7 @@ import { validSalesSelection } from '@/lib/product-archive-sales-period' import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE, PRODUCT_ARCHIVE_SALES_STATUSES, + PRODUCT_ARCHIVE_TEXT_FILTER_KEYS, type ProductArchiveFilters, } from '@/lib/supply-chain-product-archive' @@ -13,6 +14,12 @@ const archiveSalesPeriodSchema = z.string().regex(/^(\d{4}-\d{2}-\d{2}|\d{4}-\d{ // candidate list (e.g. thousands of SPUs) — such payloads arrive via POST body // once they outgrow the query string. const archiveValueArraySchema = z.array(z.string().trim().max(200)).max(20000) +// 「只看正常编码」与「排除以…开头」只开放给 NOT NULL 的文本列。 +const archiveTextFilterKeySchema = z.enum(PRODUCT_ARCHIVE_TEXT_FILTER_KEYS) +const archiveExcludeRuleSchema = z.object({ + field: archiveTextFilterKeySchema, + startsWith: z.string().trim().min(1).max(64), +}).strict() const productArchiveFiltersSchema = z.object({ salesGranularity: archiveSalesGranularitySchema.optional(), @@ -62,6 +69,8 @@ const productArchiveFiltersSchema = z.object({ opsDirector: archiveValueArraySchema.optional(), opsDeptHead: archiveValueArraySchema.optional(), opsHead: archiveValueArraySchema.optional(), + hasDrawing: archiveValueArraySchema.optional(), + hasVideo: archiveValueArraySchema.optional(), warehouseHead: archiveValueArraySchema.optional(), warehouseOwnership: archiveValueArraySchema.optional(), warehouseType: archiveValueArraySchema.optional(), @@ -108,6 +117,8 @@ const productArchiveFiltersSchema = z.object({ createTimeTo: archiveDateSchema.optional(), updateTimeFrom: archiveDateSchema.optional(), updateTimeTo: archiveDateSchema.optional(), + normalCodeOnly: z.array(archiveTextFilterKeySchema).max(PRODUCT_ARCHIVE_TEXT_FILTER_KEYS.length).optional(), + excludeStartsWith: z.array(archiveExcludeRuleSchema).max(20).optional(), }).strict().refine(validSalesSelection, { message: '销售统计周期不正确', path: ['salesPeriod'] }) export function parseProductArchiveFilters(raw: string | null) {