# Easy Hotel — PMS module

Self-contained property-management layer for Easy Hotel. Lives entirely under `pms/` and is
loaded by a single line in `easy-hotel.php`:

```php
include 'pms/pms.php';
```

Delete that line and the plugin behaves exactly as it did before — no orphaned hooks, no
altered availability, no changed front-end output.

## Why it is built this way

Availability in Easy Hotel is calculated per **room type**: `total_rooms` minus the number of
overlapping bookings, night by night. Individual rooms are not entities. That is also how
every serious hotel platform works (VikBooking, MotoPress, and the OTA APIs themselves all
sell room *types*, not room *numbers*), because:

* villas and apartments have no room numbers at all;
* Booking.com / Airbnb push inventory at room-type level;
* the hotelier must not be forced to create 40 posts to sell 40 identical rooms.

So this module adds a second, **optional** layer on top:

```
Layer 1 — required   accommodation + total_rooms      owned by the booking engine
Layer 2 — optional   unit labels + unit assignment    owned by this module
```

A booking with no unit assigned is a perfectly normal booking. The rack shows those in a
dedicated *Unassigned* lane rather than treating them as an error.

## Isolation contract

The module is allowed to **read** engine data and is never allowed to write it.

| Owned by the engine | Owned by the PMS module |
| --- | --- |
| `eshb_accomodation_metaboxes` (incl. `total_rooms`) — read only | `eshb_pms_units_metaboxes` on `eshb_accomodation` |
| `eshb_booking_metaboxes` — read only | `eshb_pms_assignment_metaboxes` on `eshb_booking` |
| availability, pricing, cart, checkout — untouched | `eshb_pms_options` |

Other guarantees:

* every class is prefixed `ESHB_PMS_`, every CSS class `eshb-pms-`, every hook `eshb_pms_`;
* no existing file is modified apart from the one `include` line;
* the module puts a tab *inside* the engine's accommodation metabox but stores none of its data
  there — the rows are moved to `eshb_pms_units_metaboxes` before the framework writes the
  array, so `eshb_accomodation_metaboxes` never gains a PMS key;
* the unit count is never duplicated — it is always derived from `total_rooms`, so the two
  can never drift out of sync;
* stay nights are computed with the same convention the engine uses (check-out night not
  occupied, same-day booking occupies one night), so the rack can never disagree with the
  availability calculation.

## Built on the plugin's own settings framework

Everything is declared with `ESHB::createSection()` + `ESHB::createMetabox()` — the same API
`admin/includes/post-types/*/metaboxes.php` uses — so it inherits its markup, styling, nonce
handling, sanitisation and `save_post` pipeline. No hand-rolled metabox, no separate save
routine.

* **Units** lives in a **"Rooms" tab of the engine's own accommodation metabox**, beside
  Capacity, Pricing, Basic Info and Services. The module calls `ESHB::createSection()` with the
  engine's metabox key (`eshb_accomodation_metaboxes`); the framework keeps sections in a shared
  registry, so no engine file is touched and the tab lands last because the module declares at
  `plugins_loaded` priority 20.
* The field itself is a stock `group` field — the same accordion the Basic Info tab uses. Its
  rows are sequential and their position *is* the unit index: row 1 describes unit #1.
* **The rows are not stored in the engine's array.** The field carries the prefixed ID
  `eshb_pms_units`, and `ESHB_PMS_Accommodation_Metabox::extract_units()` — hooked on the
  framework's own `eshb_eshb_accomodation_metaboxes_save` filter — lifts the sanitised rows out
  of the payload and writes them to `eshb_pms_units_metaboxes` before the framework stores the
  rest. Since the framework then finds no stored value for the field, the declared `default` is
  what loads the rows back: `stored_rows()` reads them from the module's key. Engine meta stays
  byte-for-byte what it always was, and removing the module leaves the unit rows intact instead
  of letting the next save wipe them.
* **Assignment** is a `callback` field that still carries an `id`, because the number of
  selects depends on the booking's `room_quantity` and the offered options depend on live
  occupancy — neither is known when the framework collects its sections. Carrying an `id`
  means the framework still collects the submitted value, runs the field's `sanitize`
  handler and writes it with the rest of the metabox data.
* Conflict validation hooks the framework's own `eshb_{$unique}_save` filter, so the module
  never writes the booking meta itself on the edit screen.

Declarations run on `plugins_loaded` at **priority 20**, after the booking engine's own. The
framework instantiates metaboxes in declaration order, so on `save_post` the engine has
already stored `eshb_booking_metaboxes` by the time the assignment is validated against the
dates and room quantity the user just submitted.

### No translation functions in the declarations

`plugins_loaded` runs before `init`, and since WordPress 6.7 calling `__()` and friends that
early produces:

> Notice: Function `_load_textdomain_just_in_time` was called incorrectly. Translation
> loading for the `easy-hotel` domain was triggered too early.

So **every label inside `register()` is a plain string** — the same convention the rest of
the plugin follows (`admin/includes/post-types/*/metaboxes.php` declares plain strings and
only translates inside column and render callbacks).

Translated strings belong in the `callback` renderers, the rack screen, the ajax responses
and the list-table columns — all of which run well after `init`.

`scratchpad/pms-early-i18n-test.php` guards this: it makes every translation function record
a call, runs the whole pre-`init` path, and fails if anything was recorded.

## Files

| File | Responsibility |
| --- | --- |
| `pms.php` | Constants, bootstrap, settings accessors, auto-assign hook |
| `includes/class.pms-units.php` | Unit registry — reads `total_rooms`, reads/sanitises the unit rows |
| `includes/class.pms-assignment.php` | Occupancy map, conflict detection, auto-assign |
| `includes/class.pms-accommodation-metabox.php` | "Rooms" tab inside the engine's accommodation metabox |
| `includes/class.pms-booking-metabox.php` | "Assigned Room" framework metabox + list column |
| `includes/class.pms-rack.php` | Room rack (tape chart) screen |
| `includes/class.pms-ajax.php` | Assign / release / auto-assign endpoints |

## Data shapes

`eshb_pms_units_metaboxes` on an accommodation — the framework `group` field's own shape, so
the rows are sequential and zero based. Row position + 1 is the unit index:

```php
[
    'units' => [
        [ 'label' => '101', 'floor' => 'Ground' ],  // unit #1
        [ 'label' => '',    'floor' => ''       ],  // unit #2, shown as "Unit 2"
    ],
]
```

`eshb_pms_assignment_metaboxes` on a booking — one slot per booked room, `0` is the nullable
state:

```php
[
    'units'       => [ 0 => 3, 1 => 0 ], // 2 rooms booked, first in unit #3, second unassigned
    'auto_assign' => '',                 // the metabox switcher, stored by the framework
]
```

`ESHB_PMS_Assignment::set_assignment()` merges into this array rather than replacing it, so
the switcher survives a rack-side assignment.

### Room numbers are labels, not identity

A unit is identified by `(accommodation_id, row position)`. The number typed into `label` is
only ever displayed, so renaming "101" to "102" moves no booking, and every occupancy query is
scoped to one accommodation.

Two consequences:

* **The same number in two accommodations is allowed and not reported.** Building A 101 and
  Building B 101 is a normal multi-building setup, and the module cannot tell it apart from one
  physical room listed under two room types anyway.
* **The same number twice inside one accommodation is reported**, by
  `ESHB_PMS_Units::find_duplicate_labels()`, as a danger notice on the accommodation screen. It
  corrupts nothing — but the rack would draw two identical rows and staff could not tell which
  booking sits in which room.

It warns rather than blocks the save. The number is cosmetic, so refusing the save would
discard everything else typed on the screen over a field the system does not key on.

Note that if two accommodations really do share a physical room, the engine will still sell
both: availability comes from `total_rooms` per accommodation, and this module never
participates in it. Blocking the *assignment* would not prevent that overbooking, it would only
leave a booking that cannot be assigned anywhere — strictly worse for the front desk. That
belongs in the inventory layer, not here.

## Extension points

```php
add_filter( 'eshb_pms_capability', fn() => 'manage_options' );
add_filter( 'eshb_pms_occupying_statuses', fn( $s ) => array_merge( $s, [ 'my-status' ] ) );
add_filter( 'eshb_pms_accommodation_units', fn( $units, $id ) => $units, 10, 2 );
add_action( 'eshb_pms_assignment_updated', fn( $booking_id, $slots ) => null, 10, 2 );
```

## Deliberately out of scope

* **Unit-level availability.** The engine sells room types; letting a guest reserve a specific
  room number would require the engine's availability query to change, which breaks the
  isolation contract and any future channel-manager work. Assignment stays operational.
* Housekeeping statuses, maintenance blocks, and staff/operator accounts — these belong on
  top of this layer and can reuse `ESHB_PMS_Assignment::get_occupancy_map()` when added.
