Fairmas
Fairmas звіти
Обидва файли Fairmas-звіту рахуються одним методом BuildFairmasReport — логіка підрахунку ідентична, відрізняються лише період і набір даних. Наслідок: будь-яка помилка в метриці автоматично присутня в обох файлах.
Джерела даних
- Guests — у звіт беруться лише статуси Booking / Living / Departured;
- Payments — беруться записи з
!p.v_IsCanceled; - St.Rooms — зріз стану номерів на конкретну дату, агрегат по типах номерів. Дає місткість (скільки всього / на ремонті / у продажу);
- St.RoomsGuests — деталізація до рівня «конкретна бронь — номер — ніч». Наразі місткість для RoomSold та RoomArrival береться саме з St.RoomsGuests.
Алгоритм розрахунку (4 кроки для кожного дня)
Крок 1. Місткість. Складаємо по всіх типах номерів цієї дати:
TotalRooms= сумаRoomsInExploitation(весь фонд в експлуатації, не залежить від броней);OOO= сумаRoomsOnRepairNotSale(на ремонті, продати не можна);In-sale (TotalRoomsAvailable)= TotalRooms − OOO;OOS= 0 (хардкод).
Крок 2. Розкладка оплат. Проходимо всі оплати дня. Платить гість — використовується його сегмент; платить не гість — retail_direct:
- dwelling → Logis (проживання);
- eat → F&B (харчування);
- citytax → міський податок → викидається повністю (не є виручкою готелю);
- решта → Other.
У CSV колонки виручки вже за вирахуванням податків.
Крок 3. Режими сегментації. Є два режими:
retail_direct/retail_indirect;BySource— скільки різних джерел фігурувало (Online reservation, YandexTravel…), стільки й рядків-сегментів.
Крок 4. Номерні показники.
RoomsSold= округл(ΣCoefficientGuestRoomsOccupied) по записах гостей сегмента;RoomsArrival= округл(Σv_CoefficientGuestRoomsOccupiedArrival+v_CoefficientGuestRoomsOccupiedEarlyArrival) по записах із заїздом — баг (CoefficientGuestRoomsOccupiedArrival вже містить CoefficientGuestRoomsOccupiedEarlyArrival);Guests= Σ (Client_Count + Child_Count) по всіх записах сегмента;GuestsArrival= те саме, але лише по записах із заїздом.
Округлення використовує ToEven (1.5 → 2, 4.5 → 4).
Коефіцієнти st_RoomsGuests
Уся арифметика номерів стоїть на трьох коефіцієнтах із st_RoomsGuests:
- Occupied — вага зайнятості номеро-доби (гість 6 = 1.0 — повна ніч; гість 40 = 1.5 — ніч + ранній заїзд);
- Arrival — вага заїзду; вже містить ранній заїзд (у гостя 40 = 1.5);
- EarlyArrival — ранній заїзд окремим полем.
Ключове: у St.Rooms заїзд рахується цілими штуками, у St.RoomsGuests — дробом.
Приклад коефіцієнтів (на даних 09.08):
| GuestID | RoomID | Occupied | Arrival | EarlyArrival |
|---|---|---|---|---|
| 40 | 96 | 1.5000 | 1.5000 | 0.5000 |
| 6 | 156 | 1.0000 | 1.0000 | 0.0000 |
Баг №1 — задвоєння заїзду
Формула в коді: Sum(Arrival + EarlyArrival). Але EarlyArrival уже входить в Arrival, тож ранній заїзд рахується двічі:
- гість 40: 1.5 + 0.5 = 2.0;
- гість 6: 1.0 + 0.0 = 1.0;
- разом 3.0 → 3 (невірно).
Правильно — лише Arrival: 1.5 + 1.0 = 2.5 → 2.
Проявляється на бронях із раннім заїздом.
Прибрати + EarlyArrival закриває тикет (09.08 → 2), але не лікує корінь: ми й далі складаємо дроби й округлюємо. Два ранні заїзди по 1.5 дадуть 3 замість 2 номерів.
Баг №2 — порожні рядки за джерелом
Механізм: збіг двох незалежних фактів.
Факт 1. Виїжджаючий гість потрапляє у вибірку дня свого виїзду. Дати броні (First_Date, Last_Date) зберігаються з часом (заїзд о 14:00, виїзд о 12:00), але код порівнює їх через .Date, тобто час відкидається. Гість, що виїжджає 09.08 о 12:00, має Last_Date.Date = 09.08. Фільтр «чи живе він 09.08» (First_Date.Date <= day && Last_Date.Date >= day) дає true. Таким чином гість вважається присутнім у день свого виїзду, хоча фактично провів лише ранок.
Факт 2. Зайнятості в день виїзду немає. Ніч не зайнята, тому запису в st_RoomsGuests на цю дату не існує (записів із нульовим коефіцієнтом зайнятості в таблиці немає взагалі — це перевірено на даних). Сума коефіцієнтів зайнятості по порожньому набору дорівнює нулю.
Результат. Гість потрапляє у список гостей дня, робить свій сегмент непорожнім, і його джерело потрапляє у набір сегментів режиму BySource. Для сегмента заводиться рядок, але зайнятість і заїзд у ньому дорівнюють нулю. Отримуємо рядок, у якому назва джерела є, а всі номерні показники нульові.
Чому саме режим BySource це виявив. У старому режимі DirectIndirect сегментів лише два, і «зайвий» виїжджаючий гість вливається у retail_direct або retail_indirect та губиться у загальній масі. Режим BySource виносить кожне джерело окремим рядком, тому порожній хвіст стає видимим окремою нульовою стрічкою.
Де саме дірка в коді. Перед записом рядка сегмента стоїть відсічка:
if (!segmentGuests.Any() && segmentRevs.Logis == 0
&& segmentRevs.FaB == 0 && segmentRevs.Other == 0)
continue;
Вона пропускає рядок лише тоді, коли одночасно немає гостей і вся виручка дорівнює нулю. Проблема в тому, що умова segmentGuests.Any() перевіряє наявність гостей у списку, а перевіряти треба реальну зайнятість (roomsOccupied > 0). Але roomsOccupied обчислюється нижче за кодом, вже після continue, тому відсічка фізично не може його врахувати.
Спільний корінь обох багів
Баги різні за природою: баг №1 — це складання дробових коефіцієнтів, баг №2 — це відсічка за наявністю гостей до фактичного розрахунку зайнятості. Але обидва виростають з однієї хиби методу: звіт рахує номери сумою дробових ваг і покладається на присутність гостя у списку, а не на реальну зайнятість чи заїзд у штуках.
Правильний напрям — рахувати кількість номерів як подій, тобто скільки окремих записів заїзду або зайнятості має сегмент за день, а не суму коефіцієнтів. Тоді округлення зникає (результат одразу цілий), а співвідношення «RoomsArrival не більше за RoomsSold» тримається саме собою.