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:
2026-11-06 17:28:25 +08:00
parent f68375fa71
commit 3ee679ad4d
13 changed files with 327 additions and 23 deletions
@@ -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(