commit15
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Inventory;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/** Read-only projection of the existing Purchasing physical ledger, not a second writer. */
|
||||
final class InventoryReader
|
||||
{
|
||||
public function __construct(private ConnectionInterface $db, private string $schema, private int $tenant, private array $warehouses)
|
||||
{
|
||||
if (!preg_match('/^[a-z][a-z0-9_]{0,62}$/D', $schema) || $tenant < 1 || !$warehouses || count($warehouses) > 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))]];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user