@escapenavigator/utils 2.0.49 → 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/package.json +3 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { OrderComposition, TariffCategory } from '@escapenavigator/types/dist/tariff/tariff-category';
|
|
2
|
+
/**
|
|
3
|
+
* Границы выбора состава: до скольки можно докручивать каждую строку и можно
|
|
4
|
+
* ли текущий состав вообще оформить.
|
|
5
|
+
*
|
|
6
|
+
* Ключевая мысль — потолков два, и они независимы:
|
|
7
|
+
* - вместимость комнаты ограничивает ВСЕХ гостей;
|
|
8
|
+
* - сетка тарифа `price[N]` ограничивает только тех, кто входит в цену.
|
|
9
|
+
* Гость надбавочной категории (малыш до 7) занимает место в комнате, но на
|
|
10
|
+
* уровень `price[N]` не влияет — если считать его одним потолком, добавление
|
|
11
|
+
* малыша начинает отнимать место у взрослых.
|
|
12
|
+
*
|
|
13
|
+
* Второе: недоступный уровень внутри сетки (венью поставил 0 на «трёх») — это
|
|
14
|
+
* дырка, а не край. Через неё нужно уметь пройти к четверым, поэтому она не
|
|
15
|
+
* сужает границы, а лишь помечает состав как неоформляемый.
|
|
16
|
+
*/
|
|
17
|
+
export type CompositionLimitsSource = {
|
|
18
|
+
price?: Record<string, number> | null;
|
|
19
|
+
categories: TariffCategory[];
|
|
20
|
+
composition?: OrderComposition | null;
|
|
21
|
+
/** `questroom.playersMin`. Тарифный минимум всё равно сильнее. */
|
|
22
|
+
minPlayers?: number | null;
|
|
23
|
+
/** Вместимость: кап квеста и свободные места тикет-системы. */
|
|
24
|
+
maxPlayersLimit?: number | null;
|
|
25
|
+
};
|
|
26
|
+
export type CompositionLimits = {
|
|
27
|
+
/** Всего гостей влезает в комнату. */
|
|
28
|
+
capacityMax: number;
|
|
29
|
+
/** Гостей «в цене» — потолок сетки тарифа с учётом вместимости. */
|
|
30
|
+
pricingMax: number;
|
|
31
|
+
/** Гостей «в цене» — минимум, ниже которого игра не бронируется. */
|
|
32
|
+
effectiveMin: number;
|
|
33
|
+
pricingHeads: number;
|
|
34
|
+
totalHeads: number;
|
|
35
|
+
/** Уровни сетки с заданной ценой в пределах [effectiveMin, pricingMax]. */
|
|
36
|
+
availableLevels: number[];
|
|
37
|
+
/** Уровни для выбора числа тегами: влезают вместе с надбавочными гостями. */
|
|
38
|
+
baseLevelOptions: number[];
|
|
39
|
+
/** Состав внутри сетки, но цена на него не задана. */
|
|
40
|
+
levelUnavailable: boolean;
|
|
41
|
+
/** Гостей «в цене» не хватает на минимальную игру. */
|
|
42
|
+
belowMin: boolean;
|
|
43
|
+
/** Комната заполнена: «плюс» упёрся во вместимость. */
|
|
44
|
+
capacityReached: boolean;
|
|
45
|
+
/** Сетка цен исчерпана: «плюс» на платящих строках упёрся в тариф. */
|
|
46
|
+
pricingFull: boolean;
|
|
47
|
+
/** Состав можно оформить. */
|
|
48
|
+
bookable: boolean;
|
|
49
|
+
/** До скольки можно докрутить каждую строку. */
|
|
50
|
+
maxByCategory: Record<string, number>;
|
|
51
|
+
};
|
|
52
|
+
export declare const getCompositionLimits: ({ price, categories, composition, minPlayers, maxPlayersLimit, }: CompositionLimitsSource) => CompositionLimits;
|
|
53
|
+
/**
|
|
54
|
+
* Состав торчит выше сетки — например, гость выбрал шестерых, а потом
|
|
55
|
+
* переключил вариацию, где цены есть только до четверых. Возвращает
|
|
56
|
+
* исправленный состав или `null`, если править нечего.
|
|
57
|
+
*
|
|
58
|
+
* Единственная автоправка состава, которая себя оправдывает: без неё на бэке
|
|
59
|
+
* `price[N]` = undefined → NaN → 500. Подтягивать состав к ближайшему
|
|
60
|
+
* доступному уровню в любую сторону — нельзя: для гостя это выглядит как
|
|
61
|
+
* «добавил подростка — отняло взрослого» и как «плюс не нажимается».
|
|
62
|
+
*/
|
|
63
|
+
export declare const clampCompositionToGrid: (source: CompositionLimitsSource) => OrderComposition | null;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.clampCompositionToGrid = exports.getCompositionLimits = void 0;
|
|
4
|
+
const tariff_category_mode_enum_1 = require("@escapenavigator/types/dist/tariff/enum/tariff-category-mode.enum");
|
|
5
|
+
const composition_price_1 = require("./composition-price");
|
|
6
|
+
const get_prices_1 = require("./get-prices");
|
|
7
|
+
const positiveInt = (value) => {
|
|
8
|
+
const amount = Math.round(Number(value));
|
|
9
|
+
return Number.isFinite(amount) && amount > 0 ? amount : 0;
|
|
10
|
+
};
|
|
11
|
+
const getCompositionLimits = ({ price, categories, composition, minPlayers, maxPlayersLimit, }) => {
|
|
12
|
+
const levels = (0, get_prices_1.getPricesPlayersArray)(price || {}).map((key) => +key);
|
|
13
|
+
const maxFromTariff = levels.length ? levels[levels.length - 1] : 0;
|
|
14
|
+
const minFromTariff = levels.length ? levels[0] : 0;
|
|
15
|
+
const capacityMax = typeof maxPlayersLimit === 'number' && Number.isFinite(maxPlayersLimit)
|
|
16
|
+
? Math.max(0, maxPlayersLimit)
|
|
17
|
+
: maxFromTariff;
|
|
18
|
+
const pricingMax = Math.min(maxFromTariff, capacityMax);
|
|
19
|
+
const effectiveMin = typeof minPlayers === 'number' && Number.isFinite(minPlayers)
|
|
20
|
+
? Math.max(minFromTariff, minPlayers)
|
|
21
|
+
: minFromTariff;
|
|
22
|
+
const pricingHeads = (0, composition_price_1.getPricingHeads)(composition, categories);
|
|
23
|
+
const totalHeads = (0, composition_price_1.getTotalHeads)(composition, categories);
|
|
24
|
+
const addOnHeads = totalHeads - pricingHeads;
|
|
25
|
+
const availableLevels = Object.entries(price || {})
|
|
26
|
+
.filter(([key, amount]) => !Number.isNaN(+key) && Number(amount) > 0)
|
|
27
|
+
.map(([key]) => +key)
|
|
28
|
+
.filter((level) => level >= effectiveMin && level <= pricingMax)
|
|
29
|
+
.sort((left, right) => left - right);
|
|
30
|
+
const capacityRoom = Math.max(0, capacityMax - totalHeads);
|
|
31
|
+
const pricingRoom = Math.max(0, pricingMax - pricingHeads);
|
|
32
|
+
const maxByCategory = {};
|
|
33
|
+
categories.forEach((category) => {
|
|
34
|
+
const value = positiveInt(composition?.[category.key]);
|
|
35
|
+
const room = category.countsInPrice ? Math.min(capacityRoom, pricingRoom) : capacityRoom;
|
|
36
|
+
const own = category.max === null || category.max === undefined
|
|
37
|
+
? Number.POSITIVE_INFINITY
|
|
38
|
+
: positiveInt(category.max);
|
|
39
|
+
maxByCategory[category.key] = Math.min(own, value + room);
|
|
40
|
+
});
|
|
41
|
+
const levelUnavailable = pricingHeads >= effectiveMin && (0, composition_price_1.getPerPersonAmount)(price, pricingHeads) === 0;
|
|
42
|
+
return {
|
|
43
|
+
capacityMax,
|
|
44
|
+
pricingMax,
|
|
45
|
+
effectiveMin,
|
|
46
|
+
pricingHeads,
|
|
47
|
+
totalHeads,
|
|
48
|
+
availableLevels,
|
|
49
|
+
baseLevelOptions: availableLevels.filter((level) => level + addOnHeads <= capacityMax),
|
|
50
|
+
levelUnavailable,
|
|
51
|
+
belowMin: totalHeads > 0 && pricingHeads < effectiveMin,
|
|
52
|
+
capacityReached: capacityMax > 0 && totalHeads >= capacityMax,
|
|
53
|
+
pricingFull: pricingMax > 0 && pricingHeads >= pricingMax,
|
|
54
|
+
bookable: totalHeads > 0 &&
|
|
55
|
+
pricingHeads >= effectiveMin &&
|
|
56
|
+
!levelUnavailable &&
|
|
57
|
+
totalHeads <= capacityMax,
|
|
58
|
+
maxByCategory,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
exports.getCompositionLimits = getCompositionLimits;
|
|
62
|
+
/**
|
|
63
|
+
* Состав торчит выше сетки — например, гость выбрал шестерых, а потом
|
|
64
|
+
* переключил вариацию, где цены есть только до четверых. Возвращает
|
|
65
|
+
* исправленный состав или `null`, если править нечего.
|
|
66
|
+
*
|
|
67
|
+
* Единственная автоправка состава, которая себя оправдывает: без неё на бэке
|
|
68
|
+
* `price[N]` = undefined → NaN → 500. Подтягивать состав к ближайшему
|
|
69
|
+
* доступному уровню в любую сторону — нельзя: для гостя это выглядит как
|
|
70
|
+
* «добавил подростка — отняло взрослого» и как «плюс не нажимается».
|
|
71
|
+
*/
|
|
72
|
+
const clampCompositionToGrid = (source) => {
|
|
73
|
+
const { pricingHeads, pricingMax } = (0, exports.getCompositionLimits)(source);
|
|
74
|
+
if (!pricingMax || pricingHeads <= pricingMax)
|
|
75
|
+
return null;
|
|
76
|
+
const base = source.categories.find((category) => category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
|
|
77
|
+
if (!base)
|
|
78
|
+
return null;
|
|
79
|
+
const adjusted = positiveInt(source.composition?.[base.key]) - (pricingHeads - pricingMax);
|
|
80
|
+
if (adjusted < 0)
|
|
81
|
+
return null;
|
|
82
|
+
return { ...(source.composition || {}), [base.key]: adjusted };
|
|
83
|
+
};
|
|
84
|
+
exports.clampCompositionToGrid = clampCompositionToGrid;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
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 composition_limits_1 = require("./composition-limits");
|
|
5
|
+
const adults = {
|
|
6
|
+
key: 'adult',
|
|
7
|
+
title: 'Взрослые',
|
|
8
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE,
|
|
9
|
+
value: 0,
|
|
10
|
+
countsInPrice: true,
|
|
11
|
+
};
|
|
12
|
+
const teens = {
|
|
13
|
+
key: 'teen',
|
|
14
|
+
title: 'Подростки',
|
|
15
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.PERCENT,
|
|
16
|
+
value: 2000,
|
|
17
|
+
countsInPrice: true,
|
|
18
|
+
};
|
|
19
|
+
/** Малыши: место в комнате занимают, на уровень `price[N]` не влияют. */
|
|
20
|
+
const kids = {
|
|
21
|
+
key: 'kid',
|
|
22
|
+
title: 'До 7 лет',
|
|
23
|
+
mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.FIXED,
|
|
24
|
+
value: 0,
|
|
25
|
+
countsInPrice: false,
|
|
26
|
+
};
|
|
27
|
+
const PRICE = { 2: 9800, 3: 12000, 4: 14000, 5: 16000, 6: 18000 };
|
|
28
|
+
/** Венью закрыл игру на троих: цена 0 = «такой состав не продаём». */
|
|
29
|
+
const PRICE_WITH_HOLE = { 2: 9800, 3: 0, 4: 14000, 5: 16000 };
|
|
30
|
+
describe('надбавочные гости не отнимают места у платящих', () => {
|
|
31
|
+
it('малыш ест вместимость комнаты, но не место в сетке цен', () => {
|
|
32
|
+
const limits = (0, composition_limits_1.getCompositionLimits)({
|
|
33
|
+
price: PRICE,
|
|
34
|
+
categories: [adults, kids],
|
|
35
|
+
composition: { adult: 6, kid: 0 },
|
|
36
|
+
maxPlayersLimit: 8,
|
|
37
|
+
});
|
|
38
|
+
// Взрослых больше не добавить — сетка тарифа кончилась на шести.
|
|
39
|
+
expect(limits.maxByCategory.adult).toBe(6);
|
|
40
|
+
expect(limits.pricingFull).toBe(true);
|
|
41
|
+
// А малышей можно: в комнате ещё два места.
|
|
42
|
+
expect(limits.maxByCategory.kid).toBe(2);
|
|
43
|
+
expect(limits.capacityReached).toBe(false);
|
|
44
|
+
expect(limits.bookable).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('добавленный малыш не сдвигает потолок взрослых', () => {
|
|
47
|
+
const withKid = (0, composition_limits_1.getCompositionLimits)({
|
|
48
|
+
price: PRICE,
|
|
49
|
+
categories: [adults, kids],
|
|
50
|
+
composition: { adult: 3, kid: 2 },
|
|
51
|
+
maxPlayersLimit: 8,
|
|
52
|
+
});
|
|
53
|
+
expect(withKid.pricingHeads).toBe(3);
|
|
54
|
+
expect(withKid.totalHeads).toBe(5);
|
|
55
|
+
expect(withKid.maxByCategory.adult).toBe(6);
|
|
56
|
+
});
|
|
57
|
+
it('вместимость режет всех, когда мест меньше, чем уровней тарифа', () => {
|
|
58
|
+
const limits = (0, composition_limits_1.getCompositionLimits)({
|
|
59
|
+
price: PRICE,
|
|
60
|
+
categories: [adults, kids],
|
|
61
|
+
composition: { adult: 3, kid: 1 },
|
|
62
|
+
maxPlayersLimit: 4,
|
|
63
|
+
});
|
|
64
|
+
expect(limits.capacityReached).toBe(true);
|
|
65
|
+
expect(limits.maxByCategory.adult).toBe(3);
|
|
66
|
+
expect(limits.maxByCategory.kid).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('закрытый уровень внутри сетки', () => {
|
|
70
|
+
const at = (composition) => (0, composition_limits_1.getCompositionLimits)({
|
|
71
|
+
price: PRICE_WITH_HOLE,
|
|
72
|
+
categories: [adults, teens],
|
|
73
|
+
composition,
|
|
74
|
+
});
|
|
75
|
+
it('его видно, но оформить нельзя', () => {
|
|
76
|
+
const limits = at({ adult: 2, teen: 1 });
|
|
77
|
+
expect(limits.pricingHeads).toBe(3);
|
|
78
|
+
expect(limits.levelUnavailable).toBe(true);
|
|
79
|
+
expect(limits.bookable).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it('сквозь него можно пройти к следующему рабочему составу', () => {
|
|
82
|
+
// Главное: «плюс» на закрытом уровне остаётся живым — взрослых можно
|
|
83
|
+
// докрутить до четырёх (это пятеро в цене, потолок сетки).
|
|
84
|
+
expect(at({ adult: 2, teen: 1 }).maxByCategory.adult).toBe(4);
|
|
85
|
+
expect(at({ adult: 3, teen: 1 }).levelUnavailable).toBe(false);
|
|
86
|
+
expect(at({ adult: 3, teen: 1 }).bookable).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
it('в теги выбора числа закрытый уровень не попадает', () => {
|
|
89
|
+
expect(at({ adult: 2 }).baseLevelOptions).toEqual([2, 4, 5]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('минимум и вместимость', () => {
|
|
93
|
+
it('одни малыши игру не составляют', () => {
|
|
94
|
+
const limits = (0, composition_limits_1.getCompositionLimits)({
|
|
95
|
+
price: PRICE,
|
|
96
|
+
categories: [adults, kids],
|
|
97
|
+
composition: { adult: 0, kid: 2 },
|
|
98
|
+
});
|
|
99
|
+
expect(limits.belowMin).toBe(true);
|
|
100
|
+
expect(limits.bookable).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
it('пустой состав не считается ни бронируемым, ни ошибочным', () => {
|
|
103
|
+
const limits = (0, composition_limits_1.getCompositionLimits)({
|
|
104
|
+
price: PRICE,
|
|
105
|
+
categories: [adults, kids],
|
|
106
|
+
composition: {},
|
|
107
|
+
});
|
|
108
|
+
expect(limits.belowMin).toBe(false);
|
|
109
|
+
expect(limits.bookable).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
it('минимум квеста сильнее тарифного, но не шире сетки', () => {
|
|
112
|
+
expect((0, composition_limits_1.getCompositionLimits)({
|
|
113
|
+
price: PRICE,
|
|
114
|
+
categories: [adults],
|
|
115
|
+
composition: { adult: 3 },
|
|
116
|
+
minPlayers: 4,
|
|
117
|
+
}).belowMin).toBe(true);
|
|
118
|
+
expect((0, composition_limits_1.getCompositionLimits)({
|
|
119
|
+
price: PRICE,
|
|
120
|
+
categories: [adults],
|
|
121
|
+
composition: { adult: 2 },
|
|
122
|
+
minPlayers: 1,
|
|
123
|
+
}).effectiveMin).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
it('лимит категории не пробивается вместимостью', () => {
|
|
126
|
+
const limits = (0, composition_limits_1.getCompositionLimits)({
|
|
127
|
+
price: PRICE,
|
|
128
|
+
categories: [adults, { ...kids, max: 2 }],
|
|
129
|
+
composition: { adult: 2, kid: 1 },
|
|
130
|
+
maxPlayersLimit: 8,
|
|
131
|
+
});
|
|
132
|
+
expect(limits.maxByCategory.kid).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('состав выше сетки после смены вариации', () => {
|
|
136
|
+
it('опускается до нового потолка за счёт базовой категории', () => {
|
|
137
|
+
expect((0, composition_limits_1.clampCompositionToGrid)({
|
|
138
|
+
price: { 2: 9800, 3: 12000 },
|
|
139
|
+
categories: [adults, teens],
|
|
140
|
+
composition: { adult: 4, teen: 1 },
|
|
141
|
+
})).toEqual({ adult: 2, teen: 1 });
|
|
142
|
+
});
|
|
143
|
+
it('состав внутри сетки не трогается — даже на закрытом уровне', () => {
|
|
144
|
+
expect((0, composition_limits_1.clampCompositionToGrid)({
|
|
145
|
+
price: PRICE_WITH_HOLE,
|
|
146
|
+
categories: [adults, teens],
|
|
147
|
+
composition: { adult: 2, teen: 1 },
|
|
148
|
+
})).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
it('не опускает базовую категорию ниже нуля', () => {
|
|
151
|
+
expect((0, composition_limits_1.clampCompositionToGrid)({
|
|
152
|
+
price: { 2: 9800 },
|
|
153
|
+
categories: [adults, teens],
|
|
154
|
+
composition: { adult: 0, teen: 5 },
|
|
155
|
+
})).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { TariffReducedRateModeEnum } from '@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum';
|
|
2
|
+
import { OrderComposition, TariffCategory } from '@escapenavigator/types/dist/tariff/tariff-category';
|
|
3
|
+
/**
|
|
4
|
+
* Расчёт цены игры по составу, разложенному на категории участников.
|
|
5
|
+
*
|
|
6
|
+
* Категория — не абсолютный билет, а отношение к базовой доле группы
|
|
7
|
+
* (`price[уровень] / уровень`). Так сохраняется нелинейная сетка квестов:
|
|
8
|
+
* гость всегда дешевле в большой команде, и повышение цен не требует
|
|
9
|
+
* переписывать льготные колонки.
|
|
10
|
+
*
|
|
11
|
+
* Модуль заменяет `get-players-price` + `reduced-price` как источник истины;
|
|
12
|
+
* прежние поля (`players` / `children` / `reducedPlayers`) выражаются через
|
|
13
|
+
* категории адаптерами ниже и остаются в API ради не обновлённых клиентов.
|
|
14
|
+
*/
|
|
15
|
+
/** Конфиг тарифа в терминах, от которых зависит цена игры. */
|
|
16
|
+
export type CompositionPricingConfig = {
|
|
17
|
+
price?: Record<string, number> | null;
|
|
18
|
+
/** Пусто — категории собираются из легаси-полей (`legacy` ниже). */
|
|
19
|
+
categories?: TariffCategory[] | null;
|
|
20
|
+
minGameAmount?: number | null;
|
|
21
|
+
/** Легаси-описание состава — используется, только если `categories` пуст. */
|
|
22
|
+
legacy?: {
|
|
23
|
+
reducedRateMode?: TariffReducedRateModeEnum | null;
|
|
24
|
+
reducedRate?: number | null;
|
|
25
|
+
reducedTitle?: string | null;
|
|
26
|
+
reducedPriceDescription?: string | null;
|
|
27
|
+
childPriceDescription?: string | null;
|
|
28
|
+
} | null;
|
|
29
|
+
};
|
|
30
|
+
export type CompositionPriceSource = CompositionPricingConfig & {
|
|
31
|
+
composition?: OrderComposition | null;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Базовая категория тарифа: полная доля групповой цены. Ровно одна на тариф —
|
|
35
|
+
* именно она задаёт «цену за человека», от которой считаются остальные.
|
|
36
|
+
*/
|
|
37
|
+
export declare const baseCategory: (categories: TariffCategory[]) => TariffCategory | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Категории тарифа, собранные из легаси-полей. Нужны, пока не все тарифы
|
|
40
|
+
* прошли бэкфилл и пока старые клиенты присылают тариф без `categories`.
|
|
41
|
+
*
|
|
42
|
+
* Соответствие точное, поэтому цена не меняется: льготная ставка — это
|
|
43
|
+
* категория, занимающая место в составе, а детская — надбавка сверх группы.
|
|
44
|
+
*/
|
|
45
|
+
export declare const categoriesFromLegacy: (config: CompositionPricingConfig) => TariffCategory[];
|
|
46
|
+
/**
|
|
47
|
+
* Категории, по которым считать цену: собственные, если тариф уже переведён,
|
|
48
|
+
* иначе собранные из легаси-полей. Без базовой категории список бессмысленен
|
|
49
|
+
* (не от чего считать доли) — такой тоже уходит в легаси-ветку.
|
|
50
|
+
*/
|
|
51
|
+
export declare const resolveCategories: (config: CompositionPricingConfig) => TariffCategory[];
|
|
52
|
+
/** Нормализованный состав: целые неотрицательные числа, лишние ключи выброшены. */
|
|
53
|
+
export declare const normalizeComposition: (composition: OrderComposition | null | undefined, categories: TariffCategory[]) => OrderComposition;
|
|
54
|
+
/** Гостей всего: и те, что занимают место в сетке, и надбавочные. */
|
|
55
|
+
export declare const getTotalHeads: (composition: OrderComposition | null | undefined, categories: TariffCategory[]) => number;
|
|
56
|
+
/** Гостей, определяющих уровень сетки `price[N]`. */
|
|
57
|
+
export declare const getPricingHeads: (composition: OrderComposition | null | undefined, categories: TariffCategory[]) => number;
|
|
58
|
+
/** Групповая цена уровня. 0 — состав недоступен для брони. */
|
|
59
|
+
export declare const getLevelAmount: (price: Record<string, number> | null | undefined, heads: number) => number;
|
|
60
|
+
/**
|
|
61
|
+
* Доля одного гостя в групповой цене. Не округляем: остаток от неровного
|
|
62
|
+
* деления (108 / 3) уходит в скидку места, а групповой итог остаётся ровно
|
|
63
|
+
* тем, что стоит в прайсе.
|
|
64
|
+
*/
|
|
65
|
+
export declare const getPerPersonAmount: (price: Record<string, number> | null | undefined, heads: number) => number;
|
|
66
|
+
/**
|
|
67
|
+
* Скидка одного гостя категории от базовой доли.
|
|
68
|
+
*
|
|
69
|
+
* Считаем именно скидку, а не цену гостя: групповой итог тогда остаётся
|
|
70
|
+
* суммой из прайса, а неровное деление не размазывается копейками по головам.
|
|
71
|
+
*/
|
|
72
|
+
export declare const getCategorySeatDiscount: (category: TariffCategory, perPerson: number) => number;
|
|
73
|
+
/** Цена одного гостя категории при текущем составе. */
|
|
74
|
+
export declare const getCategorySeatPrice: (category: TariffCategory, perPerson: number) => number;
|
|
75
|
+
/**
|
|
76
|
+
* Цена игры за состав.
|
|
77
|
+
*
|
|
78
|
+
* Сумма = цена уровня по головам, занимающим место, минус скидки этих голов,
|
|
79
|
+
* плюс надбавочные категории, и всё это не ниже минимальной стоимости игры.
|
|
80
|
+
*/
|
|
81
|
+
export declare const getCompositionPrice: (source: CompositionPriceSource) => number;
|
|
82
|
+
/**
|
|
83
|
+
* Сколько гостей категории ещё снижают цену, не упираясь в пол выручки.
|
|
84
|
+
* Это не лимит выбора — отметить можно больше, цена просто перестанет падать.
|
|
85
|
+
*/
|
|
86
|
+
export declare const getMaxDiscountedSeats: (source: CompositionPriceSource, categoryKey: string) => number;
|
|
87
|
+
/**
|
|
88
|
+
* Состав по категориям из полей старого формата.
|
|
89
|
+
*
|
|
90
|
+
* `reducedPlayers` — это часть `players`, поэтому базовой категории достаётся
|
|
91
|
+
* остаток. Обратное преобразование — `legacyCountsFromComposition`.
|
|
92
|
+
*/
|
|
93
|
+
export declare const compositionFromLegacy: (legacyCounts: {
|
|
94
|
+
players?: number | null;
|
|
95
|
+
children?: number | null;
|
|
96
|
+
reducedPlayers?: number | null;
|
|
97
|
+
}, categories: TariffCategory[]) => OrderComposition;
|
|
98
|
+
/**
|
|
99
|
+
* Легаси-поля тарифа, восстановленные из категорий. Обратная операция к
|
|
100
|
+
* `categoriesFromLegacy`: венью правит тариф уже категориями, а мобильное
|
|
101
|
+
* приложение и не обновлённые интеграции продолжают читать `price.child` и
|
|
102
|
+
* `reduced*` — без этой проекции они бы застыли на значениях до перехода.
|
|
103
|
+
*
|
|
104
|
+
* Схлопывание с потерями и это нормально: легаси-формат умеет ровно одну
|
|
105
|
+
* льготную категорию и ровно одну надбавочную. Берём первые — цена там, где
|
|
106
|
+
* есть `categories`, всё равно считается по ним.
|
|
107
|
+
*/
|
|
108
|
+
export declare const legacyFromCategories: (categories: TariffCategory[], price?: Record<string, number> | null) => {
|
|
109
|
+
childPrice: number | null;
|
|
110
|
+
childPriceDescription: string | null;
|
|
111
|
+
reducedRateMode: TariffReducedRateModeEnum | null;
|
|
112
|
+
reducedRate: number;
|
|
113
|
+
reducedTitle: string | null;
|
|
114
|
+
reducedPriceDescription: string | null;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Поля старого формата из состава по категориям. Нужны, пока их читают не
|
|
118
|
+
* обновлённые клиенты (мобильное приложение) и вся отчётность по броням.
|
|
119
|
+
*
|
|
120
|
+
* На тарифе с несколькими льготными категориями `reducedPlayers` схлопывает
|
|
121
|
+
* их в одно число: восстановить цену из легаси-полей в таком случае нельзя,
|
|
122
|
+
* источник истины — `composition` и снимок категорий на брони.
|
|
123
|
+
*/
|
|
124
|
+
export declare const legacyCountsFromComposition: (composition: OrderComposition | null | undefined, categories: TariffCategory[]) => {
|
|
125
|
+
players: number;
|
|
126
|
+
children: number;
|
|
127
|
+
reducedPlayers: number;
|
|
128
|
+
};
|
|
@@ -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
|
+
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { OrderComposition, TariffCategory } from '@escapenavigator/types/dist/tariff/tariff-category';
|
|
1
2
|
import { ReducedPricingConfig } from './reduced-price';
|
|
2
3
|
/**
|
|
3
|
-
* Источник цены игры. Кроме прайс-карты и состава принимает снимок
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Источник цены игры. Кроме прайс-карты и состава принимает снимок условий
|
|
5
|
+
* тарифа — у `OrderEntity` эти поля есть, поэтому заказы получают правильную
|
|
6
|
+
* цену без правок на каждом вызове.
|
|
7
|
+
*
|
|
8
|
+
* Состав задаётся либо категориями (`composition` + `categories`), либо
|
|
9
|
+
* полями старого формата. Второе оставлено ради не обновлённых клиентов:
|
|
10
|
+
* внутри всё равно считает один движок — `composition-price`.
|
|
6
11
|
*/
|
|
7
12
|
export type GamePriceSource = ReducedPricingConfig & {
|
|
8
13
|
price: Record<string, number>;
|
|
@@ -10,6 +15,13 @@ export type GamePriceSource = ReducedPricingConfig & {
|
|
|
10
15
|
children: number;
|
|
11
16
|
/** Сколько мест в составе идёт по льготной ставке (школьники и т.п.). */
|
|
12
17
|
reducedPlayers?: number | null;
|
|
18
|
+
/** Категории участников тарифа (или их снимок на брони). */
|
|
19
|
+
categories?: TariffCategory[] | null;
|
|
20
|
+
/** Состав по категориям. Приоритетнее полей старого формата. */
|
|
21
|
+
composition?: OrderComposition | null;
|
|
22
|
+
reducedTitle?: string | null;
|
|
23
|
+
reducedPriceDescription?: string | null;
|
|
24
|
+
childPriceDescription?: string | null;
|
|
13
25
|
};
|
|
14
26
|
export declare const getPlayersPrice: (order: GamePriceSource) => number;
|
|
15
27
|
/**
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getMaxGameDiscount = exports.getPlayersPrice = void 0;
|
|
4
|
+
const composition_price_1 = require("./composition-price");
|
|
4
5
|
const reduced_price_1 = require("./reduced-price");
|
|
6
|
+
/** Приводит источник любого поколения к аргументам движка категорий. */
|
|
7
|
+
const toCompositionSource = (order) => {
|
|
8
|
+
const config = {
|
|
9
|
+
price: order.price,
|
|
10
|
+
categories: order.categories,
|
|
11
|
+
minGameAmount: order.minGameAmount,
|
|
12
|
+
legacy: {
|
|
13
|
+
reducedRateMode: order.reducedRateMode,
|
|
14
|
+
reducedRate: order.reducedRate,
|
|
15
|
+
reducedTitle: order.reducedTitle,
|
|
16
|
+
reducedPriceDescription: order.reducedPriceDescription,
|
|
17
|
+
childPriceDescription: order.childPriceDescription,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const categories = (0, composition_price_1.resolveCategories)(config);
|
|
21
|
+
const hasComposition = !!order.composition && Object.keys(order.composition).length > 0;
|
|
22
|
+
return {
|
|
23
|
+
...config,
|
|
24
|
+
categories,
|
|
25
|
+
composition: hasComposition
|
|
26
|
+
? order.composition
|
|
27
|
+
: (0, composition_price_1.compositionFromLegacy)(order, categories),
|
|
28
|
+
};
|
|
29
|
+
};
|
|
5
30
|
const getPlayersPrice = (order) => {
|
|
6
31
|
if (!order?.price)
|
|
7
32
|
return 0;
|
|
8
|
-
|
|
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);
|
|
33
|
+
return (0, composition_price_1.getCompositionPrice)(toCompositionSource(order));
|
|
23
34
|
};
|
|
24
35
|
exports.getPlayersPrice = getPlayersPrice;
|
|
25
36
|
/**
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { PromocodeTypeEnum } from '@escapenavigator/types/dist/promocode/emun/promocode-type.enum';
|
|
2
|
+
import { OrderComposition, TariffCategory } from '@escapenavigator/types/dist/tariff/tariff-category';
|
|
2
3
|
import { ReducedPricingConfig } from './reduced-price';
|
|
3
|
-
export declare const getPromocodeDiscount: ({ slotId, slotDiscount, slotDiscountAmount, total, promocodeType, promocodeDiscount, price, players, children, reducedPlayers, reducedRateMode, reducedRate, minGameAmount, }: ReducedPricingConfig & {
|
|
4
|
+
export declare const getPromocodeDiscount: ({ slotId, slotDiscount, slotDiscountAmount, total, promocodeType, promocodeDiscount, price, players, children, reducedPlayers, composition, categories, reducedRateMode, reducedRate, minGameAmount, }: ReducedPricingConfig & {
|
|
4
5
|
slotId: number;
|
|
5
6
|
children?: number;
|
|
6
7
|
total: number;
|
|
7
8
|
players: number;
|
|
8
9
|
/** Сколько мест идёт по льготной ставке (снимок с заказа). */
|
|
9
10
|
reducedPlayers?: number | null;
|
|
11
|
+
/** Состав по категориям. Приоритетнее полей выше. */
|
|
12
|
+
composition?: OrderComposition | null;
|
|
13
|
+
/** Категории тарифа (или их снимок на брони). */
|
|
14
|
+
categories?: TariffCategory[] | null;
|
|
10
15
|
/**
|
|
11
16
|
* Легаси-параметр: часть вызовов передаёт сумму, часть — ставку в bps.
|
|
12
17
|
* Используется только процентными типами (вычитание из total).
|
|
@@ -4,13 +4,15 @@ 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
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
|
+
const getPromocodeDiscount = ({ slotId, slotDiscount, slotDiscountAmount = 0, total, promocodeType, promocodeDiscount, price, players, children = 0, reducedPlayers = 0, composition, categories, reducedRateMode = null, reducedRate = 0, minGameAmount = 0, }) => {
|
|
8
8
|
const orderTotal = slotId ? total - slotDiscount : total;
|
|
9
9
|
const game = {
|
|
10
10
|
price,
|
|
11
11
|
players,
|
|
12
12
|
children,
|
|
13
13
|
reducedPlayers,
|
|
14
|
+
composition,
|
|
15
|
+
categories,
|
|
14
16
|
reducedRateMode,
|
|
15
17
|
reducedRate,
|
|
16
18
|
minGameAmount,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escapenavigator/utils",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.50",
|
|
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.
|
|
17
|
+
"@escapenavigator/types": "^2.0.48",
|
|
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": "
|
|
32
|
+
"gitHead": "480174116c4a107d2db7e0a4ae92fd0b4e0c63e5"
|
|
33
33
|
}
|