# 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's strip ([`AnalyzeTabs.vue`](/resources/js/Components/Portal/AnalyzeTabs.vue)) has **two
tabs** since 2026-09-03 (founder): **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 **Saved**, the layouts the
member starred. **Layout Analysis** and **Location Analysis** left the strip that day; their routes
(`/analyze-property/layout-analysis`, `/analyze-property`, an analysis's own `/{id}`) still answer for
direct links and the dashboard's "Latest property analysis" list, and no tab lights up on them.
Location Analysis is still the PropSense valuation this module is named for:

> **A COMPLETED project is priced against its OWN row (2026-09-03, founder: "ViiA Residence is a
> completed new project, so the market value shall just refer to own condo data").** The headline
> market PSF is normally the nearest-10 median asking PSF (`AnalysisEngineService::
> calculatePredictedPsf`). When the analysis names a project (`catalog_project_id`) that the
> catalogue says is completed (`CatalogProject::deriveSaleStatus` from its completion year) and a
> comparable within 250 m with a matching name carries asking or transacted evidence, that row's own
> asking PSF is the headline instead (`findOwnMarketRow` / `ownProjectPsf`). Names match as lower-case
> alphanumerics with one a prefix of the other, because in provider-separated mode the market row is
> EdgeProp's own record with a different id ("ViiA Residence" id 32343 vs "ViiA Residences @ KL Eco
> CIty" id 1994, 20 m apart). The engine says which basis it used in `market_value.basis`
> (`own_project` | `nearest_10`) and the row in `market_value.own_project`; both Market Value tabs
> ([`TabMarketValue.vue`](/resources/js/Components/PropertyAnalysis/TabMarketValue.vue) and
> [`ProjectInvestmentMarketValue.vue`](/resources/js/Components/ProjectDetail/ProjectInvestmentMarketValue.vue))
> pre-select that one row and print a note, so the tab and the Summary agree. Project Detail's cache
> version went to `v11` for it.

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.

> ✅ **Lock LIFTED 2026-09-04** — `ANALYZE_PROPERTY_LOCKED=0` in `.env` + `config:cache` (the env-only lift described below). The section is open to every member; only the permanent five-day-trainee gate (`TrainingAccess`, Day 3 for New Project) still applies. The lock *code* is still in the tree — the deletion list below is the pending cleanup.
>
> ⚠️ **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 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 loan is stated on the page, once**: 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. 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 road layer is OFF the listing (2026-09-10)

⚠️ **Nothing described in this section renders any more.** The founder had the whole road layer taken
off `/analyze-property/new-projects` on 2026-09-10 — the filter strip above the list (its price bands,
its quality sieves, its 现金流假设 line and the loan panel behind it) and the verdict block on every
card (the ROI / 现金流 / Dual Key badges, the arithmetic lines under them, and 带进 I 卡). The table
view's 项目质量 column went with them, so the two views still state the same facts.

`RoadFilterStrip.vue` and `LoanAssumptionModal.vue` were deleted, and `newProjects.js` lost the
vocabulary that existed only for those screens (`VERDICT`, `qualityMeta`, `verdictOf`, `verdictLine`,
`verdictTitle`, `cashflowAssumption`, `loanBasisNote`, `loanTerms`, `bmvPct`, `maintenanceLabel`).
Two guide lessons went with the UI they taught: `ap-listing` in full, and the six verdict points of
`ap-card` (see the [portal guide handbook](/docs/modules_handbook/main/portal-guide/readMe.md)).

**Everything server-side survived**, and that is the point of keeping this section: `BuildNewProjectListing`
still runs every sieve, still computes the per-card verdicts, and still honours every query parameter, so a
deep link from the course (`?roi=1&cashflow=1&price=over900&mof=70`) narrows the list exactly as before —
the member simply has no control on the page that sets one, and no badge that reads one back. The empty
state still names the filter that emptied the list, because a member who arrives by such a link would
otherwise see an unexplained blank page. **Restoring the display is a frontend change; the rules below are
still the live rules.**

What the member sees now, in order: the search box and the country tabs. Nothing else.

**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 and is still computed per card. It was PRINTED on both the card and the table row until
2026-09-10, held there by `Pages/Main/Site/loanTerms.test.js` after the two views drifted apart once;
that test went with the screens it guarded.

**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 WAS editable** (`Partials/LoanAssumptionModal.vue`, deleted 2026-09-10; the
parameters are still read). 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.

**Costs of the removals, recorded so nobody rediscovers them.** The ①②③ numbering went with the 类型 /
地段 steps in September — one step is not a funnel, and numbering it announces an order that no longer
exists. 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. And since 2026-09-10 the portal listing has **no filter control at
all**: a member on this page cannot narrow it by price, by quality or by region, only search it. Every one
of those rules still exists in SQL and still answers a URL; none of them has a way in from the screen.

⚠️ **`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 }`. The strip stated its **现金流假设** from it, naming
  whose numbers were on screen (`road.mofSource` = `'define'` | `'default'`, so a number was never
  presented as the member's when it was the course default). Both props are still sent and still drive the
  sieve; since 2026-09-10 nothing prints them.
- **Cards linked 带进 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). The link went with the card's verdict block on 2026-09-10; the route and the banner
  are untouched, and the road's own beats still hand off to it. 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. Since **2026-09-10** the two chromes show the SAME thing: the road's strip and the card's verdict block are gone, so `chrome === 'portal'` now changes only the shell, the country tabs and the empty-state copy. The page is the frame and [`Partials/ProjectCard.vue`](/resources/js/Pages/Main/Site/Partials/ProjectCard.vue) is the content (one card, developer facts only); the page keeps the URL builder (`withParam`), the region chips, the map pane and the table view. Extracted 2026-09-03 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 the card by MOUNTING it — 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 (`VERDICT`, `qualityMeta()`, `verdictOf()`, `verdictLine()`, `verdictTitle()`) were shared out of `newProjects.js` so a badge and the arithmetic under it could never disagree; they were deleted with the screens that read them (2026-09-10) and are recoverable from git if the display comes back. What is left in [newProjects.js](/resources/js/Pages/Main/Site/newProjects.js) is the plumbing: `ROAD_QUALITY_KEYS`, `LOAN_TERM_KEYS`, `PRICE_BANDS`, `buildListUrl`, `roadEmptyStep`, and the map-pane helpers. [utils/road/projectLinks.js](/resources/js/utils/road/projectLinks.js) still owns `improveHref` — the road's own beats link to the I card even though the listing no longer does.
- 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).
