diff --git a/apps/backend/src/db/migrations/010_worker_deposit_unfreeze.sql b/apps/backend/src/db/migrations/010_worker_deposit_unfreeze.sql new file mode 100644 index 00000000..ac4fb12f --- /dev/null +++ b/apps/backend/src/db/migrations/010_worker_deposit_unfreeze.sql @@ -0,0 +1,22 @@ +ALTER TABLE worker_wallets + ADD COLUMN pending_unfreeze_amount INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS worker_deposit_unfreezes ( + id BIGSERIAL PRIMARY KEY, + worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE, + work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + amount INTEGER NOT NULL, + unfreeze_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + released_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_worker_deposit_unfreezes_scan + ON worker_deposit_unfreezes(status, unfreeze_at); +CREATE INDEX idx_worker_deposit_unfreezes_worker + ON worker_deposit_unfreezes(worker_id, status); + +COMMENT ON COLUMN worker_wallets.pending_unfreeze_amount IS '待解冻押金:验收通过后 3 天到账'; +COMMENT ON TABLE worker_deposit_unfreezes IS '押金解冻计划:验收通过后进入待解冻,到期自动转入可用余额'; diff --git a/apps/backend/src/db/migrations/011_work_order_last_worker.sql b/apps/backend/src/db/migrations/011_work_order_last_worker.sql new file mode 100644 index 00000000..ac369893 --- /dev/null +++ b/apps/backend/src/db/migrations/011_work_order_last_worker.sql @@ -0,0 +1,7 @@ +ALTER TABLE work_orders + ADD COLUMN last_assigned_worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL; + +CREATE INDEX idx_work_orders_last_worker + ON work_orders(last_assigned_worker_id, status, id DESC); + +COMMENT ON COLUMN work_orders.last_assigned_worker_id IS '最近一次接单的打手,超时/取消后保留,用于打手查看历史记录'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 8bb20502..79501587 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -33,6 +33,7 @@ export type WorkerUserRow = { level_permission_json?: string | Record available_amount?: number frozen_deposit_amount?: number + pending_unfreeze_amount?: number total_credited_amount?: number total_settled_amount?: number } @@ -41,12 +42,25 @@ export type WorkerWalletRow = { worker_id: number available_amount: number frozen_deposit_amount: number + pending_unfreeze_amount: number total_credited_amount: number total_settled_amount: number created_at: string updated_at: string } +export type WorkerDepositUnfreezeRow = { + id: number + worker_id: number + work_order_id: number + amount: number + unfreeze_at: string + status: string + released_at: string | null + created_at: string + updated_at: string +} + export type WorkerWalletLedgerRow = { id: number worker_id: number @@ -150,6 +164,7 @@ export type WorkOrderRow = { required_deposit_amount: number deposit_threshold_amount: number assigned_worker_id: number | null + last_assigned_worker_id: number | null material_json: string | Record requirement_json: string | Record acceptance_json: string | Record 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 6d9b09b7..e9f0dbcb 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -1,6 +1,6 @@ import { query, withTransaction } from '../../db/client.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, toJsonString, toPositiveInteger } from './shared.js' -import type { CreateWorkOrderInput, GrabWorkOrderResult, ListInput, ProblemWorkOrderResolutionAction, ProductRuleListInput, WorkCategoryRow, WorkOrderRow, WorkOrderShareRow, WorkProductRuleRow } from './types.js' +import type { CreateWorkOrderInput, GrabWorkOrderResult, ListInput, ProblemWorkOrderResolutionAction, ProductRuleListInput, WorkCategoryRow, WorkerDepositUnfreezeRow, WorkOrderRow, WorkOrderShareRow, WorkProductRuleRow } from './types.js' import type { PoolClient } from 'pg' import { maybeUpgradeWorkerLevelWithClient } from './worker-repo.js' @@ -772,6 +772,7 @@ export async function grabWorkOrder(input: { SET status = 'in_progress', assigned_worker_id = $1, + last_assigned_worker_id = $1, assigned_at = $2, deadline_at = $3, updated_at = $2 @@ -946,6 +947,7 @@ export async function countWorkerCancellationsSince( export async function acceptWorkOrderAndSettle(input: { workOrderId: number + unfreezeDays: number now: string }): Promise<{ order: WorkOrderRow | null @@ -1009,8 +1011,11 @@ export async function acceptWorkOrderAndSettle(input: { Number(wallet?.frozen_deposit_amount || 0), ) const shareReward = Number(share.share_reward || 0) + const shouldDelayUnfreeze = input.unfreezeDays > 0 const nextAvailable = - Number(wallet?.available_amount || 0) + releaseAmount + shareReward + Number(wallet?.available_amount || 0) + + shareReward + + (shouldDelayUnfreeze ? 0 : releaseAmount) const nextFrozen = Math.max( 0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount, @@ -1022,31 +1027,70 @@ export async function acceptWorkOrderAndSettle(input: { SET available_amount = $1, frozen_deposit_amount = $2, - total_settled_amount = total_settled_amount + $3, - updated_at = $4 - WHERE worker_id = $5 + pending_unfreeze_amount = pending_unfreeze_amount + $3, + total_settled_amount = total_settled_amount + $4, + updated_at = $5 + WHERE worker_id = $6 `, - [nextAvailable, nextFrozen, shareReward, input.now, shareWorkerId], + [ + nextAvailable, + nextFrozen, + shouldDelayUnfreeze ? releaseAmount : 0, + shareReward, + input.now, + shareWorkerId, + ], ) if (releaseAmount > 0) { - 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) - `, - [ - shareWorkerId, - releaseAmount, - nextAvailable, - nextFrozen, - input.workOrderId, - JSON.stringify({ workOrderId: input.workOrderId, shareId: Number(share.id) }), - input.now, - ], - ) + if (shouldDelayUnfreeze) { + await enqueueDepositUnfreezeWithClient(client, { + workerId: shareWorkerId, + workOrderId: input.workOrderId, + amount: releaseAmount, + unfreezeDays: input.unfreezeDays, + now: input.now, + }) + 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_pending_unfreeze', $2, $3, $4, $5, '拼单验收押金待解冻', $6::jsonb, $7) + `, + [ + shareWorkerId, + releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ + workOrderId: input.workOrderId, + shareId: Number(share.id), + unfreezeDays: input.unfreezeDays, + }), + input.now, + ], + ) + } else { + 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) + `, + [ + shareWorkerId, + releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ workOrderId: input.workOrderId, shareId: Number(share.id) }), + input.now, + ], + ) + } } if (shareReward > 0) { @@ -1095,7 +1139,11 @@ export async function acceptWorkOrderAndSettle(input: { Number(wallet?.frozen_deposit_amount || 0), ) const rewardAmount = Number(workOrder.reward_amount || 0) - const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount + rewardAmount + const shouldDelayUnfreeze = input.unfreezeDays > 0 + const nextAvailable = + Number(wallet?.available_amount || 0) + + rewardAmount + + (shouldDelayUnfreeze ? 0 : releaseAmount) const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) await client.query( @@ -1104,23 +1152,61 @@ export async function acceptWorkOrderAndSettle(input: { SET available_amount = $1, frozen_deposit_amount = $2, - total_settled_amount = total_settled_amount + $3, - updated_at = $4 - WHERE worker_id = $5 + pending_unfreeze_amount = pending_unfreeze_amount + $3, + total_settled_amount = total_settled_amount + $4, + updated_at = $5 + WHERE worker_id = $6 `, - [nextAvailable, nextFrozen, rewardAmount, input.now, workerId], + [ + nextAvailable, + nextFrozen, + shouldDelayUnfreeze ? releaseAmount : 0, + rewardAmount, + input.now, + workerId, + ], ) if (releaseAmount > 0) { - 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, '验收通过释放押金', '{}'::jsonb, $6) - `, - [workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], - ) + if (shouldDelayUnfreeze) { + await enqueueDepositUnfreezeWithClient(client, { + workerId, + workOrderId: input.workOrderId, + amount: releaseAmount, + unfreezeDays: input.unfreezeDays, + now: input.now, + }) + 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_pending_unfreeze', $2, $3, $4, $5, '验收通过押金待解冻', $6::jsonb, $7) + `, + [ + workerId, + releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + JSON.stringify({ + workOrderId: input.workOrderId, + unfreezeDays: input.unfreezeDays, + }), + input.now, + ], + ) + } else { + 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, '验收通过释放押金', '{}'::jsonb, $6) + `, + [workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + ) + } } if (rewardAmount > 0) { @@ -1437,6 +1523,244 @@ export async function settleOverdueWorkOrder(input: { }) } +export async function sumPendingUnfreezeByOrderIds( + workOrderIds: number[], +): Promise> { + const totals = new Map() + const uniqueIds = [...new Set(workOrderIds.map(Number).filter((id) => id > 0))] + if (uniqueIds.length === 0) return totals + const result = await query<{ work_order_id: number; total: number }>( + ` + SELECT work_order_id, SUM(amount)::int AS total + FROM worker_deposit_unfreezes + WHERE work_order_id = ANY($1::bigint[]) + AND status = 'pending' + GROUP BY work_order_id + `, + [uniqueIds], + ) + for (const row of result.rows) { + totals.set(Number(row.work_order_id), Number(row.total || 0)) + } + return totals +} + +export async function deductPendingDepositUnfreeze(input: { + workOrderId: number + amount: number + note: string + now: string +}): Promise<{ + deductedAmount: number + failureReason: 'no_pending_deposit' | 'amount_exceeds_pending' | null +}> { + return withTransaction(async (client) => { + const pendingResult = await client.query( + ` + SELECT * + FROM worker_deposit_unfreezes + WHERE work_order_id = $1 AND status = 'pending' + ORDER BY unfreeze_at ASC + FOR UPDATE + `, + [input.workOrderId], + ) + const pending = pendingResult.rows + if (pending.length === 0) { + return { deductedAmount: 0, failureReason: 'no_pending_deposit' } + } + const totalPending = pending.reduce((sum, row) => sum + Number(row.amount || 0), 0) + if (input.amount <= 0 || input.amount > totalPending) { + return { deductedAmount: 0, failureReason: 'amount_exceeds_pending' } + } + + let remainingToDeduct = input.amount + let deductedAmount = 0 + + for (const row of pending) { + if (remainingToDeduct <= 0) break + const rowAmount = Number(row.amount || 0) + if (rowAmount <= 0) continue + const deductFromRow = Math.min(rowAmount, remainingToDeduct) + const isFullyDeducted = deductFromRow >= rowAmount + + if (isFullyDeducted) { + await client.query( + ` + UPDATE worker_deposit_unfreezes + SET status = 'deducted', updated_at = $1 + WHERE id = $2 + `, + [input.now, row.id], + ) + } else { + await client.query( + ` + UPDATE worker_deposit_unfreezes + SET amount = amount - $1, updated_at = $2 + WHERE id = $3 + `, + [deductFromRow, input.now, row.id], + ) + } + + const workerWallet = await getWorkerWalletWithClient(client, row.worker_id) + await client.query( + ` + UPDATE worker_wallets + SET pending_unfreeze_amount = GREATEST(0, pending_unfreeze_amount - $1), updated_at = $2 + WHERE worker_id = $3 + `, + [deductFromRow, input.now, row.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_deduction', $2, $3, $4, $5, '问题单扣减待解冻押金', $6::jsonb, $7) + `, + [ + row.worker_id, + -deductFromRow, + Number(workerWallet?.available_amount || 0), + Number(workerWallet?.frozen_deposit_amount || 0), + input.workOrderId, + JSON.stringify({ + workOrderId: input.workOrderId, + unfreezeId: row.id, + note: input.note, + }), + input.now, + ], + ) + deductedAmount += deductFromRow + remainingToDeduct -= deductFromRow + } + + return { deductedAmount, failureReason: null } + }) +} + +export async function listDueDepositUnfreezes({ + limit = 100, + workerId = 0, +}: { limit?: number; workerId?: number } = {}): Promise { + const params: unknown[] = [] + let workerClause = '' + if (workerId > 0) { + params.push(workerId) + workerClause = `AND worker_id = $${params.length}` + } + params.push(limit) + const result = await query( + ` + SELECT * + FROM worker_deposit_unfreezes + WHERE status = 'pending' + AND unfreeze_at <= NOW() + ${workerClause} + ORDER BY unfreeze_at ASC + LIMIT $${params.length} + `, + params, + ) + return result.rows +} + +export async function releaseDepositUnfreeze({ + unfreezeId, + now, +}: { unfreezeId: number; now: string }): Promise { + return withTransaction(async (client) => { + const currentResult = await client.query( + ` + SELECT * + FROM worker_deposit_unfreezes + WHERE id = $1 AND status = 'pending' + FOR UPDATE + `, + [unfreezeId], + ) + const unfreeze = currentResult.rows[0] || null + if (!unfreeze) return null + + await ensureWorkerWalletWithClient(client, unfreeze.worker_id, now) + const wallet = await getWorkerWalletWithClient(client, unfreeze.worker_id) + const amount = Math.min( + Number(unfreeze.amount || 0), + Number(wallet?.pending_unfreeze_amount || 0), + ) + + await client.query( + ` + UPDATE worker_deposit_unfreezes + SET status = 'released', released_at = $1, updated_at = $1 + WHERE id = $2 + `, + [now, unfreeze.id], + ) + + if (amount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + amount + const nextPending = Math.max( + 0, + Number(wallet?.pending_unfreeze_amount || 0) - amount, + ) + await client.query( + ` + UPDATE worker_wallets + SET available_amount = $1, pending_unfreeze_amount = $2, updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, nextPending, now, unfreeze.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_unfreeze', $2, $3, $4, $5, '押金已解冻到账', $6::jsonb, $7) + `, + [ + unfreeze.worker_id, + amount, + nextAvailable, + Number(wallet?.frozen_deposit_amount || 0), + unfreeze.work_order_id, + JSON.stringify({ unfreezeId: unfreeze.id }), + now, + ], + ) + } + + return { + ...unfreeze, + status: 'released', + released_at: now, + updated_at: now, + } + }) +} + +async function enqueueDepositUnfreezeWithClient( + client: PoolClient, + input: { workerId: number; workOrderId: number; amount: number; unfreezeDays: number; now: string }, +) { + if (input.amount <= 0) return + const unfreezeAt = new Date( + new Date(input.now).getTime() + input.unfreezeDays * 86_400_000, + ).toISOString() + await client.query( + ` + INSERT INTO worker_deposit_unfreezes ( + worker_id, work_order_id, amount, unfreeze_at, status, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'pending', $5, $5) + `, + [input.workerId, input.workOrderId, input.amount, unfreezeAt, input.now], + ) +} + export async function countWorkerActiveOrders(workerId: number | string): Promise { const result = await query<{ total: number }>( ` @@ -1629,6 +1953,7 @@ function buildWorkOrderWhere({ params.push(workerSharingId) filters.push( `(wo.assigned_worker_id = $${params.length} + OR wo.last_assigned_worker_id = $${params.length} OR wo.id IN ( SELECT work_order_id FROM work_order_shares WHERE worker_id = $${params.length} AND status != 'cancelled' diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index d1107f5f..da4e4bc8 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -14,6 +14,7 @@ const WORKER_USER_SELECT = ` inv.display_name AS inviter_display_name, ww.available_amount, ww.frozen_deposit_amount, + ww.pending_unfreeze_amount, ww.total_credited_amount, ww.total_settled_amount FROM worker_users wu @@ -409,6 +410,26 @@ export async function getWorkerFinanceRequestById( return result.rows[0] || null } +export async function countWorkerWithdrawRequestsOnDay( + workerId: number | string, + dayStartIso: string, + dayEndIso: string, +): Promise { + const result = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM worker_finance_requests + WHERE worker_id = $1 + AND request_type = 'withdraw' + AND status != 'cancelled' + AND created_at >= $2 + AND created_at < $3 + `, + [Number(workerId), dayStartIso, dayEndIso], + ) + return Number(result.rows[0]?.total || 0) +} + export async function listWorkerFinanceRequests({ page = 1, pageSize = 20, diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 1d64076d..bca155e9 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -4,6 +4,7 @@ import { acceptAdminWorkOrder, creditAdminWorkerWallet, createAdminMockWorkOrder, + deductAdminWorkOrderPendingDeposit, deleteAdminWorkCategory, deleteAdminWorkOrder, deleteAdminWorkerLevel, @@ -471,4 +472,27 @@ router.post( }), ) +router.post( + '/worker-platform/orders/:workOrderId/deduct-pending-deposit', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + deductAdminWorkOrderPendingDeposit( + String(req.params.workOrderId || ''), + req.body || {}, + ), + { + successMessage: '待解冻押金已扣减', + errorMessage: '扣减待解冻押金失败', + scope: '[admin/worker-platform/orders/:workOrderId/deduct-pending-deposit]', + audit: (req, data) => ({ + action: 'work_order_pending_deposit_deducted', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + export default router diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index 95269b45..5bf97d88 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -11,6 +11,7 @@ import { const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json') const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health' const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout' +const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze' export function getScheduledJobsFilePath() { return SCHEDULED_JOBS_FILE_PATH @@ -46,6 +47,12 @@ export function getWorkOrderTimeoutJob(config: JsonObject = getScheduledJobsConf || createDefaultWorkOrderTimeoutJob() } +export function getDepositUnfreezeJob(config: JsonObject = getScheduledJobsConfig()) { + return (Array.isArray(config.jobs) ? config.jobs : []) + .find((item) => String(item.id || '').trim() === DEPOSIT_UNFREEZE_JOB_ID) + || createDefaultDepositUnfreezeJob() +} + export function getScheduledJobById(jobId: unknown, config: JsonObject = getScheduledJobsConfig()) { return (Array.isArray(config.jobs) ? config.jobs : []) .find((item) => String(item.id || '').trim() === String(jobId || '').trim()) || null @@ -59,6 +66,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) { .filter((item): item is NonNullable> => Boolean(item)) const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID) const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID) + const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID) if (!hasCloudtentaclesHealth) { jobs.push(createDefaultCloudtentaclesHealthJob()) @@ -66,6 +74,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) { if (!hasWorkOrderTimeout) { jobs.push(createDefaultWorkOrderTimeoutJob()) } + if (!hasDepositUnfreeze) { + jobs.push(createDefaultDepositUnfreezeJob()) + } return { enabled: typeof source.enabled === 'boolean' ? source.enabled : true, @@ -85,10 +96,27 @@ function normalizeScheduledJob(rawValue: unknown) { if (type === 'work_order_timeout') { return normalizeWorkOrderTimeoutJob(rawValue) } + if (type === 'deposit_unfreeze') { + return normalizeDepositUnfreezeJob(rawValue) + } return null } +function normalizeDepositUnfreezeJob(rawValue: JsonObject) { + const config = isPlainObject(rawValue.config) ? rawValue.config : {} + + return { + id: DEPOSIT_UNFREEZE_JOB_ID, + type: 'deposit_unfreeze', + enabled: rawValue.enabled !== false, + intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600), + config: { + scanLimit: normalizeRangeInteger(config.scanLimit, 100, 1, 2000), + }, + } +} + function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} @@ -171,10 +199,23 @@ function createDefaultScheduledJobsConfig() { jobs: [ createDefaultCloudtentaclesHealthJob(), createDefaultWorkOrderTimeoutJob(), + createDefaultDepositUnfreezeJob(), ], } } +function createDefaultDepositUnfreezeJob() { + return { + id: DEPOSIT_UNFREEZE_JOB_ID, + type: 'deposit_unfreeze', + enabled: true, + intervalSeconds: 60, + config: { + scanLimit: 100, + }, + } +} + function createDefaultWorkOrderTimeoutJob() { return { id: WORK_ORDER_TIMEOUT_JOB_ID, diff --git a/apps/backend/src/services/scheduler/deposit-unfreeze-job.ts b/apps/backend/src/services/scheduler/deposit-unfreeze-job.ts new file mode 100644 index 00000000..717338d9 --- /dev/null +++ b/apps/backend/src/services/scheduler/deposit-unfreeze-job.ts @@ -0,0 +1,24 @@ +import type { JsonObject } from '../../types/json.js' +import { logInfo } from '../../utils/logger.js' +import { settleDueDepositUnfreezes } from '../worker-platform/worker-service.js' + +export async function runDepositUnfreezeJob(job: JsonObject) { + const config = + job.config && typeof job.config === 'object' && !Array.isArray(job.config) + ? (job.config as JsonObject) + : {} + const scanLimit = Math.max(1, Number(config.scanLimit || 100)) + + const result = await settleDueDepositUnfreezes({ limit: scanLimit }) + + logInfo('[deposit-unfreeze]', '押金解冻扫描完成', result) + + return { + ok: true, + status: 'ok', + message: `扫描 ${result.checkedCount} 笔,解冻 ${result.processedCount} 笔`, + checkedCount: result.checkedCount, + failedCount: 0, + asset: result.processedCount, + } +} diff --git a/apps/backend/src/services/scheduler/scheduler-service.ts b/apps/backend/src/services/scheduler/scheduler-service.ts index 2b1f3621..b3844f6d 100644 --- a/apps/backend/src/services/scheduler/scheduler-service.ts +++ b/apps/backend/src/services/scheduler/scheduler-service.ts @@ -6,6 +6,7 @@ import { getScheduledJobsConfig, } from './config-service.js' import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js' +import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js' import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js' const timers = new Map() @@ -182,6 +183,9 @@ function dispatchJob(job: JsonObject) { if (job.type === 'work_order_timeout') { return runWorkOrderTimeoutJob(job) } + if (job.type === 'deposit_unfreeze') { + return runDepositUnfreezeJob(job) + } throw createHttpError(`不支持的定时任务类型:${job.type}`, { statusCode: 400, diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index d5797286..e35fa22c 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -19,6 +19,7 @@ import { createWorkOrderEvent, createWorkerFinanceRequest, createWorkerUser, + deductPendingDepositUnfreeze, deleteWorkCategory, deleteWorkOrder, deleteWorkerLevel, @@ -49,6 +50,7 @@ import { listWorkerUsers, listWorkOrderShares, listWorkOrderSharesByOrderIds, + sumPendingUnfreezeByOrderIds, resolveProblemWorkOrder, reviewWorkerFinanceRequest, submitWorkOrderShareAcceptance, @@ -456,6 +458,9 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { bucket.push(share) sharesByOrderId.set(orderId, bucket) } + const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds( + items.map((item) => Number(item.id)), + ) return { items: items.map((item) => { const orderShares = sharesByOrderId.get(Number(item.id)) || [] @@ -467,6 +472,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { ).length return { ...mapWorkOrderAdmin(item), + pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0, sharingProgress: { joinedQuantity, pendingSubmissionCount, @@ -477,6 +483,62 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { } } +export async function deductAdminWorkOrderPendingDeposit( + workOrderId: number | string, + payload: JsonObject = {}, +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.ACCEPTED) { + throw createHttpError('只有已验收的工单可以扣减待解冻押金', { + statusCode: 409, + errorCode: 'work_order_deduct_pending_status_invalid', + }) + } + const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) + const note = String(payload.note || payload.reason || '').trim() + if (amount <= 0) { + throw createHttpError('扣减金额必须大于 0', { + statusCode: 400, + errorCode: 'work_order_deduct_pending_amount_invalid', + }) + } + if (!note) { + throw createHttpError('请填写扣减原因', { + statusCode: 400, + errorCode: 'work_order_deduct_pending_note_required', + }) + } + const result = await deductPendingDepositUnfreeze({ + workOrderId: workOrder.id, + amount, + note, + now: nowIso(), + }) + if (result.failureReason === 'no_pending_deposit') { + throw createHttpError('该工单没有待解冻的押金', { + statusCode: 409, + errorCode: 'work_order_deduct_pending_none', + }) + } + if (result.failureReason === 'amount_exceeds_pending') { + throw createHttpError('扣减金额超过该工单待解冻押金总额', { + statusCode: 409, + errorCode: 'work_order_deduct_pending_amount_exceeds', + }) + } + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'pending_deposit_deducted', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ amount, note }), + now: nowIso(), + }) + return { deductedAmount: result.deductedAmount } +} + export async function getAdminWorkOrderSharing(workOrderId: number | string) { const workOrder = await getRequiredWorkOrder(workOrderId) const shares = await listWorkOrderShares(workOrder.id) @@ -937,6 +999,7 @@ export async function acceptAdminWorkOrder(workOrderId: number | string) { const now = nowIso() const { order: updated, failureReason } = await acceptWorkOrderAndSettle({ workOrderId: workOrder.id, + unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays, now, }) if (failureReason === 'sharing_pending_submissions') { diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 005ce48d..3683fd12 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -285,6 +285,7 @@ export function mapWorkerUser(worker: WorkerUserRow) { wallet: { availableAmount: Number(worker.available_amount || 0), frozenDepositAmount: Number(worker.frozen_deposit_amount || 0), + pendingUnfreezeAmount: Number(worker.pending_unfreeze_amount || 0), totalCreditedAmount: Number(worker.total_credited_amount || 0), totalSettledAmount: Number(worker.total_settled_amount || 0), }, @@ -452,6 +453,7 @@ export function mapWallet(wallet: Awaited 0) { + throw createHttpError('每天限提现 1 次,今天已提交过提现申请', { + statusCode: 409, + errorCode: 'worker_withdraw_daily_limit_reached', + }) + } + const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) if (amount <= 0) { throw createHttpError('提现金额必须大于 0', { @@ -743,6 +760,38 @@ export function normalizeWorkOrderTimeoutPolicy(value: unknown): WorkOrderTimeou return isWorkOrderTimeoutPolicy(value) ? value : 'reopen' } +export async function settleDueDepositUnfreezes( + options: { limit?: number; workerId?: number } = {}, +) { + const due = await listDueDepositUnfreezes({ + limit: Math.max(1, Number(options.limit || 100)), + workerId: Number(options.workerId || 0), + }) + let processedCount = 0 + for (const unfreeze of due) { + const released = await releaseDepositUnfreeze({ + unfreezeId: unfreeze.id, + now: nowIso(), + }) + if (released) processedCount += 1 + } + return { + checkedCount: due.length, + processedCount, + } +} + +function resolveChinaDayRange(): { start: string; end: string } { + const utcNow = new Date() + const utcDayStartMs = + Date.UTC(utcNow.getUTCFullYear(), utcNow.getUTCMonth(), utcNow.getUTCDate()) - + 8 * 3_600_000 + return { + start: new Date(utcDayStartMs).toISOString(), + end: new Date(utcDayStartMs + 86_400_000).toISOString(), + } +} + export async function settleOverdueWorkOrders( options: { limit?: number; workerId?: number } = {}, ) { diff --git a/apps/frontend/src/pages/admin/panels/FinancePanel.tsx b/apps/frontend/src/pages/admin/panels/FinancePanel.tsx index 30d400c6..5e14cb5e 100644 --- a/apps/frontend/src/pages/admin/panels/FinancePanel.tsx +++ b/apps/frontend/src/pages/admin/panels/FinancePanel.tsx @@ -84,6 +84,7 @@ import { import { formatAdminDateTime } from '@/utils/admin-time' import { asRecord, formatMoney, formatSharingShareStatus, resolveSharingShareStatusColor } from './shared' type FinanceConfigFormValues = { + depositUnfreezeDays?: number recharge?: { enabled?: boolean channelName?: string @@ -168,6 +169,7 @@ export default function FinancePanel() { setSavingConfig(true) try { await saveAdminWorkerFinanceConfig({ + depositUnfreezeDays: Number(values.depositUnfreezeDays ?? 3), recharge: { enabled: values.recharge?.enabled !== false, channelName: String(values.recharge?.channelName || '').trim(), @@ -370,6 +372,19 @@ export default function FinancePanel() { + + + + + + 解冻期间如订单出现问题,可在"接单工单"中对已验收工单扣减待解冻押金(全额或部分)。 + + + ([]) const [sharingOrder, setSharingOrder] = useState(null) const [editOrder, setEditOrder] = useState(null) + const [deductOrder, setDeductOrder] = useState(null) const [problemForm] = Form.useForm() const [resolutionForm] = Form.useForm() const [materialForm] = Form.useForm() + const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() const [editForm] = Form.useForm<{ productName?: string platformOrderId?: string @@ -329,6 +333,29 @@ export default function WorkOrdersPanel() { }) } + function openDeductModal(row: WorkOrder) { + setDeductOrder(row) + deductForm.setFieldsValue({ + amount: Math.round(Number(row.pendingUnfreezeAmount || 0)) / 100, + note: '', + }) + } + + async function submitDeduct(values: { amount?: number; note?: string }) { + if (!deductOrder) return + const succeeded = await runAction( + () => + deductAdminWorkOrderPendingDeposit(deductOrder.workOrderId, { + amount: Number(values.amount || 0), + note: String(values.note || '').trim(), + }), + '待解冻押金已扣减', + ) + if (!succeeded) return + setDeductOrder(null) + deductForm.resetFields() + } + async function submitEdit(values: { productName?: string platformOrderId?: string @@ -573,6 +600,15 @@ export default function WorkOrdersPanel() { 验收 ) : null} + {row.status === 'accepted' && Number(row.pendingUnfreezeAmount || 0) > 0 ? ( + + ) : null} {['in_progress', 'pending_acceptance'].includes(row.status) ? ( + } + > + + 将有押金工单验收通过后进入待解冻的押金,在到期后自动转入可用余额(验收后 3 + 天到账)。 + +
+ onChange({ ...job, intervalSeconds })} + /> + + onChange({ ...job, config: { ...job.config, scanLimit } }) + } + /> +
+ {runtime ? ( + + ) : null} +
+ ) + } const accounts = job.config?.accounts || [] const defaultThreshold = Number(job.config?.assetThreshold ?? 500) @@ -891,6 +957,9 @@ function formatScheduledJobTitle(job: AdminScheduledJobItem) { if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') { return '接单工单超时扫描' } + if (job.type === 'deposit_unfreeze' || job.id === 'deposit-unfreeze') { + return '押金到期解冻' + } return job.id || job.type || '定时任务' } diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 7cee8c4b..527d7fcd 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -40,6 +40,7 @@ import { formatDateTime } from '@/utils/date-time' const STATUS_OPTIONS = [ { value: '', label: '全部订单' }, + { value: 'open', label: '已超时' }, { value: 'in_progress', label: '代练中' }, { value: 'pending_acceptance', label: '待验收' }, { value: 'problem', label: '问题单' }, @@ -52,6 +53,7 @@ const STATUS_SUMMARY_ITEMS = [ { key: 'in_progress', label: '代练中', note: '正在执行中的订单' }, { key: 'pending_acceptance', label: '待验收', note: '已提交,等待审核' }, { key: 'problem', label: '问题单', note: '需要补充或重新处理' }, + { key: 'open', label: '已超时', note: '超时被系统退回的订单数' }, { key: 'accepted', label: '已验收', note: '已完成并结算的订单' }, { key: 'cancelled', label: '已取消', note: '已结束且不再继续' }, ] as const @@ -730,6 +732,7 @@ function formatMoney(value: number | undefined) { function formatStatus(status: string) { const labels: Record = { + open: '已超时', in_progress: '代练中', pending_acceptance: '待验收', problem: '问题单', @@ -741,6 +744,7 @@ function formatStatus(status: string) { function getStatusHint(status: string) { const hints: Record = { + open: '任务已超时被系统判定失败,如需继续可前往大厅重新抢单。', in_progress: '订单进行中,完成后请及时提交验收资料。', pending_acceptance: '已提交验收,等待后台审核。', problem: '后台已标记问题,请根据备注调整后重新提交。', @@ -755,6 +759,7 @@ function resolveStatusColor(status: string) { if (status === 'cancelled') return 'default' if (status === 'problem') return 'red' if (status === 'pending_acceptance') return 'gold' + if (status === 'open') return 'red' return 'blue' } diff --git a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx index e09d5825..ad67baf8 100644 --- a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx +++ b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx @@ -653,6 +653,8 @@ export default function WorkerProfilePage() { { value: 'withdraw_paid', label: '提现打款' }, { value: 'deposit_freeze', label: '冻结押金' }, { value: 'deposit_release', label: '释放押金' }, + { value: 'deposit_pending_unfreeze', label: '押金待解冻' }, + { value: 'deposit_unfreeze', label: '押金已解冻' }, { value: 'deposit_deduction', label: '扣除押金' }, { value: 'reward_settlement', label: '结算报酬' }, ]} @@ -904,6 +906,14 @@ export default function WorkerProfilePage() { style={{ marginBottom: 16 }} /> ) : null} + {formatMoney(resolveAvailableForWithdraw(worker, summary))} @@ -1057,7 +1067,14 @@ function buildMetricCards(worker: WorkerUser, summary?: WorkerProfileSummary): M { label: '冻结押金', value: toYuan(worker.wallet.frozenDepositAmount), - note: '抢单后冻结,验收通过会自动释放', + note: '抢单后冻结,验收通过后 3 天自动解冻', + prefix: '¥', + precision: 2, + }, + { + label: '待解冻押金', + value: toYuan(worker.wallet.pendingUnfreezeAmount), + note: '验收通过后待 3 天解冻到账的押金', prefix: '¥', precision: 2, }, @@ -1146,6 +1163,8 @@ function formatLedgerType(ledgerType: string) { withdraw_paid: '提现打款', deposit_freeze: '冻结押金', deposit_release: '释放押金', + deposit_pending_unfreeze: '押金待解冻', + deposit_unfreeze: '押金已解冻', deposit_deduction: '扣除押金', reward_settlement: '结算报酬', } @@ -1157,6 +1176,8 @@ function resolveLedgerTagColor(ledgerType: string) { if (ledgerType === 'withdraw_paid') return 'purple' if (ledgerType === 'deposit_freeze') return 'gold' if (ledgerType === 'deposit_release') return 'green' + if (ledgerType === 'deposit_pending_unfreeze') return 'orange' + if (ledgerType === 'deposit_unfreeze') return 'green' if (ledgerType === 'deposit_deduction') return 'red' if (ledgerType === 'reward_settlement') return 'cyan' return 'default' diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index ea1ea740..12478ad3 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -140,6 +140,7 @@ export function fetchAdminWorkerFinanceConfig() { } export function saveAdminWorkerFinanceConfig(payload: { + depositUnfreezeDays?: number recharge?: { enabled?: boolean channelName?: string @@ -298,3 +299,13 @@ export function acceptAdminWorkOrder(workOrderId: number) { `/api/v1/admin/worker-platform/orders/${workOrderId}/accept`, ) } + +export function deductAdminWorkOrderPendingDeposit( + workOrderId: number, + payload: { amount: number; note: string }, +) { + return apiPost<{ deductedAmount: number }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/deduct-pending-deposit`, + payload, + ) +} diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 31d0b46c..cb5557c3 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -25,6 +25,7 @@ export type WorkerUser = { wallet: { availableAmount: number frozenDepositAmount: number + pendingUnfreezeAmount: number totalCreditedAmount: number totalSettledAmount: number } @@ -84,6 +85,7 @@ export type WorkerFinanceRequest = { } export type WorkerFinanceConfig = { + depositUnfreezeDays: number recharge: { enabled: boolean channelName: string @@ -225,6 +227,7 @@ export type WorkOrder = { joinedQuantity: number pendingSubmissionCount: number } + pendingUnfreezeAmount?: number timeoutMinutes?: number timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string deadlineAt?: string | null