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 01/17] =?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 From 1d5615f1ae1a5b7cb543e7e7f1f35d9fdf23857c Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 23 Oct 2026 15:14:07 +0800 Subject: [PATCH 02/17] =?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 --- .../103_work_order_shop_filter_index.sql | 9 + ...4_work_order_share_multi_participation.sql | 64 ++ .../share-summary-scope.test.ts | 99 +- .../worker-platform/work-order-query-repo.ts | 249 +++-- .../worker-active-order-count.test.ts | 49 + .../realtime/realtime-event-service.ts | 127 ++- .../src/services/worker-platform/mappers.ts | 3 + .../work-order-display-mappers.test.ts | 220 +++++ .../work-order-display-mappers.ts | 141 +++ .../worker-my-order-service.ts | 24 +- .../pages/worker/WorkerOrderShareTable.tsx | 213 +++++ .../src/pages/worker/WorkerOrdersPage.tsx | 4 +- apps/frontend/src/utils/request-id.ts | 8 + .../src/utils/worker-realtime-work-orders.ts | 15 +- .../tests/worker-realtime-work-orders.test.ts | 92 ++ docs/工单拼单多次参与改造方案-2026-10-23.md | 466 ++++++++++ ...单深挖-抢单拼单好友赠送结算-2026-10-23.md | 467 ++++++++++ docs/项目全流程与数据库设计梳理-2026-10-23.md | 874 ++++++++++++++++++ 18 files changed, 2996 insertions(+), 128 deletions(-) create mode 100644 apps/backend/src/db/migrations/103_work_order_shop_filter_index.sql create mode 100644 apps/backend/src/db/migrations/104_work_order_share_multi_participation.sql create mode 100644 apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts create mode 100644 apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts create mode 100644 apps/frontend/src/pages/worker/WorkerOrderShareTable.tsx create mode 100644 apps/frontend/src/utils/request-id.ts create mode 100644 docs/工单拼单多次参与改造方案-2026-10-23.md create mode 100644 docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md create mode 100644 docs/项目全流程与数据库设计梳理-2026-10-23.md diff --git a/apps/backend/src/db/migrations/103_work_order_shop_filter_index.sql b/apps/backend/src/db/migrations/103_work_order_shop_filter_index.sql new file mode 100644 index 00000000..1238cbbd --- /dev/null +++ b/apps/backend/src/db/migrations/103_work_order_shop_filter_index.sql @@ -0,0 +1,9 @@ +-- 后台「接单工单 → 店铺」筛选:按订单归属店铺精确匹配(可多选)。 +-- 过滤器写法是 BTRIM(o.shop_name) = ANY($n::text[]),普通 btree 无法命中表达式, +-- 所以补一个与查询同口径的表达式索引;部分索引省掉空店铺的无效条目。 +CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed + ON orders (BTRIM(shop_name)) + WHERE BTRIM(shop_name) <> ''; + +COMMENT ON INDEX idx_orders_shop_name_trimmed IS + '支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选'; diff --git a/apps/backend/src/db/migrations/104_work_order_share_multi_participation.sql b/apps/backend/src/db/migrations/104_work_order_share_multi_participation.sql new file mode 100644 index 00000000..6101ac12 --- /dev/null +++ b/apps/backend/src/db/migrations/104_work_order_share_multi_participation.sql @@ -0,0 +1,64 @@ +-- 104_work_order_share_multi_participation.sql +-- 目标:同一打手可对同一拼单工单多次参与,每次参与一条 work_order_shares; +-- 每份在参与时固化截止时间与超时策略;超时/验收/撤单按份额独立。 +-- +-- 说明: +-- 1. 只加列 + 回填 + 删「一人一条」唯一键 + 建索引,不拆分历史行、不改历史资金。 +-- 2. 历史合并份额(quantity > 1)保持原样,结算公式 quantity × unit_reward 完全兼容。 +-- 3. request_id 为客户端幂等键,唯一索引只约束非空值。 + +ALTER TABLE work_order_shares + ADD COLUMN IF NOT EXISTS deadline_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS timeout_minutes INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS timeout_policy TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS cancelled_reason TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT ''; + +COMMENT ON COLUMN work_order_shares.deadline_at IS '本份额截止时间:参与时 = created_at + 母单当时 timeout_minutes;0 分钟为 NULL'; +COMMENT ON COLUMN work_order_shares.timeout_minutes IS '参与时固化的时限(分钟),母单后续改配置不影响'; +COMMENT ON COLUMN work_order_shares.timeout_policy IS '参与时固化的超时策略 reopen/cancel_release/cancel_deduct'; +COMMENT ON COLUMN work_order_shares.cancelled_reason IS '取消来源:timeout/worker_request/admin/problem/settlement'; +COMMENT ON COLUMN work_order_shares.request_id IS '客户端幂等键(一次点击一个),重复请求返回原份额'; + +-- 回填历史份额:按当时母单配置一次性固化 +UPDATE work_order_shares s +SET timeout_minutes = COALESCE(w.timeout_minutes, 0), + timeout_policy = COALESCE(NULLIF(w.timeout_policy, ''), 'reopen'), + deadline_at = CASE + WHEN COALESCE(w.timeout_minutes, 0) > 0 + THEN s.created_at + (w.timeout_minutes || ' minutes')::interval + ELSE NULL + END +FROM work_orders w +WHERE w.id = s.work_order_id + AND s.timeout_minutes = 0; + +-- 删除「同一工单同一打手只能一条」约束(否则第二次参与报 23505) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'work_order_shares_work_order_id_worker_id_key' + ) THEN + ALTER TABLE work_order_shares + DROP CONSTRAINT work_order_shares_work_order_id_worker_id_key; + END IF; +END $$; + +-- 到期扫描:只覆盖 joined 份额,走部分索引 +CREATE INDEX IF NOT EXISTS idx_work_order_shares_due + ON work_order_shares (deadline_at) + WHERE status = 'joined'; + +-- 打手在某工单的份额列表(我的订单展开、押金累计求和) +CREATE INDEX IF NOT EXISTS idx_work_order_shares_worker_order + ON work_order_shares (worker_id, work_order_id, created_at DESC); + +-- 母单份额集合推导(recompute 母单状态、剩余份数) +CREATE INDEX IF NOT EXISTS idx_work_order_shares_order_status + ON work_order_shares (work_order_id, status, created_at ASC); + +-- 幂等键:同一打手同一个 request_id 只能产生一条份额 +CREATE UNIQUE INDEX IF NOT EXISTS uq_work_order_shares_worker_request + ON work_order_shares (worker_id, request_id) + WHERE request_id <> ''; 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 add275bf..c4c40ad6 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 @@ -66,8 +66,25 @@ test('大厅候选集(buildHallCandidateIdsSql)不再聚合整表拼单', as test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => { const raw = await readRepoSource() const source = stripComments(raw) + + // 我的订单概览里按「工单 + 份额状态」聚合金额,必须限定到当前打手; + // 它不是整表聚合,先单独摘出来校验,避免被下面的全表聚合守卫误判。 + const shareStatusStart = source.indexOf('share_status_rows AS (') + const shareStatusEnd = source.indexOf('order_status_rows AS (') + const workerShareAggregation = + shareStatusStart >= 0 && shareStatusEnd > shareStatusStart + ? source.slice(shareStatusStart, shareStatusEnd) + : '' + assert.ok(workerShareAggregation, '缺少我的订单概览的份额状态聚合') + + const sourceWithoutWorkerShareAggregation = + shareStatusStart >= 0 && shareStatusEnd > shareStatusStart + ? source.slice(0, shareStatusStart) + source.slice(shareStatusEnd) + : source const globalAggregations = [ - ...source.matchAll(/GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g), + ...sourceWithoutWorkerShareAggregation.matchAll( + /GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g, + ), ] assert.equal( @@ -78,15 +95,28 @@ test('全仓库只允许保留一处「整表拼单聚合」,且必须是无 ) const onlyRemaining = globalAggregations[0]! - const around = source.slice(Math.max(0, onlyRemaining.index - 900), onlyRemaining.index + 200) + const around = sourceWithoutWorkerShareAggregation.slice( + Math.max(0, (onlyRemaining.index ?? 0) - 900), + (onlyRemaining.index ?? 0) + 200, + ) assert.ok( around.includes('operational_share_summary'), '保留的那处整表聚合不在 operational_share_summary 分支里,需要确认是否有意为之', ) - // 注释在 stripComments 里被去掉了,所以断言理由时用原文 - const rawIndex = raw.indexOf('GROUP BY wos.work_order_id') - const rawAround = raw.slice(Math.max(0, rawIndex - 1200), rawIndex + 200) + // 注释在 stripComments 里被去掉了,所以断言理由时用原文。 + // 注意:raw 里前面还有 share_status_rows 的工单内聚合,必须找 operational 那一处。 + const rawMatches = [...raw.matchAll(/GROUP BY\s+wos\.work_order_id/g)] + const operationalRaw = rawMatches.find((match) => + raw + .slice(Math.max(0, (match.index ?? 0) - 1200), (match.index ?? 0) + 200) + .includes('operational_share_summary'), + ) + assert.ok(operationalRaw, '找不到 operational_share_summary 的整表聚合') + const rawAround = raw.slice( + Math.max(0, (operationalRaw.index ?? 0) - 1200), + (operationalRaw.index ?? 0) + 200, + ) assert.ok( rawAround.includes('无打手范围时一次聚合全部拼单'), '保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删', @@ -105,18 +135,67 @@ test('我的订单的份额关联按工单取一条,避免多份额把列表 test('我的订单概览按工单计数,金额仍按份额求和', async () => { const source = stripComments(await readRepoSource()) - const start = source.indexOf('worker_base AS (') - assert.ok(start >= 0, '缺少我的订单概览的 worker_base CTE') + const start = source.indexOf('order_flags AS (') + assert.ok(start >= 0, '缺少我的订单概览的 order_flags CTE') - const block = source.slice(start, start + 2200) - assert.match(block, /LEFT JOIN LATERAL \(/, '概览的份额关联没有按工单取一条') - assert.match(block, /share_reward_total/, '概览没有汇总多份份额的奖励金额') + const block = source.slice(start, start + 4200) + assert.match(block, /share_status_rows AS \(/, '概览没有按份额状态拆分金额') + assert.match(block, /order_status_rows AS \(/, '概览没有聚合成工单状态行') + assert.match(block, /SUM\(wos\.share_reward\)/, '概览没有汇总份额奖励') + assert.match( + block, + /GROUP BY wos\.work_order_id, wos\.status/, + '份额金额没有按「工单 + 份额状态」聚合', + ) assert.ok( !block.includes('LEFT JOIN work_order_shares wos'), '概览仍在直接 JOIN 份额表:同一工单 3 份会被统计成 3 单', ) }) +test('多份额混合状态:任一状态命中即可出现在对应页签', async () => { + const source = stripComments(await readRepoSource()) + + assert.ok(source.includes("ws_filter.status = 'submitted'"), '待验收筛选没有检查任一份额') + assert.ok(source.includes("'pending_acceptance' = ANY"), '待验收状态没有参与任一份额匹配') +}) + +/** + * 概览与列表必须是同一把尺子:列表里「待审核取消申请/未处理反馈」只把进行中的份额 + * 归入问题单,已提交待验收的份额照旧留在待验收。 + * 概览旧写法把这类工单的份额整体排除、再由第二分支记成问题单,于是出现 + * 「问题单卡片有数字、点进去列表是空的」「待验收列表有单、卡片不计数」。 + */ +test('概览与列表同口径:待处理申请只把进行中份额归问题单', async () => { + const source = stripComments(await readRepoSource()) + + const shareStart = source.indexOf('share_status_rows AS (') + assert.ok(shareStart >= 0, '缺少 share_status_rows CTE') + const shareBlock = source.slice(shareStart, shareStart + 1600) + assert.match( + shareBlock, + /WHEN wos\.status = 'joined' AND f\.has_pending_handling THEN 'problem'/, + '待处理申请只应把进行中的份额归问题单', + ) + assert.ok( + !shareBlock.includes('AND NOT f.has_pending_handling'), + '概览不能整体排除有待处理申请的份额,否则已提交待验收会从页签里消失', + ) + + const orderStart = source.indexOf('order_status_rows AS (', shareStart) + assert.ok(orderStart > shareStart, '缺少 order_status_rows CTE') + const orderBlock = source.slice(orderStart, orderStart + 1600) + assert.ok( + !/OR f\.has_pending_handling\b/.test(orderBlock), + '第二分支不能无条件把有待处理申请的工单塞进问题单(会产生重复/错页签计数)', + ) + assert.match( + orderBlock, + /WHEN f\.has_pending_handling AND f\.wo_status = 'in_progress'/, + '无份额的指派单也要按「进行中才归问题单」判定', + ) +}) + test('超时统计按工单去重,不按份额/事件行数累加', async () => { const source = stripComments(await readRepoSource()) 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 6b675528..492d3d66 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 @@ -223,25 +223,29 @@ export function workerOrderBaseStatusExpression(workerShareAlias = 'worker_share * 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单, * 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。 */ -export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { - const baseExpression = workerOrderBaseStatusExpression(workerShareAlias) - const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) - if (normalizedWorkerId <= 0) return baseExpression - const pendingHandlingExists = `( +function workerPendingHandlingExistsExpression(workerId: number): string { + return `( EXISTS ( SELECT 1 FROM worker_order_cancel_requests wcr WHERE wcr.work_order_id = wo.id - AND wcr.worker_id = ${normalizedWorkerId} + AND wcr.worker_id = ${workerId} AND wcr.status = 'pending' ) OR EXISTS ( SELECT 1 FROM worker_order_feedbacks wf WHERE wf.work_order_id = wo.id - AND wf.worker_id = ${normalizedWorkerId} + AND wf.worker_id = ${workerId} AND wf.status = 'pending' ) )` +} + +export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { + const baseExpression = workerOrderBaseStatusExpression(workerShareAlias) + const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) + if (normalizedWorkerId <= 0) return baseExpression + const pendingHandlingExists = workerPendingHandlingExistsExpression(normalizedWorkerId) const isTimeoutForMe = `( (wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId}) AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted') @@ -638,12 +642,12 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise WHERE timeout_ev.event_type LIKE 'timeout_%' AND timeout_ev.payload_json->>'workerId' = $1::text ), - worker_base AS ( + order_flags AS ( SELECT wo.id, wo.status AS wo_status, - wos.status AS wos_status, - wos.status IS NOT NULL AS is_worker_share, + wo.reward_amount, + wo.acceptance_json, wo.assigned_worker_id = $1 AS is_assigned_to_me, EXISTS ( SELECT 1 FROM work_order_events we @@ -651,88 +655,92 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise AND we.event_type LIKE 'timeout_%' AND we.payload_json->>'workerId' = $1::text ) AS has_timeout_for_me, - CASE - 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 + ( + EXISTS ( + SELECT 1 FROM worker_order_cancel_requests wcr + WHERE wcr.work_order_id = wo.id + AND wcr.worker_id = $1 + AND wcr.status = 'pending' + ) OR EXISTS ( + SELECT 1 FROM worker_order_feedbacks wf + WHERE wf.work_order_id = wo.id + AND wf.worker_id = $1 + AND wf.status = 'pending' + ) + ) AS has_pending_handling, + COALESCE(( + SELECT SUM(wos.share_reward) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = $1 + AND wos.status != 'cancelled' + ), 0)::int AS share_reward_total, + EXISTS ( + SELECT 1 FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = $1 + AND wos.status != 'cancelled' + ) AS is_worker_share FROM work_orders wo INNER JOIN visible_orders vo ON vo.work_order_id = wo.id - 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 ( + share_status_rows AS ( SELECT + wos.work_order_id, CASE - WHEN NOT is_assigned_to_me AND (NOT is_worker_share OR wos_status = 'cancelled' OR wos_status IS NULL) AND has_timeout_for_me - THEN 'open' - WHEN ( - CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END - ) = 'in_progress' AND ( - EXISTS ( - SELECT 1 - FROM worker_order_cancel_requests wcr - WHERE wcr.work_order_id = worker_base.id - AND wcr.worker_id = $1 - AND wcr.status = 'pending' - ) OR EXISTS ( - SELECT 1 - FROM worker_order_feedbacks wf - WHERE wf.work_order_id = worker_base.id - AND wf.worker_id = $1 - AND wf.status = 'pending' - ) - ) THEN 'problem' - ELSE CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END + -- 待审核取消申请/未处理反馈只把「进行中」的份额归入问题单; + -- 已提交待验收的份额必须留在待验收(列表筛选就是这么判的, + -- 这里若整体排除会出现「问题单有数字、点进去空列表」)。 + WHEN wos.status = 'joined' AND f.has_pending_handling THEN 'problem' + WHEN wos.status = 'joined' THEN 'in_progress' + WHEN wos.status = 'submitted' THEN 'pending_acceptance' + WHEN wos.status = 'accepted' THEN 'accepted' END AS status, + SUM(wos.share_reward)::int AS amount, + (ARRAY_AGG(wos.acceptance_json ORDER BY wos.id DESC))[1] AS acceptance_json + FROM work_order_shares wos + INNER JOIN order_flags f ON f.id = wos.work_order_id + WHERE wos.worker_id = $1 + AND wos.status IN ('joined', 'submitted', 'accepted') + AND f.wo_status NOT IN ('accepted', 'cancelled', 'problem') + GROUP BY wos.work_order_id, wos.status, f.has_pending_handling + ), + order_status_rows AS ( + SELECT + work_order_id, + status, + amount, acceptance_json, - is_worker_share, - worker_settlement_amount - FROM worker_base + TRUE AS is_worker_share + FROM share_status_rows + + UNION ALL + + SELECT + f.id AS work_order_id, + CASE + WHEN f.has_timeout_for_me + AND NOT f.is_assigned_to_me + AND NOT f.is_worker_share + THEN 'open' + WHEN f.wo_status IN ('accepted', 'cancelled', 'problem') + THEN f.wo_status + WHEN f.has_pending_handling AND f.wo_status = 'in_progress' + THEN 'problem' + ELSE f.wo_status + END AS status, + CASE + WHEN f.is_worker_share THEN f.share_reward_total + WHEN f.is_assigned_to_me THEN f.reward_amount + ELSE 0 + END AS amount, + f.acceptance_json, + f.is_worker_share + FROM order_flags f + WHERE + f.wo_status IN ('accepted', 'cancelled', 'problem') + OR (f.has_timeout_for_me AND NOT f.is_assigned_to_me AND NOT f.is_worker_share) + OR NOT f.is_worker_share ) SELECT status, @@ -742,7 +750,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise CASE WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') OR (status = 'open' AND is_worker_share) - THEN worker_settlement_amount + THEN amount ELSE 0 END ), @@ -755,7 +763,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() )::int AS vip_evidence_pending_count, COALESCE( - SUM(worker_settlement_amount) FILTER ( + SUM(amount) FILTER ( WHERE status = 'accepted' AND acceptance_json->>'reviewMode' = 'vip_auto' AND acceptance_json->>'evidenceStatus' = 'pending' @@ -763,8 +771,41 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise ), 0 )::int AS vip_evidence_pending_settlement_amount - FROM worker_orders + FROM order_status_rows GROUP BY status + + UNION ALL + + SELECT + 'all' AS status, + COUNT(DISTINCT work_order_id)::int AS order_count, + COALESCE( + SUM( + CASE + WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') + OR (status = 'open' AND is_worker_share) + THEN amount + ELSE 0 + END + ), + 0 + )::int AS settlement_amount, + COUNT(*) FILTER ( + WHERE status = 'accepted' + AND acceptance_json->>'reviewMode' = 'vip_auto' + AND acceptance_json->>'evidenceStatus' = 'pending' + AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() + )::int AS vip_evidence_pending_count, + COALESCE( + SUM(amount) FILTER ( + WHERE status = 'accepted' + AND acceptance_json->>'reviewMode' = 'vip_auto' + AND acceptance_json->>'evidenceStatus' = 'pending' + AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() + ), + 0 + )::int AS vip_evidence_pending_settlement_amount + FROM order_status_rows `, [normalizedWorkerId], ) @@ -1277,9 +1318,35 @@ export function buildWorkOrderWhere({ params.push(normalizedStatuses) const statusesPlaceholder = `$${params.length}` if (workerSharingId) { - filters.push( - `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`, - ) + const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])` + // 一个工单可能同时有「进行中 + 待验收」等多份不同状态的份额。 + // 只看代表份额会让其中一种状态在页签里完全消失,所以只要任一份额命中就展示。 + const anyShareStatus = `( + wo.status NOT IN ('accepted', 'cancelled', 'problem') + AND EXISTS ( + SELECT 1 + FROM work_order_shares ws_filter + WHERE ws_filter.work_order_id = wo.id + AND ws_filter.worker_id = ${workerSharingId} + AND ws_filter.status IN ('joined', 'submitted', 'accepted') + AND ( + ( + ws_filter.status = 'joined' + AND 'in_progress' = ANY(${statusesPlaceholder}::text[]) + AND NOT ${workerPendingHandlingExistsExpression(workerSharingId)} + ) + OR ( + ws_filter.status = 'submitted' + AND 'pending_acceptance' = ANY(${statusesPlaceholder}::text[]) + ) + OR ( + ws_filter.status = 'accepted' + AND 'accepted' = ANY(${statusesPlaceholder}::text[]) + ) + ) + ) + )` + filters.push(`(${representativeStatus} OR ${anyShareStatus})`) } else { filters.push( `${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`, diff --git a/apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts b/apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts new file mode 100644 index 00000000..035addf8 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +/** + * 回归:同一打手在同一拼单工单里有多份份额时,活跃接单数只应计 1 单。 + * + * 之前 countWorkerActiveOrders 仍是「指派单数 + 拼单份额行数」直接相加, + * 打手资料页、抢普通单和后台指派会把同工单的多份拼单算成多单,提前撞接单上限。 + * 事务内版本已经去重,这里把「事务内外共用同一段计数 SQL」钉死,防止再次分裂。 + */ +async function readRepoSource() { + return readFile(new URL('./work-order-query-repo.ts', import.meta.url), 'utf8') +} + +/** 去掉 SQL 行注释与 TS 行注释,避免注释里的旧写法被误判成代码。 */ +function stripComments(source: string) { + return source + .split('\n') + .map((line) => line.replace(/--.*$/, '').replace(/\/\/.*$/, '')) + .join('\n') +} + +test('活跃接单数按工单去重,同一工单多份拼单只占 1 个名额', async () => { + const source = stripComments(await readRepoSource()) + const start = source.indexOf('const COUNT_WORKER_ACTIVE_ORDERS_SQL') + assert.ok(start >= 0, '缺少共享的活跃单计数 SQL 常量') + + const sql = source.slice(start, start + 800) + assert.match(sql, /UNION/, '活跃单计数必须用 UNION 按工单去重') + assert.match(sql, /work_order_shares/, '活跃单计数必须包含拼单份额') + assert.match(sql, /swo\.status = 'open'/, '只统计母单仍在大厅的拼单份额') +}) + +test('事务内外共用同一段活跃单计数 SQL,避免口径分裂', async () => { + const source = stripComments(await readRepoSource()) + + for (const fn of ['countWorkerActiveOrders', 'countWorkerActiveOrdersWithClient']) { + const start = source.indexOf(`export async function ${fn}`) + assert.ok(start >= 0, `缺少函数 ${fn}`) + + const block = source.slice(start, start + 700) + assert.ok( + block.includes('COUNT_WORKER_ACTIVE_ORDERS_SQL'), + `${fn} 没有复用共享的活跃单计数 SQL`, + ) + assert.ok(!block.includes(') + ('), `${fn} 仍在把「指派单数 + 拼单份额行数」直接相加`) + } +}) diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index a149dd8d..487de6cc 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -3,9 +3,13 @@ import type { Response } from 'express' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { + getLatestMaterialEventAtByOrderIds, + getPendingCancelRequestOrderIds, + getPendingFeedbackOrderIds, getWorkerFinanceRequestById, getWorkerUserById, getWorkOrderById, + listWorkerTimeoutEventsByOrderIds, listWorkerWorkOrderNotes, listWorkerWorkOrderViews, listWorkOrderShares, @@ -23,10 +27,13 @@ import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js' import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js' import { isWorkOrderSharingFilled, + isWorkerOrderVipEvidencePending, mapFinanceRequest, mapWorkOrderAdmin, mapWorkOrderForWorker, mapWorkerUser, + pickWorkerRepresentativeShare, + resolveWorkerOrderStatusTabs, resolveWorkerPermissions, } from '../worker-platform/mappers.js' import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js' @@ -336,33 +343,121 @@ async function publishWorkOrderSnapshots(input: { }) } + // 资料更新时间只与工单有关,提到循环外只查一次(参与人数多时省掉 N 次查询)。 + const materialEventAt = + (await getLatestMaterialEventAtByOrderIds([workOrderId])).get(workOrderId) || '' + for (const workerId of new Set( (input.workerIds || []).map((item) => Number(item)).filter(Boolean), )) { const worker = await getWorkerUserById(workerId) if (!worker) continue - const [notes, views] = await Promise.all([ - listWorkerWorkOrderNotes(workerId, [workOrderId]), - listWorkerWorkOrderViews(workerId, [workOrderId]), - ]) - const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || '' - const viewedAt = - views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null - const share = shares.find((item) => Number(item.worker_id) === workerId) + + // 同一打手在同一工单可能有多份份额(多次拼单),必须按「全部份额」还原这一行: + // 旧实现用 shares.find(...) 只取最早一条,多份额时会把可见性、个人状态、金额都算错, + // 还会让实时补丁把行从错误的页签删掉(打手端偶发「页签里没有数据」)。 const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId - const workerOrderVisible = isAssignedWorker || Boolean(share && share.status !== 'cancelled') + const workerShares = shares.filter((item) => Number(item.worker_id) === workerId) + const activeWorkerShares = workerShares.filter((item) => item.status !== 'cancelled') + const representativeShare = pickWorkerRepresentativeShare( + activeWorkerShares.length > 0 ? activeWorkerShares : workerShares, + ) + const cancelledShare = workerShares + .filter((item) => item.status === 'cancelled') + .sort((left, right) => Number(right.id) - Number(left.id))[0] + const isOwnerCurrently = isAssignedWorker || activeWorkerShares.length > 0 + const workerOrderVisible = isOwnerCurrently + + // 只有「已不持有该单」时才需要判断超时归属,避免给正常在办的单多打一次查询。 + const needsTimeoutCheck = !isOwnerCurrently + const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, timeoutEventsByOrderId] = + await Promise.all([ + listWorkerWorkOrderNotes(workerId, [workOrderId]), + listWorkerWorkOrderViews(workerId, [workOrderId]), + getPendingCancelRequestOrderIds(workerId, [workOrderId]), + getPendingFeedbackOrderIds(workerId, [workOrderId]), + needsTimeoutCheck + ? listWorkerTimeoutEventsByOrderIds(workerId, [workOrderId]) + : Promise.resolve(new Map()), + ]) + const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || '' + const view = views.find((item) => Number(item.work_order_id) === workOrderId) + const viewedAt = view?.viewed_at || null + const timeoutEvent = timeoutEventsByOrderId.get(workOrderId) + const isTimedOutForMe = + !isOwnerCurrently && Boolean(timeoutEvent || (cancelledShare && workOrder.sharing_enabled)) + + const timeoutNotice = isTimedOutForMe + ? { + isTimedOut: true, + timedOutAt: + timeoutEvent?.createdAt || + (workOrder.deadline_at ? String(workOrder.deadline_at) : null) || + workOrder.updated_at, + warningMessage: + (workOrder.assigned_worker_id != null && + Number(workOrder.assigned_worker_id) !== workerId) || + ['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes( + workOrder.status, + ) + ? '该订单已超时返回大厅并被其他打手接手,请勿继续在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。' + : '该订单已超时退回抢单大厅,您已失去接单资格,请勿在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。', + isTakenByOther: + (workOrder.assigned_worker_id != null && + Number(workOrder.assigned_worker_id) !== workerId) || + ['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes( + workOrder.status, + ), + } + : undefined + + const hasPendingHandling = + pendingCancelOrderIds.has(workOrderId) || pendingFeedbackOrderIds.has(workOrderId) + const materialSeenAt = view?.material_seen_at || '' + const vipEvidencePending = isWorkerOrderVipEvidencePending(workOrder) + publishRealtimeEvent({ type: 'work_order.changed', entityId: workOrderId, scopes: ['worker_my_orders'], operation: workerOrderVisible ? 'upsert' : 'remove', - data: mapWorkOrderForWorker( - workOrder, - resolveWorkerPermissions(worker), - share, - note, - viewedAt, - ), + data: { + ...mapWorkOrderForWorker( + workOrder, + resolveWorkerPermissions(worker), + representativeShare, + note, + viewedAt, + { + isTimedOutForMe, + ...(timeoutNotice ? { timeoutNotice } : {}), + // 与列表一致:只带未取消份额,按 id DESC(参与先后倒序) + myShares: [...activeWorkerShares].sort( + (left, right) => Number(right.id) - Number(left.id), + ), + }, + ), + /** + * 这张单在该打手「我的订单」里属于哪些状态页签。 + * 多份额工单可同时属于代练中/待验收,单个 status 字段表达不了,前端据此判归属。 + */ + workerStatuses: resolveWorkerOrderStatusTabs({ + workOrderStatus: workOrder.status, + isAssignedWorker, + shares: workerShares, + hasPendingHandling, + hasTimeoutForMe: Boolean(timeoutEvent), + vipEvidencePending, + }), + problemMarked: !isTimedOutForMe && workOrder.status === WORK_ORDER_STATUS.PROBLEM, + pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId), + pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId), + materialUpdated: + !isTimedOutForMe && + Boolean(materialEventAt) && + (!materialSeenAt || + new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime()), + }, workerIds: [workerId], }) } diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 6b9162d3..cac7d695 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -83,6 +83,7 @@ export { } from './worker-finance-mappers.js' export { + isWorkerOrderVipEvidencePending, mapCollectLookupOrder, mapWorkCategory, mapWorkOrderAdmin, @@ -91,6 +92,8 @@ export { mapWorkOrderPublic, mapWorkOrderShare, mapWorkOrderSharingWorkers, + pickWorkerRepresentativeShare, + resolveWorkerOrderStatusTabs, resolveWorkOrderOperationalStatus, resolveWorkerShareProductName, resolveWorkerShareStatus, diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts new file mode 100644 index 00000000..b417b155 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { WorkOrderShareRow } from '../../repositories/worker-platform/types.js' +import { + isWorkerOrderVipEvidencePending, + pickWorkerRepresentativeShare, + resolveWorkerOrderStatusTabs, + summarizeWorkerShares, +} from './work-order-display-mappers.js' + +function share(overrides: Record): WorkOrderShareRow { + return { + id: 1, + work_order_id: 1, + worker_id: 1, + quantity: 1, + unit_reward: 10, + share_reward: 10, + share_deposit: 0, + status: 'joined', + deadline_at: null, + timeout_minutes: 0, + created_at: '2026-10-23T10:00:00.000Z', + ...overrides, + } as unknown as WorkOrderShareRow +} + +test('同一工单多份份额:数量/奖励/押金按份额求和,已取消份额不计入金额', () => { + const summary = summarizeWorkerShares([ + share({ + id: 1, + quantity: 2, + share_reward: 20, + share_deposit: 5, + status: 'joined', + created_at: '2026-10-23T10:00:00.000Z', + deadline_at: '2026-10-23T12:00:00.000Z', + }), + share({ + id: 2, + quantity: 3, + share_reward: 30, + share_deposit: 5, + status: 'joined', + created_at: '2026-10-23T10:10:00.000Z', + deadline_at: '2026-10-23T11:00:00.000Z', + }), + share({ + id: 3, + quantity: 9, + share_reward: 90, + share_deposit: 99, + status: 'cancelled', + created_at: '2026-10-23T09:00:00.000Z', + }), + ]) + + assert.equal(summary.quantity, 5) + assert.equal(summary.rewardAmount, 50) + assert.equal(summary.depositAmount, 10) + // 有有效份额时,接单时间取第一份有效份额,不取更早的已取消记录。 + assert.equal(summary.firstJoinedAt, '2026-10-23T10:00:00.000Z') + // 父行倒计时取未完成份额中最早的截止时间。 + assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T11:00:00.000Z') +}) + +test('历史份额没有 deadline_at 时,用份额自己的 timeout_minutes 兜底', () => { + const summary = summarizeWorkerShares([ + share({ + id: 1, + quantity: 2, + share_reward: 20, + status: 'joined', + created_at: '2026-10-23T10:00:00.000Z', + deadline_at: null, + timeout_minutes: 30, + }), + ]) + + assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T10:30:00.000Z') +}) + +test('只剩已取消份额时,金额归零但保留最早参与时间', () => { + const summary = summarizeWorkerShares([ + share({ + id: 1, + quantity: 2, + share_reward: 20, + status: 'cancelled', + created_at: '2026-10-23T09:00:00.000Z', + }), + share({ + id: 2, + quantity: 3, + share_reward: 30, + status: 'cancelled', + created_at: '2026-10-23T09:30:00.000Z', + }), + ]) + + assert.equal(summary.quantity, 0) + assert.equal(summary.rewardAmount, 0) + assert.equal(summary.depositAmount, 0) + assert.equal(summary.firstJoinedAt, '2026-10-23T09:00:00.000Z') + assert.equal(summary.earliestActiveDeadlineAt, null) +}) + +test('代表份额与列表 LATERAL 同序:joined > submitted > accepted > cancelled,再按创建时间/id 倒序', () => { + const shares = [ + share({ id: 1, status: 'cancelled', created_at: '2026-10-23T09:00:00.000Z' }), + share({ id: 2, status: 'submitted', created_at: '2026-10-23T09:30:00.000Z' }), + share({ id: 3, status: 'joined', created_at: '2026-10-23T10:00:00.000Z' }), + ] + assert.equal(pickWorkerRepresentativeShare(shares)?.id, 3) + + // 同状态取更晚创建的一份(旧实现取的是「最早一条」,多份额时必然挑错) + const sameStatus = [ + share({ id: 10, status: 'submitted', created_at: '2026-10-23T09:00:00.000Z' }), + share({ id: 11, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }), + share({ id: 12, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }), + ] + assert.equal(pickWorkerRepresentativeShare(sameStatus)?.id, 12) +}) + +test('多份额工单(一份进行中 + 一份待验收)必须同时属于两个页签', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: false, + shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })], + }) + assert.deepEqual(tabs.sort(), ['in_progress', 'pending_acceptance']) +}) + +test('有待审核取消申请/未处理反馈时,进行中份额归问题单,但待验收份额仍留在待验收', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: false, + shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })], + hasPendingHandling: true, + }) + assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem']) +}) + +test('母单进入终态后只按母单状态归类(与 SQL 关闭 anyShare 分支一致)', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'problem', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'submitted' })], + }) + assert.deepEqual(tabs, ['problem']) +}) + +test('份额全部取消时只归已取消,不得混进待验收', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'pending_acceptance', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'cancelled' })], + }) + assert.deepEqual(tabs, ['cancelled']) +}) + +test('没有份额的指派单按母单状态归类;超时退回后归已超时', () => { + assert.deepEqual( + resolveWorkerOrderStatusTabs({ + workOrderStatus: 'pending_acceptance', + isAssignedWorker: true, + shares: [], + }), + ['pending_acceptance'], + ) + assert.deepEqual( + resolveWorkerOrderStatusTabs({ + workOrderStatus: 'pending_acceptance', + isAssignedWorker: false, + shares: [share({ id: 1, status: 'cancelled' })], + hasTimeoutForMe: true, + }), + ['open'], + ) +}) + +test('VIP 免审核待补图:只有已验收 + vip_auto + 待补图 + 未到期才归入该页签', () => { + const now = Date.parse('2026-10-23T10:00:00.000Z') + const base = { + status: 'accepted', + acceptance_json: { + reviewMode: 'vip_auto', + evidenceStatus: 'pending', + evidenceDueAt: '2026-10-24T10:00:00.000Z', + }, + } + assert.equal(isWorkerOrderVipEvidencePending(base, now), true) + assert.equal( + isWorkerOrderVipEvidencePending( + { ...base, acceptance_json: { ...base.acceptance_json, evidenceStatus: 'submitted' } }, + now, + ), + false, + ) + assert.equal( + isWorkerOrderVipEvidencePending( + { + ...base, + acceptance_json: { ...base.acceptance_json, evidenceDueAt: '2026-10-22T10:00:00.000Z' }, + }, + now, + ), + false, + ) + assert.deepEqual( + resolveWorkerOrderStatusTabs({ + workOrderStatus: 'accepted', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'accepted' })], + vipEvidencePending: true, + }).sort(), + ['accepted', 'vip_evidence_pending'], + ) +}) 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 5e5d24c0..8deee423 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 @@ -597,6 +597,147 @@ export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: u return workOrderStatus } +/** 打手端「我的订单」状态页签键,与 buildWorkOrderWhere 的打手状态筛选同口径。 */ +export type WorkerOrderStatusTab = + | 'in_progress' + | 'pending_acceptance' + | 'problem' + | 'open' + | 'accepted' + | 'cancelled' + | 'vip_evidence_pending' + +/** 母单进入终态后,列表只按母单状态归类(任一命中分支会被 wo.status NOT IN (...) 关掉)。 */ +const TERMINAL_WORK_ORDER_STATUSES = new Set(['accepted', 'cancelled', 'problem']) + +/** 代表份额优先级,与 SQL LATERAL 的 CASE 顺序一致:joined > submitted > accepted > cancelled。 */ +const WORKER_SHARE_STATUS_PRIORITY: Record = { + joined: 0, + submitted: 1, + accepted: 2, + cancelled: 3, +} + +type WorkerShareLike = { + status?: unknown + created_at?: unknown + id?: unknown +} + +/** + * 同一打手在一个工单里的「代表份额」。 + * + * 实时广播过去用 `shares.find(worker_id === ...)`(= 最早一条),同一打手多次拼单时会挑到 + * 已取消/进行中的那份,进而把快照算成「不在可见范围」或错误的个人状态,导致打手端某个 + * 状态页签的行被实时补丁删掉(表现为「待验收有时候没有数据」)。 + * 这里与 `work-order-query-repo` 的 LATERAL 排序完全一致: + * status 优先级 ASC, created_at DESC, id DESC + */ +export function pickWorkerRepresentativeShare( + shares: readonly T[], +): T | null { + let representative: T | null = null + for (const share of shares) { + if (!representative) { + representative = share + continue + } + const priority = WORKER_SHARE_STATUS_PRIORITY[String(share.status || '')] ?? 4 + const representativePriority = + WORKER_SHARE_STATUS_PRIORITY[String(representative.status || '')] ?? 4 + if (priority !== representativePriority) { + if (priority < representativePriority) representative = share + continue + } + const createdMs = Date.parse(String(share.created_at || '')) + const representativeCreatedMs = Date.parse(String(representative.created_at || '')) + if (Number.isFinite(createdMs) && Number.isFinite(representativeCreatedMs)) { + if (createdMs !== representativeCreatedMs) { + if (createdMs > representativeCreatedMs) representative = share + continue + } + } + if (Number(share.id || 0) > Number(representative.id || 0)) representative = share + } + return representative +} + +/** + * 一个工单在该打手「我的订单」里会出现在哪些状态页签。 + * + * 为什么需要它:多份额工单可以同时属于「代练中」和「待验收」(一份 joined + 一份 submitted), + * 但实时快照只能带一个 `status` 字段。前端旧逻辑只要「快照状态 ≠ 当前页签」就把行从缓存删掉, + * 于是同一张单在其中一个页签凭空消失。广播里带上完整页签集合后,前端按集合判归属即可。 + * + * 口径逐条对齐 `buildWorkOrderWhere`(打手状态筛选): + * - 母单 accepted/cancelled/problem:只看代表状态(母单状态); + * - 其余:代表状态 + 「任一有效份额命中」(joined 需无待处理申请,submitted/accepted 直接命中); + * - 有该打手的超时事件且他已不持有订单:代表状态记 open。 + */ +export function resolveWorkerOrderStatusTabs(input: { + workOrderStatus: string + isAssignedWorker: boolean + /** 该打手在该工单的全部份额(含已取消),用于还原代表份额。 */ + shares?: readonly WorkerShareLike[] + /** 存在待审核取消申请或未处理反馈(进行中的份额会归入问题单)。 */ + hasPendingHandling?: boolean + /** 该打手在这张单上有超时事件。 */ + hasTimeoutForMe?: boolean + /** 命中「VIP 免审核待补图」筛选(列表里它是独立筛选,不参与 status 参数)。 */ + vipEvidencePending?: boolean +}): WorkerOrderStatusTab[] { + const tabs = new Set() + const workOrderStatus = String(input.workOrderStatus || '') + if (input.vipEvidencePending) tabs.add('vip_evidence_pending') + + if (TERMINAL_WORK_ORDER_STATUSES.has(workOrderStatus)) { + tabs.add(workOrderStatus as WorkerOrderStatusTab) + return [...tabs] + } + + const shares = input.shares || [] + const activeShares = shares.filter( + (share) => + share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted', + ) + const representative = pickWorkerRepresentativeShare(shares) + let representativeStatus = representative + ? resolveWorkerShareStatus(workOrderStatus, representative.status) + : workOrderStatus + if (input.hasTimeoutForMe && !input.isAssignedWorker && activeShares.length === 0) { + representativeStatus = WORK_ORDER_STATUS.OPEN + } else if (representativeStatus === WORK_ORDER_STATUS.IN_PROGRESS && input.hasPendingHandling) { + representativeStatus = WORK_ORDER_STATUS.PROBLEM + } + if (representativeStatus) tabs.add(representativeStatus as WorkerOrderStatusTab) + + for (const share of activeShares) { + if (share.status === 'joined') { + // 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。 + if (!input.hasPendingHandling) tabs.add('in_progress') + } else if (share.status === 'submitted') { + tabs.add('pending_acceptance') + } else if (share.status === 'accepted') { + tabs.add('accepted') + } + } + + return [...tabs] +} + +/** 「VIP 免审核,待补图」判定,口径同 `buildWorkOrderWhere` 的 vipEvidencePendingWhere。 */ +export function isWorkerOrderVipEvidencePending( + workOrder: Pick, + nowMs = Date.now(), +): boolean { + if (String(workOrder.status || '') !== WORK_ORDER_STATUS.ACCEPTED) return false + const acceptance = mapAcceptanceForResponse(workOrder.acceptance_json) as Record + if (String(acceptance.reviewMode || '') !== 'vip_auto') return false + if (String(acceptance.evidenceStatus || '') !== 'pending') return false + const dueAtMs = Date.parse(String(acceptance.evidenceDueAt || '')) + return Number.isFinite(dueAtMs) && dueAtMs > nowMs +} + export function mapWorkOrderShare(share: WorkOrderShareRow) { return { shareId: Number(share.id), 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 fe9cd24a..2619012d 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 @@ -245,25 +245,31 @@ export async function getWorkerMyOrderOverview(session: WorkerSession) { buildMyOrderCacheKey(worker.id, 'overview', 0), () => getWorkerOrderOverview(worker.id), ) - const items = rows.map((row) => ({ + const statusRows = rows.filter((row) => row.status !== 'all') + const items = statusRows.map((row) => ({ status: row.status, orderCount: row.orderCount, settlementAmount: row.settlementAmount, })) - const vipEvidencePending = rows.reduce( + const vipEvidencePending = statusRows.reduce( (summary, row) => ({ orderCount: summary.orderCount + row.vipEvidencePendingCount, settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount, }), { orderCount: 0, settlementAmount: 0 }, ) - const all = items.reduce( - (summary, item) => ({ - orderCount: summary.orderCount + item.orderCount, - settlementAmount: summary.settlementAmount + item.settlementAmount, - }), - { orderCount: 0, settlementAmount: 0 }, - ) + // 混合状态的多份额工单会同时出现在多个页签,不能再用各页签相加当「全部订单」, + // 否则同一工单会被重复计数;优先使用 SQL 里按 distinct 工单算好的 all 行。 + const allRow = rows.find((row) => row.status === 'all') + const all = allRow + ? { orderCount: allRow.orderCount, settlementAmount: allRow.settlementAmount } + : items.reduce( + (summary, item) => ({ + orderCount: summary.orderCount + item.orderCount, + settlementAmount: summary.settlementAmount + item.settlementAmount, + }), + { orderCount: 0, settlementAmount: 0 }, + ) return { items: [ ...items, diff --git a/apps/frontend/src/pages/worker/WorkerOrderShareTable.tsx b/apps/frontend/src/pages/worker/WorkerOrderShareTable.tsx new file mode 100644 index 00000000..ee16d0a6 --- /dev/null +++ b/apps/frontend/src/pages/worker/WorkerOrderShareTable.tsx @@ -0,0 +1,213 @@ +import { Button, Space, Table, Tag, Typography } from 'antd' +import type { TableColumnsType } from 'antd' + +import { DeadlineCountdown } from '@/components/DeadlineCountdown' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import type { WorkOrder, WorkOrderShare } from '@/types/worker-platform' +import { formatDateTime } from '@/utils/date-time' + +import { formatMoney, getOrderShares, getUniqueAcceptanceFiles } from './worker-order-utils' + +const SHARE_STATUS_META: Record = { + joined: { label: '进行中', color: 'processing' }, + submitted: { label: '待验收', color: 'orange' }, + accepted: { label: '已验收', color: 'green' }, + cancelled: { label: '已取消', color: 'default' }, +} + +/** + * 一个工单一行,展开后展示该打手在这个工单里的每一份份额。 + * 数量 / 奖励 / 押金直接取份额字段,与后台「拼单明细」同口径。 + */ +export function WorkerOrderShareTable({ + order, + mobile = false, + onOpenAcceptance, + onOpenCancelRequest, + onRemindAcceptance, +}: { + order: WorkOrder + mobile?: boolean + onOpenAcceptance: (order: WorkOrder, shareId: number) => void + onOpenCancelRequest: (order: WorkOrder, shareId: number) => void + onRemindAcceptance: (order: WorkOrder) => void +}) { + const shares = getOrderShares(order) + .slice() + .sort((left, right) => { + const byCreatedAt = String(left.createdAt || '').localeCompare(String(right.createdAt || '')) + return byCreatedAt !== 0 ? byCreatedAt : left.shareId - right.shareId + }) + const isTimedOut = order.status === 'open' || Boolean(order.timeoutNotice?.isTimedOut) + + if (shares.length === 0) return null + + const columns: TableColumnsType = [ + { + title: '份额', + width: 92, + render: (_, share) => ( +
+ + 第 {shares.findIndex((item) => item.shareId === share.shareId) + 1} 份 + + + #{share.shareId} + +
+ ), + }, + { + title: '数量', + width: 80, + render: (_, share) => {share.quantity} 份, + }, + { + title: '奖励', + width: 110, + render: (_, share) => ( + + {formatMoney(share.shareReward)} + + ), + }, + { + title: '押金', + width: 100, + render: (_, share) => formatMoney(share.shareDeposit), + }, + { + title: '状态', + width: 96, + render: (_, share) => { + const meta = SHARE_STATUS_META[String(share.status)] || { + label: String(share.status || '-'), + color: 'default', + } + return {meta.label} + }, + }, + { + title: '截止时间', + width: 170, + render: (_, share) => { + if (!share.deadlineAt) { + return ( + + 不限时 + + ) + } + return ( +
+ + {formatDateTime(share.deadlineAt)} + + {share.status === 'joined' && !isTimedOut ? ( + + ) : null} +
+ ) + }, + }, + { + title: '验收信息', + width: 220, + render: (_, share) => , + }, + { + title: '操作', + width: 190, + render: (_, share) => { + const canAccept = !isTimedOut && share.status === 'joined' + const canEdit = !isTimedOut && ['joined', 'submitted'].includes(String(share.status)) + const canRemind = !isTimedOut && share.status === 'submitted' + const canCancel = !isTimedOut && ['joined', 'submitted'].includes(String(share.status)) + if (!canAccept && !canEdit && !canRemind && !canCancel) { + return ( + + 无 + + ) + } + return ( + + {canAccept ? ( + + ) : null} + {canEdit && share.status === 'submitted' ? ( + + ) : null} + {canRemind ? ( + + ) : null} + {canCancel ? ( + + ) : null} + + ) + }, + }, + ] + + return ( + + rowKey="shareId" + size="small" + bordered={false} + pagination={false} + dataSource={shares} + columns={columns} + className="worker-order-share-table" + scroll={{ x: mobile ? 820 : 1080 }} + /> + ) +} + +function ShareAcceptanceCell({ share }: { share: WorkOrderShare }) { + const acceptanceFiles = getUniqueAcceptanceFiles(share.acceptance) + const draftFiles = getUniqueAcceptanceFiles(share.draftAcceptance) + if (acceptanceFiles.length === 0 && draftFiles.length === 0 && !share.submittedAt) { + return ( + + 未提交 + + ) + } + return ( +
+ {acceptanceFiles.length > 0 ? ( + + ) : null} + {share.submittedAt ? ( + + 提交:{formatDateTime(share.submittedAt)} + + ) : null} + {draftFiles.length > 0 && !share.submittedAt ? ( + 已暂存 {draftFiles.length} 张 + ) : null} + {String(share.acceptance?.note || '').trim() ? ( + + {share.acceptance.note} + + ) : null} +
+ ) +} diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 50fd61c0..f96f5eae 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router' import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd' import { useState } from 'react' @@ -193,6 +193,8 @@ export default function WorkerOrdersPage() { queryKey: ['worker-my-orders', status, keyword, page, pageSize], queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)), staleTime: 2_000, + // 切页签时保留上一页数据,避免请求返回前先闪一下空列表。 + placeholderData: keepPreviousData, refetchOnWindowFocus: false, retry: false, }) diff --git a/apps/frontend/src/utils/request-id.ts b/apps/frontend/src/utils/request-id.ts new file mode 100644 index 00000000..846ed500 --- /dev/null +++ b/apps/frontend/src/utils/request-id.ts @@ -0,0 +1,8 @@ +/** 客户端幂等键:一次用户点击一个,用于拼单参与/抢单的服务端去重。 */ +export function createRequestId(prefix = 'req'): string { + const random = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + return `${prefix}-${random}` +} diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 2fa19f5f..911335f4 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -89,7 +89,20 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: { }): { operation: 'upsert' | 'remove'; shouldInsert: boolean } { const item = isRecord(input.item) ? input.item : {} const statusFilter = String(input.statusFilter || '') - const statusMatches = !statusFilter || statusFilter === String(item.status || '') + /** + * 服务端快照里的 `workerStatuses` 才是「这张单属于哪些页签」的准确答案。 + * + * 同一打手在同一工单可能有多份份额(多次拼单),一张单可以同时出现在「代练中」和「待验收」; + * 而快照的 `status` 只能是一个值。旧逻辑拿单个 status 跟页签比对,只要不相等就删行, + * 于是多份额工单总会在其中一个页签里被删掉(表现为「待验收有时候没有数据」)。 + * 老事件/其它来源没有该字段时,退回原来的单状态比对。 + */ + const tabs = Array.isArray(item.workerStatuses) + ? item.workerStatuses.map((tab) => String(tab || '')).filter(Boolean) + : [] + const statusMatches = + !statusFilter || + (tabs.length > 0 ? tabs.includes(statusFilter) : statusFilter === String(item.status || '')) const statusProvable = input.explicitOrderNo || statusMatches const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index 192984b6..4b832e36 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -188,3 +188,95 @@ test('非第 1 页不得插入,只做就地更新', () => { assert.equal(verdict.operation, 'upsert') assert.equal(verdict.shouldInsert, false) }) + +/** + * 同一打手在同一个工单多次拼单:一张单可以同时属于「代练中」和「待验收」, + * 但快照的 status 只能是一个值。前端若按单个 status 删行,另一个页签就会莫名其妙变空 + * (线上「待验收有时候没有数据」)。服务端现在带 workerStatuses,按集合判归属。 + */ +test('多份额工单:快照 status 是进行中,但属于待验收时不得从待验收页签删行', () => { + const snapshot = buildRow({ + status: 'in_progress', + workerStatuses: ['in_progress', 'pending_acceptance'], + }) + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: snapshot, + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'upsert', '待验收页签必须保留这张多份额工单') + assert.equal(verdict.shouldInsert, true) + + const updated = patchWorkerWorkOrderListEnvelope( + buildEnvelope([buildRow({ status: 'pending_acceptance' })]), + { operation: verdict.operation, data: snapshot }, + verdict.shouldInsert, + ) + assert.equal(itemsOf(updated).length, 1) + assert.equal((itemsOf(updated)[0] as { status?: string }).status, 'in_progress') +}) + +test('多份额工单:workerStatuses 同时也是代练中页签的归属依据', () => { + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ + status: 'in_progress', + workerStatuses: ['in_progress', 'pending_acceptance'], + }), + statusFilter: 'in_progress', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'upsert') +}) + +test('不在页签集合里时才允许删行(例如已验收后离开待验收页签)', () => { + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'accepted', workerStatuses: ['accepted'] }), + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'remove') + assert.equal(verdict.shouldInsert, false) +}) + +test('VIP 待补图页签也靠 workerStatuses 判定,不会被当成状态不符删掉', () => { + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'accepted', workerStatuses: ['accepted', 'vip_evidence_pending'] }), + statusFilter: 'vip_evidence_pending', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'upsert') +}) + +test('没有 workerStatuses 的旧快照仍按单个 status 比对(保持既有行为)', () => { + const mismatch = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'accepted' }), + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(mismatch.operation, 'remove') + + const match = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'pending_acceptance' }), + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(match.operation, 'upsert') +}) diff --git a/docs/工单拼单多次参与改造方案-2026-10-23.md b/docs/工单拼单多次参与改造方案-2026-10-23.md new file mode 100644 index 00000000..cce8a61f --- /dev/null +++ b/docs/工单拼单多次参与改造方案-2026-10-23.md @@ -0,0 +1,466 @@ +# 拼单多次参与改造方案(同一打手可多次参与、每份独立计时) + +> 状态:**方案(待确认,未执行)** +> 日期:2026-10-23 +> 代码基线:工作区当前 HEAD(迁移 001–103) +> 目标:同一打手对同一拼单工单可重复参与,每次参与新增一条 `work_order_shares`;每份固定自己的截止时间;超时/验收/撤单按份额独立。 + +--- + +## 一、结论速览 + +**已确认的七项口径(2026-10-23)** + +| 决策 | 确认结果 | 落地要点 | +| --- | --- | --- | +| D1 免押额度 | **跟着母单走**(工单维度累计,等价方案 B) | 免押额度在该工单只抵扣一次:`累计需求 − 免押额度 − 本工单已冻结`,只有差额进入本份 `share_deposit` | +| D2 活跃单计数 | **按不同工单数** | 同一工单拼 3 份只占 1 个名额;改为 `COUNT(DISTINCT work_order_id)` | +| D3 撤单粒度 | **整单 + 单份额都要支持** | 不传 `shareId` = 撤销该打手在该工单的全部有效份额;传 `shareId` = 只撤这一份 | +| D4 验收方式 | **全部验收 + 单独验收都要有** | 统一验收 = 逐份额结算的批处理;单独验收按 `shareId` | +| D5 我的订单列表 | **一行一工单 + 展开份额** | 列表不拆行,工单行内嵌 `myShares[]`,每份独立展示与操作 | +| D6 防误触 | **客户端 `requestId` 幂等** | 份额表加 `request_id` + 唯一索引;重复请求返回原份额 | +| D7 验收通知 | **拼单按 `shareId` 去重;统一验收也要通知** | 每份提交各一条待办;统一验收完成再发一条整单完成通知 | + + +| 项 | 结论 | +| --- | --- | +| 能不能做 | 能,改造集中在工单模块,不触及订单/履约/凭证链路 | +| 最大阻塞 | `work_order_shares` 上的 `UNIQUE(work_order_id, worker_id)`(迁移 008),以及 7 处 `getWorkOrderShare(orderId, workerId)` 假设「一人一条」 | +| 需要新增的列 | `deadline_at`(本份截止)、`timeout_minutes` / `timeout_policy`(参与时快照)、`cancelled_reason` | +| 需要新增的索引 | 份额到期扫描索引、打手份额列表索引;**删除**唯一键 | +| 影响接口 | 打手端 5 个(参与、提交验收、草稿、补证据、退单申请)+ 返回结构 `myShare → myShares` | +| 影响前端 | 大厅「已拼」按钮逻辑、我的订单列表/详情、验收弹窗、后台工单详情份额表 | +| 数据迁移 | 只加列 + 回填 + 删唯一键,**不改历史行**;历史合并份额保持原样 | +| 工作量估算 | 后端 2 人日、前端 2 人日、测试 1 人日(不含评审与联调) | +| 需要你拍板 | 押金/免押分摊方式、活跃单计数口径、退单申请粒度等 7 项(见第十三节) | + +--- + +## 二、现状:四个硬阻塞 + +| # | 阻塞 | 位置 | 影响 | +| --- | --- | --- | --- | +| B1 | `UNIQUE(work_order_id, worker_id)` | 迁移 `008_work_order_sharing.sql:32`,约束名 `work_order_shares_work_order_id_worker_id_key` | 第二次参与直接违反唯一键 | +| B2 | 「按工单 + 打手」只取一条份额 | `getWorkOrderShare(workOrderId, workerId)`,被 7 处调用:验收提交、补证据、草稿、流转记录、备注、已读、我的订单 | 多份额后行为不确定(当前 `LIMIT 1` 取任意一条) | +| B3 | 超时时间由「母单当前配置」实时算 | `listOverdueWorkOrderShares`:`wos.created_at + wo.timeout_minutes`;`submitWorkerSharingAcceptance` 同口径 | 母单改时限会追溯影响已参与份额;且不是「参与时固化」 | +| B4 | 前端单值模型 | `myShare` 在 `worker-order-utils.tsx`、`WorkerHallPage`、`WorkerOrderMobileList`、`WorkerOrderTableColumns`、`types/worker-platform.ts` 共 30+ 处 | 大厅按钮 `disabled={… || Boolean(order.myShare)}`,第二次根本点不了 | + +补充事实:现有 41,383 条份额里,同一 `(work_order_id, worker_id)` 只有一条(唯一键保证),所以删键不会产生历史冲突。 + +--- + +## 三、目标语义(改造后的不变式) + +1. **一次参与 = 一条 `work_order_shares` 记录**,`quantity` 表示「本次参与份数」(默认 1,「立即抢单」= 承接全部剩余)。 +2. **份额截止时间在参与瞬间固化**:`deadline_at = 参与时间 + 母单当时的 timeout_minutes`;母单之后改配置不影响已参与份额(`timeout_minutes = 0` 时 `deadline_at = NULL`,不限时)。 +3. **超时只处置该份额**:份额 `joined → cancelled`(按快照 `timeout_policy` 决定押金释放/扣除),同工单其他份额状态不变。 +4. **验收只针对该份额**:`joined → submitted`(打手提交)、`submitted → accepted`(后台验收 + 结算);同工单其他份额不受影响。 +5. **撤单只针对该份额**:`joined/submitted → cancelled`,只退/扣该份额的 `share_deposit`。 +6. **母单状态只由份额集合推导**(见第七节),不再由单个份额操作直接改写。 +7. **押金按份额落账**:`share_deposit` 是该份额实际冻结的增量金额,释放/扣除都只针对它;同一打手在同一工单的多条份额互不覆盖。 + +--- + +## 四、数据库方案(迁移 `104_work_order_share_multi_participation.sql`) + +### 4.1 DDL 草案 + +```sql +-- 104_work_order_share_multi_participation.sql +-- 目标:同一打手可对同一拼单工单多次参与,每份独立计时/验收/撤单。 + +ALTER TABLE work_order_shares + ADD COLUMN IF NOT EXISTS deadline_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS timeout_minutes INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS timeout_policy TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS cancelled_reason TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT ''; + +COMMENT ON COLUMN work_order_shares.deadline_at IS '本份额截止时间:参与时 = created_at + 母单当时 timeout_minutes;0 分钟为 NULL'; +COMMENT ON COLUMN work_order_shares.timeout_minutes IS '参与时固化的时限(分钟),母单后续改配置不影响'; +COMMENT ON COLUMN work_order_shares.timeout_policy IS '参与时固化的超时策略 reopen/cancel_release/cancel_deduct'; +COMMENT ON COLUMN work_order_shares.cancelled_reason IS '取消来源:timeout/worker_request/admin/problem/settlement'; +COMMENT ON COLUMN work_order_shares.request_id IS '客户端幂等键(一次点击一个),重复请求返回原份额'; + +-- 回填历史份额(按当时母单配置一次性固化) +UPDATE work_order_shares s +SET timeout_minutes = COALESCE(w.timeout_minutes, 0), + timeout_policy = COALESCE(NULLIF(w.timeout_policy, ''), 'reopen'), + deadline_at = CASE + WHEN COALESCE(w.timeout_minutes, 0) > 0 + THEN s.created_at + (w.timeout_minutes || ' minutes')::interval + ELSE NULL + END +FROM work_orders w +WHERE w.id = s.work_order_id + AND s.timeout_minutes = 0; + +-- 删除「一人一条」约束(必须先删,否则第二次参与报 23505) +ALTER TABLE work_order_shares + DROP CONSTRAINT IF EXISTS work_order_shares_work_order_id_worker_id_key; + +-- 新增索引 +CREATE INDEX IF NOT EXISTS idx_work_order_shares_due + ON work_order_shares (deadline_at) + WHERE status = 'joined'; + +CREATE INDEX IF NOT EXISTS idx_work_order_shares_worker_order + ON work_order_shares (worker_id, work_order_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_work_order_shares_order_status + ON work_order_shares (work_order_id, status, created_at ASC); + +-- 幂等键:同一打手同一个 request_id 只能产生一条份额 +CREATE UNIQUE INDEX IF NOT EXISTS uq_work_order_shares_worker_request + ON work_order_shares (worker_id, request_id) + WHERE request_id <> ''; +``` + +> 项目约定:迁移必须幂等、不改历史文件;`ALTER TABLE … DROP CONSTRAINT IF EXISTS` 也符合现有风格(059/099 都用 DO 块判存在,这里用 `IF EXISTS` 更短,建议套 DO 块与团队风格一致)。 + +### 4.2 迁移前的数据体检(只读) + +```sql +-- 1) 同一打手是否存在重复份额(预期 0) +SELECT work_order_id, worker_id, COUNT(*) +FROM work_order_shares GROUP BY 1,2 HAVING COUNT(*) > 1; + +-- 2) 有无份额缺少母单(外键保证应为 0) +SELECT COUNT(*) FROM work_order_shares s +LEFT JOIN work_orders w ON w.id = s.work_order_id WHERE w.id IS NULL; + +-- 3) 已加入但母单已终态的脏数据 +SELECT s.status, w.status, COUNT(*) FROM work_order_shares s +JOIN work_orders w ON w.id = s.work_order_id +GROUP BY 1,2 ORDER BY 3 DESC; +``` + +### 4.3 为什么不动 `quantity` 语义 + +`quantity` 仍然是「本次参与份数」。历史行的一条记录可能代表多份(旧逻辑合并),新行默认 1 份,二者在结算公式 `share_reward = quantity × unit_reward` 下完全兼容,**不需要拆历史行**。拆行会重写历史资金归属,风险大于收益。 + +--- + +## 五、押金与免押额度(**需要你选择**) + +现状:`resolveWorkOrderShareDepositAmount` 按「该打手在本工单累计份额」计算应冻结总额,并与已冻结额取 max(不回退)。拆成多行后,必须明确免押额度怎么分摊,否则可被拆分套利。 + +| 方案 | 规则 | 优点 | 缺点 | +| --- | --- | --- | --- | +| **A 每份独立全额免押** | 每份 `max(0, round(required × q/total) − free)` | 实现最简单 | **可套利**:免押 5000、每份需求 1000 时可无限拆成 0 押金份额 | +| **B 工单累计口径(已确认)** | 先算本工单累计需求 `round(required × 累计份数/total) − 免押额度`,再减去该打手在该工单已冻结合计,差额即本份 `share_deposit`(不为负) | 经济语义与现在完全一致;不会因拆分多冻结或多免押 | 需要一次 SUM 查询;「已冻结不回退」的既有特性保留 | +| **C 按份数比例分摊免押** | `freePerUnit = floor(free / total)`,每份 `max(0, round(required×q/total) − freePerUnit×q)`,尾差归最后一份 | 每份可独立解释 | 需要记录「免押已分摊额度」,多一张表或加列;尾差处理易错 | + +**已确认采用 B(免押额度跟着母单走)**:当前线上语义就是「累计抵扣、只补差额」,B 不改变任何已验证的金额结果;A 会出现新的套利路径;C 引入尾差与额外状态。 + +### 5.1 计算式(定稿) + +```text +// 本工单该打手已参与份数(含本次,排除 cancelled) +cumQty = Σ quantity(该 worker 在该 order 的非 cancelled 份额) + 本次 qty +cumRequired = round(required_deposit_amount × cumQty / sharing_total_quantity) +cumFree = 等级 depositFreeAmount + 个人 personal_deposit_free_amount // 母单维度只用一次 +cumDeposit = max(0, cumRequired − cumFree) +frozenSoFar = Σ share_deposit(该 worker 在该 order 的非 cancelled 份额) +本份冻结 = max(0, cumDeposit − frozenSoFar) +本份 share_deposit = 本份冻结 +``` + +- 免押额度在**同一母单**内累计只抵扣一次,不会因为拆成多份而重复使用。 +- `frozenSoFar` 用 `FOR UPDATE` 读钱包同一事务内求和,天然串行。 +- 释放/扣除只针对该条 `share_deposit`,不影响同工单其他份额。 + + +> 注意:无论选哪个,`share_deposit` 必须记录**该份额实际冻结的增量**,释放/扣除只针对它,避免一次取消把同工单其他份额的押金也解冻。 + +--- + +## 六、计时与超时独立化 + +### 6.1 参与时固化 + +```text +timeoutMinutes = work_orders.timeout_minutes(参与瞬间读取) +deadline_at = timeoutMinutes > 0 ? now + timeoutMinutes 分钟 : NULL +timeout_policy = work_orders.timeout_policy(参与瞬间快照) +``` + +落在 `INSERT work_order_shares` 里,**与冻结押金同一事务**。 + +### 6.2 超时扫描改为按份额 + +`listOverdueWorkOrderShares` 改为: + +```sql +SELECT wos.id, wos.work_order_id, wos.worker_id, wos.quantity, wos.share_deposit, + wos.timeout_minutes, wos.timeout_policy, wos.created_at +FROM work_order_shares wos +JOIN work_orders wo ON wo.id = wos.work_order_id +WHERE wos.status = 'joined' + AND wos.deadline_at IS NOT NULL + AND wos.deadline_at < NOW() + AND wo.status NOT IN ('accepted', 'cancelled') +ORDER BY wos.deadline_at ASC +LIMIT $1 +``` + +变化点:不再读母单 `timeout_minutes`(改读份额快照)、不再要求母单必须 `open`(避免母单被误置 `pending_acceptance` 后份额卡死)。 + +### 6.3 处置只影响该份额 + +`settleOverdueWorkOrderShare` 保留现有押金分支(release / deduct),只改两处: + +1. 策略从 `share.timeout_policy` 取,不再从母单取; +2. 结尾把「无条件 `UPDATE work_orders SET status='open'`」替换为 `recomputeWorkOrderSharingStatusWithClient`(见第七节)。 + +--- + +## 七、母单状态收敛(新增统一函数) + +现状问题:`settleOverdueWorkOrderShare` 结尾会把母单强行置 `open` 并重置 `hall_queued_at`,多份额下会把「其他份额都已提交、本该待验收」的母单踢回大厅。 + +### 7.1 新函数 + +```text +recomputeWorkOrderSharingStatusWithClient(client, workOrderId, now) -> WorkOrderRow + +allocated = Σ quantity(status != 'cancelled') +joined = COUNT(status = 'joined') +submitted = COUNT(status = 'submitted') +accepted = COUNT(status = 'accepted') +total = sharing_total_quantity + +if allocated >= total && joined == 0 && submitted > 0 → pending_acceptance +else if allocated < total → open(若当前是 pending_acceptance 则回退 open) +else if allocated >= total && joined > 0 → open(还有份额未提交) +else if allocated == 0 → open(全部取消,重新可参与) +母单为 accepted / cancelled 时不动 +``` + +### 7.2 所有调用点 + +| 触发 | 调用 | +| --- | --- | +| 份额参与 | `open`(保持) | +| 份额提交验收 | `recompute`(替换现有的内联判断) | +| 份额验收 | `recompute`(最后一个份额验收后母单 `accepted`) | +| 份额超时 | `recompute` | +| 后台撤单份额 | `recompute` | +| 打手退单申请通过 | `recompute` | +| 统一验收 | `recompute`(等价于全部 accepted → 母单 accepted) | + +--- + +## 八、接口变更 + +### 8.1 打手端 + +| 方法 | 路径 | 变化 | +| --- | --- | --- | +| POST | `/api/v1/worker/hall/orders/:workOrderId/join-sharing` | body `quantity` 语义改为「本次份数」;新增 `requestId`(幂等);**不再拒绝已参与过的打手** | +| POST | `/api/v1/worker/hall/orders/:workOrderId/grab` | 拼单工单:`takeRemaining=true` 创建**新**份额(承接全部剩余);同样支持 `requestId` | +| POST | `/api/v1/worker/orders/:workOrderId/submit-acceptance` | body 新增可选 `shareId`;不传且该工单存在多条有效份额 → 400 `work_order_share_id_required` | +| POST | `/api/v1/worker/orders/:workOrderId/acceptance-draft` | 同上 | +| POST | `/api/v1/worker/orders/:workOrderId/acceptance-evidence` | 同上 | +| POST | `/api/v1/worker/orders/:workOrderId/cancel-requests` | body 可选 `shareId`:传 = 只申请撤该份额;不传 = 申请撤销我在该工单的全部有效份额 | +| GET | `/api/v1/worker/orders/:workOrderId` | 返回新增 `myShares[]`;`myShare` 保留为「最新一条未完成,否则最新一条」 | +| GET | `/api/v1/worker/orders` | 列表项新增 `myShares[]`(一行一工单,见 D5) | + +> 兼容策略:所有新增 `shareId` 都是**可选**的;只有「该工单该打手有多条有效份额」时才强制。老前端不会立刻坏。 + +### 8.2 后台端 + +已有按份额的接口,无需新增: + +- `POST /admin/worker-platform/orders/:id/shares/:shareId/accept` +- `POST /admin/worker-platform/orders/:id/shares/:shareId/cancel` +- `GET /admin/worker-platform/orders/:id/sharing` 已返回 `shares[]`(新增 `deadlineAt`、`timeoutMinutes`、`cancelledReason`) + +需要调整的只是展示层:`sharingWorkers` 目前把同一打手的多份额合并成一行(`mapWorkOrderSharingWorkers` 累加 quantity),建议保留汇总但**新增 `sharingShares` 明细分页**,否则后台无法逐份撤单。 + +--- + +## 九、后端改动清单(文件级) + +| 文件 | 改动 | +| --- | --- | +| `db/migrations/104_*.sql` | 新增(DDL + 回填 + 删唯一键 + 索引) | +| `repositories/worker-platform/types.ts` | `WorkOrderShareRow` 加 4 个字段 | +| `repositories/worker-platform/work-order-share-repo.ts` | **核心**:`joinWorkOrderShare` 去掉「合并到 existingShare」分支,改为每次都 INSERT;押金改累计口径;写入 deadline 快照;新增 `getWorkOrderShareById`、`listWorkOrderSharesByWorkerInOrder`;`getWorkOrderShare` 保留但改名语义为「主份额」 | +| `repositories/worker-platform/work-order-share-repo.ts` | `submitWorkOrderShareAcceptance` / `updateWorkOrderShareDraftAcceptance` / `updateWorkOrderShareAcceptanceRecord` 改为接收 `shareId`(workerId 仅做归属校验) | +| `repositories/worker-platform/work-order-query-repo.ts` | `listOverdueWorkOrderShares` 改按 `deadline_at`;`countWorkerActiveOrdersWithClient` 改为按不同工单计数(见 9.1) | +| `repositories/worker-platform/work-order-settlement-repo.ts` | `settleOverdueWorkOrderShare` 用份额快照 + 调用 `recompute`;`acceptWorkOrderShareAndSettle` 结尾调用 `recompute`;`cancelWorkOrderShare` 调用 `recompute` | +| `repositories/worker-platform/worker-cancel-request-repo.ts` | 创建退单申请必须定位到具体 `shareId`;审核通过后 `recompute` | +| `services/worker-platform/worker-hall-action-service.ts` | 拼单参与不再阻塞已参与;deadline 由 repo 计算 | +| `services/worker-platform/worker-acceptance-service.ts` | 3 处 `getWorkOrderShare` 改为「按 shareId 或唯一份额解析」;超时判定用 `share.deadline_at` | +| `services/worker-platform/worker-my-order-service.ts` | 3 处授权判断改为「存在任一有效份额」;响应补 `myShares` | +| `services/worker-platform/work-order-display-mappers.ts` | `mapWorkOrderForWorker` 接收 `myShares[]`;`myShare` 取主份额;`timeoutAt` 用 `share.deadline_at`;`mapWorkOrderShare` 输出新字段;`mapWorkOrderSharingWorkers` 增加明细 | +| `services/worker-platform/admin-work-order-sharing-service.ts` | 返回 `sharingShares` 明细 | +| `routes/worker/hall.ts`、`routes/worker/orders.ts` | 透传 `shareId` | +| `routes/admin/worker-platform/orders.ts` | 无新增(沿用 shareId 路由) | +| `services/worker-platform/index.ts` | 导出新增函数 | + +**新增内部函数**:`recomputeWorkOrderSharingStatusWithClient`(建议放 `work-order-share-repo.ts`,因为它只读份额表)。 + +### 9.1 活跃单计数(D2 已确认:按不同工单) + +```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 + 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; +``` + +`UNION` 自带去重;整单接取与拼单在同一工单上互斥,正常情况下两侧不会重叠。对只有一条份额的打手,结果与旧口径完全一致,无回归。 + + +--- + +## 十、前端改动清单 + +| 文件 | 改动 | +| --- | --- | +| `types/worker-platform.ts` | `myShares?: WorkOrderShare[]`;`WorkOrderShare` 加 `deadlineAt/timeoutMinutes/cancelledReason` | +| `pages/worker/WorkerHallPage.tsx` | 去掉 `disabled={… || Boolean(order.myShare)}`;按钮文案改「再拼 1 份 / 抢剩余 N 份」;显示「已参与 2 次 · 共 3 份」 | +| `pages/worker/worker-order-utils.tsx` | 从 `order.myShare` 改为「当前选中份额」上下文;`canSubmitAcceptance`、`canRequestCancel` 等按份额判断 | +| `pages/worker/WorkerOrdersPage.tsx` + `WorkerOrderMobileList.tsx` + `WorkerOrderTableColumns.tsx` | 一行一工单,展开显示我的份额列表(每份:份数、状态、倒计时、押金、报酬、操作) | +| `pages/worker/WorkerOrderDetailDrawer.tsx` | 份额 Tab / 列表;每份独立「提交验收 / 申请退单 / 补证据」 | +| `pages/worker/WorkerOrderModals.tsx` | 验收、退单弹窗带 `shareId` | +| `pages/admin/panels/WorkOrdersPanel.tsx` | 份额表加「参与时间、截止时间、超时策略」列;统一验收按钮在存在 `joined` 份额时禁用(接口已拦) | +| `pages/admin/panels/AfterSalesPanel.tsx` | 份额下拉在「同一打手多份额」时显示参与时间区分(已有 shareId,仅展示优化) | +| `pages/admin/panels/CancelRequestsPanel.tsx` | 显示份额份数与参与时间 | + +--- + +## 十一、并发与幂等 + +| 场景 | 处理 | +| --- | --- | +| 两个请求同时参与同一工单 | `SELECT … FROM work_orders FOR UPDATE` 已存在,份额求和在同一事务内,天然串行,不会超卖 | +| 同一打手快速点两次 | 客户端每次点击生成一个 `requestId`;服务端按 `(worker_id, request_id)` 先查后插,唯一索引兜底;命中则返回原份额并标记 `duplicated: true`,不重复冻结 | +| 超时任务与打手提交验收并发 | 两者都锁份额行(`FOR UPDATE`);后到者读到 `status != 'joined'` 直接返回,不会重复处置 | +| 统一验收与单份额验收并发 | 都 `FOR UPDATE` 锁工单;仍建议补「已结算份额」的显式标记(见风险 R3) | +| 退单申请与超时并发 | 退单申请锁工单;超时处置也锁工单;后到者按当前状态失败返回 | + +--- + +## 十二、测试计划 + +**单元/集成(必须新增)** + +1. 同一打手连续参与 3 次 → 3 条记录,`quantity` 各自独立,`deadline_at` 依次递增。 +2. 参与时母单 `timeout_minutes=60`,随后母单改为 120 → 已有份额 `deadline_at` 不变。 +3. 份额 A 超时 → 只有 A 变 `cancelled`,B 仍 `joined`;母单仍 `open`。 +4. 份额 A、B 都提交,A 超时(不可能,已提交)→ 跳过;验证只扫 `joined`。 +5. 最后一条 `joined` 份额超时 → 母单按 `recompute` 变为 `pending_acceptance`(其余已提交)或 `open`(其余仍 joined)。 +6. 全部份额取消 → 母单回 `open`,剩余份数 = 全部。 +7. 多份额分别验收 → 每笔结算金额 = 该份额 `share_reward` + `share_deposit`,互不重复。 +8. 押金累计口径:两次参与后 `Σ share_deposit = max(0, 累计需求 − 免押)`;取消其中一份只释放它自己。 +9. 退单申请指定 shareId → 只撤该份额;不传 → 撤该打手在该工单的全部有效份额。 +10. 并发参与同一工单(两个打手)→ 总份数不超 `sharing_total_quantity`。 +11. 同一 `requestId` 重复提交 → 只产生一条份额,第二次返回原份额、不重复冻结押金。 +12. 整单退单(不传 shareId)→ 该打手在该工单的全部有效份额一并撤销,其他打手份额不动。 +13. 拼单验收通知:每份提交各产生一条后台待办(按 shareId 去重);统一验收完成后再产生一条整单完成通知。 +14. 历史单回归:改造前创建的合并份额仍能正常验收、超时、退单。 + +**SQL 护栏**:`npm run check:sql`(迁移不可变、热点索引存在)。 + +--- + +## 十三、上线与回滚 + +**上线顺序** + +1. 迁移 104(加列 → 回填 → 删唯一键 → 建索引)。 +2. 后端发布(新逻辑兼容旧前端:`shareId` 可选、`myShare` 保留)。 +3. 前端发布(切到 `myShares`)。 +4. 观察 3 天:份额表增长、超时任务耗时、验收重复结算告警。 + +**回滚** + +- 代码回滚安全(旧代码读不到新列,但 `myShares` 字段多余无影响)。 +- **数据回滚不安全**:重新加回唯一键前必须合并重复份额。提供脚本草案: + +```sql +-- 回滚前合并:同一工单同一打手的多条份额合并成一条(只适用于未验收的 joined) +-- 已验收/已结算的份额不要合并,否则资金归属会被改写 +``` + +结论:**迁移发布后建议不轻易回滚**,改用开关控制前端是否展示多份额入口(如需灰度,可加 `worker_sharing_multi_participation` 配置键)。 + +**灰度建议**:先在 `worker_hall` 配置里加布尔开关 `allowRepeatParticipation`,默认关闭;开关打开才允许第二次参与,其余逻辑改造后向后兼容。 + +--- + +## 十四、风险清单 + +| # | 风险 | 影响 | 缓解 | +| --- | --- | --- | --- | +| R1 | 免押额度被拆分套利 | 资金 | 选方案 B(累计口径) | +| R2 | 活跃单计数把每份算 1 个名额 | 打手体验 / 可接单量 | 决策 D2;建议按「不同工单数」计 | +| R3 | 统一验收与逐份验收并发重复结算(原有隐患被放大) | 资金 | 验收时加 `settled_at` 或 `settlement_ledger_id` 幂等标记 | +| R4 | 前端遗漏 `myShare` 判断导致按钮永久禁用 | 功能不可用 | 全量替换 + 组件测试 | +| R5 | 超时任务扫描量上升(份额数 > 工单数) | 性能 | `idx_work_order_shares_due` 部分索引 + 每轮 limit | +| R6 | 后台通知按工单去重,第二份提交不提醒 | 运营漏验收 | 分享单的 dedupeKey 加 `shareId`(决策 D7) | +| R7 | 历史合并份额与新单份额混存 | 口径解释成本 | 文档标注;后台展示「本次参与份数」 | +| R8 | `worker_work_order_notes` / `views` 仍按工单+打手唯一 | 多份额共用一条备注 | 视为「对工单的备注」,不拆分;文档说明 | +| R9 | 母单「已份数分配完」但份额被取消后重新开放 | 大厅可见性 | `recompute` 里重置 `hall_queued_at`,避免已看过的人看不到 | + +--- + +## 十五、决策记录(已全部确认) + +| # | 决策 | 选项 | 我的建议 | +| --- | --- | --- | --- | +| D1 | 免押额度怎么分摊 | A 每份全额 / **B 工单累计** / C 按份数比例 | ✅ **已确认:跟着母单走(B)** | +| D2 | 「最大同时接单量」怎么算 | A 每份算 1 单 / **B 按不同工单计** | ✅ **已确认:B** | +| D3 | 撤单/退单申请粒度 | A 只支持整单 / B 只支持份额 | ✅ **已确认:整单 + 单份额都支持** | +| D4 | 保留「统一验收」吗 | A 保留 / B 取消 | ✅ **已确认:两种都要**(统一验收 = 逐份结算批处理) | +| D5 | 我的订单列表形态 | **A 一行一工单 + 展开份额** / B 一行一份额 | ✅ **已确认:A** | +| D6 | 是否需要防误触 | A 不做 / **C 客户端 requestId 幂等** | ✅ **已确认:C** | +| D7 | 验收通知去重 | A 按工单 / **B 拼单按 shareId** | ✅ **已确认:B;统一验收也要通知** | + +--- + +## 十六、执行顺序(范围已确认:一次性做完) + +1. 先跑第四节的数据体检 SQL,确认无重复份额、无脏数据。 +2. 写迁移 `104`(加列 + 回填 + 删唯一键 + 索引 + `request_id` 幂等索引),本地 `db:migrate` 验证,再跑 `check:sql`。 +3. 仓库层:份额创建(每次 INSERT + 幂等 + 押金累计 + deadline 快照)→ 超时(按份额 deadline)→ 验收(按 shareId)→ 撤单(整单 / 单份额两种)→ 新增 `recomputeWorkOrderSharingStatusWithClient` 并替换 7 处内联状态判断。 +4. 服务层与路由:`shareId` 可选兼容、`requestId` 透传、活跃单按不同工单计数。 +5. 通知:拼单验收待办按 `shareId` 去重;统一验收完成后再发一条整单完成通知。 +6. 前端:大厅按钮(可重复参与 + 每次生成 requestId)→ 我的订单(一行一工单 + 展开份额 + 每份独立操作)→ 后台份额表(参与时间/截止时间/逐份验收与撤单)。 +7. 验证:`npm run check`(后端 + 前端)+ Docker 内跑测试,最后手工走一遍:参与 2 次 → 一份超时 → 一份单独验收 → 剩余份数统一验收 → 核对母单状态与钱包流水。 + +> 待你启动 Docker Desktop 后开始第 2 步;代码改动本身不依赖容器,但迁移验证与 PostgreSQL 集成测试必须用容器。 + +--- + +## 十七、执行结果(2026-10-23 一次性完成) + +| 项 | 状态 | 证据 | +| --- | --- | --- | +| 数据体检 | ✅ | 0 重复 `(work_order_id, worker_id)`、0 孤立份额;103 条历史脏状态仅记录未动资金 | +| 迁移 104 | ✅ | `db:migrate` 成功;psql 确认 5 列、唯一键已删、4 个索引;宿主机 `npm run check:sql` 通过 | +| 仓库层 | ✅ | 每次参与 INSERT 新份额;`(worker_id, request_id)` 先查后插幂等;押金按累计口径只补差额;`deadline_at/timeout_minutes/timeout_policy` 参与时快照;`recomputeWorkOrderSharingStatusWithClient` 成为母单状态唯一出口 | +| 超时 / 撤单 | ✅ | 超时只处置单份并写 `cancelled_reason='timeout'`;单份撤单 `admin`、打手退单 `worker_request`;整单 + 单份额两种粒度都支持;均不再各自 UPDATE 母单状态 | +| 服务层 / 路由 | ✅ | 大厅与我的订单透传 `requestId`;多份额验收未传 `shareId` 时返回 400 `work_order_share_id_required`;活跃单按不同工单去重 | +| 通知 | ✅ | 拼单验收待办 dedupeKey 带 `shareId`;新增 `order_accepted` 整单完成通知(含统一验收) | +| 前端 | ✅ | 大厅可重复参与(每次生成 `requestId`,显示「已参与 N 次 · 共 M 份」);我的订单一行一工单 + 展开份额 + 逐份验收/退单;后台份额表新增「状态+撤单原因 / 参与时间 / 截止时间 / 超时策略」并保留逐份验收与单人撤单 | +| 测试 | ✅ | 后端 750 项:735 通过、10 跳过、5 项为既有 Windows/CRLF 环境失败(与本次改动文件无关);前端 143 项全通过 | +| 冒烟走查 | ✅ | 真实库脚本化验证:同一打手 2 次参与生成 2 条份额(押金 2000 + 3000,累计口径)→ `requestId` 重放返回原份额且不重复扣款 → 母单改超时配置不影响已固化 deadline → 单份超时只取消该份、另一份保持 joined、母单仍 open → 补满并逐份提交后母单才转 `pending_acceptance`;脚本数据已全部清理(0 残留) | + +### 已知遗留 + +- 后台 `mapWorkOrderSharingWorkers` 仍按打手汇总一行(用于列表角标),明细操作走份额表,属设计保留。 +- 5 项后端既有测试失败:`backup-db.sh` 在容器内缺 `/deploy` 文件;3 项为 CRLF 下源码字符串守卫断言;1 项 `http-cache` 路由守卫。均未触及本次改动文件,建议后续统一把源码守卫改成忽略行尾。 + diff --git a/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md b/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md new file mode 100644 index 00000000..bc23a932 --- /dev/null +++ b/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md @@ -0,0 +1,467 @@ +# 打手工单深挖:抢单 / 拼单 / 好友赠送 / 结算(2026-10-23) + +> 状态:**梳理稿(待评审,未登记索引)** +> 落笔日期:2026-10-23 +> 代码基线:工作区当前 HEAD(迁移 001–103) +> 范围:`work_orders` 从「可抢」到「已验收 + 资金结算」的完整闭环,含拼单份额、好友赠送、超时/问题单/退单申请、售后与欠款 +> 不在范围:寄卖提报与匹配算法、订单进单、领取页(见 `项目全流程与数据库设计梳理-2026-10-23.md`) + +--- + +## 一、一页结论 + +1. **一条工单只有两个资金入口**:接单时冻结押金(`deposit_freeze`),验收时结算报酬 + 释放押金(`reward_settlement` / `deposit_release`,或延迟解冻)。 +2. **押金不是「按单」而是「按接单关系的未结押金」**:`getOutstandingDepositAmountWithClient(workerId, workOrderId)` 是唯一口径,同一工单多次接取/退回也只处理当前仍冻结的部分。 +3. **拼单的免押额度按「该打手在本工单的累计份额」抵扣一次**,且**已冻结金额不会因为追加份数变少而自动回退**(`Math.max(当前冻结, 新要求)`)。 +4. **好友赠送是「三态叠加」**:`work_orders.status`(生命周期)+ `gift_phase`(赠送阶段)+ `gift_available_at`(冷却到点)。`gift_ready` **不落库**,由到点实时计算。 +5. **确认加好友是一道资金分水岭**:`friend_added_at` 一旦写入,任何「释放押金」的处置都会被强制改成「扣除押金」,寄卖卖家的发布押金同口径一并没收。 +6. **超时、问题单、退单申请三条处置路径共用一套押金策略词表**:`reopen / cancel_release / cancel_deduct`(问题单另有 `return_to_worker`,不碰押金)。 +7. **验收有两条免审通道**:内部打手(`worker_type='internal'`)与 VIP(`autoAcceptWithoutEvidence`,可无图提交,但留 24 小时补证据窗口);**好友赠送单一律人工验收**。 +8. **退单是唯一会掉等级/封号的常规路径**:VIP1 累计 5 次通过 → 直接禁用账号并吊销会话;其他等级每 10 次通过 → 降一级并把 `level_penalty_accepted_count` 抬高,变相提高升级门槛。 +9. **资金全部落在 `worker_wallet_ledgers`**(只追加),但**余额没有「由流水重算」的校验**,也没有会计期间;它仍然是业务钱包,不是总账。 +10. **锁序不统一**:抢单先锁钱包再更新工单,指派先锁工单再锁钱包,拼单先锁工单再锁钱包——`grabWorkOrder` 是唯一的「钱包优先」路径。 + +--- + +## 二、单据与资金模型 + +```mermaid +flowchart LR + subgraph 单据 + WO["work_orders
生命周期状态"] + WS["work_order_shares
拼单份额"] + EV["work_order_events
只追加时间线"] + end + subgraph 资金 + W["worker_wallets
available / frozen_deposit
pending_unfreeze / frozen_withdraw"] + L["worker_wallet_ledgers
只追加流水"] + U["worker_deposit_unfreezes
延迟解冻队列"] + end + WO -->|"整单接取"| D1["deposit_freeze"] + WS -->|"按份额接取"| D1 + WO -->|"验收"| D2["reward_settlement / deposit_release"] + WS -->|"份额验收"| D2 + D2 -->|"unfreezeDays > 0"| U + U -->|"到期任务"| D3["deposit_unfreeze"] + WO -->|"超时/问题单/退单"| D4["deposit_release 或 deposit_deduction"] + D1 --> L + D2 --> L + D3 --> L + D4 --> L + L --> W +``` + +**唯一的余额写入方式**(除提现外)都是「`SELECT … FOR UPDATE` 读钱包 → 计算 next → `UPDATE` → `INSERT ledger`」,没有任何一处只更新余额而不写流水。 + +--- + +## 三、抢单 + +### 3.1 入口与校验顺序 + +`POST /api/v1/worker/hall/orders/:workOrderId/grab` → `grabWorkerHallOrder` + +```mermaid +sequenceDiagram + participant W as 打手 + participant S as hall-action-service + participant DB as PostgreSQL + W->>S: grab(workOrderId) + S->>S: 会话有效 → 读打手 + 等级权限 + 大厅配置 + S->>S: visibleAfterIso = now - visibleDelaySeconds(等级可见延迟) + S->>DB: 读工单 + alt workOrder.sharing_enabled + S->>DB: joinWorkOrderShare(takeRemaining=true) + else 整单抢单 + S->>DB: countWorkerActiveOrders + S->>S: 校验 maxActiveOrders / 可用余额 ≥ 冻结额 + S->>DB: grabWorkOrder(...)(单事务) + S->>DB: work_order_events grabbed + 实时事件 + end +``` + +整单抢单在**事务内**再次做全部校验(防并发),并条件更新: + +```sql +UPDATE work_orders +SET status='in_progress', assigned_worker_id=$1, last_assigned_worker_id=$1, + assigned_at=$2, deadline_at=$3, + gift_phase = CASE WHEN acceptance_mode='friend_gift' THEN 'material_required' ELSE '' END, + booster_material_json='{}', friend_added_at=NULL, gift_available_at=NULL, + , updated_at=$2 +WHERE id=$4 AND status='open' AND assigned_worker_id IS NULL +``` + +`CLEAR_DELIVERY_RESIDUE_SQL` 清掉上一手的 `submitted_at / accepted_at / acceptance_json / draft_acceptance_json / problem_note / worker_note`,但**不动买家资料**(`material.collect`)和寄卖留痕(`consignmentHistory`)。 + +### 3.2 冻结押金怎么算 + +```text +整单:freezeAmount = max(0, work_order.required_deposit_amount - depositFreeAmount) +拼单:nextRequired = round(required_deposit_amount × nextQuantity / totalQuantity) + requiredShareDeposit = max(0, nextRequired - depositFreeAmount) + share_deposit = max(已冻结的 share_deposit, requiredShareDeposit) // 不回退 + 本次冻结 = share_deposit - 已冻结 +``` + +`depositFreeAmount = 等级 permission_json.depositFreeAmount + worker_users.personal_deposit_free_amount`(两者叠加,个人额度是等级之上的追加额度)。 + +> 注意:`deposit_threshold_amount`(押金门槛)目前只读写/展示,不参与冻结计算;自动生成的工单把 `required_deposit_amount` 与 `deposit_threshold_amount` 都写成 0(见风险 W1)。 + +### 3.3 大厅可见性 + +| 规则 | 实现 | +| --- | --- | +| 等级可见延迟 | `permission_json.visibleDelaySeconds`;未配置时按等级键默认:vip1 20s / vip2 15s / vip3 10s / vip4 5s / vip5 0s | +| 大厅总量 | `worker_hall.maxVisibleOrders`(默认 50,上限 500) | +| 分类上限 | `worker_hall.categoryMaxVisibleOrders`(分类 ID → 上限) | +| 拼满隐藏 | `excludeFilledSharing` | + +可见延迟**不是拦截**,而是「只把发布时间早于 `now - delay` 的单放进候选集」,抢单时在事务内再校验一次容量,防止延迟期内被绕过。 + +--- + +## 四、拼单 + +### 4.1 数据模型 + +`work_order_shares`:`UNIQUE(work_order_id, worker_id)`,一份行代表一个打手在该工单的全部份额。 + +| 列 | 含义 | +| --- | --- | +| `quantity` | 该打手累计份数(同一工单可追加) | +| `unit_reward` / `share_reward` | 单份报酬 / 累计报酬 | +| `share_deposit` | 该打手是否该单的累计冻结额(不是本次增量) | +| `status` | `joined → submitted → accepted / cancelled` | +| `acceptance_json` / `draft_acceptance_json` | 验收资料 / 草稿 | + +### 4.2 参与规则 + +- 普通参与:指定 `quantity`(默认 1),不得超过剩余份数 `sharing_total_quantity - Σ(非 cancelled 份额)`。 +- 「立即抢单」:`takeRemaining=true`,在事务锁内**承接全部剩余份数**;如果已有自己的 `joined` 份额,则在此基础上追加,否则报「数量已满」。 +- 整单接取者不能再拼单(`work_order_owner_conflict`)。 +- 重复参与被唯一键挡住;同一打手多次参与只能走「追加份数」路径。 +- 活跃单上限只在**新建份额**时校验(追加不重复计入)。 + +### 4.3 母单状态收敛 + +```text +份额 joined → 母单保持 open(可继续参与) +份额 submitted → 仍 open;若「份数已全部分配」且「没有 joined 份额」→ 母单转 pending_acceptance +单份额验收 → 该份额 accepted;若这是最后一个未结算份额 → 母单 accepted 并补跑寄卖钩子 +全部取消 → 母单回 open +``` + +`submitWorkOrderShareAcceptance` 里那句 `WHERE id=$2 AND status='open'` 是关键:只有母单还在 `open` 时才允许被自动推进,避免与后台手动验收并发覆盖。 + +### 4.4 两种验收姿势 + +| 方式 | 入口 | 行为 | +| --- | --- | --- | +| 单独验收某份额 | `POST /admin/worker-platform/orders/:id/shares/:shareId/accept` | 只结算该份额:份额报酬 + 份额押金;已结算份额不会在统一验收时重复结算 | +| 统一验收母单 | `POST /admin/worker-platform/orders/:id/accept` | 先检查所有份额都已 `submitted`(有任一 `joined` 直接 409 `sharing_pending_submissions`),再逐个结算未结算份额,最后结算整单打手(如有) | + +> 设计上的一个细节:统一验收时 `settledShareCount` 用「`status !== 'accepted'`」统计,已经单独验收过的份额会被跳过——这是避免重复发钱的唯一保护,没有幂等键。 + +### 4.5 已提交份额不能绕过 + +问题单的 `reopen / cancel_*` 与超时处置都会先检查 `getActiveWorkOrderShareCountWithClient`;有活跃份额就拒绝整单处置(`work_order_has_active_shares`)。这是为了防止「母单被整单带走,份额验收入口全部锁死」。 + +--- + +## 五、好友赠送(friend_gift) + +### 5.1 三态叠加 + +```mermaid +stateDiagram-v2 + [*] --> material_required: 建单时 acceptance_mode=friend_gift + material_required --> material_submitted: 打手提交本人资料(昵称/ID/区服/系统 + 主页截图) + material_submitted --> friend_countdown: 后台确认已加好友(记 friend_added_at,算 gift_available_at) + friend_countdown --> gift_ready: 到点(不落库,实时计算) + gift_ready --> pending_acceptance: 打手提交赠送凭证 + pending_acceptance --> accepted: 后台人工验收(必审) + material_submitted --> material_required: 后台重置资料 + friend_countdown --> material_required: 后台重置资料 +``` + +- `work_orders.status` 在整条链路上保持 `in_progress`(或 `problem`),**不因赠送阶段改变**;前端展示用 `resolveWorkOrderGiftPhase()`。 +- 冷却时长 = `gift_cooldown_hours` 小时 + `gift_cooldown_minutes` 分钟;两者都为 0 时按默认 **72 小时** 兜底(`DEFAULT_GIFT_COOLDOWN_HOURS`),上限 30 天。 +- 确认加好友时,`deadline_at` 被顺延为「冷却结束 + `timeout_minutes`」,即**冷却期不计入打手超时**。 +- 提交验收时如果 `gift_phase !== friend_countdown` 或未到点,直接 409 `work_order_gift_cooldown_active`。 + +### 5.2 押金分水岭 + +```text +friend_added_at IS NULL → 取消/退回/超时:按策略正常退还 +friend_added_at 有值 → 取消/退回/超时:释放类一律转为扣除(deposit_deduction) + 寄卖卖家的发布押金同口径没收 +``` + +实现集中在 `isWorkOrderGiftDepositForfeitable()`,被超时处置、问题单处置、退单审核三处共用。语义是:**好友已经加了,赠送动作由打手/卖家完成,此时脱离订单属于套利**。 + +### 5.3 内部现货变体(寄卖) + +寄卖库存绑定工单走 `internal_stock` 时没有接单打手,流程被压缩: + +```text +assigned_worker_id = null,material.consignment.fulfillmentType = internal_stock + → parkWorkOrderWaitingFriendAdd:status=in_progress,gift_phase=material_required(语义变成「待加好友」) + → 客服线下通知买家加好友 → 手动确认 → 进入冷却 + → 冷却结束:后台可直接验收(无需打手交图) +``` + +`hasInternalConsignmentFulfillment()` 是识别标志:`assigned_worker_id=0` + `material.consignment.inventoryId` + 非 `worker_deposit` + 无 `consignment.workerId`。 + +--- + +## 六、验收 + +### 6.1 打手提交(整单) + +| 规则 | 细节 | +| --- | --- | +| 归属 | 必须是 `assigned_worker_id` 本人(寄卖卖家走 `asConsignmentSupplier` 例外) | +| 状态 | `in_progress` / `problem` | +| 超时前置 | 已过期则先执行超时处置再抛 409(接口惰性判定) | +| 图片 | 默认必须至少 1 张(`work_order_acceptance_image_required`);VIP/内部打手可不交图 | +| 赠送单 | 必须 `friend_countdown` 且已到点;**一律人工验收**,VIP/内部免审不适用 | +| 草稿 | 可先存 `draft_acceptance_json`,提交时优先取「本次上传 + 草稿里已有的图」并去重 | + +免审条件:`worker_type === 'internal'` **或** 等级 `permission_json.autoAcceptWithoutEvidence === true`(且不是拼单份额参与,`canWorkerAutoAcceptWholeOrder` 明确排除拼单)。 + +免审且无图时,验收资料写入: + +```json +{ "reviewMode": "vip_auto", "evidenceStatus": "pending", "evidenceDueAt": "" } +``` + +补证据走 `supplementWorkerAcceptedOrderEvidence`(验收后仍可补)。 + +### 6.2 催促 + +- 提交满 **10 分钟** 才能催; +- 每 **30 分钟** 一次,最多 **3 次**; +- 只支持整单(拼单份额不能催); +- 催的是 `admin_notifications` 的同一个待办(`reminder_count` 累加),不会刷屏。 + +### 6.3 后台验收 + +- 只接受 `pending_acceptance`(`capabilities.canAccept`)。 +- 历史拼单母单如果还是 `open`,验收前自动推成 `pending_acceptance`。 +- 内部现货好友赠送在 `gift_ready` 且 `in_progress` 时,后台可直接验收(自动补 `submitted_at`)。 +- 有未提交份额 → 409 `work_order_sharing_pending_submissions`。 + +--- + +## 七、结算(核心) + +### 7.1 一个事务里做完 8 件事 + +`acceptWorkOrderAndSettle`(整单)与 `acceptWorkOrderShareAndSettle`(份额)结构一致: + +```mermaid +flowchart TD + A["锁工单(pending_acceptance)+ 锁全部非取消份额"] --> B{"还有 joined 份额?"} + B -->|是| X["409 sharing_pending_submissions"] + B -->|否| C["工单置 accepted / 份额置 accepted"] + C --> D["释放寄卖发布押金(仅整单,按 material.consignment.sourceSubmissionId)"] + D --> E["逐份额结算:售后欠款抵扣 → 报酬 → 押金"] + E --> F["整单打手结算:押金 → 报酬 → 欠款抵扣"] + F --> G["寄卖供货货款(internal_stock 时补给卖家)"] + G --> H["等级提升判定"] + H --> I["写 work_order_events accepted"] +``` + +### 7.2 资金等式(单次结算) + +```text +releaseAmount = min(该工单未结押金, 钱包 frozen_deposit_amount) +debtOffset = min(售后未结欠款, 本次报酬) // FIFO,按欠款创建时间 +shouldDelay = rewardUnfreezeEnabled && unfreezeDays > 0 +pendingDelta = (shouldDelay ? 报酬 : 0) + (unfreezeDays > 0 ? 押金 : 0) +available' = available + (shouldDelay ? 0 : 报酬) + (unfreezeDays > 0 ? 0 : 押金) − debtOffset +frozen' = max(0, frozen − releaseAmount) +total_settled += 报酬 +``` + +默认财务配置(`worker_finance` 配置键): + +| 项 | 默认 | 范围 | +| --- | --- | --- | +| `depositUnfreezeDays` | 3 天 | 0–30 | +| `rewardUnfreezeEnabled` | 开启 | 布尔 | +| `consignmentPublishDepositFen` | 2000 分(¥20) | 0–1,000,000 | +| `consignmentWhitelistEnabled` | 开启 | 布尔 | + +### 7.3 流水类型对照 + +| 场景 | ledger_type | 金额符号 | +| --- | --- | --- | +| 接单冻结 | `deposit_freeze` | − | +| 验收即释放押金 | `deposit_release` | + | +| 验收延迟解冻(入队) | `deposit_pending_unfreeze` | +(余额不加,进 pending_unfreeze) | +| 到期解冻 | `deposit_unfreeze` | + | +| 验收即结算报酬 | `reward_settlement` | + | +| 验收延迟解冻报酬 | `reward_pending_unfreeze` | + | +| 拼单报酬 | `sharing_reward` | + | +| 售后抵扣 | `after_sales_debt_offset` | − | +| 售后从可用余额扣 | `after_sales_balance_deduction` | − | +| 超时/退单/问题单扣押金 | `deposit_deduction` | − | +| 提现冻结 / 支付 / 解冻 | `withdraw_freeze` / `withdraw_paid` / `withdraw_release` | − / − / + | +| 后台手动加款 | `manual_credit` | + | + +### 7.4 延迟解冻 + +- 入队:`worker_deposit_unfreezes(worker_id, work_order_id, amount, fund_type='deposit'|'reward', unfreeze_at, status='pending')`。 +- 出队:定时任务 `deposit-unfreeze`(60s,scanLimit 100)→ `settleDueDepositUnfreezes`。 +- **到期判定按「当前配置天数」回溯**:管理员把 `depositUnfreezeDays` 调小后,存量队列会提前视为到期(`retroactiveDays`),不会卡在旧到期时间。 +- 提现前后台会先跑一次 `settleDueDepositUnfreezes({workerId})`,避免「到期了但还没解冻、余额显示不足」。 + +### 7.5 售后抵扣顺序 + +```text +验收报酬 → 先抵扣 open 欠款(FIFO)→ 抵扣部分写 after_sales_debt_offset + → 欠款结清时 cases.debt_amount 归零、status 从 recovery_pending 变 closed + → 报酬不足抵扣的部分继续留在 worker_after_sales_debts.outstanding +``` + +### 7.6 等级提升 / 降级 + +| 方向 | 触发 | 规则 | +| --- | --- | --- | +| 升 | 每次验收后 `maybeUpgradeWorkerLevelWithClient` | `有效验收数 = 累计已验收单数 − level_penalty_accepted_count`;取 `upgradeThreshold ≤ 有效数` 的最高等级;`is_level_locked` 时完全不动;只升不降 | +| 降 | 退单申请审核通过 | 非 VIP1:每 **10 次**通过降一级,并把 `level_penalty_accepted_count` 抬到「实际验收数 − 新等级门槛」,提高重新升级成本 | +| 封 | 退单申请审核通过 | VIP1 累计 **5 次**通过 → `status=disabled`、`session_version+1`、吊销全部会话 | + +`worker_level_logs.reason` 取值:`auto` / `cancel_request_penalty` / `cancel_request_ban`。 + +### 7.7 寄卖钩子 + +| 钩子 | 时机 | 作用 | +| --- | --- | --- | +| `releaseConsignmentPublishDepositBySubmissionWithClient` | 整单验收 | 释放卖家发布押金 | +| `settleConsignmentSupplierPayoutByWorkOrderWithClient` | 验收后 | 内部现货履约时订单没有接单打手,货款补给供货卖家;接单人就是卖家本人时不重复付款 | +| `releaseConsignmentInventoryBindingForWorkOrderWithClient` | 超时/退单/问题单/取消 | 把占用的现货放回在库池,避免永久 `consumed` | + +--- + +## 八、三条处置路径的押金矩阵 + +| 处置 | 入口 | 目标状态 | `reopen` | `cancel_release` | `cancel_deduct` | `return_to_worker` | +| --- | --- | --- | --- | --- | --- | --- | +| 超时 | 定时任务 / 接口惰性 | open / cancelled | 退押金 | 退押金 | 扣押金 | — | +| 问题单 | 后台 `problem/resolve` | open / cancelled / 恢复 | 退押金 | 退押金 | 扣押金 | **不动押金**,恢复到 `marked_problem` 的 `from_status` | +| 退单申请 | 后台审核通过 | open(重新上架) | — | 退押金 | — | — | + +补充规则: + +- 三种路径都先检查「是否还有活跃份额」;有则拒绝整单处置。 +- **已确认加好友的赠送单**:`cancel_release` 会被改写成 `cancel_deduct`,事件类型也按实际资金动作记(`timeout_cancel_deduct` / `problem_cancel_deduct`)。 +- 超时与问题单会释放寄卖现货绑定;`return_to_worker` 不会(单还在进行)。 +- 退单申请:待审核期间订单与押金保持原状;审核通过**在同一事务**里撤单、退押金、执行等级处罚。 +- 打手每天最多提 **5 次**退单申请(按 Asia/Shanghai 自然日,`pending + approved` 计数),同一工单+份额不能有两条 pending。 +- 拼单份额超时只在母单 `open` 时生效(份额 `joined` 且 `created_at + timeout_minutes` 已过),否则跳过。 + +--- + +## 九、售后与欠款 + +### 9.1 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> awaiting_worker_response: 后台建单(必须已验收) + awaiting_worker_response --> under_review: 打手提交说明 + 证据 + awaiting_worker_response --> under_review: 后台直接处置 + under_review --> closed: not_upheld(不成立) + under_review --> closed: remedied(已补救) + under_review --> recovery_pending: upheld 且追缴 > 报酬/可扣押金 → 产生欠款 + under_review --> closed: upheld 且全额扣到 + recovery_pending --> closed: 欠款结清(验收抵扣 / 充值抵扣 / 后台代还) +``` + +### 9.2 建单约束 + +| 约束 | 说明 | +| --- | --- | +| 工单必须 `accepted` | `work_order_not_accepted` | +| 指定份额时必须已验收 | `work_order_share_not_accepted` | +| 必须能定位责任打手 | `worker_missing`;内部现货寄卖单没有接单打手时,自动反查供货卖家(`getConsignmentSupplierByWorkOrderWithClient`)作为责任人 | +| 同一「工单 + 打手 + 份额」同时只能有 1 条进行中 case | 由部分唯一索引 `idx_worker_after_sales_cases_active_target` 保证:`UNIQUE(work_order_id, worker_id, COALESCE(work_order_share_id,0)) WHERE status IN ('awaiting_worker_response','under_review','recovery_pending')`,插入用 `ON CONFLICT DO NOTHING`,冲突即 `active_case_exists` | +| 投诉说明必填、≤ 2000 字 | | +| 问题类型默认 `fake_evidence` | | + +### 9.3 追缴顺序 + +```text +1. 该工单尚未解冻的押金(worker_deposit_unfreezes pending, fund_type='deposit') + → 走 deductPendingDepositUnfreeze,直接从队列扣,不进可用余额 +2. 可用余额(available_amount) +3. 仍不足 → 写入 worker_after_sales_debts(status='open', outstanding_amount) + → case.status = recovery_pending +``` + +后续抵扣: + +- 该打手下一次验收报酬 → 自动 FIFO 抵扣(`offsetWorkerAfterSalesDebtsWithClient`); +- 充值入账 → `repayWorkerAfterSalesDebtsFromAvailableWithClient`; +- 后台手动代还 → `repayAfterSalesCaseDebt`。 + +每一笔抵扣都会写 `worker_after_sales_case_events`(`debt_offset` / `debt_settled`)+ 对应钱包流水,两边都有痕迹。 + +--- + +## 十、发现的隐患(按资金风险排序) + +| # | 隐患 | 证据 | 后果 | +| --- | --- | --- | --- | +| W1 | 自动建单硬编码 `required_deposit_amount=0`、`deposit_threshold_amount=0` | `sync-worker-orders-service.ts` | 自动单永远无押金约束,模板里的押金配置是死配置 | +| W2 | 抢单先锁钱包、指派/拼单先锁工单 | `work-order-grab-repo.ts` vs 同文件 `assignWorkOrderToWorker` / `work-order-share-repo.ts` | 并发抢单+指派可 40P01 死锁;目前只靠客户端重试 | +| W3 | 统一验收的「已结算份额」判断只看 `status !== 'accepted'`,没有幂等键 | `acceptWorkOrderAndSettle` | 极端并发下(两次验收同时进入)可能重复发报酬;`FOR UPDATE` 锁工单能挡住大部分,但没有业务幂等 | +| W4 | 发单金额 > 用户付款只在前端提示 | `publishAdminWorkOrder` 只校验 `reward_amount > 0` | 直接调 API 可发亏损单;`source_order_amount_fen` 只是记录,不参与校验 | +| W5 | 发布工单时电子凭证核销失败只写事件,不回滚发布 | `publishAdminWorkOrder` 的 `voucherConsume.ok=false` 分支 | 券没核销但工单已在大厅;指派路径却会拦截,同一规则两套行为 | +| W6 | 钱包余额没有「由流水重算」校验,也没有月度关账 | `worker_wallets` 可变字段 | 任何一次漏写/重复写流水都不会被系统发现,只能人工对账 | +| W7 | `work_orders.task_id` 是历史遗留列(迁移 091 已注释废弃) | 仍参与部分 mapper | 新人容易误以为「工单必须挂履约任务」 | + +--- + +## 十一、两笔完整流水示例 + +> 先记一条硬约束:**好友赠送与拼单互斥**。建单时 `sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode) ? false : ...`,所以不存在「好友赠送 + 拼单份额」的单子。 + +### 11.1 拼单单 + +设定:`sharing_total_quantity=3`、`unit_reward=3000 分`、`required_deposit_amount=6000 分`;打手 A 等级免押 1000 分、活跃上限 3;`depositUnfreezeDays=3`、`rewardUnfreezeEnabled=true`。 + +| 步骤 | 动作 | 钱包变化 | 关键字段/流水 | +| --- | --- | --- | --- | +| 1 | A 参与 2 份 | nextRequired = 6000×2/3 = 4000;requiredShare = 4000−1000 = 3000;冻结 3000 | `work_order_shares(quantity=2, share_deposit=3000)`,`deposit_freeze −3000` | +| 2 | A 追加 1 份 | nextRequired = 6000;requiredShare = 5000;本次冻结 2000 | `quantity=3, share_deposit=5000`,`deposit_freeze −2000` | +| 3 | 提交验收 | 份数已满且无 `joined` 份额 → 母单 `open → pending_acceptance` | `share.status=submitted` | +| 4 | 验收该份额 | 报酬 9000 延迟解冻;押金 5000 延迟解冻 | `pending_unfreeze +14000`,`reward_pending_unfreeze +9000`,`deposit_pending_unfreeze +5000` | +| 5 | 3 天后任务 | 到期释放 | `deposit_unfreeze +14000`,`pending_unfreeze −14000` | + +### 11.2 好友赠送单 + +设定:`acceptance_mode=friend_gift`、`required_deposit_amount=5000`、免押 0、冷却 72h、`timeout_minutes=120`。 + +| 步骤 | 动作 | 结果 | +| --- | --- | --- | +| 1 | 打手抢单 | 冻结 5000(`deposit_freeze`);`gift_phase=material_required`;`deadline_at=now+2h` | +| 2 | 提交本人资料 | `gift_phase=material_submitted`;`friend_added_at=NULL` | +| 3 | 客服确认加好友 | `friend_added_at=now`;`gift_available_at=now+72h`;`deadline_at=now+72h+2h`(冷却不计超时) | +| 4 | 冷却中尝试提交 | 409 `work_order_gift_cooldown_active` | +| 5 | 到点后提交赠送凭证 | `status=pending_acceptance`;**不触发免审**(赠送单强制人工) | +| 6 | 客服验收 | 押金 5000 → 延迟解冻;报酬按 `reward_amount` 入账(同样按 `rewardUnfreezeEnabled` 决定是否延迟) | +| 7′ | 若第 3 步之后打手退单 | 押金从「应释放」改为 `deposit_deduction −5000`;寄卖发布押金同口径没收 | + +--- + +## 十二、待确认问题(等你回答后我再补口径) + +1. **押金策略的业务意图**:`required_deposit_amount` 现在是「按单金额」还是「按 SKU 单价 × 份数」?自动单为什么固定为 0——是刻意豁免,还是历史遗留? +2. **延迟解冻的初衷**:`depositUnfreezeDays=3` + `rewardUnfreezeEnabled=true` 是为了防售后,还是为了压打手现金流?这决定售后追缴是否应该优先扣「待解冻队列」而不是可用余额。 +3. **VIP1 五次退单封号**这条规则现在还在执行吗?线上是否有误封申诉流程(代码里只有 `review_note`)? +4. **好友赠送冷却 72h** 是否要按品类差异化?现在只有模板上的 `gift_cooldown_hours/minutes` 两个字段。 +5. **拼单是否允许「一人多份 + 全部单人完成」**?当前逻辑允许,且验收后一次性结算全部份额——如果业务上要求分散风险,这条需要改。 diff --git a/docs/项目全流程与数据库设计梳理-2026-10-23.md b/docs/项目全流程与数据库设计梳理-2026-10-23.md new file mode 100644 index 00000000..8dfb3385 --- /dev/null +++ b/docs/项目全流程与数据库设计梳理-2026-10-23.md @@ -0,0 +1,874 @@ +# order_site 全流程与数据库设计梳理(2026-10-23) + +> 状态:**梳理稿(待评审,尚未登记到 `docs/README.md`)** +> 落笔日期:2026-10-23 +> 代码基线:工作区当前 HEAD;数据库迁移 `001`–`103`(共 103 个文件,本地库 `schema_migrations` 记录 103 条,已全量应用) +> 核对方式:源码走查 + 本地 PostgreSQL 实读表结构 / 约束 / 索引 + 本地库状态与数据量统计 +> 说明:本地库与生产同量级(`orders` 约 37.5 万行),本文引用的数据量均为**本地库实测**,不与生产承诺一致,仅用于判断结构合理性。 + +--- + +## 一、先看结论(10 条) + +1. 系统早已不是「快手自动发货工具」,而是**三来源订单履约 + 打手众包接单 + 寄卖仓库 + 电子凭证核销 + 客服备用金台账**的一体化平台。 +2. 订单来源只有三个:`kuaishou-91`(91 回调 + 快手 send-code 两条通道,**同一个来源**)、`pdd`、`douyin-mashida`。代码层的来源键定义在 `apps/backend/src/services/product-match/types.ts`。 +3. **核心不变式:一个订单 = 骨架(`orders` + `order_items`)+ 买家凭证(快手电子凭证 / 抖音券)+ 一个执行载体**。载体只有三种:`affiliate_dash` 自动发货、`work_orders` 工单、`manual` 人工(当前无显式表,靠「无任务且无工单」判定)。 +4. 履约任务按「订单项 × 数量」拆单元:`fulfillment_tasks` 的 `UNIQUE(order_item_id, unit_index)` 是整套领取链接体系的基座。 +5. 领取链接 = `claimLinkBase + '/' + claim_tokens.token`,一任务一 token(`claim_tokens.task_id` 唯一);`CLAIM_TOKEN_TTL_HOURS=0` 表示永久有效。 +6. 打手侧是一条**独立业务线**:工单状态机 `pending_material → unassigned → open → in_progress → pending_acceptance → accepted`,另有 `problem`、`cancelled`;资金走「钱包 + 流水」模型,不是会计总账。 +7. 寄卖是第三个业务面:提报 → 审核入库(`INV…`)→ 规格匹配(系统/区服/系列/钻级/璀璨)→ 履约(内部现货 or 指派打手)→ 结算与撤销押金策略。 +8. 备用金台账(迁移 100)已落地:`fund_accounts`(主管池 / 客服账户)+ `fund_entries`(只追加流水,余额只能由流水变动)+ `manual_expenses` + `fund_reconciliations`。 +9. 数据库现状:**60 张业务表 + `schema_migrations`**,113 个外键、21 个索引的 `work_orders` 是全库索引最多的表;配置不入表结构,统一存 `app_config_entries`(本地 18 个配置键)。 +10. 主要架构性风险仍是 2026-10-17 报告列的那几处:资金门禁只在前端、Webhook 先落库后处理、`/collect` `/redeem` 无第二因子、抢单/指派锁序相反、单实例依赖进程内存。 + +--- + +## 二、系统地图 + +### 2.1 运行拓扑 + +```mermaid +flowchart LR + U1["外部平台
91卡券 / 快手 / 拼多多 / 抖音码速达"] --> C + U2["买家
领取页 / H5 / 兑换页"] --> C + U3["打手
worker.khhao.com"] --> C + U4["后台运营 / 客服 / 财务"] --> C + C["Caddy :80/443
反代 + 静态缓存 + 压缩"] --> F["frontend
React 19 + Vite(一套产物三个入口)"] + C --> B["backend
Express 5 + TS
routes → services → repositories → pg"] + B --> P[("PostgreSQL 16")] + B --> O["对象存储
生产 OSS / 本地 MinIO"] + B --> X["外部 API
affiliate-dash / 快手 OpenAPI / 抖音码速达 / PDD"] + B --> M["进程内存
限流桶 / 调度器 / SSE / 列表缓存"] +``` + +单实例假设写在代码里:限流、定时任务、SSE 订阅、列表缓存全部在进程内存(`middleware/rate-limit.ts`、`services/realtime/`),扩容前必须先外置。 + +### 2.2 后端入口与路由挂载 + +入口 `apps/backend/src/app.ts`: + +| 挂载点 | 说明 | +| --- | --- | +| `/health`、`/health/live`、`/health/ready` | 健康检查;未 ready 时其余请求统一 503 | +| `/api/v1/open/douyin-mashida` | **必须在全局 body parser 之前**挂载,签名基于原始 JSON 字符串 | +| `/api/v1/open/affiliate-dash` | 发货平台异步通知(验签 + 时间戳容差) | +| `/api/v1/open/91` | 91 卡券异步下单 / 查询(验签) | +| `/api/v1/open/kuaishou-industry` | 快手行业电子凭证 send-code / destroy / query / consume | +| `/api/v1/redeem` | 抖音券兑换页接口 | +| `/api/v1/claim` | 买家领取页(token 与 H5 两种入口) | +| `/api/v1/collect` | 待补全订单资料收集 | +| `/api/v1/pdd/extension` | 拼多多浏览器扩展上报 Cookie / 公钥 | +| `/api/v1/files` | 文件对象读取 | +| `/api/v1/worker` | 打手端(公开鉴权路由 + 会话路由) | +| `/api/dcauth` | 设备认证(dcauth 协议) | +| `/api/v1/admin` | 后台 API(auth 路由放行,其余全部 `requireAdminSession`) | +| `/s` | 短链跳转 | + +### 2.3 分层与模块职责 + +| 层 | 目录 | 职责 | +| --- | --- | --- | +| 路由 | `src/routes/**` | 参数解析、限流、鉴权、调用 service | +| 领域 | `src/domain/**` | 状态机与纯策略(任务、工单、寄卖、押金、验收模式) | +| 服务 | `src/services/**` | 订单、履约、工单平台、平台对接、财务、寄卖、调度 | +| 仓储 | `src/repositories/**` | 全部 SQL(含 `worker-platform/*` 大仓储) | +| 迁移 | `src/db/migrations` | 001 基线 + 102 个增量 | +| 前端 | `apps/frontend/src/pages/**` | `admin` 后台 / `worker` 打手端 / `claim` 领取页 / `redeem` 兑换页 / `collect` 补资料 | + +--- + +## 三、端到端主流程 + +### 3.1 总览(一条单的完整生命周期) + +```mermaid +flowchart TD + A["来源进单
91 / PDD / 抖音"] --> B["upsertOrderFromSource
advisory lock 去重 + 状态合并"] + B --> C["order_items 落库
resolveOrderItemForFulfillment 判定载体"] + C --> D{"isConfigured?"} + D -->|否| E["待补全
91 返回 orderStatus=10"] + C -->|是| F["syncDeliveryTasksForOrder
按 order_item × quantity 建任务单元"] + F --> G{"executor_key"} + G -->|affiliate_dash| H["自动发货
建 affiliate-dash 单 + 领取链接"] + G -->|manual_dispatch| I["人工履约
manual_review 待办"] + C --> J["syncWorkerOrdersForSourceOrder
product-match 匹配接单模板"] + J -->|命中且 auto_create| K["work_orders
待补素材 / 待发布"] + K --> L["打手大厅抢单 / 后台指派"] + L --> M["代练履约 → 提交验收"] + M --> N["验收 → 结算报酬 / 释放押金 / 等级进度"] + H --> O["买家领取页 claimUrl
填 UID → 绑定 → 提交发货"] + O --> P["affiliate-dash delivered 回调"] + P --> Q["任务 completed / redeemed
快手电子凭证核销闭环"] + N --> R["钱包流水 + 售后 / 欠款"] + Q --> S["后台报表 / 备用金台账 / 审计"] + R --> S +``` + +### 3.2 三个来源的进单通道 + +| sourceKey(业务口径) | 实际 provider / platform | 入口 | 数据形态 | 备注 | +| --- | --- | --- | --- | --- | +| `kuaishou-91` | `91kaquan` / `kuaishou` | `POST /api/v1/open/91/orders/create` | 一单一条,`productNo` 解析出商品名与店铺 ID | 91 是订单骨架权威源 | +| `kuaishou-91` | `91kaquan` / `kuaishou` | `POST /api/v1/open/kuaishou-industry/send-code` | 快手电子凭证补全/兜底 | 与 91 共享店铺键空间,**不是新来源** | +| `pdd` | `pdd` / `pdd` | 定时任务 `pdd_order_sync`(20s)拉取 | 两段式 `pdd_raw_orders → orders` | 浏览器扩展上报 Cookie | +| `douyin-mashida` | `douyin_mashida` / `doudian` | `POST /api/v1/open/douyin-mashida/message` | 消息推送,先落 `webhook_events` 去重 | 抖店订单 + DYC 券 | + +本地库实测分布:91 368,355 单;PDD 5,563 单;抖音 1,541 单。 + +#### 3.2.1 91 卡券(下单 + 查询) + +```mermaid +sequenceDiagram + participant K as 91卡券 + participant API as backend /open/91 + participant S as order-service + participant DB as PostgreSQL + K->>API: POST /orders/create(验签) + API->>API: buildOpen91SourceEvent
provider=91kaquan, platform=kuaishou
totalAmount=maxAmount(分) + API->>S: upsertOrderFromSource(event) + S->>DB: pg_advisory_xact_lock(hashtextextended('provider|platform|orderNo')) + S->>DB: upsert orders + replace order_items + S->>S: 载体判定 → 建任务 / 建工单 + API-->>K: orderStatus=10(未生成 claimUrl)或成功 + K->>API: POST /orders/query + API-->>K: orderStatus=20 + cards(claimUrl 加密卡密)
或 10(处理中)/ 30(失败) +``` + +要点: + +- 订单幂等键是 `UNIQUE(provider, platform, shop_id, platform_order_id)`,事务内先取 advisory lock,避免并发重复建单。 +- 状态合并用优先级表:`created(0) < paid(1) < closed(2) < refunded(3)`,`pay_status` 同理;`pending_config` 这类历史状态优先级最低,会被新事件覆盖。 +- 91 查询的三种状态:`10` 处理中/未就绪,`20` 已发货(`cards` 为加密后的 claimUrl),`30` 失败(`pending_config`、`manual_failed` 或任务失败)。 +- 历史遗留:库中仍有 25 条 `order_status='pending_config'` 的 91 老单(最近一条 2026-08-11),当前代码不再写入该状态,只作为查询口径识别。 + +#### 3.2.2 快手行业电子凭证(send-code 四条接口 + 两条出站回调) + +- 券号与 91 订单解耦:`voucher_code = 'KSV' + Crockford Base32(10 字节随机)`,`UNIQUE(voucher_code)`,冲突重试 5 次。 +- **幂等键是 `UNIQUE(oid, unit_index)`**:重复 `send-code` 返回同一批 `eticket.id`;`unit_index = 1..num`。 +- 未找到 91 任务时券先落库(`task_id` 为空),后续由 `voucher-binding-service` 绑定,支持「快手先发码、91 后建单」。 +- 出站回调用 `send_callback_status/attempt_count/last_error/response_json/sent_at` 记录,失败可重推。 +- 核销流水号幂等:`consume_serial_num = CONSUME-{voucher_code}`;状态只有 `UNUSED / CONSUMED / DESTROYED`。 +- 本地库实测:`CONSUMED` 376,179、`DESTROYED` 26,815、`UNUSED` 106 —— 说明核销链路是真实主路径。 + +#### 3.2.3 拼多多(两段式) + +```text +pdd_order_sync 定时任务(默认 20s / pageSize 10) + → pdd-client 拉订单(Cookie + anti-content) + → upsert pdd_raw_orders(UNIQUE(order_sn)) + → processPendingPddRawOrders 逐条消费 + → pdd-order-mapper 归一化 + → upsertOrderFromSource + → sync_status: pending → synced / ignored / failed + → 后台可对 failed 的原始单做 replay +``` + +设计意图:原始报文与业务订单分离,失败可重放,不会因为映射问题丢单。断路器与 Cookie 过期识别在 `pdd-sync-circuit-breaker.ts` / `isPddCookieExpiredError`。 + +#### 3.2.4 抖音码速达(推送 → 建单 → 发券 → 领取) + +```mermaid +sequenceDiagram + participant D as 抖音码速达 + participant N as notify-service + participant O as order-service + participant C as certificate-service + D->>N: POST /message?topic&pub_time&sign(原始 body) + N->>N: 验签(data 原始字符串) + N->>N: eventId = f(topic, pub_time, data) + N->>N: insertWebhookEventOnce(先落库去重) + alt topic = trade_TradePaid + N->>D: 拉订单详情(网关) + N->>O: upsertOrderFromSource + N->>C: 生成 / 同步 DYC 券(redeem_certificates) + N->>D: 推送给买家的领取链接 + else trade_TradeCanceled / refund_RefundSuccess + N->>C: 券失效 + 订单状态收敛 + end +``` + +- 抖音 `sku_order_list[].code` 是**内部 SKU**,`product_name` 是引流标题;`sku_code` 恒存内部 SKU,标题只进 `snapshot.productTitle`,永不参与匹配。 +- 券表 `redeem_certificates` 自带状态机 `issued / synced / verified / invalid` 与同步状态 `pending / success / failed`,核销走 `coupons.verifyV3`。 +- **已知缺陷**:`webhook_events` 先插入后处理,处理失败时平台重推会被判 `duplicate` 丢弃(详见风险清单)。 + +### 3.3 载体判定(本系统最关键的一步) + +```mermaid +flowchart TD + A["order_item"] --> B{"来源"} + B -->|91 / 快手| C["productNo → affiliate-dash sku 映射
常量表 + 后台 skuMapping 覆盖"] + B -->|抖音| D["内部 SKU → affiliate-dash sku 映射"] + C --> E["resolveFulfillmentRoute"] + D --> E + E --> F{"路由顺序"} + F --> G["1. 履约路由总开关
关闭 = 停止自动履约"] + F --> H["2. 指定匹配(显式映射)"] + F --> I["3. 商品名规则 exact / contains"] + F --> J["4. 内部 SKU 规则"] + F --> K["5. 全局执行器优先级"] + F --> L["6. unmatchedExecutorKey(生产为空)"] + G --> M["snapshot.fulfillmentRoute
selectedExecutorKey / reason / candidates / skipped"] + H --> M + I --> M + J --> M + K --> M + L --> M + M --> N{"selectedExecutorKey"} + N -->|affiliate_dash| O["isConfigured=true
建自动履约任务"] + N -->|manual_dispatch| P["isConfigured=true
建人工任务"] + N -->|空| Q["isConfigured=false
待补全 / 交给发单层判定"] +``` + +判定结果全部写进 `order_items.item_snapshot_json`: + +| 快照键 | 内容 | +| --- | --- | +| `fulfillmentRoute` | `selectedExecutorKey`、`reason`、`candidates`、`skipped`、`matchedRule` | +| `classification`(抖音) | `channel: affiliate_dash / unmatched`、`canonicalSku` | +| `affiliateDash` | 命中的 `sku`、`productNo`、`skuName`、`matchMode` | +| `isConfigured` | 是否已配置可执行载体 | +| `productTitle`(抖音) | 引流长标题(只读展示) | +| `kuaishouSendCode` | 快手 send-code 补全的卖家/SKU 上下文 | + +**载体互斥守护**:`sync-worker-orders-service.isAffiliateDashCarrierSnapshot()` 会跳过 `classification.channel === 'affiliate_dash'` 的订单项,避免自动发货与工单双通道同时消费一个订单项。 + +### 3.4 履约任务与领取链接 + +1. `syncDeliveryTasksForOrder` 对每个订单项按 `quantity` 补建 `unit_index = 1..n`,重复执行只补缺口。 +2. 执行器来自 `snapshot.fulfillmentRoute`: + - `affiliate_dash` → 绑定 `fulfillment_profiles.profile_key='affiliate_dash'`,`requires_claim=true`,初始状态 `paid`; + - `manual_dispatch` → `profile_key='manual_review'`,初始状态 `manual_review`; + - 未支付 → `pending_payment`。 +3. 已支付订单立即 `preparePaidFulfillmentTask`(执行器注册表:`affiliate_dash` / `manual` / `legacy-retired` 只读桩)。 +4. 领取 token:`createTaskClaimToken` 写 `claim_tokens`(`task_id` 唯一、`token` 唯一、`max_use_count=1`),`claimUrl = claimBaseUrl + '/' + token`。 +5. 抖音走独立基址 `claimLinkBase`(后台配置),**刻意不回退** `orders.claimBaseUrl`。 +6. `orders.claim_status`(迁移 099)= `active / paused / closed`,用于暂停或关闭整单兑换,是后台处置链接失效问题的新开关。 + +### 3.5 affiliate-dash 自动履约 + +```text +建任务(executor=affiliate_dash,requires_claim=true) + → preparePaidFulfillmentTask:调 affiliate-dash 建单,拿到 orderNo / bindUrl / qrUrl + → 生成 claim token 与 claimUrl(91 查询即可返回 20) + → 建单成功时即核销快手电子凭证(consumeStatus=success;失败只告警) + → 买家在领取页:查 delivery → 填 UID → bind → 轮询 bind-result → submit + → affiliate-dash 回调 order.shipping.updated(delivered) + → 任务 completed / redeemed;回调只做状态收敛与兜底补核销 +``` + +任务上下文固定形状 `context_json.affiliateDash`(`AffiliateDashFlow`),包含 `sku / orderNo / clientOrderNo / orderStatus / canShip / bindUrl / qrUrl / bound / submitStatus / consumeStatus / lastSyncedAt`。 + +### 3.6 买家侧三个页面 + +| 入口 | 路由 | 用途 | +| --- | --- | --- | +| 主站领取页 | `/#/claim/:token` | 快手渠道主路径,按 `executorKey` 渲染不同步骤 | +| H5 领取页 | `/h5/:orderNo`(BrowserRouter 分支) | 抖音等按订单号承接,`/api/v1/claim/h5/...` | +| 兑换页 | `/redeem` | 抖音 DYC 券兑换/核销 | +| 补资料页 | `/collect` | 待补全订单资料收集(**仅凭订单号,无第二因子**) | + +领取页快照与轮询逻辑在 `pages/claim/claim-snapshot.ts`、`claim-poll.ts`;`affiliate-dash` 的具体步骤在 `ClaimAffiliateDashSteps.tsx`(查 delivery → UID → bind → 轮询 → submit)。 + +### 3.7 打手众包:从匹配到结算 + +#### 3.7.1 接单模板匹配(product-match 引擎) + +匹配引擎是**纯函数**(`services/product-match/engine.ts`),来源差异只在适配器: + +| 概念 | 说明 | +| --- | --- | +| 条目 `entry` | 一条接单模板(`work_product_rules`)或其映射(`work_product_rule_mappings`)编译而来 | +| 作用域 | `sourceKeys`、`shopKeys`、`qtyMin/qtyMax`:配了就必须命中 | +| 条件组 | `all`(全部命中)、`any`(至少一条)、`bonus`(命中加分、不中不淘汰) | +| 匹配模式 | `exact`、`contains`、`series`(剥离「N个」后比系列,兼容秘钥/密钥)、`series-exact` | +| 权重 | `skuId/relSkuId 900 > skuText/skuNick 800 > series 600 > skuCode 500 > itemId 300 > itemTitle 200`(contains 降档,文本另有「具体度」加成上限 80) | +| 结果 | 唯一最高分 → `matched`;并列最高 → `ambiguous`(**不猜**);无候选 → `unmatched` | + +每次匹配都会写 `work_product_match_logs`(含 `candidates_json`、`raw_payload_json`),后台有「匹配测试 / 匹配日志 / 日志清理」三个入口。 + +#### 3.7.2 建单规则 + +```text +for each order_item: + if snapshot.classification.channel == 'affiliate_dash' → skip(载体已被自动发货占用) + if 该订单项已有工单 → skip(already_exists,不纠正旧单) + match = 匹配引擎 + 无命中/歧义 → skip + 日志 + 命中但 auto_create=false 且 autoOnly → skip + platform_order_id 已挂在**其他**订单下 → skip(防撞号) + 建单: + status = 素材必填项齐全 ? unassigned : pending_material + reward = 单价 × SKU 数量(unit_price_fen)或固定 reward_amount + sharing(拼单)按模板 sharing_total_quantity / unit_reward 计算 + acceptance_mode = normal / friend_gift(好友赠送) + timeout_minutes + timeout_policy(reopen / cancel_release / cancel_deduct) + material = 来源摘要 +(抖音)已有券快照 +pending_material → 生成后台「待补素材」通知 +``` + +#### 3.7.3 工单生命周期 + +```mermaid +stateDiagram-v2 + [*] --> pending_material + pending_material --> unassigned: 素材补全 + pending_material --> cancelled: 取消 + unassigned --> open: 发布到大厅 + unassigned --> in_progress: 后台指派 + open --> in_progress: 打手抢单 / 拼单参与 + in_progress --> pending_acceptance: 打手提交验收 + pending_acceptance --> accepted: 验收通过(结算) + pending_acceptance --> problem: 发起问题单 + problem --> open: 退回大厅 + problem --> cancelled: 取消 + in_progress --> open: 超时按 reopen 策略 + in_progress --> cancelled: 超时按 cancel 策略 + open --> cancelled: 取消 + accepted --> [*] + cancelled --> [*] +``` + +- **抢单**:单事务内 `ensureWallet → 校验活跃单上限 → 校验可用余额 ≥ 押金 → 条件更新 `WHERE status='open' AND assigned_worker_id IS NULL` → 冻结押金(`deposit_freeze` 流水)→ 写 `work_order_events`。 +- **指派**:先 `SELECT … FOR UPDATE` 锁工单,再锁钱包(与抢单顺序相反,见风险 R4)。 +- **拼单**:`work_order_shares` 一份一行,`UNIQUE(work_order_id, worker_id)`;份额状态 `joined → submitted → accepted / cancelled`;母单可长期 `open`,最后一个份额验收后母单转 `accepted`。 +- **好友赠送**:`acceptance_mode='friend_gift'`,`gift_phase`:`material_required → material_submitted → friend_countdown → gift_ready`,冷却结束才能提交赠送凭证;确认加好友后不再退押金(防套利)。 +- **超时**:定时任务 `work-order-timeout`(默认 60s,scanLimit 50)扫描 `deadline_at`,按 `timeout_policy`(`reopen` 退回大厅 / `cancel_release` 取消并退押金 / `cancel_deduct` 取消并扣押金)处置;已确认加好友的赠送单即使配 `cancel_release` 也会转为扣款。超时只写 `timeout_*` 事件并释放寄卖现货,**不直接改等级计数**(等级处罚来自退单审核通过次数,见 3.7.6)。 +- **验收与结算**(`acceptWorkOrderAndSettle`):锁工单与份额 → 报酬入账(可延迟解冻)→ 释放/延迟解冻押金(`worker_deposit_unfreezes` 队列)→ 抵扣售后欠款 → 升级等级 → 写 `worker_wallet_ledgers`。 +- **售后**:`worker_after_sales_cases`(问题类型、投诉、验收快照、处置动作、退款)+ `worker_after_sales_debts`(欠款余额)+ `worker_after_sales_case_events`(时间线)。 + +#### 3.7.4 打手资金模型 + +| 表 | 作用 | +| --- | --- | +| `worker_wallets` | 可用余额、冻结押金、累计入账、累计结算、待解冻、提现冻结 | +| `worker_wallet_ledgers` | 只追加流水,13 种类型(见下) | +| `worker_finance_requests` | 充值 / 提现申请与审核 | +| `worker_withdrawal_accounts` | 提现账户(微信 / 支付宝二维码) | +| `worker_deposit_unfreezes` | 延迟解冻队列(押金、报酬两类) | + +实测流水类型分布(本地库):`reward_settlement 108,623`、`sharing_reward 34,295`、`deposit_freeze 8,283`、`deposit_release 6,705`、`withdraw_paid 6,381`、`withdraw_freeze 5,833`、`deposit_deduction 920`、`deposit_pending_unfreeze / deposit_unfreeze 各 621`、`manual_credit 398`、`withdraw_release 121`、`after_sales_balance_deduction 20`、`after_sales_debt_offset 9`。 + +#### 3.7.5 等级、权限与风控 + +- `worker_levels.permission_json` 承载:免押额度、最大同时接单量、大厅可见延迟、自动验收(VIP)。 +- `worker_level_logs` 记录升降级;`worker_users.is_level_locked` 可锁定等级。 +- `worker_sessions` 会话落库 + `session_version`,设备数与设备类型可限制。 +- `worker_risk_events` 记录刷单/异常行为与处置(ban / unban / release),`middleware/worker-risk-control.ts` 做抢单前置限流与拦截。 +- 提现黑名单:`worker_users.is_withdrawal_blacklisted` + 原因 + 时间。 + +> 抢单/拼单/好友赠送/验收/结算的逐行细节(含押金矩阵、资金等式、退单处罚规则)见 [打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md](打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md)。 + +### 3.8 寄卖仓库 + +```mermaid +flowchart TD + A["卖家提报 consignment_submissions
游戏/系统/区服/系列/钻级/璀璨/要价/凭证"] --> B{"客服审核"} + B -->|驳回| C["rejected(可改后重提)"] + B -->|通过| D["入库 consignment_inventories
inventory_no = INV…
status=in_stock"] + D --> E{"订单匹配"} + E -->|规格匹配| F["占用 consumed
bound_order_id / bound_work_order_id"] + E -->|不匹配| D + F --> G{"履约方式"} + G -->|internal_stock| H["内部现货交付
客服操作"] + G -->|worker_deposit| I["指派打手代练
冻结打手押金"] + H --> J["验收 → 释放发布押金 + 结货款"] + I --> J + F -.->|撤销| K["revoke:打手押金 + 卖家押金
auto / refund / forfeit / keep_frozen"] +``` + +匹配口径(`consignment-matching-service.ts`): + +- **系统 / 区服 / 系列必须一致**(区服「互通」、系统「通用」视为通配); +- **钻级与璀璨低配不充高配**:号主 2 钻不能配 1 钻库存; +- 三态比对:`match` / `mismatch` / `unknown`,`unknown`(任一侧没值)**不算精确匹配**,只能进部分匹配候选; +- 人工指定时用 `classifyConsignmentMatch` 返回 `conflicts`,把「差在哪」显式告诉客服; +- 建议价从接单模板反推(`work_product_rules` 商品名 → 规格解析 → 最接近档位兜底,命中多条取最高价)。 + +卖家视角的单一阶段由 `deriveConsignmentItemStage` 从「提报 + 库存 + 工单」三套状态推导:`pending_review → in_stock → matched_waiting_friend → gift_cooldown → gift_ready → gift_submitted → fulfilling → pending_acceptance → completed`,终态 `rejected / archived / cancelled`。 + +押金策略(`domain/consignment-deposit-policy.ts`): + +| 场景 | `auto` 行为 | +| --- | --- | +| 打手履约押金撤销 | 全额退还;要扣必须显式 `forfeit` | +| 卖家发布押金撤销 | 作废(下架)→ 退还;回库(继续挂卖)→ 保持冻结 | +| 显式覆盖 | 必须填原因(`isDepositHandlingOverridden` 校验) | + +### 3.9 客服备用金台账(迁移 100) + +```text +公司 → 主管池(fund_accounts.account_type='supervisor') +主管 → 客服账户('agent',responsible_supervisor_id 指向主管) +客服 → 支出明细(manual_expenses:平台充值 / 采购 / 赔付 / 运费 / 其他) +``` + +四条硬约束(写在建表注释里): + +1. 余额只能由 `fund_entries` 变动,禁止手工改; +2. 发放 / 缴回必须同时写主管与客服两条方向相反的流水,共用 `transfer_no`; +3. 支出必须同时写客服负数流水与 `manual_expenses` 明细(`UNIQUE(fund_entry_id)`); +4. 已记账流水不修改不删除,差异走 `adjustment`。 + +对账:`fund_reconciliations`(`pending / confirmed / discrepancy`),DB 级校验 `difference_fen = actual_balance_fen - system_balance_fen`。 + +权限点:`fund.view / issue / expense / reconcile / adjust / export`,按角色(admin / finance / operator / support)分别在迁移里授权。 + +### 3.10 后台治理 + +| 能力 | 实现 | +| --- | --- | +| 登录 | 用户名密码 + 短信二次(可选) + 设备认证(`admin_user_devices` / `admin_device_challenges`) | +| 会话 | `admin_sessions` 落库 + `session_version`,可远程踢设备 | +| RBAC | 4 个内置角色(admin / finance / operator / support)+ 29 个权限点 + 72 条角色授权 | +| 审计 | `admin_audit_logs`(actor / action / target / payload),实测 55 万行 | +| 通知 | `admin_notifications`(类型 / 优先级 / 去重键 / 提醒次数 / 可见性 / 声音) | +| 实时 | `realtime_events` + SSE,后台列表与工单详情增量刷新 | +| 定时任务 | 后台可视化管理(启用、间隔、立即执行、运行状态) | + +### 3.11 定时任务 + +| id | 类型 | 默认间隔 | 作用 | +| --- | --- | --- | --- | +| `work-order-timeout` | `work_order_timeout` | 60s(scanLimit 50) | 工单超时处置 + 打手超时计数 | +| `deposit-unfreeze` | `deposit_unfreeze` | 60s(scanLimit 100) | 押金/报酬延迟解冻出队 | +| `pdd_order_sync` | `pdd_order_sync` | 20s(pageSize 10) | 拼多多订单拉取 | +| `data-retention` | `data_retention` | 24h | 清理原始报文、匹配日志、任务事件、webhook、审计;回填 `source_order_amount_fen` | + +调度器在进程内(`services/scheduler/scheduler-service.ts`),同一任务运行中会跳过本轮;配置存 `app_config_entries.scheduled_jobs`。 + +--- + +## 四、状态机一页汇总 + +### 4.1 订单 `orders` + +| 字段 | 取值 | 备注 | +| --- | --- | --- | +| `order_status` | `created / paid / closed / refunded`(另有历史值 `pending_config`、`manual_failed`) | 合并时按优先级只升不降 | +| `pay_status` | `unpaid / failed / paid / refunded` | 与 `paid_at` 联动 | +| `claim_status` | `active / paused / closed` | 兑换有效性开关(迁移 099) | + +> 注意:`order_status` **不能代表履约完成**。本地库中 375,432 条 `paid` 订单里,绝大多数并没有「订单级完成」状态,履约进度只能看 `fulfillment_tasks.task_status` 或 `work_orders.status`。 + +### 4.2 履约任务 `fulfillment_tasks` + +`pending_payment → paid → link_generated → claimed → pending_binding_prepare → waiting_binding → role_confirmed → redeeming → dispatched_pending_return → completed / redeemed`,旁支 `retry_pending`、`manual_review`、`failed`、`expired`、`closed`。 + +状态转移约束定义在 `domain/task-status.ts`(`TASK_TRANSITIONS`),终态 `completed / redeemed / expired / closed`。 + +### 4.3 接单工单 `work_orders` / 份额 `work_order_shares` + +- 工单:`pending_material / unassigned / open / in_progress / pending_acceptance / problem / accepted / cancelled`(终态 accepted、cancelled)。 +- 份额:`joined / submitted / accepted / cancelled`。 +- 好友赠送额外有 `gift_phase`:`material_required / material_submitted / friend_countdown / gift_ready`。 + +### 4.4 寄卖 + +| 对象 | 取值 | +| --- | --- | +| `consignment_submissions.status` | `pending_review / approved / rejected` | +| `consignment_inventories.status` | `in_stock / consumed / archived` | +| `consignment_inventories.allocation_status` | `待安排 / 已安排`(中文枚举,历史遗留) | +| `source_type` | `manual / worker_portal / recycle` | +| 卖家可感知阶段 | `pending_review / in_stock / matched_waiting_friend / gift_cooldown / gift_ready / gift_submitted / fulfilling / pending_acceptance / completed / rejected / archived / cancelled` | + +### 4.5 凭证与券 + +| 对象 | 取值 | +| --- | --- | +| `kuaishou_industry_vouchers.status` | `UNUSED / CONSUMED / DESTROYED` | +| `kuaishou_industry_vouchers.send_callback_status` | `success / failed / pending`(回调快手发码结果) | +| `redeem_certificates.status` | `issued / synced / verified / invalid` | +| `redeem_certificates.sync_status` | `pending / success / failed` | +| `kuaishou_cloud_task_states.ticket_status` | 快手云端取号/绑号状态(配合 `dispatch_status`、`consume_status`) | + +### 4.6 资金与售后 + +| 对象 | 取值 | +| --- | --- | +| `worker_finance_requests.status` | `pending / approved / rejected / cancelled` | +| `worker_finance_requests.request_type` | `recharge / withdraw` | +| `worker_deposit_unfreezes.status` | 待解冻 / 已释放(`released_at` 落地) | +| `worker_after_sales_cases.status` | 待响应 / 已响应 / 已处置 / 已退款(配合 `resolution_action`) | +| `worker_after_sales_debts.status` | 未结清 / 已结清 | +| `fund_entries.entry_type` | `opening / funding / issue / expense / return / adjustment` | +| `fund_reconciliations.status` | `pending / confirmed / discrepancy` | + +--- + +## 五、数据库设计 + +### 5.1 总览(本地库实读) + +| 指标 | 数值 | +| --- | --- | +| 业务表 | **59** 张(+ `schema_migrations`) | +| 迁移文件 | 103 个(001 基线 + 002–103 增量),全部已应用 | +| 外键 | 91 | +| 唯一约束 | 29 | +| CHECK 约束 | 28 | +| 索引 | 268(含主键与唯一索引) | +| 主键 | 绝大多数是 `BIGSERIAL`;钱包、工单备注、工单已读等关系表用复合主键 | +| 时间类型 | 统一 `TIMESTAMPTZ`(不存本地时间) | +| 金额类型 | 打手侧 `INTEGER`(分);`orders.total_amount` 也是 `INTEGER`;资金台账、PDD 与工单来源金额用 `BIGINT`(分);**没有 `NUMERIC`,也没有浮点金额** | +| JSON | 变化快的扩展信息一律 `JSONB`(快照、上下文、凭证原文),不建宽表 | + +设计风格非常一致:**关系表 + `*_json` 扩展 + 只追加流水**。代价是 JSONB 里的字段无法被 DB 约束保护(如 `context_json.affiliateDash` 全靠 TS 类型)。 + +### 5.2 分域表清单 + +#### A. 订单与履约主链路(12) + +| 表 | 行数(估) | 说明 | +| --- | --- | --- | +| `orders` | 375,459 | 订单骨架;`UNIQUE(provider, platform, shop_id, platform_order_id)` | +| `order_items` | 375,263 | 订单项;`item_snapshot_json` 是载体判定的全部证据 | +| `fulfillment_tasks` | 107,111 | 履约单元;`UNIQUE(order_item_id, unit_index)`、`UNIQUE(task_no)` | +| `claim_tokens` | 107,083 | 领取 token;`UNIQUE(task_id)`、`UNIQUE(token)` | +| `task_events` | 1,310,742 | 任务事件流水(最大表) | +| `task_runtime_contexts` | 45,976 | 运行时上下文(登录态、角色、截图、产物) | +| `fulfillment_profiles` | 4 | 履约档案(affiliate_dash / manual_review / legacy 桩) | +| `fulfillment_profile_requirements` | 少量 | 档案的凭证要求 | +| `pdd_raw_orders` | 5,068 | PDD 原始单,`UNIQUE(order_sn)`,`sync_status` 四态 | +| `webhook_events` | 110,233 | 外部回调去重表(`provider + event_id`) | +| `short_links` | 95 | 短链(目标 URL 哈希 + 访问计数) | +| `file_assets` | 289,260 | 对象存储资产(缩略图/中图/URL/场景/上传者) | + +#### B. 凭证(3) + +| 表 | 行数(估) | 说明 | +| --- | --- | --- | +| `kuaishou_industry_vouchers` | 403,299 | 快手电子凭证;`UNIQUE(voucher_code)`、`UNIQUE(oid, unit_index)` | +| `kuaishou_cloud_task_states` | 46,894 | 快手云端取号/绑号/核销状态(一任务一行) | +| `redeem_certificates` | 1,541 | 抖音 DYC 券,含平台券号、平台状态、同步错误 | + +#### C. 接单平台(25) + +| 表 | 行数(估) | 说明 | +| --- | --- | --- | +| `worker_users` | 1,905 | 打手账号(30 列:等级、邀请、改名卡、冻结、黑名单、寄卖权限、等级锁) | +| `worker_levels` / `worker_level_logs` | 6 / 133 | 等级定义(`permission_json`)与升降级流水 | +| `worker_sessions` | 32,727 | 会话(设备、IP、UA、过期、吊销) | +| `worker_sms_codes` | 5,206 | 短信验证码(哈希、失败次数) | +| `worker_wallets` | 1,905 | 钱包(可用、冻结押金、待解冻、提现冻结、累计) | +| `worker_wallet_ledgers` | 172,830 | 钱包流水(13 种 `ledger_type`) | +| `worker_finance_requests` | 6,769 | 充值/提现申请(含冻结金额与审核人) | +| `worker_withdrawal_accounts` | 776 | 提现账户(微信/支付宝二维码 JSON) | +| `worker_deposit_unfreezes` | 621 | 延迟解冻队列(`fund_type` 区分押金/报酬) | +| `work_categories` | 30 | 工单分类(支持父级与图标) | +| `work_orders` | 156,043 | 工单主表(44 列,全库最宽) | +| `work_order_events` | 1,258,811 | 工单事件流水 | +| `work_order_shares` | 41,383 | 拼单份额 | +| `worker_work_order_notes` | 13,604 | 打手备注(一工单一打手一行) | +| `worker_work_order_views` | 147,356 | 已读/素材已读 | +| `worker_order_cancel_requests` | 953 | 打手取消申请与审核 | +| `worker_order_feedbacks` | 501 | 工单反馈/申诉 | +| `worker_after_sales_cases` | 39 | 售后问题单 | +| `worker_after_sales_debts` | 4 | 售后欠款 | +| `worker_after_sales_case_events` | 93 | 售后时间线 | +| `worker_risk_events` | 209 | 风控事件与处置 | +| `work_product_rules` | 710 | 接单模板(29 列:匹配、奖励、押金、拼单、时限、验收模式) | +| `work_product_rule_mappings` | 707 | 模板映射(卖家/SKU/标题,`sku_match_mode`) | +| `work_product_match_logs` | 10,000 | 匹配诊断日志(保留量受清理任务控制) | + +#### D. 寄卖(2) + +| 表 | 行数(估) | 说明 | +| --- | --- | --- | +| `consignment_submissions` | 877 | 提报单(`submission_no` 唯一、要价、押金、审核结果指向库存) | +| `consignment_inventories` | 784 | 库存(`INV…` 唯一、38 列:规格、凭证、绑定订单/工单、卖家、指派打手) | + +#### E. 后台与权限(11) + +`admin_users`(40)、`admin_sessions`(3,077)、`admin_login_logs`(4,936)、`admin_roles`(4)、`admin_permissions`(29)、`admin_role_permissions`(72)、`admin_audit_logs`(556,360)、`admin_notifications`(166,883)、`admin_sms_codes`、`admin_user_devices`、`admin_device_challenges`。 + +#### F. 配置与实时(2) + +`app_config_entries`(18 行,全部平台/业务配置)、`realtime_events`(600,滚动保留)。 + +#### G. 资金台账(4) + +`fund_accounts`、`fund_entries`、`manual_expenses`、`fund_reconciliations`(本地均为空表,属于新落地模块)。 + +### 5.3 核心表字段速查 + +#### `orders` + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `id` | bigserial PK | | +| `provider` / `platform` | text NOT NULL | 例如 `91kaquan` / `kuaishou` | +| `shop_id` / `shop_name` | text | 店铺键空间(参与唯一键) | +| `platform_order_id` | text NOT NULL | 平台单号 | +| `order_status` / `pay_status` | text | 见 4.1 | +| `buyer_id` / `buyer_name` / `receiver_contact` | text | 买家信息 | +| `total_amount` | integer | **用户付款金额(分)**,三家口径已统一为「实付」;91 = `maxAmount` | +| `currency` | text | 默认 CNY | +| `raw_payload_json` | jsonb | 原始报文(保留期由 data-retention 控制) | +| `paid_at` | timestamptz | 支付/成团时间,建单时用作 `created_at` 优先值 | +| `claim_status` / `claim_status_reason` / `claim_status_updated_at` | text / text / timestamptz | 兑换有效性开关 | + +索引(12 个)覆盖:唯一键、`(provider, platform, order_status, created_at)`、平台单号、店铺筛选、claim 状态等(迁移 070/074/086/099)。 + +#### `order_items` + +`id`、`order_id`(FK CASCADE)、`sku_code`、`sku_name`、`quantity`、`spec_json`、`item_snapshot_json`。 + +没有金额字段,也没有 `canonical_product_id`:**多商品订单无法直接分摊收入**(缺口 C5)。 + +#### `fulfillment_tasks` + +| 列 | 说明 | +| --- | --- | +| `order_id` / `order_item_id` / `unit_index` | 归属与单元序号;`UNIQUE(order_item_id, unit_index)` | +| `task_no` | `DT…` 唯一编号 | +| `provider / platform / shop_id / shop_name / platform_order_id` | 冗余来源字段(便于按来源查询,避免 join) | +| `profile_id` / `executor_key` | 履约档案与执行器 | +| `task_status` / `delivery_status` / `result_code` / `result_message` | 主状态机 + 交付状态 | +| `claim_token` / `claim_expires_at` | 历史冗余列;实际 token 在 `claim_tokens`(查询时 `LEFT JOIN` 出 `primary_claim_token`) | +| `automation_mode` / `requires_claim` / `user_action_status` | 自动/人工、是否需要用户领取 | +| `attempt_count` / `last_error` | 重试 | +| `context_json` | 执行器上下文(`affiliateDash`、`kuaishouIndustryVoucher` 等) | +| `claimed_at` / `role_confirmed_at` / `redeemed_at` | 关键时间点 | + +#### `work_orders`(44 列,按用途分组) + +| 组 | 列 | +| --- | --- | +| 标识 | `id`、`work_order_no`(唯一)、`order_id`、`order_item_id`、`task_id`(历史遗留,已注释废弃)、`platform_order_id`、`product_rule_id`、`category_id` | +| 商品 | `product_name` | +| 状态 | `status`、`acceptance_mode`、`gift_phase`、`admin_note`、`problem_note` | +| 金额 | `reward_amount`、`required_deposit_amount`、`deposit_threshold_amount`、`source_order_amount_fen`、`sharing_unit_reward` | +| 归属 | `assigned_worker_id`、`last_assigned_worker_id` | +| 拼单 | `sharing_enabled`、`sharing_total_quantity`、`sharing_unit_reward` | +| 时限 | `deadline_at`、`timeout_minutes`、`timeout_policy` | +| 好友赠送 | `friend_added_at`、`gift_available_at`、`gift_cooldown_hours`、`gift_cooldown_minutes` | +| 内容 | `material_json`、`requirement_json`、`acceptance_json`、`draft_acceptance_json`、`booster_material_json`、`worker_note` | +| 大厅 | `hall_queued_at`、`pinned_at`、`published_at` | +| 时间 | `assigned_at`、`submitted_at`、`accepted_at`、`created_at`、`updated_at` | + +21 个索引,覆盖:状态+时间、分类+状态、店铺筛选、大厅队列、统计口径、关键词查询等(迁移 031/047/051/073/083/085/087/088/090/103)。 + +#### `worker_wallets` / `worker_wallet_ledgers` + +`worker_wallets`:`worker_id` PK/FK、`available_amount`、`frozen_deposit_amount`、`pending_unfreeze_amount`、`frozen_withdraw_amount`、`total_credited_amount`、`total_settled_amount`。 + +`worker_wallet_ledgers`:`worker_id`、`ledger_type`、`amount`(有符号)、`balance_after`、`frozen_after`、`related_work_order_id`、`audit_status`、`note`、`payload_json`。 + +> 余额是**可变字段**,没有「由流水重算」的校验;这是财务化的第一道缺口(见第七节)。 + +#### `consignment_inventories`(38 列) + +`inventory_no`、`game_name`、`category_id`、`item_name`、`system`、`server_zone`、`series`、`diamond_level`、`brilliance_count`、`cost_price_fen`、`seller_*`(联系人/游戏 ID/收款/手机)、`credentials_json`(**游戏账号密码**)、`screenshots_json`、`status`、`allocation_status`、`bound_order_id`、`bound_work_order_id`、`bound_platform_order_id`、`shop_name`、`source_type`、`source_submission_id`、`worker_id`、`assigned_worker_id`、`deposit_amount_fen`、`inbound_at`、`consumed_at`。 + +#### `fund_*` + +- `fund_accounts`:`account_no` 唯一、`account_type`(supervisor/agent)、`owner_admin_user_id`、`responsible_supervisor_id`(自引用)、`balance_fen >= 0`、`status`。 +- `fund_entries`:`entry_no` 唯一、`account_id`、`entry_type`(6 种)、`amount_fen <> 0`(有符号)、`balance_after_fen`、`transfer_no`、`counterparty_account_id`、`reconciliation_id`、`evidence_json`。 +- `manual_expenses`:`expense_no` 唯一、`account_id`、`amount_fen > 0`、`category`(5 种)、`payee_type`(4 种)、`order_id`/`order_item_id`、`fund_entry_id` 唯一(一支出对一流水)。 +- `fund_reconciliations`:`period_start/end`、`system_balance_fen`、`actual_balance_fen`、`difference_fen`(CHECK 等于差值)、`status`、`confirmed_by`。 + +### 5.4 关系图(核心链路) + +```mermaid +erDiagram + orders ||--o{ order_items : "1:N" + orders ||--o{ fulfillment_tasks : "1:N" + order_items ||--o{ fulfillment_tasks : "1:N (unit_index)" + fulfillment_tasks ||--|| claim_tokens : "1:1 (task_id unique)" + fulfillment_tasks ||--o{ task_events : "1:N" + fulfillment_tasks ||--o| task_runtime_contexts : "1:0..1" + fulfillment_tasks ||--o| kuaishou_cloud_task_states : "1:0..1" + orders ||--o{ kuaishou_industry_vouchers : "1:N" + fulfillment_tasks |o--o{ kuaishou_industry_vouchers : "task_id 可空" + orders ||--o{ redeem_certificates : "local_order_id" + order_items ||--o| work_orders : "1:0..1" + work_orders ||--o{ work_order_shares : "1:N" + work_orders ||--o{ work_order_events : "1:N" + worker_users ||--|| worker_wallets : "1:1" + worker_users ||--o{ worker_wallet_ledgers : "1:N" + worker_users ||--o{ worker_sessions : "1:N" + worker_users ||--o{ worker_finance_requests : "1:N" + work_orders ||--o{ worker_deposit_unfreezes : "1:N" + work_orders ||--o{ worker_after_sales_cases : "1:N" + worker_after_sales_cases ||--o{ worker_after_sales_debts : "1:N" + consignment_submissions ||--o| consignment_inventories : "审核产出" + consignment_inventories |o--o{ work_orders : "bound_work_order_id" + admin_users ||--o{ fund_accounts : "owner" + fund_accounts ||--o{ fund_entries : "1:N" + fund_entries ||--|| manual_expenses : "1:1 (fund_entry_id)" + fund_accounts ||--o{ fund_reconciliations : "1:N" + pdd_raw_orders |o--|| orders : "synced_order_id" +``` + +### 5.5 约束与索引策略(值得记住的几条) + +1. **唯一键即幂等键**:`orders(provider, platform, shop_id, platform_order_id)`、`fulfillment_tasks(order_item_id, unit_index)`、`kuaishou_industry_vouchers(oid, unit_index)`、`pdd_raw_orders(order_sn)`、`work_order_shares(work_order_id, worker_id)`、`fund_entries(entry_no)`、`manual_expenses(fund_entry_id)`。 +2. **DDL 层不枚举业务状态**:`orders` / `fulfillment_tasks` / `work_orders` 的状态列**没有 CHECK**,状态机只存在于 TS(`domain/*.ts`)。好处是迁移成本低,坏处是脏状态只能靠代码兜底(`pending_config` 就是遗留证据)。寄卖与资金表则加了 CHECK,口径不完全统一。 +3. **索引高度针对后台查询**:迁移 070/071/073/074/082/084–090 全是后台列表/统计/模糊搜索的覆盖索引,其中 073 用 `pg_trgm` GIN 支撑工单关键词(游戏 ID)搜索。 +4. **JSONB 不做 GIN**:除搜索字段外,`snapshot/context` 的查询都走「先取行再在应用层判断」,这也是解析逻辑必须集中在少数几个 service 的原因。 +5. **软删/归档**:业务表基本不物理删除(`ON DELETE CASCADE` 只用于任务→事件、订单→订单项等从属关系),保留靠 `status` 与归档字段。 + +### 5.6 配置存储(`app_config_entries`) + +配置不在文件里、也不在环境变量里(环境变量只管连接串与密钥),全部走一张 key-value 表,后台保存后即时生效(部分需要重启或重载调度器): + +| key | 内容 | +| --- | --- | +| `affiliate_dash` | 自动发货平台配置(baseUrl、商户号、密钥、skuMapping) | +| `kuaishou_industry_source` | 快手电子凭证接入(appId、secret、token、店铺) | +| `douyin_mashida` | 抖音码速达(uid/secret/baseUrl/claimLinkBase/handledTopics) | +| `pdd_source` | 拼多多(店铺、Cookie、同步开关) | +| `fulfillment_routing` | 履约路由(总开关、商品名/SKU 规则、全局优先级、unmatchedExecutorKey) | +| `scheduled_jobs` | 定时任务配置与间隔 | +| `notification` | 通用通知(Bark / WPush) | +| `worker_finance` | 打手财务策略(押金、解冻天数、提现规则) | +| `worker_product_match` | 匹配引擎开关(`legacyFallbackEnabled`) | +| `worker_hall` | 大厅可见性、容量、分类上限 | +| `worker_announcement` | 打手端公告 | +| `worker_platform_notifications` | 打手端通知模板 | +| `worker_risk_control` | 风控阈值与处罚 | +| `worker_mock_shops` | 开发/联调假店铺 | +| 历史键 | `cloudtentacles_*`、`kuaishou_feifei`(已下线通道,仅保留数据) | + +### 5.7 迁移演进(001–103) + +| 区段 | 主题 | +| --- | --- | +| 001 | 基线:后台、配置、履约档案、订单/订单项/任务/领取 token、任务事件、快手券、短链 | +| 002–003 | 登录日志、电子凭证类型 | +| 004–012 | **接单平台落地**:等级、打手、钱包、流水、分类、工单、工单事件、文件、商品规则、财务申请、拼单、超时、解冻、短信 | +| 013–028 | 回调去重、权限修补、永久 claim token、工单备注/置顶、提现账户、通知 | +| 029–040 | 会话、实时事件、搜索索引、匹配上下文/目录/SKU 模式、匹配日志、后台会话 | +| 041–058 | 排行榜、自动激活、取消申请、反馈、来源规则、冻结原因、性能护栏、大厅队列、售后、改名卡、赠送验收、解冻配置、素材已读 | +| 059–076 | **RBAC**、提现冻结、管理备注、取消措辞、赠送冷却、个人免押、数据保留、筛选索引、财务角色、搜索重建、分类父子、提现黑名单 | +| 077–082 | **寄卖**(库存、打手押金、解除收集链接、分配状态、白名单) | +| 081 | **PDD 原始单**两段式 | +| 083–093 | 工单来源金额、多组覆盖索引、风控事件、遗留列注释、autovacuum | +| 094–098 | **抖音券** `redeem_certificates`、平台券号、任务 claim 提交(后废弃)、等级锁 | +| 099–103 | 订单兑换有效性、**备用金台账**、后台显示名、短信/设备认证、店铺筛选索引 | + +约定(README):不改历史迁移;结构变更一律新增;空库按序执行;后端启动自动 `up`。 + +### 5.8 实测状态分布(本地库,用于验证对业务的理解) + +| 维度 | 分布 | +| --- | --- | +| 订单 | `paid/paid/active` 375,432;`pending_config` 25;`manual_failed` 1;`closed` 1 | +| 履约任务 | `redeemed+delivered` 49,527;`completed+delivered` 45,658;`closed+cancelled` 5,158;`closed+closed` 3,238;`waiting_binding+pending` 956 | +| 工单 | `accepted` 131,786;`pending_material` 21,509;`cancelled` 1,075;`unassigned` 760;`in_progress` 612;`pending_acceptance` 174;`open` 101;`problem` 26 | +| 快手券 | `CONSUMED` 376,179;`DESTROYED` 26,815;`UNUSED` 106 | +| 抖音券 | `verified` 1,337;`invalid` 123;`synced` 81 | +| 财务申请 | 提现 approve 6,381;充值 reject 151;提现 reject 129;充值 approve 91 | + +两个值得注意的信号: + +1. `pending_material` 占工单 13.8%(21,509 条),说明**接单素材缺口是常态**,补素材是运营主要工作量之一。 +2. `unassigned` 只有 760,而 `open` 只有 101 —— 大量工单没有经过大厅,直接被后台指派或同步生成后长期停在待补素材。 + +--- + +## 六、外部集成与安全边界 + +| 链路 | 验签 | 幂等/去重 | 备注 | +| --- | --- | --- | --- | +| 91 卡券 | 签名 + 时间戳容差 | `orders` 唯一键 + advisory lock | 卡密用 `buildOpen91Cards` 加密返回 | +| 快手电子凭证 | 签名(`crypto.ts`) | `UNIQUE(oid, unit_index)` | 4 个入站接口 + 2 个出站回调 | +| affiliate-dash 通知 | 验签 + 时间戳 | `webhook_events` | 失败重推会被判 duplicate(风险 R2) | +| 抖音码速达 | 原始 body 验签 | `webhook_events`(先落库) | 同上 | +| PDD | Cookie / anti-content | `pdd_raw_orders(order_sn)` + 断路器 | 无回调,纯拉取 | + +其他安全面: + +- 后台/打手口令都是 scrypt + 盐,会话落库 + `session_version`。 +- 日志统一脱敏(敏感 key、URL 参数、JSON 字段),有单测。 +- 生产强制:session secret 长度、显式 CORS 白名单、存储凭据完整。 +- 明显缺口:`/collect/*`、`/redeem/*` 只凭订单号;`/files/object` 无鉴权;Caddy 无 CSP/HSTS/X-Frame-Options;后台 token 存 localStorage。 + +--- + +## 七、风险与设计缺口(按优先级) + +### P0 · 资金与一致性 + +| # | 问题 | 位置 | 影响 | +| --- | --- | --- | --- | +| R1 | 发单金额高于用户付款只有前端提醒,后端无统一门禁 | `frontend/pages/admin/panels/work-order-payment.ts`;`admin-work-order-management-service.ts` | 直接调 API 可发亏损单 | +| R2 | 寄卖审核可由请求体改 `workerId` 并按改后归属结算货款 | `consignment-admin-service.ts`、`consignment-supplier-repo.ts` | 钱结给错误的人 | +| R3 | 寄卖审核详情全量返回游戏账号密码,只需 `consignment.view` | `consignment-admin-service.ts` | 权限粒度过粗 | +| R4 | 抢单先锁钱包、指派先锁工单(锁序相反) | `work-order-grab-repo.ts` | 并发下 40P01 死锁 | +| R5 | 自动工单硬编码零押金,模板押金字段不生效 | `sync-worker-orders-service.ts` | 自动发单无押金约束 | +| R6 | 发布工单时凭证核销失败不回滚,指派却拦截 | `admin-work-order-management-service.ts` / `...assignment-service.ts` | 同一规则两套行为 | + +### P1 · 可靠性 + +| # | 问题 | +| --- | --- | +| R7 | Webhook 先落库后处理,失败后平台重推被永久跳过 | +| R8 | 抖音回调同步串行调上游,单次超时 20s,与 R7 叠加不可恢复 | +| R9 | `/collect/*`、`/redeem/*` 无第二因子 | +| R10 | `/collect/upload` 可匿名上传任意文件到 OSS | +| R11 | `/files/object` 无鉴权;claim token 默认永久 | +| R12 | 无 CSP/HSTS;后台 token 在 localStorage | +| R13 | 先到先匹配,后到的快手补全数据不纠正既有工单 | +| R14 | 单实例耦合(限流/调度/SSE/缓存都在进程内) | + +### P2 · 可维护性 + +巨型文件(`WorkOrdersPanel.tsx` 3,686 行、`AdminTaskDetailPage.tsx` 1,925 行、`work-order-query-repo.ts` 1,409 行、`consignment-repo.ts` 1,410 行、`work-order-settlement-repo.ts` 1,181 行);前端无组件测试/E2E;无 CI;迁移启动自动执行且无 down。 + +### 数据模型层面的四个缺口 + +| # | 缺口 | 后果 | +| --- | --- | --- | +| D1 | 没有「人工块」载体:无自动任务且无工单的已支付订单项只靠「无任务且无工单」推断 | 人工履约不可监管、不可计量 | +| D2 | 无商品主数据 `product_catalog` / `channel_product_mappings` | 同一商品名对应 27–35 个 `sku_code`,销量统计不可靠 | +| D3 | 无成本版本与快照 `cost_items/cost_versions/cost_snapshots` | 改价不可追溯,历史毛利不可重算;自动发货 `cost_amount` 只映射不落库 | +| D4 | `order_items` 无金额字段 | 多商品订单无法分摊收入 | + +> 这几条与 `docs/成本中心与三块履约模型.md`、`docs/drafts/*` 的草案一致,草案尚未进迁移。 + +--- + +## 八、我看到的「三个业务面」边界图 + +```mermaid +flowchart LR + subgraph 订单面 + O["orders / order_items"] + T["fulfillment_tasks / claim_tokens"] + V["vouchers / redeem_certificates"] + end + subgraph 工单面 + W["work_orders / work_order_shares"] + WW["worker_users / wallets / ledgers"] + P["work_product_rules / mappings"] + end + subgraph 寄卖面 + CS["consignment_submissions"] + CI["consignment_inventories"] + end + subgraph 财务面 + FA["fund_accounts / fund_entries"] + ME["manual_expenses"] + end + O --> T + O --> V + O --> W + P --> W + W --> WW + CI --> O + CS --> CI + CI --> W + ME --> FA + W -. 影子总账未落地 .-> FA +``` + +三个面共享的只有两件事:**订单项**(`order_items.id` 是所有链路的锚点)与**打手钱包**(工单与寄卖都用它)。 + +--- + +## 九、需要你确认的问题 + +见对话中的提问(不在文档内固化,确认后我再补「口径确认」章节)。 + From 2befb57ef8d73d854fe0db3d3c87198101b01da5 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 23 Oct 2026 15:20:14 +0800 Subject: [PATCH 03/17] =?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=EF=BC=9A=E6=8B=BC?= =?UTF-8?q?=E5=8D=95=E5=90=8E=E5=8F=B0=E6=92=A4=E5=8D=95=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=BD=B1=E5=93=8D=E6=AF=8D=E5=8D=95=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../work-order-settlement-repo.ts | 19 ++++--- .../admin/worker-platform/acceptance.ts | 2 +- .../worker-platform-service.test.ts | 52 +++++++++++-------- .../pages/admin/panels/WorkOrdersPanel.tsx | 4 +- 4 files changed, 43 insertions(+), 34 deletions(-) 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 c4967d26..b39852a0 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 @@ -709,7 +709,13 @@ export async function acceptWorkOrderShareAndSettle(input: { }) } -/** 后台撤销单个拼单份额,释放该打手押金并把份数重新放回大厅。 */ +/** + * 后台撤销单个拼单份额:退还该打手押金、释放这一份的占用。 + * + * **不改母工单状态**:后台撤单常用于剔除错误参与者/纠错,撤掉一份不应把 + * 待验收(或进行中)的母单踢回大厅;需要改母单状态时走「拼单撤单」的 + * 退回大厅/取消整单显式操作。 + */ export async function cancelWorkOrderShare(input: { workOrderId: number shareId: number @@ -819,19 +825,16 @@ export async function cancelWorkOrderShare(input: { WHERE id = $2`, [input.now, input.shareId], ) - // 母单状态由剩余份额集合推导:撤掉这一份后,其余已提交份额仍可正常待验收 - const recomputedOrder = await recomputeWorkOrderSharingStatusWithClient( - client, - input.workOrderId, - input.now, - ) + // 母单状态保持不变:这里只取消这一份份额,不再按剩余份额重推母单状态。 + // (旧实现会 recompute:待验收的母单撤掉一份后 allocated < total → 变回 open, + // 等于整单被踢回抢单大厅,与「后台撤单只影响该份额」的预期不符。) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', actorId: input.actorName || '', eventType: 'sharing_share_cancelled_by_admin', fromStatus: workOrder.status, - toStatus: recomputedOrder?.status || workOrder.status, + toStatus: workOrder.status, payloadJson: JSON.stringify({ shareId: input.shareId, workerId, diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index 3a91e57a..d84a8b0f 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -148,7 +148,7 @@ router.post( req.adminSession?.displayName || req.adminSession?.username || '', ), { - successMessage: '拼单参与者已撤单,份数已退回大厅', + successMessage: '拼单参与者已撤单,押金已退还,母单状态保持不变', errorMessage: '撤销拼单参与者失败', scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/cancel]', audit: (req, data) => ({ 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 acb6a5aa..d1430dfa 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 @@ -3302,33 +3302,39 @@ test('拼单母单已验收或已取消时 recompute 不回头改状态', async } }) -test('份额超时/撤单只改份额本身,母单状态统一交给 recompute', () => { +test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收敛', async () => { 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 推导)`, - ) - } + const cancelBlock = src.slice( + src.indexOf('export async function cancelWorkOrderShare('), + src.indexOf('export async function settleOverdueWorkOrderShare('), + ) + assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在') + assert.ok( + !cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '后台单人撤单不得重算母单状态(撤一份会把待验收的母单踢回大厅)', + ) + assert.ok( + !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(cancelBlock), + '后台单人撤单不得自己写母单 status', + ) + assert.ok(cancelBlock.includes('toStatus: workOrder.status'), '撤单事件不得记录母单状态迁移') + + const timeoutBlock = src.slice( + src.indexOf('export async function settleOverdueWorkOrderShare('), + src.indexOf('export async function cancelWorkOrderSharing('), + ) + assert.ok(timeoutBlock.length > 0, 'settleOverdueWorkOrderShare 片段必须存在') + assert.ok( + timeoutBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '份额超时必须用 recompute 收敛母单状态', + ) + assert.ok( + !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(timeoutBlock), + '份额超时不得自己写母单 status(只能由 recompute 推导)', + ) }) test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => { diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 5c30d637..d76e5a68 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -700,7 +700,7 @@ export default function WorkOrdersPanel() { cancelAdminWorkOrderShare(workOrderId, cancelShare.shareId, { reason: String(values.reason || '').trim(), }), - '该参与者已撤单,押金和拼单份数已恢复', + '该参与者已撤单,押金已退还,母单状态未变更', ) if (!succeeded) return await queryClient.invalidateQueries({ @@ -3305,7 +3305,7 @@ export default function WorkOrdersPanel() { showIcon type="warning" message={`将撤销该打手承接的 ${cancelShare.quantity} 份拼单`} - description="撤单后会退还该份额冻结的押金,空出的份数重新回到接单大厅;已提交的验收资料会保留在后台记录中。" + description="撤单只释放这一份份额并退还该份额冻结的押金,母单状态保持不变(待验收/进行中的单不会被踢回大厅);已提交的验收资料会保留在后台记录中。" style={{ marginBottom: 16 }} /> From 7e1e1f7075e1d0bd3efb343cccc975952947b2ee Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 23 Oct 2026 16:37:59 +0800 Subject: [PATCH 04/17] =?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=EF=BC=9A=E6=8B=BC?= =?UTF-8?q?=E5=8D=95=E5=90=8E=E6=8C=89=E9=92=AE=E5=9B=BA=E5=AE=9A=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E3=80=8C=E5=86=8D=E6=8B=BC=E4=B8=80=E6=AC=A1=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-hall-action-service.ts | 10 ++++ .../worker-platform/worker-hall-service.ts | 46 +++++++++++++++---- .../worker-platform-service.test.ts | 27 +++++++++++ apps/frontend/src/layouts/WorkerLayout.tsx | 3 ++ .../src/pages/worker/WorkerHallPage.tsx | 26 +++++++---- .../src/utils/worker-realtime-work-orders.ts | 19 +++++++- .../tests/worker-realtime-work-orders.test.ts | 46 +++++++++++++++++++ 7 files changed, 159 insertions(+), 18 deletions(-) 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 7b12e56b..a53dd758 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 @@ -18,6 +18,7 @@ import { throwGrabWorkOrderFailure, } from './mappers.js' import { getWorkerHallConfig } from './worker-hall-config-service.js' +import { invalidateWorkerHallListCache } from './worker-hall-service.js' import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js' import { getRequiredWorkOrder, @@ -69,6 +70,9 @@ export async function grabWorkerHallOrder( if (!result.share) { throw resolveWorkOrderShareJoinError(result.failureReason) } + // 参与成功后立刻失效该打手的大厅缓存:前端紧接着会重拉列表, + // 2s 缓存若命中会返回「参与前」的行(没有 myShare),按钮就不会变成「再拼一次」。 + invalidateWorkerHallListCache([worker.id]) await publishWorkOrderParticipantChange(Number(workOrder.id), { workerIds: [Number(worker.id)], hallChanged: true, @@ -112,6 +116,10 @@ export async function grabWorkerHallOrder( throwGrabWorkOrderFailure(grabbed.failureReason) } + // 抢单成功后同样立刻失效大厅缓存:否则紧接着的列表重拉可能命中 2s 旧缓存, + // 把已经抢走的单又显示出来(用户会以为还能再抢)。 + invalidateWorkerHallListCache([worker.id]) + await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'worker', @@ -166,6 +174,8 @@ export async function joinWorkerSharingOrder( if (!result.share) { throw resolveWorkOrderShareJoinError(result.failureReason) } + // 同上:拼单成功后要让紧接着的列表请求看到最新份额。 + invalidateWorkerHallListCache([worker.id]) await publishWorkOrderParticipantChange(Number(workOrderId), { workerIds: [Number(worker.id)], hallChanged: true, diff --git a/apps/backend/src/services/worker-platform/worker-hall-service.ts b/apps/backend/src/services/worker-platform/worker-hall-service.ts index dfae9a6c..4cba412b 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-service.ts @@ -65,13 +65,17 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work const categories = (await listWorkCategories()).map(mapWorkCategory) const orderIds = items.map((item) => Number(item.id)) const shares = await listWorkOrderSharesByOrderIds(orderIds) - const myShareByOrderId = new Map( - shares - .filter( - (share) => share.status !== 'cancelled' && Number(share.worker_id) === Number(worker.id), - ) - .map((share) => [Number(share.work_order_id), share]), - ) + // 同一打手可能在一个工单里有多份份额(多次拼单)。大厅卡片靠这些字段判断 + // 是否已参与(按钮显示「再拼一次」),所以必须按工单收集全部有效份额。 + const mySharesByOrderId = new Map() + for (const share of shares) { + if (share.status === 'cancelled') continue + if (Number(share.worker_id) !== Number(worker.id)) continue + const orderId = Number(share.work_order_id) + const current = mySharesByOrderId.get(orderId) || [] + current.push(share) + mySharesByOrderId.set(orderId, current) + } const sharingProgressByOrderId = new Map< number, { joinedQuantity: number; pendingSubmissionCount: number } @@ -90,7 +94,9 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work const isInternalWorker = String(worker.worker_type || '').trim() === 'internal' return { items: items.map((item) => { - const myShare = myShareByOrderId.get(Number(item.id)) + const myShares = mySharesByOrderId.get(Number(item.id)) || [] + // `listWorkOrderSharesByOrderIds` 按 id ASC,最后一条即最近一次参与。 + const myShare = myShares[myShares.length - 1] || null const mappedOrder = mapWorkOrderForWorker(item, permissions) return { ...mappedOrder, @@ -98,6 +104,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work ? item.published_at || item.hall_queued_at || item.created_at || null : null, myShare: myShare ? mapWorkOrderShare(myShare) : null, + myShares: myShares.map(mapWorkOrderShare), sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || { joinedQuantity: 0, pendingSubmissionCount: 0, @@ -154,3 +161,26 @@ export function maskWorkerLeaderboardDisplayName(value: unknown): string { const firstCharacter = Array.from(String(value || '').trim())[0] return firstCharacter ? `${firstCharacter}****` : '****' } + +/** + * 逐出指定打手的大厅列表缓存。 + * + * 大厅列表缓存 TTL 只有 2s,但拼单成功后的即时重拉正好落在窗口内: + * 拿回来的是「参与前」的行(没有 myShare、拼单进度也是旧的),按钮就停在 + * 「拼单 ¥x/份」不变。参与/抢单成功后按打手整体失效,保证紧接着的列表是新数据。 + * 缓存键形如 `[workerId,page,pageSize,keyword,categoryId]`,用 `[id,` 作前缀可避免 123/1234 误伤。 + */ +export function invalidateWorkerHallListCache( + workerIds: Array, +): number { + const normalized = new Set( + workerIds + .map((item) => Number(item || 0)) + .filter((workerId) => Number.isFinite(workerId) && workerId > 0), + ) + let evicted = 0 + for (const workerId of normalized) { + evicted += hallListCache.evictByPrefix(`[${workerId},`) + } + return evicted +} 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 d1430dfa..46e6870b 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 @@ -3154,6 +3154,33 @@ test('接单排行榜必须有短缓存(单次全表聚合 277MB/742ms,独 ) }) +test('大厅卡片要带个人份额,拼单成功后立刻失效大厅缓存(按钮显示「再拼一次」)', async () => { + const { readFile } = await import('node:fs/promises') + const hallSrc = await readFile(new URL('./worker-hall-service.ts', import.meta.url), 'utf8') + const listBody = hallSrc.slice( + hallSrc.indexOf('export async function listWorkerHallOrders'), + hallSrc.indexOf('export async function getWorkerHallCategories'), + ) + assert.ok(listBody.includes('mySharesByOrderId'), '大厅没有按工单收集个人份额') + assert.match(listBody, /myShares: myShares\.map\(mapWorkOrderShare\)/, '大厅响应缺少 myShares') + assert.ok( + hallSrc.includes('evictByPrefix(`[${workerId},`)'), + '大厅缓存缺少按打手失效的入口(拼单后的列表会命中 2s 旧缓存)', + ) + + const actionSrc = await readFile( + new URL('./worker-hall-action-service.ts', import.meta.url), + 'utf8', + ) + const joinBody = actionSrc.slice( + actionSrc.indexOf('export async function joinWorkerSharingOrder'), + ) + assert.ok( + joinBody.includes('invalidateWorkerHallListCache([worker.id])'), + '拼单成功后必须失效大厅缓存,否则按钮不会变成「再拼一次」', + ) +}) + test('已冻结打手调级或变更资料不应因未传 freezeReason 报错', async () => { const { readFile } = await import('node:fs/promises') const src = await readFile(new URL('./admin-worker-user-service.ts', import.meta.url), 'utf8') diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 68658ec5..c7d41fa0 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -435,6 +435,9 @@ function applyWorkerHallCachePatch( current, { ...event, data: workerSnapshot, operation }, shouldInsert, + // 大厅广播是「所有打手通用」的快照,不含当前打手自己的份额; + // 覆盖行时必须保留,否则「再拼一次」会被冲回「拼单 ¥x/份」。 + ['myShare', 'myShares'], ), ) } diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index 59be1699..ea04e956 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -635,6 +635,8 @@ export default function WorkerHallPage() { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) const productName = resolveHallProductName(order) || '未命名商品' const sharingRemaining = resolveSharingRemaining(order) + /** 已参与过就用固定文案「再拼一次」,拼几次都不再改按钮文字(需求)。 */ + const myShareSummary = resolveMyShareSummary(order) return (
@@ -717,12 +719,15 @@ export default function WorkerHallPage() { size="middle" className="worker-hall-sharing-button" disabled={Boolean(disabledReason)} + title={ + myShareSummary.count > 0 + ? `已参与 ${myShareSummary.count} 次 · 共 ${myShareSummary.quantity} 份` + : undefined + } onClick={() => openSharingModal(order)} > - {resolveMyShareSummary(order).count > 0 - ? `已参与 ${resolveMyShareSummary(order).count} 次 · 共 ${ - resolveMyShareSummary(order).quantity - } 份 / 再拼` + {myShareSummary.count > 0 + ? '再拼一次' : `拼单 ${formatMoney(order.sharing.unitReward)}/份`} ) : null} @@ -1073,6 +1078,8 @@ export default function WorkerHallPage() { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) const productName = resolveHallProductName(order) || '未命名商品' const sharingRemaining = resolveSharingRemaining(order) + /** 已参与过就用固定文案「再拼一次」,拼几次都不再改按钮文字(需求)。 */ + const myShareSummary = resolveMyShareSummary(order) const depositAmount = resolveHallDepositAmount(order) const rewardAmount = resolveHallRewardAmount(order) @@ -1169,12 +1176,15 @@ export default function WorkerHallPage() { size="large" className="worker-hall-sharing-button" disabled={Boolean(disabledReason)} + title={ + myShareSummary.count > 0 + ? `已参与 ${myShareSummary.count} 次 · 共 ${myShareSummary.quantity} 份` + : undefined + } onClick={() => openSharingModal(order)} > - {resolveMyShareSummary(order).count > 0 - ? `已参与 ${resolveMyShareSummary(order).count} 次 · 共 ${ - resolveMyShareSummary(order).quantity - } 份 / 再拼` + {myShareSummary.count > 0 + ? '再拼一次' : `拼单 ${formatMoney(order.sharing.unitReward)}/份`} ) : null} diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 911335f4..4a2d48f3 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -121,6 +121,13 @@ export function patchWorkerWorkOrderListEnvelope( current: unknown, event: WorkerListEvent, insert = false, + /** + * 就地更新时,快照里没有的「本打手私有字段」保留旧值。 + * + * 大厅广播是发给所有打手的通用快照,天然没有 `myShare`/`myShares`(那是每个打手自己的份额)。 + * 整行替换会把「再拼一次」冲回「拼单 ¥x/份」,所以覆盖行时必须保留。 + */ + preserveFields: readonly string[] = [], ): unknown { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current @@ -140,7 +147,14 @@ export function patchWorkerWorkOrderListEnvelope( (entry) => toNumber((entry as { workOrderId?: unknown })?.workOrderId) === id, ) if (index >= 0) { - nextItems = items.map((entry, itemIndex) => (itemIndex === index ? event.data : entry)) + const existing = items[index] + const merged: Record = { ...data } + if (isRecord(existing)) { + for (const field of preserveFields) { + if (!(field in merged)) merged[field] = existing[field] + } + } + nextItems = items.map((entry, itemIndex) => (itemIndex === index ? merged : entry)) } else if (insert) { nextItems = [event.data, ...items] } else { @@ -173,12 +187,13 @@ export function patchWorkerWorkOrderInfiniteEnvelope( current: unknown, event: WorkerListEvent, insert = false, + preserveFields: readonly string[] = [], ): unknown { if (!isRecord(current) || !Array.isArray(current.pages)) return current return { ...current, pages: current.pages.map((page: unknown, index: number) => - patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0), + patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0, preserveFields), ), } } diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index 4b832e36..5c796cb5 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -280,3 +280,49 @@ test('没有 workerStatuses 的旧快照仍按单个 status 比对(保持既 }) assert.equal(match.operation, 'upsert') }) + +/** + * 大厅按钮需求:拼过一次后按钮固定显示「再拼一次」,不再随次数变化。 + * 这依赖 myShare/myShares 一直留在行上;而大厅广播是「所有打手通用」的快照, + * 不含个人份额,覆盖行时必须保留,否则按钮会被冲回「拼单 ¥x/份」。 + */ +test('大厅广播覆盖行时必须保留本打手的 myShare/myShares,并更新拼单进度', () => { + const cachedRow = buildRow({ + status: 'open', + myShare: { shareId: 77, quantity: 1, status: 'joined' }, + myShares: [{ shareId: 77, quantity: 1, status: 'joined' }], + sharingProgress: { joinedQuantity: 1, pendingSubmissionCount: 1 }, + }) + const envelope = { pages: [buildEnvelope([cachedRow])], pageParams: [1] } + const snapshot = buildRow({ + status: 'open', + sharingProgress: { joinedQuantity: 2, pendingSubmissionCount: 2 }, + }) + const patched = patchWorkerWorkOrderInfiniteEnvelope( + envelope, + { operation: 'upsert', data: snapshot }, + true, + ['myShare', 'myShares'], + ) as { pages: unknown[] } + const row = itemsOf(patched.pages[0])[0] as { + myShare?: { shareId: number } + myShares?: unknown[] + sharingProgress?: { joinedQuantity: number } + } + assert.equal(row.myShare?.shareId, 77, '不能把「再拼一次」的依据冲掉') + assert.equal(row.myShares?.length, 1) + assert.equal(row.sharingProgress?.joinedQuantity, 2, '公开进度仍要用广播里的最新值') + + // 新插入的行本来就不属于我,不能凭空补上旧字段 + const inserted = patchWorkerWorkOrderInfiniteEnvelope( + envelope, + { operation: 'upsert', data: buildRow({ workOrderId: 9999, status: 'open' }) }, + true, + ['myShare', 'myShares'], + ) as { pages: unknown[] } + const insertedRow = itemsOf(inserted.pages[0]).find((item) => item.workOrderId === 9999) as + | { myShare?: unknown; myShares?: unknown } + | undefined + assert.equal(insertedRow?.myShare ?? null, null) + assert.equal(insertedRow?.myShares ?? null, null) +}) From f9aeece7abef0b2bce7d1610f64a6fbd7ef48ee0 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 23 Oct 2026 17:45:05 +0800 Subject: [PATCH 05/17] =?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=EF=BC=9A=E6=8B=BC?= =?UTF-8?q?=E5=8D=95=E6=98=8E=E7=BB=86ui=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../105_work_order_share_problem.sql | 21 ++ .../share-summary-scope.test.ts | 37 +++ .../src/repositories/worker-platform/types.ts | 4 + .../worker-platform/work-order-issue-repo.ts | 296 +++++++++++++++++- .../worker-platform/work-order-query-repo.ts | 33 +- .../admin/worker-platform/acceptance.ts | 54 ++++ .../realtime/realtime-event-service.ts | 8 +- .../admin-work-order-management-service.ts | 118 +++++++ .../work-order-display-mappers.test.ts | 30 ++ .../work-order-display-mappers.ts | 37 ++- .../worker-my-order-service.ts | 8 +- .../worker-platform-service.test.ts | 55 ++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 253 ++++++++++++++- .../src/pages/admin/panels/shared.tsx | 1 + .../pages/worker/WorkerOrderShareTable.tsx | 39 ++- .../src/pages/worker/worker-order-utils.tsx | 7 +- .../src/services/admin/worker-platform.ts | 23 ++ apps/frontend/src/styles/admin.css | 28 ++ apps/frontend/src/types/worker-platform.ts | 7 + 19 files changed, 1037 insertions(+), 22 deletions(-) create mode 100644 apps/backend/src/db/migrations/105_work_order_share_problem.sql diff --git a/apps/backend/src/db/migrations/105_work_order_share_problem.sql b/apps/backend/src/db/migrations/105_work_order_share_problem.sql new file mode 100644 index 00000000..b831aa40 --- /dev/null +++ b/apps/backend/src/db/migrations/105_work_order_share_problem.sql @@ -0,0 +1,21 @@ +-- 105_work_order_share_problem.sql +-- 目标:后台「拼单明细」支持按份额标记问题单。 +-- 1. 只影响该份额持有人:母单 work_orders.status / problem_note 一律不写; +-- 2. 份额自身状态(joined/submitted/accepted/cancelled)不变,问题标记是正交属性; +-- 3. 整单问题单能力保留(母单 problem 状态仍按原逻辑覆盖所有参与者)。 +-- +-- 说明:备注选填;problem_marked_at 为空表示未标记;解除标记即清空这三个字段。 + +ALTER TABLE work_order_shares + ADD COLUMN IF NOT EXISTS problem_note TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS problem_marked_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS problem_marked_by TEXT NOT NULL DEFAULT ''; + +COMMENT ON COLUMN work_order_shares.problem_note IS '份额级问题备注(后台标记问题时填写,选填)'; +COMMENT ON COLUMN work_order_shares.problem_marked_at IS '份额级问题标记时间;NULL = 正常。仅影响该份额持有人展示'; +COMMENT ON COLUMN work_order_shares.problem_marked_by IS '标记操作人(后台账号显示名)'; + +-- 打手「问题单」页签筛选与概览统计需要按 (worker_id, work_order_id) 探测被标记的份额。 +CREATE INDEX IF NOT EXISTS idx_work_order_shares_problem_worker + ON work_order_shares (worker_id, work_order_id) + WHERE problem_marked_at IS NOT NULL; 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 c4c40ad6..8611395c 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 @@ -204,3 +204,40 @@ test('超时统计按工单去重,不按份额/事件行数累加', async () = '超时单数没有按工单去重', ) }) + +test('份额级问题单:概览优先归 problem,且筛选只认本人被标记的份额', async () => { + const source = stripComments(await readRepoSource()) + + const shareStart = source.indexOf('share_status_rows AS (') + const orderStart = source.indexOf('order_status_rows AS (') + assert.ok(shareStart >= 0 && orderStart > shareStart, '缺少 share_status_rows CTE') + const shareBlock = source.slice(shareStart, orderStart) + + assert.match( + shareBlock, + /CASE\s+WHEN wos\.problem_marked_at IS NOT NULL THEN 'problem'/, + '份额级问题单必须是概览 CASE 的第一分支(优先于份额状态)', + ) + assert.match( + shareBlock, + /GROUP BY wos\.work_order_id, wos\.status, f\.has_pending_handling,\s*\(wos\.problem_marked_at IS NOT NULL\)/, + '概览聚合必须带上份额问题标记,否则同工单会被合并回原状态', + ) + assert.ok( + shareBlock.includes('WHERE wos.worker_id = $1'), + '概览份额统计必须限定当前打手,不能把其他打手的问题份额算进来', + ) + + const existsStart = source.indexOf('export function workerShareProblemExistsExpression(') + assert.ok(existsStart >= 0, '缺少按打手判定份额问题的表达式') + const existsBlock = source.slice(existsStart, existsStart + 420) + assert.match(existsBlock, /wsp\.worker_id = \$\{workerId\}/, '问题份额必须按打手隔离') + assert.match(existsBlock, /wsp\.status <> 'cancelled'/, '已取消份额不参与问题单筛选') + assert.match(existsBlock, /wsp\.problem_marked_at IS NOT NULL/, '缺少问题标记条件') + + assert.match( + source, + /wo\.status = 'problem'\s*OR \$\{workerShareProblemExistsExpression\(workerSharingId\)\}/, + '问题单页签必须同时命中母单问题与份额问题', + ) +}) diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 29f33a59..7ed39fac 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -365,6 +365,10 @@ export type WorkOrderShareRow = { timeout_policy?: string /** 取消来源:timeout/worker_request/admin/problem/settlement */ cancelled_reason?: string + /** 份额级问题单:备注 / 标记时间(NULL = 正常)/ 操作人;不影响母单状态 */ + problem_note?: string + problem_marked_at?: string | null + problem_marked_by?: string /** 客户端幂等键 */ request_id?: string worker_username?: string diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 0d958721..27919778 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -3,12 +3,16 @@ import type { PoolClient } from 'pg' import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js' import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js' -import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js' +import { + ensureWorkerWalletWithClient, + getWorkerWalletWithClient, + WORK_ORDER_SHARE_SELECT, +} from './shared.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js' import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' -import type { ProblemWorkOrderResolutionAction, WorkOrderRow } from './types.js' +import type { ProblemWorkOrderResolutionAction, WorkOrderRow, WorkOrderShareRow } from './types.js' /** * 统计工单的活跃拼单份额(除已取消外都算活跃)。 @@ -278,6 +282,294 @@ export async function resolveProblemWorkOrder(input: { }) } +/** + * 后台按份额标记问题单。 + * + * 只写份额自身的 `problem_*` 三个字段: + * - 母单 `status` / `problem_note` 一律不动(同工单其他打手完全无感); + * - 份额状态(joined/submitted/accepted)也不动,问题标记是正交属性; + * - 只有该份额持有人会在「我的订单」里看到问题单。 + * + * 母单已 accepted / cancelled / problem 时不允许按份额标记: + * 前两者已结算/终止,后者本身就是整单问题单(走整单处置)。 + */ +export async function markWorkOrderShareProblem(input: { + workOrderId: number + shareId: number + note: string + now: string + actorName?: string +}): Promise<{ + order: WorkOrderRow | null + share: WorkOrderShareRow | null + failureReason: 'work_order_not_markable' | 'share_not_found' | 'share_status_not_markable' | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if ( + !workOrder || + ['accepted', 'cancelled', 'problem'].includes(String(workOrder.status || '')) + ) { + return { + order: workOrder, + share: null, + failureReason: 'work_order_not_markable' as const, + } + } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [input.shareId, input.workOrderId], + ) + const share = shareResult.rows[0] || null + if (!share) { + return { order: workOrder, share: null, failureReason: 'share_not_found' as const } + } + if (share.status === 'cancelled') { + return { + order: workOrder, + share, + failureReason: 'share_status_not_markable' as const, + } + } + + await client.query( + `UPDATE work_order_shares + SET problem_note = $1, + problem_marked_at = $2, + problem_marked_by = $3, + updated_at = $2 + WHERE id = $4`, + [String(input.note || '').trim(), input.now, input.actorName || '', input.shareId], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'sharing_share_marked_problem', + // 母单状态不迁移,事件只记录份额级标记 + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + shareId: input.shareId, + workerId: Number(share.worker_id || 0), + note: String(input.note || '').trim(), + }), + now: input.now, + }) + + const updated = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [input.shareId], + ) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + share: updated.rows[0] || null, + failureReason: null, + } + }) +} + +/** + * 处置份额级问题单:与母单处置同名的四种方式,但只作用于这一份份额。 + * + * - return_to_worker:清标记;已提交的份额回到进行中并清空验收提交(继续代练)。 + * - reopen / cancel_release:取消该份额并退还押金。 + * - cancel_deduct:取消该份额并扣除押金。 + * + * 母单 `status` / `problem_note` 一律不写,同工单其他打手不受影响。 + */ +export async function disposeWorkOrderShareProblem(input: { + workOrderId: number + shareId: number + action: ProblemWorkOrderResolutionAction + note: string + now: string + actorName?: string +}): Promise<{ + order: WorkOrderRow | null + share: WorkOrderShareRow | null + depositAmount: number + failureReason: + | 'work_order_not_disposable' + | 'share_not_found' + | 'share_not_marked' + | 'share_status_not_disposable' + | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || ['accepted', 'cancelled'].includes(String(workOrder.status || ''))) { + return { + order: workOrder, + share: null, + depositAmount: 0, + failureReason: 'work_order_not_disposable' as const, + } + } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [input.shareId, input.workOrderId], + ) + const share = shareResult.rows[0] || null + if (!share) { + return { + order: workOrder, + share: null, + depositAmount: 0, + failureReason: 'share_not_found' as const, + } + } + if (!share.problem_marked_at) { + return { + order: workOrder, + share, + depositAmount: 0, + failureReason: 'share_not_marked' as const, + } + } + // 已验收份额的报酬/押金已结算,只能走售后,不能在这里取消或退回。 + if (!['joined', 'submitted'].includes(String(share.status || ''))) { + return { + order: workOrder, + share, + depositAmount: 0, + failureReason: 'share_status_not_disposable' as const, + } + } + + const workerId = Number(share.worker_id || 0) + let depositAmount = 0 + + if (input.action === 'return_to_worker') { + await client.query( + `UPDATE work_order_shares + SET status = CASE WHEN status = 'submitted' THEN 'joined' ELSE status END, + acceptance_json = CASE + WHEN status = 'submitted' THEN '{}'::jsonb + ELSE acceptance_json + END, + draft_acceptance_json = CASE + WHEN status = 'submitted' THEN '{}'::jsonb + ELSE draft_acceptance_json + END, + submitted_at = CASE WHEN status = 'submitted' THEN NULL ELSE submitted_at END, + deadline_at = CASE + WHEN status = 'submitted' THEN + CASE + WHEN timeout_minutes > 0 + THEN $2::timestamptz + (timeout_minutes * INTERVAL '1 minute') + ELSE NULL + END + ELSE deadline_at + END, + problem_note = '', + problem_marked_at = NULL, + problem_marked_by = '', + updated_at = $2 + WHERE id = $1`, + [input.shareId, input.now], + ) + } else { + const shouldDeduct = input.action === 'cancel_deduct' + if (workerId > 0) { + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + depositAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (depositAmount > 0) { + const nextAvailable = + Number(wallet?.available_amount || 0) + (shouldDeduct ? 0 : depositAmount) + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - depositAmount) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, input.now, workerId], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, + [ + workerId, + shouldDeduct ? 'deposit_deduction' : 'deposit_release', + shouldDeduct ? -depositAmount : depositAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + shouldDeduct ? '份额问题单处置扣除押金' : '份额问题单处置退还押金', + JSON.stringify({ + action: input.action, + shareId: input.shareId, + note: input.note, + }), + input.now, + ], + ) + } + } + + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', + cancelled_reason = 'problem', + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + problem_note = '', + problem_marked_at = NULL, + problem_marked_by = '', + updated_at = $1 + WHERE id = $2`, + [input.now, input.shareId], + ) + } + + // 母单状态保持不变:与「后台单人撤单」同口径,只处置这一份。 + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'sharing_share_problem_disposed', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + action: input.action, + shareId: input.shareId, + workerId, + note: input.note, + depositAmount, + }), + now: input.now, + }) + + const updated = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [input.shareId], + ) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + share: updated.rows[0] || null, + depositAmount, + failureReason: null, + } + }) +} + export async function settleOverdueWorkOrder(input: { workOrderId: number policy: 'reopen' | 'cancel_release' | 'cancel_deduct' 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 492d3d66..d24cbe29 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 @@ -241,11 +241,29 @@ function workerPendingHandlingExistsExpression(workerId: number): string { )` } +/** + * 该打手在本工单是否有被后台标记问题单的份额(份额级问题单)。 + * + * 只影响该份额持有人:母单 `status` 不变,同工单其他打手也看不到。 + * 多条份额时只要有一份被标记,这张单在「我的订单」里就显示为问题单。 + */ +export function workerShareProblemExistsExpression(workerId: number): string { + return `EXISTS ( + SELECT 1 + FROM work_order_shares wsp + WHERE wsp.work_order_id = wo.id + AND wsp.worker_id = ${workerId} + AND wsp.status <> 'cancelled' + AND wsp.problem_marked_at IS NOT NULL + )` +} + export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { const baseExpression = workerOrderBaseStatusExpression(workerShareAlias) const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) if (normalizedWorkerId <= 0) return baseExpression const pendingHandlingExists = workerPendingHandlingExistsExpression(normalizedWorkerId) + const shareProblemExists = workerShareProblemExistsExpression(normalizedWorkerId) const isTimeoutForMe = `( (wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId}) AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted') @@ -258,6 +276,8 @@ export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'wo )` return `CASE WHEN ${isTimeoutForMe} THEN 'open' + WHEN (${baseExpression}) IN ('accepted', 'cancelled', 'problem') THEN ${baseExpression} + WHEN ${shareProblemExists} THEN 'problem' WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem' ELSE ${baseExpression} END` @@ -688,6 +708,9 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise SELECT wos.work_order_id, CASE + -- 份额级问题单:只有该份额持有人会在概览里多出一条 problem 计数; + -- 同工单其他打手、母单状态都不受影响。被标记的份额不再计入原状态。 + WHEN wos.problem_marked_at IS NOT NULL THEN 'problem' -- 待审核取消申请/未处理反馈只把「进行中」的份额归入问题单; -- 已提交待验收的份额必须留在待验收(列表筛选就是这么判的, -- 这里若整体排除会出现「问题单有数字、点进去空列表」)。 @@ -703,7 +726,8 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise WHERE wos.worker_id = $1 AND wos.status IN ('joined', 'submitted', 'accepted') AND f.wo_status NOT IN ('accepted', 'cancelled', 'problem') - GROUP BY wos.work_order_id, wos.status, f.has_pending_handling + GROUP BY wos.work_order_id, wos.status, f.has_pending_handling, + (wos.problem_marked_at IS NOT NULL) ), order_status_rows AS ( SELECT @@ -1288,8 +1312,8 @@ export function buildWorkOrderWhere({ } } else if (normalizedStatuses.length > 0) { if (workerSharingId && isProblemOnlyStatusFilter(normalizedStatuses)) { - // 「仅问题单」短路:问题单 = wo.status 直接是 problem,或 进行中 且挂着 - // 待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行), + // 「仅问题单」短路:问题单 = 母单 problem、自己某份份额被后台标记问题单, + // 或 进行中 且挂着待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行), // 从它们出发反查,避免为了一个通常为空的结果枚举该打手全部可见单再逐行求值 // 完整 CASE(重打手 8,586 单 × 逐行探测 ≈ 233MB 缓冲读)。 // 注意左臂必须写成 sargable 的 wo.status = 'problem':完整表达式里 @@ -1299,6 +1323,7 @@ export function buildWorkOrderWhere({ const baseExpression = workerOrderBaseStatusExpression('worker_share') filters.push(`( wo.status = 'problem' + OR ${workerShareProblemExistsExpression(workerSharingId)} OR ( ${baseExpression} = 'in_progress' AND wo.id IN ( @@ -1321,6 +1346,7 @@ export function buildWorkOrderWhere({ const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])` // 一个工单可能同时有「进行中 + 待验收」等多份不同状态的份额。 // 只看代表份额会让其中一种状态在页签里完全消失,所以只要任一份额命中就展示。 + // 被后台标记问题单的份额只归问题单页签,不再按原状态出现(问题标记是正交属性)。 const anyShareStatus = `( wo.status NOT IN ('accepted', 'cancelled', 'problem') AND EXISTS ( @@ -1329,6 +1355,7 @@ export function buildWorkOrderWhere({ WHERE ws_filter.work_order_id = wo.id AND ws_filter.worker_id = ${workerSharingId} AND ws_filter.status IN ('joined', 'submitted', 'accepted') + AND ws_filter.problem_marked_at IS NULL AND ( ( ws_filter.status = 'joined' diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index d84a8b0f..3dfb0bea 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -7,7 +7,9 @@ import { cancelAdminWorkOrderSharing, deductAdminWorkOrderPendingDeposit, markAdminWorkOrderProblem, + markAdminWorkOrderShareProblem, resolveAdminProblemWorkOrder, + disposeAdminWorkOrderShareProblem, } from '../../../services/worker-platform/index.js' import { createJsonHandler, requireAdminPermission, requireAnyAdminPermission } from '../session.js' @@ -161,6 +163,58 @@ router.post( ), ) +// 份额级问题单:只影响该份额持有人,母单状态不变。 +router.post( + '/worker-platform/orders/:workOrderId/shares/:shareId/problem', + requireAdminPermission('worker_order.manage_problem'), + createJsonHandler( + (req) => + markAdminWorkOrderShareProblem( + String(req.params.workOrderId || ''), + String(req.params.shareId || ''), + req.body || {}, + req.adminSession?.displayName || req.adminSession?.username || '', + ), + { + successMessage: '该份额已标记为问题单,仅影响该打手', + errorMessage: '标记份额问题单失败', + scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/problem]', + audit: (req, data) => ({ + action: 'work_order_share_marked_problem', + targetType: 'work_order_share', + targetId: String(req.params.shareId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + +// 份额级问题单处置:处置方式与母单一致,但只作用于这一份份额,母单状态不变。 +router.post( + '/worker-platform/orders/:workOrderId/shares/:shareId/problem/dispose', + requireAdminPermission('worker_order.manage_problem'), + createJsonHandler( + (req) => + disposeAdminWorkOrderShareProblem( + String(req.params.workOrderId || ''), + String(req.params.shareId || ''), + req.body || {}, + req.adminSession?.displayName || req.adminSession?.username || '', + ), + { + successMessage: '该份额问题单已处置', + errorMessage: '处置份额问题单失败', + scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/problem/dispose]', + audit: (req, data) => ({ + action: 'work_order_share_problem_disposed', + targetType: 'work_order_share', + targetId: String(req.params.shareId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/deduct-pending-deposit', requireAdminPermission('worker_order.deduct_deposit'), diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index 487de6cc..799b6616 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -255,6 +255,9 @@ async function publishWorkOrderSnapshots(input: { hallChanged?: boolean }) { const workOrderId = Math.max(0, Number(input.workOrderId) || 0) + // 调用方显式给出的受影响打手必须同步失效:下面的快照生成要先查库, + // 若等都查完再失效,紧接着到达的「我的订单」请求会命中旧缓存(30s TTL)。 + invalidateWorkerMyOrderCaches(input.workerIds || []) const workOrder = await getWorkOrderById(workOrderId) if (!workOrder) { // 工单已不存在(删除):调用方传入的打手集合仍需失效缓存, @@ -449,7 +452,10 @@ async function publishWorkOrderSnapshots(input: { hasTimeoutForMe: Boolean(timeoutEvent), vipEvidencePending, }), - problemMarked: !isTimedOutForMe && workOrder.status === WORK_ORDER_STATUS.PROBLEM, + problemMarked: + !isTimedOutForMe && + (workOrder.status === WORK_ORDER_STATUS.PROBLEM || + activeWorkerShares.some((share) => Boolean(share.problem_marked_at))), pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId), pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId), materialUpdated: diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts index e2831c88..70014207 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts @@ -2,8 +2,10 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { createWorkOrderEvent, deleteWorkOrder, + disposeWorkOrderShareProblem, listWorkOrderShares, listWorkOrders, + markWorkOrderShareProblem, publishUnassignedWorkOrder, reopenCancelledWorkOrder, resolveProblemWorkOrder, @@ -22,6 +24,7 @@ import { } from './publish-voucher-service.js' import { mapWorkOrderAdmin, + mapWorkOrderShare, normalizeAmountFen, normalizeInteger, normalizeOptionalId, @@ -436,3 +439,118 @@ export async function resolveAdminProblemWorkOrder( }) return { order: mapWorkOrderAdmin(updated) } } + +/** + * 后台「拼单明细」按份额标记问题单。 + * + * 只影响该份额持有人:母单状态/备注不动,同工单其他打手无感。 + * 备注选填;重复标记视为更新备注(幂等)。 + */ +export async function markAdminWorkOrderShareProblem( + workOrderId: number | string, + shareId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const note = String(payload.note || payload.problemNote || '').trim() + const result = await markWorkOrderShareProblem({ + workOrderId: Number(workOrderId), + shareId: Number(shareId), + note, + now: nowIso(), + actorName, + }) + if (result.failureReason === 'work_order_not_markable') { + throw createHttpError('当前订单状态不能按份额标记问题单', { + statusCode: 409, + errorCode: 'work_order_share_problem_status_invalid', + }) + } + if (result.failureReason === 'share_not_found' || !result.share) { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_share_problem_share_not_found', + }) + } + if (result.failureReason === 'share_status_not_markable') { + throw createHttpError('已撤单的份额不能标记问题单', { + statusCode: 409, + errorCode: 'work_order_share_problem_share_status_invalid', + }) + } + // 只通知该份额持有人刷新「我的订单」;后台列表由快照广播覆盖。 + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds: [Number(result.share.worker_id || 0)].filter((workerId) => workerId > 0), + }) + return { + order: result.order ? mapWorkOrderAdmin(result.order) : null, + share: mapWorkOrderShare(result.share), + } +} + +/** + * 后台「拼单明细」处置份额级问题单。 + * + * 处置方式与母单处置完全一致(退回打手继续代练 / 释放押金回大厅 / + * 取消退押金 / 取消扣押金),但只作用于这一份份额:母单状态/备注不动, + * 同工单其他打手无感。备注必填,与母单处置同口径。 + */ +export async function disposeAdminWorkOrderShareProblem( + workOrderId: number | string, + shareId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const action = normalizeProblemResolutionAction(payload.action) + const note = String(payload.note || '').trim() + if (!note) { + throw createHttpError('请填写处置备注', { + statusCode: 400, + errorCode: 'work_order_share_problem_disposal_note_required', + }) + } + + const result = await disposeWorkOrderShareProblem({ + workOrderId: Number(workOrderId), + shareId: Number(shareId), + action, + note, + now: nowIso(), + actorName, + }) + if (result.failureReason === 'work_order_not_disposable') { + throw createHttpError('当前订单状态不能处置份额问题单', { + statusCode: 409, + errorCode: 'work_order_share_problem_disposal_status_invalid', + }) + } + if (result.failureReason === 'share_not_found' || !result.share) { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_share_problem_share_not_found', + }) + } + if (result.failureReason === 'share_not_marked') { + throw createHttpError('该份额当前不是问题单', { + statusCode: 409, + errorCode: 'work_order_share_problem_not_marked', + }) + } + if (result.failureReason === 'share_status_not_disposable') { + throw createHttpError('已验收或已取消的份额不能处置,请走售后或单人撤单', { + statusCode: 409, + errorCode: 'work_order_share_problem_share_status_invalid', + }) + } + + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds: [Number(result.share.worker_id || 0)].filter((workerId) => workerId > 0), + }) + return { + order: result.order ? mapWorkOrderAdmin(result.order) : null, + share: mapWorkOrderShare(result.share), + depositAmount: result.depositAmount, + } +} diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts index b417b155..b592aa2c 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts @@ -218,3 +218,33 @@ test('VIP 免审核待补图:只有已验收 + vip_auto + 待补图 + 未到 ['accepted', 'vip_evidence_pending'], ) }) + +test('份额级问题单:被标记的份额只归问题单,同单其他份额仍按原状态归类', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: false, + shares: [ + share({ id: 1, status: 'joined', problem_marked_at: '2026-10-23T11:00:00.000Z' }), + share({ id: 2, status: 'submitted' }), + ], + }) + assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem']) +}) + +test('份额级问题单:唯一份额被标记后不得再出现在原状态页签', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'joined', problem_marked_at: '2026-10-23T11:00:00.000Z' })], + }) + assert.deepEqual(tabs, ['problem']) +}) + +test('份额级问题单:已验收份额被标记后只归问题单,母单仍为进行中', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'accepted', problem_marked_at: '2026-10-23T11:00:00.000Z' })], + }) + assert.deepEqual(tabs, ['problem']) +}) 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 8deee423..6af2857c 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 @@ -526,12 +526,21 @@ export function mapWorkOrderForWorker( } = mapped const isTimedOutForMe = options?.isTimedOutForMe === true || Boolean(options?.timeoutNotice?.isTimedOut) - const personalStatus = isTimedOutForMe - ? WORK_ORDER_STATUS.OPEN - : resolveWorkerShareStatus(workOrder.status, myShare?.status) - const workerShares = options?.myShares && options.myShares.length > 0 ? options.myShares : myShare ? [myShare] : [] + /** + * 份额级问题单:该打手在本单只要有一份被后台标记,这张单就显示为问题单 + * (母单仍是 accepted / cancelled / problem 时以母单为准,与列表 SQL 同序)。 + */ + const problemShare = workerShares.find( + (share) => share.status !== 'cancelled' && Boolean(share.problem_marked_at), + ) + const personalStatus = isTimedOutForMe + ? WORK_ORDER_STATUS.OPEN + : problemShare && !['accepted', 'cancelled', 'problem'].includes(String(workOrder.status || '')) + ? WORK_ORDER_STATUS.PROBLEM + : resolveWorkerShareStatus(workOrder.status, myShare?.status) + const shareSummary = summarizeWorkerShares(workerShares, Number(workOrder.timeout_minutes || 0)) const hasActiveShares = workerShares.some((share) => share.status !== 'cancelled') @@ -541,6 +550,13 @@ export function mapWorkOrderForWorker( ? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity) : visibleOrder.productName, status: personalStatus, + /** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */ + problemNote: + String(workOrder.status || '') === WORK_ORDER_STATUS.PROBLEM + ? visibleOrder.problemNote + : String(problemShare?.problem_note || ''), + /** 是否为「份额级问题单」(区别于平台整单标记) */ + shareProblemMarked: Boolean(problemShare), rewardAmount: isTimedOutForMe ? 0 : hasActiveShares @@ -622,6 +638,8 @@ type WorkerShareLike = { status?: unknown created_at?: unknown id?: unknown + /** 份额级问题单标记时间;非空表示该份额被后台标记问题单 */ + problem_marked_at?: unknown } /** @@ -700,18 +718,24 @@ export function resolveWorkerOrderStatusTabs(input: { (share) => share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted', ) + // 份额级问题单:只要有一份被后台标记,这张单就归问题单(与 SQL 的 workerShareProblemExists 同义)。 + const problemShare = activeShares.find((share) => Boolean(share.problem_marked_at)) const representative = pickWorkerRepresentativeShare(shares) let representativeStatus = representative ? resolveWorkerShareStatus(workOrderStatus, representative.status) : workOrderStatus if (input.hasTimeoutForMe && !input.isAssignedWorker && activeShares.length === 0) { representativeStatus = WORK_ORDER_STATUS.OPEN + } else if (problemShare) { + representativeStatus = WORK_ORDER_STATUS.PROBLEM } else if (representativeStatus === WORK_ORDER_STATUS.IN_PROGRESS && input.hasPendingHandling) { representativeStatus = WORK_ORDER_STATUS.PROBLEM } if (representativeStatus) tabs.add(representativeStatus as WorkerOrderStatusTab) for (const share of activeShares) { + // 被标记问题单的份额只归问题单页签,不再按原状态出现(与 SQL 的 anyShare 分支一致)。 + if (share.problem_marked_at) continue if (share.status === 'joined') { // 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。 if (!input.hasPendingHandling) tabs.add('in_progress') @@ -748,6 +772,11 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) { shareReward: Number(share.share_reward || 0), shareDeposit: Number(share.share_deposit || 0), status: share.status, + /** 份额级问题单:只影响该份额持有人,母单状态不变 */ + problemMarked: Boolean(share.problem_marked_at), + problemNote: share.problem_note || '', + problemMarkedAt: share.problem_marked_at || null, + problemMarkedBy: share.problem_marked_by || '', acceptance: mapAcceptanceForResponse(share.acceptance_json), draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json), submittedAt: share.submitted_at, 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 2619012d..76f837a9 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 @@ -225,7 +225,13 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker { isTimedOutForMe, timeoutNotice, myShares: activeShares }, ), // 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。 - problemMarked: !isTimedOutForMe && item.status === 'problem', + // 份额级问题单也算「平台标记」:母单状态没变,但该打手这份被后台标记了。 + problemMarked: + !isTimedOutForMe && + (item.status === 'problem' || + activeShares.some( + (share) => share.status !== 'cancelled' && Boolean(share.problem_marked_at), + )), pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId), pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId), materialUpdated, 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 46e6870b..7b83afd4 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 @@ -3364,6 +3364,61 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收 ) }) +test('份额级问题单标记/处置只写份额字段,不得改母单状态或触发整单重算', () => { + const src = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url), + 'utf8', + ) + const markBlock = src.slice( + src.indexOf('export async function markWorkOrderShareProblem('), + src.indexOf('export async function disposeWorkOrderShareProblem('), + ) + assert.ok(markBlock.length > 0, 'markWorkOrderShareProblem 片段必须存在') + assert.ok(markBlock.includes('UPDATE work_order_shares'), '标记必须写份额表') + assert.ok( + !/UPDATE\s+work_orders/i.test(markBlock), + '份额级问题单不得写母单(含 status / problem_note)', + ) + assert.ok( + !markBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '份额级问题单不得触发整单状态重算', + ) + assert.ok( + markBlock.includes('toStatus: workOrder.status') && + markBlock.includes('fromStatus: workOrder.status'), + '标记事件不得记录母单状态迁移', + ) + + const disposeBlock = src.slice( + src.indexOf('export async function disposeWorkOrderShareProblem('), + src.indexOf('export async function settleOverdueWorkOrder('), + ) + assert.ok(disposeBlock.length > 0, 'disposeWorkOrderShareProblem 片段必须存在') + assert.ok(disposeBlock.includes('UPDATE work_order_shares'), '处置必须写份额表') + assert.ok(!/UPDATE\s+work_orders/i.test(disposeBlock), '份额处置不得改母单') + assert.ok( + !disposeBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '份额处置不得触发整单状态重算', + ) + assert.ok( + disposeBlock.includes('toStatus: workOrder.status') && + disposeBlock.includes('fromStatus: workOrder.status'), + '处置事件不得记录母单状态迁移', + ) + assert.ok( + disposeBlock.includes("status = CASE WHEN status = 'submitted' THEN 'joined'"), + '退回打手必须把已提交的份额恢复为进行中', + ) + assert.ok( + disposeBlock.includes("shouldDeduct ? 'deposit_deduction' : 'deposit_release'"), + '取消类处置必须按方式退还或扣除押金', + ) + assert.ok( + disposeBlock.includes("cancelled_reason = 'problem'"), + '取消类处置必须记录问题单处置来源', + ) +}) + test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => { const src = fs.readFileSync( new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index d76e5a68..628f24fa 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -57,6 +57,7 @@ import { confirmAdminWorkOrderFriend, deductAdminWorkOrderPendingDeposit, deleteAdminWorkOrder, + disposeAdminWorkOrderShareProblem, fetchAdminWorkCategories, fetchAdminSession, fetchAdminWorkOrderSharing, @@ -65,6 +66,7 @@ import { fetchAdminWorkerUsers, fetchAdminWorkOrders, markAdminWorkOrderProblem, + markAdminWorkOrderShareProblem, pinAdminWorkOrder, publishAdminWorkOrder, reopenAdminCancelledWorkOrder, @@ -241,6 +243,9 @@ export default function WorkOrdersPanel() { const canCancelShare = sessionPermissions ? sessionPermissions.includes('worker_order.cancel_share') : hasAdminPermission('worker_order.cancel_share') + const canManageShareProblem = sessionPermissions + ? sessionPermissions.includes('worker_order.manage_problem') + : hasAdminPermission('worker_order.manage_problem') const canEditOrder = sessionPermissions ? sessionPermissions.includes('worker_order.edit') : hasAdminPermission('worker_order.edit') @@ -298,6 +303,8 @@ export default function WorkOrdersPanel() { const [materialPasteText, setMaterialPasteText] = useState('') const [sharingOrder, setSharingOrder] = useState(null) const [cancelShare, setCancelShare] = useState(null) + const [problemShare, setProblemShare] = useState(null) + const [disposeShare, setDisposeShare] = useState(null) const [cancelSharingOrder, setCancelSharingOrder] = useState(null) const [assignOrder, setAssignOrder] = useState(null) const [assignWorkerId, setAssignWorkerId] = useState(undefined) @@ -310,6 +317,11 @@ export default function WorkOrdersPanel() { const [materialForm] = Form.useForm() const [cancelForm] = Form.useForm() const [cancelShareForm] = Form.useForm() + const [problemShareForm] = Form.useForm<{ note?: string }>() + const [disposeShareForm] = Form.useForm<{ + action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct' + note?: string + }>() const [cancelSharingForm] = Form.useForm() const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() const [editForm] = Form.useForm<{ @@ -710,6 +722,51 @@ export default function WorkOrdersPanel() { cancelShareForm.resetFields() } + /** 按份额标记问题单:只影响该打手,母单状态不变。备注选填。 */ + async function submitProblemShare(values: { note?: string }) { + if (!sharingOrder || !problemShare) return + const workOrderId = sharingOrder.workOrderId + const succeeded = await runAction( + () => + markAdminWorkOrderShareProblem( + workOrderId, + problemShare.shareId, + String(values.note || '').trim(), + ), + '该份额已标记为问题单,仅影响该打手', + ) + if (!succeeded) return + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-order-sharing', workOrderId], + }) + setProblemShare(null) + problemShareForm.resetFields() + } + + /** 处置份额问题单:方式与母单处置一致,但只作用于这一份,母单状态不变。 */ + async function submitShareDisposal(values: { + action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct' + note?: string + }) { + if (!sharingOrder || !disposeShare || !values.action) return + const workOrderId = sharingOrder.workOrderId + const action = values.action + const succeeded = await runAction( + () => + disposeAdminWorkOrderShareProblem(workOrderId, disposeShare.shareId, { + action, + note: String(values.note || '').trim(), + }), + '该份额问题单已处置', + ) + if (!succeeded) return + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-order-sharing', workOrderId], + }) + setDisposeShare(null) + disposeShareForm.resetFields() + } + async function submitCancelSharing(values: CancelSharingFormValues) { if (!cancelSharingOrder || !values.action) return const action = values.action @@ -3023,13 +3080,14 @@ export default function WorkOrdersPanel() { sharingOrder?.capabilities?.canConfigureSharing && canConfigureSharing ? undefined : null } destroyOnHidden - width={720} + width={1200} > {sharingOrder ? (
+ share.problemMarked + ? 'admin-share-row-problem' + : share.status === 'cancelled' + ? 'admin-share-row-cancelled' + : '' + } columns={[ { title: '打手', + width: 132, + fixed: 'left', render: (_, share) => share.worker?.displayName || share.worker?.username || '-', }, - { title: '数量', dataIndex: 'quantity' }, + { title: '数量', dataIndex: 'quantity', width: 64, align: 'center' }, { title: '份额报酬', + width: 96, + align: 'right', render: (_, share) => formatMoney(share.shareReward), }, { title: '状态', + width: 104, render: (_, share) => ( @@ -3171,13 +3242,40 @@ export default function WorkOrdersPanel() { ), }, + { + title: '问题', + width: 120, + render: (_, share) => + share.problemMarked ? ( + + 问题单 + {String(share.problemNote || '').trim() ? ( + + {share.problemNote} + + ) : ( + + 未填备注 + + )} + + ) : ( + - + ), + }, { title: '参与时间', + width: 128, render: (_, share) => share.createdAt ? formatAdminDateTime(share.createdAt) : '-', }, { title: '截止时间', + width: 150, render: (_, share) => share.deadlineAt ? ( @@ -3190,17 +3288,19 @@ export default function WorkOrdersPanel() { }, { title: '超时策略', + width: 90, render: (_, share) => share.timeoutPolicy ? formatTimeoutPolicyLabel(share.timeoutPolicy) : '-', }, { title: '提交时间', + width: 128, render: (_, share) => share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-', }, { title: '验收图', - width: 150, + width: 96, render: (_, share) => ( ), }, { title: '备注', - render: (_, share) => String(share.acceptance?.note || '').trim() || '-', + width: 120, + render: (_, share) => { + const note = String(share.acceptance?.note || '').trim() + return note ? ( + + {note} + + ) : ( + '-' + ) + }, }, { title: '操作', - width: 190, + width: 300, + fixed: 'right', render: (_, share) => { - if (share.status === 'accepted') return 已验收 if (share.status === 'cancelled') return 已撤单 + if (share.status === 'accepted') return 已验收 + + // 份额级问题单:标记只影响该打手;已标记时按母单同一套方式处置。 + // 已验收份额的报酬/押金已结算,不能处置,因此也不提供标记入口。 + const disposableShare = + share.status === 'joined' || share.status === 'submitted' + const problemAction = + canManageShareProblem && disposableShare ? ( + share.problemMarked ? ( + + ) : ( + + ) + ) : null + return ( - + {share.status === 'submitted' ? ( + + +
+
+ ) : ( + + + + {hint} + + + )} + + {busy ? ( + `${phase === 'compressing' ? '压缩中' : '上传中'} ${percent ?? 0}%`} + style={{ marginTop: 8 }} + /> + ) : null} + + ) +} + +function estimatedSizeMb(config: AcceptanceVideoConfig) { + const bytesPerSecond = ((config.bitrateKbps + config.audioBitrateKbps) * 1000) / 8 + const estimated = (bytesPerSecond * config.maxDurationSeconds) / (1024 * 1024) + return Math.max(1, Math.ceil(estimated)) +} + +function formatDuration(seconds: number) { + const total = Math.max(0, Math.round(Number(seconds) || 0)) + const minutes = Math.floor(total / 60) + const rest = total % 60 + return minutes > 0 ? `${minutes}分${String(rest).padStart(2, '0')}秒` : `${rest}秒` +} + +function formatSize(bytes?: number) { + const size = Number(bytes || 0) + if (!size) return '大小未知' + if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB` + return `${Math.max(1, Math.round(size / 1024))}KB` +} diff --git a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx index 2f865870..db7e1626 100644 --- a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx @@ -1,4 +1,4 @@ -import { ReloadOutlined } from '@ant-design/icons' +import { ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' import { App, @@ -26,7 +26,12 @@ import { repayAdminAfterSalesCaseDebt, resolveAdminAfterSalesCase, } from '@/services/admin' -import type { UploadedFile, WorkerAfterSalesCase, WorkOrder } from '@/types/worker-platform' +import type { + UploadedFile, + WorkerAcceptanceVideo, + WorkerAfterSalesCase, + WorkOrder, +} from '@/types/worker-platform' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { formatAdminDateTime } from '@/utils/admin-time' import { uniqueUploadedFiles } from '@/utils/image-identity' @@ -259,6 +264,15 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan /> ) : null} + {getAfterSalesSnapshotVideo(item) ? ( + + 查看固化验收视频 + + ) : null} ), }, @@ -570,4 +584,21 @@ function getAfterSalesSnapshotFiles(item: WorkerAfterSalesCase): UploadedFile[] ) } +/** 售后固化快照里的验收视频(有则给出可点击链接)。 */ +function getAfterSalesSnapshotVideo(item: WorkerAfterSalesCase): WorkerAcceptanceVideo | null { + const snapshot = asRecord(item.acceptanceSnapshot) + const acceptance = asRecord(snapshot.acceptance) + const video = asRecord(acceptance.video) + const url = String(video.url || '').trim() + if (!url) return null + return { + objectKey: String(video.objectKey || video.object_key || ''), + url, + filename: String(video.filename || ''), + contentType: String(video.contentType || video.content_type || ''), + size: Number(video.size || 0), + durationSeconds: Number(video.durationSeconds || video.duration_seconds || 0), + } +} + export default AfterSalesPanel diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 628f24fa..8f5b6cc4 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -14,6 +14,7 @@ import { UserAddOutlined, WarningOutlined, RollbackOutlined, + VideoCameraOutlined, } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' import { @@ -28,6 +29,7 @@ import { Input, InputNumber, Modal, + Popover, Radio, Select, Space, @@ -45,6 +47,7 @@ import JsonPreview from '@/components/admin/JsonPreview' import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import ImageUpload from '@/components/files/ImageUpload' import ImagePreviewList from '@/components/files/ImagePreviewList' +import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/VideoPreview' import { acceptAdminWorkOrder, acceptAdminWorkOrderShare, @@ -2603,6 +2606,11 @@ export default function WorkOrdersPanel() { {getAcceptanceFiles(detailOrder).length + getAcceptanceImageUrls(detailOrder).length || '-'} + + {detailOrder.acceptance?.video + ? formatAcceptanceVideoDuration(detailOrder.acceptance.video.durationSeconds) + : '未上传'} + {detailOrder.acceptance?.reviewMode === 'vip_auto' ? ( {detailOrder.acceptance.evidenceStatus === 'pending' @@ -2626,6 +2634,11 @@ export default function WorkOrdersPanel() { ) : ( 暂无验收图 )} + @@ -3205,7 +3218,7 @@ export default function WorkOrdersPanel() { loading={sharingQuery.isFetching} pagination={false} tableLayout="fixed" - scroll={{ x: 1528 }} + scroll={{ x: 1624 }} rowClassName={(share) => share.problemMarked ? 'admin-share-row-problem' @@ -3316,6 +3329,26 @@ export default function WorkOrdersPanel() { /> ), }, + { + title: '验收视频', + width: 96, + render: (_, share) => { + const video = share.acceptance?.video + if (!video?.url) return '-' + return ( + } + > + + + ) + }, + }, { title: '备注', width: 120, diff --git a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx index ebab51c9..074d350d 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx @@ -3,6 +3,7 @@ import { Alert, Button, Card, Descriptions, Drawer, Space, Steps, Tag, Typograph import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import ImagePreviewList from '@/components/files/ImagePreviewList' +import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/VideoPreview' import type { WorkOrder } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display' @@ -104,7 +105,7 @@ export function WorkerOrderDetailDrawer({ icon={} onClick={() => onOpenAcceptance(order)} > - {hasSubmittedAcceptance(order) ? '补充图片' : '上传图片 / 提交验收'} + {hasSubmittedAcceptance(order) ? '补充验收资料' : '上传图片/视频 · 提交验收'} ) : null} {canRequestCancel(order) ? ( @@ -322,6 +323,7 @@ function GiftProgressCard({ order }: { order: WorkOrder }) { function AcceptanceSummary({ order }: { order: WorkOrder }) { const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order)) const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order)) + const acceptanceVideo = getWorkerAcceptance(order)?.video ?? getWorkerDraft(order)?.video ?? null const hasAcceptance = acceptanceFiles.length > 0 const hasDraft = draftFiles.length > 0 && !hasSubmittedAcceptance(order) @@ -329,7 +331,12 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) {
{acceptanceFiles.length} - + + {acceptanceVideo + ? formatAcceptanceVideoDuration(acceptanceVideo.durationSeconds) + : '未上传'} + + {formatDateTime(getAcceptanceSubmittedAt(order))} @@ -345,6 +352,8 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) { 暂无已提交验收图片 )} + + {hasDraft ? (
}> diff --git a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx index 537abb5e..beb0228e 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx @@ -476,7 +476,7 @@ function WorkerOrderMobileCard({ className="worker-order-mobile-action-submit" onClick={() => onOpenAcceptance(order)} > - {hasSubmittedAcceptance(order) ? '补充图片' : '提交验收'} + {hasSubmittedAcceptance(order) ? '补充验收资料' : '上传图片/视频 · 提交验收'} ) : null} {canRemindAcceptance(order) ? ( diff --git a/apps/frontend/src/pages/worker/WorkerOrderModals.tsx b/apps/frontend/src/pages/worker/WorkerOrderModals.tsx index cdcd2ecb..ed7397c0 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderModals.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderModals.tsx @@ -3,8 +3,10 @@ import { Button, Form, Input, Modal, Radio, Space, Tag, Typography } from 'antd' import type { FormInstance } from 'antd' import ImageUpload from '@/components/files/ImageUpload' +import VideoUpload from '@/components/files/VideoUpload' import type { UploadedFile, + WorkerAcceptanceVideo, WorkerCancelRequestRisk, WorkerOrderFeedback, WorkOrder, @@ -59,10 +61,12 @@ export function WorkerOrderModals({ onSaveNote, editingOrder, acceptanceFiles, + acceptanceVideo, acceptanceForm, workerCanAutoAcceptWithoutEvidence, onCloseAcceptance, onAcceptanceFilesChange, + onAcceptanceVideoChange, onSubmitAcceptance, onSaveAcceptanceImages, giftMaterialOrder, @@ -89,10 +93,12 @@ export function WorkerOrderModals({ onSaveNote: (values: WorkerNoteFormValues) => void | Promise editingOrder: WorkOrder | null acceptanceFiles: UploadedFile[] + acceptanceVideo: WorkerAcceptanceVideo | null acceptanceForm: FormInstance workerCanAutoAcceptWithoutEvidence: boolean onCloseAcceptance: () => void onAcceptanceFilesChange: (files: UploadedFile[]) => void + onAcceptanceVideoChange: (video: WorkerAcceptanceVideo | null) => void onSubmitAcceptance: (values: AcceptanceFormValues) => void | Promise onSaveAcceptanceImages: (values: AcceptanceFormValues) => void | Promise giftMaterialOrder: WorkOrder | null @@ -283,6 +289,12 @@ export function WorkerOrderModals({ showPasteHint /> + + + (null) const [feedbackOrder, setFeedbackOrder] = useState(null) const [acceptanceFiles, setAcceptanceFiles] = useState([]) + /** 验收视频(单条,非必填);dirty 表示本次弹窗里换过视频,用于补图时允许"只补视频" */ + const [acceptanceVideo, setAcceptanceVideo] = useState(null) + const [acceptanceVideoDirty, setAcceptanceVideoDirty] = useState(false) const [giftMaterialOrder, setGiftMaterialOrder] = useState(null) const [giftMaterialFiles, setGiftMaterialFiles] = useState([]) const [form] = Form.useForm() @@ -340,18 +343,20 @@ export default function WorkerOrdersPage() { const editingTarget = buildShareEditingOrder(order, shareId) setEditingShareId(shareId) setEditingOrder(editingTarget) + setAcceptanceVideoDirty(false) + const submitted = getWorkerAcceptance(editingTarget) if (canSupplementAcceptedEvidence(editingTarget)) { - // 已验收补图只上传新增文件,原有验收图片由服务端保留。 + // 已验收补图只上传新增文件,原有验收图片由服务端保留;视频只能替换、不能删除。 setAcceptanceFiles([]) + setAcceptanceVideo(submitted?.video ?? null) form.resetFields() setDetailOrder(null) return } // 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容 - const source = hasSubmittedAcceptance(editingTarget) - ? getWorkerAcceptance(editingTarget) - : getWorkerDraft(editingTarget) + const source = hasSubmittedAcceptance(editingTarget) ? submitted : getWorkerDraft(editingTarget) setAcceptanceFiles(getUniqueAcceptanceFiles(source)) + setAcceptanceVideo(source?.video ?? null) form.setFieldsValue({ note: String(source?.note || '') }) setDetailOrder(null) } @@ -484,22 +489,25 @@ export default function WorkerOrdersPage() { if (!editingOrder) return const isAcceptedEvidenceSupplement = canSupplementAcceptedEvidence(editingOrder) if (isAcceptedEvidenceSupplement) { - if (acceptanceFiles.length === 0) { - message.error('请上传新的验收图片') + if (acceptanceFiles.length === 0 && !acceptanceVideoDirty) { + message.error('请上传新的验收图片,或替换验收视频') return } try { await supplementWorkerAcceptedOrderEvidence(editingOrder.workOrderId, { files: acceptanceFiles, + ...(acceptanceVideoDirty ? { video: acceptanceVideo } : {}), ...(editingShareId ? { shareId: editingShareId } : {}), }) - message.success('验收图片已补充') + message.success('验收资料已补充') setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() await refreshAll() } catch (error) { - message.error(error instanceof Error ? error.message : '补充验收图片失败') + message.error(error instanceof Error ? error.message : '补充验收资料失败') } return } @@ -518,11 +526,14 @@ export default function WorkerOrdersPage() { await submitWorkerAcceptance(editingOrder.workOrderId, { note: values.note, files: acceptanceFiles, + video: acceptanceVideo, ...(editingShareId ? { shareId: editingShareId } : {}), }) message.success(canAutoAccept ? '已自动验收,可在 24 小时内补充图片' : '已提交验收') setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() await refreshAll() } catch (error) { @@ -540,13 +551,16 @@ export default function WorkerOrdersPage() { await saveWorkerAcceptanceDraft(editingOrder.workOrderId, { note: values.note, files: acceptanceFiles, + video: acceptanceVideo, ...(editingShareId ? { shareId: editingShareId } : {}), }) message.success( - hasSubmittedAcceptance(editingOrder) ? '验收图片已更新' : '验收图片已暂存,可继续补充', + hasSubmittedAcceptance(editingOrder) ? '验收资料已更新' : '验收资料已暂存,可继续补充', ) setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() await refreshAll() } catch (error) { @@ -754,14 +768,21 @@ export default function WorkerOrdersPage() { onSaveNote={saveOrderNote} editingOrder={editingOrder} acceptanceFiles={acceptanceFiles} + acceptanceVideo={acceptanceVideo} acceptanceForm={form} workerCanAutoAcceptWithoutEvidence={workerCanAutoAcceptWithoutEvidence} onCloseAcceptance={() => { setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() }} onAcceptanceFilesChange={setAcceptanceFiles} + onAcceptanceVideoChange={(video) => { + setAcceptanceVideo(video) + setAcceptanceVideoDirty(true) + }} onSubmitAcceptance={submitAcceptance} onSaveAcceptanceImages={saveAcceptanceImages} giftMaterialOrder={giftMaterialOrder} diff --git a/apps/frontend/src/services/files.ts b/apps/frontend/src/services/files.ts index db31fddd..59aa00e4 100644 --- a/apps/frontend/src/services/files.ts +++ b/apps/frontend/src/services/files.ts @@ -18,3 +18,16 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope) const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form) return response.data.file } + +/** + * 验收视频上传:视频已在浏览器内压好,这里不再做图片压缩。 + * 带上 durationSeconds 供服务端在容器解析不到时长时兜底(见 resolveVideoDuration)。 + */ +export async function uploadVideoFile(file: File, scene: string, durationSeconds: number) { + const form = new FormData() + form.append('file', file) + form.append('scene', scene) + form.append('durationSeconds', String(durationSeconds)) + const response = await apiPostForm<{ file: UploadedFile }>('/api/v1/worker/files/upload', form) + return response.data.file +} diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 31a484bf..d553c1cd 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -9,6 +9,8 @@ import type { WorkerCancelRequest, WorkerCancelRequestRisk, WorkerAnnouncementConfig, + WorkerAcceptanceConfig, + WorkerAcceptanceVideo, WorkerOrderFeedback, WorkerAfterSalesCase, WorkerHallCategoriesResponse, @@ -143,6 +145,11 @@ export function fetchWorkerAnnouncement() { return apiGet('/api/v1/worker/announcement') } +/** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */ +export function fetchWorkerAcceptanceConfig() { + return apiGet('/api/v1/worker/acceptance/config') +} + export function fetchWorkerWalletLedgers(params?: Record) { return apiGet>( '/api/v1/worker/profile/wallet-ledgers', @@ -301,10 +308,16 @@ export function saveWorkerOrderNote(workOrderId: number, note: string) { return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/note`, { note }) } -export function submitWorkerAcceptance( - workOrderId: number, - payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number }, -) { +export type WorkerAcceptancePayload = { + note?: string + imageUrls?: string[] + files?: UploadedFile[] + /** 验收视频(非必填);null 在草稿阶段表示删除,已提交阶段服务端只接受替换 */ + video?: WorkerAcceptanceVideo | null + shareId?: number +} + +export function submitWorkerAcceptance(workOrderId: number, payload: WorkerAcceptancePayload) { return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/submit-acceptance`, payload, @@ -328,10 +341,7 @@ export function remindWorkerAcceptance(workOrderId: number) { ) } -export function saveWorkerAcceptanceDraft( - workOrderId: number, - payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number }, -) { +export function saveWorkerAcceptanceDraft(workOrderId: number, payload: WorkerAcceptancePayload) { return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/acceptance-draft`, payload, @@ -340,7 +350,7 @@ export function saveWorkerAcceptanceDraft( export function supplementWorkerAcceptedOrderEvidence( workOrderId: number, - payload: { files: UploadedFile[]; shareId?: number }, + payload: { files: UploadedFile[]; video?: WorkerAcceptanceVideo | null; shareId?: number }, ) { return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/acceptance-evidence`, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index ce2309d8..b2cefb52 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -492,6 +492,31 @@ export type UploadedFile = { filename: string contentType: string size: number + /** 仅验收视频:服务端解析(或客户端上报)的时长,单位秒 */ + durationSeconds?: number +} + +/** 验收视频(单条,非必填)。 */ +export type WorkerAcceptanceVideo = { + objectKey: string + url: string + filename?: string + contentType?: string + size?: number + durationSeconds: number + width?: number + height?: number +} + +/** 验收视频配置(来自 .env 的 WORKER_ACCEPTANCE_VIDEO_*,后台不提供配置界面)。 */ +export type WorkerAcceptanceConfig = { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number } export type WorkOrderStatus = @@ -516,6 +541,8 @@ export type WorkerAcceptance = { note?: string files?: UploadedFile[] imageUrls?: string[] + /** 验收视频(非必填):打手压缩后上传的单个视频 */ + video?: WorkerAcceptanceVideo | null submittedAt?: string updatedAt?: string reviewMode?: 'vip_auto' | string diff --git a/apps/frontend/src/utils/video-compress.ts b/apps/frontend/src/utils/video-compress.ts new file mode 100644 index 00000000..8f287785 --- /dev/null +++ b/apps/frontend/src/utils/video-compress.ts @@ -0,0 +1,306 @@ +/** + * 验收视频:浏览器内探测 + 重编码(Canvas + MediaRecorder)。 + * + * 为什么不用 ffmpeg.wasm:核心包 31MB、需要 COOP/COEP,iOS 内存易崩; + * 这里只是把「游戏中录屏的 30 秒片段」压到 720p/1.2Mbps,WebM(VP8) 足够, + * 且零依赖、不上传原片(打手多为手机流量)。 + * + * 时长策略(产品已确认):**超过上限直接拒绝,不自动裁剪**。 + */ +export type AcceptanceVideoConfig = { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number +} + +export type VideoProbeResult = { + durationSeconds: number + width: number + height: number +} + +export type CompressedVideo = { + blob: Blob + durationSeconds: number + width: number + height: number + contentType: string + extension: 'webm' | 'mp4' +} + +/** 按兼容性从高到低尝试;实际可用性由 MediaRecorder.isTypeSupported 决定。 */ +const MIME_CANDIDATES = [ + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp9,opus', + 'video/webm', + 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', + 'video/mp4', +] + +export function pickSupportedVideoMimeType( + isSupported: (mime: string) => boolean = defaultMimeSupport, +): string { + for (const candidate of MIME_CANDIDATES) { + if (isSupported(candidate)) return candidate + } + return '' +} + +/** 时长是否超出配置上限;返回空串表示通过。 */ +export function resolveVideoDurationLimitError( + durationSeconds: number, + maxDurationSeconds: number, +): string { + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) + return '无法读取视频时长,请更换视频' + if (durationSeconds > maxDurationSeconds + 0.5) { + return `视频时长不能超过 ${maxDurationSeconds} 秒,请先裁剪后再上传` + } + return '' +} + +/** 按目标高度等比缩放(取偶数值,编码器要求宽高为偶数)。 */ +export function resolveVideoTargetSize( + width: number, + height: number, + targetHeight: number, +): { width: number; height: number } { + const safeWidth = Math.max(2, Math.round(width || 0)) + const safeHeight = Math.max(2, Math.round(height || 0)) + const limit = Math.max(2, Math.round(targetHeight || 720)) + const scale = Math.min(1, limit / safeHeight) + return { + width: ensureEven(safeWidth * scale), + height: ensureEven(safeHeight * scale), + } +} + +export function isVideoCompressionSupported(): boolean { + if (typeof window === 'undefined' || typeof document === 'undefined') return false + if (typeof MediaRecorder === 'undefined') return false + if (typeof HTMLCanvasElement === 'undefined') return false + if (typeof HTMLCanvasElement.prototype.captureStream !== 'function') return false + return Boolean(pickSupportedVideoMimeType()) +} + +/** 读取视频时长/分辨率;部分 WebM 的 duration 为 Infinity,这里做一次定位探测。 */ +export function probeVideoFile(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file) + const video = document.createElement('video') + let settled = false + const cleanup = () => { + URL.revokeObjectURL(url) + video.removeAttribute('src') + video.load() + } + const finish = () => { + if (settled) return + settled = true + const durationSeconds = Number.isFinite(video.duration) ? video.duration : 0 + const width = video.videoWidth + const height = video.videoHeight + cleanup() + if (!durationSeconds || durationSeconds <= 0 || !width || !height) { + reject(new Error('无法读取视频信息,请更换视频或先裁剪')) + return + } + resolve({ durationSeconds, width, height }) + } + + video.preload = 'metadata' + video.muted = true + video.src = url + video.onloadedmetadata = () => { + if (Number.isFinite(video.duration) && video.duration > 0) { + finish() + return + } + // WebM 常见:duration = Infinity。跳到极远处逼浏览器计算真实时长。 + video.currentTime = 1e101 + const timer = setTimeout(finish, 1500) + video.ondurationchange = () => { + clearTimeout(timer) + finish() + } + } + video.onerror = () => { + if (settled) return + settled = true + cleanup() + reject(new Error('无法读取视频信息,请更换视频或先裁剪')) + } + }) +} + +/** + * 压缩到配置参数并返回新的 Blob;超过时长上限直接抛错(不裁剪)。 + * onProgress 回调 0~1,用于展示"压缩中"进度。 + */ +export async function compressVideoForUpload( + file: File, + config: AcceptanceVideoConfig, + onProgress?: (ratio: number) => void, +): Promise { + if (!isVideoCompressionSupported()) { + throw new Error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传') + } + const mimeType = pickSupportedVideoMimeType() + const probe = await probeVideoFile(file) + const limitError = resolveVideoDurationLimitError( + probe.durationSeconds, + config.maxDurationSeconds, + ) + if (limitError) throw new Error(limitError) + + const target = resolveVideoTargetSize(probe.width, probe.height, config.targetHeight) + const url = URL.createObjectURL(file) + const video = document.createElement('video') + const canvas = document.createElement('canvas') + let audioContext: AudioContext | null = null + let stopped = false + let frameHandle = 0 + + const stopRecording = () => { + if (stopped) return + stopped = true + try { + video.pause() + } catch { + // ignore + } + } + + try { + video.src = url + video.muted = true + video.playsInline = true + video.preload = 'auto' + await waitForEvent(video, 'loadeddata') + + canvas.width = target.width + canvas.height = target.height + const context = canvas.getContext('2d') + if (!context) throw new Error('当前浏览器不支持视频压缩,请更换浏览器') + + const stream = canvas.captureStream(Math.max(15, Math.min(30, config.fps || 30))) + try { + const AudioContextCtor = + window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (AudioContextCtor) { + audioContext = new AudioContextCtor() + const source = audioContext.createMediaElementSource(video) + const destination = audioContext.createMediaStreamDestination() + source.connect(destination) + for (const track of destination.stream.getAudioTracks()) stream.addTrack(track) + } + } catch { + // 视频无音轨或浏览器限制:退化为无声视频,不阻断压缩。 + } + + const recorder = new MediaRecorder(stream, { + mimeType, + videoBitsPerSecond: Math.max(300, Number(config.bitrateKbps) || 1200) * 1000, + audioBitsPerSecond: Math.max(32, Number(config.audioBitrateKbps) || 64) * 1000, + }) + const chunks: Blob[] = [] + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) chunks.push(event.data) + } + const recorderStopped = new Promise((resolve) => { + recorder.onstop = () => resolve() + }) + + const drawFrame = () => { + if (stopped) return + if (video.readyState >= 2) { + context.drawImage(video, 0, 0, target.width, target.height) + } + onProgress?.(Math.max(0, Math.min(1, video.currentTime / probe.durationSeconds))) + if (video.ended || video.currentTime >= config.maxDurationSeconds) { + stopRecording() + return + } + frameHandle = requestAnimationFrame(drawFrame) + } + + recorder.start(1000) + await video.play() + frameHandle = requestAnimationFrame(drawFrame) + video.addEventListener('ended', stopRecording, { once: true }) + + // 防呆:视频卡住时最多等「时长 + 10 秒」,避免压缩界面永久转圈。 + const timeoutHandle = setTimeout(stopRecording, (probe.durationSeconds + 10) * 1000) + await new Promise((resolve) => { + const interval = setInterval(() => { + if (stopped) { + clearInterval(interval) + resolve() + } + }, 200) + }) + clearTimeout(timeoutHandle) + if (recorder.state !== 'inactive') recorder.stop() + await recorderStopped + if (frameHandle) cancelAnimationFrame(frameHandle) + + const blob = new Blob(chunks, { type: mimeType.split(';')[0] }) + if (!blob.size) throw new Error('视频压缩失败,请重试或更换视频') + const contentType = blob.type || 'video/webm' + onProgress?.(1) + return { + blob, + durationSeconds: Math.min(probe.durationSeconds, config.maxDurationSeconds), + width: target.width, + height: target.height, + contentType, + extension: contentType.includes('mp4') ? 'mp4' : 'webm', + } + } finally { + if (frameHandle) cancelAnimationFrame(frameHandle) + try { + audioContext?.close() + } catch { + // ignore + } + URL.revokeObjectURL(url) + video.removeAttribute('src') + video.load() + } +} + +function defaultMimeSupport(mime: string): boolean { + if (typeof MediaRecorder === 'undefined') return false + return typeof MediaRecorder.isTypeSupported === 'function' + ? MediaRecorder.isTypeSupported(mime) + : false +} + +function ensureEven(value: number): number { + const rounded = Math.max(2, Math.round(value)) + return rounded % 2 === 0 ? rounded : rounded - 1 +} + +function waitForEvent(target: HTMLMediaElement, eventName: string): Promise { + return new Promise((resolve, reject) => { + const onEvent = () => { + cleanup() + resolve() + } + const onError = () => { + cleanup() + reject(new Error('视频读取失败,请更换文件')) + } + const cleanup = () => { + target.removeEventListener(eventName, onEvent) + target.removeEventListener('error', onError) + } + target.addEventListener(eventName, onEvent, { once: true }) + target.addEventListener('error', onError, { once: true }) + }) +} diff --git a/apps/frontend/tests/video-compress.test.ts b/apps/frontend/tests/video-compress.test.ts new file mode 100644 index 00000000..2eb84d90 --- /dev/null +++ b/apps/frontend/tests/video-compress.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + pickSupportedVideoMimeType, + resolveVideoDurationLimitError, + resolveVideoTargetSize, +} from '../src/utils/video-compress.ts' + +test('时长策略:超过上限直接拒绝(不自动裁剪)', () => { + assert.equal(resolveVideoDurationLimitError(30, 30), '') + assert.equal(resolveVideoDurationLimitError(29.4, 30), '') + assert.match(resolveVideoDurationLimitError(31, 30), /不能超过 30 秒/) + assert.match(resolveVideoDurationLimitError(30.6, 30), /不能超过 30 秒/) +}) + +test('时长策略:读不到时长也要拒绝,避免上传未知长度视频', () => { + assert.match(resolveVideoDurationLimitError(0, 30), /无法读取视频时长/) + assert.match(resolveVideoDurationLimitError(Number.NaN, 30), /无法读取视频时长/) +}) + +test('压缩尺寸:按目标高度等比缩放且宽高为偶数', () => { + assert.deepEqual(resolveVideoTargetSize(1920, 1080, 720), { width: 1280, height: 720 }) + assert.deepEqual(resolveVideoTargetSize(1080, 1920, 720), { width: 404, height: 720 }) + // 原视频比目标更小:不放大 + assert.deepEqual(resolveVideoTargetSize(640, 360, 720), { width: 640, height: 360 }) + const odd = resolveVideoTargetSize(1081, 721, 720) + assert.equal(odd.width % 2, 0) + assert.equal(odd.height % 2, 0) +}) + +test('容器选择:优先 WebM(VP8),其次 VP9/WebM,最后 MP4', () => { + assert.equal( + pickSupportedVideoMimeType((mime) => true), + 'video/webm;codecs=vp8,opus', + ) + assert.equal( + pickSupportedVideoMimeType((mime) => mime === 'video/webm' || mime.startsWith('video/mp4')), + 'video/webm', + ) + assert.equal( + pickSupportedVideoMimeType((mime) => mime.startsWith('video/mp4')), + 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', + ) + assert.equal( + pickSupportedVideoMimeType(() => false), + '', + ) +}) From 0d3dd3eacd49a14939ffd04e94c7323ad1166406 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sat, 24 Oct 2026 17:03:57 +0800 Subject: [PATCH 08/17] =?UTF-8?q?add:=E5=A6=82=E6=9E=9C=E6=89=93=E6=89=8B?= =?UTF-8?q?=E7=AB=AF=E4=BD=BF=E7=94=A8=E7=9A=84=E8=AE=BE=E5=A4=87=E6=98=AF?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=EF=BC=8C=E9=AA=8C=E8=AF=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=92=8C=E9=AA=8C=E8=AF=81=E8=A7=86=E9=A2=91=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E7=9A=84=E6=97=B6=E5=80=99=E5=8A=A0=E4=B8=AA=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=9B=B8=E6=9C=BA=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/files/ImageUpload.tsx | 48 +++++++++- .../src/components/files/VideoUpload.tsx | 92 ++++++++++++++++--- apps/frontend/src/styles/base.css | 57 ++++++++++++ apps/frontend/src/utils/camera-capture.ts | 44 +++++++++ apps/frontend/src/utils/video-compress.ts | 2 +- apps/frontend/tests/camera-capture.test.ts | 34 +++++++ apps/frontend/vite.config.ts | 9 ++ 7 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 apps/frontend/src/utils/camera-capture.ts create mode 100644 apps/frontend/tests/camera-capture.test.ts diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx index 6ecaf21e..9a117f83 100644 --- a/apps/frontend/src/components/files/ImageUpload.tsx +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -1,11 +1,13 @@ -import { CloudUploadOutlined, PlusOutlined } from '@ant-design/icons' -import { App, Typography, Upload } from 'antd' +import { CameraOutlined, CloudUploadOutlined, PlusOutlined } from '@ant-design/icons' +import { App, Button, Typography, Upload } from 'antd' import type { UploadFile, UploadProps } from 'antd' import { useEffect, useRef, useState } from 'react' -import type { ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react' +import type { ChangeEvent, ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react' import { uploadFile } from '@/services/files' import type { UploadedFile } from '@/types/worker-platform' +import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture' +import { useIsMobile } from '@/utils/use-is-mobile' type ImageUploadProps = { value?: UploadedFile[] @@ -16,6 +18,8 @@ type ImageUploadProps = { showPasteHint?: boolean /** 开启后,页面任意位置粘贴剪贴板截图都会上传到本组件(多实例时以最近点击/挂载的为准) */ captureGlobalPaste?: boolean + /** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */ + allowCamera?: boolean } type GlobalPasteTarget = { @@ -62,14 +66,20 @@ export default function ImageUpload({ maxCount = 6, showPasteHint = false, captureGlobalPaste = false, + allowCamera, }: ImageUploadProps) { const { message } = App.useApp() + const isMobile = useIsMobile() const [isDragging, setIsDragging] = useState(false) + const cameraInputRef = useRef(null) const dragCounterRef = useRef(0) const targetIdRef = useRef(0) const valueRef = useRef(value) valueRef.current = value + const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope, allowCamera }) + const cameraInputProps = cameraCaptureInputProps('image') + const fileList: UploadFile[] = value.map((file) => ({ uid: file.objectKey || file.url, name: file.filename || '图片', @@ -105,6 +115,15 @@ export default function ImageUpload({ await uploadImages(images, source) } + /** 系统相机拍照:一次一张,省去 multiple(部分浏览器在 capture 模式下会忽略多选)。 */ + async function handleCameraSelect(event: ChangeEvent) { + const file = event.target.files?.[0] + // 让「再拍同一张」也能再次触发 change。 + event.target.value = '' + if (!file) return + await uploadImages([file]) + } + function handlePaste(event: ClipboardEvent) { const files = Array.from(event.clipboardData?.files || []) if (files.length === 0) return @@ -237,9 +256,30 @@ export default function ImageUpload({ )} + {cameraEnabled && value.length < maxCount ? ( +
+ void handleCameraSelect(event)} + /> + +
+ ) : null} {showPasteHint ? ( - 点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处 + {cameraEnabled + ? '点击「上传」从相册选择,或点「拍照」直接调用相机' + : '点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处'} ) : null}
diff --git a/apps/frontend/src/components/files/VideoUpload.tsx b/apps/frontend/src/components/files/VideoUpload.tsx index 658b7c31..6a6408e1 100644 --- a/apps/frontend/src/components/files/VideoUpload.tsx +++ b/apps/frontend/src/components/files/VideoUpload.tsx @@ -1,4 +1,9 @@ -import { DeleteOutlined, LoadingOutlined, VideoCameraOutlined } from '@ant-design/icons' +import { + CameraOutlined, + DeleteOutlined, + LoadingOutlined, + VideoCameraOutlined, +} from '@ant-design/icons' import { App, Button, Progress, Space, Typography } from 'antd' import { useQuery } from '@tanstack/react-query' import { useRef, useState, type ChangeEvent } from 'react' @@ -6,6 +11,8 @@ import { useRef, useState, type ChangeEvent } from 'react' import { uploadVideoFile } from '@/services/files' import { fetchWorkerAcceptanceConfig } from '@/services/worker' import type { WorkerAcceptanceVideo } from '@/types/worker-platform' +import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture' +import { useIsMobile } from '@/utils/use-is-mobile' import { compressVideoForUpload, isVideoCompressionSupported, @@ -18,6 +25,8 @@ type VideoUploadProps = { value?: WorkerAcceptanceVideo | null onChange?: (video: WorkerAcceptanceVideo | null) => void disabled?: boolean + /** 是否显示移动端「拍摄」入口;不传时按移动端自动判断(本组件仅用于打手端) */ + allowCamera?: boolean } const VIDEO_ACCEPT = 'video/mp4,video/webm,video/quicktime,video/*' @@ -28,17 +37,26 @@ const VIDEO_ACCEPT = 'video/mp4,video/webm,video/quicktime,video/*' * 流程:选片 → 读时长/分辨率 → 超过配置上限直接拒绝(不裁剪)→ Canvas 重编码为 * 720p/1.2Mbps(见 utils/video-compress)→ 上传 → 回填视频信息。 * 浏览器不支持 MediaRecorder / captureStream 时直接提示更换浏览器,不上传原片。 + * + * 移动端额外提供「拍摄 / 重拍」入口(capture=environment 调起系统相机); + * 录制时长由系统相机控制,HTML 无法强制限制,超长由时长校验拦下。 */ export default function VideoUpload({ value = null, onChange, disabled = false, + allowCamera, }: VideoUploadProps) { const { message } = App.useApp() + const isMobile = useIsMobile() const inputRef = useRef(null) + const cameraInputRef = useRef(null) const [phase, setPhase] = useState<'idle' | 'compressing' | 'uploading'>('idle') const [progress, setProgress] = useState(0) + const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope: 'worker', allowCamera }) + const cameraInputProps = cameraCaptureInputProps('video') + const configQuery = useQuery({ queryKey: ['worker-acceptance-config'], queryFn: fetchWorkerAcceptanceConfig, @@ -51,10 +69,7 @@ export default function VideoUpload({ const busy = phase !== 'idle' const canPick = Boolean(config?.enabled) && supported && !disabled && !busy - async function handleSelect(event: ChangeEvent) { - const file = event.target.files?.[0] - // 让"选择同一个文件"也能再次触发 change。 - event.target.value = '' + async function processFile(file: File | undefined) { if (!file || !config) return if (!config.enabled) { message.error('验收视频功能暂未开启') @@ -107,8 +122,17 @@ export default function VideoUpload({ } } + function handleSelect(event: ChangeEvent) { + const file = event.target.files?.[0] + // 让「选择/拍摄同一个文件」也能再次触发 change。 + event.target.value = '' + void processFile(file) + } + const hint = config - ? `选填,最长 ${config.maxDurationSeconds} 秒;上传前会自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内` + ? cameraEnabled + ? `选填,拍摄请控制在 ${config.maxDurationSeconds} 秒内(超时会被拒绝);上传前自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内` + : `选填,最长 ${config.maxDurationSeconds} 秒;上传前会自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内` : '选填;正在读取验收视频配置…' return ( @@ -118,8 +142,18 @@ export default function VideoUpload({ type="file" accept={VIDEO_ACCEPT} style={{ display: 'none' }} - onChange={(event) => void handleSelect(event)} + onChange={handleSelect} /> + {cameraEnabled ? ( + + ) : null} {value ? (
@@ -134,7 +168,17 @@ export default function VideoUpload({ {formatDuration(value.durationSeconds)} · {formatSize(value.size)} - + + {cameraEnabled ? ( + + ) : null} @@ -152,13 +196,31 @@ export default function VideoUpload({
) : ( - + {busy ? ( + + ) : ( + + {cameraEnabled ? ( + + ) : null} + + + )} {hint} diff --git a/apps/frontend/src/styles/base.css b/apps/frontend/src/styles/base.css index 4a725157..6aec5f35 100644 --- a/apps/frontend/src/styles/base.css +++ b/apps/frontend/src/styles/base.css @@ -305,6 +305,63 @@ select { .image-upload-hint { font-size: 12px; line-height: 1.5; + clear: both; +} + +/* antd 的 picture-card 列表可能是浮动/行内块,让外层独占整行,避免相机按钮被挤到同一行 */ +.image-upload-droppable > .ant-upload-wrapper { + display: block; + width: 100%; +} + +/* 移动端系统相机入口:隐藏原生 file input,仅保留按钮 */ +.image-upload-camera { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + /* 强制独占一行:缩略图列表可能是行内块/浮动,clear + flex-basis 保证按钮落到下一行 */ + clear: both; + flex: 1 0 100%; + justify-self: stretch; + margin-top: 8px; +} + +/* + * 相机 file input 用「移出视口」隐藏,而不是 display:none: + * 部分 iOS Safari 版本不允许对 display:none 的 file input 触发 .click(),会表现为点了没反应。 + */ +.image-upload-camera-input, +.worker-acceptance-video-camera-input { + position: fixed; + top: 0; + left: -9999px; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.worker-acceptance-video-preview { + display: flex; + flex-direction: column; + gap: 8px; +} + +.worker-acceptance-video-player { + width: 100%; + max-width: 360px; + max-height: 240px; + border-radius: 6px; + background: #000; +} + +.worker-acceptance-video-meta { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; } .image-preview-list { diff --git a/apps/frontend/src/utils/camera-capture.ts b/apps/frontend/src/utils/camera-capture.ts new file mode 100644 index 00000000..7d046737 --- /dev/null +++ b/apps/frontend/src/utils/camera-capture.ts @@ -0,0 +1,44 @@ +/** 上传场景:只有打手端(worker)在移动设备上需要直接调起系统相机。 */ +export type CameraEntryScope = 'admin' | 'worker' | 'collect' + +/** 相机捕获类型:图片走拍照,视频走拍摄。 */ +export type CameraCaptureKind = 'image' | 'video' + +type ResolveCameraEntryOptions = { + /** 当前是否为移动端布局(useIsMobile,宽度 <= 768) */ + isMobile: boolean + /** 上传作用域 */ + scope?: CameraEntryScope | (string & {}) + /** 显式开关:传了就以此为准(true 强制显示,false 强制隐藏) */ + allowCamera?: boolean +} + +/** + * 是否显示「拍照 / 拍摄」入口。 + * + * 规则:默认仅「打手端 + 移动端」显示。管理端/采集端仍只保留相册选择; + * 桌面端(宽度 > 768)隐藏,避免出现点了只会打开文件选择的无效入口。 + * + * 说明:这里用 `` 调起系统相机, + * 不依赖 getUserMedia,因此在局域网 HTTP(如 http://192.168.10.8:8080)下同样可用。 + */ +export function resolveCameraEntryEnabled(options: ResolveCameraEntryOptions): boolean { + if (typeof options.allowCamera === 'boolean') return options.allowCamera + return options.isMobile && options.scope === 'worker' +} + +/** + * 系统相机 file input 的属性。 + * + * `capture="environment"` 让移动浏览器优先打开后置摄像头;iOS Safari 的部分版本会 + * 忽略该属性并弹出「拍照 / 从相册选择」菜单,属于预期内的降级行为。 + */ +export function cameraCaptureInputProps(kind: CameraCaptureKind): { + accept: string + capture: 'environment' +} { + return { + accept: kind === 'image' ? 'image/*' : 'video/*', + capture: 'environment', + } +} diff --git a/apps/frontend/src/utils/video-compress.ts b/apps/frontend/src/utils/video-compress.ts index 8f287785..70db2c77 100644 --- a/apps/frontend/src/utils/video-compress.ts +++ b/apps/frontend/src/utils/video-compress.ts @@ -264,7 +264,7 @@ export async function compressVideoForUpload( } finally { if (frameHandle) cancelAnimationFrame(frameHandle) try { - audioContext?.close() + void audioContext?.close() } catch { // ignore } diff --git a/apps/frontend/tests/camera-capture.test.ts b/apps/frontend/tests/camera-capture.test.ts new file mode 100644 index 00000000..21c15e46 --- /dev/null +++ b/apps/frontend/tests/camera-capture.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '../src/utils/camera-capture.ts' + +test('相机入口:仅打手端移动端显示', () => { + assert.equal(resolveCameraEntryEnabled({ isMobile: true, scope: 'worker' }), true) + assert.equal(resolveCameraEntryEnabled({ isMobile: false, scope: 'worker' }), false) + assert.equal(resolveCameraEntryEnabled({ isMobile: true, scope: 'admin' }), false) + assert.equal(resolveCameraEntryEnabled({ isMobile: true, scope: 'collect' }), false) + assert.equal(resolveCameraEntryEnabled({ isMobile: true }), false) +}) + +test('相机入口:allowCamera 显式开关优先', () => { + assert.equal( + resolveCameraEntryEnabled({ isMobile: true, scope: 'worker', allowCamera: false }), + false, + ) + assert.equal( + resolveCameraEntryEnabled({ isMobile: false, scope: 'admin', allowCamera: true }), + true, + ) +}) + +test('相机构造:图片/视频分别使用对应 accept 与后置摄像头', () => { + assert.deepEqual(cameraCaptureInputProps('image'), { + accept: 'image/*', + capture: 'environment', + }) + assert.deepEqual(cameraCaptureInputProps('video'), { + accept: 'video/*', + capture: 'environment', + }) +}) diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 48dbd160..780847ec 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -55,6 +55,15 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5173, + /** + * Windows 下 Docker 绑定挂载(D:\... -> /app)收不到 inotify 事件,Vite 的文件监听 + * 会失效:代码改了页面却一直不更新(和后端 tsx watch 同类问题)。 + * 改用轮询,改文件后 HMR 才能正常触发。 + */ + watch: { + usePolling: true, + interval: 300, + }, // 隧道/自定义域名访问 dev server 时需加入白名单,否则 Vite 会拦截 Host allowedHosts, proxy: { From a7a8634a96d85f415e578e0bd66f94adae64539a Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 25 Oct 2026 12:27:32 +0800 Subject: [PATCH 09/17] =?UTF-8?q?feat:=E5=8A=A0=E4=B8=80=E4=B8=AA=E7=BD=91?= =?UTF-8?q?=E7=AB=99=E5=8F=91=E5=B8=83=E5=85=AC=E5=91=8A=E4=BC=9A=E5=BC=B9?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=E7=82=B9=E7=A1=AE=E5=AE=9A=E6=88=91=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E6=99=93=E7=9A=84=E5=BC=B9=E7=AA=97;mock=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=E6=8E=A5=E5=8D=95?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF;=E6=9C=AC=E5=9C=B0=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin-mock-work-order-service.ts | 14 +++ .../mock-work-order-rule-match.test.ts | 99 +++++++++++++++++++ .../mock-work-order-rule-match.ts | 76 ++++++++++++++ .../worker/WorkerAnnouncementPopup.tsx | 76 ++++++++++++++ apps/frontend/src/layouts/WorkerLayout.tsx | 3 + .../pages/admin/panels/AnnouncementPanel.tsx | 2 +- apps/frontend/src/styles/worker.css | 18 ++++ apps/frontend/src/utils/announcement-ack.ts | 60 +++++++++++ apps/frontend/src/utils/camera-capture.ts | 2 +- apps/frontend/tests/announcement-ack.test.ts | 54 ++++++++++ deploy/caddy/Caddyfile.dev | 3 +- docker-compose.dev.yml | 2 - 12 files changed, 403 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts create mode 100644 apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts create mode 100644 apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx create mode 100644 apps/frontend/src/utils/announcement-ack.ts create mode 100644 apps/frontend/tests/announcement-ack.test.ts diff --git a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts index 1a48e5ec..25fc980d 100644 --- a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts +++ b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts @@ -9,6 +9,7 @@ import { createWorkOrderEvent, getWorkCategoryById, listWorkOrdersByPlatformOrderId, + listWorkProductRules, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -27,6 +28,7 @@ import { normalizeRequirementFields, } from './mappers.js' import { rememberAdminMockCustomShop } from './admin-mock-custom-shop-service.js' +import { resolveMockWorkOrderProductRule } from './mock-work-order-rule-match.js' import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js' import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' @@ -64,6 +66,14 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true' const fields = normalizeRequirementFields(payload.fields) const { platformOrderId, productName, shop } = input + // Mock 工单同样要挂到接单模板上:后台「同步模板价格」按 product_rule_id 精确匹配, + // 不绑定的话改模板价永远命中不到 Mock 单(真实单在 sync-worker-orders-service 已绑定)。 + // 这里只做商品名文本匹配,命中多条同分视为歧义、宁可不绑定。 + const productRuleMatch = resolveMockWorkOrderProductRule({ + rules: await listWorkProductRules({ enabled: true }), + productName, + categoryId, + }) // 订单号全局唯一:与自动同步的真实工单也不能撞号,否则打手按单号回填资料会命中多条。 const existingOrders = await listWorkOrdersByPlatformOrderId(platformOrderId) if (existingOrders.length > 0) { @@ -104,6 +114,7 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { } const workOrder = await createWorkOrder({ workOrderNo: generateWorkOrderNo(), + productRuleId: productRuleMatch.rule ? Number(productRuleMatch.rule.id) : null, platformOrderId, productName, categoryId, @@ -143,6 +154,9 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { rewardAmount, sellerId: String(shop.sellerId || '').trim(), shopName: String(shop.shopName || '').trim(), + productRuleId: productRuleMatch.rule ? Number(productRuleMatch.rule.id) : 0, + productRuleKey: productRuleMatch.rule ? String(productRuleMatch.rule.rule_key || '') : '', + productRuleMatch: productRuleMatch.reason, }), now, }) diff --git a/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts new file mode 100644 index 00000000..5a44b115 --- /dev/null +++ b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js' +import { resolveMockWorkOrderProductRule } from './mock-work-order-rule-match.js' + +function rule( + input: Partial & { id: number; product_name: string }, +): WorkProductRuleRow { + return { + id: input.id, + rule_key: input.rule_key ?? `rule-${input.id}`, + product_name: input.product_name, + category_id: input.category_id ?? null, + enabled: input.enabled ?? true, + sort_order: input.sort_order ?? 100, + unit_price_fen: input.unit_price_fen ?? 0, + reward_amount: input.reward_amount ?? 0, + sharing_enabled: input.sharing_enabled ?? false, + sharing_total_quantity: input.sharing_total_quantity ?? 1, + sharing_unit_reward: input.sharing_unit_reward ?? 0, + sharing_auto_from_order: input.sharing_auto_from_order ?? false, + timeout_minutes: input.timeout_minutes ?? 0, + timeout_policy: input.timeout_policy ?? 'reopen', + } as WorkProductRuleRow +} + +test('Mock 模板匹配:商品名互相包含即命中(忽略空白)', () => { + const rules = [rule({ id: 9, product_name: '指挥官密钥', category_id: 1, unit_price_fen: 1100 })] + const result = resolveMockWorkOrderProductRule({ + rules, + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(result.reason, 'matched') + assert.equal(result.rule?.id, 9) + + const withSpaces = resolveMockWorkOrderProductRule({ + rules, + productName: ' 指挥官 密钥 3个 ', + categoryId: 1, + }) + assert.equal(withSpaces.rule?.id, 9) +}) + +test('Mock 模板匹配:同分类优先,其次更具体的商品名', () => { + const rules = [ + rule({ id: 1, product_name: '指挥官密钥', category_id: 5, sort_order: 1 }), + rule({ id: 2, product_name: '指挥官密钥', category_id: 1, sort_order: 9 }), + rule({ id: 3, product_name: '指挥官密钥-皮肤链接', category_id: 1, sort_order: 9 }), + ] + const base = resolveMockWorkOrderProductRule({ + rules, + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(base.rule?.id, 2, '同分类的模板必须优先于其他分类') + + const specific = resolveMockWorkOrderProductRule({ + rules, + productName: '指挥官密钥-皮肤链接1个', + categoryId: 1, + }) + assert.equal(specific.rule?.id, 3, '更长的商品名(更具体)优先') +}) + +test('Mock 模板匹配:歧义或未命中不绑定', () => { + const ambiguous = resolveMockWorkOrderProductRule({ + rules: [ + rule({ id: 1, product_name: '指挥官密钥', category_id: 1 }), + rule({ id: 2, product_name: '指挥官密钥', category_id: 1 }), + ], + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(ambiguous.rule, null) + assert.equal(ambiguous.reason, 'ambiguous') + + const unmatched = resolveMockWorkOrderProductRule({ + rules: [rule({ id: 9, product_name: '指挥官密钥', category_id: 1 })], + productName: '指挥官9999', + categoryId: 1, + }) + assert.equal(unmatched.rule, null) + assert.equal(unmatched.reason, 'unmatched') + + const empty = resolveMockWorkOrderProductRule({ rules: [], productName: ' ', categoryId: 1 }) + assert.equal(empty.reason, 'unmatched') +}) + +test('Mock 模板匹配:停用的模板不参与匹配', () => { + const result = resolveMockWorkOrderProductRule({ + rules: [rule({ id: 9, product_name: '指挥官密钥', category_id: 1, enabled: false })], + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(result.rule, null) + assert.equal(result.reason, 'unmatched') +}) diff --git a/apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts new file mode 100644 index 00000000..12cb44a7 --- /dev/null +++ b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts @@ -0,0 +1,76 @@ +import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js' + +/** + * Mock 工单的接单模板匹配(文本匹配)。 + * + * 快手真实单走 `resolveMatchingProductRule`(依赖 sellerId/itemId/skuId 等平台字段), + * 而本地 Mock 单只有商品名和分类,所以这里用**商品名文本**做确定性匹配, + * 让 Mock 单也能挂上 `product_rule_id`,从而被「同步模板价格」命中。 + * + * 规则: + * - 只考虑启用中的模板; + * - 模板商品名与 Mock 商品名互相包含即视为命中(忽略大小写/空白); + * - 同分类优先,其次更长的商品名(更具体)优先,最后按 sort_order; + * - 同分类且同长度的多条模板属于歧义,宁可不绑定,避免把 Mock 单挂到错误模板上。 + */ +export type MockWorkOrderRuleMatchReason = 'matched' | 'unmatched' | 'ambiguous' + +export type MockWorkOrderRuleMatch = { + rule: WorkProductRuleRow | null + reason: MockWorkOrderRuleMatchReason + candidates: string[] +} + +export function normalizeMockProductMatchText(value: unknown): string { + return String(value || '') + .replace(/[\s\u3000]+/g, '') + .trim() + .toLowerCase() +} + +export function resolveMockWorkOrderProductRule(input: { + rules: WorkProductRuleRow[] + productName: string + categoryId?: number | null +}): MockWorkOrderRuleMatch { + const productName = normalizeMockProductMatchText(input.productName) + if (!productName) return { rule: null, reason: 'unmatched', candidates: [] } + const categoryId = Number(input.categoryId || 0) + + const scored = input.rules + .filter((rule) => rule.enabled !== false) + .map((rule) => { + const ruleName = normalizeMockProductMatchText(rule.product_name) + if (!ruleName) return null + if (!productName.includes(ruleName) && !ruleName.includes(productName)) return null + return { + rule, + categoryMatch: categoryId > 0 && Number(rule.category_id || 0) === categoryId ? 1 : 0, + nameLength: ruleName.length, + sortOrder: Number(rule.sort_order || 100), + } + }) + .filter((item): item is NonNullable => item !== null) + + if (scored.length === 0) return { rule: null, reason: 'unmatched', candidates: [] } + + scored.sort( + (a, b) => + b.categoryMatch - a.categoryMatch || + b.nameLength - a.nameLength || + a.sortOrder - b.sortOrder || + Number(a.rule.id) - Number(b.rule.id), + ) + const [best] = scored + if (!best) return { rule: null, reason: 'unmatched', candidates: [] } + const candidates = scored.map((item) => String(item.rule.rule_key || item.rule.id)) + + const equallySpecific = scored.filter( + (item) => item.categoryMatch === best.categoryMatch && item.nameLength === best.nameLength, + ) + if (new Set(equallySpecific.map((item) => Number(item.rule.id))).size > 1) { + return { rule: null, reason: 'ambiguous', candidates } + } + + return { rule: best.rule, reason: 'matched', candidates } +} diff --git a/apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx b/apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx new file mode 100644 index 00000000..c64d0e2d --- /dev/null +++ b/apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx @@ -0,0 +1,76 @@ +import { SoundOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { Button, Modal, Typography } from 'antd' +import { useEffect, useState } from 'react' + +import { fetchWorkerAnnouncement } from '@/services/worker' +import { + readAnnouncementAck, + resolveAnnouncementVersion, + shouldShowAnnouncementPopup, + writeAnnouncementAck, +} from '@/utils/announcement-ack' + +/** + * 打手端公告弹窗。 + * + * 后台「打手端滚动公告」一改动正文,就会在打手端下次进入时弹出;必须点「我已知晓」 + * 才能关闭(遮罩/ESC 不生效)。确认结果按公告内容指纹记在 localStorage, + * 同一份公告只弹一次,改了正文会重新弹。 + */ +export default function WorkerAnnouncementPopup() { + const [acknowledgedVersion, setAcknowledgedVersion] = useState(() => readAnnouncementAck()) + const [open, setOpen] = useState(false) + + const announcementQuery = useQuery({ + queryKey: ['worker-announcement'], + queryFn: fetchWorkerAnnouncement, + retry: false, + // 全局默认 staleTime: Infinity,公告需要在下一次进入页面时重新拉取 + staleTime: 5 * 60_000, + }) + const announcement = announcementQuery.data?.data + const content = String(announcement?.content || '').trim() + + useEffect(() => { + const shouldShow = shouldShowAnnouncementPopup({ + enabled: announcement?.enabled, + content, + acknowledgedVersion, + }) + if (shouldShow) setOpen(true) + }, [announcement?.enabled, content, acknowledgedVersion]) + + function acknowledge() { + const version = resolveAnnouncementVersion(content) + writeAnnouncementAck(version) + setAcknowledgedVersion(version) + setOpen(false) + } + + return ( + + 公告 + + } + closable={false} + maskClosable={false} + keyboard={false} + centered + width={460} + className="worker-announcement-popup" + footer={ + + } + > + + {content} + + + ) +} diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 9afdf8cc..26c34b48 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -34,6 +34,7 @@ import { } from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' +import WorkerAnnouncementPopup from '@/components/worker/WorkerAnnouncementPopup' import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech' const menuItems: MenuProps['items'] = [ @@ -191,6 +192,7 @@ export default function WorkerLayout() {
+
@@ -285,6 +287,7 @@ export default function WorkerLayout() { + diff --git a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx index c4258f0c..9cb5a206 100644 --- a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx @@ -226,7 +226,7 @@ function AnnouncementCard() { name="content" label="公告内容" rules={[{ required: true, whitespace: true, message: '请填写公告内容' }]} - extra="公告会显示在打手端各页面顶部并横向滚动。" + extra="公告会显示在打手端各页面顶部并横向滚动;正文一改动,打手端下次进入会弹出公告弹窗,需点「我已知晓」才关闭。" > diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 49d5ac0f..8403f5c8 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -73,6 +73,24 @@ } } +/* 公告弹窗:正文保留换行,长公告可滚动 */ +.worker-announcement-popup-title { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.worker-announcement-popup-content { + margin: 0 !important; + max-height: 50vh; + overflow-y: auto; + color: #1f2937; + font-size: 14px; + line-height: 1.75; + white-space: pre-wrap; + word-break: break-word; +} + .worker-page-head h3, .worker-page-head > .ant-typography { margin: 0 !important; diff --git a/apps/frontend/src/utils/announcement-ack.ts b/apps/frontend/src/utils/announcement-ack.ts new file mode 100644 index 00000000..d0442ec7 --- /dev/null +++ b/apps/frontend/src/utils/announcement-ack.ts @@ -0,0 +1,60 @@ +/** + * 打手端公告弹窗的「已确认」记忆。 + * + * 后台公告只有一条全局配置(`worker_announcement = { enabled, content }`),没有公告 ID, + * 因此这里用**内容指纹**充当版本号:只要运营改了公告正文,指纹就变,打手端会重新弹一次。 + * 确认记录存在 localStorage(方案 B:服务端不落库),换设备或清缓存后会再弹一次。 + */ + +const ANNOUNCEMENT_ACK_STORAGE_KEY = 'order-site-worker-announcement-ack' + +type AnnouncementPopupState = { + enabled?: boolean + content?: string + /** 本地已确认过的公告版本(来自 localStorage) */ + acknowledgedVersion?: string +} + +/** 公告正文的内容指纹(FNV-1a 32 位),空内容返回空串表示「没有可确认的公告」。 */ +export function resolveAnnouncementVersion(content: string): string { + const normalized = String(content || '').trim() + if (!normalized) return '' + let hash = 0x811c9dc5 + for (let index = 0; index < normalized.length; index += 1) { + hash ^= normalized.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return `v1-${hash.toString(16).padStart(8, '0')}` +} + +/** + * 是否需要弹窗:公告开启 + 有正文 + 当前版本还没被这台设备确认过。 + * 已确认过的版本不再弹;运营改了正文(版本变化)后会重新弹。 + */ +export function shouldShowAnnouncementPopup(state: AnnouncementPopupState): boolean { + if (state.enabled !== true) return false + const version = resolveAnnouncementVersion(state.content || '') + if (!version) return false + return state.acknowledgedVersion !== version +} + +/** 读取本机已确认的公告版本(隐私模式/禁用存储时返回空串,页面照常可用)。 */ +export function readAnnouncementAck(): string { + try { + if (typeof window === 'undefined') return '' + return window.localStorage.getItem(ANNOUNCEMENT_ACK_STORAGE_KEY) || '' + } catch { + return '' + } +} + +/** 记录本机已确认的公告版本。 */ +export function writeAnnouncementAck(version: string): void { + if (!version) return + try { + if (typeof window === 'undefined') return + window.localStorage.setItem(ANNOUNCEMENT_ACK_STORAGE_KEY, version) + } catch { + // 存储不可用(隐私模式/配额满)时忽略,下一次打开会再弹一次。 + } +} diff --git a/apps/frontend/src/utils/camera-capture.ts b/apps/frontend/src/utils/camera-capture.ts index 7d046737..e64c47c0 100644 --- a/apps/frontend/src/utils/camera-capture.ts +++ b/apps/frontend/src/utils/camera-capture.ts @@ -20,7 +20,7 @@ type ResolveCameraEntryOptions = { * 桌面端(宽度 > 768)隐藏,避免出现点了只会打开文件选择的无效入口。 * * 说明:这里用 `` 调起系统相机, - * 不依赖 getUserMedia,因此在局域网 HTTP(如 http://192.168.10.8:8080)下同样可用。 + * 不依赖 getUserMedia,因此在纯 HTTP(如 http://worker.localhost/)下同样可用。 */ export function resolveCameraEntryEnabled(options: ResolveCameraEntryOptions): boolean { if (typeof options.allowCamera === 'boolean') return options.allowCamera diff --git a/apps/frontend/tests/announcement-ack.test.ts b/apps/frontend/tests/announcement-ack.test.ts new file mode 100644 index 00000000..ec086374 --- /dev/null +++ b/apps/frontend/tests/announcement-ack.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + resolveAnnouncementVersion, + shouldShowAnnouncementPopup, +} from '../src/utils/announcement-ack.ts' + +test('公告版本:内容不变版本不变,忽略首尾空白', () => { + const version = resolveAnnouncementVersion('平台担保交易,拒绝私下转账') + assert.ok(version.startsWith('v1-')) + assert.equal(resolveAnnouncementVersion(' 平台担保交易,拒绝私下转账 '), version) +}) + +test('公告版本:内容变化即新版本,空内容没有版本', () => { + const before = resolveAnnouncementVersion('旧公告') + const after = resolveAnnouncementVersion('旧公告(已更新)') + assert.notEqual(before, after) + assert.equal(resolveAnnouncementVersion(''), '') + assert.equal(resolveAnnouncementVersion(' '), '') +}) + +test('弹窗判定:关闭/空正文不弹', () => { + assert.equal( + shouldShowAnnouncementPopup({ enabled: false, content: '公告内容', acknowledgedVersion: '' }), + false, + ) + assert.equal( + shouldShowAnnouncementPopup({ enabled: true, content: '', acknowledgedVersion: '' }), + false, + ) + assert.equal(shouldShowAnnouncementPopup({ content: '公告内容' }), false) +}) + +test('弹窗判定:未确认真弹,确认过同一版本不再弹,改内容后重新弹', () => { + const content = '严禁私下交易' + const version = resolveAnnouncementVersion(content) + assert.equal( + shouldShowAnnouncementPopup({ enabled: true, content, acknowledgedVersion: '' }), + true, + ) + assert.equal( + shouldShowAnnouncementPopup({ enabled: true, content, acknowledgedVersion: version }), + false, + ) + assert.equal( + shouldShowAnnouncementPopup({ + enabled: true, + content: '严禁私下交易(2026-10-25 更新)', + acknowledgedVersion: version, + }), + true, + ) +}) diff --git a/deploy/caddy/Caddyfile.dev b/deploy/caddy/Caddyfile.dev index b0766c0f..5bc55e7c 100644 --- a/deploy/caddy/Caddyfile.dev +++ b/deploy/caddy/Caddyfile.dev @@ -1,5 +1,4 @@ -# 局域网联调:8080 端口给打手端用(无需改客户端 hosts/DNS),80 仍给后台/管理端。 -:80, :8080 { +:80 { route { handle /api/* { reverse_proxy backend:3000 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d52ff921..382dd9e7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -164,8 +164,6 @@ services: ports: - "80:80" - "443:443" - # 局域网联调:打手端用 8080 端口区分(无需改客户端 hosts/DNS) - - "${WORKER_LAN_PORT:-8080}:8080" environment: TZ: ${TZ:-Asia/Shanghai} volumes: From 9c620d39cb7ad400d31e76e5c1a0c0b50c860676 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 25 Oct 2026 13:44:34 +0800 Subject: [PATCH 10/17] =?UTF-8?q?feat:=E6=8B=BC=E5=8D=95=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=A0=E4=B8=80=E4=B8=AA=E6=92=A4=E4=BB=BD?= =?UTF-8?q?=E9=A2=9D=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E6=95=B0=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../107_work_order_share_cancel_note.sql | 11 ++ .../108_work_order_share_cancel_quantity.sql | 12 ++ .../domain/work-order-share-quantity.test.ts | 51 +++++++ .../src/domain/work-order-share-quantity.ts | 21 +++ .../src/repositories/worker-platform/types.ts | 4 + .../work-order-settlement-repo.ts | 6 +- .../worker-platform/work-order-share-repo.ts | 119 +++++++++++++++ .../admin/worker-platform/acceptance.ts | 27 ++++ .../admin-work-order-acceptance-service.ts | 65 ++++++++ .../src/services/worker-platform/index.ts | 1 + .../work-order-display-mappers.ts | 4 + .../worker-platform-service.test.ts | 58 +++++++ .../src/components/WorkOrderEventTimeline.tsx | 94 ++++++++++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 141 +++++++++++++++++- .../src/services/admin/worker-platform.ts | 12 ++ apps/frontend/src/types/worker-platform.ts | 4 + 16 files changed, 627 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/db/migrations/107_work_order_share_cancel_note.sql create mode 100644 apps/backend/src/db/migrations/108_work_order_share_cancel_quantity.sql create mode 100644 apps/backend/src/domain/work-order-share-quantity.test.ts create mode 100644 apps/backend/src/domain/work-order-share-quantity.ts diff --git a/apps/backend/src/db/migrations/107_work_order_share_cancel_note.sql b/apps/backend/src/db/migrations/107_work_order_share_cancel_note.sql new file mode 100644 index 00000000..e215a2ad --- /dev/null +++ b/apps/backend/src/db/migrations/107_work_order_share_cancel_note.sql @@ -0,0 +1,11 @@ +-- 107_work_order_share_cancel_note.sql +-- 目标:后台「拼单明细」列表要能直接看到撤销原因。 +-- 1. cancelled_reason 仍保留枚举口径(admin/timeout/worker_request/problem/settlement), +-- 用于状态标签与既有逻辑判断,不塞自由文本; +-- 2. cancel_note 存运营在「单人撤单 / 撤份额」弹窗里填写的原因(选填),只做展示; +-- 3. 未填写时保持空串,列表显示为「-」;历史数据不做回填(原因只存在于事件流里)。 + +ALTER TABLE work_order_shares + ADD COLUMN IF NOT EXISTS cancel_note TEXT NOT NULL DEFAULT ''; + +COMMENT ON COLUMN work_order_shares.cancel_note IS '后台撤单/撤份额时填写的原因(自由文本,选填),仅用于展示'; diff --git a/apps/backend/src/db/migrations/108_work_order_share_cancel_quantity.sql b/apps/backend/src/db/migrations/108_work_order_share_cancel_quantity.sql new file mode 100644 index 00000000..3f998a9e --- /dev/null +++ b/apps/backend/src/db/migrations/108_work_order_share_cancel_quantity.sql @@ -0,0 +1,12 @@ +-- 108_work_order_share_cancel_quantity.sql +-- 目标:拼单明细的「撤销原因」要带上撤销份数(列表显示为「撤 4 份 · 原因」)。 +-- 1. cancel_quantity 记录这一份份额被撤掉的份数: +-- - 撤份额:每次累加 reduceQuantity(同一份额多次撤则累计); +-- - 单人撤单:等于撤销时该份额的全部份数; +-- 2. 0 = 从未撤过;超时 / 打手退单 / 问题单处置不写这一列,原因仍由状态标签表达; +-- 3. 不做历史回填:改动前的撤单没有份数记录,列表保持「-」。 + +ALTER TABLE work_order_shares + ADD COLUMN IF NOT EXISTS cancel_quantity INTEGER NOT NULL DEFAULT 0; + +COMMENT ON COLUMN work_order_shares.cancel_quantity IS '被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过),仅用于展示'; diff --git a/apps/backend/src/domain/work-order-share-quantity.test.ts b/apps/backend/src/domain/work-order-share-quantity.test.ts new file mode 100644 index 00000000..2514db42 --- /dev/null +++ b/apps/backend/src/domain/work-order-share-quantity.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveShareReduceTargetQuantity } from './work-order-share-quantity.js' + +test('按份数撤份额:正常扣减', () => { + assert.deepEqual(resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 4 }), { + nextQuantity: 5, + failureReason: null, + }) + assert.deepEqual(resolveShareReduceTargetQuantity({ currentQuantity: 2, reduceQuantity: 1 }), { + nextQuantity: 1, + failureReason: null, + }) +}) + +test('按份数撤份额:不能撤光、不能撤 0、不能超量', () => { + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 1, reduceQuantity: 1 }).failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 0 }).failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: -2 }).failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 9 }).failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 10 }).failureReason, + 'invalid_quantity', + ) +}) + +test('按份数撤份额:非法输入(非数字/小数/空)一律拒绝', () => { + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: Number.NaN, reduceQuantity: 4 }) + .failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 4.7 }).nextQuantity, + 5, + '小数向下取整后按 4 份处理', + ) +}) diff --git a/apps/backend/src/domain/work-order-share-quantity.ts b/apps/backend/src/domain/work-order-share-quantity.ts new file mode 100644 index 00000000..34433023 --- /dev/null +++ b/apps/backend/src/domain/work-order-share-quantity.ts @@ -0,0 +1,21 @@ +/** + * 后台按份数撤销拼单份额:校验并算出保留下来的份数。 + * + * 口径(已确认): + * - 只能部分撤销,撤到 0 份走「单人撤单」,所以 `1 ≤ 撤掉份数 ≤ 当前份数 − 1`; + * - 押金不随份数减少而退还(保持原冻结额),这里只算数量。 + */ +export function resolveShareReduceTargetQuantity(input: { + currentQuantity: number + reduceQuantity: number +}): { nextQuantity: number; failureReason: 'invalid_quantity' | null } { + const current = Math.floor(Number(input.currentQuantity) || 0) + const reduce = Math.floor(Number(input.reduceQuantity) || 0) + if (!Number.isFinite(current) || !Number.isFinite(reduce)) { + return { nextQuantity: 0, failureReason: 'invalid_quantity' } + } + if (current < 2 || reduce < 1 || reduce > current - 1) { + return { nextQuantity: 0, failureReason: 'invalid_quantity' } + } + return { nextQuantity: current - reduce, failureReason: null } +} diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 7ed39fac..0cfef91c 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -365,6 +365,10 @@ export type WorkOrderShareRow = { timeout_policy?: string /** 取消来源:timeout/worker_request/admin/problem/settlement */ cancelled_reason?: string + /** 后台撤单/撤份额时填写的原因(自由文本,选填),仅用于列表展示 */ + cancel_note?: string + /** 被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过) */ + cancel_quantity?: number /** 份额级问题单:备注 / 标记时间(NULL = 正常)/ 操作人;不影响母单状态 */ problem_note?: string problem_marked_at?: string | null 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 b39852a0..56cb8390 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 @@ -817,13 +817,15 @@ export async function cancelWorkOrderShare(input: { `UPDATE work_order_shares SET status = 'cancelled', cancelled_reason = 'admin', + cancel_note = $2, + cancel_quantity = COALESCE(quantity, 0), acceptance_json = '{}'::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = NULL, accepted_at = NULL, updated_at = $1 - WHERE id = $2`, - [input.now, input.shareId], + WHERE id = $3`, + [input.now, String(input.reason || '').trim(), input.shareId], ) // 母单状态保持不变:这里只取消这一份份额,不再按剩余份额重推母单状态。 // (旧实现会 recompute:待验收的母单撤掉一份后 allocated < total → 变回 open, 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 98b19bd9..69f84af3 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,6 +1,7 @@ import type { PoolClient } from 'pg' import { query, withTransaction } from '../../db/client.js' +import { resolveShareReduceTargetQuantity } from '../../domain/work-order-share-quantity.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, @@ -11,6 +12,7 @@ import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { countWorkerActiveOrdersWithClient, getWorkOrderById, + getWorkOrderByIdWithClient, isWorkOrderWithinHallCapacityWithClient, } from './work-order-repo.js' import type { WorkOrderRow, WorkOrderShareRow } from './types.js' @@ -414,6 +416,123 @@ export async function joinWorkOrderShare(input: { }) } +/** + * 后台按份数撤销某个打手的部分份额。 + * + * 只改这一行的数量与份额报酬: + * - 大厅余量 = 总份数 − Σ 有效份额数量,所以减少的份数会自动回到大厅; + * - 母单状态不动(与「单人撤单」同口径); + * - 押金按要求保持原冻结额,不做退还; + * - 验收资料(验收图/视频/备注)留在原份额上,不清空。 + */ +export async function reduceWorkOrderShareQuantity(input: { + workOrderId: number + shareId: number + reduceQuantity: number + note?: string + actorName?: string + now: string +}): Promise<{ + order: WorkOrderRow | null + share: WorkOrderShareRow | null + failureReason: + | 'work_order_not_reducible' + | 'share_not_found' + | 'share_status_not_reducible' + | 'share_quantity_invalid' + | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if ( + !workOrder || + !['open', 'in_progress', 'pending_acceptance'].includes(String(workOrder.status || '')) + ) { + return { order: workOrder, share: null, failureReason: 'work_order_not_reducible' as const } + } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [input.shareId, input.workOrderId], + ) + const share = shareResult.rows[0] || null + if (!share) { + return { order: workOrder, share: null, failureReason: 'share_not_found' as const } + } + if (!['joined', 'submitted'].includes(String(share.status || ''))) { + return { order: workOrder, share, failureReason: 'share_status_not_reducible' as const } + } + + const target = resolveShareReduceTargetQuantity({ + currentQuantity: Number(share.quantity || 0), + reduceQuantity: input.reduceQuantity, + }) + if (target.failureReason || target.nextQuantity <= 0) { + return { order: workOrder, share, failureReason: 'share_quantity_invalid' as const } + } + + const unitReward = toPositiveInteger(share.unit_reward, 0) + const nextRewardAmount = target.nextQuantity * unitReward + // 原因只做展示:本次没填就保留上一次填的,避免后续撤份额把已有说明覆盖成空 + const nextCancelNote = String(input.note || '').trim() || String(share.cancel_note || '') + // 撤销份数累计:同一份额多次撤份额时,列表显示总共撤了多少份 + const nextCancelQuantity = + Math.max(0, Number(share.cancel_quantity || 0)) + input.reduceQuantity + await client.query( + `UPDATE work_order_shares + SET quantity = $1, share_reward = $2, cancel_note = $3, + cancel_quantity = $4, updated_at = $5 + WHERE id = $6`, + [ + target.nextQuantity, + nextRewardAmount, + nextCancelNote, + nextCancelQuantity, + input.now, + input.shareId, + ], + ) + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'sharing_share_quantity_reduced', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + shareId: input.shareId, + workerId: Number(share.worker_id || 0), + fromQuantity: Number(share.quantity || 0), + toQuantity: target.nextQuantity, + reduceQuantity: input.reduceQuantity, + rewardBefore: Number(share.share_reward || 0), + rewardAfter: nextRewardAmount, + // 押金口径:本次保持原冻结额,不退不补 + shareDeposit: Number(share.share_deposit || 0), + note: String(input.note || '').trim(), + }), + now: input.now, + }) + + const updatedShare = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [input.shareId], + ) + // 必须回读聚合口径的母单(active_share_quantity 等),否则前端拿到的拼单进度会归零 + const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId) + return { + order: updatedOrder || workOrder, + share: updatedShare.rows[0] || null, + failureReason: null, + } + }) +} + export async function submitWorkOrderShareAcceptance(input: { workOrderId: number workerId: number diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index 3dfb0bea..7f6c8cc7 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -8,6 +8,7 @@ import { deductAdminWorkOrderPendingDeposit, markAdminWorkOrderProblem, markAdminWorkOrderShareProblem, + reduceAdminWorkOrderShareQuantity, resolveAdminProblemWorkOrder, disposeAdminWorkOrderShareProblem, } from '../../../services/worker-platform/index.js' @@ -163,6 +164,32 @@ router.post( ), ) +// 按份数撤销部分份额:减少的份数自动回到大厅,押金保持原冻结额。 +router.post( + '/worker-platform/orders/:workOrderId/shares/:shareId/reduce-quantity', + requireAdminPermission('worker_order.cancel_share'), + createJsonHandler( + (req) => + reduceAdminWorkOrderShareQuantity( + String(req.params.workOrderId || ''), + String(req.params.shareId || ''), + req.body || {}, + req.adminSession?.displayName || req.adminSession?.username || '', + ), + { + successMessage: '已撤销指定份数,剩余份数继续履约', + errorMessage: '撤销拼单份额失败', + scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/reduce-quantity]', + audit: (req, data) => ({ + action: 'work_order_share_quantity_reduced', + targetType: 'work_order_share', + targetId: String(req.params.shareId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + // 份额级问题单:只影响该份额持有人,母单状态不变。 router.post( '/worker-platform/orders/:workOrderId/shares/:shareId/problem', 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 be0a7cd7..57dd7989 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 @@ -5,6 +5,7 @@ import { cancelWorkOrderShare, cancelWorkOrderSharing, countWorkOrderPendingSharingSubmissions, + reduceWorkOrderShareQuantity, updateWorkOrder, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' @@ -189,6 +190,70 @@ export async function cancelAdminWorkOrderShare( } } +/** + * 后台按份数撤销某个打手的部分份额。 + * + * 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额, + * 母单状态与验收资料都不动(之后仍可对该份额「单独验收」)。 + */ +export async function reduceAdminWorkOrderShareQuantity( + workOrderId: number | string, + shareId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const reduceQuantity = Math.floor(Number(payload.reduceQuantity ?? payload.quantity ?? 0)) + if (!Number.isFinite(reduceQuantity) || reduceQuantity <= 0) { + throw createHttpError('请填写要撤掉的份数', { + statusCode: 400, + errorCode: 'work_order_sharing_share_reduce_quantity_required', + }) + } + + const result = await reduceWorkOrderShareQuantity({ + workOrderId: Number(workOrderId), + shareId: Number(shareId), + reduceQuantity, + note: String(payload.note || payload.reason || '').trim(), + actorName, + now: nowIso(), + }) + if (result.failureReason === 'work_order_not_reducible') { + throw createHttpError('当前拼单状态不能撤销份额', { + statusCode: 409, + errorCode: 'work_order_sharing_share_reduce_status_invalid', + }) + } + if (result.failureReason === 'share_not_found') { + throw createHttpError('未找到该拼单参与记录', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + if (result.failureReason === 'share_status_not_reducible' || !result.share || !result.order) { + throw createHttpError('只有已参与或已提交验收的拼单份额可以撤销份数', { + statusCode: 409, + errorCode: 'work_order_sharing_share_reduce_share_status_invalid', + }) + } + if (result.failureReason === 'share_quantity_invalid') { + throw createHttpError('撤掉的份数必须在 1 到「当前份数 − 1」之间;要全部撤销请用「单人撤单」', { + statusCode: 409, + errorCode: 'work_order_sharing_share_reduce_quantity_invalid', + }) + } + + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds: await resolveWorkOrderRealtimeWorkerIds(result.order), + hallChanged: true, + }) + return { + order: mapWorkOrderAdmin(result.order), + share: mapWorkOrderShare(result.share), + } +} + export async function cancelAdminWorkOrderSharing( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 35d3c5ac..39a70d4f 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -54,6 +54,7 @@ export { acceptAdminWorkOrders, cancelAdminWorkOrderShare, cancelAdminWorkOrderSharing, + reduceAdminWorkOrderShareQuantity, } from './admin-work-order-acceptance-service.js' export * from './worker-product-match-config-service.js' export * from './worker-announcement-config-service.js' diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 6af2857c..7893be62 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 @@ -788,6 +788,10 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) { timeoutMinutes: Number(share.timeout_minutes || 0), timeoutPolicy: share.timeout_policy || '', cancelledReason: share.cancelled_reason || '', + /** 后台撤单/撤份额时填写的原因(自由文本,选填) */ + cancelNote: share.cancel_note || '', + /** 被撤掉的份数:撤份额累计,单人撤单为整份份数;0 = 未撤过 */ + cancelQuantity: Math.max(0, Number(share.cancel_quantity || 0)), worker: share.worker_id ? { workerId: Number(share.worker_id), 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 7b83afd4..d4d7cb20 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 @@ -3364,6 +3364,64 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收 ) }) +test('撤份额与单人撤单都把原因和份数写进份额行,列表才能显示「撤 N 份 · 原因」', () => { + const settlementSrc = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url), + 'utf8', + ) + const cancelBlock = settlementSrc.slice( + settlementSrc.indexOf('export async function cancelWorkOrderShare('), + settlementSrc.indexOf('export async function settleOverdueWorkOrderShare('), + ) + assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在') + assert.ok(cancelBlock.includes('cancel_note = $'), '单人撤单必须把原因写进 cancel_note') + assert.ok( + cancelBlock.includes("String(input.reason || '').trim()"), + '单人撤单写入 cancel_note 的必须是运营填写的自由文本原因', + ) + assert.ok( + cancelBlock.includes("cancelled_reason = 'admin'"), + 'cancelled_reason 仍保留枚举口径,自由文本不得塞进去', + ) + assert.ok( + cancelBlock.includes('cancel_quantity = COALESCE(quantity, 0)'), + '单人撤单要按整份数量记录撤销份数,列表才能显示「撤 N 份」', + ) + + const shareSrc = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), + 'utf8', + ) + const reduceBlock = shareSrc.slice( + shareSrc.indexOf('export async function reduceWorkOrderShareQuantity('), + shareSrc.indexOf('export async function submitWorkOrderShareAcceptance('), + ) + assert.ok(reduceBlock.length > 0, 'reduceWorkOrderShareQuantity 片段必须存在') + assert.ok(reduceBlock.includes('cancel_note = $'), '撤份额必须把原因写进 cancel_note') + assert.ok( + reduceBlock.includes('String(share.cancel_note'), + '后续撤份额没填原因时必须保留上一次填的说明', + ) + assert.ok( + reduceBlock.includes('cancel_quantity = $') && + reduceBlock.includes('Number(share.cancel_quantity'), + '撤份额必须按「已有撤销份数 + 本次份数」累计写入 cancel_quantity', + ) + + const mapperSrc = fs.readFileSync( + new URL('./work-order-display-mappers.ts', import.meta.url), + 'utf8', + ) + assert.ok( + mapperSrc.includes('cancelNote: share.cancel_note'), + '拼单明细接口必须下发 cancelNote,否则列表显示不出来', + ) + assert.ok( + mapperSrc.includes('cancelQuantity: Math.max(0, Number(share.cancel_quantity'), + '拼单明细接口必须下发 cancelQuantity,列表才能拼「撤 N 份」前缀', + ) +}) + test('份额级问题单标记/处置只写份额字段,不得改母单状态或触发整单重算', () => { const src = fs.readFileSync( new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url), diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index 95f9da5c..c5393fa0 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -34,6 +34,18 @@ function formatStatusLabel(status: string) { return STATUS_LABELS[status] || status || '未知' } +/** 份额级问题单处置动作中文名(与后台弹窗的四个选项一一对应) */ +const SHARE_PROBLEM_ACTION_LABELS: Record = { + return_to_worker: '退回打手继续处理', + reopen: '取消该份额并退回接单大厅', + cancel_release: '取消该份额并退还押金', + cancel_deduct: '取消该份额并扣除押金', +} + +function formatShareProblemAction(action: string) { + return SHARE_PROBLEM_ACTION_LABELS[action] || '' +} + function readPayload(event: WorkOrderEvent, key: string): string { const value = event.payload?.[key] if (value === undefined || value === null) return '' @@ -311,6 +323,88 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { } case 'sharing_config_updated': return { title: '更新了拼单配置', color: 'default' } + case 'sharing_joined': { + const workerLabel = resolveEventWorkerLabel(event) + const quantity = readPayloadAmount(event, 'quantity') + const deadlineAt = readPayload(event, 'deadlineAt') + const timeoutMinutes = readPayloadAmount(event, 'timeoutMinutes') + const deadlineText = deadlineAt + ? `截止 ${dayjs(deadlineAt).format('MM-DD HH:mm')}` + : timeoutMinutes > 0 + ? `限时 ${Math.round(timeoutMinutes)} 分钟` + : '不限时' + return { + title: workerLabel ? `打手参与拼单(${workerLabel})` : '打手参与拼单', + description: [quantity > 0 ? `参与 ${quantity} 份` : '', deadlineText] + .filter(Boolean) + .join(' · '), + color: 'purple', + } + } + case 'sharing_share_quantity_reduced': { + const workerLabel = resolveEventWorkerLabel(event) + const fromQuantity = readPayloadAmount(event, 'fromQuantity') + const toQuantity = readPayloadAmount(event, 'toQuantity') + const reduceQuantity = readPayloadAmount(event, 'reduceQuantity') + const details = [ + fromQuantity > 0 && toQuantity > 0 ? `${fromQuantity} 份 → ${toQuantity} 份` : '', + reduceQuantity > 0 ? `撤销 ${reduceQuantity} 份` : '', + note ? `原因:${note}` : '', + '撤掉的份数已退回接单大厅,押金保持冻结', + ].filter(Boolean) + return { + title: workerLabel ? `撤销部分拼单份额(${workerLabel})` : '撤销部分拼单份额', + description: details.join(';'), + color: 'orange', + } + } + case 'sharing_share_marked_problem': { + const workerLabel = resolveEventWorkerLabel(event) + return { + title: workerLabel ? `标记拼单份额问题单(${workerLabel})` : '标记拼单份额问题单', + description: note + ? `原因:${note};仅该份额受影响,母单状态不变` + : '未填备注;仅该份额受影响,母单状态不变', + color: 'red', + } + } + case 'sharing_share_problem_disposed': { + const workerLabel = resolveEventWorkerLabel(event) + const actionLabel = formatShareProblemAction(readPayload(event, 'action')) + const depositAmount = readPayloadAmount(event, 'depositAmount') + const details = [ + actionLabel ? `处理方式:${actionLabel}` : '', + depositAmount > 0 ? `涉及押金 ${formatFenAmount(depositAmount)}` : '', + note ? `备注:${note}` : '', + ].filter(Boolean) + return { + title: workerLabel ? `处置拼单份额问题单(${workerLabel})` : '处置拼单份额问题单', + description: details.length > 0 ? details.join(';') : undefined, + color: 'orange', + } + } + case 'sharing_acceptance_draft_saved': + case 'sharing_acceptance_updated': { + const workerLabel = resolveEventWorkerLabel(event) + const imageCount = readPayloadAmount(event, 'imageCount') + const hasVideo = Boolean(event.payload?.hasVideo) + const details = [ + imageCount > 0 ? `验收图 ${imageCount} 张` : '', + hasVideo ? '含验收视频' : '', + ].filter(Boolean) + return { + title: + type === 'sharing_acceptance_draft_saved' + ? workerLabel + ? `保存了拼单验收草稿(${workerLabel})` + : '保存了拼单验收草稿' + : workerLabel + ? `更新了拼单验收资料(${workerLabel})` + : '更新了拼单验收资料', + description: details.length > 0 ? details.join(' · ') : undefined, + color: 'purple', + } + } case 'price_synced_from_rule': { const rewardAmount = Number(readPayload(event, 'rewardAmount') || 0) const unitReward = Number(readPayload(event, 'sharingUnitReward') || 0) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 8f5b6cc4..24308931 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -72,6 +72,7 @@ import { markAdminWorkOrderShareProblem, pinAdminWorkOrder, publishAdminWorkOrder, + reduceAdminWorkOrderShareQuantity, reopenAdminCancelledWorkOrder, resetAdminWorkOrderGiftMaterial, resolveAdminProblemWorkOrder, @@ -306,6 +307,7 @@ export default function WorkOrdersPanel() { const [materialPasteText, setMaterialPasteText] = useState('') const [sharingOrder, setSharingOrder] = useState(null) const [cancelShare, setCancelShare] = useState(null) + const [reduceShare, setReduceShare] = useState(null) const [problemShare, setProblemShare] = useState(null) const [disposeShare, setDisposeShare] = useState(null) const [cancelSharingOrder, setCancelSharingOrder] = useState(null) @@ -320,6 +322,7 @@ export default function WorkOrdersPanel() { const [materialForm] = Form.useForm() const [cancelForm] = Form.useForm() const [cancelShareForm] = Form.useForm() + const [reduceShareForm] = Form.useForm<{ reduceQuantity?: number; note?: string }>() const [problemShareForm] = Form.useForm<{ note?: string }>() const [disposeShareForm] = Form.useForm<{ action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct' @@ -725,6 +728,32 @@ export default function WorkOrdersPanel() { cancelShareForm.resetFields() } + /** 按份数撤销部分份额:减少的份数回到大厅,押金保持冻结。 */ + async function submitReduceShare(values: { reduceQuantity?: number; note?: string }) { + if (!sharingOrder || !reduceShare) return + const workOrderId = sharingOrder.workOrderId + const reduceQuantity = Math.floor(Number(values.reduceQuantity || 0)) + if (!Number.isFinite(reduceQuantity) || reduceQuantity <= 0) { + message.warning('请填写要撤掉的份数') + return + } + const keepQuantity = Math.max(0, reduceShare.quantity - reduceQuantity) + const succeeded = await runAction( + () => + reduceAdminWorkOrderShareQuantity(workOrderId, reduceShare.shareId, { + reduceQuantity, + note: String(values.note || '').trim(), + }), + `已撤掉 ${reduceQuantity} 份,保留 ${keepQuantity} 份继续履约`, + ) + if (!succeeded) return + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-order-sharing', workOrderId], + }) + setReduceShare(null) + reduceShareForm.resetFields() + } + /** 按份额标记问题单:只影响该打手,母单状态不变。备注选填。 */ async function submitProblemShare(values: { note?: string }) { if (!sharingOrder || !problemShare) return @@ -3083,9 +3112,11 @@ export default function WorkOrdersPanel() { onCancel={() => { setSharingOrder(null) setCancelShare(null) + setReduceShare(null) setCancelSharingOrder(null) sharingForm.resetFields() cancelShareForm.resetFields() + reduceShareForm.resetFields() cancelSharingForm.resetFields() }} onOk={() => sharingForm.submit()} @@ -3218,7 +3249,7 @@ export default function WorkOrdersPanel() { loading={sharingQuery.isFetching} pagination={false} tableLayout="fixed" - scroll={{ x: 1624 }} + scroll={{ x: 1794 }} rowClassName={(share) => share.problemMarked ? 'admin-share-row-problem' @@ -3363,6 +3394,27 @@ export default function WorkOrdersPanel() { ) }, }, + { + title: '撤单记录', + width: 170, + render: (_, share) => { + // 份数前缀由展示层拼接:接口只给结构化的份数与原因文本 + const cancelledQuantity = Math.max( + 0, + Math.floor(Number(share.cancelQuantity || 0)), + ) + const note = String(share.cancelNote || '').trim() + if (!cancelledQuantity && !note) return '-' + const text = cancelledQuantity + ? `撤 ${cancelledQuantity} 份${note ? ` · ${note}` : ''}` + : note + return ( + + {text} + + ) + }, + }, { title: '操作', width: 300, @@ -3451,6 +3503,24 @@ export default function WorkOrdersPanel() { 单人撤单 ) : null} + {(share.status === 'joined' || share.status === 'submitted') && + share.quantity >= 2 ? ( + + ) : null} {problemAction}
) @@ -3496,6 +3566,75 @@ export default function WorkOrdersPanel() { ) : null} + { + setReduceShare(null) + reduceShareForm.resetFields() + }} + onOk={() => reduceShareForm.submit()} + destroyOnHidden + > + {reduceShare ? ( +
+ + + + + + {() => { + const reduceQuantity = Math.floor( + Number(reduceShareForm.getFieldValue('reduceQuantity') || 0), + ) + const boundedReduce = Math.max( + 0, + Math.min(Math.max(0, reduceShare.quantity - 1), reduceQuantity), + ) + const keepQuantity = Math.max(0, reduceShare.quantity - boundedReduce) + return ( + + 调整后保留 {keepQuantity} 份,回到大厅 {boundedReduce} 份;剩余份额报酬{' '} + {formatMoney(keepQuantity * Number(reduceShare.unitReward || 0))} + + ) + }} + + + + + + ) : null} +
+ (`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload) } +/** 按份数撤销某个打手的部分份额:减少的份数回到大厅,押金保持原冻结额。 */ +export function reduceAdminWorkOrderShareQuantity( + workOrderId: number, + shareId: number, + payload: { reduceQuantity: number; note?: string }, +) { + return apiPost<{ order: WorkOrder; share: WorkOrderShare }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/reduce-quantity`, + payload, + ) +} + /** 按份额标记问题单:只影响该份额持有人,母单状态不变。备注选填。 */ export function markAdminWorkOrderShareProblem(workOrderId: number, shareId: number, note = '') { return apiPost<{ order: WorkOrder; share: WorkOrderShare }>( diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index b2cefb52..07109305 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -572,6 +572,10 @@ export type WorkOrderShare = { timeoutMinutes?: number timeoutPolicy?: string cancelledReason?: string + /** 后台撤单/撤份额时填写的原因(自由文本,选填) */ + cancelNote?: string + /** 被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过) */ + cancelQuantity?: number /** 份额级问题单:只影响该份额持有人,母单状态不变 */ problemMarked?: boolean problemNote?: string From d787d3c3649c1662a1f06c7e28b93a4e15af4a11 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 25 Oct 2026 14:41:13 +0800 Subject: [PATCH 11/17] =?UTF-8?q?feat:=E6=8A=A2=E5=8D=95=E5=A4=A7=E5=8E=85?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=8E=92=E5=88=97=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/repositories/realtime-event-repo.ts | 9 +++-- .../realtime/realtime-event-service.ts | 34 +++++++++++++++++-- .../services/realtime/realtime-replay.test.ts | 34 +++++++++++++++++++ apps/frontend/src/hooks/useRealtimeEvents.ts | 32 +++++++++++++++++ apps/frontend/src/layouts/AdminLayout.tsx | 10 ++++++ apps/frontend/src/layouts/WorkerLayout.tsx | 10 ++++++ 6 files changed, 124 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/repositories/realtime-event-repo.ts b/apps/backend/src/repositories/realtime-event-repo.ts index 31b40d11..bcc2c6f8 100644 --- a/apps/backend/src/repositories/realtime-event-repo.ts +++ b/apps/backend/src/repositories/realtime-event-repo.ts @@ -62,10 +62,13 @@ export async function createRealtimeEvent(input: { export async function listRealtimeEventsAfter(eventId: number, limit = 500) { const result = await query( ` - SELECT * FROM realtime_events - WHERE id > $1 + SELECT * FROM ( + SELECT * FROM realtime_events + WHERE id > $1 + ORDER BY id DESC + LIMIT $2 + ) replay_window ORDER BY id ASC - LIMIT $2 `, [Math.max(0, Number(eventId) || 0), Math.min(500, Math.max(1, Number(limit) || 500))], ) diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index c99eee1f..b2572084 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -85,6 +85,24 @@ let nextSubscriberId = 1 let heartbeatTimer: NodeJS.Timeout | null = null let publishQueue: Promise = Promise.resolve() +/** + * 单次补发的事件上限,与 realtime_events 表保留的条数一致 + * (见 realtime-event-repo 的清理策略:只保留最近 500 条)。 + */ +export const REALTIME_REPLAY_EVENT_LIMIT = 500 + +/** + * 是否应该补发历史事件。 + * + * 只有断线重连带上了 Last-Event-ID(游标 > 0)才补发。新连接(游标 = 0)**不补发**: + * 补发的每一条都携带「事件发生那一刻」的工单快照,直接打进刚由列表接口加载好的缓存, + * 会把已涨价/已拼满的订单覆盖回旧值(表现为「进大厅显示旧单价旧进度,点一次刷新才对」)。 + */ +export function shouldReplayRealtimeEvents(lastEventId: number): boolean { + const normalized = Number(lastEventId) + return Number.isFinite(normalized) && normalized > 0 +} + /** 建立后台实时事件流,事件快照用于前端定点更新缓存。 */ export function openAdminRealtimeStream(response: Response, lastEventId = 0) { return openRealtimeStream(response, null, lastEventId) @@ -532,7 +550,10 @@ function openRealtimeStream(response: Response, workerId: number | null, lastEve connectedAt: new Date().toISOString(), lastEventId: Math.max(0, Number(lastEventId) || 0), }) - void replayEvents(subscriber, Math.max(0, Number(lastEventId) || 0)) + // 新连接不补发历史事件,只有断线重连(带游标)才回放缺失的增量。 + if (shouldReplayRealtimeEvents(lastEventId)) { + void replayEvents(subscriber, Number(lastEventId)) + } ensureHeartbeat() response.on('close', () => { @@ -542,7 +563,16 @@ function openRealtimeStream(response: Response, workerId: number | null, lastEve } async function replayEvents(subscriber: RealtimeSubscriber, lastEventId: number) { - const events = await listRealtimeEventsAfter(lastEventId) + const events = await listRealtimeEventsAfter(lastEventId, REALTIME_REPLAY_EVENT_LIMIT) + // 补发窗口被拿满 = 更早的事件已经从事件表里滚掉(断线太久)。 + // 这时逐条套用旧快照只会把列表写回旧值,直接让客户端整体重拉一次。 + if (events.length >= REALTIME_REPLAY_EVENT_LIMIT) { + writeEvent(subscriber, 'resync', { + headEventId: Number(events[events.length - 1]?.id || 0), + reason: 'replay_window_overflow', + }) + return + } for (const row of events) { const event = mapStoredEvent(row) if (event.eventId <= lastEventId || !isEventVisibleToSubscriber(event, subscriber)) continue diff --git a/apps/backend/src/services/realtime/realtime-replay.test.ts b/apps/backend/src/services/realtime/realtime-replay.test.ts index 7df17f5c..de60e5ee 100644 --- a/apps/backend/src/services/realtime/realtime-replay.test.ts +++ b/apps/backend/src/services/realtime/realtime-replay.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import fs from 'node:fs' import test from 'node:test' import { shouldReplayRealtimeEvents } from './realtime-event-service.js' @@ -20,3 +21,36 @@ test('断线重连(带 Last-Event-ID)才补发事件', () => { assert.equal(shouldReplayRealtimeEvents(1), true) assert.equal(shouldReplayRealtimeEvents(3993659), true) }) + +test('补发窗口取最新的一段,而不是最旧的一段', () => { + const source = fs.readFileSync( + new URL('../../repositories/realtime-event-repo.ts', import.meta.url), + 'utf8', + ) + const block = source.slice( + source.indexOf('export async function listRealtimeEventsAfter('), + source.indexOf('\n}', source.indexOf('export async function listRealtimeEventsAfter(')), + ) + assert.ok(block.length > 0, 'listRealtimeEventsAfter 片段必须存在') + assert.ok( + /ORDER BY id DESC\s+LIMIT \$2\s+\)\s*replay_window\s+ORDER BY id ASC/s.test(block), + '补发必须「内层 id DESC 取最新 N 条、外层 id ASC 回放」;否则断线久了窗口会停在旧事件上', + ) +}) + +test('补发窗口溢出时改为让客户端整体重拉', () => { + const source = fs.readFileSync(new URL('./realtime-event-service.ts', import.meta.url), 'utf8') + const replayBlock = source.slice( + source.indexOf('async function replayEvents('), + source.indexOf('function mapStoredEvent('), + ) + assert.ok(replayBlock.length > 0, 'replayEvents 片段必须存在') + assert.ok( + replayBlock.includes('events.length >= REALTIME_REPLAY_EVENT_LIMIT'), + '补发窗口被拿满时必须判定为溢出,不能继续逐条套用旧快照', + ) + assert.ok( + replayBlock.includes("'resync'"), + '溢出时要给客户端发 resync,让列表整体重拉(接口才是权威数据源)', + ) +}) diff --git a/apps/frontend/src/hooks/useRealtimeEvents.ts b/apps/frontend/src/hooks/useRealtimeEvents.ts index f22d5900..ea3c88cc 100644 --- a/apps/frontend/src/hooks/useRealtimeEvents.ts +++ b/apps/frontend/src/hooks/useRealtimeEvents.ts @@ -21,6 +21,13 @@ type UseRealtimeEventsOptions = { token: string onEvent: (event: RealtimeEvent) => void onConnected?: () => void + /** + * 服务端告知「补发窗口溢出,历史事件已不连续」。 + * + * 此时不能再依赖逐条快照补丁(会把列表写回旧值),调用方应整体失效对应查询, + * 让列表接口重新给出权威数据。 + */ + onResync?: (info: { headEventId: number; reason: string }) => void /** 鉴权失效(401/403)时触发:连接停止重连,调用方应清理会话并跳转登录页。 */ onAuthExpired?: () => void } @@ -33,13 +40,16 @@ export function useRealtimeEvents({ token, onEvent, onConnected, + onResync, onAuthExpired, }: UseRealtimeEventsOptions) { const onEventRef = useRef(onEvent) const onConnectedRef = useRef(onConnected) + const onResyncRef = useRef(onResync) const onAuthExpiredRef = useRef(onAuthExpired) onEventRef.current = onEvent onConnectedRef.current = onConnected + onResyncRef.current = onResync onAuthExpiredRef.current = onAuthExpired useEffect(() => { @@ -79,6 +89,15 @@ export function useRealtimeEvents({ } onConnectedRef.current?.() await readSseStream(response.body, (eventName, data) => { + // 补发窗口溢出:历史事件不连续,不能逐条复位快照,交给调用方整体重拉。 + if (eventName === 'resync') { + const notice = normalizeResyncNotice(data) + if (notice) { + lastEventId = Math.max(lastEventId, notice.headEventId) + onResyncRef.current?.(notice) + } + return + } if (eventName !== 'realtime') return const event = normalizeRealtimeEvent(data) if (event) { @@ -197,3 +216,16 @@ function normalizeRealtimeEvent(value: string): RealtimeEvent | null { return null } } + +/** 服务端 resync 通知:补发窗口溢出,客户端应把游标推进到 headEventId 并整体重拉。 */ +function normalizeResyncNotice(value: string): { headEventId: number; reason: string } | null { + try { + const parsed = JSON.parse(value) as { headEventId?: unknown; reason?: unknown } + return { + headEventId: Math.max(0, Number(parsed.headEventId) || 0), + reason: String(parsed.reason || ''), + } + } catch { + return null + } +} diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index babc4e64..edb35e1f 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -119,6 +119,16 @@ export default function AdminLayout() { url: '/api/v1/admin/realtime', token: role === 'finance' ? '' : getAdminToken(), onEvent: (event) => applyAdminRealtimeEvent(event, queryClient), + // 补发窗口溢出(断线太久):历史事件已不连续,整体失效让接口重新给权威数据。 + onResync: () => { + void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-after-sales-cases'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-finance-requests'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-notifications'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-tasks'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-orders'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-dashboard-summary'] }) + }, // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 onAuthExpired: () => { clearAdminSession() diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 26c34b48..d697d8c0 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -138,6 +138,16 @@ export default function WorkerLayout() { url: '/api/v1/worker/realtime', token: getWorkerToken(), onEvent: (event) => applyWorkerRealtimeEvent(event, queryClient), + // 补发窗口溢出(断线太久):历史事件已不连续,逐条套旧快照会把列表写回旧值, + // 这里整体失效,让列表接口重新给权威数据。 + onResync: () => { + void queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-finance-requests'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-profile'] }) + }, // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 onAuthExpired: () => { expireWorkerSession() From 027cfc95b10639da8b2a5e1f530469a7a098de2d Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 25 Oct 2026 14:46:28 +0800 Subject: [PATCH 12/17] =?UTF-8?q?fix:=E6=8B=BC=E5=8D=95=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E5=A2=9E=E5=8A=A0=E5=AE=BD=E5=BA=A6=EF=BC=8C?= =?UTF-8?q?=E4=BF=9D=E8=AF=81=E6=93=8D=E4=BD=9C=E8=83=BD=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E7=9A=84=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 24308931..6c325d64 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -3124,7 +3124,7 @@ export default function WorkOrdersPanel() { sharingOrder?.capabilities?.canConfigureSharing && canConfigureSharing ? undefined : null } destroyOnHidden - width={1200} + width={1360} > {sharingOrder ? ( @@ -3249,7 +3249,7 @@ export default function WorkOrdersPanel() { loading={sharingQuery.isFetching} pagination={false} tableLayout="fixed" - scroll={{ x: 1794 }} + scroll={{ x: 1914 }} rowClassName={(share) => share.problemMarked ? 'admin-share-row-problem' @@ -3417,7 +3417,7 @@ export default function WorkOrdersPanel() { }, { title: '操作', - width: 300, + width: 420, fixed: 'right', render: (_, share) => { if (share.status === 'cancelled') return 已撤单 From 6993c36efb4dcb036aa1a72c218e4bb974d6c7b0 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 25 Oct 2026 16:16:27 +0800 Subject: [PATCH 13/17] =?UTF-8?q?fix:=E7=9B=B8=E6=9C=BA=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/files/ImageUpload.tsx | 35 ++++++----- .../src/components/files/VideoUpload.tsx | 63 +++++++++++-------- apps/frontend/src/styles/base.css | 31 ++++++--- apps/frontend/src/utils/camera-capture.ts | 4 ++ deploy/caddy/Caddyfile.dev | 58 ++++++++++++----- docker-compose.dev.yml | 32 ++++++++++ 6 files changed, 158 insertions(+), 65 deletions(-) diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx index 9a117f83..18611d43 100644 --- a/apps/frontend/src/components/files/ImageUpload.tsx +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -71,7 +71,6 @@ export default function ImageUpload({ const { message } = App.useApp() const isMobile = useIsMobile() const [isDragging, setIsDragging] = useState(false) - const cameraInputRef = useRef(null) const dragCounterRef = useRef(0) const targetIdRef = useRef(0) const valueRef = useRef(value) @@ -258,21 +257,25 @@ export default function ImageUpload({ {cameraEnabled && value.length < maxCount ? (
- void handleCameraSelect(event)} - /> - + {/* + 相机入口用「透明原生 input 铺满按钮」的方式: + 用户手指直接点在 input 上,浏览器才会把它当作拍照手势; + 旧实现把 input 移到 left:-9999px 再由按钮脚本 .click(),部分安卓 WebView + 会把相机 intent 降级成文件选择(表现为点了「拍照」却弹出文件浏览)。 + */} + + void handleCameraSelect(event)} + /> + +
) : null} {showPasteHint ? ( diff --git a/apps/frontend/src/components/files/VideoUpload.tsx b/apps/frontend/src/components/files/VideoUpload.tsx index 6a6408e1..7bf54d7c 100644 --- a/apps/frontend/src/components/files/VideoUpload.tsx +++ b/apps/frontend/src/components/files/VideoUpload.tsx @@ -50,7 +50,6 @@ export default function VideoUpload({ const { message } = App.useApp() const isMobile = useIsMobile() const inputRef = useRef(null) - const cameraInputRef = useRef(null) const [phase, setPhase] = useState<'idle' | 'compressing' | 'uploading'>('idle') const [progress, setProgress] = useState(0) @@ -144,16 +143,6 @@ export default function VideoUpload({ style={{ display: 'none' }} onChange={handleSelect} /> - {cameraEnabled ? ( - - ) : null} {value ? (
@@ -170,14 +159,22 @@ export default function VideoUpload({ {cameraEnabled ? ( - + // 透明原生 input 铺满按钮:真实手势点 input 才会被当作「拍摄」, + // 脚本 click 在部分安卓 WebView 会退化成文件选择。 + + + + ) : null} + // 同「重拍」:透明原生 input 铺满按钮,靠真实手势调起系统相机。 + + + + ) : null} ) : null} {canRemindAcceptance(order) ? ( From b0ab8cd4103a729b70c9c4431ea81a8f4c60891e Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 26 Oct 2026 10:25:36 +0800 Subject: [PATCH 17/17] =?UTF-8?q?feat:=E5=94=AE=E5=90=8E=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E5=8D=95=E6=96=B0=E5=A2=9E=E4=B8=80=E4=B8=AA=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=90=9C?= =?UTF-8?q?=E7=B4=A2key:=E5=94=AE=E5=90=8E=E5=8D=95=E5=8F=B7=EF=BC=8C?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E5=8F=B7=EF=BC=8C=E5=B9=B3=E5=8F=B0=E5=8F=B7?= =?UTF-8?q?=EF=BC=8C=E8=B4=A3=E4=BB=BB=E6=89=93=E6=89=8B=EF=BC=8C=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E5=8A=A0=E4=B8=8A=E7=8A=B6=E6=80=81=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/admin/panels/AfterSalesPanel.tsx | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx index db7e1626..dcf5382a 100644 --- a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx @@ -33,6 +33,7 @@ import type { WorkOrder, } from '@/types/worker-platform' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { normalizeSearchText } from '@/utils/admin-realtime-work-orders' import { formatAdminDateTime } from '@/utils/admin-time' import { uniqueUploadedFiles } from '@/utils/image-identity' import { asRecord, formatMoney } from './shared' @@ -54,6 +55,14 @@ type AfterSalesRepayFormValues = { note?: string } +/** 售后单状态筛选(与列表标签同一套文案)。 */ +const AFTER_SALES_STATUS_OPTIONS = [ + { value: 'awaiting_worker_response', label: '待打手说明' }, + { value: 'under_review', label: '客服处理中' }, + { value: 'recovery_pending', label: '追缴中' }, + { value: 'closed', label: '已关闭' }, +] + export type AfterSalesPanelHandle = { openCreateModal: (row: WorkOrder) => void } @@ -67,6 +76,10 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan const [afterSalesFiles, setAfterSalesFiles] = useState([]) const [afterSalesPage, setAfterSalesPage] = useState(1) const [afterSalesPageSize, setAfterSalesPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + /** 搜索框输入值;点搜索/回车后才写入 afterSalesKeyword,避免每敲一个字就请求。 */ + const [afterSalesKeywordInput, setAfterSalesKeywordInput] = useState('') + const [afterSalesKeyword, setAfterSalesKeyword] = useState('') + const [afterSalesStatus, setAfterSalesStatus] = useState('') const [afterSalesCreateForm] = Form.useForm() const [afterSalesResolutionForm] = Form.useForm() const [afterSalesRepayForm] = Form.useForm() @@ -77,9 +90,20 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan enabled: Boolean(afterSalesOrder?.sharing?.enabled), }) const afterSalesCasesQuery = useQuery({ - queryKey: ['admin-worker-platform-after-sales-cases', afterSalesPage, afterSalesPageSize], + queryKey: [ + 'admin-worker-platform-after-sales-cases', + afterSalesPage, + afterSalesPageSize, + afterSalesKeyword, + afterSalesStatus, + ], queryFn: () => - fetchAdminAfterSalesCases({ page: afterSalesPage, pageSize: afterSalesPageSize }), + fetchAdminAfterSalesCases({ + page: afterSalesPage, + pageSize: afterSalesPageSize, + ...(afterSalesKeyword ? { keyword: afterSalesKeyword } : {}), + ...(afterSalesStatus ? { status: afterSalesStatus } : {}), + }), }) useImperativeHandle(ref, () => ({ @@ -200,6 +224,41 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan } bordered={false} > +
+ + { + const nextValue = event.target.value + setAfterSalesKeywordInput(nextValue) + // 清空输入立即恢复全量,不用再点一次搜索。 + if (!nextValue.trim()) { + setAfterSalesKeyword('') + setAfterSalesPage(1) + } + }} + onSearch={(value) => { + // 归一化(全角→半角、去零宽字符):粘贴来的单号常夹带不可见字符。 + setAfterSalesKeyword(normalizeSearchText(value)) + setAfterSalesPage(1) + }} + /> +