From 6a5a8d70b0ec6500a76b0a704c0fca6927dd2453 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 26 Oct 2026 12:15:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=E6=8B=BC=E5=8D=95=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=80=E4=B8=AA=E5=8F=96=E6=B6=88=E9=83=A8?= =?UTF-8?q?=E5=88=86=E7=9A=84=E6=8C=89=E9=92=AE=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E4=B9=8B=E5=90=8E=EF=BC=8C=E6=89=93=E6=89=8B?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E4=B8=AD=E5=92=8C=E5=BE=85=E6=8E=A5=E5=8D=95?= =?UTF-8?q?=E7=9A=84=E9=83=BD=E4=BC=9A=E5=8F=96=E6=B6=88=E8=AE=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../work-order-sharing-part-cancel.test.ts | 103 ++++++ .../domain/work-order-sharing-part-cancel.ts | 56 +++ .../src/repositories/worker-platform/types.ts | 6 + .../worker-platform/work-order-query-repo.ts | 4 +- .../worker-platform/work-order-repo.ts | 1 + .../work-order-settlement-repo.ts | 336 ++++++++++++++++-- .../admin/worker-platform/acceptance.ts | 26 ++ .../admin-work-order-acceptance-service.ts | 91 +++++ .../src/services/worker-platform/index.ts | 1 + .../work-order-display-mappers.ts | 9 + .../worker-platform-service.test.ts | 46 +++ .../src/components/WorkOrderEventTimeline.tsx | 18 + .../pages/admin/panels/WorkOrdersPanel.tsx | 137 +++++++ .../src/services/admin/worker-platform.ts | 18 + apps/frontend/src/types/worker-platform.ts | 8 + 15 files changed, 822 insertions(+), 38 deletions(-) create mode 100644 apps/backend/src/domain/work-order-sharing-part-cancel.test.ts create mode 100644 apps/backend/src/domain/work-order-sharing-part-cancel.ts diff --git a/apps/backend/src/domain/work-order-sharing-part-cancel.test.ts b/apps/backend/src/domain/work-order-sharing-part-cancel.test.ts new file mode 100644 index 00000000..94fe3799 --- /dev/null +++ b/apps/backend/src/domain/work-order-sharing-part-cancel.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + resolveSharingPartCancelPlan, + resolveSharingPartCancelRefund, +} from './work-order-sharing-part-cancel.js' + +test('取消部分:保留已验收份数,取消进行中 + 待接单份数', () => { + const plan = resolveSharingPartCancelPlan({ + workOrderStatus: 'open', + totalQuantity: 10, + acceptedQuantity: 8, + }) + assert.deepEqual(plan, { nextTotalQuantity: 8, cancelledQuantity: 2, failureReason: null }) +}) + +test('取消部分:一份都没验收时整单取消(总量归零)', () => { + const plan = resolveSharingPartCancelPlan({ + workOrderStatus: 'pending_acceptance', + totalQuantity: 10, + acceptedQuantity: 0, + }) + assert.deepEqual(plan, { nextTotalQuantity: 0, cancelledQuantity: 10, failureReason: null }) +}) + +test('取消部分:全部份额已验收时没有可取消份数', () => { + const plan = resolveSharingPartCancelPlan({ + workOrderStatus: 'accepted', + totalQuantity: 8, + acceptedQuantity: 8, + }) + // accepted 不在允许状态里,优先报状态非法 + assert.equal(plan.failureReason, 'status_invalid') + + const openPlan = resolveSharingPartCancelPlan({ + workOrderStatus: 'open', + totalQuantity: 8, + acceptedQuantity: 8, + }) + assert.deepEqual(openPlan, { + nextTotalQuantity: 8, + cancelledQuantity: 0, + failureReason: 'nothing_to_cancel', + }) +}) + +test('取消部分:问题单允许操作,已取消/已验收单不允许', () => { + assert.equal( + resolveSharingPartCancelPlan({ + workOrderStatus: 'problem', + totalQuantity: 3, + acceptedQuantity: 1, + }).failureReason, + null, + ) + for (const status of ['cancelled', 'accepted', 'unassigned']) { + assert.equal( + resolveSharingPartCancelPlan({ + workOrderStatus: status, + totalQuantity: 3, + acceptedQuantity: 0, + }).failureReason, + 'status_invalid', + ) + } +}) + +test('取消部分:已验收份数异常时按总量截断', () => { + const plan = resolveSharingPartCancelPlan({ + workOrderStatus: 'open', + totalQuantity: 5, + acceptedQuantity: 9, + }) + assert.deepEqual(plan, { + nextTotalQuantity: 5, + cancelledQuantity: 0, + failureReason: 'nothing_to_cancel', + }) +}) + +test('取消部分退款:按实付金额比例计算并四舍五入到分', () => { + assert.equal( + resolveSharingPartCancelRefund({ + paidAmountFen: 20000, + totalQuantity: 10, + cancelledQuantity: 9, + }), + 18000, + ) + assert.equal( + resolveSharingPartCancelRefund({ paidAmountFen: 999, totalQuantity: 3, cancelledQuantity: 1 }), + 333, + ) + assert.equal( + resolveSharingPartCancelRefund({ paidAmountFen: 0, totalQuantity: 10, cancelledQuantity: 10 }), + 0, + ) + // 取消份数超过总量时按总量截断 + assert.equal( + resolveSharingPartCancelRefund({ paidAmountFen: 5000, totalQuantity: 5, cancelledQuantity: 8 }), + 5000, + ) +}) diff --git a/apps/backend/src/domain/work-order-sharing-part-cancel.ts b/apps/backend/src/domain/work-order-sharing-part-cancel.ts new file mode 100644 index 00000000..76d75ea7 --- /dev/null +++ b/apps/backend/src/domain/work-order-sharing-part-cancel.ts @@ -0,0 +1,56 @@ +/** + * 拼单「取消部分」口径(已确认): + * - 只保留已验收份额;打手进行中(joined / submitted)与待接单的剩余份数一起取消; + * - 取消的份额退还押金,已验收份额的报酬 / 押金 / 验收资料一律不动; + * - 取消后剩下的份额全部是已验收,所以母单直接收尾为已验收;一份都没验收则整单取消。 + */ + +/** 允许执行取消部分的母单状态:问题单也允许,问题标记随份额一起取消。 */ +export const SHARING_PART_CANCEL_STATUSES = [ + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', +] as const + +export function resolveSharingPartCancelPlan(input: { + workOrderStatus: string + /** 母单当前总份数 */ + totalQuantity: number + /** 已验收份额的份数合计 */ + acceptedQuantity: number +}): { + /** 取消后保留的总份数(= 已验收份数) */ + nextTotalQuantity: number + /** 本次要取消的份数(进行中份额 + 待接单剩余) */ + cancelledQuantity: number + failureReason: 'status_invalid' | 'nothing_to_cancel' | null +} { + const status = String(input.workOrderStatus || '').trim() + if (!SHARING_PART_CANCEL_STATUSES.includes(status as never)) { + return { nextTotalQuantity: 0, cancelledQuantity: 0, failureReason: 'status_invalid' } + } + const total = Math.max(0, Math.floor(Number(input.totalQuantity) || 0)) + const accepted = Math.min(total, Math.max(0, Math.floor(Number(input.acceptedQuantity) || 0))) + const cancelled = total - accepted + if (cancelled <= 0) { + return { nextTotalQuantity: accepted, cancelledQuantity: 0, failureReason: 'nothing_to_cancel' } + } + return { nextTotalQuantity: accepted, cancelledQuantity: cancelled, failureReason: null } +} + +/** + * 建议退款金额:按订单实付金额 × 取消份数 / 原总量,四舍五入到分。 + * 只是默认值,后台可以在弹窗里手工改。 + */ +export function resolveSharingPartCancelRefund(input: { + paidAmountFen: number + totalQuantity: number + cancelledQuantity: number +}): number { + const paid = Math.max(0, Math.round(Number(input.paidAmountFen) || 0)) + const total = Math.max(0, Math.floor(Number(input.totalQuantity) || 0)) + const cancelled = Math.max(0, Math.floor(Number(input.cancelledQuantity) || 0)) + if (paid <= 0 || total <= 0 || cancelled <= 0) return 0 + return Math.round((paid * Math.min(cancelled, total)) / total) +} diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 0cfef91c..b667f865 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -396,6 +396,8 @@ export type WorkOrderRow = { deposit_threshold_amount: number assigned_worker_id: number | null last_assigned_worker_id: number | null + /** 来源订单实付金额(分);取消部分时用于按比例计算退款。 */ + source_order_amount_fen: number material_json: string | Record requirement_json: string | Record acceptance_json: string | Record @@ -429,6 +431,8 @@ export type WorkOrderRow = { /** 拼单聚合字段,由工单查询统一计算。 */ active_share_quantity?: number pending_share_count?: number + /** 已验收份额的份数合计,用于「取消部分」判断还剩多少未验收。 */ + accepted_share_quantity?: number category_name?: string parent_category_id?: number | null parent_category_name?: string @@ -539,6 +543,8 @@ export type WorkOrderCapabilities = { canPublish: boolean canCancel: boolean canCancelSharing: boolean + /** 取消部分:保留已验收份额,取消进行中份额与待接单剩余份数。 */ + canCancelSharingPart: boolean canResolveProblem: boolean canDeduct: boolean canConfirmFriend: boolean diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index c95ff3c3..72dbcd70 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -20,6 +20,7 @@ export const WORK_ORDER_SELECT = ` wo.*, COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity, COALESCE(share_summary.pending_share_count, 0)::int AS pending_share_count, + COALESCE(share_summary.accepted_share_quantity, 0)::int AS accepted_share_quantity, wc.name AS category_name, parent_wc.id AS parent_category_id, parent_wc.name AS parent_category_name, @@ -65,7 +66,8 @@ export const WORK_ORDER_SELECT = ` LEFT JOIN LATERAL ( SELECT SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity, - COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count + COUNT(*) FILTER (WHERE wos.status = 'joined') AS pending_share_count, + SUM(wos.quantity) FILTER (WHERE wos.status = 'accepted') AS accepted_share_quantity FROM work_order_shares wos WHERE wos.work_order_id = wo.id ) share_summary ON TRUE diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index 082c44c5..efa517a5 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -29,5 +29,6 @@ export { acceptWorkOrderShareAndSettle, cancelWorkOrderShare, cancelWorkOrderSharing, + cancelWorkOrderSharingPart, settleOverdueWorkOrderShare, } from './work-order-settlement-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 56cb8390..03cfdc06 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -1,4 +1,10 @@ +import type { PoolClient } from 'pg' + import { withTransaction } from '../../db/client.js' +import { + resolveSharingPartCancelPlan, + resolveSharingPartCancelRefund, +} from '../../domain/work-order-sharing-part-cancel.js' import { offsetWorkerAfterSalesDebtsWithClient } from './after-sales-repo.js' import { ensureWorkerWalletWithClient, @@ -22,6 +28,62 @@ import { settleConsignmentSupplierPayoutByWorkOrderWithClient, } from './consignment-deposit-repo.js' +/** + * 拼单母单收尾:标记已验收,并补寄卖的发布押金释放与供货结算。 + * + * 「最后一个份额验收」与「取消部分」都会把母单推到已验收,两条路径必须同口径, + * 否则发布押金挂死、供货卖家拿不到货款。 + */ +async function finalizeSharingOrderAcceptedWithClient( + client: PoolClient, + input: { + workOrder: WorkOrderRow + workOrderId: number + now: string + unfreezeDays: number + rewardUnfreezeEnabled: boolean + }, +) { + await client.query( + `UPDATE work_orders SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`, + [input.now, input.workOrderId], + ) + + const shareMaterial = + typeof input.workOrder.material_json === 'string' + ? (() => { + try { + return JSON.parse(input.workOrder.material_json) as Record + } catch { + return {} + } + })() + : input.workOrder.material_json && typeof input.workOrder.material_json === 'object' + ? (input.workOrder.material_json as Record) + : {} + const shareConsignment = + shareMaterial.consignment && typeof shareMaterial.consignment === 'object' + ? (shareMaterial.consignment as Record) + : null + const shareSourceSubmissionId = shareConsignment?.sourceSubmissionId + if (shareSourceSubmissionId) { + await releaseConsignmentPublishDepositBySubmissionWithClient(client, { + submissionId: String(shareSourceSubmissionId), + note: `寄卖拼单份额全部验收完成释放发布押金 #${input.workOrderId}`, + now: input.now, + }) + } + // 拼单母单没有单一接单人(份额打手已各自按 share_reward 结算), + // 传 0 让供货货款必走本渠道,不会与份额报酬重复。 + await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, { + workOrderId: input.workOrderId, + assignedWorkerId: 0, + rewardUnfreezeEnabled: input.rewardUnfreezeEnabled, + unfreezeDays: input.unfreezeDays, + now: input.now, + }) +} + export async function acceptWorkOrderAndSettle(input: { workOrderId: number unfreezeDays: number @@ -645,45 +707,13 @@ export async function acceptWorkOrderShareAndSettle(input: { const allAccepted = Number(progress?.unfinished_count || 0) === 0 const nextOrderStatus = filled && allAccepted ? 'accepted' : workOrder.status if (nextOrderStatus === 'accepted') { - await client.query( - `UPDATE work_orders SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`, - [input.now, input.workOrderId], - ) - - // 最后一个份额把母单转成已验收时,必须与统一验收(acceptWorkOrderAndSettle)同口径补寄卖钩子, - // 否则这条路径下发布押金永久挂死、供货卖家拿不到货款。 - const shareMaterial = - typeof workOrder.material_json === 'string' - ? (() => { - try { - return JSON.parse(workOrder.material_json) as Record - } catch { - return {} - } - })() - : workOrder.material_json && typeof workOrder.material_json === 'object' - ? (workOrder.material_json as Record) - : {} - const shareConsignment = - shareMaterial.consignment && typeof shareMaterial.consignment === 'object' - ? (shareMaterial.consignment as Record) - : null - const shareSourceSubmissionId = shareConsignment?.sourceSubmissionId - if (shareSourceSubmissionId) { - await releaseConsignmentPublishDepositBySubmissionWithClient(client, { - submissionId: String(shareSourceSubmissionId), - note: `寄卖拼单份额全部验收完成释放发布押金 #${input.workOrderId}`, - now: input.now, - }) - } - // 拼单母单没有单一接单人(份额打手已在上面各自按 share_reward 结算), - // 传 0 让供货货款必走本渠道,不会与份额报酬重复。 - await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, { + // 最后一个份额把母单转成已验收,与统一验收(acceptWorkOrderAndSettle)同口径补寄卖钩子。 + await finalizeSharingOrderAcceptedWithClient(client, { + workOrder, workOrderId: input.workOrderId, - assignedWorkerId: 0, - rewardUnfreezeEnabled: input.rewardUnfreezeEnabled, - unfreezeDays: input.unfreezeDays, now: input.now, + unfreezeDays: input.unfreezeDays, + rewardUnfreezeEnabled: input.rewardUnfreezeEnabled, }) } await createWorkOrderEventWithClient(client, { @@ -1226,3 +1256,235 @@ export async function cancelWorkOrderSharing(input: { } }) } + +/** + * 拼单「取消部分」:只保留已验收份额,打手进行中(joined / submitted)与待接单的剩余份数一起取消。 + * + * 口径(已确认): + * - 取消的份额退还该份额冻结的押金(`min(份额押金, 钱包冻结额)`);已验收份额的报酬 / 押金 / 验收资料不动; + * - 取消后总量收敛为已验收份数;一份都没验收则整单取消; + * - 剩余份额全部已验收时,母单直接收尾为已验收(含寄卖发布押金释放与供货结算); + * - 退款金额只登记(系统没有退款通道),默认按实付比例,后台可改。 + */ +export async function cancelWorkOrderSharingPart(input: { + workOrderId: number + /** 退款金额(分);null / undefined 时按实付金额比例计算 */ + refundAmountFen?: number | null + reason: string + now: string + actorName?: string + unfreezeDays: number + rewardUnfreezeEnabled: boolean +}): Promise<{ + order: WorkOrderRow | null + cancelledShareIds: number[] + cancelledWorkerIds: number[] + cancelledQuantity: number + releasedDepositAmount: number + refundAmount: number + failureReason: 'work_order_not_cancellable' | 'status_invalid' | 'nothing_to_cancel' | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || workOrder.sharing_enabled !== true) { + return { + order: workOrder, + cancelledShareIds: [], + cancelledWorkerIds: [], + cancelledQuantity: 0, + releasedDepositAmount: 0, + refundAmount: 0, + failureReason: 'work_order_not_cancellable' as const, + } + } + + const sharesResult = await client.query( + `SELECT * FROM work_order_shares + WHERE work_order_id = $1 AND status != 'cancelled' + ORDER BY id ASC + FOR UPDATE`, + [input.workOrderId], + ) + const shares = sharesResult.rows + const totalQuantity = Math.max(1, Math.floor(Number(workOrder.sharing_total_quantity) || 1)) + const acceptedQuantity = shares + .filter((share) => share.status === 'accepted') + .reduce((sum, share) => sum + Math.max(0, Number(share.quantity || 0)), 0) + const plan = resolveSharingPartCancelPlan({ + workOrderStatus: String(workOrder.status || ''), + totalQuantity, + acceptedQuantity, + }) + if (plan.failureReason) { + return { + order: workOrder, + cancelledShareIds: [], + cancelledWorkerIds: [], + cancelledQuantity: 0, + releasedDepositAmount: 0, + refundAmount: 0, + failureReason: plan.failureReason, + } + } + + const cancellableShares = shares.filter((share) => + ['joined', 'submitted'].includes(String(share.status || '')), + ) + let releasedDepositAmount = 0 + for (const share of cancellableShares) { + const workerId = Number(share.worker_id || 0) + if (workerId <= 0) continue + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + const releaseAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releaseAmount <= 0) continue + const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) + 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, 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, 'deposit_release', $2, $3, $4, $5, '取消部分拼单退还押金', $6::jsonb, $7)`, + [ + workerId, + releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ + workOrderId: input.workOrderId, + shareId: Number(share.id), + quantity: Number(share.quantity || 0), + reason: String(input.reason || '').trim(), + }), + input.now, + ], + ) + releasedDepositAmount += releaseAmount + } + + const cancelledShareIds = cancellableShares.map((share) => Number(share.id)) + const cancelledWorkerIds = [ + ...new Set(cancellableShares.map((share) => Number(share.worker_id || 0)).filter(Boolean)), + ] + if (cancelledShareIds.length > 0) { + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', + cancelled_reason = 'admin_partial', + cancel_note = $3, + cancel_quantity = COALESCE(quantity, 0), + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + problem_note = '', + problem_marked_at = NULL, + problem_marked_by = '', + updated_at = $1 + WHERE id = ANY($2::bigint[])`, + [input.now, cancelledShareIds, String(input.reason || '').trim()], + ) + } + + const refundAmount = + input.refundAmountFen === undefined || input.refundAmountFen === null + ? resolveSharingPartCancelRefund({ + paidAmountFen: Number(workOrder.source_order_amount_fen || 0), + totalQuantity, + cancelledQuantity: plan.cancelledQuantity, + }) + : Math.max(0, Math.round(Number(input.refundAmountFen) || 0)) + + if (plan.nextTotalQuantity <= 0) { + await client.query( + `UPDATE work_orders + SET status = 'cancelled', assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL, + ${CLEAR_DELIVERY_RESIDUE_SQL}, + ${RESET_GIFT_FLOW_SQL}, + published_at = NULL, pinned_at = NULL, updated_at = $1 + WHERE id = $2`, + [input.now, input.workOrderId], + ) + } else { + const unitReward = Math.max(0, Number(workOrder.sharing_unit_reward || 0)) + const nextRewardAmount = Math.max( + 0, + Number(workOrder.reward_amount || 0) - plan.cancelledQuantity * unitReward, + ) + const nextRequiredDeposit = + totalQuantity > 0 + ? Math.max( + 0, + Math.round( + (Number(workOrder.required_deposit_amount || 0) * plan.nextTotalQuantity) / + totalQuantity, + ), + ) + : 0 + await client.query( + `UPDATE work_orders + SET sharing_total_quantity = $1, reward_amount = $2, required_deposit_amount = $3, updated_at = $4 + WHERE id = $5`, + [ + plan.nextTotalQuantity, + nextRewardAmount, + nextRequiredDeposit, + input.now, + input.workOrderId, + ], + ) + await finalizeSharingOrderAcceptedWithClient(client, { + workOrder, + workOrderId: input.workOrderId, + now: input.now, + unfreezeDays: input.unfreezeDays, + rewardUnfreezeEnabled: input.rewardUnfreezeEnabled, + }) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'sharing_part_cancelled', + fromStatus: workOrder.status, + toStatus: plan.nextTotalQuantity <= 0 ? 'cancelled' : 'accepted', + payloadJson: JSON.stringify({ + cancelledShareIds, + cancelledShareCount: cancelledShareIds.length, + cancelledQuantity: plan.cancelledQuantity, + acceptedQuantity, + totalQuantityBefore: totalQuantity, + totalQuantityAfter: plan.nextTotalQuantity, + releasedDepositAmount, + refundAmount, + reason: String(input.reason || '').trim(), + }), + now: input.now, + }) + + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + cancelledShareIds, + cancelledWorkerIds, + cancelledQuantity: plan.cancelledQuantity, + releasedDepositAmount, + refundAmount, + failureReason: null, + } + }) +} diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index 7f6c8cc7..fe5c1121 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -5,6 +5,7 @@ import { acceptAdminWorkOrderShare, cancelAdminWorkOrderShare, cancelAdminWorkOrderSharing, + cancelAdminWorkOrderSharingPart, deductAdminWorkOrderPendingDeposit, markAdminWorkOrderProblem, markAdminWorkOrderShareProblem, @@ -139,6 +140,31 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/sharing/cancel-part', + requireAdminPermission('worker_order.cancel_share'), + createJsonHandler( + (req) => + cancelAdminWorkOrderSharingPart( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.displayName || req.adminSession?.username || '', + req.adminSession?.permissions || [], + ), + { + successMessage: '已取消未验收部分', + errorMessage: '取消部分失败', + scope: '[admin/worker-platform/orders/:workOrderId/sharing/cancel-part]', + audit: (req, data) => ({ + action: 'work_order_sharing_part_cancelled', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/shares/:shareId/cancel', requireAdminPermission('worker_order.cancel_share'), diff --git a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts index 57dd7989..a381af92 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts @@ -4,6 +4,7 @@ import { acceptWorkOrderShareAndSettle, cancelWorkOrderShare, cancelWorkOrderSharing, + cancelWorkOrderSharingPart, countWorkOrderPendingSharingSubmissions, reduceWorkOrderShareQuantity, updateWorkOrder, @@ -23,6 +24,7 @@ import { import { mapWorkOrderAdmin, mapWorkOrderShare, + normalizeAmountFen, normalizeOptionalId, resolveWorkOrderOperationalStatus, } from './mappers.js' @@ -329,6 +331,95 @@ export async function cancelAdminWorkOrderSharing( } } +/** + * 拼单「取消部分」:保留已验收份额,取消打手进行中(joined / submitted)与待接单的剩余份数, + * 取消的份额退还押金;一份都没验收时整单取消。 + */ +export async function cancelAdminWorkOrderSharingPart( + workOrderId: number | string, + payload: JsonObject = {}, + actorName = '', + actorPermissions: string[] = [], +) { + if (!actorPermissions.includes('worker_order.cancel_share')) { + throw createHttpError('当前账号没有取消部分拼单的权限', { + statusCode: 403, + errorCode: 'admin_permission_denied', + }) + } + const reason = String(payload.reason || '').trim() + if (!reason) { + throw createHttpError('取消部分时请填写原因', { + statusCode: 400, + errorCode: 'work_order_sharing_part_cancel_reason_required', + }) + } + const rawRefundAmount = payload.refundAmount + let refundAmountFen: number | null = null + if ( + rawRefundAmount !== undefined && + rawRefundAmount !== null && + String(rawRefundAmount).trim() !== '' + ) { + refundAmountFen = normalizeAmountFen(rawRefundAmount, -1) + if (refundAmountFen < 0) { + throw createHttpError('退款金额不能为负数', { + statusCode: 400, + errorCode: 'work_order_sharing_part_cancel_refund_invalid', + }) + } + } + const financeConfig = getWorkerFinanceConfig() + const result = await cancelWorkOrderSharingPart({ + workOrderId: Number(workOrderId), + refundAmountFen, + reason, + now: nowIso(), + actorName, + unfreezeDays: financeConfig.depositUnfreezeDays, + rewardUnfreezeEnabled: financeConfig.rewardUnfreezeEnabled, + }) + if (result.failureReason === 'work_order_not_cancellable') { + throw createHttpError('当前订单不是可取消部分的拼单', { + statusCode: 409, + errorCode: 'work_order_sharing_part_cancel_status_invalid', + }) + } + if (result.failureReason === 'status_invalid') { + throw createHttpError('当前订单状态不能取消部分', { + statusCode: 409, + errorCode: 'work_order_sharing_part_cancel_status_invalid', + }) + } + if (result.failureReason === 'nothing_to_cancel') { + throw createHttpError('没有可取消的未验收份数', { + statusCode: 409, + errorCode: 'work_order_sharing_part_cancel_nothing_to_cancel', + }) + } + if (!result.order) { + throw createHttpError('取消部分失败,订单状态可能已变化', { + statusCode: 409, + errorCode: 'work_order_sharing_part_cancel_conflict', + }) + } + const activeWorkerIds = await resolveWorkOrderRealtimeWorkerIds(result.order) + const workerIds = [...new Set([...activeWorkerIds, ...result.cancelledWorkerIds])] + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds, + hallChanged: true, + }) + for (const workerId of result.cancelledWorkerIds) publishWorkerWalletRealtimeChange(workerId) + return { + order: mapWorkOrderAdmin(result.order), + cancelledShareCount: result.cancelledShareIds.length, + cancelledQuantity: result.cancelledQuantity, + releasedDepositAmount: result.releasedDepositAmount, + refundAmount: result.refundAmount, + } +} + export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') { const workOrderIds = [ ...new Set( diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 39a70d4f..1ba3fc5c 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -54,6 +54,7 @@ export { acceptAdminWorkOrders, cancelAdminWorkOrderShare, cancelAdminWorkOrderSharing, + cancelAdminWorkOrderSharingPart, reduceAdminWorkOrderShareQuantity, } from './admin-work-order-acceptance-service.js' export * from './worker-product-match-config-service.js' diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 7893be62..c449c256 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -248,6 +248,7 @@ export function mapWorkOrderAdmin( const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + const acceptedShareQuantity = Math.max(0, Number(workOrder.accepted_share_quantity || 0)) const hasActiveShares = activeShareQuantity > 0 const sharingFilled = workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity @@ -306,6 +307,10 @@ export function mapWorkOrderAdmin( workOrder.sharing_enabled === true && hasActiveShares && ['open', 'pending_acceptance', 'in_progress'].includes(workOrder.status), + canCancelSharingPart: + workOrder.sharing_enabled === true && + sharingTotalQuantity > acceptedShareQuantity && + ['open', 'pending_acceptance', 'in_progress', 'problem'].includes(workOrder.status), canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM, canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED, canConfirmFriend: @@ -369,11 +374,15 @@ export function mapWorkOrderAdmin( joinedQuantity: activeShareQuantity, pendingSubmissionCount: pendingShareCount, remainingQuantity: Math.max(0, sharingTotalQuantity - activeShareQuantity), + acceptedQuantity: acceptedShareQuantity, + cancellableQuantity: Math.max(0, sharingTotalQuantity - acceptedShareQuantity), filled: sharingFilled, }, capabilities, rewardAmount: Number(workOrder.reward_amount || 0), requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), + /** 来源订单实付金额(分):取消部分时用于预填退款金额。 */ + sourceOrderAmount: Number(workOrder.source_order_amount_fen || 0), sharing: { enabled: workOrder.sharing_enabled === true, totalQuantity: Number(workOrder.sharing_total_quantity || 1), 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 d4d7cb20..00511954 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 @@ -3501,3 +3501,49 @@ test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参 '押金必须按该打手在本工单的累计份数与已冻结合计计算增量', ) }) + +test('取消部分:只取消未验收份额并保留已验收,剩余份数收敛后收尾母单', () => { + const repoSrc = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url), + 'utf8', + ) + const block = repoSrc.slice( + repoSrc.indexOf('export async function cancelWorkOrderSharingPart('), + repoSrc.length, + ) + assert.ok(block.length > 0, 'cancelWorkOrderSharingPart 必须存在') + assert.ok( + block.includes("['joined', 'submitted']"), + '只允许取消进行中(joined / submitted)份额,已验收份额必须保留', + ) + assert.ok( + !block.includes("status = 'accepted'"), + '母单转已验收必须走统一收尾函数,不得直接改状态', + ) + assert.ok( + block.includes('finalizeSharingOrderAcceptedWithClient('), + '有已验收份额时必须走统一收尾(含寄卖结算)', + ) + assert.ok(block.includes('deposit_release'), '取消的份额必须退还押金') + assert.ok(block.includes('admin_partial'), '取消原因必须标记为 admin_partial') + assert.ok(block.includes('problem_marked_at = NULL'), '问题标记要随被取消的份额一起清掉') + assert.ok(block.includes('sharing_total_quantity = $1'), '取消后必须把母单总量收敛为已验收份数') + assert.ok( + block.includes('source_order_amount_fen') && block.includes('resolveSharingPartCancelRefund('), + '退款默认值必须按实付金额比例计算', + ) + + const routeSrc = fs.readFileSync( + new URL('../../routes/admin/worker-platform/acceptance.ts', import.meta.url), + 'utf8', + ) + assert.ok( + routeSrc.includes("'/worker-platform/orders/:workOrderId/sharing/cancel-part'"), + '取消部分接口必须存在', + ) + const mapperSrc = fs.readFileSync( + new URL('./work-order-display-mappers.ts', import.meta.url), + 'utf8', + ) + assert.ok(mapperSrc.includes('canCancelSharingPart:'), '前端按钮依赖 canCancelSharingPart 能力位') +}) diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index c5393fa0..a06ad096 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -440,6 +440,24 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { color: 'red', } } + case 'sharing_part_cancelled': { + const cancelledQuantity = readPayloadAmount(event, 'cancelledQuantity') + const totalQuantityAfter = readPayloadAmount(event, 'totalQuantityAfter') + const releasedDepositAmount = readPayloadAmount(event, 'releasedDepositAmount') + const refundAmount = readPayloadAmount(event, 'refundAmount') + const details = [ + cancelledQuantity > 0 ? `取消 ${cancelledQuantity} 份未验收份数` : '', + totalQuantityAfter > 0 ? `保留 ${totalQuantityAfter} 份已验收` : '整单已取消', + releasedDepositAmount > 0 ? `退还押金 ${formatFenAmount(releasedDepositAmount)}` : '', + refundAmount > 0 ? `登记退款 ${formatFenAmount(refundAmount)}` : '', + note ? `原因:${note}` : '', + ].filter(Boolean) + return { + title: '取消拼单未验收部分', + description: details.length > 0 ? details.join(';') : undefined, + color: 'red', + } + } case 'pending_deposit_deducted': return { title: '扣减了待解冻押金', color: 'default' } case 'voucher_backfilled': diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 6c325d64..556b3aa4 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -56,6 +56,7 @@ import { cancelAdminWorkOrder, cancelAdminWorkOrderShare, cancelAdminWorkOrderSharing, + cancelAdminWorkOrderSharingPart, completeAdminWorkOrderGiftCooldown, confirmAdminWorkOrderFriend, deductAdminWorkOrderPendingDeposit, @@ -185,6 +186,11 @@ type CancelSharingFormValues = { reason?: string } +type CancelSharingPartFormValues = { + refundAmount?: number + reason?: string +} + /** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */ function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null { const consignment = asRecord((row.material as Record | undefined)?.consignment) @@ -311,6 +317,7 @@ export default function WorkOrdersPanel() { const [problemShare, setProblemShare] = useState(null) const [disposeShare, setDisposeShare] = useState(null) const [cancelSharingOrder, setCancelSharingOrder] = useState(null) + const [cancelSharingPartOrder, setCancelSharingPartOrder] = useState(null) const [assignOrder, setAssignOrder] = useState(null) const [assignWorkerId, setAssignWorkerId] = useState(undefined) const [editOrder, setEditOrder] = useState(null) @@ -329,6 +336,7 @@ export default function WorkOrdersPanel() { note?: string }>() const [cancelSharingForm] = Form.useForm() + const [cancelSharingPartForm] = Form.useForm() const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() const [editForm] = Form.useForm<{ productName?: string @@ -551,6 +559,18 @@ export default function WorkOrdersPanel() { ['joined', 'submitted'].includes(share.status), ) const hasAcceptedSharingShares = sharingShares.some((share) => share.status === 'accepted') + const cancelSharingPartTotalQuantity = Math.max( + 1, + Number(cancelSharingPartOrder?.sharing?.totalQuantity || 1), + ) + const cancelSharingPartAcceptedQuantity = Math.max( + 0, + Number(cancelSharingPartOrder?.sharingProgress?.acceptedQuantity || 0), + ) + const cancelSharingPartCancelledQuantity = Math.max( + 0, + cancelSharingPartTotalQuantity - cancelSharingPartAcceptedQuantity, + ) const assignWorkersQuery = useQuery({ queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword], queryFn: () => @@ -821,6 +841,48 @@ export default function WorkOrdersPanel() { cancelSharingForm.resetFields() } + /** 打开「取消部分」:默认按实付金额比例预填退款金额。 */ + function openCancelSharingPartModal(order: WorkOrder) { + const totalQuantity = Math.max(1, Number(order.sharing?.totalQuantity || 1)) + const acceptedQuantity = Math.max(0, Number(order.sharingProgress?.acceptedQuantity || 0)) + const cancelledQuantity = Math.max(0, totalQuantity - acceptedQuantity) + const paidAmountFen = Math.max(0, Number(order.sourceOrderAmount || 0)) + setCancelSharingPartOrder(order) + cancelSharingPartForm.setFieldsValue({ + refundAmount: + totalQuantity > 0 + ? Math.round((paidAmountFen * cancelledQuantity) / totalQuantity) / 100 + : 0, + reason: '', + }) + } + + async function submitCancelSharingPart(values: CancelSharingPartFormValues) { + if (!cancelSharingPartOrder) return + const workOrderId = cancelSharingPartOrder.workOrderId + const refundAmount = values.refundAmount + const succeeded = await runAction( + () => + cancelAdminWorkOrderSharingPart(workOrderId, { + ...(refundAmount === undefined || refundAmount === null + ? {} + : { refundAmount: Number(refundAmount) }), + reason: String(values.reason || '').trim(), + }), + (result) => + `已取消 ${result.data.cancelledQuantity} 份未验收份数,退还押金 ${formatMoney( + result.data.releasedDepositAmount, + )},登记退款 ${formatMoney(result.data.refundAmount)}`, + ) + if (!succeeded) return + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-order-sharing', workOrderId], + }) + setCancelSharingPartOrder(null) + setSharingOrder(null) + cancelSharingPartForm.resetFields() + } + async function refreshAll() { await Promise.all([ queryClient.invalidateQueries({ @@ -3114,6 +3176,7 @@ export default function WorkOrdersPanel() { setCancelShare(null) setReduceShare(null) setCancelSharingOrder(null) + setCancelSharingPartOrder(null) sharingForm.resetFields() cancelShareForm.resetFields() reduceShareForm.resetFields() @@ -3201,6 +3264,26 @@ export default function WorkOrdersPanel() { + {sharingOrder.capabilities?.canCancelSharingPart ? ( + + +
+ +
+
+ ) : null} {sharingQuery.isLoading ? ( 加载中... ) : (sharingQuery.data?.data.shares || []).length === 0 ? ( @@ -3782,6 +3865,60 @@ export default function WorkOrdersPanel() { ) : null} + { + setCancelSharingPartOrder(null) + cancelSharingPartForm.resetFields() + }} + onOk={() => cancelSharingPartForm.submit()} + okText="确认取消" + okButtonProps={{ danger: true }} + destroyOnHidden + > + {cancelSharingPartOrder ? ( +
+ 0 + ? '取消后母单总量收敛为已验收份数,并直接转为已验收;取消的份额会退还押金。' + : '本单没有已验收份额,提交后整张订单会被取消;取消的份额会退还押金。' + } + style={{ marginBottom: 16 }} + /> + + + {cancelSharingPartTotalQuantity} 份 + + + {cancelSharingPartAcceptedQuantity} 份 + + + {cancelSharingPartCancelledQuantity} 份(进行中 + 待接单) + + + + + + + + + + ) : null} +
+ {/* 内部寄售库存匹配拦截弹窗 */} (`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing/cancel`, payload) } +/** 取消部分:保留已验收份额,取消进行中份额与待接单剩余份数,退还取消份额的押金。 */ +export function cancelAdminWorkOrderSharingPart( + workOrderId: number, + payload: { + /** 退款金额(元);不传则后端按实付比例算 */ + refundAmount?: number + reason: string + }, +) { + return apiPost<{ + order: WorkOrder + cancelledShareCount: number + cancelledQuantity: number + releasedDepositAmount: number + refundAmount: number + }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/sharing/cancel-part`, payload) +} + export function acceptAdminWorkOrders(workOrderIds: number[]) { return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', { workOrderIds, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 07109305..d7940b56 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -648,6 +648,8 @@ export type WorkOrder = { canPublish: boolean canCancel: boolean canCancelSharing?: boolean + /** 取消部分:保留已验收份额,取消进行中份额与待接单剩余份数。 */ + canCancelSharingPart?: boolean canResolveProblem: boolean canDeduct: boolean canConfirmFriend?: boolean @@ -655,6 +657,8 @@ export type WorkOrder = { canResetGiftFlow?: boolean } rewardAmount: number + /** 来源订单实付金额(分):取消部分时用于预填退款金额。 */ + sourceOrderAmount?: number /** 当前打手生效的免押额度,仅打手端订单数据返回。 */ depositFreeAmount?: number requiredDepositAmount?: number @@ -668,6 +672,10 @@ export type WorkOrder = { joinedQuantity: number pendingSubmissionCount: number remainingQuantity?: number + /** 已验收份额的份数合计 */ + acceptedQuantity?: number + /** 可取消的未验收份数 = 进行中 + 待接单 */ + cancellableQuantity?: number filled?: boolean } pendingUnfreezeAmount?: number From 2c9f741873c77a402686b7c9b31835dff53a55d7 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 26 Oct 2026 12:41:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=E6=92=A4=E5=8D=95=E4=B9=8B=E5=90=8E=20?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E6=8C=87=E6=B4=BE=E7=BB=99=E4=BD=A0=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E6=89=93=E6=89=8B=20=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E8=AE=A2=E5=8D=95=20=E4=B8=8D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=8F=B7=E4=B8=8D=E6=98=BE=E7=A4=BA=E4=BB=BB?= =?UTF-8?q?=E4=BD=95=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-platform/work-order-query-repo.ts | 3 +++ .../worker-my-order-service.ts | 13 +++++---- .../worker-platform-service.test.ts | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 72dbcd70..03a72865 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -1256,6 +1256,9 @@ export function buildWorkOrderWhere({ FROM work_order_shares wos WHERE wos.work_order_id = wo.id AND wos.worker_id = ${workerSharePlaceholder}::bigint + -- 当前接单人就是本人时,旧的已取消份额不能再代表这张单: + -- 撤单后把同一单重新指派给同一打手,否则列表会一直判成「已取消」。 + AND NOT (wos.status = 'cancelled' AND wo.assigned_worker_id = ${workerSharePlaceholder}::bigint) ORDER BY CASE wos.status WHEN 'joined' THEN 0 diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index 76f837a9..11802692 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -167,11 +167,14 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker const cancelledShare = cancelledShareByOrderId.get(workOrderId) const timeoutEvent = timeoutEventsByOrderId.get(workOrderId) + const assignedToMe = + item.assigned_worker_id != null && Number(item.assigned_worker_id) === Number(worker.id) + // 当前接单人就是本人时,旧的已取消份额只作历史留痕,不能再参与状态判定: + // 撤单后重新指派给同一打手,否则会被映射成「已取消」,按钮全部不可用。 + const fallbackCancelledShare = assignedToMe ? null : cancelledShare + // 判断当前打手是否仍持有该单(任一有效份额即算持有) - const isOwnerCurrently = - (item.assigned_worker_id != null && - Number(item.assigned_worker_id) === Number(worker.id)) || - activeShares.length > 0 + const isOwnerCurrently = assignedToMe || activeShares.length > 0 // 若不是当前持有人,但有该打手的超时事件或取消份额,则判定为该打手的已超时历史单 const isTimedOutForMe = @@ -219,7 +222,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker ...mapWorkOrderForWorker( item, permissions, - activeShare || cancelledShare || null, + activeShare || fallbackCancelledShare || null, noteByOrderId.get(workOrderId) || '', viewedAtByOrderId.get(workOrderId) || null, { isTimedOutForMe, timeoutNotice, myShares: activeShares }, 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 00511954..2ec7c59b 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 @@ -3547,3 +3547,30 @@ test('取消部分:只取消未验收份额并保留已验收,剩余份数 ) assert.ok(mapperSrc.includes('canCancelSharingPart:'), '前端按钮依赖 canCancelSharingPart 能力位') }) + +test('撤单后重派同一打手:旧已取消份额不得把当前接单状态判成已取消', () => { + const querySrc = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok( + querySrc.includes( + "AND NOT (wos.status = 'cancelled' AND wo.assigned_worker_id = ${workerSharePlaceholder}::bigint)", + ), + '当前接单人就是本人时,代表份额必须排除已取消份额,否则列表会判成「已取消」', + ) + + const serviceSrc = fs.readFileSync( + new URL('./worker-my-order-service.ts', import.meta.url), + 'utf8', + ) + assert.ok( + serviceSrc.includes('const assignedToMe =') && + serviceSrc.includes('const fallbackCancelledShare = assignedToMe ? null : cancelledShare'), + '当前接单人就是本人时,不得把已取消份额当 myShare 传给映射', + ) + assert.ok( + serviceSrc.includes('activeShare || fallbackCancelledShare || null'), + '列表映射必须使用排除后的已取消份额兜底', + ) +})