@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,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,148 @@
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 hasExtraCategories: (categories: TariffCategory[] | null | undefined) => boolean;
52
+ /**
53
+ * Дописывает в уже сохранённые категории те строки, которые ещё живут только
54
+ * в легаси-полях (`price.child`, `reduced*`).
55
+ *
56
+ * Нужно рефилу: тариф, который успели открыть в новом редакторе, получил
57
+ * `[BASE]` и больше не попадает в «пустой список», хотя детская цена на нём
58
+ * ещё есть. Идемпотентен: уже перенесённые детская/льгота не дублируются.
59
+ */
60
+ export declare const mergeLegacyCategories: (existing: TariffCategory[] | null | undefined, config: CompositionPricingConfig) => TariffCategory[];
61
+ /**
62
+ * Категории, по которым считать цену: собственные, если тариф уже переведён,
63
+ * иначе собранные из легаси-полей. Без базовой категории список бессмысленен
64
+ * (не от чего считать доли) — такой тоже уходит в легаси-ветку.
65
+ */
66
+ export declare const resolveCategories: (config: CompositionPricingConfig) => TariffCategory[];
67
+ /**
68
+ * «2 взрослых, 3 студента» — подпись под общим числом человек, когда в тарифе
69
+ * есть льготные или надбавочные строки. Без них раскладывать нечего.
70
+ */
71
+ export declare const formatCompositionBreakdown: (composition: OrderComposition | null | undefined, categories: TariffCategory[] | null | undefined) => string;
72
+ /** Нормализованный состав: целые неотрицательные числа, лишние ключи выброшены. */
73
+ export declare const normalizeComposition: (composition: OrderComposition | null | undefined, categories: TariffCategory[]) => OrderComposition;
74
+ /** Гостей всего: и те, что занимают место в сетке, и надбавочные. */
75
+ export declare const getTotalHeads: (composition: OrderComposition | null | undefined, categories: TariffCategory[]) => number;
76
+ /** Гостей, определяющих уровень сетки `price[N]`. */
77
+ export declare const getPricingHeads: (composition: OrderComposition | null | undefined, categories: TariffCategory[]) => number;
78
+ /** Групповая цена уровня. 0 — состав недоступен для брони. */
79
+ export declare const getLevelAmount: (price: Record<string, number> | null | undefined, heads: number) => number;
80
+ /**
81
+ * Доля одного гостя в групповой цене. Не округляем: остаток от неровного
82
+ * деления (108 / 3) уходит в скидку места, а групповой итог остаётся ровно
83
+ * тем, что стоит в прайсе.
84
+ */
85
+ export declare const getPerPersonAmount: (price: Record<string, number> | null | undefined, heads: number) => number;
86
+ /**
87
+ * Скидка одного гостя категории от базовой доли.
88
+ *
89
+ * Считаем именно скидку, а не цену гостя: групповой итог тогда остаётся
90
+ * суммой из прайса, а неровное деление не размазывается копейками по головам.
91
+ */
92
+ export declare const getCategorySeatDiscount: (category: TariffCategory, perPerson: number) => number;
93
+ /** Цена одного гостя категории при текущем составе. */
94
+ export declare const getCategorySeatPrice: (category: TariffCategory, perPerson: number) => number;
95
+ /**
96
+ * Цена игры за состав.
97
+ *
98
+ * Сумма = цена уровня по головам, занимающим место, минус скидки этих голов,
99
+ * плюс надбавочные категории, и всё это не ниже минимальной стоимости игры.
100
+ */
101
+ export declare const getCompositionPrice: (source: CompositionPriceSource) => number;
102
+ /**
103
+ * Сколько гостей категории ещё снижают цену, не упираясь в пол выручки.
104
+ * Это не лимит выбора — отметить можно больше, цена просто перестанет падать.
105
+ */
106
+ export declare const getMaxDiscountedSeats: (source: CompositionPriceSource, categoryKey: string) => number;
107
+ /**
108
+ * Состав по категориям из полей старого формата.
109
+ *
110
+ * `reducedPlayers` — это часть `players`, поэтому базовой категории достаётся
111
+ * остаток. Обратное преобразование — `legacyCountsFromComposition`.
112
+ */
113
+ export declare const compositionFromLegacy: (legacyCounts: {
114
+ players?: number | null;
115
+ children?: number | null;
116
+ reducedPlayers?: number | null;
117
+ }, categories: TariffCategory[]) => OrderComposition;
118
+ /**
119
+ * Легаси-поля тарифа, восстановленные из категорий. Обратная операция к
120
+ * `categoriesFromLegacy`: венью правит тариф уже категориями, а мобильное
121
+ * приложение и не обновлённые интеграции продолжают читать `price.child` и
122
+ * `reduced*` — без этой проекции они бы застыли на значениях до перехода.
123
+ *
124
+ * Схлопывание с потерями и это нормально: легаси-формат умеет ровно одну
125
+ * льготную категорию и ровно одну надбавочную. Берём первые — цена там, где
126
+ * есть `categories`, всё равно считается по ним.
127
+ */
128
+ export declare const legacyFromCategories: (categories: TariffCategory[], price?: Record<string, number> | null) => {
129
+ childPrice: number | null;
130
+ childPriceDescription: string | null;
131
+ reducedRateMode: TariffReducedRateModeEnum | null;
132
+ reducedRate: number;
133
+ reducedTitle: string | null;
134
+ reducedPriceDescription: string | null;
135
+ };
136
+ /**
137
+ * Поля старого формата из состава по категориям. Нужны, пока их читают не
138
+ * обновлённые клиенты (мобильное приложение) и вся отчётность по броням.
139
+ *
140
+ * На тарифе с несколькими льготными категориями `reducedPlayers` схлопывает
141
+ * их в одно число: восстановить цену из легаси-полей в таком случае нельзя,
142
+ * источник истины — `composition` и снимок категорий на брони.
143
+ */
144
+ export declare const legacyCountsFromComposition: (composition: OrderComposition | null | undefined, categories: TariffCategory[]) => {
145
+ players: number;
146
+ children: number;
147
+ reducedPlayers: number;
148
+ };