Files
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

154 lines
8.3 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\AdministrativeGeography;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
use App\Services\InventoryLots;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class StockController extends Controller
{
private function actor(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User, 403);
return $actor;
}
private function access(Request $request, bool $manage = false): void
{
$roles = $manage ? [PurchasingRole::Admin, PurchasingRole::Warehouse] : [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance];
abort_unless($this->actor($request)->hasFunctions($roles), 403);
}
public function index(Request $request): Response
{
$this->access($request);
$filters = $request->validate([
'search' => 'nullable|string|max:100',
'location' => 'nullable|string|regex:/^[0-9]+$/D|max:20',
'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $this->actor($request)->tenant_id)],
]);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$geography = app(AdministrativeGeography::class);
$warehouseQuery = Warehouse::query();
if (! empty($filters['location'])) {
abort_unless($geography->availableFor('warehouses'), 503);
$warehouseQuery->where('geography_reference->catalogue', config('geography.source_sha256'))->whereIn('geography_reference->record_id', $geography->descendants($filters['location']));
}
$columns = ['id', 'code', 'name'];
if (Schema::hasColumn('warehouses', 'geography_reference')) {
$columns[] = 'geography_reference';
}
$warehouses = $warehouseQuery->orderBy('code')->get($columns);
$warehouseGeography = $warehouses->mapWithKeys(fn (Warehouse $warehouse) => [$warehouse->id => $geography->describe($warehouse->geography_reference)])->all();
$selected = StockItem::query();
if (! empty($filters['search'])) {
$selected->where(fn ($q) => $q->where('code', 'like', '%'.$filters['search'].'%')->orWhere('name', 'like', '%'.$filters['search'].'%'));
}
$movements = StockMovement::query()->whereIn('stock_item_id', $selected->select('id'));
if (! empty($filters['location'])) {
$movements->whereIn('warehouse_id', $warehouses->pluck('id'));
}
if (! empty($filters['warehouse_id'])) {
$movements->where('warehouse_id', $filters['warehouse_id']);
}
$balances = (clone $movements)->selectRaw('stock_item_id, warehouse_id, SUM(quantity_milli) as quantity_milli')->groupBy('stock_item_id', 'warehouse_id')->get()->map(fn ($balance) => [
'stock_item_id' => $balance->stock_item_id, 'warehouse_id' => $balance->warehouse_id,
'quantity_milli' => BigDecimal::of($balance->quantity_milli)->toInt(),
'lot_control' => app(InventoryLots::class)->summary($balance->stock_item_id, $balance->warehouse_id),
]);
return Inertia::render('stock/index', [
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'geographyEnabled' => $geography->availableFor('warehouses'), 'warehouseGeography' => $warehouseGeography,
'geographyFilter' => empty($filters['location']) ? null : $geography->describe(['catalogue' => config('geography.source_sha256'), 'record_id' => $filters['location'], 'country' => 'MZ']),
'movements' => $movements->latest('id')->paginate(20)->withQueryString(), 'filters' => $filters,
'canManage' => $this->actor($request)->hasFunctions([PurchasingRole::Admin, PurchasingRole::Warehouse]),
]);
}
public function item(Request $request, RequisitionWorkflow $audit): RedirectResponse
{
$this->access($request, true);
$request->merge(['code' => Str::upper(trim($request->string('code')->toString())), 'unit' => Str::lower(trim($request->string('unit')->toString()))]);
$data = $request->validate([
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('stock_items')->where('tenant_id', $this->actor($request)->tenant_id)],
'name' => 'required|string|max:200', 'unit' => 'required|string|max:30',
]);
DB::transaction(function () use ($request, $audit, $data) {
$item = StockItem::query()->create([...$data, 'tenant_id' => $this->actor($request)->tenant_id, 'created_by' => $this->actor($request)->id]);
$audit->audit($this->actor($request), 'stock.item_created', null, null, $item->toArray());
});
return back();
}
public function warehouse(Request $request, RequisitionWorkflow $audit): RedirectResponse
{
$this->access($request, true);
$request->merge(['code' => Str::upper(trim($request->string('code')->toString()))]);
$data = $request->validate([
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('warehouses')->where('tenant_id', $this->actor($request)->tenant_id)],
'name' => 'required|string|max:200',
...app(AdministrativeGeography::class)->referenceRules('warehouses'),
]);
DB::transaction(function () use ($request, $audit, $data) {
app(ConflictGuard::class)->lockCompany($this->actor($request));
$actor = app(CurrentActor::class)->lock($this->actor($request), [PurchasingRole::Admin, PurchasingRole::Warehouse]);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
if (array_key_exists('geography_reference', $data)) {
$data['geography_reference'] = app(AdministrativeGeography::class)->canonicalFor('warehouses', $data['geography_reference']);
}
$warehouse = Warehouse::query()->create([...$data, 'tenant_id' => $this->actor($request)->tenant_id, 'created_by' => $this->actor($request)->id]);
$audit->audit($this->actor($request), 'stock.warehouse_created', null, null, $warehouse->toArray());
});
return back();
}
public function warehouseGeography(Request $request, int $warehouse, RequisitionWorkflow $audit): RedirectResponse
{
$this->access($request, true);
$geography = app(AdministrativeGeography::class);
abort_unless($geography->availableFor('warehouses'), 503);
$data = $request->validate([...$geography->referenceRules('warehouses'), 'geography_reference' => 'present|nullable|array:country,catalogue,record_id', 'expected_reference' => 'present|nullable|array:country,catalogue,record_id']);
DB::transaction(function () use ($request, $warehouse, $audit, $data, $geography) {
app(ConflictGuard::class)->lockCompany($this->actor($request));
$actor = app(CurrentActor::class)->lock($this->actor($request), [PurchasingRole::Admin, PurchasingRole::Warehouse]);
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
$record = Warehouse::query()->lockForUpdate()->findOrFail($warehouse);
if ($record->geography_reference != $data['expected_reference']) {
throw ValidationException::withMessages(['geography_reference' => 'A localização mudou. Actualize antes de guardar.']);
}
$before = $record->toArray();
$after = $geography->canonicalFor('warehouses', $data['geography_reference']);
if ($record->geography_reference != $after) {
$record->update(['geography_reference' => $after]);
$audit->audit($actor, 'stock.warehouse_geography_updated', null, $before, $record->toArray());
}
});
return back();
}
}