diff --git a/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts index cff7767b..9f21b15f 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts @@ -143,6 +143,7 @@ export async function assignWorkOrderToWorker(input: { order: WorkOrderRow | null failureReason: | 'work_order_not_assignable' + | 'work_order_has_shares' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null @@ -167,6 +168,19 @@ export async function assignWorkOrderToWorker(input: { } const fromStatus = workOrder.status + // 已有拼单份额的订单不能按整单指派:否则会出现“整单接单人 + 其他人的份额”混在一起。 + const shareCountResult = await client.query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_order_shares + WHERE work_order_id = $1 AND status <> 'cancelled' + `, + [input.workOrderId], + ) + if (Number(shareCountResult.rows[0]?.total || 0) > 0) { + return { order: null, failureReason: 'work_order_has_shares' } + } + await ensureWorkerWalletWithClient(client, input.workerId, input.now) const wallet = await getWorkerWalletWithClient(client, input.workerId) const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) @@ -193,6 +207,9 @@ export async function assignWorkOrderToWorker(input: { friend_added_at = NULL, gift_available_at = NULL, ${CLEAR_DELIVERY_RESIDUE_SQL}, + -- 管理端/寄卖履约都是整单指派,不会创建 work_order_shares; + -- 必须同时清掉拼单标记,避免出现“已开拼单但没有份额”的脏状态。 + sharing_enabled = false, updated_at = $2 WHERE id = $4 `, diff --git a/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts index 13190bcf..0038ee98 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-assignment-service.ts @@ -4,6 +4,7 @@ import { countWorkerActiveOrders, createWorkOrderEvent, getWorkerUserById, + listWorkOrderShares, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -64,6 +65,18 @@ export async function assignAdminWorkOrderToWorker( }) } + // 已有拼单份额的订单不能再按整单指派;放在核销电子凭证之前, + // 避免“凭证已核销但指派失败”的副作用。 + const activeShares = (await listWorkOrderShares(workOrder.id)).filter( + (share) => share.status !== 'cancelled', + ) + if (activeShares.length > 0) { + throw createHttpError('该订单已有拼单份额,不能按整单指派;请使用拼单管理', { + statusCode: 409, + errorCode: 'work_order_has_shares', + }) + } + const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderAssign(workOrder) if (!voucherConsume.ok) { const failedAt = nowIso() @@ -112,6 +125,15 @@ export async function assignAdminWorkOrderToWorker( { statusCode: 409, errorCode: 'worker_deposit_insufficient' }, ) } + if (failureReason === 'work_order_has_shares') { + throw createHttpError( + resolveAssignFailureMessage( + voucherConsume.voucherCount, + '该订单已有拼单份额,不能按整单指派;请使用拼单管理', + ), + { statusCode: 409, errorCode: 'work_order_has_shares' }, + ) + } if (!updated) { throw createHttpError( resolveAssignFailureMessage(voucherConsume.voucherCount, '指派失败,订单状态可能已变化'), diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 21f32d75..4f1b1094 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -24,7 +24,9 @@ import type { UploadedFile, WorkOrder, WorkerAcceptanceVideo } from '@/types/wor import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders' import { formatDateTime } from '@/utils/date-time' import { useIsMobile } from '@/utils/use-is-mobile' +import { shouldBlockAcceptanceForMissingShares } from '@/utils/worker-order-acceptance' import { buildWorkerOrderQuery } from '@/utils/worker-order-query' +import { getWorkerId } from '@/utils/worker-auth' import { WorkerOrderDesktopList } from './WorkerOrderDesktopList' import { WorkerOrderDetailDrawer } from './WorkerOrderDetailDrawer' import { WorkerOrderMobileList } from './WorkerOrderMobileList' @@ -324,8 +326,16 @@ export default function WorkerOrdersPage() { function openAcceptanceModal(order: WorkOrder) { const shares = getOrderShares(order) - // 整单(非拼单)本来就没有份额,不能拦;只有拼单订单缺份额时才提示刷新。 - if (shares.length === 0 && order.sharing?.enabled === true) { + // 整单(非拼单)本来就没有份额,不能拦;拼单订单被管理端整单指派给本人时, + // 后端也会按整单验收,同样不能拦。只有“拼单缺份额且不是指派给本人”才提示刷新。 + if ( + shouldBlockAcceptanceForMissingShares({ + shareCount: shares.length, + sharingEnabled: order.sharing?.enabled === true, + assignedWorkerId: order.worker?.workerId, + currentWorkerId: getWorkerId(), + }) + ) { message.warning('未找到可验收的拼单份额,请刷新后重试') return } diff --git a/apps/frontend/src/utils/worker-order-acceptance.ts b/apps/frontend/src/utils/worker-order-acceptance.ts new file mode 100644 index 00000000..0a7d3bd5 --- /dev/null +++ b/apps/frontend/src/utils/worker-order-acceptance.ts @@ -0,0 +1,24 @@ +/** + * 打手验收前置判断:拼单订单缺份额时是否应该拦截。 + * + * 管理端「指派打手」是整单指派,不会创建 work_order_shares; + * 这类订单虽然 sharing.enabled=true,但接单人就是当前打手, + * 必须按整单验收放行,否则会被前端防呆误拦。 + */ +export type AcceptanceShareGuardInput = { + shareCount: number + sharingEnabled: boolean + assignedWorkerId: number | null | undefined + currentWorkerId: number +} + +export function shouldBlockAcceptanceForMissingShares(input: AcceptanceShareGuardInput): boolean { + if (input.shareCount > 0) return false + if (input.sharingEnabled !== true) return false + + const currentWorkerId = Number(input.currentWorkerId || 0) + // 本地会话里拿不到打手 ID 时保持旧行为,避免误放行。 + if (!Number.isFinite(currentWorkerId) || currentWorkerId <= 0) return true + + return Number(input.assignedWorkerId || 0) !== currentWorkerId +} diff --git a/apps/frontend/tests/worker-order-acceptance.test.ts b/apps/frontend/tests/worker-order-acceptance.test.ts new file mode 100644 index 00000000..51137217 --- /dev/null +++ b/apps/frontend/tests/worker-order-acceptance.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { shouldBlockAcceptanceForMissingShares } from '../src/utils/worker-order-acceptance.ts' + +test('拼单缺份额:整单指派给当前打手时放行', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: true, + assignedWorkerId: 1013, + currentWorkerId: 1013, + }), + false, + ) +}) + +test('拼单缺份额:指派给其他打手时拦截', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: true, + assignedWorkerId: 999, + currentWorkerId: 1013, + }), + true, + ) +}) + +test('拼单缺份额:没有接单人时拦截', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: true, + assignedWorkerId: null, + currentWorkerId: 1013, + }), + true, + ) +}) + +test('有份额时按正常份额流程处理,不拦截', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 1, + sharingEnabled: true, + assignedWorkerId: 1013, + currentWorkerId: 1013, + }), + false, + ) +}) + +test('非拼单整单没有份额,不拦截', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: false, + assignedWorkerId: 1013, + currentWorkerId: 1013, + }), + false, + ) +}) + +test('本地取不到当前打手 ID 时保持旧行为(拦截)', () => { + assert.equal( + shouldBlockAcceptanceForMissingShares({ + shareCount: 0, + sharingEnabled: true, + assignedWorkerId: 1013, + currentWorkerId: 0, + }), + true, + ) +})