@escapenavigator/utils 2.0.49 → 2.1.0

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,339 @@
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.formatCompositionBreakdown = exports.resolveCategories = exports.mergeLegacyCategories = exports.hasExtraCategories = 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 hasExtraCategories = (categories) => (categories || []).some((category) => category.mode !== tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
72
+ exports.hasExtraCategories = hasExtraCategories;
73
+ /**
74
+ * Дописывает в уже сохранённые категории те строки, которые ещё живут только
75
+ * в легаси-полях (`price.child`, `reduced*`).
76
+ *
77
+ * Нужно рефилу: тариф, который успели открыть в новом редакторе, получил
78
+ * `[BASE]` и больше не попадает в «пустой список», хотя детская цена на нём
79
+ * ещё есть. Идемпотентен: уже перенесённые детская/льгота не дублируются.
80
+ */
81
+ const mergeLegacyCategories = (existing, config) => {
82
+ const fromLegacy = (0, exports.categoriesFromLegacy)(config);
83
+ const own = (existing || []).filter((category) => !!category?.key);
84
+ if (!own.length || !(0, exports.baseCategory)(own))
85
+ return fromLegacy;
86
+ const keys = new Set(own.map((category) => category.key));
87
+ const hasAddOn = own.some((category) => !category.countsInPrice);
88
+ const hasReduced = own.some((category) => category.countsInPrice && category.mode !== tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
89
+ const extras = fromLegacy.filter((category) => {
90
+ if (category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE)
91
+ return false;
92
+ if (keys.has(category.key))
93
+ return false;
94
+ if (!category.countsInPrice && hasAddOn)
95
+ return false;
96
+ if (category.countsInPrice && hasReduced)
97
+ return false;
98
+ return true;
99
+ });
100
+ return extras.length ? [...own, ...extras] : own;
101
+ };
102
+ exports.mergeLegacyCategories = mergeLegacyCategories;
103
+ /**
104
+ * Категории, по которым считать цену: собственные, если тариф уже переведён,
105
+ * иначе собранные из легаси-полей. Без базовой категории список бессмысленен
106
+ * (не от чего считать доли) — такой тоже уходит в легаси-ветку.
107
+ */
108
+ const resolveCategories = (config) => {
109
+ const own = (config.categories || []).filter((category) => !!category?.key);
110
+ return own.length && (0, exports.baseCategory)(own) ? own : (0, exports.categoriesFromLegacy)(config);
111
+ };
112
+ exports.resolveCategories = resolveCategories;
113
+ /**
114
+ * «2 взрослых, 3 студента» — подпись под общим числом человек, когда в тарифе
115
+ * есть льготные или надбавочные строки. Без них раскладывать нечего.
116
+ */
117
+ const formatCompositionBreakdown = (composition, categories) => {
118
+ if (!(0, exports.hasExtraCategories)(categories))
119
+ return '';
120
+ return (categories || [])
121
+ .map((category) => {
122
+ const count = positiveInt(composition?.[category.key]);
123
+ const title = category.title?.trim();
124
+ if (!count || !title)
125
+ return '';
126
+ return `${count} ${title}`;
127
+ })
128
+ .filter(Boolean)
129
+ .join(', ');
130
+ };
131
+ exports.formatCompositionBreakdown = formatCompositionBreakdown;
132
+ /** Нормализованный состав: целые неотрицательные числа, лишние ключи выброшены. */
133
+ const normalizeComposition = (composition, categories) => {
134
+ const counts = {};
135
+ categories.forEach((category) => {
136
+ const count = positiveInt(composition?.[category.key]);
137
+ const max = category.max === null || category.max === undefined ? null : positiveInt(category.max);
138
+ counts[category.key] = max === null ? count : Math.min(count, max);
139
+ });
140
+ return counts;
141
+ };
142
+ exports.normalizeComposition = normalizeComposition;
143
+ /** Гостей всего: и те, что занимают место в сетке, и надбавочные. */
144
+ const getTotalHeads = (composition, categories) => categories.reduce((sum, category) => sum + positiveInt(composition?.[category.key]), 0);
145
+ exports.getTotalHeads = getTotalHeads;
146
+ /** Гостей, определяющих уровень сетки `price[N]`. */
147
+ const getPricingHeads = (composition, categories) => categories.reduce((sum, category) => category.countsInPrice ? sum + positiveInt(composition?.[category.key]) : sum, 0);
148
+ exports.getPricingHeads = getPricingHeads;
149
+ /** Групповая цена уровня. 0 — состав недоступен для брони. */
150
+ const getLevelAmount = (price, heads) => {
151
+ const amount = Number(price?.[heads]);
152
+ return Number.isFinite(amount) && amount > 0 ? amount : 0;
153
+ };
154
+ exports.getLevelAmount = getLevelAmount;
155
+ /**
156
+ * Доля одного гостя в групповой цене. Не округляем: остаток от неровного
157
+ * деления (108 / 3) уходит в скидку места, а групповой итог остаётся ровно
158
+ * тем, что стоит в прайсе.
159
+ */
160
+ const getPerPersonAmount = (price, heads) => {
161
+ const amount = (0, exports.getLevelAmount)(price, heads);
162
+ return amount && heads > 0 ? amount / heads : 0;
163
+ };
164
+ exports.getPerPersonAmount = getPerPersonAmount;
165
+ /**
166
+ * Скидка одного гостя категории от базовой доли.
167
+ *
168
+ * Считаем именно скидку, а не цену гостя: групповой итог тогда остаётся
169
+ * суммой из прайса, а неровное деление не размазывается копейками по головам.
170
+ */
171
+ const getCategorySeatDiscount = (category, perPerson) => {
172
+ switch (category.mode) {
173
+ case tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA:
174
+ return positiveInt(category.value);
175
+ case tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED:
176
+ // Ставка задана абсолютом: на больших группах обычная цена сама
177
+ // опускается до неё — тогда скидки нет.
178
+ return Math.max(0, Math.round(perPerson - positiveInt(category.value)));
179
+ case tariff_category_mode_enum_1.TariffCategoryModeEnum.PERCENT:
180
+ return Math.min(Math.round(perPerson), Math.round((perPerson * clampBps(category.value)) / 10000));
181
+ default:
182
+ return 0;
183
+ }
184
+ };
185
+ exports.getCategorySeatDiscount = getCategorySeatDiscount;
186
+ /** Цена одного гостя категории при текущем составе. */
187
+ const getCategorySeatPrice = (category, perPerson) => {
188
+ if (category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED)
189
+ return positiveInt(category.value);
190
+ if (category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE)
191
+ return Math.round(perPerson);
192
+ return Math.max(0, Math.round(perPerson) - (0, exports.getCategorySeatDiscount)(category, perPerson));
193
+ };
194
+ exports.getCategorySeatPrice = getCategorySeatPrice;
195
+ /**
196
+ * Цена игры за состав.
197
+ *
198
+ * Сумма = цена уровня по головам, занимающим место, минус скидки этих голов,
199
+ * плюс надбавочные категории, и всё это не ниже минимальной стоимости игры.
200
+ */
201
+ const getCompositionPrice = (source) => {
202
+ const categories = (0, exports.resolveCategories)(source);
203
+ const counts = (0, exports.normalizeComposition)(source.composition, categories);
204
+ const pricingHeads = (0, exports.getPricingHeads)(counts, categories);
205
+ const groupAmount = (0, exports.getLevelAmount)(source.price, pricingHeads);
206
+ const perPerson = pricingHeads > 0 ? groupAmount / pricingHeads : 0;
207
+ const addOn = categories.reduce((sum, category) => category.countsInPrice
208
+ ? sum
209
+ : sum + counts[category.key] * (0, exports.getCategorySeatPrice)(category, perPerson), 0);
210
+ // Цены на такой состав нет (или она 0 — «состав недоступен»). Минимальная
211
+ // стоимость игры к нему не применяется: иначе отключённое число игроков
212
+ // внезапно начало бы стоить пол выручки.
213
+ if (groupAmount <= 0)
214
+ return addOn;
215
+ const discount = categories.reduce((sum, category) => category.countsInPrice
216
+ ? sum + counts[category.key] * (0, exports.getCategorySeatDiscount)(category, perPerson)
217
+ : sum, 0);
218
+ return (0, reduced_price_1.applyMinGameAmount)(groupAmount - Math.min(discount, groupAmount) + addOn, source);
219
+ };
220
+ exports.getCompositionPrice = getCompositionPrice;
221
+ /**
222
+ * Сколько гостей категории ещё снижают цену, не упираясь в пол выручки.
223
+ * Это не лимит выбора — отметить можно больше, цена просто перестанет падать.
224
+ */
225
+ const getMaxDiscountedSeats = (source, categoryKey) => {
226
+ const categories = (0, exports.resolveCategories)(source);
227
+ const category = categories.find((item) => item.key === categoryKey);
228
+ if (!category?.countsInPrice)
229
+ return 0;
230
+ const counts = (0, exports.normalizeComposition)(source.composition, categories);
231
+ const pricingHeads = (0, exports.getPricingHeads)(counts, categories);
232
+ const groupAmount = (0, exports.getLevelAmount)(source.price, pricingHeads);
233
+ if (!groupAmount)
234
+ return 0;
235
+ const seatDiscount = (0, exports.getCategorySeatDiscount)(category, groupAmount / pricingHeads);
236
+ if (!seatDiscount)
237
+ return 0;
238
+ const room = groupAmount - (0, reduced_price_1.getMinGameAmount)(source);
239
+ if (room <= 0)
240
+ return 0;
241
+ return Math.min(pricingHeads, Math.floor(room / seatDiscount));
242
+ };
243
+ exports.getMaxDiscountedSeats = getMaxDiscountedSeats;
244
+ /* ------------------------- Легаси-совместимость ------------------------- */
245
+ /**
246
+ * Состав по категориям из полей старого формата.
247
+ *
248
+ * `reducedPlayers` — это часть `players`, поэтому базовой категории достаётся
249
+ * остаток. Обратное преобразование — `legacyCountsFromComposition`.
250
+ */
251
+ const compositionFromLegacy = (legacyCounts, categories) => {
252
+ const players = positiveInt(legacyCounts.players);
253
+ const reduced = Math.min(positiveInt(legacyCounts.reducedPlayers), players);
254
+ const base = (0, exports.baseCategory)(categories);
255
+ const composition = {};
256
+ categories.forEach((category) => {
257
+ composition[category.key] = 0;
258
+ });
259
+ if (base)
260
+ composition[base.key] = players - reduced;
261
+ const reducedCategory = categories.find((category) => category.countsInPrice && category.mode !== tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
262
+ if (reducedCategory)
263
+ composition[reducedCategory.key] = reduced;
264
+ const childCategory = categories.find((category) => !category.countsInPrice);
265
+ if (childCategory)
266
+ composition[childCategory.key] = positiveInt(legacyCounts.children);
267
+ return composition;
268
+ };
269
+ exports.compositionFromLegacy = compositionFromLegacy;
270
+ /**
271
+ * Легаси-поля тарифа, восстановленные из категорий. Обратная операция к
272
+ * `categoriesFromLegacy`: венью правит тариф уже категориями, а мобильное
273
+ * приложение и не обновлённые интеграции продолжают читать `price.child` и
274
+ * `reduced*` — без этой проекции они бы застыли на значениях до перехода.
275
+ *
276
+ * Схлопывание с потерями и это нормально: легаси-формат умеет ровно одну
277
+ * льготную категорию и ровно одну надбавочную. Берём первые — цена там, где
278
+ * есть `categories`, всё равно считается по ним.
279
+ */
280
+ const legacyFromCategories = (categories, price) => {
281
+ const addOn = categories.find((category) => !category.countsInPrice);
282
+ const reduced = categories.find((category) => category.countsInPrice && category.mode !== tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
283
+ const legacy = {
284
+ childPrice: addOn ? positiveInt(addOn.value) : null,
285
+ childPriceDescription: addOn?.description ?? null,
286
+ reducedRateMode: null,
287
+ reducedRate: 0,
288
+ reducedTitle: reduced?.title || null,
289
+ reducedPriceDescription: reduced?.description ?? null,
290
+ };
291
+ if (!reduced)
292
+ return legacy;
293
+ if (reduced.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED) {
294
+ legacy.reducedRateMode = tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.FIXED;
295
+ legacy.reducedRate = positiveInt(reduced.value);
296
+ return legacy;
297
+ }
298
+ legacy.reducedRateMode = tariff_reduced_rate_mode_enum_1.TariffReducedRateModeEnum.DELTA;
299
+ if (reduced.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.DELTA) {
300
+ legacy.reducedRate = positiveInt(reduced.value);
301
+ return legacy;
302
+ }
303
+ // Процент в легаси-формате не выразить. Переводим в фиксированную скидку по
304
+ // самой большой группе: там доля гостя минимальна, значит и скидка выйдет
305
+ // наименьшей из возможных — старый клиент недосчитает скидку, но никогда не
306
+ // продаст игру дешевле, чем задумано.
307
+ const levels = Object.keys(price || {})
308
+ .map((key) => +key)
309
+ .filter((heads) => heads > 0 && (0, exports.getLevelAmount)(price, heads) > 0);
310
+ const largest = levels.length ? Math.max(...levels) : 0;
311
+ legacy.reducedRate = largest
312
+ ? (0, exports.getCategorySeatDiscount)(reduced, (0, exports.getPerPersonAmount)(price, largest))
313
+ : 0;
314
+ return legacy;
315
+ };
316
+ exports.legacyFromCategories = legacyFromCategories;
317
+ /**
318
+ * Поля старого формата из состава по категориям. Нужны, пока их читают не
319
+ * обновлённые клиенты (мобильное приложение) и вся отчётность по броням.
320
+ *
321
+ * На тарифе с несколькими льготными категориями `reducedPlayers` схлопывает
322
+ * их в одно число: восстановить цену из легаси-полей в таком случае нельзя,
323
+ * источник истины — `composition` и снимок категорий на брони.
324
+ */
325
+ const legacyCountsFromComposition = (composition, categories) => {
326
+ const counts = (0, exports.normalizeComposition)(composition, categories);
327
+ return categories.reduce((acc, category) => {
328
+ const count = counts[category.key];
329
+ if (!category.countsInPrice) {
330
+ acc.children += count;
331
+ return acc;
332
+ }
333
+ acc.players += count;
334
+ if (category.mode !== tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE)
335
+ acc.reducedPlayers += count;
336
+ return acc;
337
+ }, { players: 0, children: 0, reducedPlayers: 0 });
338
+ };
339
+ exports.legacyCountsFromComposition = legacyCountsFromComposition;
@@ -0,0 +1 @@
1
+ export {};