feat(consignment): 统一寄卖匹配看板口径并收口占用与重匹配

- 看板与引擎统一数量、模板规格、正向规格信号判定,纳入 internal_matching/unassigned

- 库存侧改走新引擎匹配,移除旧 /fulfill 手动入口与旧弹窗

- 验收时将 reserved allocation 收口为 consumed,补份额级收口

- 拼单重新匹配通过 material 排除全部原库存/原卖家

- 开发/测试环境放宽迁移顺序校验,生产保持严格;修正集成测试份额报酬口径
This commit is contained in:
2026-11-12 15:17:09 +08:00
parent 229eef31a8
commit e39f640dac
18 changed files with 962 additions and 759 deletions
@@ -0,0 +1,250 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Alert, App, Button, Checkbox, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import {
matchAdminConsignmentWorkOrder,
fetchPendingPistolMatches,
} from '@/services/admin/consignment'
import type { PendingPistolMatchPair } from '@/types/consignment'
import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
/**
* 库存侧「匹配订单」:从一条在库现货出发,复用待匹配看板的统一判定
* (profile、模板规格、数量、重新匹配排除),落库走新引擎 `/consignment/match`。
*
* 旧 `/fulfill` 链路只做整单、且不认数量拆分,这里不再暴露。
*/
export default function ConsignmentInventoryMatchModal({
open,
inventoryId,
inventoryLabel,
onClose,
onMatched,
}: {
open: boolean
inventoryId: number | null
inventoryLabel?: string
onClose: () => void
onMatched?: () => void
}) {
const { message } = App.useApp()
const [selectedId, setSelectedId] = useState<number | null>(null)
const [allowNonExact, setAllowNonExact] = useState(false)
const [reason, setReason] = useState('')
const [submitting, setSubmitting] = useState(false)
const query = useQuery({
queryKey: ['admin-consignment-inventory-match-orders', inventoryId ?? null],
queryFn: () =>
fetchPendingPistolMatches({
inventoryId: Number(inventoryId),
includeConflicts: true,
limit: 1000,
}),
enabled: open && Boolean(inventoryId),
refetchOnWindowFocus: false,
retry: false,
})
const pairs = useMemo(() => query.data?.data?.pairs ?? [], [query.data])
const selected = useMemo(
() => pairs.find((pair) => pair.workOrderId === selectedId) || null,
[pairs, selectedId],
)
const selectedBlocked = selected
? selected.quantitySufficient === false || (selected.matchType === 'conflict' && !allowNonExact)
: true
const needReason = Boolean(selected && selected.matchType !== 'exact')
const columns: TableColumnsType<PendingPistolMatchPair> = [
{
title: '选择',
key: 'select',
width: 64,
render: (_, record) => {
const disabled =
record.quantitySufficient === false || (record.matchType === 'conflict' && !allowNonExact)
return (
<Button
size="small"
type={selectedId === record.workOrderId ? 'primary' : 'default'}
disabled={disabled}
onClick={() => setSelectedId(record.workOrderId)}
>
{selectedId === record.workOrderId ? '已选' : '选择'}
</Button>
)
},
},
{
title: '工单',
key: 'order',
width: 220,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Text copyable={{ text: record.workOrderNo }} style={{ fontSize: 12, fontWeight: 600 }}>
{record.workOrderNo || '-'}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{record.platformOrderId || '-'} · {record.workOrderStatus || '-'}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
</Text>
</Space>
),
},
{
title: '商品名',
key: 'productName',
width: 200,
render: (_, record) => (
<Text ellipsis={{ tooltip: record.productName }} style={{ maxWidth: '100%' }}>
{record.productName || '-'}
</Text>
),
},
{
title: '规格判定',
key: 'match',
width: 120,
render: (_, record) => {
if (record.quantitySufficient === false) return <Tag color="orange">数量不足</Tag>
if (record.matchType === 'exact') return <Tag color="green">精确</Tag>
if (record.matchType === 'conflict') return <Tag>不匹配</Tag>
return <Tag color="blue">非精确</Tag>
},
},
{
title: '数量',
key: 'quantity',
width: 130,
render: (_, record) => (
<Text
type={record.quantitySufficient === false ? 'danger' : 'secondary'}
style={{ fontSize: 12 }}
>
{record.sharingEnabled
? `拼单还需 ${record.requiredQuantity ?? 1} 份 / 该库存剩 ${record.availableQuantity ?? '-'} 件`
: `需求 ${record.requiredQuantity ?? 1} 件 / 该库存剩 ${record.availableQuantity ?? '-'} 件`}
</Text>
),
},
{
title: '匹配说明',
key: 'reason',
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>
),
},
]
function handleClose() {
setSelectedId(null)
setAllowNonExact(false)
setReason('')
onClose()
}
async function handleSubmit() {
if (!selected || !inventoryId) return
setSubmitting(true)
try {
const res = await matchAdminConsignmentWorkOrder({
workOrderId: selected.workOrderId,
inventoryIds: [Number(inventoryId)],
allowNonExact: allowNonExact || selected.matchType !== 'exact',
reason: reason.trim(),
})
if (res.data?.matched === false) {
message.warning('工单状态或库存已变化,请刷新后重试')
await query.refetch()
return
}
message.success('已用该寄卖库存匹配工单')
setSelectedId(null)
setAllowNonExact(false)
setReason('')
onMatched?.()
handleClose()
} catch (error) {
message.error(error instanceof Error ? error.message : '匹配失败,请刷新后重试')
void query.refetch()
} finally {
setSubmitting(false)
}
}
return (
<Modal
title={`匹配订单 · ${inventoryLabel || `库存 #${inventoryId ?? ''}`}`}
open={open}
width="min(1200px, 94vw)"
onCancel={handleClose}
okText="确认匹配"
cancelText="取消"
okButtonProps={{ disabled: !selected || selectedBlocked || (needReason && !reason.trim()) }}
confirmLoading={submitting}
onOk={() => void handleSubmit()}
destroyOnHidden
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="按寄卖配置与数量判定候选工单;多数量库存会按剩余量扣减,不会被整批消耗。"
description="精确匹配可直接提交;非精确或规格冲突必须勾选强制匹配并填写原因。拼单工单必须一次拼满,单条库存不够时请到工单侧匹配。"
/>
<Space wrap size={12}>
<Checkbox
checked={allowNonExact}
onChange={(event) => setAllowNonExact(event.target.checked)}
>
允许非精确/冲突强制匹配
</Checkbox>
<Input
style={{ width: 360 }}
placeholder="强制匹配原因(非精确必填)"
value={reason}
onChange={(event) => setReason(event.target.value)}
disabled={!allowNonExact && selected?.matchType === 'exact'}
/>
<Button 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}
<PercentTable<PendingPistolMatchPair>
rowKey={(record) => `${record.workOrderId}-${record.inventoryId}`}
size="small"
loading={query.isFetching}
dataSource={pairs}
columns={columns}
pagination={{ pageSize: 10, showSizeChanger: false }}
locale={{
emptyText: <Empty description="这条库存当前没有可匹配的工单" />,
}}
/>
</Space>
</Modal>
)
}
@@ -1,571 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Checkbox,
Descriptions,
Empty,
Input,
Modal,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd'
import { SearchOutlined, TeamOutlined } from '@ant-design/icons'
import {
fetchInventoryMatchableOrders,
fetchWorkOrderConsignmentMatchOptions,
fulfillWorkOrderWithConsignment,
} from '@/services/admin/consignment'
import type { ConsignmentMatchOptionView, ConsignmentMatchableOrder } from '@/types/consignment'
import {
canSubmitManualMatch,
describeManualMatchType,
filterManualMatchOptions,
resolveManualMatchRequirement,
type ManualMatchType,
} from '@/utils/consignment-manual-match'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { formatDateTime } from '@/utils/date-time'
import { notifyVoucherConsume } from '@/utils/voucher-consume'
const { Text, Paragraph } = Typography
type Selected = {
workOrderId: number | string
workOrderLabel: string
workOrderCreatedAt?: string
inventoryId: number | string
inventoryLabel: string
inventoryInboundAt?: string
matchType: ManualMatchType
conflicts: string[]
/** 供货打手(用于回显供货来源) */
workerId?: number | null
workerName?: string
/** 对接客服(用于履约前核对归属) */
contactSupporterName?: string
}
export type ConsignmentManualMatchModalProps = {
open: boolean
onClose: () => void
/** 订单侧入口:从订单出发选现货 */
workOrder?: { workOrderId: number | string; label: string; createdAt?: string } | null
/** 库存侧入口:从现货出发选订单 */
inventory?: { inventoryId: number | string; label: string; inboundAt?: string } | null
onMatched?: () => void
}
/**
* 「手动指定现货」弹窗:订单侧与库存侧共用一套交互与落地逻辑。
*
* 统一走内部现货直接履约(原子占库、免外派佣金、不冻结重复押金、跳过打手资料直接进入待加好友)。
*/
export default function ConsignmentManualMatchModal({
open,
onClose,
workOrder,
inventory,
onMatched,
}: ConsignmentManualMatchModalProps) {
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<ConsignmentMatchOptionView[]>([])
const [orders, setOrders] = useState<ConsignmentMatchableOrder[]>([])
const [inventoryLabel, setInventoryLabel] = useState('')
const [inventoryInboundAt, setInventoryInboundAt] = useState('')
const [inventoryWorker, setInventoryWorker] = useState<{
workerId: number | null
workerName: string
} | null>(null)
const [inventoryContact, setInventoryContact] = useState<{
contactSupporterName: string
} | null>(null)
const [selected, setSelected] = useState<Selected | null>(null)
const [confirmedPartial, setConfirmedPartial] = useState(false)
const [confirmedMismatch, setConfirmedMismatch] = useState(false)
const [matchNote, setMatchNote] = useState('')
const [submitting, setSubmitting] = useState(false)
const mode: 'order' | 'inventory' = inventory ? 'inventory' : 'order'
const resetState = () => {
setKeyword('')
setSelected(null)
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
setInventoryInboundAt('')
setInventoryContact(null)
}
const load = async () => {
setLoading(true)
try {
if (inventory) {
// 关键字与分页透传后端:库存侧默认只拉 50 条时,第 51 单以后永远搜不到
const res = await fetchInventoryMatchableOrders(inventory.inventoryId, {
keyword: keyword.trim() || undefined,
pageSize: 100,
})
setOrders(res.data?.orders || [])
setInventoryLabel(
res.data?.inventory
? `${res.data.inventory.itemName}(${res.data.inventory.sourceSubmissionNo || res.data.inventory.inventoryNo})`
: inventory.label,
)
setInventoryInboundAt(res.data?.inventory?.inboundAt || inventory.inboundAt || '')
setInventoryWorker({
workerId: res.data?.inventory?.workerId ?? null,
workerName: res.data?.inventory?.workerName || '',
})
setInventoryContact({
contactSupporterName: res.data?.inventory?.contactSupporterName || '',
})
} else if (workOrder) {
const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId)
setOptions(res.data?.options || [])
}
} catch (err: any) {
message.error(err.message || '读取可匹配数据失败')
} finally {
setLoading(false)
}
}
// 库存侧:关键字防抖透传后端搜索(本地过滤只作用在已拉取的 100 条内)
useEffect(() => {
if (!open || !inventory) return
const timer = setTimeout(() => {
void load()
}, 300)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword])
const filteredOptions = useMemo(
() =>
filterManualMatchOptions(options, keyword, (item) => [
item.inventory.itemName,
item.inventory.sourceSubmissionNo,
item.inventory.inventoryNo,
item.inventory.system,
item.inventory.serverZone,
item.inventory.series,
item.inventory.workerName,
item.inventory.buyerName,
item.inventory.sellerGameId,
item.inventory.id,
]),
[options, keyword],
)
const filteredOrders = useMemo(
() =>
filterManualMatchOptions(orders, keyword, (item) => [
item.workOrderNo,
item.workOrderId,
item.productName,
item.requiredSpec.system,
item.requiredSpec.serverZone,
item.requiredSpec.series,
item.status,
]),
[orders, keyword],
)
const requirement = resolveManualMatchRequirement(selected?.matchType || 'exact')
const canSubmit =
Boolean(selected) &&
canSubmitManualMatch({
matchType: selected?.matchType || 'exact',
confirmedPartial,
confirmedMismatch,
matchNote,
})
async function handleSubmit() {
if (!selected) return
setSubmitting(true)
try {
const res = await fulfillWorkOrderWithConsignment({
workOrderId: selected.workOrderId,
inventoryId: selected.inventoryId,
allowPartial: selected.matchType !== 'exact' || confirmedPartial,
allowSpecMismatch: selected.matchType === 'conflict' || confirmedMismatch,
matchNote: matchNote.trim(),
})
// 核销失败时共享工具会弹警告(状态已变更但凭证没消耗),此时不能再弹"成功"掩盖
const voucher = (res.data as any)?.voucherConsume
notifyVoucherConsume(voucher)
if (!voucher || voucher.ok) {
message.success('已使用内部寄售现货直接履约,已进入待加好友阶段')
}
onMatched?.()
resetState()
onClose()
} catch (err: any) {
// 列表数据过期(并发占用 / 打开弹窗后订单规格被改):刷新列表按最新判定重来,
// 否则运营会对着已失效的候选反复重试、循环撞同一个错
const code = String(err?.errorCode || '')
if (
code === 'consignment_inventory_race_condition' ||
code === 'consignment_inventory_unavailable'
) {
message.error('该现货刚被其他操作占用或已下架,已刷新列表,请重新选择')
setSelected(null)
void load()
return
}
if (
code === 'consignment_spec_mismatch' ||
code === 'consignment_spec_partial_requires_confirm'
) {
message.error('订单或现货的规格已变化(列表数据过期),已刷新,请按最新判定重新确认')
setSelected(null)
void load()
return
}
message.error(err.message || '内部现货履约失败')
} finally {
setSubmitting(false)
}
}
return (
<Modal
title={mode === 'order' ? `手动指定现货 · ${workOrder?.label || ''}` : '手动匹配订单'}
open={open}
width={860}
okText="确认内部现货履约"
okButtonProps={{
disabled: !canSubmit,
loading: submitting,
}}
onOk={() => void handleSubmit()}
onCancel={() => {
resetState()
onClose()
}}
afterOpenChange={(visible) => {
if (visible) {
void load()
} else {
resetState()
}
}}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="手动匹配将直接选用内部寄售现货履约(原子占库、免外派佣金、不冻结重复押金、自动跳过打手资料)。"
description={
mode === 'order'
? '下面是该订单的全部在库现货(含规格不一致的),核对差异后直接内部履约。'
: `当前现货:${inventoryLabel || inventory?.label || ''}${
inventoryInboundAt ? `(审核入库:${formatDateTime(inventoryInboundAt)})` : ''
}。下面是可匹配的订单列表,核对后直接内部履约。`
}
/>
<Input
placeholder={
mode === 'order'
? '搜索物品名 / 寄卖单号 (SUB...) / 仓库号 (INV...) / 规格 / 供货打手'
: '搜索工单号 (WK... / WO...) / 平台单号 / 标题 / 规格'
}
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
/>
<Spin spinning={loading}>
<div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 4 }}>
{mode === 'order' ? (
filteredOptions.length === 0 ? (
<Empty description="没有可匹配的现货" />
) : (
filteredOptions.map((option) => {
const meta = describeManualMatchType(option.matchType)
const isSelected =
selected?.inventoryId != null &&
String(selected.inventoryId) === String(option.inventory.id)
return (
<div
key={option.inventory.id}
style={{
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
borderRadius: 8,
padding: 10,
marginBottom: 8,
background: isSelected ? '#fff7e6' : '#fff',
}}
>
<Space
style={{ width: '100%', justifyContent: 'space-between' }}
align="start"
>
<Space direction="vertical" size={2}>
<Space size={6} wrap>
<Tag color="cyan">
{option.inventory.sourceSubmissionNo || option.inventory.inventoryNo}
</Tag>
<Text strong>{option.inventory.itemName}</Text>
<Tag color={meta.color}>{meta.label}</Tag>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
寄卖单号{' '}
{option.inventory.sourceSubmissionNo || option.inventory.inventoryNo} ·{' '}
{option.inventory.system || '未指定'}/
{option.inventory.serverZone || '未指定'}/
{option.inventory.series || '未指定'}{' '}
{option.inventory.diamondLevel
? `${option.inventory.diamondLevel}钻`
: ''}{' '}
· 货源 {option.inventory.workerName || '未关联打手'} · 结算价 ¥
{(Number(option.inventory.costPriceFen || 0) / 100).toFixed(2)} ·
对接客服 {formatConsignmentContactSupporter(option.inventory).text}
{option.inventory.inboundAt || option.inventory.createdAt ? (
<>
{' '}
· 审核入库{' '}
{formatDateTime(
option.inventory.inboundAt || option.inventory.createdAt,
)}
</>
) : null}
</Text>
{option.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
差异:{option.conflicts.join(';')}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{option.reason || '规格完全一致'}
</Text>
)}
</Space>
<Button
size="small"
type={isSelected ? 'primary' : 'default'}
onClick={() => {
setSelected({
workOrderId: workOrder?.workOrderId || 0,
workOrderLabel: workOrder?.label || '',
workOrderCreatedAt: workOrder?.createdAt,
inventoryId: option.inventory.id,
inventoryLabel: option.inventory.itemName,
inventoryInboundAt:
option.inventory.inboundAt || option.inventory.createdAt,
matchType: option.matchType,
conflicts: option.conflicts,
workerId: option.inventory.workerId,
workerName: option.inventory.workerName,
contactSupporterName: option.inventory.contactSupporterName,
})
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}}
>
{isSelected ? '已选' : '选它'}
</Button>
</Space>
</div>
)
})
)
) : filteredOrders.length === 0 ? (
<Empty description="没有待匹配的订单" />
) : (
filteredOrders.map((order) => {
const meta = describeManualMatchType(order.matchType)
const isSelected =
selected?.workOrderId != null &&
String(selected.workOrderId) === String(order.workOrderId)
return (
<div
key={order.workOrderId}
style={{
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
borderRadius: 8,
padding: 10,
marginBottom: 8,
background: isSelected ? '#fff7e6' : '#fff',
}}
>
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<Space direction="vertical" size={2}>
<Space size={6} wrap>
<Tag color="purple">{order.workOrderNo || `#${order.workOrderId}`}</Tag>
<Text strong>{order.productName}</Text>
<Tag color={meta.color}>{meta.label}</Tag>
{order.status === 'open' ? (
<Tag color="blue">大厅待抢</Tag>
) : order.status === 'unassigned' ? (
<Tag color="orange">待指派</Tag>
) : order.status === 'pending_material' ? (
<Tag color="gold">待补资料</Tag>
) : null}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
要求:{order.requiredSpec.system || '未指定'}/
{order.requiredSpec.serverZone || '未指定'}/
{order.requiredSpec.series || '未指定'}{' '}
{order.requiredSpec.diamondLevel
? `${order.requiredSpec.diamondLevel}钻`
: ''}{' '}
· 报酬 ¥{(order.rewardAmountFen / 100).toFixed(2)}
{order.createdAt ? (
<> · 创建时间 {formatDateTime(order.createdAt)}</>
) : null}
</Text>
{order.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
差异:{order.conflicts.join(';')}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{order.reason || '规格相符'}
</Text>
)}
</Space>
<Button
size="small"
type={isSelected ? 'primary' : 'default'}
onClick={() => {
setSelected({
workOrderId: order.workOrderId,
workOrderLabel: `${order.workOrderNo || `#${order.workOrderId}`} · ${order.productName}`,
workOrderCreatedAt: order.createdAt,
inventoryId: inventory?.inventoryId || 0,
inventoryLabel,
inventoryInboundAt,
matchType: order.matchType,
conflicts: order.conflicts,
workerId: inventoryWorker?.workerId ?? null,
workerName: inventoryWorker?.workerName || '',
contactSupporterName: inventoryContact?.contactSupporterName || '',
})
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}}
>
{isSelected ? '已选' : '选它'}
</Button>
</Space>
</div>
)
})
)}
</div>
</Spin>
{selected ? (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 12 }}>
<Descriptions size="small" column={1} style={{ marginBottom: 8 }}>
<Descriptions.Item label="订单">
<Space size={6} wrap>
<span>{selected.workOrderLabel || '—'}</span>
{selected.workOrderCreatedAt ? (
<Text type="secondary" style={{ fontSize: 12 }}>
(创建于 {formatDateTime(selected.workOrderCreatedAt)})
</Text>
) : null}
</Space>
</Descriptions.Item>
<Descriptions.Item label="现货">
<Space size={6} wrap>
<span>{selected.inventoryLabel || '—'}</span>
{selected.inventoryInboundAt ? (
<Text type="secondary" style={{ fontSize: 12 }}>
(审核入库于 {formatDateTime(selected.inventoryInboundAt)})
</Text>
) : null}
</Space>
</Descriptions.Item>
<Descriptions.Item label="供货来源">
{selected.workerId ? (
<Space size={6}>
<Tag icon={<TeamOutlined />} color="blue">
{selected.workerName || `供货打手 #${selected.workerId}`}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
(验收后将自动为该打手发放货款)
</Text>
</Space>
) : (
<Tag color="default">内部库存现货(无打手关联)</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="对接客服">
{formatConsignmentContactSupporter(selected).text}
</Descriptions.Item>
<Descriptions.Item label="匹配判定">
<Tag color={describeManualMatchType(selected.matchType).color}>
{describeManualMatchType(selected.matchType).label}
</Tag>
{selected.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
{selected.conflicts.join(';')}
</Text>
) : null}
</Descriptions.Item>
</Descriptions>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{requirement.needsPartialConfirm ? (
<Checkbox
checked={confirmedPartial}
onChange={(e) => setConfirmedPartial(e.target.checked)}
>
允许非精确匹配(订单规格未写全或高配充低配)
</Checkbox>
) : null}
{requirement.needsMismatchConfirm ? (
<>
<Alert
type="error"
showIcon
message="该现货与订单规格不一致,确认要强制匹配?"
description="系统/区服/系列/钻级不符通常是发错货;确实要指定请勾选下方确认并填写原因,操作会留痕。"
/>
<Checkbox
checked={confirmedMismatch}
onChange={(e) => setConfirmedMismatch(e.target.checked)}
>
确认规格不一致,仍要手动匹配
</Checkbox>
</>
) : null}
{requirement.needsNote ? (
<Input.TextArea
rows={2}
maxLength={200}
showCount
placeholder="手动匹配原因(必填,会写入工单留痕,便于事后核对)"
value={matchNote}
onChange={(e) => setMatchNote(e.target.value)}
/>
) : null}
<Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
确认后现货立即被占用并进入「待加好友」,双方加好买家好友后点确认加好友进入赠送冷却期。
如需退回请使用「撤销寄售履约」。
</Paragraph>
</Space>
</div>
) : null}
</Space>
</Modal>
)
}
@@ -12,13 +12,25 @@ import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
function resolveScoreTag(pair: PendingPistolMatchPair) {
if (pair.matchType === 'exact') return { color: 'green', label: '精确' }
if (pair.quantitySufficient === false) return { color: 'orange', label: '数量不足' }
if (pair.matchType === 'exact') {
return pair.autoMatchable === false
? { color: 'gold', label: '需人工' }
: { color: 'green', label: '精确' }
}
if (pair.matchType === 'conflict') return { color: 'default', label: '不匹配' }
if (pair.autoMatchable) return { color: 'blue', label: '可自动' }
if (pair.score >= 90) return { color: 'blue', label: '非精确' }
if (pair.score >= 70) return { color: 'orange', label: '非精确' }
return { color: 'gold', label: '需核对' }
}
function resolveOrderStatusTag(status?: string) {
if (status === 'internal_matching') return { color: 'purple', label: '内部匹配中' }
if (status === 'unassigned') return { color: 'default', label: '待分配' }
return { color: 'blue', label: '待抢单' }
}
export default function ConsignmentPendingMatchModal({
open,
onClose,
@@ -106,9 +118,28 @@ export default function ConsignmentPendingMatchModal({
<Text type="secondary" style={{ fontSize: 11 }}>
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
</Text>
{Number(record.totalQuantity || 1) > 1 ? (
<Text type="secondary" style={{ fontSize: 11 }}>
拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份
<Space size={4} wrap>
<Tag
color={resolveOrderStatusTag(record.workOrderStatus).color}
style={{ marginInlineEnd: 0 }}
>
{resolveOrderStatusTag(record.workOrderStatus).label}
</Tag>
{Number(record.totalQuantity || 1) > 1 ? (
<Text type="secondary" style={{ fontSize: 11 }}>
拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份
</Text>
) : null}
</Space>
{Number(record.requiredQuantity || 0) > 0 ? (
<Text
type={record.quantitySufficient === false ? 'danger' : 'secondary'}
style={{ fontSize: 11 }}
>
{record.sharingEnabled
? `拼单还需 ${record.requiredQuantity} 份 / 该库存剩 ${record.availableQuantity ?? '-'} 件`
: `需求 ${record.requiredQuantity} 件 / 该库存剩 ${record.availableQuantity ?? '-'} 件`}
{record.quantitySufficient === false ? '(整单汇总不足)' : ''}
</Text>
) : null}
</Space>
@@ -198,7 +229,11 @@ export default function ConsignmentPendingMatchModal({
return (
<Modal
title={categoryName ? `匹配 · ${categoryName} 待抢单 × 在库现货` : '匹配 · 待抢单 × 在库现货'}
title={
categoryName
? `匹配 · ${categoryName} 待匹配订单 × 在库现货`
: '匹配 · 待匹配订单 × 在库现货'
}
open={open}
width="min(1320px, 94vw)"
footer={null}
@@ -211,19 +246,21 @@ export default function ConsignmentPendingMatchModal({
showIcon
message={
categoryName
? `只统计「${categoryName}」分类下的待抢单 × 在库待匹配现货,不直接改单。`
: '按寄卖配置匹配全部待抢单 × 在库待匹配现货,不直接改单。'
? `只统计「${categoryName}」分类下的待匹配订单 × 在库待匹配现货,不直接改单。`
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货,不直接改单。'
}
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);拼单订单按剩余份数展示。"
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);内部匹配中、待分配、待抢单的工单都会纳入。数量不足或没有正向规格信号的非精确项会单独标记,自动匹配不会选中。"
/>
<Space wrap size={12}>
{categoryName ? <Tag color="blue">分类:{categoryName}</Tag> : null}
<Tag>待抢单 {data?.pendingOrderCount ?? 0} 单</Tag>
<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>
<Tag color="purple">数量不足 {data?.quantityShortCount ?? 0}</Tag>
<Tag color="cyan">可自动 {data?.autoMatchableCount ?? 0}</Tag>
{includeConflicts ? <Tag>不匹配 {data?.conflictCount ?? 0}</Tag> : null}
</Space>
@@ -52,7 +52,7 @@ import {
updateAdminConsignmentInventory,
} from '@/services/admin/consignment'
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import ConsignmentInventoryMatchModal from '@/components/admin/ConsignmentInventoryMatchModal'
import type {
ConsignmentInventory,
ConsignmentInventoryFilterField,
@@ -1466,19 +1466,13 @@ export default function ConsignmentInventoryPanel() {
</Form>
</Modal>
{/* 手动匹配订单:从现货出发挑订单(与订单侧共用同一弹窗) */}
<ConsignmentManualMatchModal
{/* 匹配订单:从现货出发,走新引擎(数量/配置/重新匹配排除与看板同口径) */}
<ConsignmentInventoryMatchModal
key={manualMatchItem?.id ?? 'none'}
open={Boolean(manualMatchItem)}
inventoryId={manualMatchItem?.id ?? null}
inventoryLabel={manualMatchItem?.itemName}
onClose={() => setManualMatchItem(null)}
inventory={
manualMatchItem
? {
inventoryId: manualMatchItem.id,
label: manualMatchItem.itemName,
inboundAt: manualMatchItem.inboundAt || manualMatchItem.createdAt,
}
: null
}
onMatched={() => void inventoriesQuery.refetch()}
/>
@@ -92,20 +92,17 @@ import {
import type { CustomerRefundPreview } from '@/services/admin/worker-platform'
import {
checkWorkOrderConsignmentMatches,
fulfillWorkOrderWithConsignment,
matchAdminConsignmentWorkOrder,
revokeWorkOrderConsignmentFulfillment,
} from '@/services/admin/consignment'
import { notifyVoucherConsume } from '@/utils/voucher-consume'
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { canManuallyMatchConsignmentOrder } from '@/utils/consignment-manual-match'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import {
isConsignmentOrder,
isInternalConsignmentOrder,
resolveConsignmentScreenshotUrls,
} from '@/utils/consignment-display'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import ConsignmentWorkOrderMatchModal from '@/components/admin/ConsignmentWorkOrderMatchModal'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
@@ -433,7 +430,6 @@ export default function WorkOrdersPanel() {
}>()
// 寄售库存匹配拦截弹窗状态
const [manualMatchOrder, setManualMatchOrder] = useState<WorkOrder | null>(null)
const [consignmentMatchOrder, setConsignmentMatchOrder] = useState<WorkOrder | null>(null)
const [consignmentInterceptModal, setConsignmentInterceptModal] = useState<{
open: boolean
@@ -1207,17 +1203,23 @@ export default function WorkOrdersPanel() {
if (!consignmentInterceptModal.order) return
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: true }))
try {
const res = await fulfillWorkOrderWithConsignment({
const res = await matchAdminConsignmentWorkOrder({
workOrderId: consignmentInterceptModal.order.workOrderId,
inventoryId,
allowPartial,
inventoryIds: [inventoryId],
allowNonExact: allowPartial,
reason: allowPartial ? '内部寄售库存手动匹配(非精确)' : '',
})
message.success('已占用内部库存,跳过打手资料,已进入待加好友阶段')
notifyVoucherConsume(res?.data?.voucherConsume)
if (res.data?.matched === false) {
message.warning('工单状态或库存已变化,请刷新后重试')
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: false }))
await refreshAll()
return
}
message.success('已占用内部库存并匹配工单')
setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false })
await refreshAll()
} catch (err: any) {
message.error(err.message || '内部履约失败')
message.error(err.message || '内部库存匹配失败')
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: false }))
}
}
@@ -2066,18 +2068,9 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{/*
手动指定现货必须**常驻**:只在"检测到候选"的提醒弹窗里给入口,
订单没有任何系统候选时客服反而进不去——而那正是最需要人工指定的时候。
寄卖匹配只保留新引擎入口:旧的 /fulfill 手动匹配不支持数量拆分,
还会绕过 profile/重新匹配排除,已从 UI 收敛掉。
*/}
{canFulfillConsignment && canManuallyMatchConsignmentOrder(row) ? (
<Button
icon={<ThunderboltOutlined />}
onClick={() => setManualMatchOrder(row)}
title="从全部在库现货里人工指定一件(支持规格冲突强制匹配,需填原因)"
>
手动匹配寄售
</Button>
) : null}
{canFulfillConsignment &&
['internal_matching', 'unassigned', 'open'].includes(String(row.status || '')) ? (
<Button
@@ -4411,10 +4404,10 @@ export default function WorkOrdersPanel() {
matchData: null,
submitting: false,
})
setManualMatchOrder(currentOrder)
setConsignmentMatchOrder(currentOrder)
}}
>
手动指定现货匹配
内部库存匹配
</Button>
) : null,
<Button
@@ -4627,23 +4620,6 @@ export default function WorkOrdersPanel() {
}}
/>
<ConsignmentManualMatchModal
open={Boolean(manualMatchOrder)}
onClose={() => setManualMatchOrder(null)}
workOrder={
manualMatchOrder
? {
workOrderId: manualMatchOrder.workOrderId,
label: `#${manualMatchOrder.workOrderId} ${manualMatchOrder.productName || ''}`,
createdAt: manualMatchOrder.createdAt,
}
: null
}
onMatched={() => {
void refreshAll()
}}
/>
{/* 撤单 / 撤销寄售履约弹窗 */}
<Modal
title={`撤单 / 撤销寄售履约 · ${getDisplayOrderNo(revokeConsignmentModal.order)}`}
@@ -268,10 +268,12 @@ export function fetchInventoryMatchableOrders(
)
}
/** 全局匹配看板:待抢单 × 在库待匹配现货,按细粒度评分排序。 */
/** 全局匹配看板:待匹配订单 × 在库待匹配现货,按细粒度评分排序。 */
export function fetchPendingPistolMatches(params?: {
/** 只统计该寄卖分类;不传 = 全部 */
categoryId?: number
/** 只统计这一条库存(库存侧「匹配订单」用) */
inventoryId?: number
keyword?: string
includeConflicts?: boolean
limit?: number
@@ -280,6 +282,7 @@ export function fetchPendingPistolMatches(params?: {
'/api/v1/admin/worker-platform/consignment/pending-pistol-matches',
{
categoryId: params?.categoryId,
inventoryId: params?.inventoryId,
keyword: params?.keyword?.trim() || undefined,
includeConflicts: params?.includeConflicts ? 'true' : undefined,
limit: params?.limit,
+14
View File
@@ -359,6 +359,18 @@ export type PendingPistolMatchPair = {
totalQuantity?: number
/** 命中的寄卖配置 id;无配置时为 null(旧手枪兜底) */
profileId?: number | null
/** 本单一次需要匹配的数量(整单=总数量,拼单=剩余份数) */
requiredQuantity?: number
/** 该条库存的可匹配余量 */
availableQuantity?: number
/** 该库存余量是否够本单一次匹配;拼单按整单汇总判断 */
quantitySufficient?: boolean
/** 自动匹配是否真会选中:精确,或配置允许非精确且订单有正向规格信号 */
autoMatchable?: boolean
/** 是否为拼单工单 */
sharingEnabled?: boolean
/** 工单当前状态:internal_matching / open / unassigned */
workOrderStatus?: string
}
export type PendingPistolMatchResult = {
@@ -368,6 +380,8 @@ export type PendingPistolMatchResult = {
exactCount: number
partialCount: number
conflictCount: number
quantityShortCount: number
autoMatchableCount: number
truncated: boolean
pairs: PendingPistolMatchPair[]
}
@@ -4,9 +4,8 @@ import { fileURLToPath } from 'node:url'
import test from 'node:test'
/**
* 手动匹配弹窗曾经被插到「编辑寄售物品」弹窗**内部**,导致外层弹窗没打开时它根本没挂载:
* 用户点「手动匹配订单」什么都不会发生。这类"点了没反应"的问题在浏览器里才看得出来,
* 这里用静态检查把它钉住:组件必须挂在顶层(前面不能有未闭合的 Modal)。
* 手动匹配弹窗必须挂在页面顶层,不能嵌在别的 Modal 里:
* 外层弹窗没打开时它根本不会挂载,用户点按钮会「没反应」。
*/
function assertTopLevelMount(filePath: string, componentName: string) {
const source = readFileSync(filePath, 'utf8')
@@ -23,19 +22,19 @@ function assertTopLevelMount(filePath: string, componentName: string) {
)
}
test('库存面板:手动匹配订单弹窗挂在顶层(按钮点了必须能弹出来)', () => {
test('库存面板:匹配订单弹窗挂在顶层(按钮点了必须能弹出来)', () => {
assertTopLevelMount(
fileURLToPath(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url),
),
'ConsignmentManualMatchModal',
'ConsignmentInventoryMatchModal',
)
})
test('订单面板:手动指定现货弹窗同样挂在顶层', () => {
test('订单面板:内部库存匹配弹窗同样挂在顶层', () => {
assertTopLevelMount(
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'ConsignmentManualMatchModal',
'ConsignmentWorkOrderMatchModal',
)
})
@@ -57,8 +56,6 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
),
'utf8',
)
// 库存面板:手动匹配订单按钮要与编辑/作废拆开 —— 前者 fulfill(提交打到
// /fulfill、/assign-worker,后端要求 consignment.fulfill),后者 manage
assert.ok(
inventoryPanel.includes("resolveAdminPermission(sessionPermissions, 'consignment.fulfill')"),
'库存面板必须解析 consignment.fulfill 权限',
@@ -74,56 +71,50 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'utf8',
)
// 拦截弹窗 footer 的入口同样要门控,无 fulfill 权限的人不该看到能直达 403 的按钮
const footerButtonIndex = ordersPanel.indexOf('手动指定现货匹配')
assert.ok(footerButtonIndex > 0, '找不到「手动指定现货匹配」按钮')
const footerButtonIndex = ordersPanel.lastIndexOf('内部库存匹配')
assert.ok(footerButtonIndex > 0, '找不到「内部库存匹配」按钮')
const beforeFooterButton = ordersPanel.slice(0, footerButtonIndex)
assert.ok(
beforeFooterButton.lastIndexOf('canFulfillConsignment ?') >
beforeFooterButton.lastIndexOf('footer={['),
'拦截弹窗 footer 的「手动指定现货匹配」必须按 canFulfillConsignment 门控',
'拦截弹窗 footer 的「内部库存匹配」必须按 canFulfillConsignment 门控',
)
})
test('手动匹配弹窗:数据过期的 409 要清选择并刷新,走内部现货直接履约', () => {
test('库存侧匹配弹窗:必须走新引擎,不再调用旧 /fulfill', () => {
const source = readFileSync(
fileURLToPath(
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url),
new URL('../src/components/admin/ConsignmentInventoryMatchModal.tsx', import.meta.url),
),
'utf8',
)
// 并发占用/规格在打开弹窗后被改 → 清空选择并重新拉列表,不能让运营循环撞同一个错
assert.ok(source.includes('consignment_inventory_race_condition'), '要处理并发占用错误码')
assert.ok(source.includes('consignment_spec_mismatch'), '要处理规格变化错误码')
assert.ok(source.includes('setSelected(null)'), '数据过期时要清空已选')
// 统一内部现货直接履约:调用 fulfillWorkOrderWithConsignment,免外派佣金与二次押金
assert.ok(source.includes('fetchPendingPistolMatches'), '库存侧必须复用待匹配看板的统一判定')
assert.ok(source.includes('inventoryId: Number(inventoryId)'), '必须按当前库存过滤候选工单')
assert.ok(
source.includes('fulfillWorkOrderWithConsignment'),
'手动匹配必须调用内部现货直接履约接口',
source.includes('matchAdminConsignmentWorkOrder'),
'库存侧落库必须调用新引擎 /consignment/match',
)
assert.ok(
!source.includes('assignWorkOrderWithConsignmentWorker'),
'手动匹配不得再调用旧的指派打手接口',
)
// 凭证核销反馈与关键字搜索:用共享出口 / 透传后端,不内联复制
assert.ok(source.includes("from '@/utils/voucher-consume'"), '核销反馈必须用共享出口')
assert.ok(source.includes('keyword: keyword.trim() || undefined'), '库存侧关键字要透传后端')
assert.ok(!source.includes('fulfillWorkOrderWithConsignment'), '库存侧不得再调用旧 /fulfill 链路')
assert.ok(source.includes('quantitySufficient'), '数量不足的工单必须禁用选择')
assert.ok(source.includes('允许非精确/冲突强制匹配'), '非精确必须显式勾选')
})
test('发布拦截弹窗:只走内部现货履约,文案停在待加好友而不是冷静期', () => {
test('订单面板:拦截弹窗与行内入口都只走新引擎', () => {
const source = readFileSync(
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'utf8',
)
assert.ok(source.includes('fulfillWorkOrderWithConsignment'), '拦截弹窗必须调用内部现货履约')
assert.ok(source.includes('matchAdminConsignmentWorkOrder'), '拦截弹窗必须调用新引擎匹配')
assert.ok(
!source.includes('fulfillWorkOrderWithConsignment'),
'订单面板不得再调用旧 /fulfill 链路',
)
assert.ok(
!source.includes('assignWorkOrderWithConsignmentWorker'),
'拦截弹窗不得再调用旧的指派打手接口',
'订单面板不得调用旧的指派打手接口',
)
assert.ok(!source.includes('手动匹配寄售'), '旧「手动匹配寄售」入口必须移除')
assert.ok(source.includes('进入待加好友'), '拦截弹窗必须写明工单进入待加好友')
assert.ok(!source.includes('自动进冷静期'), '拦截弹窗不得再写自动进冷静期')
assert.ok(!source.includes('进入赠送冷静期'), '成功提示不得再写成进入赠送冷静期')
assert.ok(source.includes('入库时间:'), '拦截弹窗现货展示必须包含入库时间')
})
test('寄售仓库库存面板:必须包含「通过审核入库时间」列,且详情弹窗正确展示时间', () => {
@@ -139,33 +130,4 @@ test('寄售仓库库存面板:必须包含「通过审核入库时间」列
'表格必须格式化入库时间',
)
assert.ok(source.includes('label="通过审核入库时间"'), '详情弹窗必须包含「通过审核入库时间」项')
assert.ok(
source.includes('inboundAt: manualMatchItem.inboundAt || manualMatchItem.createdAt'),
'打开手动匹配弹窗时必须透传现货的入库时间',
)
})
test('手动匹配弹窗:候选订单必须展示创建时间,现货展示审核入库时间', () => {
const source = readFileSync(
fileURLToPath(
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url),
),
'utf8',
)
assert.ok(
source.includes('创建时间 {formatDateTime(order.createdAt)}'),
'候选订单卡片必须展示工单创建时间',
)
assert.ok(
source.includes('审核入库:${formatDateTime(inventoryInboundAt)}'),
'弹窗现货提示栏必须展示审核入库时间',
)
assert.ok(
source.includes('审核入库于 {formatDateTime(selected.inventoryInboundAt)}'),
'已选现货概要必须展示审核入库时间',
)
assert.ok(
source.includes('创建于 {formatDateTime(selected.workOrderCreatedAt)}'),
'已选订单概要必须展示创建时间',
)
})