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
@@ -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>
</>
)
}