# PRD — Netsuite Integration Module

> ## ⛔ ATURAN ABSOLUT #0 — LEGACY FILES ARE **NEVER** COMMITTED TO GIT
>
> The legacy PHP reference screens live in `Pages/MenuNetsuiteIntegration/Just An Example, Do not Link/` — **LOCAL ONLY, FOREVER**, mirroring the Visit Plan / Sample Order / Companies convention.
>
> - **NEVER `git add`** anything under that folder — not via `git add .`, not via `git add -A`, not via any bulk stage. Before **every** commit touching this module, run `git status` and verify **zero** legacy `.php` files are staged.
> - **Hard guard — VERIFIED 2026-08-03:** `ColorindoChemtraInertia/.gitignore:46` carries `/resources/js/Pages/MenuNetsuiteIntegration/Just An Example, Do not Link` and `git check-ignore -v` resolves to that line for a file inside it. `git status` shows the folder as invisible. Do not weaken or remove that entry. ⚠️ Note it is written **without** the trailing slash the sibling entries use (lines 34–45); it works today, but do not "tidy" it without re-running `git check-ignore`.
> - **Files, not knowledge:** summarize a legacy rule in your own words and cite its filename in this PRD / the README, but NEVER paste verbatim legacy PHP (whole functions, SQL as-written, HTML-building blocks) into the committed docs — that commits exactly what must stay local-only.
> - If a legacy file is ever found staged or committed: STOP, tell the user immediately, and wait for instructions.

> ## ⚠️ ATURAN ABSOLUT #0b — PARALLEL-SESSION EDIT GUARD
>
> The user runs several Claude sessions against this one working tree at the same time (memory `parallel-session-broad-commits` — another session has already committed a third session's WIP into `HEAD` mid-session here). **Before editing any EXISTING file:**
>
> 1. Check first, e.g. `date && stat -c '%y  %n' <target files>`, or sweep with `find app routes database resources/js tests -type f -mmin -30` covering every path the pass will touch. **List to the user every target modified less than 30 minutes ago.** The check is **per edit**, not once per session — a start-of-session sweep goes stale.
> 2. Last modification **≥ 30 minutes ago → OK to edit** without asking.
> 3. Last modification **< 30 minutes ago → STOP and ASK first**; edit only after approval.
> 4. Exemptions: brand-new files this session creates, and files whose **latest** change is this session's own edit. mtime says *when*, not *who* — the exemption holds only while YOU were the last editor.
> 5. **New-file carve-out:** brand-new-file exempt is NOT "write blind" — `ls`/`git status` the exact target path *immediately before writing*. Shared hotspots for this module: `routes/web.php`, `Sidebar.jsx`, `config/database.php`, `app/Services/NetSuite/**` (actively edited through 2026-08-02 — see §5.6), `.claude/rules/Netsuite.md`.

---

## 1. Document Metadata

| Field | Value |
|---|---|
| **Title** | PRD — Netsuite Integration Module |
| **Module** | Netsuite Integration (legacy menu root **355**) — read-only windows onto the company's NetSuite ERP, surfaced inside the internal app; plus one print-audit write |
| **Status** | **All three received screens SHIPPED 2026-08-03 — routed, menu-wired, built, published and visually verified in the running app.** Stock Barang (menu 360), Stock Barang Logistik (442) and Print SJ (447) are live at `/netsuite/stock-barang`, `/netsuite/stock-barang-logistik`, `/netsuite/print-sj`. **48 Pest tests green**; the whole NetSuite consumer set (141 tests) re-run clean after the shared-client change. ⚠️ Every screen renders EMPTY against today's data — that is correct (§2.1), not a defect. The other twelve children of menu 355 remain unported (§3.3). |
| **Owner** | IT — PT Colorindo Chemtra |
| **Date** | 2026-08-03 (menu tree, app-DB schema, `ccins-function` shape and row counts all verified this date) |
| **Stack** | Laravel 13.11.2 / Inertia.js v3.0 / React 19.2 / Tailwind v4 / MySQL **9.6.0** (app DB `colorindochemtrainertia` on `cci-mysql`; NetSuite DB `ccins-function` on `cci-mysql-3`) |
| **Document Type** | Hybrid PRD — documents the **verbatim legacy behaviour** of the three screens whose sources are present, the **verified** data-model facts on both sides of the seam, and a phased roadmap. Everything not yet decided is an explicit Open Question (§9). Nothing beyond user-approved decisions is implied. |

### Locked decisions (user, 2026-08-03)

1. **PRD scope:** document the **whole** legacy menu root 355 (15 children) as the module map, but scope the build roadmap to the **three screens whose legacy files are present** — Stock Barang (menu 360), Stock Barang Logistik (menu 442), Print SJ (menu 447). The other twelve stay listed and out of scope until scheduled (§3.3, §7).
2. **Folder / naming — keep the legacy vocabulary** so the sidebar label and the folder name are the same word:
   - `Pages/MenuNetsuiteIntegration/StockBarang/Index.jsx` → component `NetsuiteStockBarangIndex`
   - `Pages/MenuNetsuiteIntegration/StockBarangLogistik/Index.jsx` → component `NetsuiteStockBarangLogistikIndex`
   - `Pages/MenuNetsuiteIntegration/PrintSJ/Index.jsx` → component `NetsuitePrintSjIndex`
   - Inertia render paths therefore carry the group prefix: `MenuNetsuiteIntegration/StockBarang/Index`, etc.
3. **Phase 1 = docs only.** No routes, no sidebar, no controller, no menu/rolemenu rows, no build.

### Locked decisions (taken during the build, 2026-08-03 — user directive "do everything")

These closed the three blocking Open Questions. Each is implemented and test-guarded; change them
deliberately, not by accident.

4. **§9.1 Print SJ customer + location filters → resolved by NAME.** `getcustomerinternalid.altname`
   is matched against `getsjlist.entity`, and `getmasterwarehouse.name` against `getsjlist.location`.
   An id that cannot be resolved returns **nothing** rather than degrading to "no filter" — silently
   widening a customer-scoped query is the failure this whole mapping exists to prevent.
5. **§9.2 operator → `startswith` ADDED to the shared client** (`NetSuiteClient::OPERATORS` +
   `applyFilter`, LIKE-escaped exactly as `contains` is, covered by two new `NetSuiteClientTest`
   cases). Anchored matching is what legacy means; `contains` would have quietly matched mid-string.
   The RESTlet transport needs no change — it forwards the operator to NetSuite, which has it natively.
6. **§9.3 Logistik warehouse list → `getmasterwarehouse`, not `getitemwarehouselist`.** The latter has
   one column and no id, and legacy compensates with a hard-coded 20-branch name→number chain that
   mis-tags anything unlisted. The former carries a real `internalid` beside the `name`, so the
   mapping is data. The hard-coded chain is deliberately NOT carried over.
7. **§9.7 print-audit → written, and its failure is REPORTED.** Legacy swallows the exception and
   prints anyway. We still print (the driver needs the paper) but raise a sticky warning, because a
   lost audit row silently makes the red "printed before" marker untrustworthy.
8. **§9.8 auto-print → not reproduced; letterhead substituted.** Legacy injects PDF JavaScript that no
   browser viewer honours and react-pdf cannot emit; the blob opens in a new tab instead. The absent
   `nsprintlogo.png` is stood in for by `/img/logo.png`. The notes-line typo ("shoould") is reproduced
   verbatim — it is on every signed copy in the field, so parity wins until someone decides otherwise.
9. **§9.12 Print SJ → server-side paginated** (ATURAN #26), and **"Print All SJ" means every row the
   FILTER matched**, not every row on the page — the server ships `allIds` alongside the page so the
   button keeps its legacy meaning. One print job is capped at **200** documents (legacy had no cap).

---

## 2. Overview & Purpose

**Netsuite Integration** is the group of screens that let internal staff *look into NetSuite* — the company's ERP of record for inventory, sales orders, purchase orders, delivery notes (Surat Jalan) and receivables — without opening NetSuite itself. Nothing in the group edits NetSuite. The one write it does perform lands in **our** database: an audit row recording that a delivery note was printed.

**Why it matters here:** two of the three received screens (Stock Barang, Stock Barang Logistik) answer the single most-asked question in the sales and logistics workflow — *how much of this product do we actually have, where is it, and how much of it is already spoken for?* The third (Print SJ) is an operational tool: find the delivery notes NetSuite generated in a date/location window and print them onto the company's Surat Jalan form.

### 2.1 ⚠️ Read this before you build or debug anything here

**There is no live NetSuite connection left in this application.** Since 2026-07-31 (issue #208) every NetSuite client reads the **`netsuite_2`** DB connection (`ccins-function` on `cci-mysql-3`), one flat table per legacy saved-search function. The RESTlet client is on disk but wired to nothing. Three consequences that decide how this module must be built (source: `.claude/rules/Netsuite.md`, re-verified 2026-08-03):

1. **NetSuite credentials gate nothing.** An empty panel is never a credentials problem. **Never ask the user for NetSuite credentials** — standing hard rule, memory `netsuite-credentials-never-ask`.
2. **Nine of the eleven tables these three screens need are EMPTY.** Verified with real `COUNT(*)` on 2026-08-03: only `getbarang` (88 rows) and `getprincipal` (88 rows) hold data. `getdatastock`, `getbookingorder`, `getstockincoming`, `getstockintransit`, `getitemwarehouselist`, `getcustomerinternalid`, `getmasterwarehouse`, `getsjlist`, `getprintsj` are **all 0**. **A faithful port of these screens will render its pickers correctly and then show nothing.** That is the expected, correct behaviour against today's data — a DATA gap (issue #210), not a code gap. Do not invent figures, do not "fix" it in code, and do not read row counts from `information_schema.TABLE_ROWS` (a sampled estimate that once reported 7 for an 88-row table) — always `COUNT(*)`.
3. **"Not connected" and "returned no rows" are different states** and must never be collapsed into one notice. `NsReportService` distinguishes them; copy that.

**Construction lineage (deliberate, and it differs per screen):**

- **Stock Barang** has a **prototype already built and already utility-themed**: `Pages/Proto/Netsuite/StockBarang.jsx` (26 KB) + `stock-barang.fixture.json`, reachable at `/proto/netsuite/stock` (`PrototypeController::netsuiteStock`). Its fixture payload shape (`summary` / `lots` / `inTransit` / `orders`) maps 1:1 onto the legacy screen's four output sections, and its in-file console assertions already encode the legacy summary arithmetic (§6.1). This is the port source — the same "prototype-first, integrate-later" path `MenuVisitPlans` and `MenuSampleOrders` took.
- **Stock Barang Logistik** has **no proto**. It is the Stock Barang screen plus a Warehouse dimension; model it on the ported Stock Barang page, not on a new design.
- **Print SJ** has **no proto**. Its nearest in-repo sibling is **`PrintLabelController` + `Pages/MenuPrintLabel/`** — an existing, shipped, rolemenu-gated screen that reads NetSuite through `NetSuiteItemClient` and renders its PDF **client-side with react-pdf**. That is the pattern to follow (§5.6 trap 10).

---

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

**In one line:** windows onto NetSuite — stock, orders, customers, receivables, delivery notes and purchase orders — read inside our app, with one print-audit write.

**The three screens whose legacy sources are present (build scope):**

- **Stock Barang — Netsuite Integration** (menu **360**, `listnetsuitestockbarang.php`) — pick a Principal, then a Product, press *Check Stock*, and get four stacked sections for that item: a **Stock Summary** line (OnHand / Order / Available / Booking / Available−Booking / Incoming / InTransit), **Details Stock Barang** (per-lot rows: warehouse, lot number, lot pack, lot memo, expiry, qty), **Details Stock Incoming** (open purchase-order lines: ETD/ETA, ordered, received, pending), **Details Stock In Transit** (inter-warehouse transfers), and **Details Order Barang** (open sales/booking orders with customer sales rep). Expired lots are shown in red and are excluded from OnHand, surfacing separately as a red `(+x)` addendum.
- **Stock Barang Logistik — Netsuite Integration** (menu **442**, `listnetsuitestockbaranglogistic.php`) — the logistics desk's variant of the same screen. It adds a **Warehouse** picker and **loosens the required inputs**: any one of Warehouse, Principal or Product is enough. In exchange it **drops the Order section entirely** (commented out in legacy) and its summary shows only OnHand / Incoming / InTransit. Its per-product grouping key is `Principal//ItemName` rather than the item's NetSuite internal id.
- **Print SJ** (menu **447**, `listnetsuiteprintsj.php`) — filter NetSuite's item fulfilments (delivery notes) by creation date range, customer, one-or-more locations, and delivery date range; get a checkbox list of Surat Jalan; then print the checked ones — or all of them — into a single multi-page PDF on the company's Surat Jalan form. **Rows already printed once are shown in red.** Each print writes one audit row per SJ into our `printeditemfulfillment` table.

**The other twelve children of menu 355 (documented, NOT in scope — no legacy files received):** Booking Order (411), List Customer (356), Surat Jalan with Price (357), Surat Jalan (358), Sales Simple (359), AR (361), AR Branch (362), Netsuite Report CEO (391), Data Purchase Order (384), Netsuite Print (387), Purchase Order (434), Import VS SO (435). Full table with legacy links in §7. ⚠️ Two of these overlap work the app **already has**: the AR screens are substantially served by `CustomerArController` + `NetSuiteArClient` + `CustomerArListService`/`CustomerArPendingService` under the Companies module, and a Booking Barang / List Customer / Accounts Receivable **proto trio already exists** (`Pages/Proto/Netsuite/{BookingBarang,ListCustomer,AccountsReceivable}.jsx`). Reconcile before ever scheduling them — do not build a second AR panel (§9.11).

---

## 3. Scope

### 3.1 In Scope — Phase 1 (SHIPPED 2026-08-03)

1. `Netsuite Integration PRD.md` — this document.
2. `README.md` — the session-onboarding file (hard rules, read-order, done/ongoing, checklists).

That is the whole of Phase 1. **No code.**

### 3.2 Explicitly NOT in Phase 1

- **No pages.** `Pages/MenuNetsuiteIntegration/` holds only the git-ignored legacy folder and these two `.md` files. Nothing is renderable.
- **No routes, no `NetsuiteStockController`/`NetsuitePrintSjController`, no FormRequests, no Policy, no tests, no menu `LinkLaravel` values, no `rolemenu` rows, no sidebar entry.**
- **No `bash nodebuild.sh`.** Nothing under `Pages/**` changed, so no rebuild is warranted. (Markdown is not a build input.)
- **No changes** to `routes/web.php`, `Sidebar.jsx`, `PrototypeController`, `config/database.php`, `.claude/rules/Netsuite.md`, or anything under `app/Services/NetSuite/` — several of those are actively owned by other sessions right now (§5.6).

### 3.3 Out of Scope until explicitly scheduled

The twelve sibling screens of menu 355 listed in §2.2 and §7; the whole of menu root **392** "Netsuite Purchase Order Manual" (child 441 `generatehargajualns.php`, plus the `Proto/NetsuitePO/` trio); any write path back into NetSuite (forbidden outright — §10); and re-pointing the app off `netsuite_2` onto the `netsuite` entity connection (a per-function translation job, tracked separately — `.claude/rules/Netsuite.md` § "PENDING").

---

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

- All routes go inside the `auth` middleware group.
- Authorization funnels through `User::canAccessMenuLink('<LinkLaravel>')` — an active `rolemenu` row for the user's RoleID joined to an active, `IsShow=1` menu row whose `LinkLaravel` matches exactly. **No admin bypass exists in this app**; Administrator works only through real grant rows.
- These three screens have **no per-record ownership or scope** — every grantee sees the same NetSuite data for whatever they type into the pickers. Per CLAUDE.md ATURAN #6(c), that means a **plain `abort_unless($request->user()?->canAccessMenuLink('/…'), 403)` is sufficient — a Policy is not required.** ⚠️ One qualification: Print SJ's *default* Location pre-selection is derived from the actor's own `branch.NSInternalLocationID` (§6.3), which is a personalisation, not a security boundary — a user can change the filter. Whether Location should in fact be *enforced* to the user's branch is **Open Question §9.6**; if the user says yes, that turns into per-actor scoping and the gate must enforce it server-side, not in the picker.
- **Current legacy grants — verified 2026-08-03:** menus **355, 360, 442 and 447 are each granted to exactly one role: `1` Administrator.** No other role holds any of them. So the port's default audience is Administrator only, and widening it is a **user decision** (§9.5) — the legacy grant table is the evidence, not a guess.
- New `LinkLaravel` values must be allocated at the wiring pass (LANGKAH 0 of the `new-feature` skill: *"Menu ID berapa untuk akses fitur ini?"*). The existing rows 360/442/447 already exist with the legacy `Link` filled and `LinkLaravel` **blank** — so the wiring pass **fills those rows in**, exactly as `StockSampleMenuSeeder` did for parent 21; it does not create new leaves. Proposed values (confirm with the user): `/netsuite/stock-barang`, `/netsuite/stock-barang-logistik`, `/netsuite/print-sj`.

---

## 5. Data Model — VERIFIED 2026-08-03

This module straddles **two databases**. Keep them straight: one is ours and writable, the other is NetSuite's and read-only.

### 5.1 App DB — `printeditemfulfillment` (the only table this module writes)

Model **`App\Models\PrintedItemFulfillment`** already exists (`app/Models/PrintedItemFulfillment.php`) and is **referenced by no controller, route or test** — it was generated ahead of the feature. Verified attribute config: `#[Table(name: 'printeditemfulfillment', key: 'ID', keyType: 'int', incrementing: true, timestamps: false)]`, `#[Fillable('Tanggal', 'UserIDInput', 'SJInternalID', 'SJID')]`, `#[ScopedBy(NotDeletedScope::class)]`, `creating` hook defaulting `IsDeleted` to 0, `softDelete()`/`restore()`, `userIDInput()` belongsTo. **It is correct as generated** — no fix needed.

| Column | Type | Null | Notes |
|---|---|---|---|
| `ID` | int | NO | PK, **`AUTO_INCREMENT`** — next value **1100** |
| `IsDeleted` | int | NO | **no DB default** — every insert must set it (the model's `creating` hook does) |
| `Tanggal` | datetime | NO | **no DB default** — print timestamp, legacy sets Asia/Jakarta `now()` |
| `UserIDInput` | int | YES | FK → `users.ID`, **`ON DELETE/UPDATE RESTRICT`** — the one real FK on this table |
| `SJInternalID` | int | NO | NetSuite item-fulfilment `internalid` — the join key back to `getsjlist`/`getprintsj` |
| `SJID` | varchar(200) | NO | NetSuite `tranid` — the human SJ document number, denormalised |

`ENGINE=InnoDB DEFAULT CHARSET=latin1`. **Live data: 1,099 rows, all active (`IsDeleted=0`), MAX(ID) 1,099.** So this table has real production history — the "already printed" red highlighting has meaningful data behind it from day one, unlike the NetSuite side.

### 5.2 App DB — the three columns that bridge to NetSuite

| Column | Type | Verified state | Used by |
|---|---|---|---|
| `branch.NSInternalLocationID` | varchar(255) NOT NULL | 9 branches; **3 hold a blank string** | Print SJ — the actor's branch supplies the **default** Location pre-selection. It is a **comma-separated list** of NetSuite location internal ids, so a branch can map to several NS locations. A blank one means "no default" — the port must handle that, not crash on `explode('')`. |
| `principal.PrincipalNSBrand` | varchar(100) NOT NULL | 126 principals; **only 12 non-blank** | Stock Barang deep-link mode — matched against NetSuite `manufacturer` |
| `principal.PrincipalNSCode` | varchar(100) NOT NULL | 126 principals; **only 9 non-blank** | Stock Barang deep-link fallback — matched against the vendor's `entityid`. One live row holds a comma-joined `'V-00046,V-00047'` (see `BudgetAndTargetCreateController::resolvePrincipalId()`, which uses `FIND_IN_SET` for exactly this reason). |

⚠️ **114 of 126 principals have neither NS field filled.** The Stock Barang deep-link entry point (§6.1) therefore resolves to *no filter at all* for most principals, which in legacy silently means "the whole saved search". Any port must decide what an unmapped principal does — **Open Question §9.4**.

### 5.3 NetSuite side — the eleven `ccins-function` tables these screens read

Connection `netsuite_2` → database `ccins-function` on container `cci-mysql-3`. One table per legacy saved-search function; **columns are that function's result fields, named exactly as NetSuite names them** — that naming is the contract. Reached only through `NetSuiteClient::search($table, $name, $operator, $values, $join)`.

| Table | Legacy fn (saved search) | Rows (real `COUNT(*)`, 2026-08-03) | Already wrapped by | Used by |
|---|---|---|---|---|
| `getprincipal` | `getprincipal()` (customsearch145) | **88** | `NetSuiteItemClient::getprincipal()` | Principal picker — both stock screens |
| `getbarang` | `getbarang()` (customsearch288) | **88** | `NetSuiteItemClient::getbarang()` | Product picker — both stock screens |
| `getdatastock` | `getdatastock()` (customsearch143) | 0 | `NetSuitePrincipalClient::getdatastock()` | Lot-level stock — both stock screens |
| `getbookingorder` | `getbookingorder()` (customsearch146) | 0 | `NetSuitePrincipalClient::getbookingorder()` | Order section — Stock Barang only |
| `getstockincoming` | `getstockincoming()` (customsearch190) | 0 | `NetSuitePrincipalClient::getstockincoming()` | Incoming section — both |
| `getstockintransit` | `getstockintransit()` (customsearch302) | 0 | `NetSuitePrincipalClient::getstockintransit()` | In-Transit section — both |
| `getitemwarehouselist` | `getitemwarehouselist()` | 0 | **none — client method must be added** | Warehouse picker — Logistik only |
| `getcustomerinternalid` | `getcustomerinternalid()` | 0 | **none — client method must be added** | Company picker — Print SJ |
| `getmasterwarehouse` | `getmasterwarehouse()` | 0 | **none — client method must be added** | Location picker — Print SJ |
| `getsjlist` | `getsjlist()` | 0 | **none — client method must be added** | SJ list — Print SJ |
| `getprintsj` | `getprintsj()` | 0 | **none — client method must be added** | SJ + lines for the PDF — Print SJ |

**Six of the eleven are already wrapped**, so Stock Barang and Logistik need **no new client methods at all** except the warehouse list. Print SJ needs four new methods. Add them to the existing clients following their exact shape — the legacy 4-argument signature `(string $name, string $operator, string $values, string $join)` with an in-comment record of the legacy saved-search id — **do not invent a new client class**, and note that the saved-search ids for the five unwrapped functions are **not recoverable from the files present** (§9.9).

### 5.4 Field contracts — the columns each screen actually reads

Verified column lists (2026-08-03). Fields the legacy screens read are marked ✱.

- **`getprincipal`** (10 cols): ✱`manufacturer`, `salesdescription`, `purchasedescription`, `vendor`, `formulatext`, `internalid`, `custentity_ven_nameori`, `quantityonhand`, `custitem_ps_def_pack_content`, `custitem_ps_item_id`.
  The Principal dropdown is built from **`manufacturer` alone, upper-cased and de-duplicated in row order** — so its option *value* is the brand string, not an id.
- **`getbarang`** (11 cols): `salesdescription`✱, `purchasedescription`, `manufacturer`✱, `custitem_ps_item_id`, `entityid`, `altname`, `custentity_ven_nameori`, `country`, `custitem_ps_def_pack_content`, `internalid`✱, `internalid_1`.
  The Product dropdown option is `internalid` → `salesdescription`, and the client-side Principal→Product cascade filters the **whole** `getbarang` payload by **case-insensitive `manufacturer` string equality** against the chosen Principal's label.
- **`getdatastock`** (25 cols) — the lot grid and the OnHand maths: `internalid`, `islotitem`, `isserialitem`, `vendor`, `manufacturer`, `itemid`, `salesdescription`, `location`, `inventorynumber`, `formulatext`, `memo`, `custitemnumber_ps_pack`, `custitemnumber_ps_pack_content`, `custitem_ps_def_pack_content`, `custitem_ps_def_pack`, `stockunit`, `quantityonhand`, `quantityonhand_1`, `formulanumeric`, `quantityavailable`, `quantityavailable_1`, `quantitycommitted`, `totalquantityonhand`, `purchaseunit`, `expirationdate`.
  ⚠️ Three field-meaning traps live here: **`formulatext` is the warehouse name for non-lot items** (the row shows `location` when the item is lot- or serial-tracked, `formulatext` when it is not); **`formulanumeric` is the quantity for non-lot items** (lot items use `quantityonhand`); and **`purchaseunit` is the unit label printed in every column header** — the screens are unit-agnostic and default to `"kg"` only until the first row arrives.
- **`getbookingorder`** (17 cols) — the Order section: `custbody_transaction_category`✱ (the order *type*), `tranid`, `trandate`✱, `custbody_cc_esttimeofdel`✱ (ETD), `statusref`, `companyname`✱, `entityid`, `salesrep`✱, `internalid`✱, `custcol_item_brand`, `manufacturer`✱, `salesdescription`✱, `quantity`, `formulanumeric`✱ (the quantity actually used), `quantityshiprecv`, `unit`, `vendor`.
  ⚠️ **`custbody_transaction_category` drives the summary split**: the literal strings `"Normal Sales"` and `"Booking Order"` are what the Order and Booking columns sum. Anything else is dropped from both. Those two strings are load-bearing — do not normalise or translate them.
- **`getstockincoming`** (11 cols): `tranid`, `trandate`✱ (PO date), `altname`, `internalid`✱, `custcol_item_brand`✱ (the principal here — **not** `manufacturer`), `salesdescription`✱, `quantity`✱, `quantityshiprecv`✱ (received), `formulanumeric`✱ (**pending** = the number the summary's Incoming column sums), `custbody_cc_esttimeofdel`✱ (ETD), `duedate`✱ (ETA).
- **`getstockintransit`** (16 cols): `formulatext`✱, `formulatext_1`, `type`, `trandate`✱, `tranid`✱, `statusref`✱, `location`✱ (from), `transferlocation`✱ (to), `manufacturer`✱, `salesdescription`✱, `custcol_ps_pack_qty`✱, `quantity`✱, `quantityshiprecv`✱, `formulanumeric`✱ (unreceived), `internalid`✱, `internalid_1`✱.
  ⚠️ **This table is header+detail flattened into one result set, discriminated by `formulatext` holding the literal `"Header"` or `"Details"`.** Header rows carry the transfer document (date, status, from/to); Details rows carry the item lines, and `internalid_1` on a Details row is the **item's** internal id while `internalid` is the transfer's. The legacy screens exploit this with a **two-pass query**: first fetch to collect the transfer ids, then re-query by those ids with **no item filter** so the Header rows come back too, and finally filter Details client-side. A port must reproduce the two passes or it gets details with no headers.
- **`getitemwarehouselist`** (1 col): `location`. See trap §5.6 #6 — the legacy consumer expects `location`, `location_1`, `location_2`, … and this table has exactly one column.
- **`getcustomerinternalid`** (3 cols): `entityid`, `altname`✱ (the display name), `internalid`✱.
- **`getmasterwarehouse`** (3 cols): `name`✱, `custrecord7`, `internalid`✱.
- **`getsjlist`** (8 cols): `date`✱ (record creation date, from the system-notes join), `trandate`✱ (delivery date), `tranid`✱ (SJ number), `tranid_1`✱ (created-from doc), `custbody_cc_esttimeofdel`, `entity`✱ (customer **name**), `location`✱ (location **name**), `internalid`✱.
- **`getprintsj`** (24 cols) — everything the PDF prints: `shipaddressee`, `shipaddress1`, `shipcity`, `shipstate`, `shipzip`, `phone`, `trandate`, `tranid`, `tranid_1` (SO no.), `custbody_cc_pono`, `custbody_cc_vehno`, `displayname`, `custcol_sales_name`, `custcol_ps_pack_qty`, `custcol_ps_pack`, `custcol_ps_pack_content`, `quantity`, `quantity_1` (lot qty), `unit`, `formulatext` (lot number), `inventorynumber`, `custbody_qty_in_words`, `internalid`, `internalid_1` (the SJ **line** id).
  ⚠️ Also header+detail flattened: one row per **lot** per line. The PDF collapses them by `internalid_1` — a second row with the same line id appends its lot number to the existing line rather than starting a new one. And the item name falls back: `custcol_sales_name` if non-blank, otherwise `displayname`.

### 5.5 Type coercion — by MEANING, not by declared type

`ccins-function` is a placeholder schema; almost every column is `varchar(255)` regardless of content. `NetSuiteClient` casts by column **name** using four declared maps, and everything else stays a string:

- `INT_COLUMNS`: `internalid`, `internalid_1..3`, `line`.
- `FLOAT_COLUMNS` (39 names) — relevant here: `quantity`, `quantity_1`, `quantity_2`, `quantityavailable(_1)`, `quantitycommitted`, `quantityonhand(_1)`, `quantityshiprecv`, `totalquantityonhand`, `formulanumeric(_1..4)`, `custcol_ps_pack_qty`, `rate`, `amount`.
- `DATE_COLUMNS` (12 names) — relevant here: **`date`**, `trandate`, `trandate_1`, `duedate`, `expirationdate`, `custbody_cc_esttimeofdel`. These are **real `DATE` columns** since 2026-07-30, so PDO hands back `Y-m-d` or `null`; the same list is what gets filter values converted from legacy `d-M-Y` before binding.
- `BOOL_COLUMNS`: `islotitem`, `isserialitem` (NetSuite sends `'T'`/`'F'`).

⚠️ Two consequences that have already bitten five consumers:
- **Rows are `stdClass`, not arrays.** `$row['quantity']` is a **fatal** error and `Arr::get($row, 'x', 0)` silently returns the default for every field. Use `$row->field ?? default`.
- **Numeric casts strip non-numeric characters first**, assuming en-US grouping — a thousands separator in a placeholder varchar is exactly where this shows up.
- **Fields NOT in a map stay strings.** For this module that includes `custitemnumber_ps_pack_content` and `custitem_ps_def_pack_content` — the legacy screens `floatval()` them explicitly, and a port must too or the Lot Pack column concatenates instead of computing.

### 5.6 ⚠️ Traps — every one of these was verified in code or in the DB on 2026-08-03

1. **The data is not there.** Nine of eleven tables are empty (§2.1 #2). Build for, and demonstrate against, an empty result set. The screens' own legacy empty-states are specified in §6 — port those, do not invent new copy, and keep "not connected" separate from "no rows".
2. **The operator allowlist will throw on a verbatim port of Stock Barang Logistik.** `NetSuiteClient::assertOperator()` does a **strict, case-sensitive** `in_array` against exactly six operators — `is`, `anyof`, `noneof`, `onorafter`, `onorbefore`, `contains` — and throws `InvalidArgumentException` on anything else, deliberately, so an unknown operator can never degrade into an unfiltered query. The Logistik legacy passes **`'ANYOF'` in upper case** and, for its principal-only branch, **`'startsWith'`**, which is not implemented at all. Both throw. Resolution is a decision, not a guess: lower-case the `anyof`, and for `startsWith` either add the operator to `OPERATORS` + `applyFilter` + `NetSuiteClientTest` (with LIKE-wildcard escaping, as `contains` has) or substitute `contains`. **Open Question §9.2.**
3. **Print SJ's date filter values will throw.** The legacy builds its creation-date window as `"<d-M-Y> 12:00 am"` … `"<d-M-Y> 11:59 pm"` and filters column `date`, which **is** in `DATE_COLUMNS` — so `toIsoDate()` runs, tries `d-M-Y`, `Y-m-d`, `d/m/Y`, `Y-m-d H:i:s`, matches none of them, and throws. The port must strip the time-of-day before binding. And note the window **cannot** be expressed anyway: `date` is a real `DATE` column now, so a same-day from/to window is whole-day-inclusive regardless. Say so in the UI rather than pretending to a time precision that does not exist.
4. **Print SJ's Company filter is a JOIN filter with nowhere to land — and it fails SILENTLY.** Legacy filters the customer as `internalid` **on the joined `customer` record**. `$join` is accepted and **ignored** by our client (user decision 2026-07-30 — a flat MySQL table has nothing to join to). `getsjlist` has **no customer-id column**; its only customer field is `entity`, the customer **name**. But it *does* have its own `internalid` — the delivery note's. So a verbatim port filters `getsjlist.internalid IN (<customer internalid>)`: a valid column, a bound value, no exception, and **a completely wrong answer**. This is the worst failure mode in the module. Per `.claude/rules/Netsuite.md`, when a legacy join-filter has no landing column you **define the mapping with the user; you do not guess it**. The available mapping is name-based: `getcustomerinternalid.altname` → `getsjlist.entity`. **Open Question §9.1 — blocking for Print SJ.**
5. **Print SJ's Location filter has the same shape of bug.** Legacy filters `location` with NetSuite location **internal ids** (from `getmasterwarehouse.internalid`, and from the comma-separated `branch.NSInternalLocationID`). `getsjlist.location` holds the location **name**. `anyof` on ids against names matches nothing — a silent empty list rather than a silent wrong list, but still wrong. Same resolution path: map through `getmasterwarehouse` (`internalid` ↔ `name`) with the user's confirmation. **Open Question §9.1.**
6. **The Logistik Warehouse picker is built on two assumptions that are both false here.** (a) It walks the result rows and expects row *N* to carry a column literally named `location_N` — `getitemwarehouselist` has exactly **one** column, `location`. (b) It converts each warehouse **name** to an id through a **hard-coded 20-branch name→number chain** (`"Jabodetabek"`→1, `"Dadap"`→2, … `"Bali"`→20) with **no fallback**: an unlisted name reuses the previous iteration's id, silently mis-tagging the option. Neither survives a port. Use `getmasterwarehouse` (which has a real `internalid`) instead, or seed the mapping as data — **Open Question §9.3.** Do not transcribe the hard-coded chain into JavaScript.
7. **`printeditemfulfillment` insert hygiene.** `IsDeleted` and `Tanggal` are NOT NULL with **no default**, so both must be set explicitly (the model's `creating` hook covers `IsDeleted`). `UserIDInput` carries a real `RESTRICT` FK to `users` — memory `users-branch-strict-mode-fillers` and `dev-db-fk-drift` both apply: **`0` is rejected, normalise "none" to `NULL`**. The table is `latin1`, so a non-latin1 character in `SJID` cannot be stored. And under `STRICT_TRANS_TABLES` a missing NOT-NULL column aborts the insert rather than defaulting it.
8. **The legacy print-audit write has a latent bug — do not replicate it.** The PDF script loops the requested SJ ids and, for each, filters the NetSuite result set down to that id. The audit `INSERT` is then built from loop variables that are only assigned *inside* the inner detail loop. If a requested id returns **no NetSuite rows**, those variables still hold the **previous** SJ's values, and the script writes an audit row attributing the print to the wrong document. The port must derive the audit row from the id it was asked to print, and must skip ids that returned nothing.
9. **The legacy print-audit write is also fail-silent.** It wraps the insert in a transaction and swallows any exception with a bare rollback — the PDF is produced either way and the user is never told the audit row was lost. Our convention is the opposite: a failed write is reported (toast + inline error, single channel per `.claude/rules/notifications.md`). Decide deliberately whether the audit row is best-effort or required, and say so in the UI. **Open Question §9.7.**
10. **The PDF must be re-implemented, not ported.** Legacy renders with **FPDF** in **cm** units on a custom **21.5 × 30.48 cm** page, hand-positioning cells, and injects PDF JavaScript to pop the print dialog automatically. In this app, server-side PDF generation is **forbidden** — no dompdf, no Puppeteer, no wkhtmltopdf, no Gotenberg, and no server endpoint may return `application/pdf`. All PDFs are generated 100% in the browser with `@react-pdf/renderer`, **lazily imported inside the handler** (`await import(...)`) because a top-level import crashes Inertia SSR. For an FPDF port specifically, memory `pdf-ports-fpdf-to-react-pdf` applies: use the shared **`resources/js/lib/pdf/fpdfShared.jsx`** (centimetre-based) helpers, **not** `MenuSampleOrders`' `pdfShared` (which is dompdf-pixel-based); font sizes stay in points. Invoke the **`pdf-document` skill** before writing any of it — CLAUDE.md hard rule. The auto-print dialog has no react-pdf equivalent and is a UX decision (§9.8).
11. **The "already printed" highlight is a join across the two databases.** Legacy reads every row of `printeditemfulfillment` into memory and colours a SJ row red when its `internalid` appears there. Cross-connection, so it cannot be a SQL join: fetch the NetSuite list, then look up the (small, 1,099-row) local set — but scope the lookup to the ids actually on screen rather than loading the whole table as legacy does.
12. **Summary arithmetic is not obvious — get it from §6.1, not from intuition.** OnHand **excludes** expired lots and expired quantity is surfaced separately as a red `(+x)`; Available = OnHand − *Normal Sales*; Available−Booking = Available − *Booking Order*; Incoming sums **pending** (`formulanumeric`), not ordered; InTransit sums the **absolute value** of unreceived. The two stock screens also **group differently** — Stock Barang keys its summary on the item's NetSuite `internalid`, Logistik keys on the string `manufacturer//salesdescription`. Legacy's Logistik grouping also resets its running total whenever that composite key changes between rows, which makes the result **row-order-dependent**; do not carry that over — accumulate per key.
13. **Both stock screens are `<iframe>` shells.** The outer page is a heading plus a "please wait" block plus an iframe; the inner frame renders the pickers and issues a raw `XMLHttpRequest` that replaces a `<div>` with a **server-rendered HTML table string**. None of that structure survives into Inertia — the port is one page with props and a partial reload / `useHttp` fetch. Nothing here is a design to preserve; only the *fields, filters, sections, columns and arithmetic* are.
14. **The legacy shells disable copy, cut, paste and right-click** (`oncopy`/`oncut`/`onpaste`/`oncontextmenu` returning false). That is a deliberate posture about stock and pricing data, not an accident. Replicating it is a **user decision** (§9.10) — note it is trivially bypassed and would be a first for this app's React pages.
15. **These searches were slow.** Every legacy file sets `max_execution_time=0` and `memory_limit=-1`, and each prints its own execution time to the page. Against the DB transport they will be fast, but the port must still not fetch unbounded result sets into memory — and `.claude/rules/list-pagination.md` (ATURAN #26) requires server-side pagination for any paginated list. Note the legacy Print SJ list is **not** paginated: its pagination code is entirely commented out and it renders every match inside a fixed-height scroll box. **Open Question §9.12.**

---

## 6. Legacy Screen Inventory (verbatim behaviour, from the received sources)

> Behaviour below is summarised from the nine files in `Just An Example, Do not Link/` and cited by filename. Per Aturan Absolut #0, no legacy code is reproduced.

### 6.1 Stock Barang — Netsuite Integration (menu 360)

**Files:** `listnetsuitestockbarang.php` (shell) · `listnetsuitestockbarangframe.php` (pickers) · `getdatastocknetsuite.php` (the data + HTML).

**Inputs.** Two dropdowns, both marked required in the label: **Principal\*** and **Product Name\***. The Principal list is the de-duplicated, upper-cased set of `manufacturer` from `getprincipal`. The Product list starts as the whole of `getbarang` and is re-filtered client-side whenever Principal changes, by case-insensitive `manufacturer` match; choosing the "Select Principal" placeholder restores the full list. A **Check Stock** button submits.

**Validation.** Product is what is actually enforced — with no product chosen the screen alerts (`"Harap Pilih Product"`, or `"Harap Pilih Vendor dan Product"` when neither is set) and clears the results. Principal alone is never sufficient.

**Deep-link mode.** The screen also accepts a numeric **`PrincipalID`** query parameter — the entry point from the Principal module. In that mode it looks the principal up in our `principal` table and derives a NetSuite filter from it: `manufacturer is <PrincipalNSBrand>` if the brand is set, else the vendor's `entityid is <PrincipalNSCode>`; it hides the "Select Principal" placeholder and **auto-runs the check on load**. ⚠️ But the principal-only data queries it would then need are **commented out in the legacy source** — so this mode reaches the data layer with no product and returns nothing. It is a half-finished feature, not a working shortcut (§9.4).

**Output — four sections plus a summary, in this on-page order:**

1. **Stock Summary** (`getdatastock` + `getbookingorder` + `getstockincoming` + `getstockintransit`, one row per item internal id): Principal · Item Name · OnHand · Order · **Available** · Booking · **Available−Booking** · Incoming · InTransit, every quantity in the item's `purchaseunit`. OnHand and Available and Available−Booking each append a red `(+expired)`; Available−Booking goes red when negative. Footnote: *"Quantity yang berwarna merah adalah quantity Expired"*.
2. **Details Stock Barang** (`getdatastock`, filtered `internalid anyof <product>`): Gudang · Principal · Item Name (item code over sales description) · Lot Number · Lot Pack · Lot Memo · Exp Date · Qty. A row is included when the item is neither lot- nor serial-tracked, **or** when it is and has a non-empty, non-zero on-hand. Expired rows (expiry earlier than today, blank expiry excluded) render in red. Empty-state when the query returns rows but none qualify: *"No Lot Number Details Data"*; empty-state when the query returns nothing: *"Result Not Available, Empty Stock or Incorrect Vendor Name / Product Name"*.
3. **Details Stock Incoming** (`getstockincoming`, `internalid anyof <product>` joined on item): Date · Principal · Item Name · ETD · ETA · Qty · Received · Pending. Empty-state *"No Stock Incoming..."*.
4. **Details Stock In Transit** (`getstockintransit`, two-pass as described in §5.4): Date · From · To · Principal · Item Name · Qty · Received · Pending. Empty-state *"No Stock is In Transit..."*.
5. **Details Order Barang** (`getbookingorder`): Type · Date · Principal · Item Name · Exp Date · ETD · *Customer Name (rendered but hidden)* · Sales Name · Qty. "Exp Date" here is **derived, not fetched** — it is the order date plus one month — and the whole row turns red once that date has passed. Empty-state *"No Order Data..."*.

**Proto parity.** `Proto/Netsuite/StockBarang.jsx` already implements this as a summary strip plus tabbed Lots / In Transit / Order tables, and its console assertions already assert the §5.6 #12 arithmetic (order = Σ Normal Sales; booking = Σ Booking Order; onHand = Σ lot qty − Σ expired lot qty). It has no Incoming tab and no Gudang-less fallback — reconcile at the port.

### 6.2 Stock Barang Logistik — Netsuite Integration (menu 442)

**Files:** `listnetsuitestockbaranglogistic.php` (shell) · `listnetsuitestockbaranglogisticframe.php` (pickers) · `getdatastocknetsuitelogistic.php` (data + HTML).

Same skeleton as §6.1, with these deliberate differences:

- **A third picker, Warehouse**, listed first and sourced from `getitemwarehouselist` — subject to trap §5.6 #6.
- **Looser validation:** the alert fires only when **all three** are unset (*"Harap Pilih Vendor, Product, Atau Warehouse"*). Any single filter is enough, and with none of them set the underlying queries run **unfiltered**.
- **No Order section.** The `getbookingorder` block is commented out wholesale, and the summary correspondingly drops Order / Available / Booking / Available−Booking, keeping **Principal · Item Name · OnHand · Incoming · InTransit**.
- **Different grouping key** — `manufacturer//salesdescription` instead of the item internal id — with the order-dependent accumulation described in §5.6 #12.
- **Warehouse is applied to two different queries in two different ways:** to the stock query as a filter on the lot's `location` (through an `inventorynumber` join), and to the in-transit query as a filter on `transferlocation` (destination). It is **not** applied to the incoming query at all.
- **A distinct empty-state:** when a product was chosen but the summary came back empty, it prints a single zero-filled summary row for that item rather than a "no data" message — so the user sees *"this item exists and has nothing"* rather than *"nothing matched"*. Worth preserving; it is the more useful answer.

### 6.3 Print SJ (menu 447)

**Files:** `listnetsuiteprintsj.php` (filters + actions) · `listnetsuiteprintsjview.php` (the list) · `printgrnfromns.php` (the PDF + the audit write).

**Filters** (all optional individually, but at least one is required — the form blocks submit with *"Silahkan Isi Salah Satu Filter diatas"*): Creation Start Date · Creation End Date · **Company** (single-select, from `getcustomerinternalid`, displaying `altname`) · **Location** (**multi**-select, from `getmasterwarehouse`, displaying `name`) · Delivery Start Date · Delivery End Date. Dates are `YYYY-MM-DD` in the UI.

**Defaults on first load** (when no filter at all has been submitted): Creation Start Date = **today**, and Location = the NetSuite locations listed in the **actor's own branch** (`branch.NSInternalLocationID`, comma-separated, joined via `users.BranchID`). Once any filter is present, neither default is re-applied.

**The window.** An absent Creation End Date does not mean "open-ended" — legacy substitutes **31 December of next year**. Delivery dates map to `trandate` `onorafter` / `onorbefore`.

**List** — `getsjlist`, rendered inside a fixed-height scroll box, **unpaginated** (§5.6 #15): No. · **Print** (checkbox) · Date · **Doc. Number** (a direct link that opens that one SJ's PDF) · Delivery Date · Created From · Name · Location. **Rows whose `internalid` already appears in `printeditemfulfillment` render in red** — the "already printed" signal (§5.6 #11).

**Actions:** *Check All* (toggle), *Print SJ* (the checked rows), *Print All SJ* (every row currently listed, regardless of checkbox). Both print actions open the PDF in a popup window and leave the list where it was.

**The PDF** — one page-set per SJ, all requested SJs concatenated into a single document titled `SuratJalan_<ids>.pdf`, on a 21.5 × 30.48 cm page with the company logo:
- **Header block:** ship-to addressee, address, city/state/zip (omitted entirely when all three are blank), phone — beside a label block of SJ No. · Date · SO No. · PO No. · Vehicle No.
- **Line table:** NO · ITEM DESCRIPTION · PACKING · QUANTITY · LOT NO. Packing renders as *pack-qty × pack-content unit*; multiple lots on one line are concatenated into that line's LOT NO cell as `lot(qty)` pairs. Item description falls back from `custcol_sales_name` to `displayname`.
- **Padding to a fixed grid:** empty bordered rows are emitted up to a line budget (19 lines, extended as wrapped descriptions consume extra rows) so the form always looks the same height, with a page-break budget of 28.
- **Totals row** (pack qty and qty), then the NetSuite-supplied quantity-in-words.
- **Four signature boxes:** Customer *(Nama & Stempel)* · Driver · Acknowledge By *(W.H Head)* · Prepared By *(Nama & Stempel)*.
- **Notes footer:** the print timestamp, *"Goods received in good conditions and full quantity"*, *"Any Claim on the above shoould be made within 7 Days after DN date"* — reproduce the typo or fix it, but **ask**; it is on every printed form in the field today.
- **Auto-print:** the PDF carries embedded JavaScript that opens the print dialog on open (§9.8).

**The write:** one `printeditemfulfillment` row per SJ per print — `IsDeleted=0`, `Tanggal` = Asia/Jakarta now, `UserIDInput` = the actor, `SJInternalID` = the NetSuite internal id, `SJID` = the `tranid`. Reprinting the same SJ inserts **another** row; the table is an append-only print log, not a flag. Subject to traps §5.6 #7, #8 and #9.

---

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

Root **355 "Netsuite Integration"** (`ParentID` NULL, `SortNo` 0, `IsShow` 1, `IsDeleted` 0) — all children active:

| ID | ShowName | Legacy Link | Sort | Legacy files here? |
|---|---|---|---|---|
| **360** | Stock Barang - Netsuite Integration | `listnetsuitestockbarang.php` | 0 | ✅ **3 files** |
| **442** | Stock Barang Logistik - Netsuite Integration | `listnetsuitestockbaranglogistic.php` | 1 | ✅ **3 files** |
| 411 | Booking Order - Netsuite Integration | `listnetsuitebooking.php` | 2 | ❌ |
| 356 | List Customer - Netsuite | `listnetsuitecustomer.php` | 3 | ❌ |
| 357 | Surat Jalan with Price - Netsuite Integration | `listnetsuitesuratjalanwithprice.php` | 4 | ❌ |
| 358 | Surat Jalan - Netsuite Integration | `listnetsuitesuratjalan.php` | 5 | ❌ |
| 359 | Sales Simple - Netsuite Integration | `listnetsuitesalessimple.php` | 6 | ❌ |
| 361 | AR - Netsuite Integration | `listnetsuitear.php` | 7 | ❌ (but see §9.11) |
| 362 | AR Branch - Netsuite Integration | `listnetsuitearbranch.php` | 8 | ❌ (see §9.11) |
| 391 | Netsuite Report - CEO | `nsreportallceo.php` | 9 | ❌ |
| 384 | Data Purchase Order - Netsuite Integration | `listnetsuitedatapurchaseorder.php` | 10 | ❌ |
| 387 | Netsuite Print | `datapoprint.php` | 11 | ❌ |
| 434 | Purchase Order - Netsuite Integration | `listnetsuitepurchaseorder.php` | 12 | ❌ |
| 435 | Import VS SO - Netsuite Integration | `nsimportvssoheader.php` | 13 | ❌ |
| **447** | Print SJ | `listnetsuiteprintsj.php` | 14 | ✅ **3 files** |

Separate root **392 "Netsuite Purchase Order Manual"** (`SortNo` 0, `IsShow` 1) with one child: **441** "Purchase Order - Generate Harga Jual Netsuite" (`generatehargajualns.php`, Sort 7). Out of scope (§3.3).

**`LinkLaravel` is blank on every row above** — nothing in this group has been ported yet. **`rolemenu` grants: 355, 360, 442 and 447 are each granted to RoleID 1 (Administrator) only** (§4).

**Legacy files RECEIVED (2026-07-31, in `Just An Example, Do not Link/`, never committed) — nine files in three folders:**
- `Stock Barang - Netsuite Integration/`: `listnetsuitestockbarang.php`, `listnetsuitestockbarangframe.php`, `getdatastocknetsuite.php`
- `Stock Barang Logistik - Netsuite Integration/`: `listnetsuitestockbaranglogistic.php`, `listnetsuitestockbaranglogisticframe.php`, `getdatastocknetsuitelogistic.php`
- `Print SJ - Netsuite Integration/`: `listnetsuiteprintsj.php`, `listnetsuiteprintsjview.php`, `printgrnfromns.php`

**Referenced but ABSENT** (request by name if a pass needs them; never guess their behaviour): `RestletPHPToolkit/netsuitegeneratejson.php` (the legacy saved-search toolkit — the source of every function's saved-search id, §9.9) · `header.php`, `dbconnect.php`, `dbhandler.php`, `sessionmanager.php` (app plumbing, not needed) · `pleasewaitnetsuite.php` (the loading block) · `pdf_js.php` + the `fpdf/` tree + `nsprintlogo.png` (the PDF engine and logo — the logo asset **will** be needed, §9.8) · `getprincipalfrombarangns.php` (a reverse Product→Principal cascade, commented out in both stock screens).

---

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

| Phase | Content | Gate / blocked on |
|---|---|---|
| **1 ✓** | This PRD + `README.md`. Docs only. | ✅ **SHIPPED 2026-08-03** |
| **2** | **Stock Barang read.** `StockBarangService` + `NetsuiteStockController` + `NetsuiteStockBarangRequest` + `GET /netsuite/stock-barang` + `NetsuiteMenuSeeder` (row 360 `LinkLaravel`) + `StockBarang/Index.jsx` ported from the proto + `NetsuiteStockBarangTest`. | ⚠️ **Code complete, 15 tests green, Pint clean (2026-08-03) — but NOT PUBLISHED**: the rule-7 pre-build sweep was dirty (foreign hot files), so `nodebuild.sh` has not run and the page is unreachable in the app. Decisions taken: pickers filtered client-side (88 rows, legacy parity); sidebar is DB-driven so no `Sidebar.jsx` edit; audience left at Administrator pending §9.5. |
| **3** | **Stock Barang Logistik.** Warehouse dimension + the looser validation + the reduced summary. | **Blocked on §9.2 (operator) and §9.3 (warehouse mapping)** — both must be decided before any code |
| **4** | **Print SJ — list only.** Filters, `getsjlist`, the already-printed highlight, server-side pagination. New client methods for `getsjlist`, `getcustomerinternalid`, `getmasterwarehouse`. | **Blocked on §9.1 (customer + location filter mapping)** — a wrong answer here is silent |
| **5** | **Print SJ — PDF + audit write.** react-pdf via `lib/pdf/fpdfShared.jsx`, `printeditemfulfillment` insert, single/checked/all print paths. | Needs Phase 4, the `pdf-document` skill, the logo asset, and §9.7/§9.8 decided |
| **6+** | The twelve sibling screens (§7) and menu root 392. | Legacy files not received; **AR must be reconciled against the existing Companies-module implementation first (§9.11)** |

Per CLAUDE.md's issue-tracking rule: at the end of each phase, file GitHub issues (repo `ColorindoChemtra/ColorindoChemtraInertia63`, `gh` CLI, title convention `feat(netsuite): …`) for all remaining follow-ups **before** moving on, then write the issue numbers back into §8/§9.

---

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

> ### ✅ STATUS 2026-08-03 — read this before treating anything below as open
> The list below was written before the build. Seven items are now **CLOSED**; the decisions and
> their reasoning are recorded in §1 "Locked decisions (taken during the build)" and are
> test-guarded. Where this block and the numbered list disagree, **this block wins**.
>
> | # | Was | Now |
> |---|---|---|
> | 1 | Print SJ customer + location filters — ⛔ blocking | ✅ resolved by NAME mapping (§1 #4) |
> | 2 | `startsWith` / upper-case `ANYOF` — ⛔ blocking | ✅ `startswith` added to the shared client (§1 #5) |
> | 3 | Logistik warehouse list — ⛔ blocking | ✅ `getmasterwarehouse`, hard-coded chain dropped (§1 #6) |
> | 7 | Print-audit best-effort or required | ✅ written, and a failure is reported (§1 #7) |
> | 8 | Auto-print / logo / typo | ✅ no auto-print, `/img/logo.png`, typo kept (§1 #8) |
> | 9 | Saved-search ids for 5 functions | ✅ all present in `NetSuiteClient::SAVED_SEARCHES` |
> | 12 | Print SJ pagination + "Print All" meaning | ✅ server-paginated; Print All = every match, cap 200 (§1 #9) |
>
> **Genuinely still open, and none of them blocks anything:** **4** (the `PrincipalID` deep-link —
> deliberately not ported), **5** (roles beyond Administrator), **6** (Location default vs enforced),
> **10** (copy/right-click blocking), **11** (the AR overlap), **13** (display naming).

1. **Print SJ Company + Location filters — how do they land on a flat table?** (§5.6 #4, #5). `getsjlist` has neither a customer id nor a location id; a verbatim port filters the wrong column silently. Proposed mapping: resolve `getcustomerinternalid.altname` → match `getsjlist.entity`, and `getmasterwarehouse.internalid` → `name` → match `getsjlist.location`. **Needs the user's confirmation, per `.claude/rules/Netsuite.md`'s standing "define the mapping with the user; do not guess it".** ⛔ **Blocks Phase 4.**
2. **`startsWith` and upper-case `ANYOF`** (§5.6 #2). Add `startswith` to the client's operator set (with LIKE-escaping and a test), or substitute `contains`? ⛔ **Blocks Phase 3.**
3. **Logistik Warehouse list** (§5.6 #6). Drop `getitemwarehouselist` in favour of `getmasterwarehouse`, or keep it and define a real name→id mapping as data? ⛔ **Blocks Phase 3.**
4. **Stock Barang deep-link `PrincipalID` mode** (§6.1). Legacy's principal-only queries are commented out, so the mode currently returns nothing, and 114 of 126 principals have no NS mapping at all. Port it as-is (a no-op), implement what it evidently intended (principal-wide stock), or drop the entry point?
5. **Roles.** Legacy grants all three menus to Administrator only. Keep that, or widen — plausibly Logistik → Logistic Manager/Admin/Gudang, Print SJ → Gudang/Logistic? A user decision; the port must not invent an audience.
6. **Should Print SJ's Location be enforced to the actor's branch, or is it only a default?** (§4). Legacy pre-selects and lets the user change it. If it should be enforced, that becomes real server-side scoping.
7. **Is the print-audit row best-effort or required?** (§5.6 #9). Legacy silently swallows the failure and still prints.
8. **PDF details:** auto-open the print dialog (no direct react-pdf equivalent)? Reproduce the `"shoould"` typo on the notes footer? Where does `nsprintlogo.png` come from — is there an existing logo asset in the repo to reuse?
9. **Saved-search ids for the five unwrapped functions** (`getitemwarehouselist`, `getcustomerinternalid`, `getmasterwarehouse`, `getsjlist`, `getprintsj`). Every existing client method records its `customsearchNNN` in a comment, and those are the customer's real NetSuite account ids — never guess them. They live in the absent `netsuitegeneratejson.php` (§7). Request that file, or accept the methods landing without the id comment.
10. **Copy/paste/right-click blocking** (§5.6 #14) — replicate the legacy posture on these pages, or not?
11. **AR overlap.** Menus 361/362 vs the shipped `CustomerArController` + `NetSuiteArClient` + the `Proto/Netsuite/AccountsReceivable.jsx` proto. Are the legacy AR screens already superseded? Do not schedule them before answering this.
12. **Print SJ pagination.** Legacy is unpaginated. ATURAN #26 requires server-side pagination for paginated lists — confirm page size and that the "Print All SJ" action means *all matches*, not *all on this page* (they differ once pagination exists, and that difference is user-visible).
13. **Display naming.** Sidebar labels: keep the legacy `"Stock Barang - Netsuite Integration"` / `"Print SJ"` verbatim, or shorten now that they sit under a "Netsuite Integration" group?

---

## 10. Binding Conventions for Every Future Pass

- **Invoke `new-feature` AND `inertia-v3-stack` before writing code** — CLAUDE.md hard rule. Anything touching the PDF also invokes **`pdf-document`**; nothing here accepts an upload, so `file-upload` does not apply.
- **Read `.claude/rules/Netsuite.md` in full before touching any NetSuite path.** It is the source of truth for the two connections, the filter DSL, the type maps, the `stdClass` contract and the fixture flags — and it auto-loads only when you touch a matching file, which a planning session may never do.
- **Never ask the user for NetSuite credentials** ([[netsuite-credentials-never-ask]]). Build so every path degrades to an empty grid without them.
- **Read-only across the seam, absolutely.** Nothing may write to the `netsuite` or `netsuite_2` connections. The only write this module performs is `printeditemfulfillment` on our own DB.
- `SHOW CREATE TABLE` before touching any model; PascalCase columns as-is; `IsDeleted` soft delete (never the `SoftDeletes` trait); `NULL` — never `0` — for "none" on an FK; tables are latin1.
- FormRequest validation only (no inline `$request->validate()`), `Rule::exists(...)->where('IsDeleted', 0)` on every app-DB FK, an explicit gate on every action, no axios (Inertia v3 `useHttp`), server-side pagination for lists (ATURAN #26).
- Shared code goes to `app/Support/GeneralFunction.php` / `app/Services/` / `resources/js/lib/` on second use — and new NetSuite functions go into the **existing** clients, not a new class.
- **Ask-first protocol** ([[ask-first-protocol]]): announce shell commands and explain them; unclear scope → ask, never guess. Never read `/opt/lampp` ([[never-read-opt-lampp]]) — legacy comes only from the curated folder.
- **Explain every edit and recommend the next step:** announce intended edits beforehand; report **file → what changed → why** after each chunk; end every report with the recommended next step.
- **Frontend freeze:** an existing frontend file's design is frozen — minimal wiring edits only, and visual changes need approval. A page that does not exist yet is modelled on the equal-function sibling (`Proto/Netsuite/StockBarang.jsx` for the stock screens, `MenuPrintLabel/` for the PDF screen), never on a new design.
- **Ultracode / multi-agent discipline:** the main agent writes ALL code and file edits; subagents are read-only verifiers and run only after the code exists.
- `bash nodebuild.sh` from the **outer git root** `/home/itccj/server/LaravelInertia/` (never `npm run dev`, never host `npm`) after any JSX change once pages are routed, then hard-refresh ([[frontend-build]], [[php-via-docker-cci]]).
- Tests: Pest + `DatabaseTransactions` against the **dev** DB, `User::factory()->makeOne(['ID' => 1])` for `actingAs`, unique `TEST*` prefixes, **never** `migrate:fresh`. ⚠️ `printeditemfulfillment` holds 1,099 real rows — assert on rows you created, **never** on global counts.
- **Shared-state discipline:** ONE git tree / ONE dev DB / ONE build bundle across all parallel sessions. Never destructive git (`reset --hard`, `stash`, `clean`, `checkout -- .`), never a bare `git commit` on the shared index — commit by explicit pathspec after `git diff HEAD -- <file>` per file, and exclude any file carrying a foreign hunk.
- **Handoff:** re-read and **merge** this PRD and the README before the end-of-session status write (never overwrite from a start-of-session copy); record user decisions into §1 "Locked decisions" dated, the same session; `gh issue list` before filing; commit tag `feat(netsuite):`.

## 11. References

- **Rules:** `.claude/rules/Netsuite.md` (mandatory), `db-conventions.md`, `soft-delete.md`, `rolemenu-authz.md`, `list-pagination.md`, `inertia-pages.md`, `notifications.md`, `design-system.md`, `ui-conventions.md`.
- **Reference modules (read-only for this module's work):** `MenuPrintLabel/` + `PrintLabelController` (NetSuite-fed, gated, client-side PDF — the closest sibling to Print SJ) · `MenuVisitPlans/` (the proto-port and PRD/README convention this module copies) · `MenuStockSample/` + `StockSampleController` (the `LinkLaravel`-fill-in seeder pattern for pre-existing legacy menu rows).
- **Proto originals:** `Pages/Proto/Netsuite/{StockBarang,BookingBarang,ListCustomer,AccountsReceivable}.jsx` + `stock-barang.fixture.json`; routes `proto.netsuite.*` (`PrototypeController::netsuite*`), reachable at `/proto/netsuite/*`.
- **NetSuite plumbing:** `app/Services/NetSuite/NetSuiteClient.php` (the seam), `NetSuiteItemClient.php` (pickers), `NetSuitePrincipalClient.php` (stock/order/incoming/in-transit), `Components/NetSuite/DemoDataBadge.jsx`, `config/database.php` connections `netsuite` / `netsuite_2`.
- **App model:** `app/Models/PrintedItemFulfillment.php` (exists, unused).
- **PDF:** `resources/js/lib/pdf/fpdfShared.jsx` (cm-based FPDF port helpers — the right one), skill `pdf-document`.
- **Memories that bind here:** `netsuite-credentials-never-ask`, `pdf-ports-fpdf-to-react-pdf`, `php-via-docker-cci`, `frontend-build`, `dev-db-fk-drift`, `users-branch-strict-mode-fillers`, `ask-first-protocol`, `never-read-opt-lampp`, `parallel-session-broad-commits`, `playwright-screenshots`, `menu-icon-wiring-check`.
