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 = 'شیفت صندوق یافت نشد',
ALREADY_OPEN = 'یک شیفت صندوق باز از قبل وجود دارد',
ALREADY_CLOSED = 'این شیفت صندوق قبلا بسته شده است',
ACCESS_DENIED = 'شما به این شیفت صندوق دسترسی ندارید',
ADMIN_NOT_FOUND = 'مدیر یافت نشد',
RESTAURANT_NOT_FOUND = 'رستوران یافت نشد',
}
+6
View File
@@ -10,6 +10,9 @@ export enum Permission {
// Order Management
MANAGE_ORDERS = 'manage_orders',
VIEW_ORDER_REPORTS = 'view_order_reports',
MANAGE_SHIFTS = 'manage_shifts',
VIEW_ALL_SHIFTS = 'view_all_shifts',
// User & Admin Management
MANAGE_ADMINS = 'manage_admins',
@@ -46,6 +49,9 @@ export const PermissionTitles: Record<Permission, string> = {
[Permission.MANAGE_FOODS]: 'مدیریت غذاها',
[Permission.MANAGE_CATEGORIES]: 'مدیریت دسته‌بندی‌ها',
[Permission.MANAGE_ORDERS]: 'مدیریت سفارشات',
[Permission.VIEW_ORDER_REPORTS]: 'مشاهده گزارشات سفارش',
[Permission.MANAGE_SHIFTS]: 'مدیریت شیفت‌ها',
[Permission.VIEW_ALL_SHIFTS]: 'مشاهده همه شیفت‌ها',
[Permission.MANAGE_ADMINS]: 'مدیریت مدیران',
[Permission.MANAGE_USERS]: 'مدیریت کاربران',
[Permission.VIEW_REPORTS]: 'مشاهده گزارشات',
@@ -9,7 +9,6 @@ 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';
import { UpdateCashShiftDto } from '../dto/update-cash-shift.dto';
@ApiTags('cash-shifts')
@ApiBearerAuth()
@@ -18,7 +17,7 @@ export class CashShiftsController {
constructor(private readonly cashShiftsService: CashShiftsService) {}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Permissions(Permission.MANAGE_SHIFTS)
@Post('admin/cash-shifts/open')
@ApiOperation({ summary: 'Open a new cash shift' })
@ApiBody({ type: OpenCashShiftDto })
@@ -27,21 +26,7 @@ export class CashShiftsController {
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@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)
@Permissions(Permission.MANAGE_SHIFTS)
@Patch('admin/cash-shifts/:shiftId/close')
@ApiOperation({ summary: 'Close an open cash shift' })
@ApiParam({ name: 'shiftId', description: 'Cash shift ID' })
@@ -49,50 +34,59 @@ export class CashShiftsController {
closeShift(
@Param('shiftId') shiftId: string,
@RestId() restId: string,
@AdminId() adminId: string,
@Body() dto: CloseCashShiftDto,
) {
return this.cashShiftsService.closeShift(shiftId, restId, dto);
return this.cashShiftsService.closeShift(shiftId, restId, adminId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Permissions(Permission.MANAGE_SHIFTS)
@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);
getCurrentShiftSummary(@RestId() restId: string, @AdminId() adminId: string) {
return this.cashShiftsService.calculateCurrentShiftSummary(restId, adminId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Permissions(Permission.MANAGE_SHIFTS)
@Get('admin/cash-shifts/current')
@ApiOperation({ summary: 'Get the current open cash shift' })
getCurrentOpenShift(@RestId() restId: string) {
return this.cashShiftsService.getCurrentOpenShift(restId);
getCurrentOpenShift(@RestId() restId: string, @AdminId() adminId: string) {
return this.cashShiftsService.getCurrentOpenShift(restId, adminId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Permissions(Permission.MANAGE_SHIFTS)
@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);
findAll(@RestId() restId: string, @AdminId() adminId: string, @Query() dto: FindCashShiftsDto) {
return this.cashShiftsService.findAll(restId, adminId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Permissions(Permission.MANAGE_SHIFTS)
@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);
getShiftSummary(
@Param('shiftId') shiftId: string,
@RestId() restId: string,
@AdminId() adminId: string,
) {
return this.cashShiftsService.calculateShiftSummary(shiftId, restId, adminId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Permissions(Permission.MANAGE_SHIFTS)
@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);
findOne(
@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)
@Permissions(Permission.VIEW_REPORTS)
@Permissions(Permission.VIEW_ORDER_REPORTS)
@ApiOperation({ summary: 'Get order report grouped by food' })
@Get('admin/orders/report/by-food')
getFoodOrderReport(@RestId() restId: string, @Query() dto: FoodOrderReportDto) {
@@ -154,7 +154,7 @@ export class OrdersController {
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.VIEW_REPORTS)
@Permissions(Permission.VIEW_ORDER_REPORTS)
@ApiOperation({ summary: 'Get order report grouped by day' })
@Get('admin/orders/report/by-day')
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 { CashShift } from '../entities/cash-shift.entity';
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 { CloseCashShiftDto } from '../dto/close-cash-shift.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 { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { Order } from '../entities/order.entity';
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 { calculateCashShiftSummary } from '../utils/calculate-cash-shift-summary.util';
import { PermissionsService } from 'src/modules/roles/providers/permissions.service';
@Injectable()
export class CashShiftsService {
constructor(
private readonly em: EntityManager,
private readonly cashShiftRepository: CashShiftRepository,
private readonly permissionsService: PermissionsService,
) {}
async openShift(restaurantId: string, adminId: string, dto: OpenCashShiftDto): Promise<CashShift> {
@@ -54,7 +56,12 @@ export class CashShiftsService {
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(
CashShift,
{ id: shiftId, restaurant: { id: restaurantId } },
@@ -65,40 +72,13 @@ export class CashShiftsService {
throw new NotFoundException(CashShiftMessage.NOT_FOUND);
}
if (dto.openingAmount !== undefined) {
shift.openingAmount = dto.openingAmount;
await this.assertShiftAccess(shift, adminId, restaurantId);
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) {
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;
shift.expectedAmount = summary.expectedAmount;
@@ -115,18 +95,21 @@ export class CashShiftsService {
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);
}
async calculateShiftSummary(
shiftId: string,
restaurantId: string,
adminId: string,
): Promise<CashShiftSummary> {
const shift = await this.findOne(shiftId, restaurantId, adminId);
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);
async calculateCurrentShiftSummary(
restaurantId: string,
adminId: string,
): Promise<CashShiftSummary | null> {
const shift = await this.getCurrentOpenShift(restaurantId, adminId);
if (!shift) {
return null;
}
@@ -135,11 +118,22 @@ export class CashShiftsService {
return calculateCashShiftSummary(Number(shift.openingAmount), orders);
}
findAll(restaurantId: string, dto: FindCashShiftsDto): Promise<PaginatedResult<CashShift>> {
return this.cashShiftRepository.findAllPaginated(restaurantId, dto);
async findAll(
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(
CashShift,
{ id: shiftId, restaurant: { id: restaurantId } },
@@ -150,11 +144,13 @@ export class CashShiftsService {
throw new NotFoundException(CashShiftMessage.NOT_FOUND);
}
await this.assertShiftAccess(shift, adminId, restaurantId);
return shift;
}
getCurrentOpenShift(restaurantId: string): Promise<CashShift | null> {
return this.cashShiftRepository.findOpenShift(restaurantId);
async getCurrentOpenShift(restaurantId: string, adminId: string): Promise<CashShift | null> {
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> {
@@ -185,4 +181,25 @@ export class CashShiftsService {
{ 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> {
return this.findOne(
{ restaurant: { id: restId }, status: CashShiftStatus.OPEN },
{ populate: ['admin'] },
);
findOpenShift(restId: string, adminId?: string): Promise<CashShift | null> {
const where: FilterQuery<CashShift> = {
restaurant: { id: restId },
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,
offset,
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 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);
const totalPages = Math.ceil(total / limit);
return {
data,
+4 -1
View File
@@ -33,6 +33,9 @@ export const rolesData: RoleConfig[] = [
name: 'حسابدار ',
isSystem: false,
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,
},
];