commit
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-28 12:06:05 +02:00
parent 90a6848721
commit 4d42f083ce
82 changed files with 7538 additions and 76 deletions
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\CostCenter;
use App\Models\Department;
use App\Models\Tenant;
use App\Models\User;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class CompanyController extends Controller
{
public function setup(Request $request): Response|RedirectResponse
{
return $request->user()->tenant_id ? to_route('dashboard') : Inertia::render('company/setup');
}
public function store(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{
$data = $request->validate(['name' => 'required|string|max:150', 'department' => 'required|string|max:100']);
DB::transaction(function () use ($request, $data, $workflow) {
$user = User::query()->lockForUpdate()->findOrFail($request->user()->id);
abort_if($user->tenant_id !== null, 403);
$tenant = Tenant::query()->create(['name' => $data['name']]);
$department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => $data['department']]);
CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'GERAL', 'name' => 'Centro de custo geral']);
$user->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Admin])->save();
$workflow->audit($user, 'company.created', null, null, ['name' => $data['name']]);
});
return to_route('dashboard');
}
public function index(Request $request): Response
{
$this->admin($request);
return Inertia::render('company/index', [
'company' => Tenant::query()->findOrFail($request->user()->tenant_id),
'departments' => Department::query()->orderBy('name')->get(),
'costCenters' => CostCenter::query()->orderBy('code')->get(),
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id', 'purchasing_role']),
'roles' => collect(PurchasingRole::cases())->map(fn (PurchasingRole $role) => ['value' => $role->value, 'label' => $role->label()]),
]);
}
public function department(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{
$this->admin($request);
$data = $request->validate(['name' => ['required', 'string', 'max:100', Rule::unique('departments')->where('tenant_id', $request->user()->tenant_id)]]);
DB::transaction(function () use ($request, $data, $workflow) {
$department = Department::query()->create(['tenant_id' => $request->user()->tenant_id, ...$data]);
$workflow->audit($request->user(), 'department.created', null, null, $department->toArray());
});
return back();
}
public function costCenter(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{
$this->admin($request);
$data = $request->validate([
'department_id' => ['required', Rule::exists('departments', 'id')->where('tenant_id', $request->user()->tenant_id)],
'code' => ['required', 'string', 'max:30', Rule::unique('cost_centers')->where('tenant_id', $request->user()->tenant_id)],
'name' => 'required|string|max:100',
]);
DB::transaction(function () use ($request, $data, $workflow) {
$center = CostCenter::query()->create(['tenant_id' => $request->user()->tenant_id, ...$data]);
$workflow->audit($request->user(), 'cost_center.created', null, null, $center->toArray());
});
return back();
}
public function user(Request $request, RequisitionWorkflow $workflow): RedirectResponse
{
$this->admin($request);
$data = $request->validate([
'name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users',
'password' => 'required|string|min:12|confirmed',
'department_id' => ['required', Rule::exists('departments', 'id')->where('tenant_id', $request->user()->tenant_id)],
'purchasing_role' => ['required', Rule::enum(PurchasingRole::class)],
]);
DB::transaction(function () use ($request, $data, $workflow) {
$user = new User;
$user->forceFill($data + ['tenant_id' => $request->user()->tenant_id])->save();
$workflow->audit($request->user(), 'user.created', null, null, $user->only(['id', 'name', 'email', 'department_id', 'purchasing_role']));
});
return back();
}
private function admin(Request $request): void
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
}
}
@@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers;
use App\Models\ConflictDeclaration;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Services\ConflictGuard;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConflictDeclarationController extends Controller
{
public function index(Request $request): Response
{
return Inertia::render('conflicts/index', [
'declarations' => ConflictDeclaration::query()->visibleTo($request->user())
->with(['author:id,name', 'supplier:id,name', 'requisition:id,number', 'successor:id,previous_id'])
->latest('id')->paginate(15),
// These minimal choices allow personal declarations without granting supplier-management access.
'suppliers' => Supplier::query()->orderBy('name')->get(['id', 'name']),
'requisitions' => Requisition::query()->visibleTo($request->user())->latest()->get(['id', 'number']),
]);
}
public function store(Request $request, ConflictGuard $guard, RequisitionWorkflow $workflow): RedirectResponse
{
$data = $request->validate([
'scope' => ['required', Rule::in(['supplier', 'process', 'process_supplier'])],
'supplier_id' => ['nullable', 'required_if:scope,supplier,process_supplier', 'prohibited_if:scope,process', 'integer', Rule::exists('suppliers', 'id')->where('tenant_id', $request->user()->tenant_id)],
'requisition_id' => ['nullable', 'required_if:scope,process,process_supplier', 'prohibited_if:scope,supplier', 'integer', Rule::exists('requisitions', 'id')->where('tenant_id', $request->user()->tenant_id)],
'has_conflict' => 'required|boolean', 'known_since' => 'required|date_format:Y-m-d|before_or_equal:today',
'reason' => 'required|string|min:10|max:5000',
'user_id' => 'prohibited', 'tenant_id' => 'prohibited', 'declared_at' => 'prohibited',
'root_id' => 'prohibited', 'previous_id' => 'prohibited',
]);
DB::transaction(function () use ($request, $guard, $workflow, $data) {
$guard->lockCompany($request->user());
if (! empty($data['requisition_id'])) {
Requisition::query()->visibleTo($request->user())->findOrFail($data['requisition_id']);
}
$declaration = ConflictDeclaration::query()->create([
'tenant_id' => $request->user()->tenant_id, 'user_id' => $request->user()->id,
'supplier_id' => $data['supplier_id'] ?? null, 'requisition_id' => $data['requisition_id'] ?? null,
'has_conflict' => $data['has_conflict'], 'known_since' => $data['known_since'],
'reason' => $data['reason'], 'declared_at' => now(),
]);
$workflow->audit($request->user(), 'conflict.declared', null, null, $declaration->toArray());
});
return to_route('conflicts.index');
}
public function show(Request $request, ConflictDeclaration $declaration): Response
{
ConflictDeclaration::query()->visibleTo($request->user())->findOrFail($declaration->id);
$rootId = $declaration->root_id ?? $declaration->id;
$history = ConflictDeclaration::query()->where(fn ($query) => $query->whereKey($rootId)->orWhere('root_id', $rootId))
->with(['author:id,name', 'supplier:id,name', 'requisition:id,number'])->orderBy('id')->get();
$latest = $history->last();
return Inertia::render('conflicts/show', ['history' => $history, 'canCorrect' => $latest?->user_id === $request->user()->id]);
}
public function update(Request $request, ConflictDeclaration $declaration, ConflictGuard $guard, RequisitionWorkflow $workflow): RedirectResponse
{
abort_unless($declaration->user_id === $request->user()->id, 403);
$data = $request->validate([
'known_since' => 'required|date_format:Y-m-d|before_or_equal:today', 'reason' => 'required|string|min:10|max:5000',
'has_conflict' => 'prohibited', 'supplier_id' => 'prohibited', 'requisition_id' => 'prohibited',
'user_id' => 'prohibited', 'tenant_id' => 'prohibited', 'declared_at' => 'prohibited',
'root_id' => 'prohibited', 'previous_id' => 'prohibited',
]);
DB::transaction(function () use ($request, $guard, $workflow, $declaration, $data) {
$guard->lockCompany($request->user());
$declaration = ConflictDeclaration::query()->lockForUpdate()->findOrFail($declaration->id);
if ($declaration->successor()->exists()) {
throw ValidationException::withMessages(['history' => 'Já existe uma correcção mais recente. Reabra o histórico antes de corrigir.']);
}
$revision = ConflictDeclaration::query()->create([
...$declaration->only(['tenant_id', 'user_id', 'supplier_id', 'requisition_id', 'has_conflict']),
...$data, 'declared_at' => now(), 'root_id' => $declaration->root_id ?? $declaration->id, 'previous_id' => $declaration->id,
]);
$workflow->audit($request->user(), 'conflict.corrected', null, $declaration->toArray(), $revision->toArray());
});
return to_route('conflicts.show', $declaration);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Services\ProposalComparison;
use App\Services\PurchaseWorkflow;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class PurchaseController extends Controller
{
private function access(Request $request): void
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
}
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
{
$this->access($request);
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
return Inertia::render('purchases/award', [
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id,
'canDecide' => $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
'canIssue' => $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
]);
}
public function select(Request $request, Requisition $requisition, PurchaseWorkflow $workflow): RedirectResponse
{
$this->access($request);
$data = $request->validate([
'quotation_id' => 'required|integer', 'justification' => 'required|string|min:10|max:5000',
'delivery_date' => 'required|date_format:Y-m-d|after_or_equal:today', 'delivery_location' => 'required|string|min:3|max:500',
'status' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
'approval_snapshot' => 'prohibited', 'decision_matrix' => 'prohibited', 'created_by' => 'prohibited', 'tenant_id' => 'prohibited',
'override' => 'prohibited', 'exception' => 'prohibited',
]);
$workflow->select($request->user(), $requisition, $data);
return to_route('purchases.award', $requisition);
}
public function decide(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
{
$this->access($request);
$data = $request->validate(['decision' => ['required', Rule::in(['approved', 'rejected'])], 'comment' => 'nullable|required_if:decision,rejected|string|max:2000']);
$workflow->decide($request->user(), $award, $data['decision'], $data['comment'] ?? null);
return to_route('purchases.award', $award->requisition_id);
}
public function issue(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
{
$this->access($request);
$request->validate([
'status' => 'prohibited', 'snapshot' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
'issued_by' => 'prohibited', 'tenant_id' => 'prohibited', 'quotation_id' => 'prohibited', 'override' => 'prohibited', 'exception' => 'prohibited',
]);
$order = $workflow->issue($request->user(), $award);
return to_route('orders.show', $order);
}
public function index(Request $request): Response
{
$this->access($request);
return Inertia::render('purchases/index', [
'orders' => PurchaseOrder::query()->visibleTo($request->user())->latest('id')->paginate(15),
'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
]);
}
public function show(Request $request, PurchaseOrder $order): Response
{
$this->access($request);
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
return Inertia::render('purchases/show', [
'order' => $order->load('items'),
'history' => AuditLog::query()->where('requisition_id', $order->requisition_id)->whereIn('action', ['award.created', 'award.approved', 'award.rejected', 'purchase_order.issued'])->orderBy('id')->get(['id', 'user_id', 'action', 'created_at']),
]);
}
public function print(Request $request, PurchaseOrder $order): View
{
$this->access($request);
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot]);
}
}
@@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\ProposalEvaluation;
use App\Models\PurchaseAward;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Services\ConflictGuard;
use App\Services\ProposalComparison;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class QuotationController extends Controller
{
private function access(Request $request): void
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
}
public function index(Request $request): Response
{
$this->access($request);
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($request->user())->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
}
public function show(Request $request, Requisition $requisition): Response
{
$this->access($request);
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
$guard = app(ConflictGuard::class);
$result = app(ProposalComparison::class)->inspect($requisition);
$scores = array_map(fn ($row) => [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $request->user())], $result['rows']);
$closed = PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
return Inertia::render('quotations/show', [
'requisition' => $requisition->load('items'), 'quotations' => $scores,
'suppliers' => Supplier::query()->where('status', 'active')->where('blocked', false)->whereDate('next_review', '>=', today())->orderBy('name')->get(['id', 'name']),
'canCreate' => ! $closed && $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
'canEvaluate' => ! $closed && $request->user()->purchasing_role !== PurchasingRole::Admin && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
'ready' => $result['ready'],
]);
}
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Procurement, 403);
$data = $request->validate([
'supplier_id' => 'required|integer', 'reference' => 'required|string|max:100',
'delivery_days' => 'required|integer|min:1|max:3650', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today',
'payment_terms' => 'required|string|min:3|max:500', 'no_conflict' => 'accepted',
'items' => 'required|array|min:1|max:100', 'items.*.requisition_item_id' => 'required|integer|distinct',
'items.*.unit_price' => ['required', 'numeric', 'decimal:0,2', 'min:0.01', 'max:1000000000'],
]);
DB::transaction(function () use ($request, $requisition, $data, $workflow) {
$guard = app(ConflictGuard::class);
$guard->lockCompany($request->user());
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($requisition->status === 'approved', 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
throw ValidationException::withMessages(['award' => 'As propostas estão fechadas pela adjudicação deste processo.']);
}
$guard->assertParticipation($request->user(), $requisition);
$supplier = Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail();
$guard->assertProcess($requisition, $supplier->id, $request->user());
if (! $supplier->eligible()) {
throw ValidationException::withMessages(['supplier_id' => 'O fornecedor precisa de estar activo, sem impedimento e com revisão válida.']);
}
if (Quotation::query()->where('requisition_id', $requisition->id)->where('supplier_id', $supplier->id)->exists()) {
throw ValidationException::withMessages(['supplier_id' => 'Já existe uma proposta deste fornecedor neste processo.']);
}
$lines = $requisition->items()->get()->keyBy('id');
if ($lines->count() !== count($data['items'])) {
throw ValidationException::withMessages(['items' => 'A proposta deve conter todas as linhas da requisição.']);
}
$items = [];
$subtotal = 0;
$tax = 0;
foreach ($data['items'] as $input) {
$line = $lines->get($input['requisition_item_id']);
if (! $line) {
throw ValidationException::withMessages(['items' => 'Artigo não pertence a esta requisição.']);
}
$price = BigDecimal::of($input['unit_price'])->multipliedBy(100)->toScale(0)->toInt();
$amount = BigDecimal::of($line->quantity)->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt();
$vat = BigDecimal::of($amount)->multipliedBy($line->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
$subtotal += $amount;
$tax += $vat;
if ($subtotal + $tax > 100000000000000) {
throw ValidationException::withMessages(['items' => 'Valor total acima do limite suportado.']);
}
$items[] = ['tenant_id' => $request->user()->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
}
if ($subtotal <= 0) {
throw ValidationException::withMessages(['items' => 'O total arredondado deve ser pelo menos 0,01 MZN.']);
}
$quote = Quotation::query()->create([
'tenant_id' => $request->user()->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $request->user()->id,
'reference' => $data['reference'], 'delivery_days' => $data['delivery_days'], 'valid_until' => $data['valid_until'], 'payment_terms' => $data['payment_terms'],
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'currency' => 'MZN',
'weights' => ['price' => 40, 'quality' => 20, 'delivery' => 15, 'history' => 15, 'payment' => 10],
]);
$quote->items()->createMany($items);
$workflow->audit($request->user(), 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
});
return back();
}
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
{
$this->access($request);
abort_if($request->user()->purchasing_role === PurchasingRole::Admin, 403);
$data = $request->validate(['quality' => 'required|numeric|decimal:0,2|between:0,10', 'history' => 'required|numeric|decimal:0,2|between:0,10', 'payment' => 'required|numeric|decimal:0,2|between:0,10', 'comment' => 'required|string|min:10|max:2000', 'no_conflict' => 'accepted']);
DB::transaction(function () use ($request, $quotation, $data, $workflow) {
$guard = app(ConflictGuard::class);
$guard->lockCompany($request->user());
$requisition = Requisition::query()->visibleTo($request->user())->lockForUpdate()->findOrFail($quotation->requisition_id);
abort_unless($requisition->status === 'approved' && $requisition->requester_id !== $request->user()->id, 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
throw ValidationException::withMessages(['award' => 'As avaliações estão fechadas pela adjudicação deste processo.']);
}
$guard->assertParticipation($request->user(), $requisition);
$quotation = Quotation::query()->lockForUpdate()->findOrFail($quotation->id);
$guard->assertProcess($requisition, $quotation->supplier_id, $request->user());
if ($quotation->valid_until < today() || ! $quotation->supplier?->eligible()) {
throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
}
if ($quotation->evaluations()->where('evaluator_id', $request->user()->id)->exists()) {
throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
}
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $request->user()->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $request->user()->id]);
$workflow->audit($request->user(), 'quotation.evaluated', $requisition, null, $evaluation->toArray());
});
return back();
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreRequisitionRequest;
use App\Models\AuditLog;
use App\Models\CostCenter;
use App\Models\Requisition;
use App\Models\Tenant;
use App\Services\ConflictGuard;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class RequisitionController extends Controller
{
public function index(Request $request): Response
{
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected'])]]);
$query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']);
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
{
return Inertia::render('requisitions/form', [
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
'requisition' => null,
]);
}
public function store(StoreRequisitionRequest $request, RequisitionWorkflow $workflow): RedirectResponse
{
$requisition = $workflow->save($request->user(), $request->validated());
return to_route('requisitions.show', $requisition);
}
public function show(Request $request, Requisition $requisition): Response
{
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
$requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']);
return Inertia::render('requisitions/show', [
'requisition' => $requisition,
'canEdit' => $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
'canDecide' => $requisition->canDecide($request->user()),
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at']),
]);
}
public function edit(Request $request, Requisition $requisition): Response
{
abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403);
return Inertia::render('requisitions/form', [
'requisition' => $requisition->load('items'),
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
]);
}
public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
$workflow->save($request->user(), $request->validated(), $requisition);
return to_route('requisitions.show', $requisition);
}
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
$workflow->submit($request->user(), $requisition);
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'],
]);
$workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null);
return back();
}
public function dashboard(Request $request): Response
{
$visible = Requisition::query()->visibleTo($request->user());
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
return Inertia::render('dashboard', [
'company' => $request->user()->tenant_id ? Tenant::query()->find($request->user()->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($request->user()))->count(),
],
'recent' => (clone $visible)->with(['requester:id,name', 'department:id,name'])->latest()->limit(6)->get(),
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->take(5)->values(),
]);
}
}
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -50,6 +51,12 @@ class ProfileController extends Controller
{
$user = $request->user();
if ($user->tenant_id !== null) {
throw ValidationException::withMessages([
'password' => 'Uma conta associada a uma empresa não pode ser eliminada desta forma, para preservar os registos de compras e auditoria.',
]);
}
Auth::logout();
$user->delete();
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Http\Requests\SaveSupplierRequest;
use App\Models\Supplier;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class SupplierController extends Controller
{
public function index(Request $request): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$filters = $request->validate(['search' => 'nullable|string|max:100']);
$query = Supplier::query();
if (! empty($filters['search'])) {
$query->where(fn ($q) => $q->where('name', 'like', '%'.$filters['search'].'%')->orWhere('nuit', 'like', '%'.$filters['search'].'%'));
}
return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->purchasing_role === PurchasingRole::Procurement, 'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance]);
}
public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse
{
$data = $request->validated();
DB::transaction(function () use ($request, $data, $workflow) {
$supplier = Supplier::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'last_changed_by' => $request->user()->id, 'status' => 'pending']);
$workflow->audit($request->user(), 'supplier.created', null, null, $supplier->toArray());
});
return back();
}
public function show(Request $request, Supplier $supplier): Response
{
Gate::authorize('view', $supplier);
$canDocuments = $request->user()->can('viewDocuments', $supplier);
return Inertia::render('suppliers/show', [
'supplier' => $supplier,
'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [],
'canEdit' => $request->user()->can('update', $supplier),
'canDocuments' => $canDocuments,
'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance,
]);
}
public function edit(Supplier $supplier): Response
{
Gate::authorize('update', $supplier);
return Inertia::render('suppliers/edit', ['supplier' => $supplier]);
}
public function update(SaveSupplierRequest $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
{
Gate::authorize('update', $supplier);
$data = $request->validated();
DB::transaction(function () use ($request, $supplier, $workflow, $data) {
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
if ($supplier->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'A ficha foi alterada por outro utilizador. Reabra a edição para carregar os dados actuais.']);
}
$before = $supplier->toArray();
unset($data['version']);
$supplier->fill($data);
if ($supplier->isDirty()) {
$supplier->fill(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $request->user()->id, 'version' => $supplier->version + 1])->save();
$workflow->audit($request->user(), 'supplier.updated', null, $before, $supplier->toArray());
}
});
return to_route('suppliers.show', $supplier);
}
public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
$data = $request->validate(['status' => ['required', Rule::in(['active', 'suspended'])], 'comment' => 'required|string|min:10|max:2000', 'version' => 'required|integer|min:1']);
DB::transaction(function () use ($request, $supplier, $data, $workflow) {
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
abort_if($supplier->created_by === $request->user()->id, 403);
abort_if($supplier->last_changed_by === $request->user()->id, 403);
if ($supplier->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'A ficha ou os documentos foram alterados. Reabra a ficha antes de validar.']);
}
if ($data['status'] === 'active' && ($supplier->blocked || $supplier->next_review < today())) {
throw ValidationException::withMessages(['status' => 'Fornecedor impedido ou com revisão vencida. Não pode ser activado.']);
}
$before = $supplier->toArray();
$supplier->update(['status' => $data['status'], 'review_comment' => $data['comment'], 'reviewed_by' => $request->user()->id, 'version' => $supplier->version + 1]);
$workflow->audit($request->user(), 'supplier.reviewed', null, $before, $supplier->toArray());
});
return back();
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use App\Models\Supplier;
use App\Models\SupplierDocument;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class SupplierDocumentController extends Controller
{
public function store(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
{
Gate::authorize('update', $supplier);
$data = $request->validate([
'document_type' => ['required', Rule::in(['license', 'tax', 'conflict', 'other'])],
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'extensions:pdf,jpg,jpeg,png', 'min:1', 'max:2048'],
]);
$file = $request->file('file');
abort_unless($file instanceof UploadedFile, 422);
$path = $file->store($supplier->tenant_id.'/'.$supplier->id, 'supplier_documents');
abort_if($path === false, 500);
try {
DB::transaction(function () use ($request, $supplier, $workflow, $data, $file, $path) {
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
$before = $supplier->toArray();
$name = Str::substr(Str::replaceMatches('/[^\pL\pN ._()-]/u', '_', basename(str_replace('\\', '/', $file->getClientOriginalName()))), 0, 180);
$document = $supplier->documents()->create([
'tenant_id' => $supplier->tenant_id, 'uploaded_by' => $request->user()->id,
'document_type' => $data['document_type'], 'original_name' => $name,
'path' => $path, 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(),
'sha256' => hash_file('sha256', $file->getPathname()),
]);
$supplier->update(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $request->user()->id, 'version' => $supplier->version + 1]);
$workflow->audit($request->user(), 'supplier.document_uploaded', null, $before, ['supplier' => $supplier->toArray(), 'document' => $document->toArray()]);
});
} catch (Throwable $exception) {
Storage::disk('supplier_documents')->delete($path);
throw $exception;
}
return back();
}
public function download(Request $request, Supplier $supplier, SupplierDocument $document, RequisitionWorkflow $workflow): StreamedResponse
{
Gate::authorize('viewDocuments', $supplier);
abort_unless($document->supplier_id === $supplier->id, 404);
$disk = Storage::disk('supplier_documents');
abort_unless($disk->exists($document->path), 404);
$workflow->audit($request->user(), 'supplier.document_downloaded', null, null, ['supplier_id' => $supplier->id, 'document_id' => $document->id, 'name' => $document->original_name]);
return $disk->download($document->path, $document->original_name, [
'Content-Type' => 'application/octet-stream',
'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'private, no-store',
'Content-Security-Policy' => "default-src 'none'; sandbox",
]);
}
}