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:
parent
c5e1c50418
commit
57e422308b
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user