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