cash shift
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260714120000_addCashShifts extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -788,6 +788,14 @@ export const enum PagerMessage {
|
|||||||
CREATED_SUCCESS = 'درخواست پیج با موفقیت ایجاد شد',
|
CREATED_SUCCESS = 'درخواست پیج با موفقیت ایجاد شد',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const enum CashShiftMessage {
|
||||||
|
NOT_FOUND = 'شیفت صندوق یافت نشد',
|
||||||
|
ALREADY_OPEN = 'یک شیفت صندوق باز از قبل وجود دارد',
|
||||||
|
ALREADY_CLOSED = 'این شیفت صندوق قبلا بسته شده است',
|
||||||
|
ADMIN_NOT_FOUND = 'مدیر یافت نشد',
|
||||||
|
RESTAURANT_NOT_FOUND = 'رستوران یافت نشد',
|
||||||
|
}
|
||||||
|
|
||||||
export const enum UserSuccessMessage {
|
export const enum UserSuccessMessage {
|
||||||
ADDRESS_DELETED_SUCCESS = 'آدرس با موفقیت حذف شد',
|
ADDRESS_DELETED_SUCCESS = 'آدرس با موفقیت حذف شد',
|
||||||
SCORE_CONVERTED_SUCCESS = 'امتیاز با موفقیت به کیف پول تبدیل شد',
|
SCORE_CONVERTED_SUCCESS = 'امتیاز با موفقیت به کیف پول تبدیل شد',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -68,6 +68,11 @@ export class FindOrdersDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cashShiftId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 'createdAt' })
|
@ApiPropertyOptional({ default: 'createdAt' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Order>(this);
|
||||||
|
}
|
||||||
@@ -20,11 +20,13 @@ import { OrderItem } from './order-item.entity';
|
|||||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||||
import { OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
import { OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
||||||
import { Payment } from 'src/modules/payments/entities/payment.entity';
|
import { Payment } from 'src/modules/payments/entities/payment.entity';
|
||||||
|
import { CashShift } from './cash-shift.entity';
|
||||||
|
|
||||||
@Entity({ tableName: 'orders' })
|
@Entity({ tableName: 'orders' })
|
||||||
@Index({ properties: ['restaurant'] })
|
@Index({ properties: ['restaurant'] })
|
||||||
@Index({ properties: ['restaurant', 'status'] })
|
@Index({ properties: ['restaurant', 'status'] })
|
||||||
@Index({ properties: ['restaurant', 'orderNumber'] })
|
@Index({ properties: ['restaurant', 'orderNumber'] })
|
||||||
|
@Index({ properties: ['cashShift'] })
|
||||||
export class Order extends BaseEntity {
|
export class Order extends BaseEntity {
|
||||||
@ManyToOne(() => User, { nullable: true })
|
@ManyToOne(() => User, { nullable: true })
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
@@ -32,6 +34,9 @@ export class Order extends BaseEntity {
|
|||||||
@ManyToOne(() => Restaurant)
|
@ManyToOne(() => Restaurant)
|
||||||
restaurant!: Restaurant;
|
restaurant!: Restaurant;
|
||||||
|
|
||||||
|
@ManyToOne(() => CashShift, { nullable: true })
|
||||||
|
cashShift?: CashShift | null;
|
||||||
|
|
||||||
@ManyToOne(() => Delivery)
|
@ManyToOne(() => Delivery)
|
||||||
deliveryMethod!: Delivery;
|
deliveryMethod!: Delivery;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
import { OrdersService } from './providers/orders.service';
|
import { OrdersService } from './providers/orders.service';
|
||||||
|
import { CashShiftsService } from './providers/cash-shifts.service';
|
||||||
import { OrdersController } from './controllers/orders.controller';
|
import { OrdersController } from './controllers/orders.controller';
|
||||||
|
import { CashShiftsController } from './controllers/cash-shifts.controller';
|
||||||
import { Order } from './entities/order.entity';
|
import { Order } from './entities/order.entity';
|
||||||
import { OrderItem } from './entities/order-item.entity';
|
import { OrderItem } from './entities/order-item.entity';
|
||||||
|
import { CashShift } from './entities/cash-shift.entity';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||||
import { Food } from '../foods/entities/food.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 { PaymentsModule } from '../payments/payments.module';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { OrderRepository } from './repositories/order.repository';
|
import { OrderRepository } from './repositories/order.repository';
|
||||||
|
import { CashShiftRepository } from './repositories/cash-shift.repository';
|
||||||
import { OrderListeners } from './listeners/order.listeners';
|
import { OrderListeners } from './listeners/order.listeners';
|
||||||
import { OrdersCrone } from './crone/order.crone';
|
import { OrdersCrone } from './crone/order.crone';
|
||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '../admin/admin.module';
|
||||||
@@ -24,7 +28,7 @@ import { UserModule } from '../users/user.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
MikroOrmModule.forFeature([Order, OrderItem, User, Restaurant, Food, UserAddress, PaymentMethod]),
|
MikroOrmModule.forFeature([Order, OrderItem, CashShift, User, Restaurant, Food, UserAddress, PaymentMethod]),
|
||||||
CartModule,
|
CartModule,
|
||||||
UtilsModule,
|
UtilsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -35,8 +39,8 @@ import { UserModule } from '../users/user.module';
|
|||||||
InventoryModule,
|
InventoryModule,
|
||||||
forwardRef(() => UserModule)
|
forwardRef(() => UserModule)
|
||||||
],
|
],
|
||||||
controllers: [OrdersController],
|
controllers: [OrdersController, CashShiftsController],
|
||||||
providers: [OrdersService, OrderRepository, OrderListeners, OrdersCrone],
|
providers: [OrdersService, CashShiftsService, OrderRepository, CashShiftRepository, OrderListeners, OrdersCrone],
|
||||||
exports: [OrderRepository, OrdersService],
|
exports: [OrderRepository, OrdersService, CashShiftsService],
|
||||||
})
|
})
|
||||||
export class OrdersModule { }
|
export class OrdersModule { }
|
||||||
|
|||||||
@@ -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<CashShift> {
|
||||||
|
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<CashShift> {
|
||||||
|
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<CashShiftSummary> {
|
||||||
|
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<CashShiftSummary | null> {
|
||||||
|
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<PaginatedResult<CashShift>> {
|
||||||
|
return this.cashShiftRepository.findAllPaginated(restaurantId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(shiftId: string, restaurantId: string): Promise<CashShift> {
|
||||||
|
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<CashShift | null> {
|
||||||
|
return this.cashShiftRepository.findOpenShift(restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignOrderToOpenShift(order: Order, em: EntityManager = this.em): Promise<void> {
|
||||||
|
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<Order[]> {
|
||||||
|
return this.em.find(
|
||||||
|
Order,
|
||||||
|
{ cashShift: shift, restaurant: { id: restaurantId } },
|
||||||
|
{ populate: ['payments'] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { AdminCreateOrderDto } from '../dto/admin-create-order.dto';
|
|||||||
import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
|
import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
|
||||||
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
|
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
|
||||||
import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-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 OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
|
||||||
|
|
||||||
type ValidatedCartForOrder = {
|
type ValidatedCartForOrder = {
|
||||||
@@ -63,6 +64,7 @@ export class OrdersService {
|
|||||||
private readonly paymentsService: PaymentsService,
|
private readonly paymentsService: PaymentsService,
|
||||||
private readonly inventoryService: InventoryService,
|
private readonly inventoryService: InventoryService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
private readonly cashShiftsService: CashShiftsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async checkout(userId: string, restaurantId: string) {
|
async checkout(userId: string, restaurantId: string) {
|
||||||
@@ -133,6 +135,7 @@ export class OrdersService {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto);
|
await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto);
|
||||||
|
await this.cashShiftsService.assignOrderToOpenShift(order, em);
|
||||||
await em.flush();
|
await em.flush();
|
||||||
this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`);
|
this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`);
|
||||||
return order;
|
return order;
|
||||||
@@ -230,6 +233,7 @@ export class OrdersService {
|
|||||||
items: orderItemsData.map(item => ({ foodId: item.food.id, quantity: item.quantity })),
|
items: orderItemsData.map(item => ({ foodId: item.food.id, quantity: item.quantity })),
|
||||||
};
|
};
|
||||||
await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto);
|
await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto);
|
||||||
|
await this.cashShiftsService.assignOrderToOpenShift(order, em);
|
||||||
await em.flush();
|
await em.flush();
|
||||||
|
|
||||||
this.logger.debug(`Admin created order ${order.id} for user ${user?.id || 'anonymous'} (restaurant: ${restaurantId})`);
|
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,
|
orderBy: dto.orderBy,
|
||||||
order: dto.order,
|
order: dto.order,
|
||||||
excludeOnlinePendingPayment: true,
|
excludeOnlinePendingPayment: true,
|
||||||
|
cashShiftId: dto.cashShiftId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -363,6 +368,7 @@ export class OrdersService {
|
|||||||
'userAddress',
|
'userAddress',
|
||||||
'carAddress',
|
'carAddress',
|
||||||
'paymentMethod',
|
'paymentMethod',
|
||||||
|
'cashShift',
|
||||||
'payments',
|
'payments',
|
||||||
'items',
|
'items',
|
||||||
'items.food',
|
'items.food',
|
||||||
|
|||||||
@@ -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<CashShift> {
|
||||||
|
constructor(readonly em: EntityManager) {
|
||||||
|
super(em, CashShift);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllPaginated(restId: string, opts: FindCashShiftsOpts = {}): Promise<PaginatedResult<CashShift>> {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
status,
|
||||||
|
adminId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
orderBy = 'openedAt',
|
||||||
|
order = 'desc',
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const where: FilterQuery<CashShift> = { 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<CashShift | null> {
|
||||||
|
return this.findOne(
|
||||||
|
{ restaurant: { id: restId }, status: CashShiftStatus.OPEN },
|
||||||
|
{ populate: ['admin'] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ type FindOrdersOpts = {
|
|||||||
orderBy?: string;
|
orderBy?: string;
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
cashShiftId?: string;
|
||||||
excludeOnlinePendingPayment?: boolean;
|
excludeOnlinePendingPayment?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
order = 'desc',
|
order = 'desc',
|
||||||
userId,
|
userId,
|
||||||
excludeOnlinePendingPayment = false,
|
excludeOnlinePendingPayment = false,
|
||||||
|
cashShiftId,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -70,6 +72,10 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
where.user = { id: userId };
|
where.user = { id: userId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cashShiftId) {
|
||||||
|
where.cashShift = { id: cashShiftId };
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by date range
|
// Filter by date range
|
||||||
if (startDate || endDate) {
|
if (startDate || endDate) {
|
||||||
where.createdAt = {};
|
where.createdAt = {};
|
||||||
@@ -118,7 +124,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
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
|
// Collect all (orderId, foodId) pairs for efficient review lookup
|
||||||
|
|||||||
@@ -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<PaymentMethodEnum>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user