Files
hfb_jysh/distribution-admin/src/pages/Settlements.tsx
T

445 lines
15 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react'
import {
Card,
Table,
Typography,
Space,
Select,
Tag,
DatePicker,
Button,
message,
Modal,
Alert,
Descriptions,
Divider,
Statistic,
Row,
Col,
Empty,
} from 'antd'
import {
DollarCircleOutlined,
FileTextOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
PlusOutlined,
} from '@ant-design/icons'
import {
createSettlementBatch,
formatMoney,
listSettlementBatches,
listSettlementItems,
listSites,
settleBatch,
cancelSettlementBatch,
getMe,
type SettlementBatch,
type SettlementItem,
type Site,
} from '../api'
import { useMember } from '../MemberContext'
import type { Dayjs } from 'dayjs'
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
settled: { label: '已释放入账', color: 'success' },
draft: { label: '待释放入账', color: 'warning' },
pending: { label: '待释放入账', color: 'warning' },
processing: { label: '结算处理中', color: 'processing' },
cancelled: { label: '已作废', color: 'default' },
}
const PARTY_ROLE_LABELS: Record<string, string> = {
seller: '渠道代销分润 (40%)',
distributor_sell: '渠道代销分润 (40%)',
distributor_b: '渠道代销分润 (40%)',
receiver: '号源出租分润 (35%)',
distributor_receive: '号源出租分润 (35%)',
distributor_a: '号源出租分润 (35%)',
two_party_distributor: '分销商分润',
platform: '平台服务费',
}
export default function Settlements() {
const member = useMember()
const [sites, setSites] = useState<Site[]>([])
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [batches, setBatches] = useState<SettlementBatch[]>([])
const [loading, setLoading] = useState(false)
const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
const [itemsFor, setItemsFor] = useState<SettlementBatch | null>(null)
const [items, setItems] = useState<SettlementItem[]>([])
const [itemsLoading, setItemsLoading] = useState(false)
const [creating, setCreating] = useState(false)
const load = useCallback(async (id: number) => {
setLoading(true)
try {
setBatches(await listSettlementBatches(id))
} catch {
message.error('结算账单加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
async function init() {
let currentMember = member
if (!currentMember) {
try {
currentMember = await getMe()
} catch {
// ignore
}
}
const activeSiteID = currentMember?.site_id
if (activeSiteID) {
setSiteID(activeSiteID)
void load(activeSiteID)
}
if (currentMember?.is_superadmin) {
try {
const rows = await listSites()
setSites(rows)
if (!activeSiteID && rows.length > 0) {
setSiteID(rows[0].id)
void load(rows[0].id)
}
} catch {
// ignore
}
}
}
void init()
}, [member, load])
async function handleCreate() {
if (!siteID || !range?.[0] || !range?.[1]) {
message.warning('请选择站点和结算周期')
return
}
setCreating(true)
try {
const batch = await createSettlementBatch(siteID, range[0].toISOString(), range[1].toISOString())
message.success(`结算批次 ${batch.batch_no} 已创建,合计金额 ${formatMoney(batch.total_amount_cent)}`)
void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '批次创建失败')
} finally {
setCreating(false)
}
}
async function handleSettle(batch: SettlementBatch) {
Modal.confirm({
title: '立即释放入账?',
content: `即将把批次【${batch.batch_no}】的 ${formatMoney(batch.total_amount_cent)} 转入商户可提现余额,人工批次会绕过冷却期。`,
onOk: async () => {
try {
await settleBatch(batch.id)
message.success(`批次 ${batch.batch_no} 已释放入账`)
if (siteID) void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '释放失败')
}
},
})
}
async function handleCancel(batch: SettlementBatch) {
Modal.confirm({
title: '作废未入账批次?',
content: `作废【${batch.batch_no}】后,其中订单可重新进入自动释放或新的人工批次。`,
okButtonProps: { danger: true },
onOk: async () => {
try {
await cancelSettlementBatch(batch.id)
message.success(`批次 ${batch.batch_no} 已作废`)
if (siteID) void load(siteID)
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage ?? '作废失败')
}
},
})
}
async function showItems(batch: SettlementBatch) {
setItemsFor(batch)
setItemsLoading(true)
try {
setItems(await listSettlementItems(batch.id))
} catch {
message.error('结算对账明细加载失败')
} finally {
setItemsLoading(false)
}
}
const isSuperadmin = member?.is_superadmin
return (
<Space direction="vertical" className="page" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
{isSuperadmin ? '结算管理与批次对账' : '结算对账单与财务对账'}
</Typography.Title>
<Typography.Text type="secondary">
{isSuperadmin
? '冷却期满后系统按站点按天自动释放入账;也可手工创建批次立即入账,或作废未入账草稿以免挡住自动释放'
: '订单完成冷却期结束后系统自动释放入账,此处查看对账批次与入账明细'}
</Typography.Text>
</div>
{isSuperadmin && sites.length > 1 && (
<Select
placeholder="切换分销网站"
style={{ width: 220 }}
value={siteID}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
onChange={(value) => {
setSiteID(value)
void load(value)
}}
/>
)}
</div>
<Alert
type="info"
showIcon
message="自动结算与对账说明"
description="订单完成后进入站点结算冷却期(由主站后台按商户配置,默认 7 天,0 天为立即释放),期满系统自动生成结算批次并将收益释放到「资金与提现」的可提现余额;退款或维权中的订单会暂停释放。"
/>
{/* Superadmin batch creation controls */}
{isSuperadmin && (
<Card title="管理员手工出账(立即结算)" size="small" style={{ background: '#f8fafc' }}>
<Space wrap>
<Typography.Text strong>选择结算周期:</Typography.Text>
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate} loading={creating}>
生成结算批次并结算
</Button>
</Space>
</Card>
)}
<Card title={isSuperadmin ? '结算批次列表' : '历史结算对账单'}>
<Table
rowKey="id"
loading={loading}
dataSource={batches}
pagination={false}
scroll={{ x: 1000 }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无结算批次。订单完成冷却期结束后系统会自动出具对账单并入账。"
/>
),
}}
columns={[
{
title: '账单批次号',
dataIndex: 'batch_no',
width: 220,
render: (v: string) => (
<div style={{ whiteSpace: 'nowrap' }}>
<Typography.Text
strong
copyable={{ text: v, tooltips: ['复制批次号', '已复制'] }}
className="mono-code"
style={{
fontSize: 13,
whiteSpace: 'nowrap',
wordBreak: 'keep-all',
display: 'inline-flex',
alignItems: 'center',
}}
>
{v}
</Typography.Text>
</div>
),
},
{
title: '结算周期开始',
dataIndex: 'period_start',
width: 170,
render: (v: string) => (
<span style={{ fontSize: 13, color: '#595959' }}>
{v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-'}
</span>
),
},
{
title: '结算周期截止',
dataIndex: 'period_end',
width: 170,
render: (v: string) => (
<span style={{ fontSize: 13, color: '#595959' }}>
{v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-'}
</span>
),
},
{
title: '账单合计收益',
dataIndex: 'total_amount_cent',
width: 160,
render: (v: number) => (
<Typography.Text strong style={{ color: '#389e0d', fontSize: 16 }}>
+{formatMoney(v)}
</Typography.Text>
),
},
{
title: '结算状态',
dataIndex: 'status',
width: 130,
render: (v: string) => {
const conf = STATUS_LABELS[v] || { label: v, color: 'default' }
return <Tag color={conf.color}>{conf.label}</Tag>
},
},
{
title: '操作',
width: 240,
render: (_: unknown, row: SettlementBatch) => (
<Space>
<Button
type="link"
size="small"
icon={<FileTextOutlined />}
onClick={() => void showItems(row)}
>
查看明细
</Button>
{isSuperadmin && row.status !== 'settled' && row.status !== 'cancelled' && (
<Button
type="link"
size="small"
style={{ color: '#52c41a' }}
onClick={() => void handleSettle(row)}
>
立即释放入账
</Button>
)}
{isSuperadmin && row.status !== 'settled' && row.status !== 'cancelled' && (
<Button
type="link"
size="small"
danger
onClick={() => void handleCancel(row)}
>
作废
</Button>
)}
</Space>
),
},
]}
/>
</Card>
{/* Itemized Settlement Details Modal */}
<Modal
open={itemsFor !== null}
title={
<Space>
<DollarCircleOutlined style={{ color: '#52c41a' }} />
<span>结算账单对账明细 · {itemsFor?.batch_no ?? ''}</span>
</Space>
}
footer={null}
width={680}
onCancel={() => setItemsFor(null)}
destroyOnClose
>
{itemsFor && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div
style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '12px 16px',
}}
>
<Row gutter={16}>
<Col span={8}>
<Statistic
title="账单结算总额"
value={((itemsFor.total_amount_cent || 0) / 100).toFixed(2)}
precision={2}
prefix="+¥"
valueStyle={{ color: '#389e0d', fontWeight: 'bold' }}
/>
</Col>
<Col span={8}>
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>结算状态</div>
<Tag
color={STATUS_LABELS[itemsFor.status]?.color || 'default'}
style={{ fontSize: 13, padding: '2px 8px' }}
>
{STATUS_LABELS[itemsFor.status]?.label || itemsFor.status}
</Tag>
</Col>
<Col span={8}>
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>包含明细笔数</div>
<strong style={{ fontSize: 18 }}>{items.length} 笔</strong>
</Col>
</Row>
</div>
<Table
rowKey="id"
loading={itemsLoading}
dataSource={items}
pagination={false}
scroll={{ x: 550 }}
columns={[
{
title: '关联订单 ID',
dataIndex: 'order_id',
render: (v: number) => <Typography.Text strong className="mono-code">订单 #{v}</Typography.Text>,
},
{
title: '分润角色 / 收益类型',
dataIndex: 'party_role',
render: (v: string) => (
<Tag color="blue">{PARTY_ROLE_LABELS[v] || v}</Tag>
),
},
{
title: '分成到账金额',
dataIndex: 'amount_cent',
render: (v: number) => (
<Typography.Text strong style={{ color: '#389e0d', fontSize: 15 }}>
+{formatMoney(v)}
</Typography.Text>
),
},
{
title: '入账状态',
dataIndex: 'status',
render: (v: string) => (
<Tag color={v === 'settled' ? 'success' : 'default'}>
{v === 'settled' ? '已入账钱包' : v || '待打款'}
</Tag>
),
},
]}
/>
</Space>
)}
</Modal>
</Space>
)
}