# Analyze Property → the mobile app's API (Layout Analysis · Saved)

## What it does

Gives the PropertyLab mobile app (Flutter, `property-lab/propertylab-mobile`) the **same Layout
Analysis table and Saved shortlist the portal shows**, as versioned JSON. There is no second
backend and no second calculation: every endpoint below runs the portal's own code and only
changes the envelope. The New Project listing and project detail contracts are Codex's
(`MobileAnalyzeController@newProjects` / `@project`); this file covers what was added 2026-09-20,
and the three parity contracts on those endpoints (further down).

| Endpoint | Portal equivalent | Gate (identical to the web route) |
|---|---|---|
| `GET /member-api/v1/analyze/layout-analysis` | `/analyze-property/layout-analysis` | `auth:api` · `lock.analyze` · `feature:LAYOUT_ANALYSIS` |
| `GET /member-api/v1/analyze/saved` | `/analyze-property/saved` | `auth:api` · `lock.analyze` · `feature:NEW_PROJECTS` |
| `POST /member-api/v1/saved-layouts` | `POST /saved-layouts` | `auth:api` · `feature:NEW_PROJECTS` — deliberately OUTSIDE `lock.analyze` |

All need `Authorization: Bearer <member JWT>` and `Accept: application/json`; without a token they
answer `401`, without the feature `403`. Responses are `Cache-Control: private, no-store`.

## How it works

- **One table.** [`App\Actions\BuildLayoutTable`](/app/Actions/BuildLayoutTable.php) builds the
  props; the portal renders them, the API transforms them. `MobileLayoutAnalysisTest::
  test_the_app_and_the_portal_page_are_the_same_table` fails if they ever list different rows.
- **Query parameters — the portal's own:** `sort` (one of `sort.options`; anything else falls back
  to `cashflow`), `direction` (`asc` | `desc`, default `desc`), `q` (project / location / layout
  name), `price` (a `BuildNewProjectListing::PRICE_BANDS` key — the same bands as New Project),
  `positive=1` (cash flow above zero only), `page`. 25 per page. Rows with no rent sort LAST on
  every order.
- **Figures are numbers, and unknown is `null`.** `price`, `psf`, `fair_value`, `discount_pct`,
  `rent_median`, `yield_pct`, `instalment`, `cashflow` come straight off `layout_analyses` — the
  projection the analysis engine writes. A layout the engine had no rent for has `null` cash flow
  and yield: the app must print "—", never RM 0. The app formats; it never recomputes.
- **Opening a row.** `project.country` + `project.slug` are exactly what
  `GET /member-api/v1/analyze/projects/{country}/{slug}` takes, and `floor_plan_uuid` is the unit
  to pre-select (the portal's `?unit=`), so Units · Price / Investment Analysis / Financial
  Projection open on the layout the member tapped.
- **The star.** `saved` on each row; toggle with `POST /member-api/v1/saved-layouts`
  `{ "floor_plan": "<floor_plan_uuid>" }` → `{ "saved": true|false }` (the state AFTER the toggle).
  It is the portal's own `toggleSaved` — one writer for the shortlist — and sits outside the
  Analyze lockdown for the same reason the web route does: the Save button also lives on the
  project page.
- **`unanalysed_saved`** (Saved only): layouts starred before their analysis exists have no row to
  show; the count lets the app say "2 saved layouts have no analysis yet", as the portal does.

### Response shape (`data`)

```json
{
  "contract_version": 1,
  "section": "layout-analysis",
  "layouts": [{
    "uuid": "…", "floor_plan_uuid": "…", "saved": false,
    "project": { "uuid": "…", "name": "Binastra Cochrane", "country": "my", "slug": "binastra-cochrane",
                 "location": "Cheras", "property_type": "Serviced Residence", "image": "https://…" },
    "layout":  { "name": "Type A", "image": "https://…", "sqft": 850, "bedrooms": 3,
                 "is_dual_key": false, "key_label": null },
    "currency": "MYR", "price": 649620, "psf": 1001, "fair_value": 700000, "discount_pct": 7.2,
    "rent_median": 3200, "yield_pct": 5.9, "instalment": 2576, "cashflow": 624
  }],
  "filters": { "q": "", "price": "", "positive": false },
  "sort": { "by": "cashflow", "direction": "desc", "options": ["cashflow", "yield_pct", "price", "…"] },
  "unanalysed_saved": 0,
  "pagination": { "page": 1, "last_page": 3, "total": 67, "from": 1, "to": 25, "per_page": 25 }
}
```

## Related files

- [`app/Actions/BuildLayoutTable.php`](/app/Actions/BuildLayoutTable.php) — the table
- [`app/Http/Controllers/Api/v1/MobileAnalyzeController.php`](/app/Http/Controllers/Api/v1/MobileAnalyzeController.php) — `layoutAnalysis()`, `saved()`
- [`app/Http/Transformers/v1/MobileAnalyzeTransformer.php`](/app/Http/Transformers/v1/MobileAnalyzeTransformer.php) — `layoutTable()`
- [`routes/api/v1.php`](/routes/api/v1.php) — the three routes and their gates
- [`tests/Feature/Api/v1/MobileLayoutAnalysisTest.php`](/tests/Feature/Api/v1/MobileLayoutAnalysisTest.php)

## Project detail — three parity contracts (2026-09-20, mobile audit G-01 / G-02 / G-03)

An audit of the app against the portal found three places where the SAME member looking at the
SAME project was shown different numbers. Each was a mobile endpoint answering from a different
source than the page. All three fixes are **additive** — older app builds keep working; what
changes for them is that the default figures now match the website, which is the point.

### G-01 · One rent per layout — `…/analysis` and `…/projection`

The page quotes ONE monthly rent for a layout on its Units card, Investment Analysis and Financial
Projection (`layoutRent` in `ProjectDetailContent.vue`, computed in the browser by
`petaLayoutRent`). `/projection` seeded the engine's POOLED `result.rental.median_rent` instead.
Binastra Cochrane Type A (Studio + 1 bed, RM 649,620):

| | Website | App before | App now |
|---|---|---|---|
| Seed rent | RM 4,383 | RM 3,850 | RM 4,383 |
| Paid off | month 144 (Y12 m12) | month 166 (Y14 m10) | month 144 |
| Total interest | RM 171,752 | RM 202,114 | RM 171,752 |
| Retained cashflow | RM 2,467,147 | RM 2,044,780 | RM 2,467,147 |

Both endpoints now ask [`Src\Analysis\Support\LayoutRent`](/src/Analysis/Support/LayoutRent.php),
the single server-side way to ask `RentalPrediction` for a floor plan's rent (see the rent-port
warning in [readMe.md](/docs/modules_handbook/main/analyze-property/readMe.md), failure 6).
**Do not port `petaLayoutRent` to Dart** — it has drifted between JS and PHP five times already.

- **`GET …/projects/{country}/{slug}/analysis?floor_plan=<uuid>`** gains three top-level keys,
  added AFTER the saved payload is read (never persisted; `result` is byte-for-byte what it was,
  and the website ignores them):
  - `predicted_rent` — the layout's predicted monthly rent (dual key = the sum of its keys), or
    `null` when the analysis cannot price it. This is what Investment Analysis quotes; the app must
    use it for rent, yield and cashflow, **never `result.rental.median_rent`**.
  - `rent_source` — `"analysis"`, or `null` with a null rent.
  - `gross_yield` — this layout's gross yield on that rent, `predicted_rent × 12 ÷ its price × 100`
    (2 dp), or `null` (2026-09-22, for the app's key-metrics strip — the same sum as Layout
    Analysis's `yield_pct` and the New Project card's ROI badge).
  - `keys[]` — `{ label, bedrooms, sqft, rent, sample, radius_km, widened }` per key for a
    multi-key layout, `[]` otherwise. `label` is `"Studio"` / `"1 bed"`; `sqft` is `null` when the
    layout has no estimated split (the key was then priced on its bedroom market's median);
    `sample` is how many listings the figure was drawn from; `widened: true` means the 1 km ring
    was empty and `radius_km` (2) answered — print it, as the page does. The rows sum to
    `predicted_rent` by construction (`forLayout()` sums `keyBreakdown()`).
- **`GET …/projection?floor_plan=<uuid>`** seeds `inputs.rent` from the page's whole precedence —
  analysis → the plan's stored `rental_price` → the live PropSense median (shared, hour-cached
  with `/unit-rental`; only reached when the first two are empty) → 0.35% of price — and `data`
  gains:
  - `rent_source` — `"analysis"` | `"snapshot"` | `"live"` | `"fallback"` (the page's
    `layoutRentSource` vocabulary), or `"input"` when the request carried `rent=`. **`fallback`
    must be labelled as an assumption** — the portal prints an amber caption for it.
  - `predicted_rent` — the seed itself (`null` on `fallback`), still present when the member
    typed their own rent, so "reset to predicted" needs no second request.

### G-02 · The member's Define-card MoF — `GET …/analyze/new-projects`

The portal judges every card's cashflow on the margin of finance from the member's DMAIC Define
card; the mobile controller passed `mof: null`, so a 70% member saw 90% verdicts. `defineNumbers()`
now lives in the controller trait
[`ResolvesDefineNumbers`](/app/Http/Controllers/Concerns/ResolvesDefineNumbers.php), used by both
`AnalyzePropertyController` and `MobileAnalyzeController` — one reader, no second copy of the DSR
chain (`Completeness` still owns the arithmetic).

- The SIGNED-IN listing passes the card's MoF; `road.mof` / `road.mofSource` (`"define"` |
  `"default"`) then read exactly as on the portal, and `?mof=70|80|90` still overrides.
- `data.d_numbers` (additive): `{ mof, borrowable, price_max, cycle_no }`, or `null` for a member
  with no Define card. The portal's `dNumbers` prop, in this envelope's snake_case. A card whose
  loan count is unknown gives `mof: null` and the listing falls back to the default — and says so.
- The guest listing (`/mobile-api/v1/catalogue/new-projects`) never looks for a card:
  `d_numbers` is `null` there, as `road` is.

### G-03 · One AI reading per project — `GET …/amenity-demand`

The page renders the project's STORED location insight (`data.location_insight` in the project
response) and calls `/amenity-demand` only when there is none. The app always called it, and was
served the Redis-cached LIVE generation — a different text, because `LocationInsightStore::put()`
skips a re-store while the inputs are unchanged, so a generation made after the 7-day cache lapsed
was served but never stored. Binastra Cochrane: 15/65/20 "MRT Commuter Families" on the website,
25/55/20 "Young Urban Professionals" in the app.

- The endpoint now returns the stored insight whenever one exists — same shape (`ai_status`,
  `ai_analysis`, `catalyst_score`, `outlook`), no engine run, no model call — and generates (then
  stores) only for a project that has none. Additive `source`: `"stored"` | `"generated"`.
- The website is unaffected: it only calls the endpoint when nothing is stored.
- ⚠️ **Known consequence:** this endpoint was, by accident, the only thing that ever replaced a
  stored insight (a call whose input hash had changed stored a new version). Nothing refreshes a
  stored insight now. A deliberate regenerate path — admin action or command — is the right home
  for that and does not exist yet.

Tests: [`MobileProjectRentTest`](/tests/Feature/Api/v1/MobileProjectRentTest.php),
[`MobileNewProjectsDefineMofTest`](/tests/Feature/Api/v1/MobileNewProjectsDefineMofTest.php),
[`MobileAmenityDemandStoredFirstTest`](/tests/Feature/Api/v1/MobileAmenityDemandStoredFirstTest.php),
plus `FinancialProjectionServiceTest` and `LayoutRentParityTest::test_the_key_breakdown_adds_up_to_the_layouts_rent`.

## Location Analysis list — `GET member-api/v1/analyze/location-analyses` (2026-09-21, G-56 / G-57)

`MobileAnalyzeController::locationAnalyses()` selects only
`MobileAnalyzeTransformer::LOCATION_CARD_COLUMNS` — **never the `result` blob** (sorting rows that
carry it once ran MySQL out of sort memory) — plus the amenity AI's status lifted out of it with
`JSON_EXTRACT(result, '$.amenities.demand_intelligence.ai_status')`.

- **G-57** — every card carries `status` (`completed` | `error`, from `PropertyAnalysis::STATUS_*`)
  and `ai_status` (`pending` | `complete` | `failed` | null), so the app's list can say which
  analyses are still reading their amenities and which failed.
- **G-56** — `?page=N` (optionally `&per_page=`, 1–50, default 20) pages the list and adds
  `meta { current_page, last_page, total }`. Without `page` the answer is the whole list with no
  `meta`, exactly as before, so a deployed app that does not page keeps working.
- Order: newest first, `id` as the tiebreaker so a page boundary never repeats or drops a row.
- Tests: `MobileLocationAnalysisTest::test_cards_say_whether_the_analysis_and_its_amenity_ai_are_ready`,
  `test_the_list_pages_when_asked_and_is_whole_when_not`.

## Location Analysis geocode — `GET …/location-analyses/geocode` (2026-09-21, G-55)

`?q=` (3+ characters) → `{ places: [{ label, latitude, longitude, state, area }] }`;
`?lat=&lng=` → `{ place: { address, state, area } }`; both carry `attribution`. It is the portal
picker's Nominatim call (`LocationPicker.vue`: `countrycodes=my`, `q + ", Malaysia"`, 5 results,
`zoom=16` on reverse) made by `Src\Analysis\Services\Geocoder`, whose `region()` is the picker's
`extractRegion()` field for field — so the app fills `pin_address` / `state` / `area` as the website
does. Nominatim's policy: an identifying User-Agent, ≤ 1 req/s — every answer is cached a day and
the route has its own `location-geocode` 30/min budget. A failed lookup is an empty answer, never an
error. Tests: `MobileLocationAnalysisTest::test_geocode_*`, `test_a_failed_geocode_*`.

## Location Analysis rent per bedroom — `rental_predicted` (2026-09-21, G-58)

`GET …/location-analyses/{uuid}` adds `rental_predicted { default_bedrooms, bedrooms: { "2": { value,
mode, sample }, … } }` — the Rental tab's predicted rent for every bedroom pill, exactly as
`TabRental.vue` opens it (no filters, auto-selected nearest ten, `psf_size`, the analysis's own size;
the default pill reads the engine's `comparables`, the others filter `all_comparables`). Computed from
the STORED result by `Src\Analysis\Support\LocationRentalPrediction`, a port of
`utils/analyzeProperty/rentalPredict.js` — change one, change both (`LocationRentalPredictionTest` ⇄
`rentalPredict.test.js`). Not `RentalPrediction`: that is the project page's listing pipeline.

⚠️ **Portal fix shipped with it.** `rentalPredict.js` ran the median rent PSF through the RENT median,
which rounds to whole ringgit — RM 4.40 psf became RM 4, and a 1,000 sq ft unit's default predicted
rent read RM 4,000 instead of RM 4,400. The rate is now kept to two decimals (`medianRate()`), as the
project page's port already did. Location Analysis rents on the website can move by up to ~10%.

## App runtime config — `GET mobile-api/v1/config` (2026-09-20)

Public, cacheable for five minutes, throttled 60/min. It carries what the app must be able to change
**without a release** — today, its map source:

```json
{ "data": { "contract_version": 1,
  "map": { "provider": "mapbox", "user": "mapbox", "style_id": "streets-v12", "tile_size": 512, "token": "pk.…" } } }
```

- **Why an endpoint, not a build flag.** An APK is forever; a tile token compiled into one can never be
  rotated. The app asks here at start-up and falls back to its token-less CARTO basemap whenever
  `provider` is not `mapbox` or no token is set (`{ "provider": "carto" }`).
- **Why `streets-v12` and not the website's `mapbox/standard`.** The app draws RASTER tiles
  (`flutter_map`); Mapbox's Static Tiles API does not serve the Standard style. Change the style with
  `MAPBOX_MOBILE_STYLE_USER` / `MAPBOX_MOBILE_STYLE_ID` — no app release needed.
- **Token.** `MAPBOX_MOBILE_TOKEN` when set, else the website's public `MAPBOX_TOKEN` (already sent to
  every anonymous browser). Create the dedicated one — scoped to `styles:tiles`, no URL restriction, since
  an app sends no `Referer` — so web and app usage are separable on the bill.
- Files: `Api\v1\MobileConfigController`, `Transformers\v1\MobileConfigTransformer`,
  `config/services.php` (`mapbox.mobile_*`), `tests/Feature/Api/v1/MobileConfigTest.php`.

**Project detail `pricing.entry` (2026-09-22).** `{ name, price, sqft, psf, rent, gross_yield }` of the cheapest credibly
priced official layout (price ≥ `CatalogProject::MIN_CREDIBLE_UNIT_PRICE`, sqft > 0; the plan's own
`asking_psf` when served, else price ÷ sqft), for the owner's "From RM 1.47M · RM 1,120 psf" line —
a price paired with the PSF of the SAME unit, instead of a min–max range. `null` for guests
(per-unit pricing is members-only, like `floor_plans`). `rent` / `gross_yield` are that layout's
Layout Analysis `rent_median` / `yield_pct` (read by uuid from the site database), `null` until it
is analysed — so the app's first screen can quote a yield without a second request.
`CatalogueDetailService::entryUnit()`.


## My properties — `GET member-api/v1/my-properties` (2026-09-23)

The units a member BOUGHT, through the seven post-VP stages (收匙 → 验房 → 缺陷 → 装修 → 招租 →
出租中 → 卖出). Read-only, JWT, contact-verification gated (403 with the sentence to show). Two routes:
the list, and `…/my-properties/{uuid}` for one unit — the uuid is the BOOKING's.

```json
{ "data": { "contract_version": 1,
  "properties": [ { "uuid": "…", "unit": "AAAA", "project": { "uuid": "…", "name": "Maxim Risen" },
                    "phase": "journey", "current_stage": "key-collection", "stages": [ … ],
                    "progress": { "done": 0, "total": 22 }, "next_task": { … }, "is_overdue": true } ],
  "assignees": { "1": { "name": "Owner", … }, … } } }
```

The single-unit route serves the same object under **`property`** (singular), not `properties`.

**Additive since 2026-09-25 — the post-VP partner (Antserv).** Both routes (and `member/dashboard`)
carry a top-level **`partner`** object — `Src\PostVp\Support\PostVpPartner::payload()`, the SAME
payload the website renders (badge, absolute `logo` / `logo_2x`, headline, body, the unit-card
`line`, four `standards`, `disclaimer`, `roles`, every string with a `*_zh` twin) — and every unit
carries **`shows_partner`** (from the keys onwards), so the app never re-derives where the line
belongs. See [Landlord Management](/docs/modules_handbook/main/landlord-management/readMe.md#the-post-vp-partner--antserv-2026-09-25).

- **Nothing is computed here.** The unit is shaped by `Src\PostVp\Support\UnitJourneyPresenter` in its
  MEMBER view — byte-identical to the array the portal's dashboard card and
  `/property/my-properties/{uuid}` receive as Inertia props. The shared query is
  `App\Http\Controllers\Concerns\OwnsPostVpUnits` (`ownedUnits()` / `ownedUnit()`); all three surfaces
  call it. It replaced three copies of the same query — a filter added to one of them and not the
  others is how the app and the website start disagreeing about what somebody owns.
- **The lead id IS the authorization.** A member reads their own bookings, Active or Completed only —
  a cancelled booking bought nothing. Another member's uuid is a **404, never a 403**, so a guessed
  uuid learns nothing. No lead record → an empty list (not an error) on the list route, 404 on the
  unit route.
- **`assignees` travels with the payload** because a task carries its assignee as an integer. Without
  the map the app would hardcode the vocabulary and go stale the day a fifth one is added.
- ⚠️ **The app shipped against a fixture of this payload before the route existed.**
  `test/fixtures/my_properties.json` in the Flutter repo was generated from the presenter's real
  output, and its golden tests were green the whole time the endpoint 404'd — the app's Home section
  swallows every failure silently, so "no such route" looked exactly like "you own nothing". If the
  card is missing on a member who owns a unit, check the HTTP status before suspecting the data.
  `MobileMyPropertiesTest` now pins the shape.
