# Stock issues + stock receipts — pagination, period filters and search · report

**Status:** DONE. Three commits on `hazemdev2`, none pushed, none merged.

| Repo | Commit | Scope |
|---|---|---|
| BE `/home/moonui2/moon-erp-be` | `69ab8e0d4` | shared filter trait, both controllers, 13 tests, CHANGELOG |
| FE `/home/moonui2/public_html/moon-erp` | `9b44714ad` | shared list-filter class, both screens, services, i18n, scss |
| FE (follow-up) | `34197f7a8` | «الكل» period button, complete type-filter lists, re-search fix |

Tests: `StockDocumentSearchFiltersTest` **13 passed (81 assertions)**; regression on
`InventoryIssueApiTest` + `InventoryReceiptApiTest` + `InventoryIssueJobTagLinkTest`
**90 passed (276 assertions)**, zero failures. `ng build` and `tsc --noEmit -p tsconfig.app.json`
both green. **Not deployed to `/app`** (the brief overrides the standing "always deploy" memory).

---

## Precedence rule when both `period` and explicit dates are sent

**If EITHER `date_from` or `date_to` is present, `period` is ignored entirely.**

It is all-or-nothing on purpose, not an intersection and not a per-bound fallback. Mixing the
two (`period=month` plus a `date_from` in the previous year) can only produce a window nobody
asked for. So a single explicit bound disables the shortcut just as fully as both do —
`period=today&date_from=2026-01-01` means "from 1 Jan onwards", not "today".

The frontend mirrors this rather than relying on it: pressing a period button clears both date
pickers, and touching a date picker clears the period selection, so the screen can never show
two controls where only one is in force. Two tests pin the rule
(`lets an explicit date win over the period shortcut`).

---

## Backend

### `Modules/Inventory/app/Http/Controllers/Concerns/FiltersStockDocuments.php` (new)

One trait, used by `InventoryIssueController` and `InventoryReceiptController`. The two
controllers previously carried two copies of "search", and the copies had already drifted
(each matched only its own document number). Three helpers:

- **`documentPerPage()`** — `min((int) request('per_page', 25), 100)`, copied verbatim from
  `ProductController::index` rather than re-invented, plus a guard so a non-positive value
  falls back to 25 instead of asking for `LIMIT 0`.
- **`applyPeriodFilter()`** — `date_from` / `date_to` and the `today|week|month` shortcut,
  with the precedence rule above. `week` = start of current week → today, `month` = 1st →
  today, `today` = today only. An unknown `period` value is ignored (returns everything),
  not treated as an empty window.

  ⚠ **«الأسبوع» starts on MONDAY** — `Carbon::today()->startOfWeek()` with Carbon's default.
  I checked for a repo convention: the only explicit one is HRM's scheduling tests, which
  pin `Carbon::MONDAY`, and nothing sets `setWeekStartsAt` or a `firstDayOfWeek` anywhere.
  So Monday matches the codebase — but for an Egyptian storekeeper the working week
  plausibly starts **Saturday**. Flagging it rather than guessing: if the owner wants
  Saturday it is one argument (`startOfWeek(Carbon::SATURDAY)`) in the trait, and the tests
  do not discriminate today (the fixture falls inside the window either way).
- **`applyDocumentSearch()`** — the widened search.

### Trap 1 — the grouped closure

The whole search is `$query->where(function (Builder $q) { … })`, which emits
`and (… or … or …)`. Every alternative is added onto `$q`, never onto the outer builder.
Guarded by three tests: search+status+warehouse on issues, the same on receipts, and
search+date-window. Each plants decoys that match the search but fail one other filter — if
the `orWhere`s escape the group, those decoys come back and the tests fail.

### Trap 2 — the convention polymorph

`reference_type` is an enum and `reference_id` points at a different table per value; there
is no relation to join. Each type gets its own subquery, and each subquery is
**company-scoped explicitly**:

```php
$q->orWhere(fn ($ref) => $ref
    ->where('reference_type', IssueReferenceType::ProductionOrder->value)
    ->whereIn('reference_id', ProductionOrder::query()
        ->where('company_id', $companyId)          // the tenant guard
        ->where('order_number', 'like', "%{$search}%")
        ->select('id')));
```

Each branch also pins its own `reference_type`, so a job tag whose id collides with a
production-order id cannot cross-match. Two tests cover it: another company's production
order matches nothing, another company's job tag matches nothing.

The production-order branch is wrapped in `class_exists(ProductionOrder::class)`, mirroring
`InventoryIssueResource::referenceNumber()` — Inventory keeps no hard dependency on
Production, because the client runs the warehouses module without it.

### Deliberate asymmetry: job tags are issues-only

`ReceiptReferenceType` has no `JobTag` case, so a job-tag branch on receipts could only ever
match nothing. `applyDocumentSearch()` takes `withJobTag: true` from the issue controller
only. Receipts therefore search five targets, issues six. This is the enum's shape, not an
omission.

### One behaviour change worth flagging: `whereDate`

Date bounds now compare via `whereDate` instead of a bare string comparison. The column is a
`DATE`, but a driver may hand back `2026-03-18 00:00:00`, and then
`date <= '2026-03-18'` is a **string** comparison that excludes the upper bound's own day —
"from the 1st to the 18th" silently dropped everything dated the 18th. This was a latent bug
in the pre-existing `date_to` filter, surfaced by the period tests. The idiom matches what
`InventoryReportController` already does on this very column. Cost: on MySQL the
`['company_id','date']` index can no longer serve the range. At 18–19 documents this is
irrelevant, and the brief is explicit that correctness beats optimisation.

---

## Frontend

### `shared/util/stock-document-list-filters.ts` (new) — one class, both screens

Owns the request pipeline, the filter state, the paging and the URL sync. The two components
each construct one and delegate; nothing about paging or filtering is duplicated per screen.

**Criterion 7, by construction** (there is no FE test runner in this repo):

- *One request per pause* — keystrokes go to `searchInput`, a Subject piped through
  `debounceTime(400)`. Nothing else is debounced: a select, a
  period button and a page change must feel instant, so they call `loadPage()` directly.
- *A late reply cannot win* — **every** list request leaves through the `pageRequests`
  Subject, whose pipe is `switchMap`. switchMap unsubscribes the in-flight request (aborting
  its HTTP call) the moment a newer one is asked for, so a slow answer to an older query is
  cancelled and can never reach `items.set()`. Debounce alone does not give this: two
  requests 500 ms apart both survive the debounce and can still land out of order. This is
  the pairing `products.component.ts` settled on two days ago for exactly this bug.
- `catchError` sits **inside** the switchMap projection, so a failure is confined to the one
  request; hoisted outside, the first error would complete the outer stream and the screen
  would stop answering filters entirely.

**Criterion 1** — `init()` loads page 1; the lazy `<p-table>`'s own initial `onLazyLoad` is
swallowed by an `initialLoaded` guard, so opening either screen issues exactly one request
for one page.

**Criterion 2** — `[lazy]="true"`, `totalRecords` from `meta.total`, `[first]`/`[rows]` bound
to the class. `buildFilters()` is re-snapshotted on every request, so paging carries every
active filter.

**Re-searching the same term works.** `distinctUntilChanged()` — which the products
precedent uses — remembers its last emission for the life of the stream, so "Orion" →
clear-filters → "Orion" again is *suppressed*: the box shows the text and the table stays
unfiltered. The shared class compares against the search term actually in force instead,
so `clearFilters()` resets the comparison for free. Worth porting back to products.

**URL sync** — `q · status · wh · ref · period · from · to`, `queryParamsHandling: 'merge'`
and `replaceUrl: true` (the search box writes on every typing pause; each pause must not
become a history entry). `readFiltersFromUrl()` restores on open and re-applies the
period-vs-dates precedence to a hand-edited link.

Two details worth knowing:

- Dates are formatted `YYYY-MM-DD` in **local** time. `toISOString()` converts to UTC first,
  which east of Greenwich turns "the 1st" into "the 31st" and shifts the whole window.
- The date pickers bind `onSelect`, `onClear` **and** `onBlur` — a date *typed* into the
  field fires neither of the first two. Because `onBlur` fires on every blur, `onDateChange()`
  compares the `from|to` pair against the one already in force and no-ops when unchanged, so
  tabbing through the field costs nothing.

### Filter lists are built independently of the create dialog's

Both screens build a **separate** `referenceTypeFilterOptions`, not the dialog's
`referenceTypeOptions`. The dialog list is what you may *create*; the filter must name every
type a document can *already carry*, or those documents are visible in the table yet
unfilterable. Concretely: issues hide «أمر إنتاج» from the dialog when the company switch is
off, and receipts never offer `purchase_bill` (the controlled purchases flow raises those) or
`production_order`. All of them are now in the filter lists. One new bilingual key was needed
for this — `INVENTORY.REF_PURCHASE_BILL`.

The period group also gained an explicit **«الكل»** button (`setPeriod(null)`). Re-clicking
the active button already cleared it, but that is undiscoverable, and the clear-filters icon
drops every filter rather than just the period.

### Touched files

`core/services/stock-document-filters.ts` (new, the shared query shape),
`stock-issue.service.ts` / `stock-receipt.service.ts` (optional `filters` on `list()`;
`listAll()` left in place but marked `@deprecated` — it has no callers left and is precisely
the pattern that caused this ticket), both components + templates,
`features/_stock-document-filter-bar.scss` (new, `@use`d by both component stylesheets
following the `production/_report-shared.scss` precedent), and four new `INVENTORY` keys in
**both** `ar.json` and `en.json` (`STATUS_PENDING_APPROVAL`, `STATUS_CANCELLED`,
`DOC_SEARCH_PLACEHOLDER`, `RECEIPT_SEARCH_PLACEHOLDER`). The i18n edits are strictly
additive — verified by diff, 4 lines added per file, nothing reordered or removed.

`loadData()` was kept under its old name on both components and now delegates to
`list.reload()`, so the ~10 existing call sites (header refresh, approval actions, every
save/cancel/delete) needed no edits. The delete paths that used to splice the row out of the
local array now reload the page instead — with server paging, splicing would leave the
paginator's total and the rest of the page stale.

---

## Acceptance criteria

| # | Criterion | Where |
|---|---|---|
| 1 | one request, one page on open | `initialLoaded` guard + `init()` |
| 2 | totalRecords from server; paging keeps filters | `keeps every active filter while paging` |
| 3 | all six search targets | `searches issues by every one of the six targets` (+ receipts twin) |
| 4 | search composes with status + warehouse | 3 tests, issues + receipts + date window |
| 5 | today/week/month + date precedence | 3 tests (week = Monday-start — see flag above) |
| 6 | another company's production order matches nothing | 3 tests (PO, job tag, whole document) |
| 7 | debounce + no stale repaint | by construction, documented above |
| 8 | build + typecheck green | `ng build`, `tsc --noEmit`, both clean |

## Notes / concerns

- **Criterion 8 says `pest Modules/Inventory`; the environment rule says run your own file
  only.** The environment rule won: the module suite is the `/fullpush` gate, not a
  per-package gate. I ran my file plus the three most directly affected existing test files
  (90 tests) as the regression check.
- **`whereDate` costs the index** on the `date` range, as described above. If the document
  count ever reaches a scale where that matters, the index-friendly form is comparing against
  `"$to 23:59:59"` rather than reverting to the bare string compare — which is the bug.
- **Job tags on receipts** are absent by enum design, not oversight. If receipts ever gain a
  `JobTag` reference type, flipping `withJobTag: true` in `InventoryReceiptController` is the
  whole change.
- **`listAll()` is now dead on both services.** I deprecated rather than deleted it (zero
  callers, so deletion is safe, but it is outside the brief's scope). It should probably go
  in a later cleanup.
- **«الأسبوع» = Monday-start**, matching the only convention in the repo. Easiest thing on
  this page for the owner to veto; see the period section.
- **The `distinctUntilChanged` re-search bug exists in `products.component.ts`**, which is
  where this pipeline was copied from. Not fixed there (out of scope), but it is the same
  defect and the shared class is now the corrected copy.
- Not deployed to `/app`, not pushed, not merged — all three per the brief.
