diff --git a/apps/backend/src/db/migrations/043_worker_cancel_requests.sql b/apps/backend/src/db/migrations/043_worker_cancel_requests.sql new file mode 100644 index 00000000..351f01a6 --- /dev/null +++ b/apps/backend/src/db/migrations/043_worker_cancel_requests.sql @@ -0,0 +1,32 @@ +-- 打手撤单申请:提交后由客服审核,审核通过才执行撤单与处罚。 + +ALTER TABLE worker_users + ADD COLUMN IF NOT EXISTS level_penalty_accepted_count INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS worker_order_cancel_requests ( + id BIGSERIAL PRIMARY KEY, + work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + work_order_share_id BIGINT REFERENCES work_order_shares(id) ON DELETE SET NULL, + worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE, + reason TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + review_note TEXT NOT NULL DEFAULT '', + reviewed_by TEXT NOT NULL DEFAULT '', + level_before_id BIGINT REFERENCES worker_levels(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL, + reviewed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_cancel_requests_pending_target + ON worker_order_cancel_requests (work_order_id, worker_id, COALESCE(work_order_share_id, 0)) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_worker_cancel_requests_worker_created + ON worker_order_cancel_requests (worker_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_worker_cancel_requests_status_created + ON worker_order_cancel_requests (status, created_at DESC); + +COMMENT ON COLUMN worker_users.level_penalty_accepted_count IS '撤单处罚扣减的有效验收次数,不删除真实历史订单'; +COMMENT ON TABLE worker_order_cancel_requests IS '打手发起、客服审核的撤单申请'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 2c0bffed..8e805129 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -30,6 +30,7 @@ export type WorkerUserRow = { level_key?: string level_name?: string level_permission_json?: string | Record + level_penalty_accepted_count?: number available_amount?: number frozen_deposit_amount?: number pending_unfreeze_amount?: number @@ -66,6 +67,26 @@ export type WorkerWalletRow = { updated_at: string } +export type WorkerCancelRequestRow = { + id: number + work_order_id: number + work_order_share_id: number | null + worker_id: number + reason: string + status: string + review_note: string + reviewed_by: string + level_before_id: number | null + created_at: string + reviewed_at: string | null + updated_at: string + work_order_no?: string + product_name?: string + worker_username?: string + worker_display_name?: string + share_quantity?: number | null +} + export type WorkerDepositUnfreezeRow = { id: number worker_id: number diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index 791df23a..3c3f8e9d 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -13,6 +13,7 @@ import type { ProductRuleListInput, WorkCategoryRow, WorkerDepositUnfreezeRow, + WorkerCancelRequestRow, WorkOrderRow, WorkOrderShareRow, WorkOrderStatistics, @@ -59,6 +60,20 @@ const WORK_ORDER_SHARE_SELECT = ` LEFT JOIN worker_users wu ON wu.id = wos.worker_id ` +const WORKER_CANCEL_REQUEST_SELECT = ` + SELECT + cr.*, + wo.work_order_no, + wo.product_name, + wu.username AS worker_username, + wu.display_name AS worker_display_name, + wos.quantity AS share_quantity + FROM worker_order_cancel_requests cr + INNER JOIN work_orders wo ON wo.id = cr.work_order_id + INNER JOIN worker_users wu ON wu.id = cr.worker_id + LEFT JOIN work_order_shares wos ON wos.id = cr.work_order_share_id +` + type WorkerOrderOverviewRow = { status: string order_count: number @@ -1707,6 +1722,413 @@ export async function cancelWorkerWorkOrder(input: { }) } +/** 打手提交撤单申请;待审核期间订单与押金均保持原状。 */ +export async function createWorkerCancelRequest(input: { + workOrderId: number + workerId: number + reason: string + now: string +}): Promise<{ + request: WorkerCancelRequestRow | null + dailyUsed: number + failureReason: + | 'work_order_not_cancellable' + | 'work_order_owner_required' + | 'share_not_cancellable' + | 'request_pending' + | 'daily_limit_reached' + | 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) { + return { request: null, dailyUsed: 0, failureReason: 'work_order_not_cancellable' } + } + + 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' } + } + shareId = Number(share.id) + } else if ( + workOrder.status !== 'in_progress' || + Number(workOrder.assigned_worker_id || 0) !== input.workerId + ) { + return { + request: null, + dailyUsed: 0, + failureReason: workOrder.assigned_worker_id + ? 'work_order_not_cancellable' + : 'work_order_owner_required', + } + } + + const dailyResult = await client.query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM worker_order_cancel_requests + WHERE worker_id = $1 + AND status IN ('pending', 'approved') + AND created_at >= date_trunc('day', $2::timestamptz AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai'`, + [input.workerId, input.now], + ) + const dailyUsed = Number(dailyResult.rows[0]?.total || 0) + if (dailyUsed >= 5) { + return { request: null, dailyUsed, failureReason: 'daily_limit_reached' } + } + + const pendingResult = await client.query<{ id: number }>( + `SELECT id FROM worker_order_cancel_requests + WHERE work_order_id = $1 + AND worker_id = $2 + AND COALESCE(work_order_share_id, 0) = $3 + AND status = 'pending' + LIMIT 1`, + [input.workOrderId, input.workerId, shareId || 0], + ) + if (pendingResult.rows[0]) { + return { request: null, dailyUsed, failureReason: 'request_pending' } + } + + const created = await client.query<{ id: number }>( + `INSERT INTO worker_order_cancel_requests ( + work_order_id, work_order_share_id, worker_id, reason, status, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'pending', $5, $5) + RETURNING id`, + [input.workOrderId, shareId, input.workerId, input.reason, input.now], + ) + const requestId = Number(created.rows[0]?.id || 0) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'cancel_request_submitted', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ requestId, shareId, reason: input.reason }), + now: input.now, + }) + return { + request: await getWorkerCancelRequestByIdWithClient(client, requestId), + dailyUsed: dailyUsed + 1, + failureReason: null, + } + }) +} + +export async function listWorkerCancelRequests( + workerId: number | string, +): Promise { + const result = await query( + `${WORKER_CANCEL_REQUEST_SELECT} + WHERE cr.worker_id = $1 + ORDER BY cr.id DESC + LIMIT 100`, + [Number(workerId)], + ) + return result.rows +} + +export async function listAdminWorkerCancelRequests( + status = 'pending', +): Promise { + const result = await query( + `${WORKER_CANCEL_REQUEST_SELECT} + WHERE ($1 = '' OR cr.status = $1) + ORDER BY cr.id DESC + LIMIT 200`, + [status], + ) + return result.rows +} + +export async function getWorkerCancelRequestRisk(workerId: number | string, now: string) { + const result = await query<{ daily_used: number; approved_total: number }>( + `SELECT + COUNT(*) FILTER ( + WHERE status IN ('pending', 'approved') + AND created_at >= date_trunc('day', $2::timestamptz AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai' + )::int AS daily_used, + COUNT(*) FILTER (WHERE status = 'approved')::int AS approved_total + FROM worker_order_cancel_requests + WHERE worker_id = $1`, + [Number(workerId), now], + ) + const row = result.rows[0] + const approvedTotal = Number(row?.approved_total || 0) + const nextPenaltyAt = Math.ceil((approvedTotal + 1) / 10) * 10 + return { + dailyUsed: Number(row?.daily_used || 0), + dailyLimit: 5, + approvedTotal, + nextPenaltyAt, + remainingToPenalty: Math.max(0, nextPenaltyAt - approvedTotal), + } +} + +/** 客服审核通过时在同一事务内撤单、退押金并执行等级处罚。 */ +export async function reviewWorkerCancelRequest(input: { + requestId: number + approved: boolean + reviewNote: string + actorName: string + now: string +}): Promise<{ + request: WorkerCancelRequestRow | null + order: WorkOrderRow | null + releasedDepositAmount: number + levelChanged: boolean + accountDisabled: boolean + failureReason: 'request_not_found' | 'request_not_pending' | 'target_state_changed' | null +}> { + return withTransaction(async (client) => { + const requestResult = await client.query( + 'SELECT * FROM worker_order_cancel_requests WHERE id = $1 FOR UPDATE', + [input.requestId], + ) + const request = requestResult.rows[0] || null + if (!request) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'request_not_found', + } + } + if (request.status !== 'pending') { + return { + request: await getWorkerCancelRequestByIdWithClient(client, request.id), + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'request_not_pending', + } + } + + const workOrderResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [request.work_order_id], + ) + const workOrder = workOrderResult.rows[0] || null + if (!workOrder) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'target_state_changed', + } + } + + if (!input.approved) { + await client.query( + `UPDATE worker_order_cancel_requests + SET status = 'rejected', review_note = $1, reviewed_by = $2, reviewed_at = $3, updated_at = $3 + WHERE id = $4`, + [input.reviewNote, input.actorName, input.now, request.id], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(workOrder.id), + actorType: 'admin', + actorId: input.actorName, + eventType: 'cancel_request_rejected', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ requestId: request.id, note: input.reviewNote }), + now: input.now, + }) + return { + request: await getWorkerCancelRequestByIdWithClient(client, request.id), + order: await getWorkOrderByIdWithClient(client, workOrder.id), + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: null, + } + } + + let releasedDepositAmount = 0 + if (request.work_order_share_id) { + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [request.work_order_share_id, request.work_order_id], + ) + const share = shareResult.rows[0] || null + if ( + !share || + Number(share.worker_id) !== Number(request.worker_id) || + !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status) + ) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'target_state_changed', + } + } + await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now) + const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id)) + releasedDepositAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releasedDepositAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount + const nextFrozen = Math.max( + 0, + Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount, + ) + 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, 'deposit_release', $2, $3, $4, $5, '撤单申请审核通过退还拼单押金', $6::jsonb, $7)`, + [ + request.worker_id, + releasedDepositAmount, + nextAvailable, + nextFrozen, + request.work_order_id, + JSON.stringify({ requestId: request.id, shareId: share.id, reason: request.reason }), + input.now, + ], + ) + } + await client.query( + `UPDATE work_order_shares SET status = 'cancelled', updated_at = $1 WHERE id = $2`, + [input.now, share.id], + ) + await client.query( + `UPDATE work_orders + SET status = 'open', submitted_at = NULL, published_at = COALESCE(published_at, $1), updated_at = $1 + WHERE id = $2`, + [input.now, workOrder.id], + ) + } else { + if ( + workOrder.status !== 'in_progress' || + Number(workOrder.assigned_worker_id || 0) !== Number(request.worker_id) + ) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'target_state_changed', + } + } + await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now) + const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id)) + releasedDepositAmount = Math.min( + await getOutstandingDepositAmountWithClient( + client, + Number(request.worker_id), + workOrder.id, + ), + Number(wallet?.frozen_deposit_amount || 0), + ) + await client.query( + `UPDATE work_orders + SET status = 'open', assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL, + published_at = $1, updated_at = $1 + WHERE id = $2`, + [input.now, workOrder.id], + ) + if (releasedDepositAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount + const nextFrozen = Math.max( + 0, + Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount, + ) + 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, 'deposit_release', $2, $3, $4, $5, '撤单申请审核通过退还押金', $6::jsonb, $7)`, + [ + request.worker_id, + releasedDepositAmount, + nextAvailable, + nextFrozen, + request.work_order_id, + JSON.stringify({ requestId: request.id, reason: request.reason }), + input.now, + ], + ) + } + } + + const penalty = await applyWorkerCancelRequestPenaltyWithClient(client, { + workerId: Number(request.worker_id), + requestId: Number(request.id), + now: input.now, + }) + await client.query( + `UPDATE worker_order_cancel_requests + SET status = 'approved', review_note = $1, reviewed_by = $2, reviewed_at = $3, + level_before_id = $4, updated_at = $3 + WHERE id = $5`, + [input.reviewNote, input.actorName, input.now, penalty.levelBeforeId, request.id], + ) + const updatedOrder = await getWorkOrderByIdWithClient(client, workOrder.id) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(workOrder.id), + actorType: 'admin', + actorId: input.actorName, + eventType: 'cancel_request_approved', + fromStatus: workOrder.status, + toStatus: updatedOrder?.status || workOrder.status, + payloadJson: JSON.stringify({ + requestId: request.id, + shareId: request.work_order_share_id, + releasedDepositAmount, + levelChanged: penalty.levelChanged, + accountDisabled: penalty.accountDisabled, + note: input.reviewNote, + }), + now: input.now, + }) + return { + request: await getWorkerCancelRequestByIdWithClient(client, request.id), + order: updatedOrder, + releasedDepositAmount, + levelChanged: penalty.levelChanged, + accountDisabled: penalty.accountDisabled, + failureReason: null, + } + }) +} + export async function unassignWorkOrder(input: { workOrderId: number now: string @@ -3349,6 +3771,171 @@ async function createWorkOrderEventWithClient( ) } +async function getWorkerCancelRequestByIdWithClient( + client: PoolClient, + requestId: number | string, +): Promise { + const result = await client.query( + `${WORKER_CANCEL_REQUEST_SELECT} WHERE cr.id = $1 LIMIT 1`, + [Number(requestId)], + ) + return result.rows[0] || null +} + +async function countWorkerAcceptedOrdersWithClient( + client: PoolClient, + workerId: number, +): Promise { + const result = await client.query<{ total: number }>( + `SELECT COUNT(DISTINCT order_key)::int AS total + FROM ( + SELECT COALESCE( + 'order:' || NULLIF(wo.order_id::text, ''), + 'platform_order:' || NULLIF(BTRIM(wo.platform_order_id), ''), + 'work_order:' || wo.id::text + ) AS order_key + FROM work_orders wo + WHERE wo.assigned_worker_id = $1 AND wo.status = 'accepted' + + UNION + + SELECT COALESCE( + 'order:' || NULLIF(wo.order_id::text, ''), + 'platform_order:' || NULLIF(BTRIM(wo.platform_order_id), ''), + 'work_order:' || wo.id::text + ) AS order_key + 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 = 'accepted' + ) AS completed_orders`, + [workerId], + ) + return Number(result.rows[0]?.total || 0) +} + +/** 将有效验收次数压到低一级门槛,保留真实订单与资金流水。 */ +async function applyWorkerCancelRequestPenaltyWithClient( + client: PoolClient, + input: { workerId: number; requestId: number; now: string }, +): Promise<{ levelBeforeId: number | null; levelChanged: boolean; accountDisabled: boolean }> { + const workerResult = await client.query<{ + level_id: number | null + level_key: string + level_name: string + sort_order: number + level_penalty_accepted_count: number + }>( + `SELECT wu.level_id, wl.level_key, wl.name AS level_name, wl.sort_order, + wu.level_penalty_accepted_count + FROM worker_users wu + LEFT JOIN worker_levels wl ON wl.id = wu.level_id + WHERE wu.id = $1 + FOR UPDATE OF wu`, + [input.workerId], + ) + const worker = workerResult.rows[0] + const levelBeforeId = worker?.level_id ? Number(worker.level_id) : null + if (!worker || !levelBeforeId) { + return { levelBeforeId, levelChanged: false, accountDisabled: false } + } + + const approvedResult = await client.query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM worker_order_cancel_requests + WHERE worker_id = $1 AND status = 'approved'`, + [input.workerId], + ) + const approvedTotal = Number(approvedResult.rows[0]?.total || 0) + 1 + const isVip1 = String(worker.level_key || '').toLowerCase() === 'vip1' + + if (isVip1) { + const vip1ApprovedResult = await client.query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM worker_order_cancel_requests + WHERE worker_id = $1 AND status = 'approved' AND level_before_id = $2`, + [input.workerId, levelBeforeId], + ) + const vip1ApprovedTotal = Number(vip1ApprovedResult.rows[0]?.total || 0) + 1 + if (vip1ApprovedTotal >= 5) { + await client.query( + `UPDATE worker_users + SET status = 'disabled', session_version = session_version + 1, updated_at = $1 + WHERE id = $2`, + [input.now, input.workerId], + ) + await client.query( + `UPDATE worker_sessions + SET status = 'revoked', revoked_at = $1, updated_at = $1 + WHERE worker_id = $2 AND status = 'active'`, + [input.now, input.workerId], + ) + await client.query( + `INSERT INTO worker_level_logs ( + worker_id, from_level_id, to_level_id, reason, payload_json, created_at + ) VALUES ($1, $2, $2, 'cancel_request_ban', $3::jsonb, $4)`, + [ + input.workerId, + levelBeforeId, + JSON.stringify({ requestId: input.requestId, vip1ApprovedTotal, approvedTotal }), + input.now, + ], + ) + return { levelBeforeId, levelChanged: false, accountDisabled: true } + } + return { levelBeforeId, levelChanged: false, accountDisabled: false } + } + + if (approvedTotal % 10 !== 0) { + return { levelBeforeId, levelChanged: false, accountDisabled: false } + } + + const lowerLevelResult = await client.query<{ + id: number + level_key: string + threshold: number + }>( + `SELECT id, level_key, COALESCE((permission_json->>'upgradeThreshold')::int, 0) AS threshold + FROM worker_levels + WHERE status = 'active' AND sort_order < $1 + ORDER BY sort_order DESC, id DESC + LIMIT 1`, + [Number(worker.sort_order || 0)], + ) + const lowerLevel = lowerLevelResult.rows[0] + if (!lowerLevel) { + return { levelBeforeId, levelChanged: false, accountDisabled: false } + } + const actualAcceptedCount = await countWorkerAcceptedOrdersWithClient(client, input.workerId) + const nextPenaltyAcceptedCount = Math.max( + Number(worker.level_penalty_accepted_count || 0), + actualAcceptedCount - Math.max(0, Number(lowerLevel.threshold || 0)), + ) + await client.query( + `UPDATE worker_users + SET level_id = $1, level_penalty_accepted_count = $2, updated_at = $3 + WHERE id = $4`, + [lowerLevel.id, nextPenaltyAcceptedCount, input.now, input.workerId], + ) + await client.query( + `INSERT INTO worker_level_logs ( + worker_id, from_level_id, to_level_id, reason, payload_json, created_at + ) VALUES ($1, $2, $3, 'cancel_request_penalty', $4::jsonb, $5)`, + [ + input.workerId, + levelBeforeId, + lowerLevel.id, + JSON.stringify({ + requestId: input.requestId, + approvedTotal, + actualAcceptedCount, + penaltyAcceptedCount: nextPenaltyAcceptedCount, + }), + input.now, + ], + ) + return { levelBeforeId, levelChanged: true, accountDisabled: false } +} + async function getOutstandingDepositAmountWithClient( client: PoolClient, workerId: number, diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index ca966671..9dfa19fa 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -1087,12 +1087,14 @@ export async function maybeUpgradeWorkerLevelWithClient( const current = await client.query<{ id: number level_id: number | null + level_penalty_accepted_count: number upgrade_threshold: number | null }>( ` SELECT wu.id, wu.level_id, + wu.level_penalty_accepted_count, COALESCE((wl.permission_json->>'upgradeThreshold')::int, 0) AS upgrade_threshold FROM worker_users wu LEFT JOIN worker_levels wl ON wl.id = wu.level_id @@ -1134,6 +1136,10 @@ export async function maybeUpgradeWorkerLevelWithClient( [workerId], ) const acceptedCount = Number(acceptedResult.rows[0]?.total || 0) + const effectiveAcceptedCount = Math.max( + 0, + acceptedCount - Number(currentRow.level_penalty_accepted_count || 0), + ) const nextLevelResult = await client.query<{ id: number @@ -1149,7 +1155,7 @@ export async function maybeUpgradeWorkerLevelWithClient( ORDER BY COALESCE((permission_json->>'upgradeThreshold')::int, 0) DESC, sort_order ASC LIMIT 1 `, - [acceptedCount], + [effectiveAcceptedCount], ) const nextLevel = nextLevelResult.rows[0] const nextLevelId = Number(nextLevel?.id || 0) @@ -1176,7 +1182,13 @@ export async function maybeUpgradeWorkerLevelWithClient( worker_id, from_level_id, to_level_id, reason, payload_json, created_at ) VALUES ($1, $2, $3, 'auto', $4::jsonb, $5) `, - [workerId, currentRow.level_id || null, nextLevelId, JSON.stringify({ acceptedCount }), now], + [ + workerId, + currentRow.level_id || null, + nextLevelId, + JSON.stringify({ acceptedCount, effectiveAcceptedCount }), + now, + ], ) return getWorkerUserByIdWithClient(client, workerId) } diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index d2decc53..a6a34f09 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -31,12 +31,14 @@ import { listAdminWorkerLevels, listAdminWorkerUsers, listAdminWorkOrders, + listAdminWorkerCancelRequests, markAdminWorkOrderProblem, pinAdminWorkOrder, publishAdminWorkOrder, reopenAdminCancelledWorkOrder, reprocessAdminKuaishouSendCodeWorkOrders, reviewAdminWorkerFinanceRequest, + reviewAdminWorkerCancelRequest, resolveAdminProblemWorkOrder, reviewAdminWorkerUser, resetAdminWorkerPassword, @@ -483,6 +485,40 @@ router.get( }), ) +router.get( + '/worker-platform/cancel-requests', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => listAdminWorkerCancelRequests(req.query), { + successMessage: 'ok', + errorMessage: '读取撤单申请失败', + scope: '[admin/worker-platform/cancel-requests]', + }), +) + +router.post( + '/worker-platform/cancel-requests/:requestId/review', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + reviewAdminWorkerCancelRequest( + String(req.params.requestId || ''), + req.body || {}, + req.adminSession?.username || '', + ), + { + successMessage: '撤单申请已审核', + errorMessage: '审核撤单申请失败', + scope: '[admin/worker-platform/cancel-requests/:requestId/review]', + audit: (req, data) => ({ + action: 'worker_cancel_request_reviewed', + targetType: 'worker_cancel_request', + targetId: String(req.params.requestId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/mock', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index c04f9955..1572801f 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -37,6 +37,8 @@ import { remindWorkerOrderAcceptance, supplementWorkerAcceptedOrderEvidence, submitWorkerOrderAcceptance, + getWorkerCancelRequestSummary, + submitWorkerCancelRequest, } from '../services/worker-platform/index.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' import { @@ -440,6 +442,34 @@ router.get( }), ) +router.get( + '/orders/cancel-requests', + requireActiveWorker, + createRouteHandler((req) => getWorkerCancelRequestSummary(getRequiredWorkerSession(req)), { + successMessage: 'ok', + errorMessage: '读取取消申请失败', + scope: '[worker/orders/cancel-requests]', + }), +) + +router.post( + '/orders/:workOrderId/cancel-requests', + requireActiveWorker, + createRouteHandler( + (req) => + submitWorkerCancelRequest( + String(req.params.workOrderId || ''), + req.body || {}, + getRequiredWorkerSession(req), + ), + { + successMessage: '取消申请已提交,等待客服审核', + errorMessage: '提交取消申请失败', + scope: '[worker/orders/:workOrderId/cancel-requests]', + }, + ), +) + router.get( '/orders/:workOrderId/events', requireActiveWorker, diff --git a/apps/backend/src/services/admin/admin-notification-service.ts b/apps/backend/src/services/admin/admin-notification-service.ts index b55b3199..6c3d1805 100644 --- a/apps/backend/src/services/admin/admin-notification-service.ts +++ b/apps/backend/src/services/admin/admin-notification-service.ts @@ -96,6 +96,32 @@ export async function createWorkerAcceptanceAdminNotification(input: { }) } +export async function createWorkerCancelRequestAdminNotification(input: { + requestId: number + workOrderId: number + workOrderNo: string + productName: string + workerName: string + reason: string +}) { + return createConfiguredWorkerPlatformNotification('cancel_requested', { + notificationType: 'worker_cancel_requested', + priority: 'high', + entityType: 'worker_cancel_request', + entityId: input.requestId, + dedupeKey: `worker_cancel_request:${input.requestId}`, + title: '有打手撤单申请待审核', + body: [ + `订单:${input.workOrderNo || input.workOrderId}`, + `商品:${input.productName || '-'}`, + `打手:${input.workerName || '-'}`, + `原因:${input.reason || '-'}`, + ].join(';'), + actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=cancel-requests&cancelRequestId=${input.requestId}`, + now: new Date().toISOString(), + }) +} + export async function remindWorkerAcceptanceAdminNotification(workOrderId: number) { const now = new Date().toISOString() const eventConfig = getWorkerPlatformNotificationConfig().events.acceptance_reminded diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index a13bb83e..90c5cd6f 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -50,6 +50,8 @@ import { resolveProblemWorkOrder, reviewWorkerFinanceRequest, reopenCancelledWorkOrder, + listAdminWorkerCancelRequests as listWorkerCancelRequestsForAdmin, + reviewWorkerCancelRequest, updateWorkOrder, updateWorkOrderBasic, updateWorkerUser, @@ -108,6 +110,7 @@ import { mapWorkProductRule, mapWorkerLevel, mapWorkerUser, + mapWorkerCancelRequest, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, @@ -1355,6 +1358,74 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { } } +export async function listAdminWorkerCancelRequests(query: JsonObject = {}) { + const status = String(query.status || 'pending').trim() + const normalizedStatus = ['pending', 'approved', 'rejected'].includes(status) ? status : 'pending' + return { + items: (await listWorkerCancelRequestsForAdmin(normalizedStatus)).map(mapWorkerCancelRequest), + } +} + +export async function reviewAdminWorkerCancelRequest( + requestId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const approved = payload.approved === true || String(payload.action || '').trim() === 'approve' + const reviewNote = String(payload.reviewNote || payload.note || '').trim() + if (!reviewNote) { + throw createHttpError('请填写审核备注', { + statusCode: 400, + errorCode: 'worker_cancel_request_review_note_required', + }) + } + const result = await reviewWorkerCancelRequest({ + requestId: Number(requestId), + approved, + reviewNote, + actorName, + now: nowIso(), + }) + if (result.failureReason === 'request_not_found') { + throw createHttpError('撤单申请不存在', { + statusCode: 404, + errorCode: 'worker_cancel_request_not_found', + }) + } + if (result.failureReason === 'request_not_pending') { + throw createHttpError('该撤单申请已审核,请刷新后重试', { + statusCode: 409, + errorCode: 'worker_cancel_request_not_pending', + }) + } + if (!result.request || !result.order) { + throw createHttpError('订单状态已变化,无法审核该撤单申请', { + statusCode: 409, + errorCode: 'worker_cancel_request_target_changed', + }) + } + await resolveAdminNotificationEntity( + 'worker_cancel_request', + Number(result.request.id), + approved ? 'approved' : 'rejected', + ) + const workerId = Number(result.request.worker_id) + publishWorkOrderRealtimeChange({ + workOrderId: Number(result.order.id), + workerIds: workerId > 0 ? [workerId] : [], + hallChanged: approved, + }) + if (approved && workerId > 0) publishWorkerWalletRealtimeChange(workerId) + return { + request: mapWorkerCancelRequest(result.request), + order: mapWorkOrderAdmin(result.order), + approved, + releasedDepositAmount: result.releasedDepositAmount, + levelChanged: result.levelChanged, + accountDisabled: result.accountDisabled, + } +} + export async function deductAdminWorkOrderPendingDeposit( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 8321c15f..bf14f0f5 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -257,6 +257,41 @@ export function mapWorkerUser(worker: WorkerUserRow) { } } +export function mapWorkerCancelRequest(request: { + id: number + work_order_id: number + work_order_share_id: number | null + worker_id: number + reason: string + status: string + review_note: string + reviewed_by: string + created_at: string + reviewed_at: string | null + work_order_no?: string + product_name?: string + worker_username?: string + worker_display_name?: string + share_quantity?: number | null +}) { + return { + requestId: Number(request.id), + workOrderId: Number(request.work_order_id), + shareId: request.work_order_share_id ? Number(request.work_order_share_id) : null, + workerId: Number(request.worker_id), + workOrderNo: String(request.work_order_no || ''), + productName: String(request.product_name || ''), + workerName: String(request.worker_display_name || request.worker_username || ''), + shareQuantity: request.share_quantity ? Number(request.share_quantity) : null, + reason: String(request.reason || ''), + status: String(request.status || ''), + reviewNote: String(request.review_note || ''), + reviewedBy: String(request.reviewed_by || ''), + createdAt: request.created_at, + reviewedAt: request.reviewed_at, + } +} + export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) { if (!ledger) return null return { 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 2fd90adc..74d5272f 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 @@ -8,6 +8,7 @@ export const WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS = [ 'acceptance_submitted', 'acceptance_reminded', 'material_required', + 'cancel_requested', ] as const export type WorkerPlatformNotificationEventKey = @@ -51,6 +52,7 @@ export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformN soundEnabled: false, externalPushEnabled: false, }, + cancel_requested: createEnabledEventConfig(), }, } } diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 240a27bf..a1b9fa0c 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -6,6 +6,7 @@ import { acceptWorkOrderAndSettle, acceptWorkOrderShareAndSettle, countWorkerAcceptedOrders, + createWorkerCancelRequest, countWorkerActiveOrders, countWorkerTimeoutEvents, countWorkerWithdrawRequestsOnDay, @@ -42,6 +43,7 @@ import { listPendingMaterialWorkOrdersByPlatformOrderId, listWorkerFinanceRequests, listWorkerCompletionLeaderboard, + listWorkerCancelRequests, listWorkerSharesByWorker, listWorkerWalletLedgers, listWorkerWorkOrderNotes, @@ -53,6 +55,7 @@ import { listWorkOrderShares, listWorkOrderSharesByOrderIds, listWorkerLevels, + getWorkerCancelRequestRisk, listDueDepositUnfreezes, listOverdueWorkOrders, releaseDepositUnfreeze, @@ -82,6 +85,7 @@ import { getWorkerFinanceConfig } from './worker-finance-config-service.js' import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' import { createWorkerAcceptanceAdminNotification, + createWorkerCancelRequestAdminNotification, createWorkerRechargeAdminNotification, createWorkerWithdrawAdminNotification, getWorkerAcceptanceReminder, @@ -111,6 +115,7 @@ import { mapWorkOrderPublic, mapWorkOrderEvents, mapWorkOrderShare, + mapWorkerCancelRequest, mapWorkerUser, normalizeAmountFen, assertWithdrawChannelAllowedForAmount, @@ -1738,6 +1743,82 @@ export async function getWorkerMyOrderEvents(workOrderId: number | string, sessi return { items: await mapWorkOrderEvents(events) } } +export async function getWorkerCancelRequestSummary(session: WorkerSession) { + requireActiveWorkerSession(session) + const [risk, requests] = await Promise.all([ + getWorkerCancelRequestRisk(session.workerId, nowIso()), + listWorkerCancelRequests(session.workerId), + ]) + return { risk, items: requests.map(mapWorkerCancelRequest) } +} + +export async function submitWorkerCancelRequest( + workOrderId: number | string, + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const reason = String(payload.reason || '').trim() + if (!reason) { + throw createHttpError('请填写申请取消原因', { + statusCode: 400, + errorCode: 'worker_cancel_request_reason_required', + }) + } + if (reason.length > 300) { + throw createHttpError('申请取消原因不能超过 300 个字符', { + statusCode: 400, + errorCode: 'worker_cancel_request_reason_too_long', + }) + } + const now = nowIso() + const result = await createWorkerCancelRequest({ + workOrderId: Number(workOrderId), + workerId: Number(session.workerId), + reason, + now, + }) + if (result.failureReason === 'daily_limit_reached') { + throw createHttpError('今日最多申请取消 5 次,请明日再试', { + statusCode: 429, + errorCode: 'worker_cancel_request_daily_limit', + }) + } + if (result.failureReason === 'request_pending') { + throw createHttpError('该订单已有待审核的取消申请', { + statusCode: 409, + errorCode: 'worker_cancel_request_pending', + }) + } + if (result.failureReason === 'share_not_cancellable') { + throw createHttpError('当前拼单份额不能申请取消', { + statusCode: 409, + errorCode: 'worker_cancel_request_share_status_invalid', + }) + } + if (!result.request) { + throw createHttpError('当前订单不能申请取消', { + statusCode: 409, + errorCode: 'worker_cancel_request_status_invalid', + }) + } + const worker = await getRequiredWorker(session.workerId) + await createWorkerCancelRequestAdminNotification({ + requestId: Number(result.request.id), + workOrderId: Number(result.request.work_order_id), + workOrderNo: result.request.work_order_no || '', + productName: result.request.product_name || '', + workerName: worker.display_name || worker.username, + reason, + }) + const risk = await getWorkerCancelRequestRisk(session.workerId, now) + publishWorkOrderRealtimeChange({ + workOrderId: Number(result.request.work_order_id), + workerIds: [Number(session.workerId)], + }) + return { request: mapWorkerCancelRequest(result.request), risk } +} + export async function saveWorkerOrderNote( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index 1ee420a7..f5712b8c 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -95,6 +95,21 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { } case 'cancelled_by_worker': return { title: '打手取消接单', color: 'red' } + case 'cancel_request_submitted': + return { + title: note ? `打手提交撤单申请:${note}` : '打手提交撤单申请,等待客服审核', + color: 'orange', + } + case 'cancel_request_rejected': + return { + title: note ? `客服驳回撤单申请:${note}` : '客服驳回撤单申请', + color: 'green', + } + case 'cancel_request_approved': + return { + title: note ? `客服同意撤单申请:${note}` : '客服同意撤单申请,订单已回到大厅', + color: 'red', + } case 'timeout_reopen': return { title: '订单超时未完成,已退回抢单大厅', color: 'orange' } diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index f76be514..89de0025 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -7,6 +7,7 @@ import PageHeader from '@/components/admin/PageHeader' import { fetchAdminWorkerPlatformSummary } from '@/services/admin' import { getAdminRole } from '@/utils/admin-auth' import CategoriesPanel from './panels/CategoriesPanel' +import CancelRequestsPanel from './panels/CancelRequestsPanel' import FinancePanel from './panels/FinancePanel' import LevelsPanel from './panels/LevelsPanel' import NotificationsPanel from './panels/NotificationsPanel' @@ -21,8 +22,7 @@ export default function AdminWorkerPlatformPage() { const role = getAdminRole() const isAdmin = role === 'admin' const isSupport = role === 'support' - const visibleActiveTab = - isSupport || (!isAdmin && activeTab === 'notifications') ? 'orders' : activeTab + const visibleActiveTab = !isAdmin && activeTab === 'notifications' ? 'orders' : activeTab useEffect(() => { const tab = searchParams.get('tab') @@ -46,9 +46,13 @@ export default function AdminWorkerPlatformPage() { onChange={changeTab} items={ isSupport - ? [{ key: 'orders', label: '接单工单', children: }] + ? [ + { key: 'orders', label: '接单工单', children: }, + { key: 'cancel-requests', label: '撤单审核', children: }, + ] : [ { key: 'orders', label: '接单工单', children: }, + { key: 'cancel-requests', label: '撤单审核', children: }, { key: 'rules', label: '接单模板', children: }, { key: 'product-match', label: '匹配诊断', children: }, { key: 'categories', label: '分类', children: }, @@ -70,6 +74,7 @@ const ACTIVE_TAB_STORAGE_KEY = 'admin-worker-platform-tab' function loadActiveTabPreference(): string { const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY) return saved === 'orders' || + saved === 'cancel-requests' || saved === 'rules' || saved === 'product-match' || saved === 'categories' || @@ -88,6 +93,7 @@ function loadActiveTab(tab: string | null): string { function isWorkerPlatformTab(tab: string | null): tab is string { return [ 'orders', + 'cancel-requests', 'rules', 'product-match', 'categories', diff --git a/apps/frontend/src/pages/admin/panels/CancelRequestsPanel.tsx b/apps/frontend/src/pages/admin/panels/CancelRequestsPanel.tsx new file mode 100644 index 00000000..356a0906 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/CancelRequestsPanel.tsx @@ -0,0 +1,203 @@ +import { ReloadOutlined } from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { App, Button, Card, Form, Input, Modal, Space, Table, Tag, Typography } from 'antd' +import type { TableColumnsType } from 'antd' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router' + +import { fetchAdminWorkerCancelRequests, reviewAdminWorkerCancelRequest } from '@/services/admin' +import type { WorkerCancelRequest } from '@/types/worker-platform' +import { formatAdminDateTime } from '@/utils/admin-time' + +type ReviewFormValues = { + reviewNote?: string +} + +export default function CancelRequestsPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [searchParams, setSearchParams] = useSearchParams() + const [reviewTarget, setReviewTarget] = useState<{ + request: WorkerCancelRequest + approved: boolean + } | null>(null) + const [form] = Form.useForm() + const requestsQuery = useQuery({ + queryKey: ['admin-worker-cancel-requests', 'pending'], + queryFn: () => fetchAdminWorkerCancelRequests('pending'), + }) + + useEffect(() => { + const requestId = Number(searchParams.get('cancelRequestId') || 0) + if (!Number.isInteger(requestId) || requestId <= 0) return + const request = (requestsQuery.data?.data.items || []).find( + (item) => item.requestId === requestId, + ) + if (!request) return + setReviewTarget({ request, approved: true }) + form.resetFields() + setSearchParams( + (current) => { + current.delete('cancelRequestId') + return current + }, + { replace: true }, + ) + }, [form, requestsQuery.data, searchParams, setSearchParams]) + + async function refreshAll() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-worker-cancel-requests'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }), + queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }), + ]) + } + + function openReview(request: WorkerCancelRequest, approved: boolean) { + setReviewTarget({ request, approved }) + form.resetFields() + } + + async function submitReview(values: ReviewFormValues) { + if (!reviewTarget) return + try { + const response = await reviewAdminWorkerCancelRequest(reviewTarget.request.requestId, { + approved: reviewTarget.approved, + reviewNote: String(values.reviewNote || '').trim(), + }) + const result = response.data + const resultText = result.accountDisabled + ? ',该打手已封禁' + : result.levelChanged + ? ',该打手已降一级 VIP' + : '' + message.success(`${result.approved ? '已同意' : '已驳回'}撤单申请${resultText}`) + setReviewTarget(null) + form.resetFields() + await refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '审核撤单申请失败') + } + } + + const columns: TableColumnsType = [ + { + title: '订单', + width: 260, + render: (_, request) => ( +
+ {request.productName || '-'} + {request.workOrderNo || '-'} +
+ ), + }, + { + title: '打手 / 份数', + width: 180, + render: (_, request) => ( +
+ {request.workerName || `打手 #${request.workerId}`} + {request.shareQuantity ? ( + 拼单 {request.shareQuantity} 份 + ) : ( + 整单 + )} +
+ ), + }, + { + title: '申请原因', + render: (_, request) => ( + {request.reason} + ), + }, + { + title: '申请时间', + width: 180, + render: (_, request) => formatAdminDateTime(request.createdAt), + }, + { + title: '操作', + width: 180, + render: (_, request) => ( + + + + + ), + }, + ] + + return ( + <> + } + loading={requestsQuery.isFetching} + onClick={() => requestsQuery.refetch()} + > + 刷新 + + } + bordered={false} + > + + rowKey="requestId" + size="small" + loading={requestsQuery.isLoading} + dataSource={requestsQuery.data?.data.items || []} + columns={columns} + pagination={false} + locale={{ emptyText: '暂无待审核撤单申请' }} + scroll={{ x: 900 }} + /> + + { + setReviewTarget(null) + form.resetFields() + }} + onOk={() => form.submit()} + okText={reviewTarget?.approved ? '确认同意' : '确认驳回'} + okButtonProps={reviewTarget?.approved ? undefined : { danger: true }} + destroyOnHidden + > + {reviewTarget ? ( + + + {reviewTarget.request.workerName || `打手 #${reviewTarget.request.workerId}`}: + {reviewTarget.request.reason} + + + {reviewTarget.approved + ? '同意后将退还冻结押金,并将整单或对应拼单份额回到大厅;该申请会计入撤单处罚次数。' + : '驳回后不会计入累计次数,且会释放本次申请的当天额度。'} + +
+ + + +
+
+ ) : null} +
+ + ) +} diff --git a/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx b/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx index b84faeea..5eafa4c2 100644 --- a/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/NotificationsPanel.tsx @@ -24,6 +24,7 @@ const notificationEventRows: NotificationEventRow[] = [ { key: 'acceptance_submitted', name: '提交验收', description: '打手提交订单验收资料' }, { key: 'acceptance_reminded', name: '催验收', description: '打手在验收后催促处理' }, { key: 'material_required', name: '待补资料工单', description: '新工单等待后台补充资料' }, + { key: 'cancel_requested', name: '撤单申请', description: '打手提交新的撤单申请,等待客服审核' }, ] export default function NotificationsPanel() { diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index d6541997..5dccf123 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -267,7 +267,6 @@ export default function WorkOrdersPanel() { }), enabled: Boolean(assignOrder), }) - useEffect(() => { setSelectedAcceptanceOrderIds([]) }, [statuses, keyword, workerId, workerLevelIds, targetWorkOrderId, page, pageSize]) @@ -1327,7 +1326,6 @@ export default function WorkOrdersPanel() { ) : null} - worker.displayName || worker.username || `打手#${worker.workerId}`) .join('、') - const quantity = order.sharingWorkers.reduce((sum, worker) => sum + Number(worker.quantity || 0), 0) + const quantity = order.sharingWorkers.reduce( + (sum, worker) => sum + Number(worker.quantity || 0), + 0, + ) return (
{names} diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index fad774ff..f6315546 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -34,6 +34,7 @@ import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import ImagePreviewList from '@/components/files/ImagePreviewList' import ImageUpload from '@/components/files/ImageUpload' import { + fetchWorkerCancelRequests, fetchWorkerMyOrderOverview, fetchWorkerMyOrders, fetchWorkerProfile, @@ -43,6 +44,7 @@ import { saveWorkerOrderNote, supplementWorkerAcceptedOrderEvidence, submitWorkerAcceptance, + submitWorkerCancelRequest, } from '@/services/worker' import type { CollectField, @@ -73,6 +75,10 @@ type WorkerNoteFormValues = { note?: string } +type CancelRequestFormValues = { + reason?: string +} + function buildWorkerOrderQuery(status: string, keyword: string, page: number, pageSize: number) { return { keyword, @@ -194,9 +200,11 @@ export default function WorkerOrdersPage() { const [detailOrder, setDetailOrder] = useState(null) const [editingOrder, setEditingOrder] = useState(null) const [noteOrder, setNoteOrder] = useState(null) + const [cancelRequestOrder, setCancelRequestOrder] = useState(null) const [acceptanceFiles, setAcceptanceFiles] = useState([]) const [form] = Form.useForm() const [noteForm] = Form.useForm() + const [cancelRequestForm] = Form.useForm() const ordersQuery = useQuery({ queryKey: ['worker-my-orders', status, keyword, page, pageSize], @@ -221,6 +229,11 @@ export default function WorkerOrdersPage() { queryFn: fetchWorkerMyOrderOverview, retry: false, }) + const cancelRequestsQuery = useQuery({ + queryKey: ['worker-cancel-requests'], + queryFn: fetchWorkerCancelRequests, + retry: false, + }) const overviewByStatus = new Map( (overviewQuery.data?.data.items || []).map((item) => [item.status, item]), ) @@ -229,6 +242,7 @@ export default function WorkerOrdersPage() { await Promise.all([ queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }), queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }), + queryClient.invalidateQueries({ queryKey: ['worker-cancel-requests'] }), ]) } @@ -314,6 +328,27 @@ export default function WorkerOrdersPage() { noteForm.setFieldsValue({ note: String(order.workerNote || '') }) } + function openCancelRequestModal(order: WorkOrder) { + markOrderAsRead(order) + setCancelRequestOrder(order) + cancelRequestForm.resetFields() + } + + async function submitCancelRequest(values: CancelRequestFormValues) { + if (!cancelRequestOrder) return + try { + await submitWorkerCancelRequest(cancelRequestOrder.workOrderId, { + reason: String(values.reason || '').trim(), + }) + message.success('取消申请已提交,等待客服审核') + setCancelRequestOrder(null) + cancelRequestForm.resetFields() + await refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '提交取消申请失败') + } + } + async function saveOrderNote(values: WorkerNoteFormValues) { if (!noteOrder) return try { @@ -644,6 +679,11 @@ export default function WorkerOrdersPage() { {canRemindAcceptance(row) ? ( ) : null} + {canRequestCancel(row) ? ( + + ) : null}
), @@ -950,6 +990,11 @@ export default function WorkerOrdersPage() { 催验收 ) : null} + {canRequestCancel(order) ? ( + + ) : null} @@ -1113,6 +1158,11 @@ export default function WorkerOrdersPage() { {hasSubmittedAcceptance(detailOrder) ? '补充图片' : '上传图片 / 提交验收'} ) : null} + {canRequestCancel(detailOrder) ? ( + + ) : null} ) : null } @@ -1229,6 +1279,46 @@ export default function WorkerOrdersPage() { ) : null} + { + setCancelRequestOrder(null) + cancelRequestForm.resetFields() + }} + onOk={() => cancelRequestForm.submit()} + okText="提交申请" + destroyOnHidden + > + + + 提交后由客服审核;通过后退还本次冻结押金,订单或拼单份额将回到大厅。 + + + 今日申请:{cancelRequestsQuery.data?.data.risk.dailyUsed || 0}/ + {cancelRequestsQuery.data?.data.risk.dailyLimit || 5};累计通过: + {cancelRequestsQuery.data?.data.risk.approvedTotal || 0} 次。 + + + 每累计 10 次通过申请最多降一级;VIP1 期间累计 5 次通过将封禁账号。 + + +
+ + + +
+
('/api/v1/admin/worker-platform/summary') } +export function fetchAdminWorkerCancelRequests( + status: 'pending' | 'approved' | 'rejected' = 'pending', +) { + return apiGet<{ items: WorkerCancelRequest[] }>('/api/v1/admin/worker-platform/cancel-requests', { + status, + }) +} + +export function reviewAdminWorkerCancelRequest( + requestId: number, + payload: { approved: boolean; reviewNote: string }, +) { + return apiPost<{ + request: WorkerCancelRequest + order: WorkOrder + approved: boolean + releasedDepositAmount: number + levelChanged: boolean + accountDisabled: boolean + }>(`/api/v1/admin/worker-platform/cancel-requests/${requestId}/review`, payload) +} + export function fetchAdminWorkerLevels() { return apiGet<{ items: WorkerLevel[] }>('/api/v1/admin/worker-platform/levels') } diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 934acb99..67323675 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -6,6 +6,8 @@ import type { WorkOrderEvent, WorkOrderShare, WorkerFinanceRequest, + WorkerCancelRequest, + WorkerCancelRequestRisk, WorkerHallOrdersResponse, WorkerLeaderboardResponse, WorkerLeaderboardPeriod, @@ -211,6 +213,19 @@ export function fetchWorkerMyOrderEvents(workOrderId: number) { return apiGet<{ items: WorkOrderEvent[] }>(`/api/v1/worker/orders/${workOrderId}/events`) } +export function fetchWorkerCancelRequests() { + return apiGet<{ risk: WorkerCancelRequestRisk; items: WorkerCancelRequest[] }>( + '/api/v1/worker/orders/cancel-requests', + ) +} + +export function submitWorkerCancelRequest(workOrderId: number, payload: { reason: string }) { + return apiPost<{ request: WorkerCancelRequest; risk: WorkerCancelRequestRisk }>( + `/api/v1/worker/orders/${workOrderId}/cancel-requests`, + payload, + ) +} + export function markWorkerOrderRead(workOrderId: number) { return apiPost<{ viewedAt: string; isUnread: false }>(`/api/v1/worker/orders/${workOrderId}/read`) } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index fa2080ac..0eb2e286 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -100,6 +100,31 @@ export type WorkerFinanceRequest = { } } +export type WorkerCancelRequest = { + requestId: number + workOrderId: number + shareId: number | null + workerId: number + workOrderNo: string + productName: string + workerName: string + shareQuantity: number | null + reason: string + status: 'pending' | 'approved' | 'rejected' | string + reviewNote: string + reviewedBy: string + createdAt: string + reviewedAt: string | null +} + +export type WorkerCancelRequestRisk = { + dailyUsed: number + dailyLimit: number + approvedTotal: number + nextPenaltyAt: number + remainingToPenalty: number +} + export type WorkerFinanceConfig = { depositUnfreezeDays: number recharge: { @@ -125,6 +150,7 @@ export type WorkerPlatformNotificationEventKey = | 'acceptance_submitted' | 'acceptance_reminded' | 'material_required' + | 'cancel_requested' export type WorkerPlatformNotificationConfig = { events: Record<