feat(product-archive): SPU 列支持「只看正常编码」与可累加排除规则

产品全量档案的 SPU 列筛选原来只有「勾选具体值 = 包含」一种语义,没法表达
「不要 DF- 开头的 / 不要纯数字的」这类排除需求,只能靠不断放宽正选来绕。

新增两类能力(原有「勾选值包含」语义一字未改):
- 「只看正常编码」:字母前缀(2~6位) + 可选「-字母段」 + 数字编号(3~8位),
  一次收掉纯数字 / 1643A 型 / 含中文的商品名 / 32 位哈希等非正常形态
- 「排除以…开头」:可累加的前缀黑名单(如 WM- 外贸、DF- 代发、MKH),
  对应「还有不想看就再填一条」

实现要点:
- lib:新增 ProductArchiveTextFilterKey / ProductArchiveExcludeRule 与
  PRODUCT_ARCHIVE_NORMAL_CODE_PATTERN;ProductArchiveFilters 只追加
  normalCodeOnly、excludeStartsWith 两个可选字段,老字段类型与语义不动
- 校验:zod schema 是 .strict(),同步放行两个新字段(否则整包筛选会被 400 拒掉)
- 服务端:buildProductArchiveWhere 保持同步,只追加 NOT startsWith;
  需要查库的「正常编码」放在新增的异步 buildProductArchiveWhereWithTextRules
  (Prisma 7.3 的字符串过滤不支持 regex,改为原生 SQL 取符合形态的取值清单拼 IN,
  带 5 分钟缓存)。preparePeriodArchiveQuery 是全站唯一取数入口,
  列表、候选项、导出、图片导出自动生效
- 文本规则故意不参与 omitFilter:若参与,候选值列表会冒出已被排除的取值,
  用户勾了却得到 0 行
- UI:文本列弹层改左右双排(左=形态与排除规则,右=搜索与候选值),
  弹层高度与横向偏移按触发按钮所在位置动态计算,保证清除/应用在任何视口都可见
- 偏好:两个新字段进入 sanitizeFilters 白名单,规则可持久化,点清除即清空

验证:本地真实库 66033 行——只勾正常编码 27645 行、再接 WM- 26852 行、
再接 DF- 26788 行,与 SQL 基准逐一致;3001 HTTP 对账五个口径全对、
非法列返回 400、候选项接口在勾了正常编码后返回的 SPU 全部符合形态;
真机 UI 12/12(应用 26788、刷新记住、回显、清除回 66033);
布局专项 36/36(1600x900 / 1440x720 / 1366x640 / 1280x600 下弹层均在视口内、
按钮可见、不超左右边界);新增单测 15/15;type-check EXIT=0。
This commit is contained in:
WuYongJie 2026-09-17 11:32:53 +08:00
parent c5e1c50418
commit 57e422308b
7 changed files with 626 additions and 52 deletions

View File

@ -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<string, FilterControl | undefined> = {
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<string> = new Set(PRODUCT_ARCHIVE_TEXT_FILTER_KEYS)
const NORMAL_CODE_FIELD_SET: ReadonlySet<string> = 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<string>()
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<string[]>([])
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [normalCodeOn, setNormalCodeOn] = useState(false)
const [excludeInputs, setExcludeInputs] = useState<string[]>([])
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' ? (
<ProductArchiveFilterOptionList
field={control.filterKey}
placeholder={control.placeholder}
filters={filters}
selectedValues={selectedValues}
onToggle={toggleOption}
onSelectAll={(values) => {
setSelectedValues((current) => [...new Set([...current, ...values])])
}}
/>
) : null
return (
<span className="archive-header-filter" ref={rootRef}>
@ -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 && (
<form
className="archive-header-filter-popover"
className={[
'archive-header-filter-popover',
textFilterKey ? 'is-text' : '',
popoverLayout.above ? 'is-above' : '',
].filter(Boolean).join(' ')}
style={{
'--popover-shift': `${popoverLayout.shift}px`,
'--popover-max-height': `${popoverLayout.maxHeight}px`,
} as CSSProperties}
onSubmit={(event) => {
event.preventDefault()
apply()
}}
>
{control.kind === 'options' && (
<ProductArchiveFilterOptionList
field={control.filterKey}
placeholder={control.placeholder}
filters={filters}
selectedValues={selectedValues}
onToggle={toggleOption}
onSelectAll={(values) => {
setSelectedValues((current) => [...new Set([...current, ...values])])
}}
/>
)}
{textFilterKey ? (
<div className="archive-header-filter-body">
<div className="archive-header-filter-rules">
{supportsNormalCode && (
<label className="archive-header-filter-normalcode">
<input
type="checkbox"
checked={normalCodeOn}
onChange={(event) => setNormalCodeOn(event.target.checked)}
/>
<span>只看正常编码</span>
</label>
)}
{control.kind === 'dateRange' && (
<div className="archive-header-filter-dates">
<input
type="date"
aria-label={`${label}开始日期`}
value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)}
/>
<span>至</span>
<input
type="date"
aria-label={`${label}结束日期`}
value={dateTo}
onChange={(event) => setDateTo(event.target.value)}
/>
<div className="archive-header-filter-rules-label">排除「以…开头」</div>
{excludeInputs.length > 0 && (
<div className="archive-header-filter-rule-list">
{excludeInputs.map((keyword, index) => (
<div className="archive-header-filter-rule" key={`exclude-${index}`}>
<input
value={keyword}
placeholder="如 WM-"
aria-label={`排除以该内容开头的${label},第 ${index + 1} 条`}
onChange={(event) => setExcludeInputs((current) => current.map(
(item, itemIndex) => (itemIndex === index ? event.target.value : item),
))}
/>
<button
type="button"
className="archive-header-filter-rule-remove"
aria-label={`删除第 ${index + 1} 条排除规则`}
title="删除这条"
onClick={() => setExcludeInputs((current) => current.filter((_, itemIndex) => itemIndex !== index))}
>
<X size={12} />
</button>
</div>
))}
</div>
)}
<button
type="button"
className="archive-header-filter-add-rule"
onClick={() => setExcludeInputs((current) => [...current, ''])}
>
+ 再加一条
</button>
</div>
<div className="archive-header-filter-values">{optionList}</div>
</div>
) : (
<>
{optionList}
{control.kind === 'dateRange' && (
<div className="archive-header-filter-dates">
<input
type="date"
aria-label={`${label}开始日期`}
value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)}
/>
<span>至</span>
<input
type="date"
aria-label={`${label}结束日期`}
value={dateTo}
onChange={(event) => setDateTo(event.target.value)}
/>
</div>
)}
</>
)}
<div className="archive-header-filter-actions">
@ -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; }
`}</style>

View File

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

View File

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

View File

@ -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<Record<string, unknown>> }
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<Record<string, unknown>> }
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()
})
})

View File

@ -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<string>()
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<ProductArchiveTextFilterKey, Prisma.SupplyChainProductArchiveScalarFieldEnum>
/**
* 「正常编码」取值清单的缓存。
*
* Prisma 7 的字符串过滤不支持正则(生成类型里没有 regex),所以只能先用原生 SQL
* 把符合形态的取值查出来,再拼成 `in` 条件。这些取值只在档案同步时才会变,
* 因此按列缓存几分钟,避免每次翻页都做一次全表正则扫描。
*/
const NORMAL_CODE_CACHE_TTL_MS = 5 * 60 * 1000
const normalCodeValueCache = new Map<string, { values: string[]; expiresAt: number }>()
async function listNormalCodeValues(databaseField: string): Promise<string[]> {
const cached = normalCodeValueCache.get(databaseField)
if (cached && cached.expiresAt > Date.now()) return cached.values
const column = Prisma.raw(`"${databaseField}"`)
const rows = await prisma.$queryRaw<Array<{ value: string | null }>>(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<Prisma.SupplyChainProductArchiveWhereInput[]> {
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<Prisma.SupplyChainProductArchiveWhereInput> {
const base = buildProductArchiveWhere(filters, omitFilter)
const extra = await resolveNormalCodeClauses(filters)
return extra.length > 0 ? { AND: [base, ...extra] } : base
}

View File

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

View File

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