Files
dukang/apps/admin-web/src/pages/PartnerBillsPage.tsx
T

697 lines
23 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type BillItemRow = {
id: string;
kind: 'ORDER' | 'REDEEM';
refId: string;
refNo: string;
title?: string | null;
extra?: string | null;
baseAmount: number;
rate: number;
commission: number;
occurredAt: string;
};
type Row = {
id: string;
billNo: string;
orderCommission: number;
redeemCommission: number;
totalAmount: number;
status: string;
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
paymentRef?: string | null;
paidAt?: string | null;
orderItems?: BillItemRow[];
redeemItems?: BillItemRow[];
partner?: {
companyName?: string;
name?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
partnerAccount?: {
companyName?: string;
name?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
};
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string };
const STATUS_LABELS: Record<string, string> = {
PENDING_REVIEW: '待审核',
AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
};
const STATUS_COLORS: Record<string, string> = {
PENDING_REVIEW: 'gold',
AWAITING_CONFIRM: 'blue',
UNPAID: 'red',
PAID: 'green',
REJECTED: 'red',
};
function partnerDashLabel(p?: {
companyName?: string | null;
name?: string | null;
phone?: string | null;
} | null): string {
if (!p) return '—';
const company = (p.companyName || '').trim();
const person = (p.name || '').trim();
if (company && person) return `${company}-${person}`;
return company || person || (p.phone || '').trim() || '—';
}
function weekMonday(d: Dayjs) {
const wd = d.day();
return d.subtract(wd === 0 ? 6 : wd - 1, 'day').startOf('day');
}
export default function PartnerBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [partners, setPartners] = useState<PartnerOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partner-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.weekStartYmd) qs.set('weekStartYmd', filters.weekStartYmd);
return qs;
},
[filters],
);
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items))
.catch(() => {});
}, []);
async function generateBill(values: { partnerId?: string; week: Dayjs }) {
const weekStartYmd = weekMonday(values.week).format('YYYY-MM-DD');
if (!values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ weekStartYmd }) },
);
message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`);
} else {
await request('/admin/partner-bills/generate', {
method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, weekStartYmd }),
});
message.success('账单已生成(待审核)');
}
setGenOpen(false);
reload();
}
function sendBills(ids: string[]) {
Modal.confirm({
title: '发送给合伙人?',
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
okText: '确认发送',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
} else {
await request('/admin/partner-bills/batch-send', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已发送');
setSelectedKeys([]);
reload();
},
});
}
function markPaid(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: (
<div>
<div>
将标记 {ids.length} 笔账单为已打款
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { method: 'POST', body });
} else {
await request('/admin/partner-bills/batch-mark-paid', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已标记打款');
setSelectedKeys([]);
reload();
},
});
}
function openReject(row: Row) {
Modal.confirm({
title: '驳回该账单?',
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
okText: '继续填写理由',
cancelText: '取消',
onOk: () => {
setRejectTarget(row);
rejectForm.resetFields();
setRejectOpen(true);
},
});
}
async function submitReject(values: { reason: string }) {
if (!rejectTarget) return;
setRejecting(true);
try {
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: values.reason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setRejectTarget(null);
reload();
} finally {
setRejecting(false);
}
}
async function openDetail(id: string) {
const d = await request<Row>(`/admin/partner-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.weekStartYmd) qs.set('weekStartYmd', filters.weekStartYmd);
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
const suffix = filters.weekStartYmd || 'all';
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
message.success(`已导出 ${result.count} 条`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const partnerName = (r: Row) => partnerDashLabel(r.partnerAccount ?? r.partner);
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter(
(r) => (r.status === 'PENDING_REVIEW' || r.status === 'REJECTED') && Number(r.totalAmount) > 0,
);
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
const baseColumns: ColumnsType<Row> = [
{
title: '账单号',
dataIndex: 'billNo',
width: 180,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '合伙人',
width: 200,
render: (_, r) => partnerName(r),
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
},
{
title: '酒单佣金',
dataIndex: 'orderCommission',
width: 110,
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '核销佣金',
dataIndex: 'redeemCommission',
width: 110,
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '合计应付',
dataIndex: 'totalAmount',
width: 130,
render: (v) =>
Number(v) <= 0 ? '¥0.00(不同步)' : ${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
width: 100,
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
},
{
title: '状态',
dataIndex: 'status',
width: 130,
render: (s, row) => (
<Space direction="vertical" size={0}>
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
{s === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
),
},
{
title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
明细
</Button>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && Number(row.totalAmount) > 0 && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
发送
</Button>
)}
{row.status === 'UNPAID' && (
<>
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
确认打款
</Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}>
驳回
</Button>
</>
)}
{row.status === 'AWAITING_CONFIRM' && (
<Button type="link" size="small" danger onClick={() => openReject(row)}>
驳回
</Button>
)}
</Space>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<AdminListHeader
title="合伙人账单"
settings={settingsButton}
description="每周一 8:00 自动生成上周账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款。零元账单不同步给合伙人。"
/>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic title="酒单佣金合计" value={summary.orderCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="核销佣金合计" value={summary.redeemCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.totalAmount} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { partnerId?: string; status?: string; week?: Dayjs }) => {
setFilters({
partnerId: v.partnerId || '',
status: v.status || '',
weekStartYmd: v.week ? weekMonday(v.week).format('YYYY-MM-DD') : '',
});
setPage(1);
}}
>
<Form.Item name="partnerId" label="合伙人">
<Select
allowClear
showSearch
placeholder="全部合伙人"
style={{ width: 220 }}
optionFilterProp="label"
options={partners.map((p) => ({
value: p.id,
label: partnerDashLabel(p),
}))}
/>
</Form.Item>
<Form.Item name="week" label="账期周">
<DatePicker picker="week" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 150 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
重置
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={() => setGenOpen(true)}>
生成账单
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
导出 Excel
</Button>
</Form.Item>
<Form.Item>
<Button disabled={!canSend.length} onClick={() => sendBills(canSend.map((r) => r.id))}>
批量发送 ({canSend.length})
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!canPay.length}
onClick={() => markPaid(canPay.map((r) => r.id), payAmount)}
>
批量打款 ({canPay.length})
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
}}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form
form={genForm}
layout="vertical"
initialValues={{ week: weekMonday(dayjs().subtract(1, 'week')) }}
onFinish={generateBill}
>
<Form.Item name="week" label="账单周(选任意一天,按周一起算)" rules={[{ required: true }]}>
<DatePicker picker="week" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="partnerId" label="指定合伙人(留空则全部主合伙人)">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部主合伙人"
options={partners.map((p) => ({
value: p.id,
label: partnerDashLabel(p),
}))}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block>
生成(待审核)
</Button>
</Form>
</Modal>
<Modal
title="驳回打款申请"
open={rejectOpen}
onCancel={() => {
setRejectOpen(false);
setRejectTarget(null);
}}
footer={null}
destroyOnClose
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
账单 {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
</Typography.Paragraph>
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
<Form.Item
name="reason"
label="驳回理由"
rules={[
{ required: true, message: '请填写驳回理由' },
{ max: 500, message: '不超过 500 字' },
]}
>
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount />
</Form.Item>
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
确认驳回
</Button>
</Form>
</Modal>
<Drawer
title="合伙人账单明细"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={720}
>
{detail && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="合伙人">{partnerName(detail)}</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="订单佣金">
¥{Number(detail.orderCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销佣金">
¥{Number(detail.redeemCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="应付合计">
¥{Number(detail.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLORS[detail.status] || 'default'}>
{STATUS_LABELS[detail.status] || detail.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
<Tabs
style={{ marginTop: 16 }}
items={[
{
key: 'orders',
label: `酒订单 (${detail.orderItems?.length ?? 0})`,
children: (
<>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.orderItems ?? []}
columns={[
{ title: '订单号', dataIndex: 'refNo', width: 160 },
{ title: '商品', dataIndex: 'title' },
{ title: '数量', dataIndex: 'extra', width: 70 },
{
title: '实付',
dataIndex: 'baseAmount',
render: (v: number) => ${Number(v).toFixed(2)}`,
},
{
title: '费率',
dataIndex: 'rate',
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
},
{
title: '佣金',
dataIndex: 'commission',
render: (v: number) => ${Number(v).toFixed(2)}`,
},
]}
/>
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
酒单佣金 ¥{Number(detail.orderCommission).toFixed(2)}
</Typography.Paragraph>
</>
),
},
{
key: 'redeems',
label: `核销订单 (${detail.redeemItems?.length ?? 0})`,
children: (
<>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.redeemItems ?? []}
columns={[
{ title: '核销单号', dataIndex: 'refNo', width: 160 },
{ title: '门店', dataIndex: 'title' },
{
title: '核销额',
dataIndex: 'baseAmount',
render: (v: number) => ${Number(v).toFixed(2)}`,
},
{
title: '费率',
dataIndex: 'rate',
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
},
{
title: '佣金',
dataIndex: 'commission',
render: (v: number) => ${Number(v).toFixed(2)}`,
},
]}
/>
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
核销佣金 ¥{Number(detail.redeemCommission).toFixed(2)}
</Typography.Paragraph>
</>
),
},
]}
/>
<Typography.Title level={5} style={{ marginTop: 16 }}>
收款账户
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{partnerBank(detail)?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{partnerBank(detail)?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{partnerBank(detail)?.bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
</div>
);
}