Issues/76 (#6)

* refactor: consolidate order lifecycle validation logic and remove slot lifecycle

- Streamlined the order lifecycle by consolidating validation logic and removing redundant error handling.
- Deleted the slot lifecycle file to simplify the codebase, as its functionality is no longer needed.
- This change enhances maintainability and reduces complexity in the order management system.

* feat: enhance Telegram notification heading for order updates

- Updated the heading emoji logic to display a checkered flag for completed orders and a pencil for updates, improving clarity in notifications.
- Added specific handling for completed records in the notification heading to differentiate between updated and completed statuses.
This commit is contained in:
Vlad Chikalkin 2025-08-11 16:25:56 +03:00 committed by GitHub
parent b0aa644435
commit 9f1b13192f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 10 additions and 365 deletions

View File

@ -1,24 +1,6 @@
import { bot, extractId, dayjs } from '../../../../utils';
import { DEFAULT_TZ } from '../../../../constants';
const ERR_MISSING_TIME = 'Не указано время';
const ERR_INVALID_TIME = 'Некорректное время';
const ERR_OVERLAPPING_TIME = 'Время пересекается с другими заказами';
const ERR_INACTIVE_CLIENT = 'Клиент не активен';
const ERR_INACTIVE_MASTER = 'Мастер не активен';
const ERR_SLOT_CLOSED = 'Слот закрыт';
const ERR_INVALID_CLIENT = 'Некорректный клиент';
const ERR_INVALID_MASTER = 'Некорректный мастер';
const ERR_MISSING_CLIENT = 'Не указан клиент';
const ERR_MISSING_SLOT = 'Не указан слот';
const ERR_MISSING_SERVICE = 'Не указан сервис';
const ERR_ORDER_OUT_OF_SLOT = 'Время заказа выходит за пределы слота';
const ERR_EXISTING_ORDER_OR_SLOT_NOT_FOUND =
'Существующий заказ или слот не найден';
const ERR_CANNOT_COMPLETE_BEFORE_START =
'Нельзя завершить запись до её наступления';
const ERR_ORDER_IN_PAST = 'Нельзя создать запись на время в прошлом';
const STATE_MAP: Record<State, string> = {
approved: 'Подтверждено',
cancelled: 'Отменено',
@ -87,13 +69,20 @@ async function sendTelegramNotification(orderEntity: Order, isUpdate = false) {
const emojiForState = EMOJI_MAP[state] || '';
const stateLabel = STATE_MAP[state] || state;
// Эмодзи в заголовке: карандаш при обновлении, иначе эмодзи статуса
const headingEmoji = isUpdate ? '✏️' : emojiForState;
// Эмодзи в заголовке: карандаш при обновлении, флаг для завершенных, иначе эмодзи статуса
const headingEmoji = isUpdate
? (state === 'completed' ? '🏁' : '✏️')
: emojiForState;
let heading = '';
if (isUpdate) {
// Специальная обработка для завершенных записей
if (state === 'completed') {
heading = `${headingEmoji} <b>Запись завершена</b>`;
} else {
heading = `${headingEmoji} <b>Запись изменена</b>`;
}
} else {
const isApproved = state === 'approved';
const creationText = isApproved
@ -170,201 +159,4 @@ export default {
await sendTelegramNotification(updatedEntity, true);
},
async beforeCreate(event) {
const { data } = event.params;
const { datetime_start, datetime_end, client, services } = data;
const isUpdate = !!data?.publishedAt;
if (isUpdate) return;
const clientId = extractId(client);
const slotId = extractId(data.slot);
// Проверка наличия обязательных полей
if (!slotId) throw new Error(ERR_MISSING_SLOT);
if (!clientId) throw new Error(ERR_MISSING_CLIENT);
if (!extractId(services)) throw new Error(ERR_MISSING_SERVICE);
// Проверка корректности времени заказа.
if (!datetime_start || !datetime_end) {
throw new Error(ERR_MISSING_TIME);
}
if (new Date(datetime_end) <= new Date(datetime_start)) {
throw new Error(ERR_INVALID_TIME);
}
// Проверка, что заказ не создается на время в прошлом
const now = dayjs().tz(DEFAULT_TZ);
const orderStart = dayjs(datetime_start).tz(DEFAULT_TZ);
if (orderStart.isBefore(now, 'minute')) {
throw new Error(ERR_ORDER_IN_PAST);
}
// Получаем слот
const slot = await strapi.db.query('api::slot.slot').findOne({
where: { id: slotId },
populate: ['master'],
});
if (!slot) throw new Error(ERR_MISSING_SLOT);
// Проверка, что заказ укладывается в рамки слота
if (
new Date(datetime_start) < new Date(slot.datetime_start) ||
new Date(datetime_end) > new Date(slot.datetime_end)
) {
throw new Error(ERR_ORDER_OUT_OF_SLOT);
}
// 1. Слот не должен быть закрыт
if (slot.state === 'closed') {
throw new Error(ERR_SLOT_CLOSED);
}
// Получаем клиента
const clientEntity = await strapi.db
.query('api::customer.customer')
.findOne({
where: { id: clientId },
populate: { masters: true },
});
if (!clientEntity) throw new Error(ERR_MISSING_CLIENT);
// Проверка активности клиента
if (!clientEntity.active) {
throw new Error(ERR_INACTIVE_CLIENT);
}
// Получаем мастера слота
const slotMaster = slot.master;
if (!slotMaster) throw new Error(ERR_INVALID_MASTER);
if (!slotMaster.active || slotMaster.role !== 'master') {
throw new Error(ERR_INACTIVE_MASTER);
}
// 2. Проверка ролей и связей
const isClientMaster = clientEntity.role === 'master';
const slotMasterId = slotMaster.id;
if (!isClientMaster) {
// Клиент не должен быть мастером слота
if (clientEntity.id === slotMasterId) {
throw new Error(ERR_INVALID_CLIENT);
}
// Клиент должен быть в списке клиентов мастера
const masters = clientEntity.masters?.map(m => m.id) || [];
if (!masters.includes(slotMasterId)) {
throw new Error(ERR_INVALID_MASTER);
}
} else {
// Мастер не может записать другого мастера
if (slotMasterId !== clientEntity.id) {
throw new Error(ERR_INVALID_MASTER);
}
}
// Проверка пересечений заказов по времени.
const overlappingEntities = await strapi.db
.query('api::order.order')
.findMany({
where: {
documentId: { $ne: data.documentId },
datetime_start: { $lt: datetime_end },
datetime_end: { $gt: datetime_start },
slot: {
id: { $eq: slotId },
},
state: {
$notIn: ['cancelled'],
},
},
populate: ['slot'],
});
if (overlappingEntities.length > 0) {
throw new Error(ERR_OVERLAPPING_TIME);
}
},
async beforeUpdate(event) {
const { data, where } = event.params;
const { id: entityId } = where;
const { datetime_start, datetime_end, state } = data;
const existingOrder = await strapi.db.query('api::order.order').findOne({
where: { id: entityId },
select: ['documentId', 'datetime_start', 'datetime_end'],
populate: ['slot', 'client'],
});
if (state && !datetime_start && !datetime_end) {
if (state === 'completed') {
const clientId = extractId(existingOrder.client);
const lastOrder = await strapi.db.query('api::order.order').findMany({
where: {
client: {
id: clientId,
},
state: 'completed',
},
orderBy: { order_number: 'desc' },
limit: 1,
});
const lastOrderNumber =
lastOrder.length > 0 ? lastOrder[0].order_number : 0;
data.order_number = lastOrderNumber + 1;
}
const now = dayjs().tz(DEFAULT_TZ);
const orderStart = dayjs(existingOrder.datetime_start).tz(DEFAULT_TZ);
if (state === 'completed' && now.isBefore(orderStart, 'minute')) {
throw new Error(ERR_CANNOT_COMPLETE_BEFORE_START);
}
return;
}
if (
existingOrder.datetime_start === datetime_start &&
existingOrder.datetime_end === datetime_end
) {
return;
}
if (!datetime_start || !datetime_end) {
throw new Error(ERR_INVALID_TIME);
}
if (new Date(datetime_end) <= new Date(datetime_start)) {
throw new Error(ERR_INVALID_TIME);
}
if (!existingOrder || !existingOrder.slot) {
throw new Error(ERR_EXISTING_ORDER_OR_SLOT_NOT_FOUND);
}
const slotId = existingOrder.slot.documentId;
const overlappingEntities = await strapi.db
.query('api::order.order')
.findMany({
where: {
id: { $ne: entityId },
datetime_start: { $lt: datetime_end },
datetime_end: { $gt: datetime_start },
slot: {
documentId: { $eq: slotId },
},
},
populate: ['slot'],
});
if (overlappingEntities.length > 0) {
throw new Error(ERR_OVERLAPPING_TIME);
}
},
};

View File

@ -1,147 +0,0 @@
import { DEFAULT_TZ } from '../../../../constants';
import { extractId, dayjs } from '../../../../utils';
const ERR_INVALID_TIME = 'Некорректное время';
const ERR_OVERLAPPING_TIME = 'Время пересекается с другими слотами';
const ERR_FORBIDDEN_SLOT_STATUS =
'Нельзя менять время слота, если есть связанные заказы';
const FORBIDDEN_ORDER_STATES = [
'scheduled',
'approved',
'completed',
'cancelling',
];
const ERR_INACTIVE_MASTER = 'Мастер не активен';
const ERR_INVALID_MASTER = 'Некорректный мастер';
const ERR_PAST_SLOT = 'Нельзя создать слот в прошлом';
const ERR_SLOT_HAS_ORDERS = 'Нельзя удалить слот с активными заказами';
const ERR_RECORD_NOT_FOUND = 'Запись не найдена';
export default {
async beforeCreate(event) {
const { data } = event.params;
const { master, datetime_start, datetime_end } = data;
const isUpdate = !!data?.publishedAt;
if (isUpdate) return;
// Проверка, что мастер существует и активен
const masterId = extractId(master);
const masterEntity = await strapi.db
.query('api::customer.customer')
.findOne({
where: { id: masterId },
});
if (!masterEntity) throw new Error(ERR_INVALID_MASTER);
if (!masterEntity.active || masterEntity.role !== 'master') {
throw new Error(ERR_INACTIVE_MASTER);
}
// Проверка, что слот не создаётся в прошлом
if (datetime_start) {
const now = dayjs().tz(DEFAULT_TZ);
const slotStart = dayjs(datetime_start).tz(DEFAULT_TZ);
if (slotStart.isBefore(now, 'day')) {
throw new Error(ERR_PAST_SLOT);
}
}
// Проверка валидности времени
if (!datetime_start || !datetime_end) {
throw new Error(ERR_INVALID_TIME);
}
if (new Date(datetime_end) <= new Date(datetime_start)) {
throw new Error(ERR_INVALID_TIME);
}
const overlappingEntities = await strapi.db
.query('api::slot.slot')
.findMany({
where: {
documentId: { $ne: data.documentId },
datetime_start: { $lt: datetime_end },
datetime_end: { $gt: datetime_start },
master: masterId,
},
});
if (overlappingEntities.length > 0) {
throw new Error(ERR_OVERLAPPING_TIME);
}
},
async beforeUpdate(event) {
const { data, where } = event.params;
const { id: entityId } = where;
// Если меняется хотя бы одно из полей времени
const isTimeChange = 'datetime_start' in data || 'datetime_end' in data;
if (isTimeChange) {
let datetime_start = data.datetime_start;
let datetime_end = data.datetime_end;
// Подтянуть недостающие значения из существующего слота
const existingSlot = await strapi.db.query('api::slot.slot').findOne({
where: { id: entityId },
select: ['datetime_start', 'datetime_end'],
});
if (!datetime_start) datetime_start = existingSlot?.datetime_start;
if (!datetime_end) datetime_end = existingSlot?.datetime_end;
// Проверка: оба времени должны быть определены
if (!datetime_start || !datetime_end) {
throw new Error(ERR_INVALID_TIME);
}
// Проверка валидности времени
if (new Date(datetime_end) <= new Date(datetime_start)) {
throw new Error(ERR_INVALID_TIME);
}
const existingEntity = await strapi.db.query('api::slot.slot').findOne({
where: { id: entityId },
select: ['documentId'],
populate: ['orders'],
});
const orders = existingEntity?.orders;
if (
orders?.length > 0 &&
orders?.some(order => FORBIDDEN_ORDER_STATES.includes(order.state))
) {
throw new Error(ERR_FORBIDDEN_SLOT_STATUS);
}
if (!existingEntity) {
throw new Error(ERR_RECORD_NOT_FOUND);
}
const { documentId } = existingEntity;
const overlappingEntities = await strapi.db
.query('api::slot.slot')
.findMany({
where: {
id: { $ne: entityId },
documentId: { $ne: documentId },
datetime_start: { $lt: datetime_end },
datetime_end: { $gt: datetime_start },
},
});
if (overlappingEntities.length > 0) {
throw new Error(ERR_OVERLAPPING_TIME);
}
}
},
// async beforeDelete(event) {
// const { where } = event.params;
// const slotId = where.id;
// const slot = await strapi.db.query('api::slot.slot').findOne({
// where: { id: slotId },
// populate: ['orders'],
// });
// if (slot?.orders?.length) {
// throw new Error(ERR_SLOT_HAS_ORDERS);
// }
// },
};