fix(worker-platform): 修正寄卖超时押金扣除与拼单份额归属
- 超时扣除类策略在解绑前没收寄卖发布押金,兼容 material 历史回退 - 拼单份额按库存归属没收,避免误扣其他卖家 - 履约押金核销排除 consignment_publish 流水 - 补充 7 场景隔离库集成测试
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user