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:
yml2213
2026-10-26 13:32:47 +08:00
parent f0eb8a5e05
commit 62763ddf0c
31 changed files with 2961 additions and 55 deletions
+2
View File
@@ -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>
}