feat(consignment): 自动匹配改为进入待抢单前一次性匹配

1) 配置改为开启自动匹配 + 匹配订单生效时间,移除内部匹配等待与定时轮询;2) 所有进入待抢单的路径统一先自动匹配一次,未命中进大厅;3) 手动匹配仅允许待抢单;4) 新增迁移 160 回填配置并迁移存量 internal_matching。
This commit is contained in:
2026-11-13 13:25:05 +08:00
parent 5bda59f94e
commit 341f8b2000
27 changed files with 596 additions and 822 deletions
@@ -19,6 +19,7 @@ import {
App,
Button,
Card,
DatePicker,
Empty,
Form,
Input,
@@ -33,6 +34,7 @@ import {
Typography,
} from 'antd'
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
import dayjs, { type Dayjs } from 'dayjs'
import PercentTable from '@/components/common/PercentTable'
import ConsignmentTemplateCopyModal, {
@@ -93,11 +95,11 @@ type ConfigFormValues = {
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountYuan: number }
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
matching: {
mode: 'off' | 'manual' | 'auto'
autoMatchEnabled: boolean
exactOnly: boolean
preferLowReward: boolean
orderEffectiveFrom: Dayjs
}
internalMatch: { delayMinutes: number }
timeout: {
hours: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
@@ -208,19 +210,10 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
},
inventoryLimit: { ...config.inventoryLimit },
matching: {
mode: config.matching.mode || 'off',
autoMatchEnabled: Boolean(config.matching.autoMatchEnabled),
exactOnly: config.matching.exactOnly,
preferLowReward: config.matching.preferLowReward,
},
internalMatch: {
// 内部等待只属于手动匹配;非手动模式切回手动时给默认 30 分钟。
// 兼容旧配置/旧缓存缺少 internalMatch 的情况,不能直接读嵌套字段。
delayMinutes:
config.matching.mode === 'manual'
? Number(config.internalMatch?.delayMinutes || 0)
: Number(config.internalMatch?.delayMinutes ?? 0) > 0
? Number(config.internalMatch?.delayMinutes)
: 30,
orderEffectiveFrom: dayjs(config.matching.orderEffectiveFrom || undefined),
},
timeout: {
hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60),
@@ -257,11 +250,11 @@ function emptyFormValues(): ConfigFormValues {
serviceFee: { enabled: false, mode: 'percent', percent: 0, amountYuan: 0 },
inventoryLimit: { enabled: false, maxActiveQuantity: 0 },
matching: {
mode: 'off',
autoMatchEnabled: false,
exactOnly: true,
preferLowReward: true,
orderEffectiveFrom: dayjs(),
},
internalMatch: { delayMinutes: 30 },
timeout: { hours: 0.5, policy: 'reopen' },
priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 },
}
@@ -338,14 +331,11 @@ function buildPayload(
: 0,
},
matching: {
mode: values.matching.mode,
autoMatchEnabled: values.matching.autoMatchEnabled,
exactOnly: values.matching.exactOnly,
preferLowReward: values.matching.preferLowReward,
},
internalMatch: {
// 内部等待只属于手动匹配:关闭匹配不再等待;自动匹配不使用等待窗口。
delayMinutes:
values.matching.mode === 'off' ? 0 : Number(values.internalMatch?.delayMinutes || 0),
orderEffectiveFrom:
values.matching.orderEffectiveFrom?.toISOString() || new Date().toISOString(),
},
timeout: {
minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)),
@@ -1036,7 +1026,7 @@ export default function ConsignmentConfigPanel() {
const serviceFeeEnabled = Form.useWatch(['serviceFee', 'enabled'], form)
const serviceFeeMode = Form.useWatch(['serviceFee', 'mode'], form)
const inventoryLimitEnabled = Form.useWatch(['inventoryLimit', 'enabled'], form)
const matchingMode = Form.useWatch(['matching', 'mode'], form)
const autoMatchEnabled = Form.useWatch(['matching', 'autoMatchEnabled'], form)
const priceDropEnabled = Form.useWatch(['priceDrop', 'enabled'], form)
const priceDropMode = Form.useWatch(['priceDrop', 'mode'], form)
const headerStatus: ProfileStatusInfo = selectedProfile
@@ -1923,25 +1913,28 @@ export default function ConsignmentConfigPanel() {
<ConsignmentSectionTitle
index="08"
title="匹配设置"
description="匹配模式决定新单如何匹配库存;内部等待仅手动匹配使用"
description="开启后订单进入待抢单前自动匹配一次;关闭时维持人工匹配"
/>
</div>
<div style={gridStyle}>
<Form.Item
label="匹配模式"
name={['matching', 'mode']}
label="开启自动匹配"
name={['matching', 'autoMatchEnabled']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Select
options={[
{ value: 'off', label: '关闭自动匹配(仍可人工指定)' },
{ value: 'manual', label: '手动匹配' },
{ value: 'auto', label: '自动匹配' },
]}
/>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
{matchingMode !== 'off' ? (
{autoMatchEnabled ? (
<>
<Form.Item
label="匹配订单生效时间"
name={['matching', 'orderEffectiveFrom']}
extra="只自动匹配该时间之后创建的订单"
style={{ marginBottom: 0 }}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="仅精确匹配"
name={['matching', 'exactOnly']}
@@ -1951,45 +1944,30 @@ export default function ConsignmentConfigPanel() {
<Switch checkedChildren="仅精确" unCheckedChildren="允许高配" />
</Form.Item>
<Form.Item
label="低价订单优先"
label="低价库存优先"
name={['matching', 'preferLowReward']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
</Form.Item>
{matchingMode === 'manual' ? (
<Form.Item
label="手动匹配等待(分钟)"
name={['internalMatch', 'delayMinutes']}
extra="仅手动匹配生效:工单在内部池等待运营挑库存"
style={{ marginBottom: 0 }}
>
<InputNumber
min={0}
precision={0}
style={{ width: '100%' }}
addonAfter="分钟"
/>
</Form.Item>
) : null}
</>
) : null}
</div>
{matchingMode === 'off' ? (
{!autoMatchEnabled ? (
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message="关闭自动匹配后:新单直接进入接单大厅,不等待内部库存、也不会自动匹配;运营仍可在后台手动指定寄卖库存。"
message="关闭自动匹配后:订单直接进入待抢单,不等待内部库存、也不会自动匹配;运营仍可在后台对待抢单手动指定寄卖库存。"
/>
) : null}
{matchingMode === 'auto' ? (
{autoMatchEnabled ? (
<Alert
type="info"
showIcon
style={{ marginTop: 12 }}
message="自动匹配:发布/补资料时立即匹配,未命中直接进大厅,不使用内部等待窗口。"
message="开启自动匹配后:订单进入待抢单前自动匹配一次,命中直接进行中,未命中才进大厅;不再后台定时补配,且只匹配生效时间之后创建的订单。"
/>
) : null}
</section>
+10 -4
View File
@@ -367,9 +367,13 @@ export type PendingPistolMatchPair = {
quantitySufficient?: boolean
/** 自动匹配是否真会选中:精确,或配置允许非精确且订单有正向规格信号 */
autoMatchable?: boolean
/** 该订单所属配置是否开启自动匹配 */
autoMatchEnabled?: boolean
/** 自动匹配生效时间;订单创建时间早于它时不会自动匹配 */
orderEffectiveFrom?: string | null
/** 是否为拼单工单 */
sharingEnabled?: boolean
/** 工单当前状态:internal_matching / open / unassigned */
/** 工单当前状态:看板只纳入待抢单 open */
workOrderStatus?: string
}
@@ -432,12 +436,13 @@ export type ConsignmentProfileConfig = {
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountFen: number }
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
matching: {
/** off = 完全关闭匹配;manual = 人工匹配;auto = 自动调度 */
mode: 'off' | 'manual' | 'auto'
/** 是否开启自动匹配:订单进入待抢单前只尝试一次 */
autoMatchEnabled: boolean
exactOnly: boolean
preferLowReward: boolean
/** 只自动匹配该时间之后创建的订单(ISO) */
orderEffectiveFrom: string
}
internalMatch: { delayMinutes: number }
timeout: {
minutes: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
@@ -569,6 +574,7 @@ export type ConsignmentMatchCandidatesResponse = {
profileId: number | null
profileVersionId: number | null
matchingMode: 'off' | 'manual' | 'auto' | null
autoMatchEnabled?: boolean | null
exactOnly: boolean | null
categoryId: number | null
sharingEnabled: boolean
@@ -10,15 +10,19 @@ function readPanelSource() {
).replace(/\r\n/g, '\n')
}
test('手动匹配:只保留等待时长,不再提供「超时后回大厅」开关', () => {
test('自动匹配:开关 + 生效时间,不再有内部匹配等待与后台定时补配', () => {
const source = readPanelSource()
assert.ok(source.includes('label="手动匹配等待(分钟)"'), '等待时长文案必须明确只作用于手动匹配')
assert.ok(source.includes("matchingMode === 'manual' ? ("), '等待时长必须只在手动匹配下展示')
assert.ok(!source.includes('超时后回大厅'), '回大厅是固定行为,不应再有开关')
assert.ok(!source.includes('fallbackToHall'), '配置与表单必须移除 fallbackToHall')
assert.ok(source.includes('label="开启自动匹配"'), '必须提供自动匹配开关')
assert.ok(source.includes('label="匹配订单生效时间"'), '必须提供匹配订单生效时间')
assert.ok(
source.includes('自动匹配:发布/补资料时立即匹配,未命中直接进大厅,不使用内部等待窗口。'),
'auto 模式必须说明不使用等待窗口',
source.includes("name={['matching', 'orderEffectiveFrom']}"),
'生效时间必须绑定配置字段',
)
assert.ok(source.includes('showTime'), '生效时间必须支持精确到时分秒')
assert.ok(source.includes('只自动匹配该时间之后创建的订单'), '必须说明生效时间作用范围')
assert.ok(!source.includes('匹配模式'), '旧的匹配模式选项必须移除')
assert.ok(!source.includes('手动匹配等待'), '内部匹配等待窗口必须移除')
assert.ok(!source.includes('label="内部匹配等待(分钟)"'), '旧文案必须移除')
assert.ok(!source.includes('fallbackToHall'), '配置与表单必须移除 fallbackToHall')
assert.ok(source.includes('不再后台定时补配'), '必须说明已取消后台定时补配')
})
@@ -25,7 +25,7 @@ test('管理端:订单服务之后必须存在寄卖配置 tab', () => {
)
})
test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/内部匹配/超时/降价', () => {
test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/超时/降价', () => {
const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
for (const label of [
'打手端 tab 名称',
@@ -33,9 +33,9 @@ test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/
'按数量',
'开启服务费',
'开启单人寄售上限',
'匹配模式',
'关闭匹配',
'手动匹配等待(分钟)',
'开启自动匹配',
'匹配订单生效时间',
'仅精确匹配',
'代练超时(小时)',
'重新匹配并退还押金',
'开启降价提示',
@@ -44,10 +44,20 @@ test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/
}
})
test('寄卖配置:旧配置缺少 internalMatch 时不能崩溃', () => {
test('寄卖配置:旧配置缺少 autoMatchEnabled / orderEffectiveFrom 时不能崩溃', () => {
const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
assert.ok(panel.includes('config.internalMatch?.delayMinutes'), '读取旧配置必须使用可选链')
assert.ok(panel.includes('values.internalMatch?.delayMinutes'), '保存旧配置必须使用可选链')
assert.ok(
panel.includes('Boolean(config.matching.autoMatchEnabled)'),
'缺少 autoMatchEnabled 时必须用 Boolean 容错',
)
assert.ok(
panel.includes('dayjs(config.matching.orderEffectiveFrom || undefined)'),
'缺少 orderEffectiveFrom 时必须回退到当前时间',
)
assert.ok(
panel.includes('values.matching.orderEffectiveFrom?.toISOString()'),
'保存时生效时间必须使用可选链',
)
assert.ok(panel.includes('staleTime: 0'), '配置查询必须每次重拉,避免旧缓存缺字段')
})