# WP4 report — Stock card: the «المصدر المباشر» column

**Repo:** FE `/home/moonui2/public_html/moon-erp` · **Branch:** `hazemdev2`
**Base:** `865ba0983` · **Commit:** `4c9daa50d` · **BE change:** none · **Status:** DONE

---

## 1. What shipped

A new column «المصدر المباشر» / «Direct Source» on the stock card's movements table,
rendering WP3's `source` object. The existing SOURCE column is **untouched** — same
markup, same `navigateToSource()`, same `referenceRouteMap`. The two columns now sit
side by side and answer two different questions:

| Column | Question it answers |
|---|---|
| «المصدر المباشر» (new) | what the movement was **FOR** — the production order / job tag / partner |
| «المصدر» (existing) | which **document** posted it — the issue note, opened via the old click handler |

Per row the new cell renders `badge(kind) + name`, an optional «عبر …» hop line, and an
optional «ملغي» chip.

### Rendering rules, one per payload shape

| Payload | Rendered |
|---|---|
| `resolved:true`, `id` + `route_hint` set | badge + name as a real `<a routerLink>` |
| `resolved:true`, `id:null` (**all consignment kinds**) | badge + name as **plain text** — never a link |
| `resolved:false` | the muted, italic `label` + a tooltip. Never a link |
| `kind:'unknown'` | same muted branch — `label` is the raw slug verbatim |
| `source` absent / `null` | «—» |
| `cancelled:true` | name struck through + a red «ملغي» chip whose tooltip says the link opens the **original** document |
| `via` present | a second muted line «عبر إذن صرف GDN-000030» |

### The badges — a job tag can never read as a production order

Distinguished by **shape + icon**, not colour alone (survives colour-blindness and a
black-and-white print):

| kind | badge |
|---|---|
| `production_order` | solid **indigo**, square corners, `pi-cog` |
| `job_tag` | **amber**, **dashed** border, **pill** (999px), `pi-tag` |
| `consignment_*` | teal, `pi-users` |
| any document | neutral slate, `pi-file` |
| `unknown` | muted slate, `pi-question-circle` |

Dark-mode tints for all five are in the file's existing `body.dark-mode` block.

### Real links, not a click handler

The cell uses `<a [routerLink]="link.commands" [queryParams]="link.queryParams">`, so it
produces a genuine `href` — middle-click, Ctrl+click, "copy link address" and
open-in-new-tab all work. That was the point: the owner wants to *get to* the order
while the card stays open.

Link inputs are **memoised** in a `computed<Map<number, SourceLink>>` keyed by movement
id and derived from the `entries()` signal, so each row's routerLink inputs keep a stable
object identity across change-detection ticks instead of being rebuilt (and every `href`
rewritten) on every cycle.

### `route_hint` → route

```
issue-tags          → /core/issue-tags
stock-issues        → /core/stock-issues
stock-receipts      → /core/stock-receipts          (also `opening`)
stock-adjustments   → /core/stock-adjustments
warehouse-transfers → /core/warehouse-transfers
sales-invoices      → /sales/invoices
sales-returns       → /sales/returns
delivery-notes      → /sales/delivery-notes
purchase-returns    → /purchases/returns
purchase-bills      → /purchases/bills
store-orders        → /webstore/orders
production-orders   → /factory/orders/:id           ← special-cased, see §3
store-order-returns → (no FE route) → plain text
consignment_* / unknown → route_hint is null → plain text
```

Everything except production orders deep-links with `?viewId=<id>`, the app-wide
convention (`my-approvals`, the command bar, journal entries, cash movement all use it).
A hint with no entry in the map falls through to plain text — never a broken link.

---

## 2. Files changed

| File | Change |
|---|---|
| `src/app/core/models/inventory.model.ts` | +`MovementSource`, +`MovementSourceVia`, +`source?: MovementSource \| null` on `StockCardEntry` |
| `src/app/features/stock-card/stock-card.component.ts` | +`RouterLink` import, +`SourceLink` interface, +`sourceRouteMap`, +`buildSourceLink()` / `sourceLinks` computed / `sourceLink()`, +`sourceBadgeClass()`, +`sourceIcon()` |
| `src/app/features/stock-card/stock-card.component.html` | +1 `<th>`, +1 `<td>` before the existing SOURCE cell, empty-message colspan 10/11 → 11/12 |
| `src/app/features/stock-card/stock-card.component.scss` | +`.direct-source`, `.src-badge*`, `.src-name*`, `.src-cancelled-chip`, `.src-via`, `.src-unresolved` + their dark-mode tints |
| `src/assets/i18n/ar.json`, `en.json` | +6 keys each, additive, inside the existing `INVENTORY` block |

`source` is typed **optional/nullable** on purpose: `InventoryMovementResource` emits
`'source' => $this->source ?? null`, so any endpoint (or older self-hosted BE) that never
ran the resolver sends `null` and the column degrades to «—». Inside the object every key
is required, exactly per WP3's contract — the template reads `null` values, never missing
keys.

### New i18n keys (both files)

| Key | ar | en |
|---|---|---|
| `INVENTORY.SOURCE` | المصدر | Source |
| `INVENTORY.DIRECT_SOURCE` | المصدر المباشر | Direct Source |
| `INVENTORY.SOURCE_VIA` | عبر | via |
| `INVENTORY.SOURCE_CANCELLED` | ملغي | Cancelled |
| `INVENTORY.SOURCE_CANCELLED_HINT` | حركة عكسية لإلغاء — الرابط يفتح المستند الأصلي | Reversal movement — the link opens the original document |
| `INVENTORY.SOURCE_UNRESOLVED` | المصدر الأصلي غير متاح | The original source is no longer available |

> `INVENTORY.SOURCE` **did not exist** in either file — the *existing* column header was
> rendering the raw key `INVENTORY.SOURCE` on screen. Adding it is purely additive and
> fixes that; the existing column's behaviour is otherwise unchanged.

No `git checkout`/`restore`/`stash` was run on either i18n file. Both re-parse as valid
JSON (verified with `json.load`).

---

## 3. The one deliberate deviation — production orders link to the Order Cockpit

WP3's table maps `production-orders` → `/production/orders`. **I link to
`/factory/orders/:id` instead**, and here is why.

`/production/orders` is a **list** screen, and unlike stock-issues, sales-invoices,
purchase-bills etc. it **does not honour `?viewId=`** at all — I checked
`production-orders.component.ts`, which reads only `?status=`, `?open_receive=` and
`?open_issue=`. A `?viewId=` link there would drop the user on a list of 40 orders and do
nothing, which is precisely the failure the WP is meant to fix.

`/factory/orders/:id` is the **Order Cockpit** — a real routed record page for a single
order, and it is the app's own URL for "open this order": the orders list's `openCockpit()`
navigates to exactly that path. So this is not a new URL scheme; it is the existing one for
a record that *has* a record page. It also makes the link a clean `href` with no query
string, which is what the middle-click requirement wants.

Screens that currently ignore `?viewId=` and therefore land on the list rather than the
record: **`/core/issue-tags`**, **`/core/warehouse-transfers`**, **`/webstore/orders`**.
Their links still take you to the right screen (which is what the brief asked for the tag),
and the `viewId` param is already on the URL, so those screens become correct the moment
anyone adds the standard 6-line deep-link handler. Not in scope here.

---

## 4. Verification against live data

The resolver was run over the real `moonui2_dev_be` (company 4) movements through
`MovementSourceResolver::attach()`, which is exactly what the endpoint calls. **Every
render branch is covered by real rows:**

```
mv110 inventory_issue/39      → production_order PRD-2026-00023 id=40 route=production-orders
                                via {inventory_issue GDN-000030 id=39}
mv134 inventory_issue/56      → job_tag "امر انتاح111" id=1 route=issue-tags
                                via {inventory_issue GDN-000047 id=56}
mv139 inventory_issue/60      → job_tag "اورد 1" id=2  via {inventory_issue GDN-000051}
mv82  consignment_receipt/26  → consignment_partner "Adam ayman"  id=null route=null
mv83  consignment_borrow/1    → consignment_borrow  "Adam ayman"  id=null route=null
mv105 consignment_replenish/1 → consignment_replenish "Adam ayman" id=null route=null
mv33  inventory_issue_cancel  → inventory_issue GDN-000015 cancelled=TRUE
mv13  opening/7               → opening GRN-000007 id=7 route=stock-receipts
mv87  inventory_adjustment/1  → inventory_adjustment ADJ-000001
mv75  inventory_transfer/1    → inventory_transfer TRF-000001
mv230 sales_invoice/33        → sales_invoice INV-2026-00033
mv23  inventory_issue/14      → inventory_issue GDN-000014       ← the "seventh" movement
```

The JSON key set matched WP3's documented contract **byte for byte** — no drift between
the report and the live API.

### Correction to the brief's real-data note

The brief said the seventh issue movement (`GDN-000014`, whose order id 16 has no row)
comes back **`resolved: false`** and must render as plain text. **It does not.** The live
payload is:

```json
{"kind":"inventory_issue","label":"Stock Issue","name":"GDN-000014","id":14,
 "route_hint":"stock-issues","cancelled":false,"resolved":true,"via":null}
```

`resolved` is **true**. This is WP3's documented and correct design — *"Failed second hop
⇒ fall back to the intermediate document, not to nothing"* (WP3 report §3): the missing
production order 16 makes the resolver stop at the issue, a document this tenant genuinely
owns, with `via` left `null` because that hop never happened. So that row renders as a
neutral **Stock Issue GDN-000014** badge linked to the stock-issues screen — not a
production order, not a dangling link, not «—». That satisfies the acceptance criterion's
intent (never a broken link, never mislabelled as an order) by a different, better path.
**No `resolved:false` row exists in this database**, so the muted-label branch is covered
by code inspection and by WP3's own tests, not by a live row. Nothing here needs fixing —
flagging only so the note in the brief does not get carried forward as fact.

### Acceptance criteria

| # | Criterion | Result |
|---|---|---|
| 1 | 7 production issues on 17139 show أمر إنتاج + MO number, link opens the order | ✅ **6 of 7** (see above — the 7th is correctly the issue). Link → `/factory/orders/40` |
| 2 | Job tag shows the tag name with a badge clearly not the order badge | ✅ amber / dashed / pill / `pi-tag` vs indigo / square / `pi-cog` |
| 3 | Consignment shows the partner name with no document link | ✅ all three kinds, `id:null` → plain-text branch |
| 4 | No resolvable source → «—», not clickable | ✅ `@else { — }`; unresolved shows the muted label, also not clickable |
| 5 | The existing document column unchanged, still navigates as before | ✅ untouched; `referenceRouteMap`, `getSourceRoute()`, `navigateToSource()` all byte-identical |
| 6 | Real `routerLink`s — middle-click / new tab work | ✅ `<a [routerLink] [queryParams]>` |
| 7 | RTL intact, table does not overflow its container | ✅ flex + `gap`, no physical left/right anywhere; the table already lives in PrimeNG's `[scrollable]` container which scrolls horizontally |
| 8 | Build green, no new type errors | ✅ see below |

### Build

```
npx tsc --noEmit                  → clean, no output
npx ng build --base-href /app/    → Application bundle generation complete. [39.6 s]
                                    Initial total 1.23 MB / 261.37 kB transfer
```

Only the pre-existing warnings (CommonJS bailouts; three unrelated LIS/clinic SCSS budget
overruns). Nothing from stock-card. `DIRECT_SOURCE` and `src-badge-tag` both confirmed
present in the emitted lazy chunk `chunk-O756NSBZ.js`.

**Not deployed to `/app`** — the orchestrator deploys. `dist/` and every edited source file
are `chown moonui2:moonui2`.

---

## 5. For the orchestrator — one open item

**WP3's CHANGELOG bullet now contains a stale promise.** `docs/moonstack/CHANGELOG.md`
`[Unreleased]`, the bullet *"The stock card now works out the REAL reason behind every
movement…"*, ends with:

> "The column that displays it on screen arrives with the next update to the stock-card
> screen." / «أمّا العمود اللي بيعرضه على الشاشة فهييجي مع التحديث الجاي لشاشة كارت الصنف.»

That deferral is closed by this commit and must not ship as written.

**I did not edit it.** The file lives in the BE repo, and `git status` there shows it is
currently **modified and uncommitted** alongside another WP's in-flight work
(`StockBalanceController.php`, `StockBalanceResource.php`,
`StockBalanceTollCustomerTest.php`). Writing into a file another agent is holding would
either collide or smuggle my bullet into their commit. Please close the deferral — strike
that last sentence in both languages and fold in the column — when that WP lands.

## 6. Out of scope (untouched, as briefed)

Printing (WP6) · the stock-balances screen (WP5) · the BE resolver (WP3) ·
`referenceRouteMap` and the existing SOURCE column · adding `?viewId=` handling to
issue-tags / warehouse-transfers / webstore-orders.
