Files
rent/apps/server/src/modules/order/order.service.ts
T
2026-05-14 00:17:52 +08:00

535 lines
18 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, LessThan } from 'typeorm';
import { Order } from '@/entities/order.entity';
import { Room } from '@/entities/room.entity';
import { RoomCalendar } from '@/entities/room-calendar.entity';
import { Merchant } from '@/entities/merchant.entity';
import { Review } from '@/entities/review.entity';
import { CreateOrderDto, QueryOrderDto, ConfirmOrderDto } from './dto/order.dto';
import { ActivityService } from '@/modules/activity/activity.service';
import { ConfigService } from '@/modules/config/config.service';
import { RefundService } from '@/modules/finance/refund.service';
import { CouponService } from '@/modules/coupon/coupon.service';
@Injectable()
export class OrderService {
constructor(
@InjectRepository(Order)
private orderRepo: Repository<Order>,
@InjectRepository(Room)
private roomRepo: Repository<Room>,
@InjectRepository(RoomCalendar)
private calendarRepo: Repository<RoomCalendar>,
@InjectRepository(Merchant)
private merchantRepo: Repository<Merchant>,
@InjectRepository(Review)
private reviewRepo: Repository<Review>,
private readonly activityService: ActivityService,
private readonly configService: ConfigService,
private readonly refundService: RefundService,
private readonly couponService: CouponService,
) {}
async create(userId: number, dto: CreateOrderDto) {
const room = await this.roomRepo.findOne({ where: { id: dto.roomId, status: 'on_sale' } });
if (!room) throw new NotFoundException('房源不存在或已下架');
const checkIn = new Date(dto.checkInDate);
const checkOut = new Date(dto.checkOutDate);
if (checkIn >= checkOut) throw new BadRequestException('离店日期必须大于入住日期');
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
if (nights < 1) throw new BadRequestException('至少入住一晚');
// 获取入住期间每天的房价和库存
const dates: string[] = [];
for (let i = 0; i < nights; i++) {
const date = new Date(checkIn);
date.setDate(date.getDate() + i);
dates.push(date.toISOString().split('T')[0]);
}
const calendars = await this.calendarRepo.find({
where: { roomId: dto.roomId, date: Between(dates[0], dates[dates.length - 1]) },
});
// 检查每天的库存和房态
const roomCount = dto.roomCount || 1;
let totalRoomPrice = 0;
for (const date of dates) {
const calendar = calendars.find(c => c.date === date);
if (!calendar) {
throw new BadRequestException(`${date} 未设置房价和库存`);
}
if (calendar.status === 'unavailable') {
throw new BadRequestException(`${date} 房间不可售`);
}
const availableStock = calendar.stock - calendar.sold;
if (availableStock < roomCount) {
throw new BadRequestException(`${date} 库存不足,仅剩 ${availableStock}`);
}
// 累加每天的房价
totalRoomPrice += Number(calendar.price);
}
const roomAmount = totalRoomPrice * roomCount;
// 处理优惠券
let couponDiscount = 0;
let userCouponId: number | null = null;
if (dto.couponId) {
// 查询用户可用优惠券
const availableCoupons = await this.couponService.findAvailableCoupons(
userId,
roomAmount,
room.merchantId,
dto.roomId,
);
const userCoupon = availableCoupons.find(uc => uc.coupon.id === dto.couponId);
if (!userCoupon) {
throw new BadRequestException('优惠券不可用');
}
// 计算优惠金额
couponDiscount = this.couponService.calculateDiscount(userCoupon.coupon, roomAmount);
userCouponId = userCoupon.id;
}
const totalAmount = roomAmount;
const payAmount = totalAmount - couponDiscount;
// 计算软件服务费和商家收入
const serviceFeeRate = await this.configService.getServiceFeeRate();
const serviceFee = Math.round(payAmount * serviceFeeRate * 100) / 100;
const merchantIncome = Math.round((payAmount - serviceFee) * 100) / 100;
const now = new Date();
const orderNo = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
String(Math.floor(Math.random() * 1000000)).padStart(6, '0'),
].join('');
const order = this.orderRepo.create({
orderNo,
userId,
merchantId: room.merchantId,
roomId: room.id,
checkInDate: dto.checkInDate,
checkOutDate: dto.checkOutDate,
nights,
roomCount,
guestCount: dto.guestCount || 1,
roomPrice: Math.round((totalRoomPrice / nights) * 100) / 100, // 使用平均房价
roomAmount,
serviceFee,
merchantIncome,
couponDiscount,
userCouponId: userCouponId !== null ? userCouponId : undefined,
totalAmount,
payAmount,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
contactIdCard: dto.contactIdCard || undefined,
remark: dto.remark || '',
paymentMethod: dto.paymentMethod || null,
});
const savedOrder = await this.orderRepo.save(order);
// 使用优惠券
if (userCouponId) {
await this.couponService.useCoupon(userCouponId, savedOrder.id);
}
// 扣减库存
for (const date of dates) {
await this.calendarRepo.increment(
{ roomId: dto.roomId, date },
'sold',
roomCount,
);
}
return savedOrder;
}
async findByUser(userId: number, query: QueryOrderDto) {
const { page = 1, pageSize = 10, status } = query;
const qb = this.orderRepo.createQueryBuilder('o')
.leftJoinAndSelect('o.room', 'r')
.leftJoinAndSelect('o.merchant', 'm')
.where('o.user_id = :userId', { userId });
if (status) {
qb.andWhere('o.status = :status', { status });
}
qb.orderBy('o.createdAt', 'DESC');
qb.skip((page - 1) * pageSize).take(pageSize);
const [list, total] = await qb.getManyAndCount();
// 查询每个订单是否已评价
const orderIds = list.map(order => order.id);
const reviews = orderIds.length > 0
? await this.reviewRepo.find({
where: orderIds.map(id => ({ orderId: id })),
select: ['orderId'],
})
: [];
const reviewedOrderIds = new Set(reviews.map(r => r.orderId));
// 添加 hasReviewed 字段到每个订单
const listWithReview = list.map(order => ({
...order,
hasReviewed: reviewedOrderIds.has(order.id),
}));
return { list: listWithReview, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
async findByMerchant(merchantId: number, query: QueryOrderDto) {
const { page = 1, pageSize = 10, status, orderNo, startDate, endDate } = query;
const qb = this.orderRepo.createQueryBuilder('o')
.leftJoinAndSelect('o.room', 'r')
.leftJoinAndSelect('o.user', 'u')
.where('o.merchant_id = :merchantId', { merchantId });
if (status) qb.andWhere('o.status = :status', { status });
if (orderNo) qb.andWhere('o.order_no LIKE :orderNo', { orderNo: `%${orderNo}%` });
if (startDate) qb.andWhere('o.created_at >= :startDate', { startDate });
if (endDate) qb.andWhere('o.created_at <= :endDate', { endDate });
qb.orderBy('o.createdAt', 'DESC');
qb.skip((page - 1) * pageSize).take(pageSize);
const [list, total] = await qb.getManyAndCount();
return { list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
async findById(id: number) {
const order = await this.orderRepo.findOne({
where: { id },
relations: ['room', 'merchant', 'user'],
});
if (!order) throw new NotFoundException('订单不存在');
return order;
}
async cancel(userId: number, id: number, reason: string) {
const order = await this.findById(id);
if (order.userId !== userId) throw new ForbiddenException('无权操作此订单');
if (!['pending_pay', 'pending_confirm', 'pending_checkin'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可取消');
}
// 恢复房态库存(所有可取消的订单都需要恢复库存)
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];
await this.calendarRepo.decrement(
{ roomId: order.roomId, date: dateStr },
'sold',
order.roomCount,
);
}
// 已支付的订单(pending_confirm、pending_checkin)取消时需退款
const needRefund = order.status !== 'pending_pay';
await this.orderRepo.update(id, {
status: 'cancelled',
cancelReason: reason,
cancelledAt: new Date(),
...(needRefund ? {
refundAmount: order.payAmount,
refundAt: new Date(),
} : {}),
});
return { message: needRefund ? '订单已取消,退款将原路返回' : '订单已取消' };
}
async confirm(merchantId: number, id: number) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'pending_confirm') {
throw new BadRequestException('当前订单状态不可确认');
}
await this.orderRepo.update(id, {
status: 'pending_checkin',
confirmedAt: new Date(),
});
return { message: '订单已确认' };
}
async reject(merchantId: number, id: number, reason: string) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'pending_confirm') {
throw new BadRequestException('当前订单状态不可拒绝');
}
await this.orderRepo.update(id, {
status: 'cancelled',
cancelReason: reason,
cancelledAt: new Date(),
});
return { message: '订单已拒绝' };
}
async checkin(merchantId: number, id: number) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'pending_checkin') {
throw new BadRequestException('当前订单状态不可办理入住');
}
await this.orderRepo.update(id, {
status: 'checked_in',
checkinAt: new Date(),
});
return { message: '已办理入住' };
}
async checkout(merchantId: number, id: number) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'checked_in') {
throw new BadRequestException('当前订单状态不可确认离店');
}
// 检查是否已到离店日期
const today = new Date().toISOString().split('T')[0];
if (order.checkOutDate > today) {
throw new BadRequestException('未到离店日期,不可确认离店');
}
await this.orderRepo.update(id, {
status: 'completed',
checkoutAt: new Date(),
});
// 增加商家销量
await this.incrementMerchantSalesCount(order.merchantId);
// 触发邀请返现
this.activityService.handleOrderCompleted(id).catch(() => {});
return { message: '已确认离店,订单已完成' };
}
async complete(id: number) {
const order = await this.findById(id);
if (order.status !== 'checked_in') {
throw new BadRequestException('当前订单状态不可完成');
}
await this.orderRepo.update(id, {
status: 'completed',
checkoutAt: new Date(),
});
// 增加商家销量
await this.incrementMerchantSalesCount(order.merchantId);
// 触发邀请返现
this.activityService.handleOrderCompleted(id).catch(() => {});
return { message: '订单已完成' };
}
// 用户申请退款(待确认、待入住状态)- 直接退款,无需审核
async refund(userId: number, id: number, reason: string) {
const order = await this.findById(id);
if (order.userId !== userId) throw new ForbiddenException('无权操作此订单');
if (!['pending_confirm', 'pending_checkin'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可申请退款');
}
// 恢复房态库存
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 },
});
if (calendar) {
await this.calendarRepo.update(calendar.id, {
sold: Math.max(0, calendar.sold - order.roomCount),
});
}
}
// 调用 RefundService 处理财务退款
try {
await this.refundService.processRefund(order, reason);
return { message: '退款成功,款项将原路返回' };
} catch (error) {
// 退款失败,恢复订单状态(库存已恢复,需要手动回滚)
throw new BadRequestException(`退款失败: ${error.message}`);
}
}
// 商家同意退款(已废弃,退款不再需要审核)
async approveRefund(merchantId: number, id: number) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'refunding') {
throw new BadRequestException('当前订单状态不可处理退款');
}
// 恢复房态库存
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 },
});
if (calendar) {
await this.calendarRepo.update(calendar.id, {
sold: Math.max(0, calendar.sold - order.roomCount),
});
}
}
await this.orderRepo.update(id, {
status: 'refunded',
refundAmount: order.payAmount,
refundAt: new Date(),
cancelledAt: new Date(),
});
return { message: '退款成功' };
}
// 商家拒绝退款(已废弃,退款不再需要审核)
async rejectRefund(merchantId: number, id: number, reason: string) {
const order = await this.findById(id);
if (order.merchantId !== merchantId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'refunding') {
throw new BadRequestException('当前订单状态不可处理退款');
}
await this.orderRepo.update(id, {
status: 'cancelled',
cancelReason: `退款被拒: ${reason}`,
cancelledAt: new Date(),
});
return { message: '已拒绝退款' };
}
async pay(userId: number, id: number, paymentMethod: 'wechat' | 'alipay' | 'balance') {
const order = await this.findById(id);
if (order.userId !== userId) throw new ForbiddenException('无权操作此订单');
if (order.status !== 'pending_pay') {
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 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 },
});
if (calendar) {
await this.calendarRepo.update(calendar.id, {
sold: calendar.sold + order.roomCount,
});
}
}
return { message: '支付成功', paymentNo };
}
async findAll(query: QueryOrderDto) {
const { page = 1, pageSize = 10, status, orderNo, startDate, endDate } = query;
const qb = this.orderRepo.createQueryBuilder('o')
.leftJoinAndSelect('o.room', 'r')
.leftJoinAndSelect('o.merchant', 'm')
.leftJoinAndSelect('o.user', 'u');
if (status) qb.andWhere('o.status = :status', { status });
if (orderNo) qb.andWhere('o.order_no LIKE :orderNo', { orderNo: `%${orderNo}%` });
if (startDate) qb.andWhere('o.created_at >= :startDate', { startDate });
if (endDate) qb.andWhere('o.created_at <= :endDate', { endDate });
qb.orderBy('o.createdAt', 'DESC');
qb.skip((page - 1) * pageSize).take(pageSize);
const [list, total] = await qb.getManyAndCount();
return { list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
// 获取服务费记录列表(只查询已完成的订单)
async getServiceFees(query: QueryOrderDto) {
const { page = 1, pageSize = 10, orderNo, startDate, endDate } = query;
const qb = this.orderRepo.createQueryBuilder('o')
.leftJoinAndSelect('o.room', 'r')
.leftJoinAndSelect('o.merchant', 'm')
.where('o.status = :status', { status: 'completed' })
.andWhere('o.service_fee > 0');
if (orderNo) qb.andWhere('o.order_no LIKE :orderNo', { orderNo: `%${orderNo}%` });
if (startDate) qb.andWhere('o.checkout_at >= :startDate', { startDate });
if (endDate) qb.andWhere('o.checkout_at <= :endDate', { endDate });
qb.orderBy('o.checkoutAt', 'DESC');
qb.skip((page - 1) * pageSize).take(pageSize);
const [list, total] = await qb.getManyAndCount();
// 计算总服务费
const totalResult = await this.orderRepo
.createQueryBuilder('o')
.select('SUM(o.service_fee)', 'totalServiceFee')
.where('o.status = :status', { status: 'completed' })
.andWhere('o.service_fee > 0')
.getRawOne();
return {
list,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
totalServiceFee: parseFloat(totalResult?.totalServiceFee || '0'),
};
}
/**
* 增加商家销量
*/
private async incrementMerchantSalesCount(merchantId: number): Promise<void> {
await this.merchantRepo.increment({ id: merchantId }, 'salesCount', 1);
}
}