fix(consignment): 内部库存匹配仅允许待分配和待抢单
This commit is contained in:
@@ -22,13 +22,12 @@ test('手动匹配不再在事务外改状态:数量不足/并发占库失败
|
||||
const body = source.slice(start, end)
|
||||
|
||||
assert.ok(
|
||||
body.includes('startStatuses: [WORK_ORDER_STATUS.OPEN]'),
|
||||
'手动匹配只允许对待抢单 open 生效,由 apply 事务内部流转',
|
||||
body.includes('startStatuses: MANUAL_CONSIGNMENT_MATCHABLE_STATUSES'),
|
||||
'手动匹配状态必须与候选接口共用同一常量,由 apply 事务内部流转',
|
||||
)
|
||||
assert.ok(
|
||||
!body.includes('WORK_ORDER_STATUS.INTERNAL_MATCHING') &&
|
||||
!body.includes('WORK_ORDER_STATUS.UNASSIGNED'),
|
||||
'手动匹配不得再接受内部匹配中/待分配状态',
|
||||
body.includes('MANUAL_CONSIGNMENT_MATCHABLE_STATUSES.includes(workOrder.status)'),
|
||||
'手动匹配必须校验可操作状态',
|
||||
)
|
||||
assert.ok(
|
||||
!body.includes("UPDATE work_orders SET status = 'internal_matching'"),
|
||||
@@ -40,6 +39,20 @@ test('手动匹配不再在事务外改状态:数量不足/并发占库失败
|
||||
)
|
||||
})
|
||||
|
||||
test('手动匹配状态:只允许待分配/待抢单,待补资料/内部匹配中不允许', async () => {
|
||||
const source = await readFile(ENGINE, 'utf8')
|
||||
const start = source.indexOf('const MANUAL_CONSIGNMENT_MATCHABLE_STATUSES')
|
||||
const end = source.indexOf('\n]', start)
|
||||
assert.ok(start >= 0 && end > start, '找不到手动匹配状态常量')
|
||||
const block = source.slice(start, end)
|
||||
for (const status of ['UNASSIGNED', 'OPEN']) {
|
||||
assert.ok(block.includes(`WORK_ORDER_STATUS.${status}`), `手动匹配状态缺少 ${status}`)
|
||||
}
|
||||
for (const status of ['PENDING_MATERIAL', 'INTERNAL_MATCHING']) {
|
||||
assert.ok(!block.includes(`WORK_ORDER_STATUS.${status}`), `手动匹配状态不得包含 ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('apply 事务内部:允许起始状态、整单检查有效份额、拼单必须一次拼满', async () => {
|
||||
const source = await readFile(ENGINE, 'utf8')
|
||||
const wholeStart = source.indexOf('async function applyWholeOrderMatch')
|
||||
|
||||
@@ -50,6 +50,23 @@ type MatchCandidate = {
|
||||
profile: ConsignmentProfileSnapshot | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动内部库存匹配允许的工单状态。
|
||||
*
|
||||
* 这里只控制「哪些工单阶段可以人工指定现货」,不改变候选范围:
|
||||
* 候选仍由 `inventoryMatchesProfile` 限定为同一寄卖配置的库存。
|
||||
*
|
||||
* - unassigned:待分配
|
||||
* - open:待抢单
|
||||
*
|
||||
* 待补资料(pending_material)必须先把资料补全、发布到待抢单后再匹配;
|
||||
* internal_matching 是历史状态,迁移 160 已统一转为 open,不再作为匹配起点。
|
||||
*/
|
||||
const MANUAL_CONSIGNMENT_MATCHABLE_STATUSES: string[] = [
|
||||
WORK_ORDER_STATUS.UNASSIGNED,
|
||||
WORK_ORDER_STATUS.OPEN,
|
||||
]
|
||||
|
||||
function parseMaterial(value: unknown): Record<string, unknown> {
|
||||
const parsed = safeParseJson(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
@@ -224,9 +241,8 @@ type ConsignmentMatchContext = {
|
||||
specConflicts?: string[]
|
||||
partial?: boolean
|
||||
/**
|
||||
* 允许作为匹配起点的工单状态。自动匹配只允许 internal_matching;
|
||||
* 手动匹配允许 internal_matching / unassigned / open,由落库事务内部完成状态流转,
|
||||
* 避免事务外先改状态、失败后留下无截止时间的 internal_matching。
|
||||
* 允许作为匹配起点的工单状态。自动匹配由调用方传入(unassigned/open);
|
||||
* 手动匹配只允许 unassigned / open,由落库事务内部完成状态流转。
|
||||
*/
|
||||
startStatuses?: string[]
|
||||
}
|
||||
@@ -1284,8 +1300,8 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
||||
if (!workOrder) {
|
||||
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
||||
}
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.OPEN) {
|
||||
throw createHttpError('只有待抢单工单可以匹配寄卖库存', {
|
||||
if (!MANUAL_CONSIGNMENT_MATCHABLE_STATUSES.includes(workOrder.status)) {
|
||||
throw createHttpError('当前工单状态不允许匹配寄卖库存', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_not_matchable',
|
||||
})
|
||||
@@ -1435,8 +1451,8 @@ async function runManualConsignmentMatch(
|
||||
if (!workOrder) {
|
||||
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
||||
}
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.OPEN) {
|
||||
throw createHttpError('只有待抢单工单可以匹配寄卖库存', {
|
||||
if (!MANUAL_CONSIGNMENT_MATCHABLE_STATUSES.includes(workOrder.status)) {
|
||||
throw createHttpError('当前工单状态不允许匹配寄卖库存', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_not_matchable',
|
||||
})
|
||||
@@ -1500,8 +1516,9 @@ async function runManualConsignmentMatch(
|
||||
...evaluation.conflicts,
|
||||
...evaluation.unknowns,
|
||||
]),
|
||||
// 手动匹配只对待抢单(大厅)工单生效;状态流转在 apply 事务内部完成。
|
||||
startStatuses: [WORK_ORDER_STATUS.OPEN],
|
||||
// 手动匹配允许待补资料/待分配/历史内部匹配中/待抢单;
|
||||
// 状态流转在 apply 事务内部完成,失败时不会留下无截止时间的 internal_matching。
|
||||
startStatuses: MANUAL_CONSIGNMENT_MATCHABLE_STATUSES,
|
||||
}
|
||||
|
||||
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
|
||||
|
||||
@@ -1897,6 +1897,66 @@ async function main() {
|
||||
assert(Number(finalInv.rows[0]!.remaining_quantity) === 0, '拆分完成后库存应扣完')
|
||||
})
|
||||
|
||||
// ---------- 16. 待分配工单可手动匹配,待补资料不允许 ----------
|
||||
await check('待分配工单:候选可读且手动匹配落库;待补资料直接拒绝', async () => {
|
||||
const manualStatusCategoryId = await createCategory('manual-status')
|
||||
const manualStatusConfig = {
|
||||
...profileConfig,
|
||||
matching: {
|
||||
autoMatchEnabled: false,
|
||||
exactOnly: true,
|
||||
preferLowReward: true,
|
||||
orderEffectiveFrom: '1970-01-01T00:00:00.000Z',
|
||||
},
|
||||
}
|
||||
const manualStatusProfile = await createProfileForCategory({
|
||||
categoryId: manualStatusCategoryId,
|
||||
label: 'manual-status',
|
||||
config: manualStatusConfig,
|
||||
})
|
||||
|
||||
const pendingWorkOrder = await createWorkOrder({
|
||||
no: `CT-WO-MANUAL-PENDING-${suffix}`,
|
||||
status: 'pending_material',
|
||||
categoryId: manualStatusCategoryId,
|
||||
})
|
||||
let pendingRejected = false
|
||||
try {
|
||||
await listConsignmentMatchCandidatesForWorkOrder(Number(pendingWorkOrder.id))
|
||||
} catch (error) {
|
||||
pendingRejected =
|
||||
error instanceof Error && error.message.includes('当前工单状态不允许匹配寄卖库存')
|
||||
}
|
||||
assert(pendingRejected, '待补资料工单不得读取匹配候选')
|
||||
|
||||
const statusInventory = await createInventory({
|
||||
no: `CT-INV-MANUAL-UNASSIGNED-${suffix}`,
|
||||
workerId: sellerId,
|
||||
categoryId: manualStatusCategoryId,
|
||||
profileId: manualStatusProfile.profileId,
|
||||
profileVersionId: manualStatusProfile.versionId,
|
||||
profileSnapshotConfig: manualStatusConfig,
|
||||
saleFen: 9000,
|
||||
capFen: 9000,
|
||||
})
|
||||
const statusWorkOrder = await createWorkOrder({
|
||||
no: `CT-WO-MANUAL-UNASSIGNED-${suffix}`,
|
||||
status: 'unassigned',
|
||||
categoryId: manualStatusCategoryId,
|
||||
})
|
||||
const candidates = await listConsignmentMatchCandidatesForWorkOrder(Number(statusWorkOrder.id))
|
||||
assert(
|
||||
candidates.options.some((item) => item.inventoryId === Number(statusInventory.id)),
|
||||
'待分配工单候选缺少同配置库存',
|
||||
)
|
||||
const matched = await matchConsignmentWorkOrderManually({
|
||||
workOrderId: Number(statusWorkOrder.id),
|
||||
inventoryIds: [Number(statusInventory.id)],
|
||||
actorName: 'ctest-manual-status',
|
||||
})
|
||||
assert(matched.matched, '待分配工单手动匹配未落库')
|
||||
})
|
||||
|
||||
// ---------- 输出 ----------
|
||||
console.log('\n========== 结果 ==========')
|
||||
for (const result of results) {
|
||||
|
||||
@@ -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('寄卖配置面板:订单服务风格分类树、独立分区与价格配置', () => {
|
||||
|
||||
Reference in New Issue
Block a user