From 75a5ee8192220fb7eb39b41e9f4de098acfe90d7 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 9 Nov 2026 12:19:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=E4=BB=8D=E5=8F=AF=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E6=8C=87=E5=AE=9A=EF=BC=8C=E5=8C=B9=E9=85=8D=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=AE=B0=E5=BD=95=E9=85=8D=E7=BD=AE=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - off 模式文案改为「关闭自动匹配(仍可人工指定)」,手动匹配弹窗增加警示 - 候选接口返回 matchingMode/profileVersionId/exactOnly - 匹配日志新增 profile_version_id/matching_mode/exact_only 快照(迁移153) - 修正拼单份额测试为库存售价口径(3×6000×95%=17100) - 补 off 全链路、模式切换、并发占库、多数量拆分 E2E --- .../153_consignment_match_log_snapshot.sql | 14 + .../consignment-match-engine-service.ts | 52 ++- .../consignment-match-log-service.ts | 23 +- .../consignment-matching.integration.test.ts | 360 +++++++++++++++++- .../admin/ConsignmentWorkOrderMatchModal.tsx | 8 + .../admin/panels/ConsignmentConfigPanel.tsx | 4 +- apps/frontend/src/types/consignment.ts | 4 + 7 files changed, 440 insertions(+), 25 deletions(-) create mode 100644 apps/backend/src/db/migrations/153_consignment_match_log_snapshot.sql diff --git a/apps/backend/src/db/migrations/153_consignment_match_log_snapshot.sql b/apps/backend/src/db/migrations/153_consignment_match_log_snapshot.sql new file mode 100644 index 00000000..62cb88fb --- /dev/null +++ b/apps/backend/src/db/migrations/153_consignment_match_log_snapshot.sql @@ -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 开关快照'; diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 08b76488..cefa61a6 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -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) => diff --git a/apps/backend/src/services/worker-platform/consignment-match-log-service.ts b/apps/backend/src/services/worker-platform/consignment-match-log-service.ts index 7257b8f2..80fd8bb4 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-log-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-log-service.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index a49f19e4..46c9b152 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -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>( @@ -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 + }) { + 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, + ) { + 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>( @@ -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>('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>( + '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>( + '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>( + '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>( + '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>( + '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>( + '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>( + '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>( + '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>( + '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>( + '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) { diff --git a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx index faa1f26b..5c7f060f 100644 --- a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx @@ -227,6 +227,14 @@ export default function ConsignmentWorkOrderMatchModal({ ) : null} {options.length > 0 ? ( + {data?.matchingMode === 'off' ? ( + + ) : null}