新增财务角色与财务报表并完善后台权限
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
-- 财务专员角色与财务模块权限。
|
||||||
|
INSERT INTO admin_roles (role_key, role_name, description)
|
||||||
|
VALUES ('finance', '财务', '负责财务报表、资金申请审核和打款核对')
|
||||||
|
ON CONFLICT (role_key) DO UPDATE SET
|
||||||
|
role_name = EXCLUDED.role_name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description)
|
||||||
|
VALUES
|
||||||
|
('finance.view', '查看财务报表', '财务管理', '查看财务总览、日报和资金明细'),
|
||||||
|
('finance.review', '审核资金申请', '财务管理', '审核充值和提现申请'),
|
||||||
|
('finance.config', '配置财务规则', '财务管理', '维护充值、提现和押金规则'),
|
||||||
|
('finance.export', '导出财务数据', '财务管理', '导出筛选后的财务明细')
|
||||||
|
ON CONFLICT (permission_key) DO UPDATE SET
|
||||||
|
permission_name = EXCLUDED.permission_name,
|
||||||
|
permission_group = EXCLUDED.permission_group,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
updated_at = NOW();
|
||||||
|
|
||||||
|
-- 管理员保留全部权限;运营兼容现有资金审核能力;财务专员只获得财务相关权限。
|
||||||
|
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||||
|
SELECT 'admin', permission_key
|
||||||
|
FROM admin_permissions
|
||||||
|
WHERE permission_key LIKE 'finance.%'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||||
|
VALUES ('operator', 'finance.view'), ('operator', 'finance.review')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||||
|
VALUES ('finance', 'finance.view'), ('finance', 'finance.review')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -5,6 +5,7 @@ import auditLogsRouter from './admin/audit-logs.js'
|
|||||||
import cloudtentaclesRecordsRouter from './admin/cloudtentacles-records.js'
|
import cloudtentaclesRecordsRouter from './admin/cloudtentacles-records.js'
|
||||||
import dashboardRouter from './admin/dashboard.js'
|
import dashboardRouter from './admin/dashboard.js'
|
||||||
import devMockRouter from './admin/dev-mock.js'
|
import devMockRouter from './admin/dev-mock.js'
|
||||||
|
import financeRouter from './admin/finance.js'
|
||||||
import filesRouter from './admin/files.js'
|
import filesRouter from './admin/files.js'
|
||||||
import kuaishouIndustryRouter from './admin/kuaishou-industry.js'
|
import kuaishouIndustryRouter from './admin/kuaishou-industry.js'
|
||||||
import ordersRouter from './admin/orders.js'
|
import ordersRouter from './admin/orders.js'
|
||||||
@@ -24,6 +25,7 @@ router.use(authRouter)
|
|||||||
router.use(requireAdminSession)
|
router.use(requireAdminSession)
|
||||||
router.use(filesRouter)
|
router.use(filesRouter)
|
||||||
router.use(dashboardRouter)
|
router.use(dashboardRouter)
|
||||||
|
router.use(financeRouter)
|
||||||
router.use(usersRouter)
|
router.use(usersRouter)
|
||||||
router.use(rbacRouter)
|
router.use(rbacRouter)
|
||||||
router.use(auditLogsRouter)
|
router.use(auditLogsRouter)
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import type { JsonObject } from '../../types/json.js'
|
|||||||
|
|
||||||
import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js'
|
import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js'
|
||||||
import { getAdminLoginLogs } from '../../services/admin/admin-login-log-service.js'
|
import { getAdminLoginLogs } from '../../services/admin/admin-login-log-service.js'
|
||||||
import { createJsonHandler } from './session.js'
|
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
router.use('/dashboard', requireAdminRoles(['admin', 'operator', 'support']))
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/dashboard/summary',
|
'/dashboard/summary',
|
||||||
createJsonHandler(() => getAdminDashboardSummary(), {
|
createJsonHandler(() => getAdminDashboardSummary(), {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminFinanceSummary,
|
||||||
|
listAdminFinanceTransactions,
|
||||||
|
} from '../../services/admin/admin-finance-service.js'
|
||||||
|
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/finance/summary',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'finance']),
|
||||||
|
createJsonHandler((req) => getAdminFinanceSummary(req.query), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取财务汇总失败',
|
||||||
|
scope: '[admin/finance/summary]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/finance/transactions',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'finance']),
|
||||||
|
createJsonHandler((req) => listAdminFinanceTransactions(req.query), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取财务明细失败',
|
||||||
|
scope: '[admin/finance/transactions]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -7,6 +7,8 @@ import type { AdminOrderRouteParams, AdminOrderRouteQuery } from '../../types/ad
|
|||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
router.use('/orders', requireAdminRoles(['admin', 'operator', 'support']))
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/orders',
|
'/orders',
|
||||||
createJsonHandler((req) => getAdminOrders(req.query as AdminOrderRouteQuery), {
|
createJsonHandler((req) => getAdminOrders(req.query as AdminOrderRouteQuery), {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type { AdminTaskActionResponse } from '../../types/admin/write-models.js'
|
|||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
router.use('/tasks', requireAdminRoles(['admin', 'operator', 'support']))
|
||||||
|
|
||||||
function getTaskId(req: Request): string {
|
function getTaskId(req: Request): string {
|
||||||
return String((req.params as AdminTaskRouteParams).taskId || '')
|
return String((req.params as AdminTaskRouteParams).taskId || '')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const router = Router()
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/worker-platform/summary',
|
'/worker-platform/summary',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
createJsonHandler(() => getAdminWorkerPlatformSummary(), {
|
createJsonHandler(() => getAdminWorkerPlatformSummary(), {
|
||||||
successMessage: 'ok',
|
successMessage: 'ok',
|
||||||
errorMessage: '读取接单平台概览失败',
|
errorMessage: '读取接单平台概览失败',
|
||||||
@@ -342,7 +343,7 @@ router.get(
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/worker-platform/finance-config',
|
'/worker-platform/finance-config',
|
||||||
requireAdminRoles(['admin', 'operator', 'support']),
|
requireAdminRoles(['admin', 'operator', 'support', 'finance']),
|
||||||
createJsonHandler(() => getAdminWorkerFinanceConfig(), {
|
createJsonHandler(() => getAdminWorkerFinanceConfig(), {
|
||||||
successMessage: 'ok',
|
successMessage: 'ok',
|
||||||
errorMessage: '读取资金配置失败',
|
errorMessage: '读取资金配置失败',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const router = Router()
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/worker-platform/finance-requests',
|
'/worker-platform/finance-requests',
|
||||||
requireAdminRoles(['admin', 'operator', 'support']),
|
requireAdminRoles(['admin', 'operator', 'support', 'finance']),
|
||||||
createJsonHandler((req) => listAdminWorkerFinanceRequests(req.query), {
|
createJsonHandler((req) => listAdminWorkerFinanceRequests(req.query), {
|
||||||
successMessage: 'ok',
|
successMessage: 'ok',
|
||||||
errorMessage: '读取资金申请失败',
|
errorMessage: '读取资金申请失败',
|
||||||
@@ -20,7 +20,7 @@ router.get(
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/worker-platform/finance-requests/:requestId/review',
|
'/worker-platform/finance-requests/:requestId/review',
|
||||||
requireAdminRoles(['admin', 'operator']),
|
requireAdminRoles(['admin', 'operator', 'finance']),
|
||||||
createJsonHandler(
|
createJsonHandler(
|
||||||
(req) =>
|
(req) =>
|
||||||
reviewAdminWorkerFinanceRequest(
|
reviewAdminWorkerFinanceRequest(
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { normalizeAdminRole } from './admin-auth-service.js'
|
||||||
|
|
||||||
|
test('normalizeAdminRole preserves the dedicated finance role', () => {
|
||||||
|
assert.equal(normalizeAdminRole('finance'), 'finance')
|
||||||
|
assert.equal(normalizeAdminRole('FINANCE'), 'finance')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizeAdminRole keeps unknown roles compatible as operator', () => {
|
||||||
|
assert.equal(normalizeAdminRole('unknown-role'), 'operator')
|
||||||
|
})
|
||||||
@@ -34,7 +34,7 @@ export type AdminSession = {
|
|||||||
permissions: string[]
|
permissions: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminRole = 'admin' | 'operator' | 'support'
|
type AdminRole = 'admin' | 'operator' | 'support' | 'finance'
|
||||||
type AdminUserStatus = 'active' | 'disabled'
|
type AdminUserStatus = 'active' | 'disabled'
|
||||||
|
|
||||||
export async function ensureAdminUsersBootstrapped(): Promise<void> {
|
export async function ensureAdminUsersBootstrapped(): Promise<void> {
|
||||||
@@ -513,6 +513,10 @@ export function normalizeAdminRole(role: unknown): AdminRole {
|
|||||||
return 'support'
|
return 'support'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized === 'finance') {
|
||||||
|
return 'finance'
|
||||||
|
}
|
||||||
|
|
||||||
return 'operator'
|
return 'operator'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +543,7 @@ function normalizeRoleQuery(role: unknown): string {
|
|||||||
const normalized = String(role || '')
|
const normalized = String(role || '')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
return ['admin', 'operator', 'support'].includes(normalized) ? normalized : ''
|
return ['admin', 'operator', 'support', 'finance'].includes(normalized) ? normalized : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatusQuery(status: unknown): string {
|
function normalizeStatusQuery(status: unknown): string {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { calculateFinanceNetCashFlow, resolveFinanceRange } from './admin-finance-service.js'
|
||||||
|
|
||||||
|
test('resolveFinanceRange uses China date boundaries and defaults to today', () => {
|
||||||
|
const result = resolveFinanceRange({}, '2026-09-02T01:00:00.000Z')
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
from: '2026-09-01T16:00:00.000Z',
|
||||||
|
to: '2026-09-02T16:00:00.000Z',
|
||||||
|
fromDate: '2026-09-02',
|
||||||
|
toDate: '2026-09-02',
|
||||||
|
bucketFrom: '2026-09-01T16:00:00.000Z',
|
||||||
|
bucketTo: '2026-09-02T16:00:00.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFinanceRange swaps reversed dates and keeps inclusive end date', () => {
|
||||||
|
const result = resolveFinanceRange(
|
||||||
|
{ dateFrom: '2026-09-05', dateTo: '2026-09-03' },
|
||||||
|
'2026-09-02T00:00:00.000Z',
|
||||||
|
)
|
||||||
|
assert.equal(result.fromDate, '2026-09-03')
|
||||||
|
assert.equal(result.toDate, '2026-09-05')
|
||||||
|
assert.equal(result.from, '2026-09-02T16:00:00.000Z')
|
||||||
|
assert.equal(result.to, '2026-09-05T16:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFinanceRange preserves minute precision while bucketing by China calendar day', () => {
|
||||||
|
const result = resolveFinanceRange(
|
||||||
|
{ dateFrom: '2026-09-02 10:15', dateTo: '2026-09-02 18:30' },
|
||||||
|
'2026-09-02T00:00:00.000Z',
|
||||||
|
)
|
||||||
|
assert.equal(result.from, '2026-09-02T02:15:00.000Z')
|
||||||
|
assert.equal(result.to, '2026-09-02T10:30:59.000Z')
|
||||||
|
assert.equal(result.bucketFrom, '2026-09-01T16:00:00.000Z')
|
||||||
|
assert.equal(result.bucketTo, '2026-09-02T16:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calculateFinanceNetCashFlow uses cash basis and excludes reward accrual', () => {
|
||||||
|
assert.equal(
|
||||||
|
calculateFinanceNetCashFlow({
|
||||||
|
paidAmount: 10000,
|
||||||
|
refundAmount: 1000,
|
||||||
|
rechargeAmount: 500,
|
||||||
|
workerRecoveryAmount: 1000,
|
||||||
|
withdrawAmount: 3000,
|
||||||
|
afterSalesRefundAmount: 200,
|
||||||
|
}),
|
||||||
|
7300,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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 [dailyResult, snapshotResult] = await Promise.all([
|
||||||
|
query<FinanceDailyRow>(
|
||||||
|
`${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 event.occurred_at >= $1::timestamptz
|
||||||
|
AND event.occurred_at < $2::timestamptz
|
||||||
|
AND event.occurred_at >= day
|
||||||
|
AND event.occurred_at < day + interval '1 day'
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day DESC`,
|
||||||
|
[range.from, range.to, range.bucketFrom, range.bucketTo],
|
||||||
|
),
|
||||||
|
query<FinanceSnapshotRow>(
|
||||||
|
`
|
||||||
|
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<FinanceChannelDailyRow>(
|
||||||
|
`${FINANCE_EVENTS_CTE}
|
||||||
|
SELECT
|
||||||
|
to_char(event.occurred_at 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 event.occurred_at >= $1::timestamptz
|
||||||
|
AND event.occurred_at < $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 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 event.occurred_at >= $1
|
||||||
|
AND event.occurred_at < $2${categoryClause}${channelClause}`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
const itemsParams = [...params, pageSize, offset]
|
||||||
|
const itemsResult = await query<FinanceTransactionRow>(
|
||||||
|
`${FINANCE_EVENTS_CTE}
|
||||||
|
SELECT category, source_id, occurred_at, amount, label, reference_no, channel
|
||||||
|
FROM finance_events event
|
||||||
|
WHERE event.occurred_at >= $1
|
||||||
|
AND event.occurred_at < $2${categoryClause}${channelClause}
|
||||||
|
ORDER BY event.occurred_at 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 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()
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ import { query, withTransaction } from '../../db/client.js'
|
|||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
|
|
||||||
export const ADMIN_PERMISSION_KEYS = [
|
export const ADMIN_PERMISSION_KEYS = [
|
||||||
|
'finance.view',
|
||||||
|
'finance.review',
|
||||||
|
'finance.config',
|
||||||
|
'finance.export',
|
||||||
'worker_order.view',
|
'worker_order.view',
|
||||||
'worker_order.edit',
|
'worker_order.edit',
|
||||||
'worker_order.configure_share',
|
'worker_order.configure_share',
|
||||||
@@ -112,7 +116,7 @@ export async function updateAdminRolePermissions(roleKey: unknown, permissionKey
|
|||||||
const normalizedRole = String(roleKey || '')
|
const normalizedRole = String(roleKey || '')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
if (!['admin', 'operator', 'support'].includes(normalizedRole)) {
|
if (!['admin', 'operator', 'support', 'finance'].includes(normalizedRole)) {
|
||||||
throw createHttpError('角色不存在或不可修改', {
|
throw createHttpError('角色不存在或不可修改', {
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
errorCode: 'admin_role_invalid',
|
errorCode: 'admin_role_invalid',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import AdminLayout from '@/layouts/AdminLayout'
|
|||||||
import WorkerLayout from '@/layouts/WorkerLayout'
|
import WorkerLayout from '@/layouts/WorkerLayout'
|
||||||
|
|
||||||
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
|
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
|
||||||
|
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
||||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
||||||
const AdminCloudtentaclesRecordsPage = lazy(
|
const AdminCloudtentaclesRecordsPage = lazy(
|
||||||
() => import('@/pages/admin/AdminCloudtentaclesRecordsPage'),
|
() => import('@/pages/admin/AdminCloudtentaclesRecordsPage'),
|
||||||
@@ -54,9 +55,11 @@ function RequireAdmin() {
|
|||||||
return <Outlet />
|
return <Outlet />
|
||||||
}
|
}
|
||||||
|
|
||||||
function RequireRole({ roles }: { roles: Array<'admin' | 'operator' | 'support'> }) {
|
function RequireRole({ roles }: { roles: Array<'admin' | 'operator' | 'support' | 'finance'> }) {
|
||||||
if (!roles.includes(getAdminRole())) {
|
if (!roles.includes(getAdminRole())) {
|
||||||
return <Navigate to="/admin/dashboard" replace />
|
return (
|
||||||
|
<Navigate to={getAdminRole() === 'finance' ? '/admin/finance' : '/admin/dashboard'} replace />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
@@ -104,15 +107,22 @@ function AdminApplication() {
|
|||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
<Route path="/admin" element={<AdminLayout />}>
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
|
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
|
||||||
<Route path="dashboard" element={<AdminDashboardPage />} />
|
<Route path="dashboard" element={<AdminDashboardPage />} />
|
||||||
<Route path="orders" element={<AdminOrdersPage />} />
|
<Route path="orders" element={<AdminOrdersPage />} />
|
||||||
<Route path="orders/:orderId" element={<AdminOrderDetailPage />} />
|
<Route path="orders/:orderId" element={<AdminOrderDetailPage />} />
|
||||||
<Route path="tasks" element={<AdminTasksPage />} />
|
<Route path="tasks" element={<AdminTasksPage />} />
|
||||||
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
|
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
|
||||||
|
<Route path="finance" element={<AdminFinancePage />} />
|
||||||
|
</Route>
|
||||||
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
|
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
|
||||||
|
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
|
||||||
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
|
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
|
||||||
<Route path="cloudtentacles-records" element={<AdminCloudtentaclesRecordsPage />} />
|
<Route path="cloudtentacles-records" element={<AdminCloudtentaclesRecordsPage />} />
|
||||||
<Route path="dev-mock" element={<AdminDevMockPage />} />
|
<Route path="dev-mock" element={<AdminDevMockPage />} />
|
||||||
|
</Route>
|
||||||
<Route element={<RequireRole roles={['admin']} />}>
|
<Route element={<RequireRole roles={['admin']} />}>
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
|
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
|
AccountBookOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
BugOutlined,
|
BugOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
@@ -87,12 +88,14 @@ export default function AdminLayout() {
|
|||||||
const devMockStatusQuery = useQuery({
|
const devMockStatusQuery = useQuery({
|
||||||
queryKey: ['admin-dev-mock-status'],
|
queryKey: ['admin-dev-mock-status'],
|
||||||
queryFn: () => fetchDevMockStatus(),
|
queryFn: () => fetchDevMockStatus(),
|
||||||
|
enabled: role !== 'finance',
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
const devMockEnabled = Boolean(devMockStatusQuery.data?.data?.enabled)
|
const devMockEnabled = Boolean(devMockStatusQuery.data?.data?.enabled)
|
||||||
const notificationsQuery = useQuery({
|
const notificationsQuery = useQuery({
|
||||||
queryKey: ['admin-notifications'],
|
queryKey: ['admin-notifications'],
|
||||||
queryFn: fetchAdminNotifications,
|
queryFn: fetchAdminNotifications,
|
||||||
|
enabled: role !== 'finance',
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
const notifications = notificationsQuery.data?.data.items || []
|
const notifications = notificationsQuery.data?.data.items || []
|
||||||
@@ -100,7 +103,7 @@ export default function AdminLayout() {
|
|||||||
|
|
||||||
useRealtimeEvents({
|
useRealtimeEvents({
|
||||||
url: '/api/v1/admin/realtime',
|
url: '/api/v1/admin/realtime',
|
||||||
token: getAdminToken(),
|
token: role === 'finance' ? '' : getAdminToken(),
|
||||||
onEvent: (event) => applyAdminRealtimeEvent(event, queryClient),
|
onEvent: (event) => applyAdminRealtimeEvent(event, queryClient),
|
||||||
// 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。
|
// 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。
|
||||||
onAuthExpired: () => {
|
onAuthExpired: () => {
|
||||||
@@ -110,13 +113,30 @@ export default function AdminLayout() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const menuItems = useMemo<MenuProps['items']>(() => {
|
const menuItems = useMemo<MenuProps['items']>(() => {
|
||||||
const operationItems: MenuProps['items'] = [
|
const operationItems: MenuProps['items'] =
|
||||||
|
role === 'finance'
|
||||||
|
? [
|
||||||
|
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
|
||||||
|
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
|
||||||
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
|
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
|
||||||
|
...(role === 'support'
|
||||||
|
? []
|
||||||
|
: [{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' }]),
|
||||||
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
|
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
|
||||||
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
|
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
|
||||||
{ key: '/admin/kuaishou-industry', icon: <SafetyCertificateOutlined />, label: '电子凭证' },
|
{
|
||||||
{ key: '/admin/cloudtentacles-records', icon: <FileSearchOutlined />, label: '发货记录' },
|
key: '/admin/kuaishou-industry',
|
||||||
|
icon: <SafetyCertificateOutlined />,
|
||||||
|
label: '电子凭证',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/cloudtentacles-records',
|
||||||
|
icon: <FileSearchOutlined />,
|
||||||
|
label: '发货记录',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const items: MenuProps['items'] = [
|
const items: MenuProps['items'] = [
|
||||||
@@ -601,6 +621,7 @@ function playNotificationSound(kind: NotificationAlertKind) {
|
|||||||
|
|
||||||
function resolveSelectedKey(pathname: string) {
|
function resolveSelectedKey(pathname: string) {
|
||||||
if (pathname.startsWith('/admin/orders')) return '/admin/orders'
|
if (pathname.startsWith('/admin/orders')) return '/admin/orders'
|
||||||
|
if (pathname.startsWith('/admin/finance')) return '/admin/finance'
|
||||||
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
|
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
|
||||||
if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform'
|
if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform'
|
||||||
if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry'
|
if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry'
|
||||||
@@ -629,5 +650,6 @@ function buildNotificationActionPath(notification: AdminNotification) {
|
|||||||
function roleLabel(role: string) {
|
function roleLabel(role: string) {
|
||||||
if (role === 'admin') return '管理员'
|
if (role === 'admin') return '管理员'
|
||||||
if (role === 'operator') return '普通运营'
|
if (role === 'operator') return '普通运营'
|
||||||
|
if (role === 'finance') return '财务'
|
||||||
return '客服'
|
return '客服'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||||
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
|
import dayjs from '@/lib/dayjs'
|
||||||
|
import { fetchAdminFinanceSummary, fetchAdminFinanceTransactions } from '@/services/admin'
|
||||||
|
import type {
|
||||||
|
AdminFinanceChannelDailyItem,
|
||||||
|
AdminFinanceDailyItem,
|
||||||
|
AdminFinanceTransaction,
|
||||||
|
} from '@/types/admin/finance'
|
||||||
|
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import { formatMoney } from './panels/shared'
|
||||||
|
|
||||||
|
type FinanceFilterForm = { dateRange?: unknown }
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS = [
|
||||||
|
{ value: '', label: '全部账目' },
|
||||||
|
{ value: 'order_paid', label: '订单收款' },
|
||||||
|
{ value: 'order_refund', label: '订单退款' },
|
||||||
|
{ value: 'worker_reward', label: '打手报酬' },
|
||||||
|
{ value: 'withdraw_paid', label: '打手提现' },
|
||||||
|
{ value: 'recharge', label: '打手充值' },
|
||||||
|
{ value: 'worker_recovery', label: '打手资金回收' },
|
||||||
|
{ value: 'after_sales_refund', label: '售后追缴退还' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AdminFinancePage() {
|
||||||
|
const [form] = Form.useForm<FinanceFilterForm>()
|
||||||
|
const [filters, setFilters] = useState({ dateFrom: '', dateTo: '' })
|
||||||
|
const [category, setCategory] = useState('')
|
||||||
|
const [channel, setChannel] = useState('')
|
||||||
|
const [channelSummaryFilter, setChannelSummaryFilter] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||||
|
|
||||||
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: ['admin-finance-summary', filters],
|
||||||
|
queryFn: () => fetchAdminFinanceSummary(filters),
|
||||||
|
})
|
||||||
|
const transactionsQuery = useQuery({
|
||||||
|
queryKey: ['admin-finance-transactions', filters, category, page, pageSize],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchAdminFinanceTransactions({
|
||||||
|
...filters,
|
||||||
|
category: category || undefined,
|
||||||
|
channel: channel || undefined,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const overview = summaryQuery.data?.data.overview
|
||||||
|
const daily = summaryQuery.data?.data.daily || []
|
||||||
|
const channelDaily = (summaryQuery.data?.data.channelDaily || []).filter(
|
||||||
|
(item) => !channelSummaryFilter || item.channel === channelSummaryFilter,
|
||||||
|
)
|
||||||
|
const transactionData = transactionsQuery.data?.data
|
||||||
|
|
||||||
|
function applyFilters(values: FinanceFilterForm) {
|
||||||
|
const range = Array.isArray(values.dateRange) ? values.dateRange : []
|
||||||
|
const dateFrom =
|
||||||
|
range[0] && dayjs(range[0]).isValid() ? dayjs(range[0]).format('YYYY-MM-DD HH:mm:ss') : ''
|
||||||
|
const dateTo =
|
||||||
|
range[1] && dayjs(range[1]).isValid()
|
||||||
|
? dayjs(range[1]).second(59).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
: ''
|
||||||
|
setPage(1)
|
||||||
|
setFilters({ dateFrom, dateTo })
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
form.resetFields()
|
||||||
|
setCategory('')
|
||||||
|
setChannel('')
|
||||||
|
setChannelSummaryFilter('')
|
||||||
|
setPage(1)
|
||||||
|
setFilters({ dateFrom: '', dateTo: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyColumns: TableColumnsType<AdminFinanceDailyItem> = [
|
||||||
|
{ title: '日期', dataIndex: 'date', width: 120 },
|
||||||
|
{ title: '支付订单', dataIndex: 'paidOrderCount', width: 100 },
|
||||||
|
{ title: '收款', dataIndex: 'paidAmount', render: formatMoney },
|
||||||
|
{ title: '退款', dataIndex: 'refundAmount', render: formatMoney },
|
||||||
|
{ title: '打手报酬', dataIndex: 'workerRewardAmount', render: formatMoney },
|
||||||
|
{ title: '已提现', dataIndex: 'withdrawAmount', render: formatMoney },
|
||||||
|
{ title: '充值入账', dataIndex: 'rechargeAmount', render: formatMoney },
|
||||||
|
{ title: '售后退还', dataIndex: 'afterSalesRefundAmount', render: formatMoney },
|
||||||
|
{
|
||||||
|
title: '净现金流',
|
||||||
|
dataIndex: 'netCashFlow',
|
||||||
|
render: (value: number) => (
|
||||||
|
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
|
||||||
|
{formatMoney(value)}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const transactionColumns: TableColumnsType<AdminFinanceTransaction> = [
|
||||||
|
{
|
||||||
|
title: '发生时间',
|
||||||
|
dataIndex: 'occurredAt',
|
||||||
|
width: 180,
|
||||||
|
render: (value) => formatAdminDateTime(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'label',
|
||||||
|
width: 150,
|
||||||
|
render: (value: string, row) => (
|
||||||
|
<Tag color={row.direction === 'in' ? 'green' : 'orange'}>{value}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '渠道', dataIndex: 'channel', width: 150 },
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
width: 140,
|
||||||
|
render: (value: number, row) => (
|
||||||
|
<Typography.Text type={row.direction === 'in' ? 'success' : 'danger'}>
|
||||||
|
{row.direction === 'in' ? '+' : '-'}
|
||||||
|
{formatMoney(value)}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '来源编号', dataIndex: 'sourceId', width: 110 },
|
||||||
|
{
|
||||||
|
title: '关联单号/渠道',
|
||||||
|
dataIndex: 'referenceNo',
|
||||||
|
render: (value: string) => value || '-',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-stack">
|
||||||
|
<PageHeader title="财务" description="按日期查看订单收款、退款、打手结算和平台现金流。" />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Form<FinanceFilterForm>
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
className="filter-form"
|
||||||
|
onFinish={applyFilters}
|
||||||
|
>
|
||||||
|
<Form.Item name="dateRange" label="账务日期">
|
||||||
|
<AdminRangePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{summaryQuery.error ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message={
|
||||||
|
summaryQuery.error instanceof Error ? summaryQuery.error.message : '读取财务汇总失败'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<FinanceStatistic title="订单收款" value={overview?.paidAmount || 0} />
|
||||||
|
<FinanceStatistic title="订单退款" value={overview?.refundAmount || 0} />
|
||||||
|
<FinanceStatistic title="打手报酬" value={overview?.workerRewardAmount || 0} />
|
||||||
|
<FinanceStatistic title="已确认提现" value={overview?.withdrawAmount || 0} />
|
||||||
|
<FinanceStatistic title="净现金流" value={overview?.netCashFlow || 0} />
|
||||||
|
<FinanceStatistic title="待提现" value={overview?.pendingWithdrawAmount || 0} />
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="每日账目"
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={summaryQuery.isFetching}
|
||||||
|
onClick={() => void summaryQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<AdminFinanceDailyItem>
|
||||||
|
rowKey="date"
|
||||||
|
loading={summaryQuery.isLoading}
|
||||||
|
dataSource={daily}
|
||||||
|
columns={dailyColumns}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1080 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="渠道日报"
|
||||||
|
extra={
|
||||||
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
value={channelSummaryFilter || undefined}
|
||||||
|
placeholder="全部渠道"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
options={Array.from(
|
||||||
|
new Set((summaryQuery.data?.data.channelDaily || []).map((item) => item.channel)),
|
||||||
|
).map((value) => ({ value, label: value }))}
|
||||||
|
onChange={(value) => setChannelSummaryFilter(value || '')}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">按日期、渠道和账目类型汇总</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<AdminFinanceChannelDailyItem>
|
||||||
|
rowKey={(row) => `${row.date}-${row.channel}-${row.category}`}
|
||||||
|
loading={summaryQuery.isLoading}
|
||||||
|
dataSource={channelDaily}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: '日期', dataIndex: 'date', width: 120 },
|
||||||
|
{ title: '渠道', dataIndex: 'channel', width: 180 },
|
||||||
|
{ title: '账目类型', dataIndex: 'category', width: 150, render: formatFinanceCategory },
|
||||||
|
{ title: '笔数', dataIndex: 'count', width: 90 },
|
||||||
|
{ title: '金额', dataIndex: 'amount', render: formatMoney },
|
||||||
|
]}
|
||||||
|
scroll={{ x: 760 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="资金明细"
|
||||||
|
extra={
|
||||||
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
value={category}
|
||||||
|
options={CATEGORY_OPTIONS}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
onChange={(value) => {
|
||||||
|
setCategory(value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
value={channel || undefined}
|
||||||
|
placeholder="全部渠道"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
options={Array.from(
|
||||||
|
new Set((summaryQuery.data?.data.channelDaily || []).map((item) => item.channel)),
|
||||||
|
).map((value) => ({ value, label: value }))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setChannel(value || '')
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={transactionsQuery.isFetching}
|
||||||
|
onClick={() => void transactionsQuery.refetch()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<AdminFinanceTransaction>
|
||||||
|
rowKey={(row) => `${row.category}-${row.sourceId}-${row.occurredAt}`}
|
||||||
|
loading={transactionsQuery.isLoading}
|
||||||
|
dataSource={transactionData?.items || []}
|
||||||
|
columns={transactionColumns}
|
||||||
|
pagination={buildAdminTablePagination({
|
||||||
|
current: transactionData?.pagination.page || page,
|
||||||
|
pageSize: transactionData?.pagination.pageSize || pageSize,
|
||||||
|
total: transactionData?.pagination.total || 0,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
scroll={{ x: 780 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFinanceCategory(value: string) {
|
||||||
|
return CATEGORY_OPTIONS.find((item) => item.value === value)?.label || value || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinanceStatistic({ title, value }: { title: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<Col xs={24} sm={12} xl={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title={title} value={formatMoney(value)} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ type CreateUserForm = {
|
|||||||
const USER_ROLE_OPTIONS = [
|
const USER_ROLE_OPTIONS = [
|
||||||
{ label: '普通运营', value: 'operator' },
|
{ label: '普通运营', value: 'operator' },
|
||||||
{ label: '客服', value: 'support' },
|
{ label: '客服', value: 'support' },
|
||||||
|
{ label: '财务', value: 'finance' },
|
||||||
{ label: '管理员', value: 'admin' },
|
{ label: '管理员', value: 'admin' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -482,6 +483,7 @@ export default function AdminUsersPage() {
|
|||||||
function formatRoleLabel(role: AdminRole) {
|
function formatRoleLabel(role: AdminRole) {
|
||||||
if (role === 'admin') return '管理员'
|
if (role === 'admin') return '管理员'
|
||||||
if (role === 'support') return '客服'
|
if (role === 'support') return '客服'
|
||||||
|
if (role === 'finance') return '财务'
|
||||||
return '普通运营'
|
return '普通运营'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
const role = getAdminRole()
|
const role = getAdminRole()
|
||||||
const isAdmin = role === 'admin'
|
const isAdmin = role === 'admin'
|
||||||
const isSupport = role === 'support'
|
const isSupport = role === 'support'
|
||||||
const visibleActiveTab = !isAdmin && activeTab === 'notifications' ? 'orders' : activeTab
|
const isFinance = role === 'finance'
|
||||||
|
const visibleActiveTab = isFinance
|
||||||
|
? 'finance'
|
||||||
|
: !isAdmin && activeTab === 'notifications'
|
||||||
|
? 'orders'
|
||||||
|
: activeTab
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tab = searchParams.get('tab')
|
const tab = searchParams.get('tab')
|
||||||
@@ -54,6 +59,7 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
|
|
||||||
function changeTab(nextTab: string) {
|
function changeTab(nextTab: string) {
|
||||||
if (!isWorkerPlatformTab(nextTab)) return
|
if (!isWorkerPlatformTab(nextTab)) return
|
||||||
|
if (isFinance && nextTab !== 'finance') return
|
||||||
setActiveTab(nextTab)
|
setActiveTab(nextTab)
|
||||||
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, nextTab)
|
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, nextTab)
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
@@ -77,13 +83,15 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
title="接单平台"
|
title="接单平台"
|
||||||
description="管理打手、接单工单、等级权限、售后追责和本地 Mock 工单。"
|
description="管理打手、接单工单、等级权限、售后追责和本地 Mock 工单。"
|
||||||
/>
|
/>
|
||||||
<SummaryCards />
|
{!isFinance ? <SummaryCards /> : null}
|
||||||
<Tabs
|
<Tabs
|
||||||
destroyOnHidden={false}
|
destroyOnHidden={false}
|
||||||
activeKey={visibleActiveTab}
|
activeKey={visibleActiveTab}
|
||||||
onChange={changeTab}
|
onChange={changeTab}
|
||||||
items={
|
items={
|
||||||
isSupport
|
isFinance
|
||||||
|
? [{ key: 'finance', label: '资金审核', children: <FinancePanel /> }]
|
||||||
|
: isSupport
|
||||||
? [
|
? [
|
||||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||||
{
|
{
|
||||||
@@ -114,7 +122,13 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
{ key: 'finance', label: '资金', children: <FinancePanel /> },
|
{ key: 'finance', label: '资金', children: <FinancePanel /> },
|
||||||
{ key: 'levels', label: '等级权限', children: <LevelsPanel /> },
|
{ key: 'levels', label: '等级权限', children: <LevelsPanel /> },
|
||||||
...(isAdmin
|
...(isAdmin
|
||||||
? [{ key: 'notifications', label: '通知配置', children: <NotificationsPanel /> }]
|
? [
|
||||||
|
{
|
||||||
|
key: 'notifications',
|
||||||
|
label: '通知配置',
|
||||||
|
children: <NotificationsPanel />,
|
||||||
|
},
|
||||||
|
]
|
||||||
: []),
|
: []),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
|||||||
import dayjs, { ADMIN_DATE_TIME_FORMAT } from '@/lib/dayjs'
|
import dayjs, { ADMIN_DATE_TIME_FORMAT } from '@/lib/dayjs'
|
||||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
import { getAdminRole } from '@/utils/admin-auth'
|
||||||
import { asRecord, formatMoney } from './shared'
|
import { asRecord, formatMoney } from './shared'
|
||||||
type FinanceConfigFormValues = {
|
type FinanceConfigFormValues = {
|
||||||
depositUnfreezeDays?: number
|
depositUnfreezeDays?: number
|
||||||
@@ -63,6 +64,7 @@ type FinanceReviewAction = 'approved' | 'rejected' | 'cancelled'
|
|||||||
|
|
||||||
export default function FinancePanel() {
|
export default function FinancePanel() {
|
||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
|
const canEditConfig = getAdminRole() !== 'finance'
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [configForm] = Form.useForm<FinanceConfigFormValues>()
|
const [configForm] = Form.useForm<FinanceConfigFormValues>()
|
||||||
@@ -436,7 +438,12 @@ export default function FinancePanel() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Button type="primary" htmlType="submit" loading={savingConfig}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={savingConfig}
|
||||||
|
disabled={!canEditConfig}
|
||||||
|
>
|
||||||
保存配置
|
保存配置
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { apiGet } from '@/lib/http'
|
||||||
|
import type { AdminFinanceSummary, AdminFinanceTransactionResult } from '@/types/admin/finance'
|
||||||
|
|
||||||
|
export function fetchAdminFinanceSummary(params: { dateFrom?: string; dateTo?: string } = {}) {
|
||||||
|
return apiGet<AdminFinanceSummary>('/api/v1/admin/finance/summary', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminFinanceTransactions(
|
||||||
|
params: {
|
||||||
|
dateFrom?: string
|
||||||
|
dateTo?: string
|
||||||
|
category?: string
|
||||||
|
channel?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
return apiGet<AdminFinanceTransactionResult>('/api/v1/admin/finance/transactions', params)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './auth'
|
export * from './auth'
|
||||||
export * from './dashboard'
|
export * from './dashboard'
|
||||||
|
export * from './finance'
|
||||||
export * from './users'
|
export * from './users'
|
||||||
export * from './audit-logs'
|
export * from './audit-logs'
|
||||||
export * from './platform-config'
|
export * from './platform-config'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type AdminRole = 'admin' | 'operator' | 'support'
|
export type AdminRole = 'admin' | 'operator' | 'support' | 'finance'
|
||||||
export type AdminUserStatus = 'active' | 'disabled'
|
export type AdminUserStatus = 'active' | 'disabled'
|
||||||
|
|
||||||
export interface AdminLoginResponse {
|
export interface AdminLoginResponse {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type AdminFinanceDailyItem = {
|
||||||
|
date: string
|
||||||
|
paidOrderCount: number
|
||||||
|
paidAmount: number
|
||||||
|
refundAmount: number
|
||||||
|
workerRewardAmount: number
|
||||||
|
withdrawAmount: number
|
||||||
|
rechargeAmount: number
|
||||||
|
afterSalesRefundAmount: number
|
||||||
|
workerRecoveryAmount: number
|
||||||
|
netCashFlow: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminFinanceOverview = {
|
||||||
|
paidOrderCount: number
|
||||||
|
paidAmount: number
|
||||||
|
refundAmount: number
|
||||||
|
workerRewardAmount: number
|
||||||
|
withdrawAmount: number
|
||||||
|
rechargeAmount: number
|
||||||
|
afterSalesRefundAmount: number
|
||||||
|
workerRecoveryAmount: number
|
||||||
|
netCashFlow: number
|
||||||
|
availableAmount: number
|
||||||
|
frozenDepositAmount: number
|
||||||
|
pendingUnfreezeAmount: number
|
||||||
|
frozenWithdrawAmount: number
|
||||||
|
pendingWithdrawAmount: number
|
||||||
|
pendingWithdrawCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminFinanceTransaction = {
|
||||||
|
category: string
|
||||||
|
channel: string
|
||||||
|
sourceId: number
|
||||||
|
occurredAt: string
|
||||||
|
amount: number
|
||||||
|
direction: 'in' | 'out'
|
||||||
|
label: string
|
||||||
|
referenceNo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminFinanceChannelDailyItem = {
|
||||||
|
date: string
|
||||||
|
channel: string
|
||||||
|
category: string
|
||||||
|
count: number
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminFinanceSummary = {
|
||||||
|
range: { from: string; to: string }
|
||||||
|
overview: AdminFinanceOverview
|
||||||
|
daily: AdminFinanceDailyItem[]
|
||||||
|
channelDaily: AdminFinanceChannelDailyItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminFinanceTransactionResult = {
|
||||||
|
items: AdminFinanceTransaction[]
|
||||||
|
pagination: { page: number; pageSize: number; total: number }
|
||||||
|
range: { from: string; to: string }
|
||||||
|
}
|
||||||
@@ -13,6 +13,16 @@ export type { AdminAuditLogItem } from './audit-logs'
|
|||||||
// Dashboard types
|
// Dashboard types
|
||||||
export type { AdminDashboardSummary } from './dashboard'
|
export type { AdminDashboardSummary } from './dashboard'
|
||||||
|
|
||||||
|
// Finance types
|
||||||
|
export type {
|
||||||
|
AdminFinanceDailyItem,
|
||||||
|
AdminFinanceChannelDailyItem,
|
||||||
|
AdminFinanceOverview,
|
||||||
|
AdminFinanceTransaction,
|
||||||
|
AdminFinanceSummary,
|
||||||
|
AdminFinanceTransactionResult,
|
||||||
|
} from './finance'
|
||||||
|
|
||||||
// Login logs
|
// Login logs
|
||||||
export type { AdminLoginLogItem, AdminLoginLogListResult } from './login-logs'
|
export type { AdminLoginLogItem, AdminLoginLogListResult } from './login-logs'
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ const ADMIN_USERNAME_KEY = 'order-site-admin-username'
|
|||||||
const ADMIN_ROLE_KEY = 'order-site-admin-role'
|
const ADMIN_ROLE_KEY = 'order-site-admin-role'
|
||||||
const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions'
|
const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions'
|
||||||
|
|
||||||
const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin', number> = {
|
const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin' | 'finance', number> = {
|
||||||
support: 1,
|
support: 1,
|
||||||
operator: 2,
|
operator: 2,
|
||||||
admin: 3,
|
admin: 3,
|
||||||
|
finance: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminToken() {
|
export function getAdminToken() {
|
||||||
@@ -30,7 +31,7 @@ export function getAdminUsername() {
|
|||||||
|
|
||||||
export function getAdminRole() {
|
export function getAdminRole() {
|
||||||
const role = localStorage.getItem(ADMIN_ROLE_KEY)
|
const role = localStorage.getItem(ADMIN_ROLE_KEY)
|
||||||
if (role === 'admin' || role === 'operator' || role === 'support') {
|
if (role === 'admin' || role === 'operator' || role === 'support' || role === 'finance') {
|
||||||
return role
|
return role
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +58,15 @@ export function hasAdminPermission(permission: string) {
|
|||||||
return getAdminPermissions().includes(String(permission || '').trim())
|
return getAdminPermissions().includes(String(permission || '').trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasAdminRole(role: 'admin' | 'operator' | 'support') {
|
export function hasAdminRole(role: 'admin' | 'operator' | 'support' | 'finance') {
|
||||||
if (!hasAdminSession()) {
|
if (!hasAdminSession()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return ADMIN_ROLE_LEVEL[getAdminRole()] >= ADMIN_ROLE_LEVEL[role]
|
const currentRole = getAdminRole()
|
||||||
|
if (role === 'finance') return currentRole === 'finance'
|
||||||
|
if (currentRole === 'finance') return false
|
||||||
|
return ADMIN_ROLE_LEVEL[currentRole] >= ADMIN_ROLE_LEVEL[role]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAdminSession(
|
export function setAdminSession(
|
||||||
@@ -71,7 +75,7 @@ export function setAdminSession(
|
|||||||
user?: {
|
user?: {
|
||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
role: 'admin' | 'operator' | 'support'
|
role: 'admin' | 'operator' | 'support' | 'finance'
|
||||||
permissions?: string[]
|
permissions?: string[]
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const adminUserRoleOptions = [
|
|||||||
{ label: '管理员', value: 'admin' },
|
{ label: '管理员', value: 'admin' },
|
||||||
{ label: '普通运营', value: 'operator' },
|
{ label: '普通运营', value: 'operator' },
|
||||||
{ label: '客服', value: 'support' },
|
{ label: '客服', value: 'support' },
|
||||||
|
{ label: '财务', value: 'finance' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const adminUserStatusOptions = [
|
export const adminUserStatusOptions = [
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 财务功能实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在后台左侧主导航增加“财务”,提供按中国时区日期筛选的全站财务总览、每日账目和资金明细。首版复用现有订单、打手资金申请、钱包流水和售后数据,不改变接单、充值、提现的现有业务流程。
|
||||||
|
|
||||||
|
## 已实现范围
|
||||||
|
|
||||||
|
- `/admin/finance` 页面和主导航入口。
|
||||||
|
- `/api/v1/admin/finance/summary`:返回日期范围内的日报和汇总指标。
|
||||||
|
- `/api/v1/admin/finance/transactions`:返回分页资金明细,并支持账目类型、渠道筛选。
|
||||||
|
- 日期范围支持 `YYYY-MM-DD HH:mm`,精确到分钟;日报仍按 `Asia/Shanghai` 自然日切桶。
|
||||||
|
- 日界线固定为 `Asia/Shanghai`,前端金额展示为元,数据库金额仍使用分。
|
||||||
|
- 总览指标:订单收款、订单退款、打手报酬、已确认提现、充值入账、售后追缴退还、净现金流、当前钱包余额/冻结/待解冻/待提现。
|
||||||
|
- 渠道日报按“日期 + 渠道 + 账目类型”汇总笔数和金额,订单渠道显示为 `provider/platform`,打手资金显示为提现/充值渠道。
|
||||||
|
- 原“接单平台 → 资金”页签继续用于打手资金申请审核和资金配置。
|
||||||
|
- 新增独立 `finance` 后台角色:仅显示财务和资金审核入口,可访问财务报表、资金申请列表/审核,不可访问订单、任务和系统概览。
|
||||||
|
- 管理员拥有全部财务权限,普通运营保留查看/审核权限,客服不获得财务权限。
|
||||||
|
|
||||||
|
## 财务口径
|
||||||
|
|
||||||
|
1. 订单收款按 `paid_at`(缺失时回退 `created_at`)归属日期。
|
||||||
|
2. 退款首版按订单 `pay_status=refunded` 的订单金额统计;部分退款明细需要后续接入独立退款流水。
|
||||||
|
3. 打手报酬取 `reward_settlement`、`sharing_reward` 钱包流水。
|
||||||
|
4. 已确认提现取资金申请 `status=approved` 且有 `reviewed_at` 的金额,避免和钱包 `withdraw_paid` 零金额流水重复计算。
|
||||||
|
5. 充值入账取已通过的充值申请;押金冻结/解冻只作为资金占用变化,不计入收入。
|
||||||
|
6. 净现金流 = 收款 + 充值 + 打手资金回收 - 订单退款 - 打手提现 - 售后追缴退还。
|
||||||
|
|
||||||
|
## 后续迭代
|
||||||
|
|
||||||
|
### 二期:人工打款可追踪
|
||||||
|
|
||||||
|
- 将提现审核和实际打款拆分。
|
||||||
|
- 增加打款时间、操作人、流水号、付款凭证和备注。
|
||||||
|
- 增加“审核通过待打款”“已打款”状态,历史 `approved` 数据保持兼容。
|
||||||
|
|
||||||
|
### 三期:统一财务总账
|
||||||
|
|
||||||
|
- 新增 append-only `finance_transactions` 表。
|
||||||
|
- 对订单收款、退款、充值、提现、结算和售后资金动作双写统一流水。
|
||||||
|
- 增加对账、CSV/Excel 导出、幂等键和异常金额检查。
|
||||||
|
|
||||||
|
## RBAC 设计
|
||||||
|
|
||||||
|
权限键:
|
||||||
|
|
||||||
|
- `finance.view`:财务总览、日报和明细
|
||||||
|
- `finance.review`:充值/提现申请审核
|
||||||
|
- `finance.config`:财务规则配置
|
||||||
|
- `finance.export`:导出财务数据
|
||||||
|
|
||||||
|
角色矩阵:
|
||||||
|
|
||||||
|
| 角色 | 财务报表 | 资金审核 | 财务配置 | 订单/任务 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 管理员 | 是 | 是 | 是 | 是 |
|
||||||
|
| 普通运营 | 是 | 是 | 否 | 是 |
|
||||||
|
| 财务 | 是 | 是 | 否 | 否 |
|
||||||
|
| 客服 | 否 | 否 | 否 | 是(客服范围) |
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 日期范围包含起止两天,日报为空时仍返回完整日期行。
|
||||||
|
- 日报各列合计与顶部总览一致。
|
||||||
|
- 提现不被申请表和钱包流水重复计入。
|
||||||
|
- 每笔明细包含来源类型、来源编号、发生时间和关联单号/渠道。
|
||||||
|
- 选择具体时间段时只统计起止时间内的事件;渠道日报能分别展示同一渠道不同日期的金额。
|
||||||
|
- 刷新、分页和账目类型筛选保持当前日期范围。
|
||||||
|
- 管理员、普通运营可访问;客服访问接口返回角色错误且不显示主导航。
|
||||||
|
|
||||||
|
## 主要文件
|
||||||
|
|
||||||
|
- 前端:`apps/frontend/src/pages/admin/AdminFinancePage.tsx`、`apps/frontend/src/services/admin/finance.ts`、`apps/frontend/src/types/admin/finance.ts`、`apps/frontend/src/layouts/AdminLayout.tsx`、`apps/frontend/src/App.tsx`
|
||||||
|
- 后端:`apps/backend/src/services/admin/admin-finance-service.ts`、`apps/backend/src/routes/admin/finance.ts`、`apps/backend/src/routes/admin.ts`
|
||||||
|
- 测试:`apps/backend/src/services/admin/admin-finance-service.test.ts`
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DELETE FROM worker_wallet_ledgers
|
||||||
|
WHERE payload_json->>'seed' = 'finance-demo-20260902';
|
||||||
|
|
||||||
|
DELETE FROM worker_finance_requests
|
||||||
|
WHERE payload_json->>'seed' = 'finance-demo-20260902';
|
||||||
|
|
||||||
|
DELETE FROM orders
|
||||||
|
WHERE raw_payload_json->>'seed' = 'finance-demo-20260902';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 本地财务页面演示数据,所有记录都带同一 seed 标记,便于完整回滚。
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM worker_users WHERE id IN (1, 3)) THEN
|
||||||
|
RAISE EXCEPTION 'worker_users id=1 and id=3 are required for finance demo seed';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
INSERT INTO orders (
|
||||||
|
provider, platform, shop_id, shop_name, platform_order_id,
|
||||||
|
order_status, pay_status, buyer_id, buyer_name, receiver_contact,
|
||||||
|
total_amount, currency, raw_payload_json, paid_at, created_at, updated_at
|
||||||
|
) VALUES
|
||||||
|
('demo', 'channel_a', 'demo-shop-a', '财务演示渠道A', 'DEMO-FIN-A-001', 'paid', 'paid', 'demo-buyer-1', '演示买家1', '13800000001', 13000, 'CNY', '{"seed":"finance-demo-20260902"}', '2026-09-01 09:15:00+08', '2026-09-01 08:50:00+08', '2026-09-01 09:15:00+08'),
|
||||||
|
('demo', 'channel_a', 'demo-shop-a', '财务演示渠道A', 'DEMO-FIN-A-002', 'paid', 'paid', 'demo-buyer-2', '演示买家2', '13800000002', 27500, 'CNY', '{"seed":"finance-demo-20260902"}', '2026-09-02 10:20:00+08', '2026-09-02 10:00:00+08', '2026-09-02 10:20:00+08'),
|
||||||
|
('demo', 'channel_b', 'demo-shop-b', '财务演示渠道B', 'DEMO-FIN-B-001', 'refunded', 'refunded', 'demo-buyer-3', '演示买家3', '13800000003', 8600, 'CNY', '{"seed":"finance-demo-20260902"}', '2026-09-02 11:10:00+08', '2026-09-02 11:00:00+08', '2026-09-02 11:40:00+08'),
|
||||||
|
('demo', 'channel_b', 'demo-shop-b', '财务演示渠道B', 'DEMO-FIN-B-002', 'paid', 'paid', 'demo-buyer-4', '演示买家4', '13800000004', 19900, 'CNY', '{"seed":"finance-demo-20260902"}', '2026-09-02 18:30:00+08', '2026-09-02 18:00:00+08', '2026-09-02 18:30:00+08'),
|
||||||
|
('demo', 'channel_c', 'demo-shop-c', '财务演示渠道C', 'DEMO-FIN-C-001', 'paid', 'paid', 'demo-buyer-5', '演示买家5', '13800000005', 4200, 'CNY', '{"seed":"finance-demo-20260902"}', '2026-09-03 14:05:00+08', '2026-09-03 13:45:00+08', '2026-09-03 14:05:00+08')
|
||||||
|
ON CONFLICT (provider, platform, shop_id, platform_order_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO worker_finance_requests (
|
||||||
|
worker_id, request_type, status, amount, account_channel, account_name, account_no,
|
||||||
|
note, reviewed_note, payload_json, created_at, updated_at, reviewed_at, frozen_amount
|
||||||
|
) VALUES
|
||||||
|
(1, 'recharge', 'approved', 5000, 'manual', '测试修改昵称鸭', 'demo-recharge-a', '演示充值 A', '演示数据已通过', '{"seed":"finance-demo-20260902"}', '2026-09-01 11:30:00+08', '2026-09-01 12:00:00+08', '2026-09-01 12:00:00+08', 0),
|
||||||
|
(1, 'withdraw', 'approved', 1800, 'alipay', '测试修改昵称鸭', 'demo-alipay-a', '演示提现 A', '演示数据已打款', '{"seed":"finance-demo-20260902"}', '2026-09-01 15:40:00+08', '2026-09-01 16:20:00+08', '2026-09-01 16:20:00+08', 0),
|
||||||
|
(3, 'withdraw', 'approved', 3200, 'wechat', '01邀请的', 'demo-wechat-b', '演示提现 B', '演示数据已打款', '{"seed":"finance-demo-20260902"}', '2026-09-02 10:05:00+08', '2026-09-02 10:45:00+08', '2026-09-02 10:45:00+08', 0),
|
||||||
|
(3, 'withdraw', 'pending', 4500, 'alipay', '01邀请的', 'demo-alipay-pending', '演示待处理提现', '', '{"seed":"finance-demo-20260902"}', '2026-09-02 13:30:00+08', '2026-09-02 13:30:00+08', NULL, 0),
|
||||||
|
(3, 'recharge', 'approved', 7600, 'wechat', '01邀请的', 'demo-wechat-recharge', '演示充值 B', '演示数据已通过', '{"seed":"finance-demo-20260902"}', '2026-09-02 18:40:00+08', '2026-09-02 19:05:00+08', '2026-09-02 19:05:00+08', 0),
|
||||||
|
(1, 'withdraw', 'rejected', 1100, 'alipay', '测试修改昵称鸭', 'demo-alipay-rejected', '演示已拒绝提现', '演示数据已拒绝', '{"seed":"finance-demo-20260902"}', '2026-09-02 20:00:00+08', '2026-09-02 20:10:00+08', '2026-09-02 20:10:00+08', 0);
|
||||||
|
|
||||||
|
INSERT INTO worker_wallet_ledgers (
|
||||||
|
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||||
|
related_work_order_id, audit_status, note, payload_json, created_at
|
||||||
|
) VALUES
|
||||||
|
(1, 'reward_settlement', 3000, 3000, 0, NULL, 'approved', '演示打手报酬 A', '{"seed":"finance-demo-20260902"}', '2026-09-01 10:00:00+08'),
|
||||||
|
(3, 'reward_settlement', 4500, 4500, 0, NULL, 'approved', '演示打手报酬 B', '{"seed":"finance-demo-20260902"}', '2026-09-02 12:10:00+08'),
|
||||||
|
(3, 'sharing_reward', 2200, 6700, 0, NULL, 'approved', '演示拼单报酬 B', '{"seed":"finance-demo-20260902"}', '2026-09-02 19:20:00+08'),
|
||||||
|
(1, 'after_sales_balance_deduction', -800, 2200, 0, NULL, 'approved', '演示资金回收 A', '{"seed":"finance-demo-20260902"}', '2026-09-02 14:00:00+08');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user