diff --git a/apps/backend/src/domain/consignment-deposit-policy.test.ts b/apps/backend/src/domain/consignment-deposit-policy.test.ts new file mode 100644 index 00000000..2db1094a --- /dev/null +++ b/apps/backend/src/domain/consignment-deposit-policy.test.ts @@ -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) +}) diff --git a/apps/backend/src/domain/consignment-deposit-policy.ts b/apps/backend/src/domain/consignment-deposit-policy.ts new file mode 100644 index 00000000..9c49e19e --- /dev/null +++ b/apps/backend/src/domain/consignment-deposit-policy.ts @@ -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 + ) +} diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 63a0d7d2..5ea138e5 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -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 { + // 已确认加好友的单子,买家好友已加上、货随时可能已经送出去,绝不能回池被下一个工单 + // 再占用(等于二次出货)。这里统一改成作废下架,所有解绑路径(取消指派/撤单/退单/ + // 售后/撤销寄售履约)自动继承这条口径,不需要各自判断。 + 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( + 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 diff --git a/apps/backend/src/repositories/worker-platform/sql-param-type-safety.test.ts b/apps/backend/src/repositories/worker-platform/sql-param-type-safety.test.ts new file mode 100644 index 00000000..5d1ed41f --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/sql-param-type-safety.test.ts @@ -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')) +}) diff --git a/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts index 6ccfa613..9b20f5c4 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-deposit-repo.ts @@ -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 { + 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: { diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index 714be83f..88e67042 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -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', }), { diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index ef8f909d..aa10567b 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -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 @@ -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, }) diff --git a/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts b/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts new file mode 100644 index 00000000..00c016f3 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-revoke-policy.test.ts @@ -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('已确认加好友作废下架')) +}) diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 22586fd9..fd4ed767 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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 () => { diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 9d5310e4..17334a4d 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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() { @@ -3132,6 +3154,59 @@ export default function WorkOrdersPanel() { + + + setRevokeConsignmentModal((prev) => ({ ...prev, workerDeposit: e.target.value })) + } + > + + + 按规则自动 + + (已确认加好友则扣除,否则全额退还) + + + 全额退还打手 + 全额扣除(押金不退) + + + {!revokeConsignmentModal.order?.worker ? ( +
+ 内部现货单没有接单打手,无需处理履约押金。 +
+ ) : null} +
+ + + + setRevokeConsignmentModal((prev) => ({ + ...prev, + consignmentDeposit: e.target.value, + })) + } + > + + + 按规则自动 + + (已确认加好友则没收;否则作废退还、回库保持冻结) + + + + 保留冻结 + + (不退还也不没收,钱一直冻着,留给后续人工处理) + + + 扣除没收(卖家押金不退) + + + + ( '/api/v1/admin/worker-platform/consignment/revoke', diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index 954c5768..57d8ca65 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -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`,占库落地时把工单停在