445 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|