feat: 迭代

This commit is contained in:
2026-05-08 20:16:34 +08:00
parent 350b9a94a1
commit da0f304a87
49 changed files with 3958 additions and 1004 deletions
@@ -19,9 +19,15 @@ export class Merchant {
@Column({ length: 500, default: '', comment: '店铺Logo' })
logo: string;
@Column({ name: 'hotel_images', type: 'text', nullable: true, comment: '酒店照片,多张URL用逗号分隔' })
hotelImages: string;
@Column({ type: 'text', nullable: true, comment: '店铺描述' })
description: string;
@Column({ name: 'store_license', length: 255, nullable: true, comment: '门店营业执照' })
storeLicense: string;
@Column({ length: 20, comment: '联系电话' })
phone: string;
@@ -46,6 +52,22 @@ export class Merchant {
@Column({ name: 'business_license', length: 500, default: '', comment: '营业执照图片' })
businessLicense: string;
@Index()
@Column({ name: 'contract_type', length: 20, default: 'personal', comment: '签约类型:personal-个人签约,company-公司签约' })
contractType: string;
@Column({ name: 'id_card_front', length: 255, nullable: true, comment: '身份证正面(个人签约)' })
idCardFront: string;
@Column({ name: 'id_card_back', length: 255, nullable: true, comment: '身份证反面(个人签约)' })
idCardBack: string;
@Column({ name: 'legal_id_card_front', length: 255, nullable: true, comment: '法人身份证正面(公司签约)' })
legalIdCardFront: string;
@Column({ name: 'legal_id_card_back', length: 255, nullable: true, comment: '法人身份证反面(公司签约)' })
legalIdCardBack: string;
@Column({ name: 'license_no', length: 50, default: '', comment: '营业执照编号' })
licenseNo: string;
@@ -65,6 +87,10 @@ export class Merchant {
@Column({ name: 'wallet_balance', type: 'decimal', precision: 10, scale: 2, unsigned: true, default: 0, comment: '待提现余额' })
walletBalance: number;
@Index()
@Column({ name: 'account_type', length: 20, default: 'company', comment: '账户类型:company-对公账户,personal-对私账户' })
accountType: string;
@Column({ name: 'bank_name', length: 100, default: '', comment: '开户银行' })
bankName: string;
@@ -74,6 +100,18 @@ export class Merchant {
@Column({ name: 'account_name', length: 50, default: '', comment: '账户名' })
accountName: string;
@Column({ name: 'bank_branch', length: 100, nullable: true, comment: '支行信息(对私账户)' })
bankBranch: string;
@Column({ name: 'bank_license', length: 255, nullable: true, comment: '开户营业执照(对公账户)' })
bankLicense: string;
@Column({ name: 'account_id_card_front', length: 255, nullable: true, comment: '开户人身份证正面(对私账户)' })
accountIdCardFront: string;
@Column({ name: 'account_id_card_back', length: 255, nullable: true, comment: '开户人身份证反面(对私账户)' })
accountIdCardBack: string;
@Index()
@Column({ type: 'decimal', precision: 2, scale: 1, unsigned: true, default: 5.0, comment: '评分' })
rating: number;
+2 -2
View File
@@ -46,8 +46,8 @@ export class Review {
@Column({ name: 'is_anonymous', type: 'boolean', default: false, comment: '是否匿名' })
isAnonymous: boolean;
@Column({ type: 'enum', enum: ['visible', 'hidden'], default: 'visible', comment: '状态' })
status: 'visible' | 'hidden';
@Column({ type: 'enum', enum: ['pending', 'visible', 'hidden'], default: 'pending', comment: '状态pending-待审核, visible-已通过, hidden-已隐藏' })
status: 'pending' | 'visible' | 'hidden';
@CreateDateColumn({ name: 'created_at', comment: '创建时间' })
createdAt: Date;
@@ -27,9 +27,42 @@ export class ApplyMerchantDto {
@IsString()
address?: string;
@IsOptional()
@IsString()
@IsNotEmpty({ message: '营业执照不能为空' })
businessLicense: string;
description?: string;
@IsString()
@IsNotEmpty({ message: '门店营业执照不能为空' })
storeLicense: string;
@IsOptional()
@IsString()
hotelImages?: string;
@IsString()
@IsNotEmpty({ message: '签约类型不能为空' })
@IsEnum(['personal', 'company'], { message: '签约类型必须是personal或company' })
contractType: string;
@IsOptional()
@IsString()
idCardFront?: string;
@IsOptional()
@IsString()
idCardBack?: string;
@IsOptional()
@IsString()
legalIdCardFront?: string;
@IsOptional()
@IsString()
legalIdCardBack?: string;
@IsOptional()
@IsString()
businessLicense?: string;
@IsOptional()
@IsString()
@@ -39,9 +72,38 @@ export class ApplyMerchantDto {
@IsString()
legalPerson?: string;
@IsString()
@IsNotEmpty({ message: '账户类型不能为空' })
@IsEnum(['company', 'personal'], { message: '账户类型必须是company或personal' })
accountType: string;
@IsOptional()
@IsString()
description?: string;
accountName?: string;
@IsString()
@IsNotEmpty({ message: '银行账号不能为空' })
bankAccount: string;
@IsString()
@IsNotEmpty({ message: '开户银行不能为空' })
bankName: string;
@IsOptional()
@IsString()
bankBranch?: string;
@IsOptional()
@IsString()
bankLicense?: string;
@IsOptional()
@IsString()
accountIdCardFront?: string;
@IsOptional()
@IsString()
accountIdCardBack?: string;
}
export class UpdateMerchantDto {
@@ -77,3 +77,11 @@ export class ConfirmOrderDto {
@IsString()
rejectReason?: string;
}
export class PayOrderDto {
@IsNumber()
orderId: number;
@IsEnum(['wechat', 'alipay', 'balance'])
paymentMethod: 'wechat' | 'alipay' | 'balance';
}
@@ -32,4 +32,10 @@ export class OrderAdminController {
async findById(@Param('id') id: number) {
return this.orderService.findById(id);
}
@Get('service-fees/list')
@ApiOperation({ summary: '服务费记录列表' })
async getServiceFees(@Query() query: QueryOrderDto) {
return this.orderService.getServiceFees(query);
}
}
@@ -1,6 +1,7 @@
import {
Controller,
Get,
Post,
Put,
Param,
Body,
@@ -36,6 +37,21 @@ export class OrderSellerController {
return this.orderService.findByMerchant(merchant.id, query);
}
@Post('detail')
@ApiOperation({ summary: '商家订单详情' })
async getDetail(
@CurrentSeller('sub') sellerId: number,
@Body('orderId') orderId: number,
) {
const merchant = await this.merchantService.findBySellerId(sellerId);
if (!merchant) throw new NotFoundException('店铺不存在');
const order = await this.orderService.findById(orderId);
if (order.merchantId !== merchant.id) {
throw new NotFoundException('订单不存在');
}
return order;
}
@Put(':id/confirm')
@ApiOperation({ summary: '确认订单' })
async confirm(
@@ -69,4 +85,15 @@ export class OrderSellerController {
if (!merchant) throw new NotFoundException('店铺不存在');
return this.orderService.checkin(merchant.id, id);
}
@Put(':id/checkout')
@ApiOperation({ summary: '确认离店' })
async checkout(
@CurrentSeller('sub') sellerId: number,
@Param('id') id: number,
) {
const merchant = await this.merchantService.findBySellerId(sellerId);
if (!merchant) throw new NotFoundException('店铺不存在');
return this.orderService.checkout(merchant.id, id);
}
}
@@ -15,6 +15,7 @@ import { CurrentUser } from '@/common/decorators/current-user.decorator';
import {
CreateOrderDto,
QueryOrderDto,
PayOrderDto,
} from './dto/order.dto';
@ApiTags('订单(用户)')
@@ -57,4 +58,23 @@ export class OrderUserController {
) {
return this.orderService.cancel(userId, id, reason);
}
@Post('pay')
@ApiOperation({ summary: '支付订单' })
async pay(
@CurrentUser('sub') userId: number,
@Body() dto: PayOrderDto,
) {
return this.orderService.pay(userId, dto.orderId, dto.paymentMethod);
}
@Put(':id/refund')
@ApiOperation({ summary: '申请退款' })
async refund(
@CurrentUser('sub') userId: number,
@Param('id') id: number,
@Body('reason') reason: string,
) {
return this.orderService.refund(userId, id, reason);
}
}
@@ -208,6 +208,30 @@ export class OrderService {
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(),
});
// 触发邀请返现
this.activityService.handleOrderCompleted(id).catch(() => {});
return { message: '已确认离店,订单已完成' };
}
async complete(id: number) {
const order = await this.findById(id);
if (order.status !== 'checked_in') {
@@ -359,4 +383,40 @@ export class OrderService {
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'),
};
}
}
@@ -2,6 +2,11 @@ import { IsNotEmpty, IsOptional, IsNumber, IsString, IsBoolean, Min, Max, IsInt
import { Type } from 'class-transformer';
export class CreateReviewDto {
@IsNotEmpty()
@IsInt()
@Type(() => Number)
orderId: number;
@IsNumber()
@Type(() => Number)
rating: number;
@@ -0,0 +1,34 @@
import {
Controller,
Get,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ReviewService } from './review.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
@Controller('seller/reviews')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('seller')
export class ReviewSellerController {
constructor(private readonly reviewService: ReviewService) {}
@Get()
async getSellerReviews(
@Request() req,
@Query('page') page: string = '1',
@Query('limit') limit: string = '10',
@Query('roomId') roomId?: string,
) {
const sellerId = req.user.sub;
return this.reviewService.getSellerReviews(
sellerId,
parseInt(page),
parseInt(limit),
roomId ? parseInt(roomId) : undefined,
);
}
}
@@ -19,14 +19,13 @@ export class ReviewUserController {
constructor(private readonly reviewService: ReviewService) {}
// 提交评价(需登录)
@Post('order/:orderId')
@Post('order')
@UseGuards(JwtAuthGuard)
async create(
@Request() req,
@Param('orderId') orderId: number,
@Body() dto: CreateReviewDto,
) {
return this.reviewService.create(req.user.userId, Number(orderId), dto);
return this.reviewService.create(req.user.sub, Number(dto.orderId), dto);
}
// 查询评价列表(公开)
@@ -7,10 +7,11 @@ import { Room } from '@/entities/room.entity';
import { ReviewService } from './review.service';
import { ReviewUserController } from './review-user.controller';
import { ReviewAdminController } from './review-admin.controller';
import { ReviewSellerController } from './review-seller.controller';
@Module({
imports: [TypeOrmModule.forFeature([Review, Order, Merchant, Room])],
controllers: [ReviewUserController, ReviewAdminController],
controllers: [ReviewUserController, ReviewSellerController, ReviewAdminController],
providers: [ReviewService],
exports: [ReviewService],
})
@@ -47,7 +47,7 @@ export class ReviewService {
content: dto.content,
images: dto.images,
isAnonymous: dto.isAnonymous || false,
status: 'visible',
status: 'pending',
});
await this.reviewRepo.save(review);
@@ -83,6 +83,37 @@ export class ReviewService {
return this.reviewRepo.findOne({ where: { orderId } });
}
// 商家查询自己的评价列表(只显示已审核通过的)
async getSellerReviews(sellerId: number, page: number = 1, limit: number = 10, roomId?: number) {
// 先查询商家信息
const merchant = await this.merchantRepo.findOne({ where: { sellerId } });
if (!merchant) throw new NotFoundException('商家不存在');
const qb = this.reviewRepo
.createQueryBuilder('r')
.leftJoinAndSelect('r.order', 'o')
.where('r.merchant_id = :merchantId', { merchantId: merchant.id })
.andWhere('r.status = :status', { status: 'visible' });
if (roomId) {
qb.andWhere('r.room_id = :roomId', { roomId });
}
qb.orderBy('r.createdAt', 'DESC');
const safePage = Number(page) || 1;
const safeLimit = Number(limit) || 10;
qb.skip((safePage - 1) * safeLimit).take(safeLimit);
const [list, total] = await qb.getManyAndCount();
return {
list,
total,
page: safePage,
pageSize: safeLimit,
totalPages: Math.ceil(total / safeLimit)
};
}
// 管理员评价列表
async findAllForAdmin(query: QueryReviewDto) {
const { page = 1, pageSize = 10, auditStatus } = query;