feat(supply-chain): 产品全量档案负责人治理与聚水潭式表头改造

This commit is contained in:
WuYongJie 2026-10-14 10:04:06 +08:00
parent fb1c74ae67
commit 83f0fbee86
44 changed files with 3994 additions and 59 deletions

View File

@ -43,7 +43,7 @@
"next": "^16.1.6",
"obj2gltf": "^3.2.0",
"pg": "^8.17.2",
"pinyin-pro": "^3.28.1",
"pinyin-pro": "^3.29.5",
"postcss": "^8.5.6",
"print-js": "^1.6.0",
"puppeteer": "^24.40.0",
@ -15537,9 +15537,9 @@
}
},
"node_modules/pinyin-pro": {
"version": "3.28.1",
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.1.tgz",
"integrity": "sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==",
"version": "3.29.5",
"resolved": "https://registry.npmjs.org/pinyin-pro/-/pinyin-pro-3.29.5.tgz",
"integrity": "sha512-Fk5YoRmtdHqJWqAuzYVG/NzvzeCbaG+ZEwbY6Nbps3QAIrvQQEtcm49frcyCM8i2b5najnzPDtYqrHRYTTiuwQ==",
"license": "MIT"
},
"node_modules/pirates": {

View File

@ -157,7 +157,7 @@
"next": "^16.1.6",
"obj2gltf": "^3.2.0",
"pg": "^8.17.2",
"pinyin-pro": "^3.28.1",
"pinyin-pro": "^3.29.5",
"postcss": "^8.5.6",
"print-js": "^1.6.0",
"puppeteer": "^24.40.0",

View File

@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import type {
ProductArchiveOwnerImportProgress,
ProductArchiveOwnerImportRow,
ProductArchiveOwnerImportStreamEvent,
} from '@/lib/supply-chain-product-archive-owner-import'
import { ErrorCode, isAppError } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { supplyChainProductArchiveOwnerImportService } from '@/server/services/supply-chain-product-archive-owner-import.service'
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_ROWS = 5_000
function cellText(value: unknown) {
return String(value ?? '').trim()
}
function parseRows(sheet: XLSX.WorkSheet) {
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
defval: '',
raw: false,
})
const headerRow = matrix[0]?.map(cellText) ?? []
const headerIndex = new Map(headerRow.map((header, index) => [header, index]))
const barcodeIndex = headerIndex.get('条码') ?? headerIndex.get('barcode')
if (barcodeIndex === undefined) return { error: '导入文件缺少“条码”列', rows: [] as ProductArchiveOwnerImportRow[] }
const opsHeadIndex = headerIndex.get('运营负责人') ?? headerIndex.get('opsHead')
const designerIndex = headerIndex.get('设计师') ?? headerIndex.get('designer')
const rows = matrix.slice(1).flatMap((values, index) => {
if (!values.some((value) => cellText(value))) return []
return [{
rowNumber: index + 2,
barcode: cellText(values[barcodeIndex]),
opsHead: opsHeadIndex === undefined ? '' : cellText(values[opsHeadIndex]),
designer: designerIndex === undefined ? '' : cellText(values[designerIndex]),
} satisfies ProductArchiveOwnerImportRow]
})
return { rows }
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
}
if (file.size > MAX_FILE_SIZE) {
return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB', 400, ctx.requestId)
}
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer' })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
if (!sheet) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
}
const parsed = parseRows(sheet)
if (parsed.error) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, parsed.error, 400, ctx.requestId)
}
if (parsed.rows.length === 0) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
}
if (parsed.rows.length > MAX_ROWS) {
return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行', 400, ctx.requestId)
}
const encoder = new TextEncoder()
const initial: ProductArchiveOwnerImportProgress = {
processed: 0,
total: parsed.rows.length,
updated: 0,
skipped: 0,
failed: 0,
warnings: [],
errors: [],
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false
let progress = initial
const send = (event: ProductArchiveOwnerImportStreamEvent) => {
if (closed) return
try {
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`))
} catch {
closed = true
}
}
const close = () => {
if (closed) return
closed = true
controller.close()
}
send({ type: 'progress', progress })
void supplyChainProductArchiveOwnerImportService.importRows(parsed.rows, ctx.user, (next) => {
progress = next
send({ type: 'progress', progress })
}).then((result) => {
progress = result
send({ type: 'complete', progress })
close()
}).catch((error: unknown) => {
console.error(`[${ctx.requestId}] Product archive owner import failed:`, error)
send({
type: 'error',
progress,
message: isAppError(error) ? error.message : '导入过程中发生错误',
})
close()
})
},
})
return new NextResponse(stream, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
})
}, {
requireAuth: true,
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
allowedBusinessRoles: ['MANAGEMENT'],
})
}

View File

@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import * as XLSX from 'xlsx'
import { PRODUCT_ARCHIVE_OWNER_IMPORT_COLUMNS } from '@/lib/supply-chain-product-archive-owner-import'
import { handleRequest } from '@/server/core/response'
export async function GET(request: NextRequest) {
return handleRequest(request, async () => {
const headers = [...PRODUCT_ARCHIVE_OWNER_IMPORT_COLUMNS]
const sheet = XLSX.utils.aoa_to_sheet([headers])
sheet['!cols'] = headers.map((header) => ({ wch: Math.max(18, header.length * 2 + 8) }))
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, '负责人导入')
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
return new NextResponse(new Uint8Array(content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent('运营负责人导入模板.xlsx')}`,
'Cache-Control': 'no-store',
},
})
}, {
requireAuth: true,
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
allowedBusinessRoles: ['MANAGEMENT'],
})
}

View File

@ -0,0 +1,122 @@
'use client'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { ChevronDown } from 'lucide-react'
export type ArchiveToolbarMenuItem = {
key: string
label: string
icon?: ReactNode
onClick?: () => void
/** 提供时渲染为链接(如模板下载)。 */
href?: string
disabled?: boolean
/** 右侧灰色说明。 */
hint?: string
}
/**
* 工具栏操作下拉菜单(导入 / 导出 / 更多 共用)。
* 收纳零散按钮,避免操作行随着能力增加无限变宽。
*/
export function ProductArchiveActionMenu({
label,
icon,
items,
title,
}: {
label: string
icon?: ReactNode
items: ArchiveToolbarMenuItem[]
title?: string
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const [open, setOpen] = useState(false)
useEffect(() => {
if (!open) return
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
}, [open])
const runItem = (item: ArchiveToolbarMenuItem) => {
setOpen(false)
item.onClick?.()
}
return (
<span className="archive-action-menu" ref={rootRef}>
<button
type="button"
className="archive-action-menu-trigger"
title={title ?? label}
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((current) => !current)}
>
{icon}
{label}
<ChevronDown size={12} strokeWidth={1.8} className={open ? 'is-open' : ''} />
</button>
{open && (
<div className="archive-action-menu-panel" role="menu">
{items.map((item) =>
item.href && !item.disabled ? (
<a key={item.key} className="archive-action-menu-item" role="menuitem" href={item.href} download onClick={() => setOpen(false)}>
{item.icon}
<span className="archive-action-menu-item-label">{item.label}</span>
{item.hint && <span className="archive-action-menu-item-hint">{item.hint}</span>}
</a>
) : (
<button
key={item.key}
type="button"
className="archive-action-menu-item"
role="menuitem"
disabled={item.disabled}
onClick={() => runItem(item)}
>
{item.icon}
<span className="archive-action-menu-item-label">{item.label}</span>
{item.hint && <span className="archive-action-menu-item-hint">{item.hint}</span>}
</button>
),
)}
</div>
)}
<style jsx>{`
.archive-action-menu { position: relative; display: inline-flex; }
.archive-action-menu-trigger {
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 12px;
background: #fff; border: 1px solid #e4e4e7; border-radius: 6px;
font-size: 13px; color: #3f3f46; cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.archive-action-menu-trigger:hover { background: #f4f4f5; border-color: #a1a1aa; }
.archive-action-menu-trigger :global(.is-open) { transform: rotate(180deg); }
.archive-action-menu-panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
min-width: 168px; padding: 5px;
background: #fff; border: 1px solid #d4d4d8; border-radius: 6px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
text-align: left;
}
.archive-action-menu-item {
display: flex; width: 100%; align-items: center; gap: 7px;
padding: 7px 9px; border: none; border-radius: 5px;
background: transparent; color: #3f3f46; font-size: 13px;
cursor: pointer; text-decoration: none;
}
.archive-action-menu-item:hover { background: #f4f4f5; color: #18181b; }
.archive-action-menu-item:disabled { color: #a1a1aa; cursor: not-allowed; }
.archive-action-menu-item:disabled:hover { background: transparent; }
.archive-action-menu-item-label { flex: 1; }
.archive-action-menu-item-hint { color: #a1a1aa; font-size: 11px; white-space: nowrap; }
`}</style>
</span>
)
}

View File

@ -0,0 +1,190 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronUp, Search, X } from 'lucide-react'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
import {
ARCHIVE_PRECISE_FIELDS,
type ArchiveToolbarField,
} from './productArchiveToolbarFields'
import type { ArchiveSavedQuery } from './useProductArchiveToolbarPreferences'
import { ProductArchiveOptionFieldFilter } from './ProductArchiveOptionFieldFilter'
import { ProductArchiveQuickFieldFilter } from './ProductArchiveQuickFieldFilter'
import { ProductArchiveFilterSettings } from './ProductArchiveFilterSettings'
import { ProductArchiveSavedQueries } from './ProductArchiveSavedQueries'
const KEYWORD_COMMIT_DELAY_MS = 600
/**
* 全量档案顶部筛选行(表头第一行,可收起):
* 模糊搜索框(SPU/SKU/条码/商品名称/供应商)+ 常显点选字段框(点击弹可搜索勾选小弹窗,
* 设置可配)+「请选择+请输入」+ 另存快捷查询;最右端边角是「设置 / 收起」。
*/
export function ProductArchiveFilterBar({
filters,
onChange,
preciseFields,
savedQueries,
onSaveQuery,
onDeleteQuery,
onPreciseFieldsChange,
onCollapse,
}: {
filters: ProductArchiveFilters
onChange: (nextFilters: ProductArchiveFilters) => void
preciseFields: string[]
savedQueries: ArchiveSavedQuery[]
onSaveQuery: (name: string) => void
onDeleteQuery: (id: string) => void
onPreciseFieldsChange: (next: string[]) => void
onCollapse: () => void
}) {
const [keywordDraft, setKeywordDraft] = useState(filters.keyword ?? '')
// 自己刚提交过的 filters 对象:同步输入框时跳过,避免把用户正在输入的文本重写掉。
const lastWrittenRef = useRef<ProductArchiveFilters | null>(null)
// 列头筛选 / 清除 / 快捷查询 / URL 带入等外部变化 → 同步进搜索框。
useEffect(() => {
if (filters === lastWrittenRef.current) return
lastWrittenRef.current = null
setKeywordDraft(filters.keyword ?? '')
}, [filters])
const commitNow = () => {
const keyword = keywordDraft.trim()
if ((filters.keyword ?? '') === keyword) return
const next = { ...filters }
if (keyword) next.keyword = keyword
else delete next.keyword
lastWrittenRef.current = next
onChange(next)
}
// 输入停顿后自动提交,避免每个键击都触发一次列表请求。
useEffect(() => {
const timer = window.setTimeout(commitNow, KEYWORD_COMMIT_DELAY_MS)
return () => window.clearTimeout(timer)
// commitNow 只依赖 keywordDraft/filters/onChange,这里故意不展开。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keywordDraft])
const visiblePreciseFields = preciseFields
.map((key) => ARCHIVE_PRECISE_FIELDS.find((item) => item.key === key))
.filter((item): item is ArchiveToolbarField => Boolean(item))
const activeFilterCount = Object.entries(filters).filter(([key, value]) =>
!['salesGranularity', 'salesPeriod'].includes(key)
&& (Array.isArray(value) ? value.length > 0 : Boolean(value)),
).length
return (
<div className="filterbar">
<div className="filterbar-search">
<Search size={14} strokeWidth={1.8} />
<input
value={keywordDraft}
placeholder="搜索 SPU / SKU / 条码 / 商品名称 / 供应商"
aria-label="模糊搜索"
onChange={(event) => setKeywordDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitNow()
}
if (event.key === 'Escape') {
lastWrittenRef.current = null
setKeywordDraft(filters.keyword ?? '')
}
}}
/>
{keywordDraft && (
<button
type="button"
className="filterbar-clear"
aria-label="清空搜索"
onClick={() => setKeywordDraft('')}
>
<X size={13} />
</button>
)}
</div>
{visiblePreciseFields.map((item) => (
<ProductArchiveOptionFieldFilter
key={item.key}
field={item}
filters={filters}
onChange={onChange}
/>
))}
<ProductArchiveQuickFieldFilter filters={filters} onChange={onChange} />
<ProductArchiveSavedQueries
queries={savedQueries}
currentFilters={filters}
canSave={activeFilterCount > 0}
onApply={onChange}
onSave={onSaveQuery}
onDelete={onDeleteQuery}
/>
<div className="filterbar-tail">
<ProductArchiveFilterSettings
selected={preciseFields}
onChange={onPreciseFieldsChange}
/>
<button
type="button"
className="filterbar-collapse"
title="收起筛选行"
aria-label="收起筛选行"
onClick={onCollapse}
>
收起
<ChevronUp size={14} strokeWidth={1.8} />
</button>
</div>
<style jsx>{`
.filterbar {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
padding: 12px 16px;
background: #fff; border: 1px solid #f4f4f5; border-radius: 8px;
flex-shrink: 0; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.filterbar-search {
display: flex; align-items: center; gap: 6px;
width: 280px; height: 30px; padding: 0 8px;
border: 1px solid #d4d4d8; border-radius: 4px;
background: #fafafa; color: #71717a;
}
.filterbar-search:focus-within { border-color: #52525b; background: #fff; }
.filterbar-search input {
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
background: transparent; color: #27272a; font-size: 12px;
}
.filterbar-clear {
display: inline-flex; padding: 0; border: none; background: transparent;
color: #a1a1aa; cursor: pointer;
}
.filterbar-clear:hover { color: #52525b; }
.filterbar-tail {
display: inline-flex; align-items: center; gap: 2px;
margin-left: auto;
}
.filterbar-collapse {
display: inline-flex; align-items: center; gap: 4px;
height: 28px; padding: 0 8px;
border: none; border-radius: 4px;
background: transparent; color: #71717a;
font-size: 12px; font-weight: 600; cursor: pointer;
}
.filterbar-collapse:hover { background: #f4f4f5; color: #27272a; }
@media (max-width: 900px) {
.filterbar-tail { margin-left: 0; }
}
`}</style>
</div>
)
}

View File

@ -132,11 +132,17 @@ export function ProductArchiveFilterOptionList({
)}
</div>
<div className="archive-header-filter-selected-summary">
<span>
<span className="archive-header-filter-summary-text" title={
selectedValues.length > 0
? `已选 ${selectedValues.length} 项`
: optionsTotal !== null
? `共 ${optionsTotal} 项${hasMoreOptions ? `,已加载 ${visibleOptions.length} 项,滚动加载更多` : ''}`
: `共 ${visibleOptions.length} 项`
}>
{selectedValues.length > 0
? `已选 ${selectedValues.length} 项`
: optionsTotal !== null
? `共 ${optionsTotal} 项${hasMoreOptions ? `(已加载 ${visibleOptions.length},滚动加载更多)` : ''}`
? `共 ${optionsTotal} 项${hasMoreOptions ? ` · 已加载 ${visibleOptions.length}` : ''}`
: `共 ${visibleOptions.length} 项`}
</span>
{visibleOptions.length > 0 && hasMoreOptions && optionsTotal !== null && (
@ -146,7 +152,7 @@ export function ProductArchiveFilterOptionList({
disabled={loadingAllOptions}
onClick={() => void selectAllMatching()}
>
{loadingAllOptions ? '正在加载全部候选…' : `全选 ${optionsTotal} 项`}
{loadingAllOptions ? '加载全部…' : `全选 ${optionsTotal} 项`}
</button>
)}
{visibleOptions.length > 0 && !(hasMoreOptions && optionsTotal !== null) && (
@ -203,12 +209,15 @@ export function ProductArchiveFilterOptionList({
border: none; background: transparent; color: #a1a1aa; cursor: pointer;
}
.archive-header-filter-selected-summary {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 8px 3px 5px; color: #52525b; font-size: 12px; font-weight: 600;
}
.archive-header-filter-summary-text {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.archive-header-filter-select-all {
padding: 0; border: none; background: transparent; color: #2563eb;
font-size: 12px; font-weight: 600; cursor: pointer;
flex: 0 0 auto; padding: 0; border: none; background: transparent; color: #2563eb;
font-size: 12px; font-weight: 600; cursor: pointer; white-space: nowrap;
}
.archive-header-filter-select-all:hover { text-decoration: underline; }
.archive-header-filter-options {

View File

@ -0,0 +1,279 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, GripVertical, Settings2, X } from 'lucide-react'
import {
ARCHIVE_PRECISE_FIELDS,
DEFAULT_ARCHIVE_PRECISE_FIELDS,
} from './productArchiveToolbarFields'
/**
* 筛选区「设置」面板:配置顶部常显哪些字段框、以及显示顺序。
* 已勾选行可拖拽排序(交互与列设置一致:手柄+勾选框+字段名),顺序即时反映到顶部;
* 恢复默认 = 默认十项与默认顺序。
*/
export function ProductArchiveFilterSettings({
selected,
onChange,
}: {
selected: string[]
onChange: (next: string[]) => void
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const dragKeyRef = useRef<string | null>(null)
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
useEffect(() => {
if (!open) return
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
}, [open])
// 清单顺序与顶部同步:已勾选字段按用户排的顺序在前(=顶部显示顺序),未勾选的按池顺序跟在后面。
const visibleFields = useMemo(() => {
const selectedSet = new Set(selected)
const ordered = [
...selected
.map((key) => ARCHIVE_PRECISE_FIELDS.find((item) => item.key === key))
.filter((item): item is (typeof ARCHIVE_PRECISE_FIELDS)[number] => Boolean(item)),
...ARCHIVE_PRECISE_FIELDS.filter((item) => !selectedSet.has(item.key)),
]
const keyword = searchText.trim().toLowerCase()
if (!keyword) return ordered
return ordered.filter(
(item) => item.label.toLowerCase().includes(keyword) || item.key.toLowerCase().includes(keyword),
)
}, [searchText, selected])
const toggle = (key: string) => {
if (selected.includes(key)) onChange(selected.filter((item) => item !== key))
else onChange([...selected, key])
}
/** 拖拽重排:把 fromKey 移到 toKey 的位置。只对已勾选(会显示在顶部)的字段生效。 */
const dropOn = (toKey: string) => {
const fromKey = dragKeyRef.current
dragKeyRef.current = null
if (!fromKey || fromKey === toKey) return
const fromIndex = selected.indexOf(fromKey)
const toIndex = selected.indexOf(toKey)
if (fromIndex < 0 || toIndex < 0) return
const next = [...selected]
next.splice(fromIndex, 1)
next.splice(toIndex, 0, fromKey)
onChange(next)
}
/** 上移/下移兜底:拖拽不可用的环境也能调整顺序。 */
const moveBy = (key: string, offset: -1 | 1) => {
const index = selected.indexOf(key)
const target = index + offset
if (index < 0 || target < 0 || target >= selected.length) return
const next = [...selected]
next.splice(target, 0, next.splice(index, 1)[0])
onChange(next)
}
return (
<span className="filter-settings" ref={rootRef}>
<button
type="button"
className="filter-settings-trigger"
title="设置顶部常显的字段框与顺序"
aria-expanded={open}
onClick={() => {
setSearchText('')
setOpen((current) => !current)
}}
>
<Settings2 size={14} strokeWidth={1.8} />
设置
</button>
{open && (
<div className="filter-settings-panel" role="dialog" aria-label="设置常显字段框与顺序">
<div className="filter-settings-hint">勾选常显在筛选行的字段框;拖动已勾选行调整显示顺序</div>
<div className="filter-settings-search">
<input
autoFocus
value={searchText}
placeholder="搜索字段名"
onChange={(event) => setSearchText(event.target.value)}
/>
{searchText && (
<button type="button" aria-label="清空字段搜索" onClick={() => setSearchText('')}>
<X size={13} />
</button>
)}
</div>
<div className="filter-settings-list">
{visibleFields.map((item) => {
const isSelected = selected.includes(item.key)
return (
<div
key={item.key}
className={`filter-settings-row ${isSelected ? 'is-selected' : ''}`}
draggable={isSelected}
onDragStart={(event) => {
dragKeyRef.current = item.key
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', item.key)
}}
onDragEnd={() => {
dragKeyRef.current = null
}}
onDragOver={(event) => {
if (isSelected) event.preventDefault()
}}
onDrop={(event) => {
event.preventDefault()
dropOn(item.key)
}}
>
<span className={`filter-settings-drag ${isSelected ? '' : 'is-disabled'}`}>
<GripVertical size={13} />
</span>
<label>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggle(item.key)}
/>
<span title={item.label}>{item.label}</span>
</label>
{isSelected && (
<span className="filter-settings-move">
<button
type="button"
aria-label={`上移${item.label}`}
title="上移"
disabled={selected.indexOf(item.key) === 0}
onClick={() => moveBy(item.key, -1)}
>
<ArrowUp size={12} />
</button>
<button
type="button"
aria-label={`下移${item.label}`}
title="下移"
disabled={selected.indexOf(item.key) === selected.length - 1}
onClick={() => moveBy(item.key, 1)}
>
<ArrowDown size={12} />
</button>
</span>
)}
</div>
)
})}
{visibleFields.length === 0 && (
<div className="filter-settings-empty">没有匹配的字段</div>
)}
</div>
<div className="filter-settings-footer">
<span className="filter-settings-count">已选 {selected.length} 项</span>
<div className="filter-settings-footer-buttons">
<button type="button" onClick={() => onChange([...DEFAULT_ARCHIVE_PRECISE_FIELDS])}>
恢复默认
</button>
<button type="button" className="primary" onClick={() => setOpen(false)}>
完成
</button>
</div>
</div>
</div>
)}
<style jsx>{`
.filter-settings { position: relative; display: inline-flex; }
.filter-settings-trigger {
display: inline-flex; align-items: center; gap: 4px;
height: 28px; padding: 0 8px;
border: none; border-radius: 4px;
background: transparent; color: #71717a;
font-size: 12px; font-weight: 600; cursor: pointer;
}
.filter-settings-trigger:hover { background: #f4f4f5; color: #27272a; }
.filter-settings-panel {
position: absolute; top: calc(100% + 7px); right: 0; z-index: 40;
display: flex; flex-direction: column;
width: 280px; max-height: 440px; padding: 10px;
background: #fff; border: 1px solid #d4d4d8; border-radius: 6px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}
.filter-settings-hint { color: #71717a; font-size: 12px; padding: 0 1px 8px; }
.filter-settings-search {
display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 8px;
border: 1px solid #d4d4d8; border-radius: 4px; color: #a1a1aa;
}
.filter-settings-search:focus-within { border-color: #52525b; }
.filter-settings-search input {
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
color: #27272a; font-size: 13px;
}
.filter-settings-search button {
display: inline-flex; padding: 0; border: none; background: transparent;
color: #a1a1aa; cursor: pointer;
}
.filter-settings-list {
display: flex; flex-direction: column; gap: 2px;
margin-top: 8px; overflow-y: auto; min-height: 100px;
}
.filter-settings-row {
display: flex; align-items: center; gap: 4px;
min-height: 30px; padding: 0 2px 0 0; border-radius: 4px;
}
.filter-settings-row.is-selected { cursor: grab; }
.filter-settings-row.is-selected:active { cursor: grabbing; }
.filter-settings-row:hover, .filter-settings-row.is-selected { background: #f4f4f5; }
.filter-settings-drag {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; flex: 0 0 auto; color: #a1a1aa;
}
.filter-settings-drag.is-disabled { opacity: 0.25; }
.filter-settings-row label {
display: flex; flex: 1; align-items: center; gap: 7px;
min-height: 30px; padding: 2px 5px 2px 0;
color: #3f3f46; font-size: 13px; cursor: pointer;
}
.filter-settings-row.is-selected label { color: #18181b; font-weight: 550; }
.filter-settings-row label span {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
user-select: none;
}
.filter-settings-move { display: inline-flex; gap: 1px; flex: 0 0 auto; }
.filter-settings-move button {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; padding: 0;
border: none; border-radius: 3px; background: transparent;
color: #a1a1aa; cursor: pointer;
}
.filter-settings-move button:hover:not(:disabled) { background: #e4e4e7; color: #27272a; }
.filter-settings-move button:disabled { opacity: 0.3; cursor: default; }
.filter-settings-row input[type='checkbox'] {
width: 14px; height: 14px; margin: 0; flex: 0 0 auto; accent-color: #27272a;
}
.filter-settings-empty { padding: 20px 0; color: #a1a1aa; font-size: 12px; text-align: center; }
.filter-settings-footer {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin-top: 10px; padding-top: 10px; border-top: 1px solid #e4e4e7;
}
.filter-settings-count { color: #71717a; font-size: 12px; }
.filter-settings-footer-buttons { display: inline-flex; gap: 7px; }
.filter-settings-footer-buttons button {
height: 28px; padding: 0 12px; border: 1px solid #d4d4d8; border-radius: 4px;
background: #fff; color: #52525b; font-size: 12px; cursor: pointer;
}
.filter-settings-footer-buttons button:hover { background: #f4f4f5; }
.filter-settings-footer-buttons button.primary {
border-color: #27272a; background: #27272a; color: #fff;
}
.filter-settings-footer-buttons button.primary:hover { background: #3f3f46; }
`}</style>
</span>
)
}

View File

@ -0,0 +1,251 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Search, X } from 'lucide-react'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
import {
ARCHIVE_QUICK_FIELD_OPTIONS,
splitArchiveFilterValues,
} from './productArchiveToolbarFields'
/**
* 「多项筛选」居中大弹窗:字段池全部字段平铺(字段名 + 输入框),
* 填了值的字段参与筛选(字段间「且」,同字段逗号多值「或」),
* 顶部可搜字段名、清空全部,底部取消/确定。
*/
export function ProductArchiveMultiFieldDialog({
filters,
onConfirm,
onCancel,
}: {
filters: ProductArchiveFilters
onConfirm: (nextFilters: ProductArchiveFilters) => void
onCancel: () => void
}) {
const [searchText, setSearchText] = useState('')
const [texts, setTexts] = useState<Record<string, string>>(() => {
const record = filters as Record<string, unknown>
const initial: Record<string, string> = {}
for (const item of ARCHIVE_QUICK_FIELD_OPTIONS) {
const values = record[item.key]
if (Array.isArray(values) && values.length > 0) {
initial[item.key] = values.map((value) => String(value)).join(',')
}
}
return initial
})
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onCancel])
const visibleFields = useMemo(() => {
const keyword = searchText.trim().toLowerCase()
if (!keyword) return ARCHIVE_QUICK_FIELD_OPTIONS
return ARCHIVE_QUICK_FIELD_OPTIONS.filter(
(item) => item.label.toLowerCase().includes(keyword) || item.key.toLowerCase().includes(keyword),
)
}, [searchText])
const filledCount = useMemo(
() => ARCHIVE_QUICK_FIELD_OPTIONS.filter((item) => splitArchiveFilterValues(texts[item.key] ?? '').length > 0).length,
[texts],
)
const setText = (key: string, text: string) => {
setTexts((current) => ({ ...current, [key]: text }))
}
const clearAll = () => setTexts({})
const confirm = () => {
const next = { ...filters } as ProductArchiveFilters
const record = next as Record<string, unknown>
for (const item of ARCHIVE_QUICK_FIELD_OPTIONS) {
const values = splitArchiveFilterValues(texts[item.key] ?? '')
if (values.length > 0) record[item.key] = values
else delete record[item.key]
}
onConfirm(next)
}
return (
<div
className="multi-dialog-overlay"
role="dialog"
aria-modal="true"
aria-label="多项筛选"
onPointerDown={(event) => {
if (event.target === event.currentTarget) onCancel()
}}
>
<div className="multi-dialog">
<div className="multi-dialog-head">
<span className="multi-dialog-title">多项筛选</span>
<button type="button" className="multi-dialog-close" aria-label="关闭" onClick={onCancel}>
<X size={15} />
</button>
</div>
<div className="multi-dialog-toolbar">
<div className="multi-dialog-search">
<Search size={14} strokeWidth={1.8} />
<input
autoFocus
value={searchText}
placeholder="搜索字段名,如:供应商 / 仓库 / 标签"
onChange={(event) => setSearchText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
{searchText && (
<button type="button" aria-label="清空字段搜索" onClick={() => setSearchText('')}>
<X size={13} />
</button>
)}
</div>
<button
type="button"
className="multi-dialog-clear"
onClick={clearAll}
disabled={filledCount === 0}
>
清空全部
</button>
</div>
<div className="multi-dialog-body">
<div className="multi-dialog-grid">
{visibleFields.map((item) => {
const text = texts[item.key] ?? ''
return (
<label key={item.key} className={`multi-dialog-field ${text ? 'has-value' : ''}`}>
<span className="multi-dialog-field-label" title={item.label}>{item.label}</span>
<input
value={text}
placeholder="多个值用逗号分隔"
aria-label={`${item.label}筛选值`}
onChange={(event) => setText(item.key, event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirm()
}
}}
/>
</label>
)
})}
</div>
{visibleFields.length === 0 && (
<div className="multi-dialog-state">没有匹配的字段</div>
)}
</div>
<div className="multi-dialog-footer">
<span className="multi-dialog-count">已填 {filledCount} 项 · 未填写的字段不参与筛选</span>
<div className="multi-dialog-footer-buttons">
<button type="button" className="cancel" onClick={onCancel}>取消</button>
<button type="button" className="confirm" onClick={confirm}>
确定{filledCount > 0 ? `(${filledCount})` : ''}
</button>
</div>
</div>
</div>
<style jsx global>{`
.multi-dialog-overlay {
position: fixed; inset: 0; z-index: 1000;
display: flex; align-items: center; justify-content: center;
background: rgba(24, 24, 27, 0.45);
padding: 24px;
}
.multi-dialog {
display: flex; flex-direction: column;
width: 880px; max-width: 100%; height: 560px; max-height: 100%;
background: #fff; border-radius: 8px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.multi-dialog-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid #f4f4f5; flex: 0 0 auto;
}
.multi-dialog-title { font-size: 14px; font-weight: 700; color: #18181b; }
.multi-dialog-close {
display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; padding: 0; border: none; border-radius: 5px;
background: transparent; color: #a1a1aa; cursor: pointer;
}
.multi-dialog-close:hover { background: #f4f4f5; color: #52525b; }
.multi-dialog-toolbar {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px 0; flex: 0 0 auto;
}
.multi-dialog-search {
display: flex; align-items: center; gap: 6px; flex: 1; max-width: 360px;
height: 32px; padding: 0 9px;
border: 1px solid #d4d4d8; border-radius: 5px; color: #a1a1aa;
}
.multi-dialog-search:focus-within { border-color: #52525b; }
.multi-dialog-search input {
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
color: #27272a; font-size: 13px;
}
.multi-dialog-search button {
display: inline-flex; padding: 0; border: none; background: transparent;
color: #a1a1aa; cursor: pointer;
}
.multi-dialog-clear {
margin-left: auto; height: 30px; padding: 0 10px;
border: 1px solid #d4d4d8; border-radius: 5px;
background: #fff; color: #3f3f46; font-size: 12px; cursor: pointer;
}
.multi-dialog-clear:hover:not(:disabled) { background: #f4f4f5; }
.multi-dialog-clear:disabled { color: #a1a1aa; cursor: not-allowed; }
.multi-dialog-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px 16px; }
.multi-dialog-grid {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 18px;
}
.multi-dialog-field { display: flex; align-items: center; gap: 8px; min-width: 0; }
.multi-dialog-field-label {
flex: 0 0 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 12px; font-weight: 600; color: #3f3f46; text-align: right;
}
.multi-dialog-field input {
min-width: 0; flex: 1; height: 30px; padding: 0 8px;
border: 1px solid #d4d4d8; border-radius: 4px;
background: #fff; color: #27272a; font-size: 12px; outline: none;
}
.multi-dialog-field input:focus { border-color: #52525b; }
.multi-dialog-field.has-value input { border-color: #2563eb; }
.multi-dialog-state {
display: flex; align-items: center; justify-content: center;
padding: 60px 20px; color: #a1a1aa; font-size: 13px;
}
.multi-dialog-footer {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 12px 16px; border-top: 1px solid #f4f4f5; flex: 0 0 auto;
}
.multi-dialog-count { color: #71717a; font-size: 12px; }
.multi-dialog-footer-buttons { display: inline-flex; gap: 8px; }
.multi-dialog-footer-buttons button {
height: 32px; padding: 0 18px; border-radius: 5px; font-size: 13px; cursor: pointer;
}
.multi-dialog-footer-buttons .cancel { border: 1px solid #d4d4d8; background: #fff; color: #52525b; }
.multi-dialog-footer-buttons .cancel:hover { background: #f4f4f5; }
.multi-dialog-footer-buttons .confirm { border: 1px solid #27272a; background: #27272a; color: #fff; }
.multi-dialog-footer-buttons .confirm:hover { background: #3f3f46; }
@media (max-width: 960px) {
.multi-dialog { width: 100%; height: 90vh; }
.multi-dialog-grid { grid-template-columns: 1fr; }
}
`}</style>
</div>
)
}

View File

@ -0,0 +1,85 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
import type { ArchiveToolbarField } from './productArchiveToolbarFields'
import { ProductArchiveSelectionDialog } from './ProductArchiveSelectionDialog'
/**
* 顶部点选字段框:框内显示已选值或占位字段名,点击弹出居中的
* 「选择面板」大弹窗(搜索 + 全选/反选/清空 + 拼音分组 + A-Z 索引 + 取消/确定)。
*/
export function ProductArchiveOptionFieldFilter({
field,
filters,
onChange,
}: {
field: ArchiveToolbarField
filters: ProductArchiveFilters
onChange: (nextFilters: ProductArchiveFilters) => void
}) {
const [open, setOpen] = useState(false)
const record = filters as Record<string, unknown>
const activeValues = Array.isArray(record[field.key])
? (record[field.key] as unknown[]).map(String)
: []
const writeValues = (values: string[]) => {
const next = { ...filters } as ProductArchiveFilters
const target = next as Record<string, unknown>
if (values.length > 0) target[field.key] = values
else delete target[field.key]
onChange(next)
}
return (
<span className="option-field">
<button
type="button"
className={`option-field-trigger ${activeValues.length > 0 ? 'has-value' : ''}`}
title={`${field.label}:点击勾选筛选`}
aria-expanded={open}
aria-label={`${field.label}筛选`}
onClick={() => setOpen(true)}
>
<span className="option-field-value">
{activeValues.length > 0
? activeValues.join(',')
: <span className="option-field-placeholder">{field.label}</span>}
</span>
<ChevronDown size={12} strokeWidth={1.8} />
</button>
{open && (
<ProductArchiveSelectionDialog
field={field}
filters={filters}
onConfirm={(values) => {
writeValues(values)
setOpen(false)
}}
onCancel={() => setOpen(false)}
/>
)}
<style jsx>{`
.option-field { position: relative; display: inline-flex; }
.option-field-trigger {
display: inline-flex; align-items: center; justify-content: space-between; gap: 4px;
height: 30px; width: 140px; padding: 0 8px;
border: 1px solid #d4d4d8; border-radius: 4px;
background: #fff; color: #27272a; font-size: 12px; cursor: pointer; outline: none;
}
.option-field-trigger:hover { border-color: #a1a1aa; }
.option-field-trigger.has-value { border-color: #2563eb; color: #1d4ed8; }
.option-field-value {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
display: inline-flex; align-items: center;
}
.option-field-placeholder { color: #a1a1aa; }
`}</style>
</span>
)
}

View File

@ -0,0 +1,81 @@
'use client'
import { AlertTriangle, CheckCircle2, LoaderCircle, X } from 'lucide-react'
import type { ProductArchiveOwnerImportState } from './useProductArchiveOwnerImport'
export function ProductArchiveOwnerImportProgress({
state,
onDismiss,
}: {
state: ProductArchiveOwnerImportState | null
onDismiss: () => void
}) {
if (!state) return null
const running = state.status === 'preparing' || state.status === 'running'
const percent = state.total > 0 ? Math.round((state.processed / state.total) * 100) : 0
const Icon = running ? LoaderCircle : state.status === 'complete' ? CheckCircle2 : AlertTriangle
return (
<aside className="owner-import-progress" aria-live="polite">
<header>
<Icon size={17} className={running ? 'is-spinning' : ''} />
<div>
<strong>{running ? '正在导入负责人' : state.status === 'complete' ? '导入完成' : '导入失败'}</strong>
<span title={state.fileName}>{state.fileName}</span>
</div>
{!running ? <button type="button" aria-label="关闭导入结果" onClick={onDismiss}><X size={15} /></button> : null}
</header>
<div className="owner-import-track"><span style={{ width: `${percent}%` }} /></div>
<div className="owner-import-stats">
<span>{state.processed}/{state.total || '-'}</span>
<span>成功 {state.updated}</span>
<span>跳过 {state.skipped}</span>
<span className={state.failed > 0 ? 'has-error' : ''}>失败 {state.failed}</span>
</div>
{state.message ? <p className="owner-import-message">{state.message}</p> : null}
{state.warnings.length > 0 ? (
<div className="owner-import-warnings">
{state.warnings.map((warning) => (
<div key={`w-${warning.rowNumber}-${warning.barcode}-${warning.message}`}>
<b>第 {warning.rowNumber} 行</b>
<p>{warning.message}</p>
</div>
))}
</div>
) : null}
{state.errors.length > 0 ? (
<div className="owner-import-errors">
{state.errors.map((error) => (
<div key={`${error.rowNumber}-${error.barcode}-${error.message}`}>
<b>第 {error.rowNumber} 行</b>
<span>{error.barcode || '未填写条码'}</span>
<p>{error.message}</p>
</div>
))}
</div>
) : null}
<style jsx>{`
.owner-import-progress { position: fixed; right: 22px; bottom: 22px; z-index: 120; width: min(440px,calc(100vw - 32px)); max-height: min(520px,calc(100vh - 44px)); overflow: auto; border: 1px solid #d4d4d8; border-radius: 8px; padding: 15px; background: #fff; box-shadow: 0 18px 50px rgba(24,24,27,.18); color: #27272a; }
header { display: flex; align-items: flex-start; gap: 9px; }
header > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
header strong { font-size: 13px; }
header span { overflow: hidden; color: #71717a; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
header button { display: grid; width: 26px; height: 26px; place-items: center; border: 0; background: transparent; color: #71717a; cursor: pointer; }
:global(.is-spinning) { animation: owner-import-spin .8s linear infinite; }
.owner-import-track { height: 5px; margin-top: 13px; overflow: hidden; border-radius: 5px; background: #e4e4e7; }
.owner-import-track span { display: block; height: 100%; background: #3f3f46; transition: width .15s ease; }
.owner-import-stats { display: flex; gap: 14px; margin-top: 9px; color: #52525b; font-size: 11px; }
.owner-import-stats .has-error, .owner-import-message { color: #b91c1c; }
.owner-import-message { margin: 10px 0 0; font-size: 12px; }
.owner-import-warnings, .owner-import-errors { display: flex; max-height: 200px; flex-direction: column; gap: 7px; margin-top: 11px; overflow-y: auto; }
.owner-import-warnings > div { display: grid; grid-template-columns: 72px 1fr; gap: 7px; font-size: 11px; color: #92400e; }
.owner-import-errors > div { display: grid; grid-template-columns: 72px minmax(80px,130px) 1fr; gap: 7px; border-top: 1px solid #f0f0f1; padding-top: 7px; font-size: 11px; }
.owner-import-errors b { color: #52525b; }
.owner-import-warnings p { margin: 0; }
.owner-import-errors span { overflow: hidden; color: #71717a; text-overflow: ellipsis; white-space: nowrap; }
.owner-import-errors p { margin: 0; color: #b91c1c; }
@keyframes owner-import-spin { to { transform: rotate(360deg); } }
`}</style>
</aside>
)
}

View File

@ -0,0 +1,51 @@
'use client'
import { UserCheck } from 'lucide-react'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
type Props = {
filters: ProductArchiveFilters
onChange: (filters: ProductArchiveFilters) => void
}
/** 工具栏上的「负责人在职状态」快捷筛选:一键把已离职/未匹配的档案区分出来。 */
export function ProductArchiveOwnerStatusFilter({ filters, onChange }: Props) {
const value = filters.ownerEmploymentStatus?.[0] ?? ''
const setStatus = (next: string) => {
const updated: ProductArchiveFilters = { ...filters }
if (next) updated.ownerEmploymentStatus = [next]
else delete updated.ownerEmploymentStatus
onChange(updated)
}
return (
<label className="owner-status-filter" aria-label="负责人在职状态筛选">
<span className="owner-status-label"><UserCheck size={14} strokeWidth={1.8} />负责人在职状态</span>
<select
aria-label="负责人在职状态"
value={value}
onChange={(event) => setStatus(event.target.value)}
>
<option value="">全部</option>
<option value="在职">在职</option>
<option value="已离职">已离职</option>
<option value="未匹配">未匹配</option>
<option value="未设置">未设置</option>
</select>
<style jsx>{`
.owner-status-filter { display: inline-flex; align-items: center; gap: 6px; }
.owner-status-label {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; font-weight: 600; color: #3f3f46; white-space: nowrap;
}
.owner-status-filter select {
height: 30px; width: 96px; padding: 0 7px;
border: 1px solid #d4d4d8; border-radius: 4px;
background: #fff; color: #27272a; font-size: 12px; cursor: pointer;
}
.owner-status-filter select:focus { outline: none; border-color: #52525b; }
`}</style>
</label>
)
}

View File

@ -0,0 +1,293 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
import {
ARCHIVE_QUICK_FIELD_OPTIONS,
findArchiveQuickField,
splitArchiveFilterValues,
} from './productArchiveToolbarFields'
import { ProductArchiveMultiFieldDialog } from './ProductArchiveMultiFieldDialog'
const COMMIT_DELAY_MS = 600
function valuesEqual(filters: ProductArchiveFilters, key: string, text: string): boolean {
const record = filters as Record<string, unknown>
const values = Array.isArray(record[key]) ? (record[key] as unknown[]).map(String) : []
const next = splitArchiveFilterValues(text)
return values.length === next.length && values.every((value, index) => value === next[index])
}
/**
* 「请选择 + 请输入」:自制下拉面板——上部字段列表(可搜索、可滚动),
* 底部固定「多项筛选」拨动开关(不随列表滚动);选字段解锁右侧输入框,
* 输入即筛(停顿自动/回车立即);开关打开居中大弹窗多条件叠加。
*/
export function ProductArchiveQuickFieldFilter({
filters,
onChange,
}: {
filters: ProductArchiveFilters
onChange: (nextFilters: ProductArchiveFilters) => void
}) {
const [selectedKey, setSelectedKey] = useState('')
const [text, setText] = useState('')
const [open, setOpen] = useState(false)
const [fieldSearch, setFieldSearch] = useState('')
const [multiOpen, setMultiOpen] = useState(false)
const rootRef = useRef<HTMLSpanElement>(null)
// 自己刚提交过的 filters 对象:同步输入框时跳过,避免把用户正在输入的文本重写掉。
const lastWrittenRef = useRef<ProductArchiveFilters | null>(null)
useEffect(() => {
if (!open) return
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
}, [open])
// 外部变化(列头/点选框/清除/快捷查询)→ 同步输入框(只跟随当前选中字段)。
useEffect(() => {
if (filters === lastWrittenRef.current) return
lastWrittenRef.current = null
if (!selectedKey) return
const record = filters as Record<string, unknown>
const values = Array.isArray(record[selectedKey]) ? (record[selectedKey] as unknown[]) : []
setText(values.map(String).join(','))
// selectedKey 变化时也要重取输入框,故入依赖。
}, [filters, selectedKey])
const commit = () => {
if (!selectedKey || valuesEqual(filters, selectedKey, text)) return
const next = { ...filters } as ProductArchiveFilters
const record = next as Record<string, unknown>
const values = splitArchiveFilterValues(text)
if (values.length > 0) record[selectedKey] = values
else delete record[selectedKey]
lastWrittenRef.current = next
onChange(next)
}
// 输入停顿后自动提交,避免每个键击都触发一次列表请求。
useEffect(() => {
if (!selectedKey) return
const timer = window.setTimeout(commit, COMMIT_DELAY_MS)
return () => window.clearTimeout(timer)
// commit 只依赖 text/filters/onChange,这里故意不展开。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text])
const chooseField = (key: string) => {
setSelectedKey(key)
const record = filters as Record<string, unknown>
const values = Array.isArray(record[key]) ? (record[key] as unknown[]) : []
setText(values.map(String).join(','))
setOpen(false)
}
const visibleFields = useMemo(() => {
const keyword = fieldSearch.trim().toLowerCase()
if (!keyword) return ARCHIVE_QUICK_FIELD_OPTIONS
return ARCHIVE_QUICK_FIELD_OPTIONS.filter(
(item) => item.label.toLowerCase().includes(keyword) || item.key.toLowerCase().includes(keyword),
)
}, [fieldSearch])
const activeQuickCount = ARCHIVE_QUICK_FIELD_OPTIONS.filter((item) => {
const values = (filters as Record<string, unknown>)[item.key]
return Array.isArray(values) && values.length > 0
}).length
return (
<span className="quick-field-filter" ref={rootRef}>
<button
type="button"
className={`quick-field-select ${open ? 'is-open' : ''} ${activeQuickCount > 0 && !selectedKey ? 'has-multi-active' : ''}`}
aria-expanded={open}
aria-haspopup="listbox"
aria-label="选择筛选字段"
onClick={() => {
setFieldSearch('')
setOpen((current) => !current)
}}
>
<span className="quick-field-select-value">
{selectedKey ? findArchiveQuickField(selectedKey)?.label ?? '' : '请选择'}
</span>
<ChevronDown size={12} strokeWidth={1.8} className={open ? 'is-open' : ''} />
</button>
<input
aria-label="输入筛选值"
className="quick-field-input"
value={text}
disabled={!selectedKey}
placeholder={selectedKey ? `${findArchiveQuickField(selectedKey)?.label ?? ''},多个用逗号分隔` : '请输入'}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commit()
}
if (event.key === 'Escape') {
const record = filters as Record<string, unknown>
const values = selectedKey && Array.isArray(record[selectedKey]) ? (record[selectedKey] as unknown[]) : []
setText(values.map(String).join(','))
}
}}
/>
{open && (
<div className="quick-field-panel" role="listbox" aria-label="选择筛选字段">
<div className="quick-field-panel-search">
<Search size={13} strokeWidth={1.8} />
<input
autoFocus
value={fieldSearch}
placeholder="搜索字段名"
onChange={(event) => setFieldSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && visibleFields.length > 0) {
event.preventDefault()
chooseField(visibleFields[0].key)
}
}}
/>
{fieldSearch && (
<button type="button" aria-label="清空字段搜索" onClick={() => setFieldSearch('')}>
<X size={12} />
</button>
)}
</div>
<div className="quick-field-panel-list">
{visibleFields.map((item) => (
<button
key={item.key}
type="button"
role="option"
aria-selected={item.key === selectedKey}
className={item.key === selectedKey ? 'is-selected' : ''}
onClick={() => chooseField(item.key)}
>
{item.label}
</button>
))}
{visibleFields.length === 0 && <div className="quick-field-panel-empty">没有匹配的字段</div>}
</div>
{/* 底部固定:多项筛选入口,不随字段列表滚动 */}
<div className="quick-field-panel-footer">
<button
type="button"
className={`quick-field-multi ${activeQuickCount > 0 ? 'has-active' : ''}`}
onClick={() => {
setOpen(false)
setMultiOpen(true)
}}
>
<span className="quick-field-multi-label">
多项筛选{activeQuickCount > 0 ? `(${activeQuickCount})` : ''}
</span>
<span className="quick-field-multi-knob" />
</button>
</div>
</div>
)}
{multiOpen && (
<ProductArchiveMultiFieldDialog
filters={filters}
onConfirm={(nextFilters) => {
onChange(nextFilters)
setMultiOpen(false)
}}
onCancel={() => setMultiOpen(false)}
/>
)}
<style jsx>{`
.quick-field-filter { position: relative; display: inline-flex; align-items: center; }
.quick-field-select {
display: inline-flex; align-items: center; justify-content: space-between; gap: 4px;
height: 30px; width: 104px; padding: 0 7px;
border: 1px solid #d4d4d8; border-radius: 4px 0 0 4px; border-right: none;
background: #fff; color: #27272a; font-size: 12px; cursor: pointer; outline: none;
}
.quick-field-select:focus { border-color: #52525b; }
.quick-field-select.has-multi-active { color: #2563eb; }
.quick-field-select :global(.is-open) { transform: rotate(180deg); }
.quick-field-select-value {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.quick-field-input {
height: 30px; width: 170px; padding: 0 8px;
border: 1px solid #d4d4d8; border-radius: 0 4px 4px 0;
background: #fff; color: #27272a; font-size: 12px; outline: none;
}
.quick-field-input:focus { border-color: #52525b; }
.quick-field-input:disabled { background: #fafafa; color: #a1a1aa; cursor: not-allowed; }
.quick-field-panel {
position: absolute; top: calc(100% + 7px); left: 0; z-index: 40;
display: flex; flex-direction: column;
width: 220px; padding: 8px;
background: #fff; border: 1px solid #d4d4d8; border-radius: 6px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}
.quick-field-panel-search {
display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 7px;
border: 1px solid #d4d4d8; border-radius: 4px; color: #a1a1aa;
flex: 0 0 auto;
}
.quick-field-panel-search:focus-within { border-color: #52525b; }
.quick-field-panel-search input {
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
color: #27272a; font-size: 12px;
}
.quick-field-panel-search button {
display: inline-flex; padding: 0; border: none; background: transparent;
color: #a1a1aa; cursor: pointer;
}
.quick-field-panel-list {
display: flex; flex-direction: column; gap: 1px;
margin-top: 6px; max-height: 264px; overflow-y: auto; min-height: 120px;
}
.quick-field-panel-list button {
display: flex; align-items: center; min-height: 28px; padding: 2px 7px;
border: none; border-radius: 4px; background: transparent;
color: #3f3f46; font-size: 12px; text-align: left; cursor: pointer;
}
.quick-field-panel-list button:hover { background: #f4f4f5; }
.quick-field-panel-list button.is-selected { background: #f4f4f5; color: #18181b; font-weight: 600; }
.quick-field-panel-empty {
display: flex; align-items: center; justify-content: center;
padding: 20px 0; color: #a1a1aa; font-size: 12px;
}
.quick-field-panel-footer {
display: flex; align-items: center; justify-content: space-between;
margin-top: 6px; padding-top: 7px; border-top: 1px solid #e4e4e7;
flex: 0 0 auto;
}
.quick-field-multi {
display: inline-flex; align-items: center; gap: 6px;
height: 26px; padding: 0 3px 0 7px;
border: none; border-radius: 4px; background: transparent;
color: #52525b; font-size: 12px; font-weight: 600; cursor: pointer;
}
.quick-field-multi:hover { background: #f4f4f5; color: #27272a; }
.quick-field-multi.has-active { color: #2563eb; }
.quick-field-multi-knob {
position: relative; width: 28px; height: 15px; border-radius: 999px;
background: #d4d4d8; transition: background 0.15s;
}
.quick-field-multi-knob::after {
content: ''; position: absolute; top: 1.5px; left: 1.5px;
width: 12px; height: 12px; border-radius: 999px; background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); transition: left 0.15s;
}
.quick-field-multi.has-active .quick-field-multi-knob { background: #2563eb; }
.quick-field-multi.has-active .quick-field-multi-knob::after { left: 14.5px; }
`}</style>
</span>
)
}

View File

@ -53,8 +53,9 @@ export function ProductArchiveSalesPeriodFilter({ filters, period, loading, onCh
</span>
)}
<style jsx>{`
.sales-period { display:flex; flex-wrap:wrap; align-items:center; gap:8px; padding:10px 16px; background:#fff; border-bottom:1px solid #e4e4e7; flex-shrink:0; }
.label { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:600; color:#3f3f46; }
/* 并入顶部操作行后的紧凑形态:不带自己的卡片底,作为行内一截。 */
.sales-period { display:inline-flex; flex-wrap:wrap; align-items:center; gap:8px; min-width:0; }
.label { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:600; color:#3f3f46; white-space:nowrap; }
.tabs { display:inline-flex; padding:2px; background:#f4f4f5; border-radius:5px; }
.tabs button { width:36px; height:28px; padding:0; border:0; border-radius:4px; color:#52525b; background:transparent; cursor:pointer; }
.tabs button[aria-pressed=true] { color:#fff; background:#27272a; }

View File

@ -0,0 +1,181 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { BookmarkPlus, X } from 'lucide-react'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
import type { ArchiveSavedQuery } from './useProductArchiveToolbarPreferences'
/** 键序无关的序列化:判断「当前筛选」是否就是某个已存快捷查询。 */
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(',')}}`
}
return JSON.stringify(value) ?? 'null'
}
/**
* 「另存为快捷查询」+ 快捷查询胶囊:
* 把当前筛选组合存成命名胶囊,点击一键套用;悬停可删除。
*/
export function ProductArchiveSavedQueries({
queries,
currentFilters,
canSave,
onApply,
onSave,
onDelete,
}: {
queries: ArchiveSavedQuery[]
currentFilters: ProductArchiveFilters
canSave: boolean
onApply: (filters: ProductArchiveFilters) => void
onSave: (name: string) => void
onDelete: (id: string) => void
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
useEffect(() => {
if (!open) return
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
}, [open])
const currentKey = stableStringify(currentFilters)
const submit = () => {
if (!name.trim() || !canSave) return
onSave(name)
setName('')
setOpen(false)
}
return (
<span className="saved-queries" ref={rootRef}>
<button
type="button"
className={`saved-queries-save ${open ? 'is-open' : ''}`}
disabled={!canSave}
title={canSave ? '把当前筛选另存为快捷查询' : '先设置筛选条件,才能另存快捷查询'}
aria-expanded={open}
onClick={() => {
setName('')
setOpen((current) => !current)
}}
>
<BookmarkPlus size={14} strokeWidth={1.8} />
另存查询
</button>
{open && (
<div className="saved-queries-popover" role="dialog" aria-label="另存为快捷查询">
<input
autoFocus
value={name}
maxLength={24}
placeholder="快捷查询名称,如:在售钢架桌"
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
submit()
}
if (event.key === 'Escape') setOpen(false)
}}
/>
<button type="button" disabled={!name.trim()} onClick={submit}>
保存
</button>
</div>
)}
{queries.length > 0 && (
<span className="saved-queries-chips">
{queries.map((query) => {
const active = stableStringify(query.filters) === currentKey
return (
<span key={query.id} className={`saved-query-chip ${active ? 'active' : ''}`}>
<button
type="button"
title={`${query.name}(点击套用该筛选)`}
onClick={() => onApply(query.filters)}
>
{query.name}
</button>
<button
type="button"
className="saved-query-chip-delete"
aria-label={`删除快捷查询:${query.name}`}
title="删除该快捷查询"
onClick={() => onDelete(query.id)}
>
<X size={11} />
</button>
</span>
)
})}
</span>
)}
<style jsx>{`
.saved-queries { position: relative; display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.saved-queries-save {
display: inline-flex; align-items: center; gap: 5px;
height: 30px; padding: 0 11px;
background: #fff; border: 1px solid #d4d4d8; border-radius: 4px;
color: #3f3f46; font-size: 12px; font-weight: 600; cursor: pointer;
}
.saved-queries-save:hover:not(:disabled) { background: #f4f4f5; border-color: #a1a1aa; }
.saved-queries-save:disabled { color: #a1a1aa; cursor: not-allowed; }
.saved-queries-save.is-open { border-color: #52525b; }
.saved-queries-popover {
position: absolute; top: calc(100% + 7px); left: 0; z-index: 40;
display: flex; align-items: center; gap: 7px;
width: 250px; padding: 10px;
background: #fff; border: 1px solid #d4d4d8; border-radius: 6px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}
.saved-queries-popover input {
min-width: 0; flex: 1; height: 30px; padding: 0 8px;
border: 1px solid #d4d4d8; border-radius: 4px;
color: #27272a; font-size: 12px; outline: none;
}
.saved-queries-popover input:focus { border-color: #52525b; }
.saved-queries-popover button {
height: 30px; padding: 0 12px; flex: 0 0 auto;
border: 1px solid #27272a; border-radius: 4px;
background: #27272a; color: #fff; font-size: 12px; cursor: pointer;
}
.saved-queries-popover button:disabled { background: #a1a1aa; border-color: #a1a1aa; cursor: not-allowed; }
.saved-queries-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.saved-query-chip {
display: inline-flex; align-items: center; gap: 3px;
height: 26px; padding: 0 3px 0 0;
background: #fff; border: 1px solid #e4e4e7; border-radius: 999px;
}
.saved-query-chip.active { border-color: #2563eb; background: rgba(37, 99, 235, 0.06); }
.saved-query-chip > button:first-child {
height: 100%; padding: 0 10px; border: none; border-radius: 999px;
background: transparent; color: #3f3f46; font-size: 12px; cursor: pointer;
}
.saved-query-chip > button:first-child:hover { color: #18181b; }
.saved-query-chip.active > button:first-child { color: #1d4ed8; font-weight: 600; }
.saved-query-chip-delete {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; padding: 0; margin-right: 3px;
border: none; border-radius: 999px; background: transparent;
color: #a1a1aa; cursor: pointer;
}
.saved-query-chip-delete:hover { background: #e4e4e7; color: #b91c1c; }
`}</style>
</span>
)
}

View File

@ -0,0 +1,533 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LoaderCircle, Search, X } from 'lucide-react'
import { pinyin } from 'pinyin-pro'
import type { ApiResponse } from '@/server/core/types'
import {
PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE,
type ProductArchiveFilterOptions,
type ProductArchiveFilters,
} from '@/lib/supply-chain-product-archive'
import type { ArchiveToolbarField } from './productArchiveToolbarFields'
import { fetchArchiveResource } from './archiveRequest'
/** 候选总量低于该值走「全量+拼音分组」模式;高于则走「服务端搜索」模式。 */
const LOCAL_MODE_MAX_TOTAL = 800
const PAGE_SIZE = 1000
const INDEX_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '#']
type LoadState =
| { kind: 'loading' }
| { kind: 'local'; values: string[] }
| { kind: 'server'; values: string[]; total: number | null; hasMore: boolean; searched: boolean }
| { kind: 'static'; values: string[] }
| { kind: 'error'; message: string }
/** 取值的分组键:拼音首字母大写;非字母开头归「#」。 */
function groupLetterOf(value: string): string {
const first = String(value ?? '').trim().charAt(0)
if (!first) return '#'
if (/[a-zA-Z]/.test(first)) return first.toUpperCase()
const letter = pinyin(first, { pattern: 'first', toneType: 'none', type: 'array' })[0] ?? ''
return /^[a-zA-Z]$/.test(letter) ? letter.toUpperCase() : '#'
}
/**
* 聚水潭式「选择面板」大弹窗:页面居中、搜索 + 全选/反选/清空 +
* 拼音首字母分组多列网格 + A-Z 索引 + 取消/确定。
* 小候选集(人名/枚举/供应商等)全量拉取本地分组;超大候选集(SPU/条码等)
* 以服务端搜索为主。静态候选(在职状态等)直接列表。
*/
export function ProductArchiveSelectionDialog({
field,
filters,
onConfirm,
onCancel,
}: {
field: ArchiveToolbarField
filters: ProductArchiveFilters
onConfirm: (values: string[]) => void
onCancel: () => void
}) {
const [selected, setSelected] = useState<string[]>(() => {
const record = filters as Record<string, unknown>
const values = record[field.key]
return Array.isArray(values) ? (values as unknown[]).map(String) : []
})
const [searchText, setSearchText] = useState('')
const [state, setState] = useState<LoadState>(() =>
field.staticOptions ? { kind: 'static', values: [...field.staticOptions] } : { kind: 'loading' },
)
const requestIdRef = useRef(0)
const bodyRef = useRef<HTMLDivElement>(null)
const requestPage = useCallback(
async (offset: number, q: string) => {
const params = new URLSearchParams({
field: field.key,
offset: String(offset),
limit: String(PAGE_SIZE),
})
if (q) params.set('q', q)
if (Object.keys(filters).length) params.set('filters', JSON.stringify(filters))
const response = await fetchArchiveResource('/api/supply-chain/product-archive/options', params)
const payload = (await response.json()) as ApiResponse<ProductArchiveFilterOptions>
if (!response.ok || !payload.success || !payload.data) {
throw new Error(payload.error?.message || '候选值加载失败')
}
return payload.data
},
[field.key, filters],
)
// 打开时先探一页:总量可承受就拉全量(本地分组),否则进入服务端搜索模式。
useEffect(() => {
if (field.staticOptions) return
const requestId = ++requestIdRef.current
void (async () => {
try {
const first = await requestPage(0, '')
if (requestId !== requestIdRef.current) return
if (first.total !== null && first.total > LOCAL_MODE_MAX_TOTAL) {
setState({ kind: 'server', values: [], total: first.total, hasMore: false, searched: false })
return
}
const collected = [...first.values]
let offset = first.values.length
let hasMore = first.hasMore
for (let page = 0; hasMore && page < 50; page += 1) {
const next = await requestPage(offset, '')
if (requestId !== requestIdRef.current) return
collected.push(...next.values)
offset += next.values.length
hasMore = next.hasMore
}
if (requestId !== requestIdRef.current) return
setState({ kind: 'local', values: [...new Set(collected)].filter((v) => v !== PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE) })
} catch (error) {
if (requestId !== requestIdRef.current) return
setState({ kind: 'error', message: error instanceof Error ? error.message : '候选值加载失败' })
}
})()
return () => {
requestIdRef.current += 1
}
}, [field, requestPage])
// 服务端搜索模式:停顿后按关键词查一页。
useEffect(() => {
if (state.kind !== 'server') return
const requestId = ++requestIdRef.current
const timer = window.setTimeout(() => {
setState((current) =>
current.kind === 'server'
? { ...current, values: [], hasMore: false, searched: false }
: current,
)
void (async () => {
try {
const data = await requestPage(0, searchText.trim())
if (requestId !== requestIdRef.current) return
setState((current) =>
current.kind === 'server'
? { ...current, values: data.values, total: data.total, hasMore: data.hasMore, searched: true }
: current,
)
} catch {
// 搜索失败保持空结果,用户可重试。
}
})()
}, 350)
return () => window.clearTimeout(timer)
}, [searchText, state.kind, requestPage])
// Esc 关闭、回车在搜索框内触发服务端模式立即搜索(防抖已覆盖,回车不额外做事)。
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onCancel])
const viewValues = useMemo(() => {
if (state.kind === 'static') return state.values
if (state.kind === 'local') {
const keyword = searchText.trim().toLowerCase()
if (!keyword) return state.values
return state.values.filter((value) => value.toLowerCase().includes(keyword))
}
if (state.kind === 'server') return state.values
return []
}, [state, searchText])
const groups = useMemo(() => {
if (state.kind === 'server' || state.kind === 'static') return []
const map = new Map<string, string[]>()
for (const value of viewValues) {
const letter = groupLetterOf(value)
const bucket = map.get(letter)
if (bucket) bucket.push(value)
else map.set(letter, [value])
}
return [...map.entries()]
.sort(([left], [right]) => (left === '#' ? 1 : right === '#' ? -1 : left.localeCompare(right)))
.map(([letter, values]) => ({
letter,
values: values.sort((left, right) => left.localeCompare(right, 'zh')),
}))
}, [state.kind, viewValues])
const availableLetters = useMemo(() => new Set(groups.map((group) => group.letter)), [groups])
const jumpToLetter = (letter: string) => {
const body = bodyRef.current
if (!body) return
const target = body.querySelector<HTMLElement>(`[data-group-letter="${letter}"]`)
if (target) body.scrollTo({ top: target.offsetTop - body.offsetTop - 4, behavior: 'smooth' })
}
const toggle = (value: string) => {
setSelected((current) =>
current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
)
}
const selectAllInView = () => {
setSelected((current) => [...new Set([...current, ...viewValues])])
}
const invertInView = () => {
setSelected((current) => {
const viewSet = new Set(viewValues)
const kept = current.filter((item) => !viewSet.has(item))
return [...kept, ...viewValues.filter((item) => !current.includes(item))]
})
}
const clearAll = () => setSelected([])
const confirm = () => onConfirm(selected)
const loading = state.kind === 'loading'
const isServerMode = state.kind === 'server'
return (
<div
className="selection-dialog-overlay"
role="dialog"
aria-modal="true"
aria-label={`选择${field.label}`}
onPointerDown={(event) => {
if (event.target === event.currentTarget) onCancel()
}}
>
<div className="selection-dialog">
<div className="selection-dialog-head">
<span className="selection-dialog-title">选择{field.label}</span>
<button type="button" className="selection-dialog-close" aria-label="关闭" onClick={onCancel}>
<X size={15} />
</button>
</div>
<div className="selection-dialog-toolbar">
<div className="selection-dialog-search">
<Search size={14} strokeWidth={1.8} />
<input
autoFocus
value={searchText}
placeholder={
isServerMode
? `输入关键词搜索${field.label}(候选较多,回车或停顿后搜索)`
: `搜索${field.label}`
}
onChange={(event) => setSearchText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault()
}}
/>
{searchText && (
<button type="button" aria-label="清空搜索" onClick={() => setSearchText('')}>
<X size={13} />
</button>
)}
</div>
{!loading && state.kind !== 'error' && (
<div className="selection-dialog-bulk">
<button type="button" onClick={selectAllInView} disabled={viewValues.length === 0}>全选</button>
<button type="button" onClick={invertInView} disabled={viewValues.length === 0}>反选</button>
<button type="button" onClick={clearAll} disabled={selected.length === 0}>清空</button>
</div>
)}
</div>
{selected.length > 0 && (
<div className="selection-dialog-selected">
{selected.slice(0, 12).map((value) => (
<span key={value} className="selection-dialog-chip">
{value}
<button type="button" aria-label={`移除${value}`} onClick={() => toggle(value)}>
<X size={10} />
</button>
</span>
))}
{selected.length > 12 && <span className="selection-dialog-chip-more">+{selected.length - 12}</span>}
</div>
)}
<div className="selection-dialog-body-wrap">
<div className="selection-dialog-body" ref={bodyRef}>
{loading && (
<div className="selection-dialog-state">
<LoaderCircle size={16} className="selection-dialog-spin" />
正在加载候选值...
</div>
)}
{state.kind === 'error' && <div className="selection-dialog-state error">{state.message}</div>}
{state.kind === 'static' && (
<div className="selection-dialog-grid static">
{viewValues.map((value) => (
<SelectionOption key={value} value={value} checked={selected.includes(value)} onToggle={toggle} />
))}
{viewValues.length === 0 && <div className="selection-dialog-state">没有匹配的候选值</div>}
</div>
)}
{state.kind === 'local' && (
<>
{groups.map((group) => (
<div key={group.letter} className="selection-dialog-group" data-group-letter={group.letter}>
<div className="selection-dialog-group-letter">{group.letter}</div>
<div className="selection-dialog-grid">
{group.values.map((value) => (
<SelectionOption key={value} value={value} checked={selected.includes(value)} onToggle={toggle} />
))}
</div>
</div>
))}
{viewValues.length === 0 && <div className="selection-dialog-state">没有匹配的候选值</div>}
</>
)}
{state.kind === 'server' && (
<>
{!state.searched && !searchText.trim() && (
<div className="selection-dialog-state">
{field.label}候选较多({state.total ?? '…'} 项),请输入关键词搜索后再勾选
</div>
)}
{state.searched && (
<>
<div className="selection-dialog-server-summary">
{state.total !== null ? `匹配 ${state.total} 项` : `已加载 ${state.values.length} 项`}
<button
type="button"
className="selection-dialog-select-matched"
disabled={state.values.length === 0}
onClick={selectAllInView}
>
全选匹配项
</button>
</div>
<div className="selection-dialog-grid static">
{viewValues.map((value) => (
<SelectionOption key={value} value={value} checked={selected.includes(value)} onToggle={toggle} />
))}
{viewValues.length === 0 && <div className="selection-dialog-state">没有匹配的候选值</div>}
</div>
</>
)}
</>
)}
</div>
{state.kind === 'local' && groups.length > 0 && (
<div className="selection-dialog-index">
{INDEX_LETTERS.map((letter) => (
<button
key={letter}
type="button"
className={availableLetters.has(letter) ? 'available' : ''}
disabled={!availableLetters.has(letter)}
onClick={() => jumpToLetter(letter)}
>
{letter}
</button>
))}
</div>
)}
</div>
<div className="selection-dialog-footer">
<span className="selection-dialog-count">已选 {selected.length} 项</span>
<div className="selection-dialog-footer-buttons">
<button type="button" className="cancel" onClick={onCancel}>取消</button>
<button type="button" className="confirm" onClick={confirm}>
确定{selected.length > 0 ? `(${selected.length})` : ''}
</button>
</div>
</div>
</div>
<style jsx global>{`
.selection-dialog-overlay {
position: fixed; inset: 0; z-index: 1000;
display: flex; align-items: center; justify-content: center;
background: rgba(24, 24, 27, 0.45);
padding: 24px;
}
.selection-dialog {
display: flex; flex-direction: column;
width: 880px; max-width: 100%; height: 560px; max-height: 100%;
background: #fff; border-radius: 8px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.selection-dialog-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid #f4f4f5; flex: 0 0 auto;
}
.selection-dialog-title { font-size: 14px; font-weight: 700; color: #18181b; }
.selection-dialog-close {
display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; padding: 0; border: none; border-radius: 5px;
background: transparent; color: #a1a1aa; cursor: pointer;
}
.selection-dialog-close:hover { background: #f4f4f5; color: #52525b; }
.selection-dialog-toolbar {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px 0; flex: 0 0 auto;
}
.selection-dialog-search {
display: flex; align-items: center; gap: 6px; flex: 1; max-width: 360px;
height: 32px; padding: 0 9px;
border: 1px solid #d4d4d8; border-radius: 5px; color: #a1a1aa;
}
.selection-dialog-search:focus-within { border-color: #52525b; }
.selection-dialog-search input {
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
color: #27272a; font-size: 13px;
}
.selection-dialog-search button {
display: inline-flex; padding: 0; border: none; background: transparent;
color: #a1a1aa; cursor: pointer;
}
.selection-dialog-bulk { display: inline-flex; gap: 6px; margin-left: auto; }
.selection-dialog-bulk button {
height: 30px; padding: 0 10px;
border: 1px solid #d4d4d8; border-radius: 5px;
background: #fff; color: #3f3f46; font-size: 12px; cursor: pointer;
}
.selection-dialog-bulk button:hover:not(:disabled) { background: #f4f4f5; }
.selection-dialog-bulk button:disabled { color: #a1a1aa; cursor: not-allowed; }
.selection-dialog-selected {
display: flex; flex-wrap: wrap; gap: 5px;
padding: 8px 16px 0; flex: 0 0 auto;
}
.selection-dialog-chip {
display: inline-flex; align-items: center; gap: 4px;
height: 22px; padding: 0 4px 0 8px;
background: rgba(37, 99, 235, 0.07); border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 999px; color: #1d4ed8; font-size: 12px;
}
.selection-dialog-chip button {
display: inline-flex; align-items: center; justify-content: center;
width: 15px; height: 15px; padding: 0; border: none; border-radius: 999px;
background: transparent; color: #1d4ed8; cursor: pointer;
}
.selection-dialog-chip button:hover { background: rgba(37, 99, 235, 0.15); }
.selection-dialog-chip-more { color: #71717a; font-size: 12px; align-self: center; }
.selection-dialog-body-wrap {
display: flex; flex: 1 1 auto; min-height: 0;
padding: 10px 8px 10px 16px;
}
.selection-dialog-body { flex: 1; min-width: 0; overflow-y: auto; padding-right: 8px; }
.selection-dialog-index {
display: flex; flex-direction: column; justify-content: center; align-items: center;
flex: 0 0 26px; gap: 1px; padding: 4px 2px;
}
.selection-dialog-index button {
width: 18px; height: 16px; padding: 0;
border: none; border-radius: 3px; background: transparent;
color: #d4d4d8; font-size: 10px; font-weight: 700; line-height: 1; cursor: default;
}
.selection-dialog-index button.available { color: #52525b; cursor: pointer; }
.selection-dialog-index button.available:hover { background: #2563eb; color: #fff; }
.selection-dialog-group { margin-bottom: 4px; }
.selection-dialog-group-letter {
position: sticky; top: 0; z-index: 1;
padding: 4px 2px;
color: #2563eb; font-size: 12px; font-weight: 800;
background: #fff;
}
.selection-dialog-grid {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px 8px;
}
.selection-dialog-option {
display: flex; align-items: center; gap: 7px;
min-height: 30px; padding: 2px 6px; border-radius: 4px;
color: #3f3f46; font-size: 12px; cursor: pointer;
}
.selection-dialog-option:hover, .selection-dialog-option.is-checked { background: #f4f4f5; }
.selection-dialog-option.is-checked { color: #18181b; font-weight: 550; }
.selection-dialog-option span {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.selection-dialog-option input { width: 14px; height: 14px; margin: 0; flex: 0 0 auto; accent-color: #27272a; }
.selection-dialog-state {
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 60px 20px; color: #a1a1aa; font-size: 13px;
}
.selection-dialog-state.error { color: #b91c1c; }
.selection-dialog-spin { animation: selection-dialog-spin 1s linear infinite; }
@keyframes selection-dialog-spin { to { transform: rotate(360deg); } }
.selection-dialog-server-summary {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 6px 2px 8px; color: #52525b; font-size: 12px; font-weight: 600;
}
.selection-dialog-select-matched {
padding: 0; border: none; background: transparent;
color: #2563eb; font-size: 12px; font-weight: 600; cursor: pointer;
}
.selection-dialog-select-matched:hover { text-decoration: underline; }
.selection-dialog-select-matched:disabled { color: #a1a1aa; cursor: not-allowed; }
.selection-dialog-footer {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 12px 16px; border-top: 1px solid #f4f4f5; flex: 0 0 auto;
}
.selection-dialog-count { color: #71717a; font-size: 12px; }
.selection-dialog-footer-buttons { display: inline-flex; gap: 8px; }
.selection-dialog-footer-buttons button {
height: 32px; padding: 0 18px; border-radius: 5px; font-size: 13px; cursor: pointer;
}
.selection-dialog-footer-buttons .cancel { border: 1px solid #d4d4d8; background: #fff; color: #52525b; }
.selection-dialog-footer-buttons .cancel:hover { background: #f4f4f5; }
.selection-dialog-footer-buttons .confirm { border: 1px solid #27272a; background: #27272a; color: #fff; }
.selection-dialog-footer-buttons .confirm:hover { background: #3f3f46; }
@media (max-width: 960px) {
.selection-dialog { width: 100%; height: 90vh; }
.selection-dialog-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
`}</style>
</div>
)
}
function SelectionOption({
value,
checked,
onToggle,
}: {
value: string
checked: boolean
onToggle: (value: string) => void
}) {
return (
<label className={`selection-dialog-option ${checked ? 'is-checked' : ''}`}>
<input type="checkbox" checked={checked} onChange={() => onToggle(value)} />
<span title={value}>{value}</span>
</label>
)
}

View File

@ -92,6 +92,10 @@ function renderCell(
return formatProductArchiveDateTime(row[prop])
}
// 负责人/设计师列优先展示在职状态标注值(离职接管/已离职后缀),原始值不动
if (prop === 'opsHead') return row.opsHeadDisplay ?? row.opsHead
if (prop === 'designer') return row.designerDisplay ?? row.designer
return row[prop as keyof SupplyChainProductArchiveRow]
}

View File

@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Settings, ArrowLeft, ArrowRight, Download, FileDown, FilterX, GripVertical, RefreshCw, ScanSearch } from 'lucide-react'
import { Settings, ArrowLeft, ArrowRight, ChevronDown, Download, FileDown, FilterX, GripVertical, MoreHorizontal, RefreshCw, ScanSearch, Upload } from 'lucide-react'
import { GROUP_COLORS } from './columns'
import { useColumnSettings } from './useColumnSettings'
import { ColumnSettingsPanel } from './ColumnSettingsPanel'
@ -20,6 +20,12 @@ import { useProductArchivePageImages } from './useProductArchivePageImages'
import { useProductArchiveImageExport } from './useProductArchiveImageExport'
import { ProductArchiveThemeStyles } from './ProductArchiveThemeStyles'
import { ProductArchiveSalesPeriodFilter } from './ProductArchiveSalesPeriodFilter'
import { ProductArchiveFilterBar } from './ProductArchiveFilterBar'
import { ProductArchiveFilterSettings } from './ProductArchiveFilterSettings'
import { ProductArchiveActionMenu, type ArchiveToolbarMenuItem } from './ProductArchiveActionMenu'
import { useProductArchiveToolbarPreferences } from './useProductArchiveToolbarPreferences'
import { useProductArchiveOwnerImport } from './useProductArchiveOwnerImport'
import { ProductArchiveOwnerImportProgress } from './ProductArchiveOwnerImportProgress'
import type {
ProductArchiveFilters,
SupplyChainProductArchiveRow,
@ -38,12 +44,15 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
setPageSize,
setFilters,
} = useProductArchiveViewPreferences()
const toolbar = useProductArchiveToolbarPreferences()
const [pageInput, setPageInput] = useState(String(currentPage))
const [imagePreview, setImagePreview] = useState<{ image: string; title: string } | null>(null)
const [showImageSearch, setShowImageSearch] = useState(false)
const [detailRow, setDetailRow] = useState<SupplyChainProductArchiveRow | null>(null)
const [changeLogBarcode, setChangeLogBarcode] = useState<string | null>(null)
const queryFiltersAppliedRef = useRef(false)
const ownerImportInputRef = useRef<HTMLInputElement>(null)
const ownerImport = useProductArchiveOwnerImport(() => void reload())
const { rows, pageInfo, salesPeriod, loading, error, reload } = useProductArchiveData(
currentPage,
pageSize,
@ -138,6 +147,61 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
}
}, [filters])
// 「更多」菜单里的占位入口:使用说明/数据字典(操作手册后续再补)。
const [toolbarNotice, setToolbarNotice] = useState<string | null>(null)
useEffect(() => {
if (!toolbarNotice) return
const timer = setTimeout(() => setToolbarNotice(null), 3000)
return () => clearTimeout(timer)
}, [toolbarNotice])
const showToolbarNotice = useCallback((message: string) => setToolbarNotice(message), [])
const importMenuItems: ArchiveToolbarMenuItem[] = [
{
key: 'import-owners',
label: ownerImport.isImporting ? '导入负责人(进行中…)' : '导入负责人',
icon: <Upload size={14} strokeWidth={1.8} />,
disabled: ownerImport.isImporting,
onClick: () => ownerImportInputRef.current?.click(),
},
{
key: 'owner-template',
label: '负责人模板',
icon: <FileDown size={14} strokeWidth={1.8} />,
href: '/api/supply-chain/product-archive/import/template',
},
]
const exportMenuItems: ArchiveToolbarMenuItem[] = [
{
key: 'export-archive',
label: creatingArchiveTask ? '导出资料(加入队列中…)' : '导出资料',
icon: <FileDown size={14} strokeWidth={1.8} />,
disabled: creatingArchiveTask,
onClick: () => void createArchiveExportTask(),
},
{
key: 'export-images',
label: exporting ? '导出图片(导出中…)' : '导出图片',
icon: <Download size={14} strokeWidth={1.8} />,
disabled: exporting,
onClick: () => void exportImages(),
},
]
const moreMenuItems: ArchiveToolbarMenuItem[] = [
{
key: 'user-guide',
label: '使用说明',
hint: '待完善',
onClick: () => showToolbarNotice('操作手册整理中,敬请期待'),
},
{
key: 'data-dictionary',
label: '数据字典',
hint: '待完善',
onClick: () => showToolbarNotice('数据字典整理中,敬请期待'),
},
]
const changeFilters = useCallback((nextFilters: ProductArchiveFilters) => {
setFilters(nextFilters)
setCurrentPage(1)
@ -165,7 +229,41 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
return (
<div className="archive-page">
{/* 工具条 */}
{/* 表头第一行:筛选区(可收起;收起后是一条细条,最右「设置 | 展开」) */}
{!toolbar.collapsed ? (
<ProductArchiveFilterBar
filters={filters}
onChange={changeFilters}
preciseFields={toolbar.preciseFields}
savedQueries={toolbar.savedQueries}
onSaveQuery={(name) => toolbar.saveQuery(name, filters)}
onDeleteQuery={toolbar.deleteQuery}
onPreciseFieldsChange={toolbar.setPreciseFields}
onCollapse={() => toolbar.setCollapsed(true)}
/>
) : (
<div className="archive-filterbar-collapsed">
<span className="archive-filterbar-collapsed-label">
筛选{activeFilterCount > 0 ? `(${activeFilterCount} 个条件生效中)` : '(未设置条件)'}
</span>
<div className="archive-filterbar-collapsed-tail">
<ProductArchiveFilterSettings
selected={toolbar.preciseFields}
onChange={toolbar.setPreciseFields}
/>
<button
type="button"
className="archive-filterbar-expand"
onClick={() => toolbar.setCollapsed(false)}
>
展开
<ChevronDown size={14} strokeWidth={1.8} />
</button>
</div>
</div>
)}
{/* 表头第二行:清除筛选 + 销售统计 + 右侧操作 */}
<div className="archive-toolbar">
{activeFilterCount > 0 && (
<button
@ -180,44 +278,52 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
清除筛选 {activeFilterCount}
</button>
)}
<ProductArchiveSalesPeriodFilter filters={filters} period={salesPeriod} loading={loading} onChange={changeFilters} />
<div className="archive-toolbar-actions">
{canDownload ? (
<>
<button
type="button"
className="archive-toolbar-action"
disabled={creatingArchiveTask}
onClick={() => void createArchiveExportTask()}
>
<FileDown size={14} strokeWidth={1.8} />
{creatingArchiveTask ? '加入队列中...' : '导出资料'}
</button>
{archiveTaskMessage ? (
<span className="self-center text-xs font-semibold text-[#047857]">{archiveTaskMessage}</span>
) : null}
<button
type="button"
className="archive-toolbar-action"
disabled={exporting}
onClick={() => void exportImages()}
>
<Download size={14} strokeWidth={1.8} />
{exporting ? '导出中...' : '导出图片'}
</button>
</>
{archiveTaskMessage ? (
<span className="self-center text-xs font-semibold text-[#047857]">{archiveTaskMessage}</span>
) : null}
{toolbarNotice ? (
<span className="self-center text-xs font-semibold text-[#52525b]">{toolbarNotice}</span>
) : null}
<button className="archive-toolbar-action" onClick={() => setShowImageSearch(true)}>
<ScanSearch size={14} strokeWidth={1.8} />
以图搜图
</button>
<ProductArchiveActionMenu label="导入" icon={<Upload size={14} strokeWidth={1.8} />} items={importMenuItems} />
{canDownload ? (
<ProductArchiveActionMenu label="导出" icon={<FileDown size={14} strokeWidth={1.8} />} items={exportMenuItems} />
) : null}
<button
type="button"
className="archive-toolbar-action"
disabled={loading}
title="重新加载当前页数据"
onClick={reload}
>
<RefreshCw size={14} strokeWidth={1.8} />
刷新
</button>
<button className="archive-toolbar-action" onClick={() => setShowColumnSettings(true)}>
<Settings size={14} strokeWidth={1.8} />
列设置
</button>
<ProductArchiveActionMenu label="更多" icon={<MoreHorizontal size={14} strokeWidth={1.8} />} items={moreMenuItems} />
</div>
</div>
<ProductArchiveSalesPeriodFilter filters={filters} period={salesPeriod} loading={loading} onChange={changeFilters} />
<input
ref={ownerImportInputRef}
type="file"
accept=".xlsx,.xls"
hidden
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) void ownerImport.importFile(file)
}}
/>
<ProductArchiveOwnerImportProgress state={ownerImport.state} onDismiss={ownerImport.dismiss} />
{/* 表格 */}
<div ref={tableWrapRef} className="archive-table-wrap">
@ -391,11 +497,28 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
padding: 16px 20px;
gap: 12px;
}
.archive-filterbar-collapsed {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 6px 16px; min-height: 36px;
background: #fff; border: 1px solid #f4f4f5; border-radius: 8px;
flex-shrink: 0; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.archive-filterbar-collapsed-label { color: #71717a; font-size: 12px; font-weight: 600; }
.archive-filterbar-collapsed-tail { display: inline-flex; align-items: center; gap: 2px; }
.archive-filterbar-expand {
display: inline-flex; align-items: center; gap: 4px;
height: 28px; padding: 0 8px;
border: none; border-radius: 4px;
background: transparent; color: #71717a;
font-size: 12px; font-weight: 600; cursor: pointer;
}
.archive-filterbar-expand:hover { background: #f4f4f5; color: #27272a; }
.archive-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 16px;
padding: 10px 16px;
background: #fff;
border: 1px solid #f4f4f5;
border-radius: 8px;

View File

@ -0,0 +1,60 @@
import type {
ProductArchiveOwnerImportProgress,
ProductArchiveOwnerImportStreamEvent,
} from '@/lib/supply-chain-product-archive-owner-import'
function parseProgress(value: unknown): ProductArchiveOwnerImportProgress {
if (!value || typeof value !== 'object') throw new Error('导入进度响应格式不正确')
const progress = value as Record<string, unknown>
if (!['processed', 'total', 'updated', 'skipped', 'failed'].every((key) => typeof progress[key] === 'number')) {
throw new Error('导入进度响应格式不正确')
}
if (!Array.isArray(progress.errors) || !Array.isArray(progress.warnings)) {
throw new Error('导入进度响应格式不正确')
}
return progress as ProductArchiveOwnerImportProgress
}
function parseEvent(line: string): ProductArchiveOwnerImportStreamEvent {
const value = JSON.parse(line) as Record<string, unknown>
const progress = parseProgress(value.progress)
if (value.type === 'progress' || value.type === 'complete') return { type: value.type, progress }
if (value.type === 'error' && typeof value.message === 'string') {
return { type: 'error', progress, message: value.message }
}
throw new Error('导入进度响应格式不正确')
}
export async function readOwnerImportStream(
response: Response,
onEvent: (event: ProductArchiveOwnerImportStreamEvent) => void,
) {
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error?.message || '导入请求失败')
}
if (!response.body) throw new Error('浏览器无法读取导入进度')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let complete: ProductArchiveOwnerImportProgress | null = null
const consume = (line: string) => {
if (!line.trim()) return
const event = parseEvent(line)
onEvent(event)
if (event.type === 'complete') complete = event.progress
if (event.type === 'error') throw new Error(event.message)
}
while (true) {
const { done, value } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
lines.forEach(consume)
if (done) break
}
consume(buffer)
if (!complete) throw new Error('导入连接提前结束,请查看已导入的数据')
return complete
}

View File

@ -0,0 +1,54 @@
import {
ARCHIVE_PRECISE_FIELDS,
ARCHIVE_QUICK_FIELD_OPTIONS,
DEFAULT_ARCHIVE_PRECISE_FIELDS,
normalizeArchivePreciseFields,
splitArchiveFilterValues,
} from './productArchiveToolbarFields'
describe('product archive toolbar fields', () => {
it('puts SPU/barcode/supplier first in the default visible fields', () => {
expect(DEFAULT_ARCHIVE_PRECISE_FIELDS.slice(0, 3)).toEqual(['spu', 'barcode', 'supplier'])
expect(DEFAULT_ARCHIVE_PRECISE_FIELDS).toEqual([
'spu', 'barcode', 'supplier',
'designer', 'opsHead', 'opsDeptHead', 'opsDirector', 'purchaser', 'warehouseHead',
'categoryL1', 'brand', 'productStatus', 'ownerEmploymentStatus',
])
})
it('gives owner employment status static candidates and others api candidates', () => {
const employment = ARCHIVE_PRECISE_FIELDS.find((item) => item.key === 'ownerEmploymentStatus')
expect(employment?.staticOptions).toEqual(['在职', '已离职', '未匹配', '未设置'])
const designer = ARCHIVE_PRECISE_FIELDS.find((item) => item.key === 'designer')
expect(designer?.staticOptions).toBeUndefined()
})
it('covers all quick-field keys and keeps sku/productName out of the precise pool', () => {
const preciseKeys = ARCHIVE_PRECISE_FIELDS.map((item) => item.key)
for (const key of ['sku', 'productName']) {
expect(preciseKeys).not.toContain(key)
}
// SPU/条码/供应商既在点选框池也在「请选择」池
for (const key of ['spu', 'barcode', 'supplier']) {
expect(preciseKeys).toContain(key)
expect(ARCHIVE_QUICK_FIELD_OPTIONS.some((item) => item.key === key)).toBe(true)
}
})
it('normalizes precise fields by dedupe and pool membership', () => {
expect(normalizeArchivePreciseFields(['designer', 'nope', 'designer', 'region'])).toEqual([
'designer', 'region',
])
expect(normalizeArchivePreciseFields('bad')).toEqual([])
})
it('splits multi-value input on CN/EN separators and dedupes case-insensitively', () => {
expect(splitArchiveFilterValues('XG0022, xg0022; DNZ0411、GBSFY-SJ0380 第二个值')).toEqual([
'XG0022',
'DNZ0411',
'GBSFY-SJ0380',
'第二个值',
])
expect(splitArchiveFilterValues(' ,,,; ')).toEqual([])
})
})

View File

@ -0,0 +1,97 @@
// 产品全量档案 —— 顶部表头(筛选区/操作区)字段池与纯函数。
// key 必须是 ProductArchiveFilters 已支持、服务端已放行的筛选键;
// 这里只做字段清单,不碰任何请求逻辑。
import { OWNER_EMPLOYMENT_STATUS_VALUES } from '@/lib/supply-chain-owner-employment'
export type ArchiveToolbarField = {
key: string
label: string
/** 有值 = 弹窗用静态候选(不走 options 接口);无值 = 走 options 接口取候选。 */
staticOptions?: readonly string[]
}
function field(key: string, label: string, staticOptions?: readonly string[]): ArchiveToolbarField {
return { key, label, staticOptions }
}
/**
* 顶部常显字段池(设置里可配显隐)。统一形态:点击弹「可搜索 + 可勾选」小弹窗,
* 字段名内置在框占位里。模糊搜索框已覆盖 SPU/SKU/条码/商品名称/供应商,不再放外框。
*/
export const ARCHIVE_PRECISE_FIELDS: ArchiveToolbarField[] = [
field('spu', 'SPU'),
field('barcode', '条码'),
field('supplier', '供应商'),
field('designer', '设计师'),
field('opsHead', '运营负责人'),
field('opsDeptHead', '运营部门负责人'),
field('opsDirector', '运营总负责人'),
field('purchaser', '采购'),
field('warehouseHead', '仓库负责人'),
field('categoryL1', '一级类目'),
field('brand', '品牌'),
field('productStatus', '商品状态'),
field('ownerEmploymentStatus', '在职状态', OWNER_EMPLOYMENT_STATUS_VALUES),
field('backupSupplier', '替换供应商'),
field('categoryL2', '二级类目'),
field('region', '区域'),
field('material', '材质'),
field('color', '颜色'),
field('productionType', '产品类型'),
field('packType', '单双包'),
field('productTag', '商品标签'),
field('supplierType', '供应商类型'),
field('settleType', '结算方式'),
field('storageType', '入库/代发'),
field('boardProvider', '板材提供方'),
field('installDocId', '安装图纸/视频ID'),
]
/** 默认常显框(SPU/条码/供应商置顶 + 运营链字段 + 高频点选框)。 */
export const DEFAULT_ARCHIVE_PRECISE_FIELDS: string[] = [
'spu', 'barcode', 'supplier',
'designer', 'opsHead', 'opsDeptHead', 'opsDirector', 'purchaser', 'warehouseHead',
'categoryL1', 'brand', 'productStatus', 'ownerEmploymentStatus',
]
/** 「请选择+请输入」的字段池:全部可精准匹配的字段。 */
export const ARCHIVE_QUICK_FIELD_OPTIONS: ArchiveToolbarField[] = [
field('sku', 'SKU'),
field('productName', '商品名称'),
...ARCHIVE_PRECISE_FIELDS,
]
const PRECISE_FIELD_KEY_SET: ReadonlySet<string> = new Set(ARCHIVE_PRECISE_FIELDS.map((item) => item.key))
/** 按清单顺序归一常显字段:去重、去掉池外键。 */
export function normalizeArchivePreciseFields(values: unknown): string[] {
const list = Array.isArray(values) ? values : []
const seen = new Set<string>()
const normalized: string[] = []
for (const key of list) {
if (typeof key !== 'string' || !PRECISE_FIELD_KEY_SET.has(key) || seen.has(key)) continue
seen.add(key)
normalized.push(key)
}
return normalized
}
export function findArchiveQuickField(key: string): ArchiveToolbarField | undefined {
return ARCHIVE_QUICK_FIELD_OPTIONS.find((item) => item.key === key)
}
/** 输入框文本拆值:中英文逗号/分号/顿号/空白分隔,去空去重。 */
export function splitArchiveFilterValues(text: string): string[] {
const seen = new Set<string>()
const values: string[] = []
for (const part of String(text ?? '').split(/[,,;;、\s]+/)) {
const value = part.trim()
if (!value) continue
const dedupeKey = value.toUpperCase()
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
values.push(value)
}
return values
}

View File

@ -0,0 +1,70 @@
'use client'
import { useCallback, useState } from 'react'
import type {
ProductArchiveOwnerImportProgress,
ProductArchiveOwnerImportStreamEvent,
} from '@/lib/supply-chain-product-archive-owner-import'
import { readOwnerImportStream } from './ownerImportStream'
export type ProductArchiveOwnerImportState = ProductArchiveOwnerImportProgress & {
fileName: string
status: 'preparing' | 'running' | 'complete' | 'error'
message?: string
}
const EMPTY: ProductArchiveOwnerImportProgress = {
processed: 0,
total: 0,
updated: 0,
skipped: 0,
failed: 0,
warnings: [],
errors: [],
}
export function useProductArchiveOwnerImport(onCompleted: () => void) {
const [state, setState] = useState<ProductArchiveOwnerImportState | null>(null)
const isImporting = state?.status === 'preparing' || state?.status === 'running'
const importFile = useCallback(async (file: File) => {
if (isImporting) return
setState({ ...EMPTY, fileName: file.name, status: 'preparing' })
const body = new FormData()
body.append('file', file)
try {
const response = await fetch('/api/supply-chain/product-archive/import', {
method: 'POST',
body,
})
await readOwnerImportStream(
response,
(event: ProductArchiveOwnerImportStreamEvent) => {
setState({
...event.progress,
fileName: file.name,
status: event.type === 'progress' ? 'running' : event.type,
...(event.type === 'error' ? { message: event.message } : {}),
})
},
)
onCompleted()
} catch (error) {
setState((current) => ({
...EMPTY,
...current,
fileName: file.name,
status: 'error',
message: error instanceof Error ? error.message : '导入失败',
}))
}
}, [isImporting, onCompleted])
return {
state,
isImporting,
importFile,
dismiss: () => setState((current) => (
current?.status === 'preparing' || current?.status === 'running' ? current : null
)),
}
}

View File

@ -0,0 +1,77 @@
/** @jest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react'
import {
parseProductArchiveToolbarPreferences,
useProductArchiveToolbarPreferences,
} from './useProductArchiveToolbarPreferences'
const STORAGE_KEY = 'supply_chain_product_archive_toolbar_v1'
describe('product archive toolbar preferences', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('keeps only pool-known precise fields', () => {
const preferences = parseProductArchiveToolbarPreferences(
JSON.stringify({
preciseFields: ['designer', 'spu', 'nope', 'designer', 'region'],
quickSelects: ['brand'],
collapsed: 'yes',
}),
)
// spu 在字段框池(置顶点选框)被保留;nope 被清掉;quickSelects 已废弃被忽略
expect(preferences.preciseFields).toEqual(['designer', 'spu', 'region'])
expect(preferences.collapsed).toBe(false)
expect(preferences.savedQueries).toEqual([])
expect('quickSelects' in preferences).toBe(false)
})
it('drops saved queries without usable filters and caps the list', () => {
const queries = Array.from({ length: 25 }, (_, index) => ({
id: `q-${index}`,
name: `查询${index}`,
filters: index % 2 === 0 ? { spu: [`XG00${index}`] } : {},
}))
const preferences = parseProductArchiveToolbarPreferences(
JSON.stringify({ savedQueries: queries }),
)
// 25 条里 13 条有效(偶数下标带 spu),先过滤无效再按上限截断
expect(preferences.savedQueries).toHaveLength(13)
expect(preferences.savedQueries.every((query) => query.filters.spu?.length === 1)).toBe(true)
})
it('persists precise fields, collapse and saved queries across remounts', async () => {
const first = renderHook(() => useProductArchiveToolbarPreferences())
await waitFor(() => expect(first.result.current.hydrated).toBe(true))
act(() => {
first.result.current.setPreciseFields(['designer', 'purchaser', 'bogus'])
first.result.current.setCollapsed(true)
first.result.current.saveQuery('在售钢架', { productStatus: ['销售'], material: ['钢'] })
})
await waitFor(() => {
expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '{}')).toMatchObject({
collapsed: true,
preciseFields: ['designer', 'purchaser'],
})
})
first.unmount()
const second = renderHook(() => useProductArchiveToolbarPreferences())
await waitFor(() => expect(second.result.current.hydrated).toBe(true))
expect(second.result.current.collapsed).toBe(true)
expect(second.result.current.preciseFields).toEqual(['designer', 'purchaser'])
expect(second.result.current.savedQueries).toHaveLength(1)
expect(second.result.current.savedQueries[0].name).toBe('在售钢架')
act(() => {
second.result.current.deleteQuery(second.result.current.savedQueries[0].id)
})
await waitFor(() => expect(second.result.current.savedQueries).toHaveLength(0))
})
})

View File

@ -0,0 +1,140 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
import { parseProductArchiveFilters } from './useProductArchiveViewPreferences'
import {
DEFAULT_ARCHIVE_PRECISE_FIELDS,
normalizeArchivePreciseFields,
} from './productArchiveToolbarFields'
const STORAGE_KEY = 'supply_chain_product_archive_toolbar_v1'
const MAX_SAVED_QUERIES = 20
export type ArchiveSavedQuery = {
id: string
name: string
filters: ProductArchiveFilters
createdAt: string
}
export type ProductArchiveToolbarPreferences = {
/** 顶部常显的字段框(设置里可配,默认运营链字段+四个高频点选框)。 */
preciseFields: string[]
/** 顶部筛选行是否收起。 */
collapsed: boolean
/** 另存的快捷查询,新保存在最前。 */
savedQueries: ArchiveSavedQuery[]
}
export const DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES: ProductArchiveToolbarPreferences = {
preciseFields: [...DEFAULT_ARCHIVE_PRECISE_FIELDS],
collapsed: false,
savedQueries: [],
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function sanitizeSavedQueries(value: unknown): ArchiveSavedQuery[] {
const list = Array.isArray(value) ? value : []
const collected: ArchiveSavedQuery[] = []
const seenIds = new Set<string>()
for (const item of list) {
if (!isRecord(item)) continue
const id = typeof item.id === 'string' ? item.id : ''
const name = typeof item.name === 'string' ? item.name.trim() : ''
if (!id || !name || seenIds.has(id)) continue
const filters = parseProductArchiveFilters(JSON.stringify(item.filters ?? {}))
if (Object.keys(filters).length === 0) continue
seenIds.add(id)
collected.push({
id,
name,
filters,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
})
}
return collected.slice(0, MAX_SAVED_QUERIES)
}
export function parseProductArchiveToolbarPreferences(raw: string | null): ProductArchiveToolbarPreferences {
if (!raw) return DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
return {
// 全部取消勾选是合法状态(顶部一个字段框都不摆),空数组保持空。
preciseFields: normalizeArchivePreciseFields(parsed.preciseFields),
collapsed: parsed.collapsed === true,
savedQueries: sanitizeSavedQueries(parsed.savedQueries),
}
} catch {
return DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES
}
}
export function useProductArchiveToolbarPreferences() {
const [preferences, setPreferences] = useState<ProductArchiveToolbarPreferences>(
DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES,
)
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
try {
setPreferences(parseProductArchiveToolbarPreferences(localStorage.getItem(STORAGE_KEY)))
} finally {
setHydrated(true)
}
}, [])
useEffect(() => {
if (!hydrated) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences))
} catch {
// 存储不可用时保留内存态。
}
}, [hydrated, preferences])
const setPreciseFields = useCallback((fields: string[]) => {
setPreferences((current) => ({ ...current, preciseFields: normalizeArchivePreciseFields(fields) }))
}, [])
const setCollapsed = useCallback((collapsed: boolean) => {
setPreferences((current) => ({ ...current, collapsed }))
}, [])
const saveQuery = useCallback((name: string, filters: ProductArchiveFilters) => {
const trimmed = name.trim()
if (!trimmed) return
setPreferences((current) => {
const query: ArchiveSavedQuery = {
id: `aq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: trimmed,
filters,
createdAt: new Date().toISOString(),
}
return {
...current,
savedQueries: [query, ...current.savedQueries].slice(0, MAX_SAVED_QUERIES),
}
})
}, [])
const deleteQuery = useCallback((id: string) => {
setPreferences((current) => ({
...current,
savedQueries: current.savedQueries.filter((query) => query.id !== id),
}))
}, [])
return {
...preferences,
hydrated,
setPreciseFields,
setCollapsed,
saveQuery,
deleteQuery,
}
}

View File

@ -11,6 +11,7 @@ import {
type ProductArchiveSalesStatus,
type ProductArchiveTextFilterKey,
} from '@/lib/supply-chain-product-archive'
import { OWNER_EMPLOYMENT_STATUS_VALUES } from '@/lib/supply-chain-owner-employment'
const STORAGE_KEY = 'supply_chain_product_archive_view_v1'
const PAGE_SIZES = [50, 100, 200] as const
@ -97,6 +98,12 @@ function sanitizeFilters(value: unknown): ProductArchiveFilters {
}
}
// 「负责人在职状态」是虚拟筛选:取值固定为四个状态值
const ownerEmploymentStatus = cleanStringArray(value.ownerEmploymentStatus)?.filter((item) =>
(OWNER_EMPLOYMENT_STATUS_VALUES as readonly string[]).includes(item),
)
if (ownerEmploymentStatus?.length) filters.ownerEmploymentStatus = ownerEmploymentStatus
for (const key of ['createTimeFrom', 'createTimeTo', 'updateTimeFrom', 'updateTimeTo'] as const) {
const normalized = cleanString(value[key])
if (normalized) filters[key] = normalized

View File

@ -0,0 +1,106 @@
import {
buildOwnerEmploymentIndex,
normalizeOwnerName,
resolveDesignerView,
resolveOwnerTakeover,
type OwnerEmploymentEntry,
} from './supply-chain-owner-employment'
function index(entries: Array<Partial<OwnerEmploymentEntry> & { name: string }>) {
return buildOwnerEmploymentIndex(entries.map((entry) => ({
sourceStatus: 'EMPLOYED' as const,
departmentOwner: '',
generalOwner: '',
...entry,
})))
}
describe('负责人在职状态与离职接管链', () => {
it('空负责人为未设置,匹配不到为未匹配,在职原样显示', () => {
const idx = index([{ name: '半夏' }])
expect(resolveOwnerTakeover('', idx)).toMatchObject({ status: '未设置', display: '' })
expect(resolveOwnerTakeover(' ', idx)).toMatchObject({ status: '未设置' })
expect(resolveOwnerTakeover('香茗', idx)).toMatchObject({ status: '未匹配', display: '香茗' })
expect(resolveOwnerTakeover('半夏', idx)).toMatchObject({ status: '在职', display: '半夏' })
})
it('花名按去零宽字符与大写口径匹配', () => {
const idx = index([{ name: '五竹' }])
expect(resolveOwnerTakeover('五\u200B竹', idx).status).toBe('在职')
expect(normalizeOwnerName(' 五竹 ')).toBe('五竹')
})
it('离职负责人转部门现任负责人', () => {
const idx = index([
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
{ name: '半夏', departmentOwner: '半夏', generalOwner: '江陵' },
{ name: '江陵', departmentOwner: '江陵', generalOwner: '江陵' },
])
expect(resolveOwnerTakeover('五竹', idx)).toEqual({
status: '已离职',
display: '五竹(已离职)→ 半夏',
successor: '半夏',
successorDeparted: false,
})
})
it('部门负责人也离职时转总负责人', () => {
const idx = index([
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
{ name: '半夏', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
{ name: '江陵', departmentOwner: '江陵', generalOwner: '江陵' },
])
expect(resolveOwnerTakeover('五竹', idx)).toEqual({
status: '已离职',
display: '五竹(已离职)→ 江陵',
successor: '江陵',
successorDeparted: false,
})
})
it('总负责人也离职时带标注展示,不再上抛', () => {
const idx = index([
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
{ name: '半夏', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
{ name: '江陵', sourceStatus: 'DIMISSIONED', departmentOwner: '江陵', generalOwner: '江陵' },
])
const view = resolveOwnerTakeover('五竹', idx)
expect(view.display).toBe('五竹(已离职)→ 江陵(已离职)')
expect(view.successorDeparted).toBe(true)
})
it('部门负责人缺位或为本人时直接取总负责人;全缺位时仅展示本人已离职', () => {
const onlyGeneral = index([
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '', generalOwner: '江陵' },
{ name: '江陵', generalOwner: '江陵' },
])
expect(resolveOwnerTakeover('五竹', onlyGeneral).display).toBe('五竹(已离职)→ 江陵')
const selfOwner = index([
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '五竹', generalOwner: '江陵' },
{ name: '江陵', generalOwner: '江陵' },
])
expect(resolveOwnerTakeover('五竹', selfOwner).display).toBe('五竹(已离职)→ 江陵')
const noSuccessor = index([
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '', generalOwner: '' },
])
expect(resolveOwnerTakeover('五竹', noSuccessor).display).toBe('五竹(已离职)')
})
it('无员工目录关联的人员按在职处理(不标注、不接管)', () => {
const idx = index([{ name: '手工行', sourceStatus: 'EMPLOYED' }])
expect(resolveOwnerTakeover('手工行', idx).status).toBe('在职')
})
it('设计师离职只加后缀,不接管;未匹配设计师原样', () => {
const idx = index([
{ name: '辛环', sourceStatus: 'DIMISSIONED' },
{ name: '在册设计师' },
])
expect(resolveDesignerView('辛环', idx)).toEqual({ display: '辛环(已离职)', departed: true })
expect(resolveDesignerView('在册设计师', idx)).toEqual({ display: '在册设计师', departed: false })
expect(resolveDesignerView('外部自由设计师', idx)).toEqual({ display: '外部自由设计师', departed: false })
expect(resolveDesignerView('', idx)).toEqual({ display: '', departed: false })
})
})

View File

@ -0,0 +1,107 @@
// 负责人在职状态与离职接管链的共享口径(2026-10-12 用户确认)。
//
// 全量档案的运营负责人/设计师在职状态是「显示层」能力:库里永远存原始花名,
// 页面与导出按人员信息表(SupplyChainManagementOwnerConfig)实时判断:
// - 负责人在职 → 原样显示;
// - 负责人离职 → `花名(已离职)→ 接管人`,接管人依次取其部门现任负责人、
// 运营总负责人;接管人也离职时仅追加(已离职)标注,不再上抛;
// - 花名匹配不到人员信息 → 原样显示,状态「未匹配」(需要补人员信息或改档案);
// - 档案无负责人 → 状态「未设置」。
// 设计师离职只加(已离职)标注,不做接管(设计师无层级链)。
export type OwnerEmploymentStatus = '在职' | '已离职' | '未匹配' | '未设置'
export const OWNER_EMPLOYMENT_STATUS_VALUES = [
'在职',
'已离职',
'未匹配',
'未设置',
] as const satisfies readonly OwnerEmploymentStatus[]
export type OwnerEmploymentEntry = {
/** 人员信息表花名(原样,不清洗) */
name: string
/** 在职状态;无员工目录关联的行按在职处理(不标注) */
sourceStatus: 'EMPLOYED' | 'DIMISSIONED'
departmentOwner: string
generalOwner: string
}
/** 与档案侧身份清洗同口径:去零宽字符 + 去首尾空白 + 大写。 */
export function normalizeOwnerName(value: string) {
return value.replace(/[\u200B-\u200D\uFEFF]/g, '').trim().toUpperCase()
}
export type OwnerEmploymentIndex = Map<string, OwnerEmploymentEntry>
/** 同名花名取第一条(调用方按 operationOwner asc、updatedAt desc 排序传入)。 */
export function buildOwnerEmploymentIndex(entries: OwnerEmploymentEntry[]): OwnerEmploymentIndex {
const index: OwnerEmploymentIndex = new Map()
for (const entry of entries) {
const key = normalizeOwnerName(entry.name)
if (key && !index.has(key)) index.set(key, entry)
}
return index
}
export type OwnerTakeoverView = {
status: OwnerEmploymentStatus
/** 展示值:在职/未匹配/未设置为原样(未设置为空串),离职为 `花名(已离职)→ 接管人` */
display: string
/** 接管人花名(仅离职且有接管人时有值) */
successor: string
/** 接管人本身也已离职 */
successorDeparted: boolean
}
export function resolveOwnerTakeover(
opsHead: string | null | undefined,
index: OwnerEmploymentIndex,
): OwnerTakeoverView {
const raw = (opsHead ?? '').trim()
if (!raw) return { status: '未设置', display: '', successor: '', successorDeparted: false }
const entry = index.get(normalizeOwnerName(raw))
if (!entry) return { status: '未匹配', display: raw, successor: '', successorDeparted: false }
if (entry.sourceStatus !== 'DIMISSIONED') {
return { status: '在职', display: raw, successor: '', successorDeparted: false }
}
const ownerKey = normalizeOwnerName(raw)
const pickSuccessor = (name: string | null | undefined) => {
const trimmed = (name ?? '').trim()
if (!trimmed || normalizeOwnerName(trimmed) === ownerKey) return null
return {
name: trimmed,
departed: index.get(normalizeOwnerName(trimmed))?.sourceStatus === 'DIMISSIONED',
}
}
const departmentOwner = pickSuccessor(entry.departmentOwner)
const generalOwner = pickSuccessor(entry.generalOwner)
// 接管链:部门现任负责人在职 → 直接接管;否则交给总负责人(总负责人也离职则
// 带标注展示,不再上抛);两者都缺位时退回展示部门负责人(如有)。
const successor = (departmentOwner && !departmentOwner.departed ? departmentOwner : null)
?? generalOwner
?? departmentOwner
if (!successor) {
return { status: '已离职', display: `${raw}(已离职)`, successor: '', successorDeparted: false }
}
return {
status: '已离职',
display: `${raw}(已离职)→ ${successor.name}${successor.departed ? '(已离职)' : ''}`,
successor: successor.name,
successorDeparted: successor.departed,
}
}
export function resolveDesignerView(
designer: string | null | undefined,
index: OwnerEmploymentIndex,
): { display: string; departed: boolean } {
const raw = (designer ?? '').trim()
if (!raw) return { display: '', departed: false }
const entry = index.get(normalizeOwnerName(raw))
if (entry?.sourceStatus === 'DIMISSIONED') {
return { display: `${raw}(已离职)`, departed: true }
}
return { display: raw, departed: false }
}

View File

@ -22,6 +22,7 @@ export const PRODUCT_CATALOG_COST_ARCHIVE_SOURCE_CODE = 'PRODUCT_CATALOG_COST_PR
export const WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE = 'WAREHOUSE_CONFIG'
export const SYSTEM_USER_DEPARTMENT_ARCHIVE_SOURCE_CODE = 'SYSTEM_USER_DEPARTMENT'
export const JST_SKU_INFO_MANAGE_ARCHIVE_SOURCE_CODE = 'JST_SKU_INFO_MANAGE'
export const MANUAL_IMPORT_ARCHIVE_SOURCE_CODE = 'MANUAL_IMPORT'
export type ProductArchiveFieldSource = {
sourceCode: string

View File

@ -0,0 +1,39 @@
// 产品全量档案「按条码上传运营负责人/设计师」导入的类型与常量。
//
// 口径(2026-10-12 用户确认):
// - 模板三列:条码(必填)、运营负责人、设计师;空单元格不覆盖对应字段;
// - 运营负责人严格校验:必须存在于人员信息(含已离职),否则该行失败;
// - 设计师自由文本;
// - 上传以 MANUAL_IMPORT 来源 + 上传时间戳落库,作为新的历史基准:
// 早于上传时间的历史审批/聚水潭设计师回填不再覆盖,之后新通过的审批可再覆盖。
export const PRODUCT_ARCHIVE_OWNER_IMPORT_COLUMNS = ['条码', '运营负责人', '设计师'] as const
export type ProductArchiveOwnerImportRow = {
/** Excel 实际行号(含表头行) */
rowNumber: number
barcode: string
opsHead: string
designer: string
}
export type ProductArchiveOwnerImportIssue = {
rowNumber: number
barcode: string
message: string
}
export type ProductArchiveOwnerImportProgress = {
processed: number
total: number
updated: number
skipped: number
failed: number
warnings: ProductArchiveOwnerImportIssue[]
errors: ProductArchiveOwnerImportIssue[]
}
export type ProductArchiveOwnerImportStreamEvent =
| { type: 'progress'; progress: ProductArchiveOwnerImportProgress }
| { type: 'complete'; progress: ProductArchiveOwnerImportProgress }
| { type: 'error'; progress: ProductArchiveOwnerImportProgress; message: string }

View File

@ -1,4 +1,5 @@
import type { ArchiveSalesPeriod } from './product-archive-sales-period'
import type { OwnerEmploymentStatus } from './supply-chain-owner-employment'
export type SupplyChainProductArchiveRow = {
id: string
@ -14,9 +15,15 @@ export type SupplyChainProductArchiveRow = {
barcode: string
productName: string
designer: string
/** 设计师在职状态展示值(离职时为 `花名(已离职)`);人员信息不可用时缺省回退 designer */
designerDisplay?: string
opsDirector: string
opsDeptHead: string
opsHead: string
/** 运营负责人离职接管展示值(如 `五竹(已离职)→ 半夏`);缺省回退 opsHead */
opsHeadDisplay?: string
/** 运营负责人在职状态(按人员信息实时判定;未加载人员信息时缺省) */
ownerEmploymentStatus?: OwnerEmploymentStatus
productStatus: string
productionType: string
material: string
@ -214,6 +221,8 @@ export type ProductArchiveFilters = {
opsDirector?: string[]
opsDeptHead?: string[]
opsHead?: string[]
/** 负责人在职状态(在职/已离职/未匹配/未设置),按人员信息实时判定的虚拟筛选 */
ownerEmploymentStatus?: string[]
warehouseHead?: string[]
warehouseOwnership?: string[]
warehouseType?: string[]

View File

@ -73,4 +73,22 @@ describe('ProductArchiveOwnerWorkflowRepository', () => {
expect(await new ProductArchiveOwnerWorkflowRepositoryClass().applyAssignment(assignment)).toBe('newer-source')
expect(tx.supplyChainProductArchive.update).not.toHaveBeenCalled()
})
it('does not overwrite a manual import baseline with an older approval', async () => {
// 上传时间 2026-10-11,审批完成时间 2026-10-09:历史审批不覆盖上传基准
const tx = setup('上传负责人', {
sourceCode: 'MANUAL_IMPORT', sourceColumn: '运营负责人',
sourceRecordId: 'BAR:2026-10-11T00:00:00.000Z', sourceUpdatedAt: '2026-10-11T00:00:00.000Z',
})
expect(await new ProductArchiveOwnerWorkflowRepositoryClass().applyAssignment(assignment)).toBe('newer-source')
expect(tx.supplyChainProductArchive.update).not.toHaveBeenCalled()
})
it('lets an approval approved after the manual import override it', async () => {
// 上传时间 2026-10-08,审批完成时间 2026-10-09:新审批正常覆盖
const tx = setup('上传负责人', {
sourceCode: 'MANUAL_IMPORT', sourceColumn: '运营负责人',
sourceRecordId: 'BAR:2026-10-08T00:00:00.000Z', sourceUpdatedAt: '2026-10-08T00:00:00.000Z',
})
expect(await new ProductArchiveOwnerWorkflowRepositoryClass().applyAssignment(assignment)).toBe('changed')
expect(tx.supplyChainProductArchive.update).toHaveBeenCalled()
})
})

View File

@ -143,28 +143,37 @@ describe('SupplyChainManagementOwnerConfigRepository', () => {
}))
})
it('同步时保留离职人员原数据,只更新在职和新入职人员', async () => {
it('同步时在职与已离职人员都建行/更新,离职行同样刷新层级', async () => {
const items = [
{ employeeId: 'active-employee', operationOwner: '在职员工', department: '新部门', secondaryDepartment: '新二级部门', profitCenter: '新中心', departmentOwner: '新主管', generalOwner: '新总负责人', employmentStatus: '在职' as const },
{ employeeId: 'departed-employee', operationOwner: '离职员工', department: '已变化部门', secondaryDepartment: '已变化二级部门', profitCenter: '已变化中心', departmentOwner: '已变化主管', generalOwner: '已变化总负责人', employmentStatus: '离职' as const },
]
await expect(supplyChainManagementOwnerConfigRepository.syncDirectoryConfigs(items))
.resolves.toEqual({ synced: 1, removed: 0, preserved: 0 })
.resolves.toEqual({ synced: 2, removed: 0, preserved: 0 })
expect(mockOwnerConfig.findMany).toHaveBeenCalledWith({
where: { sourceEmployeeId: { in: ['active-employee'] } },
where: { sourceEmployeeId: { in: ['active-employee', 'departed-employee'] } },
select: { sourceEmployeeId: true, manualOverride: true, isHidden: true },
})
expect(mockOwnerConfig.upsert).toHaveBeenCalledTimes(1)
expect(mockOwnerConfig.upsert).toHaveBeenCalledTimes(2)
expect(mockOwnerConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { sourceEmployeeId: 'active-employee' },
}))
expect(mockOwnerConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { sourceEmployeeId: 'departed-employee' },
update: expect.objectContaining({
department: '已变化部门',
departmentOwner: '已变化主管',
generalOwner: '已变化总负责人',
}),
}))
// 清理名单包含在职与离职两类员工 ID;DIMISSIONED 保护条件仍然保留
expect(mockOwnerConfig.deleteMany).toHaveBeenCalledWith({
where: {
manualOverride: false,
sourceEmployee: { is: { sourceStatus: { not: 'DIMISSIONED' } } },
sourceEmployeeId: { notIn: ['active-employee'] },
sourceEmployeeId: { notIn: ['active-employee', 'departed-employee'] },
},
})
})

View File

@ -7,6 +7,7 @@ import {
parseProductArchiveFieldSourceMap,
toProductArchiveFieldSourceJson,
} from '@/lib/supply-chain-product-archive-field-source'
import { MANUAL_IMPORT_ARCHIVE_SOURCE_CODE } from '@/lib/supply-chain-product-archive-field-source'
import {
OWNER_WORKFLOW_SOURCE_CODE,
type OwnerWorkflowAssignment,
@ -39,6 +40,10 @@ export class ProductArchiveOwnerWorkflowRepositoryClass implements ProductArchiv
if (actualCode !== change.code) throw new Error(`Archive identity changed: ${previous.barcode}`)
const sources = parseProductArchiveFieldSourceMap(previous.fieldSourceMap)
const stored = sources.opsHead
// 上传替换(MANUAL_IMPORT)是新的历史基准:审批完成时间不晚于上传时间的
// 历史审批不再覆盖;晚于上传时间的新审批正常覆盖(2026-10-12 口径)。
if (stored?.sourceCode === MANUAL_IMPORT_ARCHIVE_SOURCE_CODE &&
Date.parse(stored.sourceUpdatedAt) >= Date.parse(change.completedAt)) return 'newer-source'
if (stored?.sourceCode === OWNER_WORKFLOW_SOURCE_CODE &&
Date.parse(stored.sourceUpdatedAt) > Date.parse(change.completedAt)) return 'newer-source'
if (stored?.sourceCode === OWNER_WORKFLOW_SOURCE_CODE && stored.sourceUpdatedAt === change.completedAt) {

View File

@ -80,6 +80,14 @@ export interface SupplyChainManagementOwnerConfigRepository {
listOwnerHierarchy(): Promise<
Array<{ operationOwner: string; departmentOwner: string; generalOwner: string }>
>
listOwnerEmployment(): Promise<
Array<{
name: string
sourceStatus: 'EMPLOYED' | 'DIMISSIONED'
departmentOwner: string
generalOwner: string
}>
>
listOwnerOptions(): Promise<
Array<{
operationOwner: string
@ -172,11 +180,13 @@ class SupplyChainManagementOwnerConfigRepositoryClass
}
async syncDirectoryConfigs(items: SupplyChainManagementOwnerDirectorySyncInput[]) {
const activeItems = items.filter((item) => item.employmentStatus === '在职')
const activeEmployeeIds = activeItems.map((item) => item.employeeId)
const existingRecords = activeEmployeeIds.length > 0
// 2026-10-12 口径:在职与已离职员工都同步进人员信息表;离职行保留部门并按当前在职
// 角色刷新部门负责人/总负责人,供全量档案负责人在职状态识别与离职接管链匹配。
// 人工维护(manualOverride)行照旧跳过自动字段覆盖,隐藏行照旧跳过。
const syncedEmployeeIds = items.map((item) => item.employeeId)
const existingRecords = syncedEmployeeIds.length > 0
? await prisma.supplyChainManagementOwnerConfig.findMany({
where: { sourceEmployeeId: { in: activeEmployeeIds } },
where: { sourceEmployeeId: { in: syncedEmployeeIds } },
select: { sourceEmployeeId: true, manualOverride: true, isHidden: true },
})
: []
@ -187,8 +197,8 @@ class SupplyChainManagementOwnerConfigRepositoryClass
record.sourceEmployeeId && record.manualOverride ? [record.sourceEmployeeId] : [],
))
const batchSize = 50
for (let start = 0; start < activeItems.length; start += batchSize) {
const batch = activeItems.slice(start, start + batchSize)
for (let start = 0; start < items.length; start += batchSize) {
const batch = items.slice(start, start + batchSize)
.filter((item) => !existingByEmployeeId.get(item.employeeId)?.isHidden)
await Promise.all(batch.map((item) =>
prisma.supplyChainManagementOwnerConfig.upsert({
@ -221,17 +231,18 @@ class SupplyChainManagementOwnerConfigRepositoryClass
))
}
// 清理只针对「未离职但已不在本轮同步名单」的行;已离职(DIMISSIONED)行永久保留。
const removed = await prisma.supplyChainManagementOwnerConfig.deleteMany({
where: {
manualOverride: false,
sourceEmployee: { is: { sourceStatus: { not: 'DIMISSIONED' } } },
...(activeEmployeeIds.length > 0
? { sourceEmployeeId: { notIn: activeEmployeeIds } }
...(syncedEmployeeIds.length > 0
? { sourceEmployeeId: { notIn: syncedEmployeeIds } }
: { sourceEmployeeId: { not: null } }),
},
})
return {
synced: activeItems.length,
synced: items.length,
removed: removed.count,
preserved: manuallyMaintainedEmployeeIds.size,
}
@ -298,6 +309,42 @@ class SupplyChainManagementOwnerConfigRepositoryClass
return Array.from(byOwner.values())
}
/** 全量档案负责人在职状态判定用:花名 → 在职状态 + 部门/总负责人(同名取最新一条)。 */
async listOwnerEmployment() {
const records = await prisma.supplyChainManagementOwnerConfig.findMany({
where: { isHidden: false, operationOwner: { not: '' } },
select: {
operationOwner: true,
departmentOwner: true,
generalOwner: true,
sourceEmployee: { select: { sourceStatus: true } },
updatedAt: true,
},
orderBy: [{ operationOwner: 'asc' }, { updatedAt: 'desc' }],
})
const byOwner = new Map<
string,
{
name: string
sourceStatus: 'EMPLOYED' | 'DIMISSIONED'
departmentOwner: string
generalOwner: string
}
>()
for (const record of records) {
if (!byOwner.has(record.operationOwner)) {
byOwner.set(record.operationOwner, {
name: record.operationOwner,
// 无员工目录关联的行(人工建)状态未知,按在职处理:不标注、不接管
sourceStatus: record.sourceEmployee?.sourceStatus === 'DIMISSIONED' ? 'DIMISSIONED' : 'EMPLOYED',
departmentOwner: record.departmentOwner ?? '',
generalOwner: record.generalOwner ?? '',
})
}
}
return Array.from(byOwner.values())
}
async listDirectoryContext() {
const [employees, departments] = await Promise.all([
prisma.employeeDirectory.findMany({

View File

@ -69,6 +69,7 @@ const syncTargetSelect = {
opsDeptHead: true,
opsHead: true,
inOutWarehouse: true,
fieldSourceMap: true,
} satisfies Prisma.SupplyChainProductArchiveSelect
export type ProductArchiveEnrichmentTarget =

View File

@ -10,6 +10,12 @@ import {
type ProductArchiveFilterOptionKey,
type ProductArchiveTextFilterKey,
} from '@/lib/supply-chain-product-archive'
import {
OWNER_EMPLOYMENT_STATUS_VALUES,
buildOwnerEmploymentIndex,
resolveOwnerTakeover,
type OwnerEmploymentStatus,
} from '@/lib/supply-chain-owner-employment'
function parseDateBoundary(value: string, endOfDay = false) {
return new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}`)
@ -67,6 +73,7 @@ export function buildProductArchiveWhere(
{ spu: { contains: keyword, mode: 'insensitive' } },
{ sku: { contains: keyword, mode: 'insensitive' } },
{ productName: { contains: keyword, mode: 'insensitive' } },
{ supplierName: { contains: keyword, mode: 'insensitive' } },
],
})
}
@ -306,11 +313,77 @@ async function resolveNormalCodeClauses(
return clauses
}
/**
* 「负责人在职状态」是虚拟筛选:在职状态不存在档案表上,而是把人员信息表的
* 花名状态与档案 opsHead 的全部去重取值做交叉分类,再把命中的原始取值
* (含零宽字符等变体)拼成 `in` 条件。人员状态与档案负责人取值都只在同步/
* 导入时变化,按列缓存几分钟,避免每次翻页重复分类。
*/
const OWNER_EMPLOYMENT_CACHE_TTL_MS = 5 * 60 * 1000
const ownerEmploymentCache = new Map<string, { values: string[]; status: OwnerEmploymentStatus[]; expiresAt: number }>()
async function listDistinctOpsHeadValues(): Promise<string[]> {
const rows = await prisma.$queryRaw<Array<{ value: string | null }>>(Prisma.sql`
SELECT DISTINCT COALESCE("opsHead", '') AS value
FROM "SupplyChainProductArchive"
`)
return rows.map((row) => row.value ?? '')
}
async function listOwnerEmploymentStatusClasses(): Promise<{ values: string[]; status: OwnerEmploymentStatus[] }> {
const cached = ownerEmploymentCache.get('opsHead')
if (cached && cached.expiresAt > Date.now()) return { values: cached.values, status: cached.status }
const [ownerRows, opsHeadValues] = await Promise.all([
prisma.supplyChainManagementOwnerConfig.findMany({
where: { isHidden: false, operationOwner: { not: '' } },
select: {
operationOwner: true,
departmentOwner: true,
generalOwner: true,
sourceEmployee: { select: { sourceStatus: true } },
updatedAt: true,
},
orderBy: [{ operationOwner: 'asc' }, { updatedAt: 'desc' }],
}),
listDistinctOpsHeadValues(),
])
const index = buildOwnerEmploymentIndex(ownerRows.map((row) => ({
name: row.operationOwner,
sourceStatus: row.sourceEmployee?.sourceStatus === 'DIMISSIONED' ? 'DIMISSIONED' as const : 'EMPLOYED' as const,
departmentOwner: row.departmentOwner ?? '',
generalOwner: row.generalOwner ?? '',
})))
const status = opsHeadValues.map((value) => resolveOwnerTakeover(value, index).status)
const snapshot = { values: opsHeadValues, status, expiresAt: Date.now() + OWNER_EMPLOYMENT_CACHE_TTL_MS }
ownerEmploymentCache.set('opsHead', snapshot)
return { values: opsHeadValues, status }
}
/** 供负责人导入等会改写 opsHead 的路径清缓存用。 */
export function clearOwnerEmploymentValueCache() {
ownerEmploymentCache.delete('opsHead')
}
async function resolveOwnerEmploymentClauses(
filters: ProductArchiveFilters,
): Promise<Prisma.SupplyChainProductArchiveWhereInput[]> {
const selected = new Set((filters.ownerEmploymentStatus ?? []).filter((value) =>
(OWNER_EMPLOYMENT_STATUS_VALUES as readonly string[]).includes(value),
))
if (selected.size === 0) return []
const { values, status } = await listOwnerEmploymentStatusClasses()
const matchedRawValues = values.filter((_, index) => selected.has(status[index]))
// 选中状态当前无人命中时给一个恒假条件,保持「筛选已生效但 0 行」的语义
return [{ opsHead: { in: matchedRawValues } }]
}
/**
* 带「文本规则」的取数条件。
*
* `buildProductArchiveWhere` 保持同步(只有纯 Prisma 条件,老调用点与老测试不受影响);
* 需要查库的「只看正常编码」在这里补齐,再与基础条件合并。
* 需要查库的「只看正常编码」「负责人在职状态」在这里补齐,再与基础条件合并。
* 档案的列表、候选项、导出、图片导出都经由 preparePeriodArchiveQuery 走这一个入口。
*/
export async function buildProductArchiveWhereWithTextRules(
@ -319,5 +392,7 @@ export async function buildProductArchiveWhereWithTextRules(
): Promise<Prisma.SupplyChainProductArchiveWhereInput> {
const base = buildProductArchiveWhere(filters, omitFilter)
const extra = await resolveNormalCodeClauses(filters)
return extra.length > 0 ? { AND: [base, ...extra] } : base
const ownerClauses = await resolveOwnerEmploymentClauses(filters)
const asyncClauses = [...extra, ...ownerClauses]
return asyncClauses.length > 0 ? { AND: [base, ...asyncClauses] } : base
}

View File

@ -0,0 +1,228 @@
import { OperationLogAction, OperationLogStatus, Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE } from '@/lib/supply-chain-product-archive-change-log'
import {
MANUAL_IMPORT_ARCHIVE_SOURCE_CODE,
SUPPLY_CHAIN_OWNER_CONFIG_ARCHIVE_SOURCE_CODE,
createProductArchiveFieldSourceMap,
mergeProductArchiveFieldSourceMaps,
parseProductArchiveFieldSourceMap,
toProductArchiveFieldSourceJson,
} from '@/lib/supply-chain-product-archive-field-source'
import type { ProductArchiveChangeActor } from '@/server/services/supply-chain-product-archive-change-log.service'
export type ProductArchiveOwnerImportFieldChange = {
field: 'opsHead' | 'designer' | 'opsDeptHead' | 'opsDirector'
fieldLabel: string
previousValue: string
nextValue: string
}
type ArchiveSnapshot = {
id: string
barcode: string
opsHead: string
opsDeptHead: string
opsDirector: string
designer: string
fieldSourceMap: unknown
}
const archiveSnapshotSelect = {
id: true,
barcode: true,
opsHead: true,
opsDeptHead: true,
opsDirector: true,
designer: true,
fieldSourceMap: true,
} satisfies Prisma.SupplyChainProductArchiveSelect
export interface ProductArchiveOwnerImportRepository {
findByBarcode(barcode: string): Promise<ArchiveSnapshot | null>
applyOwnerUpdate(input: {
archiveId: string
opsHead?: string
designer?: string
occurredAt: Date
rowNumber: number
actor: ProductArchiveChangeActor
}): Promise<ProductArchiveOwnerImportFieldChange[]>
applyHierarchyUpdate(input: {
archiveId: string
opsDeptHead?: string
opsDirector?: string
occurredAt: Date
actor: ProductArchiveChangeActor
}): Promise<ProductArchiveOwnerImportFieldChange[]>
}
function fieldLabel(field: ProductArchiveOwnerImportFieldChange['field']) {
if (field === 'opsHead') return '运营负责人'
if (field === 'designer') return '设计师'
if (field === 'opsDeptHead') return '运营部门负责人'
return '运营总负责人'
}
async function writeChangeLog(
tx: Prisma.TransactionClient,
input: {
barcode: string
actor: ProductArchiveChangeActor
changes: readonly ProductArchiveOwnerImportFieldChange[]
occurredAt: Date
rowNumber?: number
messageKind: '上传替换' | '上传后层级刷新'
},
) {
await tx.operationLog.create({
data: {
action: OperationLogAction.INTAKE_PRODUCT_WRITE,
status: OperationLogStatus.SUCCESS,
source: 'USER',
actorId: input.actor.actorId ?? null,
actorName: input.actor.actorName,
skuCode: input.barcode,
workspaceCategory: PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE,
message: `${input.messageKind}:${input.changes.map((change) => change.fieldLabel).join('、')}已更新`,
details: {
kind: 'UPDATED',
barcode: input.barcode,
field: input.changes.length === 1 ? input.changes[0].field : undefined,
fieldLabel: input.changes.length === 1 ? input.changes[0].fieldLabel : undefined,
fields: input.changes.map((change) => change.field),
previousValues: Object.fromEntries(input.changes.map((change) => [change.field, change.previousValue])),
nextValues: Object.fromEntries(input.changes.map((change) => [change.field, change.nextValue])),
sourceSystem: '用户上传',
sourceRecordedAt: input.occurredAt.toISOString(),
...(input.rowNumber !== undefined ? { importRow: input.rowNumber } : {}),
},
},
})
}
export class ProductArchiveOwnerImportRepositoryClass implements ProductArchiveOwnerImportRepository {
async findByBarcode(barcode: string) {
return prisma.supplyChainProductArchive.findUnique({
where: { barcode },
select: archiveSnapshotSelect,
})
}
/**
* 上传替换运营负责人/设计师:值、字段来源(MANUAL_IMPORT + 上传时间戳,作为新的
* 历史基准)与变更日志在同一事务内落库。值未变化时不动库、不写日志。
*/
async applyOwnerUpdate(input: {
archiveId: string
opsHead?: string
designer?: string
occurredAt: Date
rowNumber: number
actor: ProductArchiveChangeActor
}) {
return prisma.$transaction(async (tx) => {
const previous = await tx.supplyChainProductArchive.findUniqueOrThrow({
where: { id: input.archiveId },
select: archiveSnapshotSelect,
})
const changes: ProductArchiveOwnerImportFieldChange[] = []
if (input.opsHead !== undefined && input.opsHead !== previous.opsHead) {
changes.push({ field: 'opsHead', fieldLabel: fieldLabel('opsHead'), previousValue: previous.opsHead, nextValue: input.opsHead })
}
if (input.designer !== undefined && input.designer !== previous.designer) {
changes.push({ field: 'designer', fieldLabel: fieldLabel('designer'), previousValue: previous.designer, nextValue: input.designer })
}
if (changes.length === 0) return []
const sourceColumns: Record<string, string> = {}
if (changes.some((change) => change.field === 'opsHead')) sourceColumns.opsHead = '运营负责人'
if (changes.some((change) => change.field === 'designer')) sourceColumns.designer = '设计师'
const incoming = createProductArchiveFieldSourceMap({
fields: sourceColumns,
sourceCode: MANUAL_IMPORT_ARCHIVE_SOURCE_CODE,
sourceSystem: '用户上传',
sourceSchema: 'product-archive-owner-import',
sourceObject: 'OwnerImport',
sourceRecordId: `${previous.barcode}:${input.occurredAt.toISOString()}`,
sourceUpdatedAt: input.occurredAt,
})
await tx.supplyChainProductArchive.update({
where: { id: previous.id },
data: {
...(changes.some((change) => change.field === 'opsHead') ? { opsHead: input.opsHead } : {}),
...(changes.some((change) => change.field === 'designer') ? { designer: input.designer } : {}),
fieldSourceMap: toProductArchiveFieldSourceJson(
mergeProductArchiveFieldSourceMaps(parseProductArchiveFieldSourceMap(previous.fieldSourceMap), incoming),
),
},
})
await writeChangeLog(tx, {
barcode: previous.barcode,
actor: input.actor,
changes,
occurredAt: input.occurredAt,
rowNumber: input.rowNumber,
messageKind: '上传替换',
})
return changes
})
}
/** 上传成功后立即按人员信息层级刷新部门负责人/总负责人(与每日 enrichment 同口径)。 */
async applyHierarchyUpdate(input: {
archiveId: string
opsDeptHead?: string
opsDirector?: string
occurredAt: Date
actor: ProductArchiveChangeActor
}) {
return prisma.$transaction(async (tx) => {
const previous = await tx.supplyChainProductArchive.findUniqueOrThrow({
where: { id: input.archiveId },
select: archiveSnapshotSelect,
})
const changes: ProductArchiveOwnerImportFieldChange[] = []
if (input.opsDeptHead !== undefined && input.opsDeptHead !== previous.opsDeptHead) {
changes.push({ field: 'opsDeptHead', fieldLabel: fieldLabel('opsDeptHead'), previousValue: previous.opsDeptHead, nextValue: input.opsDeptHead })
}
if (input.opsDirector !== undefined && input.opsDirector !== previous.opsDirector) {
changes.push({ field: 'opsDirector', fieldLabel: fieldLabel('opsDirector'), previousValue: previous.opsDirector, nextValue: input.opsDirector })
}
if (changes.length === 0) return []
const sourceColumns: Record<string, string> = {}
if (changes.some((change) => change.field === 'opsDeptHead')) sourceColumns.opsDeptHead = 'departmentOwner'
if (changes.some((change) => change.field === 'opsDirector')) sourceColumns.opsDirector = 'generalOwner'
const incoming = createProductArchiveFieldSourceMap({
fields: sourceColumns,
sourceCode: SUPPLY_CHAIN_OWNER_CONFIG_ARCHIVE_SOURCE_CODE,
sourceSystem: 'SupplyChainManagementOwnerConfig',
sourceSchema: 'public',
sourceObject: 'SupplyChainManagementOwnerConfig',
sourceRecordId: previous.barcode,
sourceUpdatedAt: input.occurredAt,
})
await tx.supplyChainProductArchive.update({
where: { id: previous.id },
data: {
...(changes.some((change) => change.field === 'opsDeptHead') ? { opsDeptHead: input.opsDeptHead } : {}),
...(changes.some((change) => change.field === 'opsDirector') ? { opsDirector: input.opsDirector } : {}),
fieldSourceMap: toProductArchiveFieldSourceJson(
mergeProductArchiveFieldSourceMaps(parseProductArchiveFieldSourceMap(previous.fieldSourceMap), incoming),
),
},
})
await writeChangeLog(tx, {
barcode: previous.barcode,
actor: input.actor,
changes,
occurredAt: input.occurredAt,
messageKind: '上传后层级刷新',
})
return changes
})
}
}
export const productArchiveOwnerImportRepository = new ProductArchiveOwnerImportRepositoryClass()

View File

@ -68,6 +68,50 @@ describe('decideProductArchiveDesignerFill', () => {
}),
).toEqual({ kind: 'conflict-kept' })
})
it('skips rows whose designer came from a newer manual import', () => {
expect(
decideProductArchiveDesignerFill({
currentDesigner: '上传设计师',
sourceDesigner: '聚水潭设计师',
conflictDesigner: '辛环',
storedDesignerSource: {
sourceCode: 'MANUAL_IMPORT',
sourceUpdatedAt: '2026-10-11T00:00:00.000Z',
},
sourceRecordedAt: '2026-10-09T00:00:00.000Z',
}),
).toEqual({ kind: 'skip' })
})
it('lets an updated source row win over an older manual import', () => {
expect(
decideProductArchiveDesignerFill({
currentDesigner: '上传设计师',
sourceDesigner: '聚水潭设计师',
conflictDesigner: '辛环',
storedDesignerSource: {
sourceCode: 'MANUAL_IMPORT',
sourceUpdatedAt: '2026-10-08T00:00:00.000Z',
},
sourceRecordedAt: '2026-10-11T00:00:00.000Z',
}),
).toEqual({ kind: 'override', designer: '辛环' })
})
it('applies normal rules when the stored source is not a manual import', () => {
expect(
decideProductArchiveDesignerFill({
currentDesigner: '',
sourceDesigner: '灵汐',
storedDesignerSource: {
sourceCode: 'INTAKE_NON_INTEGRATED_PROFILE',
sourceUpdatedAt: '2026-10-11T00:00:00.000Z',
},
sourceRecordedAt: '2026-10-09T00:00:00.000Z',
}),
).toEqual({ kind: 'fill', designer: '灵汐' })
})
})
function createDependencies(input: {

View File

@ -0,0 +1,128 @@
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/server/repositories/supply-chain-product-archive-filters', () => ({
clearOwnerEmploymentValueCache: jest.fn(),
}))
jest.mock('@/server/repositories/supply-chain-management-owner-config.repository', () => ({
supplyChainManagementOwnerConfigRepository: {},
}))
import { clearOwnerEmploymentValueCache } from '@/server/repositories/supply-chain-product-archive-filters'
import type { AuthUser } from '@/server/core/types'
import type { ProductArchiveOwnerImportRow } from '@/lib/supply-chain-product-archive-owner-import'
import {
SupplyChainProductArchiveOwnerImportService,
} from '../supply-chain-product-archive-owner-import.service'
const user: AuthUser = {
id: 'user-1',
name: '测试用户',
} as unknown as AuthUser
function setup(input: {
ownerEntries?: Array<{ name: string; sourceStatus?: 'EMPLOYED' | 'DIMISSIONED'; departmentOwner?: string; generalOwner?: string }>
hierarchy?: Array<{ operationOwner: string; departmentOwner: string; generalOwner: string }>
archives?: Record<string, { id: string; opsHead: string; designer: string }>
}) {
const ownerEntries = (input.ownerEntries ?? [{ name: '半夏' }, { name: '五竹', sourceStatus: 'DIMISSIONED' }])
.map((entry) => ({
name: entry.name,
sourceStatus: entry.sourceStatus ?? 'EMPLOYED' as const,
departmentOwner: entry.departmentOwner ?? '',
generalOwner: entry.generalOwner ?? '',
}))
const hierarchy = input.hierarchy ?? [{ operationOwner: '半夏', departmentOwner: '半夏', generalOwner: '江陵' }]
const applied: Array<{ archiveId: string; opsHead?: string; designer?: string }> = []
const hierarchyApplied: Array<{ archiveId: string; opsDeptHead?: string; opsDirector?: string }> = []
const repository = {
findByBarcode: jest.fn(async (barcode: string) => {
const archive = (input.archives ?? {})[barcode]
return archive ? { id: archive.id, barcode, opsHead: archive.opsHead, opsDeptHead: '', opsDirector: '', designer: archive.designer, fieldSourceMap: {} } : null
}),
applyOwnerUpdate: jest.fn(async ({ archiveId, opsHead, designer }: { archiveId: string; opsHead?: string; designer?: string }) => {
const current = Object.values(input.archives ?? {}).find((archive) => archive.id === archiveId)
const changes = []
if (opsHead !== undefined && opsHead !== current?.opsHead) changes.push({ field: 'opsHead' })
if (designer !== undefined && designer !== current?.designer) changes.push({ field: 'designer' })
if (changes.length) applied.push({ archiveId, opsHead, designer })
return changes.map((change) => ({ field: change.field, fieldLabel: change.field, previousValue: '', nextValue: '' }))
}),
applyHierarchyUpdate: jest.fn(async (payload: { archiveId: string; opsDeptHead?: string; opsDirector?: string }) => {
hierarchyApplied.push(payload)
return []
}),
}
const ownerConfigRepository = {
listOwnerEmployment: jest.fn().mockResolvedValue(ownerEntries),
listOwnerHierarchy: jest.fn().mockResolvedValue(hierarchy),
}
const service = new SupplyChainProductArchiveOwnerImportService(
repository as never,
ownerConfigRepository as never,
)
return { service, repository, ownerConfigRepository, applied, hierarchyApplied }
}
const row = (partial: Partial<ProductArchiveOwnerImportRow> & { barcode: string }): ProductArchiveOwnerImportRow => ({
rowNumber: partial.rowNumber ?? 2,
barcode: partial.barcode,
opsHead: partial.opsHead ?? '',
designer: partial.designer ?? '',
})
describe('SupplyChainProductArchiveOwnerImportService', () => {
beforeEach(() => jest.clearAllMocks())
it('替换运营负责人并按人员信息刷新层级', async () => {
const { service, applied, hierarchyApplied } = setup({
archives: { 'BAR-1': { id: 'a1', opsHead: '旧负责人', designer: '' } },
})
const result = await service.importRows([row({ barcode: 'BAR-1', opsHead: '半夏' })], user)
expect(result).toMatchObject({ processed: 1, updated: 1, skipped: 0, failed: 0 })
expect(applied).toEqual([{ archiveId: 'a1', opsHead: '半夏', designer: undefined }])
expect(hierarchyApplied).toEqual([
expect.objectContaining({ archiveId: 'a1', opsDeptHead: '半夏', opsDirector: '江陵' }),
])
expect(clearOwnerEmploymentValueCache).toHaveBeenCalled()
})
it('设计师自由文本,负责人严格校验人员信息', async () => {
const { service } = setup({ archives: { 'BAR-1': { id: 'a1', opsHead: '', designer: '' } } })
const result = await service.importRows([
row({ barcode: 'BAR-1', designer: '外部设计师' }),
row({ rowNumber: 3, barcode: 'BAR-1', opsHead: '不存在的人' }),
], user)
expect(result.updated).toBe(1)
expect(result.failed).toBe(1)
expect(result.errors[0]).toMatchObject({ rowNumber: 3, barcode: 'BAR-1' })
expect(result.errors[0].message).toContain('不在人员信息中')
})
it('上传已离职负责人:该行成功并给出接管提示', async () => {
const { service } = setup({ archives: { 'BAR-1': { id: 'a1', opsHead: '', designer: '' } } })
const result = await service.importRows([row({ barcode: 'BAR-1', opsHead: '五竹' })], user)
expect(result.updated).toBe(1)
expect(result.warnings[0].message).toContain('已离职')
})
it('条码不存在与两列全空的行分别计失败与跳过', async () => {
const { service } = setup({ archives: { 'BAR-1': { id: 'a1', opsHead: '', designer: '' } } })
const result = await service.importRows([
row({ barcode: 'BAR-X', opsHead: '半夏' }),
row({ barcode: 'BAR-1' }),
row({ rowNumber: 4, barcode: '' , opsHead: '半夏' }),
], user)
expect(result).toMatchObject({ processed: 3, updated: 0, skipped: 1, failed: 2 })
expect(result.errors.map((error) => error.message)).toEqual(
expect.arrayContaining([expect.stringContaining('不存在于产品档案'), '条码为空']),
)
})
it('值与现值相同的行计跳过;层级刷新失败不影响导入结果', async () => {
const { service, repository } = setup({ archives: { 'BAR-1': { id: 'a1', opsHead: '半夏', designer: '' } } })
repository.applyHierarchyUpdate.mockRejectedValue(new Error('hierarchy down'))
const result = await service.importRows([row({ barcode: 'BAR-1', opsHead: '半夏' })], user)
expect(result).toMatchObject({ updated: 0, skipped: 1, failed: 0 })
})
})

View File

@ -1,4 +1,4 @@
import { JST_SKU_INFO_MANAGE_ARCHIVE_SOURCE_CODE } from '@/lib/supply-chain-product-archive-field-source'
import { JST_SKU_INFO_MANAGE_ARCHIVE_SOURCE_CODE, MANUAL_IMPORT_ARCHIVE_SOURCE_CODE, parseProductArchiveFieldSourceMap } from '@/lib/supply-chain-product-archive-field-source'
import {
supplyChainProductArchiveEnrichmentSyncRepository,
type ProductArchiveEnrichmentPatch,
@ -30,10 +30,31 @@ export function decideProductArchiveDesignerFill(input: {
currentDesigner: string | null | undefined
sourceDesigner: string | null | undefined
conflictDesigner?: string | null
/** 档案当前设计师的来源信息(fieldSourceMap.designer),用于识别上传基准 */
storedDesignerSource?: { sourceCode: string; sourceUpdatedAt: string } | null
/** 聚水潭源行的更新时间;与上传时间戳比较决定谁新 */
sourceRecordedAt?: Date | string | null
}): ProductArchiveDesignerFillDecision {
const sourceDesigner = input.sourceDesigner?.trim() ?? ''
if (!sourceDesigner) return { kind: 'skip' }
// 上传替换(MANUAL_IMPORT)是新的历史基准:聚水潭行更新时间不晚于上传时间时
// 不动档案设计师;聚水潭行后来又更新过才按原规则参与填空/冲突处理。
if (
input.storedDesignerSource?.sourceCode === MANUAL_IMPORT_ARCHIVE_SOURCE_CODE
&& input.sourceRecordedAt
) {
const uploadedAt = Date.parse(input.storedDesignerSource.sourceUpdatedAt)
const recordedAt = Date.parse(
input.sourceRecordedAt instanceof Date
? input.sourceRecordedAt.toISOString()
: String(input.sourceRecordedAt),
)
if (Number.isFinite(uploadedAt) && Number.isFinite(recordedAt) && uploadedAt >= recordedAt) {
return { kind: 'skip' }
}
}
const currentDesigner = input.currentDesigner?.trim() ?? ''
if (!currentDesigner) return { kind: 'fill', designer: sourceDesigner }
if (currentDesigner === sourceDesigner) return { kind: 'unchanged' }
@ -128,6 +149,8 @@ export class SupplyChainProductArchiveDesignerFillService {
currentDesigner: target.designer,
sourceDesigner: profile.designer,
conflictDesigner,
storedDesignerSource: parseProductArchiveFieldSourceMap(target.fieldSourceMap).designer,
sourceRecordedAt: profile.updatedAt,
})
if (decision.kind === 'skip') continue
if (decision.kind === 'unchanged') {

View File

@ -14,6 +14,16 @@ import {
supplyChainProductArchiveWarehouseConfigRepository,
type ProductArchiveWarehouseConfigRecord,
} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository'
import {
supplyChainManagementOwnerConfigRepository,
type SupplyChainManagementOwnerConfigRepository,
} from '@/server/repositories/supply-chain-management-owner-config.repository'
import {
buildOwnerEmploymentIndex,
resolveDesignerView,
resolveOwnerTakeover,
type OwnerEmploymentIndex,
} from '@/lib/supply-chain-owner-employment'
import {
resolveProductArchiveWarehouseFields,
} from './supply-chain-product-archive.service'
@ -128,6 +138,10 @@ export class SupplyChainProductArchiveExportService {
'listActive'
> = supplyChainProductArchiveWarehouseConfigRepository,
private readonly now: () => Date = () => new Date(),
private readonly ownerEmploymentRepository: Pick<
SupplyChainManagementOwnerConfigRepository,
'listOwnerEmployment'
> | null = null,
) {}
/**
@ -135,6 +149,8 @@ export class SupplyChainProductArchiveExportService {
*
* 仓库配置只在开头取一次(和旧实现一致),不能每行去查;映射器本身无状态,
* 可以跨批复用。`now` 也在这里固定,保证同一份导出里所有相对时间字段口径一致。
* 人员信息在职状态同样只取一次:导出里运营负责人/设计师列直接写「接管展示值」,
* 与页面同一口径(2026-10-12)。
*/
private async createRowMapper() {
let warehouseConfigs: ProductArchiveWarehouseConfigRecord[] = []
@ -143,6 +159,16 @@ export class SupplyChainProductArchiveExportService {
} catch (error) {
console.warn('[product-archive-export] warehouse config lookup failed', error)
}
let ownerEmploymentIndex: OwnerEmploymentIndex | null = null
if (this.ownerEmploymentRepository) {
try {
ownerEmploymentIndex = buildOwnerEmploymentIndex(
await this.ownerEmploymentRepository.listOwnerEmployment(),
)
} catch (error) {
console.warn('[product-archive-export] owner employment lookup failed', error)
}
}
const warehouseConfigByName = new Map(
warehouseConfigs.map((config) => [normalizeWarehouseName(config.warehouseName), config]),
)
@ -157,9 +183,17 @@ export class SupplyChainProductArchiveExportService {
currentStorageType: row.storageType,
config,
})
if (!ownerEmploymentIndex) {
return {
...row,
...warehouseFields,
} satisfies SupplyChainProductArchiveRow
}
return {
...row,
...warehouseFields,
opsHead: resolveOwnerTakeover(record.opsHead ?? '', ownerEmploymentIndex).display || row.opsHead,
designer: resolveDesignerView(record.designer ?? '', ownerEmploymentIndex).display || row.designer,
} satisfies SupplyChainProductArchiveRow
}
}
@ -250,4 +284,9 @@ export class SupplyChainProductArchiveExportService {
}
export const supplyChainProductArchiveExportService =
new SupplyChainProductArchiveExportService()
new SupplyChainProductArchiveExportService(
undefined,
undefined,
undefined,
supplyChainManagementOwnerConfigRepository,
)

View File

@ -0,0 +1,153 @@
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
normalizeOwnerName,
type OwnerEmploymentEntry,
} from '@/lib/supply-chain-owner-employment'
import type { ProductArchiveOwnerImportProgress, ProductArchiveOwnerImportRow } from '@/lib/supply-chain-product-archive-owner-import'
import {
productArchiveOwnerImportRepository,
type ProductArchiveOwnerImportRepository,
} from '@/server/repositories/supply-chain-product-archive-owner-import.repository'
import {
supplyChainManagementOwnerConfigRepository,
type SupplyChainManagementOwnerConfigRepository,
} from '@/server/repositories/supply-chain-management-owner-config.repository'
import { clearOwnerEmploymentValueCache } from '@/server/repositories/supply-chain-product-archive-filters'
import { archiveChangeActorFromUser, type ProductArchiveChangeActor } from './supply-chain-product-archive-change-log.service'
export class SupplyChainProductArchiveOwnerImportService {
constructor(
private readonly repository: ProductArchiveOwnerImportRepository =
productArchiveOwnerImportRepository,
private readonly ownerConfigRepository: Pick<
SupplyChainManagementOwnerConfigRepository,
'listOwnerEmployment' | 'listOwnerHierarchy'
> = supplyChainManagementOwnerConfigRepository,
) {}
async importRows(
rows: ProductArchiveOwnerImportRow[],
user: AuthUser,
onProgress?: (progress: ProductArchiveOwnerImportProgress) => void,
): Promise<ProductArchiveOwnerImportProgress> {
const progress: ProductArchiveOwnerImportProgress = {
processed: 0,
total: rows.length,
updated: 0,
skipped: 0,
failed: 0,
warnings: [],
errors: [],
}
const ownerEntries = await this.loadOwnerEntries()
const ownerByName = new Map(ownerEntries.map((entry) => [normalizeOwnerName(entry.name), entry]))
const actor = archiveChangeActorFromUser(user)
const occurredAt = new Date()
const hierarchyRefreshBarcodes: Array<{ archiveId: string; opsHead: string }> = []
for (const row of rows) {
try {
const barcode = row.barcode.trim()
const opsHead = row.opsHead.trim()
const designer = row.designer.trim()
if (!barcode) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '条码为空')
}
if (!opsHead && !designer) {
// 整行两列都空:不算失败,按跳过计(空单元格本来也不覆盖)
progress.skipped += 1
} else {
if (opsHead) {
const entry = ownerByName.get(normalizeOwnerName(opsHead))
if (!entry) {
throw new AppError(
ErrorCode.SUPPLY_CHAIN_OWNER_CONFIG_NOT_FOUND,
`运营负责人「${opsHead}」不在人员信息中,请先在人员信息补录该人员`,
)
}
if (entry.sourceStatus === 'DIMISSIONED') {
progress.warnings.push({
rowNumber: row.rowNumber,
barcode,
message: `运营负责人「${opsHead}」已离职,页面将按接管链展示`,
})
}
}
const archive = await this.repository.findByBarcode(barcode)
if (!archive) {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, `条码 ${barcode} 不存在于产品档案`)
}
const changes = await this.repository.applyOwnerUpdate({
archiveId: archive.id,
...(opsHead ? { opsHead } : {}),
...(designer ? { designer } : {}),
occurredAt,
rowNumber: row.rowNumber,
actor,
})
if (changes.length === 0) {
progress.skipped += 1
} else {
progress.updated += 1
if (opsHead) hierarchyRefreshBarcodes.push({ archiveId: archive.id, opsHead })
}
}
} catch (error) {
progress.failed += 1
progress.errors.push({
rowNumber: row.rowNumber,
barcode: row.barcode,
message: error instanceof Error ? error.message : '导入失败',
})
}
progress.processed += 1
onProgress?.({ ...progress, warnings: [...progress.warnings], errors: [...progress.errors] })
}
// 上传改了负责人:立即按人员信息刷新该批条码的部门负责人/总负责人,
// 并清掉在职状态筛选缓存,页面筛选立刻感知新负责人,不等第二天同步。
if (hierarchyRefreshBarcodes.length > 0) {
await this.refreshHierarchy(hierarchyRefreshBarcodes, actor, occurredAt)
}
clearOwnerEmploymentValueCache()
return progress
}
private async loadOwnerEntries(): Promise<OwnerEmploymentEntry[]> {
try {
return await this.ownerConfigRepository.listOwnerEmployment()
} catch (error) {
console.warn('[product-archive-owner-import] owner config lookup failed', error)
return []
}
}
private async refreshHierarchy(
targets: Array<{ archiveId: string; opsHead: string }>,
actor: ProductArchiveChangeActor,
occurredAt: Date,
) {
try {
const hierarchy = await this.ownerConfigRepository.listOwnerHierarchy()
const hierarchyByPerson = new Map(hierarchy.map((entry) => [normalizeOwnerName(entry.operationOwner), entry]))
for (const target of targets) {
const entry = hierarchyByPerson.get(normalizeOwnerName(target.opsHead))
if (!entry || (!entry.departmentOwner && !entry.generalOwner)) continue
await this.repository.applyHierarchyUpdate({
archiveId: target.archiveId,
...(entry.departmentOwner ? { opsDeptHead: entry.departmentOwner } : {}),
...(entry.generalOwner ? { opsDirector: entry.generalOwner } : {}),
occurredAt,
actor,
})
}
} catch (error) {
// 层级刷新失败不回滚已成功的负责人替换;每日 enrichment 会自然补齐
console.warn('[product-archive-owner-import] hierarchy refresh failed', error)
}
}
}
export const supplyChainProductArchiveOwnerImportService =
new SupplyChainProductArchiveOwnerImportService()

View File

@ -6,6 +6,7 @@ import {
PRODUCT_ARCHIVE_TEXT_FILTER_KEYS,
type ProductArchiveFilters,
} from '@/lib/supply-chain-product-archive'
import { OWNER_EMPLOYMENT_STATUS_VALUES } from '@/lib/supply-chain-owner-employment'
const archiveDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
const archiveSalesGranularitySchema = z.enum(['day', 'month', 'year'])
@ -70,6 +71,8 @@ const productArchiveFiltersSchema = z.object({
opsDirector: archiveValueArraySchema.optional(),
opsDeptHead: archiveValueArraySchema.optional(),
opsHead: archiveValueArraySchema.optional(),
ownerEmploymentStatus: z.array(z.enum(OWNER_EMPLOYMENT_STATUS_VALUES))
.max(OWNER_EMPLOYMENT_STATUS_VALUES.length).optional(),
hasDrawing: archiveValueArraySchema.optional(),
hasVideo: archiveValueArraySchema.optional(),
warehouseHead: archiveValueArraySchema.optional(),

View File

@ -21,6 +21,13 @@ import {
supplyChainProductArchiveWarehouseConfigRepository,
type ProductArchiveWarehouseConfigRecord,
} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository'
import {
buildOwnerEmploymentIndex,
resolveDesignerView,
resolveOwnerTakeover,
} from '@/lib/supply-chain-owner-employment'
import type { SupplyChainManagementOwnerConfigRepository } from '@/server/repositories/supply-chain-management-owner-config.repository'
import { supplyChainManagementOwnerConfigRepository } from '@/server/repositories/supply-chain-management-owner-config.repository'
export function resolveProductArchiveWarehouseFields(input: {
warehouseName: string
@ -48,8 +55,23 @@ export class SupplyChainProductArchiveService {
typeof supplyChainProductArchiveWarehouseConfigRepository,
'listActive'
> | null = null,
private readonly ownerEmploymentRepository: Pick<
SupplyChainManagementOwnerConfigRepository,
'listOwnerEmployment'
> | null = null,
) {}
/** 人员信息在职状态索引;加载失败时返回 null,页面回退为不标注的原样展示。 */
private async loadOwnerEmploymentIndex() {
if (!this.ownerEmploymentRepository) return null
try {
return buildOwnerEmploymentIndex(await this.ownerEmploymentRepository.listOwnerEmployment())
} catch (error) {
console.warn('[product-archive] owner employment lookup failed', error)
return null
}
}
async listPage(params: {
page: number
pageSize: number
@ -73,6 +95,7 @@ export class SupplyChainProductArchiveService {
const warehouseConfigByName = new Map(
warehouseConfigs.map((config) => [normalizeWarehouseName(config.warehouseName), config]),
)
const ownerEmploymentIndex = await this.loadOwnerEmploymentIndex()
const now = new Date()
return {
salesPeriod,
@ -85,9 +108,18 @@ export class SupplyChainProductArchiveService {
currentStorageType: row.storageType,
config,
})
// 负责人/设计师在职状态标注:索引不可用时缺省,前端回退展示原值
if (!ownerEmploymentIndex) {
return { ...row, ...warehouseFields }
}
const ownerView = resolveOwnerTakeover(record.opsHead ?? '', ownerEmploymentIndex)
const designerView = resolveDesignerView(row.designer ?? '', ownerEmploymentIndex)
return {
...row,
...warehouseFields,
opsHeadDisplay: ownerView.display || row.opsHead,
ownerEmploymentStatus: ownerView.status,
designerDisplay: designerView.display || row.designer,
}
}),
pageInfo: {
@ -122,4 +154,5 @@ export const supplyChainProductArchiveService =
new SupplyChainProductArchiveService(
undefined,
supplyChainProductArchiveWarehouseConfigRepository,
supplyChainManagementOwnerConfigRepository,
)