# WP5a — Backend: the toll customer on stock balances (field · filter · search · totals)

**Repo:** BE (`/home/moonui2/moon-erp-be`) · **Branch:** `hazemdev2` · **Depends on WP3** · **Migration:** no · **FE change:** none

## Goal

> «في أرصدة المخزون محتاجين نزود عمود يظهر فيه اسم العميل صاحب المنتج … ويمكن البحث عنها والفلتر فيها»

Expose the product's **toll customer** on the stock-balances endpoint, make it searchable, and add a
filter for it. WP5b renders it.

## ⚠️ The naming trap — read before touching anything

This screen **already has an owner concept, and it is a different one.**

| Concept | Source | Meaning |
|---|---|---|
| the existing `owner` lens · `owner_quantity` · `nearest_expiry_owner_*` | `inventory_lot_balances.owner_partner_id` | who owns the **physical quantity** — consignment stock held for a customer, off the company's books |
| **what this WP adds** | `products.toll_customer_id` | who owns the **catalogue definition** of the item |

They can disagree on the same row. And the existing owner picker (`lotOwners()`, ~line 258) lists only
partners with **live consignment lots** — a toll customer whose stock is all company-owned would never
appear in it.

**Owner decision (§9 Q1, settled): keep them completely separate.** Do **not** extend the `owner`
param, do **not** reuse `owner_*` field names, do **not** merge the pickers. Name the new things
unambiguously (e.g. `toll_customer_id` / `toll_customer_name` for the fields and the filter), matching
the names `ProductResource` already uses.

## What exists (verified — do not re-derive)

- `Modules/Inventory/app/Http/Controllers/StockBalanceController.php` — `index()` (line ~56) builds its
  **own** query on `StockBalance`; it does **not** go through `ProductService::search()`. Eager loads
  `['product.baseUnit', 'productVariant', 'warehouse']` (line ~79).
- Filters read straight off `request()` with **no FormRequest and no validation**: `owner`,
  `warehouse_id`, `product_id`, `category_id`, `search`, `hide_zero`, `below_reorder`.
- `search` (lines ~96-101) is a `whereHas('product', …)` closure over **4 columns**: `name`, `name_ar`,
  `code`, `sku`.
- `StockBalanceResource` — no `toll_customer_id`, no `toll_customer_name`.
- `Modules/Core/app/Models/Product.php:193` — `tollCustomer(): BelongsTo(BusinessPartner::class, 'toll_customer_id')`.
- **The pattern to copy for the search clause** is `ProductService::search()` lines ~192-195:
  `orWhereHas('tollCustomer', fn($q) => $q->where('name','like',…)->orWhere('name_ar','like',…))`.
- **The pattern to copy for the resource fields** is `Modules/Core/app/Http/Resources/ProductResource.php:43-47`
  — `toll_customer_id` plus `toll_customer_name` = `name_ar ?: name`, via `whenLoaded`.

## What to build

1. **Eager load** `product.tollCustomer` on `index()`. Without it, reading the name in the resource is
   **one query per row**. With it, it is one extra query per page — the same profile as
   `product.baseUnit` today. Consider `byProduct()` (~line 472) and `byWarehouse()` (~line 611) too; if
   you decide the field is not needed there, say why.
2. **Resource fields** — `toll_customer_id` + `toll_customer_name`, mirroring `ProductResource`.
3. **Filter** — accept a toll-customer parameter applied as `whereHas('product', …)`. Support an
   `'own'` sentinel meaning "company-owned items only" (`whereNull`), exactly as
   `ProductService::search()` does at lines ~214-218, so the two screens behave the same way.
4. **Search** — add the customer to the existing `search` closure. It must be an `orWhereHas`
   **nested inside** the existing closure, or you will silently turn the whole `where` into an OR and
   break every other filter on the screen. This is the single most dangerous line in the WP.
5. **The totals card must agree with the list.** `companyTotals()` (~line 215) and its consignment
   aggregate (~lines 224-240) use **raw `products as p` joins**, separate from the main query. If the
   new filter is not applied there too, the card will report a different total than the rows shown.
   Apply it, and **prove it with a test**.

## Watch out

- **`below_reorder`** (~line 104) does a raw `join('products', …)` and re-`select('inventory_stock_balances.*')`.
  Combining it with the new `whereHas` is fine, but the `select` reset must survive or eager loading breaks.
- The endpoint **ignores `per_page`** (hardcoded `paginate(25)`); the FE compensates by looping pages.
  Do not change this here.
- No validation exists on any filter param today. If you add validation, do it **only** for your new
  param — silently tightening the others would change behaviour outside this WP.

## Acceptance criteria

1. A stock-balance row for an item with a toll customer returns `toll_customer_id` and
   `toll_customer_name`; an item without one returns null/absent.
2. Searching by the **customer's name** returns that customer's items — and **all other filters still
   apply simultaneously** (assert search + warehouse together; this is the nested-closure trap).
3. The new filter narrows the list to that customer's items; `'own'` returns only items with no toll
   customer.
4. **The totals card matches the filtered list** — a test asserting the card's total equals the sum
   over the filtered rows.
5. **Query count does not scale with rows** — assert a page of 25 rows with customers issues a bounded
   number of queries, and state the number.
6. `hide_zero`, `below_reorder`, `category_id`, `warehouse_id` and the existing `owner` lens are all
   **unchanged** — regression-assert at least `owner` and `below_reorder`, since those touch the same query.
7. `pest Modules/Inventory`: **zero NEW failures** vs `../baseline-inventory.txt`.

## Environment / rules

- Tests: `cd /home/moonui2/moon-erp-be && /opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest --filter='…'`
  (bare `php` is php-cgi → "Undefined constant STDOUT"). Scoped tests only.
- ⛔ **Pest loads every test file into ONE process** — prefix every top-level helper with its file's
  subject. Duplicate top-level function = fatal redeclare, exit 255, zero output (5 occurrences here).
- ⛔ NEVER `migrate:fresh` / `migrate:refresh` / `db:wipe` on `moonui2_dev_be` — not binlogged.
- API auth header is `X-Authorization: Bearer`.
- `./vendor/bin/pint` on touched files only. `chown moonui2:moonui2` every edited file.
  `bash local-deploy.sh` after BE edits.
- Add a bilingual `[Unreleased]` bullet to `docs/moonstack/CHANGELOG.md`.
- Commit on `hazemdev2`, conventional. **Do not push, do not merge.**
- moonui2 ONLY — never `/home/moonui`. Never print a git remote URL.

**Your report must state the exact parameter name and the exact resource field names** — WP5b is
written against them and cannot see your code.

## Out of scope

Any FE change (WP5b) · touching the existing `owner` lens in any way · column reorder/resize on this
screen (owner decision §9 Q5: out of scope) · validating the pre-existing filter params.
