478291e2b3
Allow HQ to save stores with zero packages, preview contract images/PDFs, inspect package audit images/content, and show gray 无需打款 when winery payable is 0. Co-authored-by: Cursor <cursoragent@cursor.com>
482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
Button,
|
||
Card,
|
||
DatePicker,
|
||
Descriptions,
|
||
Drawer,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import type { Dayjs } from 'dayjs';
|
||
import {
|
||
WINERY_SETTLEMENT_RATE,
|
||
type SystemConfigFormResponse,
|
||
} from '@dukang/shared-types';
|
||
import { fmtTime } from '../lib/constants';
|
||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||
import { request, type HqProfile } from '../lib/api';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
|
||
type Row = {
|
||
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;
|
||
};
|
||
|
||
const STATUS_LABELS: Record<string, string> = {
|
||
UNPAID: '未打款',
|
||
PAID: '已打款',
|
||
NO_PAYMENT_NEEDED: '无需打款',
|
||
};
|
||
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
UNPAID: 'red',
|
||
PAID: 'green',
|
||
NO_PAYMENT_NEEDED: 'default',
|
||
};
|
||
|
||
function isZeroPayable(amount: number | string | null | undefined) {
|
||
return Number(amount ?? 0) === 0;
|
||
}
|
||
|
||
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
|
||
function displayWineryStatus(status: string, wineryAmount: number | string) {
|
||
if (isZeroPayable(wineryAmount)) {
|
||
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
|
||
}
|
||
return {
|
||
key: status,
|
||
label: STATUS_LABELS[status] || status,
|
||
color: STATUS_COLORS[status] || 'default',
|
||
};
|
||
}
|
||
|
||
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
|
||
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
|
||
}
|
||
|
||
const DELIVERY_LABELS: Record<string, string> = {
|
||
LOCAL: '同城',
|
||
CROSS_CITY: '跨城',
|
||
};
|
||
|
||
const WINERY_BANK_KEYS = [
|
||
'WINERY_BANK_ACCOUNT_NAME',
|
||
'WINERY_BANK_NAME',
|
||
'WINERY_BANK_BRANCH',
|
||
'WINERY_BANK_ACCOUNT_NO',
|
||
] as const;
|
||
|
||
export default function WineryBillsPage() {
|
||
const [form] = Form.useForm();
|
||
const [bankForm] = Form.useForm<Record<string, string>>();
|
||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||
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.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);
|
||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||
const [bankOpen, setBankOpen] = useState(false);
|
||
const [bankLoading, setBankLoading] = useState(false);
|
||
const [bankSaving, setBankSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||
}, []);
|
||
|
||
const canEditWineryBank =
|
||
profile?.adminRole === 'SUPER_ADMIN' ||
|
||
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
|
||
|
||
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.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`);
|
||
message.success(`已导出 ${result.count} 条`);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}
|
||
|
||
async function openBankModal() {
|
||
setBankOpen(true);
|
||
setBankLoading(true);
|
||
try {
|
||
const cfg = await request<SystemConfigFormResponse>('/admin/system-config');
|
||
const values: Record<string, string> = {};
|
||
for (const key of WINERY_BANK_KEYS) {
|
||
values[key] = cfg.values[key] ?? '';
|
||
}
|
||
bankForm.setFieldsValue(values);
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '加载失败');
|
||
setBankOpen(false);
|
||
} finally {
|
||
setBankLoading(false);
|
||
}
|
||
}
|
||
|
||
async function saveBank() {
|
||
const values = await bankForm.validateFields();
|
||
setBankSaving(true);
|
||
try {
|
||
await request('/admin/system-config', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ values }),
|
||
});
|
||
message.success('酒厂银行账户已保存');
|
||
setBankOpen(false);
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '保存失败');
|
||
} finally {
|
||
setBankSaving(false);
|
||
}
|
||
}
|
||
|
||
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: 'billNo', width: 170, ellipsis: true },
|
||
{
|
||
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)}`,
|
||
},
|
||
{
|
||
title: '酒厂比例',
|
||
dataIndex: 'wineryRate',
|
||
width: 90,
|
||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||
},
|
||
{
|
||
title: '应付',
|
||
dataIndex: 'wineryAmount',
|
||
width: 110,
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (s, row) => {
|
||
const d = displayWineryStatus(s, row.wineryAmount);
|
||
return <Tag color={d.color}>{d.label}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 160,
|
||
fixed: 'right',
|
||
render: (_, row) => (
|
||
<Space size={0}>
|
||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||
明细
|
||
</Button>
|
||
{canConfirmWineryPay(row) && (
|
||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
||
确认打款
|
||
</Button>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
marginBottom: 16,
|
||
gap: 16,
|
||
}}
|
||
>
|
||
<Space direction="vertical" size={0}>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||
酒厂对账单
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0
|
||
无需打款(灰),可展开订单明细
|
||
</Typography.Text>
|
||
</Space>
|
||
{canEditWineryBank ? (
|
||
<Button type="default" onClick={() => void openBankModal()}>
|
||
酒厂银行账户信息配置
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
|
||
{summary && (
|
||
<Card size="small" style={{ marginBottom: 16 }}>
|
||
<Space size="large" wrap>
|
||
<Statistic title="账单数" value={summary.count} />
|
||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||
</Space>
|
||
</Card>
|
||
)}
|
||
|
||
<Form
|
||
form={form}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
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) : '',
|
||
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="status" label="状态">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 130 }}
|
||
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">
|
||
查询
|
||
</Button>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button
|
||
onClick={() => {
|
||
form.resetFields();
|
||
setFilters({});
|
||
setPage(1);
|
||
}}
|
||
>
|
||
重置
|
||
</Button>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||
导出 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="id"
|
||
className="admin-table-nowrap"
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={data?.items ?? []}
|
||
rowSelection={{
|
||
selectedRowKeys: selectedKeys,
|
||
onChange: setSelectedKeys,
|
||
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
|
||
}}
|
||
scroll={{ x: 1100 }}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total ?? 0,
|
||
showSizeChanger: true,
|
||
onChange: (p, ps) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<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="状态">
|
||
{displayWineryStatus(detail.status, detail.wineryAmount).label}
|
||
</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>
|
||
|
||
<Modal
|
||
title="酒厂银行账户信息配置"
|
||
open={bankOpen}
|
||
onCancel={() => setBankOpen(false)}
|
||
onOk={() => void saveBank()}
|
||
confirmLoading={bankSaving}
|
||
destroyOnClose
|
||
okText="保存"
|
||
>
|
||
<Form form={bankForm} layout="vertical" disabled={bankLoading}>
|
||
<Form.Item
|
||
name="WINERY_BANK_ACCOUNT_NAME"
|
||
label="户名"
|
||
rules={[{ required: true, message: '请填写户名' }]}
|
||
>
|
||
<Input placeholder="收款账户户名" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="WINERY_BANK_NAME"
|
||
label="开户银行"
|
||
rules={[{ required: true, message: '请填写开户银行' }]}
|
||
>
|
||
<Input placeholder="如:中国工商银行" />
|
||
</Form.Item>
|
||
<Form.Item name="WINERY_BANK_BRANCH" label="开户支行">
|
||
<Input placeholder="可选" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="WINERY_BANK_ACCOUNT_NO"
|
||
label="银行账号"
|
||
rules={[{ required: true, message: '请填写银行账号' }]}
|
||
>
|
||
<Input placeholder="银行卡号" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|