Hotel PMS: Half-Open Stays, Overbooking, Room Assignment and Out-of-Order Rooms
HardAI-assisted coding · AI-Assisted Feature Work
Harbour & Pine PMS: Stay Inventory, Room Assignment and Out-of-Order Rooms
You've joined the platform team behind the property-management system that runs Harbour & Pine's boutique hotels. Guests book a room type (standard, deluxe, suite) for a range of nights; the actual room is chosen later by the night audit. The front desks have sent in two tickets, and the revenue team has three features queued behind them. Money is in integer cents; there is no floating point anywhere.
Codebase
| File | What it does |
|---|---|
hotel/api.py | run(operations) entry point and the Dispatcher (routing, the repeat helper) |
hotel/service.py | FrontDesk: booking, quoting, cancelling, the day sheet; hooks for the later parts |
hotel/inventory.py | DemandCalendar: how many reservations of each room type hold each night |
hotel/rooms.py | RoomRegistry: physical rooms per type and how many reservations a type can sell |
hotel/policy.py | Cancellation fee policy |
hotel/pricing.py | Nightly rates and stay totals (known-good) |
hotel/dates.py | Day/hour helpers and stay_days |
hotel/reservations.py, hotel/models.py | ReservationBook, Reservation and status constants (known-good) |
hotel/guests.py, hotel/ledger.py, hotel/money.py, hotel/validation.py | Loyalty tiers, the fee ledger, cents helpers, argument checks (known-good) |
hotel/season.py | Deterministic season generator used by the load-test rehearsals (known-good, do not edit) |
hotel/config.py | Every constant: room types, rates, hours, windows, limits |
hotel/assignment.py, hotel/roomcal.py, hotel/blocks.py | Empty: homes for Parts 2 and 4 |
tests/ | Existing unit tests (a few currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh, empty property and returns one result per operation. Every operation is [name, ts, *args].
- Time.
tsis a non-negative integer number of hours since the start of day 0 and never decreases from one operation to the next.day = ts // 24,hour = ts % 24. Day 0 is a Monday. Day numbers stay below 5,000. - Nights. A stay
[check_in, check_out)is *half-open*: the guest sleeps the nightscheck_in ... check_out - 1and leaves on daycheck_out. A guest leaving on day 7 and a guest arriving on day 7 do not overlap. Check-in time is 15:00 on the arrival day. - Errors are returned, never raised, as
"error: <code>". An unknown operation name returns"error: unknown operation". Atsthat is not a non-negative integer (booleans are not integers), or the wrong number of arguments, returns"error: invalid arguments". Within an operation, a wrongly typed or empty argument is alsoinvalid argumentsand is checked before any other error. - Rates (cents per night): standard 10990, deluxe 15990, suite 27990. Friday and Saturday nights (
day % 7is 4 or 5) cost 125% of that, rounded half up to the cent. A stay's price is the sum of its nights.pricing.pyandconfig.pyhold the arithmetic and constants. Room numbers are plain strings, compared in string order. - Reservations get ids
rsv-1,rsv-2, ... in booking order (only accepted bookings use a number). A reservation isbooked(holds inventory, no room yet),assigned(has a room plan),in_house,cancelled,no_showorwalked. Onlybooked,assignedandin_housereservations hold inventory.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
add_rooms | room_type, [room numbers] | true | invalid arguments (empty list), unknown room type, room exists (all-or-nothing) |
set_tier | guest, tier | true | invalid arguments (tier is none, silver, gold or platinum) |
quote | room_type, check_in, check_out | price in cents | unknown room type, invalid stay |
availability | room_type, check_in, check_out | how many more such stays can be booked | unknown room type, invalid stay |
book | guest, room_type, check_in, check_out | reservation id | unknown room type, invalid stay, sold out |
cancel | reservation_id | the cancellation fee in cents | reservation not found, not cancellable |
day_sheet | day | {"arrivals": [ids], "departures": [ids]} | |
stay | reservation_id | {"id","guest","type","check_in","check_out","status","rooms"} | reservation not found |
fee_ledger | list of [reservation_id, kind, cents], oldest first |
invalid stay means: check_out <= check_in, a check_in before today, or more than 30 nights. cancel works only on booked or assigned reservations. day_sheet lists, ordered by reservation number (numerically), the active reservations arriving and departing on that day. stay.rooms is [[room, first_night, end_night_exclusive], ...]: empty until a room plan exists, and empty again for reservations that were cancelled, no-shows or walked. fee_ledger records every non-zero fee, kind being "cancellation" (or "no_show" from Part 3).
[["add_rooms", 0, "standard", ["101"]],
["book", 1, "ann", "standard", 5, 7],
["book", 1, "ben", "standard", 7, 9],
["book", 1, "cat", "standard", 6, 8],
["availability", 1, "standard", 5, 9],
["availability", 1, "standard", 9, 11],
["quote", 1, "standard", 5, 7]]returns [true, "rsv-1", "rsv-2", "error: sold out", 0, 1, 24728]: Ann leaves on day 7 as Ben arrives, so one room serves both; Cat overlaps night 6 and 7. Day 5 is a Saturday (13738) and day 6 a Sunday (10990).
Part 1 - Two defects reported by the front desks (FD-2210, FIN-318)
Goal: make inventory and cancellation fees follow the rules above.
- *FD-2210:* "Our one-room test property shows sold out whenever a guest arrives on the day the previous guest leaves, and cancelling a booking sometimes opens up the wrong nights."
- *FIN-318:* "Cancellations around the 3-day and 1-day marks are charged inconsistently; finance cannot reconcile them against the policy."
Rules
- A reservation holds inventory on exactly its nights
[check_in, check_out); the departure day is not held.availabilityismax(0, min over the nights of (sellable - held)), where for nowsellableis the number of rooms of that type;bookfails withsold outwhen that is 0. Cancelling gives back exactly the nights that booking held. - The cancellation fee is a share of the first night's price, decided by
hours_out = 24 * check_in + 15 - ts(hours before 15:00 on the arrival day; negative after it):hours_out >= 72costs nothing;24 <= hours_out < 72costs 50%;hours_out < 24costs 100%. A share is rounded half up to the cent. quoteand every other price must not change.
Three tests in tests/test_front_desk.py currently fail. Before you change a shared helper, check who else calls it.
Part 2 - Overbooking and room assignment
Goal: sell a little more than we own, then turn type bookings into real rooms - one room per guest if at all possible.
New operations:
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
set_overbooking | room_type, percent | true | invalid arguments (percent not an integer in 0..50), unknown room type |
assign_arrivals | day | list of [reservation_id, tag, segments] | invalid arguments, day in the past |
night_audit | days_ahead | {"own","upgraded","split","walked"} counts | invalid arguments (not an integer in 1..60) |
Overbooking. sellable(type, night) = rooms * (100 + percent) // 100 (default percent 0). availability, book and the sold out rule use it. Lowering the percentage never cancels anything.
Assignment. assign_arrivals(day) houses every booked reservation whose first night is day (day >= today, otherwise day in the past; nothing to do returns []). It handles them in priority order: higher loyalty tier first (platinum > gold > silver > none, using each guest's tier *at the time of the call*), then lower reservation number. Each one is housed completely before the next is looked at, and rooms taken by earlier assignments (any arrival day) are not available. A guest booked into type *T* may be housed in a room of *T* or of the next type up (standard -> deluxe -> suite; suite has none).
For one guest staying nights [lo, hi):
- If any room of the booked type is free on every night, take one room. If several qualify, take the tightest fit: smallest
gap, ties to the smaller room number.gapis(start of the next stay in that room, or 1,000,000) - (end of the previous stay in that room, or -1,000,000), using the room's own stays outside[lo, hi), including past ones. - Otherwise, if any room of the next type up is free on every night, take one (a free upgrade), tightest fit as above.
- Otherwise use as few rooms as possible: at the first night not yet covered, look at every room of the booked type and of the next type up that is free that night and take the one that stays free the longest (capped at
hi); ties go to the booked type, then the smaller room number. That room covers that run; repeat from the night after it. - If at some night no such room is free, the guest is walked: status
walked, the whole reservation is released (inventory and any rooms), no fee.
A guest's room plan is [[room, first_night, end_night_exclusive], ...]. assign_arrivals returns, in the order the guests were handled, [reservation_id, tag, segments] where tag is "walked" (segments []), "split" (more than one segment), "upgraded" (one segment in the next type up) or "own". Assigned reservations become assigned. Cancelling an assigned reservation also frees its rooms.
night_audit(days_ahead) runs assign_arrivals for days today + 1 ... today + days_ahead (tomorrow first) and returns how many guests ended with each tag over all those days.
[["add_rooms", 0, "standard", ["101", "102", "103"]],
["book", 1, "ann", "standard", 12, 16],
["book", 1, "zed", "standard", 13, 15],
["book", 1, "gus", "standard", 10, 14],
["assign_arrivals", 2, 12], ["assign_arrivals", 2, 13], ["assign_arrivals", 2, 10]]returns [true, "rsv-1", "rsv-2", "rsv-3", [["rsv-1","own",[["101",12,16]]]], [["rsv-2","own",[["102",13,15]]]], [["rsv-3","own",[["103",10,14]]]]]. Room 101 is busy from night 12 and room 102 from night 13, so Gus gets 103 for all four nights instead of drifting between rooms.
Part 3 - Arrivals, check-in and no-shows
Goal: unclaimed rooms must come back to the pool by themselves.
New operations:
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
check_in | reservation_id | the room number of the first night | reservation not found, already checked in, reservation not active (cancelled, no-show, walked), not assigned, too early |
check_in_arrivals | skip_every | how many guests were checked in | invalid arguments (not an integer >= 0) |
- No-shows. At 22:00 on the arrival day (
ts >= 24 * check_in + 22) every reservation of that day that is stillbookedorassignedbecomesno_show: it is charged the first night's price (ledger kind"no_show"), and every night of the stay is released (inventory and rooms). Checked-in guests are never no-shows. - Lazy. There is no timer: before the dispatcher handles any accepted operation (valid name,
ts, argument count - including ones that then return an error andrepeat's inner operations) it callsFrontDesk.advance(ts), which must apply all no-shows that became due. They are applied oldest arrival day first and, within a day, in reservation order; this fixes the order offee_ledgerentries. - A booking, quote or availability check whose first night is today is only valid before 22:00 (
invalid stayfrom 22:00 on). check_inneeds anassignedreservation andday(ts) >= check_in(any hour of the arrival day); it makes the reservationin_house. In-house reservations cannot be cancelled (not cancellable).check_in_arrivals(skip_every)checks in everyassignedreservation arriving today, in reservation order, except those whose numbern(fromrsv-n) is a multiple ofskip_every(0 skips nobody).
[["add_rooms", 0, "standard", ["101"]],
["book", 1, "ann", "standard", 10, 12],
["assign_arrivals", 2, 10],
["availability", 261, "standard", 11, 12],
["availability", 262, "standard", 11, 12],
["fee_ledger", 262]]returns [true, "rsv-1", [["rsv-1","own",[["101",10,12]]]], 0, 1, [["rsv-1","no_show",10990]]]: hour 261 is 21:00 on day 10, hour 262 is 22:00, and Ann never showed up.
Part 4 - Rooms out of order, and a whole season
Goal: housekeeping and maintenance can take a room out of service, even one a guest is already assigned to. This reverses an assumption of Part 2 (that an assignment is final), and the system must also stay fast on a full season of reservations.
New operations:
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
out_of_order | room, first_night, end_night | [block_id, moved_ids, walked_ids] | invalid arguments, room not found, invalid block, block overlaps |
return_to_service | block_id | true | invalid arguments, block not found |
report | first_day, end_day | {"demand","occupied","blocked"} | invalid arguments |
Blocks. out_of_order takes room out of service for nights [first_night, end_night). It is invalid block if end_night <= first_night or first_night is before today; block overlaps if the room already has a block overlapping those nights (touching blocks are fine). Block ids are ooo-1, ooo-2, ... in order of acceptance. A blocked night counts as occupied in that room for every assignment rule (including gap), and sellable now uses the rooms in service that night: (rooms - blocked rooms of the type) * (100 + percent) // 100. return_to_service lifts the block; nobody is moved back.
Displaced guests - assigned or in_house reservations with a stay in that room during the block. Minimal disruption means:
- Reservations not in the blocked room during the block are never touched.
- A displaced guest keeps every night before the first night they lose (their first night in that room that is inside the block). Everything from that night to
check_outis re-planned with the Part 2 rules (booked type or next type up, one room if possible, tightest fit, else fewest rooms), now that the block exists. - All displaced guests give up their lost nights before anyone is re-planned; then they are re-planned one by one in Part 2 priority order. Two displaced guests can therefore compete for the same room.
- A guest whose remainder cannot be placed is walked: status
walked, the whole reservation released. - The result is
[block_id, ids of the re-housed guests, ids of the walked guests], each in priority order. Re-housed guests keep their status (in_housestaysin_house).
Report. report(first_day, end_day) (end_day > first_day) sums over nights [first_day, end_day): demand = held reservation-nights per room type, occupied = nights occupied by reservations' rooms in rooms of each type, blocked = blocked room-nights (a single number). demand and occupied are objects with the keys standard, deluxe and suite.
Load-test helpers (known-good). ["seed_season", 0, seed, count, first_day, last_day] books count pseudo-random stays through the normal book path and returns [booked, rejected] (see season.py). ["repeat", ts, count, step, [[name, *args], ...]] runs the inner operations count times, iteration i at ts + i * step, and returns the result of the last inner operation only. Hidden tests run a season with thousands of reservations and thousands of operations in one run; the whole suite has a few seconds.
[["add_rooms", 0, "standard", ["101", "102", "103"]], ["add_rooms", 0, "deluxe", ["201"]],
["book", 1, "ann", "standard", 10, 14], ["book", 1, "ben", "standard", 11, 13], ["book", 1, "cat", "standard", 12, 15],
["assign_arrivals", 2, 10], ["assign_arrivals", 2, 11], ["assign_arrivals", 2, 12],
["out_of_order", 3, "101", 12, 20], ["stay", 3, "rsv-1"]]Ann sleeps in 101 on nights 10-11 and loses it from night 12; 102 and 103 are busy then, so she moves to the deluxe room for the rest of her stay: the block returns ["ooo-1", ["rsv-1"], []] and stay.rooms becomes [["101", 10, 12], ["201", 12, 14]].
Notes
pricing.py,money.py,reservations.py,ledger.py,guests.py,season.pyandvalidation.pyare known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour after the later features.
- Parts 1-3 are a strong result. Part 4 is a stretch.
Example 1
- Input:
- [["add_rooms",0,"standard",["101"]],["book",1,"ann","standard",5,7],["book",1,"ben","standard",7,9],["book",1,"cat","standard",6,8],["availability",1,"standard",5,9],["availability",1,"standard",9,11],["quote",1,"standard",5,7]]
- Output:
- [true,"rsv-1","rsv-2","error: sold out",0,1,24728]
- Explanation
- Ann leaves on day 7 as Ben arrives, so one room serves both. Cat overlaps. Day 5 is a Saturday (13738) and day 6 a Sunday (10990).
Example 2
- Input:
- [["add_rooms",0,"standard",["101","102","103"]],["book",1,"ann","standard",12,16],["book",1,"zed","standard",13,15],["book",1,"gus","standard",10,14],["assign_arrivals",2,12],["assign_arrivals",2,13],["assign_arrivals",2,10]]
- Output:
- [true,"rsv-1","rsv-2","rsv-3",[["rsv-1","own",[["101",12,16]]]],[["rsv-2","own",[["102",13,15]]]],[["rsv-3","own",[["103",10,14]]]]]
- Explanation
- Room 101 is busy from night 12 and 102 from night 13, so Gus gets 103 for all four nights instead of moving between rooms.
Example 3
- Input:
- [["add_rooms",0,"standard",["101"]],["book",1,"ann","standard",10,12],["assign_arrivals",2,10],["availability",261,"standard",11,12],["availability",262,"standard",11,12],["fee_ledger",262]]
- Output:
- [true,"rsv-1",[["rsv-1","own",[["101",10,12]]]],0,1,[["rsv-1","no_show",10990]]]
- Explanation
- Hour 261 is 21:00 on day 10, hour 262 is 22:00. Ann never showed up, so the whole stay is released and the first night is charged.
Guidelines
- Python 3 standard library only
- Amounts are integer cents; time is integer hours and never decreases from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- A stay [check_in, check_out) is half-open: the departure day is not a night
- Part 4 inputs contain thousands of reservations and thousands of operations in one run