feat(finance): 支持代录指定客服账户,并上线影子总账/对账/关账/快手平台退款
- 人工确认履约:管理员/财务/运营可传 fundAccountId / creditOwnerAdminUserId 指定客服备用金/积分账户;客服仍强制本人账户 - 新增迁移 112:finance_accounts / journal_entries / journal_lines / periods / reconciliations / reconciliation_items,种子 11 个科目 - 影子总账:销售事实按日生成收入/退款/成本凭证,支持手工凭证、试算平衡; 不接管钱包余额;总账同步接入定时任务与退款重算 - 期间关账/反关账:关账先补凭证并校验试算平衡,closed 期间禁止新增凭证 - 平台对账:JSON 账单导入,按 provider + platform_order_id 匹配订单/退款, 标记 matched/amount_mismatch/missing_order/missing_refund/duplicate - 平台退款:快手行业(91)同意退款 API 接入,成功后落 source=platform; 抖音/拼多多返回 501,保留手工退款 - 新增后台「财务 → 总账与对账」页面(总账/期间/对账三个 Tab) - 集成测试扩展到代录、总账平衡、关账阻断、手工凭证、对账匹配 验证:backend check 844(840 pass / 4 skip);frontend check 172 + build; 迁移 112 在 scratch 库全量执行并重跑幂等;本地生产副本已升级到 112, 销售事实同步 56 条凭证,借贷合计 1,885,351,864 = 1,885,351,864。
This commit is contained in:
@@ -21,6 +21,7 @@ import WorkerLayout from '@/layouts/WorkerLayout'
|
||||
|
||||
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
|
||||
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
||||
const AdminFinanceLedgerPage = lazy(() => import('@/pages/admin/AdminFinanceLedgerPage'))
|
||||
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
|
||||
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
|
||||
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
|
||||
@@ -147,6 +148,7 @@ function AdminApplication() {
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
|
||||
<Route path="finance" element={<AdminFinancePage />} />
|
||||
<Route path="finance/ledger" element={<AdminFinanceLedgerPage />} />
|
||||
<Route path="sales-profit" element={<AdminSalesProfitPage />} />
|
||||
<Route path="cost-center" element={<AdminCostCenterPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AccountBookOutlined,
|
||||
ApartmentOutlined,
|
||||
AuditOutlined,
|
||||
LineChartOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
@@ -30,6 +31,11 @@ export const FINANCE_NAV_ITEMS = [
|
||||
label: '收支明细',
|
||||
icon: <AccountBookOutlined />,
|
||||
},
|
||||
{
|
||||
key: '/admin/finance/ledger',
|
||||
label: '总账与对账',
|
||||
icon: <AuditOutlined />,
|
||||
},
|
||||
] as const
|
||||
|
||||
type FinanceNavKey = (typeof FINANCE_NAV_ITEMS)[number]['key']
|
||||
|
||||
@@ -26,8 +26,14 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { confirmAdminManualFulfillment, fetchAdminManualFulfillmentPending } from '@/services/admin'
|
||||
import {
|
||||
confirmAdminManualFulfillment,
|
||||
fetchAdminFundAccounts,
|
||||
fetchAdminManualFulfillmentPending,
|
||||
fetchAdminPlatformCreditOwners,
|
||||
} from '@/services/admin'
|
||||
import type { ManualFulfillmentPendingItem } from '@/types/admin'
|
||||
import { getAdminRole, getAdminUserId } from '@/utils/admin-auth'
|
||||
|
||||
type ConfirmFormValues = {
|
||||
costYuan?: number
|
||||
@@ -61,6 +67,11 @@ export function ManualFulfillmentConfirmModal({
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ConfirmFormValues>()
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [fundAccountId, setFundAccountId] = useState<number | null>(null)
|
||||
const [creditOwnerAdminUserId, setCreditOwnerAdminUserId] = useState<number | null>(null)
|
||||
const role = getAdminRole()
|
||||
const privileged = role !== 'support'
|
||||
const currentUserId = getAdminUserId()
|
||||
|
||||
const pendingQuery = useQuery({
|
||||
queryKey: ['admin-manual-fulfillment-pending', 'order', orderId],
|
||||
@@ -81,12 +92,49 @@ export function ManualFulfillmentConfirmModal({
|
||||
items[0] ||
|
||||
null
|
||||
|
||||
const fundAccountsQuery = useQuery({
|
||||
queryKey: ['admin-manual-confirm-fund-accounts'],
|
||||
queryFn: () => fetchAdminFundAccounts({ page: 1, pageSize: 100 }),
|
||||
enabled: open && privileged && Boolean(selected?.isFundCash),
|
||||
retry: false,
|
||||
})
|
||||
const creditOwnersQuery = useQuery({
|
||||
queryKey: ['admin-manual-confirm-credit-owners'],
|
||||
queryFn: () => fetchAdminPlatformCreditOwners(),
|
||||
enabled: open && privileged && Boolean(selected) && !selected?.isFundCash,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedId(initialOrderItemId || null)
|
||||
form.resetFields()
|
||||
}, [open, initialOrderItemId, form])
|
||||
|
||||
// 代录时默认选本人账户;客服没有代录权限,不展示选择。
|
||||
useEffect(() => {
|
||||
if (!open || !privileged) return
|
||||
if (selected?.isFundCash) {
|
||||
const accounts = (fundAccountsQuery.data?.data.items || []).filter(
|
||||
(item) => item.accountType === 'agent' && item.status === 'active',
|
||||
)
|
||||
const own = accounts.find((item) => item.ownerAdminUserId === currentUserId)
|
||||
setFundAccountId(own?.id ?? null)
|
||||
return
|
||||
}
|
||||
const owners = creditOwnersQuery.data?.data.items || []
|
||||
const own = owners.find((item) => item.id === currentUserId)
|
||||
setCreditOwnerAdminUserId(own?.id ?? null)
|
||||
}, [
|
||||
open,
|
||||
privileged,
|
||||
selected?.orderItemId,
|
||||
selected?.isFundCash,
|
||||
currentUserId,
|
||||
fundAccountsQuery.data,
|
||||
creditOwnersQuery.data,
|
||||
])
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (values: ConfirmFormValues) =>
|
||||
confirmAdminManualFulfillment({
|
||||
@@ -94,6 +142,10 @@ export function ManualFulfillmentConfirmModal({
|
||||
costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100),
|
||||
payeeName: values.payeeName || '',
|
||||
note: values.note || '',
|
||||
...(privileged && selected?.isFundCash && fundAccountId ? { fundAccountId } : {}),
|
||||
...(privileged && selected && !selected.isFundCash && creditOwnerAdminUserId
|
||||
? { creditOwnerAdminUserId }
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
@@ -122,7 +174,14 @@ export function ManualFulfillmentConfirmModal({
|
||||
open={open}
|
||||
title="确认人工履约"
|
||||
okText="确认履约"
|
||||
okButtonProps={{ icon: <CheckOutlined />, disabled: !selected }}
|
||||
okButtonProps={{
|
||||
icon: <CheckOutlined />,
|
||||
disabled:
|
||||
!selected ||
|
||||
(privileged &&
|
||||
((selected.isFundCash && !fundAccountId) ||
|
||||
(!selected.isFundCash && !creditOwnerAdminUserId))),
|
||||
}}
|
||||
cancelText="取消"
|
||||
confirmLoading={confirmMutation.isPending}
|
||||
onOk={() => {
|
||||
@@ -181,6 +240,46 @@ export function ManualFulfillmentConfirmModal({
|
||||
<Typography.Text>¥{((selected?.revenueFen || 0) / 100).toFixed(2)}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{privileged && selected ? (
|
||||
selected.isFundCash ? (
|
||||
<div>
|
||||
<Typography.Text type="secondary">代录到客服备用金账户:</Typography.Text>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={fundAccountId ?? undefined}
|
||||
placeholder="请选择客服备用金账户"
|
||||
style={{ width: '100%', marginTop: 6 }}
|
||||
loading={fundAccountsQuery.isLoading}
|
||||
onChange={(value) => setFundAccountId(value)}
|
||||
options={(fundAccountsQuery.data?.data.items || [])
|
||||
.filter((item) => item.accountType === 'agent' && item.status === 'active')
|
||||
.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${item.ownerName || item.accountNo} · 余额 ¥${(item.balanceFen / 100).toFixed(2)}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Typography.Text type="secondary">代录到客服积分账户:</Typography.Text>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={creditOwnerAdminUserId ?? undefined}
|
||||
placeholder="请选择客服账号"
|
||||
style={{ width: '100%', marginTop: 6 }}
|
||||
loading={creditOwnersQuery.isLoading}
|
||||
onChange={(value) => setCreditOwnerAdminUserId(value)}
|
||||
options={(creditOwnersQuery.data?.data.items || []).map((item) => ({
|
||||
value: item.id,
|
||||
label: item.displayName || item.username,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{showCostInput ? (
|
||||
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
|
||||
) : (
|
||||
|
||||
@@ -184,6 +184,7 @@ export default function AdminLayout() {
|
||||
'/admin/cost-center',
|
||||
'/admin/fund',
|
||||
'/admin/finance',
|
||||
'/admin/finance/ledger',
|
||||
].includes(selectedKey)
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(['sub-finance'])
|
||||
|
||||
@@ -199,6 +200,7 @@ export default function AdminLayout() {
|
||||
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
|
||||
{ key: '/admin/fund', icon: <WalletOutlined />, label: '钱包管理' },
|
||||
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '收支明细' },
|
||||
{ key: '/admin/finance/ledger', icon: <AuditOutlined />, label: '总账与对账' },
|
||||
]
|
||||
|
||||
const financeSubMenu = {
|
||||
@@ -844,6 +846,7 @@ function resolveSelectedKey(pathname: string) {
|
||||
if (pathname.startsWith('/admin/sales-profit')) return '/admin/sales-profit'
|
||||
if (pathname.startsWith('/admin/cost-center')) return '/admin/cost-center'
|
||||
if (pathname.startsWith('/admin/fund')) return '/admin/fund'
|
||||
if (pathname.startsWith('/admin/finance/ledger')) return '/admin/finance/ledger'
|
||||
if (pathname.startsWith('/admin/finance')) return '/admin/finance'
|
||||
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
|
||||
if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform'
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
import { AuditOutlined, PlusOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import {
|
||||
closeFinancePeriod,
|
||||
createFinanceManualVoucher,
|
||||
fetchFinanceAccounts,
|
||||
fetchFinanceJournalEntries,
|
||||
fetchFinancePeriods,
|
||||
fetchFinanceReconciliation,
|
||||
fetchFinanceReconciliations,
|
||||
fetchFinanceTrialBalance,
|
||||
importFinanceReconciliation,
|
||||
reopenFinancePeriod,
|
||||
resolveFinanceReconciliation,
|
||||
syncFinanceLedger,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
FinanceAccount,
|
||||
FinanceJournalEntry,
|
||||
FinancePeriod,
|
||||
FinanceReconciliation,
|
||||
FinanceReconciliationItem,
|
||||
FinanceTrialBalanceItem,
|
||||
} from '@/types/admin/finance-ledger'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { formatMoney } from './panels/shared'
|
||||
|
||||
const PROVIDER_OPTIONS = [
|
||||
{ value: '91kaquan', label: '91 卡券 / 快手' },
|
||||
{ value: 'pdd', label: '拼多多' },
|
||||
{ value: 'douyin_mashida', label: '抖音码速达' },
|
||||
]
|
||||
|
||||
const RECON_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
imported: { label: '待匹配', color: 'processing' },
|
||||
matched: { label: '全部匹配', color: 'green' },
|
||||
differences: { label: '有差异', color: 'orange' },
|
||||
resolved: { label: '已解决', color: 'default' },
|
||||
}
|
||||
|
||||
const MATCH_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
matched: { label: '匹配', color: 'green' },
|
||||
amount_mismatch: { label: '金额不一致', color: 'orange' },
|
||||
missing_order: { label: '系统无订单', color: 'red' },
|
||||
missing_refund: { label: '系统无退款', color: 'red' },
|
||||
duplicate: { label: '重复行', color: 'default' },
|
||||
}
|
||||
|
||||
export default function AdminFinanceLedgerPage() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const canConfig = hasAdminPermission('finance.config')
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().startOf('month'), dayjs()])
|
||||
const [entryPage, setEntryPage] = useState(1)
|
||||
const [entryPageSize, setEntryPageSize] = useState(20)
|
||||
const [entryDetail, setEntryDetail] = useState<FinanceJournalEntry | null>(null)
|
||||
const [voucherOpen, setVoucherOpen] = useState(false)
|
||||
const [reconImportOpen, setReconImportOpen] = useState(false)
|
||||
const [reconDetailId, setReconDetailId] = useState<number | null>(null)
|
||||
const [reconPage, setReconPage] = useState(1)
|
||||
const [reconPageSize, setReconPageSize] = useState(20)
|
||||
const [voucherForm] = Form.useForm()
|
||||
const [reconForm] = Form.useForm()
|
||||
|
||||
const dateFrom = range[0].format('YYYY-MM-DD')
|
||||
const dateTo = range[1].format('YYYY-MM-DD')
|
||||
|
||||
const accountsQuery = useQuery({
|
||||
queryKey: ['admin-finance-ledger-accounts'],
|
||||
queryFn: () => fetchFinanceAccounts(),
|
||||
})
|
||||
const trialQuery = useQuery({
|
||||
queryKey: ['admin-finance-ledger-trial', dateFrom, dateTo],
|
||||
queryFn: () => fetchFinanceTrialBalance({ dateFrom, dateTo }),
|
||||
})
|
||||
const entriesQuery = useQuery({
|
||||
queryKey: ['admin-finance-ledger-entries', dateFrom, dateTo, entryPage, entryPageSize],
|
||||
queryFn: () =>
|
||||
fetchFinanceJournalEntries({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: entryPage,
|
||||
pageSize: entryPageSize,
|
||||
}),
|
||||
})
|
||||
const periodsQuery = useQuery({
|
||||
queryKey: ['admin-finance-periods'],
|
||||
queryFn: () => fetchFinancePeriods(),
|
||||
})
|
||||
const reconciliationsQuery = useQuery({
|
||||
queryKey: ['admin-finance-reconciliations', reconPage, reconPageSize],
|
||||
queryFn: () => fetchFinanceReconciliations({ page: reconPage, pageSize: reconPageSize }),
|
||||
})
|
||||
const reconDetailQuery = useQuery({
|
||||
queryKey: ['admin-finance-reconciliation-detail', reconDetailId],
|
||||
queryFn: () => fetchFinanceReconciliation(Number(reconDetailId)),
|
||||
enabled: Boolean(reconDetailId),
|
||||
})
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => syncFinanceLedger({ dateFrom, dateTo }),
|
||||
onSuccess: (response) => {
|
||||
message.success(`总账同步完成:新增 ${response.data.created} 条凭证`)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance-ledger'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance-periods'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '同步总账失败'),
|
||||
})
|
||||
|
||||
const voucherMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const values = voucherForm.getFieldsValue() as {
|
||||
debitAccount: string
|
||||
creditAccount: string
|
||||
amountYuan: number
|
||||
occurredAt?: Dayjs
|
||||
memo?: string
|
||||
}
|
||||
const amountFen = Math.round(Number(values.amountYuan || 0) * 100)
|
||||
return createFinanceManualVoucher({
|
||||
occurredAt: values.occurredAt?.toISOString(),
|
||||
memo: values.memo || '',
|
||||
lines: [
|
||||
{ accountCode: values.debitAccount, debitFen: amountFen, memo: values.memo || '' },
|
||||
{ accountCode: values.creditAccount, creditFen: amountFen, memo: values.memo || '' },
|
||||
],
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('手工凭证已创建')
|
||||
setVoucherOpen(false)
|
||||
voucherForm.resetFields()
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance-ledger'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '创建凭证失败'),
|
||||
})
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationFn: (periodKey: string) => closeFinancePeriod(periodKey),
|
||||
onSuccess: () => {
|
||||
message.success('期间已关账')
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance-periods'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '关账失败'),
|
||||
})
|
||||
|
||||
const reopenMutation = useMutation({
|
||||
mutationFn: (periodKey: string) => reopenFinancePeriod(periodKey),
|
||||
onSuccess: () => {
|
||||
message.success('期间已重新打开')
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance-periods'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '反关账失败'),
|
||||
})
|
||||
|
||||
const importReconMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const values = reconForm.getFieldsValue() as {
|
||||
provider: string
|
||||
periodRange?: [Dayjs, Dayjs]
|
||||
rowsText: string
|
||||
}
|
||||
const parsed = JSON.parse(values.rowsText || '[]')
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
throw new Error('请粘贴至少一行 JSON 数组')
|
||||
}
|
||||
const periodRange = values.periodRange || range
|
||||
return importFinanceReconciliation({
|
||||
provider: values.provider,
|
||||
periodFrom: periodRange[0].format('YYYY-MM-DD'),
|
||||
periodTo: periodRange[1].format('YYYY-MM-DD'),
|
||||
rows: parsed,
|
||||
})
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
message.success(
|
||||
`导入完成:${response.data.rows} 行,匹配 ${response.data.matched},差异 ${response.data.mismatched}`,
|
||||
)
|
||||
setReconImportOpen(false)
|
||||
reconForm.resetFields()
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance-reconciliations'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '导入账单失败'),
|
||||
})
|
||||
|
||||
const resolveReconMutation = useMutation({
|
||||
mutationFn: (id: number) => resolveFinanceReconciliation(id, '后台确认差异已处理'),
|
||||
onSuccess: () => {
|
||||
message.success('已标记解决')
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance-reconciliations'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance-reconciliation-detail'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '标记失败'),
|
||||
})
|
||||
|
||||
const accountOptions = (accountsQuery.data?.data.items || []).map((item: FinanceAccount) => ({
|
||||
value: item.accountCode,
|
||||
label: `${item.accountCode} ${item.accountName}`,
|
||||
}))
|
||||
|
||||
const trialColumns: TableColumnsType<FinanceTrialBalanceItem> = [
|
||||
{ title: '科目', dataIndex: 'accountCode', width: 90 },
|
||||
{ title: '名称', dataIndex: 'accountName', width: 160 },
|
||||
{ title: '类型', dataIndex: 'accountType', width: 90 },
|
||||
{
|
||||
title: '借方',
|
||||
dataIndex: 'debitFen',
|
||||
align: 'right',
|
||||
width: 120,
|
||||
render: (value: number) => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '贷方',
|
||||
dataIndex: 'creditFen',
|
||||
align: 'right',
|
||||
width: 120,
|
||||
render: (value: number) => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '余额',
|
||||
dataIndex: 'balanceFen',
|
||||
align: 'right',
|
||||
width: 120,
|
||||
render: (value: number) => formatMoney(value),
|
||||
},
|
||||
]
|
||||
|
||||
const entryColumns: TableColumnsType<FinanceJournalEntry> = [
|
||||
{ title: '凭证号', dataIndex: 'entryNo', width: 200 },
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'occurredAt',
|
||||
width: 160,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventType', width: 140 },
|
||||
{ title: '摘要', dataIndex: 'memo' },
|
||||
{
|
||||
title: '借方合计',
|
||||
dataIndex: 'debitTotalFen',
|
||||
align: 'right',
|
||||
width: 120,
|
||||
render: (value: number) => formatMoney(value),
|
||||
},
|
||||
]
|
||||
|
||||
const periodColumns: TableColumnsType<FinancePeriod> = [
|
||||
{ title: '期间', dataIndex: 'periodKey', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (value: string) => (
|
||||
<Tag color={value === 'closed' ? 'default' : 'processing'}>
|
||||
{value === 'closed' ? '已关账' : '开放'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '关账时间',
|
||||
dataIndex: 'closedAt',
|
||||
width: 170,
|
||||
render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
render: (_, row) =>
|
||||
canConfig ? (
|
||||
row.status === 'closed' ? (
|
||||
<Button size="small" onClick={() => reopenMutation.mutate(row.periodKey)}>
|
||||
反关账
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" type="primary" onClick={() => closeMutation.mutate(row.periodKey)}>
|
||||
关账
|
||||
</Button>
|
||||
)
|
||||
) : null,
|
||||
},
|
||||
]
|
||||
|
||||
const reconColumns: TableColumnsType<FinanceReconciliation> = [
|
||||
{ title: '批次', dataIndex: 'id', width: 80 },
|
||||
{ title: '平台', dataIndex: 'provider', width: 120 },
|
||||
{
|
||||
title: '期间',
|
||||
width: 190,
|
||||
render: (_, row) => `${row.periodFrom} ~ ${row.periodTo}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (value: string) => {
|
||||
const meta = RECON_STATUS_META[value] || { label: value, color: 'default' }
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '系统金额',
|
||||
dataIndex: 'expectedFen',
|
||||
align: 'right',
|
||||
width: 120,
|
||||
render: (value: number) => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '账单金额',
|
||||
dataIndex: 'actualFen',
|
||||
align: 'right',
|
||||
width: 120,
|
||||
render: (value: number) => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '差异',
|
||||
dataIndex: 'diffFen',
|
||||
align: 'right',
|
||||
width: 120,
|
||||
render: (value: number) => (
|
||||
<Typography.Text type={value === 0 ? 'secondary' : 'danger'}>
|
||||
{formatMoney(value)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setReconDetailId(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
{canConfig && row.status !== 'resolved' ? (
|
||||
<Button size="small" onClick={() => resolveReconMutation.mutate(row.id)}>
|
||||
标记解决
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const reconItemColumns: TableColumnsType<FinanceReconciliationItem> = [
|
||||
{ title: '行', dataIndex: 'lineNo', width: 60 },
|
||||
{ title: '平台单号', dataIndex: 'platformOrderId', width: 190 },
|
||||
{ title: '类型', dataIndex: 'entryType', width: 80 },
|
||||
{
|
||||
title: '账单金额',
|
||||
dataIndex: 'amountFen',
|
||||
align: 'right',
|
||||
width: 110,
|
||||
render: (value: number) => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '系统金额',
|
||||
dataIndex: 'expectedFen',
|
||||
align: 'right',
|
||||
width: 110,
|
||||
render: (value: number | null) => (value === null ? '-' : formatMoney(value)),
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'matchStatus',
|
||||
width: 120,
|
||||
render: (value: string) => {
|
||||
const meta = MATCH_STATUS_META[value] || { label: value, color: 'default' }
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="总账与对账" description="影子总账、期间关账与平台账单对账" />
|
||||
<FinanceSubNav current="/admin/finance/ledger" />
|
||||
<Card bordered={false}>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<AdminRangePicker
|
||||
value={range}
|
||||
onChange={(value) => {
|
||||
if (value?.[0] && value?.[1]) setRange([value[0], value[1]])
|
||||
}}
|
||||
/>
|
||||
{canConfig ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncMutation.isPending}
|
||||
onClick={() => syncMutation.mutate()}
|
||||
>
|
||||
同步销售事实到总账
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
void trialQuery.refetch()
|
||||
void entriesQuery.refetch()
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'ledger',
|
||||
label: '总账',
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Statistic
|
||||
title="借方合计"
|
||||
value={formatMoney(trialQuery.data?.data.totals.debitFen || 0)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Statistic
|
||||
title="贷方合计"
|
||||
value={formatMoney(trialQuery.data?.data.totals.creditFen || 0)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Statistic
|
||||
title="借贷平衡"
|
||||
value={trialQuery.data?.data.balanced ? '是' : '否'}
|
||||
valueStyle={{
|
||||
color: trialQuery.data?.data.balanced ? '#3f8600' : '#cf1322',
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Card size="small" title="试算平衡">
|
||||
<PercentTable
|
||||
size="small"
|
||||
rowKey="accountCode"
|
||||
loading={trialQuery.isLoading}
|
||||
dataSource={trialQuery.data?.data.items || []}
|
||||
columns={trialColumns}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
<Card
|
||||
size="small"
|
||||
title="凭证"
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setVoucherOpen(true)}
|
||||
>
|
||||
手工凭证
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<PercentTable
|
||||
size="small"
|
||||
rowKey="id"
|
||||
loading={entriesQuery.isLoading}
|
||||
dataSource={entriesQuery.data?.data.items || []}
|
||||
columns={entryColumns}
|
||||
onRow={(row) => ({ onClick: () => setEntryDetail(row) })}
|
||||
pagination={{
|
||||
current: entryPage,
|
||||
pageSize: entryPageSize,
|
||||
total: entriesQuery.data?.data.pagination.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, size) => {
|
||||
setEntryPage(page)
|
||||
setEntryPageSize(size)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'periods',
|
||||
label: '期间关账',
|
||||
children: (
|
||||
<Card size="small" title="会计期间">
|
||||
<PercentTable
|
||||
size="small"
|
||||
rowKey="periodKey"
|
||||
loading={periodsQuery.isLoading}
|
||||
dataSource={periodsQuery.data?.data.items || []}
|
||||
columns={periodColumns}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'reconciliations',
|
||||
label: '平台对账',
|
||||
children: (
|
||||
<Card
|
||||
size="small"
|
||||
title="对账批次"
|
||||
extra={
|
||||
canConfig ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<AuditOutlined />}
|
||||
onClick={() => setReconImportOpen(true)}
|
||||
>
|
||||
导入账单
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<PercentTable
|
||||
size="small"
|
||||
rowKey="id"
|
||||
loading={reconciliationsQuery.isLoading}
|
||||
dataSource={reconciliationsQuery.data?.data.items || []}
|
||||
columns={reconColumns}
|
||||
pagination={{
|
||||
current: reconPage,
|
||||
pageSize: reconPageSize,
|
||||
total: reconciliationsQuery.data?.data.pagination.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, size) => {
|
||||
setReconPage(page)
|
||||
setReconPageSize(size)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
open={Boolean(entryDetail)}
|
||||
title={entryDetail?.entryNo || '凭证明细'}
|
||||
width={640}
|
||||
onClose={() => setEntryDetail(null)}
|
||||
>
|
||||
{entryDetail ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">{entryDetail.memo}</Typography.Text>
|
||||
<PercentTable
|
||||
size="small"
|
||||
rowKey="lineNo"
|
||||
pagination={false}
|
||||
dataSource={entryDetail.lines}
|
||||
columns={[
|
||||
{ title: '科目', dataIndex: 'accountName' },
|
||||
{
|
||||
title: '借方',
|
||||
dataIndex: 'debitFen',
|
||||
align: 'right',
|
||||
render: (value: number) => (value ? formatMoney(value) : '-'),
|
||||
},
|
||||
{
|
||||
title: '贷方',
|
||||
dataIndex: 'creditFen',
|
||||
align: 'right',
|
||||
render: (value: number) => (value ? formatMoney(value) : '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
open={voucherOpen}
|
||||
title="新增手工凭证"
|
||||
okText="创建"
|
||||
confirmLoading={voucherMutation.isPending}
|
||||
onOk={() => voucherMutation.mutate()}
|
||||
onCancel={() => setVoucherOpen(false)}
|
||||
>
|
||||
<Form form={voucherForm} layout="vertical">
|
||||
<Form.Item name="debitAccount" label="借方科目" rules={[{ required: true }]}>
|
||||
<Select options={accountOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="creditAccount" label="贷方科目" rules={[{ required: true }]}>
|
||||
<Select options={accountOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amountYuan" label="金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="occurredAt" label="发生时间">
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="摘要">
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={reconImportOpen}
|
||||
title="导入平台账单"
|
||||
width={720}
|
||||
okText="导入并匹配"
|
||||
confirmLoading={importReconMutation.isPending}
|
||||
onOk={() => importReconMutation.mutate()}
|
||||
onCancel={() => setReconImportOpen(false)}
|
||||
>
|
||||
<Form form={reconForm} layout="vertical" initialValues={{ provider: '91kaquan' }}>
|
||||
<Form.Item name="provider" label="平台" rules={[{ required: true }]}>
|
||||
<Select options={PROVIDER_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="periodRange" label="账单期间">
|
||||
<AdminRangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="rowsText"
|
||||
label="账单明细 JSON"
|
||||
rules={[{ required: true }]}
|
||||
extra='示例:[{"platformOrderId":"...","entryType":"sale","amountFen":100}];refund 行可带 externalRef。'
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={8}
|
||||
placeholder='[{"platformOrderId":"123","entryType":"sale","amountFen":100}]'
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
open={Boolean(reconDetailId)}
|
||||
title={`对账批次 #${reconDetailId || ''}`}
|
||||
width={860}
|
||||
onClose={() => setReconDetailId(null)}
|
||||
>
|
||||
{reconDetailQuery.data ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">
|
||||
{reconDetailQuery.data.data.provider} · {reconDetailQuery.data.data.periodFrom} ~{' '}
|
||||
{reconDetailQuery.data.data.periodTo} · 差异{' '}
|
||||
{formatMoney(reconDetailQuery.data.data.diffFen)}
|
||||
</Typography.Text>
|
||||
<PercentTable
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={reconDetailQuery.data.data.items}
|
||||
columns={reconItemColumns}
|
||||
/>
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -458,6 +458,10 @@ export function confirmAdminManualFulfillment(payload: {
|
||||
costFen?: number
|
||||
payeeName?: string
|
||||
note?: string
|
||||
/** 管理员/财务代录:指定客服备用金账户 */
|
||||
fundAccountId?: number
|
||||
/** 管理员/财务代录:指定客服积分账户归属人 */
|
||||
creditOwnerAdminUserId?: number
|
||||
}) {
|
||||
return apiPost<ManualFulfillmentConfirmResult>(
|
||||
'/api/v1/admin/finance/manual-fulfillment/confirm',
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
FinanceAccount,
|
||||
FinanceJournalEntry,
|
||||
FinancePeriod,
|
||||
FinanceReconciliation,
|
||||
FinanceReconciliationItem,
|
||||
FinanceTrialBalance,
|
||||
} from '@/types/admin/finance-ledger'
|
||||
|
||||
export function fetchFinanceAccounts() {
|
||||
return apiGet<{ items: FinanceAccount[] }>('/api/v1/admin/finance/ledger/accounts')
|
||||
}
|
||||
|
||||
export function fetchFinanceTrialBalance(params: { dateFrom: string; dateTo: string }) {
|
||||
return apiGet<FinanceTrialBalance>('/api/v1/admin/finance/ledger/trial-balance', params)
|
||||
}
|
||||
|
||||
export function fetchFinanceJournalEntries(params: {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
return apiGet<{
|
||||
items: FinanceJournalEntry[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}>('/api/v1/admin/finance/ledger/entries', params)
|
||||
}
|
||||
|
||||
export function syncFinanceLedger(payload: { dateFrom?: string; dateTo?: string }) {
|
||||
return apiPost<{ created: number; skipped: number }>('/api/v1/admin/finance/ledger/sync', payload)
|
||||
}
|
||||
|
||||
export function createFinanceManualVoucher(payload: {
|
||||
occurredAt?: string
|
||||
memo?: string
|
||||
reference?: string
|
||||
lines: Array<{
|
||||
accountCode: string
|
||||
debitFen?: number
|
||||
creditFen?: number
|
||||
memo?: string
|
||||
}>
|
||||
}) {
|
||||
return apiPost<{ entryId: number; entryNo: string }>(
|
||||
'/api/v1/admin/finance/ledger/manual-voucher',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchFinancePeriods() {
|
||||
return apiGet<{ items: FinancePeriod[] }>('/api/v1/admin/finance/periods')
|
||||
}
|
||||
|
||||
export function closeFinancePeriod(periodKey: string) {
|
||||
return apiPost<{ periodKey: string }>(
|
||||
`/api/v1/admin/finance/periods/${encodeURIComponent(periodKey)}/close`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function reopenFinancePeriod(periodKey: string) {
|
||||
return apiPost<FinancePeriod>(
|
||||
`/api/v1/admin/finance/periods/${encodeURIComponent(periodKey)}/reopen`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchFinanceReconciliations(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
provider?: string
|
||||
}) {
|
||||
return apiGet<{
|
||||
items: FinanceReconciliation[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}>('/api/v1/admin/finance/reconciliations', params)
|
||||
}
|
||||
|
||||
export function fetchFinanceReconciliation(id: number) {
|
||||
return apiGet<FinanceReconciliation & { items: FinanceReconciliationItem[] }>(
|
||||
`/api/v1/admin/finance/reconciliations/${id}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function importFinanceReconciliation(payload: {
|
||||
provider: string
|
||||
periodFrom: string
|
||||
periodTo: string
|
||||
rows: Array<{
|
||||
platformOrderId: string
|
||||
entryType: 'sale' | 'refund'
|
||||
amountFen: number
|
||||
externalRef?: string
|
||||
occurredAt?: string
|
||||
}>
|
||||
}) {
|
||||
return apiPost<{
|
||||
reconciliationId: number
|
||||
status: string
|
||||
rows: number
|
||||
matched: number
|
||||
mismatched: number
|
||||
expectedFen: number
|
||||
actualFen: number
|
||||
diffFen: number
|
||||
}>('/api/v1/admin/finance/reconciliations/import', payload)
|
||||
}
|
||||
|
||||
export function resolveFinanceReconciliation(id: number, note: string) {
|
||||
return apiPost<FinanceReconciliation>(`/api/v1/admin/finance/reconciliations/${id}/resolve`, {
|
||||
note,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './auth'
|
||||
export * from './dashboard'
|
||||
export * from './finance'
|
||||
export * from './finance-ledger'
|
||||
export * from './cost-center'
|
||||
export * from './fund'
|
||||
export * from './users'
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
export type FinanceAccount = {
|
||||
accountCode: string
|
||||
accountName: string
|
||||
accountType: 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'
|
||||
normalSide: 'debit' | 'credit'
|
||||
description: string
|
||||
}
|
||||
|
||||
export type FinanceTrialBalanceItem = {
|
||||
accountCode: string
|
||||
accountName: string
|
||||
accountType: string
|
||||
normalSide: string
|
||||
debitFen: number
|
||||
creditFen: number
|
||||
balanceFen: number
|
||||
}
|
||||
|
||||
export type FinanceTrialBalance = {
|
||||
range: { dateFrom: string; dateTo: string }
|
||||
items: FinanceTrialBalanceItem[]
|
||||
totals: { debitFen: number; creditFen: number; balanceFen: number }
|
||||
balanced: boolean
|
||||
}
|
||||
|
||||
export type FinanceJournalLine = {
|
||||
lineNo: number
|
||||
accountCode: string
|
||||
accountName: string
|
||||
debitFen: number
|
||||
creditFen: number
|
||||
memo: string
|
||||
}
|
||||
|
||||
export type FinanceJournalEntry = {
|
||||
id: number
|
||||
entryNo: string
|
||||
eventType: string
|
||||
refType: string
|
||||
refId: string
|
||||
occurredAt: string
|
||||
periodKey: string
|
||||
status: string
|
||||
memo: string
|
||||
metadata: Record<string, unknown>
|
||||
debitTotalFen: number
|
||||
lines: FinanceJournalLine[]
|
||||
}
|
||||
|
||||
export type FinancePeriod = {
|
||||
periodKey: string
|
||||
startsAt: string
|
||||
endsAt: string
|
||||
status: 'open' | 'closed'
|
||||
summary: Record<string, unknown>
|
||||
closedAt: string | null
|
||||
closedByAdminUserId: number | null
|
||||
}
|
||||
|
||||
export type FinanceReconciliation = {
|
||||
id: number
|
||||
provider: string
|
||||
periodFrom: string
|
||||
periodTo: string
|
||||
source: string
|
||||
status: 'imported' | 'matched' | 'differences' | 'resolved'
|
||||
expectedFen: number
|
||||
actualFen: number
|
||||
diffFen: number
|
||||
summary: Record<string, unknown>
|
||||
importedByAdminUserId: number | null
|
||||
importedByName: string
|
||||
resolvedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type FinanceReconciliationItem = {
|
||||
id: number
|
||||
lineNo: number
|
||||
platformOrderId: string
|
||||
externalRef: string
|
||||
entryType: 'sale' | 'refund'
|
||||
amountFen: number
|
||||
expectedFen: number | null
|
||||
diffFen: number | null
|
||||
occurredAt: string | null
|
||||
matchedOrderId: number | null
|
||||
matchedRefundId: number | null
|
||||
matchStatus: 'matched' | 'amount_mismatch' | 'missing_order' | 'missing_refund' | 'duplicate'
|
||||
detail: Record<string, unknown>
|
||||
}
|
||||
Reference in New Issue
Block a user