# RESUME — Stock balances customer · stock-card source + print · product filters

**Status: COMPLETE.** 6/6 work packages done. Shipped to `hazemdev2` in both repos, built and
deployed to `/app` (`main-PDPNCUC7.js`). **Nothing pushed, nothing merged** — `/fullpush` is the
owner's step. **Nothing browser-verified** — awaiting the owner's pass.

**Date:** 2026-08-06 · **Install:** moonui2 · **Branch:** `hazemdev2`
**Source:** [`stock-balances-card-and-product-filters.html`](../stock-balances-card-and-product-filters.html)
**Execution record:** [`LEDGER.md`](LEDGER.md) · per-WP detail in [`tasks/`](tasks/)
**Deferred findings:** the Deferrals section of `LEDGER.md` (D1–D5)

---

## The four things the owner asked for, and what shipped

### 1. Stock balances — the customer who owns the item
`/core/stock-balances` now shows **«عميل التصنيع»**, searchable and filterable. `008da5d27` (BE) +
`b817c68d2` (FE).

The design point: the screen **already had an owner concept** — the consignment *custody* owner
(`inventory_lot_balances.owner_partner_id`, who owns the physical quantity), which is a different
thing from `products.toll_customer_id` (who owns the catalogue definition). They can disagree on the
same row. Owner ruling: **two columns, two filters, explicit labels** — «المالك (أمانة)» and
«عميل التصنيع». The incumbent control turned out to have had **no visible caption at all**; giving
both a caption is what makes them read as two questions instead of one duplicated control.

The picker deliberately reuses the products screen's population, **not** `getLotOwners()` — that
lists only partners with live consignment lots and would have silently omitted any toll customer
whose stock is all company-owned.

### 2. Stock card — printing the movements
`ce883a74e`. Report-print pattern (`window.print()` + `@media print`), matching the customer/supplier
statement screens. **The 25-row trap was solved, not worked around**: the on-screen table is
client-paginated so only 25 `<tr>`s ever exist in the DOM and `window.print()` physically cannot reach
the rest. The printout is a **separate screen-hidden sheet** over the complete loaded result set, in
posting order so the balance chain reads as a ledger, with the header stating item, date range and
warehouse — because the card **opens on the current week by default** and a printout that silently
covers one week would be read as the whole history.

### 3. Stock card — the REAL source of each movement
`106976d8e` (BE resolver) + `4c9daa50d` (FE column). A new **«المصدر المباشر»** column; the existing
document column stays (the issue number is what's on the paper).

Proved from live data: product **17139** has 7 issue movements, and all 7 are recorded against
production orders — so the screen was showing 7 delivery notes where the truth was 7 production
orders. Now it shows the order number and links to `/factory/orders/:id`.

The hard part was that a movement's reference is a **convention, not a relation**: ~28 slug values,
no enum, no FK, and `reference_id` means three different things — a document id (majority), a
**business-partner id** (consignment), and a **production-order id** (backflush/staging, 1 hop).
Consignment therefore renders as a **name with no link**; a job tag renders with its **own badge and
its name**, never as an order (it has no GL and no WIP).

### 4. Products screen — the filters
`6e34b024a` fixed the reported bug: the component set `toll_customer_id` and `ProductService.list()`
never serialised it, so the request was **byte-identical to no-filter**. Four lines.

`865ba0983` fixed the nine further defects the audit found — most importantly a search that fired one
request per keystroke **with no ordering guarantee**, so a slow earlier query could repaint the table
over a newer one, and a dead parallel search path that would have silently discarded three filters if
anything ever wired it up.

## Commits

**BE** (`/home/moonui2/moon-erp-be`): `106976d8e` · `008da5d27` · `125846ef6` · `f917928b0`
**FE** (`/home/moonui2/public_html/moon-erp`): `6e34b024a` · `865ba0983` · `4c9daa50d` · `b817c68d2` · `ce883a74e`

## Verification

- **Tests:** `pest Modules/Inventory` **779 passed / 4 failed** (baseline 742/4 — same four
  pre-existing names, re-run in isolation to confirm). `Modules/Production` 661/10, unchanged.
  **Zero new failures.**
- **Batching proven by measurement, not claim:** the movement resolver issues **21 queries for a
  21-row page and 21 for a 50-row page** — asserted equal. The stock-balance eager load: **23 queries
  for 25 rows carrying 25 distinct customers**.
- **The dangerous line was proven dangerous:** WP5a temporarily hoisted the new `orWhereHas` out of
  the `search` closure and watched two guard tests fail (warehouse filter returned 3 ids instead of
  1), then restored. The guards are known-good, not hoped-for.
- **Live server checks (WP5b):** unfiltered **125** rows · `toll_customer_id=52` → **1** ·
  `toll_customer_id=own` → **124**. 124 + 1 = 125, an exact partition. `search=ABDULLAH HAMED`
  returned that customer's item with zero FE change.
- **Filter fix evidenced on real source:** the actual `list()` body was bundled with the repo's own
  esbuild and run in Node to print the genuine query string — before: `page=1&per_page=100` for both
  a customer id and `own`; after: the parameter appears, and a cleared filter omits it.
- **CHANGELOG:** 31 `[Unreleased]` bullets, all bilingual, no stale promises.
- **Build + deploy:** green; deployed to `/app`, `apiUrl` still the moonui2 dev API.

## Owner's acceptance test

1. `/app/core/products` → pick a toll customer. The list must **narrow** and the count must match.
   Then type in the search box — one request per pause, and results must not flicker back to an
   older query.
2. `/app/core/stock-balances` → the customer column shows; its filter and the **«المالك (أمانة)»**
   lens are clearly two different questions; searching a customer's name finds their items.
3. `/app/core/stock-card/17139` → the movements show **أمر إنتاج PRD-2026-…** in the new column, and
   the link opens that order. One row (`GDN-000014`) will show the **issue** instead — see below.
4. Same screen → **print**. Choose a range with more than 25 movements and check the paper row count
   against the count in the sheet's own header. **This is the one thing nobody has seen yet.**

⚠️ **Log out and back in** if testing as a non-owner role — a newly granted permission stays
invisible until then (`cached_user`).

### One row that will look wrong and isn't
`GDN-000014` references production order **16**, and `production_orders` has **no row with that id** —
not soft-deleted, no row at all. There is no FK constraint on this reference, so the database cannot
prevent it. The card correctly falls back to showing the issue. Recorded as Deferral D2; worth a
one-off sweep for other dangling references before the source column is treated as authoritative.

## Open — see `LEDGER.md` Deferrals for the full write-up

| | Item |
|---|---|
| 🔴 **D5** | **The app's print-isolation trick is dead** — `body * { visibility: hidden }` compiles to `body[_ngcontent-%COMP%] *` under emulated encapsulation and matches nothing (the string appears in **126 bundle chunks**). The **consignment return slip and the BMR printout have been printing the whole application page** behind the slip. WP6 did not copy it; those two screens still need fixing. |
| 🟠 **D2** | A dangling production-order reference in live data (above). No FK exists to prevent it. |
| 🟡 **D3** | `/core/issue-tags`, `/core/warehouse-transfers`, `/webstore/orders` ignore `?viewId=`, so the new source links land on the list, not the row. The param is already on the URL. |
| 🟡 **D1** | `ProductService.search()` is now dead code repo-wide — the filter-blind method whose re-attachment caused the defect WP2 removed. Delete it or comment why it must never be wired to a filtered screen. |
| 🟡 **D4** | The stock-balances customer picker lists every customer, not only those present on stock (no such endpoint exists). Matches the products screen. |

## Next

Owner runs `/fullpush` (sync + merge to `main` + redeploy), then tests on `/app`.
