# Fixes: Laravel + Android app

Four things were wrong. The delivery crash and the "no customers" bug turned
out to have the same root shape: a query the framework could not resolve, and
a route that was never registered.

---

## 1. Delivery crash: `Column 'product_id' in WHERE is ambiguous`

**File:** `app/Services/StockService.php`, `stockOutFefo()`

Both `stocks` and `product_batches` carry a `product_id` column. The FEFO
query joined them and then filtered on an unqualified name:

```php
$stocks = Stock::where('depot_id', $depotId)
    ->where('product_id', $productId)          // <- which table's?
    ->where('quantity', '>', 0)
    ->whereHas('batch', fn ($q) => $q->whereDate('expiry_date', '>=', now()))
    ->join('product_batches', 'stocks.product_batch_id', '=', 'product_batches.id')
    ...
```

MySQL cannot pick one, so it rejects the whole statement with error 1052.
Delivery could never have worked — this fails on the first order, every time.

Fixed by qualifying every column and putting the join first:

```php
$stocks = Stock::query()
    ->join('product_batches', 'stocks.product_batch_id', '=', 'product_batches.id')
    ->where('stocks.depot_id', $depotId)
    ->where('stocks.product_id', $productId)
    ->where('stocks.quantity', '>', 0)
    ->whereDate('product_batches.expiry_date', '>=', now()->toDateString())
    ->orderBy('product_batches.expiry_date')
    ->select('stocks.*')
    ->lockForUpdate()
    ->get();
```

Two other things went with it:

- **The `whereHas` was dropped.** It emitted an `EXISTS` subquery against
  `product_batches` — the same rows the join had already brought in. Reading
  the expiry straight off the joined table is one filter instead of two.
- **`availableQuantity()` now uses the same join.** It gates the deduction a
  few lines below, and the two were computing "non-expired" by different
  routes. Different routes drift.

### The same bug, three more places

The pattern was copied around. None of these were failing *yet*, because they
happen not to filter on the shared column — but they break the day anyone
adds a `where('product_id', ...)`, and `expiringWithin` was doing a redundant
subquery too:

- `app/Services/ReportService.php` — `stockReport()`, `expiryReport()`
- `app/Http/Controllers/Admin/InventoryController.php` — `index()`,
  `expiryAlerts()`

All four now qualify their columns.

---

## 2. "No customers assigned" in the app

**The route did not exist.**

The app calls `/api/v1/customers/assigned`. `routes/api.php` only had
`/api/v1/customers`. Laravel returned 404, the app's repository turned any
failure into an empty list, and the screen said "no customers are assigned to
you" — which was never true.

Two fixes, one on each side:

**Laravel** — added `CustomerController::assigned()` and its route. It returns
a **flat array**, deliberately not the paginated `index`: the order form puts
the whole list in one dropdown, and paging it would have silently truncated
the choices at 20 customers.

```php
Route::get('/customers/assigned', [ApiCustomerController::class, 'assigned']);
```

Declared before any `/customers/{customer}` route, or "assigned" would be
read as an id.

**App** — `ApiClient.unwrapList()` now accepts a bare array, a paginator
(`{"data": [...]}`), or a paginated resource collection. The repository had
one hard-coded shape per endpoint, so a controller switching from `get()` to
`paginate()` emptied a screen with no error anywhere. That was a live risk:
`/customers` paginates, `/customers/assigned` does not.

---

## 3. Invoices in the app, with the products

Three endpoints the app calls did not exist at all: `/invoices/due`,
`/invoices/{id}/collect`, and nothing for invoice detail.

### New: `app/Http/Controllers/Api/V1/InvoiceController.php`

| Route | What it returns |
|---|---|
| `GET /invoices` | All invoices for this rep's customers |
| `GET /invoices/due` | Unpaid and partial only, oldest due date first |
| `GET /invoices/{invoice}` | **Header, product lines, batches, payments** |
| `POST /invoices/{invoice}/collect` | Records a field payment |

Also `GET /orders/{order}` for the same detail on an order that has not been
delivered yet.

**Where the product lines come from.** An invoice has no items of its own —
it is generated one-for-one from a delivered order, so the lines are read
through `invoice.order.items`. Each line carries the product, quantity, bonus
units, unit price, line discount, and **which physical batches shipped**,
with batch number and expiry. Those batch rows are written by the FEFO
allocation at delivery, so they are empty until the order actually ships.

Every endpoint is territory-scoped through `HasSubordinates`. A rep sees
their own customers' invoices and their subordinates'; an admin sees all;
anyone else asking for an invoice outside their tree gets a 403 rather than a
silent empty result.

### New in the app

- `InvoiceDetailScreen` — header, product lines with bonus and discount
  chips, batch numbers with expiry, totals, and payment history.
- Reachable two ways: the receipt icon on any due invoice, and tapping a
  delivered order in the Orders list.
- Models: `InvoiceDetail`, `InvoiceItem`, `InvoiceBatch`, `InvoicePayment`.

---

## 4. Money arriving as zero

Laravel's `decimal:2` cast serialises money to a JSON **string** — `"1500.00"`,
not `1500.00`. The app's `DueInvoice` parsed those with `as num?`, which
returns null for a String, so every amount read as **0.00**.

Fixed on both sides, because either alone is fragile:

- **Laravel** casts to `(float)` before returning, so the JSON carries real
  numbers.
- **App** parses with `double.tryParse(...toString())`, so a string still
  works.

A key mismatch went with it: the due-list model read `is_overdue` while the
detail model read `overdue`. The API now sends both and the app accepts
either. Worth collapsing to one name later.

---

## Deploying

```bash
php artisan route:clear
php artisan config:clear
php artisan cache:clear
```

No migrations — nothing about the schema changed.

Verify the routes registered:

```bash
php artisan route:list --path=api/v1/invoices
php artisan route:list --path=api/v1/customers
```

Then, on the app side: rebuild and try a delivery. It should now produce an
invoice instead of the 1052 error.

---

## Not verified here

There is no PHP runtime in this environment, so the Laravel changes are
reviewed but **not linted or executed**. Before deploying, run:

```bash
php -l app/Services/StockService.php
php -l app/Http/Controllers/Api/V1/InvoiceController.php
php -l app/Http/Controllers/Api/V1/CustomerController.php
php -l app/Http/Controllers/Api/V1/OrderController.php
php artisan route:list
```

Same for the app: `flutter analyze` before `flutter build`.

Only `app/`, `database/`, `resources/` and `routes/` were in the upload, so
I could not check `config/`. Two things worth confirming there:

- `config/app.php` should have `'timezone' => 'Asia/Dhaka'`, so the server
  and the app agree on where a day ends.
- The unique index on locations discussed earlier, for the ping duplicates.
