@escapenavigator/utils 2.0.36 → 2.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,8 +24,9 @@
24
24
  * skip → не репортим
25
25
  * 304 (etag-success), отменённые axios-запросы (ERR_CANCELED),
26
26
  * 401/403 на app/auth (ожидаемый session/role flow), а также
27
- * обрывы связи на стороне клиента: `Network Error` и
28
- * «timeout of 0ms exceeded» (запрос умер до ответа сервера).
27
+ * обрывы связи на стороне клиента: `Network Error`,
28
+ * «timeout of 0ms exceeded» и «Request aborted» (запрос умер
29
+ * до ответа сервера).
29
30
  *
30
31
  * Группировка (`fingerprint`) делается так, чтобы все события с одинаковым
31
32
  * `apiKey + errorCode` сливались в один issue. Иначе Sentry плодит отдельный
@@ -25,8 +25,9 @@
25
25
  * skip → не репортим
26
26
  * 304 (etag-success), отменённые axios-запросы (ERR_CANCELED),
27
27
  * 401/403 на app/auth (ожидаемый session/role flow), а также
28
- * обрывы связи на стороне клиента: `Network Error` и
29
- * «timeout of 0ms exceeded» (запрос умер до ответа сервера).
28
+ * обрывы связи на стороне клиента: `Network Error`,
29
+ * «timeout of 0ms exceeded» и «Request aborted» (запрос умер
30
+ * до ответа сервера).
30
31
  *
31
32
  * Группировка (`fingerprint`) делается так, чтобы все события с одинаковым
32
33
  * `apiKey + errorCode` сливались в один issue. Иначе Sentry плодит отдельный
@@ -80,19 +81,32 @@ const SKIP_AUTH_STATUSES_ON_APPS = new Set(['app', 'auth']);
80
81
  */
81
82
  const SKIP_NOT_FOUND_API_KEYS = new Set(['login', 'loginSwitch']);
82
83
  const isNetworkErrorMessage = (message) => typeof message === 'string' && /network error/i.test(message);
84
+ /**
85
+ * Сообщения ECONNABORTED, означающие «запрос отменил браузер/ОС», а не
86
+ * «бэк не ответил за отведённый таймаут». Сконфигурированный таймаут виджета
87
+ * («timeout of 25000ms exceeded») этому не соответствует и остаётся critical.
88
+ */
89
+ const isClientAbortMessage = (message) => typeof message === 'string' && /timeout of 0ms|request aborted/i.test(message);
83
90
  function classifyApiError(ctx) {
84
91
  const { status, code, apiKey, errorCode, originalErrorCode, message, app } = ctx;
85
92
  if (code && SKIP_AXIOS_CODES.has(code)) {
86
93
  return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
87
94
  }
88
- // «timeout of 0ms exceeded» — Safari/iOS обрывает XHR при смене сети или
89
- // уходе вкладки в фон (timeout у запроса не выставлен, обрыв не наш).
95
+ // ECONNABORTED, где запрос умер на клиенте, а не на бэке:
96
+ //
97
+ // «timeout of 0ms exceeded» — Safari/iOS обрывает XHR при смене сети или
98
+ // уходе вкладки в фон (timeout у запроса не выставлен, обрыв не наш).
99
+ // Раньше репортили как business/info «для картины мобильной связности»:
100
+ // за месяц это дало десяток issue на 50+ человек
101
+ // (ESCAPE-NAVIGATOR-APP-3J/3K/3M/3H/2P/3G/33), в которых ни одного бага.
102
+ //
103
+ // «Request aborted» — axios@0.21 отдаёт это на `xhr.onabort`, то есть
104
+ // когда браузер сам убил запрос: пользователь ушёл со страницы, закрыл
105
+ // таб или нажал «стоп» до ответа. О здоровье API это не говорит ничего,
106
+ // а в Sentry попадало как critical.
90
107
  //
91
- // Раньше репортили как business/info «для картины мобильной связности»:
92
- // за месяц это дало десяток issue на 50+ человек
93
- // (ESCAPE-NAVIGATOR-APP-3J/3K/3M/3H/2P/3G/33), в которых ни одного бага.
94
108
  // Картину связности всё равно лучше видно по метрикам, а не по issue.
95
- if (code === 'ECONNABORTED' && typeof message === 'string' && /timeout of 0ms/i.test(message)) {
109
+ if (code === 'ECONNABORTED' && isClientAbortMessage(message)) {
96
110
  return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
97
111
  }
98
112
  if (typeof status === 'number' && SKIP_HTTP_STATUSES.has(status)) {
@@ -56,6 +56,15 @@ describe('classifyApiError', () => {
56
56
  expect(r.severity).toBe('skip');
57
57
  expect(r.shouldReport).toBe(false);
58
58
  });
59
+ it('уход со страницы до ответа (Request aborted) → skip', () => {
60
+ const r = (0, classify_api_error_1.classifyApiError)({
61
+ ...base,
62
+ code: 'ECONNABORTED',
63
+ message: 'Request aborted',
64
+ });
65
+ expect(r.severity).toBe('skip');
66
+ expect(r.shouldReport).toBe(false);
67
+ });
59
68
  it('сработавший таймаут виджета (25s) → НЕ skip, это медленный бэк', () => {
60
69
  const r = (0, classify_api_error_1.classifyApiError)({
61
70
  ...base,
@@ -0,0 +1,6 @@
1
+ import { SlotDiscountTypeEnum } from '@escapenavigator/types/dist/slot/enum/slot-discount-type.enum';
2
+ /**
3
+ * Скидка слота для показа: «15%» для процентной и «500 руб.» для фиксированной.
4
+ * Без знака «минус» — вызывающий сам решает, как её подать.
5
+ */
6
+ export declare const formatSlotDiscount: (discount: number, discountType: SlotDiscountTypeEnum | string | null | undefined, currency: string) => string;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatSlotDiscount = void 0;
4
+ const format_amount_1 = require("../format-amount");
5
+ const get_slot_discount_amount_1 = require("../get-slot-discount-amount");
6
+ /**
7
+ * Скидка слота для показа: «15%» для процентной и «500 руб.» для фиксированной.
8
+ * Без знака «минус» — вызывающий сам решает, как её подать.
9
+ */
10
+ const formatSlotDiscount = (discount, discountType, currency) => {
11
+ if ((0, get_slot_discount_amount_1.isFixedSlotDiscount)(discountType))
12
+ return (0, format_amount_1.formatAmount)(discount, currency);
13
+ return `${discount / 100}%`;
14
+ };
15
+ exports.formatSlotDiscount = formatSlotDiscount;
@@ -0,0 +1,22 @@
1
+ import { SlotDiscountTypeEnum } from '@escapenavigator/types/dist/slot/enum/slot-discount-type.enum';
2
+ type SlotDiscountType = SlotDiscountTypeEnum | string | null;
3
+ type Params = {
4
+ /** База начисления скидки в минорных единицах (стоимость игры за игроков). */
5
+ price: number;
6
+ /** `slots.discount`: базисные пункты для процента, минорные единицы для фикс. */
7
+ discount?: number | null;
8
+ discountType?: SlotDiscountType;
9
+ /**
10
+ * Процентная скидка исторически округлялась по-разному: `Math.round` в
11
+ * расчёте заказа и `Math.floor` в «цене от» для расписания. Параметр
12
+ * сохраняет это различие, чтобы правки не сдвигали цены на проде.
13
+ */
14
+ rounding?: 'round' | 'floor';
15
+ };
16
+ export declare const isFixedSlotDiscount: (discountType?: SlotDiscountType) => boolean;
17
+ /**
18
+ * Сумма скидки слота в минорных единицах. Никогда не превышает базу, поэтому
19
+ * итог не уходит в минус даже если фиксированная скидка больше цены игры.
20
+ */
21
+ export declare const getSlotDiscountAmount: ({ price, discount, discountType, rounding, }: Params) => number;
22
+ export {};
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getSlotDiscountAmount = exports.isFixedSlotDiscount = void 0;
4
+ const slot_discount_type_enum_1 = require("@escapenavigator/types/dist/slot/enum/slot-discount-type.enum");
5
+ const isFixedSlotDiscount = (discountType) => discountType === slot_discount_type_enum_1.SlotDiscountTypeEnum.FIXED;
6
+ exports.isFixedSlotDiscount = isFixedSlotDiscount;
7
+ /**
8
+ * Сумма скидки слота в минорных единицах. Никогда не превышает базу, поэтому
9
+ * итог не уходит в минус даже если фиксированная скидка больше цены игры.
10
+ */
11
+ const getSlotDiscountAmount = ({ price, discount, discountType, rounding = 'round', }) => {
12
+ const value = discount || 0;
13
+ const base = price > 0 ? price : 0;
14
+ if (value <= 0 || !base)
15
+ return 0;
16
+ if ((0, exports.isFixedSlotDiscount)(discountType))
17
+ return Math.min(value, base);
18
+ const raw = (base * value) / 10000;
19
+ return Math.min(base, rounding === 'floor' ? Math.floor(raw) : Math.round(raw));
20
+ };
21
+ exports.getSlotDiscountAmount = getSlotDiscountAmount;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const slot_discount_type_enum_1 = require("@escapenavigator/types/dist/slot/enum/slot-discount-type.enum");
4
+ const get_slot_discount_amount_1 = require("./get-slot-discount-amount");
5
+ describe('getSlotDiscountAmount', () => {
6
+ it('treats a missing type as percent (слоты до появления поля)', () => {
7
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 10000, discount: 1500 })).toBe(1500);
8
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 10000, discount: 1500, discountType: null })).toBe(1500);
9
+ });
10
+ it('rounds percent by half up and by floor on demand', () => {
11
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 3333, discount: 1500 })).toBe(500);
12
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 3333, discount: 1500, rounding: 'floor' })).toBe(499);
13
+ });
14
+ it('takes a fixed discount as an amount in minor units', () => {
15
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({
16
+ price: 10000,
17
+ discount: 1500,
18
+ discountType: slot_discount_type_enum_1.SlotDiscountTypeEnum.FIXED,
19
+ })).toBe(1500);
20
+ });
21
+ it('never exceeds the base price', () => {
22
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({
23
+ price: 3000,
24
+ discount: 500000,
25
+ discountType: slot_discount_type_enum_1.SlotDiscountTypeEnum.FIXED,
26
+ })).toBe(3000);
27
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 3000, discount: 20000 })).toBe(3000);
28
+ });
29
+ it('returns 0 for empty input', () => {
30
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 0, discount: 1500 })).toBe(0);
31
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 10000 })).toBe(0);
32
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: -100, discount: 1500 })).toBe(0);
33
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({
34
+ price: 10000,
35
+ discount: -500,
36
+ discountType: slot_discount_type_enum_1.SlotDiscountTypeEnum.FIXED,
37
+ })).toBe(0);
38
+ });
39
+ });
package/dist/index.d.ts CHANGED
@@ -8,12 +8,14 @@ export * from './convert-to-options';
8
8
  export * from './date';
9
9
  export * from './enum-to-options';
10
10
  export * from './format-flex-duration-label';
11
+ export * from './format-slot-discount';
11
12
  export * from './functional-popover-panel-style';
12
13
  export * from './get-documents-links';
13
14
  export * from './get-full-name';
14
15
  export * from './get-handled-error-message';
15
16
  export * from './get-order-cancelation-state';
16
17
  export * from './get-service-error';
18
+ export * from './get-slot-discount-amount';
17
19
  export * from './google-places-link-warnings';
18
20
  export * from './google-places-nonquest';
19
21
  export * from './image-url';
@@ -29,6 +31,7 @@ export * from './resolve-calendar-location-id';
29
31
  export * from './role-chat-permissions';
30
32
  export * from './role-comment-permissions';
31
33
  export * from './role-export-permissions';
34
+ export * from './role-refund-permissions';
32
35
  export * from './serialize-record';
33
36
  export * from './serialize-slot';
34
37
  export * from './slot-representative-price';
package/dist/index.js CHANGED
@@ -24,12 +24,14 @@ __exportStar(require("./convert-to-options"), exports);
24
24
  __exportStar(require("./date"), exports);
25
25
  __exportStar(require("./enum-to-options"), exports);
26
26
  __exportStar(require("./format-flex-duration-label"), exports);
27
+ __exportStar(require("./format-slot-discount"), exports);
27
28
  __exportStar(require("./functional-popover-panel-style"), exports);
28
29
  __exportStar(require("./get-documents-links"), exports);
29
30
  __exportStar(require("./get-full-name"), exports);
30
31
  __exportStar(require("./get-handled-error-message"), exports);
31
32
  __exportStar(require("./get-order-cancelation-state"), exports);
32
33
  __exportStar(require("./get-service-error"), exports);
34
+ __exportStar(require("./get-slot-discount-amount"), exports);
33
35
  __exportStar(require("./google-places-link-warnings"), exports);
34
36
  __exportStar(require("./google-places-nonquest"), exports);
35
37
  __exportStar(require("./image-url"), exports);
@@ -45,6 +47,7 @@ __exportStar(require("./resolve-calendar-location-id"), exports);
45
47
  __exportStar(require("./role-chat-permissions"), exports);
46
48
  __exportStar(require("./role-comment-permissions"), exports);
47
49
  __exportStar(require("./role-export-permissions"), exports);
50
+ __exportStar(require("./role-refund-permissions"), exports);
48
51
  __exportStar(require("./serialize-record"), exports);
49
52
  __exportStar(require("./serialize-slot"), exports);
50
53
  __exportStar(require("./slot-representative-price"), exports);
@@ -0,0 +1,73 @@
1
+ import { PackagePricingModeEnum } from '@escapenavigator/types/dist/packages/package-pricing-mode.enum';
2
+ /**
3
+ * Единый калькулятор цены пакета. Живёт в utils, потому что одна и та же
4
+ * формула нужна в трёх местах: витрина виджета (показать «от»), конструктор
5
+ * (показать итог до брони) и бэкенд (посчитать реальную цену заказа).
6
+ * Расхождение между ними = клиент видит одну сумму, а платит другую.
7
+ */
8
+ /** Поля пакета, от которых зависит цена. Ровно то, что нужно калькулятору. */
9
+ export type PackagePricingInput = {
10
+ pricingMode: PackagePricingModeEnum;
11
+ price: number;
12
+ weekendPrice?: number | null;
13
+ weekendDays?: number[] | null;
14
+ includedPlayers: number;
15
+ extraPlayerPrice: number;
16
+ };
17
+ export type PackagePriceBreakdown = {
18
+ /** Базовая цена пакета на выбранную дату. */
19
+ base: number;
20
+ /** Сколько игроков сверх включённых в пакет. */
21
+ extraPlayers: number;
22
+ /** Доплата за игроков сверх пакета. */
23
+ extraPlayersTotal: number;
24
+ /** Стоимость выбранных платных допов. */
25
+ addonsTotal: number;
26
+ /** Итог: base + extraPlayersTotal + addonsTotal. */
27
+ total: number;
28
+ /** Цена на человека (для «≈ 1 710 ₽ / чел.»). */
29
+ perPerson: number;
30
+ /** Дата попала в «выходные» по настройкам пакета. */
31
+ isWeekend: boolean;
32
+ };
33
+ /**
34
+ * Индекс дня недели в проектной нумерации: 0 — понедельник … 6 — воскресенье.
35
+ * Совпадает с `availableDays` слотов/апселлов и компонентом `DaysOfWeek`,
36
+ * поэтому НЕ равен `Date.getDay()`.
37
+ */
38
+ export declare const getProjectWeekday: (date: Date | string) => number;
39
+ export declare const isPackageWeekend: (pkg: Pick<PackagePricingInput, "weekendDays">, date?: Date | string | null) => boolean;
40
+ /** Цена пакета за день без учёта игроков и допов. */
41
+ export declare const getPackageBasePrice: (pkg: PackagePricingInput, date?: Date | string | null) => number;
42
+ /** Диапазон цен пакета по дням недели — для витрины («от / до»). */
43
+ export declare const getPackagePriceRange: (pkg: PackagePricingInput) => {
44
+ min: number;
45
+ max: number;
46
+ hasWeekendPrice: boolean;
47
+ };
48
+ export declare const getPackageExtraPlayers: (pkg: PackagePricingInput, players: number) => number;
49
+ /**
50
+ * Полный расчёт цены пакета в режиме `fixed`.
51
+ *
52
+ * Для `discount`-пакетов возвращает нули по базе: там цену диктуют тарифы
53
+ * слотов, а пакет умеет только скидку — считать её без состава группы нельзя.
54
+ */
55
+ export declare const calculatePackagePrice: ({ pkg, date, players, addonsTotal, }: {
56
+ pkg: PackagePricingInput;
57
+ date?: Date | string | null;
58
+ players: number;
59
+ addonsTotal?: number;
60
+ }) => PackagePriceBreakdown;
61
+ /**
62
+ * Прайс-мапа `{ игроков: цена }` для заказа-носителя пакета.
63
+ *
64
+ * Заказ хранит снимок тарифа в `order.price`, и `getPlayersPrice` берёт из
65
+ * него значение по текущему числу игроков. Поэтому мы отдаём не одно число,
66
+ * а карту на весь допустимый диапазон: если менеджер потом поменяет состав,
67
+ * пересчёт останется по формуле пакета, а не превратится в ноль.
68
+ */
69
+ export declare const buildPackagePriceMap: ({ pkg, date, maxPlayers, }: {
70
+ pkg: PackagePricingInput;
71
+ date?: Date | string | null;
72
+ maxPlayers: number;
73
+ }) => Record<string, number>;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildPackagePriceMap = exports.calculatePackagePrice = exports.getPackageExtraPlayers = exports.getPackagePriceRange = exports.getPackageBasePrice = exports.isPackageWeekend = exports.getProjectWeekday = void 0;
4
+ const package_pricing_mode_enum_1 = require("@escapenavigator/types/dist/packages/package-pricing-mode.enum");
5
+ const DEFAULT_WEEKEND_DAYS = [5, 6];
6
+ const toNumber = (value) => {
7
+ const parsed = Number(value);
8
+ return Number.isFinite(parsed) ? parsed : 0;
9
+ };
10
+ /**
11
+ * Индекс дня недели в проектной нумерации: 0 — понедельник … 6 — воскресенье.
12
+ * Совпадает с `availableDays` слотов/апселлов и компонентом `DaysOfWeek`,
13
+ * поэтому НЕ равен `Date.getDay()`.
14
+ */
15
+ const getProjectWeekday = (date) => {
16
+ const parsed = typeof date === 'string' ? new Date(`${date}T00:00:00`) : date;
17
+ if (Number.isNaN(parsed.getTime()))
18
+ return 0;
19
+ return (parsed.getDay() + 6) % 7;
20
+ };
21
+ exports.getProjectWeekday = getProjectWeekday;
22
+ const isPackageWeekend = (pkg, date) => {
23
+ if (!date)
24
+ return false;
25
+ const weekendDays = pkg.weekendDays?.length ? pkg.weekendDays : DEFAULT_WEEKEND_DAYS;
26
+ return weekendDays.includes((0, exports.getProjectWeekday)(date));
27
+ };
28
+ exports.isPackageWeekend = isPackageWeekend;
29
+ /** Цена пакета за день без учёта игроков и допов. */
30
+ const getPackageBasePrice = (pkg, date) => {
31
+ const weekday = Math.max(0, toNumber(pkg.price));
32
+ const weekend = pkg.weekendPrice == null ? weekday : Math.max(0, toNumber(pkg.weekendPrice));
33
+ return (0, exports.isPackageWeekend)(pkg, date) ? weekend : weekday;
34
+ };
35
+ exports.getPackageBasePrice = getPackageBasePrice;
36
+ /** Диапазон цен пакета по дням недели — для витрины («от / до»). */
37
+ const getPackagePriceRange = (pkg) => {
38
+ const weekday = Math.max(0, toNumber(pkg.price));
39
+ const weekend = pkg.weekendPrice == null ? weekday : Math.max(0, toNumber(pkg.weekendPrice));
40
+ return {
41
+ min: Math.min(weekday, weekend),
42
+ max: Math.max(weekday, weekend),
43
+ hasWeekendPrice: pkg.weekendPrice != null && weekend !== weekday,
44
+ };
45
+ };
46
+ exports.getPackagePriceRange = getPackagePriceRange;
47
+ const getPackageExtraPlayers = (pkg, players) => Math.max(0, Math.floor(toNumber(players)) - Math.max(0, Math.floor(toNumber(pkg.includedPlayers))));
48
+ exports.getPackageExtraPlayers = getPackageExtraPlayers;
49
+ /**
50
+ * Полный расчёт цены пакета в режиме `fixed`.
51
+ *
52
+ * Для `discount`-пакетов возвращает нули по базе: там цену диктуют тарифы
53
+ * слотов, а пакет умеет только скидку — считать её без состава группы нельзя.
54
+ */
55
+ const calculatePackagePrice = ({ pkg, date, players, addonsTotal = 0, }) => {
56
+ const isFixed = pkg.pricingMode === package_pricing_mode_enum_1.PackagePricingModeEnum.FIXED;
57
+ const base = isFixed ? (0, exports.getPackageBasePrice)(pkg, date) : 0;
58
+ const extraPlayers = isFixed ? (0, exports.getPackageExtraPlayers)(pkg, players) : 0;
59
+ const extraPlayersTotal = extraPlayers * Math.max(0, toNumber(pkg.extraPlayerPrice));
60
+ const safeAddons = Math.max(0, toNumber(addonsTotal));
61
+ const total = base + extraPlayersTotal + safeAddons;
62
+ const guests = Math.max(1, Math.floor(toNumber(players)));
63
+ return {
64
+ base,
65
+ extraPlayers,
66
+ extraPlayersTotal,
67
+ addonsTotal: safeAddons,
68
+ total,
69
+ perPerson: Math.round(total / guests),
70
+ isWeekend: (0, exports.isPackageWeekend)(pkg, date),
71
+ };
72
+ };
73
+ exports.calculatePackagePrice = calculatePackagePrice;
74
+ /**
75
+ * Прайс-мапа `{ игроков: цена }` для заказа-носителя пакета.
76
+ *
77
+ * Заказ хранит снимок тарифа в `order.price`, и `getPlayersPrice` берёт из
78
+ * него значение по текущему числу игроков. Поэтому мы отдаём не одно число,
79
+ * а карту на весь допустимый диапазон: если менеджер потом поменяет состав,
80
+ * пересчёт останется по формуле пакета, а не превратится в ноль.
81
+ */
82
+ const buildPackagePriceMap = ({ pkg, date, maxPlayers, }) => {
83
+ const base = (0, exports.getPackageBasePrice)(pkg, date);
84
+ const extraPrice = Math.max(0, toNumber(pkg.extraPlayerPrice));
85
+ const limit = Math.max(1, Math.floor(toNumber(maxPlayers)));
86
+ const map = {};
87
+ for (let players = 1; players <= limit; players++) {
88
+ map[players] = base + (0, exports.getPackageExtraPlayers)(pkg, players) * extraPrice;
89
+ }
90
+ return map;
91
+ };
92
+ exports.buildPackagePriceMap = buildPackagePriceMap;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const package_pricing_mode_enum_1 = require("@escapenavigator/types/dist/packages/package-pricing-mode.enum");
4
+ const _1 = require(".");
5
+ /** «Хит» из витрины: 13 590 в будни, 15 590 в выходные, 8 гостей, +800 за гостя. */
6
+ const hit = {
7
+ pricingMode: package_pricing_mode_enum_1.PackagePricingModeEnum.FIXED,
8
+ price: 13590,
9
+ weekendPrice: 15590,
10
+ weekendDays: [5, 6],
11
+ includedPlayers: 8,
12
+ extraPlayerPrice: 800,
13
+ };
14
+ describe('getProjectWeekday', () => {
15
+ it('нумерует с понедельника, а не с воскресенья как Date.getDay', () => {
16
+ expect((0, _1.getProjectWeekday)('2026-08-17')).toBe(0);
17
+ expect((0, _1.getProjectWeekday)('2026-08-22')).toBe(5);
18
+ expect((0, _1.getProjectWeekday)('2026-08-23')).toBe(6);
19
+ });
20
+ });
21
+ describe('isPackageWeekend', () => {
22
+ it('по умолчанию выходные — суббота и воскресенье', () => {
23
+ expect((0, _1.isPackageWeekend)({ weekendDays: [] }, '2026-08-22')).toBe(true);
24
+ expect((0, _1.isPackageWeekend)({ weekendDays: null }, '2026-08-19')).toBe(false);
25
+ });
26
+ it('уважает кастомный набор дней', () => {
27
+ expect((0, _1.isPackageWeekend)({ weekendDays: [4, 5, 6] }, '2026-08-21')).toBe(true);
28
+ });
29
+ it('без даты не угадывает', () => {
30
+ expect((0, _1.isPackageWeekend)(hit, null)).toBe(false);
31
+ });
32
+ });
33
+ describe('getPackageBasePrice', () => {
34
+ it('берёт цену выходного дня только в выходные', () => {
35
+ expect((0, _1.getPackageBasePrice)(hit, '2026-08-19')).toBe(13590);
36
+ expect((0, _1.getPackageBasePrice)(hit, '2026-08-22')).toBe(15590);
37
+ });
38
+ it('без цены выходного дня цена одна на всю неделю', () => {
39
+ expect((0, _1.getPackageBasePrice)({ ...hit, weekendPrice: null }, '2026-08-22')).toBe(13590);
40
+ });
41
+ });
42
+ describe('getPackagePriceRange', () => {
43
+ it('отдаёт диапазон для витрины', () => {
44
+ expect((0, _1.getPackagePriceRange)(hit)).toEqual({
45
+ min: 13590,
46
+ max: 15590,
47
+ hasWeekendPrice: true,
48
+ });
49
+ });
50
+ it('схлопывает диапазон, когда выходная цена совпадает с будней', () => {
51
+ expect((0, _1.getPackagePriceRange)({ ...hit, weekendPrice: 13590 })).toEqual({
52
+ min: 13590,
53
+ max: 13590,
54
+ hasWeekendPrice: false,
55
+ });
56
+ });
57
+ });
58
+ describe('calculatePackagePrice', () => {
59
+ it('считает 9 гостей в будни как пакет + одна доплата', () => {
60
+ expect((0, _1.calculatePackagePrice)({ pkg: hit, date: '2026-08-19', players: 9, addonsTotal: 1000 })).toEqual({
61
+ base: 13590,
62
+ extraPlayers: 1,
63
+ extraPlayersTotal: 800,
64
+ addonsTotal: 1000,
65
+ total: 15390,
66
+ perPerson: 1710,
67
+ isWeekend: false,
68
+ });
69
+ });
70
+ it('не берёт доплату, пока гостей не больше включённых', () => {
71
+ const result = (0, _1.calculatePackagePrice)({ pkg: hit, date: '2026-08-19', players: 5 });
72
+ expect(result.extraPlayers).toBe(0);
73
+ expect(result.total).toBe(13590);
74
+ });
75
+ it('в discount-режиме базы у пакета нет — цену диктуют тарифы слотов', () => {
76
+ const result = (0, _1.calculatePackagePrice)({
77
+ pkg: { ...hit, pricingMode: package_pricing_mode_enum_1.PackagePricingModeEnum.DISCOUNT },
78
+ date: '2026-08-22',
79
+ players: 12,
80
+ addonsTotal: 500,
81
+ });
82
+ expect(result.base).toBe(0);
83
+ expect(result.extraPlayersTotal).toBe(0);
84
+ expect(result.total).toBe(500);
85
+ });
86
+ });
87
+ describe('buildPackagePriceMap', () => {
88
+ it('покрывает весь диапазон, чтобы смена состава не обнуляла заказ', () => {
89
+ const map = (0, _1.buildPackagePriceMap)({ pkg: hit, date: '2026-08-19', maxPlayers: 10 });
90
+ expect(map[1]).toBe(13590);
91
+ expect(map[8]).toBe(13590);
92
+ expect(map[9]).toBe(14390);
93
+ expect(map[10]).toBe(15190);
94
+ expect(Object.keys(map)).toHaveLength(10);
95
+ });
96
+ it('в выходные строится от выходной цены', () => {
97
+ const map = (0, _1.buildPackagePriceMap)({ pkg: hit, date: '2026-08-22', maxPlayers: 9 });
98
+ expect(map[9]).toBe(16390);
99
+ });
100
+ });
@@ -0,0 +1,7 @@
1
+ import { RoleRO } from '@escapenavigator/types/dist/role/role.ro';
2
+ export type RoleRefundPermissions = Pick<RoleRO, 'totalAccess' | 'canAddRefunds'>;
3
+ /**
4
+ * Право оформлять возвраты (в т.ч. Stripe). Владелец (`totalAccess`) —
5
+ * как будто флаг включён.
6
+ */
7
+ export declare const canAddRefunds: (role?: RoleRefundPermissions | null) => boolean;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.canAddRefunds = void 0;
4
+ /**
5
+ * Право оформлять возвраты (в т.ч. Stripe). Владелец (`totalAccess`) —
6
+ * как будто флаг включён.
7
+ */
8
+ const canAddRefunds = (role) => !!role?.totalAccess || !!role?.canAddRefunds;
9
+ exports.canAddRefunds = canAddRefunds;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const role_refund_permissions_1 = require("./role-refund-permissions");
4
+ describe('canAddRefunds', () => {
5
+ it('is false without role or flag', () => {
6
+ expect((0, role_refund_permissions_1.canAddRefunds)()).toBe(false);
7
+ expect((0, role_refund_permissions_1.canAddRefunds)(null)).toBe(false);
8
+ expect((0, role_refund_permissions_1.canAddRefunds)({ totalAccess: false, canAddRefunds: false })).toBe(false);
9
+ });
10
+ it('is true when flag is on', () => {
11
+ expect((0, role_refund_permissions_1.canAddRefunds)({ totalAccess: false, canAddRefunds: true })).toBe(true);
12
+ });
13
+ it('is true for totalAccess even when flag is off', () => {
14
+ expect((0, role_refund_permissions_1.canAddRefunds)({ totalAccess: true, canAddRefunds: false })).toBe(true);
15
+ });
16
+ });
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.serializeSlot = exports.serializeSlotOrderData = void 0;
4
+ const slot_discount_type_enum_1 = require("@escapenavigator/types/dist/slot/enum/slot-discount-type.enum");
4
5
  const defailt_rule_1 = require("@escapenavigator/types/dist/slot-rule/defailt-rule");
5
6
  const buildRuleSummary = (rule) => {
6
7
  const { prepayment, prepaymentType, title, minHoursForBooking, minMinutesForBooking, minHoursForFreeCanceling, cancelationRule, minHoursForFullFine, cancelationAmount, } = rule || defailt_rule_1.defaultRule;
@@ -62,9 +63,16 @@ const serializeSlot = ({ slot, orders = [] }) => ({
62
63
  tariff: slot.tariff?.price || slot.tariff || {},
63
64
  status: slot.status,
64
65
  discount: slot.discount ?? 0,
66
+ discountType: slot.discountType ?? slot_discount_type_enum_1.SlotDiscountTypeEnum.PERCENT,
65
67
  onlyPhone: slot.onlyPhone ?? false,
66
68
  forceOnlineBooking: slot.forceOnlineBooking ?? false,
67
69
  ignoreResourceLimit: slot.ignoreResourceLimit ?? false,
70
+ // Пустой список не отдаём вообще: `calendar-day` сериализует до 1000
71
+ // слотов за раз, и `"excludedUpsellingIds":[]` на каждом — это лишние
72
+ // десятки килобайт в ответе ради информации «ничего не запрещено».
73
+ excludedUpsellingIds: slot.excludedUpsellingIds?.length
74
+ ? slot.excludedUpsellingIds
75
+ : undefined,
68
76
  breakReason: slot.breakReason ?? null,
69
77
  availableTeams: slot.availableTeams ?? null,
70
78
  numSeatsAvailable: slot.numSeatsAvailable ?? slot.availablePlayers ?? 0,
@@ -1,7 +1,9 @@
1
+ import { SlotDiscountTypeEnum } from '@escapenavigator/types/dist/slot/enum/slot-discount-type.enum';
1
2
  type SlotPriceSource = {
2
3
  tariff?: unknown;
3
4
  basePrice?: number;
4
5
  discount?: number;
6
+ discountType?: SlotDiscountTypeEnum | string | null;
5
7
  };
6
8
  /**
7
9
  * Плоская карта цен из тарифа слота.
@@ -10,6 +12,14 @@ type SlotPriceSource = {
10
12
  export declare const getTariffPriceMap: (tariff: unknown) => Record<string, number>;
11
13
  /** Минимальная положительная цена из тарифа (без скидки слота). */
12
14
  export declare const getTariffBasePrice: (tariff: unknown) => number | null;
15
+ /**
16
+ * Цена «от» до и после скидки слота. Обе величины нужны расписанию: одну
17
+ * показываем, вторую зачёркиваем.
18
+ */
19
+ export declare const getSlotRepresentativePrices: (slot: SlotPriceSource) => {
20
+ base: number;
21
+ discounted: number;
22
+ } | null;
13
23
  /** Цена «от» для слота в расписании (с учётом скидки слота). */
14
24
  export declare const getSlotRepresentativePrice: (slot: SlotPriceSource) => number | null;
15
25
  export {};
@@ -1,12 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getSlotRepresentativePrice = exports.getTariffBasePrice = exports.getTariffPriceMap = void 0;
4
- const applySlotDiscount = (base, discount) => {
5
- const value = discount || 0;
6
- if (!value)
7
- return base;
8
- return base - Math.floor((base / 10000) * value);
9
- };
3
+ exports.getSlotRepresentativePrice = exports.getSlotRepresentativePrices = exports.getTariffBasePrice = exports.getTariffPriceMap = void 0;
4
+ const get_slot_discount_amount_1 = require("./get-slot-discount-amount");
10
5
  /**
11
6
  * Плоская карта цен из тарифа слота.
12
7
  * Принимает и `{ id, price: { "2": 1500 } }` (листинг), и `{ "2": 1500 }` (/slots/details).
@@ -36,13 +31,25 @@ const getTariffBasePrice = (tariff) => {
36
31
  return null;
37
32
  };
38
33
  exports.getTariffBasePrice = getTariffBasePrice;
39
- /** Цена «от» для слота в расписании (с учётом скидки слота). */
40
- const getSlotRepresentativePrice = (slot) => {
34
+ /**
35
+ * Цена «от» до и после скидки слота. Обе величины нужны расписанию: одну
36
+ * показываем, вторую зачёркиваем.
37
+ */
38
+ const getSlotRepresentativePrices = (slot) => {
41
39
  const base = typeof slot.basePrice === 'number' && slot.basePrice > 0
42
40
  ? slot.basePrice
43
41
  : (0, exports.getTariffBasePrice)(slot.tariff);
44
42
  if (base == null)
45
43
  return null;
46
- return applySlotDiscount(base, slot.discount);
44
+ const discountAmount = (0, get_slot_discount_amount_1.getSlotDiscountAmount)({
45
+ price: base,
46
+ discount: slot.discount,
47
+ discountType: slot.discountType,
48
+ rounding: 'floor',
49
+ });
50
+ return { base, discounted: base - discountAmount };
47
51
  };
52
+ exports.getSlotRepresentativePrices = getSlotRepresentativePrices;
53
+ /** Цена «от» для слота в расписании (с учётом скидки слота). */
54
+ const getSlotRepresentativePrice = (slot) => (0, exports.getSlotRepresentativePrices)(slot)?.discounted ?? null;
48
55
  exports.getSlotRepresentativePrice = getSlotRepresentativePrice;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/utils",
3
- "version": "2.0.36",
3
+ "version": "2.0.39",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,7 +14,7 @@
14
14
  "test": "jest"
15
15
  },
16
16
  "dependencies": {
17
- "@escapenavigator/types": "^2.0.34",
17
+ "@escapenavigator/types": "^2.0.37",
18
18
  "axios": "^0.21.4",
19
19
  "class-transformer": "^0.5.1",
20
20
  "class-validator": "^0.13.2",
@@ -29,5 +29,5 @@
29
29
  "ts-jest": "^29.1.1",
30
30
  "typescript": "^5.6"
31
31
  },
32
- "gitHead": "3112d9d954979e406e77a787d459e16c49da3d5a"
32
+ "gitHead": "a5a2b7072d29242e6477d13cf07022fca9862b8c"
33
33
  }