fix(consignment): 内部库存匹配仅允许待分配和待抢单

This commit is contained in:
2026-11-13 16:34:44 +08:00
parent d8793db593
commit 40fa9e2725
9 changed files with 163 additions and 41 deletions
@@ -61,6 +61,9 @@ export default function ConsignmentWorkOrderMatchModal({
queryKey: ['admin-consignment-match-candidates', workOrderId],
queryFn: () => fetchAdminConsignmentMatchCandidates(Number(workOrderId)),
enabled: open && Boolean(workOrderId),
// 409 等业务拒绝对话框要直接把原因展示出来,不能重试后再被误判成“没有库存”。
retry: false,
refetchOnWindowFocus: false,
})
const data = candidatesQuery.data?.data
const options = useMemo(() => data?.options || [], [data?.options])
@@ -222,10 +225,25 @@ export default function ConsignmentWorkOrderMatchModal({
}
>
{candidatesQuery.isLoading ? <Card loading /> : null}
{!candidatesQuery.isLoading && options.length === 0 ? (
{candidatesQuery.isError ? (
<Alert
type="error"
showIcon
message="读取可匹配寄卖库存失败"
description={
candidatesQuery.error instanceof Error ? candidatesQuery.error.message : '请稍后重试'
}
action={
<Button size="small" onClick={() => void candidatesQuery.refetch()}>
重试
</Button>
}
/>
) : null}
{!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0 ? (
<Empty description="没有同配置的在库寄卖库存" />
) : null}
{options.length > 0 ? (
{!candidatesQuery.isError && options.length > 0 ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{data?.matchingMode === 'off' ? (
<Alert
@@ -239,7 +257,7 @@ export default function ConsignmentWorkOrderMatchModal({
type={remainingNeeded <= 0 ? 'warning' : 'info'}
showIcon
message={infoMessage}
description="系统候选按寄卖配置打分;非精确/冲突库存必须人工勾选并填写原因。拼单必须一次拼满,失败不会占用任何库存。"
description="待分配工单也可直接人工匹配;候选仍按寄卖配置打分,非精确/冲突库存必须人工勾选并填写原因。拼单必须一次拼满,失败不会占用任何库存。"
/>
<Space wrap size={8}>
<Tag color="blue">
@@ -163,7 +163,6 @@ const ADMIN_NOTE_QUICK_TAGS = ['已通知加好友'] as const
const FILTERABLE_WORK_ORDER_STATUSES = [
'pending_material',
'unassigned',
'internal_matching',
'open',
'in_progress',
'pending_acceptance',
@@ -2071,19 +2070,13 @@ export default function WorkOrdersPanel() {
寄卖匹配只保留新引擎入口:旧的 /fulfill 手动匹配不支持数量拆分,
还会绕过 profile/重新匹配排除,已从 UI 收敛掉。
*/}
{canFulfillConsignment &&
['internal_matching', 'unassigned', 'open'].includes(String(row.status || '')) ? (
{canFulfillConsignment && ['unassigned', 'open'].includes(String(row.status || '')) ? (
<Button
type={String(row.status || '') === 'internal_matching' ? 'primary' : 'default'}
icon={<ThunderboltOutlined />}
onClick={() => setConsignmentMatchOrder(row)}
title={
String(row.status || '') === 'internal_matching'
? '手动选择寄卖库存并立即匹配;拼单必须一次拼满'
: '按寄卖配置精确匹配库存;拼单工单可选多条库存一起拼'
}
title="按寄卖配置匹配同配置库存;拼单工单可选多条库存一起拼"
>
{String(row.status || '') === 'internal_matching' ? '手动匹配寄卖' : '内部库存匹配'}
内部库存匹配
</Button>
) : null}
{canCustomerRefund &&
@@ -74,11 +74,11 @@ export function filterManualMatchOptions<T>(
/**
* 哪些订单状态可以手动指定现货。
*
* 与后端 `fulfillWorkOrderWithConsignment` 的前置校验保持一致(允许 unassigned /
* pending_material / open):入口先按同一口径收敛,避免"按钮点了才报错"。
* 与后端新引擎的前置校验保持一致:只允许待分配(unassigned)和待抢单(open)。
* 待补资料必须先补资料/发布到待抢单;历史内部匹配中已由迁移统一转为待抢单。
*/
export function isManuallyMatchableOrderStatus(status: string): boolean {
return status === 'unassigned' || status === 'pending_material' || status === 'open'
return status === 'unassigned' || status === 'open'
}
/**
@@ -61,11 +61,11 @@ test('关键词过滤:现货能按寄卖单号/物品名搜到,大小写与
assert.equal(filterManualMatchOptions(list, '不存在', pick).length, 0)
})
test('待指派/待补资料/大厅待抢的订单均能手动指定现货(与后端前置校验同口径)', async () => {
test('待指派/大厅待抢的订单能手动指定现货,待补资料不再允许', async () => {
const { isManuallyMatchableOrderStatus } =
await import('../src/utils/consignment-manual-match.ts')
assert.equal(isManuallyMatchableOrderStatus('unassigned'), true)
assert.equal(isManuallyMatchableOrderStatus('pending_material'), true)
assert.equal(isManuallyMatchableOrderStatus('pending_material'), false)
assert.equal(isManuallyMatchableOrderStatus('open'), true)
for (const status of ['in_progress', 'pending_acceptance', 'accepted', 'cancelled', 'disputed']) {
assert.equal(isManuallyMatchableOrderStatus(status), false, `${status} 不该给手动匹配入口`)
@@ -96,7 +96,7 @@ test('寄卖只做整单:已有打手参与的拼单工单不给「手动匹
)
// 普通整单
assert.equal(canManuallyMatchConsignmentOrder({ status: 'unassigned' }), true)
assert.equal(canManuallyMatchConsignmentOrder({ status: 'pending_material' }), true)
assert.equal(canManuallyMatchConsignmentOrder({ status: 'pending_material' }), false)
// 状态本身不允许的仍然不给
for (const status of ['in_progress', 'pending_acceptance', 'accepted', 'cancelled']) {
@@ -114,15 +114,33 @@ test('整单覆盖:必须一条库存满足全部数量', () => {
assert.equal(enough.sufficient, true)
})
test('工单页:internal_matching 显示「手动匹配寄卖」入口', () => {
test('工单页:内部库存匹配只对待分配/待抢单显示,不再包含内部匹配中', () => {
const source = readFileSync(
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'utf8',
)
assert.ok(source.includes('手动匹配寄卖'), '内部匹配中的按钮文案必须是手动匹配寄卖')
assert.ok(
source.includes("String(row.status || '') === 'internal_matching'"),
'按钮文案必须只在 internal_matching 下切换',
source.includes("['unassigned', 'open'].includes(String(row.status || ''))"),
'内部库存匹配入口必须只覆盖待分配/待抢单',
)
assert.ok(!source.includes("'internal_matching'"), '状态筛选与入口都不应再包含内部匹配中')
})
test('匹配弹窗:接口错误必须展示,不能伪装成没有库存', () => {
const source = readFileSync(
fileURLToPath(
new URL('../src/components/admin/ConsignmentWorkOrderMatchModal.tsx', import.meta.url),
),
'utf8',
)
assert.ok(source.includes('candidatesQuery.isError'), '必须处理候选查询错误')
assert.ok(source.includes('读取可匹配寄卖库存失败'), '必须展示错误标题')
assert.ok(source.includes('重试'), '必须提供重试入口')
assert.ok(
source.includes(
'!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0',
),
'空状态必须排除错误场景',
)
})
@@ -71,14 +71,17 @@ test('打手端:寄卖页必须按已发布配置动态生成 tab,并支持
assert.ok(page.includes('寄售数量'), '发布表单必须支持数量')
})
test('工单页:必须挂载内部库存匹配弹窗,并支持状态 internal_matching', () => {
test('工单页:必须挂载内部库存匹配弹窗,且不再提供内部匹配中筛选', () => {
const panel = read('../src/pages/admin/panels/WorkOrdersPanel.tsx')
assert.ok(panel.includes('ConsignmentWorkOrderMatchModal'), '工单页必须挂载内部匹配弹窗')
assert.ok(panel.includes("'internal_matching'"), '状态筛选必须支持内部匹配中')
assert.ok(!panel.includes("'internal_matching'"), '状态筛选必须移除内部匹配中')
const shared = read('../src/pages/admin/panels/shared.tsx')
assert.ok(shared.includes("internal_matching: '内部匹配中'"), '状态文案必须包含内部匹配中')
assert.ok(shared.includes("internal_matching: 'magenta'"), '状态配色必须包含内部匹配中')
assert.ok(
shared.includes("internal_matching: '内部匹配中'"),
'历史事件详情仍需展示内部匹配中文案',
)
assert.ok(shared.includes("internal_matching: 'magenta'"), '历史事件详情仍需保留内部匹配中配色')
})
test('寄卖配置面板:订单服务风格分类树、独立分区与价格配置', () => {