154 lines
8.3 KiB
PHP
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();
|
|
}
|
|
}
|