物流对账单
This commit is contained in:
@@ -31,6 +31,7 @@ import ResourcesPage from './pages/ResourcesPage';
|
||||
import StoreBillsPage from './pages/StoreBillsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import WineryBillsPage from './pages/WineryBillsPage';
|
||||
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import SupportTicketsPage from './pages/SupportTicketsPage';
|
||||
import InvoicesPage from './pages/InvoicesPage';
|
||||
@@ -95,6 +96,7 @@ export default function App() {
|
||||
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||
|
||||
@@ -75,6 +75,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/finance/store-bills', label: '门店账单' },
|
||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -156,6 +157,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/finance/store-bills': 'finance',
|
||||
'/finance/partner-bills': 'finance',
|
||||
'/finance/winery-bills': 'finance',
|
||||
'/finance/logistics-bills': 'finance',
|
||||
'benefit-group': 'benefit',
|
||||
'/benefit/coupons': 'benefit',
|
||||
'/benefit/ledgers': 'benefit',
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
@@ -14,10 +16,13 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
DEFAULT_XFX_LOGISTICS_PRICING,
|
||||
FULFILLMENT_PROVIDER_STATUS_LABELS,
|
||||
FULFILLMENT_PROVIDER_TYPE_LABELS,
|
||||
FulfillmentProviderStatus,
|
||||
FulfillmentProviderType,
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||||
LogisticsSettlementMethod,
|
||||
isXfxProviderCode,
|
||||
type FulfillmentProviderDto,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -32,6 +37,9 @@ const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||
const SIGN_OPTIONS = [
|
||||
{ value: 'MD5', label: 'MD5' },
|
||||
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
|
||||
@@ -77,6 +85,12 @@ export default function FulfillmentProvidersPage() {
|
||||
status: FulfillmentProviderStatus.ACTIVE,
|
||||
apiUrl: DEFAULT_XFX_API_URL,
|
||||
signType: 'MD5',
|
||||
settlementMethod: LogisticsSettlementMethod.PREPAID,
|
||||
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||||
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||||
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -84,6 +98,7 @@ export default function FulfillmentProvidersPage() {
|
||||
function openEdit(row: FulfillmentProviderDto) {
|
||||
setEditRow(row);
|
||||
const xfx = row.xiaofeixiaConfig;
|
||||
const pricing = row.pricingRules;
|
||||
form.setFieldsValue({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
@@ -94,6 +109,16 @@ export default function FulfillmentProvidersPage() {
|
||||
apiKey: '',
|
||||
signType: xfx?.signType || 'MD5',
|
||||
appId: xfx?.appId || '',
|
||||
bankAccountName: row.bankAccountName || '',
|
||||
bankName: row.bankName || '',
|
||||
bankBranch: row.bankBranch || '',
|
||||
bankAccountNo: row.bankAccountNo || '',
|
||||
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
|
||||
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||||
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||||
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -104,6 +129,18 @@ export default function FulfillmentProvidersPage() {
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
status: v.status,
|
||||
bankAccountName: v.bankAccountName || null,
|
||||
bankName: v.bankName || null,
|
||||
bankBranch: v.bankBranch || null,
|
||||
bankAccountNo: v.bankAccountNo || null,
|
||||
settlementMethod: v.settlementMethod,
|
||||
pricingRules: {
|
||||
baseBottles: Number(v.baseBottles),
|
||||
baseFee: Number(v.baseFee),
|
||||
extraBottleFee: Number(v.extraBottleFee),
|
||||
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||||
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||||
@@ -144,6 +181,19 @@ export default function FulfillmentProvidersPage() {
|
||||
dataIndex: 'type',
|
||||
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
|
||||
},
|
||||
{
|
||||
title: '结算',
|
||||
dataIndex: 'settlementMethod',
|
||||
width: 100,
|
||||
render: (v) =>
|
||||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
|
||||
},
|
||||
{
|
||||
title: '充值余额',
|
||||
dataIndex: 'prepaidBalance',
|
||||
width: 100,
|
||||
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -188,7 +238,7 @@ export default function FulfillmentProvidersPage() {
|
||||
仓配管理
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
注册第三方履约接口并填写凭证;仓库绑定后,推单将使用此处配置的 API 地址与密钥
|
||||
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
@@ -200,7 +250,7 @@ export default function FulfillmentProvidersPage() {
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="小飞侠配置已从「系统设置 → 同城配送」迁至本页。寄件地址以仓库联系人/地址为准。"
|
||||
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
||||
/>
|
||||
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||
@@ -210,7 +260,7 @@ export default function FulfillmentProvidersPage() {
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
width={560}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
@@ -227,11 +277,58 @@ export default function FulfillmentProvidersPage() {
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">物流对账</Divider>
|
||||
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||||
<Select options={SETTLEMENT_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="收款户名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankName" label="开户银行">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户支行">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="银行账号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name="baseBottles"
|
||||
label="起送瓶数"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="baseFee"
|
||||
label="起送费用(元)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="extraBottleFee"
|
||||
label="加一瓶(元)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
|
||||
<InputNumber min={1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
|
||||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
{showXfxFields && (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 8 }}>
|
||||
小飞侠接口参数
|
||||
</Typography.Title>
|
||||
<Divider orientation="left">小飞侠接口参数</Divider>
|
||||
<Form.Item
|
||||
name="apiUrl"
|
||||
label="API 地址"
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user