import { query } from '../../db/client.js' import type { JsonObject } from '../../types/json.js' import { nowIso } from '../../utils/time.js' const CHINA_TIME_ZONE = 'Asia/Shanghai' const DAY_MS = 24 * 60 * 60 * 1000 type FinanceRange = { from: string to: string fromDate: string toDate: string bucketFrom: string bucketTo: string } const FINANCE_EVENTS_CTE = ` WITH finance_events AS ( SELECT 'order_paid'::text AS category, o.id::bigint AS source_id, COALESCE(o.paid_at, o.created_at) AS occurred_at, o.total_amount::bigint AS amount, '订单收款'::text AS label, o.platform_order_id::text AS reference_no, COALESCE(NULLIF(CONCAT_WS('/', NULLIF(o.provider, ''), NULLIF(o.platform, '')), ''), '其他')::text AS channel, o.created_at AS request_created_at, NULL::timestamptz AS request_reviewed_at FROM orders o WHERE o.pay_status IN ('paid', 'refunded') AND o.total_amount > 0 UNION ALL SELECT 'order_refund'::text, o.id::bigint, COALESCE(o.updated_at, o.created_at), o.total_amount::bigint, '订单退款'::text, o.platform_order_id::text, COALESCE(NULLIF(CONCAT_WS('/', NULLIF(o.provider, ''), NULLIF(o.platform, '')), ''), '其他')::text, o.created_at, NULL::timestamptz FROM orders o WHERE o.pay_status = 'refunded' AND o.total_amount > 0 UNION ALL SELECT CASE WHEN wl.ledger_type IN ('reward_settlement', 'sharing_reward') THEN 'worker_reward' ELSE 'worker_recovery' END::text, wl.id::bigint, wl.created_at, ABS(wl.amount)::bigint, CASE WHEN wl.ledger_type IN ('reward_settlement', 'sharing_reward') THEN '打手报酬' ELSE '打手资金回收' END::text, COALESCE(wl.related_work_order_id::text, ''), 'worker-platform'::text, wl.created_at, NULL::timestamptz FROM worker_wallet_ledgers wl WHERE wl.ledger_type IN ( 'reward_settlement', 'sharing_reward', 'after_sales_deposit_deduction', 'after_sales_balance_deduction', 'after_sales_debt_offset' ) AND wl.amount <> 0 UNION ALL SELECT 'withdraw_paid'::text, wfr.id::bigint, wfr.reviewed_at, wfr.amount::bigint, '打手提现'::text, wfr.account_channel::text, COALESCE(NULLIF(wfr.account_channel, ''), '其他')::text, wfr.created_at, wfr.reviewed_at FROM worker_finance_requests wfr WHERE wfr.request_type = 'withdraw' AND wfr.status = 'approved' AND wfr.reviewed_at IS NOT NULL AND wfr.amount > 0 UNION ALL SELECT 'recharge'::text, wfr.id::bigint, wfr.reviewed_at, wfr.amount::bigint, '打手充值'::text, wfr.account_channel::text, COALESCE(NULLIF(wfr.account_channel, ''), '其他')::text, wfr.created_at, wfr.reviewed_at FROM worker_finance_requests wfr WHERE wfr.request_type = 'recharge' AND wfr.status = 'approved' AND wfr.reviewed_at IS NOT NULL AND wfr.amount > 0 UNION ALL SELECT 'after_sales_refund'::text, wasc.id::bigint, wasc.refunded_at, wasc.refunded_amount::bigint, '售后追缴退还'::text, wasc.case_no::text, 'worker-platform'::text, wasc.created_at, NULL::timestamptz FROM worker_after_sales_cases wasc WHERE wasc.refunded_at IS NOT NULL AND wasc.refunded_amount > 0 ) ` export async function getAdminFinanceSummary(payload: JsonObject = {}) { const range = resolveFinanceRange(payload, nowIso()) const dateType = normalizeFinanceDateType(payload.dateType) const eventDateExpression = resolveFinanceEventDateExpression(dateType) const [dailyResult, snapshotResult] = await Promise.all([ query( `${FINANCE_EVENTS_CTE} SELECT to_char(day AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS day, COALESCE(SUM(amount) FILTER (WHERE category = 'order_paid'), 0)::bigint AS paid_amount, COUNT(*) FILTER (WHERE category = 'order_paid')::int AS paid_order_count, COALESCE(SUM(amount) FILTER (WHERE category = 'order_refund'), 0)::bigint AS refund_amount, COALESCE(SUM(amount) FILTER (WHERE category = 'worker_reward'), 0)::bigint AS worker_reward_amount, COALESCE(SUM(amount) FILTER (WHERE category = 'withdraw_paid'), 0)::bigint AS withdraw_amount, COALESCE(SUM(amount) FILTER (WHERE category = 'recharge'), 0)::bigint AS recharge_amount, COALESCE(SUM(amount) FILTER (WHERE category = 'after_sales_refund'), 0)::bigint AS after_sales_refund_amount, COALESCE(SUM(amount) FILTER (WHERE category = 'worker_recovery'), 0)::bigint AS worker_recovery_amount FROM generate_series($3::timestamptz, $4::timestamptz - interval '1 day', interval '1 day') day LEFT JOIN finance_events event ON ${eventDateExpression} >= $1::timestamptz AND ${eventDateExpression} < $2::timestamptz AND ${eventDateExpression} >= day AND ${eventDateExpression} < day + interval '1 day' GROUP BY day ORDER BY day DESC`, [range.from, range.to, range.bucketFrom, range.bucketTo], ), query( ` SELECT (SELECT COALESCE(SUM(available_amount), 0)::bigint FROM worker_wallets) AS available_amount, (SELECT COALESCE(SUM(frozen_deposit_amount), 0)::bigint FROM worker_wallets) AS frozen_deposit_amount, (SELECT COALESCE(SUM(pending_unfreeze_amount), 0)::bigint FROM worker_wallets) AS pending_unfreeze_amount, (SELECT COALESCE(SUM(frozen_withdraw_amount), 0)::bigint FROM worker_wallets) AS frozen_withdraw_amount, (SELECT COALESCE(SUM(amount), 0)::bigint FROM worker_finance_requests WHERE status = 'pending' AND request_type = 'withdraw') AS pending_withdraw_amount, (SELECT COUNT(*)::int FROM worker_finance_requests WHERE status = 'pending' AND request_type = 'withdraw') AS pending_withdraw_count `, ), ]) const daily = dailyResult.rows.map(mapFinanceDailyRow) const channelDailyResult = await query( `${FINANCE_EVENTS_CTE} SELECT to_char(${eventDateExpression} AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS day, event.channel, event.category, COUNT(*)::int AS total, COALESCE(SUM(event.amount), 0)::bigint AS amount FROM finance_events event WHERE ${eventDateExpression} >= $1::timestamptz AND ${eventDateExpression} < $2::timestamptz GROUP BY 1, 2, 3 ORDER BY day DESC, event.channel ASC, event.category ASC`, [range.from, range.to], ) const totals = daily.reduce( (result, row) => ({ paidOrderCount: result.paidOrderCount + row.paidOrderCount, paidAmount: result.paidAmount + row.paidAmount, refundAmount: result.refundAmount + row.refundAmount, workerRewardAmount: result.workerRewardAmount + row.workerRewardAmount, withdrawAmount: result.withdrawAmount + row.withdrawAmount, rechargeAmount: result.rechargeAmount + row.rechargeAmount, afterSalesRefundAmount: result.afterSalesRefundAmount + row.afterSalesRefundAmount, workerRecoveryAmount: result.workerRecoveryAmount + row.workerRecoveryAmount, netCashFlow: result.netCashFlow + row.netCashFlow, }), emptyFinanceTotals(), ) const snapshot = snapshotResult.rows[0] || emptyFinanceSnapshot() return { range: { from: range.fromDate, to: range.toDate }, overview: { ...totals, availableAmount: Number(snapshot.available_amount || 0), frozenDepositAmount: Number(snapshot.frozen_deposit_amount || 0), pendingUnfreezeAmount: Number(snapshot.pending_unfreeze_amount || 0), frozenWithdrawAmount: Number(snapshot.frozen_withdraw_amount || 0), pendingWithdrawAmount: Number(snapshot.pending_withdraw_amount || 0), pendingWithdrawCount: Number(snapshot.pending_withdraw_count || 0), }, daily, channelDaily: channelDailyResult.rows.map((row) => ({ date: String(row.day).slice(0, 10), channel: row.channel || '其他', category: row.category, count: Number(row.total || 0), amount: Number(row.amount || 0), })), } } export async function listAdminFinanceTransactions(payload: JsonObject = {}) { const range = resolveFinanceRange(payload, nowIso()) const dateType = normalizeFinanceDateType(payload.dateType) const eventDateExpression = resolveFinanceEventDateExpression(dateType) const page = normalizePositiveInt(payload.page, 1) const pageSize = Math.min(normalizePositiveInt(payload.pageSize, 20), 100) const category = String(payload.category || '').trim() const channel = String(payload.channel || '').trim() const allowedCategories = new Set([ 'order_paid', 'order_refund', 'worker_reward', 'worker_recovery', 'withdraw_paid', 'recharge', 'after_sales_refund', ]) const params: unknown[] = [range.from, range.to] const categoryClause = allowedCategories.has(category) ? ` AND event.category = $${params.push(category)}` : '' const channelClause = channel ? ` AND event.channel = $${params.push(channel)}` : '' const offset = (page - 1) * pageSize const countResult = await query<{ total: number }>( `${FINANCE_EVENTS_CTE} SELECT COUNT(*)::int AS total FROM finance_events event WHERE ${eventDateExpression} >= $1 AND ${eventDateExpression} < $2${categoryClause}${channelClause}`, params, ) const itemsParams = [...params, pageSize, offset] const itemsResult = await query( `${FINANCE_EVENTS_CTE} SELECT category, source_id, occurred_at, amount, label, reference_no, channel FROM finance_events event WHERE ${eventDateExpression} >= $1 AND ${eventDateExpression} < $2${categoryClause}${channelClause} ORDER BY ${eventDateExpression} DESC, event.source_id DESC LIMIT $${itemsParams.length - 1} OFFSET $${itemsParams.length}`, itemsParams, ) return { items: itemsResult.rows.map((row) => ({ category: row.category, sourceId: Number(row.source_id), occurredAt: row.occurred_at, amount: Number(row.amount || 0), direction: ['order_paid', 'recharge', 'worker_recovery'].includes(row.category) ? 'in' : 'out', label: row.label, referenceNo: row.reference_no || '', channel: row.channel || '其他', })), pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) }, range: { from: range.fromDate, to: range.toDate }, } } export function resolveFinanceRange(payload: JsonObject = {}, now = nowIso()): FinanceRange { const today = chinaDateParts(now) const fromInput = String(payload.dateFrom || payload.from || '') const toInput = String(payload.dateTo || payload.to || '') const fromDate = normalizeDateTimeDate(fromInput) || today const toDate = normalizeDateTimeDate(toInput) || fromDate const [safeFrom, safeTo] = fromDate <= toDate ? [fromDate, toDate] : [toDate, fromDate] const from = parseFinanceBoundary(fromInput, safeFrom, false) const to = parseFinanceBoundary(toInput, safeTo, true) const normalizedFrom = Date.parse(from) <= Date.parse(to) ? from : chinaDateToUtcIso(safeFrom) const normalizedTo = Date.parse(from) <= Date.parse(to) ? to : new Date(Date.parse(chinaDateToUtcIso(safeTo)) + DAY_MS).toISOString() return { from: normalizedFrom, to: normalizedTo, fromDate: safeFrom, toDate: safeTo, bucketFrom: chinaDateToUtcIso(safeFrom), bucketTo: new Date(Date.parse(chinaDateToUtcIso(safeTo)) + DAY_MS).toISOString(), } } export function calculateFinanceNetCashFlow(row: { paidAmount: number refundAmount: number rechargeAmount: number workerRecoveryAmount: number withdrawAmount: number afterSalesRefundAmount: number }) { return ( row.paidAmount + row.rechargeAmount - row.refundAmount - row.withdrawAmount - row.afterSalesRefundAmount + row.workerRecoveryAmount ) } type FinanceDailyRow = { day: string paid_amount: number paid_order_count: number refund_amount: number worker_reward_amount: number withdraw_amount: number recharge_amount: number after_sales_refund_amount: number worker_recovery_amount: number } type FinanceSnapshotRow = { available_amount: number frozen_deposit_amount: number pending_unfreeze_amount: number frozen_withdraw_amount: number pending_withdraw_amount: number pending_withdraw_count: number } type FinanceTransactionRow = { category: string source_id: number occurred_at: string amount: number label: string reference_no: string channel: string } type FinanceChannelDailyRow = { day: string channel: string category: string total: number amount: number } function mapFinanceDailyRow(row: FinanceDailyRow) { const mapped = { date: String(row.day).slice(0, 10), paidOrderCount: Number(row.paid_order_count || 0), paidAmount: Number(row.paid_amount || 0), refundAmount: Number(row.refund_amount || 0), workerRewardAmount: Number(row.worker_reward_amount || 0), withdrawAmount: Number(row.withdraw_amount || 0), rechargeAmount: Number(row.recharge_amount || 0), afterSalesRefundAmount: Number(row.after_sales_refund_amount || 0), workerRecoveryAmount: Number(row.worker_recovery_amount || 0), } return { ...mapped, netCashFlow: calculateFinanceNetCashFlow(mapped) } } function emptyFinanceTotals() { return { paidOrderCount: 0, paidAmount: 0, refundAmount: 0, workerRewardAmount: 0, withdrawAmount: 0, rechargeAmount: 0, afterSalesRefundAmount: 0, workerRecoveryAmount: 0, netCashFlow: 0, } } function emptyFinanceSnapshot(): FinanceSnapshotRow { return { available_amount: 0, frozen_deposit_amount: 0, pending_unfreeze_amount: 0, frozen_withdraw_amount: 0, pending_withdraw_amount: 0, pending_withdraw_count: 0, } } function normalizePositiveInt(value: unknown, fallback: number) { const parsed = Number(value) return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback } function normalizeFinanceDateType(value: unknown): 'occurred' | 'created' | 'reviewed' { const normalized = String(value || '').trim() if (normalized === 'created' || normalized === 'reviewed') return normalized return 'occurred' } function resolveFinanceEventDateExpression(dateType: 'occurred' | 'created' | 'reviewed') { if (dateType === 'created') return 'COALESCE(event.request_created_at, event.occurred_at)' if (dateType === 'reviewed') return 'event.request_reviewed_at' return 'event.occurred_at' } function normalizeDateTimeDate(value: string) { const match = value.trim().match(/^(\d{4}-\d{2}-\d{2})/) if (!match || Number.isNaN(Date.parse(`${match[1]}T00:00:00Z`))) return '' return match[1] } function parseFinanceBoundary(value: string, fallbackDate: string, end: boolean) { const normalized = value.trim().replace('T', ' ') if (!normalized || /^\d{4}-\d{2}-\d{2}$/.test(normalized)) { return new Date(Date.parse(chinaDateToUtcIso(fallbackDate)) + (end ? DAY_MS : 0)).toISOString() } const minuteMatch = normalized.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})$/) const valueWithSeconds = minuteMatch ? `${minuteMatch[1]} ${minuteMatch[2]}:${end ? '59' : '00'}` : normalized const parsed = new Date(`${valueWithSeconds}+08:00`) return Number.isNaN(parsed.getTime()) ? new Date(Date.parse(chinaDateToUtcIso(fallbackDate)) + (end ? DAY_MS : 0)).toISOString() : parsed.toISOString() } function chinaDateParts(value: string) { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: CHINA_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(new Date(value)) const result = Object.fromEntries(parts.map((part) => [part.type, part.value])) return `${result.year}-${result.month}-${result.day}` } function chinaDateToUtcIso(value: string) { return new Date(`${value}T00:00:00+08:00`).toISOString() }