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

**Repo:** BE (`/home/moonui2/moon-erp-be`) · **Branch:** `hazemdev2` · **Migration:** no · **FE change:** none
**This is the heaviest package in the plan. Read all of it before writing anything.**

## Goal

The stock card shows a movement's *document* (`GDN-000014`) and links to the stock-issues screen. The
owner wants the **real source**: for a production issue, the production order; for a job-tagged issue,
the tag's name. Proved from live data — product **17139** has 7 issue movements and **all 7 resolve to
production orders**.

Add a server-side resolver that, for each movement on a page, returns a small structured object the
frontend can render and link from. **WP4 renders it; this WP only produces it.**

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

- Movements: `Modules/Inventory/app/Models/InventoryMovement.php`, table `inventory_movements`.
  Columns `reference_type` (**plain string, NOT NULL, no enum**) + `reference_id` (**bigint, NOT NULL,
  no FK**), indexed together. **The model declares zero relations to any source document.**
- Resource: `Modules/Inventory/app/Http/Resources/InventoryMovementResource.php` — emits the raw
  `reference_type` / `reference_id` and nothing else. Used in exactly two places, both in
  `StockCardController` (lines ~167 and ~213).
- Endpoint: `StockCardController::stockCard()` (`Modules/Inventory/app/Http/Controllers/StockCardController.php:90`),
  `paginate(50)` hardcoded, ordered by `id desc` **deliberately** (`balance_after` is a snapshot
  stamped in insert order — do not change the ordering).
- **The batched, no-N+1 pattern you must copy already exists on this exact endpoint**: the
  `lot_allocations` block at `StockCardController.php:126-163` loads everything for the page in ONE
  query and attaches it. Follow that shape.
- **The label precedent exists one level up**: `InventoryIssueResource` emits `reference_type_label`
  (enum `->label()`, translated) and `reference_number` via `referenceNumber()` (lines 65-82) — which
  for `ProductionOrder` does `ProductionOrder::query()->whereKey(...)->value('order_number')` **per
  row**, and for `JobTag` calls `jobTagName()` which asserts `company_id` **in memory** (line ~106)
  because a convention-polymorph `belongsTo` cannot express tenant scope. Reuse the ideas; do **not**
  reuse the per-row query — this WP must be batched.

## The chains

| Movement `reference_type` | Hops | Resolution |
|---|---|---|
| `inventory_issue` | **2** | → `inventory_issues.id` → its own `reference_type` ∈ {`production_order`, `job_tag`, `sale`} + `reference_id` → the real source |
| `production_order` · `production_staging` | **1** | `reference_id` **is** `production_orders.id` (written directly by backflush / staging) |
| `inventory_receipt` (production FG) | 2 | → `inventory_receipts.id` → `reference_type = production_order` |
| `consignment_receipt` · `consignment_return` | 1 | ⚠️ `reference_id` is a **`business_partners.id`**, NOT a document |
| `consignment_borrow` | 1 | `consignment_borrows.id` |
| `consignment_replenish` | 1 | a `consignment_material_ledger` row id |
| `sales_invoice` · `sales_return` · `delivery_note` · `purchase_return` · `purchase_bill_cancel` · `store_order*` · `inventory_adjustment` · `inventory_transfer` · `opening` · … | 1 | the named document's id |
| `*_cancel` (every one) | 1 | ⚠️ points at the **ORIGINAL** document, not a cancellation document |

**Live distribution on `moonui2_dev_be`** — `inventory_issues.reference_type`: `production_order` ×20,
`sale` ×20, `job_tag` ×3.

## What to build

A resolver returning, per movement, something like:

```
source: {
  kind:   'production_order' | 'job_tag' | 'sales_invoice' | 'consignment_partner' | 'adjustment' | … | 'unknown',
  label:  '<translated type name>',      // ar/en
  name:   '<order_number | tag name | partner name | document number>',
  id:     <the id the FE should link to>,   // null when there is nothing to open
  route_hint: '<stable slug the FE maps to a route>'   // or null
}
```

Design constraints — every one of these is load-bearing:

1. **Batched.** ONE query per source table per page, not per row. Follow the `lot_allocations` block.
2. **Company-scoped explicitly.** The reference carries no tenant guarantee. Filter by `company_id`
   in the queries, and where a model has no direct company column, assert it the way `jobTagName()`
   does. **A cross-company reference must resolve to nothing, not to another tenant's record.**
3. **`reference_id` is not always a document.** `consignment_receipt` / `consignment_return` carry a
   **partner id** — resolve to the partner's name and return **no document link**. Getting this wrong
   opens the wrong screen with a nonsense id.
4. **Job tag ≠ production order.** `InventoryIssueTag` has `name`/`name_ar`; a job-tagged issue never
   creates an `MfgMaterialIssue`, never posts WIP. Label it as a job tag, never as an order.
5. **`ProductionOrder` has no `name`** — use `order_number`.
6. **Soft-deleted / missing references must not throw.** Fall back to the raw type + id.
7. **`*_cancel` resolves to the original document**, flagged as a cancellation.
8. **Translations**: `Modules/Inventory/lang/{en,ar}/inventory.php` currently has `reference_types`
   (receipt enum) and `issue_reference_types` (issue enum) only — the **movement** slugs have no keys
   at all. Add what you need to **both** files.
9. **Do not change** the existing `reference_type` / `reference_id` fields on the resource — the
   frontend's current column still uses them (decision: the new column is additive).
10. **Do not change the query ordering** (`id desc`) or the page size.

## Acceptance criteria

1. A movement from a **manual production issue** resolves to the production order with its
   `order_number` (the 2-hop chain). Use product 17139's real shape as the test case.
2. A movement from **backflush** resolves to the production order in **1 hop**.
3. A **job-tagged** issue resolves to the tag with its **name**, labelled as a job tag — explicitly
   asserted NOT to be labelled a production order.
4. A **consignment receipt/return** resolves to the **partner name** and returns **no document link**.
5. A `*_cancel` movement resolves to the original document and is flagged as cancelled.
6. A movement whose reference no longer exists (deleted) returns the raw type + id and **does not throw**.
7. A reference belonging to **another company** resolves to nothing — with a test.
8. **Query count does not scale with rows**: assert with `DB::listen` / a query counter that a page of
   50 mixed-source movements issues a bounded number of queries (state the number). This is the
   criterion that proves "batched" rather than claiming it.
9. `pest Modules/Inventory` and `pest Modules/Production`: **zero NEW failures** vs
   `../baseline-inventory.txt` (Production known-good: 661 passed / 10 failed — ConsignmentFoundation
   ×1, CostAiAnomalyVariance ×3, ProductionVariance ×6).

## 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 — your new file plus the two module suites above. Not the whole suite.
- ⛔ **Pest loads every test file into ONE process** — prefix every top-level helper with its file's
  subject. A duplicate top-level function = fatal redeclare, exit 255, **zero output**. Five
  occurrences in this project.
- ⛔ NEVER `migrate:fresh` / `migrate:refresh` / `db:wipe` on `moonui2_dev_be` — not binlogged.
- API auth header is `X-Authorization: Bearer` (not `Authorization`).
- `./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.

## Out of scope

Any frontend change (WP4 consumes this) · the stock-balances resource (WP5a) · chasing an inventory
**count** through its adjustment — resolve to the adjustment and stop (owner decision §9 Q4) ·
changing pagination or ordering.
