diff --git a/apps/backend/src/db/migrations/145_work_order_consignment_original_reward.sql b/apps/backend/src/db/migrations/145_work_order_consignment_original_reward.sql new file mode 100644 index 00000000..baef267a --- /dev/null +++ b/apps/backend/src/db/migrations/145_work_order_consignment_original_reward.sql @@ -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 时写入,释放库存时恢复并清空'; diff --git a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts index da1763fb..6e178353 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts @@ -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 { + 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 { + await restoreConsignmentOriginalRewardWithClient(client, workOrderId, now) const result = await client.query( `SELECT * FROM consignment_inventory_allocations WHERE work_order_id = $1 AND status = 'reserved' FOR UPDATE`, [workOrderId], diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 64c3c1f8..5a7e78a6 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -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 diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 8b1e6fde..c7e82e19 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -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 diff --git a/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts index 009d4c27..4a4e2f01 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts @@ -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 }, ): Promise { @@ -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, { diff --git a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts index f9a4411a..f06e15fc 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts @@ -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) } diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index f81f77d6..329f0326 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -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, }) diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts new file mode 100644 index 00000000..70c60e32 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts @@ -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 必须支持固化原始报酬', + ) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 1b973617..8b59a718 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -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( ` @@ -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( @@ -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, diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index 0e06c6f3..c732a90f 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -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>( + '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>( - '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璀 订单只能靠它。 diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index a1b937dd..7cc8b9c2 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -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), diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index c6ff00fa..be4a213c 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -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), } } diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 203e2e81..220e7eb1 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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(