fix(worker-platform): 修正寄卖超时押金扣除与拼单份额归属

- 超时扣除类策略在解绑前没收寄卖发布押金,兼容 material 历史回退

- 拼单份额按库存归属没收,避免误扣其他卖家

- 履约押金核销排除 consignment_publish 流水

- 补充 7 场景隔离库集成测试
This commit is contained in:
2026-11-10 12:09:14 +08:00
parent 05d3675f55
commit 87658a59b1
6 changed files with 727 additions and 43 deletions
@@ -423,9 +423,64 @@ export async function forfeitConsignmentPublishDepositBySubmissionWithClient(
return forfeitAmount
}
/** 从工单物料解析本次/最近一次寄售占用的库存:优先当前 consignment,解绑后回退 consignmentHistory。 */
export function resolveConsignmentInventoryIdFromMaterial(material: unknown): number {
const parseRecord = (value: unknown): Record<string, unknown> => {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
} catch {
return {}
}
}
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
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<number> {
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<number> {
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,
@@ -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],
)
@@ -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,
@@ -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
@@ -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<void>) {
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<Record<string, any>>(
`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<Record<string, any>>(
`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<Scenario> {
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<Record<string, any>>(
`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<Record<string, any>>(
'SELECT status FROM work_order_shares WHERE id = $1',
[shareAId],
)
const shareB = await query<Record<string, any>>(
'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<Record<string, any>>(
'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')}`,
)
}
},
)
@@ -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,