# PRD — Quotation (Transactional) Module

## 1. Document Metadata

| Field | Value |
|---|---|
| **Title** | PRD — Quotation (Transactional) Module |
| **Module** | Quotation (customer price-quote lifecycle: create → Approval PM → Approval SM → Print → Process to Order → Good Receive → Feedback, with Revise / Reject / Recreate / Cancel side-exits) |
| **Owner** | IT — PT Colorindo Chemtra |
| **Date** | 2026-07-16 |
| **Status** | **Shipped and in use** — 16 menu rows, 51 routes, a 4.3k-line `QuotationController`, 288 green Pest tests. **NOT at full legacy parity** — see §3.3 and §14. |
| **Stack** | Laravel 13.8 / Inertia.js v3 / React 19.2 / Tailwind v4 / MySQL 8.4 |
| **Document Type** | **Honest-state PRD.** Documents what is BUILT, what deliberately DIVERGES, and what is MISSING, each with legacy `file:line` evidence. Written on top of a complete 15-area legacy parity audit (2026-07-16) in which every gap was adversarially verified. Nothing here is aspirational: if it says shipped, a test or a cited line proves it. |

> **Why this document exists.** Quotation is the oldest and largest port in the app (the first transactional module; Sample Order and LWR were later copies of its templates). It grew over ~30 incremental passes with a spec+plan per pass, but no single document ever described the whole module — so nobody could answer "does Quotations run like legacy yet?". This PRD is that answer, and §3.3/§14 are deliberately unflattering.

---

## 2. Overview & Purpose

The **Quotation** module issues customer price quotes and carries them through approval, printing, ordering and feedback. It is the commercial core of the app: it is where prices are set, approved by the principal's PM and the division's SM, printed for the customer, converted to an order, and closed out.

A quotation is a **header** (`quotation`) plus **line items** (`quotationdetails`), each with its own status and its own audit trail (`quotationassignment` / `quotationdetailsassignment`). Both header and lines move through a status machine (§5.4). Approval PM is **line-grained** (each principal's head approves only their own products' lines); Approval SM is **header-grained**.

**Live data (dev DB `colorindochemtrainertia`, 2026-07-16):** 297 active quotations · 517 detail lines · 1,300 header history rows · 1,567 line history rows · 26 link rows. Status spread: Request 111, Print 74, Cancel 44, Approval SM 19, Revise 17, Approval PM 12, Good Receive 11, Feedback 3, Process To Order 3, Price Indication 1, Update PO Number 1.

**Construction lineage:** Quotation is the ORIGIN template — `Pages/MenuQuotations/Quotations/{List,Detail}.jsx` and `ApprovalPm/Index.jsx` were later copied to build Sample Order and LWR. Shared shells since 2026-06-14: `Components/MenuQuotations/QuotationListPage/*` (6 list consumers) and `QuotationDetailPage/*`.

---

## 3. Scope

### 3.1 In scope — SHIPPED

14 sidebar menus + 2 hidden capability rows (§4.2), all under sidebar parent **34**, each gated by its own `menu.LinkLaravel` (§4.3):

| Menu | Link | Legacy origin |
|---|---|---|
| Quotations (list + Create + Detail) | `/quotations` | `createquotation.php`, `listquotation.php`* |
| Approval PM | `/quotations/approval-pm` | `approvalpm/listquotationapprovalpm.php` (+view) |
| Approval SM | `/quotations/approval-sm` | `listquotationapprovalsm.php`, `quotationapprovalsm.php` |
| Process to Order | `/quotations/process-to-order` | `processtoorder/quotationprocesstoorder.php` (+2) |
| Good Receive | `/quotations/good-receive` | `goodreceive/quotationgoodreceive.php` (+2) |
| Feedback | `/quotations/feedback` | `Feedback/quotationfeedback.php` (+2) |
| View Head | `/quotations/view-head` | `viewhead/listquotationheaddept.php` (+view) |
| View All | `/quotations/view-all` | `viewall/listquotationall.php` (+view) |
| View Details (+ PM / SM / CS / MM / Head Dept) | `/quotations/view-details[-pm\|-sm\|-cs\|-mm\|-head-dept]` | `viewdetails/*` (13 files) |

\* `listquotation.php` is **not** in `public/referenceonly/` — the base list is the one area with no legacy file to diff against. Treat its parity as **unverified**.

Also shipped: Revise/Recreate (§6.9), Link With (§6.10), Customer AR panel (§6.11), 3 emails (§6.12).

### 3.2 Deliberate divergences (62 audited, documented — do NOT "fix")

Verified against a design doc or an explicit user decision. The load-bearing ones:

- **Approval PM comment REQUIRED** (legacy allowed blank) — spec 2026-07-10.
- **Approval PM quotation-level visibility** — other principals' lines render read-only rather than being hidden; admin god-view. Spec 2026-07-10 §1.
- **PM per-line assignment `Remark` = the comment** (legacy wrote the literal `'0'`).
- **Hardened write guards everywhere** — `lockForUpdate`, in-transaction status/scope re-checks, all-or-nothing batches. Legacy trusted the page and committed per line.
- **Company scope relaxation** — a user whose scope resolves empty sees ALL companies with `companiesScoped=false` (house pattern). ⚠️ **Quotation OPTED OUT of this 2026-07-16** (§6.13 #6): Create/Revise pass `relax:false` (legacy-strict, empty → empty) because the relaxation was a real leak. `CompanyProject` keeps the relaxation.
- **Strict-MySQL adaptations** — `varchar` max lengths, `0` FK → `NULL`, explicit NOT-NULL fills.
- **Emails send N separate messages** via the queue (house pattern), not one shared-To message; bodies hide soft-deleted rows where a few legacy queries did not.
- **PM View Details rows are inert** — legacy assigns `$HrefURL` but never uses it, so its rows don't link either. Correctly preserved (§14 note).

### 3.3 NOT at parity — the honest part

The 2026-07-16 audit inventoried **707 legacy capabilities** across all 15 areas and adversarially verified every claimed gap (23 claims were **refuted** by skeptics and dropped):

| Status | Count |
|---|---|
| ✅ Ported | 419 |
| 🟡 Deliberately diverged (§3.2) | 62 |
| 🟠 **Partial** | 93 |
| 🔴 **Missing** | 133 |
| **Confirmed gaps** | **132** — 10 blocker · 40 major · 65 minor · 17 cosmetic |

**Five blockers were FIXED in the 2026-07-16 pass** (§6.13), plus three follow-on hardening fixes (strict Create scope + server gate, sales-name suffix, and the Last-PM/SM cross-quotation source). **Five blockers remain**, all deferred with issues:

| Blocker | Why deferred | Issue |
|---|---|---|
| All 8 Print/Download actions are `alert()` stubs (PTO, Good Receive, shared Detail) | Needs real dompdf templates | #48, #68 |
| Performa Invoice + Pelunasan print/download stubs — the Pelunasan **field-write + audit row is lost too** | Same | #48 |
| Print never fires the legacy **status → 6 (Print)** transition, so the lifecycle stalls | Consequence of the above | #48 |
| Recreate silently DROPS the company-project link (**70 of 77** linked lines) | Needs a carry-over decision | #134 |
| Revise/Recreate orphans `quotationlink` rows | Same | #134 |

> ⚠️ **Print is the biggest hole.** 74 of 297 live quotations sit at status 6 (Print) — the single largest non-Request bucket. The port cannot *produce* that transition. Users reaching Print/Download today get "Fitur ini belum diintegrasikan".

### 3.4 Out of scope (genuinely absent)

Quotation **Graph** section (#52) · Pivot report (#47) · Export to Excel (#54) · the 12-tab company-360 panel on the detail · legacy `listlinkwithotherpm.php`.

---

## 4. Users & Permissions

### 4.1 Access prerequisite

All 66 routes live inside `Route::middleware('auth')`. Guests → `/login`.

### 4.2 Menu rows (live, dev DB 2026-07-16)

All under parent **34**. Sidebar leaves (`IsShow=1`): 3194 Quotations · 3314 Approval PM · 3791 Approval SM · 4079 Process to Order · 4318 Good Receive · 4319 Feedback · 3959 View Head · 4368 View All · 4356 View Details · 4375 VD PM · 4376 VD Head Dept · 4377 VD SM · 4378 VD CS · 4379 VD MM.

**Capability rows (`IsShow=0`)** — gated via `User::hasMenuGrant()` (which ignores `IsShow`), because `canAccessMenuLink()` requires `IsShow=1`: **4369** Change Status · **4374** Cancel Quotation.

### 4.3 Enforcement

`QuotationPolicy` matches `menu.LinkLaravel` against active `rolemenu` grants. **No admin bypass** — RoleID 1 works only because grant rows exist. Every controller method either `authorize()`s or `abort_unless(canAccessMenuLink(...), 403)`.

### 4.4 Data scope per menu — **CORRECTED 2026-07-16**

Each menu's row scope, now verified line-by-line against its legacy file:

| Menu | Legacy scope | Legacy ref | Port |
|---|---|---|---|
| View Details (base) | none — unscoped | `listquotationdetailslistsview.php` (no join) | ✅ unscoped |
| View Details **PM** | `userprincipal.IsHeadDiv=1` | `…viewpm.php:381,975` (ACTIVE) | ✅ |
| View Details **MM** | `userdivision.IsReadReport=1` | `…viewmm.php:133` | ✅ |
| View Details **Head Dept** | `UserIDInput` OR `company.UserIDSales` ∈ sales tree | `…headdeptview.php:190` | ✅ |
| View Details **SM** | `userdivision.IsHeadDiv=1` | `…viewsm.php:190` (ACTIVE) | ✅ **fixed 2026-07-16** |
| View Details **CS** | `userdivision` membership (no flag) | `…listcsview.php:193` (ACTIVE) | ✅ **fixed 2026-07-16** |
| Approval PM | head principals | | ✅ (quotation-level, §3.2) |
| Approval SM | head divisions, strict | | ✅ |
| View Head | recursive sales tree | | ✅ |
| Create/Revise company list | sales tree ∪ head-div ∪ `companyaccessothers` | `createquotation.php:3385-3413` | ✅ **fixed 2026-07-16** — dropdown scoped (legacy-strict, no relaxation) AND `store()` re-checks server-side (403), since the dropdown is only UI |

> ⚠️ **Trap for future work.** PM's legacy file contains **commented-out** scope joins at `…viewpm.php:125-126,139` *and* live ones at `:381,975`. SM/CS have live joins that an earlier pass misread as absent — the controller docblock asserted "base / SM / CS — no scope", and a test asserted the leak was correct. **Only the legacy source is authoritative; never trust this port's own comments or tests as evidence of legacy behavior.**

---

## 5. Data Model

Existing production schema; every model is `$timestamps = false`, PK `ID`, soft-delete via `IsDeleted` + `NotDeletedScope`.

### 5.1 `quotation` (header) — `App\Models\Quotation`

41 columns. Key: `ID` · `IsDeleted` · `QuotationStatusID` → `quotationstatus` · `ReferenceQuotationID` (self-FK, set by Recreate) · `QuotationSubjectID` / `QuotationTypeID` / `QuotationTitleID` · `Tanggal` (datetime, created) · `QuotationDate` (date) · `ProjectID` → `companyproject` · `CompanyID` / `CompanyCPID` · `DivisionID` · `IndustryID` (**varchar(100)**, not int — anomaly) · `DeliveryAddressID`/`DeliveryAddress` + `InvoiceAddressID`/`InvoiceAddress` (id + denormalised text) · `SnKQuotationCompany` varchar(255) (**always written `''`** — §14) · `CustomerPONo` / `PODate` · `UserIDInput` / `UserIDSales` · `USDRate` decimal(20,10) · `IsOrder` · `DeliveryDate` · `Comment` · 6 footer-term FKs (`QuotationPriceDescID`, `QuotationSalesTermID`, `QuotationPaymentTermID`, `QuotationDeliveryTimeID`, `QuotationValidityID`, `QuotationStockAvailibilityID` — *sic*, misspelled in DB) · `QuotationFeedbackStatusID` · `DeliveryFee` · `DPPercentage` / `DPPriceUSD` / `DPPriceIDR` · `TotalUSD` / `TotalIDR`.

### 5.2 `quotationdetails` (lines) — `App\Models\QuotationDetails`

24 columns: `ID` · `IsDeleted` · `QuotationID` · `QuotationDetailStatusID` · `ProjectID` (→ `companyprojectdetailcc`, repointed 2026-08-06 — was wrongly `companyproject`; see the migration docblock) · `ProjectIDDetails` (dormant, FK dropped same date) · `ItemOriginID` · `BarangID` → `barang` · `ApplicationID` · `PrincipalPrintName` · `ProductName` · `QuantityPacking` · `QuotationPackID` / `PackingID` / `SatuanID` · `USDRate` · `UnitPriceUSD` / `UnitPriceIDR` · `SubtotalUSD` / `SubtotalIDR` · `OrderQuantity` / `SatuanOrderQty` · `Remarks` / `RemarkInternal`.

### 5.3 History + links

- `quotationassignment` — `ID, IsDeleted, QuotationID, QuotationStatusID, Tanggal, UserID, Comment(500)`. One row per header transition.
- `quotationdetailsassignment` — same shape per line (`QuotationDetailID`, `Remark`, `RemarkInternal`).
- `quotationlink` — `ID, IsDeleted, InsertDate, QuotationID, QuotationWith, QuotationWithID`. `QuotationWith`: **1=LWR, 3=Sample, 4=Visit**.

### 5.4 Status lookups (live rows)

**`quotationstatus`** (header, all `IsDeleted=0`): 1 Request · 2 Approval SM · 3 Approval PM · 4 Revise · 5 Reject · 6 Print · 7 Feedback · 8 Cancel · 9 Update PO Number · 10 Update Delivery Fee · 11 Process To Order · 12 Good Shipped · 13 Good Receive · 14 Price Indication.

**`quotationdetailstatus`** (lines): 1 Request · 2 Approval PM · 3 Approval SM · 4 Revise · 5 Reject · 6 Print · 7 Feedback · 8 Recreate Quotation · 9 Cancel · 10 Process To Order · 11 Good Shipped · 12 Good Receive · 13 Price Indication.
> ⚠️ **Anomaly:** `quotationdetailstatus` has **no `IsDeleted` column** and its name column is `StatusDetailName` (not `StatusName`) — the model carries no `NotDeletedScope`.

**`quotationfeedbackstatus`**: 1 Follow up · 3 Proceed (2, 4 are `IsDeleted=1`).

> **Status names mean "approved by", not "waiting for"** — the same semantics as Sample Order. PM approves lines while the header sits at 1 → 3; SM then approves the header → 2.

---

## 6. Functional Requirements

### 6.1 Quotations list — `/quotations`

Server-paginated list on the `QuotationListPage` shell (6 consumers): allowlisted sort, LIKE search, per-page 5/10/15/20, status pills, column customization. ⚠️ Legacy `listquotation.php` is absent from `referenceonly/` — parity **unverified**.

### 6.2 Create — `Quotations/Create.jsx`

6 sections (Header / Customer / Exchange Rate / PO Details / Product Items / Footer) + modal line editor.

- **Company dropdown** — scoped: sales tree ∪ head-div ∪ `companyaccessothers`, relaxing to all + `companiesScoped=false` when empty (**fixed 2026-07-16**).
- Company pick cascades Division + Industry (derived server-side), CP list, CP addresses, and **footer-term autofill from the company's last quotation**.
- **IsOrder** — a subject flagged `IsOrder=1` force-checks it (server re-forces in `StoreQuotationRequest`), gating Delivery Date, delivery/invoice address, Order Qty + Satuan.
  ⚠️ **The flag is DATA, and data is how this silently breaks.** The three layers (options payload, `Create*.jsx` switch, `StoreQuotationRequest::prepareForValidation()`) are all correct, but they do nothing unless `quotationsubject.IsOrder = 1` on the right rows. That flag was set in dev by #28 in June 2026 and was **wiped by a later reload of the legacy dump** — the whole feature read as "broken" for weeks while the code was fine. Re-set on 2026-08-07 for ID 5 (Confirmed Order) and 6 (Is Order); every other subject is 0. `quotationsubject` has **no maintenance menu**, so the only way to change it is SQL. Production UPDATE is still an open item on #28.
- **In-app guide** — a `?` in the page header opens the docked "How to" panel: **25 steps covering every input on the form**, English, each pinned on its real control, including the four inside the Add Product dialog and the three that only exist once Is Order is on. Content: `resources/js/lib/tutorials/quotations/create.js`, resolved by route name, so **Revise and Create-for-Others deliberately do NOT show it**. Shipped 2026-08-07 as the pilot for #309 → `.claude/rules/tutorial-panel.md`. ⚠️ Coverage is deliberately total — a guide that skips fields teaches the reader those fields don't matter, and four of the ones the first draft skipped (*Type*, *Quotation Title*, *Date*, *Delivery Date*) are required. Only *PO Date* is folded into a neighbouring step, because v1/v2 and v3 order it differently. **Open, postponed:** the workflow diagram's path is to become editable from the DB (a path, never a blob) — see the rules file.
- Line modal: principal → product cascade, `PrincipalPrintName`, packing → satuan reseed, USD price → live IDR + subtotal at the header `USDRate`, Application (division-scoped, autofilled from the product's last ACTIVE quote), remarks.
- **To Project** picker (existing + `new` sentinels) writes the full `companyproject*` graph.
- `store()` — one transaction: `quotation` → `quotationassignment` → per line `quotationdetails` + `quotationdetailsassignment`, totals computed server-side; head-division email dispatched post-commit.
- ❌ Company T&C not shown/stored · ❌ AR credit gate · ❌ linking is display-only · ❌ product history autofill → **#135**.

### 6.3 Shared Detail — `Quotations/Detail.jsx`

Served by `show` / `view-all.show` / `view-details.show`. Header + line table + 4 linked-record tabs + history. Capabilities gated per menu: **Change Status** (4369), **Cancel** (4374), Revise/Recreate, Link With, Update Order.
- ❌ All 8 print/download actions are stubs (#48/#68) · ❌ 12-tab company-360 panel · ❌ VAT-aware DP calculator + DP clamp (present on PTO, absent here) · ⚠️ variant-grant holders get 403 (**#137**).

### 6.4 Approval PM — `ApprovalPm/Index.jsx`

Line-grained queue: header status 1, scoped to the actor's `IsHeadDiv` principals, each line annotated `canAct`.
- **Approve** → checked lines → 2; header → 3 **only once every active line is approved**. **Revise** → checked lines → 4 + header → 4 (siblings untouched) + creator email. No Reject (legacy has none).
- Comment required; all-or-nothing under `lockForUpdate` with per-line scope re-checks.
- **2026-07-16:** the queue no longer fabricates PM/SM notes, History, Remarks or linked-record kinds — empty log now renders empty (§6.13).
- ❌ Price-history column, real last-PM/SM source, sorting, 200-row cap, deep links → **#138**; below-min warning → #122.

### 6.5 Approval SM — `ApprovalSm/*`

Queue = header status 1 (visible) + 3 (actionable), strictly scoped to head divisions. Approve → 2 (+ creator email), Revise → 4, Reject → 5; all details cascade; comment ≤500; Cancel (8) short-circuits.
- **2026-07-16:** revise + reject now email the creator with the legacy subjects (§6.13).
- ❌ Structured filters (date range, Sales, Company, IsOrder, PO Date) · ❌ 12-tab company panel · ❌ credit-limit gate (#43).

### 6.6 Process to Order · 6.7 Good Receive · 6.8 Feedback

PTO: queue + detail with Process / Price Indication / Update / Revise DB actions. Good Receive: receive + update. Feedback: moves to Feedback (7) + records `quotationfeedbackstatus`.
- ❌ **Every print/download on both PTO and Good Receive is a stub**, including the Pelunasan field-write + audit row (#48). ❌ Feedback Graph (#52). ❌ PTO revise email (#134-adjacent; see §6.12).

### 6.9 Revise / Recreate

`reviseCreate`/`reviseStore` clone a quotation into a fresh one (`ReferenceQuotationID` = source), cancelling the source. Company/sales pickers now scoped (**fixed 2026-07-16**, shared `quotationCreateOptions()`).
- ❌ Drops the company-project link (70/77) · ❌ link carry-over · ❌ T&C snapshot → **#134**.

### 6.10 Link With

`quotationlink` + reverse tables; table + create + unlink on the shared detail. ❌ Create-time linking display-only (#135) · ❌ URL column not clickable (#69).

### 6.11 Customer AR (NetSuite) — WIRED 2026-07-31 (issue #208)

`CustomerArController` + `CustomerOutstanding/*` panels on Create / Approval SM / Process to Order.

**No longer RESTlet-deferred.** `NetSuiteArClient` reads the **`netsuite_2`** DB connection
(`ccins-function`) — `getar` (customsearch163), filtered `('entityid','is',<NS code>,'customer')`
exactly as legacy `getnsrestlistar.php:81` does. Confirmed against the legacy source, which was
supplied on 2026-07-31 and now lives (local-only, gitignored) under
`Pages/MenuCompanies/Just An Example, Do not Link/Credit Ceiling/`.

Two facts worth carrying:

- **`getar.formulacurrency` is AGING DAYS, not currency** despite the name — legacy bands it
  0-30 / 31-60 / >60 (`getnsrestlistar.php:118-125`). `CustomerArListService` already read it
  that way; that reading is now confirmed rather than inferred.
- Rows are **`stdClass`**. `CustomerArListService` / `CustomerArPendingService` used
  `Arr::get($row, …)`, which does **not** read an object and returns the default for every
  field — the panels would have rendered all-zero with no error. Fixed to `$row->field ?? …`.

**The panels are empty today**: `getar` holds 0 rows. That is a DATA gap, not a code gap.

⚠️ *(The 2026-07-16 audit's "fabricated AR invoices" claim was refuted — the cited legacy file
does not exist. But **issue #187 found a real version of it**: `NETSUITE_AR_FIXTURE=true` was
serving an invented Rp 988,000,000 credit limit against the real customer name "WARNATAMA
CEMERLANG, PT" on these very panels, unlabelled. Fixed 2026-07-31 — the flag stays usable, but
`Components/NetSuite/DemoDataBadge.jsx` now renders above any panel showing fixture rows, and
`AppServiceProvider` refuses to boot production with any `NETSUITE_*_FIXTURE` on.)*

### 6.12 Emails — 3 of 3 quotation mails ported

All queued (`QUEUE_CONNECTION=database`), dispatched **post-commit**; a worker must run or they sit in `jobs`.

| Legacy fn | Trigger | Laravel |
|---|---|---|
| `sendEmailQuotationToHeadDivision` (L566) | create + recreate → head-div fan-out | `SendQuotationHeadDivisionEmails` → `QuotationToHeadDivisionMail` |
| `sendEmailQuotationToUser` (L773) | **SM approve** → creator | `QuotationApprovedMail` |
| `sendEmailQuoToUserRevise` (L3132) | **PM revise** + **SM revise/reject** → creator | `SendQuotationReviseEmails` → `QuotationRevisedMail` |

❌ Process-to-Order revise still emails nobody.

### 6.13 Parity fixes — SHIPPED 2026-07-16

TDD throughout (RED → GREEN), **16 new tests**, all green; every scope/data fix DIFFERENTIALLY
verified by running the **verbatim legacy SQL beside the ported query** on the real dev DB
(row-sets identical for 12 users on SM/CS and 6 on the Create UNION).

**Five confirmed blockers closed:**

1. **View Details SM scope** — `userdivision.IsHeadDiv=1`. Was leaking every division's prices/PO numbers/internal remarks.
2. **View Details CS scope** — `userdivision` membership. Same leak.
3. **Create + Revise company dropdown scope** — uses `ScopesAccessibleCompanies` (the trait already existed and implemented the legacy UNION exactly; `QuotationController` simply never used it). Shared `quotationCreateOptions()` fixes both.
4. **Approval PM fabricated data removed** — `DEMO_PEOPLE`/`DEMO_REMARKS`/`demoDate`/`STATUS_FLOW` and the invented link kind/ref are gone. A PM was signing off prices against invented approver names/remarks with no marker they were fake — the fabrication hit **110/111** PM cells and **111/111** SM cells in live data.
5. **SM revise/reject emails** — `QuotationRevisedMail` gained a `stat` variant carrying the legacy Reject subjects verbatim.

**Follow-on fixes from a second differential-verification pass (same day):**

6. **Create scope made legacy-STRICT + a real server gate.** The trait's empty-scope *relaxation* (show ALL companies) was still leaking: **78/105** users have empty scope and **15** of them can reach Create → they were seeing all 698 companies (legacy shows an empty dropdown). Now `relax:false` for Quotation, and — critically — the dropdown is only UI, so `store()` gained an `abort_unless(canTransactForCompany(...), 403)` server gate AFTER the menu-grant `authorize()`. Without it, anyone could POST any of the 698 ids. `CompanyProject` keeps `relax:true` (unchanged).
7. **Sales-name suffix** — the dropdown now appends " (SalesName)" for other-owned companies (legacy `createquotation.php:3411`), via a nullable `salesName` in the payload.
8. **Last Quotation PM/SM cell now reads the CORRECT source (#138 part).** The cell fetched the *queued quotation's own* log (≈always empty at status 1); it now lazy-fetches **cross-quotation** history per (company, product) from the verified `last-pm/sm-comments` engine — legacy `getlastquotationpm/sm.php(CompanyID,ProductID)`. Real value: **99/171** queue lines have prior PM history, **81/171** have SM, that were previously invisible. Backend exposes `companyId` + per-line `barangId`; an **end-to-end test** proves a queued quotation reaches across to a *different* quotation's prior comment. Authz was a non-issue (every approval-pm role also holds the create grant the endpoint checks). Fetched on first expand (one click) to avoid a burst of XHRs on a 200-row queue — a small, noted divergence from legacy's auto-AJAX.

Also corrected: the `buildDetailListResponse` docblock (which asserted the false "SM/CS unscoped" claim) and `QuotationViewDetailsVariantsTest`, whose test *asserted the leak was correct*.

**Still deferred within #138:** the price-history column (last-10 quotes with prices), column sorting, the 200-row cap surfacing, and linked-record deep links.

---

## 7. Validation Rules

FormRequests only (never inline): `StoreQuotationRequest` · `ReviseQuotationRequest` · `QuotationApprovalPmRequest` (`details[]` required + `comment` required ≤500) · `QuotationApprovalSmRequest` (`comment` ≤500) · `QuotationChangeStatusRequest` · `QuotationFeedbackRequest` · `UpdateQuotationProcessToOrderRequest` · `StoreQuotationLinkRequest`.

Server always re-derives/re-forces: Division + Industry from the company, `IsOrder` from the subject, `QuantityPacking` from packing, totals from lines. FK existence via `Rule::exists(... IsDeleted=0)`.

❌ Known relaxations vs legacy: Application not required per line; Delivery/PO Date silently default to Quotation Date (§14).

---

## 8. Routes / API

**51 routes**, all `auth`. (`php artisan route:list --path=quotation` reports 66 — the extra 15 are `/proto/quotations/*` prototype scaffolds, which are auth-only and sit OUTSIDE the rolemenu system; do not count or gate them as module routes.) Shape:
`quotations.{index,create,store,show}` · `.revise.{create,store}` · `.{cancel,change-status,update-order}` · `.links.{options,store,destroy}` · `.approval-pm[.act]` · `.approval-sm[.show,.approve,.revise,.reject]` · `.process-to-order[.show,.process,.price-indication,.update,.revise]` · `.good-receive[.show,.receive,.update]` · `.feedback[.show,.submit]` · `.view-head` · `.view-all[.show]` · `.view-details[.show]` + 5 variants · JSON option endpoints (`products.byPrincipal`, `products.{barang}.last-quote|last-application|last-pm-comments|last-sm-comments`, `companies.{company}.last-footer-terms`, `applications.byCompany`, `projects.byCompany`, `related.{type}`).

---

## 9. UI / UX

shadcn/ui + design tokens; no hardcoded hex; brand `#57008b`; body 14px. Shared shells: `QuotationListPage` (list/sort/search/pills/columns) and `QuotationDetailPage` (+ `ChangeStatusDialog`, `LinkWithModal`, `PrintActionButtons`, history popovers). Flash: `flash.success/error` = inline banner; `flash.toast` = toast — never both for one action.

**In-app guide (Create only, shipped 2026-08-07).** The `?` chip sits in the **page header**, rightmost group, immediately left of Back — never in a list toolbar (it is a fixed `size-9`, matching the `h-9` header rule; the toolbar is `h-8`). Opening it makes the page yield 376px of right margin rather than being covered, and paints numbered coach marks on the real controls. Pins and panel sit at **`z-[60]`**, above the `z-50` dialog layer, so a step can point inside the Add Product dialog and the panel is not dimmed while it is open. ⚠️ A `position:fixed` overlay does **not** inherit the margin shift, so the 980px Add Product dialog runs under the panel; its left column (where steps 7–10 point) stays clear. Rules + the three measurement traps: `.claude/rules/tutorial-panel.md`.

> ⚠️ **Frontend is served from `public/build`** — there is no dev server. **Rebuild (`npm run build`) after any `.jsx` change** or the stale bundle will look like a permission/logic bug.

---

## 10. Business Rules & Constraints

- **BR-1** PM approval is per LINE; the header advances 1 → 3 only when every active line is approved.
- **BR-2** SM approval is per HEADER; queue shows 1 + 3, only 3 is actionable.
- **BR-3** Cancel (8) short-circuits every SM decision before any write — and therefore sends no email.
- **BR-4** Comment is REQUIRED on all PM/SM decisions (stricter than legacy).
- **BR-5** Batches are all-or-nothing under `lockForUpdate`; scope + status re-checked in-transaction.
- **BR-6** A subject flagged `IsOrder` forces `IsOrder=1` server-side. **Live only where the data says so** — `quotationsubject.IsOrder=1` on ID 5 + 6 in dev; not yet applied in production (#28). With every row at 0 the rule is inert and Is Order is a plain manual switch, which is exactly how it behaved before 2026-08-07.
- **BR-7** Recreate cancels the source and sets `ReferenceQuotationID`.
- **BR-8** Every write pairs a header/line status change with its `*assignment` audit row.
- **BR-9** No admin bypass — grants are data.
- **BR-10** Data scope is per menu and comes from the legacy file, not from convention (§4.4).
- **BR-11** Emails dispatch post-commit, queued, null-guarded on a missing address.

---

## 11. Non-Functional

Grouped queries (no N+1) on list/detail/queue payloads; allowlisted sort/filter columns (no raw SQL from input); `DB::transaction` + row locks on every multi-table write; soft-delete everywhere; DOMPurify required for any raw HTML render.
⚠️ Approval PM caps at 200 rows and only logs server-side (#138). ⚠️ `MAIL_MAILER=log` — nothing is delivered until SMTP + a queue worker are configured.

---

## 12. Acceptance Criteria / Test Coverage

**293 Pest tests green, 1,925 assertions** (`php artisan test --filter=Quotation`), 27 files. Full suite: 1,167 tests, 61 failures — **all pre-existing and unrelated** (SampleOrder 54, VisitPlan 3, Bank, CompanyProductPivot, Example, LabWorkRequest; see #74/#45). Zero Quotation/CompanyProject failures; the 16 tests added this session are all green.

Tests run against the **dev MySQL DB** inside `DatabaseTransactions`; `User::factory()->makeOne(['ID' => …])` for `actingAs`; unique `TEST…` prefixes.

Added 2026-07-16 (16): SM head-div scope · CS membership scope · base-unscoped control · Create company scope · Create legacy-strict empty list · **store() FORBIDS out-of-scope company (403)** · store() allows own company · falls-back-to-creator (rewired fixture) · sales-name suffix · SM revise subject · SM reject subject · SM approve sends no revise mail · cancelled sends no mail · queue exposes companyId + barangId · **end-to-end PM/SM cross-quotation wiring**.

> The 2026-07-16 scope/data fixes were additionally **differential-tested**: the verbatim legacy SQL was run beside each ported query on the real dev DB, and the row-sets matched exactly (SM/CS for 12 users, Create UNION for 6). A green Pest suite alone is NOT parity evidence (see the warning above) — the differential check is.

> ⚠️ **A green suite is NOT evidence of legacy parity.** 280 tests were green while three cross-division leaks and a fabricated-data queue were live — one test actively asserted a leak was correct. Tests prove the port does what it was *designed* to do. Only a diff against `referenceonly/` proves parity.

**No JS test harness exists** (no vitest/jest). The §6.13 frontend fix was verified by build + grep, not a unit test — adding a harness needs a decision (CLAUDE.md rule #24).

**Create-page guide (2026-08-07).** `tests/Unit/TutorialGuideTargetsTest.php` — 3 tests, 29 assertions — reads the guide module and the four files it declares and asserts every `data-tut` selector still exists, that every registry key is a real route name, and that the panel has not slid back into `Proto/`. **Seen to fail** by deleting `data-tut="product-qty"`, then restored; repeat that after changing it. It cannot catch a renamed button *label* making the prose wrong — nothing can, which is why the content sits next to the code. Browser verification (headless Chromium, 41 checks: pin-to-target alignment within 2px, z-order vs the dialog, v2/v3 parity) was run from a scratchpad script, **not committed** — there is no e2e harness in this repo.

---

## 13. Edge Cases & Known Constraints

- `quotationdetailstatus` has no `IsDeleted` and uses `StatusDetailName`.
- `quotation.IndustryID` is **varchar(100)**, not an int FK.
- `QuotationStockAvailibilityID` is misspelled in the DB — keep it.
- Dev-DB `company` rows carry FK-invalid `IndustryID`s: **any UPDATE on them fails**. Tests must use `Company::factory()` (`ownedBy()`), not mutate legacy rows.
- Empty company scope → relaxation, not an empty dropdown (§3.2).
- Detail reports do **not** exclude lines whose PARENT quotation is soft-deleted (the legacy join filters only `qtd.IsDeleted`) — faithfully preserved. Latent today: the dev DB has 1 soft-deleted quotation and **0** active lines under it, so the divergence is currently invisible and would only surface once headers start being soft-deleted.
- Legacy `sendEmailQuoToUserRevise` contains a real bug: `elseif($stat="Reject")` is an **assignment**, so every non-Revise value lands in Reject. `QuotationRevisedMail` reproduces the reachable behavior.

---

## 14. Out of Scope / Future Enhancements

All 132 confirmed gaps are tracked. Filed 2026-07-16: **#134** (recreate drops project link + orphans links + T&C snapshot) · **#135** (Create: T&C, AR gate, real linking, product autofill) · **#136** (list filters: Company CP + Customer PO Date across 6 menus, per-page 50/100, search semantics) · **#137** (View Details variant grant → 403 on detail) · **#138** (Approval PM decision-support: price history, real last-PM/SM, sorting, 200 cap, deep links).

Pre-existing: #42/#43 (AR gate, approval emails) · #44 · #47 (Pivot/Graph/Export on View Head) · #48 + #68 (**all PDF/print** — the largest remaining item) · #52 (Graph) · #54 (Export) · #57/#59 (shell conversions) · #64/#65/#66/#67 · #69 (clickable links) · #75 (NetSuite RESTlet) · #122 (below-min price).

**The 82 minor/cosmetic gaps** are catalogued in the audit artifacts and mostly cluster into four recurring themes, each needing ONE decision rather than 82:
1. **Items-per-page** — legacy offered 10/20/50/100 + `$_SESSION['npp']` persistence; port caps at 20 (7 menus).
2. **Quotation No search** — legacy matched any ID *containing* the digits; port is exact-match (6 menus).
3. **Company / Company CP / PO Date filters** — dropped or demoted to free-text (6 menus) → #136.
4. **Sortable columns** — `CompanyCategory` / `IsOrder` not sortable (4 menus).

**Create page — RESOLVED 2026-08-11.** `CreateV2.jsx` won and now serves all three routes (`quotations.create`, `quotations.create-for-others`, `quotations.revise.create`). `Create.jsx` (v1) is kept as a frozen design reference, reachable only at `/proto/quotations/create-v1` — gated by the Quotation create ability, and 404 in production like the rest of `/proto`. The `QuotationVersionSwitch` component, the `quotations/create/v2` route and the `quotations.create.v2` guide key are all deleted. **Do not edit `Create.jsx` to keep it in step with v2** — it is a snapshot, and it is registered in the hidden-feature register (GH #380).

`CreateV3.jsx` was the third variant and is **gone** (2026-08-07): page, controller action, route, its test case, its `quotations.create.v3` registry key, and its entry in the guide's `files` list all removed together. That set is the checklist for retiring the remaining variant — miss the `files` entry and `TutorialGuideTargetsTest` fails, miss the registry key and it silently points at nothing.

Others: `QtyPacking` renders `—` instead of order quantity when `QuantityPacking` is 0 · CP salutation prefix dropped · `SnKQuotationCompany` warning never rendered anywhere · Recreate has no confirmation before cancelling the source · a "New Quotation" button appears on read-only View Head.

**Do NOT "fix":** PM View Details inert rows (legacy is inert too) · the 62 divergences in §3.2.

---

## Appendix — Audit provenance

Method: 15 area agents each read their legacy file(s) in full and diffed against the port, citing `file:line` on both sides; every claimed gap then went to an independent skeptic instructed to REFUTE it and to default to "refuted" when uncertain. **23 claims were refuted and dropped**, including a "fabricated NetSuite AR data" blocker (cited legacy file does not exist), a "PM rows have no detail link" gap (legacy rows are inert), and a "Create deep-link absent" gap (already tracked as #107/#117). Three blockers were additionally hand-verified against the legacy source before any fix.

Artifacts (session scratchpad, not committed): `quotation_audit.json`, `quotation_audit_part2.json`, `part2_verified.json`, `confirmed_all.json`.

**Standing rule this audit established:** when checking legacy scope, grep for BOTH active and commented-out joins — `viewdetails/listquotationdetailsviewpm.php` has commented joins at :125-139 *and* live ones at :381/:975. Reading only the first block is exactly how the SM/CS leaks shipped.
