feat: 迭代

This commit is contained in:
2026-05-26 21:27:48 +08:00
parent 4c7a1e06a8
commit d1147713f8
43 changed files with 1137 additions and 883 deletions
+3 -1
View File
@@ -16,6 +16,7 @@ import FinanceSettlementDetail from '@/pages/finance/SettlementDetail';
import FinanceWithdrawals from '@/pages/finance/Withdrawals';
import FinanceWallet from '@/pages/finance/Wallet';
import FinanceTransactions from '@/pages/finance/Transactions';
import FinancePendingSettlement from '@/pages/finance/PendingSettlement';
import Settings from '@/pages/Settings';
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -42,7 +43,8 @@ const App: React.FC = () => (
<Route path="room-calendar/:roomId" element={<RoomCalendar />} />
<Route path="reviews" element={<ReviewList />} />
<Route path="finance">
<Route index element={<Navigate to="/finance/settlements" replace />} />
<Route index element={<Navigate to="/finance/pending-settlement" replace />} />
<Route path="pending-settlement" element={<FinancePendingSettlement />} />
<Route path="settlements" element={<FinanceSettlements />} />
<Route path="settlements/:id" element={<FinanceSettlementDetail />} />
<Route path="withdrawals" element={<FinanceWithdrawals />} />
+9
View File
@@ -43,3 +43,12 @@ export function getSettlementDetail(id: number) {
export function getSettlementItems(id: number, params: any) {
return request.get(`/api/merchant/finance/settlements/${id}/items`, { params });
}
// 待结算订单相关
export function getPendingSettlementOrders(params: any) {
return request.get('/api/merchant/finance/settlements/pending/orders', { params });
}
export function getPendingSettlementSummary() {
return request.get('/api/merchant/finance/settlements/pending/summary');
}
@@ -14,6 +14,7 @@ import {
AuditOutlined,
PayCircleOutlined,
AccountBookOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/store/auth';
@@ -30,6 +31,7 @@ const menuItems = [
icon: <WalletOutlined />,
label: '财务管理',
children: [
{ key: '/finance/pending-settlement', icon: <ClockCircleOutlined />, label: '待结算订单' },
{ key: '/finance/settlements', icon: <AuditOutlined />, label: '结算对账' },
{ key: '/finance/withdrawals', icon: <PayCircleOutlined />, label: '收款记录' },
{ key: '/finance/wallet', icon: <AccountBookOutlined />, label: '我的钱包' },
@@ -0,0 +1,242 @@
import React, { useEffect, useState } from 'react';
import { Card, Table, Statistic, Row, Col, Space, Tag } from 'antd';
import { ClockCircleOutlined, DollarOutlined, FileTextOutlined, CalendarOutlined } from '@ant-design/icons';
import { getPendingSettlementOrders, getPendingSettlementSummary } from '@/api/finance';
import type { ColumnsType } from 'antd/es/table';
import { formatMoney, formatDate, formatDateTime } from '@rent/shared-utils';
import { useTableData } from '@/hooks/useTableData';
interface PendingOrder {
id: number;
orderNo: string;
roomName: string;
checkInDate: string;
checkOutDate: string;
nights: number;
payAmount: number;
serviceFee: number;
merchantIncome: number;
checkoutAt: string;
contactName: string;
contactPhone: string;
}
interface Summary {
orderCount: number;
totalPayAmount: number;
totalServiceFee: number;
totalMerchantIncome: number;
startDate: string;
lastSettlementDate: string | null;
}
const PendingSettlement: React.FC = () => {
const [summary, setSummary] = useState<Summary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const {
data: orders,
loading,
total,
page,
pageSize,
setPage,
setPageSize,
refresh,
} = useTableData<PendingOrder>({
fetchFn: async (params) => {
const res = await getPendingSettlementOrders(params);
return {
list: res.data.list || [],
total: res.data.total,
page: params.page || 1,
pageSize: params.pageSize || 20,
totalPages: Math.ceil(res.data.total / (params.pageSize || 20)),
};
},
initialParams: { pageSize: 20 },
});
useEffect(() => {
fetchSummary();
}, []);
const fetchSummary = async () => {
setSummaryLoading(true);
try {
const res = await getPendingSettlementSummary();
setSummary(res.data);
} catch (error) {
console.error('获取待结算统计失败', error);
} finally {
setSummaryLoading(false);
}
};
const columns: ColumnsType<PendingOrder> = [
{
title: '订单号',
dataIndex: 'orderNo',
key: 'orderNo',
width: 180,
fixed: 'left',
},
{
title: '房型名称',
dataIndex: 'roomName',
key: 'roomName',
width: 150,
},
{
title: '入住日期',
dataIndex: 'checkInDate',
key: 'checkInDate',
width: 120,
render: (date: string) => formatDate(date),
},
{
title: '离店日期',
dataIndex: 'checkOutDate',
key: 'checkOutDate',
width: 120,
render: (date: string) => formatDate(date),
},
{
title: '入住晚数',
dataIndex: 'nights',
key: 'nights',
width: 100,
render: (nights: number) => `${nights}`,
},
{
title: '订单金额',
dataIndex: 'payAmount',
key: 'payAmount',
width: 120,
render: (amount: number) => formatMoney(amount),
},
{
title: '服务费',
dataIndex: 'serviceFee',
key: 'serviceFee',
width: 120,
render: (fee: number) => <span style={{ color: '#ff4d4f' }}>-{formatMoney(fee, '')}</span>,
},
{
title: '预计收入',
dataIndex: 'merchantIncome',
key: 'merchantIncome',
width: 120,
render: (income: number) => (
<span style={{ fontWeight: 'bold', color: '#52c41a' }}>{formatMoney(income)}</span>
),
},
{
title: '完成时间',
dataIndex: 'checkoutAt',
key: 'checkoutAt',
width: 180,
render: (date: string) => formatDateTime(date),
},
{
title: '联系人',
key: 'contact',
width: 150,
render: (_, record) => (
<div>
<div>{record.contactName}</div>
<div style={{ fontSize: 12, color: '#999' }}>{record.contactPhone}</div>
</div>
),
},
];
return (
<div>
<h2 style={{ marginBottom: 24 }}></h2>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card loading={summaryLoading}>
<Statistic
title="待结算订单数"
value={summary?.orderCount || 0}
prefix={<FileTextOutlined />}
suffix="笔"
/>
</Card>
</Col>
<Col span={6}>
<Card loading={summaryLoading}>
<Statistic
title="订单总金额"
value={summary?.totalPayAmount || 0}
precision={2}
prefix={<DollarOutlined />}
suffix="元"
/>
</Card>
</Col>
<Col span={6}>
<Card loading={summaryLoading}>
<Statistic
title="平台服务费"
value={summary?.totalServiceFee || 0}
precision={2}
prefix={<DollarOutlined />}
suffix="元"
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
<Col span={6}>
<Card loading={summaryLoading}>
<Statistic
title="预计结算金额"
value={summary?.totalMerchantIncome || 0}
precision={2}
prefix={<DollarOutlined />}
suffix="元"
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
</Row>
<Card
extra={
<Space>
{summary?.lastSettlementDate && (
<Tag icon={<CalendarOutlined />} color="blue">
: {formatDate(summary.lastSettlementDate)}
</Tag>
)}
<Tag icon={<ClockCircleOutlined />} color="orange">
: {summary?.startDate ? formatDate(summary.startDate) : '-'}
</Tag>
</Space>
}
>
<Table
columns={columns}
dataSource={orders}
rowKey="id"
loading={loading}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: setPage,
onShowSizeChange: (_, size) => setPageSize(size),
}}
/>
</Card>
</div>
);
};
export default PendingSettlement;
+1 -34
View File
@@ -7,21 +7,6 @@ export interface WalletInfo {
availableBalance: number;
}
// 交易流水接口
export interface Transaction {
id: number;
accountId: number;
direction: 'in' | 'out';
transactionType: string;
amount: number;
balanceBefore: number;
balanceAfter: number;
description: string;
relatedId?: number;
relatedType?: string;
createdAt: string;
}
// 提现记录接口
export interface Withdrawal {
id: number;
@@ -46,28 +31,10 @@ export const walletApi = {
});
},
// 获取交易流水
getTransactions(params?: {
direction?: 'in' | 'out';
transactionType?: string;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}) {
return request<{ items: Transaction[]; total: number }>({
url: '/api/app/finance/transactions',
method: 'GET',
data: params,
});
},
// 申请提现
withdraw(data: {
amount: number;
accountType: 'alipay' | 'wechat';
accountName: string;
accountNumber: string;
paymentChannel: 'wechat' | 'alipay';
}) {
return request({
url: '/api/app/finance/withdraw',
-6
View File
@@ -197,12 +197,6 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/wallet/transactions",
"style": {
"navigationBarTitleText": "交易流水"
}
},
{
"path": "pages/wallet/withdraw",
"style": {
-7
View File
@@ -32,9 +32,7 @@
<text class="date-tip">入住</text>
</view>
<view class="date-sep">
<view class="sep-line" />
<text class="sep-nights">{{ nightCount }}</text>
<view class="sep-line" />
</view>
<view class="date-col">
<text class="date-day">{{ checkOutLabel }}</text>
@@ -633,11 +631,6 @@ onActivated(() => {
padding: 0 32rpx;
}
.sep-line {
width: 40rpx;
height: 2rpx;
background: #ddd;
}
.sep-nights {
font-size: 20rpx;
+124 -135
View File
@@ -18,37 +18,29 @@
<!-- 余额卡片 -->
<view class="balance-card">
<view class="balance-header">
<view class="balance-icon-wrapper">
<u-icon name="rmb-circle-fill" :size="24" color="#FF6B35" />
<view class="balance-left">
<view class="balance-header">
<u-icon name="rmb-circle-fill" :size="18" color="#FF6B35" />
<text class="balance-title">我的收益</text>
</view>
<view class="balance-amount">
<text class="balance-value">¥{{ wallet.availableBalance || '0.00' }}</text>
<text v-if="!canWithdraw && config?.config" class="balance-tip">
{{ config.config.withdrawThreshold }}元可提现
</text>
</view>
<text class="balance-title">我的收益</text>
</view>
<view class="balance-main">
<view class="balance-amount-section">
<text class="balance-label">可提现余额</text>
<view class="balance-value-row">
<text class="balance-symbol">¥</text>
<text class="balance-value">{{ wallet.availableBalance || '0.00' }}</text>
</view>
<view v-if="!canWithdraw" class="balance-tip">
<u-icon name="info-circle" :size="12" color="#FF8C00" />
<text class="tip-text">满10元可提现</text>
</view>
<view class="balance-actions">
<view
class="action-btn action-btn-primary"
:class="{ disabled: !canWithdraw }"
@tap="goWithdraw"
>
<text class="action-btn-text">提现</text>
</view>
<view class="balance-actions">
<view
class="action-btn action-btn-primary"
:class="{ disabled: !canWithdraw }"
@tap="goWithdraw"
>
<text class="action-btn-text">立即提现</text>
</view>
<view class="action-btn action-btn-secondary" @tap="goWithdrawals">
<text class="action-btn-text">提现记录</text>
</view>
<view class="action-btn action-btn-secondary" @tap="goWithdrawals">
<text class="action-btn-text">记录</text>
</view>
</view>
</view>
@@ -119,38 +111,18 @@
<text class="rules-title">活动规则</text>
</view>
<view class="rules-content">
<view class="rule-item">
<view class="rule-number">1</view>
<view v-if="config?.config" class="rules-content">
<view v-for="(rule, index) in rules" :key="index" class="rule-item">
<view class="rule-number">{{ index + 1 }}</view>
<view class="rule-content">
<text class="rule-title-text">首单高返</text>
<text class="rule-desc">好友首次下单您可获得订单金额 <text class="highlight">5%</text> 返现</text>
<text class="rule-title-text">{{ rule.title }}</text>
<text class="rule-desc" v-html="rule.desc"></text>
</view>
</view>
</view>
<view class="rule-item">
<view class="rule-number">2</view>
<view class="rule-content">
<text class="rule-title-text">持续收益</text>
<text class="rule-desc">好友再次下单您仍可获得 <text class="highlight">0.5%</text> 返现奖励</text>
</view>
</view>
<view class="rule-item">
<view class="rule-number">3</view>
<view class="rule-content">
<text class="rule-title-text">返现限额</text>
<text class="rule-desc">单笔返现最低 <text class="highlight">0.01</text>最高 <text class="highlight">50</text></text>
</view>
</view>
<view class="rule-item">
<view class="rule-number">4</view>
<view class="rule-content">
<text class="rule-title-text">提现门槛</text>
<text class="rule-desc">余额满 <text class="highlight">10</text> 即可提现实时到账</text>
</view>
</view>
<view v-else class="rules-loading">
<text class="loading-text">加载中...</text>
</view>
</view>
@@ -181,7 +153,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { getInviteStats } from '@/api/user/invite';
import { getInviteStats, getInviteConfig } from '@/api/user/invite';
import { walletApi } from '@/api/user/wallet';
import { useUserStore } from '@/store/user';
@@ -195,15 +167,55 @@ const wallet = ref<any>({
availableBalance: 0,
});
const config = ref<any>(null);
const userStore = useUserStore();
const canWithdraw = computed(() => {
return Number(wallet.value.availableBalance) >= 10;
const threshold = config.value?.config?.withdrawThreshold || 10;
return Number(wallet.value.availableBalance) >= threshold;
});
// 格式化百分比,去掉多余的0
const formatPercent = (rate: number) => {
const percent = rate * 100;
// 使用parseFloat自动去掉末尾的0
return parseFloat(percent.toFixed(2));
};
// 动态生成活动规则
const rules = computed(() => {
if (!config.value?.config) return [];
const cfg = config.value.config;
const maxOrderIndex = cfg.maxOrderIndex || 2;
return [
{
title: '首单高返',
desc: `好友首次下单,您可获得订单金额 ${formatPercent(cfg.firstOrderRate)}% 返现`,
},
{
title: maxOrderIndex > 1 ? '持续收益' : '返现次数',
desc: maxOrderIndex > 1
? `好友再次下单,您仍可获得 ${formatPercent(cfg.secondOrderRate)}% 返现奖励(最多前${maxOrderIndex}单)`
: '每位好友仅首单可获得返现',
},
{
title: '返现限额',
desc: `单笔返现最低 ${cfg.minCashback}元,最高 ${cfg.maxCashback}`,
},
{
title: '提现门槛',
desc: `余额满 ${cfg.withdrawThreshold}元 即可提现,实时到账`,
},
];
});
onMounted(() => {
fetchStats();
fetchWallet();
fetchConfig();
});
async function fetchStats() {
@@ -224,13 +236,23 @@ async function fetchWallet() {
}
}
async function fetchConfig() {
try {
const res = await getInviteConfig();
config.value = res.data || null;
} catch (e) {
console.error('获取活动配置失败:', e);
}
}
function handleShare() {
uni.showToast({ title: '请点击右上角分享', icon: 'none' });
}
function goWithdraw() {
if (!canWithdraw.value) {
uni.showToast({ title: '余额不足10元,暂不可提现', icon: 'none' });
const threshold = config.value?.config?.withdrawThreshold || 10;
uni.showToast({ title: `余额不足${threshold}元,暂不可提现`, icon: 'none' });
return;
}
uni.navigateTo({ url: '/pages/wallet/withdraw' });
@@ -348,130 +370,87 @@ function goPoster() {
z-index: 1;
margin: 0 24rpx 24rpx;
background: linear-gradient(135deg, #FF8C5A 0%, #FF6B35 100%);
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 12rpx 32rpx rgba(255, 107, 53, 0.25);
overflow: hidden;
border-radius: 16rpx;
padding: 20rpx 24rpx;
box-shadow: 0 6rpx 20rpx rgba(255, 107, 53, 0.18);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.balance-card::before {
content: '';
position: absolute;
top: -50rpx;
right: -50rpx;
width: 200rpx;
height: 200rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
.balance-left {
flex: 1;
min-width: 0;
}
.balance-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 24rpx;
}
.balance-icon-wrapper {
width: 48rpx;
height: 48rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
margin-bottom: 8rpx;
}
.balance-title {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.balance-main {
position: relative;
z-index: 1;
}
.balance-amount-section {
margin-bottom: 32rpx;
}
.balance-label {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 12rpx;
}
.balance-value-row {
.balance-amount {
display: flex;
align-items: baseline;
flex-direction: column;
gap: 4rpx;
margin-bottom: 12rpx;
}
.balance-symbol {
font-size: 32rpx;
color: #ffffff;
font-weight: 600;
}
.balance-value {
font-size: 64rpx;
font-size: 40rpx;
color: #ffffff;
font-weight: 700;
line-height: 1;
letter-spacing: 2rpx;
line-height: 1.2;
letter-spacing: 0.5rpx;
}
.balance-tip {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
background: rgba(255, 140, 0, 0.2);
border-radius: 20rpx;
width: fit-content;
}
.tip-text {
font-size: 22rpx;
color: #FFF5E6;
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
margin-top: 2rpx;
}
.balance-actions {
display: flex;
gap: 16rpx;
flex-direction: column;
gap: 10rpx;
flex-shrink: 0;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 24rpx;
border-radius: 16rpx;
padding: 14rpx 28rpx;
border-radius: 12rpx;
transition: all 0.3s ease;
min-width: 120rpx;
&:active {
transform: scale(0.96);
transform: scale(0.95);
}
}
.action-btn-primary {
background: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
&.disabled {
background: rgba(255, 255, 255, 0.3);
opacity: 0.6;
background: rgba(255, 255, 255, 0.4);
opacity: 0.7;
}
.action-btn-text {
color: #FF6B35;
font-weight: 600;
font-size: 26rpx;
}
}
@@ -731,6 +710,16 @@ function goPoster() {
font-weight: 600;
}
.rules-loading {
padding: 40rpx 0;
text-align: center;
}
.loading-text {
font-size: 26rpx;
color: #999;
}
/* ========== 底部记录入口 ========== */
.bottom-records {
position: relative;
-11
View File
@@ -94,17 +94,6 @@
<text class="order-label">已下单</text>
</view>
</view>
<view v-if="item.orderCount > 0" class="record-footer">
<view class="earnings-info">
<u-icon name="rmb-circle-fill" :size="14" color="#FF6B35" />
<text class="earnings-text">已获收益</text>
<text class="earnings-amount">¥{{ item.totalCashback || '0.00' }}</text>
</view>
<view class="arrow-icon">
<u-icon name="arrow-right" :size="14" color="#CCCCCC" />
</view>
</view>
</view>
<!-- 加载状态 -->
+1 -1
View File
@@ -71,7 +71,7 @@ const userInfo = computed(() => userStore.userInfo);
// 常用工具列表
const toolList = [
{ key: 'orders', label: '我的订单', icon: 'list', iconColor: '#1A1A1A', bgColor: '#FFFFFF' },
{ key: 'orders', label: '我的订单', icon: 'file-text', iconColor: '#1A1A1A', bgColor: '#FFFFFF' },
{ key: 'wallet', label: '我的钱包', icon: 'rmb-circle', iconColor: '#1A1A1A', bgColor: '#FFFFFF' },
{ key: 'coupon', label: '优惠券', icon: 'coupon', iconColor: '#1A1A1A', bgColor: '#FFFFFF' },
{ key: 'invite', label: '邀请返现', icon: 'gift', iconColor: '#1A1A1A', bgColor: '#FFFFFF' },
+12 -2
View File
@@ -172,7 +172,7 @@
<view class="form-item">
<view class="form-label-row">
<text class="form-label">身份证号</text>
<text class="form-optional">选填</text>
<text class="form-required">*</text>
</view>
<input
class="form-input"
@@ -600,6 +600,16 @@ async function handleSubmit() {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
return;
}
if (!contactIdCard.value) {
uni.showToast({ title: '请输入身份证号', icon: 'none' });
return;
}
// 验证身份证号格式(15位或18位)
const idCardReg = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/;
if (!idCardReg.test(contactIdCard.value)) {
uni.showToast({ title: '请输入正确的身份证号', icon: 'none' });
return;
}
try {
const res = await createOrder({
@@ -610,7 +620,7 @@ async function handleSubmit() {
guestCount: guestCount.value,
contactName: contactName.value,
contactPhone: contactPhone.value,
contactIdCard: contactIdCard.value || undefined,
contactIdCard: contactIdCard.value,
remark: remark.value,
couponId: selectedCoupon.value?.coupon?.id || undefined,
});
-10
View File
@@ -57,12 +57,6 @@
<!-- 快捷入口 -->
<view class="quick-actions">
<view class="action-item" @tap="goTransactions">
<view class="action-icon">
<u-icon name="order" :size="24" color="#FF6B35" />
</view>
<text class="action-text">交易流水</text>
</view>
<view class="action-item" @tap="goWithdrawals">
<view class="action-icon">
<u-icon name="list" :size="24" color="#FF6B35" />
@@ -146,10 +140,6 @@ function goWithdraw() {
uni.navigateTo({ url: '/pages/wallet/withdraw' });
}
function goTransactions() {
uni.navigateTo({ url: '/pages/wallet/transactions' });
}
function goWithdrawals() {
uni.navigateTo({ url: '/pages/wallet/withdrawals' });
}
@@ -1,469 +0,0 @@
<template>
<view class="page-transactions">
<!-- 导航栏 -->
<view class="navbar">
<view class="navbar-left" @tap="goBack">
<u-icon name="arrow-left" :size="20" color="#1A1A1A" />
</view>
<text class="navbar-title">交易流水</text>
<view class="navbar-right"></view>
</view>
<!-- 筛选栏 -->
<view class="filter-bar">
<scroll-view scroll-x class="filter-scroll">
<view class="filter-items">
<view
v-for="type in transactionTypes"
:key="type.value"
:class="['filter-item', { active: filterType === type.value }]"
@tap="filterType = type.value; fetchTransactions()"
>
<text class="filter-text">{{ type.label }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 交易列表 -->
<scroll-view
v-if="!loading"
scroll-y
class="transactions-list"
@scrolltolower="loadMore"
>
<view v-if="transactions.length > 0" class="list-content">
<view
v-for="item in transactions"
:key="item.id"
class="transaction-item"
>
<view class="item-left">
<view class="item-icon" :class="getDirectionClass(item.direction)">
<u-icon :name="getDirectionIcon(item.direction)" :size="20" color="#ffffff" />
</view>
<view class="item-info">
<text class="item-title">{{ item.description || getTypeLabel(item.transactionType) }}</text>
<text class="item-time">{{ formatTime(item.createdAt) }}</text>
</view>
</view>
<view class="item-right">
<text :class="['item-amount', getAmountClass(item.direction)]">
{{ getAmountPrefix(item.direction) }}{{ formatMoney(item.amount) }}
</text>
<text class="item-balance">余额: ¥{{ formatMoney(item.balanceAfter) }}</text>
</view>
</view>
<!-- 加载更多 -->
<view v-if="hasMore" class="load-more">
<text class="load-more-text">加载更多...</text>
</view>
<view v-else-if="transactions.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state">
<view class="empty-icon">
<u-icon name="file-text" :size="80" color="#D8D8D8" />
</view>
<text class="empty-text">暂无交易记录</text>
</view>
</scroll-view>
<!-- 加载状态 -->
<view v-else class="loading-state">
<view class="loading-spinner">
<view class="spinner-dot"></view>
<view class="spinner-dot"></view>
<view class="spinner-dot"></view>
</view>
<text class="loading-text">加载中...</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { walletApi } from '@/api/user/wallet';
const loading = ref(true);
const transactions = ref<any[]>([]);
const filterType = ref('');
const page = ref(1);
const pageSize = 20;
const hasMore = ref(true);
const transactionTypes = [
{ label: '全部', value: '' },
{ label: '收入', value: 'in' },
{ label: '支出', value: 'out' },
];
onMounted(() => {
fetchTransactions();
});
async function fetchTransactions(reset = true) {
if (reset) {
page.value = 1;
transactions.value = [];
hasMore.value = true;
}
loading.value = reset;
try {
const params: any = {
page: page.value,
pageSize,
};
if (filterType.value) {
params.direction = filterType.value;
}
const res = await walletApi.getTransactions(params);
const newData = res.data.items || [];
if (reset) {
transactions.value = newData;
} else {
transactions.value = [...transactions.value, ...newData];
}
hasMore.value = newData.length >= pageSize;
} catch (e: any) {
console.error('获取交易流水失败:', e);
uni.showToast({ title: e.message || '加载失败', icon: 'none' });
} finally {
loading.value = false;
}
}
function loadMore() {
if (!hasMore.value || loading.value) return;
page.value++;
fetchTransactions(false);
}
function getTypeLabel(type: string): string {
const typeMap: Record<string, string> = {
invite_cashback: '邀请返现',
withdrawal: '提现',
refund: '退款',
};
return typeMap[type] || type;
}
function getDirectionIcon(direction: string): string {
return direction === 'in' ? 'arrow-down-circle' : 'arrow-up-circle';
}
function getDirectionClass(direction: string): string {
return direction === 'in' ? 'type-income' : 'type-expense';
}
function getAmountClass(direction: string): string {
return direction === 'in' ? 'amount-income' : 'amount-expense';
}
function getAmountPrefix(direction: string): string {
return direction === 'in' ? '+' : '-';
}
function formatMoney(value: number | string): string {
const num = Number(value || 0);
return num.toFixed(2);
}
function formatTime(time: string): string {
if (!time) return '';
const date = new Date(time);
const now = new Date();
const diff = now.getTime() - date.getTime();
const day = 24 * 60 * 60 * 1000;
if (diff < day && date.getDate() === now.getDate()) {
return `今天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
} else if (diff < 2 * day && date.getDate() === now.getDate() - 1) {
return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
} else {
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
}
function goBack() {
uni.navigateBack();
}
</script>
<style lang="scss" scoped>
.page-transactions {
min-height: 100vh;
background: #F5F7FA;
display: flex;
flex-direction: column;
}
/* 导航栏 */
.navbar {
background: #ffffff;
padding: 0 24rpx;
border-bottom: 1rpx solid #F0F0F0;
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.navbar-left,
.navbar-right {
width: 80rpx;
display: flex;
align-items: center;
padding: 12rpx;
margin: -12rpx;
transition: opacity 0.3s ease;
&:active {
opacity: 0.6;
}
}
.navbar-left {
justify-content: flex-start;
}
.navbar-right {
justify-content: flex-end;
}
.navbar-title {
flex: 1;
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: #1A1A1A;
}
/* 筛选栏 */
.filter-bar {
background: #ffffff;
border-bottom: 1rpx solid #F0F0F0;
}
.filter-scroll {
white-space: nowrap;
}
.filter-items {
display: inline-flex;
padding: 16rpx 24rpx;
gap: 16rpx;
}
.filter-item {
display: inline-block;
padding: 12rpx 24rpx;
background: #F5F7FA;
border-radius: 32rpx;
transition: all 0.3s ease;
&.active {
background: linear-gradient(135deg, #FF8C5A 0%, #FF6B35 100%);
}
&:active {
opacity: 0.7;
}
}
.filter-text {
font-size: 26rpx;
color: #666;
white-space: nowrap;
.active & {
color: #ffffff;
font-weight: 600;
}
}
/* 交易列表 */
.transactions-list {
flex: 1;
overflow-y: auto;
}
.list-content {
padding: 16rpx 0;
}
.transaction-item {
margin: 0 24rpx 16rpx;
padding: 24rpx;
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
}
.item-left {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
}
.item-icon {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.type-income {
background: linear-gradient(135deg, #52C41A 0%, #389E0D 100%);
}
&.type-expense {
background: linear-gradient(135deg, #FF6B35 0%, #FF4D4F 100%);
}
}
.item-info {
display: flex;
flex-direction: column;
gap: 8rpx;
flex: 1;
}
.item-title {
font-size: 28rpx;
font-weight: 600;
color: #1A1A1A;
}
.item-time {
font-size: 24rpx;
color: #999;
}
.item-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
}
.item-amount {
font-size: 32rpx;
font-weight: 700;
&.amount-income {
color: #52C41A;
}
&.amount-expense {
color: #FF4D4F;
}
}
.item-balance {
font-size: 24rpx;
color: #999;
}
/* 加载更多 */
.load-more,
.no-more {
padding: 32rpx;
text-align: center;
}
.load-more-text,
.no-more-text {
font-size: 24rpx;
color: #999;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
gap: 24rpx;
}
.empty-icon {
opacity: 0.3;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
/* 加载状态 */
.loading-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 120rpx 0;
}
.loading-spinner {
display: flex;
align-items: center;
gap: 12rpx;
}
.spinner-dot {
width: 12rpx;
height: 12rpx;
background: #FF6B35;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.loading-text {
font-size: 24rpx;
color: #999;
}
</style>
+39 -46
View File
@@ -44,34 +44,21 @@
<view
v-for="type in accountTypes"
:key="type.value"
:class="['type-item', { active: form.accountType === type.value }]"
@tap="form.accountType = type.value"
:class="['type-item', { active: form.paymentChannel === type.value }]"
@tap="form.paymentChannel = type.value"
>
<u-icon :name="type.icon" :size="24" :color="form.accountType === type.value ? '#FF6B35' : '#999'" />
<u-icon :name="type.icon" :size="28" :color="form.paymentChannel === type.value ? '#FF6B35' : '#999'" />
<text class="type-text">{{ type.label }}</text>
</view>
</view>
</view>
<!-- 账户信息 -->
<!-- 提现说明 -->
<view class="form-item">
<text class="item-label">真实姓名</text>
<input
v-model="form.accountName"
class="text-input"
placeholder="请输入真实姓名"
:maxlength="20"
/>
</view>
<view class="form-item">
<text class="item-label">{{ form.accountType === 'alipay' ? '支付宝账号' : '微信账号' }}</text>
<input
v-model="form.accountNumber"
class="text-input"
:placeholder="form.accountType === 'alipay' ? '请输入支付宝账号' : '请输入微信账号'"
:maxlength="30"
/>
<view class="wechat-tip">
<u-icon name="info-circle" :size="16" color="#FF6B35" />
<text class="wechat-tip-text">提现将直接到账当前登录的微信账号</text>
</view>
</view>
</view>
@@ -112,13 +99,10 @@ const submitting = ref(false);
const form = ref({
amount: '',
accountType: 'alipay',
accountName: '',
accountNumber: '',
paymentChannel: 'wechat',
});
const accountTypes = [
{ label: '支付宝', value: 'alipay', icon: 'pay-circle' },
{ label: '微信', value: 'wechat', icon: 'weixin' },
];
@@ -168,20 +152,10 @@ async function handleSubmit() {
return;
}
if (!form.value.accountName) {
uni.showToast({ title: '请输入真实姓名', icon: 'none' });
return;
}
if (!form.value.accountNumber) {
uni.showToast({ title: '请输入账号', icon: 'none' });
return;
}
// 确认提现
uni.showModal({
title: '确认提现',
content: `确认提现 ¥${formatMoney(form.value.amount)}${getAccountTypeLabel(form.value.accountType)}`,
content: `确认提现 ¥${formatMoney(form.value.amount)}${getAccountTypeLabel(form.value.paymentChannel)}`,
success: async (res) => {
if (res.confirm) {
await submitWithdraw();
@@ -196,9 +170,7 @@ async function submitWithdraw() {
try {
await walletApi.withdraw({
amount: Number(form.value.amount),
accountType: form.value.accountType as 'alipay' | 'wechat',
accountName: form.value.accountName,
accountNumber: form.value.accountNumber,
paymentChannel: form.value.paymentChannel as 'wechat',
});
uni.showToast({ title: '提现申请已提交', icon: 'success' });
@@ -216,7 +188,6 @@ async function submitWithdraw() {
function getAccountTypeLabel(type: string): string {
const typeMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
};
return typeMap[type] || type;
@@ -233,7 +204,9 @@ function goBack() {
background: #F5F7FA;
display: flex;
flex-direction: column;
padding-bottom: 120rpx;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
overflow-x: hidden;
}
/* 导航栏 */
@@ -379,14 +352,16 @@ function goBack() {
}
.type-item {
flex: 1;
padding: 24rpx 16rpx;
width: 160rpx;
height: 160rpx;
padding: 24rpx;
background: #F5F7FA;
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
justify-content: center;
gap: 12rpx;
transition: all 0.3s ease;
border: 2rpx solid transparent;
@@ -401,7 +376,7 @@ function goBack() {
}
.type-text {
font-size: 24rpx;
font-size: 26rpx;
color: #666;
.active & {
@@ -419,6 +394,22 @@ function goBack() {
color: #1A1A1A;
}
.wechat-tip {
display: flex;
align-items: center;
gap: 8rpx;
padding: 20rpx;
background: #FFF7E6;
border-radius: 12rpx;
}
.wechat-tip-text {
flex: 1;
font-size: 24rpx;
color: #FF6B35;
line-height: 1.5;
}
/* 提示卡片 */
.tips-card {
margin: 0 24rpx 16rpx;
@@ -463,10 +454,11 @@ function goBack() {
bottom: 0;
left: 0;
right: 0;
padding: 16rpx 24rpx;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: #ffffff;
border-top: 1rpx solid #F0F0F0;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.04);
box-sizing: border-box;
}
.submit-btn {
@@ -476,6 +468,7 @@ function goBack() {
border-radius: 48rpx;
text-align: center;
transition: all 0.3s ease;
box-sizing: border-box;
&:active:not(.disabled) {
transform: scale(0.98);
+9
View File
@@ -186,3 +186,12 @@ export function getMerchantReport(merchantId: number, params: any) {
export function exportReport(type: string, params: any) {
return request.get(`/api/admin/finance/reports/export/${type}`, { params, responseType: 'blob' });
}
// 服务费管理
export function getServiceFeeStatistics() {
return request.get('/api/admin/finance/reports/service-fees/statistics');
}
export function getServiceFeeList(params: any) {
return request.get('/api/admin/finance/reports/service-fees/list', { params });
}
@@ -238,7 +238,7 @@ const InviteManage: React.FC = () => {
<InputNumber min={0.01} max={1} step={0.01} prefix="¥" style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="最高返现金额" name="maxCashback" rules={[{ required: true }]} extra="单笔返现最高金额">
<InputNumber min={10} max={500} step={10} prefix="¥" style={{ width: '100%' }} />
<InputNumber min={10} max={5000} step={10} prefix="¥" style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="提现门槛" name="withdrawThreshold" rules={[{ required: true }]} extra="用户余额满多少元可以申请提现">
<InputNumber min={1} max={100} step={1} prefix="¥" style={{ width: '100%' }} />
@@ -240,7 +240,7 @@ const BankCards: React.FC = () => {
label="卡片名称"
rules={[{ required: true, message: '请输入卡片名称' }]}
>
<Input placeholder="例如:主账户、备用账户" />
<Input placeholder="例如:PLATFORM_MAIN (平台主账户)、PLATFORM_BACKUP (平台备用账户)" />
</Form.Item>
<Form.Item
name="bankName"
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Statistic, Table, Tag } from 'antd';
import { WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons';
import { getFinancialOverview, getPlatformTransactions } from '@/api/finance';
import { getAccountDisplayName } from '@/utils/accountNameMap';
import type { ColumnsType } from 'antd/es/table';
interface FinancialOverview {
@@ -14,11 +15,11 @@ interface FinancialOverview {
platformTotalExpense: number;
totalUserBalance: number;
totalMerchantBalance: number;
totalMerchantPendingSettlement: number;
userCount: number;
merchantCount: number;
todayIncome: number;
todayExpense: number;
todayOrders: number;
}
interface Transaction {
@@ -116,18 +117,19 @@ const FinanceDashboard: React.FC = () => {
{/* 系统总账户金额 - 突出显示 */}
<Card style={{ marginBottom: 24, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<Statistic
title={<span style={{ color: '#fff', fontSize: 16 }}></span>}
title={<span style={{ color: '#fff', fontSize: 16 }}></span>}
value={Number(overview.systemTotalAmount || 0)}
prefix={<WalletOutlined style={{ color: '#fff' }} />}
suffix={<span style={{ color: '#fff' }}></span>}
suffix={<span style={{ color: '#fff' }}> ({getAccountDisplayName('SYSTEM_MAIN')})</span>}
precision={2}
valueStyle={{ color: '#fff', fontSize: 36, fontWeight: 'bold' }}
/>
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, marginTop: 8 }}>
¥{Number(overview.totalUserPaid || 0).toFixed(2)}- 退¥{Number(overview.totalRefund || 0).toFixed(2)}- ¥{Number(overview.totalWithdrawn || 0).toFixed(2)}
{/* <div style={{ color: 'rgba(255,255,255,0.9)', fontSize: 13, marginTop: 8, fontWeight: 500 }}>
商家账户余额(¥{Number(overview.totalMerchantBalance || 0).toFixed(2)}+ 用户账户余额(¥{Number(overview.totalUserBalance || 0).toFixed(2)}+ 平台净收益(¥{Number(overview.platformBalance || 0).toFixed(2)}
</div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 4 }}>
= ¥{Number(overview.totalMerchantBalance || 0).toFixed(2)}+ ¥{Number(overview.totalUserBalance || 0).toFixed(2)}+ ¥{Number(overview.platformBalance || 0).toFixed(2)}
*/}
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 11, marginTop: 6 }}>
balance = ¥{Number(overview.totalUserPaid || 0).toFixed(2)}- 退¥{Number(overview.totalRefund || 0).toFixed(2)}- ¥{Number(overview.totalWithdrawn || 0).toFixed(2)}= ¥{(Number(overview.totalUserPaid || 0) - Number(overview.totalRefund || 0) - Number(overview.totalWithdrawn || 0)).toFixed(2)}
</div>
</Card>
@@ -180,11 +182,16 @@ const FinanceDashboard: React.FC = () => {
<Col span={6}>
<Card>
<Statistic
title="今日订单数"
value={overview.todayOrders}
suffix="单"
valueStyle={{ color: '#722ed1' }}
title="商家待结算金额"
value={overview.totalMerchantPendingSettlement}
precision={2}
prefix={<DollarOutlined />}
suffix="元"
valueStyle={{ color: '#ff9800' }}
/>
<div style={{ fontSize: 12, color: '#999', marginTop: 8 }}>
</div>
</Card>
</Col>
</Row>
@@ -3,7 +3,7 @@ import { Card, Table, DatePicker, Space, Tag, Button, Statistic, Row, Col, messa
import { DownloadOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { formatMoney, formatDateTime } from '@rent/shared-utils';
import { getFinancialOverview, getMerchantTransactions } from '@/api/finance';
import { getServiceFeeStatistics, getServiceFeeList } from '@/api/finance';
import type { Dayjs } from 'dayjs';
const { RangePicker } = DatePicker;
@@ -45,7 +45,7 @@ const ServiceFees: React.FC = () => {
const fetchStatistics = async () => {
try {
const res = await getFinancialOverview({});
const res = await getServiceFeeStatistics();
if (res?.data) {
setStatistics({
todayCommission: res.data?.todayCommission || 0,
@@ -72,7 +72,7 @@ const ServiceFees: React.FC = () => {
params.endDate = dateRange[1].format('YYYY-MM-DD');
}
const res = await getMerchantTransactions(params);
const res = await getServiceFeeList(params);
if (res?.data) {
setDataSource(res.data?.list || []);
setPagination({
@@ -0,0 +1,31 @@
/**
* 账户名称映射工具
* 数据库存储英文常量,前端显示中文名称
*/
export const ACCOUNT_NAMES = {
SYSTEM_MAIN: 'SYSTEM_MAIN',
PLATFORM_MAIN: 'PLATFORM_MAIN',
PLATFORM_BACKUP: 'PLATFORM_BACKUP',
} as const;
export const ACCOUNT_NAME_MAP: Record<string, string> = {
SYSTEM_MAIN: '系统总账户',
PLATFORM_MAIN: '平台主账户',
PLATFORM_BACKUP: '平台备用账户',
};
/**
* 将英文账户名称转换为中文显示名称
*/
export function getAccountDisplayName(accountName: string): string {
return ACCOUNT_NAME_MAP[accountName] || accountName;
}
/**
* 将中文显示名称转换为英文账户名称
*/
export function getAccountConstName(displayName: string): string {
const entry = Object.entries(ACCOUNT_NAME_MAP).find(([_, value]) => value === displayName);
return entry ? entry[0] : displayName;
}
@@ -0,0 +1,15 @@
/**
* 账户名称常量
* 数据库存储使用这些英文常量
*/
export const ACCOUNT_NAMES = {
/** 系统总账户 */
SYSTEM_MAIN: 'SYSTEM_MAIN',
/** 平台主账户 */
PLATFORM_MAIN: 'PLATFORM_MAIN',
/** 平台备用账户 */
PLATFORM_BACKUP: 'PLATFORM_BACKUP',
} as const;
export type AccountName = typeof ACCOUNT_NAMES[keyof typeof ACCOUNT_NAMES];
@@ -5,7 +5,7 @@ export class PlatformAccount {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Column({ type: 'varchar', length: 50, comment: '账户名称(如:主账户、备用账户' })
@Column({ type: 'varchar', length: 50, comment: '账户名称(如:PLATFORM_MAIN、PLATFORM_BACKUP' })
account_name: string;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0, comment: '可用余额(平台净收益 = total_income - total_expense' })
@@ -5,7 +5,7 @@ export class SystemAccount {
@PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
id: number;
@Column({ type: 'varchar', length: 50, comment: '账户名称(如:主账户' })
@Column({ type: 'varchar', length: 50, comment: '账户名称(如:SYSTEM_MAIN' })
account_name: string;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0, comment: '可用余额(total_income - total_refund - total_withdrawn' })
@@ -102,7 +102,7 @@ export class ActivityService {
firstOrderRate: 0.05,
secondOrderRate: 0.005,
minCashback: 0.01,
maxCashback: 50,
maxCashback: 1000,
withdrawThreshold: 10,
maxOrderIndex: 2,
},
@@ -67,4 +67,26 @@ export class ReportAdminController {
// TODO: 实现导出功能
return { message: '导出功能开发中' };
}
@Get('service-fees/statistics')
@ApiOperation({ summary: '服务费统计' })
async getServiceFeeStatistics() {
return this.reportService.getServiceFeeStatistics();
}
@Get('service-fees/list')
@ApiOperation({ summary: '服务费明细列表' })
async getServiceFeeList(
@Query('page') page: number = 1,
@Query('pageSize') pageSize: number = 20,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
) {
return this.reportService.getServiceFeeList({
page: Number(page),
pageSize: Number(pageSize),
startDate,
endDate,
});
}
}
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ActivityController } from './activity.controller';
import { ActivityService } from './activity.service';
@@ -22,7 +22,7 @@ import { FinanceModule } from '@/modules/shared/finance/finance.module';
Order,
User,
]),
FinanceModule,
forwardRef(() => FinanceModule),
],
controllers: [ActivityController],
providers: [ActivityService],
@@ -10,12 +10,10 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/common';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import { WithdrawalService } from '@/modules/shared/finance/withdrawal.service';
import { TransactionService } from '@/modules/shared/finance/transaction.service';
import { AccountService } from '@/modules/shared/finance/account.service';
import {
CreateUserWithdrawalDto,
QueryUserWithdrawalDto,
QueryTransactionDto,
} from '@/modules/shared/finance/dto/finance.dto';
@ApiTags('财务管理(用户)')
@@ -25,7 +23,6 @@ import {
export class FinanceUserController {
constructor(
private readonly withdrawalService: WithdrawalService,
private readonly transactionService: TransactionService,
private readonly accountService: AccountService,
) {}
@@ -63,23 +60,4 @@ export class FinanceUserController {
pageSize: dto.pageSize,
});
}
@Get('transactions')
@ApiOperation({ summary: '交易流水列表' })
async getTransactions(
@CurrentUser('sub') userId: number,
@Query() dto: QueryTransactionDto,
) {
const account = await this.accountService.getUserAccount(userId);
return this.transactionService.getUserTransactions({
accountId: account.id,
direction: dto.direction,
transactionType: dto.transactionType,
startDate: dto.startDate,
endDate: dto.endDate,
page: dto.page,
pageSize: dto.pageSize,
});
}
}
@@ -320,42 +320,59 @@ export class OrderService {
throw new BadRequestException('当前订单状态不可支付');
}
// 模拟支付成功
const paymentNo = `PAY${Date.now()}${Math.floor(Math.random() * 10000)}`;
await this.orderRepo.update(order.id, {
status: 'pending_confirm',
paymentMethod,
paymentNo,
paidAt: new Date(),
});
// 使用事务确保所有操作原子性
const queryRunner = this.orderRepo.manager.connection.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
// 记录系统总账户收入(用户实付金额)
const transactionNo = `TXN${Date.now()}${Math.floor(Math.random() * 10000)}`;
await this.accountService.addSystemIncome(
order.payAmount,
transactionNo,
'order_payment',
order.id,
order.orderNo,
`用户支付订单:${order.orderNo}`,
);
// 扣减房态库存
const checkIn = new Date(order.checkInDate);
const checkOut = new Date(order.checkOutDate);
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const calendar = await this.calendarRepo.findOne({
where: { roomId: order.roomId, date: dateStr },
try {
// 1. 更新订单状态为已支付
const paymentNo = `PAY${Date.now()}${Math.floor(Math.random() * 10000)}`;
await queryRunner.manager.update(Order, order.id, {
status: 'pending_confirm',
paymentMethod,
paymentNo,
paidAt: new Date(),
});
if (calendar) {
await this.calendarRepo.update(calendar.id, {
// 2. 记录系统总账户收入(用户实付金额)
const transactionNo = `TXN${Date.now()}${Math.floor(Math.random() * 10000)}`;
await this.accountService.addSystemIncome(
order.payAmount,
transactionNo,
'order_payment',
order.id,
order.orderNo,
`用户支付订单:${order.orderNo}`,
);
// 3. 扣减房态库存
const checkIn = new Date(order.checkInDate);
const checkOut = new Date(order.checkOutDate);
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const calendar = await queryRunner.manager.findOne(RoomCalendar, {
where: { roomId: order.roomId, date: dateStr },
});
if (!calendar) {
throw new BadRequestException(`房态日历数据异常:${dateStr}`);
}
await queryRunner.manager.update(RoomCalendar, calendar.id, {
sold: calendar.sold + order.roomCount,
});
}
}
return { message: '支付成功', paymentNo };
// 提交事务
await queryRunner.commitTransaction();
return { message: '支付成功', paymentNo };
} catch (error) {
// 回滚事务
await queryRunner.rollbackTransaction();
throw error;
} finally {
// 释放连接
await queryRunner.release();
}
}
/**
@@ -368,42 +385,59 @@ export class OrderService {
throw new BadRequestException('当前订单状态不可支付');
}
// 模拟支付成功
const paymentNo = `PAY${Date.now()}${Math.floor(Math.random() * 10000)}`;
await this.orderRepo.update(id, {
status: 'pending_confirm',
paymentMethod,
paymentNo,
paidAt: new Date(),
});
// 使用事务确保所有操作原子性
const queryRunner = this.orderRepo.manager.connection.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
// 记录系统总账户收入(用户实付金额)
const transactionNo = `TXN${Date.now()}${Math.floor(Math.random() * 10000)}`;
await this.accountService.addSystemIncome(
order.payAmount,
transactionNo,
'order_payment',
order.id,
order.orderNo,
`用户支付订单:${order.orderNo}`,
);
// 扣减房态库存
const checkIn = new Date(order.checkInDate);
const checkOut = new Date(order.checkOutDate);
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const calendar = await this.calendarRepo.findOne({
where: { roomId: order.roomId, date: dateStr },
try {
// 1. 更新订单状态为已支付
const paymentNo = `PAY${Date.now()}${Math.floor(Math.random() * 10000)}`;
await queryRunner.manager.update(Order, id, {
status: 'pending_confirm',
paymentMethod,
paymentNo,
paidAt: new Date(),
});
if (calendar) {
await this.calendarRepo.update(calendar.id, {
// 2. 记录系统总账户收入(用户实付金额)
const transactionNo = `TXN${Date.now()}${Math.floor(Math.random() * 10000)}`;
await this.accountService.addSystemIncome(
order.payAmount,
transactionNo,
'order_payment',
order.id,
order.orderNo,
`用户支付订单:${order.orderNo}`,
);
// 3. 扣减房态库存
const checkIn = new Date(order.checkInDate);
const checkOut = new Date(order.checkOutDate);
for (let d = new Date(checkIn); d < checkOut; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
const calendar = await queryRunner.manager.findOne(RoomCalendar, {
where: { roomId: order.roomId, date: dateStr },
});
if (!calendar) {
throw new BadRequestException(`房态日历数据异常:${dateStr}`);
}
await queryRunner.manager.update(RoomCalendar, calendar.id, {
sold: calendar.sold + order.roomCount,
});
}
}
return { message: '支付成功', paymentNo };
// 提交事务
await queryRunner.commitTransaction();
return { message: '支付成功', paymentNo };
} catch (error) {
// 回滚事务
await queryRunner.rollbackTransaction();
throw error;
} finally {
// 释放连接
await queryRunner.release();
}
}
/**
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MerchantAccount } from '@/entities/merchant-account.entity';
import { MerchantTransaction } from '@/entities/merchant-transaction.entity';
import { SettlementService } from '@/modules/shared/finance/settlement.service';
@Injectable()
export class MerchantFinanceService {
@@ -11,6 +12,7 @@ export class MerchantFinanceService {
private merchantAccountRepo: Repository<MerchantAccount>,
@InjectRepository(MerchantTransaction)
private merchantTransactionRepo: Repository<MerchantTransaction>,
private settlementService: SettlementService,
) {}
async getMerchantAccount(merchantId: number) {
@@ -33,6 +35,9 @@ export class MerchantFinanceService {
await this.merchantAccountRepo.save(account);
}
// 动态计算待结算金额
const pendingSettlement = await this.settlementService.getPendingSettlementAmount(merchantId);
// 计算可用余额并返回格式化的数据
return {
id: account.id,
@@ -43,7 +48,7 @@ export class MerchantFinanceService {
totalExpense: Number(account.total_expense),
totalSettlement: Number(account.total_settlement),
totalWithdraw: Number(account.total_withdraw),
pendingSettlement: Number(account.pending_settlement),
pendingSettlement: Number(pendingSettlement),
debtAmount: Number(account.debt_amount),
status: account.status,
lastSettlementAt: account.last_settlement_at,
@@ -93,4 +93,24 @@ export class SettlementMerchantController {
pageSize
};
}
@Get('pending/orders')
@ApiOperation({ summary: '查询待结算订单列表' })
async getPendingSettlementOrders(
@CurrentSeller('sub') sellerId: number,
@Query('page') page: number = 1,
@Query('pageSize') pageSize: number = 20,
) {
const merchantId = await this.getMerchantId(sellerId);
return this.settlementService.getPendingSettlementOrders(merchantId, page, pageSize);
}
@Get('pending/summary')
@ApiOperation({ summary: '查询待结算订单统计' })
async getPendingSettlementSummary(
@CurrentSeller('sub') sellerId: number,
) {
const merchantId = await this.getMerchantId(sellerId);
return this.settlementService.getPendingSettlementSummary(merchantId);
}
}
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MerchantOrderController } from './order.controller';
import { MerchantOrderService } from './order.service';
@@ -6,11 +6,15 @@ import { Order } from '@/entities/order.entity';
import { Room } from '@/entities/room.entity';
import { RoomCalendar } from '@/entities/room-calendar.entity';
import { MerchantProfileModule } from '../profile/profile.module';
import { UserActivityModule } from '@/modules/app/activity/activity.module';
import { FinanceModule } from '@/modules/shared/finance/finance.module';
@Module({
imports: [
TypeOrmModule.forFeature([Order, Room, RoomCalendar]),
MerchantProfileModule,
forwardRef(() => UserActivityModule),
forwardRef(() => FinanceModule),
],
controllers: [MerchantOrderController],
providers: [MerchantOrderService],
@@ -1,13 +1,18 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, ForbiddenException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from '@/entities/order.entity';
import { Room } from '@/entities/room.entity';
import { RoomCalendar } from '@/entities/room-calendar.entity';
import { QueryOrderDto } from './dto/order.dto';
import { ActivityService } from '@/modules/app/activity/activity.service';
import { AccountService } from '@/modules/shared/finance/account.service';
import { TransactionService } from '@/modules/shared/finance/transaction.service';
@Injectable()
export class MerchantOrderService {
private readonly logger = new Logger(MerchantOrderService.name);
constructor(
@InjectRepository(Order)
private orderRepo: Repository<Order>,
@@ -15,6 +20,9 @@ export class MerchantOrderService {
private roomRepo: Repository<Room>,
@InjectRepository(RoomCalendar)
private calendarRepo: Repository<RoomCalendar>,
private readonly activityService: ActivityService,
private readonly accountService: AccountService,
private readonly transactionService: TransactionService,
) {}
async findByMerchant(merchantId: number, query: QueryOrderDto) {
@@ -111,6 +119,51 @@ export class MerchantOrderService {
checkoutAt: new Date(),
});
// 订单完成后立即结算服务费给平台账户
try {
const transactionNo = this.transactionService.generateTransactionNo();
const serviceFee = Number(order.serviceFee || 0);
if (serviceFee > 0) {
await this.accountService.addPlatformServiceFee(
serviceFee,
transactionNo,
'order_complete',
order.id,
order.orderNo,
`订单完成服务费 - ${order.orderNo}`,
);
this.logger.log(`订单 ${orderNo} 完成,平台服务费 ${serviceFee} 元已入账`);
}
} catch (error) {
this.logger.error(`订单 ${orderNo} 服务费结算失败: ${error.message}`);
// 不影响订单完成流程,只记录错误
}
// 增加商家待结算金额
try {
const merchantIncome = Number(order.merchantIncome || 0);
if (merchantIncome > 0) {
await this.accountService.addMerchantPendingSettlement(
order.merchantId,
merchantIncome,
);
this.logger.log(`订单 ${orderNo} 完成,商家待结算金额 ${merchantIncome} 元已增加`);
}
} catch (error) {
this.logger.error(`订单 ${orderNo} 商家待结算金额更新失败: ${error.message}`);
// 不影响订单完成流程,只记录错误
}
// 触发邀请返现
try {
await this.activityService.handleOrderCompleted(order.id);
this.logger.log(`订单 ${orderNo} 完成,已触发邀请返现处理`);
} catch (error) {
this.logger.error(`订单 ${orderNo} 邀请返现处理失败: ${error.message}`);
// 不影响订单完成流程,只记录错误
}
return { message: '已确认离店,订单已完成' };
}
}
@@ -10,6 +10,7 @@ import { MerchantTransaction } from '@/entities/merchant-transaction.entity';
import { PlatformTransaction } from '@/entities/platform-transaction.entity';
import { SystemTransaction } from '@/entities/system-transaction.entity';
import { QueryUserAccountsDto, QueryMerchantAccountsDto } from './dto/account.dto';
import { ACCOUNT_NAMES } from '@/constants/account.constant';
@Injectable()
export class AccountService {
@@ -82,7 +83,7 @@ export class AccountService {
/**
* 获取平台账户
*/
async getPlatformAccount(accountName: string = '主账户'): Promise<PlatformAccount> {
async getPlatformAccount(accountName: string = ACCOUNT_NAMES.PLATFORM_MAIN): Promise<PlatformAccount> {
const account = await this.platformAccountRepo.findOne({ where: { account_name: accountName } });
if (!account) {
@@ -246,12 +247,15 @@ export class AccountService {
}
const balance = Number(account.balance);
if (balance < amount) {
throw new BadRequestException('账户余额不足');
const frozenBalance = Number(account.frozen_balance);
const availableBalance = balance - frozenBalance;
if (availableBalance < amount) {
throw new BadRequestException('可用余额不足');
}
account.balance = balance - amount;
account.frozen_balance = Number(account.frozen_balance) + amount;
// 只增加冻结金额,不减少余额
account.frozen_balance = frozenBalance + amount;
account.version += 1;
await queryRunner.manager.save(account);
@@ -287,7 +291,7 @@ export class AccountService {
throw new BadRequestException('冻结余额不足');
}
account.balance = Number(account.balance) + amount;
// 只减少冻结金额,不增加余额(因为余额本来就没减少)
account.frozen_balance = frozenBalance - amount;
account.version += 1;
@@ -303,6 +307,7 @@ export class AccountService {
/**
* 商家账户增加余额(结算)
* 同时减少待结算金额
*/
async addMerchantBalance(
merchantId: number,
@@ -336,6 +341,8 @@ export class AccountService {
account.balance = balanceAfter;
account.total_income = Number(account.total_income) + amount;
// 结算时减少待结算金额
account.pending_settlement = Math.max(0, Number(account.pending_settlement) - amount);
account.version += 1;
await queryRunner.manager.save(account);
@@ -366,6 +373,40 @@ export class AccountService {
}
}
/**
* 商家账户增加待结算金额(订单完成时)
*/
async addMerchantPendingSettlement(
merchantId: number,
amount: number,
): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const account = await queryRunner.manager.findOne(MerchantAccount, {
where: { merchant_id: merchantId },
lock: { mode: 'pessimistic_write' },
});
if (!account) {
throw new NotFoundException('商家账户不存在');
}
account.pending_settlement = Number(account.pending_settlement) + amount;
account.version += 1;
await queryRunner.manager.save(account);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* 商家账户扣减余额(提现)
*/
@@ -451,7 +492,7 @@ export class AccountService {
try {
const account = await queryRunner.manager.findOne(PlatformAccount, {
where: { account_name: '主账户' },
where: { account_name: ACCOUNT_NAMES.PLATFORM_MAIN },
lock: { mode: 'pessimistic_write' },
});
@@ -510,7 +551,7 @@ export class AccountService {
try {
const account = await queryRunner.manager.findOne(PlatformAccount, {
where: { account_name: '主账户' },
where: { account_name: ACCOUNT_NAMES.PLATFORM_MAIN },
lock: { mode: 'pessimistic_write' },
});
@@ -569,7 +610,7 @@ export class AccountService {
try {
const account = await queryRunner.manager.findOne(PlatformAccount, {
where: { account_name: '主账户' },
where: { account_name: ACCOUNT_NAMES.PLATFORM_MAIN },
lock: { mode: 'pessimistic_write' },
});
@@ -773,7 +814,7 @@ export class AccountService {
/**
* 获取系统总账户
*/
async getSystemAccount(accountName: string = '主账户'): Promise<SystemAccount> {
async getSystemAccount(accountName: string = ACCOUNT_NAMES.SYSTEM_MAIN): Promise<SystemAccount> {
const account = await this.systemAccountRepo.findOne({ where: { account_name: accountName } });
if (!account) {
@@ -802,7 +843,7 @@ export class AccountService {
try {
const account = await queryRunner.manager.findOne(SystemAccount, {
where: { account_name: '主账户' },
where: { account_name: ACCOUNT_NAMES.SYSTEM_MAIN },
lock: { mode: 'pessimistic_write' },
});
@@ -861,7 +902,7 @@ export class AccountService {
try {
const account = await queryRunner.manager.findOne(SystemAccount, {
where: { account_name: '主账户' },
where: { account_name: ACCOUNT_NAMES.SYSTEM_MAIN },
lock: { mode: 'pessimistic_write' },
});
@@ -920,7 +961,7 @@ export class AccountService {
try {
const account = await queryRunner.manager.findOne(SystemAccount, {
where: { account_name: '主账户' },
where: { account_name: ACCOUNT_NAMES.SYSTEM_MAIN },
lock: { mode: 'pessimistic_write' },
});
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Settlement } from '@/entities/settlement.entity';
import { SettlementItem } from '@/entities/settlement-item.entity';
@@ -50,7 +50,7 @@ import { MerchantModule } from '@/modules/merchant/merchant.module';
Merchant,
Order,
]),
MerchantModule,
forwardRef(() => MerchantModule),
],
providers: [
SettlementService,
@@ -14,6 +14,7 @@ import { Settlement } from '@/entities/settlement.entity';
import { UserWithdrawal } from '@/entities/user-withdrawal.entity';
import { MerchantWithdrawal } from '@/entities/merchant-withdrawal.entity';
import { PlatformWithdrawal } from '@/entities/platform-withdrawal.entity';
import { ACCOUNT_NAMES } from '@/constants/account.constant';
import dayjs from 'dayjs';
@Injectable()
@@ -68,7 +69,7 @@ export class ReconciliationService {
*/
async performReconciliation(date: string) {
const platformAccount = await this.platformAccountRepo.findOne({
where: { account_name: '主账户' }
where: { account_name: ACCOUNT_NAMES.PLATFORM_MAIN }
});
const platformBalanceStart = Number(platformAccount?.balance || 0);
@@ -221,7 +222,7 @@ export class ReconciliationService {
*/
async getAccountSummary() {
const platformAccount = await this.platformAccountRepo.findOne({
where: { account_name: '主账户' }
where: { account_name: ACCOUNT_NAMES.PLATFORM_MAIN }
});
const merchantStats = await this.merchantAccountRepo
@@ -12,6 +12,7 @@ import { MerchantWithdrawal } from '@/entities/merchant-withdrawal.entity';
import { UserWithdrawal } from '@/entities/user-withdrawal.entity';
import { PlatformWithdrawal } from '@/entities/platform-withdrawal.entity';
import { MktInviteWithdrawal } from '@/entities/mkt-invite-withdrawal.entity';
import { ACCOUNT_NAMES } from '@/constants/account.constant';
import dayjs from 'dayjs';
@Injectable()
@@ -47,17 +48,18 @@ export class ReportService {
async getOverview() {
// 获取系统总账户
const systemAccount = await this.systemAccountRepo.findOne({
where: { account_name: '系统总账户' },
where: { account_name: ACCOUNT_NAMES.SYSTEM_MAIN },
});
// 获取平台账户
const platformAccount = await this.platformAccountRepo.findOne({
where: { account_name: '主账户' },
where: { account_name: ACCOUNT_NAMES.PLATFORM_MAIN },
});
const merchantStats = await this.merchantAccountRepo
.createQueryBuilder('a')
.select('SUM(a.balance)', 'totalBalance')
.addSelect('SUM(a.pending_settlement)', 'totalPendingSettlement')
.addSelect('COUNT(*)', 'count')
.getRawOne();
@@ -88,24 +90,34 @@ export class ReportService {
.andWhere('t.direction = :direction', { direction: 'expense' })
.getRawOne();
// 系统总账户金额(直接从数据库读取)
const systemTotalAmount = Number(systemAccount?.balance || 0);
// 各层级账户余额
const totalMerchantBalance = Number(merchantStats?.totalBalance || 0);
const totalMerchantPendingSettlement = Number(merchantStats?.totalPendingSettlement || 0);
const totalUserBalance = Number(userStats?.totalBalance || 0);
const platformBalance = Number(platformAccount?.balance || 0);
return {
// 系统总账户金额(用户实付 - 退款 - 提现
systemTotalAmount: Number(systemAccount?.balance || 0),
// 系统总账户金额(从 system_accounts 表读取
systemTotalAmount,
totalUserPaid: Number(systemAccount?.total_income || 0),
totalRefund: Number(systemAccount?.total_refund || 0),
totalWithdrawn: Number(systemAccount?.total_withdrawn || 0),
// 平台净收益(服务费 - 邀请返现)
platformBalance: Number(platformAccount?.balance || 0),
platformBalance,
platformTotalIncome: Number(platformAccount?.total_income || 0),
platformTotalExpense: Number(platformAccount?.total_expense || 0),
// 商家账户统计
totalMerchantBalance: Number(merchantStats?.totalBalance || 0),
totalMerchantBalance,
totalMerchantPendingSettlement,
merchantCount: Number(merchantStats?.count || 0),
// 用户账户统计
totalUserBalance: Number(userStats?.totalBalance || 0),
totalUserBalance,
userCount: Number(userStats?.count || 0),
// 今日统计
@@ -314,4 +326,123 @@ export class ReportService {
netAmount: Number(income?.sum || 0) - Number(expense?.sum || 0),
};
}
/**
* 服务费统计
*/
async getServiceFeeStatistics() {
const today = dayjs().format('YYYY-MM-DD');
const monthStart = dayjs().startOf('month').format('YYYY-MM-DD');
const monthEnd = dayjs().endOf('month').format('YYYY-MM-DD');
// 今日服务费
const todayFee = await this.orderRepo
.createQueryBuilder('o')
.select('SUM(o.service_fee)', 'sum')
.where('DATE(o.created_at) = :date', { date: today })
.andWhere('o.status != :status', { status: 'cancelled' })
.getRawOne();
// 本月服务费
const monthFee = await this.orderRepo
.createQueryBuilder('o')
.select('SUM(o.service_fee)', 'sum')
.where('DATE(o.created_at) BETWEEN :start AND :end', {
start: monthStart,
end: monthEnd,
})
.andWhere('o.status != :status', { status: 'cancelled' })
.getRawOne();
// 累计服务费
const totalFee = await this.orderRepo
.createQueryBuilder('o')
.select('SUM(o.service_fee)', 'sum')
.where('o.status != :status', { status: 'cancelled' })
.getRawOne();
// 待结算服务费(已完成但未结算的订单)
const pendingFee = await this.orderRepo
.createQueryBuilder('o')
.leftJoin('settlement_items', 'si', 'si.order_id = o.id')
.select('SUM(o.service_fee)', 'sum')
.where('o.status = :status', { status: 'completed' })
.andWhere('si.id IS NULL')
.getRawOne();
return {
todayCommission: Number(todayFee?.sum || 0),
monthCommission: Number(monthFee?.sum || 0),
totalCommission: Number(totalFee?.sum || 0),
pendingCommission: Number(pendingFee?.sum || 0),
};
}
/**
* 服务费明细列表
*/
async getServiceFeeList(params: {
page: number;
pageSize: number;
startDate?: string;
endDate?: string;
}) {
const { page, pageSize, startDate, endDate } = params;
const query = this.orderRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.merchant', 'm')
.leftJoinAndSelect('o.user', 'u')
.leftJoin('settlement_items', 'si', 'si.order_id = o.id')
.leftJoin('settlements', 's', 's.id = si.settlement_id')
.select([
'o.id',
'o.orderNo',
'o.totalAmount',
'o.serviceFee',
'o.status',
'o.createdAt',
'm.id',
'm.name',
'u.id',
'u.nickname',
's.settledAt',
])
.where('o.status != :status', { status: 'cancelled' })
.andWhere('o.service_fee > 0');
if (startDate && endDate) {
query.andWhere('DATE(o.created_at) BETWEEN :startDate AND :endDate', {
startDate,
endDate,
});
}
const [list, total] = await query
.orderBy('o.created_at', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
const result = list.map((order) => ({
id: order.id,
orderId: order.id,
orderNo: order.orderNo,
merchantName: order.merchant?.shopName || '-',
userName: order.user?.nickname || '-',
orderAmount: Number(order.totalAmount),
commissionRate: 0.05, // 固定5%,如果需要动态获取可以从配置读取
commissionAmount: Number(order.serviceFee),
status: order['s_settledAt'] ? 'settled' : 'pending',
settledAt: order['s_settledAt'] || null,
createdAt: order.createdAt,
}));
return {
list: result,
total,
page,
pageSize,
};
}
}
@@ -51,33 +51,66 @@ export class SettlementService {
throw new BadRequestException(`该周期 ${lastWeekStart} ~ ${lastWeekEnd} 已经结算过,无法重复结算`);
}
const orders = await this.orderRepo
// 查询所有已完成且截止到上周末的订单
this.logger.log(`查询条件: status=completed, checkout_at <= ${lastWeekEnd} 23:59:59`);
const allOrders = await this.orderRepo
.createQueryBuilder('o')
.where('o.status = :status', { status: 'completed' })
.andWhere('o.merchant_id IS NOT NULL')
.andWhere('o.checkout_at BETWEEN :start AND :end', {
start: `${lastWeekStart} 00:00:00`,
.andWhere('o.checkout_at IS NOT NULL')
.andWhere('o.checkout_at <= :end', {
end: `${lastWeekEnd} 23:59:59`
})
.getMany();
if (orders.length === 0) {
this.logger.log(`查询到 ${allOrders.length} 个已完成订单`);
if (allOrders.length === 0) {
this.logger.log('没有需要结算的订单');
throw new BadRequestException('该周期内没有需要结算的订单');
}
const ordersByMerchant = orders.reduce((acc, order) => {
// 批量查询已结算的订单ID
const orderIds = allOrders.map(o => o.id);
const settledItems = await this.settlementItemRepo
.createQueryBuilder('si')
.select('si.order_id')
.where('si.order_id IN (:...orderIds)', { orderIds })
.getRawMany();
const settledOrderIds = new Set(settledItems.map(item => item.order_id));
// 过滤出未结算的订单,并按商家分组
const ordersByMerchant: Record<number, Order[]> = {};
let skippedCount = 0;
for (const order of allOrders) {
const merchantId = order.merchantId;
if (!merchantId || isNaN(merchantId)) {
this.logger.warn(`订单 ${order.id} 的 merchantId 无效: ${merchantId}`);
return acc;
continue;
}
if (!acc[merchantId]) {
acc[merchantId] = [];
// 检查该订单是否已经被结算过
if (settledOrderIds.has(order.id)) {
skippedCount++;
continue;
}
acc[merchantId].push(order);
return acc;
}, {} as Record<number, Order[]>);
if (!ordersByMerchant[merchantId]) {
ordersByMerchant[merchantId] = [];
}
ordersByMerchant[merchantId].push(order);
}
this.logger.log(`共查询到 ${allOrders.length} 个订单,已结算 ${skippedCount} 个,待结算 ${allOrders.length - skippedCount}`);
if (Object.keys(ordersByMerchant).length === 0) {
this.logger.log('没有需要结算的订单(所有订单都已结算)');
throw new BadRequestException('没有需要结算的订单');
}
let successCount = 0;
let failCount = 0;
@@ -96,7 +129,7 @@ export class SettlementService {
}
this.logger.log(`周结算任务执行完成,成功:${successCount},失败:${failCount}`);
return { successCount, failCount, totalOrders: orders.length };
return { successCount, failCount, totalOrders: allOrders.length - skippedCount };
} catch (error) {
this.logger.error(`周结算任务执行失败:${error.message}`);
throw error;
@@ -333,6 +366,93 @@ export class SettlementService {
return orderAmount - serviceFee;
}
/**
* 获取商家待结算订单列表
*/
async getPendingSettlementOrders(merchantId: number, page: number = 1, pageSize: number = 20) {
const lastSettlement = await this.settlementRepo.findOne({
where: { merchantId },
order: { settledAt: 'DESC' }
});
const startDate = lastSettlement
? dayjs(lastSettlement.periodEnd).add(1, 'day').format('YYYY-MM-DD')
: '2020-01-01';
const queryBuilder = this.orderRepo.createQueryBuilder('order')
.leftJoinAndSelect('order.room', 'room')
.leftJoinAndSelect('order.user', 'user')
.where('order.merchantId = :merchantId', { merchantId })
.andWhere('order.status = :status', { status: 'completed' })
.andWhere('order.checkoutAt IS NOT NULL')
.andWhere('order.checkoutAt >= :startDate', { startDate: `${startDate} 00:00:00` })
.andWhere('order.checkoutAt <= :endDate', { endDate: new Date() })
.orderBy('order.checkoutAt', 'DESC');
const skip = (page - 1) * pageSize;
queryBuilder.skip(skip).take(pageSize);
const [orders, total] = await queryBuilder.getManyAndCount();
return {
list: orders.map(order => ({
id: order.id,
orderNo: order.orderNo,
roomName: order.room?.name || '',
checkInDate: order.checkInDate,
checkOutDate: order.checkOutDate,
nights: order.nights,
payAmount: Number(order.payAmount),
serviceFee: Number(order.serviceFee || 0),
merchantIncome: Number(order.merchantIncome || 0),
checkoutAt: order.checkoutAt,
contactName: order.contactName,
contactPhone: order.contactPhone,
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
};
}
/**
* 获取商家待结算订单统计
*/
async getPendingSettlementSummary(merchantId: number) {
const lastSettlement = await this.settlementRepo.findOne({
where: { merchantId },
order: { settledAt: 'DESC' }
});
const startDate = lastSettlement
? dayjs(lastSettlement.periodEnd).add(1, 'day').format('YYYY-MM-DD')
: '2020-01-01';
const orders = await this.orderRepo
.createQueryBuilder('o')
.where('o.merchant_id = :merchantId', { merchantId })
.andWhere('o.status = :status', { status: 'completed' })
.andWhere('o.checkout_at IS NOT NULL')
.andWhere('o.checkout_at >= :startDate', { startDate: `${startDate} 00:00:00` })
.andWhere('o.checkout_at <= :endDate', { endDate: new Date() })
.getMany();
const orderCount = orders.length;
const totalPayAmount = orders.reduce((sum, o) => sum + Number(o.payAmount), 0);
const totalServiceFee = orders.reduce((sum, o) => sum + Number(o.serviceFee || 0), 0);
const totalMerchantIncome = orders.reduce((sum, o) => sum + Number(o.merchantIncome || 0), 0);
return {
orderCount,
totalPayAmount,
totalServiceFee,
totalMerchantIncome,
startDate,
lastSettlementDate: lastSettlement?.settledAt || null,
};
}
/**
* 预览周结算数据(不实际执行)
*/