import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import axios from 'axios' import { CleanupIcon, DownloadIcon, RefreshIcon, CheckIcon, CloseIcon, PencilIcon, SparkIcon, ContractIcon } from '../components/Icons' import { SkuThumb, useSkuThumbs } from '../components/SkuThumb' const JOB_STATUS = { queued: { label: '排队中', tone: 'mute' }, parsing: { label: '解析中', tone: 'warn' }, pending_review: { label: '待复核', tone: 'warn' }, reviewed: { label: '已复核', tone: 'ok' }, parse_failed: { label: '解析失败', tone: 'danger' } } const ITEM_STATUS = { pending_review: { label: '待复核', tone: 'warn' }, approved: { label: '已通过', tone: 'ok' }, rejected: { label: '已驳回', tone: 'danger' } } /** 一条明细的状态显示:入库过的优先显示「已入库」——它比 approved 更具体 */ function itemStatusMeta(item) { if (item.committed_at) return { label: '已入库', tone: 'ok' } return ITEM_STATUS[item.status] || { label: item.status, tone: 'mute' } } function formatMoment(value) { if (!value) return '—' const date = new Date(value) if (Number.isNaN(date.getTime())) return String(value) return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) } function fmtSize(bytes) { if (!Number.isFinite(bytes)) return '—' if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / 1024 / 1024).toFixed(1)} MB` } /** 解析结果表头认了哪几列 —— 规则引擎唯一能自证的现场,认错了就看这里 */ function ColumnMapPanel({ raw }) { const map = useMemo(() => { if (!raw) return null try { const parsed = JSON.parse(raw) const entries = Object.entries(parsed || {}) return entries.length ? entries : null } catch { return null } }, [raw]) if (!map) { return

没有认出任一列 —— 可以用「AI 解析」或逐条手工补录。

} return (
{map.map(([field, hit]) => ( {field} ← {hit.header} {hit.score} 分 ))}
) } /** 单行复核编辑器:点「修正」后就地变成可编辑,避免为了改一个价格再开一层弹窗 */ function ReviewRow({ item, selected, onToggle, onReviewed, onCommitted, thumbs }) { const [editing, setEditing] = useState(false) const [form, setForm] = useState({ sku: item.sku || '', product_name: item.product_name || '', spec: item.spec || '', purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price), currency: item.currency || '', quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity), moq: item.moq === null || item.moq === undefined ? '' : String(item.moq), review_note: item.review_note || '' }) const [saving, setSaving] = useState(false) const [error, setError] = useState('') useEffect(() => { setForm({ sku: item.sku || '', product_name: item.product_name || '', spec: item.spec || '', purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price), currency: item.currency || '', quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity), moq: item.moq === null || item.moq === undefined ? '' : String(item.moq), review_note: item.review_note || '' }) setEditing(false) setError('') }, [item]) const payload = status => ({ status, review_note: form.review_note.trim() || null, sku: form.sku.trim() || null, product_name: form.product_name.trim() || null, spec: form.spec.trim() || null, purchase_price: form.purchase_price === '' ? null : Number(form.purchase_price), currency: form.currency.trim() || null, quantity: form.quantity === '' ? null : Number(form.quantity), moq: form.moq === '' ? null : Number(form.moq) }) /** 只改状态(驳回 / 放回待复核) */ const send = async (status) => { setSaving(true) setError('') try { await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload(status)) setEditing(false) onReviewed() } catch (reviewError) { setError(reviewError?.response?.data?.error || '操作失败,请稍后重试') } finally { setSaving(false) } } /** 保存修正并直接入库:先把改过的字段存回队列,再走入库接口 */ const saveAndCommit = async () => { setSaving(true) setError('') try { await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload('pending_review')) await axios.post('/api/contracts/import-items/commit', { ids: [item.id] }) setEditing(false) onCommitted() } catch (commitError) { setError(commitError?.response?.data?.error || '入库失败,请稍后重试') } finally { setSaving(false) } } /** 不改任何字段,直接把这一条入库 */ const commitOne = async () => { setSaving(true) setError('') try { await axios.post('/api/contracts/import-items/commit', { ids: [item.id] }) onCommitted() } catch (commitError) { setError(commitError?.response?.data?.error || '入库失败,请稍后重试') } finally { setSaving(false) } } const statusMeta = itemStatusMeta(item) const committed = Boolean(item.committed_at) const renamed = committed && item.product_sku && item.sku && item.product_sku !== item.sku if (editing) { return ( {item.filename}{item.row_no ? `#${item.row_no}` : ''} setForm({ ...form, sku: event.target.value })} /> setForm({ ...form, product_name: event.target.value })} /> setForm({ ...form, spec: event.target.value })} /> setForm({ ...form, purchase_price: event.target.value })} /> setForm({ ...form, currency: event.target.value })} /> setForm({ ...form, quantity: event.target.value })} /> setForm({ ...form, moq: event.target.value })} /> setForm({ ...form, review_note: event.target.value })} /> {error &&

{error}

} ) } return ( onToggle(item.id)} aria-label={`选择 ${item.sku || item.filename + '#' + item.row_no}`} /> {item.filename}{item.row_no ? `#${item.row_no}` : ''} {item.sku ? ( {item.sku} {renamed && → {item.product_sku}} ) : 未识别} {item.product_name || 未识别} {item.spec || —} {item.purchase_price === null || item.purchase_price === undefined ? — : item.purchase_price} {item.currency || —} {item.quantity === null || item.quantity === undefined ? '—' : item.quantity} {item.moq === null || item.moq === undefined ? '—' : item.moq} {statusMeta.label} {item.parse_source === 'ai' && AI} {item.review_note || —} ) } function ContractCleanup() { const [jobs, setJobs] = useState([]) const [activeJobId, setActiveJobId] = useState(null) const [jobDetail, setJobDetail] = useState(null) const [loadingJobs, setLoadingJobs] = useState(true) const [loadingDetail, setLoadingDetail] = useState(false) const [uploading, setUploading] = useState(false) const [error, setError] = useState('') const [notice, setNotice] = useState('') const [dragging, setDragging] = useState(false) const [statusFilter, setStatusFilter] = useState('') const [selected, setSelected] = useState(() => new Set()) const [committing, setCommitting] = useState(false) const [aiRunning, setAiRunning] = useState(false) const [commitReport, setCommitReport] = useState(null) const [originals, setOriginals] = useState(null) const [showAll, setShowAll] = useState(false) const fileInputRef = useRef(null) const headPickRef = useRef(null) const PAGE_LIMIT = 300 const loadJobs = useCallback(async (preferId) => { setLoadingJobs(true) try { const { data } = await axios.get('/api/contracts/import-jobs') const rows = Array.isArray(data) ? data : [] setJobs(rows) setActiveJobId(current => { if (preferId) return preferId if (current && rows.some(row => row.id === current)) return current return rows[0]?.id ?? null }) } catch (loadError) { setError(loadError?.response?.data?.error || '读取导入任务失败') } finally { setLoadingJobs(false) } }, []) useEffect(() => { loadJobs() }, [loadJobs]) const loadDetail = useCallback(async (jobId) => { if (!jobId) { setJobDetail(null); return } setLoadingDetail(true) try { const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}`) setJobDetail(data) } catch (loadError) { setError(loadError?.response?.data?.error || '读取任务明细失败') setJobDetail(null) } finally { setLoadingDetail(false) } }, []) useEffect(() => { loadDetail(activeJobId) setSelected(new Set()) setCommitReport(null) setStatusFilter('') setShowAll(false) }, [activeJobId, loadDetail]) /* 原件只在服务器留 7 天,所以「还存在哪些原件」得单独问一次接口 */ const loadOriginals = useCallback(async (jobId) => { if (!jobId) { setOriginals(null); return } try { const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}/files`) setOriginals(data) } catch { setOriginals(null) } }, []) useEffect(() => { loadOriginals(activeJobId) }, [activeJobId, loadOriginals]) const upload = async (fileList) => { const files = [...(fileList || [])] if (!files.length) return setUploading(true) setError('') setNotice('') try { const form = new FormData() files.forEach(file => form.append('files', file)) const { data } = await axios.post('/api/contracts/import', form) const jobId = data?.job?.id await loadJobs(jobId) if (jobId) { await loadDetail(jobId); loadOriginals(jobId) } setNotice(`${files.length} 个文件已解析。${data?.message || ''}`) } catch (uploadError) { setError(uploadError?.response?.data?.error || '上传失败,请稍后重试') } finally { setUploading(false) if (fileInputRef.current) fileInputRef.current.value = '' } } const onDrop = (event) => { event.preventDefault() setDragging(false) upload(event.dataTransfer?.files) } const refreshAfterReview = useCallback(async () => { await loadDetail(activeJobId) // 复核完最后一条,任务状态会从 pending_review 变 reviewed,列表也要跟着刷新 await loadJobs(activeJobId) }, [activeJobId, loadDetail, loadJobs]) const refreshAfterCommit = useCallback(async () => { setSelected(new Set()) await loadDetail(activeJobId) await loadJobs(activeJobId) }, [activeJobId, loadDetail, loadJobs]) const items = jobDetail?.items || [] const filteredItems = useMemo( () => (statusFilter ? items.filter(item => item.status === statusFilter) : items), [items, statusFilter] ) const shownItems = useMemo( () => (showAll ? filteredItems : filteredItems.slice(0, PAGE_LIMIT)), [filteredItems, showAll] ) const counts = useMemo(() => { const result = { total: items.length, pending_review: 0, approved: 0, rejected: 0, committed: 0 } items.forEach(item => { result[item.status] = (result[item.status] || 0) + 1 if (item.committed_at) result.committed += 1 }) return result }, [items]) const thumbs = useSkuThumbs(items.map(item => item.sku).filter(Boolean)) const pickableIds = useMemo( () => filteredItems.filter(item => !item.committed_at).map(item => item.id), [filteredItems] ) const allPicked = pickableIds.length > 0 && pickableIds.every(id => selected.has(id)) const somePicked = pickableIds.some(id => selected.has(id)) useEffect(() => { if (headPickRef.current) headPickRef.current.indeterminate = somePicked && !allPicked }, [somePicked, allPicked]) const togglePick = (id) => { setSelected(current => { const next = new Set(current) if (next.has(id)) next.delete(id); else next.add(id) return next }) } const pickAll = () => setSelected(new Set(pickableIds)) const invertPick = () => setSelected(current => { const next = new Set() pickableIds.forEach(id => { if (!current.has(id)) next.add(id) }) return next }) const clearPick = () => setSelected(new Set()) const commitSelected = async () => { const ids = [...selected] if (!ids.length) { setError('请先勾选要入库的明细'); return } if (!window.confirm(`把勾选的 ${ids.length} 条写进产品库?\n\n已存在的货号会更新采购价并记入调价历史,库里没有的货号会新建。`)) return setCommitting(true) setError('') setNotice('') setCommitReport(null) try { const { data } = await axios.post('/api/contracts/import-items/commit', { ids }) setCommitReport(data) setNotice(data?.message || '入库完成') await refreshAfterCommit() } catch (commitError) { setError(commitError?.response?.data?.error || '入库失败,请稍后重试') } finally { setCommitting(false) } } const runAiParse = async () => { if (!activeJobId) return if (!window.confirm('用模型重新解析这个任务?\n\n原来是规则解析出来的「待复核」行会被替换掉(已经复核或已入库的行不受影响)。')) return setAiRunning(true) setError('') setNotice('') try { const { data } = await axios.post(`/api/contracts/import-jobs/${activeJobId}/ai-parse`) setNotice(data?.message || 'AI 解析完成') await refreshAfterCommit() } catch (aiError) { setError(aiError?.response?.data?.error || 'AI 解析失败') } finally { setAiRunning(false) } } const downloadOriginal = async (name) => { try { const response = await axios.get(`/api/contracts/import-jobs/${activeJobId}/file`, { params: { name }, responseType: 'blob' }) const url = window.URL.createObjectURL(response.data) const link = document.createElement('a') link.href = url link.download = name document.body.appendChild(link) link.click() link.remove() window.URL.revokeObjectURL(url) } catch (downloadError) { setError('原件下载失败') } } const activeJob = jobDetail?.job const jobMeta = activeJob ? (JOB_STATUS[activeJob.status] || { label: activeJob.status, tone: 'mute' }) : null return (

合同清洗中心

供应商合同批量导入 → 按字符规则认列(不认行列位置)→ 人工审核入库。 规则认不出来的,可以交给 AI 解析。原件只在服务器保留 7 天后自动清理。

{ event.preventDefault(); setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={onDrop} >
{uploading ? '正在上传并解析…' : '把供应商合同 / 报价单拖到这里'} 支持 xlsx / xls / csv / tsv / txt。按标题里的字符特征认列(中英文与常见缩写都认), 表头不在第一行也能识别;认不准的可以改用 AI 解析或人工补录。
upload(event.target.files)} />
{error &&

{error}

} {notice &&

{notice}

} {commitReport?.skipped?.length > 0 && (
有 {commitReport.skipped.length} 条没入库:
)}
{!activeJob ? (

选一个导入任务

左边点一个任务,这里会列出逐条解析结果供复核入库。

) : loadingDetail ? (

读取明细中…

) : ( <>

{activeJob.job_no} {jobMeta.label}

解析方式 {activeJob.parse_source === 'ai' ? 'AI 模型' : '字符规则'} · 规则版本 {activeJob.rule_version} · 创建 {formatMoment(activeJob.created_at)}

{Object.entries(ITEM_STATUS).map(([value, meta]) => ( counts[value] ? ( ) : null ))} {counts.committed > 0 && ( 已入库 {counts.committed} )}
{activeJob.status === 'parse_failed' && activeJob.notes && (

上一次解析中断在:{activeJob.notes}。可以修正文件表头后重新导入。

)}
已勾选 {selected.size} 条
解析详情(认了哪几列 / 原件留存)
本次认列结果
原件留存 {originals?.files?.length ? (
{originals.files.map(file => ( ))} 服务器只保留 {originals.retention_days} 天 {originals.expires_at ? `(${new Date(originals.expires_at).toLocaleDateString('zh-CN')} 起自动清理)` : ''}
) : (

{originals?.expired ? `原件已过保留期(${originals.retention_days} 天)被自动清理 —— 解析结果与已入库的价格不受影响。` : '没有留存的原件。'}

)}
{counts.pending_review === 0 && counts.total > 0 && (

这个任务已经处理完毕{counts.committed ? `,其中 ${counts.committed} 条已入库` : ''}。

)} {filteredItems.length === 0 ? (

{items.length ? '当前筛选下没有明细' : '这个任务没有解析出任何行'}

{items.length ? '换个状态筛选看看。' : '规则没认出来。可以点上面的「AI 解析」让模型试一次,或点「修正」手工补录。'}

) : ( <>
{shownItems.map(item => ( ))}
(allPicked ? clearPick() : pickAll())} aria-label="全选" /> 来源货号产品名称规格 采购价币种数量起订量 状态复核备注 操作
{filteredItems.length > shownItems.length && (

还有 {filteredItems.length - shownItems.length} 条未显示

)} )} )}
) } export default ContractCleanup