From bfd2325ac55c7a0536d341886bbf2b5fca1368cb Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 23 Oct 2026 14:26:41 +0800 Subject: [PATCH] =?UTF-8?q?add:=E6=96=B0=E5=A2=9E=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=B7=A5=E5=8D=95=E5=90=8C=E4=B8=80=E4=BA=BA=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E5=A4=9A=E6=AC=A1=E6=8B=BC=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../share-summary-scope.test.ts | 33 ++ .../src/repositories/worker-platform/types.ts | 9 + .../worker-platform/work-order-query-repo.ts | 133 ++++--- .../work-order-settlement-repo.ts | 46 +-- .../worker-platform/work-order-share-repo.ts | 367 ++++++++++++------ .../worker-cancel-request-repo.ts | 129 +++++- .../worker-platform/worker-repo.ts | 2 +- apps/backend/src/routes/worker/hall.ts | 6 +- .../admin/admin-notification-service.ts | 37 +- .../admin-work-order-acceptance-service.ts | 15 +- .../work-order-display-mappers.ts | 111 +++++- .../worker-acceptance-service.ts | 109 +++++- .../worker-hall-action-service.ts | 18 +- .../worker-my-order-service.ts | 27 +- ...er-platform-notification-config-service.ts | 2 + .../worker-platform-service.test.ts | 198 +++++++++- .../pages/admin/panels/NotificationsPanel.tsx | 5 + .../pages/admin/panels/WorkOrdersPanel.tsx | 35 +- .../src/pages/admin/panels/shared.tsx | 10 + .../src/pages/worker/WorkerHallPage.tsx | 37 +- .../pages/worker/WorkerOrderDesktopList.tsx | 12 + .../pages/worker/WorkerOrderMobileList.tsx | 24 +- .../pages/worker/WorkerOrderTableColumns.tsx | 103 ++--- .../src/pages/worker/WorkerOrdersPage.tsx | 141 ++++++- .../src/pages/worker/worker-order-utils.tsx | 8 + apps/frontend/src/services/worker.ts | 31 +- apps/frontend/src/styles/worker.css | 23 ++ apps/frontend/src/types/worker-platform.ts | 8 + 28 files changed, 1332 insertions(+), 347 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts index f4ec8554..add275bf 100644 --- a/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts +++ b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts @@ -92,3 +92,36 @@ test('全仓库只允许保留一处「整表拼单聚合」,且必须是无 '保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删', ) }) + +test('我的订单的份额关联按工单取一条,避免多份额把列表行放大', async () => { + const source = stripComments(await readRepoSource()) + + assert.ok(source.includes('LEFT JOIN LATERAL ('), '缺少按工单取代表份额的 LATERAL 关联') + assert.ok( + !source.includes('LEFT JOIN work_order_shares worker_share'), + '仍在直接 JOIN 份额表:同一工单多份拼单会让「我的订单」出现多行', + ) +}) + +test('我的订单概览按工单计数,金额仍按份额求和', async () => { + const source = stripComments(await readRepoSource()) + const start = source.indexOf('worker_base AS (') + assert.ok(start >= 0, '缺少我的订单概览的 worker_base CTE') + + const block = source.slice(start, start + 2200) + assert.match(block, /LEFT JOIN LATERAL \(/, '概览的份额关联没有按工单取一条') + assert.match(block, /share_reward_total/, '概览没有汇总多份份额的奖励金额') + assert.ok( + !block.includes('LEFT JOIN work_order_shares wos'), + '概览仍在直接 JOIN 份额表:同一工单 3 份会被统计成 3 单', + ) +}) + +test('超时统计按工单去重,不按份额/事件行数累加', async () => { + const source = stripComments(await readRepoSource()) + + assert.ok( + source.includes('COUNT(DISTINCT work_order_id)::int AS total'), + '超时单数没有按工单去重', + ) +}) diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 4b81675c..29f33a59 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -358,6 +358,15 @@ export type WorkOrderShareRow = { accepted_at: string | null created_at: string updated_at: string + /** 本份额截止时间;参与时固化,null = 不限时 */ + deadline_at?: string | null + /** 参与时固化的时限分钟数与策略 */ + timeout_minutes?: number + timeout_policy?: string + /** 取消来源:timeout/worker_request/admin/problem/settlement */ + cancelled_reason?: string + /** 客户端幂等键 */ + request_id?: string worker_username?: string worker_display_name?: string } 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 60d7f2d5..6b675528 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 @@ -643,7 +643,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise wo.id, wo.status AS wo_status, wos.status AS wos_status, - wos.id IS NOT NULL AS is_worker_share, + wos.status IS NOT NULL AS is_worker_share, wo.assigned_worker_id = $1 AS is_assigned_to_me, EXISTS ( SELECT 1 FROM work_order_events we @@ -652,17 +652,38 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise AND we.payload_json->>'workerId' = $1::text ) AS has_timeout_for_me, CASE - WHEN wos.id IS NOT NULL THEN wos.share_reward + WHEN wos.status IS NOT NULL THEN wos.share_reward_total WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount ELSE 0 END AS worker_settlement_amount, wo.acceptance_json FROM work_orders wo INNER JOIN visible_orders vo ON vo.work_order_id = wo.id - LEFT JOIN work_order_shares wos - ON wos.work_order_id = wo.id - AND wos.worker_id = $1 - AND wos.status != 'cancelled' + LEFT JOIN LATERAL ( + SELECT + ( + SELECT w2.status + FROM work_order_shares w2 + WHERE w2.work_order_id = wo.id + AND w2.worker_id = $1 + AND w2.status != 'cancelled' + ORDER BY + CASE w2.status + WHEN 'joined' THEN 0 + WHEN 'submitted' THEN 1 + WHEN 'accepted' THEN 2 + ELSE 3 + END, + w2.created_at DESC, + w2.id DESC + LIMIT 1 + ) AS status, + COALESCE(SUM(wos.share_reward), 0)::int AS share_reward_total + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = $1 + AND wos.status != 'cancelled' + ) wos ON true ), worker_orders AS ( SELECT @@ -793,7 +814,12 @@ export interface OverdueWorkOrderShareItem { shareCreatedAt: string } -/** 查询已超过接单时限、尚未提交材料的拼单份额(status=joined)。 */ +/** + * 查询已到期、尚未提交材料的拼单份额(status=joined)。 + * + * 到期判定只看份额自身的 deadline_at(参与时固化),不再用「母单当前配置 + created_at」实时算: + * 母单改时限不会追溯影响已参与份额。 + */ export async function listOverdueWorkOrderShares({ limit = 50, workerId = 0, @@ -821,18 +847,17 @@ export async function listOverdueWorkOrderShares({ wos.worker_id, wos.quantity, wos.share_deposit, - wo.timeout_minutes, - wo.timeout_policy, + wos.timeout_minutes, + wos.timeout_policy, wos.created_at AS share_created_at FROM work_order_shares wos INNER JOIN work_orders wo ON wo.id = wos.work_order_id WHERE wos.status = 'joined' - AND wo.sharing_enabled = TRUE - AND wo.timeout_minutes > 0 - AND wos.created_at + (wo.timeout_minutes || ' minutes')::interval < NOW() - AND wo.status = 'open' + AND wos.deadline_at IS NOT NULL + AND wos.deadline_at < NOW() + AND wo.status NOT IN ('accepted', 'cancelled') ${workerClause} - ORDER BY wos.created_at ASC + ORDER BY wos.deadline_at ASC LIMIT $${params.length}`, params, ) @@ -848,25 +873,31 @@ export async function listOverdueWorkOrderShares({ })) } +/** + * 打手当前活跃单数 SQL(按**不同工单**计)。 + * + * 同一工单拼多份只占 1 个名额:拼单份额按 work_order_id 去重,避免拆份占满接单上限。 + * 事务内外的计数必须共用这一段 SQL,否则会出现「资料页/抢普通单按份额算、 + * 拼单入口按工单算」的口径分裂。 + */ +const COUNT_WORKER_ACTIVE_ORDERS_SQL = ` + SELECT COUNT(*)::int AS total + FROM ( + SELECT wo.id + FROM work_orders wo + WHERE wo.assigned_worker_id = $1 AND wo.status = 'in_progress' + UNION + SELECT wos.work_order_id + FROM work_order_shares wos + INNER JOIN work_orders swo ON swo.id = wos.work_order_id + WHERE wos.worker_id = $1 + AND wos.status = 'joined' + AND swo.status = 'open' + ) AS active_orders +` + export async function countWorkerActiveOrders(workerId: number | string): Promise { - const result = await query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int - FROM work_orders - WHERE assigned_worker_id = $1 - AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 - AND wos.status = 'joined' - AND wo.status = 'open' - ) AS total - `, - [Number(workerId)], - ) + const result = await query<{ total: number }>(COUNT_WORKER_ACTIVE_ORDERS_SQL, [Number(workerId)]) return Number(result.rows[0]?.total || 0) } @@ -874,20 +905,7 @@ export async function countWorkerActiveOrdersWithClient( client: PoolClient, workerId: number, ): Promise { - const result = await client.query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int FROM work_orders - WHERE assigned_worker_id = $1 AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 AND wos.status = 'joined' AND wo.status = 'open' - ) AS total - `, - [workerId], - ) + const result = await client.query<{ total: number }>(COUNT_WORKER_ACTIVE_ORDERS_SQL, [workerId]) return Number(result.rows[0]?.total || 0) } @@ -907,7 +925,8 @@ export async function countTimeoutEventsByWorkerIds( const counts = new Map() const result = await query<{ worker_id: string; total: number }>( ` - SELECT payload_json->>'workerId' AS worker_id, COUNT(*)::int AS total + SELECT payload_json->>'workerId' AS worker_id, + COUNT(DISTINCT work_order_id)::int AS total FROM work_order_events WHERE event_type LIKE 'timeout_%' AND payload_json->>'workerId' IS NOT NULL @@ -1165,9 +1184,23 @@ export function buildWorkOrderWhere({ WHERE timeout_ev.event_type LIKE 'timeout_%' AND timeout_ev.payload_json->>'workerId' = ${workerSharePlaceholder}::text ) worker_visible ON worker_visible.work_order_id = wo.id - LEFT JOIN work_order_shares worker_share - ON worker_share.work_order_id = wo.id - AND worker_share.worker_id = ${workerSharePlaceholder}::bigint`) + LEFT JOIN LATERAL ( + SELECT wos.status, wos.created_at + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = ${workerSharePlaceholder}::bigint + ORDER BY + CASE wos.status + WHEN 'joined' THEN 0 + WHEN 'submitted' THEN 1 + WHEN 'accepted' THEN 2 + WHEN 'cancelled' THEN 3 + ELSE 4 + END, + wos.created_at DESC, + wos.id DESC + LIMIT 1 + ) worker_share ON true`) } if (useOperationalStatus) { 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 6642e055..c4967d26 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 @@ -10,6 +10,7 @@ import { import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' +import { recomputeWorkOrderSharingStatusWithClient } from './work-order-share-repo.js' import { enqueueDepositUnfreezeWithClient, getOutstandingDepositAmountWithClient, @@ -809,6 +810,7 @@ export async function cancelWorkOrderShare(input: { await client.query( `UPDATE work_order_shares SET status = 'cancelled', + cancelled_reason = 'admin', acceptance_json = '{}'::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = NULL, @@ -817,14 +819,11 @@ export async function cancelWorkOrderShare(input: { WHERE id = $2`, [input.now, input.shareId], ) - await client.query( - `UPDATE work_orders - SET status = $1, - submitted_at = NULL, - published_at = COALESCE(published_at, $2), - updated_at = $2 - WHERE id = $3`, - [nextOrderStatus, input.now, input.workOrderId], + // 母单状态由剩余份额集合推导:撤掉这一份后,其余已提交份额仍可正常待验收 + const recomputedOrder = await recomputeWorkOrderSharingStatusWithClient( + client, + input.workOrderId, + input.now, ) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, @@ -832,7 +831,7 @@ export async function cancelWorkOrderShare(input: { actorId: input.actorName || '', eventType: 'sharing_share_cancelled_by_admin', fromStatus: workOrder.status, - toStatus: nextOrderStatus, + toStatus: recomputedOrder?.status || workOrder.status, payloadJson: JSON.stringify({ shareId: input.shareId, workerId, @@ -878,7 +877,11 @@ export async function settleOverdueWorkOrderShare(input: { [input.workOrderId], ) const workOrder = orderResult.rows[0] || null - if (!workOrder || workOrder.sharing_enabled !== true || workOrder.status !== 'open') { + if ( + !workOrder || + workOrder.sharing_enabled !== true || + !['open', 'pending_acceptance', 'in_progress'].includes(String(workOrder.status || '')) + ) { return { order: workOrder, share: null, @@ -901,7 +904,8 @@ export async function settleOverdueWorkOrderShare(input: { } } - const timeoutMinutes = Number(workOrder.timeout_minutes || 0) + // 到期判定只看份额自身快照:参与时固化的 deadline_at / timeout_minutes + const timeoutMinutes = Number(share.timeout_minutes ?? workOrder.timeout_minutes ?? 0) if (timeoutMinutes <= 0) { return { order: workOrder, @@ -910,8 +914,11 @@ export async function settleOverdueWorkOrderShare(input: { failureReason: 'share_not_overdue', } } - const deadlineMs = new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000 - if (deadlineMs > new Date(input.now).getTime()) { + const snapshotDeadline = String(share.deadline_at || '').trim() + const deadlineMs = snapshotDeadline + ? new Date(snapshotDeadline).getTime() + : new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000 + if (Number.isFinite(deadlineMs) && deadlineMs > new Date(input.now).getTime()) { return { order: workOrder, share, @@ -996,6 +1003,7 @@ export async function settleOverdueWorkOrderShare(input: { await client.query( `UPDATE work_order_shares SET status = 'cancelled', + cancelled_reason = 'timeout', acceptance_json = '{}'::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = NULL, @@ -1005,15 +1013,8 @@ export async function settleOverdueWorkOrderShare(input: { [input.now, input.shareId], ) - await client.query( - `UPDATE work_orders - SET status = 'open', - published_at = COALESCE(published_at, $1), - hall_queued_at = $1, - updated_at = $1 - WHERE id = $2`, - [input.now, input.workOrderId], - ) + // 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅 + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, @@ -1160,6 +1161,7 @@ export async function cancelWorkOrderSharing(input: { await client.query( `UPDATE work_order_shares SET status = 'cancelled', + cancelled_reason = 'admin', acceptance_json = '{}'::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = NULL, diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 417be135..98b19bd9 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -1,3 +1,5 @@ +import type { PoolClient } from 'pg' + import { query, withTransaction } from '../../db/client.js' import { ensureWorkerWalletWithClient, @@ -13,14 +15,6 @@ import { } from './work-order-repo.js' import type { WorkOrderRow, WorkOrderShareRow } from './types.js' -async function getWorkOrderShareById(shareId: number): Promise { - const result = await query( - `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, - [shareId], - ) - return result.rows[0] || null -} - export async function listWorkOrderShares( workOrderId: number | string, ): Promise { @@ -90,18 +84,56 @@ export async function countWorkOrderPendingSharingSubmissions( return Number(result.rows[0]?.total || 0) } +/** + * 该打手在该工单的「主份额」:优先未完成(joined/submitted)的最新一条, + * 否则取最新一条(含已验收/已取消)。仅用于展示与权限判断,**不要**用它做验收/撤单定位。 + */ export async function getWorkOrderShare( workOrderId: number | string, workerId: number | string, ): Promise { const result = await query( `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' LIMIT 1`, + WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' + ORDER BY CASE WHEN wos.status IN ('joined', 'submitted') THEN 0 ELSE 1 END, wos.id DESC + LIMIT 1`, [Number(workOrderId), Number(workerId)], ) return result.rows[0] || null } +/** 该打手在该工单的全部有效份额(未取消),按参与顺序返回。 */ +export async function listWorkOrderSharesByWorkerInOrder( + workOrderId: number | string, + workerId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' + ORDER BY wos.id ASC`, + [Number(workOrderId), Number(workerId)], + ) + return result.rows +} + +/** 按份额 id 读取(可带工单约束,防止跨单操作)。 */ +export async function getWorkOrderShareById( + shareId: number | string, + workOrderId?: number | string, +): Promise { + const params: unknown[] = [Number(shareId)] + let workOrderClause = '' + if (workOrderId !== undefined && Number(workOrderId) > 0) { + params.push(Number(workOrderId)) + workOrderClause = ` AND wos.work_order_id = $${params.length}` + } + const result = await query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1${workOrderClause} LIMIT 1`, + params, + ) + return result.rows[0] || null +} + /** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */ export function resolveWorkOrderShareJoinQuantity(input: { quantity: number @@ -112,27 +144,41 @@ export function resolveWorkOrderShareJoinQuantity(input: { } /** - * 计算打手当前累计拼单份额实际需要冻结的押金。 - * 等级与个人叠加后的免押额度针对该打手在本工单的累计份额生效;已经冻结的金额不自动回退。 + * 计算「本次参与」应新增冻结的押金。 + * + * 口径(已确认):免押额度跟着母单走 —— 在该工单内只抵扣一次。 + * cumRequired = round(required × 累计份数 / 总份数) + * cumDeposit = max(0, cumRequired − 免押额度) + * 本次冻结 = max(0, cumDeposit − 该打手在本工单已冻结合计) + * + * alreadyFrozenDeposit 只用于避免重复冻结,不会因为份数变少而回退(既有语义)。 */ -export function resolveWorkOrderShareDepositAmount(input: { +export function resolveWorkOrderShareDepositPlan(input: { requiredDepositAmount: number totalQuantity: number + /** 本次参与前,该打手在该工单已参与的有效份数(不含本次) */ + quantityBefore: number + /** 本次参与份数 */ nextQuantity: number depositFreeAmount?: number - currentFrozenDeposit?: number -}) { + /** 该打手在该工单已冻结的押金合计 */ + frozenDepositSoFar?: number +}): { cumulativeRequired: number; cumulativeDeposit: number; increment: number } { const totalQuantity = Math.max(1, Number(input.totalQuantity || 0)) - const nextRequiredDeposit = Math.round( - (Math.max(0, Number(input.requiredDepositAmount || 0)) * - Math.max(0, Number(input.nextQuantity || 0))) / - totalQuantity, + const cumulativeQuantity = + Math.max(0, Number(input.quantityBefore || 0)) + Math.max(0, Number(input.nextQuantity || 0)) + const cumulativeRequired = Math.round( + (Math.max(0, Number(input.requiredDepositAmount || 0)) * cumulativeQuantity) / totalQuantity, ) - const requiredShareDeposit = Math.max( + const cumulativeDeposit = Math.max( 0, - nextRequiredDeposit - Math.max(0, Number(input.depositFreeAmount || 0)), + cumulativeRequired - Math.max(0, Number(input.depositFreeAmount || 0)), ) - return Math.max(Math.max(0, Number(input.currentFrozenDeposit || 0)), requiredShareDeposit) + const increment = Math.max( + 0, + cumulativeDeposit - Math.max(0, Number(input.frozenDepositSoFar || 0)), + ) + return { cumulativeRequired, cumulativeDeposit, increment } } export async function joinWorkOrderShare(input: { @@ -141,15 +187,19 @@ export async function joinWorkOrderShare(input: { quantity: number /** 立即抢单时在事务锁内承接全部剩余份数。 */ takeRemaining?: boolean - /** 当前打手的有效免押额度(分),按其本次累计拼单份额抵扣。 */ + /** 当前打手的有效免押额度(分),按其在本工单的累计份额抵扣一次。 */ depositFreeAmount?: number maxActiveOrders: number hallCandidateLimit?: number hallCategoryLimits?: Record visibleAfterIso?: string + /** 客户端幂等键:同一个打手同一个 requestId 只产生一条份额。 */ + requestId?: string now: string }): Promise<{ share: WorkOrderShareRow | null + /** true = 命中幂等键,返回既有份额,未重复冻结 */ + duplicated: boolean failureReason: | 'work_order_not_open' | 'sharing_disabled' @@ -173,7 +223,7 @@ export async function joinWorkOrderShare(input: { ) const workOrder = orderResult.rows[0] || null if (!workOrder || workOrder.status !== 'open') { - return { share: null, failureReason: 'work_order_not_open' } + return { share: null, duplicated: false, failureReason: 'work_order_not_open' } } if ( input.hallCandidateLimit && @@ -184,13 +234,26 @@ export async function joinWorkOrderShare(input: { visibleAfterIso: input.visibleAfterIso, })) ) { - return { share: null, failureReason: 'work_order_not_open' } + return { share: null, duplicated: false, failureReason: 'work_order_not_open' } } if (workOrder.sharing_enabled !== true) { - return { share: null, failureReason: 'sharing_disabled' } + return { share: null, duplicated: false, failureReason: 'sharing_disabled' } } if (Number(workOrder.assigned_worker_id || 0) === input.workerId) { - return { share: null, failureReason: 'work_order_owner_conflict' } + return { share: null, duplicated: false, failureReason: 'work_order_owner_conflict' } + } + + // 幂等:同一打手同一 requestId 直接返回原份额(不重复冻结、不重复算份数) + const requestId = String(input.requestId || '').trim() + if (requestId) { + const idempotentResult = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.worker_id = $1 AND wos.request_id = $2 LIMIT 1`, + [input.workerId, requestId], + ) + const existing = idempotentResult.rows[0] || null + if (existing) { + return { share: existing, duplicated: true, failureReason: null } + } } const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1) @@ -210,83 +273,82 @@ export async function joinWorkOrderShare(input: { takeRemaining: input.takeRemaining === true, }) if (requestedQuantity <= 0 || requestedQuantity > remaining) { - return { share: null, failureReason: 'sharing_quantity_full' } + return { share: null, duplicated: false, failureReason: 'sharing_quantity_full' } } - const existingShareResult = await client.query( - `SELECT * - FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 - FOR UPDATE`, + // 该打手在本工单的累计份数与已冻结押金(免押额度按母单只抵扣一次) + const workerAggResult = await client.query<{ quantity_before: number; frozen_before: number }>( + ` + SELECT + COALESCE(SUM(quantity), 0)::int AS quantity_before, + COALESCE(SUM(share_deposit), 0)::int AS frozen_before + FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 AND status != 'cancelled' + `, [input.workOrderId, input.workerId], ) - const existingShare = existingShareResult.rows[0] || null - if (existingShare && (!input.takeRemaining || existingShare.status !== 'joined')) { - return { share: null, failureReason: 'sharing_quantity_full' } - } - if (!existingShare) { + const quantityBefore = Number(workerAggResult.rows[0]?.quantity_before || 0) + const frozenBefore = Number(workerAggResult.rows[0]?.frozen_before || 0) + + // 活跃单按「不同工单」计:已经在该工单有份额时不重复占名额 + if (quantityBefore <= 0) { const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) if (activeOrderCount >= input.maxActiveOrders) { - return { share: null, failureReason: 'worker_active_order_limit' } + return { share: null, duplicated: false, failureReason: 'worker_active_order_limit' } } } const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0) - const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity - const shareReward = nextQuantity * unitReward - const nextShareDeposit = resolveWorkOrderShareDepositAmount({ + const shareReward = requestedQuantity * unitReward + const depositPlan = resolveWorkOrderShareDepositPlan({ requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), totalQuantity, - nextQuantity, + quantityBefore, + nextQuantity: requestedQuantity, ...(input.depositFreeAmount === undefined ? {} : { depositFreeAmount: input.depositFreeAmount }), - currentFrozenDeposit: Number(existingShare?.share_deposit || 0), + frozenDepositSoFar: frozenBefore, }) - const depositToFreeze = Math.max( - 0, - nextShareDeposit - Number(existingShare?.share_deposit || 0), - ) + const depositToFreeze = depositPlan.increment const wallet = await getWorkerWalletWithClient(client, input.workerId) const available = Number(wallet?.available_amount || 0) if (available < depositToFreeze) { - return { share: null, failureReason: 'worker_deposit_insufficient' } + return { share: null, duplicated: false, failureReason: 'worker_deposit_insufficient' } } - const shareResult = existingShare - ? await client.query<{ id: number }>( - ` - UPDATE work_order_shares - SET - quantity = $1, - unit_reward = $2, - share_reward = $3, - share_deposit = $4, - updated_at = $5 - WHERE id = $6 - RETURNING id - `, - [nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id], - ) - : await client.query<{ id: number }>( - ` + // 参与瞬间固化截止时间与超时策略:母单后续改配置不影响这一份 + const timeoutMinutes = toPositiveInteger(workOrder.timeout_minutes, 0) + const timeoutPolicy = String(workOrder.timeout_policy || '').trim() || 'reopen' + const deadlineAt = + timeoutMinutes > 0 + ? new Date(new Date(input.now).getTime() + timeoutMinutes * 60 * 1000).toISOString() + : null + + const shareResult = await client.query<{ id: number }>( + ` INSERT INTO work_order_shares ( work_order_id, worker_id, quantity, unit_reward, share_reward, - share_deposit, status, acceptance_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $7) + share_deposit, status, acceptance_json, deadline_at, timeout_minutes, + timeout_policy, request_id, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $8, $9, $10, $11, $11) RETURNING id `, - [ - input.workOrderId, - input.workerId, - requestedQuantity, - unitReward, - shareReward, - nextShareDeposit, - input.now, - ], - ) + [ + input.workOrderId, + input.workerId, + requestedQuantity, + unitReward, + shareReward, + depositToFreeze, + deadlineAt, + timeoutMinutes, + timeoutPolicy, + requestId, + input.now, + ], + ) if (depositToFreeze > 0) { const nextAvailable = available - depositToFreeze @@ -313,7 +375,12 @@ export async function joinWorkOrderShare(input: { nextFrozen, input.workOrderId, `拼单冻结押金 ${requestedQuantity} 份`, - JSON.stringify({ workOrderId: input.workOrderId, quantity: requestedQuantity }), + JSON.stringify({ + workOrderId: input.workOrderId, + quantity: requestedQuantity, + shareId: Number(shareResult.rows[0]?.id || 0), + cumulativeDeposit: depositPlan.cumulativeDeposit, + }), input.now, ], ) @@ -328,8 +395,13 @@ export async function joinWorkOrderShare(input: { toStatus: 'open', payloadJson: JSON.stringify({ workerId: input.workerId, + shareId: Number(shareResult.rows[0]?.id || 0), quantity: requestedQuantity, takeRemaining: input.takeRemaining === true, + deadlineAt, + timeoutMinutes, + timeoutPolicy, + requestId: requestId || null, }), now: input.now, }) @@ -338,13 +410,14 @@ export async function joinWorkOrderShare(input: { `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, [Number(shareResult.rows[0]?.id || 0)], ) - return { share: share.rows[0] || null, failureReason: null } + return { share: share.rows[0] || null, duplicated: false, failureReason: null } }) } export async function submitWorkOrderShareAcceptance(input: { workOrderId: number workerId: number + shareId: number acceptanceJson: string now: string }): Promise<{ @@ -352,11 +425,8 @@ export async function submitWorkOrderShareAcceptance(input: { failureReason: 'share_not_found' | 'share_status_invalid' | 'work_order_status_invalid' | null }> { return withTransaction(async (client) => { - const orderResult = await client.query<{ - status: string - sharing_total_quantity: number - }>( - `SELECT status, sharing_total_quantity + const orderResult = await client.query<{ status: string }>( + `SELECT status FROM work_orders WHERE id = $1 FOR UPDATE`, @@ -374,10 +444,10 @@ export async function submitWorkOrderShareAcceptance(input: { ` SELECT id FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 + WHERE id = $3 AND work_order_id = $1 AND worker_id = $2 FOR UPDATE `, - [input.workOrderId, input.workerId], + [input.workOrderId, input.workerId, input.shareId], ) const shareId = lockResult.rows[0]?.id || 0 if (!shareId) { @@ -403,34 +473,7 @@ export async function submitWorkOrderShareAcceptance(input: { [input.acceptanceJson, input.now, Number(share.id)], ) - const progressResult = await client.query<{ - joined_quantity: number - pending_submission_count: number - }>( - `SELECT - COALESCE(SUM(quantity), 0)::int AS joined_quantity, - COUNT(*) FILTER (WHERE status = 'joined')::int AS pending_submission_count - FROM work_order_shares - WHERE work_order_id = $1 - AND status != 'cancelled'`, - [input.workOrderId], - ) - const progress = progressResult.rows[0] - const allQuantityAllocated = - Number(progress?.joined_quantity || 0) >= - toPositiveInteger(workOrder.sharing_total_quantity, 1) - if (allQuantityAllocated && Number(progress?.pending_submission_count || 0) === 0) { - await client.query( - `UPDATE work_orders - SET - status = 'pending_acceptance', - submitted_at = COALESCE(submitted_at, $1), - updated_at = $1 - WHERE id = $2 - AND status = 'open'`, - [input.now, input.workOrderId], - ) - } + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, @@ -439,7 +482,11 @@ export async function submitWorkOrderShareAcceptance(input: { eventType: 'sharing_acceptance_submitted', fromStatus: 'joined', toStatus: 'submitted', - payloadJson: JSON.stringify({ workerId: input.workerId, quantity: share.quantity }), + payloadJson: JSON.stringify({ + workerId: input.workerId, + shareId: Number(share.id), + quantity: share.quantity, + }), now: input.now, }) const updated = await client.query( @@ -450,6 +497,86 @@ export async function submitWorkOrderShareAcceptance(input: { }) } +/** + * 按份额集合推导母单拼单状态(唯一口径,禁止各调用点各自 UPDATE work_orders.status)。 + * + * allocated >= total && joined == 0 && submitted > 0 → pending_acceptance + * allocated < total 或 joined > 0 → open + * allocated == 0(全部取消) → open(重新可参与) + * 母单 accepted / cancelled → 不动 + * + * 返回更新后的母单行(供调用方回给前端)。 + */ +export async function recomputeWorkOrderSharingStatusWithClient( + client: PoolClient, + workOrderId: number, + now: string, +): Promise { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder) return null + if (['accepted', 'cancelled'].includes(String(workOrder.status || ''))) { + return workOrder + } + + const aggResult = await client.query<{ + allocated: number + joined_count: number + submitted_count: number + accepted_count: number + }>( + ` + SELECT + COALESCE(SUM(quantity), 0)::int AS allocated, + COUNT(*) FILTER (WHERE status = 'joined')::int AS joined_count, + COUNT(*) FILTER (WHERE status = 'submitted')::int AS submitted_count, + COUNT(*) FILTER (WHERE status = 'accepted')::int AS accepted_count + FROM work_order_shares + WHERE work_order_id = $1 AND status != 'cancelled' + `, + [workOrderId], + ) + const agg = aggResult.rows[0] + const allocated = Number(agg?.allocated || 0) + const joinedCount = Number(agg?.joined_count || 0) + const submittedCount = Number(agg?.submitted_count || 0) + const acceptedCount = Number(agg?.accepted_count || 0) + const total = toPositiveInteger(workOrder.sharing_total_quantity, 1) + + const nextStatus = + allocated >= total && joinedCount === 0 && submittedCount > 0 ? 'pending_acceptance' : 'open' + + // 已全是 accepted 且母单已验收时不再回退(上面的终态分支已挡住) + if (allocated === 0 && acceptedCount === 0 && workOrder.status === 'open') { + return workOrder + } + if (String(workOrder.status || '') === nextStatus) { + return workOrder + } + + const updated = await client.query( + ` + UPDATE work_orders + SET + status = $1, + published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, + hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END, + submitted_at = CASE + WHEN $1 = 'pending_acceptance' THEN COALESCE(submitted_at, $2) + ELSE submitted_at + END, + updated_at = $2 + WHERE id = $3 + RETURNING * + `, + [nextStatus, now, workOrderId], + ) + return updated.rows[0] || null +} + /** 保存整单验收草稿(暂存图片/说明),不改变工单状态 */ export async function updateWorkOrderDraftAcceptance(input: { workOrderId: number @@ -488,13 +615,14 @@ export async function updateWorkOrderAcceptanceRecord(input: { export async function updateWorkOrderShareDraftAcceptance(input: { workOrderId: number workerId: number + shareId: number draftJson: string now: string }): Promise { const result = await query( `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = $1 AND wos.worker_id = $2`, - [Number(input.workOrderId), Number(input.workerId)], + WHERE wos.id = $3 AND wos.work_order_id = $1 AND wos.worker_id = $2`, + [Number(input.workOrderId), Number(input.workerId), Number(input.shareId)], ) const share = result.rows[0] || null if (!share) return null @@ -513,13 +641,14 @@ export async function updateWorkOrderShareDraftAcceptance(input: { export async function updateWorkOrderShareAcceptanceRecord(input: { workOrderId: number workerId: number + shareId: number acceptanceJson: string now: string }): Promise { const result = await query( `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = $1 AND wos.worker_id = $2`, - [Number(input.workOrderId), Number(input.workerId)], + WHERE wos.id = $3 AND wos.work_order_id = $1 AND wos.worker_id = $2`, + [Number(input.workOrderId), Number(input.workerId), Number(input.shareId)], ) const share = result.rows[0] || null if (!share) return 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 f4b3c5bd..e69f7de8 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 @@ -11,6 +11,7 @@ import { } from './shared.js' import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' +import { recomputeWorkOrderSharingStatusWithClient } from './work-order-share-repo.js' import { getOutstandingDepositAmountWithClient, getWorkOrderByIdWithClient, @@ -229,6 +230,8 @@ export async function getPendingCancelRequestOrderIds( export async function createWorkerCancelRequest(input: { workOrderId: number workerId: number + /** 指定份额:只申请撤销这一份;不传(拼单)= 申请撤销我在该工单的全部有效份额 */ + shareId?: number | null reason: string now: string }): Promise<{ @@ -254,17 +257,35 @@ export async function createWorkerCancelRequest(input: { let shareId: number | null = null if (workOrder.sharing_enabled === true) { - const shareResult = await client.query( - `SELECT * FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 - FOR UPDATE`, - [input.workOrderId, input.workerId], - ) - const share = shareResult.rows[0] || null - if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) { - return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' } + const requestedShareId = Number(input.shareId || 0) + if (requestedShareId > 0) { + const shareResult = await client.query( + `SELECT * FROM work_order_shares + WHERE id = $3 AND work_order_id = $1 AND worker_id = $2 + FOR UPDATE`, + [input.workOrderId, input.workerId, requestedShareId], + ) + const share = shareResult.rows[0] || null + if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) { + return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' } + } + shareId = Number(share.id) + } else { + // 整单退单(拼单):至少要有 1 条可撤份额;审核通过时撤销我在该工单的全部有效份额 + const sharesResult = await client.query( + `SELECT * FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 + FOR UPDATE`, + [input.workOrderId, input.workerId], + ) + const cancellable = sharesResult.rows.filter((share) => + resolveWorkOrderShareCancellationStatus(workOrder.status, share.status), + ) + if (cancellable.length === 0) { + return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' } + } + shareId = null } - shareId = Number(share.id) } else if ( workOrder.status !== 'in_progress' || Number(workOrder.assigned_worker_id || 0) !== input.workerId @@ -534,6 +555,7 @@ export async function reviewWorkerCancelRequest(input: { await client.query( `UPDATE work_order_shares SET status = 'cancelled', + cancelled_reason = 'worker_request', acceptance_json = '{}'::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = NULL, @@ -542,17 +564,84 @@ export async function reviewWorkerCancelRequest(input: { WHERE id = $2`, [input.now, share.id], ) - await client.query( - `UPDATE work_orders - SET status = 'open', - ${CLEAR_DELIVERY_RESIDUE_SQL}, - ${RESET_GIFT_FLOW_SQL}, - published_at = COALESCE(published_at, $1), - hall_queued_at = COALESCE(hall_queued_at, $1), - updated_at = $1 - WHERE id = $2`, - [input.now, workOrder.id], + await recomputeWorkOrderSharingStatusWithClient(client, workOrder.id, input.now) + } else if (workOrder.sharing_enabled === true) { + // 整单退单(拼单):撤销我在该工单的全部可撤份额,逐份退还(或没收)押金 + const sharesResult = await client.query( + `SELECT * FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 AND status IN ('joined', 'submitted') + FOR UPDATE`, + [workOrder.id, request.worker_id], ) + const cancellableShares = sharesResult.rows.filter((share) => + resolveWorkOrderShareCancellationStatus(workOrder.status, share.status), + ) + if (cancellableShares.length === 0) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'target_state_changed', + } + } + await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now) + for (const share of cancellableShares) { + const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id)) + const releaseAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releaseAmount > 0) { + 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( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, input.now, request.worker_id], + ) + 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)`, + [ + request.worker_id, + forfeitGiftDeposit ? 'deposit_deduction' : 'deposit_release', + forfeitGiftDeposit ? -releaseAmount : releaseAmount, + nextAvailable, + nextFrozen, + request.work_order_id, + forfeitGiftDeposit + ? '打手整单退单审核通过扣除拼单押金(已确认加好友)' + : '打手整单退单审核通过退还拼单押金', + JSON.stringify({ + requestId: request.id, + shareId: Number(share.id), + reason: request.reason, + }), + input.now, + ], + ) + releasedDepositAmount += releaseAmount + } + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', + cancelled_reason = 'worker_request', + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + updated_at = $1 + WHERE id = $2`, + [input.now, share.id], + ) + } + await recomputeWorkOrderSharingStatusWithClient(client, workOrder.id, input.now) } else { if ( workOrder.status !== 'in_progress' || diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index b8735e4e..45bdca3f 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -317,7 +317,7 @@ export async function getWorkerUserStatistics( const timeoutResult = await query<{ total: number }>( ` - SELECT COUNT(*)::int AS total + SELECT COUNT(DISTINCT (e.payload_json->>'workerId', e.work_order_id))::int AS total FROM work_order_events e INNER JOIN worker_users wu ON e.payload_json->>'workerId' = wu.id::text ${whereClause ? `${whereClause} AND` : 'WHERE'} e.event_type LIKE 'timeout_%' diff --git a/apps/backend/src/routes/worker/hall.ts b/apps/backend/src/routes/worker/hall.ts index 7824822d..e7a0327d 100644 --- a/apps/backend/src/routes/worker/hall.ts +++ b/apps/backend/src/routes/worker/hall.ts @@ -50,7 +50,11 @@ router.post( requireActiveWorker, createRouteHandler( (req) => - grabWorkerHallOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), + grabWorkerHallOrder( + String(req.params.workOrderId || ''), + getRequiredWorkerSession(req), + req.body || {}, + ), { successMessage: '抢单成功', errorMessage: '抢单失败', diff --git a/apps/backend/src/services/admin/admin-notification-service.ts b/apps/backend/src/services/admin/admin-notification-service.ts index 1771e762..46ef0706 100644 --- a/apps/backend/src/services/admin/admin-notification-service.ts +++ b/apps/backend/src/services/admin/admin-notification-service.ts @@ -77,18 +77,25 @@ export async function createWorkerAcceptanceAdminNotification(input: { productName: string workerName: string imageCount: number + /** 拼单份额:每份提交各生成一条待办(按 shareId 去重) */ + shareId?: number | null }) { + const shareId = Number(input.shareId || 0) return createConfiguredWorkerPlatformNotification('acceptance_submitted', { notificationType: 'worker_acceptance_submitted', priority: 'normal', entityType: 'work_order', entityId: input.workOrderId, - dedupeKey: acceptanceNotificationKey(input.workOrderId), + dedupeKey: + shareId > 0 + ? acceptanceNotificationKey(input.workOrderId, shareId) + : acceptanceNotificationKey(input.workOrderId), title: '有订单待验收', body: [ `订单:${input.workOrderNo || input.workOrderId}`, `商品:${input.productName || '-'}`, `打手:${input.workerName || '-'}`, + ...(shareId > 0 ? [`份额:#${shareId}`] : []), `验收图片:${input.imageCount} 张`, ].join(';'), actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`, @@ -96,6 +103,28 @@ export async function createWorkerAcceptanceAdminNotification(input: { }) } +/** 整单(含拼单全部份额)验收完成:单独一条完成通知,不与待验收待办混用。 */ +export async function createWorkOrderFullyAcceptedAdminNotification(input: { + workOrderId: number + workOrderNo: string + productName: string +}) { + return createConfiguredWorkerPlatformNotification('order_accepted', { + notificationType: 'worker_order_accepted', + priority: 'normal', + entityType: 'work_order', + entityId: input.workOrderId, + dedupeKey: `work_order_accepted:${input.workOrderId}`, + title: '订单已全部验收', + body: [ + `订单:${input.workOrderNo || input.workOrderId}`, + `商品:${input.productName || '-'}`, + ].join(';'), + actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`, + now: new Date().toISOString(), + }) +} + export async function createWorkerCancelRequestAdminNotification(input: { requestId: number workOrderId: number @@ -279,8 +308,10 @@ async function createConfiguredWorkerPlatformNotification( return notification } -function acceptanceNotificationKey(workOrderId: number) { - return `work_order_acceptance:${workOrderId}` +function acceptanceNotificationKey(workOrderId: number, shareId = 0) { + return shareId > 0 + ? `work_order_acceptance:${workOrderId}:share:${shareId}` + : `work_order_acceptance:${workOrderId}` } function formatAmount(amountFen: number) { 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 19cd2373..be0a7cd7 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 @@ -11,7 +11,10 @@ import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' import { nowIso } from '../../utils/time.js' -import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js' +import { + createWorkOrderFullyAcceptedAdminNotification, + resolveAdminNotificationEntity, +} from '../admin/admin-notification-service.js' import { publishWorkerWalletRealtimeChange, publishWorkOrderRealtimeChange, @@ -80,6 +83,11 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa }) } await resolveAdminNotificationEntity('work_order', Number(workOrder.id), 'accepted') + await createWorkOrderFullyAcceptedAdminNotification({ + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + productName: workOrder.product_name, + }) const workerIds = await resolveWorkOrderRealtimeWorkerIds(workOrder) publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), workerIds }) for (const workerId of workerIds) publishWorkerWalletRealtimeChange(workerId) @@ -119,6 +127,11 @@ export async function acceptAdminWorkOrderShare( if (workerId > 0) publishWorkerWalletRealtimeChange(workerId) if (result.order.status === WORK_ORDER_STATUS.ACCEPTED) { await resolveAdminNotificationEntity('work_order', Number(workOrderId), 'accepted') + await createWorkOrderFullyAcceptedAdminNotification({ + workOrderId: Number(workOrderId), + workOrderNo: result.order.work_order_no, + productName: result.order.product_name, + }) } return { order: mapWorkOrderAdmin(result.order), share: mapWorkOrderShare(result.share) } } 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 a6a7975d..5e5d24c0 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 @@ -423,6 +423,8 @@ export function mapWorkOrderAdmin( export type WorkerOrderMapOptions = { isTimedOutForMe?: boolean + /** 同一打手在该工单的全部有效份额;不传时回退为 myShare 单条 */ + myShares?: WorkOrderShareRow[] timeoutNotice?: | { isTimedOut: boolean @@ -435,6 +437,72 @@ export type WorkerOrderMapOptions = { | undefined } +/** 同一打手在一个工单里的多份份额汇总(列表一行一工单展示用)。 */ +export type WorkerShareSummary = { + quantity: number + rewardAmount: number + depositAmount: number + firstJoinedAt: string | null + /** 未完成份额里最早的截止时间;用于父行倒计时,避免只看到其中一份。 */ + earliestActiveDeadlineAt: string | null +} + +export function summarizeWorkerShares( + shares: WorkOrderShareRow[], + fallbackTimeoutMinutes = 0, +): WorkerShareSummary { + let quantity = 0 + let rewardAmount = 0 + let depositAmount = 0 + /** 有有效份额时取第一份有效份额的参与时间;只剩已取消份额时回退到最早的取消份额。 */ + let firstActiveJoinedAtMs = Number.POSITIVE_INFINITY + let firstAnyJoinedAtMs = Number.POSITIVE_INFINITY + let earliestDeadlineMs = Number.POSITIVE_INFINITY + + for (const share of shares) { + const createdAtMs = Date.parse(String(share.created_at || '')) + if (Number.isFinite(createdAtMs) && createdAtMs < firstAnyJoinedAtMs) { + firstAnyJoinedAtMs = createdAtMs + } + + if (share.status === 'cancelled') continue + + if (Number.isFinite(createdAtMs) && createdAtMs < firstActiveJoinedAtMs) { + firstActiveJoinedAtMs = createdAtMs + } + + quantity += Number(share.quantity || 0) + rewardAmount += Number(share.share_reward || 0) + depositAmount += Number(share.share_deposit || 0) + + if (share.status === 'joined') { + const timeoutMinutes = Number(share.timeout_minutes || fallbackTimeoutMinutes || 0) + const deadlineMs = share.deadline_at + ? Date.parse(String(share.deadline_at)) + : Number.isFinite(createdAtMs) && timeoutMinutes > 0 + ? createdAtMs + timeoutMinutes * 60_000 + : Number.NaN + if (Number.isFinite(deadlineMs) && deadlineMs < earliestDeadlineMs) { + earliestDeadlineMs = deadlineMs + } + } + } + + return { + quantity, + rewardAmount, + depositAmount, + firstJoinedAt: Number.isFinite(firstActiveJoinedAtMs) + ? new Date(firstActiveJoinedAtMs).toISOString() + : Number.isFinite(firstAnyJoinedAtMs) + ? new Date(firstAnyJoinedAtMs).toISOString() + : null, + earliestActiveDeadlineAt: Number.isFinite(earliestDeadlineMs) + ? new Date(earliestDeadlineMs).toISOString() + : null, + } +} + export function mapWorkOrderForWorker( workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }, @@ -461,35 +529,47 @@ export function mapWorkOrderForWorker( const personalStatus = isTimedOutForMe ? WORK_ORDER_STATUS.OPEN : resolveWorkerShareStatus(workOrder.status, myShare?.status) + + const workerShares = + options?.myShares && options.myShares.length > 0 ? options.myShares : myShare ? [myShare] : [] + const shareSummary = summarizeWorkerShares(workerShares, Number(workOrder.timeout_minutes || 0)) + const hasActiveShares = workerShares.some((share) => share.status !== 'cancelled') + return { ...visibleOrder, - productName: myShare - ? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0)) + productName: hasActiveShares + ? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity) : visibleOrder.productName, status: personalStatus, rewardAmount: isTimedOutForMe ? 0 - : myShare - ? Number(myShare.share_reward || 0) + : hasActiveShares + ? shareSummary.rewardAmount : visibleOrder.rewardAmount, freezeDepositAmount: isTimedOutForMe ? 0 - : myShare - ? Number(myShare.share_deposit || 0) + : hasActiveShares + ? shareSummary.depositAmount : resolveFreezeDepositAmount(workOrder, permissions), /** 用于大厅拼单按当前剩余份数准确计算个人免押后的押金。 */ depositFreeAmount: Number(permissions.depositFreeAmount || 0), - assignedAt: myShare?.created_at || visibleOrder.assignedAt, + assignedAt: shareSummary.firstJoinedAt || visibleOrder.assignedAt, submittedAt: myShare?.submitted_at || visibleOrder.submittedAt, acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt, - deadlineAt: - myShare && Number(workOrder.timeout_minutes || 0) > 0 - ? new Date( - new Date(myShare.created_at).getTime() + Number(workOrder.timeout_minutes) * 60 * 1000, - ).toISOString() - : visibleOrder.deadlineAt, + deadlineAt: isTimedOutForMe + ? visibleOrder.deadlineAt + : shareSummary.earliestActiveDeadlineAt || + (myShare && Number(workOrder.timeout_minutes || 0) > 0 + ? String(myShare.deadline_at || '') || + new Date( + new Date(myShare.created_at).getTime() + + Number(workOrder.timeout_minutes) * 60 * 1000, + ).toISOString() + : visibleOrder.deadlineAt), workerNote, myShare: myShare ? mapWorkOrderShare(myShare) : null, + /** 同一打手在该工单的全部有效份额(一行一工单 + 展开份额) */ + myShares: workerShares.map(mapWorkOrderShare), ...(viewedAt === undefined ? {} : { isUnread: !viewedAt }), ...(options?.timeoutNotice ? { timeoutNotice: options.timeoutNotice } : {}), } @@ -533,6 +613,11 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) { acceptedAt: share.accepted_at, createdAt: share.created_at, updatedAt: share.updated_at, + /** 本份额独立截止时间(参与时固化);null = 不限时 */ + deadlineAt: share.deadline_at || null, + timeoutMinutes: Number(share.timeout_minutes || 0), + timeoutPolicy: share.timeout_policy || '', + cancelledReason: share.cancelled_reason || '', worker: share.worker_id ? { workerId: Number(share.worker_id), diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts index 4b6291ab..fd3e4632 100644 --- a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts @@ -9,7 +9,7 @@ import { acceptWorkOrderShareAndSettle, countWorkOrderPendingSharingSubmissions, createWorkOrderEvent, - getWorkOrderShare, + listWorkOrderSharesByWorkerInOrder, settleOverdueWorkOrder, settleOverdueWorkOrderShare, submitWorkOrderShareAcceptance, @@ -74,9 +74,21 @@ export async function submitWorkerOrderAcceptance( const workerId = Number(session.workerId) const worker = await getRequiredWorker(session.workerId) if (!options.asConsignmentSupplier) { - const sharingShare = await getWorkOrderShare(workOrder.id, workerId) - if (sharingShare) { - return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session) + const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload) + if (target.error === 'required') { + throw createHttpError('该工单有多条拼单份额,请指定 shareId', { + statusCode: 400, + errorCode: 'work_order_share_id_required', + }) + } + if (target.error === 'not_found') { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + if (target.share) { + return submitWorkerSharingAcceptance(workOrder, target.share, payload, session) } } const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode) @@ -340,9 +352,21 @@ export async function supplementWorkerAcceptedOrderEvidence( requireActiveWorkerSession(session) const workOrder = await getRequiredWorkOrder(workOrderId) const worker = await getRequiredWorker(session.workerId) - const sharingShare = await getWorkOrderShare(workOrder.id, session.workerId) - if (sharingShare) { - return supplementWorkerAcceptedSharingEvidence(workOrder, sharingShare, payload, session) + const target = await resolveWorkerTargetShare(workOrder.id, session.workerId, payload) + if (target.error === 'required') { + throw createHttpError('该工单有多条拼单份额,请指定 shareId', { + statusCode: 400, + errorCode: 'work_order_share_id_required', + }) + } + if (target.error === 'not_found') { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + if (target.share) { + return supplementWorkerAcceptedSharingEvidence(workOrder, target.share, payload, session) } if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) { throw createHttpError('只能补充自己的订单验收图片', { @@ -411,16 +435,23 @@ async function submitWorkerSharingAcceptance( }) } const now = nowIso() - const timeoutMinutes = Number(workOrder.timeout_minutes || 0) + const timeoutMinutes = Number(sharingShare.timeout_minutes ?? workOrder.timeout_minutes ?? 0) + const snapshotDeadline = String(sharingShare.deadline_at || '').trim() + const deadlineMs = snapshotDeadline + ? new Date(snapshotDeadline).getTime() + : new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 if ( sharingShare.status === 'joined' && timeoutMinutes > 0 && - new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 <= Date.now() + Number.isFinite(deadlineMs) && + deadlineMs <= Date.now() ) { await settleOverdueWorkOrderShare({ workOrderId: workOrder.id, shareId: sharingShare.id, - policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy), + policy: normalizeWorkOrderTimeoutPolicy( + sharingShare.timeout_policy || workOrder.timeout_policy, + ), now, }) publishWorkOrderRealtimeChange({ @@ -463,6 +494,7 @@ async function submitWorkerSharingAcceptance( const result = await submitWorkOrderShareAcceptance({ workOrderId: Number(workOrder.id), workerId: Number(session.workerId), + shareId: Number(sharingShare.id), acceptanceJson: JSON.stringify(acceptance), now, }) @@ -509,6 +541,14 @@ async function submitWorkerSharingAcceptance( sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)), } } + await createWorkerAcceptanceAdminNotification({ + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + productName: workOrder.product_name, + workerName: worker.display_name || worker.username, + imageCount: acceptance.imageUrls.length, + shareId: Number(sharingShare.id), + }) await publishWorkOrderParticipantChange(Number(workOrder.id), { workerIds: [Number(session.workerId)], }) @@ -552,6 +592,7 @@ async function supplementWorkerAcceptedSharingEvidence( const updated = await updateWorkOrderShareAcceptanceRecord({ workOrderId: Number(workOrder.id), workerId: Number(session.workerId), + shareId: Number(sharingShare.id), acceptanceJson: JSON.stringify(acceptance), now, }) @@ -575,7 +616,33 @@ async function supplementWorkerAcceptedSharingEvidence( return { share: mapWorkOrderShare(updated || sharingShare) } } -/** 从提交或草稿载荷中解析验收图片与说明。 */ +/** + * 定位本次操作要处理的份额: + * - 传了 shareId:必须是本人该工单下的有效份额(否则 not_found) + * - 没传且只有 1 条:直接用 + * - 没传且有多条:要求前端明确指定(required) + * - 没有任何份额:返回空(走整单逻辑) + */ +async function resolveWorkerTargetShare( + workOrderId: number, + workerId: number | string, + payload: JsonObject, +): Promise<{ share: WorkOrderShareRow | null; error: 'required' | 'not_found' | null }> { + const requested = Number(payload.shareId ?? payload.workOrderShareId ?? 0) + const shares = await listWorkOrderSharesByWorkerInOrder(workOrderId, workerId) + if (Number.isFinite(requested) && requested > 0) { + const found = shares.find((share) => Number(share.id) === requested) || null + return { share: found, error: found ? null : 'not_found' } + } + if (shares.length === 0) { + return { share: null, error: null } + } + if (shares.length === 1) { + return { share: shares[0] ?? null, error: null } + } + return { share: null, error: 'required' } +} + function resolveAcceptanceImages(payload: JsonObject, draftValue: unknown) { const files = normalizeUploadedFiles(payload.files) const imageUrls = [ @@ -606,9 +673,21 @@ export async function saveWorkerOrderAcceptanceDraft( requireActiveWorkerSession(session) const workOrder = await getRequiredWorkOrder(workOrderId) const workerId = Number(session.workerId) - const sharingShare = await getWorkOrderShare(workOrder.id, workerId) - if (sharingShare) { - return saveWorkerSharingAcceptanceDraft(workOrder, sharingShare, payload, session) + const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload) + if (target.error === 'required') { + throw createHttpError('该工单有多条拼单份额,请指定 shareId', { + statusCode: 400, + errorCode: 'work_order_share_id_required', + }) + } + if (target.error === 'not_found') { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + if (target.share) { + return saveWorkerSharingAcceptanceDraft(workOrder, target.share, payload, session) } if (Number(workOrder.assigned_worker_id || 0) !== workerId) { throw createHttpError('只能操作自己的订单', { @@ -728,6 +807,7 @@ async function saveWorkerSharingAcceptanceDraft( const share = await updateWorkOrderShareAcceptanceRecord({ workOrderId: Number(workOrder.id), workerId: Number(session.workerId), + shareId: Number(sharingShare.id), acceptanceJson: JSON.stringify(acceptance), now, }) @@ -765,6 +845,7 @@ async function saveWorkerSharingAcceptanceDraft( const share = await updateWorkOrderShareDraftAcceptance({ workOrderId: Number(workOrder.id), workerId: Number(session.workerId), + shareId: Number(sharingShare.id), draftJson: JSON.stringify(draft), now, }) diff --git a/apps/backend/src/services/worker-platform/worker-hall-action-service.ts b/apps/backend/src/services/worker-platform/worker-hall-action-service.ts index 4c59b43f..7b12e56b 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-action-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-action-service.ts @@ -30,12 +30,17 @@ import { publishWorkOrderRealtimeChange, } from '../realtime/realtime-event-service.js' -export async function grabWorkerHallOrder(workOrderId: number | string, session: WorkerSession) { +export async function grabWorkerHallOrder( + workOrderId: number | string, + session: WorkerSession, + payload: JsonObject = {}, +) { requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) const permissions = resolveWorkerPermissions(worker) const hallConfig = getWorkerHallConfig() const now = nowIso() + const requestId = String(payload.requestId || '').trim() const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0) const visibleAfterIso = visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : '' @@ -58,6 +63,7 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: hallCandidateLimit: hallConfig.maxVisibleOrders, hallCategoryLimits: hallConfig.categoryMaxVisibleOrders, visibleAfterIso, + ...(requestId ? { requestId } : {}), now, }) if (!result.share) { @@ -68,7 +74,11 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: hallChanged: true, walletChangedWorkerIds: [Number(worker.id)], }) - return { order: mapWorkOrderForWorker(workOrder, permissions, result.share) } + return { + order: mapWorkOrderForWorker(workOrder, permissions, result.share), + share: mapWorkOrderShare(result.share), + duplicated: result.duplicated, + } } const activeCount = await countWorkerActiveOrders(worker.id) @@ -139,6 +149,7 @@ export async function joinWorkerSharingOrder( const visibleAfterIso = visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : '' const quantity = normalizePositiveInteger(payload.quantity, 1) + const requestId = String(payload.requestId || '').trim() const now = nowIso() const result = await joinWorkOrderShare({ workOrderId: Number(workOrderId), @@ -149,6 +160,7 @@ export async function joinWorkerSharingOrder( hallCandidateLimit: hallConfig.maxVisibleOrders, hallCategoryLimits: hallConfig.categoryMaxVisibleOrders, visibleAfterIso, + ...(requestId ? { requestId } : {}), now, }) if (!result.share) { @@ -159,7 +171,7 @@ export async function joinWorkerSharingOrder( hallChanged: true, walletChangedWorkerIds: [Number(worker.id)], }) - return { share: mapWorkOrderShare(result.share) } + return { share: mapWorkOrderShare(result.share), duplicated: result.duplicated } } function resolveWorkOrderDeadlineAt(workOrder: WorkOrderRow, now: string): string | null { 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 4b372edb..fe9cd24a 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 @@ -118,8 +118,14 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker sort: 'worker_claimed_at_desc', }) const workOrderIds = items.map((item) => Number(item.id)) - const myShares = await listWorkerSharesByWorker(worker.id, workOrderIds) - const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share])) + const workerShares = await listWorkerSharesByWorker(worker.id, workOrderIds) + const sharesByOrderId = new Map() + for (const share of workerShares) { + const orderId = Number(share.work_order_id) + const current = sharesByOrderId.get(orderId) || [] + current.push(share) + sharesByOrderId.set(orderId, current) + } const [ notes, views, @@ -152,15 +158,20 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker return { items: items.map((item) => { const workOrderId = Number(item.id) - const activeShare = myShareByOrderId.get(workOrderId) + const activeShares = sharesByOrderId.get(workOrderId) || [] + // 主份额用于列表摘要:优先未完成(joined/submitted),否则最新一条 + const activeShare = + activeShares.find((share) => ['joined', 'submitted'].includes(String(share.status))) || + activeShares[0] || + null const cancelledShare = cancelledShareByOrderId.get(workOrderId) const timeoutEvent = timeoutEventsByOrderId.get(workOrderId) - // 判断当前打手是否仍持有该单 + // 判断当前打手是否仍持有该单(任一有效份额即算持有) const isOwnerCurrently = (item.assigned_worker_id != null && Number(item.assigned_worker_id) === Number(worker.id)) || - Boolean(activeShare) + activeShares.length > 0 // 若不是当前持有人,但有该打手的超时事件或取消份额,则判定为该打手的已超时历史单 const isTimedOutForMe = @@ -211,7 +222,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker activeShare || cancelledShare || null, noteByOrderId.get(workOrderId) || '', viewedAtByOrderId.get(workOrderId) || null, - { isTimedOutForMe, timeoutNotice }, + { isTimedOutForMe, timeoutNotice, myShares: activeShares }, ), // 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。 problemMarked: !isTimedOutForMe && item.status === 'problem', @@ -372,9 +383,13 @@ export async function submitWorkerCancelRequest( }) } const now = nowIso() + const requestedShareId = Number(payload.shareId ?? payload.workOrderShareId ?? 0) + const shareId = + Number.isFinite(requestedShareId) && requestedShareId > 0 ? requestedShareId : null const result = await createWorkerCancelRequest({ workOrderId: Number(workOrderId), workerId: Number(session.workerId), + shareId, reason, now, }) diff --git a/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts index 76c7e604..9655be06 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts @@ -7,6 +7,7 @@ export const WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS = [ 'recharge_requested', 'acceptance_submitted', 'acceptance_reminded', + 'order_accepted', 'material_required', 'cancel_requested', 'feedback_submitted', @@ -48,6 +49,7 @@ export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformN recharge_requested: createEnabledEventConfig(), acceptance_submitted: createEnabledEventConfig(), acceptance_reminded: createEnabledEventConfig(), + order_accepted: createEnabledEventConfig(), material_required: { todoEnabled: false, soundEnabled: false, 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 92899afc..acb6a5aa 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 @@ -18,10 +18,11 @@ import { buildWorkOrderWhere, buildWorkerUserWhere, resolveWorkOrderShareCancellationStatus, - resolveWorkOrderShareDepositAmount, + resolveWorkOrderShareDepositPlan, resolveWorkOrderSharingCancellationPlan, resolveWorkOrderShareJoinQuantity, resolveOpenSharingSyncReward, + recomputeWorkOrderSharingStatusWithClient, } from '../../repositories/worker-platform/index.js' import { resolveProblemReturnToWorkerStatus } from '../../repositories/worker-platform/work-order-issue-repo.js' import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' @@ -1471,25 +1472,63 @@ test('个人免押额度与等级免押额度叠加', () => { ) }) -test('拼单按打手累计份额扣除个人免押额度', () => { - assert.equal( - resolveWorkOrderShareDepositAmount({ +test('拼单按打手在母单内的累计份额扣除免押额度', () => { + // 首次参与 2 份:累计需求 2000,本次冻结 2000 + assert.deepEqual( + resolveWorkOrderShareDepositPlan({ requiredDepositAmount: 10_000, totalQuantity: 10, - nextQuantity: 5, - depositFreeAmount: 3_000, + quantityBefore: 0, + nextQuantity: 2, + depositFreeAmount: 0, }), - 2_000, + { cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 2_000 }, ) - assert.equal( - resolveWorkOrderShareDepositAmount({ + + // 已冻结 1500 后再追加 1 份:累计需求 2000,只补差额 500,不回退 + assert.deepEqual( + resolveWorkOrderShareDepositPlan({ requiredDepositAmount: 10_000, totalQuantity: 10, - nextQuantity: 5, - depositFreeAmount: 8_000, - currentFrozenDeposit: 1_500, + quantityBefore: 1, + nextQuantity: 1, + depositFreeAmount: 0, + frozenDepositSoFar: 1_500, }), - 1_500, + { cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 500 }, + ) +}) + +test('拼单免押额度跟着母单走,在整个工单内只抵扣一次', () => { + const base = { + requiredDepositAmount: 10_000, + totalQuantity: 10, + depositFreeAmount: 5_000, + } + // 累计 2 份:需求 2000 < 免押 5000,不用冻结 + assert.equal( + resolveWorkOrderShareDepositPlan({ ...base, quantityBefore: 0, nextQuantity: 2 }).increment, + 0, + ) + // 累计 4 份:需求 4000 仍低于免押额度,继续不冻结(免押额没有被重复消耗) + assert.deepEqual( + resolveWorkOrderShareDepositPlan({ + ...base, + quantityBefore: 2, + nextQuantity: 2, + frozenDepositSoFar: 0, + }), + { cumulativeRequired: 4_000, cumulativeDeposit: 0, increment: 0 }, + ) + // 累计 6 份:需求 6000 超出免押 5000,仅对超出的 1000 冻结 + assert.deepEqual( + resolveWorkOrderShareDepositPlan({ + ...base, + quantityBefore: 4, + nextQuantity: 2, + frozenDepositSoFar: 0, + }), + { cumulativeRequired: 6_000, cumulativeDeposit: 1_000, increment: 1_000 }, ) }) @@ -1759,7 +1798,10 @@ test('打手订单状态筛选使用个人拼单份额状态', () => { assert.deepEqual(params, [42, ['in_progress', 'pending_acceptance']]) assert.match(joins, /work_order_shares share_source/) - assert.match(joins, /worker_share\.worker_id = \$1/) + // 份额关联改为按工单取一条代表份额,避免多份拼单把列表/统计行放大。 + assert.match(joins, /LEFT JOIN LATERAL \(/) + assert.match(joins, /wos\.worker_id = \$1/) + assert.match(joins, /\) worker_share ON true/) assert.match(whereClause, /WHEN 'joined' THEN 'in_progress'/) assert.match(whereClause, /WHEN 'submitted' THEN 'pending_acceptance'/) assert.match(whereClause, /worker_share\.status/) @@ -3185,3 +3227,131 @@ test('拼单超时处置链路完整性验证', async () => { 'settleOverdueWorkOrderShare 必须将超时份额置为 cancelled 并释放/扣除押金', ) }) + +type ShareAggregate = { + allocated: number + joined_count: number + submitted_count: number + accepted_count: number +} + +async function runRecompute( + workOrder: Partial, + aggregate: ShareAggregate, +): Promise<{ status: string | null; updates: unknown[][]; shareQueries: number }> { + const updates: unknown[][] = [] + let shareQueries = 0 + const client = { + query: async (sql: string, params: unknown[] = []) => { + if (sql.includes('UPDATE work_orders')) { + updates.push(params) + return { rows: [{ ...buildWorkOrderRow(workOrder), status: params[0] }] } + } + if (sql.includes('FROM work_orders')) { + return { rows: [buildWorkOrderRow(workOrder)] } + } + if (sql.includes('FROM work_order_shares')) { + shareQueries += 1 + return { rows: [aggregate] } + } + return { rows: [] } + }, + } as unknown as PoolClient + + const updated = await recomputeWorkOrderSharingStatusWithClient( + client, + 1, + '2026-10-23T12:00:00.000Z', + ) + return { status: updated ? String(updated.status) : null, updates, shareQueries } +} + +test('拼单母单状态由份额集合唯一推导:满额且无待履约份额才转待验收', async () => { + const pending = await runRecompute( + { status: 'open', sharing_total_quantity: 10 }, + { allocated: 10, joined_count: 0, submitted_count: 2, accepted_count: 0 }, + ) + assert.equal(pending.status, 'pending_acceptance') + assert.equal(pending.updates.length, 1) + + // 一份超时后另一份仍在履约(joined_count > 0):母单回到 open,不能提前待验收 + const stillOpen = await runRecompute( + { status: 'pending_acceptance', sharing_total_quantity: 10 }, + { allocated: 9, joined_count: 1, submitted_count: 1, accepted_count: 0 }, + ) + assert.equal(stillOpen.status, 'open') + assert.equal(stillOpen.updates.length, 1) + + // 未满额也必须是 open + const partial = await runRecompute( + { status: 'pending_acceptance', sharing_total_quantity: 10 }, + { allocated: 4, joined_count: 0, submitted_count: 1, accepted_count: 0 }, + ) + assert.equal(partial.status, 'open') +}) + +test('拼单母单已验收或已取消时 recompute 不回头改状态', async () => { + for (const status of ['accepted', 'cancelled']) { + const result = await runRecompute( + { status, sharing_total_quantity: 10 }, + { allocated: 10, joined_count: 0, submitted_count: 0, accepted_count: 3 }, + ) + assert.equal(result.status, status) + assert.equal(result.updates.length, 0) + assert.equal(result.shareQueries, 0, '终态母单连份额聚合都不该再跑') + } +}) + +test('份额超时/撤单只改份额本身,母单状态统一交给 recompute', () => { + const src = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url), + 'utf8', + ) + const blocks = [ + [ + 'export async function cancelWorkOrderShare(', + 'export async function settleOverdueWorkOrderShare(', + ], + [ + 'export async function settleOverdueWorkOrderShare(', + 'export async function cancelWorkOrderSharing(', + ], + ] + for (const [start, end] of blocks) { + const block = src.slice(src.indexOf(start!), src.indexOf(end!)) + assert.ok(block.length > 0, `${start} 片段必须存在`) + assert.ok( + block.includes('recomputeWorkOrderSharingStatusWithClient('), + `${start} 必须用 recompute 收敛母单状态`, + ) + assert.ok( + !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(block), + `${start} 不得自己写母单 status(只能由 recompute 推导)`, + ) + } +}) + +test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => { + const src = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok( + src.includes('wos.worker_id = $1 AND wos.request_id = $2'), + '参与前必须按 (worker_id, request_id) 查既有份额', + ) + assert.ok(src.includes('duplicated: true'), '命中幂等键必须返回 duplicated 标记') + assert.ok( + src.includes('INSERT INTO work_order_shares (') && + !/UPDATE\s+work_order_shares[\s\S]{0,120}?quantity\s*=\s*quantity\s*\+/i.test(src), + '每次参与都必须 INSERT 新份额,不得把份数加到原记录上', + ) + assert.ok( + src.includes('timeoutMinutes * 60 * 1000') && src.includes('deadline_at, timeout_minutes'), + 'deadline 必须按本次参与时间 + 母单当时的 timeout_minutes 固化', + ) + assert.ok( + src.includes('quantityBefore') && src.includes('frozenDepositSoFar: frozenBefore'), + '押金必须按该打手在本工单的累计份数与已冻结合计计算增量', + ) +}) diff --git a/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx b/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx index 0899d2ba..6ce53f64 100644 --- a/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx @@ -23,6 +23,11 @@ const notificationEventRows: NotificationEventRow[] = [ { key: 'recharge_requested', name: '充值申请', description: '打手提交新的充值申请' }, { key: 'acceptance_submitted', name: '提交验收', description: '打手提交订单验收资料' }, { key: 'acceptance_reminded', name: '催验收', description: '打手在验收后催促处理' }, + { + key: 'order_accepted', + name: '整单验收完成', + description: '全部份额验收完成(含拼单统一验收)', + }, { key: 'material_required', name: '待补资料工单', description: '新工单等待后台补充资料' }, { key: 'cancel_requested', diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 6803c29e..5c30d637 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -114,6 +114,7 @@ import { asRecord, formatMoney, formatSharingShareStatus, + formatSharingShareCancelledReason, formatStatus, resolveGiftPhaseColor, resolveSharingShareStatusColor, @@ -3145,6 +3146,7 @@ export default function WorkOrdersPanel() { dataSource={sharingShares} loading={sharingQuery.isFetching} pagination={false} + scroll={{ x: 1280 }} columns={[ { title: '打手', @@ -3159,11 +3161,38 @@ export default function WorkOrdersPanel() { { title: '状态', render: (_, share) => ( - - {formatSharingShareStatus(share.status)} - + + + {formatSharingShareStatus(share.status)} + + {share.status === 'cancelled' && share.cancelledReason ? ( + {formatSharingShareCancelledReason(share.cancelledReason)} + ) : null} + ), }, + { + title: '参与时间', + render: (_, share) => + share.createdAt ? formatAdminDateTime(share.createdAt) : '-', + }, + { + title: '截止时间', + render: (_, share) => + share.deadlineAt ? ( + + {formatAdminDateTime(share.deadlineAt)} + {share.timeoutMinutes ? ` · ${share.timeoutMinutes} 分钟` : null} + + ) : ( + 不限时 + ), + }, + { + title: '超时策略', + render: (_, share) => + share.timeoutPolicy ? formatTimeoutPolicyLabel(share.timeoutPolicy) : '-', + }, { title: '提交时间', render: (_, share) => diff --git a/apps/frontend/src/pages/admin/panels/shared.tsx b/apps/frontend/src/pages/admin/panels/shared.tsx index 5e7f72b7..30c64f90 100644 --- a/apps/frontend/src/pages/admin/panels/shared.tsx +++ b/apps/frontend/src/pages/admin/panels/shared.tsx @@ -252,6 +252,16 @@ export function formatSharingShareStatus(status: string) { return labels[status] || status } +/** 撤单原因:份额级独立撤单后,后台需要一眼看出是谁撤的。 */ +export function formatSharingShareCancelledReason(reason: string) { + const labels: Record = { + timeout: '超时失效', + admin: '后台撤单', + worker_request: '打手退单', + } + return labels[reason] || reason +} + export function formatAcceptanceMode(mode: string) { if (mode === 'friend_gift') return '好友赠送' return '标准验收' diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index 3fa0f6e5..59be1699 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -66,6 +66,7 @@ import { resolveHallRewardAmount, } from '@/utils/worker-product-name' import { formatDateTime, formatPublishedElapsedTime } from '@/utils/date-time' +import { createRequestId } from '@/utils/request-id' type HallSummary = { pendingAcceptanceCount: number @@ -314,7 +315,7 @@ export default function WorkerHallPage() { async function submitGrab(order: WorkOrder) { setGrabbingId(order.workOrderId) try { - await grabWorkerOrder(order.workOrderId) + await grabWorkerOrder(order.workOrderId, { requestId: createRequestId('grab') }) message.success('抢单成功,已进入我的订单') await refreshAll() } catch (error) { @@ -335,6 +336,7 @@ export default function WorkerHallPage() { try { await joinWorkerSharingOrder(sharingOrder.workOrderId, { quantity: sharingQuantity, + requestId: createRequestId('share'), }) message.success('拼单成功,已进入我的订单') setSharingOrder(null) @@ -346,6 +348,15 @@ export default function WorkerHallPage() { } } + /** 我在该工单的份额摘要:参与次数 + 累计份数 */ + function resolveMyShareSummary(order: WorkOrder): { count: number; quantity: number } { + const shares = order.myShares?.length ? order.myShares : order.myShare ? [order.myShare] : [] + return { + count: shares.length, + quantity: shares.reduce((total, share) => total + Number(share.quantity || 0), 0), + } + } + function resolveSharingRemaining(order: WorkOrder): number { return resolveHallRemainingQuantity(order) } @@ -700,18 +711,18 @@ export default function WorkerHallPage() {
- {order.sharing?.enabled && - (sharingRemaining > 0 || order.myShare) && - !disabledReason ? ( + {order.sharing?.enabled && sharingRemaining > 0 && !disabledReason ? ( ) : null} @@ -1152,18 +1163,18 @@ export default function WorkerHallPage() {
- {order.sharing?.enabled && - (sharingRemaining > 0 || order.myShare) && - !disabledReason ? ( + {order.sharing?.enabled && sharingRemaining > 0 && !disabledReason ? ( ) : null} diff --git a/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx b/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx index c5c0f68b..e50630f0 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx @@ -8,6 +8,7 @@ import { } from '@ant-design/icons' import { Alert, Button, Card, Empty, Input, Space, Table, Tabs, Typography } from 'antd' import type { TableColumnsType } from 'antd' +import type { ReactNode } from 'react' import type { Pagination, WorkerOrderOverviewItem, WorkOrder } from '@/types/worker-platform' @@ -29,6 +30,7 @@ export function WorkerOrderDesktopList({ page, pageSize, columns, + renderExpandedRow, loading, searching, refreshing, @@ -49,6 +51,7 @@ export function WorkerOrderDesktopList({ page: number pageSize: number columns: TableColumnsType + renderExpandedRow?: (order: WorkOrder) => ReactNode loading: boolean searching: boolean refreshing: boolean @@ -262,6 +265,15 @@ export function WorkerOrderDesktopList({ dataSource={orders} columns={columns} className="worker-orders-table" + expandable={ + renderExpandedRow + ? { + expandedRowRender: renderExpandedRow, + rowExpandable: (order) => (order.myShares?.length || 0) > 1, + columnWidth: 42, + } + : undefined + } locale={{ emptyText: error ? ( diff --git a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx index 2327befc..537abb5e 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx @@ -7,7 +7,8 @@ import { ReloadOutlined, SearchOutlined, } from '@ant-design/icons' -import { Alert, Button, Empty, Input, Pagination, Spin, Tag } from 'antd' +import { Alert, Button, Empty, Input, Pagination, Spin, Tag, Typography } from 'antd' +import type { ReactNode } from 'react' import { DeadlineCountdown } from '@/components/DeadlineCountdown' import ImagePreviewList from '@/components/files/ImagePreviewList' @@ -53,6 +54,7 @@ export function WorkerOrderMobileList({ pagination, page, pageSize, + renderShareTable, loading, searching, refreshing, @@ -81,6 +83,7 @@ export function WorkerOrderMobileList({ pagination: PaginationData | undefined page: number pageSize: number + renderShareTable?: (order: WorkOrder) => ReactNode loading: boolean searching: boolean refreshing: boolean @@ -173,6 +176,7 @@ export function WorkerOrderMobileList({ - {order.myShare ? ( + {order.myShare || order.myShares?.length ? ( - 已拼 {order.myShare.quantity} 份 ({formatMoney(order.myShare.shareReward)}) + 已参与 {order.myShares?.length || 1} 次 · 共{' '} + {order.myShares?.reduce((total, share) => total + share.quantity, 0) || + order.myShare?.quantity || + 0}{' '} + 份 ) : null} {formatDateTime(order.assignedAt)}
+ {(order.myShares?.length || 0) > 1 && renderShareTable ? ( +
+ + 拼单明细({order.myShares?.length} 份) + + {renderShareTable(order)} +
+ ) : null} + {order.status === 'problem' && String(order.problemNote || '').trim() ? (
问题: diff --git a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx index e1e84592..461e602c 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx @@ -83,7 +83,9 @@ export function buildWorkerOrderTableColumns({ {row.categoryName ? {row.categoryName} : null} - {row.myShare ? 拼单 : null} + {row.myShares?.length || row.myShare ? ( + 拼单 {row.myShares?.length || 1} 次 + ) : null} 押金:{formatMoney(row.freezeDepositAmount)} - {row.myShare ? ( + {row.myShare || row.myShares?.length ? ( - {row.myShare.quantity} 份 · {formatMoney(row.myShare.shareReward)} + {row.myShares?.length || 1} 次 ·{' '} + {row.myShares?.reduce((total, share) => total + share.quantity, 0) || + row.myShare?.quantity || + 0}{' '} + 份 ) : null}
@@ -239,48 +245,57 @@ export function buildWorkerOrderTableColumns({ { title: '操作', width: 150, - render: (_, row) => ( -
- - - - ) : null} - {canEditAcceptanceImages(row) ? ( - - ) : null} - {canRemindAcceptance(row) ? ( - - ) : null} - {canRequestCancel(row) ? ( - - ) : null} - {canSubmitFeedback(row) ? ( - - + render: (_, row) => { + const shareCount = row.myShares?.length || (row.myShare ? 1 : 0) + const hasMultipleShares = shareCount > 1 + return ( +
+ + +
- ), + {canSubmitGiftMaterial(row) ? ( + + ) : null} + {!hasMultipleShares && canEditAcceptanceImages(row) ? ( + + ) : null} + {!hasMultipleShares && canRemindAcceptance(row) ? ( + + ) : null} + {!hasMultipleShares && canRequestCancel(row) ? ( + + ) : null} + {hasMultipleShares ? ( + + 展开行查看 {shareCount} 份份额操作 + + ) : null} + {canSubmitFeedback(row) ? ( + + + + ) : null} +
+
+ ) + }, }, ] } diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 9b6b9a49..50fd61c0 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router' -import { App, Badge, Button, Form, Space, Typography } from 'antd' +import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd' import { useState } from 'react' import { @@ -22,11 +22,13 @@ import { } from '@/services/worker' import type { UploadedFile, WorkOrder } from '@/types/worker-platform' import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders' +import { formatDateTime } from '@/utils/date-time' import { useIsMobile } from '@/utils/use-is-mobile' import { buildWorkerOrderQuery } from '@/utils/worker-order-query' import { WorkerOrderDesktopList } from './WorkerOrderDesktopList' import { WorkerOrderDetailDrawer } from './WorkerOrderDetailDrawer' import { WorkerOrderMobileList } from './WorkerOrderMobileList' +import { WorkerOrderShareTable } from './WorkerOrderShareTable' import { type AcceptanceFormValues, type CancelRequestFormValues, @@ -41,6 +43,7 @@ import { canSupplementAcceptedEvidence, getCopyOrderInfoItems, getMaterialScreenshots, + getOrderShares, getUniqueAcceptanceFiles, getWorkerAcceptance, getWorkerDraft, @@ -165,6 +168,15 @@ export default function WorkerOrdersPage() { const [pageSize, setPageSize] = useState(20) const [detailOrder, setDetailOrder] = useState(null) const [editingOrder, setEditingOrder] = useState(null) + /** 本次验收/补图针对的份额;null = 整单 */ + const [editingShareId, setEditingShareId] = useState(null) + /** 多份额时先让打手选择要操作哪一份 */ + const [sharePicker, setSharePicker] = useState<{ + order: WorkOrder + action: 'accept' | 'cancel' + } | null>(null) + const [pickedShareId, setPickedShareId] = useState(0) + const [cancelShareId, setCancelShareId] = useState(null) const [noteOrder, setNoteOrder] = useState(null) const [cancelRequestOrder, setCancelRequestOrder] = useState(null) const [feedbackOrder, setFeedbackOrder] = useState(null) @@ -292,10 +304,41 @@ export default function WorkerOrdersPage() { } } + /** 把订单对象替换成「指定份额」视角,让验收弹窗读写该份额的验收/草稿。 */ + function buildShareEditingOrder(order: WorkOrder, shareId: number | null): WorkOrder { + if (!shareId) return order + const share = getOrderShares(order).find((item) => item.shareId === shareId) + if (!share) return order + return { + ...order, + myShare: share, + // 已验收的份额走补图分支;其它份额沿用母单状态。 + status: share.status === 'accepted' ? 'accepted' : order.status, + } + } + function openAcceptanceModal(order: WorkOrder) { markOrderAsRead(order) - setEditingOrder(order) - if (canSupplementAcceptedEvidence(order)) { + const shares = getOrderShares(order) + if (shares.length > 1) { + setPickedShareId(shares[0]?.shareId || 0) + setSharePicker({ order, action: 'accept' }) + return + } + openAcceptanceWithShare(order, shares[0]?.shareId ?? null) + } + + /** 子表里直接对某一份份额发起验收/补图。 */ + function openAcceptanceForShare(order: WorkOrder, shareId: number) { + markOrderAsRead(order) + openAcceptanceWithShare(order, shareId) + } + + function openAcceptanceWithShare(order: WorkOrder, shareId: number | null) { + const editingTarget = buildShareEditingOrder(order, shareId) + setEditingShareId(shareId) + setEditingOrder(editingTarget) + if (canSupplementAcceptedEvidence(editingTarget)) { // 已验收补图只上传新增文件,原有验收图片由服务端保留。 setAcceptanceFiles([]) form.resetFields() @@ -303,9 +346,9 @@ export default function WorkerOrdersPage() { return } // 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容 - const source = hasSubmittedAcceptance(order) - ? getWorkerAcceptance(order) - : getWorkerDraft(order) + const source = hasSubmittedAcceptance(editingTarget) + ? getWorkerAcceptance(editingTarget) + : getWorkerDraft(editingTarget) setAcceptanceFiles(getUniqueAcceptanceFiles(source)) form.setFieldsValue({ note: String(source?.note || '') }) setDetailOrder(null) @@ -356,6 +399,21 @@ export default function WorkerOrdersPage() { function openCancelRequestModal(order: WorkOrder) { markOrderAsRead(order) + const shares = getOrderShares(order) + if (shares.length > 1) { + setPickedShareId(shares[0]?.shareId || 0) + setSharePicker({ order, action: 'cancel' }) + return + } + setCancelShareId(shares[0]?.shareId ?? null) + setCancelRequestOrder(order) + cancelRequestForm.resetFields() + } + + /** 子表里直接对某一份份额申请取消。 */ + function openCancelRequestForShare(order: WorkOrder, shareId: number) { + markOrderAsRead(order) + setCancelShareId(shareId) setCancelRequestOrder(order) cancelRequestForm.resetFields() } @@ -365,6 +423,7 @@ export default function WorkerOrdersPage() { try { await submitWorkerCancelRequest(cancelRequestOrder.workOrderId, { reason: String(values.reason || '').trim(), + shareId: cancelShareId, }) message.success('取消申请已提交,等待客服审核') setCancelRequestOrder(null) @@ -430,6 +489,7 @@ export default function WorkerOrdersPage() { try { await supplementWorkerAcceptedOrderEvidence(editingOrder.workOrderId, { files: acceptanceFiles, + ...(editingShareId ? { shareId: editingShareId } : {}), }) message.success('验收图片已补充') setEditingOrder(null) @@ -456,6 +516,7 @@ export default function WorkerOrdersPage() { await submitWorkerAcceptance(editingOrder.workOrderId, { note: values.note, files: acceptanceFiles, + ...(editingShareId ? { shareId: editingShareId } : {}), }) message.success(canAutoAccept ? '已自动验收,可在 24 小时内补充图片' : '已提交验收') setEditingOrder(null) @@ -477,6 +538,7 @@ export default function WorkerOrdersPage() { await saveWorkerAcceptanceDraft(editingOrder.workOrderId, { note: values.note, files: acceptanceFiles, + ...(editingShareId ? { shareId: editingShareId } : {}), }) message.success( hasSubmittedAcceptance(editingOrder) ? '验收图片已更新' : '验收图片已暂存,可继续补充', @@ -501,6 +563,20 @@ export default function WorkerOrdersPage() { setPage(1) } + function renderShareTable(order: WorkOrder) { + return ( + { + void submitAcceptanceReminder(target) + }} + /> + ) + } + const columns = buildWorkerOrderTableColumns({ onOpenDetail: openOrderDetail, onOpenAcceptance: openAcceptanceModal, @@ -562,6 +638,7 @@ export default function WorkerOrdersPage() { pagination={pagination} page={page} pageSize={pageSize} + renderShareTable={renderShareTable} loading={ordersQuery.isLoading} searching={ordersQuery.isFetching} refreshing={ordersQuery.isFetching || overviewQuery.isFetching} @@ -611,6 +688,7 @@ export default function WorkerOrdersPage() { page={page} pageSize={pageSize} columns={columns} + renderExpandedRow={renderShareTable} loading={ordersQuery.isLoading} searching={ordersQuery.isFetching} refreshing={ordersQuery.isFetching || overviewQuery.isFetching} @@ -695,6 +773,57 @@ export default function WorkerOrdersPage() { onGiftMaterialFilesChange={setGiftMaterialFiles} onSubmitGiftMaterial={submitGiftMaterial} /> + + setSharePicker(null)} + onOk={() => { + if (!sharePicker) return + if (sharePicker.action === 'accept') { + openAcceptanceWithShare(sharePicker.order, pickedShareId || null) + } else { + setCancelShareId(pickedShareId || null) + setCancelRequestOrder(sharePicker.order) + cancelRequestForm.resetFields() + } + setSharePicker(null) + }} + > + setPickedShareId(Number(event.target.value))} + style={{ display: 'flex', flexDirection: 'column', gap: 10 }} + > + {sharePicker && sharePicker.action === 'cancel' ? ( + 全部有效份额(整单退单) + ) : null} + {sharePicker + ? getOrderShares(sharePicker.order).map((share) => ( + + + #{share.shareId} + {share.quantity} 份 + + {share.status === 'joined' + ? '进行中' + : share.status === 'submitted' + ? '待验收' + : share.status === 'accepted' + ? '已验收' + : share.status} + + + {share.deadlineAt ? `${formatDateTime(share.deadlineAt)} 截止` : '不限时'} + + + + )) + : null} + + ) } diff --git a/apps/frontend/src/pages/worker/worker-order-utils.tsx b/apps/frontend/src/pages/worker/worker-order-utils.tsx index e9234e02..ab35d8e8 100644 --- a/apps/frontend/src/pages/worker/worker-order-utils.tsx +++ b/apps/frontend/src/pages/worker/worker-order-utils.tsx @@ -6,6 +6,7 @@ import type { UploadedFile, WorkerAcceptance, WorkOrder, + WorkOrderShare, } from '@/types/worker-platform' import { getImageIdentity } from '@/utils/image-identity' @@ -33,6 +34,13 @@ export function patchWorkOrderReadState(current: unknown, workOrderId: number) { } } +/** 同一打手在该工单的全部有效份额;单份额订单回退为 myShare 一条。 */ +export function getOrderShares(order: WorkOrder | null | undefined): WorkOrderShare[] { + if (!order) return [] + if (order.myShares?.length) return order.myShares + return order.myShare ? [order.myShare] : [] +} + /** 好友赠送验收模式(整单,不参与拼单)。 */ export function isFriendGiftOrder(order: WorkOrder) { return order.acceptanceMode === 'friend_gift' && !order.myShare diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 4a77183e..31a484bf 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -220,12 +220,18 @@ export function fetchWorkerHallLeaderboard(period: WorkerLeaderboardPeriod = 'al return apiGet('/api/v1/worker/hall/leaderboard', { period }) } -export function grabWorkerOrder(workOrderId: number) { - return apiPost<{ order: WorkOrder }>(`/api/v1/worker/hall/orders/${workOrderId}/grab`) +export function grabWorkerOrder(workOrderId: number, payload: { requestId?: string } = {}) { + return apiPost<{ order: WorkOrder; duplicated?: boolean }>( + `/api/v1/worker/hall/orders/${workOrderId}/grab`, + payload, + ) } -export function joinWorkerSharingOrder(workOrderId: number, payload: { quantity: number }) { - return apiPost<{ share: WorkOrderShare }>( +export function joinWorkerSharingOrder( + workOrderId: number, + payload: { quantity: number; requestId?: string }, +) { + return apiPost<{ share: WorkOrderShare; duplicated?: boolean }>( `/api/v1/worker/hall/orders/${workOrderId}/join-sharing`, payload, ) @@ -249,7 +255,10 @@ export function fetchWorkerCancelRequests() { ) } -export function submitWorkerCancelRequest(workOrderId: number, payload: { reason: string }) { +export function submitWorkerCancelRequest( + workOrderId: number, + payload: { reason: string; shareId?: number | null }, +) { return apiPost<{ request: WorkerCancelRequest; risk: WorkerCancelRequestRisk }>( `/api/v1/worker/orders/${workOrderId}/cancel-requests`, payload, @@ -294,9 +303,9 @@ export function saveWorkerOrderNote(workOrderId: number, note: string) { export function submitWorkerAcceptance( workOrderId: number, - payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] }, + payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number }, ) { - return apiPost<{ order: WorkOrder }>( + return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/submit-acceptance`, payload, ) @@ -321,9 +330,9 @@ export function remindWorkerAcceptance(workOrderId: number) { export function saveWorkerAcceptanceDraft( workOrderId: number, - payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] }, + payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number }, ) { - return apiPost<{ order: WorkOrder }>( + return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/acceptance-draft`, payload, ) @@ -331,9 +340,9 @@ export function saveWorkerAcceptanceDraft( export function supplementWorkerAcceptedOrderEvidence( workOrderId: number, - payload: { files: UploadedFile[] }, + payload: { files: UploadedFile[]; shareId?: number }, ) { - return apiPost<{ order: WorkOrder }>( + return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/acceptance-evidence`, payload, ) diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index b0dbb020..49d5ac0f 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -4853,3 +4853,26 @@ body { padding-top: 6px; border-top: 1px dashed #bfdbfe; } + +/* 我的订单:一行一工单,展开后的份额子表 */ +.worker-orders-table .ant-table-expanded-row > td { + background: #fafafa; +} + +.worker-order-share-table .ant-table { + font-size: 12px; +} + +.worker-order-share-table .ant-table-thead > tr > th { + background: #f5f5f5; + font-size: 12px; +} + +.worker-order-mobile-shares { + display: flex; + flex-direction: column; + gap: 6px; + margin: 8px 12px 4px; + padding-top: 8px; + border-top: 1px dashed #f0f0f0; +} diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 8d70feb1..0bbcc961 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -297,6 +297,7 @@ export type WorkerPlatformNotificationEventKey = | 'recharge_requested' | 'acceptance_submitted' | 'acceptance_reminded' + | 'order_accepted' | 'material_required' | 'cancel_requested' | 'feedback_submitted' @@ -539,6 +540,11 @@ export type WorkOrderShare = { acceptedAt: string | null createdAt: string updatedAt: string + /** 本份额独立截止时间(参与时固化);null = 不限时 */ + deadlineAt?: string | null + timeoutMinutes?: number + timeoutPolicy?: string + cancelledReason?: string worker: null | { workerId: number username: string @@ -620,6 +626,8 @@ export type WorkOrder = { freezeDepositAmount?: number sharing?: WorkOrderSharingConfig myShare?: WorkOrderShare | null + /** 同一打手在该工单的全部有效份额(一行一工单 + 展开份额) */ + myShares?: WorkOrderShare[] sharingProgress?: { joinedQuantity: number pendingSubmissionCount: number