feat(consignment): 统一寄卖匹配看板口径并收口占用与重匹配
- 看板与引擎统一数量、模板规格、正向规格信号判定,纳入 internal_matching/unassigned - 库存侧改走新引擎匹配,移除旧 /fulfill 手动入口与旧弹窗 - 验收时将 reserved allocation 收口为 consumed,补份额级收口 - 拼单重新匹配通过 material 排除全部原库存/原卖家 - 开发/测试环境放宽迁移顺序校验,生产保持严格;修正集成测试份额报酬口径
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)}'),
|
||||
'已选订单概要必须展示创建时间',
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user