diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index a281c47d..d36a2ed5 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -2,6 +2,7 @@ export const APP_CONFIG_KEYS = { notification: 'notification', scheduledJobs: 'scheduled_jobs', fulfillmentRouting: 'fulfillment_routing', + workerFinance: 'worker_finance', cloudtentaclesSources: 'cloudtentacles_sources', cloudtentaclesSession: 'cloudtentacles_session', cloudtentaclesOverrideRules: 'cloudtentacles_override_rules', diff --git a/apps/backend/src/repositories/worker-platform-repo.ts b/apps/backend/src/repositories/worker-platform-repo.ts index 56a79486..968d2136 100644 --- a/apps/backend/src/repositories/worker-platform-repo.ts +++ b/apps/backend/src/repositories/worker-platform-repo.ts @@ -74,6 +74,9 @@ export type WorkerFinanceRequestRow = { created_at: string updated_at: string reviewed_at: string | null + worker_username?: string + worker_display_name?: string + worker_phone?: string } export type WorkCategoryRow = { @@ -179,6 +182,7 @@ type FinanceRequestListInput = { workerId?: number status?: string requestType?: string + keyword?: string } type CreateWorkerInput = { @@ -242,6 +246,16 @@ const WORK_PRODUCT_RULE_SELECT = ` LEFT JOIN work_categories wc ON wc.id = wpr.category_id ` +const WORKER_FINANCE_REQUEST_SELECT = ` + SELECT + wfr.*, + wu.username AS worker_username, + wu.display_name AS worker_display_name, + wu.phone AS worker_phone + FROM worker_finance_requests wfr + LEFT JOIN worker_users wu ON wu.id = wfr.worker_id +` + export async function getWorkerLevelByKey(levelKey: string): Promise { const result = await query( 'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1', @@ -580,32 +594,43 @@ export async function createWorkerFinanceRequest(input: { return result.rows[0] || null } +export async function getWorkerFinanceRequestById( + requestId: number | string, +): Promise { + const result = await query( + `${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`, + [Number(requestId)], + ) + return result.rows[0] || null +} + export async function listWorkerFinanceRequests({ page = 1, pageSize = 20, workerId = 0, status = '', requestType = '', + keyword = '', }: FinanceRequestListInput = {}): Promise<{ items: WorkerFinanceRequestRow[]; total: number }> { const { whereClause, params } = buildWorkerFinanceRequestWhere({ workerId, status, requestType, + keyword, }) const totalResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM worker_finance_requests wfr ${whereClause}`, + `SELECT COUNT(*)::int AS total FROM worker_finance_requests wfr + LEFT JOIN worker_users wu ON wu.id = wfr.worker_id + ${whereClause}`, params, ) const offset = (page - 1) * pageSize params.push(pageSize, offset) const itemsResult = await query( - ` - SELECT * - FROM worker_finance_requests wfr - ${whereClause} - ORDER BY wfr.created_at DESC, wfr.id DESC - LIMIT $${params.length - 1} OFFSET $${params.length} - `, + `${WORKER_FINANCE_REQUEST_SELECT} + ${whereClause} + ORDER BY wfr.created_at DESC, wfr.id DESC + LIMIT $${params.length - 1} OFFSET $${params.length}`, params, ) return { @@ -614,6 +639,146 @@ export async function listWorkerFinanceRequests({ } } +export async function reviewWorkerFinanceRequest(input: { + requestId: number + status: string + reviewedNote: string + now: string +}): Promise<{ + request: WorkerFinanceRequestRow | null + failureReason: 'request_not_pending' | 'withdraw_insufficient' | null +}> { + return withTransaction(async (client) => { + const currentResult = await client.query( + ` + SELECT * + FROM worker_finance_requests + WHERE id = $1 + LIMIT 1 + FOR UPDATE + `, + [input.requestId], + ) + const current = currentResult.rows[0] || null + if (!current) { + return { + request: null, + failureReason: null, + } + } + + if (current.status !== 'pending') { + return { + request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId), + failureReason: 'request_not_pending', + } + } + + if (input.status === 'approved') { + await ensureWorkerWalletWithClient(client, Number(current.worker_id), input.now) + const wallet = await getWorkerWalletWithClient(client, Number(current.worker_id)) + const availableAmount = Number(wallet?.available_amount || 0) + const frozenAmount = Number(wallet?.frozen_deposit_amount || 0) + const amount = Number(current.amount || 0) + const note = + input.reviewedNote || + (current.request_type === 'withdraw' ? '提现申请审核通过' : '充值申请审核通过') + + if (current.request_type === 'withdraw') { + if (availableAmount < amount) { + return { + request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId), + failureReason: 'withdraw_insufficient', + } + } + + const nextAvailable = availableAmount - amount + await client.query( + ` + UPDATE worker_wallets + SET available_amount = $1, updated_at = $2 + WHERE worker_id = $3 + `, + [nextAvailable, input.now, Number(current.worker_id)], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + audit_status, note, payload_json, created_at + ) VALUES ($1, 'withdraw_paid', $2, $3, $4, 'approved', $5, $6::jsonb, $7) + `, + [ + Number(current.worker_id), + -amount, + nextAvailable, + frozenAmount, + note, + JSON.stringify({ + requestId: Number(current.id), + requestType: current.request_type, + source: 'admin_finance_review', + }), + input.now, + ], + ) + } else { + const nextAvailable = availableAmount + amount + await client.query( + ` + UPDATE worker_wallets + SET + available_amount = $1, + total_credited_amount = total_credited_amount + $2, + updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, amount, input.now, Number(current.worker_id)], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + audit_status, note, payload_json, created_at + ) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7) + `, + [ + Number(current.worker_id), + amount, + nextAvailable, + frozenAmount, + note, + JSON.stringify({ + requestId: Number(current.id), + requestType: current.request_type, + source: 'admin_finance_review', + }), + input.now, + ], + ) + } + } + + await client.query( + ` + UPDATE worker_finance_requests + SET + status = $1, + reviewed_note = $2, + reviewed_at = $3, + updated_at = $3 + WHERE id = $4 + `, + [input.status, input.reviewedNote, input.now, input.requestId], + ) + + return { + request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId), + failureReason: null, + } + }) +} + export async function getWorkerFinanceRequestSummary(workerId: number | string) { const result = await query<{ pending_withdraw_amount: number @@ -1421,6 +1586,17 @@ async function getWorkerUserByIdWithClient( return result.rows[0] || null } +async function getWorkerFinanceRequestByIdWithClient( + client: PoolClient, + requestId: number | string, +): Promise { + const result = await client.query( + `${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`, + [Number(requestId)], + ) + return result.rows[0] || null +} + async function getWorkOrderByIdWithClient( client: PoolClient, workOrderId: number | string, @@ -1500,6 +1676,7 @@ function buildWorkerFinanceRequestWhere({ workerId = 0, status = '', requestType = '', + keyword = '', }: FinanceRequestListInput) { const filters: string[] = [] const params: unknown[] = [] @@ -1515,6 +1692,16 @@ function buildWorkerFinanceRequestWhere({ params.push(requestType) filters.push(`wfr.request_type = $${params.length}`) } + if (keyword) { + params.push(`%${keyword}%`) + filters.push( + `(wu.username ILIKE $${params.length} + OR wu.display_name ILIKE $${params.length} + OR wu.phone ILIKE $${params.length} + OR wfr.account_name ILIKE $${params.length} + OR wfr.account_no ILIKE $${params.length})`, + ) + } return { whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', params, diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index e23b02ca..bc400d89 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -4,7 +4,9 @@ import { acceptAdminWorkOrder, creditAdminWorkerWallet, createAdminMockWorkOrder, + getAdminWorkerFinanceConfig, getAdminWorkerPlatformSummary, + listAdminWorkerFinanceRequests, listAdminWorkCategories, listAdminWorkProductRules, listAdminWorkerLevels, @@ -12,13 +14,16 @@ import { listAdminWorkOrders, markAdminWorkOrderProblem, publishAdminWorkOrder, + reviewAdminWorkerFinanceRequest, resolveAdminProblemWorkOrder, reviewAdminWorkerUser, + saveAdminWorkerFinanceConfig, saveAdminWorkCategory, saveAdminWorkProductRule, saveAdminWorkerLevel, submitAdminWorkOrderMaterial, syncAdminWorkerOrdersFromSource, + unpublishAdminWorkOrder, } from '../../services/worker-platform/worker-platform-service.js' import { createJsonHandler, requireAdminRoles } from './session.js' @@ -161,6 +166,62 @@ router.post( ), ) +router.get( + '/worker-platform/finance-config', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler(() => getAdminWorkerFinanceConfig(), { + successMessage: 'ok', + errorMessage: '读取资金配置失败', + scope: '[admin/worker-platform/finance-config]', + }), +) + +router.post( + '/worker-platform/finance-config', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => saveAdminWorkerFinanceConfig(req.body || {}), { + successMessage: '资金配置已保存', + errorMessage: '保存资金配置失败', + scope: '[admin/worker-platform/finance-config]', + audit: (_req, data) => ({ + action: 'worker_finance_config_saved', + targetType: 'worker_finance_config', + targetId: 'worker_finance', + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + +router.get( + '/worker-platform/finance-requests', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => listAdminWorkerFinanceRequests(req.query), { + successMessage: 'ok', + errorMessage: '读取资金申请失败', + scope: '[admin/worker-platform/finance-requests]', + }), +) + +router.post( + '/worker-platform/finance-requests/:requestId/review', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + reviewAdminWorkerFinanceRequest(String(req.params.requestId || ''), req.body || {}), + { + successMessage: '资金申请已处理', + errorMessage: '处理资金申请失败', + scope: '[admin/worker-platform/finance-requests/:requestId/review]', + audit: (req, data) => ({ + action: 'worker_finance_request_reviewed', + targetType: 'worker_finance_request', + targetId: String(req.params.requestId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.get( '/worker-platform/orders', requireAdminRoles(['admin', 'operator', 'support']), @@ -238,6 +299,22 @@ router.post( }), ) +router.post( + '/worker-platform/orders/:workOrderId/unpublish', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => unpublishAdminWorkOrder(String(req.params.workOrderId || '')), { + successMessage: '订单已撤回到未分配', + errorMessage: '下架接单订单失败', + scope: '[admin/worker-platform/orders/:workOrderId/unpublish]', + audit: (req, data) => ({ + action: 'work_order_unpublished', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + router.post( '/worker-platform/orders/:workOrderId/problem', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/services/worker-platform/worker-finance-config-service.ts b/apps/backend/src/services/worker-platform/worker-finance-config-service.ts new file mode 100644 index 00000000..13d69267 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-finance-config-service.ts @@ -0,0 +1,123 @@ +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' +import type { JsonObject } from '../../types/json.js' +import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' +import { + normalizeStoredFileUrl, + refreshUploadedFileUrls, + type UploadedFileDto, +} from '../file-storage/file-storage-service.js' + +export type WorkerFinanceConfig = { + recharge: { + enabled: boolean + channelName: string + accountName: string + accountNo: string + qrCodeImage: UploadedFileDto | null + instructions: string + } + withdraw: { + enabled: boolean + instructions: string + } +} + +export function getWorkerFinanceConfig(): WorkerFinanceConfig { + const config = readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerFinance, + fallback: createDefaultWorkerFinanceConfig, + normalize: normalizeWorkerFinanceConfig, + }) + return refreshWorkerFinanceConfig(config) +} + +export async function saveWorkerFinanceConfig(rawValue: unknown): Promise { + const saved = await saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerFinance, + value: rawValue, + normalize: normalizeWorkerFinanceConfig, + }) + return refreshWorkerFinanceConfig(saved) +} + +export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + const recharge = isPlainObject(source.recharge) ? source.recharge : {} + const withdraw = isPlainObject(source.withdraw) ? source.withdraw : {} + + return { + recharge: { + enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true, + channelName: String(recharge.channelName || recharge.channel_name || '').trim(), + accountName: String(recharge.accountName || recharge.account_name || '').trim(), + accountNo: String(recharge.accountNo || recharge.account_no || '').trim(), + qrCodeImage: normalizeUploadedFileForStorage( + recharge.qrCodeImage || recharge.qr_code_image, + ), + instructions: String(recharge.instructions || '').trim(), + }, + withdraw: { + enabled: typeof withdraw.enabled === 'boolean' ? withdraw.enabled : true, + instructions: String(withdraw.instructions || '').trim(), + }, + } +} + +export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig { + return { + recharge: { + enabled: true, + channelName: '', + accountName: '', + accountNo: '', + qrCodeImage: null, + instructions: '', + }, + withdraw: { + enabled: true, + instructions: '', + }, + } +} + +function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceConfig { + return { + recharge: { + ...config.recharge, + qrCodeImage: config.recharge.qrCodeImage + ? (refreshUploadedFileUrls(config.recharge.qrCodeImage) as UploadedFileDto) + : null, + }, + withdraw: { + ...config.withdraw, + }, + } +} + +function normalizeUploadedFileForStorage(value: unknown): UploadedFileDto | null { + if (!isPlainObject(value)) { + return null + } + + const objectKey = String(value.objectKey || value.object_key || '').trim() + if (!objectKey) { + return null + } + + return { + objectKey, + url: normalizeStoredFileUrl(value.url, objectKey), + thumbnailUrl: normalizeStoredFileUrl( + value.thumbnailUrl || value.thumbnail_url, + objectKey, + ), + mediumUrl: normalizeStoredFileUrl(value.mediumUrl || value.medium_url, objectKey), + filename: String(value.filename || '').trim() || '收款二维码', + contentType: String(value.contentType || value.content_type || '').trim(), + size: Math.max(0, Number(value.size || 0) || 0), + } +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.ts b/apps/backend/src/services/worker-platform/worker-platform-service.ts index 3adb4f45..37b15cf6 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.ts @@ -13,7 +13,9 @@ import { createWorkOrderEvent, createWorkerFinanceRequest, createWorkerUser, + getWorkerFinanceRequestById, getWorkerFinanceRequestSummary, + getWorkerLevelById, incrementWorkerSessionVersion, getWorkOrderById, getWorkOrderByOrderItemId, @@ -29,6 +31,7 @@ import { listWorkerLevels, listWorkerUsers, resolveProblemWorkOrder, + reviewWorkerFinanceRequest, updateWorkOrder, updateWorkerPassword, updateWorkerUser, @@ -56,6 +59,10 @@ import { refreshStoredFileUrl, refreshUploadedFileUrls, } from '../file-storage/file-storage-service.js' +import { + getWorkerFinanceConfig, + saveWorkerFinanceConfig, +} from './worker-finance-config-service.js' export type WorkerSession = { sessionId: string @@ -286,9 +293,10 @@ export function requireActiveWorkerSession(session: WorkerSession | null | undef export async function getWorkerProfile(session: WorkerSession) { const worker = await getRequiredWorker(session.workerId) - const [financeSummary, acceptedOrderCount] = await Promise.all([ + const [financeSummary, acceptedOrderCount, financeConfig] = await Promise.all([ getWorkerFinanceRequestSummary(session.workerId), countWorkerAcceptedOrders(session.workerId), + Promise.resolve(getWorkerFinanceConfig()), ]) return { worker: mapWorkerUser(worker), @@ -299,6 +307,7 @@ export async function getWorkerProfile(session: WorkerSession) { approvedWithdrawAmount: financeSummary.approvedWithdrawAmount, pendingRechargeAmount: financeSummary.pendingRechargeAmount, }, + financeConfig, } } @@ -348,6 +357,13 @@ export async function createWorkerRechargeRequest( ) { requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) + const financeConfig = getWorkerFinanceConfig() + if (financeConfig.recharge.enabled === false) { + throw createHttpError('当前充值入口暂未开放,请联系管理员', { + statusCode: 409, + errorCode: 'worker_recharge_disabled', + }) + } const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) if (amount <= 0) { throw createHttpError('充值金额必须大于 0', { @@ -369,6 +385,12 @@ export async function createWorkerRechargeRequest( source: 'worker_profile_recharge', username: worker.username, phone: worker.phone || '', + financeConfig: { + channelName: financeConfig.recharge.channelName, + accountName: financeConfig.recharge.accountName, + accountNo: financeConfig.recharge.accountNo, + qrCodeImage: financeConfig.recharge.qrCodeImage, + }, }), now: nowIso(), }) @@ -389,6 +411,13 @@ export async function createWorkerWithdrawRequest( ) { requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) + const financeConfig = getWorkerFinanceConfig() + if (financeConfig.withdraw.enabled === false) { + throw createHttpError('当前提现入口暂未开放,请联系管理员', { + statusCode: 409, + errorCode: 'worker_withdraw_disabled', + }) + } const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) if (amount <= 0) { throw createHttpError('提现金额必须大于 0', { @@ -878,11 +907,21 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) { export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) { const worker = await getRequiredWorker(workerId) const status = normalizeReviewStatus(payload.status) - const levelId = - normalizeOptionalId(payload.levelId) || - worker.level_id || - (await ensureWorkerPlatformDefaults()).level?.id || - null + const requestedLevelId = normalizeOptionalId(payload.levelId) + let levelId = worker.level_id || null + if (requestedLevelId) { + const level = await getWorkerLevelById(requestedLevelId) + if (!level) { + throw createHttpError('接单等级不存在', { + statusCode: 404, + errorCode: 'worker_level_not_found', + }) + } + levelId = Number(level.id) + } + if (!levelId) { + levelId = (await ensureWorkerPlatformDefaults()).level?.id || null + } const now = nowIso() const updated = await updateWorkerUser(worker.id, { status, @@ -914,6 +953,81 @@ export async function creditAdminWorkerWallet(workerId: number | string, payload return { wallet: mapWallet(wallet) } } +export async function getAdminWorkerFinanceConfig() { + return getWorkerFinanceConfig() +} + +export async function saveAdminWorkerFinanceConfig(payload: JsonObject = {}) { + const config = await saveWorkerFinanceConfig(payload) + return config +} + +export async function listAdminWorkerFinanceRequests(query: JsonObject = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const status = normalizeFinanceRequestStatus(query.status) + const requestType = normalizeFinanceRequestType(query.requestType) + const keyword = String(query.keyword || '').trim() + const { items, total } = await listWorkerFinanceRequests({ + page, + pageSize, + status, + requestType, + keyword, + }) + return { + items: items.map(mapFinanceRequest), + pagination: { page, pageSize, total }, + } +} + +export async function reviewAdminWorkerFinanceRequest( + requestId: number | string, + payload: JsonObject = {}, +) { + const current = await getWorkerFinanceRequestById(requestId) + if (!current) { + throw createHttpError('资金申请不存在', { + statusCode: 404, + errorCode: 'worker_finance_request_not_found', + }) + } + + const status = normalizeAdminFinanceReviewStatus(payload.status) + const reviewedNote = String(payload.reviewedNote || payload.note || '').trim() + const reviewed = await reviewWorkerFinanceRequest({ + requestId: Number(current.id), + status, + reviewedNote, + now: nowIso(), + }) + + if (reviewed.failureReason === 'request_not_pending') { + throw createHttpError('该申请已处理,请刷新后重试', { + statusCode: 409, + errorCode: 'worker_finance_request_reviewed', + }) + } + + if (reviewed.failureReason === 'withdraw_insufficient') { + throw createHttpError('打手余额不足,暂时无法通过该提现申请', { + statusCode: 409, + errorCode: 'worker_finance_request_withdraw_insufficient', + }) + } + + if (!reviewed.request) { + throw createHttpError('资金申请不存在', { + statusCode: 404, + errorCode: 'worker_finance_request_not_found', + }) + } + + return { + request: mapFinanceRequest(reviewed.request), + } +} + export async function listAdminWorkOrders(query: JsonObject = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) @@ -1079,6 +1193,32 @@ export async function publishAdminWorkOrder(workOrderId: number | string) { return { order: mapWorkOrderAdmin(updated || workOrder) } } +export async function unpublishAdminWorkOrder(workOrderId: number | string) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { + throw createHttpError('只有待抢单且未被接走的订单可以下架', { + statusCode: 409, + errorCode: 'work_order_unpublish_status_invalid', + }) + } + const now = nowIso() + const updated = await updateWorkOrder(workOrder.id, { + status: WORK_ORDER_STATUS.UNASSIGNED, + published_at: null, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'unpublished', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.UNASSIGNED, + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + export async function markAdminWorkOrderProblem( workOrderId: number | string, payload: JsonObject = {}, @@ -1559,6 +1699,14 @@ function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined) createdAt: request.created_at, updatedAt: request.updated_at, reviewedAt: request.reviewed_at, + worker: request.worker_id + ? { + workerId: Number(request.worker_id), + username: request.worker_username || '', + displayName: request.worker_display_name || request.worker_username || '', + phone: request.worker_phone || '', + } + : null, } } @@ -1902,6 +2050,7 @@ function normalizeWalletLedgerType(value: unknown) { if ( [ 'manual_credit', + 'withdraw_paid', 'deposit_freeze', 'deposit_release', 'deposit_deduction', @@ -1940,6 +2089,17 @@ function normalizeFinanceRequestStatus(value: unknown) { }) } +function normalizeAdminFinanceReviewStatus(value: unknown) { + const status = String(value || '').trim() + if (['approved', 'rejected', 'cancelled'].includes(status)) { + return status + } + throw createHttpError('审核状态不正确', { + statusCode: 400, + errorCode: 'worker_finance_review_status_invalid', + }) +} + function normalizeWithdrawChannel(value: unknown) { const channel = String(value || '').trim() if (['alipay', 'wechat', 'bank'].includes(channel)) { diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 52307a03..8715f35b 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -28,16 +28,19 @@ import { Typography, } from 'antd' import type { TableColumnsType } from 'antd' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router' import JsonPreview from '@/components/admin/JsonPreview' +import ImageUpload from '@/components/files/ImageUpload' import ImagePreviewList from '@/components/files/ImagePreviewList' import PageHeader from '@/components/admin/PageHeader' import { acceptAdminWorkOrder, createAdminMockWorkOrder, creditAdminWorkerWallet, + fetchAdminWorkerFinanceConfig, + fetchAdminWorkerFinanceRequests, fetchAdminWorkCategories, fetchAdminWorkProductRules, fetchAdminWorkerLevels, @@ -46,13 +49,16 @@ import { fetchAdminWorkOrders, markAdminWorkOrderProblem, publishAdminWorkOrder, + reviewAdminWorkerFinanceRequest, resolveAdminProblemWorkOrder, reviewAdminWorkerUser, + saveAdminWorkerFinanceConfig, saveAdminWorkCategory, saveAdminWorkProductRule, saveAdminWorkerLevel, submitAdminWorkOrderMaterial, syncAdminWorkerOrdersFromSource, + unpublishAdminWorkOrder, } from '@/services/admin' import type { CollectField, @@ -60,6 +66,8 @@ import type { WorkCategory, WorkOrder, WorkProductRule, + WorkerFinanceConfig, + WorkerFinanceRequest, WorkerLevel, WorkerUser, } from '@/types/worker-platform' @@ -69,6 +77,23 @@ import { } from '@/utils/admin-pagination' import { formatAdminDateTime } from '@/utils/admin-time' +type FinanceConfigFormValues = { + recharge?: { + enabled?: boolean + channelName?: string + accountName?: string + accountNo?: string + qrCodeImageList?: UploadedFile[] + instructions?: string + } + withdraw?: { + enabled?: boolean + instructions?: string + } +} + +type FinanceReviewAction = 'approved' | 'rejected' | 'cancelled' + export default function AdminWorkerPlatformPage() { return (
@@ -84,6 +109,7 @@ export default function AdminWorkerPlatformPage() { { key: 'rules', label: '物品规则', children: }, { key: 'categories', label: '分类', children: }, { key: 'workers', label: '打手', children: }, + { key: 'finance', label: '资金', children: }, { key: 'levels', label: '等级权限', children: }, ]} /> @@ -123,7 +149,7 @@ function SummaryCards() { } function WorkOrdersPanel() { - const { message } = App.useApp() + const { message, modal } = App.useApp() const navigate = useNavigate() const queryClient = useQueryClient() const [status, setStatus] = useState('') @@ -329,6 +355,26 @@ function WorkOrdersPanel() { 发布 ) : null} + {row.status === 'open' ? ( + + ) : null} {row.status === 'pending_acceptance' ? ( ) : null} + ), @@ -1231,6 +1310,38 @@ function WorkersPanel() { scroll={{ x: 1000 }} /> + { + setLevelWorker(null) + levelForm.resetFields() + }} + onOk={() => levelForm.submit()} + destroyOnHidden + > +
+ + + {levelWorker?.displayName || levelWorker?.username || '-'} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + setKeywordInput(event.target.value)} + onSearch={(value) => { + setKeyword(String(value || '').trim()) + setPage(1) + }} + /> + { + setRequestStatus(nextStatus) + setPage(1) + }} + /> + + + } + > + + rowKey="requestId" + loading={financeRequestsQuery.isLoading} + dataSource={financeRequestsQuery.data?.data.items || []} + columns={requestColumns} + pagination={buildAdminTablePagination({ + current: financeRequestsPagination?.page || page, + pageSize: financeRequestsPagination?.pageSize || pageSize, + total: financeRequestsPagination?.total || 0, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage) + setPageSize(nextPageSize) + }, + })} + scroll={{ x: 1380 }} + /> + + + { + setReviewState(null) + reviewForm.resetFields() + }} + onOk={() => reviewForm.submit()} + > + {reviewState ? ( + + + + {reviewState.request.worker?.displayName || + reviewState.request.worker?.username || + '-'} + + + {formatFinanceRequestType(reviewState.request.requestType)} + + + {formatMoney(reviewState.request.amount)} + + + {renderFinanceRequestAccountText(reviewState.request)} + + + {reviewState.request.note || '-'} + + + +
+ + + +
+
+ ) : null} +
+
+ ) +} + function LevelsPanel() { const { message } = App.useApp() const queryClient = useQueryClient() @@ -1556,3 +2069,115 @@ function formatWorkerStatus(status: string) { } return labels[status] || status } + +function mapFinanceConfigToFormValues( + config?: WorkerFinanceConfig, +): FinanceConfigFormValues { + return { + recharge: { + enabled: config?.recharge.enabled !== false, + channelName: config?.recharge.channelName || '', + accountName: config?.recharge.accountName || '', + accountNo: config?.recharge.accountNo || '', + qrCodeImageList: config?.recharge.qrCodeImage ? [config.recharge.qrCodeImage] : [], + instructions: config?.recharge.instructions || '', + }, + withdraw: { + enabled: config?.withdraw.enabled !== false, + instructions: config?.withdraw.instructions || '', + }, + } +} + +function formatFinanceReviewAction(action: FinanceReviewAction) { + if (action === 'approved') return '通过' + if (action === 'rejected') return '拒绝' + return '取消' +} + +function formatFinanceRequestType(requestType: string) { + if (requestType === 'recharge') return '充值申请' + if (requestType === 'withdraw') return '提现申请' + return requestType || '-' +} + +function formatFinanceRequestStatus(status: string) { + const labels: Record = { + pending: '待处理', + approved: '已通过', + rejected: '已拒绝', + cancelled: '已取消', + } + return labels[status] || status || '-' +} + +function resolveFinanceRequestStatusColor(status: string) { + if (status === 'approved') return 'green' + if (status === 'pending') return 'gold' + if (status === 'rejected') return 'red' + if (status === 'cancelled') return 'default' + return 'blue' +} + +function renderFinanceRequestAccount(request: WorkerFinanceRequest) { + return ( +
+ {renderFinanceRequestAccountText(request)} + {request.requestType === 'withdraw' && request.accountNo ? ( + + {maskFinanceAccount(request.accountNo)} + + ) : null} +
+ ) +} + +function renderFinanceRequestAccountText(request: WorkerFinanceRequest) { + if (request.requestType === 'withdraw') { + const channel = formatFinanceChannel(request.accountChannel) + const accountName = request.accountName || '-' + return `${channel} / ${accountName}` + } + + const rechargeSnapshot = getRechargeSnapshot(request) + if ( + rechargeSnapshot.channelName || + rechargeSnapshot.accountName || + rechargeSnapshot.accountNo + ) { + return [ + rechargeSnapshot.channelName, + rechargeSnapshot.accountName, + rechargeSnapshot.accountNo ? maskFinanceAccount(rechargeSnapshot.accountNo) : '', + ] + .filter(Boolean) + .join(' / ') + } + + return '线下充值,等待管理员核对' +} + +function getRechargeSnapshot(request: WorkerFinanceRequest) { + const payload = asRecord(request.payload) + const financeConfig = asRecord(payload.financeConfig) + return { + channelName: String(financeConfig.channelName || '').trim(), + accountName: String(financeConfig.accountName || '').trim(), + accountNo: String(financeConfig.accountNo || '').trim(), + } +} + +function formatFinanceChannel(channel: string) { + if (channel === 'alipay') return '支付宝' + if (channel === 'wechat') return '微信收款' + if (channel === 'bank') return '银行卡' + if (channel === 'manual') return '线下转账' + return channel || '-' +} + +function maskFinanceAccount(value: string) { + const text = String(value || '').trim() + if (!text) return '-' + if (text.length <= 8) return text + return `${text.slice(0, 4)} **** ${text.slice(-4)}` +} diff --git a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx index d424bdcb..c2d8da7f 100644 --- a/apps/frontend/src/pages/worker/WorkerProfilePage.tsx +++ b/apps/frontend/src/pages/worker/WorkerProfilePage.tsx @@ -19,6 +19,7 @@ import { Descriptions, Empty, Form, + Image, Input, InputNumber, Modal, @@ -113,6 +114,7 @@ export default function WorkerProfilePage() { const worker = profileQuery.data?.data.worker const summary = profileQuery.data?.data.summary + const financeConfig = profileQuery.data?.data.financeConfig const canUseFinanceActions = worker?.status === 'active' const ledgersQuery = useQuery({ @@ -179,6 +181,30 @@ export default function WorkerProfilePage() { setActiveTab('requests') } + function openRechargeModal() { + if (!canUseFinanceActions) { + message.warning('当前账号尚未通过审核,暂时不能发起充值申请') + return + } + if (financeConfig?.recharge.enabled === false) { + message.warning('当前充值入口暂未开放,请联系管理员') + return + } + setRechargeOpen(true) + } + + function openWithdrawModal() { + if (!canUseFinanceActions) { + message.warning('当前账号尚未通过审核,暂时不能发起提现申请') + return + } + if (financeConfig?.withdraw.enabled === false) { + message.warning('当前提现入口暂未开放,请联系管理员') + return + } + setWithdrawOpen(true) + } + async function submitRecharge(values: RechargeFormValues) { setSubmittingRecharge(true) try { @@ -327,7 +353,7 @@ export default function WorkerProfilePage() { ) : ( - 线下充值,等待管理员入账 + renderRechargeRequestSummary(row) ), }, { @@ -452,7 +478,7 @@ export default function WorkerProfilePage() { type="primary" icon={} disabled={!canUseFinanceActions} - onClick={() => setRechargeOpen(true)} + onClick={openRechargeModal} > 充值申请 @@ -460,7 +486,7 @@ export default function WorkerProfilePage() { type="primary" icon={} disabled={!canUseFinanceActions} - onClick={() => setWithdrawOpen(true)} + onClick={openWithdrawModal} > 提现申请 @@ -549,6 +575,7 @@ export default function WorkerProfilePage() { options={[ { value: '', label: '全部流水' }, { value: 'manual_credit', label: '人工充值' }, + { value: 'withdraw_paid', label: '提现打款' }, { value: 'deposit_freeze', label: '冻结押金' }, { value: 'deposit_release', label: '释放押金' }, { value: 'deposit_deduction', label: '扣除押金' }, @@ -697,6 +724,51 @@ export default function WorkerProfilePage() { initialValues={{ amount: undefined, note: '' }} onFinish={submitRecharge} > + + + {financeConfig?.recharge.qrCodeImage ? ( + + ) : null} + + + {financeConfig?.recharge.channelName || '-'} + + + {financeConfig?.recharge.accountName || '-'} + + + {financeConfig?.recharge.accountNo ? ( + + {financeConfig.recharge.accountNo} + + ) : ( + '-' + )} + + + {financeConfig?.recharge.instructions || '请转账后填写备注说明,方便管理员核对。'} + + + + {financeConfig?.withdraw.instructions ? ( + + ) : null} {formatMoney(resolveAvailableForWithdraw(worker, summary))} @@ -962,6 +1042,7 @@ function resolveWorkerStatusColor(status: string) { function formatLedgerType(ledgerType: string) { const labels: Record = { manual_credit: '人工充值', + withdraw_paid: '提现打款', deposit_freeze: '冻结押金', deposit_release: '释放押金', deposit_deduction: '扣除押金', @@ -972,6 +1053,7 @@ function formatLedgerType(ledgerType: string) { function resolveLedgerTagColor(ledgerType: string) { if (ledgerType === 'manual_credit') return 'blue' + if (ledgerType === 'withdraw_paid') return 'purple' if (ledgerType === 'deposit_freeze') return 'gold' if (ledgerType === 'deposit_release') return 'green' if (ledgerType === 'deposit_deduction') return 'red' @@ -995,6 +1077,46 @@ function formatRequestStatus(status: string) { return labels[status] || status || '-' } +function hasRechargeConfig( + financeConfig: { + recharge?: { accountName?: string; accountNo?: string; qrCodeImage?: unknown } + } | undefined, +) { + return Boolean( + financeConfig?.recharge?.accountName || + financeConfig?.recharge?.accountNo || + financeConfig?.recharge?.qrCodeImage, + ) +} + +function renderRechargeRequestSummary(row: WorkerFinanceRequest) { + const snapshot = getRechargeSnapshot(row) + if (!snapshot.channelName && !snapshot.accountName && !snapshot.accountNo) { + return 线下充值,等待管理员入账 + } + + return ( +
+ + {[snapshot.channelName, snapshot.accountName].filter(Boolean).join(' / ') || '线下充值'} + + + {snapshot.accountNo ? maskAccountNo(snapshot.accountNo) : '等待管理员核对'} + +
+ ) +} + +function getRechargeSnapshot(row: WorkerFinanceRequest) { + const payload = asRecord(row.payload) + const financeConfig = asRecord(payload.financeConfig) + return { + channelName: String(financeConfig.channelName || '').trim(), + accountName: String(financeConfig.accountName || '').trim(), + accountNo: String(financeConfig.accountNo || '').trim(), + } +} + function resolveRequestStatusColor(status: string) { if (status === 'approved') return 'green' if (status === 'pending') return 'gold' @@ -1016,3 +1138,9 @@ function maskAccountNo(value: string) { if (text.length <= 8) return text return `${text.slice(0, 4)} **** ${text.slice(-4)}` } + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 00868929..38079d05 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -3,6 +3,8 @@ import type { WorkCategory, WorkOrder, WorkProductRule, + WorkerFinanceConfig, + WorkerFinanceRequest, WorkerLevel, WorkerListResponse, WorkerUser, @@ -110,6 +112,44 @@ export function creditAdminWorkerWallet( ) } +export function fetchAdminWorkerFinanceConfig() { + return apiGet('/api/v1/admin/worker-platform/finance-config') +} + +export function saveAdminWorkerFinanceConfig(payload: { + recharge?: { + enabled?: boolean + channelName?: string + accountName?: string + accountNo?: string + qrCodeImage?: unknown + instructions?: string + } + withdraw?: { + enabled?: boolean + instructions?: string + } +}) { + return apiPost('/api/v1/admin/worker-platform/finance-config', payload) +} + +export function fetchAdminWorkerFinanceRequests(params?: Record) { + return apiGet>( + '/api/v1/admin/worker-platform/finance-requests', + params, + ) +} + +export function reviewAdminWorkerFinanceRequest( + requestId: number, + payload: { status: 'approved' | 'rejected' | 'cancelled'; reviewedNote?: string; note?: string }, +) { + return apiPost<{ request: WorkerFinanceRequest }>( + `/api/v1/admin/worker-platform/finance-requests/${requestId}/review`, + payload, + ) +} + export function fetchAdminWorkOrders(params?: Record) { return apiGet>( '/api/v1/admin/worker-platform/orders', @@ -157,6 +197,12 @@ export function publishAdminWorkOrder(workOrderId: number) { ) } +export function unpublishAdminWorkOrder(workOrderId: number) { + return apiPost<{ order: WorkOrder }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/unpublish`, + ) +} + export function markAdminWorkOrderProblem(workOrderId: number, note: string) { return apiPost<{ order: WorkOrder }>( `/api/v1/admin/worker-platform/orders/${workOrderId}/problem`, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 19353c28..1fdff5a2 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -59,12 +59,34 @@ export type WorkerFinanceRequest = { createdAt: string updatedAt: string reviewedAt: string | null + worker: null | { + workerId: number + username: string + displayName: string + phone: string + } +} + +export type WorkerFinanceConfig = { + recharge: { + enabled: boolean + channelName: string + accountName: string + accountNo: string + qrCodeImage: UploadedFile | null + instructions: string + } + withdraw: { + enabled: boolean + instructions: string + } } export type WorkerProfileResponse = { worker: WorkerUser permissions: Record summary: WorkerProfileSummary + financeConfig: WorkerFinanceConfig } export type WorkerLevel = {