From 360c5ea20e0659c1a8f52ce9cc4ef01467445813 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 23 Aug 2026 11:23:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A5=BD=E5=8F=8B=E8=B5=A0=E9=80=81=E5=8D=95?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=8A=A0=E5=A5=BD=E5=8F=8B=E5=90=8E=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E6=89=A3=E6=8A=BC=E9=87=91=E8=A6=86=E7=9B=96=E6=92=A4?= =?UTF-8?q?=E5=8D=95=E6=92=A4=E5=9B=9E=E5=8F=96=E6=B6=88=E6=8C=87=E6=B4=BE?= =?UTF-8?q?=E6=92=A4=E5=8D=95=E7=94=B3=E8=AF=B7=E4=B8=8E=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=8D=95=E5=A4=84=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/work-order-acceptance-mode.test.ts | 60 ++++++++++++++++ .../src/domain/work-order-acceptance-mode.ts | 6 ++ .../worker-platform/work-order-cancel-repo.ts | 68 +++++++++++++++---- .../worker-platform/work-order-issue-repo.ts | 29 ++++++-- .../worker-cancel-request-repo.ts | 25 +++++-- .../src/components/WorkOrderEventTimeline.tsx | 7 +- 6 files changed, 170 insertions(+), 25 deletions(-) create mode 100644 apps/backend/src/domain/work-order-acceptance-mode.test.ts diff --git a/apps/backend/src/domain/work-order-acceptance-mode.test.ts b/apps/backend/src/domain/work-order-acceptance-mode.test.ts new file mode 100644 index 00000000..10898609 --- /dev/null +++ b/apps/backend/src/domain/work-order-acceptance-mode.test.ts @@ -0,0 +1,60 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + isWorkOrderGiftDepositForfeitable, + normalizeWorkOrderAcceptanceMode, + resolveWorkOrderGiftPhase, + WORK_ORDER_ACCEPTANCE_MODE, + WORK_ORDER_GIFT_PHASE, +} from './work-order-acceptance-mode.js' + +test('好友赠送押金不可退判定收敛确认加好友标记', () => { + assert.equal( + isWorkOrderGiftDepositForfeitable({ + acceptance_mode: 'friend_gift', + friend_added_at: '2026-08-01T00:00:00.000Z', + }), + true, + ) + // 未确认加好友(待交资料/已交资料阶段):押金仍可退 + assert.equal( + isWorkOrderGiftDepositForfeitable({ + acceptance_mode: 'friend_gift', + friend_added_at: null, + }), + false, + ) + // 标准模式不受影响 + assert.equal( + isWorkOrderGiftDepositForfeitable({ + acceptance_mode: 'standard', + friend_added_at: '2026-08-01T00:00:00.000Z', + }), + false, + ) + assert.equal(isWorkOrderGiftDepositForfeitable({}), false) +}) + +test('赠送阶段按冷却到点实时推导 gift_ready', () => { + const past = new Date(Date.now() - 60_000).toISOString() + const future = new Date(Date.now() + 60_000).toISOString() + assert.equal( + resolveWorkOrderGiftPhase({ + acceptance_mode: WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT, + gift_phase: WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN, + gift_available_at: past, + }), + WORK_ORDER_GIFT_PHASE.GIFT_READY, + ) + assert.equal( + resolveWorkOrderGiftPhase({ + acceptance_mode: WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT, + gift_phase: WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN, + gift_available_at: future, + }), + WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN, + ) + assert.equal(normalizeWorkOrderAcceptanceMode('friend_gift'), 'friend_gift') + assert.equal(normalizeWorkOrderAcceptanceMode(''), 'standard') +}) diff --git a/apps/backend/src/domain/work-order-acceptance-mode.ts b/apps/backend/src/domain/work-order-acceptance-mode.ts index 7175b6b1..bdfadd0c 100644 --- a/apps/backend/src/domain/work-order-acceptance-mode.ts +++ b/apps/backend/src/domain/work-order-acceptance-mode.ts @@ -44,6 +44,7 @@ export type WorkOrderGiftSource = { acceptance_mode?: string | null gift_phase?: string | null gift_available_at?: string | null + friend_added_at?: string | null } export function isWorkOrderGiftAvailable(source: WorkOrderGiftSource): boolean { @@ -52,6 +53,11 @@ export function isWorkOrderGiftAvailable(source: WorkOrderGiftSource): boolean { return Number.isFinite(availableAt) && availableAt <= Date.now() } +/** 好友赠送单已由后台确认加好友后,取消/脱离打手不再退还押金,押金只能经验收结算。 */ +export function isWorkOrderGiftDepositForfeitable(source: WorkOrderGiftSource): boolean { + return isFriendGiftAcceptanceMode(source.acceptance_mode) && Boolean(source.friend_added_at) +} + /** 返回当前赠送阶段;冷却到点后返回实时计算的 gift_ready。 */ export function resolveWorkOrderGiftPhase(source: WorkOrderGiftSource): string { if (!isFriendGiftAcceptanceMode(source.acceptance_mode)) return '' diff --git a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts index 09212461..c4ae93ce 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts @@ -1,4 +1,5 @@ import { withTransaction } from '../../db/client.js' +import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' @@ -124,6 +125,7 @@ export async function unassignWorkOrder(input: { return { order: null, failureReason: 'work_order_no_worker' } } + const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder) await ensureWorkerWalletWithClient(client, workerId, input.now) const wallet = await getWorkerWalletWithClient(client, workerId) const releaseAmount = Math.min( @@ -147,7 +149,8 @@ export async function unassignWorkOrder(input: { ) if (releaseAmount > 0) { - const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount + const nextAvailable = + Number(wallet?.available_amount || 0) + (forfeitGiftDeposit ? 0 : releaseAmount) const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) await client.query( ` @@ -162,9 +165,19 @@ export async function unassignWorkOrder(input: { 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_release', $2, $3, $4, $5, '后台取消指派退还押金', '{}'::jsonb, $6) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) `, - [workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + [ + workerId, + forfeitGiftDeposit ? 'deposit_deduction' : 'deposit_release', + forfeitGiftDeposit ? -releaseAmount : releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + forfeitGiftDeposit ? '后台取消指派扣除押金(已确认加好友)' : '后台取消指派退还押金', + JSON.stringify(forfeitGiftDeposit ? { giftDepositForfeited: true } : {}), + input.now, + ], ) } @@ -175,7 +188,11 @@ export async function unassignWorkOrder(input: { eventType: 'unassigned_by_admin', fromStatus: 'in_progress', toStatus: 'unassigned', - payloadJson: JSON.stringify({ workerId, releaseAmount }), + payloadJson: JSON.stringify({ + workerId, + releaseAmount: forfeitGiftDeposit ? 0 : releaseAmount, + forfeitedAmount: forfeitGiftDeposit ? releaseAmount : 0, + }), now: input.now, }) @@ -214,6 +231,7 @@ export async function returnAssignedWorkOrderToHall(input: { return { order: null, failureReason: 'work_order_no_worker' } } + const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder) await ensureWorkerWalletWithClient(client, workerId, input.now) const wallet = await getWorkerWalletWithClient(client, workerId) const releaseAmount = Math.min( @@ -239,7 +257,8 @@ export async function returnAssignedWorkOrderToHall(input: { ) if (releaseAmount > 0) { - const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount + const nextAvailable = + Number(wallet?.available_amount || 0) + (forfeitGiftDeposit ? 0 : releaseAmount) const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) await client.query( ` @@ -254,9 +273,19 @@ export async function returnAssignedWorkOrderToHall(input: { 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_release', $2, $3, $4, $5, '后台退回大厅退还押金', '{}'::jsonb, $6) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) `, - [workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + [ + workerId, + forfeitGiftDeposit ? 'deposit_deduction' : 'deposit_release', + forfeitGiftDeposit ? -releaseAmount : releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + forfeitGiftDeposit ? '后台退回大厅扣除押金(已确认加好友)' : '后台退回大厅退还押金', + JSON.stringify(forfeitGiftDeposit ? { giftDepositForfeited: true } : {}), + input.now, + ], ) } @@ -269,7 +298,8 @@ export async function returnAssignedWorkOrderToHall(input: { toStatus: 'open', payloadJson: JSON.stringify({ workerId, - releaseAmount, + releaseAmount: forfeitGiftDeposit ? 0 : releaseAmount, + forfeitedAmount: forfeitGiftDeposit ? releaseAmount : 0, reason: String(input.reason || '').trim(), }), now: input.now, @@ -310,6 +340,7 @@ export async function cancelAssignedWorkOrder(input: { return { order: null, failureReason: 'work_order_no_worker' } } + const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder) await ensureWorkerWalletWithClient(client, workerId, input.now) const wallet = await getWorkerWalletWithClient(client, workerId) const releaseAmount = Math.min( @@ -326,6 +357,7 @@ export async function cancelAssignedWorkOrder(input: { assigned_at = NULL, deadline_at = NULL, published_at = NULL, + ${RESET_GIFT_FLOW_SQL}, updated_at = $1 WHERE id = $2 `, @@ -333,7 +365,8 @@ export async function cancelAssignedWorkOrder(input: { ) if (releaseAmount > 0) { - const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount + const nextAvailable = + Number(wallet?.available_amount || 0) + (forfeitGiftDeposit ? 0 : releaseAmount) const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) await client.query( ` @@ -348,9 +381,19 @@ export async function cancelAssignedWorkOrder(input: { 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_release', $2, $3, $4, $5, '后台撤单退还押金', '{}'::jsonb, $6) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) `, - [workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + [ + workerId, + forfeitGiftDeposit ? 'deposit_deduction' : 'deposit_release', + forfeitGiftDeposit ? -releaseAmount : releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + forfeitGiftDeposit ? '后台撤单扣除押金(已确认加好友)' : '后台撤单退还押金', + JSON.stringify(forfeitGiftDeposit ? { giftDepositForfeited: true } : {}), + input.now, + ], ) } @@ -363,7 +406,8 @@ export async function cancelAssignedWorkOrder(input: { toStatus: 'cancelled', payloadJson: JSON.stringify({ workerId, - releaseAmount, + releaseAmount: forfeitGiftDeposit ? 0 : releaseAmount, + forfeitedAmount: forfeitGiftDeposit ? releaseAmount : 0, reason: String(input.reason || '').trim(), }), now: input.now, diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 8712ca61..f5e9e483 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -1,4 +1,5 @@ import { withTransaction } from '../../db/client.js' +import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' @@ -57,8 +58,15 @@ export async function resolveProblemWorkOrder(input: { restoreStatus = resolveProblemReturnToWorkerStatus(fromStatus) } - const shouldReleaseDeposit = ['reopen', 'cancel_release'].includes(input.action) - const shouldDeductDeposit = input.action === 'cancel_deduct' + // 赠送单已确认加好友后不再退押金:释放类处置转为扣除,事件类型按实际资金动作记录。 + const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder) + const releaseActions = ['reopen', 'cancel_release'] + const shouldReleaseDeposit = releaseActions.includes(input.action) && !forfeitGiftDeposit + const shouldDeductDeposit = + input.action === 'cancel_deduct' || + (releaseActions.includes(input.action) && forfeitGiftDeposit) + const effectiveAction = + input.action === 'cancel_release' && forfeitGiftDeposit ? 'cancel_deduct' : input.action const workerId = Number(workOrder.assigned_worker_id || 0) let resolvedDepositAmount = 0 @@ -173,11 +181,12 @@ export async function resolveProblemWorkOrder(input: { workOrderId: input.workOrderId, actorType: 'admin', actorId: input.actorName || '', - eventType: `problem_${input.action}`, + eventType: `problem_${effectiveAction}`, fromStatus: 'problem', toStatus: targetStatus, payloadJson: JSON.stringify({ action: input.action, + giftDepositForfeited: forfeitGiftDeposit || undefined, note: input.note, depositAmount: resolvedDepositAmount, }), @@ -226,8 +235,15 @@ export async function settleOverdueWorkOrder(input: { return { order: null, failureReason: 'work_order_has_active_shares' } } - const shouldReleaseDeposit = ['reopen', 'cancel_release'].includes(input.policy) - const shouldDeductDeposit = input.policy === 'cancel_deduct' + // 赠送单已确认加好友后不再退押金:释放类处置转为扣除,事件类型按实际资金动作记录。 + const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder) + const releasePolicies = ['reopen', 'cancel_release'] + const shouldReleaseDeposit = releasePolicies.includes(input.policy) && !forfeitGiftDeposit + const shouldDeductDeposit = + input.policy === 'cancel_deduct' || + (releasePolicies.includes(input.policy) && forfeitGiftDeposit) + const effectivePolicy = + input.policy === 'cancel_release' && forfeitGiftDeposit ? 'cancel_deduct' : input.policy const workerId = Number(workOrder.assigned_worker_id || 0) let resolvedDepositAmount = 0 @@ -306,11 +322,12 @@ export async function settleOverdueWorkOrder(input: { workOrderId: input.workOrderId, actorType: 'system', actorId: '', - eventType: `timeout_${input.policy}`, + eventType: `timeout_${effectivePolicy}`, fromStatus: 'in_progress', toStatus: targetStatus, payloadJson: JSON.stringify({ policy: input.policy, + giftDepositForfeited: forfeitGiftDeposit || undefined, depositAmount: resolvedDepositAmount, workerId: workerId > 0 ? workerId : null, }), diff --git a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts index 6c285c0f..e5398052 100644 --- a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts @@ -1,6 +1,7 @@ import type { PoolClient } from 'pg' import { query, withTransaction } from '../../db/client.js' +import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, @@ -458,6 +459,8 @@ export async function reviewWorkerCancelRequest(input: { } let releasedDepositAmount = 0 + // 赠送单与拼单互斥,拼单份额分支不会命中赠送单;整单分支按确认加好友判定扣押金。 + const forfeitGiftDeposit = isWorkOrderGiftDepositForfeitable(workOrder) if (request.work_order_share_id) { const shareResult = await client.query( `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, @@ -555,7 +558,8 @@ export async function reviewWorkerCancelRequest(input: { [input.now, workOrder.id], ) if (releasedDepositAmount > 0) { - const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount + const nextAvailable = + Number(wallet?.available_amount || 0) + (forfeitGiftDeposit ? 0 : releasedDepositAmount) const nextFrozen = Math.max( 0, Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount, @@ -570,14 +574,22 @@ export async function reviewWorkerCancelRequest(input: { `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_release', $2, $3, $4, $5, '撤单申请审核通过退还押金', $6::jsonb, $7)`, + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, [ request.worker_id, - releasedDepositAmount, + forfeitGiftDeposit ? 'deposit_deduction' : 'deposit_release', + forfeitGiftDeposit ? -releasedDepositAmount : releasedDepositAmount, nextAvailable, nextFrozen, request.work_order_id, - JSON.stringify({ requestId: request.id, reason: request.reason }), + forfeitGiftDeposit + ? '撤单申请审核通过扣除押金(已确认加好友)' + : '撤单申请审核通过退还押金', + JSON.stringify({ + requestId: request.id, + reason: request.reason, + giftDepositForfeited: forfeitGiftDeposit || undefined, + }), input.now, ], ) @@ -607,7 +619,8 @@ export async function reviewWorkerCancelRequest(input: { payloadJson: JSON.stringify({ requestId: request.id, shareId: request.work_order_share_id, - releasedDepositAmount, + releasedDepositAmount: forfeitGiftDeposit ? 0 : releasedDepositAmount, + forfeitedDepositAmount: forfeitGiftDeposit ? releasedDepositAmount : 0, levelChanged: penalty.levelChanged, accountDisabled: penalty.accountDisabled, note: input.reviewNote, @@ -617,7 +630,7 @@ export async function reviewWorkerCancelRequest(input: { return { request: await getWorkerCancelRequestByIdWithClient(client, request.id), order: updatedOrder, - releasedDepositAmount, + releasedDepositAmount: forfeitGiftDeposit ? 0 : releasedDepositAmount, levelChanged: penalty.levelChanged, accountDisabled: penalty.accountDisabled, failureReason: null, diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index c2ffd8eb..14be43df 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -107,10 +107,15 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { return { title: '取消指派,订单回到未分配状态', color: 'green' } case 'returned_to_hall_by_admin': { const releaseAmount = readPayloadAmount(event, 'releaseAmount') + const forfeitedAmount = readPayloadAmount(event, 'forfeitedAmount') return { title: note ? `撤回订单到抢单大厅(${note})` : '撤回订单到抢单大厅', description: - releaseAmount > 0 ? `已退回该打手冻结押金 ${formatFenAmount(releaseAmount)}` : undefined, + releaseAmount > 0 + ? `已退回该打手冻结押金 ${formatFenAmount(releaseAmount)}` + : forfeitedAmount > 0 + ? `已扣除该打手冻结押金 ${formatFenAmount(forfeitedAmount)}(好友赠送单已确认加好友,押金不退)` + : undefined, color: 'green', } }