# WP3 report — Backend: resolve a stock movement's REAL source

**Repo:** BE `/home/moonui2/moon-erp-be` · **Branch:** `hazemdev2` · **Base:** `718468f09`
**Migration:** none · **FE change:** none · **Status:** DONE

---

## 1. The `source` JSON shape — the contract WP4 must be written against

`source` is emitted by `InventoryMovementResource` on **every** movement row of
`GET /api/inventory/stock-card/{product}` **and** `GET /api/inventory/movements`.

**It is always present and always an object.** Every key is always present too — a
frontend column must never test for a missing key, only for a `null` value.

```jsonc
"source": {
  "kind":       "production_order",   // string, never null   — see the kind table below
  "label":      "أمر إنتاج",           // string, never empty  — translated per request locale
  "name":       "PRD-77001",          // string | null        — order_number / tag name / partner name / doc number
  "id":         42,                   // int | null           — the id to link to; null = nothing to open
  "route_hint": "production-orders",  // string | null        — stable slug; null = no screen
  "cancelled":  false,                // bool                 — this movement came from a *_cancel posting
  "resolved":   true,                 // bool                 — false ⇒ name/id/route_hint are ALL null
  "via": {                            // object | null        — the intermediate document on a 2-hop chain
    "kind":       "inventory_issue",
    "label":      "إذن صرف",
    "name":       "GDN-000014",
    "id":         88,
    "route_hint": "stock-issues"
  }
}
```

### Reading rules for WP4

| Situation | What the payload looks like | What to render |
|---|---|---|
| Resolved, linkable | `resolved:true`, `name` set, `id` + `route_hint` set | `label` + `name` as a link to `route_hint`/`id` |
| Resolved, **not** linkable (all consignment kinds) | `resolved:true`, `name` set, `id:null`, `route_hint:null` | `label` + `name` as **plain text** — never a link |
| Unresolved (deleted / other company / never existed) | `resolved:false`, `name:null`, `id:null`, `route_hint:null` | `label` only, muted; the row's raw `reference_type`/`reference_id` are still there |
| Unknown slug | `kind:"unknown"`, `label` = **the raw slug verbatim** | the raw slug, muted |
| 2-hop chain | `via` is an object | optionally «`label` `name` — عبر `via.label` `via.name`» |
| Cancellation | `cancelled:true` | strike-through / «ملغي» badge; the target is the **original** document |

> `via` is a *link*, not a full node — it has 5 keys only and never nests another `via`.

### `kind` → `route_hint` (complete, closed set)

| `kind` | `route_hint` | FE route it maps to |
|---|---|---|
| `production_order` | `production-orders` | `/production/orders` |
| `job_tag` | `issue-tags` | `/core/issue-tags` |
| `inventory_issue` | `stock-issues` | `/core/stock-issues` |
| `inventory_receipt` | `stock-receipts` | `/core/stock-receipts` |
| `opening` | `stock-receipts` | `/core/stock-receipts` (an opening balance *is* a receipt row) |
| `inventory_adjustment` | `stock-adjustments` | `/core/stock-adjustments` |
| `inventory_transfer` | `warehouse-transfers` | `/core/warehouse-transfers` |
| `sales_invoice` | `sales-invoices` | sales invoices |
| `sales_return` | `sales-returns` | sales returns |
| `delivery_note` | `delivery-notes` | delivery notes |
| `purchase_return` | `purchase-returns` | purchase returns |
| `purchase_bill` | `purchase-bills` | purchase bills |
| `store_order` | `store-orders` | webstore orders |
| `store_order_return` | `store-order-returns` | webstore order returns |
| `consignment_partner` | **`null`** | — never a link |
| `consignment_borrow` | **`null`** | — never a link |
| `consignment_replenish` | **`null`** | — never a link |
| `unknown` | **`null`** | — |

`route_hint` is also `null` on **any** kind when `resolved` is `false`.

---

## 2. The verified reference map, as implemented

| Movement `reference_type` | Hops | Resolves to | `kind` |
|---|---|---|---|
| `inventory_issue`, `inventory_issue_cancel` | 2 | issue → `production_order` \| `job_tag` \| `sale`(→ sales invoice); anything else stops at the issue | `production_order` / `job_tag` / `sales_invoice` / `inventory_issue` |
| `inventory_receipt`, `inventory_receipt_cancel` | 2 | receipt → `production_order` when it is an FG receipt, else the receipt | `production_order` / `inventory_receipt` |
| `opening` | 1 | the `inventory_receipts` row (`ApproveReceipt` stamps `opening` instead of `inventory_receipt`) | `opening` |
| `production_order`, `production_staging` | 1 | `production_orders.id` written directly by backflush / staging | `production_order` |
| `consignment_receipt`, `consignment_return` | 1 | ⚠️ **a `business_partners.id`** → the partner name, **no link** | `consignment_partner` |
| `consignment_borrow` | 1 | `consignment_borrows.id` → its `customer_id` → partner name, **no link** | `consignment_borrow` |
| `consignment_replenish` | 1 | `consignment_material_ledgers.id` → its `customer_id` → partner name, **no link** | `consignment_replenish` |
| `inventory_adjustment(_cancel)` · `inventory_transfer(_cancel)` · `sales_invoice(_cancel)` · `sales_return(_cancel)` · `delivery_note(_cancel)` · `purchase_return(_cancel)` · `purchase_bill_cancel` · `store_order(_cancel)` · `store_order_return` | 1 | the named document, by its number column | per the table above |
| anything else | — | `kind:"unknown"`, `label` = the raw slug | `unknown` |

Every `*_cancel` slug points at the **original** document (verified in `CancelIssue.php:58`,
`CancelAdjustment.php:40`, `CancelTransfer.php:45`, `CancelReceipt.php:71`,
`CancelSalesInvoice.php:164`, `CancelSalesReturn.php:83`, `CancelDeliveryNote.php:89`,
`CancelPurchaseReturn.php:81`, `CancelPurchaseBill.php:132`, `RestoreStoreOrderStock.php:130`)
and is flagged `cancelled: true`. There is no cancellation document to open.

**Live distribution** on `moonui2_dev_be` at time of writing —
`inventory_movements.reference_type`: `opening` ×88, `inventory_issue` ×60,
`inventory_receipt` ×39, `inventory_adjustment` ×10, `sales_invoice` ×8,
`inventory_issue_cancel` ×7, `inventory_transfer` ×2, `consignment_receipt` ×1,
`consignment_replenish` ×1, `consignment_borrow` ×1.
`inventory_issues.reference_type`: `production_order` ×20, `sale` ×20, `job_tag` ×3.

### Verified against the live dev database (product 17139, page of 50)

The finished resolver was run over the real movements of product **17139** on
`moonui2_dev_be` (company 4):

```
sales_invoice   33 → sales_invoice     INV-2026-00033  id=33
inventory_issue 39 → production_order  PRD-2026-00023  id=40  via=GDN-000030
inventory_issue 38 → production_order  PRD-2026-00023  id=40  via=GDN-000029
inventory_receipt 50 → inventory_receipt GRN-000043    id=50
inventory_issue 37 → production_order  PRD-2026-00022  id=39  via=GDN-000028
inventory_issue 22 → production_order  PRD-2026-00021  id=36  via=GDN-000019
inventory_issue 21 → production_order  PRD-2026-00015  id=30  via=GDN-000018
inventory_issue 19 → production_order  PRD-2026-00013  id=28  via=GDN-000016
inventory_issue 14 → inventory_issue   GDN-000014      id=14  via=null   ← see below
opening          7 → opening           GRN-000007      id=7
```

**6 of the 7 issue movements resolve to a production order**, not 7 as the brief expected.
The seventh, `inventory_issue 14` → `GDN-000014`, *does* carry
`reference_type = production_order, reference_id = 16` — but **production order 16 does not
exist in the database at all** (not soft-deleted: no row). So the graceful fallback of
design constraint 6 fires on live data and the card shows the issue `GDN-000014` instead of
a dangling link. That is the correct behaviour, and it is a real-world confirmation of
criterion 6 rather than a defect in the resolver.

---

## 3. Files changed

| File | Change |
|---|---|
| `Modules/Inventory/app/Support/MovementSourceResolver.php` | **NEW** — the whole resolver (~470 lines with the map + rationale) |
| `Modules/Inventory/app/Http/Controllers/StockCardController.php` | +1 import, one `attach()` call in `stockCard()` and one in `movements()` |
| `Modules/Inventory/app/Http/Resources/InventoryMovementResource.php` | +1 field `source` (raw `reference_type`/`reference_id` untouched) |
| `Modules/Inventory/lang/en/inventory.php` | +`movement_source_types` (18 keys) |
| `Modules/Inventory/lang/ar/inventory.php` | +`movement_source_types` (18 keys) |
| `Modules/Inventory/tests/Feature/StockCardMovementSourceTest.php` | **NEW** — 18 tests / 140 assertions |
| `docs/moonstack/CHANGELOG.md` | bilingual `[Unreleased]` bullet |

`movements()` was wired too **on purpose**: both endpoints share
`InventoryMovementResource`, so a fix in the shared layer keeps them from drifting. The
call is the same batched pass; nothing else about that endpoint changed.

### Design decisions worth knowing

- **Raw `DB::table`, not Eloquent.** It bypasses `TenantAware` (so the `company_id` filter
  is the *only* tenant filter and is visible in one place), keeps Inventory free of a hard
  class dependency on Sales/Purchases/Production/WebStore, and lets a table that does not
  exist in a partial install degrade to "unresolved" through a `QueryException` catch
  instead of fatalling.
- **One `select()` chokepoint.** Every source table is read through a single private
  method that always applies `company_id` **and** `whereNull('deleted_at')`. A new slug
  cannot forget the tenant guard.
- **Failed second hop ⇒ fall back to the intermediate document**, not to nothing. A
  job-tagged issue whose tag belongs to another company still shows the *issue* (a
  document this tenant owns), with `via` left `null` so the card never draws a hop that
  did not happen.
- **`resolved` instead of a half-filled node.** Cross-company / deleted references return
  the *kind* (already public in `reference_type`) but `name`, `id` and `route_hint` all
  `null` — nothing of the other tenant's record crosses the boundary.

---

## 4. Query budget — the batching proof (acceptance criterion 8)

Measured with a `DB::listen` counter around a real HTTP request in
`the query count for a page of 50 mixed-source movements does not scale with the rows`.

Two products carrying the **same 21-family source mix**, one with **21 rows** and one with
a **full page of 50 rows**:

```
21 rows → 21 queries
50 rows → 21 queries      ← identical. That is what "batched" means.
```

Breakdown of the 21 (identical on both pages):

| # | Queries | What |
|---|---|---|
| 5 | `count(*)`, the page itself, `products`, `warehouses`, `inventory_issue_lot_allocations` | what the endpoint already issued **before** this WP |
| 16 | one per distinct **source table** on the page | added by the resolver |

The 16: `inventory_issues`, `inventory_receipts`, `consignment_borrows`,
`consignment_material_ledgers` (stage A) + `production_orders`, `inventory_adjustments`,
`inventory_transfers`, `sales_returns`, `sales_delivery_notes`, `purchase_returns`,
`purchase_bills`, `store_orders`, `store_order_returns`, `business_partners`,
`inventory_issue_tags`, `sales_invoices` (stage B).

**16 is the hard ceiling** — it is the number of distinct tables the map can reach, not a
function of the row count. A realistic stock card (issues + receipts + adjustments) costs
**4–6** extra queries. A per-row resolver on the same 50-row page would have issued ~50–100.

The test asserts **both** `bigCount === smallCount` (the batching invariant) and
`bigCount === 21` (so a per-row lookup sneaking in later fails here, not in production).

---

## 5. Tests

New file — `Modules/Inventory/tests/Feature/StockCardMovementSourceTest.php`,
**18 passed / 140 assertions**. Every top-level helper is prefixed `movementSource…`
(Pest loads all test files into one process; a duplicate top-level function is a fatal
redeclare, exit 255, zero output).

| Criterion | Test |
|---|---|
| 1 — manual production issue, 2 hops | `a movement from a manual production issue resolves to the production order through the issue` (asserts `order_number`, `id`, and the `via` issue) |
| 2 — backflush, 1 hop | `a backflush movement resolves to the production order in one hop` + `a production staging movement …` + `a finished-goods receipt resolves through the receipt …` |
| 3 — job tag ≠ order | `a job-tagged issue resolves to the tag by name and is never labelled a production order` — explicitly asserts `kind !== 'production_order'`, `label !== ` the production-order label, and `route_hint !== 'production-orders'` |
| 4 — consignment → partner, no link | `a consignment receipt resolves to the owning partner and offers no document link` (+ return, + borrow/replenish) — asserts `id === null` and `route_hint === null` |
| 5 — `*_cancel` → original | `a cancelled issue movement resolves to the ORIGINAL source and is flagged cancelled` + the adjustment variant |
| 6 — missing reference does not throw | `a movement whose reference no longer exists returns an unresolved source without throwing` (soft-deleted order + an id that never existed) + `a reference_type this build has never heard of degrades to unknown carrying the raw slug` |
| 7 — foreign company resolves to nothing | `a reference belonging to another company resolves to nothing` (also asserts the foreign `order_number` appears **nowhere** in the payload) + `a job tag owned by another company never leaks its name through the issue chain` |
| 8 — bounded query count | `the query count for a page of 50 mixed-source movements does not scale with the rows` |
| additive contract | `the raw reference_type and reference_id fields are untouched alongside the resolved source` + `every row on a mixed page carries a source object with the full stable key set` |

### Suite results (criterion 9)

Run as `cd /home/moonui2/moon-erp-be && /opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest <path>`.

| Suite | Baseline | After WP3 | New failures |
|---|---|---|---|
| new file | — | **18 passed** (140 assertions) | — |
| `Modules/Inventory` | 4 failed / 742 passed | **4 failed / 760 passed** (3250 assertions) | **0** — +18 passed is exactly this WP's new file |
| `Modules/Production` | 10 failed / 661 passed | **10 failed / 661 passed** (3979 assertions) | **0** |

The 4 Inventory failures are byte-for-byte the baseline's, by test name:

- `FEFO allocation consumes the earliest-expiry lot first and records…` (`LotAllocationTest`)
- `a quantity beyond the owner lots leaves an unassigned shortfall, no…` (`LotAllocationTest`)
- `opening balance creates inventory movement with type opening` (`OpeningBalanceApiTest`)
- `FEFO across two source lots preserves both batches at the destinati…` (`TransferLotPreservationTest`)

The 10 Production failures are the documented known-good set:
`ConsignmentFoundation` ×1 (`settle-buy under DIRECT grn_mode…`),
`CostAiAnomalyVariance` ×3 (`F1 variance:` …), `ProductionVariance` ×6 (`BE-V:` …).

---

## 6. Out of scope / follow-ups

- **WP4** renders the column. It is the only consumer of this shape.
- `store_order_return` and the pure-Purchases tables have no factories in this build, so
  the query-count test points at them with synthetic ids — the *number of queries* is
  identical either way, which is what that test measures.
- An `inventory_receipt` whose own `reference_type` is `purchase` / `purchase_bill` /
  `return` / `adjustment` deliberately stops at the **receipt**. Chasing a GRN or a bill
  through the receipt was not asked for and would add two more tables to the budget; the
  receipt is a real document the keeper can open. If the owner later wants «فاتورة الشراء»
  on the card, it is one more entry in `RECEIPT_SLUGS`' hop switch.
- An inventory **count** still resolves to its adjustment and stops (owner decision §9 Q4).
