626 lines
20 KiB
TypeScript
626 lines
20 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
Button,
|
||
Card,
|
||
DatePicker,
|
||
Descriptions,
|
||
Drawer,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Table,
|
||
Tabs,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import type { Dayjs } from 'dayjs';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||
type LogisticsSettlementMethod,
|
||
} from '@dukang/shared-types';
|
||
import { fmtTime } from '../lib/constants';
|
||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||
import { request } from '../lib/api';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
|
||
type BillRow = {
|
||
id: string;
|
||
billNo: string;
|
||
fulfillmentProviderId: string;
|
||
providerCode?: string;
|
||
providerName?: string;
|
||
periodStart: string;
|
||
periodEnd: string;
|
||
orderCount: number;
|
||
bottleCount: number;
|
||
logisticsAmount: number;
|
||
settlementMethod: string;
|
||
status: string;
|
||
paidAt?: string | null;
|
||
};
|
||
|
||
type BillItem = {
|
||
id: string;
|
||
orderNo: string;
|
||
quantity: number;
|
||
logisticsAmount: number;
|
||
shippedAt: string;
|
||
};
|
||
|
||
type ProviderSummary = {
|
||
providerId: string;
|
||
providerCode: string;
|
||
providerName: string;
|
||
settlementMethod: string;
|
||
prepaidBalance: number;
|
||
bankAccountName?: string | null;
|
||
bankName?: string | null;
|
||
bankAccountNo?: string | null;
|
||
orderCount: number;
|
||
bottleCount: number;
|
||
logisticsAmount: number;
|
||
billId?: string | null;
|
||
billStatus?: string | null;
|
||
pricingRules?: {
|
||
baseBottles: number;
|
||
baseFee: number;
|
||
extraBottleFee: number;
|
||
boxBottles?: number;
|
||
boxFee?: number;
|
||
} | null;
|
||
};
|
||
|
||
const STATUS_LABELS: Record<string, string> = {
|
||
UNPAID: '未结算',
|
||
PAID: '已结算',
|
||
};
|
||
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
UNPAID: 'red',
|
||
PAID: 'green',
|
||
};
|
||
|
||
export default function LogisticsBillsPage() {
|
||
const [form] = Form.useForm();
|
||
const [summaryForm] = Form.useForm();
|
||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<BillRow>(
|
||
'/admin/logistics-bills',
|
||
() => {
|
||
const qs = new URLSearchParams();
|
||
if (filters.status) qs.set('status', filters.status);
|
||
if (filters.providerId) qs.set('providerId', filters.providerId);
|
||
if (filters.year) qs.set('year', filters.year);
|
||
if (filters.month) qs.set('month', filters.month);
|
||
return qs;
|
||
},
|
||
[filters],
|
||
);
|
||
const [exporting, setExporting] = useState(false);
|
||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||
const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [providers, setProviders] = useState<Array<{ id: string; code: string; name: string }>>([]);
|
||
const [summaryRows, setSummaryRows] = useState<ProviderSummary[]>([]);
|
||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||
const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month'));
|
||
const [genOpen, setGenOpen] = useState(false);
|
||
const [genForm] = Form.useForm();
|
||
const [rechargeOpen, setRechargeOpen] = useState(false);
|
||
const [rechargeTarget, setRechargeTarget] = useState<ProviderSummary | null>(null);
|
||
const [rechargeForm] = Form.useForm();
|
||
|
||
useEffect(() => {
|
||
request<Array<{ id: string; code: string; name: string }>>('/admin/fulfillment-providers')
|
||
.then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name }))))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
async function loadSummary(period: Dayjs) {
|
||
setSummaryLoading(true);
|
||
try {
|
||
const res = await request<{ items: ProviderSummary[] }>(
|
||
`/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`,
|
||
);
|
||
setSummaryRows(res.items ?? []);
|
||
} finally {
|
||
setSummaryLoading(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void loadSummary(summaryPeriod);
|
||
}, [summaryPeriod]);
|
||
|
||
function confirmPay(ids: string[], amountHint?: number) {
|
||
Modal.confirm({
|
||
title: '确认结算?',
|
||
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
|
||
okText: '确认结算',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
if (ids.length === 1) {
|
||
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||
} else {
|
||
await request('/admin/logistics-bills/batch-confirm', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ ids }),
|
||
});
|
||
}
|
||
message.success('已确认结算');
|
||
setSelectedKeys([]);
|
||
reload();
|
||
void loadSummary(summaryPeriod);
|
||
},
|
||
});
|
||
}
|
||
|
||
async function openDetail(id: string) {
|
||
const d = await request<BillRow & { items?: BillItem[] }>(`/admin/logistics-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.providerId) qs.set('providerId', filters.providerId);
|
||
if (filters.year) qs.set('year', filters.year);
|
||
if (filters.month) qs.set('month', filters.month);
|
||
const result = await request<{ csv: string; count: number }>(
|
||
`/admin/logistics-bills/export?${qs}`,
|
||
);
|
||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||
downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`);
|
||
message.success(`已导出 ${result.count} 条`);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}
|
||
|
||
async function generateBills() {
|
||
const v = await genForm.validateFields();
|
||
const month = v.month as Dayjs;
|
||
await request('/admin/logistics-bills/generate', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
year: month.year(),
|
||
month: month.month() + 1,
|
||
providerId: v.providerId || undefined,
|
||
}),
|
||
});
|
||
message.success('已生成物流对账单');
|
||
setGenOpen(false);
|
||
reload();
|
||
void loadSummary(summaryPeriod);
|
||
}
|
||
|
||
async function submitRecharge() {
|
||
if (!rechargeTarget) return;
|
||
const v = await rechargeForm.validateFields();
|
||
await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ amount: v.amount, remark: v.remark }),
|
||
});
|
||
message.success('充值成功');
|
||
setRechargeOpen(false);
|
||
void loadSummary(summaryPeriod);
|
||
}
|
||
|
||
const summary = data?.summary;
|
||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
||
|
||
const billColumns: ColumnsType<BillRow> = [
|
||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||
{
|
||
title: '承运商',
|
||
width: 140,
|
||
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
|
||
},
|
||
{
|
||
title: '账期',
|
||
width: 200,
|
||
render: (_, r) =>
|
||
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
||
},
|
||
{
|
||
title: '结算方式',
|
||
dataIndex: 'settlementMethod',
|
||
width: 100,
|
||
render: (v) =>
|
||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||
},
|
||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||
{
|
||
title: '物流费',
|
||
dataIndex: 'logisticsAmount',
|
||
width: 110,
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 90,
|
||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 160,
|
||
fixed: 'right',
|
||
render: (_, row) => (
|
||
<Space size={0}>
|
||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||
明细
|
||
</Button>
|
||
{row.status === 'UNPAID' && (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => confirmPay([row.id], Number(row.logisticsAmount))}
|
||
>
|
||
确认结算
|
||
</Button>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const summaryColumns: ColumnsType<ProviderSummary> = [
|
||
{
|
||
title: '承运商',
|
||
render: (_, r) => `${r.providerName} (${r.providerCode})`,
|
||
},
|
||
{
|
||
title: '结算方式',
|
||
dataIndex: 'settlementMethod',
|
||
width: 110,
|
||
render: (v) =>
|
||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||
},
|
||
{
|
||
title: '计价标准',
|
||
width: 220,
|
||
render: (_, r) => {
|
||
const p = r.pricingRules;
|
||
if (!p) return '—';
|
||
return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${
|
||
p.boxBottles ? `,${p.boxBottles}瓶箱¥${p.boxFee}` : ''
|
||
}`;
|
||
},
|
||
},
|
||
{
|
||
title: '银行账户',
|
||
width: 180,
|
||
ellipsis: true,
|
||
render: (_, r) =>
|
||
r.bankAccountName
|
||
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
|
||
: '—',
|
||
},
|
||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||
{
|
||
title: '应付物流费',
|
||
dataIndex: 'logisticsAmount',
|
||
width: 110,
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '充值余额',
|
||
dataIndex: 'prepaidBalance',
|
||
width: 110,
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '账单',
|
||
width: 100,
|
||
render: (_, r) =>
|
||
r.billStatus ? (
|
||
<Tag color={STATUS_COLORS[r.billStatus] || 'default'}>
|
||
{STATUS_LABELS[r.billStatus] || r.billStatus}
|
||
</Tag>
|
||
) : (
|
||
'未生成'
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 100,
|
||
render: (_, r) =>
|
||
r.settlementMethod === 'PREPAID' ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => {
|
||
setRechargeTarget(r);
|
||
rechargeForm.resetFields();
|
||
setRechargeOpen(true);
|
||
}}
|
||
>
|
||
充值
|
||
</Button>
|
||
) : null,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||
物流对账
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
||
</Typography.Text>
|
||
</Space>
|
||
|
||
<Tabs
|
||
items={[
|
||
{
|
||
key: 'summary',
|
||
label: '按承运商汇总',
|
||
children: (
|
||
<>
|
||
<Form
|
||
form={summaryForm}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
initialValues={{ month: summaryPeriod }}
|
||
onFinish={(v: { month?: Dayjs }) => {
|
||
const m = v.month || dayjs().subtract(1, 'month');
|
||
setSummaryPeriod(m);
|
||
}}
|
||
>
|
||
<Form.Item name="month" label="账期">
|
||
<DatePicker picker="month" allowClear={false} />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit">
|
||
查询
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
<Table
|
||
rowKey="providerId"
|
||
loading={summaryLoading}
|
||
columns={summaryColumns}
|
||
dataSource={summaryRows}
|
||
pagination={false}
|
||
scroll={{ x: 1200 }}
|
||
/>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: 'bills',
|
||
label: '月账单',
|
||
children: (
|
||
<>
|
||
{summary && (
|
||
<Card size="small" style={{ marginBottom: 16 }}>
|
||
<Space size="large" wrap>
|
||
<Statistic title="账单数" value={summary.count} />
|
||
<Statistic
|
||
title="瓶数合计"
|
||
value={summary.bottleCount ?? 0}
|
||
/>
|
||
<Statistic
|
||
title="物流费合计"
|
||
value={summary.logisticsAmount ?? summary.totalAmount ?? 0}
|
||
prefix="¥"
|
||
precision={2}
|
||
/>
|
||
</Space>
|
||
</Card>
|
||
)}
|
||
|
||
<Form
|
||
form={form}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
onFinish={(v: {
|
||
status?: string;
|
||
providerId?: string;
|
||
month?: Dayjs;
|
||
}) => {
|
||
setFilters({
|
||
status: v.status || '',
|
||
providerId: v.providerId || '',
|
||
year: v.month ? String(v.month.year()) : '',
|
||
month: v.month ? String(v.month.month() + 1) : '',
|
||
});
|
||
setPage(1);
|
||
}}
|
||
>
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
options={[
|
||
{ value: 'UNPAID', label: '未结算' },
|
||
{ value: 'PAID', label: '已结算' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="providerId" label="承运商">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 180 }}
|
||
options={providers.map((p) => ({
|
||
value: p.id,
|
||
label: `${p.name} (${p.code})`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="month" label="账期">
|
||
<DatePicker picker="month" />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Space>
|
||
<Button type="primary" htmlType="submit">
|
||
查询
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
genForm.setFieldsValue({ month: dayjs().subtract(1, 'month') });
|
||
setGenOpen(true);
|
||
}}
|
||
>
|
||
生成账单
|
||
</Button>
|
||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||
导出
|
||
</Button>
|
||
<Button
|
||
disabled={!selectedKeys.length}
|
||
onClick={() =>
|
||
confirmPay(
|
||
selectedKeys.map(String),
|
||
selectedAmount,
|
||
)
|
||
}
|
||
>
|
||
批量结算
|
||
</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<Table
|
||
rowKey="id"
|
||
loading={loading}
|
||
columns={billColumns}
|
||
dataSource={data?.items ?? []}
|
||
rowSelection={{
|
||
selectedRowKeys: selectedKeys,
|
||
onChange: setSelectedKeys,
|
||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||
}}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total ?? 0,
|
||
onChange: (p, ps) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
},
|
||
}}
|
||
scroll={{ x: 1100 }}
|
||
/>
|
||
</>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<Drawer
|
||
title="物流对账明细"
|
||
width={640}
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
>
|
||
{detail && (
|
||
<>
|
||
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||
<Descriptions.Item label="承运商">
|
||
{detail.providerName} ({detail.providerCode})
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="账期">
|
||
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="物流费">
|
||
¥{Number(detail.logisticsAmount).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
{STATUS_LABELS[detail.status] || detail.status}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="结算时间">
|
||
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
pagination={false}
|
||
dataSource={detail.items ?? []}
|
||
columns={[
|
||
{ title: '订单号', dataIndex: 'orderNo' },
|
||
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
|
||
{
|
||
title: '物流费',
|
||
dataIndex: 'logisticsAmount',
|
||
width: 90,
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '发货时间',
|
||
dataIndex: 'shippedAt',
|
||
width: 160,
|
||
render: fmtTime,
|
||
},
|
||
]}
|
||
/>
|
||
</>
|
||
)}
|
||
</Drawer>
|
||
|
||
<Modal
|
||
title="生成物流月账单"
|
||
open={genOpen}
|
||
onCancel={() => setGenOpen(false)}
|
||
onOk={() => void generateBills()}
|
||
destroyOnClose
|
||
>
|
||
<Form form={genForm} layout="vertical">
|
||
<Form.Item name="month" label="账期月" rules={[{ required: true }]}>
|
||
<DatePicker picker="month" style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="providerId" label="承运商(空=全部)">
|
||
<Select
|
||
allowClear
|
||
options={providers.map((p) => ({
|
||
value: p.id,
|
||
label: `${p.name} (${p.code})`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={rechargeTarget ? `充值 · ${rechargeTarget.providerName}` : '充值'}
|
||
open={rechargeOpen}
|
||
onCancel={() => setRechargeOpen(false)}
|
||
onOk={() => void submitRecharge()}
|
||
destroyOnClose
|
||
>
|
||
<Form form={rechargeForm} layout="vertical">
|
||
<Form.Item
|
||
name="amount"
|
||
label="金额"
|
||
rules={[{ required: true, message: '请输入充值金额' }]}
|
||
>
|
||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
|
||
</Form.Item>
|
||
<Form.Item name="remark" label="备注">
|
||
<Input placeholder="可选,如预充运费" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|