diff --git a/package.json b/package.json index 7e80e8c..0c384c2 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@strapi/plugin-graphql": "^5.15.0", "@strapi/plugin-users-permissions": "5.15.0", "@strapi/strapi": "5.15.0", + "dayjs": "^1.11.13", "pg": "8.8.0", "react": "^18.0.0", "react-dom": "^18.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4eeb2f..32829f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@strapi/strapi': specifier: 5.15.0 version: 5.15.0(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.5)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3))(@codemirror/language@6.10.5)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.35.0)(@swc/helpers@0.5.15)(@types/hoist-non-react-statics@3.3.5)(@types/node@20.17.9)(@types/react-dom@18.3.1)(@types/react@18.3.12)(codemirror@5.65.18)(esbuild@0.25.5)(koa@2.16.1)(pg@8.8.0)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.36.0)(type-fest@4.29.0) + dayjs: + specifier: ^1.11.13 + version: 1.11.13 pg: specifier: 8.8.0 version: 8.8.0 @@ -2808,6 +2811,9 @@ packages: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -7789,7 +7795,7 @@ snapshots: '@strapi/design-system': 2.0.0-rc.24(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.5)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3))(@codemirror/language@6.10.5)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.35.0)(@strapi/icons@2.0.0-rc.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.1)(@types/react@18.3.12)(codemirror@5.65.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.0.0-rc.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/permissions': 5.15.0 - '@strapi/types': 5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.4.4) + '@strapi/types': 5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.7.2) '@strapi/typescript-utils': 5.15.0 '@strapi/utils': 5.15.0 '@testing-library/dom': 10.1.0 @@ -7982,7 +7988,7 @@ snapshots: '@strapi/database': 5.15.0(@types/node@20.17.9)(pg@8.8.0) '@strapi/design-system': 2.0.0-rc.24(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.5)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.3))(@codemirror/language@6.10.5)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.35.0)(@strapi/icons@2.0.0-rc.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/react-dom@18.3.1)(@types/react@18.3.12)(codemirror@5.65.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.0.0-rc.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) - '@strapi/types': 5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.4.4) + '@strapi/types': 5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.7.2) '@strapi/utils': 5.15.0 date-fns: 2.30.0 date-fns-tz: 2.0.1(date-fns@2.30.0) @@ -8075,7 +8081,7 @@ snapshots: '@strapi/generators': 5.15.0 '@strapi/logger': 5.15.0 '@strapi/permissions': 5.15.0 - '@strapi/types': 5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.4.4) + '@strapi/types': 5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.7.2) '@strapi/typescript-utils': 5.15.0 '@strapi/utils': 5.15.0 bcryptjs: 2.4.3 @@ -8525,7 +8531,7 @@ snapshots: '@strapi/logger': 5.15.0 '@strapi/permissions': 5.15.0 '@strapi/review-workflows': 5.15.0(2imnoqd43gbalynv3yjuponlpy) - '@strapi/types': 5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.4.4) + '@strapi/types': 5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.7.2) '@strapi/typescript-utils': 5.15.0 '@strapi/upload': 5.15.0(6nrgkysfwt52tfycqocmy4s2kq) '@strapi/utils': 5.15.0 @@ -8627,34 +8633,6 @@ snapshots: - webpack-dev-server - webpack-plugin-serve - '@strapi/types@5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.4.4)': - dependencies: - '@casl/ability': 6.5.0 - '@koa/cors': 5.0.0 - '@koa/router': 12.0.2 - '@strapi/database': 5.15.0(@types/node@20.17.9)(pg@8.8.0) - '@strapi/logger': 5.15.0 - '@strapi/permissions': 5.15.0 - '@strapi/utils': 5.15.0 - commander: 8.3.0 - koa: 2.16.1 - koa-body: 6.0.1 - node-schedule: 2.1.1 - typedoc: 0.25.10(typescript@5.4.4) - typedoc-github-wiki-theme: 1.1.0(typedoc-plugin-markdown@3.17.1(typedoc@0.25.10(typescript@5.7.2)))(typedoc@0.25.10(typescript@5.7.2)) - typedoc-plugin-markdown: 3.17.1(typedoc@0.25.10(typescript@5.7.2)) - transitivePeerDependencies: - - '@types/node' - - better-sqlite3 - - mysql - - mysql2 - - pg - - pg-native - - sqlite3 - - supports-color - - tedious - - typescript - '@strapi/types@5.15.0(@types/node@20.17.9)(pg@8.8.0)(typescript@5.7.2)': dependencies: '@casl/ability': 6.5.0 @@ -9908,6 +9886,8 @@ snapshots: dependencies: '@babel/runtime': 7.26.0 + dayjs@1.11.13: {} + debounce@1.2.1: {} debug@2.6.9: @@ -13142,14 +13122,6 @@ snapshots: handlebars: 4.7.8 typedoc: 0.25.10(typescript@5.7.2) - typedoc@0.25.10(typescript@5.4.4): - dependencies: - lunr: 2.3.9 - marked: 4.3.0 - minimatch: 9.0.5 - shiki: 0.14.7 - typescript: 5.4.4 - typedoc@0.25.10(typescript@5.7.2): dependencies: lunr: 2.3.9 diff --git a/src/api/order/content-types/order/lifecycles.ts b/src/api/order/content-types/order/lifecycles.ts index 1687369..b571024 100644 --- a/src/api/order/content-types/order/lifecycles.ts +++ b/src/api/order/content-types/order/lifecycles.ts @@ -11,15 +11,23 @@ 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 = 'Нельзя завершить запись до её наступления'; -function timeToDate(time: string) { - return new Date(`1970-01-01T${time}Z`); +const dayjs = require('dayjs'); +const utc = require('dayjs/plugin/utc'); +const timezone = require('dayjs/plugin/timezone'); + +if (!dayjs.prototype.tz) { + dayjs.extend(utc); + dayjs.extend(timezone); } export default { async beforeCreate(event) { const { data } = event.params; - const { time_start, time_end, client, services } = data; + const { datetime_start, datetime_end, client, services } = data; const clientId = extractId(client); const slotId = extractId(data.slot); @@ -29,10 +37,10 @@ export default { if (!extractId(services)) throw new Error(ERR_MISSING_SERVICE); // Проверка корректности времени заказа. - if (!time_start || !time_end) { + if (!datetime_start || !datetime_end) { throw new Error(ERR_MISSING_TIME); } - if (timeToDate(time_start) >= timeToDate(time_end)) { + if (new Date(datetime_end) <= new Date(datetime_start)) { throw new Error(ERR_INVALID_TIME); } @@ -43,6 +51,14 @@ export default { }); 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); @@ -96,13 +112,13 @@ export default { .findMany({ where: { documentId: { $ne: data.documentId }, - time_start: { $lt: time_end }, - time_end: { $gt: time_start }, + datetime_start: { $lt: datetime_end }, + datetime_end: { $gt: datetime_start }, slot: { id: { $eq: slotId }, }, state: { - $notIn: ['cancelled', 'completed'], + $notIn: ['cancelled'], }, }, populate: ['slot'], @@ -116,15 +132,15 @@ export default { async beforeUpdate(event) { const { data, where } = event.params; const { id: entityId } = where; - const { time_start, time_end, state } = data; + const { datetime_start, datetime_end, state } = data; const existingOrder = await strapi.db.query('api::order.order').findOne({ where: { id: entityId }, - select: ['documentId', 'time_start', 'time_end'], + select: ['documentId', 'datetime_start', 'datetime_end'], populate: ['slot', 'client'], }); - if (state && !time_start && !time_end) { + if (state && !datetime_start && !datetime_end) { if (state === 'completed') { const clientId = extractId(existingOrder.client); @@ -144,26 +160,33 @@ export default { data.order_number = lastOrderNumber + 1; } + const now = dayjs().tz('Europe/Moscow'); + const orderStart = dayjs(existingOrder.datetime_start).tz('Europe/Moscow'); + + if (state === 'completed' && now.isBefore(orderStart, 'minute')) { + throw new Error(ERR_CANNOT_COMPLETE_BEFORE_START); + } + return; } if ( - existingOrder.time_start === time_start && - existingOrder.time_end === time_end + existingOrder.datetime_start === datetime_start && + existingOrder.datetime_end === datetime_end ) { return; } - if (!time_start || !time_end) { + if (!datetime_start || !datetime_end) { throw new Error(ERR_INVALID_TIME); } - if (timeToDate(time_start) >= timeToDate(time_end)) { + if (new Date(datetime_end) <= new Date(datetime_start)) { throw new Error(ERR_INVALID_TIME); } if (!existingOrder || !existingOrder.slot) { - throw new Error('Существующий заказ или слот не найден'); + throw new Error(ERR_EXISTING_ORDER_OR_SLOT_NOT_FOUND); } const slotId = existingOrder.slot.documentId; @@ -173,8 +196,8 @@ export default { .findMany({ where: { id: { $ne: entityId }, - time_start: { $lt: time_end }, - time_end: { $gt: time_start }, + datetime_start: { $lt: datetime_end }, + datetime_end: { $gt: datetime_start }, slot: { documentId: { $eq: slotId }, }, diff --git a/src/api/order/content-types/order/schema.json b/src/api/order/content-types/order/schema.json index 00bd675..6d0a93f 100644 --- a/src/api/order/content-types/order/schema.json +++ b/src/api/order/content-types/order/schema.json @@ -51,17 +51,19 @@ "target": "api::slot.slot", "inversedBy": "orders" }, - "time_start": { - "type": "time" - }, - "time_end": { - "type": "time" - }, "services": { "type": "relation", "relation": "manyToMany", "target": "api::service.service", "inversedBy": "orders" + }, + "datetime_start": { + "type": "datetime", + "required": true + }, + "datetime_end": { + "type": "datetime", + "required": true } } } diff --git a/src/api/slot/content-types/slot/lifecycles.ts b/src/api/slot/content-types/slot/lifecycles.ts index 65d8286..4d445cc 100644 --- a/src/api/slot/content-types/slot/lifecycles.ts +++ b/src/api/slot/content-types/slot/lifecycles.ts @@ -1,4 +1,12 @@ import { extractId } from '../../../../utils'; +const dayjs = require('dayjs'); +const utc = require('dayjs/plugin/utc'); +const timezone = require('dayjs/plugin/timezone'); + +if (!dayjs.prototype.tz) { + dayjs.extend(utc); + dayjs.extend(timezone); +} const ERR_INVALID_TIME = 'Некорректное время'; const ERR_OVERLAPPING_TIME = 'Время пересекается с другими слотами'; @@ -16,15 +24,12 @@ const ERR_INACTIVE_MASTER = 'Мастер не активен'; const ERR_INVALID_MASTER = 'Некорректный мастер'; const ERR_PAST_SLOT = 'Нельзя создать слот в прошлом'; const ERR_SLOT_HAS_ORDERS = 'Нельзя удалить слот с активными заказами'; - -function timeToDate(date, time) { - return new Date(`${date}T${time}:00Z`); -} +const ERR_RECORD_NOT_FOUND = 'Запись не найдена'; export default { async beforeCreate(event) { const { data } = event.params; - const { master, date, time_start, time_end } = data; + const { master, datetime_start, datetime_end } = data; // Проверка, что мастер существует и активен const masterId = extractId(master); @@ -39,70 +44,67 @@ export default { } // Проверка, что слот не создаётся в прошлом - if (date && time_start) { - const slotDate = timeToDate(date, time_start); - if (slotDate < new Date()) { + if (datetime_start) { + const now = dayjs().tz('Europe/Moscow'); + const slotStart = dayjs(datetime_start).tz('Europe/Moscow'); + if (slotStart.isBefore(now, 'minute')) { throw new Error(ERR_PAST_SLOT); } } - // --- Существующие проверки времени и пересечений --- - if (!time_start || !time_end) { - throw new Error('Некорректное время'); + // Проверка валидности времени + if (!datetime_start || !datetime_end) { + throw new Error(ERR_INVALID_TIME); } - if (timeToDate(date, time_start) >= timeToDate(date, time_end)) { - throw new Error('Некорректное время'); + 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: { - date, documentId: { $ne: data.documentId }, - time_start: { $lt: time_end }, - time_end: { $gt: time_start }, + datetime_start: { $lt: datetime_end }, + datetime_end: { $gt: datetime_start }, master: masterId, }, }); if (overlappingEntities.length > 0) { - throw new Error('Время пересекается с другими слотами'); + throw new Error(ERR_OVERLAPPING_TIME); } }, async beforeUpdate(event) { const { data, where } = event.params; const { id: entityId } = where; - // Если меняется хотя бы одно из полей времени или дата - const isTimeChange = - 'time_start' in data || 'time_end' in data || 'date' in data; + // Если меняется хотя бы одно из полей времени + const isTimeChange = 'datetime_start' in data || 'datetime_end' in data; if (isTimeChange) { - let date = data.date; - let time_start = data.time_start; - let time_end = data.time_end; + 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: ['date', 'time_start', 'time_end'], + select: ['datetime_start', 'datetime_end'], }); - if (!date) date = existingSlot?.date; - if (!time_start) time_start = existingSlot?.time_start; - if (!time_end) time_end = existingSlot?.time_end; + if (!datetime_start) datetime_start = existingSlot?.datetime_start; + if (!datetime_end) datetime_end = existingSlot?.datetime_end; // Проверка: оба времени должны быть определены - if (!time_start || !time_end) { + if (!datetime_start || !datetime_end) { throw new Error(ERR_INVALID_TIME); } // Проверка валидности времени - if (timeToDate(date, time_start) >= timeToDate(date, time_end)) { + 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: ['date', 'documentId'], + select: ['documentId'], populate: ['orders'], }); @@ -116,23 +118,20 @@ export default { } if (!existingEntity) { - throw new Error('Запись не найдена'); + throw new Error(ERR_RECORD_NOT_FOUND); } const { documentId } = existingEntity; - const overlappingEntities = await strapi.db .query('api::slot.slot') .findMany({ where: { - date, id: { $ne: entityId }, documentId: { $ne: documentId }, - time_start: { $lt: time_end }, - time_end: { $gt: time_start }, + datetime_start: { $lt: datetime_end }, + datetime_end: { $gt: datetime_start }, }, }); - if (overlappingEntities.length > 0) { throw new Error(ERR_OVERLAPPING_TIME); } diff --git a/src/api/slot/content-types/slot/schema.json b/src/api/slot/content-types/slot/schema.json index fa6175b..814816c 100644 --- a/src/api/slot/content-types/slot/schema.json +++ b/src/api/slot/content-types/slot/schema.json @@ -12,14 +12,6 @@ }, "pluginOptions": {}, "attributes": { - "time_start": { - "type": "time", - "required": true - }, - "time_end": { - "type": "time", - "required": true - }, "state": { "type": "enumeration", "enum": [ @@ -40,8 +32,13 @@ "target": "api::customer.customer", "inversedBy": "slots" }, - "date": { - "type": "date" + "datetime_end": { + "type": "datetime", + "required": true + }, + "datetime_start": { + "type": "datetime", + "required": true } } } diff --git a/types/generated/contentTypes.d.ts b/types/generated/contentTypes.d.ts index cca6aa0..defefc9 100644 --- a/types/generated/contentTypes.d.ts +++ b/types/generated/contentTypes.d.ts @@ -470,6 +470,8 @@ export interface ApiOrderOrder extends Struct.CollectionTypeSchema { createdAt: Schema.Attribute.DateTime; createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; + datetime_end: Schema.Attribute.DateTime & Schema.Attribute.Required; + datetime_start: Schema.Attribute.DateTime & Schema.Attribute.Required; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation<'oneToMany', 'api::order.order'> & Schema.Attribute.Private; @@ -490,8 +492,6 @@ export interface ApiOrderOrder extends Struct.CollectionTypeSchema { ] > & Schema.Attribute.DefaultTo<'created'>; - time_end: Schema.Attribute.Time; - time_start: Schema.Attribute.Time; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; @@ -577,7 +577,8 @@ export interface ApiSlotSlot extends Struct.CollectionTypeSchema { createdAt: Schema.Attribute.DateTime; createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; - date: Schema.Attribute.Date; + datetime_end: Schema.Attribute.DateTime & Schema.Attribute.Required; + datetime_start: Schema.Attribute.DateTime & Schema.Attribute.Required; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation<'oneToMany', 'api::slot.slot'> & Schema.Attribute.Private; @@ -585,8 +586,6 @@ export interface ApiSlotSlot extends Struct.CollectionTypeSchema { orders: Schema.Attribute.Relation<'oneToMany', 'api::order.order'>; publishedAt: Schema.Attribute.DateTime; state: Schema.Attribute.Enumeration<['open', 'reserved', 'closed']>; - time_end: Schema.Attribute.Time & Schema.Attribute.Required; - time_start: Schema.Attribute.Time & Schema.Attribute.Required; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private;