@escapenavigator/utils 2.0.48 → 2.0.50
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.
- package/dist/composition-limits.d.ts +63 -0
- package/dist/composition-limits.js +84 -0
- package/dist/composition-limits.spec.d.ts +1 -0
- package/dist/composition-limits.spec.js +157 -0
- package/dist/composition-price.d.ts +128 -0
- package/dist/composition-price.js +283 -0
- package/dist/composition-price.spec.d.ts +1 -0
- package/dist/composition-price.spec.js +366 -0
- package/dist/get-players-price.d.ts +15 -3
- package/dist/get-players-price.js +26 -15
- package/dist/get-promocode-discount.d.ts +6 -1
- package/dist/get-promocode-discount.js +3 -1
- package/dist/reduced-price.d.ts +13 -8
- package/dist/reduced-price.js +14 -4
- package/dist/reduced-price.spec.js +14 -0
- package/package.json +3 -3
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.legacyCountsFromComposition = exports.legacyFromCategories = exports.compositionFromLegacy = exports.getMaxDiscountedSeats = exports.getCompositionPrice = exports.getCategorySeatPrice = exports.getCategorySeatDiscount = exports.getPerPersonAmount = exports.getLevelAmount = exports.getPricingHeads = exports.getTotalHeads = exports.normalizeComposition = exports.resolveCategories = exports.categoriesFromLegacy = exports.baseCategory = void 0;
|
|
4
|
+
const tariff_category_mode_enum_1 = require("@escapenavigator/types/dist/tariff/enum/tariff-category-mode.enum");
|
|
5
|
+
const tariff_reduced_rate_mode_enum_1 = require("@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum");
|
|
6
|
+
const tariff_category_1 = require("@escapenavigator/types/dist/tariff/tariff-category");
|
|
7
|
+
const reduced_price_1 = require("./reduced-price");
|
|
8
|
+
const int = (value) => {
|
|
9
|
+
const amount = Math.round(Number(value));
|
|
10
|
+
return Number.isFinite(amount) ? amount : 0;
|
|
11
|
+
};
|
|
12
|
+
const positiveInt = (value) => Math.max(0, int(value));
|
|
13
|
+
/** Базисные пункты скидки: 1% = 100, потолок — 100%. */
|
|
14
|
+
const clampBps = (value) => Math.min(10000, positiveInt(value));
|
|
15
|
+
/**
|
|
16
|
+
* Базовая категория тарифа: полная доля групповой цены. Ровно одна на тариф —
|
|
17
|
+
* именно она задаёт «цену за человека», от которой считаются остальные.
|
|
18
|
+
*/
|
|
19
|
+
const baseCategory = (categories) => categories.find((category) => category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
|
|
20
|
+
exports.baseCategory = baseCategory;
|
|
21
|
+
/**
|
|
22
|
+
* Категории тарифа, собранные из легаси-полей. Нужны, пока не все тарифы
|
|
23
|
+
* прошли бэкфилл и пока старые клиенты присылают тариф без `categories`.
|
|
24
|
+
*
|
|
25
|
+
* Соответствие точное, поэтому цена не меняется: льготная ставка — это
|
|
26
|
+
* категория, занимающая место в составе, а детская — надбавка сверх группы.
|
|
27
|
+
*/
|
|
28
|
+
const categoriesFromLegacy = (config) => {
|
|
29
|
+
const legacy = config.legacy || {};
|
|
30
|
+
const categories = [
|
|
31
|
+
{
|
|
32
|
+
key: tariff_category_1.BASE_CATEGORY_KEY,
|
|
33
|
+
title: '',
|
|
34
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE,
|
|
35
|
+
value: 0,
|
|
36
|
+
countsInPrice: true,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
const reducedRate = positiveInt(legacy.reducedRate);
|
|
40
|
+
if (legacy.reducedRateMode && reducedRate > 0) {
|
|
41
|
+
categories.push({
|
|
42
|
+
key: tariff_category_1.REDUCED_CATEGORY_KEY,
|
|
43
|
+
title: legacy.reducedTitle || '',
|
|
44
|
+
description: legacy.reducedPriceDescription ?? null,
|
|
45
|
+
mode: legacy.reducedRateMode === tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.DELTA
|
|
46
|
+
? tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA
|
|
47
|
+
: tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED,
|
|
48
|
+
value: reducedRate,
|
|
49
|
+
countsInPrice: true,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const childPrice = config.price?.child;
|
|
53
|
+
if (childPrice !== undefined && childPrice !== null && !Number.isNaN(+childPrice)) {
|
|
54
|
+
categories.push({
|
|
55
|
+
key: tariff_category_1.CHILD_CATEGORY_KEY,
|
|
56
|
+
title: '',
|
|
57
|
+
description: legacy.childPriceDescription ?? null,
|
|
58
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED,
|
|
59
|
+
value: positiveInt(childPrice),
|
|
60
|
+
countsInPrice: false,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return categories;
|
|
64
|
+
};
|
|
65
|
+
exports.categoriesFromLegacy = categoriesFromLegacy;
|
|
66
|
+
/**
|
|
67
|
+
* Категории, по которым считать цену: собственные, если тариф уже переведён,
|
|
68
|
+
* иначе собранные из легаси-полей. Без базовой категории список бессмысленен
|
|
69
|
+
* (не от чего считать доли) — такой тоже уходит в легаси-ветку.
|
|
70
|
+
*/
|
|
71
|
+
const resolveCategories = (config) => {
|
|
72
|
+
const own = (config.categories || []).filter((category) => !!category?.key);
|
|
73
|
+
return own.length && (0, exports.baseCategory)(own) ? own : (0, exports.categoriesFromLegacy)(config);
|
|
74
|
+
};
|
|
75
|
+
exports.resolveCategories = resolveCategories;
|
|
76
|
+
/** Нормализованный состав: целые неотрицательные числа, лишние ключи выброшены. */
|
|
77
|
+
const normalizeComposition = (composition, categories) => {
|
|
78
|
+
const counts = {};
|
|
79
|
+
categories.forEach((category) => {
|
|
80
|
+
const count = positiveInt(composition?.[category.key]);
|
|
81
|
+
const max = category.max === null || category.max === undefined ? null : positiveInt(category.max);
|
|
82
|
+
counts[category.key] = max === null ? count : Math.min(count, max);
|
|
83
|
+
});
|
|
84
|
+
return counts;
|
|
85
|
+
};
|
|
86
|
+
exports.normalizeComposition = normalizeComposition;
|
|
87
|
+
/** Гостей всего: и те, что занимают место в сетке, и надбавочные. */
|
|
88
|
+
const getTotalHeads = (composition, categories) => categories.reduce((sum, category) => sum + positiveInt(composition?.[category.key]), 0);
|
|
89
|
+
exports.getTotalHeads = getTotalHeads;
|
|
90
|
+
/** Гостей, определяющих уровень сетки `price[N]`. */
|
|
91
|
+
const getPricingHeads = (composition, categories) => categories.reduce((sum, category) => category.countsInPrice ? sum + positiveInt(composition?.[category.key]) : sum, 0);
|
|
92
|
+
exports.getPricingHeads = getPricingHeads;
|
|
93
|
+
/** Групповая цена уровня. 0 — состав недоступен для брони. */
|
|
94
|
+
const getLevelAmount = (price, heads) => {
|
|
95
|
+
const amount = Number(price?.[heads]);
|
|
96
|
+
return Number.isFinite(amount) && amount > 0 ? amount : 0;
|
|
97
|
+
};
|
|
98
|
+
exports.getLevelAmount = getLevelAmount;
|
|
99
|
+
/**
|
|
100
|
+
* Доля одного гостя в групповой цене. Не округляем: остаток от неровного
|
|
101
|
+
* деления (108 / 3) уходит в скидку места, а групповой итог остаётся ровно
|
|
102
|
+
* тем, что стоит в прайсе.
|
|
103
|
+
*/
|
|
104
|
+
const getPerPersonAmount = (price, heads) => {
|
|
105
|
+
const amount = (0, exports.getLevelAmount)(price, heads);
|
|
106
|
+
return amount && heads > 0 ? amount / heads : 0;
|
|
107
|
+
};
|
|
108
|
+
exports.getPerPersonAmount = getPerPersonAmount;
|
|
109
|
+
/**
|
|
110
|
+
* Скидка одного гостя категории от базовой доли.
|
|
111
|
+
*
|
|
112
|
+
* Считаем именно скидку, а не цену гостя: групповой итог тогда остаётся
|
|
113
|
+
* суммой из прайса, а неровное деление не размазывается копейками по головам.
|
|
114
|
+
*/
|
|
115
|
+
const getCategorySeatDiscount = (category, perPerson) => {
|
|
116
|
+
switch (category.mode) {
|
|
117
|
+
case tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA:
|
|
118
|
+
return positiveInt(category.value);
|
|
119
|
+
case tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED:
|
|
120
|
+
// Ставка задана абсолютом: на больших группах обычная цена сама
|
|
121
|
+
// опускается до неё — тогда скидки нет.
|
|
122
|
+
return Math.max(0, Math.round(perPerson - positiveInt(category.value)));
|
|
123
|
+
case tariff_category_mode_enum_1.TariffCategoryModeEnum.PERCENT:
|
|
124
|
+
return Math.min(Math.round(perPerson), Math.round((perPerson * clampBps(category.value)) / 10000));
|
|
125
|
+
default:
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
exports.getCategorySeatDiscount = getCategorySeatDiscount;
|
|
130
|
+
/** Цена одного гостя категории при текущем составе. */
|
|
131
|
+
const getCategorySeatPrice = (category, perPerson) => {
|
|
132
|
+
if (category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED)
|
|
133
|
+
return positiveInt(category.value);
|
|
134
|
+
if (category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE)
|
|
135
|
+
return Math.round(perPerson);
|
|
136
|
+
return Math.max(0, Math.round(perPerson) - (0, exports.getCategorySeatDiscount)(category, perPerson));
|
|
137
|
+
};
|
|
138
|
+
exports.getCategorySeatPrice = getCategorySeatPrice;
|
|
139
|
+
/**
|
|
140
|
+
* Цена игры за состав.
|
|
141
|
+
*
|
|
142
|
+
* Сумма = цена уровня по головам, занимающим место, минус скидки этих голов,
|
|
143
|
+
* плюс надбавочные категории, и всё это не ниже минимальной стоимости игры.
|
|
144
|
+
*/
|
|
145
|
+
const getCompositionPrice = (source) => {
|
|
146
|
+
const categories = (0, exports.resolveCategories)(source);
|
|
147
|
+
const counts = (0, exports.normalizeComposition)(source.composition, categories);
|
|
148
|
+
const pricingHeads = (0, exports.getPricingHeads)(counts, categories);
|
|
149
|
+
const groupAmount = (0, exports.getLevelAmount)(source.price, pricingHeads);
|
|
150
|
+
const perPerson = pricingHeads > 0 ? groupAmount / pricingHeads : 0;
|
|
151
|
+
const addOn = categories.reduce((sum, category) => category.countsInPrice
|
|
152
|
+
? sum
|
|
153
|
+
: sum + counts[category.key] * (0, exports.getCategorySeatPrice)(category, perPerson), 0);
|
|
154
|
+
// Цены на такой состав нет (или она 0 — «состав недоступен»). Минимальная
|
|
155
|
+
// стоимость игры к нему не применяется: иначе отключённое число игроков
|
|
156
|
+
// внезапно начало бы стоить пол выручки.
|
|
157
|
+
if (groupAmount <= 0)
|
|
158
|
+
return addOn;
|
|
159
|
+
const discount = categories.reduce((sum, category) => category.countsInPrice
|
|
160
|
+
? sum + counts[category.key] * (0, exports.getCategorySeatDiscount)(category, perPerson)
|
|
161
|
+
: sum, 0);
|
|
162
|
+
return (0, reduced_price_1.applyMinGameAmount)(groupAmount - Math.min(discount, groupAmount) + addOn, source);
|
|
163
|
+
};
|
|
164
|
+
exports.getCompositionPrice = getCompositionPrice;
|
|
165
|
+
/**
|
|
166
|
+
* Сколько гостей категории ещё снижают цену, не упираясь в пол выручки.
|
|
167
|
+
* Это не лимит выбора — отметить можно больше, цена просто перестанет падать.
|
|
168
|
+
*/
|
|
169
|
+
const getMaxDiscountedSeats = (source, categoryKey) => {
|
|
170
|
+
const categories = (0, exports.resolveCategories)(source);
|
|
171
|
+
const category = categories.find((item) => item.key === categoryKey);
|
|
172
|
+
if (!category?.countsInPrice)
|
|
173
|
+
return 0;
|
|
174
|
+
const counts = (0, exports.normalizeComposition)(source.composition, categories);
|
|
175
|
+
const pricingHeads = (0, exports.getPricingHeads)(counts, categories);
|
|
176
|
+
const groupAmount = (0, exports.getLevelAmount)(source.price, pricingHeads);
|
|
177
|
+
if (!groupAmount)
|
|
178
|
+
return 0;
|
|
179
|
+
const seatDiscount = (0, exports.getCategorySeatDiscount)(category, groupAmount / pricingHeads);
|
|
180
|
+
if (!seatDiscount)
|
|
181
|
+
return 0;
|
|
182
|
+
const room = groupAmount - (0, reduced_price_1.getMinGameAmount)(source);
|
|
183
|
+
if (room <= 0)
|
|
184
|
+
return 0;
|
|
185
|
+
return Math.min(pricingHeads, Math.floor(room / seatDiscount));
|
|
186
|
+
};
|
|
187
|
+
exports.getMaxDiscountedSeats = getMaxDiscountedSeats;
|
|
188
|
+
/* ------------------------- Легаси-совместимость ------------------------- */
|
|
189
|
+
/**
|
|
190
|
+
* Состав по категориям из полей старого формата.
|
|
191
|
+
*
|
|
192
|
+
* `reducedPlayers` — это часть `players`, поэтому базовой категории достаётся
|
|
193
|
+
* остаток. Обратное преобразование — `legacyCountsFromComposition`.
|
|
194
|
+
*/
|
|
195
|
+
const compositionFromLegacy = (legacyCounts, categories) => {
|
|
196
|
+
const players = positiveInt(legacyCounts.players);
|
|
197
|
+
const reduced = Math.min(positiveInt(legacyCounts.reducedPlayers), players);
|
|
198
|
+
const base = (0, exports.baseCategory)(categories);
|
|
199
|
+
const composition = {};
|
|
200
|
+
categories.forEach((category) => {
|
|
201
|
+
composition[category.key] = 0;
|
|
202
|
+
});
|
|
203
|
+
if (base)
|
|
204
|
+
composition[base.key] = players - reduced;
|
|
205
|
+
const reducedCategory = categories.find((category) => category.countsInPrice && category.mode !== tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
|
|
206
|
+
if (reducedCategory)
|
|
207
|
+
composition[reducedCategory.key] = reduced;
|
|
208
|
+
const childCategory = categories.find((category) => !category.countsInPrice);
|
|
209
|
+
if (childCategory)
|
|
210
|
+
composition[childCategory.key] = positiveInt(legacyCounts.children);
|
|
211
|
+
return composition;
|
|
212
|
+
};
|
|
213
|
+
exports.compositionFromLegacy = compositionFromLegacy;
|
|
214
|
+
/**
|
|
215
|
+
* Легаси-поля тарифа, восстановленные из категорий. Обратная операция к
|
|
216
|
+
* `categoriesFromLegacy`: венью правит тариф уже категориями, а мобильное
|
|
217
|
+
* приложение и не обновлённые интеграции продолжают читать `price.child` и
|
|
218
|
+
* `reduced*` — без этой проекции они бы застыли на значениях до перехода.
|
|
219
|
+
*
|
|
220
|
+
* Схлопывание с потерями и это нормально: легаси-формат умеет ровно одну
|
|
221
|
+
* льготную категорию и ровно одну надбавочную. Берём первые — цена там, где
|
|
222
|
+
* есть `categories`, всё равно считается по ним.
|
|
223
|
+
*/
|
|
224
|
+
const legacyFromCategories = (categories, price) => {
|
|
225
|
+
const addOn = categories.find((category) => !category.countsInPrice);
|
|
226
|
+
const reduced = categories.find((category) => category.countsInPrice && category.mode !== tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
|
|
227
|
+
const legacy = {
|
|
228
|
+
childPrice: addOn ? positiveInt(addOn.value) : null,
|
|
229
|
+
childPriceDescription: addOn?.description ?? null,
|
|
230
|
+
reducedRateMode: null,
|
|
231
|
+
reducedRate: 0,
|
|
232
|
+
reducedTitle: reduced?.title || null,
|
|
233
|
+
reducedPriceDescription: reduced?.description ?? null,
|
|
234
|
+
};
|
|
235
|
+
if (!reduced)
|
|
236
|
+
return legacy;
|
|
237
|
+
if (reduced.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED) {
|
|
238
|
+
legacy.reducedRateMode = tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.FIXED;
|
|
239
|
+
legacy.reducedRate = positiveInt(reduced.value);
|
|
240
|
+
return legacy;
|
|
241
|
+
}
|
|
242
|
+
legacy.reducedRateMode = tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.DELTA;
|
|
243
|
+
if (reduced.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA) {
|
|
244
|
+
legacy.reducedRate = positiveInt(reduced.value);
|
|
245
|
+
return legacy;
|
|
246
|
+
}
|
|
247
|
+
// Процент в легаси-формате не выразить. Переводим в фиксированную скидку по
|
|
248
|
+
// самой большой группе: там доля гостя минимальна, значит и скидка выйдет
|
|
249
|
+
// наименьшей из возможных — старый клиент недосчитает скидку, но никогда не
|
|
250
|
+
// продаст игру дешевле, чем задумано.
|
|
251
|
+
const levels = Object.keys(price || {})
|
|
252
|
+
.map((key) => +key)
|
|
253
|
+
.filter((heads) => heads > 0 && (0, exports.getLevelAmount)(price, heads) > 0);
|
|
254
|
+
const largest = levels.length ? Math.max(...levels) : 0;
|
|
255
|
+
legacy.reducedRate = largest
|
|
256
|
+
? (0, exports.getCategorySeatDiscount)(reduced, (0, exports.getPerPersonAmount)(price, largest))
|
|
257
|
+
: 0;
|
|
258
|
+
return legacy;
|
|
259
|
+
};
|
|
260
|
+
exports.legacyFromCategories = legacyFromCategories;
|
|
261
|
+
/**
|
|
262
|
+
* Поля старого формата из состава по категориям. Нужны, пока их читают не
|
|
263
|
+
* обновлённые клиенты (мобильное приложение) и вся отчётность по броням.
|
|
264
|
+
*
|
|
265
|
+
* На тарифе с несколькими льготными категориями `reducedPlayers` схлопывает
|
|
266
|
+
* их в одно число: восстановить цену из легаси-полей в таком случае нельзя,
|
|
267
|
+
* источник истины — `composition` и снимок категорий на брони.
|
|
268
|
+
*/
|
|
269
|
+
const legacyCountsFromComposition = (composition, categories) => {
|
|
270
|
+
const counts = (0, exports.normalizeComposition)(composition, categories);
|
|
271
|
+
return categories.reduce((acc, category) => {
|
|
272
|
+
const count = counts[category.key];
|
|
273
|
+
if (!category.countsInPrice) {
|
|
274
|
+
acc.children += count;
|
|
275
|
+
return acc;
|
|
276
|
+
}
|
|
277
|
+
acc.players += count;
|
|
278
|
+
if (category.mode !== tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE)
|
|
279
|
+
acc.reducedPlayers += count;
|
|
280
|
+
return acc;
|
|
281
|
+
}, { players: 0, children: 0, reducedPlayers: 0 });
|
|
282
|
+
};
|
|
283
|
+
exports.legacyCountsFromComposition = legacyCountsFromComposition;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tariff_category_mode_enum_1 = require("@escapenavigator/types/dist/tariff/enum/tariff-category-mode.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 composition_price_1 = require("./composition-price");
|
|
7
|
+
/** Прайс реальной комнаты (Paris / Strange): база 98 € за двоих. */
|
|
8
|
+
const PARIS = {
|
|
9
|
+
2: 9800,
|
|
10
|
+
3: 12000,
|
|
11
|
+
4: 14000,
|
|
12
|
+
5: 16000,
|
|
13
|
+
6: 18000,
|
|
14
|
+
7: 18900,
|
|
15
|
+
8: 20000,
|
|
16
|
+
9: 20700,
|
|
17
|
+
10: 22000,
|
|
18
|
+
11: 24200,
|
|
19
|
+
12: 26400,
|
|
20
|
+
};
|
|
21
|
+
const adults = {
|
|
22
|
+
key: 'adult',
|
|
23
|
+
title: 'Взрослые',
|
|
24
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE,
|
|
25
|
+
value: 0,
|
|
26
|
+
countsInPrice: true,
|
|
27
|
+
};
|
|
28
|
+
describe('фиксированная скидка с человека', () => {
|
|
29
|
+
const students = {
|
|
30
|
+
key: 'student',
|
|
31
|
+
title: 'Студенты',
|
|
32
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA,
|
|
33
|
+
value: 500,
|
|
34
|
+
countsInPrice: true,
|
|
35
|
+
};
|
|
36
|
+
const price = (adult, student) => (0, composition_price_1.getCompositionPrice)({
|
|
37
|
+
price: PARIS,
|
|
38
|
+
categories: [adults, students],
|
|
39
|
+
composition: { adult, student },
|
|
40
|
+
minGameAmount: 9800,
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* Льготная колонка венью целиком: суммы взяты из их прайс-листа. Раньше
|
|
44
|
+
* этот же результат давала связка `reducedRate` + `reducedPlayers` — так
|
|
45
|
+
* проверяем, что перевод на категории не сдвинул ни одной цены.
|
|
46
|
+
*/
|
|
47
|
+
it.each([
|
|
48
|
+
[3, 10500],
|
|
49
|
+
[4, 12000],
|
|
50
|
+
[5, 13500],
|
|
51
|
+
[6, 15000],
|
|
52
|
+
[7, 15400],
|
|
53
|
+
[8, 16000],
|
|
54
|
+
[9, 16200],
|
|
55
|
+
[10, 17000],
|
|
56
|
+
[11, 18700],
|
|
57
|
+
[12, 20400],
|
|
58
|
+
])('вся группа из %i студентов стоит %i', (heads, expected) => {
|
|
59
|
+
expect(price(0, heads)).toBe(expected);
|
|
60
|
+
});
|
|
61
|
+
it('смешанный состав считается по головам', () => {
|
|
62
|
+
// Четверо за 140 €, из них двое студентов по −5 €.
|
|
63
|
+
expect(price(2, 2)).toBe(13000);
|
|
64
|
+
});
|
|
65
|
+
it('состав определяет уровень сетки независимо от категорий', () => {
|
|
66
|
+
// 1 + 3 и 4 + 0 — один и тот же уровень price[4].
|
|
67
|
+
expect(price(1, 3)).toBe(14000 - 1500);
|
|
68
|
+
expect(price(4, 0)).toBe(14000);
|
|
69
|
+
});
|
|
70
|
+
it('на минимальной группе скидка упирается в пол и выбор не блокирует', () => {
|
|
71
|
+
expect(price(0, 2)).toBe(9800);
|
|
72
|
+
expect((0, composition_price_1.getMaxDiscountedSeats)({ price: PARIS, categories: [adults, students], composition: { adult: 0, student: 2 }, minGameAmount: 9800 }, 'student')).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('фиксированная цена за человека', () => {
|
|
76
|
+
// Прайс из письма венью: 88 € за двоих (он же минимум), 108 € за трёх.
|
|
77
|
+
const price = { 2: 8800, 3: 10800, 4: 12000 };
|
|
78
|
+
const reduced = {
|
|
79
|
+
key: 'reduced',
|
|
80
|
+
title: 'Школьники',
|
|
81
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED,
|
|
82
|
+
value: 2200,
|
|
83
|
+
countsInPrice: true,
|
|
84
|
+
};
|
|
85
|
+
const total = (adult, school) => (0, composition_price_1.getCompositionPrice)({
|
|
86
|
+
price,
|
|
87
|
+
categories: [adults, reduced],
|
|
88
|
+
composition: { adult, reduced: school },
|
|
89
|
+
minGameAmount: 8800,
|
|
90
|
+
});
|
|
91
|
+
it('на троих одно льготное место даёт 94 €', () => {
|
|
92
|
+
expect(total(2, 1)).toBe(9400);
|
|
93
|
+
});
|
|
94
|
+
it('на четверых льготными могут быть все — 88 €', () => {
|
|
95
|
+
expect(total(0, 4)).toBe(8800);
|
|
96
|
+
});
|
|
97
|
+
it('смешанный состав: 2 обычных по 30 € + 2 льготных по 22 €', () => {
|
|
98
|
+
expect(total(2, 2)).toBe(10400);
|
|
99
|
+
});
|
|
100
|
+
it('не даёт скидки там, где обычная цена уже опустилась до льготной', () => {
|
|
101
|
+
// На 10 человек доля и так 22 € — категория ничего не меняет.
|
|
102
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
103
|
+
price: PARIS,
|
|
104
|
+
categories: [adults, reduced],
|
|
105
|
+
composition: { adult: 0, reduced: 10 },
|
|
106
|
+
minGameAmount: 9800,
|
|
107
|
+
})).toBe(22000);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('процентная скидка', () => {
|
|
111
|
+
const seniors = {
|
|
112
|
+
key: 'senior',
|
|
113
|
+
title: 'Пенсионеры',
|
|
114
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.PERCENT,
|
|
115
|
+
value: 2000,
|
|
116
|
+
countsInPrice: true,
|
|
117
|
+
};
|
|
118
|
+
it('снимает свой процент с доли гостя', () => {
|
|
119
|
+
// price[4] = 140 €, доля 35 €, −20 % = 7 € с каждого пенсионера.
|
|
120
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
121
|
+
price: PARIS,
|
|
122
|
+
categories: [adults, seniors],
|
|
123
|
+
composition: { adult: 3, senior: 1 },
|
|
124
|
+
})).toBe(13300);
|
|
125
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
126
|
+
price: PARIS,
|
|
127
|
+
categories: [adults, seniors],
|
|
128
|
+
composition: { adult: 0, senior: 4 },
|
|
129
|
+
})).toBe(11200);
|
|
130
|
+
});
|
|
131
|
+
it('сто процентов делают гостя бесплатным, но место в сетке он занимает', () => {
|
|
132
|
+
const free = { ...seniors, value: 10000 };
|
|
133
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
134
|
+
price: PARIS,
|
|
135
|
+
categories: [adults, free],
|
|
136
|
+
composition: { adult: 2, senior: 2 },
|
|
137
|
+
})).toBe(7000);
|
|
138
|
+
});
|
|
139
|
+
it('процент не уводит цену ниже минимальной стоимости игры', () => {
|
|
140
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
141
|
+
price: PARIS,
|
|
142
|
+
categories: [adults, { ...seniors, value: 10000 }],
|
|
143
|
+
composition: { adult: 2, senior: 2 },
|
|
144
|
+
minGameAmount: 9800,
|
|
145
|
+
})).toBe(9800);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('категории сверх групповой цены', () => {
|
|
149
|
+
const kids = {
|
|
150
|
+
key: 'kid',
|
|
151
|
+
title: 'Дети до 7 лет',
|
|
152
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED,
|
|
153
|
+
value: 0,
|
|
154
|
+
countsInPrice: false,
|
|
155
|
+
};
|
|
156
|
+
it('бесплатные дети не платят и не двигают уровень сетки', () => {
|
|
157
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
158
|
+
price: PARIS,
|
|
159
|
+
categories: [adults, kids],
|
|
160
|
+
composition: { adult: 4, kid: 3 },
|
|
161
|
+
})).toBe(14000);
|
|
162
|
+
});
|
|
163
|
+
it('платные дети идут надбавкой к цене группы', () => {
|
|
164
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
165
|
+
price: PARIS,
|
|
166
|
+
categories: [adults, { ...kids, value: 1000 }],
|
|
167
|
+
composition: { adult: 4, kid: 2 },
|
|
168
|
+
})).toBe(16000);
|
|
169
|
+
});
|
|
170
|
+
it('недоступный состав не считается, но надбавка остаётся', () => {
|
|
171
|
+
// Уровня на 20 человек в прайсе нет: минимум к такому составу не
|
|
172
|
+
// применяется, иначе отключённое число игроков начало бы стоить денег.
|
|
173
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
174
|
+
price: PARIS,
|
|
175
|
+
categories: [adults, { ...kids, value: 1000 }],
|
|
176
|
+
composition: { adult: 20, kid: 1 },
|
|
177
|
+
minGameAmount: 9800,
|
|
178
|
+
})).toBe(1000);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe('смешанный состав из нескольких категорий', () => {
|
|
182
|
+
const categories = [
|
|
183
|
+
adults,
|
|
184
|
+
{
|
|
185
|
+
key: 'student',
|
|
186
|
+
title: 'Студенты',
|
|
187
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA,
|
|
188
|
+
value: 500,
|
|
189
|
+
countsInPrice: true,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: 'senior',
|
|
193
|
+
title: 'Пенсионеры',
|
|
194
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.PERCENT,
|
|
195
|
+
value: 2500,
|
|
196
|
+
countsInPrice: true,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
key: 'kid',
|
|
200
|
+
title: 'Дети до 7 лет',
|
|
201
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED,
|
|
202
|
+
value: 0,
|
|
203
|
+
countsInPrice: false,
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
it('считает все категории в одной броне', () => {
|
|
207
|
+
// Уровень по головам в сетке = 2 + 1 + 1 = 4 → 140 €, доля 35 €.
|
|
208
|
+
// Студент −5 €, пенсионер −25 % = −8.75 → 9 €, ребёнок бесплатно.
|
|
209
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
210
|
+
price: PARIS,
|
|
211
|
+
categories,
|
|
212
|
+
composition: { adult: 2, student: 1, senior: 1, kid: 2 },
|
|
213
|
+
})).toBe(14000 - 500 - 875);
|
|
214
|
+
});
|
|
215
|
+
it('лимит категории режет состав', () => {
|
|
216
|
+
const limited = categories.map((category) => category.key === 'kid' ? { ...category, value: 1000, max: 1 } : category);
|
|
217
|
+
expect((0, composition_price_1.getCompositionPrice)({
|
|
218
|
+
price: PARIS,
|
|
219
|
+
categories: limited,
|
|
220
|
+
composition: { adult: 4, kid: 5 },
|
|
221
|
+
})).toBe(15000);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
describe('совместимость с прежним форматом состава', () => {
|
|
225
|
+
const legacyTariff = {
|
|
226
|
+
price: { ...PARIS, child: 1000 },
|
|
227
|
+
legacy: {
|
|
228
|
+
reducedRateMode: tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.DELTA,
|
|
229
|
+
reducedRate: 500,
|
|
230
|
+
reducedTitle: 'Школьники',
|
|
231
|
+
childPriceDescription: 'До 7 лет',
|
|
232
|
+
},
|
|
233
|
+
minGameAmount: 9800,
|
|
234
|
+
};
|
|
235
|
+
it('тариф без категорий раскладывается на них автоматически', () => {
|
|
236
|
+
const categories = (0, composition_price_1.categoriesFromLegacy)(legacyTariff);
|
|
237
|
+
expect(categories.map((category) => [category.key, category.mode, category.countsInPrice]))
|
|
238
|
+
.toEqual([
|
|
239
|
+
['adult', tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE, true],
|
|
240
|
+
['reduced', tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA, true],
|
|
241
|
+
['child', tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED, false],
|
|
242
|
+
]);
|
|
243
|
+
});
|
|
244
|
+
it('старые поля состава дают ту же цену, что и категории', () => {
|
|
245
|
+
const legacyPrice = (0, get_players_price_1.getPlayersPrice)({
|
|
246
|
+
price: legacyTariff.price,
|
|
247
|
+
players: 4,
|
|
248
|
+
children: 2,
|
|
249
|
+
reducedPlayers: 2,
|
|
250
|
+
reducedRateMode: tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.DELTA,
|
|
251
|
+
reducedRate: 500,
|
|
252
|
+
minGameAmount: 9800,
|
|
253
|
+
});
|
|
254
|
+
expect(legacyPrice).toBe((0, composition_price_1.getCompositionPrice)({
|
|
255
|
+
...legacyTariff,
|
|
256
|
+
categories: (0, composition_price_1.categoriesFromLegacy)(legacyTariff),
|
|
257
|
+
composition: { adult: 2, reduced: 2, child: 2 },
|
|
258
|
+
}));
|
|
259
|
+
expect(legacyPrice).toBe(14000 - 1000 + 2000);
|
|
260
|
+
});
|
|
261
|
+
it('состав переводится в старые поля и обратно без потерь', () => {
|
|
262
|
+
const categories = (0, composition_price_1.categoriesFromLegacy)(legacyTariff);
|
|
263
|
+
const composition = (0, composition_price_1.compositionFromLegacy)({ players: 4, children: 2, reducedPlayers: 3 }, categories);
|
|
264
|
+
expect(composition).toEqual({ adult: 1, reduced: 3, child: 2 });
|
|
265
|
+
expect((0, composition_price_1.legacyCountsFromComposition)(composition, categories)).toEqual({
|
|
266
|
+
players: 4,
|
|
267
|
+
children: 2,
|
|
268
|
+
reducedPlayers: 3,
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
it('несколько льготных категорий схлопываются в одно число льготных мест', () => {
|
|
272
|
+
const categories = [
|
|
273
|
+
adults,
|
|
274
|
+
{ key: 'student', title: '', mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA, value: 500, countsInPrice: true },
|
|
275
|
+
{ key: 'senior', title: '', mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.PERCENT, value: 2500, countsInPrice: true },
|
|
276
|
+
];
|
|
277
|
+
expect((0, composition_price_1.legacyCountsFromComposition)({ adult: 1, student: 2, senior: 1 }, categories)).toEqual({ players: 4, children: 0, reducedPlayers: 3 });
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
describe('проекция категорий обратно в легаси-поля', () => {
|
|
281
|
+
const kids = {
|
|
282
|
+
key: 'kid',
|
|
283
|
+
title: 'До 7 лет',
|
|
284
|
+
description: 'Бесплатно',
|
|
285
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED,
|
|
286
|
+
value: 0,
|
|
287
|
+
countsInPrice: false,
|
|
288
|
+
};
|
|
289
|
+
it('надбавочная категория становится детской ценой тарифа', () => {
|
|
290
|
+
expect((0, composition_price_1.legacyFromCategories)([adults, kids], PARIS)).toMatchObject({
|
|
291
|
+
childPrice: 0,
|
|
292
|
+
childPriceDescription: 'Бесплатно',
|
|
293
|
+
reducedRateMode: null,
|
|
294
|
+
reducedRate: 0,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
it('фикс и дельта переносятся один в один', () => {
|
|
298
|
+
const students = {
|
|
299
|
+
key: 'student',
|
|
300
|
+
title: 'Студенты',
|
|
301
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA,
|
|
302
|
+
value: 500,
|
|
303
|
+
countsInPrice: true,
|
|
304
|
+
};
|
|
305
|
+
expect((0, composition_price_1.legacyFromCategories)([adults, students], PARIS)).toMatchObject({
|
|
306
|
+
reducedRateMode: tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.DELTA,
|
|
307
|
+
reducedRate: 500,
|
|
308
|
+
reducedTitle: 'Студенты',
|
|
309
|
+
});
|
|
310
|
+
expect((0, composition_price_1.legacyFromCategories)([adults, { ...students, mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED, value: 2200 }], PARIS)).toMatchObject({
|
|
311
|
+
reducedRateMode: tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.FIXED,
|
|
312
|
+
reducedRate: 2200,
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
it('процент превращается в самую осторожную фиксированную скидку', () => {
|
|
316
|
+
const seniors = {
|
|
317
|
+
key: 'senior',
|
|
318
|
+
title: 'Пенсионеры',
|
|
319
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.PERCENT,
|
|
320
|
+
value: 2500,
|
|
321
|
+
countsInPrice: true,
|
|
322
|
+
};
|
|
323
|
+
// Доля гостя минимальна на группе из 12 (26400 / 12 = 2200), значит и
|
|
324
|
+
// 25% от неё — 550 — это наименьшая скидка, которую даёт эта строка.
|
|
325
|
+
// Старый клиент недосчитает скидку, но не продаст игру дешевле нужного.
|
|
326
|
+
const legacy = (0, composition_price_1.legacyFromCategories)([adults, seniors], PARIS);
|
|
327
|
+
expect(legacy).toMatchObject({
|
|
328
|
+
reducedRateMode: tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.DELTA,
|
|
329
|
+
reducedRate: 550,
|
|
330
|
+
});
|
|
331
|
+
const onSmallestGroup = (0, composition_price_1.getCompositionPrice)({
|
|
332
|
+
price: PARIS,
|
|
333
|
+
categories: [adults, seniors],
|
|
334
|
+
composition: { adult: 1, senior: 1 },
|
|
335
|
+
});
|
|
336
|
+
expect(onSmallestGroup).toBeLessThanOrEqual(9800 - legacy.reducedRate);
|
|
337
|
+
});
|
|
338
|
+
it('тариф без льготных строк обнуляет ставку, а не оставляет прежнюю', () => {
|
|
339
|
+
expect((0, composition_price_1.legacyFromCategories)([adults], PARIS)).toEqual({
|
|
340
|
+
childPrice: null,
|
|
341
|
+
childPriceDescription: null,
|
|
342
|
+
reducedRateMode: null,
|
|
343
|
+
reducedRate: 0,
|
|
344
|
+
reducedTitle: null,
|
|
345
|
+
reducedPriceDescription: null,
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
describe('цена строки категории', () => {
|
|
350
|
+
it('меняется вместе с составом — доля гостя падает на больших группах', () => {
|
|
351
|
+
const students = {
|
|
352
|
+
key: 'student',
|
|
353
|
+
title: '',
|
|
354
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA,
|
|
355
|
+
value: 500,
|
|
356
|
+
countsInPrice: true,
|
|
357
|
+
};
|
|
358
|
+
expect((0, composition_price_1.getCategorySeatPrice)(adults, (0, composition_price_1.getPerPersonAmount)(PARIS, 2))).toBe(4900);
|
|
359
|
+
expect((0, composition_price_1.getCategorySeatPrice)(adults, (0, composition_price_1.getPerPersonAmount)(PARIS, 6))).toBe(3000);
|
|
360
|
+
expect((0, composition_price_1.getCategorySeatPrice)(students, (0, composition_price_1.getPerPersonAmount)(PARIS, 6))).toBe(2500);
|
|
361
|
+
});
|
|
362
|
+
it('на недоступном уровне цены нет', () => {
|
|
363
|
+
expect((0, composition_price_1.getPerPersonAmount)(PARIS, 20)).toBe(0);
|
|
364
|
+
expect((0, composition_price_1.getCategorySeatPrice)(adults, (0, composition_price_1.getPerPersonAmount)(PARIS, 20))).toBe(0);
|
|
365
|
+
});
|
|
366
|
+
});
|