# CR-8 — Bookings module (bookings, customers, customer_docs)

Status: **approved for implementation** · Author: BA/reviewer session 2026-08-31
Wireframes (design source of truth, vendored in `docs/wireframes-v12/`):
`booking-management.html` (listing) · `new-booking.html` (create) ·
`edit-booking.html` (edit, tabbed) · `view-booking.html` (show) ·
`add-upload-document.html` (document upload screen).
Recipe: `docs/adding-a-module.md` still governs **except where §0 below
overrides it** — the client gave explicit instructions that deviate from house
convention, and the client wins.

## 0. Client-mandated convention overrides (do NOT "fix" these back)

These are the user's explicit instructions for THIS module. Each one
contradicts the house recipe on purpose; note the override in the relevant
file's docblock so a future reader doesn't "correct" it:

1. **No lang files.** All user-facing text — labels, placeholders, section
   titles, flash messages, validation attribute names, JS strings — is written
   directly in the controllers and blade views. Do not create
   `lang/en/bookings.php` or touch existing lang files (exception: nothing —
   the sidebar label `nav.bookings` already exists and is reused).
2. **No `routes/modules/bookings.php`.** All booking routes are declared
   directly inside the existing `Route::middleware('auth')->group()` in
   `routes/web.php` (after the module requires).
3. **ONE shared FormRequest for create and edit** (`BookingRequest`), because
   the required fields are identical. It serves both store and update.
4. **No Policy classes and no new tests.** Authorization still exists (see §7)
   but via bare permission checks (`Gate::authorize('bookings.view')`,
   `$this->user()->can('bookings.create')`, `@can('bookings.edit')`) — spatie
   registers permissions as gate abilities, and `Gate::before` still carries
   Super Admin. Do not create `app/Policies/BookingPolicy.php` or any
   `tests/Feature/Bookings/*`. The EXISTING suite must stay green, which
   requires minimally updating tests that pin things this CR changes (see §10).
5. **Separate create.blade.php and edit.blade.php** (house twin pattern anyway;
   they differ structurally here — edit is tabbed).

## 1. Scope

**In scope:** `bookings`, `customers`, `customer_docs` tables; booking CRUD
(listing + create + edit + show); cancel action (NOT delete — client decision);
inquiry code format change to 5 digits; "Proceed to Booking" wiring; unit
booking + inquiry `booking_confirm` writers (first in the app); customer
document upload/list/download/delete scoped to the booking-type→doc-type
mapping; sidebar Bookings entry goes live; permissions `bookings.*`.

**Non-goals:** payment/financials anywhere (client: "financial not required" —
no Payment Summary card, no Revenue stat card, no amount fields); PAN/Aadhaar
fields (drawn on view-booking but captured nowhere — omit the rows);
Tower/Floor/Unit Type/Built-up/Facing rows on the unit card (units have no such
columns — render only Project, Unit No., Carpet Area); the `booking` option in
the inquiries listing's Booking Action select STAYS inert (only the "Proceed to
Booking" button is wired, per instruction); the "Customer Documents" and
"Cancellation" sidebar sections stay preview-inert; registered/transferred
statuses have no writer yet; no demo booking seeder; `tools/responsive-smoke.mjs`
untouched.

## 2. Database

### 2.1 `bookings`

| column | type | notes |
|---|---|---|
| id | id | |
| code | string unique | `BK-YYYY-00000`; `Booking::nextCode()` cloned from `Inquiry::nextCode()` (same lockForUpdate + withTrashed scan + `RetryOnUniqueCollision::once()` around the whole transaction); not fillable, forceFill only |
| booking_type_id | FK booking_types, restrictOnDelete | the "Booking Entity Type" |
| booking_date | date | defaults to today in the form, not in the schema |
| inquiry_id | FK inquiries, restrictOnDelete | required |
| project_id | FK projects, restrictOnDelete | |
| unit_id | FK units, restrictOnDelete | |
| status | string default 'draft' | enum `BookingStatus`: draft, confirmed, registered, cancelled, transferred (only draft/confirmed/cancelled writable this CR; string column never SQL ENUM) |
| through_name | string nullable | the "Through its …" name |
| assigned_to | FK users nullable nullOnDelete | "Assigned Sales Person"; on create defaults to the inquiry's assignee |
| contact_name / contact_mobile / contact_email | string nullable | Contact Person section |
| contact_address | text nullable | |
| special_conditions | text nullable | Remarks section |
| created_by / updated_by | FK users nullable nullOnDelete | forceFill, not fillable |
| timestamps, softDeletes | | |

### 2.2 `customers`

One row per person on a booking; the main customer and every co-owner.

| column | type | notes |
|---|---|---|
| id | id | |
| booking_id | FK bookings, cascadeOnDelete | |
| name | string | "Common Name" |
| occupation | string nullable | value of the existing `Occupation` enum |
| mobile_number | string | |
| alternate_mobile | string nullable | |
| email | string nullable | required for the main customer via request rules only |
| address | text nullable | required for main via rules only |
| **is_co_owner** | unsignedTinyInteger **default 0** | **client-specified**: 0 = main customer, 1 = co-owner; set to 1 whenever a co-owner row is saved |
| created_by / updated_by | FK users nullable | |
| timestamps, softDeletes | | |

Exactly ONE `is_co_owner = 0` row per booking (invariant the service maintains;
edits update-in-place / create / delete co-owner rows by comparing submitted
rows against existing ones — carry each row's `id` as a hidden field).

### 2.3 `customer_docs`

| column | type | notes |
|---|---|---|
| id | id | |
| booking_id | FK bookings, cascadeOnDelete | |
| customer_id | FK customers, cascadeOnDelete | whose document: the main customer, or a co-owner if the Co-Applicant select was used |
| customer_doc_type_id | FK customer_doc_types, restrictOnDelete | |
| file_path | string | server-named, see §6 |
| original_name | string | client name, data only, truncated 255 |
| remarks | text nullable | the upload screen's "Remark Notes", stored on each row of the batch |
| created_by | FK users nullable | |
| timestamps, softDeletes | | |

### 2.4 Inquiry code format change (client instruction)

Format becomes **`INQ-YYYY-00000`** (5 digits). Two changes:
- `Inquiry::CODE_DIGITS` 4 → 5 (generator + factory follow automatically).
- A data migration re-pads every existing code:
  `INQ-2026-0142` → `INQ-2026-00142` (string surgery on the segment after the
  second dash; `withTrashed`; uniqueness is preserved because padding is
  injective). Header docblock explains the client instruction.
- Sweep the existing suite for literal 4-digit code assertions and update them
  (that is fixing pins, not writing new tests).

`Booking::CODE_DIGITS = 5` too (wireframe draws `BK-2026-0384`; one house
style for generated codes wins — recorded deviation).

## 3. Behaviour

### 3.1 Statuses and side effects (client-confirmed decisions)

- **Save as Draft** → `status = draft`. NO side effects: unit untouched,
  inquiry untouched. Two drafts may point at the same unit.
- **Confirm Booking** → `status = confirmed`, and inside the SAME transaction:
  re-read the unit `lockForUpdate()`; it must still be `available`
  (else a validation error on `unit_id`, back to the form); set
  `units.status = booked`; set the inquiry `status = booking_confirm` via
  forceFill. (First writer of both in the app — say so in docblocks. Known,
  accepted quirk: a later interested follow-up moves the inquiry back to
  `follow_up`; documented in FollowUpRecorder.)
- **Cancel** (replaces Delete everywhere — client decision): member action
  `POST /bookings/{booking}/cancel` with a JS confirm. Sets
  `status = cancelled`; if it was confirmed, in the same transaction free the
  unit (`booked` → `available`, lockForUpdate) and revert the inquiry to
  `follow_up`. A cancelled booking is view-only: no edit, no cancel button, no
  document upload/delete (downloads still allowed). There is NO destroy route
  and NO delete button anywhere in this module.
- **Edit**: a draft's footer shows "Save as Draft" + "Confirm Booking" (its
  path to confirmation); a confirmed booking's footer shows only "Save Changes"
  (status stays confirmed) — matches edit-booking.html, which draws a
  confirmed booking. Changing the unit on a confirmed booking frees the old
  unit and books the new one (both under lock, one transaction). Changing the
  inquiry on a confirmed booking reverts the old inquiry to `follow_up` and
  sets the new one to `booking_confirm`.
- Submit buttons: one form, `name="action"` value `draft|confirm|save`;
  validated against the allowed set for the booking's state.

### 3.2 Form field rules (both screens; one `BookingRequest`)

Required: booking_type_id (exists, active-agnostic, non-deleted), booking_date
(date), inquiry_id (exists non-deleted), project_id, unit_id (exists, belongs
to project — tamper-checked; must be `available` OR the booking's own current
unit on edit), main customer: name, occupation (in `Occupation` enum values),
mobile_number, email (email rule), address.
`through_name`: **required when** the selected booking type's name is one of
the mapped six (see §3.3); otherwise forbidden-to-matter (nullable, ignored,
cleared on save when unmapped).
Optional: alternate_mobile, contact_* (email rule when present),
special_conditions, assigned_to (exists users).
Co-owners: `co_owners` array, each row: `name` **required** (client
instruction: "Common name required for all Co-Owner"), `mobile_number`
required, `occupation` nullable in-enum, `alternate_mobile`/`email`(email)/
`address` nullable, `id` nullable-integer belonging to THIS booking (tamper
check — a foreign row id must fail validation).
Max lengths 255 (text columns unlimited). Since it is one FormRequest for both
verbs, `unique`-style rules don't apply (there are none on these tables).
jsvalidation: bind like the inquiry form, with `clientRuleExclusions()` for the
conditional/array rules proengsoft can't express.

### 3.3 The "Through its …" map

Constant on the Booking model (name-keyed; booking types are dynamic rows and
only these six get a block — any other type, including Individual, shows none):
HUF → "Through its Karta"; Proprietorship → "Through its Proprietor";
LLP → "Through its Designated Partner / Authorized Person";
Partnership → "Through its Partner / Authorized Person";
Company → "Through its Director / Authorized Person";
Trust → "Through its Trustee / Authorized Signatory".
Placeholders per wireframe ("Enter Karta name", …). One block in the DOM,
retitled/shown by JS from a JSON map in a data attribute (rendered from the
constant — single source). Server enforces the required-when rule from the
same constant.

### 3.4 Dropdown population

- **Inquiry select**: every non-deleted inquiry that does NOT already have a
  live booking (draft or confirmed; cancelled/soft-deleted bookings don't
  block), PLUS the booking's own inquiry on edit. Option label exactly:
  `INQ-2026-00145 - Rajesh Patel (3 BHK, Navratna Gulmohar Park)` — code,
  full_name, property_type label, project name; property_type/project parts
  omitted gracefully when null. Eager-load; no N+1.
- **Project select**: all non-deleted projects.
- **Unit select** ("Only available units shown"): units of the selected project
  with `status = available` ONLY (stricter than the inquiry module's rule —
  this is a purchase, not a preference), plus, on edit, the booking's own unit.
  Labels: `A-1201 (Available)` / own unit `A-1204 (Current)`. New JSON cascade
  endpoint `GET api/bookings/projects/{project}/units` (+ `?include_unit=` for
  edit) registered in web.php beside the other booking routes, authorized
  `Gate::authorize('bookings.view')` — do NOT widen the inquiries endpoint.
  JS mirrors nrems-inquiry-form.js's loadUnits (fetch, createElement, clear on
  failure).
- **Assigned Sales Person** (edit-booking wireframe; also render on create for
  twin parity — deviation recorded): all active users, like the inquiry form's
  assigned_to.
- **Occupation**: the existing `Occupation` enum options on BOTH screens and in
  co-owner rows (create-wireframe draws a free-text input; edit-wireframe and
  every co-owner row draw the select — the select wins for data sanity;
  recorded deviation).

### 3.5 Prefill from an inquiry ("Proceed to Booking")

`GET /bookings/create?inquiry={id}` prefills (server-side, create only):
inquiry_id selected; name ← full_name; mobile_number ← whatsapp_number;
alternate_mobile, email, occupation, address ← current_address;
project_id; unit_id ← tentative_unit_id (only if that unit is still
available — else leave unit empty); assigned_to ← inquiry's assigned_to;
booking_type left empty. Changing the inquiry select later does NOT rewrite
typed fields (assumption, recorded). Booking date input defaults to today
(`value` = today when no old input).

**Wiring the button:** in `inquiries/show.blade.php`, `create.blade.php`,
`edit.blade.php`, `follow-up.blade.php`, replace the disabled
`.btn-booking` button with a live link
`<a href="{{ route('bookings.create', ['inquiry' => $inquiry->id]) }}" class="btn btn-outline btn-booking">`
— same icon and label, no `disabled`, no coming-soon title — **only where an
$inquiry exists** (create.blade.php has none: keep it disabled there, since
there is no inquiry to book yet). Gate the live link behind
`@can('bookings.create')` (fall back to the disabled button without it). An
inquiry already holding a live booking, or one whose status is
`booking_confirm`: render the disabled button (title text inline, no lang:
"Booking already exists").

### 3.6 Customer documents (the "documents tab")

- **Upload screen** `GET/POST /bookings/{booking}/documents` styled from
  `add-upload-document.html`: Reference section shows the booking's main
  customer as a fixed display control (not a free select — this flow is
  per-booking; deviation recorded) + optional "Co-Applicant" select of THIS
  booking's co-owners + the Booking Type chip (`fa-tag`, primary-light pill,
  exactly as drawn). Document rows (dashed dropzone-styled file input per
  wireframe, `accept=".pdf,.jpg,.jpeg,.png,.doc,.docx"`, hint
  "PDF, JPG, PNG, DOC — Max 5 MB"): Document Type select listing **ONLY the
  CustomerDocTypes mapped to the booking's booking type** (client instruction),
  + file. "Add More" clones a row from a `<template>` (createElement-composed,
  no innerHTML with data), Remove per row (first row irremovable, like the
  wireframe). "Remark Notes" textarea applies to the batch. Footer: Cancel +
  "Upload Document".
- **After booking save** (draft AND confirm), redirect to this screen with the
  success flash; Cancel returns to the listing. Booking types with zero mapped
  doc types skip straight to the listing.
- **Validation** per row: doc type id required, integer, must be mapped to the
  booking's booking type AND non-deleted (tamper check); file required,
  `file|mimes:pdf,jpg,jpeg,png,doc,docx|mimetypes:...|max:5120` (both mimes and
  mimetypes, ProjectFileService precedent); co-applicant id, when present, must
  be a co-owner OF THIS booking. At least one row.
- **Storage** — `BookingDocumentService` cloned from the four
  `ProjectFileService` rules: server-named file `{doctype-slug}-{40 hex}.{ext}`
  with the sniffed extension; directory `bookings/{booking->id}`;
  `config('nrems.uploads_disk')` private-only; download is a streamed
  attachment with looked-up content type + `nosniff`, every failure a 404.
  Files written OUTSIDE any transaction; rows inside one.
- **Documents tab** on edit + show (per edit-booking.html): table Document Type
  / File Name / Uploaded By / Date / Actions (View = download stream, Delete =
  POST form w/ confirm; delete removes the row (soft) and the stored object).
  "Upload Document" button → the upload screen. Downloads:
  `GET /bookings/{booking}/documents/{document}` with a scoped binding (the
  document must belong to the booking — 404 otherwise).
- **History tab** on edit + show: the wireframe's `.timeline` fed from the
  booking's activitylog entries (HasAudit) — time + a plain-English event line
  composed in the view (created/updated/status events; keep it simple).
- Tabs are same-page show/hide (edit-booking.html's switchTab), not separate
  routes.

## 4. Screens — pixel fidelity

- **Listing** (`booking-management.html`): 3 stat cards — Total Bookings,
  Active (= confirmed count, sub-line "X% of total"), Cancelled (sub-line
  "X% cancellation") — the ₹ Revenue card is DROPPED (client) and the grid
  renders 3 across; Total's sub-line "X this month" computed real. Filter bar:
  search ("Search by booking ID, Booking Name, unit..."), project select ("All
  Projects"), status select ("All Status" + the five statuses), From/To date
  filters. Default = all statuses; the wireframe's "Showing confirmed bookings
  only." info line is DROPPED (it contradicts the All-Status default —
  recorded deviation). Yajra server-side table per house pattern
  (whitelist physical columns; computed columns unsearchable; filters as
  `filter_*` preXhr params; search covers code, main-customer name,
  unit_number via filterColumn): Date (d M Y) / Booking Details (main
  customer name, `fw-500`) / Project / Unit / Booking Entity Type / Assigned To
  / Actions (`action-group` with `btn-icon`s: View eye, Edit pen — hidden for
  cancelled — and Cancel `fa-ban` danger, per client, replacing the wireframe's
  trash). "Add Booking" primary button above the card per house listing
  pattern (wireframe's action row is empty — recorded addition, matching
  projects/inquiries).
- **Create** (`new-booking.html`): one card, sections in order: Booking
  Information (`fa-file-contract`; entity type / booking date / inquiry,
  form-row-3, inquiry helper "Link this booking to an inquiry"; plus the
  Assigned Sales Person row for parity — §3.4), Booking Details (`fa-user`;
  common name + through-block + occupation, then mobile/alternate,
  email/address rows exactly as drawn), Co-Owner Details (`fa-users`; title row
  with "Add More Co-Owner" outline-sm button right; **starts EMPTY** — client:
  "Default Co-Owner Details blank"; the dashed info note
  `Click "Add More Co-Owner" to add additional co-owners.` always present),
  Contact Person (`fa-address-card`), Unit Selection (`fa-building`; project +
  unit, helper "Only available units shown"), Remarks (`fa-comment`). Footer:
  Cancel (link to listing) / "Save as Draft" (outline, fa-save) / "Confirm
  Booking" (primary, fa-check).
- **Co-owner blocks**: `.co-applicant-block` styling from new-booking.html's
  page-local CSS (ported to nrems-wireframe.css app-additions), header
  "Co-Owner N" + remove `btn-icon-sm text-danger` (fa-times), fields per
  wireframe (name/occupation, mobile/alternate, email/address). Rows are
  `co_owners[i][field]`, cloned from a `<template>`; numbering re-drawn on
  add/remove.
- **Edit** (`edit-booking.html`): top bar — status badge + muted meta
  `BK-2026-00384 · {customer} · {unit}` + View outline button; tab strip
  (Basic Details / Documents / History with the drawn icons); Basic tab = the
  create card with edit-wireframe's field order in Booking Information
  (booking date / inquiry / entity type, then Assigned Sales Person) and
  footer per §3.1. The stray duplicated occupation block in the wireframe
  (lines 275-288) is a wireframe bug — ignore.
- **Show** (`view-booking.html`): title "Booking Details - BK-…", breadcrumb
  `Home / Booking Management / BK-…`; status badge row (badge enlarged per
  inline style) + right buttons: Print (window.print()), Edit (hidden when
  cancelled), Cancel Booking (`btn-danger btn-sm`, fa-ban; hidden when
  cancelled/draft→ show for draft too? — show for draft AND confirmed, hidden
  cancelled); tabs; Basic tab: grid-2 of Customer Details card (Booking
  Details name / Phone / Email / Co-Owner rows — one per co-owner; PAN/Aadhaar
  omitted) + Unit Details card (Project / Unit No. / Carpet Area only);
  Contact Person card (its rows + the "Through its" row:
  `Karta — {through_name}` style, only when set). Payment Summary omitted.
- **Statuses** → badges: draft = neutral/secondary, confirmed = success (+dot),
  registered = info, cancelled = danger, transferred = warning; via
  `x-status-badge` (extend `StatusBadge` with a BOOKING_STATUSES map) or an
  inline partial — implementer's pick, consistent across listing/edit/show.
- All page-local wireframe CSS these screens need (`.co-applicant-block`,
  doc-row/dropzone looks, timeline if missing, tab styles if missing) is ported
  into the nrems-wireframe.css app-additions tail — check what already exists
  before adding.

## 5. Client-mandated JS behaviour (co-owner guard)

`public/assets/js/nrems-booking-form.js` (one file, config via data attributes
on a single root, data-role hooks, no innerHTML for data, delegated events):
- Add/remove co-owner blocks (from `<template>`), renumber titles.
- **On "Add More Co-Owner" click: if the MAIN customer's Common Name is empty,
  or any existing co-owner block's Common Name is empty, do NOT add a block —
  show an inline error message under the offending field(s)** (red
  `.invalid-feedback d-block` text, e.g. "Please enter the common name before
  adding a co-owner."), focus the first offender, clear the message once the
  field is filled (input listener). Client instruction verbatim: the error must
  appear on the click, before form submit, via JavaScript. Server rules still
  enforce it (§3.2).
- Entity-type → through-block show/hide + retitle (map from data attribute).
- Project → unit cascade (§3.4).
- Booking date default: server renders value; JS does nothing.
- Old-input restore: co-owner rows re-render server-side from `old('co_owners')`
  on validation failure (blade loop over old input, not JS).

Strings INSIDE this JS: allowed inline in the data-strings JSON rendered from
the blade (keeps the no-wording-in-JS rule while honouring "no lang files" —
the strings literal lives in the view).

## 6. Backend shape

- Enums: `BookingStatus` (string-backed, label() no-default; five cases).
- Models: `Booking` (HasAudit, HasFactory; fillable = the §3.2 client-writable
  columns EXCEPT code/status/created_by/updated_by which are forceFill;
  relations inquiry/project/unit/bookingType/assignee/customers/coOwners
  (is_co_owner scopes)/mainCustomer (hasOne where 0)/documents; CODE_PREFIX
  'BK-', nextCode(), THROUGH_MAP const), `Customer` (HasAudit; casts
  is_co_owner int; booking()), `CustomerDoc` (HasAudit; booking(), customer(),
  docType()).
- Factories: yes for all three (harmless, useful later; not tests).
- `BookingService` (app/Services/Bookings/): the single writer of
  bookings+customers rows and the unit/inquiry side effects; `save(...)` used
  by store and update; `cancel(Booking)`; all §3.1 transactions and
  lockForUpdate logic live here, controller stays thin.
- `BookingDocumentService` (§3.6).
- `BookingUnitOptions` service or a scoped query in the cascade controller —
  implementer's call; do NOT modify `App\Services\Inquiries\UnitOptions`.
- `BookingsDataTable` per house skeleton (whitelist: booking_date + code…
  physical only; relation order via constant-SQL orderColumn like
  InquiriesDataTable's RELATION_ORDER_SQL; eager-load
  mainCustomer/project/unit/bookingType/assignee).
- Controllers: `BookingController` (final; index/create/store/show/edit/update
  + cancel member) and `BookingDocumentController` (final; create/store +
  download `show` + destroy). Flash sentences inline (no lang), e.g.
  `Booking BK-2026-00385 saved as draft.` / `…confirmed.` / `…cancelled.` /
  `Documents uploaded.`
- Routes (in web.php, inside the auth group, with a comment noting the client
  instruction): `Route::resource('bookings', ...)->except(['destroy'])` with
  snake names + `POST bookings/{booking}/cancel` (`bookings.cancel`) +
  `GET/POST bookings/{booking}/documents` (`bookings.documents.create/store`) +
  `GET bookings/{booking}/documents/{document}` (`bookings.documents.download`,
  scoped binding) + `POST .../documents/{document}/delete` or DELETE
  (`bookings.documents.destroy`) + `GET api/bookings/projects/{project}/units`
  (`bookings.unit_options`) — member/api routes BEFORE the resource.
- Permissions: append `bookings.view|create|edit|delete` to
  `RolePermissionSeeder::PERMISSIONS`; Admin all; **Sales: view/create/edit
  (no delete)** — CR-6 precedent; Operations none. `bookings.delete` guards the
  CANCEL action and document deletes (there is no destroy; recorded decision —
  cancel is the destructive act here). Sidebar: Bookings entry live under
  `@can('bookings.view')` with `$inBookings = request()->routeIs('bookings.*')`,
  Sales heading gate widened (CR-7 ceremony).

## 7. Security gates (review will check)

1. Every route authorized: reads `Gate::authorize('bookings.view')`, writes via
   BookingRequest::authorize() (`bookings.create` on store / `bookings.edit` on
   update — detect by bound route param), cancel + doc-destroy
   `bookings.delete`, uploads `bookings.edit`, downloads `bookings.view`.
   Guests redirect to login. NO unauthorized route, policies or not.
2. Tamper: unit not in project / not available / co-owner id from another
   booking / doc type not mapped to the booking's type / co-applicant not of
   this booking / document of another booking (scoped binding) — all rejected.
3. Mass assignment: code/status/created_by/updated_by/is_co_owner never
   fillable from request data (is_co_owner is set by the service by row
   position, never from input).
4. Uploads: both `mimes` and `mimetypes`; server-generated names; private disk;
   streamed nosniff downloads; original_name sanitized on the way OUT
   (ProjectFileService::downloadName precedent).
5. XSS: all names/labels escaped; option labels built server-side or via
   textContent; `Js::from()`/escaped-JSON data attributes for JS payloads.
6. Transactions + locks per §3.1/§3.6; nextCode inside the transaction wrapped
   by RetryOnUniqueCollision.
7. DataTable whitelist physical-only; enum filters via tryFrom-or-drop.

## 8. Gates (unchanged)

`php artisan config:clear` + `php artisan test` — the EXISTING suite fully
green (no new tests written); `composer analyse` level 6 = 0 errors; scoped
`vendor/bin/pint --test` clean. PATH note: prepend
`C:\wamp64\bin\php\php8.3.28`.

## 9. Decisions & assumptions log

- D1 (client): cancel replaces delete; effects per §3.1.
- D2 (client): confirm books unit + sets inquiry booking_confirm; draft inert.
- D3 (client): no financial UI at all.
- D4: inquiry codes re-padded to 5 digits by data migration; bookings born at 5.
- D5: occupation is a select (enum) on both screens; create-wireframe's text
  input loses to the edit-wireframe + co-owner + inquiry-module precedent.
- D6: Assigned Sales Person rendered on create too (twin parity).
- D7: "Showing confirmed bookings only." line dropped as self-contradictory.
- D8: listing's delete icon replaced by Cancel (`fa-ban`); trash appears
  nowhere.
- D9: booking-action select's `booking` option stays inert this CR.
- D10: through-map keyed by booking type NAME; unmapped types (incl.
  Individual and any user-created type) get no block; through_name cleared
  when unmapped.
- D11: upload screen's Customer control is a fixed display of the booking's
  main customer (wireframe's free customer select belongs to the future
  standalone Customer Documents module).
- D12: PAN/Aadhaar and Tower/Floor/etc. rows omitted (no columns exist).
- D13: `bookings.delete` permission gates cancel + document deletion.
- A1: no reservation of units by drafts.
- A2: history timeline is a plain rendering of activitylog rows.

## 10. Existing tests this CR may touch (fix pins ONLY, add nothing)

`AppShellTest` (Bookings entry goes live: gating arm + route/href dataset row +
the "inquiries.view holder must not see Bookings" case flips to a
bookings.view-based assertion per CR-7 ceremony); `RolePermissionSeedTest`
(matrix 16 → 20); any test pinning a literal 4-digit inquiry code; nothing
else. If an unrelated test breaks, the change causing it is wrong — fix the
change, not the test.
