feat(settlement): 统一门店账单列表合并 T+1 与手动提现

总部财务以门店账单为唯一入口;新增 store-settlements API,去掉独立提现审菜单并更新企微引导文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:04:45 +08:00
parent 26472f8678
commit 54fca99208
8 changed files with 561 additions and 71 deletions
+8 -3
View File
@@ -30,7 +30,6 @@ import ProductsPage from './pages/ProductsPage';
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage';
import StoreWithdrawalsPage from './pages/StoreWithdrawalsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage';
@@ -97,13 +96,19 @@ export default function App() {
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route path="/finance/store-withdrawals" element={<StoreWithdrawalsPage />} />
<Route
path="/finance/store-withdrawals"
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
/>
<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="/store-withdrawals" element={<Navigate to="/finance/store-withdrawals" replace />} />
<Route
path="/store-withdrawals"
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
/>
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/tickets/support" element={<SupportTicketsPage />} />
@@ -74,7 +74,6 @@ const MENU_ITEMS: MenuProps['items'] = [
label: '财务',
children: [
{ key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/store-withdrawals', label: '门店提现审' },
{ key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' },
+1 -1
View File
@@ -721,7 +721,7 @@ export default function DashboardPage() {
<Card
loading={loading}
title="待审门店提现"
extra={<Link to="/finance/store-withdrawals"></Link>}
extra={<Link to="/finance/store-bills?kind=WITHDRAW"></Link>}
>
<Statistic
value={stats?.pendingStoreWithdrawals ?? 0}
+332 -64
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Card,
@@ -6,6 +7,7 @@ import {
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
@@ -17,44 +19,61 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import {
STORE_SETTLEMENT_KIND_LABELS,
STORE_SETTLEMENT_STATUS_LABELS,
storeSettlementStatusLabel,
type StoreSettlementKind,
type StoreSettlementStatus,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
type Row = {
kind: StoreSettlementKind;
id: string;
billNo: string;
billDate: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
payoutAmount: number;
status: string;
paidAt?: string | null;
store?: { id: string; name: string; cityName: string; phone?: string };
amount: number;
status: StoreSettlementStatus;
date: string;
overdue?: boolean;
redeemCount?: number | null;
redeemAmount?: number | null;
settlementRate?: number | null;
payoutCount?: number | null;
store?: { id: string; name: string; cityName: string; phone?: string | null };
};
type StoreOption = { id: string; name: string; phone: string };
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款',
PAID: '已打款',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
PENDING_REVIEW: 'orange',
REJECTED: 'red',
};
const KIND_COLORS: Record<StoreSettlementKind, string> = {
T1_BILL: 'blue',
WITHDRAW: 'purple',
};
export default function StoreBillsPage() {
const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
});
const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-bills',
'/admin/store-settlements',
() => {
const qs = new URLSearchParams();
if (filters.kind) qs.set('kind', filters.kind);
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
@@ -64,21 +83,38 @@ export default function StoreBillsPage() {
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [detailKind, setDetailKind] = useState<StoreSettlementKind | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
const [overdueSummary, setOverdueSummary] = useState<{
pendingCount: number;
overdueCount: number;
} | null>(null);
useEffect(() => {
form.setFieldsValue({
kind: filters.kind || undefined,
status: filters.status || undefined,
});
}, [filters.kind, filters.status, form]);
useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setStores(res.items))
.catch(() => {});
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
}, []);
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
content: `将确认 ${ids.length} T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
@@ -102,9 +138,75 @@ export default function StoreBillsPage() {
});
}
async function openDetail(id: string) {
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
function approveWithdraw(id: string) {
let paymentRef = '';
Modal.confirm({
title: '审核通过并标记已结算?',
content: (
<Input
placeholder="打款凭证号(可选)"
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
),
okText: '通过并结算',
cancelText: '取消',
onOk: async () => {
await request(`/admin/store-withdrawals/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
});
message.success('已通过并标记已结算');
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
},
});
}
function rejectWithdraw(id: string) {
let reason = '';
Modal.confirm({
title: '驳回提现申请?',
content: (
<Input.TextArea
rows={3}
placeholder="请填写驳回理由"
onChange={(e) => {
reason = e.target.value;
}}
/>
),
okText: '确认驳回',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
if (!reason.trim()) {
message.error('请填写驳回理由');
throw new Error('reason required');
}
await request(`/admin/store-withdrawals/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason.trim() }),
});
message.success('已驳回');
setDrawerOpen(false);
reload();
},
});
}
async function openDetail(row: Row) {
const path =
row.kind === 'WITHDRAW' ? `/admin/store-withdrawals/${row.id}` : `/admin/store-bills/${row.id}`;
const d = await request<Record<string, unknown>>(path);
setDetail(d);
setDetailKind(row.kind);
setDrawerOpen(true);
}
@@ -118,88 +220,134 @@ export default function StoreBillsPage() {
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`);
message.success(`已导出 ${result.count} T+1 账单`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0);
const selectedRows = (data?.items ?? []).filter(
(r) =>
selectedKeys.includes(`${r.kind}-${r.id}`) &&
r.kind === 'T1_BILL' &&
r.status === 'UNPAID',
);
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 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: 'kind',
width: 100,
render: (k: StoreSettlementKind) => (
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
),
},
{
title: '单号',
dataIndex: 'billNo',
width: 180,
ellipsis: true,
render: (v, row) => (
<Space>
<span>{v}</span>
{row.overdue ? <Tag color="magenta"></Tag> : null}
</Space>
),
},
{
title: '日期',
dataIndex: 'date',
width: 160,
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
},
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
{
title: '核销金额',
dataIndex: 'redeemAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '结算比例',
dataIndex: 'settlementRate',
width: 90,
render: (v) => `${Math.round(Number(v) * 100)}%`,
title: '笔数',
width: 80,
render: (_, row) =>
row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
},
{
title: '应付金额',
dataIndex: 'payoutAmount',
dataIndex: 'amount',
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>,
width: 100,
render: (s: string, row) => (
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
),
},
{
title: '操作',
width: 160,
width: 200,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
{row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
{row.kind === 'T1_BILL' && row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.amount))}>
</Button>
)}
{row.kind === 'WITHDRAW' && row.status === 'PENDING_REVIEW' && (
<>
<Button type="link" size="small" onClick={() => approveWithdraw(row.id)}>
</Button>
<Button type="link" size="small" danger onClick={() => rejectWithdraw(row.id)}>
</Button>
</>
)}
</Space>
),
},
];
const withdrawDetailItems =
(detail?.items as Array<Record<string, unknown>> | undefined) ?? [];
const storeAccount = detail?.storeAccount as
| {
name?: string;
phone?: string;
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
}
| undefined;
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
8:00 绿
T+1 沿
</Typography.Text>
{overdueSummary && overdueSummary.overdueCount > 0 ? (
<Typography.Text type="danger">
{overdueSummary.overdueCount} {overdueSummary.pendingCount}
</Typography.Text>
) : null}
</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.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.payoutAmount ?? summary.totalAmount ?? 0} prefix="¥" precision={2} />
</Space>
</Card>
)}
@@ -208,16 +356,37 @@ export default function StoreBillsPage() {
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => {
initialValues={{
kind: filters.kind || undefined,
status: filters.status || undefined,
}}
onFinish={(v: {
kind?: string;
storeId?: string;
status?: string;
range?: [Dayjs, Dayjs];
}) => {
setFilters({
kind: v.kind || '',
storeId: v.storeId || '',
status: v.status || '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
});
setPage(1);
setSelectedKeys([]);
}}
>
<Form.Item name="kind" label="类型">
<Select
allowClear
style={{ width: 140 }}
options={Object.entries(STORE_SETTLEMENT_KIND_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item name="storeId" label="门店">
<Select
allowClear
@@ -232,10 +401,16 @@ export default function StoreBillsPage() {
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
options={Object.entries(STORE_SETTLEMENT_STATUS_LABELS).map(([value, label]) => ({
value,
label:
value === 'PAID'
? '已打款/已结算'
: label,
}))}
/>
</Form.Item>
<Form.Item name="range" label="账单日">
<Form.Item name="range" label="日">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
@@ -249,6 +424,7 @@ export default function StoreBillsPage() {
form.resetFields();
setFilters({});
setPage(1);
setSelectedKeys([]);
}}
>
@@ -256,33 +432,36 @@ export default function StoreBillsPage() {
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
T+1 Excel
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedKeys.length}
disabled={!selectedRows.length}
loading={batchLoading}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
onClick={() => confirmPay(selectedRows.map((r) => r.id), selectedAmount)}
>
({selectedKeys.length})
({selectedRows.length})
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
rowKey={(r) => `${r.kind}-${r.id}`}
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
getCheckboxProps: (r) => ({
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
}),
}}
scroll={{ x: 1200 }}
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize,
@@ -295,14 +474,25 @@ export default function StoreBillsPage() {
}}
/>
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && (
<Drawer
title={detailKind === 'WITHDRAW' ? '手动提现明细' : 'T+1 对账单明细'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={detailKind === 'WITHDRAW' ? 640 : 520}
>
{detail && detailKind === 'T1_BILL' && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="账单日">
{String(detail.billDate || '').slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="应付">
¥{Number(detail.payoutAmount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{storeSettlementStatusLabel('T1_BILL', String(detail.status))}
</Descriptions.Item>
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
@@ -318,7 +508,8 @@ export default function StoreBillsPage() {
columns={[
{
title: '核销单号',
render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
render: (_, r) =>
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
},
{
title: '金额',
@@ -334,6 +525,83 @@ export default function StoreBillsPage() {
/>
</>
)}
{detail && detailKind === 'WITHDRAW' && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
<Descriptions.Item label="金额">
¥{Number(detail.amount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{storeSettlementStatusLabel('WITHDRAW', String(detail.status))}
</Descriptions.Item>
<Descriptions.Item label="申请时间">
{detail.appliedAt ? fmtTime(String(detail.appliedAt)) : '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回理由">
{detail.rejectReason ? String(detail.rejectReason) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{storeAccount ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{storeAccount.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{storeAccount.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{storeAccount.bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={withdrawDetailItems}
columns={[
{
title: '核销单号',
render: (_, r) => {
const payout = r.storePayout as
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
| undefined;
return String(payout?.redeemRecord?.redeemNo || '—');
},
},
{
title: '应付',
render: (_, r) => {
const payout = r.storePayout as { payoutAmount?: number } | undefined;
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
},
},
]}
/>
{String(detail.status) === 'PENDING_REVIEW' ? (
<Space style={{ marginTop: 16 }}>
<Button type="primary" onClick={() => approveWithdraw(String(detail.id))}>
</Button>
<Button danger onClick={() => rejectWithdraw(String(detail.id))}>
</Button>
</Space>
) : null}
</>
)}
</Drawer>
</div>
);