From 95b9c38031f4fc548bd97081ad4da1af70e9e2f0 Mon Sep 17 00:00:00 2001 From: Matin Date: Wed, 12 Feb 2025 14:25:39 +0330 Subject: [PATCH] chore: complete create discount --- src/app.module.ts | 2 + src/common/enums/message.enum.ts | 22 +++++ .../entities/danak-service.entity.ts | 6 +- .../providers/danak-services.service.ts | 52 ++++++++++- .../discounts/DTO/create-discount.dto.ts | 44 +++++++++ src/modules/discounts/discounts.controller.ts | 54 +++++++++++ src/modules/discounts/discounts.module.ts | 15 +++ .../discounts/entities/discount.entity.ts | 38 ++++++++ .../discounts/enums/discount-type.enum.ts | 9 ++ .../discounts/providers/discounts.service.ts | 91 +++++++++++++++++++ .../repositories/discount.repository.ts | 12 +++ 11 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 src/modules/discounts/DTO/create-discount.dto.ts create mode 100644 src/modules/discounts/discounts.controller.ts create mode 100644 src/modules/discounts/discounts.module.ts create mode 100644 src/modules/discounts/entities/discount.entity.ts create mode 100644 src/modules/discounts/enums/discount-type.enum.ts create mode 100644 src/modules/discounts/providers/discounts.service.ts create mode 100644 src/modules/discounts/repositories/discount.repository.ts diff --git a/src/app.module.ts b/src/app.module.ts index 5c4e9c4..e01305e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { AuthModule } from "./modules/auth/auth.module"; import { ContactUsModule } from "./modules/contact-us/contact-us.module"; import { CriticismModule } from "./modules/criticisms/criticisms.module"; import { DanakServicesModule } from "./modules/danak-services/danak-services.module"; +import { DiscountModule } from "./modules/discounts/discounts.module"; import { InvoicesModule } from "./modules/invoices/invoices.module"; import { LearningModule } from "./modules/learnings/learning.module"; import { NotificationModule } from "./modules/notifications/notifications.module"; @@ -52,6 +53,7 @@ import { WalletsModule } from "./modules/wallets/wallets.module"; InvoicesModule, SubscriptionsModule, LearningModule, + DiscountModule, ], controllers: [], providers: [], diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 59cc2c6..439a61a 100644 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -330,3 +330,25 @@ export const enum LearningMessage { CATEGORY_MUST_BE_ID = "دسته‌بندی باید شناسه باشد", CATEGORY_MUST_BE_UUID = "دسته‌بندی باید UUID باشد", } + +export const enum DiscountMessage { + TYPE_REQUIRED = "وارد کردن نوع تخفیف الزامی است", + TYPE_INVALID = "نوع تخفیف نامعتبر است", + CALCULATION_TYPE_REQUIRED = "وارد کردن نوع محاسبه تخفیف الزامی است", + CALCULATION_TYPE_INVALID = "نوع محاسبه تخفیف نامعتبر است", + AMOUNT_REQUIRED = "وارد کردن مقدار تخفیف الزامی است", + AMOUNT_INVALID = "مقدار تخفیف باید عدد باشد", + TITLE_REQUIRED = "وارد کردن مقدار عنوان الزامی است", + TITLE_INVALID = "مقدار عنوان باید یک رشته باشد", + CODE_REQUIRED = "وارد کردن کد تخفیف الزامی است", + CODE_INVALID = "کد تخفیف نامعتبر است", + START_DATE_REQUIRED = "وارد کردن تاریخ شروع الزامی است", + START_DATE_INVALID = "تاریخ شروع نامعتبر است", + END_DATE_REQUIRED = "وارد کردن تاریخ پایان الزامی است", + END_DATE_INVALID = "تاریخ پایان نامعتبر است", + SERVICES_INVALID = "لیست خدمات باید آرایه‌ای از اعداد باشد", + SERVICES_DUPLICATE = "خدمات نباید تکراری باشند", + SERVICES_UUID_INVALID = "ایدی سرویس معتبرنیست", + NOT_FOUND = "کد تخفیف با این مشخصات یافت نشد", + CODE_EXIST = "کد تخفیف قبلا ایجاد شده است", +} diff --git a/src/modules/danak-services/entities/danak-service.entity.ts b/src/modules/danak-services/entities/danak-service.entity.ts index bbc5702..346f6b9 100644 --- a/src/modules/danak-services/entities/danak-service.entity.ts +++ b/src/modules/danak-services/entities/danak-service.entity.ts @@ -1,9 +1,10 @@ -import { Column, Entity, ManyToOne, OneToMany } from "typeorm"; +import { Column, Entity, ManyToMany, ManyToOne, OneToMany } from "typeorm"; import { DanakServiceCategory } from "./danak-service-category.entity"; import { DanakServiceImage } from "./danak-service-image.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { Announcement } from "../../announcements/entities/announcement.entity"; +import { Discount } from "../../discounts/entities/discount.entity"; import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity"; import { Ticket } from "../../tickets/entities/ticket.entity"; import { ServicesLanguage } from "../enums/services-language.enum"; @@ -63,6 +64,9 @@ export class DanakService extends BaseEntity { @OneToMany(() => SubscriptionPlan, (plan) => plan.service, { cascade: true }) subscriptionPlans: SubscriptionPlan[]; + + @ManyToMany(() => Discount, (discount) => discount.services, { nullable: true }) + discounts?: Discount[]; } // @ManyToMany(() => User, (user) => user.danakServices) diff --git a/src/modules/danak-services/providers/danak-services.service.ts b/src/modules/danak-services/providers/danak-services.service.ts index 0f8bac1..9db0258 100644 --- a/src/modules/danak-services/providers/danak-services.service.ts +++ b/src/modules/danak-services/providers/danak-services.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable } from "@nestjs/common"; -import { FindOptionsWhere, IsNull } from "typeorm"; +import { FindOptionsWhere, In, IsNull } from "typeorm"; import { ParamDto } from "../../../common/DTO/param.dto"; import { CategoryMessage, CommonMessage, ServiceMessage } from "../../../common/enums/message.enum"; @@ -200,4 +200,54 @@ export class DanakServicesService { } /******************************************** */ + + async findServicesByIds(ids: string[]) { + const services = await this.danakServicesRepository.findBy({ id: In(ids) }); + return services; + } + + /******************************************** */ + + // async getDiscountedPrice(serviceId: string) { + // const service = await this.danakServicesRepository.findOne({ + // where: { + // id: serviceId, + // }, + // relations: ["discount"], + // }); + // if (!service) throw new BadRequestException(ServiceMessage.SERVICE_NOT_FOUND_BY_ID); + + // const now = new Date(); + + // const serviceDiscount = service.discounts.filter( + // (d) => d.type === DiscountType.DIRECT && d.isActive && d.startDate <= now && d.endDate >= now, + // ); + + // const globalDiscounts = await this.discountRepository + // .createQueryBuilder("discount") + // .leftJoin("discount.services", "service") + // .where("discount.type = :discountType", { discountType: DiscountType.DIRECT }) + // .andWhere("discount.isActive = true") + // .andWhere("discount.startDate <= :now", { now }) + // .andWhere("discount.endDate >= :now", { now }) + // .andWhere("service.id IS NULL") + // .getMany(); + + // const applicableDiscounts = [...serviceDiscount, ...globalDiscounts]; + // if (applicableDiscounts.length === 0) return service.; + // let maxDiscountValue = 0; + // for (const discount of applicableDiscounts) { + // let discountValue = 0; + // if (discount.calculationType === DiscountCalculationType.FIXED) { + // discountValue = discount.amount; + // } else if (discount.calculationType === DiscountCalculationType.PERCENTAGE) { + // discountValue = service.price * (discount.amount / 100); + // } + // if (discountValue > maxDiscountValue) { + // maxDiscountValue = discountValue; + // } + // } + // const discountedPrice = service.price - maxDiscountValue; + // return discountedPrice > 0 ? discountedPrice : 0; + // } } diff --git a/src/modules/discounts/DTO/create-discount.dto.ts b/src/modules/discounts/DTO/create-discount.dto.ts new file mode 100644 index 0000000..a75bf68 --- /dev/null +++ b/src/modules/discounts/DTO/create-discount.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ArrayUnique, IsArray, IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID } from "class-validator"; + +import { DiscountMessage } from "../../../common/enums/message.enum"; +import { DiscountCalculationType, DiscountType } from "../enums/discount-type.enum"; + +export class CreateDiscountDto { + @ApiProperty({ description: "title of discount", type: String, example: "discount 1" }) + @IsNotEmpty({ message: DiscountMessage.TITLE_REQUIRED }) + @IsString({ message: DiscountMessage.TITLE_INVALID }) + title: string; + + @ApiProperty({ description: "discount type", enum: DiscountType, example: DiscountType.CODE }) + @IsNotEmpty({ message: DiscountMessage.TYPE_REQUIRED }) + @IsEnum(DiscountType, { message: DiscountMessage.TYPE_INVALID }) + type: DiscountType; + + @ApiProperty({ description: "calculation type", enum: DiscountCalculationType, example: DiscountCalculationType.FIXED }) + @IsNotEmpty({ message: DiscountMessage.CALCULATION_TYPE_REQUIRED }) + @IsEnum(DiscountCalculationType, { message: DiscountMessage.CALCULATION_TYPE_INVALID }) + calculationType: DiscountCalculationType; + + @ApiProperty({ description: "amount of discount", type: Number, example: 100 }) + @IsNotEmpty({ message: DiscountMessage.AMOUNT_REQUIRED }) + @IsNumber({}, { message: DiscountMessage.AMOUNT_INVALID }) + amount: number; + + @ApiProperty({ description: "start discount date", type: String, format: "date-time", example: "2025-01-01T00:00:00Z" }) + @IsNotEmpty({ message: DiscountMessage.START_DATE_REQUIRED }) + @IsDateString({}, { message: DiscountMessage.START_DATE_INVALID }) + startDate: string; + + @ApiProperty({ description: "end discount date", type: String, format: "date-time", example: "2025-12-31T23:59:59Z" }) + @IsNotEmpty({ message: DiscountMessage.END_DATE_REQUIRED }) + @IsDateString({}, { message: DiscountMessage.END_DATE_INVALID }) + endDate: string; + + @ApiPropertyOptional({ description: "لیست آیدی‌های خدمات", type: [String], example: ["f47ac10b-58cc-4372-a567-0e02b2c3d479"] }) + @IsOptional() + @IsArray({ message: DiscountMessage.SERVICES_INVALID }) + @ArrayUnique({ message: DiscountMessage.SERVICES_DUPLICATE }) + @IsUUID("4", { each: true, message: DiscountMessage.SERVICES_UUID_INVALID }) + services?: string[]; +} diff --git a/src/modules/discounts/discounts.controller.ts b/src/modules/discounts/discounts.controller.ts new file mode 100644 index 0000000..c19ee09 --- /dev/null +++ b/src/modules/discounts/discounts.controller.ts @@ -0,0 +1,54 @@ +import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { CreateDiscountDto } from "./DTO/create-discount.dto"; +import { DiscountService } from "./providers/discounts.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { Roles } from "../../common/decorators/roles.decorator"; +import { ParamDto } from "../../common/DTO/param.dto"; +import { RoleEnum } from "../users/enums/role.enum"; + +@ApiTags("discounts") +@AuthGuards() +@Controller("discounts") +export class DiscountController { + constructor(private readonly discountService: DiscountService) {} + + //************************************ */ + + @ApiOperation({ summary: "Create a new discount" }) + @Roles(RoleEnum.ADMIN) + @Post() + async create(@Body() createDiscountDto: CreateDiscountDto) { + return this.discountService.create(createDiscountDto); + } + + //************************************ */ + + @ApiOperation({ summary: "Retrieve all discounts" }) + @Roles(RoleEnum.ADMIN) + @Get() + async findAll() { + return this.discountService.findAll(); + } + + //************************************ */ + + @ApiOperation({ summary: "Retrieve a discount by ID" }) + @Roles(RoleEnum.ADMIN) + @Get(":id") + async findOne(@Param() paramDto: ParamDto) { + return this.discountService.findOne(paramDto.id); + } + + //************************************ */ + + @ApiOperation({ summary: "Toggle active/deactive discount" }) + @Roles(RoleEnum.ADMIN) + @Patch(":id/toggle") + async toggleActive(@Param() paramDto: ParamDto) { + return this.discountService.toggleActive(paramDto.id); + } + + //************************************ */ +} diff --git a/src/modules/discounts/discounts.module.ts b/src/modules/discounts/discounts.module.ts new file mode 100644 index 0000000..07fc487 --- /dev/null +++ b/src/modules/discounts/discounts.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { DiscountController } from "./discounts.controller"; +import { Discount } from "./entities/discount.entity"; +import { DiscountService } from "./providers/discounts.service"; +import { DiscountRepository } from "./repositories/discount.repository"; +import { DanakServicesModule } from "../danak-services/danak-services.module"; + +@Module({ + imports: [TypeOrmModule.forFeature([Discount]), DanakServicesModule], + providers: [DiscountService, DiscountRepository], + controllers: [DiscountController], +}) +export class DiscountModule {} diff --git a/src/modules/discounts/entities/discount.entity.ts b/src/modules/discounts/entities/discount.entity.ts new file mode 100644 index 0000000..3d55bfa --- /dev/null +++ b/src/modules/discounts/entities/discount.entity.ts @@ -0,0 +1,38 @@ +import Decimal from "decimal.js"; +import { Column, Entity, JoinTable, ManyToMany } from "typeorm"; + +import { BaseEntity } from "../../../common/entities/base.entity"; +import { DecimalTransformer } from "../../../common/transformers/decimal.transformer"; +import { DanakService } from "../../danak-services/entities/danak-service.entity"; +import { DiscountCalculationType, DiscountType } from "../enums/discount-type.enum"; + +@Entity() +export class Discount extends BaseEntity { + @Column({ type: "varchar", length: 200, nullable: false }) + title: string; + + @Column({ type: "enum", enum: DiscountType }) + type: DiscountType; + + @Column({ type: "enum", enum: DiscountCalculationType }) + calculationType: DiscountCalculationType; + + @Column({ type: "decimal", precision: 10, scale: 2, nullable: false, transformer: new DecimalTransformer() }) + amount: Decimal; + + @Column({ type: "boolean", default: true }) + isActive: boolean; + + @Column({ nullable: true }) + code?: string; + + @Column({ type: "timestamptz" }) + startDate: Date; + + @Column({ type: "timestamptz" }) + endDate: Date; + + @ManyToMany(() => DanakService, (danakService) => danakService.discounts, { nullable: true }) + @JoinTable() + services?: DanakService[]; +} diff --git a/src/modules/discounts/enums/discount-type.enum.ts b/src/modules/discounts/enums/discount-type.enum.ts new file mode 100644 index 0000000..91a6669 --- /dev/null +++ b/src/modules/discounts/enums/discount-type.enum.ts @@ -0,0 +1,9 @@ +export enum DiscountType { + DIRECT = "DIRECT", + CODE = "CODE", +} + +export enum DiscountCalculationType { + FIXED = "FIXED", + PERCENTAGE = "PERCENTAGE", +} diff --git a/src/modules/discounts/providers/discounts.service.ts b/src/modules/discounts/providers/discounts.service.ts new file mode 100644 index 0000000..bf717b4 --- /dev/null +++ b/src/modules/discounts/providers/discounts.service.ts @@ -0,0 +1,91 @@ +import { randomBytes } from "node:crypto"; + +import { BadRequestException, Injectable } from "@nestjs/common"; + +import { CommonMessage, DiscountMessage } from "../../../common/enums/message.enum"; +import { DanakServicesService } from "../../danak-services/providers/danak-services.service"; +import { CreateDiscountDto } from "../DTO/create-discount.dto"; +import { DiscountRepository } from "../repositories/discount.repository"; + +@Injectable() +export class DiscountService { + constructor( + private readonly discountRepository: DiscountRepository, + private readonly danakServicesService: DanakServicesService, + ) {} + + /******************************************** */ + + async create(createDiscountDto: CreateDiscountDto) { + const { services, ...discountData } = createDiscountDto; + + const code = await this.generateUniqueCouponCode(); + + const discount = this.discountRepository.create({ + ...discountData, + code, + }); + + if (services && services.length) { + const foundServices = await this.danakServicesService.findServicesByIds(services); + discount.services = foundServices; + } + await this.discountRepository.save(discount); + + return { + message: CommonMessage.CREATED, + discount, + }; + } + + /******************************************** */ + + async findAll() { + const services = await this.discountRepository.find({ relations: ["services"] }); + return { services }; + } + + /******************************************** */ + + async findOne(id: string) { + const discount = await this.discountRepository.findOne({ + where: { id }, + relations: ["services"], + }); + if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND); + return { discount }; + } + + /******************************************** */ + + async toggleActive(id: string) { + const discount = await this.discountRepository.findOne({ where: { id } }); + if (!discount) { + throw new BadRequestException(DiscountMessage.NOT_FOUND); + } + + discount.isActive = !discount.isActive; + + const updatedDiscount = await this.discountRepository.save(discount); + + return { + message: CommonMessage.UPDATE_SUCCESS, + updatedDiscount, + }; + } + + /******************************************** */ + + async generateUniqueCouponCode(): Promise { + const code = this.generateCouponCode(); + const exists = await this.discountRepository.findOne({ where: { code } }); + if (exists) return this.generateUniqueCouponCode(); + return code; + } + + /******************************************** */ + + private generateCouponCode(length = 8) { + return randomBytes(length).toString("hex").slice(0, length).toUpperCase(); + } +} diff --git a/src/modules/discounts/repositories/discount.repository.ts b/src/modules/discounts/repositories/discount.repository.ts new file mode 100644 index 0000000..a8f64d1 --- /dev/null +++ b/src/modules/discounts/repositories/discount.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Discount } from "../entities/discount.entity"; + +@Injectable() +export class DiscountRepository extends Repository { + constructor(@InjectRepository(Discount) discountRepository: Repository) { + super(discountRepository.target, discountRepository.manager, discountRepository.queryRunner); + } +}