1000 || array_filter($warehouses, fn ($id) => !is_int($id) || $id < 1)) throw new \InvalidArgumentException('Invalid inventory source scope'); } private function table(string $name) { return $this->db->table($this->schema.'.'.$name); } private function movements() { return $this->table('stock_movements')->where('stock_movements.tenant_id', $this->tenant)->whereIn('warehouse_id', $this->warehouses); } /** Must be called inside the host's read-only repeatable-read transaction. */ public function revision(): array { $ledger = $this->movements()->selectRaw('count(*)::text AS count,coalesce(max(id),0)::text AS last')->first(); // Metadata is hashed in SQL, not materialised as a whole catalogue in PHP. $items = $this->table('stock_items')->where('tenant_id', $this->tenant) ->selectRaw("md5(coalesce(string_agg(jsonb_build_array(id,code,name,unit)::text, '' ORDER BY id),'')) AS hash")->first(); $warehouses = $this->table('warehouses')->where('tenant_id', $this->tenant)->whereIn('id', $this->warehouses) ->selectRaw("count(*) AS count,md5(coalesce(string_agg(jsonb_build_array(id,code,name)::text, '' ORDER BY id),'')) AS hash")->first(); if ((int) $warehouses->count !== count(array_unique($this->warehouses))) throw new \UnexpectedValueException('Unmapped inventory warehouse'); return ['ledger_count' => $ledger->count, 'ledger_last' => $ledger->last, 'catalogue' => $items->hash, 'warehouses' => $warehouses->hash]; } public function page(array $filters): array { $view = $filters['view']; $warehouse = $filters['warehouse_id'] ?? null; if ($warehouse !== null && !in_array($warehouse, $this->warehouses, true)) throw new \DomainException('Warehouse outside authorised source'); if ($view === 'items') { $q = $this->table('stock_items')->where('tenant_id', $this->tenant)->select(['id','code','name','unit'])->orderBy('id'); } elseif ($view === 'warehouses') { $q = $this->table('warehouses')->where('tenant_id', $this->tenant)->whereIn('id', $this->warehouses)->select(['id','code','name'])->orderBy('id'); } elseif (in_array($view, ['balances','movements'], true)) { $q = $this->movements() ->join($this->schema.'.stock_items AS i', fn ($j) => $j->on('i.id','=','stock_movements.stock_item_id')->on('i.tenant_id','=','stock_movements.tenant_id')) ->join($this->schema.'.warehouses AS w', fn ($j) => $j->on('w.id','=','stock_movements.warehouse_id')->on('w.tenant_id','=','stock_movements.tenant_id')); if ($warehouse !== null) $q->where('stock_movements.warehouse_id', $warehouse); if ($view === 'balances') { $q->selectRaw('stock_movements.stock_item_id,stock_movements.warehouse_id,i.code,i.name,i.unit,w.code AS warehouse_code,w.name AS warehouse_name,SUM(stock_movements.quantity_milli)::text AS physical_milli,MAX(stock_movements.id)::text AS ledger_version') ->groupBy('stock_movements.stock_item_id','stock_movements.warehouse_id','i.code','i.name','i.unit','w.code','w.name') ->orderBy('stock_movements.stock_item_id')->orderBy('stock_movements.warehouse_id'); } else { $q->select(['stock_movements.id','stock_movements.stock_item_id','stock_movements.warehouse_id','i.code','i.name','i.unit','w.code AS warehouse_code','w.name AS warehouse_name', 'stock_movements.movement_type','stock_movements.moved_at']) ->selectRaw('stock_movements.quantity_milli::text AS quantity_milli') // Only safe provenance; never return the raw document/financial snapshot. ->selectRaw("stock_movements.snapshot->>'reference' AS source_reference") ->orderBy('stock_movements.id'); } } else throw new \InvalidArgumentException('Unsupported inventory view'); $search = $filters['search'] ?? ''; if ($search !== '') { $text = '%'.str_replace(['\\','%','_'], ['\\\\','\\%','\\_'], $search).'%'; $prefix = in_array($view, ['items','warehouses'], true) ? '' : 'i.'; $q->where(fn ($s) => $s->where($prefix.'code','ilike',$text)->orWhere($prefix.'name','ilike',$text)); } $total = (int) $this->db->query()->fromSub((clone $q)->reorder(), 'inventory_rows')->count(); $perPage = $filters['per_page']; $page = $filters['page']; $rows = $q->limit($perPage)->offset(($page-1)*$perPage)->get()->map(fn ($r) => (array) $r)->all(); if ($view === 'balances') foreach ($rows as &$row) { $row += ['reserved_milli' => null, 'blocked_milli' => null, 'available_milli' => null]; } return ['rows' => $rows, 'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage, 'last_page' => max(1, (int) ceil($total/$perPage))]]; } }