OrdersService: add checkBeforeCreate

This commit is contained in:
vchikalkin 2025-08-05 14:57:13 +03:00
parent 81dfdec7a5
commit 429f5dcab2
8 changed files with 200 additions and 25 deletions

View File

@ -7,7 +7,7 @@ import { useIsMaster } from '@/hooks/api/customers';
import { useOrderMutation, useOrderQuery } from '@/hooks/api/orders';
import { usePushWithData } from '@/hooks/url';
import { Enum_Order_State } from '@repo/graphql/types';
import { isBeforeToday } from '@repo/utils/datetime-format';
import { isBeforeNow } from '@repo/utils/datetime-format';
export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
const push = usePushWithData();
@ -50,7 +50,7 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
push('/orders/add', order);
}
const isOrderStale = order?.datetime_start && isBeforeToday(order?.datetime_start);
const isOrderStale = order?.datetime_start && isBeforeNow(order?.datetime_start);
const canCancel = !isOrderStale && (isCreated || (isMaster && isCancelling) || isApproved);
const canComplete = isMaster && isApproved;

View File

@ -7,7 +7,7 @@ import { useCustomerQuery } from '@/hooks/api/customers';
import { useMasterSlotsQuery } from '@/hooks/api/slots';
import { useDateTimeStore } from '@/stores/datetime';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { getDateUTCRange, isTodayOrAfter } from '@repo/utils/datetime-format';
import { getDateUTCRange, isNowOrAfter } from '@repo/utils/datetime-format';
import { type PropsWithChildren } from 'react';
export function DaySlotsList() {
@ -26,7 +26,7 @@ export function DaySlotsList() {
return <LoadingSpinner />;
}
const isSelectedDateTodayOrAfter = selectedDate && isTodayOrAfter(selectedDate);
const isSelectedDateTodayOrAfter = selectedDate && isNowOrAfter(selectedDate);
if (!slots?.length) {
return (

View File

@ -5,7 +5,7 @@ import FloatingActionPanel from '../shared/action-panel';
import { type SlotComponentProps } from './types';
import { useSlotDelete, useSlotMutation, useSlotQuery } from '@/hooks/api/slots';
import { Enum_Slot_State } from '@repo/graphql/types';
import { isBeforeToday } from '@repo/utils/datetime-format';
import { isBeforeNow } from '@repo/utils/datetime-format';
import { useRouter } from 'next/navigation';
export function SlotButtons({ documentId }: Readonly<SlotComponentProps>) {
@ -42,7 +42,7 @@ export function SlotButtons({ documentId }: Readonly<SlotComponentProps>) {
const hasOrders = Boolean(slot?.orders.length);
const isSlotStale = slot?.datetime_start && isBeforeToday(slot?.datetime_start);
const isSlotStale = slot?.datetime_start && isBeforeNow(slot?.datetime_start);
const canDelete = !isSlotStale && !hasOrders;
const canToggle = !isSlotStale;

View File

@ -20,8 +20,8 @@ export function useCustomerContacts() {
refetch: refetchMasters,
} = useMastersQuery();
const clients = clientsData?.customers?.at(0)?.clients || [];
const masters = mastersData?.customers?.at(0)?.masters || [];
const clients = clientsData?.clients || [];
const masters = mastersData?.masters || [];
const isLoading = isLoadingClients || isLoadingMasters;

View File

@ -43,7 +43,9 @@ export class CustomersService extends BaseService {
},
});
return result.data;
const customer = result.data.customers.at(0);
return customer;
}
async getCustomer(variables: VariablesOf<typeof GQL.GetCustomerDocument>) {
@ -69,7 +71,9 @@ export class CustomersService extends BaseService {
},
});
return result.data;
const customer = result.data.customers.at(0);
return customer;
}
async updateCustomer(

View File

@ -1,25 +1,43 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable @typescript-eslint/naming-convention */
import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
import { BaseService } from './base';
import { CustomersService } from './customers';
import { ServicesService } from './services';
import { SlotsService } from './slots';
import { type VariablesOf } from '@graphql-typed-document-node/core';
import { isCustomerMaster } from '@repo/utils/customer';
import { getMinutes } from '@repo/utils/datetime-format';
import { getMinutes, isBeforeNow } from '@repo/utils/datetime-format';
import dayjs from 'dayjs';
const ERRORS = {
INVALID_SERVICE_DURATION: 'Invalid service duration',
MISSING_CLIENT: 'Missing client',
MISSING_ORDER: 'Order not found',
MISSING_SERVICE_ID: 'Missing service id',
MISSING_SERVICES: 'Missing services',
MISSING_SLOT: 'Missing slot id',
MISSING_START_TIME: 'Missing time start',
NO_PERMISSION: 'No permission',
INACTIVE_CLIENT: 'Клиент не активен',
INACTIVE_MASTER: 'Мастер не активен',
INVALID_MASTER: 'Некорректный мастер',
INVALID_SERVICE_DURATION: 'Неверная длительность услуги',
INVALID_TIME: 'Некорректное время',
MISSING_CLIENT: 'Не указан клиент',
MISSING_ORDER: 'Заказ не найден',
MISSING_SERVICE_ID: 'Не указан идентификатор услуги',
MISSING_SERVICES: 'Отсутствуют услуги',
MISSING_SLOT: 'Не указан слот',
MISSING_START_TIME: 'Не указано время начала',
MISSING_TIME: 'Не указано время',
NO_MASTER_SELF_BOOK: 'Нельзя записать к самому себе',
NO_PERMISSION: 'Нет доступа',
NOT_FOUND_CLIENT: 'Клиент не найден',
NOT_FOUND_MASTER: 'Мастер не найден',
ORDER_IN_PAST: 'Нельзя создать запись на время в прошлом',
ORDER_OUT_OF_SLOT: 'Время заказа выходит за пределы слота',
OVERLAPPING_TIME: 'Время пересекается с другими заказами',
SLOT_CLOSED: 'Слот закрыт',
};
export class OrdersService extends BaseService {
async createOrder(variables: VariablesOf<typeof GQL.CreateOrderDocument>) {
await this.checkBeforeCreate(variables);
const { customer } = await this._getUser();
// Проверки на существование обязательных полей для предотвращения ошибок типов
@ -86,6 +104,8 @@ export class OrdersService extends BaseService {
}
async updateOrder(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
await this.checkUpdatePermission(variables);
const { customer } = await this._getUser();
const { query } = await getClientWithToken();
@ -127,4 +147,147 @@ export class OrdersService extends BaseService {
return mutationResult.data;
}
private async checkBeforeCreate(variables: VariablesOf<typeof GQL.CreateOrderDocument>) {
const {
client: clientId,
datetime_end,
datetime_start,
services,
slot: slotId,
} = variables.input;
// Проверка наличия обязательных полей
if (!slotId) throw new Error(ERRORS.MISSING_SLOT);
if (!clientId) throw new Error(ERRORS.MISSING_CLIENT);
if (!services?.length) throw new Error(ERRORS.MISSING_SERVICES);
// Проверка корректности времени заказа.
if (!datetime_start || !datetime_end) {
throw new Error(ERRORS.MISSING_TIME);
}
if (new Date(datetime_end) <= new Date(datetime_start)) {
throw new Error(ERRORS.INVALID_TIME);
}
// Проверка, что заказ не создается на время в прошлом
if (isBeforeNow(datetime_start, 'minute')) {
throw new Error(ERRORS.ORDER_IN_PAST);
}
const slotService = new SlotsService(this._user);
// Получаем слот
const { slot } = await slotService.getSlot({ documentId: slotId });
if (!slot) throw new Error(ERRORS.MISSING_SLOT);
// Проверка, что заказ укладывается в рамки слота
if (
new Date(datetime_start) < new Date(slot.datetime_start) ||
new Date(datetime_end) > new Date(slot.datetime_end)
) {
throw new Error(ERRORS.ORDER_OUT_OF_SLOT);
}
// 1. Слот не должен быть закрыт
if (slot.state === GQL.Enum_Slot_State.Closed) {
throw new Error(ERRORS.SLOT_CLOSED);
}
const customerService = new CustomersService(this._user);
// Получаем клиента
const { customer: clientEntity } = await customerService.getCustomer({ documentId: clientId });
if (!clientEntity) throw new Error(ERRORS.NOT_FOUND_CLIENT);
// Проверка активности клиента
if (!clientEntity?.active) {
throw new Error(ERRORS.INACTIVE_CLIENT);
}
// Получаем мастера слота
const slotMaster = slot.master;
if (!slotMaster) throw new Error(ERRORS.INVALID_MASTER);
if (!slotMaster.active || slotMaster.role !== GQL.Enum_Customer_Role.Master) {
throw new Error(ERRORS.INACTIVE_MASTER);
}
// 2. Проверка ролей и связей
const isClientMaster = clientEntity.role === GQL.Enum_Customer_Role.Master;
const slotMasterId = slot.master?.documentId;
if (!slotMasterId) {
throw new Error(ERRORS.NOT_FOUND_MASTER);
}
if (isClientMaster) {
// Мастер может записывать только себя
if (slotMasterId !== clientId) {
throw new Error(ERRORS.INVALID_MASTER);
}
} else {
// Клиент не должен быть мастером слота
if (slotMasterId === clientId) {
throw new Error(ERRORS.NO_MASTER_SELF_BOOK);
}
const clientMasters = await customerService.getMasters({ documentId: clientId });
const isLinkedToSlotMaster = clientMasters?.masters.some(
(master) => master?.documentId === slotMasterId,
);
// Клиент должен быть привязан к мастеру слота
if (!isLinkedToSlotMaster) {
throw new Error(ERRORS.INVALID_MASTER);
}
}
// Проверка пересечений заказов по времени.
const { orders: overlappingOrders } = await this.getOrders({
filters: {
datetime_end: { gt: datetime_start },
datetime_start: { lt: datetime_end },
slot: {
documentId: { eq: slotId },
},
state: {
notIn: [GQL.Enum_Order_State.Cancelled],
},
},
});
if (overlappingOrders?.length) {
throw new Error(ERRORS.OVERLAPPING_TIME);
}
}
private async checkUpdatePermission(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
const { customer } = await this._getUser();
const { order } = await this.getOrder({ documentId: variables.documentId });
if (!order) throw new Error(ERRORS.MISSING_ORDER);
const isOrderClient = order.client?.documentId === customer.documentId;
const isOrderMaster = order.slot?.master?.documentId === customer.documentId;
if (!isOrderClient && !isOrderMaster) {
throw new Error(ERRORS.NO_PERMISSION);
}
if (isOrderClient && variables?.data && Object.keys(variables.data).length > 1) {
throw new Error(ERRORS.NO_PERMISSION);
}
if (
isOrderClient &&
variables?.data?.state &&
variables.data.state !== GQL.Enum_Order_State.Cancelling
) {
throw new Error(ERRORS.NO_PERMISSION);
}
}
}

View File

@ -4,7 +4,7 @@ import * as GQL from '../types';
import { BaseService } from './base';
import { ServicesService } from './services';
import { type VariablesOf } from '@graphql-typed-document-node/core';
import { getMinutes, isBeforeToday } from '@repo/utils/datetime-format';
import { getMinutes, isBeforeNow } from '@repo/utils/datetime-format';
import dayjs from 'dayjs';
export const ERRORS = {
@ -190,7 +190,7 @@ export class SlotsService extends BaseService {
}
// Проверка, что слот не создаётся в прошлом
if (datetime_start && isBeforeToday(datetime_start)) {
if (datetime_start && isBeforeNow(datetime_start)) {
throw new Error(ERRORS.NO_PAST_SLOT);
}

View File

@ -1,4 +1,5 @@
/* eslint-disable import/no-unassigned-import */
import { type OpUnitType } from 'dayjs';
import dayjs, { type ConfigType } from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
@ -91,15 +92,22 @@ export function getTimeZoneLabel(tz: string = DEFAULT_TZ): string {
return `GMT${offset}`;
}
export function isBeforeToday(date: Date | string) {
/**
* This indicates whether the date is before the other supplied date-time.
* ```
* // Default unit - 'day':
* isBeforeNow(date, 'day')
* ```
*/
export function isBeforeNow(date: Date | string, unit: OpUnitType = 'day') {
const now = dayjs().tz(DEFAULT_TZ);
const inputDate = dayjs(date).tz(DEFAULT_TZ);
return inputDate.isBefore(now, 'day');
return inputDate.isBefore(now, unit);
}
export function isTodayOrAfter(date: Date | string) {
return !isBeforeToday(date);
export function isNowOrAfter(date: Date | string) {
return !isBeforeNow(date);
}
export function sumTime(datetime: DateTime, durationMinutes: number) {