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:
@@ -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,
|
||||
|
||||
+353
-7
@@ -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' ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user