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:
parent
b0aa644435
commit
9f1b13192f
@ -1,24 +1,6 @@
|
|||||||
import { bot, extractId, dayjs } from '../../../../utils';
|
import { bot, extractId, dayjs } from '../../../../utils';
|
||||||
import { DEFAULT_TZ } from '../../../../constants';
|
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> = {
|
const STATE_MAP: Record<State, string> = {
|
||||||
approved: 'Подтверждено',
|
approved: 'Подтверждено',
|
||||||
cancelled: 'Отменено',
|
cancelled: 'Отменено',
|
||||||
@ -87,13 +69,20 @@ async function sendTelegramNotification(orderEntity: Order, isUpdate = false) {
|
|||||||
const emojiForState = EMOJI_MAP[state] || '';
|
const emojiForState = EMOJI_MAP[state] || '';
|
||||||
const stateLabel = STATE_MAP[state] || state;
|
const stateLabel = STATE_MAP[state] || state;
|
||||||
|
|
||||||
// Эмодзи в заголовке: карандаш при обновлении, иначе эмодзи статуса
|
// Эмодзи в заголовке: карандаш при обновлении, флаг для завершенных, иначе эмодзи статуса
|
||||||
const headingEmoji = isUpdate ? '✏️' : emojiForState;
|
const headingEmoji = isUpdate
|
||||||
|
? (state === 'completed' ? '🏁' : '✏️')
|
||||||
|
: emojiForState;
|
||||||
|
|
||||||
let heading = '';
|
let heading = '';
|
||||||
|
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
|
// Специальная обработка для завершенных записей
|
||||||
|
if (state === 'completed') {
|
||||||
|
heading = `${headingEmoji} <b>Запись завершена</b>`;
|
||||||
|
} else {
|
||||||
heading = `${headingEmoji} <b>Запись изменена</b>`;
|
heading = `${headingEmoji} <b>Запись изменена</b>`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const isApproved = state === 'approved';
|
const isApproved = state === 'approved';
|
||||||
const creationText = isApproved
|
const creationText = isApproved
|
||||||
@ -170,201 +159,4 @@ export default {
|
|||||||
|
|
||||||
await sendTelegramNotification(updatedEntity, true);
|
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
};
|
|
||||||
Loading…
x
Reference in New Issue
Block a user