From c05e9187df170bf91fd6d1640a574a24c455eeed Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Thu, 19 Feb 2026 11:20:15 +0330 Subject: [PATCH] order --- .../invoice/entities/invoice-item.entity.ts | 8 +- .../order/controllers/order.controller.ts | 216 ++++++ src/modules/order/crone/order.crone.ts | 17 + src/modules/order/dto/assign-designer.dto.ts | 11 + src/modules/order/dto/create-order.dto.ts | 146 ++++ .../order/dto/create-print-form.dto.ts | 16 + src/modules/order/dto/find-order-items.dto.ts | 53 ++ src/modules/order/dto/find-orders.dto.ts | 80 +++ .../order/dto/update-order-item.dto.ts | 7 + src/modules/order/dto/update-order.dto.ts | 14 + src/modules/order/entities/order.entity.ts | 55 ++ src/modules/order/events/order.events.ts | 21 + .../order/interface/order.interface.ts | 47 ++ .../order/listeners/order.listeners.ts | 212 ++++++ src/modules/order/order.module.ts | 39 ++ src/modules/order/providers/order.service.ts | 636 ++++++++++++++++++ .../repositories/order-item.repository.ts | 88 +++ .../order/repositories/order.repository.ts | 147 ++++ 18 files changed, 1811 insertions(+), 2 deletions(-) create mode 100644 src/modules/order/controllers/order.controller.ts create mode 100644 src/modules/order/crone/order.crone.ts create mode 100644 src/modules/order/dto/assign-designer.dto.ts create mode 100644 src/modules/order/dto/create-order.dto.ts create mode 100644 src/modules/order/dto/create-print-form.dto.ts create mode 100644 src/modules/order/dto/find-order-items.dto.ts create mode 100644 src/modules/order/dto/find-orders.dto.ts create mode 100644 src/modules/order/dto/update-order-item.dto.ts create mode 100644 src/modules/order/dto/update-order.dto.ts create mode 100644 src/modules/order/entities/order.entity.ts create mode 100644 src/modules/order/events/order.events.ts create mode 100644 src/modules/order/interface/order.interface.ts create mode 100644 src/modules/order/listeners/order.listeners.ts create mode 100644 src/modules/order/order.module.ts create mode 100644 src/modules/order/providers/order.service.ts create mode 100644 src/modules/order/repositories/order-item.repository.ts create mode 100644 src/modules/order/repositories/order.repository.ts diff --git a/src/modules/invoice/entities/invoice-item.entity.ts b/src/modules/invoice/entities/invoice-item.entity.ts index 377d409..4aa5275 100644 --- a/src/modules/invoice/entities/invoice-item.entity.ts +++ b/src/modules/invoice/entities/invoice-item.entity.ts @@ -1,7 +1,8 @@ -import { Entity, ManyToOne, OptionalProps, Property } from '@mikro-orm/core'; +import { Collection, Entity, ManyToOne, OptionalProps, Property,OneToMany } from '@mikro-orm/core'; import { Product } from 'src/modules/product/entities/product.entity'; import { BaseEntity } from 'src/common/entities/base.entity'; import { Invoice } from './invoice.entity'; +import { Order } from 'src/modules/order/entities/order.entity'; @Entity({ tableName: 'invoice_items' }) @@ -14,11 +15,14 @@ export class InvoiceItem extends BaseEntity { @ManyToOne(() => Product) product!: Product; + @OneToMany(() => Order, order => order.invoiceItem) + orders = new Collection(this); + @Property({ type: 'int' }) quantity!: number; - @Property({ type: 'int'}) + @Property({ type: 'int' }) unitPrice: number; @Property({ diff --git a/src/modules/order/controllers/order.controller.ts b/src/modules/order/controllers/order.controller.ts new file mode 100644 index 0000000..450c660 --- /dev/null +++ b/src/modules/order/controllers/order.controller.ts @@ -0,0 +1,216 @@ +import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body, Delete } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { OrderService } from '../providers/order.service'; +import { AuthGuard } from '../../auth/guards/auth.guard'; +import { UserId } from '../../../common/decorators/user-id.decorator'; +import { FindOrdersDto } from '../dto/find-orders.dto'; +import { + CreateOrderAsUSerDto, CreateOrderItemAsUserDto, + CreateOrderItemDtoAsAdmin, CreateOrderAsAdminDto +} from '../dto/create-order.dto'; +import { AdminId } from 'src/common/decorators/admin-id.decorator'; +import { AssignDesignerDto } from '../dto/assign-designer.dto'; +import { UpdateOrderItemDtoAsAdmin, UpdateOrderItemDtoAsUser } from '../dto/update-order-item.dto'; +import { UpdateOrderAsAdminDto } from '../dto/update-order.dto'; +import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard'; +import { CreatePrintFormDto } from '../dto/create-print-form.dto'; +import { FindOrderItemsDto } from '../dto/find-order-items.dto'; +import { Permissions } from 'src/common/decorators/permissions.decorator'; +import { PermissionEnum } from 'src/common/enums/permission.enum'; + +@ApiTags('orders') +@ApiBearerAuth() +@Controller() +export class OrderController { + constructor(private readonly orderService: OrderService) { } + + // ================== Public =================== + + @Post('public/orders') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'create order ' }) + createOrder(@UserId() userId: string, @Body() body: CreateOrderAsUSerDto) { + return this.orderService.createOrderAsUser(userId, body); + } + + @Delete('public/orders/:id') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Delete order ' }) + deleteOrder(@Param('id') orderId: string, @UserId() userId: string) { + return this.orderService.removeOrderAsUser(userId, orderId); + } + + @Get('public/orders') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get all orders with pagination and filters' }) + findAll(@Query() dto: FindOrdersDto, @UserId() userId: string) { + return this.orderService.findOrdersAsUser(userId, dto); + } + + @Get('public/orders/:id') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'order detail' }) + orderDetail(@Param('id') orderId: string, @UserId() userId: string) { + return this.orderService.getOrderAsUser(userId, orderId); + } + + @Post('public/orders/:id/item') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'add item to order ' }) + addOrderItem(@Param('id') orderId: string, @UserId() userId: string, @Body() body: CreateOrderItemAsUserDto) { + return this.orderService.addOrderItemAsUser(userId, orderId, body); + } + + + @Patch('public/orders/:id/item/:itemId') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Update order item ' }) + updateOrderItem(@Param('id') orderId: string, @UserId() userId: string, @Param('itemId') itemId: string, @Body() body: UpdateOrderItemDtoAsUser) { + return this.orderService.updateOrderItemAsUser(userId, orderId, itemId, body); + } + + + @Patch('public/orders/:orderId/items/:orderItemId/confirm') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Confirm Invoice Item ' }) + confirmOrderItem( + @Param('orderId') orderId: string, + @Param('orderItemId') orderItemId: string, + @UserId() userId: string + ) { + return this.orderService.confirmOrderItem(userId, orderId, orderItemId); + } + + @Delete('public/orders/:orderId/items/:orderItemId') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Remove order Item ' }) + removeOrderItem( + @Param('orderId') orderId: string, + @Param('orderItemId') orderItemId: string, + @UserId() userId: string + ) { + return this.orderService.removeOrderItemAsUser(userId, orderId, orderItemId); + } + + /*========================== Admin Routes =====================*/ + + @Post('admin/orders') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.CREATE_ORDER) + @ApiOperation({ summary: 'create order ' }) + createOrderAsAdmin(@AdminId() adminId: string, @Body() body: CreateOrderAsAdminDto) { + return this.orderService.createOrderAsAdmin(adminId, body); + } + + @Get('admin/orders/request') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.VIEW_REQUESTS) + @ApiOperation({ summary: 'Get all order requests with pagination and filters' }) + findOrderRequestAsAdmin(@Query() dto: FindOrdersDto) { + return this.orderService.findOrderRequestsAsAdmin(dto); + } + + // this route permission is handled inside service + @Get('admin/orders/invoiced') + @UseGuards(AdminAuthGuard) + @ApiOperation({ summary: 'Get all invoiced orders with pagination and filters' }) + findInvoicedOrdersAsAdmin(@Query() dto: FindOrdersDto, @AdminId() adminId: string) { + return this.orderService.findInvoicedOrdersAsAdmin(adminId, dto); + } + + @Get('admin/orders/:id') + @UseGuards(AdminAuthGuard) + @ApiOperation({ summary: 'order detail' }) + orderDetailAsAdmin(@Param('id') orderId: string) { + return this.orderService.findOrderOrFail(orderId); + } + + @Patch('admin/orders/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.UPDATE_ORDER) + @ApiOperation({ summary: 'Update Order ' }) + updateOrder(@Param('id') id: string, @Body() dto: UpdateOrderAsAdminDto) { + return this.orderService.updateOrderAsAdmin2(id, dto); + } + + @Delete('admin/orders/:orderId') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.REMOVE_ORDER) + @ApiOperation({ summary: 'Hard Delete Order ' }) + hardDelete(@Param('orderId') orderId: string) { + return this.orderService.hardDeleteOrder(orderId); + } + + // =============== Order Item ================ + + @Get('admin/orders/item/print') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.VIEW_All_ORDERS) + @ApiOperation({ summary: 'get print orders' }) + getPrintOrders(@Body() dto: FindOrderItemsDto) { + return this.orderService.findPrintOrderItemsAsAdmin(dto); + } + + @Get('admin/orders/item/:id') + @UseGuards(AdminAuthGuard) + @ApiOperation({ summary: 'order item detail' }) + getOrderItem(@Param('id') itemId: string) { + return this.orderService.findOrderItemOrFailByItemId(itemId); + } + + + @Post('admin/orders/:orderId/item/:itemId/designer') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.ASSIGN_DESIGNER) + @ApiOperation({ summary: 'Assign Order Designer ' }) + assignDesigner(@Param('orderId') orderId: string, @Param('itemId') itemId: string, + @Body() body: AssignDesignerDto) { + return this.orderService.assignDesigner(orderId, itemId, body.designerId); + } + + @Patch('admin/orders/:id/item/:itemId/print') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.CREATE_ORDER_PRINT) + @ApiOperation({ summary: 'Create Print form for order item' }) + createPrintForm(@Param('id') id: string, @Param('itemId') itemId: string, @Body() dto: CreatePrintFormDto) { + return this.orderService.createPrintForm(id, itemId, dto); + } + + // @Post('admin/orders/:orderId/invoice') + // @UseGuards(AdminAuthGuard) + // @ApiOperation({ summary: 'Create Order invoice ' }) + // createInvoice(@Param('orderId') orderId: string) { + // return this.orderService.createInvoice(orderId); + // } + + + @Delete('admin/orders/:id/items/:itemId') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.UPDATE_ORDER) + @ApiOperation({ summary: 'Remove order Item ' }) + removeOrderItemAdAdmin( + @Param('id') orderId: string, + @Param('itemId') itemId: string, + ) { + return this.orderService.removeOrderItemAsAdmin(orderId, itemId); + } + + @Post('admin/orders/:id/item') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.UPDATE_ORDER) + @ApiOperation({ summary: 'add item to order ' }) + addOrderItemAsAdmin(@Param('id') orderId: string, @Body() body: CreateOrderItemDtoAsAdmin) { + return this.orderService.addOrderItemAsAdmin(orderId, body); + } + + + @Patch('admin/orders/:id/item/:itemId') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.UPDATE_ORDER) + @ApiOperation({ summary: 'Update order item' }) + updateOrderItemAsAdmin(@Param('id') orderId: string, @Param('itemId') itemId: string, + @Body() body: UpdateOrderItemDtoAsAdmin) { + return this.orderService.updateOrderItemAsAdmin(orderId, itemId, body); + } + +} diff --git a/src/modules/order/crone/order.crone.ts b/src/modules/order/crone/order.crone.ts new file mode 100644 index 0000000..0013f67 --- /dev/null +++ b/src/modules/order/crone/order.crone.ts @@ -0,0 +1,17 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + + +@Injectable() +export class OrdersCrone { + private readonly logger = new Logger(OrdersCrone.name); + + constructor( + private readonly em: EntityManager, + private readonly eventEmitter: EventEmitter2, + ) { } + + +} diff --git a/src/modules/order/dto/assign-designer.dto.ts b/src/modules/order/dto/assign-designer.dto.ts new file mode 100644 index 0000000..9894459 --- /dev/null +++ b/src/modules/order/dto/assign-designer.dto.ts @@ -0,0 +1,11 @@ +import { + IsString +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AssignDesignerDto { + @IsString() + @ApiProperty() + designerId: string; +} + diff --git a/src/modules/order/dto/create-order.dto.ts b/src/modules/order/dto/create-order.dto.ts new file mode 100644 index 0000000..850e077 --- /dev/null +++ b/src/modules/order/dto/create-order.dto.ts @@ -0,0 +1,146 @@ +import { + IsString, + IsInt, IsArray, IsNumber, + IsNotEmpty, + ArrayMinSize, + IsOptional, + IsBoolean, + IsEnum, + IsDateString +} from 'class-validator'; +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IAttachment, IField, OrderItemStatusEnum } from '../interface/order.interface'; + + +export class CreateOrderItemAsUserDto { + @IsString() + @ApiProperty() + productId: string; + + @ApiProperty() + @IsNumber() + quantity: number + + @ApiProperty({ enum: OrderItemStatusEnum }) + @IsEnum(() => OrderItemStatusEnum) + status: OrderItemStatusEnum + + @ApiProperty() + @IsArray() + attributes: IField[] + + @ApiPropertyOptional({ example: 'توضیحات' }) + @IsString() + description: string + + @ApiPropertyOptional({ example: [] }) + @IsArray() + attachments: { url: string, type: string }[] + + +} + +export class CreateOrderItemDtoAsAdmin extends CreateOrderItemAsUserDto { + // @ApiPropertyOptional({ example: [] }) + // @IsArray() + // printAttributes?: IField[] + + // @ApiPropertyOptional({ example: [] }) + // @IsArray() + // printAttachments?: IAttachment[] + + @IsInt() + @ApiProperty() + designerId: string; + + @ApiProperty() + @IsNumber() + unitPrice: number + + @ApiProperty() + @IsNumber() + discount: number + + @ApiPropertyOptional() + @IsString() + adminDescription: string + + @ApiPropertyOptional() + @IsDateString() + confirmedAt: string + +} + +export class CreateOrderAsUSerDto { + @IsArray() + @ApiProperty({ + isArray: true, type: [CreateOrderItemAsUserDto], + example: [ + { + productId: 'sd4', + quantity: 100, + attributes: [ + { + attributeId: 1, + value: 'string | boolean| number' + } + ], + attachments: [ + { url: '', type: '' } + ], + description: '' + } + ] + }) + @IsNotEmpty() + @ArrayMinSize(1, { message: 'At least one item is required' }) + @Type(() => CreateOrderItemAsUserDto) + items: CreateOrderItemAsUserDto[]; +} + + +export class CreateOrderAsAdminDto { + @ApiProperty({ example: '' }) + @IsString() + @IsNotEmpty() + userId: string + + @IsArray() + @ApiProperty({ + isArray: true, type: [CreateOrderItemDtoAsAdmin], example: [ + { + productId: 1, + designerId: '', + attachments: [{ url: '', type: '' }], + quantity: 100, + attributes: [{ fieldId: 10, value: 'blue' }], + unitPrice: 150000, + discount: 20000, + adminDescription: '', + description: 'توضیحات کاربر', + + } + ] + }) + @IsNotEmpty() + @ArrayMinSize(1, { message: 'At least one product is required' }) + @Type(() => CreateOrderItemDtoAsAdmin) + items: CreateOrderItemDtoAsAdmin[]; + + + @ApiProperty({}) + @IsString() + @IsOptional() + paymentMethod: string + + @ApiProperty({}) + @IsBoolean() + enableTax: boolean + + @ApiProperty({}) + @IsInt() + estimatedDays: number + + +} diff --git a/src/modules/order/dto/create-print-form.dto.ts b/src/modules/order/dto/create-print-form.dto.ts new file mode 100644 index 0000000..532664b --- /dev/null +++ b/src/modules/order/dto/create-print-form.dto.ts @@ -0,0 +1,16 @@ +import { IsArray, } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IField } from '../interface/order.interface'; + +export class CreatePrintFormDto { + @ApiPropertyOptional({ + example: [{ + fieldId: 10, value: '' + }] + }) + @IsArray() + printAttributes?: IField[] + +} + + diff --git a/src/modules/order/dto/find-order-items.dto.ts b/src/modules/order/dto/find-order-items.dto.ts new file mode 100644 index 0000000..4a6bfa7 --- /dev/null +++ b/src/modules/order/dto/find-order-items.dto.ts @@ -0,0 +1,53 @@ +import { + IsOptional, + IsString, + IsNumber, + Min, + IsIn, + IsEnum, + IsDateString, + IsArray, +} from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { OrderStatusEnum } from '../interface/order.interface'; +import { PaymentStatusEnum } from '../../payment/interface/payment'; + +const sortOrderOptions = ['asc', 'desc'] as const; +type SortOrder = (typeof sortOrderOptions)[number]; + +export class FindOrderItemsDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page: number = 1; + + @ApiPropertyOptional({ default: 10, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit: number = 10; + + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + + @ApiPropertyOptional({ default: 'createdAt' }) + @IsOptional() + @IsString() + orderBy: string = 'createdAt'; + + @ApiPropertyOptional({ + enum: sortOrderOptions, + default: 'desc', + }) + @IsOptional() + @IsIn(sortOrderOptions) + order: SortOrder = 'desc'; +} diff --git a/src/modules/order/dto/find-orders.dto.ts b/src/modules/order/dto/find-orders.dto.ts new file mode 100644 index 0000000..941a134 --- /dev/null +++ b/src/modules/order/dto/find-orders.dto.ts @@ -0,0 +1,80 @@ +import { + IsOptional, + IsString, + IsNumber, + Min, + IsIn, + IsEnum, + IsDateString, + IsArray, +} from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { OrderStatusEnum } from '../interface/order.interface'; +import { PaymentStatusEnum } from '../../payment/interface/payment'; + +const sortOrderOptions = ['asc', 'desc'] as const; +type SortOrder = (typeof sortOrderOptions)[number]; + +export class FindOrdersDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page: number = 1; + + @ApiPropertyOptional({ default: 10, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit: number = 10; + + + @ApiPropertyOptional({ + description: 'Filter by order statuses', + enum: OrderStatusEnum, + isArray: true, + }) + @IsOptional() + @Transform(({ value }) => + Array.isArray(value) ? value : value?.split(',') + ) + @IsArray() + @IsEnum(OrderStatusEnum, { each: true }) + statuses?: OrderStatusEnum[]; + + // @ApiPropertyOptional({ enum: PaymentStatusEnum }) + // @IsOptional() + // @IsEnum(PaymentStatusEnum) + // paymentStatus?: PaymentStatusEnum; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional({ default: 'createdAt' }) + @IsOptional() + @IsString() + orderBy: string = 'createdAt'; + + @ApiPropertyOptional({ + enum: sortOrderOptions, + default: 'desc', + }) + @IsOptional() + @IsIn(sortOrderOptions) + order: SortOrder = 'desc'; +} diff --git a/src/modules/order/dto/update-order-item.dto.ts b/src/modules/order/dto/update-order-item.dto.ts new file mode 100644 index 0000000..41de503 --- /dev/null +++ b/src/modules/order/dto/update-order-item.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateOrderItemAsUserDto, CreateOrderItemDtoAsAdmin } from './create-order.dto'; + +export class UpdateOrderItemDtoAsUser extends PartialType(CreateOrderItemAsUserDto) { } +export class UpdateOrderItemDtoAsAdmin extends PartialType(CreateOrderItemDtoAsAdmin) { } + + diff --git a/src/modules/order/dto/update-order.dto.ts b/src/modules/order/dto/update-order.dto.ts new file mode 100644 index 0000000..879235d --- /dev/null +++ b/src/modules/order/dto/update-order.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger'; +import { CreateOrderAsAdminDto, CreateOrderItemDtoAsAdmin } from './create-order.dto'; +import { IsNumber } from 'class-validator'; + +export class UpdateOrderAsAdminDto extends PartialType(OmitType(CreateOrderAsAdminDto, 'items' as any)) { + @ApiProperty() + items: UpdateOrCreateOrderItem[] +} + +export class UpdateOrCreateOrderItem extends CreateOrderItemDtoAsAdmin { + @ApiProperty() + @IsNumber() + id: string +} \ No newline at end of file diff --git a/src/modules/order/entities/order.entity.ts b/src/modules/order/entities/order.entity.ts new file mode 100644 index 0000000..9e62194 --- /dev/null +++ b/src/modules/order/entities/order.entity.ts @@ -0,0 +1,55 @@ +import { + Entity, + Index, + ManyToOne, + Property, + Enum, + PrimaryKey, + OptionalProps, +} from '@mikro-orm/core'; +import { User } from '../../user/entities/user.entity'; +import { ulid } from 'ulid'; +import { Admin } from 'src/modules/admin/entities/admin.entity'; +import { BaseEntity } from 'src/common/entities/base.entity'; +import { OrderStatusEnum } from '../interface/order.interface'; +import { InvoiceItem } from 'src/modules/invoice/entities/invoice-item.entity'; +import { IField } from 'src/modules/request/interface/request.interface'; +import { Product } from 'src/modules/product/entities/product.entity'; + +@Entity({ tableName: 'orders' }) +@Index({ properties: ['user'] }) +export class Order extends BaseEntity { + [OptionalProps]?: 'createdAt' | 'deletedAt' | 'orderNumber' + + @ManyToOne(() => InvoiceItem) + invoiceItem: InvoiceItem + + @ManyToOne(() => Product) + product: Product; + + @PrimaryKey({ type: 'string', columnType: 'char(26)' }) + id: string = ulid() + + + @ManyToOne(() => User) + user!: User; + + @ManyToOne(() => Admin, { nullable: true }) + designer?: Admin + + @ManyToOne(() => Admin, { nullable: true }) + creator?: Admin + + @Property({ + type: 'int', + autoincrement: true + }) + orderNumber: number; + + @Property({ type: 'json', nullable: true }) + attributes?: IField[] // print form selected attributes + + @Enum(() => OrderStatusEnum) + status: OrderStatusEnum = OrderStatusEnum.CREATED; + +} diff --git a/src/modules/order/events/order.events.ts b/src/modules/order/events/order.events.ts new file mode 100644 index 0000000..f937188 --- /dev/null +++ b/src/modules/order/events/order.events.ts @@ -0,0 +1,21 @@ +import type { OrderStatusEnum } from '../interface/order.interface'; + +export class OrderCreatedEvent { + constructor( + public readonly orderId: string, + public readonly: string, + public readonly orderNumber: string, + public readonly total: number, + ) { } +} + +export class OrderStatusChangedEvent { + constructor( + public readonly orderId: string, + public readonly userId: string, + public readonly orderNumber: string, + public readonly: string, + public readonly previousStatus: OrderStatusEnum, + public readonly newStatus: OrderStatusEnum, + ) { } +} diff --git a/src/modules/order/interface/order.interface.ts b/src/modules/order/interface/order.interface.ts new file mode 100644 index 0000000..52b63ac --- /dev/null +++ b/src/modules/order/interface/order.interface.ts @@ -0,0 +1,47 @@ +export enum OrderStatusEnum { + CREATED = 'created', + + INVOICED = 'invoiced', + WAITING_to_CONFIRM_INVOICE = 'waiting_to_confirm_invoice', + + + DESIGN_INITIATED = 'design_initiated', + DESIGN_FINISHED = 'design_finished', + DESIGN_REVISION = 'design_revision', + + READY_TO_PRINTING = 'ready_to_printing', + IN_PRINTING = 'in_printing', + + READY_FOR_DELIVERY = 'ready_for_delivery', + DELIVERED = 'delivered', + + AFTER_PRINT_COVER = 'after_print_cover', + AFTER_PRINT_BOXING = 'after_print_boxing', + AFTER_PRINT_CARTONING = 'after_print_cartoning', + + + FINISHED = 'finished', + + CANCELED = 'canceled', + +} + + +export interface IAttachment { type: string, url: string } + + +export interface CreateOrder { + userId: string + // status: OrderStatusEnum + enableTax?: boolean + paymentMethod?: string + adminId?: string + estimatedDays?: number +} + + +export type UpdateOrder = Partial + + + + diff --git a/src/modules/order/listeners/order.listeners.ts b/src/modules/order/listeners/order.listeners.ts new file mode 100644 index 0000000..d15550a --- /dev/null +++ b/src/modules/order/listeners/order.listeners.ts @@ -0,0 +1,212 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { AdminRepository } from 'src/modules/admin/repositories/admin.repository'; +import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events'; +import { PermissionEnum } from 'src/common/enums/permission.enum'; +import { NotifEvent } from 'src/modules/notification/interfaces/notification.interface'; +import { NotificationService } from 'src/modules/notification/services/notification.service'; +import { ConfigService } from '@nestjs/config'; +import { OrderRepository } from '../repositories/order.repository'; +// import { OrderStatusEnum } from '../interface/order.interface'; +import { PaymentMethodEnum } from 'src/modules/payment/interface/payment'; +import { UserService } from 'src/modules/user/providers/user.service'; + +@Injectable() +export class OrderListeners { + private readonly logger = new Logger(OrderListeners.name); + private orderCreatedSmsTemplateId: string; + private orderStatusChangedSmsTemplateId: string; + // private orderCompletedSmsTemplateId: string; + constructor( + private readonly adminService: AdminRepository, + private readonly OrderRepository: OrderRepository, + private readonly notificationService: NotificationService, + private readonly configService: ConfigService, + private readonly userService: UserService, + ) { + this.orderCreatedSmsTemplateId = this.configService.get('SMS_PATTERN_ORDER_CREATED') ?? '123'; + this.orderStatusChangedSmsTemplateId = this.configService.get('SMS_PATTERN_ORDER_STATUS_CHANGE') ?? '123'; + // this.orderCompletedSmsTemplateId = this.configService.get('SMS_PATTERN_ORDER_STATUS_COMPLETED') ?? '123'; + } + + // private getStatusFarsi(status: OrderStatus): string { + // const statusMap: Record = { + // [OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت', + // [OrderStatus.PAID]: 'پرداخت شده', + // [OrderStatus.PREPARING]: 'در حال آماده‌سازی', + // [OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش', + // [OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون', + // [OrderStatus.SHIPPED]: 'ارسال شده', + // [OrderStatus.COMPLETED]: 'تکمیل شده', + // [OrderStatus.CANCELED]: 'لغو شده', + // }; + // return statusMap[status] || status; + // } + + // @OnEvent(OrderCreatedEvent.name) + // async handleOrderCreated(event: OrderCreatedEvent) { + // try { + // this.logger.log( + // `Order created event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, + // ); + + // const order = await this.OrderRepository.findOne(event.orderId); + // if (order?.paymentMethod.method === PaymentMethodEnum.Online) { + // return; + // } + + + // // get admnin os restuaraant that have order permissuins + // const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS); + // const recipients = admins.map(admin => ({ + // adminId: admin.id, + // })); + + // await this.notificationService.sendNotification({ + + // message: { + // title: NotifTitleEnum.ORDER_CREATED, + // content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`, + // sms: { + // templateId: this.orderCreatedSmsTemplateId, + // parameters: { + // orderNumber: event.orderNumber, + // total: event.total.toString(), + // }, + // }, + // pushNotif: { + // title: `سفارش جدید`, + // content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`, + // icon: `/`, + // action: { + // type: NotifTitleEnum.ORDER_CREATED, + // url: `/`, + // }, + // }, + // }, + // recipients, + // metadata: { + // priority: 1, + // }, + // }); + // } catch (error) { + // this.logger.error( + // `Failed to send notification for order created event: ${event.}`, + // error instanceof Error ? error.stack : String(error), + // ); + // } + // } + + // @OnEvent(OrderStatusChangedEvent.name) + // async handleOrderStatusChanged(event: OrderStatusChangedEvent) { + // try { + // this.logger.log( + // `Order status changed event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, + // ); + // //TODO : REFACTOR to use queue or other way to handle this + // const recipients = [ + // { + // userId: event.userId, + // }, + // ]; + // if (event.newStatus === OrderStatus.COMPLETED) { + + // if (!event?.userId) { + // this.logger.log( + // `User not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, + // ); + // } + + // // const restaurant = await this.RestaurantRepository.findOne(event.); + // // if (!restaurant) { + // // this.logger.log( + // // `Restaurant not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, + // // ); + // // return; + // // } + // // const score = restaurant.score; + // // if (!score) { + // // this.logger.log( + // // `Score not found for restaurant: ${event.} and order number: ${event.orderNumber}`, + // // ); + // // return; + // // } + + // // increase score for user + // // const order = await this.OrderRepository.findOne(event.orderId); + // // if (!order) { + // // this.logger.log( + // // `Order not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`, + // // ); + // // return; + // // } + // // this.userService.createWalletTransaction(event.userId, event., { + // // amount: order.subTotal, + // // type: WalletTransactionType.CREDIT, + // // reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT, + // // }); + + // await this.notificationService.sendNotification({ + // : event., + // message: { + // title: NotifTitleEnum.ORDER_STATUS_CHANGED, + // content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`, + // sms: { + // templateId: this.orderCreatedSmsTemplateId, + // parameters: { + // orderNumber: event.orderNumber, + // }, + // }, + // pushNotif: { + // title: `تغییر وضعیت سفارش`, + // content: `لطفا برای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`, + // icon: `/`, + // action: { + // type: NotifTitleEnum.ORDER_STATUS_CHANGED, + // url: ``, + // }, + // }, + // }, + // recipients, + // metadata: { + // priority: 1, + // }, + // }); + // } else { + // await this.notificationService.sendNotification({ + // : event., + // message: { + // title: NotifTitleEnum.ORDER_STATUS_CHANGED, + // content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`, + // sms: { + // templateId: this.orderStatusChangedSmsTemplateId, + // parameters: { + // orderNumber: event.orderNumber, + // status: this.getStatusFarsi(event.newStatus), + // }, + // }, + // pushNotif: { + // title: `تغییر وضعیت سفارش`, + // content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`, + // icon: `/`, + // action: { + // type: NotifTitleEnum.ORDER_STATUS_CHANGED, + // url: ``, + // }, + // }, + // }, + // recipients, + // metadata: { + // priority: 1, + // }, + // }); + // } + // } catch (error) { + // this.logger.error( + // `Failed to send notification for order status changed event: ${event.}`, + // error instanceof Error ? error.stack : String(error), + // ); + // } + // } + +} diff --git a/src/modules/order/order.module.ts b/src/modules/order/order.module.ts new file mode 100644 index 0000000..53f9a3b --- /dev/null +++ b/src/modules/order/order.module.ts @@ -0,0 +1,39 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { OrderService } from './providers/order.service'; +import { OrderController } from './controllers/order.controller'; +import { Order } from './entities/order.entity'; +import { OrderItem } from './entities/order-item.entity'; +import { AuthModule } from '../auth/auth.module'; +import { PaymentModule } from '../payment/payments.module'; +import { JwtModule } from '@nestjs/jwt'; +import { OrderRepository } from './repositories/order.repository'; +import { OrderListeners } from './listeners/order.listeners'; +import { OrdersCrone } from './crone/order.crone'; +import { AdminModule } from '../admin/admin.module'; +import { NotificationsModule } from '../notification/notifications.module'; +import { UserModule } from '../user/user.module'; +import { UtilsModule } from '../util/utils.module'; +import { ProductModule } from '../product/product.module'; +import { TicketModule } from '../ticket/ticket.module'; +import { OrderItemRepository } from './repositories/order-item.repository'; + +@Module({ + imports: [ + MikroOrmModule.forFeature([Order, OrderItem]), + AuthModule, + forwardRef(() => PaymentModule), + JwtModule, + AdminModule, + NotificationsModule, + UtilsModule, + forwardRef(() => UserModule), + ProductModule, + forwardRef(() => TicketModule) + ], + controllers: [OrderController], + providers: [OrderService, OrderRepository, OrderListeners, + OrdersCrone, OrderItemRepository], + exports: [OrderRepository, OrderService, OrderItemRepository], +}) +export class OrderModule { } diff --git a/src/modules/order/providers/order.service.ts b/src/modules/order/providers/order.service.ts new file mode 100644 index 0000000..1674b7d --- /dev/null +++ b/src/modules/order/providers/order.service.ts @@ -0,0 +1,636 @@ +import { Injectable, BadRequestException, Logger, BadGatewayException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { OrderRepository } from '../repositories/order.repository'; +import { FindOrdersDto } from '../dto/find-orders.dto'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + CreateOrderAsAdminDto, + CreateOrderAsUSerDto, CreateOrderItemAsUserDto, + CreateOrderItemDtoAsAdmin +} from '../dto/create-order.dto'; +import { UserService } from 'src/modules/user/providers/user.service'; +import { + OrderItemStatusEnum, + CreateOrderWithItems, + CreateOrderItemPopulated, + UpdateOrder, + UpdateOrderItem, + OrderStatusEnum +} from '../interface/order.interface'; +import { OrderItemRepository } from '../repositories/order-item.repository'; +import { ProductService } from 'src/modules/product/providers/product.service'; +import { ProductRepository } from 'src/modules/product/repositories/product.repository'; +import { TicketService } from 'src/modules/ticket/providers/tickets.service'; +import { TicketRepository } from 'src/modules/ticket/repositories/tickets.repository'; +import { AdminRepository } from 'src/modules/admin/repositories/admin.repository'; +import { Order } from '../entities/order.entity'; +import { PaymentRepository } from 'src/modules/payment/repositories/payment.repository'; +import { UpdateOrderItemDtoAsAdmin, UpdateOrderItemDtoAsUser } from '../dto/update-order-item.dto'; +import { Admin } from 'src/modules/admin/entities/admin.entity'; +import { AdminService } from 'src/modules/admin/providers/admin.service'; +import { OrderItem } from '../entities/order-item.entity'; +import { UpdateOrderAsAdminDto } from '../dto/update-order.dto'; +import { CreatePrintFormDto } from '../dto/create-print-form.dto'; +import { FindOrderItemsDto } from '../dto/find-order-items.dto'; +import { PermissionService } from 'src/modules/roles/providers/permissions.service'; +import { PermissionEnum } from 'src/common/enums/permission.enum'; + + +@Injectable() +export class OrderService { + private readonly logger = new Logger(OrderService.name); + + constructor( + private readonly em: EntityManager, + private readonly orderRepository: OrderRepository, + private readonly orderItemRepository: OrderItemRepository, + private readonly userService: UserService, + private readonly adminService: AdminService, + private readonly productService: ProductService, + private readonly productRepository: ProductRepository, + private readonly ticketRepository: TicketRepository, + private readonly ticketService: TicketService, + private readonly eventEmitter: EventEmitter2, + private readonly adminRepository: AdminRepository, + private readonly paymentRepository: PaymentRepository, + private readonly permissionService: PermissionService, + ) { } + + async createOrderAsAdmin(adminId: string, dto: CreateOrderAsAdminDto) { + const order = await this.createOrder({ ...dto, adminId }) + // await this.calculateOrder(order) + // this.em.flush() + this.logger.log("Order created as admin") + return order + + } + + async createOrderAsUser(userId: string, dto: CreateOrderAsUSerDto) { + const order = await this.createOrder({ ...dto, userId }) + this.logger.log("Order created as user") + return order + + } + + async updateOrderAsAdmin(orderId: string, dto: UpdateOrderAsAdminDto) { + const order = await this.findOrderOrFail(orderId) + + // const updateOrder = await this.updateOrder(order, dto) + + // const updateOrder = await this.calculateOrder(order) + + // await this.em.flush() + + this.logger.log("Order updated as admin") + + // return updateOrder + } + + + async updateOrderAsAdmin2(orderId: string, dto: UpdateOrderAsAdminDto) { + const order = await this.findOrderOrFail(orderId) + + const { items, ...rest } = dto + + return this.em.transactional(async (em) => { + + em.assign(order, rest) + + for (const item of items) { + + if (item.id) { // update + const currentItem = order.items.find(i => i.id == item.id) + + if (!currentItem) { + throw new BadGatewayException("Order Item not found") + } + + const { designerId, productId, ...itemRest } = item + + let newProduct = currentItem.product + + if (productId && productId !== currentItem.product.id) { + + newProduct = await this.productService.findOneOrFail(productId) + + } + + let newDesigner: Admin | undefined + + if (designerId && designerId !== currentItem.designer?.id) { + + newDesigner = await this.adminService.findOrFail(designerId) + + } + + em.assign(currentItem, { ...itemRest, designer: newDesigner, product: newProduct }) + + + } else { + + if (!item.productId) { + throw new BadRequestException(`Product id is required for item ${item.id}`) + } + + const product = await this.productService.findOneOrFail(item.productId) + + const newOrderItem = em.create(OrderItem, { + order, + product, + quantity: item.quantity, + unitPrice: item.unitPrice, + + adminDescription: item.adminDescription, + attachments: item.attachments, + attributes: item.attributes, + description: item.description, + discount: item.discount, + confirmedAt: item.confirmedAt, + status: OrderItemStatusEnum.CREATED + }) + + em.persist(newOrderItem) + } + + } + + await em.flush() + + this.logger.log(`order #${order.orderNumber} was updated as Admin`) + + return order + }) + + + } + + async addOrderItemAsUser(userId: string, orderId: string, dto: CreateOrderItemAsUserDto) { + + const order = await this.findOrderOrFail(orderId) + + // if (order.status !== OrderStatusEnum.CREATED) { + // throw new BadRequestException(`You can not update when status is ${order.status}`) + // } + + if (order.user.id !== userId) { + throw new BadRequestException(`This order doest belongs to you!`) + } + + const product = await this.productService.findOneOrFail(dto.productId) + + const orderItem = this.createOrderItemEntity(order, { ...dto, product }) + + await this.em.flush() + + return orderItem + } + + async addOrderItemAsAdmin(orderId: string, dto: CreateOrderItemDtoAsAdmin) { + + const order = await this.findOrderOrFail(orderId) + + const product = await this.productService.findOneOrFail(dto.productId) + + let designer: Admin | undefined + if (dto.designerId) { + designer = await this.adminService.findOrFail(dto.designerId) + } + + const orderItem = this.createOrderItemEntity(order, { ...dto, designer, product }) + + await this.em.flush() + + // await this.calculateOrder(undefined, orderId) + + // await this.em.flush() + + return orderItem + } + + async updateOrderItemAsUser(userId: string, orderId: string, itemId: string, dto: UpdateOrderItemDtoAsUser) { + + const orderItem = await this.findOrderItemOrFail(orderId, itemId) + + if (orderItem.order.user.id !== userId) { + throw new BadRequestException(`This order doesnt belongs to you`) + } + + // if (orderItem.order.status !== OrderStatusEnum.CREATED) { + // throw new BadRequestException(`You can not update when status is ${orderItem.order.status}`) + // } + + if (orderItem.confirmedAt) { + throw new BadRequestException(`You can not update when item is confirmed`) + } + + const updated = await this.updateOrderItem(orderItem, dto) + + return updated + } + + async updateOrderItemAsAdmin(orderId: string, itemId: string, dto: UpdateOrderItemDtoAsAdmin) { + + const orderItem = await this.findOrderItemOrFail(orderId, itemId) + + const updated = await this.updateOrderItem(orderItem, dto) + + // await this.calculateOrder(undefined, orderId) + + // await this.em.flush() + + return updated + } + + async createPrintForm(orderId: string, itemId: string, dto: CreatePrintFormDto) { + + const orderItem = await this.findOrderItemOrFail(orderId, itemId) + + const updated = await this.updateOrderItem(orderItem, { ...dto, printFormCreatedAt: new Date() }) + + return updated + } + + async removeOrderItemAsAdmin(orderId: string, itemId: string) { + + const orderItem = await this.findOrderItemOrFail(orderId, itemId) + + await this.removeOrderItem(orderItem) + + // await this.calculateOrder(undefined, orderId) + + // await this.em.flush() + + return { message: 'success' } + } + + async removeOrderItemAsUser(userId: string, orderId: string, itemId: string) { + + const orderItem = await this.findOrderItemOrFail(orderId, itemId) + + if (orderItem.order.user.id !== userId) { + throw new BadRequestException(`this order is not belongs to you`) + } + + // if (orderItem.order.status !== OrderStatusEnum.CREATED) { + // throw new BadRequestException(`You can not delete when order status is ${orderItem.order.status}`) + // } + + if (orderItem.confirmedAt) { + throw new BadRequestException(`You can not delete when item is confirmed`) + } + + await this.removeOrderItem(orderItem) + + return { message: 'success' } + } + + + async findOrdersAsUser(userId: string, dto: FindOrdersDto) { + const orders = await this.orderRepository.findAllPaginated({ userId, ...dto }) + return orders + } + + async findOrderRequestsAsAdmin(dto: FindOrdersDto) { + const orders = await this.orderRepository.findAllPaginated({ ...dto, isInvoiced: false }) + return orders + } + + async findInvoicedOrdersAsAdmin(adminId: string, dto: FindOrdersDto) { + const canSeeAll = await this.adminService.hasPermission(adminId, PermissionEnum.VIEW_All_INVOICED_ORDERS) + + let canSeeAsigned = false + + if (!canSeeAll) { + canSeeAsigned = await this.adminService.hasPermission(adminId, PermissionEnum.VIEW_MY_ASSIGNED_INVOICED_REQUESTS) + } + + if (!canSeeAsigned && !canSeeAll) { + throw new BadRequestException("You dont have Permission") + } + + let designerId = canSeeAll ? undefined : adminId + + const orders = await this.orderRepository.findAllPaginated({ ...dto, isInvoiced: true, designerId }) + + return orders + } + + async findPrintOrderItemsAsAdmin(dto: FindOrderItemsDto) { + const orderItems = await this.orderItemRepository.findAllPaginated({ ...dto, isPrintFormAdded: true, }) + return orderItems + } + + async findPrints(dto: FindOrdersDto) { + const orders = await this.orderRepository.findAllPaginated({ ...dto, statuses: [] }) + return orders + } + + + async confirmOrderItem(userId: string, orderId: string, orderItemId: string) { + + const orderItem = await this.findOrderItemOrFail(orderId, orderItemId) + + + if (orderItem.order.user.id !== userId) { + throw new BadRequestException("Order Item does not belong to you") + } + + orderItem.confirmedAt = new Date() + + await this.em.persistAndFlush(orderItem) + + return orderItem + } + + async assignDesigner(orderId: string, orderItemId: string, designerId: string) { + + const orderItem = await this.findOrderItemOrFail(orderId, orderItemId) + + const designer = await this.adminRepository.findOne({ id: designerId }) + + if (!designer) { + throw new BadRequestException("designer not found") + } + + orderItem.designer = designer + + await this.em.persistAndFlush(orderItem) + + return orderItem + + } + + async removeOrderAsUser(userId: string, orderId: string) { + const order = await this.findOrderOrFail(orderId) + + if (order.user.id !== userId) { + throw new BadRequestException("this order doesnt belongs to you") + } + + // if (![OrderStatusEnum.CREATED].includes(order.status)) { + // throw new BadRequestException("Order can not be deleted") + // } + + return this.hardDeleteOrder(orderId) + } + + // async updateStatus(orderId: string, newStatus: OrderStatusEnum) { + + // const order = await this.findOrderOrFail(orderId) + + // // order.status = newStatus + + // await this.em.flush() + + // return order + // } + + // async createInvoice(orderId: string) { + // const order = await this.updateStatus(orderId, OrderStatusEnum.INVOICED) + // return order + // } + + async getOrderAsUser(userId: string, orderId: string) { + const order = await this.findOrderOrFail(orderId) + if (order.user.id !== userId) { + throw new BadRequestException("This order is not belongs to you") + } + return order + } + + // ==================== Entity Methods ==================== + + private async createOrderItemEntity(order: Order, dto: CreateOrderItemPopulated, em?: EntityManager) { + const { attributes, description, quantity, attachments, discount, + unitPrice, product, designer, printAttributes, status } = dto + + const eM = em ?? this.em + return eM.create( + OrderItem, + { + designer, + order, + attributes, + description, + quantity, + attachments, + discount, + unitPrice, + product, + printAttributes: printAttributes, + status + }) + } + + // ==================== Core Logic ==================== + + async createOrder(dto: CreateOrderWithItems) { + const { items, userId, enableTax, + estimatedDays, paymentMethod, adminId } = dto + + // Validate and fetch all required entities + const user = await this.userService.findOrFail(userId) + const admin = adminId ? await this.adminService.findOrFail(adminId) : undefined + const products = await this.productService.findProductsByIds(items.map(item => item.productId)) + const desiners = await this.adminService.findByIds(items.map(item => item.designerId).filter(id => id != null)) + + const productMap = new Map( + products.map(p => [Number(p.id), p]) + ); + const designerMap = new Map( + desiners.map(d => [d.id, d]) + ) + + return this.em.transactional(async (em) => { + + const order = em.create(Order, { + creator: admin, + user, + enableTax, + estimatedDays, + paymentMethod, + status: OrderStatusEnum.REQUEST, + }) + + em.persist(order) + + for (const item of items) { + const product = productMap.get(Number(item.productId)) + + if (!product) { + throw new BadRequestException("Product not found!") + } + let designer: undefined | Admin = undefined + + if (item.designerId) { + designer = designerMap.get(item.designerId) + if (!designer) { + throw new BadRequestException("designer not found!") + } + } + + const orderItem = await this.createOrderItemEntity(order, { ...item, designer, product }, em) + + order.items.add(orderItem) + } + + // TODO : calculation must be done after create order + + // await this.calculateOrder(order) + + await em.flush() + + return order + }) + + + } + + async updateOrder(order: Order, param: UpdateOrder) { + const { adminId, enableTax, + estimatedDays, paymentMethod, userId } = param + + + if (userId) { + const user = await this.userService.findOrFail(userId) + order.user = user + } + + + if (adminId != undefined && adminId !== null) { + const admin = await this.adminService.findOrFail(adminId) + order.creator = admin + } + + + if (enableTax != undefined) { + order.enableTax = enableTax + } + + if (estimatedDays != undefined) { + order.estimatedDays = estimatedDays + } + + if (paymentMethod != undefined) { + order.paymentMethod = paymentMethod + } + + // if (status != undefined) { + // order.status = status + + // if (status === OrderStatusEnum.INVOICED && !order.invoicedAt) { + // order.invoicedAt = new Date() + // } + // } + + + + await this.em.persistAndFlush(order) + + return order + } + + async updateOrderItem(orderItem: OrderItem, dto: UpdateOrderItem) { + const { attributes, description, product, quantity, printFormCreatedAt, + attachments, discount, unitPrice, designer, printAttributes, } = dto + + if (product && orderItem.product.id !== product.id) { + orderItem.product = product + } + + if (designer && orderItem.designer?.id !== designer.id) { + orderItem.designer = designer + } + + if (attributes != undefined) { + orderItem.attributes = attributes + } + + if (description != undefined) { + orderItem.description = description + } + if (quantity != undefined) { + orderItem.quantity = quantity + } + + if (attachments != undefined) { + orderItem.attachments = attachments + } + if (discount != undefined) { + orderItem.discount = discount + } + + if (unitPrice != undefined) { + orderItem.unitPrice = unitPrice + } + + if (printFormCreatedAt != undefined) { + orderItem.printFormCreatedAt = printFormCreatedAt + } + + if (printAttributes != undefined) { + orderItem.printAttributes = printAttributes + } + + await this.em.flush() + + return orderItem + } + + async removeOrderItem(orderItem: OrderItem) { + + await this.em.removeAndFlush(orderItem) + + return true + } + + async hardDeleteOrder(orderId: string) { + const order = await this.findOrderOrFail(orderId) + + if (order.payments.length > 0) { + throw new BadRequestException("order with payments can not be deleted!") + } + + await this.em.transactional(async (em) => { + await this.ticketRepository.nativeDelete({ orderItem: { order: { id: orderId } } }) + await this.orderRepository.nativeDelete(order) + // order item will be deleted because cascade is true + + // TODO : images also must be deleted + await em.flush() + }) + + return { message: "Order deleted successfully" } + + } + + // ==================== Helper Methods ==================== + + async findOrderOrFail(orderId: string) { + const order = await this.orderRepository.findOne({ id: orderId }, + { populate: ['items', 'items.product', 'payments', 'user', 'items.designer'] }) + if (!order) { + throw new BadRequestException('Order not found') + } + return order + } + + async findOrderItemOrFail(orderId: string, itemId: string) { + const orderItem = await this.orderItemRepository.findOne({ id: itemId, order: { id: orderId } }, + { populate: ['order', 'order.user'] }) + if (!orderItem) { + throw new BadRequestException('Order item not found') + } + return orderItem + } + + async findOrderItemOrFailByItemId(itemId: string) { + const orderItem = await this.orderItemRepository.findOne({ id: itemId }, + { populate: ['order', 'order.user'] }) + if (!orderItem) { + throw new BadRequestException('Order item not found') + } + return orderItem + } + +} diff --git a/src/modules/order/repositories/order-item.repository.ts b/src/modules/order/repositories/order-item.repository.ts new file mode 100644 index 0000000..bd2b820 --- /dev/null +++ b/src/modules/order/repositories/order-item.repository.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository, FilterQuery } from '@mikro-orm/postgresql'; +import { OrderItem } from '../entities/order-item.entity'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; +import { FindOrderItemsDto } from '../dto/find-order-items.dto'; + +class FindOrderItemsOpts extends FindOrderItemsDto { + userId?: string; + isPrintFormAdded?: boolean +}; + + +@Injectable() +export class OrderItemRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, OrderItem); + } + + async findAllPaginated(opts: FindOrderItemsOpts): Promise> { + const { + page = 1, + limit = 10, + search, + orderBy = 'createdAt', + order = 'desc', + userId, + isPrintFormAdded + } = opts; + + const offset = (page - 1) * limit; + + const where: FilterQuery = {}; + + + + if (userId) { + where.order = { user: { id: userId } }; + } + + if (isPrintFormAdded != undefined) { + if (isPrintFormAdded == true) { + where.printFormCreatedAt = { $not: null } + } else { + where.printFormCreatedAt = null + } + } + + + // Search by order number or user information + if (search) { + const searchPattern = `%${search}%`; + const searchConditions: any[] = [ + { user: { firstName: { $ilike: searchPattern } } }, + { user: { lastName: { $ilike: searchPattern } } }, + { user: { phone: { $ilike: searchPattern } } }, + ]; + + // If search is numeric, also search by orderNumber + const numericSearch = Number(search); + if (!isNaN(numericSearch) && isFinite(numericSearch)) { + searchConditions.push({ orderNumber: numericSearch }); + } + + where.$or = searchConditions; + } + + // First, fetch orders without reviews + const [data, total] = await this.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + populate: ['designer', 'product'], + }); + + + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + }, + }; + } +} diff --git a/src/modules/order/repositories/order.repository.ts b/src/modules/order/repositories/order.repository.ts new file mode 100644 index 0000000..c72fef3 --- /dev/null +++ b/src/modules/order/repositories/order.repository.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; +import { Order } from '../entities/order.entity'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; +import { OrderStatusEnum } from '../interface/order.interface'; +import { FindOrdersDto } from '../dto/find-orders.dto'; + + +class FindOrdersOpts extends FindOrdersDto { + userId?: string; + isInvoiced?: boolean; + designerId?: string +}; + +@Injectable() +export class OrderRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, Order); + } + + async findAllPaginated(opts: FindOrdersOpts): Promise> { + const { + page = 1, + limit = 10, + statuses, + search, + orderBy = 'createdAt', + order = 'desc', + userId, + from, + to, + isInvoiced, + designerId + } = opts; + + const offset = (page - 1) * limit; + + const where: FilterQuery = {}; + + // Filter by statuses + if (statuses) { + // Ensure statuses is always an array and normalize it + const normalizedStatuses = Array.isArray(statuses) ? statuses : [statuses]; + // Filter out any empty values + const validStatuses = normalizedStatuses.filter((s): s is OrderStatusEnum => !!s); + + if (validStatuses.length > 0) { + // Use direct equality for single value, $in for multiple values to avoid SQL generation issues + if (validStatuses.length === 1) { + where.status = validStatuses[0]; + } else { + where.status = { $in: validStatuses }; + } + } + } + + if (userId) { + where.user = { id: userId }; + } + + if (isInvoiced != undefined) { + if (isInvoiced == true) { + where.invoicedAt = { $not: null } + } else { + where.invoicedAt = null + } + } + + if (designerId) { + where.items = { $some: { designer: { id: designerId } } } + } + // Filter by date range + if (from || to) { + where.createdAt = {}; + if (from) { + where.createdAt.$gte = new Date(from); + } + if (to) { + where.createdAt.$lte = new Date(to); + } + } + + // Search by order number or user information + if (search) { + const searchPattern = `%${search}%`; + const searchConditions: any[] = [ + { user: { firstName: { $ilike: searchPattern } } }, + { user: { lastName: { $ilike: searchPattern } } }, + { user: { phone: { $ilike: searchPattern } } }, + ]; + + // If search is numeric, also search by orderNumber + const numericSearch = Number(search); + if (!isNaN(numericSearch) && isFinite(numericSearch)) { + searchConditions.push({ orderNumber: numericSearch }); + } + + where.$or = searchConditions; + } + + // First, fetch orders without reviews + const [data, total] = await this.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + populate: ['items', 'items.product', 'user'], + }); + + // Collect all (orderId, productId) pairs for efficient review lookup + // const orderproductPairs: Array<{ orderId: string; productId: bigint }> = []; + // for (const order of data) { + // for (const item of order.items.getItems()) { + // if (item.product?.id) { + // orderproductPairs.push({ orderId: order.id, productId: item.product.id }); + // } + // } + // } + + + + // Map reviewIds to products + // for (const order of data) { + // for (const item of order.items.getItems()) { + // if (item.product) { + // const key = `${order.id}-${item.product.id}`; + // // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + // const product = item.product as any; + // // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + + // } + // } + // } + + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + }, + }; + } +}