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' ? (