535 lines
18 KiB
TypeScript
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);
|
|
}
|
|
}
|