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 = 'درخواست پیج با موفقیت ایجاد شد',
|
||||
}
|
||||
|
||||
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 = 'امتیاز با موفقیت به کیف پول تبدیل شد',
|
||||
|
||||
@@ -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()
|
||||
endDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cashShiftId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@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 { 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;
|
||||
|
||||
|
||||
@@ -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 { 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 { }
|
||||
|
||||
@@ -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 { 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',
|
||||
|
||||
@@ -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;
|
||||
order?: 'asc' | 'desc';
|
||||
userId?: string;
|
||||
cashShiftId?: string;
|
||||
excludeOnlinePendingPayment?: boolean;
|
||||
};
|
||||
|
||||
@@ -43,6 +44,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
order = 'desc',
|
||||
userId,
|
||||
excludeOnlinePendingPayment = false,
|
||||
cashShiftId,
|
||||
} = opts;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -70,6 +72,10 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
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<Order> {
|
||||
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
|
||||
|
||||
@@ -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