# Analyze Property (Main · User Portal)

**Portal:** Main · **Routes:** `main.portal.analyze.*` · **Nav:** sidebar → **Acquisition → "Analyze Property"** · **Gated by:** `auth` (member-scoped)

## What it does
The section has **two tabs** ([`AnalyzeTabs.vue`](/resources/js/Components/Portal/AnalyzeTabs.vue)):
**New Project** — the member portal's view of the shared project catalogue, which since 2026-08-28 also
carries the DMAIC road's three-step filter strip ([below](#new-project--the-dmaic-roads-three-step-strip-2026-08-28))
— and **Location Analysis**, the PropSense valuation this module is named for:

A member pins a property on a map, enters its price / size / bedrooms (and optional financing), and
gets an instant **PropSense valuation**: a Summary headline (your PSF vs. predicted market PSF, fair
value, verdict) plus a set of tabs — **Market Value, Rental, Airbnb, Build Team, New Supply,
Amenities**. The whole analysis is computed once at save time from nearby **EdgeProp** comparables and
stored as a JSON snapshot, so re-opening a saved analysis is a cheap read. The valuation math is ported
**formula-exact from petav2's `PropSenseV2Controller`** — do not re-derive or "improve" it.

> ⚠️ **TEMPORARILY LOCKED DOWN (2026-08-08) — admins only.** The section is being reworked, so every
> `/analyze-property*` page is closed to non-admins and serves a "we're upgrading" screen instead;
> its JSON endpoints (project search, agents, Scout conversation) refuse with **403 JSON**, because a
> locked page whose data endpoints stay open is not locked. Admins pass straight through — they are
> full portal users themselves ([Admin + Lead are HATS](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md))
> and need it open to keep building. Everything below describes the section as it behaves for them,
> and for everyone once the lock lifts.
>
> **An admin is told the page is hidden, and can see the member's view.** Passing straight through
> would otherwise leave staff with no way to tell that what they are looking at is not what a member
> gets — so the middleware attaches an `analyzeLock` shared prop on the way through, and
> [`AnalyzeLockBanner`](/resources/js/Components/Portal/AnalyzeLockBanner.vue) renders off it inside
> the section's tab strip (the one component **both** tab pages already mount, so neither page needed
> an edit). Its **Preview what members see** button is a plain `<a href>` to `?preview=locked`, which
> falls through to the *same* branch a member hits — the only honest preview is the screen itself, not
> a copy of it — and the locked screen then offers **Back to admin view**. That way back
> (`exitUrl`) is handed to **admins only**: a member who guessed the flag still gets the lock with no
> door in it.
>
> The lock is deliberately **outside** the module so the rework never collides with it:
> [`LockAnalyzeProperty`](/app/Http/Middleware/LockAnalyzeProperty.php) (its own middleware, holding
> its own path list) + [`Locked.vue`](/resources/js/Pages/Main/Portal/AnalyzeProperty/Locked.vue) (its
> own page, mounting nothing from the module). It hangs off
> `features.analyze_property_locked` (`config/features.php`), so **re-opening is
> `ANALYZE_PROPERTY_LOCKED=0` in `.env` — no deploy**; after that, delete the middleware, the page,
> `AnalyzeLockBanner.vue` (+ its two lines in `AnalyzeTabs.vue`), `AnalyzePropertyLockdownTest`, the
> config entry, the `lock.analyze` alias in `app/Http/Kernel.php` and its mention in `routes/main.php`.
> ⚠️ **It must be `=0`, not `=false`.** `env()` in this app is **CakePHP's**
> (`vendor/cakephp/core/functions.php`) and returns the raw string, so `(bool) "false"` is `true` and
> `ANALYZE_PROPERTY_LOCKED=false` leaves the section locked while looking like a broken flag. Write
> `ANALYZE_PROPERTY_LOCKED=0`, then `php artisan config:cache`.
> The flag is forced **off** in `phpunit.xml` so the module's own suite keeps exercising the real pages.

> Analyses are owned by the member's **lead** (`lead_id`), like AI Conversations and Wealth Plans —
> see [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md).
> Market data is read live from the external read-only `reference` DB (EdgeProp, Airbnb, agents); it is
> never written or migrated.

## How it works
- **Create.** `create` renders the map + form (`Create.vue` with `LocationPicker`). The member pins the
  location (or picks a project via the `project-search` typeahead, which autofills coords + implied
  size) and submits price / size / bedrooms / purpose / financing.
- **Run + persist.** `store` maps the inputs and calls `RunPropertyAnalysis` (an Action) → it runs
  `AnalysisEngineService::build()`, then `PropertyAnalysis::summarize()` promotes the headline columns
  (`user_psf`, `market_psf`, `fair_price`, `verdict`). `PropertyAnalysisRepository::create()` writes the
  row (engine result JSON + promoted columns) in a transaction, then redirects to `show`. If no nearby
  comparables exist, the engine throws `NoComparableProjectsException` and the form flashes an error.
- **The engine (`AnalysisEngineService::build`).** Pulls **EdgeProp projects within 2 km** of the pin
  (`EdgepropProject::nearby`). From them it derives: a transaction-**weighted** market PSF, average
  growth, expected rental, demand counts, and a **comparison table**. The **predicted PSF** (Summary
  headline + Market Value default) takes petav2-LIVE's comparable SET — the **nearest-10** comparables
  by distance — and reads their **median ASKING PSF**, falling back to the median transacted PSF only
  when nothing nearby is on the market. petav2 averaged the two medians; that changed on **2026-09-03**
  (founder's call) because the two answer different questions: an asking PSF is what comparable stock
  is offered at today, which is what a buyer is choosing against, while a transacted PSF is what
  somebody paid for an older unit months ago. It moved the fair value by a median of 2% and the verdict
  on 80 of 704 saved layouts, and it is why `ANALYSIS_CACHE_VERSION` was bumped to **v8** (it is
  **v10** as of 2026-09-03: v9 moved the comparable-type gate to the whole nearby set, v10 stopped
  refusing EdgeProp's `Flat` — both below) — every saved snapshot
  holds `market_psf` / `fair_price` / `verdict` computed on the basis in force when it was written, so
  changing the basis without bumping leaves the same project answering two ways. The Market Value tab's
  "Predict based on" control opens on **Asking** for the same reason: a tab whose default disagrees with
  the Summary above it is a split brain.
  **New-project parity:** when `property_purpose = new_project`, comparables are filtered to
  **post-2016 developments** (petav2's `gt2016` age filter) so a new launch isn't priced against
  decades-old subsale stock; subsale/auction keep the full set. The verdict (OVERPRICED / UNDERVALUED /
  FAIR PRICE) uses ±15% thresholds; the displayed deviation % is measured against the **user's** price
  (petav2 `dealDeltaPct`). The engine also populates the other tabs: rental comparison + listings + room
  rental, Airbnb (nearby `airbnbs`, P25/P50/P75 performer bands + market histograms), Build Team (top
  agents), and New Supply (`properties` in `Development` status within 5 km).
- **Amenities tab (AI, lazy).** The engine computes the amenity categories + a BMS-DSE demand score
  synchronously, but the **narrative + STR demand** are generated by **Gemini**. The snapshot starts
  with `demand_intelligence.ai_status = 'pending'`; on first `show`, the controller dispatches the
  queued `AnalyzeAmenityDemand` job (the AI module's resilient `AiJob` lane — overlap-keyed +
  idempotent), which calls Gemini once and patches `ai_analysis` onto the snapshot. The page polls until
  `complete`; on permanent failure it reuses a prior completed analysis for the same area, else marks
  `failed`. See [AI Integration](/docs/modules_handbook/shared/ai/readMe.md).
- **Result page (`show`).** `Show.vue` is the orchestrator: identity header + `VerdictBadge` + the tab
  components. (`VerdictBand` is a different component, used inside `TabSummary.vue` — not here.) Inline **financing edits** (`updateFinancing`) and **Build Team** agent search (`agents`,
  coords read server-side) hit dedicated endpoints. **Photos** — a single Layout Plan (replaced on
  re-upload) and appended Project Photos — go through `MediaService`
  ([Media](/docs/modules_handbook/shared/media/readMe.md)). All actions resolve the analysis via
  `ownedAnalysis()` (uuid + current lead), so a member only ever touches their own.
- **List + delete.** `index` shows a card grid of the member's saved analyses (promoted columns only);
  `destroy` soft-deletes.

## Layout Analysis — one row per unit (2026-09-03)

`GET /analyze-property/layout-analysis` lists every analysed LAYOUT. The New Project listing
answers "which project", and a project is a RANGE: Binastra Cochrane runs from RM 649,620 at
RM 1,001 psf to RM 1,170,000 at RM 1,140, the first under the area's market PSF and the last
nearly 10% over, with cash flows of +RM 624 and −RM 1,380. A buyer signs against one unit.

**It runs no analysis on the request path.** Every figure already exists inside a
`catalog_analysis_snapshots` payload, put there by members opening the Investment tab.
`php artisan layouts:project` flattens them into `layout_analyses` — a read model in OUR
database, not the shared catalogue — carrying `computed_at` and `engine_version` so a stale row
can be found rather than trusted. `--missing` lists gaps; `--run` fills them through the project
page's OWN `buildAnalysis()`, so a backfilled payload is byte-identical to one a page view would
have produced. 356 layouts, all with a rent, as of 2026-09-03.

**The default basis depends on the layout's SHAPE, and the asymmetry is deliberate (2026-09-04,
founder's call).**

| Layout | Basis | Why |
|---|---|---|
| single key | **PSF × Size** | its built-up is a PUBLISHED figure, so a rate applied to it beats a median rent — which is the middle of whatever *sizes* were listed nearby, and would quote an 800 sqft unit the rent of a 1,100 sqft neighbour |
| multi key | **Average** — the midpoint of PSF × size and the set's median rent, per key, summed | a KEY's area is NOT published: it is estimated off the plan drawing, and the rate multiplying it comes from STANDALONE units that run systematically larger than a key of the same bedroom count. Both errors push the same way, so a key hedges |

The gap is not academic. Binastra Cochrane's Type A dual key reads **RM 5,200** on medians,
**RM 3,565** on PSF × size and **RM 4,383** on the midpoint — because the estimated studio key is
220 sqft while the standalone studios the RM 7.00 rate came from are about 328 (RM 2,300 ÷ 7.00).
Expect PSF × size to run low on dual keys for exactly that reason. **Do not "unify" the two bases
later**: the difference is between multiplying a published area and multiplying a guess, and
`test_the_two_bases_are_not_interchangeable` exists to stop it being tidied away.
⚠️ **Three places pick this basis by the same rule and must agree**: `petaLayoutRent` (the page),
`RentalPrediction::forLayout` (`BASIS_PSF` / `BASIS_AVERAGE`, what `layouts:project` writes), and
the Rental report's own `predictMode` default. Change one, change all three, re-run the projection,
and update the parity fixture. The harness caught exactly that mid-change on 2026-09-04, when PHP
had moved to the midpoint and the JS had not.

**⚠️ The chart's dashed line is a STATISTIC OF THE BARS, never the prediction.** It reads the
comparable set's median in the axis's own units — median rent PSF on the PSF axis, median rent on
the Rent axis — and "Predict based on" must not move it. It used to be `medianRentalPsf` on one
axis and `predictedRental` on the other, so a toggle changed *what kind of quantity the line was*,
and switching basis (or typing a Custom figure) visibly moved a line drawn across OTHER projects'
rents as if their median had changed. The prediction is still readable against the bars — it sits
in the KPI row and summary boxes directly above, which do follow the control. Same rule per key on
a dual key: each key's line is that key's own comparable median, not its contribution to the sum.

**The two halves, and why each exists (2026-09-03).**
The two are drawn from the same cleaned listings and answer different questions: a median RENT is
the middle of whatever *sizes* happened to be listed nearby, so it prices a unit as its neighbours
rather than as itself — an 800 sqft layout quoted the rent of a 1,100 sqft neighbour wherever the
ring ran larger, and every layout in one project got the same rent however much floor it bought.
A median rent PSF is a **rate**, and this layout's own area is what it gets applied to. So step 8
of the pipeline below is `median rental PSF × sqft`, with the plain median rent kept as the
**fallback** for the two cases with nothing to multiply: a set with no usable PSF, and a layout
with no recorded size. Both surfaces' "Predict based on" control now *opens* on **PSF × Size**
(the project page's Rental report, and Analyze Property's Rental tab) — a control whose default
disagrees with the figure above it is a split brain, the same reason Market Value opens on Asking.
⚠️ Two traps if you touch it: the PSF is rounded to two decimals **before** the multiply, because
that is the rate the page displays and multiplies; and `PredictControls` opens on `modes[0]`, so
its host's default `ref` and the first entry of its `:modes` array must name the same mode.
**A DUAL KEY is priced the same way once its SPLIT is known.** Each key gets its own bedroom
market's rent PSF × its own floor area, and the two are summed. That area is published nowhere —
a developer states one built-up for the whole unit and never the division — so it is estimated
from the plan DRAWING by [`KeySizeEstimator`](/src/Analysis/Services/KeySizeEstimator.php) (a
vision call, prompt `dual_key_split`), an ordered list aligned to `key_bedrooms`. Run it with
`php artisan catalog:estimate-key-sizes` (`--dry-run` to look first, `--slug=` for one project),
then re-run `layouts:project`.

⚠️ **The estimate is stored in `catalog_floor_plan_key_sizes` — the SITE database — and never on
`catalog_floor_plans.key_sqft`.** The catalogue row is the canonical home and is still READ first,
so a split the Hub estimates once, or an admin measures by hand, outranks every local guess. But a
platform must not write it, for two independent reasons and either one is sufficient: a platform
holds read-only master credentials by design (`petav3_read` has SELECT and nothing else — the same
constraint `site_catalog_projects` was built for), and even where the catalogue IS local and
writable, that table is UPSTREAM-OWNED — a sync from master upserts every column from the canonical
row. On **2026-09-04 that silently wiped 80 estimated splits in one go**, restoring each row's
`updated_at` to the master's own value, so the loss left nothing behind that looked like a loss.
Read order is therefore catalogue column → site estimate → per-key medians, and
`CatalogFloorPlan::preloadKeySizes()` must be called before any loop that reads a set of layouts
(the detail service, the projection command) or the site lookup becomes one round trip per layout.

⚠️ **The AI produces DATA, never a rule.** Both implementations read the column and neither calls
a model — an AI call cannot run in the browser at render time, so a rule that needed one could
have no PHP counterpart, and the parity harness could not see it. That is the exact shape of the
four failures listed below.

Three guards, each earned: the reply is **scaled** so the parts sum to the published built-up (the
model drifts from a total it was told); a key under **150 sqft** rejects the whole reply (nothing
smaller is separately lettable, so it is a misread plan); and a split that does not cover EVERY key
is **discarded rather than partly used**, because one key on PSF × size beside one on a median adds
two different quantities and prints the sum as one rent. Every discard falls back to the per-key
medians — the previous behaviour, never RM 0. An admin can correct a split on the catalogue
floor-plan form; that **pins** it in `manual_overrides`, and the estimator never overwrites a
pinned one. `max_tokens` is **8192** for ~120 tokens of JSON: on a thinking model the budget is
spent on reasoning first and the visible reply is what gets truncated — at 1024, Gemini Flash
burned 1,017 tokens, emitted 98 characters of a cut-off JSON object, and the layout silently
stayed unsplit with nothing in the logs but a shrug.

**The "Predict based on" control applies to a dual key too, one key at a time (2026-09-04).** All
three bases — PSF × Size, Median, Average — are asked of EACH key's own comparable set and the
answers summed; only Custom stays whole-unit, because it is a number the reader typed for the unit
and there is nothing to ask of a key (the breakdown then keeps showing the PSF reading as the
evidence Custom is being weighed against). They were disabled while the keyed sum was hard-wired to
one basis, and that was right at the time — a control that renders as live and silently does
nothing is worse than one that is missing. The rule that survives: **ask every key the same
question and add the answers.** Mixing bases across keys would add two different quantities, which
is what `petaKeySizes` already exists to prevent. Note `averageRental` here is the midpoint of the
set's median rent and its PSF × size reading — a hedge between the two methods, NOT the arithmetic
mean of the listings — so "Average" on a dual key is that midpoint per key, summed. The PHP port
mirrors the DEFAULT basis only (PSF × size); the other two are interactive readings that nothing
persists, which is why the parity harness still compares one number.

**A split also unlocks the PSF chart for a dual key**, which was locked to absolute rent before it
existed — correctly, then: the rent was a sum of two medians and neither key had an area to divide
by, so a "per sqft" reading would have set a blended figure against single-key comparables priced
on a different basis. With a split each key's chart is a rate read against the same rate, and it is
the reading the unit is BOUGHT for: a dual key earns more per square foot than one undivided unit
of the same size, and that gap is invisible in absolute rent, where it just looks like a bigger
unit. The gate is `canChartPsf` (`!isMultiKey || hasKeySizes`) — a split-less multi-key layout
stays on rent, and its marker line, chart heading and the "Predict based on" tooltip all say which
basis they are in. Read one rule from this: **anything stated in ringgit-per-sqft beside a
multi-key unit must be gated on the split**, never on `is_dual_key`.

⚠️ **The rent is a PORT, and it has now been wrong four times.** The table must quote what the
project page quotes, and the page computes it in the browser
(`petaLayoutRent` in `Components/ProjectDetail/projectInvestment.js`). `Src\Analysis\Support\RentalPrediction`
mirrors it — **change one, change both.** The failures, all worth reading before touching it:

1. **Reading `rental.median_rent` straight out of the payload.** It matched exactly on the
   project checked by hand and disagreed on HALF of a 40-layout sample, by up to RM 1,900 a
   month. The page additionally keeps only comparables within 1 km and trims size, rent and PSF
   beyond two standard deviations. A coincidence is not an algorithm.
2. **`$plan->bedrooms ? … : null`, where a STUDIO is 0 and 0 is falsy.** A studio was filtered by
   nothing, so its rent became the median of every one-, two- and three-bedroom listing nearby:
   Dawn KLCC's 350 sqft studio published at RM 8,500 against the page's RM 2,700, a 16.8% yield
   against 5.3%. Use `LayoutAnalysisProjector::bedroomsFor()`.

3. **A DUAL-KEY layout, priced as one home.** A dual-key unit lets as two homes to two tenants, so
   its rent is the sum of each key's own bedroom market. The page had always done that; the table
   asked the combined bedroom market instead. Binastra Cochrane Type A is `Studio + 1 bed`:
   2,300 + 2,900 = **5,200** against the 2-bedroom market's **3,500** — every one of that project's
   four layouts short RM 1,700 a month, turning +RM 2,324 of cash flow into +RM 624 and, on Type C,
   +RM 708 into −RM 1,292. 56 of the 356 analysed layouts are dual key.
4. **`bedrooms: null` is a STUDIO, and the port dropped it.** The JS test is
   `Number(listing.bedrooms) !== bedroom`, and `Number(null)` is **0**; the port used
   `(int) ($row['bedrooms'] ?? -1)`, and `??` fires on null as well as on a missing key. It could
   only ever show at `bedrooms = 0` — at any other count both sides exclude those rows anyway —
   which is why it survived. Worth RM 100 on Binastra's studio leg. Use
   `RentalPrediction::bedroomsOf()`; a MISSING key still excludes, because `Number(undefined)` is NaN.
5. **An EMPTY 1 km ring, read as RM 0.** Absence of evidence is not evidence of a weak market, and
   a dual-key unit whose studio leg contributes nothing is understated by a whole key. The Atas has
   no studio inside 1 km; the real ones (Menara Seputih 1,300, Faber Indah 1,650, VIVO 1,700) sit
   at 1.11–1.22 km, barely outside. Both sides now ask at 1 km and, **only if that finds nothing**,
   ask again at 2 km — the radius the engine collects nearby projects at, so there is no more data
   to reach for. Never a third widening, and never a widening when the first ring answered: a
   comparable two kilometres away is a worse comparable, used only in place of silence.
   `RentalPrediction::bedroomRent()` / `petaBedroomRent()` return `widened` alongside the number,
   and the page prints "widened to 2 km" under a leg that used it — two identical-looking medians
   must not hide that one of them reached twice as far.

**Failures 2 and 4 are the same bug in different clothes: the studio is where a bedroom bug hides,
because 0 is falsy in PHP and truthy-ish nowhere consistent.**

⚠️ **A high-rise is priced against high-rise stock, and that gate belongs at the SOURCE.** The type
test (`CatalogProject::isComparableType()` — raw land, landed houses and low-cost public housing
are out) used to be inlined in `isComparable()`, which was called in exactly one
place: the sale comparison table. Everything else derived from nearby projects — rent, growth,
demand, median sizes — took the unfiltered set, so a landed enclave could not price a condo but
could still set its rent. The Atas published a RM 10,000 "studio" on the strength of one 4,800 sqft
bungalow letting 740 m away at RM 2.08 psf, whose listing carried no bedroom count (so the pipeline
read it as a studio). `AnalysisEngineService` now applies the TYPE half of the gate to the whole
nearby set before any figure is derived from it; the EVIDENCE half stays per consumer, because
"is this the same kind of home?" and "does it have enough history to price against?" are different
questions.

⚠️ **That gate used to refuse EdgeProp's `Flat` outright, and that was its own error.** The label
covers two different things: low-cost walk-ups AND genuine towers the provider never tagged
`Condominium`. Near The Atas alone it was discarding SkyVogue Residences (RM 892k median, 3-bed
lettings at 3,600–4,500), River Park Bangsar South, Residensi Aetas Seputeh and The Atas Residence
itself — 12 projects, in a ring already thin enough to need the widening above. Since the label
cannot separate them, the exclusion moved to what can: the low-cost categories are named in the
project's own name (`PPR`, `Program Perumahan Rakyat`, `Perumahan Awam`, `Rumah Pangsa`,
`Pangsapuri Kos Rendah`,
`"… Flat"`), listed in `CatalogProject::PUBLIC_HOUSING_MARKERS`. Categories only — a rule that
lists individual projects stops being a rule — so public housing that does not say so in its name
(Razak Mansion) is a known gap. Admitting the low-cost stock instead would price a new condo off a
PPR block's RM 800: the bungalow failure again, one segment down.

**A known, unfixed hole in the same gate: terrace and townhouse stock is not excluded at all.** A
terrace enclave's lettings sit in a condo's rent set today. It predates all of this and is left
alone deliberately — narrowing the set is a separate decision from the two above, and the ring is
already thin.

**Failures 1 and 2 were about the TEST, not the code.** The parity sweep passed while the bug was
live, because the sweep derived `bedrooms` with the projector's own rule before handing it to both
implementations — it compared the bug against itself. A parity harness must derive its inputs the
way PRODUCTION derives them. The sweep now exports the raw column and lets each side apply its own
rule; re-run it after any change to either implementation:

```
# export every row's payload with the RAW bedroom column, then compare
php artisan tinker   # see the handbook history for the export snippet
RENT_PARITY_FILE=<file> npx vitest run <a temporary sweep test>
```

**Failure 3 was about WHERE THE RULE LIVED, and it is the one worth internalising.** The dual-key
sum was written inside `ProjectDetail.vue`, not in the ported module — so there was no PHP
counterpart to mirror, and the harness could only ever compare the half of the rule that both
sides exposed. Every test stayed green while the two answers differed by RM 1,700 a month. **A rule
that lives in a page component is invisible to the port.** Both sides now expose the whole rule —
`petaLayoutRent(rental, { bedrooms, keyBedrooms, price, unitSize })` and
`RentalPrediction::forLayout($rental, $bedrooms, $keyBedrooms)` — and the page and the projector
each call nothing but that.

The permanent guards are `tests/Unit/Analysis/LayoutRentParityTest.php` and the `rent parity`
block in `projectInvestment.test.js`, which read one shared fixture from both languages. That
fixture now carries a studio pool with a **null-bedroom row that is decisive** (without it the
median is 2,100, with it 2,150 — an earlier draft put the row where both answers coincided, which
is a fixture that cannot fail), a **dual-key case**, and three radius pools that pin the widening
from both ends: a bedroom count answered inside 1 km (must NOT widen), one empty at 1 km and
populated at 1.5 km (must widen, once), and one with nothing inside 2 km (must stay RM 0 rather
than keep searching until some number appears). Each new assertion was checked to go RED against
the old implementation before being kept.

**Every row opens its own layout, IN A NEW TAB.** The project name, the project photo and the
看这户型 button link to `/my/projects/{slug}?chrome=portal&unit={floor plan uuid}` (the button adds
`&tab=invest`), as plain `<a target="_blank">` rather than Inertia `<Link>`. Two reasons, both
load-bearing: `unit=` because without it the detail page opens on its own anchor layout and answers
about a different unit than the row the reader clicked (see the Project Detail handbook's *Deep
links* section); the new tab because this is a shortlist — a reader compares eight layouts, and an
in-place visit costs them the filters, sort and scroll they built to get here.

**The LAYOUT thumbnail is the one thing that does not navigate.** It opens the floor plan in a
lightbox on the page, because deciding between two layouts means looking at both drawings and a
plan that costs a page load each way does not get looked at twice. The plan sits on white inside
the overlay — it is a line drawing, and a dark surround eats its thinnest lines.

**A dual-key layout carries a badge**, with the split (`Dual key · Studio + 2 bed`) underneath when
the feed recorded it. Dual key changes what the unit IS — two lockable homes behind one door, so it
can be lived in and let at the same time — which is often the whole reason one row's cash flow beats
the row above it. The label comes from `CatalogFloorPlan::keyLabel()`, the same method the project
page uses, so the two surfaces cannot describe one layout two ways. 56 of the 356 analysed layouts
are dual key.

**The catalogue facts are read live, not projected.** `AnalyzePropertyController::projectThumbnails()`
(project hero → gallery, via `BuildNewProjectListing::cardImage()`, so the cards and this table
cannot show different photos) and `layoutFacts()` (the layout's own `floor_plan` media **and** its
dual-key flag, together in one query because they come off the same row) each run ONE query for the
25 rows on screen. Media is re-uploaded and re-ordered far more often than
an analysis is re-run, so a URL frozen into `layout_analyses` at projection time would rot with
nothing failing. The floor plan renders `object-contain` on white — a square crop of a line
drawing shows a corner of a bedroom and says nothing; the project photo stays `object-cover`.

## The shortlist — `saved_layouts` (2026-09-03)

`GET /analyze-property/saved` is the SAME page component as Layout Analysis, holding only what the
member starred (`savedOnly` + `shortlisted` props). Deliberately not a second table: a shortlist is
the comparison the reader just built, and showing it in a different shape makes them re-read columns
they already know. `POST /saved-layouts` toggles one layout and returns `back()`, so one endpoint
serves the table's row heart, the project page's Save button and the shortlist's own un-star.

⚠️ **The write lives OUTSIDE the `analyze-property/*` prefix on purpose.** That prefix carries the
temporary `LockAnalyzeProperty` lockdown, and the Save button also sits on the PROJECT page, which
is not locked — a toggle that answered with the lockdown screen would break a button on a page the
lockdown has nothing to do with. Viewing the shortlist stays inside the section (and inside the
lock) because that is where the table is.

- **Per LAYOUT, not per project** — the same reason this whole page is per layout. "I saved Binastra
  Cochrane" cannot say which of its four layouts was meant.
- **`saved_layouts` is NOT `lead_floor_plan_views`**, which sits next door and looks similar. That is
  a rollup of what a lead LOOKED at, written automatically for sales to route a follow-up; this is
  what the member CHOSE to keep. A view is evidence, a save is intent, and conflating them makes an
  accidental click read as a shortlist entry.
- **One repository method, `toggle()`**, deciding from the database inside the transaction. Splitting
  it into save/forget would put the choice in the controller, where two tabs open on the same layout
  make it the wrong one; the unique `(lead_id, catalog_floor_plan_id)` makes a double-click
  idempotent rather than a duplicate row.
- **A starred layout with no analysis row is reported, not dropped.** The project page's Save button
  is on every unit, not only analysed ones, and such a layout has nothing to show in this table —
  the header says how many are waiting rather than letting them vanish from the member's own list.
- ⚠️ **The project page has two similarly named props and they are different things.**
  `savedLayouts` = layouts with a saved ANALYSIS (an engine cache). `shortlisted` = layouts the
  member STARRED. `shortlisted: null` means "no lead record", which hides the control; `[]` means
  "nothing starred yet", which still shows it.

**The instalment is the FULL price at 4% over 35 years**, which is what the project page's Summary
uses (`ProjectInvestmentAnalysis.vue` seeds it deliberately) and is not the 90% a reader assumes.
⚠️ Until 2026-09-07 that basis was printed above the table, together with the row count and the
as-of date, in a grey band that also explained why one row is one layout; the founder removed the
whole band, so the page now states no loan basis at all — anything that says it again must read
`LayoutAnalysisProjector`'s constants rather than retype 4%. The band survives on the SHORTLIST
(`?savedOnly`), where it carries the "still waiting on analysis" count, which is a state warning
rather than teaching copy. A layout with no rental evidence shows an em-dash for rent, yield and
cash flow and sorts last — minus-the-whole-instalment for a unit whose rent we do not know reads
as a terrible investment rather than as missing data.

## New Project — the filter row today (2026-09-03)

**The three-step strip below is history.** The founder removed the 类型 and 地段 steps outright on
2026-09-03; what is left is one row. The section that follows is kept because the SQL behind the two
removed steps is still live — every query parameter still works, so a deep link from the course keeps
narrowing — but nothing on screen sets them any more.

What the member sees now, in order: the search box, a **price band**, the **quality sieves**, and the
**cash-flow assumption panel**.

**BMV is no longer a sieve.** Only 9,619 of 37,858 floor plans carry an asking PSF, so as a filter it
mostly hid projects for having no data rather than for being priced badly — a chip that removes a
project for something we do not know about it is not a sieve, it is a guess. It is still the course's
rule, still computed per card, and still PRINTED on both the card and the table row. Sieve and number
are different things, and `Pages/Main/Site/loanTerms.test.js` holds the two views to the same three
lines after they drifted apart once.

**The price band** (`?price=under500|500to900|over900`, `BuildNewProjectListing::PRICE_BANDS`, mirrored
by `PRICE_BANDS` in `newProjects.js`) reads the project's FROM price, so a band means "its cheapest unit
is in here". A project with no price is excluded from every band rather than shown in all of them — a
band is a claim about the price and we do not have one. `over900` is the one that is a RULE rather than
a preference: a foreigner cannot be approved below a state's minimum purchase price, which starts around
RM 1,000,000 in KL and RM 1,500,000 for strata in Selangor.

**The cash-flow assumption is editable** (`Partials/LoanAssumptionModal.vue`). The strip used to print
「成数 90% · 年利 4% · 35 年 · 管理费 RM 0.35/sf」 and give the reader nowhere to go with it, while those
numbers decide which projects are on screen at all. `?mof|rate|tenure|maint|rebate` are clamped
server-side — they reach SQL through `DB::raw` — and ride every link on the page, because a verdict
computed on the member's terms that silently reverts on the next click is worse than never offering the
panel.

⚠️ **The margin is applied to the SPA, not to the card price.** Cards show the NET price, what the buyer
pays after the developer's rebate; a bank lends a percentage of the SPA. The loan is
`price ÷ (1 − rebate) × mof` with `config('road.quality.rebate_pct')` defaulting to 10, so the default
margin borrows 100% of the card price. Applying 90% to the card price understated the instalment on
every project in the list until 2026-09-03. The panel says this in words, because "90%" does not read
like "the whole price" to anyone.

**The sidebar lands here, not on Location Analysis** (`AppLayout.vue`): the catalogue already knows a
launch's price and units, so it is the shorter path to an answer.

**Two costs of removing the two steps, recorded so nobody rediscovers them.** The ①②③ numbering went
with them — one step is not a funnel, and numbering it announces an order that no longer exists. And the
state / district chips were 地段's 其他地区 fallback, so on the portal they went too (`showCityChips =
!roadOn`); the PUBLIC listing keeps them, having never had the strip. **The listing no longer teaches the
growth-area rule anywhere** — 3 km radius, 成熟区必须 BMV — which now lives only on the road's A station
and in the Area Guide. A member choosing by corridor has to bring that judgement with them.

⚠️ **`BuildNewProjectListing` has no PHP test.** Threading the four loan terms into the card mapper
without adding them to the `->map(function ($row) use (...))` capture list took
`/analyze-property/new-projects` down with a 500 on 2026-09-03. `php -l` cannot see an uncaptured
closure variable. Until there is coverage, exercise this action through tinker after any change to it —
sign a member in, call `execute()` for the default path, a price band, member loan terms and the public
listing, and read the card count back.

## New Project — the DMAIC road's three-step strip (2026-08-28)

`GET /analyze-property/new-projects` renders the SAME page as the public listing
(`Pages/Main/Site/NewProjects.vue`, built by [`BuildNewProjectListing`](/app/Actions/BuildNewProjectListing.php))
with `chrome=portal` and `launchesOnly: true`. Phase 2 of [DMAIC 之路](/docs/modules_handbook/main/dmaic-road/readMe.md)
added a filter strip above the existing search + city/district chips that asks the road's **M · A · I**
questions **in that order** — a sequence, never a flat filter bar, because each step narrows the one
before it:

1. **类型** — segmented, in the M card's own vocabulary (`JourneyCard::TYPE_*`): 居住型高楼 (the course's
   default: Condominium + Serviced Apartment) · 有地 · 商业 (SoHo, shop, office — commercial title, a
   different tenant, so the course files SoHo here). `?type=highrise|landed|commercial`, mapped to the
   catalogue's comma-joined `property_type` by `BuildNewProjectListing::TYPE_GROUPS`.
2. **地段** — chips from `config('road.growth_areas')` (`?area_key=klcc`), resolved through the
   catalogue's existing `CatalogProject::scopeNearby()` haversine at
   `config('road.growth_area_radius_km')` = **3 km**. The catalogue has NO join key to the Area Guide's
   areas — `catalog_projects.area` is free feed text — so distance to the guide's pin is the only honest
   membership test. Areas flagged `mature` (KLCC · Bangsar · Mont Kiara) print the course's
   「成熟区 · BMV 必要」 hint. A growth area and a state chip are two answers to the same question, so
   picking an area clears `city` / `district`, and the strip can never show two active 地段 answers.
3. **项目质量** — the I stage's three sieves as toggles: `?bmv=1`, `?cashflow=1`, `?dual_key=1`.

**The filter and the badge are the same rule, deliberately.** `roadFragments()` writes each sieve once as
a SQL fragment (correlated subqueries on `catalog_floor_plans` — **never a join**, which would multiply
the paginator count, GUIDELINES §14); the listing's `WHERE` and the card's `SELECT` both use it, and
`roadVerdicts()` re-judges the SELECTed numbers in PHP. So a project the filter admits shows **通过** on
its card, and one it rejects does not.

| sieve | rule | numbers on the card |
|---|---|---|
| `bmv` | best asking PSF ≤ `config('road.quality.bmv_ratio')` (0.95) × `catalog_projects.psf_median`. Asking = `MIN(floor_plans.asking_psf)`, falling back to `price_min ÷ MIN(sqft)` | asking PSF, median PSF, the % gap |
| `cashflow` | `rent − instalment − maintenance ≥ 0` for the CHEAPEST plan. rent = its `rent_median` else `rental_price`; price = its `price` else the project's `price_min`; instalment = `Instalment::monthly(price × MoF, 4 %, 35 y)`; maintenance = `round(sqft × 0.35)` | plan name, rent, price, MoF, loan, instalment, maintenance, the gap |
| `dual_key` | any plan with `is_dual_key` or `is_studio_key` | the plan's name, the project's plan count |

A missing input is **资料不足** (`unknown`), never a fail — a project the catalogue has no rent for has
not failed the cashflow test, it has not been tested. A toggle asks for projects that PASS, so unknown
is excluded by the filter.

- **The MoF is the member's own.** `AnalyzePropertyController::newProjects()` reads the road's live cycle
  (`LearningJourney::currentFor()`) and its Define card, and passes an additive
  `dNumbers: { mof, borrowable, priceMax, cycle_no }`. Under the toggles the strip states its
  **现金流假设** either way — 「按你的 D 卡：成数 90% · 4% · 35 年 · 你的上限 RM 611,000」 or
  「按 90%：先填 D 卡」 with a **去填 →** link to `/property/academy/road/define?beat=8`. `road.mofSource`
  (`'define'` | `'default'`) is what the page reads to choose between the two, so a number is never
  presented as the member's when it is the course default.
- **Every card links 带进 I 卡** → `improveHref(uuid)` = `/property/academy/road/improve?beat=8&project={uuid}`,
  which opens the Improve card with a fill/ignore banner offering the project's figures (only empty
  fields are filled). See the road's Phase 2 section.
- **Counts, and the empty state that names a step.** Each type segment is counted against the search
  scope BEFORE the type narrows it, each area against the type scope, each sieve alone against the 地段
  scope — so a number never depends on its neighbours. `road.counts` (`type` / `area` / `quality`) lets
  `roadEmptyStep()` say *which* step emptied the list and offer to relax it, instead of a bare "no
  projects match".
- **Portal + Malaysia only.** `$roadOn = chrome === 'portal' && iso2 === 'MY'`. Outside that every road
  parameter is IGNORED (not just unrendered), so neither the public site nor the HK / UAE portal tabs can
  be narrowed by a typed URL, and `props.road` is `null` so the page mounts nothing. Page size stays 24.
- **⚠️ On this box only ~6 MY rows are `publiclyListed()`**, so the strip looks sparse here. Its counts
  are honest; the catalogue is thin. Production publishes the real set.
- **⚠️ Members cannot see any of this yet** — `ANALYZE_PROPERTY_LOCKED` defaults **true**, so the whole
  section serves `Locked.vue` to a non-admin (see the lockdown block above). Admins pass through.

The growth-area table is a COPY of the Area Guide's Malaysian pins
(`resources/js/utils/areaGuide/malaysia.js`, which is the SOURCE);
[`GrowthAreasConfigTest`](/tests/Feature/Main/Portal/Analyze/GrowthAreasConfigTest.php) reads that JS file
and fails the moment the two drift, because a member would otherwise read one story in the guide and be
shown projects around a different point.

## Since 2026-09-02 — the ROI sieve and the trainee gate
- The strip's **fourth sieve, ROI** (`?roi=1`): the entry unit's rent × 12 ÷ price must reach
  `config('road.quality.roi_floor')` (7 % — the course's R1). Same unit as the cashflow sieve; the card
  carries `road.roi { result, rent, price, roi_pct, floor }`, the page reads `road.roiFloor`.
- `DEFINE_CARD_HREF` is `…/define?beat=card` (Stage.vue resolves the alias to D's card screen).
- `LockAnalyzeProperty` now also holds a **five-day trainee** (when the global flag is off or lets the
  request through): the section opens with the M card (Day 2), the New Project listing
  (`analyze-property/new-projects*`, feature `analyze.projects`) with the A card (Day 3). `Locked.vue`
  receives `reason / day / cardHref`; JSON endpoints answer 403 with the reason.

## Related files

**Backend — Model, Repository, Action**
- [src/Analysis/PropertyAnalysis.php](/src/Analysis/PropertyAnalysis.php) — the saved analysis; `PROPERTY_PURPOSES` / `BUYER_STATUSES` / `VERDICTS` / `STATUSES` constants; `summarize()`; `media()` morph; lead-keyed.
- [src/Analysis/Repositories/PropertyAnalysisRepository.php](/src/Analysis/Repositories/PropertyAnalysisRepository.php) — `create` / `updateFinancing` / `updateAmenityAi` / `delete` (each in `DB::transaction`).
- [app/Actions/RunPropertyAnalysis.php](/app/Actions/RunPropertyAnalysis.php) — runs the engine + shapes the row (keeps the repository a thin writer).
- [app/Actions/FindNearbyAgents.php](/app/Actions/FindNearbyAgents.php) — scored nearby agents for the Build Team tab.
- [app/Actions/BuildNewProjectListing.php](/app/Actions/BuildNewProjectListing.php) — the New Project tab's whole payload (shared with the public listing): cards, chips, pagination, the map token, plus the road's `TYPE_GROUPS` / `QUALITIES` / `roadFragments()` / `roadVerdicts()` and the `road` prop.
- [src/Journey/Support/Instalment.php](/src/Journey/Support/Instalment.php) — the one PHP instalment formula the cashflow sieve uses (`monthly()` for the badge, `factor()` for the per-ringgit constant bound into the SQL).

**Backend — Engine & Reference (read-only market data)**
- [src/Analysis/Services/AnalysisEngineService.php](/src/Analysis/Services/AnalysisEngineService.php) — the valuation engine (Summary + every tab); ported formula-exact from petav2 `PropSenseV2Controller` (cited line numbers per method).
- [src/Analysis/Reference/CatalogProject.php](/src/Analysis/Reference/CatalogProject.php) — country-scoped comparable projects, canonical developers and listing/PSF helpers · [CatalogFloorPlanAnalytics.php](/src/Analysis/Reference/CatalogFloorPlanAnalytics.php) · [MarketPropertyAgent.php](/src/Analysis/Reference/MarketPropertyAgent.php).
- [src/Analysis/Exceptions/NoComparableProjectsException.php](/src/Analysis/Exceptions/NoComparableProjectsException.php) — thrown when no project is within 2 km.

**Backend — Controller, Form Requests, AI job**
- [app/Http/Controllers/Main/Portal/AnalyzePropertyController.php](/app/Http/Controllers/Main/Portal/AnalyzePropertyController.php) — index/create/store/show/destroy + projectSearch, updateFinancing, storeMedia/destroyMedia, agents; `ownedAnalysis()` scoping.
- [app/Http/Requests/Main/Portal/Analyze/StoreRequest.php](/app/Http/Requests/Main/Portal/Analyze/StoreRequest.php) · [UpdateFinancingRequest.php](/app/Http/Requests/Main/Portal/Analyze/UpdateFinancingRequest.php) · [StoreMediaRequest.php](/app/Http/Requests/Main/Portal/Analyze/StoreMediaRequest.php) · [AgentSearchRequest.php](/app/Http/Requests/Main/Portal/Analyze/AgentSearchRequest.php)
- [app/Jobs/Ai/AnalyzeAmenityDemand.php](/app/Jobs/Ai/AnalyzeAmenityDemand.php) — the queued Gemini amenity job (extends [AiJob](/app/Jobs/Ai/AiJob.php)).

**AI prompt (Amenities)**
- [config/ai_prompts.php](/config/ai_prompts.php) (`PROMPT_AMENITY_DEMAND` entry) · [resources/prompts/amenity_demand.md](/resources/prompts/amenity_demand.md) — the system prompt; `AiRequest::PROMPT_AMENITY_DEMAND`.

**Frontend (Vue)**
- Pages: [Index.vue](/resources/js/Pages/Main/Portal/AnalyzeProperty/Index.vue) (card grid) · [Create.vue](/resources/js/Pages/Main/Portal/AnalyzeProperty/Create.vue) (map + form) · [Show.vue](/resources/js/Pages/Main/Portal/AnalyzeProperty/Show.vue) (result orchestrator).
- New Project tab: [Pages/Main/Site/NewProjects.vue](/resources/js/Pages/Main/Site/NewProjects.vue) — ONE page for the public site and the portal; the strip, the four verdict badges and 带进 I 卡 render only when `chrome === 'portal'` and `road` is present. Since **2026-09-03** the page is the frame and two partials are the content: [`Partials/RoadFilterStrip.vue`](/resources/js/Pages/Main/Site/Partials/RoadFilterStrip.vue) (the three steps, their labels, scope lines and assumption line) and [`Partials/ProjectCard.vue`](/resources/js/Pages/Main/Site/Partials/ProjectCard.vue) (one card, its four verdicts and 带进 I 卡). The page kept what both halves share — the URL builder (`withParam`, passed to the strip as `hrefFor`), the region chips the strip's 其他地区 button reveals, the map pane and the table view. Extracted for two reasons: the page was 800 lines with the card buried inside the Mapbox plumbing, and [How to Use PropertyLab](/docs/modules_handbook/main/portal-guide/readMe.md) teaches both by MOUNTING them — a lesson that mounts the shipping component cannot teach a screen that has since moved, which a screenshot silently would.
- ⚠️ **These components carry `data-guide="ap.*"` anchors, and they are load-bearing.**
  [How to Use PropertyLab](/docs/modules_handbook/main/portal-guide/readMe.md) teaches this section by
  MOUNTING the real components with a captured payload for one real project (Binastra Cochrane), and
  its test suite fails when an anchor stops resolving. That is the alarm working, not an obstacle:
  rename or move a block and the lesson pointing at it needs rewriting in the same commit. The strip,
  the card and `Components/ProjectDetail/{OverviewTab, ProjectUnitsPriceTab, UnitsTab,
  ProjectInvestmentAnalysis, FinancialProjectionTab, DeveloperTab, AmenitiesPanel, NewSupplyPanel}`
  carry them.
- The verdict WORDS are shared, not duplicated: `newProjects.js` owns `VERDICT`, `qualityMeta()`, `verdictOf()`, `verdictLine()` and `verdictTitle()`, and the card, the table view and the strip all read them — a badge and the arithmetic printed under it are the same judgement twice, so they can never disagree. Helpers also in [newProjects.js](/resources/js/Pages/Main/Site/newProjects.js) (`ROAD_QUALITY_KEYS`, `buildListUrl`, `roadEmptyStep`) and [utils/road/projectLinks.js](/resources/js/utils/road/projectLinks.js) (`improveHref` — the road owns the address of its own card).
- Tab components ([resources/js/Components/PropertyAnalysis/](/resources/js/Components/PropertyAnalysis/)): [TabSummary.vue](/resources/js/Components/PropertyAnalysis/TabSummary.vue) · [TabMarketValue.vue](/resources/js/Components/PropertyAnalysis/TabMarketValue.vue) · [TabRental.vue](/resources/js/Components/PropertyAnalysis/TabRental.vue) (embeds [TabRoomRental.vue](/resources/js/Components/PropertyAnalysis/TabRoomRental.vue) — room rental is a panel inside Rental, not its own tab) · [TabAirbnb.vue](/resources/js/Components/PropertyAnalysis/TabAirbnb.vue) (+ [Airbnb/](/resources/js/Components/PropertyAnalysis/Airbnb/) Overview/Calculator/Map + `calc.js`) · [TabBuildTeam.vue](/resources/js/Components/PropertyAnalysis/TabBuildTeam.vue) · [TabNewSupply.vue](/resources/js/Components/PropertyAnalysis/TabNewSupply.vue) · [TabAmenities.vue](/resources/js/Components/PropertyAnalysis/TabAmenities.vue) · [VerdictBadge.vue](/resources/js/Components/PropertyAnalysis/VerdictBadge.vue) · [ScoutPanel.vue](/resources/js/Components/PropertyAnalysis/ScoutPanel.vue) (**PETA Scout** — the collapsed "Ask PETA" card on `show`; opening it lazily creates/resumes the conversation bound to THIS analysis via `POST /analyze-property/{uuid}/ai-conversation` and then streams over the shared AI Conversations SSE endpoint).
  These are **shared components, not page partials** — three surfaces render the same tabs: the member's `Show.vue`, Manage → Leads' [AnalysisShow.vue](/resources/js/Pages/Manage/Leads/AnalysisShow.vue) (read-only, via `AnalysisDetail`), and the public [Main/Site/ProjectDetail.vue](/resources/js/Pages/Main/Site/ProjectDetail.vue) (Market Value / Rental / Airbnb only). They used to sit under `AnalyzeProperty/Partials/`; they moved out the moment a second host needed them.
- Page partials (`AnalyzeProperty/Partials/` — only these three): [LocationPicker.vue](/resources/js/Pages/Main/Portal/AnalyzeProperty/Partials/LocationPicker.vue) · [AnalysisLoadingOverlay.vue](/resources/js/Pages/Main/Portal/AnalyzeProperty/Partials/AnalysisLoadingOverlay.vue) (scanning overlay during submit) · [AnalysisDetail.vue](/resources/js/Pages/Main/Portal/AnalyzeProperty/Partials/AnalysisDetail.vue) (the `VerdictBadge` + tab strip extracted whole so the admin-side `AnalysisShow` renders a member's analysis identically, without duplicating `Show.vue`'s owner-only actions).
- Shared components ([resources/js/Components/Analyze/](/resources/js/Components/Analyze/)): `MetricCard`, `ScoreRing`, `VerdictBand`, `ComparisonBarChart`, `ComparisonMap`, `PoiMap`, `FilterChips`, `PillNav`, `PredictControls`, `FinancingEditBox`, `Badge`, `Button`.

**Migrations**
- [database/migrations/2026_06_09_000001_create_property_analyses_table.php](/database/migrations/2026_06_09_000001_create_property_analyses_table.php) · [2026_06_13_000004_add_lead_id_to_property_analyses_table.php](/database/migrations/2026_06_13_000004_add_lead_id_to_property_analyses_table.php).
- [database/migrations/2026_07_20_100003_create_market_reference_tables.php](/database/migrations/2026_07_20_100003_create_market_reference_tables.php) — governed, country-scoped `market_airbnbs` and `market_property_agents`; catalogue comparables live in `catalog_projects`.

**Seeders**
- [database/seeds/MarketDataSeeder.php](/database/seeds/MarketDataSeeder.php) — explicit, deterministic local Analyze fixture written directly to the redesigned schema; no petaV2/prod connection.
- [database/seeds/NewSupplyE2eSeeder.php](/database/seeds/NewSupplyE2eSeeder.php) — deterministic member + lead + one hand-crafted analysis snapshot for the New Supply tab Playwright E2E (no engine run / market DB needed).

**Routes**
- [routes/main.php](/routes/main.php) — the `main.portal.analyze.*` group (index/create/store/show/destroy + project-search, agents, financing, media). `analyze-property/new-projects` is declared **before** `analyze-property/{id}`, or the literal segment is read as an analysis id.

**Tests (the New Project strip)**
- [tests/Feature/Main/Portal/Analyze/RoadListingStripTest.php](/tests/Feature/Main/Portal/Analyze/RoadListingStripTest.php) — each step narrows the query, the verdicts agree with the filter that would admit the row, the D-card assumption line, and the public chrome untouched.
- [tests/Feature/Main/Portal/Analyze/GrowthAreasConfigTest.php](/tests/Feature/Main/Portal/Analyze/GrowthAreasConfigTest.php) — `config('road.growth_areas')` vs `utils/areaGuide/malaysia.js`.
- [tests/Feature/Main/Portal/Analyze/NewProjectsTabTest.php](/tests/Feature/Main/Portal/Analyze/NewProjectsTabTest.php) — the tab itself (market tabs, chrome, pagination).

**Temporary lockdown (delete together when the section re-opens)**
- [app/Http/Middleware/LockAnalyzeProperty.php](/app/Http/Middleware/LockAnalyzeProperty.php) — the gate: admins pass, everyone else gets the locked page (403 JSON on the fetch endpoints); no-op when the flag is off. Registered as the `lock.analyze` alias in [app/Http/Kernel.php](/app/Http/Kernel.php) and stacked on the portal group in [routes/main.php](/routes/main.php).
- [resources/js/Pages/Main/Portal/AnalyzeProperty/Locked.vue](/resources/js/Pages/Main/Portal/AnalyzeProperty/Locked.vue) — the "we're upgrading" poster: the brand mark inside a scanning field, a **neural field** whose signals travel inward, and a **reasoning stream** typed token-by-token (uneven cadence, block caret) over an indeterminate rail — the two things that make it read as *inference* rather than generic sci-fi chrome. Almost no copy by design. All motion is `<style scoped>` in this one file (nothing in `app.css`) and every loop, including the typing, is off under `prefers-reduced-motion`. Shows the **Back to admin view** bar when `exitUrl` is set.
- [resources/js/Components/Portal/AnalyzeLockBanner.vue](/resources/js/Components/Portal/AnalyzeLockBanner.vue) — the admin banner ("Only admins can see this page right now" + **Preview what members see**), mounted by [AnalyzeTabs.vue](/resources/js/Components/Portal/AnalyzeTabs.vue) and rendering only when the `analyzeLock` shared prop is present.
- [config/features.php](/config/features.php) — `analyze_property_locked` (`ANALYZE_PROPERTY_LOCKED`, default **true**; turn it off with `=0`, never `=false` — see the warning above).
- [tests/Feature/Main/Portal/Analyze/AnalyzePropertyLockdownTest.php](/tests/Feature/Main/Portal/Analyze/AnalyzePropertyLockdownTest.php) — turns the flag back on and covers member-locked / JSON-403 / admin-passes / rest-of-portal-untouched / flag-off-re-opens.
- ⚠️ **This is the ONLY lock on that group line again** (2026-08-27). The [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md) copied this pattern, but became a TAB of the Learning Hub — a tab has no path for middleware to match, so its lock moved into `CoursesController::areaGuideState()` and `'lock.area-guide'` left the group. Re-opening this section still means deleting `'lock.analyze'` from that array — not the array, and not the group's other middleware.

**See also:** [AI Integration](/docs/modules_handbook/shared/ai/readMe.md) (the Amenities Gemini job + request log) · [Media](/docs/modules_handbook/shared/media/readMe.md) (Layout Plan + Project Photos) · [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md) (copies this lockdown pattern; its Malaysian pins are the source of the growth areas) · [DMAIC 之路](/docs/modules_handbook/main/dmaic-road/readMe.md) (the M · A · I stages this tab answers, and the I card 带进 I 卡 fills) · [Project Catalogue](/docs/modules_handbook/shared/project-catalogue/readMe.md) (the `catalog_projects` / `catalog_floor_plans` rows the listing and every sieve read).
