# Inventory valuation fix + history reset (2026-09-25)

What went wrong, what was changed, what was run on the server, and what to check afterwards.

---

## 1. The problem

Dashboard "Total Inventory Value" (Somerset4x4) jumped from £8,190.07 to £8,405.88 (+£215.81) after booking in TF2142
by the scanner (+1 at £72.17). It should have added about £72.

**Root cause — the scanner endpoint booked every adjustment into the valuation twice.**
`app/Http/Controllers/Api/ProductController.php::updateStock` (`POST /api/scanner/stock-update`):
1. it updated `inventory_valuations` itself (using the price typed on the scanner), then
2. created a MoveLine in state `done`, which triggered `Webkul\InventoryValuation\Observers\MoveLineObserver`
   and booked the same move again (at the product's standard cost, because the move carried no cost).

Effects: stock value inflated, valuation quantity drifted above on-hand (valuation qty 4 vs 3 on hand for TF2142),
removals double-reduced, and the IN/OUT "Unit Cost" was blank (the move never got the cost).
It had been happening on every earlier scanner adjustment too (about 21 products were affected).

The dashboard figure = `SUM(inventory_valuations.total_value)` for internal, non-scrap locations
(`plugins/webkul/inventory-valuation/src/Filament/Widgets/InventoryValueWidget.php`).

## 2. Code changes (all deployed to the server)

| Change | Where |
|---|---|
| Scanner adjustments booked once. The MoveLine now carries `unit_cost` (scanner price, else current average); the observer is the only thing that books the valuation. Manual valuation only remains as a fallback when no adjustment location exists. | `app/Http/Controllers/Api/ProductController.php` |
| **On Hand After** = the ACTUAL stock record at that location at the moment the move is done (not a sum of moves). Recorded when a move line is created as done or flips to done. The transfer/operations flow flips state *before* moving stock, so it records again at the end of `validateTransferMoveLine`. | `plugins/webkul/inventories/src/Services/OnHandSnapshot.php`, `.../Observers/MoveLineObserver.php`, `.../InventoryManager.php` |
| **Unit Cost** filled in when a move has none: valuation total value ÷ quantity at that moment (falls back to average cost). A cost the move already has is never overwritten. | `OnHandSnapshot.php` |
| **Stock Value After** = valuation total value straight after the move. New column `inventories_move_lines.stock_value_snapshot`, written by the valuation observer. Shown in IN/OUT. | migration `2026_09_25_000001_add_stock_value_snapshot_to_inventories_move_lines`, `plugins/webkul/inventory-valuation/src/Observers/MoveLineObserver.php`, `ManageMoves.php` |
| IN/OUT number columns right-aligned; location moved to a tooltip on On Hand After. | `.../ProductResource/Pages/ManageMoves.php` |
| `inventory:reset-history` command (see section 4). | `app/Console/Commands/ResetInventoryHistory.php` |

Tests (real DB, self-cleaning): `tests/Feature/ScannerStockUpdateValuationTest.php`,
`InventoryOnHandSnapshotTest.php`, `ResetInventoryHistoryTest.php`.

## 3. Data corrections run on the server (before the reset)

- Backup table created: `inventory_valuations_backup_20260925` (copy of `inventory_valuations` before the manual fix).
- TF2142 valuation set by hand to qty 3, avg cost £71.23, value £213.69.
- 21 other products with valuation quantity ≠ on-hand set to `quantity = on-hand`, `total_value = on-hand × average_cost`
  (Somerset location id 48 only). Net change -£308.91.
- Dashboard afterwards: **£8,024.09**.

## 4. The history reset (run on the server, 2026-09-25 ~11:33)

`php artisan inventory:reset-history` — dry run by default.

Options: `--execute`, `--sku=A,B` (limit to products), `--cutoff="Y-m-d H:i"` (date on opening moves, default now),
`--include-operations` (also clear DONE moves that belong to an operation; default keeps them), `--force` (no prompt).

What it does:
1. Copies the rows it will remove to `*_archive_<YYYYMMDD>` tables: `inventories_moves`, `inventories_move_lines`,
   `inventory_valuation_lines`, `inventory_valuations`.
2. Clears the move / move-line / valuation-line history and the valuation rows for the scoped products.
3. Creates one **"Opening stock"** move per product + internal warehouse location for the current on-hand quantity,
   from the Inventory Adjustment location, with unit cost = the product's **average cost** (from the valuation;
   falls back to product cost; anything with neither is valued £0 and listed in the report).
   The normal observers then rebuild the valuation and record On Hand After / Unit Cost / Stock Value After.
4. Never changes stock quantities or reservations. Skips dropship-warehouse locations and negative quantities.

Server run: dry run planned 129 moves / 126 move lines / 166 valuation lines cleared, 305 opening moves,
all costs from average cost, new total £8,024.09 = current total. Tried on TF2142 first, then the full run.
Verified afterwards: dashboard £8,024.09; TF2142 opening +3 @ £71.23 = £213.69; ERR3340 opening +12 @ £2.255 = £27.06.

Local database was reset the same way earlier (local data differs from the server).

## 5. Known / expected things (not bugs)

- 4 negative stock rows (ERR3340M, TF117, ERR3340, GA90009, -25 each) are all on
  **Virtual Locations/Inventory Adjustment** — normal counterpart of adding stock; not counted in stock value.
- Allmakes4x4 (dropship) warehouse stock (e.g. 25 of ERR3340) has no cost / £0 value and got no opening line — as before.
- Older scanner adjustments and any history from before the reset only exist in the archive tables.

## 6. To check in a few days

- [ ] Do a small scanner add and a small scanner removal on a product: IN/OUT should show ONE line each with
      On Hand After, Unit Cost (scanner price or average) and Stock Value After filled in; Stock / Valuation qty = on hand.
- [ ] Do/see a sale (channel order): its IN/OUT line should have On Hand After + Stock Value After.
- [ ] Drift check (should return no rows for location 48):
```bash
php artisan tinker --execute='print_r(DB::select("SELECT p.sku, v.quantity AS valued_qty, COALESCE(q.quantity,0) AS on_hand, v.average_cost, v.total_value FROM inventory_valuations v JOIN products_products p ON p.id=v.product_id LEFT JOIN inventories_product_quantities q ON q.product_id=v.product_id AND q.location_id=v.location_id WHERE v.location_id=48 AND ABS(v.quantity - COALESCE(q.quantity,0)) > 0.0001"));'
```
- [ ] Dashboard total still moves by sensible amounts after sales / receipts.
- [ ] Decide whether to drop the archive tables (`*_archive_20260925`, `inventory_valuations_backup_20260925`) once happy —
      take a copy first.

## 7. Rollback (only if something is clearly wrong)

- Full DB backup taken before the reset: `~/inventory_before_reset_<date>.sql` on the server
  (`mysqldump` of the six inventory tables).
- Restore valuation rows only from the archive: copy back from `inventory_valuations_archive_20260925`.
- Do NOT re-run `inventory:reset-history --execute` twice without a reason: the second run would archive the opening
  lines and re-create them (fine, but the archive tables for that date would be appended to).

## 8. Open decisions / ideas

- Completed operations (receipts/deliveries) were left as-is (`--include-operations` not used; there were none).
- Opening lines for dropship-warehouse stock were not created (would need a cost decided per product).
- Estimated backfill of On Hand After for pre-reset history was deliberately NOT done (can't be "actual").
