fix(consignment): 手动匹配寄卖后刷新工单列表并展示寄卖价格

This commit is contained in:
2026-11-07 15:57:39 +08:00
parent 8920afa0e5
commit 0e9087c8bb
11 changed files with 712 additions and 75 deletions
@@ -1,4 +1,16 @@
import { App, Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
import {
App,
Alert,
Button,
Card,
Checkbox,
Empty,
Input,
Modal,
Space,
Tag,
Typography,
} from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
@@ -8,11 +20,26 @@ import {
matchAdminConsignmentWorkOrder,
} from '@/services/admin/consignment'
import type { ConsignmentMatchCandidate } from '@/types/consignment'
import {
computeConsignmentMatchScore,
describeConsignmentMatch,
resolveMatchCoverage,
} from '@/utils/consignment-match-coverage'
function money(fen: number) {
return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}`
}
function resolveScoreTag(item: ConsignmentMatchCandidate) {
const type = item.matchType || 'exact'
const score = computeConsignmentMatchScore(item)
if (type === 'conflict') return { color: 'default', label: '不匹配' }
if (type === 'exact') return { color: 'green', label: '精确' }
if (score >= 90) return { color: 'blue', label: '非精确' }
if (score >= 70) return { color: 'orange', label: '非精确' }
return { color: 'gold', label: '需核对' }
}
export default function ConsignmentWorkOrderMatchModal({
open,
workOrderId,
@@ -26,6 +53,7 @@ export default function ConsignmentWorkOrderMatchModal({
}) {
const { message } = App.useApp()
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [includeConflicts, setIncludeConflicts] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [nonExactReason, setNonExactReason] = useState('')
@@ -35,55 +63,127 @@ export default function ConsignmentWorkOrderMatchModal({
enabled: open && Boolean(workOrderId),
})
const data = candidatesQuery.data?.data
const options = data?.options || []
const options = useMemo(() => data?.options || [], [data?.options])
const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1))
// 整单匹配只需一条库存(由该库存一次占满订单总数量);拼单才按剩余份数选多条。
const needed = data?.sharingEnabled
? Math.max(1, totalQuantity - Number(data?.activeQuantity || 0))
: 1
const activeQuantity = Math.max(0, Number(data?.activeQuantity || 0))
const sharingEnabled = Boolean(data?.sharingEnabled)
const remainingNeeded = sharingEnabled
? Math.max(0, totalQuantity - activeQuantity)
: totalQuantity
const visibleOptions = useMemo(
() =>
includeConflicts
? options
: options.filter((item) => (item.matchType || 'exact') !== 'conflict'),
[includeConflicts, options],
)
const selectedOptions = useMemo(
() => options.filter((item) => selectedIds.includes(item.inventoryId)),
[options, selectedIds],
)
const coverage = useMemo(
() =>
resolveMatchCoverage({
sharingEnabled,
totalQuantity,
activeQuantity,
selected: selectedOptions.map((item) => ({
inventoryId: item.inventoryId,
remainingQuantity: item.remainingQuantity,
})),
}),
[activeQuantity, selectedOptions, sharingEnabled, totalQuantity],
)
const counts = useMemo(() => {
let exact = 0
let partial = 0
let conflict = 0
for (const item of options) {
const type = item.matchType || 'exact'
if (type === 'exact') exact += 1
else if (type === 'conflict') conflict += 1
else partial += 1
}
return { exact, partial, conflict }
}, [options])
useEffect(() => {
if (!open) {
setSelectedIds([])
setSelectedIds((prev) => (prev.length ? [] : prev))
setIncludeConflicts(false)
setNonExactReason('')
return
}
if (options.length === 0) return
// 默认只选精确匹配;非精确库存必须人工勾选并填写原因。
// 整单还要保证一条库存能一次满足订单总数量,否则默认选中也会被后端拒绝。
if (options.length === 0 || remainingNeeded <= 0) {
setSelectedIds((prev) => (prev.length ? [] : prev))
return
}
// 默认只选精确库存;整单必须一条满足总量,拼单按剩余份数累加到一次拼满。
const exactOptions = options.filter(
(item) =>
(item.matchType || 'exact') === 'exact' &&
(!data?.sharingEnabled || item.remainingQuantity >= 1) &&
(data?.sharingEnabled || item.remainingQuantity >= totalQuantity),
(item) => (item.matchType || 'exact') === 'exact' && Number(item.remainingQuantity || 0) > 0,
)
setSelectedIds(exactOptions.slice(0, needed).map((item) => item.inventoryId))
}, [open, options, needed, data?.sharingEnabled, totalQuantity])
if (!sharingEnabled) {
const chosen = exactOptions.find(
(item) => Number(item.remainingQuantity || 0) >= totalQuantity,
)
setSelectedIds(chosen ? [chosen.inventoryId] : [])
return
}
let covered = 0
const ids: number[] = []
for (const item of exactOptions) {
if (covered >= remainingNeeded) break
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])
const nonExactSelected = useMemo(
() =>
selectedIds.some((id) => {
const item = options.find((option) => option.inventoryId === id)
return Boolean(item?.matchType && item.matchType !== 'exact')
selectedOptions.some((item) => {
const type = item.matchType || 'exact'
return type !== 'exact'
}),
[options, selectedIds],
[selectedOptions],
)
const totalPrice = useMemo(
() =>
selectedIds.reduce((sum, id) => {
const item = options.find((option) => option.inventoryId === id)
return sum + Number(item?.salePriceFen || 0)
}, 0),
[options, selectedIds],
)
const canSubmit =
selectedIds.length > 0 &&
coverage.sufficient &&
(!nonExactSelected || nonExactReason.trim().length > 0)
const infoMessage =
remainingNeeded <= 0
? '该工单已匹配满,无需再次匹配。'
: sharingEnabled
? `该工单拼单 ${totalQuantity} 份,已匹配 ${activeQuantity} 份,本次还需 ${remainingNeeded} 份;必须一次选满才能提交。`
: `该工单为整单匹配,请选择 1 条可一次满足 ${totalQuantity} 件的库存。`
return (
<Modal
title="寄卖库存匹配"
title="手动匹配寄卖"
open={open}
onCancel={onClose}
width={860}
width="min(1180px, 96vw)"
destroyOnHidden
footer={
<Space>
@@ -91,7 +191,7 @@ export default function ConsignmentWorkOrderMatchModal({
<Button
type="primary"
loading={submitting}
disabled={selectedIds.length === 0 || (nonExactSelected && !nonExactReason.trim())}
disabled={!canSubmit}
onClick={async () => {
if (!workOrderId) return
setSubmitting(true)
@@ -128,29 +228,62 @@ export default function ConsignmentWorkOrderMatchModal({
{options.length > 0 ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
type={remainingNeeded <= 0 ? 'warning' : 'info'}
showIcon
message={
data?.sharingEnabled
? `该工单拼单 ${totalQuantity} 份,已匹配 ${data.activeQuantity} 份,本次还需 ${needed} 份库存。`
: `该工单为整单匹配,请选择 1 条可一次满足 ${totalQuantity} 件的库存。`
}
description="自动匹配只做精确规格;拼单工单可多选,每条库存按剩余数量生成份额。选择非精确库存必须填写原因。"
message={infoMessage}
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"
pagination={false}
dataSource={options}
loading={candidatesQuery.isFetching}
pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 1050, y: 420 }}
dataSource={visibleOptions}
rowSelection={{
// 整单只能选一条;拼单可多选,每条库存按剩余数量生成份额。
type: data?.sharingEnabled ? 'checkbox' : 'radio',
// 整单只能选一条;拼单可多选,必须覆盖剩余份数才能提交。
type: sharingEnabled ? 'checkbox' : 'radio',
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))),
}}
columns={[
{
title: '匹配打分',
key: 'score',
width: 100,
render: (_, item) => {
const score = computeConsignmentMatchScore(item)
const tag = resolveScoreTag(item)
return (
<Space direction="vertical" size={3} style={{ width: '100%' }}>
<span style={{ fontSize: 18, fontWeight: 700, lineHeight: 1 }}>{score}</span>
<Tag color={tag.color} style={{ marginInlineEnd: 0 }}>
{tag.label}
</Tag>
</Space>
)
},
},
{
title: '寄卖库存',
key: 'inventory',
width: 210,
render: (_, item) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{item.itemName}</Typography.Text>
@@ -162,33 +295,15 @@ export default function ConsignmentWorkOrderMatchModal({
},
{
title: '规格',
key: 'spec',
width: 210,
render: (_, item) =>
item.specText ||
`${item.system}/${item.serverZone}/${item.series}/${item.diamondLevel}钻/${item.brillianceCount}璀`,
},
{
title: '匹配',
width: 90,
render: (_, item) => {
const type = item.matchType || 'exact'
const label =
type === 'exact'
? '精确'
: type === 'partial'
? '高配'
: type === 'unknown'
? '未知'
: '冲突'
const color = type === 'exact' ? 'green' : type === 'partial' ? 'orange' : 'red'
return (
<Tag color={color} title={item.matchReasons?.join('、') || ''}>
{label}
</Tag>
)
},
},
{
title: '库存',
key: 'stock',
width: 90,
render: (_, item) => (
<Tag color={item.remainingQuantity > 1 ? 'blue' : 'default'}>
@@ -198,11 +313,44 @@ export default function ConsignmentWorkOrderMatchModal({
},
{
title: '售价',
width: 110,
key: 'price',
width: 100,
render: (_, item) => money(item.salePriceFen),
},
{
title: '匹配说明',
key: 'reason',
width: 280,
render: (_, item) => (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Typography.Text style={{ fontSize: 12 }}>
{describeConsignmentMatch(item)}
</Typography.Text>
{(item.matchDetails || [])
.filter((detail) => detail.status !== 'match' && detail.status !== 'ignored')
.slice(0, 3)
.map((detail) => (
<Typography.Text key={detail.key} type="secondary" style={{ fontSize: 11 }}>
{detail.label}:订单「{detail.orderValue || '-'}」/ 库存「
{detail.inventoryValue || '-'}」
</Typography.Text>
))}
</Space>
),
},
]}
/>
{selectedIds.length > 0 && !coverage.sufficient ? (
<Alert
type="warning"
showIcon
message={
sharingEnabled
? `还差 ${coverage.gap} 份,拼单必须一次拼满才能提交`
: `所选库存仅剩 ${coverage.selectedQuantity} 件,整单需要 ${coverage.requiredQuantity} 件`
}
/>
) : null}
{nonExactSelected ? (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Alert
@@ -219,7 +367,8 @@ export default function ConsignmentWorkOrderMatchModal({
</Space>
) : null}
<Typography.Text type="secondary">
已选 {selectedIds.length} 条,合计成本 {money(totalPrice)}
已选 {selectedIds.length} 条,覆盖 {coverage.selectedQuantity}{' '}
{sharingEnabled ? '份' : '件'}
</Typography.Text>
</Space>
) : null}
@@ -1834,6 +1834,32 @@ export default function WorkOrdersPanel() {
<AmountEditableCell order={row} field="rewardAmount" onSaved={refreshAll} />
),
},
{
title: '寄卖价格',
width: 120,
render: (_, row) => {
// 拼单的价格按份额展示在「拼单明细」,主列不汇总,避免口径混淆。
if (row.sharing?.enabled) {
return <Typography.Text type="secondary">-</Typography.Text>
}
const totalPriceFen = Number(row.consignmentSaleTotalFen || 0)
if (totalPriceFen <= 0) {
return <Typography.Text type="secondary">-</Typography.Text>
}
const unitPriceFen = Number(row.consignmentSalePriceFen || 0)
const quantity = Math.max(1, Number(row.consignmentSaleQuantity || 1))
return (
<Space direction="vertical" size={0}>
<Typography.Text strong>{formatMoney(totalPriceFen)}</Typography.Text>
{quantity > 1 ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
单价 {formatMoney(unitPriceFen)} × {quantity}
</Typography.Text>
) : null}
</Space>
)
},
},
{
title: '押金',
width: 130,
@@ -2055,11 +2081,16 @@ export default function WorkOrdersPanel() {
{canFulfillConsignment &&
['internal_matching', 'unassigned', 'open'].includes(String(row.status || '')) ? (
<Button
type={String(row.status || '') === 'internal_matching' ? 'primary' : 'default'}
icon={<ThunderboltOutlined />}
onClick={() => setConsignmentMatchOrder(row)}
title="按寄卖配置精确匹配库存;拼单工单可选多条库存一起拼"
title={
String(row.status || '') === 'internal_matching'
? '手动选择寄卖库存并立即匹配;拼单必须一次拼满'
: '按寄卖配置精确匹配库存;拼单工单可选多条库存一起拼'
}
>
内部库存匹配
{String(row.status || '') === 'internal_matching' ? '手动匹配寄卖' : '内部库存匹配'}
</Button>
) : null}
{canCustomerRefund &&
@@ -2471,7 +2502,7 @@ export default function WorkOrdersPanel() {
setPageSize(nextPageSize)
},
})}
scroll={{ x: 1240 }}
scroll={{ x: 1360 }}
/>
</Card>
<Drawer
@@ -3693,7 +3724,7 @@ export default function WorkOrdersPanel() {
loading={sharingQuery.isFetching}
pagination={false}
tableLayout="fixed"
scroll={{ x: 1914 }}
scroll={{ x: 2038 }}
rowClassName={(share) =>
share.problemMarked
? 'admin-share-row-problem'
@@ -3716,6 +3747,25 @@ export default function WorkOrdersPanel() {
align: 'right',
render: (_, share) => formatMoney(share.shareReward),
},
{
title: '寄卖价格',
width: 124,
align: 'right',
render: (_, share) => {
if (!share.consignmentInventoryId) return '-'
const unitPriceFen = Number(share.consignmentSalePriceFen || 0)
if (unitPriceFen <= 0) return '-'
const quantity = Math.max(1, Number(share.quantity || 1))
return (
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}>
<span>{formatMoney(unitPriceFen)}/份</span>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
合计 {formatMoney(unitPriceFen * quantity)}
</Typography.Text>
</Space>
)
},
},
{
title: '状态',
// 状态 + 取消原因两个 Tag 纵向排列;宽度按最长原因「后台取消部分」留足,
@@ -4567,9 +4617,13 @@ export default function WorkOrdersPanel() {
workOrderId={consignmentMatchOrder?.workOrderId ?? null}
onClose={() => setConsignmentMatchOrder(null)}
onMatched={() => {
void queryClient.invalidateQueries({ queryKey: ['admin-work-orders'] })
void queryClient.invalidateQueries({ queryKey: ['admin-work-orders-stats'] })
void queryClient.invalidateQueries({ queryKey: ['admin-consignment-inventories'] })
void refreshAll()
const matchedOrderId = consignmentMatchOrder?.workOrderId
if (matchedOrderId) {
void queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-order-sharing', matchedOrderId],
})
}
}}
/>
@@ -4586,7 +4640,7 @@ export default function WorkOrdersPanel() {
: null
}
onMatched={() => {
void ordersQuery.refetch()
void refreshAll()
}}
/>
@@ -617,6 +617,8 @@ export type WorkOrderShare = {
cancelQuantity?: number
/** 寄卖拼单:本份额对应的库存与加好友/赠送冷却状态 */
consignmentInventoryId?: number | null
/** 本份额寄卖价格(分,匹配时快照;普通份额为 null) */
consignmentSalePriceFen?: number | null
friendAddedAt?: string | null
giftAvailableAt?: string | null
/** 份额级问题单:只影响该份额持有人,母单状态不变 */
@@ -704,6 +706,10 @@ export type WorkOrder = {
canResetGiftFlow?: boolean
}
rewardAmount: number
/** 整单寄卖匹配价格(分);拼单为 null,价格在拼单明细展示 */
consignmentSalePriceFen?: number | null
consignmentSaleQuantity?: number | null
consignmentSaleTotalFen?: number | null
/** 来源订单实付金额(分):取消部分时用于预填退款金额。 */
sourceOrderAmount?: number
/** 最近一次修改用户付款金额前的金额(分);NULL 表示修改前金额未知,仅后台可见。 */
@@ -46,7 +46,7 @@ export const WORKER_PLATFORM_TAB_QUERY_KEYS: Record<string, string[][]> = {
['admin-consignment-submissions'],
['admin-consignment-submissions-pending-total'],
],
orders: [['admin-work-orders'], ['admin-work-orders-stats']],
orders: [['admin-worker-platform-orders'], ['admin-worker-platform-summary']],
'cancel-requests': [['admin-work-order-cancel-requests']],
'after-sales': [['admin-after-sales-orders'], ['admin-after-sales-stats']],
feedbacks: [['admin-feedbacks']],
@@ -0,0 +1,82 @@
import type { ConsignmentMatchCandidate } from '../types/consignment'
/** 与寄售仓库「一键查询待匹配订单」同一口径的 0-100 匹配打分。 */
export function computeConsignmentMatchScore(
item: Pick<ConsignmentMatchCandidate, 'matchType' | 'matchDetails'>,
): number {
const details = (item.matchDetails || []).filter((detail) => detail.status !== 'ignored')
if (details.length === 0) {
const type = item.matchType || 'exact'
if (type === 'exact') return 100
if (type === 'partial') return 85
if (type === 'unknown') return 50
return 0
}
const matched = details.reduce((sum, detail) => {
if (detail.status === 'match') return sum + 1
if (detail.status === 'partial') return sum + 0.85
if (detail.status === 'unknown') return sum + 0.5
return sum
}, 0)
return Math.round((matched / details.length) * 100)
}
/** 匹配说明:精确 / 高配 / 缺规格 / 冲突原因。 */
export function describeConsignmentMatch(
item: Pick<ConsignmentMatchCandidate, 'matchType' | 'matchReasons'>,
): string {
const type = item.matchType || 'exact'
if (type === 'exact') return '规格完全一致'
const reasons = (item.matchReasons || []).filter(Boolean)
if (reasons.length > 0) {
return `${type === 'conflict' ? '不匹配' : '待核对'}:${reasons.join(';')}`
}
if (type === 'partial') return '规格高于要求(非精确)'
return '需人工核对'
}
/**
* 选中的库存是否够一次拼满。
*
* - 拼单:按剩余份数累加,必须覆盖「总份数 - 已匹配份数」;
* - 整单:只能选一条,且该条剩余数量必须 >= 订单总数量。
*/
export function resolveMatchCoverage(input: {
sharingEnabled: boolean
totalQuantity: number
activeQuantity: number
selected: Array<Pick<ConsignmentMatchCandidate, 'inventoryId' | 'remainingQuantity'>>
}): {
requiredQuantity: number
selectedQuantity: number
sufficient: boolean
gap: number
} {
const total = Math.max(1, Math.round(Number(input.totalQuantity || 0)))
const active = Math.max(0, Math.round(Number(input.activeQuantity || 0)))
const remaining = input.selected.reduce(
(sum, item) => sum + Math.max(0, Math.round(Number(item.remainingQuantity || 0))),
0,
)
if (input.sharingEnabled) {
const required = Math.max(0, total - active)
return {
requiredQuantity: required,
selectedQuantity: remaining,
sufficient: required > 0 && remaining >= required,
gap: Math.max(0, required - remaining),
}
}
const selectedQuantity = input.selected.reduce(
(max, item) => Math.max(max, Math.round(Number(item.remainingQuantity || 0))),
0,
)
return {
requiredQuantity: total,
selectedQuantity,
sufficient: input.selected.length === 1 && selectedQuantity >= total,
gap: Math.max(0, total - selectedQuantity),
}
}