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