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:
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
isDepositHandlingOverridden,
|
||||
normalizeConsignmentDepositHandling,
|
||||
normalizeWorkerDepositHandling,
|
||||
resolveConsignmentDepositAction,
|
||||
resolveWorkerDepositMode,
|
||||
} from './consignment-deposit-policy.js'
|
||||
|
||||
test('押金处置选项归一:非法值一律退回按规则自动', () => {
|
||||
assert.equal(normalizeWorkerDepositHandling('refund'), 'refund')
|
||||
assert.equal(normalizeWorkerDepositHandling('forfeit'), 'forfeit')
|
||||
assert.equal(normalizeWorkerDepositHandling('auto'), 'auto')
|
||||
assert.equal(normalizeWorkerDepositHandling('随便写的'), 'auto')
|
||||
assert.equal(normalizeWorkerDepositHandling(undefined), 'auto')
|
||||
|
||||
assert.equal(normalizeConsignmentDepositHandling('keep_frozen'), 'keep_frozen')
|
||||
assert.equal(normalizeConsignmentDepositHandling('forfeit'), 'forfeit')
|
||||
assert.equal(normalizeConsignmentDepositHandling(null), 'auto')
|
||||
})
|
||||
|
||||
test('打手履约押金:自动规则跟随"是否已确认加好友"', () => {
|
||||
// 已确认加好友 → 自动没收
|
||||
assert.equal(resolveWorkerDepositMode('auto', true), 'forfeit')
|
||||
// 没确认加好友 → 自动全额退还
|
||||
assert.equal(resolveWorkerDepositMode('auto', false), 'release')
|
||||
assert.equal(resolveWorkerDepositMode(undefined, false), 'release')
|
||||
})
|
||||
|
||||
test('打手履约押金:运营显式选择可以推翻自动规则', () => {
|
||||
// 已确认加好友仍然选择退还
|
||||
assert.equal(resolveWorkerDepositMode('refund', true), 'release')
|
||||
// 没确认加好友也选择扣除
|
||||
assert.equal(resolveWorkerDepositMode('forfeit', false), 'forfeit')
|
||||
})
|
||||
|
||||
test('卖家发布押金:自动规则 = 已确认加好友没收 / 作废退还 / 回库保持冻结', () => {
|
||||
assert.equal(
|
||||
resolveConsignmentDepositAction('auto', { autoForfeit: true, archive: true }),
|
||||
'forfeit',
|
||||
)
|
||||
assert.equal(
|
||||
resolveConsignmentDepositAction('auto', { autoForfeit: true, archive: false }),
|
||||
'forfeit',
|
||||
)
|
||||
assert.equal(
|
||||
resolveConsignmentDepositAction('auto', { autoForfeit: false, archive: true }),
|
||||
'release',
|
||||
)
|
||||
assert.equal(
|
||||
resolveConsignmentDepositAction('auto', { autoForfeit: false, archive: false }),
|
||||
'keep_frozen',
|
||||
)
|
||||
})
|
||||
|
||||
test('卖家发布押金:显式"保留冻结"不会去退也不会去打款流水', () => {
|
||||
assert.equal(
|
||||
resolveConsignmentDepositAction('keep_frozen', { autoForfeit: true, archive: true }),
|
||||
'keep_frozen',
|
||||
)
|
||||
assert.equal(
|
||||
resolveConsignmentDepositAction('forfeit', { autoForfeit: false, archive: true }),
|
||||
'forfeit',
|
||||
)
|
||||
})
|
||||
|
||||
test('覆盖押金处置时视为需要留原因', () => {
|
||||
assert.equal(isDepositHandlingOverridden({}), false)
|
||||
assert.equal(isDepositHandlingOverridden({ workerDeposit: 'auto' }), false)
|
||||
assert.equal(isDepositHandlingOverridden({ workerDeposit: 'refund' }), true)
|
||||
assert.equal(isDepositHandlingOverridden({ consignmentDeposit: 'keep_frozen' }), true)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 寄卖撤销时的押金处置策略。
|
||||
*
|
||||
* 撤销寄售履约会牵动两笔钱:接单打手的履约押金、寄卖卖家的发布押金。
|
||||
* 默认都由规则自动判定(`auto`),运营也可以显式覆盖;覆盖时必须填撤销原因,
|
||||
* 便于事后对账和追责。
|
||||
*/
|
||||
|
||||
export const WORKER_DEPOSIT_HANDLING = {
|
||||
/** 按规则自动:已确认加好友则没收,否则全额退还 */
|
||||
AUTO: 'auto',
|
||||
REFUND: 'refund',
|
||||
FORFEIT: 'forfeit',
|
||||
} as const
|
||||
|
||||
export const CONSIGNMENT_DEPOSIT_HANDLING = {
|
||||
/** 按规则自动:已确认加好友则没收;否则作废时退还、回库时继续冻结 */
|
||||
AUTO: 'auto',
|
||||
/** 保持冻结(既不退还也不没收,留给后续人工处理) */
|
||||
KEEP_FROZEN: 'keep_frozen',
|
||||
FORFEIT: 'forfeit',
|
||||
} as const
|
||||
|
||||
export type WorkerDepositHandling =
|
||||
(typeof WORKER_DEPOSIT_HANDLING)[keyof typeof WORKER_DEPOSIT_HANDLING]
|
||||
export type ConsignmentDepositHandling =
|
||||
(typeof CONSIGNMENT_DEPOSIT_HANDLING)[keyof typeof CONSIGNMENT_DEPOSIT_HANDLING]
|
||||
|
||||
/** 打手履约押金的最终处置动作 */
|
||||
export type WorkerDepositMode = 'release' | 'forfeit'
|
||||
/** 卖家发布押金的最终处置动作;keep_frozen 表示本次不动这笔押金 */
|
||||
export type ConsignmentDepositAction = 'release' | 'forfeit' | 'keep_frozen'
|
||||
|
||||
export function normalizeWorkerDepositHandling(value: unknown): WorkerDepositHandling {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (raw === WORKER_DEPOSIT_HANDLING.REFUND) return WORKER_DEPOSIT_HANDLING.REFUND
|
||||
if (raw === WORKER_DEPOSIT_HANDLING.FORFEIT) return WORKER_DEPOSIT_HANDLING.FORFEIT
|
||||
return WORKER_DEPOSIT_HANDLING.AUTO
|
||||
}
|
||||
|
||||
export function normalizeConsignmentDepositHandling(value: unknown): ConsignmentDepositHandling {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (raw === CONSIGNMENT_DEPOSIT_HANDLING.KEEP_FROZEN) {
|
||||
return CONSIGNMENT_DEPOSIT_HANDLING.KEEP_FROZEN
|
||||
}
|
||||
if (raw === CONSIGNMENT_DEPOSIT_HANDLING.FORFEIT) return CONSIGNMENT_DEPOSIT_HANDLING.FORFEIT
|
||||
return CONSIGNMENT_DEPOSIT_HANDLING.AUTO
|
||||
}
|
||||
|
||||
/**
|
||||
* 打手履约押金:`auto` 时跟随「是否已确认加好友」——确认加好友意味着货已不可逆,
|
||||
* 取消/脱离由打手侧承担损失。
|
||||
*/
|
||||
export function resolveWorkerDepositMode(
|
||||
handling: unknown,
|
||||
autoForfeit: boolean,
|
||||
): WorkerDepositMode {
|
||||
const normalized = normalizeWorkerDepositHandling(handling)
|
||||
if (normalized === WORKER_DEPOSIT_HANDLING.REFUND) return 'release'
|
||||
if (normalized === WORKER_DEPOSIT_HANDLING.FORFEIT) return 'forfeit'
|
||||
return autoForfeit ? 'forfeit' : 'release'
|
||||
}
|
||||
|
||||
/**
|
||||
* 卖家发布押金:`auto` 时——已确认加好友一律没收;否则按现货去向定,
|
||||
* 作废(货不再挂卖)退还,回库(继续挂卖)保持冻结。
|
||||
*/
|
||||
export function resolveConsignmentDepositAction(
|
||||
handling: unknown,
|
||||
input: { autoForfeit: boolean; archive: boolean },
|
||||
): ConsignmentDepositAction {
|
||||
const normalized = normalizeConsignmentDepositHandling(handling)
|
||||
if (normalized === CONSIGNMENT_DEPOSIT_HANDLING.KEEP_FROZEN) return 'keep_frozen'
|
||||
if (normalized === CONSIGNMENT_DEPOSIT_HANDLING.FORFEIT) return 'forfeit'
|
||||
if (input.autoForfeit) return 'forfeit'
|
||||
return input.archive ? 'release' : 'keep_frozen'
|
||||
}
|
||||
|
||||
/** 显式覆盖了押金处置(非 auto)时必须留原因。 */
|
||||
export function isDepositHandlingOverridden(input: {
|
||||
workerDeposit?: unknown
|
||||
consignmentDeposit?: unknown
|
||||
}): boolean {
|
||||
return (
|
||||
normalizeWorkerDepositHandling(input.workerDeposit) !== WORKER_DEPOSIT_HANDLING.AUTO ||
|
||||
normalizeConsignmentDepositHandling(input.consignmentDeposit) !==
|
||||
CONSIGNMENT_DEPOSIT_HANDLING.AUTO
|
||||
)
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
revokeWorkOrderConsignmentFulfillment,
|
||||
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
|
||||
import { createJsonHandler, requireAdminPermission } from '../session.js'
|
||||
import {
|
||||
normalizeConsignmentDepositHandling,
|
||||
normalizeWorkerDepositHandling,
|
||||
} from '../../../domain/consignment-deposit-policy.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -251,6 +255,9 @@ router.post(
|
||||
? 'archive_inventory'
|
||||
: 'reopen',
|
||||
reason: String(req.body?.reason || ''),
|
||||
// 押金处置由运营显式选择,非法值一律归一成"按规则自动"
|
||||
workerDeposit: normalizeWorkerDepositHandling(req.body?.workerDeposit),
|
||||
consignmentDeposit: normalizeConsignmentDepositHandling(req.body?.consignmentDeposit),
|
||||
actorName: req.adminSession?.username || 'admin',
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -27,6 +27,17 @@ import {
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||
import {
|
||||
normalizeConsignmentDepositHandling,
|
||||
normalizeWorkerDepositHandling,
|
||||
resolveConsignmentDepositAction,
|
||||
resolveWorkerDepositMode,
|
||||
} from '../../domain/consignment-deposit-policy.js'
|
||||
import type {
|
||||
ConsignmentDepositHandling,
|
||||
WorkerDepositHandling,
|
||||
} from '../../domain/consignment-deposit-policy.js'
|
||||
import { settleWorkOrderDepositWithClient } from '../../repositories/worker-platform/work-order-deposit-repo.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
@@ -610,6 +621,10 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
workOrderId: number | string
|
||||
action: 'reopen' | 'archive_inventory' | 'cancel_order'
|
||||
reason: string
|
||||
/** 打手履约押金处置:auto(按规则)/ refund(全额退还)/ forfeit(全额扣除) */
|
||||
workerDeposit?: WorkerDepositHandling | undefined
|
||||
/** 卖家发布押金处置:auto(按规则)/ keep_frozen(保留冻结)/ forfeit(扣除没收) */
|
||||
consignmentDeposit?: ConsignmentDepositHandling | undefined
|
||||
actorName: string
|
||||
}): Promise<{
|
||||
order: ReturnType<typeof mapWorkOrderAdmin>
|
||||
@@ -658,9 +673,12 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
// 1. 处理关联库存解绑与状态流转。
|
||||
// 必须带「仍被本工单占用」条件:否则会把别的工单正在使用的现货抢过来解绑,
|
||||
// 造成同一件现货被多张工单履约。
|
||||
const invSql =
|
||||
input.action === 'archive_inventory'
|
||||
? `
|
||||
// 已确认加好友 = 买家好友已加上、货随时可能送出去:不论运营选的是回库还是作废,
|
||||
// 都只能作废下架,否则这件现货会回到在库池被下一个工单再次占用(二次出货)。
|
||||
const autoForfeit = isWorkOrderGiftDepositForfeitable(lockedOrder)
|
||||
const archiveOnly = autoForfeit || input.action === 'archive_inventory'
|
||||
const invSql = archiveOnly
|
||||
? `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'archived',
|
||||
@@ -669,12 +687,12 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
bound_work_order_id = NULL,
|
||||
bound_platform_order_id = '',
|
||||
shop_name = '',
|
||||
remark = remark || ' [交付异常作废: ' || $1 || ']',
|
||||
remark = remark || ($5::text || $1 || ']'),
|
||||
updated_at = $2
|
||||
WHERE id = $3 AND status = 'consumed' AND bound_work_order_id = $4
|
||||
RETURNING *
|
||||
`
|
||||
: `
|
||||
: `
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
status = 'in_stock',
|
||||
@@ -695,6 +713,7 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
now,
|
||||
inventoryId,
|
||||
lockedOrder.id,
|
||||
autoForfeit ? ' [已确认加好友作废下架: ' : ' [交付异常作废: ',
|
||||
])
|
||||
inv = invRes.rows[0] || null
|
||||
if (!inv) {
|
||||
@@ -704,34 +723,50 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 卖家发布押金:默认走规则(已确认加好友→没收;作废→退还;回库→保持冻结),
|
||||
// 运营可以显式覆盖成"保留冻结"或"扣除没收"。
|
||||
const consignmentDepositAction = resolveConsignmentDepositAction(input.consignmentDeposit, {
|
||||
autoForfeit,
|
||||
archive: archiveOnly,
|
||||
})
|
||||
const submissionId = consignmentInfo.sourceSubmissionId || inv.source_submission_id
|
||||
if (submissionId) {
|
||||
// 已确认加好友的单子,卖家发布押金不再退还:撤销回库/取消/作废一律走没收,
|
||||
// 不能再走"退还押金"或"重新冻结"(后者会把押金留着等下次退还)。
|
||||
if (isWorkOrderGiftDepositForfeitable(lockedOrder)) {
|
||||
if (submissionId && consignmentDepositAction !== 'keep_frozen') {
|
||||
if (consignmentDepositAction === 'forfeit') {
|
||||
await forfeitConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
workOrderId: Number(lockedOrder.id),
|
||||
note: `已确认加好友,寄卖押金不退(${
|
||||
input.action === 'archive_inventory' ? '履约作废' : '撤销回库'
|
||||
})${inv.inventory_no || submissionId}`,
|
||||
note: `寄卖押金没收(${archiveOnly ? '履约作废' : '撤销回库'})${
|
||||
inv.inventory_no || submissionId
|
||||
}`,
|
||||
now,
|
||||
})
|
||||
} else if (input.action === 'archive_inventory') {
|
||||
} else {
|
||||
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
note: `寄卖履约作废退还押金 ${inv.inventory_no || submissionId}`,
|
||||
now,
|
||||
})
|
||||
} else {
|
||||
await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
|
||||
submissionId,
|
||||
note: `寄卖撤销回库重新冻结押金 ${inv.inventory_no || submissionId}`,
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打手履约押金:撤销寄售履约会把工单打回未指派/已取消,必须同时结算打手押金,
|
||||
// 否则这笔钱会一直冻着(以前这里只清 assigned_worker_id,押金没有出口)。
|
||||
const revokedWorkerId = Number(lockedOrder.assigned_worker_id || 0)
|
||||
const workerDepositMode = resolveWorkerDepositMode(input.workerDeposit, autoForfeit)
|
||||
let workerDepositAmount = 0
|
||||
if (revokedWorkerId > 0) {
|
||||
workerDepositAmount = await settleWorkOrderDepositWithClient(client, {
|
||||
workOrderId: Number(lockedOrder.id),
|
||||
workerId: revokedWorkerId,
|
||||
mode: workerDepositMode,
|
||||
note:
|
||||
workerDepositMode === 'forfeit'
|
||||
? '撤销寄售履约扣除履约押金(押金不退)'
|
||||
: '撤销寄售履约退还履约押金',
|
||||
now,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 处理工单状态流转
|
||||
let nextOrderStatus: WorkOrderStatus = WORK_ORDER_STATUS.UNASSIGNED
|
||||
if (input.action === 'cancel_order') {
|
||||
@@ -800,6 +835,14 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
||||
action: input.action,
|
||||
reason,
|
||||
inventoryId,
|
||||
// 押金去向要留痕:运营覆盖了规则时能对账、能追责
|
||||
depositHandling: {
|
||||
workerDeposit: normalizeWorkerDepositHandling(input.workerDeposit),
|
||||
consignmentDeposit: normalizeConsignmentDepositHandling(input.consignmentDeposit),
|
||||
},
|
||||
workerDeposit: { mode: workerDepositMode, amount: workerDepositAmount },
|
||||
consignmentDeposit: { action: consignmentDepositAction },
|
||||
inventoryArchived: archiveOnly,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
async function readServiceSource() {
|
||||
return readFile(new URL('./consignment-fulfillment-service.ts', import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
test('撤销寄售履约必须结算打手履约押金,不能只清 assigned_worker_id', async () => {
|
||||
const source = await readServiceSource()
|
||||
// 曾经的漏洞:撤销只清 assigned_worker_id、只处理卖家发布押金,
|
||||
// 打手的履约押金没有任何出口,会一直冻在钱包里。
|
||||
assert.ok(source.includes('settleWorkOrderDepositWithClient('))
|
||||
assert.ok(source.includes('resolveWorkerDepositMode('))
|
||||
assert.ok(source.includes('revokedWorkerId'))
|
||||
assert.ok(source.includes('撤销寄售履约退还履约押金'))
|
||||
assert.ok(source.includes('撤销寄售履约扣除履约押金'))
|
||||
})
|
||||
|
||||
test('撤销寄售履约的现货去向由押金规则与运营选择共同决定', async () => {
|
||||
const source = await readServiceSource()
|
||||
assert.ok(source.includes('const autoForfeit = isWorkOrderGiftDepositForfeitable(lockedOrder)'))
|
||||
assert.ok(
|
||||
source.includes("const archiveOnly = autoForfeit || input.action === 'archive_inventory'"),
|
||||
)
|
||||
assert.ok(source.includes('resolveConsignmentDepositAction(input.consignmentDeposit'))
|
||||
// 押金去向要写进工单事件,方便对账
|
||||
assert.ok(
|
||||
source.includes('workerDeposit: { mode: workerDepositMode, amount: workerDepositAmount }'),
|
||||
)
|
||||
assert.ok(source.includes('consignmentDeposit: { action: consignmentDepositAction }'))
|
||||
assert.ok(source.includes('inventoryArchived: archiveOnly'))
|
||||
})
|
||||
|
||||
test('已确认加好友后作废现货:必须关掉匹配开关并留痕', async () => {
|
||||
const source = await readServiceSource()
|
||||
assert.ok(source.includes('allow_matching = FALSE'))
|
||||
assert.ok(source.includes('已确认加好友作废下架'))
|
||||
})
|
||||
@@ -159,6 +159,9 @@ test('工单终止解绑寄卖现货:只认 bound_work_order_id,且不放开
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
statements.push(sql)
|
||||
if (sql.includes('SELECT acceptance_mode')) {
|
||||
return { rows: [{ acceptance_mode: 'standard', friend_added_at: null }] }
|
||||
}
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
@@ -167,12 +170,46 @@ test('工单终止解绑寄卖现货:只认 bound_work_order_id,且不放开
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
reason: '后台撤单取消订单',
|
||||
})
|
||||
const sql = statements[0]!
|
||||
const sql = statements.find((item) => item.includes('UPDATE consignment_inventories'))!
|
||||
assert.ok(sql.includes('WHERE bound_work_order_id = $2'))
|
||||
assert.ok(sql.includes("status = 'consumed'"))
|
||||
assert.ok(sql.includes("status = 'in_stock'"))
|
||||
assert.ok(!sql.includes('allow_matching'))
|
||||
})
|
||||
|
||||
test('已确认加好友后解绑:现货作废下架,绝不回池被下一个工单占用', async () => {
|
||||
const statements: { sql: string; values: unknown[] }[] = []
|
||||
const client = {
|
||||
query: async (sql: string, values?: unknown[]) => {
|
||||
statements.push({ sql, values: values || [] })
|
||||
if (sql.includes('SELECT acceptance_mode')) {
|
||||
return {
|
||||
rows: [{ acceptance_mode: 'friend_gift', friend_added_at: '2026-09-15T00:00:00.000Z' }],
|
||||
}
|
||||
}
|
||||
if (sql.includes('UPDATE consignment_inventories')) {
|
||||
return { rows: [{ id: 9, inventory_no: 'INV009', status: 'archived' }] }
|
||||
}
|
||||
return { rows: [{ id: 100154 }] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
const released = await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, 100154, {
|
||||
now: '2026-09-15T00:00:00.000Z',
|
||||
reason: '后台撤单',
|
||||
})
|
||||
|
||||
const invStatement = statements.find((item) =>
|
||||
item.sql.includes('UPDATE consignment_inventories'),
|
||||
)!
|
||||
// 作废下架而不是回库:status 必须落在 archived,并且顺手关掉匹配开关
|
||||
assert.ok(invStatement.sql.includes("status = 'archived'"))
|
||||
assert.ok(invStatement.sql.includes('allow_matching = FALSE'))
|
||||
assert.ok(!invStatement.sql.includes("status = 'in_stock'"))
|
||||
assert.ok(invStatement.sql.includes('已确认加好友,作废下架'))
|
||||
assert.equal(released?.status, 'archived')
|
||||
})
|
||||
|
||||
test('人工作废与退回复库在现货仍被交付中工单占用时不改状态', async () => {
|
||||
const statements: string[] = []
|
||||
const client = {
|
||||
@@ -259,6 +296,10 @@ test('工单解绑回库时同步摘掉工单上的寄售标记', async () => {
|
||||
const client = {
|
||||
query: async (sql: string, values?: unknown[]) => {
|
||||
statements.push({ sql, values: values || [] })
|
||||
if (sql.includes('SELECT acceptance_mode')) {
|
||||
// 未确认加好友的普通单:现货仍然回池
|
||||
return { rows: [{ acceptance_mode: 'standard', friend_added_at: null }] }
|
||||
}
|
||||
if (sql.includes('UPDATE consignment_inventories')) {
|
||||
return { rows: [{ id: 9, inventory_no: 'INV009', status: 'in_stock' }] }
|
||||
}
|
||||
@@ -271,10 +312,14 @@ test('工单解绑回库时同步摘掉工单上的寄售标记', async () => {
|
||||
reason: '后台撤单',
|
||||
})
|
||||
|
||||
assert.equal(statements.length, 2)
|
||||
assert.ok(statements[1]!.sql.includes('UPDATE work_orders'))
|
||||
assert.ok(statements[1]!.sql.includes("material_json - 'consignment'"))
|
||||
assert.deepEqual(statements[1]!.values.slice(2), [100154, '9'])
|
||||
const detach = statements.find((item) => item.sql.includes('UPDATE work_orders'))!
|
||||
assert.ok(detach.sql.includes("material_json - 'consignment'"))
|
||||
assert.deepEqual(detach.values.slice(2), [100154, '9'])
|
||||
// 摘标记必须发生在现货改状态之后,否则工单会继续显示已失效的寄售履约
|
||||
const invIndex = statements.findIndex((item) =>
|
||||
item.sql.includes('UPDATE consignment_inventories'),
|
||||
)
|
||||
assert.ok(statements.indexOf(detach) > invIndex)
|
||||
})
|
||||
|
||||
test('工单终态链路全部接上寄卖解绑,避免现货永久 consumed', async () => {
|
||||
|
||||
@@ -315,12 +315,18 @@ export default function WorkOrdersPanel() {
|
||||
order: WorkOrder | null
|
||||
action: 'reopen' | 'cancel_order' | 'archive_inventory'
|
||||
reason: string
|
||||
/** 打手履约押金处置:默认按规则自动,覆盖后必须填原因 */
|
||||
workerDeposit: 'auto' | 'refund' | 'forfeit'
|
||||
/** 卖家发布押金处置:默认按规则自动 */
|
||||
consignmentDeposit: 'auto' | 'keep_frozen' | 'forfeit'
|
||||
submitting: boolean
|
||||
}>({
|
||||
open: false,
|
||||
order: null,
|
||||
action: 'reopen',
|
||||
reason: '',
|
||||
workerDeposit: 'auto',
|
||||
consignmentDeposit: 'auto',
|
||||
submitting: false,
|
||||
})
|
||||
|
||||
@@ -820,6 +826,8 @@ export default function WorkOrdersPanel() {
|
||||
order: row,
|
||||
action: 'reopen',
|
||||
reason: '',
|
||||
workerDeposit: 'auto',
|
||||
consignmentDeposit: 'auto',
|
||||
submitting: false,
|
||||
})
|
||||
}
|
||||
@@ -836,13 +844,17 @@ export default function WorkOrdersPanel() {
|
||||
workOrderId: revokeConsignmentModal.order.workOrderId,
|
||||
action: revokeConsignmentModal.action,
|
||||
reason: revokeConsignmentModal.reason,
|
||||
workerDeposit: revokeConsignmentModal.workerDeposit,
|
||||
consignmentDeposit: revokeConsignmentModal.consignmentDeposit,
|
||||
})
|
||||
message.success('寄售履约已撤销!物品已退单回库,工单已更新')
|
||||
message.success('寄售履约已撤销,现货与押金已按所选方式处理')
|
||||
setRevokeConsignmentModal({
|
||||
open: false,
|
||||
order: null,
|
||||
action: 'reopen',
|
||||
reason: '',
|
||||
workerDeposit: 'auto',
|
||||
consignmentDeposit: 'auto',
|
||||
submitting: false,
|
||||
})
|
||||
await refreshAll()
|
||||
@@ -3078,6 +3090,8 @@ export default function WorkOrdersPanel() {
|
||||
order: null,
|
||||
action: 'reopen',
|
||||
reason: '',
|
||||
workerDeposit: 'auto',
|
||||
consignmentDeposit: 'auto',
|
||||
submitting: false,
|
||||
})
|
||||
}}
|
||||
@@ -3090,8 +3104,16 @@ export default function WorkOrdersPanel() {
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="撤销后,关联寄售物品将退单回库;物品的【允许匹配】开关保持原设置不变"
|
||||
description="只适用于【进行中 / 待验收 / 问题单】。已验收订单无法撤销(现货已交付、押金已结清),买家退款或纠纷请走售后流程。"
|
||||
message={
|
||||
revokeConsignmentModal.order?.gift?.friendAddedAt
|
||||
? '该单已确认加好友:撤销时现货一律作废下架,不会退回在库池'
|
||||
: '撤销后,关联寄售物品将退单回库;物品的【允许匹配】开关保持原设置不变'
|
||||
}
|
||||
description={
|
||||
revokeConsignmentModal.order?.gift?.friendAddedAt
|
||||
? '买家好友已经加上,货随时可能送出去,回池会被下一个工单再次占用(等于二次出货)。押金默认按「已确认加好友不退」处理,可在下方按需覆盖。'
|
||||
: '只适用于【进行中 / 待验收 / 问题单】。已验收订单无法撤销(现货已交付、押金已结清),买家退款或纠纷请走售后流程。'
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
@@ -3132,6 +3154,59 @@ export default function WorkOrdersPanel() {
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="打手履约押金">
|
||||
<Radio.Group
|
||||
value={revokeConsignmentModal.workerDeposit}
|
||||
onChange={(e) =>
|
||||
setRevokeConsignmentModal((prev) => ({ ...prev, workerDeposit: e.target.value }))
|
||||
}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
<Radio value="auto">
|
||||
按规则自动
|
||||
<span style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
(已确认加好友则扣除,否则全额退还)
|
||||
</span>
|
||||
</Radio>
|
||||
<Radio value="refund">全额退还打手</Radio>
|
||||
<Radio value="forfeit">全额扣除(押金不退)</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
{!revokeConsignmentModal.order?.worker ? (
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
|
||||
内部现货单没有接单打手,无需处理履约押金。
|
||||
</div>
|
||||
) : null}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="卖家发布押金">
|
||||
<Radio.Group
|
||||
value={revokeConsignmentModal.consignmentDeposit}
|
||||
onChange={(e) =>
|
||||
setRevokeConsignmentModal((prev) => ({
|
||||
...prev,
|
||||
consignmentDeposit: e.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
<Radio value="auto">
|
||||
按规则自动
|
||||
<span style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
(已确认加好友则没收;否则作废退还、回库保持冻结)
|
||||
</span>
|
||||
</Radio>
|
||||
<Radio value="keep_frozen">
|
||||
保留冻结
|
||||
<span style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
(不退还也不没收,钱一直冻着,留给后续人工处理)
|
||||
</span>
|
||||
</Radio>
|
||||
<Radio value="forfeit">扣除没收(卖家押金不退)</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="撤单原因说明" required>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
|
||||
@@ -146,6 +146,10 @@ export function revokeWorkOrderConsignmentFulfillment(payload: {
|
||||
workOrderId: number | string
|
||||
action?: 'reopen' | 'cancel_order' | 'archive_inventory'
|
||||
reason?: string
|
||||
/** 打手履约押金处置:auto 按规则自动 / refund 全额退还 / forfeit 全额扣除 */
|
||||
workerDeposit?: 'auto' | 'refund' | 'forfeit'
|
||||
/** 卖家发布押金处置:auto 按规则自动 / keep_frozen 保留冻结 / forfeit 扣除没收 */
|
||||
consignmentDeposit?: 'auto' | 'keep_frozen' | 'forfeit'
|
||||
}) {
|
||||
return apiPost<{ workOrder: unknown; inventory?: ConsignmentInventory | null }>(
|
||||
'/api/v1/admin/worker-platform/consignment/revoke',
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
|
||||
| `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
|
||||
| `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` |
|
||||
| 本批提交(批次 G,同一提交内含本文档,故不写自身 hash) | 批次 G:确认加好友后现货作废不回池 + 撤销寄售履约押金可选(含修打手押金永久冻结、真实库 42804 500) | `consignment-repo.ts`、`consignment-deposit-policy.ts`、`work-order-deposit-repo.ts`、撤销履约服务与路由、`WorkOrdersPanel.tsx` |
|
||||
| 本批提交(批次 F2,同一提交内含本文档,故不写自身 hash) | 批次 F2:确认加好友后卖家发布押金不退(`deposit_deduction` 落地)+ 二次冻结护栏 | `consignment-deposit-repo.ts`、取消/退单仓库、撤销履约服务 |
|
||||
| 本批提交(批次 E,同一提交内含本文档,故不写自身 hash) | 前端四权限门控收口、`allocation_status` 约束与权限文案修正、面板错误态/防双提交/真实待审计数 | `admin-auth.ts`、两个寄卖面板、`AdminWorkerPlatformPage.tsx`、迁移 `080` |
|
||||
|
||||
@@ -276,6 +277,35 @@
|
||||
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
|
||||
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
|
||||
|
||||
### 批次 G · 现货作废口径 + 押金处置可选(本轮)
|
||||
|
||||
1. ✅ **确认加好友后现货一律作废下架,不再回池**(严口径):
|
||||
`确认加好友` 意味着买家好友已加上、货随时可能送出去,回池会被下一个工单再次占用
|
||||
(等于同一件货二次出货)。这条口径收口在寄卖自己的
|
||||
`releaseConsignmentInventoryBindingForWorkOrderWithClient` 里:解绑前先读工单的
|
||||
`acceptance_mode / friend_added_at`,命中则 `status='archived'` + `allow_matching=FALSE`
|
||||
+ 备注留痕,否则仍按原逻辑 `in_stock` 回池。所有解绑路径(取消指派/撤单/退回大厅/退单/
|
||||
售后/撤销寄售履约)自动继承,不需要各自判断,发单系统的接口一行没动。
|
||||
2. ✅ **撤销寄售履约弹窗支持两笔押金各自可选**(只加在寄卖专属入口
|
||||
`POST /consignment/revoke` 上):
|
||||
- 打手履约押金:`auto`(已确认加好友则扣除,否则全额退还)/ `refund` / `forfeit`
|
||||
- 卖家发布押金:`auto`(已确认加好友则没收;否则作废退还、回库保持冻结)/ `keep_frozen` / `forfeit`
|
||||
默认全部 `auto` = 原行为;覆盖后原因照旧必填,并把选项与金额写进工单事件
|
||||
(`workerDeposit` / `consignmentDeposit` / `inventoryArchived`)便于对账追责。
|
||||
策略判定抽成纯函数 `domain/consignment-deposit-policy.ts`,可单测。
|
||||
3. ✅ **修掉一个资金漏洞:撤销寄售履约从不结算打手履约押金**。
|
||||
它以前只清 `assigned_worker_id`、只处理卖家发布押金,打手的履约押金没有任何出口,
|
||||
`worker_deposit`(指派打手)单撤销后押金会**一直冻在钱包里**。现在撤销时按上面的
|
||||
选项调用 `settleWorkOrderDepositWithClient` 结算(退还或没收,金额取冻结台账与钱包冻结额的较小值)。
|
||||
4. ✅ **修掉一个真实库必炸的 500(42804)**:`detachConsignmentMaterialFromWorkOrderWithClient`
|
||||
里 `$1` 既作 `$1::text`(写进 jsonb 的 `detachedAt`)又赋值给 `updated_at`,Postgres 把它
|
||||
整体推断成 text,赋值给 timestamptz 列直接报错。凡解绑现货时摘寄售标记的路径
|
||||
(取消指派/撤单/退单/售后/撤销履约/人工回库)在真实库上全都会炸,而假 client 的单测不跑
|
||||
真 SQL,一直没暴露。已显式 `$1::timestamptz`,并补了一道类级静态护栏
|
||||
(`sql-param-type-safety.test.ts`)扫描同类"占位符既当 text 又当时间戳"的写法。
|
||||
5. ⏳ 待你拍板:`撤销寄售履约` 的状态白名单仍含 `pending_acceptance`(打手已提交赠送凭证)。
|
||||
未确认加好友的单子在这一步撤销仍会回池——如果这类单也可能已经交付,需要一并收紧。
|
||||
|
||||
### 批次 F · 待加好友流程与押金不退(本轮)
|
||||
|
||||
1. ✅ **内部现货履约不再自动进冷却**:新增 `parkWorkOrderWaitingFriendAddWithClient`,占库落地时把工单停在
|
||||
|
||||
Reference in New Issue
Block a user