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 ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
import ResourcesPage from './pages/ResourcesPage'; import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage'; import StoreBillsPage from './pages/StoreBillsPage';
import StoreWithdrawalsPage from './pages/StoreWithdrawalsPage';
import PartnerBillsPage from './pages/PartnerBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage'; import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage'; import LogisticsBillsPage from './pages/LogisticsBillsPage';
@@ -97,13 +96,19 @@ export default function App() {
<Route path="/redeem-pending" element={<PendingRedeemPage />} /> <Route path="/redeem-pending" element={<PendingRedeemPage />} />
<Route path="/redeem/debug" element={<RedeemDebugPage />} /> <Route path="/redeem/debug" element={<RedeemDebugPage />} />
<Route path="/finance/store-bills" element={<StoreBillsPage />} /> <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/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} /> <Route path="/finance/winery-bills" element={<WineryBillsPage />} />
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} /> <Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} /> <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-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="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} /> <Route path="/tickets" element={<TicketsPage />} />
<Route path="/tickets/support" element={<SupportTicketsPage />} /> <Route path="/tickets/support" element={<SupportTicketsPage />} />
@@ -74,7 +74,6 @@ const MENU_ITEMS: MenuProps['items'] = [
label: '财务', label: '财务',
children: [ children: [
{ key: '/finance/store-bills', label: '门店账单' }, { key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/store-withdrawals', label: '门店提现审' },
{ key: '/finance/partner-bills', label: '合伙人账单' }, { key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' }, { key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' }, { key: '/finance/logistics-bills', label: '物流对账' },
+1 -1
View File
@@ -721,7 +721,7 @@ export default function DashboardPage() {
<Card <Card
loading={loading} loading={loading}
title="待审门店提现" title="待审门店提现"
extra={<Link to="/finance/store-withdrawals"></Link>} extra={<Link to="/finance/store-bills?kind=WITHDRAW"></Link>}
> >
<Statistic <Statistic
value={stats?.pendingStoreWithdrawals ?? 0} value={stats?.pendingStoreWithdrawals ?? 0}
+332 -64
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { import {
Button, Button,
Card, Card,
@@ -6,6 +7,7 @@ import {
Descriptions, Descriptions,
Drawer, Drawer,
Form, Form,
Input,
Modal, Modal,
Select, Select,
Space, Space,
@@ -17,44 +19,61 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs'; 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 { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
kind: StoreSettlementKind;
id: string; id: string;
billNo: string; billNo: string;
billDate: string; amount: number;
redeemCount: number; status: StoreSettlementStatus;
redeemAmount: number; date: string;
settlementRate: number; overdue?: boolean;
payoutAmount: number; redeemCount?: number | null;
status: string; redeemAmount?: number | null;
paidAt?: string | null; settlementRate?: number | null;
store?: { id: string; name: string; cityName: string; phone?: string }; payoutCount?: number | null;
store?: { id: string; name: string; cityName: string; phone?: string | null };
}; };
type StoreOption = { id: string; name: string; phone: string }; type StoreOption = { id: string; name: string; phone: string };
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款',
PAID: '已打款',
};
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red', UNPAID: 'red',
PAID: 'green', PAID: 'green',
PENDING_REVIEW: 'orange',
REJECTED: 'red',
};
const KIND_COLORS: Record<StoreSettlementKind, string> = {
T1_BILL: 'blue',
WITHDRAW: 'purple',
}; };
export default function StoreBillsPage() { export default function StoreBillsPage() {
const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const [form] = Form.useForm(); 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 [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-bills', '/admin/store-settlements',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.kind) qs.set('kind', filters.kind);
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
@@ -64,21 +83,38 @@ export default function StoreBillsPage() {
[filters], [filters],
); );
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [detailKind, setDetailKind] = useState<StoreSettlementKind | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]); const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [batchLoading, setBatchLoading] = useState(false); 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(() => { useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setStores(res.items)) .then((res) => setStores(res.items))
.catch(() => {}); .catch(() => {});
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
}, []); }, []);
function confirmPay(ids: string[], amountHint?: number) { function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({ Modal.confirm({
title: '确认打款?', title: '确认打款?',
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`, content: `将确认 ${ids.length} T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
okText: '确认打款', okText: '确认打款',
cancelText: '取消', cancelText: '取消',
onOk: async () => { onOk: async () => {
@@ -102,9 +138,75 @@ export default function StoreBillsPage() {
}); });
} }
async function openDetail(id: string) { function approveWithdraw(id: string) {
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`); 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); setDetail(d);
setDetailKind(row.kind);
setDrawerOpen(true); setDrawerOpen(true);
} }
@@ -118,88 +220,134 @@ export default function StoreBillsPage() {
if (filters.dateTo) qs.set('dateTo', filters.dateTo); if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`); const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`); downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`); message.success(`已导出 ${result.count} T+1 账单`);
} finally { } finally {
setExporting(false); setExporting(false);
} }
} }
const summary = data?.summary; const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); const selectedRows = (data?.items ?? []).filter(
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0); (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> = [ const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{ {
title: '账单日', title: '类型',
dataIndex: 'billDate', dataIndex: 'kind',
width: 110, width: 100,
render: (v) => String(v || '').slice(0, 10), 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', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, { title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
{ {
title: '核销金额', title: '笔数',
dataIndex: 'redeemAmount', width: 80,
width: 110, render: (_, row) =>
render: (v) => `¥${Number(v).toFixed(2)}`, row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
},
{
title: '结算比例',
dataIndex: 'settlementRate',
width: 90,
render: (v) => `${Math.round(Number(v) * 100)}%`,
}, },
{ {
title: '应付金额', title: '应付金额',
dataIndex: 'payoutAmount', dataIndex: 'amount',
width: 110, width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 90, width: 100,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>, render: (s: string, row) => (
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
),
}, },
{ {
title: '操作', title: '操作',
width: 160, width: 200,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}> <Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button> </Button>
{row.status === 'UNPAID' && ( {row.kind === 'T1_BILL' && row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}> <Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.amount))}>
</Button> </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> </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 ( return (
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
8:00 绿 T+1 沿
</Typography.Text> </Typography.Text>
{overdueSummary && overdueSummary.overdueCount > 0 ? (
<Typography.Text type="danger">
{overdueSummary.overdueCount} {overdueSummary.pendingCount}
</Typography.Text>
) : null}
</Space> </Space>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap> <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.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> </Space>
</Card> </Card>
)} )}
@@ -208,16 +356,37 @@ export default function StoreBillsPage() {
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} 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({ setFilters({
kind: v.kind || '',
storeId: v.storeId || '', storeId: v.storeId || '',
status: v.status || '', status: v.status || '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '', dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '', dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
}); });
setPage(1); 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="门店"> <Form.Item name="storeId" label="门店">
<Select <Select
allowClear allowClear
@@ -232,10 +401,16 @@ export default function StoreBillsPage() {
<Select <Select
allowClear allowClear
style={{ width: 120 }} 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>
<Form.Item name="range" label="账单日"> <Form.Item name="range" label="日">
<DatePicker.RangePicker /> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -249,6 +424,7 @@ export default function StoreBillsPage() {
form.resetFields(); form.resetFields();
setFilters({}); setFilters({});
setPage(1); setPage(1);
setSelectedKeys([]);
}} }}
> >
@@ -256,33 +432,36 @@ export default function StoreBillsPage() {
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}> <Button loading={exporting} onClick={() => void exportExcel()}>
Excel T+1 Excel
</Button> </Button>
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"
disabled={!selectedKeys.length} disabled={!selectedRows.length}
loading={batchLoading} loading={batchLoading}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)} onClick={() => confirmPay(selectedRows.map((r) => r.id), selectedAmount)}
> >
({selectedKeys.length}) ({selectedRows.length})
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
<Table <Table
rowKey="id" rowKey={(r) => `${r.kind}-${r.id}`}
className="admin-table-nowrap" className="admin-table-nowrap"
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedKeys, selectedRowKeys: selectedKeys,
onChange: setSelectedKeys, 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={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -295,14 +474,25 @@ export default function StoreBillsPage() {
}} }}
/> />
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}> <Drawer
{detail && ( 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 column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item> <Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item> <Descriptions.Item label="账单日">
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item> {String(detail.billDate || '').slice(0, 10)}
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item> </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="打款时间"> <Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item> </Descriptions.Item>
@@ -318,7 +508,8 @@ export default function StoreBillsPage() {
columns={[ columns={[
{ {
title: '核销单号', title: '核销单号',
render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'), render: (_, r) =>
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
}, },
{ {
title: '金额', 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> </Drawer>
</div> </div>
); );
+51
View File
@@ -11,6 +11,44 @@ export const STORE_WITHDRAW_STATUS_LABELS: Record<StoreWithdrawStatus, string> =
PAID: '已结算', PAID: '已结算',
}; };
/** 总部「门店账单」统一列表:T+1 出账 / 手动提现 */
export type StoreSettlementKind = 'T1_BILL' | 'WITHDRAW';
export const STORE_SETTLEMENT_KIND_LABELS: Record<StoreSettlementKind, string> = {
T1_BILL: 'T+1出账',
WITHDRAW: '手动提现',
};
export type StoreSettlementStatus = FinancePayStatus | StoreWithdrawStatus;
export const STORE_SETTLEMENT_STATUS_LABELS: Record<StoreSettlementStatus, string> = {
UNPAID: '未打款',
PAID: '已打款',
PENDING_REVIEW: '待审核',
REJECTED: '已驳回',
};
export interface StoreSettlementRowDto {
kind: StoreSettlementKind;
id: string;
billNo: string;
storeId: 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;
};
}
export interface StoreWithdrawBankAccountDto { export interface StoreWithdrawBankAccountDto {
bankAccountName?: string | null; bankAccountName?: string | null;
bankAccountNo?: string | null; bankAccountNo?: string | null;
@@ -73,6 +111,19 @@ export const FINANCE_PAY_STATUS_LABELS: Record<FinancePayStatus, string> = {
PAID: '已打款', PAID: '已打款',
}; };
export function storeSettlementStatusLabel(
kind: StoreSettlementKind,
status: string,
): string {
if (kind === 'WITHDRAW' && status in STORE_WITHDRAW_STATUS_LABELS) {
return STORE_WITHDRAW_STATUS_LABELS[status as StoreWithdrawStatus];
}
if (status in FINANCE_PAY_STATUS_LABELS) {
return FINANCE_PAY_STATUS_LABELS[status as FinancePayStatus];
}
return STORE_SETTLEMENT_STATUS_LABELS[status as StoreSettlementStatus] ?? status;
}
export interface PartnerBillDto { export interface PartnerBillDto {
id: string; id: string;
billNo: string; billNo: string;
@@ -224,6 +224,25 @@ export class AdminStorePayoutController {
} }
} }
@Controller('admin/store-settlements')
@UseGuards(HqAuthGuard)
export class AdminStoreSettlementController {
constructor(private readonly settlementService: SettlementService) {}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminStoreSettlements({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
kind: query.kind,
status: query.status,
storeId: query.storeId,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
});
}
}
@Controller('admin/store-bills') @Controller('admin/store-bills')
@UseGuards(HqAuthGuard) @UseGuards(HqAuthGuard)
export class AdminStoreBillController { export class AdminStoreBillController {
@@ -9,6 +9,7 @@ import {
AdminPartnerBillController, AdminPartnerBillController,
AdminStoreBillController, AdminStoreBillController,
AdminStorePayoutController, AdminStorePayoutController,
AdminStoreSettlementController,
AdminStoreWithdrawController, AdminStoreWithdrawController,
AdminWineryBillController, AdminWineryBillController,
PartnerMeController, PartnerMeController,
@@ -26,6 +27,7 @@ import {
ShopWithdrawController, ShopWithdrawController,
AdminStoreWithdrawController, AdminStoreWithdrawController,
AdminStorePayoutController, AdminStorePayoutController,
AdminStoreSettlementController,
AdminStoreBillController, AdminStoreBillController,
AdminPartnerBillController, AdminPartnerBillController,
AdminWineryBillController, AdminWineryBillController,
@@ -339,7 +339,7 @@ export class SettlementService {
`单号:${created.withdrawNo}`, `单号:${created.withdrawNo}`,
`金额:¥${Number(created.amount).toFixed(2)}`, `金额:¥${Number(created.amount).toFixed(2)}`,
`明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`, `明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`,
'请尽快在 HQ「财务 → 门店提现」处理。', '请尽快在 HQ「财务 → 门店账单(手动提现」处理。',
].join('\n'), ].join('\n'),
dedupeKey: `store_withdraw_applied|${created.id.toString()}`, dedupeKey: `store_withdraw_applied|${created.id.toString()}`,
dedupeTtlSec: 3600, dedupeTtlSec: 3600,
@@ -594,7 +594,7 @@ export class SettlementService {
detail: [ detail: [
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`, `待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
...lines, ...lines,
'请尽快在 HQ「财务 → 门店提现」处理。', '请尽快在 HQ「财务 → 门店账单(手动提现」处理。',
].join('\n'), ].join('\n'),
dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`, dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
dedupeTtlSec: 6 * 3600, dedupeTtlSec: 6 * 3600,
@@ -857,6 +857,152 @@ export class SettlementService {
return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length }; return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length };
} }
/**
* 总部「门店账单」统一列表:T+1 StoreBill + 手动提现 StoreWithdrawRequest
* 中小数据量内存合并后分页。
*/
async listAdminStoreSettlements(query: {
page?: number;
pageSize?: number;
kind?: string;
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const kind = query.kind === 'T1_BILL' || query.kind === 'WITHDRAW' ? query.kind : undefined;
const status = query.status?.trim() || undefined;
const includeBills =
!kind || kind === 'T1_BILL'
? !status || status === 'UNPAID' || status === 'PAID'
: false;
const includeWithdraws =
!kind || kind === 'WITHDRAW'
? !status ||
status === 'PENDING_REVIEW' ||
status === 'REJECTED' ||
status === 'PAID'
: false;
const billWhere = includeBills
? this.buildStoreBillWhere({
status: status === 'UNPAID' || status === 'PAID' ? status : undefined,
storeId: query.storeId,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
})
: null;
const withdrawWhere: Prisma.StoreWithdrawRequestWhereInput | null = includeWithdraws
? (() => {
const where: Prisma.StoreWithdrawRequestWhereInput = {};
if (status === 'PENDING_REVIEW' || status === 'REJECTED' || status === 'PAID') {
where.status = status;
}
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.dateFrom || query.dateTo) {
where.appliedAt = {};
if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom);
if (query.dateTo) {
const end = new Date(query.dateTo);
end.setHours(23, 59, 59, 999);
where.appliedAt.lte = end;
}
}
return where;
})()
: null;
const bills = billWhere
? await this.prisma.storeBill.findMany({
where: billWhere,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
},
})
: [];
const withdraws = withdrawWhere
? await this.prisma.storeWithdrawRequest.findMany({
where: withdrawWhere,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
},
})
: [];
const now = new Date();
type UnifiedRow = {
kind: 'T1_BILL' | 'WITHDRAW';
id: bigint;
billNo: string;
storeId: bigint;
amount: number;
status: string;
date: Date;
overdue?: boolean;
redeemCount?: number;
redeemAmount?: number;
settlementRate?: number;
payoutCount?: number;
store?: { id: bigint; name: string; cityName: string; phone: string | null };
};
const rows: UnifiedRow[] = [
...bills.map((b) => ({
kind: 'T1_BILL' as const,
id: b.id,
billNo: b.billNo,
storeId: b.storeId,
amount: Number(b.payoutAmount),
status: b.status,
date: b.billDate,
redeemCount: b.redeemCount,
redeemAmount: Number(b.redeemAmount),
settlementRate: Number(b.settlementRate),
store: b.store,
})),
...withdraws.map((w) => ({
kind: 'WITHDRAW' as const,
id: w.id,
billNo: w.withdrawNo,
storeId: w.storeId,
amount: Number(w.amount),
status: w.status,
date: w.appliedAt,
overdue: w.status === 'PENDING_REVIEW' ? isWithdrawOverdue(w.appliedAt, now) : false,
payoutCount: w.payoutCount,
store: w.store,
})),
];
rows.sort((a, b) => {
const dt = b.date.getTime() - a.date.getTime();
if (dt !== 0) return dt;
return Number(b.id - a.id);
});
const total = rows.length;
const slice = rows.slice((page - 1) * pageSize, page * pageSize);
const redeemAmount = bills.reduce((s, b) => s + Number(b.redeemAmount), 0);
const payoutAmount = rows.reduce((s, r) => s + r.amount, 0);
return serializeBigInt({
items: slice,
total,
page,
pageSize,
summary: {
count: total,
redeemAmount,
payoutAmount,
totalAmount: payoutAmount,
},
});
}
async listAdminStoreBills(query: { async listAdminStoreBills(query: {
page?: number; page?: number;
pageSize?: number; pageSize?: number;