feat(settlement): redesign factory/partner/store statement bills
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
Add WineryBill/StoreBill tables, partner review-send-confirm flow, daily/monthly cron, and admin multi-select confirm with status filters. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,7 +3,10 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
@@ -21,17 +24,34 @@ import { request } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
cityName?: string;
|
||||
receiverCity?: string;
|
||||
payAmount: number;
|
||||
id: string;
|
||||
billNo: string;
|
||||
billDate: string;
|
||||
orderCount: number;
|
||||
orderAmount: number;
|
||||
wineryRate: number;
|
||||
wineryAmount: number;
|
||||
status: string;
|
||||
paidAt?: string | null;
|
||||
};
|
||||
|
||||
type BillItem = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
payAmount: number;
|
||||
wineryAmount: number;
|
||||
paidAt: string;
|
||||
productName?: string;
|
||||
quantity?: number;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
};
|
||||
|
||||
const DELIVERY_LABELS: Record<string, string> = {
|
||||
@@ -42,29 +62,64 @@ const DELIVERY_LABELS: Record<string, string> = {
|
||||
export default function WineryBillsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/winery-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
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/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/winery-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已确认打款');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const d = await request<Row & { items?: BillItem[] }>(`/admin/winery-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.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
|
||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
|
||||
downloadExcelCsv(result.csv, `酒厂对账单_${suffix}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
@@ -73,23 +128,22 @@ export default function WineryBillsPage() {
|
||||
|
||||
const summary = data?.summary;
|
||||
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '配送类型',
|
||||
dataIndex: 'deliveryType',
|
||||
width: 90,
|
||||
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
|
||||
},
|
||||
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
|
||||
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
|
||||
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
|
||||
{ title: '数量', dataIndex: 'quantity', width: 70 },
|
||||
{
|
||||
title: '酒单实付',
|
||||
dataIndex: 'payAmount',
|
||||
title: '账单日',
|
||||
dataIndex: 'billDate',
|
||||
width: 110,
|
||||
render: (v) => String(v || '').slice(0, 10),
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{
|
||||
title: '酒单实付合计',
|
||||
dataIndex: 'orderAmount',
|
||||
width: 120,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
@@ -105,10 +159,27 @@ export default function WineryBillsPage() {
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 170,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
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.wineryAmount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -116,19 +187,19 @@ export default function WineryBillsPage() {
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂账单
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+30 结算:按自然月列出同城/跨城已付酒单,应付 = 酒单实付 × {ratePct}%(暂定)
|
||||
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="酒单数" value={summary.count} />
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
@@ -137,28 +208,30 @@ export default function WineryBillsPage() {
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
|
||||
onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => {
|
||||
setFilters({
|
||||
status: v.status || '',
|
||||
year: v.month ? String(v.month.year()) : '',
|
||||
month: v.month ? String(v.month.month() + 1) : '',
|
||||
deliveryType: v.deliveryType || '',
|
||||
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="month" label="账期月">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deliveryType" label="配送类型">
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'LOCAL', label: '同城' },
|
||||
{ value: 'CROSS_CITY', label: '跨城' },
|
||||
]}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="month" label="账期月">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="账单日">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
@@ -180,15 +253,29 @@ export default function WineryBillsPage() {
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!selectedKeys.length}
|
||||
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
||||
>
|
||||
批量确认打款 ({selectedKeys.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="orderId"
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -200,6 +287,56 @@ export default function WineryBillsPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="酒厂对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={640}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
||||
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).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>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
订单明细
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={detail.items ?? []}
|
||||
columns={[
|
||||
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
|
||||
{
|
||||
title: '配送',
|
||||
dataIndex: 'deliveryType',
|
||||
width: 70,
|
||||
render: (v) => DELIVERY_LABELS[v] || v,
|
||||
},
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'payAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '酒厂应付',
|
||||
dataIndex: 'wineryAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 150,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user