feat(consignment): 确认加好友后现货作废不回池,撤销履约押金可选并结算打手押金
现货口径(严):已确认加好友 = 买家好友已加上、货随时可能送出去,回池会被下一个工单 再次占用(等于二次出货)。收口在 releaseConsignmentInventoryBindingForWorkOrderWithClient: 解绑前读工单 acceptance_mode/friend_added_at,命中则 archived + allow_matching=FALSE + 留痕, 否则仍回池。所有解绑路径自动继承,发单系统接口未动。 撤销寄售履约(寄卖专属入口)支持两笔押金各自可选,默认按规则自动: - 打手履约押金:auto / refund / forfeit - 卖家发布押金:auto / keep_frozen / forfeit 策略抽成 domain/consignment-deposit-policy.ts 纯函数;覆盖时原因照旧必填,选项与金额写进 工单事件便于对账。 顺带修掉两个 bug: 1. 撤销寄售履约从不结算打手履约押金——只清 assigned_worker_id、只处理卖家押金, worker_deposit 单撤销后打手押金永久冻结。现按选项结算(退还/没收)。 2. detachConsignmentMaterialFromWorkOrderWithClient 里 $1 既作 $1::text(detachedAt) 又赋值给 updated_at,Postgres 整体推断成 text → 42804。凡解绑现货摘寄售标记的路径 (取消指派/撤单/退单/售后/撤销履约/人工回库)在真实库上必然 500,假 client 单测抓不到。 已显式 ::timestamptz,并补类级静态护栏扫同类写法。 验证:真实库(全回滚)——已确认加好友解绑 → archived/allow_matching=false; 未确认 → 仍 in_stock;打手押金冻结 3000 → 退还 3000(重复结算 0)→ 再冻 2500 → 没收 2500 (可用余额不变、冻结归零、流水 deposit_deduction)。后端 432 例(新增 5 例)全绿。
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { toJsonString } from './shared.js'
|
||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||
|
||||
export type ConsignmentInventoryRow = {
|
||||
id: string | number
|
||||
@@ -669,7 +670,9 @@ export async function detachConsignmentMaterialFromWorkOrderWithClient(
|
||||
),
|
||||
true
|
||||
),
|
||||
updated_at = $1
|
||||
/* $1 在本语句里已按 text 使用(detachedAt),这里必须显式转回 timestamptz:
|
||||
否则 Postgres 把它整体推断成 text,赋值给 timestamptz 列直接报 42804。 */
|
||||
updated_at = $1::timestamptz
|
||||
WHERE id = $3
|
||||
AND material_json ? 'consignment'
|
||||
AND ($4::text IS NULL OR material_json -> 'consignment' ->> 'inventoryId' = $4::text)
|
||||
@@ -784,8 +787,41 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient(
|
||||
workOrderId: number | string,
|
||||
input: { now: string; reason?: string },
|
||||
): Promise<ConsignmentInventoryRow | null> {
|
||||
// 已确认加好友的单子,买家好友已加上、货随时可能已经送出去,绝不能回池被下一个工单
|
||||
// 再占用(等于二次出货)。这里统一改成作废下架,所有解绑路径(取消指派/撤单/退单/
|
||||
// 售后/撤销寄售履约)自动继承这条口径,不需要各自判断。
|
||||
const orderRes = await client.query<{
|
||||
acceptance_mode: string | null
|
||||
friend_added_at: string | null
|
||||
}>('SELECT acceptance_mode, friend_added_at FROM work_orders WHERE id = $1', [workOrderId])
|
||||
const order = orderRes.rows[0]
|
||||
if (!order) return null
|
||||
const archiveOnly = isWorkOrderGiftDepositForfeitable(order)
|
||||
|
||||
const res = await client.query<ConsignmentInventoryRow>(
|
||||
archiveOnly
|
||||
? `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'archived',
|
||||
allocation_status = '待安排',
|
||||
assigned_worker_id = NULL,
|
||||
bound_order_id = NULL,
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
consumed_at = NULL,
|
||||
allow_matching = FALSE,
|
||||
remark = CASE
|
||||
WHEN $3 = '' THEN remark || ' [已确认加好友,作废下架]'
|
||||
ELSE remark || ' [已确认加好友,作废下架: ' || $3 || ']'
|
||||
END,
|
||||
updated_at = $1
|
||||
WHERE bound_work_order_id = $2
|
||||
AND status = 'consumed'
|
||||
RETURNING *
|
||||
`
|
||||
: `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
@@ -815,7 +851,9 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient(
|
||||
await detachConsignmentMaterialFromWorkOrderWithClient(client, {
|
||||
workOrderId,
|
||||
inventoryId: released.id,
|
||||
reason: `工单解绑回库: ${input.reason || ''}`,
|
||||
reason: archiveOnly
|
||||
? `已确认加好友,现货作废下架: ${input.reason || ''}`
|
||||
: `工单解绑回库: ${input.reason || ''}`,
|
||||
now: input.now,
|
||||
})
|
||||
return released
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { glob, readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
/**
|
||||
* 真实库踩过的坑:同一个占位符既按 `$1::text` 用(写进 jsonb),又赋值给 timestamp 列
|
||||
* (`updated_at = $1`)时,Postgres 会把整个占位符推断成 text,
|
||||
* 赋值时报 `42804 column "updated_at" is of type timestamp with time zone but expression is of type text`。
|
||||
*
|
||||
* 假 client 的单测不跑真 SQL,抓不到这类错误,只能靠静态扫描兜住。
|
||||
*/
|
||||
const TIMESTAMP_ASSIGNMENT =
|
||||
/(?:^|\s)(updated_at|created_at|accepted_at|deadline_at|submitted_at|friend_added_at|gift_available_at|consumed_at|revoked_at|archived_at|assigned_at|published_at|pinned_at)\s*=\s*\$(\d+)(?!\s*::)/g
|
||||
const TEXT_CAST = /\$(\d+)::text/g
|
||||
|
||||
test('仓库 SQL 不许让同一个占位符既当 text 又当时间戳', async () => {
|
||||
const offenders: string[] = []
|
||||
for await (const file of glob('src/**/*.ts', { cwd: new URL('../../../', import.meta.url) })) {
|
||||
if (file.includes('.test.')) continue
|
||||
const source = await readFile(new URL(`../../../${file}`, import.meta.url), 'utf8')
|
||||
// 模板字符串按反引号切片:每片大约对应一条 SQL 语句
|
||||
for (const chunk of source.split('`')) {
|
||||
if (!chunk.includes('UPDATE') && !chunk.includes('INSERT')) continue
|
||||
const textParams = new Set([...chunk.matchAll(TEXT_CAST)].map((match) => match[1]))
|
||||
if (textParams.size === 0) continue
|
||||
for (const match of chunk.matchAll(TIMESTAMP_ASSIGNMENT)) {
|
||||
const [, column, param] = match
|
||||
if (textParams.has(param!)) {
|
||||
offenders.push(`${file}: ${column} = $${param} 与 $${param}::text 冲突`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [])
|
||||
})
|
||||
|
||||
test('摘寄售标记的 SQL 显式把时间戳参数转回 timestamptz', async () => {
|
||||
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
|
||||
const detachStatement = source
|
||||
.split('`')
|
||||
.find((chunk) => chunk.includes("material_json - 'consignment'"))
|
||||
assert.ok(detachStatement, '没找到摘寄售标记的 SQL')
|
||||
assert.ok(detachStatement.includes('updated_at = $1::timestamptz'))
|
||||
})
|
||||
@@ -45,6 +45,66 @@ export async function getOutstandingDepositAmountWithClient(
|
||||
return resolveOutstandingDepositAmount(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算工单侧的履约押金:退还(release)或没收(forfeit)。
|
||||
*
|
||||
* 与取消/脱离工单时的处理同口径(金额取冻结台账与钱包冻结额的较小值),
|
||||
* 供「撤销寄售履约」这类需要显式选择押金去向的场景复用——
|
||||
* 这类场景以前只清 assigned_worker_id,会把打手押金一直冻着。
|
||||
*/
|
||||
export async function settleWorkOrderDepositWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
mode: 'release' | 'forfeit'
|
||||
note: string
|
||||
now: string
|
||||
},
|
||||
): Promise<number> {
|
||||
if (!input.workerId) return 0
|
||||
|
||||
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||
const amount = Math.min(
|
||||
await getOutstandingDepositAmountWithClient(client, input.workerId, input.workOrderId),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
if (amount <= 0) return 0
|
||||
|
||||
const forfeit = input.mode === 'forfeit'
|
||||
const nextAvailable = Number(wallet?.available_amount || 0) + (forfeit ? 0 : amount)
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - amount)
|
||||
await client.query(
|
||||
`
|
||||
UPDATE worker_wallets
|
||||
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
|
||||
WHERE worker_id = $4
|
||||
`,
|
||||
[nextAvailable, nextFrozen, input.now, input.workerId],
|
||||
)
|
||||
await client.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, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
|
||||
`,
|
||||
[
|
||||
input.workerId,
|
||||
forfeit ? 'deposit_deduction' : 'deposit_release',
|
||||
forfeit ? -amount : amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
input.note,
|
||||
JSON.stringify(forfeit ? { giftDepositForfeited: true } : {}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
return amount
|
||||
}
|
||||
|
||||
export async function enqueueDepositUnfreezeWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
|
||||
Reference in New Issue
Block a user