Add data generation scripts and public holidays service
- Add scripts for generating booking data, building metadata, and updating holidays - Implement public_holidays.py service with holiday lookup functionality - Update CSV data with expanded hotel booking records - Add holidays.json dataset for public holiday dates - Enhance LLM report service with improved formatting - Update metadata domain model and API dependencies - Add test coverage for dataset and public holidays
This commit is contained in:
@@ -15,6 +15,7 @@ src/nf_hotel_api/
|
||||
services/ # Business logic
|
||||
cleaning.py # DataCleaningService - wrong format / empty cells / wrong data / duplicates / pricing
|
||||
statistics.py # DescriptiveStatsService - df.describe() (booking_id dropped)
|
||||
public_holidays.py # PublicHolidayCalendar - Cambodian holidays from the `holidays` package
|
||||
llm_report.py # LLMReportService - calls the local LLM, strips <think> blocks
|
||||
report.py # ReportService - orchestrates the above use case
|
||||
api/ # FastAPI routers, DI wiring, HTTP-only concerns
|
||||
@@ -140,21 +141,16 @@ Reference data that is not part of the bookings lives in
|
||||
[data/hotel_metadata.json](data/hotel_metadata.json) (path set by
|
||||
`METADATA_PATH`, loaded by `JsonHotelMetadataRepository`):
|
||||
|
||||
```json
|
||||
{
|
||||
"hotel": "NF Hotel",
|
||||
"currency": "USD",
|
||||
"room_types": {
|
||||
"A": { "size": "Small", "standard_price_per_night": 20, "room_count": null },
|
||||
"B": { "size": "Large", "standard_price_per_night": 25, "room_count": null }
|
||||
}
|
||||
}
|
||||
```
|
||||
| Field | Content |
|
||||
|---|---|
|
||||
| `hotel`, `address`, `currency` | NF Hotel, Street 172, Phnom Penh, Cambodia; prices in USD |
|
||||
| `room_types` | per `assigned_room_type`: size, standard price per night, number of rooms |
|
||||
| `nearby_events` | events in Phnom Penh 2022-2025 (name, dates, venue, `source_url`) |
|
||||
|
||||
| `assigned_room_type` | `room_size` | Standard price | Rooms in hotel |
|
||||
|---|---|---|---|
|
||||
| `A` | Small | $20 | *to be filled in* |
|
||||
| `B` | Large | $25 | *to be filled in* |
|
||||
| `A` | Small | $20 | 10 |
|
||||
| `B` | Large | $25 | 10 |
|
||||
|
||||
- **`prize_per_nigth` in the CSV is the price the customer actually paid** and
|
||||
is never overwritten. Only a missing or negative value is replaced with the
|
||||
@@ -164,13 +160,90 @@ Reference data that is not part of the bookings lives in
|
||||
- `revenue` = (`stays_in_weekend_nights` + `stays_in_week_nights`) x
|
||||
`prize_per_nigth` for non-cancelled bookings, `0` for cancelled ones.
|
||||
- `room_size`, `prize_per_nigth` and `revenue` are part of the descriptive
|
||||
statistics sent to the LLM, and the prompt includes the room catalogue
|
||||
(standard prices and room counts) from the metadata file.
|
||||
- `room_count` is `null` until the real number of rooms is known. Occupancy
|
||||
rate needs it, so it is not calculated yet. Events are also still missing.
|
||||
statistics sent to the LLM. The prompt also includes the metadata (address,
|
||||
room types with prices and room counts, nearby events) and the public
|
||||
holidays for the years in the data.
|
||||
|
||||
To change a standard price or set a room count, edit `data/hotel_metadata.json`
|
||||
and restart the service (it is read once at startup).
|
||||
To change a price, a room count, a holiday or an event, edit
|
||||
`data/hotel_metadata.json` and restart the service (it is read once at
|
||||
startup). `scripts/build_metadata.py` is the helper that produced the file.
|
||||
|
||||
### Public holidays
|
||||
|
||||
Public holidays are **not stored in the hotel metadata**. They are looked up at
|
||||
run time in the Python [`holidays`](https://pypi.org/project/holidays/) package
|
||||
(country `KH`, Cambodia) by `PublicHolidayCalendar`
|
||||
([services/public_holidays.py](src/nf_hotel_api/services/public_holidays.py)).
|
||||
The LLM prompt lists the holidays for the years the bookings' arrival dates
|
||||
span, and the dataset generator uses them to shape demand.
|
||||
|
||||
[data/holidays.json](data/holidays.json) is a generated **export** of that
|
||||
calendar (holiday name, start and end date, with the package version it came
|
||||
from) for anyone who wants to read the holidays without running Python. The
|
||||
application does not read it; the package stays the source of truth. Refresh it
|
||||
after upgrading the package or when a new year starts:
|
||||
|
||||
```bash
|
||||
.venv/Scripts/python scripts/update_holidays.py
|
||||
```
|
||||
|
||||
By default it covers 2022 up to next year; use `--first-year` / `--last-year`
|
||||
to change that. `tests/test_public_holidays.py` fails when the file no longer
|
||||
matches the package, which is the signal to re-run the script.
|
||||
|
||||
### Nearby events
|
||||
|
||||
Looked up on the web; each entry carries its `source_url`. All venues are in
|
||||
Phnom Penh, but the distance to Street 172 was **not measured**, so the
|
||||
20 km radius is an assumption based on the venues being in the city.
|
||||
|
||||
| Event | Date | Venue |
|
||||
|---|---|---|
|
||||
| 40th and 41st ASEAN Summits | 10-13 Nov 2022 | Phnom Penh |
|
||||
| 2023 SEA Games | 5-17 May 2023 | Morodok Techo Sports Complex, Olympic Sports Complex, Chroy Changvar Convention Centre |
|
||||
| 12th ASEAN Para Games | 3-9 Jun 2023 | Morodok Techo National Stadium |
|
||||
| Phnom Penh International Half Marathon | 11 Jun 2023, 16 Jun 2024, 15 Jun 2025 | Phnom Penh |
|
||||
| Miss Grand Cambodia 2024 final | 12 Jul 2024 | Koh Pich Theater |
|
||||
| CAMFOOD & CAMHOTEL 2024 | 6-8 Nov 2024 | Diamond Island Convention & Exhibition Center |
|
||||
| Cambodia ASEAN Business Summit 2025 | 6 Mar 2025 | Sofitel Phnom Penh Phokeetra |
|
||||
| 2025 AFC Challenge League final | 10 May 2025 | Phnom Penh |
|
||||
| Mekong Forum 2025 | 30-31 Jul 2025 | Shangri-La Hotel Phnom Penh |
|
||||
| CamboP&ELight 2025 | 6-9 Aug 2025 | Diamond Island Convention & Exhibition Center |
|
||||
| Phnom Penh Design Festival 2025 | 31 Oct - 2 Nov 2025 | Factory Phnom Penh |
|
||||
|
||||
The list is not exhaustive (2022 and 2024 in particular have few entries).
|
||||
|
||||
## The dataset (`data/nf_hotel_bookings.csv`)
|
||||
|
||||
**The bookings are synthetic, not real reservations.** They are generated by
|
||||
[scripts/generate_bookings.py](scripts/generate_bookings.py) (fixed seed, so the
|
||||
file is reproducible) to fit this hotel:
|
||||
|
||||
- Arrivals 1 Jan 2022 - 31 Dec 2025, about 8,500 rows.
|
||||
- The hotel has only 10 small and 10 large rooms. The simulation never sells
|
||||
more rooms of a type than exist on any night; requests for a sold-out night
|
||||
are turned away and do not appear in the file. `tests/test_dataset.py`
|
||||
checks this.
|
||||
- Demand follows the calendar in the metadata: it is higher on Fridays and
|
||||
Saturdays and in the cool season, much higher during big events (SEA Games,
|
||||
ASEAN Summit) and the Water Festival, and lower during Khmer New Year and
|
||||
Pchum Ben, when people leave the capital. Average occupancy comes out around
|
||||
55-60 %, close to full during the biggest events.
|
||||
- `prize_per_nigth` is the price paid: the standard price with a discount for
|
||||
Corporate/Groups/Offline TA bookings and a surcharge on peak days.
|
||||
- About 2 % deliberately dirty rows (duplicates, impossible guest counts, blank
|
||||
meals) are added so `DataCleaningService` has real work to do.
|
||||
|
||||
All multipliers (holiday and event effects, price rules, guest mix) are
|
||||
**modelling assumptions**, not measurements. They are constants at the top of
|
||||
the script; change them and regenerate:
|
||||
|
||||
```bash
|
||||
.venv/Scripts/python scripts/generate_bookings.py
|
||||
```
|
||||
|
||||
(Close the CSV in Excel first, otherwise Windows blocks the write.) Replace the
|
||||
file with real bookings when they are available; nothing else needs to change.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
{
|
||||
"country": "KH",
|
||||
"source": "python-holidays 0.104",
|
||||
"first_year": 2022,
|
||||
"last_year": 2027,
|
||||
"holidays": [
|
||||
{
|
||||
"name": "International New Year Day",
|
||||
"start_date": "2022-01-01",
|
||||
"end_date": "2022-01-01"
|
||||
},
|
||||
{
|
||||
"name": "Day of Victory over the Genocidal Regime",
|
||||
"start_date": "2022-01-07",
|
||||
"end_date": "2022-01-07"
|
||||
},
|
||||
{
|
||||
"name": "International Women's Rights Day",
|
||||
"start_date": "2022-03-08",
|
||||
"end_date": "2022-03-08"
|
||||
},
|
||||
{
|
||||
"name": "Khmer New Year's Day",
|
||||
"start_date": "2022-04-14",
|
||||
"end_date": "2022-04-16"
|
||||
},
|
||||
{
|
||||
"name": "International Labor Day",
|
||||
"start_date": "2022-05-01",
|
||||
"end_date": "2022-05-01"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Birthday",
|
||||
"start_date": "2022-05-14",
|
||||
"end_date": "2022-05-14"
|
||||
},
|
||||
{
|
||||
"name": "Visaka Bochea Day",
|
||||
"start_date": "2022-05-15",
|
||||
"end_date": "2022-05-15"
|
||||
},
|
||||
{
|
||||
"name": "Royal Ploughing Ceremony",
|
||||
"start_date": "2022-05-19",
|
||||
"end_date": "2022-05-19"
|
||||
},
|
||||
{
|
||||
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
|
||||
"start_date": "2022-06-18",
|
||||
"end_date": "2022-06-18"
|
||||
},
|
||||
{
|
||||
"name": "Constitution Day; Pchum Ben Day",
|
||||
"start_date": "2022-09-24",
|
||||
"end_date": "2022-09-24"
|
||||
},
|
||||
{
|
||||
"name": "Pchum Ben Day",
|
||||
"start_date": "2022-09-25",
|
||||
"end_date": "2022-09-26"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihanouk Mourning Day",
|
||||
"start_date": "2022-10-15",
|
||||
"end_date": "2022-10-15"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Coronation Day",
|
||||
"start_date": "2022-10-29",
|
||||
"end_date": "2022-10-29"
|
||||
},
|
||||
{
|
||||
"name": "Water Festival",
|
||||
"start_date": "2022-11-07",
|
||||
"end_date": "2022-11-08"
|
||||
},
|
||||
{
|
||||
"name": "National Independence Day; Water Festival",
|
||||
"start_date": "2022-11-09",
|
||||
"end_date": "2022-11-09"
|
||||
},
|
||||
{
|
||||
"name": "International New Year Day",
|
||||
"start_date": "2023-01-01",
|
||||
"end_date": "2023-01-01"
|
||||
},
|
||||
{
|
||||
"name": "Day of Victory over the Genocidal Regime",
|
||||
"start_date": "2023-01-07",
|
||||
"end_date": "2023-01-07"
|
||||
},
|
||||
{
|
||||
"name": "International Women's Rights Day",
|
||||
"start_date": "2023-03-08",
|
||||
"end_date": "2023-03-08"
|
||||
},
|
||||
{
|
||||
"name": "Khmer New Year's Day",
|
||||
"start_date": "2023-04-14",
|
||||
"end_date": "2023-04-16"
|
||||
},
|
||||
{
|
||||
"name": "International Labor Day",
|
||||
"start_date": "2023-05-01",
|
||||
"end_date": "2023-05-01"
|
||||
},
|
||||
{
|
||||
"name": "Visaka Bochea Day",
|
||||
"start_date": "2023-05-04",
|
||||
"end_date": "2023-05-04"
|
||||
},
|
||||
{
|
||||
"name": "Royal Ploughing Ceremony",
|
||||
"start_date": "2023-05-08",
|
||||
"end_date": "2023-05-08"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Birthday",
|
||||
"start_date": "2023-05-14",
|
||||
"end_date": "2023-05-14"
|
||||
},
|
||||
{
|
||||
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
|
||||
"start_date": "2023-06-18",
|
||||
"end_date": "2023-06-18"
|
||||
},
|
||||
{
|
||||
"name": "Constitution Day",
|
||||
"start_date": "2023-09-24",
|
||||
"end_date": "2023-09-24"
|
||||
},
|
||||
{
|
||||
"name": "Pchum Ben Day",
|
||||
"start_date": "2023-10-13",
|
||||
"end_date": "2023-10-14"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihanouk Mourning Day; Pchum Ben Day",
|
||||
"start_date": "2023-10-15",
|
||||
"end_date": "2023-10-15"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Coronation Day",
|
||||
"start_date": "2023-10-29",
|
||||
"end_date": "2023-10-29"
|
||||
},
|
||||
{
|
||||
"name": "National Independence Day",
|
||||
"start_date": "2023-11-09",
|
||||
"end_date": "2023-11-09"
|
||||
},
|
||||
{
|
||||
"name": "Water Festival",
|
||||
"start_date": "2023-11-26",
|
||||
"end_date": "2023-11-28"
|
||||
},
|
||||
{
|
||||
"name": "International New Year Day",
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-01-01"
|
||||
},
|
||||
{
|
||||
"name": "Day of Victory over the Genocidal Regime",
|
||||
"start_date": "2024-01-07",
|
||||
"end_date": "2024-01-07"
|
||||
},
|
||||
{
|
||||
"name": "International Women's Rights Day",
|
||||
"start_date": "2024-03-08",
|
||||
"end_date": "2024-03-08"
|
||||
},
|
||||
{
|
||||
"name": "Khmer New Year's Day",
|
||||
"start_date": "2024-04-13",
|
||||
"end_date": "2024-04-16"
|
||||
},
|
||||
{
|
||||
"name": "International Labor Day",
|
||||
"start_date": "2024-05-01",
|
||||
"end_date": "2024-05-01"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Birthday",
|
||||
"start_date": "2024-05-14",
|
||||
"end_date": "2024-05-14"
|
||||
},
|
||||
{
|
||||
"name": "Visaka Bochea Day",
|
||||
"start_date": "2024-05-22",
|
||||
"end_date": "2024-05-22"
|
||||
},
|
||||
{
|
||||
"name": "Royal Ploughing Ceremony",
|
||||
"start_date": "2024-05-26",
|
||||
"end_date": "2024-05-26"
|
||||
},
|
||||
{
|
||||
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
|
||||
"start_date": "2024-06-18",
|
||||
"end_date": "2024-06-18"
|
||||
},
|
||||
{
|
||||
"name": "Constitution Day",
|
||||
"start_date": "2024-09-24",
|
||||
"end_date": "2024-09-24"
|
||||
},
|
||||
{
|
||||
"name": "Pchum Ben Day",
|
||||
"start_date": "2024-10-01",
|
||||
"end_date": "2024-10-03"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihanouk Mourning Day",
|
||||
"start_date": "2024-10-15",
|
||||
"end_date": "2024-10-15"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Coronation Day",
|
||||
"start_date": "2024-10-29",
|
||||
"end_date": "2024-10-29"
|
||||
},
|
||||
{
|
||||
"name": "National Independence Day",
|
||||
"start_date": "2024-11-09",
|
||||
"end_date": "2024-11-09"
|
||||
},
|
||||
{
|
||||
"name": "Water Festival",
|
||||
"start_date": "2024-11-14",
|
||||
"end_date": "2024-11-16"
|
||||
},
|
||||
{
|
||||
"name": "Peace Day in Cambodia",
|
||||
"start_date": "2024-12-29",
|
||||
"end_date": "2024-12-29"
|
||||
},
|
||||
{
|
||||
"name": "International New Year Day",
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-01-01"
|
||||
},
|
||||
{
|
||||
"name": "Day of Victory over the Genocidal Regime",
|
||||
"start_date": "2025-01-07",
|
||||
"end_date": "2025-01-07"
|
||||
},
|
||||
{
|
||||
"name": "International Women's Rights Day",
|
||||
"start_date": "2025-03-08",
|
||||
"end_date": "2025-03-08"
|
||||
},
|
||||
{
|
||||
"name": "Khmer New Year's Day",
|
||||
"start_date": "2025-04-14",
|
||||
"end_date": "2025-04-16"
|
||||
},
|
||||
{
|
||||
"name": "International Labor Day",
|
||||
"start_date": "2025-05-01",
|
||||
"end_date": "2025-05-01"
|
||||
},
|
||||
{
|
||||
"name": "Visaka Bochea Day",
|
||||
"start_date": "2025-05-11",
|
||||
"end_date": "2025-05-11"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Birthday",
|
||||
"start_date": "2025-05-14",
|
||||
"end_date": "2025-05-14"
|
||||
},
|
||||
{
|
||||
"name": "Royal Ploughing Ceremony",
|
||||
"start_date": "2025-05-15",
|
||||
"end_date": "2025-05-15"
|
||||
},
|
||||
{
|
||||
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
|
||||
"start_date": "2025-06-18",
|
||||
"end_date": "2025-06-18"
|
||||
},
|
||||
{
|
||||
"name": "Pchum Ben Day",
|
||||
"start_date": "2025-09-21",
|
||||
"end_date": "2025-09-23"
|
||||
},
|
||||
{
|
||||
"name": "Constitution Day",
|
||||
"start_date": "2025-09-24",
|
||||
"end_date": "2025-09-24"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihanouk Mourning Day",
|
||||
"start_date": "2025-10-15",
|
||||
"end_date": "2025-10-15"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Coronation Day",
|
||||
"start_date": "2025-10-29",
|
||||
"end_date": "2025-10-29"
|
||||
},
|
||||
{
|
||||
"name": "Water Festival",
|
||||
"start_date": "2025-11-04",
|
||||
"end_date": "2025-11-06"
|
||||
},
|
||||
{
|
||||
"name": "National Independence Day",
|
||||
"start_date": "2025-11-09",
|
||||
"end_date": "2025-11-09"
|
||||
},
|
||||
{
|
||||
"name": "Peace Day in Cambodia",
|
||||
"start_date": "2025-12-29",
|
||||
"end_date": "2025-12-29"
|
||||
},
|
||||
{
|
||||
"name": "International New Year Day",
|
||||
"start_date": "2026-01-01",
|
||||
"end_date": "2026-01-01"
|
||||
},
|
||||
{
|
||||
"name": "Day of Victory over the Genocidal Regime",
|
||||
"start_date": "2026-01-07",
|
||||
"end_date": "2026-01-07"
|
||||
},
|
||||
{
|
||||
"name": "International Women's Rights Day",
|
||||
"start_date": "2026-03-08",
|
||||
"end_date": "2026-03-08"
|
||||
},
|
||||
{
|
||||
"name": "Khmer New Year's Day",
|
||||
"start_date": "2026-04-14",
|
||||
"end_date": "2026-04-16"
|
||||
},
|
||||
{
|
||||
"name": "International Labor Day; Visaka Bochea Day",
|
||||
"start_date": "2026-05-01",
|
||||
"end_date": "2026-05-01"
|
||||
},
|
||||
{
|
||||
"name": "Royal Ploughing Ceremony",
|
||||
"start_date": "2026-05-05",
|
||||
"end_date": "2026-05-05"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Birthday",
|
||||
"start_date": "2026-05-14",
|
||||
"end_date": "2026-05-14"
|
||||
},
|
||||
{
|
||||
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
|
||||
"start_date": "2026-06-18",
|
||||
"end_date": "2026-06-18"
|
||||
},
|
||||
{
|
||||
"name": "Constitution Day",
|
||||
"start_date": "2026-09-24",
|
||||
"end_date": "2026-09-24"
|
||||
},
|
||||
{
|
||||
"name": "Pchum Ben Day",
|
||||
"start_date": "2026-10-10",
|
||||
"end_date": "2026-10-12"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihanouk Mourning Day",
|
||||
"start_date": "2026-10-15",
|
||||
"end_date": "2026-10-15"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Coronation Day",
|
||||
"start_date": "2026-10-29",
|
||||
"end_date": "2026-10-29"
|
||||
},
|
||||
{
|
||||
"name": "National Independence Day",
|
||||
"start_date": "2026-11-09",
|
||||
"end_date": "2026-11-09"
|
||||
},
|
||||
{
|
||||
"name": "Water Festival",
|
||||
"start_date": "2026-11-23",
|
||||
"end_date": "2026-11-25"
|
||||
},
|
||||
{
|
||||
"name": "Peace Day in Cambodia",
|
||||
"start_date": "2026-12-29",
|
||||
"end_date": "2026-12-29"
|
||||
},
|
||||
{
|
||||
"name": "International New Year Day",
|
||||
"start_date": "2027-01-01",
|
||||
"end_date": "2027-01-01"
|
||||
},
|
||||
{
|
||||
"name": "Day of Victory over the Genocidal Regime",
|
||||
"start_date": "2027-01-07",
|
||||
"end_date": "2027-01-07"
|
||||
},
|
||||
{
|
||||
"name": "International Women's Rights Day",
|
||||
"start_date": "2027-03-08",
|
||||
"end_date": "2027-03-08"
|
||||
},
|
||||
{
|
||||
"name": "Khmer New Year's Day",
|
||||
"start_date": "2027-04-14",
|
||||
"end_date": "2027-04-16"
|
||||
},
|
||||
{
|
||||
"name": "International Labor Day",
|
||||
"start_date": "2027-05-01",
|
||||
"end_date": "2027-05-01"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Birthday",
|
||||
"start_date": "2027-05-14",
|
||||
"end_date": "2027-05-14"
|
||||
},
|
||||
{
|
||||
"name": "Visaka Bochea Day",
|
||||
"start_date": "2027-05-20",
|
||||
"end_date": "2027-05-20"
|
||||
},
|
||||
{
|
||||
"name": "Royal Ploughing Ceremony",
|
||||
"start_date": "2027-05-24",
|
||||
"end_date": "2027-05-24"
|
||||
},
|
||||
{
|
||||
"name": "HM Queen Norodom Monineath Sihanouk the Queen-Mother's Birthday",
|
||||
"start_date": "2027-06-18",
|
||||
"end_date": "2027-06-18"
|
||||
},
|
||||
{
|
||||
"name": "Constitution Day",
|
||||
"start_date": "2027-09-24",
|
||||
"end_date": "2027-09-24"
|
||||
},
|
||||
{
|
||||
"name": "Pchum Ben Day",
|
||||
"start_date": "2027-09-29",
|
||||
"end_date": "2027-10-01"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihanouk Mourning Day",
|
||||
"start_date": "2027-10-15",
|
||||
"end_date": "2027-10-15"
|
||||
},
|
||||
{
|
||||
"name": "HM King Norodom Sihamoni's Coronation Day",
|
||||
"start_date": "2027-10-29",
|
||||
"end_date": "2027-10-29"
|
||||
},
|
||||
{
|
||||
"name": "National Independence Day",
|
||||
"start_date": "2027-11-09",
|
||||
"end_date": "2027-11-09"
|
||||
},
|
||||
{
|
||||
"name": "Water Festival",
|
||||
"start_date": "2027-11-12",
|
||||
"end_date": "2027-11-14"
|
||||
},
|
||||
{
|
||||
"name": "Peace Day in Cambodia",
|
||||
"start_date": "2027-12-29",
|
||||
"end_date": "2027-12-29"
|
||||
}
|
||||
]
|
||||
}
|
||||
+108
-2
@@ -1,8 +1,114 @@
|
||||
{
|
||||
"hotel": "NF Hotel",
|
||||
"address": {
|
||||
"street": "Street 172",
|
||||
"city": "Phnom Penh",
|
||||
"country": "Cambodia"
|
||||
},
|
||||
"currency": "USD",
|
||||
"room_types": {
|
||||
"A": { "size": "Small", "standard_price_per_night": 20, "room_count": null },
|
||||
"B": { "size": "Large", "standard_price_per_night": 25, "room_count": null }
|
||||
"A": {
|
||||
"size": "Small",
|
||||
"standard_price_per_night": 20,
|
||||
"room_count": 10
|
||||
},
|
||||
"B": {
|
||||
"size": "Large",
|
||||
"standard_price_per_night": 25,
|
||||
"room_count": 10
|
||||
}
|
||||
},
|
||||
"nearby_events": [
|
||||
{
|
||||
"name": "40th and 41st ASEAN Summits and Related Summits",
|
||||
"start_date": "2022-11-10",
|
||||
"end_date": "2022-11-13",
|
||||
"venue": "Phnom Penh",
|
||||
"source_url": "https://www.pmo.gov.sg/Newsroom/PM-Lee-Hsien-Loong-to-attend-the-40th-and-41st-ASEAN-Summits-and-Related-Summits-in-Cambodia-2022"
|
||||
},
|
||||
{
|
||||
"name": "2023 SEA Games (Southeast Asian Games)",
|
||||
"start_date": "2023-05-05",
|
||||
"end_date": "2023-05-17",
|
||||
"venue": "Morodok Techo Sports Complex (Chroy Changvar), Olympic Sports Complex, Chroy Changvar Convention Centre",
|
||||
"source_url": "https://en.wikipedia.org/wiki/2023_SEA_Games"
|
||||
},
|
||||
{
|
||||
"name": "12th ASEAN Para Games",
|
||||
"start_date": "2023-06-03",
|
||||
"end_date": "2023-06-09",
|
||||
"venue": "Morodok Techo National Stadium",
|
||||
"source_url": "https://en.wikipedia.org/wiki/2023_ASEAN_Para_Games"
|
||||
},
|
||||
{
|
||||
"name": "Phnom Penh International Half Marathon",
|
||||
"start_date": "2023-06-11",
|
||||
"end_date": "2023-06-11",
|
||||
"venue": "Phnom Penh",
|
||||
"source_url": "https://aims-worldrunning.org/races/10059.html"
|
||||
},
|
||||
{
|
||||
"name": "Phnom Penh International Half Marathon",
|
||||
"start_date": "2024-06-16",
|
||||
"end_date": "2024-06-16",
|
||||
"venue": "Phnom Penh",
|
||||
"source_url": "https://aims-worldrunning.org/races/10059.html"
|
||||
},
|
||||
{
|
||||
"name": "Miss Grand Cambodia 2024 final",
|
||||
"start_date": "2024-07-12",
|
||||
"end_date": "2024-07-12",
|
||||
"venue": "Koh Pich Theater",
|
||||
"source_url": "https://en.wikipedia.org/wiki/Miss_Grand_Cambodia_2024"
|
||||
},
|
||||
{
|
||||
"name": "CAMFOOD & CAMHOTEL 2024 trade fair",
|
||||
"start_date": "2024-11-06",
|
||||
"end_date": "2024-11-08",
|
||||
"venue": "Diamond Island Convention & Exhibition Center",
|
||||
"source_url": "https://www.tradeindia.com/tradeshows/venue/diamond-island-convention-exhibition-center-diecc/1173/"
|
||||
},
|
||||
{
|
||||
"name": "Cambodia ASEAN Business Summit 2025",
|
||||
"start_date": "2025-03-06",
|
||||
"end_date": "2025-03-06",
|
||||
"venue": "Sofitel Phnom Penh Phokeetra",
|
||||
"source_url": "https://cambodiainvestmentreview.com/2025/01/30/cambodia-asean-business-summit-2025-set-for-march-6-at-sofitel-phnom-penh/"
|
||||
},
|
||||
{
|
||||
"name": "2025 AFC Challenge League final",
|
||||
"start_date": "2025-05-10",
|
||||
"end_date": "2025-05-10",
|
||||
"venue": "Phnom Penh",
|
||||
"source_url": "https://en.wikipedia.org/wiki/2025_AFC_Challenge_League_final"
|
||||
},
|
||||
{
|
||||
"name": "Phnom Penh International Half Marathon",
|
||||
"start_date": "2025-06-15",
|
||||
"end_date": "2025-06-15",
|
||||
"venue": "Phnom Penh",
|
||||
"source_url": "https://aims-worldrunning.org/races/10059.html"
|
||||
},
|
||||
{
|
||||
"name": "Mekong Forum 2025",
|
||||
"start_date": "2025-07-30",
|
||||
"end_date": "2025-07-31",
|
||||
"venue": "Shangri-La Hotel Phnom Penh",
|
||||
"source_url": "https://mekonginstitute.org/mekong-forum-2025/"
|
||||
},
|
||||
{
|
||||
"name": "CamboP&ELight 2025 trade fair",
|
||||
"start_date": "2025-08-06",
|
||||
"end_date": "2025-08-09",
|
||||
"venue": "Diamond Island Convention & Exhibition Center",
|
||||
"source_url": "https://www.tradeindia.com/tradeshows/venue/diamond-island-convention-exhibition-center-diecc/1173/"
|
||||
},
|
||||
{
|
||||
"name": "Phnom Penh Design Festival 2025",
|
||||
"start_date": "2025-10-31",
|
||||
"end_date": "2025-11-02",
|
||||
"venue": "Factory Phnom Penh",
|
||||
"source_url": "https://ppua.edu.kh/events/ppdf/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+8538
-40044
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -30,11 +30,12 @@ requires a class to generate field validation, JSON (de)serialization, and
|
||||
the OpenAPI schema FastAPI exposes at `/docs`. Each class is a **data
|
||||
contract**, not behavior - no methods, just typed fields.
|
||||
|
||||
### `src/nf_hotel_api/domain/metadata.py` - classes: `RoomType`, `HotelMetadata`
|
||||
### `src/nf_hotel_api/domain/metadata.py` - classes: `RoomType`, `Address`, `NearbyEvent`, `HotelMetadata`
|
||||
|
||||
Pydantic models for the reference data in `data/hotel_metadata.json`: per room
|
||||
type its size, standard price per night and (optional) number of rooms.
|
||||
`HotelMetadata.describe()` renders the catalogue as text for the LLM prompt.
|
||||
Pydantic models for the reference data in `data/hotel_metadata.json`: address,
|
||||
per room type its size, standard price per night and number of rooms, plus the
|
||||
nearby Phnom Penh events (date ranges are validated). Public holidays are not
|
||||
stored; see `services/public_holidays.py`. `HotelMetadata.describe()` renders it all as text for the LLM prompt.
|
||||
|
||||
## Core (config & security)
|
||||
|
||||
@@ -85,6 +86,13 @@ standard price, and derives `room_size` and `revenue`. The class keeps these ste
|
||||
testable (see `tests/test_cleaning.py`), and lets the whole pipeline be
|
||||
swapped out in `ReportService`.
|
||||
|
||||
### `src/nf_hotel_api/services/public_holidays.py` - class `PublicHolidayCalendar`
|
||||
|
||||
Thin wrapper over the `holidays` package (country `KH`): returns holidays per
|
||||
year, looks up a single day, merges consecutive days into `HolidayPeriod`s and
|
||||
renders them as text for the LLM prompt. `data/holidays.json` is an export of
|
||||
the periods, regenerated by `scripts/update_holidays.py`.
|
||||
|
||||
### `src/nf_hotel_api/services/statistics.py` - class `DescriptiveStatsService`
|
||||
|
||||
A single-purpose class computing and JSON-serializing `df.describe()`. It
|
||||
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.6",
|
||||
"httpx>=0.27",
|
||||
"holidays>=0.60",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""One-off helper that writes data/hotel_metadata.json.
|
||||
|
||||
Events were looked up on the web (source_url per event). Public holidays are
|
||||
not stored here: they come from the `holidays` package at run time. Edit the
|
||||
lists and re-run to change the file - or edit the JSON directly.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def e(name, start, end, venue, url):
|
||||
return {"name": name, "start_date": start, "end_date": end, "venue": venue, "source_url": url}
|
||||
|
||||
|
||||
EVENTS = [
|
||||
e("40th and 41st ASEAN Summits and Related Summits", "2022-11-10", "2022-11-13",
|
||||
"Phnom Penh",
|
||||
"https://www.pmo.gov.sg/Newsroom/PM-Lee-Hsien-Loong-to-attend-the-40th-and-41st-ASEAN-Summits-and-Related-Summits-in-Cambodia-2022"),
|
||||
e("2023 SEA Games (Southeast Asian Games)", "2023-05-05", "2023-05-17",
|
||||
"Morodok Techo Sports Complex (Chroy Changvar), Olympic Sports Complex, Chroy Changvar Convention Centre",
|
||||
"https://en.wikipedia.org/wiki/2023_SEA_Games"),
|
||||
e("12th ASEAN Para Games", "2023-06-03", "2023-06-09",
|
||||
"Morodok Techo National Stadium",
|
||||
"https://en.wikipedia.org/wiki/2023_ASEAN_Para_Games"),
|
||||
e("Phnom Penh International Half Marathon", "2023-06-11", "2023-06-11",
|
||||
"Phnom Penh", "https://aims-worldrunning.org/races/10059.html"),
|
||||
e("Phnom Penh International Half Marathon", "2024-06-16", "2024-06-16",
|
||||
"Phnom Penh", "https://aims-worldrunning.org/races/10059.html"),
|
||||
e("Miss Grand Cambodia 2024 final", "2024-07-12", "2024-07-12",
|
||||
"Koh Pich Theater", "https://en.wikipedia.org/wiki/Miss_Grand_Cambodia_2024"),
|
||||
e("CAMFOOD & CAMHOTEL 2024 trade fair", "2024-11-06", "2024-11-08",
|
||||
"Diamond Island Convention & Exhibition Center",
|
||||
"https://www.tradeindia.com/tradeshows/venue/diamond-island-convention-exhibition-center-diecc/1173/"),
|
||||
e("Cambodia ASEAN Business Summit 2025", "2025-03-06", "2025-03-06",
|
||||
"Sofitel Phnom Penh Phokeetra",
|
||||
"https://cambodiainvestmentreview.com/2025/01/30/cambodia-asean-business-summit-2025-set-for-march-6-at-sofitel-phnom-penh/"),
|
||||
e("2025 AFC Challenge League final", "2025-05-10", "2025-05-10",
|
||||
"Phnom Penh", "https://en.wikipedia.org/wiki/2025_AFC_Challenge_League_final"),
|
||||
e("Phnom Penh International Half Marathon", "2025-06-15", "2025-06-15",
|
||||
"Phnom Penh", "https://aims-worldrunning.org/races/10059.html"),
|
||||
e("Mekong Forum 2025", "2025-07-30", "2025-07-31",
|
||||
"Shangri-La Hotel Phnom Penh", "https://mekonginstitute.org/mekong-forum-2025/"),
|
||||
e("CamboP&ELight 2025 trade fair", "2025-08-06", "2025-08-09",
|
||||
"Diamond Island Convention & Exhibition Center",
|
||||
"https://www.tradeindia.com/tradeshows/venue/diamond-island-convention-exhibition-center-diecc/1173/"),
|
||||
e("Phnom Penh Design Festival 2025", "2025-10-31", "2025-11-02",
|
||||
"Factory Phnom Penh", "https://ppua.edu.kh/events/ppdf/"),
|
||||
]
|
||||
|
||||
METADATA = {
|
||||
"hotel": "NF Hotel",
|
||||
"address": {"street": "Street 172", "city": "Phnom Penh", "country": "Cambodia"},
|
||||
"currency": "USD",
|
||||
"room_types": {
|
||||
"A": {"size": "Small", "standard_price_per_night": 20, "room_count": 10},
|
||||
"B": {"size": "Large", "standard_price_per_night": 25, "room_count": 10},
|
||||
},
|
||||
"nearby_events": EVENTS,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
path = ROOT / "data" / "hotel_metadata.json"
|
||||
path.write_text(json.dumps(METADATA, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print(f"wrote {path} ({len(EVENTS)} events)")
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Generates the synthetic NF Hotel booking dataset (data/nf_hotel_bookings.csv).
|
||||
|
||||
These are NOT real bookings. The hotel is small (see room_count per room type
|
||||
in data/hotel_metadata.json), so the simulation never sells more rooms of a
|
||||
type than the hotel has on any night. Demand follows the calendar in the
|
||||
metadata file (events near the hotel) and on Cambodian public holidays from
|
||||
the `holidays` package.
|
||||
|
||||
Every multiplier below is a modelling ASSUMPTION, not a measurement - change
|
||||
the constants and re-run to test other scenarios:
|
||||
|
||||
.venv/Scripts/python scripts/generate_bookings.py
|
||||
|
||||
The output is reproducible (fixed seed). A small share of deliberately dirty
|
||||
rows (duplicates, impossible guest counts, blanks) is added at the end so the
|
||||
cleaning pipeline has something to do; set DIRTY_SHARE = 0 to disable.
|
||||
"""
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from nf_hotel_api.domain.metadata import HotelMetadata # noqa: E402
|
||||
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar # noqa: E402
|
||||
|
||||
SEED = 2022
|
||||
FIRST_ARRIVAL = date(2022, 1, 1)
|
||||
LAST_ARRIVAL = date(2025, 12, 31)
|
||||
OUTPUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "data" / "nf_hotel_bookings.csv"
|
||||
METADATA_FILE = ROOT / "data" / "hotel_metadata.json"
|
||||
|
||||
# --- Demand model (assumptions) -------------------------------------------
|
||||
BASE_REQUESTS_PER_DAY = 5.5 # booking requests arriving per day in a normal week
|
||||
MONTH_FACTOR = { # cool/dry season busiest, rainy season quietest
|
||||
1: 1.25, 2: 1.2, 3: 1.05, 4: 0.9, 5: 0.85, 6: 0.8,
|
||||
7: 0.85, 8: 0.85, 9: 0.8, 10: 0.95, 11: 1.2, 12: 1.3,
|
||||
}
|
||||
WEEKDAY_FACTOR = {0: 0.95, 1: 0.95, 2: 0.95, 3: 1.0, 4: 1.15, 5: 1.2, 6: 1.0}
|
||||
# Holiday effect on demand for a Phnom Penh hotel. Matched on the holiday name
|
||||
# as spelled by the `holidays` package.
|
||||
HOLIDAY_FACTORS = [
|
||||
("Water Festival", 1.8), # Phnom Penh hosts the biggest celebrations
|
||||
("Khmer New Year", 0.75), # capital empties as people travel to the provinces
|
||||
("Pchum Ben", 0.8), # same: family and pagoda visits in home provinces
|
||||
]
|
||||
OTHER_HOLIDAY_FACTOR = 1.15
|
||||
# Event effect, matched on the event name (first match wins).
|
||||
EVENT_FACTORS = [
|
||||
("ASEAN Summits", 2.2),
|
||||
("SEA Games", 2.5),
|
||||
("ASEAN Para Games", 1.7),
|
||||
("Half Marathon", 1.3),
|
||||
("AFC Challenge League", 1.3),
|
||||
]
|
||||
OTHER_EVENT_FACTOR = 1.2
|
||||
|
||||
# --- Guests and stays -----------------------------------------------------
|
||||
STAY_NIGHTS = [1, 2, 3, 4, 5, 6, 7, 10]
|
||||
STAY_WEIGHTS = [0.33, 0.30, 0.17, 0.08, 0.05, 0.03, 0.03, 0.01]
|
||||
COUNTRIES = {
|
||||
"Cambodia": 0.24, "China": 0.14, "Vietnam": 0.08, "United States": 0.08,
|
||||
"France": 0.06, "United Kingdom": 0.06, "Australia": 0.05, "Japan": 0.05,
|
||||
"South Korea": 0.05, "Thailand": 0.04, "Germany": 0.04, "Singapore": 0.03,
|
||||
"Malaysia": 0.03, "Other": 0.05,
|
||||
}
|
||||
SEGMENTS = {"Online TA": 0.45, "Direct": 0.20, "Offline TA/TO": 0.12,
|
||||
"Corporate": 0.09, "Groups": 0.08, "Complementary": 0.01}
|
||||
# Price paid vs the standard price of the room type (assumption).
|
||||
SEGMENT_PRICE_FACTOR = {"Corporate": 0.9, "Groups": 0.9, "Offline TA/TO": 0.95, "Complementary": 0.0}
|
||||
PEAK_PRICE_FACTOR = 1.15 # applied when the arrival date has a demand factor >= 1.5
|
||||
PEAK_DEMAND_THRESHOLD = 1.5
|
||||
LARGE_ROOM_PREFERENCE = 0.85 # parties of 3+ or with children who ask for a large room
|
||||
|
||||
DIRTY_SHARE = 0.02
|
||||
|
||||
|
||||
def demand_factor(day: date, metadata: HotelMetadata, holiday_names: dict[date, str]) -> float:
|
||||
factor = MONTH_FACTOR[day.month] * WEEKDAY_FACTOR[day.weekday()]
|
||||
holiday = holiday_names.get(day)
|
||||
if holiday:
|
||||
factor *= next((f for key, f in HOLIDAY_FACTORS if key in holiday), OTHER_HOLIDAY_FACTOR)
|
||||
for event in metadata.nearby_events:
|
||||
if event.covers(day):
|
||||
factor *= next((f for key, f in EVENT_FACTORS if key in event.name), OTHER_EVENT_FACTOR)
|
||||
break
|
||||
return factor
|
||||
|
||||
|
||||
def pick(rng: np.random.Generator, weights: dict[str, float]) -> str:
|
||||
keys = list(weights)
|
||||
probabilities = np.array(list(weights.values()), dtype=float)
|
||||
return str(rng.choice(keys, p=probabilities / probabilities.sum()))
|
||||
|
||||
|
||||
def simulate(metadata: HotelMetadata, rng: np.random.Generator) -> pd.DataFrame:
|
||||
days = (LAST_ARRIVAL - FIRST_ARRIVAL).days + 1
|
||||
horizon = days + 30 # stays that begin near the end run past LAST_ARRIVAL
|
||||
capacity = {code: room.room_count for code, room in metadata.room_types.items()}
|
||||
if any(count is None for count in capacity.values()):
|
||||
raise SystemExit("room_count must be set for every room type in hotel_metadata.json")
|
||||
booked = {code: np.zeros(horizon, dtype=int) for code in capacity}
|
||||
holiday_names = PublicHolidayCalendar().for_years(FIRST_ARRIVAL.year, LAST_ARRIVAL.year)
|
||||
small, large = "A", "B"
|
||||
|
||||
rows = []
|
||||
for offset in range(days):
|
||||
arrival = FIRST_ARRIVAL + timedelta(days=offset)
|
||||
factor = demand_factor(arrival, metadata, holiday_names)
|
||||
for _ in range(rng.poisson(BASE_REQUESTS_PER_DAY * factor)):
|
||||
adults = int(rng.choice([1, 2, 3, 4], p=[0.25, 0.55, 0.12, 0.08]))
|
||||
children = int(rng.choice([0, 1, 2], p=[0.85, 0.10, 0.05])) if adults >= 2 else 0
|
||||
babies = int(rng.random() < 0.02)
|
||||
wants_large = adults >= 3 or children > 0
|
||||
preferred = large if rng.random() < (LARGE_ROOM_PREFERENCE if wants_large else 0.2) else small
|
||||
nights = int(rng.choice(STAY_NIGHTS, p=STAY_WEIGHTS))
|
||||
segment = pick(rng, SEGMENTS)
|
||||
if segment == "Corporate":
|
||||
nights = min(nights, 4)
|
||||
deposit = pick(rng, {"No Deposit": 0.90, "Non Refund": 0.07, "Refundable": 0.03})
|
||||
lead_mean = 45 if factor >= 1.3 else 22
|
||||
lead_time = int(min(rng.exponential(lead_mean), 365))
|
||||
cancel_probability = 0.20 * (0.3 if deposit == "Non Refund" else 1.0)
|
||||
is_canceled = int(rng.random() < cancel_probability)
|
||||
|
||||
stay = slice(offset, offset + nights)
|
||||
assigned = None
|
||||
for code in (preferred, small if preferred == large else large):
|
||||
if (booked[code][stay] < capacity[code]).all():
|
||||
assigned = code
|
||||
break
|
||||
if assigned is None and not is_canceled:
|
||||
continue # sold out on at least one night: request turned away
|
||||
if assigned is None: # a cancelled request keeps its preferred type
|
||||
assigned = preferred
|
||||
if not is_canceled:
|
||||
booked[assigned][stay] += 1
|
||||
|
||||
stay_dates = [arrival + timedelta(days=n) for n in range(nights)]
|
||||
weekend_nights = sum(d.weekday() >= 5 for d in stay_dates)
|
||||
standard = metadata.room_types[assigned].standard_price_per_night
|
||||
price = standard * SEGMENT_PRICE_FACTOR.get(segment, 1.0)
|
||||
if factor >= PEAK_DEMAND_THRESHOLD and segment != "Complementary":
|
||||
price *= PEAK_PRICE_FACTOR
|
||||
rows.append(
|
||||
{
|
||||
"hotel": metadata.hotel,
|
||||
"is_canceled": is_canceled,
|
||||
"lead_time": lead_time,
|
||||
"arrival_date_week_number": arrival.isocalendar()[1],
|
||||
"booking_date": arrival - timedelta(days=lead_time),
|
||||
"arrival_date": arrival,
|
||||
"arrival_date_day_of_month": arrival.day,
|
||||
"stays_in_weekend_nights": weekend_nights,
|
||||
"stays_in_week_nights": nights - weekend_nights,
|
||||
"adults": adults,
|
||||
"children": children,
|
||||
"babies": babies,
|
||||
"meal": pick(rng, {"BB": 0.70, "HB": 0.10, "FB": 0.02, "SC": 0.18}),
|
||||
"country": pick(rng, COUNTRIES),
|
||||
"market_segment": segment,
|
||||
"is_repeated_guest": int(rng.random() < 0.08),
|
||||
"previous_cancellations": int(rng.random() < 0.04),
|
||||
"assigned_room_type": assigned,
|
||||
"booking_changes": int(rng.choice([0, 1, 2], p=[0.88, 0.09, 0.03])),
|
||||
"deposit_type": deposit,
|
||||
"agent": int(rng.choice([0, 9, 14, 28, 40, 240], p=[0.35, 0.15, 0.15, 0.1, 0.1, 0.15])),
|
||||
"customer_type": "Group Contract" if segment == "Groups" else pick(
|
||||
rng, {"No Contract (Single)": 0.75, "No Contract (Group)": 0.17, "Contract (Single)": 0.08}
|
||||
),
|
||||
"required_car_parking_spaces": int(rng.random() < 0.05),
|
||||
"total_of_special_requests": int(rng.choice([0, 1, 2, 3], p=[0.55, 0.28, 0.12, 0.05])),
|
||||
"prize_per_nigth": round(price, 2),
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df = df.sort_values(["booking_date", "arrival_date"], kind="stable").reset_index(drop=True)
|
||||
df.insert(0, "booking_id", np.arange(1, len(df) + 1))
|
||||
return df
|
||||
|
||||
|
||||
def add_dirty_rows(df: pd.DataFrame, rng: np.random.Generator) -> pd.DataFrame:
|
||||
"""Append duplicates and corrupt a few values, like a real messy export."""
|
||||
if DIRTY_SHARE <= 0:
|
||||
return df
|
||||
df = df.copy()
|
||||
n = max(1, int(len(df) * DIRTY_SHARE))
|
||||
corrupt = df.sample(n, random_state=SEED + 1).index
|
||||
half = len(corrupt) // 2
|
||||
df.loc[corrupt[:half], "adults"] = rng.choice([20, 55], size=half)
|
||||
df.loc[corrupt[half:], ["adults", "children", "babies"]] = 0 # booking without guests
|
||||
df["meal"] = df["meal"].astype(object)
|
||||
df.loc[df.sample(n // 2, random_state=SEED + 2).index, "meal"] = ""
|
||||
# Duplicates are copied last so they are exact copies (same values, new id)
|
||||
# of rows as they appear in the export, including any corruption above.
|
||||
duplicates = df.sample(n, random_state=SEED)
|
||||
df = pd.concat([df, duplicates], ignore_index=True)
|
||||
df = df.sort_values(["booking_date", "arrival_date"], kind="stable").reset_index(drop=True)
|
||||
df["booking_id"] = np.arange(1, len(df) + 1)
|
||||
return df
|
||||
|
||||
|
||||
def main() -> None:
|
||||
metadata = HotelMetadata.model_validate_json(METADATA_FILE.read_text(encoding="utf-8"))
|
||||
rng = np.random.default_rng(SEED)
|
||||
df = add_dirty_rows(simulate(metadata, rng), rng)
|
||||
for column in ("booking_date", "arrival_date"):
|
||||
df[column] = pd.to_datetime(df[column]).dt.strftime("%d-%m-%Y")
|
||||
df.to_csv(OUTPUT, sep=";", index=False)
|
||||
print(f"wrote {len(df)} rows to {OUTPUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Regenerates data/holidays.json from the `holidays` package.
|
||||
|
||||
The file is an export, not a source of truth: the application reads holidays
|
||||
straight from the package (services/public_holidays.py). Run this script to
|
||||
refresh the export - after upgrading the package, or when a new year starts:
|
||||
|
||||
.venv/Scripts/python scripts/update_holidays.py
|
||||
.venv/Scripts/python scripts/update_holidays.py --first-year 2022 --last-year 2028
|
||||
|
||||
By default it covers 2022 (start of the booking data) up to next year.
|
||||
`tests/test_public_holidays.py` fails when the file no longer matches the
|
||||
package, which is the signal to re-run this script.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar # noqa: E402
|
||||
|
||||
OUTPUT = ROOT / "data" / "holidays.json"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--first-year", type=int, default=2022)
|
||||
parser.add_argument("--last-year", type=int, default=date.today().year + 1)
|
||||
parser.add_argument("--country", default="KH", help="ISO country code used by the holidays package")
|
||||
args = parser.parse_args()
|
||||
|
||||
calendar = PublicHolidayCalendar(args.country)
|
||||
document = {
|
||||
"country": calendar.country_code,
|
||||
"source": f"python-holidays {calendar.package_version()}",
|
||||
"first_year": args.first_year,
|
||||
"last_year": args.last_year,
|
||||
"holidays": [
|
||||
{
|
||||
"name": period.name,
|
||||
"start_date": period.start_date.isoformat(),
|
||||
"end_date": period.end_date.isoformat(),
|
||||
}
|
||||
for period in calendar.periods(args.first_year, args.last_year)
|
||||
],
|
||||
}
|
||||
OUTPUT.write_text(json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print(f"wrote {OUTPUT} ({len(document['holidays'])} holiday periods, {args.first_year}-{args.last_year})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,6 +7,7 @@ from nf_hotel_api.domain.metadata import HotelMetadata
|
||||
from nf_hotel_api.repositories.metadata_repository import JsonHotelMetadataRepository
|
||||
from nf_hotel_api.services.cleaning import DataCleaningService
|
||||
from nf_hotel_api.services.llm_report import LLMReportService
|
||||
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar
|
||||
from nf_hotel_api.services.report import ReportService
|
||||
from nf_hotel_api.services.statistics import DescriptiveStatsService
|
||||
|
||||
@@ -33,6 +34,7 @@ def get_llm_service(
|
||||
) -> LLMReportService:
|
||||
return LLMReportService(
|
||||
metadata=metadata,
|
||||
holiday_calendar=PublicHolidayCalendar(),
|
||||
base_url=settings.llm_base_url,
|
||||
api_key=settings.llm_api_key,
|
||||
model=settings.llm_model,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class RoomType(BaseModel):
|
||||
@@ -11,19 +13,59 @@ class RoomType(BaseModel):
|
||||
room_count: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class Address(BaseModel):
|
||||
street: str
|
||||
city: str
|
||||
country: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.street}, {self.city}, {self.country}"
|
||||
|
||||
|
||||
class NearbyEvent(BaseModel):
|
||||
"""An event in Phnom Penh close enough to the hotel to affect bookings."""
|
||||
|
||||
name: str
|
||||
start_date: date
|
||||
end_date: date
|
||||
venue: str
|
||||
source_url: str
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _end_not_before_start(self) -> "NearbyEvent":
|
||||
if self.end_date < self.start_date:
|
||||
raise ValueError(f"{self.name}: end_date is before start_date")
|
||||
return self
|
||||
|
||||
def covers(self, day: date) -> bool:
|
||||
return self.start_date <= day <= self.end_date
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.start_date == self.end_date:
|
||||
return f"{self.start_date.isoformat()}: {self.name} ({self.venue})"
|
||||
return (
|
||||
f"{self.start_date.isoformat()} to {self.end_date.isoformat()}: "
|
||||
f"{self.name} ({self.venue})"
|
||||
)
|
||||
|
||||
|
||||
class HotelMetadata(BaseModel):
|
||||
"""Reference data about the hotel that is not part of the booking records.
|
||||
|
||||
``room_types`` is keyed by the ``assigned_room_type`` code used in the CSV.
|
||||
Public holidays are deliberately not stored here; see
|
||||
``services.public_holidays.PublicHolidayCalendar``.
|
||||
"""
|
||||
|
||||
hotel: str
|
||||
address: Address | None = None
|
||||
currency: str = "USD"
|
||||
room_types: dict[str, RoomType]
|
||||
nearby_events: list[NearbyEvent] = []
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Plain-text summary of the room catalogue, for the LLM prompt."""
|
||||
lines = []
|
||||
"""Plain-text summary of the hotel, its rooms and events, for the LLM prompt."""
|
||||
lines = [f"Hotel: {self.hotel}" + (f", {self.address}" if self.address else "")]
|
||||
for code, room in self.room_types.items():
|
||||
count = "unknown" if room.room_count is None else str(room.room_count)
|
||||
lines.append(
|
||||
@@ -31,4 +73,7 @@ class HotelMetadata(BaseModel):
|
||||
f"{room.standard_price_per_night:g} {self.currency} per night, "
|
||||
f"number of rooms in hotel: {count}"
|
||||
)
|
||||
if self.nearby_events:
|
||||
lines.append("Events in Phnom Penh near the hotel:")
|
||||
lines.extend(f"- {event.describe()}" for event in self.nearby_events)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from nf_hotel_api.domain.metadata import HotelMetadata
|
||||
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar
|
||||
|
||||
_PROMPT_TEMPLATE = """You work as a data analyst and in marketing to optimize hotel operations.
|
||||
User cannot interact with you so do not ask questions.
|
||||
@@ -16,7 +17,7 @@ _HOTEL_CONTEXT_TEMPLATE = """Hotel reference data (room types the bookings refer
|
||||
{room_catalogue}
|
||||
The column prize_per_nigth is the price the customer actually paid per night.
|
||||
The column revenue is nights x prize_per_nigth for non-cancelled bookings.
|
||||
"""
|
||||
{holidays}"""
|
||||
|
||||
# Reasoning models (e.g. Qwen3) may wrap their internal reasoning in
|
||||
# <think>...</think>; that content must never reach the API response.
|
||||
@@ -37,23 +38,17 @@ class LLMReportService:
|
||||
model: str,
|
||||
timeout_seconds: float,
|
||||
metadata: HotelMetadata | None = None,
|
||||
holiday_calendar: PublicHolidayCalendar | None = None,
|
||||
) -> None:
|
||||
self._metadata = metadata
|
||||
self._holiday_calendar = holiday_calendar
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._model = model
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
async def generate_report(self, descriptive_stats: dict[str, Any]) -> str:
|
||||
hotel_context = (
|
||||
_HOTEL_CONTEXT_TEMPLATE.format(room_catalogue=self._metadata.describe())
|
||||
if self._metadata
|
||||
else ""
|
||||
)
|
||||
prompt = _PROMPT_TEMPLATE.format(
|
||||
hotel_context=hotel_context,
|
||||
descriptive_analysis_data=json.dumps(descriptive_stats, indent=2),
|
||||
)
|
||||
prompt = self._build_prompt(descriptive_stats)
|
||||
|
||||
payload = {
|
||||
"model": self._model,
|
||||
@@ -81,6 +76,32 @@ class LLMReportService:
|
||||
|
||||
return self._strip_thinking(raw_content)
|
||||
|
||||
def _build_prompt(self, descriptive_stats: dict[str, Any]) -> str:
|
||||
hotel_context = (
|
||||
_HOTEL_CONTEXT_TEMPLATE.format(
|
||||
room_catalogue=self._metadata.describe(),
|
||||
holidays=self._holiday_section(descriptive_stats),
|
||||
)
|
||||
if self._metadata
|
||||
else ""
|
||||
)
|
||||
return _PROMPT_TEMPLATE.format(
|
||||
hotel_context=hotel_context,
|
||||
descriptive_analysis_data=json.dumps(descriptive_stats, indent=2),
|
||||
)
|
||||
|
||||
def _holiday_section(self, descriptive_stats: dict[str, Any]) -> str:
|
||||
"""Public holidays for the years the bookings' arrival dates span."""
|
||||
if self._holiday_calendar is None:
|
||||
return ""
|
||||
arrival = descriptive_stats.get("arrival_date", {})
|
||||
try:
|
||||
first_year = int(str(arrival["min"])[:4])
|
||||
last_year = int(str(arrival["max"])[:4])
|
||||
except (KeyError, ValueError):
|
||||
return ""
|
||||
return f"Public holidays:\n{self._holiday_calendar.describe(first_year, last_year)}\n"
|
||||
|
||||
@staticmethod
|
||||
def _strip_thinking(content: str) -> str:
|
||||
return _THINK_BLOCK_PATTERN.sub("", content).strip()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
|
||||
import holidays
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HolidayPeriod:
|
||||
"""A holiday, or a run of consecutive days with the same holiday name."""
|
||||
|
||||
name: str
|
||||
start_date: date
|
||||
end_date: date
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.start_date == self.end_date:
|
||||
return f"{self.start_date.isoformat()}: {self.name}"
|
||||
return f"{self.start_date.isoformat()} to {self.end_date.isoformat()}: {self.name}"
|
||||
|
||||
|
||||
class PublicHolidayCalendar:
|
||||
"""Public holidays looked up in the ``holidays`` package (not stored in metadata).
|
||||
|
||||
Defaults to Cambodia (``KH``), where the hotel is. ``data/holidays.json`` is
|
||||
an export of :meth:`periods`, refreshed by ``scripts/update_holidays.py``.
|
||||
"""
|
||||
|
||||
def __init__(self, country_code: str = "KH") -> None:
|
||||
self._country_code = country_code
|
||||
|
||||
@property
|
||||
def country_code(self) -> str:
|
||||
return self._country_code
|
||||
|
||||
@staticmethod
|
||||
def package_version() -> str:
|
||||
return holidays.__version__
|
||||
|
||||
def for_years(self, first_year: int, last_year: int) -> dict[date, str]:
|
||||
"""Return ``{day: holiday name}`` for every holiday from first to last year."""
|
||||
calendar = holidays.country_holidays(
|
||||
self._country_code, years=range(first_year, last_year + 1), language="en_US"
|
||||
)
|
||||
return dict(sorted(calendar.items()))
|
||||
|
||||
def name_on(self, day: date) -> str | None:
|
||||
return holidays.country_holidays(self._country_code, years=day.year, language="en_US").get(day)
|
||||
|
||||
def periods(self, first_year: int, last_year: int) -> list[HolidayPeriod]:
|
||||
"""Consecutive days with the same name are merged into one period."""
|
||||
periods: list[HolidayPeriod] = []
|
||||
for day, name in self.for_years(first_year, last_year).items():
|
||||
last = periods[-1] if periods else None
|
||||
if last and last.name == name and day - last.end_date == timedelta(days=1):
|
||||
periods[-1] = HolidayPeriod(name, last.start_date, day)
|
||||
else:
|
||||
periods.append(HolidayPeriod(name, day, day))
|
||||
return periods
|
||||
|
||||
def describe(self, first_year: int, last_year: int) -> str:
|
||||
"""One line per holiday period, for the LLM prompt."""
|
||||
return "\n".join(f"- {p.describe()}" for p in self.periods(first_year, last_year))
|
||||
+1
-1
@@ -109,6 +109,6 @@ def hotel_metadata() -> HotelMetadata:
|
||||
currency="USD",
|
||||
room_types={
|
||||
"A": RoomType(size="Small", standard_price_per_night=20, room_count=10),
|
||||
"B": RoomType(size="Large", standard_price_per_night=25, room_count=5),
|
||||
"B": RoomType(size="Large", standard_price_per_night=25, room_count=10),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Guards the bundled synthetic dataset against the hotel's real constraints."""
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from nf_hotel_api.core.config import get_settings
|
||||
from nf_hotel_api.repositories.booking_repository import CsvBookingRepository
|
||||
from nf_hotel_api.repositories.metadata_repository import JsonHotelMetadataRepository
|
||||
from nf_hotel_api.services.cleaning import DataCleaningService
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def metadata():
|
||||
return JsonHotelMetadataRepository(get_settings().metadata_path).load()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def clean_bookings(metadata) -> pd.DataFrame:
|
||||
settings = get_settings()
|
||||
raw = CsvBookingRepository(settings.default_data_path, settings.csv_separator).load()
|
||||
return DataCleaningService(metadata).clean(raw)
|
||||
|
||||
|
||||
def test_arrivals_are_within_2022_to_2025(clean_bookings):
|
||||
assert clean_bookings["arrival_date"].min() >= pd.Timestamp(2022, 1, 1)
|
||||
assert clean_bookings["arrival_date"].max() <= pd.Timestamp(2025, 12, 31)
|
||||
|
||||
|
||||
def test_only_known_room_types_are_used(clean_bookings, metadata):
|
||||
assert set(clean_bookings["assigned_room_type"]) <= set(metadata.room_types)
|
||||
|
||||
|
||||
def test_hotel_is_never_overbooked(clean_bookings, metadata):
|
||||
"""Non-cancelled bookings must fit in the rooms the hotel actually has."""
|
||||
stays = clean_bookings[clean_bookings["is_canceled"] == 0]
|
||||
for code, room in metadata.room_types.items():
|
||||
of_type = stays[stays["assigned_room_type"] == code]
|
||||
nights = (of_type["stays_in_weekend_nights"] + of_type["stays_in_week_nights"]).astype(int)
|
||||
# +1 on arrival, -1 on the day after the last night; the running sum is the occupancy.
|
||||
starts = of_type["arrival_date"].value_counts()
|
||||
ends = (of_type["arrival_date"] + pd.to_timedelta(nights, unit="D")).value_counts()
|
||||
change = starts.sub(ends, fill_value=0).sort_index()
|
||||
assert change.cumsum().max() <= room.room_count, f"room type {code} overbooked"
|
||||
|
||||
|
||||
def test_prices_are_present_and_non_negative(clean_bookings):
|
||||
assert clean_bookings["prize_per_nigth"].notna().all()
|
||||
assert (clean_bookings["prize_per_nigth"] >= 0).all()
|
||||
@@ -78,3 +78,34 @@ async def test_generate_report_raises_llm_service_error_on_bad_response(monkeypa
|
||||
|
||||
with pytest.raises(LLMServiceError):
|
||||
await service.generate_report({"adults": {"mean": 2}})
|
||||
|
||||
|
||||
def _service_with_context(**kwargs):
|
||||
from nf_hotel_api.domain.metadata import HotelMetadata, RoomType
|
||||
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar
|
||||
|
||||
metadata = HotelMetadata(
|
||||
hotel="NF Hotel",
|
||||
room_types={"A": RoomType(size="Small", standard_price_per_night=20, room_count=10)},
|
||||
)
|
||||
return LLMReportService(
|
||||
base_url="http://fake/v1", api_key="k", model="m", timeout_seconds=1.0,
|
||||
metadata=metadata, holiday_calendar=PublicHolidayCalendar(), **kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_prompt_lists_public_holidays_for_the_years_in_the_data():
|
||||
stats = {"arrival_date": {"min": "2024-01-03T00:00:00", "max": "2024-12-30T00:00:00"}}
|
||||
|
||||
prompt = _service_with_context()._build_prompt(stats)
|
||||
|
||||
assert "Public holidays:" in prompt
|
||||
assert "2024-04-13 to 2024-04-16" in prompt
|
||||
assert "2023-" not in prompt.split("We have extract")[0]
|
||||
|
||||
|
||||
def test_prompt_skips_holidays_when_arrival_dates_are_unknown():
|
||||
prompt = _service_with_context()._build_prompt({"adults": {"mean": 2}})
|
||||
|
||||
assert "Public holidays" not in prompt
|
||||
assert "Room type A: Small" in prompt
|
||||
|
||||
@@ -33,3 +33,55 @@ def test_describe_lists_prices_and_marks_unknown_room_counts():
|
||||
|
||||
assert "Room type A: Small, standard price 20 USD per night, number of rooms in hotel: 10" in text
|
||||
assert "Room type B: Large, standard price 25 USD per night, number of rooms in hotel: unknown" in text
|
||||
|
||||
|
||||
def test_bundled_metadata_has_address_room_counts_and_events():
|
||||
from nf_hotel_api.core.config import get_settings
|
||||
|
||||
metadata = JsonHotelMetadataRepository(get_settings().metadata_path).load()
|
||||
|
||||
assert str(metadata.address) == "Street 172, Phnom Penh, Cambodia"
|
||||
assert metadata.room_types["A"].room_count == 10
|
||||
assert metadata.room_types["B"].room_count == 10
|
||||
assert all(e.source_url.startswith("https://") for e in metadata.nearby_events)
|
||||
|
||||
|
||||
def test_bundled_metadata_does_not_store_public_holidays():
|
||||
from nf_hotel_api.core.config import get_settings
|
||||
|
||||
assert "public_holidays" not in get_settings().metadata_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_event_rejects_end_before_start():
|
||||
from datetime import date
|
||||
|
||||
from nf_hotel_api.domain.metadata import NearbyEvent
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
NearbyEvent(
|
||||
name="Bad", start_date=date(2024, 5, 2), end_date=date(2024, 5, 1),
|
||||
venue="x", source_url="https://example.com",
|
||||
)
|
||||
|
||||
|
||||
def test_describe_includes_address_and_events():
|
||||
from datetime import date
|
||||
|
||||
from nf_hotel_api.domain.metadata import Address, NearbyEvent
|
||||
|
||||
metadata = HotelMetadata(
|
||||
hotel="NF Hotel",
|
||||
address=Address(street="Street 172", city="Phnom Penh", country="Cambodia"),
|
||||
room_types={"A": RoomType(size="Small", standard_price_per_night=20, room_count=10)},
|
||||
nearby_events=[
|
||||
NearbyEvent(
|
||||
name="Trade fair", start_date=date(2024, 11, 6), end_date=date(2024, 11, 6),
|
||||
venue="DIECC", source_url="https://example.com",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
text = metadata.describe()
|
||||
|
||||
assert "Street 172, Phnom Penh, Cambodia" in text
|
||||
assert "2024-11-06: Trade fair (DIECC)" in text
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from datetime import date
|
||||
|
||||
from nf_hotel_api.services.public_holidays import PublicHolidayCalendar
|
||||
|
||||
|
||||
def test_for_years_covers_the_requested_years_in_date_order():
|
||||
result = PublicHolidayCalendar().for_years(2022, 2023)
|
||||
|
||||
assert list(result) == sorted(result)
|
||||
assert {d.year for d in result} == {2022, 2023}
|
||||
|
||||
|
||||
def test_khmer_new_year_and_water_festival_2024_match_the_official_calendar():
|
||||
calendar = PublicHolidayCalendar().for_years(2024, 2024)
|
||||
|
||||
for day in (date(2024, 4, 13), date(2024, 4, 16)):
|
||||
assert "Khmer New Year" in calendar[day]
|
||||
for day in (date(2024, 11, 14), date(2024, 11, 16)):
|
||||
assert "Water Festival" in calendar[day]
|
||||
assert "Peace Day" in calendar[date(2024, 12, 29)]
|
||||
|
||||
|
||||
def test_name_on_returns_none_for_a_normal_day():
|
||||
calendar = PublicHolidayCalendar()
|
||||
|
||||
assert calendar.name_on(date(2024, 8, 14)) is None
|
||||
assert "Pchum Ben" in calendar.name_on(date(2025, 9, 22))
|
||||
|
||||
|
||||
def test_describe_merges_consecutive_days_into_a_range():
|
||||
text = PublicHolidayCalendar().describe(2024, 2024)
|
||||
|
||||
assert "- 2024-04-13 to 2024-04-16: Khmer New Year's Day" in text
|
||||
assert "- 2024-11-14 to 2024-11-16: Water Festival" in text
|
||||
assert "- 2024-01-01: International New Year Day" in text
|
||||
|
||||
|
||||
def test_holidays_json_is_an_up_to_date_export_of_the_package():
|
||||
"""Fails when the `holidays` package changed: re-run scripts/update_holidays.py."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
document = json.loads(
|
||||
(Path(__file__).resolve().parents[1] / "data" / "holidays.json").read_text(encoding="utf-8")
|
||||
)
|
||||
calendar = PublicHolidayCalendar(document["country"])
|
||||
|
||||
expected = [
|
||||
{"name": p.name, "start_date": p.start_date.isoformat(), "end_date": p.end_date.isoformat()}
|
||||
for p in calendar.periods(document["first_year"], document["last_year"])
|
||||
]
|
||||
|
||||
assert document["holidays"] == expected
|
||||
|
||||
|
||||
def test_holidays_json_covers_the_booking_years():
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
document = json.loads(
|
||||
(Path(__file__).resolve().parents[1] / "data" / "holidays.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
assert document["first_year"] <= 2022
|
||||
assert document["last_year"] >= 2025
|
||||
Reference in New Issue
Block a user