feat(consignment): 拆分自动/手动匹配口径并优化内部匹配列表

This commit is contained in:
2026-11-14 18:26:01 +08:00
parent 5ff0ddb58e
commit e97932dcdf
16 changed files with 877 additions and 144 deletions
@@ -1,16 +1,4 @@
import {
App,
Alert,
Button,
Card,
Checkbox,
Empty,
Input,
Modal,
Space,
Tag,
Typography,
} from 'antd'
import { App, Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
@@ -30,6 +18,29 @@ function money(fen: number) {
return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}`
}
const EMPTY_INVENTORY_IDS: number[] = []
/** 搜索框命中范围:单号 / 物品 / 规格 / 打手 / 匹配说明。 */
function buildCandidateHaystack(item: ConsignmentMatchCandidate): string {
return [
item.consignmentNo || '',
item.inventoryNo,
item.itemName,
item.specText || '',
Object.values(item.specValues || {}).join(' '),
item.workerName || '',
(item.matchReasons || []).join(' '),
describeConsignmentMatch(item),
]
.join(' ')
.toLowerCase()
}
function sameIdList(left: number[], right: number[]): boolean {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
function resolveScoreTag(item: ConsignmentMatchCandidate) {
const type = item.matchType || 'exact'
const score = computeConsignmentMatchScore(item)
@@ -43,19 +54,23 @@ function resolveScoreTag(item: ConsignmentMatchCandidate) {
export default function ConsignmentWorkOrderMatchModal({
open,
workOrderId,
initialInventoryIds,
onClose,
onMatched,
}: {
open: boolean
workOrderId: number | null
/** 外部带入的预选库存(例如待匹配看板点「选择」),允许是非精确项 */
initialInventoryIds?: number[]
onClose: () => void
onMatched?: () => void
}) {
const { message } = App.useApp()
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [includeConflicts, setIncludeConflicts] = useState(false)
const [keyword, setKeyword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [nonExactReason, setNonExactReason] = useState('')
const presetInventoryKey = (initialInventoryIds || EMPTY_INVENTORY_IDS).join(',')
const candidatesQuery = useQuery({
queryKey: ['admin-consignment-match-candidates', workOrderId],
@@ -70,17 +85,25 @@ export default function ConsignmentWorkOrderMatchModal({
const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1))
const activeQuantity = Math.max(0, Number(data?.activeQuantity || 0))
const sharingEnabled = Boolean(data?.sharingEnabled)
// 状态不允许时接口只读返回候选:仍可搜索/核对库存,但禁止提交。
const matchable = data?.matchable !== false
const blockedReason = data?.blockedReason || '当前工单状态不允许匹配寄卖库存'
const remainingNeeded = sharingEnabled
? Math.max(0, totalQuantity - activeQuantity)
: totalQuantity
const visibleOptions = useMemo(
() =>
includeConflicts
? options
: options.filter((item) => (item.matchType || 'exact') !== 'conflict'),
[includeConflicts, options],
)
const normalizedKeyword = keyword.trim().toLowerCase()
// 默认展示全部候选,按匹配打分降序;关键词只做过滤,不改变排序口径。
const visibleOptions = useMemo(() => {
const filtered = normalizedKeyword
? options.filter((item) => buildCandidateHaystack(item).includes(normalizedKeyword))
: options
return [...filtered].sort((left, right) => {
const scoreDiff = computeConsignmentMatchScore(right) - computeConsignmentMatchScore(left)
if (scoreDiff !== 0) return scoreDiff
return Number(right.remainingQuantity || 0) - Number(left.remainingQuantity || 0)
})
}, [normalizedKeyword, options])
const selectedOptions = useMemo(
() => options.filter((item) => selectedIds.includes(item.inventoryId)),
@@ -117,7 +140,7 @@ export default function ConsignmentWorkOrderMatchModal({
useEffect(() => {
if (!open) {
setSelectedIds((prev) => (prev.length ? [] : prev))
setIncludeConflicts(false)
setKeyword('')
setNonExactReason('')
return
}
@@ -126,6 +149,16 @@ export default function ConsignmentWorkOrderMatchModal({
return
}
// 外部预选优先:待匹配看板点「选择」时直接勾中对应库存(可能是非精确项)。
if (presetInventoryKey) {
const presetIds = presetInventoryKey
.split(',')
.map((value) => Number(value))
.filter((id) => Number.isInteger(id) && id > 0)
setSelectedIds((prev) => (sameIdList(prev, presetIds) ? prev : presetIds))
return
}
// 默认只选精确库存;整单必须一条满足总量,拼单按剩余份数累加到一次拼满。
const exactOptions = options.filter(
(item) => (item.matchType || 'exact') === 'exact' && Number(item.remainingQuantity || 0) > 0,
@@ -134,7 +167,8 @@ export default function ConsignmentWorkOrderMatchModal({
const chosen = exactOptions.find(
(item) => Number(item.remainingQuantity || 0) >= totalQuantity,
)
setSelectedIds(chosen ? [chosen.inventoryId] : [])
const nextIds = chosen ? [chosen.inventoryId] : []
setSelectedIds((prev) => (sameIdList(prev, nextIds) ? prev : nextIds))
return
}
@@ -145,20 +179,8 @@ export default function ConsignmentWorkOrderMatchModal({
ids.push(item.inventoryId)
covered += Math.max(0, Number(item.remainingQuantity || 0))
}
setSelectedIds(ids)
}, [open, options, remainingNeeded, sharingEnabled, totalQuantity])
// 隐藏不匹配项时,顺手清掉已选中的冲突库存,避免提交到不可见的行。
useEffect(() => {
if (includeConflicts) return
setSelectedIds((prev) => {
const next = prev.filter((id) => {
const item = options.find((option) => option.inventoryId === id)
return !item || (item.matchType || 'exact') !== 'conflict'
})
return next.length === prev.length ? prev : next
})
}, [includeConflicts, options])
setSelectedIds((prev) => (sameIdList(prev, ids) ? prev : ids))
}, [open, options, presetInventoryKey, remainingNeeded, sharingEnabled, totalQuantity])
const nonExactSelected = useMemo(
() =>
@@ -170,6 +192,7 @@ export default function ConsignmentWorkOrderMatchModal({
)
const canSubmit =
matchable &&
selectedIds.length > 0 &&
coverage.sufficient &&
(!nonExactSelected || nonExactReason.trim().length > 0)
@@ -240,11 +263,45 @@ export default function ConsignmentWorkOrderMatchModal({
}
/>
) : null}
{!candidatesQuery.isLoading && !candidatesQuery.isError ? (
<Space wrap size={8} style={{ marginBottom: 12 }}>
<Input.Search
allowClear
placeholder="搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手"
style={{ width: 320 }}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<Typography.Text type="secondary">
显示 {visibleOptions.length} / 候选 {options.length} 条
</Typography.Text>
</Space>
) : null}
{!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0 ? (
<Empty description="没有同配置的在库寄卖库存" />
) : null}
{!candidatesQuery.isError && options.length > 0 ? (
{!candidatesQuery.isLoading &&
!candidatesQuery.isError &&
options.length > 0 &&
visibleOptions.length === 0 ? (
<Empty
description={
keyword.trim()
? '没有匹配该关键词的库存,换个寄卖单号/库存号/物品/打手试试'
: '没有可展示的库存'
}
/>
) : null}
{!candidatesQuery.isError && visibleOptions.length > 0 ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{!matchable ? (
<Alert
type="warning"
showIcon
message={blockedReason}
description="当前仍可搜索和核对库存;补充资料/重新发布到待分配或待抢单后才能提交匹配。"
/>
) : null}
{data?.matchingMode === 'off' ? (
<Alert
type="warning"
@@ -257,30 +314,23 @@ export default function ConsignmentWorkOrderMatchModal({
type={remainingNeeded <= 0 ? 'warning' : 'info'}
showIcon
message={infoMessage}
description="待分配工单也可直接人工匹配;候选仍按寄卖配置打分,非精确/冲突库存必须人工勾选并填写原因。拼单必须一次拼满,失败不会占用任何库存。"
description="默认按匹配打分从高到低展示全部候选;非精确/冲突必须填写人工原因;拼单必须一次拼满,失败不会占用任何库存。"
/>
<Space wrap size={8}>
<Tag color="blue">
{sharingEnabled ? `待匹配 ${remainingNeeded} 份` : `整单 ${totalQuantity} 件`}
</Tag>
{sharingEnabled ? <Tag>已匹配 {activeQuantity} 份</Tag> : null}
<Tag>候选 {visibleOptions.length} 条</Tag>
<Tag color="green">精确 {counts.exact}</Tag>
<Tag color="orange">非精确 {counts.partial}</Tag>
<Tag>冲突 {counts.conflict}</Tag>
<Checkbox
checked={includeConflicts}
onChange={(event) => setIncludeConflicts(event.target.checked)}
>
显示不匹配项
</Checkbox>
</Space>
<PercentTable<ConsignmentMatchCandidate>
rowKey="inventoryId"
size="small"
loading={candidatesQuery.isFetching}
pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 1050, y: 420 }}
scroll={{ x: 1210, y: 420 }}
dataSource={visibleOptions}
rowSelection={{
// 整单只能选一条;拼单可多选,必须覆盖剩余份数才能提交。
@@ -306,6 +356,19 @@ export default function ConsignmentWorkOrderMatchModal({
)
},
},
{
title: '寄卖单号',
key: 'consignmentNo',
width: 170,
render: (_, item) => (
<Typography.Text
copyable={{ text: item.consignmentNo || item.inventoryNo }}
style={{ fontSize: 12 }}
>
{item.consignmentNo || item.inventoryNo || '-'}
</Typography.Text>
),
},
{
title: '寄卖库存',
key: 'inventory',