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
@@ -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) {