feat(consignment): 新增待抢单手枪一键查询匹配看板

- 寄售仓库刷新前新增「一键查询待匹配订单」入口
- 待抢单非拼单工单 × 在库待匹配现货,按系统/区服/系列/钻级/璀璨细粒度评分
- 支持关键字搜索、冲突项开关、刷新与分页
- 只读查询,不自动履约、不改工单
This commit is contained in:
2026-11-02 14:59:46 +08:00
parent 13869d345f
commit 53fe841460
9 changed files with 914 additions and 1 deletions
@@ -0,0 +1,266 @@
import { useQuery } from '@tanstack/react-query'
import { ReloadOutlined } from '@ant-design/icons'
import { Alert, Button, Checkbox, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import { fetchPendingPistolMatches } from '@/services/admin/consignment'
import type { PendingPistolMatchPair } from '@/types/consignment'
import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
function resolveScoreTag(pair: PendingPistolMatchPair) {
if (pair.matchType === 'exact') return { color: 'green', label: '精确' }
if (pair.matchType === 'conflict') return { color: 'default', label: '不匹配' }
if (pair.score >= 90) return { color: 'blue', label: '非精确' }
if (pair.score >= 70) return { color: 'orange', label: '非精确' }
return { color: 'gold', label: '需核对' }
}
export default function ConsignmentPendingMatchModal({
open,
onClose,
}: {
open: boolean
onClose: () => void
}) {
const [keyword, setKeyword] = useState('')
const [includeConflicts, setIncludeConflicts] = useState(false)
const query = useQuery({
queryKey: ['admin-pending-pistol-matches', keyword, includeConflicts],
queryFn: () => fetchPendingPistolMatches({ keyword, includeConflicts, limit: 300 }),
enabled: open,
refetchOnWindowFocus: false,
retry: false,
})
const data = query.data?.data
const pairs = data?.pairs || []
const columns: TableColumnsType<PendingPistolMatchPair> = [
{
title: '匹配打分',
key: 'score',
width: 90,
render: (_, record) => {
const tag = resolveScoreTag(record)
return (
<Space direction="vertical" size={3} style={{ width: '100%' }}>
<span style={{ fontSize: 18, fontWeight: 700, lineHeight: 1 }}>{record.score}</span>
<Tag color={tag.color} style={{ marginInlineEnd: 0 }}>
{tag.label}
</Tag>
</Space>
)
},
},
{
title: '订单信息',
key: 'order',
width: 280,
render: (_, record) => (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 11 }}>
工单
</Text>
{record.workOrderNo ? (
<Text
copyable={{ text: record.workOrderNo }}
style={{ fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}
>
{record.workOrderNo}
</Text>
) : (
<Text type="secondary">-</Text>
)}
</Space>
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 11 }}>
平台
</Text>
{record.platformOrderId ? (
<Text
copyable={{ text: record.platformOrderId }}
style={{ fontSize: 11, whiteSpace: 'nowrap' }}
>
{record.platformOrderId}
</Text>
) : (
<Text type="secondary">-</Text>
)}
</Space>
<Text type="secondary" style={{ fontSize: 11 }}>
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
</Text>
</Space>
),
},
{
title: '商品名',
key: 'productName',
width: 220,
render: (_, record) => (
<Text ellipsis={{ tooltip: record.productName }} style={{ maxWidth: '100%' }}>
{record.productName || '-'}
</Text>
),
},
{
title: '寄售信息',
key: 'consignment',
width: 280,
render: (_, record) => (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 11 }}>
寄售
</Text>
<Text
copyable={{ text: record.consignmentNo || record.inventoryNo }}
style={{ fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}
>
{record.consignmentNo || record.inventoryNo || '-'}
</Text>
</Space>
{record.inventoryNo && record.inventoryNo !== record.consignmentNo ? (
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 11 }}>
仓库
</Text>
<Text
copyable={{ text: record.inventoryNo }}
style={{ fontSize: 11, whiteSpace: 'nowrap' }}
>
{record.inventoryNo}
</Text>
</Space>
) : null}
<Text type="secondary" style={{ fontSize: 11 }}>
入库 {record.inventoryInboundAt ? formatDateTime(record.inventoryInboundAt) : '-'}
</Text>
</Space>
),
},
{
title: '寄售物品',
key: 'itemName',
width: 220,
render: (_, record) => (
<Text ellipsis={{ tooltip: record.itemName }} style={{ maxWidth: '100%' }}>
{record.itemName || '-'}
</Text>
),
},
{
title: '匹配说明',
key: 'reason',
width: 300,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Text style={{ fontSize: 12 }}>{record.reason || '-'}</Text>
{record.conflicts.length > 0 ? (
<Text type="danger" style={{ fontSize: 11 }}>
{record.conflicts.join(';')}
</Text>
) : null}
</Space>
),
},
]
return (
<Modal
title="匹配 · 待抢单手枪 × 在库现货"
open={open}
width="min(1320px, 94vw)"
footer={null}
destroyOnHidden
onCancel={onClose}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="只查询非拼单的待抢单手枪单 × 在库待匹配现货,不直接改单。"
description="分数 0-100:系统 20 / 区服 20 / 系列 20 / 钻级 25 / 璀璨 15。非精确项仍需按现有规则确认;不按录单、入库时间先后过滤。"
/>
<Space wrap size={12}>
<Tag>待抢单 {data?.pendingOrderCount ?? 0} 单</Tag>
<Tag>在库待匹配 {data?.inStockInventoryCount ?? 0} 件</Tag>
<Tag color="blue">匹配组合 {data?.pairCount ?? 0} 条</Tag>
<Tag color="green">精确 {data?.exactCount ?? 0}</Tag>
<Tag color="orange">非精确 {data?.partialCount ?? 0}</Tag>
{includeConflicts ? <Tag>不匹配 {data?.conflictCount ?? 0}</Tag> : null}
</Space>
<Space wrap size={12}>
<Input.Search
allowClear
placeholder="搜索平台号 / 工单号 / 商品名 / 寄售单号 / 寄售物品"
style={{ width: 420 }}
onSearch={(value) => setKeyword(value.trim())}
/>
<Checkbox
checked={includeConflicts}
onChange={(event) => setIncludeConflicts(event.target.checked)}
>
显示 0 分不匹配项
</Checkbox>
<Button
icon={<ReloadOutlined />}
loading={query.isFetching}
onClick={() => void query.refetch()}
>
刷新
</Button>
</Space>
{query.isError ? (
<Alert
type="error"
showIcon
message="读取匹配结果失败"
description={query.error instanceof Error ? query.error.message : '请稍后重试'}
/>
) : null}
{data?.truncated ? (
<Alert
type="warning"
showIcon
message={`结果较多,仅展示前 ${pairs.length} 条,请用搜索缩小范围`}
/>
) : null}
<PercentTable<PendingPistolMatchPair>
rowKey={(record) => `${record.workOrderId}-${record.inventoryId}`}
size="small"
loading={query.isFetching}
dataSource={pairs}
columns={columns}
scroll={{ y: 460 }}
pagination={{
pageSize: 20,
showSizeChanger: false,
showTotal: (total) => `共 ${total} 条`,
}}
locale={{
emptyText: (
<Empty
description={
includeConflicts
? '没有可展示的配对'
: '没有有分匹配;可勾选「显示 0 分不匹配项」查看冲突'
}
/>
),
}}
/>
</Space>
</Modal>
)
}
@@ -1,4 +1,10 @@
import { EyeOutlined, ReloadOutlined, RollbackOutlined, StopOutlined } from '@ant-design/icons'
import {
EyeOutlined,
ReloadOutlined,
RollbackOutlined,
StopOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
@@ -27,6 +33,7 @@ import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal'
import {
archiveAdminConsignmentInventory,
fetchAdminConsignmentContacts,
@@ -149,6 +156,7 @@ export default function ConsignmentInventoryPanel() {
const [viewingItem, setViewingItem] = useState<ConsignmentInventory | null>(null)
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
const [manualMatchItem, setManualMatchItem] = useState<ConsignmentInventory | null>(null)
const [pendingMatchOpen, setPendingMatchOpen] = useState(false)
const [returnReason, setReturnReason] = useState<string>('')
const [editForm] = Form.useForm()
@@ -836,6 +844,10 @@ export default function ConsignmentInventoryPanel() {
}}
/>
<Button icon={<ThunderboltOutlined />} onClick={() => setPendingMatchOpen(true)}>
一键查询待匹配订单
</Button>
<Button icon={<ReloadOutlined />} onClick={refresh}>
刷新
</Button>
@@ -1305,6 +1317,12 @@ export default function ConsignmentInventoryPanel() {
}
onMatched={() => void inventoriesQuery.refetch()}
/>
{/* 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货 */}
<ConsignmentPendingMatchModal
open={pendingMatchOpen}
onClose={() => setPendingMatchOpen(false)}
/>
</div>
)
}
@@ -5,6 +5,7 @@ import type {
ConsignmentStatsSummary,
ConsignmentSubmission,
InventoryMatchableOrders,
PendingPistolMatchResult,
WorkOrderInventoryMatchCheck,
WorkOrderInventoryMatchOptions,
} from '@/types/consignment'
@@ -158,6 +159,22 @@ export function fetchInventoryMatchableOrders(
)
}
/** 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货,按细粒度评分排序。 */
export function fetchPendingPistolMatches(params?: {
keyword?: string
includeConflicts?: boolean
limit?: number
}) {
return apiGet<PendingPistolMatchResult>(
'/api/v1/admin/worker-platform/consignment/pending-pistol-matches',
{
keyword: params?.keyword?.trim() || undefined,
includeConflicts: params?.includeConflicts ? 'true' : undefined,
limit: params?.limit,
},
)
}
/** 履约/指派返回的电子凭证核销结果:ok=false 时必须提示运营人工核销,不能报成功 */
export type WorkOrderVoucherConsumeResult = {
ok: boolean
+29
View File
@@ -240,3 +240,32 @@ export type WorkOrderInventoryMatchCheck = {
partialCount: number
matches: ConsignmentMatchResult[]
}
/** 后台「寄售仓库 → 匹配」:待抢单手枪 × 在库待匹配现货的配对明细 */
export type PendingPistolMatchPair = {
workOrderId: number
workOrderNo: string
platformOrderId: string
productName: string
workOrderCreatedAt: string | null
inventoryId: number
consignmentNo: string
inventoryNo: string
itemName: string
inventoryInboundAt: string | null
matchType: 'exact' | 'partial' | 'conflict'
score: number
reason: string
conflicts: string[]
}
export type PendingPistolMatchResult = {
pendingOrderCount: number
inStockInventoryCount: number
pairCount: number
exactCount: number
partialCount: number
conflictCount: number
truncated: boolean
pairs: PendingPistolMatchPair[]
}