@escapenavigator/utils 2.0.46 → 2.0.48

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const promocode_type_enum_1 = require("@escapenavigator/types/dist/promocode/emun/promocode-type.enum");
4
+ const tariff_reduced_rate_mode_enum_1 = require("@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum");
5
+ const get_players_price_1 = require("./get-players-price");
6
+ const get_promocode_discount_1 = require("./get-promocode-discount");
7
+ const get_slot_discount_amount_1 = require("./get-slot-discount-amount");
8
+ const slot_representative_price_1 = require("./slot-representative-price");
9
+ /**
10
+ * Пол выручки в боевом расчёте: цена игры, скидка слота, промокод и «цена от».
11
+ *
12
+ * Проверяем именно то, чего боялся венью: не только льготный состав, но и
13
+ * проценты сверху не должны опускать игру ниже минимума. И симметрично — пока
14
+ * минимум не задан, ни одна из этих формул не меняет поведение.
15
+ */
16
+ // Прайс из письма: 88 € за двоих (он же пол), 108 € за трёх, 120 € за четырёх.
17
+ const PRICE = { 2: 8800, 3: 10800, 4: 12000, child: 1500 };
18
+ const FLOOR = 8800;
19
+ const FIXED_22 = {
20
+ reducedRateMode: tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.FIXED,
21
+ reducedRate: 2200,
22
+ minGameAmount: FLOOR,
23
+ };
24
+ describe('getPlayersPrice: состав не пробивает минимум', () => {
25
+ it('без минимума считает как раньше: цена уровня плюс дети', () => {
26
+ expect((0, get_players_price_1.getPlayersPrice)({ price: PRICE, players: 3, children: 2 })).toBe(10800 + 3000);
27
+ });
28
+ it('льготные места уменьшают цену уровня, а не добавляют головы', () => {
29
+ expect((0, get_players_price_1.getPlayersPrice)({
30
+ price: PRICE,
31
+ players: 4,
32
+ children: 0,
33
+ reducedPlayers: 2,
34
+ ...FIXED_22,
35
+ })).toBe(10400);
36
+ });
37
+ it('вся группа по льготной ставке упирается в пол, а не проваливается под него', () => {
38
+ // 3 × 22 € = 66 €, но игра не может стоить меньше 88 €.
39
+ expect((0, get_players_price_1.getPlayersPrice)({
40
+ price: PRICE,
41
+ players: 3,
42
+ children: 0,
43
+ reducedPlayers: 3,
44
+ ...FIXED_22,
45
+ })).toBe(FLOOR);
46
+ });
47
+ it('детская цена тоже считается внутри пола: игра целиком не дешевле минимума', () => {
48
+ // Дешёвая комната для одного взрослого с ребёнком: 20 € + 15 € = 35 €.
49
+ expect((0, get_players_price_1.getPlayersPrice)({
50
+ price: { 1: 2000, child: 1500 },
51
+ players: 1,
52
+ children: 1,
53
+ minGameAmount: 5000,
54
+ })).toBe(5000);
55
+ });
56
+ it('недоступный состав остаётся нулём, а не превращается в пол выручки', () => {
57
+ // Иначе отключённое число игроков внезапно начало бы «стоить» минимум и
58
+ // пролезало бы в бронь в обход пикера.
59
+ expect((0, get_players_price_1.getPlayersPrice)({ price: { 2: 8800, 3: 0 }, players: 3, children: 0, ...FIXED_22 })).toBe(0);
60
+ expect((0, get_players_price_1.getPlayersPrice)({ price: PRICE, players: 9, children: 0, ...FIXED_22 })).toBe(0);
61
+ });
62
+ it('бюджет скидок — это ровно расстояние от цены до пола', () => {
63
+ expect((0, get_players_price_1.getMaxGameDiscount)({ price: PRICE, players: 3, children: 0, ...FIXED_22 })).toBe(2000);
64
+ expect((0, get_players_price_1.getMaxGameDiscount)({ price: PRICE, players: 2, children: 0, ...FIXED_22 })).toBe(0);
65
+ // Без минимума бюджет = вся цена игры, то есть потолка фактически нет.
66
+ expect((0, get_players_price_1.getMaxGameDiscount)({ price: PRICE, players: 2, children: 0 })).toBe(8800);
67
+ });
68
+ });
69
+ describe('скидка слота', () => {
70
+ it('20% с трёх режется до бюджета: 108 € − 88 €', () => {
71
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({
72
+ price: 10800,
73
+ discount: 2000,
74
+ maxAmount: (0, get_players_price_1.getMaxGameDiscount)({
75
+ price: PRICE,
76
+ players: 3,
77
+ children: 0,
78
+ ...FIXED_22,
79
+ }),
80
+ })).toBe(2000);
81
+ });
82
+ it('на минимальном составе скидки не остаётся вовсе', () => {
83
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 8800, discount: 2000, maxAmount: 0 })).toBe(0);
84
+ });
85
+ it('без потолка процент считается как раньше', () => {
86
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 10800, discount: 2000 })).toBe(2160);
87
+ });
88
+ });
89
+ describe('промокод', () => {
90
+ const promocode = (type, discount, floor = FLOOR) => (0, get_promocode_discount_1.getPromocodeDiscount)({
91
+ promocodeType: type,
92
+ promocodeDiscount: discount,
93
+ slotId: 1,
94
+ players: 3,
95
+ children: 0,
96
+ price: PRICE,
97
+ total: 10800,
98
+ slotDiscount: 0,
99
+ slotDiscountAmount: 0,
100
+ minGameAmount: floor,
101
+ });
102
+ it('процент от базы режется до пола', () => {
103
+ // 50% от 108 € = 54 €, но опустить игру можно только на 20 €.
104
+ expect(promocode(promocode_type_enum_1.PromocodeTypeEnum.PERCENT_BASE_SUM, 5000)).toBe(2000);
105
+ });
106
+ it('фиксированный промокод тоже упирается в пол', () => {
107
+ expect(promocode(promocode_type_enum_1.PromocodeTypeEnum.FIXED, 5000)).toBe(2000);
108
+ });
109
+ it('флеш-цена не может увести игру ниже минимума', () => {
110
+ // Флеш-цена 60 € при поле 88 € — скидка урезается до 20 €.
111
+ expect(promocode(promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE, 6000)).toBe(2000);
112
+ // Флеш-цена выше пола отрабатывает полностью: 108 € → 95 €.
113
+ expect(promocode(promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE, 9500)).toBe(1300);
114
+ });
115
+ it('без минимума ничего не режется — прежнее поведение', () => {
116
+ expect(promocode(promocode_type_enum_1.PromocodeTypeEnum.PERCENT_BASE_SUM, 5000, 0)).toBe(5400);
117
+ expect(promocode(promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE, 6000, 0)).toBe(4800);
118
+ });
119
+ it('скидка слота съедает бюджет первой', () => {
120
+ // Слот уже забрал 15 € из 20 € — промокоду остаётся 5 €.
121
+ expect((0, get_promocode_discount_1.getPromocodeDiscount)({
122
+ promocodeType: promocode_type_enum_1.PromocodeTypeEnum.PERCENT_BASE_SUM,
123
+ promocodeDiscount: 5000,
124
+ slotId: 1,
125
+ players: 3,
126
+ children: 0,
127
+ price: PRICE,
128
+ total: 10800,
129
+ slotDiscount: 1500,
130
+ slotDiscountAmount: 1500,
131
+ minGameAmount: FLOOR,
132
+ })).toBe(500);
133
+ });
134
+ });
135
+ describe('«цена от» в расписании', () => {
136
+ it('не обещает цену, по которой бронь не оформится', () => {
137
+ expect((0, slot_representative_price_1.getSlotRepresentativePrices)({
138
+ tariff: { price: PRICE, minGameAmount: FLOOR },
139
+ discount: 2000,
140
+ })).toEqual({ base: 8800, discounted: 8800 });
141
+ });
142
+ it('без минимума зачёркнутая цена считается как раньше', () => {
143
+ expect((0, slot_representative_price_1.getSlotRepresentativePrices)({ tariff: { price: PRICE }, discount: 2000 })).toEqual({
144
+ base: 8800,
145
+ discounted: 7040,
146
+ });
147
+ });
148
+ it('минимум ниже базовой цены оставляет часть скидки', () => {
149
+ expect((0, slot_representative_price_1.getSlotRepresentativePrices)({
150
+ tariff: { price: PRICE },
151
+ minGameAmount: 8000,
152
+ discount: 2000,
153
+ })).toEqual({ base: 8800, discounted: 8000 });
154
+ });
155
+ });
@@ -1,5 +1,20 @@
1
- export declare const getPlayersPrice: (order: {
1
+ import { ReducedPricingConfig } from './reduced-price';
2
+ /**
3
+ * Источник цены игры. Кроме прайс-карты и состава принимает снимок льготной
4
+ * ставки и минимальной стоимости — у `OrderEntity` эти поля есть, поэтому
5
+ * заказы получают правильную цену без правок на каждом вызове.
6
+ */
7
+ export type GamePriceSource = ReducedPricingConfig & {
2
8
  price: Record<string, number>;
3
9
  players: number;
4
10
  children: number;
5
- }) => number;
11
+ /** Сколько мест в составе идёт по льготной ставке (школьники и т.п.). */
12
+ reducedPlayers?: number | null;
13
+ };
14
+ export declare const getPlayersPrice: (order: GamePriceSource) => number;
15
+ /**
16
+ * Сколько всего можно скинуть с игры, не пробив минимальную стоимость.
17
+ * Ноль означает «скидок на этом составе не остаётся»: так минимум защищает
18
+ * выручку от процентов, а не только от льготного состава.
19
+ */
20
+ export declare const getMaxGameDiscount: (order: GamePriceSource) => number;
@@ -1,13 +1,31 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getPlayersPrice = void 0;
3
+ exports.getMaxGameDiscount = exports.getPlayersPrice = void 0;
4
+ const reduced_price_1 = require("./reduced-price");
4
5
  const getPlayersPrice = (order) => {
5
6
  if (!order?.price)
6
7
  return 0;
7
- let baseAmount = order.price[order.players] || 0;
8
- if (!Number.isNaN(+order.price.child) && order.children) {
9
- baseAmount += order.price.child * order.children;
10
- }
11
- return baseAmount;
8
+ const groupAmount = Number(order.price[order.players]) || 0;
9
+ const childAmount = !Number.isNaN(+order.price.child) && order.children
10
+ ? order.price.child * order.children
11
+ : 0;
12
+ // Цены на такой состав нет (или она 0 — «состав недоступен»). Заказ по
13
+ // нему не считается, и минимальная стоимость игры к нему не применяется:
14
+ // иначе отключённое число игроков внезапно начало бы стоить пол выручки.
15
+ if (groupAmount <= 0)
16
+ return childAmount;
17
+ // Льготные места вычитаются из групповой цены, а не считаются отдельной
18
+ // суммой по головам: групповой итог остаётся ровно из прайса, а неровное
19
+ // деление (108 / 3) не размазывается копейками.
20
+ const seats = Math.min(Math.max(0, Math.floor(Number(order.reducedPlayers) || 0)), order.players);
21
+ const reducedDiscount = Math.min(seats * (0, reduced_price_1.getReducedSeatDiscount)(order), groupAmount);
22
+ return (0, reduced_price_1.applyMinGameAmount)(groupAmount - reducedDiscount + childAmount, order);
12
23
  };
13
24
  exports.getPlayersPrice = getPlayersPrice;
25
+ /**
26
+ * Сколько всего можно скинуть с игры, не пробив минимальную стоимость.
27
+ * Ноль означает «скидок на этом составе не остаётся»: так минимум защищает
28
+ * выручку от процентов, а не только от льготного состава.
29
+ */
30
+ const getMaxGameDiscount = (order) => Math.max(0, (0, exports.getPlayersPrice)(order) - (0, reduced_price_1.getMinGameAmount)(order));
31
+ exports.getMaxGameDiscount = getMaxGameDiscount;
@@ -1,9 +1,12 @@
1
1
  import { PromocodeTypeEnum } from '@escapenavigator/types/dist/promocode/emun/promocode-type.enum';
2
- export declare const getPromocodeDiscount: ({ slotId, slotDiscount, slotDiscountAmount, total, promocodeType, promocodeDiscount, price, players, children, }: {
2
+ import { ReducedPricingConfig } from './reduced-price';
3
+ export declare const getPromocodeDiscount: ({ slotId, slotDiscount, slotDiscountAmount, total, promocodeType, promocodeDiscount, price, players, children, reducedPlayers, reducedRateMode, reducedRate, minGameAmount, }: ReducedPricingConfig & {
3
4
  slotId: number;
4
5
  children?: number;
5
6
  total: number;
6
7
  players: number;
8
+ /** Сколько мест идёт по льготной ставке (снимок с заказа). */
9
+ reducedPlayers?: number | null;
7
10
  /**
8
11
  * Легаси-параметр: часть вызовов передаёт сумму, часть — ставку в bps.
9
12
  * Используется только процентными типами (вычитание из total).
@@ -3,15 +3,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getPromocodeDiscount = void 0;
4
4
  const promocode_type_enum_1 = require("@escapenavigator/types/dist/promocode/emun/promocode-type.enum");
5
5
  const get_players_price_1 = require("./get-players-price");
6
- const getPromocodeDiscount = ({ slotId, slotDiscount, slotDiscountAmount = 0, total, promocodeType, promocodeDiscount, price, players, children = 0, }) => {
6
+ const reduced_price_1 = require("./reduced-price");
7
+ const getPromocodeDiscount = ({ slotId, slotDiscount, slotDiscountAmount = 0, total, promocodeType, promocodeDiscount, price, players, children = 0, reducedPlayers = 0, reducedRateMode = null, reducedRate = 0, minGameAmount = 0, }) => {
7
8
  const orderTotal = slotId ? total - slotDiscount : total;
9
+ const game = {
10
+ price,
11
+ players,
12
+ children,
13
+ reducedPlayers,
14
+ reducedRateMode,
15
+ reducedRate,
16
+ minGameAmount,
17
+ };
8
18
  let amount = 0;
9
19
  if (promocodeType === promocode_type_enum_1.PromocodeTypeEnum.PROMOTIONAL)
10
20
  return 0;
11
21
  if (promocodeType === promocode_type_enum_1.PromocodeTypeEnum.FIXED)
12
22
  amount = promocodeDiscount;
13
23
  if (promocodeType === promocode_type_enum_1.PromocodeTypeEnum.PERCENT_BASE_SUM)
14
- amount = Math.floor(((0, get_players_price_1.getPlayersPrice)({ price, children, players }) / 10000) * promocodeDiscount);
24
+ amount = Math.floor(((0, get_players_price_1.getPlayersPrice)(game) / 10000) * promocodeDiscount);
15
25
  if (promocodeType === promocode_type_enum_1.PromocodeTypeEnum.PERCENT_ALL_SUM)
16
26
  amount = Math.floor((orderTotal / 10000) * promocodeDiscount);
17
27
  if (promocodeType === promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE) {
@@ -19,10 +29,17 @@ const getPromocodeDiscount = ({ slotId, slotDiscount, slotDiscountAmount = 0, to
19
29
  // Скидка = базовая цена за игроков (за вычетом слот-скидки) − флеш-цена.
20
30
  // Если базовая цена уже ниже флеш-цены — скидки нет (не удорожаем).
21
31
  // Доп. услуги и наценка на оплату в формуле не участвуют — идут сверху.
22
- const playersPrice = (0, get_players_price_1.getPlayersPrice)({ price, children, players });
32
+ const playersPrice = (0, get_players_price_1.getPlayersPrice)(game);
23
33
  const playersPriceAfterSlot = playersPrice - (slotId ? slotDiscountAmount : 0);
24
34
  amount = Math.max(0, playersPriceAfterSlot - promocodeDiscount);
25
35
  }
36
+ // Минимальная стоимость игры: промокод (включая флеш-цену) не опускает
37
+ // игру ниже пола выручки. Пока минимум не задан — расчёт как раньше,
38
+ // байт в байт.
39
+ if ((0, reduced_price_1.getMinGameAmount)(game)) {
40
+ const budget = (0, get_players_price_1.getMaxGameDiscount)(game) - (slotId ? slotDiscountAmount : 0);
41
+ return Math.max(0, Math.min(amount, budget));
42
+ }
26
43
  return amount;
27
44
  };
28
45
  exports.getPromocodeDiscount = getPromocodeDiscount;
@@ -12,11 +12,17 @@ type Params = {
12
12
  * сохраняет это различие, чтобы правки не сдвигали цены на проде.
13
13
  */
14
14
  rounding?: 'round' | 'floor';
15
+ /**
16
+ * Потолок скидки в минорных единицах. Нужен минимальной стоимости игры:
17
+ * скидка слота не должна опускать игру ниже пола выручки. Без параметра
18
+ * поведение прежнее.
19
+ */
20
+ maxAmount?: number;
15
21
  };
16
22
  export declare const isFixedSlotDiscount: (discountType?: SlotDiscountType) => boolean;
17
23
  /**
18
24
  * Сумма скидки слота в минорных единицах. Никогда не превышает базу, поэтому
19
25
  * итог не уходит в минус даже если фиксированная скидка больше цены игры.
20
26
  */
21
- export declare const getSlotDiscountAmount: ({ price, discount, discountType, rounding, }: Params) => number;
27
+ export declare const getSlotDiscountAmount: ({ price, discount, discountType, rounding, maxAmount, }: Params) => number;
22
28
  export {};
@@ -8,14 +8,15 @@ exports.isFixedSlotDiscount = isFixedSlotDiscount;
8
8
  * Сумма скидки слота в минорных единицах. Никогда не превышает базу, поэтому
9
9
  * итог не уходит в минус даже если фиксированная скидка больше цены игры.
10
10
  */
11
- const getSlotDiscountAmount = ({ price, discount, discountType, rounding = 'round', }) => {
11
+ const getSlotDiscountAmount = ({ price, discount, discountType, rounding = 'round', maxAmount, }) => {
12
12
  const value = discount || 0;
13
13
  const base = price > 0 ? price : 0;
14
14
  if (value <= 0 || !base)
15
15
  return 0;
16
+ const cap = typeof maxAmount === 'number' ? Math.min(base, Math.max(0, maxAmount)) : base;
16
17
  if ((0, exports.isFixedSlotDiscount)(discountType))
17
- return Math.min(value, base);
18
+ return Math.min(value, cap);
18
19
  const raw = (base * value) / 10000;
19
- return Math.min(base, rounding === 'floor' ? Math.floor(raw) : Math.round(raw));
20
+ return Math.min(cap, rounding === 'floor' ? Math.floor(raw) : Math.round(raw));
20
21
  };
21
22
  exports.getSlotDiscountAmount = getSlotDiscountAmount;
@@ -0,0 +1,68 @@
1
+ import { TariffReducedRateModeEnum } from '@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum';
2
+ /**
3
+ * Льготные места внутри одной брони (школьники, студенты) и пол выручки.
4
+ *
5
+ * Ключевая идея: сколько льготных мест влезает — не отдельная настройка, а
6
+ * следствие минимальной стоимости игры. Венью задаёт «ниже 98 € комната не
7
+ * продаётся», и правила вида «на двоих льготных мест нет, на троих — одно»
8
+ * получаются сами. Поэтому здесь нет ни лимитов по составам, ни таблицы
9
+ * льготных цен: только ставка и минимум.
10
+ */
11
+ export type ReducedPricingConfig = {
12
+ /** `null` — тариф без льготных мест. */
13
+ reducedRateMode?: TariffReducedRateModeEnum | null;
14
+ /**
15
+ * При `DELTA` — насколько льготный гость дешевле обычного, при `FIXED` —
16
+ * цена льготного гостя. Минорные единицы.
17
+ */
18
+ reducedRate?: number | null;
19
+ /** Минимальная стоимость игры. 0 / null — минимума нет. */
20
+ minGameAmount?: number | null;
21
+ };
22
+ /** Снимок льготной ставки и минимума, который уезжает в заказ рядом с `price`. */
23
+ export type GameSnapshot = Required<ReducedPricingConfig> & {
24
+ reducedTitle: string | null;
25
+ };
26
+ /**
27
+ * Достаёт из тарифа поля, влияющие на цену игры. Хранить их на заказе нужно по
28
+ * той же причине, что и прайс-карту: повышение цен не должно переоценивать уже
29
+ * оформленные брони.
30
+ */
31
+ export declare const tariffGameSnapshot: (tariff?: Partial<GameSnapshot> | null) => GameSnapshot;
32
+ type PriceLevel = {
33
+ price?: Record<string, number> | null;
34
+ players: number;
35
+ };
36
+ type ReducedArgs = PriceLevel & ReducedPricingConfig;
37
+ /** Нормализованный пол выручки: отрицательный и мусорный трактуем как «нет». */
38
+ export declare const getMinGameAmount: ({ minGameAmount }: ReducedPricingConfig) => number;
39
+ /** Поднимает сумму до минимальной стоимости игры. */
40
+ export declare const applyMinGameAmount: (amount: number, config: ReducedPricingConfig) => number;
41
+ /**
42
+ * Цена уровня в расчёте на человека. Не округляем: дробный остаток уходит в
43
+ * скидку места, а групповой итог остаётся ровно из прайса.
44
+ */
45
+ export declare const getPerPersonAmount: (level: PriceLevel) => number;
46
+ /**
47
+ * Скидка одного льготного места.
48
+ *
49
+ * Считаем скидку, а не цену льготного гостя: тогда неровное деление (108 / 3)
50
+ * не размазывается по головам копеечными расхождениями, а групповой итог
51
+ * остаётся тем же, что стоит в прайсе.
52
+ */
53
+ export declare const getReducedSeatDiscount: (args: ReducedArgs) => number;
54
+ /**
55
+ * Сколько мест можно отдать по льготной ставке, не пробив минимум. Ноль
56
+ * значит «при этом составе льготных мест нет» — так получается прочерк на
57
+ * минимальной группе.
58
+ */
59
+ export declare const getMaxReducedPlayers: (args: ReducedArgs) => number;
60
+ /**
61
+ * Групповая цена с учётом льготных мест и минимума. Детская надбавка сюда не
62
+ * входит: льготные места вычитаются из `players`, а не добавляют головы, —
63
+ * поэтому `price[players]` ищется как обычно.
64
+ */
65
+ export declare const getGroupPriceWithReduced: (args: ReducedArgs & {
66
+ reducedPlayers?: number | null;
67
+ }) => number;
68
+ export {};
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getGroupPriceWithReduced = exports.getMaxReducedPlayers = exports.getReducedSeatDiscount = exports.getPerPersonAmount = exports.applyMinGameAmount = exports.getMinGameAmount = exports.tariffGameSnapshot = void 0;
4
+ const tariff_reduced_rate_mode_enum_1 = require("@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum");
5
+ /**
6
+ * Достаёт из тарифа поля, влияющие на цену игры. Хранить их на заказе нужно по
7
+ * той же причине, что и прайс-карту: повышение цен не должно переоценивать уже
8
+ * оформленные брони.
9
+ */
10
+ const tariffGameSnapshot = (tariff) => ({
11
+ minGameAmount: Math.max(0, Math.round(Number(tariff?.minGameAmount) || 0)),
12
+ reducedRateMode: tariff?.reducedRateMode || null,
13
+ reducedRate: Math.max(0, Math.round(Number(tariff?.reducedRate) || 0)),
14
+ reducedTitle: tariff?.reducedTitle || null,
15
+ });
16
+ exports.tariffGameSnapshot = tariffGameSnapshot;
17
+ /** Групповая цена уровня, 0 если состав недоступен. */
18
+ const groupAmount = ({ price, players }) => {
19
+ const amount = Number(price?.[players]);
20
+ return Number.isFinite(amount) && amount > 0 ? amount : 0;
21
+ };
22
+ /** Нормализованный пол выручки: отрицательный и мусорный трактуем как «нет». */
23
+ const getMinGameAmount = ({ minGameAmount }) => {
24
+ const amount = Math.round(Number(minGameAmount) || 0);
25
+ return amount > 0 ? amount : 0;
26
+ };
27
+ exports.getMinGameAmount = getMinGameAmount;
28
+ /** Поднимает сумму до минимальной стоимости игры. */
29
+ const applyMinGameAmount = (amount, config) => Math.max(amount, (0, exports.getMinGameAmount)(config));
30
+ exports.applyMinGameAmount = applyMinGameAmount;
31
+ /**
32
+ * Цена уровня в расчёте на человека. Не округляем: дробный остаток уходит в
33
+ * скидку места, а групповой итог остаётся ровно из прайса.
34
+ */
35
+ const getPerPersonAmount = (level) => {
36
+ const amount = groupAmount(level);
37
+ return amount && level.players > 0 ? amount / level.players : 0;
38
+ };
39
+ exports.getPerPersonAmount = getPerPersonAmount;
40
+ /**
41
+ * Скидка одного льготного места.
42
+ *
43
+ * Считаем скидку, а не цену льготного гостя: тогда неровное деление (108 / 3)
44
+ * не размазывается по головам копеечными расхождениями, а групповой итог
45
+ * остаётся тем же, что стоит в прайсе.
46
+ */
47
+ const getReducedSeatDiscount = (args) => {
48
+ if (!args.reducedRateMode)
49
+ return 0;
50
+ const rate = Math.round(Number(args.reducedRate) || 0);
51
+ if (rate <= 0)
52
+ return 0;
53
+ if (args.reducedRateMode === tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.DELTA)
54
+ return rate;
55
+ // FIXED: ставка задана абсолютом. Если она не ниже цены уровня (на больших
56
+ // группах обычная цена сама опускается до льготной) — скидки нет.
57
+ return Math.max(0, Math.round((0, exports.getPerPersonAmount)(args) - rate));
58
+ };
59
+ exports.getReducedSeatDiscount = getReducedSeatDiscount;
60
+ /**
61
+ * Сколько мест можно отдать по льготной ставке, не пробив минимум. Ноль
62
+ * значит «при этом составе льготных мест нет» — так получается прочерк на
63
+ * минимальной группе.
64
+ */
65
+ const getMaxReducedPlayers = (args) => {
66
+ const seatDiscount = (0, exports.getReducedSeatDiscount)(args);
67
+ if (!seatDiscount)
68
+ return 0;
69
+ const amount = groupAmount(args);
70
+ if (!amount)
71
+ return 0;
72
+ const room = amount - (0, exports.getMinGameAmount)(args);
73
+ if (room <= 0)
74
+ return 0;
75
+ return Math.min(args.players, Math.floor(room / seatDiscount));
76
+ };
77
+ exports.getMaxReducedPlayers = getMaxReducedPlayers;
78
+ /**
79
+ * Групповая цена с учётом льготных мест и минимума. Детская надбавка сюда не
80
+ * входит: льготные места вычитаются из `players`, а не добавляют головы, —
81
+ * поэтому `price[players]` ищется как обычно.
82
+ */
83
+ const getGroupPriceWithReduced = (args) => {
84
+ const amount = groupAmount(args);
85
+ if (!amount)
86
+ return 0;
87
+ const seats = Math.min(Math.max(0, Math.floor(Number(args.reducedPlayers) || 0)), args.players);
88
+ return (0, exports.applyMinGameAmount)(amount - seats * (0, exports.getReducedSeatDiscount)(args), args);
89
+ };
90
+ exports.getGroupPriceWithReduced = getGroupPriceWithReduced;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tariff_reduced_rate_mode_enum_1 = require("@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum");
4
+ const reduced_price_1 = require("./reduced-price");
5
+ /**
6
+ * Прайс реальной комнаты (Paris / Strange): базовая цена за двоих 98 €, дальше
7
+ * цена за человека падает. Минорные единицы.
8
+ */
9
+ const PARIS = {
10
+ 2: 9800,
11
+ 3: 12000,
12
+ 4: 14000,
13
+ 5: 16000,
14
+ 6: 18000,
15
+ 7: 18900,
16
+ 8: 20000,
17
+ 9: 20700,
18
+ 10: 22000,
19
+ 11: 24200,
20
+ 12: 26400,
21
+ };
22
+ const PARIS_REDUCED = {
23
+ reducedRateMode: tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.DELTA,
24
+ reducedRate: 500,
25
+ minGameAmount: 9800,
26
+ };
27
+ describe('льготная шкала «дешевле на 5 € с человека»', () => {
28
+ /**
29
+ * Вся льготная колонка венью — это одна дельта плюс минимум. Здесь
30
+ * проверяем, что формула воспроизводит её целиком: ожидаемые суммы взяты
31
+ * из их прайс-листа, а не посчитаны этим же кодом.
32
+ */
33
+ it.each([
34
+ [3, 10500],
35
+ [4, 12000],
36
+ [5, 13500],
37
+ [6, 15000],
38
+ [7, 15400],
39
+ [8, 16000],
40
+ [9, 16200],
41
+ [10, 17000],
42
+ [11, 18700],
43
+ [12, 20400],
44
+ ])('на %i участниках вся группа по льготной ставке стоит %i', (players, expected) => {
45
+ expect((0, reduced_price_1.getGroupPriceWithReduced)({
46
+ price: PARIS,
47
+ players,
48
+ reducedPlayers: players,
49
+ ...PARIS_REDUCED,
50
+ })).toBe(expected);
51
+ });
52
+ it('на минимальной группе льготных мест нет: 88 € пробили бы базу 98 €', () => {
53
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price: PARIS, players: 2, ...PARIS_REDUCED })).toBe(0);
54
+ expect((0, reduced_price_1.getGroupPriceWithReduced)({
55
+ price: PARIS,
56
+ players: 2,
57
+ reducedPlayers: 2,
58
+ ...PARIS_REDUCED,
59
+ })).toBe(9800);
60
+ });
61
+ it('со следующего состава льготной может быть вся группа', () => {
62
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price: PARIS, players: 3, ...PARIS_REDUCED })).toBe(3);
63
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price: PARIS, players: 12, ...PARIS_REDUCED })).toBe(12);
64
+ });
65
+ it('у комнаты с базой 140 € льготных мест на четверых уже не остаётся', () => {
66
+ // Hexer стартует с 4 человек и стоит 140 € — пол упирается в саму цену
67
+ // уровня, поэтому льготное место туда не влезает.
68
+ expect((0, reduced_price_1.getMaxReducedPlayers)({
69
+ price: PARIS,
70
+ players: 4,
71
+ ...PARIS_REDUCED,
72
+ minGameAmount: 14000,
73
+ })).toBe(0);
74
+ });
75
+ });
76
+ describe('фиксированная ставка 22 € за льготного гостя', () => {
77
+ // Прайс из письма: 88 € за двоих (он же минимум), 108 € за трёх, 120 € за
78
+ // четырёх. Ожидания — ровно те три случая, которые описал венью.
79
+ const price = { 2: 8800, 3: 10800, 4: 12000 };
80
+ const config = {
81
+ reducedRateMode: tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.FIXED,
82
+ reducedRate: 2200,
83
+ minGameAmount: 8800,
84
+ };
85
+ it('на двоих льготных мест нет', () => {
86
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price, players: 2, ...config })).toBe(0);
87
+ });
88
+ it('на троих влезает одно льготное место, игра стоит 94 €', () => {
89
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price, players: 3, ...config })).toBe(1);
90
+ expect((0, reduced_price_1.getGroupPriceWithReduced)({ price, players: 3, reducedPlayers: 1, ...config })).toBe(9400);
91
+ });
92
+ it('на четверых льготными могут быть все, игра стоит 88 €', () => {
93
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price, players: 4, ...config })).toBe(4);
94
+ expect((0, reduced_price_1.getGroupPriceWithReduced)({ price, players: 4, reducedPlayers: 4, ...config })).toBe(8800);
95
+ });
96
+ it('смешанный состав считается по головам', () => {
97
+ // 2 обычных по 30 € + 2 льготных по 22 € = 104 €.
98
+ expect((0, reduced_price_1.getGroupPriceWithReduced)({ price, players: 4, reducedPlayers: 2, ...config })).toBe(10400);
99
+ });
100
+ it('не даёт скидки там, где обычная цена уже опустилась до льготной', () => {
101
+ // На 10+ человек цена за человека сама равна 22 € — льготное место
102
+ // ничего не меняет, и предлагать его незачем.
103
+ expect((0, reduced_price_1.getReducedSeatDiscount)({ price: PARIS, players: 10, ...config, minGameAmount: 9800 })).toBe(0);
104
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price: PARIS, players: 10, ...config, minGameAmount: 9800 })).toBe(0);
105
+ });
106
+ });
107
+ describe('края', () => {
108
+ it('без льготной ставки мест нет, но минимум всё равно держит цену', () => {
109
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price: PARIS, players: 4, minGameAmount: 9800 })).toBe(0);
110
+ expect((0, reduced_price_1.getGroupPriceWithReduced)({
111
+ price: { 2: 4400 },
112
+ players: 2,
113
+ reducedPlayers: 0,
114
+ minGameAmount: 9800,
115
+ })).toBe(9800);
116
+ });
117
+ it('без минимума льготными может стать почти вся группа — это и есть дырка', () => {
118
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price: PARIS, players: 4, ...PARIS_REDUCED, minGameAmount: 0 })).toBe(4);
119
+ });
120
+ it('недоступный состав не считается', () => {
121
+ expect((0, reduced_price_1.getMaxReducedPlayers)({ price: { 2: 9800, 3: 0 }, players: 3, ...PARIS_REDUCED })).toBe(0);
122
+ expect((0, reduced_price_1.getGroupPriceWithReduced)({
123
+ price: PARIS,
124
+ players: 20,
125
+ reducedPlayers: 1,
126
+ ...PARIS_REDUCED,
127
+ })).toBe(0);
128
+ });
129
+ it('льготных мест не бывает больше, чем участников', () => {
130
+ expect((0, reduced_price_1.getGroupPriceWithReduced)({
131
+ price: PARIS,
132
+ players: 3,
133
+ reducedPlayers: 99,
134
+ ...PARIS_REDUCED,
135
+ })).toBe(10500);
136
+ });
137
+ it('мусор в минимуме игнорируется', () => {
138
+ expect((0, reduced_price_1.applyMinGameAmount)(5000, { minGameAmount: null })).toBe(5000);
139
+ expect((0, reduced_price_1.applyMinGameAmount)(5000, { minGameAmount: -100 })).toBe(5000);
140
+ expect((0, reduced_price_1.applyMinGameAmount)(5000, { minGameAmount: 9800 })).toBe(9800);
141
+ });
142
+ });
@@ -4,6 +4,11 @@ type SlotPriceSource = {
4
4
  basePrice?: number;
5
5
  discount?: number;
6
6
  discountType?: SlotDiscountTypeEnum | string | null;
7
+ /**
8
+ * Минимальная стоимость игры. Передаётся отдельно там, где тариф приходит
9
+ * плоской картой цен (`/slots/details`) и конфига в нём нет.
10
+ */
11
+ minGameAmount?: number | null;
7
12
  };
8
13
  /**
9
14
  * Плоская карта цен из тарифа слота.
@@ -31,6 +31,17 @@ const getTariffBasePrice = (tariff) => {
31
31
  return null;
32
32
  };
33
33
  exports.getTariffBasePrice = getTariffBasePrice;
34
+ /**
35
+ * Минимальная стоимость игры из тарифа. У плоской карты цен (`/slots/details`)
36
+ * конфига нет — тогда его передают отдельным полем слота.
37
+ */
38
+ const getMinGameAmount = (slot) => {
39
+ const fromTariff = slot.tariff && typeof slot.tariff === 'object'
40
+ ? Number(slot.tariff.minGameAmount)
41
+ : NaN;
42
+ const amount = Math.round(Number(slot.minGameAmount) || fromTariff || 0);
43
+ return Number.isFinite(amount) && amount > 0 ? amount : 0;
44
+ };
34
45
  /**
35
46
  * Цена «от» до и после скидки слота. Обе величины нужны расписанию: одну
36
47
  * показываем, вторую зачёркиваем.
@@ -41,11 +52,15 @@ const getSlotRepresentativePrices = (slot) => {
41
52
  : (0, exports.getTariffBasePrice)(slot.tariff);
42
53
  if (base == null)
43
54
  return null;
55
+ const floor = getMinGameAmount(slot);
44
56
  const discountAmount = (0, get_slot_discount_amount_1.getSlotDiscountAmount)({
45
57
  price: base,
46
58
  discount: slot.discount,
47
59
  discountType: slot.discountType,
48
60
  rounding: 'floor',
61
+ // «Цена от» в расписании — обещание гостю. Показать её ниже минимальной
62
+ // стоимости игры значит пообещать цену, по которой бронь не оформится.
63
+ maxAmount: floor ? Math.max(0, base - floor) : undefined,
49
64
  });
50
65
  return { base, discounted: base - discountAmount };
51
66
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/utils",
3
- "version": "2.0.46",
3
+ "version": "2.0.48",
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.44",
17
+ "@escapenavigator/types": "^2.0.46",
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": "1a84aa81dd428759ad51821e8095fd0b80b7cc62"
32
+ "gitHead": "af1ce55d865cc7be91336093f79c50ab2ec88f34"
33
33
  }