webadmin增加财务模块

This commit is contained in:
2026-07-14 15:16:41 +08:00
parent 450dc44d0e
commit d61a43cc8c
15 changed files with 1352 additions and 132 deletions
+7 -3
View File
@@ -28,6 +28,7 @@ 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 PartnerBillsPage from './pages/PartnerBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage';
import TicketsPage from './pages/TicketsPage'; import TicketsPage from './pages/TicketsPage';
import UserLogsPage from './pages/UserLogsPage'; import UserLogsPage from './pages/UserLogsPage';
import HqLogsPage from './pages/HqLogsPage'; import HqLogsPage from './pages/HqLogsPage';
@@ -79,9 +80,12 @@ export default function App() {
<Route path="/redeem-records" element={<RedeemRecordsPage />} /> <Route path="/redeem-records" element={<RedeemRecordsPage />} />
<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="/store-bills" element={<StoreBillsPage />} /> <Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route path="/store-payouts" element={<Navigate to="/store-bills" replace />} /> <Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/partner-bills" element={<PartnerBillsPage />} /> <Route path="/finance/winery-bills" element={<WineryBillsPage />} />
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" element={<Navigate to="/finance/store-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="/logs/users" element={<UserLogsPage />} /> <Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} /> <Route path="/logs/stores" element={<StoreLogsPage />} />
+12 -3
View File
@@ -16,6 +16,7 @@ import {
FileTextOutlined, FileTextOutlined,
LockOutlined, LockOutlined,
SettingOutlined, SettingOutlined,
AccountBookOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { clearAuth, request, type HqProfile } from '../lib/api'; import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
@@ -44,7 +45,6 @@ const MENU_ITEMS: MenuProps['items'] = [
children: [ children: [
{ key: '/stores', label: '门店列表' }, { key: '/stores', label: '门店列表' },
{ key: '/store-accounts', label: '门店账户' }, { key: '/store-accounts', label: '门店账户' },
{ key: '/store-bills', label: '门店账单' },
{ key: '/store-media', label: '门店资源' }, { key: '/store-media', label: '门店资源' },
], ],
}, },
@@ -56,7 +56,16 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/cities', label: '城市' }, { key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' }, { key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' }, { key: '/city-warehouses', label: '仓库' },
{ key: '/partner-bills', label: '合伙人结算' }, ],
},
{
key: 'finance-group',
icon: <AccountBookOutlined />,
label: '财务',
children: [
{ key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' },
], ],
}, },
{ {
@@ -144,7 +153,7 @@ export default function AdminLayout() {
theme="dark" theme="dark"
mode="inline" mode="inline"
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'benefit-group', 'logs-group', 'deliveries-group']} defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
items={MENU_ITEMS} items={MENU_ITEMS}
onClick={({ key }) => { onClick={({ key }) => {
if (key.startsWith('/')) navigate(key); if (key.startsWith('/')) navigate(key);
+10
View File
@@ -57,6 +57,16 @@ export type Paginated<T> = {
total: number; total: number;
page: number; page: number;
pageSize: number; pageSize: number;
summary?: {
count: number;
redeemAmount?: number;
payoutAmount?: number;
orderAmount?: number;
orderCommission?: number;
redeemCommission?: number;
wineryAmount?: number;
totalAmount: number;
};
}; };
export type DashboardStats = { export type DashboardStats = {
+10
View File
@@ -0,0 +1,10 @@
/** Download CSV (UTF-8 BOM) so Excel opens Chinese columns correctly. */
export function downloadExcelCsv(csv: string, filename: string) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
a.click();
URL.revokeObjectURL(url);
}
+259 -58
View File
@@ -1,9 +1,23 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Form, Input, Select, Table, Typography, message, Modal, DatePicker } from 'antd'; import {
Button,
Card,
DatePicker,
Form,
Modal,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import { request } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
@@ -15,34 +29,66 @@ type Row = {
status: string; status: string;
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
partner?: { companyName: string }; partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
};
type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
CONFIRMED: '已确认',
PAID: '已打款',
};
const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default',
CONFIRMED: 'blue',
PAID: 'green',
}; };
export default function PartnerBillsPage() { export default function PartnerBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const [partners, setPartners] = useState<PartnerOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partner-bills', '/admin/partner-bills',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId); if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
return qs; return qs;
}, },
[filters], [filters],
); );
const [genOpen, setGenOpen] = useState(false); const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm(); const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false);
async function generateBill(values: { partnerId: string; month: dayjs.Dayjs }) { useEffect(() => {
await request('/admin/partner-bills/generate', { void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
method: 'POST', .then((res) => setPartners(res.items))
body: JSON.stringify({ .catch(() => {});
partnerId: values.partnerId, }, []);
year: values.month.year(),
month: values.month.month() + 1, async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) {
}), const year = values.month.year();
}); const month = values.month.month() + 1;
message.success('账单已生成'); if (values.all || !values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ year, month }) },
);
message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`);
} else {
await request('/admin/partner-bills/generate', {
method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
});
message.success('账单已生成');
}
setGenOpen(false); setGenOpen(false);
reload(); reload();
} }
@@ -62,61 +108,216 @@ export default function PartnerBillsPage() {
reload(); reload();
} }
async function exportCsv() { async function exportExcel() {
const result = await request<{ csv: string }>('/admin/partner-bills/export'); setExporting(true);
const blob = new Blob([result.csv], { type: 'text/csv;charset=utf-8' }); try {
const url = URL.createObjectURL(blob); const qs = new URLSearchParams();
const a = document.createElement('a'); if (filters.status) qs.set('status', filters.status);
a.href = url; if (filters.partnerId) qs.set('partnerId', filters.partnerId);
a.download = 'partner-bills.csv'; if (filters.year) qs.set('year', filters.year);
a.click(); if (filters.month) qs.set('month', filters.month);
URL.revokeObjectURL(url); const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
} }
const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180 }, { title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
{ title: '合伙人', dataIndex: ['partner', 'companyName'] },
{ title: '订单佣金', dataIndex: 'orderCommission', width: 100, render: (v) => `¥${v}` },
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 100, render: (v) => `¥${v}` },
{ title: '合计', dataIndex: 'totalAmount', width: 100, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}` },
{ {
title: '操作', width: 180, title: '合伙人',
width: 160,
ellipsis: true,
render: (_, r) => partnerName(r),
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`,
},
{
title: '酒单佣金',
dataIndex: 'orderCommission',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '核销佣金',
dataIndex: 'redeemCommission',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '合计应付',
dataIndex: 'totalAmount',
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>,
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<> <Space size={0}>
{row.status === 'DRAFT' && <Button type="link" size="small" onClick={() => confirmBill(row.id)}></Button>} {row.status === 'DRAFT' && (
{row.status === 'CONFIRMED' && <Button type="link" size="small" onClick={() => markPaid(row.id)}></Button>} <Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
</>
</Button>
)}
{row.status === 'CONFIRMED' && (
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button>
)}
</Space>
), ),
}, },
]; ];
return ( return (
<div> <div>
<Typography.Title level={4}>T+30</Typography.Title> <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}> <Typography.Title level={4} style={{ margin: 0 }}>
<Button type="primary" onClick={() => setGenOpen(true)}></Button>
<Button onClick={exportCsv}> CSV</Button> </Typography.Title>
</div> <Typography.Text type="secondary">
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> T+30 × ×
<Form.Item name="status" label="状态"> </Typography.Text>
<Select allowClear style={{ width: 120 }} options={[ </Space>
{ value: 'DRAFT', label: '草稿' },
{ value: 'CONFIRMED', label: '已确认' }, {summary && (
{ value: 'PAID', label: '已打款' }, <Card size="small" style={{ marginBottom: 16 }}>
]} /> <Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic title="酒单佣金合计" value={summary.orderCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="核销佣金合计" value={summary.redeemCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.totalAmount} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { partnerId?: string; status?: string; month?: Dayjs }) => {
setFilters({
partnerId: v.partnerId || '',
status: v.status || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
});
setPage(1);
}}
>
<Form.Item name="partnerId" label="合伙人">
<Select
allowClear
showSearch
placeholder="全部合伙人"
style={{ width: 220 }}
optionFilterProp="label"
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.phone || p.id,
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={() => setGenOpen(true)}>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
</Button>
</Form.Item> </Form.Item>
<Form.Item name="partnerId" label="合伙人ID"><Input allowClear /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> <Table
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}> rowKey="id"
<Form form={genForm} layout="vertical" onFinish={generateBill}> className="admin-table-nowrap"
<Form.Item name="partnerId" label="合伙人 ID" rules={[{ required: true }]}><Input /></Form.Item> loading={loading}
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}><DatePicker picker="month" style={{ width: '100%' }} /></Form.Item> columns={columns}
<Button type="primary" htmlType="submit" block></Button> dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal title="生成合伙人账单(T+30" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form
form={genForm}
layout="vertical"
initialValues={{ month: dayjs().subtract(1, 'month') }}
onFinish={generateBill}
>
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="partnerId" label="指定合伙人(留空则全部主合伙人)">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部主合伙人"
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.phone || p.id,
}))}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form> </Form>
</Modal> </Modal>
</div> </div>
+110 -17
View File
@@ -1,8 +1,24 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Select, Space, Table, Tag, Typography, message } from 'antd'; import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
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 { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
@@ -15,7 +31,7 @@ type Row = {
paidAt?: string; paidAt?: string;
createdAt: string; createdAt: string;
store?: { id: string; name: string; cityName: string; phone?: string }; store?: { id: string; name: string; cityName: string; phone?: string };
redeemRecord?: { redeemNo: string; amount?: number }; redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
}; };
type StoreOption = { id: string; name: string; phone: string }; type StoreOption = { id: string; name: string; phone: string };
@@ -40,12 +56,15 @@ export default function StoreBillsPage() {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
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.dateTo) qs.set('dateTo', filters.dateTo);
return qs; return qs;
}, },
[filters], [filters],
); );
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false);
useEffect(() => { useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -62,7 +81,33 @@ export default function StoreBillsPage() {
reload(); reload();
} }
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(
`/admin/store-payouts/export?${qs}`,
);
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{
title: '核销日期',
width: 110,
render: (_, r) =>
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
},
{ 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 },
@@ -75,10 +120,16 @@ export default function StoreBillsPage() {
}, },
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` }, { title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
{ {
title: '到账金额', title: '核销比例',
dataIndex: 'settlementRate',
width: 90,
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
},
{
title: '应付门店',
dataIndex: 'payoutAmount', dataIndex: 'payoutAmount',
width: 100, width: 100,
render: (v, row) => `¥${v}${row.settlementRate ? ` (${Math.round(row.settlementRate * 100)}%)` : ''}`, render: (v) => `¥${v}`,
}, },
{ {
title: '打款状态', title: '打款状态',
@@ -86,8 +137,7 @@ export default function StoreBillsPage() {
width: 100, width: 100,
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
}, },
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime }, { title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
{ {
title: '操作', title: '操作',
width: 140, width: 140,
@@ -117,17 +167,39 @@ export default function StoreBillsPage() {
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> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
T+1 T+1 = ×
</Typography.Text> </Typography.Text>
</Space> </Space>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="明细笔数" value={summary.count} />
<Statistic title="核销面额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form <Form
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v) => { onFinish={(v: {
setFilters(v); storeId?: string;
status?: string;
dateRange?: [Dayjs, Dayjs];
}) => {
setFilters({
storeId: v.storeId || '',
status: v.status || '',
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
});
setPage(1); setPage(1);
}} }}
> >
@@ -152,20 +224,39 @@ export default function StoreBillsPage() {
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item name="dateRange" label="核销日期">
<Button type="primary" htmlType="submit"></Button> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}></Button> <Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
</Button>
</Form.Item> </Form.Item>
</Form> </Form>
<Table <Table
rowKey="id" rowKey="id"
className="admin-table-nowrap" className="admin-table-nowrap"
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} scroll={{ x: 1300 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -177,15 +268,18 @@ export default function StoreBillsPage() {
}, },
}} }}
/> />
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && ( {detail && (
<Descriptions column={1} bordered size="small"> <Descriptions column={1} bordered size="small">
<Descriptions.Item label="门店">{String((detail.store as { name?: string })?.name ?? '—')}</Descriptions.Item> <Descriptions.Item label="门店">
{String((detail.store as { name?: string })?.name ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="核销单号"> <Descriptions.Item label="核销单号">
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')} {String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item> <Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
<Descriptions.Item label="到账金额">¥{String(detail.payoutAmount)}</Descriptions.Item> <Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
<Descriptions.Item label="结算比例"> <Descriptions.Item label="结算比例">
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'} {detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
</Descriptions.Item> </Descriptions.Item>
@@ -196,7 +290,6 @@ export default function StoreBillsPage() {
<Descriptions.Item label="实际打款"> <Descriptions.Item label="实际打款">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
</Descriptions> </Descriptions>
)} )}
</Drawer> </Drawer>
@@ -0,0 +1,205 @@
import { useState } from 'react';
import {
Button,
Card,
DatePicker,
Form,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
type Row = {
orderId: string;
orderNo: string;
deliveryType: string;
cityName?: string;
receiverCity?: string;
payAmount: number;
wineryRate: number;
wineryAmount: number;
paidAt: string;
productName?: string;
quantity?: number;
};
const DELIVERY_LABELS: Record<string, string> = {
LOCAL: '同城',
CROSS_CITY: '跨城',
};
export default function WineryBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/winery-bills',
() => {
const qs = new URLSearchParams();
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
return qs;
},
[filters],
);
const [exporting, setExporting] = useState(false);
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
const columns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
{
title: '配送类型',
dataIndex: 'deliveryType',
width: 90,
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
},
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
{ title: '数量', dataIndex: 'quantity', width: 70 },
{
title: '酒单实付',
dataIndex: 'payAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '酒厂比例',
dataIndex: 'wineryRate',
width: 90,
render: (v) => `${Math.round(Number(v) * 100)}%`,
},
{
title: '酒厂应付',
dataIndex: 'wineryAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '支付时间',
dataIndex: 'paidAt',
width: 170,
render: (v) => (v ? fmtTime(v) : '—'),
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
T+30 / = × {ratePct}%
</Typography.Text>
</Space>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="酒单数" value={summary.count} />
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
setFilters({
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
deliveryType: v.deliveryType || '',
});
setPage(1);
}}
>
<Form.Item name="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="deliveryType" label="配送类型">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
</Button>
</Form.Item>
</Form>
<Table
rowKey="orderId"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</div>
);
}
+18 -2
View File
@@ -33,10 +33,26 @@ export default defineConfig(async () => ({
framework: 'react', framework: 'react',
compiler: { compiler: {
type: 'vite', type: 'vite',
vitePlugins: [], vitePlugins: [
{
name: 'dukang-mini-user-vite-memory',
config() {
return {
// H5 Vite watch 预构建 Stencil 组件时峰值极易 >8GBnoDiscovery 降低冷启动成本
optimizeDeps: {
noDiscovery: true,
include: ['react', 'react-dom', 'react/jsx-runtime'],
},
build: {
sourcemap: false,
},
};
},
},
],
}, },
cache: { cache: {
enable: false, enable: true,
}, },
mini: { mini: {
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */ /** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
+8 -4
View File
@@ -4,10 +4,11 @@
"private": true, "private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)", "description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": { "scripts": {
"dev": "taro build --type h5 --watch", "dev": "node ../../scripts/dev-mini-user-h5.mjs",
"dev:weapp": "taro build --type weapp --watch --mode development", "dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch",
"build": "taro build --type h5", "dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development",
"build:weapp": "taro build --type weapp", "build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5",
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp",
"lint": "echo ok" "lint": "echo ok"
}, },
"dependencies": { "dependencies": {
@@ -32,6 +33,9 @@
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.24.4",
"@babel/plugin-proposal-decorators": "^7.24.1",
"@babel/plugin-transform-class-properties": "^7.24.1",
"@babel/preset-react": "^7.24.1", "@babel/preset-react": "^7.24.1",
"@tarojs/cli": "4.2.0", "@tarojs/cli": "4.2.0",
"@tarojs/vite-runner": "4.2.0", "@tarojs/vite-runner": "4.2.0",
+44
View File
@@ -23,3 +23,47 @@ export interface PartnerBillDetailDto extends PartnerBillDto {
confirmedAt?: string | null; confirmedAt?: string | null;
paidAt?: string | null; paidAt?: string | null;
} }
/** 门店结算默认比例(核销金额 × 比例) */
export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
export const WINERY_SETTLEMENT_RATE = 0.3;
export type FinanceBillSummary = {
count: number;
redeemAmount?: number;
payoutAmount?: number;
orderAmount?: number;
orderCommission?: number;
redeemCommission?: number;
wineryAmount?: number;
totalAmount: number;
};
export type StoreDailyBillRow = {
billDate: string;
storeId: string;
storeName: string;
storePhone?: string;
cityName?: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
payoutAmount: number;
pendingCount: number;
paidCount: number;
};
export type WineryOrderBillRow = {
orderId: string;
orderNo: string;
deliveryType: string;
cityName?: string;
payAmount: number;
wineryRate: number;
wineryAmount: number;
paidAt: string;
receiverCity?: string;
};
+9
View File
@@ -320,6 +320,15 @@ importers:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
devDependencies: devDependencies:
'@babel/core':
specifier: ^7.24.4
version: 7.29.7
'@babel/plugin-proposal-decorators':
specifier: ^7.24.1
version: 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-class-properties':
specifier: ^7.24.1
version: 7.29.7(@babel/core@7.29.7)
'@babel/preset-react': '@babel/preset-react':
specifier: ^7.24.1 specifier: ^7.24.1
version: 7.29.7(@babel/core@7.29.7) version: 7.29.7(@babel/core@7.29.7)
+167
View File
@@ -0,0 +1,167 @@
/**
* mini-user H5 本地预览:
* Taro Vite `--watch` 在本机易 OOM(峰值 >8GB),改为:
* 1) 先完整 build(约 30s
* 2) 静态托管 dist + /api 代理
* 3) 监听 src 变更后防抖重建
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.resolve(__dirname, '../apps/mini-user');
const DIST = path.resolve(APP_ROOT, 'dist');
const PORT = Number(process.env.PORT || 5177);
const API_ORIGIN = (process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top').replace(/\/$/, '');
const TARO_BIN = path.resolve(APP_ROOT, 'node_modules/@tarojs/cli/bin/taro');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.map': 'application/json',
};
function runBuild() {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
['--max-old-space-size=8192', TARO_BIN, 'build', '--type', 'h5'],
{
cwd: APP_ROOT,
stdio: 'inherit',
env: process.env,
},
);
child.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`taro build failed with code ${code}`));
});
});
}
function sendFile(res, filePath) {
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
fs.createReadStream(filePath).pipe(res);
}
function proxyApi(req, res) {
const target = new URL(req.url || '/', API_ORIGIN);
const lib = target.protocol === 'https:' ? https : http;
const headers = { ...req.headers, host: target.host };
delete headers['accept-encoding'];
const upstream = lib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || undefined,
path: target.pathname + target.search,
method: req.method,
headers,
},
(up) => {
res.writeHead(up.statusCode || 502, up.headers);
up.pipe(res);
},
);
upstream.on('error', (err) => {
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`API proxy error: ${err.message}`);
});
req.pipe(upstream);
}
function startServer() {
const server = http.createServer((req, res) => {
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
if (urlPath.startsWith('/api')) {
proxyApi(req, res);
return;
}
const candidates = [
path.join(DIST, urlPath),
path.join(DIST, urlPath, 'index.html'),
path.join(DIST, 'index.html'),
];
const file = candidates.find((p) => fs.existsSync(p) && fs.statSync(p).isFile());
if (!file) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
return;
}
sendFile(res, file);
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`\n mini-user H5 preview: http://localhost:${PORT}`);
console.log(` API proxy -> ${API_ORIGIN}`);
console.log(' Watching apps/mini-user/src for changes...\n');
});
}
function watchSrc() {
const srcDir = path.join(APP_ROOT, 'src');
let timer = null;
let building = false;
let queued = false;
const schedule = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
if (building) {
queued = true;
return;
}
building = true;
try {
console.log('\n[dev] src changed, rebuilding...');
await runBuild();
console.log('[dev] rebuild done — refresh browser');
} catch (e) {
console.error('[dev] rebuild failed:', e instanceof Error ? e.message : e);
} finally {
building = false;
if (queued) {
queued = false;
schedule();
}
}
}, 800);
};
fs.watch(srcDir, { recursive: true }, (_event, filename) => {
if (!filename) return;
if (/\.(tsx?|jsx?|css|scss|json|png|jpg|svg)$/i.test(filename)) {
schedule();
}
});
}
async function main() {
if (!fs.existsSync(TARO_BIN)) {
throw new Error(`Taro CLI not found: ${TARO_BIN}`);
}
console.log('[dev] initial H5 build...');
await runBuild();
startServer();
watchSrc();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -59,9 +59,49 @@ export class AdminStorePayoutController {
pageSize: query.pageSize ? Number(query.pageSize) : 20, pageSize: query.pageSize ? Number(query.pageSize) : 20,
status: query.status, status: query.status,
storeId: query.storeId, storeId: query.storeId,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
}); });
} }
@Get('daily')
daily(@Query() query: Record<string, string>) {
return this.settlementService.listAdminStoreDailyBills({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 50,
status: query.status,
storeId: query.storeId,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
});
}
@Get('export')
export(@Query() query: Record<string, string>) {
return this.settlementService.exportAdminStorePayouts({
status: query.status,
storeId: query.storeId,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
});
}
@Post('batch-confirm')
@HqOperation({
action: HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM,
refType: 'STORE_PAYOUT',
batch: true,
includeBody: true,
})
batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) {
return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body);
}
@Post('scan-due')
scanDue() {
return this.settlementService.scanDueStorePayouts();
}
@Get(':id') @Get(':id')
detail(@Param('id') id: string) { detail(@Param('id') id: string) {
return this.settlementService.getAdminStorePayout(BigInt(id)); return this.settlementService.getAdminStorePayout(BigInt(id));
@@ -77,22 +117,6 @@ export class AdminStorePayoutController {
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string; batchNo?: string; remark?: string }) { confirm(@Param('id') id: string, @Body() body: { paymentRef?: string; batchNo?: string; remark?: string }) {
return this.settlementService.confirmStorePayout(BigInt(id), body); return this.settlementService.confirmStorePayout(BigInt(id), body);
} }
@Post('batch-confirm')
@HqOperation({
action: HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM,
refType: 'STORE_PAYOUT',
batch: true,
includeBody: true,
})
batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) {
return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body);
}
@Post('scan-due')
scanDue() {
return this.settlementService.scanDueStorePayouts();
}
} }
@Controller('admin/partner-bills') @Controller('admin/partner-bills')
@@ -111,6 +135,17 @@ export class AdminPartnerBillController {
return this.settlementService.generatePartnerBill(body); return this.settlementService.generatePartnerBill(body);
} }
@Post('generate-all')
@HqOperation({
action: HqOperationAction.PARTNER_BILL_GENERATE,
refType: 'PARTNER_BILL',
batch: true,
includeBody: true,
})
generateAll(@Body() body: { year: number; month: number }) {
return this.settlementService.generateAllPartnerBills(body);
}
@Get() @Get()
list(@Query() query: Record<string, string>) { list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminPartnerBills({ return this.settlementService.listAdminPartnerBills({
@@ -118,6 +153,8 @@ export class AdminPartnerBillController {
pageSize: query.pageSize ? Number(query.pageSize) : 20, pageSize: query.pageSize ? Number(query.pageSize) : 20,
status: query.status, status: query.status,
partnerId: query.partnerId, partnerId: query.partnerId,
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
}); });
} }
@@ -126,6 +163,8 @@ export class AdminPartnerBillController {
return this.settlementService.exportPartnerBills({ return this.settlementService.exportPartnerBills({
partnerId: query.partnerId, partnerId: query.partnerId,
status: query.status, status: query.status,
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
}); });
} }
@@ -156,6 +195,32 @@ export class AdminPartnerBillController {
} }
} }
@Controller('admin/winery-bills')
@UseGuards(HqAuthGuard)
export class AdminWineryBillController {
constructor(private readonly settlementService: SettlementService) {}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminWineryBills({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
deliveryType: query.deliveryType,
});
}
@Get('export')
export(@Query() query: Record<string, string>) {
return this.settlementService.exportAdminWineryBills({
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
deliveryType: query.deliveryType,
});
}
}
@Controller('partner/me') @Controller('partner/me')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class PartnerMeController { export class PartnerMeController {
@@ -6,6 +6,7 @@ import { SettlementService } from './settlement.service';
import { import {
AdminPartnerBillController, AdminPartnerBillController,
AdminStorePayoutController, AdminStorePayoutController,
AdminWineryBillController,
PartnerMeController, PartnerMeController,
SettlementController, SettlementController,
ShopPayoutController, ShopPayoutController,
@@ -19,6 +20,7 @@ import {
ShopPayoutController, ShopPayoutController,
AdminStorePayoutController, AdminStorePayoutController,
AdminPartnerBillController, AdminPartnerBillController,
AdminWineryBillController,
], ],
providers: [SettlementService], providers: [SettlementService],
exports: [SettlementService], exports: [SettlementService],
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service'; import { AnalyticsService } from '../analytics/analytics.service';
@@ -9,6 +10,11 @@ function generateBillNo() {
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
} }
function csvEscape(value: string) {
if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
return value;
}
@Injectable() @Injectable()
export class SettlementService { export class SettlementService {
constructor( constructor(
@@ -77,27 +83,192 @@ export class SettlementService {
pageSize?: number; pageSize?: number;
status?: string; status?: string;
storeId?: string; storeId?: string;
dateFrom?: string;
dateTo?: string;
}) { }) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
const where: Prisma.StorePayoutWhereInput = {}; const where = this.buildStorePayoutWhere(query);
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
if (query.storeId) where.storeId = BigInt(query.storeId);
const [items, total] = await Promise.all([ const [items, total, aggregates] = await Promise.all([
this.prisma.storePayout.findMany({ this.prisma.storePayout.findMany({
where, where,
orderBy: { expectedPayAt: 'asc' }, orderBy: [{ createdAt: 'desc' }, { expectedPayAt: 'asc' }],
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
include: { include: {
store: { select: { id: true, name: true, cityName: true, phone: true } }, store: { select: { id: true, name: true, cityName: true, phone: true } },
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } }, redeemRecord: { select: { redeemNo: true, amount: true, userId: true, createdAt: true } },
}, },
}), }),
this.prisma.storePayout.count({ where }), this.prisma.storePayout.count({ where }),
this.prisma.storePayout.aggregate({
where,
_sum: { redeemAmount: true, payoutAmount: true },
_count: true,
}),
]); ]);
return serializeBigInt({ items, total, page, pageSize });
const summary = {
count: aggregates._count,
redeemAmount: Number(aggregates._sum.redeemAmount ?? 0),
payoutAmount: Number(aggregates._sum.payoutAmount ?? 0),
totalAmount: Number(aggregates._sum.payoutAmount ?? 0),
};
return serializeBigInt({ items, total, page, pageSize, summary });
}
/** 门店账单按日汇总(T+1:按核销日归集) */
async listAdminStoreDailyBills(query: {
page?: number;
pageSize?: number;
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 50;
const where = this.buildStorePayoutWhere(query);
const payouts = await this.prisma.storePayout.findMany({
where,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
redeemRecord: { select: { createdAt: true } },
},
orderBy: { createdAt: 'desc' },
});
type DailyAgg = {
billDate: string;
storeId: string;
storeName: string;
storePhone?: string;
cityName?: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
payoutAmount: number;
pendingCount: number;
paidCount: number;
};
const map = new Map<string, DailyAgg>();
for (const p of payouts) {
const billDate = (p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10);
const storeId = p.storeId.toString();
const key = `${billDate}:${storeId}`;
const existing = map.get(key);
if (!existing) {
map.set(key, {
billDate,
storeId,
storeName: p.store?.name ?? '—',
storePhone: p.store?.phone ?? undefined,
cityName: p.store?.cityName ?? undefined,
redeemCount: 1,
redeemAmount: Number(p.redeemAmount),
settlementRate: Number(p.settlementRate),
payoutAmount: Number(p.payoutAmount),
pendingCount: p.status === 'PENDING' ? 1 : 0,
paidCount: p.status === 'PAID' ? 1 : 0,
});
} else {
existing.redeemCount += 1;
existing.redeemAmount += Number(p.redeemAmount);
existing.payoutAmount += Number(p.payoutAmount);
if (p.status === 'PENDING') existing.pendingCount += 1;
if (p.status === 'PAID') existing.paidCount += 1;
}
}
const all = Array.from(map.values()).sort((a, b) => {
if (a.billDate === b.billDate) return a.storeName.localeCompare(b.storeName, 'zh-CN');
return b.billDate.localeCompare(a.billDate);
});
const summary = {
count: all.length,
redeemAmount: Math.round(all.reduce((s, r) => s + r.redeemAmount, 0) * 100) / 100,
payoutAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100,
totalAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100,
};
const start = (page - 1) * pageSize;
const items = all.slice(start, start + pageSize).map((row) => ({
...row,
redeemAmount: Math.round(row.redeemAmount * 100) / 100,
payoutAmount: Math.round(row.payoutAmount * 100) / 100,
}));
return { items, total: all.length, page, pageSize, summary };
}
async exportAdminStorePayouts(query: {
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const where = this.buildStorePayoutWhere(query);
const payouts = await this.prisma.storePayout.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
store: { select: { name: true, phone: true, cityName: true } },
redeemRecord: { select: { redeemNo: true, createdAt: true } },
},
});
const header = [
'核销日期',
'门店',
'登录手机',
'城市',
'核销单号',
'核销面额',
'结算比例',
'应付金额',
'状态',
'预计打款',
'实际打款',
].join(',');
const rows = payouts.map((p) =>
[
(p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10),
csvEscape(p.store?.name ?? ''),
csvEscape(p.store?.phone ?? ''),
csvEscape(p.store?.cityName ?? ''),
csvEscape(p.redeemRecord?.redeemNo ?? ''),
Number(p.redeemAmount),
`${Math.round(Number(p.settlementRate) * 100)}%`,
Number(p.payoutAmount),
p.status,
p.expectedPayAt.toISOString().slice(0, 10),
p.paidAt ? p.paidAt.toISOString().slice(0, 10) : '',
].join(','),
);
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: payouts.length };
}
private buildStorePayoutWhere(query: {
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}): Prisma.StorePayoutWhereInput {
const where: Prisma.StorePayoutWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.dateFrom || query.dateTo) {
const createdAt: Prisma.DateTimeFilter = {};
if (query.dateFrom) createdAt.gte = new Date(`${query.dateFrom}T00:00:00.000`);
if (query.dateTo) createdAt.lte = new Date(`${query.dateTo}T23:59:59.999`);
where.createdAt = createdAt;
}
return where;
} }
async getAdminStorePayout(id: bigint) { async getAdminStorePayout(id: bigint) {
@@ -172,24 +343,95 @@ export class SettlementService {
pageSize?: number; pageSize?: number;
status?: string; status?: string;
partnerId?: string; partnerId?: string;
year?: number;
month?: number;
}) { }) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
const where = this.buildPartnerBillWhere(query);
const [rawItems, total, aggregates] = await Promise.all([
this.prisma.partnerBill.findMany({
where,
orderBy: { periodStart: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partnerAccount: {
select: { id: true, companyName: true, name: true, phone: true },
},
},
}),
this.prisma.partnerBill.count({ where }),
this.prisma.partnerBill.aggregate({
where,
_sum: { orderCommission: true, redeemCommission: true, totalAmount: true },
_count: true,
}),
]);
const items = rawItems.map((b) => ({
...b,
partner: b.partnerAccount,
}));
const summary = {
count: aggregates._count,
orderCommission: Number(aggregates._sum.orderCommission ?? 0),
redeemCommission: Number(aggregates._sum.redeemCommission ?? 0),
totalAmount: Number(aggregates._sum.totalAmount ?? 0),
};
return serializeBigInt({ items, total, page, pageSize, summary });
}
private buildPartnerBillWhere(query: {
status?: string;
partnerId?: string;
year?: number;
month?: number;
}): Prisma.PartnerBillWhereInput {
const where: Prisma.PartnerBillWhereInput = {}; const where: Prisma.PartnerBillWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals']; if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.year && query.month) {
const periodStart = new Date(query.year, query.month - 1, 1);
const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999);
where.periodStart = { gte: periodStart, lte: periodEnd };
}
return where;
}
const [items, total] = await Promise.all([ /** 为所有主合伙人生成指定自然月账单 */
this.prisma.partnerBill.findMany({ async generateAllPartnerBills(body: { year: number; month: number }) {
where, const partners = await this.prisma.partnerAccount.findMany({
orderBy: { createdAt: 'desc' }, where: { isPrimary: 1, status: 'ACTIVE' },
skip: (page - 1) * pageSize, select: { id: true, companyName: true },
take: pageSize, });
include: { partnerAccount: { select: { companyName: true } } }, const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = [];
}), for (const p of partners) {
this.prisma.partnerBill.count({ where }), try {
]); await this.generatePartnerBill({
return serializeBigInt({ items, total, page, pageSize }); partnerId: p.id.toString(),
year: body.year,
month: body.month,
});
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true });
} catch (e) {
results.push({
partnerId: p.id.toString(),
companyName: p.companyName ?? '',
ok: false,
message: e instanceof Error ? e.message : '失败',
});
}
}
return {
total: partners.length,
success: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
results,
};
} }
async getAdminPartnerBill(id: bigint) { async getAdminPartnerBill(id: bigint) {
@@ -310,31 +552,170 @@ export class SettlementService {
return serializeBigInt(updated); return serializeBigInt(updated);
} }
async exportPartnerBills(query: { partnerId?: string; status?: string }) { async exportPartnerBills(query: {
const where: Prisma.PartnerBillWhereInput = {}; partnerId?: string;
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); status?: string;
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals']; year?: number;
month?: number;
}) {
const where = this.buildPartnerBillWhere(query);
const bills = await this.prisma.partnerBill.findMany({ const bills = await this.prisma.partnerBill.findMany({
where, where,
include: { partnerAccount: { select: { companyName: true } } }, include: { partnerAccount: { select: { companyName: true, phone: true } } },
orderBy: { createdAt: 'desc' }, orderBy: { periodStart: 'desc' },
}); });
const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status'; const header = [
'账单号',
'合伙人',
'登录手机',
'账期起',
'账期止',
'酒单佣金',
'核销佣金',
'合计应付',
'状态',
'确认时间',
'打款时间',
].join(',');
const rows = bills.map((b) => const rows = bills.map((b) =>
[ [
b.billNo, csvEscape(b.billNo),
b.partnerAccount.companyName, csvEscape(b.partnerAccount.companyName ?? ''),
csvEscape(b.partnerAccount.phone ?? ''),
b.periodStart.toISOString().slice(0, 10), b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10), b.periodEnd.toISOString().slice(0, 10),
Number(b.orderCommission), Number(b.orderCommission),
Number(b.redeemCommission), Number(b.redeemCommission),
Number(b.totalAmount), Number(b.totalAmount),
b.status, b.status,
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
].join(','), ].join(','),
); );
return { csv: [header, ...rows].join('\n'), count: bills.length }; return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
}
/** 酒厂账单:T+30,按自然月列出已付酒单,应付 = 实付 × WINERY_SETTLEMENT_RATE */
async listAdminWineryBills(query: {
page?: number;
pageSize?: number;
year?: number;
month?: number;
deliveryType?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildWineryOrderWhere(query);
const [orders, total, aggregates] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { paidAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { city: { select: { name: true } } },
}),
this.prisma.order.count({ where }),
this.prisma.order.aggregate({
where,
_sum: { payAmount: true },
_count: true,
}),
]);
const rate = WINERY_SETTLEMENT_RATE;
const items = orders.map((o) => {
const payAmount = Number(o.payAmount);
const wineryAmount = Math.round(payAmount * rate * 100) / 100;
return {
orderId: o.id.toString(),
orderNo: o.orderNo,
deliveryType: o.deliveryType,
cityName: o.city?.name,
payAmount,
wineryRate: rate,
wineryAmount,
paidAt: o.paidAt?.toISOString() ?? '',
receiverCity: o.receiverCity,
productName: o.productName,
quantity: o.quantity,
};
});
const orderAmount = Number(aggregates._sum.payAmount ?? 0);
const summary = {
count: aggregates._count,
orderAmount,
wineryAmount: Math.round(orderAmount * rate * 100) / 100,
totalAmount: Math.round(orderAmount * rate * 100) / 100,
};
return { items, total, page, pageSize, summary };
}
async exportAdminWineryBills(query: {
year?: number;
month?: number;
deliveryType?: string;
}) {
const where = this.buildWineryOrderWhere(query);
const orders = await this.prisma.order.findMany({
where,
orderBy: { paidAt: 'desc' },
include: { city: { select: { name: true } } },
});
const rate = WINERY_SETTLEMENT_RATE;
const header = [
'订单号',
'配送类型',
'开城城市',
'收货城市',
'商品',
'数量',
'实付金额',
'酒厂比例',
'酒厂应付',
'支付时间',
].join(',');
const rows = orders.map((o) => {
const payAmount = Number(o.payAmount);
return [
csvEscape(o.orderNo),
o.deliveryType === 'LOCAL' ? '同城' : o.deliveryType === 'CROSS_CITY' ? '跨城' : o.deliveryType,
csvEscape(o.city?.name ?? ''),
csvEscape(o.receiverCity ?? ''),
csvEscape(o.productName ?? ''),
o.quantity,
payAmount,
`${Math.round(rate * 100)}%`,
Math.round(payAmount * rate * 100) / 100,
o.paidAt ? o.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '',
].join(',');
});
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: orders.length };
}
private buildWineryOrderWhere(query: {
year?: number;
month?: number;
deliveryType?: string;
}): Prisma.OrderWhereInput {
const where: Prisma.OrderWhereInput = {
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
};
if (query.deliveryType === 'LOCAL' || query.deliveryType === 'CROSS_CITY') {
where.deliveryType = query.deliveryType;
}
if (query.year && query.month) {
const start = new Date(query.year, query.month - 1, 1);
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);
where.paidAt = { gte: start, lte: end };
} else {
where.paidAt = { not: null };
}
return where;
} }
async scanDueStorePayouts() { async scanDueStorePayouts() {