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

**Feature slug:** `stock-card-source-and-filters`
**Started:** 2026-08-06 · **Install:** moonui2 · **Branch:** `hazemdev2` (both repos)
**Source plan:** [`stock-balances-card-and-product-filters.html`](../stock-balances-card-and-product-filters.html)
(URL: `https://moonui2.elbaset.com/knowledge-base/plans/stock-balances-card-and-product-filters.html`)

**Starting HEADs:** BE `718468f09` · FE `571188f1a`

**Owner approval (verbatim):** «نفذ بكل توصياتك وخلص كل المراحل» — 2026-08-06.
That approves **every recommendation in §٩**:

| # | Decision | Ruling |
|---|---|---|
| 1 | The word "owner" is already taken on Stock Balances | **Two separate columns + two separate filters, explicitly named** — «المالك (أمانة)» (existing, lot custody) and «عميل التصنيع» (new, `products.toll_customer_id`). **Never merged.** |
| 2 | Source column | **Add a NEW «المصدر المباشر» column; keep the existing document column.** The issue number is still what's on the paper. |
| 3 | Print | **Print only** (the report `window.print()` + `@media print` pattern). Excel/PDF export is NOT in this run. |
| 4 | Inventory count is 3 hops away | **Do not chase it.** Resolve to the adjustment and stop. |
| 5 | Column reorder/resize on Stock Balances | **Out of scope.** Goes to the backlog if wanted. |

**Owner's acceptance test:** on `/app/core/products` pick a toll customer → the list actually narrows and
the count matches. On `/app/core/stock-balances` a customer column shows, is searchable and filterable,
and is visibly distinct from the consignment-owner lens. On `/app/core/stock-card/17139` the movements
show **أمر إنتاج MO-…** (not GDN-…) in a new direct-source column that links straight to the order, a
job-tagged issue shows the tag's **name**, and the movements print.

---

## Decisions (settled — do NOT re-litigate)

1. **Two owners, never merged** (§9 Q1). `inventory_lot_balances.owner_partner_id` = who owns the
   physical quantity (consignment). `products.toll_customer_id` = who owns the catalogue definition.
   They can disagree on the same row, and the existing owner picker only lists partners with **live
   consignment lots** — so a toll customer whose stock is all company-owned would never appear in it.
   Separate column, separate filter, explicit labels.
2. **The new source column is additive** (§9 Q2). The existing document column stays.
3. **Print, not export** (§9 Q3). Follow the report pattern already used by customer-statements,
   supplier-statements, cash-movement, BMR and the consignment return slip — **not** `PrintService`
   (that is for templated documents) and **not** `ExportService`.
4. **The movement reference is a convention, not a relation.** ~28 slug values, **no enum, no label
   map, no FK**, and `reference_id` has **three different meanings**: a document id (majority), a
   **business-partner id** (`consignment_receipt`, `consignment_return`), and a **production-order id**
   (`production_order`, `production_staging`). Any resolver that assumes "reference_id is a document"
   sends the user to the wrong screen.
5. **`ProductionOrder` has no `name` column** — the display key is `order_number`. A job tag
   (`InventoryIssueTag`) *does* have `name`/`name_ar`. They render differently on purpose.
6. **A job tag is not a production order.** No GL, no WIP, no `MfgMaterialIssue`. It must never be
   labelled as one.
7. **Company scope must be explicit everywhere.** A convention-polymorph reference carries no tenant
   guarantee; the existing precedent asserts `company_id` in memory for exactly this reason.
8. **No new table, no new column, no migration** is needed by this feature.

## Serialization / DAG

**One writer per repo at a time.** BE and FE are different repos → one BE WP may run concurrently
with one FE WP, never two of the same repo.

```
BE lane:  WP3 ──────────────► WP5a
FE lane:  WP1 ──► WP2 ──► WP4 ──► WP5b ──► WP6
                    ▲       ▲        ▲        ▲
                    │       └ needs WP3       └ prints the WP4 column
                    └ WP1 first (same files)
```

## Baseline

`baseline-inventory.txt` in this directory (`pest Modules/Inventory`). Core is known-good at
**719 passed / 10 failed** from 2026-08-04. Green = **zero NEW failures**.

## WP table

| WP | Scope | Repo | Depends | Migration | Status | Commits / result |
|----|-------|------|---------|-----------|--------|------------------|
| WP1 | Products screen: `toll_customer_id` actually reaches the API | FE | — | no | ✅ | `6e34b024a` · +4 lines · build+tsc green |
| WP2 | Products screen: the other 9 filter defects | FE | WP1 | no | ✅ | `865ba0983` · 7 files · build+tsc green |
| WP3 | BE: movement **source resolver** — batched, all ~28 types, translated, company-scoped | BE | — | no | ✅ | `106976d8e` · 18P/140A · 21 queries flat at any page size |
| WP4 | Stock card: new «المصدر المباشر» column with real `routerLink` | FE | WP3 | no | ✅ | `4c9daa50d` · build+tsc green |
| WP5a | BE: `toll_customer_*` on `StockBalanceResource` + filter + search + **totals card** | BE | WP3 | no | ✅ | `008da5d27` · 19 new tests · 779P/4F · 23 queries/25 rows |
| WP5b | Stock balances: customer column + filter UI, distinct from the owner lens | FE | WP4, WP5a | no | ✅ | `b817c68d2` (+ BE `125846ef6` changelog text) |
| WP6 | Stock card: print the movements (report pattern) | FE | WP5b | no | ✅ | `ce883a74e` (+ BE `f917928b0` changelog) |

## Deferrals

**D1 — `ProductService.search()` is now dead code repo-wide.** WP2 removed the products screen's
parallel search machinery (the landmine that would have silently discarded category/status/customer
filters and replaced the paginated table). The **service method** it called survives with zero callers.
Harmless today, but it is precisely the shape that caused the defect: a filter-blind search that
returns a full result set. *Recommendation: delete it, or add a comment naming why it must never be
wired to a filtered screen.* Deliberately left alone — a service-layer deletion is not what WP2 was
scoped to decide.

---

**D2 — a dangling production-order reference in live data.** `inventory_issues.GDN-000014` references
`production_order` **16**, and `production_orders` has **no row with that id** — not soft-deleted, no
row at all. Found by WP3 while validating against the owner's own example item (17139), and confirmed
independently by the orchestrator with a `LEFT JOIN`.

The card handles it correctly (falls back to showing the issue), so nothing is broken. But it means at
least one issue in this database points at an order that no longer exists, and nobody knows how it got
that way — there is **no FK constraint** on the convention-polymorph reference, so the database cannot
prevent it. *Worth a one-off sweep for other dangling references before anyone reads the source column
as authoritative.* Not actioned: it is a data question, not a code change.

---

**D3 — three list screens ignore the `?viewId=` deep-link convention.** `/core/issue-tags`,
`/core/warehouse-transfers` and `/webstore/orders` do not read the param, so the stock card's new
source links land on the list rather than opening the record. The param is already on the URL, so
adding a handler on each screen is all that is needed. Production orders avoided this by linking to
the real record route (`/factory/orders/:id`) instead. Low priority; harmless (the user reaches the
right screen, just not the right row).

---

**D4 — the stock-balances customer picker lists every customer, not only those present on stock.**
It reuses the products screen's population (`PartnerService.listAll()` filtered on `is_customer`), so
on this install almost every option returns zero rows. Deliberate and consistent with the products
screen; a "distinct toll customers appearing on stock" endpoint does not exist. A small BE follow-up
if the empty options become annoying in practice. Not a correctness issue.

---

**D5 — the app's print-isolation trick is DEAD, and two shipped screens print wrong because of it.**
Found by WP6 while following the reference implementation; **verified independently by the
orchestrator in the built bundle.**

`consignment.component.scss:479-497` (and BMR) use the classic
`body * { visibility: hidden } / .x-print, .x-print * { visibility: visible }` isolation. Under
Angular's **emulated view encapsulation** that compiles to `body[_ngcontent-%COMP%] *[_ngcontent-%COMP%]`
— and `<body>` never carries a component's content attribute, so **the selector matches nothing**.
Confirmed: the string `body[_ngcontent-%COMP%]` appears in **126 chunks** of the shipped bundle.

**Consequence:** the consignment return slip and the BMR printout render the entire application page
— sidebar, topbar, filters — behind the slip. Every existing user of those two print buttons has been
getting that.

WP6 did **not** copy the broken trick; it used `::ng-deep` plus `display:none` on an explicit shell
list derived from `main-layout.component.html`. Fixing the two incumbent screens is a separate ticket
— out of scope here, but it is a live defect in shipped features, not a theoretical one.

## Log

- 2026-08-06 — Phase A: ledger + briefs; `pest Modules/Inventory` baseline started.

- 2026-08-06 — Phase A complete: all 6 briefs written. WP1 (FE) + WP3 (BE) dispatched in parallel —
  different repos, so no shared writer.
- 2026-08-06 — **Verified by the orchestrator before dispatch** (so no implementer re-derives or
  guesses it):
  * `products.component.ts:2573` sets `f.toll_customer_id`; `product.service.ts:77` `list()` reads six
    keys and **not** that one — read both directly. The BE side already works
    (`ProductService::search():214-218` handles `<id>` and `'own'`; `tollCustomer` is eager-loaded).
  * **Live data, `moonui2_dev_be`:** product **17139** has 7 `inventory_issue` movements and **all 7
    resolve to `production_order`**; company-wide `inventory_issues.reference_type` = `production_order`
    ×20, `sale` ×20, `job_tag` ×3. So the owner's example is not an edge case — it is the norm.
  * **«تاج تشغيل» is `inventory_issue_tags`** (rows: «امر انتاح111», «اورد 1») — a plain operational
    label with `name`/`name_ar`; no GL, no WIP, never creates an `MfgMaterialIssue`. **Not** a
    production order. `ProductionOrder` has **no `name` column** — its display key is `order_number`.
  * Both targets have list routes (`/core/issue-tags`, `/factory/orders`) but **no per-record detail
    route**, so linking uses the app's existing `?viewId=` deep-link convention.
  * **Only 2 products of 17,166 carry a `toll_customer_id` on this install**, and 17139 is not one of
    them — the new customer column will look empty here. Recorded in the WP5b brief so its implementer
    doesn't chase a phantom bug.

- 2026-08-06 — **WP1 ✅** `6e34b024a`. **One file, +4 lines, 0 deletions** — the scope discipline the
  brief demanded held exactly. Build + `tsc --noEmit` green.
  **The red-before-green evidence is worth recording because the agent invented an honest method for a
  repo with no test runner:** it bundled the **real, unmodified** `product.service.ts` with the repo's
  own esbuild into a throwaway Node script, built the instance via `Object.create(prototype)` so the
  constructor's `inject(HttpClient)` never ran, stubbed `http.get` to capture `opts.params`, and called
  the **actual `list()` body**, printing the genuine `HttpParams.toString()`.
  Before: `page=1&per_page=100` for both a numeric id and `'own'` — i.e. **provably identical to
  no-filter**. After: `…&toll_customer_id=42` and `…&toll_customer_id=own`; cleared/null omit the key;
  the five sibling keys serialise byte-identically. Harness deleted, not committed.
  It also stated plainly what the method does **not** cover — no browser, no live server round trip —
  rather than letting the proof read as bigger than it is. That is the standard.
  **Guard chosen:** `!= null && !== ''` rather than the neighbouring truthiness idiom — states presence
  instead of relying on `'own'` happening to be truthy, mirrors the deliberate `!= null` at
  `stock-balance.service.ts:125`, and the `!== ''` is what guarantees a cleared filter sends no empty
  value. Paging verified by reading the path (`onPageChange` → `loadPage` → fresh `buildFilters()`).
  Open: nobody has yet watched a live server return a filtered page — one click after deploy closes it.

- 2026-08-06 — **WP2 ✅** `865ba0983` (7 files, +269/−90). Build + `tsc` green. All nine defects done.
  **Evidence, using WP1's Node/esbuild-on-real-source technique — measured, not asserted:**
  3 keystrokes **3 requests → 1**; a slow "old" query racing a fast "new" one showed **"old"** before
  and shows **"new"** after, with the stale request aborted; opening the screen **2 requests → 1**.
  WP1 re-verified unchanged in the same harness (`toll_customer_id=42` / `=own` / omitted).
  **The three judgement calls, adjudicated:**
  * **D4 tree view — did BOTH, and the reasoning was better than my brief's.** Filters now pass through
    to each category's product fetch, **and** a pill appears when a filter is active saying the
    category hierarchy itself is unfiltered — because `productCategoryService.tree()` has no filter
    parameter, so pass-through alone would have replaced one half-truth with another.
  * **D5 batch print/export — labelled, not widened.** With the page capped at 25, "export every
    filtered row" is ~160 sequential uncancellable requests on a 4,000-item filter. Out of proportion;
    the tooltip now says "current page" with the live count. Accepted.
  * **D8 — converted to `!= null`** in component and service. Truthiness reads as "a filter was chosen"
    but means "not `0/''/null`"; WP1 and `stock-balance.service.ts:125` already avoid it, so leaving
    the five neighbours would have made that discipline look like a local quirk.
  **Accepted edit outside the briefed files:** 2 tooltip bindings in the shared
  `data-table.component.ts`. **Verified by the orchestrator as text-only and `lazy()`-gated** — a
  non-lazy table really does export everything, so the honest label only appears where it's true.
  Two new i18n keys, added to BOTH files.
  **Noted, not acted on:** `ProductService.search()` now has **zero callers repo-wide**. It is the
  method that would silently drop three filters if ever re-attached (D2's landmine, now defused on the
  component side). Left in place as a service-layer decision → recorded as **Deferral D1**.
  Open: nothing exercised against a live server yet; CHANGELOG bullet owed at Phase C (the FE repo has
  no CHANGELOG — it lives in the BE repo).

- 2026-08-06 — **WP3 ✅** `106976d8e`. New file **18 passed / 140 assertions**; `Modules/Inventory`
  760 P / 4 F vs baseline 742 P / 4 F — same 4 names, +18 = exactly the new file; `Modules/Production`
  661 P / 10 F, identical to known-good. **Zero new failures.**
  **Batching proven, not claimed:** 21 rows → **21 queries**, 50 rows → **21 queries**, asserted equal.
  16 of those come from the resolver — one per distinct source table, the hard ceiling; a realistic
  card costs 4–6. The test pins **exactly 21**, so a future silent regression fails loudly.
  **The `source` contract WP4 builds against** (always present, all keys always present):
  `kind` (never null; `unknown` for an unmapped slug) · `label` (translated; raw slug when unknown) ·
  `name` · `id` (null ⇒ nothing to open) · `route_hint` · `cancelled` (target is the ORIGINAL doc) ·
  `resolved` (false ⇒ name/id/route_hint all null) · `via` (the intermediate document on a 2-hop
  chain, 5 keys, never nested). Consignment kinds return `resolved:true` with `id:null` and
  `route_hint:null` — **name only, never a link**, exactly as Decision 4 required.
  **🔴 The agent corrected a fact the orchestrator had asserted — and it was right.** My ledger and my
  message to the owner said product 17139's 7 issue movements "all resolve to production orders".
  Verified directly: all 7 **reference** `production_order`, but `GDN-000014` points at order id **16,
  which has no row in `production_orders` at all** (not soft-deleted — no row). So **6 of 7** will show
  an order number; the 7th falls back to the issue. Criterion 6's dangling-reference fallback is what
  makes that safe, and it fired on real data on its first outing. **Data observation, not a code
  defect** — recorded as Deferral D2.
  **Accepted one line beyond the brief:** the resolver was wired to `movements()` as well as
  `stockCard()` — same shared resource, same batched call, no regressions. Consistent rather than
  leaving one of the two endpoints emitting a poorer shape.
  Noted: a receipt whose own reference is `purchase`/`purchase_bill` stops at the receipt by design —
  one config entry if the owner ever wants the bill on the card.

- 2026-08-06 — **WP4 ✅** `4c9daa50d`. Build + `tsc` green. **The owner's headline ask is now on screen.**
  **It corrected the orchestrator a second time, and again it was right.** My brief told it
  `GDN-000014` would come back `resolved: false`. Running WP3's resolver over the live rows, it comes
  back **`resolved: true`, `kind: inventory_issue`, `name: GDN-000014`** — WP3's documented "failed
  second hop ⇒ fall back to the intermediate document" behaviour. So it renders as a neutral Stock
  Issue badge linked to stock-issues: never an order, never a broken link. My *description* of the
  behaviour to the owner (falls back to the issue) was right; the label I put on it was not.
  **No `resolved:false` row exists in this database at all**, so that branch is covered by inspection,
  not by live data — recorded honestly rather than claimed as tested.
  **Accepted deviation, and the agent's reasoning beat the brief's:** production orders link to
  **`/factory/orders/:id`** (the Order Cockpit), not WP3's `viewId` hint. **Verified by the
  orchestrator:** `production-orders.component.ts` contains **zero** occurrences of `viewId`, so a
  `viewId` link would land on all 40 orders and do nothing; `/factory` lazy-loads
  `production-standalone.routes` which registers `orders/:id`; and the list's own `openCockpit()`
  navigates to exactly that URL. It used the app's own record URL instead of a convention that
  happens not to be implemented there.
  Three screens still land on a list rather than a record because they don't handle `viewId` yet —
  `/core/issue-tags`, `/core/warehouse-transfers`, `/webstore/orders`. The param is already on the URL
  for whoever adds the handler. Out of scope; recorded as **Deferral D3**.
  **Incidental find worth having:** `INVENTORY.SOURCE` was **missing from both i18n files** — the
  screen's *existing* source column header has been rendering its raw translation key. Added to both.
  **⚠️ Phase C debt:** WP3's CHANGELOG bullet says the on-screen column "arrives with the next update".
  This commit closes that, but the agent correctly did **not** edit the CHANGELOG — the BE repo had
  uncommitted changes from WP5a in flight, and writing there would have collided or smuggled its
  bullet into another WP's commit. **Phase C must strike that sentence in both languages.**

- 2026-08-06 — **WP5a ✅** `008da5d27`. 19 new tests / 77 assertions; `Modules/Inventory`
  **779 passed / 4 failed** = 760 + 19, and the 4 were re-run in isolation and are exactly the
  baseline set (2× `LotAllocationTest`, `OpeningBalanceApiTest`, `TransferLotPreservationTest`).
  **Zero new failures.** Measured **23 queries for 25 rows carrying 25 distinct customers** (asserted
  `<= 25`) — the eager load turns 25 extra reads into one.
  **The best thing in this WP is how it treated the dangerous line.** The brief warned that the new
  `orWhereHas` must nest *inside* the existing `search` closure or the whole `where` collapses into an
  OR. Rather than assume the guard tests were meaningful, the agent **temporarily hoisted the clause
  one level up and watched 2 guard tests fail** (the warehouse test returned 3 ids instead of 1), then
  restored from backup. The guards are known-good, not hoped-for. That is the standard for a test that
  exists to catch one specific mistake.
  The naming trap held: `owner`, `owner_*` and `lotOwners()` are untouched, and a test seeds a row
  where the **catalogue** owner and the **custody** owner are different partners and asserts both
  filters compose.
  **Contract WP5b builds against:** filter param **`toll_customer_id`** (partner id, or the literal
  `own`); fields **`toll_customer_id`** (int|null) + **`toll_customer_name`** (string|null,
  Arabic-first `name_ar ?: name`); the **existing `search` param** now also matches the customer's
  name — no new search param. Fields on `index`/`byProduct`/`byWarehouse`; filter on `index` only.
  ⚠️ **No picker endpoint exists.** WP5b must not source options from `/stock-balances/lot-owners` —
  that lists consignment custody owners, the wrong population. Use the products/partners side.

- 2026-08-06 — **WP5b ✅** `b817c68d2` (FE) + `125846ef6` (BE, CHANGELOG text only). Build + `tsc` green.
  **Verified against a live server, not by reading:** unfiltered **125** rows · `?toll_customer_id=52`
  → **1** (product 17168, name resolves) · `?toll_customer_id=own` → **124**. 124 + 1 = 125 — an exact
  partition with no leakage in either direction. And **`?search=ABDULLAH HAMED` returned that
  customer's item with zero FE change**, which confirms WP5a's "search needs no FE work" claim instead
  of taking it on trust. Test token revoked afterwards.
  **Final labels:** «المالك (أمانة)» / "Owner (consignment)" for the existing control — which turned
  out to have had **no visible caption at all** — and «عميل التصنيع» / "Toll customer" for the new one.
  Giving *both* a caption is what makes them read as two questions rather than one duplicated control;
  relabelling the incumbent was in scope precisely because adding the second created the ambiguity.
  **Picker source:** `PartnerService.listAll()` filtered on `is_customer`, character-for-character the
  products screen's own `loadTollCustomers()` — **not** `getLotOwners()`, which lists consignment
  custody owners and would have silently omitted any toll customer whose stock is all company-owned.
  **Phase C debt cleared early:** WP3's CHANGELOG bullet promised the on-screen column "arrives with
  the next update". That sentence became false when WP4 landed, so it was replaced — text only, no new
  bullet, no BE code. Doing it here (rather than at Phase C) was correct: the BE tree was clean by then.
  Noted, not chased: the picker lists every customer rather than only those present on stock (matches
  the products screen; the "distinct toll customers on stock" endpoint doesn't exist) → **Deferral D4**.
  The pre-existing branch-restriction paginator over-count is untouched and not worsened — the new
  filter narrows server-side, so `meta.total` and the rows shrink together.

- 2026-08-06 — **WP6 ✅** `ce883a74e` (+ BE `f917928b0`, CHANGELOG only). Build + `tsc` green.
  **The 25-row trap was solved properly rather than worked around.** The on-screen `p-table` is
  client-paginated, so PrimeNG only ever holds 25 `<tr>`s in the DOM — `window.print()` *physically
  cannot* reach the rest. Instead of fighting that, WP6 prints a **separate screen-hidden sheet** over
  a new `printEntries()` computed (`[...entries()]` sorted by id, i.e. the complete
  `getStockCardFilteredAll()` result in posting order so the balance chain reads as a ledger), with
  `thead` repeating per page and totals from the existing whole-set computeds.
  **It also refused to copy a broken pattern from the reference implementation** — see Deferral D5.
  That is the right instinct: the brief said "follow the reference", and following it literally would
  have shipped a printout with the whole app behind it.
  **Judgement call, accepted:** the opening/closing balance is **withheld** when the sheet spans
  several warehouses, because `balance_after` is a **per-warehouse** snapshot and a mixed running
  balance would read authoritative while being wrong. The sheet prints a note saying so; totals
  in/out/net always print. Correct — a plausible wrong number is worse than an honest absence.
  ⚠️ **Not visually verified** — no browser available. Page breaks, trailing blank pages, A4 column
  fit and RTL in the print preview are reasoned from the emitted CSS, not observed. **This is the one
  thing that most needs the owner's eyes.**

---

## Phase C — closeout (2026-08-06)

- **All 6 WPs ✅.** FE: `6e34b024a` · `865ba0983` · `4c9daa50d` · `b817c68d2` · `ce883a74e`.
  BE: `106976d8e` · `008da5d27` · `125846ef6` · `f917928b0`.
- **Tests:** `pest Modules/Inventory` **779 passed / 4 failed** (baseline 742/4 — the 4 are the same
  pre-existing names, re-run in isolation to confirm); `Modules/Production` 661 P / 10 F, unchanged.
  **Zero new failures.** No BE code landed after WP5a, so those numbers are current.
- **CHANGELOG:** 31 `[Unreleased]` bullets, **0** missing `{{ar}}`, and the stale "arrives with the
  next update" promise is gone (grep returns 0) — WP5b and WP6 each closed their own.
- **Built and deployed to `/app`** — bundle `main-PDPNCUC7.js`, `apiUrl` confirmed still the moonui2
  dev API, `chown -R moonui2:moonui2`.
- **Nothing pushed, nothing merged.** `/fullpush` is the owner's step.
- **Nothing browser-verified.** Every criterion was met by test, live HTTP call, built-bundle
  inspection, or code path. WP5b did make real server calls; WP6's print output did not.
