From 87658a59b179d3d8415da223f8ff6e0acf44ced6 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Tue, 10 Nov 2026 12:09:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker-platform):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=AF=84=E5=8D=96=E8=B6=85=E6=97=B6=E6=8A=BC=E9=87=91=E6=89=A3?= =?UTF-8?q?=E9=99=A4=E4=B8=8E=E6=8B=BC=E5=8D=95=E4=BB=BD=E9=A2=9D=E5=BD=92?= =?UTF-8?q?=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 超时扣除类策略在解绑前没收寄卖发布押金,兼容 material 历史回退 - 拼单份额按库存归属没收,避免误扣其他卖家 - 履约押金核销排除 consignment_publish 流水 - 补充 7 场景隔离库集成测试 --- .../consignment-deposit-repo.ts | 79 ++- .../work-order-deposit-repo.ts | 3 + .../worker-platform/work-order-issue-repo.ts | 32 +- .../work-order-settlement-repo.ts | 18 +- .../consignment-timeout.integration.test.ts | 618 ++++++++++++++++++ .../worker-platform-service.test.ts | 20 +- 6 files changed, 727 insertions(+), 43 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/consignment-timeout.integration.test.ts diff --git a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts index 603f0746..2b445706 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts @@ -423,9 +423,64 @@ export async function forfeitConsignmentPublishDepositBySubmissionWithClient( return forfeitAmount } +/** 从工单物料解析本次/最近一次寄售占用的库存:优先当前 consignment,解绑后回退 consignmentHistory。 */ +export function resolveConsignmentInventoryIdFromMaterial(material: unknown): number { + const parseRecord = (value: unknown): Record => { + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } + } + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} + } + const root = parseRecord(material) + const currentId = Number(parseRecord(root.consignment).inventoryId || 0) + if (currentId > 0) return currentId + const history = Array.isArray(root.consignmentHistory) ? root.consignmentHistory : [] + for (let index = history.length - 1; index >= 0; index -= 1) { + const id = Number(parseRecord(history[index]).inventoryId || 0) + if (id > 0) return id + } + return 0 +} + +/** 按库存没收寄卖发布押金:份额超时必须按份额自己的库存归属,避免没收错卖家。 */ +export async function forfeitConsignmentPublishDepositByInventoryWithClient( + client: PoolClient, + input: { + inventoryId: number | string + workOrderId?: number | null + note: string + now: string + }, +): Promise { + const res = await client.query<{ source_submission_id: number | null }>( + 'SELECT source_submission_id FROM consignment_inventories WHERE id = $1', + [input.inventoryId], + ) + const submissionId = res.rows[0]?.source_submission_id + if (!submissionId) return 0 + return forfeitConsignmentPublishDepositBySubmissionWithClient(client, { + submissionId, + workOrderId: input.workOrderId ?? null, + note: input.note, + now: input.now, + }) +} + /** - * 工单侧入口:按工单 material_json 里的寄卖现货找到卖家的提报单,没收其发布押金。 + * 工单侧入口:按工单物料里的寄卖现货找到卖家的提报单,没收其发布押金。 * 不是寄卖履约单、或押金已释放/已没收时返回 0(幂等)。 + * + * 兼容解绑顺序:若 material.consignment 已被移入 consignmentHistory, + * 回退取最近一条历史库存,避免「先解绑再没收」时静默失效。 */ export async function forfeitConsignmentPublishDepositByWorkOrderWithClient( client: PoolClient, @@ -435,24 +490,14 @@ export async function forfeitConsignmentPublishDepositByWorkOrderWithClient( now: string }, ): Promise { - const res = await client.query<{ submission_id: number | null }>( - ` - SELECT inv.source_submission_id AS submission_id - FROM work_orders wo - JOIN consignment_inventories inv - ON inv.id = CASE - WHEN (wo.material_json->'consignment'->>'inventoryId') ~ '^[0-9]+$' - THEN (wo.material_json->'consignment'->>'inventoryId')::int - END - WHERE wo.id = $1 - `, + const res = await client.query<{ material_json: unknown }>( + 'SELECT material_json FROM work_orders WHERE id = $1', [input.workOrderId], ) - const submissionId = res.rows[0]?.submission_id - if (!submissionId) return 0 - - return forfeitConsignmentPublishDepositBySubmissionWithClient(client, { - submissionId, + const inventoryId = resolveConsignmentInventoryIdFromMaterial(res.rows[0]?.material_json) + if (!inventoryId) return 0 + return forfeitConsignmentPublishDepositByInventoryWithClient(client, { + inventoryId, workOrderId: Number(input.workOrderId), note: input.note, now: input.now, diff --git a/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts index 9b20f5c4..ff0dfaad 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts @@ -39,6 +39,9 @@ export async function getOutstandingDepositAmountWithClient( COALESCE(SUM(CASE WHEN ledger_type = 'deposit_deduction' THEN ABS(amount) ELSE 0 END), 0)::int AS deducted_amount FROM worker_wallet_ledgers WHERE worker_id = $1 AND related_work_order_id = $2 + -- 寄卖发布押金是独立保证金,不能计入该工单的履约押金核销, + -- 否则「先没收发布押金、再结算履约押金」会把履约押金少退/少扣。 + AND COALESCE(payload_json->>'kind', '') <> 'consignment_publish' `, [workerId, workOrderId], ) diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 93788664..a6c1ec41 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -604,15 +604,6 @@ export async function settleOverdueWorkOrder(input: { // 赠送单已确认加好友后不再退押金:释放类处置转为扣除,事件类型按实际资金动作记录。 const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder) - // 确认加好友之后该单押金不退:打手履约押金按上面的 forfeitGiftDeposit 处理, - // 寄卖卖家的发布押金同口径一并没收(写 deposit_deduction 流水,之后不可能再退)。 - if (forfeitGiftDeposit) { - await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, { - workOrderId: Number(workOrder.id), - note: '已确认加好友,取消/脱离工单没收寄卖发布押金', - now: input.now, - }) - } const releasePolicies = ['reopen', 'cancel_release', 'rematch_release'] const isRematch = input.policy === 'rematch_release' || input.policy === 'rematch_deduct' const shouldReleaseDeposit = releasePolicies.includes(input.policy) && !forfeitGiftDeposit @@ -622,6 +613,10 @@ export async function settleOverdueWorkOrder(input: { (releasePolicies.includes(input.policy) && forfeitGiftDeposit) const effectivePolicy = releasePolicies.includes(input.policy) && forfeitGiftDeposit ? 'cancel_deduct' : input.policy + // 寄卖发布押金口径:赠送单确认加好友一律没收;普通超时只有「扣除押金」类策略没收, + // 释放类策略(reopen / cancel_release / rematch_release)保留冻结,随商品在库继续担保。 + const shouldForfeitPublishDeposit = + forfeitGiftDeposit || input.policy === 'cancel_deduct' || input.policy === 'rematch_deduct' const workerId = Number(workOrder.assigned_worker_id || 0) let resolvedDepositAmount = 0 @@ -706,6 +701,18 @@ export async function settleOverdueWorkOrder(input: { [targetStatus, input.now, input.workOrderId], ) + // 寄卖发布押金必须在解绑前处理:解绑会把 material.consignment 移入 consignmentHistory, + // 之后再按工单反查库存会落空(历史缺陷)。赠送单确认加好友时一律没收;普通超时仅扣除类策略没收。 + if (shouldForfeitPublishDeposit) { + await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, { + workOrderId: input.workOrderId, + note: forfeitGiftDeposit + ? '已确认加好友,取消/脱离工单没收寄卖发布押金' + : `寄卖履约超时,扣除寄卖保证金(${effectivePolicy})`, + now: input.now, + }) + } + // 超时处置(重开/取消):被本工单占用的寄卖现货必须先放回在库池。 // 内部现货履约单在赠送冷静期内同样带 deadline_at,若不解绑,现货会永久停留在 consumed: // 既不再参与匹配,发货押金也没有释放入口。 @@ -713,13 +720,8 @@ export async function settleOverdueWorkOrder(input: { now: input.now, reason: `超时处置(${effectivePolicy})`, }) - // 新版寄卖分配(支持数量/拼单):回补剩余数量,并按平台规则没收寄卖保证金。 + // 新版寄卖分配(支持数量/拼单):回补剩余数量。 await releaseConsignmentAllocationsForWorkOrderWithClient(client, input.workOrderId, input.now) - await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, { - workOrderId: input.workOrderId, - note: `寄卖履约超时,扣除寄卖保证金(${effectivePolicy})`, - now: input.now, - }) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 364408ff..42ca33d0 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -25,7 +25,7 @@ import { import { maybeUpgradeWorkerLevel } from './worker-ranking-repo.js' import type { WorkOrderRow, WorkOrderShareRow } from './types.js' import { - forfeitConsignmentPublishDepositByWorkOrderWithClient, + forfeitConsignmentPublishDepositByInventoryWithClient, releaseConsignmentPublishDepositBySubmissionWithClient, settleConsignmentSupplierPayoutByWorkOrderWithClient, } from './consignment-deposit-repo.js' @@ -1144,15 +1144,21 @@ export async function settleOverdueWorkOrderShare(input: { } } - // 寄卖份额超时:库存回池 + 按平台规则扣除该供货打手的寄卖保证金。 - await releaseConsignmentAllocationForShareWithClient(client, Number(share.id), input.now) - if (share.consignment_inventory_id) { - await forfeitConsignmentPublishDepositByWorkOrderWithClient(client, { + // 寄卖份额超时:库存回池 + 按平台规则没收该份额供货打手的寄卖保证金。 + // 必须按份额自己的库存归属:母单 material 只指向第一件库存,多卖家拼单会没收错人。 + const shouldForfeitPublishDeposit = + input.policy === 'cancel_deduct' || + input.policy === 'rematch_deduct' || + Boolean(share.friend_added_at) + if (shouldForfeitPublishDeposit && share.consignment_inventory_id) { + await forfeitConsignmentPublishDepositByInventoryWithClient(client, { + inventoryId: share.consignment_inventory_id, workOrderId: input.workOrderId, note: '寄卖拼单超时,扣除寄卖保证金', now: input.now, - }).catch(() => 0) + }) } + await releaseConsignmentAllocationForShareWithClient(client, Number(share.id), input.now) await client.query( `UPDATE work_order_shares diff --git a/apps/backend/src/services/worker-platform/consignment-timeout.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-timeout.integration.test.ts new file mode 100644 index 00000000..1516ee5d --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-timeout.integration.test.ts @@ -0,0 +1,618 @@ +/** + * 寄卖超时押金口径集成测试(仅隔离库)。 + * + * 2026-11-10 确认口径: + * - cancel_deduct / rematch_deduct:履约押金扣除,同时没收寄卖发布押金并写流水; + * - reopen / cancel_release / rematch_release:履约押金退还,发布押金保留冻结; + * - 好友赠送单已确认加好友:发布押金一律没收; + * - 拼单份额超时:按份额自己的库存归属没收发布押金,不得误伤其他卖家。 + * + * 运行: + * CONSIGNMENT_MATCHING_TEST_DATABASE_URL=postgres://.../order_site_ctest \ + * DATABASE_URL=postgres://.../order_site_ctest \ + * node --import tsx --test src/services/worker-platform/consignment-timeout.integration.test.ts + */ +import test from 'node:test' + +import { closeDb, query, withTransaction } from '../../db/client.js' +import { freezeConsignmentPublishDepositWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js' +import { settleOverdueWorkOrders } from './worker-service.js' + +const TEST_DATABASE_URL = String(process.env.CONSIGNMENT_MATCHING_TEST_DATABASE_URL || '').trim() + +type Check = { name: string; ok: boolean; detail: string } +const results: Check[] = [] + +async function check(name: string, fn: () => Promise) { + try { + await fn() + results.push({ name, ok: true, detail: '' }) + } catch (error) { + results.push({ + name, + ok: false, + detail: error instanceof Error ? error.message : String(error), + }) + } +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +const suffix = `${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}` +let seq = 0 + +type Policy = 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct' +type Scenario = { + sellerId: number + submissionId: number + inventoryId: number + workOrderId: number +} + +async function createAdmin() { + const res = await query<{ id: number }>( + `INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at) + VALUES ($1, 'x', 'admin', 'active', NOW(), NOW()) RETURNING id`, + [`ctimeout_admin_${suffix}`], + ) + return Number(res.rows[0]!.id) +} + +async function createWorker(label: string) { + const res = await query<{ id: number }>( + `INSERT INTO worker_users + (username, password_hash, display_name, phone, status, invite_code, can_consign, created_at, updated_at) + VALUES ($1, 'x', $2, $3, 'active', $4, TRUE, NOW(), NOW()) RETURNING id`, + [ + `ctimeout_${label}_${suffix}`, + `超时口径打手-${label}`, + `139${String(Date.now()).slice(-8)}`, + `CT${label}${suffix}`.slice(0, 32), + ], + ) + const id = Number(res.rows[0]!.id) + await query( + `INSERT INTO worker_wallets (worker_id, available_amount, frozen_deposit_amount, created_at, updated_at) + VALUES ($1, 100000, 0, NOW(), NOW())`, + [id], + ) + return id +} + +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`, + [`ctimeout_${label}_${suffix}`, `超时口径分类-${label}`], + ) + return Number(res.rows[0]!.id) +} + +async function createProfile(input: { + categoryId: number + label: string + adminId: number + matchingMode: 'off' | 'manual' +}) { + const config = { + version: 1, + tabTitle: `超时口径-${input.label}`, + specs: [], + specSource: 'manual', + matching: { mode: input.matchingMode, exactOnly: true, preferLowReward: true }, + internalMatch: { delayMinutes: 30 }, + timeout: { minutes: 60, policy: 'reopen' }, + } + 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}`, + '超时口径', + JSON.stringify(config), + input.adminId, + ], + ) + const profileId = 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`, + [profileId, JSON.stringify(config)], + ) + const versionId = Number(versionRes.rows[0]!.id) + await query( + `UPDATE consignment_profiles SET current_version_id = $1, status = 'published', updated_at = NOW() + WHERE id = $2`, + [versionId, profileId], + ) + return { profileId, versionId, config } +} + +async function createSubmission(input: { + workerId: number + categoryId: number + depositFen: number +}) { + const submissionNo = `CTIMEOUT-SUB-${++seq}-${suffix}` + const res = await query<{ id: number }>( + `INSERT INTO consignment_submissions + (submission_no, category_id, item_name, worker_id, worker_name, seller_phone, + deposit_amount_fen, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, '13800138000', $6, 'approved', NOW(), NOW()) RETURNING id`, + [ + submissionNo, + input.categoryId, + `超时口径商品-${suffix}`, + input.workerId, + `打手-${input.workerId}`, + input.depositFen, + ], + ) + const submissionId = Number(res.rows[0]!.id) + await withTransaction((client) => + freezeConsignmentPublishDepositWithClient(client, { + workerId: input.workerId, + amountFen: input.depositFen, + submissionId, + submissionNo, + note: `超时口径发布冻结 ${submissionNo}`, + now: new Date().toISOString(), + }), + ) + return submissionId +} + +async function createInventory(input: { + categoryId: number + sellerId: number + submissionId: number + profileId: number + versionId: number + snapshot: unknown +}) { + const res = await query<{ id: number }>( + `INSERT INTO consignment_inventories + (inventory_no, category_id, item_name, system, server_zone, series, diamond_level, + brilliance_count, cost_price_fen, sale_price_fen, price_cap_fen, quantity, + remaining_quantity, allow_matching, status, allocation_status, source_type, + source_submission_id, worker_id, worker_name, profile_id, profile_version_id, + profile_snapshot_json, spec_values_json, spec_key, spec_text, listed_at, inbound_at, + created_at, updated_at) + VALUES ($1, $2, $3, '苹果', '微信区', '暗潮', 1, 1, 7000, 9000, 9000, 1, + 0, TRUE, 'consumed', '已安排', 'worker_portal', $4, $5, $6, $7, $8, + $9::jsonb, '{}'::jsonb, 'system=苹果|series=暗潮', '苹果 暗潮 1钻', + NOW(), NOW(), NOW(), NOW()) RETURNING id`, + [ + `CTIMEOUT-INV-${++seq}-${suffix}`, + input.categoryId, + `超时口径商品-${suffix}`, + input.submissionId, + input.sellerId, + `打手-${input.sellerId}`, + input.profileId, + input.versionId, + JSON.stringify(input.snapshot), + ], + ) + return Number(res.rows[0]!.id) +} + +async function freezeFulfillmentDeposit(workerId: number, workOrderId: number, amountFen: number) { + await query( + `UPDATE worker_wallets + SET available_amount = available_amount - $2, + frozen_deposit_amount = frozen_deposit_amount + $2, + updated_at = NOW() + WHERE worker_id = $1`, + [workerId, amountFen], + ) + const wallet = await readWallet(workerId) + await query( + `INSERT INTO worker_wallet_ledgers + (worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at) + VALUES ($1, 'deposit_freeze', $2, $3, $4, $5, '超时口径履约冻结', '{}'::jsonb, NOW())`, + [workerId, -amountFen, wallet.available, wallet.frozen, workOrderId], + ) +} + +async function createOrder(input: { + categoryId: number + sellerId: number + inventoryId: number + policy: Policy + gift?: boolean +}) { + const material = + input.inventoryId > 0 + ? { + consignment: { + inventoryId: input.inventoryId, + salePriceFen: 9000, + quantity: 1, + grossRewardFen: 9000, + serviceFeeFen: 450, + netRewardFen: 8550, + }, + } + : {} + const res = await query<{ id: number }>( + `INSERT INTO work_orders + (work_order_no, product_name, category_id, status, reward_amount, + consignment_original_reward_fen, assigned_worker_id, assigned_at, deadline_at, + timeout_minutes, timeout_policy, material_json, acceptance_mode, friend_added_at, + created_at, updated_at) + VALUES ($1, '和平精英 苹果 微信区 暗潮 1钻 1璀 苏尔南', $2, 'in_progress', $3, $4, $5, + NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour', 60, $6, $7::jsonb, $8, $9, + NOW(), NOW()) RETURNING id`, + [ + `CTIMEOUT-WO-${++seq}-${suffix}`, + input.categoryId, + input.gift ? 10000 : 8550, + input.gift ? null : 10000, + input.sellerId, + input.policy, + JSON.stringify(material), + input.gift ? 'friend_gift' : 'standard', + input.gift ? new Date().toISOString() : null, + ], + ) + return Number(res.rows[0]!.id) +} + +async function bindInventory(inventoryId: number, workOrderId: number) { + await query( + `UPDATE consignment_inventories SET bound_work_order_id = $1, updated_at = NOW() WHERE id = $2`, + [workOrderId, inventoryId], + ) + await query( + `INSERT INTO consignment_inventory_allocations + (inventory_id, work_order_id, quantity, unit_price_fen, status, created_at, updated_at) + VALUES ($1, $2, 1, 9000, 'reserved', NOW(), NOW())`, + [inventoryId, workOrderId], + ) +} + +async function readWallet(workerId: number) { + const res = await query<{ available_amount: number; frozen_deposit_amount: number }>( + `SELECT available_amount, frozen_deposit_amount FROM worker_wallets WHERE worker_id = $1`, + [workerId], + ) + return { + available: Number(res.rows[0]?.available_amount || 0), + frozen: Number(res.rows[0]?.frozen_deposit_amount || 0), + } +} + +async function readOrder(workOrderId: number) { + const res = await query>( + `SELECT status, reward_amount, consignment_original_reward_fen, + rematch_excluded_worker_id, rematch_excluded_inventory_id + FROM work_orders WHERE id = $1`, + [workOrderId], + ) + return res.rows[0]! +} + +async function readLedgers(workOrderId: number) { + const res = await query>( + `SELECT ledger_type, amount FROM worker_wallet_ledgers + WHERE related_work_order_id = $1 ORDER BY id`, + [workOrderId], + ) + return res.rows.map((row) => ({ type: String(row.ledger_type), amount: Number(row.amount) })) +} + +async function setupScenario(input: { + label: string + policy: Policy + matchingMode: 'off' | 'manual' + adminId: number + gift?: boolean +}): Promise { + const sellerId = await createWorker(input.label) + const categoryId = await createCategory(input.label) + const profile = await createProfile({ + categoryId, + label: input.label, + adminId: input.adminId, + matchingMode: input.matchingMode, + }) + const submissionId = await createSubmission({ workerId: sellerId, categoryId, depositFen: 2000 }) + const inventoryId = await createInventory({ + categoryId, + sellerId, + submissionId, + profileId: profile.profileId, + versionId: profile.versionId, + snapshot: profile.config, + }) + const workOrderId = await createOrder({ + categoryId, + sellerId, + inventoryId, + policy: input.policy, + gift: input.gift, + }) + await bindInventory(inventoryId, workOrderId) + await freezeFulfillmentDeposit(sellerId, workOrderId, 3000) + return { sellerId, submissionId, inventoryId, workOrderId } +} + +async function assertScenario( + scenario: Scenario, + input: { + status: string + deposit: 'release' | 'deduct' + publishForfeited: boolean + available: number + frozen: number + rematch?: boolean + }, +) { + const order = await readOrder(scenario.workOrderId) + const wallet = await readWallet(scenario.sellerId) + const ledgers = await readLedgers(scenario.workOrderId) + assert(order.status === input.status, `status=${order.status}`) + assert(Number(order.reward_amount) === 10000, `reward=${order.reward_amount}`) + assert(order.consignment_original_reward_fen === null, '原始报酬未清空') + assert(wallet.available === input.available, `available=${wallet.available}`) + assert(wallet.frozen === input.frozen, `frozen=${wallet.frozen}`) + const released = ledgers.some((row) => row.type === 'deposit_release' && row.amount === 3000) + const deducted = ledgers.some((row) => row.type === 'deposit_deduction' && row.amount === -3000) + assert( + input.deposit === 'release' ? released && !deducted : deducted && !released, + `履约押金流水 release=${released} deduct=${deducted}`, + ) + const publish = ledgers.filter( + (row) => row.type === 'deposit_deduction' && row.amount === -2000, + ).length + assert(publish === (input.publishForfeited ? 1 : 0), `发布押金没收流水=${publish}`) + const inventory = await query>( + `SELECT status, remaining_quantity, bound_work_order_id FROM consignment_inventories WHERE id = $1`, + [scenario.inventoryId], + ) + assert(inventory.rows[0]!.status === 'in_stock', `inventory=${inventory.rows[0]!.status}`) + assert(Number(inventory.rows[0]!.remaining_quantity) === 1, '库存数量未回补') + assert(inventory.rows[0]!.bound_work_order_id === null, '库存未解绑') + if (input.rematch) { + assert( + Number(order.rematch_excluded_worker_id) === scenario.sellerId, + `excludedWorker=${order.rematch_excluded_worker_id}`, + ) + assert( + Number(order.rematch_excluded_inventory_id) === scenario.inventoryId, + `excludedInventory=${order.rematch_excluded_inventory_id}`, + ) + } +} + +async function main() { + const adminId = await createAdmin() + + await check('cancel_deduct:扣履约押金 + 没收发布押金 + 写流水', async () => { + const scenario = await setupScenario({ + label: 'DEDUCT_CANCEL', + policy: 'cancel_deduct', + matchingMode: 'off', + adminId, + }) + await settleOverdueWorkOrders({ limit: 10, workerId: scenario.sellerId }) + await assertScenario(scenario, { + status: 'cancelled', + deposit: 'deduct', + publishForfeited: true, + available: 95000, + frozen: 0, + }) + }) + + await check('rematch_deduct:扣履约押金 + 没收发布押金 + 重新匹配', async () => { + const scenario = await setupScenario({ + label: 'DEDUCT_REMATCH', + policy: 'rematch_deduct', + matchingMode: 'manual', + adminId, + }) + await settleOverdueWorkOrders({ limit: 10, workerId: scenario.sellerId }) + await assertScenario(scenario, { + status: 'internal_matching', + deposit: 'deduct', + publishForfeited: true, + available: 95000, + frozen: 0, + rematch: true, + }) + }) + + await check('reopen:退履约押金,发布押金保留冻结', async () => { + const scenario = await setupScenario({ + label: 'REOPEN', + policy: 'reopen', + matchingMode: 'off', + adminId, + }) + await settleOverdueWorkOrders({ limit: 10, workerId: scenario.sellerId }) + await assertScenario(scenario, { + status: 'open', + deposit: 'release', + publishForfeited: false, + available: 98000, + frozen: 2000, + }) + }) + + await check('cancel_release:退履约押金,发布押金保留冻结', async () => { + const scenario = await setupScenario({ + label: 'RELEASE_CANCEL', + policy: 'cancel_release', + matchingMode: 'off', + adminId, + }) + await settleOverdueWorkOrders({ limit: 10, workerId: scenario.sellerId }) + await assertScenario(scenario, { + status: 'cancelled', + deposit: 'release', + publishForfeited: false, + available: 98000, + frozen: 2000, + }) + }) + + await check('rematch_release:退履约押金 + 发布押金保留 + 重新匹配', async () => { + const scenario = await setupScenario({ + label: 'RELEASE_REMATCH', + policy: 'rematch_release', + matchingMode: 'manual', + adminId, + }) + await settleOverdueWorkOrders({ limit: 10, workerId: scenario.sellerId }) + await assertScenario(scenario, { + status: 'internal_matching', + deposit: 'release', + publishForfeited: false, + available: 98000, + frozen: 2000, + rematch: true, + }) + }) + + await check('赠送单 + 寄卖:发布押金与履约押金各自独立核销', async () => { + const scenario = await setupScenario({ + label: 'GIFT', + policy: 'reopen', + matchingMode: 'off', + adminId, + gift: true, + }) + await settleOverdueWorkOrders({ limit: 10, workerId: scenario.sellerId }) + const wallet = await readWallet(scenario.sellerId) + const ledgers = await readLedgers(scenario.workOrderId) + assert(wallet.available === 95000, `available=${wallet.available}`) + assert(wallet.frozen === 0, `frozen=${wallet.frozen}`) + assert( + ledgers.some((row) => row.type === 'deposit_deduction' && row.amount === -3000), + '履约押金未按 3000 扣除', + ) + assert( + ledgers.some((row) => row.type === 'deposit_deduction' && row.amount === -2000), + '发布押金未没收', + ) + }) + + await check('拼单份额超时:只没收份额所属卖家的发布押金', async () => { + const sellerA = await createWorker('SHARE_A') + const sellerB = await createWorker('SHARE_B') + const categoryId = await createCategory('SHARE') + const profile = await createProfile({ + categoryId, + label: 'SHARE', + adminId, + matchingMode: 'off', + }) + const submissionA = await createSubmission({ workerId: sellerA, categoryId, depositFen: 2000 }) + const submissionB = await createSubmission({ workerId: sellerB, categoryId, depositFen: 3000 }) + const inventoryA = await createInventory({ + categoryId, + sellerId: sellerA, + submissionId: submissionA, + profileId: profile.profileId, + versionId: profile.versionId, + snapshot: profile.config, + }) + const inventoryB = await createInventory({ + categoryId, + sellerId: sellerB, + submissionId: submissionB, + profileId: profile.profileId, + versionId: profile.versionId, + snapshot: profile.config, + }) + const orderRes = await query<{ id: number }>( + `INSERT INTO work_orders + (work_order_no, product_name, category_id, status, reward_amount, sharing_enabled, + sharing_total_quantity, sharing_unit_reward, timeout_minutes, timeout_policy, + material_json, created_at, updated_at) + VALUES ($1, '和平精英 苹果 微信区 暗潮 1钻 1璀 苏尔南', $2, 'open', 10000, TRUE, 2, 5000, + 60, 'cancel_deduct', '{}'::jsonb, NOW(), NOW()) RETURNING id`, + [`CTIMEOUT-WO-SHARE-${suffix}`, categoryId], + ) + const workOrderId = Number(orderRes.rows[0]!.id) + const insertShare = async (workerId: number, inventoryId: number) => { + const res = await query<{ id: number }>( + `INSERT INTO work_order_shares + (work_order_id, worker_id, quantity, unit_reward, share_reward, share_deposit, status, + deadline_at, timeout_minutes, timeout_policy, consignment_inventory_id, + created_at, updated_at) + VALUES ($1, $2, 1, 5000, 5000, 0, 'joined', NOW() - INTERVAL '1 hour', 60, + 'cancel_deduct', $3, NOW(), NOW()) RETURNING id`, + [workOrderId, workerId, inventoryId], + ) + return Number(res.rows[0]!.id) + } + const shareAId = await insertShare(sellerA, inventoryA) + const shareBId = await insertShare(sellerB, inventoryB) + + await settleOverdueWorkOrders({ limit: 10, workerId: sellerA }) + const shareA = await query>( + 'SELECT status FROM work_order_shares WHERE id = $1', + [shareAId], + ) + const shareB = await query>( + 'SELECT status FROM work_order_shares WHERE id = $1', + [shareBId], + ) + assert(shareA.rows[0]!.status === 'cancelled', `shareA=${shareA.rows[0]!.status}`) + assert(shareB.rows[0]!.status === 'joined', `shareB=${shareB.rows[0]!.status}`) + const walletA = await readWallet(sellerA) + const walletB = await readWallet(sellerB) + assert(walletA.frozen === 0, `sellerA frozen=${walletA.frozen}`) + assert(walletB.frozen === 3000, `sellerB frozen=${walletB.frozen}`) + const ledgerA = await readLedgers(workOrderId) + assert( + ledgerA.some((row) => row.type === 'deposit_deduction' && row.amount === -2000), + 'sellerA 发布押金未没收', + ) + const ledgerB = await query>( + 'SELECT ledger_type, amount FROM worker_wallet_ledgers WHERE worker_id = $1', + [sellerB], + ) + assert( + !ledgerB.rows.some( + (row) => row.ledger_type === 'deposit_deduction' && Number(row.amount) === -3000, + ), + 'sellerB 发布押金被误扣', + ) + }) +} + +test( + '寄卖超时押金口径:扣除类没收发布押金、释放类保留冻结、份额按归属', + { + skip: TEST_DATABASE_URL ? false : '未设置 CONSIGNMENT_MATCHING_TEST_DATABASE_URL', + }, + async () => { + assert( + String(process.env.DATABASE_URL || '').trim() === TEST_DATABASE_URL, + 'DATABASE_URL 必须与 CONSIGNMENT_MATCHING_TEST_DATABASE_URL 一致,避免误跑开发库', + ) + try { + await main() + } finally { + await closeDb().catch(() => undefined) + } + const failed = results.filter((result) => !result.ok) + if (failed.length > 0) { + throw new Error( + `寄卖超时押金口径测试失败:\n${failed + .map((result) => `${result.name}: ${result.detail}`) + .join('\n')}`, + ) + } + }, +) 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 5320a022..0bd4b0fe 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 @@ -1785,10 +1785,12 @@ test('拼单份额取消后原打手可以重新参与(一人一条唯一键 migration.includes('DROP CONSTRAINT work_order_shares_work_order_id_worker_id_key'), '必须移除「同一工单同一打手只能一条份额」唯一键,否则取消后再次参与会 23505', ) - const shareRepoSrc = await readFile( - new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), - 'utf8', - ) + const shareRepoSrc = ( + await readFile( + new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), + 'utf8', + ) + ).replace(/\r\n/g, '\n') assert.ok( !shareRepoSrc.includes('worker_share_locked') && !shareRepoSrc.includes('worker_share_exists'), '再次参与不得因已有(已取消)份额被拒绝,取消的份额只是历史记录', @@ -2798,7 +2800,7 @@ test('不是寄卖履约单的工单不会去没收卖家押金', async () => { assert.equal(forfeited, 0) assert.equal(calls.length, 1, '查不到寄卖现货就该直接返回,不做多余写操作') - assert.ok(calls[0]!.includes("material_json->'consignment'->>'inventoryId'")) + assert.ok(calls[0]!.includes('SELECT material_json FROM work_orders')) }) test('确认加好友后的取消/退单/作废路径都要没收卖家发布押金', async () => { @@ -2819,6 +2821,14 @@ test('确认加好友后的取消/退单/作废路径都要没收卖家发布押 // 超时处置等路径会无条件没收(不依赖 isWorkOrderGiftDepositForfeitable), // 因此这里只要求“每处判定都有对应没收”,允许存在额外的无条件没收调用。 assert.ok(hookCount >= forfeitFlags, `${file} 每处"押金不退"判定都要同步没收卖家发布押金`) + if (file === 'work-order-issue-repo.ts') { + // 超时处置把赠送没收与「扣除押金」类策略合并为一次策略判定,不再紧跟判定行。 + assert.ok( + src.includes('const shouldForfeitPublishDeposit'), + `${file} 超时处置必须按策略判定是否没收发布押金`, + ) + continue + } const followedCount = ( src.match( /isWorkOrderGiftDepositForfeitable\(workOrder\)\r?\n\s*\/\/ 确认加好友之后该单押金不退:/g,