f35e1a3ba5
含现场提货入账、零应付仍出账、核对补生成,以及线上历史账单修复 SQL。
599 lines
19 KiB
TypeScript
599 lines
19 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
Card,
|
|
DatePicker,
|
|
Descriptions,
|
|
Drawer,
|
|
Form,
|
|
Input,
|
|
Modal,
|
|
Select,
|
|
Space,
|
|
Statistic,
|
|
Table,
|
|
Tag,
|
|
Tooltip,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import type { Dayjs } from 'dayjs';
|
|
import 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';
|
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
|
import { AdminListHeader } from '../components/AdminListHeader';
|
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
|
|
|
|
|
type Row = {
|
|
id: string;
|
|
billNo: string;
|
|
billDate: string;
|
|
orderCount: number;
|
|
orderAmount: number;
|
|
wineryRate: number;
|
|
wineryAmount: number;
|
|
status: string;
|
|
paidAt?: string | null;
|
|
paymentRef?: string | null;
|
|
wineryBank?: {
|
|
bankAccountName?: string | null;
|
|
bankName?: string | null;
|
|
bankBranch?: string | null;
|
|
bankAccountNo?: 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: '跨城',
|
|
ON_SITE_PICKUP: '现场提货',
|
|
};
|
|
|
|
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 [reconcileForm] = Form.useForm<{ range?: [Dayjs, Dayjs] }>();
|
|
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 [reconciling, setReconciling] = useState(false);
|
|
const [reconcileOpen, setReconcileOpen] = 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) {
|
|
let paymentRef = '';
|
|
Modal.confirm({
|
|
title: '确认打款?',
|
|
content: (
|
|
<div>
|
|
<div>
|
|
将确认 {ids.length} 笔酒厂对账单
|
|
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。
|
|
</div>
|
|
<Input
|
|
placeholder="打款凭证号(可选)"
|
|
style={{ marginTop: 8 }}
|
|
onChange={(e) => {
|
|
paymentRef = e.target.value;
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
okText: '确认打款',
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
|
|
if (ids.length === 1) {
|
|
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST', body });
|
|
} 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 runReconcile(values: { range?: [Dayjs, Dayjs] }) {
|
|
setReconciling(true);
|
|
try {
|
|
const body: { dateFrom?: string; dateTo?: string } = {};
|
|
if (values.range?.[0]) body.dateFrom = values.range[0].format('YYYY-MM-DD');
|
|
if (values.range?.[1]) body.dateTo = values.range[1].format('YYYY-MM-DD');
|
|
const result = await request<{
|
|
generated: number;
|
|
skipped: number;
|
|
orphanOrders: number;
|
|
days: number;
|
|
}>('/admin/winery-bills/reconcile', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
message.success(
|
|
`核对完成:重算 ${result.generated} 天,跳过 ${result.skipped} 天,补入孤儿订单 ${result.orphanOrders} 笔`,
|
|
);
|
|
setReconcileOpen(false);
|
|
reload();
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '核对失败');
|
|
} finally {
|
|
setReconciling(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 baseColumns: ColumnsType<Row> = [
|
|
{
|
|
title: '账单号',
|
|
dataIndex: 'billNo',
|
|
width: 170,
|
|
render: (v, row) => (
|
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
|
),
|
|
},
|
|
{
|
|
title: (
|
|
<Tooltip title="出账当天的北京日历日;T+3 只决定纳入哪天完成的订单">
|
|
账单日
|
|
</Tooltip>
|
|
),
|
|
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>
|
|
),
|
|
},
|
|
];
|
|
|
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize });
|
|
|
|
return (
|
|
<div>
|
|
{settingsModal}
|
|
<AdminListHeader
|
|
title="酒厂对账单"
|
|
settings={settingsButton}
|
|
description={`T+3:每 3 天出一期(相对 2026-08-01),纳入上期 3 天同城/跨城/现场提货已付已完成订单(实付 × ${ratePct}%);应付为 0 仍出账「无需打款」`}
|
|
actions={
|
|
<Space>
|
|
<Button type="primary" onClick={() => setReconcileOpen(true)}>
|
|
核对补生成
|
|
</Button>
|
|
{canEditWineryBank ? (
|
|
<Button type="default" onClick={() => void openBankModal()}>
|
|
酒厂银行账户信息配置
|
|
</Button>
|
|
) : null}
|
|
</Space>
|
|
}
|
|
/>
|
|
|
|
{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: 'max-content' }}
|
|
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.Item label="打款凭证">
|
|
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
{detail.wineryBank ? (
|
|
<>
|
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
|
收款账户(酒厂)
|
|
</Typography.Title>
|
|
<Descriptions column={1} size="small" bordered>
|
|
<Descriptions.Item label="户名">
|
|
{detail.wineryBank.bankAccountName || '—'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="开户银行">
|
|
{detail.wineryBank.bankName || '—'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="开户支行">
|
|
{detail.wineryBank.bankBranch || '—'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="账号">
|
|
{detail.wineryBank.bankAccountNo || '—'}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
</>
|
|
) : null}
|
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
|
订单明细
|
|
</Typography.Title>
|
|
<Table
|
|
size="small"
|
|
rowKey="id"
|
|
pagination={false}
|
|
dataSource={detail.items ?? []}
|
|
columns={[
|
|
{ title: '订单号', dataIndex: 'orderNo' },
|
|
{
|
|
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>
|
|
|
|
<Modal
|
|
title="核对补生成酒厂账单"
|
|
open={reconcileOpen}
|
|
onCancel={() => setReconcileOpen(false)}
|
|
footer={null}
|
|
destroyOnClose
|
|
>
|
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
|
核对时会补齐同城配送、跨城配送、现场提货的已付已完成订单;并修复历史现场单缺完成时间的问题。
|
|
实付或应付为 0 也会出账,状态显示为「无需打款」。已打款账单不回刷。
|
|
</Typography.Paragraph>
|
|
<Form
|
|
form={reconcileForm}
|
|
layout="vertical"
|
|
initialValues={{ range: [dayjs().subtract(60, 'day'), dayjs()] }}
|
|
onFinish={(v) => void runReconcile(v)}
|
|
>
|
|
<Form.Item name="range" label="出账日区间(可留空则默认近 60 天)">
|
|
<DatePicker.RangePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Button type="primary" htmlType="submit" block loading={reconciling}>
|
|
开始核对
|
|
</Button>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|