# WP5b report — Stock balances: the toll-customer column and filter (UI)

**Status:** DONE · **Repo:** FE `/home/moonui2/public_html/moon-erp` · **Branch:** `hazemdev2`
**Commit:** `b817c68d2` (FE) · plus `125846ef6` (BE, CHANGELOG text only — see §7)
**Build:** `npx ng build --base-href /app/` → green (38.7 s, only the pre-existing CommonJS
warnings for `qrcode` / `file-saver` / `html2canvas`). `npx tsc --noEmit` → clean, exit 0.
**Not pushed, not merged. Not deployed to `/app`** — the orchestrator deploys.

---

## 1. The contract — verified against the live API, not assumed

Before writing a line I hit `https://moonui2.elbaset.com/moon-erp-be/api/inventory/stock-balances`
with a throwaway Sanctum token for user 3 (company 4), which was revoked afterwards.

| Request | Result |
|---|---|
| (no filter) | `meta.total` = **125** |
| `?toll_customer_id=52` | **1** row — product 17168 `NOR-00001`, `toll_customer_id: 52`, `toll_customer_name: "ABDULLAH HAMED SALEEM AL SAHLY"` |
| `?toll_customer_id=own` | **124** rows, every one with `toll_customer_id: null` and `toll_customer_name: null` |
| `?search=ABDULLAH HAMED` | **1** row — the same 17168 |

124 + 1 = 125, so the `own` sentinel and the id filter partition the set exactly.

**Search needs no FE change — confirmed, not assumed.** `?search=ABDULLAH HAMED` (a string
that appears nowhere in the product's name `منتج`, code `NOR-00001` or SKU) returned that
product. The widened server-side `search` works through the existing, untouched `searchText`
box. Nothing was added to the FE for it.

The WP5a contract and the live API **agree** on every point: param name `toll_customer_id`,
the `own` literal, field names `toll_customer_id` / `toll_customer_name`, Arabic-first naming.

### The test data
`moonui2_dev_be` has exactly 2 products with a `toll_customer_id`, as the brief warned:

| product | code | toll customer | stock-balance rows |
|---|---|---|---|
| 17168 | `NOR-00001` | 52 — ABDULLAH HAMED SALEEM AL SAHLY | **1** |
| 17167 | `PRD-17158` | 61 — عبدالحليم محمد يونس | 0 |

Only 17168 is reachable from this screen at all. Every verification above is against it. The
Arabic-name search case has no reachable row here; WP5a covers it with a BE test.

---

## 2. The naming problem — what was actually done about it

This was the point of the WP, so it gets the most space.

| | the OLD control | the NEW control |
|---|---|---|
| means | consignment **custody** — who owns the physical quantity | the **catalogue** owner — who owns the item definition |
| source | `inventory_lot_balances.owner_partner_id` | `products.toll_customer_id` |
| query param | `owner` | `toll_customer_id` |
| URL param | `owner` | `toll_customer` |
| **final label** | **«المالك (أمانة)» / "Owner (consignment)"** | **«عميل التصنيع» / "Toll customer"** |
| picker source | `getLotOwners()` (unchanged) | `PartnerService.listAll()` + `is_customer` |

**The existing control was relabelled, as the brief put in scope.** It previously had *no
visible caption at all* — it was a bare `p-select` whose only text was its current value
(«الكل الفعلي» / «ملكنا فقط» / «🤝 name»). Sitting alone that was survivable; sitting next to
a second ownership picker it would have been indefensible. So **both** pickers now carry a
small caption above them (`.filter-labelled` > `.filter-label`), which is what makes the pair
readable as two questions rather than one duplicated control. That is the only change to the
existing lens — its options, its param, its banner, its column renames are all untouched.

Two new i18n keys, both added to `ar.json` **and** `en.json`:

- `INVENTORY.OWNER_CUSTODY_LABEL` — «المالك (أمانة)» / "Owner (consignment)"
- `INVENTORY.TOLL_CUSTOMER_ALL` — «كل الأصناف» / "All items" (the new picker's placeholder,
  i.e. no filter)

The new picker's own caption and its "own" option **reuse the products screen's existing
keys** — `PRODUCTS.TOLL_CUSTOMER` («عميل التصنيع») and `PRODUCTS.TOLL_CUSTOMER_OWN`
(«موادنا») — deliberately, so the two screens can never drift into calling the same thing
two different names.

`filter-row` moved from `align-items: center` to `flex-end`: the two captioned pickers are
taller than the rest of the bar, so bottom-alignment keeps the whole row on one baseline.

---

## 3. Where the picker options come from — and why not the other place

**Chosen: `PartnerService.listAll()`, filtered on `p.is_customer`, labelled
`name_ar || name` with the material-code prefix in parentheses when there is one.**

This is *character-for-character the same source, filter and label* as the products screen's
own toll-customer picker (`products.component.ts:1148 loadTollCustomers()`). Same population,
same wording, same ordering — a customer that appears in one screen's picker appears in the
other's.

**Rejected: `getLotOwners()` / `/stock-balances/lot-owners`.** It returns only partners with
**live consignment lots**. A toll customer whose items are all company-owned stock — which is
the normal case for toll manufacturing, where the customer owns the *definition* but the
company owns the *material* — holds no consignment lot and would simply be absent from the
list, with no error and nothing on screen to suggest anything was missing. That is the exact
silent-omission failure the brief and the WP5a report both flagged. It is untouched and still
feeds the custody lens only.

`listAll()` (not `list()`) because the API caps a page at 25 and this install has >100
customers. A failed fetch sets the list to `[]` — one empty picker, the rest of the screen
unaffected.

---

## 4. Files changed

### `src/app/core/models/inventory.model.ts`
`StockBalance` gains `toll_customer_id?: number | null` and `toll_customer_name?: string | null`,
optional (the resource emits them behind `whenLoaded`), with a comment naming the distinction
from the `owner_*` / `nearest_expiry_owner_*` fields directly above them.

### `src/app/core/services/stock-balance.service.ts`
`listPage()`'s filter type gains `toll_customer_id?: number | string | null` (string because
`'own'` is a legitimate value). Sent as:

```ts
if (filters.toll_customer_id != null && filters.toll_customer_id !== '') {
  params = params.set('toll_customer_id', String(filters.toll_customer_id));
}
```

`!= null` per the house discipline at line 125 — and the extra `!== ''` guard because the BE
reads the param with `filled()`, so an empty string means "no filter" there; sending it would
be a pointless round-trip through a param the server discards.

### `src/app/features/stock-balances/stock-balances.component.ts`
- `tollCustomerFilter = signal<number | 'own' | null>(null)` — the three-valued state.
- `tollCustomers` + `tollCustomerOptions()` — the `'own'` sentinel prepended to the partner list.
- `loadTollCustomers()` — §3.
- `parseTollParam()` — narrows the URL param. `'own'` is a literal; anything else must be a
  finite number or it becomes `null`. Without this a hand-typed `?toll_customer=xyz` produced
  `NaN`, which is `!= null`, which would light the clear-filters button while the picker showed
  nothing selected.
- `loadPage()` — `if (this.tollCustomerFilter() != null)`. **Presence, not truthiness**: `'own'`
  is truthy so it survives either way, but a truthy check is the kind of thing the next person
  "simplifies" into a bug, so the comment says why.
- `onTollCustomerFilterChange()` — mirrors `onOwnerFilterChange()` exactly: set, invalidate the
  lot cache, reset to page 1, `router.navigate` with `queryParamsHandling: 'merge'`, reload.
- `hasActiveFilters()` — `this.tollCustomerFilter() != null` added.
- `clearFilters()` — resets the signal and nulls `toll_customer` in the URL alongside `owner`.

### `…component.html`
- A block comment above the filter pair spelling out which is which, for the next reader.
- Both ownership pickers wrapped in `.filter-labelled` with a caption.
- The new `p-select`: `[filter]="true" filterBy="label"` (>100 options), `[showClear]="true"`,
  placeholder `INVENTORY.TOLL_CUSTOMER_ALL`.
- New column, **after `PRODUCTS.NAME`, before `INVENTORY.VARIANT`** — it identifies the item,
  so it belongs with the identity columns, not among the quantities.
- Cell: `{{ item.toll_customer_name || '—' }}`, `dir="auto"` (a customer name can be
  Latin-scripted inside an RTL page — partner 52's is).
- `colspan="12"` → `"13"` in both the expansion row and the empty-message row.

### `…component.scss`
`.filter-labelled` / `.filter-label`, `.toll-customer-cell`, the `flex-end` change, and
dark-mode colours for both new classes.

### `src/assets/i18n/{ar,en}.json`
Two keys each, purely additive — `git diff` on both files is exactly `+2 / -0`. No
`checkout`/`restore`/`stash` went anywhere near them.

---

## 5. Acceptance criteria

| # | Criterion | Evidence |
|---|---|---|
| 1 | column shows the name, «—» otherwise | `toll_customer_name \|\| '—'`; live API confirms the name on 17168 and `null` on all 124 others |
| 2 | filter narrows; «own» = items with no toll customer | `?toll_customer_id=52` → 1 · `?toll_customer_id=own` → 124 · unfiltered 125 |
| 3 | the two filters are visibly distinct | both captioned — «المالك (أمانة)» vs «عميل التصنيع»; §2 |
| 4 | search by customer name works | `?search=ABDULLAH HAMED` → the customer's item, **zero FE change**; §1 |
| 5 | a filtered view survives refresh / is shareable | `toll_customer` URL param written by `onTollCustomerFilterChange()`, read by `parseTollParam()` in `ngOnInit` |
| 6 | owner lens, warehouse, category, hide-zero, clear-filters unchanged | only additive edits; the sole change to the existing lens is the caption above it. `clearFilters()` and `hasActiveFilters()` extended, not rewritten |
| 7 | RTL intact, table may scroll but not break out | the table was already `[scrollable]="true"` inside `.table-card`; the new column is one more `<th>/<td>` in that same scroller. `dir="auto"` on the cell |
| 8 | build + typecheck | both green, twice (before and after the `parseTollParam` hardening) |

---

## 6. Noted, not fixed (as instructed)

1. **The branch-restriction paginator over-count.** `loadPage()` (now `ts:~236-239`) filters the
   returned page client-side to the user's warehouses but sets `totalRecords` from `meta.total`,
   so a branch-restricted user sees a paginator that promises more rows than exist.
   **Pre-existing; untouched.** The new filter does not make it worse — it narrows server-side,
   so `meta.total` and the data shrink together, exactly like `warehouse_id` already does.
2. **`base_unit_symbol`** is rendered at `html:~205` but is still not declared on `StockBalance`.
   Pre-existing, not chased. (It survives because the template is not strictly typed against the
   interface for that access path.)
3. **No `toll_customer_id` filter on `byProduct` / `byWarehouse`** — WP5a shipped the fields
   there but not the filter, correctly: both are already scoped to one product / one warehouse.
   No FE caller wants it.
4. **The picker lists every customer, not only customers that appear as toll customers.** With 2
   toll products and >100 customers here, almost every option returns an empty list. That is the
   deliberate trade — it matches the products screen exactly, and the alternative (a "distinct
   toll customers on stock" endpoint) does not exist. WP5a flagged it as a possible small BE
   follow-up; it is not needed for this WP to be correct.

---

## 7. The CHANGELOG (why a BE commit exists in an FE work package)

WP5a's `[Unreleased]` bullet closed with *"The column that shows it on screen arrives with the
next update to the stock-balances screen"* — and its report explicitly said WP5b should adjust
that sentence rather than add a second bullet. That sentence is now false, so under the
deferrals-ledger rule it was replaced (EN and AR) with what actually shipped: the column beside
the item name, the two captioned pickers, and the URL-synced filter. **No new bullet, no other
BE file touched, no code change** — `docs/moonstack/CHANGELOG.md` only, committed separately as
`125846ef6`.

## 8. Housekeeping

- `chown moonui2:moonui2` on all 7 FE files and the BE CHANGELOG. Verified.
- The verification token (`personal_access_tokens.name = 'wp5b-verify'`) was revoked.
- Nothing under `/home/moonui` was read or written. No remote URL printed.
- Both commits are on `hazemdev2`, conventional, **not pushed and not merged**.
