feat(settlement): 统一门店账单列表合并 T+1 与手动提现
总部财务以门店账单为唯一入口;新增 store-settlements API,去掉独立提现审菜单并更新企微引导文案。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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: '物流对账' },
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user