# PRD — Budget and Target (BnT) Module

> ## ATURAN ABSOLUT #0 — legacy files are LOCAL ONLY
> The legacy PHP reference screens belong in `Just An Example, Do not Link/` in this folder and
> are **never** staged, committed or pushed — guarded by `.gitignore:33`, enforced by
> `README.md` rule 1. **Files, not knowledge:** summarise a legacy rule in your own words and
> cite its filename here, but never paste verbatim legacy PHP or SQL-as-written into this
> committed document. Never read the raw legacy install at `/opt/lampp` to work around a
> missing file — that is a hard rail.
>
> ## ATURAN ABSOLUT #0b — parallel sessions share this tree
> Several Claude sessions edit this checkout at once. mtime-check before every edit, create new
> files only after `ls`/`git status` on the exact path, commit by explicit pathspec only, and
> never run repo-global destructive git. See `README.md` rules 2 and 12.
>
> ## ✅ ATURAN ABSOLUT #0c — the workflow is now VERIFIED (updated 2026-07-21)
> **Superseded.** This block used to warn that the workflow was inference because the legacy
> files were absent. **34 legacy files arrived on 2026-07-21 and were read end to end.** The
> inferred state machine in §8 turned out to be **correct**; the tier-scope table in §4 turned
> out to be **partly wrong** (MM and `own`) and has been fixed; and §5.0's "the document is a
> GROUP" claim turned out to be **wrong** and has been rewritten. §9 Q1, Q2, Q5, Q6, Q7 and Q11
> are answered, with citations. What is still open is Q3, Q4, Q8, Q9 and Q12 — for those, ask.
>
> The rule that replaces the old warning: **cite the legacy file when you state a business rule,
> and never re-derive one from the data alone when a file answers it.**

---

## 1. Document Metadata

| Field | Value |
|---|---|
| **Title** | PRD — Budget and Target (BnT) Module |
| **Module** | Budget and Target — the annual sales plan: a salesperson proposes Budget/Target quantities and prices per company and product for a year, then PM → SM → MM each review and may restate the numbers. Legacy root menu **308**. |
| **Status** | **NOT BUILT.** No controller, routes, pages, policy, seeder or tests. Exists: 6 auto-generated models, a dormant mail Blade, a UI prototype, and seeded `periode`/`status` lookups. This document is a **blueprint**, not a record of shipped work. |
| **Owner** | IT — PT Colorindo Chemtra |
| **Date** | 2026-07-21 (data model verified this date against live dev `colorindochemtrainertia`) |
| **Stack** | Laravel 13.8 / PHP 8.4 / Inertia.js v3 / React 19.2 / Tailwind v4 / MySQL 8.4 — existing production schema, no structural changes |
| **Document Type** | Hybrid PRD — **verified** data-model facts, **inferred** workflow (flagged as such throughout), the prototype's design inventory, a phased roadmap, and the open questions that gate it. Nothing beyond user-approved decisions is implied. |

### Locked decisions (user)

| Date | Decision |
|---|---|
| 2026-07-21 | Module folder is `Pages/MenuBudgetAndTargets/` (plural), matching MenuVisitPlans / MenuSampleOrders / MenuQuotations. |
| 2026-07-21 | This module gets its own **self-contained** README rather than cross-referencing Visit Plan's, so a fresh session reads one file. Rules shared with Visit Plan are tagged `[SHARED]` and must be changed in both files together. |
| 2026-07-21 | **`Pages/Proto/Budget/` + `Components/Proto/Budget/` are the DESIGN SOURCE ONLY.** The design was copied into `MenuBudgetAndTargets/` + `Components/MenuBudgetAndTargets/`; the proto originals stay untouched and reference-only (README rule 8). The proto's two known breakages remain **theirs to fix, not ours**. |
| 2026-07-21 | **Approval SM reuses the Approval PM board** ("approval sm should be equal to approval pm, so just use approval pm for now"). The prototype's separate, simpler SM screen was **not** ported. This matches legacy, where the PM/SM/MM approval screens are structurally identical and differ only in queue status filter and scope join. **Recommended follow-up: Approval MM (menu 322) should use the same board** for the same reason. |

| 2026-07-21 | **Approval PM / SM / MM all reuse one board; the tier lives in the controller only.** Per-tier override fields stay NOT visible, following the design source. |
| 2026-07-21 | **Fix all three legacy defects** rather than porting them: one transaction per batch, re-assert the expected current status in the WHERE clause, and capture the approver's comment PER LINE instead of one page-level box. |
| 2026-07-21 | **Role grants go through the existing Role/Menu admin screen.** Fill `menu.LinkLaravel` on the 13 BnT menus; do NOT invent `rolemenu` rows. Synthetic roles in Pest only. |
| 2026-07-21 | **Phase 8 (email) is DEFERRED** to GitHub issue #159. |
| 2026-07-21 | **`UserIDSales` on Create is a snapshot of `company.UserIDSales`.** ⚠️ This **DIVERGES from BnT legacy**, which set `UserIDSales = UserIDInput = ` the session user (`Create/createbudgetandtarget.php:24-25`). It aligns BnT with the project-wide Quotation/SampleOrder convention ([[userid-sales-snapshot]]). **Consequence to remember:** the `head` tier scopes on `UserIDSales`, so a line now surfaces to the company rep's management chain rather than the creator's. |
| 2026-07-21 | **Phase 7 reports are NetSuite-backed and degrade to empty.** Plan figures come from `budgetandtarget` at status 9; actuals come from `getperformancebntmonth`. No mock data ships — see the "honesty gates" note in §6.4. |
| 2026-07-21 | **0-sentinel display rule:** where `PrincipalID` is 0/NULL the lists fall back to the denormalised `ASTPrincipalName` snapshot (which still holds the real name, e.g. "STERLING COLOUR"); `Division` has no such column and shows `—`. No badge, no data modified. |
| 2026-07-22 | **Division on Create is a SNAPSHOT of `company.DivisionID`, not a user input.** Confirmed by the now-supplied legacy `getcompanydivision.php` (`division = company.DivisionID`), the file the keystone's own note flagged to re-check. This **reverses** the earlier `creatableDivisionIds()` (user's-division) rule chosen while that file was missing; the "45/45 vs 16/45" gap was the creation-time snapshot drifting as companies were reassigned (same as `UserIDSales`). The header Division dropdown added during wiring was removed; `DivisionID` is derived server-side per line at save (NULL when the company has none, per rule 16). |
| 2026-07-22 | **View / read-tier look = the proto PIVOT on REAL plan data** (user chose "Port the proto pivot (real plan)"). `usePivotEngine` was de-PRNG'd — every `mulberry32`/`BUDGET`/`TRX` fact table removed — and the read tiers (`List.jsx`) + `Reports/Pivot.jsx` now render the stored Budget/Target measures grouped by principal → company → product. The analytic measures (Attain%/YoY/Growth/Forecast/Var/Gap/%Total-actual/ASP) need NetSuite actuals and stay HIDDEN (`availableMeasures(hasActuals)` + `effVals`) until those exist — never faked. Wiring actuals = issue #163. |
| 2026-07-22 | **XLS export DEFERRED; the Export button is HIDDEN** (issue #162) — it had no handler, and a dead button is worse than none. |
| 2026-07-30 | **NetSuite is reached by DATABASE, not by RESTlet.** BnT reads the `netsuite_2` connection (`ccins-function`) — one table per legacy saved-search function, columns = that function's result fields verbatim. New `NetSuiteClient`; `NetSuiteBudgetTargetClient` keeps its method names and the legacy 4-arg signature. This **answers §9 Q13** ("how does our port reach NetSuite"): neither direct Restlet calls nor a deferral — a DB mirror of the function results. `NetSuiteRestletClient` stays on disk for the five other modules' clients (#208). |
| 2026-07-30 | **`netsuite_2` is the source "FOR NOW"** — the production source changes later, tracked in issue **#207**. It is NOT a config swap: the other connection `netsuite` (`cci-netsuite`) is entity-shaped, `netsuite_2` is function-shaped, and they share neither table nor column names. |
| 2026-07-30 | **`$join` (the 4th legacy argument) is accepted and IGNORED.** A flat MySQL table has nothing to join to, and nothing in the Laravel port passes a non-empty join. If one is ever needed, the column mapping is a user decision — do not guess it. |
| 2026-07-30 | **Date columns became native `DATE`** across all 83 ns-function tables (12 names, 101 pairs). They had been `varchar` holding d-M-Y, which cannot be range-compared. Callers still pass d-M-Y; the client converts before binding. Applied by `netsuite-function-date-columns.sql` and baked into `docker/mysql/dbinit-ns-temp/ns-db.sql`. |
| 2026-07-30 | **Values are coerced by MEANING, not by declared column type** (user: *"whether db is using varchar or decimal or float or int … I need you to ensure everything"*). The ns-function schema is a placeholder where nearly everything is `varchar(255)`. Numeric casts strip thousands separators first. |
| 2026-07-30 | **ACTUALS come from the local `netsuiteinvoicedetail` table**, not the NetSuite functions — following legacy's own newer Performance report, whose `getperformancebnt*` calls are commented out. This also **answers §9 Q4**: `budgetandtargetachievements` is confirmed unused. `ValueInvoice` is the USD column (`TotalRevenue` is IDR). |
| 2026-07-30 | **The REPORTS label principals with the NetSuite brand (`ASTPrincipalName`)**, the opposite of the read-tier lists, because actuals are keyed on `ItemBrand`. Forced by data: the local and NS names disagree on 39 of 75 plan lines, and only 12 of 112 principals carry `PrincipalNSBrand` to map the other way. |
| 2026-07-30 | **Decision emails fire on EVERY approve / revise / cancel** — broader than legacy's 4-of-10 send points (§9 **Q10 answered**: legacy's silence was NOT intended to be preserved). One email per GROUP, not per line; only lines that actually moved. |
| 2026-07-30 | **Pagination split: the 6 QUEUES paginate server-side; the 6 READ TIERS do not.** The read tiers' pivot and 5-Year panel aggregate the whole `rows` array, so paging their source would falsify every total. Server-side aggregation for them is issue #209. This is a scoped deferral, **not** a new exception to ATURAN #26. |
| 2026-07-30 | **`rolemenu` grants stay a Role/Menu admin-screen task** (the 2026-07-21 decision was re-confirmed, not reversed). Tier→role mapping fixed as PM = Product Executive (6), SM = Sales Manager (5) + Sales Manager Surabaya (18), MM = Marketing Manager (15). Click-list: `BNT-GO-LIVE-ROLEMENU-GRANTS.md`. **§9 Q8 answered.** |
| 2026-07-30 | **The Principal picker carries `manufacturer`**, and `ASTPrincipalCode` is the **vendor code** — `substr(vendor, 0, 7)`, which lives inside `getprincipal.vendor`. Confirmed against live data: every well-formed `ASTPrincipalCode` is exactly 7 chars, format `V-NNNNN`, matching `principal.PrincipalNSCode`. `PrincipalID` is resolved SERVER-side from that snapshot (the browser cannot know our local PK), writing **NULL never 0** on a miss. |

*(Add every future user decision here, dated — `README.md` §5 On-end requires it.)*

---

## 2. Overview & Purpose

Once a year each salesperson commits to a number. For a **period** (a calendar year) and one
of **their companies**, they list products and state a planned quantity (**Budget**), an
aspirational quantity (**Target**) and a unit price. Value columns follow from
quantity × price.

That proposal then climbs three management tiers — **PM → SM → MM** — and *each tier may
restate quantity, price and value*. The record preserves all four opinions side by side: the
salesperson's original in one set of columns, then PM's, SM's and MM's in their own. A
separate **cancel** path withdraws an already-approved line and needs its own three approvals.

**So this is not a CRUD module.** It is a negotiation log with three reviewers, and the
commercially interesting data is the *difference* between what sales asked for and what each
tier agreed to. Any design that models approval as a simple status flag throws away the point
of the schema — see §6.4.

### 2.1 What the whole menu is for (screen-by-screen)

Root menu **308** "Budget and Target" holds 13 leaves; Pengelolaan menu **314** holds one more.

- **Create Budget and Target** (310) — the entry grid. One line per product, grouped by
  company; annual figures (no month/quarter dimension exists).
- **View Budget and Target** (312) — the salesperson's own plans.
- **View All / Head Dept / SM / MM / PM** (313, 421, 318, 323, 317) — the same list re-scoped
  per management tier. Read-only.
- **Approval PM / SM / MM** (311, 316, 322) — the three review queues. Each writes *its own*
  quantity/price/value columns and a history row.
- **Cancel Approval PM / SM / MM** (320, 321, 324) — the withdrawal path, a second and
  separate state machine over the same rows.
- **Periode Budget and Target** (315, under Pengelolaan 314) — the year lookup that every
  other screen filters on.

---

## 3. Scope

### 3.1 In Scope — nothing yet

No phase has been scoped or approved. §8 proposes an order; each phase must be confirmed with
the user before it starts.

### 3.2 Explicitly NOT in this blueprint pass (2026-07-21)

- Any controller, route, page, policy, request, seeder, migration or test. **Nothing was built.**
- Any change to the models, the dormant mail Blade, or the prototype.
- Any answer to §9 that the data alone cannot support. Where the data was ambiguous this
  document says so instead of choosing.

### 3.3 Out of Scope until explicitly scheduled

- **Monthly / quarterly budget entry.** The schema is annual-only; every monthly figure in the
  prototype's reports is synthesised from annual numbers. Adding real monthly granularity is a
  **schema conversation**, not a UI one (§9 Q12).
- **Plan-vs-actual reporting.** Depends on what `budgetandtargetachievements` is (§9 Q4).
- **Email.** `sendEmailBNTtoUser` is blueprinted and its Blade exists, but wiring any send is
  deliberately last (§8 Phase 8) — see the delivery warning there.
- **The AST product catalogue.** Owned by the in-flight AST-integration workstream, not by
  this module (§9 Q2).

---

## 4. Users & Permissions — pattern locked by precedent, not yet built

Access follows the project-wide Role/Menu contract (CLAUDE.md): a `rolemenu` grant from the
user's role to a `menu` row, joined on `menu.LinkLaravel`. **Sidebar visibility and the server
403 gate must use IDENTICAL grant conditions**, enforced at the leaf.

The tier scopes this module needs are already implemented for Visit Plan in
`app/Services/VisitPlanAccess.php` and should be **generalised, not re-derived**:

**⚠️ Corrected 2026-07-21 — read from BnT's own legacy, replacing the Visit Plan guesses.**
Two rows below were wrong. Every predicate is quoted from the `View/*view.php` file named.

| Tier | Menu | BnT's ACTUAL scope predicate | Source |
|---|---|---|---|
| own | 312 | `bnt.UserIDInput = <session>` — **creator only.** *Not* `UserIDSales`; being the named salesperson on someone else's line does **not** show it here | `listbudgetandtargetview.php:73` |
| all | 313 | `bnt.IsDeleted = 0` — genuinely unscoped, no user predicate at all | `listbudgetandtargetallview.php:73` |
| head | 421 | `bnt.UserIDSales IN (<subtree>)` — the actor's `users.HeadID` subtree, self included. Built by a **PHP recursion**, not SQL | `listbudgetandtargetheaddeptview.php:68-108` |
| sm | 318 | `userdivision.IsHeadDiv = 1` joined on **`bnt.DivisionID`** — the row's own division, **never the company's** | `listbudgetandtargetsmview.php:78` |
| mm | 323 | **`userprincipal.IsMM = 1` joined on `bnt.PrincipalID`** | `listbudgetandtargetmmview.php:76` |
| pm | 317 | `userprincipal.IsHeadDiv = 1` joined on `bnt.PrincipalID` | `listbudgetandtargetpmview.php:77` |

**The two corrections, called out so they are not silently re-broken:**

1. **`mm` was wrong.** The PRD previously said `userdivision.IsReadReport = 1` on the *company's* `DivisionID` — that is Visit Plan's rule, not BnT's. BnT's MM tier keys on **`userprincipal.IsMM`** against the **row's principal**. (`IsReadReport` does appear in BnT — but only to populate two *dropdowns* on the MM screen, never to scope rows: `listbudgetandtargetmm.php:538`, `:566`. The dropdown and the grid therefore disagree in legacy.)
2. **`own` was wrong.** It is `UserIDInput` **only**; the "probably `UserIDSales` too" hedge is not in the code.

⚠️ **`IsHeadDiv` is overloaded** and this is a live trap: in `userdivision` it means *division head* (the SM tier); in `userprincipal` it means *principal manager* (the PM tier). Same column name, two different tables, two different meanings.

The three **approval queues** apply the same scope joins, plus a status filter — PM `=1`, SM `=3`, MM `=4` — and the three **cancel queues** mirror them exactly with `=5`, `=7`, `=8`.

Every tier still additionally requires its own `rolemenu` grant: a scope match without the grant is **not** access. Legacy's own role gate lives in `header.php`, which was **not** supplied — so §9 Q8 stays open.

⚠️ **Confirm each against BnT's own legacy list files before coding.** Visit Plan's audit found
two of the "obvious" guesses wrong (MM keys on the *company's* division, not the record's; head
scopes on `UserIDSales` only). Every tier also requires its own menu grant — a scope match
without the grant is **not** access.

**Unresolved:** which roles get which of the 13 menus (§9 Q8), and whether an on-behalf create
exists (§9 Q9 — `UserIDInput ≠ UserIDSales` in the data suggests it does).

---

## 5. Data Model — VERIFIED against dev DB `colorindochemtrainertia`, 2026-07-21

Six tables, all `ID int auto_increment` PK + manual `IsDeleted`, `$timestamps = false`.
Row counts are live as of the verification date.

### 5.0 ⚠️ There is no header table — and the unit of work is the **LINE** (corrected 2026-07-21)

`budgetandtarget` is a **flat line table** with no header. That part was always right. But this
section previously concluded *"plan every screen, every gate and every status transition around
the **group**, not the row"* — and reading the legacy shows **that is backwards**. It was
extrapolated from the mail Blade, which is the one place the group genuinely governs.

**What the 34 legacy files actually do — the line is the unit of everything:**

| Concern | Legacy behaviour | Source |
|---|---|---|
| List queries | **no `GROUP BY` in any of the six** — one HTML row per `budgetandtarget.ID` | all `View/*view.php` |
| Approve | `UPDATE budgetandtarget … WHERE ID = :InsertID`, looped over checked rows | `Approval PM/…:68` |
| Revise | same single-PK `WHERE`, status only | `Approval PM/…:152` |
| Cancel | same single-PK `WHERE` | `Cancel PM/…:99` |
| Detail screens | take a single `?ID=`, redirect home without one | `budgetandtargetdetails.php:8-12` |
| History | `budgetandtargetassignment` keyed per line — a batch of N writes N rows | `Approval PM/…:88-95` |
| User-facing identity | the bare autoincrement PK, under a column header literally reading `BNT.ID` | `listbudgetandtargetview.php:486,565` |

**There is no BnT document number** — no zero-padded code, no per-year sequence. The "BNT No"
search box is a `LIKE` against the integer PK.

**Where the group IS real — exactly two places:**

1. **Create** inserts a batch of lines sharing `(PeriodeID, DivisionID, CompanyID, UserIDInput,
   UserIDSales)` in one transaction (`Create/createbudgetandtarget.php:63-115`).
2. **The notification email** GROUPs by those five columns to build its header — which is what
   the dormant Blade encodes, and the *only* thing the old claim was based on.

**So: build line-first, and treat the group as a derived view for the Create screen and the
email.** The revised entry point proves it — `Create/createbudgetandtargetrevise.php` edits
**one line** by `?ID=`, not a group.

⚠️ **Three legacy defects here are worth a conscious decision rather than an accidental port:**
`beginTransaction()` sits **inside** the batch loop, so a failed batch is half-committed and the
`catch` unwinds only the line in flight; the approve/cancel UPDATEs **never re-assert the
expected current status**, so a stale page or forged POST can drive any row to any state; and
the approver's comment is a **single page-level textbox** copied verbatim onto every selected
line's history row.

### 5.1 `budgetandtarget` — the plan lines (75 rows) — model `App\Models\BudgetAndTarget`

| Column | Type | Notes |
|---|---|---|
| `ID` | int AI | PK |
| `IsDeleted` | int | manual soft delete |
| `BudgetAndTargetStatusID` | int NULL | FK → `budgetandtargetstatus` |
| `Tanggal` | datetime | created / last touched |
| `BudgetAndTargetPeriodeID` | int NULL | FK → `budgetandtargetperiode` (the year) |
| `UserIDInput` / `UserIDSales` | int NULL | creator / salesperson — differ on an on-behalf entry |
| `PrincipalID` / `DivisionID` / `CompanyID` | int NULL | FK → `principal` / `division` / `company` — ⚠️ **0-sentinels present, §5.6 #1** |
| `QtBudget`, `QtTarget` | decimal(20,5) | the SALES' quantities |
| `UnitPriceUSD`, `UnitPriceIDR` | decimal(20,5) | the SALES' unit price |
| `ValueBudgetUSD`, `ValueTargetUSD`, `ValueIDR` | decimal(20,5) | the SALES' computed values |
| `Comment` | varchar(500) | |
| `ASTProdIntID` | int | ⚠️ **not a reliable FK, §5.6 #2** |
| `ASTProductCode`, `ASTProductName` | varchar(200) | product, denormalised from AST |
| `ASTPrincipalCode`, `ASTPrincipalName` | varchar(100) | principal, denormalised from AST |
| `QtyBudget{PM,SM,MM}` | decimal(25,2) | **each tier's revised budget qty** |
| `QtyTarget{PM,SM,MM}` | decimal(25,2) | each tier's revised target qty |
| `UnitPrice{PM,SM,MM}` | decimal(25,2) | each tier's revised unit price |
| `ValueBudgetUSD{PM,SM,MM}` | decimal(25,2) | each tier's revised budget value |
| `ValueTargetUSD{PM,SM,MM}` | decimal(25,2) | each tier's revised target value |

**18 of the 38 columns exist so each tier can restate the numbers without destroying the
previous opinion.** In the live data they are usually copies of the sales' figures (the
reviewer agreed), which means the approval UI must **pre-fill each tier's fields from the
previous tier**, not present blanks.

**FKs enforced on dev** (all `ON DELETE/UPDATE RESTRICT`): `BudgetAndTargetPeriodeID`,
`BudgetAndTargetStatusID`, `CompanyID`, `DivisionID`, `PrincipalID`, `UserIDInput`,
`UserIDSales`.

### 5.2 `budgetandtargetassignment` — history (328 rows)

`BudgetAndTargetID`, `BudgetAndTargetStatusID`, `Tanggal`, `UserID`, `Comment` — the house
`<entity>assignment` audit pattern, identical in shape to `visitplanassignment`. **Keyed to a
LINE, not a group**, so a group-level action writes N rows inside one transaction.

### 5.3 Lookups

- **`budgetandtargetperiode`** (9 rows) — `PeriodeName`, `StartDate`, `EndDate`.
  "Periode 2018".."Periode 2026", each spanning its calendar year.
- **`budgetandtargetstatus`** (10 rows) — `StatusName`:

  | ID | Name | | ID | Name |
  |---|---|---|---|---|
  | 1 | Created | | 6 | Revise |
  | 2 | ~~Approved~~ (`IsDeleted=1`, retired) | | 7 | Approve PM Cancel |
  | 3 | Approval PM | | 8 | Approve SM Cancel |
  | 4 | Approval SM | | 9 | Approval MM |
  | 5 | Cancel Request | | 10 | Approval MM Cancel |

  Live distribution in `budgetandtarget`: 59 rows at **9**, 14 at **1**, 1 at **3**, 1 at **6**.

### 5.4 `budgetandtargetreal` (170 rows) — ⚠️ purpose UNCONFIRMED

Column-for-column identical to `budgetandtarget` **minus `ASTProdIntID`**. Covers periods 3–5
(2020–2022) where `budgetandtarget` covers 5–9; only **13 of 135** `(periode, company,
product)` keys overlap; carries the full status vocabulary including the cancel states.
Candidates: an earlier generation of the same table, or a parallel "realisasi" plan.
**Do not design against it until §9 Q3 is answered.**

### 5.5 `budgetandtargetachievements` (24 rows) — the actuals

`BudgetAndTargetPeriodeID`, `CompanyID`, **`BarangID`** (a real product FK — unlike the plan
table), `QuantityInKG`, `UnitPriceUSD/IDR`, `ValueUSD/IDR`, `AchievedTarget` datetime,
`Comment`. This is "what was actually sold against the plan". Only periods 1–2 (2018–19) are
populated, so it looks **abandoned** — §9 Q4.

### 5.6 ⚠️ Schema traps — every future pass MUST handle these

1. **The 0-sentinel will break your first INSERT.** 35 rows have `PrincipalID = 0`, 30 have
   `DivisionID = 0`, 2 have `CompanyID = 0`, 1 has `UserIDSales = 0`. **No row with `ID = 0`
   exists** in `principal`, `division`, `company` or `users`, yet dev enforces those FKs with
   RESTRICT. Existing rows are grandfathered; a **new** insert copying the sentinel dies with
   `SQLSTATE[23000]`. Prod probably lacks the FKs, so this is a dev-only failure that looks
   mysterious. **Write `NULL`, never `0`.** The display rule for the 65 legacy rows is a user
   decision — do not "fix" the data (README rule 13).
2. **The product is not a foreign key.** `ASTProdIntID` matches `barang.ID` for only **12 of
   56** distinct values, and many rows are pure free text (`ASTProdIntID = 0`, empty code,
   names like "ORANGE RDN" / "RED 48:3"). **AST is an external system** the masters are synced
   from; there is **no `ast*` table in this database**. An `AstIntegration` prototype exists
   under `Pages/Proto/AstIntegration/` (another workstream — reference only). §9 Q2.
3. **Two different money precisions.** Sales columns are `decimal(20,5)`; the PM/SM/MM columns
   are `decimal(25,2)`. Comparing or summing across them silently rounds. Cast explicitly.
4. **Two currencies, inconsistently.** `UnitPriceIDR` / `ValueIDR` exist on the sales columns;
   the tier columns are **USD only**. Whatever the IDR rule is, reviewers' figures don't carry it.
5. **Group actions fan out.** `budgetandtargetassignment` is per line (§5.2) — a group approval
   writes one row per line, transactionally.
6. **The models are auto-generated and unverified.** Check each against this section before
   trusting it; do **not** regenerate them.

---

## 6. Page Inventory — what the PROTOTYPE designs (analysed 2026-07-21)

`Pages/Proto/Budget/*` + `Components/Proto/Budget/*`. **All data is mock and client-side**
(seeded-PRNG fact tables, hardcoded arrays); the controller methods render with zero props.
Routes `proto.budget.*`. Reference only — README rule 8.

### 6.1 `Create.jsx` — the entry grid

Grouped by **Company** (collapsible), one line per product. Nine columns:
`Principal (select) · Product (select) · Qty Budget · Qty Target · Unit Price USD ·
Value Budget (computed) · Value Target (computed, +Δ%) · Remark · actions`.
Row shape `{id, company, division, principal, product, qtBudget, qtTarget, unitPrice, remark}`.
Value cells are computed (`qty × unitPrice`), never stored — decide whether to persist (the
table has columns for them) or recompute (§9 Q11). Header controls: Period (2018–2026), a
**Generate Data** button that seeds baseline rows from a prior year, Company/Division display
filters, **Add Detail**. Footer buttons `Save draft` / `Submit budget` have **no handlers**.

⚠️ **No month or quarter entry anywhere** (§3.3). ⚠️ **No Company or Division input in the
grid** — `division` is only inherited when exactly one filter is selected, and a row added with
no company filter lands in an unassignable "Unassigned" group.

### 6.2 `ApprovalPM.jsx` — a dense analytics board

Shares one component with the "View All" pivot page (`mode="approval"` vs `"view"`). Grouping
tree over `principal / company / sales / division / product`, freeze/pin columns, prior-year
columns, a per-row **5-year monthly history** drill-down, per-row **Approve / Revise**, and a
bulk action bar.

### 6.3 `ApprovalSM.jsx` — a completely different, simpler screen

Search form (BNT No / Periode / Division / Creator / Sales / Company / Principal) + a flat
14-column result list, with only bulk **Approve SM / Reject SM**. ⚠️ Both handlers currently
just clear the selection. ⚠️ **Vocabulary clash:** PM says *Revise*, SM says *Reject*; the real
status lookup has **Revise (6)** and no "Reject" at all — settle this (§9 Q5).

### 6.4 🔴 The prototype does not model the module's core mechanic

The real table devotes 18 columns to per-tier overrides (§5.1). The prototype's approval is a
**boolean** — `pending / approved / revised` in local React state, with **no editable number
fields and no comment box**.

**Therefore:** the lists, filters, pivot and 5-year report are reusable; **the approval screens
must be redesigned**, not ported, to (a) show the previous tier's figures, (b) let this tier
override them, (c) capture a comment into `budgetandtargetassignment`. This is the one
documented exception to the frontend-freeze rule (README rule 10).

#### 🔴🔴 The prototype fabricates every historical and analytical number — "honesty gates" (added 2026-07-21)

Discovered while cutting the data seam, and **worse than §6.4 alone suggests**. The prototype
does not merely lack the tier-override mechanic; large parts of what it *displays* are invented:

| Surface | What generated it |
|---|---|
| The whole pivot (23 measures) | `usePivotEngine.js` — a seeded PRNG (`mulberry32`) over a hardcoded product/customer list |
| Prior-year Quantity / Value columns | `HIST_FACTOR = {−3: 0.72, −2: 0.81, −1: 0.91}` applied to the current budget |
| Per-row 5-year monthly drill-down | `buildHistory()` — annual figure ÷ 12 with ±12% deterministic jitter |
| Quarterly Q1..Q4 split | `Q_RATIOS = [0.23, 0.26, 0.25, 0.26]` — the schema is **annual-only**, so any quarter split is an assumption |
| "Last updated: Jun 16, 2026 10:30 AM" | a hardcoded string |
| `BNT-2026-nnn` document numbers | a hash of product+company; **there is no BnT document number** |

All of these need ACTUAL sales, which live in NetSuite. `BudgetBoard` therefore takes two
gates, **both defaulting to OFF** — `pivot` and `historyAvailable` — plus `mergeLines` (off,
because legacy never groups). With them off the board renders only real, stored columns. The
report pages show `NetSuitePendingNotice` explaining what is hidden and why.

**Do not switch these on to "make the page look finished".** They may only be enabled once the
server genuinely supplies the data behind them.

### 6.5 `usePivotEngine.js` + `PivotTable.jsx` — worth keeping

23 measures (actual / budget / target, Attain%, YoY%, Growth%, Var, Gap, forecast run-rate,
%Total, ASP), cumulative-YTD vs per-period modes, YoY computed by linking each column to the
same season of the prior year, multi-level sort, configurable row/column dimension tree.
Reusable more or less as-is once the mock fact tables are swapped for server-fed data — but it
needs a live actuals source, i.e. §9 Q4.

### 6.6 `FiveYearGrowthReport.jsx`

Five actual years plus two plan rows (Budget, Target) for the selected year; per-principal
`Qt | % | Value | %` blocks, paginated 4 principals per page. **Both plan rows compare against
the previous year's ACTUAL**, not against plan.

### 6.7 Also missing from the prototype

No IDR anywhere · no status badge or status filter (the filter state exists but is unreachable)
· no BNT numbering rule (`bntNo` is a display-only hash) · no permissions (all `/proto/*` is
auth-only, outside `rolemenu`) · no email affordance · no approver comment capture.

### 6.8 Two prototype breakages — report, do not fix (README rule 8)

1. `Proto/Budget/ApprovalSM.jsx:77` calls `route('proto.budget.index')`, which is **not a
   registered route** — Ziggy throws, so the page fails to render. Same bug class as the Visit
   Plan PM-calendar crash fixed in `adbc34d`; a repo-wide sweep for unresolvable `route()`
   names would be worthwhile.
2. `proto.budget.create-modal` renders `Proto/Budget/CreateModalComparison`, which **does not
   exist**; the sidebar links to it.

---

## 7. Legacy Menu Tree (dev DB `menu` table, verified 2026-07-21)

Root **308** "Budget and Target". All leaves `IsShow=1`, `IsDeleted=0`. The `Link` column gives
the legacy file to request — see `Just An Example, Do not Link/_PUT-LEGACY-FILES-HERE.md`.

| Menu | ShowName | Legacy file |
|---|---|---|
| 310 | Create Budget and Target | `createbudgetandtarget.php` |
| 312 | View Budget and Target | `listbudgetandtarget.php` |
| 313 | View All Budget and Target | `listbudgetandtargetall.php` |
| 421 | View Head Dept - Budget and Target | `listbudgetandtargetheaddept.php` |
| 318 | View SM - Budget and Target | `listbudgetandtargetsm.php` |
| 323 | View MM - Budget and Target | `listbudgetandtargetmm.php` |
| 317 | View PM - Budget and Target | `listbudgetandtargetpm.php` |
| 311 | Approval PM Budget and Target | `listapprovalpmbudgetandtarget.php` |
| 316 | Approval SM Budget and Target | `listapprovalsmbudgetandtarget.php` |
| 322 | Approval MM Budget and Target | `listapprovalmmbudgetandtarget.php` |
| 320 | Cancel Approval PM - Budget and Target | `listapprovalpmcancelbnt.php` |
| 321 | Cancel Approval SM - Budget and Target | `listapprovalsmcancelbnt.php` |
| 324 | Cancel Approval MM - Budget and Target | `listapprovalmmcancelbnt.php` |

Under **Pengelolaan** (root 11) → **314** "Budget and Target":

| Menu | ShowName | Legacy file |
|---|---|---|
| 315 | Periode Budget and Target | `listbudgetandtargetperiode.php` |

⚠️ All 14 carry a legacy `Link` and an **empty `LinkLaravel`** — so none is wired to the
Laravel app yet. Filling `LinkLaravel` + granting `rolemenu` is part of each phase's wiring,
and CLAUDE.md's duplicate-legacy-menu trap applies: gate on `LinkLaravel`, and remember the
admin screen searches on `Name` while the sidebar renders `ShowName`.

---

## 8. Roadmap (proposed — each phase re-confirms scope with the user first)

⚠️ **The flow below is the INFERRED reading** (Aturan Absolut #0c). Status appears to name the
tier that last acted: `1 Created → 3 Approval PM → 4 Approval SM → 9 Approval MM (final)`, with
`6 Revise` bouncing back and a separate `5 Cancel Request → 7 → 8 → 10` withdrawal chain.
Evidence: 59 of 75 rows sit at 9, the last tier. **The opposite reading — status names the tier
whose approval is awaited — fits the same data identically.** §9 Q1.

```mermaid
flowchart TD
    A[Sales creates lines] -->|1 Created| B{PM reviews}
    B -->|approve, may restate numbers| C[3 Approval PM]
    B -->|send back| R[6 Revise]
    C --> D{SM reviews}
    D -->|approve, may restate numbers| E[4 Approval SM]
    D -->|send back| R
    E --> F{MM reviews}
    F -->|approve, may restate numbers| G[9 Approval MM - FINAL]
    F -->|send back| R
    R -->|sales fixes, resubmits| B
    G -->|withdrawal requested| H[5 Cancel Request]
    H --> I[7 Approve PM Cancel]
    I --> J[8 Approve SM Cancel]
    J --> K[10 Approval MM Cancel - FINAL]
```

| Phase | Content | Blocked on |
|---|---|---|
| **0** | Obtain the 14 legacy files; answer §9 Q1–Q12; verify the 6 models against §5 | the user |
| **1** | `budgetandtargetperiode` lookup CRUD (menu 315) | nothing — **the recommended first slice**: smallest real scope, the `bank` reference pattern applies verbatim, touches no disputed workflow, and every other screen needs the period picker |
| **2** | Read-only own list (312) + the group-aware query (§5.0) | Phase 1 |
| **3** | Create (310): the entry grid, transactional multi-line insert + assignment rows | Q2, Q11, Phase 2 |
| **4** | The 5 tier list variants (313/421/318/323/317) via a generalised access service | Q8, Phase 2 |
| **5** | Approval PM/SM/MM (311/316/322) — tier-column writes + history + comment | **Q1**, Q5, Q6, Phases 3–4 |
| **6** | Cancel flow (320/321/324) | Q7, Phase 5 |
| **7** | Pivot + 5-year reports + XLS export | Q4, Phase 4 |
| **8** | Email (`sendEmailBNTtoUser`): Mailable + wire the dormant Blade | Q10 — **and the delivery warning below** |

> **⚠️ Before wiring ANY send:** `.env` has `MAIL_MAILER=smtp` (live Brevo relay), **not** the
> log driver, and **no queue worker is running** — queued mail accumulates in the `jobs` table
> and is silently never delivered (GitHub issue **#155**). Verify `.env` yourself every time;
> never assume ([[mail-driver-check-env]]).

---

## 9. Open Questions (resolve from legacy files / user before the relevant pass)

**Six of these were ANSWERED on 2026-07-21 from the supplied legacy.** They are kept here with
their answers rather than deleted, so nobody re-opens a settled question.

### ✅ Answered

| # | Question | **Answer** (source) |
|---|---|---|
| **Q1** | Does "Approval PM" mean *PM has approved* or *awaiting PM*? | **PM HAS APPROVED.** Statuses are past-tense. The PM queue selects status **1**, SM selects **3**, MM selects **4**; approve writes 3 / 4 / 9. So a row at "Approval PM" is PM-signed and **awaiting SM**. (`Approval PM/…view.php:69`, `Approval SM/…view.php:69`, `Approval MM/…view.php:68`; writes at `Approval PM/…:56,68`, `Approval SM/…:109`, `Approval MM/…:116`) — **the §8 inferred chain was correct.** |
| **Q2** | Where does the product catalogue come from? Is free text allowed? | **NetSuite**, live per page load via `RestletPHPToolkit` (`getbarang()`, `getprincipal()`, `gettransactionitemlastprice()`). The `AST*` columns store NetSuite values (`ASTProdIntID` = NS `internalid`). The older **AST = PostgreSQL** master (`t05_*` via `DBHandlerPostGres`) is commented out everywhere except `View/listbudgetandtargetall.php:139`. **Free text is allowed** — unpicked products keep `ASTProdIntID = 0`. (`Create/createbudgetandtarget.php`, `Create/getsalesanalysisns.php`) |
| **Q5** | Can a reviewer edit numbers *and* send back? Does Revise reset tier columns? Is SM's verb "Revise" or "Reject"? | **"Revise" at every tier — "Reject" does not occur anywhere in the legacy.** Approve copies the previous tier's figures forward into this tier's columns (sales→PM→SM→MM). The per-tier numeric inputs exist and are fully wired but sit in `display:none` cells, so **in practice approve is a status flip + copy-forward**. Revise writes **status only** and does **not** clear the tier columns — stale PM figures survive an SM revise. Comment is one page-level box, stamped on every selected line. |
| **Q6** | On Revise, does the record go to sales or the previous tier? | **To the creator** (`UserIDInput`) — status 6 leaves all three queues, and the revise email is addressed to `UserIDInput`. The creator's edit screen then writes it back to **status 1**, so it re-enters at PM. (`Approval PM/…:187`; `Create/createbudgetandtargetrevise.php:33`) |
| **Q7** | Who may raise a Cancel Request? Only after full approval? | Raised from the View/detail screens, **not** only after approval. The gate is a **blocklist** — offered whenever status ∉ {5,7,8,10}, i.e. from **1, 3, 4, 6 and 9**. It is UI-only; the POST handler re-checks nothing. Note `listbudgetandtargetall.php` is unscoped and also carries the handler, so anyone reaching "View All" can cancel any row. (`View/budgetandtargetdetails.php:640`) |
| **Q13** | How does our Laravel port reach NetSuite — direct Restlet calls, a synced local mirror, or defer? | **A DB MIRROR OF THE FUNCTION RESULTS** (user decision 2026-07-30). Not the RESTlet, not a deferral. The `netsuite_2` connection (`ccins-function`) holds **one table per public function** of the legacy toolkit — 83 tables against 83 functions; the only function without one is `jsongenerator`, the transport itself. Table columns are that function's result fields named exactly as NetSuite names them. `NetSuiteClient` runs the legacy 4-arg filter DSL as SQL. So Create's product catalogue, principal list and last-price lookup all resolve without any NetSuite credential. ⛔ **Never ask for RESTlet credentials.** The production source moves later — issue #207. |
| **Q11** | Are the Value columns persisted or recomputed? | **Persisted.** Create computes them client-side and POSTs them (`txtValueBudgetUSD[]`), approve writes each tier's `Value*` columns, and every read renders the stored column with no arithmetic. Integrity depends entirely on whatever wrote the row. |

### ❓ Still open

| # | Question | Blocks |
|---|---|---|
| **Q3** | What is **`budgetandtargetreal`** (170 rows)? Archive, parallel plan, or dead? | Whether 170 rows matter. |
| **Q4** | Is **`budgetandtargetachievements`** still used? Only 2018–19 data. | Plan-vs-actual reporting. **New evidence — probably NOT:** the Performance reports take actuals from **NetSuite** (`getperformancebntmonth()`) and define "the plan" as `BudgetAndTargetStatusID IN (9)`, never touching this table. Confirm before Phase 7. |
| **Q8** | Which **roles** get each of the 13 menus? | `rolemenu` seeding + sidebar↔403 parity. **Not answerable from what was supplied** — legacy's role gate lives in `header.php`, which was not sent. Phase 4. |
| **Q9** | Is there an **on-behalf** create? | **Partly answered — the Create screen has none:** it sets `UserIDInput = UserIDSales = session user` (`Create/createbudgetandtarget.php:24-25`). Yet the `head` tier scopes on `UserIDSales` while `own` scopes on `UserIDInput`, and live rows exist where the two differ — so on-behalf entries came from somewhere the supplied files don't show. Ask the user. Phase 3. |
| **Q10** | Does BnT send email beyond `sendEmailBNTtoUser`? | **No other function is called** — but *when* it fires is the real question: only **PM revise, SM revise, MM approve, MM revise**. PM approve is commented out, SM approve sends nothing, and the **entire cancel flow sends nothing** (its `mailhandler.php` include is dead). Whether that silence is intended is a **user decision**. Phase 8. |
| **Q12** | Is **monthly/quarterly** budget entry actually required? | **Probably not for entry.** The plan is annual; the Performance reports do slice by Date/Quarter/Month/Semester/Year, but they slice **NetSuite actuals**, not the plan. So monthly *reporting* is required, monthly *entry* is not — confirm. Otherwise a schema change, §3.3. |
| ~~**Q13**~~ | ~~How does our Laravel port reach **NetSuite**?~~ | **ANSWERED 2026-07-30 — moved to "Answered" below.** |

---

## 10. Binding Conventions for Every Future Pass

1. **Read `README.md` in this folder top-to-bottom first.** Its GATE 1 is a blocking
   precondition, not advice.
2. **Invoke `new-feature` + `inertia-v3-stack` before writing code** (CLAUDE.md hard rule).
3. **TDD** — Pest red first, then implement. `DatabaseTransactions` against the live dev DB.
4. **FormRequest-only validation**; **Policy + `authorize()`** on every non-read action;
   Eloquent or bound parameters only.
5. **Never assert global row counts** on `budgetandtarget*` — all four data tables hold real
   dev rows. Assert presence/scope of your own rows.
6. **Mint real `role` rows** for synthetic test grants — `rolemenu.RoleID → role.ID` is enforced
   on dev, so an invented id fails with `SQLSTATE 23000`.
7. **Never insert a 0-sentinel** (§5.6 #1) — write `NULL`.
8. **Inertia render prefix is `MenuBudgetAndTargets/...`** — the page path must match the
   folder, and tests assert the same string.
9. **Additive-only migrations**; no structural change to the existing schema.
10. **After any `.jsx` edit run `bash nodebuild.sh`** from the outer root, after the mandatory
    pre-build sweep (README rule 7).
11. **Commit by explicit pathspec only**, never bare, and never anything from the legacy folder.
12. **Update this PRD and the README at session end** (README §5 On-end), and record user
    decisions in §1 "Locked decisions" with a date.

---

## 11. References

| What | Where |
|---|---|
| Module rules / onboarding | `README.md` (this folder) |
| Legacy files (local only) | `Just An Example, Do not Link/` + its `_PUT-LEGACY-FILES-HERE.md` |
| Pattern template — multi-tier approval, `<entity>assignment`, tier scoping, XLS export | `../MenuVisitPlans/` (README + PRD + code); `app/Services/VisitPlanAccess.php` |
| UI design source | `../Proto/Budget/*`, `../../Components/Proto/Budget/*` |
| Lookup-CRUD reference (Phase 1) | the `bank` reference feature — CLAUDE.md "Reference Feature" |
| Mail port recipe + the dormant Blade | `resources/views/mail/budget-and-target-document.blade.php` (read its header comment); `app/Mail/VisitPlanToUserMail.php` |
| Legacy email catalogue entry | CLAUDE.md → "Legacy Email Port Catalog" → BNT blueprint |
| Project contract | `ColorindoChemtraInertia/CLAUDE.md` (read in full) |
| Queue/mail delivery warning | GitHub issue #155 |
