# PRD — Sample Order (Transactional) Module

## 1. Document Metadata

| Field | Value |
|---|---|
| **Title** | PRD — Sample Order (Transactional) Module |
| **Module** | Sample Order (customer sample-request workflow: request → approvals → packing → surat jalan → received → feedback) |
| **Status** | **Phase 1 shipped** (UI shell: 4 pages, sidebar wiring). **Phase 2 SHIPPED 2026-06-11** — read-only `SampleOrderController` (`index`/`show`/`approvalPm`), `SampleOrderPolicy` active, Detail line items extended to legacy parity, 18 Pest tests green. **Phase 3 create/store SHIPPED 2026-06-11** — `sample-orders.create`/`store` + 4 JSON option endpoints, `StoreSampleOrderRequest`, transactional 4-table insert, Create page on `useForm`, 10 more Pest tests. Deferred: links #36, To-Project #37, email #38. **Approval SM SHIPPED 2026-06-12** (§6.7). **Detail capability actions SHIPPED 2026-06-12** — strict PM queue (BR-14) + Cancel / Send-Email / Change-Status with verified stock compensation (§6.8). **Approval PM actions SHIPPED 2026-06-12** — line-grained backend + bulk wiring on the queue page (§6.9). **Packing SHIPPED 2026-06-12** — unscoped status-2 queue + lot assignment/stock deduction/no-lot split, with a minimal dedicated page (§6.10). **Surat Jalan SHIPPED 2026-06-12** — 6→7 with suratjalan/suratjalandetails rows + SJ numbering + manual AWB entry (§6.11; PDFs + courier API deferred). |
| **Owner** | IT — PT Colorindo Chemtra |
| **Date** | 2026-06-10 (Phase 1) · updated 2026-06-11 (Phase 2 + Phase 3 create/store) |
| **Stack** | Laravel 13.8 / Inertia.js v3 / React 19.2 / Tailwind v4 / MySQL 8.4 |
| **Document Type** | Hybrid PRD — documents shipped Phase-1 behavior verbatim **plus** the approved Phase-2 read-integration contract. Phase-2 items are explicitly marked; nothing beyond approved decisions is implied. |

---

## 2. Overview & Purpose

The **Sample Order** module manages customer requests for **product samples**. In the legacy application a sample order moves through a status workflow — Request → Approval (SM/PM) → Packing/Prepare → Surat Jalan → Sample Received → Feedback (with Revise / Reject / Cancel side-exits) — recorded in the existing production tables `sampleorder` (header), `sampleorderdetail` (line items), and `sampleorderassignment` (status/approval history).

**Why it exists:** Sample orders are the second transactional workflow (after Quotations) being ported from the legacy app to the new Laravel/Inertia/React stack. The module gives sales/PM users a modern List / Detail / Approval-queue experience over the same production data.

**Construction lineage (deliberate):**
- **List / Detail / Approval PM** pages are copies of the **Quotation module template** (`Pages/MenuQuotations/Quotations/{List,Detail}.jsx`, `Pages/MenuQuotations/ApprovalPm/Index.jsx`), adapted to sample-order columns and statuses (money/PO concepts removed).
- **Create** page started as a copy of the **NewUI prototype** (`Pages/Proto/SampleOrders/Create.jsx`); since the create pass (2026-06-11) it runs on `useForm`/`useHttp` + server props and JSON endpoints — prototype styling retained (§9.4), mock data gone.
- The planned backend mirrors `QuotationController` (allowlisted sort, search, pagination, presenter methods) and `QuotationPolicy` (rolemenu-driven authorization).

---

## 3. Scope

### 3.1 In Scope — Phase 1 (SHIPPED)

1. **Four Inertia pages** under `Pages/MenuSampleOrders/`: `SampleOrders/List.jsx`, `SampleOrders/Detail.jsx`, `SampleOrders/Create.jsx`, `ApprovalPm/Index.jsx`.
2. **Three render-only routes** (closures, no props, no DB) inside the `auth` group: `sample-orders.index`, `sample-orders.approval-pm`, `sample-orders.show`. Pages mount with their built-in empty-state defaults.
3. **Sidebar wiring (DB-driven nav):** existing legacy root menu `ID=24` "Sample Order" (Sort 10) given `Icon='Package'`; two new leaves created via the Pengelolaan UI — `ID=1299` "Sample Orders" → `/sample-orders` (Sort 0) and `ID=1300` "Approval PM" → `/sample-orders/approval-pm` (Sort 1); `rolemenu` grants for **RoleID 1 (Administrator) only** (rows 1741, 1742).
4. **`SampleOrderPolicy`** (`app/Policies/SampleOrderPolicy.php`) — rolemenu-based, mirrors `QuotationPolicy` with `MENU_LINK = '/sample-orders'`. **Currently dormant** (no shipped code path invokes it; see §4.4).
5. **List "New sample order" button** routed to the existing prototype Create (`proto.sample-orders.create` → `/proto/sample-orders/create`) until a real create route exists.

### 3.2 Phase 2 (SHIPPED 2026-06-11)

- A read-only `SampleOrderController` replaced the three closures: `index` (server-driven List), `show` (Detail presenter), `approvalPm` (PM review queue), each authorizing per action (Policy now ACTIVE). Two decisions locked 2026-06-11: **Sales = the order's own `UserIDSales`** (creation-time snapshot — BR-9) and **Detail line items extended to legacy parity** (BR-10). No write endpoints in Phase 2.

### 3.3 Phase 3 — create/store pass (SHIPPED 2026-06-11)

- Real `sample-orders.create` + `POST sample-orders` replacing the prototype-only Create: `StoreSampleOrderRequest`, transactional insert of `sampleorder` → `sampleorderassignment` → per item `sampleorderdetail` + `sampleorderdetailassignment`, the legacy `SampleOrderLimit` submission gate (BR-13), 4 JSON option endpoints, Create page converted to `useForm`/`useHttp`, List button repointed to the real route. Deferred from this pass: Link-with-Other persistence (#36), To-Project writes (#37), head-division email (#38).

### 3.4 Out of Scope (genuinely absent today)

- **Redesigned per-line PM page** — the PM transitions themselves shipped (§6.9; the queue page's buttons act in bulk on the actor's actable lines); only the dedicated line-grained PM page remains future UI work. The Detail hero's static PM buttons await the same redesign.
- **Edit / Update, Delete, Restore** — no routes, controller methods, or UI flows.
- **Email sending + logistics/booking fields** (`Vendor`, `BookingID`, `AWB`, `Tarif`, …) — `store()` fills them with empty/zero placeholders (strict schema, BR-12); the `IsSendEmail` flag is toggleable from Detail since §6.8 but **no email is ever sent** (#38 — and the dev mailer stays on the `log` driver).
- **Master-data CRUD for sample lookups** — legacy Pengelolaan menu group `ID=129` ("Sample Order" under Pengelolaan, children: Sample Delivery `15`, Sample Order Feedback Status `134`, Sample Order By `142`) exists in the `menu` table but none of those lookup CRUDs are built.
- **Export / import, bulk operations, audit timestamps** — same posture as the Bank reference.

---

## 4. Users & Permissions

### 4.1 Access prerequisite

All three shipped routes live inside the single `Route::middleware('auth')->group(...)` block in `routes/web.php`. **Only authenticated users** can reach them; guests are bounced to `/login`.

### 4.2 Sidebar visibility (rolemenu-driven, shipped)

The nav tree (`Menu::navTreeForRole(RoleID)`) prunes leaves a role is not granted. Today only **Administrator (RoleID 1)** holds grants on menus 1299/1300, so only Administrator sees *Sample Order → Sample Orders / Approval PM* in the sidebar. The group node (root 24) needs no grant — groups render when ≥1 child leaf survives; root 24's twenty legacy children all have blank `LinkLaravel`, so exactly the two new leaves render.

### 4.3 `SampleOrderPolicy` (shipped file, mirrors QuotationPolicy)

Six abilities — `viewAny`, `view`, `create`, `update`, `delete`, `restore` — all funnel through:

```php
private const MENU_LINK = '/sample-orders';

private function allowed(?User $user): bool
{
    return $user !== null && $user->canAccessMenuLink(self::MENU_LINK);
}
```

`User::canAccessMenuLink()` matches only when ALL hold: an active `rolemenu` row (`IsDeleted=0`) for the user's `RoleID`; the joined `menu` row is `IsDeleted=0`, `IsShow=1`, `LinkLaravel <> ''`; and `LinkLaravel` equals `'/sample-orders'` exactly (strict comparison, memoized per request). **No admin bypass exists** (no `Gate::before`, empty `$policies` map) — Administrator passes only because grant row 1741 exists in the data. Resolution is by naming convention (`App\Models\SampleOrder` → `App\Policies\SampleOrderPolicy`).

### 4.4 Enforcement — ACTIVE since Phase 2 (2026-06-11)

The Phase-1 gap (render-only closures, dormant Policy) is closed: `SampleOrderController` calls `$this->authorize('viewAny'|'view', …)` on `index`/`show`, and `approvalPm` gates on `canAccessMenuLink('/sample-orders/approval-pm')` (menu 1300), mirroring `QuotationController::approvalPm()`. Authenticated users whose role lacks the grants receive 403 (test-covered on all three endpoints).

---

## 5. Data Model

All tables are existing production schema (read-only ports; structures verified via `DESCRIBE` against the dev DB `colorindochemtrainertia` on 2026-06-10). Eloquent models already exist for every table below (attribute-driven config: `#[Table]`, `#[Fillable]`, `#[ScopedBy(NotDeletedScope::class)]`, `timestamps: false`, PK `ID`).

### 5.1 `sampleorder` (header) — model `App\Models\SampleOrder`

39 columns. Key columns (all PascalCase, `IsDeleted` int soft-delete flag):

| Column | Type | Notes |
|---|---|---|
| `ID` | int | PK, auto-increment |
| `IsDeleted` | int | 0=active / 1=deleted; `creating()` defaults to 0 |
| `SampleOrderStatusID` | int | FK → `sampleorderstatus` (workflow state, §5.4) |
| `Tanggal` | **datetime** | Created date-time (display trims to date) |
| `CompanyID`, `CompanyCPID` | int | FK → `company` / `companycp`; plus **denormalized** `CompanyCPName/Address/Kecamatan/Kota/Provinsi/KodePos` varchar columns |
| `UserIDInput`, `UserIDSales` | int | FK → `users` (creator / sales) |
| `DivisionID`, `IndustryID`, `PriorityID`, `DeliveryID`, `LocationID` | int | FK → `division` / `industry` / `priority` / `delivery` / `location` |
| `SampleOrderByID` | int | FK → `sampleorderby` (how the order arrived); `TanggalSOBy` date |
| `SampleOrderFeedbackStatusID` | int | FK → `sampleorderfeedbackstatus` |
| `ProjectID` | int | FK → `companyproject`; plus free-text `Project` varchar(2000) |
| `Comment` | varchar(2000) | Internal comment |
| `ReferenceSOID` | int | Self-reference → `sampleorder.ID` |
| `IsSendEmail`, `Vendor`, `BookingID`, `AWB`, `TanggalAWBInput`, `ServiceCode`, `ServiceName`, `Tarif`, `Asuransi`, `Packing`, `TotalTarif`, `TotalWeight` | mixed | Email/logistics columns — **not surfaced by this module** |

Model defines 13 explicit `belongsTo` relations (company, companyCP, delivery, division, industry, location, priority, project, referenceSO, sampleOrderBy, sampleOrderFeedbackStatus, sampleOrderStatus, userIDInput, userIDSales). **It defines no `hasMany` for details/assignments** — a locked Phase-2 decision keeps the model untouched and fetches children controller-side, grouped by `SampleOrderID` (§6.4).

### 5.2 `sampleorderdetail` (line items) — model `App\Models\SampleOrderDetail`

| Column | Type | Notes |
|---|---|---|
| `ID`, `IsDeleted`, `SampleOrderID` | int | PK / flag / FK → header |
| `SampleOrderDetailStatusID` | int | FK → `sampleorderdetailstatus` (per-line status, §5.4) |
| `BarangID` | int | FK → `barang` (product; `barang.PrincipalID` → `principal` supplies the principal name) |
| `SatuanID`, `ApplicationID` | int | FK → `satuan` / `application` |
| `ProductName` | varchar(200) | Free-text product name |
| `Quantity`, `QtReceived` | decimal(10,2) | |
| `Remarks`, `RemarkCoverLetter`, `RemarkInternal` | varchar(500) | Three remark slots (surat jalan / cover letter / internal) |
| `FeedbackRemark` | text | |
| `BarangListID`, `BarangListIDRequest` | int | FK → **`baranglists`** (lot/stock rows — see ⚠️ below) |
| `ProjectID`, `ProjectIDDetails`, `SuratJalanDetailID` | int | `ProjectID` FK → `companyprojectdetailcc` (repointed 2026-08-06 — was wrongly `companyproject`; see the migration docblock); `ProjectIDDetails` dormant (FK dropped, column kept); `SuratJalanDetailID` FK → suratjalandetails |

> ⚠️ **`baranglist` vs `baranglists` versioning trap:** both tables exist. Verified against live data: of sampled `BarangListID` values, **500/500 resolve against `baranglists`** (plural, 17 361 rows, max ID matches) vs 11/500 against `baranglist` (singular, 89 rows — stale). The `BarangLists` model correctly maps the plural table; `LotNumber` is its display column.

### 5.3 `sampleorderassignment` (history) — model `App\Models\SampleOrderAssignment`

`ID, IsDeleted, SampleOrderID, SampleOrderStatusID, Tanggal (datetime), UserID, Comment varchar(500)` — one row per status transition, the `<entity>assignment` audit pattern (same shape as `quotationassignment`). Relations: `sampleOrderStatus()`, `user()`.

### 5.4 Status lookups (live rows, dev DB 2026-06-10)

**`sampleorderstatus`** (header workflow): 1 Request · 2 Approval SM · 3 Approval PM · 4 Revise · 5 Reject · 6 Packing/ Prepare · 7 Surat Jalan · 8 Sample Received · 9 Feedback · 10 Done *(IsDeleted=1)* · 11 Cancel.

Live distribution (active rows, total 1 038): Request 59, Approval SM 152, Approval PM 4, Revise 37, Reject 12, Packing/Prepare 4, Surat Jalan 208, Sample Received 431, Feedback 36, Cancel 91. (`sampleorderdetail` 2 322 rows; `sampleorderassignment` 4 832 rows.)

**`sampleorderdetailstatus`** (line-item status): 12 rows (Request, Approval PM, Packing/ Prepare, Surat Jalan, Sample Received, Feedback, Recreate Sample Order, Feedback *(dup ID 8)*, Cancel, Revise, Reject, Approval SM). ⚠️ **Anomaly:** this table has **no `IsDeleted` column** — the model correctly carries **no** `NotDeletedScope`.

**Other lookups:** `sampleorderfeedbackstatus` (`StatusName`: Follow up, Proceed + deleted test rows), `sampleorderby` (`Name`: Email — single active row), `delivery` (`DeliveryName`: Courier, Sales Rep., Along with Order Del. active), `priority` (`PriorityName`: Low, Normal, High).

### 5.5 Soft-delete semantics

Standard project pattern: custom `IsDeleted` int flag + `NotDeletedScope` global scope on every model above **except** `SampleOrderDetailStatus` (no column). `softDelete()`/`restore()` methods exist on the models but no shipped code path calls them.

---

## 6. Functional Requirements

### 6.1 Navigation & access — SHIPPED

- **FR-1.1** Sidebar group **Sample Order** (icon `Package`) with leaves **Sample Orders** → `/sample-orders` and **Approval PM** → `/sample-orders/approval-pm`, visible to roles granted menus 1299/1300 (today: Administrator only).
- **FR-1.2** The three routes render their Inertia components with **no props**; each page supplies safe defaults (`sampleOrders = EMPTY_PAGINATOR` on List, `sampleOrders = []` on Approval PM, `sampleOrder = {}` on Detail), so all pages render their empty states without errors.
- **FR-1.3** `sample-orders.show` accepts a numeric `{id}` (`whereNumber`) — the id is currently ignored by the closure.

### 6.2 List — `MenuSampleOrders/SampleOrders/List` (UI SHIPPED; data contract = Phase 2)

**User story:** As a granted user, I want to browse, search, sort, and customize the sample-order list.

- **FR-2.1 (shipped)** 16 column definitions in 3 groups — Identifiers: `id` (SO No, required), `company` (required), `status`, `feedback`; Metadata: `creator`, `sales`, `division`, `industry`, `priority`; Order & Dates: `tanggal`, `sampleOrderBy` (SO By), `tanggalSOBy` (Date SO By), `delivery`, `project`, `comment`, `sampleList`. Default-visible: all except `sampleList`.
- **FR-2.2 (shipped)** Column customization via `CustomizeColumnsModal` + drag-to-reorder headers; state persisted in `localStorage` key **`sampleOrderColumnsState_v1`**.
- **FR-2.3 (shipped)** Search box (300 ms debounce), sortable headers (all columns except `sampleList`), page-size select **5/10/15/20**, numbered pagination — all of which issue `router.get(route('sample-orders.index'), …)` with `preserveState/preserveScroll/replace`. *Server-driven since Phase 2 (2026-06-11).*
- **FR-2.4 (shipped)** Status cell renders `StatusBadge` with tone map: Request→warning; Approval SM/PM, Feedback, Process, On Delivery→primary; Revise→warning; Reject/Cancel→danger; Print, Process To Order, Delivered, Good Shipped/Receive, Compiled→success; **unknown names→neutral** (note: real statuses `Packing/ Prepare`, `Surat Jalan`, `Sample Received` are not in the map and fall back to neutral).
- **FR-2.5 (shipped)** Row click → `router.visit(route('sample-orders.show', so.id))`. Empty state: **"No sample orders found."**
- **FR-2.6 (shipped)** Header button **"New sample order"** → `route('proto.sample-orders.create')` (prototype page) with an in-code note to repoint when the real create route lands.
- **FR-2.7 (SHIPPED 2026-06-11)** `index()` requires `viewAny`; joins `company, sampleorderstatus, sampleorderfeedbackstatus, sampleorderby, delivery, priority, division, industry, users (creator/sales)` — sales joined on **`sampleorder.UserIDSales`** (BR-9); allowlisted sort map for the 15 sortable keys; `LIKE` search across company/status/feedback/SO-by/delivery/priority/division/industry/creator/sales/project/comment plus numeric `ID`; paginates per `per_page` ∈ {5,10,15,20} (default 10) with `withQueryString()`; `sampleList` built from `sampleorderdetail` (+`satuan`, `application`) grouped by `SampleOrderID` (shape `{productName, qty, satuan, application}`); datetimes trimmed to date (`0000-00-00*` → blank).

### 6.3 Detail — `MenuSampleOrders/SampleOrders/Detail` (UI SHIPPED; data contract = Phase 2)

- **FR-3.1 (shipped)** Hero card "Sample Order #{id}" + status badge; when `status === 'Approval PM'` shows **Reject / Approve** buttons (⚠️ static, no handlers), otherwise a static "Action" button.
- **FR-3.2 (shipped)** 5-stat strip: Total Items, Total Qty, Sample Order By, Delivery, Priority.
- **FR-3.3 (shipped)** Doc sections (`DocList` key/value grids): General Information, Company & Contact, Order Information, Additional Information, History (meta line + latest entry fields). Missing data renders **"Tidak ada data."** (Indonesian — inherited from the Quotation Detail copy).
- **FR-3.4 (shipped; EXTENDED to legacy parity 2026-06-11)** Line-items table, 15 columns: ID, Status, Principal, Barang, Product Name, Qty, Qt Received, Satuan, Application, Request Lot Number, List Barang, Remarks, Remark Cover Letter, Remark Internal, History (per-line status log, `date | status | user | remark`, chronological — legacy format). Empty: **"Tidak ada line item."**
- **FR-3.5 (SHIPPED 2026-06-11)** `show(int $id)` loads the header with its lookups, authorizes `view`, fetches details (`+ sampleOrderDetailStatus, satuan, application, barang→principal, barangList + barangListIDRequest (LotNumber)`), per-line history (`sampleorderdetailassignment + status + user`, chronological, grouped by `SampleOrderDetailID`) and assignments (newest-first, `+ sampleOrderStatus, user`) via grouped controller-side queries, and presents `general/companyContact/order/additional/history/lineItems/totals` exactly as the page consumes them. Company-CP fields fall back to the header's denormalized `CompanyCPName/Address` columns when the FK row is missing. The two lot relations load **without** `NotDeletedScope` (legacy joins `baranglists` unscoped, so historical lot numbers stay visible); lot cells render blank when the FK is `0` (legacy rule).

### 6.4 Approval PM — `MenuSampleOrders/ApprovalPm/Index` (UI SHIPPED; data contract = Phase 2)

- **FR-4.1 (shipped)** Master-detail review queue: filter card (search; collapsible panel with Date Range all/7/30/90/365, Industry, Principal, Product, Division, Sales, Company filters — all **client-side** over the `sampleOrders` prop), 4 summary stats (Total Sample Orders, Total Items, Companies, Last 7 Days), paginated list table (No, SO No, Company, Status, Items; page size 5/10/15/20 client-side), and a detail panel with **Overview / Line Items / History** tabs (history shows 4 entries, "Show more" expands). **Reject / Approve** buttons are static (no handlers).
- **FR-4.2 (SHIPPED 2026-06-11)** `approvalPm()` gates on `canAccessMenuLink('/sample-orders/approval-pm')`; queue = **`SampleOrderStatusID = 1` (Request)** — a literal mirror of the Quotation PM queue (explicit decision 2026-06-10; today that is 59 orders); scoped to the user's head-division principals (`userprincipal.IsHeadDiv = 1` → `whereExists` over `sampleorderdetail` join `barang` on `PrincipalID`); **STRICT since 2026-06-12 (BR-14)**: users with no head-div principals see an **empty queue**, Administrator included (the former see-all relaxation and its `scoped` flag are removed); capped at 200 rows (logged warning when exceeded); returns flat presented queue items.

### 6.5 Create — `MenuSampleOrders/SampleOrders/Create` (SHIPPED — real create/store, 2026-06-11)

- **FR-5.1 `create()`:** renders the page with legacy-scoped `companies` (sales = user + recursive subordinates via `users.HeadID`, head-div divisions via `userdivision.IsHeadDiv=1`, `companyaccessothers` grants; **empty scope → ALL companies + `companiesScoped=false` flag**, the Approval-PM relaxation, with a visible note), option lists (`deliveries`, `sampleOrderBys`, `satuans`, `principals` with per-principal approvers from `userprincipal`), and the submission `gate` (`pending`/`max`/`blocked`, BR-13) driving the header badges ("Transaksi OK" / "Tidak bisa melakukan transaksi" · "Pending Sample Order: N / Limit M") and the disabled "Cannot Request" state.
- **FR-5.2 form behavior:** Company pick loads `companies/{id}/context` → derived readonly Division/Industry, sales name, `TermNCondSample` T&C (red note), active CPs with a KodePos/Telephone/Address completeness flag (⚠ marker + warning), and division-group Applications. The Add/Edit Barang dialog cascades principal → `products/by-principal` → product → `products/{barang}/lots` (active `baranglists` with `Quantity > 0`, expired lots flagged); picking a lot locks Satuan to the lot's and caps Quantity at lot stock; a stock box lists the product's lots. Items support Edit (reloads the cascades) and Delete. Link-with-Other tabs list the company's LWR / Quotation / Visit Report rows created by the logged-in user (`related/{type}`) — **display only** until #36.
- **FR-5.3 `store()`:** validates (§7), re-checks the gate, derives `UserIDSales`/`DivisionID`/`IndustryID`/`SnKSOCompany` from the company row (sales snapshot, BR-9), denormalizes the CP into the header, and inserts the 4 tables in one transaction (status 1 everywhere; picked lot → `BarangListIDRequest`). Redirect → Detail, or back to Create when `another=1` ("Save & create another"); flash "Sample order berhasil dibuat."
- **FR-5.4 deliberate deviations:** "none" FK columns stored as **NULL** (BR-11); To-Project picker removed until #37; Comment optional (the legacy server check never enforced it despite the label).

---

### 6.6 Multi-view (SHIPPED 2026-06-11)

Five sibling views of the list/detail, served by ONE parametrized List.jsx + Detail.jsx (`view` + `caps` props; per-view localStorage column keys). Columns are identical across views — the "table" difference is the per-user ROW scope; the "inside" difference is the capabilities object, consumed today by the hero's static buttons and later by the #31/#32 action blocks (Change Status = the legacy blue-outline block, Revise/Recreate, Cancel, Send Email, Link With).

| View | Route | Row scope | caps (changeStatus / revise / cancel / sendEmail / linkWith) |
|---|---|---|---|
| View Request | `/sample-orders/view-request` | `UserIDInput = me OR UserIDSales = me` | ✗ ✓ ✓ ✓ ✓ |
| View Head | `/sample-orders/view-head` | creator or sales ∈ my `users.HeadID` subtree (incl. me) | ✗ ✗ ✗ ✗ ✓ |
| View SM | `/sample-orders/view-sm` | `DivisionID` ∈ my `userdivision` rows with `IsHeadDiv=1` | ✗ ✓ ✓ ✓ ✓ |
| View All | `/sample-orders` (base) | unscoped | ✓ ✓ ✓ ✓ ✓ |
| View All (Read Only) | `/sample-orders/view-all-readonly` | unscoped | ✗ ✗ ✗ ✗ ✗ |

- Each view has its own `{id}` detail route enforcing the SAME scope (out-of-scope → 404). Gating mirrors approvalPm: each view requires an active rolemenu grant on its own `LinkLaravel`; View All keeps the resource Policy.
- **Create entry point follows the user, not the view:** every list shows "New sample order" only when `canCreate` (Policy). `SampleOrderPolicy::create` now passes with the `/sample-orders` grant OR the dedicated `/sample-orders/create` grant (menu 1556 — legacy "Create Sample Order" row reborn), so sales roles can create without View All.
- Menu rows (created 2026-06-11): 1556 Create Sample Order(0), 1557 View Request(1), 1558 View Head(2), 1559 View SM(3), 1299 renamed "View All"(4), 1560 View All Read Only(5), 1300 Approval PM(6) — Administrator granted all; other roles via the RoleMenus UI.
- Shared FE meta in `resources/js/lib/sampleOrderViews.js` (labels + route names).
- Tests: `SampleOrderViewsTest` (11 tests — gating, per-view scoping incl. detail 404s, caps assertions, canCreate=false for a view-request-only role; the head out-of-subtree case skips when the dev DB has no user outside admin's subtree).

### 6.7 Approval SM (SHIPPED 2026-06-12 — first write stage)

Rules locked from the legacy Approval SM screens (`Approval SM/*.php`) with the user 2026-06-12:

- **Queue** (`GET /sample-orders/approval-sm`, menu 1576): order headers with `SampleOrderStatusID` 1 (Request — **view-only awareness**) or 3 (PM approved — **actionable**, `canAct`), scoped to the actor's `userdivision.IsHeadDiv=1` divisions. **STRICT — no relaxation**: a user with no head-div rows sees an empty queue, Administrator included (unlike `approvalPm()`'s relaxation, flagged for review). Cap 200. Renders the parametrized `ApprovalPm/Index` page with `stage='sm'`.
- **Actions** (`POST /sample-orders/approval-sm/{id}/{approve|revise|reject}`, comment required ≤500): implemented in `App\Services\SampleOrderTransitionService::applySm()` — one transaction: header → 2/4/5, all active details → 12/10/11, one `sampleorderassignment` + one `sampleorderdetailassignment` per line (Remark = comment). Guards (hardened over legacy): `lockForUpdate`, action only from status **3** (legacy only blocked "cancelled"), division scope re-checked at write time (legacy trusted the page). Errors surface on the dialog's comment field.
- **UI**: the SM stage of the shared queue page gets working Reject / Revise / Approve buttons (disabled + "Menunggu Approval PM" hint on status-1 rows) and a per-action confirm dialog with the required comment.
- **Email on revise/reject** (legacy `MailHandler::sendEmailSOToUserRevise`): deferred to #38; hook point marked in the service.
- **Tests**: `SampleOrderApprovalSmTest` (8 tests — gating, queue composition + canAct flags, strict empty queue, all three transitions' table writes, status-1 refusal, out-of-division refusal, comment required).
- **Status semantics clarified (important):** status names mean "approved by", not "waiting for" — PM approves per **line** (principal heads) while the header sits at 1→3; SM then approves the **header** (1|3 visible, 3 actionable) → 2; packing picks up from 2.

### 6.8 Detail capability actions (SHIPPED 2026-06-12 — Cancel / Send-Email / Change-Status)

Locked from the legacy View All detail (`View All/listsamplerequestdetailsall.php`); stock rules verified against the legacy Packing / Surat Jalan / Good Issue / Feedback screens (pushed into the reference folder 2026-06-12).

- **Endpoints + gating:** `POST /sample-orders/{id}/{cancel|send-email|change-status}`. The body carries the acting `view`, fully re-verified server-side by `SampleOrderController::gateCapability()`: known view (404) → menu grant (`gateView`) → the view's caps flag (403, §6.6 matrix) → row scope via `applyViewScope` (out-of-scope ids 404 exactly like `show()`). Change Status stays View All-only (BR-11).
- **Cancel** (`SampleOrderTransitionService::cancel`): allowed only from statuses **1–4** — legacy merely hid the button, here the guard runs in-transaction under `lockForUpdate`. Header → 11, active details → 9, one `sampleorderassignment` + one `sampleorderdetailassignment` per line with the fixed legacy comment **"Cancelled by User."** (cancel takes no user comment). No stock involvement (statuses 1–4 precede Packing). Guard errors land on the confirm dialog's `cancel` key.
- **Send-Email** (`setSendEmail`): bare `IsSendEmail` 0/1 update with an explicit posted value (double-click safe) — **no status guard, no history rows** (legacy parity: `yessendemail`/`nosendemail` handlers). The flag is only consumed by later passes (#38); nothing is sent. UI = shadcn `Switch`; always stays on the page (the legacy "& go back to list" navigation variant was dropped).
- **Change Status** (`changeStatus`): comment mandatory (≤500); targets = every **ACTIVE** `sampleorderstatus` row (user rule 2026-06-12: all except `IsDeleted=1` — so 10 "Done" is out), mapped header→detail per legacy (1→1, 2→12, 3→2, 4→10, 5→11, 6→3, 7→4, 8→5, 9→8, 11→9); sources unrestricted, **including un-cancel from 11**. The one legacy block ports: from current 6–9, targets 4/5/11 are refused ("…sudah melewati Packing/Prepare."). `changeStatusBlockReason()` is shared with the controller, so the dialog's options arrive pre-disabled with the same reasons (`statusOptions` prop).
- **Stock compensation (VERIFIED 2026-06-12):** packing deducts lot stock entering 6 (`samplerequestpacking.php` btn-cek); good issue returns **unreceived** (`QtReceived=0`) lines' stock at 8 (`samplerequestgoodissue.php`); surat jalan and feedback move nothing. Therefore: **6/7 → 1/2/3 RESTORES** the stock of active, lot-assigned, `QtReceived=0` lines (legacy *wrote* this but an unbound variable made it a production no-op — fixed per verified intent); **8/9 → 6/7 RE-DEDUCTS** the same line set (the one legacy statement that did run); 8/9 → 1/2/3 and re-stamps (incl. 7→6) move nothing. `QtReceived > 0` lines are always excluded — their goods are with the customer. One atomic join-UPDATE; InnoDB row locks serialize concurrent adjustments.
- **UI:** an **Actions card** on Detail (under the stats strip), rows rendered per caps: Send-Email switch (shows `IsSendEmail: Yes/No`), Change-Status dialog (`FloatingField` select + comment, disabled options listed with their reasons), Cancel confirm dialog — or the legacy-parity **"Cannot Cancel"** text outside statuses 1–4. The hero's static PM Approve/Reject buttons are untouched (remaining #31 scope).
- **Tests:** `SampleOrderActionsTest` (13 tests) — see §12.

### 6.9 Approval PM actions (SHIPPED 2026-06-12 — line-grained backend + bulk wiring)

Locked from the legacy PM screens (`Approval PM/listsamplerequestapprovalpm.php` + view include) with three user decisions: comment **REQUIRED** for all actions (legacy allowed blank), batches **ALL-OR-NOTHING** (legacy committed per line and aborted mid-loop, stranding half-processed batches), and the existing queue page's static buttons wired as an order-level bulk convenience until the redesigned per-line page lands.

- **Endpoint:** `POST /sample-orders/approval-pm/{approve|revise|reject}` (`SampleOrderApprovalPmRequest`: `details[]` checked line ids + comment ≤500), gated by the `/sample-orders/approval-pm` menu link.
- **Semantics (`SampleOrderTransitionService::applyPm`):** PM is line-grained. **Approve** flips only the checked lines → detail 2 (+ one detail-assignment each); the header moves 1 → **3** (+ assignment) only once **every active line** is approved (legacy countAll==count2 rule) — partial approval leaves the header at 1 with the remaining lines queued. **Revise/Reject** are whole-order despite the checkboxes (legacy blanket update): every active line → 10/11, header → 4/5, full assignment set via the shared `applyTransition()`.
- **Guards (hardened over legacy):** every checked line must be detail-status 1 on a header at status 1 (the queue contract), and the actor's `userprincipal.IsHeadDiv=1` scope is re-checked **per line** at write time; any failing line rejects the entire batch (`lockForUpdate`, errors keyed `details`).
- **Queue annotation:** `approvalPm()` marks each line item with `canAct` (status 1 + in the actor's principals) — a queued order may also carry OTHER PMs' lines; the page's bulk buttons submit exactly the `canAct` ids.
- **UI (existing page, minimal wiring):** the PM stage's Reject / Revise / Approve buttons post all actable lines of the selected order through the shared action dialog (shows the line count + a whole-order warning for revise/reject); disabled when the actor holds no actable line. The redesigned per-line page reuses the same endpoint when it lands.
- **Email on revise/reject** (`sendEmailSOToUserRevise` per affected order): deferred to #38; hook marked in the service.
- **Tests:** `SampleOrderApprovalPmActTest` (8 tests) — see §12.

### 6.10 Packing (SHIPPED 2026-06-12 — queue + lot assignment + split, minimal page)

Locked from the legacy Packing screens (`Packing/*.php`) with five user decisions (2026-06-12): queue **UNSCOPED** (legacy's division filter is commented out in its source), the split is a **FULL business copy** (deliberate deviation — see below), system rows carry the **acting user** with the legacy `'Input By System'` comment (legacy's `UserID=0` violates our FK RESTRICT), **comment required** for all three actions, and a **minimal dedicated page** to drive it.

- **Routes:** `GET /sample-orders/packing` (queue) · `GET /sample-orders/packing/{id}` (screen) · `POST …/{id}/pack` (`SampleOrderPackingRequest`: `lots` map detail→lot, comment) · `POST …/{id}/{revise|reject}` (`SampleOrderCommentRequest`). All gated by the `/sample-orders/packing` menu link.
- **Queue:** every `SampleOrderStatusID = 2` order (cap 200); screen lines = detail-status **2 or 12** (the legacy filter). Lot dropdown contract per line: active `baranglists` with the **same barang AND satuan** and `Quantity >= line.Quantity`, pre-selected to `BarangListIDRequest` when still offered; expired lots flagged.
- **Pack (`SampleOrderTransitionService::pack`):** ONE transaction (legacy committed packing and the split separately). Header → 6 + assignment; per lot-assigned line: `BarangListID` set, line → 3, detail-assignment, **stock deducted** (`baranglists.Quantity -= line.Quantity`). Hardened server-side under `lockForUpdate`: posted map must cover exactly the packable set, ≥1 lot ("Silahkan pilih list barang."), no duplicate lots ("Duplicate list barang."), and the dropdown contract re-verified per lot (no overdraw race) — legacy had these client-side only.
- **Split:** no-lot lines move to a NEW order — status 2 (straight back into this queue), `ReferenceSOID` = the packed order, fresh `Tanggal`; **FULL copy** of every other header/line field (`replicate()`): Industry, Project, SnK, CP snapshot, SO-By/date, lines keep requested lot + both remarks + their status. ⚠ Deliberate deviation: legacy's hand-written column list silently blanked all of those (visible on historical split orders) and could not run on our strict schema anyway. New-line assignment rows record the line's REAL copied status (legacy logged 2 even for 12-lines). Old no-lot lines → **7 "Recreate Sample Order"** + `'Cancelled By System'` assignment.
- **Revise / Reject:** whole-order 4/10, 5/11 via `packingReviseReject()` (queue contract: header must be 2; legacy only refused cancelled orders). Emails deferred (#38). No stock involvement (nothing deducted yet).
- **UI:** `MenuSampleOrders/Packing/Index.jsx` (clickable queue, client search) + `Packing/Detail.jsx` (summary card with the red SnK note, per-line lot selects, required comment, Packing/Revise/Reject with confirm dialogs incl. the legacy split warning). Needs a **menu row** `LinkLaravel='/sample-orders/packing'` + rolemenu grant for the sidebar/gate.
- **Shared-function note:** the status-tone map was extracted to `resources/js/lib/sampleOrderStatusTones.js` (was triplicated — part of #34) and gained the previously-missing workflow names (`Packing/ Prepare`, `Surat Jalan`, `Sample Received`, `Recreate Sample Order`); `SampleOrderCommentRequest` supersedes `SampleOrderApprovalSmRequest` (rule of two: SM + packing share it).
- **Tests:** `SampleOrderPackingTest` (7 tests) — see §12.

### 6.11 Surat Jalan (SHIPPED 2026-06-12 — 6→7 + SJ rows + manual AWB; PDFs/courier API deferred)

Locked from the legacy SJ screens (`Surat Jalan/*.php`); schema for `suratjalan` / `suratjalandetails` verified via `SHOW CREATE TABLE` 2026-06-12 (FK columns nullable with RESTRICT constraints → NULL for "none" per BR-11; NOT NULL varchars/decimals/datetimes get explicit empties; `suratjalan.Vendor` is a VARCHAR storing the vendor select's id, unlike the int on `sampleorder`).

- **Routes:** `GET /sample-orders/surat-jalan` (queue) · `GET …/{id}` (screen) · `POST …/{id}/generate` (`SampleOrderCommentRequest` — third consumer) · `POST …/{id}/awb` (`SampleOrderAwbRequest`). Gated by the `/sample-orders/surat-jalan` menu link (row 1665).
- **Queue (updated 2026-06-12):** orders at status **6 AND 7**, unscoped (legacy parity, like packing). A generated order **stays listed** at status 7 (user decision) — only its status can no longer change here; it leaves at Good Issue (7→8). Ordered **status 6 first, then 7** (`SampleOrderStatusID` asc), newest id within each (user request). The list shows a **Status badge** with distinct tones — 6 `Packing/ Prepare` = warning/amber, 7 `Surat Jalan` = primary/violet (shared `sampleOrderStatusTones.js`). The screen also serves both 6 and 7 (status-3 lines render; a status-7 order shows none, only the AWB form + created-SJ list).
- **Generate (`SampleOrderTransitionService::generateSuratJalan`):** hardened (legacy had NO status guard) — header must be 6 with ≥1 status-3 line, comment required (pre-checked client-side before the confirm dialog), `lockForUpdate`, one transaction. Header → 7 + assignment (the user comment); one `suratjalan` row copying the order's parties + the logistics snapshot **from the order's own header columns** (deviation: legacy posted form fields that merely mirrored them — single source of truth instead), `UserIDCreatorSJ` = actor, `SuratJalanNo` = `YYYY/MM/` + zero-padded row id (race-safe by construction, legacy format); one `suratjalandetails` copy per status-3 line (incl. its lot); lines 3 → 4 with the legacy `'-'` assignment remark (the comment lives on the header row). **Returns to the list** (user decision 2026-06-12) — the order remains there as status 7 for AWB entry / reprint.
- **Update AWB (`suratJalanUpdateAwb`):** manual logistics header update at status 6/7 — Vendor (validated against `apipengiriman`), AWB, Tanggal AWB, ServiceCode (+ legacy rule `ServiceName='REX'` iff a code is set), Tarif, Asuransi, Packing, TotalWeight. **Syncs into the order's `suratjalan` row(s) in the same transaction** so AWB entered AFTER generating stays consistent for any consumer (the legacy reprint screen reads AWB from `sampleorder`, so this is belt-and-suspenders; `suratjalan.Vendor` is a varchar). Deviation: legacy kept most fields readonly + autofilled from the REX tracking API; with the API deferred they are all editable. `BookingID` is deliberately not updatable (only the legacy API webhook writes it).
- **Deferred (issue #56 scope):** the four PDFs (SJ report `reportsuratjalan.php`, cover letters EN/ID, AWB label), the courier-API block (rates check, Generate-AWB booking via `apipengiriman.Php/OrderPhp` endpoints, tracking autofill, `webhookshipdeo.php` — the legacy comment "JANGAN LUPA EDIT DI ShipdeoAPI/webhookshipdeo.php JUGA" applies when it lands), the CP-postcode quick-edit (writes `companycp`), `sendEmailSampleOrderToUser` (#38; hook marked), and the **View/Reprint Surat Jalan menu** (legacy `View Surat Jalan/*` — a searchable list of ALL `suratjalan` rows + a read-only detail with three reprint PDF buttons, no status change; its detail reads AWB from `sampleorder`). The list+detail view is buildable now; its three actions are all deferred PDFs.
- **UI:** `MenuSampleOrders/SuratJalan/Index.jsx` (queue + Status + AWB columns) and `SuratJalan/Detail.jsx` (summary incl. logistics values, created-SJ list, status-3 lines with lot numbers, Update-AWB form card, Generate card with confirm dialog + comment pre-check, and disabled placeholder buttons for the two Surat Pengantar PDFs).
- **Tests:** `SampleOrderSuratJalanTest` (6 tests) — see §12.

### 6.12 Sample Received / Good Issue (SHIPPED end-to-end 2026-06-29 — LOCKED 2026-06-25, backend 2026-06-26, stock-hardened + WIRED 2026-06-29)

Design locked with the user 2026-06-25 from the legacy Good Issue screens (`Sample Received/*.php`); the three-state `SampleOrderTransitionService::goodIssue()` + `SampleOrderGoodIssueRequest` + `SampleOrderGoodIssueTest` were **built 2026-06-26** (replacing the 2026-06-18 two-state pass) and **audited + stock-hardened 2026-06-29** (the §6.8 interaction below). Departs from legacy's one-shot Received/Not-Received finalize to support **partial / multi-round receiving** — an order can be confirmed across several visits, staying in the queue until every line resolves. **Backend only; controller/routes/pages/menu remain the Phase-2 wiring pass.**

- **Entry / queue:** orders at header status **7** (Surat Jalan); screen lines = detail-status **4** (Surat Jalan). Queue scope = **mine-or-admin** (status-7 orders where `UserIDInput = me OR UserIDSales = me`; `RoleID = 1` sees all) — user decision. Comment **required** (header-level). Stage actions only, no Cancel/Change-Status (BR-19).
- **Three per-line states**, default = **Still In Transit** with `QtReceived = 0`:
  - **Still In Transit** — line **stays at detail status 4**; the typed `QtReceived` (0 or partial) is **saved + logged**; **no stock change**. This is what keeps the order open.
  - **Received** — line → detail status **5**; `QtReceived` must be **> 0 and ≤ Quantity** (partial allowed); **stock stays deducted**; logged. *Auto-flip:* typing `QtReceived = Quantity` flips the line to Received.
  - **Not Received** — line → detail status **5**; `QtReceived` **forced 0**; the **full line stock is returned** to the lot (`baranglists.Quantity += Quantity`, legacy behavior); logged.
- **Header promotion:** → **8 (Sample Received)** only when **no line is Still In Transit** (every line is Received or Not Received). If any line is still in transit, the header **stays at 7** and the order returns to the queue for a later round (BR-22).
- **Stock summary:** stays deducted for *Received* and *In Transit*; returned (full line qty) only for *Not Received*. ⚠ A *Received* line with a **partial** qty keeps the **full** line's stock deducted — the shortfall is treated as consumed/lost (legacy-faithful: Received never returned stock). Flagged 2026-06-25; revisit if the user later wants the shortfall returned.
- **History:** every line writes a `sampleorderdetailassignment` row **each round** recording its state + `QtReceived` (replaces the legacy `'-'` remark — user request); the header writes a `sampleorderassignment` row (status 8 when promoted, else the round's comment while it stays at 7).
- **§6.8 cross-check (RESOLVED 2026-06-29):** the original compensation keyed on `QtReceived = 0`, which an audit showed could **double-credit** an already-returned *Not Received* line on a backward Change-Status (header still 7 after a partial round). The backward **restore** branch now keys on **detail status 3/4** (the still-deducted, unresolved set), closing both that over-credit and the transit under-credit (a partial-*transit* line with `QtReceived>0` is now restored). See §6.8 / §13.
- **Wiring (SHIPPED 2026-06-29):** `SampleOrderController::goodIssue / goodIssueShow / goodIssueProcess` (**mine-or-admin** scope — `UserIDInput=me OR UserIDSales=me`, RoleID 1 all; re-checked at write time) + routes `sample-orders.good-issue[.show|.process]` + the existing `GoodIssue/Index.jsx` + `Detail.jsx` pages + **menu 2923** (`/sample-orders/good-issue`, ParentID 24 / sort 9, RoleID-1 grant) + 4 HTTP feature tests in `SampleOrderGoodIssueTest`. Vite rebuilt.

### 6.13 Feedback (8→9) — SHIPPED end-to-end 2026-06-29 (backend + wiring)

Locked + built 2026-06-29 from the legacy Feedback screens (`Feedback/samplerequestfeedback.php` — the per-line **detail-screen** path; the bulk-queue `listsamplerequestfeedback.php` variant used a single shared remark and skipped `FeedbackRemark`, and is **not** ported). The final workflow stage before Done — a **one-shot finalize**: no multi-round, no stock, no revise/reject, and legacy sends **no email** here (so #38 does not apply).

- **Entry / queue:** orders at header status **8** (Sample Received); screen lines = detail status **5** (at header 8 every active line is already resolved to 5 by Good Issue). Queue scope = **creator-only** (`UserIDInput = me`) — legacy-literal (`listsamplerequestfeedback.php`), a **deliberate divergence** from Good Issue's mine-or-admin (§6.12): the two legacy screens genuinely differ and the user chose to keep each faithful (decision 2026-06-29). Stage action only; no Cancel / Change-Status (BR-19).
- **Action (`SampleOrderTransitionService::feedback()`):** one transaction, `lockForUpdate`. Header → **9 (Feedback)** and sets **`SampleOrderFeedbackStatusID`** (per-order Follow up / Proceed, **REQUIRED** — user decision; the legacy "0/none" sentinel is rejected, our schema has a RESTRICT FK with no `ID=0` row, BR-11). One `sampleorderassignment` (status 9) carrying the **OPTIONAL** header "Feedback & Date" comment (legacy never validated it — may be empty). Per status-5 line → detail **6 (Feedback)** with its **`FeedbackRemark`** (per-line, **OPTIONAL**/blank), plus one `sampleorderdetailassignment` (status 6) whose `Remark` = the **same** per-line text (legacy parity — the per-line `ItemFeedback` fed both `sampleorderdetail.FeedbackRemark` and the assignment `Remark`).
- **Hardenings over legacy (which had no guards):** header must be 8 with ≥1 status-5 line; the feedback status must be an active row; the posted line-id set must match the order's status-5 lines **exactly** (stale-page guard, mirrors Good Issue); the per-line remark caps at 500 (the binding `sampleorderdetailassignment.Remark` varchar(500), since the text feeds both columns). Guard failures throw `ValidationException` keyed (`comment` / `feedbackStatus` / `lines`).
- **No stock involvement** — goods are with the customer (received) or already returned (not_received) at Good Issue; the §6.8 backward-stock compensation is untouched by Feedback (it keys restore on detail 3/4, never 5/6).
- **Wiring (SHIPPED 2026-06-29):** `SampleOrderController::feedback / feedbackShow / feedbackProcess` (**creator-only** scope — `UserIDInput=me`, re-checked at write time) + routes `sample-orders.feedback[.show|.process]` + the new `Feedback/Index.jsx` + `Detail.jsx` pages (mirroring GoodIssue) + **menu 2924** (`/sample-orders/feedback`, ParentID 24 / sort 10, RoleID-1 grant) + 4 HTTP feature tests. Vite rebuilt.
- **Tests:** `SampleOrderFeedbackTest` — 9 service-level/FormRequest + 4 HTTP Pest tests (see §12).

### 6.14 View Details report family (SHIPPED 2026-06-30 — 5 read-only line-item views)

Faithful port of the legacy `listsampledetails*.php` family + mirror of the existing LWR/Quotation **View Details** pattern. Five **read-only, line-item-grain** pages — one row per `sampleorderdetail`, carrying its order context — that differ ONLY by row scope (the "table is filled with details, not headers" request):

| view | menu link | row scope (the WHERE) | grain |
|---|---|---|---|
| **all** | `/sample-orders/view-details` | none (every line) | — |
| **pm** | `/sample-orders/view-details-pm` | `barang.PrincipalID ∈ userprincipal(UserID=me, IsHeadDiv=1)` | LINE |
| **head** | `/sample-orders/view-details-head-dept` | `UserIDInput`/`UserIDSales ∈ withSubordinates(me)` | ORDER |
| **sm** | `/sample-orders/view-details-sm` | `sampleorder.DivisionID ∈ userdivision(UserID=me, IsHeadDiv=1)` | ORDER |
| **mm** | `/sample-orders/view-details-mm` | `barang.PrincipalID ∈ userprincipal(UserID=me, IsMM=1)` | LINE |

- **Backend:** `SampleOrderController::viewDetails / viewDetailsPm / viewDetailsHeadDept / viewDetailsSm / viewDetailsMm` → `buildDetailListResponse($request, $scope)` (gates on the view's OWN menu link via `canAccessMenuLink`) + `detailListBaseQuery` (one row per `sampleorderdetail`, joined to order/company/barang/principal/lookups, `sampleorder.IsDeleted=0`) + `detailHistories` (per-line `sampleorderdetailassignment` log) + `mapDetailRow`. Capped at `DETAIL_LIST_CAP=2000`, `ORDER BY sampleorderdetail.ID DESC`. pm/mm filter at the LINE level (a PM/MM sees only their own principal's lines — the exact SO-23516 motivation); head/sm filter at the ORDER level (every line of an in-scope order shows). pm/mm are **table-only** (`drillIn=false`); all/head/sm drill into `sample-orders.show`.
- **Wiring (SHIPPED 2026-06-30):** 5 additive routes `sample-orders.view-details[-pm|-head-dept|-sm|-mm]` (after `view-all-readonly`) + the new parametrized `MenuSampleOrders/ViewDetails/List.jsx` page (mirrors `MenuLWRs/ViewDetails/List.jsx` — client search/pill/date/sort/pagination + draggable columns, read-only) + **`SampleOrderViewDetailsMenuSeeder`** (menus **3028–3032** under group 24, sort 110–150, RoleID-1 grants + legacy grant mirror from `listsampledetails*.php`), registered in `DatabaseSeeder`. Vite rebuilt.
- **Tests:** `SampleOrderViewDetailsTest` — 7 Pest (guest redirect, ungranted 403, per-line shape + title + drillIn, **pm**/**mm** line-level in/out scope via a narrow-scope real user, **sm**/**head** order-level in/out scope).

### 6.15 Request vs Stock Sample report (SHIPPED 2026-06-30 — read-only line-item)

Faithful port of legacy `listsampledetailsvsstock.php`. A **per-line** report of pending sample-order detail lines (**order status 1/2/3** only — Request / Approval SM / Approval PM) shown **beside the current lot stock** of each requested product (`baranglists` where `Quantity > 0`, listed as `LotNumber (Qty Satuan)`). Read-only.

- **Columns:** No · SO No · Company · Status · Creator · **Sales (= `company.UserIDSales`, legacy-literal — user decision 2026-06-30, a deliberate divergence from the snapshot `sampleorder.UserIDSales` used elsewhere)** · Division · Industry · Company Category · Tanggal · Contact Person · Principal · Nama Barang (`barang.NamaBarang`) · Lot Number Req (`baranglists` via `BarangListIDRequest`) · Quantity · **Stock Barang**.
- **Backend:** `SampleOrderController::requestVsStock` (gates on `/sample-orders/request-vs-stock`) — one per-line query (`INNER JOIN sampleorder … SampleOrderStatusID IN (1,2,3)`), capped at `DETAIL_LIST_CAP=2000`, `ORDER BY s.ID DESC`. The "Stock Barang" column is fed by **one grouped `stockByBarang()` query** over the page's distinct `BarangID`s (vs the legacy per-row subquery / N+1). Client-side search/pill/date/sort/pagination (the in-scope set is ~416 lines).
- **Wiring (SHIPPED 2026-06-30):** route `sample-orders.request-vs-stock` + new `MenuSampleOrders/RequestVsStock/List.jsx` (mirrors the View Details page, read-only, no drill-in, draggable + resizable columns) + menu **3255** (sort 160, RoleID-1 grant + legacy grant mirror from `listsampledetailsvsstock.php`) via the (renamed-in-spirit) `SampleOrderViewDetailsMenuSeeder`. Vite rebuilt.
- **Deferred:** the legacy Export-to-Excel / Export-to-CSV buttons (their export source files were not ported) — tracked as a follow-up.
- **Tests:** `SampleOrderRequestVsStockTest` — 4 Pest (guest redirect, ungranted 403, per-line shape + title + populated stock column, status-1/2/3 scope in/out).

---

## 7. Validation Rules

`StoreSampleOrderRequest` (SHIPPED 2026-06-11). Header: `CompanyID` required + active; `CompanyCPID` required, active **and belonging to the posted company**; `DeliveryID`/`SampleOrderByID` required + active; `TanggalSOBy` required date; `Project` required (max 2000); `Comment` nullable (max 2000). Items (`min:1`): `BarangID`/`SatuanID`/`ApplicationID` required + active, `ProductName` required (max 200), `Quantity` required numeric 0.01–99 999 999.99 (decimal 10,2), three remarks nullable (max 500), `BarangListIDRequest` nullable (0 = no lot). `after()` hooks: **CP completeness** (KodePos + Telephone + Address non-empty — the legacy gate) and **lot checks** (active `baranglists` row, barang match, `Quantity` ≤ lot stock). The controller additionally re-checks the `SampleOrderLimit` gate (BR-13) and requires the company to carry a resolvable Division + Industry.

---

## 8. Routes / API

All inside the `auth` middleware group in `routes/web.php` (verified via `php -l` + `artisan route:list`, last 2026-06-11).

| Verb | URI | Route name | Handler | Authorize |
|---|---|---|---|---|
| GET | `/sample-orders` | `sample-orders.index` | `SampleOrderController@index` | `viewAny` (SampleOrderPolicy) |
| GET | `/sample-orders/create` | `sample-orders.create` | `SampleOrderController@create` | `create` |
| POST | `/sample-orders` | `sample-orders.store` | `SampleOrderController@store` (`StoreSampleOrderRequest`) | `create` + gate re-check |
| GET | `/sample-orders/companies/{company}/context` | `sample-orders.companies.context` | `companyContext` (JSON) | `create` |
| GET | `/sample-orders/products/by-principal` | `sample-orders.products.byPrincipal` | `productsByPrincipal` (JSON) | `create` |
| GET | `/sample-orders/products/{barang}/lots` | `sample-orders.products.lots` | `lotsByProduct` (JSON) | `create` |
| GET | `/sample-orders/related/{type}` | `sample-orders.related` (`lwr`\|`quotation`\|`visit`) | `related` (JSON, display-only) | `create` |
| GET | `/sample-orders/approval-pm` | `sample-orders.approval-pm` | `SampleOrderController@approvalPm` | `canAccessMenuLink('/sample-orders/approval-pm')` |
| POST | `/sample-orders/approval-pm/{action}` (`approve`\|`revise`\|`reject`) | `sample-orders.approval-pm.act` | `approvalPmAct` (`SampleOrderApprovalPmRequest`) | same menu link + Service line/header/scope guards |
| POST | `/sample-orders/{id}/cancel` | `sample-orders.cancel` | `cancel` | `gateCapability` (view grant + `caps.cancel` + row scope) |
| POST | `/sample-orders/{id}/send-email` | `sample-orders.send-email` | `setSendEmail` (`SampleOrderSendEmailRequest`) | `gateCapability` (`caps.sendEmail`) |
| POST | `/sample-orders/{id}/change-status` | `sample-orders.change-status` | `changeStatus` (`SampleOrderChangeStatusRequest`) | `gateCapability` (`caps.changeStatus`) |
| GET | `/sample-orders/{id}` | `sample-orders.show` | `SampleOrderController@show` (`whereNumber('id')`) | `view` (SampleOrderPolicy) |
| GET | `/proto/sample-orders/create` | `proto.sample-orders.create` | `PrototypeController::sampleOrdersCreate` → old proto page | none (auth only) |

All literal paths (`create`, `approval-pm`, `companies/…`, `products/…`, `related/…`) are declared **before** `{id}` (numeric-constrained), so the parameter route can never capture them.

---

## 9. UI / UX Requirements

**Conventions:** every page assigns `Component.layout = [AppLayout]` (array form) and lives under the `MenuSampleOrders/` page-path prefix (a new top-level group, confirmed 2026-06-10 — the first transactional sibling of `MenuQuotations/`). Data transport is Inertia v3 XHR (no axios). Status display always uses the shared `StatusBadge` (`Components/Proto/UI/StatusBadge.jsx`).

- **9.1 List** — utility/shadcn vocabulary (shadcn `Table`, `Button`, `Input`; token classes). Toolbar: right-aligned search + column-settings icon button. Headers are draggable with a 4-dot grip affordance and sort arrows. Sticky behaviors, zebra rows (`even:bg-secondary/40`), row hover, pill-shaped header row.
- **9.2 Detail** — utility/shadcn; hero + stats strip + 3-column doc-section grid (responsive to 2/1 cols) + wide line-items table (`min-w-400`).
- **9.3 Approval PM** — utility/shadcn; live clock in header; filter chips show an active-filter count badge; selected row highlights and (on narrow viewports ≤1180px) expands the detail panel inline beneath the row.
- **9.4 Create** — **prototype styling kept** (FloatingField/SearchableSelect/Pill/TabBar proto components + gradient pill buttons) while the page now runs fully on `useForm`/`useHttp` (2026-06-11); the visual re-theme to utility/shadcn remains a follow-up (#34).
- **9.5 Language mix (known):** page chrome is **English** ("No sample orders found.", "Back to List", "New sample order") while Detail's `DocList`/line-item empty states are **Indonesian** ("Tidak ada data.", "Tidak ada line item.") — both inherited verbatim from their source templates. The status-tone map is **duplicated in all three adapted pages** (List, Detail, ApprovalPm).
- **9.6 Build constraint:** the app serves a **prebuilt Vite bundle** — any change to these pages requires `npm run build` before it is visible.

---

## 10. Business Rules & Constraints

- **BR-1 Template fidelity:** List/Detail/ApprovalPm must keep prop-contract parity with their Quotation counterparts so the Phase-2 controller can mirror `QuotationController`'s presenter pattern 1:1.
- **BR-2 PM queue definition (locked 2026-06-10):** "pending PM approval" = `SampleOrderStatusID = 1` (Request) — the literal Quotation mirror — **not** status 2 (Approval SM) or 3 (Approval PM).
- **BR-3 Access = menu grants:** both sidebar visibility and (once Phase 2 lands) endpoint authorization derive from the same `rolemenu` data — granting a role the "Sample Orders" menu in the Pengelolaan UI widens both at once, with no code change.
- **BR-4 Model untouched in Phase 2:** `SampleOrder` gets no new relations; child rows are fetched via `SampleOrderDetail`/`SampleOrderAssignment` queries grouped by `SampleOrderID`.
- **BR-5 Use `baranglists` (plural):** any lot/stock display must resolve `BarangListID` against `baranglists` — the singular `baranglist` table is stale legacy data (§5.2).
- **BR-6 `sampleorderdetailstatus` has no `IsDeleted`:** never apply `NotDeletedScope` semantics to it.
- **BR-7 Soft-delete only:** when write operations eventually land, they must use the project's `IsDeleted` flag pattern (no hard deletes, no `SoftDeletes` trait).
- **BR-8 PascalCase DB columns** used verbatim in all queries; Inertia props for this module are presented in **camelCase** by the Phase-2 presenters (matching what the shipped pages already consume — e.g. `sampleOrderBy`, `tanggalSOBy`, `lineItems`).
- **BR-9 Sales = order snapshot (locked 2026-06-11):** every "Sales" display joins `users` on **`sampleorder.UserIDSales`** — the creation-time snapshot of the company's sales (mirrors `QuotationController`). The legacy View All screen showed `company.UserIDSales` (the company's *current* rep) instead; ~7% of historic orders differ where companies were reassigned. Snapshot-display is the project-wide default, but per the user: **ASK before applying it to each new module.**
- **BR-10 Line-item legacy parity (locked 2026-06-11):** Detail line items include Principal (via `barang.PrincipalID → principal.PrincipalName`), QtReceived, Request Lot Number (`BarangListIDRequest`), fulfilled lot (`BarangListID`), and per-line history from `sampleorderdetailassignment`; lot lookups resolve against `baranglists` WITHOUT the soft-delete scope. The Approval PM queue reuses the same line-item shape minus per-line history (not rendered there).
- **BR-11 Multi-view (locked 2026-06-11):** "mine" = creator OR sales (either column matches); View Head's subtree includes the head themself; the Change-Status block stays View All-only; Read Only strips every action incl. Link With; views are role-gated via their own menu links AND user-scoped in the query (see §6.6).
- **BR-12 Approval SM (locked 2026-06-12):** SM acts only on PM-approved orders (status 3); queue shows 1+3 but 1 is view-only; head-division scope is STRICT (no admin relaxation) and re-checked at write time; comment mandatory; transitions approve→2/12, revise→4/10, reject→5/11 via `SampleOrderTransitionService` (PM reuses it when its redesigned page lands).
- **BR-11 NULL for "none" FKs (locked 2026-06-11):** the dev DB enforces real `RESTRICT` FK constraints on every `sampleorder*` FK column and has no `ID=0` lookup rows (legacy 0s survive only because the dump loaded with FK checks off) — writes store **NULL** where legacy used the `0` sentinel (`PriorityID`, `LocationID`, `ReferenceSOID`, `ProjectID`, `BarangListID`, `SuratJalanDetailID`, no-lot `BarangListIDRequest`). Display treats 0 and NULL identically (blank).
- **BR-12 Zero dates writable / strict no-default columns (locked 2026-06-11):** `config/database.php` mysql sets explicit `modes` = Laravel's strict set **minus** `NO_ZERO_DATE`/`NO_ZERO_IN_DATE` (`STRICT_TRANS_TABLES` kept), preserving legacy `0000-00-00` semantics (`TanggalAWBInput` at create). Every insert must explicitly fill the schema's no-default NOT NULL columns: header `IsSendEmail, Vendor (int), BookingID, AWB, TanggalAWBInput, ServiceCode, ServiceName, Tarif, Asuransi, Packing, TotalTarif, TotalWeight`; detail `QtReceived, FeedbackRemark`; detail-assignment `FeedbackRemark`.
- **BR-14 PM queue STRICT (locked 2026-06-12):** the Phase-2 relaxation is gone — no `userprincipal.IsHeadDiv=1` rows → empty PM queue, Administrator included (mirrors BR-12). Note the scope sources differ by stage: SM scopes by DIVISION (`userdivision`), PM by PRINCIPAL (`userprincipal`).
- **BR-15 Capability actions are view-contextual (locked 2026-06-12):** every Detail action POSTs its acting `view`; the server re-verifies menu grant + the view's caps flag + row scope (`gateCapability`) — the §6.6 caps matrix stays the single source of truth.
- **BR-16 Cancel (locked 2026-06-12):** only from statuses 1–4; header→11, active details→9, both assignment types, fixed "Cancelled by User." comment; no stock involvement.
- **BR-17 Change Status (locked 2026-06-12):** comment mandatory; targets = all ACTIVE statuses (never `IsDeleted=1` → 10 Done excluded); sources unrestricted incl. un-cancel from 11; from 6–9 the targets 4/5/11 are blocked; backward moves carry the VERIFIED stock compensation (§6.8) — `QtReceived>0` lines always excluded.
- **BR-18 PM actions (locked 2026-06-12):** line-grained approve with the all-lines-approved header promotion (1→3); revise/reject whole-order (4/10, 5/11); comment mandatory; batches all-or-nothing; per-line head-principal scope enforced at write time; PM revise/reject emails deferred (#38). The `details[]` endpoint serves BOTH the current bulk buttons and the future redesigned per-line page — no backend change needed when it lands.
- **BR-21 Surat Jalan (locked 2026-06-12):** generate only from status 6 with ≥1 status-3 line, comment mandatory; suratjalan row snapshots the ORDER's header logistics (not posted values); `SuratJalanNo` = `YYYY/MM/`+padded row id; lines 3→4 with `'-'` assignment remark; `ServiceName='REX'` iff ServiceCode set; PDFs/courier API/CP-postcode edit/reprint-menu deferred. **Queue + screen serve statuses 6 AND 7** — a generated order stays listed at 7 (no further status change here; leaves at 7→8), queue ordered 6-before-7, status badge tones distinct (6 warning, 7 primary). **Generate returns to the list.** **Update AWB syncs into the `suratjalan` row(s)** so post-generate AWB stays consistent.
- **BR-20 Packing (locked 2026-06-12):** queue unscoped (legacy disabled its division filter); pack = lot per line under the dropdown contract (same barang+satuan, sufficient stock — re-verified under lock), header 2→6, lines→3, stock deducted; no-lot lines split into a FULL-copy status-2 order (`ReferenceSOID` set, old lines → 7 Recreate, system rows = acting user + 'Input By System'); revise/reject whole-order from status 2 only; comment mandatory; one transaction.
- **BR-19 Action placement (locked 2026-06-12, user rule):** Cancel and Change-Status appear ONLY on the Detail page behind the §6.6 caps matrix — NEVER on approval queues (PM/SM) or downstream workflow screens (Packing / Surat Jalan / Good Issue / Feedback). Workflow screens carry only their own stage's actions, mirroring legacy (e.g. packing = Packing/Revise/Reject, no cancel). Approval SM keeps its three actions incl. Revise (§6.7).
- **BR-13 Submission gate (legacy, enforced on both sides):** create is blocked when the user's pending sample orders (`SampleOrderStatusID IN (1,2,3,6,7)`, `UserIDSales` = user) exceed the max `groupdivision.SampleOrderLimit` across the user's divisions (via `userdivision`); a user with no divisions gets limit 0 — any pending order blocks (faithful to the legacy NULL comparison). The page disables submit ("Cannot Request") and `store()` re-checks.
- **BR-22 Good Issue / Sample Received (locked 2026-06-25, SPEC — not yet built):** three-state per-line receiving — **Still In Transit** (default; line stays at detail status 4; typed `QtReceived` saved; no stock move), **Received** (detail → 5; `QtReceived` > 0 and ≤ qty, partial allowed; stock stays deducted; auto-flip when `QtReceived = qty`), **Not Received** (detail → 5; `QtReceived` 0; full line stock returned to the lot). Header → **8** only when NO line is Still In Transit (otherwise stays at 7 for a later round). Queue = mine-or-admin over status-7 orders; comment required; every line logs its state + `QtReceived` each round (replaces legacy `'-'`). Deliberately departs from legacy's one-shot finalize to allow partial / multi-round receipt; a partial *Received* keeps the full line's stock deducted (shortfall = consumed). Revises the 2026-06-18 two-state `goodIssue()`/`SampleOrderGoodIssueRequest`. See §6.12.
- **BR-23 Feedback (locked + built 2026-06-29, backend only):** the final 8→9 stage — header → 9 + `SampleOrderFeedbackStatusID` (REQUIRED active) and every status-5 line → 6 with a per-line `FeedbackRemark` (the per-line text feeds both the line column and the assignment `Remark`, legacy parity); header comment OPTIONAL, per-line remark OPTIONAL; one-shot (no multi-round / stock / revise / reject / email — legacy sends none); queue **creator-only** (`UserIDInput = me`) at status 8 — a deliberate divergence from Good Issue's mine-or-admin (each kept legacy-faithful, §6.12 vs §6.13). Guards: header 8, active status, ≥1 status-5 line, exact posted-set coverage; one transaction + `lockForUpdate`. Service-level Pest only; controller / routes / page deferred to Phase 2.

---

## 11. Non-Functional Requirements

- **NFR-1 Stack:** Laravel 13.8, Inertia v3, React 19.2, Tailwind v4, MySQL 8.4; models attribute-driven.
- **NFR-2 Security (current honest posture):** routes are auth-gated but **not role-gated** (closures skip the Policy, §4.4); no data is exposed because no props are served. Phase 2 activates per-action `authorize()` + the approval-pm menu-link gate.
- **NFR-3 Performance (Phase 2 design):** list pagination capped at 20/page; approval queue capped at 200 rows with a logged warning; child rows fetched in two grouped queries (no N+1); `canAccessMenuLink` memoized per request.
- **NFR-4 Maintainability:** module deliberately replicates the Quotation template so the two transactional modules stay structurally interchangeable; deviations (no model hasMany, no money columns) are documented here.
- **NFR-5 Platform:** responsive web admin only; List table `min-w-[920px]`, ApprovalPm collapses to single column ≤1180px.

---

## 12. Acceptance Criteria / Test Coverage

**Shipped suite (2026-06-11): 18 Pest tests / 181 assertions, all green** — `SampleOrderListTest`, `SampleOrderDetailTest`, `SampleOrderApprovalPmTest`, plus a new `SampleOrderFactory` (covers all 24 NOT NULL `sampleorder` columns, verified via `SHOW COLUMNS`). Per project conventions: Pest + `DatabaseTransactions` against the dev DB, `actingAsAdmin()`, `assertInertia` with the `MenuSampleOrders/...` component prefix. Coverage: index renders paginated `sampleOrders` + filter echo; search narrows and datetimes trim to the date part; per-page/sort allowlists (incl. malicious sort-key fallback); `sampleList` contents and empty case; show presents sections/totals and legacy-parity line items (per-line history, blank-lot rule) with newest-first header history; 404 on unknown and soft-deleted ids; approval-pm queue unscoped vs head-div-scoped (`scoped` flag) and contains only status-1 orders; guests redirected to login; ungranted roles (RoleID 2) receive 403 on all three endpoints.

**Create-pass suite (2026-06-11): `SampleOrderCreateTest` — 10 Pest tests / 66 assertions, all green.** Coverage: guest redirects (create + store), 403 for ungranted roles, create-page props (companies/options/gate), 4-table store happy path asserting the derived header fields (sales snapshot, T&C, CP denormalization, NULL "none" FKs), the legacy required-field set, CP-of-another-company rejection, CP-completeness rejection, lot stock cap, and the gate blocking store. Full project suite after the pass: 299 passed; 2 pre-existing failures unrelated to this module (#40, #41).

**Actions-pass suite (2026-06-12): `SampleOrderActionsTest` — 13 Pest tests; module suite 58 passed / 1 skipped (475 assertions).** Coverage: guest redirects + ungranted 403 on all three actions, cancel happy path (header 11 / details 9 / both assignment rows with the fixed comment), past-Packing cancel refusal, out-of-view-scope 404, caps-less view 403, send-email set/clear with **no** history rows, change-status legacy map + comment recording, un-cancel from 11, validation (missing comment, soft-deleted target 10), past-Packing target block, and the three verified stock paths (6→1 restore, 8→6 re-deduct skipping `QtReceived>0` lines, 8→1 no-op). `SampleOrderApprovalPmTest` rewritten for the STRICT queue (BR-14): empty without principals, principal-matched composition (in-scope vs wrong-status vs detail-less orders).

**PM actions suite (2026-06-12): `SampleOrderApprovalPmActTest` — 8 Pest tests; module suite 65 passed / 2 skipped (513 assertions).** Coverage: gating, partial→full approve with the header-promotion rule, whole-order revise/reject from one checked line, all-or-nothing mixed batch, write-time principal-scope refusal, header-moved-on refusal, required comment, and the queue's per-line `canAct` annotation.

**Surat Jalan suite (2026-06-12): `SampleOrderSuratJalanTest` — 6 Pest tests; module suite 78 passed / 2 skipped (634 assertions).** Coverage: gating, queue lists status 6 AND 7 ordered 6-before-7 with line counts + statusId (excludes other statuses), the 6/7-only screen contract (status-3 lines, 404 otherwise), the full generate path (header 7 + commented assignment, numbered suratjalan row, suratjalandetails copies, lines 3→4 with '-' remarks, unrelated lines untouched, **redirect to list**), the generate guards (status 7 / no lines / missing comment — no orphan SJ row), and the AWB update incl. ServiceName='REX' derivation, **the suratjalan-row sync**, and the 6/7 contract.

**Packing suite (2026-06-12): `SampleOrderPackingTest` — 7 Pest tests; module suite 72 passed / 2 skipped (576 assertions).** Coverage: menu-grant gating (admin 403 without the row), unscoped status-2 queue with packable-line counts, the screen's line filter + lot-dropdown contract + requested-lot preselect, the full pack-with-split path (header 6, lot assigned, stock −, FULL-copy split order with preserved Industry/Project/requested-lot/remarks/status, 'Input By System' rows, old line → 7), pack-without-split, every server-side lot rule (≥1, duplicates, insufficient stock, coverage, comment, non-status-2 header), and whole-order revise/reject incl. the status-2 contract.

**Good Issue backend suite (2026-06-26): `SampleOrderGoodIssueTest` — 12 Pest tests (87 assertions); module suite 91 passed / 2 skipped. BACKEND ONLY — service-level + FormRequest, NO HTTP/route tests (the controller queue/show/process methods, routes, `GoodIssue/Index.jsx`+`Detail.jsx`, and the menu row + grant are the deferred wiring pass — §6.12 stays "build PENDING", NOT shipped).** Revises the 2026-06-18 two-state `goodIssue()`/`SampleOrderGoodIssueRequest` to the locked three-state model (BR-22). Coverage: `rules()` validated via a `Validator` (per-line `state` ∈ {transit, received, not_received} + `qty`, required comment; rejects unknown state / missing state / missing comment / non-numeric / negative qty); and `SampleOrderTransitionService::goodIssue()` called directly asserting DB state — header promotes **7→8 only when no line stays in transit** (else stays 7 with the round logged at status 7), received & not-received lines → detail 5 (not-received returns the FULL lot stock, received keeps it deducted incl. a **partial** received), transit stays at detail 4 with its typed `QtReceived` saved, the **auto-flip** (`qty = Quantity` → received + promote), **multi-round** receipt across two visits, per-line history remarks recording state + QtReceived (replacing the legacy `'-'`), and the guards (header-7 only, exact posted-set coverage, received qty > 0 and ≤ ordered, no status-4 lines). The §6.8 backward stock-compensation (`adjustUnreceivedLotStock`, keyed on `QtReceived=0`) is **left untouched** and flagged in-code for re-verification now that a transit line can carry `QtReceived > 0`. Also fixed two pre-existing paginator-extraction breaks in `SampleOrderPackingTest`/`SampleOrderSuratJalanTest` (read `props.sampleOrders.data`, not the paginator wrapper keys).

**Good Issue stock-fix + Feedback backend (2026-06-29): module suite 103 passed / 1 skipped (766 assertions).** Phase 1A — an adversarial audit of `goodIssue()` surfaced a HIGH **phantom over-credit**: multi-round receipt can leave an already-returned *Not Received* line (detail 5 / `QtReceived 0`) under a header still at 7, and the §6.8 backward stock-compensation re-credited it on a Change-Status 7→1/2/3 (it keyed on `QtReceived=0`). Fixed by re-keying the **restore** branch on **detail status 3/4** (which also closes the documented transit under-credit); a regression test was added to `SampleOrderActionsTest` (header-7 not_received + transit line → Change-Status 7→3 credits the returned lot **once** and restores the transit lot), plus two `SampleOrderGoodIssueTest` coverage tests (not_received force-`QtReceived`-to-0 with a typed positive qty; the service-level negative-qty guard). Phase 1B — **Feedback (8→9)** backend built: `SampleOrderTransitionService::feedback()` + `SampleOrderFeedbackRequest` + `SampleOrderFeedbackTest` (9 tests — FormRequest required-active-status / required-lines / optional remark+comment; service finalize 8→9 setting `SampleOrderFeedbackStatusID` + lines 5→6 + per-line `FeedbackRemark` + header/per-line assignments; guards: non-8 header, invalid status, no status-5 lines, posted-set mismatch). **BACKEND ONLY** — no controller / routes / pages / menu (the Phase-2 wiring pass; §6.12 + §6.13 stay "wiring deferred").

**Sample Order Request vs Stock report (2026-06-30): `SampleOrderRequestVsStockTest` — 4 Pest; full SampleOrder module suite 132 passed / 1 skipped (949 assertions).** Faithful port of `listsampledetailsvsstock.php` (§6.15): per-line list of status-1/2/3 detail lines beside current lot stock. `SampleOrderController::requestVsStock` + `stockByBarang` (one grouped baranglists query, not the legacy N+1), route `sample-orders.request-vs-stock`, new `RequestVsStock/List.jsx`, menu 3255. Sales = `company.UserIDSales` (legacy-literal, user-confirmed). Export to Excel/CSV deferred. Coverage: gate (guest/ungranted), per-line shape + populated stock column, status-1/2/3 in/out scope.

**Sample Order View Details family (2026-06-30): `SampleOrderViewDetailsTest` — 7 Pest; full SampleOrder module suite 126 passed / 1 skipped (912 assertions).** Five read-only line-item views (§6.14) sharing `buildDetailListResponse`, differing only by the per-view WHERE. Coverage: guest redirect, ungranted-role 403 (per-view menu gate), per-line row shape + `title`/`drillIn` props, **pm**/**mm** LINE-level principal scope (in-scope line present, out-of-scope absent, `all` carries both) verified via a narrow-scope real non-admin user, and **sm**/**head** ORDER-level scope (in/out division & subtree). Faithful mirror of the LWR/Quotation View Details pattern; menus 3028–3032 seeded + legacy grants mirrored.

**Sample Order Good Issue + Feedback WIRING (Phase 2, 2026-06-29): module suite 111 passed / 1 skipped (810 assertions).** Completes the §6.12 / §6.13 wiring deferred earlier the same day. `SampleOrderController` gained queue/show/process methods for both stages (Good Issue **mine-or-admin**, Feedback **creator-only** — scope re-checked at write time), six additive routes (`sample-orders.good-issue[.show|.process]`, `sample-orders.feedback[.show|.process]`, mirroring Surat Jalan), the new `Feedback/Index.jsx` + `Detail.jsx` pages (mirroring the existing GoodIssue pages), and 8 HTTP feature tests (4 each: guest/ungranted gating, queue composition + scope, show + 404, process happy path) self-granting via `soGrantViewMenu`. Menu rows **2923** (`/sample-orders/good-issue`) + **2924** (`/sample-orders/feedback`) inserted under group 24 (sort 9/10) with RoleID-1 grants; Vite bundle rebuilt. §6.12 + §6.13 now SHIPPED end-to-end.

**Stock ledger fix (`IsStockReturnable`, 2026-06-29): module suite 113 passed / 1 skipped (818 assertions).** The §6.8 backward stock-compensation was re-keyed off mutable detail status / QtReceived onto an explicit per-line ledger flag (additive migration `2026_06_29_130000_add_is_stock_returnable_to_sampleorderdetail`, `hasColumn`-guarded + backfilled — 448 in-flight packed/transit lines flagged 1). Closes the audited **F1** regression (a chained `8→6→1` Change-Status no longer over-credits a consumed received line) and the previously-LOW stale-`BarangListID` path (Good Issue's not_received return is now gated on the flag). Maintained at every stock move: `pack()` → 1, `goodIssue()` received/not_received → 0 / transit → 1, `adjustUnreceivedLotStock()` restore → 0 / deduct → 1. Two regression tests added to `SampleOrderActionsTest` (F1: `8→6→1` received line credited zero times; F2: a partial-transit `QtReceived>0` line restored by the full ordered qty); existing stock paths (6→1, 8→6, 8→1, 7→3) stay green. `SampleOrderDetail` model + `SampleOrderGoodIssueTest` fixtures updated for the flag.

Phase-1 verification was manual/static (2026-06-10): `php -l` on `routes/web.php`; `artisan route:list` confirms the three routes + names; nav tree for RoleID 1 renders `Sample Order > [Sample Orders, Approval PM]`; menu/grant rows verified by direct DB reads.

---

## 13. Edge Cases & Known Constraints

- **Dormant Policy / open URLs — RESOLVED 2026-06-11:** the controller authorizes every action; ungranted roles now receive 403.
- **Orphaned Create copy — RESOLVED 2026-06-11:** `sample-orders.create` now renders the `MenuSampleOrders` Create (converted to `useForm`); the proto original still renders at `proto.sample-orders.create` (retirement decision pending; the legacy reference folder is #29).
- **`route('sample-orders.create')` — EXISTS since 2026-06-11:** the List "New sample order" button points at it.
- **Status-tone gaps:** legacy statuses `Packing/ Prepare`, `Surat Jalan`, `Sample Received` render neutral badges (not in the tone maps); `Done` (status 10) is soft-deleted in the lookup.
- **Duplicate "Feedback" detail-status rows** (IDs 6 and 8) exist in `sampleorderdetailstatus`; display code must not assume name uniqueness.
- **`sampleorderby` has one active row** ("Email") — the prototype Create's hardcoded WhatsApp/Phone/Direct Visit options do **not** exist in the lookup table.
- **Datetime vs date:** `sampleorder.Tanggal` is a datetime (Quotation's equivalent is a date); presenters must trim to the date part and treat `0000-00-00*` as blank.
- **List server round-trips are no-ops — RESOLVED 2026-06-11:** search/sort/pagination now hit `SampleOrderController@index` and return real data.
- **Legacy menu rows under root 24** (20 children, e.g. "Create Sample Order", "Approval PM - Sample Order") look like per-action permission flags carrying multi-role grants. They were deliberately **not** reused for nav (kept dormant, blank `LinkLaravel`) to avoid mutating a potential future permission taxonomy.
- **Change-Status stock compensation (legacy-faithful 2026-06-12; re-keyed onto the `IsStockReturnable` ledger 2026-06-29):** a per-line `sampleorderdetail.IsStockReturnable` flag (1 = currently holding deducted lot stock a backward move should return — packed / in-transit; 0 = pre-packing, received/consumed, or already-returned) is maintained explicitly at every stock move (`pack` → 1; Good Issue received/not_received → 0, transit → 1; Change-Status restore → 0, deduct → 1). The backward **restore** (6/7 → 1/2/3) returns + clears `IsStockReturnable = 1` lines; the **deduct** (8/9 → 6/7) re-deducts the not_received lines (`QtReceived = 0`, lot-assigned) + re-sets the flag. Keying on the explicit ledger (not mutable detail status / QtReceived) is **relabel-proof** — it closes the original HIGH over-credit, the transit under-credit, the audited **F1** chained-`8→6→1` over-credit of a consumed received line, AND the previously-LOW stale-`BarangListID` path (Good Issue's not_received return is now gated on the flag). Residual legacy-faithful quirks (unchanged): a 6/7→8/9 stamp still skips the good-issue stock return (an exotic 6→8→6 bounce double-deducts); a re-deduct may drive `baranglists.Quantity` negative (no guard); lots join unscoped (a soft-deleted lot still moves).

---

## 14. Out of Scope / Future Enhancements

In intended order; only the first item is design-locked.

1. ✅ **Phase 2 — reads (DONE 2026-06-11):** closures swapped for `SampleOrderController` (`index`/`show`/`approvalPm` per §6 contracts) + `SampleOrderPolicy` active + 18-test Pest suite. No model edits, no new routes.
2. ✅ **Create/store pass (DONE 2026-06-11):** routes + `StoreSampleOrderRequest` + transactional 4-table insert + Create page on `useForm` + List button repointed + 10-test suite (issue #30 closed). Split-off remainders: Link-with-Other persistence (#36), To-Project writes (#37), head-division email (#38), store-side company-scope re-check (#39), proto create route retirement (decision pending).
3. **Approve/Reject actions (#31):** ✅ COMPLETE 2026-06-12 — SM stage (§6.7), Detail capability actions (§6.8), and the PM line-grained actions (§6.9). Only the redesigned per-line PM **page** (pure UI) remains; it reuses the shipped endpoint.
4. **Downstream workflow:** ✅ Packing (§6.10) + ✅ Surat Jalan (§6.11) SHIPPED 2026-06-12. Remaining: Sample Received/Good Issue (QtReceived + unreceived-stock return), Feedback — legacy sources already pushed; plus the deferred SJ extras (PDFs, courier API, CP-postcode edit) tracked in #56.
5. **Sample lookup CRUDs under Pengelolaan** (menu 129 children): Sample Delivery, Sample Order Feedback Status, Sample Order By.
6. **Quality follow-ups (#34):** extract the triplicated status-tone map; unify the English/Indonesian string mix; re-theme the Create page to utility/shadcn; widen role grants via the RoleMenus UI when the module goes live for sales/PM users.
