feat(consignment): 关闭自动匹配仍可人工指定,匹配日志记录配置快照

- off 模式文案改为「关闭自动匹配(仍可人工指定)」,手动匹配弹窗增加警示
- 候选接口返回 matchingMode/profileVersionId/exactOnly
- 匹配日志新增 profile_version_id/matching_mode/exact_only 快照(迁移153)
- 修正拼单份额测试为库存售价口径(3×6000×95%=17100)
- 补 off 全链路、模式切换、并发占库、多数量拆分 E2E
This commit is contained in:
2026-11-09 12:19:53 +08:00
parent 01c2517f29
commit 75a5ee8192
7 changed files with 440 additions and 25 deletions
@@ -0,0 +1,14 @@
-- 153_consignment_match_log_snapshot.sql
-- 匹配日志快照化:记录匹配发生时生效的寄卖配置版本与匹配策略,
-- 便于配置改版后还原当时的判定依据(profileId 已由 147 提供)。
ALTER TABLE consignment_match_logs
ADD COLUMN IF NOT EXISTS profile_version_id BIGINT,
ADD COLUMN IF NOT EXISTS matching_mode TEXT,
ADD COLUMN IF NOT EXISTS exact_only BOOLEAN;
COMMENT ON COLUMN consignment_match_logs.profile_version_id IS
'匹配发生时生效的寄卖配置版本 ID(consignment_profile_versions.id)';
COMMENT ON COLUMN consignment_match_logs.matching_mode IS
'匹配发生时生效的匹配模式快照:off / manual / auto';
COMMENT ON COLUMN consignment_match_logs.exact_only IS
'匹配发生时生效的 exactOnly 开关快照';
@@ -360,6 +360,9 @@ export async function tryMatchConsignmentWorkOrderBeforePublish(
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no || '',
profileId: profile.profileId,
profileVersionId: profile.versionId,
matchingMode: profile.config.matching.mode,
exactOnly: profile.config.matching.exactOnly,
categoryId: profile.categoryId,
triggerType: 'auto' as const,
actorType: 'system',
@@ -680,6 +683,9 @@ export async function startConsignmentRematch(
workOrderId: Number(workOrderId),
workOrderNo: workOrder.work_order_no || '',
profileId: profile.profileId,
profileVersionId: profile.versionId,
matchingMode: profile.config.matching.mode,
exactOnly: profile.config.matching.exactOnly,
categoryId: profile.categoryId,
triggerType: 'rematch',
result: 'matched',
@@ -1330,7 +1336,14 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
const profile = await resolveProfileCached(workOrder)
profileRef = profile
if (!profile) continue
const logBase = { ...baseLog, profileId: profile.profileId, categoryId: profile.categoryId }
const logBase = {
...baseLog,
profileId: profile.profileId,
profileVersionId: profile.versionId,
matchingMode: profile.config.matching.mode,
exactOnly: profile.config.matching.exactOnly,
categoryId: profile.categoryId,
}
if (profile.config.matching.mode !== 'auto') continue
const matching = profile.config.matching
const candidates = pickCandidateInventories(workOrder, profile, inventories, {
@@ -1581,6 +1594,11 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
return {
workOrderId: Number(workOrder.id),
profileId: profile?.profileId ?? null,
profileVersionId: profile?.versionId ?? null,
matchingMode: profile?.config.matching.mode ?? null,
exactOnly: profile?.config.matching.exactOnly ?? null,
categoryId:
profile?.categoryId ?? (workOrder.category_id ? Number(workOrder.category_id) : null),
sharingEnabled: Boolean(workOrder.sharing_enabled),
totalQuantity: Math.max(1, Number(workOrder.sharing_total_quantity || 1)),
activeQuantity: Number(active.rows[0]?.active || 0),
@@ -1604,10 +1622,16 @@ export async function matchConsignmentWorkOrderManually(input: {
}): Promise<{ matched: boolean; matchedCount: number }> {
const startedAt = Date.now()
const workOrder = await getWorkOrderById(input.workOrderId).catch(() => null)
// 先解析当前生效的配置版本,日志才能记录匹配发生时的归属与策略快照,
// 而不是只依赖工单上的 consignment_profile_id(大厅单该字段为空)。
const profile = workOrder ? await resolveProfileForWorkOrder(workOrder).catch(() => null) : null
const logBase = {
workOrderId: Number(input.workOrderId),
workOrderNo: workOrder?.work_order_no || '',
profileId: workOrder?.consignment_profile_id ? Number(workOrder.consignment_profile_id) : null,
profileId: profile?.profileId ?? null,
profileVersionId: profile?.versionId ?? null,
matchingMode: profile?.config.matching.mode ?? null,
exactOnly: profile?.config.matching.exactOnly ?? null,
categoryId: workOrder?.category_id ? Number(workOrder.category_id) : null,
triggerType: 'manual' as const,
actorType: 'admin',
@@ -1619,7 +1643,7 @@ export async function matchConsignmentWorkOrderManually(input: {
},
}
try {
const result = await runManualConsignmentMatch(input)
const result = await runManualConsignmentMatch(input, profile)
await recordConsignmentMatchLog({
...logBase,
result: result.matched ? 'matched' : 'failed',
@@ -1641,14 +1665,17 @@ export async function matchConsignmentWorkOrderManually(input: {
}
}
async function runManualConsignmentMatch(input: {
workOrderId: number
inventoryIds: number[]
actorName?: string
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
allowNonExact?: boolean
reason?: string
}): Promise<{ matched: boolean; matchedCount: number }> {
async function runManualConsignmentMatch(
input: {
workOrderId: number
inventoryIds: number[]
actorName?: string
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
allowNonExact?: boolean
reason?: string
},
resolvedProfile?: ConsignmentProfileSnapshot | null,
): Promise<{ matched: boolean; matchedCount: number }> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
@@ -1665,7 +1692,8 @@ async function runManualConsignmentMatch(input: {
errorCode: 'work_order_not_matchable',
})
}
const profile = await resolveProfileForWorkOrder(workOrder)
const profile =
resolvedProfile === undefined ? await resolveProfileForWorkOrder(workOrder) : resolvedProfile
const orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile)
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
(inventory) =>
@@ -9,6 +9,9 @@ export type ConsignmentMatchLogRow = {
work_order_id: number | null
work_order_no: string
profile_id: number | null
profile_version_id: number | null
matching_mode: string
exact_only: boolean | null
category_id: number | null
trigger_type: string
result: string
@@ -27,6 +30,9 @@ export type RecordConsignmentMatchLogInput = {
workOrderId?: number | null
workOrderNo?: string
profileId?: number | null
profileVersionId?: number | null
matchingMode?: string | null
exactOnly?: boolean | null
categoryId?: number | null
triggerType: ConsignmentMatchLogTrigger
result: ConsignmentMatchLogResult
@@ -49,19 +55,24 @@ export async function recordConsignmentMatchLog(
await query(
`
INSERT INTO consignment_match_logs (
work_order_id, work_order_no, profile_id, category_id, trigger_type, result,
work_order_id, work_order_no, profile_id, profile_version_id, matching_mode,
exact_only, category_id, trigger_type, result,
reason_code, reason_text, candidate_count, matched_count,
actor_type, actor_id, duration_ms, payload_json, created_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13, $14::jsonb, $15
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12, $13,
$14, $15, $16, $17::jsonb, $18
)
`,
[
input.workOrderId ?? null,
String(input.workOrderNo || '').slice(0, 80),
input.profileId ?? null,
input.profileVersionId ?? null,
input.matchingMode ? String(input.matchingMode).slice(0, 20) : null,
input.exactOnly === null || input.exactOnly === undefined ? null : Boolean(input.exactOnly),
input.categoryId ?? null,
input.triggerType,
input.result,
@@ -98,6 +109,10 @@ function mapLog(row: ConsignmentMatchLogRow) {
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
workOrderNo: row.work_order_no || '',
profileId: row.profile_id ? Number(row.profile_id) : null,
profileVersionId: row.profile_version_id ? Number(row.profile_version_id) : null,
matchingMode: row.matching_mode || '',
exactOnly:
row.exact_only === null || row.exact_only === undefined ? null : Boolean(row.exact_only),
categoryId: row.category_id ? Number(row.category_id) : null,
triggerType: row.trigger_type as ConsignmentMatchLogTrigger,
result: row.result as ConsignmentMatchLogResult,
@@ -17,10 +17,14 @@ import {
} from './consignment-admin-service.js'
import {
autoMatchConsignmentWorkOrders,
enterConsignmentInternalMatching,
expireConsignmentInternalMatching,
fallbackConsignmentWorkOrderToHall,
listConsignmentMatchCandidatesForWorkOrder,
matchConsignmentWorkOrderManually,
releaseConsignmentForWorkOrder,
startConsignmentRematch,
tryMatchConsignmentWorkOrderBeforePublish,
} from './consignment-match-engine-service.js'
import { fulfillWorkOrderWithConsignment } from './consignment-fulfillment-service.js'
import {
@@ -315,6 +319,7 @@ async function main() {
internalDeadline?: string | null
acceptanceMode?: string
productRuleId?: number | null
categoryId?: number
}) {
const material = {
collect: {
@@ -337,7 +342,7 @@ async function main() {
[
input.no,
input.productName || '和平精英 苹果 微信区 暗潮 1钻 1璀 苏尔南',
categoryId,
input.categoryId ?? categoryId,
input.status || 'unassigned',
10000,
JSON.stringify(material),
@@ -373,6 +378,9 @@ async function main() {
quantity?: number
sourceSubmissionId?: number | null
profileId?: number | null
profileVersionId?: number | null
profileSnapshotConfig?: unknown
categoryId?: number
sellerPhone?: string
}) {
const res = await query<Record<string, any>>(
@@ -389,7 +397,7 @@ async function main() {
RETURNING *`,
[
input.no,
categoryId,
input.categoryId ?? categoryId,
input.itemName || '测试苏尔南手枪',
input.system || '苹果',
input.zone || '微信区',
@@ -404,8 +412,10 @@ async function main() {
input.workerId,
`测试卖家${input.workerId}`,
input.profileId === null ? null : (input.profileId ?? profileId),
input.profileId === null ? null : versionId,
input.profileId === null ? null : JSON.stringify(profileConfig),
input.profileId === null ? null : (input.profileVersionId ?? versionId),
input.profileId === null
? null
: JSON.stringify(input.profileSnapshotConfig ?? profileConfig),
JSON.stringify({
system: input.system || '苹果',
serverZone: input.zone || '微信区',
@@ -428,6 +438,64 @@ async function main() {
return inserted
}
async function createCategory(label: string) {
const res = await query<{ id: number }>(
`INSERT INTO work_categories (category_key, name, sort_order, status, created_at, updated_at)
VALUES ($1, $2, 100, 'active', NOW(), NOW()) RETURNING id`,
[`ctest_${label}_${suffix}`, `测试分类-${label}-${suffix}`],
)
return Number(res.rows[0]!.id)
}
/** 新建一份「已发布」配置:用于 off/手动/自动切换与并发场景,避免污染主 profile。 */
async function createProfileForCategory(input: {
categoryId: number
label: string
config: Record<string, unknown>
}) {
const profileRes = await query<{ id: number }>(
`INSERT INTO consignment_profiles
(category_id, name, title, description, tab_title, config_json, status,
created_by_admin_user_id, created_at, updated_at)
VALUES ($1, $2, $2, '', $3, $4::jsonb, 'draft', $5, NOW(), NOW()) RETURNING id`,
[
input.categoryId,
`测试寄卖-${input.label}-${suffix}`,
'测试寄卖',
JSON.stringify(input.config),
adminId,
],
)
const newProfileId = Number(profileRes.rows[0]!.id)
const versionRes = await query<{ id: number }>(
`INSERT INTO consignment_profile_versions (profile_id, version_no, config_json, published_at, created_at)
VALUES ($1, 1, $2::jsonb, NOW(), NOW()) RETURNING id`,
[newProfileId, JSON.stringify(input.config)],
)
const newVersionId = Number(versionRes.rows[0]!.id)
await query(
`UPDATE consignment_profiles SET current_version_id = $1, status = 'published', updated_at = NOW() WHERE id = $2`,
[newVersionId, newProfileId],
)
return { profileId: newProfileId, versionId: newVersionId }
}
/** 模拟「修改配置并重新发布」:草稿与已发布快照同步改写。 */
async function updatePublishedConfig(
targetProfileId: number,
targetVersionId: number,
config: Record<string, unknown>,
) {
await query(
`UPDATE consignment_profiles SET config_json = $1::jsonb, updated_at = NOW() WHERE id = $2`,
[JSON.stringify(config), targetProfileId],
)
await query(`UPDATE consignment_profile_versions SET config_json = $1::jsonb WHERE id = $2`, [
JSON.stringify(config),
targetVersionId,
])
}
console.log(`\n========== 寄卖全链路探针 ==========`)
console.log(`profile=${profileId} category=${categoryId} seller=${sellerId}`)
@@ -1059,8 +1127,9 @@ async function main() {
assert(shares.rows.length === 1, `shares=${shares.rows.length}`)
assert(Number(shares.rows[0]!.quantity) === 3, `share quantity=${shares.rows[0]!.quantity}`)
assert(
Number(shares.rows[0]!.share_reward) === 19950,
`share reward=${shares.rows[0]!.share_reward},应按 3×7000 扣 5% 服务费`,
Number(shares.rows[0]!.share_reward) === 17100,
// 寄卖拼单按库存售价结算(6000×3),不是订单单价 7000;再扣 5% 服务费。
`share reward=${shares.rows[0]!.share_reward},应按库存售价 3×6000 扣 5% 服务费`,
)
const inv = await query<Record<string, any>>(
@@ -1080,7 +1149,7 @@ async function main() {
})
const splitInv = await createInventory({
no: `CT-INV-SPLIT-${suffix}`,
no: `CT-INV-SPLIT2-${suffix}`,
workerId: sellerId,
quantity: 3,
saleFen: 5000,
@@ -1547,6 +1616,283 @@ async function main() {
])
})
// ---------- 12. off 全链路:建单 → 不匹配 → 大厅 ----------
await check('off 全链路:建单不占库、不进入内部等待、发布后进大厅', async () => {
const offCategoryId = await createCategory('off')
const offConfig = {
...profileConfig,
matching: { mode: 'off', exactOnly: true, preferLowReward: true },
internalMatch: { delayMinutes: 30 },
}
const offProfile = await createProfileForCategory({
categoryId: offCategoryId,
label: 'off',
config: offConfig,
})
const offInv = await createInventory({
no: `CT-INV-OFF-${suffix}`,
workerId: sellerId,
categoryId: offCategoryId,
profileId: offProfile.profileId,
profileVersionId: offProfile.versionId,
profileSnapshotConfig: offConfig,
saleFen: 9000,
capFen: 9000,
})
const offWo = await createWorkOrder({ no: `CT-WO-OFF-${suffix}`, categoryId: offCategoryId })
const beforePublish = await tryMatchConsignmentWorkOrderBeforePublish(Number(offWo.id))
assert(!beforePublish, 'off 模式不得在发布前匹配')
const entered = await enterConsignmentInternalMatching(Number(offWo.id))
assert(!entered, 'off 模式不得进入内部匹配等待')
// 关闭的是自动匹配,人工仍可指定库存:候选接口要如实返回 off 与候选行。
const candidates = await listConsignmentMatchCandidatesForWorkOrder(Number(offWo.id))
assert(candidates.matchingMode === 'off', `候选接口 matchingMode=${candidates.matchingMode}`)
assert(candidates.options.length === 1, `off 手动候选=${candidates.options.length}`)
await fallbackConsignmentWorkOrderToHall(Number(offWo.id))
const woRow = await query<Record<string, any>>('SELECT status FROM work_orders WHERE id = $1', [
Number(offWo.id),
])
assert(woRow.rows[0]!.status === 'open', `off 单应在大厅 open,实际 ${woRow.rows[0]!.status}`)
const invRow = await query<Record<string, any>>(
'SELECT remaining_quantity, bound_work_order_id FROM consignment_inventories WHERE id = $1',
[Number(offInv.id)],
)
assert(Number(invRow.rows[0]!.remaining_quantity) === 1, 'off 模式不得占用库存')
assert(invRow.rows[0]!.bound_work_order_id === null, 'off 模式不得绑定工单')
})
// ---------- 13. 运行中切换 manual → off → auto ----------
await check('运行中切换模式:manual→off 回大厅,off→auto 可自动匹配', async () => {
const switchCategoryId = await createCategory('switch')
const manualConfig = {
...profileConfig,
matching: { mode: 'manual', exactOnly: true, preferLowReward: true },
internalMatch: { delayMinutes: 30 },
}
const switchProfile = await createProfileForCategory({
categoryId: switchCategoryId,
label: 'switch',
config: manualConfig,
})
const switchInv = await createInventory({
no: `CT-INV-SWITCH-${suffix}`,
workerId: sellerId,
categoryId: switchCategoryId,
profileId: switchProfile.profileId,
profileVersionId: switchProfile.versionId,
profileSnapshotConfig: manualConfig,
saleFen: 9000,
capFen: 9000,
})
const manualWo = await createWorkOrder({
no: `CT-WO-SWITCH-MANUAL-${suffix}`,
status: 'internal_matching',
categoryId: switchCategoryId,
internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
// manual 模式不参与自动匹配,工单必须留在内部等待。
await autoMatchConsignmentWorkOrders({ limit: 50 })
const manualRow = await query<Record<string, any>>(
'SELECT status FROM work_orders WHERE id = $1',
[Number(manualWo.id)],
)
assert(
manualRow.rows[0]!.status === 'internal_matching',
`manual 单被自动匹配,status=${manualRow.rows[0]!.status}`,
)
// 切到 off:重新匹配必须显式回大厅,不能继续占等待窗口。
await updatePublishedConfig(switchProfile.profileId, switchProfile.versionId, {
...manualConfig,
matching: { mode: 'off', exactOnly: true, preferLowReward: true },
})
const rematched = await startConsignmentRematch(Number(manualWo.id), {})
assert(!rematched, 'off 模式重新匹配应返回 false')
const offRow = await query<Record<string, any>>(
'SELECT status FROM work_orders WHERE id = $1',
[Number(manualWo.id)],
)
assert(offRow.rows[0]!.status === 'open', `off 切换后应回大厅,实际 ${offRow.rows[0]!.status}`)
// 切到 auto:同一份库存应能被新的待匹配工单自动吃掉。
const autoConfig = {
...profileConfig,
matching: { mode: 'auto', exactOnly: true, preferLowReward: true },
}
await updatePublishedConfig(switchProfile.profileId, switchProfile.versionId, autoConfig)
const autoWo = await createWorkOrder({
no: `CT-WO-SWITCH-AUTO-${suffix}`,
status: 'internal_matching',
categoryId: switchCategoryId,
internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
await autoMatchConsignmentWorkOrders({ limit: 50 })
const autoRow = await query<Record<string, any>>(
'SELECT status FROM work_orders WHERE id = $1',
[Number(autoWo.id)],
)
assert(
autoRow.rows[0]!.status === 'in_progress',
`auto 切换后应自动匹配,实际 ${autoRow.rows[0]!.status}`,
)
const bound = await query<Record<string, any>>(
'SELECT bound_work_order_id FROM consignment_inventories WHERE id = $1',
[Number(switchInv.id)],
)
assert(
Number(bound.rows[0]!.bound_work_order_id) === Number(autoWo.id),
`库存未绑定自动匹配工单,bound=${bound.rows[0]!.bound_work_order_id}`,
)
})
// ---------- 14. 并发占库:同一库存只能成功一次 ----------
await check('并发占库:两张工单同时抢同一库存,只能成功一次', async () => {
const lockCategoryId = await createCategory('lock')
const lockConfig = {
...profileConfig,
matching: { mode: 'manual', exactOnly: true, preferLowReward: true },
}
const lockProfile = await createProfileForCategory({
categoryId: lockCategoryId,
label: 'lock',
config: lockConfig,
})
const lockInv = await createInventory({
no: `CT-INV-LOCK-${suffix}`,
workerId: sellerId,
categoryId: lockCategoryId,
profileId: lockProfile.profileId,
profileVersionId: lockProfile.versionId,
profileSnapshotConfig: lockConfig,
saleFen: 9000,
capFen: 9000,
})
const lockWoA = await createWorkOrder({
no: `CT-WO-LOCK-A-${suffix}`,
categoryId: lockCategoryId,
})
const lockWoB = await createWorkOrder({
no: `CT-WO-LOCK-B-${suffix}`,
categoryId: lockCategoryId,
})
const settled = await Promise.allSettled([
matchConsignmentWorkOrderManually({
workOrderId: Number(lockWoA.id),
inventoryIds: [Number(lockInv.id)],
actorName: 'ctest-lock-a',
}),
matchConsignmentWorkOrderManually({
workOrderId: Number(lockWoB.id),
inventoryIds: [Number(lockInv.id)],
actorName: 'ctest-lock-b',
}),
])
const success = settled.filter(
(item) => item.status === 'fulfilled' && item.value.matched,
).length
assert(success === 1, `并发匹配成功次数=${success},必须恰好 1 次`)
const invRow = await query<Record<string, any>>(
'SELECT remaining_quantity, bound_work_order_id FROM consignment_inventories WHERE id = $1',
[Number(lockInv.id)],
)
assert(Number(invRow.rows[0]!.remaining_quantity) === 0, '并发匹配后库存必须扣完')
assert(
[Number(lockWoA.id), Number(lockWoB.id)].includes(
Number(invRow.rows[0]!.bound_work_order_id),
),
`库存绑定到未知工单:${invRow.rows[0]!.bound_work_order_id}`,
)
})
// ---------- 15. 多数量库存拆分 2+1 ----------
await check('多数量库存拆分:3 件拆给两张工单(拼单2件 + 整单1件)', async () => {
const splitCategoryId = await createCategory('split')
const splitConfig = {
...profileConfig,
matching: { mode: 'manual', exactOnly: true, preferLowReward: true },
}
const splitProfile = await createProfileForCategory({
categoryId: splitCategoryId,
label: 'split',
config: splitConfig,
})
const splitInv = await createInventory({
no: `CT-INV-SPLIT-${suffix}`,
workerId: sellerId,
categoryId: splitCategoryId,
profileId: splitProfile.profileId,
profileVersionId: splitProfile.versionId,
profileSnapshotConfig: splitConfig,
saleFen: 6000,
capFen: 6000,
quantity: 3,
})
const splitWoA = await createWorkOrder({
no: `CT-WO-SPLIT-A-${suffix}`,
status: 'internal_matching',
categoryId: splitCategoryId,
sharing: { total: 2, unitReward: 7000 },
internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
const splitWoB = await createWorkOrder({
no: `CT-WO-SPLIT-B-${suffix}`,
status: 'internal_matching',
categoryId: splitCategoryId,
internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
const first = await matchConsignmentWorkOrderManually({
workOrderId: Number(splitWoA.id),
inventoryIds: [Number(splitInv.id)],
actorName: 'ctest-split-a',
})
assert(
first.matched && first.matchedCount === 1,
`第一张拼单匹配失败:${JSON.stringify(first)}`,
)
const mid = await query<Record<string, any>>(
'SELECT remaining_quantity FROM consignment_inventories WHERE id = $1',
[Number(splitInv.id)],
)
assert(
Number(mid.rows[0]!.remaining_quantity) === 1,
`第一次拆分后剩余 ${mid.rows[0]!.remaining_quantity}`,
)
const second = await matchConsignmentWorkOrderManually({
workOrderId: Number(splitWoB.id),
inventoryIds: [Number(splitInv.id)],
actorName: 'ctest-split-b',
})
assert(
second.matched && second.matchedCount === 1,
`第二张拼单匹配失败:${JSON.stringify(second)}`,
)
const shares = await query<Record<string, any>>(
'SELECT work_order_id, quantity FROM work_order_shares WHERE work_order_id = $1 ORDER BY id',
[Number(splitWoA.id)],
)
assert(Number(shares.rows[0]?.quantity) === 2, `A 份额=${shares.rows[0]?.quantity}`)
// B 是整单(total=1),走整单匹配不生成份额行,但必须消耗剩余 1 件。
const splitWoBRow = await query<Record<string, any>>(
'SELECT status FROM work_orders WHERE id = $1',
[Number(splitWoB.id)],
)
assert(
splitWoBRow.rows[0]!.status === 'in_progress',
`B 整单应匹配成功,实际 ${splitWoBRow.rows[0]!.status}`,
)
const finalInv = await query<Record<string, any>>(
'SELECT remaining_quantity FROM consignment_inventories WHERE id = $1',
[Number(splitInv.id)],
)
assert(Number(finalInv.rows[0]!.remaining_quantity) === 0, '拆分完成后库存应扣完')
})
// ---------- 输出 ----------
console.log('\n========== 结果 ==========')
for (const result of results) {
@@ -227,6 +227,14 @@ export default function ConsignmentWorkOrderMatchModal({
) : null}
{options.length > 0 ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{data?.matchingMode === 'off' ? (
<Alert
type="warning"
showIcon
message="该分类已关闭自动匹配"
description="关闭自动匹配不会禁止人工操作:本次手动匹配仍会直接占用寄卖现货并写入匹配留痕,请确认库存与订单规格后再提交。"
/>
) : null}
<Alert
type={remainingNeeded <= 0 ? 'warning' : 'info'}
showIcon
@@ -1864,7 +1864,7 @@ export default function ConsignmentConfigPanel() {
>
<Select
options={[
{ value: 'off', label: '关闭匹配' },
{ value: 'off', label: '关闭自动匹配(仍可人工指定)' },
{ value: 'manual', label: '手动匹配' },
{ value: 'auto', label: '自动匹配' },
]}
@@ -1911,7 +1911,7 @@ export default function ConsignmentConfigPanel() {
type="warning"
showIcon
style={{ marginTop: 12 }}
message="关闭匹配后:新单直接进入接单大厅,不等待内部库存,也不会自动匹配。"
message="关闭自动匹配后:新单直接进入接单大厅,不等待内部库存、也不会自动匹配;运营仍可在后台手动指定寄卖库存。"
/>
) : null}
{matchingMode === 'auto' ? (
+4
View File
@@ -545,6 +545,10 @@ export type ConsignmentMatchCandidate = {
export type ConsignmentMatchCandidatesResponse = {
workOrderId: number
profileId: number | null
profileVersionId: number | null
matchingMode: 'off' | 'manual' | 'auto' | null
exactOnly: boolean | null
categoryId: number | null
sharingEnabled: boolean
totalQuantity: number
activeQuantity: number