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),
|
workOrderId: Number(workOrder.id),
|
||||||
workOrderNo: workOrder.work_order_no || '',
|
workOrderNo: workOrder.work_order_no || '',
|
||||||
profileId: profile.profileId,
|
profileId: profile.profileId,
|
||||||
|
profileVersionId: profile.versionId,
|
||||||
|
matchingMode: profile.config.matching.mode,
|
||||||
|
exactOnly: profile.config.matching.exactOnly,
|
||||||
categoryId: profile.categoryId,
|
categoryId: profile.categoryId,
|
||||||
triggerType: 'auto' as const,
|
triggerType: 'auto' as const,
|
||||||
actorType: 'system',
|
actorType: 'system',
|
||||||
@@ -680,6 +683,9 @@ export async function startConsignmentRematch(
|
|||||||
workOrderId: Number(workOrderId),
|
workOrderId: Number(workOrderId),
|
||||||
workOrderNo: workOrder.work_order_no || '',
|
workOrderNo: workOrder.work_order_no || '',
|
||||||
profileId: profile.profileId,
|
profileId: profile.profileId,
|
||||||
|
profileVersionId: profile.versionId,
|
||||||
|
matchingMode: profile.config.matching.mode,
|
||||||
|
exactOnly: profile.config.matching.exactOnly,
|
||||||
categoryId: profile.categoryId,
|
categoryId: profile.categoryId,
|
||||||
triggerType: 'rematch',
|
triggerType: 'rematch',
|
||||||
result: 'matched',
|
result: 'matched',
|
||||||
@@ -1330,7 +1336,14 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
|
|||||||
const profile = await resolveProfileCached(workOrder)
|
const profile = await resolveProfileCached(workOrder)
|
||||||
profileRef = profile
|
profileRef = profile
|
||||||
if (!profile) continue
|
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
|
if (profile.config.matching.mode !== 'auto') continue
|
||||||
const matching = profile.config.matching
|
const matching = profile.config.matching
|
||||||
const candidates = pickCandidateInventories(workOrder, profile, inventories, {
|
const candidates = pickCandidateInventories(workOrder, profile, inventories, {
|
||||||
@@ -1581,6 +1594,11 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
|||||||
return {
|
return {
|
||||||
workOrderId: Number(workOrder.id),
|
workOrderId: Number(workOrder.id),
|
||||||
profileId: profile?.profileId ?? null,
|
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),
|
sharingEnabled: Boolean(workOrder.sharing_enabled),
|
||||||
totalQuantity: Math.max(1, Number(workOrder.sharing_total_quantity || 1)),
|
totalQuantity: Math.max(1, Number(workOrder.sharing_total_quantity || 1)),
|
||||||
activeQuantity: Number(active.rows[0]?.active || 0),
|
activeQuantity: Number(active.rows[0]?.active || 0),
|
||||||
@@ -1604,10 +1622,16 @@ export async function matchConsignmentWorkOrderManually(input: {
|
|||||||
}): Promise<{ matched: boolean; matchedCount: number }> {
|
}): Promise<{ matched: boolean; matchedCount: number }> {
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
const workOrder = await getWorkOrderById(input.workOrderId).catch(() => null)
|
const workOrder = await getWorkOrderById(input.workOrderId).catch(() => null)
|
||||||
|
// 先解析当前生效的配置版本,日志才能记录匹配发生时的归属与策略快照,
|
||||||
|
// 而不是只依赖工单上的 consignment_profile_id(大厅单该字段为空)。
|
||||||
|
const profile = workOrder ? await resolveProfileForWorkOrder(workOrder).catch(() => null) : null
|
||||||
const logBase = {
|
const logBase = {
|
||||||
workOrderId: Number(input.workOrderId),
|
workOrderId: Number(input.workOrderId),
|
||||||
workOrderNo: workOrder?.work_order_no || '',
|
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,
|
categoryId: workOrder?.category_id ? Number(workOrder.category_id) : null,
|
||||||
triggerType: 'manual' as const,
|
triggerType: 'manual' as const,
|
||||||
actorType: 'admin',
|
actorType: 'admin',
|
||||||
@@ -1619,7 +1643,7 @@ export async function matchConsignmentWorkOrderManually(input: {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await runManualConsignmentMatch(input)
|
const result = await runManualConsignmentMatch(input, profile)
|
||||||
await recordConsignmentMatchLog({
|
await recordConsignmentMatchLog({
|
||||||
...logBase,
|
...logBase,
|
||||||
result: result.matched ? 'matched' : 'failed',
|
result: result.matched ? 'matched' : 'failed',
|
||||||
@@ -1641,14 +1665,17 @@ export async function matchConsignmentWorkOrderManually(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runManualConsignmentMatch(input: {
|
async function runManualConsignmentMatch(
|
||||||
workOrderId: number
|
input: {
|
||||||
inventoryIds: number[]
|
workOrderId: number
|
||||||
actorName?: string
|
inventoryIds: number[]
|
||||||
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
|
actorName?: string
|
||||||
allowNonExact?: boolean
|
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
|
||||||
reason?: string
|
allowNonExact?: boolean
|
||||||
}): Promise<{ matched: boolean; matchedCount: number }> {
|
reason?: string
|
||||||
|
},
|
||||||
|
resolvedProfile?: ConsignmentProfileSnapshot | null,
|
||||||
|
): Promise<{ matched: boolean; matchedCount: number }> {
|
||||||
const workOrder = await getWorkOrderById(input.workOrderId)
|
const workOrder = await getWorkOrderById(input.workOrderId)
|
||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
||||||
@@ -1665,7 +1692,8 @@ async function runManualConsignmentMatch(input: {
|
|||||||
errorCode: 'work_order_not_matchable',
|
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 orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile)
|
||||||
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
|
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
|
||||||
(inventory) =>
|
(inventory) =>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export type ConsignmentMatchLogRow = {
|
|||||||
work_order_id: number | null
|
work_order_id: number | null
|
||||||
work_order_no: string
|
work_order_no: string
|
||||||
profile_id: number | null
|
profile_id: number | null
|
||||||
|
profile_version_id: number | null
|
||||||
|
matching_mode: string
|
||||||
|
exact_only: boolean | null
|
||||||
category_id: number | null
|
category_id: number | null
|
||||||
trigger_type: string
|
trigger_type: string
|
||||||
result: string
|
result: string
|
||||||
@@ -27,6 +30,9 @@ export type RecordConsignmentMatchLogInput = {
|
|||||||
workOrderId?: number | null
|
workOrderId?: number | null
|
||||||
workOrderNo?: string
|
workOrderNo?: string
|
||||||
profileId?: number | null
|
profileId?: number | null
|
||||||
|
profileVersionId?: number | null
|
||||||
|
matchingMode?: string | null
|
||||||
|
exactOnly?: boolean | null
|
||||||
categoryId?: number | null
|
categoryId?: number | null
|
||||||
triggerType: ConsignmentMatchLogTrigger
|
triggerType: ConsignmentMatchLogTrigger
|
||||||
result: ConsignmentMatchLogResult
|
result: ConsignmentMatchLogResult
|
||||||
@@ -49,19 +55,24 @@ export async function recordConsignmentMatchLog(
|
|||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO consignment_match_logs (
|
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,
|
reason_code, reason_text, candidate_count, matched_count,
|
||||||
actor_type, actor_id, duration_ms, payload_json, created_at
|
actor_type, actor_id, duration_ms, payload_json, created_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6,
|
$1, $2, $3, $4, $5,
|
||||||
$7, $8, $9, $10,
|
$6, $7, $8, $9,
|
||||||
$11, $12, $13, $14::jsonb, $15
|
$10, $11, $12, $13,
|
||||||
|
$14, $15, $16, $17::jsonb, $18
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
input.workOrderId ?? null,
|
input.workOrderId ?? null,
|
||||||
String(input.workOrderNo || '').slice(0, 80),
|
String(input.workOrderNo || '').slice(0, 80),
|
||||||
input.profileId ?? null,
|
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.categoryId ?? null,
|
||||||
input.triggerType,
|
input.triggerType,
|
||||||
input.result,
|
input.result,
|
||||||
@@ -98,6 +109,10 @@ function mapLog(row: ConsignmentMatchLogRow) {
|
|||||||
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
|
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
|
||||||
workOrderNo: row.work_order_no || '',
|
workOrderNo: row.work_order_no || '',
|
||||||
profileId: row.profile_id ? Number(row.profile_id) : null,
|
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,
|
categoryId: row.category_id ? Number(row.category_id) : null,
|
||||||
triggerType: row.trigger_type as ConsignmentMatchLogTrigger,
|
triggerType: row.trigger_type as ConsignmentMatchLogTrigger,
|
||||||
result: row.result as ConsignmentMatchLogResult,
|
result: row.result as ConsignmentMatchLogResult,
|
||||||
|
|||||||
+353
-7
@@ -17,10 +17,14 @@ import {
|
|||||||
} from './consignment-admin-service.js'
|
} from './consignment-admin-service.js'
|
||||||
import {
|
import {
|
||||||
autoMatchConsignmentWorkOrders,
|
autoMatchConsignmentWorkOrders,
|
||||||
|
enterConsignmentInternalMatching,
|
||||||
expireConsignmentInternalMatching,
|
expireConsignmentInternalMatching,
|
||||||
|
fallbackConsignmentWorkOrderToHall,
|
||||||
listConsignmentMatchCandidatesForWorkOrder,
|
listConsignmentMatchCandidatesForWorkOrder,
|
||||||
matchConsignmentWorkOrderManually,
|
matchConsignmentWorkOrderManually,
|
||||||
releaseConsignmentForWorkOrder,
|
releaseConsignmentForWorkOrder,
|
||||||
|
startConsignmentRematch,
|
||||||
|
tryMatchConsignmentWorkOrderBeforePublish,
|
||||||
} from './consignment-match-engine-service.js'
|
} from './consignment-match-engine-service.js'
|
||||||
import { fulfillWorkOrderWithConsignment } from './consignment-fulfillment-service.js'
|
import { fulfillWorkOrderWithConsignment } from './consignment-fulfillment-service.js'
|
||||||
import {
|
import {
|
||||||
@@ -315,6 +319,7 @@ async function main() {
|
|||||||
internalDeadline?: string | null
|
internalDeadline?: string | null
|
||||||
acceptanceMode?: string
|
acceptanceMode?: string
|
||||||
productRuleId?: number | null
|
productRuleId?: number | null
|
||||||
|
categoryId?: number
|
||||||
}) {
|
}) {
|
||||||
const material = {
|
const material = {
|
||||||
collect: {
|
collect: {
|
||||||
@@ -337,7 +342,7 @@ async function main() {
|
|||||||
[
|
[
|
||||||
input.no,
|
input.no,
|
||||||
input.productName || '和平精英 苹果 微信区 暗潮 1钻 1璀 苏尔南',
|
input.productName || '和平精英 苹果 微信区 暗潮 1钻 1璀 苏尔南',
|
||||||
categoryId,
|
input.categoryId ?? categoryId,
|
||||||
input.status || 'unassigned',
|
input.status || 'unassigned',
|
||||||
10000,
|
10000,
|
||||||
JSON.stringify(material),
|
JSON.stringify(material),
|
||||||
@@ -373,6 +378,9 @@ async function main() {
|
|||||||
quantity?: number
|
quantity?: number
|
||||||
sourceSubmissionId?: number | null
|
sourceSubmissionId?: number | null
|
||||||
profileId?: number | null
|
profileId?: number | null
|
||||||
|
profileVersionId?: number | null
|
||||||
|
profileSnapshotConfig?: unknown
|
||||||
|
categoryId?: number
|
||||||
sellerPhone?: string
|
sellerPhone?: string
|
||||||
}) {
|
}) {
|
||||||
const res = await query<Record<string, any>>(
|
const res = await query<Record<string, any>>(
|
||||||
@@ -389,7 +397,7 @@ async function main() {
|
|||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
input.no,
|
input.no,
|
||||||
categoryId,
|
input.categoryId ?? categoryId,
|
||||||
input.itemName || '测试苏尔南手枪',
|
input.itemName || '测试苏尔南手枪',
|
||||||
input.system || '苹果',
|
input.system || '苹果',
|
||||||
input.zone || '微信区',
|
input.zone || '微信区',
|
||||||
@@ -404,8 +412,10 @@ async function main() {
|
|||||||
input.workerId,
|
input.workerId,
|
||||||
`测试卖家${input.workerId}`,
|
`测试卖家${input.workerId}`,
|
||||||
input.profileId === null ? null : (input.profileId ?? profileId),
|
input.profileId === null ? null : (input.profileId ?? profileId),
|
||||||
input.profileId === null ? null : versionId,
|
input.profileId === null ? null : (input.profileVersionId ?? versionId),
|
||||||
input.profileId === null ? null : JSON.stringify(profileConfig),
|
input.profileId === null
|
||||||
|
? null
|
||||||
|
: JSON.stringify(input.profileSnapshotConfig ?? profileConfig),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
system: input.system || '苹果',
|
system: input.system || '苹果',
|
||||||
serverZone: input.zone || '微信区',
|
serverZone: input.zone || '微信区',
|
||||||
@@ -428,6 +438,64 @@ async function main() {
|
|||||||
return inserted
|
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(`\n========== 寄卖全链路探针 ==========`)
|
||||||
console.log(`profile=${profileId} category=${categoryId} seller=${sellerId}`)
|
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(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]!.quantity) === 3, `share quantity=${shares.rows[0]!.quantity}`)
|
||||||
assert(
|
assert(
|
||||||
Number(shares.rows[0]!.share_reward) === 19950,
|
Number(shares.rows[0]!.share_reward) === 17100,
|
||||||
`share reward=${shares.rows[0]!.share_reward},应按 3×7000 扣 5% 服务费`,
|
// 寄卖拼单按库存售价结算(6000×3),不是订单单价 7000;再扣 5% 服务费。
|
||||||
|
`share reward=${shares.rows[0]!.share_reward},应按库存售价 3×6000 扣 5% 服务费`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const inv = await query<Record<string, any>>(
|
const inv = await query<Record<string, any>>(
|
||||||
@@ -1080,7 +1149,7 @@ async function main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const splitInv = await createInventory({
|
const splitInv = await createInventory({
|
||||||
no: `CT-INV-SPLIT-${suffix}`,
|
no: `CT-INV-SPLIT2-${suffix}`,
|
||||||
workerId: sellerId,
|
workerId: sellerId,
|
||||||
quantity: 3,
|
quantity: 3,
|
||||||
saleFen: 5000,
|
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========== 结果 ==========')
|
console.log('\n========== 结果 ==========')
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
|
|||||||
@@ -227,6 +227,14 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
) : null}
|
) : null}
|
||||||
{options.length > 0 ? (
|
{options.length > 0 ? (
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
{data?.matchingMode === 'off' ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="该分类已关闭自动匹配"
|
||||||
|
description="关闭自动匹配不会禁止人工操作:本次手动匹配仍会直接占用寄卖现货并写入匹配留痕,请确认库存与订单规格后再提交。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Alert
|
<Alert
|
||||||
type={remainingNeeded <= 0 ? 'warning' : 'info'}
|
type={remainingNeeded <= 0 ? 'warning' : 'info'}
|
||||||
showIcon
|
showIcon
|
||||||
|
|||||||
@@ -1864,7 +1864,7 @@ export default function ConsignmentConfigPanel() {
|
|||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
{ value: 'off', label: '关闭匹配' },
|
{ value: 'off', label: '关闭自动匹配(仍可人工指定)' },
|
||||||
{ value: 'manual', label: '手动匹配' },
|
{ value: 'manual', label: '手动匹配' },
|
||||||
{ value: 'auto', label: '自动匹配' },
|
{ value: 'auto', label: '自动匹配' },
|
||||||
]}
|
]}
|
||||||
@@ -1911,7 +1911,7 @@ export default function ConsignmentConfigPanel() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginTop: 12 }}
|
style={{ marginTop: 12 }}
|
||||||
message="关闭匹配后:新单直接进入接单大厅,不等待内部库存,也不会自动匹配。"
|
message="关闭自动匹配后:新单直接进入接单大厅,不等待内部库存、也不会自动匹配;运营仍可在后台手动指定寄卖库存。"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{matchingMode === 'auto' ? (
|
{matchingMode === 'auto' ? (
|
||||||
|
|||||||
@@ -545,6 +545,10 @@ export type ConsignmentMatchCandidate = {
|
|||||||
export type ConsignmentMatchCandidatesResponse = {
|
export type ConsignmentMatchCandidatesResponse = {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
profileId: number | null
|
profileId: number | null
|
||||||
|
profileVersionId: number | null
|
||||||
|
matchingMode: 'off' | 'manual' | 'auto' | null
|
||||||
|
exactOnly: boolean | null
|
||||||
|
categoryId: number | null
|
||||||
sharingEnabled: boolean
|
sharingEnabled: boolean
|
||||||
totalQuantity: number
|
totalQuantity: number
|
||||||
activeQuantity: number
|
activeQuantity: number
|
||||||
|
|||||||
Reference in New Issue
Block a user