From 59e958e809e28691dbf531257ce451a9bac993ae Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Tue, 14 Jul 2026 12:57:30 +0330 Subject: [PATCH] cash shift --- .../Migration20260714120000_addCashShifts.ts | 61 +++++++ src/common/enums/message.enum.ts | 8 + .../controllers/cash-shifts.controller.ts | 83 +++++++++ .../orders/dto/close-cash-shift.dto.ts | 16 ++ .../orders/dto/find-cash-shifts.dto.ts | 61 +++++++ src/modules/orders/dto/find-orders.dto.ts | 5 + src/modules/orders/dto/open-cash-shift.dto.ts | 16 ++ .../orders/entities/cash-shift.entity.ts | 48 ++++++ src/modules/orders/entities/order.entity.ts | 5 + .../orders/interface/cash-shift.interface.ts | 21 +++ src/modules/orders/orders.module.ts | 12 +- .../orders/providers/cash-shifts.service.ts | 157 ++++++++++++++++++ .../orders/providers/orders.service.ts | 6 + .../repositories/cash-shift.repository.ts | 82 +++++++++ .../orders/repositories/order.repository.ts | 8 +- .../calculate-cash-shift-summary.util.ts | 43 +++++ 16 files changed, 627 insertions(+), 5 deletions(-) create mode 100644 database/migrations/Migration20260714120000_addCashShifts.ts create mode 100644 src/modules/orders/controllers/cash-shifts.controller.ts create mode 100644 src/modules/orders/dto/close-cash-shift.dto.ts create mode 100644 src/modules/orders/dto/find-cash-shifts.dto.ts create mode 100644 src/modules/orders/dto/open-cash-shift.dto.ts create mode 100644 src/modules/orders/entities/cash-shift.entity.ts create mode 100644 src/modules/orders/interface/cash-shift.interface.ts create mode 100644 src/modules/orders/providers/cash-shifts.service.ts create mode 100644 src/modules/orders/repositories/cash-shift.repository.ts create mode 100644 src/modules/orders/utils/calculate-cash-shift-summary.util.ts diff --git a/database/migrations/Migration20260714120000_addCashShifts.ts b/database/migrations/Migration20260714120000_addCashShifts.ts new file mode 100644 index 0000000..628768d --- /dev/null +++ b/database/migrations/Migration20260714120000_addCashShifts.ts @@ -0,0 +1,61 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260714120000_addCashShifts extends Migration { + + override async up(): Promise { + this.addSql(` + create table "cash_shifts" ( + "id" char(26) not null, + "created_at" timestamptz not null default now(), + "updated_at" timestamptz not null default now(), + "deleted_at" timestamptz null, + "admin_id" char(26) not null, + "restaurant_id" char(26) not null, + "opened_at" timestamptz not null, + "closed_at" timestamptz null, + "opening_amount" numeric(10,0) not null default 0, + "expected_amount" numeric(10,0) null, + "counted_amount" numeric(10,0) null, + "difference_amount" numeric(10,0) null, + "orders" int not null default 0, + "status" text check ("status" in ('open', 'closed')) not null default 'open', + "notes" text null, + constraint "cash_shifts_pkey" primary key ("id") + ); + `); + this.addSql(`create index "cash_shifts_created_at_index" on "cash_shifts" ("created_at");`); + this.addSql(`create index "cash_shifts_deleted_at_index" on "cash_shifts" ("deleted_at");`); + this.addSql(`create index "cash_shifts_admin_id_index" on "cash_shifts" ("admin_id");`); + this.addSql(`create index "cash_shifts_restaurant_id_index" on "cash_shifts" ("restaurant_id");`); + this.addSql(`create index "cash_shifts_restaurant_id_status_index" on "cash_shifts" ("restaurant_id", "status");`); + this.addSql(` + alter table "cash_shifts" + add constraint "cash_shifts_admin_id_foreign" + foreign key ("admin_id") references "admins" ("id") on update cascade; + `); + this.addSql(` + alter table "cash_shifts" + add constraint "cash_shifts_restaurant_id_foreign" + foreign key ("restaurant_id") references "restaurants" ("id") on update cascade; + `); + + this.addSql(`alter table "orders" add column "cash_shift_id" char(26) null;`); + this.addSql(`create index "orders_cash_shift_id_index" on "orders" ("cash_shift_id");`); + this.addSql(` + alter table "orders" + add constraint "orders_cash_shift_id_foreign" + foreign key ("cash_shift_id") references "cash_shifts" ("id") on update cascade on delete set null; + `); + } + + override async down(): Promise { + this.addSql(`alter table "orders" drop constraint if exists "orders_cash_shift_id_foreign";`); + this.addSql(`drop index if exists "orders_cash_shift_id_index";`); + this.addSql(`alter table "orders" drop column if exists "cash_shift_id";`); + + this.addSql(`alter table "cash_shifts" drop constraint if exists "cash_shifts_restaurant_id_foreign";`); + this.addSql(`alter table "cash_shifts" drop constraint if exists "cash_shifts_admin_id_foreign";`); + this.addSql(`drop table if exists "cash_shifts" cascade;`); + } + +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 913a5d6..2ff5011 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -788,6 +788,14 @@ export const enum PagerMessage { CREATED_SUCCESS = 'درخواست پیج با موفقیت ایجاد شد', } +export const enum CashShiftMessage { + NOT_FOUND = 'شیفت صندوق یافت نشد', + ALREADY_OPEN = 'یک شیفت صندوق باز از قبل وجود دارد', + ALREADY_CLOSED = 'این شیفت صندوق قبلا بسته شده است', + ADMIN_NOT_FOUND = 'مدیر یافت نشد', + RESTAURANT_NOT_FOUND = 'رستوران یافت نشد', +} + export const enum UserSuccessMessage { ADDRESS_DELETED_SUCCESS = 'آدرس با موفقیت حذف شد', SCORE_CONVERTED_SUCCESS = 'امتیاز با موفقیت به کیف پول تبدیل شد', diff --git a/src/modules/orders/controllers/cash-shifts.controller.ts b/src/modules/orders/controllers/cash-shifts.controller.ts new file mode 100644 index 0000000..67f139c --- /dev/null +++ b/src/modules/orders/controllers/cash-shifts.controller.ts @@ -0,0 +1,83 @@ +import { Controller, Get, Post, Param, UseGuards, Query, Body, Patch } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiBody } from '@nestjs/swagger'; +import { CashShiftsService } from '../providers/cash-shifts.service'; +import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard'; +import { RestId } from 'src/common/decorators/rest-id.decorator'; +import { AdminId } from 'src/common/decorators/admin-id.decorator'; +import { Permissions } from 'src/common/decorators/permissions.decorator'; +import { Permission } from 'src/common/enums/permission.enum'; +import { OpenCashShiftDto } from '../dto/open-cash-shift.dto'; +import { CloseCashShiftDto } from '../dto/close-cash-shift.dto'; +import { FindCashShiftsDto } from '../dto/find-cash-shifts.dto'; + +@ApiTags('cash-shifts') +@ApiBearerAuth() +@Controller() +export class CashShiftsController { + constructor(private readonly cashShiftsService: CashShiftsService) {} + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Post('admin/cash-shifts/open') + @ApiOperation({ summary: 'Open a new cash shift' }) + @ApiBody({ type: OpenCashShiftDto }) + openShift(@RestId() restId: string, @AdminId() adminId: string, @Body() dto: OpenCashShiftDto) { + return this.cashShiftsService.openShift(restId, adminId, dto); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Patch('admin/cash-shifts/:shiftId/close') + @ApiOperation({ summary: 'Close an open cash shift' }) + @ApiParam({ name: 'shiftId', description: 'Cash shift ID' }) + @ApiBody({ type: CloseCashShiftDto }) + closeShift( + @Param('shiftId') shiftId: string, + @RestId() restId: string, + @Body() dto: CloseCashShiftDto, + ) { + return this.cashShiftsService.closeShift(shiftId, restId, dto); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Get('admin/cash-shifts/current/summary') + @ApiOperation({ summary: 'Calculate summary for the current open cash shift' }) + getCurrentShiftSummary(@RestId() restId: string) { + return this.cashShiftsService.calculateCurrentShiftSummary(restId); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Get('admin/cash-shifts/current') + @ApiOperation({ summary: 'Get the current open cash shift' }) + getCurrentOpenShift(@RestId() restId: string) { + return this.cashShiftsService.getCurrentOpenShift(restId); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Get('admin/cash-shifts') + @ApiOperation({ summary: 'List cash shifts with pagination and filters' }) + findAll(@RestId() restId: string, @Query() dto: FindCashShiftsDto) { + return this.cashShiftsService.findAll(restId, dto); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Get('admin/cash-shifts/:shiftId/summary') + @ApiOperation({ summary: 'Calculate summary for a cash shift' }) + @ApiParam({ name: 'shiftId', description: 'Cash shift ID' }) + getShiftSummary(@Param('shiftId') shiftId: string, @RestId() restId: string) { + return this.cashShiftsService.calculateShiftSummary(shiftId, restId); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Get('admin/cash-shifts/:shiftId') + @ApiOperation({ summary: 'Get a cash shift by ID' }) + @ApiParam({ name: 'shiftId', description: 'Cash shift ID' }) + findOne(@Param('shiftId') shiftId: string, @RestId() restId: string) { + return this.cashShiftsService.findOne(shiftId, restId); + } +} diff --git a/src/modules/orders/dto/close-cash-shift.dto.ts b/src/modules/orders/dto/close-cash-shift.dto.ts new file mode 100644 index 0000000..90fe8e4 --- /dev/null +++ b/src/modules/orders/dto/close-cash-shift.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class CloseCashShiftDto { + @ApiProperty({ description: 'Physically counted cash amount', minimum: 0 }) + @Type(() => Number) + @IsNumber() + @Min(0) + countedAmount!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/modules/orders/dto/find-cash-shifts.dto.ts b/src/modules/orders/dto/find-cash-shifts.dto.ts new file mode 100644 index 0000000..45b0be5 --- /dev/null +++ b/src/modules/orders/dto/find-cash-shifts.dto.ts @@ -0,0 +1,61 @@ +import { + IsDateString, + IsEnum, + IsIn, + IsNumber, + IsOptional, + IsString, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { CashShiftStatus } from '../interface/cash-shift.interface'; + +const sortOrderOptions = ['asc', 'desc'] as const; +type SortOrder = (typeof sortOrderOptions)[number]; + +export class FindCashShiftsDto { + @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({ enum: CashShiftStatus }) + @IsOptional() + @IsEnum(CashShiftStatus) + status?: CashShiftStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + adminId?: string; + + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ default: 'openedAt' }) + @IsOptional() + @IsString() + orderBy: string = 'openedAt'; + + @ApiPropertyOptional({ enum: sortOrderOptions, default: 'desc' }) + @IsOptional() + @IsIn(sortOrderOptions) + order: SortOrder = 'desc'; +} diff --git a/src/modules/orders/dto/find-orders.dto.ts b/src/modules/orders/dto/find-orders.dto.ts index 1ab7404..9a7e04c 100644 --- a/src/modules/orders/dto/find-orders.dto.ts +++ b/src/modules/orders/dto/find-orders.dto.ts @@ -68,6 +68,11 @@ export class FindOrdersDto { @IsDateString() endDate?: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + cashShiftId?: string; + @ApiPropertyOptional({ default: 'createdAt' }) @IsOptional() @IsString() diff --git a/src/modules/orders/dto/open-cash-shift.dto.ts b/src/modules/orders/dto/open-cash-shift.dto.ts new file mode 100644 index 0000000..89a4256 --- /dev/null +++ b/src/modules/orders/dto/open-cash-shift.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class OpenCashShiftDto { + @ApiProperty({ description: 'Opening cash amount in drawer', minimum: 0 }) + @Type(() => Number) + @IsNumber() + @Min(0) + openingAmount!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/modules/orders/entities/cash-shift.entity.ts b/src/modules/orders/entities/cash-shift.entity.ts new file mode 100644 index 0000000..a02c99a --- /dev/null +++ b/src/modules/orders/entities/cash-shift.entity.ts @@ -0,0 +1,48 @@ +import { Collection, Entity, Enum, Index, ManyToOne, OneToMany, Property } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Admin } from '../../admin/entities/admin.entity'; +import { Restaurant } from '../../restaurants/entities/restaurant.entity'; +import { CashShiftStatus } from '../interface/cash-shift.interface'; +import { Order } from './order.entity'; + +@Entity({ tableName: 'cash_shifts' }) +@Index({ properties: ['restaurant'] }) +@Index({ properties: ['restaurant', 'status'] }) +@Index({ properties: ['admin'] }) +export class CashShift extends BaseEntity { + @ManyToOne(() => Admin) + admin!: Admin; + + @ManyToOne(() => Restaurant) + restaurant!: Restaurant; + + @Property({ columnType: 'timestamptz' }) + openedAt: Date = new Date(); + + @Property({ columnType: 'timestamptz', nullable: true }) + closedAt?: Date | null; + + @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) + openingAmount: number = 0; + + @Property({ type: 'decimal', precision: 10, scale: 0, nullable: true }) + expectedAmount?: number | null; + + @Property({ type: 'decimal', precision: 10, scale: 0, nullable: true }) + countedAmount?: number | null; + + @Property({ type: 'decimal', precision: 10, scale: 0, nullable: true }) + differenceAmount?: number | null; + + @Property({ type: 'int', default: 0, fieldName: 'orders' }) + ordersCount: number = 0; + + @Enum(() => CashShiftStatus) + status: CashShiftStatus = CashShiftStatus.OPEN; + + @Property({ type: 'text', nullable: true }) + notes?: string | null; + + @OneToMany(() => Order, order => order.cashShift) + shiftOrders = new Collection(this); +} diff --git a/src/modules/orders/entities/order.entity.ts b/src/modules/orders/entities/order.entity.ts index 4c50f90..a9d5ded 100644 --- a/src/modules/orders/entities/order.entity.ts +++ b/src/modules/orders/entities/order.entity.ts @@ -20,11 +20,13 @@ import { OrderItem } from './order-item.entity'; import { Delivery } from '../../delivery/entities/delivery.entity'; import { OrderUserAddress, OrderCarAddress } from '../interface/order.interface'; import { Payment } from 'src/modules/payments/entities/payment.entity'; +import { CashShift } from './cash-shift.entity'; @Entity({ tableName: 'orders' }) @Index({ properties: ['restaurant'] }) @Index({ properties: ['restaurant', 'status'] }) @Index({ properties: ['restaurant', 'orderNumber'] }) +@Index({ properties: ['cashShift'] }) export class Order extends BaseEntity { @ManyToOne(() => User, { nullable: true }) user?: User | null; @@ -32,6 +34,9 @@ export class Order extends BaseEntity { @ManyToOne(() => Restaurant) restaurant!: Restaurant; + @ManyToOne(() => CashShift, { nullable: true }) + cashShift?: CashShift | null; + @ManyToOne(() => Delivery) deliveryMethod!: Delivery; diff --git a/src/modules/orders/interface/cash-shift.interface.ts b/src/modules/orders/interface/cash-shift.interface.ts new file mode 100644 index 0000000..0b75e0b --- /dev/null +++ b/src/modules/orders/interface/cash-shift.interface.ts @@ -0,0 +1,21 @@ +import { PaymentMethodEnum } from '../../payments/interface/payment'; + +export enum CashShiftStatus { + OPEN = 'open', + CLOSED = 'closed', +} + +export const SHIFT_REVENUE_PAYMENT_METHODS = [ + PaymentMethodEnum.Cash, + PaymentMethodEnum.Online, + PaymentMethodEnum.CreditCard, +] as const; + +export interface CashShiftSummary { + ordersCount: number; + cashReceived: number; + onlineReceived: number; + creditCardReceived: number; + totalReceived: number; + expectedAmount: number; +} diff --git a/src/modules/orders/orders.module.ts b/src/modules/orders/orders.module.ts index 6208faf..ec36818 100644 --- a/src/modules/orders/orders.module.ts +++ b/src/modules/orders/orders.module.ts @@ -1,9 +1,12 @@ import { Module, forwardRef } from '@nestjs/common'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { OrdersService } from './providers/orders.service'; +import { CashShiftsService } from './providers/cash-shifts.service'; import { OrdersController } from './controllers/orders.controller'; +import { CashShiftsController } from './controllers/cash-shifts.controller'; import { Order } from './entities/order.entity'; import { OrderItem } from './entities/order-item.entity'; +import { CashShift } from './entities/cash-shift.entity'; import { User } from '../users/entities/user.entity'; import { Restaurant } from '../restaurants/entities/restaurant.entity'; import { Food } from '../foods/entities/food.entity'; @@ -15,6 +18,7 @@ import { AuthModule } from '../auth/auth.module'; import { PaymentsModule } from '../payments/payments.module'; import { JwtModule } from '@nestjs/jwt'; import { OrderRepository } from './repositories/order.repository'; +import { CashShiftRepository } from './repositories/cash-shift.repository'; import { OrderListeners } from './listeners/order.listeners'; import { OrdersCrone } from './crone/order.crone'; import { AdminModule } from '../admin/admin.module'; @@ -24,7 +28,7 @@ import { UserModule } from '../users/user.module'; @Module({ imports: [ - MikroOrmModule.forFeature([Order, OrderItem, User, Restaurant, Food, UserAddress, PaymentMethod]), + MikroOrmModule.forFeature([Order, OrderItem, CashShift, User, Restaurant, Food, UserAddress, PaymentMethod]), CartModule, UtilsModule, AuthModule, @@ -35,8 +39,8 @@ import { UserModule } from '../users/user.module'; InventoryModule, forwardRef(() => UserModule) ], - controllers: [OrdersController], - providers: [OrdersService, OrderRepository, OrderListeners, OrdersCrone], - exports: [OrderRepository, OrdersService], + controllers: [OrdersController, CashShiftsController], + providers: [OrdersService, CashShiftsService, OrderRepository, CashShiftRepository, OrderListeners, OrdersCrone], + exports: [OrderRepository, OrdersService, CashShiftsService], }) export class OrdersModule { } diff --git a/src/modules/orders/providers/cash-shifts.service.ts b/src/modules/orders/providers/cash-shifts.service.ts new file mode 100644 index 0000000..ac79331 --- /dev/null +++ b/src/modules/orders/providers/cash-shifts.service.ts @@ -0,0 +1,157 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { CashShift } from '../entities/cash-shift.entity'; +import { CashShiftStatus, CashShiftSummary } from '../interface/cash-shift.interface'; +import { CashShiftRepository } from '../repositories/cash-shift.repository'; +import { OpenCashShiftDto } from '../dto/open-cash-shift.dto'; +import { CloseCashShiftDto } from '../dto/close-cash-shift.dto'; +import { FindCashShiftsDto } from '../dto/find-cash-shifts.dto'; +import { Admin } from '../../admin/entities/admin.entity'; +import { Restaurant } from '../../restaurants/entities/restaurant.entity'; +import { Order } from '../entities/order.entity'; +import { CashShiftMessage } from 'src/common/enums/message.enum'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; +import { calculateCashShiftSummary } from '../utils/calculate-cash-shift-summary.util'; + +@Injectable() +export class CashShiftsService { + constructor( + private readonly em: EntityManager, + private readonly cashShiftRepository: CashShiftRepository, + ) {} + + async openShift(restaurantId: string, adminId: string, dto: OpenCashShiftDto): Promise { + const existingOpenShift = await this.cashShiftRepository.findOpenShift(restaurantId); + if (existingOpenShift) { + throw new BadRequestException(CashShiftMessage.ALREADY_OPEN); + } + + const [admin, restaurant] = await Promise.all([ + this.em.findOne(Admin, { id: adminId }), + this.em.findOne(Restaurant, { id: restaurantId }), + ]); + + if (!admin) { + throw new NotFoundException(CashShiftMessage.ADMIN_NOT_FOUND); + } + if (!restaurant) { + throw new NotFoundException(CashShiftMessage.RESTAURANT_NOT_FOUND); + } + + const shift = this.em.create(CashShift, { + admin, + restaurant, + openedAt: new Date(), + openingAmount: dto.openingAmount, + ordersCount: 0, + notes: dto.notes ?? null, + status: CashShiftStatus.OPEN, + }); + + await this.em.persistAndFlush(shift); + await this.em.populate(shift, ['admin', 'restaurant']); + return shift; + } + + async closeShift(shiftId: string, restaurantId: string, dto: CloseCashShiftDto): Promise { + const shift = await this.em.findOne( + CashShift, + { id: shiftId, restaurant: { id: restaurantId } }, + { populate: ['admin', 'restaurant'] }, + ); + + if (!shift) { + throw new NotFoundException(CashShiftMessage.NOT_FOUND); + } + if (shift.status === CashShiftStatus.CLOSED) { + throw new BadRequestException(CashShiftMessage.ALREADY_CLOSED); + } + + const summary = await this.calculateShiftSummary(shiftId, restaurantId); + const countedAmount = dto.countedAmount; + + shift.expectedAmount = summary.expectedAmount; + shift.countedAmount = countedAmount; + shift.differenceAmount = countedAmount - summary.expectedAmount; + shift.ordersCount = summary.ordersCount; + shift.closedAt = new Date(); + shift.status = CashShiftStatus.CLOSED; + if (dto.notes !== undefined) { + shift.notes = dto.notes; + } + + await this.em.persistAndFlush(shift); + return shift; + } + + async calculateShiftSummary(shiftId: string, restaurantId: string): Promise { + const shift = await this.em.findOne(CashShift, { id: shiftId, restaurant: { id: restaurantId } }); + if (!shift) { + throw new NotFoundException(CashShiftMessage.NOT_FOUND); + } + + const orders = await this.getShiftOrders(shift, restaurantId); + return calculateCashShiftSummary(Number(shift.openingAmount), orders); + } + + async calculateCurrentShiftSummary(restaurantId: string): Promise { + const shift = await this.cashShiftRepository.findOpenShift(restaurantId); + if (!shift) { + return null; + } + + const orders = await this.getShiftOrders(shift, restaurantId); + return calculateCashShiftSummary(Number(shift.openingAmount), orders); + } + + findAll(restaurantId: string, dto: FindCashShiftsDto): Promise> { + return this.cashShiftRepository.findAllPaginated(restaurantId, dto); + } + + async findOne(shiftId: string, restaurantId: string): Promise { + const shift = await this.em.findOne( + CashShift, + { id: shiftId, restaurant: { id: restaurantId } }, + { populate: ['admin', 'restaurant'] }, + ); + + if (!shift) { + throw new NotFoundException(CashShiftMessage.NOT_FOUND); + } + + return shift; + } + + getCurrentOpenShift(restaurantId: string): Promise { + return this.cashShiftRepository.findOpenShift(restaurantId); + } + + async assignOrderToOpenShift(order: Order, em: EntityManager = this.em): Promise { + if (order.cashShift) { + return; + } + + const restaurantId = typeof order.restaurant === 'string' ? order.restaurant : order.restaurant.id; + const openShift = await em.findOne(CashShift, { + restaurant: { id: restaurantId }, + status: CashShiftStatus.OPEN, + }); + + if (!openShift) { + return; + } + + order.cashShift = openShift; + openShift.ordersCount += 1; + em.persist(order); + em.persist(openShift); + } + + private getShiftOrders(shift: CashShift, restaurantId: string): Promise { + return this.em.find( + Order, + { cashShift: shift, restaurant: { id: restaurantId } }, + { populate: ['payments'] }, + ); + } +} diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index 903b4e1..ce67788 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -29,6 +29,7 @@ import { AdminCreateOrderDto } from '../dto/admin-create-order.dto'; import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto'; import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto'; import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto'; +import { CashShiftsService } from './cash-shifts.service'; type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number }; type ValidatedCartForOrder = { @@ -63,6 +64,7 @@ export class OrdersService { private readonly paymentsService: PaymentsService, private readonly inventoryService: InventoryService, private readonly eventEmitter: EventEmitter2, + private readonly cashShiftsService: CashShiftsService, ) { } async checkout(userId: string, restaurantId: string) { @@ -133,6 +135,7 @@ export class OrdersService { })), }; await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto); + await this.cashShiftsService.assignOrderToOpenShift(order, em); await em.flush(); this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`); return order; @@ -230,6 +233,7 @@ export class OrdersService { items: orderItemsData.map(item => ({ foodId: item.food.id, quantity: item.quantity })), }; await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto); + await this.cashShiftsService.assignOrderToOpenShift(order, em); await em.flush(); this.logger.debug(`Admin created order ${order.id} for user ${user?.id || 'anonymous'} (restaurant: ${restaurantId})`); @@ -347,6 +351,7 @@ export class OrdersService { orderBy: dto.orderBy, order: dto.order, excludeOnlinePendingPayment: true, + cashShiftId: dto.cashShiftId, }); return result; @@ -363,6 +368,7 @@ export class OrdersService { 'userAddress', 'carAddress', 'paymentMethod', + 'cashShift', 'payments', 'items', 'items.food', diff --git a/src/modules/orders/repositories/cash-shift.repository.ts b/src/modules/orders/repositories/cash-shift.repository.ts new file mode 100644 index 0000000..f6b35bd --- /dev/null +++ b/src/modules/orders/repositories/cash-shift.repository.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; +import { CashShift } from '../entities/cash-shift.entity'; +import { CashShiftStatus } from '../interface/cash-shift.interface'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; + +type FindCashShiftsOpts = { + page?: number; + limit?: number; + status?: CashShiftStatus; + adminId?: string; + startDate?: string; + endDate?: string; + orderBy?: string; + order?: 'asc' | 'desc'; +}; + +@Injectable() +export class CashShiftRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, CashShift); + } + + async findAllPaginated(restId: string, opts: FindCashShiftsOpts = {}): Promise> { + const { + page = 1, + limit = 10, + status, + adminId, + startDate, + endDate, + orderBy = 'openedAt', + order = 'desc', + } = opts; + + const offset = (page - 1) * limit; + const where: FilterQuery = { restaurant: { id: restId } }; + + if (status) { + where.status = status; + } + + if (adminId) { + where.admin = { id: adminId }; + } + + if (startDate || endDate) { + where.openedAt = {}; + if (startDate) { + where.openedAt.$gte = new Date(startDate); + } + if (endDate) { + where.openedAt.$lte = new Date(endDate); + } + } + + const [data, total] = await this.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order }, + populate: ['admin', 'restaurant'], + }); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + findOpenShift(restId: string): Promise { + return this.findOne( + { restaurant: { id: restId }, status: CashShiftStatus.OPEN }, + { populate: ['admin'] }, + ); + } +} diff --git a/src/modules/orders/repositories/order.repository.ts b/src/modules/orders/repositories/order.repository.ts index 130d931..8602e03 100644 --- a/src/modules/orders/repositories/order.repository.ts +++ b/src/modules/orders/repositories/order.repository.ts @@ -18,6 +18,7 @@ type FindOrdersOpts = { orderBy?: string; order?: 'asc' | 'desc'; userId?: string; + cashShiftId?: string; excludeOnlinePendingPayment?: boolean; }; @@ -43,6 +44,7 @@ export class OrderRepository extends EntityRepository { order = 'desc', userId, excludeOnlinePendingPayment = false, + cashShiftId, } = opts; const offset = (page - 1) * limit; @@ -70,6 +72,10 @@ export class OrderRepository extends EntityRepository { where.user = { id: userId }; } + if (cashShiftId) { + where.cashShift = { id: cashShiftId }; + } + // Filter by date range if (startDate || endDate) { where.createdAt = {}; @@ -118,7 +124,7 @@ export class OrderRepository extends EntityRepository { limit, offset, orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, - populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'items', 'items.food'] as never, + populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'items', 'items.food'] as never, }); // Collect all (orderId, foodId) pairs for efficient review lookup diff --git a/src/modules/orders/utils/calculate-cash-shift-summary.util.ts b/src/modules/orders/utils/calculate-cash-shift-summary.util.ts new file mode 100644 index 0000000..2df77ac --- /dev/null +++ b/src/modules/orders/utils/calculate-cash-shift-summary.util.ts @@ -0,0 +1,43 @@ +import { Order } from '../entities/order.entity'; +import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment'; +import { CashShiftSummary, SHIFT_REVENUE_PAYMENT_METHODS } from '../interface/cash-shift.interface'; + +const shiftRevenueMethods = new Set(SHIFT_REVENUE_PAYMENT_METHODS); + +export function calculateCashShiftSummary(openingAmount: number, orders: Order[]): CashShiftSummary { + let cashReceived = 0; + let onlineReceived = 0; + let creditCardReceived = 0; + + for (const order of orders) { + for (const payment of order.payments.getItems()) { + if (payment.status !== PaymentStatusEnum.Paid || !shiftRevenueMethods.has(payment.method)) { + continue; + } + + const amount = Number(payment.amount); + switch (payment.method) { + case PaymentMethodEnum.Cash: + cashReceived += amount; + break; + case PaymentMethodEnum.Online: + onlineReceived += amount; + break; + case PaymentMethodEnum.CreditCard: + creditCardReceived += amount; + break; + } + } + } + + const totalReceived = cashReceived + onlineReceived + creditCardReceived; + + return { + ordersCount: orders.length, + cashReceived, + onlineReceived, + creditCardReceived, + totalReceived, + expectedAmount: Number(openingAmount) + totalReceived, + }; +}