222 lines
11 KiB
PHP
222 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Http\Requests\StoreRequisitionRequest;
|
|
use App\Models\CostCenter;
|
|
use App\Models\Equipment;
|
|
use App\Models\EquipmentSparePart;
|
|
use App\Models\Requisition;
|
|
use App\Models\SparePart;
|
|
use App\Models\StockItem;
|
|
use App\Models\TechnicalReview;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\ApprovalRouting;
|
|
use App\Services\ComparisonPolicy;
|
|
use App\Services\ConflictGuard;
|
|
use App\Services\EquipmentWorkflow;
|
|
use App\Services\InternalSupplyTrace;
|
|
use App\Services\OrganizationAccess;
|
|
use App\Services\RequisitionAuditTrail;
|
|
use App\Services\RequisitionCatalogue;
|
|
use App\Services\RequisitionWorkflow;
|
|
use App\Services\SupplySuggestions;
|
|
use App\Services\WorkContext;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Validation\Rule;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class RequisitionController extends Controller
|
|
{
|
|
private function actor(Request $request): User
|
|
{
|
|
$actor = $request->user('web');
|
|
abort_unless($actor instanceof User, 403);
|
|
|
|
return $actor;
|
|
}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected', 'cancelled'])], 'view' => ['nullable', Rule::in(['all', 'mine', 'approvals', 'decided'])]]);
|
|
$query = Requisition::query()->visibleTo($this->actor($request))->with(['requester:id,name', 'department:id,name', 'steps']);
|
|
if (($filters['view'] ?? 'all') === 'mine') {
|
|
$query->where('requester_id', $this->actor($request)->id);
|
|
} elseif (($filters['view'] ?? 'all') === 'approvals') {
|
|
$eligible = (clone $query)->where('status', 'pending')->get()
|
|
->filter(fn (Requisition $requisition) => $requisition->canDecide($this->actor($request)))->modelKeys();
|
|
$query->whereIn('id', $eligible);
|
|
} elseif (($filters['view'] ?? 'all') === 'decided') {
|
|
$query->whereHas('steps', fn ($steps) => $steps->where('approver_id', $this->actor($request)->id)->whereIn('decision', ['approved', 'rejected']));
|
|
}
|
|
if (! empty($filters['search'])) {
|
|
$query->where('number', 'like', '%'.$filters['search'].'%');
|
|
}
|
|
if (! empty($filters['status'])) {
|
|
$query->where('status', $filters['status']);
|
|
}
|
|
|
|
return Inertia::render('requisitions/index', [
|
|
'requisitions' => $query->latest()->paginate(15)->withQueryString(),
|
|
'filters' => $filters,
|
|
]);
|
|
}
|
|
|
|
public function create(Request $request): Response
|
|
{
|
|
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
|
|
|
|
return Inertia::render('requisitions/form', [
|
|
'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
|
|
'requisition' => null,
|
|
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
|
|
...$this->equipmentOptions($request),
|
|
'catalogue' => $this->catalogueOptions($request),
|
|
]);
|
|
}
|
|
|
|
public function store(StoreRequisitionRequest $request, RequisitionWorkflow $workflow): RedirectResponse
|
|
{
|
|
$requisition = $workflow->save($this->actor($request), $request->validated());
|
|
|
|
return to_route('requisitions.show', $requisition);
|
|
}
|
|
|
|
public function show(Request $request, Requisition $requisition): Response
|
|
{
|
|
return DB::transaction(function () use ($request, $requisition) {
|
|
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
|
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
}
|
|
|
|
return $this->detail($request, $requisition);
|
|
});
|
|
}
|
|
|
|
private function detail(Request $request, Requisition $requisition): Response
|
|
{
|
|
$requisition = Requisition::query()->visibleTo($this->actor($request))->findOrFail($requisition->id);
|
|
Inertia::encryptHistory();
|
|
$request->validate(['audit_page' => 'nullable|integer|min:1|max:1000000']);
|
|
$trail = app(RequisitionAuditTrail::class);
|
|
$audit = $trail->query($requisition)->orderBy('id')->paginate(25, ['*'], 'audit_page')->withQueryString();
|
|
$requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']);
|
|
|
|
return Inertia::render('requisitions/show', [
|
|
'requisition' => $requisition,
|
|
'approvalRouting' => app(ApprovalRouting::class)->current($requisition, $this->actor($request)),
|
|
'canRefreshRouting' => $requisition->status === 'pending' && $this->actor($request)->hasFunctions(['admin', 'procurement']) && ! app(OrganizationAccess::class)->maintenanceOnly($this->actor($request)) && Schema::hasTable('workflow_notices'),
|
|
'replenishmentSource' => app(SupplySuggestions::class)->origin($requisition),
|
|
'automaticSource' => app(InternalSupplyTrace::class)->purchaseOrigin($this->actor($request), $requisition),
|
|
'comparisonPolicy' => app(ComparisonPolicy::class)->forRequest($requisition),
|
|
'canEdit' => app(OrganizationAccess::class)->canRequest($this->actor($request)) && $requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft',
|
|
'canDecide' => $requisition->canDecide($this->actor($request)),
|
|
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($this->actor($request), $requisition),
|
|
'audit' => $audit->getCollection()->map(fn ($a) => $trail->row($a)),
|
|
'auditPagination' => ['total' => $audit->total(), 'current_page' => $audit->currentPage(), 'last_page' => $audit->lastPage(),
|
|
'prev_page_url' => $audit->previousPageUrl(), 'next_page_url' => $audit->nextPageUrl()],
|
|
'technical' => [
|
|
'canReview' => app(EquipmentWorkflow::class)->canReview($this->actor($request), $requisition),
|
|
'basis_hash' => app(EquipmentWorkflow::class)->basis($requisition),
|
|
'reviews' => TechnicalReview::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function edit(Request $request, Requisition $requisition): Response
|
|
{
|
|
abort_unless(app(OrganizationAccess::class)->canRequest($this->actor($request)), 403);
|
|
abort_unless($requisition->requester_id === $this->actor($request)->id && $requisition->status === 'draft', 403);
|
|
|
|
return Inertia::render('requisitions/form', [
|
|
'requisition' => $requisition->load('items'),
|
|
'defaultTaxRate' => (string) (Tenant::query()->findOrFail($this->actor($request)->tenant_id)->default_tax_rate ?? '16.00'),
|
|
'costCenters' => CostCenter::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(),
|
|
...$this->equipmentOptions($request),
|
|
'catalogue' => $this->catalogueOptions($request),
|
|
]);
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function equipmentOptions(Request $request): array
|
|
{
|
|
$equipment = Equipment::query()->where('department_id', $this->actor($request)->department_id)->orderBy('code')->get(['id', 'code', 'description']);
|
|
$parts = SparePart::query()->whereIn('id', EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->select('spare_part_id'))->get(['id', 'stock_item_id']);
|
|
|
|
return ['equipment' => $equipment, 'spareParts' => $parts, 'stockItems' => StockItem::query()->whereIn('id', $parts->pluck('stock_item_id'))->get(['id', 'code', 'name', 'unit'])];
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function catalogueOptions(Request $request): array
|
|
{
|
|
$search = $request->validate(['catalogo' => 'nullable|string|max:100'])['catalogo'] ?? '';
|
|
|
|
return app(RequisitionCatalogue::class)->options($this->actor($request), $search);
|
|
}
|
|
|
|
public function technical(Request $request, Requisition $requisition): RedirectResponse
|
|
{
|
|
$data = $request->validate(['basis_hash' => 'required|string|size:64', 'decision' => ['required', Rule::in(['approved', 'rejected'])], 'reason' => 'required|string|max:4000']);
|
|
app(EquipmentWorkflow::class)->review($this->actor($request), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
|
|
|
|
return back();
|
|
}
|
|
|
|
public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
|
{
|
|
$workflow->save($this->actor($request), $request->validated(), $requisition);
|
|
|
|
return to_route('requisitions.show', $requisition);
|
|
}
|
|
|
|
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
|
{
|
|
$command = $request->validate(['operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1']);
|
|
$workflow->submit($this->actor($request), $requisition, $command['operation_key'] ?? null, $command['process_version'] ?? null);
|
|
|
|
return back();
|
|
}
|
|
|
|
public function decide(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'decision' => ['required', Rule::in(['approved', 'rejected'])],
|
|
'comment' => ['nullable', 'required_if:decision,rejected', 'string', 'max:2000'],
|
|
'operation_key' => 'nullable|uuid', 'process_version' => 'nullable|integer|min:1',
|
|
]);
|
|
$workflow->decide($this->actor($request), $requisition, $data['decision'], $data['comment'] ?? null, $data['operation_key'] ?? null, $data['process_version'] ?? null);
|
|
|
|
return back();
|
|
}
|
|
|
|
public function dashboard(Request $request): Response
|
|
{
|
|
$actor = $request->user('web');
|
|
abort_unless($actor instanceof User, 403);
|
|
$contexts = app(WorkContext::class);
|
|
if ($contexts->enforced($actor)) {
|
|
return Inertia::render('work-context', ['context' => $contexts->shared($request)]);
|
|
}
|
|
$visible = Requisition::query()->visibleTo($this->actor($request));
|
|
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
|
|
|
|
return Inertia::render('dashboard', [
|
|
'company' => $this->actor($request)->tenant_id ? Tenant::query()->find($this->actor($request)->tenant_id) : null,
|
|
'stats' => [
|
|
'total' => (clone $visible)->count(),
|
|
'pending' => $pending->count(),
|
|
'approved' => (clone $visible)->where('status', 'approved')->count(),
|
|
'approvedMinor' => (clone $visible)->where('status', 'approved')->sum('total_minor'),
|
|
'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->count(),
|
|
],
|
|
'recent' => (clone $visible)->with(['requester:id,name', 'department:id,name'])->latest()->limit(6)->get(),
|
|
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($this->actor($request)))->take(5)->values(),
|
|
]);
|
|
}
|
|
}
|