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 = { UNPAID: '未结算', PAID: '已结算', }; const STATUS_COLORS: Record = { UNPAID: 'red', PAID: 'green', }; export default function LogisticsBillsPage() { const [form] = Form.useForm(); const [summaryForm] = Form.useForm(); const [filters, setFilters] = useState>({}); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/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([]); const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); const [providers, setProviders] = useState>([]); const [summaryRows, setSummaryRows] = useState([]); 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(null); const [rechargeForm] = Form.useForm(); useEffect(() => { request>('/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(`/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 = [ { 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) => {STATUS_LABELS[s] || s}, }, { title: '操作', width: 160, fixed: 'right', render: (_, row) => ( {row.status === 'UNPAID' && ( )} ), }, ]; const summaryColumns: ColumnsType = [ { 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 ? ( {STATUS_LABELS[r.billStatus] || r.billStatus} ) : ( '未生成' ), }, { title: '操作', width: 100, render: (_, r) => r.settlementMethod === 'PREPAID' ? ( ) : null, }, ]; return (
物流对账 按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
{ const m = v.month || dayjs().subtract(1, 'month'); setSummaryPeriod(m); }} >
), }, { key: 'bills', label: '月账单', children: ( <> {summary && ( )} { setFilters({ status: v.status || '', providerId: v.providerId || '', year: v.month ? String(v.month.year()) : '', month: v.month ? String(v.month.month() + 1) : '', }); setPage(1); }} > ({ value: p.id, label: `${p.name} (${p.code})`, }))} />
({ disabled: r.status !== 'UNPAID' }), }} pagination={{ current: page, pageSize, total: data?.total ?? 0, onChange: (p, ps) => { setPage(p); setPageSize(ps); }, }} scroll={{ x: 1100 }} /> ), }, ]} /> setDrawerOpen(false)} > {detail && ( <> {detail.billNo} {detail.providerName} ({detail.providerCode}) {String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)} ¥{Number(detail.logisticsAmount).toFixed(2)} {STATUS_LABELS[detail.status] || detail.status} {detail.paidAt ? fmtTime(detail.paidAt) : '—'}
`¥${Number(v).toFixed(2)}`, }, { title: '发货时间', dataIndex: 'shippedAt', width: 160, render: fmtTime, }, ]} /> )} setGenOpen(false)} onOk={() => void generateBills()} destroyOnClose >
); }