fix(consignment): 修复内部匹配 delay=0 黑洞、匹配改写报酬不恢复、草稿未发布即生效
P0-1: internalMatch.delayMinutes 归一化后 <=0 直接回大厅,不再依赖 fallbackToHall 进入 internal_matching(手动/自动两条入口共用 resolveInternalMatchDelayMinutes)。 P0-2: 新增 work_orders.consignment_original_reward_fen(迁移 145),匹配改写报酬前固化原值;两个统一释放入口(releaseConsignmentAllocationsForWorkOrderWithClient / releaseConsignmentInventoryBindingForWorkOrderWithClient)恢复并清空,覆盖释放、取消、超时、驳回等全部路径;二次匹配不再覆盖原值。 P1-1: 打手端分类、工单配置解析、寄售仓库筛选项改读 consignment_profile_versions.config_json(current_version 快照),后台仍读写草稿;版本缺失时回退草稿兼容历史数据。 测试:新增配置归一化/报酬恢复/发布快照单测,集成测试补草稿隔离与快照发布用例;隔离库 35/35 通过,worker-platform 单测 415 通过。
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
-- 145_work_order_consignment_original_reward.sql
|
||||
-- 寄卖内部匹配会改写 work_orders.reward_amount(按库存售价-服务费结算)。
|
||||
-- 这里固化“匹配前的原始工单报酬”,所有释放库存的路径恢复它,避免超时/取消/退款后
|
||||
-- 大厅继续展示寄卖结算价、或二次匹配在已被改写的金额上继续扣服务费。
|
||||
-- 幂等:可安全重跑。
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS consignment_original_reward_fen BIGINT;
|
||||
|
||||
COMMENT ON COLUMN work_orders.consignment_original_reward_fen IS
|
||||
'寄卖内部匹配前的原始工单报酬(分);匹配改写 reward_amount 时写入,释放库存时恢复并清空';
|
||||
@@ -226,12 +226,41 @@ export async function releaseConsignmentAllocationRowsWithClient(
|
||||
await releaseAllocationRows(client, rows, now)
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复寄卖匹配前的工单报酬。
|
||||
*
|
||||
* 匹配成功时把 reward_amount 改写为「库存售价-服务费」,原值固化在
|
||||
* work_orders.consignment_original_reward_fen。所有释放库存的路径都必须调用本函数,
|
||||
* 否则超时/取消/退款后大厅会继续展示寄卖结算价,二次匹配还会在已改写金额上继续扣费。
|
||||
*
|
||||
* 幂等:只处理原始报酬非空的工单,恢复后立即清空,重复调用无副作用。
|
||||
*/
|
||||
export async function restoreConsignmentOriginalRewardWithClient(
|
||||
client: PoolClient,
|
||||
workOrderId: number,
|
||||
now: string,
|
||||
): Promise<boolean> {
|
||||
const result = await client.query<{ reward_amount: number | string }>(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET reward_amount = COALESCE(consignment_original_reward_fen, reward_amount),
|
||||
consignment_original_reward_fen = NULL,
|
||||
updated_at = $2
|
||||
WHERE id = $1 AND consignment_original_reward_fen IS NOT NULL
|
||||
RETURNING reward_amount
|
||||
`,
|
||||
[workOrderId, now],
|
||||
)
|
||||
return Boolean(result.rows[0])
|
||||
}
|
||||
|
||||
/** 释放整个工单占用的全部库存(整单取消 / 退回大厅 / 撤销履约)。 */
|
||||
export async function releaseConsignmentAllocationsForWorkOrderWithClient(
|
||||
client: PoolClient,
|
||||
workOrderId: number,
|
||||
now: string,
|
||||
): Promise<number> {
|
||||
await restoreConsignmentOriginalRewardWithClient(client, workOrderId, now)
|
||||
const result = await client.query<ConsignmentAllocationRow>(
|
||||
`SELECT * FROM consignment_inventory_allocations WHERE work_order_id = $1 AND status = 'reserved' FOR UPDATE`,
|
||||
[workOrderId],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { query, withTransaction } from '../../db/client.js'
|
||||
import {
|
||||
releaseConsignmentAllocationRowsWithClient,
|
||||
releaseConsignmentAllocationsForWorkOrderWithClient,
|
||||
restoreConsignmentOriginalRewardWithClient,
|
||||
type ConsignmentAllocationRow,
|
||||
} from './consignment-allocation-repo.js'
|
||||
import { toJsonString } from './shared.js'
|
||||
@@ -984,6 +985,9 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient(
|
||||
}>('SELECT acceptance_mode, friend_added_at FROM work_orders WHERE id = $1', [workOrderId])
|
||||
const order = orderRes.rows[0]
|
||||
if (!order) return null
|
||||
// 解绑库存意味着这张工单不再由寄卖库存履约:先把匹配时改写的报酬恢复成原值,
|
||||
// 再走后面的回库/作废逻辑(无论走 archiveOnly 还是普通回库都要恢复)。
|
||||
await restoreConsignmentOriginalRewardWithClient(client, Number(workOrderId), input.now)
|
||||
const confirmedShare = await client.query<{ id: number }>(
|
||||
`SELECT ca.id
|
||||
FROM consignment_inventory_allocations ca
|
||||
|
||||
@@ -425,6 +425,8 @@ export type WorkOrderRow = {
|
||||
internal_match_deadline_at?: string | null
|
||||
hall_visible_at?: string | null
|
||||
internal_match_attempts?: number
|
||||
/** 寄卖内部匹配前的原始工单报酬;匹配改写 reward_amount 时写入,释放库存时恢复并清空。 */
|
||||
consignment_original_reward_fen?: number | string | null
|
||||
/** 超时「重新匹配」时排除的原卖家与原库存,避免同一卖家立刻再次接回同一单。 */
|
||||
rematch_excluded_worker_id?: number | null
|
||||
rematch_excluded_inventory_id?: number | null
|
||||
|
||||
@@ -132,6 +132,8 @@ export async function parkWorkOrderWaitingFriendAddWithClient(
|
||||
eventType?: string | undefined
|
||||
materialJson?: unknown
|
||||
rewardAmountFen?: number | null | undefined
|
||||
/** 本次同时改写 reward_amount 时,先把原值固化到 consignment_original_reward_fen。 */
|
||||
captureOriginalReward?: boolean | undefined
|
||||
extraPayload?: Record<string, unknown>
|
||||
},
|
||||
): Promise<WorkOrderRow | null> {
|
||||
@@ -174,6 +176,11 @@ export async function parkWorkOrderWaitingFriendAddWithClient(
|
||||
last_assigned_worker_id = NULL,
|
||||
accepted_at = CASE WHEN $4 THEN NULL ELSE accepted_at END,
|
||||
material_json = COALESCE($5::jsonb, material_json),
|
||||
consignment_original_reward_fen = CASE
|
||||
WHEN $8::boolean AND $7::bigint IS NOT NULL
|
||||
THEN COALESCE(consignment_original_reward_fen, reward_amount)
|
||||
ELSE consignment_original_reward_fen
|
||||
END,
|
||||
reward_amount = COALESCE($7, reward_amount),
|
||||
updated_at = $2::timestamptz
|
||||
WHERE id = $6
|
||||
@@ -186,6 +193,7 @@ export async function parkWorkOrderWaitingFriendAddWithClient(
|
||||
materialJson,
|
||||
workOrder.id,
|
||||
targetRewardAmountFen,
|
||||
input.captureOriginalReward === true,
|
||||
],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
|
||||
@@ -95,7 +95,13 @@ export type WorkOrderUpdatePatch = Partial<
|
||||
| 'accepted_at'
|
||||
| 'updated_at'
|
||||
>
|
||||
>
|
||||
> & {
|
||||
/**
|
||||
* 本次同时改写 reward_amount 时,先把改写前的金额固化到
|
||||
* consignment_original_reward_fen(仅在为空时写入),供释放库存时恢复。
|
||||
*/
|
||||
capture_consignment_original_reward?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 只更新 patch 中显式传入的列。
|
||||
@@ -135,6 +141,12 @@ export function buildWorkOrderUpdateStatement(
|
||||
if (patch.reward_amount !== undefined) {
|
||||
assign('reward_amount', toPositiveInteger(patch.reward_amount, 0))
|
||||
}
|
||||
if (patch.capture_consignment_original_reward === true) {
|
||||
// SET 表达式读取的是更新前的行值,因此这里拿到的是本次改写前的原报酬。
|
||||
assignments.push(
|
||||
'consignment_original_reward_fen = COALESCE(consignment_original_reward_fen, reward_amount)',
|
||||
)
|
||||
}
|
||||
if (patch.sharing_enabled !== undefined) {
|
||||
assign('sharing_enabled', patch.sharing_enabled === true)
|
||||
}
|
||||
|
||||
@@ -546,6 +546,8 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
eventType: 'consignment_fulfilled',
|
||||
materialJson: updatedMaterial,
|
||||
rewardAmountFen: effectiveRewardAmountFen,
|
||||
// 内部现货履约按成本价改写报酬时,先固化原值供后续释放恢复。
|
||||
captureOriginalReward: effectiveRewardAmountFen !== previousRewardAmountFen,
|
||||
extraPayload: {
|
||||
inventoryId: inv.id,
|
||||
inventoryNo: inv.inventory_no,
|
||||
@@ -806,6 +808,8 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
await updateWorkOrder(workOrder.id, {
|
||||
material_json: updatedMaterial,
|
||||
reward_amount: rewardAmountFen,
|
||||
// 指派供货卖家本人时会把报酬改写为结算价;先固化原值,释放库存时恢复。
|
||||
capture_consignment_original_reward: rewardAmountFen !== previousRewardAmountFen,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveInternalMatchDelayMinutes } from './consignment-match-engine-service.js'
|
||||
|
||||
test('内部匹配等待时长:0/负数/缺失都归一为 0(直接发大厅)', () => {
|
||||
assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: 0 }), 0)
|
||||
assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: -5 }), 0)
|
||||
assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: '0' }), 0)
|
||||
assert.equal(resolveInternalMatchDelayMinutes({}), 0)
|
||||
assert.equal(resolveInternalMatchDelayMinutes(null), 0)
|
||||
assert.equal(resolveInternalMatchDelayMinutes(undefined), 0)
|
||||
assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: 30 }), 30)
|
||||
assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: '12.6' }), 13)
|
||||
})
|
||||
|
||||
test('内部匹配入口:0 分钟必须直接返回,不能依赖 fallbackToHall 进入 internal_matching', async () => {
|
||||
const source = await readFile(
|
||||
new URL('./consignment-match-engine-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const start = source.indexOf('export async function enterConsignmentInternalMatching')
|
||||
const end = source.indexOf('export async function reenterConsignmentInternalMatching', start)
|
||||
assert.ok(start >= 0 && end > start, '找不到 enterConsignmentInternalMatching')
|
||||
const body = source.slice(start, end)
|
||||
|
||||
assert.ok(
|
||||
body.includes(
|
||||
'const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch)',
|
||||
),
|
||||
'进入内部匹配必须使用统一的等待时长解析',
|
||||
)
|
||||
assert.ok(
|
||||
body.includes('if (delayMinutes <= 0) return false'),
|
||||
'0 分钟必须直接返回,禁止进入 internal_matching',
|
||||
)
|
||||
assert.ok(
|
||||
!body.includes('delayMinutes <= 0 && !profile.config.internalMatch.fallbackToHall'),
|
||||
'不允许再用 fallbackToHall 绕过 0 分钟保护(会生成无截止时间的悬空工单)',
|
||||
)
|
||||
})
|
||||
|
||||
test('匹配改写报酬:首次固化原值,所有统一释放路径都必须恢复', async () => {
|
||||
const [engineSrc, allocationSrc, consignmentSrc, managementSrc, fulfillmentSrc, giftSrc] =
|
||||
await Promise.all([
|
||||
readFile(new URL('./consignment-match-engine-service.ts', import.meta.url), 'utf8'),
|
||||
readFile(
|
||||
new URL(
|
||||
'../../repositories/worker-platform/consignment-allocation-repo.ts',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
readFile(
|
||||
new URL('../../repositories/worker-platform/consignment-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
readFile(
|
||||
new URL(
|
||||
'../../repositories/worker-platform/work-order-management-repo.ts',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
readFile(new URL('./consignment-fulfillment-service.ts', import.meta.url), 'utf8'),
|
||||
readFile(
|
||||
new URL('../../repositories/worker-platform/work-order-gift-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
])
|
||||
|
||||
assert.ok(
|
||||
engineSrc.includes(
|
||||
'consignment_original_reward_fen = COALESCE(consignment_original_reward_fen, reward_amount)',
|
||||
),
|
||||
'整单匹配必须固化匹配前的原始报酬(且二次匹配不覆盖)',
|
||||
)
|
||||
assert.ok(
|
||||
engineSrc.includes('previousRewardAmountFen: originalRewardFen'),
|
||||
'material 快照应沿用首次匹配的原始报酬',
|
||||
)
|
||||
|
||||
const releaseStart = allocationSrc.indexOf(
|
||||
'export async function releaseConsignmentAllocationsForWorkOrderWithClient',
|
||||
)
|
||||
const releaseEnd = allocationSrc.indexOf(
|
||||
'export async function markConsignmentAllocationsConsumedWithClient',
|
||||
releaseStart,
|
||||
)
|
||||
assert.ok(releaseStart >= 0 && releaseEnd > releaseStart, '找不到整单释放函数')
|
||||
assert.ok(
|
||||
allocationSrc
|
||||
.slice(releaseStart, releaseEnd)
|
||||
.includes('await restoreConsignmentOriginalRewardWithClient(client, workOrderId, now)'),
|
||||
'新分配释放路径必须恢复原始报酬',
|
||||
)
|
||||
|
||||
const bindingStart = consignmentSrc.indexOf(
|
||||
'export async function releaseConsignmentInventoryBindingForWorkOrderWithClient',
|
||||
)
|
||||
assert.ok(bindingStart >= 0, '找不到旧绑定释放函数')
|
||||
assert.ok(
|
||||
consignmentSrc
|
||||
.slice(bindingStart, bindingStart + 3000)
|
||||
.includes(
|
||||
'restoreConsignmentOriginalRewardWithClient(client, Number(workOrderId), input.now)',
|
||||
),
|
||||
'旧绑定释放路径必须恢复原始报酬',
|
||||
)
|
||||
|
||||
assert.ok(
|
||||
managementSrc.includes('capture_consignment_original_reward') &&
|
||||
managementSrc.includes(
|
||||
'consignment_original_reward_fen = COALESCE(consignment_original_reward_fen, reward_amount)',
|
||||
),
|
||||
'通用工单更新补丁必须支持固化原始报酬',
|
||||
)
|
||||
assert.ok(
|
||||
fulfillmentSrc.includes('capture_consignment_original_reward:') &&
|
||||
fulfillmentSrc.includes('captureOriginalReward:'),
|
||||
'旧履约/指派链路改写报酬时必须固化原值',
|
||||
)
|
||||
assert.ok(
|
||||
giftSrc.includes('captureOriginalReward') &&
|
||||
giftSrc.includes('COALESCE(consignment_original_reward_fen, reward_amount)'),
|
||||
'parkWorkOrderWaitingFriendAddWithClient 必须支持固化原始报酬',
|
||||
)
|
||||
})
|
||||
@@ -74,6 +74,19 @@ function addMinutesIso(minutes: number, now: string): string | null {
|
||||
return new Date(new Date(now).getTime() + normalized * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部匹配等待时长(分钟)。0 表示不等待、直接发大厅。
|
||||
*
|
||||
* 注意:0 绝不能进入 internal_matching —— addMinutesIso(0) 返回 null,
|
||||
* 工单会变成「内部匹配中且无截止时间」,而超时任务只处理 deadline 非空的工单,
|
||||
* 最终就是永久悬空(既不在大厅也不超时)。
|
||||
*/
|
||||
export function resolveInternalMatchDelayMinutes(
|
||||
config: { delayMinutes?: unknown } | null | undefined,
|
||||
): number {
|
||||
return Math.max(0, Math.round(Number(config?.delayMinutes || 0)))
|
||||
}
|
||||
|
||||
function inventorySalePriceFen(inventory: ConsignmentInventoryRow): number {
|
||||
return Math.max(0, Math.round(Number(inventory.sale_price_fen || inventory.cost_price_fen || 0)))
|
||||
}
|
||||
@@ -272,8 +285,11 @@ export async function enterConsignmentInternalMatching(
|
||||
if (!profile) return false
|
||||
// 关闭匹配:新单直接进大厅,不进入内部匹配队列。
|
||||
if (profile.config.matching.mode === 'off') return false
|
||||
const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes)
|
||||
if (delayMinutes <= 0 && !profile.config.internalMatch.fallbackToHall) return false
|
||||
const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch)
|
||||
// 0 分钟 = 不等待,直接发大厅。此时不能进入 internal_matching:
|
||||
// addMinutesIso(0) 返回 null,工单会变成「内部匹配中且无截止时间」,
|
||||
// 超时任务只处理 deadline 非空的工单,结果就是永久悬空。
|
||||
if (delayMinutes <= 0) return false
|
||||
const deadline = addMinutesIso(delayMinutes, now)
|
||||
const result = await query<WorkOrderRow>(
|
||||
`
|
||||
@@ -321,7 +337,7 @@ export async function reenterConsignmentInternalMatching(
|
||||
if (!profile) return false
|
||||
// 关闭匹配:退回大厅后不再重新排队等内部库存。
|
||||
if (profile.config.matching.mode === 'off') return false
|
||||
const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes)
|
||||
const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch)
|
||||
if (delayMinutes <= 0) return false
|
||||
const deadline = addMinutesIso(delayMinutes, now)
|
||||
const result = await query<WorkOrderRow>(
|
||||
@@ -516,6 +532,10 @@ async function applyWholeOrderMatch(
|
||||
if (!allocation) return { applied: false }
|
||||
|
||||
const config = profile?.config
|
||||
// 首次匹配时固化原报酬;二次匹配沿用同一原值,避免在已被改写的金额上继续扣服务费。
|
||||
const originalRewardFen = Number(
|
||||
current.consignment_original_reward_fen ?? current.reward_amount ?? 0,
|
||||
)
|
||||
const grossRewardFen =
|
||||
(unitPriceFen || Math.max(0, Number(current.reward_amount || 0))) * requiredQuantity
|
||||
const serviceFeeFen = config ? buildServiceFeeFen(config, grossRewardFen) : 0
|
||||
@@ -540,7 +560,7 @@ async function applyWholeOrderMatch(
|
||||
grossRewardFen,
|
||||
serviceFeeFen,
|
||||
netRewardFen,
|
||||
previousRewardAmountFen: Number(current.reward_amount || 0),
|
||||
previousRewardAmountFen: originalRewardFen,
|
||||
workerId: Number(inventory.worker_id),
|
||||
workerName: inventory.worker_name || '',
|
||||
matchedAt: now,
|
||||
@@ -569,6 +589,7 @@ async function applyWholeOrderMatch(
|
||||
deadline_at = $4,
|
||||
timeout_minutes = $5,
|
||||
timeout_policy = $6,
|
||||
consignment_original_reward_fen = COALESCE(consignment_original_reward_fen, reward_amount),
|
||||
reward_amount = $7,
|
||||
material_json = $8::jsonb,
|
||||
hall_visible_at = NULL,
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
releaseConsignmentForWorkOrder,
|
||||
} from './consignment-match-engine-service.js'
|
||||
import { fulfillWorkOrderWithConsignment } from './consignment-fulfillment-service.js'
|
||||
import {
|
||||
listWorkerConsignmentProfiles,
|
||||
resolveConsignmentProfileForCategory,
|
||||
} from './consignment-profile-service.js'
|
||||
import {
|
||||
computeConsignmentPrice,
|
||||
normalizeConsignmentSpecValues,
|
||||
@@ -713,6 +717,10 @@ async function main() {
|
||||
assert(row.status === 'in_progress', `status=${row.status}`)
|
||||
assert(Number(row.assigned_worker_id) === sellerId, `assigned=${row.assigned_worker_id}`)
|
||||
assert(Number(row.reward_amount) === 8550, `reward=${row.reward_amount}`)
|
||||
assert(
|
||||
Number(row.consignment_original_reward_fen) === 10000,
|
||||
`原始报酬=${row.consignment_original_reward_fen}`,
|
||||
)
|
||||
assert(
|
||||
Number(row.material_json.consignment.inventoryId) === Number(invARow.id),
|
||||
'material 未写库存',
|
||||
@@ -821,6 +829,15 @@ async function main() {
|
||||
[Number(exactWo.id)],
|
||||
)
|
||||
assert(allocation.rows[0]!.status === 'released', `allocation=${allocation.rows[0]!.status}`)
|
||||
const restored = await query<Record<string, any>>(
|
||||
'SELECT reward_amount, consignment_original_reward_fen FROM work_orders WHERE id = $1',
|
||||
[Number(exactWo.id)],
|
||||
)
|
||||
assert(
|
||||
Number(restored.rows[0]!.reward_amount) === 10000,
|
||||
`释放后报酬应恢复原值 10000,实际 ${restored.rows[0]!.reward_amount}`,
|
||||
)
|
||||
assert(restored.rows[0]!.consignment_original_reward_fen === null, '恢复后原始报酬字段必须清空')
|
||||
})
|
||||
await query('UPDATE consignment_inventories SET allow_matching = FALSE WHERE id = $1', [
|
||||
Number(invARow.id),
|
||||
@@ -1291,7 +1308,29 @@ async function main() {
|
||||
assert(event.rows.length === 1, '缺少超时事件')
|
||||
})
|
||||
|
||||
// ---------- 8.5 草稿隔离(P1-1)----------
|
||||
await check('配置草稿未发布前不得影响打手端/工单读取', async () => {
|
||||
await query(
|
||||
`UPDATE consignment_profiles SET config_json = jsonb_set(config_json, '{matching,exactOnly}', 'false'::jsonb) WHERE id = $1`,
|
||||
[profileId],
|
||||
)
|
||||
const workerProfiles = await listWorkerConsignmentProfiles()
|
||||
const mine = workerProfiles.find((item) => Number(item.profileId) === profileId)
|
||||
assert(mine, '打手端必须能读到该分类配置')
|
||||
assert(
|
||||
mine!.config.matching.exactOnly === true,
|
||||
`草稿 exactOnly=false 不应影响打手端,实际 ${mine!.config.matching.exactOnly}`,
|
||||
)
|
||||
const snapshot = await resolveConsignmentProfileForCategory(categoryId)
|
||||
assert(snapshot, '工单必须能解析到寄卖配置')
|
||||
assert(
|
||||
snapshot!.config.matching.exactOnly === true,
|
||||
`工单解析配置必须读已发布快照,实际 ${snapshot!.config.matching.exactOnly}`,
|
||||
)
|
||||
})
|
||||
|
||||
// ---------- 9. 死配置探测 ----------
|
||||
// P1-1:匹配只读已发布版本快照,改草稿不生效,必须连快照一起改(等价于重新发布)。
|
||||
await query(
|
||||
`UPDATE consignment_profiles SET config_json = jsonb_set(config_json, '{matching,exactOnly}', 'false'::jsonb) WHERE id = $1`,
|
||||
[profileId],
|
||||
@@ -1300,10 +1339,18 @@ async function main() {
|
||||
`UPDATE consignment_profiles SET config_json = jsonb_set(config_json, '{matching,preferLowReward}', 'false'::jsonb) WHERE id = $1`,
|
||||
[profileId],
|
||||
)
|
||||
await query(
|
||||
`UPDATE consignment_profile_versions SET config_json = jsonb_set(config_json, '{matching,exactOnly}', 'false'::jsonb) WHERE id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
await query(
|
||||
`UPDATE consignment_profile_versions SET config_json = jsonb_set(config_json, '{matching,preferLowReward}', 'false'::jsonb) WHERE id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
await check('配置开关 exactOnly=false 应允许自动匹配非精确库存', async () => {
|
||||
const profile = await query<Record<string, any>>(
|
||||
'SELECT config_json FROM consignment_profiles WHERE id = $1',
|
||||
[profileId],
|
||||
'SELECT config_json FROM consignment_profile_versions WHERE id = $1',
|
||||
[versionId],
|
||||
)
|
||||
assert(profile.rows[0]!.config_json.matching.exactOnly === false, 'exactOnly 未更新')
|
||||
// 此时唯一在库可匹配的只剩 3钻3璀(高配)库存,1钻1璀 订单只能靠它。
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildServiceFeeFen,
|
||||
normalizeConsignmentProfileConfig,
|
||||
resolvePriceDropSuggestion,
|
||||
resolvePublishedProfileConfig,
|
||||
} from './consignment-profile-service.js'
|
||||
|
||||
test('寄卖配置:缺省值必须可安全用于发布(保证金/服务费默认关闭)', () => {
|
||||
@@ -107,6 +108,23 @@ test('合并迁移 142 必须包含寄卖配置、库存数量/售价与分配
|
||||
}
|
||||
})
|
||||
|
||||
test('寄卖配置:打手端/工单/仓库只认已发布版本快照,草稿改了也不生效', () => {
|
||||
const published = resolvePublishedProfileConfig({
|
||||
// 草稿:后台刚保存但还没发布
|
||||
config_json: { tabTitle: '草稿标题', matching: { mode: 'auto' } },
|
||||
// 当前发布版本快照
|
||||
current_version_config_json: { tabTitle: '已发布标题', matching: { mode: 'off' } },
|
||||
})
|
||||
assert.equal(published.tabTitle, '已发布标题', '必须读已发布快照而不是草稿')
|
||||
assert.equal(published.matching.mode, 'off', '匹配开关不能被未发布草稿改写')
|
||||
|
||||
// 历史数据没有版本快照(或版本被删)时回退草稿,避免老配置突然不可用
|
||||
const legacy = resolvePublishedProfileConfig({ config_json: { tabTitle: '老配置' } })
|
||||
assert.equal(legacy.tabTitle, '老配置')
|
||||
const empty = resolvePublishedProfileConfig({})
|
||||
assert.equal(empty.tabTitle, '')
|
||||
})
|
||||
|
||||
test('打手端寄卖分类:苏尔南手枪固定排第一', async () => {
|
||||
const source = await readFile(
|
||||
new URL('./consignment-profile-service.ts', import.meta.url),
|
||||
|
||||
@@ -117,6 +117,8 @@ export type ConsignmentProfileRow = {
|
||||
category_name?: string
|
||||
category_parent_id?: number | null
|
||||
current_version_no?: number | null
|
||||
/** 当前发布版本快照(consignment_profile_versions.config_json) */
|
||||
current_version_config_json?: unknown
|
||||
}
|
||||
|
||||
export type ConsignmentProfileSnapshot = {
|
||||
@@ -342,12 +344,29 @@ const PROFILE_SELECT = `
|
||||
p.*,
|
||||
c.name AS category_name,
|
||||
c.parent_id AS category_parent_id,
|
||||
v.version_no AS current_version_no
|
||||
v.version_no AS current_version_no,
|
||||
v.config_json AS current_version_config_json
|
||||
FROM consignment_profiles p
|
||||
INNER JOIN work_categories c ON c.id = p.category_id
|
||||
LEFT JOIN consignment_profile_versions v ON v.id = p.current_version_id
|
||||
`
|
||||
|
||||
/**
|
||||
* 已发布口径:打手端分类、工单匹配、寄售仓库筛选项都必须读当前发布版本快照
|
||||
* (consignment_profile_versions.config_json),而 consignment_profiles.config_json 只是草稿。
|
||||
* 后台保存草稿只写 config_json,若读取端也读草稿,就会出现「未发布即生效」。
|
||||
* 历史数据没有版本快照时回退草稿,保证老配置不会突然不可用。
|
||||
*/
|
||||
export function resolvePublishedProfileConfig(row: {
|
||||
current_version_config_json?: unknown
|
||||
config_json?: unknown
|
||||
}): ConsignmentProfileConfig {
|
||||
const published = row.current_version_config_json
|
||||
return normalizeConsignmentProfileConfig(
|
||||
published === undefined || published === null ? row.config_json : published,
|
||||
)
|
||||
}
|
||||
|
||||
export async function listAdminConsignmentProfiles() {
|
||||
const [categories, profiles] = await Promise.all([
|
||||
listAllWorkCategories(),
|
||||
@@ -514,7 +533,7 @@ export async function listWorkerConsignmentProfiles() {
|
||||
`,
|
||||
)
|
||||
return result.rows.map((row) => {
|
||||
const config = normalizeConsignmentProfileConfig(row.config_json)
|
||||
const config = resolvePublishedProfileConfig(row)
|
||||
return {
|
||||
profileId: Number(row.id),
|
||||
categoryId: Number(row.category_id),
|
||||
@@ -659,7 +678,7 @@ export async function listAdminConsignmentInventoryFilterOptions(): Promise<{
|
||||
if (!effectiveProfile) continue
|
||||
if (!ownProfile && !inventoryCountByCategoryId.get(Number(category.id))) continue
|
||||
|
||||
const config = normalizeConsignmentProfileConfig(effectiveProfile.config_json)
|
||||
const config = resolvePublishedProfileConfig(effectiveProfile)
|
||||
items.push({
|
||||
categoryId: Number(category.id),
|
||||
categoryKey: category.category_key,
|
||||
@@ -710,7 +729,7 @@ export async function resolveConsignmentProfileForCategory(
|
||||
categoryName: row.category_name || '',
|
||||
versionId: Number(row.current_version_id),
|
||||
versionNo: Number(row.current_version_no || 1),
|
||||
config: normalizeConsignmentProfileConfig(row.config_json),
|
||||
config: resolvePublishedProfileConfig(row),
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -733,7 +752,7 @@ export async function getPublishedConsignmentProfileById(
|
||||
categoryName: row.category_name || '',
|
||||
versionId: Number(row.current_version_id),
|
||||
versionNo: Number(row.current_version_no || 1),
|
||||
config: normalizeConsignmentProfileConfig(row.config_json),
|
||||
config: resolvePublishedProfileConfig(row),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,9 +203,7 @@ test('已确认加好友后解绑:现货作废下架,绝不回池被下一
|
||||
reason: '后台撤单',
|
||||
})
|
||||
|
||||
const invStatement = statements.find((item) =>
|
||||
item.sql.includes('UPDATE consignment_inventories'),
|
||||
)!
|
||||
const invStatement = statements.find((item) => item.sql.includes('已确认加好友,作废下架'))!
|
||||
// 作废下架而不是回库:status 必须落在 archived,并且顺手关掉匹配开关
|
||||
assert.ok(invStatement.sql.includes("status = 'archived'"))
|
||||
assert.ok(invStatement.sql.includes('allow_matching = FALSE'))
|
||||
@@ -441,7 +439,7 @@ test('工单解绑回库时同步摘掉工单上的寄售标记', async () => {
|
||||
reason: '后台撤单',
|
||||
})
|
||||
|
||||
const detach = statements.find((item) => item.sql.includes('UPDATE work_orders'))!
|
||||
const detach = statements.find((item) => item.sql.includes("material_json - 'consignment'"))!
|
||||
assert.ok(detach.sql.includes("material_json - 'consignment'"))
|
||||
assert.deepEqual(detach.values.slice(2), [100154, '9'])
|
||||
// 摘标记必须发生在现货改状态之后,否则工单会继续显示已失效的寄售履约
|
||||
@@ -2811,13 +2809,15 @@ test('确认加好友后的取消/退单/作废路径都要没收卖家发布押
|
||||
const hookCount = src.split('forfeitConsignmentPublishDepositByWorkOrderWithClient(').length - 1
|
||||
const forfeitFlags = src.split('isWorkOrderGiftDepositForfeitable(workOrder)').length - 1
|
||||
assert.ok(forfeitFlags > 0, `${file} 应保留押金不退判定`)
|
||||
assert.equal(hookCount, forfeitFlags, `${file} 每处"押金不退"判定都要同步没收卖家发布押金`)
|
||||
assert.equal(
|
||||
src.split('isWorkOrderGiftDepositForfeitable(workOrder)\n // 确认加好友之后该单押金不退')
|
||||
.length - 1,
|
||||
forfeitFlags,
|
||||
`${file} 没收动作必须紧跟判定、位于同一事务里`,
|
||||
)
|
||||
// 超时处置等路径会无条件没收(不依赖 isWorkOrderGiftDepositForfeitable),
|
||||
// 因此这里只要求“每处判定都有对应没收”,允许存在额外的无条件没收调用。
|
||||
assert.ok(hookCount >= forfeitFlags, `${file} 每处"押金不退"判定都要同步没收卖家发布押金`)
|
||||
const followedCount = (
|
||||
src.match(
|
||||
/isWorkOrderGiftDepositForfeitable\(workOrder\)\r?\n\s*\/\/ 确认加好友之后该单押金不退:/g,
|
||||
) || []
|
||||
).length
|
||||
assert.equal(followedCount, forfeitFlags, `${file} 没收动作必须紧跟判定、位于同一事务里`)
|
||||
}
|
||||
|
||||
const serviceSrc = await readFile.readFile(
|
||||
|
||||
Reference in New Issue
Block a user