shift
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-14 19:59:20 +03:30
parent b9d7a4adc3
commit 13f21d4d3f
9 changed files with 115 additions and 153 deletions
+1
View File
@@ -792,6 +792,7 @@ export const enum CashShiftMessage {
NOT_FOUND = 'شیفت صندوق یافت نشد', NOT_FOUND = 'شیفت صندوق یافت نشد',
ALREADY_OPEN = 'یک شیفت صندوق باز از قبل وجود دارد', ALREADY_OPEN = 'یک شیفت صندوق باز از قبل وجود دارد',
ALREADY_CLOSED = 'این شیفت صندوق قبلا بسته شده است', ALREADY_CLOSED = 'این شیفت صندوق قبلا بسته شده است',
ACCESS_DENIED = 'شما به این شیفت صندوق دسترسی ندارید',
ADMIN_NOT_FOUND = 'مدیر یافت نشد', ADMIN_NOT_FOUND = 'مدیر یافت نشد',
RESTAURANT_NOT_FOUND = 'رستوران یافت نشد', RESTAURANT_NOT_FOUND = 'رستوران یافت نشد',
} }
+6
View File
@@ -10,6 +10,9 @@ export enum Permission {
// Order Management // Order Management
MANAGE_ORDERS = 'manage_orders', MANAGE_ORDERS = 'manage_orders',
VIEW_ORDER_REPORTS = 'view_order_reports',
MANAGE_SHIFTS = 'manage_shifts',
VIEW_ALL_SHIFTS = 'view_all_shifts',
// User & Admin Management // User & Admin Management
MANAGE_ADMINS = 'manage_admins', MANAGE_ADMINS = 'manage_admins',
@@ -46,6 +49,9 @@ export const PermissionTitles: Record<Permission, string> = {
[Permission.MANAGE_FOODS]: 'مدیریت غذاها', [Permission.MANAGE_FOODS]: 'مدیریت غذاها',
[Permission.MANAGE_CATEGORIES]: 'مدیریت دسته‌بندی‌ها', [Permission.MANAGE_CATEGORIES]: 'مدیریت دسته‌بندی‌ها',
[Permission.MANAGE_ORDERS]: 'مدیریت سفارشات', [Permission.MANAGE_ORDERS]: 'مدیریت سفارشات',
[Permission.VIEW_ORDER_REPORTS]: 'مشاهده گزارشات سفارش',
[Permission.MANAGE_SHIFTS]: 'مدیریت شیفت‌ها',
[Permission.VIEW_ALL_SHIFTS]: 'مشاهده همه شیفت‌ها',
[Permission.MANAGE_ADMINS]: 'مدیریت مدیران', [Permission.MANAGE_ADMINS]: 'مدیریت مدیران',
[Permission.MANAGE_USERS]: 'مدیریت کاربران', [Permission.MANAGE_USERS]: 'مدیریت کاربران',
[Permission.VIEW_REPORTS]: 'مشاهده گزارشات', [Permission.VIEW_REPORTS]: 'مشاهده گزارشات',
@@ -9,7 +9,6 @@ import { Permission } from 'src/common/enums/permission.enum';
import { OpenCashShiftDto } from '../dto/open-cash-shift.dto'; import { OpenCashShiftDto } from '../dto/open-cash-shift.dto';
import { CloseCashShiftDto } from '../dto/close-cash-shift.dto'; import { CloseCashShiftDto } from '../dto/close-cash-shift.dto';
import { FindCashShiftsDto } from '../dto/find-cash-shifts.dto'; import { FindCashShiftsDto } from '../dto/find-cash-shifts.dto';
import { UpdateCashShiftDto } from '../dto/update-cash-shift.dto';
@ApiTags('cash-shifts') @ApiTags('cash-shifts')
@ApiBearerAuth() @ApiBearerAuth()
@@ -18,7 +17,7 @@ export class CashShiftsController {
constructor(private readonly cashShiftsService: CashShiftsService) {} constructor(private readonly cashShiftsService: CashShiftsService) {}
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS) @Permissions(Permission.MANAGE_SHIFTS)
@Post('admin/cash-shifts/open') @Post('admin/cash-shifts/open')
@ApiOperation({ summary: 'Open a new cash shift' }) @ApiOperation({ summary: 'Open a new cash shift' })
@ApiBody({ type: OpenCashShiftDto }) @ApiBody({ type: OpenCashShiftDto })
@@ -27,21 +26,7 @@ export class CashShiftsController {
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS) @Permissions(Permission.MANAGE_SHIFTS)
@Patch('admin/cash-shifts/:shiftId')
@ApiOperation({ summary: 'Update a cash shift' })
@ApiParam({ name: 'shiftId', description: 'Cash shift ID' })
@ApiBody({ type: UpdateCashShiftDto })
updateShift(
@Param('shiftId') shiftId: string,
@RestId() restId: string,
@Body() dto: UpdateCashShiftDto,
) {
return this.cashShiftsService.updateShift(shiftId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/cash-shifts/:shiftId/close') @Patch('admin/cash-shifts/:shiftId/close')
@ApiOperation({ summary: 'Close an open cash shift' }) @ApiOperation({ summary: 'Close an open cash shift' })
@ApiParam({ name: 'shiftId', description: 'Cash shift ID' }) @ApiParam({ name: 'shiftId', description: 'Cash shift ID' })
@@ -49,50 +34,59 @@ export class CashShiftsController {
closeShift( closeShift(
@Param('shiftId') shiftId: string, @Param('shiftId') shiftId: string,
@RestId() restId: string, @RestId() restId: string,
@AdminId() adminId: string,
@Body() dto: CloseCashShiftDto, @Body() dto: CloseCashShiftDto,
) { ) {
return this.cashShiftsService.closeShift(shiftId, restId, dto); return this.cashShiftsService.closeShift(shiftId, restId, adminId, dto);
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS) @Permissions(Permission.MANAGE_SHIFTS)
@Get('admin/cash-shifts/current/summary') @Get('admin/cash-shifts/current/summary')
@ApiOperation({ summary: 'Calculate summary for the current open cash shift' }) @ApiOperation({ summary: 'Calculate summary for the current open cash shift' })
getCurrentShiftSummary(@RestId() restId: string) { getCurrentShiftSummary(@RestId() restId: string, @AdminId() adminId: string) {
return this.cashShiftsService.calculateCurrentShiftSummary(restId); return this.cashShiftsService.calculateCurrentShiftSummary(restId, adminId);
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS) @Permissions(Permission.MANAGE_SHIFTS)
@Get('admin/cash-shifts/current') @Get('admin/cash-shifts/current')
@ApiOperation({ summary: 'Get the current open cash shift' }) @ApiOperation({ summary: 'Get the current open cash shift' })
getCurrentOpenShift(@RestId() restId: string) { getCurrentOpenShift(@RestId() restId: string, @AdminId() adminId: string) {
return this.cashShiftsService.getCurrentOpenShift(restId); return this.cashShiftsService.getCurrentOpenShift(restId, adminId);
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS) @Permissions(Permission.MANAGE_SHIFTS)
@Get('admin/cash-shifts') @Get('admin/cash-shifts')
@ApiOperation({ summary: 'List cash shifts with pagination and filters' }) @ApiOperation({ summary: 'List cash shifts with pagination and filters' })
findAll(@RestId() restId: string, @Query() dto: FindCashShiftsDto) { findAll(@RestId() restId: string, @AdminId() adminId: string, @Query() dto: FindCashShiftsDto) {
return this.cashShiftsService.findAll(restId, dto); return this.cashShiftsService.findAll(restId, adminId, dto);
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS) @Permissions(Permission.MANAGE_SHIFTS)
@Get('admin/cash-shifts/:shiftId/summary') @Get('admin/cash-shifts/:shiftId/summary')
@ApiOperation({ summary: 'Calculate summary for a cash shift' }) @ApiOperation({ summary: 'Calculate summary for a cash shift' })
@ApiParam({ name: 'shiftId', description: 'Cash shift ID' }) @ApiParam({ name: 'shiftId', description: 'Cash shift ID' })
getShiftSummary(@Param('shiftId') shiftId: string, @RestId() restId: string) { getShiftSummary(
return this.cashShiftsService.calculateShiftSummary(shiftId, restId); @Param('shiftId') shiftId: string,
@RestId() restId: string,
@AdminId() adminId: string,
) {
return this.cashShiftsService.calculateShiftSummary(shiftId, restId, adminId);
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS) @Permissions(Permission.MANAGE_SHIFTS)
@Get('admin/cash-shifts/:shiftId') @Get('admin/cash-shifts/:shiftId')
@ApiOperation({ summary: 'Get a cash shift by ID' }) @ApiOperation({ summary: 'Get a cash shift by ID' })
@ApiParam({ name: 'shiftId', description: 'Cash shift ID' }) @ApiParam({ name: 'shiftId', description: 'Cash shift ID' })
findOne(@Param('shiftId') shiftId: string, @RestId() restId: string) { findOne(
return this.cashShiftsService.findOne(shiftId, restId); @Param('shiftId') shiftId: string,
@RestId() restId: string,
@AdminId() adminId: string,
) {
return this.cashShiftsService.findOne(shiftId, restId, adminId);
} }
} }
@@ -146,7 +146,7 @@ export class OrdersController {
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.VIEW_REPORTS) @Permissions(Permission.VIEW_ORDER_REPORTS)
@ApiOperation({ summary: 'Get order report grouped by food' }) @ApiOperation({ summary: 'Get order report grouped by food' })
@Get('admin/orders/report/by-food') @Get('admin/orders/report/by-food')
getFoodOrderReport(@RestId() restId: string, @Query() dto: FoodOrderReportDto) { getFoodOrderReport(@RestId() restId: string, @Query() dto: FoodOrderReportDto) {
@@ -154,7 +154,7 @@ export class OrdersController {
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.VIEW_REPORTS) @Permissions(Permission.VIEW_ORDER_REPORTS)
@ApiOperation({ summary: 'Get order report grouped by day' }) @ApiOperation({ summary: 'Get order report grouped by day' })
@Get('admin/orders/report/by-day') @Get('admin/orders/report/by-day')
getDailyOrderReport(@RestId() restId: string, @Query() dto: DailyOrderReportDto) { getDailyOrderReport(@RestId() restId: string, @Query() dto: DailyOrderReportDto) {
@@ -1,17 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class UpdateCashShiftDto {
@ApiPropertyOptional({ description: 'Opening cash amount in drawer', minimum: 0 })
@Type(() => Number)
@IsOptional()
@IsNumber()
@Min(0)
openingAmount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql'; import { EntityManager } from '@mikro-orm/postgresql';
import { CashShift } from '../entities/cash-shift.entity'; import { CashShift } from '../entities/cash-shift.entity';
import { CashShiftStatus, CashShiftSummary } from '../interface/cash-shift.interface'; import { CashShiftStatus, CashShiftSummary } from '../interface/cash-shift.interface';
@@ -6,19 +6,21 @@ import { CashShiftRepository } from '../repositories/cash-shift.repository';
import { OpenCashShiftDto } from '../dto/open-cash-shift.dto'; import { OpenCashShiftDto } from '../dto/open-cash-shift.dto';
import { CloseCashShiftDto } from '../dto/close-cash-shift.dto'; import { CloseCashShiftDto } from '../dto/close-cash-shift.dto';
import { FindCashShiftsDto } from '../dto/find-cash-shifts.dto'; import { FindCashShiftsDto } from '../dto/find-cash-shifts.dto';
import { UpdateCashShiftDto } from '../dto/update-cash-shift.dto';
import { Admin } from '../../admin/entities/admin.entity'; import { Admin } from '../../admin/entities/admin.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { Order } from '../entities/order.entity'; import { Order } from '../entities/order.entity';
import { CashShiftMessage } from 'src/common/enums/message.enum'; import { CashShiftMessage } from 'src/common/enums/message.enum';
import { Permission } from 'src/common/enums/permission.enum';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { calculateCashShiftSummary } from '../utils/calculate-cash-shift-summary.util'; import { calculateCashShiftSummary } from '../utils/calculate-cash-shift-summary.util';
import { PermissionsService } from 'src/modules/roles/providers/permissions.service';
@Injectable() @Injectable()
export class CashShiftsService { export class CashShiftsService {
constructor( constructor(
private readonly em: EntityManager, private readonly em: EntityManager,
private readonly cashShiftRepository: CashShiftRepository, private readonly cashShiftRepository: CashShiftRepository,
private readonly permissionsService: PermissionsService,
) {} ) {}
async openShift(restaurantId: string, adminId: string, dto: OpenCashShiftDto): Promise<CashShift> { async openShift(restaurantId: string, adminId: string, dto: OpenCashShiftDto): Promise<CashShift> {
@@ -54,7 +56,12 @@ export class CashShiftsService {
return shift; return shift;
} }
async updateShift(shiftId: string, restaurantId: string, dto: UpdateCashShiftDto): Promise<CashShift> { async closeShift(
shiftId: string,
restaurantId: string,
adminId: string,
dto: CloseCashShiftDto,
): Promise<CashShift> {
const shift = await this.em.findOne( const shift = await this.em.findOne(
CashShift, CashShift,
{ id: shiftId, restaurant: { id: restaurantId } }, { id: shiftId, restaurant: { id: restaurantId } },
@@ -65,40 +72,13 @@ export class CashShiftsService {
throw new NotFoundException(CashShiftMessage.NOT_FOUND); throw new NotFoundException(CashShiftMessage.NOT_FOUND);
} }
if (dto.openingAmount !== undefined) { await this.assertShiftAccess(shift, adminId, restaurantId);
shift.openingAmount = dto.openingAmount;
if (shift.status === CashShiftStatus.CLOSED && shift.countedAmount !== null) {
const summary = await this.calculateShiftSummary(shiftId, restaurantId);
shift.expectedAmount = summary.expectedAmount;
shift.differenceAmount = Number(shift.countedAmount) - summary.expectedAmount;
shift.ordersCount = summary.ordersCount;
}
}
if (dto.notes !== undefined) {
shift.notes = dto.notes;
}
await this.em.persistAndFlush(shift);
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) { if (shift.status === CashShiftStatus.CLOSED) {
throw new BadRequestException(CashShiftMessage.ALREADY_CLOSED); throw new BadRequestException(CashShiftMessage.ALREADY_CLOSED);
} }
const summary = await this.calculateShiftSummary(shiftId, restaurantId); const summary = await this.calculateShiftSummary(shiftId, restaurantId, adminId);
const countedAmount = dto.countedAmount; const countedAmount = dto.countedAmount;
shift.expectedAmount = summary.expectedAmount; shift.expectedAmount = summary.expectedAmount;
@@ -115,18 +95,21 @@ export class CashShiftsService {
return shift; return shift;
} }
async calculateShiftSummary(shiftId: string, restaurantId: string): Promise<CashShiftSummary> { async calculateShiftSummary(
const shift = await this.em.findOne(CashShift, { id: shiftId, restaurant: { id: restaurantId } }); shiftId: string,
if (!shift) { restaurantId: string,
throw new NotFoundException(CashShiftMessage.NOT_FOUND); adminId: string,
} ): Promise<CashShiftSummary> {
const shift = await this.findOne(shiftId, restaurantId, adminId);
const orders = await this.getShiftOrders(shift, restaurantId); const orders = await this.getShiftOrders(shift, restaurantId);
return calculateCashShiftSummary(Number(shift.openingAmount), orders); return calculateCashShiftSummary(Number(shift.openingAmount), orders);
} }
async calculateCurrentShiftSummary(restaurantId: string): Promise<CashShiftSummary | null> { async calculateCurrentShiftSummary(
const shift = await this.cashShiftRepository.findOpenShift(restaurantId); restaurantId: string,
adminId: string,
): Promise<CashShiftSummary | null> {
const shift = await this.getCurrentOpenShift(restaurantId, adminId);
if (!shift) { if (!shift) {
return null; return null;
} }
@@ -135,11 +118,22 @@ export class CashShiftsService {
return calculateCashShiftSummary(Number(shift.openingAmount), orders); return calculateCashShiftSummary(Number(shift.openingAmount), orders);
} }
findAll(restaurantId: string, dto: FindCashShiftsDto): Promise<PaginatedResult<CashShift>> { async findAll(
return this.cashShiftRepository.findAllPaginated(restaurantId, dto); restaurantId: string,
adminId: string,
dto: FindCashShiftsDto,
): Promise<PaginatedResult<CashShift>> {
const canViewAll = await this.canViewAllShifts(adminId, restaurantId);
const query = { ...dto };
if (!canViewAll) {
query.adminId = adminId;
}
return this.cashShiftRepository.findAllPaginated(restaurantId, query);
} }
async findOne(shiftId: string, restaurantId: string): Promise<CashShift> { async findOne(shiftId: string, restaurantId: string, adminId: string): Promise<CashShift> {
const shift = await this.em.findOne( const shift = await this.em.findOne(
CashShift, CashShift,
{ id: shiftId, restaurant: { id: restaurantId } }, { id: shiftId, restaurant: { id: restaurantId } },
@@ -150,11 +144,13 @@ export class CashShiftsService {
throw new NotFoundException(CashShiftMessage.NOT_FOUND); throw new NotFoundException(CashShiftMessage.NOT_FOUND);
} }
await this.assertShiftAccess(shift, adminId, restaurantId);
return shift; return shift;
} }
getCurrentOpenShift(restaurantId: string): Promise<CashShift | null> { async getCurrentOpenShift(restaurantId: string, adminId: string): Promise<CashShift | null> {
return this.cashShiftRepository.findOpenShift(restaurantId); const canViewAll = await this.canViewAllShifts(adminId, restaurantId);
return this.cashShiftRepository.findOpenShift(restaurantId, canViewAll ? undefined : adminId);
} }
async assignOrderToOpenShift(order: Order, em: EntityManager = this.em): Promise<void> { async assignOrderToOpenShift(order: Order, em: EntityManager = this.em): Promise<void> {
@@ -185,4 +181,25 @@ export class CashShiftsService {
{ populate: ['payments'] }, { populate: ['payments'] },
); );
} }
private async canViewAllShifts(adminId: string, restaurantId: string): Promise<boolean> {
const permissions = await this.permissionsService.getAdminPermissions(adminId, restaurantId);
return permissions.includes(Permission.VIEW_ALL_SHIFTS);
}
private async assertShiftAccess(
shift: CashShift,
adminId: string,
restaurantId: string,
): Promise<void> {
const canViewAll = await this.canViewAllShifts(adminId, restaurantId);
if (canViewAll) {
return;
}
const shiftAdminId = typeof shift.admin === 'string' ? shift.admin : shift.admin.id;
if (shiftAdminId !== adminId) {
throw new ForbiddenException(CashShiftMessage.ACCESS_DENIED);
}
}
} }
@@ -73,10 +73,16 @@ export class CashShiftRepository extends EntityRepository<CashShift> {
}; };
} }
findOpenShift(restId: string): Promise<CashShift | null> { findOpenShift(restId: string, adminId?: string): Promise<CashShift | null> {
return this.findOne( const where: FilterQuery<CashShift> = {
{ restaurant: { id: restId }, status: CashShiftStatus.OPEN }, restaurant: { id: restId },
{ populate: ['admin'] }, status: CashShiftStatus.OPEN,
); };
if (adminId) {
where.admin = { id: adminId };
}
return this.findOne(where, { populate: ['admin'] });
} }
} }
@@ -124,58 +124,10 @@ 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', 'cashShift', 'items', 'items.food'] as never, populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'payments'] as never,
}); });
// Collect all (orderId, foodId) pairs for efficient review lookup const totalPages = Math.ceil(total / limit);
const orderFoodPairs: Array<{ orderId: string; foodId: string }> = [];
for (const order of data) {
for (const item of order.items.getItems()) {
if (item.food?.id) {
orderFoodPairs.push({ orderId: order.id, foodId: item.food.id });
}
}
}
// Fetch all relevant reviews in a single query
const reviewsMap: Map<string, string> = new Map();
if (orderFoodPairs.length > 0) {
const orderIds = [...new Set(orderFoodPairs.map(p => p.orderId))];
const foodIds = [...new Set(orderFoodPairs.map(p => p.foodId))];
const reviews = await this.em.find(
Review,
{
order: { id: { $in: orderIds } },
food: { id: { $in: foodIds } },
},
{
fields: ['id', 'order', 'food'],
populate: ['order', 'food'],
},
);
// Create a map: key = `${orderId}-${foodId}`, value = reviewId
for (const review of reviews) {
const key = `${review.order.id}-${review.food.id}`;
reviewsMap.set(key, review.id);
}
}
// Map reviewIds to foods
for (const order of data) {
for (const item of order.items.getItems()) {
if (item.food) {
const key = `${order.id}-${item.food.id}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const food = item.food as any;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
food.reviewId = reviewsMap.get(key) || null;
}
}
}
const totalPages = Math.ceil(total / limit);
return { return {
data, data,
+4 -1
View File
@@ -33,6 +33,9 @@ export const rolesData: RoleConfig[] = [
name: 'حسابدار ', name: 'حسابدار ',
isSystem: false, isSystem: false,
permissionFilter: (name: Permission) => permissionFilter: (name: Permission) =>
name === Permission.VIEW_REPORTS || name === Permission.MANAGE_PAYMENTS || name === Permission.MANAGE_ORDERS, name === Permission.VIEW_REPORTS ||
name === Permission.VIEW_ORDER_REPORTS ||
name === Permission.MANAGE_PAYMENTS ||
name === Permission.MANAGE_ORDERS,
}, },
]; ];